类幸存者这类密集战斗游戏里,伤害数字飘动不只是视觉点缀。玩家每次攻击命中,是否能看到清晰、不卡顿、不重叠的文字反馈,直接决定打击感。用 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 压力,最终表现为战斗后期掉帧。
对象池的思路是:
- 在场景启动时预创建一批飘字对象,并且隐藏。
- 收到伤害事件时,从池里取一个激活显示。
- 动画播放结束后,隐藏并塞回池里,供下次复用。
当池里没有空闲对象时,再创建一个新对象补充。这样既能保证吃紧场景下有对象可用,又不会一开始创建过多对象占用不必要的性能。
| 方案 | 生成开销 | GC 压力 | 实现复杂度 | 推荐场景 |
|---|---|---|---|---|
| 每次 Instantiate | 高 | 高 | 低 | Demo 演示、临时测试 |
| 普通 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 - Camera | UI 与场景摄像机结合 | 需要给 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; }字段含义如下:
| 字段 | 类型 | 说明 | 示例 |
|---|---|---|---|
| worldPosition | Vector3 | 飘字显示的世界坐标 | 怪物受击点 |
| content | string | 显示文本 | damage.ToString() |
| textColor | Color | 文字颜色 | 暴击黄色 |
| fontSize | int | 文字字号 | 普通 32,暴击 44 |
| duration | float | 动画持续秒数 | 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 是整个飘字模块的管理入口,负责三件事:
- 预创建飘字对象。
- 监听
DamageTextEvent。 - 从池中取对象、播放动画、回收对象。
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 和传入的 Camera | Overlay模式传 null,Camera模式传canvas.worldCamera |
| 飘字文字叠在一起,动画不停止 | 对象池复用前没有调用StopAllCoroutines | 检查Reactivate是否停止旧协程 | 在Reactivate开头调用StopAllCoroutines() |
| 对象池对象越来越多 | 飘字结束后没有回收,或回收到了另一个管理器 | 检查Recycle是否被调用,以及GetComponentInParent是否能找到管理器 | 确保池根节点和 FloatingTextView 在同一个 Canvas 下 |
| 暴击颜色和字号不生效 | 颜色或字号字段没有从事件传入 | 检查发送处字段赋值 | 优先使用字段名赋值,避免遗漏 |
| 世界坐标点在相机背后仍然显示 | 未判断WorldToScreenPoint.z | 打印screenPoint.z | z < 0时回收并跳过 |
排错时先从事件发送端查起,确认DamageTextEvent是否真的发出;再看坐标转换,最后看对象池回收。不要一开始就怀疑 QFramework 本身。
6. 常见坑、最佳实践与后续扩展
6.1 六个容易踩的坑
第一条坑是频繁Instantiate和Destroy。飘字是高频 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 提示、金币飘字都可以复用它。