diff --git a/Assets/PerfectWorld/Prefab/UIManager.prefab b/Assets/PerfectWorld/Prefab/UIManager.prefab index 2443099e56..412f0b8f74 100644 --- a/Assets/PerfectWorld/Prefab/UIManager.prefab +++ b/Assets/PerfectWorld/Prefab/UIManager.prefab @@ -1522,7 +1522,7 @@ RectTransform: m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0.5, y: 1} m_AnchorMax: {x: 0.5, y: 1} - m_AnchoredPosition: {x: -187.8, y: -47.6} + m_AnchoredPosition: {x: -187.8, y: -47.600098} m_SizeDelta: {x: 191, y: 60} m_Pivot: {x: 0.5, y: 0.5} --- !u!222 &6965363531247653623 @@ -4825,6 +4825,7 @@ RectTransform: m_Children: - {fileID: 8741686998992894603} - {fileID: 3488899534283412697} + - {fileID: 4668526399359599056} - {fileID: 9056141770234008732} - {fileID: 6541409353547558602} - {fileID: 2907261990866691440} @@ -6262,6 +6263,7 @@ MonoBehaviour: m_EditorClassIdentifier: inputField: {fileID: 9217902013627304316} chatSystem: {fileID: 0} + _spriteMap: {fileID: 0} channelButtons: [] --- !u!1 &3544484534608324905 GameObject: @@ -8700,8 +8702,8 @@ MonoBehaviour: - {fileID: 2971821658315981769} - {fileID: 452969679978752531} - {fileID: 3647934876571221831} - HpItemButton: {fileID: 7382895648940793749} - MpItemButton: {fileID: 7614763976671640739} + HpItemButton: {fileID: 0} + MpItemButton: {fileID: 0} m_nCurPanel1: 1 m_nCurPanel2: 1 m_bShowAll1: 0 @@ -10710,7 +10712,7 @@ RectTransform: m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0.5} m_AnchorMax: {x: 0, y: 0.5} - m_AnchoredPosition: {x: 60.559204, y: -126.849976} + m_AnchoredPosition: {x: 60.559204, y: -126.8501} m_SizeDelta: {x: 101.855, y: 57.3622} m_Pivot: {x: 0.5, y: 0.5} --- !u!222 &3863452661654338700 @@ -11648,7 +11650,6 @@ MonoBehaviour: _btnTaskTrace: {fileID: 3253955040536933532} _taskTraceParent: {fileID: 2578159539417438268} _btnTeamList: {fileID: 540188344648694736} - _lockviewList: {fileID: 0} --- !u!1 &7352847439676120744 GameObject: m_ObjectHideFlags: 0 @@ -18712,7 +18713,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 8435310359341866937, guid: b5a4a3ed5bf0e5a49ba0f89d26e1f36e, type: 3} propertyPath: m_AnchoredPosition.y - value: -25.335571 + value: -25.33545 objectReference: {fileID: 0} - target: {fileID: 8579427623307909814, guid: b5a4a3ed5bf0e5a49ba0f89d26e1f36e, type: 3} propertyPath: m_AnchorMax.y @@ -18948,6 +18949,14 @@ PrefabInstance: propertyPath: m_AnchoredPosition.y value: 0 objectReference: {fileID: 0} + - target: {fileID: 2621697629504226575, guid: a531b4b63ab8355419f297fe10d32abe, type: 3} + propertyPath: miniChatContent + value: + objectReference: {fileID: 3171192075315926561} + - target: {fileID: 2621697629504226575, guid: a531b4b63ab8355419f297fe10d32abe, type: 3} + propertyPath: onOpenChatPanelButton + value: + objectReference: {fileID: 1884013949825403952} - target: {fileID: 2818704151482351807, guid: a531b4b63ab8355419f297fe10d32abe, type: 3} propertyPath: m_AnchorMax.y value: 0 @@ -20714,6 +20723,124 @@ RectTransform: m_CorrespondingSourceObject: {fileID: 4475312012745311020, guid: c82978c3789dad44da354dc354c782b2, type: 3} m_PrefabInstance: {fileID: 8244659259478137406} m_PrefabAsset: {fileID: 0} +--- !u!1001 &8343482093857271211 +PrefabInstance: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_Modification: + serializedVersion: 3 + m_TransformParent: {fileID: 3233441867675090637} + m_Modifications: + - target: {fileID: 843061721837273778, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + propertyPath: m_Name + value: prefab_MiniChat + objectReference: {fileID: 0} + - target: {fileID: 3676046446549609595, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + propertyPath: m_Pivot.x + value: 0.5 + objectReference: {fileID: 0} + - target: {fileID: 3676046446549609595, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + propertyPath: m_Pivot.y + value: 0.5 + objectReference: {fileID: 0} + - target: {fileID: 3676046446549609595, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + propertyPath: m_AnchorMax.x + value: 1 + objectReference: {fileID: 0} + - target: {fileID: 3676046446549609595, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + propertyPath: m_AnchorMax.y + value: 1 + objectReference: {fileID: 0} + - target: {fileID: 3676046446549609595, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + propertyPath: m_AnchorMin.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3676046446549609595, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + propertyPath: m_AnchorMin.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3676046446549609595, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + propertyPath: m_SizeDelta.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3676046446549609595, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3676046446549609595, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + propertyPath: m_LocalPosition.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3676046446549609595, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + propertyPath: m_LocalPosition.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3676046446549609595, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + propertyPath: m_LocalPosition.z + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3676046446549609595, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + propertyPath: m_LocalRotation.w + value: 1 + objectReference: {fileID: 0} + - target: {fileID: 3676046446549609595, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + propertyPath: m_LocalRotation.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3676046446549609595, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + propertyPath: m_LocalRotation.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3676046446549609595, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + propertyPath: m_LocalRotation.z + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3676046446549609595, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + propertyPath: m_AnchoredPosition.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3676046446549609595, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + propertyPath: m_AnchoredPosition.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3676046446549609595, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + propertyPath: m_LocalEulerAnglesHint.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3676046446549609595, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + propertyPath: m_LocalEulerAnglesHint.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3676046446549609595, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + propertyPath: m_LocalEulerAnglesHint.z + value: 0 + objectReference: {fileID: 0} + m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] + m_SourcePrefab: {fileID: 100100000, guid: 05206a01b3910384cb9a17c74225d554, type: 3} +--- !u!114 &1884013949825403952 stripped +MonoBehaviour: + m_CorrespondingSourceObject: {fileID: 7633421558195200411, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + m_PrefabInstance: {fileID: 8343482093857271211} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} + m_Name: + m_EditorClassIdentifier: +--- !u!224 &3171192075315926561 stripped +RectTransform: + m_CorrespondingSourceObject: {fileID: 6901861700741151626, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + m_PrefabInstance: {fileID: 8343482093857271211} + m_PrefabAsset: {fileID: 0} +--- !u!224 &4668526399359599056 stripped +RectTransform: + m_CorrespondingSourceObject: {fileID: 3676046446549609595, guid: 05206a01b3910384cb9a17c74225d554, type: 3} + m_PrefabInstance: {fileID: 8343482093857271211} + m_PrefabAsset: {fileID: 0} --- !u!1001 &8508566894086459119 PrefabInstance: m_ObjectHideFlags: 0 diff --git a/Assets/PerfectWorld/ScriptableObjects/ChatSystems/EmotionLibrarySpriteMap.asset b/Assets/PerfectWorld/ScriptableObjects/ChatSystems/EmotionLibrarySpriteMap.asset index c873bc9b2b..de5f612de4 100644 --- a/Assets/PerfectWorld/ScriptableObjects/ChatSystems/EmotionLibrarySpriteMap.asset +++ b/Assets/PerfectWorld/ScriptableObjects/ChatSystems/EmotionLibrarySpriteMap.asset @@ -12,7 +12,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: af2fa24fb63c4aa45bb99a711c857114, type: 3} m_Name: EmotionLibrarySpriteMap m_EditorClassIdentifier: - Library: {fileID: 11400000, guid: 3011939e4e9c0ce4e83bc03a748fcf96, type: 2} - TmpSpriteAsset: {fileID: 11400000, guid: c41005c129ba4d66911b75229fd70b45, type: 2} + Library: {fileID: 11400000, guid: e4fbe1473e80a9c4794327d8bb11a4ab, type: 2} + TmpSpriteAsset: {fileID: 11400000, guid: ee97404ddf0b24345809ec3b36f78e02, type: 2} PreferSpriteNameTag: 1 DefaultAnimFps: 10 diff --git a/Assets/PerfectWorld/Scripts/Chat/ChatEmotionDisplayPipeline.cs b/Assets/PerfectWorld/Scripts/Chat/ChatEmotionDisplayPipeline.cs index 6224c4e22c..1838fd338e 100644 --- a/Assets/PerfectWorld/Scripts/Chat/ChatEmotionDisplayPipeline.cs +++ b/Assets/PerfectWorld/Scripts/Chat/ChatEmotionDisplayPipeline.cs @@ -1,4 +1,6 @@ +using System.Text.RegularExpressions; using CSNetwork; +using UnityEngine; namespace BrewMonster.Scripts.Chat { @@ -8,16 +10,44 @@ namespace BrewMonster.Scripts.Chat /// public sealed class ChatEmotionDisplayPipeline { + /// + /// Khi thiếu ký tự PUA trước payload, Unmarshal không chạy nhưng vẫn còn <0><set:index> (Serialize). + /// When PUA prefix is missing, Unmarshal skips but <0><set:index> (Serialize) may remain. + /// + private static readonly Regex LooseMarshaledEmotionRegex = new Regex( + @"<0><(\d+):(\d+)>", + RegexOptions.Compiled); + private IEmotionSpriteMap _spriteMap = new StubEmotionSpriteMap(); + /// + /// Wire 正文(服务器 / 输入缓冲)→ TMP 显示用富文本 — 与 CECGameUIMan.AddChatMessage 内转换一致(不含 FilterBadWords)。 + /// Wire body (server / input buffer) → TMP rich text for display — same as CECGameUIMan.AddChatMessage (without FilterBadWords). + /// + public static string ConvertWireBodyToTmpDisplay(string wireBody, IEmotionSpriteMap map, int cEmotion) + { + if (string.IsNullOrEmpty(wireBody)) + return ""; + if (map == null) + return wireBody; + + var p = new ChatEmotionDisplayPipeline(map); + string filtered = p.ApplyChannelEmotionFilter(wireBody, cEmotion); + return p.ConvertInlineItemsToTmp(filtered); + } + public ChatEmotionDisplayPipeline(IEmotionSpriteMap spriteMap = null) { if (spriteMap != null) _spriteMap = spriteMap; + else + BMLogger.LogWarning("[ChatEmotionDisplayPipeline] spriteMap is null — using StubEmotionSpriteMap. Call SetSpriteMap() or assign EmotionLibrarySpriteMap SO on CECUIManager."); } public void SetSpriteMap(IEmotionSpriteMap spriteMap) { + if (spriteMap == null) + BMLogger.LogWarning("[ChatEmotionDisplayPipeline] SetSpriteMap() called with null — EmotionLibrarySpriteMap SO chưa được gán trên CECUIManager Inspector."); _spriteMap = spriteMap ?? new StubEmotionSpriteMap(); } @@ -35,15 +65,38 @@ namespace BrewMonster.Scripts.Chat { EditBoxItemsSet itemsSet = new EditBoxItemsSet(); string displayText = AUICommon.UnmarshalEditBoxText(pszMsgAfterBadWordFilter, itemsSet); + string tmpText; if (itemsSet.GetItemCount() <= 0) - return pszMsgAfterBadWordFilter; + tmpText = pszMsgAfterBadWordFilter; + else + { + tmpText = displayText; + tmpText = AUICommon.ConvertEmotionsToTMP(tmpText, itemsSet, _spriteMap); + tmpText = AUICommon.ConvertCoordsToTMP(tmpText, itemsSet); + tmpText = AUICommon.ConvertIvtrItemsToTMP(tmpText, itemsSet); + tmpText = AUICommon.StripRemainingItemCodes(tmpText); + } - string tmpText = displayText; - tmpText = AUICommon.ConvertEmotionsToTMP(tmpText, itemsSet, _spriteMap); - tmpText = AUICommon.ConvertCoordsToTMP(tmpText, itemsSet); - tmpText = AUICommon.ConvertIvtrItemsToTMP(tmpText, itemsSet); - tmpText = AUICommon.StripRemainingItemCodes(tmpText); - return tmpText; + return ReplaceLooseMarshaledEmotionTags(tmpText); + } + + /// + /// Fallback: wire emotion chỉ còn dạng Serialize <0><set:index> (không qua PUA) → TMP <sprite>. + /// Fallback: emotion wire as Serialize-only <0><set:index> (no PUA) → TMP <sprite>. + /// + private string ReplaceLooseMarshaledEmotionTags(string text) + { + if (string.IsNullOrEmpty(text) || _spriteMap == null) + return text; + + return LooseMarshaledEmotionRegex.Replace(text, match => + { + int set = int.Parse(match.Groups[1].Value); + int index = int.Parse(match.Groups[2].Value); + if (_spriteMap.TryGetSprite(set, index, out EmotionSpriteInfo info)) + return EmotionTMPTagBuilder.BuildSpriteTag(info); + return match.Value; + }); } } } diff --git a/Assets/PerfectWorld/Scripts/Chat/ChatWireTmpCodec.cs b/Assets/PerfectWorld/Scripts/Chat/ChatWireTmpCodec.cs new file mode 100644 index 0000000000..5b510f63cc --- /dev/null +++ b/Assets/PerfectWorld/Scripts/Chat/ChatWireTmpCodec.cs @@ -0,0 +1,100 @@ +using System.Text; +using System.Text.RegularExpressions; +using CSNetwork; + +namespace BrewMonster.Scripts.Chat +{ + /// + /// Chat 输入/协议:wire(MarshalEditBoxText)↔ TMP <sprite> 显示。 + /// Chat input/protocol: wire (MarshalEditBoxText) ↔ TMP <sprite> display. + /// + public static class ChatWireTmpCodec + { + private static readonly Regex SpriteTagRegex = new Regex(@"]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + /// + /// Tạo một đoạn wire marshal cho một emotion (set:index) — gửi server đúng protocol. + /// Build one marshaled wire segment for a single emotion (set:index) — correct server protocol. + /// + public static string BuildMarshaledEmotionWire(int emotionSet, int emotionIndex) + { + var item = new EditBoxItemBase(AUICommon.EditboxItemType.enumEIEmotion); + item.SetName("W"); + item.SetInfo(AUICommon.MarshalEmotionInfo(emotionSet, emotionIndex)); + var items = new EditBoxItemsSet(); + char c = items.AppendItem(item); + if (c == '\0') + return ""; + + string display = c.ToString(); + return AUICommon.MarshalEditBoxText(display, items); + } + + /// + /// TMP 正文(无频道前缀)→ wire marshal(用于发送)。 + /// TMP body text (no channel prefix) → marshaled wire (for sending). + /// + public static string TmpBodyToWire(string tmpBody, IEmotionSpriteMap map) + { + if (string.IsNullOrEmpty(tmpBody)) + return ""; + if (map == null) + return tmpBody; + + var sb = new StringBuilder(tmpBody.Length); + int last = 0; + foreach (Match m in SpriteTagRegex.Matches(tmpBody)) + { + sb.Append(tmpBody, last, m.Index - last); + string tag = m.Value; + if (TryMatchSpriteTagToEmotion(map, tag, out int es, out int ei)) + sb.Append(BuildMarshaledEmotionWire(es, ei)); + else + sb.Append(tag); + last = m.Index + m.Length; + } + + sb.Append(tmpBody, last, tmpBody.Length - last); + return sb.ToString(); + } + + /// + /// Khớp tag với EmotionTMPTagBuilder — duyệt (set,index) đủ nhỏ. + /// Match tag to EmotionTMPTagBuilder output — brute-force over (set,index) within reasonable bounds. + /// + public static bool TryMatchSpriteTagToEmotion(IEmotionSpriteMap map, string spriteTag, out int emotionSet, out int emotionIndex) + { + emotionSet = 0; + emotionIndex = 0; + if (map == null || string.IsNullOrEmpty(spriteTag)) + return false; + + string normalized = spriteTag.Trim(); + for (int s = 0; s < AUICommon.AUIMANAGER_MAX_EMOTIONGROUPS; s++) + { + for (int e = 0; e < 512; e++) + { + if (!EmotionTMPTagBuilder.TryBuildEmotionTag(map, s, e, out string built)) + continue; + if (built == normalized) + { + emotionSet = s; + emotionIndex = e; + return true; + } + } + } + + return false; + } + + /// + /// Wire → TMP 富文本(FilterEmotionSet + Unmarshal + ConvertInlineItemsToTmp)。 + /// Wire → TMP rich text (FilterEmotionSet + Unmarshal + ConvertInlineItemsToTmp). + /// + public static string WireBodyToTmpForDisplay(string wireBody, IEmotionSpriteMap map, int cEmotion) + { + return ChatEmotionDisplayPipeline.ConvertWireBodyToTmpDisplay(wireBody, map, cEmotion); + } + } +} diff --git a/Assets/PerfectWorld/Scripts/Chat/ChatWireTmpCodec.cs.meta b/Assets/PerfectWorld/Scripts/Chat/ChatWireTmpCodec.cs.meta new file mode 100644 index 0000000000..6ac771ec84 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/Chat/ChatWireTmpCodec.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9a9d1336ec810654dae6b1bb4076ae3a \ No newline at end of file diff --git a/Assets/PerfectWorld/Scripts/Chat/EmotionData/EmotionLibrarySO.cs b/Assets/PerfectWorld/Scripts/Chat/EmotionData/EmotionLibrarySO.cs index 2459b04feb..6b7760cf07 100644 --- a/Assets/PerfectWorld/Scripts/Chat/EmotionData/EmotionLibrarySO.cs +++ b/Assets/PerfectWorld/Scripts/Chat/EmotionData/EmotionLibrarySO.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using TMPro; using UnityEngine; namespace BrewMonster.Scripts.Chat.EmotionData @@ -16,6 +17,11 @@ namespace BrewMonster.Scripts.Chat.EmotionData public int CellHeight = 32; public Texture2D SourceAtlas; public string SourceTxtAssetPath = ""; + + [Tooltip("TMP_SpriteAsset tương ứng với atlas này (cell_0000…). Bắt buộc cho emoji động (nhiều frame). " + + "TMP_SpriteAsset matching this atlas (cell_0000…). Required for animated emoji (multi-frame).")] + public TMP_SpriteAsset TmpSpriteAsset; + public List Entries = new List(); } diff --git a/Assets/PerfectWorld/Scripts/Chat/EmotionData/EmotionLibrarySpriteMap.cs b/Assets/PerfectWorld/Scripts/Chat/EmotionData/EmotionLibrarySpriteMap.cs index 90b6f7477a..508683a1e8 100644 --- a/Assets/PerfectWorld/Scripts/Chat/EmotionData/EmotionLibrarySpriteMap.cs +++ b/Assets/PerfectWorld/Scripts/Chat/EmotionData/EmotionLibrarySpriteMap.cs @@ -1,120 +1,134 @@ -using System; -using TMPro; using UnityEngine; namespace BrewMonster.Scripts.Chat.EmotionData { /// /// Ánh xạ (emotionSet, emotionIndex) từ sang tag TMP. - /// Gán asset này vào (field emotion) — không phải MonoBehaviour nên không kéo SO trên Inspector được. + /// 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 bằng Emotion Atlas Converter. - /// 2) Tạo từ cùng atlas (Window → TextMeshPro → Sprite Importer), - /// đảm bảo tên sub-sprite khớp (vd. cell_0000) với atlas Multiple từ tool. - /// 3) Gán TMP_SpriteAsset vào ô chat (Sprite Asset / Additional). - /// 4) Kéo SO này vào field trên GameObject có (Awake gọi SetEmotionSpriteMap). + /// 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). + /// 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 (Entries + FrameSprites).")] + [Tooltip("Dữ liệu emotion đã convert. Emotion data converted by the atlas tool.")] public EmotionLibrarySO Library; - [Tooltip("Sprite Asset dùng cho chat TMP — cùng tên sub-sprite với atlas (cell_XXXX). Bắt buộc cho emoji động (anim) hoặc khi không dùng name tag.")] - public TMP_SpriteAsset TmpSpriteAsset; - - [Tooltip("Nếu true và chỉ 1 frame: thử (không cần tra index). Động (nhiều frame) vẫn cần TmpSpriteAsset để tra index.")] + [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.")] + [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 || emotionIndex < 0 || emotionIndex >= set.Entries.Count) + 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; + } + + 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) - return false; - - if (frames.Length == 1) { - Sprite s0 = frames[0]; - if (s0 == null) - return false; + Debug.LogWarning($"[EmotionLibrarySpriteMap] TryGetSprite({emotionSet},{emotionIndex}) → FAIL: entry.FrameSprites null/empty. Chạy lại Emotion Atlas Converter để populate FrameSprites."); + return false; + } - if (PreferSpriteNameTag && !string.IsNullOrEmpty(s0.name)) + // 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) { - info = new EmotionSpriteInfo - { - UseSpriteName = true, - SpriteName = s0.name, - IsAnimated = false - }; + 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 { UseSpriteName = true, SpriteName = FormatCellSpriteName(startCell), IsAnimated = false }; return true; } - int idx = FindSpriteIndexInTmpAsset(s0.name); - if (idx < 0) - return false; - - info = new EmotionSpriteInfo { SpriteIndex = idx, IsAnimated = false }; + info = new EmotionSpriteInfo { SpriteIndex = startCell, IsAnimated = false }; return true; } - if (TmpSpriteAsset == null) - return false; + // ── Multi-frame (animated) ──────────────────────────────────────── + int endCell = startCell + frameCount - 1; - int start = FindSpriteIndexInTmpAsset(frames[0]?.name); - int end = FindSpriteIndexInTmpAsset(frames[frames.Length - 1]?.name); - if (start < 0 || end < 0) - return false; - - int fps = EstimateFps(entry.FrameTicks, frames.Length); + int fps = EstimateFps(entry.FrameTicks, frameCount); info = new EmotionSpriteInfo { - SpriteIndex = start, - IsAnimated = true, - AnimEndFrame = end, - AnimFPS = fps > 0 ? fps : DefaultAnimFps + SpriteIndex = startCell, + IsAnimated = true, + AnimEndFrame = endCell, + AnimFPS = fps > 0 ? fps : DefaultAnimFps }; return true; } - private int FindSpriteIndexInTmpAsset(string spriteName) - { - if (TmpSpriteAsset == null || string.IsNullOrEmpty(spriteName)) - return -1; + // ───────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────── - if (TmpSpriteAsset.spriteInfoList != null) - { - for (int i = 0; i < TmpSpriteAsset.spriteInfoList.Count; i++) - { - var s = TmpSpriteAsset.spriteInfoList[i]; - if (s != null && s.name == spriteName) - return i; - } - } - - return -1; - } + /// + /// 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 last = frameTicks[numFrames - 1]; - int first = frameTicks[0]; - int span = last - first; + int span = frameTicks[numFrames - 1] - frameTicks[0]; if (span <= 0) return 0; return Mathf.Max(1, Mathf.RoundToInt((numFrames - 1) * 60f / span)); diff --git a/Assets/PerfectWorld/Scripts/Chat/EmotionTMPTagBuilder.cs b/Assets/PerfectWorld/Scripts/Chat/EmotionTMPTagBuilder.cs new file mode 100644 index 0000000000..d5ca589b56 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/Chat/EmotionTMPTagBuilder.cs @@ -0,0 +1,156 @@ +using TMPro; +using UnityEngine; + +namespace BrewMonster.Scripts.Chat +{ + /// + /// Helper tĩnh: chuyển EmotionSpriteInfo → TMP rich-text tag và insert vào TMP_InputField. + /// Static helper: converts EmotionSpriteInfo → TMP rich-text tag and inserts it into a TMP_InputField. + /// + /// Cách dùng điển hình / Typical usage: + /// + /// // Khi người dùng bấm chọn emoji từ picker: + /// // When user taps an emoji in the picker: + /// EmotionTMPTagBuilder.InsertEmotionTag(_chatInputField, _spriteMap, emotionSet, emotionIndex); + /// + /// // Hoặc chỉ lấy tag string rồi tự xử lý: + /// // Or just get the tag string and handle it yourself: + /// if (EmotionTMPTagBuilder.TryBuildEmotionTag(_spriteMap, set, idx, out string tag)) + /// Debug.Log(tag); // → "" hoặc "" + /// + /// + public static class EmotionTMPTagBuilder + { + // ───────────────────────────────────────────────────────────────────── + // Tag builders + // ───────────────────────────────────────────────────────────────────── + + /// + /// Tạo TMP rich-text tag từ EmotionSpriteInfo đã resolve. + /// Build TMP rich-text tag from an already-resolved EmotionSpriteInfo. + /// + /// Thứ tự ưu tiên / Priority: + /// 1. UseSpriteName → <sprite name="…"> + /// 2. IsAnimated → <sprite anim="start,end,fps"> + /// 3. (fallback) → <sprite index=N> + /// + public static string BuildSpriteTag(EmotionSpriteInfo info) + { + if (info.UseSpriteName && !string.IsNullOrEmpty(info.SpriteName)) + return $""; + + if (info.IsAnimated) + return $""; + + return $""; + } + + /// + /// Tra bảng spriteMap rồi tạo TMP tag. + /// Lookup spriteMap then build the TMP tag. + /// + /// Bảng ánh xạ emotion → sprite. Emotion-to-sprite mapping. + /// Chỉ số bộ (N trong Emotions{N}). Emotion set index. + /// Chỉ số emotion trong bộ (dòng trong .txt). Emotion index within set. + /// TMP tag được tạo ra nếu thành công. Built TMP tag on success. + /// true nếu tạo được tag, false nếu không tìm thấy emotion. true if tag was built. + public static bool TryBuildEmotionTag(IEmotionSpriteMap spriteMap, int emotionSet, int emotionIndex, out string tag) + { + tag = string.Empty; + if (spriteMap == null) + { + Debug.LogWarning("[Cuong] TryBuildEmotionTag spriteMap is null."); + return false; + } + + if (!spriteMap.TryGetSprite(emotionSet, emotionIndex, out EmotionSpriteInfo info)) + { + Debug.LogWarning("[Cuong] TryBuildEmotionTag."); + return false; + } + tag = BuildSpriteTag(info); + return true; + } + + // ───────────────────────────────────────────────────────────────────── + // TMP_InputField helpers + // ───────────────────────────────────────────────────────────────────── + + /// + /// Insert TMP emotion tag vào TMP_InputField tại vị trí caret hiện tại. + /// Insert the TMP emotion tag into a TMP_InputField at the current caret position. + /// + /// InputField đang nhập chat. The active chat input field. + /// Bảng ánh xạ. Sprite map. + /// Chỉ số bộ. Emotion set index. + /// Chỉ số emotion. Emotion index within set. + /// true nếu tag đã được insert. true if the tag was inserted. + public static bool InsertEmotionTag(TMP_InputField inputField, IEmotionSpriteMap spriteMap, int emotionSet, int emotionIndex) + { + if (inputField == null) + { + BMLogger.LogWarning("[Cuong] InsertEmotionTag inputField is null."); + return false; + } + + if (!TryBuildEmotionTag(spriteMap, emotionSet, emotionIndex, out string tag)) + { + BMLogger.LogWarning($"[Cuong] TryBuildEmotionTag false {emotionSet} {emotionIndex} {tag}"); + return false; + } + + InsertTagAtCaret(inputField, tag); + return true; + } + + /// + /// Insert tag tại vị trí caret, cập nhật caret sau khi insert. + /// Insert tag at caret position, advance caret after insertion. + /// + public static void InsertTagAtCaret(TMP_InputField inputField, string tag) + { + if (inputField == null || string.IsNullOrEmpty(tag)) + return; + + string text = inputField.text ?? string.Empty; + int caret = Mathf.Clamp(inputField.caretPosition, 0, text.Length); + + inputField.text = text.Insert(caret, tag); + + // Đặt caret sau tag vừa insert — Move caret to end of inserted tag + inputField.caretPosition = caret + tag.Length; + inputField.ForceLabelUpdate(); + } + + // ───────────────────────────────────────────────────────────────────── + // Diagnostics + // ───────────────────────────────────────────────────────────────────── + + /// + /// Log toàn bộ emotion set/index có trong spriteMap ra Console — dùng debug. + /// Log all emotion set/index entries in spriteMap to Console — for debugging. + /// + public static void DebugLogAllEntries(IEmotionSpriteMap spriteMap, int maxSets = 10, int maxEmotionsPerSet = 50) + { + if (spriteMap == null) + { + Debug.LogWarning("[Cuong] DebugLogAllEntries spriteMap is null."); + return; + } + + System.Text.StringBuilder sb = new System.Text.StringBuilder(); + sb.AppendLine("[EmotionTMPTagBuilder] All entries:"); + + for (int s = 0; s < maxSets; s++) + { + for (int e = 0; e < maxEmotionsPerSet; e++) + { + if (spriteMap.TryGetSprite(s, e, out EmotionSpriteInfo info)) + sb.AppendLine($" set={s} idx={e} → {BuildSpriteTag(info)}"); + } + } + + BMLogger.Log(sb.ToString()); + } + } +} diff --git a/Assets/PerfectWorld/Scripts/Chat/EmotionTMPTagBuilder.cs.meta b/Assets/PerfectWorld/Scripts/Chat/EmotionTMPTagBuilder.cs.meta new file mode 100644 index 0000000000..093e5d1af1 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/Chat/EmotionTMPTagBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 200b07ddd8d07064b9e6896fda84dafe \ No newline at end of file diff --git a/Assets/PerfectWorld/Scripts/Chat/StubEmotionSpriteMap.cs b/Assets/PerfectWorld/Scripts/Chat/StubEmotionSpriteMap.cs index b590680f08..757392a477 100644 --- a/Assets/PerfectWorld/Scripts/Chat/StubEmotionSpriteMap.cs +++ b/Assets/PerfectWorld/Scripts/Chat/StubEmotionSpriteMap.cs @@ -1,3 +1,5 @@ +using BrewMonster; + /// /// Stub实现 — 在没有真实TMP_SpriteAsset(emotion atlas)时使用的占位映射。 /// Stub implementation used when no real TMP_SpriteAsset (emotion atlas) is available yet. @@ -15,7 +17,7 @@ public class StubEmotionSpriteMap : IEmotionSpriteMap public bool TryGetSprite(int emotionSet, int emotionIndex, out EmotionSpriteInfo info) { int startFrame = (emotionSet * EMOTIONS_PER_SET + emotionIndex) * FRAMES_PER_EMOTION; - + BMLogger.Log("[Cuong] TryGetSprite"); info = new EmotionSpriteInfo { SpriteIndex = startFrame, diff --git a/Assets/PerfectWorld/Scripts/Chat/UI/ChatSystemlUI.cs b/Assets/PerfectWorld/Scripts/Chat/UI/ChatSystemlUI.cs index 36532b0ed1..d2ac43f23e 100644 --- a/Assets/PerfectWorld/Scripts/Chat/UI/ChatSystemlUI.cs +++ b/Assets/PerfectWorld/Scripts/Chat/UI/ChatSystemlUI.cs @@ -17,21 +17,14 @@ namespace BrewMonster.Scripts.ChatUI public class ChatSystemlUI : MonoBehaviour { - [Header("MiniChat")] - public Button onOpenChatPanelButton; - [Tooltip("Parent cho các dòng tin xem trước (nên có VerticalLayoutGroup).")] - public RectTransform miniChatContent; - [Tooltip("Null = dùng messagePrefab.")] - public ChatMessageView miniMessagePrefab; - [SerializeField] int miniChatPreviewLines = 5; - [Tooltip("Tắt raycast trên dòng preview để click xuyên xuống onOpenChatPanelButton (TMP/Image mặc định chặn Button).")] - [SerializeField] bool miniChatPassThroughClicks = true; - [Header("ChatPanelUI")] public ScrollRect scrollRect; + [Tooltip("Nền/fullscreen block — bật/tắt cùng lúc với chatPanelUIGO (có thể để null).")] + public GameObject BgGameObject; public GameObject chatPanelUIGO; public RectTransform content; public ChatMessageView messagePrefab; public Button closeChatPanelButton; + public Button closeBGChatPanelButton; public Button emojiButton; public Button sendButton; @@ -49,9 +42,6 @@ namespace BrewMonster.Scripts.ChatUI private List _messages = new(); private List _visibleViews = new(); private List _filteredMessagesCache = new(); - private readonly List _miniFilterBuffer = new(); - private readonly List _miniChatViews = new(); - private ObjectPool _pool; private bool _userAtBottom = true; @@ -74,6 +64,7 @@ namespace BrewMonster.Scripts.ChatUI EventBus.Subscribe(OnChatMessageClear); EventBus.Subscribe(OnChatMessageReceived); EventBus.Subscribe(OnChannelFilterChanged); + EventBus.Subscribe(OnOpenChatPanelRequested); _pool = new ObjectPool( CreateItem, OnGetItem, @@ -86,13 +77,20 @@ namespace BrewMonster.Scripts.ChatUI scrollRect.onValueChanged.AddListener(OnScrollChanged); + SetChatPanelAndBgVisible(false); + } + + /// Bật/tắt panel chat và BG cùng trạng thái (2 GO tách nhưng luồng giống nhau). + void SetChatPanelAndBgVisible(bool visible) + { if (chatPanelUIGO != null) - chatPanelUIGO.SetActive(false); + chatPanelUIGO.SetActive(visible); + if (BgGameObject != null) + BgGameObject.SetActive(visible); } void OnEnable() { - RefreshMiniChat(); if (chatPanelUIGO == null || !chatPanelUIGO.activeSelf || _pool == null || content == null) return; @@ -107,10 +105,10 @@ namespace BrewMonster.Scripts.ChatUI void WireUiButtons() { - if (onOpenChatPanelButton != null) - onOpenChatPanelButton.onClick.AddListener(OpenChatPanel); if (closeChatPanelButton != null) closeChatPanelButton.onClick.AddListener(CloseChatPanel); + if (closeBGChatPanelButton != null) + closeBGChatPanelButton.onClick.AddListener(CloseChatPanel); if (emojiButton != null) emojiButton.onClick.AddListener(ToggleEmojiPanel); if (closeEmojiPanelButton != null) @@ -121,10 +119,10 @@ namespace BrewMonster.Scripts.ChatUI private void OnDestroy() { - if (onOpenChatPanelButton != null) - onOpenChatPanelButton.onClick.RemoveListener(OpenChatPanel); if (closeChatPanelButton != null) closeChatPanelButton.onClick.RemoveListener(CloseChatPanel); + if (closeBGChatPanelButton != null) + closeBGChatPanelButton.onClick.RemoveListener(CloseChatPanel); if (emojiButton != null) emojiButton.onClick.RemoveListener(ToggleEmojiPanel); if (closeEmojiPanelButton != null) @@ -135,25 +133,29 @@ namespace BrewMonster.Scripts.ChatUI EventBus.Unsubscribe(OnChatMessageClear); EventBus.Unsubscribe(OnChatMessageReceived); EventBus.Unsubscribe(OnChannelFilterChanged); + EventBus.Unsubscribe(OnOpenChatPanelRequested); if (_pool != null) { _pool.Clear(); _pool.Dispose(); } - - ClearMiniChatViews(); } private void OnChannelFilterChanged(ChatChannelFilterChangedEvent e) { if (this == null) return; _currentFilterChannel = e.channel; - RefreshMiniChat(); if (chatPanelUIGO != null && chatPanelUIGO.activeSelf) RefreshVisible(); } + void OnOpenChatPanelRequested(OpenChatPanelRequestedEvent _) + { + if (this == null) return; + OpenChatPanel(); + } + private bool ShouldShowMessage(ChatMessageData data) { if (_currentFilterChannel == ChatChannel.GP_CHAT_LOCAL) return true; @@ -226,8 +228,6 @@ namespace BrewMonster.Scripts.ChatUI if (_messages.Count > maxStoredMessages) _messages.RemoveAt(0); - RefreshMiniChat(); - if (chatPanelUIGO == null || !chatPanelUIGO.activeSelf) return; @@ -309,68 +309,6 @@ namespace BrewMonster.Scripts.ChatUI ScrollToBottom(); } - void RefreshMiniChat() - { - if (miniChatContent == null) return; - - _miniFilterBuffer.Clear(); - foreach (var msg in _messages) - { - if (ShouldShowMessage(msg)) - _miniFilterBuffer.Add(msg); - } - - int take = Mathf.Min(Mathf.Max(1, miniChatPreviewLines), _miniFilterBuffer.Count); - int startIdx = _miniFilterBuffer.Count - take; - - while (_miniChatViews.Count < take) - { - var v = CreateMiniChatItem(); - if (v == null) return; - _miniChatViews.Add(v); - } - - while (_miniChatViews.Count > take) - { - var last = _miniChatViews[_miniChatViews.Count - 1]; - _miniChatViews.RemoveAt(_miniChatViews.Count - 1); - if (last != null) - Destroy(last.gameObject); - } - - for (int i = 0; i < take; i++) - { - var data = _miniFilterBuffer[startIdx + i]; - var view = _miniChatViews[i]; - Sprite icon = _iconCache.ContainsKey(data.channel) ? _iconCache[data.channel] : null; - view.gameObject.SetActive(true); - view.transform.SetSiblingIndex(i); - if (miniChatPassThroughClicks) - DisableGraphicsRaycastUnder(view.transform); - view.Bind(icon, data.message); - } - - Canvas.ForceUpdateCanvases(); - } - - /// - /// Mini chat rows use TMP/Image with raycastTarget on — they sit above the open button and steal clicks. - /// - static void DisableGraphicsRaycastUnder(Transform root) - { - if (root == null) return; - foreach (var g in root.GetComponentsInChildren(true)) - g.raycastTarget = false; - } - - ChatMessageView CreateMiniChatItem() - { - var prefab = miniMessagePrefab != null ? miniMessagePrefab : messagePrefab; - if (prefab == null || miniChatContent == null) return null; - var item = Instantiate(prefab, miniChatContent, false); - return item; - } - public void ScrollToBottom() { Canvas.ForceUpdateCanvases(); @@ -387,24 +325,13 @@ namespace BrewMonster.Scripts.ChatUI _visibleViews.Clear(); _messages.Clear(); - ClearMiniChatViews(); - } - - void ClearMiniChatViews() - { - foreach (var v in _miniChatViews) - { - if (v != null) - Destroy(v.gameObject); - } - - _miniChatViews.Clear(); } public void OnHandlerChatButton() { + if (chatPanelUIGO == null) return; bool open = !chatPanelUIGO.activeSelf; - chatPanelUIGO.SetActive(open); + SetChatPanelAndBgVisible(open); if (open) RefreshVisible(); @@ -415,7 +342,7 @@ namespace BrewMonster.Scripts.ChatUI public void OpenChatPanel() { if (chatPanelUIGO == null) return; - chatPanelUIGO.SetActive(true); + SetChatPanelAndBgVisible(true); RefreshVisible(); _chatInput ??= GetComponent(); @@ -425,8 +352,7 @@ namespace BrewMonster.Scripts.ChatUI public void CloseChatPanel() { - if (chatPanelUIGO == null) return; - chatPanelUIGO.SetActive(false); + SetChatPanelAndBgVisible(false); SetEmojiPanelVisible(false); } @@ -454,5 +380,10 @@ namespace BrewMonster.Scripts.ChatUI } } - public struct OnEventClearChat{} + public struct OnEventClearChat { } + + /// + /// Mini chat (hoặc HUD) publish để mở panel chat đầy đủ; subscribe. + /// + public struct OpenChatPanelRequestedEvent { } } diff --git a/Assets/PerfectWorld/Scripts/Chat/UI/EmojiButtonCell.cs b/Assets/PerfectWorld/Scripts/Chat/UI/EmojiButtonCell.cs new file mode 100644 index 0000000000..be3fcf7f35 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/Chat/UI/EmojiButtonCell.cs @@ -0,0 +1,70 @@ +using System; +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +namespace BrewMonster.Scripts.ChatUI +{ + /// + /// Một ô emoji trong bảng chọn emoji — One emoji cell in the emoji picker grid. + /// Gán lên prefab Button có Image + (tuỳ chọn) TMP_Text cho tooltip. + /// + [RequireComponent(typeof(Button))] + public class EmojiButtonCell : MonoBehaviour + { + [SerializeField] Image _icon; + [Tooltip("Tuỳ chọn: hiện hint text (tên emoji). Optional: show hint/tooltip text.")] + + public event Action OnClicked; // (emotionSet, emotionIndex) + + private int _emotionSet; + private int _emotionIndex; + private Button _button; + + private void Awake() + { + _button = GetComponent