diff --git a/game/core/adventure_runner.gd b/game/core/adventure_runner.gd index 44ccda53..721008d8 100644 --- a/game/core/adventure_runner.gd +++ b/game/core/adventure_runner.gd @@ -27,19 +27,14 @@ var levels_total := 0 # 序列总关数(读 level 表行 var master_seed := 0 # 本局主种子(摘要第五件事) var summary := {} # 最近一次结算的冒险摘要(idle 后保留——重开才新生成) var _runner: LevelRunner # 当前关卡行进器(每关新建——关卡域状态天然隔离) -var _pending_next_level := false # 大奖励已兑付、等 continue_to_next_level 确认 -var _last_level_payload := {} # 最近一次关卡结束载荷(失败结算的败退节点明细来源——数值验证取数面) -var _last_treasure_cleared := 0 # additive(最近一次关卡末清空计数,summary 取数面) var _treasure_unlocked := {} # aux 解锁门控集(params.treasure_unlocked 接收面) var _attunement: Array[Variant] = [] # 同调装配集(params.attunement 接收面,start_level 外下传 LevelRunner) -var _save_manager = null # 断点承载注入(null = 不写断点;模拟/冒烟缺省) -var _resume_bp := {} # 续玩快照暂存(resume_adventure → _start_current_level 单跳传递) -var _loadout_seeded := false # 同调行已种标记(每冒险一次——首关 bind 时点投行;行跨关存活后续关零动作) # ---- 三选一停点(编排迁 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: Array[Variant] = [] # 树 rule 键集(Fork 激活侧 effects 透传;下传 enc.tree_rules 消费面) var _shop_mode := "legacy" # 商店模式("legacy"=固定三件加定价〔off 对照承载,缺省零扰动〕/"pool"=共享池抽 3 买 1 重抽;真实入口 main.gd 显式投 pool) @@ -57,6 +52,8 @@ func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void: _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}) # 件出口(组件交出非委托壳——呈现/测量面取主角账三件与令牌池单点;领域拆分批接口块) @@ -107,7 +104,7 @@ func start_adventure(seed_val: int, params: Dictionary = {}) -> bool: _reset_curio_state() levels_total = db.run.level.level_count() # 表访问收口(关数读 level 表行数) level_index = 1 - _pending_next_level = false + _bp_store._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 @@ -115,7 +112,7 @@ func start_adventure(seed_val: int, params: Dictionary = {}) -> bool: _refresh_bank.set_refresh_y(maxi(0, int(params.get("attune_refresh_y", 0)))) # additive(Y 冒险周期池) _loadout.set_attunement(_attunement) # 装配集注入(活引用——种行面) adventure_inventory.reset() # 冒险级库存重置(新局空库存) - _loadout_seeded = false # 同调行投行延至首关 bind 时点(库存就位后) + _bp_store._loadout_seeded = false # 同调行投行延至首关 bind 时点(库存就位后) adventure_counters = {} # 冒险桶清零(效果引擎 adv_ 计数器宿主——冒险开始归零) # 刷新练习开局注入(refresh_stock_grant 装配即得一次性加仓: # 装配集 kind 分发扫描;只入存量账、临时账不动=V8 契约;resume 不重注入〔存量随断点已在〕) @@ -152,99 +149,50 @@ func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool: return false master_seed = int(bp.get("master_seed", 0)) rng.restore_adventure(master_seed, int(bp.get("rng_counter", 0))) - _resume_ledger(bp, params) - _resume_curio_state(bp) - _resume_params(bp, params) - _resume_counters(bp) + # ---- 断点回放(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._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 Array else [] # A/B 面 rule 键集(续玩参数面同 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) 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(): - _pending_next_level = true + _bp_store._pending_next_level = true _state = State.RUNNING _stop.restore_pending(bp_pending) # 选择停点直回(续玩不重跑关卡——信号经停点件中继) return true if not bp_result.is_empty(): - _pending_next_level = true + _bp_store._pending_next_level = true _stop.restore_resolved(bp_result) # 已兑付不重兑付只重发(置态+重发收编一步——信号面归停点件) _state = State.RUNNING return true - _resume_bp = bp.duplicate(true) + _bp_store._resume_bp = bp.duplicate(true) # 充能读侧以行集为源(键值仅校验读数——回填时点在首关 bind 行集就位后,经 _loadout_on_level_inventory) - _loadout_seeded = false + _bp_store._loadout_seeded = false _loadout.set_attunement(_attunement) adventure_inventory.reset() _state = State.RUNNING return _start_current_level() -# 续玩账面回填(生命/特性/钱包/加算区/已购档/钥石/逐杀明细/真视——主角账三件片段各还原各键; -# bonus 子键 clamp 类夹 ≥0;日记在档=推导比对载入、缺键=旧档汇总直载) -func _resume_ledger(bp: Dictionary, params: Dictionary) -> void: - _stats.restore_fragment(bp, params) - _wallet.restore_fragment(bp, params) - _journal.restore_fragment(bp, params) - summary = {} - levels_total = db.run.level.level_count() # 表访问收口(关数读 level 表行数) - level_index = int(bp.get("level_index", 1)) - _pending_next_level = false - - -# 续玩三选一态回填(池与件计数归件、停点态归本类;dual_merge/keep_on_null 策略见 BREAKPOINT_FIELDS) -func _resume_curio_state(bp: Dictionary) -> void: - _reset_curio_state() - var bp_taken: Dictionary[Variant, Variant] = bp.get("bonus_taken", {}) as Dictionary - for id in _curio.taken.keys(): - _curio.taken[id] = maxi(0, int(bp_taken.get(id, 0))) - for id in bp_taken.keys(): - _curio.taken[String(id)] = maxi(0, int(bp_taken.get(id, 0))) # 身份维键(relic:)随快照还原 - # 令牌池余库还原(TP0 v2.2——快照缺键 = 旧版断点,保持满库重建态:旧档只读一版周期的防御口径; - # relic 令牌 feat_id 随还原) - var bp_deck = _bp_read("bonus_deck", bp) - if bp_deck is Array: - var restored: Array[Variant] = [] - for t in (bp_deck as Array): - var tok: Dictionary[Variant, Variant] = t as Dictionary - var rtok := {"kind": String(tok.get("kind", "")), "tier": String(tok.get("tier", ""))} - if String(rtok["kind"]) == "relic": - rtok["feat_id"] = String(tok.get("feat_id", "")) # relic 令牌 feat_id 随还原 - restored.append(rtok) - _curio.deck = restored - # 特性持有集还原(缺键 = 旧版断点防御空集——主角数值账字段单点) - _stats.feats = [] - for r in (bp.get("relics", []) as Array): - _stats.feats.append(String(r)) - -# 续玩参数面回填(同 start_adventure 参数面;生命账本越界钳制在此落——clamp 类) -func _resume_params(bp: Dictionary, params: Dictionary) -> void: - _stats.rebase_override(params.get("hero_override", {})) # 续玩参数面同 start_adventure(规范化归数值账) - _stats.attack_type = String(params.get("attack_type", "")) # 续玩参数面同 start_adventure(账号层选中态随 resume 重注入) - _stats.armor_type = String(params.get("armor_type", "")) - # 生命账本越界钳制(T17——spawn 守卫 EC16 免疫):断点可能带出高于生效上限的账本值 - #(如调试浮窗调高期写出的值,实例:hero_hp=28 > 基础 26)——恢复后钳到生效上限, - # 否则首战 spawn「hero_hp 入参越界」拒收 → 「敌人系统 spawn 被拒」fatal。钳制式单点归 - # 数值账 clamp_hp_to_resume_cap(保守口径不含加算区——与原钳制式逐字同)。 - _stats.clamp_hp_to_resume_cap(int(db.combat.hero.max_hp)) - _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) - _stop.restore_fragment(bp, params) # 停点件片段回填(凭证计数) - _refresh_bank.restore_fragment(bp, params) # 刷新账片段回填(刷新存量/Y 池——Y 缺键回退参数面 params_fallback 类) - _tree_rules = params.get("tree_rules", []) if params.get("tree_rules", []) is Array else [] # A/B 面 rule 键集(续玩参数面同 start_adventure 同式,消费面 set_tree_rules 下传) - - -# 续玩计数器回填(冒险域计数——凭证计数/刷新存量/Y 池随停点件片段回填) -func _resume_counters(bp: Dictionary) -> void: - # adventure 域计数器回填(缺键 = 旧版断点防御空集;_reset_curio_state 已清零在先——桶归宿主字典) - var bp_counters = _bp_read("adventure_counters", bp) - if bp_counters is Dictionary: - adventure_counters = (bp_counters as Dictionary).duplicate(true) - - func _reset_curio_state() -> void: _curio.reset() # 池与件获取计数(池半归件;停点态归停点件下行清理) _stop.reset_adventure_state() - _loadout_seeded = false + _bp_store._loadout_seeded = false _stats.feats = [] adventure_counters = {} @@ -263,7 +211,7 @@ func effective_max_hp() -> int: # 断点承载注入(组合根冒险开始时调用;模拟/冒烟不注入 = 断点面整体静默) func set_save_manager(sm: SaveManagerCore) -> void: - _save_manager = sm + _bp_store._save_manager = sm # 当前关卡行进器读面(呈现层/冒烟用——每关新建实例,调用方须在 level_started 后重取) @@ -278,13 +226,13 @@ func apply_rest(pct: int, hp: int, max_hp: int) -> int: # 大奖励确认 → 进下一关(仅兑付后受理——零自动推进的冒险级停点;三选一未兑付拒绝推进) func continue_to_next_level() -> bool: - if not _pending_next_level: + 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 - _pending_next_level = false + _bp_store._pending_next_level = false _stop.clear_resolved() level_index += 1 return _start_current_level() @@ -343,9 +291,9 @@ func _loadout_on_level_inventory() -> void: var inv = _runner.treasure_inventory if inv == null: return - if not _loadout_seeded: - _loadout_seeded = true - var bp_charges: Variant = _bp_read("attunement_charges", _resume_bp, null) if not _resume_bp.is_empty() else null + if not _bp_store._loadout_seeded: + _bp_store._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: @@ -368,8 +316,8 @@ func _loadout_on_level_inventory() -> void: # apply_adventure_summary 逐关卡完成关次置位〕;末关 Boss 照常入账;明细随摘要 additive 键供结算面归因) func _boss_keystone_drop(li: int) -> void: var first := true - if _save_manager != null: - first = not _save_manager.meta_state.career_state().boss_first_kill.has(str(li)) + if _bp_store._save_manager != null: + first = not _bp_store._save_manager.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}) # 逐杀明细+累计单写原语 @@ -419,9 +367,9 @@ func _start_current_level() -> bool: # 断点续玩分支:图由主种子+参数档重生成同图(产图与计数器号位无关,停点零重抽语义随之消解), # resume_state 带停点句柄与已到序——重建到停点后经 present_resumed_choice 呈现(信号顺序契约同入口)。 var resume_state: Dictionary[Variant, Variant] = {} - if not _resume_bp.is_empty(): - resume_state = {"node_id": int(_bp_read("node_id", _resume_bp, -1)), - "visited_ids": _bp_read("visited_ids", _resume_bp, []) as Array} + 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(呈现层在 @@ -431,7 +379,7 @@ func _start_current_level() -> bool: if ok: _emit_level_started() _present_level_entry(resume_state) - _resume_bp = {} + _bp_store._resume_bp = {} return ok @@ -455,7 +403,7 @@ 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_read("aux_items", _resume_bp, []) as Array) + _runner.restore_treasure_items(_bp_store._bp_read("aux_items", _bp_store._resume_bp, []) as Array) _runner.present_resumed_choice() @@ -464,7 +412,7 @@ func _on_level_finished(payload: Dictionary) -> void: if state != "running": push_error("[冒险结构] 重复关卡结束宣布拒绝——state=%s(账本与摘要零变化)" % state) return - _last_level_payload = payload.duplicate(true) + _bp_store._last_level_payload = payload.duplicate(true) var result := String(payload.get("result", "")) if result == "defeat": _stats.hero_hp = 0 # 倒下记 0(生命账本战后回写口径) @@ -473,7 +421,7 @@ func _on_level_finished(payload: Dictionary) -> void: if result != "victory": push_error("[冒险结构] 关卡结束结果出域: %s" % result) return - _last_treasure_cleared = int(payload.get("aux_cleared", 0)) # additive(末关 summary 取数面) + _bp_store._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: @@ -481,7 +429,7 @@ func _on_level_finished(payload: Dictionary) -> void: #(信号同步派发——处理器内立即确认时 _pending_next_level 必须已就位); # 停点件内部分回血先行 → 加权洗牌掷 3 → 三选一停点(顺序即契约——回血按当刻账本与上限结算, # 选择停点零自动推进)。载荷_base 随附钱包读数(停点件零钱包依赖——GP 件入账回调仍走 grant_curio_gp) - _pending_next_level = true + _bp_store._pending_next_level = true var stop_base: Dictionary[Variant, Variant] = payload.duplicate(true) stop_base["gp"] = _wallet.gp _stop.open_level_clear(stop_base) @@ -499,7 +447,7 @@ func _settle(result: String) -> void: "stopped_at": 0 if result == "completed" else level_index, "keystone_total": _journal.keystone_gained(), "master_seed": master_seed, - "aux_cleared_last": _last_treasure_cleared, # additive 扩签(末关清空反馈;非末关走 bonus 载荷) + "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 枯竭的终局面) @@ -507,19 +455,19 @@ func _settle(result: String) -> void: } # 败退明细(数值验证工具取数面——tools/balance_verifier.py 止步节点分布数据源; # 仅失败局附加,键缺省 = 未败退,既有五件事消费方零感知) - if result == "failed" and not _last_level_payload.is_empty(): + if result == "failed" and not _bp_store._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", "")), + "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() # 三选一停点态收束清零(结算受理只算一次语义——重复宣布零变化) - if _save_manager != null: - _save_manager.clear_breakpoint() # 三结局清断点(退出冒险=放弃流程+清除断点) - _save_manager.write_to_disk() + if _bp_store._save_manager != null: + _bp_store._save_manager.clear_breakpoint() # 三结局清断点(退出冒险=放弃流程+清除断点) + _bp_store._save_manager.write_to_disk() _state = State.SETTLING adventure_finished.emit(summary) _state = State.IDLE @@ -530,131 +478,7 @@ func _on_level_choices_presented(_candidates: Array) -> void: _snapshot_breakpoint() -# ---- 断点快照(字段登记单源:写侧拼装与读侧回退策略同登记一表)---- -# 键序即快照键序(27 顶层键——写侧/读侧/键集断言三面同源)。读侧回退策略词表: -# plain 无特殊回退面(缺键取代码内缺省)/clamp 数值夹 ≥0/keep_on_null 缺键或类型不符保持既有态 -# dual_merge 双向合并(bonus_taken)/params_fallback 缺键回退参数面(attune_refresh_y) -# token_migration 旧版候选形态迁移(bonus_pending.candidates) -# 注入缺省 null = 整面静默(模拟/冒烟零扰动)。 -const BREAKPOINT_FIELDS := { - "level_index": {"strategy": "plain"}, - "node_id": {"strategy": "plain"}, - "hero_hp": {"strategy": "clamp"}, - "bonus": {"strategy": "clamp"}, - "gp": {"strategy": "plain"}, - "shop_purchased": {"strategy": "plain"}, - "shop_shelf": {"strategy": "plain"}, - "shop_visit_buys": {"strategy": "plain"}, - "attunement_charges": {"strategy": "keep_on_null"}, - "graph_gen_v2": {"strategy": "plain"}, - "master_seed": {"strategy": "plain"}, - "rng_counter": {"strategy": "plain"}, - "keystone": {"strategy": "plain"}, - "visited_ids": {"strategy": "plain"}, - "aux_items": {"strategy": "plain"}, - "bonus_taken": {"strategy": "dual_merge"}, - "bonus_deck": {"strategy": "keep_on_null"}, - "relics": {"strategy": "plain"}, - "bonus_pending": {"strategy": "token_migration"}, - "feat_ticket_pity": {"strategy": "plain"}, - "feat_ticket_sold": {"strategy": "plain"}, - "adventure_counters": {"strategy": "keep_on_null"}, - "vision_counter_used": {"strategy": "clamp"}, - "refresh_stock": {"strategy": "clamp"}, - "attune_refresh_y": {"strategy": "params_fallback"}, - "bonus_result": {"strategy": "plain"}, - "keystone_drops": {"strategy": "plain"}, - "gain_journal": {"strategy": "plain"}, # 获得日记(拆件批 T4 扩列——唯一声明的断点扩展;旧档缺键=汇总直载跳过比对) -} - - +# ---- 断点快照委托(组装与读侧策略机组归 breakpoint_store 件——六成员+27 键登记单源随件; +# 本壳一行转发:CurioStop 构造注入的 Callable 目标与本类 _on_level_choices_presented 调用点零改)---- func _snapshot_breakpoint() -> void: - if _save_manager == null or state != "running": - return - var parts: Dictionary[Variant, Variant] = _bp_parts() - var bp: Dictionary[Variant, Variant] = {} - for key in BREAKPOINT_FIELDS.keys(): - bp[key] = parts[key] - _save_manager.set_breakpoint(bp) - _save_manager.write_to_disk() - - -# 断点写侧取值(键集以 BREAKPOINT_FIELDS 为准——未登记键即取值越界,fail-fast) -func _bp_parts() -> Dictionary[Variant, Variant]: - var stats_frag: Dictionary[Variant, Variant] = _stats.breakpoint_fragment() - var wallet_frag: Dictionary[Variant, Variant] = _wallet.breakpoint_fragment() - var journal_frag: Dictionary[Variant, Variant] = _journal.breakpoint_fragment() - var bank_frag: Dictionary[Variant, Variant] = _refresh_bank.breakpoint_fragment() - var stop_frag: Dictionary[Variant, Variant] = _stop.breakpoint_fragment() - var level_frag: Dictionary[Variant, Variant] = _runner.breakpoint_fragment() if _runner != null else { - "node_id": -1, "visited_ids": [] as Array, "aux_items": [] as Array, - "shop_shelf": [] as Array, "shop_visit_buys": 0} # 三选一停点续玩分支 _runner 为 null——节点域按缺省落档 - # 该两态的续玩只消费 bonus_* 附随件(选择停点/已兑付停点快照) - var parts: Dictionary[Variant, Variant] = { - "level_index": level_index, - "node_id": int(level_frag.get("node_id", -1)), - "hero_hp": int(stats_frag.get("hero_hp", 0)), - "bonus": journal_frag.get("bonus", {}), - "gp": int(wallet_frag.get("gp", 0)), - "shop_purchased": journal_frag.get("shop_purchased", {}), - "shop_shelf": level_frag.get("shop_shelf", []), # 货架入断点(离开店/非店停点 = 空架合法态) - "shop_visit_buys": int(level_frag.get("shop_visit_buys", 0)), # 本次进店已购次数(A 链定价输入) - "attunement_charges": (_loadout.breakpoint_fragment(_runner.treasure_inventory) if _runner != null and _runner.treasure_inventory != null else {"attunement_charges": {}}).get("attunement_charges", {}), # 同调主动件充能余量随断点(行派生——save v4 域键名策略不变) - "graph_gen_v2": 1, # 生成图换代代差标记(缺键 = 换代前旧断点 → resume 显式拒绝) - "master_seed": master_seed, - "rng_counter": rng.counter(), - "keystone": int(journal_frag.get("keystone", 0)), - "visited_ids": level_frag.get("visited_ids", []), - "aux_items": level_frag.get("aux_items", []), - "bonus_taken": _curio.taken.duplicate(true), # 三选一件获取次数(kind 维度派生账本——断点附随件与读数承载) - "bonus_deck": _curio.deck.duplicate(true), # 令牌池余库(TP0 v2.2——池消耗即上限,续玩余库逐枚还原) - "relics": stats_frag.get("relics", []), # 特性持有集(局内随机渠道获得态续玩防丢失) - "bonus_pending": stop_frag.get("bonus_pending", {}), # 三选一停点态(空 = 无停点——选择停点可中断契约) - "feat_ticket_pity": int(stop_frag.get("feat_ticket_pity", 0)), # T5 专长凭证保底计数(扩列向后兼容——旧档缺键缺省 0) - "feat_ticket_sold": int(stop_frag.get("feat_ticket_sold", 0)), # T5 专长凭证限购计数(同上) - "adventure_counters": adventure_counters.duplicate(true), # adventure 域计数器随断点(续玩跨战账不丢——桶归宿主字典) - "vision_counter_used": int(journal_frag.get("vision_counter_used", 0)), # 真视使用计数随断点(续玩跨战账不丢) - "refresh_stock": int(bank_frag.get("refresh_stock", 0)), # 刷新存量账随断点 - "attune_refresh_y": int(bank_frag.get("attune_refresh_y", 0)), # Y 冒险周期池余量随断点(扩列向后兼容——旧档缺键回退参数面) - "bonus_result": stop_frag.get("bonus_result", {}), # 已兑付待推进态(空 = 无——中断续玩不重兑付) - "keystone_drops": journal_frag.get("keystone_drops", []), # 逐杀掉落明细(摘要归因续接) - "gain_journal": journal_frag.get("gain_journal", {}), # 获得日记(唯一声明的断点扩列——日记为现行事实源) - } - # 拼盘完整性自断言(fail-fast——片段并集与登记键集不符即中止落盘) - var stray: Array[Variant] = [] - for key in parts.keys(): - if not BREAKPOINT_FIELDS.has(key): - stray.append(key) - if not stray.is_empty(): - push_error("[冒险结构] 断点拼盘含未登记键: %s(fail-fast——片段与登记单源失同步)" % str(stray)) - return parts - - - -# 读侧单点(BREAKPOINT_FIELDS 策略标签分发;未登记键 fail-fast 不静默按 plain 兜)。 -# 二次消费面(_start_current_level 经 _resume_bp 取 node_id/visited_ids/aux_items)同走此口。 -func _bp_read(key: String, bp: Dictionary, fallback: Variant = null) -> Variant: - if not BREAKPOINT_FIELDS.has(key): - push_error("[冒险结构] 断点字段未登记(读侧): %s" % key) - var v: Variant = bp.get(key, fallback) - if _bp_strategy(key) == "keep_on_null" and v != null and not (v is Dictionary or v is Array): - return null # 类型不符——保持既有态(调用方跳过覆盖) - return v - - -# 读侧策略标签(BREAKPOINT_FIELDS 读侧消费面单点) -func _bp_strategy(key: String) -> String: - return String((BREAKPOINT_FIELDS.get(key, {}) as Dictionary).get("strategy", "plain")) - - -# 数值类读侧(clamp 夹 ≥0 / params_fallback 断点缺键回退参数面整额注入 / plain 直取) -func _bp_read_int(key: String, bp: Dictionary, fallback: int, params: Dictionary = {}) -> int: - match _bp_strategy(key): - "clamp": - return maxi(0, int(bp.get(key, fallback))) - "params_fallback": - return maxi(0, int(bp.get(key, int(params.get(key, fallback))))) - _: - return int(bp.get(key, fallback)) - - + _bp_store.snapshot(state, level_index, master_seed, _runner, adventure_counters) diff --git a/game/core/breakpoint_store.gd b/game/core/breakpoint_store.gd new file mode 100644 index 00000000..61b10d21 --- /dev/null +++ b/game/core/breakpoint_store.gd @@ -0,0 +1,218 @@ +## VERTICAL SLICE - NOT FOR PRODUCTION +## 存档断点组装与回放唯一承载(AdventureRunner 拆件——单一变化原因=断点快照键面与回放策略): +## 断点快照拼盘、回放回填、读侧策略分发;BREAKPOINT_FIELDS 27 键登记单源随件(键名/策略标签/键序冻结)。 +## 构造=引用束字典注入(db/rng/主角账三件/curio/stop/bank/loadout——件禁反向持宿主); +## 快照宿主输入(state/level_index/master_seed/runner/adventure_counters)逐调传参; +## resume_adventure 留宿主(壳+宿主侧回填语句——所有权缝分家,语句序逐步保形)。 +class_name BreakpointStore +extends RefCounted + +# ---- 断点快照(字段登记单源:写侧拼装与读侧回退策略同登记一表)---- +# 键序即快照键序(27 顶层键——写侧/读侧/键集断言三面同源)。读侧回退策略词表: +# plain 无特殊回退面(缺键取代码内缺省)/clamp 数值夹 ≥0/keep_on_null 缺键或类型不符保持既有态 +# dual_merge 双向合并(bonus_taken)/params_fallback 缺键回退参数面(attune_refresh_y) +# token_migration 旧版候选形态迁移(bonus_pending.candidates) +# 注入缺省 null = 整面静默(模拟/冒烟零扰动)。 +const BREAKPOINT_FIELDS := { + "level_index": {"strategy": "plain"}, + "node_id": {"strategy": "plain"}, + "hero_hp": {"strategy": "clamp"}, + "bonus": {"strategy": "clamp"}, + "gp": {"strategy": "plain"}, + "shop_purchased": {"strategy": "plain"}, + "shop_shelf": {"strategy": "plain"}, + "shop_visit_buys": {"strategy": "plain"}, + "attunement_charges": {"strategy": "keep_on_null"}, + "graph_gen_v2": {"strategy": "plain"}, + "master_seed": {"strategy": "plain"}, + "rng_counter": {"strategy": "plain"}, + "keystone": {"strategy": "plain"}, + "visited_ids": {"strategy": "plain"}, + "aux_items": {"strategy": "plain"}, + "bonus_taken": {"strategy": "dual_merge"}, + "bonus_deck": {"strategy": "keep_on_null"}, + "relics": {"strategy": "plain"}, + "bonus_pending": {"strategy": "token_migration"}, + "feat_ticket_pity": {"strategy": "plain"}, + "feat_ticket_sold": {"strategy": "plain"}, + "adventure_counters": {"strategy": "keep_on_null"}, + "vision_counter_used": {"strategy": "clamp"}, + "refresh_stock": {"strategy": "clamp"}, + "attune_refresh_y": {"strategy": "params_fallback"}, + "bonus_result": {"strategy": "plain"}, + "keystone_drops": {"strategy": "plain"}, + "gain_journal": {"strategy": "plain"}, # 获得日记(拆件批 T4 扩列——唯一声明的断点扩展;旧档缺键=汇总直载跳过比对) +} + +# 引用束(宿主构造注入——成员名与宿主侧原名一致,迁移函数体逐字) +var db: ConfigDb +var rng: RngServiceCore +var _stats: HeroStats +var _wallet: GpWallet +var _journal: GainJournal +var _curio: CurioDeck = null # 令牌池件(池余库/获取计数随断点) +var _stop: CurioStop = null # 停点件(停点态/凭证计数片段) +var _refresh_bank: RefreshBank = null # 刷新账件(刷新存量/Y 池片段) +var _loadout: AttuneLoadout = null # 同调配装件(充能行集读侧) +# 断点域六成员(自宿主收编——宿主其余读写点经本件) +var _save_manager = null # 断点承载注入(null = 不写断点;模拟/冒烟缺省) +var _resume_bp := {} # 续玩快照暂存(resume_adventure → _start_current_level 单跳传递) +var _pending_next_level := false # 大奖励已兑付、等 continue_to_next_level 确认 +var _last_level_payload := {} # 最近一次关卡结束载荷(失败结算的败退节点明细来源——数值验证取数面) +var _last_treasure_cleared := 0 # additive(最近一次关卡末清空计数,summary 取数面) +var _loadout_seeded := false # 同调行已种标记(每冒险一次——首关 bind 时点投行;行跨关存活后续关零动作) + + +func _init(refs: Dictionary) -> void: + db = refs["db"] + rng = refs["rng"] + _stats = refs["stats"] + _wallet = refs["wallet"] + _journal = refs["journal"] + _curio = refs["curio"] + _stop = refs["stop"] + _refresh_bank = refs["bank"] + _loadout = refs["loadout"] + + +# 断点快照拼盘落盘(宿主 _snapshot_breakpoint 委托目标——宿主输入逐调传参) +func snapshot(state: String, level_index: int, master_seed: int, runner: LevelRunner, counters: Dictionary[Variant, Variant]) -> void: + if _save_manager == null or state != "running": + return + var parts: Dictionary[Variant, Variant] = _bp_parts(level_index, master_seed, runner, counters) + var bp: Dictionary[Variant, Variant] = {} + for key in BREAKPOINT_FIELDS.keys(): + bp[key] = parts[key] + _save_manager.set_breakpoint(bp) + _save_manager.write_to_disk() + + +# 断点写侧取值(键集以 BREAKPOINT_FIELDS 为准——未登记键即取值越界,fail-fast) +func _bp_parts(level_index: int, master_seed: int, runner: LevelRunner, counters: Dictionary[Variant, Variant]) -> Dictionary[Variant, Variant]: + var stats_frag: Dictionary[Variant, Variant] = _stats.breakpoint_fragment() + var wallet_frag: Dictionary[Variant, Variant] = _wallet.breakpoint_fragment() + var journal_frag: Dictionary[Variant, Variant] = _journal.breakpoint_fragment() + var bank_frag: Dictionary[Variant, Variant] = _refresh_bank.breakpoint_fragment() + var stop_frag: Dictionary[Variant, Variant] = _stop.breakpoint_fragment() + var level_frag: Dictionary[Variant, Variant] = runner.breakpoint_fragment() if runner != null else { + "node_id": -1, "visited_ids": [] as Array, "aux_items": [] as Array, + "shop_shelf": [] as Array, "shop_visit_buys": 0} # 三选一停点续玩分支 runner 为 null——节点域按缺省落档 + # 该两态的续玩只消费 bonus_* 附随件(选择停点/已兑付停点快照) + var parts: Dictionary[Variant, Variant] = { + "level_index": level_index, + "node_id": int(level_frag.get("node_id", -1)), + "hero_hp": int(stats_frag.get("hero_hp", 0)), + "bonus": journal_frag.get("bonus", {}), + "gp": int(wallet_frag.get("gp", 0)), + "shop_purchased": journal_frag.get("shop_purchased", {}), + "shop_shelf": level_frag.get("shop_shelf", []), # 货架入断点(离开店/非店停点 = 空架合法态) + "shop_visit_buys": int(level_frag.get("shop_visit_buys", 0)), # 本次进店已购次数(A 链定价输入) + "attunement_charges": (_loadout.breakpoint_fragment(runner.treasure_inventory) if runner != null and runner.treasure_inventory != null else {"attunement_charges": {}}).get("attunement_charges", {}), # 同调主动件充能余量随断点(行派生——save v4 域键名策略不变) + "graph_gen_v2": 1, # 生成图换代代差标记(缺键 = 换代前旧断点 → resume 显式拒绝) + "master_seed": master_seed, + "rng_counter": rng.counter(), + "keystone": int(journal_frag.get("keystone", 0)), + "visited_ids": level_frag.get("visited_ids", []), + "aux_items": level_frag.get("aux_items", []), + "bonus_taken": _curio.taken.duplicate(true), # 三选一件获取次数(kind 维度派生账本——断点附随件与读数承载) + "bonus_deck": _curio.deck.duplicate(true), # 令牌池余库(TP0 v2.2——池消耗即上限,续玩余库逐枚还原) + "relics": stats_frag.get("relics", []), # 特性持有集(局内随机渠道获得态续玩防丢失) + "bonus_pending": stop_frag.get("bonus_pending", {}), # 三选一停点态(空 = 无停点——选择停点可中断契约) + "feat_ticket_pity": int(stop_frag.get("feat_ticket_pity", 0)), # T5 专长凭证保底计数(扩列向后兼容——旧档缺键缺省 0) + "feat_ticket_sold": int(stop_frag.get("feat_ticket_sold", 0)), # T5 专长凭证限购计数(同上) + "adventure_counters": counters.duplicate(true), # adventure 域计数器随断点(续玩跨战账不丢——桶归宿主字典) + "vision_counter_used": int(journal_frag.get("vision_counter_used", 0)), # 真视使用计数随断点(续玩跨战账不丢) + "refresh_stock": int(bank_frag.get("refresh_stock", 0)), # 刷新存量账随断点 + "attune_refresh_y": int(bank_frag.get("attune_refresh_y", 0)), # Y 冒险周期池余量随断点(扩列向后兼容——旧档缺键回退参数面) + "bonus_result": stop_frag.get("bonus_result", {}), # 已兑付待推进态(空 = 无——中断续玩不重兑付) + "keystone_drops": journal_frag.get("keystone_drops", []), # 逐杀掉落明细(摘要归因续接) + "gain_journal": journal_frag.get("gain_journal", {}), # 获得日记(唯一声明的断点扩列——日记为现行事实源) + } + # 拼盘完整性自断言(fail-fast——片段并集与登记键集不符即中止落盘) + var stray: Array[Variant] = [] + for key in parts.keys(): + if not BREAKPOINT_FIELDS.has(key): + stray.append(key) + if not stray.is_empty(): + push_error("[冒险结构] 断点拼盘含未登记键: %s(fail-fast——片段与登记单源失同步)" % str(stray)) + return parts + + +# 续玩账面回填(生命/特性/钱包/加算区/已购档/钥石/逐杀明细/真视——主角账三件片段各还原各键; +# bonus 子键 clamp 类夹 ≥0;日记在档=推导比对载入、缺键=旧档汇总直载) +#(宿主侧账面语句——summary/levels_total/level_index/_pending_next_level——按语句序留宿主壳) +func restore_ledger(bp: Dictionary, params: Dictionary) -> void: + _stats.restore_fragment(bp, params) + _wallet.restore_fragment(bp, params) + _journal.restore_fragment(bp, params) + + +# 续玩三选一态回填(池与件计数归件、停点态归宿主;dual_merge/keep_on_null 策略见 BREAKPOINT_FIELDS) +#(前置清理 _reset_curio_state 留宿主——桶归宿主字典;本函数承接其后的件侧还原语句,序逐步保形) +func restore_curio_state(bp: Dictionary) -> void: + var bp_taken: Dictionary[Variant, Variant] = bp.get("bonus_taken", {}) as Dictionary + for id in _curio.taken.keys(): + _curio.taken[id] = maxi(0, int(bp_taken.get(id, 0))) + for id in bp_taken.keys(): + _curio.taken[String(id)] = maxi(0, int(bp_taken.get(id, 0))) # 身份维键(relic:)随快照还原 + # 令牌池余库还原(TP0 v2.2——快照缺键 = 旧版断点,保持满库重建态:旧档只读一版周期的防御口径; + # relic 令牌 feat_id 随还原) + var bp_deck = _bp_read("bonus_deck", bp) + if bp_deck is Array: + var restored: Array[Variant] = [] + for t in (bp_deck as Array): + var tok: Dictionary[Variant, Variant] = t as Dictionary + var rtok := {"kind": String(tok.get("kind", "")), "tier": String(tok.get("tier", ""))} + if String(rtok["kind"]) == "relic": + rtok["feat_id"] = String(tok.get("feat_id", "")) # relic 令牌 feat_id 随还原 + restored.append(rtok) + _curio.deck = restored + # 特性持有集还原(缺键 = 旧版断点防御空集——主角数值账字段单点) + _stats.feats = [] + for r in (bp.get("relics", []) as Array): + _stats.feats.append(String(r)) + + +# 续玩参数面——数值账规范化与越界钳制(件侧;宿主侧四重绑定与预算注入按语句序夹在两件侧调用之间) +func apply_resume_params(params: Dictionary) -> void: + _stats.rebase_override(params.get("hero_override", {})) # 续玩参数面同 start_adventure(规范化归数值账) + _stats.attack_type = String(params.get("attack_type", "")) # 续玩参数面同 start_adventure(账号层选中态随 resume 重注入) + _stats.armor_type = String(params.get("armor_type", "")) + # 生命账本越界钳制(T17——spawn 守卫 EC16 免疫):断点可能带出高于生效上限的账本值 + #(如调试浮窗调高期写出的值,实例:hero_hp=28 > 基础 26)——恢复后钳到生效上限, + # 否则首战 spawn「hero_hp 入参越界」拒收 → 「敌人系统 spawn 被拒」fatal。钳制式单点归 + # 数值账 clamp_hp_to_resume_cap(保守口径不含加算区——与原钳制式逐字同)。 + _stats.clamp_hp_to_resume_cap(int(db.combat.hero.max_hp)) + + +# 停点件/刷新账片段回填(凭证计数;刷新存量/Y 池——Y 缺键回退参数面 params_fallback 类) +func restore_stop_bank_fragments(bp: Dictionary, params: Dictionary) -> void: + _stop.restore_fragment(bp, params) + _refresh_bank.restore_fragment(bp, params) + + +# 读侧单点(BREAKPOINT_FIELDS 策略标签分发;未登记键 fail-fast 不静默按 plain 兜)。 +# 二次消费面(_start_current_level 经 _resume_bp 取 node_id/visited_ids/aux_items)同走此口。 +func _bp_read(key: String, bp: Dictionary, fallback: Variant = null) -> Variant: + if not BREAKPOINT_FIELDS.has(key): + push_error("[冒险结构] 断点字段未登记(读侧): %s" % key) + var v: Variant = bp.get(key, fallback) + if _bp_strategy(key) == "keep_on_null" and v != null and not (v is Dictionary or v is Array): + return null # 类型不符——保持既有态(调用方跳过覆盖) + return v + + +# 读侧策略标签(BREAKPOINT_FIELDS 读侧消费面单点) +func _bp_strategy(key: String) -> String: + return String((BREAKPOINT_FIELDS.get(key, {}) as Dictionary).get("strategy", "plain")) + + +# 数值类读侧(clamp 夹 ≥0 / params_fallback 断点缺键回退参数面整额注入 / plain 直取) +func _bp_read_int(key: String, bp: Dictionary, fallback: int, params: Dictionary = {}) -> int: + match _bp_strategy(key): + "clamp": + return maxi(0, int(bp.get(key, fallback))) + "params_fallback": + return maxi(0, int(bp.get(key, int(params.get(key, fallback))))) + _: + return int(bp.get(key, fallback)) diff --git a/game/core/breakpoint_store.gd.uid b/game/core/breakpoint_store.gd.uid new file mode 100644 index 00000000..30819d96 --- /dev/null +++ b/game/core/breakpoint_store.gd.uid @@ -0,0 +1 @@ +uid://cw5qdd012h728 diff --git a/game/presentation/views/board_hover.gd b/game/presentation/views/board_hover.gd new file mode 100644 index 00000000..44da7b45 --- /dev/null +++ b/game/presentation/views/board_hover.gd @@ -0,0 +1,130 @@ +## VERTICAL SLICE - NOT FOR PRODUCTION +## 悬停预承诺呈现域件(BoardView 拆件——单一变化原因=悬停反馈语义): +## 三语义光标、目标格边框、BFS 路径预览、chord 双击首遇锚的唯一承载(自 board_view 收编 11 函数族)。 +## 无 class_name(类名缓存面纪律)——经宿主 board_view preload 引用;构造=引用束注入 +## (cells/minesweeper/diggable 共享引用+格命中换算/壳级光标写入/hover_forbidden 与 chord 发射注入 Callable—— +## 输入经宿主三入站口委托,禁反向持宿主引用);_r0 出生展开区成员留宿主。 +extends RefCounted + +const COL_HL_TARGET := UiKit.GOLD # 悬停目标格边框(金) +const COL_SEAL := UiKit.SEAL_BLACK # 封印黑(与宿主 :658 双侧服役——各自 alias UiKit 单源) +const CURSOR_DIG := Input.CURSOR_CROSS # 挖——镐形自绘纹理载体 +const CURSOR_MOVE_SEM := Input.CURSOR_ARROW # 移动——箭头(命名避开 Control.CURSOR_MOVE 原生成员遮蔽) +const CHORD_WINDOW_MS := 350 + +var _cells := {} # 共享引用(宿主容器同对象——rebuild 只 clear 不重绑) +var _minesweeper = null # 排雷玩法 只读查询引用(宿主 rebuild 重绑时同拍回写本成员) +var _diggable := {} # 共享引用(可挖域——呼吸微光作用集同源) +var _cb_cell_at: Callable # 格命中换算(宿主 _cell_at——整图公式单点留宿主) +var _cb_cursor: Callable # 壳级光标写入(宿主 mouse_default_cursor_shape 单点) +var _cb_forbidden: Callable # hover_forbidden 信号发射(首遇 toast 去重归组合根) +var _cb_chord: Callable # chord_attempted 信号发射(chord 肌肉记忆提示锚) +var _hover := Vector2i(-9, -9) +var _last_click := {"cell": Vector2i(-9, -9), "ms": -100000} + + +func _init(bundle: Dictionary) -> void: + _cells = bundle["cells"] + _minesweeper = bundle["minesweeper"] + _diggable = bundle["diggable"] + _cb_cell_at = bundle["cell_at"] + _cb_cursor = bundle["cursor"] + _cb_forbidden = bundle["forbidden"] + _cb_chord = bundle["chord"] + + +func _hover_at(pos: Vector2) -> void: + var c: Vector2i = _cb_cell_at.call(pos) + if c.x < 0: + if _hover.x >= 0: + _on_cell_unhover(_hover) + elif c != _hover: + if _hover.x >= 0: + _on_cell_unhover(_hover) + _on_cell_hover(c) + + +func _on_shell_mouse_exited() -> void: + if _hover.x >= 0: + _on_cell_unhover(_hover) + + +# chord 肌肉记忆首遇锚:同格双左击快开尝试(350ms 窗)——每 run 一次提示由组合根去重 +func _detect_chord(c: Vector2i) -> void: + var now := Time.get_ticks_msec() + if _last_click.cell == c and now - int(_last_click.ms) <= CHORD_WINDOW_MS: + _cb_chord.call(c) + _last_click.ms = -100000 + return + _last_click = {"cell": c, "ms": now} + + +func _on_cell_hover(c: Vector2i) -> void: + _hover = c + _apply_hover_visuals(c) + + +func _on_cell_unhover(c: Vector2i) -> void: + if _hover == c: + _hover = Vector2i(-9, -9) + _clear_hover_visuals(c) + + +func _refresh_hover_after_paint() -> void: + # 状态随 refresh 变化后光标/边框语义需重算(悬停格四格态可能已变) + if _hover.x >= 0 and _cells.has(_hover): + _apply_hover_visuals(_hover) + + +func _apply_hover_visuals(c: Vector2i) -> void: + if _minesweeper == null or not _cells.has(c): + return + _clear_hover_visuals(c) + var u: Node2D = _cells[c] + var revealed: bool = _minesweeper.is_revealed(c) + var seal: bool = _minesweeper.has_seal(c) + var adjacent: bool = _diggable.has(c) + if revealed: + u.face.hl_visible = true + u.face.hl_color = COL_HL_TARGET # 移动目标格边框(金) + _set_cursor_shape(c, CURSOR_MOVE_SEM) + _show_path_preview(c) + elif adjacent: + u.face.hl_visible = true + u.face.hl_color = COL_HL_TARGET # 挖掘目标格边框(相邻封印格同此——左键直接挖) + _set_cursor_shape(c, CURSOR_DIG) + elif seal: + u.face.hl_visible = true + u.face.hl_color = COL_SEAL # 已承诺封印标记(封印黑;无交互可供) + _set_cursor_shape(c, Input.CURSOR_FORBIDDEN) + else: + u.face.hl_visible = true + u.face.hl_color = UiKit.RED_FORBID # 不可及域灰禁框(禁止光标+首遇 toast 双通道) + _set_cursor_shape(c, Input.CURSOR_FORBIDDEN) + _cb_forbidden.call(c) + + +func _clear_hover_visuals(c: Vector2i) -> void: + if not _cells.has(c): + return + _cells[c].face.hl_visible = false + _hide_path_preview() + + +func _set_cursor_shape(c: Vector2i, shape: int) -> void: + _cb_cursor.call(shape) # 壳级光标(悬停格语义决定——单格悬停恒一) + + +func _show_path_preview(c: Vector2i) -> void: + # 合法性指示:悬停已开格 → BFS 路径蓝细线点迹(compute_move_path 只读面) + if _minesweeper == null: + return + var path: Array[Variant] = _minesweeper.compute_move_path(c) + for pc in path: + if _cells.has(pc): + _cells[pc].face.dot_visible = true + + +func _hide_path_preview() -> void: + for c in _cells: + _cells[c].face.dot_visible = false diff --git a/game/presentation/views/board_hover.gd.uid b/game/presentation/views/board_hover.gd.uid new file mode 100644 index 00000000..a797fca9 --- /dev/null +++ b/game/presentation/views/board_hover.gd.uid @@ -0,0 +1 @@ +uid://dojreaughx7tf diff --git a/game/presentation/views/board_view.gd b/game/presentation/views/board_view.gd index ea4502b8..a271ca4b 100644 --- a/game/presentation/views/board_view.gd +++ b/game/presentation/views/board_view.gd @@ -12,6 +12,7 @@ const _CELL_UNIT := preload("res://presentation/views/board_cell_unit.gd") const _BOARD_WORLD := preload("res://presentation/views/board_world.gd") const _TILE_PALETTE := preload("res://presentation/views/tile_palette.gd") const _HERO_SPRITE := preload("res://presentation/views/hero_sprite.gd") +const _BOARD_HOVER := preload("res://presentation/views/board_hover.gd") const COL_BRICK := UiKit.BRICK # 未开——掩尘完整地砖(色板原语 #C9AF83) const COL_FLOOR := UiKit.FLOOR # 已开——裸土 #B39A78(碎裂裸露地面) @@ -20,17 +21,13 @@ const COL_HERO := UiKit.HERO_BLUE # 小人边框——小人蓝 const COL_SEAL := UiKit.SEAL_BLACK # 封印黑 #000000 const COL_GOLD := UiKit.GOLD # 奖励金 #F0A828 const COL_CONTACT := UiKit.CONTACT_GLOW # 接触带微光基色(蓝族浅一档·近白蓝——§3.2 v2.8) -const COL_HL_TARGET := UiKit.GOLD # 悬停目标格边框(金) const REWARD_BADGE := { # 已开格格面资源身份 icon(cell_type 九型键) "attack": "攻", "defense": "守", "heal": "医", "blast": "爆", "weaken": "弱", "pierce": "穿", "magic": "魔", "action": "行", # 行 = 行动点型(ap_up/ap_drain 两行) "gp": "金", # 金 = GP 型(gp_gain/gp_loss 两行;金色系 ~40° 相位,禁入带紫赤 300–345° 外,取色 UiKit.GOLD 徽标色单点) } const BADGE_PLACEHOLDER := "?" # 未知图标键占位纹样(HUD框架:禁静默空图标) -const CURSOR_DIG := Input.CURSOR_CROSS # 挖——镐形自绘纹理载体 const CURSOR_SEAL := Input.CURSOR_POINTING_HAND # 封印——封印形自绘纹理载体 -const CURSOR_MOVE_SEM := Input.CURSOR_ARROW # 移动——箭头(命名避开 Control.CURSOR_MOVE 原生成员遮蔽) -const CHORD_WINDOW_MS := 350 const SEP := 2 # 格间距(格尺寸公式与命中换算共用) # 棋盘立体参数 @@ -67,8 +64,6 @@ var _contact_unit: Node2D = null # 接触带承载单元(=英雄格 CellUnit var _install_tw: Tween # 入场淡入 tween(rebuild 重入先杀) var _ripple_tws: Array[Tween] = [] # R0 涟漪逐格 tween(rebuild 重入全杀) var _dig_glow := false # 挖掘期才允许唯一在动元素(art-bible §2.1) -var _hover := Vector2i(-9, -9) -var _last_click := {"cell": Vector2i(-9, -9), "ms": -100000} var _r0 := {} # 出生展开区 R₀(地图战斗资源 mapping 资格域 = S∖R₀——徽章查询跳过,防 E19 诊断噪音) var _desat_mat: ShaderMaterial var _desat_tween: Tween @@ -77,6 +72,7 @@ var _cell := 46.0 # 当前格边长(set_cell_area 按容器尺寸重 # ── 拆出件(状态单点在件内)── var _palette = _TILE_PALETTE.new() # 瓦片件:装载缓存+查表+明度/墨族静态算法 var _hero_sprite = _HERO_SPRITE.new() # 英雄精灵件:_init 挂为本件子级(tween 需在树上) +var _hover_comp: _BOARD_HOVER # 悬停预承诺呈现域件:三语义光标/目标格边框/路径预览/chord 首遇锚(_init 建为子件——HeroSprite 挂载先例) # ── 件成员同名转发(探针直读面零改动:读 _palette.seal_tex、读写 _hero_sprite.tex_idle;其余为随迁状态单点再导出)── # ---- 视野迷雾(纯呈现层;现行事实源=HUD框架「视野迷雾」节 v0.13,渲染规格=art-bible §6.5 v3.5)---- @@ -120,6 +116,25 @@ func _init() -> void: _hero_sprite.tex_idle = UiKit.load_tex(UiKit.TEX_HERO_IDLE) _hero_sprite.tex_walk = UiKit.load_tex(UiKit.TEX_HERO_WALK) _palette.load_board_tiles() # 高墙/地面瓦片整批守卫装载(缺位静默回退——英雄同款纪律,禁刷屏) + _hover_comp = _BOARD_HOVER.new({ + "cells": _cells, "minesweeper": _minesweeper, "diggable": _diggable, + "cell_at": _cell_at, "cursor": _set_shell_cursor, + "forbidden": _emit_hover_forbidden, "chord": _emit_chord_attempted, + }) + + +# ---- 悬停域注入面(件回调宿主的壳级单点——Callable 目标)---- + +func _set_shell_cursor(shape: int) -> void: + mouse_default_cursor_shape = shape # 壳级光标(悬停格语义决定——单格悬停恒一) + + +func _emit_hover_forbidden(c: Vector2i) -> void: + hover_forbidden.emit(c) ## 非相邻未开格悬停(首遇 toast 归组合根去重) + + +func _emit_chord_attempted(c: Vector2i) -> void: + chord_attempted.emit(c) ## 双击快开尝试(chord 肌肉记忆提示锚) func board() -> BoardData: @@ -128,7 +143,7 @@ func board() -> BoardData: func _ready() -> void: _apply_custom_cursors() - mouse_exited.connect(_on_shell_mouse_exited) + mouse_exited.connect(_hover_comp._on_shell_mouse_exited) # 结算期/道具窗口态:D 区去饱和 + 金橙偏移锁输入(明度不变——art-bible §2); @@ -224,6 +239,7 @@ func _refresh_slab() -> void: func rebuild(board: BoardData, minesweeper: MinesweeperRules) -> void: _board = board _minesweeper = minesweeper + _hover_comp._minesweeper = _minesweeper # 件内只读引用随宿主重绑同拍回写(每战新排雷玩法对象) _palette.bind_board(_board, _minesweeper) # 查表族棋盘态随板同步(换板后首拍 refresh 前置) for child in _world.get_children(): child.queue_free() @@ -234,7 +250,7 @@ func rebuild(board: BoardData, minesweeper: MinesweeperRules) -> void: _contact_unit = null _hero_sprite.last = Vector2i(-99, -99) _r0.clear() - _hover = Vector2i(-9, -9) + _hover_comp._hover = Vector2i(-9, -9) for c in _board.compute_r0(): _r0[c] = true # 瓦片套随板图型切换(board_gen 词表 v1.1 dungeon/underdark/deep_underdark; @@ -283,7 +299,7 @@ func refresh() -> void: for y in _board.height: for x in _board.width: _paint_cell(Vector2i(x, y), hero) - _refresh_hover_after_paint() + _hover_comp._refresh_hover_after_paint() _apply_contact_bands() _update_hero(hero) # 英雄精灵 diff 驱动 @@ -433,33 +449,18 @@ func _update_world_size() -> void: # 真实输入路径(Node2D 迁移批 Decision 3):壳层 _gui_input → 整图公式换算 → 复用既有 hover/点选/chord 出口 +#(悬停族已拆 board_hover 件——本壳留三入站口委托) func _gui_input(ev: InputEvent) -> void: if _minesweeper == null: return if ev is InputEventMouseMotion: - _hover_at(ev.position) + _hover_comp._hover_at(ev.position) elif ev is InputEventMouseButton and ev.pressed: var c := _cell_at(ev.position) if c.x >= 0: _on_cell_input(c, ev) -func _hover_at(pos: Vector2) -> void: - var c := _cell_at(pos) - if c.x < 0: - if _hover.x >= 0: - _on_cell_unhover(_hover) - elif c != _hover: - if _hover.x >= 0: - _on_cell_unhover(_hover) - _on_cell_hover(c) - - -func _on_shell_mouse_exited() -> void: - if _hover.x >= 0: - _on_cell_unhover(_hover) - - func _on_cell_input(c: Vector2i, ev: InputEvent) -> void: if _minesweeper == null or not (ev is InputEventMouseButton and ev.pressed): return @@ -468,7 +469,7 @@ func _on_cell_input(c: Vector2i, ev: InputEvent) -> void: action_requested.emit(c, "move") else: action_requested.emit(c, "dig") - _detect_chord(c) + _hover_comp._detect_chord(c) elif ev.button_index == MOUSE_BUTTON_RIGHT: if not _minesweeper.has_seal(c): action_requested.emit(c, "seal_place") @@ -476,89 +477,6 @@ func _on_cell_input(c: Vector2i, ev: InputEvent) -> void: # 左键相邻封印格直接挖(规则引擎放行)。 -# chord 肌肉记忆首遇锚:同格双左击快开尝试(350ms 窗)——每 run 一次提示由组合根去重 -func _detect_chord(c: Vector2i) -> void: - var now := Time.get_ticks_msec() - if _last_click.cell == c and now - int(_last_click.ms) <= CHORD_WINDOW_MS: - chord_attempted.emit(c) - _last_click.ms = -100000 - return - _last_click = {"cell": c, "ms": now} - - -# ---- 悬停预承诺(pattern 4):三语义光标 + 目标格边框 + BFS 路径预览 ---- - -func _on_cell_hover(c: Vector2i) -> void: - _hover = c - _apply_hover_visuals(c) - - -func _on_cell_unhover(c: Vector2i) -> void: - if _hover == c: - _hover = Vector2i(-9, -9) - _clear_hover_visuals(c) - - -func _refresh_hover_after_paint() -> void: - # 状态随 refresh 变化后光标/边框语义需重算(悬停格四格态可能已变) - if _hover.x >= 0 and _cells.has(_hover): - _apply_hover_visuals(_hover) - - -func _apply_hover_visuals(c: Vector2i) -> void: - if _minesweeper == null or not _cells.has(c): - return - _clear_hover_visuals(c) - var u: Node2D = _cells[c] - var revealed: bool = _minesweeper.is_revealed(c) - var seal: bool = _minesweeper.has_seal(c) - var adjacent: bool = _diggable.has(c) - if revealed: - u.face.hl_visible = true - u.face.hl_color = COL_HL_TARGET # 移动目标格边框(金) - _set_cursor_shape(c, CURSOR_MOVE_SEM) - _show_path_preview(c) - elif adjacent: - u.face.hl_visible = true - u.face.hl_color = COL_HL_TARGET # 挖掘目标格边框(相邻封印格同此——左键直接挖) - _set_cursor_shape(c, CURSOR_DIG) - elif seal: - u.face.hl_visible = true - u.face.hl_color = COL_SEAL # 已承诺封印标记(封印黑;无交互可供) - _set_cursor_shape(c, Input.CURSOR_FORBIDDEN) - else: - u.face.hl_visible = true - u.face.hl_color = UiKit.RED_FORBID # 不可及域灰禁框(禁止光标+首遇 toast 双通道) - _set_cursor_shape(c, Input.CURSOR_FORBIDDEN) - hover_forbidden.emit(c) - - -func _clear_hover_visuals(c: Vector2i) -> void: - if not _cells.has(c): - return - _cells[c].face.hl_visible = false - _hide_path_preview() - - -func _set_cursor_shape(c: Vector2i, shape: int) -> void: - mouse_default_cursor_shape = shape # 壳级光标(悬停格语义决定——单格悬停恒一) - - -func _show_path_preview(c: Vector2i) -> void: - # 合法性指示:悬停已开格 → BFS 路径蓝细线点迹(compute_move_path 只读面) - if _minesweeper == null: - return - var path: Array[Variant] = _minesweeper.compute_move_path(c) - for pc in path: - if _cells.has(pc): - _cells[pc].face.dot_visible = true - - -func _hide_path_preview() -> void: - for c in _cells: - _cells[c].face.dot_visible = false - - # ---- 奖励徽章(已开格身份 icon——封印之下、纹带之上、不进数字区) ---- func _update_badge(c: Vector2i) -> String: @@ -898,9 +816,9 @@ func diggable_probe() -> Dictionary: return _diggable.duplicate() -# 悬停格只读出口(case① 悬停到达断言消费面) +# 悬停格只读出口(case① 悬停到达断言消费面——读件投影,返回语义零变化) func hover_probe() -> Vector2i: - return _hover + return _hover_comp._hover # 迷雾参数只读快照(case⑮ 参数漂移断言消费面) diff --git a/game/tests/unit/core/breakpoint_resume_test.gd b/game/tests/unit/core/breakpoint_resume_test.gd index 14e0b478..9de8b519 100644 --- a/game/tests/unit/core/breakpoint_resume_test.gd +++ b/game/tests/unit/core/breakpoint_resume_test.gd @@ -8,6 +8,7 @@ extends SceneTree const SaveManagerScript := preload("res://core/save_manager.gd") const AdventureRunnerScript := preload("res://core/adventure_runner.gd") +const BreakpointStoreScript := preload("res://core/breakpoint_store.gd") const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:check/汇总退出样板单点(静默收集通道) var _bp_path := "user://test_breakpoint_resume.json" @@ -182,7 +183,7 @@ func _init() -> void: "P7 同种子重开局同图(开局入口复现承载)〔v7〕") # ---- Part 8:断点字段登记单源(登记键集 == 写侧实拼字典键集;键名增删即红)---- - var fields: Dictionary = AdventureRunnerScript.BREAKPOINT_FIELDS + var fields: Dictionary = BreakpointStoreScript.BREAKPOINT_FIELDS # 断点域拆件改址——登记单源随件(判据零变化) var reg_keys: Array = fields.keys() reg_keys.sort() var bp_keys: Array = bp_j.keys()