## CombatManager — 游戏循环状态机(属于 CombatScene 节点) ## 状态:BATTLE → SETTLEMENT → SHOP → BATTLE… ## 权威来源:development_plan.md S2 CombatManager extends Node enum GameState { INIT = 0, BATTLE = 1, SETTLEMENT = 2, SHOP = 3, GAME_OVER = 4, } var state: int = GameState.INIT ## 玩家法杯法术列表(已安装到 Core 的 ID;"" = 空槽,供 MATRIX/CIRCUIT 位置化布局) var _deck_spell_ids: Array = ["action_spark_bolt"] var _equipped_core: CoreDefinition = null var _equipped_compiled: CompiledDeck = null ## 可切换的 Core 名册(商店内 Tab 循环切换;S5 高级 Core 实际可玩入口) const _CORE_ROSTER: Array = ["wand_basic", "wand_fast", "wand_memory", "matrix_board", "circuit_fork"] var _core_idx: int = 0 ## 备牌区:拥有但未装入插槽的法术 id(背包 UI 在此与插槽间移动) var _bench: Array = [] ## 本局开始时刻(秒),用于排行榜评分的 elapsed_sec var _run_start_time: float = 0.0 ## 商店 UI 回调(由场景设置) var _on_shop_closed_cb: Callable = Callable() func _ready() -> void: EventBus.subscribe(EventID.WAVE_COMPLETE, _on_wave_complete) EventBus.subscribe(EventID.PLAYER_DIED, _on_player_died) ProfileManager.set_wand_provider(self) # 法杖状态纳入 A/B 存档(ADR-A2) ## 开始新局 func start_game() -> void: PlayerStats.reset_for_run() WaveManager.reset() BulletManager.reset() EnemyManager.reset() StatusManager.reset() DpsTracker.reset() VFXManager.reset() ZoneManager.reset() MinionManager.reset() SpatialGrid.clear() _core_idx = 0 _equipped_core = WandPreset.make_core_by_id("wand_basic") _deck_spell_ids = ["action_spark_bolt"] _bench.clear() _run_start_time = Time.get_ticks_msec() / 1000.0 _rebuild_wand() _transition_to_battle(1) ## 继续现有进度恢复 func resume_game() -> void: var saved: Dictionary = ProfileManager.load_run() if saved.is_empty(): start_game() return ProfileManager.apply_run(saved) _rebuild_wand() var wave: int = max(1, WaveManager.current_wave) _transition_to_battle(wave) func _transition_to_battle(wave_num: int) -> void: var prev: String = GameState.keys()[state] if state < GameState.size() else "UNKNOWN" state = GameState.BATTLE SubPayloadRegistry.lock_for_battle() # P6-N10: 战斗期间锁定,防止竞争条件 PlayerManager.equip_wand(_equipped_core, _equipped_compiled) WaveManager.start_wave(wave_num) EventBus.emit(EventID.GAME_STATE_CHANGED, {"from": prev, "to": "BATTLE"}) func _on_wave_complete(payload: Dictionary) -> void: var wave: int = int(payload.get("wave", WaveManager.current_wave)) state = GameState.SETTLEMENT ProfileManager.save_run() EventBus.emit(EventID.GAME_STATE_CHANGED, {"from": "BATTLE", "to": "SETTLEMENT"}) # 厂他延迟进入商店(等待结算动画, S2 直接进入) call_deferred("_open_shop", wave) func _open_shop(wave: int) -> void: state = GameState.SHOP ShopManager.open_shop() EventBus.emit(EventID.GAME_STATE_CHANGED, {"from": "SETTLEMENT", "to": "SHOP"}) if not _on_shop_closed_cb.is_null(): _on_shop_closed_cb.call(wave) ## 外部调用:购买法术并安装到法杯 ## LINEAR:MODIFIER 插首个 ACTION 前、ACTION 追加尾部(Noita 风格) ## MATRIX/CIRCUIT:位置化——填入首个空槽("") 以保持拓扑布局 func install_spell(spell: SpellNode) -> void: if spell == null: return var max_slots: int = _equipped_core.slot_count if _equipped_core else 5 if _is_positional_core(): var placed: bool = false for i in _deck_spell_ids.size(): if String(_deck_spell_ids[i]) == "": _deck_spell_ids[i] = spell.id placed = true break if not placed: if _deck_spell_ids.size() < max_slots: _deck_spell_ids.append(spell.id) else: _bench.append(spell.id) # 满了进备牌区,玩家可在背包内重排 elif _deck_spell_ids.size() >= max_slots: _bench.append(spell.id) # 满了进备牌区 elif spell.type == SpellNode.SpellType.MODIFIER: var insert_pos: int = 0 for i in _deck_spell_ids.size(): var sn: SpellNode = SpellRegistry.get_spell(_deck_spell_ids[i]) if sn and sn.type == SpellNode.SpellType.ACTION: insert_pos = i break insert_pos = i + 1 _deck_spell_ids.insert(insert_pos, spell.id) else: _deck_spell_ids.append(spell.id) _rebuild_wand() ProfileManager.mark_dirty() func _is_positional_core() -> bool: return _equipped_core != null and _equipped_core.topology != CoreDefinition.Topology.LINEAR ## 切换装备的 Core(商店内调用);载入该 Core 的演示默认 Deck 并重编译 func switch_core() -> void: _core_idx = (_core_idx + 1) % _CORE_ROSTER.size() var cid: String = _CORE_ROSTER[_core_idx] _equipped_core = _make_core_by_id(cid) _deck_spell_ids = _default_deck_for(cid) _rebuild_wand() PlayerManager.equip_wand(_equipped_core, _equipped_compiled) # 即时换装 ProfileManager.mark_dirty() func get_core_display() -> String: return _equipped_core.display_name if _equipped_core else "—" func _make_core_by_id(cid: String) -> CoreDefinition: return WandPreset.make_core_by_id(cid) # 数据驱动(cores.json)+ 硬编码回退 ## 每个 Core 的演示默认 Deck("" = 空槽):让玩家切换即见到该拓扑机制 func _default_deck_for(cid: String) -> Array: match cid: "wand_memory": return ["logic_every_n_shots", "action_spark_bolt"] "matrix_board": return ["action_spark_bolt", "", "", "", "modifier_damage_plus", "", "", ""] "circuit_fork": return ["", "action_spark_bolt", "action_fire_bolt"] _: return ["action_spark_bolt"] # ── 背包 / 插槽编辑(inventory UI 调用)────────────────────────── func get_slot_count() -> int: return _equipped_core.slot_count if _equipped_core else 5 ## 返回补齐到 slot_count 长度的插槽视图("" = 空槽) func get_padded_deck() -> Array: var n: int = get_slot_count() var out: Array = _deck_spell_ids.duplicate() while out.size() < n: out.append("") if out.size() > n: out.resize(n) return out func get_bench() -> Array: return _bench ## 写回插槽视图:LINEAR 压实去空槽,MATRIX/CIRCUIT 保留位置 func _apply_padded(arr: Array) -> void: if _is_positional_core(): _deck_spell_ids = arr.duplicate() else: var compact: Array = [] for s in arr: if String(s) != "": compact.append(s) _deck_spell_ids = compact _rebuild_wand() PlayerManager.equip_wand(_equipped_core, _equipped_compiled) ProfileManager.mark_dirty() ## 交换两个插槽内容 func swap_slots(a: int, b: int) -> void: var d: Array = get_padded_deck() if a < 0 or b < 0 or a >= d.size() or b >= d.size(): return var t = d[a]; d[a] = d[b]; d[b] = t _apply_padded(d) ## 清空插槽 → 内容移入备牌区 func slot_to_bench(idx: int) -> void: var d: Array = get_padded_deck() if idx < 0 or idx >= d.size(): return var sid: String = String(d[idx]) if sid == "": return _bench.append(sid) d[idx] = "" _apply_padded(d) ## 备牌 → 插槽(插槽已占用则原内容退回备牌区) func bench_to_slot(bench_idx: int, slot_idx: int) -> void: var d: Array = get_padded_deck() if bench_idx < 0 or bench_idx >= _bench.size() or slot_idx < 0 or slot_idx >= d.size(): return var sid: String = String(_bench[bench_idx]) var existing: String = String(d[slot_idx]) _bench.remove_at(bench_idx) if existing != "": _bench.append(existing) d[slot_idx] = sid _apply_padded(d) ## 关闭商店进入下一波 func close_shop_and_next_wave() -> void: ShopManager.close_shop() BulletManager.reset() EnemyManager.reset() ZoneManager.reset() MinionManager.reset() SpatialGrid.clear() var next_wave: int = WaveManager.current_wave + 1 _transition_to_battle(next_wave) func _on_player_died(_payload: Dictionary) -> void: state = GameState.GAME_OVER # 先采集本局战绩(reset 前),写入排行榜 var wave_reached: int = max(1, WaveManager.current_wave) var kills: int = PlayerManager.kill_count var elapsed: int = int(Time.get_ticks_msec() / 1000.0 - _run_start_time) var score: int = EndlessRecords.compute_score(wave_reached, elapsed) EndlessRecords.add_record(wave_reached, elapsed, kills) PlayerManager.reset() BulletManager.reset() EnemyManager.reset() ZoneManager.reset() MinionManager.reset() SpatialGrid.clear() ProfileManager.clear_run() EventBus.emit(EventID.GAME_OVER, { "wave": wave_reached, "elapsed_sec": elapsed, "kills": kills, "score": score}) func _rebuild_wand() -> void: if _equipped_core == null: _equipped_core = WandPreset.make_core_by_id("wand_basic") var nodes: Array = [] for sid in _deck_spell_ids: if String(sid) == "": nodes.append(null) # 空槽:保持 MATRIX/CIRCUIT 位置(LINEAR 扁平化会跳过 null) continue nodes.append(SpellRegistry.get_spell(sid)) # 纯数据驱动;未知 id → null(下游按位置跳过) _equipped_compiled = SpellEvaluator.compile_wand(_equipped_core, nodes) # ── 法杖存档(ProfileManager A/B 槽,ADR-A2)───────────────── func get_wand_save_data() -> Dictionary: return { "core_id": _equipped_core.id if _equipped_core else "wand_basic", "deck": _deck_spell_ids.duplicate(), "bench": _bench.duplicate(), } func apply_wand_save_data(d: Dictionary) -> void: var cid: String = String(d.get("core_id", "wand_basic")) _equipped_core = _make_core_by_id(cid) _core_idx = _CORE_ROSTER.find(cid) if _core_idx < 0: _core_idx = 0 # JSON 反序列化为 Array;逐项转 String 保证类型一致 _deck_spell_ids = [] for s in d.get("deck", ["action_spark_bolt"]): _deck_spell_ids.append(String(s)) if _deck_spell_ids.is_empty(): _deck_spell_ids = ["action_spark_bolt"] _bench = [] for s in d.get("bench", []): _bench.append(String(s)) _rebuild_wand() func get_deck_spell_ids() -> Array: return _deck_spell_ids ## 防呆校验:Deck 是否含至少一个 ACTION 法术(否则法术塔无法开火,见 game_design §5.1 G5 防呆机制) func has_castable_action() -> bool: for sid in _deck_spell_ids: if String(sid) == "": continue var sn: SpellNode = SpellRegistry.get_spell(sid) if sn != null and sn.type == SpellNode.SpellType.ACTION: return true return false func get_equipped_compiled() -> CompiledDeck: return _equipped_compiled