ECS框架-远程攻击与弹道

远程攻击与弹道

这一节的核心内容,是把之前已经搭好的近战攻击链路继续往前推进,扩展出一套完整的远程攻击流程:

  • 远程单位播放攻击动画
  • 在动画的某一帧触发 "emit" 发射事件
  • 生成投射物实体
  • 投射物沿着弹道飞行
  • 飞行结束后触发命中结算

这一步做完之后,战斗系统就不再只是“近战单位贴脸命中”,而是开始具备“动画驱动攻击时机 + 投射物飞行 + 命中结算”的完整表现。


学习目标

  • 理解远程攻击为什么不能直接在攻击起手时立刻造成伤害
  • 理解动画事件在远程攻击中的作用:"emit" 负责发射,"hit" 负责命中
  • 学会设计 EmitProjectileEvent
  • 学会给投射物单独设计 ProjectileComponent
  • 理解投射物为什么要保存“起点快照”和“终点快照”
  • 掌握线性插值 lerp 的基本思想和计算方式
  • 理解抛物线偏移是如何叠加到线性插值轨迹上的
  • 理解为什么箭矢旋转角度要根据“前一帧位置 -> 当前帧位置”的方向来算

这一节解决的核心问题

如果远程攻击仍然沿用近战思路,在动画开始时直接扣血,就会有几个明显问题:

  • 箭还没飞出去,目标已经掉血了,表现不自然
  • 远程单位和近战单位的攻击时机完全一样,缺少区分
  • 后续很难扩展出子弹、火球、治疗弹、爆炸物等不同表现

因此这一节要把“造成伤害”拆成两步:

  1. 动画某一帧先发射投射物
  2. 投射物飞到目标后再触发真正伤害

这样之后,攻击表现、飞行轨迹、命中反馈就都能独立演化。


整体链路

远程攻击的完整调用链路大致如下:

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
  • 再确认目标组件存在,而且目标实体依然有效
  • 然后把发射瞬间需要的数据全部打包进事件里

为什么投射物要保存起点和终点

这是这节里最重要的设计点之一。

投射物飞行时,不应该每一帧都重新去读“目标当前坐标”,否则会出现两个问题:

  1. 弹道会变成“追踪弹”
  2. 插值轨迹会越来越乱

更合理的方式是:

  • 发射那一刻记录起点 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;

可以把它理解成两层:

  1. 先算“水平上的直线位置”
  2. 再叠加“竖直方向的弧线偏移”

最后组合成一个抛物线效果。


为什么不能把当前坐标继续拿来插值

这是这节里很容易犯错的一点。

错误思路大概像这样:

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, 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 里死亡通常不是“立刻消失”,而是“先打标签再统一清理”,所以结算逻辑必须考虑重复事件

本节总结

这一节真正学到的,不只是“把箭射出去”。

更重要的是理解了:

  • 动画负责表现时机
  • 事件负责系统解耦
  • 投射物负责中间飞行过程
  • 战斗结算系统负责最终扣血

同时还补上了两个很重要的数学知识点:

  • 线性插值:决定投射物在起点和终点之间的位置
  • 方向角计算:决定投射物贴图如何顺着轨迹旋转

这样一来,远程攻击系统就从“立即扣血的假远程”,变成了真正具备表现力的“动画驱动 + 弹道飞行 + 命中结算”的完整链路。

后面不管是扩展:

  • 弓箭
  • 火球
  • 治疗弹
  • 爆炸弹
  • 带追踪效果的魔法弹

都有了比较稳定的基础。

posted @ 2026-04-14 20:37  wenyiGamecpp  阅读(14)  评论(0)    收藏  举报