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

488 lines
30 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
## 冒险结构编排器:三关序列、生命/GP 账本、三选一停点与三结局结算唯一所有者,headless 可跑完整冒险(垂直切片验证:一条命走完三关);令牌池与凭证判定归 CurioDeck 件。
## Core 层禁 UI 引用;数值零字面量只读 db;core↔core 连信号一律方法 Callable,禁 lambda 捕获 self(RefCounted 成环)。
## 主种子恰一次注入归本类(LevelRunner 不重播种);set_save_manager 缺省 null = 断点面整体静默(模拟/冒烟零扰动)。
## 呈现层须在 level_started 后重取 runner()(每关新建实例)——先重取再开选路口,否则被空防御丢弃。
class_name AdventureRunner
extends RefCounted
signal level_started(level_index: int, display_name: String) ## 每关开始(含冒险首关)——呈现层重取 runner()、刷新图条与层名行
signal curio_choice_presented(payload: Dictionary) ## 专长三选一停点(非末关的关卡完成恰一次;通道白名单后候选恒专长+回血在载荷)——{level_index, candidates, heal_gained, hp_before, hp_after, keystone_gained, keystone_total, gp, aux_cleared}
signal curio_resolved(result: Dictionary) ## 三选一兑付完成(pick_curio/替换受理后恰一次)——{picked, value, hp_before, hp_after, level_index}
signal adventure_finished(summary: Dictionary) ## 冒险结算(completed/failed/abandoned)——summary 五件事:打穿没有/完成几关/止步第几关/钥石合计/主种子(+additive keystone_drops)
var db: ConfigDb
var rng: RngServiceCore
enum State { IDLE, RUNNING, SETTLING } # 会话状态机(内部 enum,外部 String 契约经 getter 词表)
var _state: State = State.IDLE
var state: String: # 外部契约面(只读——外部零写点实证;词表 "idle"/"running"/"settling")
get:
return State.keys()[_state].to_lower()
var _stats: HeroStats # 主角数值账(生命/类型轴/特性集——公式单点,拆件批 T1 建件、领域拆分批三件化)
var _wallet: GpWallet # GP 钱包件(单写原语——LevelRunner 与 ShopSession 读写单点)
var _journal: GainJournal # 加算日记件(获得记录三层/钥石/真视/已购档单写原语)
var adventure_counters: Dictionary[Variant, Variant] = {} # 冒险桶(效果引擎 adv_ 计数器宿主字典——跨战持久)
var level_index := 0 # 当前关卡序号(1 起)
var levels_total := 0 # 序列总关数(读 level 表行数)
var master_seed := 0 # 本局主种子(摘要第五件事)
var summary := {} # 最近一次结算的冒险摘要(idle 后保留——重开才新生成)
var _runner: LevelRunner # 当前关卡行进器(每关新建——关卡域状态天然隔离)
var _treasure_unlocked := {} # aux 解锁门控集(params.treasure_unlocked 接收面)
var _attunement: Array[Variant] = [] # 同调装配集(params.attunement 接收面,start_level 外下传 LevelRunner)
# ---- 三选一停点(编排迁 CurioStop 件——停点三态/刷新三源/凭证出箱/信号面)----
var _curio: CurioDeck = null # 令牌池件(_init 建;纯池——多重集/抽签/cap/凭证判定唯一宿主)
var _stop: CurioStop = null # 停点件(_init 建;每冒险一次——关卡换实例不换件)
var _refresh_bank: RefreshBank = null # 刷新账件(_init 建——三源账与付费报价;宿主预算注入与断点片段直收)
var _loadout: AttuneLoadout = null # 同调配装件(_init 建;充能单源化——物化行唯一事实源)
var _bp_store: BreakpointStore # 断点组装与回放件(_init 建;六成员+27 键登记单源+读写侧机组随件——宿主读写经件)
var adventure_inventory: TreasureItems # 冒险级秘宝库存(单源化——同调未用行跨关存活的承载面;关卡域语义归 level 使用面)
var _tree_rules: Dictionary[Variant, Variant] = {} # 树规则参数字典(Fork 激活侧 effects_* 透传;下传 enc.tree_rules 消费面)
var _shop_mode := "legacy" # 商店模式("legacy"=固定三件加定价〔off 对照承载,缺省零扰动〕/"pool"=共享池抽 3 买 1 重抽;真实入口 main.gd 显式投 pool)
func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void:
db = db_ref
rng = rng_ref
_journal = GainJournal.new()
_wallet = GpWallet.new()
_stats = HeroStats.new(db, {}, _journal) # 数值账消费日记汇总(同源单件注入)
_curio = CurioDeck.new(db, rng)
_refresh_bank = RefreshBank.new(db)
_stop = CurioStop.new(db, {"deck": _curio, "stats": _stats, "journal": _journal, "bank": _refresh_bank},
_snapshot_breakpoint)
_loadout = AttuneLoadout.new(db, _attunement)
adventure_inventory = TreasureItems.new(db)
_bp_store = BreakpointStore.new({"db": db, "rng": rng, "stats": _stats, "wallet": _wallet,
"journal": _journal, "curio": _curio, "stop": _stop, "bank": _refresh_bank, "loadout": _loadout})
# 件出口(组件交出非委托壳——呈现/测量面取主角账三件与令牌池单点;领域拆分批接口块)
func stats() -> HeroStats:
return _stats
func wallet() -> GpWallet:
return _wallet
func journal() -> GainJournal:
return _journal
func deck() -> CurioDeck:
return _curio
func refresh_bank() -> RefreshBank:
return _refresh_bank
# 件出口(停点件——呈现/测量面取停点编排单点;拆件批接口块)
func stop() -> CurioStop:
return _stop
# ---- 冒险生命周期 ----
# 冒险开始(主种子注入恰一次——全冒险唯一 rng.new_adventure 调用点;重开 = 全新一轮:
# 账本/加算区/已购档/钥石/摘要全重置,生命置满基础上限,从第 1 关起步)。
# params.hero_override = 永久强化 flat 注入(元进度实现批——缺省 {} = 全零白板,与 B1 空参数路径逐位一致;
# -1/缺键 = 表值兜底口径,规范化为 0 flat)
func start_adventure(seed_val: int, params: Dictionary = {}) -> bool:
if state != "idle":
push_error("[冒险结构] start_adventure 拒绝——state=%s(重开须待上一局结算完毕)" % state)
return false
master_seed = seed_val
rng.new_adventure(seed_val)
_stats.rebase_override(params.get("hero_override", {})) # 永久强化 flat 注入(规范化归数值账)
_journal.reset_adventure_accounts() # 日记域账面清零(汇总/明细/已购档/钥石/真视——先于置满)
_wallet.clear_wallet() # 钱包清零
_stats.reset_adventure_accounts() # 生命置满基础上限+特性持有集清零(口径不含加算区——日记先清)
_stats.attack_type = String(params.get("attack_type", "")) # 两根类型轴快照(契约 8 锁死:开局后不随存档选中变化)
_stats.armor_type = String(params.get("armor_type", ""))
summary = {}
_reset_curio_state()
levels_total = db.run.level.level_count() # 表访问收口(关数读 level 表行数)
level_index = 1
_bp_store.set_pending_next_level(false)
_shop_mode = "pool" if String(params.get("shop_mode", "legacy")) == "pool" else "legacy" # 缺省 legacy(off 对照零扰动)
_treasure_unlocked = params.get("treasure_unlocked", {}) if params.get("treasure_unlocked", {}) is Dictionary else {}
_attunement = params.get("attunement", []) if params.get("attunement", []) is Array else [] # additive
_refresh_bank.set_refresh_x_cap(_attune_refresh_x_total()) # 刷新账 X 基数预算注入(装配集冒险期间锁死)
_refresh_bank.set_refresh_y(maxi(0, int(params.get("attune_refresh_y", 0)))) # additive(Y 冒险周期池)
_loadout.set_attunement(_attunement) # 装配集注入(活引用——种行面)
adventure_inventory.reset() # 冒险级库存重置(新局空库存)
_bp_store.set_loadout_seeded(false) # 同调行投行延至首关 bind 时点(库存就位后)
adventure_counters = {} # 冒险桶清零(效果引擎 adv_ 计数器宿主——冒险开始归零)
# 刷新练习开局注入(refresh_stock_grant 装配即得一次性加仓:
# 装配集 kind 分发扫描;只入存量账、临时账不动=V8 契约;resume 不重注入〔存量随断点已在〕)
for iid_v2 in _attunement:
var rp_row: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(iid_v2))
if (rp_row.get("effect_kinds", []) as Array).has("refresh_stock_grant"):
_refresh_bank.add_refresh_stock(int(rp_row.get("magnitude", 0)))
# 特性开局注入(MC 成型画像载体:params.relics_seed 非空=开局持有集〔七流派
# 各 2 件档一成型——测量面专用;真实局规则改写型特性只走局内随机渠道,红线②层级纪律不破〕)
var feat_seed: Array[Variant] = params.get("relics_seed", []) if params.get("relics_seed", []) is Array else []
_tree_rules = params.get("tree_rules", {}) if params.get("tree_rules", {}) is Dictionary else {} # A/B 面规则键参数字典
if not feat_seed.is_empty():
for r in feat_seed:
_stats.feats.append(String(r))
_state = State.RUNNING
return _start_current_level()
# 断点续玩(「继续上局」入口;bp = save.breakpoint_snapshot() 快照,params 同 start_adventure
# 的注入参数面):账面(生命/GP/加算区/已购档/钥石累计)逐字段回填 + rng.restore_adventure 回填
# 「主种子+计数器」→ _start_current_level 续玩路径(模板随快照强制、重建到停点节点,零重抽)。
# 快照缺字段按防御默认(空快照拒绝——调用方先 has_breakpoint)。
func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool:
if state != "idle":
push_error("[冒险结构] resume_adventure 拒绝——state=%s(非 idle 不可续玩)" % state)
return false
if bp.is_empty():
push_error("[冒险结构] resume_adventure 拒绝——空断点快照")
return false
if int(bp.get("graph_gen_v2", 0)) != 1:
# 旧代差断点(模板池时代的 node_id 与生成图句柄空间可碰撞错位)——显式拒绝,
# 禁按新语义静默回放;旧模板图已随模板池退役,无迁移动作可做(凭空捏图=兜底图红线)。
push_error("[冒险结构] resume_adventure 拒绝——断点缺生成图代差标记(graph_gen_v2),属换代前旧断点")
return false
master_seed = int(bp.get("master_seed", 0))
rng.restore_adventure(master_seed, int(bp.get("rng_counter", 0)))
# ---- 断点回放(R2 整族归件——件侧语句入 _bp_store、宿主侧语句按原语句序留本壳,序逐步保形)----
_bp_store.restore_ledger(bp, params) # 续玩账面回填——主角账三件片段各还原各键(件侧;clamp 类夹 ≥0;日记在档=推导比对载入、缺键=旧档汇总直载)
summary = {}
levels_total = db.run.level.level_count() # 表访问收口(关数读 level 表行数)
level_index = int(bp.get("level_index", 1))
_bp_store.set_pending_next_level(false)
_reset_curio_state() # 续玩三选一态回填前置清理(池与件计数归件、停点态归本类;桶归宿主字典)
_bp_store.restore_curio_state(bp) # 续玩三选一态回填(池余库/获取计数/特性持有集——件侧;dual_merge/keep_on_null 策略见 BREAKPOINT_FIELDS)
_bp_store.apply_resume_params(params) # 续玩参数面——数值账规范化与越界钳制(件侧)
_shop_mode = "pool" if String(params.get("shop_mode", "legacy")) == "pool" else "legacy" # 续玩参数面同 start_adventure(缺省 legacy)
_treasure_unlocked = params.get("treasure_unlocked", {}) if params.get("treasure_unlocked", {}) is Dictionary else {}
_attunement = params.get("attunement", []) if params.get("attunement", []) is Array else [] # additive
_refresh_bank.set_refresh_x_cap(_attune_refresh_x_total()) # 刷新账 X 基数预算注入(续玩参数面同 start)
_bp_store.restore_stop_bank_fragments(bp, params) # 停点件/刷新账片段回填(件侧——凭证计数;刷新存量/Y 池缺键回退参数面)
_tree_rules = params.get("tree_rules", {}) if params.get("tree_rules", {}) is Dictionary else {} # A/B 面规则键参数字典(续玩参数面同 start_adventure 同式,消费面 set_tree_rules 下传)
# 续玩计数器回填(adventure 域计数——缺键 = 旧版断点防御空集;_reset_curio_state 已清零在先——桶归宿主字典)
var bp_counters = _bp_store.bp_read("adventure_counters", bp)
if bp_counters is Dictionary:
adventure_counters = (bp_counters as Dictionary).duplicate(true)
return _resume_pending_or_start(bp)
# 续玩收束段(宿主侧——停点直回/已兑付重发/无停点续玩起步;R2 回放段的尾半,语句序与
# resume_adventure 内联时逐句相同):三选一停点态与待推进位经件读写,选路停点快照单跳传递。
func _resume_pending_or_start(bp: Dictionary) -> bool:
var bp_pending: Dictionary[Variant, Variant] = bp.get("bonus_pending", {}) as Dictionary
var bp_result: Dictionary[Variant, Variant] = bp.get("bonus_result", {}) as Dictionary
if not bp_pending.is_empty():
_bp_store.set_pending_next_level(true)
_state = State.RUNNING
_stop.restore_pending(bp_pending) # 选择停点直回(续玩不重跑关卡——信号经停点件中继)
return true
if not bp_result.is_empty():
_bp_store.set_pending_next_level(true)
_stop.restore_resolved(bp_result) # 已兑付不重兑付只重发(置态+重发收编一步——信号面归停点件)
_state = State.RUNNING
return true
_bp_store.set_resume_bp(bp.duplicate(true))
# 充能读侧以行集为源(键值仅校验读数——回填时点在首关 bind 行集就位后,经 _loadout_on_level_inventory)
_bp_store.set_loadout_seeded(false)
_loadout.set_attunement(_attunement)
adventure_inventory.reset()
_state = State.RUNNING
return _start_current_level()
func _reset_curio_state() -> void:
_curio.reset() # 池与件获取计数(池半归件;停点态归停点件下行清理)
_stop.reset_adventure_state()
_bp_store.set_loadout_seeded(false)
_stats.feats = []
adventure_counters = {}
# 调试浮窗百分比通道(测试工具覆盖——每关新 runner 都要应用;正式侧归元进度。单点归数值账)
func set_hero_override(atk_pct: int, hp_pct: int) -> void:
_stats.set_debug_override(atk_pct, hp_pct)
if _runner != null:
_runner.set_hero_override(atk_pct, hp_pct)
# 冒险账本侧生效上限(公式单点归数值账——置满/回血封顶与战斗内上限同式同源)
func effective_max_hp() -> int:
return _stats.effective_max_hp()
# 断点承载注入(组合根冒险开始时调用;模拟/冒烟不注入 = 断点面整体静默)
func set_save_manager(sm: SaveManagerCore) -> void:
_bp_store.bind_save_manager(sm)
# 当前关卡行进器读面(呈现层/冒烟用——每关新建实例,调用方须在 level_started 后重取)
func runner() -> LevelRunner:
return _runner
# 接口③治疗封顶回报执行体(恢复点节点兑付——按比例回血并封顶)。算术宿主 = HeroStats.rest_heal(公式单点)
func apply_rest(pct: int, hp: int, max_hp: int) -> int:
return HeroStats.rest_heal(pct, hp, max_hp)
# 大奖励确认 → 进下一关(仅兑付后受理——零自动推进的冒险级停点;三选一未兑付拒绝推进)
func continue_to_next_level() -> bool:
if not _bp_store.pending_next_level():
push_error("[冒险结构] continue_to_next_level 拒绝——无待确认的关卡推进")
return false
if _stop.curio_stop_pending():
push_error("[冒险结构] continue_to_next_level 拒绝——三选一停点未兑付(零自动推进契约)")
return false
_bp_store.set_pending_next_level(false)
_stop.clear_resolved()
level_index += 1
return _start_current_level()
# 停点空候选消化(池空=三选一环节整体跳过——呈现层跳开选择面;编排归停点件)
# ---- 提升三选一(选择停点零自动推进、重复兑付被拒;兑付恰一件、未选项零生效)----
# 玩家点选兑付(state 守卫留应用层——编排归停点件;呈现层直调面 T5 改 stop())
# 特性持有集读面(效果接线/求值/摘要唯一取数口——副本防外部改写)
func feats_held() -> Array:
return _stats.feats_held()
# ---- 商店共享池消费面(纯池——库存=牌库剩余令牌)----
# 库存余量(池消耗即上限;奖励摘牌与商店购买同库承载,全冒险一次性)
# 终局池快照(_settle 清账前调用——复位会重置计数,摘要 additive 键须在此前取数;读面直连令牌池件)
func _pool_stock_snapshot() -> Dictionary:
return _curio.stock_snapshot()
# ---- 奖励节点三选一(与关卡完成三选一共享同池同权重同次数上限——编排归停点件)----
# 候选呈现(节点到达时调用——恰一 reward_pick scope 洗牌 + 道具件掷定 scope;本停点断点不载,
# 续玩重建到选路停点后重走重抽,恢复的全局计数器保证同候选逐位复现)。
func node_curio_candidates() -> Dictionary:
return _stop.node_curio_candidates()
func present_node_curio(candidates: Array) -> void:
_stop.present_node_curio(candidates)
# 点选兑付(节点域——编排归停点件;呈现层直调面 T5 改 stop())
# 刷新件余量基数(kind 分发查询——装配集内 effect_kinds 含 bonus_refresh 的行 magnitude 合计;
# 刷新件类加件零 id 分支;停点件 X 重置基数经 start/resume 预算注入)
func _attune_refresh_x_total() -> int:
var total := 0
for iid_v in _attunement:
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(iid_v))
if (arow.get("effect_kinds", []) as Array).has("bonus_refresh"):
total += int(arow.get("magnitude", 0))
return total
# 同调行投行分派(关卡域库存就位时点——每关一次;首关=种行或旧档行集为源、后续关=快照补行)
func _loadout_on_level_inventory() -> void:
if _runner.treasure_inventory == null:
_runner.treasure_inventory = TreasureItems.new(db) # 惰性建(承原 sync_attune_inventory 同款——种行落点就位)
var inv = _runner.treasure_inventory
if inv == null:
return
if not _bp_store.loadout_seeded():
_bp_store.set_loadout_seeded(true)
var bp_charges: Variant = _bp_store.bp_read("attunement_charges", _bp_store.resume_bp(), null) if not _bp_store.resume_bp().is_empty() else null
if bp_charges is Dictionary:
_loadout.restore_fragment({"attunement_charges": bp_charges}, inv) # 旧档读侧:行集为源+键值校验读数
else:
_loadout.seed_for_adventure(inv) # 冒险开始全量种行(不筛周期——C 区投影行数不变)
# ---- 三选一刷新与凭证(编排归停点件——本类留组合根委托读面)----
# 当前可刷新次数(X 优先 + Y 池 + 刷新存量账——呈现层余量读数口)
# 刷新存量账读数(T4——刷新券加仓余量单独读面;呈现层与断点消费)
# 节点停点在途态读回(组合根重 present 用——candidates 值拷贝)
# 三选一刷新(免费位 X/Y——组合根唯一调用口;无停点/无余量 false 零副作用)
# 三选一 GP 付费刷新(对齐商店 MC-1——组合根先经 curio_stop_pending+钱包支出再调,本口只做计数与重抽)
# 本次 GP 付费刷新价(与商店货架刷新同源——单点调参)
# 三选一停点在途谓词(付费路径扣费前置校验——无停点禁扣钱,防空转烧钱包)
# —— 专长获取专长凭证四口径判定(实装归 CurioDeck 件:调用即一次展示判定与记账)——
# 已拿上限判定(身份维度;缺省放行=白名单外 kind 兜底)
# Boss 钥石逐杀掉落(掉落渠道 = Boss/精英击杀;首杀/复杀两档读 meta.tres boss_keystone;
# 首杀判据 = 存档 boss_first_kill 标记位〔断点未注入 = 恒首杀——模拟/冒烟零扰动;标记仍在收束
# apply_adventure_summary 逐关卡完成关次置位〕;末关 Boss 照常入账;明细随摘要 additive 键供结算面归因)
func _boss_keystone_drop(li: int) -> void:
var first := true
var sm_keystone = _bp_store.save_manager()
if sm_keystone != null:
first = not sm_keystone.meta_state.career_state().boss_first_kill.has(str(li))
var row: Dictionary[Variant, Variant] = db.crossrun.meta.boss_keystone_row(li)
var amount: int = int(row.get("first_kill", 0)) if first else int(row.get("repeat_kill", 0))
_journal.record_keystone_drop({"level": li, "first": first, "amount": amount}) # 逐杀明细+累计单写原语
# 主动放弃(仅非战斗期受理——战斗域单事务在飞;与失败同型结算,已获大奖励不追回。
# MVP 无界面入口,语义先行预留;结算受理只算一次——非 running 态拒绝)
func abandon() -> bool:
if state != "running":
push_error("[冒险结构] abandon 拒绝——state=%s(非进行中不可放弃)" % state)
return false
if _runner != null and _runner.current_battle() != null:
push_error("[冒险结构] abandon 拒绝——战斗进行中(战斗域单事务在飞)")
return false
_settle("abandoned")
return true
# ---- 关卡推进(私有——序列编排单一出口)----
func _start_current_level() -> bool:
# 关卡对象构造注入一次给齐(拆件批 T3——ctx 键集见 LevelRunner._init 头注;
# 凭证出箱在组包时点取走〔节点级一次性——未用即过期语义不变〕)
var outbox: Dictionary[Variant, Variant] = _stop.voucher_outbox()
var pool_wiring: Dictionary[Variant, Variant] = {"deck": _curio.deck, "consume": _stop.consume_pool_purchase,
"ticket": _curio.feat_ticket_consider, "cap": _curio.cap_ok} if _shop_mode == "pool" else {}
var ctx: Dictionary[Variant, Variant] = {
"stats": _stats,
"wallet": _wallet,
"journal": _journal,
"loadout": _loadout,
"adv_bucket": adventure_counters,
"treasure_inventory": adventure_inventory,
"attunement": _attunement,
"tree_rules": _tree_rules,
"shop_mode": _shop_mode,
"treasure_unlocked": _treasure_unlocked,
"hero_attack_type": _stats.attack_type,
"hero_armor_type": _stats.armor_type,
"voucher_rap": int(outbox.get("rap", 0)),
"voucher_time_sand": int(outbox.get("time_sand", 0)),
"pool_wiring": pool_wiring,
}
_runner = LevelRunner.new(db, rng, ctx)
_bind_level_runner()
_stop.rebind_level_runner(_runner, level_index) # 停点件每关注入时点(GP 件入账回调换代——防跨关调死对象)
# 断点续玩分支:图由主种子+参数档重生成同图(产图与计数器号位无关,停点零重抽语义随之消解),
# resume_state 带停点句柄与已到序——重建到停点后经 present_resumed_choice 呈现(信号顺序契约同入口)。
var resume_state: Dictionary[Variant, Variant] = {}
if not _bp_store.resume_bp().is_empty():
resume_state = {"node_id": int(_bp_store.bp_read("node_id", _bp_store.resume_bp(), -1)),
"visited_ids": _bp_store.bp_read("visited_ids", _bp_store.resume_bp(), []) as Array}
# 接口①开始关卡:生命经主角账共享交接(账面当前值即开局生命——冒险开始置满 / 大奖励回满已写好);
# 主角账三件与冒险桶注入(构造注入 ctx——拆件批 T3 收口形态)。
# defer_entry=true 起每关(含首关)——先 emit level_started(呈现层在
# 该回调重取 runner 实例),后入口候选呈现;顺序反了选路口会被呈现层的 runner 空防御静默丢弃。
# 呈现零 RNG 消耗、pick 走同一 _enter_node——测量面逐位零漂移。
var ok: bool = _runner.start_level_external(level_index, apply_rest, true, resume_state)
if ok:
_emit_level_started()
_present_level_entry(resume_state)
_bp_store.clear_resume_bp()
return ok
# 关卡行进器接线(每关新实例:结算受理连接 + 能力件骑库存注入——信号转发族已退役,
# 呈现/模拟器在 level_started 重取点直连 runner 信号〔is_connected 守卫恰好一次〕)
func _bind_level_runner() -> void:
# core↔core 接线只许方法 Callable(S6——lambda 捕获 self 会延长 RefCounted 生命周期乃至成环)
_runner.level_finished.connect(_on_level_finished)
_runner.choices_presented.connect(_on_level_choices_presented) # 选路停点快照触发(原转发兼快照双职的核心侧受理)
_loadout_on_level_inventory() # 同调行投行分派(种行/旧档行集为源/每关快照补行)
# 关卡开始广播(装配已随构造注入进 ctx——本函数只剩信号广播;信号顺序契约:
# 关卡开始信号先于入口候选呈现〔defer_entry 两步式以本发射为界〕)
func _emit_level_started() -> void:
level_started.emit(level_index, String(_runner.level_row.get("display_name", "")))
# 入口呈现(入口停点与中途停点两态;restore_treasure_items 先于呈现=C 区库存就位)
func _present_level_entry(resume_state: Dictionary) -> void:
if resume_state.is_empty() or int(resume_state.get("node_id", -1)) < 0:
_runner.present_entry_choice()
else:
_runner.restore_treasure_items(_bp_store.bp_read("aux_items", _bp_store.resume_bp(), []) as Array)
_runner.present_resumed_choice()
# 接口④关卡结束(结果 + 生命终值 + 节点位置——胜负皆报;结算受理只算一次)
func _on_level_finished(payload: Dictionary) -> void:
if state != "running":
push_error("[冒险结构] 重复关卡结束宣布拒绝——state=%s(账本与摘要零变化)" % state)
return
_bp_store.set_last_level_payload(payload.duplicate(true))
var result := String(payload.get("result", ""))
if result == "defeat":
_stats.hero_hp = 0 # 倒下记 0(生命账本战后回写口径)
_settle("failed")
return
if result != "victory":
push_error("[冒险结构] 关卡结束结果出域: %s" % result)
return
_bp_store.set_last_treasure_cleared(int(payload.get("aux_cleared", 0))) # additive(末关 summary 取数面)
# Boss 钥石逐杀掉落(击杀掉落渠道,末关照常入账;首杀/复杀读 meta.tres boss_keystone)
_boss_keystone_drop(level_index)
if level_index < levels_total:
# 关卡完成兑付(非末关恰一次;三层奖励框架):编排归停点件——推进位先于 emit 置位
#(信号同步派发——处理器内立即确认时 _pending_next_level 必须已就位);
# 停点件内部分回血先行 → 加权洗牌掷 3 → 三选一停点(顺序即契约——回血按当刻账本与上限结算,
# 选择停点零自动推进)。载荷_base 随附钱包读数(停点件零钱包依赖——GP 件入账回调仍走 grant_curio_gp)
_bp_store.set_pending_next_level(true)
var stop_base: Dictionary[Variant, Variant] = payload.duplicate(true)
stop_base["gp"] = _wallet.gp
_stop.open_level_clear(stop_base)
return
# 末关的关卡完成:不发三选一与部分回血(没有下一战可受益)、Boss 钥石照常入账——冒险完成结算
_settle("completed")
func _settle(result: String) -> void:
# 冒险摘要五件事:打穿没有 / 完成几关 / 止步第几关 / 钥石入账合计 / 本局主种子
summary = {
"result": result,
"cleared": result == "completed",
"levels_cleared": levels_total if result == "completed" else level_index - 1,
"stopped_at": 0 if result == "completed" else level_index,
"keystone_total": _journal.keystone_gained(),
"master_seed": master_seed,
"aux_cleared_last": _bp_store.last_treasure_cleared(), # additive 扩签(末关清空反馈;非末关走 bonus 载荷)
"keystone_drops": _journal.keystone_drop_log(), # additive 扩签(逐杀掉落明细;settle 入账明细/复盘归因数据源)
"vision_used_delta": _journal.vision_used(), # additive 扩签(真视使用本局计数;settle 聚合入档)
"pool_stock": _pool_stock_snapshot(), # additive 扩签(终局池读数:清账前快照,商店买走 vs 奖励领走 vs 枯竭的终局面)
"relics": _stats.feats_held(), # additive 扩签(特性终局持有集:清账前快照,成型判读取数面)
}
# 败退明细(数值验证工具取数面——tools/balance_verifier.py 止步节点分布数据源;
# 仅失败局附加,键缺省 = 未败退,既有五件事消费方零感知)
if result == "failed" and not _bp_store.last_level_payload().is_empty():
summary["defeat"] = {
"level_index": level_index,
"node_id": int(_bp_store.last_level_payload().get("node_id", -1)),
"node_type": String(_bp_store.last_level_payload().get("node_type", "")),
}
if _runner != null:
summary["gp_final"] = int(_runner.gp) # gp_final 清账前快照(终局面板取数源=runner 钱包单点)
_runner.close_wallet() # GP 冒险收束清零(三结局都清——经转发发 wallet_changed "clear" 供日志/呈现)
_reset_curio_state() # 三选一停点态收束清零(结算受理只算一次语义——重复宣布零变化)
var sm_end = _bp_store.save_manager()
if sm_end != null:
sm_end.clear_breakpoint() # 三结局清断点(退出冒险=放弃流程+清除断点)
sm_end.write_to_disk()
_state = State.SETTLING
adventure_finished.emit(summary)
_state = State.IDLE
# 选路停点快照受理(level.choices_presented 直订——选路停点=唯一可恢复停点形态;战斗中不存)
func _on_level_choices_presented(_candidates: Array) -> void:
_snapshot_breakpoint()
# ---- 断点快照委托(组装与读侧策略机组归 breakpoint_store 件——六成员+27 键登记单源随件;
# 本壳一行转发:CurioStop 构造注入的 Callable 目标与本类 _on_level_choices_presented 调用点零改)----
func _snapshot_breakpoint() -> void:
_bp_store.snapshot(state, level_index, master_seed, _runner, adventure_counters)