ECS框架-远程攻击与弹道
远程攻击与弹道
这一节的核心内容,是把之前已经搭好的近战攻击链路继续往前推进,扩展出一套完整的远程攻击流程:
- 远程单位播放攻击动画
- 在动画的某一帧触发
"emit"发射事件 - 生成投射物实体
- 投射物沿着弹道飞行
- 飞行结束后触发命中结算
这一步做完之后,战斗系统就不再只是“近战单位贴脸命中”,而是开始具备“动画驱动攻击时机 + 投射物飞行 + 命中结算”的完整表现。
学习目标
- 理解远程攻击为什么不能直接在攻击起手时立刻造成伤害
- 理解动画事件在远程攻击中的作用:
"emit"负责发射,"hit"负责命中 - 学会设计
EmitProjectileEvent - 学会给投射物单独设计
ProjectileComponent - 理解投射物为什么要保存“起点快照”和“终点快照”
- 掌握线性插值
lerp的基本思想和计算方式 - 理解抛物线偏移是如何叠加到线性插值轨迹上的
- 理解为什么箭矢旋转角度要根据“前一帧位置 -> 当前帧位置”的方向来算
这一节解决的核心问题
如果远程攻击仍然沿用近战思路,在动画开始时直接扣血,就会有几个明显问题:
- 箭还没飞出去,目标已经掉血了,表现不自然
- 远程单位和近战单位的攻击时机完全一样,缺少区分
- 后续很难扩展出子弹、火球、治疗弹、爆炸物等不同表现
因此这一节要把“造成伤害”拆成两步:
- 动画某一帧先发射投射物
- 投射物飞到目标后再触发真正伤害
这样之后,攻击表现、飞行轨迹、命中反馈就都能独立演化。
整体链路
远程攻击的完整调用链路大致如下:
AttackStartSystem
-> 播放 ranged_attack 动画
AnimationSystem
-> 在指定动画帧发送 AnimationEvent("emit")
AnimationEventSystem
-> 处理 emit 事件,发送 EmitProjectileEvent
ProjectileSystem
-> 创建投射物
-> 每帧更新投射物位置
-> 飞行结束时发送 AttackEvent
CombatResolveSystem
-> 处理伤害,扣血 / 死亡 / 受伤
这里最关键的一点是:
远程单位不直接命中目标,而是先发射一个独立的“投射物实体”。
相关数据结构
这一节里新增或重点使用到的数据结构,大致有这几个:
struct AttackEvent{
entt::entity attacker_;
entt::entity target_;
float damage_;
};
struct HealEvent{
entt::entity healer_;
entt::entity target_;
float heal_;
};
struct EmitProjectileEvent{
entt::id_type projectile_id_;
glm::vec2 start_pos_;
glm::vec2 end_pos_;
entt::entity target_entity_;
float damage_;
};
以及投射物实体真正挂载的数据:
struct ProjectileComponent {
float arc_height_{0};
float current_flight_time_{0};
float total_flight_time_{0};
glm::vec2 start_position_{0};
glm::vec2 previous_position_{0};
glm::vec2 target_position_{0};
entt::entity target_{entt::null};
float damage_{0};
};
这里能看到一个很重要的设计习惯:
- 事件 负责把数据从一个系统传给另一个系统
- 组件 负责把实体运行过程中需要持续保存的数据挂在实体身上
所以:
EmitProjectileEvent是“投射物即将创建时的一次性数据”ProjectileComponent是“投射物创建完成后,每一帧都要用到的持续数据”
事件设计:为什么需要 EmitProjectileEvent
如果只是简单传“攻击者实体”和“目标实体”,看起来似乎也能生成投射物,但这样耦合太强。
因为当投射物真正开始飞行之后:
- 发射者可能已经死了
- 目标可能已经移动了
- 目标甚至可能已经死了
如果投射物每一帧都回头依赖这些实时实体状态,逻辑就会很脆弱。
更稳的方式是:
在发射瞬间,把投射物真正需要的数据先快照下来。
比如:
struct EmitProjectileEvent{
entt::id_type projectile_id_;
glm::vec2 start_pos_;
glm::vec2 end_pos_;
entt::entity target_entity_;
float damage_;
};
这里的意义是:
projectile_id_:发射什么投射物start_pos_:从哪里发射end_pos_:发射那一刻,目标当时在哪里target_entity_:命中时要打谁damage_:基础伤害
这就是“快照思想”。
在代码里,发射事件通常是由动画事件系统发送的:
void AnimationEventSystem::handleEmitEvent(const engine::utils::AnimationEvent &event)
{
if (registry_.all_of<game::component::ProjectileIDComponent>(event.entity_)) {
auto& proj_id_cmp = registry_.get<game::component::ProjectileIDComponent>(event.entity_);
auto& trans_cmp = registry_.get<engine::component::TransformComponent>(event.entity_);
auto target_cmp = registry_.try_get<game::component::TargetComponent>(event.entity_);
if (!target_cmp || !registry_.valid(target_cmp->entity_)) return;
const glm::vec2& target_pos =
registry_.get<engine::component::TransformComponent>(target_cmp->entity_).position_;
auto& stats_cmp = registry_.get<game::component::StatsComponent>(event.entity_);
dispatcher_.enqueue(game::defs::EmitProjectileEvent{
proj_id_cmp.id,
trans_cmp.position_,
target_pos,
target_cmp->entity_,
stats_cmp.atk
});
}
}
这一段逻辑非常值得记住:
- 先确认发射者真的有
ProjectileIDComponent - 再确认目标组件存在,而且目标实体依然有效
- 然后把发射瞬间需要的数据全部打包进事件里
为什么投射物要保存起点和终点
这是这节里最重要的设计点之一。
投射物飞行时,不应该每一帧都重新去读“目标当前坐标”,否则会出现两个问题:
- 弹道会变成“追踪弹”
- 插值轨迹会越来越乱
更合理的方式是:
- 发射那一刻记录起点
start_pos_ - 发射那一刻记录目标位置
target_pos_ - 之后整段飞行都基于这两个固定点做插值
这样一来:
- 轨迹稳定
- 结果可预测
- 系统职责清晰
所以 ProjectileComponent 通常会保存类似这些数据:
struct ProjectileComponent {
float arc_height_{0};
float current_flight_time_{0};
float total_flight_time_{0};
glm::vec2 start_pos_{0}; // 起始位置
glm::vec2 pre_pos_{0}; // 前一帧位置
glm::vec2 target_pos_{0}; // 目标位置
entt::entity target_{entt::null};
float damage_{0};
};
其中:
start_pos_用来做整段飞行的基准插值target_pos_表示本次飞行要去的终点pre_pos_用来计算当前运动方向,从而更新旋转角度
对应到实体工厂里,创建投射物通常会像这样:
entt::entity EntityFactory::createProjectile(
entt::id_type projectile_id,
const glm::vec2& start_position,
const glm::vec2& target_position,
entt::entity target,
float damage)
{
const auto& blueprint = blueprint_manager_.getProjectileClassBlueprint(projectile_id);
auto entity = registry_.create();
registry_.emplace<game::component::ProjectileComponent>(
entity,
blueprint.projectile_.arc_height_,
0.0f,
blueprint.projectile_.total_flight_time_,
start_position,
start_position,
target_position,
target,
damage
);
addTransformComponent(entity, start_position);
addSpriteComponent(entity, blueprint.sprite_);
addAudioComponent(entity, blueprint.sound_);
registry_.emplace<engine::component::RenderComponent>(
entity,
engine::component::RenderComponent::MAIN_LAYER + 1
);
return entity;
}
这个创建函数里,最关键的一步不是加 sprite,也不是加 render,
而是把飞行所需的数据一次性补齐。
线性插值 Lerp
这一节另一个非常重要的知识点是 线性插值。
它的作用很简单:
已知两个点 A 和 B,给一个进度 t,算出从 A 到 B 中间的某个位置。
其中:
t = 0时,在起点t = 1时,在终点t = 0.5时,在中点
二维情况下,公式可以写成:
pos = A + (B - A) * t;
也可以写成等价形式:
pos = B + (A - B) * (1 - t);
或者如果只强调“从 A 走向 B”,也常写成:
pos = (1 - t) * A + t * B;
在 glm 里,可以直接写成:
glm::vec2 pos = glm::mix(A, B, t);
glm::mix(A, B, t) 本质上就是线性插值。
如果把公式稍微展开,会更容易理解:
pos = A + (B - A) * t;
可以拆成两步:
B - A // 从 A 指向 B 的方向和距离
(B - A) * t // 走这段距离的 t 比例
A + (...) // 从 A 出发,往 B 的方向走过去
所以它本质上不是一个“神奇公式”,而是:
- 先求方向
- 再按比例走过去
这也是为什么它特别适合做:
- 子弹飞行
- 摄像机平滑移动
- UI 动画
- 角色补间
线性插值的直观理解
假设:
- 起点
A = (0, 0) - 终点
B = (100, 0)
那么:
t = 0.0 -> pos = (0, 0)
t = 0.25 -> pos = (25, 0)
t = 0.5 -> pos = (50, 0)
t = 0.75 -> pos = (75, 0)
t = 1.0 -> pos = (100, 0)
这就是一条直线上的匀速移动。
你现在投射物的基础飞行部分,本质上就是:
float t = current_flight_time / total_flight_time;
trans.position_ = glm::mix(start_pos_, target_pos_, t);
意思是:
“用已经飞行的时间占总飞行时间的比例,来决定此刻应该处在起点到终点之间的哪个位置。”
如果把它换成更完整一点的写法,就是:
float t = proj_cmp.current_flight_time_ / proj_cmp.total_flight_time_;
t = glm::clamp(t, 0.0f, 1.0f);
trans_cmp.position_ = glm::mix(proj_cmp.start_position_, proj_cmp.target_position_, t);
这里 clamp 的意义是防止 t 超过 [0, 1] 范围,避免出现插值越界。
抛物线轨迹是怎么来的
只做线性插值,得到的是直线飞行。
但箭矢、火球这类远程攻击通常希望带一点弧线,所以会在直线轨迹基础上,再叠加一个竖直方向偏移。
常见写法:
float arc_offset = glm::sin(t * glm::pi<float>()) * arc_height_;
trans.position_.y -= arc_offset;
这段的含义是:
- 当
t = 0时,sin(0) = 0 - 当
t = 0.5时,sin(pi / 2) = 1 - 当
t = 1时,sin(pi) = 0
所以:
- 刚发射时偏移为 0
- 飞到中间时偏移最大
- 飞到终点时偏移又回到 0
这就形成了一个“中间拱起来”的弧线。
如果坐标系里 y 轴向下为正,那么:
position.y -= arc_offset;
就表示“向上抬一段距离”。
因此,投射物完整的位置更新往往会写成:
float t = proj_cmp.current_flight_time_ / proj_cmp.total_flight_time_;
trans_cmp.position_ = glm::mix(proj_cmp.start_position_, proj_cmp.target_position_, t);
float arc_offset = glm::sin(t * glm::pi<float>()) * proj_cmp.arc_height_;
trans_cmp.position_.y -= arc_offset;
可以把它理解成两层:
- 先算“水平上的直线位置”
- 再叠加“竖直方向的弧线偏移”
最后组合成一个抛物线效果。
为什么不能把当前坐标继续拿来插值
这是这节里很容易犯错的一点。
错误思路大概像这样:
position = glm::mix(position, target_position, t);
问题在于:
position已经是上一帧算过一次的结果- 你下一帧又拿这个“被改过的当前位置”继续插值
这样会导致轨迹逐帧漂移,最后飞行路径不再是“固定起点到固定终点”的轨道。
正确做法应该是始终从固定起点出发:
position = glm::mix(start_pos_, target_pos_, t);
这也是为什么 ProjectileComponent 里必须保存 start_pos_,而不是只保存当前坐标。
可以专门对比一下:
错误写法:
position = glm::mix(position, target_position, t);
问题:
- 每一帧的起点都变了
- 路径不是稳定轨道
- 很像“不断纠偏”的追踪弹
正确写法:
position = glm::mix(start_position, target_position, t);
优点:
- 起点固定
- 终点固定
- 整段轨迹稳定
- 结果容易预测
这是这节里很关键的一个分界线。
rotation 旋转角度的知识点
投射物除了位置会变化,朝向通常也要变化。
否则箭虽然沿着弧线飞,但贴图始终保持一个固定角度,看起来就会很假。
关键思路是:
- 用“当前帧位置 - 前一帧位置”得到运动方向向量
- 再把这个方向向量转成角度
例如:
auto dir = trans_cmp.position_ - proj_cmp.previous_position_;
trans_cmp.rotation_ = glm::atan(dir.y, dir.x) * 180 / glm::pi<float>();
proj_cmp.previous_position_ = trans_cmp.position_;
这里 dir 的含义是:
“这一帧相对上一帧,投射物往哪个方向移动了。”
这个方向,就是当前轨迹的切线方向。
完整一点的投射物 update 通常会像这样:
void ProjectileSystem::update(float delta_time)
{
auto view = registry_.view<game::component::ProjectileComponent,
engine::component::TransformComponent>();
for (auto entity : view) {
auto& trans_cmp = registry_.get<engine::component::TransformComponent>(entity);
auto& proj_cmp = registry_.get<game::component::ProjectileComponent>(entity);
proj_cmp.current_flight_time_ += delta_time;
if (proj_cmp.current_flight_time_ >= proj_cmp.total_flight_time_) {
registry_.emplace<game::defs::DeadTag>(entity);
if (!registry_.valid(proj_cmp.target_)) continue;
dispatcher_.enqueue(game::defs::AttackEvent{entity, proj_cmp.target_, proj_cmp.damage_});
dispatcher_.enqueue(engine::utils::PlaySoundEvent{entity, "hit"_hs});
continue;
}
float t = proj_cmp.current_flight_time_ / proj_cmp.total_flight_time_;
trans_cmp.position_ = glm::mix(proj_cmp.start_position_, proj_cmp.target_position_, t);
float arc_offset = glm::sin(t * glm::pi<float>()) * proj_cmp.arc_height_;
trans_cmp.position_.y -= arc_offset;
auto dir = trans_cmp.position_ - proj_cmp.previous_position_;
trans_cmp.rotation_ = glm::atan(dir.y, dir.x) * 180.0f / glm::pi<float>();
proj_cmp.previous_position_ = trans_cmp.position_;
}
}
这段代码其实就把第19节的几个核心知识点都串起来了:
- 时间进度
- 线性插值
- 抛物线偏移
- 旋转角度
- 命中结算
为什么 rotation 要用前一帧位置算
因为你的弹道不是直线,而是曲线。
如果只用:
target_pos - start_pos
那算出来的是“整段路径的大方向”,这个角度是固定不变的。
但真实抛物线飞行时:
- 刚发射时是向上斜飞
- 飞到中间时接近水平
- 落下时又变成向下斜飞
所以箭的朝向应该随着曲线不断变化。
而:
当前位置 - 前一帧位置
近似表示的是“这一小段轨迹的切线方向”,也就是此刻真正的飞行方向。
这就是为什么这套算法看起来会更自然。
glm::atan(dir.y, dir.x) 到底算的是什么
这个函数本质上和常见的 atan2(y, x) 是一类东西。
它会根据向量 (x, y) 计算出它相对 x 轴正方向的夹角。
例如:
(1, 0)->0°(0, 1)->90°(-1, 0)->180°(0, -1)->-90°
所以:
glm::atan(dir.y, dir.x)
得到的是 弧度,再乘:
180 / pi
才变成 角度。
如果你的箭贴图默认朝右,那么这个角度通常可以直接用。
如果贴图默认朝上、朝左、朝下,就需要再加一个固定偏移角。
比如:
rotation = angle + 90.0f;
或者:
rotation = angle - 90.0f;
这个要看美术资源默认朝向决定。
这里有一个非常实用的经验:
- 贴图默认朝右:通常直接用
atan(y, x) - 贴图默认朝上:通常要
+90°或-90° - 贴图默认朝左:通常要
+180°或翻转
所以当你发现“箭飞得对,但头总是歪的”,第一怀疑对象往往不是数学公式,而是 素材默认朝向。
一个容易忽略的小边界
如果某一帧投射物位移特别小:
dir = current_pos - pre_pos
就可能接近 (0, 0),这时旋转角度可能不稳定。
一般情况下问题不大,但如果后面你看到箭偶尔抖一下,可以加一层保护:
auto dir = trans_cmp.position_ - proj_cmp.pre_pos_;
if (glm::length(dir) > 0.001f) {
trans_cmp.rotation_ = glm::atan(dir.y, dir.x) * 180.0f / glm::pi<float>();
}
proj_cmp.pre_pos_ = trans_cmp.position_;
这样只有真正发生移动时才更新角度。
另外还有一个边界:
如果投射物到达终点时目标已经无效,当前实现通常会选择:
- 投射物自己销毁
- 但不再触发伤害
这是一种合理策略,因为说明它“飞到了,但目标已经不存在了”。
这一节遇到的问题
1. 投射物不应该依赖目标实时位置
我一开始容易写成:
每一帧都去读 target 当前坐标
这样会导致:
- 投射物轨迹不稳定
- 目标一移动,飞行路径也跟着变
- 逻辑更像“追踪弹”而不是“抛射物”
后面修正成了:
- 发射时记录
start_pos_ - 发射时记录
target_pos_ - 飞行时始终用这两个固定点做插值
这个思路更接近课程源码,也更稳。
2. 不能用“当前坐标继续插值”
如果每一帧都这样写:
position = mix(position, target, t);
轨迹会越来越偏,因为 position 已经不是原始起点了。
正确写法应该始终基于固定的 start_pos_。
3. AttackEvent 的 target 字段不能传错
我在修改攻击事件的时候,曾经混淆了攻击者和目标。
这个问题一旦出现,后面的伤害结算、死亡判断、受伤标签都会全部错位。
正确设计应该明确:
struct AttackEvent {
entt::entity attacker_;
entt::entity target_;
float damage_;
};
这样后面的 CombatResolveSystem 才知道真正该给谁扣血。
4. 已死亡目标要防止重复结算
由于 ECS 中实体的销毁通常不是“事件一到立刻 destroy”,而是:
- 先打
DeadTag - 下一帧再统一清理
所以如果同一帧里又来了新的攻击事件,就可能出现“已死目标重复受击”的问题。
因此在 CombatResolveSystem 里需要先判断:
if (!registry.valid(target) || registry.all_of<DeadTag>(target)) return;
也就是说,ECS 里的死亡处理通常不是:
一旦血量 <= 0 就立刻 destroy
而更常见的是:
if (hp <= 0) {
registry.emplace_or_replace<DeadTag>(entity);
}
然后由单独的清理系统在下一帧统一移除。
这种设计更稳定,但也意味着战斗系统必须处理“这一帧实体虽然还在,但已经算死亡”的情况。
5. 阻挡计数不能重复减到负数
如果敌人死亡后重复进入结算逻辑,阻挡者的 current_count_ 就可能不断减,最后变成负数。
所以需要做下限保护:
blocker.current_count_--;
if (blocker.current_count_ <= 0) {
blocker.current_count_ = 0;
}
这类问题很典型,说明:
- 系统之间是有时序的
- 一个实体状态变化后,不一定会立刻从世界里消失
- 所以“重复事件”和“延迟清理”一定要考虑
这节里几个特别重要的经验
- 动画事件系统不要直接承担所有战斗逻辑,它更适合负责“识别时机”并发送事件
- 投射物一旦创建出来,就应该尽量成为一个“自足对象”
- 飞行轨迹要基于快照,而不是实时回头读目标状态
- 插值时要有固定起点,否则路径会失真
- 曲线飞行时,朝向应该跟着切线方向更新
- ECS 里死亡通常不是“立刻消失”,而是“先打标签再统一清理”,所以结算逻辑必须考虑重复事件
本节总结
这一节真正学到的,不只是“把箭射出去”。
更重要的是理解了:
- 动画负责表现时机
- 事件负责系统解耦
- 投射物负责中间飞行过程
- 战斗结算系统负责最终扣血
同时还补上了两个很重要的数学知识点:
- 线性插值:决定投射物在起点和终点之间的位置
- 方向角计算:决定投射物贴图如何顺着轨迹旋转
这样一来,远程攻击系统就从“立即扣血的假远程”,变成了真正具备表现力的“动画驱动 + 弹道飞行 + 命中结算”的完整链路。
后面不管是扩展:
- 弓箭
- 火球
- 治疗弹
- 爆炸弹
- 带追踪效果的魔法弹
都有了比较稳定的基础。

浙公网安备 33010602011771号