diff --git a/Assets/PerfectWorld/Scripts/Editor.meta b/Assets/PerfectWorld/Scripts/Editor.meta
new file mode 100644
index 0000000000..5b43d058a5
--- /dev/null
+++ b/Assets/PerfectWorld/Scripts/Editor.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 63ad32ae8f64ef1449e51fe4460570a8
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/PerfectWorld/Scripts/Editor/EmotionAtlasConverterCore.cs b/Assets/PerfectWorld/Scripts/Editor/EmotionAtlasConverterCore.cs
new file mode 100644
index 0000000000..a03273dd3f
--- /dev/null
+++ b/Assets/PerfectWorld/Scripts/Editor/EmotionAtlasConverterCore.cs
@@ -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
+{
+ ///
+ /// 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).
+ ///
+ public static class EmotionAtlasConverterCore
+ {
+ public const string CellSpriteNamePrefix = "cell_";
+
+ public static string CellSpriteName(int cellIndex) => $"{CellSpriteNamePrefix}{cellIndex:D4}";
+
+ /// 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).
+ 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(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(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()
+ };
+
+ 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(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 LoadSpritesByName(string atlasAssetPath)
+ {
+ var map = new Dictionary();
+ 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);
+ }
+
+ ///
+ /// Kiểm tra trùng EmotionSetIndex giữa các slot đang có đủ Atlas+Txt.
+ ///
+ public static bool TryValidateBatchIndices(IReadOnlyList slots, out string error)
+ {
+ error = null;
+ var used = new HashSet();
+ 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;
+ }
+ }
+ }
+
+ ///
+ /// Một dòng batch: index bộ (N) + atlas + txt — có thể thêm/bớt trong cửa sổ tool.
+ ///
+ [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;
+ }
+}
diff --git a/Assets/PerfectWorld/Scripts/Editor/EmotionAtlasConverterCore.cs.meta b/Assets/PerfectWorld/Scripts/Editor/EmotionAtlasConverterCore.cs.meta
new file mode 100644
index 0000000000..b1534b5a99
--- /dev/null
+++ b/Assets/PerfectWorld/Scripts/Editor/EmotionAtlasConverterCore.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 38dfc1555b7238a4c99647d8d8dcb7e4
\ No newline at end of file
diff --git a/Assets/PerfectWorld/Scripts/Editor/EmotionAtlasConverterWindow.cs b/Assets/PerfectWorld/Scripts/Editor/EmotionAtlasConverterWindow.cs
new file mode 100644
index 0000000000..f634945feb
--- /dev/null
+++ b/Assets/PerfectWorld/Scripts/Editor/EmotionAtlasConverterWindow.cs
@@ -0,0 +1,229 @@
+using System.Collections.Generic;
+using BrewMonster.Scripts.Chat.EmotionData;
+using UnityEditor;
+using UnityEngine;
+
+namespace BrewMonster.Scripts.Editor
+{
+ ///
+ /// Tool: atlas + txt → Sprite Mode Multiple (một PNG/bộ) + SO; batch linh hoạt số slot.
+ ///
+ 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 _batchSlots = new List();
+ 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(true, "Emotion Atlas Converter", true);
+ }
+
+ private void OnEnable()
+ {
+ if (_batchSlots == null)
+ _batchSlots = new List();
+ 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();
+ 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();
+ 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);
+ }
+ }
+}
diff --git a/Assets/PerfectWorld/Scripts/Editor/EmotionAtlasConverterWindow.cs.meta b/Assets/PerfectWorld/Scripts/Editor/EmotionAtlasConverterWindow.cs.meta
new file mode 100644
index 0000000000..0ed9c9e814
--- /dev/null
+++ b/Assets/PerfectWorld/Scripts/Editor/EmotionAtlasConverterWindow.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: bd89b10678fe79046af39970b5c83b4c
\ No newline at end of file