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 做成幂等收据,并把保存成功作为确认。不要提前把事件总线塞进单机循环,也不要让战斗机为“方便”拿回存档写权限。架构评审真正要守住的是:瞬时状态可以重建,永久副作用只能由唯一所有者提交。