using System; using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Linq; using DG.Tweening; using GameServer.Client; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; using Bansonic; public class globalChatSystem : MonoBehaviour { private enum ChatChannelMode { World, Private } private sealed class WorldSystemFeedEntry { public string text; public long createdAtUnixMs; public long sequence; } private sealed class WorldFeedEntry { public object entry; public long timestampMs; public int order; public string key; public long sequence; } public GameObject playerMessagePrefab; public GameObject selfMessagePrefab; public GameObject worldSystemMessagePrefab; public Transform playerMessageParent; [Header("extra")] public Button emojiButton; [Header("send")] public InputField messageInputField; public Button sendButton; [Header("button")] public Button closeButton; [Header("display")] [SerializeField] private int maxMessageHistory = 1000; [SerializeField] private int messageDisplayWindow = 20; [SerializeField] private Color[] messageTypeColors = { Color.white, new Color(0.23292516f, 0.7924528f, 0.22801706f, 1f) }; [SerializeField] Text channelNameText; [SerializeField] Button back_to_worldwideChatButton; [SerializeField] Toggle globalChatToggle; [SerializeField] Toggle friendChatToggle; [SerializeField] private CanvasGroup chatCanvasGroup; private ArenaRoomService _service; private ScrollRect _scrollRect; private Coroutine _chatCooldownRoutine; private Text _placeholderText; private string _defaultPlaceholder = string.Empty; private float _nextSendAllowedAt; private bool _isSendingMessage; private string _pendingSendContent = string.Empty; private readonly List _systemFeedEntries = new List(); private bool _isViewPinnedToBottom = true; private bool _suppressScrollEvents; private int _windowStartIndex = -1; private int _lastFeedCount; private readonly List _renderedKeys = new List(); private readonly List _renderedObjects = new List(); private readonly Dictionary _feedSequences = new Dictionary(StringComparer.Ordinal); private long _nextFeedSequence = 1; private ChatChannelMode _currentMode = ChatChannelMode.World; private string _currentPrivatePartnerId; private string _lastPrivatePartnerId = string.Empty; private string _defaultChannelName = string.Empty; private static string _pendingPrivatePartnerId; private Coroutine _layoutRefreshCoroutine; private bool _layoutRefreshDirty; private bool _scrollToBottomAfterLayout; private readonly List _pendingLayoutDirtyRoots = new List(); private Coroutine _refocusInputCoroutine; private bool _refocusInputRequested; private bool _suppressModeToggleEvents; private bool _modeTogglesAutoCreated; private ToggleGroup _chatModeToggleGroup; private Tween _visibilityTween; public static globalChatSystem Instance { get; private set; } public static event Action ActivePrivateConversationChanged; private const float WorldChatSendCooldownSeconds = 10f; private const float PrivateChatSendCooldownSeconds = 1f; private const int InitialHistoryLoadLimit = 20; private const int PrivateChatCacheLimit = 50; private const int MaxDisplayWindowLimit = 20; private const string DefaultWorldChannelName = "世界聊天"; private const string CooldownPlaceholderFormat = "\u53d1\u9001\u51b7\u5374\u4e2d {0}s"; private const string PrivateChannelNameFormat = "\u6b63\u5728\u4e0e[{0}]\u79c1\u804a"; private const string DefaultFriendChannelName = "\u597d\u53cb\u804a\u5929"; private const string NoRecentPrivateConversationNotice = "\u6682\u65e0\u6700\u8fd1\u79c1\u804a\u5bf9\u8c61"; private const string LastPrivatePartnerPrefKey = "global_chat_last_private_partner_id"; public static void OpenPrivateConversation(string steamId) { if (string.IsNullOrWhiteSpace(steamId)) { return; } _pendingPrivatePartnerId = steamId.Trim(); globalChatSystem target = Instance; if (target == null) { target = FindAnyInstance(); } if (target == null) { return; } if (!target.gameObject.activeSelf) { target.gameObject.SetActive(true); return; } target.StartCoroutine(target.OpenPrivateConversationCoroutine(_pendingPrivatePartnerId)); _pendingPrivatePartnerId = null; } private static globalChatSystem FindAnyInstance() { globalChatSystem[] all = FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); return all != null && all.Length > 0 ? all[0] : null; } private void Awake() { Instance = this; _lastPrivatePartnerId = PlayerPrefs.GetString(LastPrivatePartnerPrefKey, string.Empty); EnsureCanvasGroupReference(); } private void Start() { _service = ArenaRoomService.Instance; _scrollRect = playerMessageParent != null ? playerMessageParent.GetComponentInParent() : null; if (_scrollRect != null) { _scrollRect.onValueChanged.RemoveListener(OnScrollValueChanged); _scrollRect.onValueChanged.AddListener(OnScrollValueChanged); } if (closeButton != null) { closeButton.onClick.RemoveListener(CloseSelf); closeButton.onClick.AddListener(CloseSelf); } if (sendButton != null) { sendButton.onClick.RemoveListener(OnSendClicked); sendButton.onClick.AddListener(OnSendClicked); } if (messageInputField != null) { messageInputField.onEndEdit.RemoveListener(OnInputEndEdit); messageInputField.onEndEdit.AddListener(OnInputEndEdit); _placeholderText = messageInputField.placeholder as Text; if (_placeholderText != null) { _defaultPlaceholder = _placeholderText.text; } } if (channelNameText != null) { _defaultChannelName = channelNameText.text ?? string.Empty; if (string.IsNullOrWhiteSpace(_defaultChannelName)) { _defaultChannelName = DefaultWorldChannelName; } } if (back_to_worldwideChatButton != null) { back_to_worldwideChatButton.onClick.RemoveListener(HandleBackToWorldClicked); back_to_worldwideChatButton.onClick.AddListener(HandleBackToWorldClicked); } EnsureChatModeToggles(); if (_service != null) { _service.SetWorldChatCacheLimit(GetEffectiveCacheLimit()); _service.SetPrivateChatCacheLimit(PrivateChatCacheLimit); _ = _service.EnsureWorldChatConnected(); } UpdateSendUiState(); } private void OnEnable() { EnsureCanvasGroupReference(); PlayOpenFadeIfNeeded(); if (_service == null) { _service = ArenaRoomService.Instance; } if (_service == null) { return; } _service.OnWorldChatMessagesChanged -= HandleWorldChatMessagesChanged; _service.OnWorldChatMessagesChanged += HandleWorldChatMessagesChanged; _service.OnPrivateChatMessagesChanged -= HandlePrivateChatMessagesChanged; _service.OnPrivateChatMessagesChanged += HandlePrivateChatMessagesChanged; ClearDisplayedMessages(); _isViewPinnedToBottom = true; _windowStartIndex = -1; EnsureChatModeToggles(); NotifyActivePrivateConversationChanged(); if (!string.IsNullOrWhiteSpace(_pendingPrivatePartnerId)) { string targetSteamId = _pendingPrivatePartnerId; _pendingPrivatePartnerId = null; _ = SwitchToPrivateChannelAsync(targetSteamId); } else { _ = InitializeWorldChatAsync(); } } private void OnDisable() { if (_service != null) { _service.OnWorldChatMessagesChanged -= HandleWorldChatMessagesChanged; _service.OnPrivateChatMessagesChanged -= HandlePrivateChatMessagesChanged; } if (_scrollRect != null) { _scrollRect.onValueChanged.RemoveListener(OnScrollValueChanged); } UnbindChatModeToggleListeners(); if (_chatCooldownRoutine != null) { StopCoroutine(_chatCooldownRoutine); _chatCooldownRoutine = null; } if (_layoutRefreshCoroutine != null) { StopCoroutine(_layoutRefreshCoroutine); _layoutRefreshCoroutine = null; } if (_refocusInputCoroutine != null) { StopCoroutine(_refocusInputCoroutine); _refocusInputCoroutine = null; } RestoreDefaultPlaceholder(); NotifyActivePrivateConversationChanged(); } private void OnDestroy() { KillVisibilityTween(); if (closeButton != null) { closeButton.onClick.RemoveListener(CloseSelf); } if (back_to_worldwideChatButton != null) { back_to_worldwideChatButton.onClick.RemoveListener(HandleBackToWorldClicked); } UnbindChatModeToggleListeners(); if (sendButton != null) { sendButton.onClick.RemoveListener(OnSendClicked); } if (messageInputField != null) { messageInputField.onEndEdit.RemoveListener(OnInputEndEdit); } if (_scrollRect != null) { _scrollRect.onValueChanged.RemoveListener(OnScrollValueChanged); } if (_layoutRefreshCoroutine != null) { StopCoroutine(_layoutRefreshCoroutine); _layoutRefreshCoroutine = null; } if (_refocusInputCoroutine != null) { StopCoroutine(_refocusInputCoroutine); _refocusInputCoroutine = null; } if (Instance == this) { Instance = null; } RestoreDefaultPlaceholder(); } private async System.Threading.Tasks.Task InitializeWorldChatAsync() { if (_service == null) { return; } try { await SwitchToWorldChannelAsync(); } catch (Exception ex) { Debug.LogWarning($"[globalChatSystem] Init world chat failed: {ex.Message}"); } } private void HandleWorldChatMessagesChanged(IReadOnlyList messages) { if (_currentMode != ChatChannelMode.World) { return; } RenderMessages(messages, true); } private void HandlePrivateChatMessagesChanged(string partnerId, IReadOnlyList messages) { if (_currentMode != ChatChannelMode.Private || string.IsNullOrWhiteSpace(_currentPrivatePartnerId) || !string.Equals(_currentPrivatePartnerId, partnerId, StringComparison.Ordinal)) { return; } ApplyChannelHeader(); RenderMessages(messages, true); } private IEnumerator OpenPrivateConversationCoroutine(string steamId) { yield return null; if (!string.IsNullOrWhiteSpace(steamId)) { _ = SwitchToPrivateChannelAsync(steamId); } } private async System.Threading.Tasks.Task SwitchToWorldChannelAsync() { if (_service == null) { return; } _currentMode = ChatChannelMode.World; _currentPrivatePartnerId = null; ResetFeedForChannelSwitch(); ApplyChannelHeader(); NotifyActivePrivateConversationChanged(); await _service.EnsureWorldChatConnected(); await _service.LoadWorldHistory(InitialHistoryLoadLimit); RenderMessages(_service.GetWorldChatMessages(), false); } private async System.Threading.Tasks.Task SwitchToPrivateChannelAsync(string partnerId) { if (_service == null || string.IsNullOrWhiteSpace(partnerId)) { return; } _currentMode = ChatChannelMode.Private; _currentPrivatePartnerId = partnerId.Trim(); RememberPrivatePartner(_currentPrivatePartnerId); ResetFeedForChannelSwitch(); ApplyChannelHeader(); NotifyActivePrivateConversationChanged(); await _service.LoadPrivateHistory(_currentPrivatePartnerId, InitialHistoryLoadLimit); RenderMessages(_service.GetPrivateChatMessages(_currentPrivatePartnerId), false); } private void HandleBackToWorldClicked() { _ = SwitchToWorldChannelAsync(); } private void ApplyChannelHeader() { if (back_to_worldwideChatButton != null) { back_to_worldwideChatButton.gameObject.SetActive(_currentMode == ChatChannelMode.Private && !HasChatModeToggles()); } if (_modeTogglesAutoCreated && channelNameText != null) { channelNameText.gameObject.SetActive(false); } UpdateChatModeToggleVisuals(); if (channelNameText == null || _modeTogglesAutoCreated) { return; } if (_currentMode == ChatChannelMode.Private && !string.IsNullOrWhiteSpace(_currentPrivatePartnerId)) { string partnerName = ResolvePrivatePartnerName(_currentPrivatePartnerId); channelNameText.text = string.Format(PrivateChannelNameFormat, partnerName); return; } channelNameText.text = string.IsNullOrWhiteSpace(_defaultChannelName) ? DefaultWorldChannelName : _defaultChannelName; } private string ResolvePrivatePartnerName(string partnerId) { if (string.IsNullOrWhiteSpace(partnerId)) { return string.Empty; } if (_service != null) { SocialFriendEntry friend = _service.GetFriendsSnapshot().FirstOrDefault(item => item != null && string.Equals(item.friend_steam_id, partnerId, StringComparison.Ordinal)); if (friend != null) { return NetworkManager.ResolveDisplayName(friend.friend_name, partnerId, false); } } return NetworkManager.ResolveDisplayName(string.Empty, partnerId, false); } private void ResetFeedForChannelSwitch() { _systemFeedEntries.Clear(); ClearDisplayedMessages(); _isViewPinnedToBottom = true; _windowStartIndex = -1; _lastFeedCount = 0; } private async void OnSendClicked() { if (_service == null || messageInputField == null) { return; } if (Time.unscaledTime < _nextSendAllowedAt) { AddSystemMessage(string.Format("\u4f60\u8fd8\u6709{0}\u79d2\u624d\u53ef\u4ee5\u53d1\u9001\u6d88\u606f", Mathf.CeilToInt(_nextSendAllowedAt - Time.unscaledTime))); RefocusInputNextFrame(); UpdateSendUiState(); return; } string content = messageInputField.text != null ? messageInputField.text.Trim() : string.Empty; if (string.IsNullOrWhiteSpace(content)) { RefocusInputNextFrame(); return; } if (_isSendingMessage && string.Equals(content, _pendingSendContent, StringComparison.Ordinal)) { RefocusInputNextFrame(); return; } BeginSendCooldown(GetCurrentSendCooldownSeconds()); _isSendingMessage = true; _pendingSendContent = content; try { messageInputField.text = string.Empty; RefocusInputNextFrame(); if (_currentMode == ChatChannelMode.Private) { await _service.SendPrivateMessage(_currentPrivatePartnerId, content); } else { await _service.SendWorldMessage(content); } } catch (Exception ex) { if (messageInputField != null && string.IsNullOrEmpty(messageInputField.text)) { messageInputField.text = content; RefocusInputNextFrame(); } AddSystemMessage(MapWorldChatErrorToNotice(ex)); Debug.LogWarning($"[globalChatSystem] Send world message failed: {ex.Message}"); } finally { _isSendingMessage = false; _pendingSendContent = string.Empty; } } private void BeginSendCooldown(float cooldownSeconds) { _nextSendAllowedAt = Time.unscaledTime + Mathf.Max(0f, cooldownSeconds); if (_chatCooldownRoutine != null) { StopCoroutine(_chatCooldownRoutine); } _chatCooldownRoutine = StartCoroutine(ChatCooldownCoroutine()); UpdateSendUiState(); } private IEnumerator ChatCooldownCoroutine() { while (Time.unscaledTime < _nextSendAllowedAt) { UpdateSendUiState(); yield return null; } _chatCooldownRoutine = null; UpdateSendUiState(); } private void UpdateSendUiState() { bool coolingDown = Time.unscaledTime < _nextSendAllowedAt; if (sendButton != null) { sendButton.interactable = !coolingDown; } if (_placeholderText != null) { if (coolingDown) { float remain = Mathf.Max(0f, _nextSendAllowedAt - Time.unscaledTime); _placeholderText.text = string.Format(CooldownPlaceholderFormat, Mathf.CeilToInt(remain)); } else { _placeholderText.text = _defaultPlaceholder; } } } private void RestoreDefaultPlaceholder() { if (_placeholderText != null) { _placeholderText.text = _defaultPlaceholder; } } private void OnInputEndEdit(string value) { if (!isActiveAndEnabled || messageInputField == null) { return; } if (Input.GetKeyDown(KeyCode.Return) || Input.GetKeyDown(KeyCode.KeypadEnter)) { OnSendClicked(); } } private void RenderMessages(IReadOnlyList messages, bool preserveWindow) { if (playerMessageParent == null || playerMessagePrefab == null || messages == null) { ClearDisplayedMessages(); return; } List feed = BuildFeed(messages); UpdateWindowState(feed.Count, preserveWindow); int startIndex = Mathf.Clamp(_windowStartIndex, 0, Mathf.Max(0, feed.Count - 1)); int endIndex = Mathf.Min(feed.Count, startIndex + GetRenderedWindowCapacity()); var visibleEntries = new List(Mathf.Max(0, endIndex - startIndex)); for (int i = startIndex; i < endIndex; i++) { visibleEntries.Add(feed[i]); } bool changed = ApplyRenderedEntries(visibleEntries); if (changed) { ScheduleLayoutRefresh(_isViewPinnedToBottom); } } private float GetCurrentSendCooldownSeconds() { return _currentMode == ChatChannelMode.Private ? PrivateChatSendCooldownSeconds : WorldChatSendCooldownSeconds; } private void ClearDisplayedMessages() { if (playerMessageParent == null) { _renderedKeys.Clear(); _renderedObjects.Clear(); return; } for (int i = playerMessageParent.childCount - 1; i >= 0; i--) { Transform child = playerMessageParent.GetChild(i); child.SetParent(null, false); child.gameObject.SetActive(false); Destroy(child.gameObject); } _renderedKeys.Clear(); _renderedObjects.Clear(); } private Transform CreateSelfMessageWrapper() { GameObject wrapper = new GameObject("SelfMessageWrapper", typeof(RectTransform), typeof(ContentSizeFitter), typeof(VerticalLayoutGroup)); RectTransform rect = wrapper.GetComponent(); rect.SetParent(playerMessageParent, false); rect.anchorMin = new Vector2(0f, 1f); rect.anchorMax = new Vector2(0f, 1f); rect.pivot = new Vector2(0.5f, 0.5f); rect.anchoredPosition = Vector2.zero; rect.sizeDelta = new Vector2(530f, 66f); ContentSizeFitter fitter = wrapper.GetComponent(); fitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained; fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize; VerticalLayoutGroup layout = wrapper.GetComponent(); layout.padding = new RectOffset(0, 0, 0, 0); layout.spacing = 0f; layout.childAlignment = TextAnchor.UpperRight; layout.reverseArrangement = false; layout.childControlWidth = false; layout.childControlHeight = false; layout.childScaleWidth = false; layout.childScaleHeight = false; layout.childForceExpandWidth = false; layout.childForceExpandHeight = true; return rect; } private void OnScrollValueChanged(Vector2 normalizedPosition) { if (_suppressScrollEvents || _service == null) { return; } IReadOnlyList messages = GetActiveMessages(); List feed = BuildFeed(messages); if (feed.Count <= GetRenderedWindowCapacity()) { _isViewPinnedToBottom = true; return; } if (normalizedPosition.y <= 0.05f) { int maxStart = Mathf.Max(0, feed.Count - GetRenderedWindowCapacity()); if (_windowStartIndex < maxStart) { _windowStartIndex = Mathf.Min(maxStart, _windowStartIndex + GetEffectiveDisplayWindow()); _isViewPinnedToBottom = _windowStartIndex >= maxStart; RenderMessages(messages, true); SetScrollNormalizedPosition(0.15f); return; } _isViewPinnedToBottom = true; } else if (normalizedPosition.y >= 0.95f && _windowStartIndex > 0) { _windowStartIndex = Mathf.Max(0, _windowStartIndex - GetEffectiveDisplayWindow()); _isViewPinnedToBottom = false; RenderMessages(messages, true); SetScrollNormalizedPosition(0.85f); } else { _isViewPinnedToBottom = false; } } private List BuildFeed(IReadOnlyList messages) { List feed = new List(); int order = 0; int startIndex = Mathf.Max(0, messages.Count - GetEffectiveCacheLimit()); for (int i = startIndex; i < messages.Count; i++) { ArenaRoomChatMessage message = messages[i]; if (message != null) { feed.Add(new WorldFeedEntry { entry = message, timestampMs = ParseMessageUnixTimestamp(message.created_at), order = order++, key = BuildMessageKey(message), sequence = GetOrAssignFeedSequence(BuildMessageKey(message)) }); } } for (int i = 0; i < _systemFeedEntries.Count; i++) { WorldSystemFeedEntry systemEntry = _systemFeedEntries[i]; if (systemEntry == null) { continue; } feed.Add(new WorldFeedEntry { entry = systemEntry, timestampMs = systemEntry.createdAtUnixMs, order = order++, key = BuildSystemKey(systemEntry), sequence = systemEntry.sequence }); } feed.Sort(CompareFeedEntries); return feed; } private void UpdateWindowState(int feedCount, bool preserveWindow) { _lastFeedCount = feedCount; int windowCapacity = GetRenderedWindowCapacity(); int maxStart = Mathf.Max(0, feedCount - windowCapacity); if (!preserveWindow || _windowStartIndex < 0 || _isViewPinnedToBottom) { _windowStartIndex = maxStart; _isViewPinnedToBottom = true; return; } _windowStartIndex = Mathf.Clamp(_windowStartIndex, 0, maxStart); } private void AddSystemMessage(string content) { if (string.IsNullOrWhiteSpace(content)) { return; } _systemFeedEntries.Add(new WorldSystemFeedEntry { text = content, createdAtUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), sequence = _nextFeedSequence++ }); TrimSystemFeedCache(); RenderMessages(GetActiveMessages(), true); } private bool ApplyRenderedEntries(List visibleEntries) { var desiredKeys = new List(visibleEntries.Count); for (int i = 0; i < visibleEntries.Count; i++) { desiredKeys.Add(visibleEntries[i].key); } if (desiredKeys.Count == _renderedKeys.Count) { bool identical = true; for (int i = 0; i < desiredKeys.Count; i++) { if (!string.Equals(desiredKeys[i], _renderedKeys[i], StringComparison.Ordinal)) { identical = false; break; } } if (identical) { return false; } } int prefix = 0; int sharedCount = Mathf.Min(_renderedKeys.Count, desiredKeys.Count); while (prefix < sharedCount && string.Equals(_renderedKeys[prefix], desiredKeys[prefix], StringComparison.Ordinal)) { prefix++; } int suffix = 0; while (suffix < (_renderedKeys.Count - prefix) && suffix < (desiredKeys.Count - prefix) && string.Equals( _renderedKeys[_renderedKeys.Count - 1 - suffix], desiredKeys[desiredKeys.Count - 1 - suffix], StringComparison.Ordinal)) { suffix++; } for (int i = _renderedObjects.Count - suffix - 1; i >= prefix; i--) { GameObject instance = _renderedObjects[i]; if (instance != null) { Destroy(instance); } _renderedObjects.RemoveAt(i); _renderedKeys.RemoveAt(i); } for (int i = prefix; i < desiredKeys.Count - suffix; i++) { GameObject instance = CreateFeedObject(visibleEntries[i]); UI_OrderedEntryAnimator.PlaySingle(instance, i - prefix, 0.18f, 0.012f, -14f, 0.98f, true); _renderedObjects.Insert(i, instance); _renderedKeys.Insert(i, desiredKeys[i]); RegisterLayoutDirtyRoot(instance); } for (int i = 0; i < _renderedObjects.Count; i++) { if (_renderedObjects[i] != null) { _renderedObjects[i].transform.SetSiblingIndex(i); } } return true; } private GameObject CreateFeedObject(WorldFeedEntry feedEntry) { if (feedEntry.entry is WorldSystemFeedEntry systemEntry) { if (worldSystemMessagePrefab == null) { return null; } GameObject systemInstance = Instantiate(worldSystemMessagePrefab, playerMessageParent, false); systemMessagePrefab systemController = systemInstance.GetComponent(); if (systemController != null) { systemController.Bind(systemEntry.text); } return systemInstance; } ArenaRoomChatMessage message = feedEntry.entry as ArenaRoomChatMessage; if (message == null) { return null; } string localSteamId = NetworkManager.Instance != null ? NetworkManager.Instance.SteamId : string.Empty; string senderSteamId = !string.IsNullOrWhiteSpace(message.sender_id) ? message.sender_id : message.steam_id; bool isSelf = !string.IsNullOrWhiteSpace(localSteamId) && string.Equals(senderSteamId, localSteamId, StringComparison.Ordinal); Color bubbleColor = ResolveMessageBubbleColor(isSelf); string displayName = NetworkManager.ResolveDisplayName(message.display_name, senderSteamId, false); string avatarUrl = NetworkManager.ResolveAvatarUrl(message.avatar_url, senderSteamId); GameObject instance; if (isSelf && selfMessagePrefab != null) { Transform selfWrapper = CreateSelfMessageWrapper(); instance = Instantiate(selfMessagePrefab, selfWrapper, false); instance = selfWrapper.gameObject; } else { instance = Instantiate(playerMessagePrefab, playerMessageParent, false); } playerMessagePrefab controller = instance.GetComponentInChildren(); if (controller != null) { string titleId = !string.IsNullOrWhiteSpace(message.player_title_id) ? message.player_title_id : string.Empty; string title = !string.IsNullOrWhiteSpace(message.player_title) ? message.player_title : string.Empty; PlayerTitleResolver.ResolvedTitle resolvedTitle = PlayerTitleResolver.Resolve(titleId, title); controller.Bind(senderSteamId, avatarUrl, displayName, resolvedTitle.titleId, resolvedTitle.titleText, message.content, bubbleColor); } return instance; } private static string BuildSystemKey(WorldSystemFeedEntry entry) { return $"system:{entry.createdAtUnixMs}:{entry.text}"; } private static string BuildMessageKey(ArenaRoomChatMessage message) { if (message == null) { return "message:null"; } if (message.id > 0) { return $"message:id:{message.id}"; } string sender = !string.IsNullOrWhiteSpace(message.sender_id) ? message.sender_id : message.steam_id; return $"message:fallback:{message.room_code}:{sender}:{message.receiver_id}:{message.created_at}:{message.content}"; } private void TrimSystemFeedCache() { int overflow = _systemFeedEntries.Count - GetEffectiveCacheLimit(); if (overflow > 0) { _systemFeedEntries.RemoveRange(0, overflow); } } private static int CompareFeedEntries(WorldFeedEntry left, WorldFeedEntry right) { int compare = left.sequence.CompareTo(right.sequence); if (compare != 0) { return compare; } compare = left.timestampMs.CompareTo(right.timestampMs); if (compare != 0) { return compare; } return left.order.CompareTo(right.order); } private long GetOrAssignFeedSequence(string key) { if (string.IsNullOrWhiteSpace(key)) { return _nextFeedSequence++; } if (_feedSequences.TryGetValue(key, out long sequence)) { return sequence; } sequence = _nextFeedSequence++; _feedSequences[key] = sequence; return sequence; } private static long ParseMessageUnixTimestamp(string createdAt) { if (string.IsNullOrWhiteSpace(createdAt)) { return long.MaxValue; } if (long.TryParse(createdAt, out long unixValue)) { return unixValue >= 1_000_000_000_000L ? unixValue : unixValue * 1000L; } if (DateTimeOffset.TryParse(createdAt, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out DateTimeOffset parsed)) { return parsed.ToUnixTimeMilliseconds(); } return long.MaxValue; } private Color ResolveMessageBubbleColor(bool isSelf) { if (messageTypeColors == null || messageTypeColors.Length == 0) { return Color.white; } if (isSelf && messageTypeColors.Length > 1) { return messageTypeColors[1]; } return messageTypeColors[0]; } private void SetScrollNormalizedPosition(float value) { if (_scrollRect == null) { return; } _suppressScrollEvents = true; _scrollRect.verticalNormalizedPosition = Mathf.Clamp01(value); _suppressScrollEvents = false; } private static string MapWorldChatErrorToNotice(Exception ex) { string raw = ex != null ? ex.Message ?? string.Empty : string.Empty; string code = raw; int separator = raw.IndexOf(':'); if (separator >= 0) { code = raw.Substring(0, separator).Trim(); } switch (code) { case "BLOCKED_CONTENT": return "\u6d88\u606f\u5185\u5305\u542b\u8fdd\u7981\u8bcd"; case "RATE_LIMIT": return "\u53d1\u9001\u8fc7\u4e8e\u9891\u7e41\uff0c\u8bf7\u7a0d\u540e\u518d\u8bd5"; case "MESSAGE_TOO_LONG": return "\u6d88\u606f\u8d85\u8fc7\u957f\u5ea6\u9650\u5236"; case "EMPTY_MESSAGE": return "\u6d88\u606f\u5185\u5bb9\u4e3a\u7a7a"; default: return raw; } } private void ScheduleLayoutRefresh(bool scrollToBottom) { _layoutRefreshDirty = true; _scrollToBottomAfterLayout |= scrollToBottom; if (_layoutRefreshCoroutine == null) { _layoutRefreshCoroutine = StartCoroutine(DeferredLayoutRefreshCoroutine()); } } private IEnumerator DeferredLayoutRefreshCoroutine() { while (true) { yield return null; bool shouldRebuild = _layoutRefreshDirty; bool shouldScrollToBottom = _scrollToBottomAfterLayout; _layoutRefreshDirty = false; _scrollToBottomAfterLayout = false; if (shouldRebuild) { ForceRebuildLayoutImmediate(); if (shouldScrollToBottom && _scrollRect != null) { Canvas.ForceUpdateCanvases(); _scrollRect.verticalNormalizedPosition = 0f; } } if (!_layoutRefreshDirty) { break; } } _layoutRefreshCoroutine = null; } private void ForceRebuildLayoutImmediate() { if (!(playerMessageParent is RectTransform parentRect)) { return; } for (int i = 0; i < _pendingLayoutDirtyRoots.Count; i++) { RectTransform childRect = _pendingLayoutDirtyRoots[i]; if (childRect != null) { LayoutRebuilder.ForceRebuildLayoutImmediate(childRect); } } _pendingLayoutDirtyRoots.Clear(); LayoutRebuilder.ForceRebuildLayoutImmediate(parentRect); if (parentRect.parent is RectTransform outerRect) { LayoutRebuilder.ForceRebuildLayoutImmediate(outerRect); } Canvas.ForceUpdateCanvases(); } private void RefocusInputNextFrame() { _refocusInputRequested = true; if (_refocusInputCoroutine == null) { _refocusInputCoroutine = StartCoroutine(RefocusInputCoroutine()); } } private IEnumerator RefocusInputCoroutine() { while (true) { yield return null; if (!_refocusInputRequested) { break; } _refocusInputRequested = false; if (messageInputField == null || !gameObject.activeInHierarchy) { continue; } EventSystem.current?.SetSelectedGameObject(messageInputField.gameObject); messageInputField.Select(); messageInputField.ActivateInputField(); messageInputField.MoveTextEnd(false); } _refocusInputCoroutine = null; } private void RegisterLayoutDirtyRoot(GameObject instance) { if (instance == null) { return; } RectTransform rect = instance.transform as RectTransform; if (rect == null) { return; } for (int i = 0; i < _pendingLayoutDirtyRoots.Count; i++) { if (_pendingLayoutDirtyRoots[i] == rect) { return; } } _pendingLayoutDirtyRoots.Add(rect); } private void CloseSelf() { HideWithFade(); } private void EnsureCanvasGroupReference() { if (chatCanvasGroup == null) { chatCanvasGroup = GetComponent(); } } private void KillVisibilityTween() { if (_visibilityTween != null && _visibilityTween.IsActive()) { _visibilityTween.Kill(false); } _visibilityTween = null; } private void PlayOpenFadeIfNeeded() { if (chatCanvasGroup == null) { return; } KillVisibilityTween(); chatCanvasGroup.gameObject.SetActive(true); chatCanvasGroup.alpha = 0f; chatCanvasGroup.interactable = false; chatCanvasGroup.blocksRaycasts = false; _visibilityTween = chatCanvasGroup.DOFade(1f, 0.25f) .SetEase(Ease.Linear) .SetUpdate(true) .OnComplete(() => { if (chatCanvasGroup == null) { return; } chatCanvasGroup.alpha = 1f; chatCanvasGroup.interactable = true; chatCanvasGroup.blocksRaycasts = true; _visibilityTween = null; }); } private void HideWithFade() { if (chatCanvasGroup == null) { gameObject.SetActive(false); return; } KillVisibilityTween(); chatCanvasGroup.interactable = false; chatCanvasGroup.blocksRaycasts = false; _visibilityTween = chatCanvasGroup.DOFade(0f, 0.25f) .SetEase(Ease.Linear) .SetUpdate(true) .OnComplete(() => { if (chatCanvasGroup != null) { chatCanvasGroup.alpha = 0f; } gameObject.SetActive(false); _visibilityTween = null; }); } public static bool IsViewingPrivateConversation(string steamId) { if (string.IsNullOrWhiteSpace(steamId) || Instance == null || !Instance.isActiveAndEnabled) { return false; } return Instance._currentMode == ChatChannelMode.Private && string.Equals(Instance._currentPrivatePartnerId, steamId.Trim(), StringComparison.Ordinal); } private void EnsureChatModeToggles() { if (globalChatToggle == null || friendChatToggle == null) { CreateFallbackChatModeToggles(); } BindChatModeToggleListeners(); UpdateChatModeToggleVisuals(); } private void BindChatModeToggleListeners() { if (globalChatToggle != null) { globalChatToggle.onValueChanged.RemoveListener(HandleGlobalChatToggleChanged); globalChatToggle.onValueChanged.AddListener(HandleGlobalChatToggleChanged); } if (friendChatToggle != null) { friendChatToggle.onValueChanged.RemoveListener(HandleFriendChatToggleChanged); friendChatToggle.onValueChanged.AddListener(HandleFriendChatToggleChanged); } } private void UnbindChatModeToggleListeners() { if (globalChatToggle != null) { globalChatToggle.onValueChanged.RemoveListener(HandleGlobalChatToggleChanged); } if (friendChatToggle != null) { friendChatToggle.onValueChanged.RemoveListener(HandleFriendChatToggleChanged); } } private void HandleGlobalChatToggleChanged(bool isOn) { if (_suppressModeToggleEvents || !isOn) { return; } _ = SwitchToWorldChannelAsync(); } private void HandleFriendChatToggleChanged(bool isOn) { if (_suppressModeToggleEvents || !isOn) { return; } OpenRecentPrivateConversationFromToggle(); } private async void OpenRecentPrivateConversationFromToggle() { string partnerId = ResolvePreferredPrivatePartnerId(); if (string.IsNullOrWhiteSpace(partnerId)) { AddSystemMessage(NoRecentPrivateConversationNotice); UpdateChatModeToggleVisuals(); return; } await SwitchToPrivateChannelAsync(partnerId); } private void UpdateChatModeToggleVisuals() { if (globalChatToggle != null) { SetToggleWithoutNotify(globalChatToggle, _currentMode == ChatChannelMode.World); } if (friendChatToggle != null) { SetToggleWithoutNotify(friendChatToggle, _currentMode == ChatChannelMode.Private); } } private void SetToggleWithoutNotify(Toggle toggle, bool value) { if (toggle == null) { return; } _suppressModeToggleEvents = true; toggle.SetIsOnWithoutNotify(value); _suppressModeToggleEvents = false; } private bool HasChatModeToggles() { return globalChatToggle != null && friendChatToggle != null; } private void CreateFallbackChatModeToggles() { if (globalChatToggle != null && friendChatToggle != null) { return; } RectTransform headerRect = channelNameText != null ? channelNameText.rectTransform.parent as RectTransform : null; if (headerRect == null) { return; } if (channelNameText != null) { channelNameText.gameObject.SetActive(false); } headerRect.sizeDelta = new Vector2(360f, 34f); if (_chatModeToggleGroup == null) { _chatModeToggleGroup = headerRect.GetComponent(); if (_chatModeToggleGroup == null) { _chatModeToggleGroup = headerRect.gameObject.AddComponent(); } _chatModeToggleGroup.allowSwitchOff = false; } if (globalChatToggle == null) { globalChatToggle = CreateRuntimeModeToggle(headerRect, "globalChatToggle", DefaultWorldChannelName, new Vector2(-90f, 0f)); } if (friendChatToggle == null) { friendChatToggle = CreateRuntimeModeToggle(headerRect, "friendChatToggle", DefaultFriendChannelName, new Vector2(90f, 0f)); } if (globalChatToggle != null) { globalChatToggle.group = _chatModeToggleGroup; } if (friendChatToggle != null) { friendChatToggle.group = _chatModeToggleGroup; } _modeTogglesAutoCreated = globalChatToggle != null && friendChatToggle != null; } private Toggle CreateRuntimeModeToggle(RectTransform parent, string objectName, string labelText, Vector2 anchoredPosition) { Sprite defaultSprite = Resources.GetBuiltinResource("UISprite.psd"); Font defaultFont = channelNameText != null && channelNameText.font != null ? channelNameText.font : Resources.GetBuiltinResource("Arial.ttf"); GameObject toggleObject = new GameObject(objectName, typeof(RectTransform), typeof(Image), typeof(Toggle)); RectTransform toggleRect = toggleObject.GetComponent(); toggleRect.SetParent(parent, false); toggleRect.anchorMin = new Vector2(0.5f, 0.5f); toggleRect.anchorMax = new Vector2(0.5f, 0.5f); toggleRect.pivot = new Vector2(0.5f, 0.5f); toggleRect.anchoredPosition = anchoredPosition; toggleRect.sizeDelta = new Vector2(164f, 30f); Image background = toggleObject.GetComponent(); background.sprite = defaultSprite; background.type = Image.Type.Sliced; background.color = new Color(1f, 1f, 1f, 0.32f); Toggle toggle = toggleObject.GetComponent(); toggle.targetGraphic = background; toggle.transition = Selectable.Transition.ColorTint; ColorBlock colors = toggle.colors; colors.normalColor = new Color(1f, 1f, 1f, 0.45f); colors.highlightedColor = new Color(1f, 1f, 1f, 0.55f); colors.pressedColor = new Color(0.86f, 0.94f, 1f, 0.85f); colors.selectedColor = new Color(0.86f, 0.94f, 1f, 0.95f); colors.disabledColor = new Color(1f, 1f, 1f, 0.2f); toggle.colors = colors; GameObject checkmarkObject = new GameObject("Checkmark", typeof(RectTransform), typeof(Image)); RectTransform checkmarkRect = checkmarkObject.GetComponent(); checkmarkRect.SetParent(toggleRect, false); checkmarkRect.anchorMin = Vector2.zero; checkmarkRect.anchorMax = Vector2.one; checkmarkRect.offsetMin = new Vector2(3f, 3f); checkmarkRect.offsetMax = new Vector2(-3f, -3f); Image checkmark = checkmarkObject.GetComponent(); checkmark.sprite = defaultSprite; checkmark.type = Image.Type.Sliced; checkmark.color = new Color(0.63f, 0.87f, 1f, 0.95f); toggle.graphic = checkmark; GameObject labelObject = new GameObject("Label", typeof(RectTransform), typeof(Text)); RectTransform labelRect = labelObject.GetComponent(); labelRect.SetParent(toggleRect, false); labelRect.anchorMin = Vector2.zero; labelRect.anchorMax = Vector2.one; labelRect.offsetMin = new Vector2(10f, 0f); labelRect.offsetMax = new Vector2(-10f, 0f); Text label = labelObject.GetComponent(); label.font = defaultFont; label.fontSize = 16; label.alignment = TextAnchor.MiddleCenter; label.horizontalOverflow = HorizontalWrapMode.Overflow; label.verticalOverflow = VerticalWrapMode.Overflow; label.color = new Color(0.19607843f, 0.19607843f, 0.19607843f, 1f); label.text = labelText; return toggle; } private void RememberPrivatePartner(string partnerId) { if (string.IsNullOrWhiteSpace(partnerId)) { return; } _lastPrivatePartnerId = partnerId.Trim(); PlayerPrefs.SetString(LastPrivatePartnerPrefKey, _lastPrivatePartnerId); PlayerPrefs.Save(); } private string ResolvePreferredPrivatePartnerId() { if (!string.IsNullOrWhiteSpace(_lastPrivatePartnerId)) { return _lastPrivatePartnerId; } if (TryGetMostRecentPrivatePartnerId(out string recentPartnerId)) { return recentPartnerId; } return string.Empty; } private bool TryGetMostRecentPrivatePartnerId(out string partnerId) { partnerId = string.Empty; if (_service == null) { return false; } string bestPartner = string.Empty; long bestTimestamp = long.MinValue; foreach (SocialFriendEntry friend in _service.GetFriendsSnapshot()) { string candidateId = friend != null ? friend.friend_steam_id : string.Empty; if (string.IsNullOrWhiteSpace(candidateId)) { continue; } IReadOnlyList messages = _service.GetPrivateChatMessages(candidateId); if (messages == null || messages.Count == 0) { continue; } ArenaRoomChatMessage lastMessage = messages[messages.Count - 1]; long timestamp = ParseMessageUnixTimestamp(lastMessage != null ? lastMessage.created_at : string.Empty); if (timestamp > bestTimestamp) { bestTimestamp = timestamp; bestPartner = candidateId; } } if (string.IsNullOrWhiteSpace(bestPartner)) { return false; } partnerId = bestPartner; return true; } private void NotifyActivePrivateConversationChanged() { string activePartnerId = isActiveAndEnabled && _currentMode == ChatChannelMode.Private ? _currentPrivatePartnerId ?? string.Empty : string.Empty; ActivePrivateConversationChanged?.Invoke(activePartnerId); } private int GetEffectiveCacheLimit() { return Mathf.Max(InitialHistoryLoadLimit, Mathf.Max(1, maxMessageHistory)); } private int GetEffectiveDisplayWindow() { return Mathf.Clamp(messageDisplayWindow, 1, MaxDisplayWindowLimit); } private int GetRenderedWindowCapacity() { return Mathf.Max(GetEffectiveDisplayWindow(), Mathf.Min(GetEffectiveCacheLimit(), GetEffectiveDisplayWindow() * 3)); } private IReadOnlyList GetActiveMessages() { if (_service == null) { return Array.Empty(); } if (_currentMode == ChatChannelMode.Private && !string.IsNullOrWhiteSpace(_currentPrivatePartnerId)) { return _service.GetPrivateChatMessages(_currentPrivatePartnerId); } return _service.GetWorldChatMessages(); } }