ECS框架-目标锁定及攻击
目标锁定与攻击
上节我们完成了玩家蓝图、玩家创建以及敌人阻挡关系。现在场上已经同时有了:
- 会沿路径前进的敌人
- 可以被放置的玩家单位
- 可以拦住敌人的近战玩家
但目前它们还只是“站到一起”,还没有形成真正的战斗循环。
这一节要补上的,是战斗里最关键的一条链路:
- 目标锁定
- 攻击起手
- 攻击冷却
- 攻击动画切换
- 动画结束后的状态恢复
也就是说,我们要让单位从“会站位”升级成“会判断谁该打、什么时候打、打的时候播什么动画、打完回到什么状态”。


学习目标
- 理解
TargetComponent在 ECS 里的作用:谁当前正在关注/攻击谁 - 学会区分三类目标逻辑:
- 攻击型玩家找敌人
- 远程敌人找玩家
- 治疗者找受伤友军
- 实现
AttackReadyTag+ActionLockTag这两个状态标签 - 建立“冷却结束 -> 攻击起手 -> 播放动画 -> 动画结束 -> 恢复状态”的完整流程
- 学会设计朝向优先级:目标、阻挡者、移动方向谁优先
- 理解 EnTT 里一个非常重要的坑:遍历 view 时,不要破坏当前 view 的筛选条件
一、战斗循环的总体拆分
这一节不要把所有逻辑塞进 GameScene::update() 里,而是继续延续 ECS 的思想,把战斗流程拆成多个系统:
SetTargetSystem:决定谁的目标是谁AttackStartSystem:冷却好了以后,真正开始攻击并播放攻击动画TimerSystem:累计攻击冷却,冷却结束后挂上AttackReadyTagAnimationStateSystem:攻击动画播完后,恢复 idle / walkOrientationSystem:决定单位当前应该朝向哪里
它们之间的关系大概是:
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.hsrc/game/system/set_target_system.cpp
这个系统的核心任务,是每帧更新“目标是否有效”和“没有目标的单位该去找谁”。
当前可以拆成 4 个子函数:
updateHasTarget():已有目标的单位,检查目标是否还有效updatePlayerTarget():攻击型玩家找最近敌人updateHealTarget():治疗者找范围内血量百分比最低的友军updateRangedEnemyTarget():远程敌人找最近玩家
1)已有目标的单位,要先检查目标还是否有效
auto view = registry.view<game::component::TargetComponent>();
检查内容主要有两类:
- 目标实体是否已经被销毁
- 目标是否已经离开了检测范围
如果失效,就移除 TargetComponent。
一个比较完整的写法如下:
void SetTargetSystem::updateHasTarget(entt::registry ®istry)
{
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);
}
}
}
这一步很重要,因为后面的 OrientationSystem、AttackStartSystem 都会默认“有 TargetComponent 的单位已经有合法目标”。
2)攻击型玩家:找最近敌人
排除条件:
- 排除治疗者
HealerTag - 排除已经有目标的单位
核心思路:
- 遍历所有没有目标的玩家攻击单位
- 再遍历所有敌人
- 找出“检测范围内最近的敌人”
- 给当前玩家挂上
TargetComponent
这里最近目标的写法很经典:
if (target_entity == entt::null) {
target_entity = enemy_entity;
} else {
// 比较新敌人与旧目标谁更近
}
一个完整版本大致如下:
void SetTargetSystem::updatePlayerTarget(entt::registry ®istry)
{
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() 里:
- 遍历所有治疗者
- 先清掉上一轮旧目标
- 遍历所有带
InjuredTag的玩家 - 在范围内选择 血量百分比最低 的那一个
- 给治疗者挂上
TargetComponent
这里不要只比较绝对血量,而要比较:
player_stats.hp_ / player_stats.max_hp_
这样血量更低但血条更厚的单位,不会错误地被认为比真正残血单位更危险。
治疗者版本的完整代码会更像这样:
void SetTargetSystem::updateHealTarget(entt::registry ®istry)
{
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
逻辑和玩家找敌人类似,也是:
- 遍历没有目标的远程敌人
- 在范围内找最近玩家
- 挂上
TargetComponent
对应代码和玩家版基本平行:
void SetTargetSystem::updateRangedEnemyTarget(entt::registry ®istry)
{
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.hsrc/game/system/attack_start_system.cpp
这个系统不负责“找目标”,只负责:
- 哪些单位现在满足攻击条件
- 满足条件后播放什么动画
- 是否需要停下来
- 是否需要加动作锁
它可以拆成 3 部分:
updatePlayerAttack()updateRangedEnemyAttack()updateBlockedByAttack()
1)玩家攻击
玩家单位满足条件:
PlayerComponentTargetComponentAttackReadyTag
然后:
- 移除
AttackReadyTag - 普通玩家播放
attack - 治疗者播放
heal
2)远程敌人攻击
远程敌人满足条件:
EnemyComponentTargetComponentAttackReadyTag- 排除
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)被阻挡的近战敌人攻击
满足条件:
EnemyComponentBlockedByComponentAttackReadyTag- 排除
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.hsrc/game/system/timer_system.cpp
当前 StatsComponent 已经有两个字段:
float atk_interval_{};
float atk_timer_{};
它们分别表示:
atk_interval_:攻击间隔atk_timer_:当前已经累计了多少时间
当前推荐的设计约定
这里建议把 atk_interval_ 理解成:
两次起手之间的冷却时间
而不是“动画时间 + 冷却时间的总和”。
这样一来,TimerSystem 的逻辑就应该是:
- 如果已经有
AttackReadyTag,跳过 - 如果有
ActionLockTag,也跳过 - 只有既不 ready、也不 locked 的单位,才继续累计
atk_timer_ - 达到
atk_interval_后:atk_timer_ = 0- 挂上
AttackReadyTag
这套规则的好处是:
- 攻击动画播放期间不会偷偷累计下一次冷却
- 攻击节奏更稳定
- 不会出现“动画还没播完,冷却已经好了”的奇怪观感
一个推荐实现如下:
void TimerSystem::update(entt::registry ®istry, 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.hsrc/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.hsrc/game/system/orientation_system.cpp
朝向系统看起来不复杂,但实际上很容易出现“前一个子函数刚设好的朝向,被后一个子函数覆盖掉”的问题。
当前可以拆成:
updateHasTargetUnits()updateBlockedByUnits()updateMovingUnits()
1)有目标的单位
朝向目标实体。
这个规则适用于:
- 攻击中的远程敌人
- 治疗中的治疗者
- 正在攻击动作里的单位
2)被阻挡的敌人
朝向阻挡自己的玩家单位。
这是近战敌人的优先逻辑。
3)移动单位
朝向速度方向。
但是这里有一个很隐蔽的细节:
updateMovingUnits()不能处理那些已经进入动作锁的实体。
原因是:
AttackStartSystem已经给这类单位加了ActionLockTag- 并且很多时候还会把速度设成
0 - 如果
updateMovingUnits()不排除ActionLockTag,它就会拿着0速度把前面已经设好的朝向又覆盖掉
所以移动朝向那部分至少要排除:
BlockedByComponentActionLockTag
也就是说,最终优先级应该类似:
- 被阻挡:朝阻挡者
- 动作锁中且有目标:朝目标
- 真正还在移动:朝速度方向
一个更稳定的 updateMovingUnits() 写法如下:
void OrientationSystem::updateMovingUnits(entt::registry ®istry)
{
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
战斗逻辑除了每个系统内部写对,还要求 更新顺序合理。
当前比较推荐的顺序是:
RemoveDeadSystemBlockSystemSetTargetSystemAttackStartSystemTimerSystemFollowPathSystemMovementSystemOrientationSystemAnimationSystemYsortSystem
这个顺序背后的原则是:
- 先更新状态
- 再更新表现
例如:
- 先决定是否被阻挡、是否有目标、是否开始攻击
- 再根据这些状态去更新速度、朝向、动画
如果把顺序写反了,就会出现“这一帧状态已经变化了,但表现要下一帧才跟上”的一帧延迟问题。
十、测试入口:在 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() 这种快速造场景入口,在后面继续做伤害和治疗时会非常有用,不要太早删掉。

浙公网安备 33010602011771号