Godot 战斗状态机不该拥有奖励:关内状态与永久成长如何分界
开局场景
挂机战斗看似只有“走路、遇敌、互砍、领奖”,真正危险的是把领奖也写进战斗机:Boss 死亡同一帧既改金币、又推进关卡、再重启下一场,任何回调重入都会让短期计时器和永久存档互相污染。当前 Godot 项目把边界写进类型:CombatSimulation 只发事实,不写成长。
# scripts/simulation/combat_simulation.gd · signals / transient fields
extends RefCounted
signal state_changed(hero_state: Dictionary, monster_state: Dictionary)
signal reward_generated(reward: Dictionary)
signal level_completed(level_id: String)
const PHASE_WALKING := "walking"
const PHASE_ENCOUNTER := "encounter"
const PHASE_FIGHTING := "fighting"
const PHASE_BOSS_INTRO := "boss_intro"
const PHASE_BOSS_FIGHT := "boss_fight"
var _phase := PHASE_WALKING
var _hero_hp := 1
var _monster_hp := 0
var _hero_attack_timer := 0.0
var _monster_attack_timer := 0.0
var _respawn_timer := 0.0
var _defeated_count := 0
var _boss_active := false

边界先定
状态所有权可以用两个不变量判定。其一,start() 必须清空怪物缓存、Boss 标记、计时器、路程和击杀数,新关不能继承旧战斗。其二,金币、经验、背包、当前关卡与无尽层只允许 ProgressionService 修改。系统边界因此不是 Node 与脚本,而是“可随一场战斗丢弃”与“必须进入存档”的分界。
这也给重启语义一个可检查答案:关内对象即使整组丢弃,最多损失尚未交接的表现过程;一旦奖励事件进入成长服务,后续恢复只能读取成长状态重新开局,不能从旧怪物缓存续跑。否则同一份奖励会同时受战斗快照和存档版本支配,唯一所有者就不存在了。
| 状态 | 唯一所有者 | 生命周期 | 对外形式 |
|---|---|---|---|
| phase、双方 HP、攻击计时、路程 | CombatSimulation | 单场战斗 | UI 快照 |
| 奖励描述、通关事实 | CombatSimulation 产生 | 单次事件 | signal |
| 金币、经验、背包、关卡、无尽层 | ProgressionService | 跨会话 | 深拷贝与存档 |
# scripts/simulation/combat_simulation.gd · start / tick
func start(player_stats: Dictionary, level_id: String) -> void:
_player_stats = player_stats.duplicate(true)
_level_id = level_id
_load_level_config()
_phase = PHASE_WALKING
_monster_id = ""
_current_monster = {}
_hero_hp = int(_player_stats.get("max_hp", 1))
_monster_hp = 0
_hero_attack_timer = 0.0
_monster_attack_timer = 0.0
_defeated_count = 0
_boss_active = false
_respawn_timer = 0.0
_travel = 0.0
_next_encounter_at = _level_spawn_gap
_boss_intro_timer = 0.0
_emit_state()
func tick(delta: float) -> void:
if _respawn_timer > 0.0:
_tick_respawn(delta)
return
match _phase:
PHASE_WALKING: _tick_walking(delta)
PHASE_ENCOUNTER: _tick_encounter(delta)
PHASE_FIGHTING, PHASE_BOSS_FIGHT: _tick_fighting(delta)
PHASE_BOSS_INTRO: _tick_boss_intro(delta)
_emit_state()
一帧顺序
正常时序是 _process → tick → phase handler → state_changed → StageView。UI 每帧只拿快照,不读取攻击计时器和怪物缓存;奖励则走另一条较低频的事件链。这里最重要的不是 signal 语法,而是战斗机没有获得存档引用,因此“误写永久状态”在结构上无从发生。
# scripts/app/app_root.gd · _connect_services / _process
func _connect_services() -> void:
_combat.state_changed.connect(_on_combat_state_changed)
_combat.reward_generated.connect(_on_reward_generated)
_combat.level_completed.connect(_on_level_completed)
_progression.changed.connect(_on_progression_changed)
func _process(delta: float) -> void:
_combat.tick(delta)
_auto_save_timer += delta
if _auto_save_timer >= 5.0:
_auto_save_timer = 0.0
_save_game()
func _on_combat_state_changed(
hero_state: Dictionary,
monster_state: Dictionary
) -> void:
_stage_view.set_combat_state(hero_state, monster_state)
失败时序
失败路径也必须留在关内。怪物先把英雄打到零,只设置两秒复活倒计时;倒计时结束恢复战斗血量,不写金币、关卡或存档。相反,英雄先手击杀后必须立即 return,禁止已经死亡的怪物在同一帧反击。这两个早退定义了确定的帧内顺序,也防止“死亡回调”和“奖励回调”同时争夺永久状态。
# scripts/simulation/combat_simulation.gd · _tick_fighting / _apply_monster_attack
func _tick_fighting(delta: float) -> void:
_hero_attack_timer -= delta
_monster_attack_timer -= delta
if _hero_attack_timer <= 0.0:
_hero_attack_timer += CombatRulesScript.attack_interval(
float(_player_stats.get("attack_speed", 1.0))
)
_apply_hero_attack()
if _monster_hp <= 0:
return
if _monster_attack_timer <= 0.0:
var monster := _get_current_monster_definition()
_monster_attack_timer += CombatRulesScript.attack_interval(
float(monster.get("attack_speed", 0.5))
)
_apply_monster_attack(monster)
func _apply_monster_attack(monster: Dictionary) -> void:
var damage: int = CombatRulesScript.base_damage(monster, _player_stats)
_hero_hp -= damage
if _hero_hp <= 0:
_hero_hp = 0
_respawn_timer = 2.0
奖励交接
击杀只生成奖励描述并发信号;AppRoot 把它交给成长服务,随后同步新的战斗属性并保存。通关同理:成长服务决定下一关或无尽层,组合根再以新 scalar 调用 start()。正常链是“事实 → 永久写入 → 新战斗”,而不是战斗机内部自我重启。
# scripts/app/app_root.gd · _on_reward_generated / _on_level_completed
func _on_reward_generated(reward: Dictionary) -> void:
var result: Dictionary = _progression.apply_reward(reward)
_combat.update_player_stats(_progression.get_combat_stats())
_save_game()
func _on_level_completed(level_id: String) -> void:
var next_level_id: String = _progression.complete_level(level_id)
_combat.set_endless_scalar(_progression.get_endless_scalar())
_combat.start(_progression.get_combat_stats(), next_level_id)
_save_game()
# scripts/progression/progression_service.gd · complete_level
func complete_level(level_id: String) -> String:
var level: Dictionary = _content.get_level(level_id)
var next_level_id := str(level.get("next", level_id))
if next_level_id == level_id:
_state["endless_tier"] = int(_state.get("endless_tier", 0)) + 1
next_level_id = "level_1"
_state["current_level_id"] = next_level_id
changed.emit()
return next_level_id

当天复算
固定输入避免把随机数包装成性能结论:普通关把移动速度设为 180,验证八秒预算内进入战斗;第五关使用高属性,验证二十秒预算内完成且奖励不少于 11 次;纯规则测试把暴击 roll 显式传入。这里验证的是状态转移和边界,不是渲染帧率。
cd "$PROJECT_ROOT"
/usr/bin/time -p Godot --headless --path . \
--script scripts/dev/combat_smoke_test.gd
/usr/bin/time -p Godot --headless --path . \
--script scripts/dev/combat_rules_smoke_test.gd
/usr/bin/time -p Godot --headless --path . \
--script scripts/dev/endless_smoke_test.gd
Godot Engine v4.6.1.stable.official
COMBAT_ENCOUNTER_OK elapsed=1.6
COMBAT_SMOKE_OK rewards=11 elapsed=6.6
real 3.03
COMBAT_RULES_SMOKE_OK
real 1.41
ENDLESS_SMOKE_OK power=142 scalar=2.2
real 1.81
exit=0 passed=3 failed=0 skipped=0 process_time=6.25s
演进判断
当前单线程 signal 链的取舍很明确:顺序直观、调试成本低,但奖励应用与保存仍在同一进程,不能抵抗进程在两者之间退出。只要存档是本地单写者,这个边界足够;当出现云存档、多设备合并、可重放战斗或奖励跨进程投递时,触发升级:为奖励增加稳定事件 ID,把 apply_reward 做成幂等收据,并把保存成功作为确认。不要提前把事件总线塞进单机循环,也不要让战斗机为“方便”拿回存档写权限。架构评审真正要守住的是:瞬时状态可以重建,永久副作用只能由唯一所有者提交。