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