UI/RoomBrowser.cs
using System;
using System.Collections.Generic;
using Sandbox.Network;
using Sandbox.UI;
/// <summary>
/// 房间列表(v0.6.7.0 用户需求):主菜单 [2] JOIN GAME 打开——列出 Steam 上本游戏的公开大厅,
/// 点击行加入;REFRESH 手动刷新(显示中每 10s 自动刷一次);JOIN LOCAL 同机调试直连兜底
/// (双开时大厅查询搜不到同账号房间,靠这条进);BACK 回主菜单。
/// 纯 C# Panel + 同目录 RoomBrowser.cs.scss(显隐走 Style.Display,与 LobbyPanel 同款)。
/// </summary>
public sealed class RoomBrowser : PanelComponent
{
Label _status;
Label[] _rows;
Panel _list; // 房间列表滚动容器(原生网页式滚动)
Label _refresh;
Label _local;
Label _back;
readonly List<LobbyInformation> _lobbies = new();
bool _built;
bool _shown;
bool _joining; // 已点了一个房间正在连(防重复点击;成功由 Tick 加入流收尾)
bool _retryOpen; // EnsureWorld 先于 OnStart 调 Show 时重试
bool _querying;
TimeSince _sinceQuery = 999f;
const int MaxRooms = 200; // 列表容量:行壳一次建好(Display.None 零绘制),文本流式绑定
const int BindChunk = 20; // 每批绑定的行数(流式加载,滚近底部再绑下一批)
int _boundCount; // 当前已绑定显示的行数
// 样式预览(2026-09-09 用户要求):查不到真实房间时显示示例房看排版(含滚动条效果)。
// 已调完关闭;下次预览再改 true——示例行点击无反应(OnRowClicked 对超出真实数量的索引直接忽略)
const bool ShowSampleRows = false;
static readonly ( string Name, int Members, int Max, bool Full )[] SampleRows =
{
( "FLYBIRD'S ROOM", 3, 16, false ),
( "KITTEN CAFE", 16, 16, true ),
( "PRO LOBBY", 7, 16, false ),
( "中文房间测试", 5, 16, false ),
( "SNACK TIME", 12, 16, false ),
( "大球吃小球", 9, 16, false ),
( "NOOB ZONE", 2, 16, false ),
( "ROLLING THUNDER", 16, 16, true ),
( "AFTERNOON CHILL", 6, 16, false ),
( "EU DUEL CLUB", 11, 16, false ),
};
static readonly Color RowDim = new( 0.29f, 0.23f, 0.39f, 0.3f );
static readonly Color RowNormal = new( 0.29f, 0.23f, 0.39f, 0.9f );
/// <summary> 流式绑定要显示的总行数(样例模式开且无真实房间时 = 4 行示例) </summary>
int VisibleTotal => ( _lobbies.Count == 0 && ShowSampleRows ) ? SampleRows.Length : _lobbies.Count;
protected override void OnStart()
{
base.OnStart();
BuildUi();
Hide();
}
void BuildUi()
{
var root = Panel;
if ( root is null ) return;
if ( _built && _status.IsValid() && root.ChildrenCount > 0 ) return;
root.DeleteChildren( true );
root.Style.Position = PositionMode.Absolute;
root.Style.Left = 0f;
root.Style.Top = 0f;
root.Style.Right = 0f;
root.Style.Bottom = 0f;
root.Style.BackgroundColor = new Color( 0.008f, 0.012f, 0.05f, 0.55f );
root.Style.PointerEvents = PointerEvents.All;
UiKit.AttachStyles( root ); // 共享部件样式(.cr-pill 药丸按钮)
var title = new Label() { Classes = "rb-title" };
title.Text = "ROOM BROWSER";
root.AddChild( title );
var head = new Label() { Classes = "rb-head" };
head.Text = "ROOM PLAYERS";
root.AddChild( head );
// 房间列表:单列流内排列装进原生滚动容器(overflow-y + 滚轮)。
// 行壳一次建好但全部 Display.None(零绘制成本),文本由 BindMoreRows 滚近底部时分批绑定
_list = new Panel() { Classes = "rb-list" };
root.AddChild( _list );
_rows = new Label[MaxRooms];
for ( int i = 0; i < _rows.Length; i++ )
{
var row = new Label() { Classes = "rb-row" };
int index = i;
// lambda 捕获 this(实例方法)——无捕获静态 lambda 热重载后无法重映射(实测点击死锁)
row.AddEventListener( "onclick", () => OnRowClicked( index ) );
row.Style.Display = DisplayMode.None;
_list.AddChild( row );
_rows[i] = row;
}
// 状态条后于行牌创建——绘制顺序压在空牌上面(读取中/无房间/失败提示都走这里)
_status = new Label() { Classes = "rb-status" };
root.AddChild( _status );
_refresh = UiKit.PillButton( root, "REFRESH", "rb-refresh", () => OnRefreshClicked() );
_local = UiKit.PillButton( root, "JOIN LOCAL", "rb-local", () => OnLocalClicked() );
_back = UiKit.PillButton( root, "BACK", "rb-back", () => OnBackClicked() );
_built = true;
}
/// <summary> 主菜单 [2] 进入(CircleroyaleGame.MenuAction 调用):开门即查询 </summary>
public void Show()
{
BuildUi();
if ( !_built )
{
_retryOpen = true; // Panel 未就绪(EnsureWorld 同帧调用),OnUpdate 补开
return;
}
_retryOpen = false;
_shown = true;
_joining = false;
Panel.Style.Display = DisplayMode.Flex;
_sinceQuery = 999f; // 首帧立即查询
SetStatus( "" );
RenderRows();
}
/// <summary> 收起(加入成功 / BACK / 回菜单) </summary>
public void Hide()
{
_shown = false;
_retryOpen = false;
_joining = false;
if ( Panel.IsValid() ) Panel.Style.Display = DisplayMode.None;
}
/// <summary> 大厅加入失败(CircleroyaleGame.OnJoinAttemptFailed 调用):解除"正在加入"锁。
/// _joining 卡 true 会让行与两个按钮(REFRESH / JOIN LOCAL)永久点不动 </summary>
public void OnJoinDenied()
{
_joining = false;
}
/// <summary> 状态行(加入失败/超时提示也走这里) </summary>
public void SetStatus( string text )
{
if ( _status.IsValid() ) _status.Text = text;
}
/// <summary> 页面是否开着(ReconcileUi 终态判定用:开着时主菜单让位) </summary>
public bool IsOpen => _shown;
protected override void OnUpdate()
{
base.OnUpdate();
BuildUi();
if ( _retryOpen && !_shown )
{
Show();
return;
}
if ( !_shown ) return;
if ( Panel.IsValid() && Panel.Style.Display != DisplayMode.Flex ) Panel.Style.Display = DisplayMode.Flex; // 同值守卫(v0.7.8.87)
Mouse.Visibility = MouseVisibility.Visible; // 行/按钮要能点
// 显示中每 10s 自动刷新一次房间列表(手动 REFRESH 也可)
if ( !_querying && _sinceQuery > 10f ) Refresh();
// 流式加载:滚到距底 <400px 时绑下一批行牌(ScrollSize 是内容总高,随绑定增长)。
// 上限同时受行池 _rows.Length 约束(v0.7.8.87):座位不够时不能一直空转绑行
if ( _list.IsValid() && _boundCount < VisibleTotal && _boundCount < _rows.Length
&& _list.ScrollOffset.y > _list.ScrollSize.y - 400f )
BindMoreRows();
if ( Input.Pressed( "Slot4" ) ) OnBackClicked();
}
/// <summary> 查询 Steam 大厅(异步;回来时若已关闭则丢弃) </summary>
async void Refresh()
{
if ( _querying ) return;
_querying = true;
_sinceQuery = 0;
SetStatus( "FINDING ROOMS ..." );
try
{
var lobbies = await Networking.QueryLobbies();
if ( !_shown ) return; // 查询回来时页面已经关了
// 人数多的排前面(满员也列出但置灰不可点)
_lobbies.Clear();
_lobbies.AddRange( lobbies );
_lobbies.Sort( ( a, b ) => b.Members.CompareTo( a.Members ) );
if ( _lobbies.Count == 0 )
SetStatus( "NO OPEN ROOMS — HOST ONE FROM ANOTHER MACHINE, OR JOIN LOCAL BELOW" );
else
SetStatus( $"{_lobbies.Count} ROOM{(_lobbies.Count == 1 ? "" : "S")} FOUND — CLICK TO JOIN" );
RenderRows();
}
catch ( Exception e )
{
Log.Warning( $"[net] room list query failed: {e.Message}" );
if ( _shown ) SetStatus( "QUERY FAILED — TRY REFRESH" );
}
finally
{
_querying = false;
}
}
void RenderRows()
{
if ( _rows is null || _rows[0] is null || !_rows[0].IsValid() ) return;
// 刷新重绑:全部行壳先藏掉(空壳零绘制),再流式绑第一批。
// Display 同值守卫:10s 自动刷新会整表重绑,裸写=整页布局重算=周期性闪烁
_boundCount = 0;
foreach ( var r in _rows )
if ( r.IsValid() && r.Style.Display != DisplayMode.None ) r.Style.Display = DisplayMode.None;
BindMoreRows();
if ( _list.IsValid() ) _list.ScrollOffset = Vector2.Zero; // 回到顶部
}
/// <summary> 流式加载:把下一批行牌填文本显示出来(滚近底部时 OnUpdate 再调) </summary>
void BindMoreRows()
{
if ( _rows is null || _rows[0] is null || !_rows[0].IsValid() ) return;
bool samples = _lobbies.Count == 0 && ShowSampleRows;
// 双重钳位(v0.7.8.87):行池只有 MaxRooms 个,查询结果比行池多时不能越界索引
int end = Math.Min( Math.Min( VisibleTotal, _boundCount + BindChunk ), _rows.Length );
for ( int i = _boundCount; i < end; i++ )
{
var row = _rows[i];
if ( !row.IsValid() ) continue;
string text;
bool full;
if ( samples )
{
var s = SampleRows[i];
text = $"{s.Name} — {s.Members}/{s.Max}{( s.Full ? " (FULL)" : "" )}";
full = s.Full;
}
else
{
var l = _lobbies[i];
var name = string.IsNullOrWhiteSpace( l.Name ) ? "CIRCLEROYALE ROOM" : l.Name;
text = $"{name} — {l.Members}/{l.MaxMembers}{( l.IsFull ? " (FULL)" : "" )}";
full = l.IsFull;
}
// 防抖(自动刷新每 10s 重绑全部可见行,同值裸写=闪烁)
if ( row.Text != text ) row.Text = text;
var c = full ? RowDim : RowNormal;
if ( row.Style.FontColor != c ) row.Style.FontColor = c;
GameHud.SetDisplay( row, DisplayMode.Flex );
}
_boundCount = end;
}
void OnRowClicked( int index )
{
if ( !_shown || _joining ) return;
if ( index >= _lobbies.Count ) return;
var lobby = _lobbies[index];
if ( lobby.IsFull )
{
SetStatus( "THAT ROOM IS FULL — PICK ANOTHER" );
return;
}
_joining = true;
SetStatus( $"JOINING '{( string.IsNullOrWhiteSpace( lobby.Name ) ? "ROOM" : lobby.Name )}' ..." );
NetworkManager.Instance?.JoinLobby( lobby );
}
/// <summary> 手动刷新 </summary>
void OnRefreshClicked()
{
if ( !_shown || _joining ) return;
Refresh();
}
/// <summary> 同机调试直连(双开搜不到同账号大厅,走这条) </summary>
void OnLocalClicked()
{
if ( !_shown || _joining ) return;
_joining = true;
SetStatus( "CONNECTING TO LOCAL HOST ..." );
CircleroyaleGame.Current?.JoinLocalRoom();
}
/// <summary> 回主菜单 </summary>
void OnBackClicked()
{
if ( !_shown ) return;
CircleroyaleGame.Current?.CloseBrowser();
}
}