ECS框架-目标锁定及攻击

目标锁定与攻击

上节我们完成了玩家蓝图、玩家创建以及敌人阻挡关系。现在场上已经同时有了:

  • 会沿路径前进的敌人
  • 可以被放置的玩家单位
  • 可以拦住敌人的近战玩家

但目前它们还只是“站到一起”,还没有形成真正的战斗循环。

这一节要补上的,是战斗里最关键的一条链路:

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

也就是说,我们要让单位从“会站位”升级成“会判断谁该打、什么时候打、打的时候播什么动画、打完回到什么状态”。

image-20260409153951571

image-20260409154009599


学习目标

  • 理解 TargetComponent 在 ECS 里的作用:谁当前正在关注/攻击谁
  • 学会区分三类目标逻辑:
    • 攻击型玩家找敌人
    • 远程敌人找玩家
    • 治疗者找受伤友军
  • 实现 AttackReadyTag + ActionLockTag 这两个状态标签
  • 建立“冷却结束 -> 攻击起手 -> 播放动画 -> 动画结束 -> 恢复状态”的完整流程
  • 学会设计朝向优先级:目标、阻挡者、移动方向谁优先
  • 理解 EnTT 里一个非常重要的坑:遍历 view 时,不要破坏当前 view 的筛选条件

一、战斗循环的总体拆分

这一节不要把所有逻辑塞进 GameScene::update() 里,而是继续延续 ECS 的思想,把战斗流程拆成多个系统:

  • SetTargetSystem:决定谁的目标是谁
  • AttackStartSystem:冷却好了以后,真正开始攻击并播放攻击动画
  • TimerSystem:累计攻击冷却,冷却结束后挂上 AttackReadyTag
  • AnimationStateSystem:攻击动画播完后,恢复 idle / walk
  • OrientationSystem:决定单位当前应该朝向哪里

它们之间的关系大概是:

BlockSystem
    ↓
SetTargetSystem
    ↓
AttackStartSystem
    ↓
AnimationSystem 播攻击动画
    ↓
AnimationFinishedEvent
    ↓
AnimationStateSystem 恢复状态

与此同时:
TimerSystem 负责累计下一次攻击冷却
OrientationSystem 负责当前帧的朝向

这样做的好处是:

  • 每个系统职责单一
  • 后面要加伤害结算、弹道、技能时不容易乱
  • 出 bug 时可以明确判断是“锁敌错了”还是“攻击起手错了”还是“动画状态没切回来”

二、TargetComponent:谁正在盯着谁

文件:src/game/component/target_component.h

目标组件非常简单,只负责保存一个目标实体:

struct TargetComponent {
    entt::entity entity_ {entt::null};
};

它的意义不是“永久绑定”,而是“当前帧/当前阶段这个单位正在把谁当成自己的目标”。

以后不管是近战、远程还是治疗者,都可以统一通过 TargetComponent 进入后续攻击流程。


三、目标锁定系统 SetTargetSystem

文件:

  • src/game/system/set_target_system.h
  • src/game/system/set_target_system.cpp

这个系统的核心任务,是每帧更新“目标是否有效”和“没有目标的单位该去找谁”。

当前可以拆成 4 个子函数:

  • updateHasTarget():已有目标的单位,检查目标是否还有效
  • updatePlayerTarget():攻击型玩家找最近敌人
  • updateHealTarget():治疗者找范围内血量百分比最低的友军
  • updateRangedEnemyTarget():远程敌人找最近玩家

1)已有目标的单位,要先检查目标还是否有效

auto view = registry.view<game::component::TargetComponent>();

检查内容主要有两类:

  1. 目标实体是否已经被销毁
  2. 目标是否已经离开了检测范围

如果失效,就移除 TargetComponent

一个比较完整的写法如下:

void SetTargetSystem::updateHasTarget(entt::registry &registry)
{
    auto view = registry.view<game::component::TargetComponent>();
    std::vector<entt::entity> entities(view.begin(), view.end());

    for (auto entity : entities) {
        auto target = registry.get<game::component::TargetComponent>(entity);

        if (!registry.valid(target.entity_)) {
            registry.remove<game::component::TargetComponent>(entity);
            continue;
        }

        auto &trans_comp = registry.get<engine::component::TransformComponent>(entity);
        auto &target_trans_comp = registry.get<engine::component::TransformComponent>(target.entity_);
        auto &stats_comp = registry.get<game::component::StatsComponent>(entity);

        float check_radius = game::defs::CHECK_RADIUS + stats_comp.range_;
        if (engine::utils::distanceSquared(trans_comp.position_, target_trans_comp.position_) > check_radius * check_radius) {
            registry.remove<game::component::TargetComponent>(entity);
        }
    }
}

这一步很重要,因为后面的 OrientationSystemAttackStartSystem 都会默认“有 TargetComponent 的单位已经有合法目标”。

2)攻击型玩家:找最近敌人

排除条件:

  • 排除治疗者 HealerTag
  • 排除已经有目标的单位

核心思路:

  1. 遍历所有没有目标的玩家攻击单位
  2. 再遍历所有敌人
  3. 找出“检测范围内最近的敌人”
  4. 给当前玩家挂上 TargetComponent

这里最近目标的写法很经典:

if (target_entity == entt::null) {
    target_entity = enemy_entity;
} else {
    // 比较新敌人与旧目标谁更近
}

一个完整版本大致如下:

void SetTargetSystem::updatePlayerTarget(entt::registry &registry)
{
    auto view = registry.view<game::component::PlayerComponent>
        (entt::exclude<game::defs::HealerTag, game::component::TargetComponent>);

    std::vector<entt::entity> entities(view.begin(), view.end());

    for (auto player_entity : entities) {
        auto player_transform = registry.get<engine::component::TransformComponent>(player_entity);
        auto player_stats = registry.get<game::component::StatsComponent>(player_entity);
        auto enemy_view = registry.view<game::component::EnemyComponent>();

        entt::entity target_entity = entt::null;

        for (auto enemy_entity : enemy_view) {
            auto enemy_transform = registry.get<engine::component::TransformComponent>(enemy_entity);

            float check_radius = game::defs::CHECK_RADIUS + player_stats.range_;
            if (engine::utils::distanceSquared(player_transform.position_, enemy_transform.position_) <= check_radius * check_radius) {
                if (target_entity == entt::null) {
                    target_entity = enemy_entity;
                } else {
                    auto target_transform = registry.get<engine::component::TransformComponent>(target_entity);
                    if (engine::utils::distanceSquared(player_transform.position_, enemy_transform.position_) <
                        engine::utils::distanceSquared(player_transform.position_, target_transform.position_)) {
                        target_entity = enemy_entity;
                    }
                }
            }
        }

        if (target_entity != entt::null) {
            registry.emplace<game::component::TargetComponent>(player_entity, target_entity);
        }
    }
}

3)治疗者:找范围内最需要治疗的人

治疗者和攻击型单位最大的区别是:它不是找敌人,而是找受伤友军

所以需要先给受伤单位打上:

  • InjuredTag

然后在 updateHealTarget() 里:

  1. 遍历所有治疗者
  2. 先清掉上一轮旧目标
  3. 遍历所有带 InjuredTag 的玩家
  4. 在范围内选择 血量百分比最低 的那一个
  5. 给治疗者挂上 TargetComponent

这里不要只比较绝对血量,而要比较:

player_stats.hp_ / player_stats.max_hp_

这样血量更低但血条更厚的单位,不会错误地被认为比真正残血单位更危险。

治疗者版本的完整代码会更像这样:

void SetTargetSystem::updateHealTarget(entt::registry &registry)
{
    auto healer_view = registry.view<game::defs::HealerTag, game::component::PlayerComponent>();

    for (auto healer_entity : healer_view) {
        auto healer_transform = registry.get<engine::component::TransformComponent>(healer_entity);
        auto healer_stats = registry.get<game::component::StatsComponent>(healer_entity);

        // 每次重新找目标前,先清掉旧目标
        registry.remove<game::component::TargetComponent>(healer_entity);

        auto injured_view = registry.view<game::component::PlayerComponent, game::defs::InjuredTag>();
        entt::entity target_entity = entt::null;

        for (auto player_entity : injured_view) {
            auto player_transform = registry.get<engine::component::TransformComponent>(player_entity);

            float check_radius = game::defs::CHECK_RADIUS + healer_stats.range_;
            if (engine::utils::distanceSquared(healer_transform.position_, player_transform.position_) <= check_radius * check_radius) {
                if (target_entity == entt::null) {
                    target_entity = player_entity;
                } else {
                    auto target_stats = registry.get<game::component::StatsComponent>(target_entity);
                    auto player_stats = registry.get<game::component::StatsComponent>(player_entity);

                    if (player_stats.hp_ / player_stats.max_hp_ < target_stats.hp_ / target_stats.max_hp_) {
                        target_entity = player_entity;
                    }
                }
            }
        }

        if (target_entity != entt::null) {
            registry.emplace<game::component::TargetComponent>(healer_entity, target_entity);
        }
    }
}

4)远程敌人:找最近的玩家

近战敌人不需要在这里找目标,因为它们的主要攻击对象通常是“阻挡它的玩家单位”。

所以这里主要处理的是:

  • 敌方远程单位
  • 并且当前没有 TargetComponent

逻辑和玩家找敌人类似,也是:

  1. 遍历没有目标的远程敌人
  2. 在范围内找最近玩家
  3. 挂上 TargetComponent

对应代码和玩家版基本平行:

void SetTargetSystem::updateRangedEnemyTarget(entt::registry &registry)
{
    auto view = registry.view<game::component::EnemyComponent, game::defs::RangedUnitTag>
        (entt::exclude<game::component::TargetComponent>);

    std::vector<entt::entity> entities(view.begin(), view.end());

    for (auto enemy_entity : entities) {
        auto enemy_transform = registry.get<engine::component::TransformComponent>(enemy_entity);
        auto enemy_stats = registry.get<game::component::StatsComponent>(enemy_entity);
        auto player_view = registry.view<game::component::PlayerComponent>();

        entt::entity target_entity = entt::null;

        for (auto player_entity : player_view) {
            auto player_transform = registry.get<engine::component::TransformComponent>(player_entity);

            float check_radius = game::defs::CHECK_RADIUS + enemy_stats.range_;
            if (engine::utils::distanceSquared(enemy_transform.position_, player_transform.position_) <= check_radius * check_radius) {
                if (target_entity == entt::null) {
                    target_entity = player_entity;
                } else {
                    auto target_transform = registry.get<engine::component::TransformComponent>(target_entity);
                    if (engine::utils::distanceSquared(enemy_transform.position_, player_transform.position_) <
                        engine::utils::distanceSquared(enemy_transform.position_, target_transform.position_)) {
                        target_entity = player_entity;
                    }
                }
            }
        }

        if (target_entity != entt::null) {
            registry.emplace<game::component::TargetComponent>(enemy_entity, target_entity);
        }
    }
}

四、攻击准备:AttackReadyTag 与 ActionLockTag

这一节最关键的两个标签是:

struct AttackReadyTag {};
struct ActionLockTag {};

它们的含义不同:

AttackReadyTag

表示:

  • 这个单位的攻击冷却已经结束
  • 现在可以进入攻击起手逻辑

ActionLockTag

表示:

  • 这个单位当前正处于某个动作的执行期间
  • 例如正在播放攻击动画
  • 在锁期间,不应该再次开始下一次动作

简化理解就是:

  • AttackReadyTag --- “可以开始”
  • ActionLockTag --- “正在执行,先别打断”

五、AttackStartSystem:真正开始攻击

文件:

  • src/game/system/attack_start_system.h
  • src/game/system/attack_start_system.cpp

这个系统不负责“找目标”,只负责:

  • 哪些单位现在满足攻击条件
  • 满足条件后播放什么动画
  • 是否需要停下来
  • 是否需要加动作锁

它可以拆成 3 部分:

  • updatePlayerAttack()
  • updateRangedEnemyAttack()
  • updateBlockedByAttack()

1)玩家攻击

玩家单位满足条件:

  • PlayerComponent
  • TargetComponent
  • AttackReadyTag

然后:

  • 移除 AttackReadyTag
  • 普通玩家播放 attack
  • 治疗者播放 heal

2)远程敌人攻击

远程敌人满足条件:

  • EnemyComponent
  • TargetComponent
  • AttackReadyTag
  • 排除 BlockedByComponent
  • 排除 ActionLockTag

然后:

  • 移除 AttackReadyTag
  • 加上 ActionLockTag
  • 速度清零
  • 播放 ranged_attack

这里远程敌人需要锁,是因为它在播放攻击动画期间,不应该再重新起手,也不应该继续移动。

参考代码:

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});
    }
}

3)被阻挡的近战敌人攻击

满足条件:

  • EnemyComponent
  • BlockedByComponent
  • AttackReadyTag
  • 排除 ActionLockTag

然后:

  • 移除 AttackReadyTag
  • 加上 ActionLockTag
  • 播放 attack

这里特别容易漏掉的一点是:

如果你只在远程敌人分支里加 ActionLockTag,却忘了在被阻挡攻击分支里也加锁,那么近战敌人就可能在攻击动画播到一半时再次重复起手。

代码参考:

void AttackStartSystem::updateBlockedByAttack(entt::registry& registry, entt::dispatcher& dispatcher)
{
    auto view = registry.view<game::component::EnemyComponent,
        game::component::BlockedByComponent,
        game::defs::AttackReadyTag>(entt::exclude<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);
        dispatcher.enqueue(engine::utils::PlayAnimationEvent{entity, "attack"_hs, false});
    }
}

六、TimerSystem:攻击冷却

文件:

  • src/game/system/timer_system.h
  • src/game/system/timer_system.cpp

当前 StatsComponent 已经有两个字段:

float atk_interval_{};
float atk_timer_{};

它们分别表示:

  • atk_interval_:攻击间隔
  • atk_timer_:当前已经累计了多少时间

当前推荐的设计约定

这里建议把 atk_interval_ 理解成:

两次起手之间的冷却时间

而不是“动画时间 + 冷却时间的总和”。

这样一来,TimerSystem 的逻辑就应该是:

  1. 如果已经有 AttackReadyTag,跳过
  2. 如果有 ActionLockTag,也跳过
  3. 只有既不 ready、也不 locked 的单位,才继续累计 atk_timer_
  4. 达到 atk_interval_ 后:
    • atk_timer_ = 0
    • 挂上 AttackReadyTag

这套规则的好处是:

  • 攻击动画播放期间不会偷偷累计下一次冷却
  • 攻击节奏更稳定
  • 不会出现“动画还没播完,冷却已经好了”的奇怪观感

一个推荐实现如下:

void TimerSystem::update(entt::registry &registry, float delta_time)
{
    auto view = registry.view<game::component::StatsComponent>();

    for (auto entity : view) {
        auto &stats = view.get<game::component::StatsComponent>(entity);

        bool ready = registry.all_of<game::defs::AttackReadyTag>(entity);
        bool locked = registry.all_of<game::defs::ActionLockTag>(entity);

        if (ready || locked) {
            continue;
        }

        stats.atk_timer_ += delta_time;
        if (stats.atk_timer_ >= stats.atk_interval_) {
            stats.atk_timer_ = 0.0f;
            registry.emplace<game::defs::AttackReadyTag>(entity);
        }
    }
}

七、AnimationStateSystem:攻击动画结束后恢复状态

文件:

  • src/game/system/animation_state_system.h
  • src/game/system/animation_state_system.cpp

前面的系统只负责“开始攻击”,但动画播完以后如果不做恢复,单位就会一直停在攻击动画最后一帧。

所以这里引入:

  • AnimationFinishedEvent

监听动画结束后做恢复:

玩家

  • 攻击/治疗动画播完后,切回 idle

敌人

  • 如果当前仍然被阻挡,切回 idle
  • 否则切回 walk
  • 并恢复移动速度

同时这里还要统一移除:

  • ActionLockTag

这一步相当于告诉后面的系统:

这次攻击动作已经完整结束,可以重新进入冷却和下一轮动作了。

代码参考:

void AnimationStateSystem::onFinishAnimationEvent(const engine::utils::AnimationFinishedEvent &event)
{
    registry_.remove<game::defs::ActionLockTag>(event.entity_);

    if (registry_.all_of<game::component::PlayerComponent>(event.entity_)) {
        dispatcher_.enqueue(engine::utils::PlayAnimationEvent{event.entity_, "idle"_hs, true});
    }
    else if (registry_.all_of<game::component::EnemyComponent>(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});
            auto enemy_cmp = registry_.get<game::component::EnemyComponent>(event.entity_);
            registry_.emplace_or_replace<engine::component::VelocityComponent>(event.entity_, glm::vec2(enemy_cmp.speed_));
        }
    }
}

八、朝向系统:真正难的是优先级

文件:

  • src/game/system/orientation_system.h
  • src/game/system/orientation_system.cpp

朝向系统看起来不复杂,但实际上很容易出现“前一个子函数刚设好的朝向,被后一个子函数覆盖掉”的问题。

当前可以拆成:

  • updateHasTargetUnits()
  • updateBlockedByUnits()
  • updateMovingUnits()

1)有目标的单位

朝向目标实体。

这个规则适用于:

  • 攻击中的远程敌人
  • 治疗中的治疗者
  • 正在攻击动作里的单位

2)被阻挡的敌人

朝向阻挡自己的玩家单位。

这是近战敌人的优先逻辑。

3)移动单位

朝向速度方向。

但是这里有一个很隐蔽的细节:

updateMovingUnits() 不能处理那些已经进入动作锁的实体。

原因是:

  1. AttackStartSystem 已经给这类单位加了 ActionLockTag
  2. 并且很多时候还会把速度设成 0
  3. 如果 updateMovingUnits() 不排除 ActionLockTag,它就会拿着 0 速度把前面已经设好的朝向又覆盖掉

所以移动朝向那部分至少要排除:

  • BlockedByComponent
  • ActionLockTag

也就是说,最终优先级应该类似:

  • 被阻挡:朝阻挡者
  • 动作锁中且有目标:朝目标
  • 真正还在移动:朝速度方向

一个更稳定的 updateMovingUnits() 写法如下:

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

    for (auto entity : view) {
        auto &vel_cmp = view.get<engine::component::VelocityComponent>(entity);
        auto &sprite_cmp = view.get<engine::component::SpriteComponent>(entity);

        bool face_right = vel_cmp.velocity_.x > 0;
        if (registry.all_of<game::defs::FaceLeftTag>(entity)) {
            sprite_cmp.sprite_.is_flipped = face_right;
        } else {
            sprite_cmp.sprite_.is_flipped = !face_right;
        }
    }
}

九、GameScene 中的系统顺序

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

战斗逻辑除了每个系统内部写对,还要求 更新顺序合理

当前比较推荐的顺序是:

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

这个顺序背后的原则是:

  • 先更新状态
  • 再更新表现

例如:

  • 先决定是否被阻挡、是否有目标、是否开始攻击
  • 再根据这些状态去更新速度、朝向、动画

如果把顺序写反了,就会出现“这一帧状态已经变化了,但表现要下一帧才跟上”的一帧延迟问题。


十、测试入口:在 GameScene 中手动造场景

为了快速测试,这一节可以在 GameScene 里添加一些测试函数。

例如:

  • mouse_left:创建受伤近战玩家
  • mouse_right:创建受伤远程玩家
  • jump:创建治疗者
  • pause:清空所有玩家

治疗测试的关键不是只创建治疗者,而是要确保友军真的进入“受伤状态”。

例如:

auto entity = entity_factory_->createPlayerUnit("warrior"_hs, mouse_position);
auto& stats = registry_.get<game::component::StatsComponent>(entity);
stats.hp_ /= 2;
registry_.emplace<game::defs::InjuredTag>(entity);

这里有一个很容易犯的错误:

auto stats = registry_.get<game::component::StatsComponent>(entity);

如果你这样写,stats 会是一个副本,改血量不会影响实体本体。

正确写法应该是:

auto& stats = registry_.get<game::component::StatsComponent>(entity);

例如你现在在 GameScene 中可以这样写测试函数:

bool GameScene::onCreateTestPlayerMelee()
{
    auto mouse_position = context_.getInputManager().getLogicalMousePosition();

    auto entity = entity_factory_->createPlayerUnit("warrior"_hs, mouse_position);
    auto& stats = registry_.get<game::component::StatsComponent>(entity);
    stats.hp_ /= 2;
    registry_.emplace<game::defs::InjuredTag>(entity);
    return true;
}

bool GameScene::onCreateTestHealer()
{
    auto mouse_position = context_.getInputManager().getLogicalMousePosition();
    entity_factory_->createPlayerUnit("witch"_hs, mouse_position);
    return true;
}

十一、EnTT 函数与标签判断

这一节常用的几个函数:

  • registry.all_of<Tag>(entity)
    判断实体是否有某个组件或标签

  • registry.try_get<Component>(entity)
    尝试获取组件,失败返回空指针

  • registry.emplace<Component>(entity, ...)
    添加组件

  • registry.emplace_or_replace<Component>(entity, ...)
    有则替换,无则添加

  • registry.remove<Component>(entity)
    移除组件

其中最常用的一种判断方式就是:

registry.all_of<game::defs::HealerTag>(entity)

它可以直接告诉我们:

  • 当前实体是不是治疗者

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

1)遍历 view 时破坏当前 view 条件

这是这一节最典型的 EnTT 坑。

例如:

auto view = registry.view<game::component::EnemyComponent,
    game::component::TargetComponent,
    game::defs::AttackReadyTag>();

for (auto entity : view) {
    registry.remove<game::defs::AttackReadyTag>(entity);
}

这里 view 本身就依赖 AttackReadyTag,你在遍历过程中把它删掉,就属于:

一边遍历视图,一边修改视图条件

这很容易导致迭代器失效。

最稳妥的写法是:

std::vector<entt::entity> entities(view.begin(), view.end());
for (auto entity : entities) {
    ...
}

例如下面这种写法就有风险:

auto view = registry.view<game::component::EnemyComponent,
    game::component::TargetComponent,
    game::defs::AttackReadyTag>();

for (auto entity : view) {
    registry.remove<game::defs::AttackReadyTag>(entity);
}

而下面这样就更稳:

auto view = registry.view<game::component::EnemyComponent,
    game::component::TargetComponent,
    game::defs::AttackReadyTag>();

std::vector<entt::entity> entities(view.begin(), view.end());
for (auto entity : entities) {
    registry.remove<game::defs::AttackReadyTag>(entity);
}

经验法则:

  • 遍历 view<A, B, C>
  • 尽量不要在循环里直接增删 A/B/C
  • exclude<T> 里的 T 也算 view 条件
  • 最稳的是先记下实体,再做修改

2)朝向系统里后面的子函数覆盖前面的结果

看起来每个子函数都没错,但系统顺序叠起来以后,后面的 updateMovingUnits() 很可能把前面的“朝目标”结果又覆盖掉。

所以朝向系统必须想清楚:

  • 谁优先
  • 哪些实体应该被后续子函数排除

3)动作锁只加了一半

如果你只给远程敌人攻击分支加 ActionLockTag,却忘了给被阻挡攻击分支加锁,那么近战敌人仍然会在攻击动画中途重复起手。

4)冷却系统里 return 写成整函数返回

例如:

if (ready) {
    return;
}

这样会让整个 TimerSystem::update() 提前结束,导致后面的实体都不计时了。

这里通常应该写成:

continue;

十三、本节总结

这一节真正完成的,不是“让单位播了一个攻击动画”,而是建立了一个完整战斗闭环:

  • 单位会找目标
  • 单位会判断目标是否失效
  • 冷却结束后会起手攻击
  • 攻击时会播放对应动画
  • 动画期间用 ActionLockTag 锁住动作
  • 动画结束后恢复到合适状态
  • 朝向系统会根据当前状态决定最终朝向

到这里,项目里就已经有了塔防/战棋/RTS 类游戏最基础的“单位战斗骨架”。

后面再往下扩展时,最自然的下一步通常就是:

  • 伤害结算
  • 治疗结算
  • 弹道 / 投射物
  • 受击动画 / 死亡动画
  • 技能与特效

十四、建议与改进方向

如果你已经完成了这一节,后续可以继续优化的点有:

1)把 TargetComponent 的职责保持纯粹

它只负责“当前目标是谁”,不要把“是否正在攻击”“是否可以移动”也混进去。

2)把“冷却”和“动作占用”分开理解

  • AttackReadyTag:节奏
  • ActionLockTag:动作执行中

两者分开之后,系统会清晰很多。

3)朝向优先级一定要显式设计

后面一旦有更多状态,例如受击、施法、死亡,如果不提前设计好优先级,朝向系统会越来越容易互相覆盖。

4)测试函数尽量保留一段时间

onCreateTestPlayerMelee()onCreateTestHealer() 这种快速造场景入口,在后面继续做伤害和治疗时会非常有用,不要太早删掉。

posted @ 2026-04-10 21:14  wenyiGamecpp  阅读(5)  评论(0)    收藏  举报