# 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}(商店购买与关卡大奖励共用账本——战斗开始参数 = 基础表值 + # 加算区,TunePanel 百分比在加算之后乘)+ 商店房节点兑付(开覆盖层/购买出账/离开回选路确认—— # 复用 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 = null # 当前战斗编排器(关卡结束置 null) var aux_inventory = null # 魔法道具 关卡域库存(迭代 7 T3——AuxItems;跨战斗延续, var _aux_unlocked := {} # aux 解锁门控集(迭代 7 T5——拍板③甲;空集=零投放) # 关卡末清空+快照计数入 level_finished 载荷) var hero_atk_pct := 0 # 测试面板覆盖值(切片专用) var hero_hp_pct := 0 var _running := false var level_result := "" # "" / "victory" / "defeat" var _awaiting_choice := false # 选路态(兑付确认后、pick 前为零自动推进的可见停点) 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 # ---- 测试面板覆盖(模拟天赋树属性提升——仅测试用,正式侧归 元进度;经 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)先加 # → TunePanel 百分比后乘(调整板 = 测试工具恒乘最后——代拍板 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 轴——无 TunePanel 百分比通道,纯 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))) # 覆盖复位(关卡结束归零——表值不可变,仅清百分比;调用方兼容保留) 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 = "" _awaiting_choice = false _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) _running = true var entry := _entry_node() if entry.is_empty(): push_error("[关卡节点地图] 入口节点缺失(无入边唯一节点——图合法性校验应已拦截)") _running = false 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 = "" _awaiting_choice = false _candidates = [] visited = [] _graph_mode = false for node in nodes(): if node.has("next"): _graph_mode = true break _running = true # 断点续玩路径(迭代 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("[关卡节点地图] 入口节点缺失(无入边唯一节点——图合法性校验应已拦截)") _running = false return false if defer_entry: _awaiting_choice = true # 入口停点(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 not _running or not _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("[关卡节点地图] 断点恢复失败——入口节点缺失") _running = false return false _awaiting_choice = true _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) _running = false 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 _awaiting_choice = true _candidates = _out_nodes(stop) if _candidates.is_empty(): push_error("[关卡节点地图] 断点恢复失败——停点节点 %d 无出边(非合法停点)" % node_id) _running = false return false return true # 停点呈现(断点续玩路径——present_entry_choice 的中途节点版):信号顺序契约同入口 # (呈现层须先经 level_started 重取 runner 实例)。零 RNG 消耗。 func present_resumed_choice() -> bool: if not _running or not _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_aux_items(items: Array) -> void: if aux_inventory == null: aux_inventory = AuxItems.new(db) aux_inventory.reset() aux_inventory.restore_inventory(items) func _find_level_row(level_index: int) -> Dictionary: for row in db.level.rows: if int(row.level_index) == level_index: return row return {} # ---- 模板池解析(关卡节点图生成 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 _awaiting_choice func _enter_node(node: Dictionary) -> bool: if not _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 回选路口 node_entered.emit(node, {"gp": gp}) return true push_error("[关卡节点地图] 未知节点类型: %s" % str(node)) return false func _start_battle(node: Dictionary) -> bool: _settlement = BattleOrchestrator.new(db, rng) if aux_inventory == null: aux_inventory = AuxItems.new(db) # 惰性建——关卡域单实例(跨战斗延续) _settlement.aux_inventory = aux_inventory # 迭代 7 T3——库存注入(授予路由+use_aux 编排消费面) if _settlement != null: _settlement.set_aux_unlocked(_aux_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(), "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 _running = false _settlement = null var aux_cleared := 0 if aux_inventory != null: aux_cleared = aux_inventory.inventory_count() # 快照计数(清空前——「剩余道具随关卡结束清空」可见反馈取数) aux_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": aux_cleared}) # additive 扩签(不参与派生——node_map 扩签先例) return # 接口漂移清偿(关卡节点地图 v1.0 ⑦):战斗胜利不再自动推进——确认后进选路口 _settlement = null _present_choices() # 恢复数学单一路径(漂移③清偿——同一公式两个落点:本体内置默认 + AdventureRunner.apply_rest, # 统一归并归后续批):floor(上限 × 比例 ÷ 100) 后封顶缺口;冒险结构 GDD 公式逐位一致 func rest_heal_amount(pct: int, hp: int, max_hp: int) -> int: return mini(int(floor(float(max_hp) * float(pct) / 100.0)), max_hp - hp) 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 _awaiting_choice = true _candidates = cands choices_presented.emit(cands) return true # 玩家选下一跳(候选外编号拒绝;Boss 恒无出边——选路只在中间节点发生) func pick_next_node(node_id: int) -> bool: if not _awaiting_choice: return false for cand in _candidates: if _node_id(cand) == node_id: _awaiting_choice = false _candidates = [] return _enter_node(cand) return false # ---- 只读出口(E1/E3 封装收口:呈现层/工具经此读当前战斗,禁直读协作对象)---- func current_battle(): return _settlement # aux 解锁门控集注入(迭代 7 T5——AdventureRunner 自 params 传递) func set_aux_unlocked(ids: Dictionary) -> void: _aux_unlocked = ids if _settlement != null: _settlement.set_aux_unlocked(ids) # 奖励节点兑付透传(迭代 7 T5——呈现层三选一 → 关卡域库存;来源归因 reward_node) func grant_aux_item(item_id: String, source: String) -> Dictionary: if aux_inventory == null: aux_inventory = AuxItems.new(db) return aux_inventory.add_item(item_id, source, Vector2i(-1, -1), 0) return _settlement # ---- 玩家输入转发(呈现层只与 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 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: return false # 余额不足(覆盖层按钮禁用为常态防线——此处兜底拒绝) 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()