Files
spellforge/scripts/domain/combat/combat_manager.gd
T
joywayerandClaude Opus 4.8 dc05eeada7 fix: 修复审计发现的 3 个潜在 bug(CoreFeatureTag / pierce / Deck 防呆)
1. CoreFeatureTag 位运算串扰:feature_tags 实为单选枚举(游戏设计器 core_tab
   以 OptionButton 下标写入),但 spell_evaluator 用位与 `&` 判定,非 2 的幂
   常量会串扰(如 3 & 1 = 1 误判持久内存)。改为 `==` 相等判定,常量值保持
   与设计器下标一致;补充注释说明单选语义与未来多选(位标志)的迁移路径。
2. modifier_pierce_plus 空操作:MODIFIER 携带的 pierce 从未生效(可购买无效)。
   新增 CastStats.pierce_add,_apply_modifier 累加,_push_projectile 叠加到
   ACTION 自带 pierce。穿透修正现在生效。
3. 战前 Deck 防呆缺失:可带无 ACTION 卡组进战斗。新增
   CombatManager.has_castable_action();商店"下一波"按钮校验,无 ACTION 时
   显示警告并阻止进入(新增 SHOP_NEED_ACTION i18n 键,zh_CN/zh_TW/en/ja)。

静态审查通过;因本机无 Godot CLI 未经运行时验证,需在编辑器 play_scene 验收。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 14:46:41 +08:00

299 lines
10 KiB
GDScript
Raw 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.
## 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)
## 外部调用:购买法术并安装到法杯
## LINEARMODIFIER 插首个 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