add tool change UI

This commit is contained in:
CuongNV
2026-04-06 11:21:51 +07:00
parent 79de37afa4
commit afcfc1aa16
5 changed files with 589 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 63ad32ae8f64ef1449e51fe4460570a8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,348 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using BrewMonster.Scripts.Chat.EmotionData;
using UnityEditor;
using UnityEngine;
namespace BrewMonster.Scripts.Editor
{
/// <summary>
/// Convert một bộ: ghi atlas (hoặc slice tại chỗ) + TextureImporter Sprite Multiple full lưới,
/// gán FrameSprites theo tên cell_XXXX khớp C++ (row-major, hàng trên = row 0).
/// </summary>
public static class EmotionAtlasConverterCore
{
public const string CellSpriteNamePrefix = "cell_";
public static string CellSpriteName(int cellIndex) => $"{CellSpriteNamePrefix}{cellIndex:D4}";
/// <param name="sliceInPlace">true = chỉnh importer trên asset atlas hiện tại (ghi đè import settings). false = copy PNG vào thư mục output rồi slice (an toàn hơn).</param>
public static bool ConvertOneSet(
Texture2D sourceAtlas,
TextAsset txtAsset,
int emotionSetIndex,
int cellW,
int cellH,
string outputRootFolder,
bool sliceInPlace,
out EmotionSetSnapshot snapshot,
out string error)
{
snapshot = null;
error = null;
if (sourceAtlas == null)
{
error = "Atlas null.";
return false;
}
if (txtAsset == null)
{
error = "TextAsset null.";
return false;
}
string sourceAtlasPath = AssetDatabase.GetAssetPath(sourceAtlas);
if (string.IsNullOrEmpty(sourceAtlasPath))
{
error = "Atlas không phải asset trong project.";
return false;
}
var parseResult = EmotionTxtParser.Parse(txtAsset.text);
if (!parseResult.Success)
{
error = parseResult.ErrorMessage;
return false;
}
if (cellW <= 0 || cellH <= 0)
{
error = "Cell size phải > 0.";
return false;
}
Texture2D readable = GetReadableTexture(sourceAtlas);
if (readable == null)
{
error = "Không đọc được pixel từ atlas.";
return false;
}
try
{
int texW = readable.width;
int texH = readable.height;
if (texW % cellW != 0 || texH % cellH != 0)
{
error = $"Texture ({texW}x{texH}) không chia hết cho cell ({cellW}x{cellH}).";
return false;
}
int nNumX = texW / cellW;
int nNumY = texH / cellH;
int totalCells = nNumX * nNumY;
foreach (var e in parseResult.Entries)
{
if (e.NumFrames > 0 && e.StartPos + e.NumFrames - 1 >= totalCells)
{
error = $"Set {emotionSetIndex}: StartPos={e.StartPos}, NumFrames={e.NumFrames} vượt lưới ({totalCells} ô).";
return false;
}
}
EnsureFolder(outputRootFolder);
string setFolder = $"{outputRootFolder}/Emotions{emotionSetIndex}";
if (!AssetDatabase.IsValidFolder(setFolder))
AssetDatabase.CreateFolder(outputRootFolder, $"Emotions{emotionSetIndex}");
string atlasAssetPath;
Texture2D atlasForSo;
if (sliceInPlace)
{
atlasAssetPath = sourceAtlasPath;
if (!ApplyMultipleSpriteSheet(atlasAssetPath, texW, texH, cellW, cellH, nNumX, nNumY, out error))
return false;
atlasForSo = AssetDatabase.LoadAssetAtPath<Texture2D>(atlasAssetPath);
if (atlasForSo == null)
{
error = "Không load lại Texture2D sau slice.";
return false;
}
}
else
{
string fileName = $"Emotions{emotionSetIndex}_atlas.png";
atlasAssetPath = $"{setFolder}/{fileName}";
byte[] png = readable.EncodeToPNG();
File.WriteAllBytes(atlasAssetPath, png);
AssetDatabase.Refresh();
if (!ApplyMultipleSpriteSheet(atlasAssetPath, texW, texH, cellW, cellH, nNumX, nNumY, out error))
return false;
atlasForSo = AssetDatabase.LoadAssetAtPath<Texture2D>(atlasAssetPath);
if (atlasForSo == null)
{
error = $"Không load texture tại {atlasAssetPath}";
return false;
}
}
var spriteByName = LoadSpritesByName(atlasAssetPath);
if (spriteByName.Count < totalCells)
{
error = $"Chỉ tìm thấy {spriteByName.Count}/{totalCells} sprite sau import.";
return false;
}
snapshot = new EmotionSetSnapshot
{
EmotionSetIndex = emotionSetIndex,
CellWidth = cellW,
CellHeight = cellH,
SourceAtlas = atlasForSo,
SourceTxtAssetPath = AssetDatabase.GetAssetPath(txtAsset),
Entries = new List<EmotionEntryData>()
};
foreach (var src in parseResult.Entries)
{
var copy = new EmotionEntryData
{
StartPos = src.StartPos,
NumFrames = src.NumFrames,
Hint = src.Hint,
FrameTicks = (int[])src.FrameTicks.Clone(),
FrameSprites = new Sprite[src.NumFrames]
};
for (int f = 0; f < src.NumFrames; f++)
{
int cellIndex = src.StartPos + f;
string key = CellSpriteName(cellIndex);
if (!spriteByName.TryGetValue(key, out var spr) || spr == null)
{
error = $"Thiếu sprite '{key}' trong atlas.";
return false;
}
copy.FrameSprites[f] = spr;
}
snapshot.Entries.Add(copy);
}
return true;
}
finally
{
if (readable != null && readable != sourceAtlas)
Object.DestroyImmediate(readable);
}
}
private static bool ApplyMultipleSpriteSheet(
string assetPath,
int texW,
int texH,
int cellW,
int cellH,
int nNumX,
int nNumY,
out string error)
{
error = null;
var importer = AssetImporter.GetAtPath(assetPath) as TextureImporter;
if (importer == null)
{
error = $"Không mở TextureImporter: {assetPath}";
return false;
}
importer.textureType = TextureImporterType.Sprite;
importer.spriteImportMode = SpriteImportMode.Multiple;
importer.spritePixelsPerUnit = 100;
importer.mipmapEnabled = false;
importer.alphaIsTransparency = true;
importer.maxTextureSize = Mathf.Max(importer.maxTextureSize, Mathf.Max(texW, texH));
importer.spritesheet = BuildSpriteMetaData(texW, texH, cellW, cellH, nNumX, nNumY);
importer.SetPlatformTextureSettings(new TextureImporterPlatformSettings
{
name = "Default",
overridden = false
});
importer.SaveAndReimport();
return true;
}
private static SpriteMetaData[] BuildSpriteMetaData(int texW, int texH, int cellW, int cellH, int nNumX, int nNumY)
{
var list = new List<SpriteMetaData>(nNumX * nNumY);
for (int row = 0; row < nNumY; row++)
{
for (int col = 0; col < nNumX; col++)
{
int cellIndex = row * nNumX + col;
float x = col * cellW;
float y = texH - (row + 1) * cellH;
list.Add(new SpriteMetaData
{
name = CellSpriteName(cellIndex),
rect = new Rect(x, y, cellW, cellH),
pivot = new Vector2(0.5f, 0.5f),
alignment = (int)SpriteAlignment.Center,
border = Vector4.zero
});
}
}
return list.ToArray();
}
private static Dictionary<string, Sprite> LoadSpritesByName(string atlasAssetPath)
{
var map = new Dictionary<string, Sprite>();
foreach (var o in AssetDatabase.LoadAllAssetsAtPath(atlasAssetPath))
{
if (o is Sprite sp && !string.IsNullOrEmpty(sp.name))
map[sp.name] = sp;
}
return map;
}
public static void EnsureFolder(string folder)
{
if (AssetDatabase.IsValidFolder(folder))
return;
string parent = "Assets";
foreach (var part in folder.Replace('\\', '/').Split('/'))
{
if (string.IsNullOrEmpty(part) || part == "Assets")
continue;
string newPath = parent + "/" + part;
if (!AssetDatabase.IsValidFolder(newPath))
AssetDatabase.CreateFolder(parent, part);
parent = newPath;
}
}
public static void ApplySnapshotToSetSO(EmotionSetDataSO so, EmotionSetSnapshot snap)
{
if (so == null || snap == null) return;
so.EmotionSetIndex = snap.EmotionSetIndex;
so.CellWidth = snap.CellWidth;
so.CellHeight = snap.CellHeight;
so.SourceAtlas = snap.SourceAtlas;
so.SourceTxtAssetPath = snap.SourceTxtAssetPath;
so.Entries.Clear();
foreach (var e in snap.Entries)
so.Entries.Add(e);
}
/// <summary>
/// Kiểm tra trùng EmotionSetIndex giữa các slot đang có đủ Atlas+Txt.
/// </summary>
public static bool TryValidateBatchIndices(IReadOnlyList<EmotionBatchSlot> slots, out string error)
{
error = null;
var used = new HashSet<int>();
foreach (var s in slots)
{
if (s?.Atlas == null && s?.Txt == null)
continue;
if (s.Atlas == null || s.Txt == null)
continue;
if (!used.Add(s.EmotionSetIndex))
{
error = $"Trùng Emotion set index: {s.EmotionSetIndex}";
return false;
}
}
return true;
}
private static Texture2D GetReadableTexture(Texture2D atlas)
{
try
{
var dup = new Texture2D(atlas.width, atlas.height, TextureFormat.RGBA32, false);
var prev = RenderTexture.active;
var rt = RenderTexture.GetTemporary(atlas.width, atlas.height, 0, RenderTextureFormat.ARGB32);
Graphics.Blit(atlas, rt);
RenderTexture.active = rt;
dup.ReadPixels(new Rect(0, 0, atlas.width, atlas.height), 0, 0);
dup.Apply(false, false);
RenderTexture.active = prev;
RenderTexture.ReleaseTemporary(rt);
return dup;
}
catch
{
return null;
}
}
}
/// <summary>
/// Một dòng batch: index bộ (N) + atlas + txt — có thể thêm/bớt trong cửa sổ tool.
/// </summary>
[System.Serializable]
public class EmotionBatchSlot
{
[Tooltip("N trong Emotions{N} (thư mục output & tên file atlas copy).")]
public int EmotionSetIndex;
public Texture2D Atlas;
public TextAsset Txt;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 38dfc1555b7238a4c99647d8d8dcb7e4
@@ -0,0 +1,229 @@
using System.Collections.Generic;
using BrewMonster.Scripts.Chat.EmotionData;
using UnityEditor;
using UnityEngine;
namespace BrewMonster.Scripts.Editor
{
/// <summary>
/// Tool: atlas + txt → Sprite Mode Multiple (một PNG/bộ) + SO; batch linh hoạt số slot.
/// </summary>
public class EmotionAtlasConverterWindow : EditorWindow
{
private Texture2D _sourceAtlas;
private TextAsset _txtAsset;
private int _emotionSetIndex;
private int _cellW = 32;
private int _cellH = 32;
private string _outputFolder = "Assets/PerfectWorld/UI/Chat/GeneratedEmotions";
private bool _sliceInPlace;
private List<EmotionBatchSlot> _batchSlots = new List<EmotionBatchSlot>();
private Vector2 _batchScroll;
private string _libraryAssetPath = "Assets/PerfectWorld/UI/Chat/GeneratedEmotions/EmotionLibrary.asset";
[MenuItem("Tools/Perfect World/ChatSystem/Emotion Atlas Converter…")]
public static void Open()
{
GetWindow<EmotionAtlasConverterWindow>(true, "Emotion Atlas Converter", true);
}
private void OnEnable()
{
if (_batchSlots == null)
_batchSlots = new List<EmotionBatchSlot>();
if (_batchSlots.Count == 0)
{
for (int i = 0; i < 8; i++)
_batchSlots.Add(new EmotionBatchSlot { EmotionSetIndex = i });
}
}
private void OnGUI()
{
EditorGUILayout.LabelField("Chung / Shared", EditorStyles.boldLabel);
_cellW = EditorGUILayout.IntField("Cell width (W)", _cellW);
_cellH = EditorGUILayout.IntField("Cell height (H)", _cellH);
_outputFolder = EditorGUILayout.TextField("Output folder", _outputFolder);
_sliceInPlace = EditorGUILayout.ToggleLeft(
"Slice tại asset nguồn (ghi đè import Multiple lên atlas đang chọn) — Slice in-place on source asset",
_sliceInPlace);
if (_sliceInPlace)
{
EditorGUILayout.HelpBox(
"Cảnh báo: Unity sẽ đổi import của file atlas gốc thành Sprite Multiple full lưới.\n" +
"Warning: overwrites source texture import settings.",
MessageType.Warning);
}
else
{
EditorGUILayout.HelpBox(
"Mặc định: copy atlas vào Output/Emotions{N}/Emotions{N}_atlas.png rồi Multiple slice (giữ nguyên file nguồn).\n" +
"Default: copy atlas to output folder then slice (source unchanged).",
MessageType.None);
}
EditorGUILayout.Space(8f);
EditorGUILayout.LabelField("Một bộ / Single set", EditorStyles.boldLabel);
_sourceAtlas = (Texture2D)EditorGUILayout.ObjectField("Atlas", _sourceAtlas, typeof(Texture2D), false);
_txtAsset = (TextAsset)EditorGUILayout.ObjectField("EmotionsN.txt", _txtAsset, typeof(TextAsset), false);
_emotionSetIndex = EditorGUILayout.IntField("Emotion set index (N)", _emotionSetIndex);
EditorGUILayout.Space();
if (GUILayout.Button("Convert → EmotionSetData + Atlas (Multiple)"))
{
RunConvertSingle();
}
EditorGUILayout.Space(12f);
EditorGUILayout.LabelField("Batch → EmotionLibrary", EditorStyles.boldLabel);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("+ Thêm slot", GUILayout.Width(100)))
_batchSlots.Add(new EmotionBatchSlot { EmotionSetIndex = NextSuggestedSetIndex() });
if (GUILayout.Button(" Xóa slot cuối", GUILayout.Width(120)) && _batchSlots.Count > 0)
_batchSlots.RemoveAt(_batchSlots.Count - 1);
EditorGUILayout.LabelField($"Số slot: {_batchSlots.Count}");
EditorGUILayout.EndHorizontal();
_batchScroll = EditorGUILayout.BeginScrollView(_batchScroll, GUILayout.MinHeight(160f));
for (int i = 0; i < _batchSlots.Count; i++)
{
var slot = _batchSlots[i];
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField($"#{i}", GUILayout.Width(24));
slot.EmotionSetIndex = EditorGUILayout.IntField("Set N", slot.EmotionSetIndex, GUILayout.Width(120));
slot.Atlas = (Texture2D)EditorGUILayout.ObjectField(slot.Atlas, typeof(Texture2D), false);
slot.Txt = (TextAsset)EditorGUILayout.ObjectField(slot.Txt, typeof(TextAsset), false);
if (GUILayout.Button("×", GUILayout.Width(22)))
{
_batchSlots.RemoveAt(i);
i--;
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndVertical();
continue;
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndVertical();
}
EditorGUILayout.EndScrollView();
_libraryAssetPath = EditorGUILayout.TextField("Library .asset path", _libraryAssetPath);
EditorGUILayout.Space();
if (GUILayout.Button("Convert batch → EmotionLibrary.asset"))
{
RunConvertLibrary();
}
EditorGUILayout.Space(8f);
EditorGUILayout.HelpBox(
"Mỗi bộ: một texture **Sprite Multiple**, tên sub-sprite `cell_0000` … theo chỉ số ô (giống C++).\n" +
"Each set: one **Sprite Multiple** texture; sub-sprite names `cell_0000` … by cell index.",
MessageType.Info);
}
private int NextSuggestedSetIndex()
{
int max = -1;
foreach (var s in _batchSlots)
{
if (s.EmotionSetIndex > max)
max = s.EmotionSetIndex;
}
return max + 1;
}
private void RunConvertSingle()
{
if (!EmotionAtlasConverterCore.ConvertOneSet(
_sourceAtlas, _txtAsset, _emotionSetIndex, _cellW, _cellH, _outputFolder, _sliceInPlace,
out var snapshot, out string err))
{
EditorUtility.DisplayDialog("Error", err, "OK");
return;
}
var so = ScriptableObject.CreateInstance<EmotionSetDataSO>();
EmotionAtlasConverterCore.ApplySnapshotToSetSO(so, snapshot);
string setFolder = $"{_outputFolder}/Emotions{_emotionSetIndex}";
string soPath = $"{setFolder}/EmotionSetData_{_emotionSetIndex}.asset";
AssetDatabase.CreateAsset(so, soPath);
AssetDatabase.SaveAssets();
EditorUtility.DisplayDialog("Done", $"Đã tạo:\n{soPath}\nAtlas (Multiple) trong thư mục set (hoặc đã slice tại nguồn).", "OK");
EditorGUIUtility.PingObject(so);
}
private void RunConvertLibrary()
{
if (!EmotionAtlasConverterCore.TryValidateBatchIndices(_batchSlots, out string dupErr))
{
EditorUtility.DisplayDialog("Error", dupErr, "OK");
return;
}
var library = ScriptableObject.CreateInstance<EmotionLibrarySO>();
library.Sets.Clear();
int ok = 0;
int total = _batchSlots.Count;
for (int i = 0; i < total; i++)
{
var slot = _batchSlots[i];
if (slot.Atlas == null && slot.Txt == null)
continue;
if (slot.Atlas == null || slot.Txt == null)
{
EditorUtility.DisplayDialog("Error",
$"Slot #{i} (Set N={slot.EmotionSetIndex}): cần cả Atlas và TXT.",
"OK");
Object.DestroyImmediate(library);
return;
}
EditorUtility.DisplayProgressBar("Emotion Library", $"Set {slot.EmotionSetIndex}…", (float)i / Mathf.Max(1, total));
if (!EmotionAtlasConverterCore.ConvertOneSet(
slot.Atlas, slot.Txt, slot.EmotionSetIndex, _cellW, _cellH, _outputFolder, _sliceInPlace,
out var snapshot, out string err))
{
EditorUtility.ClearProgressBar();
EditorUtility.DisplayDialog("Error", $"Set {slot.EmotionSetIndex}: {err}", "OK");
Object.DestroyImmediate(library);
return;
}
library.Sets.Add(snapshot);
ok++;
}
EditorUtility.ClearProgressBar();
if (ok == 0)
{
EditorUtility.DisplayDialog("Error", "Không có cặp Atlas+TXT hợp lệ.", "OK");
Object.DestroyImmediate(library);
return;
}
string dir = System.IO.Path.GetDirectoryName(_libraryAssetPath)?.Replace('\\', '/') ?? _outputFolder;
if (!string.IsNullOrEmpty(dir))
EmotionAtlasConverterCore.EnsureFolder(dir);
AssetDatabase.CreateAsset(library, _libraryAssetPath);
AssetDatabase.SaveAssets();
EditorUtility.DisplayDialog("Done",
$"EmotionLibrary: {ok} bộ.\n{_libraryAssetPath}",
"OK");
EditorGUIUtility.PingObject(library);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bd89b10678fe79046af39970b5c83b4c