Unity Netcode for Entities 实战:在 HelloNetcode 示例中实现世界空间血条(World-Space Health Bars)
【免费下载链接】EntityComponentSystemSamples项目地址: https://gitcode.com/GitHub_Trending/en/EntityComponentSystemSamples
本文基于 Unity Entities(DOTS)仓库中 HelloNetcode 系列高级示例01_HealthBars,完整讲解如何在多人游戏中为每个玩家生成并维护一个跟随角色、始终朝向摄像机的世界空间血条。读完后你将掌握:如何把托管侧 GameObject 预制体引入 ECS 世界并在客户端动态实例化、如何通过IDisposable/ICloneable托管组件解决 Ghost 实体的 UI 生命周期问题,以及如何正确区分本地玩家与远端对手的血条放置和死亡(服务器权威)状态处理。
示例定位与前置要求
01_HealthBars属于 HelloNetcode 示例体系 的3_Advanced(高级)层级,它构建在两个中级示例之上:
- HitScanWeapon(命中扫描武器):负责"射击 → 命中 → 扣血",源码见 ShootingSystem.cs 与 HitScanWeapon 示例文档;
- Respawning(重生):负责为角色添加
Health组件、死亡判定与重生逻辑,见 Respawning 示例文档。
HealthBars.md 中列出的前置示例链为:
- GoInGame
- SpawnPlayer
- Physics
- CharacterController
- HitScanWeapon
- Respawning
运行方式:进入 Play Mode 时,在 Multiplayer Play Mode 工具窗口中至少启用一个Thin Client,即可看到所有玩家头顶出现血条(黑色背景 + 绿色血条)。射击命中后血条递减,耗尽即触发 Respawning 示例描述的重生流程,角色在新位置以满血状态重生。
核心数据:Health 组件从哪来
血条显示的数值并非本示例自定义,而是复用 Respawning 示例中的Health组件(HealthAuthoring.cs):
public struct Health : IComponentData { [GhostField(Smoothing = SmoothingAction.Clamp)] public short MaximumHitPoints; [GhostField(Smoothing = SmoothingAction.Clamp)] public short CurrentHitPoints; }几个值得注意的设计点:
- 使用
short而非浮点数,官方注释(HealthAuthoring.cs)解释:避免浮点精度问题,且允许取负值以兼容治疗等"加血"操作; - 两个字段都是 Ghost 字段并指定
Smoothing = SmoothingAction.Clamp,即插值平滑时不允许数值在插值过程中越过实际端点,保证血条数值变化平滑但不"超前"; - Authoring 默认
MaximumHitPoints = 100,Baker 同时用它初始化CurrentHitPoints。
扣血逻辑在 DamageSystem.cs:每次命中使受害者的CurrentHitPoints -= 20,因此满血 100 点、5 次命中致死,与示例文档"Five successful hits will be enough to knock them down"完全对应。
第一步:Authoring 烘焙生成器配置
HealthBarSpawnerAuthoring.cs 定义了一个 ECS 组件与对应的 MonoBehaviour 作者化组件:
public class HealthBarSpawner : IComponentData { public GameObject HealthBarPrefab; // 血条 UI 预制体引用 public float OpponentHeightOffset; // 对手头顶偏移(向上) public float PlayerTowardCameraOffset; // 本地玩家向摄像机方向的推拉距离 public float PlayerHeightOffset; // 本地玩家高度偏移 } public class HealthBarSpawnerAuthoring : MonoBehaviour { public GameObject HealthBarPrefab; public float OpponentHeightOffset = 0.5f; public float PlayerTowardCameraOffset = 1.8f; public float PlayerHeightOffset = -1.5f; class Baker : Baker<HealthBarSpawnerAuthoring> { public override void Bake(HealthBarSpawnerAuthoring authoring) { var entity = GetEntity(TransformUsageFlags.Dynamic); AddComponentObject(entity, new HealthBarSpawner { HealthBarPrefab = authoring.HealthBarPrefab, OpponentHeightOffset = authoring.OpponentHeightOffset, PlayerTowardCameraOffset = authoring.PlayerTowardCameraOffset, PlayerHeightOffset = authoring.PlayerHeightOffset, }); } } }要点解析:
- 三个偏移参数是血条放置策略的全部配置,语义上区分了两类实体:对手(Opponent,血条悬于头顶
+0.5单位)与本地玩家(第一/三人称下头顶血条会被模型或视角挡住,故用PlayerHeightOffset = -1.5与PlayerTowardCameraOffset = 1.8把血条压到角色下方并沿"血条→摄像机"方向拉近/推远,保证可见且不被遮挡); - 之所以用
AddComponentObject(而非AddComponent)烘焙,是因为HealthBarSpawner是一个类(class)组件,其内部持有对GameObject预制体的引用。这正是 HealthBars.md "Note" 一节强调的设计说明:HealthBarSpawnerAuthoring刻意把 IComponent 实现为 class 而非 struct,就是为了保持对血条预制体 GameObject 的引用。整个组件及两套#if !UNITY_DISABLE_MANAGED_COMPONENTS条件编译,意味着该功能仅在允许托管组件的构建下可用; - 该组件所在的实体在场景中是
HealthBar实体场景内的一个 Authoring 对象,最终由 SpawnHealthBarSystem 以GetSingleton方式读取。
第二步:SpawnHealthBarSystem —— 客户端实例化 UI 并挂托管组件
SpawnHealthBarSystem.cs 是整个示例最关键的文件,它同时定义了 UI 承载组件HealthUI与生成系统。
HealthUI:可销毁、可克隆的托管组件
public class HealthUI : IComponentData, IDisposable, ICloneable { public Transform HealthBar; public Image HealthSlider; public float OpponentHeightOffset; public float PlayerHeightOffset; public float PlayerTowardCameraOffset; public void Dispose() { // 由于实现了 IDisposable,可以在 Ghost 实体被销毁时 // 触发对 HealthBar GameObject 的销毁 if (HealthBar != null) Object.Destroy(HealthBar.gameObject); } public object Clone() { if (HealthBar == null || HealthBar.gameObject == null) return new HealthUI(); var newHealthBar = Object.Instantiate(HealthBar.gameObject); var images = HealthBar.gameObject.GetComponentsInChildren<Image>(); return new HealthUI { HealthBar = newHealthBar.GetComponent<Transform>(), HealthSlider = images[1] }; } }这套设计解决了托管 UI 与 DOTS 实体生命周期错位的经典问题:
IDisposable:当 Netcode 在服务器权威下销毁 Ghost 实体(例如玩家断线、重生重建实体)时,ECS 会自动调用Dispose(),从而Object.Destroy掉对应的血条 GameObject,避免场景里残留"幽灵 UI";ICloneable:Ghost 实体在客户端本地预测重建(如本地玩家重新生成)时会克隆组件,Clone()通过Object.Instantiate为新实体创建一份全新的血条 GameObject 并返回新组件,保证每个实体各有一份独立 UI。
生成系统的查询与实例化
系统声明为(SpawnHealthBarSystem.cs):
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(PresentationSystemGroup))] public partial struct SpawnHealthBarSystem : ISystemOnUpdate的核心逻辑(SpawnHealthBarSystem.cs):
var ecb = new EntityCommandBuffer(Allocator.Temp); var query = state.EntityManager.CreateEntityQuery(ComponentType.ReadOnly<HealthBarSpawner>()); var spawner = query.GetSingleton<HealthBarSpawner>(); foreach (var (_, entity) in SystemAPI.Query<RefRO<Health>>() .WithEntityAccess().WithNone<HealthUI>()) { var go = Object.Instantiate(spawner.HealthBarPrefab); var image = go.GetComponentsInChildren<Image>(); ecb.AddComponent(entity, new HealthUI { HealthBar = go.transform, HealthSlider = image[1], OpponentHeightOffset = spawner.OpponentHeightOffset, PlayerTowardCameraOffset = spawner.PlayerTowardCameraOffset, PlayerHeightOffset = spawner.PlayerHeightOffset, }); } ecb.Playback(state.EntityManager);实现细节与文档呼应之处:
- 只用常规
Object.Instantiate实例化——这是 HealthBars.md Note 一节明确指出的做法:血条 UI 不是 DOTS 实体,不走烘焙/子场景流程,而是在客户端运行时直接实例化托管侧 UI 预制体(HealthBar.prefab); - 主查询
Query<RefRO<Health>>().WithNone<HealthUI>()是幂等设计:只处理"拥有Health但尚未挂HealthUI"的实体,因此无论是新连接的远端玩家、还是重生后重建的本地玩家,都恰好会被实例化一次血条; OnCreate中通过RequireForUpdate<HealthBarSpawner>()与RequireForUpdate<Health>()做惰性激活——场景里没有血条生成器实体或没有玩家时,系统自动停用,零开销;- 组件添加通过
EntityCommandBuffer缓冲后统一Playback,符合 ECS 中批量变更的标准写法。
HealthBarPrefab内部的层级约定是:GetComponentsInChildren<Image>()取第[0]个为黑色背景底板、第[1]个为作为fillAmount滑动条的血条填充层(白色像素贴图着色)。配套的 CharacterWithHealthbar.prefab 展示了带血条的角色装配形态,纹理资源为同目录下的 32x32WhitePixel.jpg。
第三步:UpdateHealthBarSystem —— 跟随、朝向与死亡状态
UpdateHealthBarSystem.cs 负责每帧把血条"钉"在角色上并刷新血量,同样运行于ClientSimulation的PresentationSystemGroup,并标注[RequireMatchingQueriesForUpdate](查询无匹配实体时不执行)。
防御性启用检查
if (Camera.main == null) { state.Enabled = false; return; }场景中不存在 Main 摄像机(例如纯服务器/无 UI 世界)时直接停用系统,避免空引用。
本地玩家与对手的差异化放置
主查询(UpdateHealthBarSystem.cs)同时取HealthUI、RefRO<Health>、RefRO<AutoCommandTarget>、RefRO<GhostOwner>、RefRO<LocalToWorld>五类数据,并按实体区分两类放置策略:
if (state.EntityManager.IsComponentEnabled<GhostOwnerIsLocal>(entity)) { // 本地玩家:抬高后沿"血条→摄像机"方向推拉,并整体 LookRotation 朝向摄像机 var targetHealthBarPos = ltw.ValueRO.Position; targetHealthBarPos.y += ui.PlayerHeightOffset; var n = mainCamera.transform.position - ui.HealthBar.position; targetHealthBarPos += (float3)(n.normalized * ui.PlayerTowardCameraOffset); ui.HealthBar.SetPositionAndRotation(targetHealthBarPos, Quaternion.LookRotation(n)); } else { // 远端对手:直接置于头顶 OpponentHeightOffset 处,并朝向摄像机 var targetHealthBarPos = ltw.ValueRO.Position; targetHealthBarPos.y += ui.OpponentHeightOffset; var n = mainCamera.transform.position - ui.HealthBar.position; ui.HealthBar.SetPositionAndRotation(targetHealthBarPos, Quaternion.LookRotation(n)); }- 通过
GhostOwnerIsLocal组件判断该 Ghost 是否属于本地玩家,从而套用两套 Authoring 中配置好的偏移参数; - 两者都用
Quaternion.LookRotation让血条平面始终正对主摄像机,实现"永远可读"的世界空间 UI; - 位置来源是实体的
LocalToWorld.Position,因此血条天然跟随角色的插值/预测位置,无需额外同步。
血量渲染与"等待服务器确认"的死亡处理
var hpNormalized = math.saturate((float)health.ValueRO.CurrentHitPoints / health.ValueRO.MaximumHitPoints); var playerColor = NetworkIdDebugColorUtility.GetColor(owner.ValueRO.NetworkId); // Killed by server: if (act.ValueRO.Enabled) { // 存活:血条填充为玩家调试色 ui.HealthSlider.color = playerColor; } else { // 无视预测,直接置 0,并把背景调成半透明(alpha=0.3)表示"权威死亡" hpNormalized = 0; playerColor.a = 0.3f; ui.HealthSlider.transform.parent.GetComponent<Image>().color = playerColor; } ui.HealthSlider.fillAmount = hpNormalized;这里的AutoCommandTarget是 Netcode 的权威状态信号:
act.ValueRO.Enabled == true表示服务器已确认该实体存活,血条按CurrentHitPoints / MaximumHitPoints归一化后驱动Image.fillAmount,填充颜色用NetworkIdDebugColorUtility.GetColor(owner.NetworkId)按玩家 NetworkId 着色,便于在多人场景中区分个体;act.ValueRO.Enabled == false表示服务器已判定死亡。由于本地预测可能尚未追上(客户端可能仍认为目标存活),此处选择"无论预测如何一律置 0",并将底板透明度降到 0.3 呈现灰色"权威死亡"外观。源码注释解释了只在此条件成立时处理的意图:"We're waiting for server confirmation"——即死亡表现以服务器权威为准,不跟随预测回滚;- 死亡后由 RespawnSystem 重建玩家实体,旧实体销毁触发
HealthUI.Dispose()销毁旧血条,新实体因再次满足WithNone<HealthUI>()查询而获得新血条,血量随之满血恢复——形成完整闭环。
关键设计小结
| 设计点 | 做法 | 依据 |
|---|---|---|
| UI 预制体引用 | 用 class 型IComponentData(HealthBarSpawner)承载GameObject引用,AddComponentObject烘焙 | HealthBars.md Note 一节、HealthBarSpawnerAuthoring.cs |
| UI 实例化 | 客户端系统内常规Object.Instantiate,非 DOTS 实体 | SpawnHealthBarSystem.cs |
| 生成幂等性 | Query<RefRO<Health>>().WithNone<HealthUI>()只处理未挂 UI 的实体 | SpawnHealthBarSystem.cs |
| UI 生命周期 | HealthUI实现IDisposable(随实体销毁)与ICloneable(预测重建时克隆) | SpawnHealthBarSystem.cs |
| 数值权威 | 死亡表现等待AutoCommandTarget服务器确认,期间血条强制置 0 | UpdateHealthBarSystem.cs |
| 运行前提 | 托管组件可用(!UNITY_DISABLE_MANAGED_COMPONENTS);至少一个 Thin Client 进 Play Mode 可观察 | HealthBars.md、三处源码文件顶部的条件编译 |
如何运行与验证
- 打开仓库中的 NetcodeSamples 工程,确认当前场景包含
3_Advanced/01_HealthBars的 HealthBar 场景/实体场景; - 依赖链确认:GoInGame、SpawnPlayer、Physics、CharacterController、HitScanWeapon、Respawning 各示例的 Authoring 组件均在场景装配(对应文档 Requirements 一节);
- 进入 Play Mode 并在 Multiplayer Play Mode 工具中启用至少一个 Thin Client;
- 观察点:所有玩家头顶出现黑底绿条血条 → 射击命中后血条递减(每次 -20)→ 血量归零时服务器确认后血条灰化 → 角色随机位置重生且血条满血恢复。
如需继续深入,可参考同一体系中的 HitScanWeapon 文档(命中判定与Hit组件流转)和 Respawning 文档(实体销毁重建时CommandTargetComponent、LinkedEntityGroup的重新装配要求),三者共同构成了本血条示例的完整数据链路。
【免费下载链接】EntityComponentSystemSamples项目地址: https://gitcode.com/GitHub_Trending/en/EntityComponentSystemSamples
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考