UI/MainMenu.cs
using Sandbox.UI;
/// <summary>
/// 主菜单(M4;2026-09-09 参考手游布局重排,用户定稿):中央大 logo + 三按钮横排
/// (CREATE GAME / JOIN GAME / VS BOTS),左上玩家资料卡(头像/名字/等级),
/// 右上设置/排行榜小按钮;QUIT 按需求移除(编辑器停 Play 即退出,standalone 再加回)。
/// 点击 + 键盘快捷键双通道(编辑器内嵌视口可能收不到鼠标点击,键盘保底):
/// 1=建服 2=加入 3=人机 4=设置页 5(数字键)=排行榜。
/// 样式在同目录 MainMenu.cs.scss(按"类名.cs.scss"自动加载)。
/// </summary>
public sealed class MainMenu : PanelComponent
{
Label _status;
Label _level;
Image _avatar;
TimeSince _sinceAvatarTry = 1f;
bool _built;
protected override void OnStart()
{
base.OnStart();
BuildUi();
}
void BuildUi()
{
var root = Panel;
if ( root is null ) return;
// 已建好且引用有效 → 不重复建(热重载半残时整体清空重建)
if ( _built && _status.IsValid() && root.ChildrenCount > 0 ) return;
root.DeleteChildren( true );
// 左上:玩家资料卡(头像 / 名字 / 等级)
var profile = new Panel() { Classes = "profile" };
root.AddChild( profile );
_avatar = new Image() { Classes = "profile-avatar" };
profile.AddChild( _avatar );
var info = new Panel() { Classes = "profile-info" };
profile.AddChild( info );
var name = new Label() { Classes = "profile-name" };
name.Text = Sandbox.Utility.Steam.PersonaName;
info.AddChild( name );
_level = new Label() { Classes = "profile-level" };
info.AddChild( _level );
// 右上:设置 / 排行榜(图标小按钮:Gear / Trophy)
var actions = new Panel() { Classes = "top-actions" };
root.AddChild( actions );
AddIconButton( actions, "ui/skin/ic_gear.png", "settings" );
AddIconButton( actions, "ui/skin/ic_trophy.png", "rankings" );
// 中央大 logo(副标题已按需求移除)
var title = new Label() { Classes = "title" };
title.Text = "CIRCLEROYALE";
root.AddChild( title );
// 三个主按钮横排(v0.7.8.43 参考手游布局)
var row = new Panel() { Classes = "menu-row" };
root.AddChild( row );
AddButton( row, "CREATE GAME", "host" );
AddButton( row, "JOIN GAME", "join" );
AddButton( row, "VS BOTS", "bots" );
_status = new Label() { Classes = "status" };
root.AddChild( _status );
var ver = new Label() { Classes = "version" };
ver.Text = GameConfig.Version;
root.AddChild( ver );
var hint = new Label() { Classes = "hint" };
hint.Text = "WASD MOVE — SPACE SPLIT — R FEED";
root.AddChild( hint );
TryLoadAvatar();
_built = true;
}
void AddButton( Panel parent, string text, string id )
{
var b = new Label() { Classes = $"btn {id}" };
b.Text = text;
// lambda 必须捕获 this(调实例方法)——无捕获的静态 lambda 在热重载后无法重映射,
// 点击会抛 NoMatchLambda 异常(实测:按钮全变"点了没反应")
b.AddEventListener( "onclick", () => OnMenuButton( id ) );
parent.AddChild( b );
}
/// <summary> 图标小按钮(右上角设置/排行榜):dobou 槽底图 + 像素图标。
/// 图标不定位不设尺寸,全交给 scss(.icon-btn 的 flex 居中 + .icon-btn-img 的宽高)——
/// 之前图标绝对定位,参照物是整个右上角容器而不是按钮,两个图标叠在一起(实测);
/// C# 内联样式会压过样式表,这里设了尺寸用户在 scss 里就改不动了 </summary>
void AddIconButton( Panel parent, string iconPath, string id )
{
var b = new Panel() { Classes = "icon-btn" };
var icon = new Image() { Classes = "icon-btn-img" };
icon.Texture = Texture.Load( iconPath );
icon.Style.PointerEvents = PointerEvents.None; // 点击穿透到按钮
b.AddChild( icon );
b.AddEventListener( "onclick", () => OnMenuButton( id ) ); // 捕获 this:热重载可重映射
parent.AddChild( b );
}
/// <summary> 本机玩家头像:Steam 拉取(下载异步,OnUpdate 1s 节流重试),失败落占位图 </summary>
void TryLoadAvatar()
{
if ( _avatar is null || !IsValid ) return;
var sid = (long)(ulong)Sandbox.Utility.Steam.SteamId;
var tex = sid != 0 ? Texture.LoadAvatar( sid, 128 ) : null;
_avatar.Texture = tex ?? Texture.Load( GameConfig.AvatarPlaceholderPath, false );
}
/// <summary> 菜单按钮点击(实例方法:热重载可重映射) </summary>
void OnMenuButton( string id )
{
GameSfx.UiClick();
CircleroyaleGame.Current?.MenuAction( id switch { "host" => 0, "join" => 1, "bots" => 2, "settings" => 4, "rankings" => 5, _ => 3 } );
}
protected override void OnUpdate()
{
base.OnUpdate();
// 强制光标可见可交互(面板默认 PointerEvents=None,Auto 模式下没人"想要鼠标"光标就没了);
// Hide() 时恢复 Auto。放在 OnUpdate 每帧断言,任何样式级联意外都兜得住
Mouse.Visibility = MouseVisibility.Visible;
BuildUi();
// 键盘保底(编辑器内嵌视口鼠标事件不可靠,键盘一定能进来):
// [4] 设置页;数字 5 直读(Slot5 未在 Input 配置里)= 排行榜;QUIT 已移除
if ( Input.Pressed( "Slot1" ) ) CircleroyaleGame.Current?.MenuAction( 0 );
if ( Input.Pressed( "Slot2" ) ) CircleroyaleGame.Current?.MenuAction( 1 );
if ( Input.Pressed( "Slot3" ) ) CircleroyaleGame.Current?.MenuAction( 2 );
if ( Input.Pressed( "Slot4" ) ) CircleroyaleGame.Current?.MenuAction( 4 );
if ( Input.Keyboard.Pressed( "5" ) ) CircleroyaleGame.Current?.MenuAction( 5 );
// 等级角标(云端 XP 异步加载,GameXp 内部节流自愈重试)
GameXp.EnsureLocalXp();
SetText( _level, GameXp.LevelText() );
// 头像没拉到(Steam 下载异步)每秒重试一次,拉到即停
if ( _avatar.IsValid() && _avatar.Texture is null && _sinceAvatarTry > 1f )
{
_sinceAvatarTry = 0;
TryLoadAvatar();
}
}
static void SetText( Label l, string text )
{
if ( l is null || !l.IsValid() || l.Text == text ) return;
l.Text = text;
}
/// <summary> 状态行(连接中 / 失败重试提示) </summary>
public void SetStatus( string text )
{
if ( _status.IsValid() ) _status.Text = text;
}
/// <summary> 开局后隐藏菜单(Panel 无 Enabled,直接禁掉整个 GameObject),并把光标还给游戏 </summary>
public void Hide()
{
if ( !GameObject.Enabled ) return; // 已隐藏:别动光标(ReconcileUi 每帧调,房间页要光标可点)
Mouse.Visibility = MouseVisibility.Auto;
GameObject.Enabled = false;
}
/// <summary> 从房间/结算回菜单(M5):重新显示(OnUpdate 恢复光标断言) </summary>
public void Show()
{
GameObject.Enabled = true;
BuildUi();
if ( _status.IsValid() ) _status.Text = "";
}
}