伤害飘字系统笔记 这套系统本质上是一条比较成熟的战斗 UI 表现链路:
先把一条飘字抽象成数据
管理器负责调度、资源、对象池、分层
单个飘字对象只负责显示和动画
播放结束后自动回收
它不是单纯“把数字显示出来”,而是把 数值格式化、类型编码、字库映射、动画播放 串成了一套完整流程。
1. 整体思路 如果你以前做过 UGUI + prefab + 对象池 的飘字,这套可以理解成增强版:
还是 UGUI 文本
还是对象池复用
但增加了按目标排队
增加了多套字体切换
增加了统一数字格式化
增加了自定义字库编码
一个典型入口大概像这样:
1 2 3 4 5 6 7 8 9 public void ShowDamage(float damage, FontType fontType, bool isCrit, Unit target) { var data = isCrit ? DamageFloatData.Crit(damage, fontType, target.worldPos) : DamageFloatData.Normal(damage, fontType, target.worldPos); data.IsHero = target.isHero; ShowDamage(data, target); }
这里最重要的点是:外部不直接操作 UI,而是先构造一条“飘字数据”。
2. 数据层怎么设计 一条飘字通常会包含这些信息:
1 2 3 4 5 6 7 8 9 10 public struct DamageFloatData { public float Damage { get; set; } public FontType FontType { get; set; } public FloatTextType TextType { get; set; } public string DisplayText { get; set; } public Vector3 WorldPosition { get; set; } public Vector3 Offset { get; set; } public bool IsHero; }
这层的意义是把“业务含义”和“显示实现”分开。
比如:
普通伤害是一种数据
暴击伤害是一种数据
Miss 也是一种数据
无效也是一种数据
特殊飘字通常直接塞预设文本:
1 2 3 4 5 6 7 8 9 10 11 12 public static DamageFloatData Miss(Vector3 worldPos, FontType fontType, Vector3 offset = default) { return new DamageFloatData { Damage = 0, FontType = fontType, TextType = FloatTextType.Miss, DisplayText = "s", WorldPosition = worldPos, Offset = offset }; }
这里的 "s" 不是普通字母,而更像是一个 字库控制码 。
3. 对象池怎么接入 这套做法不是手动拖一个 prefab 进池,而是代码动态创建飘字对象。
核心结构大概是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 private DamageFloatTextItem CreateFloatTextItem() { GameObject go = new GameObject("FloatText"); go.SetActive(false); DamageFloatTextItem item = go.AddComponent<DamageFloatTextItem>(); RectTransform rect = go.AddComponent<RectTransform>(); CanvasGroup canvasGroup = go.AddComponent<CanvasGroup>(); Text text = new GameObject("Text").AddComponent<Text>(); text.transform.SetParent(rect, false); text.alignment = TextAnchor.MiddleCenter; text.raycastTarget = false; return item; }
然后交给对象池统一管理:
1 2 3 4 5 6 7 8 9 _objectPool = new ObjectPool<DamageFloatTextItem>( createFunc: CreateFloatTextItem, actionOnGet: OnGetFromPool, actionOnRelease: OnReleaseToPool, actionOnDestroy: OnDestroyPoolItem, collectionCheck: false, defaultCapacity: 100, maxSize: 500 );
回收时最关键的是把状态清干净:
1 2 3 4 5 6 private void OnReleaseToPool(DamageFloatTextItem item) { item.CancelAnimation(); item.gameObject.SetActive(false); item.transform.SetParent(_poolContainer, false); }
这里值得学的是:
不只是隐藏对象
还要取消动画
还要恢复默认状态
避免池对象下次取出时带脏数据
4. 为什么要按目标排队 这是它比普通飘字池更成熟的地方。
如果同一个怪短时间吃到很多伤害,所有飘字同时出来会严重重叠,所以它会给每个单位维护独立队列。
结构类似:
1 2 3 4 5 private class UnitFloatQueue { public Queue<QueuedFloatText> Queue = new Queue<QueuedFloatText>(); public bool IsProcessing = false; }
入队时:
1 2 3 4 5 6 7 8 9 10 private void EnqueueFloatText(DamageFloatData data, Unit target) { UnitFloatQueue unitQueue = GetOrCreateUnitQueue(target.eid); unitQueue.Queue.Enqueue(new QueuedFloatText(data, target, worldPos)); if (!unitQueue.IsProcessing) { _coroutineHost.StartCoroutine(ProcessUnitQueueCoroutine(target.eid, unitQueue)); } }
这样做的好处:
同一个目标的飘字更有顺序
不同目标互不影响
可以限制单目标过量掉字
高频战斗里更稳定
5. 数字是怎么处理的 它的重点不是复杂算法,而是 统一数值格式化 。
思路类似:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 private static readonly (float threshold, string suffix)[] SuffixTable = { (1_000_000_000_000, "T"), (1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K"), (1, "") }; public static string GetFormatDamageTxt(float damage) { if (damage <= 0) return "0"; foreach (var (threshold, suffix) in SuffixTable) { if (damage >= threshold) { float value = damage / threshold; return FormatWithSuffix(value, suffix); } } return (Math.Truncate(damage * 100) / 100).ToString("0.##"); }
这意味着:
999 -> 999
1200 -> 1.2K
1500000 -> 1.5M
这非常值得学,因为真实项目里最好不要到处直接 ToString()。
6. 类型前缀是什么 这套系统不是只传数字,而是先拼一串“带标记的字符串”。
逻辑大概像这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 switch (data.FontType) { case FontType.Fire: case FontType.Ice: case FontType.Lightning: _textComponent.text = "e" + (_isCrit ? "!" : "") + data.GetDisplayText(); break; case FontType.Heal: case FontType.Mana: _textComponent.text = "+" + (_isCrit ? "!" : "") + data.GetDisplayText(); break; default: _textComponent.text = (_isCrit ? "!" : "") + data.GetDisplayText(); break; } if (data.IsHero && data.FontType != FontType.Heal && data.FontType != FontType.Mana) { _textComponent.text = "-" + _textComponent.text; }
可以把这些前缀理解成:
e:元素伤害标记
!:暴击标记
+:治疗/回蓝
-:掉血
s:MISS
i:无效
所以最终字符串可能会是:
1234
!1234
e1234
e!1234
+500
-1234
s
i
关键点在于:这些字符不一定按字面显示,而是交给字库去解释。
7. 位图字体 / 自定义字库是什么 这部分是整套系统最关键的表现基础。
它不是系统字体直接画文本,而是:
美术把数字和特殊符号画到一张图上
字体配置把字符映射到这张图里的某一块
代码仍然像传普通字符串一样传给 Text
最终显示出来的是美术字,而不是默认字形
你可以把它理解成:
0-9 是画好的图片字
+ - K M 是画好的符号
MISS 可能是一整块图
无效 可能也是一整块图
元素图标也可能被映射成字符
典型资源结构会是这样:
1 2 3 fire.fontsettings firemat.mat firetex.png
它的优势是:
视觉风格统一
美术控制力强
不需要自己拼很多 Image
很适合高频飘字
8. 单条飘字动画怎么组织 单条飘字不会把所有逻辑塞进管理器,而是由飘字对象自己播放动画。
入口大概是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public void Play(DamageFloatData data, Font font, Vector3 startScreenPos, Vector3 targetScreenPos, int eid, Action<DamageFloatTextItem, int> onComplete = null) { Initialize(); _onCompleteCallback = onComplete; _startScreenPos = startScreenPos; _targetScreenPos = targetScreenPos; _isCrit = data.TextType == DamageFloatData.FloatTextType.Crit; _textComponent.font = font; _textComponent.text = BuildDisplayText(data); _animRect.position = startScreenPos; _animRect.localScale = Vector3.zero; _canvasGroup.alpha = 0f; _animationCTS = new CancellationTokenSource(); RunAnimationAsync(_animationCTS.Token).Forget(); }
异步动画通常拆成几个阶段:
1 2 3 4 5 6 7 8 9 private async UniTaskVoid RunAnimationAsync(CancellationToken token) { // 1. 飞出 // 2. 放大弹出 // 3. 停留 // 4. 缩小淡出 _onCompleteCallback?.Invoke(this, eid); }
这套写法的好处:
动画逻辑只在单体对象里
管理器不用关心细节
动画可以取消
播放结束自动回收
9. 如果自己做,最小实现顺序 如果你想自己照着实现,最适合的顺序是:
先做一个最简单的 UGUI 飘字对象
给它写一个 Play
做飞出和淡出动画
接入对象池
再加统一管理器
再加按目标排队
最后再换成自定义字库
最小骨架可以理解成:
1 2 3 4 5 6 7 ShowDamage(...) -> 构造 DamageFloatData -> 入队 -> 从池取对象 -> 设置文本和字体 -> 播放动画 -> 回收
10. 总结 这套系统最值得学的,不是“怎么让数字飘起来”,而是下面这些工程化思路:
用数据抽象表现请求
用对象池管理高频 UI
用队列处理重叠问题
用统一格式化控制数字显示
用位图字体实现美术化文本
用单体对象封装动画生命周期
一句话概括就是:
它把“战斗数值”先转换成“可显示的数据”,再编码成“字库可识别的字符串”,最后通过 UGUI、对象池和动画系统稳定地播放出来。