Files
gplan-2/game/core/level_runner.gd
T

915 lines
45 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 线性第一关(战×2→道具→战×2→恢复→Boss)能否承载"数值提升→通关期望改善"的元进度验证
# Date: 2026-09-11(2026-09-17 分支节点图批:行进器升级图版推进——单链 = 出边恰一的退化形态)
# 关卡节点地图(v1.0 图版落地——节点+连线,连线只朝前进方向,唯一入口,Boss 恒唯一终点):
# 关卡域生命周期唯一编排者——HP 跨战斗携带、恢复点节点兑付、关卡末收束。
# 行进纪律(零自动推进):到达即兑付;兑付后确认才出现选路口——战斗胜利与奖励/恢复确认一律经
# choices_presented 进入选路态,玩家 pick_next_node 显式选下一跳(接口漂移清偿:战斗胜利不再自动推进、
# continue_after_node 泛化为兑付确认——选路确认归 pick_next_node)。
# 2026-09-15 格面资源批:可选类道具与库存整体删除——奖励点节点内容随功能道具渠道(P1,渠道未定)悬置为
# 直通节点(零兑付);战斗各自持有 地图战斗资源 容器(映射随战斗退役,无跨战斗状态可延续)。
# Core 层零 UI 依赖(headless 可跑完整关卡——数据配置 Rule 7)。
# 测试专用:hero_atk_pct/hero_hp_pct 数值覆盖(调整板模拟天赋树——正式侧归元进度,切片不落盘)。
# E4 收口(2026-09-13):覆盖值经 encounter 显式传入战斗(不再写 db.hero 表——表值运行期不可变,
# 双基线同步/双写叠加 6→18→54 整类隐患随表写一并移除)。
# 对应 GDD:关卡节点地图 v1.0(图版结构——本文件为 P1 批实现位;命名规范见 CLAUDE.md)
# GP+商店房实施批(2026-09-21):GP 冒险钱包(战斗完成入账 clamp ≥0 · 冒险收束清零)+ 统一冒险
# 加算区 bonus{atk,max_hp,armor_class}(商店购买与关卡大奖励共用账本——战斗开始参数 = 基础表值 +
# 加算区,调试浮窗百分比在加算之后乘)+ 商店房节点兑付(开覆盖层/购买出账/离开回选路确认——
# 复用 continue_after_node 兑付确认纪律,零自动推进)。
# 冒险结构实现批(2026-09-21)——接口漂移清偿登记(06 清单「切片行进代码与定稿的接口漂移」①②③⑥⑦):
# ① 种子域归冒险结构:start_level 旧路径(单关测试模式)保留自调 rng.new_adventure;新路径
# start_level_external(冒险模式)不重播种——主种子注入恰一次归 AdventureRunner.start_adventure。
# ② 生命账本归冒险结构:hero_hp/gp 改属性落 _ledger 字典——冒险模式经 start_level_external 注入
# AdventureRunner 共享账本(Dictionary 引用共享),单关模式 = 本实例私有账本;战斗结果上报语义 =
# runner 经共享账本属性直写(接口②——战后生命终值已在本类 _on_battle_finished 回写账本)。
# ③ 恢复数学抽成单一路径 rest_heal_amount——冒险模式经 _rest_handler 回调(AdventureRunner.apply_rest,
# 接口③治疗封顶回报执行体归冒险结构);单关模式默认内置。公式逐位一致,零行为变更。
# ⑥ 收束先清库后宣布——现行无关卡域库存(格面资源批 2026-09-15 可选类删除后为空集),战斗结束
# retire_mapping + despawn_all 先于 battle_finished 发出即事实清库,不另设空接口(假契约)。
# ⑦ 倒下的失败结算归冒险结构:本类只发 level_finished(每战结果上报语义),失败结算由
# AdventureRunner._on_level_finished 执行;GP 冒险收束清零同步归冒险结构(本类冒险模式不关钱包)。
class_name LevelRunner
extends RefCounted
signal node_entered(node: Dictionary, info: Dictionary) # info 按 type:battle/boss={} · reward={}(内容待拍板) · rest={healed,hp,max_hp} · shop={gp}
signal choices_presented(candidates: Array) # 选路口(兑付确认后——候选节点引用数组,零自动推进)
signal step_row(row: Dictionary) # 转发当前 战斗结算 结算行
signal round_started(round_index: int)
signal board_changed()
signal level_finished(payload: Dictionary) # {result, hp, node_index, node_id, node_type}
signal wallet_changed(event: String, gp: int, extra: Dictionary) # GP 钱包事件投影:gain{amount}/buy{product,price}/clear——试玩日志与商店覆盖层刷新源(GP+商店房实施批)
var db: ConfigDb
var rng: RngServiceCore
var level_row: Dictionary[String, Variant] = {}
var node_index := -1
# ---- 账本载体(冒险结构实现批——漂移②清偿)----
# 冒险模式 = AdventureRunner 共享字典(Dictionary 引用共享——hero_hp/gp 属性读写直接落冒险账本);
# 单关模式 = 本实例私有账本(start_level 重建,既有单关语义逐位保留)。
var _ledger: Dictionary = {"hero_hp": 0, "gp": 0}
var _rest_handler: Callable # 治疗封顶回报执行体(默认内置 rest_heal_amount;冒险模式 = AdventureRunner.apply_rest——接口③)
var _external := false # 冒险模式标记(钱包收束时机与播种语义分叉点)
# hero_hp / gp 属性化(既有读写点零改动——值类型经属性落 _ledger)
var hero_hp: int:
get: return int(_ledger["hero_hp"])
set(v): _ledger["hero_hp"] = v
var gp: int: # GP 冒险钱包(局内货币——冒险开始 = 0、收束清零;不入存档不入摘要)
get: return int(_ledger["gp"])
set(v): _ledger["gp"] = v
var bonus := {"atk": 0, "max_hp": 0, "armor_class": 0} # 统一冒险加算区(冒险开始全 0)——商店购买与关卡大奖励写同一本账(冒险模式 = AdventureRunner 共享字典;大奖励接线归 AdventureRunner._on_level_finished)
var base_override := {"atk": 0, "max_hp": 0, "armor_class": 0} # 永久强化 flat 注入(元进度实现批——冒险模式 = AdventureRunner 共享字典经 start_level_external 注入;单关测试模式 = 私有全零)
var _shop_purchased := {} # product_id → 已购档数(现价重算输入;冒险开始清零)
# 协作对象私有(E1/E3 封装收口 2026-09-13):settlement 读取经 current_battle() 成文出口
var _settlement: BattleOrchestrator = null # 当前战斗编排器(关卡结束置 null)
var treasure_inventory: TreasureItems = null # 秘宝 关卡域库存(迭代 7 T3——TreasureItems;跨战斗延续,
var _treasure_unlocked := {} # aux 解锁门控集(迭代 7 T5——拍板③甲;空集=零投放)
var _attunement: Array = [] # 同调装配集(迭代 11 第①批 T5——adventure params.attunement 下传,begin_battle enc 携带进战斗结算;空集=off 零扰动)
var _attune_charges: Dictionary = {} # 同调主动件充能账本(迭代 11 第①批 T6——adventure 共享 dict 引用;settlement use_treasure 扣减直改同引用)
# 关卡末清空+快照计数入 level_finished 载荷)
# ---- 商店共享池模式态(迭代 11 第②批 T3——缺省 legacy=固定三件加定价 off 对照承载,零 RNG 零池消费;TP0 令牌池换代)----
var _shop_pool_mode := false # pool 模式旗(set_shop_pool 注入时置位——AdventureRunner 按 params.shop_mode 下传)
var _pool_deck: Array = [] # 共享池令牌库共享引用(AdventureRunner._curio_deck——库存=剩余令牌数单点)
var _pool_consume := Callable() # 池消费 handler(AdventureRunner._consume_pool_purchase——_apply_curio_pick 同口写加算区+账本+摘牌)
var _pool_ticket := Callable() # 门票展示判定 handler(T5——AdventureRunner._feat_ticket_consider;节点与商店共享计数)
var _shop_shelf: Array = [] # 当前货架(进店抽 3/买后全重抽——令牌字典 {"kind","tier"} 序,同 kind 去重每 kind 至多一卡)
var _shop_visit_buys := 0 # 本次进店已购次数(A 链定价输入——离开清零)
var _shop_paid_refreshes := 0 # 本次进店已付费刷新次数(MC-1——GP 刷新价曲线输入,离开清零)
var hero_atk_pct := 0 # 测试面板覆盖值(切片专用)
var hero_hp_pct := 0
# ---- 关卡相位轴(2026-10-02 修复批——布尔旗收拢:_running/_awaiting_choice 两旗 → 显式 Phase enum;
# 对齐 行动经济 Phase 先例(action_economy.gd:内部 enum + 守卫换算,外部读面契约零变化)——
# level_result:String 与 awaiting_choice() 被 AdventureRunner/呈现层消费的语义逐字不变)----
enum Phase { IDLE, RUNNING, AWAITING_CHOICE, ENDED }
var _phase: int = Phase.IDLE
var level_result := "" # "" / "victory" / "defeat"(外部契约面——语义零变化)
var _candidates: Array[Dictionary] = [] # 当前选路候选(节点引用数组)
var visited: Array[Dictionary] = [] # 已兑付/到达节点序列(呈现层图条数据源;含当前节点)
var _graph_mode := false # 图形态缓存(行集任一节点带 next;单链 = false)
func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void:
db = db_ref
rng = rng_ref
# 进行中谓词(原 _running 语义——RUNNING 或 AWAITING_CHOICE 两相位合计;内部守卫专用)
func _is_running() -> bool:
return _phase == Phase.RUNNING or _phase == Phase.AWAITING_CHOICE
# ---- 测试面板覆盖(模拟天赋树属性提升——仅测试用,正式侧归 元进度;经 encounter 传入战斗)----
func set_hero_override(atk_pct: int, hp_pct: int) -> void:
hero_atk_pct = atk_pct
hero_hp_pct = hp_pct
# 叠加次序(元进度实现批钉死):表值 → 永久强化 flat(base_override)→ 加算区 flat(bonus)先加
# → 调试浮窗百分比后乘(调试数值工具恒乘最后——代拍板 6,与 GP 批既有次序兼容)
func effective_atk() -> int:
var base: int = int(db.hero.atk) + int(base_override.get("atk", 0)) + int(bonus.get("atk", 0))
return maxi(1, int(round(float(base) * (100.0 + float(hero_atk_pct)) / 100.0)))
func effective_max_hp() -> int:
var base: int = int(db.hero.max_hp) + int(base_override.get("max_hp", 0)) + int(bonus.get("max_hp", 0))
return maxi(1, int(round(float(base) * (100.0 + float(hero_hp_pct)) / 100.0)))
# 护甲等级生效值(永久强化 flat + 加算区 armor_class 轴——无调试浮窗百分比通道,纯 flat)
func effective_ac() -> int:
return maxi(0, int(db.hero.armor_class) + int(base_override.get("armor_class", 0)) + int(bonus.get("armor_class", 0)))
# 行动点加成生效值(迭代 10 奖励重构批——统一冒险加算区第四轴 ap;经战斗开始入参 hero_ap_bonus
# 传入战斗域,兑现 = 每场战斗开始首轮行动点基线 +加成值〔行动经济 GDD 跨冒险行动点加成轴节〕)
func effective_ap_bonus() -> int:
return maxi(0, int(bonus.get("ap", 0)))
# 覆盖复位(关卡结束归零——表值不可变,仅清百分比;调用方兼容保留)
func restore_hero_base() -> void:
hero_atk_pct = 0
hero_hp_pct = 0
# ---- 关卡生命周期 ----
func start_level(level_index: int, seed_val: int, base_override_in: Dictionary = {}, template_pick: int = -1) -> bool:
# 单关测试模式(旧路径——既有语义一字不改):私有账本、内置恢复数学、自调 new_adventure(漂移①
# 单关=冒险的历史简化在测试模式保留;正式种子域归 AdventureRunner.start_adventure)
# base_override_in = 永久强化 flat 注入尾参(换算率标定批 2026-09-23——缺省 {} 全零白板,
# 与既有调用方逐位一致零行为变更;契约同 AdventureRunner.start_adventure params.hero_override:
# 三键 {atk, max_hp, armor_class}、缺键/-1 = 0、负值 clamp 0。游戏直入口仍恒白板——
# 注入面只服务数值验证工具,非玩家可达路径)
_external = false
_rest_handler = Callable(self, "rest_heal_amount")
_ledger = {"hero_hp": 0, "gp": 0}
var row := _find_level_row(level_index)
if row.is_empty():
push_error("[关卡节点地图] level_index %d 在 LevelTable 无行" % level_index)
return false
node_index = -1
level_result = ""
_phase = Phase.IDLE
_candidates = []
visited = []
_graph_mode = false
gp = 0 # 冒险钱包清零开局(GP+商店房实施批——局内货币生命周期起点)
bonus = {"atk": 0, "max_hp": 0, "armor_class": 0} # 加算区冒险开始全 0
base_override = {"atk": 0, "max_hp": 0, "armor_class": 0} # 永久强化 flat:缺省全零白板(单关入口零元进度——注入面见上)
_shop_purchased = {} # 已购档数清零(跨冒险不延续——递增定价按冒险内累计)
for k in base_override.keys():
base_override[k] = maxi(0, int(base_override_in.get(k, 0))) # 注入规范化(契约同冒险开始参数面)
for node in nodes():
if node.has("next"):
_graph_mode = true
break
hero_hp = effective_max_hp()
rng.new_adventure(seed_val) # 关卡起点 = 冒险起点(切片口径;跨关接续归 冒险结构)
level_row.assign(_resolve_template_row(row, template_pick)) # 池解析在播种后(node_map 抽签须已播种——EC7)
_phase = Phase.RUNNING
var entry := _entry_node()
if entry.is_empty():
push_error("[关卡节点地图] 入口节点缺失(无入边唯一节点——图合法性校验应已拦截)")
_phase = Phase.ENDED
return false
return _enter_node(entry)
# 冒险结构接口①:开始关卡(载荷 = 关卡序号 + 当前生命 + 生效上限——生命经共享账本交接;
# 种子域归冒险结构:本路径不重播种;账本归冒险结构:本路径不重置 gp/bonus/_shop_purchased,
# 也不重置 hero_hp——账本当前值即开局生命,AdventureRunner 已在 ledger 写好)。
# base_override = 永久强化 flat 共享引用(元进度实现批——缺省 {} 全零白板,与 B1 空参数路径逐位一致)。
# 流程与 start_level 同款(_find_level_row → 状态初始化 → _graph_mode 探测 → _enter_node(entry)),
# 差异仅在账本/加算区/已购档/恢复执行体的注入与零重置。
# 停点前移批 2026-09-23:defer_entry 尾参(缺省 false = 既有调用方零变更)——true 时不自动
# _enter_node(entry),改置选路态(awaiting_choice + 单候选 = 入口节点),由 AdventureRunner 在
# level_started 之后调 present_entry_choice() 呈现、玩家 pick_next_node 显式进首战。拆两步的
# 信号顺序契约:呈现层须先经 level_started 重取 runner 实例,choices_presented 才不会被
# _on_choices_presented 的 runner 空防御静默丢弃。入口呈现零 RNG 消耗、pick 走同一个
# _enter_node(entry)——与旧自动进入逐位等价(测量面零漂移红线)。
func start_level_external(level_index: int, ledger: Dictionary, bonus_ref: Dictionary,
purchased_ref: Dictionary, rest_handler: Callable, base_override_ref: Dictionary = {},
defer_entry: bool = false, template_pick: int = -1, resume_state: Dictionary = {}) -> bool:
var row := _find_level_row(level_index)
if row.is_empty():
push_error("[关卡节点地图] level_index %d 在 LevelTable 无行" % level_index)
return false
_external = true
_ledger = ledger
bonus = bonus_ref
base_override = base_override_ref
_shop_purchased = purchased_ref
_rest_handler = rest_handler
level_row.assign(_resolve_template_row(row, template_pick))
node_index = -1
level_result = ""
_phase = Phase.IDLE
_candidates = []
visited = []
_graph_mode = false
for node in nodes():
if node.has("next"):
_graph_mode = true
break
_phase = Phase.RUNNING
# 断点续玩路径(迭代 8 卡①——defer_entry 之外的第三种停点入口):直接重建到停点所在节点,
# 零 RNG 消耗(模板已随 template_pick 强制、节点状态纯重建不 _enter_node——当场战斗从头打)。
# resume_state = {"node_id": int(-1 = 入口停点), "visited_ids": Array[int]};空字典 = 既有路径逐位不变。
if not resume_state.is_empty():
return _restore_to_stop(int(resume_state.get("node_id", -1)),
resume_state.get("visited_ids", []) as Array)
var entry := _entry_node()
if entry.is_empty():
push_error("[关卡节点地图] 入口节点缺失(无入边唯一节点——图合法性校验应已拦截)")
_phase = Phase.ENDED
return false
if defer_entry:
_phase = Phase.AWAITING_CHOICE # 入口停点(node_index 保持 -1——进节点前无 current_node)
_candidates = [entry]
return true
return _enter_node(entry)
# 入口停点呈现(停点前移批 2026-09-23——defer_entry=true 的第二步):发出入口候选供呈现层弹
# 选路层;非停点态(缺省自动进入路径)拒绝。零 RNG 消耗——呈现与行进取号解耦。
func present_entry_choice() -> bool:
if _phase != Phase.AWAITING_CHOICE or _candidates.is_empty() or node_index != -1:
push_error("[关卡节点地图] present_entry_choice 拒绝——非入口停点态(defer_entry 未开或已 pick)")
return false
choices_presented.emit(_candidates)
return true
# ---- 断点续玩(迭代 8 卡①——停点粒度重建)----
# 停点重建(start_level_external resume_state 路径的落点):node_id = 停点所在节点编号(-1 = 入口
# 停点),visited_ids = 已到节点编号序(呈现层图条数据源重建)。纯状态重建零 RNG 消耗;
# 候选 = 停点节点出边(与 choices_presented 停点语义逐位一致)。
func _restore_to_stop(node_id: int, visited_ids: Array) -> bool:
if node_id < 0:
# 入口停点:与 defer_entry 同款形态(node_index 保持 -1,单候选 = 入口节点)
var entry := _entry_node()
if entry.is_empty():
push_error("[关卡节点地图] 断点恢复失败——入口节点缺失")
_phase = Phase.ENDED
return false
_phase = Phase.AWAITING_CHOICE
_candidates = [entry]
return true
var stop: Dictionary = {}
for node in nodes():
if _node_id(node) == node_id:
stop = node
break
if stop.is_empty():
push_error("[关卡节点地图] 断点恢复失败——停点节点编号 %d 不在本关图" % node_id)
_phase = Phase.ENDED
return false
node_index = nodes().find(stop)
visited = []
for vid in visited_ids:
for node in nodes():
if _node_id(node) == int(vid):
visited.append(node)
break
_phase = Phase.AWAITING_CHOICE
_candidates = _out_nodes(stop)
if _candidates.is_empty():
push_error("[关卡节点地图] 断点恢复失败——停点节点 %d 无出边(非合法停点)" % node_id)
_phase = Phase.ENDED
return false
return true
# 停点呈现(断点续玩路径——present_entry_choice 的中途节点版):信号顺序契约同入口
# (呈现层须先经 level_started 重取 runner 实例)。零 RNG 消耗。
func present_resumed_choice() -> bool:
if _phase != Phase.AWAITING_CHOICE or _candidates.is_empty() or node_index < 0:
push_error("[关卡节点地图] present_resumed_choice 拒绝——非中途停点态")
return false
choices_presented.emit(_candidates)
return true
# 断点道具库存回填(迭代 8 卡①——奖励节点已选道具随停点快照回局;幂等,先清后入受理序重排)
func restore_treasure_items(items: Array) -> void:
if treasure_inventory == null:
treasure_inventory = TreasureItems.new(db)
treasure_inventory.reset()
treasure_inventory.restore_inventory(items)
func _find_level_row(level_index: int) -> Dictionary:
return db.level_row(level_index) # 表访问收口(2026-10-02——行集直扫归 LevelTable/ConfigDB 访问器)
# ---- 模板池解析(关卡节点图生成 v0.4——M-3 迭代 5 T5)----
# row.templates 非空 = 池形态:template_pick >= 0 = 强制取该模板(首次脚本模式/冒烟固定图,零 RNG 消费);
# -1 = 抽签(node_map 用途标签恰一 scope,均匀取模)。空池 = 旧单图形态原样返回(向后兼容零迁移)。
# 解析产物 = row 副本换 nodes(下游 nodes()/校验/呈现全透明);active_template_name 供 MC 分层出数;
# active_template_index 供断点快照备案(迭代 8 卡①——已抽模板续玩零重抽零 RNG 消耗)。
var active_template_name := ""
var active_template_index := -1 # -1 = 非池形态(旧单图)/未解析
func _resolve_template_row(row: Dictionary, template_pick: int) -> Dictionary:
var templates: Array = row.get("templates", [])
active_template_name = ""
active_template_index = -1
if templates.is_empty():
return row
var idx := 0
if template_pick >= 0:
idx = clampi(template_pick, 0, templates.size() - 1)
else:
var stream: RandomNumberGenerator = rng.open_stream("node_map")
idx = stream.randi_range(0, templates.size() - 1)
var tpl: Dictionary = templates[idx]
active_template_name = String(tpl.get("display_name", ""))
active_template_index = idx
var out := row.duplicate(true)
out["nodes"] = tpl.get("nodes", [])
return out
func nodes() -> Array:
return level_row.get("nodes", [])
func current_node() -> Dictionary:
if node_index < 0 or node_index >= nodes().size():
return {}
return nodes()[node_index]
# ---- 图版行进面(v1.0:出边表选路 · 零自动推进 · 单链 = 隐式出边退化形态)----
# 编号空间(与 LevelTable 校验器同一解析):图形态 = node_depth(全节点编号化);单链 = 数组下标。
# 混用两空间会在单链上碰撞(如 reward 下标 2 vs 战斗 node_depth 2)——节点寻址一律经 _node_id。
func _node_id(node: Dictionary) -> int:
if _graph_mode:
return int(node.get("node_depth", -1))
return nodes().find(node)
# 入口节点 = 无入边唯一节点(图合法性校验保证恰一)
func _entry_node() -> Dictionary:
var inbound := {}
for node in nodes():
for tid in _out_ids(node):
inbound[tid] = true
for node in nodes():
if not inbound.has(_node_id(node)):
return node
return {}
# 出边编号表:显式 next 优先;单链(无 next 键)= 数组顺序下一项(退化形态)
func _out_ids(node: Dictionary) -> Array:
if node.has("next"):
return node.get("next", [])
var nodes: Array = nodes()
var i := nodes.find(node)
if i >= 0 and i < nodes.size() - 1:
return [i + 1]
return []
# 出边候选(节点引用数组——选路口/呈现层共用读面)
func _out_nodes(node: Dictionary) -> Array[Dictionary]:
var ids := {}
for n in nodes():
ids[_node_id(n)] = n
var out: Array[Dictionary] = []
for tid in _out_ids(node):
if ids.has(tid):
out.append(ids[tid])
return out
# 当前选路候选(选路态读面——呈现层渲染下一跳;非选路态 = 空)
func next_candidates() -> Array:
return _candidates.duplicate()
# 当前选路候选编号表(pick_next_node 的合法入参域——编号空间解析单一出口)
func next_candidate_ids() -> Array:
var out: Array[int] = []
for c in _candidates:
out.append(_node_id(c))
return out
# 当前节点编号(呈现层/日志读面——编号空间解析单一出口)
func current_node_id() -> int:
return _node_id(current_node())
# 任意节点编号 / 出边编号表(呈现层整图渲染读面——与行进同源,禁呈现层复算编号空间)
func node_id_of(node: Dictionary) -> int:
return _node_id(node)
func node_out_ids(node: Dictionary) -> Array:
return _out_ids(node)
# 已到节点编号序列(只读投影——编号空间解析单一出口)
func visited_ids() -> Array:
var out: Array[int] = []
for v in visited:
out.append(_node_id(v))
return out
func awaiting_choice() -> bool:
return _phase == Phase.AWAITING_CHOICE
func _enter_node(node: Dictionary) -> bool:
if not _is_running():
return false
node_index = nodes().find(node)
visited.append(node)
match String(node.get("type", "")):
"battle", "boss":
return _start_battle(node)
"reward":
# 奖励点内容待拍板(格面资源批 2026-09-15:可选类道具删除,功能道具渠道 P1 未定——
# 节点保留占位、零兑付直通);推进 = 兑付确认后选路(零自动推进)
node_entered.emit(node, {})
return true
"rest":
var healed := _apply_rest()
node_entered.emit(node, {"healed": healed, "hp": hero_hp, "max_hp": effective_max_hp()})
return true
"shop":
# 商店房兑付(GP+商店房实施批 2026-09-21):到达即开店(覆盖层归呈现层),购买出账/
# 离开归玩家意图——零自动推进;离开确认经 continue_after_node 回选路口
if _shop_pool_mode:
_shop_open_pool() # 迭代 11 第②批 T3——进店抽 3(恰一 shop_draw scope;买后全重抽同签)
node_entered.emit(node, {"gp": gp, "shelf": true})
return true
node_entered.emit(node, {"gp": gp}) # legacy 固定三件(off 对照承载——零 RNG 零池消费)
return true
push_error("[关卡节点地图] 未知节点类型: %s" % str(node))
return false
func _start_battle(node: Dictionary) -> bool:
_settlement = BattleOrchestrator.new(db, rng)
if treasure_inventory == null:
treasure_inventory = TreasureItems.new(db) # 惰性建——关卡域单实例(跨战斗延续)
_settlement.treasure_inventory = treasure_inventory # 迭代 7 T3——库存注入(授予路由+use_treasure 编排消费面)
if _settlement != null:
_settlement.set_treasure_unlocked(_treasure_unlocked) # 迭代 7 T5——解锁门控下传(装板摇号前)
# core↔core 接线只许方法 Callable(ADR-0001 静态规则 S6——lambda 捕获 self 会延长
# RefCounted 生命周期乃至成环:settlement 的信号持捕获 runner 的 lambda,runner 又持 settlement)
_settlement.step_row.connect(_forward_step_row)
_settlement.round_started.connect(_forward_round_started)
_settlement.board_changed.connect(_forward_board_changed)
_settlement.battle_finished.connect(_on_battle_finished)
# node_entered 先于 begin_battle:呈现层须先拿到 settlement 引用,才能响应 begin 期间发出的
# board_changed(首板安装)并重建棋盘——后发会导致首战棋盘不渲染(2026-09-11 playtest 缺陷①)
node_entered.emit(node, {})
if not _settlement.begin_battle({"level_index": int(level_row.level_index), "node_depth": int(node.get("node_depth", 0)),
"hero_hp": hero_hp, "hero_atk": effective_atk(), "hero_max_hp": effective_max_hp(),
"hero_armor_class": effective_ac(), "hero_ap_bonus": effective_ap_bonus(),
"attunement": _attunement,
"attune_charges": _attune_charges,
"feats": _feats,
"tree_rules": _tree_rules,
"node_type": String(node.get("type", ""))}):
push_error("[关卡节点地图] 战斗启动失败(depth %s)" % str(node.get("node_depth", -1)))
return false
return true
func _forward_step_row(row: Dictionary) -> void:
step_row.emit(row)
func _forward_round_started(ri: int) -> void:
round_started.emit(ri)
func _forward_board_changed() -> void:
board_changed.emit()
func _on_battle_finished(payload: Dictionary) -> void:
var node := current_node()
var result: String = String(payload.get("result", ""))
if result == "victory":
hero_hp = maxi(1, int(payload.get("hero_hp", 0))) # despawn 前捕获值(载荷自包含)
_gain_wallet(int(payload.get("gp_dropped", 0))) # 战斗完成入账(clamp ≥0——破财可把 escrow 打负,钱包入账时钳制)
if String(node.get("type", "")) == "boss" or result == "defeat":
# Boss 胜利 = 关卡通关;任意战斗败北 = 关卡失败(漂移⑦清偿——每战结果上报冒险结构,
# 失败结算归 AdventureRunner;本类只发 level_finished)
level_result = "victory" if result == "victory" else "defeat"
var hp_left := hero_hp if result == "victory" else 0
if not _external:
close_wallet() # 单关模式:关卡收束即冒险收束(GP 清零);冒险模式归 AdventureRunner._settle
_phase = Phase.ENDED
_settlement = null
var treasure_cleared := 0
if treasure_inventory != null:
treasure_cleared = treasure_inventory.inventory_count() # 快照计数(清空前——「剩余道具随关卡结束清空」可见反馈取数)
treasure_inventory.reset() # 迭代 7 T3——关卡末库存清空(无论输赢,幂等)
level_finished.emit({"result": level_result, "hp": hp_left, "node_index": node_index,
"node_id": _node_id(node), "node_type": String(node.get("type", "")),
"aux_cleared": treasure_cleared}) # additive 扩签(不参与派生——node_map 扩签先例)
return
# 接口漂移清偿(关卡节点地图 v1.0 ⑦):战斗胜利不再自动推进——确认后进选路口
_settlement = null
_present_choices()
# 恢复数学单一路径(漂移③清偿;2026-10-02 修复批——同式双落点收口:算术宿主 = 本静态方法,
# AdventureRunner.apply_rest 同式委托,字面量双写退役)。floor(上限 × 比例 ÷ 100) 后封顶缺口;
# 冒险结构 GDD 公式逐位一致
static func rest_heal(pct: int, hp: int, max_hp: int) -> int:
return mini(int(floor(float(max_hp) * float(pct) / 100.0)), max_hp - hp)
func rest_heal_amount(pct: int, hp: int, max_hp: int) -> int:
return rest_heal(pct, hp, max_hp) # 公有成文出口保持(start_level 内置恢复执行体经 Callable 引用)
func _apply_rest() -> int:
var pct: int = int(level_row.get("rest_heal_pct", 0))
# 接口③治疗封顶回报:单关模式 = 内置 rest_heal_amount;冒险模式 = AdventureRunner.apply_rest
#(执行归冒险结构——回报比例、由其按封顶公式回写共享账本后再由本类叠加)
var actual: int = int(_rest_handler.call(pct, hero_hp, effective_max_hp()))
hero_hp += actual
return actual
# ---- 选路(零自动推进的停点语义:兑付确认 → 候选呈现 → 显式 pick)----
# 兑付确认后呈现选路口(战斗胜利 / 奖励·恢复确认统一入口——单候选同样确认,Boss 外无免确认通道)
func _present_choices() -> bool:
var cands := _out_nodes(current_node())
if cands.is_empty():
push_error("[关卡节点地图] 当前节点无出边且非 Boss(图合法性校验应已拦截)")
return false
_phase = Phase.AWAITING_CHOICE
_candidates = cands
choices_presented.emit(cands)
return true
# 玩家选下一跳(候选外编号拒绝;Boss 恒无出边——选路只在中间节点发生)
func pick_next_node(node_id: int) -> bool:
if _phase != Phase.AWAITING_CHOICE:
return false
for cand in _candidates:
if _node_id(cand) == node_id:
_phase = Phase.RUNNING
_candidates = []
return _enter_node(cand)
return false
# ---- 只读出口(E1/E3 封装收口:呈现层/工具经此读当前战斗,禁直读协作对象)----
func current_battle():
return _settlement
# aux 解锁门控集注入(迭代 7 T5——AdventureRunner 自 params 传递)
func set_treasure_unlocked(ids: Dictionary) -> void:
_treasure_unlocked = ids
if _settlement != null:
_settlement.set_treasure_unlocked(ids)
# 同调装配集注入(迭代 11 第①批 T5——冒险开始参数面;AdventureRunner 唯一调用方)
func set_attunement(ids: Array) -> void:
_attunement = ids.duplicate()
# 特性持有集注入(迭代 12 T1——AdventureRunner._start_current_level 唯一调用方;begin_battle
# enc.feats 携带进战斗求值;**共享引用不复制**——关内奖励节点兑付的 relic 须当关后续战斗生效
# (attune_charges 共享账本同款);空集=零扰动)
var _feats: Array = []
func set_feats(ids: Array) -> void:
_feats = ids
# 特性持有集读面(迭代 12 呈现补齐——HUD C 区特性段数据源)
func feats_held() -> Array:
return _feats.duplicate()
# 树 rule 键集注入(迭代 12 T10——begin_battle enc.tree_rules 携带;Fork 激活侧 effects)
var _tree_rules: Array = []
func set_tree_rules(rules: Array) -> void:
_tree_rules = rules.duplicate()
# 同调主动件充能账本注入(迭代 11 第①批 T6——共享 dict 引用直改;能力件骑库存面注入:
# 差额注入防续玩双份——resume 路径 restore_inventory 已含断点内 attune 记录)
func set_attune_charges(charges: Dictionary) -> void:
_attune_charges = charges
if treasure_inventory == null:
treasure_inventory = TreasureItems.new(db)
for iid in charges:
var existing := 0
for r in treasure_inventory.snapshot_inventory():
if String(r.item_id) == String(iid) and String(r.source_class) == "attunement" and not bool(r.used):
existing += 1
for i in range(maxi(0, int(charges[iid]) - existing)):
treasure_inventory.add_item(String(iid), "attunement", Vector2i(-1, -1), 0)
# 奖励节点兑付透传(迭代 7 T5——呈现层三选一 → 关卡域库存;来源归因 reward_node)。
# 迭代 11 第①批 T7——同调位持有闸随过渡键退役拆除(aux 卡出池后仅剩防御面;能力件容量闸
# 正主 = 营地装配面 Attunement.slots 满位禁选)。
func grant_treasure_item(item_id: String, source: String) -> Dictionary:
if treasure_inventory == null:
treasure_inventory = TreasureItems.new(db)
return treasure_inventory.add_item(item_id, source, Vector2i(-1, -1), 0)
# 三选一 GP 袋件入账(迭代 10——钱包收口扩「奖励入账」入口;复用战斗完成同一入账原语,
# wallet_changed 载荷带 source 供呈现归因)
func grant_curio_gp(amount: int) -> void:
gp = maxi(0, gp + amount)
wallet_changed.emit("gain", gp, {"amount": amount, "source": "curio_pick"})
# ---- 玩家输入转发(呈现层只与 runner 对话)----
# 奖励/恢复/商店房兑付确认(呈现层在覆盖层上点击"继续/离开"后调用;自动模拟在 node_entered 回调内立即调用)
# 泛化语义(接口漂移清偿):确认 ≠ 推进——确认后进选路口,下一跳归 pick_next_node
func continue_after_node() -> bool:
var t := String(current_node().get("type", ""))
if t != "reward" and t != "rest" and t != "shop":
return false
if t == "shop":
_shop_shelf = [] # 离开店面货架态清零(断点快照 shop_shelf 键随离开落空架合法态——迭代 11 第②批 T2)
_shop_visit_buys = 0
_shop_paid_refreshes = 0 # MC-1
return _present_choices()
# ---- GP 冒险钱包(GP+商店房实施批 2026-09-21:入账/出账/收束清零三收口)----
# 战斗完成入账(gp_dropped 载荷——clamp ≥0;破财可把战斗域 escrow 打负,钱包不透支)
func _gain_wallet(amount: int) -> void:
gp = maxi(0, gp + amount)
wallet_changed.emit("gain", gp, {"amount": amount})
# 冒险收束清零(通关/倒下/放弃——局内货币生命周期终点,不入存档不入摘要;public——冒险模式由
# AdventureRunner._settle 在冒险收束时调用,单关模式 = 关卡收束即冒险收束)
func close_wallet() -> void:
gp = 0
wallet_changed.emit("clear", 0, {})
# ---- 商店房兑付(GP+商店房实施批:呈现层只读投影 + 购买出账)----
# 商店覆盖层数据源(只读投影——现价/已购档数在此现算,呈现层禁复算定价公式)
func shop_products() -> Array:
var out: Array[Dictionary] = []
for prow in db.shop_products():
out.append({
"product_id": String(prow.get("product_id", "")),
"display_name": String(prow.get("display_name", "")),
"attr": String(prow.get("attr", "")),
"flat_value": int(prow.get("flat_value", 0)),
"price": current_price(prow),
"purchased": purchased_tier(String(prow.get("product_id", ""))),
})
return out
# 现价 = base_price + price_level_bonus(level_index) + price_increment × 该商品已购档数
func current_price(prow: Dictionary) -> int:
return int(prow.get("base_price", 0)) + db.shop_price_level_bonus(int(level_row.level_index)) \
+ int(prow.get("price_increment", 0)) * purchased_tier(String(prow.get("product_id", "")))
func purchased_tier(product_id: String) -> int:
return int(_shop_purchased.get(product_id, 0))
# 购买(未知商品/属性键出域/余额不足拒绝——返回 false 零副作用):
# 出账 → 加算区 += flat_value → 购血同步当前生命(封顶新上限)→ 已购档数 +1
func buy_product(product_id: String) -> bool:
var prow: Dictionary = db.shop_product_row(product_id)
if prow.is_empty():
push_error("[关卡节点地图] 商店房未知商品: %s" % product_id)
return false
var attr := String(prow.get("attr", ""))
if not bonus.has(attr):
push_error("[关卡节点地图] 商品属性键出域: %s(attr=%s)" % [product_id, attr])
return false
var price := current_price(prow)
if gp < price:
push_warning("[关卡节点地图] 购买拒绝——余额不足: %s(需 %d,钱包 %d)" % [product_id, price, gp])
return false # 余额不足(覆盖层按钮禁用为常态防线——此处兜底拒绝;2026-10-02 补静默留痕)
gp -= price
var flat := int(prow.get("flat_value", 0))
bonus[attr] = int(bonus[attr]) + flat
if attr == "max_hp":
hero_hp = mini(hero_hp + flat, effective_max_hp()) # 购血:上限与当前生命同步 +Δ(封顶新上限)
_shop_purchased[product_id] = purchased_tier(product_id) + 1
wallet_changed.emit("buy", gp, {"product": product_id, "price": price})
return true
func player_request_refresh() -> bool:
if _settlement == null:
return false
return _settlement.player_request_refresh()
# ---- 商店共享池模式(迭代 11 第②批 T3——P1 共享池/P4 ceil;legacy 固定三件路径上方原样保留为 off 对照承载)----
# 注入(AdventureRunner._start_current_level 唯一调用方——pool 模式专用):令牌库共享引用 +
# 池消费 handler。legacy 模式不注入(_shop_pool_mode 恒 false——off 态零 RNG 零池消费零扰动)。
func set_shop_pool(deck_ref: Array, consume_handler: Callable, ticket_consider: Callable = Callable()) -> void:
_pool_deck = deck_ref
_pool_consume = consume_handler
_pool_ticket = ticket_consider # T5——门票展示判定(AdventureRunner._feat_ticket_consider;同流取数复用 shop_draw scope)
_shop_pool_mode = true
# 进店开店(_enter_node shop 分支 pool 模式):进店购买次计数清零 + 抽 3 落架(恰一 shop_draw scope)
func _shop_open_pool() -> void:
_shop_visit_buys = 0
_shop_paid_refreshes = 0 # MC-1——进店付费刷新计数清零
_shop_draw_shelf()
# 货架抽签(进店 + 每次买后全重抽共用;TP0 令牌级货架):牌库 = 令牌库中货架词表内 kind 的剩余
# 令牌(库存=剩余令牌数——池消耗即上限,与奖励三选一兑付摘牌同库承载);部分 Fisher-Yates 等概率
# 洗牌不放回(逐枚等概率——kind 出现率∝剩余令牌数,无独立权重 knob;同 kind 去重每 kind 至多一卡,
# 购买身份 kind 单键无歧义);消费 shop_draw 封闭流恰一 scope(每次抽 3 恰一取号)。牌库空 = 空货架
# 合法态(池空硬闸——买光即枯竭既定后果)。
func _shop_draw_shelf() -> void:
_shop_shelf = []
var sellable: Array = []
for row in db.shop_pool_rows():
var shelf_kind := String(row.get("kind_id", ""))
for t in _pool_deck:
var tok: Dictionary = t as Dictionary
if String(tok.get("kind", "")) == shelf_kind:
sellable.append({"kind": shelf_kind, "tier": String(tok.get("tier", ""))})
if sellable.is_empty():
return
var stream: RandomNumberGenerator = rng.open_stream("shop_draw")
for i in sellable.size():
var j := stream.randi_range(i, sellable.size() - 1)
var tmp = sellable[i]
sellable[i] = sellable[j]
sellable[j] = tmp
var seen: Array = []
for tok in sellable:
var k := String((tok as Dictionary).get("kind", ""))
if seen.has(k):
continue
seen.append(k)
_shop_shelf.append(tok)
if _shop_shelf.size() >= 3:
break
# T5 门票注入(非 Boss 渠道唯一专长入口——货架与节点共享保底计数;判定含 pity 记账,替换末位占一席。
# 取数复用本函数 shop_draw scope 的 stream——零新 scope,确定性序列不漂移)
if _pool_ticket.is_valid() and not _shop_shelf.is_empty() and bool(_pool_ticket.call(stream)):
_shop_shelf[_shop_shelf.size() - 1] = {"kind": "feat_ticket", "tier": "rare"}
# 商店保底退役(迭代 15 卡② T4——渠道轴定位裁决:商店渠道只出属性值提升+资源,专长在非 Boss
# 渠道只以门票形态出现(奇物.md v2.7/A4 断言)。迭代 12 T6「货架零特性挤卡换吞噬流件」简化口径
# 与渠道白名单直接冲突(devour=规则改写型=专长系),随渠道分离退役;专长商店入口=T5 门票。
# 库存余量(TP0——令牌库内该 kind 剩余枚数单点读;未注入/防御读 = 0)
func pool_stock(kind: String) -> int:
var n := 0
for t in _pool_deck:
if String((t as Dictionary).get("kind", "")) == kind:
n += 1
return n
# 现价 = ceil(基价 × b^(关序−1) × A^(本次进店已购次数))(P4 甲 ceil 向上单一取整——玩家不利向;
# A/b 表 knob provisional;核心现算,呈现层禁复算——契约 5)。relic 卡(保底占位——shop 词表外)
# 基价 provisional 15 固定锚(台账披露待追认——shop.tres 词表扩列归数值批)。
func pool_price(kind: String) -> int:
var row: Dictionary = db.shop_pool_row(kind)
if row.is_empty():
if kind == "relic":
return int(ceil(15.0 * pow(db.shop_price_b(), float(maxi(0, int(level_row.level_index) - 1))) * pow(db.shop_price_a(), float(_shop_visit_buys))))
return 999999
var base := float(maxi(1, int(row.get("base_price", 1))))
var price_f: float = base * pow(db.shop_price_b(), float(maxi(0, int(level_row.level_index) - 1))) \
* pow(db.shop_price_a(), float(_shop_visit_buys))
return int(ceil(price_f))
# 货架只读投影(呈现层数据源——kind/显示名/件值/现价/库存余量齐备,UI 零复算)。
# 件值=在架令牌本体值(TP0——令牌级货架,kind+tier 定值;价格公式维持批②裁定 tier-blind)。
func shop_shelf() -> Array:
var out: Array[Dictionary] = []
for tok in _shop_shelf:
var kind := String((tok as Dictionary).get("kind", ""))
var tier := String((tok as Dictionary).get("tier", ""))
var row: Dictionary = db.shop_pool_row(kind)
var display := String(row.get("display_name", kind))
var value := int(db.adventure.curio_value(kind, tier))
if row.is_empty() and kind == "relic":
var rrow: Dictionary = db.feat_row(String(tok.get("feat_id", "")))
display = String(rrow.get("display_name", "特性"))
value = 0
out.append({
"kind_id": kind,
"tier": tier,
"feat_id": String(tok.get("feat_id", "")),
"display_name": display,
"value": value,
"price": pool_price(kind),
"stock_left": pool_stock(kind),
})
return out
# 商店独立刷新(MC-1——P-C2 裁 2026-10-04 推翻 v1.5「无独立刷新」):免费次数(天赋增益位,基础
# knob=0——Q2 裁「基础免费 0 次」)用尽后 GP 付费刷新;刷新=货架整架重抽(shop_draw 恰一新 scope,
# 与进店/买后重抽同签——重抽同源共享池);池空/非池模式=不可刷新合法态。
func shop_refresh_free_left() -> int:
return maxi(0, int(db.adventure.shop_refresh_free))
func shop_refresh_cost() -> int:
return int(ceil(float(db.adventure.shop_refresh_cost_base)
* pow(db.adventure.shop_refresh_cost_curve, float(_shop_paid_refreshes))))
func shop_refresh() -> bool:
if not _shop_pool_mode or _shop_shelf.is_empty():
return false
if shop_refresh_free_left() <= 0:
var cost := shop_refresh_cost()
if gp < cost:
return false
gp -= cost
_shop_paid_refreshes += 1
_shop_draw_shelf()
return true
# GP 冒险钱包单口支出(2026-10-06——三选一 GP 付费刷新与商店刷新共用同一钱包;余额不足 false 零副作用)
func spend_gp(amount: int) -> bool:
if amount <= 0 or gp < amount:
return false
gp -= amount
return true
# 购买(pool 模式唯一购买口):在架(kind→在架令牌)/余额前置校验零副作用 → handler 消费在架
# 令牌本体(kind+tier——_apply_curio_pick 同口写加算区+账本+摘牌,跨渠道共库)→ 出账 → 进店购买
# 次计数 → wallet 事件 → 三件全重抽(未买不保留——重抽绑购买,无独立刷新动作)
func buy_pool_item(kind: String) -> bool:
var shelf_tok := {}
for tok in _shop_shelf:
if String((tok as Dictionary).get("kind", "")) == kind:
shelf_tok = tok
break
if not _shop_pool_mode or shelf_tok.is_empty():
push_error("[关卡节点地图] 商店购买拒绝——非池模式或件不在架: %s" % kind)
return false
var price := pool_price(kind)
if gp < price:
push_warning("[关卡节点地图] 购买拒绝——余额不足: %s(需 %d,钱包 %d)" % [kind, price, gp])
return false
var tier := String(shelf_tok.get("tier", ""))
if _pool_consume.is_null() or not bool(_pool_consume.call(kind, tier)):
push_error("[关卡节点地图] 购买拒绝——池库存消费失败: %s|%s" % [kind, tier])
return false
gp -= price
_shop_visit_buys += 1
wallet_changed.emit("buy", gp, {"product": kind, "price": price})
_shop_draw_shelf() # 买 1 → 三件全重抽(恰一 shop_draw scope——同进店取号签)
return true
# 池模式谓词(呈现层组合根分发用——货架面 vs legacy 商品面)
func is_shop_pool() -> bool:
return _shop_pool_mode
# 断点快照读面(P2 甲货架+账本入断点——AdventureRunner._snapshot_breakpoint 消费;离开店即空架)
func shop_bp_shelf() -> Array:
return _shop_shelf.duplicate()
func shop_bp_visit_buys() -> int:
return _shop_visit_buys