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;
	SettingsPanel _settings;
	RankingsPanel _rankings;
	FoodManager _food;
	PowerUpManager _power;
	NetworkManager _net;
	MainMenu _menu;

	readonly List<Ball> _balls = new();
	readonly Dictionary<Guid, Vector3> _lastPos = new();   // host 位移校验用
	readonly Dictionary<Guid, TimeSince> _teleportGrace = new();   // host:合法瞬移(重生换位/晋升)后的校验宽限期
	TimeSince _sinceScan = 1f;         // 球注册扫描
	TimeSince _sinceFoodRequest = 1f;  // 客户端请求食物全量的节流(首帧即请求)
	TimeSince _sinceMatchTick = 1f;    // 倒计时 1Hz 校时广播
	TimeSince _sinceTerritoryWire = 1f;   // 领土状态变化广播节流(5Hz 上限 + 2s 保活)
	bool _pendingFrontRespawn;            // 本机下次复活的前线偏好(领土 M7.2,owner 换位时消费)
	int _botNameIndex;
	int _teamSeq;                      // 团队赛轮转分队计数(开局重置)

	// ---- 分身/孢子(M3,纯数据实体见 CellPiece)----
	readonly List<CellPiece> _cells = new();                    // host 权威模拟;客户端镜像(CellsState RPC)
	readonly Dictionary<Guid, Vector2> _steerDir = new();       // host:每球平滑转向(分身跟随用)
	readonly Dictionary<long, int> _teamScratch = new();        // 团队赛阵营映射复用缓冲(每帧 3 处先后构建,省堆分配)
	readonly Dictionary<int, CellWire> _wireById = new();       // CellsState 应用缓冲(按 Id O(1) 查找)
	readonly HashSet<int> _cellIdScratch = new();               // CellsState 新增判定缓冲
	readonly Dictionary<Guid, TimeSince> _sinceEject = new();   // host:吐孢子节流
	TimeSince _sinceCellSync = 1f;
	TimeSince _sinceCellHeal;        // 分身主人失联自愈的 ScanBalls 节流(独立计时,别挪用 _sinceScan 打乱球扫描节奏)
	bool _cellsDirty;                                           // 增删标记(列表清空后还要补发一次空快照)
	int _nextCellId = 1;
	Vector2[] _spikes;                                          // 绿刺位置(host 生成;客户端经 SpikesFull 同步)
	float[] _spikeFed;                                          // 每朵刺累计喂入质量(host 权威;客户端 SpikeFed RPC 镜像,v0.7.8.24)
	readonly List<SpikeSpine> _spines = new();                  // 刺爆弹幕:host=权威模拟(含命中),客户端=纯视觉弹道(v0.7.8.24)
	readonly HashSet<string> _strayUiLogged = new();            // 孤儿 UI 认领日志去重(ScanBalls 每秒跑,同名只报一次)

	/// <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 权威,客户端为事件镜像) </summary>
	public PowerUpManager PowerUps => _power;

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

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

	/// <summary> 刺的累计喂食量(0~SpikeGrowTarget,渲染长大用;host 权威,客户端经 SpikeFed RPC 镜像) </summary>
	public float SpikeFedAt( int index ) =>
		_spikeFed is not null && (uint)index < (uint)_spikeFed.Length ? _spikeFed[index] : 0f;

	/// <summary> 刺爆弹幕(渲染读;host 权威模拟含命中,客户端为纯视觉弹道) </summary>
	public IReadOnlyList<SpikeSpine> Spines => _spines;

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

	/// <summary> 跟随相机(屏幕震动触发入口) </summary>
	public NeonCamera Camera => _camera;

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

		// 权威模拟走固定步(见 TickSimulation 注释):挂 FinishFixedUpdate 而非 StartFixedUpdate,
		// 以排在组件 OnFixedUpdate 之后——球本步移动完,吃判定拿到本步新位置
		Listen( Stage.FinishFixedUpdate, 0, TickSimulation, "CircleroyaleGame.TickSimulation" );
	}

	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 )
			{
				GameLog.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" ) )
		{
			GameLog.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;
		}

		// 权威端模拟已移至固定步(TickSimulation,见其注释)——此处只剩渲染帧该做的事
		// (细胞渲染平滑、扫描、UI/网络维护、比赛进程),不再随帧率重复跑吃判定

		// 刺爆弹幕(客户端):纯视觉弹道推进(host 在 TickSpikes 权威模拟+命中)
		if ( !NetworkManager.IsAuthority ) TickSpinesVisual();

		// 细胞渲染位置平滑: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 )
			{
				GameLog.Info( $"[net] RequestFoodFull failed (session not ready): {e.Message}" );
			}
		}

		TickRespawn();
		TickTerritory();
	}

	/// <summary>
	/// host 权威模拟(**固定步**,Stage.FinishFixedUpdate):食物重生 / 道具拾取 / bot 决策 /
	/// 位移校验 / 分身模拟 / 吃判定 / 尖刺 / 分身与孢子快照广播。
	///
	/// 为什么必须是固定步(v0.7.8.105 修,审计 #11):这些循环里有大量 O(球 × 分身) 与
	/// O(球²) 的判定,原先挂在 Stage.UpdateBones(**每渲染帧**)上,于是模拟频率 = 主机帧率——
	/// 144Hz 主机的判定开销与吃判定触发频率是 50Hz 的 2.9 倍,且与真正跑在 50Hz 固定步里的
	/// 移动/BotBrain 采样率不一致(移动与吃判定看到的位置差最高一帧)。
	/// 引擎保证 StartFixedUpdate/FinishFixedUpdate 在 `Time.Scope(step, 1/FixedUpdateFrequency)`
	/// 内触发(FixedUpdate.cs:32 + Time.cs:37-49),所以这里读到的 `Time.Delta` 恒为 1/50 固定步长,
	/// 各 Tick 内既有的 `Time.Delta` 用法无需改动。
	/// 顺序与本方法在同一固定步内的相对次序即原 Tick 内的顺序;挂在 FinishFixedUpdate
	/// (而非 StartFixedUpdate)是为了排在组件 OnFixedUpdate 之后:球本步已移动,吃判定拿到本步新位置。
	/// </summary>
	void TickSimulation()
	{
		if ( !Game.IsPlaying ) return;
		if ( !NetworkManager.IsAuthority ) return;
		if ( !_gameStarted && !_menuDemo ) return;   // 菜单阶段零模拟(与 Tick 同款门禁)
		if ( MatchState.MatchOver ) return;          // 结算期全场冻结

		_food.Tick();
		_power?.Tick( _balls, _cells );
		TickBots();
		TickMovementValidation();
		TickCells();
		TickEating();
		TickSpikes();

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

	/// <summary> 领土模式每帧(M7):host 检查征服胜利 + 变化/保活广播 5Hz 快照;客户端只等 ApplyRemote。
	/// 终局快照先行——客户端结算榜按领土胜负显示,慢一拍就会按旧格数判错冠军 </summary>
	void TickTerritory()
	{
		if ( !MatchState.IsTerritory || MatchState.MatchOver ) return;

		if ( !NetworkManager.IsAuthority )
		{
			_sinceTerritoryWire = 1f;
			return;
		}

		TerritoryManager.TickPoints( Time.Delta );   // 占旗积分(每旗每 3s +1,M7.4 用户定稿)
		ClassSkillManager.Tick();                    // 坦克临时质量到期回收

		if ( TerritoryManager.ConquerWinner >= 0 )
		{
			NetworkManager.TerritoryState( TerritoryManager.Snapshot(), TerritoryManager.PointsWire() );
			EndMatch();
			return;
		}

		if ( TerritoryManager.NeedsSync || _sinceTerritoryWire > 2f )
		{
			_sinceTerritoryWire = 0;
			NetworkManager.TerritoryState( TerritoryManager.Snapshot(), TerritoryManager.PointsWire() );
		}
	}

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

	void TickEating()
	{
		// 球 × 食物:食物中心进入球体即被吃(空间哈希粗筛 + 精确复核,4140 颗时省 ~20 万次/帧)
		// 磁铁 buff(v0.7.3.0):吸食半径翻倍——隔着一个身位也能把食物吸进嘴里(只影响食物)
		var foods = _food.Foods;
		foreach ( var ball in _balls )
		{
			if ( !ball.IsValid() || !ball.Alive ) continue;

			var bp = ball.WorldPosition;
			var r = ball.HasBuff( PowerUpManager.Kind.Magnet ) ? ball.Radius * 2f : 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 ) );
				}
			}
		}

		// 质量衰减(v0.7.8.24 球球大作战手感):大球持续掉重,压制滚雪球、逼大哥不停行动。
		// 小球免衰减;分身同率(TickCells)。Mass 是 [Sync(FromHost)],衰减值自动下发。
		// 领土模式(M7 用户定稿②):己方球在己方大本营内免衰减(回家保养的拉扯点)
		foreach ( var b in _balls )
		{
			if ( !b.IsValid() || !b.Alive ) continue;
			if ( b.Mass > GameConfig.MassDecayMinMass
				&& !( GameConfig.TerritoryHqStopsDecay && TerritoryManager.IsOwnHq( b.TeamIndex, b.WorldPosition ) ) )
				b.Mass = MathF.Max( GameConfig.MassDecayMinMass, b.Mass - b.Mass * GameConfig.MassDecayPerSecond * Time.Delta );
		}

		// 敌营安全屋(M7.2):敌方球闯大本营持续掉重——驱赶而不处刑(下限出生质量)
		foreach ( var b in _balls )
		{
			if ( !b.IsValid() || !b.Alive ) continue;
			if ( TerritoryManager.InHostileHq( b.TeamIndex, b.WorldPosition ) )
				b.Mass = MathF.Max( GameConfig.StartMass, b.Mass - b.Mass * GameConfig.TerritoryHqHurtPerSecond * Time.Delta );
		}

		// 球 × 球:触碰即吃(质量比门槛;护盾期/死亡免战)。
		// 死亡不销毁对象——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;   // 护盾期双向免战
				if ( smaller.HasBuff( PowerUpManager.Kind.Shield ) ) 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 ) )
				{
					GameLog.Info( $"[game] body lost: {smaller.PlayerName} <- {bigger.PlayerName} (promoted)" );
					continue;
				}

				smaller.MarkDead();
				GameLog.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 ) );
				}
			}
		}

		// 队伍映射(团队赛):孢子可食延迟分档 + 下方球×分身免伤共用
		var teamOf = BuildTeamMap();

		// 孢子被吞:自己+队友 0.2s 档(v0.7.8.22/23 用户定稿,球球大作战式短时互喂——
		// 自己马上回收、喂队友大哥跟自食一样顺);敌人 0.5s 档。0.2s 只是出生净空:
		// 大球吐的孢子半径可超过 16px 出生偏移,零延迟会被吐出者出生同帧吸回,吐丝直接失效
		for ( int i = _cells.Count - 1; i >= 0; i-- )
		{
			var blob = _cells[i];
			if ( blob.PieceKind != CellPiece.Kind.EjectedMass ) continue;

			bool eaten = false;

			// 孢子喂道具("血量池",v0.7.4.0 用户定稿):孢子碰道具被吸收充入共享积分池,
			// 积分 ≥ 喂食者当前体重 5% 即触发效果(立即生效,不入背包)——虎口夺食。
			// ⚠️ 判定必须在 EjectedEdibleDelay **之前**(v0.7.5.0 修"喂不中"bug):
			// 孢子吐出滑行 ~355 距离,0.5s 可食期到时已飞过 236——经过道具的那一帧几乎
			// 总在延迟期内,等延迟过了孢子早飞远了。喂食无质量回流,没有"秒吃自己"漏洞。
			if ( _power?.Ready ?? false )
			{
				var slots = _power.Slots;
				for ( int s = 0; s < slots.Count; s++ )
				{
					var slot = slots[s];
					if ( !slot.Alive ) continue;

					var pp = new Vector3( slot.Pos.x, slot.Pos.y, 0f );
					if ( blob.Pos.Distance( pp ) >= GameConfig.PowerUpRadius + 8f ) continue;

					eaten = true;
					_power.Feed( FindBallBySteamId( blob.OwnerSteamId ), s, blob.Mass );
					break;
				}

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

			foreach ( var b in _balls )
			{
				if ( !b.IsValid() || !b.Alive ) continue;
				var ally = SameSide( blob.OwnerSteamId, b.OwnerSteamId, teamOf );
				if ( blob.SinceSpawn < ( ally ? GameConfig.EjectedAllyEdibleDelay : GameConfig.EjectedEdibleDelay ) ) 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;
					var ally = SameSide( blob.OwnerSteamId, c.OwnerSteamId, teamOf );
					if ( blob.SinceSpawn < ( ally ? GameConfig.EjectedAllyEdibleDelay : GameConfig.EjectedEdibleDelay ) ) 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):团队赛下分身不与队友的主球互吃——teamOf 已在上方建好

		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 规则):只损失主球这个身体——最大分身升级成新主球
				// 带盾主球免疫(v0.7.3.0):护盾连"被分身吞"也挡住
				if ( !ball.HasBuff( PowerUpManager.Kind.Shield )
					&& 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 ) )
					{
						GameLog.Info( $"[game] body lost: {ball.PlayerName} <- cell#{c.Id} (promoted)" );
					}
					else
					{
						ball.MarkDead();
						if ( !ball.IsBot ) PopPiecesOf( ball.OwnerSteamId );
						GameLog.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 );
				NeonRenderer.Puff( small.Pos, small.ColorIndex );   // 被吞反馈(v0.7.8.92):host 侧;客户端由快照移除统一补
				_cellsDirty = true;
				if ( small == a )
				{
					_cells.RemoveAt( i );
					i--;   // 下一个元素移位补位,外层 i++ 后正好指向它
					break;
				}
				_cells.RemoveAt( j );
			}
		}
	}

	/// <summary> bot 死亡后原地复活(不再销毁重建——进行中 NetworkSpawn 不复制到已连接客户端,实测)
	/// ⚠️ 蛰伏球(Dormant)不是死亡——复活它=凭空多出无队 FFA bot(v0.7.8.25 修) </summary>
	void TickBots()
	{
		foreach ( var b in _balls )
		{
			if ( !b.IsValid() || !b.IsBot || b.Alive ) continue;
			if ( b.Dormant ) continue;   // 蛰伏备用球:等开局激活,绝不自动复活
			if ( b.SinceDeath < GameConfig.BotRespawnSeconds ) continue;

			// 领土模式 bot 自动上前线(M7.2):没前线格时 TryFrontSpawn 内部回落大本营
			RespawnBall( b, frontLine: MatchState.IsTerritory );
		}
	}

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

		TryRespawnLocal();
	}

	/// <summary> 本机重生请求(空格 / 死亡面板点击共用):权威端直接原地复活;
	/// 客户端发静态 RPC(host 端 Rpc.Caller 找回连接,Alive 同步下发)。
	/// 领土模式 frontLine=true 复活到最近己方占领格(M7.2 二选一,默认大本营)。
	/// 菜单/房间阶段不受理(蛰伏球是死的,但比赛没开始;空格在结算面板另有用途) </summary>
	public void TryRespawnLocal( bool frontLine = false )
	{
		if ( !_gameStarted || MatchState.MatchOver ) return;
		if ( !LocalBall.IsValid() || LocalBall.Alive ) return;

		GameLog.Info( "[game] local respawn requested" );
		if ( NetworkManager.IsAuthority )
		{
			RespawnBall( LocalBall, frontLine: frontLine );
		}
		else
		{
			// 远程球的位置由 owner 自己算(Alive 翻转后本地换位)——前线偏好随点击存本机,host 传参无意义
			_pendingFrontRespawn = frontLine;
			NetworkManager.RequestRespawn( frontLine );
		}
	}

	/// <summary> 取走本机下次复活的"前线"偏好(owner 换位时用;每次重生请求都会重写,消费即清) </summary>
	public bool ConsumeFrontRespawn()
	{
		var v = _pendingFrontRespawn;
		_pendingFrontRespawn = false;
		return v;
	}

	/// <summary> 原地复活一颗球:重置数值 + Alive=true(同步下发;对象永不销毁)。
	/// teamIndex ≥ 0 时重写队伍号(开局激活时的团队分配;普通赛 -1 不动)。
	/// 领土模式 frontLine=true 复活到最近己方占领格(M7.2 二选一),否则大本营。
	/// cue=false 用于开局批量激活(提示音只用于比赛中的复活,v0.7.8.11) </summary>
	void RespawnBall( Ball ball, int teamIndex = -1, bool cue = true, bool frontLine = false )
	{
		ball.Mass = GameConfig.StartMass;
		// 团队赛兜底(v0.7.8.25):无队球(蛰伏误复活/历史异常态)复活时补编队,绝不带 T=-1 上场
		if ( teamIndex < 0 && MatchState.IsTeam && ball.TeamIndex < 0 )
			teamIndex = NextTeamIndex();
		ball.Init( ball.IsBot, teamIndex );   // 重置保护期/速度/颜色(同步下发)
		ball.Dormant = false;
		ball.Alive = true;

		// 领土职业(M7.4):系统随机分配,每次复活重掷——"由系统决定"且无跨局残留态
		if ( MatchState.IsTerritory )
			ball.TerritoryClass = (byte)Game.Random.Int( 0, 3 );

		ClassSkillManager.ResetBall( ball );   // 职业重掷了就把旧冷却清掉(v0.7.8.87),否则新职业按不动

		// host 自己的球/bot:直接换位;真人客户端的球:该客户端在 Alive→true 时自行随机换位(owner 模拟)
		if ( ball.IsBot || ball.OwnerSteamId == Game.SteamId.Value )
		{
			// nearPos 取死亡位置(此刻还没被覆盖)——前线复活挑最近的占领格(M7.2)
			ball.WorldPosition = RandomSpawnPos( ball.TeamIndex, frontLine, ball.WorldPosition );
		}
		else
		{
			// 远程球:host 不代换位,owner 收到 Alive 后自行瞬移——开位移校验宽限期,
			// 否则 owner 的干级别位置更新会被 TickMovementValidation 永久拉回旧处(v0.7.8.33 修)
			_teleportGrace[ball.Id] = 0;
		}

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

		if ( cue ) CueRespawn( ball.OwnerSteamId, ball.TeamIndex );
	}

	/// <summary> 本机是否该听到这声重生:**仅队友**——自己那条由 Ball owner 模拟分支播,这里跳过防叠音 </summary>
	bool ShouldHearRespawn( long steamId, int teamIndex )
	{
		if ( GameSfx.IsMine( steamId ) ) return false;
		if ( !MatchState.IsTeam || teamIndex < 0 ) return false;

		var local = LocalBall;
		return local.IsValid() && local.TeamIndex >= 0 && local.TeamIndex == teamIndex;
	}

	/// <summary> 重生提示音(v0.7.8.11):bot/敌方重生是噪音,只让"自己+队友"听见——
	/// host 本地判定 + RespawnCue 广播,各端同一条"队友才响"规则 </summary>
	void CueRespawn( long steamId, int teamIndex )
	{
		if ( ShouldHearRespawn( steamId, teamIndex ) )
			GameSfx.Respawn( Vector3.Zero );
		if ( Networking.IsActive ) NetworkManager.RespawnCue( steamId, teamIndex );
	}

	/// <summary> 客户端落地(RespawnCue 广播) </summary>
	public void OnRespawnCueRemote( long steamId, int teamIndex )
	{
		if ( NetworkManager.IsAuthority ) return;   // host 已在 CueRespawn 播过(挡广播回声)
		if ( ShouldHearRespawn( steamId, teamIndex ) )
			GameSfx.Respawn( Vector3.Zero );
	}

	// ---- 比赛进程(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 空发无害)
			GameLog.Info( "[menu] demo match restarted" );
			return;
		}

		var rows = new List<ScoreWire>();
		foreach ( var b in _balls )
		{
			if ( !b.IsValid() ) continue;
			// 只收本局真正在场的人:蛰伏球(房间预留、本局未激活)与尸体不进榜。
			// ⚠️ v0.7.8.105 修(审计 #2):原实现只查 IsValid,而 StartMatchFromLobby 只重置
			// **被激活**的 bot,多余的那些走 Alive=false;Dormant=true 时**保留上局质量**——
			// 于是把房间 PLAYERS 调小后,未激活的球带着几千质量参与排名:抢占 arr[0] 显示为
			// "bot 夺冠"、拿掉本机 champion/firstwin 成就、并把真实玩家的名次奖励整体压低
			// (GameXp 的 1st/2~3rd/4~10th 档)。HUD 早已过滤 Alive(GameHud 排行榜),此处对齐。
			if ( !b.Alive || b.Dormant ) 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 ) );

		// 领土模式(M7.4 积分制):名次按占旗积分(征服=占满全部可占领格),积分同分比格数再比质量;
		// 终局快照先行——客户端结算按领土胜负显示,慢一拍就会按旧格数判错冠军
		if ( MatchState.IsTerritory )
		{
			NetworkManager.TerritoryState( TerritoryManager.Snapshot(), TerritoryManager.PointsWire() );
			Array.Sort( arr, ( x, y ) =>
			{
				var byPts = TerritoryManager.Points( y.Team ).CompareTo( TerritoryManager.Points( x.Team ) );
				if ( byPts != 0 ) return byPts;
				var byCells = TerritoryManager.OwnedCount( y.Team ).CompareTo( TerritoryManager.OwnedCount( x.Team ) );
				return byCells != 0 ? byCells : y.Mass.CompareTo( x.Mass );
			} );
		}

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

		GameLog.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( this );
		bool won = GameAchievements.Settlement( standings, local.IsValid() ? local.OwnerSteamId : 0 );   // 成就:夺冠/登顶
		GameXp.Settlement( standings, local.IsValid() ? local.OwnerSteamId : 0, won );                    // 经验结算上报(v0.7.8.19)
		GameLog.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> 吞球事件(双端统一入口):全场"谁吃了谁"播报;音效只对参与的双方;
	/// 击杀者=本机时弹个人高光横幅(v0.7.6.0,金色艺术字) </summary>
	public void OnBallEaten( long eaterSteamId, long eatenSteamId, float massGained )
	{
		var eater = FindBallBySteamId( eaterSteamId );
		var eaten = FindBallBySteamId( eatenSteamId );

		// 吞球滑入(v0.7.8.28 juice):双端统一入口——被吃者缩团滑进吃者嘴里。
		// 分身反吞主球时 eater 是其主球(取不到具体分身),滑向其主队身体,观感为"被收编"
		if ( eater.IsValid() && eaten.IsValid() )
			NeonRenderer.Swallow( eaten.WorldPosition, eaten.ColorIndex, eater.WorldPosition, eaten.Radius );

		if ( GameSfx.IsMine( eaterSteamId ) || GameSfx.IsMine( eatenSteamId ) )
			GameSfx.EatBall( eaten.IsValid() ? eaten.WorldPosition : Vector3.Zero );

		if ( GameSfx.IsMine( eaterSteamId ) )
		{
			// 击杀高光三连(v0.7.7.0):连击横幅(文案/字号随档位)+ 升调音 + 屏幕震动
			int combo = GameSfx.RegisterKill();
			_hud?.AddKillBanner( combo, eaten?.PlayerName ?? "???", massGained );
			GameSfx.ComboSound( combo );
			_camera?.Shake( MathF.Min( 8f + combo * 1.5f, 14f ) );

			GameAchievements.LocalKill( combo, massGained, eatenSteamId );   // 成就:击杀族(v0.7.8.14)
		}
		else if ( GameSfx.IsMine( eatenSteamId ) )
		{
			GameAchievements.LocalDeath( eaterSteamId );   // 成就:首次被吃 + 记仇名单
		}

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

	/// <summary> host 位移校验:远端模拟的球单帧位移超理论上限 3 倍即拉回(反瞬移橡皮筋)。
	/// 合法瞬移(重生换位/分身晋升)有 TeleportGraceSeconds 宽限——窗口内放行 owner 的
	/// 干级别位置更新并持续锚定 _lastPos,窗口一过恢复常规校验(v0.7.8.33 修"晋升/重生后
	/// host 端球位被钉死在旧处":此前 _lastPos 从不随瞬移重置,owner 的每次更新都被拉回) </summary>
	void TickMovementValidation()
	{
		foreach ( var b in _balls )
		{
			if ( !b.IsValid() ) continue;

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

			bool grace = _teleportGrace.TryGetValue( key, out var g );
			if ( grace && g > GameConfig.TeleportGraceSeconds )
			{
				_teleportGrace.Remove( key );
				grace = false;
			}

			// 校验对象用 IsRemoteOwned(v0.7.8.105,审计 #9/#10):它是 host 侧生成意图标记,
			// 才是"这颗球归远端模拟"的可靠信号。原来用 `b.OwnerSteamId != mySteamId` 判定,
			// 而同机双开时连接是合成 SteamId、与 Game.SteamId 对不上(本文件多处实测注释)——
			// 那种场景下远端球被当作"本机的球"跳过校验,反作弊对恰好开了双开的作弊者完全失效。
			// bot 的 IsRemoteOwned 恒 false(SpawnBall 里 owner 为 null),天然排除。
			if ( !grace && b.IsRemoteOwned )
			{
				// 单步位移上限 = 理论速度 × 3(原注释即"超理论上限 3 倍即拉回")。
				// ⚠️ 去掉原来的 `+ 16f` 常数项(v0.7.8.105,审计 #10):那个常数是**按帧**加的,
				// 而此处 Time.Delta 现在是固定 1/50 步长——60fps 下 16 单位本身已≈1.4 倍合法位移,
				// 合计把容差抬到约 4.8 倍,等于允许持续超速。固定步下 3 倍余量已能吸收
				// 远端发包合并(≈3 步合并位移)的抖动。
				var maxMove = b.Speed * Time.Delta * 3f;
				if ( b.WorldPosition.Distance( last ) > maxMove )
					b.WorldPosition = last;
			}

			_lastPos[key] = b.WorldPosition;

			// 转向方向(分身跟随用):取本帧位移方向平滑;静止时保持原方向。
			// 宽限期内位移是瞬移跳变,不代表真实航向,跳过
			if ( grace ) continue;

			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();
				GameLog.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>
	/// <summary> 场地内出生点:走游戏系统的安全采样。领土模式(M7)给队号时在大本营格内采样(出生即回营)。
	/// teamIndex 小于 0 或非领土模式 = 全场随机(既有行为) </summary>
	/// <summary> 场地内出生点:走游戏系统的安全采样。领土模式(M7)给队号时在大本营格内采样;
	/// frontLine=true(M7.2 复活二选一)改最近己方占领格(没有前线格回落大本营)。
	/// teamIndex 小于 0 或非领土模式 = 全场随机(既有行为) </summary>
	public Vector3 RandomSpawnPos( int teamIndex = -1, bool frontLine = false, Vector3 nearPos = default )
	{
		var half = GameConfig.ArenaHalfSize - 128f;

		float minX = -half, maxX = half, minY = -half, maxY = half;
		if ( MatchState.IsTerritory && teamIndex >= 0 )
		{
			if ( frontLine && TerritoryManager.TryFrontSpawn( teamIndex, nearPos, out var front ) )
				return front;
			TerritoryManager.HqBounds( teamIndex, out minX, out maxX, out minY, out maxY );
		}

		Vector3 best = new Vector3( Game.Random.Float( minX, maxX ), Game.Random.Float( minY, maxY ), 0f );
		var bestScore = float.MinValue;

		for ( int i = 0; i < GameConfig.SpawnSafeSamples; i++ )
		{
			var p = new Vector3( Game.Random.Float( minX, maxX ), Game.Random.Float( minY, maxY ), 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];

			// 主人球引用失效(热重载换程序集对象 / 主人真没了)→ 先按 SteamId 重挂自愈
			// (v0.7.8.29:热重载会把组件换成新程序集对象,旧 OwnerBall 引用全失效,
			// 旧逻辑直接消散 = 每次热重载分身凭空蒸发);重挂不上才消散(孢子中性存活,不清理)
			if ( c.PieceKind != CellPiece.Kind.EjectedMass
				&& ( !c.OwnerBall.IsValid() || !c.OwnerBall.Alive ) )
			{
				var re = FindBallBySteamId( c.OwnerSteamId );
				if ( ( !re.IsValid() || !re.Alive ) && _sinceCellHeal > 0.5f )
				{
					_sinceCellHeal = 0;
					ScanBalls();   // 热重载窗口:主人球其实在场景里(新程序集对象),立刻收编再试
					re = FindBallBySteamId( c.OwnerSteamId );
				}
				if ( re.IsValid() && re.Alive )
				{
					c.OwnerBall = re;
				}
				else
				{
					_cells.RemoveAt( i );
					_cellsDirty = true;
					continue;
				}
			}

			// 尖刺分身到期(v0.7.4.0 用户定稿 B):变回普通分身——质量保留,
			// 按质量取合并冷却(v0.7.8.27 曲线)后按普通分身规则合体回收;此后不再是武器。
			// MaxLife 覆盖(M7.4 护卫固守尖刺 8s):到期同时解除锚定
			if ( c.PieceKind == CellPiece.Kind.SpikeMinion
				&& c.SinceSpawn > ( c.MaxLife > 0f ? c.MaxLife : GameConfig.SpikeMinionLife ) )
			{
				c.PieceKind = CellPiece.Kind.SplitPiece;
				c.Anchored = false;
				c.SinceSpawn = 0;
				c.MergeCooldown = GameConfig.MergeCooldownFor( c.Mass );
				NeonRenderer.Puff( c.Pos, c.ColorIndex );
				// 主人提示(v0.7.8.92):尖刺到期"变回分身"是定稿设计,但不提示的话观感就是
				// "我的尖刺莫名其妙变成了分身球"——只给主人自己出横幅
				if ( GameSfx.IsMine( c.OwnerSteamId ) )
					CircleroyaleGame.Current?.Hud?.AddBanner( "SPIKE → CLONE",
						PowerUpManager.ColorOf( (byte)PowerUpManager.Kind.Spike ) );
				_cellsDirty = true;
				continue;
			}

			// 质量衰减(v0.7.8.24 球球大作战手感):分身与球同率持续掉重(孢子不衰减)
			if ( c.PieceKind != CellPiece.Kind.EjectedMass && c.Mass > GameConfig.MassDecayMinMass )
			{
				c.Mass = MathF.Max( GameConfig.MassDecayMinMass, c.Mass - c.Mass * GameConfig.MassDecayPerSecond * dt );
			}

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

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

				// 倒流引力(批18④,"秒合"的物理基础):分身比主球重(吐球减重可制造体重差)
				// 或出生超过 PieceHomeAfterSeconds → 自动转向主球滚回,与摇杆输入按权重混合。
				// 尖刺分身排除——20 秒武器期绝不倒流合体(用户定稿 A/B)
				var owner = c.OwnerBall;
				if ( owner.IsValid() && owner.Alive && c.PieceKind == CellPiece.Kind.SplitPiece && 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 );
			}

			// 护卫固守尖刺(M7.4):锚定分身原地不动(不跟随主人、无转向)
			if ( c.Anchored ) c.SteerVel = Vector3.Zero;

			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;

			// 领土喂旗(M7):孢子落进旗圈即计分并消耗(驻军模型,host 权威)。
			// 没进旗圈或规则不允许(非邻接/大本营)时 Feed 返回 false,孢子继续当普通孢子飞,质量不白费
			if ( c.PieceKind == CellPiece.Kind.EjectedMass && MatchState.IsTerritory )
			{
				if ( TerritoryManager.Feed( c.TeamIndex, c.Pos, c.Mass ) )
				{
					_cells.RemoveAt( i );
					_cellsDirty = true;
					continue;
				}
			}
		}

		// 同主人合并(冷却按质量曲线 v0.7.8.27:小分身 1s、大分身最长 14s):**碰到即合并**(圆缘相触,
		// 不再要求嵌进圆心——用户实测反馈:靠在一起不动不算,观感就是"不能合并")。优先并回主球,其次大分身吞小分身。
		// ⚠️ 内层 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;   // 每帧每分身并一个,下一帧继续
			}
		}

		// 敌方分身相互推挤(v0.7.8.24 球球大作战手感):吃不动=实心,按质量权重互相让位。
		// 同主人走上方合并;团队赛队友可重叠;尖刺分身是武器不参与(碰到走武器判定)
		var pushTeamOf = BuildTeamMap();
		for ( int i = 0; i < _cells.Count; i++ )
		{
			var a = _cells[i];
			if ( a.PieceKind != CellPiece.Kind.SplitPiece ) continue;
			for ( int j = i + 1; j < _cells.Count; j++ )
			{
				var b = _cells[j];
				if ( b.PieceKind != CellPiece.Kind.SplitPiece ) continue;
				if ( a.OwnerSteamId == b.OwnerSteamId ) continue;
				if ( MatchState.IsTeam
					&& pushTeamOf.TryGetValue( a.OwnerSteamId, out var at )
					&& pushTeamOf.TryGetValue( b.OwnerSteamId, out var bt )
					&& at == bt ) continue;
				// 能吃就吃(TickEating 处理),不推——否则推挤会把可吞的对手挡开,大分身永远吃不掉小分身
				var hi = MathF.Max( a.Mass, b.Mass );
				var lo = MathF.Min( a.Mass, b.Mass );
				if ( hi >= lo * GameConfig.EatRatio ) continue;

				var dx = b.Pos.x - a.Pos.x;
				var dy = b.Pos.y - a.Pos.y;
				var rr = a.Radius + b.Radius;
				var d2 = dx * dx + dy * dy;
				if ( d2 >= rr * rr ) continue;
				var d = MathF.Sqrt( MathF.Max( d2, 0.0001f ) );
				var push = MathF.Min( rr - d, GameConfig.PushMaxStep );
				var nx = d > 0.001f ? dx / d : 1f;
				var ny = d > 0.001f ? dy / d : 0f;
				var wa = b.Mass / ( a.Mass + b.Mass );   // 越重让得越少
				a.Pos = new Vector3( a.Pos.x - nx * push * wa, a.Pos.y - ny * push * wa, 0f );
				b.Pos = new Vector3( b.Pos.x + nx * push * ( 1f - wa ), b.Pos.y + ny * push * ( 1f - wa ), 0f );
				_cellsDirty = true;
			}
		}
	}

	/// <summary>
	/// 执行分裂(host 权威;owner 本地直调 / 客户端经 RequestSplit RPC)。
	/// **agar 标准全员分裂(v0.7.1.0 用户需求)**:空格 = 主球 + 每个够重的分身**同时对半分裂**,
	/// 新身体**各自朝光标弹出**(v0.7.8.7 手感:aimPoint 为光标世界坐标,主球与每个分身
	/// 独立算指向,agar 标准扇形铺开)——连按可快速铺开到分身上限(1→2→4→8→16 只要 4 按)。
	/// 每颗身体独立过质量门槛(对半后不击穿质量下限 1),槽满即停。
	/// </summary>
	public void DoSplit( Ball ball, Vector2 aimPoint )
	{
		if ( !GameConfig.EnableSplit || !NetworkManager.IsAuthority ) return;
		if ( ball is null || !ball.IsValid() || !ball.Alive ) return;
		if ( ball.Mass < GameConfig.SplitMinMass ) return;
		if ( ClassSkillManager.IsTankSurging( ball ) ) return;   // 坦克爆发期禁分裂(临时质量分裂=套现,M7.4)

		var mainDir = FallbackDir( ball, DirTo( ball.WorldPosition, aimPoint ) );

		// ⚠️ 先捕获旧分身数量再生成主球新分身:SpawnPiece 是尾部追加,若循环从
		// _cells.Count-1 起步会第一轮就遍历到刚弹出的新分身把它再劈一次——
		// 一次空格出 3 颗(本体+新分身+新分身的孪生),实测 v0.7.8.26 修
		int oldCount = _cells.Count;

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

		// 每个够重的**旧**分身也对半(**各自朝光标弹出**;倒序只遍历到 oldCount,
		// 本轮新生成的主球分身/孪生一律不参与再分裂)。
		// 新分身同样贴着母分身**外缘**生成(v0.7.2.0),否则 1s 冷却一到就被吸回去
		for ( int i = oldCount - 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 dir = DirOr( c.Pos, aimPoint, mainDir );

			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,
				MergeCooldown = GameConfig.MergeCooldownFor( half ),   // v0.7.8.27 曲线
			};
			ClampToArena( twin );
			_cells.Add( twin );
			_cellsDirty = true;
		}

		GameLog.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,
			MergeCooldown = GameConfig.MergeCooldownFor( mass ),   // v0.7.8.27 曲线:分身越大合回越久
		};
		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 );
		GameLog.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 ) )
					{
						GameLog.Info( $"[game] body lost to spike: {b.PlayerName} (promoted)" );
					}
					else
					{
						b.MarkDead();
						if ( !b.IsBot ) PopPiecesOf( b.OwnerSteamId );
						GameLog.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;
			}
		}

		// 孢子喂刺(v0.7.8.24 球球大作战式):吸收充能 → 喂满爆开射刺。刺不换位不重生,
		// 喂食量归零重新攒——"在敌人身边养刺"的战术核心
		for ( int i = _cells.Count - 1; i >= 0; i-- )
		{
			var c = _cells[i];
			if ( c.PieceKind != CellPiece.Kind.EjectedMass ) 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;
				if ( dx * dx + dy * dy > touch * touch ) continue;

				if ( _spikeFed is not null && (uint)sIdx < (uint)_spikeFed.Length )
				{
					_spikeFed[sIdx] += c.Mass;
					NetworkManager.SpikeFed( sIdx, _spikeFed[sIdx] );   // 客户端镜像(刺长大显示用)
					if ( _spikeFed[sIdx] >= GameConfig.SpikeGrowTarget )
						BurstSpike( sIdx );
				}

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

		TickSpines();
		TickSpikeMinions( spikes );
	}

	/// <summary> 刺爆(host 权威,v0.7.8.24):喂食量清零 + 均匀角度射出弹幕(命中在 TickSpines)。
	/// 角度公式 SpineDir 与客户端 OnSpikeBurstRemote 共用(确定性,视觉弹道=权威弹道) </summary>
	void BurstSpike( int index )
	{
		if ( _spikes is null || _spikeFed is null || (uint)index >= (uint)_spikes.Length ) return;
		_spikeFed[index] = 0f;

		var pos = _spikes[index];
		var n = GameConfig.SpikeBurstCount;
		for ( int k = 0; k < n; k++ )
		{
			_spines.Add( new SpikeSpine
			{
				Pos = new Vector2( pos.x, pos.y ),
				Vel = SpineDir( k, n, index ) * GameConfig.SpineSpeed,
				SinceSpawn = 0,   // TimeSince 默认值≠0(记录时刻 0 → 读出巨大值立即过期),必须显式归零
			} );
		}
		NetworkManager.SpikeBurst( index );   // 全端爆效;客户端在同款角度生成视觉弹幕
	}

	/// <summary> 弹幕方向(确定性公式,host 权威与客户端视觉共用):均匀圆周 + 按刺序号错开 </summary>
	static Vector2 SpineDir( int k, int n, int index )
	{
		var ang = k / (float)n * MathF.PI * 2f + index * 0.7f;
		return new Vector2( MathF.Cos( ang ), MathF.Sin( ang ) );
	}

	/// <summary> 刺爆弹幕推进(host 权威):命中球/分身扣质量(护盾不防尖刺族;出生保护免疫),
	/// 到期消散。中立伤害——命中谁都扣(包括喂刺者自己),弹幕本体消失 </summary>
	void TickSpines()
	{
		var dt = Time.Delta;
		for ( int i = _spines.Count - 1; i >= 0; i-- )
		{
			var s = _spines[i];
			if ( s.SinceSpawn > GameConfig.SpineLife ) { _spines.RemoveAt( i ); continue; }
			s.Pos += s.Vel * dt;

			bool consumed = false;

			foreach ( var b in _balls )
			{
				if ( !b.IsValid() || !b.Alive || b.IsProtected ) continue;
				var dx = b.WorldPosition.x - s.Pos.x;
				var dy = b.WorldPosition.y - s.Pos.y;
				var rr = b.Radius + GameConfig.SpineRadius;
				if ( dx * dx + dy * dy >= rr * rr ) continue;

				b.Mass = MathF.Max( GameConfig.StartMass, b.Mass * ( 1f - GameConfig.SpineHitMassFraction ) );
				NetworkManager.SpineHit( s.Pos.x, s.Pos.y, (byte)Math.Clamp( b.ColorIndex, 0, 255 ) );
				consumed = true;
				break;
			}
			if ( consumed ) { _spines.RemoveAt( i ); continue; }

			for ( int j = _cells.Count - 1; j >= 0; j-- )
			{
				var c = _cells[j];
				if ( c.PieceKind != CellPiece.Kind.SplitPiece ) continue;
				var dx = c.Pos.x - s.Pos.x;
				var dy = c.Pos.y - s.Pos.y;
				var rr = c.Radius + GameConfig.SpineRadius;
				if ( dx * dx + dy * dy >= rr * rr ) continue;

				c.Mass = MathF.Max( GameConfig.StartMass, c.Mass * ( 1f - GameConfig.SpineHitMassFraction ) );
				_cellsDirty = true;
				NetworkManager.SpineHit( s.Pos.x, s.Pos.y, (byte)Math.Clamp( c.ColorIndex, 0, 255 ) );
				_spines.RemoveAt( i );
				consumed = true;
				break;
			}
			if ( consumed ) continue;
		}
	}

	/// <summary> 刺爆弹幕(客户端镜像):纯视觉弹道推进 + 到期消散,不判命中(扣质量由 host 权威下发) </summary>
	void TickSpinesVisual()
	{
		var dt = Time.Delta;
		for ( int i = _spines.Count - 1; i >= 0; i-- )
		{
			var s = _spines[i];
			if ( s.SinceSpawn > GameConfig.SpineLife ) { _spines.RemoveAt( i ); continue; }
			s.Pos += s.Vel * dt;
		}
	}

	/// <summary>
	/// 尖刺分身判定(v0.7.4.0):敌方玩家/分身碰到按野刺规则——比刺大被炸(连环对半+刺被吃),
	/// 比刺小被刺吃(死/晋升,刺不消耗)。**同主人与团队赛队友免疫**(武器不伤自己人)。
	/// 炸掉"比自己(主人)大的球"→ 主人奖 3 秒无敌(用户定稿 C:武器专给小球反大球,
	/// 大球用它炸小球零奖励)。判定芯与野刺同款(SpikeRadius × SpikeTouchFactor)。
	/// </summary>
	void TickSpikeMinions( Vector2[] spikes )
	{
		var touch = GameConfig.SpikeRadius * GameConfig.SpikeTouchFactor;
		var teamOf = BuildTeamMap();

		for ( int i = _cells.Count - 1; i >= 0; i-- )
		{
			var minion = _cells[i];
			if ( minion.PieceKind != CellPiece.Kind.SpikeMinion ) continue;

			var owner = minion.OwnerBall;
			bool ownerAlive = owner.IsValid() && owner.Alive;

			bool consumed = false;

			// 敌方球 × 尖刺分身
			foreach ( var b in _balls )
			{
				if ( !b.IsValid() || !b.Alive ) continue;
				if ( b == owner ) continue;   // 主人免疫
				if ( MatchState.IsTeam && ownerAlive && SameTeam( b, owner ) ) continue;

				var d = b.WorldPosition.Distance( minion.Pos );
				if ( d >= b.Radius + touch ) continue;

				if ( b.Radius > GameConfig.SpikeRadius )
				{
					// 大球被炸:吃掉尖刺分身的质量 + 连环对半炸裂;
					// 炸掉"比主人大的球" → 主人 3s 无敌(复用护盾 buff)
					float gained = minion.Mass;
					b.Mass = MathF.Min( GameConfig.MaxMass, b.Mass + gained );
					DoBurst( b );
					NotifyBallEaten( minion.OwnerSteamId, b.OwnerSteamId, gained );

					if ( ownerAlive && b.Mass > owner.Mass )
					{
						owner.ApplyBuff( (byte)PowerUpManager.Kind.Shield, GameConfig.SpikeMinionRewardSeconds );
						GameAchievements.SpikeMinionBlast( minion.OwnerSteamId );   // 成就:以小博大(v0.7.8.13)
						GameLog.Info( $"[game] spike minion burst '{b.PlayerName}' -> '{owner.PlayerName}' invincible {GameConfig.SpikeMinionRewardSeconds}s" );
					}

					NeonRenderer.Pop( minion.Pos, minion.ColorIndex, minion.Radius * 3f );
					if ( Networking.IsActive ) NetworkManager.PoppedEffect( minion.Pos, (byte)minion.ColorIndex, minion.Radius * 3f, minion.OwnerSteamId );
					consumed = true;
				}
				else
				{
					// 比刺小:被刺吃(刺保留,与野刺一致)
					SpawnPopFx( b );
					if ( !TryPromoteFromPieces( b ) )
					{
						b.MarkDead();
						if ( !b.IsBot ) PopPiecesOf( b.OwnerSteamId );
					}
					GameLog.Info( $"[game] eaten by spike minion: {b.PlayerName}" );
				}
				break;
			}

			// 敌方分身 × 尖刺分身(大分身吃刺+炸裂,小分身被吃)
			if ( !consumed )
			{
				for ( int j = _cells.Count - 1; j >= 0; j-- )
				{
					var c = _cells[j];
					if ( c.PieceKind != CellPiece.Kind.SplitPiece || c == minion ) continue;
					if ( c.OwnerSteamId == minion.OwnerSteamId ) continue;   // 主人免疫
					if ( MatchState.IsTeam
						&& teamOf.TryGetValue( c.OwnerSteamId, out var pt )
						&& ownerAlive && owner.TeamIndex >= 0 && pt == owner.TeamIndex ) continue;

					if ( c.Pos.Distance( minion.Pos ) >= c.Radius + touch ) continue;

					if ( c.Radius > GameConfig.SpikeRadius )
					{
						c.Mass = MathF.Min( GameConfig.MaxMass, c.Mass + minion.Mass );
						BurstPiece( c );
						NeonRenderer.Pop( minion.Pos, minion.ColorIndex, minion.Radius * 3f );
						consumed = true;
					}
					else
					{
						NeonRenderer.Puff( c.Pos, c.ColorIndex );
						_cells.Remove( c );   // 按引用移除:BurstPiece 内部可能已删过元素,索引不可信(v0.7.8.87)
						_cellsDirty = true;
						if ( j < i ) i--;   // 已移除的分身在当前遍历位之前,补索引
					}
					break;
				}
			}

			if ( consumed )
			{
				// 按引用移除(v0.7.8.87 修):上面 BurstPiece 在自己人满 16 分身时也会从 _cells 删元素,
				// 原来按索引 RemoveAt(i) 会删错身体或越界抛异常
				_cells.Remove( minion );
				_cellsDirty = true;
				i = Math.Min( i, _cells.Count );   // 移除后列表变短,钳到有效范围(循环还会再 --)
			}
		}
	}

	/// <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];
		_spikeFed = new float[GameConfig.SpikeCount];
		_spines.Clear();
		for ( int i = 0; i < _spikes.Length; i++ )
		{
			_spikes[i] = RandomSpikePos();
		}
		if ( Networking.IsActive ) NetworkManager.SpikesFull( _spikes );   // 无会话(菜单演示赛)不广播
		GameLog.Info( $"[game] spikes generated: {_spikes.Length}" );
	}

	/// <summary>
	/// 执行吐孢子(host 权威,**agar 标准多身体喂球**):对玩家的每一个身体——主球和所有
	/// 够质量的分身——各吐一颗中性孢子(v0.7.8.7 手感:aimPoint 为光标世界坐标,
	/// 每颗身体**独立朝光标**弹出,孢子扇形散开)。按住连吐。
	/// </summary>
	public void DoEject( Ball ball, Vector2 aimPoint )
	{
		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;
		if ( !HasEjectMass( ball ) ) return;   // 质量不够就别吃这次节流(v0.7.8.87:原来先记冷却,白吞 0.15s)
		_sinceEject[ball.Id] = 0;

		var mainDir = FallbackDir( ball, DirTo( ball.WorldPosition, aimPoint ) );

		EjectFrom( ball, mainDir );

		// 两阶段(先收集后生成):SpawnBlob 超孢子上限会 _cells.Remove 挤掉最老一颗,
		// 边遍历 _cells 边生成 = "Collection was modified"(实测炸 bot 喂大哥路径,v0.7.8.8 修)
		List<CellPiece> ejectors = null;

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

			( ejectors ??= new List<CellPiece>() ).Add( c );
		}

		if ( ejectors is null ) return;

		foreach ( var c in ejectors )
		{
			var cost = EjectCost( c.Mass );   // 两阶段间质量不会被改,重算即得
			if ( cost <= 0f ) continue;

			c.Mass -= cost;
			SpawnBlob( c.Pos, DirOr( c.Pos, aimPoint, mainDir ), c.Radius, c.OwnerSteamId, ball.TeamIndex, c.ColorIndex, cost * GameConfig.EjectBlobEfficiency );
		}
	}

	/// <summary> 主球是否还有可吐质量(无分身时决定这次按键算不算一次有效投掷;v0.7.8.87) </summary>
	static bool HasEjectMass( Ball ball ) => EjectCost( ball.Mass ) > 0f;

	/// <summary> 身体 → 光标的单位指向;光标正贴该身体时返回 fallback(主球方向) </summary>
	static Vector2 DirOr( Vector3 from, Vector2 aimPoint, Vector2 fallback )
	{
		var d = DirTo( from, aimPoint );
		return d.Length > 0.001f ? d : fallback;
	}

	/// <summary> 身体 → 瞄准点的单位指向;正贴时返回 Zero(调用方走 FallbackDir 兜底) </summary>
	static Vector2 DirTo( Vector3 from, Vector2 aimPoint )
	{
		var dx = aimPoint.x - from.x;
		var dy = aimPoint.y - from.y;
		var len = MathF.Sqrt( dx * dx + dy * dy );
		return len > 0.001f ? new Vector2( dx / len, dy / len ) : Vector2.Zero;
	}

	/// <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.TeamIndex, ball.ColorIndex, cost * GameConfig.EjectBlobEfficiency );
	}

	/// <summary> 生成一颗中性孢子(质量=消耗×返还率)并做总量上限收敛(挤掉最老)。
	/// team = 吐球者队伍(领土喂旗计分按队伍;bot 不按 OwnerSteamId 查——bot 的 SteamId 全是 0 会串队) </summary>
	void SpawnBlob( Vector3 pos, Vector2 dir, float radius, long owner, int team, int colorIndex, float mass )
	{
		var c = new CellPiece
		{
			Id = _nextCellId++,
			PieceKind = CellPiece.Kind.EjectedMass,
			OwnerSteamId = owner,
			TeamIndex = team,
			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 平滑),新增补入,消失移除。
	/// 经缓冲字典 O(n+m)(原双重全扫 O(n×m),满配 800+ 分身/孢子时是 15Hz 热路径) </summary>
	public void OnCellsStateRemote( CellWire[] wires )
	{
		if ( NetworkManager.IsAuthority ) return;   // host 回声挡掉

		_wireById.Clear();
		if ( wires is not null )
		{
			foreach ( var w in wires )
				_wireById[w.Id] = w;
		}

		for ( int i = _cells.Count - 1; i >= 0; i-- )
		{
			var c = _cells[i];
			if ( !_wireById.TryGetValue( c.Id, out var w ) )
			{
				// 消失也要给反馈(v0.7.8.92):被吞/并回/主人阵亡在客户端只是从快照里静默消失,
				// 观感就是"分身凭空消失"——原地补一朵小喷发(合并回收也会出,属可接受的吸收反馈)
				NeonRenderer.Puff( c.DrawPos, c.ColorIndex );
				_cells.RemoveAt( i );
				continue;
			}

			c.Pos = new Vector3( w.X, w.Y, 0f );
			c.Mass = w.Mass;
			c.ColorIndex = w.ColorIndex;
			c.OwnerSteamId = w.Owner;
			c.PieceKind = (CellPiece.Kind)w.Kind;
		}

		_cellIdScratch.Clear();
		foreach ( var c in _cells )
			_cellIdScratch.Add( c.Id );

		foreach ( var kv in _wireById )
		{
			if ( _cellIdScratch.Contains( kv.Key ) ) continue;

			var w = kv.Value;
			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>
	/// 尖刺分身(v0.7.4.0 道具效果):从主人现扣 SpikeMinionMassPercent 的质量生成一颗跟随分身,
	/// 20 秒内是"刺"——敌方玩家碰到按尖刺规则处理(大球被炸/小球死),到期变回普通分身合体回收质量。
	/// 生成即带主人转向跟随(TickCells 的 SpikeMinion 分支)。
	/// </summary>
	/// <summary> 护卫固守尖刺(M7.4 职业):原地生成锚定尖刺分身(8 秒,不扣主人质量),
	/// 敌方触碰走尖刺分身既有判定;到期变回普通分身开始跟随主人 </summary>
	public void SpawnGuardSpike( Ball ball )
	{
		if ( ball is null || !ball.IsValid() || !ball.Alive ) return;

		var c = new CellPiece
		{
			Id = _nextCellId++,
			PieceKind = CellPiece.Kind.SpikeMinion,
			OwnerSteamId = ball.OwnerSteamId,
			OwnerBall = ball,
			TeamIndex = ball.TeamIndex,
			ColorIndex = ball.ColorIndex,
			Mass = MathF.Max( 10f, ball.Mass * 0.15f ),
			Pos = ball.WorldPosition,
			DrawPos = ball.WorldPosition,
			SinceSpawn = 0,
			Anchored = true,
			MaxLife = GameConfig.ClassGuardLife,
		};
		_cells.Add( c );
		_cellsDirty = true;
		NeonRenderer.Puff( c.Pos, c.ColorIndex );
	}

	public void SpawnSpikeMinion( Ball ball )
	{
		if ( ball is null || !ball.IsValid() || !ball.Alive ) return;

		var mass = MathF.Max( 10f, ball.Mass * GameConfig.SpikeMinionMassPercent );
		mass = MathF.Min( mass, ball.Mass - 1f );
		if ( mass < 10f ) return;   // 质量太低(极小球)扣不出 10,放弃(道具白拿——可接受的极端边界)

		ball.Mass -= mass;

		var dir = RandomDir();
		var c = new CellPiece
		{
			Id = _nextCellId++,
			PieceKind = CellPiece.Kind.SpikeMinion,
			OwnerSteamId = ball.OwnerSteamId,
			OwnerBall = ball,
			ColorIndex = ball.ColorIndex,
			Mass = mass,
			Pos = ball.WorldPosition + new Vector3( dir.x, dir.y, 0f ) * ( ball.Radius + 40f ),
			DrawPos = ball.WorldPosition,
			SinceSpawn = 0,
		};
		ClampToArena( c );
		_cells.Add( c );
		_cellsDirty = true;

		NeonRenderer.Puff( c.Pos, ball.ColorIndex );
		GameLog.Info( $"[game] spike minion spawned for '{ball.PlayerName}' (mass {mass:0})" );
	}

	/// <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 模拟从这里继续
		_lastPos[ball.Id] = pos;   // 位移校验锚点同步瞬移,否则下一帧被当瞬移作弊拉回(v0.7.8.33 修)

		// 远程玩家的球归其主人模拟,位置要通知主人瞬移过去(否则下一帧被主人的模拟写回旧位置)
		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;
			}
		}

		GameLog.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 ( _settings.IsValid() && _settings.IsOpen ) return;   // 设置页开着:主菜单让位(v0.7.8.16)
		if ( _rankings.IsValid() && _rankings.IsOpen ) return;   // 排行页开着:主菜单让位(v0.7.8.19)
		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();
			_teleportGrace.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 里。
			// ⚠️ 豁免含 ScreenPanel 的顶层物体:UI GO 若因 Parent= 静默失效变成顶层孤儿,
			// 禁用它们会引发禁用↔启用循环(PanelComponent 每次重新启用重建面板树=爆闪)
			foreach ( var go in Scene.Children )
			{
				if ( !go.IsValid() || go == _root ) continue;
				var hasUi = false;
				foreach ( var sp in go.GetComponentsInChildren<ScreenPanel>( true ) ) { hasUi = true; break; }
				if ( hasUi ) continue;
				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();
		CreateSettingsPanel();
		CreateRankingsPanel();

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

		// 主菜单背景演示赛(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();
				GameLog.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();
				GameLog.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;

			case 4:   // SETTINGS:设置页(泛光开关/音乐音量;BACK 回菜单)
				_menu?.Hide();
				_settings?.Show();
				break;

			case 5:   // RANKINGS:全球 XP 排行页(v0.7.8.19;BACK 回菜单)
				_menu?.Hide();
				_rankings?.Show();
				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();
		_power.Init();

		var target = Math.Clamp( MatchState.PlayerTarget, 2, 64 );
		for ( int i = 0; i < target; i++ )
		{
			SpawnBall( RandomSpawnPos(), isBot: true, owner: null );
		}
		GameLog.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();
		_teleportGrace.Clear();
		_steerDir.Clear();
		_sinceEject.Clear();
		_power?.Reset();
		_networkReady = false;
		GameLog.Info( "[menu] demo stopped" );
	}

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

		// 开新房默认领土战争(v0.7.8.85 用户定稿):显式归位而不是只靠静态初始化器——
		// 热重载保留旧静态值,开房时重设才能自愈;同一房间连续多局
		// (P 退局/结算回房)不走这里,玩家改过的选择保留
		MatchState.PendingMode = MatchState.Mode.Territory;

		_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 )
			{
				// ⚠️ 只禁用"真正的模板残留"——含 ScreenPanel 的是我们自己的 UI 面板(客户端上 UI GO
				// 可能因 Parent= 静默失效变成顶层孤儿),禁用它们会触发每秒禁用↔启用循环:
				// PanelComponent 每次重新启用都重建面板树 = HUD 每秒整树重建 = 客户端爆闪(实测)
				var hasUi = false;
				foreach ( var sp in go.GetComponentsInChildren<ScreenPanel>( true ) ) { hasUi = true; break; }
				if ( hasUi )
				{
					if ( _strayUiLogged.Add( go.Name ) )
						Log.Info( $"[game] keep stray top-level UI '{go.Name}' (no balls, has ScreenPanel)" );
					continue;
				}

				if ( go.Enabled )
				{
					Log.Info( $"[game] disable stray top-level '{go.Name}' (no balls)" );
					go.Enabled = false;
				}
				continue;
			}

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

			foreach ( var child in go.Children.ToArray() )
			{
				if ( !child.IsValid() ) continue;
				// 剥掉 host 快照里自带的相机与全部 UI 面板(会随快照复制过来,客户端留着就是
				// 双份界面/host 视角的页面)——只用本机自己建的那套。
				// ⚠️ 新增 UI 面板必须同步加进这个名单(RoomBrowser/Settings/Rankings 曾漏,v0.7.8.33 补)
				if ( child.Name == "Camera" || child.Name == "Hud" || child.Name == "MainMenu"
					|| child.Name == "LobbyPanel" || child.Name == "EndBoard"
					|| child.Name == "RoomBrowser" || child.Name == "SettingsPanel" || child.Name == "RankingsPanel" )
				{
					var name = child.Name;
					// 诊断(临时):Log.Info 客户端也打印——确认剥离到底删的是谁的 UI(宿主快照 vs 本机的)
					Log.Info( $"[game] stripped snapshot '{name}' (parent='{go.Name}')" );
					child.Destroy();
				}
			}
		}
	}

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

		GameLog.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;
		ClearMatchState();   // 开局也清一遍(v0.7.8.87):从菜单直接开的第一局、人机局不经过 ResetToLobby

		_food.Init();
		GenerateSpikes();
		_power.Init();

		// 每局球组复位(球对象永不销毁——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 走下面的名额补齐

			b.TeamIndex = -1;   // 清上一局残留队伍号(v0.7.8.87):非团队局必须归 -1,否则同号互不吃
			RespawnBall( b, NextTeamIndex(), cue: false );
			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;

			b.TeamIndex = -1;   // 同上:bot 球也是跨局复用的(蛰伏的也要清,避免下次激活带旧队号)

			if ( bots < botsWanted )
			{
				RespawnBall( b, NextTeamIndex(), cue: false );
				bots++;
			}
			else
			{
				b.Alive = false;   // 多余 bot 蛰伏(不可见不模拟)
				b.Dormant = true;
				b.Mass = GameConfig.StartMass;   // ⚠️ 必须清零(v0.7.8.105,审计 #2):
				// 蛰伏球跨局复用且不参与模拟,保留上局质量会污染结算排名(见 EndMatch 注释),
				// 也会让它被激活时以巨球开局
			}
		}

		// 等在房间里的客户端没收到过食物/尖刺(OnActive 时还没生成):逐一定向补发
		var foods = _food.Foods;
		var spikes = _spikes;
		var powerWires = _power.ToWireForSync();
		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 ( powerWires is not null ) NetworkManager.PowerUpFull( powerWires );
			}
		}

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

		GameLog.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()
	{
		if ( !MatchState.IsTeam ) return -1;
		var team = _teamSeq++ / MatchState.TeamSize;
		// 领土固定 4 队(v0.7.8.35):真人超编(大于 16)时挤进既有队,绝不开第 5 队——大本营只有四角
		return MatchState.IsTerritory ? Math.Min( team, 3 ) : team;
	}

	/// <summary> 两颗球是否同队(TeamIndex 小于 0 = 普通赛/未分队,不算同队)。
	/// ⚠️ 必须带 MatchState.IsTeam 门禁(v0.7.8.87 修):球对象跨局复用,团队/领土局留下的
	/// TeamIndex 会一直挂在球上,普通赛里"同号即同队"会让整组人互相吃不动(推挤仍生效,只推不吃) </summary>
	static bool SameTeam( Ball a, Ball b ) =>
		MatchState.IsTeam && a.TeamIndex >= 0 && a.TeamIndex == b.TeamIndex;

	/// <summary> 推挤用:两个 SteamId 是否同阵营(同一 SteamId=自己的身体;团队赛按球上的 TeamIndex 查)。
	/// 同阵营不推挤(BoB 惯例:自己和队友可重叠) </summary>
	public bool SameTeamSteam( long a, long b )
	{
		if ( a == b ) return true;
		if ( !MatchState.IsTeam ) return false;
		var ta = TeamIndexOfSteam( a );
		return ta >= 0 && ta == TeamIndexOfSteam( b );
	}

	/// <summary> 团队赛阵营映射(SteamId → 队伍号):返回**复用缓冲**——每帧 TickEating/TickCells/
	/// TickSpikeMinions 三处先后构建使用,调用方当帧用完即弃,不得跨方法/跨帧持有;
	/// 普通赛返回空映射 </summary>
	Dictionary<long, int> BuildTeamMap()
	{
		_teamScratch.Clear();
		if ( !MatchState.IsTeam ) return _teamScratch;

		foreach ( var b in _balls )
		{
			if ( b.IsValid() && b.TeamIndex >= 0 ) _teamScratch[b.OwnerSteamId] = b.TeamIndex;
		}
		return _teamScratch;
	}

	int TeamIndexOfSteam( long sid )
	{
		foreach ( var b in _balls )
		{
			if ( b.IsValid() && b.OwnerSteamId == sid && b.TeamIndex >= 0 ) return b.TeamIndex;
		}
		return -1;
	}

	/// <summary> eater 与孢子主人是否同阵营(v0.7.8.23):同一 SteamId(自己),
	/// 或团队赛下查 teamOf 同队。同阵营走 0.2s 快档,敌人 0.5s。
	/// 注:bot 的 OwnerSteamId 全是 0——bot 之间的 blob 恒判"自己"快档(既有 SteamId 0 设计的既有模糊,无害) </summary>
	static bool SameSide( long blobOwner, long eater, Dictionary<long, int> teamOf )
	{
		if ( eater == blobOwner ) return true;
		return MatchState.IsTeam
			&& teamOf.TryGetValue( eater, out var et )
			&& teamOf.TryGetValue( blobOwner, out var bt )
			&& et == bt;
	}

	/// <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;
		GameLog.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 自己已直接处理
		GameLog.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 );
		GameLog.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> 加入失败提示:浏览器页开着显示在那,主菜单状态行是兜底。
	/// ⚠️ 必须同时复位房间列表页的 _joining(v0.7.8.105,审计 #4):它只在 Show/Hide 里清,
	/// 失败后一直为 true → 整个房间页所有行与 REFRESH/JOIN LOCAL 全部点击失效。 </summary>
	public void OnJoinAttemptFailed( string message )
	{
		_browser?.OnJoinDenied();
		_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();

		// JOIN 打开列表时 StopMenuDemo 清掉了背景演示赛——回菜单必须重新开起来,否则背景不动(用户实测)
		if ( !_gameStarted && !_lobbyOpen && NetworkManager.IsAuthority && !Networking.IsActive )
			StartMenuDemo();
	}

	/// <summary> 设置页收起(SettingsPanel BACK 调用):回主菜单 </summary>
	public void CloseSettings()
	{
		_settings?.Hide();
		_menu?.Show();
	}

	/// <summary> 排行页收起(RankingsPanel BACK 调用):回主菜单 </summary>
	public void CloseRankings()
	{
		_rankings?.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 ) );
		GameLog.Info( $"[game] main promoted -> continue at ({x:0},{y:0})" );
	}

	/// <summary> host:标记一次合法瞬移(位移校验锚到新位置 + 开宽限期)——指挥官技能/晋升共用语义 </summary>
	public void MarkTeleport( Ball ball, Vector3 pos )
	{
		_lastPos[ball.Id] = pos;
		_teleportGrace[ball.Id] = 0;
	}

	/// <summary> 静态 RPC 落地:指挥官技能传送——本机球瞬移到队友身边(host 已先锚定校验位) </summary>
	public void OnClassTeleportRemote( long steamId, float x, float y )
	{
		if ( NetworkManager.IsAuthority ) return;

		// 身份判定走本机球的 OwnerSteamId(v0.7.8.87 修):发包方用的是 Connection.SteamId,
		// 同机双开时它与 Game.SteamId 对不上(本文件与 NetworkManager 都有该实测注释),
		// 原来按 Game.SteamId 比对会让双开客户端的传送静默失效
		var ball = LocalBall;
		if ( !ball.IsValid() ) return;
		if ( ball.OwnerSteamId != steamId ) return;

		ball.TeleportTo( new Vector3( x, y, 0f ) );
		GameLog.Info( $"[class] commander teleport -> ({x:0},{y:0})" );
	}

	/// <summary> host:为指定玩家施放职业技能(客户端 RPC 入口;冷却/条件在管理器内校验) </summary>
	public void CastClassSkillFor( long steamId )
	{
		foreach ( var b in _balls )
		{
			if ( b.IsValid() && !b.IsBot && b.Alive && b.OwnerSteamId == steamId )
			{
				ClassSkillManager.Cast( b );
				return;
			}
		}
	}

	/// <summary> 静态 RPC 落地:为指定连接原地复活其球(按 OwnerSteamId 匹配)。
	/// ⚠️ 门禁(v0.7.8.105,审计 #6):必须"对局进行中且未结算",且不碰蛰伏球。
	/// 此前只查连接有效——房间期每颗球都是 Alive=false,Dormant=true,任何客户端都能在 host
	/// 点 START 之前调用 RequestRespawn 把自己复活并分到队伍;结算期同理(冻结被绕过)。
	/// 客户端侧 TryRespawnLocal 本来有门禁,但 RPC 入口完全绕过了它。 </summary>
	public void RespawnConnection( Connection conn, bool frontLine = false )
	{
		if ( conn is null || !conn.IsActive ) return;
		if ( !_gameStarted || MatchState.MatchOver ) return;

		var steamId = conn.SteamId.Value;
		foreach ( var b in _balls )
		{
			if ( b.IsValid() && !b.IsBot && !b.Alive && !b.Dormant && b.OwnerSteamId == steamId )
			{
				RespawnBall( b, frontLine: frontLine );
				return;
			}
		}
		GameLog.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 );
		GameLog.Info( $"[game] food full sync received: {foods.Length}" );
	}

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

		// 喂食量镜像:**长度一致就保留**(v0.7.8.87 修)。SpikesFull 在每次刺被吃/重刷时都会重发
		// (只带位置、不带喂食量),原来无条件归零 → 客户端刺的喂养进度被反复抹掉、与 host 长期不一致;
		// 只有长度变了(新局/首次同步)才重建
		var n = spikes is null ? 0 : spikes.Length;
		if ( _spikeFed is null || _spikeFed.Length != n ) _spikeFed = new float[n];
		// 不再清 _spines:全量同步每朵刺重刷都会发一次,清弹幕会把在飞的刺爆抹掉(v0.7.8.87);
		// 跨局的旧弹幕由开局清理路径(StartMatchFromLobby / ResetToLobby)负责
	}

	// ---- 喂刺/刺爆远端事件(v0.7.8.24)----

	/// <summary> 客户端镜像:某朵刺的累计喂食量(host 已直接写入,回声挡掉) </summary>
	public void OnSpikeFedRemote( int index, float fed )
	{
		if ( NetworkManager.IsAuthority ) return;
		if ( _spikeFed is not null && (uint)index < (uint)_spikeFed.Length ) _spikeFed[index] = fed;
	}

	/// <summary> 全端爆效;客户端用与 host 相同的确定性角度生成视觉弹幕(host 的权威弹幕在 BurstSpike 已生成) </summary>
	public void OnSpikeBurstRemote( int index )
	{
		if ( _spikes is null || (uint)index >= (uint)_spikes.Length ) return;
		var pos = _spikes[index];

		if ( NetworkManager.IsAuthority )
		{
			GameSfx.Pop( new Vector3( pos.x, pos.y, 0f ) );   // host 爆效声(权威弹幕已在 BurstSpike 生成,渲染直接读)
			return;
		}

		if ( _spikeFed is not null && (uint)index < (uint)_spikeFed.Length ) _spikeFed[index] = 0f;
		var n = GameConfig.SpikeBurstCount;
		for ( int k = 0; k < n; k++ )
		{
			_spines.Add( new SpikeSpine
			{
				Pos = new Vector2( pos.x, pos.y ),
				Vel = SpineDir( k, n, index ) * GameConfig.SpineSpeed,
				SinceSpawn = 0,   // 同 BurstSpike:TimeSince 必须显式归零
			} );
		}
		GameSfx.Pop( new Vector3( pos.x, pos.y, 0f ) );
	}

	/// <summary> 刺爆弹幕命中特效(全端:Broadcast 本地也执行,host 不预播) </summary>
	public void OnSpineHitRemote( float x, float y, byte colorIndex )
	{
		var pos = new Vector3( x, y, 0f );
		NeonRenderer.Puff( pos, colorIndex );
		GameSfx.Pop( pos );
	}

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

	public void OnPowerUpFullRemote( PowerUpWire[] wires )
	{
		if ( NetworkManager.IsAuthority ) return;
		_power?.ApplyFull( wires );
	}

	public void OnPowerUpPickedRemote( int slot, byte kind, long pickerSteamId )
	{
		if ( NetworkManager.IsAuthority ) return;

		// 应用前先取位置/种类放火花(ApplyPicked 会置空槽)
		if ( _power?.Ready ?? false )
		{
			var slots = _power.Slots;
			if ( (uint)slot < (uint)slots.Count )
			{
				var s = slots[slot];
				NeonRenderer.Spark( new Vector3( s.Pos.x, s.Pos.y, 0f ), PowerUpManager.ColorOf( kind ) );
				if ( GameSfx.IsMine( pickerSteamId ) )
				{
					GameSfx.EatFood( new Vector3( s.Pos.x, s.Pos.y, 0f ) );
					GameAchievements.PowerStored();   // 成就:累计捡 5 个道具入包(客户端侧计数)
				}
			}
		}

		_power?.ApplyPicked( slot );
	}

	public void OnPowerUpRespawnedRemote( int slot, byte kind, float x, float y )
	{
		if ( NetworkManager.IsAuthority ) return;
		_power?.ApplyRespawned( slot, kind, new Vector2( x, y ) );
	}

	/// <summary> 客户端个人高光横幅落地(host 已本地播过,回声挡掉;只播"是我的"那条) </summary>
	public void OnPowerBannerRemote( byte kind, long ownerSteamId, byte mode )
	{
		if ( NetworkManager.IsAuthority ) return;
		if ( !GameSfx.IsMine( ownerSteamId ) ) return;

		var text = mode == 2 ? $"{PowerUpManager.NameOf( kind )} READY (Q)"   // Q 充能到点(v0.7.8.31)
			: mode == 0 ? $"{PowerUpManager.NameOf( kind )} STORED" : GameHud.ActivationText( kind );
		_hud?.AddBanner( text, GameHud.KindColor( kind ) );
		GameSfx.Pickup();

		if ( mode == 1 ) GameAchievements.FeedTriggered( ownerSteamId );   // 成就:首次喂食触发(mode1=激活/喂养)
	}

	/// <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>();
		go.AddComponent<TerritoryRenderer>();   // 领土格底色/边界/旗标(M7;非领土模式自清空不画)
	}

	/// <summary> 全场景一个的像素批量画笔:每帧把所有球/分身写进各批次顶点(v0.7.8.40 分身头像也并入) </summary>
	void CreateNeon()
	{
		var go = new GameObject( true, "Neon" );
		go.Parent = _root;
		go.AddComponent<NeonRenderer>();
	}

	void CreateCamera()
	{
		// 防御:热重载/重入可能残留旧相机——两个 IsMainCamera 会抢渲染,而旧的那个
		// 姿态已死(NeonCamera 缓存的 _target 失效早退),渲染选中它就是画面僵死不跟随
		foreach ( var c in Scene.GetAllComponents<CameraComponent>().ToArray() )
		{
			if ( c.IsValid() && c.GameObject.IsValid() )
			{
				GameLog.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.918f, 0.957f, 0.988f );   // #EAF4FC 浅蓝底(像素风,v0.7.8.36)
		cam.EnablePostProcessing = true;

		// Bloom 保留组件(cr_bloom convar/设置页还管它),默认关——加色泛光会糊掉糖果色与像素颗粒
		var bloom = go.AddComponent<Bloom>();
		bloom.Mode = SceneCamera.BloomAccessor.BloomMode.Additive;
		bloom.Strength = 1.5f;
		bloom.Threshold = 0.55f;

		// 像素风去掉 ACES 色调映射(会把马卡龙色压灰),保持贴图原色
		_camera = go.AddComponent<NeonCamera>();
		_camera.Cam = cam;
	}

	void CreateHud()
	{
		var go = new GameObject( true, "Hud" );
		go.Parent = _root;
		// 诊断(临时,客户端爆闪):Parent= 可能静默失效(目标是 host 拥有对象时)——回读实际父物体
		var rootName = _root.IsValid() ? _root.Name : "NULL";
		var realParent = go.Parent.IsValid() ? go.Parent.Name : "NONE";
		int ballCount = 0;
		if ( _root.IsValid() ) foreach ( var b in _root.GetComponentsInChildren<Ball>( true ) ) ballCount++;
		Log.Info( $"[game] CreateHud: _root='{rootName}' ballsUnderRoot={ballCount} hudParentNow='{realParent}'" );
		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.7.8.16 二级界面):主菜单 [4] 进入——泛光开关/音乐音量,BACK 回菜单 </summary>
	void CreateSettingsPanel()
	{
		var go = new GameObject( true, "SettingsPanel" );
		go.Parent = _root;
		go.AddComponent<ScreenPanel>();
		_settings = go.AddComponent<SettingsPanel>();
	}

	/// <summary> 排行页(v0.7.8.19 二级界面):主菜单 [5] 进入——全球 XP 榜,BACK 回菜单 </summary>
	void CreateRankingsPanel()
	{
		var go = new GameObject( true, "RankingsPanel" );
		go.Parent = _root;
		go.AddComponent<ScreenPanel>();
		_rankings = go.AddComponent<RankingsPanel>();
	}

	/// <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 );
		ball.Dormant = dormant;
		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 );
		_teleportGrace.Remove( ball.Id );
		_steerDir.Remove( ball.Id );
		_sinceEject.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>
	/// <summary>
	/// 局间清理(v0.7.8.87):一局结束/回大厅时,凡是"按球记账"或"本局临时"的状态都要清。
	/// **球对象是跨局复用的**(永不销毁、下局原地复活),所以不清的后果都会漏到下一局:
	/// 分身/孢子被继续模拟(开局白送质量)、位移校验锚点/瞬移宽限错位、
	/// 职业冷却与 Q 充能残留(开局按不动 / 白送技能)、连击与击杀计数串局、
	/// 刺爆弹幕残留、前线复活偏好残留。回主菜单的 ResetToMenu 已有一份更彻底的清理,这里对齐。
	/// </summary>
	void ClearMatchState()
	{
		_cells.Clear();
		_cellsDirty = true;
		_lastPos.Clear();
		_teleportGrace.Clear();
		_steerDir.Clear();
		_sinceEject.Clear();
		_spines.Clear();
		_pendingFrontRespawn = false;

		ClassSkillManager.ResetAll();
		GameSfx.ResetMatchStats();
		GameXp.ResetMatchKills();
		GameHud.ResetHints();
	}

	public void ResetToLobby()
	{
		MatchState.Reset();
		_matchRunning = false;
		_joinCountdown = -1f;
		_gameStarted = false;

		ClearMatchState();   // 局间清理(v0.7.8.87):球对象跨局复用,见方法注释

		_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();   // 回大厅回到菜单曲
		GameLog.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();
		_teleportGrace.Clear();
		_steerDir.Clear();
		_sinceEject.Clear();
		_power?.Reset();
		ClearMatchState();   // 静态计数/引导冷却/职业冷却一并清(v0.7.8.87)
		_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();
		GameLog.Info( "[menu] back to menu" );

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