Game/CircleroyaleGame.cs
using System;
using System.Collections.Generic;

/// <summary>
/// CircleRoyale 主系统:运行时生成整个世界(相机/HUD/背景/球),承载 host 权威逻辑。
/// GameObjectSystem 会在场景加载时自动实例化(无需挂载到场景),但编辑器的编辑模式下
/// 也会 tick,所以所有游戏逻辑都用 Game.IsPlaying 拦截。模板场景对象会被整体屏蔽。
/// </summary>
public sealed class CircleroyaleGame : GameObjectSystem<CircleroyaleGame>
{
	GameObject _root;
	NeonCamera _camera;
	GameHud _hud;
	EndBoard _endBoard;
	LobbyPanel _lobby;
	RoomBrowser _browser;
	FoodManager _food;
	NetworkManager _net;
	MainMenu _menu;

	readonly List<Ball> _balls = new();
	readonly Dictionary<Guid, Vector3> _lastPos = new();   // host 位移校验用
	TimeSince _sinceScan = 1f;         // 球注册扫描
	TimeSince _sinceFoodRequest = 1f;  // 客户端请求食物全量的节流(首帧即请求)
	TimeSince _sinceMatchTick = 1f;    // 倒计时 1Hz 校时广播
	int _botNameIndex;
	int _teamSeq;                      // 团队赛轮转分队计数(开局重置)

	// ---- 分身/孢子(M3,纯数据实体见 CellPiece)----
	readonly List<CellPiece> _cells = new();                    // host 权威模拟;客户端镜像(CellsState RPC)
	readonly Dictionary<Guid, Vector2> _steerDir = new();       // host:每球平滑转向(分身跟随用)
	readonly Dictionary<Guid, TimeSince> _sinceEject = new();   // host:吐孢子节流
	TimeSince _sinceCellSync = 1f;
	bool _cellsDirty;                                           // 增删标记(列表清空后还要补发一次空快照)
	int _nextCellId = 1;
	Vector2[] _spikes;                                          // 绿刺位置(host 生成;客户端经 SpikesFull 同步)

	/// <summary> 全部球(真人 + bot) </summary>
	public IReadOnlyList<Ball> Balls => _balls;

	/// <summary> 本地球(M2 起:真人=自己那颗,bot 无) </summary>
	public Ball LocalBall { get; private set; }

	/// <summary> 本地玩家是否处于死亡待重生状态(HUD 显示死亡面板) </summary>
	public bool IsLocalDead => _worldReady && LocalBall.IsValid() && !LocalBall.Alive;

	/// <summary> 食物管理(渲染与 AI 读) </summary>
	public FoodManager Food => _food;

	/// <summary> 全部分身/孢子(渲染读;host 权威,客户端为 15Hz 快照镜像) </summary>
	public IReadOnlyList<CellPiece> Cells => _cells;

	/// <summary> 绿刺位置(静态障碍;host 生成,客户端经 SpikesFull 全量同步) </summary>
	public Vector2[] Spikes => _spikes;

	/// <summary> HUD(提供屏幕光标桥接等) </summary>
	public GameHud Hud => _hud;

	/// <summary> 房间列表页(主菜单 [2] 打开,点行加入) </summary>
	public RoomBrowser Browser => _browser;

	/// <summary> host:房间开着(等 START);此阶段玩家可调参/进出,比赛尚未生成 </summary>
	public bool IsLobbyOpen => _lobbyOpen;

	/// <summary> 比赛是否已开始(客户端区分"进房间等"还是"直接进游戏") </summary>
	public bool IsMatchStarted => _gameStarted;

	/// <summary> 客户端视角:正在会话里(房间等待/对局/加入倒计时中)——掉线自动重连判定用 </summary>
	public bool IsClientInSession => _lobbyWaiting || _gameStarted || _matchRunning || _joinCountdown >= 0f;

	/// <summary> 客户端是否停在等待页(P 退局/掉线恢复中)——本机球停止操控(球留在原地,防隔屏盲开) </summary>
	public bool IsClientWaiting => _lobbyWaiting;

	/// <summary>
	/// 客户端进入房间等待态(所有入口统一走这里,v0.7.1.1):
	/// 置标志 + **重置/解除房间心跳**——进入等待的时刻 host 不一定在房间阶段(可能对局进行中,
	/// 没有名单广播),旧计时器/武装状态会立刻误判超时,必须等 host 的下一条 LobbyState 重新武装。
	/// </summary>
	void EnterClientWaiting()
	{
		if ( _lobbyWaiting ) return;   // 已在等待态:别重置计时(否则心跳永远判不超时)

		_lobbyWaiting = true;
		_lobbyHeartbeatArmed = false;
		_sinceLobbyHeartbeat = 0;
	}

	static GameObject _sharedRoot;   // 热重载/重建时复用同一个世界根,避免堆叠多套场景

	bool _worldReady;
	bool _networkReady;
	bool _gameStarted;              // 比赛进行中(模拟门控);房间/菜单阶段 false
	bool _menuDemo;                 // 主菜单背景演示赛:authority 离线跑一场纯 bot 对战(v0.6.3.0)
	bool _lobbyOpen;                // host:房间开着(还没点 START)
	bool _lobbyWaiting;             // client:已连入,在房间里等房主开局
	bool _joinRequested;            // 客户端点了"加入":等会话以客户端身份激活(连接前几帧 IsAuthority 不可信)
	TimeSince _sinceJoinRequest;
	TimeSince _sinceMatchOver;      // 结算显示计时(10 秒后自动回大厅/主菜单)
	bool _lobbyListDirty;           // 房间玩家名单变了(主线程下一拍广播 LobbyState)
	byte _lobbyMode;                // client:房主推送的房间参数(LobbyState RPC)
	int _lobbyDuration;
	int _lobbyPlayers;
	string[] _lobbyNames = System.Array.Empty<string>();
	float _joinCountdown = -1f;     // 客户端加入倒计时(≥0 时房间页显示 3-2-1,归零进入对局)
	bool _matchRunning;             // 客户端视角:host 的对局进行中(房间页亮 JOIN GAME 按钮)

	// host 心跳(v0.6.8.0):host 房间阶段 3s 广播一次名单当心跳;客户端收到归零,
	// 静默超时 = host 死了(进程被杀时引擎会话可能不自动失效),强制拆线走重连
	TimeSince _sinceLobbyPing = 999f;   // host:房间心跳节拍
	bool _lobbyHeartbeatArmed;          // client:收到过第一次名单后才武装(防刚进房就误判)
	TimeSince _sinceLobbyHeartbeat;

	public CircleroyaleGame( Scene scene ) : base( scene )
	{
		Listen( Stage.UpdateBones, 0, Tick, "CircleroyaleGame.Tick" );
	}

	void Tick()
	{
		if ( !Game.IsPlaying ) return;

		EnsureWorld();

		// 客户端:会话激活但世界还在菜单演示态(断线重连时 EnsureWorld 在断线期把分支跑成了
		// 演示赛,且重连不会置 _joinRequested)→ 收演示赛,回到会话加入流
		if ( !NetworkManager.IsAuthority && Networking.IsActive && _menuDemo )
			StopMenuDemo();

		// 客户端:在会话里但哪个界面都不在(重连/热重载后)→ 回房间等待并拉当前状态
		if ( !NetworkManager.IsAuthority && Networking.IsActive && !_gameStarted
			&& !_lobbyWaiting && _joinCountdown < 0f )
		{
			EnterClientWaiting();
			_menu?.Hide();
			_browser?.Hide();
			_lobby?.ShowClient();
			NetworkManager.RequestMatchState();
		}

		// 客户端:Replicated 的 cr_arena_size 由引擎落到 ConvarArenaSize,这里同步到 ArenaHalfSize。
		// host 在 OnNetworkReady 做同款钳制;此前客户端从不赋值——网格/边界/钳制一直用静态默认值,
		// 场地改 3 倍后与 host 实测脱节(host 2048 / 客户端 6144,客户端网格大了 3 倍)
		if ( !NetworkManager.IsAuthority )
			GameConfig.ArenaHalfSize = Math.Clamp( GameConfig.ConvarArenaSize, 1024f, 12288f );

		// 客户端加入流:点了"加入"后等会话以客户端身份激活。
		// ⚠️ 不能点完就信 IsAuthority——连接建立前的几帧 IsActive=false,IsAuthority 恒为 true
		if ( _joinRequested && !_gameStarted )
		{
			if ( Networking.IsActive && !Networking.IsHost )
			{
				Log.Info( "[menu] join: connected as client, entering room" );
				_joinRequested = false;
				EnterClientWaiting();
				_menu?.Hide();
				_browser?.Hide();   // 房间列表页收起(从列表点进来的正常收尾)
				_lobby?.ShowClient();
				NetworkManager.RequestMatchState();
			}
			else if ( _sinceJoinRequest > 12f )
			{
				_joinRequested = false;
				_menu?.SetStatus( "JOIN FAILED — IS THE HOST UP? PRESS [2] TO RETRY" );
				_browser?.SetStatus( "JOIN TIMED OUT — PICK ANOTHER ROOM OR REFRESH" );
			}
		}

		// 客户端在会话界面里连接断开:**不立刻回菜单**——界面留在原地由 NetworkManager
		// 自动重连(TickDisconnectRecovery),重试耗尽回调 OnReconnectFailed 才回主菜单(v0.6.4.6)

		// 房间名单/参数有变(网络线程只置脏标,主线程统一广播 LobbyState)
		if ( _lobbyOpen && _lobbyListDirty )
		{
			_lobbyListDirty = false;
			BroadcastLobbyState();
		}

		// host 房间心跳(v0.6.8.0):名单没变也定时广播,客户端靠它判断"host 还活着"
		if ( _lobbyOpen && Networking.IsActive && _sinceLobbyPing > 3f )
		{
			_sinceLobbyPing = 0;
			BroadcastLobbyState();
		}

		// host 心跳超时(客户端,v0.6.8.0):host 进程被杀时引擎会话可能不自动失效
		// (僵尸态,IsActive 恒 true,掉线恢复循环不触发)——对局/房间各自有心跳,
		// 静默超时主动拆线,交给 TickDisconnectRecovery(重连耗尽回主菜单)
		// v0.7.1.1 修:两条心跳**按 host 实际阶段取一**——host 在对局阶段不发名单广播,
		// 等在房间页的客户端不能拿"名单静默"误判(此前中途加入 6s 即被踢回主菜单);
		// 对局心跳(MatchTick 1Hz)反过来也覆盖"客户端在房间页等、host 对局还在跑"的情况
		if ( !NetworkManager.IsAuthority && Networking.IsActive && GameConfig.HostLivenessTimeout > 0f )
		{
			bool matchSilent = ( _gameStarted || _matchRunning ) && !MatchState.MatchOver
				&& MatchState.SinceLastTick > GameConfig.HostLivenessTimeout;
			bool lobbySilent = _lobbyWaiting && _lobbyHeartbeatArmed && !_matchRunning
				&& _sinceLobbyHeartbeat > GameConfig.HostLivenessTimeout;

			if ( matchSilent || lobbySilent )
			{
				Log.Warning( "[net] host silent beyond liveness timeout — forcing disconnect" );
				try { Networking.Disconnect(); } catch { }

				// 对局已死:立刻退出对局态,恢复期停在房间页(CONNECTION LOST),
				// 不再对着冻结画面干等(_gameStarted 挂着会让 HUD/冻结对局一直占屏)
				_gameStarted = false;
				_matchRunning = false;
				_joinCountdown = -1f;
				_lobbyHeartbeatArmed = false;
				_lobbyWaiting = true;   // 保住 IsClientInSession:掉线恢复循环要靠它启动
				LocalBall = null;
			}
		}

		// 音乐循环(菜单/战斗都要):音量跟随 convar + 曲终自动重播
		GameMusic.Tick();

		ReconcileUi();

		// P 键快速退局(v0.6.9.0 用户需求):对局中直接返回大厅——host 广播取消让全员一起回房,
		// 客户端自己回等待页;单机(无会话)回主菜单。结算期不受理(EndBoard 自动返回在管)
		if ( _gameStarted && !MatchState.MatchOver && Input.Keyboard.Pressed( "P" ) )
		{
			Log.Info( "[match] quit to lobby via P" );
			if ( Networking.IsActive && NetworkManager.IsAuthority )
				NetworkManager.MatchCancelled();
			AfterSettlement();
			return;
		}

		// 客户端加入倒计时(v0.6.4.0 用户定稿):MatchStarted 后房间页 3-2-1,归零进入对局
		if ( _joinCountdown >= 0f )
		{
			_joinCountdown -= Time.Delta;
			_lobby?.ShowJoinCountdown( MathF.Max( 0f, _joinCountdown ) );
			if ( _joinCountdown < 0f )
			{
				_lobby?.HideJoinCountdown();
				OnMatchStartedLocal();
			}
		}

		// 菜单阶段:零模拟(食物/bot/吃判定/同步全都不跑)——除非背景演示赛在跑(v0.6.3.0)
		if ( !_gameStarted && !_menuDemo ) return;

		// 比赛进程(M5):host 权威倒计时,归零广播结算;结束后全场冻结由结算面板接管
		TickMatch();
		if ( MatchState.MatchOver )
		{
			// 结算显示 10 秒后自动回大厅(多人)/主菜单(单机);EndBoard 空格可提前
			if ( _sinceMatchOver > GameConfig.SettlementAutoReturnSeconds )
				AfterSettlement();
			return;
		}

		// 权威端:食物重生 / bot 原地复活 / 位移校验(顺便产出转向方向)/ 分身模拟 / 吃判定。
		// 分身模拟放在位移校验之后——转向方向取自球的本帧位移;吃判定最后跑(吃到的是新位置)
		if ( NetworkManager.IsAuthority )
		{
			_food.Tick();
			TickBots();
			TickMovementValidation();
			TickCells();
			TickEating();
			TickSpikes();

			// 分身/孢子状态 15Hz 快照广播(列表清空后还要补发一次空快照让客户端也清空)
			if ( _sinceCellSync > GameConfig.CellSyncInterval )
			{
				_sinceCellSync = 0;
				if ( _cells.Count > 0 || _cellsDirty ) BroadcastCells();
			}
		}

		// 细胞渲染位置平滑:host 逐帧权威位置直接贴齐;客户端把 15Hz 快照插顺
		if ( _cells.Count > 0 )
		{
			var st = 1f - MathF.Exp( -12f * Time.Delta );
			foreach ( var c in _cells )
				c.DrawPos = NetworkManager.IsAuthority ? c.Pos : Vector3.Lerp( c.DrawPos, c.Pos, st );
		}

		// 球注册:扫描场景收编(网络对象的 OnStart 不触发,实测)+ 清理失效球
		if ( _sinceScan > 1f )
		{
			_sinceScan = 0;
			ScanBalls();
		}

		// 客户端:本地食物列表未就绪(热重载/新 FoodManager)→ 节流请求全量。
		// 等球扫描到(=场景快照已收)再请求;刚连入时 host 的 OnActive 会主动发,无需抢跑
		if ( !NetworkManager.IsAuthority && _food.Foods is null && _balls.Count > 0 && _sinceFoodRequest > 3f )
		{
			_sinceFoodRequest = 0;
			try
			{
				NetworkManager.RequestFoodFull();
			}
			catch ( Exception e )
			{
				Log.Info( $"[net] RequestFoodFull failed (session not ready): {e.Message}" );
			}
		}

		TickRespawn();
	}

	// ---- 吃与成长 ----

	void TickEating()
	{
		// 球 × 食物:食物中心进入球体即被吃(空间哈希粗筛 + 精确复核,4140 颗时省 ~20 万次/帧)
		var foods = _food.Foods;
		foreach ( var ball in _balls )
		{
			if ( !ball.IsValid() || !ball.Alive ) continue;

			var bp = ball.WorldPosition;
			var r = ball.Radius;
			var near = _food.Nearby( bp, r );

			for ( int k = 0; k < near.Count; k++ )
			{
				int i = near[k];
				if ( !foods[i].Alive ) continue;

				var dx = foods[i].Pos.x - bp.x;
				var dy = foods[i].Pos.y - bp.y;
				if ( dx * dx + dy * dy > r * r ) continue;

				if ( _food.TryEat( i, ball.OwnerSteamId ) )
				{
					ball.Mass = MathF.Min( GameConfig.MaxMass, ball.Mass + GameConfig.FoodMass );
					NeonRenderer.Spark( new Vector3( foods[i].Pos.x, foods[i].Pos.y, 0f ), foods[i].ColorIndex );
					if ( GameSfx.IsMine( ball.OwnerSteamId ) )
						GameSfx.EatFood( new Vector3( foods[i].Pos.x, foods[i].Pos.y, 0f ) );
				}
			}
		}

		// 球 × 球:触碰即吃(质量比门槛;护盾期/死亡免战)。
		// 死亡不销毁对象——MarkDead 后原地复用(进行中 NetworkSpawn 不复制到已连接客户端,实测)
		for ( int i = 0; i < _balls.Count; i++ )
		{
			var a = _balls[i];
			if ( !a.IsValid() || !a.Alive ) continue;

			for ( int j = i + 1; j < _balls.Count; j++ )
			{
				var b = _balls[j];
				if ( !b.IsValid() || !b.Alive ) continue;

				var bigger = a.Mass >= b.Mass ? a : b;
				var smaller = bigger == a ? b : a;
				if ( SameTeam( a, b ) ) continue;   // 队友免伤(团队赛,v0.6.5.0)
				if ( bigger.Mass < smaller.Mass * GameConfig.EatRatio ) continue;
				if ( smaller.IsProtected || bigger.IsProtected ) continue;   // 护盾期双向免战

				var d = bigger.WorldPosition.Distance( smaller.WorldPosition );
				if ( d >= bigger.Radius + smaller.Radius ) continue;   // 触碰即吃(用户定稿)

				// 吞并封顶:不设上限的话连锁吞噬会让半径超过场地一半,
				// 边界钳制 min>max 抛异常(每帧刷屏)且重生者秒被吃(死亡循环)——实测
				float gained = smaller.Mass;
				bigger.Mass = MathF.Min( GameConfig.MaxMass, bigger.Mass + gained );
				SpawnPopFx( smaller );
				// 吞球音只对双方播(吃者 + 被吃者)+ 全场"谁吃了谁"播报(双端统一入口)
				NotifyBallEaten( bigger.OwnerSteamId, smaller.OwnerSteamId, gained );

				// agar 多身体规则(v0.6.2.0 用户定稿):单个身体死亡不导致玩家死亡——
				// 主球被吃时最大的分身升级成新主球,全部身体死光才算死
				if ( TryPromoteFromPieces( smaller ) )
				{
					Log.Info( $"[game] body lost: {smaller.PlayerName} <- {bigger.PlayerName} (promoted)" );
					continue;
				}

				smaller.MarkDead();
				Log.Info( "[game] eaten: " + smaller.PlayerName + " <- " + bigger.PlayerName );
			}
		}

		// ---- 分身/孢子参与吃(M3)----

		// 分身 × 食物(同款空间哈希粗筛)
		foreach ( var c in _cells )
		{
			if ( c.PieceKind != CellPiece.Kind.SplitPiece ) continue;

			var cp = c.Pos;
			var cr = c.Radius;
			var near = _food.Nearby( cp, cr );

			for ( int k = 0; k < near.Count; k++ )
			{
				int i = near[k];
				if ( !foods[i].Alive ) continue;

				var dx = foods[i].Pos.x - cp.x;
				var dy = foods[i].Pos.y - cp.y;
				if ( dx * dx + dy * dy > cr * cr ) continue;

				if ( _food.TryEat( i, c.OwnerSteamId ) )
				{
					c.Mass = MathF.Min( GameConfig.MaxMass, c.Mass + GameConfig.FoodMass );
					NeonRenderer.Spark( new Vector3( foods[i].Pos.x, foods[i].Pos.y, 0f ), foods[i].ColorIndex );
					if ( GameSfx.IsMine( c.OwnerSteamId ) )
						GameSfx.EatFood( new Vector3( foods[i].Pos.x, foods[i].Pos.y, 0f ) );
				}
			}
		}

		// 孢子被吞:球/分身碰到就吃(吐出 0.5s 后才可食,防秒吃自己刚吐的)
		for ( int i = _cells.Count - 1; i >= 0; i-- )
		{
			var blob = _cells[i];
			if ( blob.PieceKind != CellPiece.Kind.EjectedMass ) continue;
			if ( blob.SinceSpawn < GameConfig.EjectedEdibleDelay ) continue;

			bool eaten = false;
			foreach ( var b in _balls )
			{
				if ( !b.IsValid() || !b.Alive ) continue;
				if ( b.WorldPosition.Distance( blob.Pos ) < b.Radius + blob.Radius )
				{
					b.Mass = MathF.Min( GameConfig.MaxMass, b.Mass + blob.Mass );
					eaten = true;
					break;
				}
			}
			if ( !eaten )
			{
				foreach ( var c in _cells )
				{
					if ( c.PieceKind != CellPiece.Kind.SplitPiece || c == blob ) continue;
					if ( c.Pos.Distance( blob.Pos ) < c.Radius + blob.Radius )
					{
						c.Mass = MathF.Min( GameConfig.MaxMass, c.Mass + blob.Mass );
						eaten = true;
						break;
					}
				}
			}

			if ( eaten )
			{
				_cells.RemoveAt( i );
				_cellsDirty = true;
			}
		}

		// 跨主人:球 × 分身(同主人碰撞走 TickCells 的合并,不在这里)
		// 队友免伤(v0.6.5.0):团队赛下分身不与队友的主球互吃——按主人查队伍
		var teamOf = new Dictionary<long, int>();
		if ( MatchState.IsTeam )
		{
			foreach ( var b in _balls )
			{
				if ( b.IsValid() && b.TeamIndex >= 0 ) teamOf[b.OwnerSteamId] = b.TeamIndex;
			}
		}

		for ( int i = 0; i < _balls.Count; i++ )
		{
			var ball = _balls[i];
			if ( !ball.IsValid() || !ball.Alive ) continue;
			if ( ball.IsProtected ) continue;   // 护盾期双向免战(分身没有护盾,只会被吃)

			for ( int j = _cells.Count - 1; j >= 0; j-- )
			{
				var c = _cells[j];
				if ( c.PieceKind != CellPiece.Kind.SplitPiece ) continue;
				if ( c.OwnerSteamId == ball.OwnerSteamId ) continue;
				if ( MatchState.IsTeam
					&& teamOf.TryGetValue( c.OwnerSteamId, out var pt )
					&& pt == ball.TeamIndex ) continue;   // 队友的分身/主球互不吃

				// 球吞分身
				if ( ball.Mass >= c.Mass * GameConfig.EatRatio
					&& ball.WorldPosition.Distance( c.Pos ) < ball.Radius + c.Radius )
				{
					ball.Mass = MathF.Min( GameConfig.MaxMass, ball.Mass + c.Mass );
					_cells.RemoveAt( j );
					_cellsDirty = true;
					continue;
				}

				// 分身反吞主球(v0.6.2.0 agar 规则):只损失主球这个身体——最大分身升级成新主球
				if ( c.Mass >= ball.Mass * GameConfig.EatRatio
					&& c.Pos.Distance( ball.WorldPosition ) < c.Radius + ball.Radius )
				{
					float gained = ball.Mass;
					c.Mass = MathF.Min( GameConfig.MaxMass, c.Mass + gained );
					SpawnPopFx( ball );
					NotifyBallEaten( c.OwnerSteamId, ball.OwnerSteamId, gained );

					if ( TryPromoteFromPieces( ball ) )
					{
						Log.Info( $"[game] body lost: {ball.PlayerName} <- cell#{c.Id} (promoted)" );
					}
					else
					{
						ball.MarkDead();
						if ( !ball.IsBot ) PopPiecesOf( ball.OwnerSteamId );
						Log.Info( "[game] eaten: " + ball.PlayerName + " <- cell#" + c.Id );
					}
					break;
				}
			}
		}

		// 跨主人:分身 × 分身(i 升序遍历;被吞的自己要退索引)
		for ( int i = 0; i < _cells.Count; i++ )
		{
			var a = _cells[i];
			if ( a.PieceKind != CellPiece.Kind.SplitPiece ) continue;

			for ( int j = _cells.Count - 1; j > i; j-- )
			{
				var b = _cells[j];
				if ( b.PieceKind != CellPiece.Kind.SplitPiece ) continue;
				if ( b.OwnerSteamId == a.OwnerSteamId ) continue;
				if ( MatchState.IsTeam
					&& teamOf.TryGetValue( a.OwnerSteamId, out var ta )
					&& teamOf.TryGetValue( b.OwnerSteamId, out var tb )
					&& ta == tb ) continue;   // 队友分身互不吃(v0.6.5.0)

				var big = a.Mass >= b.Mass ? a : b;
				var small = big == a ? b : a;
				if ( big.Mass < small.Mass * GameConfig.EatRatio ) continue;
				if ( big.Pos.Distance( small.Pos ) >= big.Radius + small.Radius ) continue;

				big.Mass = MathF.Min( GameConfig.MaxMass, big.Mass + small.Mass );
				_cellsDirty = true;
				if ( small == a )
				{
					_cells.RemoveAt( i );
					i--;   // 下一个元素移位补位,外层 i++ 后正好指向它
					break;
				}
				_cells.RemoveAt( j );
			}
		}
	}

	/// <summary> bot 死亡后原地复活(不再销毁重建——进行中 NetworkSpawn 不复制到已连接客户端,实测) </summary>
	void TickBots()
	{
		foreach ( var b in _balls )
		{
			if ( !b.IsValid() || !b.IsBot || b.Alive ) continue;
			if ( b.SinceDeath < GameConfig.BotRespawnSeconds ) continue;

			RespawnBall( b );
		}
	}

	void TickRespawn()
	{
		if ( !LocalBall.IsValid() ) return;
		if ( LocalBall.Alive ) return;
		if ( !Input.Pressed( "Jump" ) ) return;

		TryRespawnLocal();
	}

	/// <summary> 本机重生请求(空格 / 死亡面板点击共用):权威端直接原地复活;
	/// 客户端发静态 RPC(host 端 Rpc.Caller 找回连接,Alive 同步下发)。
	/// 菜单/房间阶段不受理(蛰伏球是死的,但比赛没开始;空格在结算面板另有用途) </summary>
	public void TryRespawnLocal()
	{
		if ( !_gameStarted || MatchState.MatchOver ) return;
		if ( !LocalBall.IsValid() || LocalBall.Alive ) return;

		Log.Info( "[game] local respawn requested" );
		if ( NetworkManager.IsAuthority )
		{
			RespawnBall( LocalBall );
		}
		else
		{
			NetworkManager.RequestRespawn();
		}
	}

	/// <summary> 原地复活一颗球:重置数值 + Alive=true(同步下发;对象永不销毁)。
	/// teamIndex ≥ 0 时重写队伍号(开局激活时的团队分配;普通赛 -1 不动) </summary>
	void RespawnBall( Ball ball, int teamIndex = -1 )
	{
		ball.Mass = GameConfig.StartMass;
		ball.Init( ball.IsBot, teamIndex );   // 重置保护期/速度/颜色(同步下发)
		ball.Alive = true;

		// host 自己的球/bot:直接换位;真人客户端的球:该客户端在 Alive→true 时自行随机换位(owner 模拟)
		if ( ball.IsBot || ball.OwnerSteamId == Game.SteamId.Value )
			ball.WorldPosition = RandomSpawnPos();

		Log.Info( $"[net] respawned '{ball.PlayerName}'" );
	}

	// ---- 比赛进程(M5):host 权威倒计时,归零广播结算榜 ----

	void TickMatch()
	{
		if ( MatchState.MatchOver ) return;

		// 双端本地递减:host 是权威,客户端 1Hz 校时后自己续走(观感平滑)
		MatchState.TimeLeft = MathF.Max( 0f, MatchState.TimeLeft - Time.Delta );

		if ( !NetworkManager.IsAuthority ) return;

		if ( _sinceMatchTick > 1f )
		{
			_sinceMatchTick = 0;
			NetworkManager.MatchTick( MatchState.TimeLeft );
		}

		if ( MatchState.TimeLeft <= 0f ) EndMatch();
	}

	/// <summary> 比赛结束(host):按总质量(主球+分身)排结算榜,广播 + 本地落地。
	/// 菜单背景演示赛时间到就就地重开,不走结算。 </summary>
	void EndMatch()
	{
		if ( MatchState.MatchOver ) return;

		if ( _menuDemo )
		{
			MatchState.ActivateForMatch();   // 演示赛循环重开(无连接,RPC 空发无害)
			Log.Info( "[menu] demo match restarted" );
			return;
		}

		var rows = new List<ScoreWire>();
		foreach ( var b in _balls )
		{
			if ( !b.IsValid() ) continue;
			rows.Add( new ScoreWire
			{
				SteamId = b.OwnerSteamId,
				Name = b.PlayerName,
				Mass = TotalMassFor( b ),
				Team = (byte)Math.Max( 0, b.TeamIndex ),
				Bot = b.IsBot,
			} );
		}
		var arr = rows.ToArray();
		Array.Sort( arr, ( x, y ) => y.Mass.CompareTo( x.Mass ) );

		NetworkManager.MatchOver( arr );   // 客户端展示用
		OnMatchOverRemote( arr );          // host 立即落地(广播回声被 MatchOver 守卫挡掉)

		Log.Info( $"[match] over — {arr.Length} players, winner: {( arr.Length > 0 ? arr[0].Name : "?" )}" );
	}

	/// <summary> 比赛结束落地(全端):冻结 + 结算面板;本机成绩上云、本地榜落盘(各端各自做)。
	/// 10 秒后自动回大厅(多人)/主菜单(单机),见 AfterSettlement。 </summary>
	public void OnMatchOverRemote( ScoreWire[] standings )
	{
		if ( MatchState.MatchOver ) return;   // host 广播回声防重入
		MatchState.MatchOver = true;
		_sinceMatchOver = 0;

		var local = LocalBall;
		_endBoard?.Show( standings,
			local.IsValid() ? local.OwnerSteamId : 0,
			local.IsValid() ? local.PlayerName : "" );
		LocalBoard.Record( standings );
		ScoreUploader.SubmitOwn( standings, this );
		Log.Info( $"[match] settlement shown ({standings?.Length ?? 0} rows)" );
	}

	/// <summary> 吞球通知(host):广播客户端 + 本机播报/音效 </summary>
	void NotifyBallEaten( long eaterSteamId, long eatenSteamId, float massGained )
	{
		if ( Networking.IsActive ) NetworkManager.BallEaten( eaterSteamId, eatenSteamId, massGained );   // 无会话(菜单演示赛)不广播
		OnBallEaten( eaterSteamId, eatenSteamId, massGained );
	}

	/// <summary> 吞球事件(双端统一入口):全场"谁吃了谁"播报;音效只对参与的双方 </summary>
	public void OnBallEaten( long eaterSteamId, long eatenSteamId, float massGained )
	{
		var eater = FindBallBySteamId( eaterSteamId );
		var eaten = FindBallBySteamId( eatenSteamId );
		if ( GameSfx.IsMine( eaterSteamId ) || GameSfx.IsMine( eatenSteamId ) )
			GameSfx.EatBall( eaten.IsValid() ? eaten.WorldPosition : Vector3.Zero );

		_hud?.AddKillFeed( eater?.PlayerName ?? "???",
			eaten?.PlayerName ?? "???",
			massGained, eaterSteamId, eatenSteamId );
	}

	/// <summary> host 位移校验:远端模拟的球单帧位移超理论上限 3 倍即拉回(反瞬移橡皮筋) </summary>
	void TickMovementValidation()
	{
		var mySteamId = Game.SteamId.Value;
		foreach ( var b in _balls )
		{
			if ( !b.IsValid() ) continue;

			var key = b.Id;
			var last = _lastPos.TryGetValue( key, out var lp ) ? lp : b.WorldPosition;

			if ( !b.IsBot && b.OwnerSteamId != mySteamId )
			{
				var maxMove = b.Speed * Time.Delta * 3f + 16f;
				if ( b.WorldPosition.Distance( last ) > maxMove )
					b.WorldPosition = last;
			}

			_lastPos[key] = b.WorldPosition;

			// 转向方向(分身跟随用):取本帧位移方向平滑;静止时保持原方向
			var delta = b.WorldPosition - last;
			if ( delta.Length > 0.1f )
			{
				var n = new Vector3( delta.x, delta.y, 0f ).Normal;
				var prev = _steerDir.TryGetValue( key, out var sd ) ? sd : new Vector2( n.x, n.y );
				_steerDir[key] = new Vector2( prev.x + ( n.x - prev.x ) * 0.4f, prev.y + ( n.y - prev.y ) * 0.4f );
			}
		}
	}

	int CountPlayers( bool isBot )
	{
		int n = 0;
		foreach ( var b in _balls )
		{
			if ( b.IsValid() && b.IsBot == isBot ) n++;
		}
		return n;
	}

	/// <summary>
	/// 扫描场景里的 Ball 并收编进列表(网络对象的 OnStart 不可靠,实测不触发)。
	/// 本机球判定用所有权字段:非代理 + 非bot + OwnerSteamId 是自己的 SteamId。
	/// </summary>
	void ScanBalls()
	{
		// 客户端:快照世界根自愈启用(首次 Tick 可能早于快照应用完成,EnsureWorld 扑空时兜住)
		if ( !NetworkManager.IsAuthority )
		{
			AdoptSnapshotWorld();
		}

		foreach ( var b in Scene.GetAllComponents<Ball>() )
		{
			if ( !b.IsValid() ) continue;

			// 存量 bot 自愈:v0.5.1.3 之前生成的 bot OwnerSteamId=0(分身头像/名牌无法归属)——
			// host 补发唯一假 id([Sync] 自动下发客户端),热重载/老会话里的 bot 也被覆盖
			if ( NetworkManager.IsAuthority && b.IsBot && b.OwnerSteamId < GameConfig.BotSteamIdBase )
			{
				b.OwnerSteamId = NextBotSteamId();
				Log.Info( $"[game] legacy bot '{b.PlayerName}' assigned steam id {b.OwnerSteamId}" );
			}

			if ( !_balls.Contains( b ) )
			{
				_balls.Add( b );
			}

			// 本机球 = 自己拥有的那颗(IsProxy=false 即所有权,天然可靠)。
			// 不要用 OwnerSteamId 匹配:同机双开时连接是合成 ID,与 Game.SteamId 对不上(实测踩坑)。
			// ⚠️ 每次扫描都要断言(不能只在新收编时):客户端的 Ball.OnStart 跑在快照期,
			// 早于本端 EnsureWorld 建相机,首次 SetLocalBall 时 _camera 为 null 被跳过;
			// SetTarget 内部有同球守卫,重复调用零开销。
			if ( !b.IsProxy && !b.IsBot )
			{
				SetLocalBall( b );
			}
		}

		for ( int i = _balls.Count - 1; i >= 0; i-- )
		{
			if ( !_balls[i].IsValid() ) _balls.RemoveAt( i );
		}
	}

	/// <summary> 球注册(Ball.OnStart 在所有机器上调用;host 生成与客户端复制统一入口) </summary>
	public void RegisterBall( Ball ball )
	{
		if ( !_balls.Contains( ball ) ) _balls.Add( ball );
	}

	/// <summary> 标记本机拥有的球,并让跟随相机锁定它。
	/// host 上为远程连接生成的球(IsRemoteOwned)绝不充当本机球——同账号双开时
	/// 它们与 host 球同名同 SteamId,唯一可靠的区别就是生成意图(实测踩坑)。 </summary>
	public void SetLocalBall( Ball ball )
	{
		if ( ball is null || ball.IsRemoteOwned ) return;

		LocalBall = ball;
		_camera?.SetTarget( ball );
	}

	/// <summary> 场地内随机出生点:**agar 式安全出生**——采样多点,选离"能吃掉出生质量"的
	/// 最近威胁最远的点。随机裸生会被路过的中 bot 秒吃(实测:加入 1.6s 被吃) </summary>
	public Vector3 RandomSpawnPos()
	{
		var half = GameConfig.ArenaHalfSize - 128f;

		Vector3 best = new Vector3( Game.Random.Float( -half, half ), Game.Random.Float( -half, half ), 0f );
		var bestScore = float.MinValue;

		for ( int i = 0; i < GameConfig.SpawnSafeSamples; i++ )
		{
			var p = new Vector3( Game.Random.Float( -half, half ), Game.Random.Float( -half, half ), 0f );
			var score = float.MaxValue;

			foreach ( var b in _balls )
			{
				if ( !b.IsValid() || !b.Alive ) continue;
				if ( b.Mass < GameConfig.StartMass * GameConfig.EatRatio ) continue;   // 吃不动出生球的忽略

				var d = p.Distance( b.WorldPosition ) - b.Radius;
				if ( d < score ) score = d;
			}

			if ( score > bestScore )
			{
				bestScore = score;
				best = p;
			}
		}
		return best;
	}

	static string LocalPlayerName()
	{
		try { return Connection.Local?.DisplayName ?? "You"; }
		catch { return "You"; }
	}

	// ---- 分身/孢子:host 权威模拟(M3)----

	/// <summary>
	/// 分身/孢子每帧模拟:冲量衰减 → 跟随主人转向 → 场地钳制 → 合并检查 → 清理。
	/// 孢子无转向(滑行到停),分身按主人球的平滑转向方向跟随移动。
	/// </summary>
	void TickCells()
	{
		if ( _cells.Count == 0 ) return;

		var dt = Time.Delta;

		for ( int i = _cells.Count - 1; i >= 0; i-- )
		{
			var c = _cells[i];

			// 主人球没了(被吃死亡/断线销毁)→ 分身一并消散(孢子中性存活,不清理)
			if ( c.PieceKind == CellPiece.Kind.SplitPiece
				&& ( !c.OwnerBall.IsValid() || !c.OwnerBall.Alive ) )
			{
				_cells.RemoveAt( i );
				_cellsDirty = true;
				continue;
			}

			c.Impulse *= MathF.Exp( -( c.PieceKind == CellPiece.Kind.SplitPiece
				? GameConfig.SplitImpulseDecay : GameConfig.EjectImpulseDecay ) * dt );

			if ( c.PieceKind == CellPiece.Kind.SplitPiece )
			{
				var dir = SteerDirOf( c.OwnerBall );
				var target = new Vector3( dir.x, dir.y, 0f ) * c.Speed;

				// 倒流引力(批18④,"秒合"的物理基础):分身比主球重(吐球减重可制造体重差)
				// 或出生超过 PieceHomeAfterSeconds → 自动转向主球滚回,与摇杆输入按权重混合
				var owner = c.OwnerBall;
				if ( owner.IsValid() && owner.Alive && c.IsMergeReady
					&& ( c.Mass > owner.Mass || c.SinceSpawn > GameConfig.PieceHomeAfterSeconds ) )
				{
					var to = owner.WorldPosition - c.Pos;
					if ( to.Length > 1f )
					{
						to = to.Normal;
						var blended = new Vector3(
							to.x * GameConfig.PieceAttractWeight + dir.x,
							to.y * GameConfig.PieceAttractWeight + dir.y, 0f );
						if ( blended.Length > 0.001f )
							target = blended.Normal * c.Speed;
					}
				}

				// 分身转向响应同样随质量衰减(与主球同曲线,大分身一样笨拙)
				var turn = MathF.Max( GameConfig.MinTurnSpeed,
					GameConfig.BaseTurnSpeed * MathF.Pow( GameConfig.StartMass / c.Mass, GameConfig.TurnCurve ) );
				var t = 1f - MathF.Exp( -turn * dt );
				c.SteerVel = Vector3.Lerp( c.SteerVel, target, t );
			}

			var pos = c.Pos + ( c.Impulse + c.SteerVel ) * dt;
			var half = MathF.Max( 0f, GameConfig.ArenaHalfSize - c.Radius );
			pos.x = Math.Clamp( pos.x, -half, half );
			pos.y = Math.Clamp( pos.y, -half, half );
			pos.z = 0f;
			c.Pos = pos;
		}

		// 同主人合并(冷却 1s 后):**碰到即合并**(圆缘相触,不再要求嵌进圆心——
		// 用户实测反馈:靠在一起不动不算,观感就是"不能合并")。优先并回主球,其次大分身吞小分身。
		// ⚠️ 内层 j 只向上扫(j > i):j 掉到 i 以下再 RemoveAt(i) 会索引越界
		//(下方元素被删后 i 已漂移,实测 ArgumentOutOfRangeException 刷屏)
		for ( int i = _cells.Count - 1; i >= 0; i-- )
		{
			var a = _cells[i];
			if ( a.PieceKind != CellPiece.Kind.SplitPiece || !a.IsMergeReady ) continue;

			var owner = a.OwnerBall;
			if ( owner.IsValid() && owner.Alive && owner.WorldPosition.Distance( a.Pos ) < owner.Radius + a.Radius )
			{
				owner.Mass = MathF.Min( GameConfig.MaxMass, owner.Mass + a.Mass );
				_cells.RemoveAt( i );
				_cellsDirty = true;
				continue;
			}

			for ( int j = i - 1; j >= 0; j-- )
			{
				var b = _cells[j];
				if ( b.PieceKind != CellPiece.Kind.SplitPiece ) continue;
				if ( b.OwnerSteamId != a.OwnerSteamId || !b.IsMergeReady ) continue;

				var big = a.Mass >= b.Mass ? a : b;
				var small = big == a ? b : a;
				if ( big.Pos.Distance( small.Pos ) >= big.Radius + small.Radius ) continue;

				big.Mass = MathF.Min( GameConfig.MaxMass, big.Mass + small.Mass );
				_cells.RemoveAt( j );   // 只删 j < i 的,a 的索引 i 恒定安全
				_cellsDirty = true;
				break;   // 每帧每分身并一个,下一帧继续
			}
		}
	}

	/// <summary>
	/// 执行分裂(host 权威;owner 本地直调 / 客户端经 RequestSplit RPC)。
	/// **agar 标准全员分裂(v0.7.1.0 用户需求)**:空格 = 主球 + 每个够重的分身**同时对半分裂**,
	/// 新身体全部朝瞄准方向弹出——连按可快速铺开到分身上限(1→2→4→8→16 只要 4 按)。
	/// 每颗身体独立过质量门槛(对半后不击穿质量下限 1),槽满即停。
	/// </summary>
	public void DoSplit( Ball ball, Vector2 dir )
	{
		if ( !GameConfig.EnableSplit || !NetworkManager.IsAuthority ) return;
		if ( ball is null || !ball.IsValid() || !ball.Alive ) return;
		if ( ball.Mass < GameConfig.SplitMinMass ) return;

		dir = FallbackDir( ball, dir );

		// 主球对半
		if ( CountPiecesOf( ball.OwnerSteamId ) < GameConfig.MaxSplitPieces )
		{
			var pieceMass = ball.Mass * 0.5f;
			ball.Mass -= pieceMass;
			SpawnPiece( ball, dir, pieceMass );
			NeonRenderer.Puff( ball.WorldPosition, ball.ColorIndex );
		}

		// 每个够重的分身也对半(同向弹出;倒序遍历 + 尾部追加,新分身本轮不参与)。
		// 新分身同样贴着母分身**外缘**生成(v0.7.2.0),否则 1s 冷却一到就被吸回去
		for ( int i = _cells.Count - 1; i >= 0; i-- )
		{
			var c = _cells[i];
			if ( c.PieceKind != CellPiece.Kind.SplitPiece || c.OwnerSteamId != ball.OwnerSteamId ) continue;
			if ( c.Mass < GameConfig.SplitMinMass * 2f ) continue;
			if ( CountPiecesOf( ball.OwnerSteamId ) >= GameConfig.MaxSplitPieces ) break;

			var half = c.Mass * 0.5f;
			c.Mass -= half;
			var twinRadius = GameConfig.StartRadius * MathF.Sqrt( half / GameConfig.StartMass );

			var twin = new CellPiece
			{
				Id = _nextCellId++,
				PieceKind = CellPiece.Kind.SplitPiece,
				OwnerSteamId = c.OwnerSteamId,
				OwnerBall = c.OwnerBall,
				ColorIndex = c.ColorIndex,
				Mass = half,
				Pos = c.Pos + new Vector3( dir.x, dir.y, 0f ) * ( c.Radius + twinRadius ),
				DrawPos = c.Pos,
				Impulse = new Vector3( dir.x, dir.y, 0f ) * GameConfig.SplitImpulseSpeed,
				SinceSpawn = 0,
			};
			ClampToArena( twin );
			_cells.Add( twin );
			_cellsDirty = true;
		}

		Log.Info( $"[game] split-all '{ball.PlayerName}'" );
	}

	/// <summary> 生成一个分裂分身(调用方已扣主球质量)。
	/// **贴着主球外缘生成(v0.7.2.0)**:原 0.4×半径在球体内部,大球分身飞不出合并吸附范围,
	/// 1s 冷却一到就被吸回主球——玩家实际永远只有一颗身体,主球一死无身可晋升直接真死 </summary>
	CellPiece SpawnPiece( Ball ball, Vector2 dir, float mass )
	{
		var pieceRadius = GameConfig.StartRadius * MathF.Sqrt( mass / GameConfig.StartMass );
		var c = new CellPiece
		{
			Id = _nextCellId++,
			PieceKind = CellPiece.Kind.SplitPiece,
			OwnerSteamId = ball.OwnerSteamId,
			OwnerBall = ball,
			ColorIndex = ball.ColorIndex,
			Mass = mass,
			Pos = ball.WorldPosition + new Vector3( dir.x, dir.y, 0f ) * ( ball.Radius + pieceRadius ),
			DrawPos = ball.WorldPosition,
			Impulse = new Vector3( dir.x, dir.y, 0f ) * GameConfig.SplitImpulseSpeed,
			SinceSpawn = 0,
		};
		ClampToArena( c );
		_cells.Add( c );
		_cellsDirty = true;
		return c;
	}

	/// <summary>
	/// 撞刺炸裂:连环强制对半分裂,直到分身槽满或质量掉到分裂门槛以下。
	/// 返回是否真炸了东西——大球停在刺上会每帧触发,靠它挡住特效/日志刷屏。
	/// </summary>
	bool DoBurst( Ball ball )
	{
		var slots = GameConfig.MaxSplitPieces - CountPiecesOf( ball.OwnerSteamId );
		if ( slots <= 0 || ball.Mass < GameConfig.SplitMinMass * 2f ) return false;

		int n = 0;
		while ( n < slots && ball.Mass >= GameConfig.SplitMinMass * 2f )
		{
			var pieceMass = ball.Mass * 0.5f;
			ball.Mass -= pieceMass;
			var p = SpawnPiece( ball, RandomDir(), pieceMass );
			p.MergeCooldown = GameConfig.SpikePieceMergeCooldown;   // 撞刺碎片:短冷却,主人可以很快吃回来
			n++;
		}

		SpawnPopFx( ball );
		Log.Info( $"[game] burst on spike: '{ball.PlayerName}' into {n} pieces" );
		return true;
	}

	/// <summary> 大分身撞刺:对半裂成两个(各自重置合并冷却);槽满则被刺吸收 </summary>
	void BurstPiece( CellPiece c )
	{
		var slots = GameConfig.MaxSplitPieces - CountPiecesOf( c.OwnerSteamId );
		if ( slots <= 0 )
		{
			_cells.Remove( c );
			_cellsDirty = true;
			return;
		}

		var dir = RandomDir();
		var half = c.Mass * 0.5f;

		c.Mass = half;
		c.SinceSpawn = 0;
		c.MergeCooldown = GameConfig.SpikePieceMergeCooldown;
		c.Impulse = new Vector3( dir.x, dir.y, 0f ) * GameConfig.SplitImpulseSpeed;

		var d2 = -dir;
		var other = new CellPiece
		{
			Id = _nextCellId++,
			PieceKind = CellPiece.Kind.SplitPiece,
			OwnerSteamId = c.OwnerSteamId,
			OwnerBall = c.OwnerBall,
			ColorIndex = c.ColorIndex,
			Mass = half,
			Pos = c.Pos + new Vector3( d2.x, d2.y, 0f ) * ( c.Radius * 0.5f ),
			DrawPos = c.Pos,
			Impulse = new Vector3( d2.x, d2.y, 0f ) * GameConfig.SplitImpulseSpeed,
			SinceSpawn = 0,
			MergeCooldown = GameConfig.SpikePieceMergeCooldown,
		};
		ClampToArena( other );
		_cells.Add( other );
		_cellsDirty = true;

		NeonRenderer.Pop( c.Pos, c.ColorIndex, c.Radius * 2f );
		if ( GameSfx.IsMine( c.OwnerSteamId ) )
			GameSfx.Pop( c.Pos );
		NetworkManager.PoppedEffect( c.Pos, (byte)c.ColorIndex, c.Radius * 2f, c.OwnerSteamId );
	}

	/// <summary>
	/// 尖刺判定(host,v0.5.1.4 用户定稿 agar 病毒规则):
	/// - 细胞**比刺小**:碰到即被尖刺吃掉(玩家死亡 / 分身消失);
	/// - 细胞**比刺大**:把尖刺吃掉(+SpikeMass)并强制触发分裂(DoBurst 连环炸),尖刺在别处重生(场上恒 12 颗);
	/// - 孢子碰刺芯被吸收(喂刺)。出生保护期内免疫尖刺。
	/// </summary>
	void TickSpikes()
	{
		var spikes = _spikes;
		if ( spikes is null ) return;

		var touch = GameConfig.SpikeRadius * GameConfig.SpikeTouchFactor;

		// 球 × 刺
		foreach ( var b in _balls )
		{
			if ( !b.IsValid() || !b.Alive || b.IsProtected ) continue;   // 护盾期免疫尖刺

			var bp = b.WorldPosition;
			for ( int i = 0; i < spikes.Length; i++ )
			{
				var dx = spikes[i].x - bp.x;
				var dy = spikes[i].y - bp.y;
				var rr = b.Radius + touch;
				if ( dx * dx + dy * dy > rr * rr ) continue;

				if ( b.Radius > GameConfig.SpikeRadius )
				{
					// 吃刺:+100 质量 + 强制分裂,刺重生到新位置
					b.Mass = MathF.Min( GameConfig.MaxMass, b.Mass + GameConfig.SpikeMass );
					DoBurst( b );
					RespawnSpike( i );
				}
				else
				{
					// 被刺吃(v0.6.2.0 agar 规则):只损失这个身体——最大分身升级成新主球
					SpawnPopFx( b );

					if ( TryPromoteFromPieces( b ) )
					{
						Log.Info( $"[game] body lost to spike: {b.PlayerName} (promoted)" );
					}
					else
					{
						b.MarkDead();
						if ( !b.IsBot ) PopPiecesOf( b.OwnerSteamId );
						Log.Info( "[game] eaten by spike: " + b.PlayerName );
					}
				}
				break;
			}
		}

		// 分身 × 刺(同规则;倒序遍历——被吃要移除)
		for ( int i = _cells.Count - 1; i >= 0; i-- )
		{
			var c = _cells[i];
			if ( c.PieceKind != CellPiece.Kind.SplitPiece ) continue;

			for ( int sIdx = 0; sIdx < spikes.Length; sIdx++ )
			{
				var dx = spikes[sIdx].x - c.Pos.x;
				var dy = spikes[sIdx].y - c.Pos.y;
				var rr = c.Radius + touch;
				if ( dx * dx + dy * dy > rr * rr ) continue;

				if ( c.Radius > GameConfig.SpikeRadius )
				{
					c.Mass = MathF.Min( GameConfig.MaxMass, c.Mass + GameConfig.SpikeMass );
					BurstPiece( c );   // 强制对半裂(碎片短冷却,主人能快速吃回)
					RespawnSpike( sIdx );
				}
				else
				{
					NeonRenderer.Puff( c.Pos, c.ColorIndex );
					_cells.RemoveAt( i );
					_cellsDirty = true;
				}
				break;
			}
		}

		// 孢子喂刺:碰刺芯被吸收
		for ( int i = _cells.Count - 1; i >= 0; i-- )
		{
			var c = _cells[i];
			if ( c.PieceKind != CellPiece.Kind.EjectedMass ) continue;

			foreach ( var s in spikes )
			{
				var dx = s.x - c.Pos.x;
				var dy = s.y - c.Pos.y;
				if ( dx * dx + dy * dy > touch * touch ) continue;

				_cells.RemoveAt( i );
				_cellsDirty = true;
				break;
			}
		}
	}

	/// <summary> 刺被吃掉后在别处重生(避开中心出生区),全量广播(12 个位置小包,幂等) </summary>
	void RespawnSpike( int index )
	{
		_spikes[index] = RandomSpikePos();
		if ( Networking.IsActive ) NetworkManager.SpikesFull( _spikes );   // 无会话不广播
	}

	Vector2 RandomSpikePos()
	{
		var half = GameConfig.ArenaHalfSize - GameConfig.FoodMargin - GameConfig.SpikeRadius;
		Vector2 p;
		do
		{
			p = new Vector2( Game.Random.Float( -half, half ), Game.Random.Float( -half, half ) );
		}
		while ( p.Length < 512f );   // 中心区留空(出生/重生不至于脸上有刺)
		return p;
	}

	/// <summary> host 生成尖刺(避开中心出生区)并广播;新连接走 OnActive 定向补发 </summary>
	void GenerateSpikes()
	{
		_spikes = new Vector2[GameConfig.SpikeCount];
		for ( int i = 0; i < _spikes.Length; i++ )
		{
			_spikes[i] = RandomSpikePos();
		}
		if ( Networking.IsActive ) NetworkManager.SpikesFull( _spikes );   // 无会话(菜单演示赛)不广播
		Log.Info( $"[game] spikes generated: {_spikes.Length}" );
	}

	/// <summary>
	/// 执行吐孢子(host 权威,**agar 标准多身体喂球**):对玩家的每一个身体——主球和所有
	/// 够质量的分身——各吐一颗中性孢子(从各自边缘沿输入方向弹出)。按住方向键连吐。
	/// </summary>
	public void DoEject( Ball ball, Vector2 dir )
	{
		if ( !GameConfig.EnableEject || !NetworkManager.IsAuthority ) return;
		if ( ball is null || !ball.IsValid() || !ball.Alive ) return;
		if ( _sinceEject.TryGetValue( ball.Id, out var t ) && t < GameConfig.EjectCooldownSeconds ) return;
		_sinceEject[ball.Id] = 0;

		dir = FallbackDir( ball, dir );

		EjectFrom( ball, dir );

		foreach ( var c in _cells )
		{
			if ( c.PieceKind != CellPiece.Kind.SplitPiece || c.OwnerSteamId != ball.OwnerSteamId ) continue;

			var cost = EjectCost( c.Mass );
			if ( cost <= 0f ) continue;

			c.Mass -= cost;
			SpawnBlob( c.Pos, dir, c.Radius, c.OwnerSteamId, c.ColorIndex, cost * GameConfig.EjectBlobEfficiency );
		}
	}

	/// <summary> 该质量吐一颗的成本(约 1% 随体量缩放,下限 1);返回 0 = 会击穿质量下限 1,不允许 </summary>
	static float EjectCost( float mass )
	{
		var cost = MathF.Max( GameConfig.EjectMinCost, mass * GameConfig.EjectMassPercent );
		return mass - cost >= GameConfig.EjectMinMass ? cost : 0f;
	}

	/// <summary> 主球吐出一颗孢子 </summary>
	void EjectFrom( Ball ball, Vector2 dir )
	{
		var cost = EjectCost( ball.Mass );
		if ( cost <= 0f ) return;

		ball.Mass -= cost;
		SpawnBlob( ball.WorldPosition, dir, ball.Radius, ball.OwnerSteamId, ball.ColorIndex, cost * GameConfig.EjectBlobEfficiency );
	}

	/// <summary> 生成一颗中性孢子(质量=消耗×返还率)并做总量上限收敛(挤掉最老) </summary>
	void SpawnBlob( Vector3 pos, Vector2 dir, float radius, long owner, int colorIndex, float mass )
	{
		var c = new CellPiece
		{
			Id = _nextCellId++,
			PieceKind = CellPiece.Kind.EjectedMass,
			OwnerSteamId = owner,
			ColorIndex = colorIndex,
			Mass = mass,
			Pos = pos + new Vector3( dir.x, dir.y, 0f ) * ( radius + 16f ),
			DrawPos = pos,
			Impulse = new Vector3( dir.x, dir.y, 0f ) * GameConfig.EjectImpulseSpeed,
			SinceSpawn = 0,
		};
		ClampToArena( c );
		_cells.Add( c );
		_cellsDirty = true;
		NeonRenderer.Puff( c.Pos, colorIndex );

		// 孢子总量上限:挤掉最老的一颗(TimeSince 越大越老)
		int blobs = 0;
		CellPiece oldest = null;
		foreach ( var x in _cells )
		{
			if ( x.PieceKind != CellPiece.Kind.EjectedMass ) continue;
			blobs++;
			if ( oldest is null || x.SinceSpawn > oldest.SinceSpawn ) oldest = x;
		}
		if ( blobs > GameConfig.MaxEjectedBlobs && oldest is not null )
		{
			_cells.Remove( oldest );
		}
	}

	/// <summary> host → 全体:分身/孢子状态快照(全量替换式,量小频率低,不做增量) </summary>
	void BroadcastCells()
	{
		_cellsDirty = false;

		var wires = new CellWire[_cells.Count];
		for ( int i = 0; i < _cells.Count; i++ )
		{
			var c = _cells[i];
			wires[i] = new CellWire
			{
				Id = c.Id,
				Kind = (byte)c.PieceKind,
				ColorIndex = (byte)c.ColorIndex,
				Owner = c.OwnerSteamId,
				X = c.Pos.x,
				Y = c.Pos.y,
				Mass = c.Mass,
			};
		}
		NetworkManager.CellsState( wires );
	}

	/// <summary> 客户端应用快照:按 Id 就地更新(保留 DrawPos 平滑),新增补入,消失移除 </summary>
	public void OnCellsStateRemote( CellWire[] wires )
	{
		if ( NetworkManager.IsAuthority ) return;   // host 回声挡掉

		for ( int i = _cells.Count - 1; i >= 0; i-- )
		{
			var c = _cells[i];
			bool found = false;
			for ( int w = 0; w < wires.Length; w++ )
			{
				if ( wires[w].Id != c.Id ) continue;
				c.Pos = new Vector3( wires[w].X, wires[w].Y, 0f );
				c.Mass = wires[w].Mass;
				c.ColorIndex = wires[w].ColorIndex;
				c.OwnerSteamId = wires[w].Owner;
				c.PieceKind = (CellPiece.Kind)wires[w].Kind;
				found = true;
				break;
			}
			if ( !found ) _cells.RemoveAt( i );
		}

		foreach ( var w in wires )
		{
			bool exists = false;
			foreach ( var c in _cells )
			{
				if ( c.Id == w.Id ) { exists = true; break; }
			}
			if ( exists ) continue;

			var pos = new Vector3( w.X, w.Y, 0f );
			_cells.Add( new CellPiece
			{
				Id = w.Id,
				PieceKind = (CellPiece.Kind)w.Kind,
				ColorIndex = w.ColorIndex,
				OwnerSteamId = w.Owner,
				Mass = w.Mass,
				Pos = pos,
				DrawPos = pos,
			} );
			NeonRenderer.Puff( pos, w.ColorIndex );   // 客户端补上生成喷发(host 已在本地放过)
		}
	}

	/// <summary> 死亡爆裂特效:host 本地生成 + 广播(客户端经 PoppedEffect RPC 生成同款)。
	/// 爆裂音只播"自己死亡/爆裂"的(视觉特效仍全场播,声音收窄到本人) </summary>
	void SpawnPopFx( Ball ball )
	{
		var pos = ball.WorldPosition;
		NeonRenderer.Pop( pos, ball.ColorIndex, ball.Radius );
		if ( GameSfx.IsMine( ball.OwnerSteamId ) )
			GameSfx.Pop( pos );
		if ( Networking.IsActive ) NetworkManager.PoppedEffect( pos, (byte)ball.ColorIndex, ball.Radius, ball.OwnerSteamId );   // 无会话不广播
	}

	/// <summary> 清掉某主人的全部分身(主球死亡/断线时调用;孢子保留) </summary>
	public void PopPiecesOf( long ownerSteamId )
	{
		for ( int i = _cells.Count - 1; i >= 0; i-- )
		{
			var c = _cells[i];
			if ( c.PieceKind != CellPiece.Kind.SplitPiece || c.OwnerSteamId != ownerSteamId ) continue;
			_cells.RemoveAt( i );
			_cellsDirty = true;
		}
	}

	/// <summary>
	/// agar 多身体规则(v0.6.2.0 用户定稿):主球被吃/撞刺时,把最大的分身"升级"成新主球
	/// ——球对象原地续命(质量/位置换成该分身的),玩家继续玩;全部身体死光才算真死。
	/// 主球与分身在同一 tick 内完成交接,Alive 从不翻转,客户端不会看到死亡/重置。
	/// 返回是否成功(没有分身 = 真死亡,走原 MarkDead 流程)。
	/// </summary>
	bool TryPromoteFromPieces( Ball ball )
	{
		if ( ball.IsBot ) return false;   // bot 不分裂,无分身可晋升

		CellPiece best = null;
		foreach ( var c in _cells )
		{
			if ( c.PieceKind != CellPiece.Kind.SplitPiece || c.OwnerSteamId != ball.OwnerSteamId ) continue;
			if ( best is null || c.Mass > best.Mass ) best = c;
		}
		if ( best is null ) return false;

		float mass = best.Mass;
		Vector3 pos = best.Pos;
		_cells.Remove( best );
		_cellsDirty = true;

		ball.Mass = mass;
		ball.Alive = true;
		ball.WorldPosition = pos;   // host 自己的球:owner 模拟从这里继续

		// 远程玩家的球归其主人模拟,位置要通知主人瞬移过去(否则下一帧被主人的模拟写回旧位置)
		if ( ball.IsRemoteOwned || ball.OwnerSteamId != Game.SteamId.Value )
		{
			foreach ( var conn in Connection.All )
			{
				if ( conn is null || !conn.IsActive ) continue;
				if ( conn.SteamId.Value != ball.OwnerSteamId ) continue;

				using ( Rpc.FilterInclude( conn ) )
				{
					NetworkManager.MainPromoted( pos.x, pos.y );
				}
				break;
			}
		}

		Log.Info( $"[game] promoted piece -> main: {ball.PlayerName} mass={mass:0}" );
		return true;
	}

	/// <summary>
	/// 视角焦点(v0.6.2.0 用户定稿"视角跟随最大的分身"):主球与自己所有分身中
	/// 质量最大的那个身体的位置/质量。
	/// </summary>
	public Vector3 FocusPositionOf( Ball ball, out float biggestMass )
	{
		biggestMass = ball.IsValid() ? ball.Mass : 0f;
		var pos = ball.IsValid() ? ball.WorldPosition : Vector3.Zero;
		if ( !ball.IsValid() ) return pos;

		foreach ( var c in _cells )
		{
			if ( c.PieceKind != CellPiece.Kind.SplitPiece || c.OwnerSteamId != ball.OwnerSteamId ) continue;
			if ( c.Mass > biggestMass )
			{
				biggestMass = c.Mass;
				pos = c.Pos;
			}
		}
		return pos;
	}

	int CountPiecesOf( long ownerSteamId )
	{
		int n = 0;
		foreach ( var c in _cells )
		{
			if ( c.PieceKind == CellPiece.Kind.SplitPiece && c.OwnerSteamId == ownerSteamId ) n++;
		}
		return n;
	}

	/// <summary> 该玩家是否还有分身护体(主球被吃保护 + 护盾虚环渲染用) </summary>
	public bool HasPieces( long ownerSteamId ) => CountPiecesOf( ownerSteamId ) > 0;

	/// <summary> 玩家总质量 = 主球 + 全部分身(排行榜/HUD 用;分身的质量也是真实战力) </summary>
	public float TotalMassFor( Ball ball )
	{
		if ( ball is null || !ball.IsValid() ) return 0f;

		float m = ball.Mass;
		var sid = ball.OwnerSteamId;
		foreach ( var c in _cells )
		{
			if ( c.PieceKind == CellPiece.Kind.SplitPiece && c.OwnerSteamId == sid ) m += c.Mass;
		}
		return m;
	}

	Vector2 SteerDirOf( Ball ball )
	{
		if ( !ball.IsValid() ) return Vector2.Zero;
		return _steerDir.TryGetValue( ball.Id, out var d ) ? d : Vector2.Zero;
	}

	/// <summary> 弹射方向:优先用输入方向,没给(静止开火)就沿用球的移动方向,再退随机 </summary>
	Vector2 FallbackDir( Ball ball, Vector2 dir )
	{
		if ( dir.Length > 0.001f ) return dir.Normal;
		var d = SteerDirOf( ball );
		if ( d.Length > 0.001f ) return d.Normal;

		return RandomDir();
	}

	/// <summary> 均匀随机单位方向 </summary>
	Vector2 RandomDir()
	{
		var a = Game.Random.Float( 0f, MathF.PI * 2f );
		return new Vector2( MathF.Cos( a ), MathF.Sin( a ) );
	}

	void ClampToArena( CellPiece c )
	{
		var half = MathF.Max( 0f, GameConfig.ArenaHalfSize - c.Radius );
		c.Pos.x = Math.Clamp( c.Pos.x, -half, half );
		c.Pos.y = Math.Clamp( c.Pos.y, -half, half );
		c.Pos.z = 0f;
	}

	// ---- 世界搭建 ----

	/// <summary>
	/// UI 对账(v0.6.4.5):由状态标志**推导**各面板应有的可见性,每帧强制执行。
	/// 修"客户端加入后 UI 全消失 / 房间和主菜单叠在一起"——此前 Show/Hide 全靠各路径自觉调用,
	/// 任何一条路径漏调(中途加入、热重载重建面板、残留倒计时卡住恢复分支)就永远停在坏界面。
	/// </summary>
	void ReconcileUi()
	{
		bool inSession = Networking.IsActive;

		// 残留加入倒计时会让客户端恢复分支(_joinCountdown<0 才走)永久失效——对局已不在就清掉
		if ( _joinCountdown >= 0f && ( !_matchRunning || _gameStarted || !inSession ) )
			_joinCountdown = -1f;

		if ( _gameStarted )
		{
			_menu?.Hide();
			_lobby?.Hide();
			_browser?.Hide();
			if ( !MatchState.MatchOver )
			{
				// 对局中 HUD 必须在:没建过就建(热重载重建世界后引用失效 → 重建自愈)
				if ( _hud.IsValid() )
				{
					if ( !_hud.GameObject.Enabled ) _hud.GameObject.Enabled = true;
				}
				else
				{
					CreateHud();
				}
			}
			return;
		}

		if ( _lobbyOpen )
		{
			_menu?.Hide();
			_lobby?.ShowHost();   // 幂等:已显示不重置状态
			return;
		}

		if ( inSession && !NetworkManager.IsAuthority )
		{
			// 客户端在会话里但没进对局:必须停在房间页(等待 / JOIN GAME 按钮 / 3-2-1)
			_menu?.Hide();
			_browser?.Hide();
			if ( !_lobbyWaiting )
			{
				EnterClientWaiting();
				NetworkManager.RequestMatchState();
			}
			_lobby?.ShowClient();
			return;
		}

		if ( !inSession && NetworkManager.IsReconnecting )
		{
			// 掉线自动重连中:房间页留在原地显示 "CONNECTION LOST — RECONNECTING",
			// 重试耗尽 OnReconnectFailed 才回主菜单(v0.6.4.6)
			_lobby?.ShowClient();
			return;
		}

		_lobby?.Hide();

		// 终态保证(v0.6.8.2):无会话且不在任何流程 → **主菜单必须可见**。
		// 之前只 Hide 房间不亮菜单——任何路径漏掉 Show(热重载重建、异常中断)就是全空屏
		if ( _browser.IsValid() && _browser.IsOpen ) return;   // 房间列表页开着:主菜单让位
		if ( _menu.IsValid() && !_menu.GameObject.Enabled ) _menu.Show();
	}

	void EnsureWorld()
	{
		if ( _worldReady ) return;
		_worldReady = true;

		// 复用旧的世界根(热重载时避免堆叠多套世界),并清掉其动态子物体。
		// 注意:Play 停止后 static 引用可能指向已卸载的旧场景对象(IsValid 仍为真),
		// 必须校验场景归属,否则向旧场景根挂子物体直接断言失败
		if ( _sharedRoot.IsValid() && _sharedRoot.Scene == Scene )
		{
			_root = _sharedRoot;
			foreach ( var child in _root.Children.ToArray() )
			{
				if ( child.IsValid() ) child.Destroy();
			}
			_balls.Clear();
			_lastPos.Clear();
			_cells.Clear();
			_steerDir.Clear();
			_sinceEject.Clear();
			_nextCellId = 1;
			LocalBall = null;
		}
		else
		{
			_sharedRoot = null;
			// 命名避开 "CircleroyaleGame":客户端上快照世界里 host 的世界根也叫这个名
			_root = new GameObject( true, "CircleroyaleGameLocal" );
			_sharedRoot = _root;
		}

		if ( NetworkManager.IsAuthority )
		{
			// 屏蔽场景里模板留下的所有对象(示例相机、方块等),只跑我们生成的世界。
			// Scene 本身就是根 GameObject,顶层对象都在它的 Children 里
			foreach ( var go in Scene.Children )
			{
				if ( go.IsValid() && go != _root ) go.Enabled = false;
			}

			CreateBackdrop();
			CreateNeon();
		}
		else
		{
			// 客户端:**直接启用快照世界根**(host 的世界,球都在里面),只剥掉它自带的
			// 相机/HUD(用本机的,避免双相机双 HUD)。⚠️ 不能走"把球 reparent 到本机根"
			// 的路线——引擎不允许客户端改 host 拥有对象的层级,Parent= 静默失效,球永远
			// Active=false:头像不渲染、OnUpdate 不跑(死亡头像隐藏失效→"尸体圈")
			// ——2026-09-05 探针实测(actFalse=32,自愈扫描每秒跑也无济于事)。
			AdoptSnapshotWorld();
		}

		CreateCamera();
		CreateMenu();
		CreateLobbyPanel();
		CreateRoomBrowser();

		// 食物管理与联机管理:**每台机器一份本地实例,自身不联网**。
		// 食物状态经静态 RPC 同步(FoodFull 定向全量 / FoodEaten / FoodRespawned 广播增量);
		// 此前挂在网络对象(GameNet)上的 NetList 无法复制到客户端,食物/重生全废——实测。
		// M4 主菜单:壳到此为止——建大厅还是连人由菜单点击决定(旧流程开机自动建大厅,
		// 双开第二实例必须赶在 CreateLobby 完成前连上才有"客户端"身份,天然竞态)。
		_food = new FoodManager();
		_net = _root.AddComponent<NetworkManager>();
		GameMusic.PlayMenu();   // 菜单曲(开局在 StartGame 切战斗曲)

		// 主菜单背景演示赛(v0.6.3.0):离线 authority 跑一场纯 bot 对战当背景
		if ( NetworkManager.IsAuthority && !Networking.IsActive )
		{
			StartMenuDemo();
		}
		else if ( !NetworkManager.IsAuthority && Networking.IsActive )
		{
			// 客户端在会话里(重连/热重载/自动重连后重建世界):不亮主菜单——
			// 直接回房间等待或对局(向 host 要当前状态,见 RequestMatchState)
			_menu?.Hide();
			EnterClientWaiting();
			_lobby?.ShowClient();
			NetworkManager.RequestMatchState();
		}
	}

	/// <summary> 主菜单动作(0=建服 1=快速加入 2=人机对战 3=退出)。
	/// HOST / VS BOTS 都先进**房间**(M5 二级界面):调参、看玩家、点 START 才开局 </summary>
	public void MenuAction( int index )
	{
		if ( _gameStarted || _lobbyOpen ) return;

		switch ( index )
		{
			case 0:   // HOST:本机当房主(建公开大厅,别人可加入)→ 进房间
				StopMenuDemo();
				OpenHostLobby();
				Log.Info( "[menu] host game (room)" );
				_net.RunHostFlow();
				break;

			case 1:   // JOIN:房间列表页(v0.6.7.0)——列出 Steam 公开大厅点选加入;JOIN LOCAL 是同机调试兜底
				StopMenuDemo();
				_menu?.Hide();
				_browser?.Show();
				break;

			case 2:   // VS BOTS:人机对战(不建大厅,离线房间,一样调参后 START)
				StopMenuDemo();
				OpenHostLobby();
				Log.Info( "[menu] vs bots (offline room)" );
				_net.RunHostFlow( createLobby: false );
				break;

			case 3:   // QUIT:编辑器里退不出应用,只提示
				if ( Game.IsEditor )
					_menu?.SetStatus( "QUIT IS STANDALONE-ONLY (THIS IS THE EDITOR)" );
				else
					Game.Close();
				break;
		}
	}

	/// <summary>
	/// 主菜单背景演示赛(v0.6.3.0 用户需求):authority 离线跑一场纯 bot 对战当菜单背景,
	/// 镜头观战最大 bot;规则随菜单设置,时间到就地重开。进房间/加入时 StopMenuDemo 收场。
	/// </summary>
	void StartMenuDemo()
	{
		if ( _menuDemo ) return;
		_menuDemo = true;

		GameConfig.ArenaHalfSize = Math.Clamp( GameConfig.ConvarArenaSize, 1024f, 12288f );
		MatchState.ActivateForMatch();   // 按当前菜单设置跑(无连接,RPC 空发无害)
		_food.Init();
		GenerateSpikes();

		var target = Math.Clamp( MatchState.PlayerTarget, 2, 64 );
		for ( int i = 0; i < target; i++ )
		{
			SpawnBall( RandomSpawnPos(), isBot: true, owner: null );
		}
		Log.Info( $"[menu] demo bots started ({target})" );
	}

	/// <summary> 收演示赛:清 bot/分身,重置初始化标记(真实开局的 OnLobbyOpened 会重新投食) </summary>
	void StopMenuDemo()
	{
		if ( !_menuDemo ) return;
		_menuDemo = false;

		foreach ( var b in _balls.ToArray() )
		{
			if ( b.IsValid() ) b.GameObject.Destroy();
		}
		_balls.Clear();
		_cells.Clear();
		_lastPos.Clear();
		_steerDir.Clear();
		_sinceEject.Clear();
		_networkReady = false;
		Log.Info( "[menu] demo stopped" );
	}

	/// <summary> host 开房:藏菜单、亮房间界面(蛰伏球由 OnLobbyOpened 在会话就绪后生成) </summary>
	void OpenHostLobby()
	{
		_lobbyOpen = true;
		_menu?.Hide();
		_lobby?.ShowHost();
	}

	/// <summary> 菜单状态行(NetworkManager 的异步流程也用它报进度) </summary>
	public void MenuStatus( string text ) => _menu?.SetStatus( text );

	/// <summary> 开局公共尾巴:藏菜单、上 HUD(回菜单后再开局复用已有 HUD,不重复建)、切战斗曲 </summary>
	void StartGame()
	{
		_joinRequested = false;
		_menu?.Hide();

		if ( _hud.IsValid() )
		{
			_hud.GameObject.Enabled = true;
			if ( _endBoard.IsValid() ) _endBoard.Hide();
		}
		else
		{
			CreateHud();
		}

		GameMusic.PlayBattle();
	}

	/// <summary>
	/// 客户端:启用快照世界根(幂等;EnsureWorld 调一次 + ScanBalls 每秒自愈——
	/// 首次 Tick 可能早于快照应用完成,扑空后靠自愈兜住)。
	/// 含球对象的顶层 GO 视为快照世界:启用之,并剥掉其相机/HUD 子物体;其余顶层对象
	/// (模板残留)照旧禁用。
	/// </summary>
	void AdoptSnapshotWorld()
	{
		foreach ( var go in Scene.Children.ToArray() )
		{
			if ( !go.IsValid() || go == _root ) continue;

			var hasBalls = false;
			foreach ( var b in go.GetComponentsInChildren<Ball>( true ) )
			{
				hasBalls = true;
				break;
			}

			if ( !hasBalls )
			{
				go.Enabled = false;
				continue;
			}

			if ( !go.Enabled )
			{
				go.Enabled = true;
				Log.Info( "[game] snapshot world root enabled" );
			}

			foreach ( var child in go.Children.ToArray() )
			{
				if ( !child.IsValid() ) continue;
				// 剥掉 host 快照里自带的相机与全部 UI 面板(MainMenu/LobbyPanel/EndBoard 会随快照
				// 复制过来,客户端留着就是双份界面/host 视角的房间页)——只用本机自己建的那套
				if ( child.Name == "Camera" || child.Name == "Hud" || child.Name == "MainMenu"
					|| child.Name == "LobbyPanel" || child.Name == "EndBoard" )
				{
					var name = child.Name;
					child.Destroy();
					Log.Info( $"[game] stripped snapshot '{name}'" );
				}
			}
		}
	}

	/// <summary>
	/// 房间就绪(host 建完大厅 / 离线房间时调用,M5):生成**蛰伏球**——host 球 + 满编预备 bot,
	/// Alive=false 不可见不模拟。引擎约束:"进行中 NetworkSpawn 不复制到已连接客户端"(实测),
	/// 所有球必须在各端初始快照前就存在,开局(StartMatchFromLobby)只激活、不再新建。
	/// 食物/尖刺是纯 RPC 数据(不经快照),开局时才生成。客户端不调用。
	/// </summary>
	public void OnLobbyOpened()
	{
		if ( _networkReady ) return;
		_networkReady = true;

		Log.Info( $"[game] CircleRoyale {GameConfig.Version} lobby opened auth={NetworkManager.IsAuthority}" );
		if ( !NetworkManager.IsAuthority ) return;

		// 房间设置:convar 覆写默认值(arena 经 Replicated 同步给客户端)
		GameConfig.ArenaHalfSize = Math.Clamp( GameConfig.ConvarArenaSize, 1024f, 12288f );

		SpawnBall( Vector3.Zero, isBot: false, owner: null, dormant: true );
		for ( int i = 1; i < GameConfig.LobbyReserveBalls; i++ )
		{
			SpawnBall( RandomSpawnPos(), isBot: true, owner: null, dormant: true );
		}
	}

	/// <summary>
	/// 房间内 START(host):规则生效、投食/尖刺、激活蛰伏球(远程真人/host/bot 补齐到目标数)、
	/// 给等在房间里的客户端定向补发食物尖刺、广播开局。队伍分配也在这里做(此刻模式才确定)。
	/// </summary>
	public void StartMatchFromLobby()
	{
		if ( !_lobbyOpen || _gameStarted ) return;
		_lobbyOpen = false;
		_gameStarted = true;

		MatchState.ActivateForMatch();
		_teamSeq = 0;

		_food.Init();
		GenerateSpikes();

		// 每局球组复位(球对象永不销毁——v0.6.4.2 连续多局):真人球(远程+host)全部复位,
		// bot 按剩余名额激活、多余的蛰伏;上一局的质量/生死态一并清零
		int remotes = 0;
		foreach ( var b in _balls )
		{
			if ( !b.IsValid() ) continue;
			if ( !b.IsRemoteOwned && b.IsBot ) continue;   // bot 走下面的名额补齐

			RespawnBall( b, NextTeamIndex() );
			if ( b.IsRemoteOwned ) remotes++;
		}

		int botsWanted = Math.Clamp( MatchState.PlayerTarget - 1 - remotes, 0, GameConfig.LobbyReserveBalls - 1 );
		int bots = 0;
		foreach ( var b in _balls )
		{
			if ( !b.IsValid() || !b.IsBot ) continue;

			if ( bots < botsWanted )
			{
				RespawnBall( b, NextTeamIndex() );
				bots++;
			}
			else
			{
				b.Alive = false;   // 多余 bot 蛰伏(不可见不模拟)
			}
		}

		// 等在房间里的客户端没收到过食物/尖刺(OnActive 时还没生成):逐一定向补发
		var foods = _food.Foods;
		var spikes = _spikes;
		foreach ( var conn in Connection.All )
		{
			if ( conn is null || !conn.IsActive ) continue;
			if ( Connection.Local is not null && conn.Id == Connection.Local.Id ) continue;

			using ( Rpc.FilterInclude( conn ) )
			{
				if ( foods is not null ) NetworkManager.FoodFull( foods );
				if ( spikes is not null ) NetworkManager.SpikesFull( spikes );
			}
		}

		if ( Networking.IsActive ) NetworkManager.MatchStarted();   // 无会话不广播
		OnMatchStartedLocal();

		Log.Info( $"[match] started — mode={MatchState.CurrentMode} target={MatchState.PlayerTarget} remotes={remotes} bots={bots}" );
	}

	/// <summary> 团队赛按块分队(v0.6.5.0 修 bug:原 %TeamSize 轮转=只有 TeamSize 个队、每队十几人;
	/// 用户语义是"每队 N 人"→ 48 人 3 人队应分出 16 队)。普通赛恒 -1 </summary>
	int NextTeamIndex() => MatchState.IsTeam ? _teamSeq++ / MatchState.TeamSize : -1;

	/// <summary> 两颗球是否同队(TeamIndex<0 = 普通赛/未分队,不算同队) </summary>
	static bool SameTeam( Ball a, Ball b ) =>
		a.TeamIndex >= 0 && a.TeamIndex == b.TeamIndex;

	/// <summary> 开局落地(本机):出房间、上 HUD、切战斗曲(host 直调;客户端经 RPC)。
	/// 客户端必须在这里置 _gameStarted——host 的置位在 MenuAction 里,客户端没有那条路径,
	/// 不置位的话 Tick 的"连上了就进房间"判定会在 MatchStarted 之后把房间页盖回游戏上(实测)。</summary>
	void OnMatchStartedLocal()
	{
		_lobbyWaiting = false;
		_lobbyHeartbeatArmed = false;   // 出房间:房间心跳让位给对局心跳(MatchTick)
		_lobby?.Hide();
		StartGame();
		_gameStarted = true;
		_joinRequested = false;
	}

	/// <summary> 客户端:房主点了 START(host 回声用 IsAuthority 挡掉——host 已直调) </summary>
	public void OnMatchStartedRemote()
	{
		if ( NetworkManager.IsAuthority ) return;
		Log.Info( "[match] host started the game" );

		// 用户定稿(v0.6.4.1):在房间里的客户端**不自动进局**——房间页亮起 JOIN GAME 按钮,
		// 由玩家点击后走 3 秒加入倒计时再进入;不在房间(异常态)则直接进
		if ( _lobbyWaiting )
		{
			_matchRunning = true;
			_lobby?.SetMatchRunning();
			return;
		}
		OnMatchStartedLocal();
	}

	/// <summary> host 取消对局(P 键快速退局的广播落地,仅客户端):回房间等待页 </summary>
	public void OnMatchCancelledRemote()
	{
		if ( NetworkManager.IsAuthority ) return;   // host 自己已直接处理
		Log.Info( "[match] host cancelled the match — back to lobby" );
		ResetToLobby();
	}

	/// <summary> 客户端点击房间页的 JOIN GAME:对局进行中 → 3 秒加入倒计时 → 进入 </summary>
	public void ClientJoinMatch()
	{
		if ( !_matchRunning || _gameStarted || _joinCountdown >= 0f ) return;
		if ( !_lobbyWaiting ) return;

		_joinCountdown = GameConfig.JoinCountdownSeconds;
		_lobby?.ShowJoinCountdown( GameConfig.JoinCountdownSeconds );
		Log.Info( "[match] client joining via button" );
	}

	/// <summary> 自动重连 watchdog 发起重连(NetworkManager 调用):重新走标准加入流 </summary>
	public void NotifyReconnectStarted()
	{
		_joinRequested = true;
		_sinceJoinRequest = 0;
	}

	// ---- 加入流辅助(房间列表页 v0.6.7.0)----

	/// <summary> 客户端发起加入(点房间/快速加入/本地直连共用):置请求标,Tick 捕获会话激活收尾 </summary>
	public void OnJoinAttemptStarted()
	{
		_joinRequested = true;
		_sinceJoinRequest = 0;
	}

	/// <summary> 加入失败提示:浏览器页开着显示在那,主菜单状态行是兜底 </summary>
	public void OnJoinAttemptFailed( string message ) => _browser?.SetStatus( message );

	/// <summary> 同机调试直连(房间列表页 JOIN LOCAL):local 地址 = 本机 host </summary>
	public void JoinLocalRoom()
	{
		OnJoinAttemptStarted();
		_net?.StartJoin( GameConfig.DefaultJoinAddress );
		_browser?.SetStatus( "CONNECTING TO LOCAL HOST ..." );
	}

	/// <summary> 房间列表页返回主菜单(BACK) </summary>
	public void CloseBrowser()
	{
		_browser?.Hide();
		_menu?.Show();
	}

	/// <summary> 客户端掉线,自动重连开始(NetworkManager 调用):界面留在原地 + 房间页状态行提示 </summary>
	public void OnDisconnectRecoveryStarted()
	{
		if ( _lobbyWaiting ) _lobby?.SetConnectionLost();
		Log.Warning( "[net] connection lost — staying put, auto reconnecting" );
	}

	/// <summary> 自动重连耗尽(NetworkManager 调用):回主菜单(v0.6.4.6 用户定稿) </summary>
	public void OnReconnectFailed()
	{
		ResetToMenu();
		_menu?.SetStatus( "CONNECTION LOST — COULD NOT REACH HOST" );
	}

	// ---- 房间信息同步(host → 等待中的客户端)----

	/// <summary> 房间玩家名单:host 自己 + 所有活跃连接(顺序即列表顺序,0 号是房主) </summary>
	public List<string> LobbyPlayerNames()
	{
		var list = new List<string>();
		try
		{
			if ( Connection.Local is not null ) list.Add( Connection.Local.DisplayName );

			foreach ( var c in Connection.All )
			{
				if ( c is null || !c.IsActive ) continue;
				if ( Connection.Local is not null && c.Id == Connection.Local.Id ) continue;
				list.Add( c.DisplayName );
			}
		}
		catch
		{
			// 会话未就绪时给空名单,界面显示占位
		}
		return list;
	}

	/// <summary> 名单/参数变了(网络线程只置脏标,主线程 Tick 统一广播) </summary>
	public void MarkLobbyDirty() => _lobbyListDirty = true;

	void BroadcastLobbyState()
	{
		NetworkManager.LobbyState( (byte)MatchState.PendingMode, (int)MatchState.PendingDuration,
			MatchState.PendingPlayerTarget, LobbyPlayerNames().ToArray() );
	}

	/// <summary> 客户端应用房主推送的房间信息(host 回声用 IsAuthority 挡掉) </summary>
	public void OnLobbyStateRemote( byte mode, int duration, int players, string[] names )
	{
		if ( NetworkManager.IsAuthority ) return;

		// 房间心跳:host 每 3s 推一次(名单没变也推),收到即归零
		if ( _lobbyWaiting )
		{
			_lobbyHeartbeatArmed = true;
			_sinceLobbyHeartbeat = 0;
		}

		_lobbyMode = mode;
		_lobbyDuration = duration;
		_lobbyPlayers = players;
		_lobbyNames = names ?? Array.Empty<string>();
		_lobby?.UpdateFromHost( mode, duration, players, names );
	}

	/// <summary> 客户端:主球被吃,最大分身已晋升成新主球——把球瞬移到晋升位置继续模拟
	/// (质量经 [Sync] 到达;host 回声用 IsAuthority 挡掉)。 </summary>
	public void OnMainPromotedRemote( float x, float y )
	{
		if ( NetworkManager.IsAuthority ) return;

		var ball = LocalBall;
		if ( !ball.IsValid() ) return;

		ball.TeleportTo( new Vector3( x, y, 0f ) );
		Log.Info( $"[game] main promoted -> continue at ({x:0},{y:0})" );
	}

	/// <summary> 静态 RPC 落地:为重连/复活的连接原地复活其球(按 OwnerSteamId 匹配) </summary>
	public void RespawnConnection( Connection conn )
	{
		if ( conn is null || !conn.IsActive ) return;

		var steamId = conn.SteamId.Value;
		foreach ( var b in _balls )
		{
			if ( b.IsValid() && !b.IsBot && !b.Alive && b.OwnerSteamId == steamId )
			{
				RespawnBall( b );
				return;
			}
		}
		Log.Info( $"[net] respawn request: no dead ball for '{conn.DisplayName}'" );
	}

	// ---- 食物远端事件应用(仅客户端;host 的广播回声用 IsAuthority 挡掉)----

	public void OnFoodEatenRemote( int index, long eaterSteamId )
	{
		if ( NetworkManager.IsAuthority ) return;

		// 应用前先取位置/颜色放火花(ApplyEaten 会置 Alive=false,但数据仍在——取更保险)
		var fs = _food?.Foods;
		if ( fs is not null && (uint)index < (uint)fs.Length )
		{
			var pos = new Vector3( fs[index].Pos.x, fs[index].Pos.y, 0f );
			NeonRenderer.Spark( pos, fs[index].ColorIndex );
			if ( GameSfx.IsMine( eaterSteamId ) )
				GameSfx.EatFood( pos );
		}

		_food?.ApplyEaten( index );
	}

	public void OnFoodRespawnedRemote( int index, FoodData food )
	{
		if ( NetworkManager.IsAuthority ) return;
		_food?.ApplyRespawned( index, food );
	}

	public void OnFoodFullRemote( FoodData[] foods )
	{
		if ( NetworkManager.IsAuthority ) return;
		_food?.ApplyFull( foods );
		Log.Info( $"[game] food full sync received: {foods.Length}" );
	}

	/// <summary> 客户端应用绿刺全量(静态数据,收到即用) </summary>
	public void OnSpikesFullRemote( Vector2[] spikes )
	{
		if ( NetworkManager.IsAuthority ) return;
		_spikes = spikes;
	}

	/// <summary> 客户端应用死亡爆裂特效(host 已在本地生成过,回声用 IsAuthority 挡掉)。
	/// 视觉全场播;爆裂音只播自己死亡/爆裂的(ownerSteamId 匹配本机) </summary>
	public void OnPoppedEffectRemote( Vector3 pos, byte colorIndex, float radius, long ownerSteamId )
	{
		if ( NetworkManager.IsAuthority ) return;
		NeonRenderer.Pop( pos, colorIndex, radius );
		if ( GameSfx.IsMine( ownerSteamId ) )
			GameSfx.Pop( pos );
	}

	/// <summary> 客户端吞球事件(host 回声用 IsAuthority 挡掉——host 已在 NotifyBallEaten 直调过) </summary>
	public void OnBallEatenRemote( long eaterSteamId, long eatenSteamId, float massGained )
	{
		if ( NetworkManager.IsAuthority ) return;
		OnBallEaten( eaterSteamId, eatenSteamId, massGained );
	}

	void CreateBackdrop()
	{
		var go = new GameObject( true, "GridBackdrop" );
		go.Parent = _root;
		go.AddComponent<GridBackdrop>();
	}

	/// <summary> 全场景一个的霓虹批量画笔:每帧把所有球写进两组顶点 </summary>
	void CreateNeon()
	{
		var go = new GameObject( true, "Neon" );
		go.Parent = _root;
		go.AddComponent<NeonRenderer>();
		go.AddComponent<PieceAvatarLayer>();   // 分身头像(数据实体没有 GO,本地图层跟随渲染)
	}

	void CreateCamera()
	{
		// 防御:热重载/重入可能残留旧相机——两个 IsMainCamera 会抢渲染,而旧的那个
		// 姿态已死(NeonCamera 缓存的 _target 失效早退),渲染选中它就是画面僵死不跟随
		foreach ( var c in Scene.GetAllComponents<CameraComponent>().ToArray() )
		{
			if ( c.IsValid() && c.GameObject.IsValid() )
			{
				Log.Info( "[game] destroying stale camera" );
				c.GameObject.Destroy();
			}
		}

		var go = new GameObject( true, "Camera" );
		go.Parent = _root;

		var cam = go.AddComponent<CameraComponent>();
		cam.IsMainCamera = true;
		cam.Priority = -100;
		cam.Orthographic = true;
		cam.OrthographicHeight = GameConfig.CameraBaseView;
		cam.ClearFlags = ClearFlags.All;
		cam.BackgroundColor = new Color( 0.012f, 0.020f, 0.050f );   // 近黑深蓝,衬托霓虹
		cam.EnablePostProcessing = true;

		var bloom = go.AddComponent<Bloom>();
		bloom.Mode = SceneCamera.BloomAccessor.BloomMode.Additive;
		bloom.Strength = 2.6f;
		bloom.Threshold = 0.30f;

		var tonemapping = go.AddComponent<Tonemapping>();
		tonemapping.Mode = Tonemapping.TonemappingMode.ACES;

		_camera = go.AddComponent<NeonCamera>();
		_camera.Cam = cam;
	}

	void CreateHud()
	{
		var go = new GameObject( true, "Hud" );
		go.Parent = _root;
		go.AddComponent<ScreenPanel>();
		_hud = go.AddComponent<GameHud>();

		// 结算面板(M5):独立 ScreenPanel 叠在 HUD 之上,平时隐藏
		var ego = new GameObject( true, "EndBoard" );
		ego.Parent = _root;
		ego.AddComponent<ScreenPanel>();
		_endBoard = ego.AddComponent<EndBoard>();
	}

	/// <summary> 主菜单(开局后由 StartGame 禁用整个 GameObject) </summary>
	void CreateMenu()
	{
		var go = new GameObject( true, "MainMenu" );
		go.Parent = _root;
		go.AddComponent<ScreenPanel>();
		_menu = go.AddComponent<MainMenu>();
	}

	/// <summary> 游戏房间(M5 二级界面):HOST/VS BOTS 开房后显示,START/进局后隐藏 </summary>
	void CreateLobbyPanel()
	{
		var go = new GameObject( true, "LobbyPanel" );
		go.Parent = _root;
		go.AddComponent<ScreenPanel>();
		_lobby = go.AddComponent<LobbyPanel>();
	}

	/// <summary> 房间列表页(v0.6.7.0):主菜单 [2] JOIN GAME 打开,列出 Steam 公开大厅 </summary>
	void CreateRoomBrowser()
	{
		var go = new GameObject( true, "RoomBrowser" );
		go.Parent = _root;
		go.AddComponent<ScreenPanel>();
		_browser = go.AddComponent<RoomBrowser>();
	}

	/// <summary>
	/// 生成一颗球。M0 直接代码建实体;M2 起真人球优先用 Ball 预制体
	/// (用户在编辑器创建,代码按路径 Clone,缺失时回退代码构建)。
	/// </summary>
	/// <summary>
	/// 生成一颗球(仅权威端调用):优先克隆用户创建的 ball.prefab(根节点需挂 Ball),
	/// 缺失回退纯代码构建。owner=null → host 拥有(房主自己/bot);
	/// 传连接 → 该客户端拥有,移动由其本地模拟(owner 模拟架构)。
	/// dormant=true:房间蛰伏球(Alive=false 不可见,开局由 StartMatchFromLobby 激活)——
	/// 引擎"进行中 NetworkSpawn 不复制到已连接客户端",球必须在各端快照前存在。
	/// </summary>
	public Ball SpawnBall( Vector3 position, bool isBot, Connection owner = null, bool dormant = false )
	{
		// 给远程连接生成的球必须网络化才有意义:会话未激活时 NetworkSpawn 会被跳过,
		// 球沦为 host 本地幽灵球(不广播 → 客户端收不到;proxy=false → 干扰本机球判定)——实测大坑。
		// OnActive 竞态(大厅重建中触发回调)就落在这里,直接拒绝生成。
		if ( owner is not null && !Networking.IsActive )
		{
			Log.Warning( "[game] SpawnBall: session not active, refuse ghost ball for remote owner" );
			return null;
		}

		GameObject go = null;
		Ball ball = null;

		var prefab = PrefabFile.Load( GameConfig.BallPrefabPath );
		if ( prefab is not null )
		{
			go = GameObject.Clone( GameConfig.BallPrefabPath, new Transform( position ), _root );
			ball = go.Components.Get<Ball>();
			if ( ball is null )
			{
				go.Destroy();   // 预制体根节点没挂 Ball,视为无效配置,回退代码构建
				go = null;
			}
		}

		if ( go is null )
		{
			go = new GameObject( true, isBot ? "Bot" : "Player" );
			go.Parent = _root;
			go.WorldPosition = position;
			ball = go.AddComponent<Ball>();
		}

		ball.PlayerName = isBot ? NextBotName() : ( owner?.DisplayName ?? LocalPlayerName() );
		ball.OwnerSteamId = owner?.SteamId.Value ?? ( isBot ? NextBotSteamId() : Game.SteamId.Value );
		ball.IsRemoteOwned = owner is not null;   // host 侧:远程连接的球不参与本机球判定
		// 队伍分配:蛰伏球(房间阶段)不占队号,开局激活时统一按块分队;
		// 对局中途加入的球(dormant=false 且比赛已开始)补分到下一个块(团队赛,v0.6.5.0)
		ball.Init( isBot, !dormant && _gameStarted && MatchState.IsTeam ? NextTeamIndex() : -1 );
		if ( dormant ) ball.Alive = false;   // 必须在 NetworkSpawn 之前置 false,随初始快照下发

		if ( isBot ) go.AddComponent<BotBrain>();

		// host 侧直接注册(Ball.OnStart 在网络对象上不触发,见 ScanBalls 兜底注释)
		_balls.Add( ball );
		if ( !isBot && owner is null )
			SetLocalBall( ball );

		go.NetworkMode = NetworkMode.Object;
		if ( Networking.IsActive )
			go.NetworkSpawn( owner );

		return ball;
	}

	/// <summary> 取一个未被占用的 bot 名(从游标起扫,跳过场上任何球——含蛰伏/尸体——正用着的;
	/// 48 满编 < 64 名字池,正常永远取得到;池子真被占满才循环复用兜底) </summary>
	string NextBotName()
	{
		for ( int i = 0; i < GameConfig.BotNames.Length; i++ )
		{
			var name = GameConfig.BotNames[( _botNameIndex + i ) % GameConfig.BotNames.Length];
			if ( FindBallByPlayerName( name ) is null )
			{
				_botNameIndex += i + 1;
				return name;
			}
		}
		return GameConfig.BotNames[_botNameIndex++ % GameConfig.BotNames.Length];
	}

	/// <summary> 按名字找球(bot 取名查重用) </summary>
	public Ball FindBallByPlayerName( string name )
	{
		foreach ( var b in _balls )
		{
			if ( b.IsValid() && b.PlayerName == name ) return b;
		}
		return null;
	}

	int _botSeq;   // bot 独立假 SteamId 序列(配合 BotSteamIdBase)

	/// <summary> 分配一个未被占用的 bot 假 SteamId(热重载会重置 _botSeq,扫描查重防碰撞) </summary>
	long NextBotSteamId()
	{
		var id = GameConfig.BotSteamIdBase + _botSeq++;
		while ( FindBallBySteamId( id ) is not null )
			id = GameConfig.BotSteamIdBase + _botSeq++;
		return id;
	}

	/// <summary> 按 SteamId 找球(分身头像/名牌归属解析用;bot 现在各有独立假 SteamId) </summary>
	public Ball FindBallBySteamId( long steamId )
	{
		foreach ( var b in _balls )
		{
			if ( b.IsValid() && b.OwnerSteamId == steamId ) return b;
		}
		return null;
	}

	/// <summary> 全场总质量(主球+分身)最高的活球——菜单演示赛观战镜头/死亡观战用 </summary>
	public Ball TopMassBall()
	{
		Ball best = null;
		float bestMass = -1f;
		foreach ( var b in _balls )
		{
			if ( !b.IsValid() || !b.Alive ) continue;

			var m = TotalMassFor( b );
			if ( m <= bestMass ) continue;

			bestMass = m;
			best = b;
		}
		return best;
	}

	/// <summary> 球销毁时由 Ball.OnDestroy 回调注销 </summary>
	public void RemoveBall( Ball ball )
	{
		_balls.Remove( ball );
		_lastPos.Remove( ball.Id );
		if ( LocalBall == ball ) LocalBall = null;
	}

	/// <summary>
	/// 结算结束(自动 10 秒 / 空格提前):多人回 **host 的房间**(会话保留,等下一局),
	/// 单机回主菜单。
	/// </summary>
	public void AfterSettlement()
	{
		if ( Networking.IsActive )
			ResetToLobby();
		else
			ResetToMenu();
	}

	/// <summary>
	/// 回到房间(多人,v0.6.4.2 用户定稿):**会话保留不清**——host 回房间控制端
	/// (可调参、再 START 开下一局),客户端回房间等待页(RequestMatchState 拉玩家列表)。
	/// 上一局的球留着当背景(下局 START 时统一销毁重建),模拟已冻结不会继续吃。
	/// </summary>
	public void ResetToLobby()
	{
		MatchState.Reset();
		_matchRunning = false;
		_joinCountdown = -1f;
		_gameStarted = false;

		_endBoard?.Hide();
		if ( _hud.IsValid() ) _hud.GameObject.Enabled = false;
		_menu?.Hide();

		if ( NetworkManager.IsAuthority )
		{
			_lobbyOpen = true;
			_lobby?.ShowHost();
			_net?.ResetSession();
			MarkLobbyDirty();   // 广播房间玩家列表
		}
		else
		{
			EnterClientWaiting();
			_browser?.Hide();
			_lobby?.ShowClient();
			NetworkManager.RequestMatchState();
		}

		GameMusic.PlayMenu();
		Log.Info( $"[match] back to lobby (auth={NetworkManager.IsAuthority})" );
	}

	/// <summary>
	/// 结算后回主菜单(M5):断网、清场(球销毁/分身清空/比赛态复位)、菜单重出;
	/// 世界壳(网格/相机)留着当菜单背景,下次开局沿用(StartGame 复用 HUD)。
	/// </summary>
	public void ResetToMenu()
	{
		try
		{
			if ( Networking.IsActive ) Networking.Disconnect();
		}
		catch
		{
			// 断不开也无妨:host 侧下局沿用旧会话照常能开
		}

		foreach ( var b in _balls.ToArray() )
		{
			try
			{
				if ( b.IsValid() ) b.GameObject.Destroy();
			}
			catch ( Exception e )
			{
				// 客户端销毁 host 拥有的对象可能被引擎拒绝——不能让它中断回菜单流程(实测过卡住不返回)
				Log.Warning( $"[menu] destroy ball failed: {e.Message}" );
			}
		}
		_balls.Clear();
		_cells.Clear();
		_lastPos.Clear();
		_steerDir.Clear();
		_sinceEject.Clear();
		_teamSeq = 0;
		LocalBall = null;

		_gameStarted = false;
		_lobbyOpen = false;
		_lobbyWaiting = false;
		_lobbyHeartbeatArmed = false;
		_lobbyListDirty = false;
		_joinRequested = false;
		_networkReady = false;
		_joinCountdown = -1f;
		_matchRunning = false;
		MatchState.Reset();

		_net?.ResetSession();
		_lobby?.Hide();
		_browser?.Hide();
		_endBoard?.Hide();
		if ( _hud.IsValid() ) _hud.GameObject.Enabled = false;
		_menu?.Show();
		GameMusic.PlayMenu();
		Log.Info( "[menu] back to menu" );

		// 菜单背景演示赛重新开起来
		if ( NetworkManager.IsAuthority && !Networking.IsActive )
			StartMenuDemo();
	}
}