ECS框架-动画驱动的战斗交互

动画驱动的战斗交互

上节我们已经完成了:

  • 目标锁定
  • 攻击起手
  • 攻击冷却
  • 攻击动画切换
  • 动画结束后的状态恢复

也就是说,单位已经能完成一条基础战斗链:

  • 找目标
  • 冷却结束
  • 播放攻击动画
  • 动画结束回到 idle / walk

但是这还只是“看起来在战斗”,并不等于真正把动画、伤害、治疗、音效、阻挡状态、朝向这些东西严密地串起来。

这一节要解决的,是更细一层的核心问题:

  • 攻击应该由动画帧来驱动,而不是起手瞬间直接结算
  • 不同单位的攻击事件、音效事件,要通过统一事件链连接起来
  • 被阻挡、远程攻击、动作锁、朝向优先级之间不能互相打架

换句话说,这节的重点不是“再加一个系统”,而是把原本已经存在的战斗流程,真正收紧成一条可靠的动画驱动交互链


学习目标

  • 理解为什么伤害/治疗应该绑定在动画事件帧上,而不是攻击起手时立刻生效
  • 学会在动画数据里配置 events
  • 建立完整链路:
    • AttackStartSystem
    • AnimationSystem
    • AnimationEventSystem
    • CombatResolveSystem
    • AudioSystem
  • 理解远程敌人被阻挡时,为什么 TargetComponent 不能随便删
  • 理解 BlockedByComponentTargetComponentActionLockTag 各自负责什么
  • 学会处理几个很隐蔽的时序问题:
    • 动画帧事件重复触发
    • 被阻挡远程敌人双重结算
    • 动作锁期间被中途阻挡
    • 阻挡解除后的状态恢复
    • 朝向系统被后续子逻辑覆盖

一、为什么要做“动画驱动”的战斗

如果在 AttackStartSystem 里一进入攻击就直接扣血,看起来会有两个问题:

  1. 动画和手感对不上
  • 刀还没挥到目标,血量就先掉了
  • 治疗动画还没到施法帧,血条就先回了
  1. 以后扩展会很麻烦
  • 远程单位需要“发射帧”
  • 技能可能要“施法帧”
  • 治疗、近战、远程、AOE 的触发点都不一样

所以更合理的思路是:

攻击起手只负责“开始动作”,真正的伤害/治疗结算放到动画命中帧上

这就形成了一个更自然的流程:

冷却结束
  ↓
攻击起手
  ↓
播放攻击动画
  ↓
动画命中帧触发事件
  ↓
结算伤害 / 治疗 / 音效
  ↓
动画结束恢复状态

二、动画事件数据:在蓝图里配置 events

文件:

  • assets/data/player_data.json
  • assets/data/enemy_data.json

现在动画除了:

  • row
  • frames
  • duration

之外,还要支持:

  • events

例如:

"attack": { "row": 2, "frames": [0,1,2,3], "events": { "hit": 2 } }

意思是:

  • 这个动画播放到第 2 帧时
  • 触发一个逻辑事件 "hit"

远程单位也可以配置:

"ranged_attack": { "row": 4, "frames": [0,1,2,...], "events": { "emit": 11 } }

这里的 "hit" / "emit" 不是资源路径,而是逻辑事件名


三、动画蓝图与动画组件要支持事件映射

文件:

  • src/game/data/entity_blueprint.h
  • src/game/factory/blueprint_manager.cpp
  • src/engine/component/animation_component.h
  • src/game/factory/entity_factory.cpp

1)AnimationBlueprint 增加事件表

struct AnimationBlueprint {
    float frame_time_ms_{0};
    std::unordered_map<int, entt::id_type> anim_events_{};
    int row_{0};
    std::vector<int> frames_;
};

这里的 key 是:

  • 第几帧

value 是:

  • 事件名对应的哈希 id,比如 "hit"_hs

2)BlueprintManager 解析 events

parseAnimations() 里,把:

"events": { "hit": 2 }

解析成:

anim_events[2] = "hit"_hs;

这样动画数据就从“纯表现”扩展成了“带逻辑钩子”的表现。

3)AnimationComponent 保存动画事件

最终 engine::component::Animation 里要保存:

std::unordered_map<int, entt::id_type> animation_events_{};

这样运行时动画系统就能在切帧时知道:

  • 当前新进入的这一帧
  • 有没有需要发出的逻辑事件

四、AnimationSystem:在切帧时发 AnimationEvent

文件:src/engine/system/animation_system.cpp

这一节里,动画系统不再只负责切换贴图,还要负责:

  • 在动画进入特定帧时,发送 AnimationEvent
  • 在非循环动画结束时,发送 AnimationFinishedEvent

核心原则:事件只在“进入新帧时”触发一次

这是这节最重要的细节之一。

如果写成:

  • 当前帧每次 update 都检查一次事件

那么只要这一帧持续多个 update,就会重复发事件,导致:

  • 一次攻击多次扣血
  • 一次治疗多次回血
  • 音效重复播放

更稳的写法是:

  1. 先累计时间
  2. 如果时间超过当前帧时长,就切到下一帧
  3. 只有切到下一帧时,才检查新帧是否有事件

也就是:

if(anim_comp.current_time_ms_ >= current_frame.duration_ms_){
    anim_comp.current_time_ms_ -= current_frame.duration_ms_;
    anim_comp.current_frame_index_++;

    auto event_it = current_animation.animation_events_.find(anim_comp.current_frame_index_);
    if (event_it != current_animation.animation_events_.end()){
        dispatcher_.enqueue(engine::utils::AnimationEvent{entity, event_it->second});
    }
}

这样可以避免同一帧事件重复触发。

注意

当前这种设计的语义是:

  • 进入某帧时触发事件

这意味着:

  • 如果事件配置在第 0
  • 动画刚开始播放时不会立刻触发

这不是 bug,而是这套实现的规则。
只要你后面配置事件时注意不要把关键事件放在第 0 帧即可。


五、AttackStartSystem:攻击起手只负责“开始动作”

文件:src/game/system/attack_start_system.cpp

这节以后要更明确一点:

  • AttackStartSystem 不负责伤害结算
  • 只负责:
    • 检查哪些单位能开始攻击
    • 播对应动画
    • 必要时加 ActionLockTag
    • 远程敌人攻击时清零速度

三类分支

  1. 玩家攻击
  • 普通玩家播 attack
  • 治疗者播 heal
  1. 未被阻挡的远程敌人
  • ranged_attack
  • ActionLockTag
  • 速度清零
  1. 被阻挡的敌人
  • attack
  • ActionLockTag

这里非常关键的一点是:

被阻挡攻击分支和远程攻击分支都必须加 ActionLockTag

否则动画播到一半时,又可能重复进入下一次起手。


六、AnimationEventSystem:真正把动画帧翻译成战斗事件

文件:src/game/system/animation_event_system.cpp

这个系统就是整条链的中枢。

它接收:

  • engine::utils::AnimationEvent

然后根据:

  • 当前实体是谁
  • 当前事件是 "hit" 还是 "emit"
  • 当前实体带哪些组件

再决定发什么业务事件。

1)玩家单位

如果实体有:

  • PlayerComponent

那么:

  • "hit"
    • 普通玩家发 AttackEvent
    • 治疗者发 HealEvent
  • "emit"
    • 以后给远程投射物使用,当前可以只作为音效点或预留点

2)敌人单位

如果实体有:

  • EnemyComponent

则要先分优先级:

被阻挡敌人优先

如果有:

  • BlockedByComponent

则说明当前攻击目标就是阻挡它的玩家单位。
此时就算它同时还有 TargetComponent,也不能再按远程目标结算。

所以这里的正确逻辑是:

if (blocked_by_cmp) {
    // 近战命中阻挡者
    ...
    return;
}
否则再处理远程敌人

如果没有 BlockedByComponent,但有:

  • TargetComponent

那么它走的是未被阻挡的远程攻击分支,可以按远程目标处理。

为什么不能随便删远程敌人的 TargetComponent

这一节一个很隐蔽的坑就是:

  • 远程敌人被阻挡后,不能因为它当前切到了近战攻击分支,就立刻删掉它原来的 TargetComponent

原因是:

  • BlockedByComponent 表示“当前攻击模式切成近战”
  • TargetComponent 表示“远程模式下原来锁定的是谁”

这两个状态可以暂时共存。
真正决定本帧按哪种方式命中的,是:

  • AttackStartSystem 的分支条件
  • AnimationEventSystem 的优先级

而不是 SetTargetSystem 去主动删目标。


七、CombatResolveSystem:收口伤害与治疗结算

文件:src/game/system/combat_resolve_system.cpp

动画事件系统不应该直接改血量,它只负责发业务事件:

  • AttackEvent
  • HealEvent

真正改血量、加死亡标签、移除受伤标签的地方,统一收在 CombatResolveSystem

攻击事件

收到 AttackEvent 后:

  1. 取目标的 StatsComponent
  2. 根据伤害公式扣血
  3. 如果目标是玩家,补 InjuredTag
  4. 如果血量 <= 0,打 DeadTag
  5. 如果死的是被阻挡敌人,还要让 blocker 的 current_count_--

治疗事件

收到 HealEvent 后:

  1. 给目标回血
  2. 血量超过上限就截断到 max_hp_
  3. 如果已经满血,移除 InjuredTag

一个很容易漏掉的细节

这个系统构造时同时连接了:

  • AttackEvent
  • HealEvent

所以析构时也要对称断开。
更省事的写法是:

dispatcher_.disconnect(this);

八、AudioSystem:音效也要走事件链

文件:

  • src/engine/system/audio_system.cpp
  • src/game/system/animation_event_system.cpp
  • src/engine/component/audio_component.h

1)PlaySoundEvent 只是“播放哪个资源 id”

音效事件:

struct PlaySoundEvent{
    entt::id_type sound_id{entt::null};
};

它只知道:

  • 该播放哪个真正的音效资源 id

2)但 "hit" / "emit" 不是资源 id

这是这节很容易搞错的一点。

动画事件里的:

  • "hit"
  • "emit"

只是逻辑音效名,不是 resource_mapping.json 里真正加载的资源 id。

例如:

  • 战士 "hit" 对应 "sword_hit"
  • 治疗者 "hit" 对应 "heal"
  • 黑暗女巫 "emit" 对应 "spell_shoot"

所以这里必须走一层映射:

  • 从实体自己的 AudioComponent 里查:
    • 逻辑名 -> 资源 id

例如:

auto sound_cmp = registry_.try_get<engine::component::AudioComponent>(event.entity_);
entt::id_type sound_id {entt::null};

if(sound_cmp) {
    auto it = sound_cmp->sounds_.find(event.anim_id);
    if(it != sound_cmp->sounds_.end()) {
        sound_id = it->second;
    }
}

然后再发:

dispatcher_.enqueue(engine::utils::PlaySoundEvent{sound_id});

否则如果你直接发 "hit"_hsAudioPlayer 是找不到对应资源的。

3)注意不要在只读查询时用 operator[]

如果写成:

sound_cmp->sounds_[event.anim_id]

那在 key 不存在时会自动插入默认值。
做只读查询时更稳的是:

  • find
  • contains

九、阻挡、动作锁、状态恢复的职责拆分

这一节最绕、也最容易思维打结的地方,就是:

  • BlockedByComponent
  • ActionLockTag
  • 动画状态恢复

到底谁该删,谁该管。

1)BlockSystem 负责什么

BlockSystem 只负责:

  • 建立阻挡关系
  • 清理失效的阻挡关系
  • 在新建立阻挡关系时,把敌人切进 idle

另外一个很关键的修正是:

建立阻挡关系时,要排除 ActionLockTag

否则就会出现:

  • 远程敌人已经开始播 ranged_attack
  • 这时玩家突然放到它脸上
  • BlockSystem 立刻把它切进 idle

这会把当前攻击动作中途打断。

2)BlockSystem 不应该越权管理动作锁生命周期

一个很隐蔽的问题是:

  • BlockedByComponent 无效时
  • 不能机械地立刻移除 ActionLockTag

因为:

  • BlockedByComponent 表示阻挡关系
  • ActionLockTag 表示当前动作还没播完

两者不是一个层级。

更合理的做法是:

  • BlockSystem 移除 BlockedByComponent
  • 如果当前没有 ActionLockTag,说明只是普通阻挡待机,可以直接切回 walk
  • 如果当前还有 ActionLockTag,说明动作还没播完,这时不要越权改动画,让 AnimationStateSystem 自己收尾

3)AnimationStateSystem 负责什么

AnimationStateSystem 专门负责:

  • 动作结束后移除 ActionLockTag
  • 根据当前组件状态决定回什么动画

对于敌人:

  • 如果还有 BlockedByComponent,回 idle
  • 否则回 walk

这样职责就清楚了:

  • BlockSystem 管阻挡关系
  • AnimationStateSystem 管动作生命周期

十、OrientationSystem:三个函数必须覆盖完整状态

文件:src/game/system/orientation_system.cpp

这一节后期最隐蔽的 bug 之一,就是朝向系统。

看起来只是朝左朝右,但实际上它是多个状态优先级叠加的结果。

正确思路不是:

  • 看当前动画名是什么

而是:

  • 基于组件状态定义朝向优先级

当前更稳定的优先级

  1. TargetComponent 且未被阻挡的单位:朝目标
  2. BlockedByComponent 的单位:朝阻挡者
  3. 真正移动中的单位:按速度方向

也就是说三个子函数要共同覆盖住所有敌人实体,而不是随便排掉一类。

对应到代码里更稳的写法是:

updateHasTargetUnits
  • 排除 BlockedByComponent
  • 不要简单粗暴排掉全部 ActionLockTag

因为:

  • 远程敌人正在攻击锁中时,仍然应该朝目标
updateBlockedByUnits
  • 专门处理被阻挡单位
  • 让它们朝阻挡者
updateMovingUnits
  • 继续排除 BlockedByComponent
  • 继续排除 ActionLockTag

这样就能避免:

  • 前面刚朝目标
  • 后面又被移动方向覆盖回去

十一、GameScene 中系统顺序的进一步调整

文件:src/game/scene/game_scene.cpp

这一节后期又修了一个手感问题:

  • timer_system_ 放到 attack_start_system_ 前面

原因是:

如果顺序是:

  • 先攻击起手
  • 后攻击计时

那么本帧刚好冷却结束的单位,要等下一帧才能真正起手。

更顺的顺序是:

  1. RemoveDeadSystem
  2. BlockSystem
  3. SetTargetSystem
  4. TimerSystem
  5. AttackStartSystem
  6. FollowPathSystem
  7. MovementSystem
  8. OrientationSystem
  9. AnimationSystem
  10. YsortSystem

这能让:

  • 冷却刚好结束
  • 本帧就能立刻开始攻击

十二、这一节最容易踩的坑

1)动画帧事件重复触发

如果你在“当前帧每次 update”都检查事件,而不是“切进新帧时”才检查,就会出现:

  • 一次命中,多次扣血

2)被阻挡远程敌人双重结算

如果远程敌人:

  • 既有 BlockedByComponent
  • 又有 TargetComponent

而动画事件系统没有先处理 BlockedByComponentreturn,就会造成:

  • 先打阻挡者一次
  • 再打目标一次

3)远程敌人被阻挡时错误删除 TargetComponent

这会让状态切换非常混乱,甚至出现:

  • 解除阻挡后目标恢复不顺
  • 一直 idle
  • 远程逻辑丢失

4)动作锁期间还能被 BlockSystem 中途改状态

如果建立阻挡关系时不排除 ActionLockTag,就会出现:

  • 远程攻击播到一半
  • 突然被切成 idle

5)阻挡解除后谁来恢复 walk

如果:

  • BlockSystem 不处理非锁定态恢复
  • AnimationStateSystem 只管动画结束恢复

那就会出现:

  • 阻挡解除了
  • 敌人开始移动了
  • 但动画还停在 idle

6)音效逻辑名和资源 id 混淆

"hit" / "emit" 是逻辑名,不是资源 id。
真正播放前必须先经过 AudioComponent 映射。


十三、本节总结

这一节真正做成的,不是“多播了几个音效”或者“多扣了一次血”,而是把整条战斗流程真正变成了动画驱动

  • 攻击起手只负责开始动作
  • 动画命中帧决定什么时候结算
  • 伤害/治疗通过战斗系统统一收口
  • 音效通过实体自己的音频映射播放
  • 被阻挡、远程攻击、动作锁、目标保留都能同时成立而不互相打架
  • 朝向系统也重新回到了“状态优先级”而不是“看动画名猜逻辑”

到这里,项目里的战斗骨架就已经比“简单播攻击动画”成熟很多了。

后面再扩展时,最自然的下一步会是:

  • 投射物实体
  • emit 帧真正生成弹道
  • 受击表现 / 击退 / 死亡动画
  • 技能施法
  • 更复杂的音效和特效系统

十四、建议与改进方向

1)继续坚持“组件状态优先”,不要用动画名倒推逻辑

动画名可以帮助表现,但真正的系统判断最好还是基于:

  • TargetComponent
  • BlockedByComponent
  • ActionLockTag
  • AttackReadyTag

2)把事件链理解成“翻译层”

  • AttackStartSystem:开始动作
  • AnimationSystem:进入事件帧
  • AnimationEventSystem:把帧事件翻译成业务事件
  • CombatResolveSystem:执行真正结算
  • AudioSystem:播放真正音效

这条链一旦想清楚,后面加投射物会容易很多。

3)以后加 emit 时,不要急着直接扣血

更自然的做法是:

  • emit 帧生成投射物
  • 投射物命中时再发 AttackEvent

这样远程单位和近战单位的行为差异会更真实。

4)测试入口尽量继续保留

像:

  • 快速生成近战玩家
  • 快速生成远程玩家
  • 快速生成治疗者
  • 一键清场

这种测试入口,在你继续做投射物、音效、特效时仍然非常有用。


十五、补充代码模板

为了后面复习更方便,这里把这节几个最关键的代码片段再集中整理一次。

1)AnimationBlueprint 解析事件

std::unordered_map<entt::id_type, data::AnimationBlueprint> BlueprintManager::parseAnimations(const nlohmann::json &json)
{
    std::unordered_map<entt::id_type, data::AnimationBlueprint> animations;
    if(json.contains("animation") && json["animation"].is_object()){
        auto anims = json["animation"];
        for(auto& [key, value] : anims.items()){
            entt::id_type anim_id = entt::hashed_string(key.c_str());

            std::unordered_map<int, entt::id_type> anim_events{};
            if(value.contains("events")){
                for(auto& [event_key, event_value] : value["events"].items()){
                    anim_events[event_value.get<int>()] = entt::hashed_string(event_key.c_str());
                }
            }

            data::AnimationBlueprint anim {
                value.value("duration",100),
                anim_events,
                value.value("row", 0),
                value["frames"].get<std::vector<int>>()
            };
            animations.emplace(anim_id, std::move(anim));
        }
    }
    return animations;
}

2)AnimationSystem 在切帧时发事件

const auto& current_frame = current_animation.frames_[anim_comp.current_frame_index_];
if(anim_comp.current_time_ms_ >= current_frame.duration_ms_){
    anim_comp.current_time_ms_ -= current_frame.duration_ms_;
    anim_comp.current_frame_index_++;

    auto event_it = current_animation.animation_events_.find(anim_comp.current_frame_index_);
    if (event_it != current_animation.animation_events_.end()){
        dispatcher_.enqueue(engine::utils::AnimationEvent{entity, event_it->second});
    }

    if (anim_comp.current_frame_index_ >= current_animation.frames_.size()){
        if(current_animation.loop_) {
            anim_comp.current_frame_index_ = 0;
        } else {
            anim_comp.current_frame_index_ = current_animation.frames_.size() - 1;
            dispatcher_.enqueue(engine::utils::AnimationFinishedEvent{entity, anim_comp.current_animation_id_});
        }
    }
}

3)AttackStartSystem 只负责开始动作

void AttackStartSystem::update(entt::registry &registry, entt::dispatcher& dispatcher)
{
    updatePlayerAttack(registry, dispatcher);
    updateRangedEnemyAttack(registry, dispatcher);
    updateBlockedByAttack(registry, dispatcher);
}
void AttackStartSystem::updateRangedEnemyAttack(entt::registry& registry, entt::dispatcher& dispatcher)
{
    auto view = registry.view<game::component::EnemyComponent,
        game::component::TargetComponent,
        game::defs::AttackReadyTag>(
            entt::exclude<game::component::BlockedByComponent, game::defs::ActionLockTag>);

    std::vector<entt::entity> entities(view.begin(), view.end());
    for (auto entity : entities) {
        registry.remove<game::defs::AttackReadyTag>(entity);
        registry.emplace_or_replace<game::defs::ActionLockTag>(entity);
        registry.emplace_or_replace<engine::component::VelocityComponent>(entity, glm::vec2(0,0));
        dispatcher.enqueue(engine::utils::PlayAnimationEvent{entity, "ranged_attack"_hs, false});
    }
}

4)AnimationEventSystem 统一翻译帧事件

auto sound_cmp = registry_.try_get<engine::component::AudioComponent>(event.entity_);
entt::id_type sound_id {entt::null};
if(sound_cmp) {
    auto it = sound_cmp->sounds_.find(event.anim_id);
    if(it != sound_cmp->sounds_.end()) {
        sound_id = it->second;
    }
}
auto blocked_by_cmp = registry_.try_get<game::component::BlockedByComponent>(event.entity_);
if(blocked_by_cmp) {
    auto stats_cmp = registry_.get<game::component::StatsComponent>(event.entity_);
    if(event.anim_id == "hit"_hs) {
        dispatcher_.enqueue(game::defs::AttackEvent{blocked_by_cmp->entity_, stats_cmp.atk});
    }
    return;
}

5)BlockSystem 与 AnimationStateSystem 的职责拆分

if(!registry.valid(blocked_by_comp.entity_)) {
    registry.remove<game::component::BlockedByComponent>(blocked_by_entity);
    if(!registry.all_of<game::defs::ActionLockTag>(blocked_by_entity)){
        dispatcher.enqueue(engine::utils::PlayAnimationEvent{blocked_by_entity, "walk"_hs, true});
    }
}
registry_.remove<game::defs::ActionLockTag>(event.entity_);
if(auto block_cmp = registry_.try_get<game::component::BlockedByComponent>(event.entity_); block_cmp) {
    dispatcher_.enqueue(engine::utils::PlayAnimationEvent{event.entity_, "idle"_hs, true});
} else {
    dispatcher_.enqueue(engine::utils::PlayAnimationEvent{event.entity_, "walk"_hs, true});
}

6)OrientationSystem 的最终优先级

void OrientationSystem::update(entt::registry &registry)
{
    updateHasTargetUnits(registry);
    updateBlockedByUnits(registry);
    updateMovingUnits(registry);
}
auto view = registry.view<engine::component::TransformComponent,
    game::component::TargetComponent,
    engine::component::SpriteComponent>(
        entt::exclude<game::component::BlockedByComponent, game::defs::ActionLockTag>);
auto view = registry.view<engine::component::TransformComponent,
    engine::component::VelocityComponent,
    engine::component::SpriteComponent>(
        entt::exclude<game::component::BlockedByComponent, game::defs::ActionLockTag>);

十六、这节最后最该记住的几个结论

1)攻击起手和伤害结算不是一回事

  • 起手在 AttackStartSystem
  • 结算在 AnimationEventSystem -> CombatResolveSystem

2)远程敌人被阻挡时,不能靠“删掉 TargetComponent”解决问题

  • BlockedByComponent 决定当前近战分支
  • TargetComponent 保留远程上下文

3)动作锁期间,要尽量让别的系统尊重锁

  • TimerSystem:锁中不计时
  • FollowPathSystem:锁中不寻路
  • BlockSystem:锁中不重新建立阻挡
  • OrientationSystem:锁中不再改朝向

4)音效逻辑名和音频资源 id 必须分开看

  • "hit" / "emit" 是逻辑键
  • sword_hit / heal / spell_shoot 才是真正资源 id

5)这节的核心不是“多一个系统”,而是“系统边界更清楚”

当你后面遇到 bug 时,优先先问自己:

  • 是目标系统的问题?
  • 是攻击起手的问题?
  • 是动画事件重复发了?
  • 是动作锁没管住?
  • 是表现系统把前面的状态覆盖掉了?

只要你按这个顺序查,后面的 bug 会好排很多。


十七、参考源码后得到的一个更深的结论

这一节到后面,你会发现一个比“修某个 bug”更重要的收获:

源码真正厉害的地方,不是多写了几个系统,而是职责拆得更彻底

也就是说,源码并不是单纯把逻辑写出来,而是很明确地控制:

  • 谁负责“开始动作”
  • 谁负责“解释动画帧”
  • 谁负责“真正改血量”
  • 谁负责“真正播放音效”
  • 谁负责“动作结束后的状态恢复”

这和一开始最容易写出来的那种“大 if-else 堆在一个系统里”的写法完全不同。

1)AnimationEventSystem 不应该越来越像“大杂烩”

这一节重构以后,更好的写法应该是:

  • onAnimationEvent():只做入口分发
  • handleHitEvent():只处理 "hit"
  • 以后还可以继续扩:
    • handleEmitEvent()
    • handleSpawnEvent()
    • handleSkillEvent()

像这样:

void AnimationEventSystem::onAnimationEvent(const engine::utils::AnimationEvent &event)
{
    if(!registry_.valid(event.entity_)) return;

    if(event.anim_id == "hit"_hs){
        handleHitEvent(event);
    } else if(event.anim_id == "emit"_hs) {
        // 后续 handleEmitEvent(event);
    }
}

这种写法的意义不是“看起来整洁一点”,而是:

  • 入口函数不会无限膨胀
  • 不同事件以后更容易独立改
  • 测某一类交互时,能更快定位问题到底在哪个 handler

2)音效最好彻底交给 AudioSystem 处理

源码式的更彻底做法是:

  • AnimationEventSystem 只负责“现在发生了一个 hit / emit 逻辑事件”
  • AudioSystem 再决定“这个逻辑事件对应哪个真正的声音”

你现在这一版已经比前面清楚很多了,但从习惯上要记住:

逻辑系统尽量少碰播放细节,表现系统尽量少碰战斗判定

也就是说,理想方向永远是:

  • 战斗归战斗系统
  • 音效归音效系统
  • 动画归动画系统
  • 状态恢复归状态系统

只要一个系统开始同时处理:

  • 目标判断
  • 扣血
  • 播音效
  • 切动画
  • 改朝向

那通常就说明职责开始混了。


十八、事件系统里为什么仍然值得做 valid() 防护

这一节后期你也意识到了一个小点:

  • 你主要靠 DeadTag 延迟删除
  • 看起来“正常运行时不太会立刻访问到无效实体”

这句话大方向没错,但这里仍然建议加:

if(!registry_.valid(event.entity_)) return;

原因不是说你当前逻辑一定会炸,而是:

  • dispatcher 是“先排队,再统一分发”
  • 只要存在这种时序,就有可能出现:
    • 事件发出时实体还有效
    • 真正处理到这个事件时,实体已经被销毁

尤其是后面如果继续加:

  • 投射物
  • 连锁伤害
  • 范围技能
  • 清场逻辑

这种边缘情况会更容易出现。

所以这里可以形成一个习惯:

  • 所有基于 dispatcher 的事件回调入口,优先先做有效性检查

这是一个很便宜、但很值的保护。


十九、这一节最后的“系统职责表”

为了之后复习方便,这里把这节主要系统的职责再压缩成一张表:

SetTargetSystem

  • 判断已有目标是否还有效
  • 为无目标单位重新找目标
  • 不负责攻击分支切换
  • 不靠“删 TargetComponent”处理阻挡/远程状态

TimerSystem

  • 只负责累计攻击冷却
  • AttackReadyTagActionLockTag 存在时跳过

AttackStartSystem

  • 只负责开始动作
  • 播对应攻击动画
  • 必要时加 ActionLockTag
  • 不负责真正扣血

AnimationSystem

  • 推进动画播放
  • 切帧时发送 AnimationEvent
  • 非循环动画结束时发送 AnimationFinishedEvent

AnimationEventSystem

  • 把动画事件翻译成业务事件
  • 不直接承担所有战斗逻辑
  • 入口函数尽量只分发,具体事件用 handler 处理

CombatResolveSystem

  • 统一结算伤害 / 治疗
  • DeadTag
  • 打 / 去掉 InjuredTag
  • 处理 blocker 计数恢复

AudioSystem

  • 统一处理 PlaySoundEvent
  • 负责把“逻辑播放请求”落到真正声音资源

BlockSystem

  • 维护阻挡关系
  • 建立 / 解除 BlockedByComponent
  • 非锁定态下恢复 walk
  • 不越权管理动作锁完整生命周期

AnimationStateSystem

  • 监听动作结束
  • 解锁 ActionLockTag
  • 根据当前组件状态恢复 idle / walk

OrientationSystem

  • 按状态优先级更新朝向
  • 不靠动画名推断逻辑

二十、这节之后最自然的下一步

这节做完以后,后续最自然的拓展方向已经很明确了:

1)真正实现 emit

也就是:

  • "hit":近战 / 治疗命中时机
  • "emit":远程发射时机

当前这节里你已经故意把 emit 留空,这是合理的,因为更自然的下一步不是:

  • 直接在 emit 时扣血

而是:

  1. emit 帧创建投射物实体
  2. 投射物记录目标或飞行方向
  3. 投射物命中时再发 AttackEvent

2)补完整远程单位反馈

后面远程单位最适合继续补的是:

  • 发射特效
  • 命中音效
  • 投射物命中效果
  • 受击动画

3)把“事件驱动”继续贯彻下去

这一节已经说明:

  • 把逻辑拆成事件链以后,很多 bug 虽然一开始绕,但长期更容易维护

所以后面继续做新功能时,可以优先先想:

  • 这个行为是不是也该做成事件链?
  • 它应该由哪个系统发起?
  • 它的状态恢复该由哪个系统收口?

如果能先问这三个问题,你后面的系统设计会越来越稳。
// 周报部分:
这周可以说进度有点慢了,这个项目本身难度是有的,我自己独立完成需要一些时间,然后交给ai review返回建议后再不断的进行修缮,加上还有自己的课题部分也要花时间,常常感觉时间不够用啊,本身也不是科班出身,基础也不是特别好,还有一大堆其他事情,我不知道我目前做这样的事是否是正确的,进入游戏这个行业是不是正确的,能不能找到实习,或者说找到工作?做游戏这的确是我热爱的,这点或许是我确定的,但我不知道能不能养活我自己,我也没什么大追求,或者在当下这个环境或许不适合我们这些所谓的大学生了。为什么我读书呢,或许早点工作,或是早点混社会,学门技术可能就不会想这么多了,虽然我不想保持这种悲观的心态,但也只能坚持做着当前的事,也许哪天想开了,那就一定是我开摆混吃等死了

posted @ 2026-04-12 18:28  wenyiGamecpp  阅读(12)  评论(0)    收藏  举报