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

858 lines
37 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
## 关卡域生命周期编排者:节点图行进、HP 跨战斗携带、恢复点兑付、关卡末收束(垂直切片验证:一关内数值提升可感知);商店族归 ShopSession 件。
## Core 层零 UI 依赖(headless 可跑完整关卡);零自动推进——兑付确认后经 choices_presented,推进只走 pick_next_node。
## 种子域:单关模式 start_level 自播种;冒险模式 start_level_external 不重播种(主种子恰一次归 AdventureRunner)。
## hero_atk_pct/hero_hp_pct 为测试专用覆盖——经 encounter 显式传入战斗,零表写,db.hero 运行期只读。
## 信号顺序契约:呈现层须在 level_started 回调重取 runner 实例,choices_presented 才不会被空防御丢弃。
class_name LevelRunner
extends RefCounted
signal node_entered(node: Dictionary, info: Dictionary) ## info 按 type:battle/boss={} · reward={}(三选一停点空载荷——内容经 present_node_curio 拉取) · 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[Variant, Variant] = {"hero_hp": 0, "gp": 0}
var level_counters: Dictionary[Variant, Variant] = {} # 关卡桶(lvl_ 前缀计数器;每关随 LevelRunner new 天然空=按关清零,替代旧硬编码清除名单)
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 base_override := {"atk": 0, "max_hp": 0, "armor_class": 0} # 永久强化 flat 注入(元进度实现批——冒险模式 = AdventureRunner 共享字典经 start_level_external 注入;单关测试模式 = 私有全零)
var _shop: ShopSession = null # 商店件(_init 建;双模式货架/定价/购买/刷新唯一宿主)
# 协作对象私有(E1/E3 封装收口):settlement 读取经 current_battle() 成文出口
var _settlement: BattleOrchestrator = null # 当前战斗编排器(关卡结束置 null)
var treasure_inventory: TreasureItems = null # 秘宝 关卡域库存(TreasureItems;跨战斗延续,
var _treasure_unlocked := {} # aux 解锁门控集(空集=零投放)
var _attunement: Array[Variant] = [] # 同调装配集(adventure params.attunement 下传,begin_battle enc 携带进战斗结算;空集=off 零扰动)
# 关卡末清空+快照计数入 level_finished 载荷)
# 两根类型轴(adventure 参数面快照经 set_hero_axes 下传;空串=未注入→锻造配置默认值回退。
# 消费面:effective_ac 甲型基础 AC 层 + begin_battle enc 携带进 unit_container.spawn 英雄快照)
var _hero_attack_type := ""
var _hero_armor_type := ""
# ---- 商店共享池模式态(缺省 legacy=固定三件加定价 off 对照承载,零 RNG 零池消费;TP0 令牌池)----
# 件内状态白盒读面(断点写侧与既有取数面沿用原字段名;状态本体单点在 ShopSession)
var _shop_pool_mode: bool:
get: return _shop.pool_mode
var _pool_deck: Array[Variant]:
get: return _shop.pool_deck
var _pool_consume: Callable:
get: return _shop.pool_consume
var _pool_ticket: Callable:
get: return _shop.pool_ticket
var _shop_shelf: Array[Variant]:
get: return _shop.shelf
var _shop_visit_buys: int:
get: return _shop.visit_buys
var _shop_paid_refreshes: int:
get: return _shop.paid_refreshes
var _shop_purchased: Dictionary[Variant, Variant]: # product_id → 已购档数(现价重算输入;冒险开始清零)
get: return _shop.purchased
set(v): _shop.purchased = v
var hero_atk_pct := 0 # 测试面板覆盖值(切片专用)
var hero_hp_pct := 0
# ---- 关卡相位轴(显式 Phase enum:内部 enum + 守卫换算,对齐 行动经济 Phase 先例;
# 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 _generator # 参数化长图生成器(惰性建——NodeMapGenerator 持注入的 RngService)
func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void:
db = db_ref
rng = rng_ref
_shop = ShopSession.new(db, rng)
_bind_shop()
# 进行中谓词(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)先加
# → 调试浮窗百分比后乘(调试数值工具恒乘最后)
func effective_atk() -> int:
var base: int = int(db.hero.atk) + int(base_override.get("atk", 0)) + int(run_ledger.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(run_ledger.bonus.get("max_hp", 0))
return maxi(1, int(round(float(base) * (100.0 + float(hero_hp_pct)) / 100.0)))
# 护甲等级生效值(入战 AC 三层可加:甲型基础 AC〔forge.tres 选中甲路线 base_ac〕+
# 甲路线等级加值〔base_override.armor_class——hero_override 锻造分量〕+ 加算区 armor_class 轴;
# 无调试浮窗百分比通道,纯 flat)
func effective_ac() -> int:
return maxi(0, _armor_base_ac() + int(base_override.get("armor_class", 0)) + int(run_ledger.bonus.get("armor_class", 0)))
# 甲型基础 AC(三层之第一层——选中甲路线行 base_ac;未注入/缺行=锻造配置默认值路线=轻甲 10,
# 与 hero.tres 两轴删除前 armor_class=10 行为连续)
func _armor_base_ac() -> int:
var at: String = _hero_armor_type if _hero_armor_type != "" else db.forge_default_armor_type()
var row: Dictionary[Variant, Variant] = db.forge_armor_route_by_type(at)
if row.is_empty():
return int(db.hero.armor_class) # 表缺失防御兜底(现役基准)
return int(row.get("base_ac", 0))
# 行动点加成生效值(统一冒险加算区第四轴 ap;经战斗开始入参 hero_ap_bonus
# 传入战斗域,兑现 = 每场战斗开始首轮行动点基线 +加成值〔行动经济 GDD 跨冒险行动点加成轴节〕)
func effective_ap_bonus() -> int:
return maxi(0, int(run_ledger.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 = {}) -> bool:
# 单关测试模式:私有账本、内置恢复数学、自调 new_adventure(漂移①
# 单关=冒险的历史简化在测试模式保留;正式种子域归 AdventureRunner.start_adventure)
# base_override_in = 永久强化 flat 注入尾参(缺省 {} 全零白板,
# 契约同 AdventureRunner.start_adventure params.hero_override:
# 三键 {atk, max_hp, armor_class}、缺键/-1 = 0、负值 clamp 0。游戏直入口仍恒白板——
# 注入面只服务数值验证工具,非玩家可达路径)
_external = false
_rest_handler = 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 = []
gp = 0 # 冒险钱包清零开局(局内货币生命周期起点)
run_ledger.bonus = {"atk": 0, "max_hp": 0, "armor_class": 0} # 加算区冒险开始全 0(单关入口零元进度——注入面见上)
base_override = {"atk": 0, "max_hp": 0, "armor_class": 0} # 永久强化 flat:缺省全零白板(单关入口零元进度——注入面见上)
_shop_purchased = {} # 已购档数清零(跨冒险不延续——递增定价按冒险内累计)
_bind_shop() # 钱包引用换代重绑(_ledger 已重建)
for k in base_override.keys():
base_override[k] = maxi(0, int(base_override_in.get(k, 0))) # 注入规范化(契约同冒险开始参数面)
hero_hp = effective_max_hp()
rng.new_adventure(seed_val) # 关卡起点 = 冒险起点(切片口径;跨关接续归 冒险结构)
if not _assign_generated_graph(row, seed_val):
_phase = Phase.ENDED
return false
_phase = Phase.RUNNING
var entries := _entry_nodes()
if entries.is_empty():
push_error("[关卡节点地图] 入口节点缺失(无入边节点集为空——validate_graph 应已拦)")
_phase = Phase.ENDED
return false
return _enter_node(entries[0])
# 冒险结构接口①:开始关卡(载荷 = 关卡序号 + 当前生命 + 生效上限——生命经共享账本交接;
# 种子域归冒险结构:本路径不重播种;账本归冒险结构:本路径不重置 gp/bonus/_shop_purchased,
# 也不重置 hero_hp——账本当前值即开局生命,AdventureRunner 已在 ledger 写好)。
# base_override = 永久强化 flat 共享引用(缺省 {} 全零白板,与 B1 空参数路径同值)。
# 流程与 start_level 同款(_find_level_row → 生成图装配 → 状态初始化 → _enter_node(入口)),
# 差异仅在账本/加算区/已购档/恢复执行体的注入与零重置。
# 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,
purchased_ref: Dictionary, rest_handler: Callable, base_override_ref: Dictionary = {},
defer_entry: bool = false, 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
base_override = base_override_ref
_shop_purchased = purchased_ref
_bind_shop() # 钱包共享引用换代重绑(_ledger = 冒险账本)
_rest_handler = rest_handler
if not _assign_generated_graph(row, rng.master_seed):
return false
node_index = -1
level_result = ""
_phase = Phase.IDLE
_candidates = []
visited = []
_phase = Phase.RUNNING
# 断点续玩路径(defer_entry 之外的第三种停点入口):直接重建到停点所在节点,
# 零 RNG 消耗(图由主种子+参数档重生成同图、节点状态纯重建不 _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 entries := _entry_nodes()
if entries.is_empty():
push_error("[关卡节点地图] 入口节点缺失(无入边节点集为空——validate_graph 应已拦)")
_phase = Phase.ENDED
return false
if defer_entry:
_phase = Phase.AWAITING_CHOICE # 入口停点(node_index 保持 -1)——候选=全部入口(多入口首跳即选路)
_candidates = entries
return true
return _enter_node(entries[0])
# 入口停点呈现(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
# ---- 断点续玩(停点粒度重建)----
# 停点重建(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 entries := _entry_nodes()
if entries.is_empty():
push_error("[关卡节点地图] 断点恢复失败——入口节点缺失")
_phase = Phase.ENDED
return false
_phase = Phase.AWAITING_CHOICE
_candidates = entries
return true
var stop: Dictionary[Variant, Variant] = {}
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
# 断点道具库存回填(奖励节点已选道具随停点快照回局;幂等,先清后入受理序重排)
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) # 表访问收口(行集直扫归 LevelTable/ConfigDB 访问器)
# ---- 图源装配(关卡节点图生成 v1.1——图 = f(参数档, 种子) 纯函数;无模板池、无兜底图)----
# 参数档按关卡序号解析(消费侧职责——生成器不认关卡号),种子取本局口径:单关模式用传入种子、
# 冒险模式用主种子(产图与全局计数器号位无关,断点在停点重生成同图)。产图先过消费侧独立校验
# LevelTable.validate_graph(与产出侧自证同源规格、分工不重叠);违规=起关拒绝,不降级不回退。
func _assign_generated_graph(row: Dictionary, seed_val: int) -> bool:
var level_index: int = int(row.get("level_index", 0))
var params: Dictionary[Variant, Variant] = db.level_gen_params_row(level_index)
if params.is_empty():
push_error("[关卡节点地图] level %d 无参数档行(level_gen_params 缺行——配置错误)" % level_index)
return false
if _generator == null:
_generator = NodeMapGenerator.new(rng)
var graph: Dictionary[Variant, Variant] = _generator.generate(params, seed_val)
if graph.is_empty() or _generator.is_fatal():
push_error("[关卡节点地图] level %d 生成图失败(参数无解或产出不合格——无兜底图)" % level_index)
return false
var bad := LevelTable.validate_graph(graph["nodes"] as Array)
if not bad.is_empty():
for e in bad:
push_error("[关卡节点地图] level %d 生成图违规: %s" % [level_index, String(e["message"])])
return false
var merged := row.duplicate(true)
merged["nodes"] = graph["nodes"]
level_row.assign(merged)
return true
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]
# ---- 图版行进面(生成图恒带显式出边;单链退化形态 = 出度 ≤1 的图,无数组序隐式边)----
# 节点身份=生成图句柄 id(图内唯一)。定位信息另算(enc 携带关卡序号 + 节点深度),
# 禁再以深度当身份(句柄/位置两轴语义单一)。
func _node_id(node: Dictionary) -> int:
return int(node.get("id", -1))
# 入口节点集 = 全部无入边节点(个数=参数档 entry_count;多入口时首跳即选路)
func _entry_nodes() -> Array[Dictionary]:
var inbound := {}
for node in nodes():
for tid in _out_ids(node):
inbound[int(tid)] = true
var out: Array[Dictionary] = []
for node in nodes():
if not inbound.has(_node_id(node)):
out.append(node)
return out
# 出边句柄表(生成图恒显式 next)
func _out_ids(node: Dictionary) -> Array:
return node.get("next", [])
# 出边候选(节点引用数组——选路口/呈现层共用读面)
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
# ---- 类型可见性读面(信息迷雾 v2.1:近处见类型,远处起雾)----
# 可见集 = Boss/终点(type=="boss")∪ 当前候选(下一跳恒可见——生成承诺)∪
# {id : |层(id) − 锚层| ≤ N};N = db.level.info_visible_layers(禁硬编码);
# 锚层 = 当前节点层,入口停点(无当前节点)= 0;层 = 入口 BFS 深度(与呈现层分层同源口径)。
# 纯函数零状态:已过节点不豁免(单圈语义,零累积记忆);断点快照零新增字段。
func type_visible_ids() -> Array[int]:
var out: Array[int] = []
var layers := _bfs_layers()
var anchor := 0
var cur := current_node()
if not cur.is_empty():
anchor = int(layers.get(_node_id(cur), 0))
var n: int = int(db.level.info_visible_layers)
for node in nodes():
var nid := _node_id(node)
if String(node.get("type", "")) == "boss" \
or (layers.has(nid) and absi(int(layers[nid]) - anchor) <= n):
out.append(nid)
for cid in next_candidate_ids():
if not out.has(int(cid)):
out.append(int(cid))
return out
# 入口 BFS 层表(id → 层深;入口层 0、出边逐层 +1,与 LevelMapScreen._assign_layers 同源)。
# 不可达节点不落表(加载期孤儿校验应已拦截)。
func _bfs_layers() -> Dictionary:
var layers := {}
var entries := _entry_nodes()
if entries.is_empty():
return layers
var queue: Array[Dictionary] = []
for entry in entries:
layers[_node_id(entry)] = 0
queue.append(entry)
var i := 0
while i < queue.size():
var cur: Dictionary[Variant, Variant] = queue[i]
i += 1
var d := int(layers[_node_id(cur)])
for nxt in _out_nodes(cur):
var nid := _node_id(nxt)
if not layers.has(nid):
layers[nid] = d + 1
queue.append(nxt)
return layers
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":
# 奖励节点 = 三选一停点(与通关三选一同池同权重同上限);呈现编排归
# curio_interact_controller.present_node_curio;推进 = 兑付确认后选路(零自动推进)
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":
# 商店房兑付:到达即开店(覆盖层归呈现层),购买出账/
# 离开归玩家意图——零自动推进;离开确认经 continue_after_node 回选路口
if _shop_pool_mode:
_shop.open_pool() # 进店抽 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 # 库存注入(授予路由+use_treasure 编排消费面)
if _settlement != null:
_settlement.set_treasure_unlocked(_treasure_unlocked) # 解锁门控下传(装板摇号前)
# core↔core 接线只许方法 Callable(静态规则 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(首板安装)并重建棋盘——后发会导致首战棋盘不渲染
node_entered.emit(node, {})
if not _settlement.begin_battle({"level_index": int(level_row.level_index), "node_depth": int(node.get("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(),
"attack_type": _hero_attack_type, "armor_type": _hero_armor_type, # 两根类型轴(spawn 英雄快照;空串=锻造配置默认值回退)
"attunement": _attunement,
"ledger": run_ledger, # 共享四账单类下传(单引用键)
"level_counters": level_counters, # 关卡桶下传(同关跨战共享引用、随本类 new 天然空)
"feats": _feats,
"tree_rules": _tree_rules,
"battle_index": _battle_index(),
"voucher_rap": _voucher_rap,
"voucher_time_sand": _voucher_time_sand,
"node_type": String(node.get("type", ""))}):
push_error("[关卡节点地图] 战斗启动失败(depth %s)" % str(node.get("depth", -1)))
return false
_voucher_rap = 0 # 节点级一次性:注入即消费(战斗未启动则保留至下次注入前不清)
_voucher_time_sand = 0
return true
func _forward_step_row(row: Dictionary) -> void:
step_row.emit(row)
# battle_index=第几场战斗(关卡域 1 基序——visited 已含当前节点〔_enter_node :437 先追加后启动〕;
# 按已到节点 type=battle 计数,与句柄 id / 节点深度两轴解耦〔身份与位置互不兼任〕;
# 开局姿态组「每关首轮窗」条件面)
func _battle_index() -> int:
var n := 0
for v in visited:
if String((v as Dictionary).get("type", "")) == "battle":
n += 1
return n
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() # 关卡末库存清空(无论输赢,幂等)
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()
# 恢复数学单一路径(漂移③清偿;算术宿主 = 本静态方法,
# 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() -> Variant:
return _settlement
# aux 解锁门控集注入(AdventureRunner 自 params 传递)
func set_treasure_unlocked(ids: Dictionary) -> void:
_treasure_unlocked = ids
if _settlement != null:
_settlement.set_treasure_unlocked(ids)
# 同调装配集注入(冒险开始参数面;AdventureRunner 唯一调用方)
func set_attunement(ids: Array) -> void:
_attunement = ids.duplicate()
_bind_shop() # 装配集共享引用换代重绑(刷新执照读数面)
# 特性持有集注入(AdventureRunner._start_current_level 唯一调用方;begin_battle
# enc.feats 携带进战斗求值;**共享引用不复制**——关内奖励节点兑付的 relic 须当关后续战斗生效
# (attune_charges 共享账本同款);空集=零扰动)
var _feats: Array[Variant] = []
func set_feats(ids: Array) -> void:
_feats = ids
# 特性持有集读面(HUD C 区特性段数据源)
func feats_held() -> Array:
return _feats.duplicate()
# 树 rule 键集注入(begin_battle enc.tree_rules 携带;Fork 激活侧 effects)
var _tree_rules: Array[Variant] = []
func set_tree_rules(rules: Array) -> void:
_tree_rules = rules.duplicate()
_bind_shop() # rule 键集共享引用换代重绑(散折价判定面)
# 两根类型轴注入(adventure 参数面快照;空串=未注入→锻造配置默认值回退。
# 消费面:effective_ac 甲型基础 AC 层 + begin_battle enc 携带进 unit_container.spawn 英雄快照)
func set_hero_axes(attack_type: String, armor_type: String) -> void:
_hero_attack_type = attack_type
_hero_armor_type = armor_type
# 共享四本账注入(RunLedger 单类下传,含 bonus/attune_charges/
# vision_counter/adventure_counters 四账;战斗侧只走账类原语,战末 export/开局 seed
# 时点语义不变)
var run_ledger: RunLedger = RunLedger.new()
func set_run_ledger(l: RunLedger) -> void:
run_ledger = l
_bind_shop() # 账类换代重绑(加算区 flat 落点面)
# 凭证件三账注入(储备金袋 RAP/时沙 首轮 AP/完美保险 标错豁免;节点级一次性,
# battle begin 消费后归零;缺省 0=零扰动)
var _voucher_rap := 0
var _voucher_time_sand := 0
func set_voucher_grants(rap: int, time_sand: int) -> void:
_voucher_rap = maxi(0, rap)
_voucher_time_sand = maxi(0, time_sand)
# 同调主动件充能账骑库存面注入(账本住
# RunLedger;差额注入防续玩双份——resume 路径 restore_inventory
# 已含断点内 attune 记录)
func sync_attune_inventory() -> void:
var charges: Dictionary[Variant, Variant] = run_ledger.attune_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)
# 三选一 GP 袋件入账(钱包收口的「奖励入账」入口;复用战斗完成同一入账原语,
# 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.leave() # 离开店面货架态清零(断点快照 shop_shelf 键随离开落空架合法态)
return _present_choices()
# ---- GP 冒险钱包(入账/出账/收束清零三收口)----
# 战斗完成入账(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+商店房实施批:呈现层只读投影 + 购买出账)----
# 实装归 ShopSession 件(双模式货架/定价/购买/刷新唯一宿主);本类保留同签名委托与件注入/
# 白盒入口——外部调用面与断点取数面零变化。
# 件注入(建件与宿主引用换代时调用——钱包/账类/图行/装配集为共享引用,出账直接落同一引用)
func _bind_shop() -> void:
_shop.bind({
"wallet": _ledger,
"run_ledger": run_ledger,
"level_row": level_row,
"tree_rules": _tree_rules,
"attunement": _attunement,
}, {
"wallet_emit": _emit_wallet_event,
"max_hp_gain": _gain_max_hp_flat,
"battle": current_battle,
})
func _emit_wallet_event(event: String, gp_now: int, extra: Dictionary) -> void:
wallet_changed.emit(event, gp_now, extra)
# 购血(商店 max_hp 商品——当前生命 +Δ 并封顶新上限,与账本置满/通关回血同式)
func _gain_max_hp_flat(flat: int) -> void:
hero_hp = mini(hero_hp + flat, effective_max_hp())
func shop_products() -> Array:
return _shop.shop_products()
func current_price(prow: Dictionary) -> int:
return _shop.current_price(prow)
func purchased_tier(product_id: String) -> int:
return _shop.purchased_tier(product_id)
func buy_product(product_id: String) -> bool:
return _shop.buy_product(product_id)
func player_request_refresh() -> bool:
return _shop.player_request_refresh()
# 散折价单点(测试取数面沿用本类调用名)
func _spend_discounted(price: int) -> int:
return _shop._spend_discounted(price)
# 注入(AdventureRunner._start_current_level 唯一调用方——pool 模式专用):令牌库共享引用 +
# 池消费 handler + 专长凭证判定 + cap 检查回调(cap 满 kind 剔出货架候选视图)。
func set_shop_pool(deck_ref: Array, consume_handler: Callable, ticket_consider: Callable = Callable(), cap_check: Callable = Callable()) -> void:
_shop.set_shop_pool(deck_ref, consume_handler, ticket_consider, cap_check)
# 货架抽签白盒入口(测试取数面沿用本类调用名)
func _shop_draw_shelf() -> void:
_shop._draw_shelf()
func pool_stock(kind: String) -> int:
return _shop.pool_stock(kind)
func pool_price(kind: String) -> int:
return _shop.pool_price(kind)
func shop_shelf() -> Array:
return _shop.shop_shelf()
func shop_refresh_free_left() -> int:
return _shop.shop_refresh_free_left()
func shop_refresh_cost() -> int:
return _shop.shop_refresh_cost()
func shop_refresh() -> bool:
return _shop.shop_refresh()
func spend_gp(amount: int) -> bool:
return _shop.spend_gp(amount)
func buy_pool_item(kind: String) -> bool:
return _shop.buy_pool_item(kind)
func is_shop_pool() -> bool:
return _shop.is_shop_pool()
func shop_bp_shelf() -> Array:
return _shop.shop_bp_shelf()
func shop_bp_visit_buys() -> int:
return _shop.shop_bp_visit_buys()