基于QFramework的Unity伤害飘字模块实现与对象池优化
2026/9/1 23:27:19 网站建设 项目流程

类幸存者这类密集战斗游戏里,伤害数字飘动不只是视觉点缀。玩家每次攻击命中,是否能看到清晰、不卡顿、不重叠的文字反馈,直接决定打击感。用 QFramework 做 Unity 游戏开发时,可以把这个能力抽成独立的飘字模块,通过事件解耦战斗系统和 UI,再用对象池控制实例数量。这一期内容就围绕“文字飘动支持”展开:从事件设计、Canvas 坐标换算、Prefab 复用,到接入伤害结算和排错,完整实现一套适合类幸存者项目的轻量飘字方案。

在实际项目里,飘字看起来简单,真正落地却经常遇到几个问题:UI 层级混乱、频繁生成销毁导致 GC 压力、暴击和普通伤害样式区分不明显、世界坐标转 UI 坐标出错后文字跑出屏幕。下面先从需求拆解开始,把这些问题逐个解决。

1. 文字飘动在类幸存者项目里解决什么问题

1.1 从一次受击反馈拆解飘字功能

类幸存者游戏里,人物会同时攻击大量敌人,敌我双方都会产生大量伤害数字。一个完整的飘字表现通常包含以下要素:

  • 命中世界坐标位置,一般是怪物头顶或受击点。
  • 伤害数值文本,有时还要显示“MISS”“暴击”等特殊文本。
  • 颜色区分,普通伤害用白色,暴击用黄色,治疗用绿色。
  • 字号区分,暴击数字通常比普通数字更大。
  • 运动轨迹,常见做法是向上漂浮,结束前淡出。
  • 持续时间和销毁方式,动画结束后回收,而不是直接销毁对象。

把这些需求拆开看,飘字不是“显示一段文字”这么简单,而是一个短生命周期、高频率、表现结构固定的 UI 反馈系统。如果没有独立管理,战斗逻辑和 UI 表现会严重耦合,以后换成技能系统、Buff 系统时都要改一遍。

1.2 QFramework 在飘字模块中的职责边界

QFramework 在 Unity 游戏开发里不是万能的,但它提供了两个非常适合飘字模块的能力:TypeEventSystem事件系统和可复用的对象管理思路。

TypeEventSystem可以把“伤害产生”和“飘字显示”完全解耦。战斗系统只负责发送一段伤害事件,飘字管理器只负责监听事件并显示文字。两边不用互相持有引用,新增其他反馈(比如受击闪光、声音、屏幕震动)时,只要再注册一个监听者即可。

事件解耦后的边界非常清晰:

  • 伤害计算系统:负责算伤害、扣血,最后发送事件。
  • 飘字管理器:负责监听伤害事件,从对象池取一个飘字并播放。
  • 飘字控件:负责坐标换算、动画播放、结束后回到对象池。

这样 QFramework 在模块里承担的是“事件总线”和“架构约束”,而不是把 UI 表现硬塞进战斗逻辑。

1.3 为什么用对象池而不是 Instantiate 直接创建

每次命中都Instantiate一个 UI 文本,战斗激烈时一秒钟可能生成几十个对象。Instantiate本身有开销,频繁生成和销毁还会造成 GC 压力,最终表现为战斗后期掉帧。

对象池的思路是:

  1. 在场景启动时预创建一批飘字对象,并且隐藏。
  2. 收到伤害事件时,从池里取一个激活显示。
  3. 动画播放结束后,隐藏并塞回池里,供下次复用。

当池里没有空闲对象时,再创建一个新对象补充。这样既能保证吃紧场景下有对象可用,又不会一开始创建过多对象占用不必要的性能。

方案生成开销GC 压力实现复杂度推荐场景
每次 InstantiateDemo 演示、临时测试
普通 List 缓存数量可控的小项目
对象池 Stack 缓存类幸存者等高频生成场景

当前项目是类幸存者玩法,飘字频率非常高,所以直接采用对象池方案。

2. 环境准备与 UI 基础结构

2.1 Unity 版本、输入系统与 UI 组件

本模块不依赖特定 Unity 版本,但推荐使用 Unity 2021.3 LTS 或更高版本。项目中至少需要安装 UGUI 和 TextMeshPro 相关资源,因为我们要用TMP_Text显示伤害数字。

TextMeshPro 比旧版 UGUI Text 的优势很明显:文字渲染清晰,支持字体描边、阴影、材质,适合做暴击效果的样式区分。Unity 首次导入 TMP 时需要导入 Essential Resources,否则会创建 TMP 文本时提示缺少基础资源。

如果是新场景,需要检查以下内容:

  • 场景中是否存在EventSystem
  • 是否存在 Canvas。
  • Canvas 的renderMode是否满足 UI 需求。
  • 是否安装了 TextMeshPro 并导入基础资源。

2.2 引入 QFramework 的两种方式

QFramework 的引入方式在不同版本里略有差异,这里只做通用说明。

第一种方式是通过 Package Manager 添加 Git URL,需要网络能够访问官方仓库地址,并记录版本号或 commit 号。第二种方式是直接下载 UnityPackage 导入到项目。推荐在正式项目里锁定稳定版本,避免后续升级带来 API 变化。

只要项目里可以使用:

TypeEventSystem.Global.Register<T>(...); TypeEventSystem.Global.Send<T>(...);

说明框架已经接入成功。如果使用 QFramework 架构,也可以在 Controller 里用this.SendEvent<T>(),核心事件机制是同一套。

注意:网络环境不同可能导致 UPM 拉取失败,落地时优先以官方 README 的安装步骤为准,并保证团队其他成员使用同一版本。

2.3 场景 Canvas 与 TMP 预制体

飘字属于 UI 表现,因此需要放在 Canvas 下。建议单独创建一层FloatingTextRoot或者一个空物体作为飘字根节点,避免和主界面 UI 混在一起。

Canvas 的renderMode直接影响坐标换算方式,下面这个表是必须记住的:

Canvas 渲染模式使用场景坐标换算注意点
Screen Space - Overlay最常见,UI 直接覆盖屏幕WorldToScreenPoint得到屏幕像素坐标,UI 坐标通过RectTransformUtility转换
Screen Space - CameraUI 与场景摄像机结合需要给 Canvas 指定worldCamera,否则 UI 位置会异常
World Space场景内 3D UI不能直接使用屏幕坐标,需要做世界坐标到 UI 平面的转换

本模块示例以Screen Space - Overlay为主,这也是大多数 2D 类幸存者 UI 的使用方式。

TMP 预制体结构建议:

FloatingTextView (FloatingTextView 脚本) |-- Text (TMP_Text)

FloatingTextView 脚本挂在根节点上,Text挂在子物体上,用于控制具体文字。

2.4 推荐脚本目录

为了让飘字模块能在多个场景或后续项目中复用,建议把脚本按职责拆分:

Assets/ Scripts/ DamageText/ FloatingTextView.cs FloatingTextManager.cs DamageTextEvent.cs DamageTextDemoTrigger.cs

这个目录只负责飘字相关逻辑。伤害计算、敌人属性、玩家攻击等仍然保留在各自的战斗模块中,通过事件进行通信。

3. 事件数据层:定义伤害飘字事件并发送到 UI

3.1 为什么用事件驱动而不是直接调用 UI

如果直接让敌人受伤逻辑持有飘字管理器引用,代码会变成:

floatingTextManager.ShowDamage(transform.position, damage, isCritical);

这种写法在单敌人场景没问题,但类幸存者中伤害来源非常复杂,包括玩家武器、技能、灼烧、毒等。如果每个伤害点都持有 UI 引用,战斗系统会越来越臃肿。

改用事件后,伤害计算模块只发送一个DamageTextEvent,飘字管理器自己决定要不要响应,以及如何表现。后续如果新增“暴击时额外显示特效”,也只需要在同一个事件里增加字段,或者新增一个特效监听者,不需要修改伤害系统。

3.2 定义 DamageTextEvent

一个最小可用的飘字事件需要包含位置、内容、颜色、字号和动画时长。下面用struct定义:

using UnityEngine; [System.Serializable] public struct DamageTextEvent { public Vector3 worldPosition; public string content; public Color textColor; public int fontSize; public float duration; }

字段含义如下:

字段类型说明示例
worldPositionVector3飘字显示的世界坐标怪物受击点
contentstring显示文本damage.ToString()
textColorColor文字颜色暴击黄色
fontSizeint文字字号普通 32,暴击 44
durationfloat动画持续秒数0.8f

为什么用struct而不是class?飘字事件生命周期非常短,发送后立刻被监听者读取,没有多态需求,使用struct可以减少堆内存分配和 GC 压力。当然,如果后面事件字段很多,也可以换成class,实现上差别不大。

3.3 从伤害系统发送事件

在敌人受伤逻辑里,只需要组织好参数,然后发送事件:

using UnityEngine; using QFramework; public class EnemyDamage : MonoBehaviour { public void TakeDamage(int damage, bool isCritical, Vector3 hitPoint) { // 这里应包含扣血、判断死亡等逻辑 // 事件发送放在伤害有效之后 TypeEventSystem.Global.Send<DamageTextEvent>(new DamageTextEvent { worldPosition = hitPoint, content = damage.ToString(), textColor = isCritical ? Color.yellow : Color.white, fontSize = isCritical ? 44 : 32, duration = 0.8f }); } }

这里用TypeEventSystem.Global.Send发送全局事件,好处是不管飘字管理器在哪个场景里,只要注册就能收到。缺点是全局事件要避免无节制滥用,如果项目已经使用 QFramework 的Architecture,可以把事件纳入到架构内统一管理,减少全局状态。

实际项目中敌人受伤往往来自多个方向,为了避免“重复扣血但只显示一次飘字”,建议把事件发送放在伤害已经被确认生效、并且没有被死亡流程中断的位置。

4. 表现层:FloatingTextView 与对象池实现

4.1 FloatingTextView 组件

FloatingTextView 负责单条飘字的文本设置、坐标转换和动画播放。它不负责对象池的创建逻辑,只负责在动画结束时通知管理器回收。

using System.Collections; using UnityEngine; using TMPro; public class FloatingTextView : MonoBehaviour { [SerializeField] private TMP_Text text; private RectTransform rectTransform; private Camera worldCamera; private Canvas parentCanvas; public void Setup(Camera cam) { worldCamera = cam; parentCanvas = GetComponentInParent<Canvas>(); rectTransform = GetComponent<RectTransform>(); } public void Reactivate(DamageTextEvent e) { gameObject.SetActive(true); text.text = e.content; text.color = e.textColor; text.fontSize = e.fontSize; Vector3 screenPoint = worldCamera.WorldToScreenPoint(e.worldPosition); if (screenPoint.z < 0) { // 目标点在相机背后,不显示飘字 RecycleSelf(); return; } if (RectTransformUtility.ScreenPointToLocalPointInRectangle( (RectTransform)parentCanvas.transform, screenPoint, parentCanvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : parentCanvas.worldCamera, out Vector2 localPoint)) { rectTransform.anchoredPosition = localPoint; } StopAllCoroutines(); StartCoroutine(FloatingAnimation(e.duration)); } private IEnumerator FloatingAnimation(float duration) { Vector3 from = rectTransform.anchoredPosition; Vector3 to = from + Vector3.up * 60f; Color startColor = text.color; float elapsed = 0f; while (elapsed < duration) { elapsed += Time.deltaTime; float t = Mathf.Clamp01(elapsed / duration); rectTransform.anchoredPosition = Vector3.Lerp(from, to, t); Color c = startColor; c.a = Mathf.Lerp(1f, 0f, t); text.color = c; yield return null; } RecycleSelf(); } private void RecycleSelf() { var manager = GetComponentInParent<FloatingTextManager>(); if (manager != null) { manager.Recycle(this); } else { gameObject.SetActive(false); } } }

需要重点解释两个地方。

第一,StopAllCoroutines()非常重要。对象池复用时,上一次的协程可能还在运行,如果没有停止,会出现一条飘字同时播放两次动画。

第二,screenPoint.z < 0说明目标点位于摄像机背面。如果不做这个判断,角色在转身时可能把怪物背后的飘字显示到屏幕前方,表现很怪。

4.2 屏幕坐标与 Canvas 坐标转换

世界坐标到 UI 坐标的换算,是飘字最容易出错的一步。不能直接把WorldToScreenPoint的结果赋值给RectTransform.anchoredPosition,因为anchoredPosition相对于 Canvas 锚点,而屏幕点相对于屏幕左下角或左上角,两者定义不同。

统一做法是使用RectTransformUtility.ScreenPointToLocalPointInRectangle

Vector3 screenPoint = worldCamera.WorldToScreenPoint(e.worldPosition); RectTransformUtility.ScreenPointToLocalPointInRectangle( (RectTransform)parentCanvas.transform, screenPoint, parentCanvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : parentCanvas.worldCamera, out Vector2 localPoint ); rectTransform.anchoredPosition = localPoint;

Screen Space - Overlay模式下,RectTransformUtility的 camera 参数可以传null。在Screen Space - Camera模式下,必须传 Canvas 指定的worldCamera,否则坐标换算不准。

4.3 FloatingTextManager 管理对象池和事件订阅

FloatingTextManager 是整个飘字模块的管理入口,负责三件事:

  1. 预创建飘字对象。
  2. 监听DamageTextEvent
  3. 从池中取对象、播放动画、回收对象。
using System.Collections.Generic; using UnityEngine; using QFramework; public class FloatingTextManager : MonoBehaviour { [SerializeField] private FloatingTextView floatingTextPrefab; [SerializeField] private Transform poolRoot; [SerializeField] private int prewarmCount = 20; [SerializeField] private Camera targetCamera; private readonly Stack<FloatingTextView> pool = new Stack<FloatingTextView>(); private void Awake() { if (targetCamera == null) { targetCamera = Camera.main; } for (int i = 0; i < prewarmCount; i++) { FloatingTextView view = CreateNew(); view.gameObject.SetActive(false); pool.Push(view); } TypeEventSystem.Global.Register<DamageTextEvent>(OnDamageTextEvent); } private void OnDestroy() { TypeEventSystem.Global.UnRegister<DamageTextEvent>(OnDamageTextEvent); } private void OnDamageTextEvent(DamageTextEvent e) { FloatingTextView view = pool.Count > 0 ? pool.Pop() : CreateNew(); view.Setup(targetCamera); view.Reactivate(e); } public void Recycle(FloatingTextView view) { view.gameObject.SetActive(false); pool.Push(view); } private FloatingTextView CreateNew() { return Instantiate(floatingTextPrefab, poolRoot != null ? poolRoot : transform); } }

这个实现不依赖 QFramework 的SimpleObjectPool,而是用Stack<FloatingTextView>自己做了一个轻量池。原因很简单:SimpleObjectPool在管理纯 C# 对象时很好用,但 Unity 组件必须通过Instantiate创建,使用前需要自定义 factory,自定义程度反而不如直接写一个小池清晰。

如果想用 QFramework 的SimpleObjectPool,需要把创建和销毁函数都适配到 Unity 生命周期上,比如创建时Instantiate,销毁时Destroy。核心思想是一样的。

注意:池中的对象隐藏后仍然挂在poolRoot下,不要在Recycle里改变SetParent,避免频繁触发 Transform 层级变动带来的开销。

4.4 用协程实现上升与淡出

动画部分选择协程而不是 DOTween,主要是为了减少第三方依赖。如果项目已经引入 DOTween,可以替换为:

rectTransform.DOAnchorPosY(rectTransform.anchoredPosition.y + 60f, duration) .SetEase(Ease.OutQuad); text.DOFade(0f, duration).SetEase(Ease.InQuad);

但 DOTween 的 Tween 在对象池复用时需要DOKill,否则同样会出现动画残留。协程版虽然代码略长,但生命周期完全可控,配合StopAllCoroutines()更容易理解。

上升高度和淡出时间建议做成配置项,后期手感调优会非常频繁。当前示例中上升 60 Unity 单位、持续 0.8 秒,实际项目要根据 Canvas 尺寸和 UI 缩放调整。

5. 在类幸存者战斗里接入并验证

5.1 接入路径:碰撞/受伤事件 -> 飘字

在类幸存者项目里,飘字最常见的触发点是敌人受击。一个完整的接入路径如下:

玩家武器碰撞检测 -> 调用 EnemyDamage.TakeDamage -> 扣血并判断死亡 -> 发送 DamageTextEvent -> FloatingTextManager 收到事件 -> 从对象池取出 FloatingTextView -> 坐标转换后播放动画 -> 动画结束 Recycle 回池

伤害计算和飘字显示之间没有任何直接引用,这就是事件解耦的价值。

如果项目自身已经有伤害事件,比如OnHealthChangedEvent,也可以直接扩展那个事件来携带飘字参数,不一定需要单独定义DamageTextEvent。具体取决于事件职责划分。推荐单独定义,避免把飘字 UI 参数和战斗数据模型混在一起。

5.2 验证步骤与预期表现

下面提供一份快速验证清单,适合在开发环境跑通后自查。

步骤操作预期表现
1在场景中放置 FloatingTextManager,并指定 Prefab运行后无报错,池中预创建对象不显示
2使用 Demo Trigger 组件发送事件对应世界坐标处出现飘字
3快速连续发送 50 次事件飘字数量正确,没有明显卡顿
4修改 Camera 位置或角色朝向不在摄像机背后的目标不会显示飘字
5暴击事件使用指定颜色和字号显示
6连续播放同一条飘字不会出现动画重复或文字残留

如果不想先接完整战斗系统,可以加一个测试触发器,用空格键手动发送事件:

using UnityEngine; using QFramework; public class DamageTextDemoTrigger : MonoBehaviour { public KeyCode triggerKey = KeyCode.Space; public Vector3 offset = new Vector3(0, 1.5f, 0); private void Update() { if (Input.GetKeyDown(triggerKey)) { TypeEventSystem.Global.Send<DamageTextEvent>(new DamageTextEvent { worldPosition = transform.position + offset, content = Random.Range(10, 100).ToString(), textColor = Random.value > 0.5f ? Color.yellow : Color.white, fontSize = 32, duration = 0.8f }); } } }

这个组件适合挂在一个用于测试的物体上,运行后按空格就能看到飘字效果。

5.3 事件注册与对象池排错表

开发过程中最常遇到的现象,可以按下面表格排查:

问题现象常见原因检查方式处理建议
运行后按触发键没有任何反应事件类型不匹配,或 FloatingTextManager 没有注册成功检查是否调用了TypeEventSystem.Global.Register确认事件类型和发送类型完全一致,检查日志或 Debug.Break
飘字显示在屏幕角落Canvas 坐标换算错误,Camera 参数传错检查 Canvas renderMode 和传入的 CameraOverlay模式传 null,Camera模式传canvas.worldCamera
飘字文字叠在一起,动画不停止对象池复用前没有调用StopAllCoroutines检查Reactivate是否停止旧协程Reactivate开头调用StopAllCoroutines()
对象池对象越来越多飘字结束后没有回收,或回收到了另一个管理器检查Recycle是否被调用,以及GetComponentInParent是否能找到管理器确保池根节点和 FloatingTextView 在同一个 Canvas 下
暴击颜色和字号不生效颜色或字号字段没有从事件传入检查发送处字段赋值优先使用字段名赋值,避免遗漏
世界坐标点在相机背后仍然显示未判断WorldToScreenPoint.z打印screenPoint.zz < 0时回收并跳过

排错时先从事件发送端查起,确认DamageTextEvent是否真的发出;再看坐标转换,最后看对象池回收。不要一开始就怀疑 QFramework 本身。

6. 常见坑、最佳实践与后续扩展

6.1 六个容易踩的坑

第一条坑是频繁InstantiateDestroy。飘字是高频 UI 对象,直接生成销毁会带来 GC 和卡顿。推荐使用对象池,并在池中预创建一定数量的对象。

第二条坑是坐标转换时把WorldToScreenPoint直接赋值给anchoredPosition。屏幕坐标和 Canvas 本地坐标不是一回事,必须通过RectTransformUtility转换,否则 UI 会漂移。

第三条坑是协程没有停止。对象被回收再复用时,旧协程可能还活着,导致同一对象同时执行多段动画,表现非常混乱。解决方案是在 Re-activate 时StopAllCoroutines()

第四条坑是事件注册没有在销毁时注销。MonoBehaviour 销毁时,如果事件系统还保存着注册委托,下一次事件发送可能触发空引用或重复注册。推荐在OnDestroy中调用UnRegister,或者使用 QFramework 提供的“随 GameObject 销毁自动注销”的扩展。

第五条坑是不判断screenPoint.z。摄像机背后的目标会把世界坐标投影到屏幕前方,导致敌人转身后飘字出现在错误位置。

第六条坑是池对象在场景切换后未清理。如果FloatingTextManager或其子对象跨越场景切换,要注意DontDestroyOnLoad时对象池和 Canvas 的根节点是否仍然有效。建议飘字模块跟随主 Canvas,或者场景切换时主动清理。

6.2 学习环境与生产环境的差异

在学习 Demo 里,可以直接把FloatingTextManager拖进场景,手动指定 Prefab。但到了生产环境,还需要考虑下面这些问题:

关注点学习环境生产环境
Prefab 配置手动拖拽使用 QFramework 的 ResKit 或 Addressables 加载
性能只跑一次,不关注记录每帧创建和回收数量,避免峰值超过阈值
日志简单 Debug 输出增加飘字模块日志开关,生产环境关闭
场景切换手动检查统一处理对象池清理,避免泄漏
多语言/本地化不涉及文本内容可能需要支持多语言格式
适配固定分辨率需要处理不同屏幕分辨率下字号和偏移量的缩放

生产环境里,最值得关注的不是“能不能显示”,而是“高峰期能不能稳定”。可以在FloatingTextManager中统计活跃飘字数量和对象池命中率,超过阈值时输出警告或动态控制飘字数量。

6.3 后续扩展方向

当前实现已经可以支撑类幸存者项目的基础飘字需求。如果想要更完善,可以从这几个方向扩展。

第一种是配置化。把普通伤害、暴击、治疗、MISS、格挡等不同类型的颜色、字号、上升高度、存活时间抽成一个DamageTextConfig,用 ScriptableObject 配置,战斗系统只发送“类型 ID”,飘字模块自行查表。这样新增一个异常状态提示时,不需要改代码。

第二种是特殊效果。暴击时可以额外让文字缩放一下,或者加一个描边颜色变化。TMP 支持材质和渐变效果,可以做出更明显的战斗反馈。

第三种是文字重叠优化。当怪物聚集时,多个飘字会叠在一起。可以给每个飘字增加随机横向偏移,或者维护一个位置占位表,让来自同一位置的飘字自动错开。

第四种是动态内存监控。对象池虽好,但预创建数量需要根据玩法调整。有些关卡怪物特别密集,可以设计成动态扩容,空闲超过一定时间再自动回收“多余”对象,避免长期占用内存。

第五种是接入 QFramework 的架构层。如果项目已经使用 QFramework 的 Architecture,可以把FloatingTextManager注册为系统,通过this.SendEvent<DamageTextEvent>()发送事件,而不是直接使用TypeEventSystem.Global。这样会让事件的作用域更受控,减少全局事件滥用。

建议:新手先跑通当前的事件 + 对象池 + 协程方案,再逐步替换为 DOTween、ScriptableObject 配置和架构事件。飘字功能技术难度不高,但它串联了 UI 渲染、坐标转换、事件系统和对象管理,非常适合作为理解 QFramework 模块化开发的练习项目。

真正要把“文字飘动支持”做好,核心不在于死记几个 API,而在于理解事件背后的解耦思路,以及对象池在 Unity 高频 UI 中的价值。在后续类幸存者开发中,伤害飘字、Buff 提示、金币飘字都可以复用它。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询