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 }