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

749 lines
36 KiB
GDScript
Raw Permalink 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)。
## 调试浮窗百分比通道单点归主角账(set_hero_override 转调——公式消费面同源)。
## 信号顺序契约:呈现层须在 level_started 回调重取 runner 实例,choices_presented 才不会被空防御丢弃。
## 关卡开始信号前零发射:本件全部信号在 level_started 发射前保持静默(构造与 start 收参零信号——
## defer_entry 两步式以 level_started 为界,先广播后呈现入口候选;配断言面见冒烟源码断言)。
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}(pool 态加 shelf:true)
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 level_counters: Dictionary[Variant, Variant] = {} # 关卡桶(lvl_ 前缀计数器;每关随 LevelRunner new 天然空=按关清零,替代旧硬编码清除名单)
var _rest_handler: Callable # 治疗封顶回报执行体(默认内置 rest_heal_amount;冒险模式 = AdventureRunner.apply_rest——接口③)
var _external := false # 冒险模式标记(钱包收束时机与播种语义分叉点)
# 主角账三件引用(领域拆分批三件化:冒险模式经 ctx 注入共享三件、单关模式 _init 自建私有件;
# 数值账消费日记汇总——私有默认件同源成对建)
var _journal: GainJournal
var _wallet: GpWallet
var _stats: HeroStats
var loadout = null # 同调配装件(构造注入;直构=单关零件)
var _adv_bucket: Dictionary[Variant, Variant] = {} # 冒险桶(效果引擎 adv_ 计数器宿主字典——冒险模式随 ctx 注入;单关=本实例自有)
# hero_hp / gp 属性化(既有读写点零改动——值类型经属性落主角账三件)
var hero_hp: int:
get: return _stats.hero_hp
set(v): _stats.hero_hp = v
var gp: int: # GP 冒险钱包(局内货币——冒险开始 = 0、收束清零;不入存档不入摘要)
get: return _wallet.gp
set(v): _wallet.gp = v
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 := ""
# ---- 关卡相位轴(显式 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)
# 构造注入一次给齐(后置 setter 群与商店重绑机制退役;_bind_shop 全仓仅本处构造调用)。
# ctx 键(冒险模式由 AdventureRunner._start_current_level 组包;缺省空字典 = 单关测试模式:
# 私有主角账三件自建〔空 override、数值账同源消费私有日记〕、冒险桶本实例自有、pool 模式零注入):
# stats:HeroStats/wallet:GpWallet/journal:GainJournal(共享主角账三件——活引用)/loadout:AttuneLoadout(同调配装件)/adv_bucket:Dictionary(冒险桶宿主字典)
# attunement:Array(同调装配集——活引用)/tree_rules:Array(树 rule 键集——活引用)/shop_mode:String("pool" 才注入池面)
# treasure_unlocked:Dictionary/hero_attack_type:String/hero_armor_type:String
# voucher_rap:int/voucher_time_sand:int(凭证出箱注入面)
# pool_wiring:Dictionary(池模式装配 4 参:{deck:Array, consume:Callable, ticket:Callable, cap:Callable}——缺省空字典 = legacy 零注入)
func _init(db_ref: ConfigDb, rng_ref: RngServiceCore, ctx: Dictionary = {}) -> void:
db = db_ref
rng = rng_ref
if ctx.get("stats", null) is HeroStats:
_stats = ctx["stats"]
_wallet = ctx["wallet"] if ctx.get("wallet", null) is GpWallet else GpWallet.new()
_journal = ctx["journal"] if ctx.get("journal", null) is GainJournal else GainJournal.new()
_feats = _stats.feats # 特性集活引用注入(关内兑付 relic 当关生效——契约 6)
else:
_journal = GainJournal.new() # 直构私有三件(单关模式——实例不重建,值重置走 start_level)
_wallet = GpWallet.new()
_stats = HeroStats.new(null, {}, _journal)
_stats.db = db # 直构私有账补表引用(单关模式——实例不重建,值重置走 start_level)
var loadout_ref: Variant = ctx.get("loadout", null)
loadout = loadout_ref if loadout_ref is AttuneLoadout else null # 直构=单关零 loadout(充能面跳过)
if ctx.get("adv_bucket", null) is Dictionary:
_adv_bucket = ctx["adv_bucket"]
if ctx.get("treasure_inventory", null) is TreasureItems:
treasure_inventory = ctx["treasure_inventory"] # 冒险级库存共享(同调未用行跨关存活——单源化承载面)
_treasure_unlocked = ctx.get("treasure_unlocked", {}) if ctx.get("treasure_unlocked", {}) is Dictionary else {}
_attunement = ctx.get("attunement", []) if ctx.get("attunement", []) is Array else [] # 活引用注入(契约 6——禁快照)
_tree_rules = ctx.get("tree_rules", []) if ctx.get("tree_rules", []) is Array else [] # 活引用注入
_hero_attack_type = String(ctx.get("hero_attack_type", ""))
_hero_armor_type = String(ctx.get("hero_armor_type", ""))
_voucher_rap = maxi(0, int(ctx.get("voucher_rap", 0)))
_voucher_time_sand = maxi(0, int(ctx.get("voucher_time_sand", 0)))
shop = ShopSession.new(db, rng)
var pool: Dictionary[Variant, Variant] = ctx.get("pool_wiring", {}) as Dictionary
if String(ctx.get("shop_mode", "")) == "pool" and not pool.is_empty():
shop.set_shop_pool(pool.get("deck", []) as Array, pool.get("consume", Callable()) as Callable,
pool.get("ticket", Callable()) as Callable, pool.get("cap", Callable()) as Callable)
_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:
_stats.set_debug_override(atk_pct, hp_pct)
# 生效公式单点归数值账(本类删公式本体,读面转调;叠加次序注释随公式迁数值账)
func effective_atk() -> int:
return _stats.effective_atk()
func effective_max_hp() -> int:
return _stats.effective_max_hp()
func effective_ac() -> int:
return _stats.effective_ac()
# 行动点加成生效值(统一冒险加算区第四轴;经战斗开始入参 hero_ap_bonus
# 传入战斗域,兑现 = 每场战斗开始首轮行动点基线 +加成值〔行动经济 GDD 跨冒险行动点加成轴节〕)
func effective_ap_bonus() -> int:
return maxi(0, _journal.bonus_total("ap"))
# 覆盖复位(关卡结束归零——表值不可变,仅清百分比;调用方兼容保留)
func restore_hero_base() -> void:
_stats.set_debug_override(0, 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
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 = []
_stats.rebase_override(base_override_in) # 永久强化 flat 重注(规范化归数值账——契约同冒险开始参数面)
_journal.reset_adventure_accounts() # 日记域账面清零(先于置满——生命口径不含加算区)
_wallet.clear_wallet() # 钱包清零
_stats.reset_for_single_level() # 数值账面重置(override 清零+生命置满+特性清零——实例不重建,shop 绑定不换代)
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/加算区/已购档,
# 也不重置 hero_hp——账本当前值即开局生命,AdventureRunner 已在主角账写好)。
# 流程与 start_level 同款(_find_level_row → 生成图装配 → 状态初始化 → _enter_node(入口)),
# 差异仅在恢复执行体的注入与零重置(主角账三件/冒险桶经 ctx 构造注入)。
# 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, rest_handler: Callable,
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
_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)
# ---- 断点自交片段(关卡域——拆件批 T4:应用层唯一拼盘落盘口的收件半边)----
# 关卡域片段(node_id/visited_ids/aux_items/shop_shelf/shop_visit_buys——五键自交;
# 停点续玩分支 _runner 为 null 时节点域按缺省落档〔与真机同构〕)
func breakpoint_fragment() -> Dictionary[Variant, Variant]:
var treasure_snapshot: Array[Variant] = []
if treasure_inventory != null:
treasure_snapshot = treasure_inventory.snapshot_inventory()
return {
"node_id": current_node_id(),
"visited_ids": visited_ids(),
"aux_items": treasure_snapshot,
"shop_shelf": shop.shop_bp_shelf(),
"shop_visit_buys": shop.shop_bp_visit_buys(),
}
# 断点重建参数面收口(原 _resume_bp 直读面收编——resume_state = {"node_id": int(-1 = 入口停点),
# "visited_ids": Array[int], "aux_items": Array};空字典 = 既有路径逐位不变)。
# 返回 resume_state 形态供 start_level_external 分派(_restore_to_stop / restore_treasure_items)。
func restore_fragment(resume_state: Dictionary) -> Dictionary:
var aux: Array[Variant] = resume_state.get("aux_items", []) as Array
if not aux.is_empty():
restore_treasure_items(aux)
return resume_state
func _find_level_row(level_index: int) -> Dictionary:
return db.run.level.row_by_level_index(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.run.level_gen_params.row_by_level_index(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.run.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.run.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 回选路口
var payload := {"gp": gp} # legacy 固定三件(off 对照承载——零 RNG 零池消费)
if shop.pool_mode:
shop.open_pool() # 进店抽 3(恰一 shop_draw scope;买后全重抽同签)
payload = {"gp": gp, "shelf": true}
node_entered.emit(node, payload)
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.reward_grant().treasure_inventory = self.treasure_inventory # 库存注入(经授予件出口直注——注入时序在宿主构造之后不变)
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,
"loadout": loadout, # 同调配装件下传(充能单源化——reset_cycle/consume_charge 消费面;直构 null=零装配)
"journal": _journal, # 加算日记下传(战末 bonus 汇入/真视计数单写原语消费面)
"adv_bucket": _adv_bucket, # 冒险桶下传(效果引擎 adv_ 计数器宿主字典——跨战持久同一引用)
"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() # 快照计数(清空前——「剩余道具随关卡结束清空」可见反馈取数)
# 关卡末库存清空(无论输赢,幂等)——单源化承载面:同调未用行跨关存活(充能唯一事实源,
# 原字典轨跨关记忆的行化等价——已耗行作废不保留=C 区投影与剩余读数同构)
var keep: Array[Variant] = []
for r in treasure_inventory.snapshot_inventory():
if String(r.get("source_class", "")) == "attunement" and not bool(r.get("used", false)):
keep.append(r)
treasure_inventory.restore_inventory(keep)
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
# 特性持有集(begin_battle enc.feats 求值面;**共享引用不复制**——关内奖励节点兑付的 relic 须当关生效)
var _feats: Array[Variant] = []
# 树 rule 键集(begin_battle enc.tree_rules 携带;Fork 激活侧 effects——构造注入活引用)
var _tree_rules: Array[Variant] = []
# 特性持有集读面(HUD C 区特性段数据源)
func feats_held() -> Array:
return _feats.duplicate()
# 主角数值账只读出口(组件交出—— AdventureRunner.stats() 同款;v1.3 hero() 换代)
func stats() -> HeroStats:
return _stats
# 加算日记只读出口(bonus 生产读面承接——呈现层选路屏/结算行经此读加算区)
func journal() -> GainJournal:
return _journal
# 凭证出箱注入面(储备金袋 RAP/时沙 首轮 AP;节点级一次性,battle begin 消费后归零;缺省 0=零扰动)
var _voucher_rap := 0
var _voucher_time_sand := 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 件(双模式货架/定价/购买/刷新唯一宿主);外部调用面=调用方经宿主公开
# 成员 shop 直连(调用直连不留委托壳——2026-10-26 Middle Man 裁定)。
# 件注入(建件与宿主引用换代时调用——钱包/主角账三件/图行/装配集为共享引用,出账直接落同一引用)
func _bind_shop() -> void:
shop.bind({
"wallet": _wallet,
"journal": _journal,
"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:
_stats.hero_hp = mini(_stats.hero_hp + flat, _stats.effective_max_hp())