ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Unity QFramework搭建类幸存者文字飘动反馈模块

Unity QFramework搭建类幸存者文字飘动反馈模块 类幸存者项目做到第 041 步战斗循环基本能跑通之后最先暴露出来的通常不是玩法问题而是“反馈跟不上”。攻击打中敌人伤害只体现在血条上数字没有飘出来玩家根本感受不到 build 变化。文字飘动支持要解决的就是这类高频即时反馈。这篇文章会在 Unity 开发环境下用 QFramework 框架给类幸存者项目加一个稳定的文字飘动模块让伤害、暴击、治疗、经验和升级提示都能按统一规则生成、移动、淡出和回收。适合用 QFramework 做类幸存者 Demo、正打算把战斗反馈补完整的人看。这里最值得关注的不是某个 UI 特效而是把“事件触发—文字生成—动画驱动—对象池回收”这一整条链路打通。1. 文字飘动在类幸存者项目里属于“战斗反馈层”类幸存者玩法的核心是大量敌人、自动攻击和成长 build。玩家大部分时间只负责移动和选择强化方向战斗过程会非常高频地触发命中、击杀、掉落、升级。如果这些信息全部靠血条和弹窗展示反馈会慢半拍。文字飘动是成本最低、反馈最直接的方案。1.1 文字反馈承担了哪几类信息先盘一下类幸存者项目里常见的飘字场景普通伤害数字每一次攻击命中敌人时出现颜色建议偏白或浅黄字号适中出现频率最高。暴击数字触发暴击时出现字号要明显大一些颜色用橙黄色最好再带一点缩放效果让玩家一眼就能看到关键输出。治疗数字角色回血时出现绿色通常出现在角色附近。经验拾取提示拾取经验碎片时出现的小字数字小、存在时间短不需要太抢眼。升级提示升级时出现的核心反馈文本更大位置可以放在角色上方或屏幕中央偏上承担一定的成就感表达。这些文字类型有一个共同点它们都是战斗逻辑的结果。伤害由攻击系统计算暴击由暴击系统决定治疗由治疗逻辑触发。把它们的展示方式统一到一个模块里是最合理的做法。1.2 为什么不能每个系统各显示各的类幸存者项目如果开发得比较快很容易出现一种情况技能系统自己生成一个 Text敌人受伤逻辑里又生成一个 Text拾取系统再写一份。结果就是一个场景里出现好几种字号、好几种字体层级还经常乱掉有的被 UI 面板挡住有的在暂停时消失。独立游戏开发成本有限飘字这种“所有系统都会用到”的表现层功能必须在早期抽出来统一管理。统一管理之后至少能获得这几个好处字体和字号只有一处配置不会出现表现不一致。所有飘字走同一个层级不会被战斗 UI 或血条 UI 遮住。对象池只需要维护一种预制体复用逻辑清晰。后续如果要升级成 TextMeshPro、支持本地化数字格式、增加音效只需要改飘字模块本身。QFramework 在这里的角色是提供事件、对象池、资源管理这类基础设施。飘字模块本身属于应用层不要求在框架内部写死而是建立在框架能力之上。这样既有框架的解耦能力又不会把飘字逻辑塞进框架代码里。2. 动手前先把坐标空间、生命周期和归属关系定清楚写飘字模块之前最应该先解决的不是“怎么写动画”而是“这个字到底显示在哪个坐标系里”。很多飘字问题比如文字出现在了错误位置、被 UI 遮住、移动时抖动大多是因为坐标空间没有定清楚。2.1 屏幕空间还是世界空间幸存者类游戏里战斗伤害数字应该出现在敌人头顶或受击点附近。这样一来数字在玩家视角里是“贴”在敌人身上的反馈最自然。这种需求应该使用世界空间。世界空间飘字常见有两种实现方式World Space Canvas用 UI Text 或 TextMeshPro 挂在 Canvas 下面把 Canvas 设为 World Space指定 camera。优点是可以继续用 UI 排版和字体资源样式维护方便。3D TextMeshPro / TextMesh直接用场景中的文字物体适合海量飘字但样式编辑不如 UI 直观。我的建议是如果项目里已经用了 TextMeshPro可以直接用它如果团队更熟悉 UGUI就使用 World Space Canvas。两种方式在最终表现上没有太大差异关键是把坐标转换统一处理。屏幕空间飘字则用来处理升级提示、获得新技能、系统公告这类和世界坐标无关的反馈。它们可以放到一个 Screen Space Overlay Canvas 下位置固定或按屏幕比例定位。这里很容易踩一个坑伤害数字如果放在屏幕空间就必须把世界坐标转成屏幕坐标并且在摄像机移动时重新计算。类幸存者虽然摄像机相对固定但角色和敌人都会动转来转去很容易出问题。所以战斗数字优先放世界空间系统提示放屏幕空间两条链路分开最省事。2.2 一条飘字从生成到回收的生命周期一条普通飘字的生命周期可以拆成五个阶段准备从对象池取出对象设置文字内容、颜色、字号、起始位置。显示对象激活可以播放一个轻微放大的入场动画。上升文字沿 Y 轴慢慢向上漂这是“飘”字的核心。淡出到达指定时间后透明度逐渐降为 0。回收透明度为 0 后把对象还回对象池等待下一次使用。这个流程看起来简单但要注意一个关键问题飘字使用的时间基准是什么。类幸存者游戏里如果玩家升级后触发了短暂暂停或慢动作效果飘字如果跟随 Time.deltaTime可能会瞬间跳过淡出过程或者暂停时字幕直接停下。项目一旦涉及时间缩放飘字动画最好独立使用一个时间源比如游戏内单独维护的 unscaledDeltaTime或者把飘字也归入暂停层来管理。2.3 归属关系文字归谁管飘字对象不应该属于某个技能、某个敌人或某个面板。它们应该统一归 FloatingTextManager 管理。FloatingTextManager 通常是一个场景中的 MonoBehaviour持有两个关键对象World Space Canvas用来挂战斗飘字位置由世界坐标决定。Screen Space Canvas用来挂升级提示和系统提示。FloatingTextManager 负责对象池、预制体实例化、挂载位置和回收回调。战斗系统不直接访问任何飘字对象只发送“这里有伤害数字”的事件。飘字模块接收到事件后自己去查样式、取对象、设置位置。这样职责清楚后续替换文字样式时不会影响战斗逻辑。3. 用 QFramework 事件系统搭一条最小飘字链路3.1 为什么用事件而不是直接调用在类幸存者项目里战斗系统会调用飘字模块吗不一定。比如伤害系统在计算伤害后需要告诉“飘字模块”显示一个数字。如果直接在伤害代码里调用FloatingTextManager.Instance.ShowDamage(...)看起来也没问题但伤害代码就永久依赖了 FloaingTextManager。以后如果要在把飘字模块换成伤害数字特效或者想在多人同步时把飘字做成服务器驱动改动范围会更大。使用事件之后方向就反过来了伤害系统只负责发送一个“FloatingTextEvent”。飘字模块负责监听这个事件并决定如何显示。伤害系统完全不知道飘字模块存在。这就是 QFramework 事件系统的价值。它不会强制你使用哪种架构但能把单向依赖切断。在类幸存者这种多系统高频交互的场景里事件驱动尤其适合。3.2 定义事件数据和发送端先定义一个飘字消息结构字段不需要太多public struct FloatingTextEvent { public Vector3 worldPos; public string content; public Color color; public float fontSize; public string styleId; // 可选预留扩展 public static FloatingTextEvent Create( Vector3 worldPos, string content, Color color, float fontSize, string styleId ) { return new FloatingTextEvent { worldPos worldPos, content content, color color, fontSize fontSize, styleId styleId }; } }发送端就非常简洁。假设敌人受伤逻辑在 EnemyDamageHandler 里发一条普通伤害TypeEventSystem.Global.Send(FloatingTextEvent.Create( hitPoint, damage.ToString(), Color.white, 32f ));暴击数字则可以在相同位置发送更大字号的橙色文字TypeEventSystem.Global.Send(FloatingTextEvent.Create( hitPoint, damage.ToString(), new Color(1f, 0.6f, 0f), 46f ));这里要注意QFramework 的事件系统在不同版本里的 API 可能略有差异比如Global.Send(...)和注册方式要以项目里实际安装的 QFramework 版本为准。整体思路是不变的。3.3 监听端处理并生成数字FloatingTextManager 需要注册监听private void OnEnable() { TypeEventSystem.Global.RegisterFloatingTextEvent(OnFloatingTextEvent); } private void OnDisable() { TypeEventSystem.Global.UnRegisterFloatingTextEvent(OnFloatingTextEvent); } private void OnFloatingTextEvent(FloatingTextEvent e) { // 从池中取出一个飘字对象 var item floatingTextPool.Get(); // 显示并设置位置、内容、颜色、字号 item.Show(e.worldPos, e.content, e.color, e.fontSize); }对应的飘字对象脚本我一般会设计成这样public class FloatingTextItem : MonoBehaviour { public TextMeshPro text; public CanvasGroup canvasGroup; public void Show(Vector3 pos, string content, Color color, float fontSize) { transform.position pos; text.text content; text.color color; text.fontSize fontSize; canvasGroup.alpha 1f; gameObject.SetActive(true); } }先做到这里飘字已经能“出现”了。后面再补动画和回收。3.4 最小链路测试写完监听和发送先不要急着做复杂样式做一个最小链路验证。我在测试时会这样做在场景里创建一个 World Space Canvas指定 Event Camera 为主相机。在 Canvas 下放一个 TextMeshPro 预制体并把它设为 FloatingTextManager 的飘字预制体。挂 FloatingTextManager预载 10 个对象。写一个 TestTrigger 脚本按空格键发送一条普通伤害事件。运行游戏按空格确认在指定位置出现数字。这一步能通过说明事件通知、对象池取出、文字显示、坐标定位都正常。之后再去做上升和淡出动画问题定位会更清晰。4. 用对象池接管文字的生成、运动与回收4.1 高频生成场景下Instantiate 和 Destroy 不是一个好选择类幸存者和普通 RPG 的区别在于战斗频率特别高。一个敌人可能会同时挨到多个弹道的伤害瞬间就可能生成五六个飘字。如果这时候每个飘字都走Instantiate和Destroy场景里很快会出现大量创建和销毁操作产生不必要的 GC还会带来短暂的帧率抖动。独立游戏开发时这个坑尤其隐蔽。低配机器上可能不会立刻卡死但连续战斗几分钟后内存碎片和 GC 会导致明显掉帧。正确做法是使用对象池。预先创建一批飘字对象使用完成后再放回去不销毁。这样生成的只是“从队列里取出一个对象”成本很低。4.2 一个简单的飘字对象池实现FloatingTextManager 可以自己维护一个队列不需要额外引入复杂容器public class FloatingTextManager : MonoBehaviour { public GameObject floatingTextPrefab; public int prewarmCount 30; public Transform worldCanvas; private QueueFloatingTextItem pool new QueueFloatingTextItem(); private void Awake() { for (int i 0; i prewarmCount; i) { var item CreateItem(); item.gameObject.SetActive(false); pool.Enqueue(item); } } private FloatingTextItem CreateItem() { var go Instantiate(floatingTextPrefab, worldCanvas); return go.GetComponentFloatingTextItem(); } public FloatingTextItem Get() { if (pool.Count 0) { return pool.Dequeue(); } // 池不足时扩容不需要每次都创建 return CreateItem(); } public void Release(FloatingTextItem item) { item.gameObject.SetActive(false); pool.Enqueue(item); } }注意几个细节预载数量不要一开始就设成很大。30 个已经能在默认条件下覆盖大多数短暂飘字需求。如果同屏飘字经常超过这个数再提高预载量。池不足时仍然扩容创建不会因为池满导致飘字缺失。回收时必须把对象 SetActive(false)否则动画播放完毕的旧文字会留在场景里。FloatingTextItem 自身也要在动画结束后调用 Manager 的 Releaseprivate void Finish() { floatingTextManager.Release(this); }这样对象池的取和还就闭环了。4.3 预制体、Canvas 和字体配置细节飘字预制体虽然小但配置不对会产生很多问题。使用 World Space Canvas 时Canvas 的缩放决定了文字大小。如果文字出现在敌人头顶但是太大或太小优先检查 Canvas 的 Scale 和 Text 的 font size。预制体上的 Text 类型建议直接用 TextMeshPro因为它是当前 Unity 里更稳定、性能更好的文字方案。传统 UGUI Text 也能用但大量动态文字时表现力和字体渲染效果都不如 TextMeshPro。每条飘字挂一个 CanvasGroup方便控制淡出。直接改 text.color.a 也可以但对多个子元素时 CanvasGroup 更统一。如果 World Space Canvas 使用了 Screen Space Camera 选项之外的模式还要确认 Canvas 的 worldCamera 字段是否指向主相机。还有一个容易忽略的问题飘字对象的 Root 不应该带多余的 Image 或 Raycast Target 组件。UI 射线检测会在鼠标或触摸操作时产生额外开销。飘字只是纯展示应该把 Raycast Target 关闭。5. 把样式和动画参数抽成配置方便后续调表现5.1 一份可扩展的飘字样式当飘字类型多起来后每个事件都手动指定颜色、字号很容易出错。更稳妥的方式是使用样式配置。先定义一个 Serializable 的样式类[System.Serializable] public class FloatingTextStyle { public string styleId; public Color color Color.white; public float fontSize 32f; public float riseSpeed 1.5f; public float lifeTime 0.8f; public float fadeDuration 0.4f; public Vector2 randomOffset Vector2.zero; public bool scaleOnEnable false; }然后在 FloatingTextManager 里放一个样式数组在 Inspector 里配置public FloatingTextStyle[] styles;写一个根据 styleId 查找样式的方法。找不到时使用第一个样式作为默认值。事件发送端不再需要手动指定颜色和字号只指定样式 idTypeEventSystem.Global.Send(FloatingTextEvent.Create( hitPoint, damage.ToString(), damage_normal ));这样伤害系统就不关心颜色、字号、飘动速度这些表现层参数了。美术或策划想调整表现只需要改 Editor 配置不用找开发改代码。5.2 不同来源的飘字如何区分常见样式可以这样规划样式 id用途颜色字号上升速度生命周期淡出damage_normal普通伤害白色321.50.80.4damage_crit暴击橙黄461.80.90.5heal治疗绿色321.20.80.4exp经验拾取浅蓝220.80.60.3levelup升级金色600.51.50.8pickup拾取提示浅绿241.00.70.4这些参数不是固定标准具体数值要根据游戏美术风格和手感调。我的建议是先按这个表跑起来然后在实际战斗中看观感再逐个调。暴击数字比普通伤害大一号通常就够醒目。如果希望更有冲击力可以在暴击样式上开启scaleOnEnable播放一个从 1.2 倍缩小到 1 倍的入场动画。经验拾取数字不需要太显眼因为拾取频率高、信息价值低做得太大会干扰战斗画面。5.3 动画用 DoTween 还是手写飘字动画可以直接使用 DoTween这个插件在 Unity 项目里非常常见。DoTween 的好处是代码简洁而且 DOMove、DOFade、DOScale 都有现成接口transform.DOMove(transform.position Vector3.up * riseDistance, lifeTime).SetEase(Ease.OutCubic); canvasGroup.DOFade(0f, fadeDuration).SetDelay(lifeTime - fadeDuration);但如果项目不想引入额外插件手写 Update 也完全够private float elapsed; private float fadeStartTime; private bool isPlaying; public override void Play(FloatingTextData data) { elapsed 0f; fadeStartTime data.lifeTime - data.fadeDuration; isPlaying true; } private void Update() { if (!isPlaying) return; elapsed Time.deltaTime; var t elapsed / data.lifeTime; // 上升 transform.position Vector3.up * data.riseSpeed * Time.deltaTime; // 缩放入场 if (t 0.2f) transform.localScale Vector3.Lerp(startScale, Vector3.one, t / 0.2f); // 淡出 if (elapsed fadeStartTime fadeStartTime 0) { canvasGroup.alpha Mathf.Lerp(1f, 0f, (elapsed - fadeStartTime) / data.fadeDuration); } // 回收 if (elapsed data.lifeTime) { isPlaying false; floatingTextManager.Release(this); } }手写的优势是可控性强依赖少DoTween 的优势是代码短、缓动函数丰富。使用 DoTween 时对象池回收前必须调用Kill或者使用带onKill回调的方式否则 Tween 回调会在对象复用后触发产生奇怪的表现。如果同一屏飘字数量很大建议优先考虑手写。不是 DoTween 本身性能有问题而是 Tween 对象在大量并行时也需要维护和销毁会增加管理成本。飘字这种生命周期非常简单的动画手写并不麻烦。6. 接入伤害、拾取和升级反馈并建立排查链路6.1 接进伤害结算、拾取和升级现在飘字模块已经具备基本能力接下来要把它接入类幸存者的业务逻辑。伤害结算处在计算出伤害数值之后发送事件var damage player.GetDamage(); TypeEventSystem.Global.Send(FloatingTextEvent.Create( enemy.HitPoint, damage.ToString(), damage_normal )); if (isCrit) { TypeEventSystem.Global.Send(FloatingTextEvent.Create( enemy.HitPoint, damage.ToString(), damage_crit )); }暴击这里有个细节暴击通常不应该同时显示普通伤害和暴击伤害。如果伤害系统也发了普通伤害暴击也发一条玩家会看到两个数字叠在一起。正确做法是伤害系统里先判断是否暴击只发一条事件用不同的样式 id。拾取经验时在玩家角色附近发一条样式为 exp 的飘字TypeEventSystem.Global.Send(FloatingTextEvent.Create( playerTransform.position Vector3.up * 0.5f, expValue.ToString(), exp ));升级提示可以不走世界空间改成屏幕空间 UITypeEventSystem.Global.Send(FloatingTextEvent.Create( levelUpTitlePos, LEVEL UP, levelup ));如果升级提示使用了屏幕空间FloatingTextManager 需要额外判断样式对应的 Canvas 类型。可以在样式表里加一个isScreenSpace字段然后再选择使用哪个 Canvas。6.2 验证标准接入之后不要只看“有没有数字飘出来”。我会按以下几个标准验证启动不报错事件注册没有异常。单条飘字出现位置正确内容正确。连续发送多条飘字所有飘字都能正常播放并回收。暴击样式和普通伤害样式能区分开。动画播放期间如果打开对象池查看对象数量不会持续增加。连续战斗五分钟后没有明显 GC 增长帧率稳定。游戏暂停或时间缩放下飘字表现符合预期。这些标准里第 5 条和第 6 条最容易出问题。很多模块在小规模测试时看起来正常一旦进入连续战斗就暴露出频繁创建和销毁的问题。6.3 常见问题与排查顺序飘字模块的排错顺序我一般会这样走先看现象再查事件链路最后查配置和动画。如果飘字完全没出现优先确认事件是否发送成功。可以在发送端和监听端各放一条 Debug.Log跑一次看输出。如果事件没发问题在伤害系统如果事件发了但没有飘字问题在飘字模块。如果飘字出现了但位置不对优先检查坐标空间。世界空间飘字需要看世界坐标和 Canvas 是否匹配屏幕空间飘字要看坐标转换是否正确。还有一个常见原因是 Canvas 的缩放和相机参数没配对导致数字跑到屏幕边缘。如果飘字出现后没有飘优先检查 Update 是否还在执行。比如对象被 SetActive(false) 后 Update 不会执行事件里传入的 lifeTime 为 0 也会导致立即回收。如果飘字出现一小会后突然消失优先检查对象池回收逻辑。很可能是生命周期太短或者淡出时间大于生命周期导致计算异常。如果飘字动画卡顿先检查预载数量是否不足。池不足时每次扩容都会 Instantiate即使逻辑正确首次扩容的一瞬间也会有性能峰值。可以把预载数量提高或者提前在场景加载时预热。还有一个高频问题就是回收时没有重置文字状态。比如上次回收前文字颜色是橙色下次复用时如果没重置TextMeshPro 会保留旧颜色。建议在 Show 方法里把所有状态统一重置包括位置、缩放、旋转、颜色、透明度、默认内容。6.4 性能边界和后续优化类幸存者项目里飘字数量可能非常夸张。满屏敌人同时受伤时每秒可能出现几十个飘字。这个数量级下需要做一些保护策略。首先是限制同屏飘字总数。FloatingTextManager 可以维护一个当前活跃数量超过上限时不再生成新的飘字而是丢弃或者合并。普通伤害数字丢失几个影响不大但暴击数字要保证一定显示优先级。其次是控制字符处理。伤害数字的ToString()在低频率下没有问题但高频飘字时字符串的分配也会产生 GC。可以提前做简单的字符串缓存或者把damage.ToString()的结果先存起来避免每次伤害都新建字符串。再就是 Canvas 性能。飘字在移动时会改 transform这会让 Unity UI 重新计算合批。同屏飘字越多这个成本越高。解决思路有几个使用 TextMeshPro 而不是 UGUI Text。降低飘字数量上限不要无限制显示。调整飘字动画尽量使用局部坐标移动避免频繁修改 Canvas 根节点。最后是合理的展示策略。普通伤害数字可以只保留最近几次命中暴击数字绝对保留拾取经验小字在数量超过三个时直接合并显示一个总数。这些策略都能减少画面噪点同时减少性能开销。如果你也在做类幸存者项目我的建议是先把普通伤害和暴击这一条链路跑稳再往上升级提示、拾取提示这些额外样式。文字飘动本身不复杂复杂的是让它在长时间、高频率、多敌人的战斗中不卡、不乱、不错位。这几条处理好战斗手感会明显提升一个档次。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表