ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于QFramework实现Unity战斗飘字系统:对象池与事件驱动实战

基于QFramework实现Unity战斗飘字系统:对象池与事件驱动实战 在类幸存者游戏里当角色被一大群敌人围住时如果看不到伤害数字、看不到拾取反馈、看不到升级提示战斗手感会立刻变得很“木”。玩家会觉得自己在打空气敌人死了也缺乏成就感。这一期我们围绕“文字飘动支持”来做一次完整的战斗反馈补全把散落在各处的伤害数字、经验飘字、升级提示统一收拢到一套基于 QFramework 的图层系统中。本文会从需求拆解开始逐步完成事件定义、对象池设计、浮动文字组件、管理器接入最后再接入战斗逻辑。整个过程适合已经跟着本系列完成基础战斗循环的读者也适合想在自己项目中加入“飘字系统”的独立游戏开发者。1. 背景与核心概念1.1 什么是文字飘动文字飘动英文常称为 FloatingText 或 DamagePopup指 UI 上一段短生命周期的文字从生成点开始通过位移、缩放、颜色变化、淡出等动画播放后消失的过程。它通常不参与核心玩法逻辑只承担“战斗反馈”和“信息提示”两类任务。在类幸存者游戏中玩家每秒会命中大量敌人如果每次攻击只看到敌人掉血条反馈密度是不够的。伤害数字、暴击数字、经验拾取提示、升级提示、技能冷却提示都可以用一套文字系统统一渲染避免每个功能各自写一套 UI 逻辑。1.2 为什么类幸存者游戏更需要它类幸存者的战斗特点是“敌人数多、攻击频率高、数值成长快”。这意味着玩家需要高频接收到“我的攻击有效、造成伤害”的反馈。经验宝石被批量拾取时逐个弹出提示会刷屏需要聚合或做频率限制。升级瞬间需要强反馈和普通飘字在尺寸、颜色、位置上要拉开差距。把这些反馈统一做成 FloatingText 子系统后战斗逻辑只需要发一个事件UI 层负责表现逻辑和表现就能彻底解耦。1.3 文字飘动的三种常见表现类型典型内容表现特点伤害数字-10、-25小字号、黄色/白色瞬间上浮后淡出暴击数字-45、CRITICAL大字号、橙色/红色伴随缩放脉冲拾取/升级提示经验、LEVEL UP绿色/金色纵向位移更明显持续时间略长虽然类型不同但底层实现可以共用一套组件一段文本、一个背景色、一个 CanvasGroup、一段位移动画、一段透明动画、一个回池回调。2. 环境准备与版本说明本文写作环境以比较稳定的 Unity 2021.3 LTS 为示例TextMeshPro 使用 Unity 内置模块。QFramework 版本需要根据你项目实际安装为准核心用到的事件机制在 QFramework 中属于长期稳定模块思路可以直接复用。如果项目已经在前 40 期里搭好了以下结构本文的做法可以直接嵌进去Assets/ ├── Scripts/ │ ├── Battle/ // 玩家、敌人、武器、伤害计算 │ ├── Architecture/ // QFramework 架构相关 │ ├── UI/ // 主 UI、血条 │ └── FloatingText/ // 本文新增 └── Prefabs/ └── UI/ └── FloatingText.prefab如果你的项目结构不同也没关系FloatingText 是一套独立模块放到任意目录都能用只需要调整命名空间和事件引用。3. 文字飘动需求拆解3.1 功能点列表在实际动手前先把需求拆清楚。一个完整的最小飘字系统至少需要支持设置文本内容。支持设置颜色、字号。支持从世界坐标生成并正确显示在 UI Canvas 上。支持播放“上移 淡出”动画。动画结束后自动回收到对象池供下次复用。支持被其他战斗逻辑通过事件触发而不是直接 new 一个 GameObject。3.2 表现公式为了让飘字手感统一我推荐把动画参数抽出来初始位置生成时的屏幕坐标。目标位移沿着 Y 轴正方向移动 40 到 80 像素具体取决于文本类型。生命周期常规伤害 0.8 到 1.0 秒升级提示 1.2 到 1.6 秒。透明曲线从 1 到 0使用平滑插值。缩放曲线可选暴击数字可以从 1.2 倍缩回 1.0 倍再开始淡出。这样做的好处是以后调整手感只需要改参数不需要改组件结构。3.3 解耦原则文字飘动不应该知道“是谁打了谁、打了几点血”。它只关心三件事显示什么文本。显示在哪个位置。用什么样式和生命周期播放。因此战斗逻辑只需要发一个ShowFloatingTextEvent管理器收到后负责后续所有工作。这一条是让系统不腐化的关键。4. 基于 QFramework 的架构设计4.1 事件驱动流程整个文字飘动的运行流程可以抽象为下面这条链路战斗系统计算出伤害 → 发送 ShowFloatingTextEvent → FloatingTextManager 监听事件 → 从对象池取出 FloatingText 组件 → 初始化文本、颜色、位置 → 播放位移和淡出动画 → 动画结束回收到对象池事件驱动的好处是以后新增“金币飘字”“任务提示飘字”时战斗模块完全不用改动只要继续发事件即可。UI 层扩展表现不影响战斗核心。4.2 FloatingText 子系统成员为了让代码清晰我把子系统拆成四个文件文件名职责FloatingText.cs单个飘字组件负责显示和播放动画FloatingTextPool.cs对象池负责实例化和回收FloatingTextManager.cs负责监听事件、协调位置转换和对象池调用ShowFloatingTextEvent.cs事件定义承载文本、坐标、样式下面从最小的事件定义开始逐个文件实现。5. 完整实战案例5.1 定义事件与数据结构打开或新建事件文件// 文件路径Assets/Scripts/FloatingText/ShowFloatingTextEvent.cs using UnityEngine; namespace Survivor.FloatingText { public class ShowFloatingTextEvent { public string Content; public Vector3 WorldPosition; public Color Color; public int FontSize; public float Duration; } }事件使用普通类而不是 struct是为了在频繁发送伤害飘字时可以复用同一个实例减少 GC 压力。字段保持公开方便战斗模块快速赋值。5.2 实现 FloatingText 组件这是整个系统的核心表现组件。它挂在预制体上预制体结构是FloatingText (CanvasGroup FloatingText.cs) └── Text (TextMeshProUGUI)组件代码// 文件路径Assets/Scripts/FloatingText/FloatingText.cs using System.Collections; using UnityEngine; using TMPro; namespace Survivor.FloatingText { public class FloatingText : MonoBehaviour { [SerializeField] private TextMeshProUGUI tmpText; [SerializeField] private CanvasGroup canvasGroup; private FloatingTextPool pool; private Coroutine floatingCoroutine; public void SetPool(FloatingTextPool ownerPool) { pool ownerPool; } public void Show(string content, Vector3 screenPos, Color color, int fontSize, float duration) { tmpText.text content; tmpText.color color; tmpText.fontSize fontSize; transform.position screenPos; if (canvasGroup ! null) { canvasGroup.alpha 1f; } // 清除可能残留的动画 if (floatingCoroutine ! null) { StopCoroutine(floatingCoroutine); } floatingCoroutine StartCoroutine(PlayFloatingAnimation(duration)); } private IEnumerator PlayFloatingAnimation(float duration) { Vector3 startPos transform.position; Vector3 endPos startPos new Vector3(0, 60f, 0); float elapsed 0f; while (elapsed duration) { elapsed Time.deltaTime; float t Mathf.Clamp01(elapsed / duration); // 先快后慢的上移 float moveT 1f - Mathf.Pow(1f - t, 2f); transform.position Vector3.Lerp(startPos, endPos, moveT); // 后半段开始淡出 if (canvasGroup ! null) { float fadeT Mathf.Clamp01((t - 0.4f) / 0.6f); canvasGroup.alpha 1f - fadeT; } yield return null; } // 动画结束回收到对象池 if (pool ! null) { pool.Recycle(this); } else { gameObject.SetActive(false); } } } }解释一下几个关键点tmpText使用 TextMeshProUGUI保证中文字体渲染清晰也方便后续做描边、阴影等效果。canvasGroup控制整体透明度不用逐个修改 Text 的 alpha。moveT用了平方缓出飘字会先快后慢看起来更自然。淡出从生命周期 40% 处开始避免“瞬间消失”的生硬感。5.3 实现对象池为了避免每次飘字都 Instantiate 产生 GC我写一个轻量对象池。对象池只负责创建、复用、回收三个动作。// 文件路径Assets/Scripts/FloatingText/FloatingTextPool.cs using System.Collections.Generic; using UnityEngine; namespace Survivor.FloatingText { public class FloatingTextPool { private readonly StackFloatingText pool new StackFloatingText(); private readonly FloatingText prefab; private readonly Transform parent; public FloatingTextPool(FloatingText prefab, Transform parent, int preloadCount) { this.prefab prefab; this.parent parent; for (int i 0; i preloadCount; i) { FloatingText item CreateNew(); item.gameObject.SetActive(false); pool.Push(item); } } public FloatingText Allocate(Vector3 screenPos) { FloatingText item pool.Count 0 ? pool.Pop() : CreateNew(); item.gameObject.SetActive(true); item.transform.position screenPos; return item; } public void Recycle(FloatingText item) { item.gameObject.SetActive(false); pool.Push(item); } private FloatingText CreateNew() { FloatingText item Object.Instantiate(prefab, parent); item.SetPool(this); return item; } } }实际项目中如果你已经引入了 QFramework 的SafeObjectPool也可以用它替代手写池子。但考虑到 MonoBehaviour 不能通过直接 new 创建手写池反而更直白也更加可控。5.4 实现 FloatingTextManagerManager 承担两件事监听全局事件、根据事件参数调用对象池。// 文件路径Assets/Scripts/FloatingText/FloatingTextManager.cs using UnityEngine; using QFramework; namespace Survivor.FloatingText { public class FloatingTextManager : MonoBehaviour { [SerializeField] private FloatingText floatingTextPrefab; [SerializeField] private Canvas parentCanvas; private FloatingTextPool pool; private ShowFloatingTextEvent cachedEvent; private void Awake() { pool new FloatingTextPool(floatingTextPrefab, parentCanvas.transform, 20); cachedEvent new ShowFloatingTextEvent(); } private void OnEnable() { TypeEventSystem.Global.RegisterShowFloatingTextEvent(OnShowFloatingText); } private void OnDisable() { TypeEventSystem.Global.UnRegisterShowFloatingTextEvent(OnShowFloatingText); } private void OnShowFloatingText(ShowFloatingTextEvent e) { // 世界坐标转屏幕坐标前提是 Canvas 使用 Screen Space - Overlay Vector3 screenPos Camera.main.WorldToScreenPoint(e.WorldPosition); FloatingText ft pool.Allocate(screenPos); ft.Show(e.Content, screenPos, e.Color, e.FontSize, e.Duration); } // 供非事件场景直接调用比如新手引导里的固定飘字 public void ShowFloatingText(string content, Vector3 worldPos, Color color, int fontSize, float duration) { cachedEvent.Content content; cachedEvent.WorldPosition worldPos; cachedEvent.Color color; cachedEvent.FontSize fontSize; cachedEvent.Duration duration; OnShowFloatingText(cachedEvent); } } }这里有一个容易被忽略的细节如果 Canvas 不是 Overlay 模式而是 ScreenSpaceCamera那么WorldToScreenPoint返回的屏幕坐标不能直接赋给 UI 元素需要经过RectTransformUtility.ScreenPointToLocalPointInRectangle转换。本案例先以 Overlay 模式为主后面的常见问题里会补充说明。5.5 创建预制体与场景配置在 Unity 编辑器中完成以下步骤在场景中创建一个 CanvasRenderMode 选择 Screen Space - Overlay添加 CanvasGroup。在 Canvas 下创建空物体命名为 FloatingText添加 TextMeshProUGUI 子物体。给 FloatingText 物体挂载 CanvasGroup 和 FloatingText.cs 脚本。将预设拖入 Project 窗口生成 Prefab删除场景中的临时物体。创建 FloatingTextManager 物体挂载 FloatingTextManager.cs将 Prefab 引用给它。如果你使用 QFramework 的 UIKit 管理 UI可以把 FloatingText 作为 UI 面板的一部分但要注意对象池和 Panel 的绑定关系。通常建议把 FloatingTextManager 做成全局单例不跟随某个界面关闭而销毁。5.6 在战斗流程中接入现在把飘字接入伤害流程。假设项目中已经有一个EnemyController在敌人受到伤害的方法中发送事件// 文件路径Assets/Scripts/Battle/EnemyController.cs仅展示飘字接入部分 using UnityEngine; using QFramework; using Survivor.FloatingText; public class EnemyController : MonoBehaviour { public void TakeDamage(float damage, Vector3 hitPoint) { // 原有扣血逻辑 health - damage; // 发送飘字事件 TypeEventSystem.Global.SendShowFloatingTextEvent(new ShowFloatingTextEvent { Content ${-damage}, WorldPosition hitPoint, Color Color.yellow, FontSize 24, Duration 0.9f }); if (health 0) { Die(); } } }经验拾取和升级提示的接入方式一样// 经验拾取 TypeEventSystem.Global.SendShowFloatingTextEvent(new ShowFloatingTextEvent { Content 20 EXP, WorldPosition playerPosition, Color Color.green, FontSize 20, Duration 1.0f }); // 升级提示 TypeEventSystem.Global.SendShowFloatingTextEvent(new ShowFloatingTextEvent { Content LEVEL UP!, WorldPosition playerPosition Vector3.up * 1.2f, Color new Color(1f, 0.84f, 0f), FontSize 40, Duration 1.5f });5.7 扩展暴击、聚合与随机偏移单纯接入伤害数字后会发现在高攻速下飘字非常多满屏数字既影响视线也浪费性能。这里提供三个扩展点第一暴击数字。在发送前判断是否是暴击如果是就给 FontSize 乘 1.5并让颜色变为橙色。第二随机偏移。每个飘字都固定从命中点正上方弹出会在连续命中时完全重叠。可以在 WorldPosition 上增加一个随机偏移Vector3 offset new Vector3(Random.Range(-0.3f, 0.3f), Random.Range(0, 0.4f), 0);第三聚合提示。经验宝石一次被拾取 5 个时与其显示 5 条“EXP”不如显示一条“5 EXP”。这类聚合逻辑放在事件发送端做UI 层不关心聚合规则。6. 常见问题与排查思路问题现象常见原因解决思路飘字不显示Prefab 没有挂载 CanvasGroup或 Canvas 层级被遮挡检查 FloatingText 物体是否是 Canvas 下最后一层检查 alpha 初始值飘字显示在错误位置Canvas 不是 Overlay 模式坐标转换没做改用 Overlay或者用 RectTransformUtility 转换屏幕坐标到 localPoint连续命中时数字重叠世界坐标相同没有随机偏移在事件发送端增加小范围随机偏移飘字回池后位置不对回池时没有重置 transform 的 localScale 或 rotation在 Recycle 时重置 transform.localScale Vector3.onerotation Quaternion.identity满屏飘字导致性能下降同一时间创建过多对象或对象池预创建不足增加对象池上限限制同一屏最大飘字数超过上限时优先回收最早生成的飘字中文显示为方块TextMeshPro 缺少中文字体资源在 TMP 字体设置中导入包含中文的 Font Asset事件发送后无反应Manager 没有注册事件或对象被销毁检查 FloatingTextManager 的 OnEnable 和 OnDisable 是否注册/注销事件7. 最佳实践与工程建议7.1 用对象池管理一切飘字飘字是高频短生命周期对象不能直接 Instantiate。如果项目还没有全局对象池从这套代码开始引入对象池思维后续敌人弹幕、粒子特效、拾取物都可以沿用同一个模式。我给普通项目建议的预创建数量是伤害数字20 个。经验飘字10 个。升级/提示类2 个。如果同时出现的飘字超过对象池容量后续的逻辑是扩池。不要因为性能问题跳过对象池否则 GC 会明显拖垮战斗帧率。7.2 事件结构体尽量复用在战斗中伤害飘字可能是每秒触发几十次的事件。如果每次发送都 new 一个ShowFloatingTextEvent会造成一定 GC 压力。常见做法是准备一个缓存实例发送时只更新字段if (cachedShowEvent null) { cachedShowEvent new ShowFloatingTextEvent(); } cachedShowEvent.Content -10; cachedShowEvent.WorldPosition hitPoint; TypeEventSystem.Global.Send(cachedShowEvent);注意如果事件在发送后会被异步消费复用缓存实例会带来风险。本文的 Manager 是同步消费所以可以复用。7.3 表现参数集中管理飘字的手感高度依赖动画时长、位移距离、淡出时机这些参数。建议不要散落在战斗代码里而是集中放到一个 ScriptableObject 或配置类中[System.Serializable] public class FloatingTextStyle { public Color color; public int fontSize; public float duration; public float moveDistance 60f; public float fadeDelay 0.4f; }这样策划或美术调参时不用改代码直接在 Inspector 中配置不同风格。尤其是幸存者类游戏里拆分成“普通伤害、暴击、经验、升级、拾取”五类风格后配置化的收益非常大。7.4 控制同一屏的飘字数量即使有对象池过量飘字也会造成屏幕杂乱。建议加一个简单的数量上限超出上限时丢弃新的飘字请求或者把相同位置相同内容的飘字合并。例如在 FloatingTextManager 中加一个计数器public int activeCount; public int maxActiveCount 30; // 在逻辑判断时如果 activeCount 已满直接丢弃本次请求这种做法对玩家体验更友好也避免性能瓶颈。7.5 Canvas 层级与摄像机坐标如果一个场景中同时存在多套 Canvas建议把飘字挂在专门的“OverlayUI”Canvas 下并确保它的 sortingOrder 高于主界面但低于弹窗。否则飘字会出现在角色面板下面失去反馈意义。如果 Canvas 使用了 ScreenSpaceCamera屏幕坐标转换需要额外处理RectTransformUtility.ScreenPointToLocalPointInRectangle( canvas.transform as RectTransform, screenPos, canvas.worldCamera, out Vector2 localPos );这块细节我在文章里不展开但遇到位置偏移时优先检查 Canvas 的渲染模式。8. 总结与下一步学习到这一步我们已经完成了一套基于 QFramework 事件机制的文字飘动系统。代码并不复杂但通过事件分发、对象池复用、动画协程三个部分的组合解决了类幸存者游戏中相当影响手感的一块反馈问题。你接下来可以继续关注这几个方向第一把飘字样式从硬编码改成 ScriptableObject 配置并支持按伤害类型自动选择颜色、字号。 第二为自己的飘字加入“暴击冲击效果”和“屏幕震动”的组合反馈提升战斗爽感。 第三把 FloatingTextManager 纳入 QFramework 的架构中例如通过 Utility 注册到全局让任何 Controller 都能直接获取。 第四如果飘字数量继续增长可以把 Update 协程改造成基于 Timeline 或 DOTween 的动画方案进一步提升可读性。如果本文对你有帮助可以收藏备用后续继续跟着系列推进战斗系统的其他模块。动手把代码跑起来再调一套自己顺手的飘字参数会比只看文章收获大得多。
返回列表