Files
5.24BuYu/Assets/MyGame/Framework/Editor/FntSpriteSplitter.cs
T
2026-10-20 18:30:03 +08:00

1190 lines
45 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using UnityEngine;
using UnityEditor;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Collections.Generic;
using TMPro;
using UnityEngine.TextCore;
/// <summary>
/// FntSpriteSplitter Editor 窗口
///
/// 功能:将字体定义文件中的字符分割为对应 PNG 的多个 Sprite,
/// 写入 PNG.meta 的 spriteSheet.sprites 中。
///
/// 支持三种输入格式:
/// 1. .fnt - BMFont 文本格式(标准)
/// 2. .bytes - 内容为 BMFont .fnt 文本格式(Unity 用 .bytes 后缀作为 TextAsset 加载)
/// 3. .asset - Unity 原生 Font 资源(!u!128,m_CharacterRects 列表 + 归一化 UV)
///
/// 坐标系说明:
/// - BMFont 像素坐标原点在左上(y 向下递增),Unity Sprite rect 原点在左下(y 向上递增),
/// BMFont 格式需要翻转 y:y_unity = textureHeight - y_fnt - height
/// - Unity Font 的 UV 是归一化坐标(原点左下,y 向上),与 Sprite rect 一致,无需翻转
///
/// 使用方法:
/// 1. 把字体文件拖到「字体文件」槽位(.fnt / .bytes / .asset 均可)
/// 2. 自动识别格式并解析字符;BMFont 格式还会自动从 page file 引用对应 PNG
/// 3. 在「目标 PNG」槽位确认或手动指定 PNG
/// 4. 点击「写入 Meta」生成分割 Sprite
///
/// 作者:AI Assistant
/// </summary>
public class FntSpriteSplitter : EditorWindow
{
#region 字段
/// <summary>选中的字体文件路径(Assets/... 形式)</summary>
private string fontAssetPath = "";
/// <summary>当前文件格式</summary>
private FontFormat fontFormat = FontFormat.Unknown;
/// <summary>目标 PNG 纹理</summary>
private Texture2D targetTexture;
/// <summary>.fnt 中声明的贴图尺寸(scaleW / scaleH);.asset 格式为 0(用 PNG 实际尺寸)</summary>
private int fontTextureW = 0;
private int fontTextureH = 0;
/// <summary>解析出的字符列表</summary>
private List<FontCharInfo> charList = new List<FontCharInfo>();
/// <summary>字符列表滚动位置</summary>
private Vector2 scrollPos;
/// <summary>处理结果日志</summary>
private string logText = "";
private Vector2 logScrollPos;
private bool showLog = false;
/// <summary>命名方式:true=按索引,false=按字符含义</summary>
private bool nameByIndex = true;
/// <summary>写入前是否清空原有 sprites</summary>
private bool clearBeforeWrite = true;
/// <summary>命名前缀(默认使用 PNG 文件名,可手动修改)</summary>
private string namePrefix = "";
#endregion
#region 数据结构
/// <summary>字体文件格式</summary>
private enum FontFormat
{
Unknown,
BmFontFnt, // .fnt 或 .bytes,BMFont 文本格式
UnityFontAsset // .asset,Unity 原生 Font(!u!128)
}
/// <summary>单个字符的解析信息(统一格式)</summary>
private class FontCharInfo
{
public int id; // 字符 unicode id
public int x; // 在 PNG 中的像素 x(左下原点)
public int y; // 在 PNG 中的像素 y(左下原点,已翻转)
public int width; // 像素宽
public int height; // 像素高
public int advance; // 字符前进宽度(仅供信息显示)
/// <summary>Unity 坐标系下的 rect(已翻转 y,可直接写入 meta)</summary>
public Rect unityRect;
}
#endregion
#region 菜单入口
[MenuItem("Tools/FNT Sprite Splitter")]
private static void ShowWindow()
{
var window = GetWindow<FntSpriteSplitter>("FNT Sprite Splitter");
window.minSize = new Vector2(440, 560);
window.Show();
}
#endregion
#region GUI 绘制
private void OnGUI()
{
DrawHeader();
DrawFontSlot();
DrawPngSlot();
DrawCharList();
DrawOptions();
DrawActionBar();
DrawLogArea();
}
/// <summary>顶部标题说明</summary>
private void DrawHeader()
{
EditorGUILayout.Space(8);
EditorGUILayout.HelpBox(
"将字体定义文件中的字符分割为 PNG 的多个 Sprite。\n" +
"支持三种格式:\n" +
" .fnt - BMFont 文本格式\n" +
" .bytes - 内容为 BMFont .fnt 文本格式\n" +
" .asset - Unity 原生 Font 资源(!u!128)\n" +
"1. 拖入字体文件(BMFont 格式会自动识别对应 PNG)\n" +
"2. 点击「写入 Meta」生成分割 Sprite\n" +
"3. 点击「生成 TMP_SpriteAsset」生成可直接用于 <sprite=索引> 的数字精灵资源",
MessageType.Info
);
EditorGUILayout.Space(4);
}
/// <summary>字体文件拖拽槽位</summary>
private void DrawFontSlot()
{
EditorGUILayout.LabelField("字体文件", EditorStyles.boldLabel);
Rect dropRect = EditorGUILayout.GetControlRect(GUILayout.Height(56));
GUI.Box(dropRect, "", GUI.skin.box);
var centeredStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
fontSize = 12
};
if (!string.IsNullOrEmpty(fontAssetPath))
{
string fileName = Path.GetFileName(fontAssetPath);
string formatName = FormatToDisplay(fontFormat);
string texInfo = (fontTextureW > 0 && fontTextureH > 0)
? $" 贴图 {fontTextureW}x{fontTextureH} 字符 {charList.Count} 个"
: $" 字符 {charList.Count} 个";
GUI.Label(dropRect, $"{fileName}\n[{formatName}]{texInfo}", centeredStyle);
}
else
{
GUI.Label(dropRect, "拖入 .fnt / .bytes / .asset 文件\n或 点击选择", centeredStyle);
}
// 拖拽事件
if (Event.current.type == EventType.DragUpdated && dropRect.Contains(Event.current.mousePosition))
{
bool valid = false;
foreach (var p in DragAndDrop.paths)
{
if (IsSupportedFontFile(p))
{
valid = true;
break;
}
}
DragAndDrop.visualMode = valid ? DragAndDropVisualMode.Copy : DragAndDropVisualMode.Rejected;
Event.current.Use();
}
else if (Event.current.type == EventType.DragPerform && dropRect.Contains(Event.current.mousePosition))
{
DragAndDrop.AcceptDrag();
foreach (var p in DragAndDrop.paths)
{
if (IsSupportedFontFile(p))
{
LoadFontFile(p);
break;
}
}
Event.current.Use();
}
// 点击选择按钮
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("选择字体文件", GUILayout.Height(24)))
{
string startDir = string.IsNullOrEmpty(fontAssetPath)
? Application.dataPath
: Path.GetDirectoryName(Path.GetFullPath(fontAssetPath));
string picked = EditorUtility.OpenFilePanelWithFilters(
"选择字体文件", startDir,
new[] { "Font files", "fnt,bytes,asset", "All files", "*" });
if (!string.IsNullOrEmpty(picked))
{
string assetPath = "Assets" + picked.Substring(Application.dataPath.Length).Replace('\\', '/');
LoadFontFile(assetPath);
}
}
if (GUILayout.Button("清空", GUILayout.Height(24), GUILayout.Width(60)))
{
ClearAll();
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(6);
}
/// <summary>目标 PNG 显示槽位(可手动覆盖)</summary>
private void DrawPngSlot()
{
EditorGUILayout.LabelField("目标 PNG", EditorStyles.boldLabel);
Rect dropRect = EditorGUILayout.GetControlRect(GUILayout.Height(48));
GUI.Box(dropRect, "", GUI.skin.box);
var centeredStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
fontSize = 12
};
if (targetTexture != null)
{
string pngPath = AssetDatabase.GetAssetPath(targetTexture);
int texW = targetTexture.width;
int texH = targetTexture.height;
// 检查 PNG 实际尺寸与字体声明是否一致
string warning = "";
if (fontTextureW > 0 && (texW != fontTextureW || texH != fontTextureH))
{
warning = $"\n<color=red>⚠ 尺寸不匹配!字体声明 {fontTextureW}x{fontTextureH}</color>";
}
else if (fontFormat == FontFormat.UnityFontAsset)
{
warning = "\n<color=yellow>Unity Font 模式:UV 已按 PNG 实际尺寸换算</color>";
}
var richStyle = new GUIStyle(centeredStyle) { richText = true };
GUI.Label(dropRect, $"{Path.GetFileName(pngPath)}\n{texW} x {texH} px{warning}", richStyle);
}
else
{
GUI.Label(dropRect, "拖入 PNG 或 点击选择(BMFont 格式可自动识别)", centeredStyle);
}
// 拖拽事件
if (Event.current.type == EventType.DragUpdated && dropRect.Contains(Event.current.mousePosition))
{
bool valid = false;
foreach (var p in DragAndDrop.paths)
{
if (p.EndsWith(".png", System.StringComparison.OrdinalIgnoreCase))
{
valid = true;
break;
}
}
DragAndDrop.visualMode = valid ? DragAndDropVisualMode.Copy : DragAndDropVisualMode.Rejected;
Event.current.Use();
}
else if (Event.current.type == EventType.DragPerform && dropRect.Contains(Event.current.mousePosition))
{
DragAndDrop.AcceptDrag();
foreach (var p in DragAndDrop.paths)
{
if (p.EndsWith(".png", System.StringComparison.OrdinalIgnoreCase))
{
var tex = AssetDatabase.LoadAssetAtPath<Texture2D>(p);
if (tex != null)
{
targetTexture = tex;
namePrefix = Path.GetFileNameWithoutExtension(p);
RecomputeRectsForPngSize();
}
break;
}
}
Event.current.Use();
}
// 点击选择
targetTexture = (Texture2D)EditorGUI.ObjectField(
new Rect(dropRect.x, dropRect.y, dropRect.width, dropRect.height),
targetTexture, typeof(Texture2D), false);
if (targetTexture != null)
{
string p = AssetDatabase.GetAssetPath(targetTexture);
if (!p.EndsWith(".png", System.StringComparison.OrdinalIgnoreCase))
{
targetTexture = null;
}
else if (string.IsNullOrEmpty(namePrefix))
{
namePrefix = Path.GetFileNameWithoutExtension(p);
}
}
EditorGUILayout.Space(6);
}
/// <summary>字符列表</summary>
private void DrawCharList()
{
EditorGUILayout.LabelField($"字符列表 ({charList.Count} 个)", EditorStyles.boldLabel);
Rect listRect = EditorGUILayout.GetControlRect(false, GUILayout.Height(180));
GUI.Box(listRect, "");
if (charList.Count == 0)
{
var hintStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
fontSize = 12,
normal = { textColor = Color.gray }
};
GUI.Label(listRect, "拖入字体文件后会显示字符列表", hintStyle);
}
else
{
var innerRect = new Rect(listRect.x + 4, listRect.y + 4, listRect.width - 8, listRect.height - 8);
float headerH = 20;
float rowH = 20;
float contentH = headerH + charList.Count * rowH;
scrollPos = GUI.BeginScrollView(innerRect, scrollPos, new Rect(0, 0, innerRect.width - 16, contentH));
var headerStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 10 };
float col1 = innerRect.width * 0.10f; // index
float col2 = innerRect.width * 0.15f; // char
float col3 = innerRect.width * 0.35f; // rect (unity)
float col4 = innerRect.width * 0.30f; // size
GUI.Label(new Rect(2, 0, col1, headerH), "#", headerStyle);
GUI.Label(new Rect(col1, 0, col2, headerH), "char", headerStyle);
GUI.Label(new Rect(col1 + col2, 0, col3, headerH), "unity(x,y,w,h)", headerStyle);
GUI.Label(new Rect(col1 + col2 + col3, 0, col4, headerH), "size", headerStyle);
for (int i = 0; i < charList.Count; i++)
{
var ch = charList[i];
float y = headerH + i * rowH;
// 交替背景色
if (i % 2 == 0)
{
var origColor = GUI.color;
GUI.color = new Color(0.85f, 0.85f, 0.85f, 0.3f);
GUI.DrawTexture(new Rect(0, y, innerRect.width, rowH), EditorGUIUtility.whiteTexture);
GUI.color = origColor;
}
var rowStyle = new GUIStyle(GUI.skin.label) { fontSize = 10 };
GUI.Label(new Rect(2, y, col1, rowH), i.ToString(), rowStyle);
GUI.Label(new Rect(col1, y, col2, rowH), CharToLabel(ch.id), rowStyle);
GUI.Label(new Rect(col1 + col2, y, col3, rowH),
$"({(int)ch.unityRect.x},{(int)ch.unityRect.y},{(int)ch.unityRect.width}x{(int)ch.unityRect.height})",
rowStyle);
GUI.Label(new Rect(col1 + col2 + col3, y, col4, rowH),
$"{ch.width}x{ch.height}", rowStyle);
}
GUI.EndScrollView();
}
EditorGUILayout.Space(6);
}
/// <summary>选项区域</summary>
private void DrawOptions()
{
EditorGUILayout.LabelField("选项", EditorStyles.boldLabel);
nameByIndex = EditorGUILayout.Toggle(
new GUIContent("按索引命名",
"勾选:PNG名_0, PNG名_1, ...\n不勾选:按字符含义命名(0,1,...,plus,comma,dot,a,b,...)"),
nameByIndex);
clearBeforeWrite = EditorGUILayout.Toggle(
new GUIContent("写入前清空原有 sprites",
"勾选:先清空 meta 中已有的 sprites 列表再写入\n不勾选:追加到现有 sprites 末尾"),
clearBeforeWrite);
namePrefix = EditorGUILayout.TextField(
new GUIContent("命名前缀",
"Sprite 名称前缀。按索引命名时为「前缀_索引」,按字符命名时为「前缀_字符」。留空则使用 PNG 文件名。"),
namePrefix);
EditorGUILayout.Space(4);
}
/// <summary>操作按钮区</summary>
private void DrawActionBar()
{
bool canWrite = !string.IsNullOrEmpty(fontAssetPath) && targetTexture != null && charList.Count > 0;
GUI.enabled = canWrite;
if (GUILayout.Button("写入 Meta", GUILayout.Height(32)))
{
WriteSpritesToMeta();
}
GUI.enabled = true;
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("清空 Meta 中所有 sprites", GUILayout.Height(24)))
{
if (targetTexture != null &&
EditorUtility.DisplayDialog("确认清空",
$"将清空 {targetTexture.name}.png.meta 中所有 sprite 条目,确认继续?",
"确认", "取消"))
{
ClearSpritesInMeta();
}
}
if (GUILayout.Button("全部重置", GUILayout.Height(24)))
{
ClearAll();
}
EditorGUILayout.EndHorizontal();
// 生成 TMP_SpriteAsset
EditorGUILayout.Space(4);
GUI.enabled = canWrite;
if (GUILayout.Button("生成 TMP_SpriteAsset", GUILayout.Height(28)))
{
GenerateTMPSpriteAsset();
}
GUI.enabled = true;
EditorGUILayout.Space(6);
}
/// <summary>日志区域</summary>
private void DrawLogArea()
{
if (string.IsNullOrEmpty(logText)) return;
showLog = EditorGUILayout.Foldout(showLog, "处理日志");
if (showLog)
{
EditorGUILayout.BeginVertical(GUI.skin.box);
logScrollPos = EditorGUILayout.BeginScrollView(logScrollPos, GUILayout.Height(140));
EditorGUILayout.TextArea(logText, EditorStyles.wordWrappedLabel, GUILayout.ExpandHeight(true));
EditorGUILayout.EndScrollView();
EditorGUILayout.EndVertical();
}
}
#endregion
#region 字体文件加载与解析
/// <summary>判断文件是否为支持的字格式</summary>
private bool IsSupportedFontFile(string path)
{
if (string.IsNullOrEmpty(path)) return false;
string ext = Path.GetExtension(path).ToLowerInvariant();
if (ext == ".fnt" || ext == ".bytes") return true;
if (ext == ".asset")
{
// .asset 需要检查内容是否为 Unity Font (!u!128)
try
{
string text = File.ReadAllText(Path.GetFullPath(path), Encoding.UTF8);
return text.Contains("!u!128") && text.Contains("m_CharacterRects");
}
catch { return false; }
}
return false;
}
/// <summary>格式枚举转显示文本</summary>
private string FormatToDisplay(FontFormat fmt)
{
switch (fmt)
{
case FontFormat.BmFontFnt: return "BMFont 文本";
case FontFormat.UnityFontAsset: return "Unity Font (.asset)";
default: return "未知格式";
}
}
/// <summary>加载并解析字体文件</summary>
private void LoadFontFile(string assetPath)
{
if (string.IsNullOrEmpty(assetPath) || !File.Exists(Path.GetFullPath(assetPath)))
{
EditorUtility.DisplayDialog("错误", $"找不到文件:\n{assetPath}", "确定");
return;
}
fontAssetPath = assetPath;
charList.Clear();
logText = "";
string ext = Path.GetExtension(assetPath).ToLowerInvariant();
string text = File.ReadAllText(Path.GetFullPath(assetPath), Encoding.UTF8);
// 识别格式
if (ext == ".asset" && text.Contains("!u!128"))
{
fontFormat = FontFormat.UnityFontAsset;
ParseUnityFontAsset(text);
}
else if (ext == ".fnt" || ext == ".bytes" || text.Contains("common") && text.Contains("scaleW"))
{
fontFormat = FontFormat.BmFontFnt;
ParseBmFontText(text);
}
else
{
EditorUtility.DisplayDialog("格式不支持",
$"无法识别文件格式:{assetPath}\n支持 .fnt / .bytes (BMFont文本) / .asset (Unity Font)", "确定");
fontFormat = FontFormat.Unknown;
return;
}
// 自动查找对应 PNG(仅 BMFont 格式有 page file 引用,Unity Font 的纹理引用通常为空)
if (fontFormat == FontFormat.BmFontFnt)
{
AutoFindPngFromBmFontPage(text, assetPath);
}
RecomputeRectsForPngSize();
Repaint();
}
/// <summary>解析 BMFont 文本格式(.fnt 或 .bytes)</summary>
private void ParseBmFontText(string text)
{
// 解析 common 行:scaleW 和 scaleH
var commonMatch = Regex.Match(text, @"^common\s+.*?scaleW=(\d+)\s+scaleH=(\d+)",
RegexOptions.Multiline);
if (!commonMatch.Success)
{
EditorUtility.DisplayDialog("解析失败", "找不到 common 行或 scaleW/scaleH 字段", "确定");
fontTextureW = 0;
fontTextureH = 0;
return;
}
fontTextureW = int.Parse(commonMatch.Groups[1].Value);
fontTextureH = int.Parse(commonMatch.Groups[2].Value);
// 解析所有 char 行
charList.Clear();
foreach (Match m in Regex.Matches(text,
@"^char\s+id=(-?\d+)\s+x=(-?\d+)\s+y=(-?\d+)\s+width=(\d+)\s+height=(\d+)" +
@"\s+xoffset=(-?\d+)\s+yoffset=(-?\d+)\s+xadvance=(-?\d+)",
RegexOptions.Multiline))
{
var info = new FontCharInfo
{
id = int.Parse(m.Groups[1].Value),
x = int.Parse(m.Groups[2].Value),
y = int.Parse(m.Groups[3].Value),
width = int.Parse(m.Groups[4].Value),
height = int.Parse(m.Groups[5].Value),
advance = int.Parse(m.Groups[8].Value),
};
// y 翻转:BMFont 左上原点 → Unity 左下原点
int flipY = fontTextureH - info.y - info.height;
info.unityRect = new Rect(info.x, flipY, info.width, info.height);
charList.Add(info);
}
}
/// <summary>解析 Unity 原生 Font .asset(!u!128)</summary>
private void ParseUnityFontAsset(string text)
{
// Unity Font 没有 scaleW/scaleH,UV 是归一化的,需要靠 PNG 实际尺寸换算
fontTextureW = 0;
fontTextureH = 0;
// 每个 m_CharacterRects 列表项格式:
// - serializedVersion: 2
// index: 43
// uv:
// serializedVersion: 2
// x: 0.578125
// y: 0.28125
// width: 0.15625
// height: 0.15625
// vert:
// serializedVersion: 2
// x: 0
// y: 5
// width: 10
// height: -10
// advance: 10
// flipped: 0
charList.Clear();
// 匹配每个字符块(从 index: 到下一个 index: 或 m_KerningValues 之前)
var charBlockPattern = new Regex(
@"index:\s*(-?\d+)\s*\n\s*uv:\s*\n\s*serializedVersion:\s*\d+\s*\n" +
@"\s*x:\s*([-0-9.]+)\s*\n\s*y:\s*([-0-9.]+)\s*\n" +
@"\s*width:\s*([-0-9.]+)\s*\n\s*height:\s*([-0-9.]+)\s*\n" +
@"\s*vert:\s*\n\s*serializedVersion:\s*\d+\s*\n" +
@"\s*x:\s*([-0-9.]+)\s*\n\s*y:\s*([-0-9.]+)\s*\n" +
@"\s*width:\s*([-0-9.]+)\s*\n\s*height:\s*([-0-9.]+)\s*\n" +
@"\s*advance:\s*(-?[-0-9.]+)",
RegexOptions.Multiline);
foreach (Match m in charBlockPattern.Matches(text))
{
int charId = int.Parse(m.Groups[1].Value);
float uvX = float.Parse(m.Groups[2].Value, System.Globalization.CultureInfo.InvariantCulture);
float uvY = float.Parse(m.Groups[3].Value, System.Globalization.CultureInfo.InvariantCulture);
float uvW = float.Parse(m.Groups[4].Value, System.Globalization.CultureInfo.InvariantCulture);
float uvH = float.Parse(m.Groups[5].Value, System.Globalization.CultureInfo.InvariantCulture);
// vert 字段在 UV 原点已是左下时不需要用于 rect 计算,width/height 可作为像素尺寸参考
// 但 Unity Font 的 vert.width/height 是字符绘制尺寸,应与 uv*textureSize 一致
// 这里使用 vert 的 width/height 作为像素宽高(更可靠)
float vertW = float.Parse(m.Groups[8].Value, System.Globalization.CultureInfo.InvariantCulture);
float vertH = float.Parse(m.Groups[9].Value, System.Globalization.CultureInfo.InvariantCulture);
// UV 是归一化坐标,原点左下,y 向上 → 与 Unity Sprite rect 一致,不需要翻转 y
// 实际像素坐标需在拿到 PNG 实际尺寸后才能计算,这里先存 UV,等 RecomputeRectsForPngSize 转换
// 为简化处理,这里直接用 PNG 实际尺寸换算(若 targetTexture 已设置)
int pixX, pixY, pixW, pixH;
if (targetTexture != null)
{
int texW = targetTexture.width;
int texH = targetTexture.height;
pixX = Mathf.RoundToInt(uvX * texW);
pixY = Mathf.RoundToInt(uvY * texH);
pixW = Mathf.RoundToInt(uvW * texW);
pixH = Mathf.RoundToInt(uvH * texH);
}
else
{
// PNG 未设置时暂存 0,等 PNG 加载后重算
pixX = 0; pixY = 0; pixW = Mathf.RoundToInt(vertW); pixH = Mathf.RoundToInt(Mathf.Abs(vertH));
}
var info = new FontCharInfo
{
id = charId,
x = pixX,
y = pixY,
width = pixW,
height = pixH,
advance = int.Parse(m.Groups[10].Value),
};
info.unityRect = new Rect(info.x, info.y, info.width, info.height);
charList.Add(info);
}
}
/// <summary>当 PNG 实际尺寸变化时,重新计算字符 rect(主要用于 Unity Font 模式)</summary>
private void RecomputeRectsForPngSize()
{
if (fontFormat != FontFormat.UnityFontAsset || targetTexture == null) return;
// 重新解析(因为 Unity Font 的 UV 需要乘以 PNG 尺寸)
if (!string.IsNullOrEmpty(fontAssetPath) && File.Exists(Path.GetFullPath(fontAssetPath)))
{
string text = File.ReadAllText(Path.GetFullPath(fontAssetPath), Encoding.UTF8);
ParseUnityFontAsset(text);
}
}
/// <summary>从 BMFont page file 自动查找 PNG</summary>
private void AutoFindPngFromBmFontPage(string text, string fntAssetPath)
{
var pageMatch = Regex.Match(text, @"^page\s+id=\d+\s+file=""([^""]+)""",
RegexOptions.Multiline);
if (!pageMatch.Success) return;
string pageFileName = pageMatch.Groups[1].Value;
// 优先从 .fnt 同目录查找
string fntDir = Path.GetDirectoryName(fntAssetPath).Replace('\\', '/');
string candidatePath = fntDir + "/" + pageFileName;
var tex = AssetDatabase.LoadAssetAtPath<Texture2D>(candidatePath);
if (tex == null)
{
// 在整个项目中按文件名搜索
string nameWithoutExt = Path.GetFileNameWithoutExtension(pageFileName);
string[] guids = AssetDatabase.FindAssets(nameWithoutExt + " t:Texture2D");
foreach (string g in guids)
{
string p = AssetDatabase.GUIDToAssetPath(g);
if (p.EndsWith("/" + pageFileName, System.StringComparison.OrdinalIgnoreCase))
{
tex = AssetDatabase.LoadAssetAtPath<Texture2D>(p);
if (tex != null) break;
}
}
}
if (tex != null)
{
targetTexture = tex;
namePrefix = Path.GetFileNameWithoutExtension(pageFileName);
}
}
/// <summary>把字符 id 映射成可读的名称标签</summary>
private string CharToLabel(int charId)
{
switch (charId)
{
case 43: return "+";
case 44: return ",";
case 45: return "-";
case 46: return ".";
case 47: return "/";
case 58: return ":";
case 33: return "!";
case 63: return "?";
case 40: return "(";
case 41: return ")";
case 91: return "[";
case 93: return "]";
case 123: return "{";
case 125: return "}";
case 120: return "x";
}
if (charId >= 48 && charId <= 57) return (charId - 48).ToString();
if (charId >= 65 && charId <= 90) return ((char)charId).ToString();
if (charId >= 97 && charId <= 122) return ((char)charId).ToString();
return $"u{charId}";
}
/// <summary>把字符 id 映射成可作为 sprite 名称的英文标识符</summary>
private string CharToName(int charId)
{
switch (charId)
{
case 43: return "plus";
case 44: return "comma";
case 45: return "minus";
case 46: return "dot";
case 47: return "slash";
case 58: return "colon";
case 33: return "excl";
case 63: return "quest";
case 40: return "lparen";
case 41: return "rparen";
case 91: return "lbracket";
case 93: return "rbracket";
case 123: return "lbrace";
case 125: return "rbrace";
case 120: return "x";
}
if (charId >= 48 && charId <= 57) return (charId - 48).ToString();
if (charId >= 65 && charId <= 90) return ((char)charId).ToString();
if (charId >= 97 && charId <= 122) return ((char)charId).ToString();
return $"u{charId}";
}
#endregion
#region Meta 写入
/// <summary>主入口:把字符列表写入 PNG.meta</summary>
private void WriteSpritesToMeta()
{
if (targetTexture == null || charList.Count == 0)
{
EditorUtility.DisplayDialog("提示", "请先加载字体文件并选择目标 PNG", "确定");
return;
}
string pngPath = AssetDatabase.GetAssetPath(targetTexture);
string metaPath = pngPath + ".meta";
if (!File.Exists(metaPath))
{
EditorUtility.DisplayDialog("错误", $"找不到 meta 文件:\n{metaPath}", "确定");
return;
}
// 对 BMFont 格式,使用 PNG 实际尺寸做 Y 翻转(如果与 fnt 声明不同,按实际尺寸为准)
if (fontFormat == FontFormat.BmFontFnt)
{
int texW = targetTexture.width;
int texH = targetTexture.height;
if (texW != fontTextureW || texH != fontTextureH)
{
Debug.LogWarning($"[FntSpriteSplitter] PNG 实际尺寸 {texW}x{texH} 与 fnt 声明 {fontTextureW}x{fontTextureH} 不一致,按实际尺寸翻转 y");
foreach (var ch in charList)
{
// 重新基于实际 texH 翻转:先还原 fnt 中的 y,再翻转
// fnt_y = fontTextureH - unity_y - height → 重新 unity_y = texH - fnt_y - height
int fntY = fontTextureH - (int)ch.unityRect.y - ch.height;
int flipY = texH - fntY - ch.height;
ch.unityRect = new Rect(ch.x, flipY, ch.width, ch.height);
}
}
}
else if (fontFormat == FontFormat.UnityFontAsset)
{
// Unity Font 模式:UV 已根据 PNG 实际尺寸换算,无需额外处理
RecomputeRectsForPngSize();
}
string prefix = string.IsNullOrEmpty(namePrefix)
? Path.GetFileNameWithoutExtension(pngPath)
: namePrefix;
StringBuilder sb = new StringBuilder();
StringBuilder spritesYaml = new StringBuilder();
List<string> nameFileIdEntries = new List<string>();
for (int i = 0; i < charList.Count; i++)
{
var ch = charList[i];
string spriteName = nameByIndex
? $"{prefix}_{i}"
: $"{prefix}_{CharToName(ch.id)}";
spritesYaml.AppendLine(" - serializedVersion: 2");
spritesYaml.AppendLine($" name: {spriteName}");
spritesYaml.AppendLine(" rect:");
spritesYaml.AppendLine(" serializedVersion: 2");
spritesYaml.AppendLine($" x: {(int)ch.unityRect.x}");
spritesYaml.AppendLine($" y: {(int)ch.unityRect.y}");
spritesYaml.AppendLine($" width: {(int)ch.unityRect.width}");
spritesYaml.AppendLine($" height: {(int)ch.unityRect.height}");
spritesYaml.AppendLine(" alignment: 0");
spritesYaml.AppendLine(" pivot: {x: 0.5, y: 0.5}");
spritesYaml.AppendLine(" border: {x: 0, y: 0, z: 0, w: 0}");
spritesYaml.AppendLine(" outline: []");
spritesYaml.AppendLine(" physicsShape: []");
spritesYaml.AppendLine(" tessellationDetail: -1");
spritesYaml.AppendLine(" bones: []");
spritesYaml.AppendLine($" spriteID: {GenerateSpriteID(spriteName)}");
spritesYaml.AppendLine(" internalID: 0");
spritesYaml.AppendLine(" vertices: []");
spritesYaml.AppendLine(" indices: ");
spritesYaml.AppendLine(" edges: []");
spritesYaml.AppendLine(" weights: []");
nameFileIdEntries.Add($" {spriteName}: 0");
sb.AppendLine($" [+] {spriteName} rect=({(int)ch.unityRect.x},{(int)ch.unityRect.y},{(int)ch.unityRect.width}x{(int)ch.unityRect.height})");
}
string spritesContent = " sprites:\n" + spritesYaml.ToString().TrimEnd('\n', '\r') + "\n";
string tableContent = " nameFileIdTable:\n" +
string.Join("\n", nameFileIdEntries) + "\n";
// 读取 meta
string metaText = File.ReadAllText(metaPath, Encoding.UTF8);
string result = metaText;
// 替换 sprites 块
if (clearBeforeWrite || metaText.Contains("sprites: []"))
{
if (metaText.Contains(" sprites: []"))
{
result = ReplaceFirst(result, " sprites: []", spritesContent);
}
else
{
int idx = result.IndexOf(" sprites:");
if (idx >= 0)
{
int endIdx = FindBlockEnd(result, idx);
result = result.Substring(0, idx) + spritesContent + result.Substring(endIdx);
}
else
{
// 没有 sprites 字段,找 spriteSheet: 后插入
int sheetIdx = result.IndexOf(" spriteSheet:");
if (sheetIdx >= 0)
{
int lineEnd = result.IndexOf('\n', sheetIdx);
result = result.Substring(0, lineEnd + 1) + spritesContent + result.Substring(lineEnd + 1);
}
}
}
}
else
{
// 追加模式:在现有 sprites 块末尾插入新条目
int idx = result.IndexOf(" sprites:");
if (idx >= 0)
{
int blockEnd = FindBlockEnd(result, idx);
string newEntries = spritesYaml.ToString();
result = result.Substring(0, blockEnd) + newEntries + result.Substring(blockEnd);
}
else
{
result = ReplaceFirst(result, " sprites: []", spritesContent);
}
}
// 替换 nameFileIdTable 块
int tblIdx = result.IndexOf(" nameFileIdTable:");
if (tblIdx >= 0)
{
if (clearBeforeWrite || result.Contains(" nameFileIdTable: {}"))
{
int tblEndIdx = FindBlockEnd(result, tblIdx);
result = result.Substring(0, tblIdx) + tableContent + result.Substring(tblEndIdx);
}
else
{
int tblEndIdx = FindBlockEnd(result, tblIdx);
string extra = string.Join("\n", nameFileIdEntries) + "\n";
result = result.Substring(0, tblEndIdx) + extra + result.Substring(tblEndIdx);
}
}
File.WriteAllText(metaPath, result, new UTF8Encoding(false));
AssetDatabase.ImportAsset(pngPath, ImportAssetOptions.ForceUpdate);
sb.AppendLine($"\n[完成] 已写入 {charList.Count} 个 Sprite 到:");
sb.AppendLine($" {metaPath}");
sb.AppendLine("\n→ Unity 已自动 Reimport,请在 Inspector / Sprite Editor 中确认。");
logText = sb.ToString();
showLog = true;
Debug.Log("[FntSpriteSplitter]\n" + logText);
}
/// <summary>清空 PNG.meta 中所有 sprites</summary>
private void ClearSpritesInMeta()
{
if (targetTexture == null) return;
string pngPath = AssetDatabase.GetAssetPath(targetTexture);
string metaPath = pngPath + ".meta";
if (!File.Exists(metaPath)) return;
string metaText = File.ReadAllText(metaPath, Encoding.UTF8);
string result = metaText;
int idx = result.IndexOf(" sprites:");
if (idx >= 0)
{
int endIdx = FindBlockEnd(result, idx);
result = result.Substring(0, idx) + " sprites: []\n" + result.Substring(endIdx);
}
int tblIdx = result.IndexOf(" nameFileIdTable:");
if (tblIdx >= 0)
{
int tblEndIdx = FindBlockEnd(result, tblIdx);
result = result.Substring(0, tblIdx) + " nameFileIdTable: {}\n" + result.Substring(tblEndIdx);
}
File.WriteAllText(metaPath, result, new UTF8Encoding(false));
AssetDatabase.ImportAsset(pngPath, ImportAssetOptions.ForceUpdate);
logText = $"[清空] 已清空 {Path.GetFileName(metaPath)} 中所有 sprites";
showLog = true;
}
/// <summary>
/// 基于已解析的字符列表生成 TMP_SpriteAsset。
/// 字符表 m_Unicode 使用字体中的 u8 字符 id,m_GlyphIndex 对应 glyph 表下标,
/// 因此既支持 <sprite=索引> 也支持 <sprite=字符名> 引用。
/// glyph 的 sprite 引用会从 PNG 的子 Sprite 中按「前缀_下标」匹配(需先执行「写入 Meta」分割)。
/// </summary>
private void GenerateTMPSpriteAsset()
{
if (targetTexture == null || charList.Count == 0)
{
EditorUtility.DisplayDialog("提示", "请先加载字体文件并选择目标 PNG", "确定");
return;
}
string pngPath = AssetDatabase.GetAssetPath(targetTexture);
string prefix = string.IsNullOrEmpty(namePrefix)
? Path.GetFileNameWithoutExtension(pngPath)
: namePrefix;
// 从 PNG 加载所有子 Sprite(需先分割过 meta)
var allAssets = AssetDatabase.LoadAllAssetsAtPath(pngPath);
Dictionary<string, Sprite> spriteByName = new Dictionary<string, Sprite>();
foreach (var a in allAssets)
{
if (a is Sprite spr)
spriteByName[spr.name] = spr;
}
// 创建 SpriteAsset
var spriteAsset = ScriptableObject.CreateInstance<TMP_SpriteAsset>();
spriteAsset.name = prefix + "_SpriteAsset";
spriteAsset.spriteSheet = targetTexture;
// 设置版本号,避免 getter/UpdateLookupTables 触发 UpgradeSpriteAsset
// (默认 m_Version 为空,UpgradeSpriteAsset 会访问未初始化的 spriteInfoList 导致 NullReferenceException)
var versionField = typeof(TMP_SpriteAsset).GetField("m_Version",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
if (versionField != null)
versionField.SetValue(spriteAsset, "1.1.0");
// 创建 Sprite 材质(TextMeshPro/Sprite shader)
Shader shader = Shader.Find("TextMeshPro/Sprite");
if (shader == null)
shader = Shader.Find("TextMeshPro/Mobile/Distance Field");
Material mat = new Material(shader);
mat.name = "TextMeshPro/Sprite";
mat.mainTexture = targetTexture;
spriteAsset.material = mat;
var glyphTable = new List<TMP_SpriteGlyph>();
var charTable = new List<TMP_SpriteCharacter>();
for (int i = 0; i < charList.Count; i++)
{
var ch = charList[i];
// glyph:按子 Sprite 命名「前缀_下标」查找引用
string spriteName = prefix + "_" + i;
Sprite spr;
spriteByName.TryGetValue(spriteName, out spr);
var glyph = new TMP_SpriteGlyph();
glyph.index = (uint)i;
glyph.metrics = new GlyphMetrics(
ch.width, ch.height,
-ch.width / 2f, ch.height / 2f,
ch.width);
glyph.glyphRect = new GlyphRect(
(int)ch.unityRect.x, (int)ch.unityRect.y,
(int)ch.unityRect.width, (int)ch.unityRect.height);
glyph.scale = 1f;
glyph.atlasIndex = 0;
glyph.sprite = spr;
glyphTable.Add(glyph);
// character:m_Unicode 使用 u8 字符 id,m_Name 为字符本身
var character = new TMP_SpriteCharacter();
character.unicode = (uint)ch.id;
character.glyphIndex = (uint)i;
character.scale = 1f;
character.name = CharToName(ch.id);
charTable.Add(character);
}
// setter 为 internal,改用公开 getter 返回的列表直接填充
spriteAsset.spriteGlyphTable.Clear();
spriteAsset.spriteGlyphTable.AddRange(glyphTable);
spriteAsset.spriteCharacterTable.Clear();
spriteAsset.spriteCharacterTable.AddRange(charTable);
spriteAsset.spriteInfoList = new List<TMP_Sprite>();
spriteAsset.fallbackSpriteAssets = new List<TMP_SpriteAsset>();
spriteAsset.UpdateLookupTables();
// 保存:材质作为 SpriteAsset 的子资源,结构与现有 asset 一致
string dir = Path.GetDirectoryName(pngPath).Replace('\\', '/');
string assetPath = dir + "/" + spriteAsset.name + ".asset";
assetPath = AssetDatabase.GenerateUniqueAssetPath(assetPath);
AssetDatabase.CreateAsset(spriteAsset, assetPath);
AssetDatabase.AddObjectToAsset(mat, spriteAsset);
AssetDatabase.SaveAssets();
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
int missingSprite = 0;
foreach (var g in glyphTable)
if (g.sprite == null)
missingSprite++;
StringBuilder sb = new StringBuilder();
sb.AppendLine($"[完成] 已生成 TMP_SpriteAsset:");
sb.AppendLine($" {assetPath}");
int[] charCodes = new int[charList.Count];
for (int i = 0; i < charList.Count; i++) charCodes[i] = charList[i].id;
sb.AppendLine($" 字符 id 码: {string.Join(",", charCodes)}");
sb.AppendLine($" 字符表: {charTable.Count} 个, glyph 表: {glyphTable.Count} 个");
if (missingSprite > 0)
sb.AppendLine($" ⚠ {missingSprite} 个 glyph 未匹配到子 Sprite(请先执行「写入 Meta」分割)");
sb.AppendLine("\n→ 使用方法: tmp.spriteAsset = 该资源; text = \"<sprite=0><sprite=1>...\"");
logText = sb.ToString();
showLog = true;
Debug.Log("[FntSpriteSplitter] 生成 TMP_SpriteAsset\n" + logText);
EditorGUIUtility.PingObject(spriteAsset);
}
#endregion
#region 工具方法
/// <summary>重置所有状态</summary>
private void ClearAll()
{
fontAssetPath = "";
fontFormat = FontFormat.Unknown;
targetTexture = null;
charList.Clear();
fontTextureW = 0;
fontTextureH = 0;
namePrefix = "";
logText = "";
Repaint();
}
/// <summary>替换字符串中第一次出现的 oldText</summary>
private string ReplaceFirst(string text, string oldText, string newText)
{
int idx = text.IndexOf(oldText);
if (idx < 0) return text;
return text.Substring(0, idx) + newText + text.Substring(idx + oldText.Length);
}
/// <summary>
/// 从 blockStartIndex 开始,找到当前 YAML 块的结束位置。
/// 块以 " keyName:" 开头,后续行要么以 " - " 继续列表项,
/// 要么以更多缩进(6+ 空格)作为子属性。
/// 遇到同级缩进(4 空格 + 非 "-" 非空格)的行时结束。
/// </summary>
private int FindBlockEnd(string text, int blockStartIndex)
{
int i = blockStartIndex;
int lineEnd = text.IndexOf('\n', i);
if (lineEnd < 0) return text.Length;
i = lineEnd + 1;
while (i < text.Length)
{
int nextNewline = text.IndexOf('\n', i);
if (nextNewline < 0) nextNewline = text.Length;
string line = text.Substring(i, nextNewline - i);
if (line.Trim().Length == 0)
{
i = nextNewline + 1;
continue;
}
if (line.StartsWith(" - "))
{
i = nextNewline + 1;
continue;
}
if (line.StartsWith(" "))
{
i = nextNewline + 1;
continue;
}
if (line.StartsWith(" ") && !line.StartsWith(" - ") && !line.StartsWith(" "))
{
return i;
}
return i;
}
return text.Length;
}
/// <summary>基于 sprite 名称生成稳定的 32 位 hex spriteID</summary>
private string GenerateSpriteID(string name)
{
using (var md5 = System.Security.Cryptography.MD5.Create())
{
byte[] bytes = md5.ComputeHash(Encoding.UTF8.GetBytes("fnt-sprite-" + name));
var sb = new StringBuilder();
foreach (byte b in bytes) sb.Append(b.ToString("x2"));
return sb.ToString();
}
}
#endregion
}