using System; using System.Collections; using System.Collections.Generic; using System.Globalization; using Bansonic; using GameServer.Client; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; public class roomDetails : MonoBehaviour { private sealed class RoomSystemFeedEntry { public string richText; public long createdAtUnixMs; public long sequence; } private sealed class RoomFeedEntry { public object entry; public long timestampMs; public int order; public string key; public long sequence; } [Header("room Info")] public Text roomID; public Text roomRemainTime; public Button quitRoom; public Button quitRoom2; public Button getReady; public Button showRoomRankingList; [Header("song info")] public Text thisSongName; public Text thisSongDifficulty; [Header("change songs")] public Dropdown songDropdown; public Dropdown difficultyDropdown; [Header("objs")] public GameObject roommatePrefab; public Transform roommateParent; [Header("sprites")] public Sprite[] btmSprites; [Header("roomChatSystem")] public GameObject playerMessagePrefab; public GameObject selfMessagePrefab; public GameObject systemMessagePrefab; public Transform messageParent; [SerializeField] private int maxMessageHistory = 500; [SerializeField] private int messageDisplayWindow = 20; [SerializeField] private Color[] messageTypeColors; public InputField playerMessageInputField; public Button sendMessageButton; private ArenaRoomService _service; private Coroutine _countdownRoutine; private GameObject _roomRankingPrefabTemplate; private Transform _roomRankingSpawnParent; private GameObject _spawnedRoomRankingInstance; private ScrollRect _messageScrollRect; private Coroutine _chatCooldownRoutine; private Text _chatPlaceholderText; private string _defaultChatPlaceholder = string.Empty; private float _nextChatSendAllowedAt; private bool _isSendingRoomMessage; private string _pendingRoomMessageContent = string.Empty; private readonly List _systemFeedEntries = new List(); private readonly Dictionary _feedSequences = new Dictionary(StringComparer.Ordinal); private long _nextFeedSequence; private bool _isChatViewPinnedToBottom = true; private bool _suppressChatScrollEvents; private int _chatWindowStartIndex = -1; private readonly List _renderedChatKeys = new List(); private readonly List _renderedChatObjects = new List(); private Coroutine _messageLayoutRefreshCoroutine; private bool _messageLayoutRefreshDirty; private bool _scrollChatToBottomAfterLayout; private readonly List _pendingMessageLayoutDirtyRoots = new List(); private Coroutine _refocusChatInputCoroutine; private bool _refocusChatInputRequested; private readonly List _selectableSongs = new List(); private readonly List _selectableDifficulties = new List(); private Coroutine _songLibraryWaitRoutine; private bool _suppressSongDropdownEvents; private bool _isChangingRoomSong; private bool _isClosingSelf; private const float ChatSendCooldownSeconds = 3f; private const int InitialHistoryLoadLimit = 20; private const int MaxDisplayWindowLimit = 20; private const string CooldownPlaceholderFormat = "\u53d1\u9001\u51b7\u5374\u4e2d {0}s"; private void Awake() { EnsurePopupTween(); } public void ConfigureRoomRanking(GameObject roomRankingPrefab, Transform roomRankingSpawnParent) { _roomRankingPrefabTemplate = roomRankingPrefab; _roomRankingSpawnParent = roomRankingSpawnParent; } public void Bind(ArenaRoomService service) { EnsurePopupTween(); _service = service; UIBackStack.RegisterOrBump( this, () => this != null && gameObject != null && gameObject.activeInHierarchy, () => { if (_spawnedRoomRankingInstance != null) { CloseRoomRankingWithExitAnim(); return true; } CloseSelfWithExitAnim(); return true; }); if (_service == null) { Debug.LogWarning("[roomDetails] ArenaRoomService is missing."); return; } _service.OnRoomSnapshotChanged -= HandleRoomSnapshotChanged; _service.OnRoomSnapshotChanged += HandleRoomSnapshotChanged; _service.OnKicked -= HandleKicked; _service.OnKicked += HandleKicked; _service.OnRoomDismissed -= HandleRoomDismissed; _service.OnRoomDismissed += HandleRoomDismissed; _service.OnRoomChatMessagesChanged -= HandleRoomChatMessagesChanged; _service.OnRoomChatMessagesChanged += HandleRoomChatMessagesChanged; _service.OnRoomSystemMessageReceived -= HandleRoomSystemMessageReceived; _service.OnRoomSystemMessageReceived += HandleRoomSystemMessageReceived; if (quitRoom != null) { quitRoom.onClick.RemoveListener(OnQuitClicked); quitRoom.onClick.AddListener(OnQuitClicked); } if (quitRoom2 != null) { quitRoom2.onClick.RemoveListener(OnQuitClicked); quitRoom2.onClick.AddListener(OnQuitClicked); } if (getReady != null) { getReady.onClick.RemoveListener(OnReadyButtonClicked); getReady.onClick.AddListener(OnReadyButtonClicked); } if (showRoomRankingList != null) { showRoomRankingList.onClick.RemoveListener(OnShowRoomRankingClicked); showRoomRankingList.onClick.AddListener(OnShowRoomRankingClicked); } if (sendMessageButton != null) { sendMessageButton.onClick.RemoveListener(OnSendMessageClicked); sendMessageButton.onClick.AddListener(OnSendMessageClicked); } if (songDropdown != null) { songDropdown.onValueChanged.RemoveListener(OnSongDropdownChanged); songDropdown.onValueChanged.AddListener(OnSongDropdownChanged); } if (difficultyDropdown != null) { difficultyDropdown.onValueChanged.RemoveListener(OnDifficultyDropdownChanged); difficultyDropdown.onValueChanged.AddListener(OnDifficultyDropdownChanged); } if (playerMessageInputField != null) { playerMessageInputField.onEndEdit.RemoveListener(OnChatInputEndEdit); playerMessageInputField.onEndEdit.AddListener(OnChatInputEndEdit); _chatPlaceholderText = playerMessageInputField.placeholder as Text; if (_chatPlaceholderText != null) { _defaultChatPlaceholder = _chatPlaceholderText.text; } } _messageScrollRect = messageParent != null ? messageParent.GetComponentInParent() : null; if (_messageScrollRect != null) { _messageScrollRect.onValueChanged.RemoveListener(OnChatScrollValueChanged); _messageScrollRect.onValueChanged.AddListener(OnChatScrollValueChanged); } _service.SetRoomChatCacheLimit(GetEffectiveCacheLimit()); ClearDisplayedMessages(); UpdateChatSendUiState(); _ = InitializeRoomChatAsync(); Refresh(_service.CurrentRoom); EnsureSongDropdownsReady(); } private void OnDestroy() { UIBackStack.Unregister(this); if (_service != null) { _service.OnRoomSnapshotChanged -= HandleRoomSnapshotChanged; _service.OnKicked -= HandleKicked; _service.OnRoomDismissed -= HandleRoomDismissed; _service.OnRoomChatMessagesChanged -= HandleRoomChatMessagesChanged; _service.OnRoomSystemMessageReceived -= HandleRoomSystemMessageReceived; } if (quitRoom != null) { quitRoom.onClick.RemoveListener(OnQuitClicked); } if (quitRoom2 != null) { quitRoom2.onClick.RemoveListener(OnQuitClicked); } if (getReady != null) { getReady.onClick.RemoveListener(OnReadyButtonClicked); } if (showRoomRankingList != null) { showRoomRankingList.onClick.RemoveListener(OnShowRoomRankingClicked); } if (sendMessageButton != null) { sendMessageButton.onClick.RemoveListener(OnSendMessageClicked); } if (songDropdown != null) { songDropdown.onValueChanged.RemoveListener(OnSongDropdownChanged); } if (difficultyDropdown != null) { difficultyDropdown.onValueChanged.RemoveListener(OnDifficultyDropdownChanged); } if (playerMessageInputField != null) { playerMessageInputField.onEndEdit.RemoveListener(OnChatInputEndEdit); } if (_countdownRoutine != null) { StopCoroutine(_countdownRoutine); _countdownRoutine = null; } if (_messageScrollRect != null) { _messageScrollRect.onValueChanged.RemoveListener(OnChatScrollValueChanged); } if (_chatCooldownRoutine != null) { StopCoroutine(_chatCooldownRoutine); _chatCooldownRoutine = null; } if (_messageLayoutRefreshCoroutine != null) { StopCoroutine(_messageLayoutRefreshCoroutine); _messageLayoutRefreshCoroutine = null; } if (_refocusChatInputCoroutine != null) { StopCoroutine(_refocusChatInputCoroutine); _refocusChatInputCoroutine = null; } if (_songLibraryWaitRoutine != null) { StopCoroutine(_songLibraryWaitRoutine); _songLibraryWaitRoutine = null; } RestoreDefaultChatPlaceholder(); if (_spawnedRoomRankingInstance != null) { UIBackStack.Unregister(_spawnedRoomRankingInstance); Destroy(_spawnedRoomRankingInstance); _spawnedRoomRankingInstance = null; } } private void HandleRoomSnapshotChanged(ArenaRoomSnapshot snapshot) { if (snapshot == null) { CloseSelfWithExitAnim(); return; } Refresh(snapshot); } private void HandleKicked(string kickedSteamId, string bySteamId) { CloseSelfWithExitAnim(); } private void HandleRoomDismissed(string roomCode, string bySteamId) { CloseSelfWithExitAnim(); } private void HandleRoomChatMessagesChanged(IReadOnlyList messages) { RenderChatMessages(messages, true); } private void HandleRoomSystemMessageReceived(string richText) { if (string.IsNullOrWhiteSpace(richText)) { return; } long createdAtUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); string key = $"system:{createdAtUnixMs}:{richText}"; _systemFeedEntries.Add(new RoomSystemFeedEntry { richText = richText, createdAtUnixMs = createdAtUnixMs, sequence = GetOrAssignFeedSequence(key) }); TrimSystemFeedCache(); RenderChatMessages(_service != null ? _service.GetRoomChatMessages() : null, true); } private async System.Threading.Tasks.Task InitializeRoomChatAsync() { if (_service == null) { return; } try { await _service.EnsureRoomChatSubscribed(); RenderChatMessages(_service.GetRoomChatMessages(), false); } catch (Exception ex) { Debug.LogWarning($"[roomDetails] Init room chat failed: {ex.Message}"); } } private async void OnQuitClicked() { if (_service == null) { CloseSelfWithExitAnim(); return; } try { string localSteamId = NetworkManager.Instance != null ? NetworkManager.Instance.SteamId : string.Empty; bool localIsHost = IsLocalHost(_service.CurrentRoom, localSteamId); if (localIsHost) { await _service.DismissRoom(); } else { await _service.LeaveRoom(); } } catch (Exception ex) { Debug.LogWarning($"[roomDetails] Leave room failed: {ex.Message}"); } finally { CloseSelfWithExitAnim(); } } private void Refresh(ArenaRoomSnapshot snapshot) { if (snapshot == null) { return; } if (roomID != null) { roomID.text = snapshot.room_code; } if (thisSongName != null) { thisSongName.text = !string.IsNullOrWhiteSpace(snapshot.song_name) ? snapshot.song_name : snapshot.song_id ?? string.Empty; } if (thisSongDifficulty != null) { thisSongDifficulty.text = FormatDifficulty(snapshot.difficulty); } if (_countdownRoutine != null) { StopCoroutine(_countdownRoutine); } _countdownRoutine = StartCoroutine(UpdateRemainTime(snapshot)); UpdateQuitButtonTexts(snapshot); UpdateReadyButton(snapshot); RenderParticipants(snapshot); RefreshSongDropdowns(snapshot); } private IEnumerator UpdateRemainTime(ArenaRoomSnapshot snapshot) { while (snapshot != null && roomRemainTime != null) { long nowUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); long remainSeconds = Math.Max(0L, snapshot.expires_at_unix - nowUnix); int remainMinutes = Mathf.CeilToInt(remainSeconds / 60f); roomRemainTime.text = $"{remainMinutes}分钟内不开始游戏将自动关闭房间"; yield return new WaitForSeconds(1f); } } private void RenderParticipants(ArenaRoomSnapshot snapshot) { if (roommateParent == null || roommatePrefab == null) { return; } for (int i = roommateParent.childCount - 1; i >= 0; i--) { Destroy(roommateParent.GetChild(i).gameObject); } string localSteamId = _service != null && NetworkManager.Instance != null ? NetworkManager.Instance.SteamId : string.Empty; bool localIsHost = IsLocalHost(snapshot, localSteamId); if (snapshot.participants == null) { return; } for (int i = 0; i < snapshot.participants.Count; i++) { ArenaRoomParticipant participant = snapshot.participants[i]; GameObject item = Instantiate(roommatePrefab, roommateParent); UI_OrderedEntryAnimator.PlayFadeOnly(item, i, 0.16f, 0.012f, true); roomPrefab itemController = item.GetComponent(); if (itemController == null) { continue; } Sprite background = null; if (participant != null && participant.is_host) { if (btmSprites != null && btmSprites.Length > 0) { background = btmSprites[0]; } } else if (btmSprites != null && btmSprites.Length > 1) { background = btmSprites[1]; } itemController.Bind(participant, i + 1, localIsHost, localSteamId, background, HandleKickRequested); } } private async void HandleKickRequested(string targetSteamId) { if (_service == null || string.IsNullOrWhiteSpace(targetSteamId)) { return; } try { await _service.KickPlayer(targetSteamId); } catch (Exception ex) { Debug.LogWarning($"[roomDetails] Kick failed: {ex.Message}"); } } private async void OnReadyButtonClicked() { if (_service == null || _service.CurrentRoom == null) { return; } try { string localSteamId = NetworkManager.Instance != null ? NetworkManager.Instance.SteamId : string.Empty; bool localIsHost = IsLocalHost(_service.CurrentRoom, localSteamId); if (localIsHost) { if (CanHostStartGame(_service.CurrentRoom)) { await _service.StartGame(); } } else { await _service.ToggleReady(); } } catch (Exception ex) { Debug.LogWarning($"[roomDetails] Ready/start failed: {ex.Message}"); } } private async void OnSendMessageClicked() { if (_service == null || playerMessageInputField == null) { return; } if (Time.unscaledTime < _nextChatSendAllowedAt) { RefocusChatInputNextFrame(); UpdateChatSendUiState(); return; } string content = playerMessageInputField.text != null ? playerMessageInputField.text.Trim() : string.Empty; if (string.IsNullOrWhiteSpace(content)) { RefocusChatInputNextFrame(); return; } if (_isSendingRoomMessage && string.Equals(content, _pendingRoomMessageContent, StringComparison.Ordinal)) { RefocusChatInputNextFrame(); return; } BeginChatCooldown(); _isSendingRoomMessage = true; _pendingRoomMessageContent = content; try { playerMessageInputField.text = string.Empty; RefocusChatInputNextFrame(); await _service.SendRoomMessage(content); } catch (Exception ex) { if (playerMessageInputField != null && string.IsNullOrEmpty(playerMessageInputField.text)) { playerMessageInputField.text = content; RefocusChatInputNextFrame(); } Debug.LogWarning($"[roomDetails] Send room message failed: {ex.Message}"); } finally { _isSendingRoomMessage = false; _pendingRoomMessageContent = string.Empty; } } private void OnChatInputEndEdit(string value) { if (!isActiveAndEnabled || playerMessageInputField == null) { return; } if (Input.GetKeyDown(KeyCode.Return) || Input.GetKeyDown(KeyCode.KeypadEnter)) { OnSendMessageClicked(); } } private void BeginChatCooldown() { _nextChatSendAllowedAt = Time.unscaledTime + ChatSendCooldownSeconds; if (_chatCooldownRoutine != null) { StopCoroutine(_chatCooldownRoutine); } _chatCooldownRoutine = StartCoroutine(ChatCooldownCoroutine()); UpdateChatSendUiState(); } private IEnumerator ChatCooldownCoroutine() { while (Time.unscaledTime < _nextChatSendAllowedAt) { UpdateChatSendUiState(); yield return null; } _chatCooldownRoutine = null; UpdateChatSendUiState(); } private void UpdateChatSendUiState() { bool coolingDown = Time.unscaledTime < _nextChatSendAllowedAt; if (sendMessageButton != null) { sendMessageButton.interactable = !coolingDown; } if (_chatPlaceholderText != null) { if (coolingDown) { float remain = Mathf.Max(0f, _nextChatSendAllowedAt - Time.unscaledTime); _chatPlaceholderText.text = string.Format(CooldownPlaceholderFormat, Mathf.CeilToInt(remain)); } else { _chatPlaceholderText.text = _defaultChatPlaceholder; } } } private void RestoreDefaultChatPlaceholder() { if (_chatPlaceholderText != null) { _chatPlaceholderText.text = _defaultChatPlaceholder; } } private void RenderChatMessages(IReadOnlyList messages, bool preserveWindow) { if (messageParent == null || messages == null || playerMessagePrefab == null) { ClearDisplayedMessages(); return; } List feed = BuildRoomFeed(messages); UpdateChatWindowState(feed.Count, preserveWindow); int startIndex = Mathf.Clamp(_chatWindowStartIndex, 0, Mathf.Max(0, feed.Count - 1)); int endIndex = Mathf.Min(feed.Count, startIndex + GetRenderedChatWindowCapacity()); var visibleEntries = new List(Mathf.Max(0, endIndex - startIndex)); for (int i = startIndex; i < endIndex; i++) { visibleEntries.Add(feed[i]); } bool changed = ApplyRenderedChatEntries(visibleEntries); if (changed) { ScheduleMessageLayoutRefresh(_isChatViewPinnedToBottom); } } private bool ApplyRenderedChatEntries(List visibleEntries) { var desiredKeys = new List(visibleEntries.Count); for (int i = 0; i < visibleEntries.Count; i++) { desiredKeys.Add(visibleEntries[i].key); } if (desiredKeys.Count == _renderedChatKeys.Count) { bool identical = true; for (int i = 0; i < desiredKeys.Count; i++) { if (!string.Equals(desiredKeys[i], _renderedChatKeys[i], StringComparison.Ordinal)) { identical = false; break; } } if (identical) { return false; } } int prefix = 0; int sharedCount = Mathf.Min(_renderedChatKeys.Count, desiredKeys.Count); while (prefix < sharedCount && string.Equals(_renderedChatKeys[prefix], desiredKeys[prefix], StringComparison.Ordinal)) { prefix++; } int suffix = 0; while (suffix < (_renderedChatKeys.Count - prefix) && suffix < (desiredKeys.Count - prefix) && string.Equals( _renderedChatKeys[_renderedChatKeys.Count - 1 - suffix], desiredKeys[desiredKeys.Count - 1 - suffix], StringComparison.Ordinal)) { suffix++; } for (int i = _renderedChatObjects.Count - suffix - 1; i >= prefix; i--) { GameObject instance = _renderedChatObjects[i]; if (instance != null) { Destroy(instance); } _renderedChatObjects.RemoveAt(i); _renderedChatKeys.RemoveAt(i); } for (int i = prefix; i < desiredKeys.Count - suffix; i++) { GameObject instance = CreateChatFeedObject(visibleEntries[i]); _renderedChatObjects.Insert(i, instance); _renderedChatKeys.Insert(i, desiredKeys[i]); RegisterMessageLayoutDirtyRoot(instance); } for (int i = 0; i < _renderedChatObjects.Count; i++) { if (_renderedChatObjects[i] != null) { _renderedChatObjects[i].transform.SetSiblingIndex(i); } } return true; } private GameObject CreateChatFeedObject(RoomFeedEntry feedEntry) { object entry = feedEntry.entry; if (entry is RoomSystemFeedEntry systemEntry) { if (systemMessagePrefab == null) { return null; } GameObject systemInstance = Instantiate(systemMessagePrefab, messageParent, false); systemMessagePrefab systemController = systemInstance.GetComponent(); if (systemController != null) { systemController.Bind(systemEntry.richText); } UI_OrderedEntryAnimator.PlayFadeOnly(systemInstance, Mathf.Max(0, messageParent.childCount - 1), 0.14f, 0.01f, true); return systemInstance; } ArenaRoomChatMessage message = entry as ArenaRoomChatMessage; if (message == null) { return null; } string localSteamId = NetworkManager.Instance != null ? NetworkManager.Instance.SteamId : string.Empty; string senderSteamId = GetMessageSenderSteamId(message); bool isSelf = !string.IsNullOrWhiteSpace(localSteamId) && string.Equals(senderSteamId, localSteamId, StringComparison.Ordinal); Color bubbleColor = ResolveMessageBubbleColor(isSelf); string displayName = ResolveMessageDisplayName(message, isSelf); string title = ResolveMessageTitle(message); GameObject instance = Instantiate(playerMessagePrefab, messageParent, false); playerMessagePrefab controller = instance.GetComponent(); if (controller != null) { string avatarUrl = GameServer.Client.NetworkManager.ResolveAvatarUrl(message.avatar_url, senderSteamId); string titleId = message != null && !string.IsNullOrWhiteSpace(message.player_title_id) ? message.player_title_id : string.Empty; PlayerTitleResolver.ResolvedTitle resolvedTitle = PlayerTitleResolver.Resolve(titleId, title); controller.Bind(senderSteamId, avatarUrl, displayName, resolvedTitle.titleId, resolvedTitle.titleText, message.content, bubbleColor); } UI_OrderedEntryAnimator.PlayFadeOnly(instance, Mathf.Max(0, messageParent.childCount - 1), 0.14f, 0.01f, true); return instance; } private static string BuildRoomSystemKey(RoomSystemFeedEntry entry) { return $"system:{entry.createdAtUnixMs}:{entry.richText}"; } private static string BuildRoomMessageKey(ArenaRoomChatMessage message) { if (message == null) { return "message:null"; } if (message.id > 0) { return $"message:id:{message.id}"; } string sender = GetMessageSenderSteamId(message); return $"message:fallback:{message.room_code}:{sender}:{message.created_at}:{message.content}"; } private void TrimSystemFeedCache() { int overflow = _systemFeedEntries.Count - GetEffectiveCacheLimit(); if (overflow > 0) { _systemFeedEntries.RemoveRange(0, overflow); } } private List BuildRoomFeed(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) { string key = BuildRoomMessageKey(message); feed.Add(new RoomFeedEntry { entry = message, timestampMs = ParseMessageUnixTimestamp(message.created_at), order = order++, key = key, sequence = GetOrAssignFeedSequence(key) }); } } for (int i = 0; i < _systemFeedEntries.Count; i++) { RoomSystemFeedEntry systemEntry = _systemFeedEntries[i]; if (systemEntry == null) { continue; } feed.Add(new RoomFeedEntry { entry = systemEntry, timestampMs = systemEntry.createdAtUnixMs, order = order++, key = BuildRoomSystemKey(systemEntry), sequence = systemEntry.sequence }); } feed.Sort(CompareFeedEntries); return feed; } private void UpdateChatWindowState(int feedCount, bool preserveWindow) { int windowCapacity = GetRenderedChatWindowCapacity(); int maxStart = Mathf.Max(0, feedCount - windowCapacity); if (!preserveWindow || _chatWindowStartIndex < 0 || _isChatViewPinnedToBottom) { _chatWindowStartIndex = maxStart; _isChatViewPinnedToBottom = true; return; } _chatWindowStartIndex = Mathf.Clamp(_chatWindowStartIndex, 0, maxStart); } private void OnChatScrollValueChanged(Vector2 normalizedPosition) { if (_suppressChatScrollEvents || _service == null) { return; } List feed = BuildRoomFeed(_service.GetRoomChatMessages()); if (feed.Count <= GetRenderedChatWindowCapacity()) { _isChatViewPinnedToBottom = true; return; } if (normalizedPosition.y <= 0.05f) { int maxStart = Mathf.Max(0, feed.Count - GetRenderedChatWindowCapacity()); if (_chatWindowStartIndex < maxStart) { _chatWindowStartIndex = Mathf.Min(maxStart, _chatWindowStartIndex + GetEffectiveDisplayWindow()); _isChatViewPinnedToBottom = _chatWindowStartIndex >= maxStart; RenderChatMessages(_service.GetRoomChatMessages(), true); SetChatScrollNormalizedPosition(0.15f); return; } _isChatViewPinnedToBottom = true; } else if (normalizedPosition.y >= 0.95f && _chatWindowStartIndex > 0) { _chatWindowStartIndex = Mathf.Max(0, _chatWindowStartIndex - GetEffectiveDisplayWindow()); _isChatViewPinnedToBottom = false; RenderChatMessages(_service.GetRoomChatMessages(), true); SetChatScrollNormalizedPosition(0.85f); } else { _isChatViewPinnedToBottom = false; } } private static int CompareFeedEntries(RoomFeedEntry left, RoomFeedEntry 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 void ClearDisplayedMessages() { if (messageParent == null) { _renderedChatKeys.Clear(); _renderedChatObjects.Clear(); return; } for (int i = messageParent.childCount - 1; i >= 0; i--) { Transform child = messageParent.GetChild(i); child.SetParent(null, false); child.gameObject.SetActive(false); Destroy(child.gameObject); } _renderedChatKeys.Clear(); _renderedChatObjects.Clear(); } 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 string ResolveMessageDisplayName(ArenaRoomChatMessage message, bool isSelf) { if (message != null && !string.IsNullOrWhiteSpace(message.display_name)) { string senderSteamId = GetMessageSenderSteamId(message); if (GameServer.Client.NetworkManager.IsUsableDisplayName(message.display_name, senderSteamId)) { return message.display_name; } } string messageSteamId = GetMessageSenderSteamId(message); if (isSelf && NetworkManager.Instance != null && GameServer.Client.NetworkManager.IsUsableDisplayName(NetworkManager.Instance.SteamDisplayName, messageSteamId)) { return NetworkManager.Instance.SteamDisplayName; } if (_service != null && _service.CurrentRoom != null && _service.CurrentRoom.participants != null && message != null) { foreach (ArenaRoomParticipant participant in _service.CurrentRoom.participants) { if (participant != null && string.Equals(participant.steam_id, messageSteamId, StringComparison.Ordinal) && GameServer.Client.NetworkManager.IsUsableDisplayName(participant.display_name, messageSteamId)) { return participant.display_name; } } } return GameServer.Client.NetworkManager.ResolveDisplayName(message != null ? message.display_name : string.Empty, messageSteamId, false); } private static string ResolveMessageTitle(ArenaRoomChatMessage message) { return message != null && !string.IsNullOrWhiteSpace(message.player_title) ? message.player_title : string.Empty; } private static string GetMessageSenderSteamId(ArenaRoomChatMessage message) { if (message == null) { return string.Empty; } if (!string.IsNullOrWhiteSpace(message.sender_id)) { return message.sender_id; } return message.steam_id ?? string.Empty; } private void ScheduleMessageLayoutRefresh(bool scrollToBottom) { _messageLayoutRefreshDirty = true; _scrollChatToBottomAfterLayout |= scrollToBottom; if (_messageLayoutRefreshCoroutine == null) { _messageLayoutRefreshCoroutine = StartCoroutine(DeferredMessageLayoutRefreshCoroutine()); } } private IEnumerator DeferredMessageLayoutRefreshCoroutine() { while (true) { yield return null; bool shouldRebuild = _messageLayoutRefreshDirty; bool shouldScrollToBottom = _scrollChatToBottomAfterLayout; _messageLayoutRefreshDirty = false; _scrollChatToBottomAfterLayout = false; if (shouldRebuild) { ForceRebuildMessageLayoutImmediate(); if (shouldScrollToBottom && _messageScrollRect != null) { Canvas.ForceUpdateCanvases(); _messageScrollRect.verticalNormalizedPosition = 0f; } } if (!_messageLayoutRefreshDirty) { break; } } _messageLayoutRefreshCoroutine = null; } private void ForceRebuildMessageLayoutImmediate() { if (!(messageParent is RectTransform messageParentRect)) { return; } for (int i = 0; i < _pendingMessageLayoutDirtyRoots.Count; i++) { RectTransform childRect = _pendingMessageLayoutDirtyRoots[i]; if (childRect != null) { LayoutRebuilder.ForceRebuildLayoutImmediate(childRect); } } _pendingMessageLayoutDirtyRoots.Clear(); LayoutRebuilder.ForceRebuildLayoutImmediate(messageParentRect); if (messageParentRect.parent is RectTransform parentRect) { LayoutRebuilder.ForceRebuildLayoutImmediate(parentRect); } Canvas.ForceUpdateCanvases(); } private void RefocusChatInputNextFrame() { if (playerMessageInputField == null) { return; } _refocusChatInputRequested = true; if (_refocusChatInputCoroutine == null) { _refocusChatInputCoroutine = StartCoroutine(RefocusChatInputCoroutine()); } } private IEnumerator RefocusChatInputCoroutine() { while (true) { yield return null; if (!_refocusChatInputRequested) { break; } _refocusChatInputRequested = false; if (playerMessageInputField == null) { continue; } EventSystem.current?.SetSelectedGameObject(playerMessageInputField.gameObject); playerMessageInputField.Select(); playerMessageInputField.ActivateInputField(); playerMessageInputField.MoveTextEnd(false); } _refocusChatInputCoroutine = null; } private void SetChatScrollNormalizedPosition(float value) { if (_messageScrollRect == null) { return; } _suppressChatScrollEvents = true; _messageScrollRect.verticalNormalizedPosition = Mathf.Clamp01(value); _suppressChatScrollEvents = false; } private void RegisterMessageLayoutDirtyRoot(GameObject instance) { if (instance == null) { return; } RectTransform rect = instance.transform as RectTransform; if (rect == null) { return; } for (int i = 0; i < _pendingMessageLayoutDirtyRoots.Count; i++) { if (_pendingMessageLayoutDirtyRoots[i] == rect) { return; } } _pendingMessageLayoutDirtyRoots.Add(rect); } private void OnShowRoomRankingClicked() { if (_service == null) { return; } GameObject template = _roomRankingPrefabTemplate; Transform parent = _roomRankingSpawnParent != null ? _roomRankingSpawnParent : transform.parent; if (template == null || parent == null) { Debug.LogWarning("[roomDetails] Room ranking prefab or parent is missing."); return; } if (_spawnedRoomRankingInstance == null) { _spawnedRoomRankingInstance = Instantiate(template, parent, false); RectTransform rect = _spawnedRoomRankingInstance.GetComponent(); if (rect != null) { rect.localScale = Vector3.one; rect.anchoredPosition3D = Vector3.zero; } } UIBackStack.RegisterOrBump( _spawnedRoomRankingInstance, () => _spawnedRoomRankingInstance != null && _spawnedRoomRankingInstance.activeInHierarchy, () => { if (_spawnedRoomRankingInstance != null) { CloseRoomRankingWithExitAnim(); } return true; }); rankingListPrefab ranking = _spawnedRoomRankingInstance.GetComponent(); if (ranking == null) { Debug.LogWarning("[roomDetails] Room ranking prefab is missing rankingListPrefab component."); return; } ArenaRoomSnapshot snapshot = _service.CurrentRoom; LeaderboardData roomRanking = _service.GetRoomRankingLeaderboardData(); string localSteamId = NetworkManager.Instance != null ? NetworkManager.Instance.SteamId : string.Empty; ranking.BindRoomRanking( snapshot != null ? snapshot.song_id : string.Empty, snapshot != null ? snapshot.song_name : string.Empty, roomRanking, localSteamId); } private void CloseSelfWithExitAnim() { if (_isClosingSelf) { return; } _isClosingSelf = true; UI_PanelExitUtility.PlayExitThen(gameObject, () => { if (gameObject != null) { Destroy(gameObject); } }); } private void CloseRoomRankingWithExitAnim() { GameObject rankingInstance = _spawnedRoomRankingInstance; if (rankingInstance == null) { return; } UIBackStack.Unregister(rankingInstance); _spawnedRoomRankingInstance = null; UI_PanelExitUtility.PlayExitThen(rankingInstance, () => { if (rankingInstance != null) { Destroy(rankingInstance); } }); } private void EnsurePopupTween() { if (GetComponent() == null) { gameObject.AddComponent(); } } private void UpdateQuitButtonTexts(ArenaRoomSnapshot snapshot) { string localSteamId = NetworkManager.Instance != null ? NetworkManager.Instance.SteamId : string.Empty; bool localIsHost = IsLocalHost(snapshot, localSteamId); string buttonText = localIsHost ? "解散房间" : "退出房间"; SetButtonText(quitRoom, buttonText); SetButtonText(quitRoom2, buttonText); } private void UpdateReadyButton(ArenaRoomSnapshot snapshot) { if (getReady == null) { return; } string localSteamId = NetworkManager.Instance != null ? NetworkManager.Instance.SteamId : string.Empty; bool localIsHost = IsLocalHost(snapshot, localSteamId); int readyCount = CountReadyPlayers(snapshot); int totalCount = snapshot != null ? Mathf.Max(snapshot.player_count, snapshot.participants != null ? snapshot.participants.Count : 0) : 0; bool roomStarted = IsRoomStarted(snapshot); string buttonText; bool interactable; if (localIsHost) { buttonText = $"开始游戏({readyCount}/{totalCount})"; interactable = !roomStarted && CanHostStartGame(snapshot); } else { bool localReady = IsLocalReady(snapshot, localSteamId); buttonText = localReady ? "取消准备" : "准备"; interactable = !roomStarted; } getReady.interactable = interactable; SetButtonText(getReady, buttonText); } private static bool IsLocalHost(ArenaRoomSnapshot snapshot, string localSteamId) { if (snapshot == null || string.IsNullOrWhiteSpace(localSteamId)) { return false; } if (string.Equals(snapshot.host_steam_id, localSteamId, StringComparison.Ordinal)) { return true; } if (snapshot.participants == null) { return false; } foreach (ArenaRoomParticipant participant in snapshot.participants) { if (participant != null && string.Equals(participant.steam_id, localSteamId, StringComparison.Ordinal) && participant.is_host) { return true; } } return false; } private static bool IsLocalReady(ArenaRoomSnapshot snapshot, string localSteamId) { if (snapshot == null || snapshot.participants == null || string.IsNullOrWhiteSpace(localSteamId)) { return false; } foreach (ArenaRoomParticipant participant in snapshot.participants) { if (participant != null && string.Equals(participant.steam_id, localSteamId, StringComparison.Ordinal)) { return participant.is_ready; } } return false; } private static int CountReadyPlayers(ArenaRoomSnapshot snapshot) { if (snapshot == null || snapshot.participants == null) { return 0; } int count = 0; foreach (ArenaRoomParticipant participant in snapshot.participants) { if (participant != null && participant.is_ready) { count++; } } return count; } private static bool CanHostStartGame(ArenaRoomSnapshot snapshot) { if (snapshot == null || snapshot.participants == null || snapshot.participants.Count < 2) { return false; } foreach (ArenaRoomParticipant participant in snapshot.participants) { if (participant == null) { continue; } if (!participant.is_host && !participant.is_ready) { return false; } } return true; } private static bool IsRoomStarted(ArenaRoomSnapshot snapshot) { if (snapshot == null || string.IsNullOrWhiteSpace(snapshot.status)) { return false; } return string.Equals(snapshot.status, "STARTED", StringComparison.OrdinalIgnoreCase) || string.Equals(snapshot.status, "PLAYING", StringComparison.OrdinalIgnoreCase); } private void EnsureSongDropdownsReady() { if (songDropdown == null && difficultyDropdown == null) { return; } SongDataLibrary library = SongDataLibrary.Instance; if (library != null && library.IsLoaded) { RefreshSongDropdowns(_service != null ? _service.CurrentRoom : null); return; } if (_songLibraryWaitRoutine == null) { _songLibraryWaitRoutine = StartCoroutine(WaitForSongLibraryAndRefresh()); } } private IEnumerator WaitForSongLibraryAndRefresh() { float timeoutAt = Time.realtimeSinceStartup + 8f; while (Time.realtimeSinceStartup < timeoutAt) { SongDataLibrary library = SongDataLibrary.Instance; if (library != null && library.IsLoaded) { _songLibraryWaitRoutine = null; RefreshSongDropdowns(_service != null ? _service.CurrentRoom : null); yield break; } yield return null; } _songLibraryWaitRoutine = null; Debug.LogWarning("[roomDetails] SongDataLibrary was not ready; song dropdowns were left empty."); } private void RefreshSongDropdowns(ArenaRoomSnapshot snapshot) { if (songDropdown == null && difficultyDropdown == null) { return; } SongDataLibrary library = SongDataLibrary.Instance; if (library == null || !library.IsLoaded) { EnsureSongDropdownsReady(); UpdateSongDropdownInteractable(snapshot); return; } RebuildSelectableSongs(library); SongData selectedSong = ResolveSnapshotSong(snapshot); if (selectedSong == null && _selectableSongs.Count > 0) { selectedSong = _selectableSongs[0]; } int selectedDifficulty = ConvertDifficultyKeyToId(snapshot != null ? snapshot.difficulty : null); if (selectedSong != null) { RebuildSelectableDifficulties(selectedSong); if (!_selectableDifficulties.Contains(selectedDifficulty)) { selectedDifficulty = _selectableDifficulties.Count > 0 ? _selectableDifficulties[0] : selectedDifficulty; } } else { _selectableDifficulties.Clear(); } _suppressSongDropdownEvents = true; try { PopulateSongDropdown(selectedSong); PopulateDifficultyDropdown(selectedSong, selectedDifficulty); } finally { _suppressSongDropdownEvents = false; } UpdateSongDropdownInteractable(snapshot); } private void RebuildSelectableSongs(SongDataLibrary library) { _selectableSongs.Clear(); List songs = library != null ? library.GetAllSongs() : null; if (songs == null) { return; } songs.Sort((left, right) => { int idCompare = (left != null ? left.songID : int.MaxValue).CompareTo(right != null ? right.songID : int.MaxValue); if (idCompare != 0) { return idCompare; } return string.Compare(left != null ? left.songName : string.Empty, right != null ? right.songName : string.Empty, StringComparison.CurrentCulture); }); foreach (SongData song in songs) { if (song != null && HasPlayableDifficulty(song)) { _selectableSongs.Add(song); } } } private SongData ResolveSnapshotSong(ArenaRoomSnapshot snapshot) { if (snapshot != null && int.TryParse(snapshot.song_id, out int songId)) { for (int i = 0; i < _selectableSongs.Count; i++) { SongData song = _selectableSongs[i]; if (song != null && song.songID == songId) { return song; } } } return null; } private void RebuildSelectableDifficulties(SongData song) { _selectableDifficulties.Clear(); if (song == null || song.chartFiles == null) { return; } foreach (ChartFileEntry entry in song.chartFiles) { if (entry == null || entry.chartFile == null || _selectableDifficulties.Contains(entry.difficulty)) { continue; } _selectableDifficulties.Add(entry.difficulty); } _selectableDifficulties.Sort(); } private void PopulateSongDropdown(SongData selectedSong) { if (songDropdown == null) { return; } songDropdown.ClearOptions(); List options = new List(); for (int i = 0; i < _selectableSongs.Count; i++) { SongData song = _selectableSongs[i]; options.Add(song != null && !string.IsNullOrWhiteSpace(song.songName) ? song.songName : $"Song {song?.songID ?? 0}"); } songDropdown.AddOptions(options); int selectedIndex = selectedSong != null ? _selectableSongs.IndexOf(selectedSong) : -1; songDropdown.value = Mathf.Clamp(selectedIndex, 0, Mathf.Max(0, _selectableSongs.Count - 1)); songDropdown.RefreshShownValue(); } private void PopulateDifficultyDropdown(SongData selectedSong, int selectedDifficulty) { if (difficultyDropdown == null) { return; } difficultyDropdown.ClearOptions(); List options = new List(); for (int i = 0; i < _selectableDifficulties.Count; i++) { options.Add(FormatDifficultyOption(selectedSong, _selectableDifficulties[i])); } difficultyDropdown.AddOptions(options); int selectedIndex = _selectableDifficulties.IndexOf(selectedDifficulty); difficultyDropdown.value = Mathf.Clamp(selectedIndex, 0, Mathf.Max(0, _selectableDifficulties.Count - 1)); difficultyDropdown.RefreshShownValue(); } private void UpdateSongDropdownInteractable(ArenaRoomSnapshot snapshot) { string localSteamId = NetworkManager.Instance != null ? NetworkManager.Instance.SteamId : string.Empty; bool canChange = !_isChangingRoomSong && snapshot != null && IsLocalHost(snapshot, localSteamId) && !IsRoomStarted(snapshot) && _selectableSongs.Count > 0; if (songDropdown != null) { songDropdown.interactable = canChange; } if (difficultyDropdown != null) { difficultyDropdown.interactable = canChange && _selectableDifficulties.Count > 0; } } private void OnSongDropdownChanged(int index) { if (_suppressSongDropdownEvents || index < 0 || index >= _selectableSongs.Count) { return; } SongData song = _selectableSongs[index]; int currentDifficulty = GetSelectedDifficultyId(); RebuildSelectableDifficulties(song); if (!_selectableDifficulties.Contains(currentDifficulty)) { currentDifficulty = _selectableDifficulties.Count > 0 ? _selectableDifficulties[0] : 2; } _suppressSongDropdownEvents = true; try { PopulateDifficultyDropdown(song, currentDifficulty); } finally { _suppressSongDropdownEvents = false; } RequestChangeRoomSong(song, currentDifficulty); } private void OnDifficultyDropdownChanged(int index) { if (_suppressSongDropdownEvents || index < 0 || index >= _selectableDifficulties.Count) { return; } SongData song = GetSelectedSong(); RequestChangeRoomSong(song, _selectableDifficulties[index]); } private async void RequestChangeRoomSong(SongData song, int difficultyId) { if (_service == null || song == null || _isChangingRoomSong) { return; } ArenaRoomSnapshot snapshot = _service.CurrentRoom; string localSteamId = NetworkManager.Instance != null ? NetworkManager.Instance.SteamId : string.Empty; if (!IsLocalHost(snapshot, localSteamId) || IsRoomStarted(snapshot)) { RefreshSongDropdowns(snapshot); return; } string difficultyKey = ConvertDifficultyIdToKey(difficultyId); if (snapshot != null && string.Equals(snapshot.song_id, song.songID.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal) && string.Equals((snapshot.difficulty ?? string.Empty).Trim(), difficultyKey, StringComparison.OrdinalIgnoreCase)) { return; } _isChangingRoomSong = true; UpdateSongDropdownInteractable(snapshot); try { await _service.ChangeRoomSong( song.songID.ToString(CultureInfo.InvariantCulture), song.songName, difficultyKey); } catch (Exception ex) { Debug.LogWarning($"[roomDetails] Change room song failed: {ex.Message}"); RefreshSongDropdowns(_service.CurrentRoom); } finally { _isChangingRoomSong = false; UpdateSongDropdownInteractable(_service.CurrentRoom); } } private SongData GetSelectedSong() { if (songDropdown == null) { return ResolveSnapshotSong(_service != null ? _service.CurrentRoom : null); } int index = songDropdown.value; return index >= 0 && index < _selectableSongs.Count ? _selectableSongs[index] : null; } private int GetSelectedDifficultyId() { if (difficultyDropdown != null) { int index = difficultyDropdown.value; if (index >= 0 && index < _selectableDifficulties.Count) { return _selectableDifficulties[index]; } } return ConvertDifficultyKeyToId(_service != null && _service.CurrentRoom != null ? _service.CurrentRoom.difficulty : null); } private static bool HasPlayableDifficulty(SongData song) { if (song == null || song.chartFiles == null) { return false; } foreach (ChartFileEntry entry in song.chartFiles) { if (entry != null && entry.chartFile != null) { return true; } } return false; } private static string FormatDifficultyOption(SongData song, int difficultyId) { string shortName = FormatDifficulty(ConvertDifficultyIdToKey(difficultyId)); ChartFileEntry entry = song != null && song.chartFiles != null ? song.chartFiles.Find(item => item != null && item.difficulty == difficultyId) : null; if (entry != null && entry.difficultyLEVEL > 0f) { return $"{shortName} Lv.{entry.difficultyLEVEL:0.#}"; } return shortName; } private static int ConvertDifficultyKeyToId(string difficultyKey) { if (string.IsNullOrWhiteSpace(difficultyKey)) { return 2; } switch (difficultyKey.Trim().ToLowerInvariant()) { case "ez": return 0; case "hd": return 1; case "im": return 3; case "in": default: return 2; } } private static string ConvertDifficultyIdToKey(int difficultyId) { switch (difficultyId) { case 0: return "ez"; case 1: return "hd"; case 3: return "im"; case 2: default: return "in"; } } private static void SetButtonText(Button button, string text) { if (button == null) { return; } Text label = button.GetComponentInChildren(true); if (label != null) { label.text = text; } } private static string FormatDifficulty(string difficulty) { if (string.IsNullOrWhiteSpace(difficulty)) { return string.Empty; } switch (difficulty.Trim().ToLowerInvariant()) { case "ez": return "EZ"; case "hd": return "HD"; case "in": return "IN"; case "im": return "IM"; default: return difficulty; } } private int GetEffectiveCacheLimit() { return Mathf.Max(InitialHistoryLoadLimit, Mathf.Max(1, maxMessageHistory)); } private int GetEffectiveDisplayWindow() { return Mathf.Clamp(messageDisplayWindow, 1, MaxDisplayWindowLimit); } private int GetRenderedChatWindowCapacity() { return Mathf.Max(GetEffectiveDisplayWindow(), Mathf.Min(GetEffectiveCacheLimit(), GetEffectiveDisplayWindow() * 3)); } }