Files
bansonic_beta_main/Assets/scripts/roomSystem/globalChatSystem.cs
T
2026-06-25 11:48:56 +08:00

1553 lines
47 KiB
C#

using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
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;
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<WorldSystemFeedEntry> _systemFeedEntries = new List<WorldSystemFeedEntry>();
private bool _isViewPinnedToBottom = true;
private bool _suppressScrollEvents;
private int _windowStartIndex = -1;
private int _lastFeedCount;
private readonly List<string> _renderedKeys = new List<string>();
private readonly List<GameObject> _renderedObjects = new List<GameObject>();
private readonly Dictionary<string, long> _feedSequences = new Dictionary<string, long>(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<RectTransform> _pendingLayoutDirtyRoots = new List<RectTransform>();
private Coroutine _refocusInputCoroutine;
private bool _refocusInputRequested;
private bool _suppressModeToggleEvents;
private bool _modeTogglesAutoCreated;
private ToggleGroup _chatModeToggleGroup;
public static globalChatSystem Instance { get; private set; }
public static event Action<string> 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<globalChatSystem>(FindObjectsInactive.Include, FindObjectsSortMode.None);
return all != null && all.Length > 0 ? all[0] : null;
}
private void Awake()
{
Instance = this;
_lastPrivatePartnerId = PlayerPrefs.GetString(LastPrivatePartnerPrefKey, string.Empty);
}
private void Start()
{
_service = ArenaRoomService.Instance;
_scrollRect = playerMessageParent != null ? playerMessageParent.GetComponentInParent<ScrollRect>() : 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()
{
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()
{
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<ArenaRoomChatMessage> messages)
{
if (_currentMode != ChatChannelMode.World)
{
return;
}
RenderMessages(messages, true);
}
private void HandlePrivateChatMessagesChanged(string partnerId, IReadOnlyList<ArenaRoomChatMessage> 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<ArenaRoomChatMessage> messages, bool preserveWindow)
{
if (playerMessageParent == null || playerMessagePrefab == null || messages == null)
{
ClearDisplayedMessages();
return;
}
List<WorldFeedEntry> 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<WorldFeedEntry>(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<RectTransform>();
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<ContentSizeFitter>();
fitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
VerticalLayoutGroup layout = wrapper.GetComponent<VerticalLayoutGroup>();
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<ArenaRoomChatMessage> messages = GetActiveMessages();
List<WorldFeedEntry> 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<WorldFeedEntry> BuildFeed(IReadOnlyList<ArenaRoomChatMessage> messages)
{
List<WorldFeedEntry> feed = new List<WorldFeedEntry>();
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<WorldFeedEntry> visibleEntries)
{
var desiredKeys = new List<string>(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]);
_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<systemMessagePrefab>();
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<playerMessagePrefab>();
if (controller != null)
{
controller.Bind(senderSteamId, avatarUrl, displayName, string.Empty, 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()
{
gameObject.SetActive(false);
}
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<ToggleGroup>();
if (_chatModeToggleGroup == null)
{
_chatModeToggleGroup = headerRect.gameObject.AddComponent<ToggleGroup>();
}
_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<Sprite>("UISprite.psd");
Font defaultFont = channelNameText != null && channelNameText.font != null
? channelNameText.font
: Resources.GetBuiltinResource<Font>("Arial.ttf");
GameObject toggleObject = new GameObject(objectName, typeof(RectTransform), typeof(Image), typeof(Toggle));
RectTransform toggleRect = toggleObject.GetComponent<RectTransform>();
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<Image>();
background.sprite = defaultSprite;
background.type = Image.Type.Sliced;
background.color = new Color(1f, 1f, 1f, 0.32f);
Toggle toggle = toggleObject.GetComponent<Toggle>();
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<RectTransform>();
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<Image>();
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<RectTransform>();
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<Text>();
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<ArenaRoomChatMessage> 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<ArenaRoomChatMessage> GetActiveMessages()
{
if (_service == null)
{
return Array.Empty<ArenaRoomChatMessage>();
}
if (_currentMode == ChatChannelMode.Private && !string.IsNullOrWhiteSpace(_currentPrivatePartnerId))
{
return _service.GetPrivateChatMessages(_currentPrivatePartnerId);
}
return _service.GetWorldChatMessages();
}
}