dark_飘字系统分析

伤害飘字系统笔记

这套系统本质上是一条比较成熟的战斗 UI 表现链路:

  1. 先把一条飘字抽象成数据
  2. 管理器负责调度、资源、对象池、分层
  3. 单个飘字对象只负责显示和动画
  4. 播放结束后自动回收

它不是单纯“把数字显示出来”,而是把 数值格式化、类型编码、字库映射、动画播放 串成了一套完整流程。


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. 位图字体 / 自定义字库是什么

这部分是整套系统最关键的表现基础。

它不是系统字体直接画文本,而是:

  1. 美术把数字和特殊符号画到一张图上
  2. 字体配置把字符映射到这张图里的某一块
  3. 代码仍然像传普通字符串一样传给 Text
  4. 最终显示出来的是美术字,而不是默认字形

你可以把它理解成:

  • 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. 如果自己做,最小实现顺序

如果你想自己照着实现,最适合的顺序是:

  1. 先做一个最简单的 UGUI 飘字对象
  2. 给它写一个 Play
  3. 做飞出和淡出动画
  4. 接入对象池
  5. 再加统一管理器
  6. 再加按目标排队
  7. 最后再换成自定义字库

最小骨架可以理解成:

1
2
3
4
5
6
7
ShowDamage(...)
-> 构造 DamageFloatData
-> 入队
-> 从池取对象
-> 设置文本和字体
-> 播放动画
-> 回收

10. 总结

这套系统最值得学的,不是“怎么让数字飘起来”,而是下面这些工程化思路:

  • 用数据抽象表现请求
  • 用对象池管理高频 UI
  • 用队列处理重叠问题
  • 用统一格式化控制数字显示
  • 用位图字体实现美术化文本
  • 用单体对象封装动画生命周期

一句话概括就是:

它把“战斗数值”先转换成“可显示的数据”,再编码成“字库可识别的字符串”,最后通过 UGUI、对象池和动画系统稳定地播放出来。