736 lines
40 KiB
GDScript
736 lines
40 KiB
GDScript
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||
# Validation Question: 三关连探冒险(地精洞窟 → 幽暗地域 → 灰矮人矿城)能否以一条命走完——
|
||
# 主种子恰一次注入、生命/GP/加算区跨关同账、大奖励非末关恰兑付一次、三结局摘要五件事成立(P0)
|
||
# Date: 2026-09-21(冒险结构实现批——设计权威 design/gdd/systems/冒险循环/冒险结构.md)
|
||
# 冒险结构编排器(Core 层零 UI 依赖——headless 可跑完整冒险):一局的关卡序列、生命账本、GP 冒险
|
||
# 账本、统一冒险加算区、关卡大奖励、三结局结算与主种子接线的唯一所有者。
|
||
#
|
||
# 接口契约四件套落点(冒险结构.md「与关卡节点地图的边界」——本类是正式侧承载):
|
||
# ① 开始关卡 = start_level_external(载荷 = 关卡序号 + 当前生命 + 生效上限——生命经共享账本交接);
|
||
# ② 战斗结果上报 = runner 经共享账本属性直写(LevelRunner._on_battle_finished 回写 hero_hp 到
|
||
# _ledger——零直调战斗域,战后生命终值随账本回流本类);
|
||
# ③ 治疗封顶回报 = apply_rest(恢复点节点兑付时 LevelRunner 经 _rest_handler 回调——按比例回血并
|
||
# 封顶的执行归本类);
|
||
# ④ 关卡结束 = _on_level_finished(LevelRunner.level_finished 到达——结果 + 生命终值 + 节点位置)。
|
||
#
|
||
# 接口漂移清偿登记(06 清单①②③⑥⑦——LevelRunner 侧同批登记):①种子域归本类
|
||
# (start_adventure 调 rng.new_adventure 全冒险恰一次;LevelRunner 冒险模式不重播种);
|
||
# ②生命账本归本类(共享 _ledger);③恢复兑付执行归本类(_rest_handler);⑥收束先清库后宣布——
|
||
# 现行无关卡域库存(格面资源批 2026-09-15 后为空集),战斗结束 retire_mapping + despawn_all 先于
|
||
# battle_finished 即事实清库,不另设空接口;⑦失败结算归本类(LevelRunner 只上报结果)。
|
||
#
|
||
# 收束次序登记(大奖励兑付时点——冒险结构.md「关卡大奖励」):关卡节点地图先清库、后宣布关卡
|
||
# 结束(次序不可换);大奖励在宣布之后、下一关开始之前兑付——本类 _on_level_finished 兑付、
|
||
# continue_to_next_level 开始下一关,两步间为呈现层大奖励屏(冒烟/自动路径立即确认)。
|
||
# 结算受理只算一次(契约):state != "running" 的关卡结束宣布与放弃请求一律拒绝并留诊断,
|
||
# 账本与摘要零变化。
|
||
# 停点前移批(2026-09-23,用户拍板「关卡开始即出节点图选路、选节点才进战斗」):每关开始 =
|
||
# 入口选路停点——_start_current_level 以 defer_entry 起关,level_started 之后才 present_entry_choice
|
||
# (信号顺序契约:呈现层先重取 runner 实例,选路口才不会被空防御丢弃);pick 入口候选走同一
|
||
# _enter_node(entry)、呈现零 RNG 消耗——与旧「start 直进首战」逐位等价,测量面零漂移。
|
||
# 关卡切换(continue_to_next_level)后新 LevelRunner 实例重新走 defer 流程;abandon 在入口停点
|
||
# 天然非战斗期(current_battle 为空)——受理语义与选路停点一致。
|
||
# 纪律:ADR-0001 S6——连 runner 信号一律 _forward_* 方法 Callable(禁 lambda 捕获 self 成环);
|
||
# Core 层禁 UI 引用;数值零字面量——level_clear_hp_bonus / keystone_per_level 只读 db.adventure,
|
||
# 基础上限只读 db.hero。
|
||
# 断点续玩(迭代 8 卡①):save_manager 经 set_save_manager 注入(缺省 null = 不写断点——模拟/
|
||
# 冒烟零触达零扰动);停点快照挂 choices_presented 转发点(选路停点=唯一可恢复停点形态,战斗中
|
||
# 不存);三结局 _settle 清断点;resume_adventure 经 rng.restore_adventure 回填「主种子+计数器」
|
||
# ——续玩从断点号位精确续接原随机时间线。
|
||
class_name AdventureRunner
|
||
extends RefCounted
|
||
|
||
signal level_started(level_index: int, display_name: String) # 每关开始(含冒险首关)——呈现层重取 runner()、刷新图条与层名行
|
||
signal bonus_choice_presented(payload: Dictionary) # 提升三选一停点(非末关通关恰一次;迭代 10 大奖励重构——big_reward_paid 退役)——{level_index, candidates, heal_gained, hp_before, hp_after, keystone_gained, keystone_total, gp, aux_cleared}
|
||
signal bonus_resolved(result: Dictionary) # 三选一兑付完成(pick_bonus/替换受理后恰一次)——{picked, value, hp_before, hp_after, level_index}
|
||
signal aux_replace_presented(items: Array, incoming: Dictionary) # 同调位满位替换子交互(pick_bonus 道具件满位时)——items=现有持有件记录、incoming=新件记录
|
||
signal adventure_finished(summary: Dictionary) # 冒险结算(completed/failed/abandoned)——summary 五件事:打穿没有/通关几关/止步第几关/钥石合计/主种子(+additive keystone_drops 迭代 10)
|
||
# ---- 转发族(签名与 LevelRunner 同款——呈现层对接透明;ADR-0001 S6 经 _forward_* 方法)----
|
||
signal node_entered(node: Dictionary, info: Dictionary)
|
||
signal choices_presented(candidates: Array)
|
||
signal step_row(row: Dictionary)
|
||
signal round_started(round_index: int)
|
||
signal board_changed()
|
||
signal wallet_changed(event: String, gp: int, extra: Dictionary)
|
||
|
||
var db
|
||
var rng
|
||
var state := "idle" # idle / running / settling(冒险结构.md 三态)
|
||
var ledger := {"hero_hp": 0, "gp": 0} # 冒险账本(生命 + GP 钱包——经 start_level_external 注入 runner 共享)
|
||
var bonus := {"atk": 0, "max_hp": 0, "armor_class": 0, "ap": 0} # 统一冒险加算区(商店购买与三选一属性件同一本账;四轴 +ap——迭代 10 奖励重构)
|
||
var purchased := {} # product_id → 已购档数(跨关延续、冒险收束清零——递增定价输入)
|
||
var level_index := 0 # 当前关卡序号(1 起)
|
||
var levels_total := 0 # 序列总关数(读 level 表行数)
|
||
var keystone := 0 # 钥石入账累计(摘要第三件事的账面)
|
||
var master_seed := 0 # 本局主种子(摘要第五件事)
|
||
var summary := {} # 最近一次结算的冒险摘要(idle 后保留——重开才新生成)
|
||
var _runner: LevelRunner # 当前关卡行进器(每关新建——关卡域状态天然隔离)
|
||
var _pending_next_level := false # 大奖励已兑付、等 continue_to_next_level 确认
|
||
var _last_level_payload := {} # 最近一次关卡结束载荷(失败结算的败退节点明细来源——数值验证取数面)
|
||
var _override := [0, 0] # TunePanel 百分比通道 [atk_pct, hp_pct](每关新 runner 都要应用)
|
||
var _base_override := {"atk": 0, "max_hp": 0, "armor_class": 0}
|
||
var _first_script := false # 首次脚本模式(关卡节点图生成 v0.4——true = 全关零抽签取池内模板 0;测量夹具恒 false) # 永久强化 flat 注入(元进度实现批——冒险开始参数面 params.hero_override 存入,共享给 LevelRunner;全零 = 白板)
|
||
var _last_aux_cleared := 0 # additive(迭代 7 T4——最近一次关卡末清空计数,summary 取数面)
|
||
var _aux_unlocked := {} # aux 解锁门控集(迭代 7 T5——params.aux_unlocked 接收面)
|
||
var _save_manager = null # 断点承载注入(迭代 8 卡①——null = 不写断点;模拟/冒烟缺省)
|
||
var _resume_bp := {} # 续玩快照暂存(resume_adventure → _start_current_level 单跳传递)
|
||
# ---- 三选一停点态(迭代 10 奖励重构批)----
|
||
var _bonus_taken := {} # 件获取次数计数(attack/max_hp/armor/ap——次数上限判定不可由加算区累加值反推,牌库剔除态依赖)
|
||
var _bonus_pending := {} # 停点态:{"candidates": Array, "payload": Dictionary, "chosen"/"incoming": 替换在途}——空 = 无停点
|
||
var _bonus_result := {} # 已兑付待推进态(bonus_resolved 载荷副本——中断续玩不重兑付)
|
||
var _bonus_aux_carry: Array[String] = [] # 道具件跨关携带(兑付后注入下一关库存——清库后授予的体验窗)
|
||
var _keystone_drops: Array = [] # Boss 逐杀掉落明细(additive 摘要键 keystone_drops——结算明细/复盘归因数据源)
|
||
var _last_draw_aux: Array[String] = [] # 最近一次抽签掷定的道具件(掷定前置——卡片直显具体道具;追加批二)
|
||
var _node_bonus_pending := {} # 奖励节点三选一停点态(迭代 10 追加批——共享池;断点不载:续玩重建到选路停点重走,恢复的计数器保证同候选重抽)
|
||
|
||
|
||
func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void:
|
||
db = db_ref
|
||
rng = rng_ref
|
||
|
||
|
||
# ---- 冒险生命周期 ----
|
||
|
||
# 冒险开始(主种子注入恰一次——全冒险唯一 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)
|
||
ledger = {"hero_hp": 0, "gp": 0}
|
||
bonus = {"atk": 0, "max_hp": 0, "armor_class": 0, "ap": 0}
|
||
purchased = {}
|
||
keystone = 0
|
||
summary = {}
|
||
_reset_bonus_state()
|
||
levels_total = db.level.rows.size()
|
||
level_index = 1
|
||
_pending_next_level = false
|
||
_base_override = _normalize_override(params.get("hero_override", {}))
|
||
_first_script = bool(params.get("first_script", false)) # 缺省 = 非首次(抽签路径;真实侧由呈现层读存档布尔位传入)
|
||
_aux_unlocked = params.get("aux_unlocked", {}) if params.get("aux_unlocked", {}) is Dictionary else {} # additive(迭代 7 T5)
|
||
ledger["hero_hp"] = int(db.hero.max_hp) + int(_base_override["max_hp"]) # 生命账本置满(表值+永久强化 flat——加算区刚归零)
|
||
state = "running"
|
||
return _start_current_level()
|
||
|
||
|
||
# 注入参数规范化:三字段缺键/-1 = 表值兜底(→ 0 flat);负值一律 clamp 0(白板 = 全零合法态)
|
||
func _normalize_override(raw: Dictionary) -> Dictionary:
|
||
var out := {"atk": 0, "max_hp": 0, "armor_class": 0}
|
||
for k in out.keys():
|
||
out[k] = maxi(0, int(raw.get(k, 0)))
|
||
return out
|
||
|
||
|
||
# 断点续玩(迭代 8 卡①——「继续上局」入口;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
|
||
master_seed = int(bp.get("master_seed", 0))
|
||
rng.restore_adventure(master_seed, int(bp.get("rng_counter", 0)))
|
||
ledger = {"hero_hp": int(bp.get("hero_hp", 0)), "gp": int(bp.get("gp", 0))}
|
||
var bp_bonus: Dictionary = bp.get("bonus", {}) as Dictionary
|
||
bonus = {"atk": maxi(0, int(bp_bonus.get("atk", 0))),
|
||
"max_hp": maxi(0, int(bp_bonus.get("max_hp", 0))),
|
||
"armor_class": maxi(0, int(bp_bonus.get("armor_class", 0))),
|
||
"ap": maxi(0, int(bp_bonus.get("ap", 0)))}
|
||
purchased = (bp.get("shop_purchased", {}) as Dictionary).duplicate(true)
|
||
keystone = int(bp.get("keystone", 0))
|
||
summary = {}
|
||
levels_total = db.level.rows.size()
|
||
level_index = int(bp.get("level_index", 1))
|
||
_pending_next_level = false
|
||
_reset_bonus_state()
|
||
var bp_taken: Dictionary = bp.get("bonus_taken", {}) as Dictionary
|
||
for id in _bonus_taken.keys():
|
||
_bonus_taken[id] = maxi(0, int(bp_taken.get(id, 0)))
|
||
for v in (bp.get("bonus_aux_carry", []) as Array):
|
||
_bonus_aux_carry.append(String(v))
|
||
_keystone_drops = (bp.get("keystone_drops", []) as Array).duplicate(true)
|
||
_base_override = _normalize_override(params.get("hero_override", {}))
|
||
_first_script = false # 续玩模板已随快照强制(已抽模板零重抽)——脚本模式不重放
|
||
_aux_unlocked = params.get("aux_unlocked", {}) if params.get("aux_unlocked", {}) is Dictionary else {}
|
||
# 三选一停点续玩分支(迭代 10——选择态/已兑付态不重跑关卡;奖励选择态不重抽、已兑付不重付)
|
||
var bp_pending: Dictionary = bp.get("bonus_pending", {}) as Dictionary
|
||
var bp_result: Dictionary = bp.get("bonus_result", {}) as Dictionary
|
||
if not bp_pending.is_empty():
|
||
_pending_next_level = true
|
||
_bonus_pending = {"candidates": (bp_pending.get("candidates", []) as Array).duplicate(true)}
|
||
if bp_pending.has("aux_detail"):
|
||
_bonus_pending["aux_detail"] = (bp_pending.get("aux_detail", []) as Array).duplicate(true) # 掷定前置随停点快照续接
|
||
var payload: Dictionary = (bp_pending.get("payload", {}) as Dictionary).duplicate(true)
|
||
if bp_pending.has("incoming"):
|
||
_bonus_pending["chosen"] = "aux_item"
|
||
var incoming: Array = (bp_pending.get("incoming", []) as Array).duplicate(true)
|
||
_bonus_pending["incoming"] = incoming
|
||
state = "running"
|
||
bonus_choice_presented.emit(payload)
|
||
# 替换在途续发(呈现层直落替换子态——持有件记录按恢复后 carry 重建)
|
||
aux_replace_presented.emit(_carry_records(),
|
||
{"index": -1, "item_id": String(incoming[0]) if not incoming.is_empty() else "", "count": incoming.size()})
|
||
return true
|
||
state = "running"
|
||
bonus_choice_presented.emit(payload)
|
||
return true
|
||
if not bp_result.is_empty():
|
||
_pending_next_level = true
|
||
_bonus_result = bp_result.duplicate(true)
|
||
state = "running"
|
||
bonus_resolved.emit(_bonus_result.duplicate(true))
|
||
return true
|
||
_resume_bp = bp.duplicate(true)
|
||
state = "running"
|
||
return _start_current_level()
|
||
|
||
|
||
# 三选一停点态归零(冒险开始/结算收束共用)
|
||
func _reset_bonus_state() -> void:
|
||
_bonus_taken = {"attack": 0, "max_hp": 0, "armor": 0, "ap": 0}
|
||
_bonus_pending = {}
|
||
_bonus_result = {}
|
||
_bonus_aux_carry = []
|
||
_keystone_drops = []
|
||
_node_bonus_pending = {}
|
||
|
||
|
||
# TunePanel 百分比通道(测试面板覆盖——每关新 runner 都要应用;正式侧归元进度)
|
||
func set_hero_override(atk_pct: int, hp_pct: int) -> void:
|
||
_override = [atk_pct, hp_pct]
|
||
if _runner != null:
|
||
_runner.set_hero_override(atk_pct, hp_pct)
|
||
|
||
|
||
# 断点承载注入(迭代 8 卡①——组合根冒险开始时调用;模拟/冒烟不注入 = 断点面整体静默)
|
||
func set_save_manager(sm) -> void:
|
||
_save_manager = sm
|
||
|
||
|
||
# 当前关卡行进器读面(呈现层/冒烟用——每关新建实例,调用方须在 level_started 后重取)
|
||
func runner() -> LevelRunner:
|
||
return _runner
|
||
|
||
|
||
# 接口③治疗封顶回报执行体(恢复点节点兑付——按比例回血并封顶;与 LevelRunner.rest_heal_amount
|
||
# 同式,同一公式两个落点是临时态,注释指认,统一归并归后续批)
|
||
func apply_rest(pct: int, hp: int, max_hp: int) -> int:
|
||
return mini(int(floor(float(max_hp) * float(pct) / 100.0)), max_hp - hp)
|
||
|
||
|
||
# 大奖励确认 → 进下一关(仅兑付后受理——零自动推进的冒险级停点;三选一未兑付拒绝推进)
|
||
func continue_to_next_level() -> bool:
|
||
if not _pending_next_level:
|
||
push_error("[冒险结构] continue_to_next_level 拒绝——无待确认的关卡推进")
|
||
return false
|
||
if not _bonus_pending.is_empty():
|
||
push_error("[冒险结构] continue_to_next_level 拒绝——三选一停点未兑付(零自动推进契约)")
|
||
return false
|
||
_pending_next_level = false
|
||
_bonus_result = {}
|
||
level_index += 1
|
||
return _start_current_level()
|
||
|
||
|
||
# ---- 提升三选一(迭代 10 奖励重构批——选择停点零自动推进、重复兑付被拒;兑付恰一件、未选项零生效)----
|
||
|
||
# 玩家点选兑付(呈现层/冒烟/自动模拟确认入口):id ∈ 停点候选。属性件写加算区+计数、AP 件写第四轴、
|
||
# GP 件走钱包「奖励入账」入口、道具件受同调位约束(满位转替换子交互)。
|
||
func pick_bonus(id: String) -> bool:
|
||
if state != "running" or _bonus_pending.is_empty():
|
||
push_error("[冒险结构] pick_bonus 拒绝——无待选停点(state=%s)" % state)
|
||
return false
|
||
var candidates: Array = _bonus_pending.get("candidates", []) as Array
|
||
if not candidates.has(id):
|
||
push_error("[冒险结构] pick_bonus 拒绝——件不在候选: %s" % id)
|
||
return false
|
||
var value: int = int(db.adventure.bonus_value(id))
|
||
var hp_before := int(ledger.get("hero_hp", 0))
|
||
match id:
|
||
"attack":
|
||
bonus["atk"] = int(bonus.get("atk", 0)) + value
|
||
_bonus_taken["attack"] = int(_bonus_taken.get("attack", 0)) + 1
|
||
"max_hp":
|
||
bonus["max_hp"] = int(bonus.get("max_hp", 0)) + value
|
||
_bonus_taken["max_hp"] = int(_bonus_taken.get("max_hp", 0)) + 1
|
||
"armor":
|
||
bonus["armor_class"] = int(bonus.get("armor_class", 0)) + value
|
||
_bonus_taken["armor"] = int(_bonus_taken.get("armor", 0)) + 1
|
||
"ap":
|
||
bonus["ap"] = int(bonus.get("ap", 0)) + value
|
||
_bonus_taken["ap"] = int(_bonus_taken.get("ap", 0)) + 1
|
||
"gp":
|
||
if _runner != null:
|
||
_runner.grant_bonus_gp(value)
|
||
"aux_item":
|
||
return _pick_aux_item(value)
|
||
_:
|
||
push_error("[冒险结构] pick_bonus 拒绝——未知件: %s" % id)
|
||
return false
|
||
_finish_bonus_pick(id, value, hp_before)
|
||
return true
|
||
|
||
|
||
# 道具件兑付(同调位约束):有位直接入跨关携带;满位转替换子交互(incoming 暂存、等 resolve_aux_replace)。
|
||
# 掷定前置(追加批二):具体道具随候选抽定(_last_draw_aux / _bonus_pending.aux_detail)——本函数消费预掷值,
|
||
# 断点续玩/无预掷值时回退当场掷定(流序不同但消费数不变,确定性由调用序保证)。
|
||
func _pick_aux_item(count: int) -> bool:
|
||
var pool: Array = _bonus_aux_pool()
|
||
if pool.is_empty():
|
||
push_error("[冒险结构] pick_bonus(aux_item) 拒绝——解锁道具池空集(白板/门控态不入牌库的防御兜底)")
|
||
return false
|
||
var hp_before := int(ledger.get("hero_hp", 0))
|
||
var picked: Array[String] = []
|
||
var detail: Array = (_bonus_pending.get("aux_detail", []) as Array).duplicate()
|
||
if detail.is_empty():
|
||
detail = _last_draw_aux.duplicate()
|
||
for i in maxi(1, count):
|
||
if i < detail.size():
|
||
picked.append(String(detail[i]))
|
||
else:
|
||
picked.append(_roll_bonus_aux_item(pool))
|
||
var slots := maxi(0, int(db.adventure.transitional_sync_slots))
|
||
if _bonus_aux_carry.size() + picked.size() <= slots:
|
||
for iid in picked:
|
||
_bonus_aux_carry.append(iid)
|
||
_finish_bonus_pick("aux_item", picked.size(), hp_before)
|
||
return true
|
||
# 满位替换子交互:现有持有件 + 新件 N+1 选 N(丢旧收新 / 放弃新件两路,终态恒 ≤ 同调位)
|
||
_bonus_pending["chosen"] = "aux_item"
|
||
_bonus_pending["incoming"] = picked.duplicate()
|
||
aux_replace_presented.emit(_carry_records(), {"index": -1, "item_id": String(picked[0]), "count": picked.size()})
|
||
return true
|
||
|
||
|
||
# 替换子交互受理(discard_index = -1 放弃新件不获取;否则丢该件腾位收新)——终态恒 ≤ 同调位
|
||
func resolve_aux_replace(discard_index: int) -> bool:
|
||
if _bonus_pending.is_empty() or String(_bonus_pending.get("chosen", "")) != "aux_item":
|
||
push_error("[冒险结构] resolve_aux_replace 拒绝——无替换在途")
|
||
return false
|
||
var incoming: Array = _bonus_pending.get("incoming", []) as Array
|
||
var hp_before := int(ledger.get("hero_hp", 0))
|
||
if discard_index >= 0:
|
||
if discard_index >= _bonus_aux_carry.size():
|
||
push_error("[冒险结构] resolve_aux_replace 拒绝——序号不在持有集: %d" % discard_index)
|
||
return false
|
||
_bonus_aux_carry.remove_at(discard_index)
|
||
for iid in incoming:
|
||
_bonus_aux_carry.append(String(iid))
|
||
# 放弃新件(discard_index < 0)= 原持有集不动、新件不获取
|
||
_finish_bonus_pick("aux_item", incoming.size(), hp_before)
|
||
return true
|
||
|
||
|
||
# 兑付收口(恰一次):清停点态 → 记已兑付态 → emit → 停点快照(等继续确认——中断续玩不重兑付)
|
||
func _finish_bonus_pick(picked: String, value: int, hp_before: int) -> void:
|
||
_bonus_pending = {}
|
||
_bonus_result = {
|
||
"picked": picked,
|
||
"value": value,
|
||
"hp_before": hp_before,
|
||
"hp_after": int(ledger.get("hero_hp", 0)),
|
||
"level_index": level_index,
|
||
}
|
||
bonus_resolved.emit(_bonus_result.duplicate(true))
|
||
_snapshot_breakpoint()
|
||
|
||
|
||
# ---- 奖励节点三选一(迭代 10 追加批——用户拍板:与通关三选一共享同池同权重同次数上限)----
|
||
|
||
# 候选呈现(节点到达时调用——恰一 reward_pick scope 洗牌 + 道具件掷定 scope;本停点断点不载,
|
||
# 续玩重建到选路停点后重走重抽,恢复的全局计数器保证同候选逐位复现)。
|
||
# 掷定前置(追加批二):返回 dict 带 aux_detail(道具件具体道具)供卡片直显。
|
||
func node_bonus_candidates() -> Dictionary:
|
||
var cands: Array = _draw_bonus_candidates()
|
||
return {"candidates": cands, "aux_detail": _last_draw_aux.duplicate()}
|
||
|
||
|
||
func present_node_bonus(candidates: Array, aux_detail: Array = []) -> void:
|
||
_node_bonus_pending = {"candidates": candidates.duplicate(true), "aux_detail": aux_detail.duplicate(true)}
|
||
|
||
|
||
# 点选兑付(节点域——无部分回血无钥石;件获取计数与牌库剔除与通关三选一同账)
|
||
func apply_node_bonus(id: String) -> bool:
|
||
if _node_bonus_pending.is_empty():
|
||
push_error("[冒险结构] apply_node_bonus 拒绝——无待选停点")
|
||
return false
|
||
var candidates: Array = _node_bonus_pending.get("candidates", []) as Array
|
||
if not candidates.has(id):
|
||
push_error("[冒险结构] apply_node_bonus 拒绝——件不在候选: %s" % id)
|
||
return false
|
||
var value: int = int(db.adventure.bonus_value(id))
|
||
match id:
|
||
"attack":
|
||
bonus["atk"] = int(bonus.get("atk", 0)) + value
|
||
_bonus_taken["attack"] = int(_bonus_taken.get("attack", 0)) + 1
|
||
"max_hp":
|
||
bonus["max_hp"] = int(bonus.get("max_hp", 0)) + value
|
||
_bonus_taken["max_hp"] = int(_bonus_taken.get("max_hp", 0)) + 1
|
||
"armor":
|
||
bonus["armor_class"] = int(bonus.get("armor_class", 0)) + value
|
||
_bonus_taken["armor"] = int(_bonus_taken.get("armor", 0)) + 1
|
||
"ap":
|
||
bonus["ap"] = int(bonus.get("ap", 0)) + value
|
||
_bonus_taken["ap"] = int(_bonus_taken.get("ap", 0)) + 1
|
||
"gp":
|
||
if _runner != null:
|
||
_runner.grant_bonus_gp(value)
|
||
"aux_item":
|
||
return _apply_node_aux(value)
|
||
_:
|
||
push_error("[冒险结构] apply_node_bonus 拒绝——未知件: %s" % id)
|
||
return false
|
||
_finish_node_bonus(id, value)
|
||
return true
|
||
|
||
|
||
# 节点渠道道具件(同调位约束):持有=关卡域库存+跨关携带,有位直接入本关库存(当关可用);
|
||
# 满位转替换子交互(丢弃目标=库存件 handle——与通关渠道的 carry 序号分轨)。
|
||
# 掷定前置:消费 _node_bonus_pending.aux_detail 预掷值(缺省回退当场掷定)。
|
||
func _apply_node_aux(count: int) -> bool:
|
||
var pool: Array = _bonus_aux_pool()
|
||
if pool.is_empty():
|
||
push_error("[冒险结构] apply_node_bonus(aux_item) 拒绝——解锁道具池空集(白板/门控态不入牌库的防御兜底)")
|
||
return false
|
||
var picked: Array[String] = []
|
||
var detail: Array = (_node_bonus_pending.get("aux_detail", []) as Array).duplicate()
|
||
for i in maxi(1, count):
|
||
if i < detail.size():
|
||
picked.append(String(detail[i]))
|
||
else:
|
||
picked.append(_roll_bonus_aux_item(pool))
|
||
var slots := maxi(0, int(db.adventure.transitional_sync_slots))
|
||
var records: Array = []
|
||
if _runner != null and _runner.aux_inventory != null:
|
||
records = _runner.aux_inventory.holding_records()
|
||
var holding := records.size() + _bonus_aux_carry.size()
|
||
if holding + picked.size() <= slots:
|
||
if _runner != null:
|
||
for iid in picked:
|
||
_runner.grant_aux_item(iid, "reward_node")
|
||
_finish_node_bonus("aux_item", picked.size())
|
||
return true
|
||
_node_bonus_pending["chosen"] = "aux_item"
|
||
_node_bonus_pending["incoming"] = picked.duplicate()
|
||
aux_replace_presented.emit(records, {"handle": -1, "item_id": String(picked[0]), "count": picked.size()})
|
||
return true
|
||
|
||
|
||
func _resolve_node_replace(discard_handle: int) -> bool:
|
||
if _node_bonus_pending.is_empty() or String(_node_bonus_pending.get("chosen", "")) != "aux_item":
|
||
push_error("[冒险结构] resolve_node_replace 拒绝——无替换在途")
|
||
return false
|
||
var incoming: Array = _node_bonus_pending.get("incoming", []) as Array
|
||
if discard_handle >= 0:
|
||
if _runner == null or _runner.aux_inventory == null or not _runner.aux_inventory.discard_item(discard_handle):
|
||
push_error("[冒险结构] resolve_node_replace 拒绝——句柄不在持有集: %d" % discard_handle)
|
||
return false
|
||
for iid in incoming:
|
||
_runner.grant_aux_item(String(iid), "reward_node")
|
||
# 放弃新件(discard_handle < 0)= 原持有集不动、新件不获取
|
||
_finish_node_bonus("aux_item", incoming.size())
|
||
return true
|
||
|
||
|
||
# 替换受理分发(单入口——节点停点在途优先按库存句柄,否则按通关停点的携带序号)
|
||
func resolve_bonus_replace(discard: int) -> bool:
|
||
if not _node_bonus_pending.is_empty() and _node_bonus_pending.has("incoming"):
|
||
return _resolve_node_replace(discard)
|
||
return resolve_aux_replace(discard)
|
||
|
||
|
||
func _finish_node_bonus(picked: String, value: int) -> void:
|
||
_node_bonus_pending = {}
|
||
bonus_resolved.emit({
|
||
"context": "reward_node",
|
||
"picked": picked,
|
||
"value": value,
|
||
"level_index": level_index,
|
||
})
|
||
|
||
|
||
# 加权洗牌不放回掷 3(E-S weight-key:每件 key = U(0,1)^(1/权重) 取前 3——单次零重复、逐件按权重出
|
||
# 概率;达次数上限件先剔牌库〔AP 用尽 2 次/属性件达 3 次永不再现;GP/道具件无次数上限,牌库恒非空〕;
|
||
# 道具件解锁池空集〔白板/门控态〕不入牌库。消费 reward_pick 封闭流——一次停点恰一 scope,同种子同三候选;
|
||
# 道具件具体道具在本调用内掷定(_last_draw_aux——掷定前置,卡片直显,追加批二)。
|
||
func _draw_bonus_candidates() -> Array:
|
||
var deck: Array = []
|
||
_last_draw_aux = []
|
||
for id in db.adventure.BONUS_IDS:
|
||
var cap := int(db.adventure.bonus_cap(String(id)))
|
||
if cap != db.adventure.BONUS_CAP_UNLIMITED and int(_bonus_taken.get(String(id), 0)) >= cap:
|
||
continue
|
||
if String(id) == "aux_item" and _bonus_aux_pool().is_empty():
|
||
continue
|
||
deck.append(String(id))
|
||
var rg: RandomNumberGenerator = rng.open_stream("reward_pick")
|
||
var keyed: Array = []
|
||
for id in deck:
|
||
var w := float(maxi(1, int(db.adventure.bonus_weight(id))))
|
||
keyed.append({"id": id, "key": pow(rg.randf(), 1.0 / w)})
|
||
keyed.sort_custom(func(a, b): return float(a["key"]) > float(b["key"]))
|
||
var out: Array = []
|
||
for i in mini(3, keyed.size()):
|
||
var cid := String(keyed[i]["id"])
|
||
out.append(cid)
|
||
if cid == "aux_item":
|
||
var roll_rg: RandomNumberGenerator = rng.open_stream("reward_pick")
|
||
for j in maxi(1, int(db.adventure.bonus_aux_item_count)):
|
||
_last_draw_aux.append(_roll_from(roll_rg))
|
||
return out
|
||
|
||
|
||
func _roll_from(rg: RandomNumberGenerator) -> String:
|
||
var pool: Array = _bonus_aux_pool()
|
||
return String(pool[rg.randi_range(0, pool.size() - 1)])
|
||
|
||
|
||
# 三选一道具件随机池(与投放门控同源:aux 活行 ∩ 解锁门控集——空集 = 道具件不入牌库)
|
||
func _bonus_aux_pool() -> Array:
|
||
var out: Array = []
|
||
var live: Dictionary = db.aux_live_ids()
|
||
for iid in live.keys():
|
||
if _aux_unlocked.has(String(iid)):
|
||
out.append(String(iid))
|
||
return out
|
||
|
||
|
||
func _roll_bonus_aux_item(pool: Array) -> String:
|
||
var rg: RandomNumberGenerator = rng.open_stream("reward_pick")
|
||
return String(pool[rg.randi_range(0, pool.size() - 1)])
|
||
|
||
|
||
func _carry_records() -> Array:
|
||
var out: Array = []
|
||
for i in _bonus_aux_carry.size():
|
||
out.append({"index": i, "item_id": _bonus_aux_carry[i]})
|
||
return out
|
||
|
||
|
||
# Boss 钥石逐杀掉落(迭代 10——渠道改 Boss/精英击杀掉落;首杀/复杀两档读 meta.tres boss_keystone;
|
||
# 首杀判据 = 存档 boss_first_kill 标记位〔断点未注入 = 恒首杀——模拟/冒烟零扰动;标记仍在收束
|
||
# apply_adventure_summary 逐通关关次置位〕;末关 Boss 照常入账;明细随摘要 additive 键供结算面归因)
|
||
func _boss_keystone_drop(li: int) -> void:
|
||
var first := true
|
||
if _save_manager != null:
|
||
first = not _save_manager.boss_first_kill_marked(li)
|
||
var row: Dictionary = db.meta_boss_keystone_row(li)
|
||
var amount: int = int(row.get("first_kill", 0)) if first else int(row.get("repeat_kill", 0))
|
||
_keystone_drops.append({"level": li, "first": first, "amount": amount})
|
||
keystone += 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:
|
||
_runner = LevelRunner.new(db, rng)
|
||
# core↔core 接线只许方法 Callable(ADR-0001 S6——lambda 捕获 self 会延长 RefCounted 生命周期乃至成环)
|
||
_runner.node_entered.connect(_forward_node_entered)
|
||
_runner.choices_presented.connect(_forward_choices_presented)
|
||
_runner.step_row.connect(_forward_step_row)
|
||
_runner.round_started.connect(_forward_round_started)
|
||
_runner.board_changed.connect(_forward_board_changed)
|
||
_runner.wallet_changed.connect(_forward_wallet_changed)
|
||
_runner.level_finished.connect(_on_level_finished)
|
||
_runner.set_hero_override(int(_override[0]), int(_override[1]))
|
||
# 断点续玩分支(迭代 8 卡①):模板随快照强制(已抽模板零重抽零 RNG)、resume_state 带
|
||
# 停点节点与已到序——重建到停点后经 present_resumed_choice 呈现(信号顺序契约同入口)。
|
||
var resume_state: Dictionary = {}
|
||
var template_pick: int = 0 if _first_script else -1 # 首次脚本 = 每关固定模板 0 零抽签(v0.4 拍板点①);否则逐关抽签
|
||
if not _resume_bp.is_empty():
|
||
resume_state = {"node_id": int(_resume_bp.get("node_id", -1)),
|
||
"visited_ids": _resume_bp.get("visited_ids", []) as Array}
|
||
template_pick = int(_resume_bp.get("template_pick", -1))
|
||
# 接口①开始关卡:生命经共享账本交接(账本当前值即开局生命——冒险开始置满 / 大奖励回满已写好);
|
||
# _base_override 共享引用随载荷传入(永久强化 flat——LevelRunner.effective_* 叠加序第二项)。
|
||
# 停点前移批 2026-09-23:defer_entry=true 起每关(含首关)——先 emit level_started(呈现层在
|
||
# 该回调重取 runner 实例),后入口候选呈现;顺序反了选路口会被呈现层的 runner 空防御静默丢弃。
|
||
# 呈现零 RNG 消耗、pick 走同一 _enter_node——测量面逐位零漂移。
|
||
var ok: bool = _runner.start_level_external(level_index, ledger, bonus, purchased,
|
||
Callable(self, "apply_rest"), _base_override, true, template_pick, resume_state)
|
||
if ok:
|
||
# 三选一道具件跨关携带注入(迭代 10——上关兑付的道具件进本关库存〔奖励只朝下一战生效〕;
|
||
# 注入即移交——carry 清空,本关末随关卡域库存清空)
|
||
for iid in _bonus_aux_carry:
|
||
_runner.grant_aux_item(iid, "bonus_pick")
|
||
_bonus_aux_carry = []
|
||
level_started.emit(level_index, String(_runner.level_row.get("display_name", "")))
|
||
# 入口停点断点(node_id=-1)与 defer_entry 同态(node_index=-1+单候选)——走入口呈现;
|
||
# 中途停点(node_id>=0)走停点呈现(restore_aux_items 先于呈现=C 区库存就位)。
|
||
if resume_state.is_empty() or int(resume_state.get("node_id", -1)) < 0:
|
||
_runner.present_entry_choice()
|
||
else:
|
||
_runner.restore_aux_items(_resume_bp.get("aux_items", []) as Array)
|
||
_runner.present_resumed_choice()
|
||
_resume_bp = {}
|
||
return ok
|
||
|
||
|
||
# 接口④关卡结束(结果 + 生命终值 + 节点位置——胜负皆报;结算受理只算一次)
|
||
func _on_level_finished(payload: Dictionary) -> void:
|
||
if state != "running":
|
||
push_error("[冒险结构] 重复关卡结束宣布拒绝——state=%s(账本与摘要零变化)" % state)
|
||
return
|
||
_last_level_payload = payload.duplicate(true)
|
||
var result := String(payload.get("result", ""))
|
||
if result == "defeat":
|
||
ledger["hero_hp"] = 0 # 倒下记 0(生命账本战后回写口径)
|
||
_settle("failed")
|
||
return
|
||
if result != "victory":
|
||
push_error("[冒险结构] 关卡结束结果出域: %s" % result)
|
||
return
|
||
_last_aux_cleared = int(payload.get("aux_cleared", 0)) # additive(迭代 7 T4——末关 summary 取数面)
|
||
# Boss 钥石逐杀掉落(迭代 10——渠道改击杀掉落,末关照常入账;首杀/复杀读 meta.tres boss_keystone)
|
||
_boss_keystone_drop(level_index)
|
||
if level_index < levels_total:
|
||
# 通关兑付(非末关恰一次;迭代 10 三层奖励框架):部分回血先行 → 加权洗牌掷 3 → 三选一停点
|
||
#(顺序即契约——回血按当刻账本与上限结算,选择停点零自动推进)
|
||
var hp_before := int(ledger.get("hero_hp", 0))
|
||
var max_eff := int(db.hero.max_hp) + int(_base_override["max_hp"]) + int(bonus.get("max_hp", 0))
|
||
var heal := mini(int(floor(float(max_eff) * float(int(db.adventure.clear_heal_pct)) / 100.0)), max_eff - hp_before)
|
||
ledger["hero_hp"] = hp_before + heal
|
||
# 置位先于 emit(信号同步派发——处理器内立即确认时 _pending_next_level 必须已就位)
|
||
_pending_next_level = true
|
||
var pick_payload: Dictionary = {
|
||
"level_index": level_index,
|
||
"candidates": _draw_bonus_candidates(),
|
||
"aux_detail": _last_draw_aux.duplicate(), # 掷定前置(追加批二——道具件具体道具随候选抽定,卡片直显)
|
||
"heal_gained": heal,
|
||
"hp_before": hp_before,
|
||
"hp_after": int(ledger.get("hero_hp", 0)),
|
||
"keystone_gained": int(_keystone_drops.back().get("amount", 0)) if not _keystone_drops.is_empty() else 0,
|
||
"keystone_total": keystone,
|
||
"gp": int(ledger.get("gp", 0)),
|
||
"aux_cleared": int(payload.get("aux_cleared", 0)), # additive 扩签(迭代 7 T4——关卡末清空反馈取数)
|
||
}
|
||
_bonus_pending = {"candidates": (pick_payload["candidates"] as Array).duplicate(true),
|
||
"aux_detail": _last_draw_aux.duplicate(),
|
||
"payload": pick_payload.duplicate(true)}
|
||
bonus_choice_presented.emit(pick_payload)
|
||
_snapshot_breakpoint() # 选择停点可中断(契约 5——续玩不重抽不静默丢失)
|
||
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": keystone,
|
||
"master_seed": master_seed,
|
||
"aux_cleared_last": _last_aux_cleared, # additive 扩签(迭代 7 T4——末关清空反馈;非末关走 bonus 载荷)
|
||
"keystone_drops": _keystone_drops.duplicate(true), # additive 扩签(迭代 10——逐杀掉落明细;settle 入账明细/复盘归因数据源)
|
||
}
|
||
# 败退明细(数值验证工具取数面 2026-09-22——tools/balance_verifier.py 止步节点分布数据源;
|
||
# 仅失败局附加,键缺省 = 未败退,既有五件事消费方零感知)
|
||
if result == "failed" and not _last_level_payload.is_empty():
|
||
summary["defeat"] = {
|
||
"level_index": level_index,
|
||
"node_id": int(_last_level_payload.get("node_id", -1)),
|
||
"node_type": String(_last_level_payload.get("node_type", "")),
|
||
}
|
||
if _runner != null:
|
||
_runner.close_wallet() # GP 冒险收束清零(三结局都清——经转发发 wallet_changed "clear" 供日志/呈现)
|
||
_reset_bonus_state() # 三选一停点态收束清零(结算受理只算一次语义——重复宣布零变化)
|
||
if _save_manager != null:
|
||
_save_manager.clear_breakpoint() # 三结局清断点(迭代 8 卡①/T3——退出冒险=放弃流程+清除断点)
|
||
_save_manager.write_to_disk()
|
||
state = "settling"
|
||
adventure_finished.emit(summary)
|
||
state = "idle"
|
||
|
||
|
||
# ---- 转发族(签名与 LevelRunner 同款——呈现层对接透明)----
|
||
|
||
func _forward_node_entered(node: Dictionary, info: Dictionary) -> void:
|
||
node_entered.emit(node, info)
|
||
|
||
|
||
func _forward_choices_presented(candidates: Array) -> void:
|
||
choices_presented.emit(candidates)
|
||
_snapshot_breakpoint() # 停点快照(迭代 8 卡①——选路停点=唯一可恢复停点形态;战斗中不存)
|
||
|
||
|
||
# ---- 断点快照(迭代 8 卡①——七字段拍板面+恢复必需附随件)----
|
||
# 七字段(拍板面):关卡序号/节点位/生命/本关加成区/GP 钱包/商店已购档/已抽模板;
|
||
# 附随件(续玩正确性必需,随批注记):主种子+取数计数器(rng 快照契约回填——续接原时间线)、
|
||
# 钥石累计(大奖励中途值——终局摘要五件事不失真)、已到节点序(呈现层图条重建)、道具库存
|
||
# (奖励节点已选件——防静默丢失)。注入缺省 null = 整面静默(模拟/冒烟零扰动)。
|
||
func _snapshot_breakpoint() -> void:
|
||
if _save_manager == null or state != "running":
|
||
return
|
||
var aux_items: Array = []
|
||
if _runner != null and _runner.aux_inventory != null:
|
||
aux_items = _runner.aux_inventory.snapshot_inventory()
|
||
# 三选一停点续玩分支 _runner 为 null(关卡已收束、下一关未开)——节点域字段按缺省落档,
|
||
# 该两态的续玩只消费 bonus_* 附随件(选择停点/已兑付停点快照,迭代 10)
|
||
var bp: Dictionary = {
|
||
"level_index": level_index,
|
||
"node_id": _runner.current_node_id() if _runner != null else -1,
|
||
"hero_hp": int(ledger.get("hero_hp", 0)),
|
||
"bonus": {
|
||
"atk": int(bonus.get("atk", 0)),
|
||
"max_hp": int(bonus.get("max_hp", 0)),
|
||
"armor_class": int(bonus.get("armor_class", 0)),
|
||
"ap": int(bonus.get("ap", 0)),
|
||
},
|
||
"gp": int(ledger.get("gp", 0)),
|
||
"shop_purchased": purchased.duplicate(true),
|
||
"template_pick": _runner.active_template_index if _runner != null else -1,
|
||
"master_seed": master_seed,
|
||
"rng_counter": rng.counter(),
|
||
"keystone": keystone,
|
||
"visited_ids": _runner.visited_ids() if _runner != null else [],
|
||
"aux_items": aux_items,
|
||
"bonus_taken": _bonus_taken.duplicate(true), # 三选一件获取次数(上限判定不可由加算区反推——迭代 10)
|
||
"bonus_pending": _bonus_pending.duplicate(true), # 三选一停点态(空 = 无停点——选择停点可中断契约)
|
||
"bonus_result": _bonus_result.duplicate(true), # 已兑付待推进态(空 = 无——中断续玩不重兑付)
|
||
"bonus_aux_carry": _bonus_aux_carry.duplicate(true), # 道具件跨关携带(注入前中断防静默丢失)
|
||
"keystone_drops": _keystone_drops.duplicate(true), # 逐杀掉落明细(摘要归因续接——迭代 10)
|
||
}
|
||
_save_manager.set_breakpoint(bp)
|
||
_save_manager.write_to_disk()
|
||
|
||
|
||
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 _forward_wallet_changed(event: String, gp_val: int, extra: Dictionary) -> void:
|
||
wallet_changed.emit(event, gp_val, extra)
|