UI/GameHud.cs
using System;
using System.Collections.Generic;
using Sandbox.UI;
/// <summary>
/// 游戏 HUD(纯 C# Panel,编辑器热重载友好):质量分数、排行榜、死亡重生、操作提示。
/// 数据源 CircleroyaleGame.Current;样式在同目录 GameHud.cs.scss(按"类名.cs.scss"自动加载)。
/// 注意:面板显隐用 Style.Display(Panel 无 Enabled 属性),根面板 pointer-events 保持 none
/// (重生走键盘 Space,不需要点击)。
/// </summary>
public sealed class GameHud : PanelComponent
{
Label _mass;
Label _hint;
Label _version;
Label[] _rows;
Label _selfRow;
Label _deathTitle;
Label _deathSub;
Label _matchInfo; // 顶部中央:模式 + 倒计时(M5)
Label[] _teamRows; // 队伍总分条(团队赛,M5)
Label[] _mateRows; // 本队成员积分(排行榜下方,团队赛,v0.6.5.0):0=队头 1..4=成员
Dictionary<Guid, TagSlot> _tagMap = new();
Dictionary<int, TagSlot> _pieceTagMap = new(); // 分身名牌:Key = 分身 Id(归属名字经主人球解析)
List<TagSlot> _tagPool;
readonly List<KillFeedEntry> _killFeed = new();
bool _built;
/// <summary> 文本防抖(v0.7.1.2):值没变就不赋——Label.Text 重设会触发布局重算,
/// 每帧重设(哪怕同值)会让 HUD 闪;排行榜/计时器/名牌/队友区全是逐帧路径 </summary>
static void SetText( Label l, string text )
{
if ( l is null || !l.IsValid() || l.Text == text ) return;
l.Text = text;
}
/// <summary> 名牌槽:Label 按球身份(Component.Id)稳定绑定,防 bot 死亡/重生时名字换位跳闪 </summary>
sealed class TagSlot
{
public Label Label;
public bool Used;
public bool Shown;
}
/// <summary> 击杀播报条目(M5"谁吃了谁"):4.5s 后移除,3.5s 起淡出 </summary>
sealed class KillFeedEntry
{
public Label Label;
public TimeSince Since;
}
protected override void OnStart()
{
base.OnStart();
BuildUi();
}
void BuildUi()
{
var root = Panel;
if ( root is null ) return;
// 已建好且引用有效 → 不重复建。热重载可能半残(引用失效),整体清空重建
if ( _built && _mass.IsValid() && _hint.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.PointerEvents = PointerEvents.None;
_mass = new Label() { Classes = "mass" };
root.AddChild( _mass );
// 版本号(右上角、排行榜上方):双开时肉眼核对两端构建一致性
_version = new Label() { Classes = "version" };
_version.Text = GameConfig.Version;
root.AddChild( _version );
_hint = new Label() { Classes = "hint" };
_hint.Text = GameConfig.EnableSplit ? "WASD MOVE — SPACE SPLIT — R FEED" : "WASD / GAMEPAD — MOVE";
root.AddChild( _hint );
// 排行榜:右上角固定行数,坐标在代码里按行号排(避开 flex 陷阱)
_rows = new Label[GameConfig.LeaderboardRows];
for ( int i = 0; i < _rows.Length; i++ )
{
var row = new Label() { Classes = "row" };
row.Style.Position = PositionMode.Absolute;
row.Style.Right = 28f;
row.Style.Top = 24f + i * 24f;
root.AddChild( row );
_rows[i] = row;
}
// 自身排名在榜外时显示在排行榜正下方
_selfRow = new Label() { Classes = "row self" };
_selfRow.Style.Position = PositionMode.Absolute;
_selfRow.Style.Right = 28f;
_selfRow.Style.Top = 24f + GameConfig.LeaderboardRows * 24f;
root.AddChild( _selfRow );
// 本队成员积分(团队赛,v0.6.5.0):排行榜/自身行下方——1 行队头 + 4 行成员(4 人队封顶)
_mateRows = new Label[5];
for ( int i = 0; i < _mateRows.Length; i++ )
{
var m = new Label() { Classes = "row" };
m.Style.Position = PositionMode.Absolute;
m.Style.Right = 28f;
m.Style.Top = 300f + i * 24f;
m.Style.Display = DisplayMode.None;
root.AddChild( m );
_mateRows[i] = m;
}
// 死亡重生面板
_deathTitle = new Label() { Classes = "death-title" };
_deathTitle.Text = "YOU WERE EATEN";
root.AddChild( _deathTitle );
_deathSub = new Label() { Classes = "death-sub" };
_deathSub.Text = "PRESS SPACE / CLICK TO RESPAWN";
// lambda 捕获 this(实例方法)——无捕获的静态 lambda 热重载后无法重映射(实测点击抛 NoMatchLambda)
_deathSub.AddEventListener( "onclick", () => OnDeathSubClicked() );
root.AddChild( _deathSub );
// 比赛规则 UI(M5):顶部中央模式+倒计时;团队赛在下面加队伍总分条
_matchInfo = new Label() { Classes = "matchinfo" };
root.AddChild( _matchInfo );
_teamRows = new Label[6];
for ( int i = 0; i < _teamRows.Length; i++ )
{
var t = new Label() { Classes = "teamscore" };
t.Style.Position = PositionMode.Absolute;
t.Style.Display = DisplayMode.None;
root.AddChild( t );
_teamRows[i] = t;
}
// 名牌投影层:Label 池 + 按球身份稳定绑定,坐标每帧由世界位置投影(见 UpdateNameTags)
_tagPool = new List<TagSlot>();
_tagMap = new Dictionary<Guid, TagSlot>();
_pieceTagMap = new Dictionary<int, TagSlot>();
for ( int i = 0; i < GameConfig.NameTagPoolSize; i++ )
{
var tag = new Label() { Classes = "nametag" };
tag.Style.Position = PositionMode.Absolute;
tag.Style.Width = 160f;
tag.Style.Display = DisplayMode.None;
root.AddChild( tag );
_tagPool.Add( new TagSlot { Label = tag } );
}
_built = true;
}
protected override void OnUpdate()
{
base.OnUpdate();
BuildUi();
var game = CircleroyaleGame.Current;
if ( game is null || !_built ) return;
bool over = MatchState.MatchOver;
UpdateMass( game, over );
UpdateLeaderboard( game, over );
UpdateMates( game, over );
UpdateMatchInfo( game );
UpdateKillFeed();
UpdateDeathPanel( game, over );
// 名牌投影改由 NeonCamera 在写完相机姿态的同一时刻调用(UpdateTagPositions),
// 保证投影用的相机位置/变焦与渲染帧完全一致——引擎查询有帧间差,快速滑屏时名牌会飞出屏(实测)
}
/// <summary>
/// 名牌:把每颗球的世界位置投影到屏幕坐标摆 Label。
/// 不手推相机轴向——用 CameraComponent.ScreenToWorld 现场标定"世界↔屏幕像素"的
/// 线性映射(投影中心/右100px/下100px 三个点解基向量),对任何相机朝向与缩放都成立。
/// Label 按球 Id 稳定绑定;边缘带滞回(出屏 100px 才隐藏),防球在边界来回时闪烁。
/// </summary>
/// <summary>
/// 名牌摆放(由 NeonCamera 在写完相机姿态后调用——投影与渲染用同一份相机数据,零帧间差)。
/// 解析投影:相机为固定正交俯视(滚动 90°:屏幕右=世界-Y、屏幕下=世界-X),
/// 只依赖相机位置与 OrthoHeight 两个渲染真源。
/// </summary>
public void UpdateTagPositions( CircleroyaleGame game, Vector3 camPos, float orthoHeight )
{
if ( orthoHeight <= 0.1f ) return;
if ( _tagPool is null || _tagPool[0].Label is null || !_tagPool[0].Label.IsValid() ) return;
var panel = Panel;
if ( !panel.IsValid() ) return;
var size = panel.Box.Rect.Size;
if ( size.x < 1f || size.y < 1f ) return;
// 每帧先清占用标记,球死亡后其槽位才能被新球复用(漏掉这步池子会逐渐耗尽)
foreach ( var ts in _tagPool )
{
ts.Used = false;
}
// ScreenToWorld 是物理像素;Style.Left/Top 是逻辑单位(RootPanel 按 1080p 基准缩放)
var rootPanel = panel.FindRootPanel();
var uiScale = rootPanel is not null && rootPanel.Scale > 0.001f ? rootPanel.Scale : 1f;
// 解析投影:s = 每像素世界单位;屏幕右=-Y、屏幕下=-X
var s = orthoHeight / size.y;
// 投影 + 摆字(球与分身共用一份逻辑)
void Place( TagSlot slot, Vector3 worldPos, string text )
{
var px = size.x * 0.5f - ( worldPos.y - camPos.y ) / s;
var py = size.y * 0.5f - ( worldPos.x - camPos.x ) / s;
bool inView = px > -20f && px < size.x + 20f && py > -20f && py < size.y + 20f;
if ( !inView )
{
if ( slot.Shown )
{
slot.Label.Style.Display = DisplayMode.None;
slot.Shown = false;
}
return;
}
slot.Label.Style.Display = DisplayMode.Flex;
slot.Label.Style.Left = px / uiScale - 80f;
slot.Label.Style.Top = py / uiScale + 8f;
SetText( slot.Label, text );
slot.Shown = true;
}
TagSlot TakeSlot() => _tagPool.FirstOrDefault( s2 => !s2.Used );
foreach ( var b in game.Balls )
{
if ( !b.IsValid() || !b.Alive ) continue; // 死亡/蛰伏球:头像都隐藏,不挂名牌(浮空尸名)
if ( !_tagMap.TryGetValue( b.Id, out var slot ) )
{
slot = TakeSlot();
if ( slot is null ) continue; // 池耗尽
_tagMap[b.Id] = slot;
}
slot.Used = true;
Place( slot, b.WorldPosition, TagText( b.PlayerName, b.TeamIndex ) );
}
// 分身名牌(M4):名字经主人球解析(bot 有独立假 SteamId,能精确到哪个 bot)
foreach ( var c in game.Cells )
{
if ( c.PieceKind != CellPiece.Kind.SplitPiece ) continue;
var owner = game.FindBallBySteamId( c.OwnerSteamId );
if ( !owner.IsValid() || !owner.Alive ) continue;
if ( !_pieceTagMap.TryGetValue( c.Id, out var slot ) )
{
slot = TakeSlot();
if ( slot is null ) continue;
_pieceTagMap[c.Id] = slot;
}
slot.Used = true;
Place( slot, new Vector3( c.DrawPos.x, c.DrawPos.y, 0f ), TagText( owner.PlayerName, owner.TeamIndex ) );
}
// 本帧未用到的槽 = 实体已死亡/分身已合并:隐藏并解除绑定
for ( int i = _tagMap.Count - 1; i >= 0; i-- )
{
var kv = _tagMap.ElementAt( i );
if ( kv.Value.Used ) continue;
kv.Value.Label.Style.Display = DisplayMode.None;
kv.Value.Shown = false;
_tagMap.Remove( kv.Key );
}
for ( int i = _pieceTagMap.Count - 1; i >= 0; i-- )
{
var kv = _pieceTagMap.ElementAt( i );
if ( kv.Value.Used ) continue;
kv.Value.Label.Style.Display = DisplayMode.None;
kv.Value.Shown = false;
_pieceTagMap.Remove( kv.Key );
}
}
void UpdateMass( CircleroyaleGame game, bool over )
{
if ( !_mass.IsValid() ) return;
// 比赛结束:数值类 HUD 全部让位给结算面板
_mass.Style.Display = over ? DisplayMode.None : DisplayMode.Flex;
if ( over ) return;
var ball = game.LocalBall;
SetText( _mass, ball.IsValid() ? $"{game.TotalMassFor( ball ):0}" : "—" );
}
void UpdateLeaderboard( CircleroyaleGame game, bool over )
{
if ( _rows is null || _rows[0] is null || !_rows[0].IsValid() ) return;
if ( over )
{
for ( int i = 0; i < _rows.Length; i++ )
{
if ( _rows[i].IsValid() ) _rows[i].Text = "";
}
if ( _selfRow.IsValid() ) _selfRow.Style.Display = DisplayMode.None;
return;
}
// 按总质量(主球+分身)降序;球数 ≤ BotFillTarget,拷贝排序的开销可忽略
var list = new List<Ball>();
foreach ( var b in game.Balls )
{
if ( b.IsValid() && b.Alive ) list.Add( b );
}
var massOf = new Dictionary<Ball, float>();
foreach ( var b in list )
massOf[b] = game.TotalMassFor( b );
list.Sort( ( x, y ) => massOf[y].CompareTo( massOf[x] ) );
var local = game.LocalBall;
var selfRank = -1;
for ( int i = 0; i < list.Count; i++ )
{
if ( local.IsValid() && list[i] == local )
{
selfRank = i;
break;
}
}
for ( int i = 0; i < _rows.Length; i++ )
{
var row = _rows[i];
if ( !row.IsValid() ) continue;
if ( i >= list.Count )
{
row.Text = "";
continue;
}
var ball = list[i];
var isSelf = local.IsValid() && ball == local;
SetText( row, TeamModeRow( $"{i + 1}.", ball.PlayerName, $"{massOf[ball]:0}", Math.Max( 0, ball.TeamIndex ) ) );
row.Style.FontColor = isSelf ? new Color( 0.21f, 0.94f, 1f ) : new Color( 1f, 1f, 1f, 0.55f );
}
// 榜外自身行
var showSelf = selfRank >= _rows.Length;
if ( _selfRow.IsValid() )
{
_selfRow.Style.Display = showSelf ? DisplayMode.Flex : DisplayMode.None;
if ( showSelf )
SetText( _selfRow, TeamModeRow( $"#{selfRank + 1}.", local.PlayerName, $"{massOf[local]:0}", Math.Max( 0, local.TeamIndex ) ) );
}
}
/// <summary> 死亡面板点击重生(实例方法:热重载可重映射) </summary>
void OnDeathSubClicked() => CircleroyaleGame.Current?.TryRespawnLocal();
/// <summary>
/// 本队成员积分(团队赛,排行榜下方,v0.6.5.0):队头 "MY TEAM [T#]" + 成员按质量降序,
/// 自己那行金色高亮。普通赛 / 没有本机球 / 结算期整块隐藏。
/// </summary>
void UpdateMates( CircleroyaleGame game, bool over )
{
if ( _mateRows is null || _mateRows[0] is null || !_mateRows[0].IsValid() ) return;
var local = game.LocalBall;
bool show = !over && MatchState.IsTeam && local.IsValid();
if ( !show )
{
foreach ( var r in _mateRows )
{
if ( r.IsValid() ) r.Style.Display = DisplayMode.None;
}
return;
}
int myTeam = Math.Max( 0, local.TeamIndex );
// 本队存活成员按总质量降序(按块分队后每队 ≤ TeamSize ≤ 4 人,直接收集排序)
var mates = new List<Ball>();
foreach ( var b in game.Balls )
{
if ( b.IsValid() && b.Alive && b.TeamIndex == myTeam ) mates.Add( b );
}
var massOf = new Dictionary<Ball, float>();
foreach ( var m in mates )
massOf[m] = game.TotalMassFor( m );
mates.Sort( ( x, y ) => massOf[y].CompareTo( massOf[x] ) );
for ( int i = 0; i < _mateRows.Length; i++ )
{
var row = _mateRows[i];
if ( !row.IsValid() ) continue;
if ( i == 0 )
{
row.Style.Display = DisplayMode.Flex;
SetText( row, $"MY TEAM [T{myTeam + 1}] {mates.Sum( m => massOf[m] ):0}" );
row.Style.FontColor = MatchState.TeamColor( myTeam );
continue;
}
int k = i - 1;
if ( k >= mates.Count )
{
row.Style.Display = DisplayMode.None;
continue;
}
var mate = mates[k];
bool self = mate == local;
row.Style.Display = DisplayMode.Flex;
SetText( row, $"{( self ? ">" : " ")} {mate.PlayerName} {massOf[mate]:0}" );
row.Style.FontColor = self ? new Color( 1f, 0.85f, 0.3f ) : new Color( 1f, 1f, 1f, 0.75f );
}
}
/// <summary> 名牌文本(团队赛带 [T#] 队伍前缀,v0.6.5.0 用户需求) </summary>
string TagText( string name, int team ) =>
MatchState.IsTeam && team >= 0 ? $"[T{team + 1}] {name}" : name;
/// <summary> 排行榜行文本(团队赛带 T# 前缀;普通赛只有名次/名字/分数) </summary>
string TeamModeRow( string rank, string name, string mass, int team )
{
return MatchState.IsTeam ? $"{rank} [T{team + 1}] {name} {mass}" : $"{rank} {name} {mass}";
}
// ---- 比赛规则 UI(M5)----
void UpdateMatchInfo( CircleroyaleGame game )
{
if ( !_matchInfo.IsValid() ) return;
if ( MatchState.MatchOver )
{
_matchInfo.Style.Display = DisplayMode.None;
HideTeamRows();
return;
}
_matchInfo.Style.Display = DisplayMode.Flex;
// 倒计时格式 m:ss——分钟必须截断取整("0" 格式会四舍五入,44 秒显示成 "1:44" 的教训)
var total = (int)MathF.Ceiling( MathF.Max( 0f, MatchState.TimeLeft ) );
var mode = MatchState.IsTeam ? "TEAM BATTLE" : "FREE FOR ALL";
SetText( _matchInfo, $"{mode} {total / 60}:{total % 60:00}" );
// 最后 30 秒变红提示
_matchInfo.Style.FontColor = total < 30 ? new Color( 1f, 0.35f, 0.45f ) : new Color( 1f, 1f, 1f, 0.85f );
if ( MatchState.IsTeam ) UpdateTeamScores( game );
else HideTeamRows();
}
/// <summary> 团队赛队伍总分条:前 5 名队伍 + 本队(不在前 5 时补一条),按总分降序 </summary>
void UpdateTeamScores( CircleroyaleGame game )
{
var totals = new Dictionary<int, float>();
foreach ( var b in game.Balls )
{
if ( !b.IsValid() || !b.Alive ) continue;
var team = Math.Max( 0, b.TeamIndex );
totals.TryGetValue( team, out var m );
totals[team] = m + game.TotalMassFor( b );
}
var order = new List<int>( totals.Keys );
order.Sort( ( x, y ) => totals[y].CompareTo( totals[x] ) );
var local = game.LocalBall;
var myTeam = local.IsValid() ? Math.Max( 0, local.TeamIndex ) : -1;
var show = new List<int>();
foreach ( var t in order )
{
if ( show.Count >= 5 ) break;
show.Add( t );
}
if ( myTeam >= 0 && totals.ContainsKey( myTeam ) && !show.Contains( myTeam ) )
show.Add( myTeam );
show.Sort( ( x, y ) => totals[y].CompareTo( totals[x] ) );
// 横排居中(逻辑宽 1920),每格 170
float width = 170f;
float startX = 960f - show.Count * width / 2f;
for ( int i = 0; i < _teamRows.Length; i++ )
{
var row = _teamRows[i];
if ( !row.IsValid() ) continue;
if ( i >= show.Count )
{
row.Style.Display = DisplayMode.None;
continue;
}
var team = show[i];
row.Style.Display = DisplayMode.Flex;
row.Style.Left = startX + i * width;
SetText( row, $"T{team + 1} {totals[team]:0}" );
bool mine = team == myTeam;
row.Style.FontColor = mine ? new Color( 1f, 1f, 1f ) : MatchState.TeamColor( team );
row.Style.Opacity = mine ? 1f : 0.75f;
}
}
void HideTeamRows()
{
if ( _teamRows is null ) return;
foreach ( var r in _teamRows )
{
if ( r.IsValid() ) r.Style.Display = DisplayMode.None;
}
}
// ---- 击杀播报(M5"谁吃了谁")----
/// <summary> 加一条播报(CircleroyaleGame.OnBallEaten 双端调用):自己参与的高亮(金=吃 红=被吃) </summary>
public void AddKillFeed( string eaterName, string eatenName, float massGained, long eaterSteamId, long eatenSteamId )
{
var root = Panel;
if ( root is null || !root.IsValid() ) return;
// 上限 6 条,挤掉最老
while ( _killFeed.Count >= 6 )
{
if ( _killFeed[0].Label.IsValid() ) _killFeed[0].Label.Delete();
_killFeed.RemoveAt( 0 );
}
bool iAte = GameSfx.IsMine( eaterSteamId );
bool iDied = GameSfx.IsMine( eatenSteamId );
var l = new Label() { Classes = iAte ? "killfeed good" : iDied ? "killfeed bad" : "killfeed" };
var text = $"{eaterName} ⟫ {eatenName}";
if ( iAte && massGained >= 1f ) text += $" +{massGained:0}";
l.Text = text;
l.Style.Position = PositionMode.Absolute;
l.Style.Left = 30f;
root.AddChild( l );
_killFeed.Add( new KillFeedEntry { Label = l, Since = 0 } );
}
void UpdateKillFeed()
{
for ( int i = _killFeed.Count - 1; i >= 0; i-- )
{
var e = _killFeed[i];
if ( !e.Label.IsValid() || e.Since > 4.5f )
{
if ( e.Label.IsValid() ) e.Label.Delete();
_killFeed.RemoveAt( i );
}
}
// 自上而下重排(老条目删除后不串位)+ 尾段淡出
for ( int i = 0; i < _killFeed.Count; i++ )
{
var e = _killFeed[i];
e.Label.Style.Top = 68f + i * 26f;
e.Label.Style.Opacity = e.Since < 3.5f ? 1f : Math.Clamp( 1f - ( e.Since - 3.5f ) / 1f, 0f, 1f );
}
}
void UpdateDeathPanel( CircleroyaleGame game, bool over )
{
var dead = game.IsLocalDead && !over; // 结算期死亡面板让位
if ( _deathTitle.IsValid() )
_deathTitle.Style.Display = dead ? DisplayMode.Flex : DisplayMode.None;
if ( _deathSub.IsValid() )
_deathSub.Style.Display = dead ? DisplayMode.Flex : DisplayMode.None;
}
}