using UnityEngine;
using UnityEditor;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Collections.Generic;
using TMPro;
using UnityEngine.TextCore;
///
/// 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
///
public class FntSpriteSplitter : EditorWindow
{
#region 字段
/// 选中的字体文件路径(Assets/... 形式)
private string fontAssetPath = "";
/// 当前文件格式
private FontFormat fontFormat = FontFormat.Unknown;
/// 目标 PNG 纹理
private Texture2D targetTexture;
/// .fnt 中声明的贴图尺寸(scaleW / scaleH);.asset 格式为 0(用 PNG 实际尺寸)
private int fontTextureW = 0;
private int fontTextureH = 0;
/// 解析出的字符列表
private List charList = new List();
/// 字符列表滚动位置
private Vector2 scrollPos;
/// 处理结果日志
private string logText = "";
private Vector2 logScrollPos;
private bool showLog = false;
/// 命名方式:true=按索引,false=按字符含义
private bool nameByIndex = true;
/// 写入前是否清空原有 sprites
private bool clearBeforeWrite = true;
/// 命名前缀(默认使用 PNG 文件名,可手动修改)
private string namePrefix = "";
#endregion
#region 数据结构
/// 字体文件格式
private enum FontFormat
{
Unknown,
BmFontFnt, // .fnt 或 .bytes,BMFont 文本格式
UnityFontAsset // .asset,Unity 原生 Font(!u!128)
}
/// 单个字符的解析信息(统一格式)
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; // 字符前进宽度(仅供信息显示)
/// Unity 坐标系下的 rect(已翻转 y,可直接写入 meta)
public Rect unityRect;
}
#endregion
#region 菜单入口
[MenuItem("Tools/FNT Sprite Splitter")]
private static void ShowWindow()
{
var window = GetWindow("FNT Sprite Splitter");
window.minSize = new Vector2(440, 560);
window.Show();
}
#endregion
#region GUI 绘制
private void OnGUI()
{
DrawHeader();
DrawFontSlot();
DrawPngSlot();
DrawCharList();
DrawOptions();
DrawActionBar();
DrawLogArea();
}
/// 顶部标题说明
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」生成可直接用于 的数字精灵资源",
MessageType.Info
);
EditorGUILayout.Space(4);
}
/// 字体文件拖拽槽位
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);
}
/// 目标 PNG 显示槽位(可手动覆盖)
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⚠ 尺寸不匹配!字体声明 {fontTextureW}x{fontTextureH}";
}
else if (fontFormat == FontFormat.UnityFontAsset)
{
warning = "\nUnity Font 模式:UV 已按 PNG 实际尺寸换算";
}
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(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);
}
/// 字符列表
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);
}
/// 选项区域
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);
}
/// 操作按钮区
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);
}
/// 日志区域
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 字体文件加载与解析
/// 判断文件是否为支持的字格式
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;
}
/// 格式枚举转显示文本
private string FormatToDisplay(FontFormat fmt)
{
switch (fmt)
{
case FontFormat.BmFontFnt: return "BMFont 文本";
case FontFormat.UnityFontAsset: return "Unity Font (.asset)";
default: return "未知格式";
}
}
/// 加载并解析字体文件
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();
}
/// 解析 BMFont 文本格式(.fnt 或 .bytes)
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);
}
}
/// 解析 Unity 原生 Font .asset(!u!128)
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);
}
}
/// 当 PNG 实际尺寸变化时,重新计算字符 rect(主要用于 Unity Font 模式)
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);
}
}
/// 从 BMFont page file 自动查找 PNG
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(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(p);
if (tex != null) break;
}
}
}
if (tex != null)
{
targetTexture = tex;
namePrefix = Path.GetFileNameWithoutExtension(pageFileName);
}
}
/// 把字符 id 映射成可读的名称标签
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}";
}
/// 把字符 id 映射成可作为 sprite 名称的英文标识符
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 写入
/// 主入口:把字符列表写入 PNG.meta
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 nameFileIdEntries = new List();
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);
}
/// 清空 PNG.meta 中所有 sprites
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;
}
///
/// 基于已解析的字符列表生成 TMP_SpriteAsset。
/// 字符表 m_Unicode 使用字体中的 u8 字符 id,m_GlyphIndex 对应 glyph 表下标,
/// 因此既支持 也支持 引用。
/// glyph 的 sprite 引用会从 PNG 的子 Sprite 中按「前缀_下标」匹配(需先执行「写入 Meta」分割)。
///
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 spriteByName = new Dictionary();
foreach (var a in allAssets)
{
if (a is Sprite spr)
spriteByName[spr.name] = spr;
}
// 创建 SpriteAsset
var spriteAsset = ScriptableObject.CreateInstance();
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();
var charTable = new List();
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();
spriteAsset.fallbackSpriteAssets = new List();
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 = \"...\"");
logText = sb.ToString();
showLog = true;
Debug.Log("[FntSpriteSplitter] 生成 TMP_SpriteAsset\n" + logText);
EditorGUIUtility.PingObject(spriteAsset);
}
#endregion
#region 工具方法
/// 重置所有状态
private void ClearAll()
{
fontAssetPath = "";
fontFormat = FontFormat.Unknown;
targetTexture = null;
charList.Clear();
fontTextureW = 0;
fontTextureH = 0;
namePrefix = "";
logText = "";
Repaint();
}
/// 替换字符串中第一次出现的 oldText
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);
}
///
/// 从 blockStartIndex 开始,找到当前 YAML 块的结束位置。
/// 块以 " keyName:" 开头,后续行要么以 " - " 继续列表项,
/// 要么以更多缩进(6+ 空格)作为子属性。
/// 遇到同级缩进(4 空格 + 非 "-" 非空格)的行时结束。
///
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;
}
/// 基于 sprite 名称生成稳定的 32 位 hex spriteID
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
}