MAX_OPS 由硬编码 40×5 改为读生效 cpu_limit——cores.json 里逐法杖配置的
3/5/6/8 自此生效,法杖间恢复运算力区分度。玩家基准取 0,故小木法杖仍为
5 → 200 步,现有平衡零改动。
法杖份额走 add_modifier 而非调用点相加:否则 hard(50) 只钳制玩家那一份,
法杖份额加在钳制之后可使总值越界。换杖时先 remove_modifiers_from("core")
再 add,防止累积。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
316 lines
11 KiB
GDScript
316 lines
11 KiB
GDScript
## 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()
|
||
EnemyBulletManager.reset()
|
||
BossManager.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()
|
||
EnemyBulletManager.reset()
|
||
BossManager.reset()
|
||
SpatialGrid.clear()
|
||
var next_wave: int = WaveManager.current_wave + 1
|
||
_transition_to_battle(next_wave)
|
||
|
||
func _on_player_died(_payload: Dictionary) -> void:
|
||
if state == GameState.GAME_OVER:
|
||
return # 防重入:同帧内多路死亡(子弹命中 + 卖血施法)不重复结算战绩/发以太
|
||
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)
|
||
MetaProgress.add_aether(SettingsManager.aether_for_wave(wave_reached))
|
||
PlayerManager.reset()
|
||
BulletManager.reset()
|
||
EnemyManager.reset()
|
||
ZoneManager.reset()
|
||
MinionManager.reset()
|
||
EnemyBulletManager.reset()
|
||
BossManager.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)
|
||
var leech: float = _equipped_core.base_mana_leech if _equipped_core else 0.0
|
||
for n in nodes:
|
||
if n != null:
|
||
leech += float(n.meta.get("mana_leech", 0.0))
|
||
PlayerStats.mana_leech = leech
|
||
# 法杖运算力以加成来源接入(非调用点相加),使 hard 上限作用于生效总值
|
||
PlayerStats.remove_modifiers_from("core")
|
||
PlayerStats.add_modifier("cpu_limit", "flat", float(_equipped_core.cpu_limit) if _equipped_core else 0.0, "core")
|
||
|
||
# ── 法杖存档(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
|