当前位置: 首页 > news >正文

ECS框架-死亡动画和血量标签

死亡动画和血量标签

这一节的核心内容,是在前面已经完成的“攻击动画驱动战斗结算”和“远程投射物命中结算”基础上,再往前补上两类非常重要的表现反馈

  • 单位受伤后,地图上显示血量条
  • 敌人死亡后,不是立刻凭空消失,而是先播放一次性死亡特效

这一步做完之后,战斗系统就不再只是“数值变化是对的”,而是开始具备更完整的视觉反馈:

  • 玩家能直接看见谁受伤了
  • 玩家能直接看见谁快死了
  • 敌人死亡时有一个自然的“结束动作”

这类表现系统虽然不直接决定“战斗能不能算对”,但会直接决定游戏是不是容易观察、容易调试、容易建立手感


学习目标

  • 理解为什么血量条本质上属于“渲染阶段的表现逻辑”
  • 理解 HasHealthBarTagInjuredTag 各自承担的职责
  • 学会设计“只有受伤单位才显示血量条”的筛选条件
  • 理解为什么死亡特效不应该直接写死在战斗系统里
  • 学会通过事件把“敌人死亡”与“创建死亡特效实体”解耦
  • 理解一次性动画实体为什么需要单独的移除标签
  • 理解“动画播放结束 -> 标记死亡 -> 下一帧统一清理”这一套回收思路
  • 理解为什么死亡特效需要保留敌人死亡瞬间的位置和朝向

这一节解决的核心问题

如果一个单位受伤了,但地图上什么都不显示,就会有几个非常明显的问题:

  • 玩家只能靠猜,无法快速判断谁快死了
  • 调试战斗时很难看出伤害到底有没有正确结算
  • 治疗、集火、优先攻击等逻辑很难直观看出来

如果一个敌人死亡时直接 destroy(entity),又会出现另一类问题:

  • 单位会像“突然蒸发”一样消失
  • 动画和战斗结果之间缺少收尾
  • 以后想扩展死亡音效、掉落、击杀统计、特效时,耦合会很重

因此这一节要做两件事:

  1. 用血量条把“受伤状态”显示出来
  2. 用一次性特效实体把“死亡表现”独立出来

这样之后,战斗系统就从“只有内部状态变化”进化成了“有清晰表现反馈的战斗系统”。


整体链路

这一节其实包含两条链路。

第一条是血量条:

单位创建-> 挂上 HasHealthBarTag
受到伤害-> 挂上 InjuredTag
HealthBarSystem-> 在 render 阶段筛选受伤单位-> 计算血量百分比-> 绘制边框和填充

第二条是死亡特效:

CombatResolveSystem-> 判断敌人死亡-> 发送 EnemyDeadEffect / EnemyDeadEvent
EffectSystem-> 监听事件-> 调用实体工厂创建一次性特效实体
AnimationSystem-> 播放特效动画
AnimationStateSystem-> 动画结束后给特效实体打 DeadTag
RemoveDeadSystem-> 下一帧统一销毁

这里最关键的一点是:

“敌人死亡”本身是一件战斗事实,而“播放死亡特效”是一件表现行为。

这两件事不应该完全写死在同一个系统里。


血量条为什么要放在渲染阶段

这节里最容易踩的第一个坑,就是把血量条绘制写进 update()

从“逻辑上”看,好像哪里都能调绘制函数,但真正的主循环通常是:

handleInput
update
clearScreen
render
present

如果你在 update() 阶段就绘制血量条,那么后面正式进入 render() 前,屏幕往往会先被 clearScreen() 清掉,结果就是:

  • 你明明执行了绘制代码
  • 但画面上什么都没有

所以血量条的正确定位是:

  • 它不是“战斗逻辑”
  • 它也不是“状态更新”
  • 它是根据当前状态做出来的一层可视化输出

因此更合理的调用顺序是:

void GameScene::render()
{render_system_->update(registry_, renderer, camera);health_bar_system_->update(registry_, renderer, camera);
}

这里的顺序也有意义:

  • 先画角色
  • 再画血量条

这样血量条才会稳定显示在角色上层,而不是被角色精灵盖住。


血量条显示条件应该怎么设计

这一节里,血量条最常见的设计并不是“所有单位永远都显示”,而是:

  • 这个单位本身允许显示血量条
  • 并且它当前确实受伤了

所以常见筛选条件会写成:

auto view = registry.view<TransformComponent,StatsComponent,HasHealthBarTag,InjuredTag
>();

这几个条件分别承担不同职责:

  • TransformComponent
    用来确定血量条绘制位置

  • StatsComponent
    用来计算 hp / max_hp

  • HasHealthBarTag
    表示“这个实体允许显示血量条”

  • InjuredTag
    表示“这个实体当前处于受伤状态”

这里有一个非常重要的 ECS 思维:

不要把多个语义混进一个标签里。

例如:

  • HasHealthBarTag 不是“正在显示血量条”
  • 它只是“具备显示资格”

而真正决定“这一帧要不要显示”的,是:

  • HasHealthBarTag
  • InjuredTag

这两个条件一起成立。

也就是说:

  • 工厂负责给正确的单位加显示资格
  • 战斗系统负责在受伤时补上 InjuredTag
  • 血量条系统只负责读取这些结果并渲染

这就是职责拆分。


为什么经常会出现“血量条写了但就是不显示”

这节里有一个很典型的问题:

  • 你已经给单位加了 HasHealthBarTag
  • 也已经写好了 HealthBarSystem
  • 但敌人还是不显示血量条

本质原因通常不是“绘制函数错了”,而是:

筛选条件没有真的闭合。

最常见的情况就是:

  • 玩家受伤时会加 InjuredTag
  • 敌人受伤时却没有加

于是敌人虽然有:

  • TransformComponent
  • StatsComponent
  • HasHealthBarTag

但就是没有:

  • InjuredTag

结果它永远进不了血量条系统的 view

所以以后你只要写 ECS 表现系统,优先检查这四步:

  1. 这个系统筛选了哪些组件/标签
  2. 这些组件/标签分别是谁负责加的
  3. 当前这帧这些条件真的成立了吗
  4. 这个系统是在 render() 还是 update() 调的

这一节的血量条问题,基本就集中在这四步里。


血量条位置和颜色是怎么来的

血量条通常需要两部分:

  1. 外框
  2. 内部填充

典型思路是:

  • 先确定一个固定尺寸,比如:
constexpr glm::vec2 HEALTH_BAR_SIZE = {48.0f, 8.0f};
  • 再确定它相对角色的位置偏移,比如:
position = transform.position_ + glm::vec2(-size.x / 2.0f, offset_y);

这里的意思是:

  • -size.x / 2:让血条以角色为中心水平对齐
  • offset_y:让血条出现在角色头顶附近

接下来再根据血量比例决定填充宽度:

health_percent = hp / max_hp;
fill_width = HEALTH_BAR_SIZE.x * health_percent;

最后再根据比例给颜色分段:

  • 高血量:绿色
  • 中血量:橙色
  • 低血量:红色

于是血量条就不只是“告诉你还剩多少血”,还顺便提供了一个非常直观的危险等级提示。


死亡特效为什么不能直接硬写在 CombatResolveSystem 里

当一个敌人血量降到 0 时,最容易想到的写法通常是:

if (hp <= 0) {// 直接在这里创建死亡特效// 直接在这里播放音效// 直接在这里统计击杀// 直接 destroy
}

这样短期看起来简单,但问题很多:

  • CombatResolveSystem 会越来越臃肿
  • 死亡后所有表现都耦合进战斗系统
  • 后面加掉落、击杀统计、死亡音效、死亡特效时都要继续往这里堆

更合理的方式是:

  • CombatResolveSystem 只负责判断“敌人确实死了”
  • 然后发出一个和死亡表现有关的事件
  • 由专门的 EffectSystem 去决定如何创建表现实体

例如:

dispatcher_.enqueue(EnemyDeadEffectEvent{class_id,position,is_flipped
});

这样做的好处是:

  • 战斗系统只负责战斗事实
  • 特效系统只负责表现实体
  • 工厂只负责创建实体

职责一下就清楚了。


死亡特效事件里为什么要带位置和朝向

这节里另一个非常容易被忽略的问题是:

光知道“谁死了”还不够,还要知道“死的时候它在哪、朝哪边”。

所以这类事件通常不应该只传一个 class_id,而要至少包含:

struct EnemyDeadEffectEvent {entt::id_type class_id_;glm::vec2 position_;bool is_flipped_;
};

这里每个字段都很重要:

  • class_id_
    决定用哪个敌人的贴图和动画资源

  • position_
    决定特效生成在地图哪里

  • is_flipped_
    决定特效是否继承敌人死亡瞬间的朝向

如果少了 is_flipped_,就会出现一种很典型的问题:

  • 敌人生前已经朝左翻转
  • 死亡特效却按蓝图默认方向重新生成
  • 结果看起来像“敌人死的一瞬间又突然转回去了”

这说明:

表现系统很多时候不只是要保留“类别”,还要保留“死亡瞬间状态”。


为什么死亡特效要做成“一次性动画实体”

死亡特效最稳的实现方式,不是往原敌人实体里硬切动画,而是:

  • 让敌人本体按照原有战斗逻辑进入死亡状态
  • 再单独创建一个“只负责播放一次动画”的特效实体

这个特效实体通常具备这些特点:

  • TransformComponent
  • SpriteComponent
  • 有只包含一个动画的 AnimationComponent
  • RenderComponent
  • OneShotRemoveTag

例如:

auto entity = registry_.create();
addTransformComponent(entity, position);
addSpriteComponent(entity, sprite, is_flipped);
addOneAnimationComponent(entity, damage_animation, sprite, "damage"_hs, false);
registry_.emplace<RenderComponent>(entity);
registry_.emplace<OneShotRemoveTag>(entity);

这里 OneShotRemoveTag 的作用非常重要:

它相当于告诉系统:

“这个实体不是普通角色,不需要恢复 idle / walk,它播完就该消失。”

这就是“表现专用实体”和“战斗实体”分离的典型做法。


为什么要等动画播完,再统一移除

这节的死亡特效如果创建出来后立刻销毁,那就等于没意义。
所以正确做法通常是:

  1. 创建一次性动画实体
  2. 让它播放一个非循环动画
  3. 动画结束时发出 AnimationFinishedEvent
  4. 在动画状态系统里识别出它是 OneShotRemoveTag
  5. 给它打 DeadTag
  6. 下一帧由 RemoveDeadSystem 统一销毁

这一套链路非常值得记住,因为它是 ECS 里很常见的回收模式:

表现实体创建-> 正常参与动画系统
动画结束-> 不立刻 destroy-> 先打 DeadTag
下一帧-> RemoveDeadSystem 统一删掉

这样做的好处是:

  • 避免在动画事件回调里直接销毁实体
  • 生命周期统一
  • 回收时机清晰
  • 不容易破坏别的系统正在遍历的 view

这和你前面已经学过的 ECS 迭代安全原则,其实是同一个思路。


为什么“先加受伤标签,再判死亡”会留下隐患

这一节里还有一个很容易被忽略的细节问题。

如果伤害结算顺序是:

resolveDamage(stats, damage);
registry.emplace_or_replace<InjuredTag>(target);if (stats.hp_ <= 0) {registry.emplace_or_replace<DeadTag>(target);
}

那么一个单位如果这次伤害直接把它打死,它这一帧就会同时拥有:

  • InjuredTag
  • DeadTag

而如果你的血量条系统只筛选:

  • HasHealthBarTag
  • InjuredTag

却没有排除 DeadTag,那么这个单位在死亡当帧理论上仍然可能被血量条系统绘制出来。

所以更稳的思路通常是把受伤和死亡分支明确拆开:

if (hp <= 0) {// 死亡逻辑
} else if (hp < max_hp) {// 受伤逻辑
}

这不是“写法洁癖”,而是为了保证标签语义干净:

  • InjuredTag 最好只表示“活着且受伤”
  • DeadTag 最好只表示“已经进入待移除或死亡状态”

这节里你容易遇到的几个典型问题

1. 血量条写了但完全不显示

最常见原因有两个:

  • 画在了 update(),后面被清屏
  • view 依赖 InjuredTag,但实体根本没被加上这个标签

这两个问题表面上都像“绘制失败”,但本质上一个是渲染阶段错了,一个是筛选条件没闭合。

2. 敌人有血量条,玩家没有,或者反过来

这种问题通常不是血量条系统本身有问题,而是:

  • 工厂没统一给所有正确单位加 HasHealthBarTag
  • 或者伤害/治疗分支没统一维护 InjuredTag

也就是说,真正要查的是“谁负责加标签”。

3. 死亡特效能生成,但朝向不对

这通常说明:

  • 你已经把“死亡事件 -> 创建特效”接好了
  • 但事件里没带 is_flipped
    或者
  • 带了这个字段,但中间某一段没传到底

这类问题特别像事件链里“数据传了一半”。

4. 死亡特效播完后不消失

通常要优先检查这条链:

  1. 动画是不是非循环播放
  2. 动画结束时有没有发 AnimationFinishedEvent
  3. AnimationStateSystem 里有没有识别 OneShotRemoveTag
  4. 有没有真正给特效实体打 DeadTag
  5. RemoveDeadSystem 是否在下一帧执行

只要这五步里少一步,就会出现“特效一直留在场上”的问题。


这节里几个特别重要的经验

  • 表现逻辑和战斗逻辑要分层,血量条和死亡特效都不应该硬塞进伤害公式里
  • ECS 系统写对了不代表就能生效,关键是筛选条件和前置标签是否真的闭合
  • 地图表现一般都应该在 render() 阶段做,而不是在 update() 里画
  • 事件最容易出的问题不是“发不出来”,而是“字段只传到一半”
  • 一次性表现实体最稳的回收方式通常不是“立刻 destroy”,而是“先打 DeadTag,下一帧统一清理”
  • 标签最好保持单一语义,否则多个系统会越来越难判断当前状态

本节总结

这一节真正学到的,不只是“把血条画出来”和“让敌人死掉时播个动画”。

更重要的是理解了:

  • 战斗系统负责判断状态变化
  • 事件负责把状态变化传给表现层
  • 实体工厂负责创建表现实体
  • 动画系统负责驱动一次性表现
  • 清理系统负责统一回收

同时,这一节也非常适合训练一种很关键的工程习惯:

不要只盯着“某个系统内部写得对不对”,而要看整条链路的数据和标签有没有真的闭合。

等这一节真正做顺之后,后面你要继续扩展:

  • 治疗特效
  • 受击闪白
  • 飘字伤害
  • 技能施法前摇特效
  • 爆炸落地特效

都会轻松很多。

因为你已经具备了一套最基础也最关键的思路:

把“战斗事实”和“视觉表现”拆开,再用事件和一次性实体把它们重新串起来。

http://www.jsqmd.com/news/646469/

相关文章:

  • ESP32 MCPWM实战:用ESP-IDF驱动舵机与LED,附完整代码与避坑指南
  • CSS定位导致元素溢出处理_利用绝对定位与裁剪属性
  • 多模态运维不是“加个视觉模块”那么简单:12个被低估的跨模态对齐陷阱,第9个让某大厂停摆47小时
  • OOD过程
  • P15819 [JOI 2015 Final] 舞会 / Ball
  • 区块链技术原理及其在金融科技领域的应用探索
  • CornerNet的Embedding向量解析:如何高效匹配物体对角点
  • Speechless:如何快速免费备份微博内容到PDF的终极完整指南
  • 别再只盯着原理了!手把手教你用Python模拟三种QKD组网方案(附代码)
  • 2026非标履带底盘厂家推荐:口碑排名与高性价比选型指南 - 博客湾
  • AI文案不再翻车,SITS2026系统上线即用的12个行业模板,限时开放首批200个白名单接入资格
  • 如何使用C#调用Oracle存储过程_OracleCommand配置CommandType.StoredProcedure
  • 【Cesium实战避坑指南】十二个高频问题与性能调优精解
  • 远程协作秘籍:分布式测试团队的沟通工具链
  • 紧急预警:2026Q2起,无多模态导航能力的AGV/AR眼镜将面临准入淘汰——奇点大会合规时间表首次公布
  • 手把手教你用LM567搭建红外检测电路(附5kHz调频避坑指南)
  • 【技术解析】EGE-UNet:轻量级分组增强架构在皮肤病变分割中的突破性应用
  • 【QGIS进阶】- 字段计算器Python函数实战:从数据清洗到自动化筛选
  • 墨水屏项目省电秘籍:用ESP8266深度睡眠+定时刷新(实测功耗对比)
  • Windows/Mac/Linux全平台保姆级教程:从零配置OpenCode到成功调用Gemini-3
  • 从硬件工程师的视角看I2C:为什么开漏+上拉是总线设计的‘最优解’?聊聊功耗、速率与可靠性
  • 如何让点击目标元素时随机移动到页面任意位置
  • 如何为Windows和Linux系统免费获取macOS风格的鼠标指针主题?
  • 大模型时代的技术演进:从Transformer到多模态融合
  • 红帆iOffice.net udfGetDocStep.asmx接口SQL注入漏洞深度解析与防御实践
  • Teamcenter Active Workspace云许可与本地网络许可的混合应用模式
  • 07_NVIDIA Triton Java API:企业级高性能推理服务
  • Origin软件弹窗提示盗版?一个1KB的批处理文件帮你一键搞定(附Hosts修改教程)
  • 2026奇点大会未公开议程泄露:Meta/Adobe/华为联合演示的跨模态图像生成协议,即将改变行业交付标准
  • 开发者副业:从开源贡献到被动收入——软件测试从业者的专业变现指南