1938 lines
58 KiB
C#
1938 lines
58 KiB
C#
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<RoomSystemFeedEntry> _systemFeedEntries = new List<RoomSystemFeedEntry>();
|
||
private readonly Dictionary<string, long> _feedSequences = new Dictionary<string, long>(StringComparer.Ordinal);
|
||
private long _nextFeedSequence;
|
||
private bool _isChatViewPinnedToBottom = true;
|
||
private bool _suppressChatScrollEvents;
|
||
private int _chatWindowStartIndex = -1;
|
||
private readonly List<string> _renderedChatKeys = new List<string>();
|
||
private readonly List<GameObject> _renderedChatObjects = new List<GameObject>();
|
||
private Coroutine _messageLayoutRefreshCoroutine;
|
||
private bool _messageLayoutRefreshDirty;
|
||
private bool _scrollChatToBottomAfterLayout;
|
||
private readonly List<RectTransform> _pendingMessageLayoutDirtyRoots = new List<RectTransform>();
|
||
private Coroutine _refocusChatInputCoroutine;
|
||
private bool _refocusChatInputRequested;
|
||
private readonly List<SongData> _selectableSongs = new List<SongData>();
|
||
private readonly List<int> _selectableDifficulties = new List<int>();
|
||
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<ScrollRect>() : 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<ArenaRoomChatMessage> 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<roomPrefab>();
|
||
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<ArenaRoomChatMessage> messages, bool preserveWindow)
|
||
{
|
||
if (messageParent == null || messages == null || playerMessagePrefab == null)
|
||
{
|
||
ClearDisplayedMessages();
|
||
return;
|
||
}
|
||
|
||
List<RoomFeedEntry> 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<RoomFeedEntry>(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<RoomFeedEntry> visibleEntries)
|
||
{
|
||
var desiredKeys = new List<string>(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<systemMessagePrefab>();
|
||
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<playerMessagePrefab>();
|
||
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<RoomFeedEntry> BuildRoomFeed(IReadOnlyList<ArenaRoomChatMessage> messages)
|
||
{
|
||
List<RoomFeedEntry> feed = new List<RoomFeedEntry>();
|
||
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<RoomFeedEntry> 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<RectTransform>();
|
||
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<rankingListPrefab>();
|
||
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<UI_PanelPopupTween>() == null)
|
||
{
|
||
gameObject.AddComponent<UI_PanelPopupTween>();
|
||
}
|
||
}
|
||
|
||
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<SongData> 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<string> options = new List<string>();
|
||
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<string> options = new List<string>();
|
||
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<Text>(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));
|
||
}
|
||
}
|
||
|