using UnityEngine; namespace BrewMonster.Scripts.Chat.EmotionData { /// /// Ánh xạ (emotionSet, emotionIndex) từ sang tag TMP. /// Maps (emotionSet, emotionIndex) from EmotionLibrarySO to TMP rich-text tag. /// /// Atlas được cắt trái→phải, trên→dưới, sub-sprite đặt tên cell_XXXX. /// Số XXXX trong tên chính là index tuyến tính → không cần tra spriteCharacterTable. /// Atlas is sliced left-to-right, top-to-bottom, named cell_XXXX. /// The number XXXX in the name IS the linear index → no spriteCharacterTable lookup needed. /// /// Cách dùng: /// 1) Tạo EmotionLibrarySO bằng Emotion Atlas Converter. /// 2) Gán TMP_SpriteAsset cho từng set vào EmotionSetSnapshot.TmpSpriteAsset trong Library /// (chỉ cần cho emoji động nhiều frame hoặc khi PreferSpriteNameTag = false). /// OnValidate / SetEmotionSpriteMap đồng bộ TmpSpriteAssetName từ asset — English: sync cached name so network thread never calls GetName(). /// 3) Kéo SO này vào field trên GameObject có CECUIManager (Awake gọi SetEmotionSpriteMap). /// [CreateAssetMenu(fileName = "EmotionLibrarySpriteMap", menuName = "Perfect World/Chat/Emotion Library Sprite Map", order = 2)] public class EmotionLibrarySpriteMap : ScriptableObject, IEmotionSpriteMap { [Tooltip("Dữ liệu emotion đã convert. Emotion data converted by the atlas tool.")] public EmotionLibrarySO Library; private void OnValidate() { EnsureCachedTmpSpriteAssetNames(); } /// /// 主线程:把 Library 里各 TmpSpriteAsset.name 写入快照字段,供 TryGetSprite 在任意线程读取。 /// Main thread: copy TmpSpriteAsset.name into snapshot fields so TryGetSprite can run off the main thread. /// public void EnsureCachedTmpSpriteAssetNames() { Library?.SyncCachedTmpSpriteAssetNamesFromObjects(); } [Tooltip("Nếu true và emoji chỉ 1 frame: dùng thay vì . " + "If true and single-frame: use instead of .")] public bool PreferSpriteNameTag = true; [Tooltip("FPS mặc định cho khi không suy ra được từ FrameTicks. " + "Default FPS for when it cannot be derived from FrameTicks.")] public int DefaultAnimFps = 10; // ───────────────────────────────────────────────────────────────────── // IEmotionSpriteMap // ───────────────────────────────────────────────────────────────────── public bool TryGetSprite(int emotionSet, int emotionIndex, out EmotionSpriteInfo info) { info = default; if (Library == null) { Debug.LogWarning($"[EmotionLibrarySpriteMap] TryGetSprite({emotionSet},{emotionIndex}) → FAIL: Library == null. Gán EmotionLibrarySO vào field Library."); return false; } EmotionSetSnapshot set = Library.GetSetOrNull(emotionSet); if (set == null) { Debug.LogWarning($"[EmotionLibrarySpriteMap] TryGetSprite({emotionSet},{emotionIndex}) → FAIL: Không tìm thấy set index={emotionSet}. " + $"Sets hiện có: [{string.Join(", ", Library.Sets?.ConvertAll(s => s?.EmotionSetIndex.ToString() ?? "null") ?? new System.Collections.Generic.List())}]"); return false; } // 禁止 TmpSpriteAsset.name:GetName() 仅主线程(网络收包线程会调 TryGetSprite)。 // Never use TmpSpriteAsset.name here — GetName() is main-thread-only (network receive may call TryGetSprite). string spriteAssetName = set.TmpSpriteAssetName ?? string.Empty; if (emotionIndex < 0 || emotionIndex >= set.Entries.Count) { Debug.LogWarning($"[EmotionLibrarySpriteMap] TryGetSprite({emotionSet},{emotionIndex}) → FAIL: emotionIndex={emotionIndex} ngoài range [0, {set.Entries.Count - 1}]."); return false; } EmotionEntryData entry = set.Entries[emotionIndex]; Sprite[] frames = entry.FrameSprites; if (frames == null || frames.Length == 0) { Debug.LogWarning($"[EmotionLibrarySpriteMap] TryGetSprite({emotionSet},{emotionIndex}) → FAIL: entry.FrameSprites null/empty. Chạy lại Emotion Atlas Converter để populate FrameSprites."); return false; } // Dùng StartPos/NumFrames từ txt (khớp EmotionAtlasConverterCore: cellIndex = StartPos + f). // Use StartPos/NumFrames from txt (matches EmotionAtlasConverterCore: cellIndex = StartPos + f). // Không đọc Sprite.name — GetName() chỉ được gọi trên main thread (vd. gói chat từ network thread). // Do not read Sprite.name — GetName() is main-thread-only (e.g. chat packets on network thread). int startCell = entry.StartPos; int frameCount = frames.Length; if (startCell < 0 || frameCount < 1) { Debug.LogWarning($"[EmotionLibrarySpriteMap] TryGetSprite({emotionSet},{emotionIndex}) → FAIL: StartPos={startCell} hoặc không có frame."); return false; } // ── Single frame ────────────────────────────────────────────────── if (frameCount == 1) { if (frames[0] == null) { Debug.LogWarning($"[EmotionLibrarySpriteMap] TryGetSprite({emotionSet},{emotionIndex}) → FAIL: frames[0] == null."); return false; } if (PreferSpriteNameTag) { // — TMP tra tên trong SpriteAsset được gán trên component. // — TMP looks up the name in the SpriteAsset on the component. info = new EmotionSpriteInfo { SpriteAssetName = spriteAssetName, UseSpriteName = true, SpriteName = FormatCellSpriteName(startCell), IsAnimated = false }; return true; } info = new EmotionSpriteInfo { SpriteAssetName = spriteAssetName, SpriteIndex = startCell, IsAnimated = false }; return true; } // ── Multi-frame (animated) ──────────────────────────────────────── int endCell = startCell + frameCount - 1; int fps = EstimateFps(entry.FrameTicks, frameCount); info = new EmotionSpriteInfo { SpriteAssetName = spriteAssetName, SpriteIndex = startCell, IsAnimated = true, AnimEndFrame = endCell, AnimFPS = fps > 0 ? fps : DefaultAnimFps }; return true; } // ───────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────── /// /// Tên ô giống tool atlas: "cell_XXXX" (4 chữ số). Không đọc Unity Sprite.name. /// Same cell naming as atlas tool: "cell_XXXX" (4 digits). Does not read Unity Sprite.name. /// private static string FormatCellSpriteName(int cellIndex) => $"cell_{cellIndex:D4}"; private static int EstimateFps(int[] frameTicks, int numFrames) { if (frameTicks == null || numFrames < 2 || frameTicks.Length < numFrames) return 0; int span = frameTicks[numFrames - 1] - frameTicks[0]; if (span <= 0) return 0; return Mathf.Max(1, Mathf.RoundToInt((numFrames - 1) * 60f / span)); } } }