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 _buff;                            // 本地球道具 buff 状态(⚡/🧲/🛡 剩余秒,v0.7.3.0)
	Label _tips;                            // 玩法提示轮播条(左下角,v0.7.5.0 英文)
	SkillButton _btnQ;                      // 技能按钮 ×2(右下角,球球大作战手游风 v0.7.8.74)
	SkillButton _btnE;
	byte _prevPowerA = 255;                 // 本地球 Q 槽上一帧种类(入局/放技能沿 → 充能重开)
	byte _qDrawn = 255;                     // 上次绘制的槽内容(图标/透明度只在切换时写)
	byte _eDrawn = 255;
	bool _hadBall;                          // 上一帧本地球是否存在
	TimeSince _qCd = 999f;                  // Q 充能进度本地估算(host 只同步种类,进度自己计)
	float _qCdDur = 1f;
	float _qCdHeight = -1f;                 // 冷却罩上次写入的高度(防每帧写样式)
	const float SkillBtnSize = 76f;         // 与 scss 的 .skillq/.skille 尺寸保持一致
	Label _version;
	Label[] _rows;
	Label _selfRow;
	Image _crown;                           // 排行榜第一名皇冠(v0.7.8.38 像素风)
	TimeSince _sinceCrownAnchor = 999f;     // 皇冠锚点 1s 节流(读 Box.Rect/FindRootPanel 会强制布局)
	float _crownX = -1f, _crownY = -1f;     // 首名行左/上缘(逻辑单位,已除 Scale)
	Label _deathTitle;
	Label _deathSub;
	Panel _deathShade;                      // 死亡全屏黑透明遮罩(v0.7.8.77),复活即隐
	Label _deathBase;                       // 领土模式复活二选一(M7.2):大本营/前线按钮
	Label _deathFront;
	Label _thint;                           // 领土情境引导(M7.3):底部居中突出条,按情境触发带冷却
	string _thintText;
	TimeSince _sinceThint = 999f;           // 当前提示已显示时长
	float _thintDuration;                   // 当前提示停留时长
	int _lastOwnedForHint = -1;             // 本队占领数快照(捕获瞬间/开局引导检测;-1 = 未入局)
	static readonly Dictionary<string, TimeSince> HintCooldowns = new();   // 引导冷却(key → 上次显示)

	/// <summary> 清引导冷却(v0.7.8.87):常驻静态,不清的话开局引导一个进程只出现一次
	/// (领土"赢法"提示注册了 9999s 冷却)。开局/回大厅时调用 </summary>
	public static void ResetHints() => HintCooldowns.Clear();
	Panel _tmap;                            // 领土小地图(M7.3):左中 292² 四格阵,色随占领/驻军
	Panel[] _tmapCells = new Panel[16];
	Panel[] _tmapBars = new Panel[16];
	Panel _tmapSelf;                        // 本机球点(白)
	Panel[] _tmapMates = new Panel[3];      // 队友点(队色)
	float _tmapCellPx = 71f;                // 单格像素宽(build 时算好,进度条宽用)
	TimeSince _sinceTmapTick = 1f;          // 小地图 10Hz 节流
	float _logicalWCache = 1920f;           // 根面板逻辑宽缓存(1s 刷新;每帧读 Box.Rect 强制布局)
	TimeSince _sinceLogicalW = 999f;
	readonly Color[] _tmapLastBg = new Color[16];   // 上一帧格色(同值守卫,防每帧重复写样式=闪烁)
	readonly float[] _tmapLastBar = new float[16];  // 上一帧进度条宽
	readonly Color[] _tmapLastMateBg = new Color[3];
	float _tmapSelfX = -1f, _tmapSelfY = -1f;       // 本机点上次写入位置
	readonly float[] _tmapMateX = { -1f, -1f, -1f };
	readonly float[] _tmapMateY = { -1f, -1f, -1f };
	static readonly Color ColorInvalid = new Color( 0f, 0f, 0f, -1f );   // 强制首帧写入的哨兵色
	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;
	int _freeSlot;                                  // 名牌空闲槽游标(每帧清零;线性找空槽每次从头扫描是 O(n²))
	readonly List<Guid> _tagDeadScratch = new();            // 回收扫描复用缓冲(字典按索引取值扩展每次全量走查 + 结构枚举器装箱)
	readonly List<int> _pieceTagDeadScratch = new();
	readonly List<KillFeedEntry> _killFeed = new();
	readonly List<Banner> _banners = new();          // 个人高光横幅(中下艺术字,v0.7.6.0)

	// —— 排行榜/队友/队伍条每帧路径的复用缓冲(50Hz 下避免每帧堆分配)——
	readonly List<Ball> _rankList = new();
	readonly Dictionary<Ball, float> _rankMass = new();
	readonly List<Ball> _matesList = new();
	readonly Dictionary<Ball, float> _matesMass = new();
	readonly Dictionary<int, float> _teamTotals = new();
	readonly List<int> _teamOrder = new();
	readonly List<int> _teamShow = new();
	bool _built;
	bool _allySkipLogged;   // 视口过小导致队友箭头区间失效——只记一次(v0.7.8.88 诊断)

	/// <summary> 个人高光横幅条目(击杀/捡道具/放技能):2.1s 生命周期,0.3s 弹入 + 1.2s 停留 + 0.6s 淡出 </summary>
	sealed class Banner
	{
		public Label Label;
		public TimeSince Since;
		public float ShownBottom = float.MinValue;
	}

	/// <summary> 右下角技能按钮(v0.7.8.74 球球大作战手游风):方块底 + 大图标 + 冷却暗罩 + 倒数数字 + 键位角标 </summary>
	sealed class SkillButton
	{
		public Panel Root;
		public Image Icon;
		public Panel Cd;        // 冷却暗罩:贴底,高度由代码按充能进度驱动
		public Label Num;       // 充能倒数秒(居中大字,Q 专用)
	}

	/// <summary> 道具使用/激活的艺术字文案(按键释放与喂养触发共用) </summary>
	public static string ActivationText( byte kind )
	{
		switch ( (PowerUpManager.Kind)kind )
		{
			case PowerUpManager.Kind.Speed: return "SPEED BOOST!";
			case PowerUpManager.Kind.Magnet: return "MAGNET ON!";
			case PowerUpManager.Kind.Shield: return "SHIELD UP!";
			case PowerUpManager.Kind.Spike: return "SPIKE MINION DEPLOYED!";
			case PowerUpManager.Kind.Mass: return "+300 MASS!";
			default: return "";
		}
	}

	/// <summary> 道具种类色(横幅配色入口) </summary>
	public static Color KindColor( byte kind ) => PowerUpManager.ColorOf( kind );

	/// <summary> 击杀高光的金色(combo 也在这一族上放大) </summary>
	static readonly Color KillGold = new( 1f, 0.81f, 0.36f );

	/// <summary> 顶部队伍条统一墨色(深梅紫,v0.7.8.77 用户定稿)——提成常量供同值守卫比较用 </summary>
	static readonly Color TeamBarInk = new( 0.29f, 0.23f, 0.39f );

	/// <summary> 名牌默认墨色(95% 不透明度;队友出屏指示会临时染队色,回屏内要复位) </summary>
	static readonly Color TagInk = new( 0.29f, 0.23f, 0.39f, 0.95f );

	/// <summary>
	/// 击杀横幅(v0.7.7.0 连播版):第 1 杀常规行,10 秒窗口内连杀升级为连杀大字
	/// (DOUBLE KILL! → TRIPLE KILL! → RAMPAGE! → GODLIKE!,字号更大金色更亮)。
	/// </summary>
	public void AddKillBanner( int combo, string eatenName, float massGained )
	{
		string text;
		string classes;

		switch ( combo )
		{
			case 2: text = $"DOUBLE KILL!  +{massGained:0}"; break;
			case 3: text = $"TRIPLE KILL!  +{massGained:0}"; break;
			case 4: text = $"RAMPAGE!  +{massGained:0}"; break;
			case >= 5: text = $"GODLIKE!  +{massGained:0}"; break;
			default: text = $"YOU ATE {eatenName}  +{massGained:0}"; break;
		}

		classes = combo >= 2 ? "banner combo" : "banner";

		var l = new Label() { Classes = classes };
		l.Text = text;
		l.Style.FontColor = KillGold;
		Panel?.AddChild( l );
		_banners.Add( new Banner { Label = l, Since = 0 } );
	}

	// ---- 玩法提示轮播(v0.7.5.0 英文文案):每 8s 换一条(含 0.5s 渐入),随主动使用/喂养机制更新 ----

	static readonly string[] Tips =
	{
		"SPACE / RMB: split ALL cells in half, toward your cursor",
		"R / LMB: eject mass — feed big teammates or spikes",
		"Pickups store into E — press E to use; Q recharges over time (small = faster)",
		"Spit mass AT a pickup to feed it — trigger at 5% of your weight",
		"Small balls feed cheap, big balls feed dear — steal half-fed pickups",
		"Spike minion: ram it into a bigger ball — burst = 3s invincible",
		"Spike minion reverts to a normal cell after 20s (mass refunded)",
		"Shield blocks being eaten, not spikes — don't hug spikes with it",
		"Magnet: 8s of double food pickup radius",
		"Big balls must drive OVER pickups to grab them — smalls grab easy",
		"Your split cells pick up powerups into the same Q / E bag",
		"Off-screen teammates show an edge arrow — follow it to regroup",
		"Main ball eaten? Biggest cell takes over — lose all cells and you're out",
		"P: bail out of the match back to the lobby",
		"Bigger = clumsier turning — plan your moves ahead",
		"Team mode: teammates can't eat each other — stick together",
		"Feed a purple spike with R mass — it grows, then bursts spikes",
		"Can't eat them? You're solid — body-block and squeeze",
		"Big balls slowly shrink — keep eating or fade away",
	};

	int _tipIndex = -1;           // 首次轮换落到 0
	TimeSince _sinceTip = 999f;   // 首帧立即渐入第一条

	/// <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 )
		{
			// 面板被销毁(世界重建/状态切换)而 _built 还是旧的 true —— 必须清掉,
			// 否则 OnUpdate 会带着"已建好"的假象继续访问 null Panel,每帧抛 NRE 崩编辑器(实测)
			_built = false;
			return;
		}

		// 已建好且引用有效 → 不重复建。热重载可能半残(引用失效),整体清空重建
		if ( _built && _mass.IsValid() && _hint.IsValid() && root.ChildrenCount > 0 ) return;

		// 诊断(临时,客户端 HUD 爆闪排查):整树重建是"全 HUD 闪一帧"的直接嫌疑,记录每次发生
		var goName = GameObject.IsValid() ? GameObject.Name : "?";
		var parentName = GameObject.IsValid() && GameObject.Parent.IsValid() ? GameObject.Parent.Name : "?";
		Log.Info( $"[hud] BuildUi REBUILD go='{goName}' parent='{parentName}' (built={_built} massValid={_mass?.IsValid() ?? false} hintValid={_hint?.IsValid() ?? false} children={root.ChildrenCount})" );

		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;

		// 死亡遮罩(v0.7.8.77):第一个子节点 = 垫在世界之上、其余 HUD 文字之下,只压暗战场
		_deathShade = new Panel() { Classes = "death-shade" };
		_deathShade.Style.Display = DisplayMode.None;
		root.AddChild( _deathShade );

		_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/RMB SPLIT — R/LMB FEED — Q/E USE ITEM"
			: "WASD / GAMEPAD — MOVE";
		root.AddChild( _hint );

		// 道具 buff 状态(v0.7.3.0):操作提示上方,文字+倒计时,种类配色
		_buff = new Label() { Classes = "buff" };
		root.AddChild( _buff );

		// 玩法提示轮播(v0.7.4.2):buff 下方、操作提示上方,每 8s 换一条
		_tips = new Label() { Classes = "tips" };
		root.AddChild( _tips );

		// 技能按钮(v0.7.8.74 球球大作战手游风):右下角两枚方块钮(图标+冷却罩+键位角标)。
		// 冷却罩/倒数秒/角标挂 HUD 根面板(挂按钮里定位会乱,v0.7.8.76),Right 按按钮右距内联写
		_btnQ = BuildSkillButton( root, "skillq", "[Q]", 30 );
		_btnE = BuildSkillButton( root, "skille", "[E]", 118 );

		// 冷却罩/倒数秒是 Q 专属(E 道具无冷却)——不藏的话 E 上常驻一块暗斑
		_btnE.Cd.Style.Display = DisplayMode.None;
		_btnE.Num.Style.Display = DisplayMode.None;

		// 排行榜:右上角固定行数,坐标在代码里按行号排(避开 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 );

		// 领土模式复活二选一(M7.2):非领土模式隐藏
		_deathBase = new Label() { Classes = "death-btn death-btn-base" };
		_deathBase.Text = "RESPAWN — BASE";
		_deathBase.AddEventListener( "onclick", () => OnDeathRespawnClicked( false ) );
		root.AddChild( _deathBase );

		_deathFront = new Label() { Classes = "death-btn death-btn-front" };
		_deathFront.Text = "RESPAWN — FRONT";
		_deathFront.AddEventListener( "onclick", () => OnDeathRespawnClicked( true ) );
		root.AddChild( _deathFront );

		// 领土情境引导(M7.3):底部居中白底小牌,情境触发 + 冷却,非领土模式不显示
		_thint = new Label() { Classes = "thint" };
		_thint.Style.Display = DisplayMode.None;
		root.AddChild( _thint );

		// 领土小地图(M7.3):左中 292² 格阵。格位置按"屏幕系"摆放(相机 90° 滚转,
		// 地图上方 = 世界 -X、右方 = 世界 -Y,和玩家屏幕移动方向一致,导航不拧巴)
		_tmap = new Panel() { Classes = "tmap" };
		_tmap.Style.Display = DisplayMode.None;
		root.AddChild( _tmap );

		const float MapInner = 286f;   // 292 - 上下左右 3px 边框
		var scale = MapInner / ( GameConfig.ArenaHalfSize * 2f );
		var cellPx = TerritoryManager.CellSize * scale;
		_tmapCellPx = cellPx - 2f;
		for ( int cy = 0; cy < GameConfig.TerritoryGrid; cy++ )
		{
			for ( int cx = 0; cx < GameConfig.TerritoryGrid; cx++ )
			{
				var i = cy * GameConfig.TerritoryGrid + cx;
				var cell = new Panel() { Classes = "tmap-cell" };
				// 世界格 → 屏幕系地图:left = (half - y1)·scale,top = (half - x1)·scale
				var x0 = -GameConfig.ArenaHalfSize + cx * TerritoryManager.CellSize;
				var y0 = -GameConfig.ArenaHalfSize + cy * TerritoryManager.CellSize;
				cell.Style.Left = ( GameConfig.ArenaHalfSize - ( y0 + TerritoryManager.CellSize ) ) * scale + 1f;
				cell.Style.Top = ( GameConfig.ArenaHalfSize - ( x0 + TerritoryManager.CellSize ) ) * scale + 1f;
				cell.Style.Width = cellPx - 2f;
				cell.Style.Height = cellPx - 2f;
				if ( IsHqCell( cx, cy ) )
				{
					cell.Style.BorderWidth = 3f;   // 大本营格粗描边标记
					cell.Style.BorderColor = new Color( 0.29f, 0.23f, 0.39f, 0.9f );
				}
				_tmap.AddChild( cell );
				_tmapCells[i] = cell;

				var bar = new Panel() { Classes = "tmap-bar" };
				bar.Style.Width = 0f;
				cell.AddChild( bar );
				_tmapBars[i] = bar;
				_tmapLastBg[i] = ColorInvalid;
				_tmapLastBar[i] = -1f;
			}
		}
		_tmapSelf = new Panel() { Classes = "tmap-self" };
		_tmap.AddChild( _tmapSelf );
		// 重建后本机点/队友点缓存也要回哨兵,否则首帧同值守卫会跳过写入(点一直空/停在上次位置)
		_tmapSelfX = -1f;
		_tmapSelfY = -1f;
		Array.Fill( _tmapLastMateBg, ColorInvalid );
		Array.Fill( _tmapMateX, -1f );
		Array.Fill( _tmapMateY, -1f );
		for ( int i = 0; i < _tmapMates.Length; i++ )
		{
			var d = new Panel() { Classes = "tmap-mate" };
			d.Style.Display = DisplayMode.None;
			_tmap.AddChild( d );
			_tmapMates[i] = d;
		}

		// 比赛规则 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 } );
		}

		// 皇冠(v0.7.8.38 像素风):钉在排行榜第一名行左侧,位置每帧跟随行内容宽度
		_crown = new Image();
		_crown.Texture = Texture.Load( "ui/pixel/crown.png" );
		_crown.Style.Position = PositionMode.Absolute;
		_crown.Style.Width = 18f;
		_crown.Style.Height = 18f;
		_crown.Style.Display = DisplayMode.None;
		root.AddChild( _crown );

		// 重建面板树后,技能钮的"上次绘制内容"缓存必须一起复位(v0.7.8.87 修):
		// 新 Image 是空贴图,缓存里若还记着旧种类,DrawQ/DrawE 会跳过赋值 → Q/E 钮空白直到种类变化
		_qDrawn = 255;
		_eDrawn = 255;
		_prevPowerA = 255;
		_qCdHeight = -1f;
		_hadBall = false;
		_sinceCrownAnchor = 999f;   // 新建的排行行框还没量过,重建后首帧立刻重取皇冠锚点(同为 1s 节流的值)
		_sinceLogicalW = 999f;      // 逻辑宽缓存同因作废(新根面板尺寸第一帧才知道)

		_built = true;
	}

	protected override void OnUpdate()
	{
		base.OnUpdate();

		BuildUi();

		var game = CircleroyaleGame.Current;
		if ( game is null || !_built ) return;

		// 面板可能在世界重建/状态切换后被销毁(_built 由 BuildUi 归零需要一帧),本帧先别碰 Panel:
		// 从这里(原生侧回调)抛出的每帧异常会漏引擎"每帧内存栈",几分钟后崩编辑器(实测最贵的一课)
		if ( Panel is null || !Panel.IsValid() ) { _built = false; return; }

		bool over = MatchState.MatchOver;

		UpdateMass( game, over );
		UpdateLeaderboard( game, over );
		UpdateMates( game, over );
		UpdateMatchInfo( game );
		UpdateKillFeed();
		UpdateDeathPanel( game, over );
		UpdateBuff( game );
		UpdateTips();
		UpdateTerritoryHint( game );   // 领土情境引导(M7.3)
		UpdateTerritoryMap( game );    // 领土小地图(M7.3,10Hz)
		UpdatePowerSlots( game );
		UpdateBanners();
		GameAchievements.MatchTick( game );   // 成就每帧体检:质量里程碑/8身体/存活5分钟/背包双满
		GameXp.MatchTick( game );             // 等级:本机等级写进自己的球(owner [Sync],全场名牌可见)
		// 名牌投影改由 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;
		}
		_freeSlot = 0;   // 空闲槽游标同帧重置(先到先用,与旧的线性查找结果一致)

		// 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;

		// 队友出屏指示(v0.7.8.12):团队赛 + 本机球有效时启用
		var local = game.LocalBall;
		bool allyMode = MatchState.IsTeam && local.IsValid();
		var halfX = size.y * 0.5f * s;              // 屏幕高度方向 ↔ 世界 X
		var halfY = size.x * 0.5f * s;              // 屏幕宽度方向 ↔ 世界 Y
		// ⚠️ 贴边内缩必须小于半宽的一半(v0.7.8.88 修):视口小(面板高 < 112 逻辑单位)时
		// `56 * s` 会大于 halfX,下面的贴边钳制就出现 min>max —— **Math.Clamp 在 min>max 时抛异常**,
		// 每帧从托管层抛回原生层会让引擎的"每帧内存栈"还不回去,池子(6 个)耗光后
		// engine2.dll 访问违规崩溃(实测每帧 ArgumentException,红屏 + 崩溃的真因)
		var marginW = MathF.Min( 56f * s, halfX * 0.5f );   // 贴边内缩(世界单位,防小视口下区间反转)

		// 同名牌池最多 320 个:位置/显隐/字色都要同值守卫——每帧裸写会让整屏名牌参与布局重算
		// (客户端闪烁源之一,v0.7.8.87)。位置是浮点,用 0.5px 阈值;字色逐分量比
		void SetTagPos( TagSlot slot, float left, float top )
		{
			if ( !SamePx( slot.Label.Style.Left, left ) ) slot.Label.Style.Left = left;
			if ( !SamePx( slot.Label.Style.Top, top ) ) slot.Label.Style.Top = top;
		}

		void SetTagColor( TagSlot slot, Color c )
		{
			if ( !slot.Label.Style.FontColor.HasValue || !SameColor( slot.Label.Style.FontColor.Value, c ) )
				slot.Label.Style.FontColor = c;
		}

		// 投影 + 摆字(球与分身共用一份逻辑)
		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;
			}

			SetDisplay( slot.Label, DisplayMode.Flex );
			SetTagPos( slot, px / uiScale - 80f, py / uiScale + 8f );
			SetTagColor( slot, TagInk );   // 复位梅紫(队友出屏指示曾设为队伍色)
			SetText( slot.Label, text );
			slot.Shown = true;
		}

		// 空闲槽游标(v0.7.8.89):旧写法是每取一槽从池头线性找空槽,球+分身最多 ~48 次/帧 × 320 槽 = O(n²)。
		// 游标单调前进,语义仍是"第一个未占用槽"(取用方随即置 Used)
		TagSlot TakeSlot()
		{
			while ( _freeSlot < _tagPool.Count && _tagPool[_freeSlot].Used ) _freeSlot++;
			return _freeSlot < _tagPool.Count ? _tagPool[_freeSlot] : null;
		}

		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;

			// 队友出屏指示(v0.7.8.12):主球钳到视口贴边——世界空间箭头指向真实方位,
			// 名字贴在箭头内侧(队友色)。视野内走下方普通名牌路径
			if ( allyMode && b != local && b.TeamIndex >= 0 && b.TeamIndex == local.TeamIndex )
			{
				// 钳制区间先行校验:Math.Clamp 的 min>max 会抛异常(每帧抛会把引擎每帧内存栈耗光,见方法头注释)。
				// 区间无效(视口退化)时索性退回普通名牌,不做边缘箭头
				var loX = camPos.x - halfX + marginW;
				var hiX = camPos.x + halfX - marginW;
				var loY = camPos.y - halfY + marginW;
				var hiY = camPos.y + halfY - marginW;

				if ( loX <= hiX && loY <= hiY )
				{
					var cx = Math.Clamp( b.WorldPosition.x, loX, hiX );
					var cy = Math.Clamp( b.WorldPosition.y, loY, hiY );
					var dx = b.WorldPosition.x - cx;
					var dy = b.WorldPosition.y - cy;
					var dl = MathF.Sqrt( dx * dx + dy * dy );

					if ( dl > 0.001f )   // ≈0 = 在视口内(钳制没生效),落普通名牌
					{
						dx /= dl;
						dy /= dl;

						NeonRenderer.EdgeArrow( new Vector3( cx, cy, 0f ), new Vector2( dx, dy ), b.NeonColor, 30f * s );

						var lx = cx - dx * 46f * s;   // 名字朝屏内偏 ~46px
						var ly = cy - dy * 46f * s;
						var px = size.x * 0.5f - ( ly - camPos.y ) / s;
						var py = size.y * 0.5f - ( lx - camPos.x ) / s;

						SetDisplay( slot.Label, DisplayMode.Flex );
						SetTagPos( slot, px / uiScale - 80f, py / uiScale + 8f );
						SetText( slot.Label, TagText( b.PlayerName, b.TeamIndex ) );
						SetTagColor( slot, b.NeonColor );
						slot.Shown = true;
						continue;
					}
				}
				else if ( _allySkipLogged == false )
				{
					// 诊断(只记一次):说明视口面板尺寸小到让贴边区间失效——这里以前会每帧抛
					// ArgumentException(Math.Clamp min>max),是"每帧内存栈被耗光 → 引擎崩溃"的触发点
					_allySkipLogged = true;
					GameLog.Info( $"[hud] ally arrow skipped, viewport too small: size={size.x:0}x{size.y:0} halfX={halfX:0} margin={marginW:0}" );
				}
			}

			Place( slot, b.WorldPosition, TagText( b.PlayerName, b.TeamIndex, b.AccountLevel ) );
		}

		// 分身名牌(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, owner.AccountLevel ) );
		}

		// 本帧未用到的槽 = 实体已死亡/分身已合并:隐藏并解除绑定
		// 先收集再删(v0.7.8.89):按索引取字典项本质是全量走查——每次调用都从头遍历
		// 并装箱结构枚举器,反向循环 = O(n²);用复用缓冲两趟走完
		_tagDeadScratch.Clear();
		foreach ( var kv in _tagMap )
		{
			if ( !kv.Value.Used ) _tagDeadScratch.Add( kv.Key );
		}
		foreach ( var key in _tagDeadScratch )
		{
			var dead = _tagMap[key];
			dead.Label.Style.Display = DisplayMode.None;
			dead.Shown = false;
			_tagMap.Remove( key );
		}

		_pieceTagDeadScratch.Clear();
		foreach ( var kv in _pieceTagMap )
		{
			if ( !kv.Value.Used ) _pieceTagDeadScratch.Add( kv.Key );
		}
		foreach ( var key in _pieceTagDeadScratch )
		{
			var dead = _pieceTagMap[key];
			dead.Label.Style.Display = DisplayMode.None;
			dead.Shown = false;
			_pieceTagMap.Remove( key );
		}
	}

	void UpdateMass( CircleroyaleGame game, bool over )
	{
		if ( !_mass.IsValid() ) return;

		// 比赛结束:数值类 HUD 全部让位给结算面板(SetDisplay 同值守卫,v0.7.8.87)
		SetDisplay( _mass, 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;

		// 领土模式(M7.3 用户定稿):右侧战绩榜整体隐藏——小地图下方团队分数替代
		if ( MatchState.IsTerritory )
		{
			for ( int i = 0; i < _rows.Length; i++ )
				SetDisplay( _rows[i], DisplayMode.None );
			SetDisplay( _selfRow, DisplayMode.None );
			SetDisplay( _crown, DisplayMode.None );
			return;
		}

		if ( over )
		{
			for ( int i = 0; i < _rows.Length; i++ )
			{
				// 清空也要守卫:Text 写入同样触发布局(v0.7.8.87)
				if ( _rows[i].IsValid() && _rows[i].Text.Length > 0 ) _rows[i].Text = "";
			}
			SetDisplay( _selfRow, DisplayMode.None );
			SetDisplay( _crown, DisplayMode.None );
			return;
		}

		// 按总质量(主球+分身)降序;球数 ≤ 48(真人+bot 上限),缓冲复用零分配
		_rankList.Clear();
		_rankMass.Clear();
		foreach ( var b in game.Balls )
		{
			if ( b.IsValid() && b.Alive ) _rankList.Add( b );
		}
		foreach ( var b in _rankList )
			_rankMass[b] = game.TotalMassFor( b );
		_rankList.Sort( ( x, y ) => _rankMass[y].CompareTo( _rankMass[x] ) );
		var list = _rankList;
		var massOf = _rankMass;

		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 )
			{
				if ( row.Text.Length > 0 ) row.Text = "";   // 同上:清空也守卫(v0.7.8.87)
				continue;
			}

			var ball = list[i];
			var isSelf = local.IsValid() && ball == local;
			SetText( row, TeamModeRow( $"{i + 1}.", ball.PlayerName, $"{massOf[ball]:0}", ball.TeamIndex ) );
			var rowColor = isSelf ? new Color( 0.898f, 0.282f, 0.553f ) : new Color( 0.29f, 0.23f, 0.39f, 0.78f );
			if ( !SameColor( row.Style.FontColor, rowColor ) ) row.Style.FontColor = rowColor;
		}

		// 榜外自身行
		var showSelf = selfRank >= _rows.Length;
		if ( _selfRow.IsValid() )
		{
			SetDisplay( _selfRow, showSelf ? DisplayMode.Flex : DisplayMode.None );
			if ( showSelf )
				SetText( _selfRow, TeamModeRow( $"#{selfRank + 1}.", local.PlayerName, $"{massOf[local]:0}", local.TeamIndex ) );
		}

		// 皇冠钉在第一名行左侧(跟随行框左缘,长名不会顶到图标;Box 是物理像素,样式是逻辑单位要除 Scale)
		if ( _crown.IsValid() )
		{
			var show = list.Count > 0;
			SetDisplay( _crown, show ? DisplayMode.Flex : DisplayMode.None );
			if ( show )
			{
				// 锚点 1s 节流(v0.7.8.89):读 Box.Rect/FindRootPanel 会强制布局,每帧读=每帧布局。
				// 行左/上缘只会因名字变长而移动,1Hz 足够(与 UpdateTerritoryScores 的 _logicalWCache 同法)
				if ( _sinceCrownAnchor > 1f )
				{
					_sinceCrownAnchor = 0;
					var rootPanel = _rows[0].FindRootPanel();
					var sc = rootPanel is not null && rootPanel.Scale > 0.001f ? rootPanel.Scale : 1f;
					_crownX = _rows[0].Box.Rect.Left / sc - 24f;
					_crownY = _rows[0].Box.Rect.Top / sc - 3f;
				}
				if ( !SamePx( _crown.Style.Left, _crownX ) ) _crown.Style.Left = _crownX;
				if ( !SamePx( _crown.Style.Top, _crownY ) ) _crown.Style.Top = _crownY;
			}
		}
	}

	/// <summary> 死亡面板点击重生(实例方法:热重载可重映射) </summary>
	void OnDeathSubClicked() => CircleroyaleGame.Current?.TryRespawnLocal();

	/// <summary> 领土模式死亡面板二选一(M7.2):true=前线(系统挑离阵亡点最近的己方占领格) </summary>
	void OnDeathRespawnClicked( bool frontLine ) => CircleroyaleGame.Current?.TryRespawnLocal( frontLine );

	/// <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 && !MatchState.IsTerritory && local.IsValid();   // 领土隐藏 MY TEAM(M7.3:地图下团队分数替代)

		if ( !show )
		{
			foreach ( var r in _mateRows )
				SetDisplay( r, DisplayMode.None );
			return;
		}

		int myTeam = Math.Max( 0, local.TeamIndex );

		// 本队存活成员按总质量降序(按块分队后每队 ≤ TeamSize ≤ 4 人,缓冲复用直接排序)
		_matesList.Clear();
		_matesMass.Clear();
		foreach ( var b in game.Balls )
		{
			if ( b.IsValid() && b.Alive && b.TeamIndex == myTeam ) _matesList.Add( b );
		}
		foreach ( var m in _matesList )
			_matesMass[m] = game.TotalMassFor( m );
		_matesList.Sort( ( x, y ) => _matesMass[y].CompareTo( _matesMass[x] ) );
		var mates = _matesList;
		var massOf = _matesMass;

		for ( int i = 0; i < _mateRows.Length; i++ )
		{
			var row = _mateRows[i];
			if ( !row.IsValid() ) continue;

			if ( i == 0 )
			{
				SetDisplay( row, DisplayMode.Flex );
				SetText( row, $"MY TEAM [T{myTeam + 1}]  {mates.Sum( m => massOf[m] ):0}" );
				var headColor = MatchState.TeamColor( myTeam );
				if ( !SameColor( row.Style.FontColor, headColor ) ) row.Style.FontColor = headColor;
				continue;
			}

			int k = i - 1;
			if ( k >= mates.Count )
			{
				SetDisplay( row, DisplayMode.None );
				continue;
			}

			var mate = mates[k];
			bool self = mate == local;
			SetDisplay( row, DisplayMode.Flex );
			SetText( row, $"{( self ? ">" : " ")} {mate.PlayerName}  {massOf[mate]:0}" );
			var mateColor = self ? new Color( 0.851f, 0.541f, 0f ) : new Color( 0.29f, 0.23f, 0.39f, 0.85f );
			if ( !SameColor( row.Style.FontColor, mateColor ) ) row.Style.FontColor = mateColor;
		}
	}

	/// <summary> 名牌文本(团队赛带 [T#] 队伍前缀,v0.6.5.0 用户需求) </summary>
	string TagText( string name, int team, int level = 0 ) =>
		( MatchState.IsTeam && team >= 0 ? $"[T{team + 1}] " : "" )
		+ ( level > 0 ? $"LV.{level} " : "" )
		+ name;

	/// <summary> 排行榜行文本(团队赛带 T# 前缀;普通赛只有名次/名字/分数) </summary>
	string TeamModeRow( string rank, string name, string mass, int team )
	{
		// team<0(蛰伏/无队球)不显示队伍前缀——Math.Max(0,-1) 曾让无队 bot 全挂 [T1](v0.7.8.25)
		return ( MatchState.IsTeam && team >= 0 ) ? $"{rank} [T{team + 1}] {name}  {mass}" : $"{rank}  {name}  {mass}";
	}

	// ---- 比赛规则 UI(M5)----

	void UpdateMatchInfo( CircleroyaleGame game )
	{
		if ( !_matchInfo.IsValid() ) return;

		if ( MatchState.MatchOver )
		{
			SetDisplay( _matchInfo, DisplayMode.None );
			HideTeamRows();
			return;
		}

		SetDisplay( _matchInfo, DisplayMode.Flex );

		// 倒计时格式 m:ss——分钟必须截断取整("0" 格式会四舍五入,44 秒显示成 "1:44" 的教训)
		var total = (int)MathF.Ceiling( MathF.Max( 0f, MatchState.TimeLeft ) );
		var mode = MatchState.IsTerritory ? "TERRITORY WAR" : MatchState.IsTeam ? "TEAM BATTLE" : "FREE FOR ALL";
		SetText( _matchInfo, $"{mode}   {total / 60}:{total % 60:00}" );

		// 平时深梅紫(浅色世界里白字看不清,v0.7.8.77 用户定稿);最后 30 秒变红提示
		var mic = total < 30 ? new Color( 1f, 0.35f, 0.45f ) : new Color( 0.29f, 0.23f, 0.39f );
		if ( !_matchInfo.Style.FontColor.HasValue || !SameColor( _matchInfo.Style.FontColor.Value, mic ) )
			_matchInfo.Style.FontColor = mic;

		if ( MatchState.IsTeam ) UpdateTeamScores( game );
		else HideTeamRows();
	}

	/// <summary> 团队赛队伍总分条:前 5 名队伍 + 本队(不在前 5 时补一条),按总分降序。
	/// 领土战争(M7)走 UpdateTerritoryScores——条上显示占领格数而非总质量 </summary>
	void UpdateTeamScores( CircleroyaleGame game )
	{
		if ( MatchState.IsTerritory )
		{
			UpdateTerritoryScores( game );
			return;
		}

		// 逻辑宽 1s 缓存——每帧 FindRootPanel+读 Box.Rect 会强制布局(参与闪烁,v0.7.8.86 修)。
		// 刷新提到这里(v0.7.8.89):原来只在领土分支刷新,普通团队赛读到的永远是 1920 默认值,
		// 非 1920 取景器下队伍条会偏——两个分支都要用真实宽
		if ( _sinceLogicalW > 1f )
		{
			_sinceLogicalW = 0;
			var rootPanel = Panel.FindRootPanel();
			var uiScale = rootPanel is not null && rootPanel.Scale > 0.001f ? rootPanel.Scale : 1f;
			_logicalWCache = Panel.Box.Rect.Width / uiScale;
		}

		_teamTotals.Clear();
		foreach ( var b in game.Balls )
		{
			if ( !b.IsValid() || !b.Alive ) continue;
			if ( b.TeamIndex < 0 ) continue;   // 无队球不进队伍聚合——Math.Max(0,-1) 曾把蛰伏误复活的 16 颗无队 bot 全灌进 T1(v0.7.8.25)
			var team = b.TeamIndex;
			_teamTotals.TryGetValue( team, out var m );
			_teamTotals[team] = m + game.TotalMassFor( b );
		}

		_teamOrder.Clear();
		_teamOrder.AddRange( _teamTotals.Keys );
		_teamOrder.Sort( ( x, y ) => _teamTotals[y].CompareTo( _teamTotals[x] ) );

		var local = game.LocalBall;
		var myTeam = local.IsValid() ? Math.Max( 0, local.TeamIndex ) : -1;

		_teamShow.Clear();
		foreach ( var t in _teamOrder )
		{
			if ( _teamShow.Count >= 5 ) break;
			_teamShow.Add( t );
		}
		if ( myTeam >= 0 && _teamTotals.ContainsKey( myTeam ) && !_teamShow.Contains( myTeam ) )
			_teamShow.Add( myTeam );
		_teamShow.Sort( ( x, y ) => _teamTotals[y].CompareTo( _teamTotals[x] ) );
		var show = _teamShow;

		// 横排居中(按 1s 缓存的真实逻辑宽,取景器不是 1920 时不再偏移),每格 170
		float width = 170f;
		float startX = _logicalWCache * 0.5f - show.Count * width / 2f;

		for ( int i = 0; i < _teamRows.Length; i++ )
		{
			var row = _teamRows[i];
			if ( !row.IsValid() ) continue;

			if ( i >= show.Count )
			{
				SetDisplay( row, DisplayMode.None );
				continue;
			}

			var team = show[i];
			SetDisplay( row, DisplayMode.Flex );
			var rowX = startX + i * width;
			if ( !SamePx( row.Style.Left, rowX ) ) row.Style.Left = rowX;
			SetText( row, $"T{team + 1}  {_teamTotals[team]:0}" );

			bool mine = team == myTeam;
			if ( !SameColor( row.Style.FontColor, TeamBarInk ) ) row.Style.FontColor = TeamBarInk;   // 统一深梅紫(v0.7.8.77),本队靠不透明度区分
			var rowOp = mine ? 1f : 0.75f;
			if ( !SameFloat( row.Style.Opacity, rowOp ) ) row.Style.Opacity = rowOp;
		}
	}

	/// <summary> 领土战争(M7)队伍条:四队占领格数(格数相同也全显,0 格队伍也要看见自己),按格数降序 </summary>
	void UpdateTerritoryScores( CircleroyaleGame game )
	{
		_teamTotals.Clear();
		for ( var t = 0; t < 4; t++ )
		{
			var pts = TerritoryManager.Points( t );
			if ( pts > 0f ) _teamTotals[t] = pts;   // 占旗积分(M7.4:每旗每 3s +1,点数定胜负)
		}

		var local = game.LocalBall;
		var myTeam = local.IsValid() ? Math.Max( 0, local.TeamIndex ) : -1;

		_teamShow.Clear();
		foreach ( var kv in _teamTotals ) _teamShow.Add( kv.Key );
		if ( myTeam >= 0 && !_teamShow.Contains( myTeam ) ) _teamShow.Add( myTeam );
		_teamShow.Sort( ( x, y ) =>
		{
			_teamTotals.TryGetValue( x, out var vx );
			_teamTotals.TryGetValue( y, out var vy );
			return vy.CompareTo( vx );
		} );

		// 领土(M7.3 用户定稿):竖排在小地图正下方,右缘与地图对齐(小地图右上角)
		// 逻辑宽 1s 缓存——每帧 FindRootPanel+读 Box.Rect 会强制布局(参与闪烁,v0.7.8.86 修)
		if ( _sinceLogicalW > 1f )
		{
			_sinceLogicalW = 0;
			var rootPanel = Panel.FindRootPanel();
			var uiScale = rootPanel is not null && rootPanel.Scale > 0.001f ? rootPanel.Scale : 1f;
			_logicalWCache = Panel.Box.Rect.Width / uiScale;
		}
		float width = 170f;
		var left = _logicalWCache - 28f - width;   // 28 = 小地图 right 边距

		for ( int i = 0; i < _teamRows.Length; i++ )
		{
			var row = _teamRows[i];
			if ( !row.IsValid() ) continue;

			if ( i >= _teamShow.Count )
			{
				SetDisplay( row, DisplayMode.None );
				continue;
			}

			var team = _teamShow[i];
			// 位置/字色只在"隐藏→显示"沿写一次(Resize 后由下沿刷新);同值不写防闪烁
			if ( row.Style.Display != DisplayMode.Flex )
			{
				row.Style.Display = DisplayMode.Flex;
				row.Style.Left = left;
				row.Style.Top = 326f + i * 40f;   // 地图 top24 + 高292 + 间隙
				row.Style.FontColor = new Color( 0.29f, 0.23f, 0.39f );   // 统一深梅紫(同团队赛,v0.7.8.77)
			}
			_teamTotals.TryGetValue( team, out var pts );
			SetText( row, $"T{team + 1}  {pts:0} PTS" );

			bool mine = team == myTeam;
			var op = mine ? 1f : 0.75f;
			if ( row.Style.Opacity != op ) row.Style.Opacity = op;
		}
	}

	void HideTeamRows()
	{
		if ( _teamRows is null ) return;
		foreach ( var r in _teamRows )
			SetDisplay( r, 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 );
			}
		}

		// 自上而下重排(老条目删除后不串位)+ 尾段淡出;起始 108 = 分数牌下方留足空隙(v0.7.8.77 用户定稿)
		for ( int i = 0; i < _killFeed.Count; i++ )
		{
			var e = _killFeed[i];
			if ( !e.Label.IsValid() ) continue;

			var top = 108f + i * 26f;
			if ( !SamePx( e.Label.Style.Top, top ) ) e.Label.Style.Top = top;   // 同值守卫(v0.7.8.87)

			var op = e.Since < 3.5f ? 1f : Math.Clamp( 1f - ( e.Since - 3.5f ) / 1f, 0f, 1f );
			if ( !SameFloat( e.Label.Style.Opacity, op ) ) e.Label.Style.Opacity = op;
		}
	}

	/// <summary>
	/// 本地球道具 buff 状态(v0.7.3.0):左下角操作提示上方,种类名 + 剩余秒 + 种类色。
	/// 剩余秒每帧在变(文本防抖挡不住),颜色只在种类切换时赋值。
	/// </summary>
	void UpdateBuff( CircleroyaleGame game )
	{
		if ( _buff is null || !_buff.IsValid() ) return;

		var ball = game.LocalBall;
		if ( !ball.IsValid() || !ball.Alive || ball.BuffKind == 255 || ball.BuffLeft <= 0f )
		{
			SetText( _buff, "" );
			return;
		}

		SetText( _buff, $"{PowerUpManager.NameOf( ball.BuffKind )}  {ball.BuffLeft:0}s" );
		var c = PowerUpManager.ColorOf( ball.BuffKind );
		if ( _buff.Style.FontColor != c ) _buff.Style.FontColor = c;
	}

	/// <summary>
	/// 玩法提示轮播(v0.7.4.2):每 8s 换一条,换条瞬间 0.5s 透明度渐入(Opacity 不触发
	/// 布局重算,渐入结束值恒 1 不再写样式);文本走 SetText 防抖。
	/// </summary>
	void UpdateTips()
	{
		if ( _tips is null || !_tips.IsValid() ) return;

		if ( _sinceTip > 8f )
		{
			_tipIndex = ( _tipIndex + 1 ) % Tips.Length;
			_sinceTip = 0;
			SetText( _tips, Tips[_tipIndex] );
		}

		var op = Math.Clamp( _sinceTip / 0.5f, 0f, 1f );
		if ( _tips.Style.Opacity != op )
			_tips.Style.Opacity = op;   // 渐入 0.5s 内逐帧变化;结束后同值不再写(防无谓样式刷新)
	}

	/// <summary>
	/// 右下角技能按钮(v0.7.8.74 球球大作战手游风:大图标 + 冷却暗罩 + 倒数秒 + 键位角标)。
	/// Q 充能进度是**本地估算**:host 只把槽种类(PowerA 字节)同步过来,客户端在
	/// "入局/复活拿到球"与"放技能(种类→空)"两个沿上按当前质量对应的充能时长起表;
	/// 估算跑完也封顶 97% 不提前亮——host 真发种类、按钮点亮那刻才是权威时刻。
	/// </summary>
	void UpdatePowerSlots( CircleroyaleGame game )
	{
		if ( _btnQ is null || _btnQ.Root is null || !_btnQ.Root.IsValid() ) return;

		var ball = game.LocalBall;
		if ( !ball.IsValid() )
		{
			_hadBall = false;
			DrawQ( 255, 0f );
			DrawE( 255 );
			return;
		}

		// 领土模式(M7.4):Q = 职业技能(系统分配、冷却制);E 道具照旧
		if ( MatchState.IsTerritory )
		{
			DrawClassQ( ball );
			DrawE( ball.PowerB );
			return;
		}

		if ( !_hadBall && ball.PowerA == 255 )
		{
			_qCd = 0;
			_qCdDur = GameConfig.QSkillCooldownFor( ball.Mass );
		}
		_hadBall = true;

		if ( ball.PowerA != _prevPowerA )
		{
			if ( ball.PowerA == 255 )   // 刚放掉/重生清空:冷却重新起算
			{
				_qCd = 0;
				_qCdDur = GameConfig.QSkillCooldownFor( ball.Mass );
			}
			_prevPowerA = ball.PowerA;
		}

		var p = _qCdDur > 0f ? Math.Clamp( _qCd / _qCdDur, 0f, 1f ) : 1f;
		DrawQ( ball.PowerA, MathF.Min( p, 0.97f ) );
		DrawE( ball.PowerB );
	}

	/// <summary>
	/// 技能按钮骨架:九宫格底 + 大图标留在按钮面板里(这两层实测定位正常);
	/// 冷却罩/倒数秒/键位角标挂 HUD 根面板、内联 Right 定位(rightPx = 按钮右距)。
	/// 冷却罩贴底、高度代码驱动:底边固定上缘下沉,像手游那样从上往下"排空"亮出图标
	/// </summary>
	SkillButton BuildSkillButton( Panel root, string cls, string keyHint, int rightPx )
	{
		var button = new Panel() { Classes = cls };
		var b = new SkillButton { Root = button };

		b.Icon = new Image() { Classes = "skill-icon" };
		button.AddChild( b.Icon );

		root.AddChild( button );

		b.Cd = new Panel() { Classes = "skill-cd" };
		b.Cd.Style.Right = rightPx + 5;
		root.AddChild( b.Cd );

		b.Num = new Label() { Classes = "skill-num" };
		b.Num.Style.Right = rightPx + 5;
		root.AddChild( b.Num );

		var key = new Label() { Classes = "skill-key" };
		key.Text = keyHint;
		key.Style.Right = rightPx + 8;
		root.AddChild( key );

		return b;
	}

	/// <summary> ⚠️ 顺序必须与 PowerUpManager.Kind 的枚举值一一对应(Speed=0/Magnet=1/Mass=2/Shield=3/Spike=4)——
	/// 曾按"speed/magnet/shield/spike/mass"写,Mass/Shield/Spike 三档图标互相错位(实测) </summary>
	static readonly string[] PowerSlugs = { "speed", "magnet", "mass", "shield", "spike" };

	Texture IconTexture( byte kind ) =>
		Texture.Load( $"ui/pixel/pw_{PowerSlugs[Math.Clamp( (int)kind, 0, PowerSlugs.Length - 1 )]}.png" );

	/// <summary> Q 钮:充能中 = 闪电暗图标 + 底部暗罩随进度下排 + 倒数秒;就绪 = 技能大图标点亮 </summary>
	void DrawQ( byte kind, float progress )
	{
		var b = _btnQ;
		bool charging = kind == 255;

		if ( kind != _qDrawn )   // 状态切换才动图标/透明度/显隐
		{
			_qDrawn = kind;
			b.Icon.Texture = charging ? Texture.Load( "ui/skin/ic_bolt.png" ) : IconTexture( kind );
			b.Icon.Style.Opacity = charging ? 0.4f : 1f;
			b.Num.Style.Display = charging ? DisplayMode.Flex : DisplayMode.None;
			b.Cd.Style.Display = charging ? DisplayMode.Flex : DisplayMode.None;
			if ( charging ) _qCdHeight = -1f;   // 重新进充能:强制下帧把罩高写回满格
		}

		if ( !charging ) return;

		var remain = MathF.Max( 1f, MathF.Ceiling( (1f - progress) * _qCdDur ) );
		SetText( b.Num, ((int)remain).ToString() );

		var h = MathF.Round( (1f - progress) * SkillBtnSize );
		if ( MathF.Abs( h - _qCdHeight ) > 0.5f )
		{
			_qCdHeight = h;
			b.Cd.Style.Height = h;
		}
	}

	/// <summary> 领土 Q 钮(M7.4):职业技能——就绪显示职业短标,冷却显示倒数秒+排空罩 </summary>
	void DrawClassQ( Ball ball )
	{
		var b = _btnQ;
		var cls = ball.TerritoryClass;

		if ( cls > 3 )
		{
			DrawQ( 255, 0f );   // 无职业(理论不出现):按空槽处理
			return;
		}

		if ( _qDrawn != (byte)( cls + 100 ) )   // +100 避开道具 kind 段
		{
			_qDrawn = (byte)( cls + 100 );
			b.Icon.Texture = Texture.Load( "ui/skin/ic_bolt.png" );
			b.Icon.Style.Opacity = 1f;
			b.Num.Style.Display = DisplayMode.Flex;
			_qCdHeight = -1f;
		}

		var cd = ClassSkillManager.CooldownOf( cls );
		var remain = ball.ClassCooldownRemaining;
		var cooling = remain > 0.1f;

		SetText( b.Num, cooling ? MathF.Ceiling( remain ).ToString() : ClassSkillManager.TagOf( cls ) );

		var h = cooling ? MathF.Round( remain / cd * SkillBtnSize ) : 0f;
		if ( MathF.Abs( h - _qCdHeight ) > 0.5f )
		{
			_qCdHeight = h;
			b.Cd.Style.Height = h;
		}
		SetDisplay( b.Cd, cooling ? DisplayMode.Flex : DisplayMode.None );
	}

	/// <summary> E 钮:持有道具 = 大图标点亮;空槽 = 整钮压暗(键位角标仍可见) </summary>
	void DrawE( byte kind )
	{
		if ( kind == _eDrawn ) return;
		_eDrawn = kind;

		var b = _btnE;
		var tex = kind == 255 ? null : IconTexture( kind );
		b.Icon.Texture = tex;
		b.Root.Style.Opacity = kind == 255 ? 0.55f : 1f;

		// 诊断(临时,E 图标排查):每次状态切换记录落实结果(kind→贴图/图标面板有效性)
		GameLog.Info( $"[hud] E slot draw kind={kind} tex={( tex is null ? "NULL" : $"{tex.Width}x{tex.Height} err={tex.IsError}" )} iconValid={b.Icon.IsValid()}" );
	}

	/// <summary> 诊断(临时,E 图标排查):Q/E 槽位与按钮/图标的真实状态,供 MCP cr_slots 采样 </summary>
	public static string DumpSlots()
	{
		var sb = new System.Text.StringBuilder();
		var game = CircleroyaleGame.Current;
		sb.AppendLine( $"territory={MatchState.IsTerritory} mode={MatchState.CurrentMode}" );

		var ball = game?.LocalBall;
		sb.AppendLine( ball is null || !ball.IsValid()
			? "localBall=NULL"
			: $"ball '{ball.PlayerName}' A={ball.PowerA} B={ball.PowerB} cls={ball.TerritoryClass} mass={ball.Mass:0} alive={ball.Alive}" );

		// 所有非 bot 球:查"存进的球 ≠ HUD 读的球"(同名重复实体/幽灵球)
		if ( game is not null )
		{
			foreach ( var b in game.Balls )
			{
				if ( b is null || !b.IsValid() || b.IsBot ) continue;
				sb.AppendLine( $"  realball '{b.PlayerName}' sid={b.OwnerSteamId} A={b.PowerA} B={b.PowerB} alive={b.Alive} same={( b == ball )}" );
			}
		}

		var hud = game?.Hud;
		if ( hud is null ) { sb.AppendLine( "hud=NULL" ); return sb.ToString(); }

		sb.AppendLine( $"cached qDrawn={hud._qDrawn} eDrawn={hud._eDrawn} hadBall={hud._hadBall}" );
		sb.AppendLine( $"btnQ root={( hud._btnQ?.Root?.IsValid() ?? false )} icon={( hud._btnQ?.Icon?.IsValid() ?? false )} tex=[{TexInfo( hud._btnQ?.Icon?.Texture )}] op={hud._btnQ?.Root?.Style.Opacity}" );
		sb.AppendLine( $"btnE root={( hud._btnE?.Root?.IsValid() ?? false )} icon={( hud._btnE?.Icon?.IsValid() ?? false )} tex=[{TexInfo( hud._btnE?.Icon?.Texture )}] op={hud._btnE?.Root?.Style.Opacity}" );

		// 现场加载探针:5 张道具图标此刻能否取到(验证 IconTexture 路径与素材可用性)
		for ( int k = 0; k < PowerSlugs.Length; k++ )
			sb.AppendLine( $"icon kind={k} ({PowerSlugs[k]}): {TexInfo( Texture.Load( $"ui/pixel/pw_{PowerSlugs[k]}.png", false ) )}" );
		return sb.ToString();
	}

	static string TexInfo( Texture t ) => t is null ? "NULL" : $"err={t.IsError} loaded={t.IsLoaded} {t.Width}x{t.Height}";

	/// <summary>
	/// 个人高光横幅(v0.7.6.0):屏幕中下堆叠最多 3 条,大号发光字——
	/// 击杀(金)/ 捡到道具(种类色)/ 使用与喂养触发(种类色)。
	/// 弹入(透明度+下落 20px 回位)→ 停留 → 淡出;最老先删,位置逐帧重排(变了才写样式)。
	/// </summary>
	public void AddBanner( string text, Color color )
	{
		if ( _banners.Count >= 3 )
		{
			var oldest = _banners[0];
			if ( oldest.Label.IsValid() ) oldest.Label.Delete();
			_banners.RemoveAt( 0 );
		}

		var l = new Label() { Classes = "banner" };
		l.Text = text;
		l.Style.FontColor = color;
		Panel?.AddChild( l );
		_banners.Add( new Banner { Label = l, Since = 0 } );
	}

	void UpdateBanners()
	{
		for ( int i = _banners.Count - 1; i >= 0; i-- )
		{
			var b = _banners[i];
			if ( b.Label is null || !b.Label.IsValid() )
			{
				_banners.RemoveAt( i );
				continue;
			}

			var age = b.Since;
			if ( age > 2.1f )
			{
				b.Label.Delete();
				_banners.RemoveAt( i );
				continue;
			}

			// 堆叠位置:最新的最靠下(贴近屏幕中下),被挤掉后其余逐帧回位
			float bottom = 170f + i * 46f;
			if ( b.ShownBottom != bottom )
			{
				b.ShownBottom = bottom;
				b.Label.Style.Bottom = bottom;
			}

			float opacity;
			if ( age < 0.3f )
			{
				opacity = age / 0.3f;
				b.Label.Style.PaddingTop = ( 1f - opacity ) * 20f;   // 弹入:从下方落回原位
			}
			else if ( age > 1.5f )
			{
				opacity = 1f - ( age - 1.5f ) / 0.6f;
			}
			else
			{
				opacity = 1f;
			}

			if ( b.Label.Style.Opacity != opacity )
				b.Label.Style.Opacity = opacity;
		}
	}

	void UpdateDeathPanel( CircleroyaleGame game, bool over )
	{
		var dead = game.IsLocalDead && !over;   // 结算期死亡面板让位
		var deadMode = dead ? DisplayMode.Flex : DisplayMode.None;
		SetDisplay( _deathShade, deadMode );    // 同值守卫(v0.7.8.87:每帧裸写 Display 会强制布局)
		SetDisplay( _deathTitle, deadMode );
		if ( _deathSub.IsValid() )
		{
			SetDisplay( _deathSub, deadMode );

			// 死亡观战提示(v0.7.8.28):跟谁看战局写在副标题里
			var spec = game.Camera?.SpectateName;
			var text = string.IsNullOrEmpty( spec )
				? "PRESS SPACE / CLICK TO RESPAWN"
				: $"SPECTATING {spec} — PRESS SPACE TO RESPAWN";
			if ( _deathSub.Text != text ) _deathSub.Text = text;
		}

		// 领土模式二选一按钮(M7.2):非领土隐藏;前线没占领格时置灰禁用(点了也回落大本营)
		var territory = dead && MatchState.IsTerritory;
		SetDisplay( _deathBase, territory ? DisplayMode.Flex : DisplayMode.None );
		SetDisplay( _deathFront, territory ? DisplayMode.Flex : DisplayMode.None );
		if ( territory && _deathFront.IsValid() )
		{
			var myTeam = game.LocalBall.IsValid() ? game.LocalBall.TeamIndex : -1;
			var hasFront = TerritoryManager.HasFrontCell( myTeam );
			var ft = hasFront ? "RESPAWN — FRONT" : "FRONT — NO CELLS";
			if ( _deathFront.Text != ft ) _deathFront.Text = ft;
			var fop = hasFront ? 1f : 0.35f;
			if ( _deathFront.Style.Opacity != fop ) _deathFront.Style.Opacity = fop;
		}
	}

	// ---- 领土玩法引导 + 小地图(M7.3)----

	/// <summary> 同值守卫式显隐(M6.3 教训:每帧重复写样式=布局重算=客户端 UI 闪烁回归)。
	/// public static:各面板(Lobby/RoomBrowser 等)周期性全量重刷时共用此守卫 </summary>
	public static void SetDisplay( Panel p, DisplayMode mode )
	{
		if ( p.IsValid() && p.Style.Display != mode ) p.Style.Display = mode;
	}

	static bool SameColor( Color a, Color b ) => a.r == b.r && a.g == b.g && a.b == b.b && a.a == b.a;

	// ---- 样式属性是 nullable(Length?/float?/Color?):比较助手把"未设置"一律算作不同(需要写) ----

	static bool SameColor( Color? a, Color b ) => a.HasValue && SameColor( a.Value, b );

	/// <summary> 像素长度是否已等于 px(未设置/单位不是 px 都算不同) </summary>
	static bool SamePx( Length? len, float px ) =>
		len.HasValue && len.Value.Unit == LengthUnit.Pixels && MathF.Abs( len.Value.Value - px ) < 0.5f;

	/// <summary> 浮点样式(Opacity 等)是否已等于目标值 </summary>
	static bool SameFloat( float? v, float target, float eps = 0.01f ) =>
		v.HasValue && MathF.Abs( v.Value - target ) < eps;

	static bool IsHqCell( int cx, int cy )
	{
		for ( var t = 0; t < 4; t++ )
		{
			var hq = TerritoryManager.HqCell( t );
			if ( hq.Cx == cx && hq.Cy == cy ) return true;
		}
		return false;
	}

	/// <summary> 情境引导:冷却 key 在冷却期内则跳过;否则显示 duration 秒并记冷却 </summary>
	void ShowTerritoryHint( string text, string key, float cooldown, float duration )
	{
		if ( HintCooldowns.TryGetValue( key, out var since ) && since < cooldown ) return;
		HintCooldowns[key] = 0;
		if ( _thintText != text ) SetText( _thint, text );
		_thintText = text;
		_sinceThint = 0;
		_thintDuration = duration;
	}

	/// <summary> 领土情境引导(玩家靠近旗提示吐球等):优先级从高到低逐条尝试,当帧无人触发则维持/淡出上一条 </summary>
	void UpdateTerritoryHint( CircleroyaleGame game )
	{
		var active = MatchState.IsTerritory && game.IsMatchStarted && !MatchState.MatchOver;
		if ( !active || _thint is null || !_thint.IsValid() )
		{
			if ( _thint is not null && _thint.IsValid() && _thint.Style.Display != DisplayMode.None )
				_thint.Style.Display = DisplayMode.None;
			_lastOwnedForHint = -1;
			return;
		}

		var ball = game.LocalBall;
		if ( ball.IsValid() && ball.Alive )
		{
			var team = ball.TeamIndex;
			var pos = ball.WorldPosition;

			// 捕获/开局检测(占领数上升沿;入局首帧=开局引导)
			var owned = TerritoryManager.OwnedCount( team );
			if ( _lastOwnedForHint < 0 )
			{
				_lastOwnedForHint = owned;
				ShowTerritoryHint( "HELD FLAGS EARN POINTS EVERY 3S — HOLD ALL 12 FOR INSTANT WIN", "win", 9999f, 5.5f );
			}
			else if ( owned > _lastOwnedForHint )
			{
				_lastOwnedForHint = owned;
				ShowTerritoryHint( "CELL CAPTURED! ENEMY SPIT WEAKENS IT — REFEED TO REINFORCE", "captured", 30f, 3.5f );
			}
			else
			{
				_lastOwnedForHint = owned;
			}

			// 情境提示(优先级序):敌营 > 非邻接旗 > 旗点吐球 > 回家回血
			if ( TerritoryManager.InHostileHq( team, pos ) )
			{
				ShowTerritoryHint( "ENEMY CASTLE — YOU ARE SLOW AND LOSING MASS, GET OUT!", "hostilehq", 20f, 3.5f );
			}
			else
			{
				// 最近旗点(16 格循环,量可忽略)
				var nd = float.MaxValue;
				int ncx = 0, ncy = 0;
				for ( int cy = 0; cy < GameConfig.TerritoryGrid; cy++ )
				{
					for ( int cx = 0; cx < GameConfig.TerritoryGrid; cx++ )
					{
						var d = TerritoryManager.CellCenter( cx, cy ).Distance( pos );
						if ( d < nd )
						{
							nd = d;
							ncx = cx;
							ncy = cy;
						}
					}
				}

				if ( nd < GameConfig.TerritoryFlagRadius * 1.8f )
				{
					TerritoryManager.CellState( ncx, ncy, out var owner, out _, out _ );
					if ( owner != team )
					{
						if ( !TerritoryManager.CanActOnCell( ncx, ncy, team ) )
							ShowTerritoryHint( "TOO FAR — ONLY CELLS NEXT TO YOUR TERRITORY CAN BE TAKEN", "adjacent", 45f, 3.5f );
						else
							ShowTerritoryHint( "HOLD LMB / R — SPIT INTO THE FLAG TO CAPTURE IT", "nearflag", 45f, 4f );
					}
				}
				else if ( TerritoryManager.IsOwnHq( team, pos ) && ball.Mass > GameConfig.MassDecayMinMass )
				{
					ShowTerritoryHint( "HOME CASTLE STOPS MASS DECAY — FALL BACK TO RECOVER", "homehq", 90f, 3.5f );
				}
			}
		}

		// 显示与淡出(渐入 0.3s / 渐出 0.5s;结束后隐藏)
		var showIt = _sinceThint < _thintDuration;
		if ( _thint.Style.Display != ( showIt ? DisplayMode.Flex : DisplayMode.None ) )
			_thint.Style.Display = showIt ? DisplayMode.Flex : DisplayMode.None;
		if ( showIt )
		{
			var op = Math.Clamp( MathF.Min( _sinceThint / 0.3f, ( _thintDuration - _sinceThint ) / 0.5f ), 0f, 1f );
			if ( _thint.Style.Opacity != op ) _thint.Style.Opacity = op;
		}
	}

	/// <summary> 领土小地图(M7.3):10Hz 刷新格色(随占领/驻军变实)+ 进度条 + 本机/队友点。
	/// 敌方球不上图(避免全图透视;队友/自己才显示) </summary>
	void UpdateTerritoryMap( CircleroyaleGame game )
	{
		var show = MatchState.IsTerritory && game.IsMatchStarted && !MatchState.MatchOver;
		if ( _tmap.IsValid() && _tmap.Style.Display != ( show ? DisplayMode.Flex : DisplayMode.None ) )
			_tmap.Style.Display = show ? DisplayMode.Flex : DisplayMode.None;
		if ( !show || _sinceTmapTick < 0.1f ) return;
		_sinceTmapTick = 0;

		var half = GameConfig.ArenaHalfSize;
		var scale = 286f / ( half * 2f );

		for ( int cy = 0; cy < GameConfig.TerritoryGrid; cy++ )
		{
			for ( int cx = 0; cx < GameConfig.TerritoryGrid; cx++ )
			{
				var i = cy * GameConfig.TerritoryGrid + cx;
				TerritoryManager.CellState( cx, cy, out var owner, out var scoreTeam, out var score );
				var frac = Math.Clamp( score / GameConfig.TerritoryCaptureScore, 0f, 1f );

				Color bg;
				if ( owner >= 0 )
				{
					var c = MatchState.TeamColor( owner );
					bg = new Color( c.r, c.g, c.b, 0.3f + 0.55f * frac );
				}
				else if ( scoreTeam >= 0 && score > 0f )
				{
					var c = MatchState.TeamColor( scoreTeam );
					bg = new Color( c.r, c.g, c.b, 0.10f + 0.30f * frac );
				}
				else
				{
					bg = new Color( 0.85f, 0.90f, 0.95f, 0.55f );
				}
				if ( !SameColor( _tmapLastBg[i], bg ) )
				{
					_tmapLastBg[i] = bg;
					_tmapCells[i].Style.BackgroundColor = bg;
				}
				var barW = _tmapCellPx * frac;
				if ( MathF.Abs( _tmapLastBar[i] - barW ) > 0.5f )
				{
					_tmapLastBar[i] = barW;
					_tmapBars[i].Style.Width = barW;
				}
			}
		}

		var local = game.LocalBall;
		var myTeam = local.IsValid() ? local.TeamIndex : -1;

		// 本机点(死了就不画,避免钉在阵亡处误导)
		if ( _tmapSelf.IsValid() )
		{
			var showSelf = local.IsValid() && local.Alive;
			SetDisplay( _tmapSelf, showSelf ? DisplayMode.Flex : DisplayMode.None );
			if ( showSelf )
			{
				var p = local.WorldPosition;
				var px = ( half - p.y ) * scale - 8f;
				var py = ( half - p.x ) * scale - 8f;
				if ( MathF.Abs( px - _tmapSelfX ) > 0.5f || MathF.Abs( py - _tmapSelfY ) > 0.5f )
				{
					_tmapSelfX = px;
					_tmapSelfY = py;
					_tmapSelf.Style.Left = px;
					_tmapSelf.Style.Top = py;
				}
			}
		}

		// 队友点(活着的同队球,最多 3 个)
		var mi = 0;
		if ( myTeam >= 0 )
		{
			foreach ( var b in game.Balls )
			{
				if ( mi >= _tmapMates.Length ) break;
				if ( !b.IsValid() || !b.Alive || b == local ) continue;
				if ( b.TeamIndex != myTeam ) continue;

				var dot = _tmapMates[mi++];
				SetDisplay( dot, DisplayMode.Flex );
				var mc = MatchState.TeamColor( myTeam );
				if ( !SameColor( _tmapLastMateBg[mi - 1], mc ) )
				{
					_tmapLastMateBg[mi - 1] = mc;
					dot.Style.BackgroundColor = mc;
				}
				var p = b.WorldPosition;
				var px = ( half - p.y ) * scale - 6f;
				var py = ( half - p.x ) * scale - 6f;
				if ( MathF.Abs( px - _tmapMateX[mi - 1] ) > 0.5f || MathF.Abs( py - _tmapMateY[mi - 1] ) > 0.5f )
				{
					_tmapMateX[mi - 1] = px;
					_tmapMateY[mi - 1] = py;
					dot.Style.Left = px;
					dot.Style.Top = py;
				}
			}
		}
		for ( ; mi < _tmapMates.Length; mi++ )
		{
			SetDisplay( _tmapMates[mi], DisplayMode.None );
			_tmapMateX[mi] = -1f;
			_tmapMateY[mi] = -1f;
		}
	}
}