using System; using UnityEngine; using TMPro; using CSNetwork.GPDataType; using System.Collections.Generic; using BrewMonster.Network; using BrewMonster.Scripts.Chat; using BrewMonster.Scripts.Chat.EmotionData; using BrewMonster.UI; using CSNetwork; namespace BrewMonster.Scripts.ChatUI { public struct ChatChannelFilterChangedEvent { public ChatChannel channel; public ChatChannelFilterChangedEvent(ChatChannel c) { channel = c; } } public struct WhisperPlayerEvent { public string playerName; public WhisperPlayerEvent(string name) { playerName = name; } } public class ChatInputHandler : MonoBehaviour { public TMP_InputField inputField; public ChatSystemSO chatSystem; [Header("Typing Preview")] [SerializeField] private TypingPreviewController typingPreview = new(); [Header("Emoji")] [Tooltip("SO ánh xạ emotion → TMP sprite tag. Gán EmotionLibrarySpriteMap đã build từ Emotion Atlas Converter.")] [SerializeField] EmotionLibrarySpriteMap _spriteMap; /// /// Nội dung tin nhắn theo protocol wire (MarshalEditBoxText) — gửi server; không phải TMP. /// Message body in wire protocol (MarshalEditBoxText) — sent to server; not TMP. /// private string _chatWireBody = ""; private bool _syncingWireToDisplay; /// /// Bản sao text input lần trước — dùng phát hiện xóa 1 ký tự trong thẻ <sprite>. /// English: Last input text snapshot — used to detect single-char delete inside a sprite tag. /// private string _lastInputFieldText = ""; private bool _applyingSpriteAwareDelete; [Serializable] public struct ChannelButtonMapping { public ChatChannel channel; public UnityEngine.UI.Button button; } public List channelButtons = new(); [Header("Channel / whisper dropdown")] [Tooltip("TMP_Dropdown: 4 kênh (Local/Team/Faction/World) khi không whisper; khi whisper thì hiện MRU tên người. English: TMP_Dropdown — four channels when not whisper; MRU names in whisper.")] [SerializeField] private TMP_Dropdown recentWhisperDropdown; /// Thứ tự kênh trong dropdown (không gồm Whisper). English: Channel order in dropdown (whisper excluded). private static readonly ChatChannel[] ChatDropdownChannelOrder = { ChatChannel.GP_CHAT_LOCAL, ChatChannel.GP_CHAT_TEAM, ChatChannel.GP_CHAT_FACTION, ChatChannel.GP_CHAT_FARCRY, }; [Tooltip("Số người whisper gần nhất tối đa trong dropdown. English: Max recent whisper targets in the dropdown.")] [SerializeField] private int maxRecentWhisperTargets = 5; /// Giới hạn MRU whisper cho UX dropdown (tối thiểu 1). English: MRU whisper cap for dropdown UX (minimum 1). public int MaxRecentWhisperTargets => Mathf.Max(1, maxRecentWhisperTargets); /// Dòng đầu dropdown: không chọn MRU (giữ target đang gõ). English: First row: no MRU pick. private const string WhisperDropdownPlaceholder = "\u2014"; private readonly List m_recentWhispers = new(); private bool m_whisperDropdownBulkUpdate; private const int MAX_HISTORY = 10; private ChatChannel m_currentChannel = ChatChannel.GP_CHAT_LOCAL; private string m_whisperTarget = ""; public void SetWhisperTarget(string playerName) { if (string.IsNullOrEmpty(playerName)) return; m_whisperTarget = playerName; OnCommand_speakmode(ChatChannel.GP_CHAT_WHISPER); } private struct ChatMsg { public ChatChannel channel; public float time; public string msg; public int pack; public int slot; } private List m_vecHistory = new(); private int m_nCurHistory = 0; private float m_dwTickFarCry = 0; private float m_dwTickFarCry2 = 0; private Vector2 _inputBarBaseAnchoredPos; private bool _cachedAnchors; private void Awake() { _spriteMap?.EnsureCachedTmpSpriteAssetNames(); EventBus.Subscribe(OnWhisperPlayerEvent); } private void OnDestroy() { EventBus.Unsubscribe(OnWhisperPlayerEvent); if (inputField != null) { inputField.onSubmit.RemoveListener(OnSubmit); inputField.onValueChanged.RemoveListener(OnInputValueChanged); } if (recentWhisperDropdown != null) recentWhisperDropdown.onValueChanged.RemoveListener(OnChatDropdownValueChanged); } private void OnWhisperPlayerEvent(WhisperPlayerEvent e) { SetWhisperTarget(e.playerName); } private void Start() { // PC / Editor: Enter submits. Android: only sendButton → SubmitFromSendButton (no onSubmit). if (inputField != null && Application.platform != RuntimePlatform.Android) inputField.onSubmit.AddListener(OnSubmit); if (inputField != null) inputField.onValueChanged.AddListener(OnInputValueChanged); _lastInputFieldText = inputField != null ? (inputField.text ?? "") : ""; WireChatDropdown(); RebuildChatDropdownOptions(); foreach (var mapping in channelButtons) { if (mapping.button != null) { ChatChannel ch = mapping.channel; // Capture variable for closure mapping.button.onClick.AddListener(() => OnCommand_speakmode(ch)); } } // Select the first button as default if available if (channelButtons.Count > 0 && channelButtons[0].button != null) { OnCommand_speakmode(channelButtons[0].channel); } // [Mobile Fix] Giữ cho chữ được hiển thị trực tiếp trên UI của game thay vì bị // đẩy vào một thanh ngang phụ (Native OS Input) bật lên cùng bàn phím. if (inputField != null) { inputField.shouldHideMobileInput = true; } UpdateChatDropdownInteractable(); UpdateTypingPreviewFromInput(); } private void Update() { // Keyboard open/close does not always trigger input value changed, // so refresh preview gate every frame. UpdateTypingPreviewFromInput(); } // ===================================================== // PORT C++: CDlgChat::OnCommand_speakmode // Sets the chat input prefix based on the selected channel from SO. // ===================================================== private void OnCommand_speakmode(ChatChannel channel) { if (chatSystem == null) return; // Update button visual states using sprites from ChatSystemSO foreach (var mapping in channelButtons) { if (mapping.button != null && mapping.button.image != null) { if (mapping.channel == channel) mapping.button.image.sprite = chatSystem.selectedSprite; else mapping.button.image.sprite = chatSystem.unselectedSprite; } } if (m_currentChannel != channel && channel != ChatChannel.GP_CHAT_WHISPER) { m_whisperTarget = ""; } m_currentChannel = channel; // Handle System channel input restriction if (m_currentChannel == ChatChannel.GP_CHAT_SYSTEM) { inputField.interactable = false; inputField.text = ""; } else { inputField.interactable = true; var config = chatSystem.channelIcons.Find(c => c.channel == channel); string currentText = inputField.text; currentText = RemoveKnownPrefix(currentText); if (channel == ChatChannel.GP_CHAT_WHISPER && !string.IsNullOrEmpty(m_whisperTarget)) { inputField.text = "/" + m_whisperTarget + " " + currentText; } else if (config.prefix != null) { inputField.text = config.prefix + currentText; } else { inputField.text = currentText; } inputField.ActivateInputField(); inputField.MoveTextEnd(false); } SyncChatWireBodyFromInput(); RebuildChatDropdownOptions(); UpdateChatDropdownInteractable(); EventBus.Publish(new ChatChannelFilterChangedEvent(m_currentChannel)); } void WireChatDropdown() { if (recentWhisperDropdown == null) return; recentWhisperDropdown.onValueChanged.RemoveListener(OnChatDropdownValueChanged); recentWhisperDropdown.onValueChanged.AddListener(OnChatDropdownValueChanged); } void UpdateChatDropdownInteractable() { if (recentWhisperDropdown == null) return; recentWhisperDropdown.interactable = m_currentChannel != ChatChannel.GP_CHAT_SYSTEM; } void OnChatDropdownValueChanged(int index) { if (m_whisperDropdownBulkUpdate) return; if (recentWhisperDropdown == null) return; if (m_currentChannel == ChatChannel.GP_CHAT_WHISPER) { if (index <= 0) return; int mruIndex = index - 1; if (mruIndex < 0 || mruIndex >= m_recentWhispers.Count) return; SetWhisperTarget(m_recentWhispers[mruIndex]); return; } if (index < 0 || index >= ChatDropdownChannelOrder.Length) return; ChatChannel ch = ChatDropdownChannelOrder[index]; if (ch == m_currentChannel) return; OnCommand_speakmode(ch); } void RebuildChatDropdownOptions() { if (recentWhisperDropdown == null) return; m_whisperDropdownBulkUpdate = true; recentWhisperDropdown.ClearOptions(); if (m_currentChannel == ChatChannel.GP_CHAT_WHISPER) { var whisperOpts = new List { new TMP_Dropdown.OptionData(WhisperDropdownPlaceholder) }; foreach (var name in m_recentWhispers) whisperOpts.Add(new TMP_Dropdown.OptionData(name)); recentWhisperDropdown.AddOptions(whisperOpts); SyncWhisperMruDropdownSelection(); } else { var channelOpts = new List(); foreach (var ch in ChatDropdownChannelOrder) channelOpts.Add(new TMP_Dropdown.OptionData(GetChannelDropdownLabel(ch))); recentWhisperDropdown.AddOptions(channelOpts); SyncPublicChannelDropdownSelection(); } m_whisperDropdownBulkUpdate = false; } void SyncPublicChannelDropdownSelection() { if (recentWhisperDropdown == null || recentWhisperDropdown.options.Count == 0) return; int idx = 0; for (int i = 0; i < ChatDropdownChannelOrder.Length; i++) { if (ChatDropdownChannelOrder[i] != m_currentChannel) continue; idx = i; break; } int max = recentWhisperDropdown.options.Count - 1; recentWhisperDropdown.SetValueWithoutNotify(Mathf.Clamp(idx, 0, max)); } void SyncWhisperMruDropdownSelection() { if (recentWhisperDropdown == null) return; int idx = 0; if (!string.IsNullOrEmpty(m_whisperTarget)) { int inMru = m_recentWhispers.IndexOf(m_whisperTarget); if (inMru >= 0) idx = inMru + 1; } int max = recentWhisperDropdown.options.Count - 1; if (max < 0) return; recentWhisperDropdown.SetValueWithoutNotify(Mathf.Clamp(idx, 0, max)); } void RecordRecentWhisper(string playerName) { if (string.IsNullOrEmpty(playerName)) return; int cap = MaxRecentWhisperTargets; m_recentWhispers.Remove(playerName); m_recentWhispers.Insert(0, playerName); while (m_recentWhispers.Count > cap) m_recentWhispers.RemoveAt(m_recentWhispers.Count - 1); if (m_currentChannel == ChatChannel.GP_CHAT_WHISPER) RebuildChatDropdownOptions(); } private string RemoveKnownPrefix(string text) { if (string.IsNullOrEmpty(text)) return text; if (text.StartsWith("!!") || text.StartsWith("!~") || text.StartsWith("!@") || text.StartsWith("!#")) { return text.Substring(2); } else if (text.StartsWith("$")) { return text.Substring(1); } else if (text.StartsWith("/")) { int spaceIndex = text.IndexOf(' '); if (spaceIndex > 0) { return text.Substring(spaceIndex + 1); } return ""; } return text; } private void OnSubmit(string text) { if (string.IsNullOrWhiteSpace(text)) return; OnCommand_speak(text); } /// /// Gọi từ nút Send trên UI. Trên Android đây là cách gửi duy nhất (không dùng Enter). /// public void SubmitFromSendButton() { if (inputField == null) return; OnSubmit(inputField.text); } // ===================================================== // PORT C++: CDlgChat::OnCommand_speak // ===================================================== private void OnCommand_speak(string text) { SyncChatWireBodyFromInput(); string routingLine = (text ?? "").Trim(); string strText = _chatWireBody?.Trim() ?? ""; if (strText.Length <= 0) { ClearTextInInputField(); //ChangeFocus(); return; } int nPack = -1; int nSlot = -1; FilterBadWords(ref strText); _chatWireBody = strText; if (!CanUseRawCharacters(0)) return; if (HandleDebugCommand(routingLine)) return; float now = Time.time; if (!CheckFarCryRequirement(routingLine, now)) return; if (!CheckSuperFarCryRequirement(routingLine, now)) return; if (!CheckSpamProtection(strText, now)) return; ChatChannel resolvedChannel = ParseAndSendMessage(routingLine, nPack, nSlot); if (resolvedChannel == ChatChannel.GP_CHAT_FARCRY && routingLine.StartsWith("!@")) m_dwTickFarCry = now; if (resolvedChannel == ChatChannel.GP_CHAT_SUPERFARCRY && routingLine.StartsWith("!#")) m_dwTickFarCry2 = now; SaveHistory(resolvedChannel, strText, nPack, nSlot, now); ClearTextInInputField(); //ChangeFocus(); } // ===================================================== // DEBUG COMMAND // ===================================================== private bool HandleDebugCommand(string text) { if (text == "##debug") { Debug.Log("Toggle debug console"); return true; } return false; } // ===================================================== // FARCRY CHECK (!@) // ===================================================== private bool CheckFarCryRequirement(string text, float now) { if (text.StartsWith("!@")) { int itemNum = GetPlayerItemCount(12979) + GetPlayerItemCount(36092); if (itemNum < 1 || GetPlayerLevel() < 5) { AddChatMessage(CECUIManager.Instance.GameUI.GetStringFromTable(731), ChatChannel.GP_CHAT_MISC); return false; } if (now - m_dwTickFarCry <= 1f) { AddChatMessage(CECUIManager.Instance.GameUI.GetStringFromTable(730), ChatChannel.GP_CHAT_MISC); return false; } } return true; } // ===================================================== // SUPER FARCRY (!#) // ===================================================== private bool CheckSuperFarCryRequirement(string text, float now) { if (text.StartsWith("!#")) { int itemNum = GetPlayerItemCount(27728) + GetPlayerItemCount(27729); if (itemNum < 1) { AddChatMessage(CECUIManager.Instance.GameUI.GetStringFromTable(8531), ChatChannel.GP_CHAT_MISC); return false; } if (now - m_dwTickFarCry2 <= 1f) { AddChatMessage(CECUIManager.Instance.GameUI.GetStringFromTable(8530), ChatChannel.GP_CHAT_MISC); return false; } } return true; } // ===================================================== // SPAM PROTECTION // ===================================================== private bool CheckSpamProtection(string text, float now) { if (m_vecHistory.Count == 0) return true; var last = m_vecHistory[m_vecHistory.Count - 1]; if (now - last.time <= 1f) { AddChatMessage(CECUIManager.Instance.GameUI.GetStringFromTable(272), ChatChannel.GP_CHAT_MISC); return false; } foreach (var cm in m_vecHistory) { if (cm.channel != ChatChannel.GP_CHAT_WHISPER && cm.msg == text && now - cm.time <= 6f) { AddChatMessage(CECUIManager.Instance.GameUI.GetStringFromTable(273), ChatChannel.GP_CHAT_MISC); return false; } } return true; } // ===================================================== // PARSE MESSAGE PREFIX // ===================================================== private ChatChannel ParseAndSendMessage(string text, int nPack, int nSlot) { ChatChannel channel = m_currentChannel; string pszMsg = _chatWireBody; if (text.StartsWith("!!")) { channel = ChatChannel.GP_CHAT_TEAM; } else if (text.StartsWith("!~")) { channel = ChatChannel.GP_CHAT_FACTION; } else if (text.StartsWith("!@")) { channel = ChatChannel.GP_CHAT_FARCRY; } else if (text.StartsWith("!#")) { channel = ChatChannel.GP_CHAT_SUPERFARCRY; } else if (text.StartsWith("$")) { channel = ChatChannel.GP_CHAT_TRADE; } else if (text.StartsWith("/")) { HandleWhisper(text, nPack, nSlot); return ChatChannel.GP_CHAT_WHISPER; } else if (channel == ChatChannel.GP_CHAT_WHISPER) { // Cho phép chat kênh Whisper mượt mà mà không bắt buộc gõ ký tự "/" ở đầu if (!string.IsNullOrEmpty(m_whisperTarget)) { HandleWhisper("/" + m_whisperTarget + " " + text, nPack, nSlot); } else { HandleWhisper("/" + text, nPack, nSlot); } return ChatChannel.GP_CHAT_WHISPER; } // Không gõ prefix thủ công thì sẽ dùng m_currentChannel đã được gán ở đầu hàm SendChat(channel, pszMsg, nPack, nSlot); return channel; } // ===================================================== // WHISPER // ===================================================== private void HandleWhisper(string text, int nPack, int nSlot) { string cmd = text.Substring(1); int spaceIndex = cmd.IndexOf(' '); if (spaceIndex <= 0) { AddChatMessage(CECUIManager.Instance.GameUI.GetStringFromTable(234), ChatChannel.GP_CHAT_MISC); return; } string player = cmd.Substring(0, spaceIndex); m_whisperTarget = player; SendPrivateChat(player, _chatWireBody, nPack, nSlot); } // ===================================================== // SEND CHAT // ===================================================== private void SendChat(ChatChannel channel, string msg, int pack, int slot) { UnityGameSession.SendChatData((byte)channel, msg, pack, slot); } private void SendPrivateChat(string target, string msg, int pack, int slot) { BMLogger.Log($"[Cuong] Whisper to {target}: {msg}"); // [Port] C++: CDlgChat::OnCommand_speak → privatechat branch // Gửi tin nhắn mật (whisper) lên server qua GameSession. // Tra cứu ID của người nhận từ danh sách bạn bè (giống C++: pFriend ? pFriend->id : -1) int idTarget = GetCharacterIdByName(target); UnityGameSession.Instance.GameSession.SendPrivateChatData(target, msg, 0, idTarget, pack, slot); // [Port] C++: DlgChat.cpp dòng 1531-1532 // Server KHÔNG echo whisper lại cho người gửi → phải hiển thị local. // C++ dùng: a_sprintf(szMsg, GetStringFromTable(233), szName, szText) // → format "对&Name&说:msg" — dấu & được thêm bởi FORMAT STRING, không phải bởi caller. // C# dùng FIXMSG_PRIVATECHAT2 tương ứng; fallback nếu format string lỗi. // QUAN TRỌNG: chỉ truyền target (không có & bao quanh) vào string.Format. // Nếu format string đã có &{0}& thì kết quả đúng là "&target&". // Nếu format string không có & thì AddChatMessage cũng chỉ wrap màu bình thường. // Tránh truyền "&target&" vì kết quả sẽ là "&&target&&" → regex chỉ match "target" // nhưng để lại & dư ở ngoài → UI hiển thị "&target&" thay vì link sạch. CECStringTab pStrTab = EC_Game.GetFixedMsgs(); string fmt = AUIDialog.FormatPrintf(pStrTab.GetWideString((int)FixedMsg.FIXMSG_PRIVATECHAT2)); string localMsg; try { localMsg = string.Format(fmt, target, _chatWireBody); } catch { // Fallback: dùng &target& để AddChatMessage tạo link — format không có & thì mình thêm localMsg = $"&{target}&: {_chatWireBody}"; } CECGameUIMan pGameUI = EC_Game.GetGameRun()?.GetUIManager()?.GetInGameUIMan(); if (pGameUI != null) { // idTarget là ID người NHẬN (hoặc -1 nếu không tìm thấy trong friend list) // AddChatMessage sẽ convert &target& thành TMP link tag có dạng idTarget|target pGameUI.AddChatMessage(localMsg, ChatChannel.GP_CHAT_WHISPER, idTarget, null, 0, 0, null, _chatWireBody); } RecordRecentWhisper(target); } // [Port] C++: pFriendMan->GetFriendByName(name) → pFriend->id // Tìm character ID của người chơi theo tên từ friend list. // Trả về -1 nếu không tìm thấy (giống C++ trả về idFriend = -1). private int GetCharacterIdByName(string name) { if (string.IsNullOrEmpty(name)) return -1; var host = EC_Game.GetGameRun()?.GetHostPlayer(); if (host == null) return -1; var friendMan = host.GetFriendMan(); if (friendMan == null) return -1; var friend = friendMan.GetFriendByName(name); return friend.HasValue ? friend.Value.Id : -1; } // ===================================================== // HISTORY // ===================================================== private void SaveHistory(ChatChannel channel, string msg, int pack, int slot, float now) { if (m_vecHistory.Count >= MAX_HISTORY) m_vecHistory.RemoveAt(0); m_vecHistory.Add(new ChatMsg { channel = channel, msg = msg, pack = pack, slot = slot, time = now }); m_nCurHistory = m_vecHistory.Count; } // ===================================================== // UTILITIES // ===================================================== private void FilterBadWords(ref string text) { CECUIManager.Instance.FilterBadWords(ref text); } private void AddChatMessage(string msg, ChatChannel channel, int idPlayer = -1, string pszPlayer = "", byte byFlag = 0) { string strModified = msg; if (channel is not ChatChannel.GP_CHAT_MISC) { // 1. Filter bad words CECUIManager.Instance.FilterBadWords(ref strModified); } if (string.IsNullOrEmpty(strModified)) return; // 2. Blacklist check (Placeholder for porting) if (IsPlayerBlacklisted(idPlayer)) return; // 3. Flag handling (Friend chat routing) if (byFlag == 1) // CHANNEL_FRIEND equivalent { // AddFriendMessage(strModified, idPlayer, pszPlayer, ...); // return; } // 4. Formatting with Rich Text string colorHex = GetChannelColorHex(channel); string prefix = GetChannelPrefix(channel); string sender = string.IsNullOrEmpty(pszPlayer) ? "" : $"{pszPlayer}: "; string finalMsg = $"{prefix}{sender}{strModified}"; // 5. Publish event EventBus.Publish(new GameSession.ChatMessageEvent(finalMsg, (byte)channel)); } private string GetChannelColorHex(ChatChannel channel) { // [Port] C++: CDlgChat::m_pszColor[] — DlgChat.cpp dòng 120-136 // Giữ đúng màu gốc cho các kênh dùng trong local error messages return channel switch { ChatChannel.GP_CHAT_LOCAL => "FFFFFF", ChatChannel.GP_CHAT_FARCRY => "FFE400", ChatChannel.GP_CHAT_TEAM => "00FF00", ChatChannel.GP_CHAT_FACTION => "00FFFC", ChatChannel.GP_CHAT_WHISPER => "0065FE", // C++: "^0065FE" — KHÔNG phải FF00FF ChatChannel.GP_CHAT_TRADE => "FF742E", ChatChannel.GP_CHAT_SYSTEM => "BED293", ChatChannel.GP_CHAT_BROADCAST => "FF3600", ChatChannel.GP_CHAT_MISC => "9AA6FF", ChatChannel.GP_CHAT_SUPERFARCRY => "ff9b3e", ChatChannel.GP_CHAT_BATTLE => "FFFFFF", ChatChannel.GP_CHAT_COUNTRY => "FFFFFF", _ => "FFFFFF" }; } private string GetChannelPrefix(ChatChannel channel) { return channel switch { ChatChannel.GP_CHAT_TEAM => "[Team] ", ChatChannel.GP_CHAT_FACTION => "[Faction] ", ChatChannel.GP_CHAT_FARCRY => "[World] ", ChatChannel.GP_CHAT_WHISPER => "[Whisper] ", _ => "" }; } /// Nhãn hiển thị trên dropdown cho từng kênh công khai. English: Dropdown caption per public channel. private string GetChannelDropdownLabel(ChatChannel channel) { return channel switch { ChatChannel.GP_CHAT_LOCAL => "Tự do", ChatChannel.GP_CHAT_TEAM => "Đội", ChatChannel.GP_CHAT_FACTION => "Bang", ChatChannel.GP_CHAT_FARCRY => "Thế giới", _ => channel.ToString() }; } private bool IsPlayerBlacklisted(int idPlayer) => false; // Placeholder private void ClearTextInInputField() { _chatWireBody = ""; if (m_currentChannel == ChatChannel.GP_CHAT_WHISPER && !string.IsNullOrEmpty(m_whisperTarget)) { inputField.text = "/" + m_whisperTarget + " "; } else { inputField.text = ""; } UpdateTypingPreviewFromInput(); } private int GetEmotionSetForCurrentChannel() { return m_currentChannel == ChatChannel.GP_CHAT_SUPERFARCRY ? GameSession.SUPER_FAR_CRY_EMOTION_SET : 0; } private string BuildVisualPrefixForCurrentChannel() { if (chatSystem == null) return ""; if (m_currentChannel == ChatChannel.GP_CHAT_WHISPER && !string.IsNullOrEmpty(m_whisperTarget)) return "/" + m_whisperTarget + " "; var cfg = chatSystem.channelIcons.Find(c => c.channel == m_currentChannel); return cfg.prefix ?? ""; } /// /// Lấy phần thân (TMP) sau prefix kênh / whisper — English: TMP body after channel or whisper prefix. /// private string ExtractMessageBodyFromVisual(string full) { if (string.IsNullOrEmpty(full)) return ""; if (full.StartsWith("/")) { int sp = full.IndexOf(' '); if (sp > 0 && sp + 1 < full.Length) return full.Substring(sp + 1); return ""; } string t = RemoveKnownPrefix(full); if (chatSystem != null) { var cfg = chatSystem.channelIcons.Find(c => c.channel == m_currentChannel); if (cfg.prefix != null && cfg.prefix.Length > 0 && t.StartsWith(cfg.prefix)) t = t.Substring(cfg.prefix.Length); } return t; } private void SyncChatWireBodyFromInput() { if (inputField == null) return; string tmpBody = ExtractMessageBodyFromVisual(inputField.text ?? ""); if (_spriteMap == null) { _chatWireBody = tmpBody; return; } _chatWireBody = ChatWireTmpCodec.TmpBodyToWire(tmpBody, _spriteMap); } private void OnInputValueChanged(string newText) { newText ??= ""; if (_syncingWireToDisplay) { _lastInputFieldText = newText; UpdateTypingPreviewFromInput(); return; } if (_applyingSpriteAwareDelete) { _lastInputFieldText = newText; SyncChatWireBodyFromInput(); UpdateTypingPreviewFromInput(); return; } string prev = _lastInputFieldText ?? ""; if (TryApplySpriteAwareSingleDelete(prev, newText, out string fixedText, out int caretRestore)) { _applyingSpriteAwareDelete = true; try { inputField.text = fixedText; } finally { _applyingSpriteAwareDelete = false; } int clampedCaret = Mathf.Clamp(caretRestore, 0, fixedText.Length); inputField.caretPosition = clampedCaret; inputField.ForceLabelUpdate(); _lastInputFieldText = fixedText; SyncChatWireBodyFromInput(); UpdateTypingPreviewFromInput(); return; } _lastInputFieldText = newText; SyncChatWireBodyFromInput(); UpdateTypingPreviewFromInput(); } /// /// Xóa Backspace/Delete từng ký tự trong thẻ TMP <sprite> → gỡ cả thẻ (một emoji). /// English: Single-char delete inside a TMP sprite tag removes the whole tag (one in-game emoji). /// private static bool TryGetSingleCharDeleteRemovedIndex(string prev, string curr, out int removedIndex) { removedIndex = -1; if (prev == null) prev = ""; if (curr == null) curr = ""; if (prev.Length != curr.Length + 1) return false; int i = 0; for (; i < curr.Length; i++) { if (prev[i] != curr[i]) { removedIndex = i; return string.CompareOrdinal(prev, i + 1, curr, i, curr.Length - i) == 0; } } removedIndex = prev.Length - 1; return true; } private bool TryApplySpriteAwareSingleDelete(string prev, string curr, out string fixedText, out int caretRestore) { fixedText = null; caretRestore = 0; if (inputField == null || !TryGetSingleCharDeleteRemovedIndex(prev, curr, out int removedIndex)) return false; string prefix = BuildVisualPrefixForCurrentChannel(); if (removedIndex < prefix.Length) return false; if (!ChatWireTmpCodec.TryGetSpriteTagRangeContainingCharacterIndex(prev, removedIndex, out int tagStart, out int tagEnd)) return false; fixedText = prev.Substring(0, tagStart) + prev.Substring(tagEnd); caretRestore = tagStart; return true; } private void RefreshInputDisplayFromWire() { if (inputField == null) return; _syncingWireToDisplay = true; try { string prefix = BuildVisualPrefixForCurrentChannel(); string tmpBody = ChatWireTmpCodec.WireBodyToTmpForDisplay(_chatWireBody, _spriteMap, GetEmotionSetForCurrentChannel()); inputField.text = prefix + tmpBody; inputField.MoveTextEnd(false); } finally { _syncingWireToDisplay = false; } } private void ChangeFocus() { inputField.ActivateInputField(); } void UpdateTypingPreviewFromInput() { if (inputField == null) return; string body = ExtractMessageBodyFromVisual(inputField.text ?? ""); typingPreview?.UpdatePreview(inputField.isFocused, body); } /// /// C++: GetHostPlayer()->GetPack()->GetItemTotalNum(id) — đếm túi chính. /// private int GetPlayerItemCount(int templateId) { var host = EC_Game.GetGameRun()?.GetHostPlayer(); EC_Inventory pack = host?.GetPack(); return pack?.GetItemTotalNum(templateId) ?? 0; } private int GetPlayerLevel() { return EC_Game.GetGameRun()?.GetHostPlayer()?.GetBasicProps().iLevel ?? 0; } // ===================================================== // EMOJI INSERT // ===================================================== /// /// Thêm emotion vào buffer wire rồi refresh TMP — gửi server đúng protocol. /// Append emotion to wire buffer then refresh TMP — server protocol preserved. /// public void AppendEmotionWire(int emotionSet, int emotionIndex) { string segment = ChatWireTmpCodec.BuildMarshaledEmotionWire(emotionSet, emotionIndex); if (string.IsNullOrEmpty(segment)) return; int reserveChars = 0; if (_spriteMap != null && EmotionTMPTagBuilder.TryBuildEmotionTag(_spriteMap, emotionSet, emotionIndex, out string tag)) reserveChars = tag.Length; if (!CanUseRawCharacters(reserveChars)) return; _chatWireBody += segment; RefreshInputDisplayFromWire(); } /// /// Chèn TMP emotion tag vào inputField tại vị trí caret hiện tại. /// Inserts the TMP emotion tag into the inputField at the current caret position. /// Gọi từ EmojiPickerUI khi người chơi bấm chọn emoji. /// Called from EmojiPickerUI when the player selects an emoji. /// public void InsertEmoji(int emotionSet, int emotionIndex) { AppendEmotionWire(emotionSet, emotionIndex); } private bool CanUseRawCharacters(int reserveChars) { if (chatSystem == null) return true; string rawBody = ExtractMessageBodyFromVisual(inputField != null ? inputField.text ?? "" : ""); if (chatSystem.CanUseRawCharacters(rawBody, reserveChars, out int usedChars, out int limitChars)) return true; //AddChatMessage($"Tin nhan vuot gioi han: {usedChars}/{limitChars} ky tu.", ChatChannel.GP_CHAT_MISC); return false; } } }