死亡动画和血量标签
这一节的核心内容,是在前面已经完成的“攻击动画驱动战斗结算”和“远程投射物命中结算”基础上,再往前补上两类非常重要的表现反馈:
- 单位受伤后,地图上显示血量条
- 敌人死亡后,不是立刻凭空消失,而是先播放一次性死亡特效
这一步做完之后,战斗系统就不再只是“数值变化是对的”,而是开始具备更完整的视觉反馈:
- 玩家能直接看见谁受伤了
- 玩家能直接看见谁快死了
- 敌人死亡时有一个自然的“结束动作”
这类表现系统虽然不直接决定“战斗能不能算对”,但会直接决定游戏是不是容易观察、容易调试、容易建立手感。
学习目标
- 理解为什么血量条本质上属于“渲染阶段的表现逻辑”
- 理解
HasHealthBarTag和InjuredTag各自承担的职责 - 学会设计“只有受伤单位才显示血量条”的筛选条件
- 理解为什么死亡特效不应该直接写死在战斗系统里
- 学会通过事件把“敌人死亡”与“创建死亡特效实体”解耦
- 理解一次性动画实体为什么需要单独的移除标签
- 理解“动画播放结束 -> 标记死亡 -> 下一帧统一清理”这一套回收思路
- 理解为什么死亡特效需要保留敌人死亡瞬间的位置和朝向
这一节解决的核心问题
如果一个单位受伤了,但地图上什么都不显示,就会有几个非常明显的问题:
- 玩家只能靠猜,无法快速判断谁快死了
- 调试战斗时很难看出伤害到底有没有正确结算
- 治疗、集火、优先攻击等逻辑很难直观看出来
如果一个敌人死亡时直接 destroy(entity),又会出现另一类问题:
- 单位会像“突然蒸发”一样消失
- 动画和战斗结果之间缺少收尾
- 以后想扩展死亡音效、掉落、击杀统计、特效时,耦合会很重
因此这一节要做两件事:
- 用血量条把“受伤状态”显示出来
- 用一次性特效实体把“死亡表现”独立出来
这样之后,战斗系统就从“只有内部状态变化”进化成了“有清晰表现反馈的战斗系统”。
整体链路
这一节其实包含两条链路。
第一条是血量条:
单位创建-> 挂上 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不是“正在显示血量条”- 它只是“具备显示资格”
而真正决定“这一帧要不要显示”的,是:
HasHealthBarTagInjuredTag
这两个条件一起成立。
也就是说:
- 工厂负责给正确的单位加显示资格
- 战斗系统负责在受伤时补上
InjuredTag - 血量条系统只负责读取这些结果并渲染
这就是职责拆分。
为什么经常会出现“血量条写了但就是不显示”
这节里有一个很典型的问题:
- 你已经给单位加了
HasHealthBarTag - 也已经写好了
HealthBarSystem - 但敌人还是不显示血量条
本质原因通常不是“绘制函数错了”,而是:
筛选条件没有真的闭合。
最常见的情况就是:
- 玩家受伤时会加
InjuredTag - 敌人受伤时却没有加
于是敌人虽然有:
TransformComponentStatsComponentHasHealthBarTag
但就是没有:
InjuredTag
结果它永远进不了血量条系统的 view。
所以以后你只要写 ECS 表现系统,优先检查这四步:
- 这个系统筛选了哪些组件/标签
- 这些组件/标签分别是谁负责加的
- 当前这帧这些条件真的成立了吗
- 这个系统是在
render()还是update()调的
这一节的血量条问题,基本就集中在这四步里。
血量条位置和颜色是怎么来的
血量条通常需要两部分:
- 外框
- 内部填充
典型思路是:
- 先确定一个固定尺寸,比如:
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,它播完就该消失。”
这就是“表现专用实体”和“战斗实体”分离的典型做法。
为什么要等动画播完,再统一移除
这节的死亡特效如果创建出来后立刻销毁,那就等于没意义。
所以正确做法通常是:
- 创建一次性动画实体
- 让它播放一个非循环动画
- 动画结束时发出
AnimationFinishedEvent - 在动画状态系统里识别出它是
OneShotRemoveTag - 给它打
DeadTag - 下一帧由
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);
}
那么一个单位如果这次伤害直接把它打死,它这一帧就会同时拥有:
InjuredTagDeadTag
而如果你的血量条系统只筛选:
HasHealthBarTagInjuredTag
却没有排除 DeadTag,那么这个单位在死亡当帧理论上仍然可能被血量条系统绘制出来。
所以更稳的思路通常是把受伤和死亡分支明确拆开:
if (hp <= 0) {// 死亡逻辑
} else if (hp < max_hp) {// 受伤逻辑
}
这不是“写法洁癖”,而是为了保证标签语义干净:
InjuredTag最好只表示“活着且受伤”DeadTag最好只表示“已经进入待移除或死亡状态”
这节里你容易遇到的几个典型问题
1. 血量条写了但完全不显示
最常见原因有两个:
- 画在了
update(),后面被清屏 view依赖InjuredTag,但实体根本没被加上这个标签
这两个问题表面上都像“绘制失败”,但本质上一个是渲染阶段错了,一个是筛选条件没闭合。
2. 敌人有血量条,玩家没有,或者反过来
这种问题通常不是血量条系统本身有问题,而是:
- 工厂没统一给所有正确单位加
HasHealthBarTag - 或者伤害/治疗分支没统一维护
InjuredTag
也就是说,真正要查的是“谁负责加标签”。
3. 死亡特效能生成,但朝向不对
这通常说明:
- 你已经把“死亡事件 -> 创建特效”接好了
- 但事件里没带
is_flipped
或者 - 带了这个字段,但中间某一段没传到底
这类问题特别像事件链里“数据传了一半”。
4. 死亡特效播完后不消失
通常要优先检查这条链:
- 动画是不是非循环播放
- 动画结束时有没有发
AnimationFinishedEvent AnimationStateSystem里有没有识别OneShotRemoveTag- 有没有真正给特效实体打
DeadTag RemoveDeadSystem是否在下一帧执行
只要这五步里少一步,就会出现“特效一直留在场上”的问题。
这节里几个特别重要的经验
- 表现逻辑和战斗逻辑要分层,血量条和死亡特效都不应该硬塞进伤害公式里
- ECS 系统写对了不代表就能生效,关键是筛选条件和前置标签是否真的闭合
- 地图表现一般都应该在
render()阶段做,而不是在update()里画 - 事件最容易出的问题不是“发不出来”,而是“字段只传到一半”
- 一次性表现实体最稳的回收方式通常不是“立刻 destroy”,而是“先打
DeadTag,下一帧统一清理” - 标签最好保持单一语义,否则多个系统会越来越难判断当前状态
本节总结
这一节真正学到的,不只是“把血条画出来”和“让敌人死掉时播个动画”。
更重要的是理解了:
- 战斗系统负责判断状态变化
- 事件负责把状态变化传给表现层
- 实体工厂负责创建表现实体
- 动画系统负责驱动一次性表现
- 清理系统负责统一回收
同时,这一节也非常适合训练一种很关键的工程习惯:
不要只盯着“某个系统内部写得对不对”,而要看整条链路的数据和标签有没有真的闭合。
等这一节真正做顺之后,后面你要继续扩展:
- 治疗特效
- 受击闪白
- 飘字伤害
- 技能施法前摇特效
- 爆炸落地特效
都会轻松很多。
因为你已经具备了一套最基础也最关键的思路:
把“战斗事实”和“视觉表现”拆开,再用事件和一次性实体把它们重新串起来。
