898 lines
36 KiB
C#
898 lines
36 KiB
C#
/*
|
||
* @Author: Claude
|
||
* @Description: TMP 表情图集自动化生成编辑器,将碎图打包为 TMP_SpriteAsset
|
||
* @Date: 2026年04月03日
|
||
* @Modify:
|
||
*/
|
||
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Reflection;
|
||
using System.Text.RegularExpressions;
|
||
using TMPro;
|
||
using UnityEditor;
|
||
using UnityEngine;
|
||
using UnityEngine.TextCore;
|
||
|
||
|
||
namespace Logic.Editor
|
||
{
|
||
/// <summary>
|
||
/// TMP 表情图集自动化生成器
|
||
/// 功能流程:检查碎图 → 合并大图 → 设置切分 → 生成 TMP_SpriteAsset
|
||
/// </summary>
|
||
public class SpriteAssetEditorWindow : EditorWindow
|
||
{
|
||
// ============================================================
|
||
// 常量
|
||
// ============================================================
|
||
private static readonly Regex ValidNameRegex = new Regex(@"^[a-z0-9_]+$", RegexOptions.Compiled);
|
||
private static readonly int[] AtlasSizeOptions = { 1024, 2048, 4096 };
|
||
private static readonly string[] AtlasSizeLabels = { "1024", "2048", "4096" };
|
||
|
||
private const string DefaultSourceFolder = "Assets/BundleResources/TH1UI/Icon/Emoji";
|
||
private const string DefaultOutputFolder = "Assets/BundleResources/SpriteAsset";
|
||
|
||
// ============================================================
|
||
// 配置字段
|
||
// ============================================================
|
||
private DefaultAsset _sourceFolder;
|
||
private DefaultAsset _outputFolder;
|
||
private string _assetName = "EmojiSpriteAsset";
|
||
private int _atlasSizeIndex = 1; // 默认 2048
|
||
private int _padding = 2;
|
||
private bool _checkSize;
|
||
private int _expectedWidth = 128;
|
||
private int _expectedHeight = 128;
|
||
|
||
// ============================================================
|
||
// UI 状态
|
||
// ============================================================
|
||
private Vector2 _barPosition;
|
||
private Vector2 _logScrollPosition;
|
||
private string _logContent = "";
|
||
private GUIStyle _boxStyle;
|
||
private GUIStyle _logStyle;
|
||
|
||
// ============================================================
|
||
// 数据结构
|
||
// ============================================================
|
||
/// <summary>
|
||
/// 通过验证的碎图信息
|
||
/// </summary>
|
||
public class ValidSpriteInfo
|
||
{
|
||
public string FilePath; // 完整文件路径
|
||
public string AssetPath; // Assets/ 相对路径
|
||
public string Name; // 文件名(不含后缀)
|
||
public Texture2D Texture; // 加载后的纹理引用
|
||
}
|
||
|
||
// ============================================================
|
||
// 编辑器入口
|
||
// ============================================================
|
||
[MenuItem("Tools/表情图集生成器")]
|
||
private static void ShowWindow()
|
||
{
|
||
var window = CreateWindow<SpriteAssetEditorWindow>();
|
||
window.titleContent = new GUIContent("表情图集生成器");
|
||
window.minSize = new Vector2(500, 600);
|
||
window.Show();
|
||
}
|
||
|
||
private void OnEnable()
|
||
{
|
||
// 首次打开时自动填入默认文件夹
|
||
if (_sourceFolder == null)
|
||
_sourceFolder = AssetDatabase.LoadAssetAtPath<DefaultAsset>(DefaultSourceFolder);
|
||
|
||
if (_outputFolder == null)
|
||
_outputFolder = AssetDatabase.LoadAssetAtPath<DefaultAsset>(DefaultOutputFolder);
|
||
}
|
||
|
||
// ============================================================
|
||
// GUI 绘制
|
||
// ============================================================
|
||
private void OnGUI()
|
||
{
|
||
// 初始化样式
|
||
if (_boxStyle == null)
|
||
{
|
||
_boxStyle = InspectorUtils.GetHelpBoxStyle();
|
||
InspectorUtils.AddBorder(_boxStyle, new Color(0.5f, 0.5f, 0.5f));
|
||
}
|
||
|
||
if (_logStyle == null)
|
||
{
|
||
_logStyle = new GUIStyle(EditorStyles.textArea)
|
||
{
|
||
richText = true,
|
||
wordWrap = true,
|
||
fontSize = 11
|
||
};
|
||
}
|
||
|
||
_barPosition = EditorGUILayout.BeginScrollView(_barPosition);
|
||
|
||
// ---- 标题 ----
|
||
EditorGUILayout.Space(8);
|
||
EditorGUILayout.LabelField("TMP 表情图集生成器", EditorStyles.boldLabel);
|
||
InspectorUtils.DrawDivider(Color.gray, 1);
|
||
EditorGUILayout.Space(4);
|
||
|
||
// ---- 源文件夹 ----
|
||
EditorGUILayout.BeginVertical(_boxStyle);
|
||
EditorGUILayout.LabelField("基本设置", EditorStyles.boldLabel);
|
||
EditorGUILayout.Space(4);
|
||
|
||
EditorGUILayout.BeginHorizontal();
|
||
InspectorUtils.InspectorTextWidthRich("<b>源文件夹:</b>");
|
||
_sourceFolder = (DefaultAsset)EditorGUILayout.ObjectField(
|
||
_sourceFolder, typeof(DefaultAsset), false, GUILayout.Width(300));
|
||
EditorGUILayout.EndHorizontal();
|
||
|
||
EditorGUILayout.BeginHorizontal();
|
||
InspectorUtils.InspectorTextWidthRich("<b>输出文件夹:</b>");
|
||
_outputFolder = (DefaultAsset)EditorGUILayout.ObjectField(
|
||
_outputFolder, typeof(DefaultAsset), false, GUILayout.Width(300));
|
||
EditorGUILayout.EndHorizontal();
|
||
|
||
EditorGUILayout.BeginHorizontal();
|
||
InspectorUtils.InspectorTextWidthRich("<b>资产名称:</b>");
|
||
_assetName = EditorGUILayout.TextField(_assetName, GUILayout.Width(300));
|
||
EditorGUILayout.EndHorizontal();
|
||
|
||
EditorGUILayout.EndVertical();
|
||
EditorGUILayout.Space(4);
|
||
|
||
// ---- 图集设置 ----
|
||
EditorGUILayout.BeginVertical(_boxStyle);
|
||
EditorGUILayout.LabelField("图集设置", EditorStyles.boldLabel);
|
||
EditorGUILayout.Space(4);
|
||
|
||
EditorGUILayout.BeginHorizontal();
|
||
InspectorUtils.InspectorTextWidthRich("<b>最大尺寸:</b>");
|
||
_atlasSizeIndex = EditorGUILayout.Popup(_atlasSizeIndex, AtlasSizeLabels, GUILayout.Width(100));
|
||
EditorGUILayout.EndHorizontal();
|
||
|
||
EditorGUILayout.BeginHorizontal();
|
||
InspectorUtils.InspectorTextWidthRich("<b>图元间距:</b>");
|
||
_padding = EditorGUILayout.IntField(_padding, GUILayout.Width(100));
|
||
_padding = Mathf.Clamp(_padding, 0, 16);
|
||
EditorGUILayout.EndHorizontal();
|
||
|
||
EditorGUILayout.Space(4);
|
||
|
||
// 尺寸检查
|
||
EditorGUILayout.BeginHorizontal();
|
||
_checkSize = InspectorUtils.InspectorToggleVertical("检查图片尺寸", _checkSize);
|
||
EditorGUILayout.EndHorizontal();
|
||
|
||
if (_checkSize)
|
||
{
|
||
EditorGUILayout.BeginHorizontal();
|
||
InspectorUtils.InspectorTextWidthRich(" <b>期望宽度:</b>");
|
||
_expectedWidth = EditorGUILayout.IntField(_expectedWidth, GUILayout.Width(100));
|
||
InspectorUtils.InspectorTextWidthRich(" <b>期望高度:</b>");
|
||
_expectedHeight = EditorGUILayout.IntField(_expectedHeight, GUILayout.Width(100));
|
||
EditorGUILayout.EndHorizontal();
|
||
}
|
||
|
||
EditorGUILayout.EndVertical();
|
||
EditorGUILayout.Space(4);
|
||
|
||
// ---- 操作按钮 ----
|
||
EditorGUILayout.BeginVertical(_boxStyle);
|
||
EditorGUILayout.LabelField("操作", EditorStyles.boldLabel);
|
||
EditorGUILayout.Space(4);
|
||
|
||
EditorGUILayout.BeginHorizontal();
|
||
if (InspectorUtils.InspectorButtonWithTextWidth("检查图片资源"))
|
||
{
|
||
OnClickValidate();
|
||
}
|
||
|
||
EditorGUILayout.Space(10);
|
||
|
||
if (InspectorUtils.InspectorButtonWithTextWidth("一键生成 SpriteAsset"))
|
||
{
|
||
OnClickGenerate();
|
||
}
|
||
|
||
EditorGUILayout.Space(10);
|
||
|
||
if (InspectorUtils.InspectorButtonWithTextWidth("清空日志"))
|
||
{
|
||
_logContent = "";
|
||
}
|
||
|
||
EditorGUILayout.EndHorizontal();
|
||
EditorGUILayout.Space(4);
|
||
EditorGUILayout.EndVertical();
|
||
EditorGUILayout.Space(4);
|
||
|
||
// ---- 日志输出 ----
|
||
EditorGUILayout.BeginVertical(_boxStyle);
|
||
EditorGUILayout.LabelField("日志输出", EditorStyles.boldLabel);
|
||
EditorGUILayout.Space(4);
|
||
|
||
_logScrollPosition = EditorGUILayout.BeginScrollView(
|
||
_logScrollPosition, GUILayout.Height(250));
|
||
EditorGUILayout.TextArea(_logContent, _logStyle, GUILayout.ExpandHeight(true));
|
||
EditorGUILayout.EndScrollView();
|
||
|
||
EditorGUILayout.EndVertical();
|
||
|
||
EditorGUILayout.EndScrollView();
|
||
}
|
||
|
||
// ============================================================
|
||
// 按钮回调
|
||
// ============================================================
|
||
|
||
/// <summary>
|
||
/// 仅检查图片资源
|
||
/// </summary>
|
||
private void OnClickValidate()
|
||
{
|
||
_logContent = "";
|
||
AppendLog("<b>===== 开始检查图片资源 =====</b>");
|
||
|
||
string sourcePath = GetFolderPath(_sourceFolder, "源文件夹");
|
||
if (sourcePath == null) return;
|
||
|
||
var result = ValidateImages(sourcePath);
|
||
if (result != null)
|
||
{
|
||
AppendLog($"\n<color=green><b>检查完成,共 {result.Count} 张有效图片</b></color>");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 一键生成完整流程
|
||
/// </summary>
|
||
private void OnClickGenerate()
|
||
{
|
||
_logContent = "";
|
||
AppendLog("<b>===== 开始一键生成 SpriteAsset =====</b>");
|
||
|
||
// 校验路径
|
||
string sourcePath = GetFolderPath(_sourceFolder, "源文件夹");
|
||
if (sourcePath == null) return;
|
||
|
||
string outputPath = GetFolderPath(_outputFolder, "输出文件夹");
|
||
if (outputPath == null) return;
|
||
|
||
if (string.IsNullOrWhiteSpace(_assetName))
|
||
{
|
||
AppendLog("<color=red>错误:资产名称不能为空</color>");
|
||
EditorUtility.DisplayDialog("错误", "资产名称不能为空。", "确定");
|
||
return;
|
||
}
|
||
|
||
// Step 1: 检查图片
|
||
AppendLog("\n<b>--- Step 1: 检查图片资源 ---</b>");
|
||
var validSprites = ValidateImages(sourcePath);
|
||
if (validSprites == null || validSprites.Count == 0)
|
||
{
|
||
AppendLog("<color=red>没有有效图片,流程终止</color>");
|
||
EditorUtility.DisplayDialog("错误", "没有找到有效的图片资源,流程终止。", "确定");
|
||
return;
|
||
}
|
||
|
||
// Step 2: 合并大图
|
||
AppendLog("\n<b>--- Step 2: 合并大图 ---</b>");
|
||
int atlasSize = AtlasSizeOptions[_atlasSizeIndex];
|
||
var packResult = PackAtlasTexture(validSprites, outputPath, atlasSize);
|
||
if (packResult == null)
|
||
{
|
||
AppendLog("<color=red>合并大图失败,流程终止</color>");
|
||
return;
|
||
}
|
||
|
||
// Step 3: 设置切分
|
||
AppendLog("\n<b>--- Step 3: 设置大图切分属性 ---</b>");
|
||
string atlasAssetPath = packResult.Value.atlasAssetPath;
|
||
var names = validSprites.Select(s => s.Name).ToArray();
|
||
bool sliceOk = SetupTextureImporter(
|
||
atlasAssetPath,
|
||
packResult.Value.rects,
|
||
names,
|
||
packResult.Value.atlasWidth,
|
||
packResult.Value.atlasHeight);
|
||
if (!sliceOk)
|
||
{
|
||
AppendLog("<color=red>设置切分失败,流程终止</color>");
|
||
return;
|
||
}
|
||
|
||
// Step 4: 生成 TMP_SpriteAsset
|
||
AppendLog("\n<b>--- Step 4: 生成 TMP_SpriteAsset ---</b>");
|
||
bool assetOk = GenerateTMPSpriteAsset(atlasAssetPath, outputPath);
|
||
if (!assetOk)
|
||
{
|
||
AppendLog("<color=red>生成 SpriteAsset 失败</color>");
|
||
return;
|
||
}
|
||
|
||
// Step 5: 自动设置 TMP Settings 的 Default Sprite Asset
|
||
AppendLog("\n<b>--- Step 5: 设置 TMP Settings ---</b>");
|
||
string generatedAssetPath = $"{outputPath}/{_assetName}.asset";
|
||
SetTMPDefaultSpriteAsset(generatedAssetPath);
|
||
|
||
AppendLog("\n<color=green><b>===== 全部流程完成!=====</b></color>");
|
||
EditorUtility.DisplayDialog("成功",
|
||
$"SpriteAsset 已生成并自动绑定!\n\n" +
|
||
$"图集:{atlasAssetPath}\n" +
|
||
$"资产:{generatedAssetPath}\n\n" +
|
||
$"已自动设置为 TMP Settings → Default Sprite Asset。\n" +
|
||
$"可直接在 TMP 文本中使用 <sprite name=\"xxx\"> 标签。",
|
||
"确定");
|
||
}
|
||
|
||
// ============================================================
|
||
// Step 1: 检查与收集图片资源
|
||
// ============================================================
|
||
|
||
/// <summary>
|
||
/// 验证源文件夹中的图片,返回通过验证的图片列表
|
||
/// </summary>
|
||
public List<ValidSpriteInfo> ValidateImages(string sourceFolderPath)
|
||
{
|
||
if (!Directory.Exists(sourceFolderPath))
|
||
{
|
||
AppendLog($"<color=red>错误:源文件夹不存在 - {sourceFolderPath}</color>");
|
||
return null;
|
||
}
|
||
|
||
var allFiles = Directory.GetFiles(sourceFolderPath);
|
||
if (allFiles.Length == 0)
|
||
{
|
||
AppendLog("<color=yellow>警告:源文件夹为空,没有找到任何文件</color>");
|
||
return new List<ValidSpriteInfo>();
|
||
}
|
||
|
||
var validList = new List<ValidSpriteInfo>();
|
||
int errorCount = 0;
|
||
|
||
foreach (var filePath in allFiles)
|
||
{
|
||
string fileName = Path.GetFileName(filePath);
|
||
string nameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
|
||
string extension = Path.GetExtension(filePath).ToLower();
|
||
|
||
// 跳过 .meta 文件
|
||
if (extension == ".meta") continue;
|
||
|
||
// 检查后缀
|
||
if (extension != ".png")
|
||
{
|
||
AppendLog($"<color=yellow> 跳过非 PNG 文件:{fileName}</color>");
|
||
errorCount++;
|
||
continue;
|
||
}
|
||
|
||
// 检查文件名合法性
|
||
if (!IsValidSpriteName(nameWithoutExt))
|
||
{
|
||
AppendLog($"<color=red> 文件名不合法:{fileName}(仅允许小写英文、数字、下划线)</color>");
|
||
errorCount++;
|
||
continue;
|
||
}
|
||
|
||
// 检查重名
|
||
if (validList.Any(s => s.Name == nameWithoutExt))
|
||
{
|
||
AppendLog($"<color=red> 文件名重复:{fileName}</color>");
|
||
errorCount++;
|
||
continue;
|
||
}
|
||
|
||
// 转换为 Assets/ 相对路径
|
||
string assetPath = FilePathToAssetPath(filePath);
|
||
if (string.IsNullOrEmpty(assetPath))
|
||
{
|
||
AppendLog($"<color=red> 无法转换为 Asset 路径:{filePath}</color>");
|
||
errorCount++;
|
||
continue;
|
||
}
|
||
|
||
// 加载纹理并检查尺寸
|
||
var texture = AssetDatabase.LoadAssetAtPath<Texture2D>(assetPath);
|
||
if (texture == null)
|
||
{
|
||
AppendLog($"<color=red> 无法加载纹理:{assetPath}</color>");
|
||
errorCount++;
|
||
continue;
|
||
}
|
||
|
||
if (_checkSize)
|
||
{
|
||
if (texture.width != _expectedWidth || texture.height != _expectedHeight)
|
||
{
|
||
AppendLog($"<color=red> 尺寸不符:{fileName}({texture.width}x{texture.height},期望 {_expectedWidth}x{_expectedHeight})</color>");
|
||
errorCount++;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
validList.Add(new ValidSpriteInfo
|
||
{
|
||
FilePath = filePath,
|
||
AssetPath = assetPath,
|
||
Name = nameWithoutExt,
|
||
Texture = texture
|
||
});
|
||
|
||
AppendLog($" <color=green>通过</color>:{fileName}({texture.width}x{texture.height})");
|
||
}
|
||
|
||
AppendLog($"\n检查结果:{validList.Count} 张有效,{errorCount} 张无效");
|
||
return validList;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 检查 Sprite 名称是否合法(仅允许小写英文、数字、下划线)
|
||
/// </summary>
|
||
public static bool IsValidSpriteName(string name)
|
||
{
|
||
if (string.IsNullOrEmpty(name)) return false;
|
||
return ValidNameRegex.IsMatch(name);
|
||
}
|
||
|
||
// ============================================================
|
||
// Step 2: 自动合并大图
|
||
// ============================================================
|
||
|
||
/// <summary>
|
||
/// Pack 结果
|
||
/// </summary>
|
||
public struct PackResult
|
||
{
|
||
public string atlasAssetPath;
|
||
public Rect[] rects;
|
||
public int atlasWidth;
|
||
public int atlasHeight;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将碎图合并为一张大图并保存
|
||
/// </summary>
|
||
public PackResult? PackAtlasTexture(List<ValidSpriteInfo> sprites, string outputFolderPath, int maxAtlasSize)
|
||
{
|
||
try
|
||
{
|
||
// 确保所有碎图可读
|
||
AppendLog(" 设置碎图为可读状态...");
|
||
foreach (var sprite in sprites)
|
||
{
|
||
EnsureTextureReadable(sprite.AssetPath);
|
||
}
|
||
|
||
// 重新加载纹理(Reimport 后引用可能变化)
|
||
var textures = new Texture2D[sprites.Count];
|
||
for (int i = 0; i < sprites.Count; i++)
|
||
{
|
||
textures[i] = AssetDatabase.LoadAssetAtPath<Texture2D>(sprites[i].AssetPath);
|
||
if (textures[i] == null)
|
||
{
|
||
AppendLog($"<color=red> 重新加载纹理失败:{sprites[i].AssetPath}</color>");
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 创建图集纹理并打包
|
||
AppendLog(" 开始 PackTextures...");
|
||
var atlas = new Texture2D(maxAtlasSize, maxAtlasSize, TextureFormat.RGBA32, false);
|
||
Rect[] rects = atlas.PackTextures(textures, _padding, maxAtlasSize);
|
||
|
||
if (rects == null || rects.Length != sprites.Count)
|
||
{
|
||
AppendLog("<color=red> PackTextures 失败:返回 Rect 数量不匹配</color>");
|
||
return null;
|
||
}
|
||
|
||
int atlasWidth = atlas.width;
|
||
int atlasHeight = atlas.height;
|
||
AppendLog($" 图集尺寸:{atlasWidth} x {atlasHeight}");
|
||
|
||
// 导出 PNG —— 先拷贝到干净的 RGBA32 纹理,防止压缩格式导致 EncodeToPNG 返回 null
|
||
string atlasFileName = $"{_assetName}_Atlas.png";
|
||
string outputAssetPath = $"{outputFolderPath}/{atlasFileName}";
|
||
string outputFullPath = AssetPathToFilePath(outputAssetPath);
|
||
|
||
var readableAtlas = new Texture2D(atlasWidth, atlasHeight, TextureFormat.RGBA32, false);
|
||
readableAtlas.SetPixels(atlas.GetPixels());
|
||
readableAtlas.Apply();
|
||
|
||
byte[] pngData = readableAtlas.EncodeToPNG();
|
||
DestroyImmediate(readableAtlas);
|
||
DestroyImmediate(atlas);
|
||
|
||
if (pngData == null)
|
||
{
|
||
AppendLog("<color=red> EncodeToPNG 失败:无法编码图集纹理</color>");
|
||
return null;
|
||
}
|
||
|
||
File.WriteAllBytes(outputFullPath, pngData);
|
||
AssetDatabase.Refresh();
|
||
AppendLog($" 图集已保存:{outputAssetPath}");
|
||
|
||
// 打印每张碎图在图集中的位置
|
||
for (int i = 0; i < sprites.Count; i++)
|
||
{
|
||
var r = rects[i];
|
||
int px = Mathf.RoundToInt(r.x * atlasWidth);
|
||
int py = Mathf.RoundToInt(r.y * atlasHeight);
|
||
int pw = Mathf.RoundToInt(r.width * atlasWidth);
|
||
int ph = Mathf.RoundToInt(r.height * atlasHeight);
|
||
AppendLog($" {sprites[i].Name} → ({px}, {py}, {pw}x{ph})");
|
||
}
|
||
|
||
return new PackResult
|
||
{
|
||
atlasAssetPath = outputAssetPath,
|
||
rects = rects,
|
||
atlasWidth = atlasWidth,
|
||
atlasHeight = atlasHeight
|
||
};
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
AppendLog($"<color=red> PackTextures 异常:{e.Message}</color>");
|
||
Debug.LogException(e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Step 3: 自动设置大图切分属性
|
||
// ============================================================
|
||
|
||
/// <summary>
|
||
/// 设置大图的 TextureImporter,完成 Sprite Multiple 切分
|
||
/// </summary>
|
||
public bool SetupTextureImporter(string atlasAssetPath, Rect[] normalizedRects, string[] names,
|
||
int atlasWidth, int atlasHeight)
|
||
{
|
||
try
|
||
{
|
||
var importer = AssetImporter.GetAtPath(atlasAssetPath) as TextureImporter;
|
||
if (importer == null)
|
||
{
|
||
AppendLog($"<color=red> 无法获取 TextureImporter:{atlasAssetPath}</color>");
|
||
return false;
|
||
}
|
||
|
||
// 设置贴图类型
|
||
importer.textureType = TextureImporterType.Sprite;
|
||
importer.spriteImportMode = SpriteImportMode.Multiple;
|
||
importer.isReadable = true;
|
||
importer.mipmapEnabled = false;
|
||
importer.textureCompression = TextureImporterCompression.Uncompressed;
|
||
importer.maxTextureSize = Mathf.Max(atlasWidth, atlasHeight);
|
||
|
||
// 构建切片数据
|
||
var spriteSheet = new SpriteMetaData[names.Length];
|
||
for (int i = 0; i < names.Length; i++)
|
||
{
|
||
var nr = normalizedRects[i];
|
||
// 将归一化 Rect 转为像素坐标(左下角坐标系)
|
||
var pixelRect = new Rect(
|
||
Mathf.RoundToInt(nr.x * atlasWidth),
|
||
Mathf.RoundToInt(nr.y * atlasHeight),
|
||
Mathf.RoundToInt(nr.width * atlasWidth),
|
||
Mathf.RoundToInt(nr.height * atlasHeight)
|
||
);
|
||
|
||
spriteSheet[i] = new SpriteMetaData
|
||
{
|
||
name = names[i],
|
||
rect = pixelRect,
|
||
alignment = (int)SpriteAlignment.Center,
|
||
pivot = new Vector2(0.5f, 0.5f)
|
||
};
|
||
|
||
AppendLog($" 切片[{i}]:{names[i]} → rect({pixelRect.x}, {pixelRect.y}, {pixelRect.width}, {pixelRect.height})");
|
||
}
|
||
|
||
importer.spritesheet = spriteSheet;
|
||
importer.SaveAndReimport();
|
||
AppendLog(" TextureImporter 设置完成,已刷新资源");
|
||
|
||
return true;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
AppendLog($"<color=red> 设置 TextureImporter 异常:{e.Message}</color>");
|
||
Debug.LogException(e);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Step 4: 自动生成 TMP_SpriteAsset
|
||
// ============================================================
|
||
|
||
/// <summary>
|
||
/// 基于切分好的图集生成 TMP_SpriteAsset
|
||
/// </summary>
|
||
public bool GenerateTMPSpriteAsset(string atlasAssetPath, string outputFolderPath)
|
||
{
|
||
try
|
||
{
|
||
// 加载图集纹理
|
||
var atlasTexture = AssetDatabase.LoadAssetAtPath<Texture2D>(atlasAssetPath);
|
||
if (atlasTexture == null)
|
||
{
|
||
AppendLog($"<color=red> 无法加载图集纹理:{atlasAssetPath}</color>");
|
||
return false;
|
||
}
|
||
|
||
// 加载所有切分后的 Sprite
|
||
var sprites = AssetDatabase.LoadAllAssetsAtPath(atlasAssetPath)
|
||
.OfType<Sprite>().OrderBy(s => s.name).ToList();
|
||
|
||
if (sprites.Count == 0)
|
||
{
|
||
AppendLog("<color=red> 未找到切分后的 Sprite,请确认 Step 3 是否正确执行</color>");
|
||
return false;
|
||
}
|
||
|
||
AppendLog($" 找到 {sprites.Count} 个 Sprite 切片");
|
||
|
||
// ====================================================================
|
||
// 严格按照 TMP 官方 TMP_SpriteAssetMenu.CreateSpriteAsset 的顺序构建
|
||
// ====================================================================
|
||
|
||
// 1. 先创建资产并持久化到磁盘(官方要求 CreateAsset 在设置属性之前)
|
||
var spriteAsset = ScriptableObject.CreateInstance<TMP_SpriteAsset>();
|
||
string assetPath = $"{outputFolderPath}/{_assetName}.asset";
|
||
AssetDatabase.CreateAsset(spriteAsset, assetPath);
|
||
|
||
// 2. 设置版本号和哈希(version setter 是 internal,需要反射)
|
||
const BindingFlags bf = BindingFlags.NonPublic | BindingFlags.Instance;
|
||
typeof(TMP_SpriteAsset).GetField("m_Version", bf)?.SetValue(spriteAsset, "1.1.0");
|
||
spriteAsset.hashCode = TMP_TextUtilities.GetSimpleHashCode(spriteAsset.name);
|
||
|
||
// 3. 设置图集纹理引用
|
||
spriteAsset.spriteSheet = atlasTexture;
|
||
|
||
// 4. 构建 GlyphTable 和 CharacterTable(使用原始像素值,不做任何缩放)
|
||
var spriteGlyphTable = new List<TMP_SpriteGlyph>();
|
||
var spriteCharacterTable = new List<TMP_SpriteCharacter>();
|
||
|
||
for (int i = 0; i < sprites.Count; i++)
|
||
{
|
||
var sprite = sprites[i];
|
||
var rect = sprite.rect;
|
||
|
||
// GlyphMetrics 使用原始像素值
|
||
// bearingX = 0(不向左偏移,避免覆盖前一字符)
|
||
// bearingY = height(图标底部对齐基线,整体在基线上方,与文字对齐)
|
||
var spriteGlyph = new TMP_SpriteGlyph();
|
||
spriteGlyph.index = (uint)i;
|
||
spriteGlyph.sprite = sprite;
|
||
spriteGlyph.metrics = new GlyphMetrics(
|
||
rect.width, // width: 原始像素宽度
|
||
rect.height, // height: 原始像素高度
|
||
0, // bearingX: 无水平偏移
|
||
rect.height * 0.9f, // bearingY: 底部略低于基线,视觉居中
|
||
rect.width // advance: 光标前进量 = 图标宽度
|
||
);
|
||
spriteGlyph.glyphRect = new GlyphRect(
|
||
(int)rect.x,
|
||
(int)rect.y,
|
||
(int)rect.width,
|
||
(int)rect.height
|
||
);
|
||
spriteGlyph.scale = 1.0f;
|
||
spriteGlyph.atlasIndex = 0;
|
||
spriteGlyphTable.Add(spriteGlyph);
|
||
|
||
// SpriteCharacter: unicode 统一用 0xFFFE(官方占位符),通过 name 查找
|
||
var spriteCharacter = new TMP_SpriteCharacter(0xFFFE, spriteGlyph);
|
||
spriteCharacter.name = sprite.name;
|
||
spriteCharacter.scale = 1.0f;
|
||
spriteCharacterTable.Add(spriteCharacter);
|
||
|
||
AppendLog($" [{i}] {sprite.name} - rect({rect.x}, {rect.y}, {rect.width}x{rect.height})");
|
||
}
|
||
|
||
// 5. 通过反射写入表数据(setter 是 internal)
|
||
typeof(TMP_SpriteAsset).GetField("m_SpriteGlyphTable", bf)?.SetValue(spriteAsset, spriteGlyphTable);
|
||
typeof(TMP_SpriteAsset).GetField("m_SpriteCharacterTable", bf)?.SetValue(spriteAsset, spriteCharacterTable);
|
||
|
||
// 6. 创建材质并作为子资产嵌入(必须在 CreateAsset 之后)
|
||
Shader spriteShader = Shader.Find("TextMeshPro/Sprite");
|
||
if (spriteShader == null)
|
||
{
|
||
AppendLog("<color=red> 错误:找不到 TextMeshPro/Sprite shader</color>");
|
||
return false;
|
||
}
|
||
|
||
var material = new Material(spriteShader);
|
||
material.SetTexture(ShaderUtilities.ID_MainTex, spriteAsset.spriteSheet);
|
||
spriteAsset.material = material;
|
||
material.hideFlags = HideFlags.HideInHierarchy;
|
||
AssetDatabase.AddObjectToAsset(material, spriteAsset);
|
||
|
||
// 7. 更新查找表(构建 name → character 哈希字典)
|
||
spriteAsset.UpdateLookupTables();
|
||
|
||
// 8. 标脏并保存
|
||
EditorUtility.SetDirty(spriteAsset);
|
||
AssetDatabase.SaveAssets();
|
||
AssetDatabase.ImportAsset(AssetDatabase.GetAssetPath(spriteAsset));
|
||
|
||
AppendLog($" <color=green>TMP_SpriteAsset 已保存:{assetPath}</color>");
|
||
AppendLog($" 共 {spriteCharacterTable.Count} 个 Sprite 字符映射");
|
||
|
||
return true;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
AppendLog($"<color=red> 生成 TMP_SpriteAsset 异常:{e.Message}</color>");
|
||
Debug.LogException(e);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Step 5: 自动设置 TMP Settings
|
||
// ============================================================
|
||
|
||
/// <summary>
|
||
/// 将生成的 SpriteAsset 自动设置为 TMP Settings 的 Default Sprite Asset
|
||
/// </summary>
|
||
private void SetTMPDefaultSpriteAsset(string spriteAssetPath)
|
||
{
|
||
try
|
||
{
|
||
var spriteAsset = AssetDatabase.LoadAssetAtPath<TMP_SpriteAsset>(spriteAssetPath);
|
||
if (spriteAsset == null)
|
||
{
|
||
AppendLog($"<color=red> 无法加载 SpriteAsset:{spriteAssetPath}</color>");
|
||
return;
|
||
}
|
||
|
||
var tmpSettings = TMP_Settings.instance;
|
||
if (tmpSettings == null)
|
||
{
|
||
AppendLog("<color=red> 未找到 TMP Settings 实例,请先通过 Window → TextMeshPro → Settings 创建</color>");
|
||
return;
|
||
}
|
||
|
||
// TMP_Settings.defaultSpriteAsset 的 setter 是 internal,通过 SerializedObject 修改
|
||
string settingsPath = AssetDatabase.GetAssetPath(tmpSettings);
|
||
var so = new SerializedObject(tmpSettings);
|
||
so.Update();
|
||
|
||
var prop = so.FindProperty("m_defaultSpriteAsset");
|
||
if (prop != null)
|
||
{
|
||
prop.objectReferenceValue = spriteAsset;
|
||
so.ApplyModifiedProperties();
|
||
EditorUtility.SetDirty(tmpSettings);
|
||
AssetDatabase.SaveAssets();
|
||
AppendLog($" <color=green>已自动设置 TMP Settings → Default Sprite Asset = {spriteAsset.name}</color>");
|
||
AppendLog($" TMP Settings 路径:{settingsPath}");
|
||
}
|
||
else
|
||
{
|
||
AppendLog("<color=yellow> 警告:未找到 m_defaultSpriteAsset 属性,请手动在 TMP Settings 中绑定</color>");
|
||
}
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
AppendLog($"<color=yellow> 自动设置 TMP Settings 失败:{e.Message},请手动绑定</color>");
|
||
Debug.LogException(e);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 工具方法
|
||
// ============================================================
|
||
|
||
/// <summary>
|
||
/// 确保纹理为可读状态
|
||
/// </summary>
|
||
private void EnsureTextureReadable(string assetPath)
|
||
{
|
||
var importer = AssetImporter.GetAtPath(assetPath) as TextureImporter;
|
||
if (importer == null) return;
|
||
|
||
bool needsReimport = false;
|
||
|
||
if (!importer.isReadable)
|
||
{
|
||
importer.isReadable = true;
|
||
needsReimport = true;
|
||
}
|
||
|
||
// 必须为未压缩格式,否则 PackTextures 产出压缩图集,EncodeToPNG 会返回 null
|
||
if (importer.textureCompression != TextureImporterCompression.Uncompressed)
|
||
{
|
||
importer.textureCompression = TextureImporterCompression.Uncompressed;
|
||
needsReimport = true;
|
||
}
|
||
|
||
if (needsReimport)
|
||
{
|
||
importer.SaveAndReimport();
|
||
AppendLog($" 已设置可读+未压缩:{Path.GetFileName(assetPath)}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取文件夹的 Assets/ 相对路径
|
||
/// </summary>
|
||
private string GetFolderPath(DefaultAsset folder, string label)
|
||
{
|
||
if (folder == null)
|
||
{
|
||
AppendLog($"<color=red>错误:请指定{label}</color>");
|
||
EditorUtility.DisplayDialog("错误", $"请先指定{label}。", "确定");
|
||
return null;
|
||
}
|
||
|
||
string path = AssetDatabase.GetAssetPath(folder);
|
||
if (string.IsNullOrEmpty(path) || !AssetDatabase.IsValidFolder(path))
|
||
{
|
||
AppendLog($"<color=red>错误:{label}路径无效 - {path}</color>");
|
||
EditorUtility.DisplayDialog("错误", $"{label}路径无效。", "确定");
|
||
return null;
|
||
}
|
||
|
||
return path;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将文件系统绝对路径转换为 Assets/ 相对路径
|
||
/// </summary>
|
||
private static string FilePathToAssetPath(string filePath)
|
||
{
|
||
string normalized = filePath.Replace('\\', '/');
|
||
string dataPath = Application.dataPath.Replace('\\', '/');
|
||
|
||
if (normalized.StartsWith(dataPath))
|
||
{
|
||
return "Assets" + normalized.Substring(dataPath.Length);
|
||
}
|
||
|
||
// 尝试查找 "Assets/" 子串
|
||
int idx = normalized.IndexOf("Assets/", StringComparison.OrdinalIgnoreCase);
|
||
if (idx >= 0)
|
||
{
|
||
return normalized.Substring(idx);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将 Assets/ 相对路径转换为文件系统绝对路径
|
||
/// </summary>
|
||
private static string AssetPathToFilePath(string assetPath)
|
||
{
|
||
string dataPath = Application.dataPath.Replace('\\', '/');
|
||
// Application.dataPath 以 "Assets" 结尾
|
||
string projectRoot = dataPath.Substring(0, dataPath.Length - "Assets".Length);
|
||
return projectRoot + assetPath;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 追加日志到输出区
|
||
/// </summary>
|
||
public void AppendLog(string message)
|
||
{
|
||
_logContent += message + "\n";
|
||
Debug.Log($"[SpriteAsset] {message}");
|
||
Repaint();
|
||
}
|
||
}
|
||
}
|