s&box Package Code Search

Search C# source code, UI razor templates, shaders, and configs across s&box packages.

Showing code results for query: * (49 total matches found)
slfy.circleroyale / Game/CircleroyaleGame.cs
Game game
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();
	}
}
slfy.circleroyale / Rendering/NeonCamera.cs
Game game
using System;

/// <summary>
/// 俯视跟随相机:正交投影、指数平滑跟随本地球;视口高度随质量增大(镜头拉远)。
/// 相机朝向固定垂直向下,鼠标→世界方向的映射见 BallInput。
/// </summary>
public sealed class NeonCamera : Component
{
	const float FollowSpeed = 6f;
	const float ZoomSpeed = 2.5f;

	/// <summary> 由 CircleroyaleGame 注入(避免每帧 GetComponent) </summary>
	public CameraComponent Cam { get; set; }

	Ball _target;
	Ball _spectate;      // 死亡观战目标(本地球活着时恒为 null)
	Vector3 _focus;
	float _shakeAmp;     // 屏幕震动幅度(击杀/放技能触发,指数衰减,v0.7.7.0)
	Bloom _bloom;        // 泛光组件(cr_bloom convar 即时开关,v0.7.8.15)

	/// <summary> 触发屏幕轻微震动(击杀连播/放技能的高光反馈;幅度按事件定,自动指数衰减) </summary>
	public void Shake( float amplitude ) => _shakeAmp = MathF.Max( _shakeAmp, amplitude );

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

		// 立即摆好默认俯视姿态(焦点=场地中心)。没有本机球之前 OnUpdate 会因
		// !_target.IsValid() 直接 return——相机保持引擎默认姿态(原点水平看),
		// 整个场地平面侧视塌成一条线:主菜单背景实测就是这样(客户端竞态期同样中招)
		ApplyPose();
		if ( Cam.IsValid() )
		{
			Cam.OrthographicHeight = GameConfig.CameraBaseView;
			_bloom = Cam.GetComponent<Bloom>();
		}
	}

	public void SetTarget( Ball ball )
	{
		if ( _target == ball ) return;   // 同一颗球:不重复 SnapTo(扫描会反复断言)
		_target = ball;
		SnapTo();
	}

	public void SnapTo()
	{
		if ( !_target.IsValid() ) return;
		_focus = _target.WorldPosition;
		_focus.z = 0f;
		ApplyPose();
		if ( Cam.IsValid() ) Cam.OrthographicHeight = ViewForMass( _target.Mass );
	}

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

		// 泛光开关(v0.7.8.15):控制台 cr_bloom 0/1 即时生效(幂等赋值,无抖动);
		// 热重载换程序集后缓存失效 → 惰性重取
		if ( Cam.IsValid() )
		{
			if ( !_bloom.IsValid() ) _bloom = Cam.GetComponent<Bloom>();
			if ( _bloom.IsValid() ) _bloom.Enabled = GameConfig.ConvarBloom;
		}

		// 自愈:热重载会把场景组件换成新程序集对象,缓存的 _target 可能变成失效引用——
		// OnUpdate 首行早退会让相机永久冻结(实测:客户端复活着、分裂着,镜头僵死不跟)。
		// 从游戏系统重新认领本机球;连本机球都没有也保持俯视姿态,绝不僵死
		if ( !_target.IsValid() )
		{
			var local = CircleroyaleGame.Current?.LocalBall;
			if ( local.IsValid() )
			{
				SetTarget( local );
			}
			else
			{
				// 菜单背景演示赛(v0.6.3.0):没有本机球时观战全场最大身体的 bot。
				// 粘滞同死亡观战:当前对象还活着且挑战者没领先 30% → 不换镜头
				var g = CircleroyaleGame.Current;
				var best = g?.TopMassBall();

				if ( _spectate.IsValid() && _spectate.Alive
					&& best.IsValid() && best != _spectate && g is not null )
				{
					if ( g.TotalMassFor( best ) <= g.TotalMassFor( _spectate ) * GameConfig.SpectateOvertakeRatio )
						best = _spectate;
				}

				if ( best.IsValid() )
				{
					_spectate = best;
					var tp = 1f - MathF.Exp( -FollowSpeed * Time.Delta );
					var fp = g.FocusPositionOf( best, out var fm );
					_focus = Vector3.Lerp( _focus, fp, tp );
					_focus.z = 0f;
					if ( Cam.IsValid() )
					{
						Cam.OrthographicHeight = MathX.Lerp( Cam.OrthographicHeight, ViewForMass( fm ),
							1f - MathF.Exp( -ZoomSpeed * Time.Delta ) );
					}
					ApplyPose();
					UpdateTagPositions( g );   // 姿态与变焦都写完,本帧投影一次即可
				}
				else
				{
					ApplyPose();
				}
				return;
			}
		}

		// 死亡观战(M4):本地球阵亡后镜头跟住当前榜首,重生瞬间弹回自己
		var cur = SpectateTarget();

		// 视角跟随最大的分身(v0.6.2.0 用户定稿):焦点与变焦基准都取
		// "主球 + 自己分身"中质量最大的身体
		var game = CircleroyaleGame.Current;
		Vector3 focusPos = cur.WorldPosition;
		float followMass = cur.Mass;
		if ( game is not null )
			focusPos = game.FocusPositionOf( cur, out followMass );

		var t = 1f - MathF.Exp( -FollowSpeed * Time.Delta );
		_focus = Vector3.Lerp( _focus, focusPos, t );
		_focus.z = 0f;

		// 变焦先写(投影用的 OrthoHeight 必须是本帧最终值),再摆位、再投影——每帧一次,
		// 不重复调(ApplyPose 每多调一次 shake 衰减就快一倍,名牌重复投影也是白做)
		if ( Cam.IsValid() )
		{
			var tz = 1f - MathF.Exp( -ZoomSpeed * Time.Delta );
			Cam.OrthographicHeight = MathX.Lerp( Cam.OrthographicHeight, ViewWithCells( cur, followMass ), tz );
		}

		ApplyPose();

		if ( Cam.IsValid() )
			UpdateTagPositions( game );
	}

	/// <summary>
	/// 死亡观战目标:团队赛优先跟**最大的存活队友**(v0.7.8.28 用户定稿——"我死了队伍还在打",
	/// 跟着大哥看战局;队友全灭才回落全场榜首)。带粘滞(v0.6.3.1)——几个目标交替领先时
	/// 不再每次反超都切镜头,挑战者须领先 SpectateOvertakeRatio 才接管。
	/// </summary>
	Ball SpectateTarget()
	{
		if ( _target.Alive )
		{
			if ( _spectate.IsValid() )
			{
				_spectate = null;
				SpectateName = null;
				SnapTo();   // 重生:镜头瞬移回自己(不从榜首位置长距离滑过来)
			}
			return _target;
		}

		var game = CircleroyaleGame.Current;
		Ball best = null;

		// 团队赛队友优先(最大存活队友);没有才看全场榜首
		if ( MatchState.IsTeam && _target.TeamIndex >= 0 && game is not null )
		{
			float bestMass = -1f;
			foreach ( var b in game.Balls )
			{
				if ( !b.IsValid() || !b.Alive || b == _target ) continue;
				if ( b.TeamIndex != _target.TeamIndex ) continue;
				var m = game.TotalMassFor( b );
				if ( m > bestMass ) { bestMass = m; best = b; }
			}
		}
		if ( !best.IsValid() )
			best = game?.TopMassBall();

		// 粘滞:当前观战对象还活着,且挑战者没领先到阈值 → 不换镜头
		if ( _spectate.IsValid() && _spectate.Alive
			&& best.IsValid() && best != _spectate && game is not null )
		{
			if ( game.TotalMassFor( best ) <= game.TotalMassFor( _spectate ) * GameConfig.SpectateOvertakeRatio )
				return _spectate;
		}

		_spectate = best.IsValid() ? best : _target;
		SpectateName = _spectate.IsValid() && _spectate != _target ? _spectate.PlayerName : null;
		return _spectate;
	}

	/// <summary> 当前死亡观战目标名(本机活着=null),HUD 死亡面板提示用 </summary>
	public string SpectateName { get; private set; }

	/// <summary>
	/// 视口高度:按"最大身体"质量取基准;有自家分身时拉远到能盖住最远的那个
	/// (分裂后细胞冲出屏幕外就看不见了,取景要把自家分身包进来)。
	/// 客户端匹配用 OwnerSteamId:分身与主球带同一个同步值,双开合成 ID 也能对上。
	/// </summary>
	float ViewWithCells( Ball cur, float baseMass )
	{
		var view = ViewForMass( baseMass );

		var game = CircleroyaleGame.Current;
		if ( game is null ) return view;

		var sid = cur.OwnerSteamId;
		float maxDist = 0f;
		foreach ( var c in game.Cells )
		{
			if ( c.PieceKind != CellPiece.Kind.SplitPiece || c.OwnerSteamId != sid ) continue;
			maxDist = MathF.Max( maxDist, cur.WorldPosition.Distance( c.DrawPos ) );
		}

		if ( maxDist > 0f )
			view = Math.Clamp( ( cur.Radius + maxDist ) * 2.6f, view, GameConfig.CameraViewMax );

		return view;
	}

	void ApplyPose()
	{
		// 屏幕震动(v0.7.7.0):每次摆位叠加一个随机方向偏移,幅度指数衰减到零——
		// 摆位本身逐帧重写(不含残量),震完自动归位;名牌投影用同一相机位置,抖动同步一致
		var shake = Vector3.Zero;
		if ( _shakeAmp > 0.1f )
		{
			var a = Game.Random.Float( 0f, MathF.PI * 2f );
			shake = new Vector3( MathF.Cos( a ), MathF.Sin( a ), 0f )
				* _shakeAmp * Game.Random.Float( 0.4f, 1f );
			_shakeAmp *= MathF.Exp( -6f * Time.Delta );
		}

		GameObject.WorldPosition = _focus + Vector3.Up * GameConfig.CameraHeight + shake;
		GameObject.WorldRotation = Rotation.LookAt( Vector3.Down, Vector3.Forward );
	}

	/// <summary> 相机姿态刚写完,立刻让 HUD 用同一份相机数据重投名牌(零帧间差) </summary>
	void UpdateTagPositions( CircleroyaleGame game )
	{
		if ( game is null ) return;
		var ortho = Cam.IsValid() ? Cam.OrthographicHeight : 0f;
		game.Hud?.UpdateTagPositions( game, GameObject.WorldPosition, ortho );
	}

	static float ViewForMass( float mass )
	{
		return Math.Clamp( GameConfig.CameraBaseView * MathF.Sqrt( mass / GameConfig.StartMass ),
			GameConfig.CameraViewMin, GameConfig.CameraViewMax );
	}
}
slfy.circleroyale / Rendering/NeonRenderer.cs
Game game
using System;

/// <summary>
/// 像素批渲染(v0.7.8.36 整体替换霓虹线框风):每帧重写各批次顶点,全场实体约 7 个 DrawCall。
/// 贴图批次走 line.shader 的 UV 采样 + 顶点色 tint(D_BLEND=0 普通 alpha):
/// 球/分身/孢子 = ball_body.png 白模 tint;食物 = food.png 六色条带;野刺/尖刺分身 = spike.png;
/// 刺爆弹幕 = spine.png(按速度旋转);道具 = powerups.png 图标叠程序化菱形底座。
/// 表情/头像(v0.7.8.40 从 SpriteRenderer 迁入——sprite 通道画在 OverlayWithDepth 批次之前,
/// 会被实心身体 quad 盖住):bot = face_0..5 按 Mood 选批,真人 = SetAvatar 登记的每 SteamId 贴图批次。
/// 线/三角批次(普通 alpha):椭圆投影、CPU 粒子方块、瞄准/边缘箭头。
/// 特效入口(Pop/Spark/Puff/Swallow)签名不变,调用方零改动。
/// </summary>
public sealed class NeonRenderer : Component
{
	SceneDynamicObject _shapeLines;   // 线段(普通 alpha):道具底座描边/边缘箭头尾杆
	SceneDynamicObject _shapeTris;    // 三角形(普通 alpha):投影/粒子/瞄准箭头/菱形填充
	SceneDynamicObject _bodies;       // ball_body.png:球/分身/孢子身体(顶点色 tint)
	SceneDynamicObject _food;         // food.png:糖豆(UV 取色格)
	SceneDynamicObject _spikes;       // spike.png:野刺 + 尖刺分身
	SceneDynamicObject _spines;       // spine.png:刺爆弹幕(旋转)
	SceneDynamicObject _icons;        // powerups.png:道具图标(UV 取格)
	SceneDynamicObject _rings;        // shield_ring.png:护盾 buff 虚线环(白模 tint,绕球慢转,v0.7.8.87)
	SceneDynamicObject[] _faces;      // face_0..5:bot 表情(Mood 选批,v0.7.8.40)

	/// <summary> ball_body.png 里球体内容占画布比例(process.py fit margin 0.06)——贴图直径→世界直径换算用 </summary>
	const float BodyContentFraction = 0.9375f;

	/// <summary> face_N.png 里脸部内容占画布比例——头像直径→世界直径换算用 </summary>
	const float FaceContentFraction = 0.9f;

	/// <summary>
	/// ⚠️ powerups.png 图集格子顺序是美术定的:speed/magnet/**shield/mass**/spike,
	/// 与 Kind 枚举(…Mass=2, Shield=3…)不同序——直接拿 Kind 当格子号会把盾和质量罐
	/// 画反(v0.7.8.92 用户实测"看到的和捡到的不一样")。下标=Kind,值=图集格子号。
	/// </summary>
	static readonly int[] PowerKindToCell = { 0, 1, 3, 2, 4 };

	sealed class AvatarBatch
	{
		public SceneDynamicObject Dyno;
		public Texture Tex;
		public Material Mat;   // 存档用(诊断读)
	}

	/// <summary> 真人 Steam 头像批次(每 SteamId 一个独立贴图批次) </summary>
	readonly Dictionary<long, AvatarBatch> _avatars = new();

	/// <summary> 头像登记表(Ball.SetAvatar 写,OnUpdate 消费;渲染器未就绪/热重载期间在此待命) </summary>
	static readonly Dictionary<long, Texture> _pendingAvatars = new();

	/// <summary> 全局引用(特效生成入口 NeonRenderer.Pop/Spark/Puff 走静态调用) </summary>
	public static NeonRenderer Instance { get; private set; }

	/// <summary> 帧率表(v0.7.8.104,cr_perf 渲染 A/B 测量用):1 秒窗口平均 </summary>
	public static float DebugFps { get; private set; }
	static int _fpsFrames;
	static TimeSince _fpsWindow = 999f;

	/// <summary> 调试 A/B(cr_perf 渲染减负定位):整体隐藏/恢复本渲染器全部批次 </summary>
	public void SetDebugVisible( bool visible )
	{
		foreach ( var d in new[] { _shapeLines, _shapeTris, _bodies, _food, _spikes, _spines, _icons, _rings } )
			if ( d is not null ) d.RenderingEnabled = visible;
		if ( _faces is not null )
			foreach ( var f in _faces )
				if ( f is not null ) f.RenderingEnabled = visible;
		foreach ( var a in _avatars.Values )
			if ( a.Dyno is not null ) a.Dyno.RenderingEnabled = visible;
	}

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

		Instance = this;
		_shapeLines = CreateShapeBatch();
		_shapeTris = CreateShapeBatch();
		_bodies = CreateTexBatch( "ui/pixel/ball_body.png" );
		_food = CreateTexBatch( "ui/pixel/food.png" );
		_spikes = CreateTexBatch( "ui/pixel/spike.png" );
		_spines = CreateTexBatch( "ui/pixel/spine.png" );
		_icons = CreateTexBatch( "ui/pixel/powerups.png" );
		_rings = CreateTexBatch( "ui/pixel/shield_ring.png" );
		_faces = new SceneDynamicObject[6];
		for ( int i = 0; i < 6; i++ )
			_faces[i] = CreateTexBatch( $"ui/pixel/face_{i}.png", topmost: true );   // 0开心 1兴奋 2惊恐 3眩晕 4瞌睡 5得意
	}

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

		// ⚠️ 世界重建只销毁子 GameObject,不会销毁组件创建的原生动态批次——遗留批次会带着
		// 最后一帧几何继续渲染,且旧材质被回收后引擎回退错误材质:满屏红几何 + 新管线创建崩溃
		// (实测红线/红块 + rendersystemvulkan 固定偏移 0x471a0)。组件销毁时必须显式 Delete。
		DeleteBatch( _shapeLines );
		DeleteBatch( _shapeTris );
		DeleteBatch( _bodies );
		DeleteBatch( _food );
		DeleteBatch( _spikes );
		DeleteBatch( _spines );
		DeleteBatch( _icons );
		DeleteBatch( _rings );
		if ( _faces is not null )
			foreach ( var f in _faces ) DeleteBatch( f );
		foreach ( var av in _avatars.Values ) DeleteBatch( av.Dyno );
		foreach ( var sp in _avatarSpare ) DeleteBatch( sp.Dyno );

		if ( Instance == this ) Instance = null;
	}

	static void DeleteBatch( SceneDynamicObject dyno )
	{
		if ( dyno is null ) return;
		try { dyno.Delete(); } catch { }
	}

	/// <summary> 材质强引用池:材质若只被原生对象引用,托管包装一旦被 GC,原生材质随之回收,
	/// 场景对象就画成引擎错误材质(红几何 = 实测的"会话内跑几分钟变红"主因之一)。创建的材质全部留档。 </summary>
	readonly List<Material> _mats = new();

	Material TrackMat( Material m )
	{
		if ( m is not null ) _mats.Add( m );
		return m;
	}

	SceneDynamicObject CreateShapeBatch( bool topmost = false )
	{
		var dyno = new SceneDynamicObject( Scene.SceneWorld );
		dyno.Material = TrackMat( LineMaterial.Create() );
		dyno.Flags.CastShadows = false;
		// 头像/表情批次放 OverlayWithoutDepth(比 OverlayWithDepth 晚一段绘制,无深度测试):
		// OverlayWithDepth 内部对各 dyno 的排序不受顶点 z 控制(实测表情被 z=1.0 的身体盖住)
		dyno.RenderLayer = topmost ? SceneRenderLayer.OverlayWithoutDepth : SceneRenderLayer.OverlayWithDepth;
		dyno.Attributes.SetCombo( "D_BLEND", 0 );   // 普通 alpha 混合(像素风无加色发光)
		return dyno;
	}

	sealed class TexBatch
	{
		public SceneDynamicObject Dyno;
		public string Path;
		public Texture Tex;
		public Material Mat;   // 存档用(Dyno.Material 只写不可读),DumpTexState 诊断读
	}

	/// <summary> 贴图批次登记(路径留底——热重载重建世界时 Texture.Load 可能瞬时失败,
	/// OnUpdate 里 1s 节流补载自愈,否则整场渲染成错误红纹理,实测) </summary>
	readonly List<TexBatch> _texBatches = new();

	TimeSince _sinceTexHeal = 1f;

	SceneDynamicObject CreateTexBatch( string texturePath, bool topmost = false )
	{
		var dyno = CreateShapeBatch( topmost );

		var batch = new TexBatch { Dyno = dyno, Path = texturePath };
		_texBatches.Add( batch );
		LoadTex( batch );

		return dyno;
	}

	void LoadTex( TexBatch b )
	{
		b.Tex = Texture.Load( b.Path, false );
		if ( b.Tex is null )
			Log.Warning( $"[pixel] texture not ready: {b.Path} (1s 后自动重试)" );

		b.Mat = TrackMat( MakeTexMaterial( b.Tex, pixel: true ) );   // SceneDynamicObject.Material 只写,先备好材质再赋值
		b.Dyno.Material = b.Mat;
	}

	/// <summary> 诊断(临时,红屏排查用):各贴图批次现存贴图 vs 现场重载 vs 材质绑定实况。
	/// 排查完可随 cr_tex 工具一起删。 </summary>
	public static string DumpTexState()
	{
		var inst = Instance;
		if ( inst is null || !inst.IsValid() ) return "no NeonRenderer instance";

		var sb = new System.Text.StringBuilder();
		sb.AppendLine( $"batches={inst._texBatches.Count} avatars={inst._avatars.Count} spare={inst._avatarSpare.Count}" );

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

		foreach ( var b in inst._texBatches )
		{
			var fresh = Texture.Load( b.Path, false );
			var mat = b.Mat;
			var matInfo = mat is null
				? "mat=NULL"
				: $"mat={mat.Name} matValid={mat.IsValid()} shValid={( mat.Shader is null ? "?" : mat.Shader.IsValid().ToString() )}";
			sb.AppendLine( $"{b.Path}: now[{Tx( b.Tex )}] fresh[{Tx( fresh )}] {matInfo}" );
		}

		int n = 0;
		foreach ( var kv in inst._avatars )
		{
			sb.AppendLine( $"avatar {kv.Key}: now[{Tx( kv.Value.Tex )}]" );
			if ( ++n >= 8 ) { sb.AppendLine( $"… +{inst._avatars.Count - n} more" ); break; }
		}

		// 现场探针:此刻新建一个 line.shader 材质,报告材质/着色器/贴图绑定是否可用
		// (红屏 = 所有批次回退错误材质时,这里能直接指出是材质创建失败还是绑定失败)
		var probe = Material.Create( "cr_probe", "shaders/line.shader" );
		if ( probe is null )
			sb.AppendLine( "probe: Material.Create -> NULL" );
		else
		{
			var sh = probe.Shader;
			sb.AppendLine( $"probe: valid={probe.IsValid()} shaderName='{probe.ShaderName}' shader={( sh is null ? "NULL" : sh.IsValid().ToString() )} first={( probe.FirstTexture is null ? "NULL" : "tex" )}" );
			var probeTex = Texture.Load( "ui/pixel/food.png", false );
			bool setOk = false;
			try { setOk = probe.Set( "g_tColor", probeTex ); } catch ( System.Exception e ) { sb.AppendLine( $"probe set threw: {e.GetType().Name}" ); }
			sb.AppendLine( $"probe set g_tColor -> {setOk}" );
		}
		return sb.ToString();
	}

	// —— 临时诊断(红屏排查):每 30s 自检一次"材质链"健康(材质/着色器有效 + 贴图可绑定 +
	//    纹理非错误纹理),只在状态翻转时打日志——故障发生的时刻会自己写进日志文件。排查完删除。——
	static Material _probeMat;
	static int _probeState = -1;   // -1 未初始化;0 OK;1 FAIL
	TimeSince _sinceProbe = 30f;

	void ProbeMaterialHealth()
	{
		if ( _sinceProbe < 30f ) return;
		_sinceProbe = 0;

		if ( _probeMat is null )
			_probeMat = Material.Create( "cr_probe", "shaders/line.shader" );

		var tex = Texture.Load( "ui/pixel/food.png", false );
		bool matOk = _probeMat is not null && _probeMat.IsValid();
		bool shOk = matOk && _probeMat.Shader is not null && _probeMat.Shader.IsValid();
		bool setOk = false;
		if ( matOk ) { try { setOk = _probeMat.Set( "g_tColor", tex ); } catch { } }
		bool texOk = tex is not null && !tex.IsError;
		bool ok = matOk && shOk && setOk && texOk;

		int state = ok ? 0 : 1;
		if ( state != _probeState )
		{
			_probeState = state;
			GameLog.Info( $"[pixel] material probe {( ok ? "OK" : "FAIL" )} (mat={matOk} shader={shOk} set={setOk} tex={texOk})" );
		}
	}

	/// <summary>
	/// 贴图批次材质(static:TerritoryRenderer 的旗/城堡贴图批次同款复用):独立实例。
	/// pixel=true 换 **Point/Nearest 点采样**(v0.7.8.93):像素贴图放大十几倍必须最近邻,
	/// 双线性会把像素颗粒糊掉——旧实现意图就是 point 采样,但 SamplerIndex 写死 0(默认
	/// bilinear 槽位)从未生效。走 line.shader 的 Bindless::GetSampler(SamplerIndex),槽位由
	/// SamplerState.Index 现取(引擎内部有缓存)。头像等照片保持默认 bilinear(点采样会锯齿)。
	/// SamplerIndex 设不进去不致命(回退默认采样,只是糊)。
	/// </summary>
	public static Material MakeTexMaterial( Texture tex, bool pixel = false )
	{
		var mat = LineMaterial.Create();
		try
		{
			if ( pixel )
				mat.Set( "SamplerIndex", PixelSamplerIndex() );
			else
				mat.Set( "SamplerIndex", 0 );
		}
		catch { }
		if ( tex is not null ) mat.Set( "g_tColor", tex );
		return mat;
	}

	static int? _pointSamplerIndex;

	/// <summary> Point 采样的 bindless 槽位(懒取:静态初始化会在渲染设备就绪前跑,专用服务器上会炸) </summary>
	static int PixelSamplerIndex()
	{
		_pointSamplerIndex ??= new Sandbox.Rendering.SamplerState { Filter = Sandbox.Rendering.FilterMode.Point }.Index;
		return _pointSamplerIndex.Value;
	}

	/// <summary> 真人头像登记(Ball 各端自取 Steam 头像后调;每 SteamId 一个独立贴图批次) </summary>
	public static void SetAvatar( long steamId, Texture tex )
	{
		if ( steamId == 0 || tex is null ) return;
		_pendingAvatars[steamId] = tex;
	}

	/// <summary> 消化头像登记:缺批次补建、贴图变了换材质(渲染器未就绪时的登记留到下一帧生效) </summary>
	void SyncAvatars()
	{
		foreach ( var kv in _pendingAvatars )
		{
			if ( _avatars.TryGetValue( kv.Key, out var av ) && av.Tex == kv.Value ) continue;

			if ( av is null )
			{
				av = TakeAvatarBatch();
				_avatars[kv.Key] = av;
			}
			av.Mat = TrackMat( MakeTexMaterial( kv.Value ) );
			av.Dyno.Material = av.Mat;
			av.Tex = kv.Value;
		}
	}

	readonly HashSet<long> _avatarSeen = new();
	readonly List<long> _avatarDrop = new();
	readonly List<AvatarBatch> _avatarSpare = new();   // 回收来复用的空批次(避免现删原生对象)
	TimeSince _sinceAvatarPrune = 5f;

	/// <summary> 取一个可用批次:优先复用回收池,池空才新建(v0.7.8.87) </summary>
	AvatarBatch TakeAvatarBatch()
	{
		if ( _avatarSpare.Count > 0 )
		{
			var spare = _avatarSpare[^1];
			_avatarSpare.RemoveAt( _avatarSpare.Count - 1 );
			return spare;
		}
		return new AvatarBatch { Dyno = CreateShapeBatch( topmost: true ) };
	}

	/// <summary>
	/// 回收不在场玩家的头像批次(v0.7.8.87):`_avatars` 是"每 SteamId 一个动态批次 + 材质"的重资源,
	/// 不回收会随"这台机器见过的玩家总数"一直涨。贴图登记留在静态 `_pendingAvatars`(很轻),
	/// 同一玩家再入场时 SyncAvatars 会复用批次重建。
	/// ⚠️ 只清几何、进回收池,**不调用 SceneObject.Delete()**:批次是"每帧 Clear + 重新 Init"驱动的,
	/// 在游戏循环里销毁原生场景对象风险高(清几何即可让脱管批次不再显示,避免幽灵头像)
	/// </summary>
	void PruneAvatars( CircleroyaleGame game )
	{
		if ( _avatars.Count == 0 || _sinceAvatarPrune < 5f ) return;
		_sinceAvatarPrune = 0;

		_avatarSeen.Clear();
		foreach ( var b in game.Balls )
		{
			if ( b.IsValid() && !b.IsBot ) _avatarSeen.Add( b.OwnerSteamId );   // 死亡待重生的球仍算在场
		}

		_avatarDrop.Clear();
		foreach ( var kv in _avatars )
		{
			if ( !_avatarSeen.Contains( kv.Key ) ) _avatarDrop.Add( kv.Key );
		}

		foreach ( var k in _avatarDrop )
		{
			var av = _avatars[k];
			_avatars.Remove( k );
			if ( av?.Dyno is null ) continue;

			av.Dyno.Clear();   // 清掉最后一帧的几何:脱管后没人再清,留着就是场景里的幽灵头像
			av.Tex = null;
			if ( _avatarSpare.Count < 24 ) _avatarSpare.Add( av );
		}
	}

	protected override void OnUpdate()
	{
		base.OnUpdate();
		if ( !Game.IsPlaying ) return;

		// 帧率表(v0.7.8.104,cr_perf A/B 测量用)
		_fpsFrames++;
		if ( _fpsWindow > 1f )
		{
			DebugFps = _fpsFrames / _fpsWindow;
			_fpsFrames = 0;
			_fpsWindow = 0;
		}

		var game = CircleroyaleGame.Current;
		if ( game is null || _shapeTris is null || _bodies is null || _faces is null ) return;

		_shapeLines.Clear();
		_shapeLines.Init( Graphics.PrimitiveType.Lines );
		_shapeTris.Clear();
		_shapeTris.Init( Graphics.PrimitiveType.Triangles );
		_bodies.Clear();
		_bodies.Init( Graphics.PrimitiveType.Triangles );
		_food.Clear();
		_food.Init( Graphics.PrimitiveType.Triangles );
		_spikes.Clear();
		_spikes.Init( Graphics.PrimitiveType.Triangles );
		_spines.Clear();
		_spines.Init( Graphics.PrimitiveType.Triangles );
		_icons.Clear();
		_icons.Init( Graphics.PrimitiveType.Triangles );
		_rings.Clear();
		_rings.Init( Graphics.PrimitiveType.Triangles );

		SyncAvatars();
		PruneAvatars( game );

		// 贴图自愈(1s 节流):热重载/资产刷新期间加载失败的批次补载
		if ( _sinceTexHeal > 1f )
		{
			_sinceTexHeal = 0;
			foreach ( var b in _texBatches )
			{
				if ( b.Tex is not null ) continue;
				LoadTex( b );
			}
		}

		ProbeMaterialHealth();

		foreach ( var f in _faces )
		{
			f.Clear();
			f.Init( Graphics.PrimitiveType.Triangles );
		}
		foreach ( var av in _avatars.Values )
		{
			av.Dyno.Clear();
			av.Dyno.Init( Graphics.PrimitiveType.Triangles );
		}

		UpdateParticles();
		UpdateWallPulses( Time.Delta );

		// ---- 球:椭圆投影 + tint 白模 + 出生保护闪烁 ----
		foreach ( var ball in game.Balls )
		{
			if ( !ball.IsValid() || !ball.Alive ) continue;

			var c = ball.WorldPosition;
			var r = ball.Radius;

			// 投影画在脚下:屏幕下方 = 世界 -X(相机 90° 滚转),横长竖扁
			AddShadowEllipse( c + new Vector3( -r * 0.12f, 0f, 0.25f ), r * 0.32f, r * 0.72f );

			// 出生保护:整球方波闪烁(暗相 35%),闪烁在身体 tint 上,头像保持可见
			var blink = 1f;
			if ( ball.IsProtected )
				blink = MathF.Sin( Time.Now * GameConfig.SpawnBlinkHz * MathF.PI * 2f ) > 0f ? 1f : 0.35f;
			var tint = new Color( ball.NeonColor.r, ball.NeonColor.g, ball.NeonColor.b, blink );

			AddQuad( _bodies, c, r / BodyContentFraction, r / BodyContentFraction, 0f,
				new Vector4( 0f, 0f, 1f, 1f ), tint, 1.0f );

			// 护盾 buff(M6.5):白模虚线环染道具蓝,绕球慢转(v0.7.8.87 接 shield_ring.png)
			if ( ball.HasBuff( PowerUpManager.Kind.Shield ) )
				AddQuad( _rings, c, r * 1.32f, r * 1.32f, Time.Now * 0.7f,
					new Vector4( 0f, 0f, 1f, 1f ), PowerUpManager.ColorOf( (byte)PowerUpManager.Kind.Shield ), 1.05f );

			// 头像(v0.7.8.40):bot=表情脸(Mood 选批),真人=Steam 头像;直径=半径,画在身体之上
			DrawAvatar( c, r, ball.IsBot, ball.Mood, ball.OwnerSteamId, 1.1f );

			// 瞄准箭头(v0.7.0.0):本机球外缘白色小三角指向光标,仅对局中显示
			if ( game.IsMatchStarted && ball == game.LocalBall && ball.AimDir.Length > 0.01f )
				WriteAimArrow( ball );
		}

		// ---- 分身/孢子/尖刺分身(纯数据实体):tint 白模 / 刺贴图,出生弹跳保留 ----
		foreach ( var c in game.Cells )
		{
			var color = GameConfig.Palette[Math.Clamp( c.ColorIndex, 0, GameConfig.Palette.Length - 1 )];
			var pos = new Vector3( c.DrawPos.x, c.DrawPos.y, 0f );
			var r = c.Radius;

			// 出生弹跳(v0.7.8.28 juice):新 id 首帧记时刻,0.3s 内缩放脉冲。客户端镜像用"首见 id"判定,双端统一
			if ( !_seenCells.Contains( c.Id ) )
			{
				_seenCells.Add( c.Id );
				_cellBorn[c.Id] = 0;
				if ( _seenCells.Count > 512 ) PruneCellFx( game );
			}
			if ( _cellBorn.TryGetValue( c.Id, out var born ) && born < 0.3f )
			{
				var phase = born / 0.3f;
				r *= phase < 0.3f
					? MathX.Lerp( 0.55f, 1.14f, phase / 0.3f )
					: MathX.Lerp( 1.14f, 1f, ( phase - 0.3f ) / 0.7f );
			}

			if ( c.PieceKind == CellPiece.Kind.SpikeMinion )
			{
				// 尖刺分身(v0.7.4.0):刺族贴图(紫色=是刺),尺寸不小于野刺观感
				var sr = MathF.Max( r, 40f );
				AddQuad( _spikes, pos, sr, sr, 0f, new Vector4( 0f, 0f, 1f, 1f ), Color.White, 0.65f );
			}
			else
			{
				// 分身/孢子:同一张白模 tint(孢子略缩,观感=小糖豆团)
				var scale = c.PieceKind == CellPiece.Kind.EjectedMass ? 0.85f : 1f;
				var half = r * scale / BodyContentFraction;
				var z = c.PieceKind == CellPiece.Kind.EjectedMass ? 0.8f : 0.9f;
				AddQuad( _bodies, pos, half, half, 0f, new Vector4( 0f, 0f, 1f, 1f ), color, z );

				// 分身头像(v0.7.8.40 自 PieceAvatarLayer 并入):只给 SplitPiece,直径=分身半径
				if ( c.PieceKind == CellPiece.Kind.SplitPiece )
				{
					var owner = game.FindBallBySteamId( c.OwnerSteamId );
					DrawAvatar( pos, r, owner.IsValid() && owner.IsBot,
						owner.IsValid() ? owner.Mood : (byte)0, c.OwnerSteamId, 0.95f );
				}
			}
		}

		// ---- 野刺:喂食长大后直接缩放贴图 ----
		var spikes = game.Spikes;
		if ( spikes is not null )
		{
			for ( int i = 0; i < spikes.Length; i++ )
			{
				var grow = 1f + GameConfig.SpikeGrowVisual * Math.Clamp( game.SpikeFedAt( i ) / GameConfig.SpikeGrowTarget, 0f, 1f );
				var half = GameConfig.SpikeRadius * grow;
				AddQuad( _spikes, new Vector3( spikes[i].x, spikes[i].y, 0f ), half, half, 0f,
					new Vector4( 0f, 0f, 1f, 1f ), Color.White, 0.6f );
			}
		}

		// ---- 刺爆弹幕(v0.7.8.24):刺弹贴图按速度方向旋转,随寿命淡出 ----
		foreach ( var sp in game.Spines )
		{
			var remain = 1f - Math.Clamp( sp.SinceSpawn / GameConfig.SpineLife, 0f, 1f );
			var dir = sp.Vel;
			if ( dir.Length < 0.001f ) continue;
			var rot = MathF.Atan2( dir.y, dir.x );
			var tint = new Color( 1f, 1f, 1f, 0.35f + 0.65f * remain );
			AddQuad( _spines, new Vector3( sp.Pos.x, sp.Pos.y, 0f ), 17f, 17f, rot,
				new Vector4( 0f, 0f, 1f, 1f ), tint, 1.2f );
		}

		// ---- 道具(v0.7.3.0):旋转菱形底座(普通 alpha)+ 种类图标贴图 ----
		var power = game.PowerUps;
		if ( power is not null && power.Ready )
		{
			var spin = Time.Now * 1.6f;
			var breathe = 1f + MathF.Sin( Time.Now * 3f ) * 0.12f;

			foreach ( var s in power.Slots )
			{
				if ( !s.Alive ) continue;

				var pos = new Vector3( s.Pos.x, s.Pos.y, 0f );
				var kindColor = PowerUpManager.ColorOf( s.Kind );
				var r = GameConfig.PowerUpRadius * breathe;

				WriteDiamond( pos, r, spin, kindColor );

				var k = PowerKindToCell[Math.Clamp( s.Kind % 5, 0, 4 )];
				var uv = new Vector4( k / 5f, 0f, ( k + 1 ) / 5f, 1f );
				AddQuad( _icons, pos + Vector3.Up * 1.3f, r * 1.35f, r * 1.35f, MathF.PI * 0.5f, uv, Color.White, 1.3f );
			}
		}

		// ---- 食物:糖豆贴图按色号取 UV 格(视口剔除保留) ----
		var foods = game.Food?.Foods;
		if ( foods is not null )
		{
			// 视口剔除(v0.6.3.0):4140 颗食物只有 ~20% 在画面里。
			// ⚠️ 轴向(v0.7.8.87 修):相机固定 90° 滚转——屏幕横向=世界 Y、屏幕纵向=世界 X
			// (见 GameHud.UpdateTagPositions 同一套投影式),所以"按宽高比放开的横向余量"必须给 Y、
			// 窄余量给 X。此前两轴写反:屏幕左右两侧仍可视范围内的食物被剔掉(观感就是缺食物)
			var fr = GameConfig.FoodRadius;
			var cullCx = Scene.Camera.WorldPosition.x;
			var cullCy = Scene.Camera.WorldPosition.y;
			var cullAspect = MathF.Max( 2.4f, Screen.Height > 0f ? Screen.Width / Screen.Height : 2.4f );
			var cullHx = Scene.Camera.OrthographicHeight * 0.5f + 64f;                 // 世界 X = 屏幕纵向(窄)
			var cullHy = Scene.Camera.OrthographicHeight * 0.5f * cullAspect + 64f;    // 世界 Y = 屏幕横向(宽,超宽屏不缺左右食物)

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

				var fx = foods[i].Pos.x;
				var fy = foods[i].Pos.y;
				if ( MathF.Abs( fx - cullCx ) > cullHx || MathF.Abs( fy - cullCy ) > cullHy ) continue;

				var k = foods[i].ColorIndex % 6;
				var uv = new Vector4( k / 6f, 0f, ( k + 1 ) / 6f, 1f );
				AddQuad( _food, new Vector3( fx, fy, 0f ), fr / 0.95f, fr / 0.95f, 0f, uv, Color.White, 0.55f );
			}
		}

		DrawParticles();
		DrawWallPulses();
		DrawEdgeArrows();
	}

	// ---- 屏幕边缘队友指示箭头(v0.7.8.12):GameHud 名牌重投时写入请求,本组件 OnUpdate 末尾消费 ----

	sealed class EdgeArrowReq
	{
		public Vector3 Pos;      // 贴边点(世界)
		public Vector2 Dir;      // 指向队友真实方位(世界,单位向量)
		public Color Color;
		public float Size;       // 世界单位(按每像素世界单位折算,保持像素大小恒定)
	}

	static readonly List<EdgeArrowReq> _edgeArrows = new();

	/// <summary> 请求一支边缘箭头(贴边世界点 + 指向 + 颜色)。OnUpdate 消费后清空,晚一帧无感 </summary>
	public static void EdgeArrow( Vector3 pos, Vector2 dir, Color color, float size )
	{
		_edgeArrows.Add( new EdgeArrowReq { Pos = pos, Dir = dir, Color = color, Size = size } );
	}

	/// <summary> 实心三角箭头 + 短尾杆,朝向 Dir </summary>
	void DrawEdgeArrows()
	{
		for ( int i = 0; i < _edgeArrows.Count; i++ )
		{
			var r = _edgeArrows[i];
			var d = new Vector3( r.Dir.x, r.Dir.y, 0f );
			var perp = new Vector3( -r.Dir.y, r.Dir.x, 0f );
			var len = r.Size;
			var halfW = len * 0.5f;

			var tip = r.Pos + d * len;
			var b1 = r.Pos + perp * halfW;
			var b2 = r.Pos - perp * halfW;

			AddTri( _shapeTris, b1, b2, tip, r.Color * 0.9f );

			// 尾杆:从箭头向屏内延伸一小段,提示"方向"而非"位置点"
			var t0 = r.Pos - d * len * 0.6f;
			_shapeLines.AddVertex( new Vertex( t0, r.Color * 0.5f ) );
			_shapeLines.AddVertex( new Vertex( r.Pos, r.Color * 0.5f ) );
		}
		_edgeArrows.Clear();
	}

	// ---- CPU 粒子(M3 表现力):普通 alpha 批次方块粒子 ----

	/// <summary> 瞄准箭头(v0.7.0.0):本机球外缘的白色实心小三角,指向光标(分裂/吐孢子方向) </summary>
	void WriteAimArrow( Ball ball )
	{
		var dir = ball.AimDir;
		var c = ball.WorldPosition;
		var r = ball.Radius;

		var len = Math.Clamp( r * 0.22f, 18f, 64f );   // 箭头长度随球径微缩放
		var halfW = len * 0.42f;
		var gap = r * 1.04f;

		var dir3 = new Vector3( dir.x, dir.y, 0f );
		var perp = new Vector3( -dir.y, dir.x, 0f );

		var baseC = c + dir3 * gap;
		var tip = c + dir3 * ( gap + len );
		var p1 = baseC + perp * halfW;
		var p2 = baseC - perp * halfW;

		AddTri( _shapeTris, p1, p2, tip, new Color( 1f, 1f, 1f, 0.55f ) );
	}

	/// <summary> 单个实心三角 </summary>
	void AddTri( SceneDynamicObject batch, Vector3 a, Vector3 b, Vector3 c, Color color )
	{
		batch.AddVertex( new Vertex( a, color ) );
		batch.AddVertex( new Vertex( b, color ) );
		batch.AddVertex( new Vertex( c, color ) );
	}

	/// <summary> 贴图四边形(两个三角):center 中心、half 半宽/半高、rot 弧度、uv=(x0,y0,x1,y1) </summary>
	static void AddQuad( SceneDynamicObject b, Vector3 center, float halfX, float halfY, float rot, Vector4 uv, Color color, float z )
	{
		var cos = MathF.Cos( rot );
		var sin = MathF.Sin( rot );
		var rx = cos * halfX; var ry = sin * halfX;      // right 轴
		var ux = -sin * halfY; var uy = cos * halfY;     // up 轴

		var v0 = new Vertex( new Vector3( center.x - rx - ux, center.y - ry - uy, z ), new Vector4( uv.x, uv.y, 0f, 0f ), color );
		var v1 = new Vertex( new Vector3( center.x + rx - ux, center.y + ry - uy, z ), new Vector4( uv.z, uv.y, 0f, 0f ), color );
		var v2 = new Vertex( new Vector3( center.x + rx + ux, center.y + ry + uy, z ), new Vector4( uv.z, uv.w, 0f, 0f ), color );
		var v3 = new Vertex( new Vector3( center.x - rx + ux, center.y - ry + uy, z ), new Vector4( uv.x, uv.w, 0f, 0f ), color );

		b.AddVertex( v0 ); b.AddVertex( v1 ); b.AddVertex( v2 );
		b.AddVertex( v0 ); b.AddVertex( v2 ); b.AddVertex( v3 );
	}

	/// <summary> 头像/表情:直径=球半径(占直径 50%,用户指定);bot 从 face_N 批次取(Mood 选格),
	/// 真人从 Steam 头像批次取。批次在 OverlayWithoutDepth 层,天然画在身体(OverlayWithDepth)之上。
	/// rot=π/2:相机 90° 滚转下贴图 u 轴朝屏幕竖直,不转的话脸是横躺的(食物/球体等圆形看不出来) </summary>
	void DrawAvatar( Vector3 c, float radius, bool bot, byte mood, long steamId, float z )
	{
		var half = radius * 0.5f / FaceContentFraction;

		if ( bot )
		{
			var k = Math.Clamp( (int)mood, 0, 5 );
			AddQuad( _faces[k], c, half, half, MathF.PI * 0.5f, new Vector4( 0f, 0f, 1f, 1f ), Color.White, z );
		}
		else if ( _avatars.TryGetValue( steamId, out var av ) )
		{
			AddQuad( av.Dyno, c, half, half, MathF.PI * 0.5f, new Vector4( 0f, 0f, 1f, 1f ), Color.White, z );
		}
	}

	/// <summary> 椭圆投影(世界 XY 平面:ax=世界 X 半轴=屏幕竖向,ay=世界 Y 半轴=屏幕横向) </summary>
	void AddShadowEllipse( Vector3 center, float ax, float ay, int segments = 14 )
	{
		segments = Math.Max( 6, segments );
		var prev = center + new Vector3( ax, 0f, 0f );
		for ( int i = 1; i <= segments; i++ )
		{
			var a = i * MathF.PI * 2f / segments;
			var p = center + new Vector3( MathF.Cos( a ) * ax, MathF.Sin( a ) * ay, 0f );
			AddTri( _shapeTris, center, prev, p, new Color( 0.59f, 0.67f, 0.82f, 0.30f ) );
			prev = p;
		}
	}

	sealed class Particle
	{
		public Vector3 Pos;
		public Vector3 Vel;
		public float Life;
		public float MaxLife;
		public float Size;
		public Color Color;
		public Vector3 Target;   // Seek=true:吸引目标(吞球滑进吃者嘴里)
		public bool Seek;
	}

	readonly List<Particle> _particles = new();
	readonly HashSet<int> _seenCells = new();                    // 出生弹跳:见过的分身/孢子 id(客户端镜像不带 SinceSpawn,用"首见"双端统一判定)
	readonly Dictionary<int, TimeSince> _cellBorn = new();

	/// <summary> 爆裂(死亡/撞刺):中大粒子径向飞散 </summary>
	public static void Pop( Vector3 pos, int colorIndex, float radius )
	{
		Spawn( pos, colorIndex, 26, 90f + radius * 0.9f, 0.45f, 0.9f, 3f, 9f );
	}

	/// <summary> 火花(吃食物):几颗小碎屑一闪 </summary>
	public static void Spark( Vector3 pos, int colorIndex )
	{
		Spawn( pos, colorIndex, 5, 100f, 0.2f, 0.4f, 2f, 4f );
	}

	/// <summary> 火花(自定义色,道具拾取用):种类色不在调色板里,直接给 Color </summary>
	public static void Spark( Vector3 pos, Color color )
	{
		SpawnColored( pos, color, 10, 140f, 0.25f, 0.5f, 2f, 5f );
	}

	/// <summary> 喷发(分裂/吐孢子生成瞬间) </summary>
	public static void Puff( Vector3 pos, int colorIndex )
	{
		Spawn( pos, colorIndex, 8, 150f, 0.25f, 0.5f, 2f, 5f );
	}

	/// <summary> 吞球滑入(v0.7.8.28 juice):被吃者缩成一团、加速滑进吃者嘴里,尺寸随寿命收缩归零 </summary>
	public static void Swallow( Vector3 pos, int colorIndex, Vector3 target, float radius )
	{
		var inst = Instance;
		if ( inst is null || !inst.IsValid() ) return;

		if ( inst._particles.Count >= GameConfig.FxMaxParticles )
			inst._particles.RemoveAt( 0 );

		var life = 0.3f;
		inst._particles.Add( new Particle
		{
			Pos = pos,
			Target = target,
			Seek = true,
			Life = life,
			MaxLife = life,
			Size = MathF.Max( 10f, radius * 0.9f ),
			Color = GameConfig.Palette[Math.Clamp( colorIndex, 0, GameConfig.Palette.Length - 1 )],
		} );
		Spawn( pos, colorIndex, 4, 120f, 0.15f, 0.3f, 2f, 4f );   // 少量碎屑点缀
	}

	static void Spawn( Vector3 pos, int colorIndex, int count, float maxSpeed, float minLife, float maxLife, float minSize, float maxSize )
	{
		SpawnColored( pos, GameConfig.Palette[Math.Clamp( colorIndex, 0, GameConfig.Palette.Length - 1 )],
			count, maxSpeed, minLife, maxLife, minSize, maxSize );
	}

	static void SpawnColored( Vector3 pos, Color color, int count, float maxSpeed, float minLife, float maxLife, float minSize, float maxSize )
	{
		var inst = Instance;
		if ( inst is null || !inst.IsValid() ) return;

		for ( int i = 0; i < count; i++ )
		{
			if ( inst._particles.Count >= GameConfig.FxMaxParticles )
				inst._particles.RemoveAt( 0 );   // 挤掉最老的,保帧率优先

			var a = Game.Random.Float( 0f, MathF.PI * 2f );
			var sp = maxSpeed * Game.Random.Float( 0.35f, 1f );
			var life = Game.Random.Float( minLife, maxLife );
			inst._particles.Add( new Particle
			{
				Pos = pos + new Vector3( Game.Random.Float( -6f, 6f ), Game.Random.Float( -6f, 6f ), 0f ),
				Vel = new Vector3( MathF.Cos( a ) * sp, MathF.Sin( a ) * sp, 0f ),
				Life = life,
				MaxLife = life,
				Size = Game.Random.Float( minSize, maxSize ),
				Color = color,
			} );
		}
	}

	void UpdateParticles()
	{
		var dt = Time.Delta;
		for ( int i = _particles.Count - 1; i >= 0; i-- )
		{
			var p = _particles[i];
			p.Life -= dt;
			if ( p.Life <= 0f )
			{
				_particles.RemoveAt( i );
				continue;
			}
			if ( p.Seek )
			{
				p.Pos = Vector3.Lerp( p.Pos, p.Target, 1f - MathF.Exp( -11f * dt ) );   // 指数吸向吃者嘴
			}
			else
			{
				p.Pos += p.Vel * dt;
				p.Vel *= MathF.Exp( -3.5f * dt );   // 阻尼,飞散后减速悬浮消散
			}
		}
	}

	/// <summary> 方块粒子(像素风):轴对齐小方块,尺寸/透明度随寿命衰减 </summary>
	void DrawParticles()
	{
		foreach ( var p in _particles )
		{
			var a = p.Life / p.MaxLife;
			var size = p.Size * ( 0.5f + a * 0.5f );
			var col = new Color( p.Color.r, p.Color.g, p.Color.b, ( 0.25f + a * 0.75f ) * 0.9f );
			AddQuad( _shapeTris, p.Pos, size * 0.5f, size * 0.5f, 0f, new Vector4( 0f, 0f, 1f, 1f ), col, 1.4f );
		}
	}

	// ---- 撞墙脉冲(v0.7.8.78):球被围墙钳住时,接触点一圈小方块向外扩散淡出——"墙挡住了"的反馈 ----

	const float WallPulseLife = 0.4f;

	sealed class WallPulseFx
	{
		public Vector3 Pos;
		public float Age;
		public Color Color;
	}

	readonly List<WallPulseFx> _wallPulses = new();

	/// <summary> 围墙受击脉冲:接触点 + 撞球颜色(程序化方块环;日后可换贴图环) </summary>
	public static void WallPulse( Vector3 pos, Color color )
	{
		var inst = Instance;
		if ( inst is null || !inst.IsValid() ) return;

		if ( inst._wallPulses.Count >= 24 ) inst._wallPulses.RemoveAt( 0 );
		inst._wallPulses.Add( new WallPulseFx { Pos = pos, Age = 0f, Color = color } );
	}

	void UpdateWallPulses( float dt )
	{
		for ( int i = _wallPulses.Count - 1; i >= 0; i-- )
		{
			_wallPulses[i].Age += dt;
			if ( _wallPulses[i].Age >= WallPulseLife ) _wallPulses.RemoveAt( i );
		}
	}

	void DrawWallPulses()
	{
		foreach ( var w in _wallPulses )
		{
			var t = w.Age / WallPulseLife;
			var r = 8f + t * 54f;                              // 半径外扩
			var alpha = ( 1f - t ) * 0.85f;                    // 渐隐
			var size = 8f - t * 3f;                            // 方块随扩散略缩
			var col = new Color( w.Color.r, w.Color.g, w.Color.b, alpha );

			for ( int s = 0; s < 14; s++ )
			{
				var a = s * MathF.PI * 2f / 14f;
				var p = w.Pos + new Vector3( MathF.Cos( a ) * r, MathF.Sin( a ) * r, 0f );
				AddQuad( _shapeTris, p, size * 0.5f, size * 0.5f, 0f, new Vector4( 0f, 0f, 1f, 1f ), col, 1.4f );
			}
		}
	}

	/// <summary> 出生弹跳注册表超限时整体重建(当前活着的 id 重新立册,重置瞬间轻微再弹一次,罕见可接受) </summary>
	void PruneCellFx( CircleroyaleGame game )
	{
		_seenCells.Clear();
		_cellBorn.Clear();
		foreach ( var c in game.Cells )
			_seenCells.Add( c.Id );
	}

	/// <summary> 道具菱形底座:旋转的正方形(半透明填充 + 描边),像素风下作图标的衬底 </summary>
	void WriteDiamond( Vector3 c, float r, float spin, Color color )
	{
		var pts = new Vector3[4];
		for ( int i = 0; i < 4; i++ )
		{
			var a = spin + i * MathF.PI * 0.5f;
			pts[i] = c + new Vector3( MathF.Cos( a ) * r, MathF.Sin( a ) * r, 0.7f );
		}

		for ( int i = 0; i < 4; i++ )
		{
			AddTri( _shapeTris, c + Vector3.Up * 0.7f, pts[i], pts[( i + 1 ) % 4], color * 0.35f );
			_shapeLines.AddVertex( new Vertex( pts[i], color ) );
			_shapeLines.AddVertex( new Vertex( pts[( i + 1 ) % 4], color ) );
		}
	}
}
slfy.circleroyale / Rendering/TerritoryRenderer.cs
Game game
using System;

/// <summary>
/// 领土战争地面渲染(M7,v0.7.8.79 像素风重写):占领格 = 队色底 + 粗边框 + 旗点旗子(flag.png
/// 白模染队色),大本营 = 城堡(castle.png 白模染队色);争夺中的中立格 = 白边框 + 争夺方色进度条;
/// 分数越高底色越实。状态版本变化才重建几何(16 格顶点量极小);贴图批次 1s 自愈补载
/// (NeonRenderer 同款)。CreateBackdrop 先于 Neon 建本组件 → 同层绘制在球/食物之下。非领土模式清空。
/// ⚠️ 相机 90° 滚转:旗/城堡这类有方向的贴图必须 rot=π/2 才屏幕正立(表情/头像同款教训)。
/// </summary>
public sealed class TerritoryRenderer : Component
{
	/// <summary> 全局引用(cr_perf 渲染 A/B 用;NeonRenderer.Instance 同款) </summary>
	public static TerritoryRenderer Instance { get; private set; }

	SceneDynamicObject _shape;    // 格底/粗边框/进度条(普通 alpha 三角形)
	SceneDynamicObject _flags;    // flag.png(白模 tint)
	SceneDynamicObject _castles;  // castle.png(白模 tint)
	int _builtVersion = -1;
	float _builtHalf = -1f;
	TimeSince _sinceRebuild = 999f;   // 重建节流(喂旗高峰 host 5Hz 快照,全清重写三批几何过频会闪)

	sealed class TexBatch
	{
		public SceneDynamicObject Dyno;
		public string Path;
		public Texture Tex;
		public Material Mat;   // 材质强引用(托管包装被 GC 会连带回收原生材质 → 红几何,实测)
	}

	/// <summary> 材质强引用池:材质只被原生对象引用时,托管包装被 GC 后原生材质随之回收,
	/// 场景对象画成引擎错误材质(红几何)。所有创建的材质在此留档。 </summary>
	readonly List<Material> _mats = new();

	Material TrackMat( Material m )
	{
		if ( m is not null ) _mats.Add( m );
		return m;
	}

	readonly List<TexBatch> _texBatches = new();
	TimeSince _sinceTexHeal = 1f;

	/// <summary> 调试 A/B(v0.7.8.104,cr_perf 渲染减负定位):隐藏/恢复全部领土批次
	/// (组件 Enabled 关不掉已构建的原生批次——SceneObject.RenderingEnabled 才是真开关) </summary>
	public void SetDebugVisible( bool visible )
	{
		if ( _shape is not null ) _shape.RenderingEnabled = visible;
		if ( _flags is not null ) _flags.RenderingEnabled = visible;
		if ( _castles is not null ) _castles.RenderingEnabled = visible;
		foreach ( var b in _texBatches )
			if ( b.Dyno is not null ) b.Dyno.RenderingEnabled = visible;
	}

	const float BorderThickness = 18f;         // 格边框粗细(世界单位,像素粗边观感)
	const float FlagWorldHeight = 400f;        // 旗子世界高度(约 1/7.5 格宽)
	const float CastleWorldFraction = 0.30f;   // 城堡占格宽比例
	const float BarWidth = 380f;               // 进度条宽
	const float BarHeight = 36f;               // 进度条高
	const float FlagTexAspect = 32f / 52f;     // flag.png 宽高比

	protected override void OnStart()
	{
		base.OnStart();
		Instance = this;
		_shape = CreateShapeBatch();
		_flags = CreateTexBatch( "ui/pixel/flag.png" );
		_castles = CreateTexBatch( "ui/pixel/castle.png" );
	}

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

		// 组件销毁时必须显式删除原生批次:世界重建只销毁子 GameObject,遗留批次会带最后一帧
		// 几何继续渲染,旧材质回收后回退错误材质——满屏红几何 + Vulkan 管线崩溃的元凶(实测)
		DeleteBatch( _shape );
		DeleteBatch( _flags );
		DeleteBatch( _castles );
	}

	static void DeleteBatch( SceneDynamicObject dyno )
	{
		if ( dyno is null ) return;
		try { dyno.Delete(); } catch { }
	}

	protected override void OnUpdate()
	{
		base.OnUpdate();
		if ( !Game.IsPlaying ) return;

		var half = GameConfig.ArenaHalfSize;

		if ( TerritoryManager.Active )
		{
			// 重建节流(≥0.25s):喂旗高峰 host 5Hz 快照,全清重写三批几何过频会闪;
			// 错过的版本不追,下一拍自然画上(进度差 1/5 格分不清)
			if ( ( _builtVersion != TerritoryManager.StateVersion || MathF.Abs( _builtHalf - half ) > 0.5f )
				&& _sinceRebuild > 0.25f )
				Rebuild( half );
		}
		else if ( _builtVersion >= 0 )
		{
			Clear();
		}

		// 贴图自愈(1s 节流):热重载/资产刷新期间加载失败补载,否则整场错误红纹理(NeonRenderer 实测)
		if ( _sinceTexHeal > 1f )
		{
			_sinceTexHeal = 0;
			foreach ( var b in _texBatches )
			{
				if ( b.Tex is not null ) continue;
				LoadTex( b );
			}
		}
	}

	void Clear()
	{
		_builtVersion = -1;
		if ( _shape.IsValid() ) _shape.Clear();
		if ( _flags.IsValid() ) _flags.Clear();
		if ( _castles.IsValid() ) _castles.Clear();
	}

	SceneDynamicObject CreateShapeBatch()
	{
		var dyno = new SceneDynamicObject( Scene.SceneWorld );
		dyno.Material = TrackMat( LineMaterial.Create() );
		dyno.Flags.CastShadows = false;
		dyno.RenderLayer = SceneRenderLayer.OverlayWithDepth;
		dyno.Attributes.SetCombo( "D_BLEND", 0 );   // 像素风普通 alpha(加色发光已随霓虹风废弃)
		return dyno;
	}

	SceneDynamicObject CreateTexBatch( string path )
	{
		var b = new TexBatch { Dyno = CreateShapeBatch(), Path = path };
		_texBatches.Add( b );
		LoadTex( b );
		return b.Dyno;
	}

	void LoadTex( TexBatch b )
	{
		b.Tex = Texture.Load( b.Path, false );
		if ( b.Tex is null )
			Log.Warning( $"[pixel] texture not ready: {b.Path} (1s 后自动重试)" );
		b.Mat = TrackMat( NeonRenderer.MakeTexMaterial( b.Tex, pixel: true ) );   // Material 只写:先备好再赋
		b.Dyno.Material = b.Mat;
	}

	void Rebuild( float half )
	{
		_builtVersion = TerritoryManager.StateVersion;
		_builtHalf = half;
		_sinceRebuild = 0;

		// 诊断(临时,客户端 HUD 爆闪排查):记录每次重建的时刻与版本,用 Log(客户端日志也打)
		Log.Info( $"[terr] rebuild v={TerritoryManager.StateVersion} half={half:0}" );

		_shape.Clear();
		_shape.Init( Graphics.PrimitiveType.Triangles );
		_flags.Clear();
		_flags.Init( Graphics.PrimitiveType.Triangles );
		_castles.Clear();
		_castles.Init( Graphics.PrimitiveType.Triangles );

		var grid = GameConfig.TerritoryGrid;
		var s = TerritoryManager.CellSize;
		var flagHalfH = FlagWorldHeight * 0.5f;
		var flagHalfW = flagHalfH * FlagTexAspect;
		var castleHalf = s * CastleWorldFraction * 0.5f;

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

				var x0 = -half + cx * s;
				var x1 = -half + ( cx + 1 ) * s;
				var y0 = -half + cy * s;
				var y1 = -half + ( cy + 1 ) * s;
				var isHq = IsHq( cx, cy );

				// 主色:归属方队色;争夺中中立=白边框+争夺方进度;无人问津=灰旗
				Color teamColor;
				bool contested = false;
				if ( owner >= 0 )
					teamColor = MatchState.TeamColor( owner );
				else if ( scoreTeam >= 0 && score > 0f )
				{
					teamColor = MatchState.TeamColor( scoreTeam );
					contested = true;
				}
				else
					teamColor = new Color( 0.62f, 0.65f, 0.73f );

				if ( owner >= 0 || contested )
				{
					// 格底:越满越实(争夺中更淡——还没到手)
					var fillA = owner >= 0 ? 0.16f + 0.18f * frac : 0.07f + 0.10f * frac;
					var fill = new Color( teamColor.r, teamColor.g, teamColor.b, fillA );
					AddQuadShape( x0 + BorderThickness, y0 + BorderThickness, x1 - BorderThickness, y1 - BorderThickness, 0.20f, fill );

					// 粗边框(像素领地感;争夺中=白色虚位以待)
					var lineC = owner >= 0
						? new Color( teamColor.r, teamColor.g, teamColor.b, 0.85f )
						: new Color( 1f, 1f, 1f, 0.55f );
					AddBorder( x0, y0, x1, y1, 0.30f, lineC );

					// 进度条(驻军/占领进度,旗/城堡下方)
					if ( frac > 0.01f )
					{
						var barTop = center.y + FlagWorldHeight * 0.62f + BarHeight;
						AddQuadShape( center.x - BarWidth * 0.5f, barTop - BarHeight, center.x + BarWidth * 0.5f, barTop, 0.35f,
							new Color( 0.16f, 0.15f, 0.22f, 0.5f ) );
						AddQuadShape( center.x - BarWidth * 0.5f, barTop - BarHeight, center.x - BarWidth * 0.5f + BarWidth * frac, barTop, 0.35f,
							new Color( teamColor.r, teamColor.g, teamColor.b, 0.95f ) );
					}
				}

				// 旗/城堡:大本营=城堡,普通格=旗子(rot=π/2 对齐屏幕正立)
				Color spriteTint = owner >= 0 ? new Color( teamColor.r, teamColor.g, teamColor.b, 1f )
					: contested ? new Color( 1f, 1f, 1f, 1f )
					: new Color( 0.72f, 0.74f, 0.82f, 0.45f );

				if ( isHq )
					AddTexQuad( _castles, center, castleHalf, castleHalf, MathF.PI * 0.5f, spriteTint, 0.52f );
				else
					AddTexQuad( _flags, center, flagHalfW, flagHalfH, MathF.PI * 0.5f, spriteTint, 0.52f );
			}
		}
	}

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

	// ---- 顶点写入(shape=纯色 quad;tex=UV quad,写法同 NeonRenderer.AddQuad)----

	void AddQuadShape( float x0, float y0, float x1, float y1, float z, Color color )
	{
		var a = new Vertex( new Vector3( x0, y0, z ), color );
		var b = new Vertex( new Vector3( x1, y0, z ), color );
		var c = new Vertex( new Vector3( x1, y1, z ), color );
		var d = new Vertex( new Vector3( x0, y1, z ), color );
		_shape.AddVertex( a ); _shape.AddVertex( b ); _shape.AddVertex( c );
		_shape.AddVertex( a ); _shape.AddVertex( c ); _shape.AddVertex( d );
	}

	void AddBorder( float x0, float y0, float x1, float y1, float z, Color color )
	{
		// 四条粗边向内铺,不越过格界
		AddQuadShape( x0, y0, x1, y0 + BorderThickness, z, color );
		AddQuadShape( x0, y1 - BorderThickness, x1, y1, z, color );
		AddQuadShape( x0, y0 + BorderThickness, x0 + BorderThickness, y1 - BorderThickness, z, color );
		AddQuadShape( x1 - BorderThickness, y0 + BorderThickness, x1, y1 - BorderThickness, z, color );
	}

	void AddTexQuad( SceneDynamicObject batch, Vector3 center, float halfX, float halfY, float rot, Color color, float z )
	{
		var cos = MathF.Cos( rot );
		var sin = MathF.Sin( rot );
		var rx = cos * halfX; var ry = sin * halfX;      // right 轴
		var ux = -sin * halfY; var uy = cos * halfY;     // up 轴

		var v0 = new Vertex( new Vector3( center.x - rx - ux, center.y - ry - uy, z ), new Vector4( 0f, 0f, 0f, 0f ), color );
		var v1 = new Vertex( new Vector3( center.x + rx - ux, center.y + ry - uy, z ), new Vector4( 1f, 0f, 0f, 0f ), color );
		var v2 = new Vertex( new Vector3( center.x + rx + ux, center.y + ry + uy, z ), new Vector4( 1f, 1f, 0f, 0f ), color );
		var v3 = new Vertex( new Vector3( center.x - rx + ux, center.y - ry + uy, z ), new Vector4( 0f, 1f, 0f, 0f ), color );

		batch.AddVertex( v0 ); batch.AddVertex( v1 ); batch.AddVertex( v2 );
		batch.AddVertex( v0 ); batch.AddVertex( v2 ); batch.AddVertex( v3 );
	}
}
slfy.circleroyale / Bot/BotStats.cs
Game game
using System;
using System.Collections.Generic;
using System.Text;

/// <summary>
/// bot 行为统计(v0.7.8.95,调参用):host 侧按球 Id 记各行为的触发计数,
/// 编辑器 MCP `cr_botstats` 读取。纯内存、不联网;热重载会清零(可接受)。
/// 目的:调 bot 数值有数据可看(逃跑/追猎/喂人/喂旗/技能的分布),不靠肉眼猜。
/// </summary>
public static class BotStats
{
	/// <summary> 一只 bot 的累计计数(Difficulty/Archetype 存 byte 避免游戏程序集类型出现在键上) </summary>
	public sealed class Entry
	{
		public string Name = "";
		public int Team = -1;
		public byte Difficulty;
		public byte Archetype;
		public TimeSince Seen;   // 最后一次决策时刻(30s 不活跃的条目不再输出)
		public long Decisions, Flees, Kites, Bequests, Hunts, SplitEscapes, SplitHunts, FeedMates, FeedFlags, SpikeBombs, Skills;
	}

	static readonly Dictionary<Guid, Entry> _byId = new();

	/// <summary> 条目数上限:球对象会被销毁重建(菜单演示赛收场、回菜单清场),而 _byId 按 Ball.Id
	/// 累积、条目永不失效——长会话会把"这台机器见过的所有 bot 球"都留在字典里。超过上限时
	/// 清掉久未决策(Seen > PruneAfterSeconds)的条目(v0.7.8.105)。 </summary>
	const int MaxEntries = 256;
	const float PruneAfterSeconds = 60f;

	/// <summary> 回收久未决策的条目(Track 内按需触发,不做每帧扫描) </summary>
	static readonly List<Guid> _pruneScratch = new();

	static void PruneStale()
	{
		if ( _byId.Count <= MaxEntries ) return;

		_pruneScratch.Clear();
		foreach ( var kv in _byId )
		{
			if ( kv.Value.Seen > PruneAfterSeconds ) _pruneScratch.Add( kv.Key );
		}

		foreach ( var k in _pruneScratch ) _byId.Remove( k );
	}

	/// <summary> 取/建一只 bot 的计数条目(BotBrain 每次决策调一次,顺带刷新名字/队伍/活跃时刻) </summary>
	public static Entry Track( Ball ball, byte difficulty, byte archetype )
	{
		if ( ball is null || !ball.IsValid() ) return null;
		PruneStale();   // 超上限时清久未决策的条目,防字典随"见过的 bot 球总数"无界增长
		if ( !_byId.TryGetValue( ball.Id, out var e ) )
		{
			e = new Entry();
			_byId[ball.Id] = e;
		}
		e.Name = ball.PlayerName;
		e.Team = ball.TeamIndex;
		e.Difficulty = difficulty;
		e.Archetype = archetype;
		e.Seen = 0;
		return e;
	}

	public static string Dump()
	{
		// 只输出 30 秒内还活着的 bot——跨局残留的旧球条目不再刷屏
		if ( _byId.Count == 0 ) return "no bot stats yet";
		var sb = new StringBuilder();
		int shown = 0;
		sb.AppendLine( "name | team | diff | archetype | dec | flee | kite | bequest | hunt | spEsc | spHunt | feed | feedFlag | bomb | skill" );
		foreach ( var e in _byId.Values )
		{
			if ( e.Seen > 30f ) continue;
			shown++;
			sb.AppendLine(
				$"{e.Name} | T{e.Team} | {(BotDifficulty)e.Difficulty} | {(BotArchetype)e.Archetype} | " +
				$"{e.Decisions} | {e.Flees} | {e.Kites} | {e.Bequests} | {e.Hunts} | {e.SplitEscapes} | {e.SplitHunts} | " +
				$"{e.FeedMates} | {e.FeedFlags} | {e.SpikeBombs} | {e.Skills}" );
		}
		sb.Insert( 0, $"bots={shown} (of {_byId.Count} tracked)\n" );
		return sb.ToString();
	}
}
slfy.circleroyale / UI/EndBoard.cs
Game game
using System;
using System.Collections.Generic;
using Sandbox.UI;

/// <summary>
/// 结算面板(M5):比赛结束全屏结算——本局排行(逐行滑入动画)+ 全服排行
/// (云端优先 ScoreUploader,空了降级 LocalBoard 本地榜)+ 回菜单按钮。
/// 纯 C# Panel + 同目录 EndBoard.cs.scss;显示期间全场模拟冻结(MatchState.MatchOver)。
/// </summary>
public sealed class EndBoard : PanelComponent
{
	Label _title;
	Label _winner;
	Image _crown;    // 冠军皇冠(v0.7.8.38 像素风):悬在 winner 行上方居中
	Label _matchHead;
	Label[] _matchRows;
	Label _selfRow;
	Label _globalHead;
	Label[] _globalRows;
	Label _globalNote;
	Label _back;

	bool _built;
	bool _shown;
	TimeSince _sinceShow;
	int _backSecs = -1;   // 返回按钮上次显示的秒数(变了才重拼文本,v0.7.8.87)

	ScoreWire[] _standings;
	long _mySteamId;

	// 逐行 reveal 动画:行索引 → 出场时刻(秒),透明度/位移在 0.25s 内渐入
	const float MatchRowDelay = 0.45f;
	const float MatchRowStagger = 0.22f;
	const float GlobalDelay = 3.4f;
	const float GlobalStagger = 0.16f;

	protected override void OnStart()
	{
		base.OnStart();
		BuildUi();
		Hide();
	}

	void BuildUi()
	{
		var root = Panel;
		if ( root is null ) return;
		if ( _built && _title.IsValid() && root.ChildrenCount > 0 ) return;

		root.DeleteChildren( true );

		root.Style.Position = PositionMode.Absolute;
		root.Style.Left = 0f;
		root.Style.Top = 0f;
		root.Style.Right = 0f;
		root.Style.Bottom = 0f;
		root.Style.BackgroundColor = new Color( 0.008f, 0.012f, 0.05f, 0.88f );
		root.Style.PointerEvents = PointerEvents.All;

		// 标题/冠军/回菜单居中;本局排行(左列)与全服排行(右列)并排(v0.6.3.0 用户定稿)
		_title = NewRow( root, 88f, 360f, 1200f, "eb-title" );
		_title.Text = "MATCH OVER";

		_winner = NewRow( root, 166f, 360f, 1200f, "eb-winner" );

		_crown = new Image();
		_crown.Texture = Texture.Load( "ui/pixel/crown.png" );
		_crown.Style.Position = PositionMode.Absolute;
		_crown.Style.Width = 22f;
		_crown.Style.Height = 22f;
		_crown.Style.Display = DisplayMode.None;
		root.AddChild( _crown );

		_matchHead = NewRow( root, 244f, 260f, 580f, "eb-head" );
		_matchHead.Text = "MATCH RANKING";

		// 本局排行:10 行 + 榜外自身行(左列)
		_matchRows = new Label[GameConfig.LeaderboardRows];
		for ( int i = 0; i < _matchRows.Length; i++ )
		{
			_matchRows[i] = NewRow( root, 276f + i * 28f, 260f, 580f, "eb-row" );
		}

		_selfRow = NewRow( root, 276f + _matchRows.Length * 28f, 260f, 580f, "eb-row self" );
		_selfRow.Style.Display = DisplayMode.None;

		_globalHead = NewRow( root, 244f, 1060f, 580f, "eb-head global" );
		_globalHead.Text = "GLOBAL TOP 10";

		_globalRows = new Label[10];
		for ( int i = 0; i < _globalRows.Length; i++ )
		{
			_globalRows[i] = NewRow( root, 276f + i * 28f, 1060f, 580f, "eb-row small" );
		}

		_globalNote = NewRow( root, 276f + _globalRows.Length * 28f, 1060f, 580f, "eb-note" );

		_back = NewRow( root, 620f, 360f, 1200f, "eb-back" );
		_back.Text = Networking.IsActive ? "RETURN TO LOBBY — [SPACE]" : "BACK TO MENU — [SPACE]";
		_back.AddEventListener( "onclick", () => OnBackClicked() );

		_built = true;
	}

	/// <summary> 建一行 Label:left/width 是 1920 设计稿坐标,换算成"中心锚定 + 相对偏移"——
	/// 任意分辨率下整体保持居中(left:960 定位在窄窗口会整体偏右,实测) </summary>
	Label NewRow( Panel root, float top, float left, float width, string classes )
	{
		var l = new Label() { Classes = classes };
		l.Style.Position = PositionMode.Absolute;
		l.Style.Left = Length.Percent( 50f );
		l.Style.MarginLeft = left - 960f;
		l.Style.Width = width;
		l.Style.Top = top;
		root.AddChild( l );
		return l;
	}

	/// <summary> 比赛结束展示结算(CircleroyaleGame.OnMatchOverRemote 调用,双端) </summary>
	public void Show( ScoreWire[] standings, long mySteamId, string myName )
	{
		_standings = standings;
		_mySteamId = mySteamId;

		BuildUi();
		if ( !_built ) return;

		_sinceShow = 0;
		_backSecs = -1;   // 强制重拼返回按钮文本(v0.7.8.87)
		_shown = true;
		Panel.Style.Display = DisplayMode.Flex;

		_winner.Text = WinnerText( standings );

		FillMatchRows();
		FillGlobalPlaceholder();
		ScoreUploader.FetchGlobal( ApplyGlobal );
	}

	/// <summary> 回大厅/主菜单(实例方法:热重载可重映射) </summary>
	void OnBackClicked() => CircleroyaleGame.Current?.AfterSettlement();

	/// <summary> 回大厅时收起(面板随 GameObject 复用,下次开局再展示) </summary>
	public void Hide()
	{
		_shown = false;
		if ( Panel.IsValid() ) Panel.Style.Display = DisplayMode.None;
	}

	// ---- 内容填充 ----

	string WinnerText( ScoreWire[] rows )
	{
		if ( rows is null || rows.Length == 0 ) return "";

		if ( !MatchState.IsTeam )
			return $"WINNER — {rows[0].Name}   ({rows[0].Mass:0})";

		// 领土战争(M7.4 积分制):胜负按占旗积分(host 排序同源,这里只取文案)
		if ( MatchState.IsTerritory )
		{
			int bestT = -1;
			float bestPts = -1f;
			for ( var t = 0; t < 4; t++ )
			{
				var pts = TerritoryManager.Points( t );
				if ( pts > bestPts )
				{
					bestPts = pts;
					bestT = t;
				}
			}
			return $"TEAM {bestT + 1} WINS   ({bestPts:0} PTS)";
		}

		// 团队赛:胜负按队伍 3 人总分
		var totals = new Dictionary<int, float>();
		foreach ( var r in rows )
		{
			totals.TryGetValue( r.Team, out var m );
			totals[r.Team] = m + r.Mass;
		}

		int best = -1;
		float bestMass = -1f;
		foreach ( var kv in totals )
		{
			if ( kv.Value > bestMass )
			{
				bestMass = kv.Value;
				best = kv.Key;
			}
		}
		return $"TEAM {best + 1} WINS   ({bestMass:0})";
	}

	void FillMatchRows()
	{
		var rows = _standings;
		if ( rows is null || rows.Length == 0 )
		{
			// 无名单(取消局/空结算):清行而不是 NRE(v0.7.8.87;WinnerText 早已按 null 防过)
			for ( int i = 0; i < _matchRows.Length; i++ )
			{
				if ( _matchRows[i].IsValid() && _matchRows[i].Text.Length > 0 ) _matchRows[i].Text = "";
			}
			if ( _selfRow.IsValid() && _selfRow.Style.Display != DisplayMode.None ) _selfRow.Style.Display = DisplayMode.None;
			return;
		}

		int selfRank = -1;
		for ( int i = 0; i < rows.Length; i++ )
		{
			if ( rows[i].SteamId == _mySteamId && !rows[i].Bot )
			{
				selfRank = i;
				break;
			}
		}

		for ( int i = 0; i < _matchRows.Length; i++ )
		{
			var row = _matchRows[i];
			if ( row is null || !row.IsValid() ) continue;   // 过期引用不可写(v0.7.8.87)

			if ( i >= rows.Length )
			{
				row.Text = "";
				continue;
			}

			var r = rows[i];
			row.Text = RowText( $"{i + 1}.", r.Name, $"{r.Mass:0}", r.Team );

			bool isSelf = r.SteamId == _mySteamId && !r.Bot;
			row.Style.FontColor = isSelf ? new Color( 0.898f, 0.282f, 0.553f ) : new Color( 0.29f, 0.23f, 0.39f, 0.85f );
		}

		// 榜外自身行
		bool showSelf = selfRank >= _matchRows.Length;
		if ( _selfRow.IsValid() )
		{
			_selfRow.Style.Display = showSelf ? DisplayMode.Flex : DisplayMode.None;
			if ( showSelf )
			{
				var r = rows[selfRank];
				_selfRow.Text = RowText( $"#{selfRank + 1}.", r.Name, $"{r.Mass:0}", r.Team );
			}
		}
	}

	/// <summary> 行文本(团队赛带 T# 前缀;普通赛只有名次/名字/分数) </summary>
	string RowText( string rank, string name, string mass, byte team )
	{
		return MatchState.IsTeam ? $"{rank} [T{team + 1}] {name}   {mass}" : $"{rank}  {name}   {mass}";
	}

	/// <summary> 全服榜先占位"加载中",云端回来后填充(空了降级本地榜) </summary>
	void FillGlobalPlaceholder()
	{
		_globalRows[0].Text = "LOADING ...";
		for ( int i = 1; i < _globalRows.Length; i++ )
			_globalRows[i].Text = "";
		_globalNote.Text = "";
	}

	void ApplyGlobal( List<( string Name, float Mass )> entries )
	{
		if ( !_shown ) return;
		if ( _globalRows is null || _globalRows[0] is null || !_globalRows[0].IsValid() ) return;

		if ( entries is null )
		{
			// 云端不可用(未发布包是常态):降级本机持久化榜
			entries = LocalBoard.Top( _globalRows.Length );
			_globalNote.Text = entries is not null ? "OFFLINE BOARD (THIS MACHINE)" : "NO RECORDS YET";
		}
		else
		{
			_globalNote.Text = "BEST SINGLE-MATCH SCORE";
		}

		for ( int i = 0; i < _globalRows.Length; i++ )
		{
			if ( entries is null || i >= entries.Count )
			{
				_globalRows[i].Text = "";
				continue;
			}
			_globalRows[i].Text = $"{i + 1}.  {entries[i].Name}   {entries[i].Mass:0}";
		}
	}

	// ---- 逐帧:reveal 动画 + 光标 + 空格回菜单 ----

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

		BuildUi();
		if ( !_shown ) return;

		if ( Panel.IsValid() && Panel.Style.Display != DisplayMode.Flex ) Panel.Style.Display = DisplayMode.Flex;   // 同值守卫(v0.7.8.87)
		Mouse.Visibility = MouseVisibility.Visible;   // 结算期按钮要能点

		// 空格提前返回(按钮文案写着 [SPACE]):放在入场动画之前,按下即响应。
		// MatchOver 后 Tick/Ball.OnUpdate 都不再跑,这里不接就没人接了。
		// BallInput.Typing 守卫:聊天框聚焦时按键会无差别进 InputContext,打字打空格不能退局
		if ( !BallInput.Typing && Input.Pressed( "Jump" ) )
		{
			CircleroyaleGame.Current?.AfterSettlement();
			return;
		}

		float t = _sinceShow;
		Animate( _title, t, 0.05f );
		Animate( _winner, t, 0.2f );
		Animate( _matchHead, t, MatchRowDelay - 0.2f );

		for ( int i = 0; i < _matchRows.Length; i++ )
			Animate( _matchRows[i], t, MatchRowDelay + i * MatchRowStagger );

		Animate( _selfRow, t, MatchRowDelay + _matchRows.Length * MatchRowStagger );
		Animate( _globalHead, t, GlobalDelay - 0.2f );

		for ( int i = 0; i < _globalRows.Length; i++ )
			Animate( _globalRows[i], t, GlobalDelay + i * GlobalStagger );

		Animate( _globalNote, t, GlobalDelay + _globalRows.Length * GlobalStagger );
		Animate( _back, t, GlobalDelay + _globalRows.Length * GlobalStagger + 0.3f );

		// 皇冠悬在冠军行正上方(winner 是定宽居中行,文字中心恒 960;透明度随其入场动画)
		if ( _crown.IsValid() )
		{
			var show = _winner.IsValid() && !string.IsNullOrEmpty( _winner.Text );
			var crownMode = show ? DisplayMode.Flex : DisplayMode.None;
			if ( _crown.Style.Display != crownMode ) _crown.Style.Display = crownMode;
			if ( show )
			{
				// 位置/锚点常量只在首帧写一次(每帧同值写=布局重算,v0.7.8.87);Top 是浮动动画,按阈值写
				if ( !SamePx( _crown.Style.MarginLeft, -11f ) )
				{
					_crown.Style.Left = Length.Percent( 50f );   // 冠军行中心锚定后皇冠跟随面板中心
					_crown.Style.MarginLeft = -11f;             // 22px 宽的半个
				}

				var top = 138f + MathF.Sin( t * 3f ) * 4f;
				if ( !SamePx( _crown.Style.Top, top ) ) _crown.Style.Top = top;
				if ( !SameFloat( _crown.Style.Opacity, _winner.Style.Opacity ?? 1f ) ) _crown.Style.Opacity = _winner.Style.Opacity;
			}
		}

		// 自动返回(v0.6.4.2):结算显示 10 秒后回大厅(多人)/主菜单(单机),空格可提前
		var remain = GameConfig.SettlementAutoReturnSeconds - _sinceShow;
		if ( remain <= 0f )
		{
			CircleroyaleGame.Current?.AfterSettlement();
			return;
		}

		if ( _back.IsValid() )
		{
			// 秒数变了才重拼文本(每帧插值=每帧一次字符串分配 + 布局写,v0.7.8.87)
			var secs = (int)MathF.Ceiling( remain );
			if ( secs != _backSecs )
			{
				_backSecs = secs;
				_back.Text = Networking.IsActive
					? $"RETURN TO LOBBY IN {secs} — [SPACE NOW]"
					: $"BACK TO MENU IN {secs} — [SPACE NOW]";
			}
		}
	}

	/// <summary> 单行渐入:出场前 Opacity 0;0.25s 内透明度 0→1、PaddingLeft 36px→0(视觉上从右滑到位) </summary>
	void Animate( Label row, float now, float revealAt )
	{
		if ( row is null || !row.IsValid() ) return;

		float k = Math.Clamp( ( now - revealAt ) / 0.25f, 0f, 1f );

		// 动画跑完(k=1)后不再写样式:结算要挂 10 秒,同值写会一直强迫布局(v0.7.8.87)
		if ( k >= 1f )
		{
			if ( !SameFloat( row.Style.Opacity, 1f ) ) row.Style.Opacity = 1f;
			if ( !SamePx( row.Style.PaddingLeft, 0f ) ) row.Style.PaddingLeft = 0f;
			return;
		}

		if ( k <= 0f )
		{
			if ( !SameFloat( row.Style.Opacity, 0f ) ) row.Style.Opacity = 0f;
			return;
		}

		row.Style.Opacity = k;   // 动画进行中逐帧变化,必须写
		row.Style.PaddingLeft = ( 1f - k ) * 36f;
	}

	// ---- 样式属性都是 nullable(Length?/float?):同值守卫的 null 安全比较("未设置"算不同 → 需要写) ----

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

	/// <summary> 浮点样式(Opacity 等)是否已等于目标值 </summary>
	static bool SameFloat( float? v, float target, float eps = 0.01f ) =>
		v.HasValue && MathF.Abs( v.Value - target ) < eps;
}
slfy.circleroyale / Game/ClassSkillManager.cs
Game game
using System;
using System.Collections.Generic;

/// <summary>
/// 职业技能管理器(M7.4,领土模式专属):Q 键施放的四职业冷却技能,host 权威。
/// 指挥官=传送到最大队友身边(远程玩家经 ClassTeleport 广播瞬移);工兵=6 秒大加速(复用 Speed buff);
/// 护卫=原地锚定尖刺 8 秒(复用尖刺分身碰撞判定);坦克=+40% 当前质量 10 秒,到期回收、期间禁分裂。
/// 冷却按球 Id 记(真人=本球;bot 各球独立)。客户端冷却为本地估算,
/// host 冷却未好时静默拒绝(无副作用)。坦克回收表持有 Ball 引用,热重载换程序集对象会失效——
/// 届时该次回收静默消失(对局本身也会被热重载打断),可接受的边界。
/// </summary>
public static class ClassSkillManager
{
	sealed class Surge
	{
		public Ball Ball;
		public float Added;
		public TimeSince Since;
	}

	static readonly List<Surge> _surges = new();
	static readonly Dictionary<Guid, TimeSince> _cd = new();   // 球 Id → 上次施放(按球不按 SteamId——bot 的 SteamId 全 0 会全队共享一个冷却)

	public static string NameOf( byte cls ) => cls switch
	{
		0 => "COMMANDER",
		1 => "ENGINEER",
		2 => "GUARD",
		3 => "TANK",
		_ => "",
	};

	/// <summary> HUD 短标(Q 钮就绪态显示) </summary>
	public static string TagOf( byte cls ) => cls switch
	{
		0 => "CMD",
		1 => "ENG",
		2 => "GRD",
		3 => "TNK",
		_ => "",
	};

	public static float CooldownOf( byte cls ) => cls switch
	{
		0 => GameConfig.ClassCdCommander,
		1 => GameConfig.ClassCdEngineer,
		2 => GameConfig.ClassCdGuard,
		3 => GameConfig.ClassCdTank,
		_ => 999f,
	};

	/// <summary> 球复活时清冷却(v0.7.8.87):复活会重掷职业,但冷却按球 Id 记、球对象又跨局复用——
	/// 不清的话刚复活 Q 显示着新职业却按不动(上一条命/上一局的 45~50s 冷却还挂着) </summary>
	public static void ResetBall( Ball ball )
	{
		if ( ball.IsValid() ) _cd.Remove( ball.Id );
	}

	/// <summary> 局间清账(v0.7.8.87):清全部冷却 + 未回收的坦克临时质量记账
	/// (跨局残留会在新局把重生球的质量又扣一遍) </summary>
	public static void ResetAll()
	{
		_cd.Clear();
		_surges.Clear();
	}

	/// <summary> 坦克爆发期内禁分裂(临时质量分裂=套现永久质量) </summary>
	public static bool IsTankSurging( Ball ball )
	{
		if ( ball is null || !ball.IsValid() ) return false;
		foreach ( var s in _surges )
		{
			if ( s.Ball == ball ) return true;
		}
		return false;
	}

	/// <summary> host 权威施放:冷却/存活/模式校验后执行;返回是否真的放了(没队友的指挥官不进冷却) </summary>
	public static bool Cast( Ball ball )
	{
		if ( !MatchState.IsTerritory ) return false;
		if ( ball is null || !ball.IsValid() || !ball.Alive ) return false;
		if ( ball.TerritoryClass > 3 ) return false;

		var sid = ball.Id;   // 冷却按球身记(真人=本球;bot 各球独立,不受 SteamId 全 0 影响)
		if ( _cd.TryGetValue( sid, out var since ) && since < CooldownOf( ball.TerritoryClass ) ) return false;

		switch ( ball.TerritoryClass )
		{
			case 0:
				if ( !CastCommander( ball ) ) return false;
				break;
			case 1:
				ball.ApplyBuff( (byte)PowerUpManager.Kind.Speed, GameConfig.ClassEngineerSeconds );
				break;
			case 2:
				CircleroyaleGame.Current?.SpawnGuardSpike( ball );
				break;
			case 3:
				CastTank( ball );
				break;
		}

		_cd[sid] = 0;
		GameLog.Info( $"[class] {NameOf( ball.TerritoryClass )} cast by '{ball.PlayerName}'" );
		return true;
	}

	static bool CastCommander( Ball ball )
	{
		var game = CircleroyaleGame.Current;
		if ( game is null ) return false;

		Ball best = null;
		foreach ( var b in game.Balls )
		{
			if ( !b.IsValid() || !b.Alive || b == ball ) continue;
			if ( b.TeamIndex < 0 || b.TeamIndex != ball.TeamIndex ) continue;
			if ( best is null || b.Mass > best.Mass ) best = b;
		}
		if ( best is null ) return false;   // 孤身无队友:技能不放、不进冷却

		var pos = best.WorldPosition;
		if ( ball.IsBot || ball.OwnerSteamId == Game.SteamId.Value )
		{
			ball.TeleportTo( pos );           // host 本机球/bot:直接瞬移
			game.MarkTeleport( ball, pos );
		}
		else
		{
			game.MarkTeleport( ball, pos );   // 远程球:host 先锚定校验位,owner 端收广播自行瞬移
			NetworkManager.ClassTeleport( ball.OwnerSteamId, pos.x, pos.y );
		}
		return true;
	}

	static void CastTank( Ball ball )
	{
		var added = MathF.Max( GameConfig.ClassTankMassMin, ball.Mass * GameConfig.ClassTankMassFraction );
		ball.Mass = MathF.Min( GameConfig.MaxMass, ball.Mass + added );
		_surges.Add( new Surge { Ball = ball, Added = added, Since = 0 } );
	}

	/// <summary> host 每帧(TickTerritory 调):坦克临时质量到期回收(下限出生质量;球没了就不补) </summary>
	public static void Tick()
	{
		for ( int i = _surges.Count - 1; i >= 0; i-- )
		{
			var s = _surges[i];
			if ( s.Since < GameConfig.ClassTankSeconds ) continue;

			var b = s.Ball;
			if ( b.IsValid() && b.Alive )
				b.Mass = MathF.Max( GameConfig.StartMass, b.Mass - s.Added );
			_surges.RemoveAt( i );
		}
	}
}
slfy.circleroyale / Game/PowerUpManager.cs
Game game
using System;
using System.Collections.Generic;

/// <summary>
/// 道具管理(v0.7.3.0):**host 权威 + 静态 RPC 同步**,与 FoodManager 同款模式
/// (纯数据实体不走网络对象——运行时 live create 不复制到客户端,实测)。
/// 固定槽位制:场上恒 PowerUpOnField 个槽,被捡后同槽延时重刷(换种类换位置),
/// 全量同步只有一个小数组,中途加入/热重载恢复都便宜。
/// 拾取主球与分身都算(v0.7.8.6,存主人背包);效果在拾取瞬间应用到 Ball(buff 挂玩家,质量罐即时加质量)。
/// Init/Tick 仅权威端调用;Apply* 仅供客户端应用远端事件。
/// </summary>
public sealed class PowerUpManager
{
	/// <summary> 道具种类(HUD/渲染/效果 switch 共用;None=255 作 Ball.BuffKind 的"无 buff"哨兵) </summary>
	public enum Kind : byte
	{
		Speed = 0,    // ⚡ 加速:8s 移速 ×1.4(黄)
		Magnet = 1,   // 🧲 磁铁:8s 吸食半径翻倍(青)
		Mass = 2,     // 💊 质量罐:+300 即时(品红)
		Shield = 3,   // 🛡 护盾:5s 不可被大球吃(蓝)
		Spike = 4,    // 💥 尖刺分身:生成跟随主人 20s 的尖刺(紫绿),撞破比自己大的球奖 3s 无敌
	}

	public const byte None = 255;

	/// <summary> 槽位渲染色(与霓虹调色板独立——道具要一眼和食物区分开) </summary>
	public static Color ColorOf( byte kind )
	{
		switch ( kind )
		{
			case (byte)Kind.Speed: return new Color( 1.00f, 0.85f, 0.25f );
			case (byte)Kind.Magnet: return new Color( 0.30f, 0.90f, 1.00f );
			case (byte)Kind.Mass: return new Color( 1.00f, 0.40f, 0.70f );
			case (byte)Kind.Shield: return new Color( 0.40f, 0.55f, 1.00f );
			case (byte)Kind.Spike: return new Color( 0.55f, 1.00f, 0.45f );   // 亮绿(野刺紫=障碍,绿色=可操纵的武器)
			default: return new Color( 0.6f, 0.6f, 0.6f );
		}
	}

	/// <summary> HUD/图例用的道具名 </summary>
	public static string NameOf( byte kind )
	{
		switch ( kind )
		{
			case (byte)Kind.Speed: return "SPEED";
			case (byte)Kind.Magnet: return "MAGNET";
			case (byte)Kind.Shield: return "SHIELD";
			case (byte)Kind.Spike: return "SPIKE";
			case (byte)Kind.Mass: return "+MASS";
			default: return "";
		}
	}

	/// <summary> 道具槽(渲染与 HUD 读;host 权威,客户端为事件镜像) </summary>
	public sealed class Slot
	{
		public bool Alive;
		public byte Kind;
		public Vector2 Pos;
		public TimeSince SincePicked;   // 仅 host:重刷计时
	}

	readonly List<Slot> _slots = new();

	/// <summary> 喂食共享积分池(按槽,仅 host):谁吐丝都往里充,积分 ≥ 本次喂食者体重×PowerUpFeedRatio 即触发给喂食者——
	/// 大球喂到一半会被小球几口抢走(虎口夺食,v0.7.4.0 用户定稿) </summary>
	float[] _feedScores = Array.Empty<float>();

	/// <summary> Q 技能充能计时(按 SteamId,仅 host;v0.7.8.30 Q 改随时间充能,E 仍拾取) </summary>
	readonly Dictionary<long, TimeSince> _qCharge = new();

	/// <summary> Q 槽当前持有技能的球(冷却暂停标记,检测"放走"的转变) </summary>
	readonly HashSet<long> _qHeld = new();

	/// <summary> 全部槽位(NeonRenderer 每帧读,各端内容一致) </summary>
	public IReadOnlyList<Slot> Slots => _slots;

	/// <summary> 客户端/热重载后本地列表未就绪(不渲染也不误判) </summary>
	public bool Ready => _slots.Count > 0;

	/// <summary> host:生成全部槽位并广播全量(开局/演示赛调用) </summary>
	public void Init()
	{
		_slots.Clear();
		ClearCharge();   // 局间清账(v0.7.8.87):见 ClearCharge 注释
		for ( int i = 0; i < GameConfig.PowerUpOnField; i++ )
		{
			_slots.Add( new Slot
			{
				Alive = true,
				Kind = RandomKind(),
				Pos = RandomPos(),
			} );
		}

		if ( Networking.IsActive ) NetworkManager.PowerUpFull( ToWire() );
		_feedScores = new float[_slots.Count];
	}

	/// <summary> 清空(回主菜单;客户端经 PowerUpFull 空数组同步) </summary>
	public void Reset()
	{
		_slots.Clear();
		_feedScores = Array.Empty<float>();
		ClearCharge();
	}

	/// <summary> 清 Q 充能/持有时长记账(v0.7.8.87):`_qCharge` 按 SteamId 记而 `TimeSince` 一直在走,
	/// 不随局清空的话,上一局充能到一半的玩家在新局开局几乎立刻白拿一个技能 </summary>
	void ClearCharge()
	{
		_qCharge.Clear();
		_qHeld.Clear();
	}

	/// <summary> host:拾取判定(主球 + 分身共用)+ 到期槽位重刷。仅权威端每帧调用。
	/// v0.7.5.0 起拾取**存入背包**(bot 例外:拾取即用)——背包满则道具留在场上不被捡;
	/// v0.7.8.6 起分身(SplitPiece)也走同一判定,捡到存主人背包 </summary>
	public void Tick( List<Ball> balls, List<CellPiece> cells = null )
	{
		if ( _slots.Count == 0 ) return;

		foreach ( var ball in balls )
		{
			if ( !ball.IsValid() || !ball.Alive ) continue;
			PickupPass( ball, ball.WorldPosition, ball.Radius );
		}

		// 分身拾取(v0.7.8.6):捡到存主人背包(同一 Q/E 背包);孢子不捡(中性食物团),
		// 尖刺分身不捡(武器不兼职取货)。主人死亡的分身下一帧 TickCells 才清,这里顺带过滤。
		// ⚠️ 倒序 for:bot 拿着(20s 后转正的)尖刺分身踩到尖刺道具 → 拾取即用 →
		// SpawnSpikeMinion 往 _cells 追加——foreach 枚举中改集合会炸(v0.7.8.20 修)
		if ( cells is not null )
		{
			for ( int i = cells.Count - 1; i >= 0; i-- )
			{
				var c = cells[i];
				if ( c.PieceKind != CellPiece.Kind.SplitPiece ) continue;
				if ( !c.OwnerBall.IsValid() || !c.OwnerBall.Alive ) continue;
				PickupPass( c.OwnerBall, c.Pos, c.Radius );
			}
		}

		// 空槽重刷
		for ( int i = 0; i < _slots.Count; i++ )
		{
			var s = _slots[i];
			if ( s.Alive || s.SincePicked < GameConfig.PowerUpRefillSeconds ) continue;

			s.Alive = true;
			s.Kind = RandomKind();
			s.Pos = RandomPos();
			if ( i < _feedScores.Length ) _feedScores[i] = 0f;   // 重刷积分清零(培养进度不作数)
			if ( Networking.IsActive ) NetworkManager.PowerUpRespawned( i, s.Kind, s.Pos.x, s.Pos.y );
		}

		// Q 技能随时间充能(v0.7.8.30 用户定稿):真人球 Q 槽空时按**当前质量**计时充能
		// (15~60s,越轻越快),到点自动发一个随机技能。bot 拾取即用无背包,不参与;
		// 持有未放时冷却暂停(技能躺在槽里等按键)。计时按 SteamId 记(跨热重载稳定)。
		// 领土模式(M7.4):Q 键让给职业技能——充能 pass 冻结清零,PowerA 保持空槽
		if ( MatchState.IsTerritory )
		{
			_qCharge.Clear();
			_qHeld.Clear();
			return;
		}
		foreach ( var b in balls )
		{
			if ( !b.IsValid() || !b.Alive || b.IsBot ) continue;

			var sid = b.OwnerSteamId;
			if ( b.PowerA != None )
			{
				_qHeld.Add( sid );   // 技能待放:冷却暂停
				continue;
			}
			if ( _qHeld.Remove( sid ) )
			{
				_qCharge[sid] = 0;   // 刚放走/复活清空:冷却从现在起算
				continue;
			}
			if ( !_qCharge.TryGetValue( sid, out var ts ) )
			{
				_qCharge[sid] = 0;   // 首见(入局)
				continue;
			}
			if ( ts < GameConfig.QSkillCooldownFor( b.Mass ) ) continue;

			_qCharge[sid] = 0;
			b.PowerA = RandomKind();
			if ( GameSfx.IsMine( b.OwnerSteamId ) )
			{
				GameSfx.Pickup();
				CircleroyaleGame.Current?.Hud?.AddBanner( $"{NameOf( b.PowerA )} READY (Q)", ColorOf( b.PowerA ) );
			}
			// 客户端玩家的 Q 到点:横幅+音效走广播(mode2),host 回声由 OnPowerBannerRemote 的 IsAuthority 挡掉(v0.7.8.31)
			if ( Networking.IsActive ) NetworkManager.PowerBanner( b.PowerA, b.OwnerSteamId, 2 );
			GameLog.Info( $"[game] q skill granted: {NameOf( b.PowerA )} -> {b.PlayerName}" );
		}
	}

	/// <summary>
	/// 一个"拾取体"(主球或分身)扫一遍槽位。bot 拾取即用;真人存入 Q/E 背包
	/// (效果等按键释放),背包满则道具留在场上。判定圈封顶反马太(大球要刻意碾上去)。
	/// </summary>
	void PickupPass( Ball receiver, Vector3 pos, float radius )
	{
		// 拾取判定封顶(v0.7.4.0 反马太):大球不再"路过顺走"——判定圈按封顶半径算,
		// 必须刻意碾到道具正上方才吃得到;小球反而轻松
		var rr = MathF.Min( radius, GameConfig.PowerUpPickRadiusCap ) + GameConfig.PowerUpRadius;

		for ( int i = 0; i < _slots.Count; i++ )
		{
			var s = _slots[i];
			if ( !s.Alive ) continue;

			var dx = s.Pos.x - pos.x;
			var dy = s.Pos.y - pos.y;
			if ( dx * dx + dy * dy > rr * rr ) continue;

			if ( receiver.IsBot )
			{
				Consume( i, receiver );   // bot 不囤:拾取即触发效果(banner 由 Consume 内 IsMine 挡掉)
			}
			else if ( receiver.StorePower( s.Kind ) >= 0 )
			{
				// 真人:存入 Q/E 背包槽(效果等玩家按键释放),道具槽重刷
				s.Alive = false;
				s.SincePicked = 0;

				NeonRenderer.Spark( new Vector3( s.Pos.x, s.Pos.y, 0f ), ColorOf( s.Kind ) );
				if ( GameSfx.IsMine( receiver.OwnerSteamId ) )
				{
					GameSfx.EatFood( new Vector3( s.Pos.x, s.Pos.y, 0f ) );
					GameAchievements.PowerStored();   // 成就:累计捡 5 个入包(host 本机侧)
				}
				if ( Networking.IsActive ) NetworkManager.PowerUpPicked( i, s.Kind, receiver.OwnerSteamId );

				ShowBanner( receiver, s.Kind, stored: true );
				GameLog.Info( $"[game] powerup stored: {NameOf( s.Kind )} -> {receiver.PlayerName}" );
			}
			// 背包满:不消耗,道具留在场上(下一颗球/下个帧再判)
		}
	}

	/// <summary> 消耗道具并触发效果(bot 拾取即用 / 喂食触发共用路径;槽重刷) </summary>
	void Consume( int index, Ball receiver )
	{
		if ( (uint)index >= (uint)_slots.Count ) return;
		var s = _slots[index];
		if ( !s.Alive ) return;

		s.Alive = false;
		s.SincePicked = 0;
		if ( index < _feedScores.Length ) _feedScores[index] = 0f;

		ApplyEffect( receiver, s.Kind );
		NeonRenderer.Spark( new Vector3( s.Pos.x, s.Pos.y, 0f ), ColorOf( s.Kind ) );
		if ( GameSfx.IsMine( receiver.OwnerSteamId ) )
			GameSfx.EatFood( new Vector3( s.Pos.x, s.Pos.y, 0f ) );

		ShowBanner( receiver, s.Kind, stored: false );
		if ( Networking.IsActive ) NetworkManager.PowerUpPicked( index, s.Kind, receiver.OwnerSteamId );
	}

	/// <summary>
	/// 个人高光横幅(v0.7.6.0):host 本地播自己的 + 广播给其他端(客户端按 IsMine 再显示)。
	/// stored=true "XXX STORED"(捡到入背包)/ false 激活文案(喂养触发/bot 拾取即用)。
	/// v0.7.7.0:横幅配套"叮咚"音(本机耳朵)。
	/// </summary>
	void ShowBanner( Ball receiver, byte kind, bool stored )
	{
		var text = stored ? $"{NameOf( kind )} STORED" : GameHud.ActivationText( kind );
		if ( GameSfx.IsMine( receiver.OwnerSteamId ) )
		{
			CircleroyaleGame.Current?.Hud?.AddBanner( text, ColorOf( kind ) );
			GameSfx.Pickup();
		}

		if ( Networking.IsActive ) NetworkManager.PowerBanner( kind, receiver.OwnerSteamId, (byte)( stored ? 0 : 1 ) );
	}

	/// <summary>
	/// host:孢子喂道具("血量池")——共享积分池按槽累计(谁吐丝都往里充),
	/// 积分 ≥ 本次喂食者当前体重 × PowerUpFeedRatio 即触发效果给喂食者(**立即生效,不入背包**)。
	/// 大球要喂到 5%×大体重才轮到自己达标,小球几口就够——中途截胡即"虎口夺食"。
	/// feeder 无效(断线/死亡瞬间)只充能不判定。
	/// </summary>
	public void Feed( Ball feeder, int index, float blobMass )
	{
		if ( (uint)index >= (uint)_slots.Count || !_slots[index].Alive ) return;
		if ( index >= _feedScores.Length ) Array.Resize( ref _feedScores, _slots.Count );

		_feedScores[index] += blobMass;

		if ( feeder is null || !feeder.IsValid() || !feeder.Alive ) return;
		if ( _feedScores[index] < feeder.Mass * GameConfig.PowerUpFeedRatio ) return;

		var kind = _slots[index].Kind;
		Consume( index, feeder );
		GameAchievements.FeedTriggered( feeder.OwnerSteamId );   // 成就:首次喂食触发(host 本机侧)
		GameLog.Info( $"[game] powerup fed to trigger: {NameOf( kind )} -> {feeder.PlayerName}" );
	}

	/// <summary> 道具效果(host 权威;质量罐即时到账,其余挂 buff,尖刺分身在游戏系统生成)。
	/// 拾取存背包(v0.7.5.0):真人按键 UsePower 时才走到这里 </summary>
	public static void ApplyEffect( Ball ball, byte kind )
	{
		switch ( kind )
		{
			case (byte)Kind.Speed:
				ball.ApplyBuff( kind, GameConfig.PowerUpSpeedSeconds );
				break;
			case (byte)Kind.Magnet:
				ball.ApplyBuff( kind, GameConfig.PowerUpMagnetSeconds );
				break;
			case (byte)Kind.Shield:
				ball.ApplyBuff( kind, GameConfig.PowerUpShieldSeconds );
				break;
			case (byte)Kind.Mass:
				ball.Mass = MathF.Min( GameConfig.MaxMass, ball.Mass + GameConfig.PowerUpMassBonus );
				break;
			case (byte)Kind.Spike:
				CircleroyaleGame.Current?.SpawnSpikeMinion( ball );
				break;
		}

		GameLog.Info( $"[game] powerup picked: {NameOf( kind )} -> {ball.PlayerName}" );
	}

	byte RandomKind() => (byte)Game.Random.Float( 0f, 4.999f );

	Vector2 RandomPos()
	{
		var half = GameConfig.ArenaHalfSize - GameConfig.PowerUpMargin;
		return new Vector2( Game.Random.Float( -half, half ), Game.Random.Float( -half, half ) );
	}

	/// <summary> 全量快照(开局补发/新连接定向用;host 侧调用) </summary>
	public PowerUpWire[] ToWireForSync() => ToWire();

	PowerUpWire[] ToWire()
	{
		var wires = new PowerUpWire[_slots.Count];
		for ( int i = 0; i < _slots.Count; i++ )
		{
			var s = _slots[i];
			wires[i] = new PowerUpWire
			{
				Kind = s.Alive ? s.Kind : None,
				X = s.Pos.x,
				Y = s.Pos.y,
			};
		}
		return wires;
	}

	// ---- 客户端应用(远端事件 → 本地槽位)----

	public void ApplyFull( PowerUpWire[] wires )
	{
		_slots.Clear();
		if ( wires is null ) return;

		foreach ( var w in wires )
		{
			_slots.Add( new Slot
			{
				Alive = w.Kind != None,
				Kind = w.Kind,
				Pos = new Vector2( w.X, w.Y ),
			} );
		}
	}

	public void ApplyPicked( int index )
	{
		if ( (uint)index >= (uint)_slots.Count ) return;   // 热重载后未同步全量前,忽略增量
		_slots[index].Alive = false;
	}

	public void ApplyRespawned( int index, byte kind, Vector2 pos )
	{
		if ( (uint)index >= (uint)_slots.Count ) return;

		var s = _slots[index];
		s.Alive = true;
		s.Kind = kind;
		s.Pos = pos;
	}
}
slfy.circleroyale / UI/MainMenu.cs
Game game
using Sandbox.UI;

/// <summary>
/// 主菜单(M4;2026-09-09 参考手游布局重排,用户定稿):中央大 logo + 三按钮横排
/// (CREATE GAME / JOIN GAME / VS BOTS),左上玩家资料卡(头像/名字/等级),
/// 右上设置/排行榜小按钮;QUIT 按需求移除(编辑器停 Play 即退出,standalone 再加回)。
/// 点击 + 键盘快捷键双通道(编辑器内嵌视口可能收不到鼠标点击,键盘保底):
/// 1=建服 2=加入 3=人机 4=设置页 5(数字键)=排行榜。
/// 样式在同目录 MainMenu.cs.scss(按"类名.cs.scss"自动加载)。
/// </summary>
public sealed class MainMenu : PanelComponent
{
	Label _status;
	Label _level;
	Image _avatar;
	TimeSince _sinceAvatarTry = 1f;
	bool _built;

	protected override void OnStart()
	{
		base.OnStart();
		BuildUi();
	}

	void BuildUi()
	{
		var root = Panel;
		if ( root is null ) return;

		// 已建好且引用有效 → 不重复建(热重载半残时整体清空重建)
		if ( _built && _status.IsValid() && root.ChildrenCount > 0 ) return;

		root.DeleteChildren( true );

		// 左上:玩家资料卡(头像 / 名字 / 等级)
		var profile = new Panel() { Classes = "profile" };
		root.AddChild( profile );

		_avatar = new Image() { Classes = "profile-avatar" };
		profile.AddChild( _avatar );

		var info = new Panel() { Classes = "profile-info" };
		profile.AddChild( info );

		var name = new Label() { Classes = "profile-name" };
		name.Text = Sandbox.Utility.Steam.PersonaName;
		info.AddChild( name );

		_level = new Label() { Classes = "profile-level" };
		info.AddChild( _level );

		// 右上:设置 / 排行榜(图标小按钮:Gear / Trophy)
		var actions = new Panel() { Classes = "top-actions" };
		root.AddChild( actions );
		AddIconButton( actions, "ui/skin/ic_gear.png", "settings" );
		AddIconButton( actions, "ui/skin/ic_trophy.png", "rankings" );

		// 中央大 logo(副标题已按需求移除)
		var title = new Label() { Classes = "title" };
		title.Text = "CIRCLEROYALE";
		root.AddChild( title );

		// 三个主按钮横排(v0.7.8.43 参考手游布局)
		var row = new Panel() { Classes = "menu-row" };
		root.AddChild( row );
		AddButton( row, "CREATE GAME", "host" );
		AddButton( row, "JOIN GAME", "join" );
		AddButton( row, "VS BOTS", "bots" );

		_status = new Label() { Classes = "status" };
		root.AddChild( _status );

		var ver = new Label() { Classes = "version" };
		ver.Text = GameConfig.Version;
		root.AddChild( ver );

		var hint = new Label() { Classes = "hint" };
		hint.Text = "WASD MOVE — SPACE SPLIT — R FEED";
		root.AddChild( hint );

		TryLoadAvatar();
		_built = true;
	}

	void AddButton( Panel parent, string text, string id )
	{
		var b = new Label() { Classes = $"btn {id}" };
		b.Text = text;
		// lambda 必须捕获 this(调实例方法)——无捕获的静态 lambda 在热重载后无法重映射,
		// 点击会抛 NoMatchLambda 异常(实测:按钮全变"点了没反应")
		b.AddEventListener( "onclick", () => OnMenuButton( id ) );
		parent.AddChild( b );
	}

	/// <summary> 图标小按钮(右上角设置/排行榜):dobou 槽底图 + 像素图标。
	/// 图标不定位不设尺寸,全交给 scss(.icon-btn 的 flex 居中 + .icon-btn-img 的宽高)——
	/// 之前图标绝对定位,参照物是整个右上角容器而不是按钮,两个图标叠在一起(实测);
	/// C# 内联样式会压过样式表,这里设了尺寸用户在 scss 里就改不动了 </summary>
	void AddIconButton( Panel parent, string iconPath, string id )
	{
		var b = new Panel() { Classes = "icon-btn" };
		var icon = new Image() { Classes = "icon-btn-img" };
		icon.Texture = Texture.Load( iconPath );
		icon.Style.PointerEvents = PointerEvents.None;   // 点击穿透到按钮
		b.AddChild( icon );
		b.AddEventListener( "onclick", () => OnMenuButton( id ) );   // 捕获 this:热重载可重映射
		parent.AddChild( b );
	}

	/// <summary> 本机玩家头像:Steam 拉取(下载异步,OnUpdate 1s 节流重试),失败落占位图 </summary>
	void TryLoadAvatar()
	{
		if ( _avatar is null || !IsValid ) return;

		var sid = (long)(ulong)Sandbox.Utility.Steam.SteamId;
		var tex = sid != 0 ? Texture.LoadAvatar( sid, 128 ) : null;
		_avatar.Texture = tex ?? Texture.Load( GameConfig.AvatarPlaceholderPath, false );
	}

	/// <summary> 菜单按钮点击(实例方法:热重载可重映射) </summary>
	void OnMenuButton( string id )
	{
		GameSfx.UiClick();
		CircleroyaleGame.Current?.MenuAction( id switch { "host" => 0, "join" => 1, "bots" => 2, "settings" => 4, "rankings" => 5, _ => 3 } );
	}

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

		// 强制光标可见可交互(面板默认 PointerEvents=None,Auto 模式下没人"想要鼠标"光标就没了);
		// Hide() 时恢复 Auto。放在 OnUpdate 每帧断言,任何样式级联意外都兜得住
		Mouse.Visibility = MouseVisibility.Visible;

		BuildUi();

		// 键盘保底(编辑器内嵌视口鼠标事件不可靠,键盘一定能进来):
		// [4] 设置页;数字 5 直读(Slot5 未在 Input 配置里)= 排行榜;QUIT 已移除
		if ( Input.Pressed( "Slot1" ) ) CircleroyaleGame.Current?.MenuAction( 0 );
		if ( Input.Pressed( "Slot2" ) ) CircleroyaleGame.Current?.MenuAction( 1 );
		if ( Input.Pressed( "Slot3" ) ) CircleroyaleGame.Current?.MenuAction( 2 );
		if ( Input.Pressed( "Slot4" ) ) CircleroyaleGame.Current?.MenuAction( 4 );
		if ( Input.Keyboard.Pressed( "5" ) ) CircleroyaleGame.Current?.MenuAction( 5 );

		// 等级角标(云端 XP 异步加载,GameXp 内部节流自愈重试)
		GameXp.EnsureLocalXp();
		SetText( _level, GameXp.LevelText() );

		// 头像没拉到(Steam 下载异步)每秒重试一次,拉到即停
		if ( _avatar.IsValid() && _avatar.Texture is null && _sinceAvatarTry > 1f )
		{
			_sinceAvatarTry = 0;
			TryLoadAvatar();
		}
	}

	static void SetText( Label l, string text )
	{
		if ( l is null || !l.IsValid() || l.Text == text ) return;
		l.Text = text;
	}

	/// <summary> 状态行(连接中 / 失败重试提示) </summary>
	public void SetStatus( string text )
	{
		if ( _status.IsValid() ) _status.Text = text;
	}

	/// <summary> 开局后隐藏菜单(Panel 无 Enabled,直接禁掉整个 GameObject),并把光标还给游戏 </summary>
	public void Hide()
	{
		if ( !GameObject.Enabled ) return;   // 已隐藏:别动光标(ReconcileUi 每帧调,房间页要光标可点)
		Mouse.Visibility = MouseVisibility.Auto;
		GameObject.Enabled = false;
	}

	/// <summary> 从房间/结算回菜单(M5):重新显示(OnUpdate 恢复光标断言) </summary>
	public void Show()
	{
		GameObject.Enabled = true;
		BuildUi();
		if ( _status.IsValid() ) _status.Text = "";
	}
}
slfy.circleroyale / ui/settingspanel.cs.scss
Game game
SettingsPanel {
	position: absolute;
	left: 0;
	top: 0;
	right: 0;
	bottom: 0;
	font-family: PixelFusion;
	color: #4a3b63;
	pointer-events: all;
	background-color: rgba( 234, 244, 252, 0.88 );   // 浅蓝磨砂(像素风)

	.sp-title {
		position: absolute;
		left: 0;
		right: 0;
		top: 88px;
		font-size: 48px;
		font-weight: 800;
		letter-spacing: 10px;
		text-align: center;
		color: #f272a7;
		text-shadow: 0 4px 0 #4a3b63;
	}

	.sp-hint {
		position: absolute;
		left: 0;
		right: 0;
		top: 178px;
		font-size: 13px;
		letter-spacing: 3px;
		text-align: center;
		color: rgba( 74, 59, 99, 0.6 );
	}

	// 设置行(v0.7.8.42):dobou 奶油按钮九宫格,600 宽居中(位置由 C# 设 left:center)
	.sp-row {
		height: 54px;
		padding-top: 8px;   // 用户要求:文字下移 8 像素
		font-size: 20px;
		font-weight: 700;
		letter-spacing: 3px;
		text-align: center;
		color: #4a3b63;
		border-image: url( ui/skin/btn_white.png ) 52 fill / 26px stretch;
		border-image-tint: #ceecee;   // 浅绿底(与 UiKit 药丸一致)
		cursor: pointer;
		pointer-events: all;
	}

	.sp-row:hover {
		border-image-tint: #e4f7dd;
		color: #277e4b;
	}

	// 返回按钮(v0.7.8.49):UiKit 药丸按钮(外观在 UiKit.cs.scss),这里只管摆放
	.sp-back {
		position: absolute;
		left: 50%;
		margin-left: -110px;   // 自己宽度 220 的一半,任意分辨率水平居中
		top: 848px;
		width: 220px;
		padding-top: 8px;
	}
}
slfy.circleroyale / Game/GameAchievements.cs
Game game
using System;
using System.Collections.Generic;

/// <summary>
/// 成就对接(v0.7.8.14,共 25 条):成就本体在 sbox.game 游戏包页面 Services > Achievements 配置
/// (ident/标题/描述/图标/分值/解锁模式),代码只按 ident 触发 Manual 成就。
/// 引擎行为(源码 AchievementCollection.ManualUnlock):ident 未配置/已解锁/非 Manual 时
/// 静默跳过——先写代码后配页面,配好即生效;每次事件都调 Unlock,由引擎去重。
/// Unlock 只对**本机玩家**生效(专用服务器自动 no-op)——所有触发点必须先 GameSfx.IsMine
/// 判定"是不是本机赚到的",host 不替客户端解锁。
/// 累计型成就(吃食物总数等)走包页面 Stat 绑定(Stats.Increment 即可),不走这里。
/// 进程内计数器(击杀数/捡包数)重启会清零——一次性成就解锁由云端去重,只影响"晚几局解锁"。
/// </summary>
public static class GameAchievements
{
	// ---- 首批 5 条(v0.7.8.13)----
	public const string FirstBlood = "first_blood";     // 首次吃掉一名玩家
	public const string Godlike = "godlike";            // 单局五连杀
	public const string SpikeBlast = "spike_blast";     // 尖刺分身炸掉比自己大的球
	public const string TeamWinner = "team_winner";     // 团队赛所在队夺冠
	public const string Champion = "champion";          // 个人第一名结算

	// ---- 第二批 20 条(v0.7.8.14)----
	public const string DoubleKill = "double_kill";     // 单局双连杀
	public const string TripleKill = "triple_kill";     // 单局三连杀
	public const string Rampage = "rampage";            // 单局四连杀
	public const string BigPrey = "big_prey";           // 单口吞掉大球
	public const string Revenge = "revenge";            // 吃掉上次吃掉自己的玩家
	public const string Hunter10 = "hunter_10";         // 累计吃 10 名玩家
	public const string Hunter50 = "hunter_50";         // 累计吃 50 名玩家
	public const string HalfTon = "half_ton";           // 一条命总质量 500
	public const string OneTon = "one_ton";             // 一条命总质量 1000
	public const string TwoTon = "two_ton";             // 一条命总质量 2000
	public const string FiveTon = "five_ton";           // 一条命总质量 5000
	public const string FirstTool = "first_tool";       // 首次使用道具
	public const string FullBag = "full_bag";           // 两格背包同时有货
	public const string PowerHoarder = "power_hoarder"; // 累计捡 5 个道具入包
	public const string FirstFeed = "first_feed";       // 首次喂食触发道具
	public const string SpikeOp = "spike_op";           // 首次放出尖刺分身
	public const string OctoSplit = "octo_split";       // 同时拥有 8 颗身体
	public const string Untouchable = "untouchable";    // 一条命存活 5 分钟
	public const string FirstDeath = "first_death";     // 第一次被吃
	public const string FirstWin = "first_win";         // 首次获胜

	const float BigPreyMass = 800f;   // 单口吞入质量门槛(返还前的口径,对应 ~1000 质量的球)

	/// <summary> 一条命内的质量里程碑(与 MassSteps 一一对应解锁 HalfTon/OneTon/TwoTon/FiveTon) </summary>
	static readonly float[] MassSteps = { 500f, 1000f, 2000f, 5000f };

	static int _lifetimeKills;     // 进程内累计击杀
	static int _lifetimePickups;   // 进程内累计道具入包
	static long _lastKiller;       // 最近吃掉本机的玩家(复仇名单)

	/// <summary> 本机击杀(每次吃球都调,引擎对已解锁静默去重);
	/// combo = 本局连杀数(GameSfx 10s 窗口),massGained = 本口吞入质量 </summary>
	public static void LocalKill( int combo, float massGained, long eatenSteamId )
	{
		GameXp.OnLocalKill();   // 经验记账:本局击杀(v0.7.8.19)

		Unlock( FirstBlood );
		if ( combo >= 2 ) Unlock( DoubleKill );
		if ( combo >= 3 ) Unlock( TripleKill );
		if ( combo >= 4 ) Unlock( Rampage );
		if ( combo >= 5 ) Unlock( Godlike );

		if ( massGained >= BigPreyMass ) Unlock( BigPrey );
		if ( eatenSteamId != 0 && eatenSteamId == _lastKiller ) Unlock( Revenge );

		_lifetimeKills++;
		if ( _lifetimeKills >= 10 ) Unlock( Hunter10 );
		if ( _lifetimeKills >= 50 ) Unlock( Hunter50 );
	}

	/// <summary> 本机被吃:参与奖 + 记仇(之后吃掉他解锁 Revenge) </summary>
	public static void LocalDeath( long killerSteamId )
	{
		_lastKiller = killerSteamId;
		Unlock( FirstDeath );
	}

	/// <summary>
	/// 每帧体检(GameHud.OnUpdate 调,全端):一条命质量里程碑 / 8 颗身体 / 存活 5 分钟 / 背包双满。
	/// 门槛判定每帧重复触发由引擎去重,无需本地状态。
	/// </summary>
	public static void MatchTick( CircleroyaleGame game )
	{
		if ( !game.IsMatchStarted || MatchState.MatchOver ) return;

		var local = game.LocalBall;
		if ( !local.IsValid() || !local.Alive ) return;

		var mass = game.TotalMassFor( local );
		if ( mass >= MassSteps[0] ) Unlock( HalfTon );
		if ( mass >= MassSteps[1] ) Unlock( OneTon );
		if ( mass >= MassSteps[2] ) Unlock( TwoTon );
		if ( mass >= MassSteps[3] ) Unlock( FiveTon );

		int pieces = 0;
		foreach ( var c in game.Cells )
		{
			if ( c.PieceKind == CellPiece.Kind.SplitPiece && c.OwnerSteamId == local.OwnerSteamId ) pieces++;
		}
		if ( pieces >= 8 ) Unlock( OctoSplit );

		if ( local.LifeSeconds > 300f ) Unlock( Untouchable );

		// 背包双满(PowerA/B 是 [Sync],255=空)——同步到位才触发,无时序竞态
		if ( local.PowerA != 255 && local.PowerB != 255 ) Unlock( FullBag );
	}

	/// <summary> 本机按 Q/E 释放道具(UsePowerSlot 横幅处调):首次用道具 / 首次放尖刺 </summary>
	public static void PowerUsed( byte kind )
	{
		Unlock( FirstTool );
		if ( kind == (byte)PowerUpManager.Kind.Spike ) Unlock( SpikeOp );
	}

	/// <summary> 本机捡到道具入包(host 本机拾取与客户端广播落地两条路都会调):累计 5 个 </summary>
	public static void PowerStored()
	{
		if ( ++_lifetimePickups >= 5 ) Unlock( PowerHoarder );
	}

	/// <summary> 喂食触发道具(host 的 Feed 判定 + 客户端 PowerBanner 落地,两路都过 IsMine) </summary>
	public static void FeedTriggered( long feederSteamId )
	{
		if ( GameSfx.IsMine( feederSteamId ) )
			Unlock( FirstFeed );
	}

	/// <summary> 尖刺分身炸掉比主人更大的球(host 判定;owner=尖刺主人,广播到各端后本机判定) </summary>
	public static void SpikeMinionBlast( long ownerSteamId )
	{
		if ( GameSfx.IsMine( ownerSteamId ) )
			Unlock( SpikeBlast );
		if ( Networking.IsActive )
			NetworkManager.SpikeReward( ownerSteamId );
	}

	/// <summary> 尖刺奖励广播落地(客户端;host 已在 SpikeMinionBlast 判过,挡回声) </summary>
	public static void OnSpikeRewardRemote( long ownerSteamId )
	{
		if ( NetworkManager.IsAuthority ) return;
		if ( GameSfx.IsMine( ownerSteamId ) )
			Unlock( SpikeBlast );
	}

	/// <summary> 比赛结算(全端各自判定):团队赛=本队总质量第一(并列也算);普通赛=个人第一名。
	/// 返回是否获胜(GameXp 结算经验用) </summary>
	public static bool Settlement( ScoreWire[] standings, long localSteamId )
	{
		if ( standings is null || standings.Length == 0 ) return false;

		bool won = false;

		if ( MatchState.IsTeam )
		{
			var local = CircleroyaleGame.Current?.LocalBall;
			if ( local.IsValid() && local.TeamIndex >= 0 )
			{
				var totals = new Dictionary<int, float>();
				foreach ( var row in standings )
				{
					var t = totals.TryGetValue( row.Team, out var m ) ? m : 0f;
					totals[row.Team] = t + row.Mass;
				}

				var mine = totals.GetValueOrDefault( local.TeamIndex );
				var best = -1f;
				foreach ( var kv in totals )
				{
					if ( kv.Value > best ) best = kv.Value;
				}

				if ( mine >= best )
				{
					Unlock( TeamWinner );
					won = true;
				}
			}
		}
		else if ( standings[0].SteamId == localSteamId )
		{
			Unlock( Champion );
			won = true;
		}

		if ( won ) Unlock( FirstWin );
		return won;
	}

	static void Unlock( string ident ) => Sandbox.Services.Achievements.Unlock( ident );
}
slfy.circleroyale / Game/ScoreUploader.cs
Game game
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

/// <summary>
/// 成绩上传与全服榜读取(M5)。云端走 Sandbox.Services:
/// 上传 = Stats.SetValue("cr_match_mass", 本局总质量) + Flush;
/// 读取 = Leaderboards.GetFromStat 聚合 Max(全服最高单局)。
/// ⚠️ 未发布的本地包后端没有包身份,Refresh 内部吞 404(Entries 保持空)——
/// 展示层据此自动降级到 LocalBoard 的本地持久化榜。
/// </summary>
public static class ScoreUploader
{
	/// <summary> 排行榜统计名(聚合 Max = 单局最高总质量) </summary>
	public const string StatName = "cr_match_mass";

	/// <summary> 本机成绩上云(按登录用户记账;拿不到本机球就静默跳过) </summary>
	public static void SubmitOwn( CircleroyaleGame game )
	{
		try
		{
			var local = game?.LocalBall;
			if ( !local.IsValid() ) return;

			var mass = game.TotalMassFor( local );
			Sandbox.Services.Stats.SetValue( StatName, mass );
			Sandbox.Services.Stats.Flush();
			GameLog.Info( $"[score] uploaded {mass:0} ({StatName})" );
		}
		catch ( Exception e )
		{
			GameLog.Info( $"[score] upload failed: {e.Message}" );
		}
	}

	/// <summary> 拉全服榜 Top10(异步;空/失败回调 null——展示层降级本地榜) </summary>
	public static async void FetchGlobal( Action<List<( string Name, float Mass )>> done )
	{
		try
		{
			var board = Sandbox.Services.Leaderboards.GetFromStat( StatName );
			board.SetAggregationMax();
			board.SetSortDescending();
			board.FilterByNone();
			board.MaxEntries = 10;
			await board.Refresh();

			var list = new List<( string Name, float Mass )>();
			foreach ( var e in board.Entries )
				list.Add( ( e.DisplayName, (float)e.Value ) );

			done( list.Count > 0 ? list : null );
		}
		catch ( Exception e )
		{
			GameLog.Info( $"[score] global board fetch failed: {e.Message}" );
			done( null );
		}
	}
}
slfy.circleroyale / Player/Ball.cs
Game game
using System;

/// <summary>
/// 球(真人/bot 共用):**owner 本地模拟移动**(真人=键盘输入,bot=host 的 BotBrain),
/// 权威数值(Mass/IsBot/ColorIndex/PlayerName/OwnerSteamId)由 host 经 [Sync(FromHost)] 下发,
/// 位置随网络 transform 自动同步(owner → 其他端)。
/// 视觉:球体/表情/头像全由 NeonRenderer 批量绘制(表情按 Mood 选 face_N 批次;
/// 真人 Steam 头像各端按 OwnerSteamId 自取后经 NeonRenderer.SetAvatar 登记)。
/// </summary>
public sealed class Ball : Component
{
	[Sync( SyncFlags.FromHost )] public float Mass { get; set; } = GameConfig.StartMass;

	[Sync( SyncFlags.FromHost )] public bool IsBot { get; set; }

	[Sync( SyncFlags.FromHost )] public int ColorIndex { get; set; }

	[Sync( SyncFlags.FromHost )] public string PlayerName { get; set; } = "Player";

	/// <summary> 拥有者 SteamId(bot 为 0);host 写入,各端据此加载对应头像 </summary>
	[Sync( SyncFlags.FromHost )] public long OwnerSteamId { get; set; }

	/// <summary> 队伍号(团队赛 0..N-1,普通赛 -1);host 开局轮转分配,重生不变 </summary>
	[Sync( SyncFlags.FromHost )] public int TeamIndex { get; set; } = -1;

	/// <summary> 蛰伏备用球(v0.7.8.25):房间预留/本局多余的 bot。Alive=false 与"死亡"无法区分,
	/// TickBots 曾把 16 颗蛰伏球当尸体原地复活成无队 FFA bot 灌进 T1——必须有独立标记 </summary>
	[Sync( SyncFlags.FromHost )] public bool Dormant { get; set; }

	/// <summary> 主人账号等级(v0.7.8.19):各端算好写进**自己的球**(普通 [Sync]=owner 可写),
	/// 全场名牌可见;bot 恒 0 不显示 </summary>
	[Sync] public int AccountLevel { get; set; }

	// ---- 道具 buff(v0.7.3.0):host 权威写入,各端展示/效果共用。
	// Kind 残留旧值没关系,HasBuff 只看 _sinceBuff < BuffDuration;
	// serial 自增保证"同类 buff 再捡一次"也触发 [Change](客户端重置本地计时) ----

	[Sync( SyncFlags.FromHost )] public byte BuffKind { get; set; } = 255;

	[Sync( SyncFlags.FromHost )] public float BuffDuration { get; set; }

	[Sync( SyncFlags.FromHost ), Change( nameof( OnBuffSynced ) )] public byte BuffSerial { get; set; }

	/// <summary> bot 表情档(v0.7.8.36 像素风):0=开心 1=兴奋(追猎) 2=惊恐(逃跑) 3=眩晕 4=瞌睡 5=得意(王牌追猎)。
	/// host 的 BotBrain 写入(FromHost),NeonRenderer 每帧按 Mood 现画表情脸;真人球不参与 </summary>
	[Sync( SyncFlags.FromHost )] public byte Mood { get; set; }

	TimeSince _sinceBuff = 999f;

	// ---- 道具背包(v0.7.5.0 主动使用):拾取存入 Q/E 两格,按键释放效果。
	// 255 = 空槽;死亡/重生清空(机会不跨命携带) ----

	[Sync( SyncFlags.FromHost )] public byte PowerA { get; set; } = 255;

	[Sync( SyncFlags.FromHost )] public byte PowerB { get; set; } = 255;

	/// <summary> 各端收到 buff 变化(含 host 本地写入)即重置本地计时——客户端据此倒算剩余时间 </summary>
	void OnBuffSynced() => _sinceBuff = 0;

	/// <summary> host:给这颗球上 buff(质量罐等即时效果不走这里) </summary>
	public void ApplyBuff( byte kind, float duration )
	{
		BuffKind = kind;
		BuffDuration = duration;
		BuffSerial++;
		_sinceBuff = 0;
	}

	/// <summary> buff 是否生效中(host 权威判定与客户端展示同一公式,客户端自然过期无需通知) </summary>
	public bool HasBuff( PowerUpManager.Kind kind ) =>
		BuffKind == (byte)kind && _sinceBuff < BuffDuration;

	/// <summary> 当前 buff 剩余秒数(HUD 倒计时用;无 buff 为 0) </summary>
	public float BuffLeft => BuffKind == 255 ? 0f : MathF.Max( 0f, BuffDuration - _sinceBuff );

	/// <summary> 背包第 slot 格(0=Q 槽 1=E 槽)的道具种类;255=空 </summary>
	public byte PowerSlot( int slot ) => slot == 0 ? PowerA : PowerB;

	/// <summary> 拾取入包(v0.7.8.30 起**只进 E 槽**——Q 改为随时间充能的技能位,不再接收拾取);
	/// E 满返回 -1(道具留在场上) </summary>
	public int StorePower( byte kind )
	{
		if ( PowerB == 255 ) { PowerB = kind; return 1; }
		return -1;
	}

	/// <summary> host:使用背包道具——清槽并触发效果(PowerUpManager.ApplyEffect) </summary>
	public void UsePower( int slot )
	{
		var kind = PowerSlot( slot );
		if ( kind == 255 ) return;

		if ( slot == 0 ) PowerA = 255;
		else PowerB = 255;

		PowerUpManager.ApplyEffect( this, kind );
	}

	/// <summary> 按键释放背包槽(pressed 为 true 时处理):本地先播激活横幅+音效+轻震,
	/// 再走权威/RPC 路径 </summary>
	void UsePowerSlot( int slot, bool pressed )
	{
		if ( !pressed ) return;

		var kind = PowerSlot( slot );
		if ( kind == 255 ) return;   // 空槽:按键无效(不播横幅不发请求)

		CircleroyaleGame.Current?.Hud?.AddBanner( GameHud.ActivationText( kind ), GameHud.KindColor( kind ) );
		GameSfx.Pickup();
		CircleroyaleGame.Current?.Camera?.Shake( 5f );   // 放技能轻震(击杀震更猛,随 combo 加码)
		GameAchievements.PowerUsed( kind );   // 成就:首次用道具 / 首次放尖刺(v0.7.8.14)

		if ( NetworkManager.IsAuthority )
			UsePower( slot );
		else
			RequestUsePower( slot );
	}

	/// <summary>
	/// 是否存活。**球对象永不销毁**:死亡 = Alive=false(同步字段,渲染/AI/判定全部跳过),
	/// 重生 = host 置回 true。绕开"进行中 NetworkSpawn 不复制到已连接客户端"的引擎行为(实测大坑)。
	/// </summary>
	[Sync( SyncFlags.FromHost )]
	public bool Alive { get; set; } = true;

	public float Radius => GameConfig.StartRadius * MathF.Sqrt( Mass / GameConfig.StartMass );

	/// <summary> 当前速度(v0.7.8.32:bot 拦截预判按它打提前量;只读,BallInput 内部维护) </summary>
	public Vector3 Velocity => _velocity;

	/// <summary> 本条命已存活秒数(成就"存活 5 分钟"用;重生时 _sinceSpawn 归零) </summary>
	public float LifeSeconds => _sinceSpawn;

	/// <summary> 本球的霓虹色(由 ColorIndex 从调色板取) </summary>
	public Color NeonColor => GameConfig.Palette[Math.Clamp( ColorIndex, 0, GameConfig.Palette.Length - 1 )];

	/// <summary> 移速:质量曲线 × 加速 buff(⚡ 时 ×1.4;Speed 属性各端同算,owner 模拟与 host 校验一致) </summary>
	public float Speed
	{
		get
		{
			var s = MathF.Max( GameConfig.MinSpeed,
				GameConfig.BaseSpeed * MathF.Pow( GameConfig.StartMass / Mass, GameConfig.SpeedCurve ) );
			if ( HasBuff( PowerUpManager.Kind.Speed ) ) s *= GameConfig.PowerUpSpeedBoost;
			// 敌营大本营减速(M7.2 安全屋);InHostileHq 对无队/非领土恒 false
			if ( TerritoryManager.InHostileHq( TeamIndex, WorldPosition ) ) s *= GameConfig.TerritoryHqSlowFactor;
			return s;
		}
	}

	/// <summary> 是否出生保护中(虚环护盾表现,双向免战) </summary>
	public bool IsProtected => _sinceSpawn < GameConfig.SpawnProtectSeconds;

	/// <summary> 领土职业(M7.4,host 随机分配随复活重掷;255=无。0 指挥官 1 工兵 2 护卫 3 坦克) </summary>
	[Sync( SyncFlags.FromHost )]
	public byte TerritoryClass { get; set; } = 255;

	TimeSince _sinceClassCast = 999f;   // 本地冷却估算(host 权威冷却在 ClassSkillManager,未好时静默拒绝)

	/// <summary> 本地估算的职业冷却剩余秒(HUD Q 钮倒 sweep 用) </summary>
	public float ClassCooldownRemaining =>
		TerritoryClass > 3 ? 0f : MathF.Max( 0f, ClassSkillManager.CooldownOf( TerritoryClass ) - _sinceClassCast );

	/// <summary> Q 施放职业技能(M7.4 领土):本地即时反馈+冷却估算起表;host 直调 / 客户端发 RPC </summary>
	void TryCastClassSkill()
	{
		if ( !Alive || TerritoryClass > 3 ) return;
		if ( ClassCooldownRemaining > 0f ) return;

		CircleroyaleGame.Current?.Hud?.AddBanner( ClassSkillManager.NameOf( TerritoryClass ) + "!", new Color( 0.98f, 0.83f, 0.35f ) );
		GameSfx.Pickup();
		CircleroyaleGame.Current?.Camera?.Shake( 5f );
		_sinceClassCast = 0;   // 本地估算起表

		if ( NetworkManager.IsAuthority )
			ClassSkillManager.Cast( this );
		else
			NetworkManager.RequestClassSkill();
	}

	/// <summary> 球体背景填充色:真人=头像主色取样,bot=霓虹配色 </summary>
	public Color BackgroundColor => _avatarColor ?? NeonColor;

	Vector3 _velocity;
	TimeSince _sinceSpawn;
	TimeSince _sinceWallHit = 999f;    // 撞墙脉冲节流(贴墙滑行不连爆)
	Color? _avatarColor;
	Vector2 _botDir;
	Vector2 _lastAimDir = Vector2.Zero;     // 光标瞄准方向(分裂/吐孢子用,v0.7.0.0)

	/// <summary> 当前瞄准方向(本机球:渲染外缘箭头用;bot 恒 Zero 不画) </summary>
	public Vector2 AimDir => _lastAimDir;
	TimeSince _sinceEject;                  // 客户端按住连吐的节流(host 还有同款校验)
	TimeSince _sinceAvatarRetry = 1f;   // 首帧立即尝试
	int _avatarRetryCount;
	bool _avatarReady;
	bool _wasAlive = true;

	/// <summary> 死亡时刻(host 记录,用于 bot 原地复活的延时) </summary>
	public TimeSince SinceDeath { get; private set; }

	/// <summary>
	/// host 侧标记:这颗球是为远程连接生成的(仅权威端有意义,不联网)。
	/// ScanBalls/SetLocalBall 用它把远程球(含竞态下未网络化的幽灵球)排除出本机球候选,
	/// 否则同账号双开时幽灵球会顶掉 host 自己的球、相机来回跳(实测)。
	/// </summary>
	public bool IsRemoteOwned { get; set; }

	/// <summary> host 侧标记死亡(对象保留,原地复用) </summary>
	public void MarkDead()
	{
		Alive = false;
		SinceDeath = 0;
		_velocity = Vector3.Zero;
	}

	/// <summary> 重置出生保护计时(host 在玩家真正入局时调用——pre-snapshot 球的
	/// 保护期在握手期间就空跑完了,实测加入 1.6s 即被吃;IsProtected 按各端本地计时判定,
	/// host 重置即恢复权威判定,客户端自己的计时随快照应用另算) </summary>
	public void RefreshSpawnProtection() => _sinceSpawn = 0;

	/// <summary> 瞬移(分身晋升主球时用):位置归 owner 模拟,owner 端必须自己搬过去;
	/// 不触发保护期/重播音效——这是身份延续,不是重生 </summary>
	public void TeleportTo( Vector3 pos )
	{
		WorldPosition = new Vector3( pos.x, pos.y, 0f );
		_velocity = Vector3.Zero;
	}

	/// <summary> host 侧初始化(仅权威端调用;客户端字段全部走网络同步)。
	/// teamIndex ≥ 0 时同时写入队伍号;团队赛颜色锁队伍色,普通赛随机霓虹色 </summary>
	public void Init( bool isBot, int teamIndex = -1 )
	{
		IsBot = isBot;
		if ( teamIndex >= 0 ) TeamIndex = teamIndex;

		ColorIndex = MatchState.IsTeam
			? ( ( TeamIndex % GameConfig.Palette.Length ) + GameConfig.Palette.Length ) % GameConfig.Palette.Length
			: (int)Game.Random.Float( 0f, GameConfig.Palette.Length - 0.001f );

		_sinceSpawn = 0;
		_velocity = Vector3.Zero;

		// 道具 buff 与背包随重生清掉(机会不跨命携带)
		BuffKind = 255;
		BuffDuration = 0f;
		PowerA = 255;
		PowerB = 255;
	}

	/// <summary> bot 由 BotBrain 每帧写入的移动方向(真人走 BallInput,不用这个) </summary>
	public void SetBotDirection( Vector2 dir ) => _botDir = dir;

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

		// 所有机器统一从网络对象的 OnStart 注册(host 生成 / 客户端随复制出现)
		CircleroyaleGame.Current?.RegisterBall( this );
		if ( !IsProxy && !IsBot )
			CircleroyaleGame.Current?.SetLocalBall( this );

		TryLoadAvatar();
	}

	/// <summary>
	/// 加载头像(真人按 OwnerSteamId 拉 Steam 头像:下载有延迟,失败每秒重试,15 次后转占位图;
	/// 拿到后经 NeonRenderer.SetAvatar 登记独立贴图批次)。bot 表情脸由 NeonRenderer 按 Mood
	/// 从 face_0..5 现画,不在这里加载。
	/// </summary>
	void TryLoadAvatar()
	{
		if ( IsBot )
		{
			_avatarReady = true;
			return;
		}

		if ( OwnerSteamId == 0 ) return;   // 同步未到,等下一轮重试

		var avatar = Texture.LoadAvatar( OwnerSteamId, 128 );
		if ( avatar is null ) return;

		SetAvatarTexture( avatar );
		_avatarReady = true;
	}

	/// <summary> 头像贴图登记进 NeonRenderer(渲染器未就绪时由其静态表待命,下一帧生效) </summary>
	void SetAvatarTexture( Texture tex )
	{
		if ( tex is null )
		{
			Log.Warning( $"avatar load failed: '{PlayerName}' steam={OwnerSteamId}" );
			return;
		}

		NeonRenderer.SetAvatar( OwnerSteamId, tex );
		_avatarColor = SampleAverageColor( tex );
	}

	/// <summary> 头像主色:全图不透明像素的均值(用作球底填充色) </summary>
	static Color? SampleAverageColor( Texture tex )
	{
		try
		{
			var pixels = tex.GetPixels( 0 );
			if ( pixels is null || pixels.Length == 0 ) return null;

			float r = 0f, g = 0f, b = 0f;
			int n = 0;
			for ( int i = 0; i < pixels.Length; i++ )
			{
				var p = pixels[i];
				if ( p.a < 32 ) continue;
				r += p.r;
				g += p.g;
				b += p.b;
				n++;
			}
			if ( n == 0 ) return null;

			return new Color( r / n / 255f, g / n / 255f, b / n / 255f );
		}
		catch
		{
			return null;
		}
	}

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

		// 头像重试:每秒一次,15 次未拿到转占位图(Steam 头像下载慢是常态)
		if ( !_avatarReady && _sinceAvatarRetry > 1f )
		{
			_sinceAvatarRetry = 0;
			if ( _avatarRetryCount++ >= 15 )
			{
				SetAvatarTexture( Texture.Load( GameConfig.AvatarPlaceholderPath, false ) );
				_avatarReady = true;
			}
			else
			{
				TryLoadAvatar();
			}
		}

		// ---- 分裂/吐孢子输入(M4.11:每帧路径,仅本机真人、活球)----
		// 权威端直接执行;客户端发实例 RPC([Rpc.Host],host 侧校验后执行,分身是 host 权威数据)。
		// 音效在按键瞬间即播(本地即时反馈;host 校验不过顶多空响一次)
		if ( IsProxy || IsRemoteOwned || IsBot || !Alive ) return;
		if ( MatchState.MatchOver ) return;   // 比赛结束:全场冻结,输入不响应

		// 停在等待页(P 退局/掉线恢复)且不在对局时停止操控:球留在原地,防隔屏盲开。
		// ⚠️ 必须带 !IsMatchStarted:只要还在对局(_gameStarted)就绝不禁输入——
		// _lobbyWaiting 若因异常残留为 true,会把对局中的移动/分裂/吐孢子/瞄准全锁死
		// (实测:不能分身/吐丝、瞄准箭头冻在旧方向)
		{
			var g = CircleroyaleGame.Current;
			if ( g is not null && g.IsClientWaiting && !g.IsMatchStarted )
			{
				return;
			}
		}

		// 瞄准(v0.7.0.0 用户需求):_lastAimDir 供渲染瞄准线用;动作方向由 host 按身体逐个算
		var aimDir = BallInput.GetAimDir( Scene.Camera, WorldPosition );
		if ( aimDir.Length > 0.001f ) _lastAimDir = aimDir;

		// 鼠标键并行(v0.7.4.1 用户需求):右键分裂、左键吐丝("Attack1"/"Attack2" 为引擎内建按钮,
		// 官方项目同款用法);空格/R 保留,两套键位共存。
		// v0.7.8.7 手感:传**光标世界坐标**而非方向——host 对每个身体单独算指向(分身也朝光标)
		var aimPoint = BallInput.GetAimPoint( Scene.Camera );

		if ( GameConfig.EnableSplit && ( Input.Pressed( "Jump" ) || Input.Pressed( "Attack2" ) ) )
		{
			if ( Mass >= GameConfig.SplitMinMass ) GameSfx.Split( WorldPosition );

			if ( NetworkManager.IsAuthority )
				CircleroyaleGame.Current?.DoSplit( this, aimPoint );
			else
				RequestSplit( aimPoint );
		}

		if ( GameConfig.EnableEject
			&& ( Input.Down( "Reload" ) || Input.Down( "Attack1" ) )
			&& _sinceEject > GameConfig.EjectCooldownSeconds )
		{
			_sinceEject = 0;
			if ( Mass >= 2f ) GameSfx.Eject( WorldPosition );   // 至少能吐最小一颗(下限 1 + 消耗 1)

			if ( NetworkManager.IsAuthority )
				CircleroyaleGame.Current?.DoEject( this, aimPoint );
			else
				RequestEject( aimPoint );
		}

		// 道具主动使用(v0.7.5.0):Q/E 释放背包里的道具——权威端直接触发,客户端发 RPC。
		// banner 按键即播(本地即时反馈,不等 host 往返);槽位空则什么都不发生
			// Q 键(M7.4):领土模式=职业技能(冷却制,PowerA 时间充能 pass 在领土下冻结);
			// 其他模式=充能道具槽(既有行为)。E 键道具照旧
			// 文本输入(聊天框)持键盘焦点时一律不读按键:引擎把按键无差别丢进所有 InputContext,
			// 不挡的话聊天里打 Q/E 会直接放职业技/道具
			if ( !BallInput.Typing )
			{
				if ( MatchState.IsTerritory )
				{
					if ( Input.Keyboard.Pressed( "Q" ) ) TryCastClassSkill();
				}
				else
				{
					UsePowerSlot( 0, Input.Keyboard.Pressed( "Q" ) );
				}
				UsePowerSlot( 1, Input.Keyboard.Pressed( "E" ) );
			}
	}

	protected override void OnFixedUpdate()
	{
		if ( IsProxy ) return;   // 只有拥有者(真人)或 host(bot)模拟
		if ( IsRemoteOwned ) return;   // host 侧远程球由其主人模拟;未网络化的幽灵球绝不本地模拟

		// 死亡状态:停住不动(对象保留);Alive 翻回 true 时本地重生(随机换位+保护期)
		if ( !Alive )
		{
			_velocity = Vector3.Zero;
			_wasAlive = false;
			return;
		}
		if ( !_wasAlive )
		{
			_wasAlive = true;
			_sinceSpawn = 0;          // 复活保护期(本地表现;权威保护期在 host 的 Init 里)
			_velocity = Vector3.Zero;
			// owner 自己随机换位(位置归 owner 模拟);领土模式默认回大本营,
			// 死亡面板点 FRONT(M7.2)则换到最近己方占领格——偏好随点击存本机,此处消费
			WorldPosition = RandomSpawnPos( TeamIndex, CircleroyaleGame.Current?.ConsumeFrontRespawn() ?? false, WorldPosition );
			if ( GameSfx.IsMine( OwnerSteamId ) )   // 只播本机球:开局 32 个 bot 同帧生成曾叠 33 层音(v0.7.8.10 修)
				GameSfx.Respawn( WorldPosition );
			GameSfx.ResetEatCount();   // 下一条命吃食物音从 0 重新计
			GameSfx.ResetCombo();      // 连击清零(新的一条命重新起算)
			GameLog.Info( $"[ball] local respawn '{PlayerName}'" );
		}

		// 比赛结束:全场冻结(结算面板接管,谁也不许再动)
		if ( MatchState.MatchOver )
		{
			_velocity = Vector3.Zero;
			return;
		}

		// 真人 = 本地输入(owner 模拟);bot = BotBrain 写入的方向。
		// 分裂/吐孢子的输入判定已移到 OnUpdate(每帧路径)——Input 按键边沿按渲染帧刷新,
		// 固定步进的 FixedUpdate 可能错过 Pressed 边沿(按了没反应的隐患)
		var dir = IsBot ? _botDir : BallInput.GetMoveDir( Scene.Camera );

		// 速度与转向都随质量衰减(批18①):体积越大极速越低、惯性越强转向越笨拙,
		// 大球必须提前预判走位。响应时间常数 = BaseTurnSpeed × (StartMass/Mass)^TurnCurve
		var turn = MathF.Max( GameConfig.MinTurnSpeed,
			GameConfig.BaseTurnSpeed * MathF.Pow( GameConfig.StartMass / Mass, GameConfig.TurnCurve ) );
		var t = 1f - MathF.Exp( -turn * Time.Delta );
		_velocity = Vector3.Lerp( _velocity, dir * Speed, t );

		var pos = WorldPosition + _velocity * Time.Delta;

		// 场地边界钳制(半径异常涨破场地时 half 兜底为 0,绝不让 Clamp 的 min>max 抛异常)
		var half = MathF.Max( 0f, GameConfig.ArenaHalfSize - Radius );
		var preX = pos.x;
		var preY = pos.y;
		pos.x = Math.Clamp( pos.x, -half, half );
		pos.y = Math.Clamp( pos.y, -half, half );
		pos.z = 0f;

		WorldPosition = pos;

		// 撞墙脉冲(v0.7.8.78):钳制真把位置按住 + 撞击速度够 + 节流,接触点来一圈扩散环。
		// 各端本地触发(真人 owner 机 / bot host 机),特效不做网络广播
		if ( _sinceWallHit > GameConfig.WallHitFxCooldown )
		{
			Vector3 contact;
			var hit = false;

			if ( preX != pos.x && MathF.Abs( _velocity.x ) > GameConfig.WallHitFxMinSpeed )
			{
				hit = true;
				contact = new Vector3( MathF.Sign( pos.x ) * ( GameConfig.ArenaHalfSize - 2f ), pos.y, 0f );
			}
			else if ( preY != pos.y && MathF.Abs( _velocity.y ) > GameConfig.WallHitFxMinSpeed )
			{
				hit = true;
				contact = new Vector3( pos.x, MathF.Sign( pos.y ) * ( GameConfig.ArenaHalfSize - 2f ), 0f );
			}
			else
			{
				contact = Vector3.Zero;
			}

			if ( hit )
			{
				_sinceWallHit = 0;
				NeonRenderer.WallPulse( contact, NeonColor );
			}
		}

		// 球体推挤(v0.7.8.24 球球大作战手感):吃不动对方=实心——推出与敌方的重叠
		ApplyPush( half );
	}

	/// <summary>
	/// 球体推挤(v0.7.8.24 球球大作战手感):**各自把自己推出重叠**——对方 bot/host 球走同代码路径、
	/// 真人球由其主人推,双方按质量权重让位,一帧内收敛到分离,不需要位置权威仲裁。
	/// 队友与自己的身体不推(BoB 惯例可重叠);能吃/被吃时不推(吃归 host 权威,吃了就不存在重叠);
	/// 尖刺分身是武器不推(碰到走武器判定);孢子不推。位置是 owner 权威,推完自动同步
	/// </summary>
	void ApplyPush( float half )
	{
		var game = CircleroyaleGame.Current;
		if ( game is null ) return;

		foreach ( var b in game.Balls )
		{
			if ( b is null || !b.IsValid() || !b.Alive || b == this ) continue;
			if ( b.OwnerSteamId == OwnerSteamId ) continue;
			if ( game.SameTeamSteam( OwnerSteamId, b.OwnerSteamId ) ) continue;

			var hi = MathF.Max( Mass, b.Mass );
			var lo = MathF.Min( Mass, b.Mass );
			if ( hi >= lo * GameConfig.EatRatio ) continue;   // 能吃就吃(host 权威),不推

			var dx = WorldPosition.x - b.WorldPosition.x;
			var dy = WorldPosition.y - b.WorldPosition.y;
			var rr = Radius + b.Radius;
			var d2 = dx * dx + dy * dy;
			if ( d2 >= rr * rr ) continue;
			var d = MathF.Sqrt( MathF.Max( d2, 0.0001f ) );
			var nx = d > 0.001f ? dx / d : 1f;
			var ny = d > 0.001f ? dy / d : 0f;
			// 完全重合兜底:推满 rr 的一半;正常情况推"重叠 × 对方质量占比"(越重让得越少)
			var push = d > 0.001f ? MathF.Min( rr - d, GameConfig.PushMaxStep ) * ( b.Mass / ( Mass + b.Mass ) ) : rr * 0.5f;

			WorldPosition = new Vector3(
				Math.Clamp( WorldPosition.x + nx * push, -half, half ),
				Math.Clamp( WorldPosition.y + ny * push, -half, half ), 0f );
		}

		// 敌方分身=实心障碍(自己不动,我让路全额推;能吃时不推——host 会吃掉它)
		foreach ( var c in game.Cells )
		{
			if ( c.PieceKind != CellPiece.Kind.SplitPiece ) continue;
			if ( c.OwnerSteamId == OwnerSteamId ) continue;
			if ( game.SameTeamSteam( OwnerSteamId, c.OwnerSteamId ) ) continue;
			if ( Mass >= c.Mass * GameConfig.EatRatio ) continue;

			var dx = WorldPosition.x - c.Pos.x;
			var dy = WorldPosition.y - c.Pos.y;
			var rr = Radius + c.Radius;
			var d2 = dx * dx + dy * dy;
			if ( d2 >= rr * rr ) continue;
			var d = MathF.Sqrt( MathF.Max( d2, 0.0001f ) );
			var nx = d > 0.001f ? dx / d : 1f;
			var ny = d > 0.001f ? dy / d : 0f;
			var push = d > 0.001f ? MathF.Min( rr - d, GameConfig.PushMaxStep ) : rr * 0.5f;

			WorldPosition = new Vector3(
				Math.Clamp( WorldPosition.x + nx * push, -half, half ),
				Math.Clamp( WorldPosition.y + ny * push, -half, half ), 0f );
		}
	}

	/// <summary> 场地内出生点(owner 复活时本地选位):走游戏系统的安全采样,无系统时纯随机兜底。
	/// 领土模式(M7):有队号就回己方大本营采样;frontLine=true(M7.2 二选一)换最近己方占领格 </summary>
	static Vector3 RandomSpawnPos( int teamIndex = -1, bool frontLine = false, Vector3 nearPos = default )
	{
		var game = CircleroyaleGame.Current;
		if ( game is not null ) return game.RandomSpawnPos( teamIndex, frontLine, nearPos );

		if ( MatchState.IsTerritory && teamIndex >= 0 )
		{
			if ( frontLine && TerritoryManager.TryFrontSpawn( teamIndex, nearPos, out var front ) )
				return front;
			TerritoryManager.HqBounds( teamIndex, out var minX, out var maxX, out var minY, out var maxY );
			return new Vector3( Game.Random.Float( minX, maxX ), Game.Random.Float( minY, maxY ), 0f );
		}

		var half = GameConfig.ArenaHalfSize - 128f;
		return new Vector3( Game.Random.Float( -half, half ), Game.Random.Float( -half, half ), 0f );
	}

	// ---- 分裂/吐孢子 RPC(M3):客户端 → host,host 校验归属后执行。
	// v0.7.8.7:参数语义改为**光标世界坐标**(aimPoint),host 逐身体算指向 ----

	[Rpc.Host]
	public void RequestSplit( Vector2 aimPoint )
	{
		var caller = Rpc.Caller;
		if ( caller is not null && caller.SteamId.Value != OwnerSteamId ) return;   // 只能操作自己的球

		CircleroyaleGame.Current?.DoSplit( this, aimPoint );
	}

	[Rpc.Host]
	public void RequestEject( Vector2 aimPoint )
	{
		var caller = Rpc.Caller;
		if ( caller is not null && caller.SteamId.Value != OwnerSteamId ) return;

		CircleroyaleGame.Current?.DoEject( this, aimPoint );
	}

	[Rpc.Host]
	public void RequestUsePower( int slot )
	{
		var caller = Rpc.Caller;
		if ( caller is not null && caller.SteamId.Value != OwnerSteamId ) return;   // 只能操作自己的球

		UsePower( slot );
	}

	protected override void OnDestroy()
	{
		base.OnDestroy();
		CircleroyaleGame.Current?.RemoveBall( this );
	}
}
slfy.circleroyale / Player/CellPiece.cs
Game game
using System;

/// <summary>
/// 分裂细胞 / 吐出的孢子(**纯数据实体,不是 GameObject**)。
/// 为什么不走网络对象:运行时 live create 广播到已连接客户端的可靠性未验证(M2 遗留),
/// 而食物同款"静态 RPC 快照"是本项目实测 100% 可靠的唯一同步通道。
/// host 权威模拟(冲量衰减 + 跟随主人转向),状态经 CellsState 静态 RPC 按 CellSyncInterval
/// 广播;各端本地镜像列表只负责渲染与平滑(DrawPos 追 Pos,客户端把 15Hz 快照插顺)。
/// </summary>
public sealed class CellPiece
{
	public enum Kind : byte
	{
		SplitPiece = 0,   // 分裂出的细胞(跟随主人方向移动,冷却后并回)
		EjectedMass = 1,  // 吐出的孢子(中性食物团,谁都能吃)
		SpikeMinion = 2,  // 尖刺分身(v0.7.4.0 道具生成):跟随主人 20s,到期变回普通分身;敌方按尖刺规则判定
	}

	public int Id;

	public Kind PieceKind;

	/// <summary> 主人 SteamId(bot 为 0;bot 不分裂,此字段仅真人有值) </summary>
	public long OwnerSteamId;

	/// <summary> 主人队伍号(-1 无队;host 出生时记一次,领土喂旗计分用,免逐帧查表) </summary>
	public int TeamIndex = -1;

	/// <summary> 锚定不动(M7.4 护卫固守尖刺):不跟随主人、无转向 </summary>
	public bool Anchored;

	/// <summary> 存在时长覆盖(秒;小于 0 = 用 Kind 默认寿命)——护卫尖刺 8s / 道具尖刺 20s(M7.4) </summary>
	public float MaxLife = -1f;

	public int ColorIndex;

	public float Mass;

	/// <summary> 权威位置(host 每帧推进;客户端 = 最近一次快照值,15Hz 跳变) </summary>
	public Vector3 Pos;

	/// <summary> 渲染位置:向 Pos 指数平滑(host 逐帧权威直接贴齐;客户端插值) </summary>
	public Vector3 DrawPos;

	/// <summary> 弹射冲量(分裂/吐出瞬间),随时间指数衰减 </summary>
	public Vector3 Impulse;

	/// <summary> 跟随主人移动方向的速度分量(仅 host 模拟) </summary>
	public Vector3 SteerVel;

	/// <summary> 出生时刻:合并冷却(分身)与可食延迟(孢子)都看它 </summary>
	public TimeSince SinceSpawn;

	/// <summary> 仅 host:主人球引用(分身清理、转向方向来源);孢子恒为 null(中性存活) </summary>
	public Ball OwnerBall;

	/// <summary> 半径沿用球的质量定律:r = StartRadius × √(mass/StartMass) </summary>
	public float Radius => GameConfig.StartRadius * MathF.Sqrt( MathF.Max( 1f, Mass ) / GameConfig.StartMass );

	/// <summary> 移动速度沿用球的速度曲线:越大越慢 </summary>
	public float Speed => MathF.Max( GameConfig.MinSpeed,
		GameConfig.BaseSpeed * MathF.Pow( GameConfig.StartMass / MathF.Max( 1f, Mass ), GameConfig.SpeedCurve ) );

	/// <summary> 合并冷却时长(秒):主动分裂按质量曲线(MergeCooldownFor);撞刺碎片短冷却(SpikePieceMergeCooldown) </summary>
	public float MergeCooldown = GameConfig.MergeCooldownBase;

	/// <summary> 合并冷却是否已过(仅分身有意义) </summary>
	public bool IsMergeReady => SinceSpawn > MergeCooldown;
}

/// <summary>
/// 刺爆弹幕(v0.7.8.24):喂刺喂满后从刺射出的尖刺弹。host 权威模拟(含命中判定),
/// 客户端为纯视觉弹道镜像(SpineDir 公式双端一致,视觉弹道=权威弹道)。
/// </summary>
public sealed class SpikeSpine
{
	public Vector2 Pos;

	public Vector2 Vel;

	/// <summary> 出生时刻(SpineLife 到期消散) </summary>
	public TimeSince SinceSpawn;
}

/// <summary> CellsState RPC 的线格式(全 blittable 字段,同 FoodData 模式) </summary>
public struct CellWire
{
	public int Id;
	public byte Kind;        // CellPiece.Kind
	public byte ColorIndex;
	public long Owner;       // OwnerSteamId
	public float X;
	public float Y;
	public float Mass;
}
slfy.circleroyale / ui/lobbypanel.cs.scss
Game game
LobbyPanel {
	position: absolute;
	left: 0;
	top: 0;
	right: 0;
	bottom: 0;
	font-family: PixelFusion;
	color: #4a3b63;
	pointer-events: all;
	background-color: rgba( 234, 244, 252, 0.88 );   // 浅蓝磨砂(像素风)

	// 版式(参考图,2026-09-09 用户定稿):左栏玩家列表(锚屏幕左)/ 右栏参数设置 + 大开始按钮。
	// 右栏一律 right 锚定(用户窗口设计宽 ~1800 ≠ 1920,固定 left 定位会顶出右缘)

	.lp-title {
		position: absolute;
		left: 0;
		right: 0;
		top: 80px;
		font-size: 100px;
		font-weight: 800;
		letter-spacing: 10px;
		text-align: center;
		color: #f272a7;
		text-shadow: 0 4px 0 #4a3b63;
	}

	.lp-status {
		position: absolute;
		left: 0;
		right: 0;
		top: 215px;
		font-size: 32px;
		font-weight: 700;
		letter-spacing: 4px;
		text-align: center;
		color: #d98a00;
		text-shadow: 0 3px 0 rgba( 74, 59, 99, 0.25 );
	}

	// 两栏小节标题(players 左栏 / settings 右栏,位置分写)
	.lp-head {
		position: absolute;
		top: 278px;
		font-size: 13px;
		letter-spacing: 4px;
		text-align: center;
		color: rgba( 74, 59, 99, 0.45 );
	}

	.lp-head.players {
		left: 80px;
		width: 600px;
	}

	.lp-head.settings {
		right: 60px;
		width: 1040px;
	}

	// 参数设置牌(右栏纵排三张;Top 由代码按行给)
	.lp-setting {
		position: absolute;
		right: 60px;
		width: 1040px;
		font-size: 30px;
		letter-spacing: 2px;
		text-align: center;
		color: #4a3b63;
		border-image: url( ui/skin/note.png ) 40 fill / 5px stretch;
		padding: 14px 10px;
		cursor: pointer;
		pointer-events: all;
	}

	.lp-setting:hover {
		color: #e5487e;
		border-image-tint: #ffe4f0;
	}

	// 设置可点提示(仅 host):牌堆下方金色引导小字
	.lp-setting-hint {
		position: absolute;
		right: 60px;
		width: 1040px;
		top: 658px;
		font-size: 24px;
		letter-spacing: 4px;
		text-align: center;
		color: rgba( 217, 138, 0, 0.75 );
	}

	// 玩家列表滚动容器(原生网页式滚动:滚轮 + 滚动条,溢出才出现)
	.lp-players-scroll {
		position: absolute;
		left: 92px;
		top: 322px;
		width: 576px;
		height: 616px;
		flex-direction: column;   // ⚠️ s&box 默认 row,不加就是横排
		overflow-y: scroll;
		scrollbar-width: 10px;   // ★ 滚动条粗细(精修改这里)
		scrollbar-color: rgba( 74, 59, 99, 0.45 ) transparent;   // ★ 滑块色 轨道色
		padding-right: 4px;
		pointer-events: all;   // 面板默认 None:不打开收不到滚轮
	}

	// 玩家行牌(流内单列,note.png 小牌)
	.lp-player {
		flex-shrink: 0;   // 不许压缩:压了就不溢出、滚动条永远出不来
		margin: 0 0 8px 0;
		font-size: 30px;
		letter-spacing: 1px;
		text-align: left;
		color: #4a3b63;
		border-image: url( ui/skin/note.png ) 28 fill / 5px stretch;
		padding: 8px 50px;
	}

	// 玩家列表容器底(dobou InventoryHotbar 九宫格,用户指定):
	// 罩住左栏两子列(行牌 x80..680 / y320..925),四周留 ~20px 内衬
	.lp-players-bg {
		position: absolute;
		left: 60px;
		top: 300px;
		width: 640px;
		height: 660px;
		border-image: url( ui/skin/hotbar.png ) 60 fill / 30px stretch;
	}

	// BOTS 补位提示:设置牌堆下、按钮上
	.lp-note {
		position: absolute;
		right: 60px;
		width: 1040px;
		top: 700px;
		font-size: 20px;
		letter-spacing: 3px;
		text-align: center;
		color: rgba(21, 9, 54, 0.4);
	}

	// 道具图例已按用户要求整个移除(2026-09-09)——Q/E 用法开局后 HUD 里还有提示

	.lp-countdown {
		position: absolute;
		left: 0;
		right: 0;
		top: 600px;
		font-family: PixelFusion;
		font-size: 40px;
		font-weight: 800;
		letter-spacing: 8px;
		text-align: center;
		color: #f272a7;
		text-shadow: 0 4px 0 #4a3b63;
	}

	// 下面三颗全是 UiKit 药丸(外观在 Assets/ui/cruikit.scss 的 .cr-pill),这里只管摆放。
	// START/JOIN 同一槽位(同一时刻只显示一个),与设置牌同中轴(right:60+1040 → 中轴 = 右缘-580)
	.lp-start {
		position: absolute;
		right: 400px;
		top: 750px;
		width: 360px;
		padding-top: 8px;
	}

	.lp-join {
		position: absolute;
		right: 400px;
		top: 850px;
		width: 360px;
		padding-top: 8px;
	}

	.lp-leave {
		position: absolute;
		right: 470px;
		top: 950px;
		width: 220px;
		padding-top: 8px;
	}
}
slfy.circleroyale / ui/rankingspanel.cs.scss
Game game
RankingsPanel {
	position: absolute;
	left: 0;
	top: 0;
	right: 0;
	bottom: 0;
	font-family: PixelFusion;
	color: #4a3b63;
	pointer-events: all;
	background-color: rgba( 234, 244, 252, 0.88 );   // 浅蓝磨砂(像素风)

	.rk-title {
		position: absolute;
		left: 0;
		right: 0;
		top: 80px;
		font-size: 100px;
		font-weight: 800;
		letter-spacing: 10px;
		text-align: center;
		color: #f272a7;
		text-shadow: 0 4px 0 #4a3b63;
	}

	.rk-status {
		position: absolute;
		left: 0;
		right: 0;
		top: 460px;   // 空卡正中:读取中/无数据/失败三种状态都显示在这(行牌之后创建,压在空牌上层)
		font-size: 32px;
		font-weight: 700;
		letter-spacing: 4px;
		text-align: center;
		color: #d98a00;
		text-shadow: 0 3px 0 rgba( 74, 59, 99, 0.25 );
	}

	.rk-head {
		position: absolute;
		left: 50%;
		margin-left: -550px;
		width: 1100px;
		top: 250px;
		font-size: 12px;
		letter-spacing: 2px;
		text-align: left;
		color: rgba( 74, 59, 99, 0.45 );
	}

	// 排行榜滚动容器(原生网页式滚动:滚轮 + 滚动条,溢出才出现)
	// 行牌流内单列;宽 1600 / 起点 188 是用户调校值的迁移
	.rk-list {
		position: absolute;
		left: 50%;
		margin-left: -800px;
		width: 1600px;
		top: 180px;
		height: 770px;   // ★ 列表视口高度(精修改这里;底到 BACK 上方为止)
		flex-direction: column;   // ⚠️ s&box 默认 row,不加就是横排
		overflow-y: scroll;
		scrollbar-width: 10px;   // ★ 滚动条粗细
		scrollbar-color: rgba( 74, 59, 99, 0.45 ) transparent;
		pointer-events: all;   // 面板默认 None:不打开收不到滚轮
	}

	.rk-row {
		flex-shrink: 0;   // 不许压缩:压了就不溢出、滚动条永远出不来
		margin: 0 0 6px 0;   // ★ 行距(原 top 起点式行距作废,改这里)
		font-size: 50px;
		letter-spacing: 10px;
		text-align: left;
		color: #4a3b63;
		border-image: url( ui/skin/note.png ) 50 fill / 5px stretch;
		padding: 20px 100px;   // ★ 行内文字与牌边缘的距离(精修改这里)
	}

	.rk-self {
		position: absolute;
		left: 0;
		right: 0;
		top: 860px;
		font-size: 32px;
		font-weight: 700;
		letter-spacing: 3px;
		text-align: center;
		color: #e5488d;
	}

	// 返回按钮:UiKit 药丸(外观在 Assets/ui/cruikit.scss),这里只管摆放
	.rk-back {
		position: absolute;
		left: 50%;
		margin-left: -110px;   // 自己宽度 220 的一半,任意分辨率水平居中
		top: 980px;
		width: 220px;
		padding-top: 8px;
	}
}
slfy.circleroyale / ui/mainmenu.cs.scss
Game game
MainMenu {
	position: absolute;
	left: 0;
	top: 0;
	right: 0;
	bottom: 0;
	font-family: PixelFusion;
	color: #4a3b63;
	pointer-events: all;   // ⚠️ 只认 auto/none/all:写 normal 非法被忽略(面板默认 None=鼠标全死)

	// 官方项目手法:flex 列 + align-items 居中(任意分辨率水平居中)。
	// ⚠️ s&box 默认 flex-direction 是 row——不显式写 column 的话子元素全部横排(实测)
	flex-direction: column;
	align-items: center;

	// ---- 左上:玩家资料卡(头像 / 名字 / 等级) ----
	.profile {
		position: absolute;
		left: 20px;
		top: 16px;
	}

	.profile-avatar {
		width: 72px;
		height: 72px;
		border: 3px solid #4a3b63;
		border-radius: 12px;
		background-color: rgba( 255, 255, 255, 0.75 );
	}

	.profile-info {
		position: absolute;
		left: 84px;
		top: 0;
		bottom: 0;
		flex-direction: column;   // ⚠️ 默认 row:名字和等级会挤成一行
		justify-content: center;   // 垂直居中于头像高度
	}

	.profile-name {
		font-size: 20px;
		font-weight: 100;
		letter-spacing: 1px;
		color: #4a3b63;
		text-shadow: 0 2px 0 rgba( 255, 255, 255, 0.9 );
	}

	.profile-level {
		margin-top: 6px;
		font-size: 12px;
		font-weight: 700;
		letter-spacing: 2px;
		color: #d98a00;
	}

	// ---- 右上:设置 / 排行榜(图标小按钮,横排) ----
	.top-actions {
		position: absolute;
		right: 40px;
		top: 40px;
		flex-direction: row;
	}

	.icon-btn {
		width: 100px;
		align-items: center;      // 图标水平居中
		justify-content: center;  // 图标垂直居中
		height: 100px;
		margin-left: 24px;   // 两颗按钮拉开间距,别糊成一团
		border-image: url( ui/skin/slot.png ) 30 fill / 10px stretch;
		cursor: pointer;
		pointer-events: all;
	}

	.icon-btn:hover {
		border-image-tint: #ffd6e8;
	}

	.icon-btn-img {
		// ⚠️ 别写 position/left/top——.icon-btn 没有 position 时百分比解析到 .top-actions 容器,
		// 两颗图标会叠到容器正中。居中由 .icon-btn 的 align/justify-center 完成,这里只管尺寸
		width: 50px;
		height: 50px;
	}

	// ---- 中央大 logo ----
	.title {
		width: 100%;
		margin-top: 150px;
		font-size: 196px;
		font-weight: 800;
		letter-spacing: 8px;
		color: #f272a7;   // 糖果粉 logo
		text-shadow: 0 6px 0 #4a3b63, 0 11px 0 rgba( 74, 59, 99, 0.35 );
		text-align: center;
	}

	// 副标题已按需求移除(v0.7.8.44)

	// ---- 三个主按钮横排(参考手游布局;粉 / 蓝 / 橙) ----
	.menu-row {
		margin-top: 400px;
		flex-direction: row;
		align-items: center;
	}

	.menu-row .btn {
		width: 250px;
		height: 64px;
		margin-top: 10px;
		margin-left: 30px;
		font-size: 25px;
		font-weight: 300;
		letter-spacing: 5px;
		color: #4a3b63;
		padding-top: 8px;
		text-align: center;
		border-image: url( ui/skin/btn_white.png ) 52 fill / 26px stretch;
		cursor: pointer;
		pointer-events: all;
	}

	.menu-row .btn.host { margin-left: 0; border-image-tint: #ffb9d0; }   // 粉=建服
	.menu-row .btn.join { border-image-tint: #b8d9ff; }                   // 蓝=加入
	.menu-row .btn.bots { border-image-tint: #ffcf9e; }                   // 橙=人机

	.menu-row .btn:hover {
		border-image-tint: #ffffff;
		color: #e5487e;
	}

	// ---- 角落固定件 ----
	.status {
		position: absolute;
		left: 0;
		right: 0;
		bottom: 52px;
		font-size: 120px;
		letter-spacing: 2px;
		color: #d98a00;
		text-align: center;
	}

	.version {
		position: absolute;
		right: 20px;
		bottom: 14px;
		font-size: 12px;
		letter-spacing: 2px;
		color: rgba( 74, 59, 99, 0.4 );
	}

	.hint {
		position: absolute;
		left: 30px;
		bottom: 14px;
		font-size: 12px;
		letter-spacing: 2px;
		color: rgba( 74, 59, 99, 0.5 );
	}
}
slfy.circleroyale / Game/FoodManager.cs
Game game
using System;

/// <summary>
/// 食物管理(**每台机器一份本地实例,不走对象复制**——NetList 挂对象无法复制到客户端,实测):
/// host 权威生成/吃/重生;状态变化经 NetworkManager 的静态 RPC 广播,各端应用到本地列表。
/// Init/TryEat/Tick 仅权威端调用;Apply* 仅供客户端应用远端事件。
/// 空间哈希(v0.6.3.0):3 倍场地 4140 颗食物后,吃判定 O(球数×食物)≈20 万次/帧成为瓶颈——
/// 512 单位桶查询把每球检查量从 4140 降到几十。
/// </summary>
public sealed class FoodManager
{
	FoodData[] _foods;
	TimeSince[] _eatenAt;                           // 仅 host(重生计时)
	readonly List<int> _pendingRespawn = new();     // 仅 host

	// ---- 空间哈希(仅 host 吃判定用;客户端无吃判定不建桶)----
	const float BucketSize = 512f;
	Dictionary<int, List<int>> _buckets;
	int _cols;
	readonly List<int> _nearResults = new();

	/// <summary> 存活食物数 </summary>
	public int AliveCount { get; private set; }

	/// <summary> 全量食物(NeonRenderer 每帧读;各端内容一致) </summary>
	public FoodData[] Foods => _foods;

	int BucketKey( Vector2 p )
	{
		var half = GameConfig.ArenaHalfSize;
		int bx = Math.Clamp( (int) ( ( p.x + half ) / BucketSize ), 0, _cols - 1 );
		int by = Math.Clamp( (int) ( ( p.y + half ) / BucketSize ), 0, _cols - 1 );
		return by * _cols + bx;
	}

	void BucketAdd( int index ) => BucketAddAt( index, _foods[index].Pos );

	void BucketAddAt( int index, Vector2 p )
	{
		int key = BucketKey( p );
		if ( !_buckets.TryGetValue( key, out var list ) )
		{
			list = new List<int>( 16 );
			_buckets[key] = list;
		}
		list.Add( index );
	}

	void BucketMove( int index, Vector2 from )
	{
		int oldKey = BucketKey( from );
		if ( _buckets.TryGetValue( oldKey, out var list ) )
			list.Remove( index );

		BucketAdd( index );
	}

	/// <summary> 圆形范围内的存活食物下标(近似:按桶粗筛,调用方需精确复核距离)。
	/// 返回内部复用列表,下一次调用即失效。仅 host(吃判定权威端)。 </summary>
	public List<int> Nearby( Vector2 pos, float radius )
	{
		_nearResults.Clear();
		if ( _buckets is null || _foods is null ) return _nearResults;

		int minBx = Math.Clamp( (int) ( ( pos.x - radius + GameConfig.ArenaHalfSize ) / BucketSize ), 0, _cols - 1 );
		int maxBx = Math.Clamp( (int) ( ( pos.x + radius + GameConfig.ArenaHalfSize ) / BucketSize ), 0, _cols - 1 );
		int minBy = Math.Clamp( (int) ( ( pos.y - radius + GameConfig.ArenaHalfSize ) / BucketSize ), 0, _cols - 1 );
		int maxBy = Math.Clamp( (int) ( ( pos.y + radius + GameConfig.ArenaHalfSize ) / BucketSize ), 0, _cols - 1 );

		for ( int by = minBy; by <= maxBy; by++ )
		{
			for ( int bx = minBx; bx <= maxBx; bx++ )
			{
				if ( !_buckets.TryGetValue( by * _cols + bx, out var list ) ) continue;

				foreach ( var idx in list )
				{
					if ( _foods[idx].Alive ) _nearResults.Add( idx );
				}
			}
		}
		return _nearResults;
	}

	/// <summary> host 投食(权威端;随后经 FoodFull RPC 发给新连接) </summary>
	public void Init()
	{
		_foods = new FoodData[GameConfig.FoodCount];
		_eatenAt = new TimeSince[GameConfig.FoodCount];
		_pendingRespawn.Clear();
		_buckets = new Dictionary<int, List<int>>( 1024 );
		_cols = Math.Max( 1, (int) ( GameConfig.ArenaHalfSize * 2f / BucketSize ) + 1 );

		for ( int i = 0; i < _foods.Length; i++ )
		{
			_foods[i] = MakeFood();
			BucketAdd( i );
		}
		AliveCount = _foods.Length;
	}

	/// <summary> 每帧:把到期的重生位重新投食并广播(仅权威端) </summary>
	public void Tick()
	{
		for ( int i = _pendingRespawn.Count - 1; i >= 0; i-- )
		{
			int idx = _pendingRespawn[i];
			if ( _eatenAt[idx] < GameConfig.FoodRespawnSeconds ) continue;

			var oldPos = _foods[idx].Pos;
			_foods[idx] = MakeFood();
			_pendingRespawn.RemoveAt( i );
			AliveCount++;
			BucketMove( idx, oldPos );
			if ( Networking.IsActive ) NetworkManager.FoodRespawned( idx, _foods[idx] );   // 无会话(菜单演示赛)不广播
		}
	}

	/// <summary> 尝试吃掉指定食物;成功返回 true(调用方据此加质量)。仅权威端,内部广播。
	/// eaterSteamId 随广播下发,客户端据此判断是不是自己的嘴(播吃音) </summary>
	public bool TryEat( int index, long eaterSteamId = 0 )
	{
		if ( (uint)index >= (uint)_foods.Length || !_foods[index].Alive ) return false;

		_foods[index].Alive = false;
		_eatenAt[index] = 0;
		_pendingRespawn.Add( index );
		AliveCount--;
		if ( Networking.IsActive ) NetworkManager.FoodEaten( index, eaterSteamId );   // 无会话不广播
		return true;
	}

	FoodData MakeFood()
	{
		var half = GameConfig.ArenaHalfSize - GameConfig.FoodMargin;
		return new FoodData
		{
			Pos = new Vector2( Game.Random.Float( -half, half ), Game.Random.Float( -half, half ) ),
			ColorIndex = (byte)Game.Random.Float( 0f, GameConfig.Palette.Length - 0.001f ),
			Alive = true,
			Type = 0,
		};
	}

	// ---- 客户端应用(远端事件 → 本地列表)----

	public void ApplyFull( FoodData[] foods )
	{
		_foods = foods;
		AliveCount = 0;
		foreach ( var f in _foods )
		{
			if ( f.Alive ) AliveCount++;
		}
	}

	public void ApplyEaten( int index )
	{
		if ( _foods is null ) return;   // 热重载后新实例未同步全量前,忽略增量
		if ( (uint)index >= (uint)_foods.Length || !_foods[index].Alive ) return;

		var f = _foods[index];
		f.Alive = false;
		_foods[index] = f;
		AliveCount--;
	}

	public void ApplyRespawned( int index, FoodData food )
	{
		if ( _foods is null ) return;
		if ( (uint)index >= (uint)_foods.Length ) return;

		// 已活着(重复/乱序投递)也更新数据但不重复计数——AliveCount 只在真复燃时 +1
		if ( !_foods[index].Alive ) AliveCount++;
		_foods[index] = food;
	}
}
slfy.circleroyale / Game/TerritoryManager.cs
Game game
using System;

/// <summary>
/// 领土战争模式(M7,用户定稿 V2.1):4×4 格,四角大本营开局属己方且永不沦陷。
/// 驻军模型占领:吐球进旗圈计分——中立格喂满 100 变色占领(驻军满 100);
/// 敌方吐球先啃驻军,啃光回中立(溢出转为自己进度)。无自动衰减、无补给线;
/// 只能抢与己方领土 4 邻接的格(大本营免疫敌方喂食)。
/// host 权威结算(TickCells 吐球命中即记 + 5Hz 变化快照广播);客户端 ApplyRemote 只读。
/// 格子几何由 ArenaHalfSize 决定(Replicated 下发,双端一致),无额外网络状态。
/// </summary>
public static class TerritoryManager
{
	const int TeamCount = 4;

	struct Cell
	{
		public int Owner;      // 占领队(-1 = 中立)
		public int ScoreTeam;  // 分数归属队(中立格的争夺进度方;-1 = 无)
		public float Score;    // 中立格 = 该队的占领进度;己方格 = 驻军值
	}

	static readonly Cell[] _cells = new Cell[GameConfig.TerritoryGrid * GameConfig.TerritoryGrid];
	static readonly int[] _owned = new int[TeamCount];
	static readonly float[] _points = new float[TeamCount];   // 占旗积分(每旗每 3s +1,用户定稿)
	static float _pointTimer;                                 // 积分发放节拍
	static int _conquerTeam = -1;   // 先到征服胜利的队(TickTerritory 读,EndMatch 后随 Reset 清)
	static int _sentVersion;        // 已广播过的版本号(host 节流:变了才发)

	public static bool Active => MatchState.IsTerritory;

	/// <summary> 状态版本号:任何格变化 +1(TerritoryRenderer 据此重建几何) </summary>
	public static int StateVersion { get; private set; }

	/// <summary> host:自上次快照后状态有变(TickTerritory 节流广播用) </summary>
	public static bool NeedsSync => StateVersion != _sentVersion;

	/// <summary> 规则生效点调用(MatchState.Apply):全端复位为开局态——四角大本营属己方满驻军,其余全中立 </summary>
	public static void Reset()
	{
		for ( int i = 0; i < _cells.Length; i++ )
		{
			_cells[i].Owner = -1;
			_cells[i].ScoreTeam = -1;
			_cells[i].Score = 0f;
		}

		for ( var t = 0; t < TeamCount; t++ )
		{
			var ( cx, cy ) = HqCell( t );
			ref var c = ref _cells[cy * GameConfig.TerritoryGrid + cx];
			c.Owner = t;
			c.ScoreTeam = t;
			c.Score = GameConfig.TerritoryCaptureScore;
		}

		_conquerTeam = -1;
		_sentVersion = 0;
		_pointTimer = 0f;
		for ( var t = 0; t < TeamCount; t++ ) _points[t] = 0f;
		Recount();
		StateVersion++;
	}

	/// <summary> 队伍的大本营格(固定角映射:0 左上、1 右上、2 右下、3 左下) </summary>
	public static ( int Cx, int Cy ) HqCell( int team ) => team switch
	{
		0 => ( 0, 0 ),
		1 => ( GameConfig.TerritoryGrid - 1, 0 ),
		2 => ( GameConfig.TerritoryGrid - 1, GameConfig.TerritoryGrid - 1 ),
		_ => ( 0, GameConfig.TerritoryGrid - 1 ),
	};

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

	// ---- 几何(双端一致,只依赖 ArenaHalfSize)----

	public static float CellSize => GameConfig.ArenaHalfSize * 2f / GameConfig.TerritoryGrid;

	public static Vector3 CellCenter( int cx, int cy )
	{
		var half = GameConfig.ArenaHalfSize;
		var s = CellSize;
		return new Vector3( -half + ( cx + 0.5f ) * s, -half + ( cy + 0.5f ) * s, 0f );
	}

	public static void CellOf( Vector3 pos, out int cx, out int cy )
	{
		var half = GameConfig.ArenaHalfSize;
		var s = CellSize;
		cx = Math.Clamp( (int)MathF.Floor( ( pos.x + half ) / s ), 0, GameConfig.TerritoryGrid - 1 );
		cy = Math.Clamp( (int)MathF.Floor( ( pos.y + half ) / s ), 0, GameConfig.TerritoryGrid - 1 );
	}

	/// <summary> 队伍大本营内的出生采样范围(贴边内缩,别出生到旗圈边上挨打) </summary>
	public static void HqBounds( int team, out float minX, out float maxX, out float minY, out float maxY )
	{
		var ( cx, cy ) = HqCell( team );
		var half = GameConfig.ArenaHalfSize;
		var s = CellSize;
		var inset = s * 0.28f;
		minX = -half + cx * s + inset;
		maxX = -half + ( cx + 1 ) * s - inset;
		minY = -half + cy * s + inset;
		maxY = -half + ( cy + 1 ) * s - inset;
	}

	/// <summary> 球是否在己方大本营内(大体型衰减豁免判定,Ball 衰减处调用) </summary>
	public static bool IsOwnHq( int team, Vector3 pos )
	{
		if ( !Active || team < 0 ) return false;
		CellOf( pos, out var cx, out var cy );
		var hq = HqCell( team );
		return cx == hq.Cx && cy == hq.Cy;
	}

	/// <summary> 球是否在敌营大本营内(M7.2 安全屋:减速+掉重;无队/非领土恒 false) </summary>
	public static bool InHostileHq( int team, Vector3 pos )
	{
		if ( !Active || team < 0 ) return false;
		CellOf( pos, out var cx, out var cy );
		ref var c = ref _cells[cy * GameConfig.TerritoryGrid + cx];
		return IsHq( cx, cy ) && c.Owner >= 0 && c.Owner != team;
	}

	/// <summary> 是否存在前线格(大本营以外的己方占领格——死亡面板 FRONT 按钮亮暗用) </summary>
	public static bool HasFrontCell( int team ) => TryFrontSpawn( team, default, out _ );

	/// <summary> 该位置的旗是否允许该队动作(己方格,或邻接己方领土的非大本营格)——UI 引导用;
	/// 与 Feed 的入侵门同一规则 </summary>
	public static bool CanActOn( int team, Vector3 pos )
	{
		CellOf( pos, out var cx, out var cy );
		return CanActOnCell( cx, cy, team );
	}

	public static bool CanActOnCell( int cx, int cy, int team )
	{
		if ( !Active || team < 0 ) return false;
		ref var c = ref _cells[cy * GameConfig.TerritoryGrid + cx];
		return c.Owner == team || ( !IsHq( cx, cy ) && AdjacentToOwned( cx, cy, team ) );
	}

	/// <summary> 前线复活点(M7.2 复活二选一):离 nearPos 最近的己方非大本营占领格内随机点;
	/// 没有前线格返回 false(只剩大本营可复活) </summary>
	public static bool TryFrontSpawn( int team, Vector3 nearPos, out Vector3 pos )
	{
		pos = default;
		if ( !Active || team < 0 ) return false;

		var best = float.MaxValue;
		var found = false;
		var grid = GameConfig.TerritoryGrid;
		for ( int cy = 0; cy < grid; cy++ )
		{
			for ( int cx = 0; cx < grid; cx++ )
			{
				ref var c = ref _cells[cy * grid + cx];
				if ( c.Owner != team || IsHq( cx, cy ) ) continue;
				var d = CellCenter( cx, cy ).Distance( nearPos );
				if ( d < best )
				{
					best = d;
					pos = CellCenter( cx, cy );
					found = true;
				}
			}
		}
		if ( !found ) return false;

		var s = CellSize;
		pos += new Vector3( Game.Random.Float( -s * 0.18f, s * 0.18f ), Game.Random.Float( -s * 0.18f, s * 0.18f ), 0f );
		return true;
	}

	// ---- 喂旗结算(host 权威)----

	/// <summary> 一颗孢子落进旗圈(TickCells 吐球命中调用)。
	/// 返回是否消耗该孢子:进旗圈且规则允许就消耗计分;没进圈或规则不允许(非邻接/大本营)
	/// 则 false,孢子继续当普通孢子飞,吐出的质量不白费 </summary>
	public static bool Feed( int team, Vector3 pos, float blobMass )
	{
		if ( !Active || team < 0 || blobMass <= 0f ) return false;

		CellOf( pos, out var cx, out var cy );
		if ( CellCenter( cx, cy ).Distance( pos ) >= GameConfig.TerritoryFlagRadius ) return false;   // 没进旗圈

		ref var c = ref _cells[cy * GameConfig.TerritoryGrid + cx];

		// 入侵规则:动别人/无主的格,必须与己方领土 4 邻接(稳扎稳打,无飞地)
		if ( c.Owner != team && ( !AdjacentToOwned( cx, cy, team ) || IsHq( cx, cy ) ) )
			return false;

		var pts = MathF.Min( blobMass * GameConfig.TerritoryScorePerMass, GameConfig.TerritoryScoreCapPerBlob );
		if ( pts <= 0f ) return false;

		var changed = false;

		if ( c.Owner == team )
		{
			// 己方格:补驻军(无自动衰减,只会被敌方啃掉后重占)
			if ( c.Score < GameConfig.TerritoryCaptureScore )
			{
				c.Score = MathF.Min( GameConfig.TerritoryCaptureScore, c.Score + pts );
				changed = true;
			}
		}
		else if ( c.Owner < 0 )
		{
			// 中立格:无主进度归自己累加;有别人的进度先啃,啃光转为自己累加(溢出计入)
			if ( c.ScoreTeam < 0 || c.ScoreTeam == team )
			{
				c.ScoreTeam = team;
				c.Score += pts;
			}
			else
			{
				c.Score -= pts;
				if ( c.Score <= 0f )
				{
					c.ScoreTeam = team;
					c.Score = -c.Score;
				}
			}

			if ( c.Score >= GameConfig.TerritoryCaptureScore )
			{
				c.Owner = team;
				c.Score = GameConfig.TerritoryCaptureScore;
				_owned[team]++;
				changed = true;
				NeonRenderer.Puff( CellCenter( cx, cy ), team );
				GameLog.Info( $"[territory] team {team + 1} captured cell ({cx},{cy}) — {_owned[team]}/{GameConfig.TerritoryWinCells}" );
			}
			else
			{
				changed = true;
			}
		}
		else
		{
			// 敌方格:先啃驻军,啃光回中立(溢出转为自己进度,接着喂就能顺势占领)
			c.Score -= pts;
			if ( c.Score <= 0f )
			{
				_owned[c.Owner]--;
				c.Owner = -1;
				c.ScoreTeam = team;
				c.Score = -c.Score;
				NeonRenderer.Puff( CellCenter( cx, cy ), team );
				GameLog.Info( $"[territory] team {team + 1} neutralized cell ({cx},{cy})" );
			}
			changed = true;
		}

		if ( changed )
		{
			StateVersion++;
			if ( _conquerTeam < 0 && _owned[team] >= GameConfig.TerritoryWinCells )
			{
				_conquerTeam = team;
				GameLog.Info( $"[territory] team {team + 1} reaches {GameConfig.TerritoryWinCells} cells — conquest!" );
			}
		}
		return true;
	}

	static bool AdjacentToOwned( int cx, int cy, int team )
	{
		return Owns( cx - 1, cy, team ) || Owns( cx + 1, cy, team )
			|| Owns( cx, cy - 1, team ) || Owns( cx, cy + 1, team );
	}

	static bool Owns( int cx, int cy, int team )
	{
		if ( cx < 0 || cy < 0 || cx >= GameConfig.TerritoryGrid || cy >= GameConfig.TerritoryGrid ) return false;
		return _cells[cy * GameConfig.TerritoryGrid + cx].Owner == team;
	}

	static void Recount()
	{
		for ( var i = 0; i < _owned.Length; i++ ) _owned[i] = 0;
		foreach ( var c in _cells )
		{
			if ( c.Owner >= 0 && c.Owner < _owned.Length ) _owned[c.Owner]++;
		}
	}

	/// <summary> 某队当前占领格数(HUD/结算/胜利判定共用) </summary>
	public static int OwnedCount( int team ) => team >= 0 && team < TeamCount ? _owned[team] : 0;

	/// <summary> host 每帧:占旗积分发放(用户定稿——每占领一旗每 3 秒 +1 分;点数只在 host 累积,随快照下发) </summary>
	public static void TickPoints( float dt )
	{
		if ( !Active ) return;
		_pointTimer += dt;
		var step = GameConfig.TerritoryPointSeconds;
		while ( _pointTimer >= step )
		{
			_pointTimer -= step;
			for ( var t = 0; t < TeamCount; t++ ) _points[t] += _owned[t];
		}
	}

	/// <summary> 某队当前占旗积分(胜利判定/HUD/结算共用;客户端经 TerritoryState 快照同步) </summary>
	public static float Points( int team ) => team >= 0 && team < TeamCount ? _points[team] : 0f;

	/// <summary> 单格状态读取(TerritoryRenderer 每次重建逐格取) </summary>
	public static void CellState( int cx, int cy, out int owner, out int scoreTeam, out float score )
	{
		if ( cx < 0 || cy < 0 || cx >= GameConfig.TerritoryGrid || cy >= GameConfig.TerritoryGrid )
		{
			owner = -1;
			scoreTeam = -1;
			score = 0f;
			return;
		}
		ref var c = ref _cells[cy * GameConfig.TerritoryGrid + cx];
		owner = c.Owner;
		scoreTeam = c.ScoreTeam;
		score = c.Score;
	}

	/// <summary> 达成征服胜利的队伍(-1 = 尚无);host 检查到即 EndMatch </summary>
	public static int ConquerWinner => _conquerTeam;

	// ---- 网络同步 ----

	/// <summary> host:当前四队占旗积分(与格子快照同拍下发) </summary>
	public static float[] PointsWire()
	{
		var arr = new float[TeamCount];
		for ( var t = 0; t < TeamCount; t++ ) arr[t] = _points[t];
		return arr;
	}

	/// <summary> host:当前 16 格状态快照(发出即记录版本,NeedsSync 归零) </summary>
	public static TerritoryWire[] Snapshot()
	{
		var wires = new TerritoryWire[_cells.Length];
		for ( int i = 0; i < _cells.Length; i++ )
		{
			wires[i] = new TerritoryWire
			{
				Owner = _cells[i].Owner < 0 ? (byte)255 : (byte)_cells[i].Owner,
				ScoreTeam = _cells[i].ScoreTeam < 0 ? (byte)255 : (byte)_cells[i].ScoreTeam,
				Score = _cells[i].Score,
			};
		}
		_sentVersion = StateVersion;
		return wires;
	}

	/// <summary> 客户端应用 host 快照(host 回声由 IsAuthority 挡掉;重复应用幂等)。
	/// points = 四队占旗积分(null 容错:旧包/空包只刷格子) </summary>
	public static void ApplyRemote( TerritoryWire[] wires, float[] points )
	{
		if ( NetworkManager.IsAuthority ) return;
		if ( wires is null || wires.Length != _cells.Length ) return;

		// 逐项比对,完全没变就跳过——host 每 2s 保活快照带相同数据,
		// 无脑 StateVersion++ 会让客户端领土几何频繁重建(闪,v0.7.8.86 修)
		var changed = false;
		for ( int i = 0; i < _cells.Length; i++ )
		{
			var owner = wires[i].Owner == 255 ? -1 : wires[i].Owner;
			var scoreTeam = wires[i].ScoreTeam == 255 ? -1 : wires[i].ScoreTeam;
			var score = wires[i].Score;
			ref var c = ref _cells[i];
			if ( c.Owner == owner && c.ScoreTeam == scoreTeam && c.Score == score ) continue;
			c.Owner = owner;
			c.ScoreTeam = scoreTeam;
			c.Score = score;
			changed = true;
		}
		if ( points is not null )
		{
			for ( var t = 0; t < TeamCount && t < points.Length; t++ )
			{
				if ( _points[t] != points[t] )
				{
					_points[t] = points[t];
					changed = true;
				}
			}
		}
		if ( !changed ) return;
		Recount();
		StateVersion++;
	}
}

/// <summary> 领土格网络快照线格式(255 = 中立/无归属;与 CellWire/ScoreWire 同顶层惯例) </summary>
public struct TerritoryWire
{
	public byte Owner;
	public byte ScoreTeam;
	public float Score;
}
slfy.circleroyale / UI/LobbyPanel.cs
Game game
using System;
using System.Collections.Generic;
using Sandbox.UI;

/// <summary>
/// 游戏房间/大厅(M5 二级界面):HOST / VS BOTS 点开后进入——host 可调比赛参数、看玩家列表、
/// 点 START 开局;客户端 JOIN 连上后进同一界面,参数与玩家列表由 host 经 LobbyState RPC 推送,
/// 显示"等待房主开始",房主开局后经 MatchStarted RPC 自动进游戏。
/// 主菜单的设置区已移到这里(房间才是设置参数的地方)。
/// 纯 C# Panel + 同目录 LobbyPanel.cs.scss。
/// </summary>
public sealed class LobbyPanel : PanelComponent
{
	Label _status;
	Label[] _settings;       // 0=模式 1=时长 2=人数(host 可点击循环,client 只读展示)
	Label _settingHint;      // "CLICK A SETTING TO CYCLE IT"(仅 host,v0.7.8.0)
	Label[] _playerRows;     // 玩家列表:单列(滚动容器内,容量 = MaxPlayerRows)
	Panel _playersScroll;    // 玩家列表原生滚动容器(overflow-y + 滚轮)
	Label _botsNote;
	Label _start;
	Label _leave;
	Label _join;             // 客户端"JOIN GAME"按钮:对局进行中时显示,点击后 3-2-1 进入(v0.6.4.1)
	Label _countdown;        // 加入倒计时(客户端收到 host 开局指令后 3-2-1,v0.6.4.0)
	bool _matchRunning;      // 客户端视角:host 的对局进行中(JOIN GAME 按钮亮起)
	bool _modeGlowActive;    // 模式行呼吸色激活中(切回客户端身份时恢复 scss 基色一次)

	bool _built;
	bool _retryOpen;         // Panel 未就绪时的开门重试(EnsureWorld 会先于 OnStart 调用 ShowClient/ShowHost)
	bool _isHost;
	bool _shown;
	bool _connectionLost;    // 掉线重连中:状态行锁定提示(否则每帧 RefreshStatus 会覆盖掉)
	TimeSince _sinceListRefresh;

	// 客户端:房主推送的房间信息(LobbyState RPC)
	byte _clientMode;
	int _clientDuration;
	int _clientPlayers;
	List<string> _clientNames = new List<string>();

	const int MaxPlayerRows = 64;   // = MaxPlayers(sbproj),滚动列表装得下满房

	// 排版预览(2026-09-09 用户调样式用):列表显示一批假玩家名。已调完关闭;下次预览再改 true
	// (static readonly 不用 const:const 的 true/false 都会把分支判成不可达代码,报 CS0162)
	static readonly bool ShowSamplePlayers = false;
	static readonly string[] SamplePlayers =
	{
		"FLYBIRD", "KITTEN_92", "小���鸟", "ProEater", "SNACKLORD", "汤圆", "Wobble", "XIAO_LONG",
		"Mochi", "大球王", "pixel_cat", "Doodle", "布丁", "GULP", "泥鳅", "Bubbles",
		"阿翔", "Tofu", "MUNCHER", "云朵",
	};

	protected override void OnStart()
	{
		base.OnStart();
		BuildUi();
		Hide();
	}

	void BuildUi()
	{
		var root = Panel;
		if ( root is null ) return;
		if ( _built && _status.IsValid() && root.ChildrenCount > 0 ) return;

		root.DeleteChildren( true );

		root.Style.Position = PositionMode.Absolute;
		root.Style.Left = 0f;
		root.Style.Top = 0f;
		root.Style.Right = 0f;
		root.Style.Bottom = 0f;
		root.Style.BackgroundColor = new Color( 0.918f, 0.957f, 0.988f, 0.82f );   // 浅蓝磨砂(像素风定稿;旧深灰遮罩在浅色世界上一坨脏灰,用户嫌丑)
		root.Style.PointerEvents = PointerEvents.All;

		UiKit.AttachStyles( root );   // 共享部件样式(.cr-pill 药丸按钮)

		var title = new Label() { Classes = "lp-title" };
		title.Text = "GAME LOBBY";
		root.AddChild( title );

		_status = new Label() { Classes = "lp-status" };
		root.AddChild( _status );

		var settingsHead = new Label() { Classes = "lp-head settings" };
		settingsHead.Text = "MATCH SETTINGS";
		root.AddChild( settingsHead );

		// 设置牌纵排(参考图:右栏三张横牌从上到下),host 可点击循环,client 只读展示
		_settings = new Label[3];
		for ( int i = 0; i < _settings.Length; i++ )
		{
			var l = new Label() { Classes = "lp-setting" };
			l.Style.Top = 330f + i * 88f;   // ★ 设置牌起点 + 行距(精修改这里)
			int index = i;
			l.AddEventListener( "onclick", () => CycleSetting( index ) );
			root.AddChild( l );
			_settings[i] = l;
		}

		// 设置可点提示(v0.7.8.0):仅 host 可见——配合模式行呼吸金光,让玩家知道能改模式
		_settingHint = new Label() { Classes = "lp-setting-hint" };
		_settingHint.Text = "CLICK A SETTING TO CYCLE IT";
		root.AddChild( _settingHint );

		// 道具图例已按用户要求整个移除(2026-09-09)——Q/E 用法开局后 HUD 里还有提示

		var playersHead = new Label() { Classes = "lp-head players" };
		playersHead.Text = "PLAYERS";
		root.AddChild( playersHead );

		// 玩家列表容器底(dobou InventoryHotbar 九宫格,用户指定 2026-09-09):
		// 先于行牌创建压在底层;空列表时也是一个有框的整洁面板,不再是裸留白
		var playersBg = new Panel() { Classes = "lp-players-bg" };
		root.AddChild( playersBg );

		// 玩家列表:单列流内排列装进原生滚动容器(overflow-y + 滚轮,网页式滚动条),
		// 容量提到 64(= MaxPlayers);hotbar 底框只是装饰,滚动发生在它上面的透明容器里
		_playersScroll = new ScrollSoundPanel() { Classes = "lp-players-scroll" };
		root.AddChild( _playersScroll );

		_playerRows = new Label[MaxPlayerRows];
		for ( int i = 0; i < _playerRows.Length; i++ )
		{
			var row = new Label() { Classes = "lp-player" };
			_playersScroll.AddChild( row );
			_playerRows[i] = row;
		}

		_botsNote = new Label() { Classes = "lp-note" };
		_botsNote.Text = "BOTS FILL REMAINING SLOTS AT START";
		root.AddChild( _botsNote );

		// 加入倒计时(客户端):收到 host 开局指令后显示 3-2-1
		_countdown = new Label() { Classes = "lp-countdown" };
		_countdown.Style.Display = DisplayMode.None;
		root.AddChild( _countdown );

		// 三个按钮全走 UiKit 药丸(外观在 Assets/ui/cruikit.scss 的 .cr-pill),这里只管摆放类
		_join = UiKit.PillButton( root, "JOIN GAME", "lp-join", () => OnJoinClicked() );

		_start = UiKit.PillButton( root, "START GAME", "lp-start", () => OnStartClicked() );

		_leave = UiKit.PillButton( root, "LEAVE", "lp-leave", () => OnLeaveClicked() );

		_built = true;
	}

	/// <summary> host 进入房间(HOST / VS BOTS 开房后调用) </summary>
	public void ShowHost()
	{
		_isHost = true;
		Open();
	}

	/// <summary> 客户端连入后进入房间(等待房主开局;列表等 LobbyState 推送) </summary>
	public void ShowClient()
	{
		_isHost = false;
		Open();
	}

	void Open()
	{
		BuildUi();
		if ( !_built )
		{
			_retryOpen = true;   // Panel 未就绪(EnsureWorld 立即调 ShowClient/ShowHost 时),OnUpdate 重试
			return;
		}

		// 面板被销毁但 _built 还残留 true(世界重建/组件禁用后)——重建,别碰 null Panel:
		// 每帧异常会漏引擎"每帧内存栈",几分钟后崩编辑器(实测最贵的一课)
		if ( Panel is null || !Panel.IsValid() )
		{
			_built = false;
			_retryOpen = true;
			return;
		}

		_retryOpen = false;
		Panel.Style.Display = DisplayMode.Flex;
		if ( _shown )
		{
			// 已显示:不重置状态(ReconcileUi 每帧调,JOIN 按钮/倒计时不能被清掉)。
			// 但状态行要恢复——掉线重连成功回到房间时,"CONNECTION LOST" 不能一直挂着
			if ( !_isHost ) RefreshStatus();
			return;
		}
		_shown = true;
		_matchRunning = false;   // 每次进房间重置(MatchStarted 到达后再亮 JOIN 按钮)
		HideJoinCountdown();
		_sinceListRefresh = 1f;   // 首帧立即刷
		RefreshAll();
	}

	/// <summary> 收起(开局 / 回菜单) </summary>
	public void Hide()
	{
		_shown = false;
		_retryOpen = false;
		if ( Panel.IsValid() ) Panel.Style.Display = DisplayMode.None;
	}

	/// <summary> 客户端应用房主推送的房间信息(CircleroyaleGame.OnLobbyStateRemote 转发) </summary>
	public void UpdateFromHost( byte mode, int duration, int players, string[] names )
	{
		_connectionLost = false;   // host 有消息 = 连接活着,解除掉线提示
		_clientMode = mode;
		_clientDuration = duration;
		_clientPlayers = players;
		_clientNames = names is not null ? new List<string>( names ) : new List<string>();

		if ( _shown && !_isHost )
		{
			RefreshSettings();
			RefreshPlayers();
			RefreshButtons();   // 掉线恢复后把常驻按钮摆回来(WAITING/JOIN 文案也靠它刷)
		}
	}

	/// <summary> 显示加入倒计时(CircleroyaleGame 每帧调用) </summary>
	public void ShowJoinCountdown( float secondsLeft )
	{
		if ( _countdown is null || !_countdown.IsValid() ) return;

		_countdown.Style.Display = DisplayMode.Flex;
		_countdown.Text = $"JOINING MATCH IN {MathF.Ceiling( MathF.Max( 0f, secondsLeft ) ):0}";

		// 倒计时期间收起 JOIN 按钮
		if ( _join.IsValid() ) _join.Style.Display = DisplayMode.None;
	}

	/// <summary> 收起加入倒计时 </summary>
	public void HideJoinCountdown()
	{
		if ( _countdown.IsValid() ) _countdown.Style.Display = DisplayMode.None;
	}

	/// <summary> 客户端视角:host 的对局进行中(房间页亮起 JOIN GAME 按钮) </summary>
	public void SetMatchRunning()
	{
		_matchRunning = true;
		RefreshButtons();

		SetText( _status, "MATCH IN PROGRESS — JOIN NOW" );
	}

	/// <summary> 文本防抖(v0.7.1.2):值没变就不赋——Label.Text 每帧重设(哪怕同值)会触发布局
	/// 重算;ReconcileUi 每帧调 ShowClient→RefreshStatus 把状态行反复重刷 = UI 一闪一闪(实测) </summary>
	static void SetText( Label l, string text )
	{
		if ( !l.IsValid() || l.Text == text ) return;
		l.Text = text;
	}

	/// <summary> 掉线自动重连中(CircleroyaleGame.OnDisconnectRecoveryStarted):状态行提示 + 收掉 JOIN 按钮 </summary>
	public void SetConnectionLost()
	{
		_connectionLost = true;
		SetText( _status, "CONNECTION LOST — RECONNECTING ..." );
		if ( _join.IsValid() ) _join.Style.Display = DisplayMode.None;
	}

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

		BuildUi();

		// EnsureWorld 先于 OnStart 调过 ShowClient/ShowHost 且 Panel 未就绪 → 此刻补开
		if ( _retryOpen && !_shown )
		{
			Open();
			return;
		}

		if ( !_shown ) return;

		if ( Panel.IsValid() && Panel.Style.Display != DisplayMode.Flex ) Panel.Style.Display = DisplayMode.Flex;   // 同值守卫(v0.7.8.87)
		Mouse.Visibility = MouseVisibility.Visible;   // 按钮/设置项要能点

		// host:玩家进出、设置变化本地就能看到,0.5s 轮刷;client 列表靠 LobbyState 推送
		if ( _isHost && _sinceListRefresh > 0.5f )
		{
			_sinceListRefresh = 0;
			RefreshPlayers();
			RefreshStatus();
		}

		// 模式行呼吸金光(v0.7.8.0,仅 host):设置行是可点的,用光效引导玩家去改模式。
		// 颜色动画每帧在变(Sin 连续),不碰 Text 不触发布局重算;客户端恢复静态基色
		if ( _settings is not null && _settings[0].IsValid() )
		{
			if ( _isHost )
			{
				var glow = 0.72f + 0.28f * MathF.Sin( Time.Now * 3.2f );
				_settings[0].Style.FontColor = new Color( 1f * glow, 0.76f * glow, 0.30f * glow );
				_modeGlowActive = true;
			}
			else if ( _modeGlowActive )
			{
				_settings[0].Style.FontColor = new Color( 0.29f, 0.23f, 0.39f );   // scss .lp-setting 基色(梅紫 #4a3b63)
				_modeGlowActive = false;
			}
		}
	}

	void RefreshAll()
	{
		RefreshSettings();
		RefreshPlayers();
		RefreshStatus();
		RefreshButtons();
	}

	void RefreshStatus()
	{
		if ( _isHost )
		{
			var names = CircleroyaleGame.Current?.LobbyPlayerNames();
			int n = names?.Count ?? 1;
			SetText( _status, $"ROOM OPEN — {n} PLAYER{( n > 1 ? "S" : "" )} IN LOBBY" );
		}
		else if ( _connectionLost )
		{
			SetText( _status, "CONNECTION LOST — RECONNECTING ..." );
		}
		else
		{
			SetText( _status, _matchRunning ? "MATCH IN PROGRESS — JOIN NOW" : "WAITING FOR HOST TO START ..." );
		}
	}

	void RefreshSettings()
	{
		if ( _settings is null || _settings[0] is null || !_settings[0].IsValid() ) return;

		byte mode;
		float duration;
		int players;

		if ( _isHost )
		{
			mode = (byte)MatchState.PendingMode;
			duration = MatchState.PendingDuration;
			players = MatchState.PendingPlayerTarget;
		}
		else
		{
			mode = _clientMode;
			duration = _clientDuration;
			players = _clientPlayers;
		}

		SetText( _settings[0], (MatchState.Mode)mode switch
		{
			MatchState.Mode.Ffa => "MODE — FREE FOR ALL",
			MatchState.Mode.Team2 => "MODE — TEAM (2 / TEAM)",
			MatchState.Mode.Team3 => "MODE — TEAM (3 / TEAM)",
			MatchState.Mode.Territory => "MODE — TERRITORY WAR",
			_ => "MODE — TEAM (4 / TEAM)",
		} );

		SetText( _settings[1], duration < 60f
			? $"TIME — {duration:0} SEC"
			: $"TIME — {duration / 60f:0} MIN" );

		// 领土模式人数固定 4 队 × 4 人(host Apply 强制 16),菜单选的 PLAYERS 无效——明示避免困惑
		SetText( _settings[2], (MatchState.Mode)mode == MatchState.Mode.Territory ? "PLAYERS — 16 (4 TEAMS)" : $"PLAYERS — {players}" );
	}

	void RefreshPlayers()
	{
		if ( _playerRows is null || _playerRows[0] is null || !_playerRows[0].IsValid() ) return;

		List<string> names;
		if ( _isHost )
		{
			names = CircleroyaleGame.Current?.LobbyPlayerNames() ?? new List<string>();
		}
		else
		{
			names = _clientNames;
		}

		if ( ShowSamplePlayers )
			names = new List<string>( SamplePlayers );   // 排版预览:假名替代真实列表(看完关掉)

		if ( names.Count == 0 ) names = new List<string> { "..." };

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

			if ( i >= names.Count )
			{
				SetText( row, "" );
				GameHud.SetDisplay( row, DisplayMode.None );   // 空位不画药片条(满屏空条很难看,实测截图)
				continue;
			}

			GameHud.SetDisplay( row, DisplayMode.Flex );
			var tag = i == 0 ? "  (HOST)" : "";
			SetText( row, $"{i + 1}.  {names[i]}{tag}" );
			// 同值守卫:客户端每收到一次 LobbyState 心跳(3s)就全量重刷本列表,
			// 裸写 Display/FontColor = 每 3 秒 64 行布局重算 = 客户端大厅周期性爆闪(实测)
			var c = i == 0 ? new Color( 0.898f, 0.282f, 0.553f ) : new Color( 0.29f, 0.23f, 0.39f, 0.85f );
			if ( row.Style.FontColor != c ) row.Style.FontColor = c;
		}
	}

	void RefreshButtons()
	{
		// host 有 START;客户端第二颗按钮常驻(用户需求 2026-09-09):未开局显示 WAITING FOR HOST
		// (点了不响应),开局亮成 JOIN GAME。LEAVE 两边都有。Display 同值守卫(随 RefreshAll 每 3s/0.5s 全量重刷)
		if ( _start.IsValid() )
			GameHud.SetDisplay( _start, _isHost ? DisplayMode.Flex : DisplayMode.None );

		if ( _join.IsValid() )
		{
			GameHud.SetDisplay( _join, !_isHost ? DisplayMode.Flex : DisplayMode.None );
			SetText( _join, _matchRunning ? "JOIN GAME" : "WAITING FOR HOST" );
		}

		// 设置可点提示(v0.7.8.0):只有 host 能改设置,提示也只给 host
		if ( _settingHint.IsValid() )
			GameHud.SetDisplay( _settingHint, _isHost ? DisplayMode.Flex : DisplayMode.None );
	}

	/// <summary> JOIN GAME 点击(实例方法:热重载可重映射)。WAITING FOR HOST 状态点了不响应 </summary>
	void OnJoinClicked()
	{
		if ( !_matchRunning ) return;
		CircleroyaleGame.Current?.ClientJoinMatch();
	}

	/// <summary> START 点击 </summary>
	void OnStartClicked() => CircleroyaleGame.Current?.StartMatchFromLobby();

	/// <summary> LEAVE 点击 </summary>
	void OnLeaveClicked() => CircleroyaleGame.Current?.ResetToMenu();

	// ---- host 调参(与主菜单时代的循环逻辑一致)----

	void CycleSetting( int index )
	{
		if ( !_isHost ) return;

		GameSfx.UiClick();   // 点了真会改参数才响(client 只读,点了不响)

		switch ( index )
		{
			case 0:
				// 模式 5 档循环(v0.7.8.34):普通赛 → 2 人队 → 3 人队 → 4 人队 → 领土战争
				MatchState.PendingMode = (MatchState.Mode) ( ( (int)MatchState.PendingMode + 1 ) % 5 );
				break;

			case 1:
				MatchState.PendingDuration = NextChoice( GameConfig.MatchDurationChoices, MatchState.PendingDuration );
				break;

			case 2:
				MatchState.PendingPlayerTarget = NextIntChoice( GameConfig.PlayerTargetChoices, MatchState.PendingPlayerTarget );
				break;
		}

		RefreshSettings();
		CircleroyaleGame.Current?.MarkLobbyDirty();   // 让等待中的客户端看到新参数
	}

	static float NextChoice( float[] choices, float current )
	{
		for ( int i = 0; i < choices.Length; i++ )
		{
			if ( MathF.Abs( choices[i] - current ) < 0.5f )
				return choices[( i + 1 ) % choices.Length];
		}
		return choices[0];
	}

	static int NextIntChoice( int[] choices, int current )
	{
		for ( int i = 0; i < choices.Length; i++ )
		{
			if ( choices[i] == current )
				return choices[( i + 1 ) % choices.Length];
		}
		return choices[0];
	}
}
Debug: View Raw JSON Response
{
    "TotalCount": 49,
    "Files": [
        {
            "Ident": "slfy.circleroyale",
            "Path": "Game/CircleroyaleGame.cs",
            "FileName": "CircleroyaleGame.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 380090,
            "IsPrivate": false,
            "Code": "using System;\nusing System.Collections.Generic;\n\n/// <summary>\n/// CircleRoyale \u4e3b\u7cfb\u7edf\uff1a\u8fd0\u884c\u65f6\u751f\u6210\u6574\u4e2a\u4e16\u754c\uff08\u76f8\u673a/HUD/\u80cc\u666f/\u7403\uff09\uff0c\u627f\u8f7d host \u6743\u5a01\u903b\u8f91\u3002\n/// GameObjectSystem \u4f1a\u5728\u573a\u666f\u52a0\u8f7d\u65f6\u81ea\u52a8\u5b9e\u4f8b\u5316\uff08\u65e0\u9700\u6302\u8f7d\u5230\u573a\u666f\uff09\uff0c\u4f46\u7f16\u8f91\u5668\u7684\u7f16\u8f91\u6a21\u5f0f\u4e0b\n/// \u4e5f\u4f1a tick\uff0c\u6240\u4ee5\u6240\u6709\u6e38\u620f\u903b\u8f91\u90fd\u7528 Game.IsPlaying \u62e6\u622a\u3002\u6a21\u677f\u573a\u666f\u5bf9\u8c61\u4f1a\u88ab\u6574\u4f53\u5c4f\u853d\u3002\n/// </summary>\npublic sealed class CircleroyaleGame : GameObjectSystem<CircleroyaleGame>\n{\n\tGameObject _root;\n\tNeonCamera _camera;\n\tGameHud _hud;\n\tEndBoard _endBoard;\n\tLobbyPanel _lobby;\n\tRoomBrowser _browser;\n\tSettingsPanel _settings;\n\tRankingsPanel _rankings;\n\tFoodManager _food;\n\tPowerUpManager _power;\n\tNetworkManager _net;\n\tMainMenu _menu;\n\n\treadonly List<Ball> _balls = new();\n\treadonly Dictionary<Guid, Vector3> _lastPos = new();   // host \u4f4d\u79fb\u6821\u9a8c\u7528\n\treadonly Dictionary<Guid, TimeSince> _teleportGrace = new();   // host\uff1a\u5408\u6cd5\u77ac\u79fb\uff08\u91cd\u751f\u6362\u4f4d/\u664b\u5347\uff09\u540e\u7684\u6821\u9a8c\u5bbd\u9650\u671f\n\tTimeSince _sinceScan = 1f;         // \u7403\u6ce8\u518c\u626b\u63cf\n\tTimeSince _sinceFoodRequest = 1f;  // \u5ba2\u6237\u7aef\u8bf7\u6c42\u98df\u7269\u5168\u91cf\u7684\u8282\u6d41\uff08\u9996\u5e27\u5373\u8bf7\u6c42\uff09\n\tTimeSince _sinceMatchTick = 1f;    // \u5012\u8ba1\u65f6 1Hz \u6821\u65f6\u5e7f\u64ad\n\tTimeSince _sinceTerritoryWire = 1f;   // \u9886\u571f\u72b6\u6001\u53d8\u5316\u5e7f\u64ad\u8282\u6d41\uff085Hz \u4e0a\u9650 + 2s \u4fdd\u6d3b\uff09\n\tbool _pendingFrontRespawn;            // \u672c\u673a\u4e0b\u6b21\u590d\u6d3b\u7684\u524d\u7ebf\u504f\u597d\uff08\u9886\u571f M7.2\uff0cowner \u6362\u4f4d\u65f6\u6d88\u8d39\uff09\n\tint _botNameIndex;\n\tint _teamSeq;                      // \u56e2\u961f\u8d5b\u8f6e\u8f6c\u5206\u961f\u8ba1\u6570\uff08\u5f00\u5c40\u91cd\u7f6e\uff09\n\n\t// ---- \u5206\u8eab/\u5b62\u5b50\uff08M3\uff0c\u7eaf\u6570\u636e\u5b9e\u4f53\u89c1 CellPiece\uff09----\n\treadonly List<CellPiece> _cells = new();                    // host \u6743\u5a01\u6a21\u62df\uff1b\u5ba2\u6237\u7aef\u955c\u50cf\uff08CellsState RPC\uff09\n\treadonly Dictionary<Guid, Vector2> _steerDir = new();       // host\uff1a\u6bcf\u7403\u5e73\u6ed1\u8f6c\u5411\uff08\u5206\u8eab\u8ddf\u968f\u7528\uff09\n\treadonly Dictionary<long, int> _teamScratch = new();        // \u56e2\u961f\u8d5b\u9635\u8425\u6620\u5c04\u590d\u7528\u7f13\u51b2\uff08\u6bcf\u5e27 3 \u5904\u5148\u540e\u6784\u5efa\uff0c\u7701\u5806\u5206\u914d\uff09\n\treadonly Dictionary<int, CellWire> _wireById = new();       // CellsState \u5e94\u7528\u7f13\u51b2\uff08\u6309 Id O(1) \u67e5\u627e\uff09\n\treadonly HashSet<int> _cellIdScratch = new();               // CellsState \u65b0\u589e\u5224\u5b9a\u7f13\u51b2\n\treadonly Dictionary<Guid, TimeSince> _sinceEject = new();   // host\uff1a\u5410\u5b62\u5b50\u8282\u6d41\n\tTimeSince _sinceCellSync = 1f;\n\tTimeSince _sinceCellHeal;        // \u5206\u8eab\u4e3b\u4eba\u5931\u8054\u81ea\u6108\u7684 ScanBalls \u8282\u6d41\uff08\u72ec\u7acb\u8ba1\u65f6\uff0c\u522b\u632a\u7528 _sinceScan \u6253\u4e71\u7403\u626b\u63cf\u8282\u594f\uff09\n\tbool _cellsDirty;                                           // \u589e\u5220\u6807\u8bb0\uff08\u5217\u8868\u6e05\u7a7a\u540e\u8fd8\u8981\u8865\u53d1\u4e00\u6b21\u7a7a\u5feb\u7167\uff09\n\tint _nextCellId = 1;\n\tVector2[] _spikes;                                          // \u7eff\u523a\u4f4d\u7f6e\uff08host \u751f\u6210\uff1b\u5ba2\u6237\u7aef\u7ecf SpikesFull \u540c\u6b65\uff09\n\tfloat[] _spikeFed;                                          // \u6bcf\u6735\u523a\u7d2f\u8ba1\u5582\u5165\u8d28\u91cf\uff08host \u6743\u5a01\uff1b\u5ba2\u6237\u7aef SpikeFed RPC \u955c\u50cf\uff0cv0.7.8.24\uff09\n\treadonly List<SpikeSpine> _spines = new();                  // \u523a\u7206\u5f39\u5e55\uff1ahost=\u6743\u5a01\u6a21\u62df\uff08\u542b\u547d\u4e2d\uff09\uff0c\u5ba2\u6237\u7aef=\u7eaf\u89c6\u89c9\u5f39\u9053\uff08v0.7.8.24\uff09\n\treadonly HashSet<string> _strayUiLogged = new();            // \u5b64\u513f UI \u8ba4\u9886\u65e5\u5fd7\u53bb\u91cd\uff08ScanBalls \u6bcf\u79d2\u8dd1\uff0c\u540c\u540d\u53ea\u62a5\u4e00\u6b21\uff09\n\n\t/// <summary> \u5168\u90e8\u7403\uff08\u771f\u4eba + bot\uff09 </summary>\n\tpublic IReadOnlyList<Ball> Balls => _balls;\n\n\t/// <summary> \u672c\u5730\u7403\uff08M2 \u8d77\uff1a\u771f\u4eba=\u81ea\u5df1\u90a3\u9897\uff0cbot \u65e0\uff09 </summary>\n\tpublic Ball LocalBall { get; private set; }\n\n\t/// <summary> \u672c\u5730\u73a9\u5bb6\u662f\u5426\u5904\u4e8e\u6b7b\u4ea1\u5f85\u91cd\u751f\u72b6\u6001\uff08HUD \u663e\u793a\u6b7b\u4ea1\u9762\u677f\uff09 </summary>\n\tpublic bool IsLocalDead => _worldReady && LocalBall.IsValid() && !LocalBall.Alive;\n\n\t/// <summary> \u98df\u7269\u7ba1\u7406\uff08\u6e32\u67d3\u4e0e AI \u8bfb\uff09 </summary>\n\tpublic FoodManager Food => _food;\n\n\t/// <summary> \u9053\u5177\u7ba1\u7406\uff08\u6e32\u67d3\u8bfb\uff1bhost \u6743\u5a01\uff0c\u5ba2\u6237\u7aef\u4e3a\u4e8b\u4ef6\u955c\u50cf\uff09 </summary>\n\tpublic PowerUpManager PowerUps => _power;\n\n\t/// <summary> \u5168\u90e8\u5206\u8eab/\u5b62\u5b50\uff08\u6e32\u67d3\u8bfb\uff1bhost \u6743\u5a01\uff0c\u5ba2\u6237\u7aef\u4e3a 15Hz \u5feb\u7167\u955c\u50cf\uff09 </summary>\n\tpublic IReadOnlyList<CellPiece> Cells => _cells;\n\n\t/// <summary> \u7eff\u523a\u4f4d\u7f6e\uff08\u9759\u6001\u969c\u788d\uff1bhost \u751f\u6210\uff0c\u5ba2\u6237\u7aef\u7ecf SpikesFull \u5168\u91cf\u540c\u6b65\uff09 </summary>\n\tpublic Vector2[] Spikes => _spikes;\n\n\t/// <summary> \u523a\u7684\u7d2f\u8ba1\u5582\u98df\u91cf\uff080~SpikeGrowTarget\uff0c\u6e32\u67d3\u957f\u5927\u7528\uff1bhost \u6743\u5a01\uff0c\u5ba2\u6237\u7aef\u7ecf SpikeFed RPC \u955c\u50cf\uff09 </summary>\n\tpublic float SpikeFedAt( int index ) =>\n\t\t_spikeFed is not null && (uint)index < (uint)_spikeFed.Length ? _spikeFed[index] : 0f;\n\n\t/// <summary> \u523a\u7206\u5f39\u5e55\uff08\u6e32\u67d3\u8bfb\uff1bhost \u6743\u5a01\u6a21\u62df\u542b\u547d\u4e2d\uff0c\u5ba2\u6237\u7aef\u4e3a\u7eaf\u89c6\u89c9\u5f39\u9053\uff09 </summary>\n\tpublic IReadOnlyList<SpikeSpine> Spines => _spines;\n\n\t/// <summary> HUD\uff08\u63d0\u4f9b\u5c4f\u5e55\u5149\u6807\u6865\u63a5\u7b49\uff09 </summary>\n\tpublic GameHud Hud => _hud;\n\n\t/// <summary> \u8ddf\u968f\u76f8\u673a\uff08\u5c4f\u5e55\u9707\u52a8\u89e6\u53d1\u5165\u53e3\uff09 </summary>\n\tpublic NeonCamera Camera => _camera;\n\n\t/// <summary> \u623f\u95f4\u5217\u8868\u9875\uff08\u4e3b\u83dc\u5355 [2] \u6253\u5f00\uff0c\u70b9\u884c\u52a0\u5165\uff09 </summary>\n\tpublic RoomBrowser Browser => _browser;\n\n\t/// <summary> host\uff1a\u623f\u95f4\u5f00\u7740\uff08\u7b49 START\uff09\uff1b\u6b64\u9636\u6bb5\u73a9\u5bb6\u53ef\u8c03\u53c2/\u8fdb\u51fa\uff0c\u6bd4\u8d5b\u5c1a\u672a\u751f\u6210 </summary>\n\tpublic bool IsLobbyOpen => _lobbyOpen;\n\n\t/// <summary> \u6bd4\u8d5b\u662f\u5426\u5df2\u5f00\u59cb\uff08\u5ba2\u6237\u7aef\u533a\u5206\"\u8fdb\u623f\u95f4\u7b49\"\u8fd8\u662f\"\u76f4\u63a5\u8fdb\u6e38\u620f\"\uff09 </summary>\n\tpublic bool IsMatchStarted => _gameStarted;\n\n\t/// <summary> \u5ba2\u6237\u7aef\u89c6\u89d2\uff1a\u6b63\u5728\u4f1a\u8bdd\u91cc\uff08\u623f\u95f4\u7b49\u5f85/\u5bf9\u5c40/\u52a0\u5165\u5012\u8ba1\u65f6\u4e2d\uff09\u2014\u2014\u6389\u7ebf\u81ea\u52a8\u91cd\u8fde\u5224\u5b9a\u7528 </summary>\n\tpublic bool IsClientInSession => _lobbyWaiting || _gameStarted || _matchRunning || _joinCountdown >= 0f;\n\n\t/// <summary> \u5ba2\u6237\u7aef\u662f\u5426\u505c\u5728\u7b49\u5f85\u9875\uff08P \u9000\u5c40/\u6389\u7ebf\u6062\u590d\u4e2d\uff09\u2014\u2014\u672c\u673a\u7403\u505c\u6b62\u64cd\u63a7\uff08\u7403\u7559\u5728\u539f\u5730\uff0c\u9632\u9694\u5c4f\u76f2\u5f00\uff09 </summary>\n\tpublic bool IsClientWaiting => _lobbyWaiting;\n\n\t/// <summary>\n\t/// \u5ba2\u6237\u7aef\u8fdb\u5165\u623f\u95f4\u7b49\u5f85\u6001\uff08\u6240\u6709\u5165\u53e3\u7edf\u4e00\u8d70\u8fd9\u91cc\uff0cv0.7.1.1\uff09\uff1a\n\t/// \u7f6e\u6807\u5fd7 + **\u91cd\u7f6e/\u89e3\u9664\u623f\u95f4\u5fc3\u8df3**\u2014\u2014\u8fdb\u5165\u7b49\u5f85\u7684\u65f6\u523b host \u4e0d\u4e00\u5b9a\u5728\u623f\u95f4\u9636\u6bb5\uff08\u53ef\u80fd\u5bf9\u5c40\u8fdb\u884c\u4e2d\uff0c\n\t/// \u6ca1\u6709\u540d\u5355\u5e7f\u64ad\uff09\uff0c\u65e7\u8ba1\u65f6\u5668/\u6b66\u88c5\u72b6\u6001\u4f1a\u7acb\u523b\u8bef\u5224\u8d85\u65f6\uff0c\u5fc5\u987b\u7b49 host \u7684\u4e0b\u4e00\u6761 LobbyState \u91cd\u65b0\u6b66\u88c5\u3002\n\t/// </summary>\n\tvoid EnterClientWaiting()\n\t{\n\t\tif ( _lobbyWaiting ) return;   // \u5df2\u5728\u7b49\u5f85\u6001\uff1a\u522b\u91cd\u7f6e\u8ba1\u65f6\uff08\u5426\u5219\u5fc3\u8df3\u6c38\u8fdc\u5224\u4e0d\u8d85\u65f6\uff09\n\n\t\t_lobbyWaiting = true;\n\t\t_lobbyHeartbeatArmed = false;\n\t\t_sinceLobbyHeartbeat = 0;\n\t}\n\n\tstatic GameObject _sharedRoot;   // \u70ed\u91cd\u8f7d/\u91cd\u5efa\u65f6\u590d\u7528\u540c\u4e00\u4e2a\u4e16\u754c\u6839\uff0c\u907f\u514d\u5806\u53e0\u591a\u5957\u573a\u666f\n\n\tbool _worldReady;\n\tbool _networkReady;\n\tbool _gameStarted;              // \u6bd4\u8d5b\u8fdb\u884c\u4e2d\uff08\u6a21\u62df\u95e8\u63a7\uff09\uff1b\u623f\u95f4/\u83dc\u5355\u9636\u6bb5 false\n\tbool _menuDemo;                 // \u4e3b\u83dc\u5355\u80cc\u666f\u6f14\u793a\u8d5b\uff1aauthority \u79bb\u7ebf\u8dd1\u4e00\u573a\u7eaf bot \u5bf9\u6218\uff08v0.6.3.0\uff09\n\tbool _lobbyOpen;                // host\uff1a\u623f\u95f4\u5f00\u7740\uff08\u8fd8\u6ca1\u70b9 START\uff09\n\tbool _lobbyWaiting;             // client\uff1a\u5df2\u8fde\u5165\uff0c\u5728\u623f\u95f4\u91cc\u7b49\u623f\u4e3b\u5f00\u5c40\n\tbool _joinRequested;            // \u5ba2\u6237\u7aef\u70b9\u4e86\"\u52a0\u5165\"\uff1a\u7b49\u4f1a\u8bdd\u4ee5\u5ba2\u6237\u7aef\u8eab\u4efd\u6fc0\u6d3b\uff08\u8fde\u63a5\u524d\u51e0\u5e27 IsAuthority \u4e0d\u53ef\u4fe1\uff09\n\tTimeSince _sinceJoinRequest;\n\tTimeSince _sinceMatchOver;      // \u7ed3\u7b97\u663e\u793a\u8ba1\u65f6\uff0810 \u79d2\u540e\u81ea\u52a8\u56de\u5927\u5385/\u4e3b\u83dc\u5355\uff09\n\tbool _lobbyListDirty;           // \u623f\u95f4\u73a9\u5bb6\u540d\u5355\u53d8\u4e86\uff08\u4e3b\u7ebf\u7a0b\u4e0b\u4e00\u62cd\u5e7f\u64ad LobbyState\uff09\n\tbyte _lobbyMode;                // client\uff1a\u623f\u4e3b\u63a8\u9001\u7684\u623f\u95f4\u53c2\u6570\uff08LobbyState RPC\uff09\n\tint _lobbyDuration;\n\tint _lobbyPlayers;\n\tstring[] _lobbyNames = System.Array.Empty<string>();\n\tfloat _joinCountdown = -1f;     // \u5ba2\u6237\u7aef\u52a0\u5165\u5012\u8ba1\u65f6\uff08\u22650 \u65f6\u623f\u95f4\u9875\u663e\u793a 3-2-1\uff0c\u5f52\u96f6\u8fdb\u5165\u5bf9\u5c40\uff09\n\tbool _matchRunning;             // \u5ba2\u6237\u7aef\u89c6\u89d2\uff1ahost \u7684\u5bf9\u5c40\u8fdb\u884c\u4e2d\uff08\u623f\u95f4\u9875\u4eae JOIN GAME \u6309\u94ae\uff09\n\n\t// host \u5fc3\u8df3\uff08v0.6.8.0\uff09\uff1ahost \u623f\u95f4\u9636\u6bb5 3s \u5e7f\u64ad\u4e00\u6b21\u540d\u5355\u5f53\u5fc3\u8df3\uff1b\u5ba2\u6237\u7aef\u6536\u5230\u5f52\u96f6\uff0c\n\t// \u9759\u9ed8\u8d85\u65f6 = host \u6b7b\u4e86\uff08\u8fdb\u7a0b\u88ab\u6740\u65f6\u5f15\u64ce\u4f1a\u8bdd\u53ef\u80fd\u4e0d\u81ea\u52a8\u5931\u6548\uff09\uff0c\u5f3a\u5236\u62c6\u7ebf\u8d70\u91cd\u8fde\n\tTimeSince _sinceLobbyPing = 999f;   // host\uff1a\u623f\u95f4\u5fc3\u8df3\u8282\u62cd\n\tbool _lobbyHeartbeatArmed;          // client\uff1a\u6536\u5230\u8fc7\u7b2c\u4e00\u6b21\u540d\u5355\u540e\u624d\u6b66\u88c5\uff08\u9632\u521a\u8fdb\u623f\u5c31\u8bef\u5224\uff09\n\tTimeSince _sinceLobbyHeartbeat;\n\n\tpublic CircleroyaleGame( Scene scene ) : base( scene )\n\t{\n\t\tListen( Stage.UpdateBones, 0, Tick, \"CircleroyaleGame.Tick\" );\n\n\t\t// \u6743\u5a01\u6a21\u62df\u8d70\u56fa\u5b9a\u6b65\uff08\u89c1 TickSimulation \u6ce8\u91ca\uff09\uff1a\u6302 FinishFixedUpdate \u800c\u975e StartFixedUpdate\uff0c\n\t\t// \u4ee5\u6392\u5728\u7ec4\u4ef6 OnFixedUpdate \u4e4b\u540e\u2014\u2014\u7403\u672c\u6b65\u79fb\u52a8\u5b8c\uff0c\u5403\u5224\u5b9a\u62ff\u5230\u672c\u6b65\u65b0\u4f4d\u7f6e\n\t\tListen( Stage.FinishFixedUpdate, 0, TickSimulation, \"CircleroyaleGame.TickSimulation\" );\n\t}\n\n\tvoid Tick()\n\t{\n\t\tif ( !Game.IsPlaying ) return;\n\n\t\tEnsureWorld();\n\n\t\t// \u5ba2\u6237\u7aef\uff1a\u4f1a\u8bdd\u6fc0\u6d3b\u4f46\u4e16\u754c\u8fd8\u5728\u83dc\u5355\u6f14\u793a\u6001\uff08\u65ad\u7ebf\u91cd\u8fde\u65f6 EnsureWorld \u5728\u65ad\u7ebf\u671f\u628a\u5206\u652f\u8dd1\u6210\u4e86\n\t\t// \u6f14\u793a\u8d5b\uff0c\u4e14\u91cd\u8fde\u4e0d\u4f1a\u7f6e _joinRequested\uff09\u2192 \u6536\u6f14\u793a\u8d5b\uff0c\u56de\u5230\u4f1a\u8bdd\u52a0\u5165\u6d41\n\t\tif ( !NetworkManager.IsAuthority && Networking.IsActive && _menuDemo )\n\t\t\tStopMenuDemo();\n\n\t\t// \u5ba2\u6237\u7aef\uff1a\u5728\u4f1a\u8bdd\u91cc\u4f46\u54ea\u4e2a\u754c\u9762\u90fd\u4e0d\u5728\uff08\u91cd\u8fde/\u70ed\u91cd\u8f7d\u540e\uff09\u2192 \u56de\u623f\u95f4\u7b49\u5f85\u5e76\u62c9\u5f53\u524d\u72b6\u6001\n\t\tif ( !NetworkManager.IsAuthority && Networking.IsActive && !_gameStarted\n\t\t\t&& !_lobbyWaiting && _joinCountdown < 0f )\n\t\t{\n\t\t\tEnterClientWaiting();\n\t\t\t_menu?.Hide();\n\t\t\t_browser?.Hide();\n\t\t\t_lobby?.ShowClient();\n\t\t\tNetworkManager.RequestMatchState();\n\t\t}\n\n\t\t// \u5ba2\u6237\u7aef\uff1aReplicated \u7684 cr_arena_size \u7531\u5f15\u64ce\u843d\u5230 ConvarArenaSize\uff0c\u8fd9\u91cc\u540c\u6b65\u5230 ArenaHalfSize\u3002\n\t\t// host \u5728 OnNetworkReady \u505a\u540c\u6b3e\u94b3\u5236\uff1b\u6b64\u524d\u5ba2\u6237\u7aef\u4ece\u4e0d\u8d4b\u503c\u2014\u2014\u7f51\u683c/\u8fb9\u754c/\u94b3\u5236\u4e00\u76f4\u7528\u9759\u6001\u9ed8\u8ba4\u503c\uff0c\n\t\t// \u573a\u5730\u6539 3 \u500d\u540e\u4e0e host \u5b9e\u6d4b\u8131\u8282\uff08host 2048 / \u5ba2\u6237\u7aef 6144\uff0c\u5ba2\u6237\u7aef\u7f51\u683c\u5927\u4e86 3 \u500d\uff09\n\t\tif ( !NetworkManager.IsAuthority )\n\t\t\tGameConfig.ArenaHalfSize = Math.Clamp( GameConfig.ConvarArenaSize, 1024f, 12288f );\n\n\t\t// \u5ba2\u6237\u7aef\u52a0\u5165\u6d41\uff1a\u70b9\u4e86\"\u52a0\u5165\"\u540e\u7b49\u4f1a\u8bdd\u4ee5\u5ba2\u6237\u7aef\u8eab\u4efd\u6fc0\u6d3b\u3002\n\t\t// \u26a0\ufe0f \u4e0d\u80fd\u70b9\u5b8c\u5c31\u4fe1 IsAuthority\u2014\u2014\u8fde\u63a5\u5efa\u7acb\u524d\u7684\u51e0\u5e27 IsActive=false\uff0cIsAuthority \u6052\u4e3a true\n\t\tif ( _joinRequested && !_gameStarted )\n\t\t{\n\t\t\tif ( Networking.IsActive && !Networking.IsHost )\n\t\t\t{\n\t\t\t\tGameLog.Info( \"[menu] join: connected as client, entering room\" );\n\t\t\t\t_joinRequested = false;\n\t\t\t\tEnterClientWaiting();\n\t\t\t\t_menu?.Hide();\n\t\t\t\t_browser?.Hide();   // \u623f\u95f4\u5217\u8868\u9875\u6536\u8d77\uff08\u4ece\u5217\u8868\u70b9\u8fdb\u6765\u7684\u6b63\u5e38\u6536\u5c3e\uff09\n\t\t\t\t_lobby?.ShowClient();\n\t\t\t\tNetworkManager.RequestMatchState();\n\t\t\t}\n\t\t\telse if ( _sinceJoinRequest > 12f )\n\t\t\t{\n\t\t\t\t_joinRequested = false;\n\t\t\t\t_menu?.SetStatus( \"JOIN FAILED \u2014 IS THE HOST UP? PRESS [2] TO RETRY\" );\n\t\t\t\t_browser?.SetStatus( \"JOIN TIMED OUT \u2014 PICK ANOTHER ROOM OR REFRESH\" );\n\t\t\t}\n\t\t}\n\n\t\t// \u5ba2\u6237\u7aef\u5728\u4f1a\u8bdd\u754c\u9762\u91cc\u8fde\u63a5\u65ad\u5f00\uff1a**\u4e0d\u7acb\u523b\u56de\u83dc\u5355**\u2014\u2014\u754c\u9762\u7559\u5728\u539f\u5730\u7531 NetworkManager\n\t\t// \u81ea\u52a8\u91cd\u8fde\uff08TickDisconnectRecovery\uff09\uff0c\u91cd\u8bd5\u8017\u5c3d\u56de\u8c03 OnReconnectFailed \u624d\u56de\u4e3b\u83dc\u5355\uff08v0.6.4.6\uff09\n\n\t\t// \u623f\u95f4\u540d\u5355/\u53c2\u6570\u6709\u53d8\uff08\u7f51\u7edc\u7ebf\u7a0b\u53ea\u7f6e\u810f\u6807\uff0c\u4e3b\u7ebf\u7a0b\u7edf\u4e00\u5e7f\u64ad LobbyState\uff09\n\t\tif ( _lobbyOpen && _lobbyListDirty )\n\t\t{\n\t\t\t_lobbyListDirty = false;\n\t\t\tBroadcastLobbyState();\n\t\t}\n\n\t\t// host \u623f\u95f4\u5fc3\u8df3\uff08v0.6.8.0\uff09\uff1a\u540d\u5355\u6ca1\u53d8\u4e5f\u5b9a\u65f6\u5e7f\u64ad\uff0c\u5ba2\u6237\u7aef\u9760\u5b83\u5224\u65ad\"host \u8fd8\u6d3b\u7740\"\n\t\tif ( _lobbyOpen && Networking.IsActive && _sinceLobbyPing > 3f )\n\t\t{\n\t\t\t_sinceLobbyPing = 0;\n\t\t\tBroadcastLobbyState();\n\t\t}\n\n\t\t// host \u5fc3\u8df3\u8d85\u65f6\uff08\u5ba2\u6237\u7aef\uff0cv0.6.8.0\uff09\uff1ahost \u8fdb\u7a0b\u88ab\u6740\u65f6\u5f15\u64ce\u4f1a\u8bdd\u53ef\u80fd\u4e0d\u81ea\u52a8\u5931\u6548\n\t\t// \uff08\u50f5\u5c38\u6001\uff0cIsActive \u6052 true\uff0c\u6389\u7ebf\u6062\u590d\u5faa\u73af\u4e0d\u89e6\u53d1\uff09\u2014\u2014\u5bf9\u5c40/\u623f\u95f4\u5404\u81ea\u6709\u5fc3\u8df3\uff0c\n\t\t// \u9759\u9ed8\u8d85\u65f6\u4e3b\u52a8\u62c6\u7ebf\uff0c\u4ea4\u7ed9 TickDisconnectRecovery\uff08\u91cd\u8fde\u8017\u5c3d\u56de\u4e3b\u83dc\u5355\uff09\n\t\t// v0.7.1.1 \u4fee\uff1a\u4e24\u6761\u5fc3\u8df3**\u6309 host \u5b9e\u9645\u9636\u6bb5\u53d6\u4e00**\u2014\u2014host \u5728\u5bf9\u5c40\u9636\u6bb5\u4e0d\u53d1\u540d\u5355\u5e7f\u64ad\uff0c\n\t\t// \u7b49\u5728\u623f\u95f4\u9875\u7684\u5ba2\u6237\u7aef\u4e0d\u80fd\u62ff\"\u540d\u5355\u9759\u9ed8\"\u8bef\u5224\uff08\u6b64\u524d\u4e2d\u9014\u52a0\u5165 6s \u5373\u88ab\u8e22\u56de\u4e3b\u83dc\u5355\uff09\uff1b\n\t\t// \u5bf9\u5c40\u5fc3\u8df3\uff08MatchTick 1Hz\uff09\u53cd\u8fc7\u6765\u4e5f\u8986\u76d6\"\u5ba2\u6237\u7aef\u5728\u623f\u95f4\u9875\u7b49\u3001host \u5bf9\u5c40\u8fd8\u5728\u8dd1\"\u7684\u60c5\u51b5\n\t\tif ( !NetworkManager.IsAuthority && Networking.IsActive && GameConfig.HostLivenessTimeout > 0f )\n\t\t{\n\t\t\tbool matchSilent = ( _gameStarted || _matchRunning ) && !MatchState.MatchOver\n\t\t\t\t&& MatchState.SinceLastTick > GameConfig.HostLivenessTimeout;\n\t\t\tbool lobbySilent = _lobbyWaiting && _lobbyHeartbeatArmed && !_matchRunning\n\t\t\t\t&& _sinceLobbyHeartbeat > GameConfig.HostLivenessTimeout;\n\n\t\t\tif ( matchSilent || lobbySilent )\n\t\t\t{\n\t\t\t\tLog.Warning( \"[net] host silent beyond liveness timeout \u2014 forcing disconnect\" );\n\t\t\t\ttry { Networking.Disconnect(); } catch { }\n\n\t\t\t\t// \u5bf9\u5c40\u5df2\u6b7b\uff1a\u7acb\u523b\u9000\u51fa\u5bf9\u5c40\u6001\uff0c\u6062\u590d\u671f\u505c\u5728\u623f\u95f4\u9875\uff08CONNECTION LOST\uff09\uff0c\n\t\t\t\t// \u4e0d\u518d\u5bf9\u7740\u51bb\u7ed3\u753b\u9762\u5e72\u7b49\uff08_gameStarted \u6302\u7740\u4f1a\u8ba9 HUD/\u51bb\u7ed3\u5bf9\u5c40\u4e00\u76f4\u5360\u5c4f\uff09\n\t\t\t\t_gameStarted = false;\n\t\t\t\t_matchRunning = false;\n\t\t\t\t_joinCountdown = -1f;\n\t\t\t\t_lobbyHeartbeatArmed = false;\n\t\t\t\t_lobbyWaiting = true;   // \u4fdd\u4f4f IsClientInSession\uff1a\u6389\u7ebf\u6062\u590d\u5faa\u73af\u8981\u9760\u5b83\u542f\u52a8\n\t\t\t\tLocalBall = null;\n\t\t\t}\n\t\t}\n\n\t\t// \u97f3\u4e50\u5faa\u73af\uff08\u83dc\u5355/\u6218\u6597\u90fd\u8981\uff09\uff1a\u97f3\u91cf\u8ddf\u968f convar + \u66f2\u7ec8\u81ea\u52a8\u91cd\u64ad\n\t\tGameMusic.Tick();\n\n\t\tReconcileUi();\n\n\t\t// P \u952e\u5feb\u901f\u9000\u5c40\uff08v0.6.9.0 \u7528\u6237\u9700\u6c42\uff09\uff1a\u5bf9\u5c40\u4e2d\u76f4\u63a5\u8fd4\u56de\u5927\u5385\u2014\u2014host \u5e7f\u64ad\u53d6\u6d88\u8ba9\u5168\u5458\u4e00\u8d77\u56de\u623f\uff0c\n\t\t// \u5ba2\u6237\u7aef\u81ea\u5df1\u56de\u7b49\u5f85\u9875\uff1b\u5355\u673a\uff08\u65e0\u4f1a\u8bdd\uff09\u56de\u4e3b\u83dc\u5355\u3002\u7ed3\u7b97\u671f\u4e0d\u53d7\u7406\uff08EndBoard \u81ea\u52a8\u8fd4\u56de\u5728\u7ba1\uff09\n\t\tif ( _gameStarted && !MatchState.MatchOver && Input.Keyboard.Pressed( \"P\" ) )\n\t\t{\n\t\t\tGameLog.Info( \"[match] quit to lobby via P\" );\n\t\t\tif ( Networking.IsActive && NetworkManager.IsAuthority )\n\t\t\t\tNetworkManager.MatchCancelled();\n\t\t\tAfterSettlement();\n\t\t\treturn;\n\t\t}\n\n\t\t// \u5ba2\u6237\u7aef\u52a0\u5165\u5012\u8ba1\u65f6\uff08v0.6.4.0 \u7528\u6237\u5b9a\u7a3f\uff09\uff1aMatchStarted \u540e\u623f\u95f4\u9875 3-2-1\uff0c\u5f52\u96f6\u8fdb\u5165\u5bf9\u5c40\n\t\tif ( _joinCountdown >= 0f )\n\t\t{\n\t\t\t_joinCountdown -= Time.Delta;\n\t\t\t_lobby?.ShowJoinCountdown( MathF.Max( 0f, _joinCountdown ) );\n\t\t\tif ( _joinCountdown < 0f )\n\t\t\t{\n\t\t\t\t_lobby?.HideJoinCountdown();\n\t\t\t\tOnMatchStartedLocal();\n\t\t\t}\n\t\t}\n\n\t\t// \u83dc\u5355\u9636\u6bb5\uff1a\u96f6\u6a21\u62df\uff08\u98df\u7269/bot/\u5403\u5224\u5b9a/\u540c\u6b65\u5168\u90fd\u4e0d\u8dd1\uff09\u2014\u2014\u9664\u975e\u80cc\u666f\u6f14\u793a\u8d5b\u5728\u8dd1\uff08v0.6.3.0\uff09\n\t\tif ( !_gameStarted && !_menuDemo ) return;\n\n\t\t// \u6bd4\u8d5b\u8fdb\u7a0b\uff08M5\uff09\uff1ahost \u6743\u5a01\u5012\u8ba1\u65f6\uff0c\u5f52\u96f6\u5e7f\u64ad\u7ed3\u7b97\uff1b\u7ed3\u675f\u540e\u5168\u573a\u51bb\u7ed3\u7531\u7ed3\u7b97\u9762\u677f\u63a5\u7ba1\n\t\tTickMatch();\n\t\tif ( MatchState.MatchOver )\n\t\t{\n\t\t\t// \u7ed3\u7b97\u663e\u793a 10 \u79d2\u540e\u81ea\u52a8\u56de\u5927\u5385\uff08\u591a\u4eba\uff09/\u4e3b\u83dc\u5355\uff08\u5355\u673a\uff09\uff1bEndBoard \u7a7a\u683c\u53ef\u63d0\u524d\n\t\t\tif ( _sinceMatchOver > GameConfig.SettlementAutoReturnSeconds )\n\t\t\t\tAfterSettlement();\n\t\t\treturn;\n\t\t}\n\n\t\t// \u6743\u5a01\u7aef\u6a21\u62df\u5df2\u79fb\u81f3\u56fa\u5b9a\u6b65\uff08TickSimulation\uff0c\u89c1\u5176\u6ce8\u91ca\uff09\u2014\u2014\u6b64\u5904\u53ea\u5269\u6e32\u67d3\u5e27\u8be5\u505a\u7684\u4e8b\n\t\t// \uff08\u7ec6\u80de\u6e32\u67d3\u5e73\u6ed1\u3001\u626b\u63cf\u3001UI/\u7f51\u7edc\u7ef4\u62a4\u3001\u6bd4\u8d5b\u8fdb\u7a0b\uff09\uff0c\u4e0d\u518d\u968f\u5e27\u7387\u91cd\u590d\u8dd1\u5403\u5224\u5b9a\n\n\t\t// \u523a\u7206\u5f39\u5e55\uff08\u5ba2\u6237\u7aef\uff09\uff1a\u7eaf\u89c6\u89c9\u5f39\u9053\u63a8\u8fdb\uff08host \u5728 TickSpikes \u6743\u5a01\u6a21\u62df+\u547d\u4e2d\uff09\n\t\tif ( !NetworkManager.IsAuthority ) TickSpinesVisual();\n\n\t\t// \u7ec6\u80de\u6e32\u67d3\u4f4d\u7f6e\u5e73\u6ed1\uff1ahost \u9010\u5e27\u6743\u5a01\u4f4d\u7f6e\u76f4\u63a5\u8d34\u9f50\uff1b\u5ba2\u6237\u7aef\u628a 15Hz \u5feb\u7167\u63d2\u987a\n\t\tif ( _cells.Count > 0 )\n\t\t{\n\t\t\tvar st = 1f - MathF.Exp( -12f * Time.Delta );\n\t\t\tforeach ( var c in _cells )\n\t\t\t\tc.DrawPos = NetworkManager.IsAuthority ? c.Pos : Vector3.Lerp( c.DrawPos, c.Pos, st );\n\t\t}\n\n\t\t// \u7403\u6ce8\u518c\uff1a\u626b\u63cf\u573a\u666f\u6536\u7f16\uff08\u7f51\u7edc\u5bf9\u8c61\u7684 OnStart \u4e0d\u89e6\u53d1\uff0c\u5b9e\u6d4b\uff09+ \u6e05\u7406\u5931\u6548\u7403\n\t\tif ( _sinceScan > 1f )\n\t\t{\n\t\t\t_sinceScan = 0;\n\t\t\tScanBalls();\n\t\t}\n\n\t\t// \u5ba2\u6237\u7aef\uff1a\u672c\u5730\u98df\u7269\u5217\u8868\u672a\u5c31\u7eea\uff08\u70ed\u91cd\u8f7d/\u65b0 FoodManager\uff09\u2192 \u8282\u6d41\u8bf7\u6c42\u5168\u91cf\u3002\n\t\t// \u7b49\u7403\u626b\u63cf\u5230\uff08=\u573a\u666f\u5feb\u7167\u5df2\u6536\uff09\u518d\u8bf7\u6c42\uff1b\u521a\u8fde\u5165\u65f6 host \u7684 OnActive \u4f1a\u4e3b\u52a8\u53d1\uff0c\u65e0\u9700\u62a2\u8dd1\n\t\tif ( !NetworkManager.IsAuthority && _food.Foods is null && _balls.Count > 0 && _sinceFoodRequest > 3f )\n\t\t{\n\t\t\t_sinceFoodRequest = 0;\n\t\t\ttry\n\t\t\t{\n\t\t\t\tNetworkManager.RequestFoodFull();\n\t\t\t}\n\t\t\tcatch ( Exception e )\n\t\t\t{\n\t\t\t\tGameLog.Info( $\"[net] RequestFoodFull failed (session not ready): {e.Message}\" );\n\t\t\t}\n\t\t}\n\n\t\tTickRespawn();\n\t\tTickTerritory();\n\t}\n\n\t/// <summary>\n\t/// host \u6743\u5a01\u6a21\u62df\uff08**\u56fa\u5b9a\u6b65**\uff0cStage.FinishFixedUpdate\uff09\uff1a\u98df\u7269\u91cd\u751f / \u9053\u5177\u62fe\u53d6 / bot \u51b3\u7b56 /\n\t/// \u4f4d\u79fb\u6821\u9a8c / \u5206\u8eab\u6a21\u62df / \u5403\u5224\u5b9a / \u5c16\u523a / \u5206\u8eab\u4e0e\u5b62\u5b50\u5feb\u7167\u5e7f\u64ad\u3002\n\t///\n\t/// \u4e3a\u4ec0\u4e48\u5fc5\u987b\u662f\u56fa\u5b9a\u6b65\uff08v0.7.8.105 \u4fee\uff0c\u5ba1\u8ba1 #11\uff09\uff1a\u8fd9\u4e9b\u5faa\u73af\u91cc\u6709\u5927\u91cf O(\u7403 \u00d7 \u5206\u8eab) \u4e0e\n\t/// O(\u7403\u00b2) \u7684\u5224\u5b9a\uff0c\u539f\u5148\u6302\u5728 Stage.UpdateBones\uff08**\u6bcf\u6e32\u67d3\u5e27**\uff09\u4e0a\uff0c\u4e8e\u662f\u6a21\u62df\u9891\u7387 = \u4e3b\u673a\u5e27\u7387\u2014\u2014\n\t/// 144Hz \u4e3b\u673a\u7684\u5224\u5b9a\u5f00\u9500\u4e0e\u5403\u5224\u5b9a\u89e6\u53d1\u9891\u7387\u662f 50Hz \u7684 2.9 \u500d\uff0c\u4e14\u4e0e\u771f\u6b63\u8dd1\u5728 50Hz \u56fa\u5b9a\u6b65\u91cc\u7684\n\t/// \u79fb\u52a8/BotBrain \u91c7\u6837\u7387\u4e0d\u4e00\u81f4\uff08\u79fb\u52a8\u4e0e\u5403\u5224\u5b9a\u770b\u5230\u7684\u4f4d\u7f6e\u5dee\u6700\u9ad8\u4e00\u5e27\uff09\u3002\n\t/// \u5f15\u64ce\u4fdd\u8bc1 StartFixedUpdate/FinishFixedUpdate \u5728 `Time.Scope(step, 1/FixedUpdateFrequency)`\n\t/// \u5185\u89e6\u53d1\uff08FixedUpdate.cs:32 + Time.cs:37-49\uff09\uff0c\u6240\u4ee5\u8fd9\u91cc\u8bfb\u5230\u7684 `Time.Delta` \u6052\u4e3a 1/50 \u56fa\u5b9a\u6b65\u957f\uff0c\n\t/// \u5404 Tick \u5185\u65e2\u6709\u7684 `Time.Delta` \u7528\u6cd5\u65e0\u9700\u6539\u52a8\u3002\n\t/// \u987a\u5e8f\u4e0e\u672c\u65b9\u6cd5\u5728\u540c\u4e00\u56fa\u5b9a\u6b65\u5185\u7684\u76f8\u5bf9\u6b21\u5e8f\u5373\u539f Tick \u5185\u7684\u987a\u5e8f\uff1b\u6302\u5728 FinishFixedUpdate\n\t/// \uff08\u800c\u975e StartFixedUpdate\uff09\u662f\u4e3a\u4e86\u6392\u5728\u7ec4\u4ef6 OnFixedUpdate \u4e4b\u540e\uff1a\u7403\u672c\u6b65\u5df2\u79fb\u52a8\uff0c\u5403\u5224\u5b9a\u62ff\u5230\u672c\u6b65\u65b0\u4f4d\u7f6e\u3002\n\t/// </summary>\n\tvoid TickSimulation()\n\t{\n\t\tif ( !Game.IsPlaying ) return;\n\t\tif ( !NetworkManager.IsAuthority ) return;\n\t\tif ( !_gameStarted && !_menuDemo ) return;   // \u83dc\u5355\u9636\u6bb5\u96f6\u6a21\u62df\uff08\u4e0e Tick \u540c\u6b3e\u95e8\u7981\uff09\n\t\tif ( MatchState.MatchOver ) return;          // \u7ed3\u7b97\u671f\u5168\u573a\u51bb\u7ed3\n\n\t\t_food.Tick();\n\t\t_power?.Tick( _balls, _cells );\n\t\tTickBots();\n\t\tTickMovementValidation();\n\t\tTickCells();\n\t\tTickEating();\n\t\tTickSpikes();\n\n\t\t// \u5206\u8eab/\u5b62\u5b50\u72b6\u6001 15Hz \u5feb\u7167\u5e7f\u64ad\uff08\u5217\u8868\u6e05\u7a7a\u540e\u8fd8\u8981\u8865\u53d1\u4e00\u6b21\u7a7a\u5feb\u7167\u8ba9\u5ba2\u6237\u7aef\u4e5f\u6e05\u7a7a\uff09\n\t\tif ( _sinceCellSync > GameConfig.CellSyncInterval )\n\t\t{\n\t\t\t_sinceCellSync = 0;\n\t\t\tif ( _cells.Count > 0 || _cellsDirty ) BroadcastCells();\n\t\t}\n\t}\n\n\t/// <summary> \u9886\u571f\u6a21\u5f0f\u6bcf\u5e27\uff08M7\uff09\uff1ahost \u68c0\u67e5\u5f81\u670d\u80dc\u5229 + \u53d8\u5316/\u4fdd\u6d3b\u5e7f\u64ad 5Hz \u5feb\u7167\uff1b\u5ba2\u6237\u7aef\u53ea\u7b49 ApplyRemote\u3002\n\t/// \u7ec8\u5c40\u5feb\u7167\u5148\u884c\u2014\u2014\u5ba2\u6237\u7aef\u7ed3\u7b97\u699c\u6309\u9886\u571f\u80dc\u8d1f\u663e\u793a\uff0c\u6162\u4e00\u62cd\u5c31\u4f1a\u6309\u65e7\u683c\u6570\u5224\u9519\u51a0\u519b </summary>\n\tvoid TickTerritory()\n\t{\n\t\tif ( !MatchState.IsTerritory || MatchState.MatchOver ) return;\n\n\t\tif ( !NetworkManager.IsAuthority )\n\t\t{\n\t\t\t_sinceTerritoryWire = 1f;\n\t\t\treturn;\n\t\t}\n\n\t\tTerritoryManager.TickPoints( Time.Delta );   // \u5360\u65d7\u79ef\u5206\uff08\u6bcf\u65d7\u6bcf 3s +1\uff0cM7.4 \u7528\u6237\u5b9a\u7a3f\uff09\n\t\tClassSkillManager.Tick();                    // \u5766\u514b\u4e34\u65f6\u8d28\u91cf\u5230\u671f\u56de\u6536\n\n\t\tif ( TerritoryManager.ConquerWinner >= 0 )\n\t\t{\n\t\t\tNetworkManager.TerritoryState( TerritoryManager.Snapshot(), TerritoryManager.PointsWire() );\n\t\t\tEndMatch();\n\t\t\treturn;\n\t\t}\n\n\t\tif ( TerritoryManager.NeedsSync || _sinceTerritoryWire > 2f )\n\t\t{\n\t\t\t_sinceTerritoryWire = 0;\n\t\t\tNetworkManager.TerritoryState( TerritoryManager.Snapshot(), TerritoryManager.PointsWire() );\n\t\t}\n\t}\n\n\t// ---- \u5403\u4e0e\u6210\u957f ----\n\n\tvoid TickEating()\n\t{\n\t\t// \u7403 \u00d7 \u98df\u7269\uff1a\u98df\u7269\u4e2d\u5fc3\u8fdb\u5165\u7403\u4f53\u5373\u88ab\u5403\uff08\u7a7a\u95f4\u54c8\u5e0c\u7c97\u7b5b + \u7cbe\u786e\u590d\u6838\uff0c4140 \u9897\u65f6\u7701 ~20 \u4e07\u6b21/\u5e27\uff09\n\t\t// \u78c1\u94c1 buff\uff08v0.7.3.0\uff09\uff1a\u5438\u98df\u534a\u5f84\u7ffb\u500d\u2014\u2014\u9694\u7740\u4e00\u4e2a\u8eab\u4f4d\u4e5f\u80fd\u628a\u98df\u7269\u5438\u8fdb\u5634\u91cc\uff08\u53ea\u5f71\u54cd\u98df\u7269\uff09\n\t\tvar foods = _food.Foods;\n\t\tforeach ( var ball in _balls )\n\t\t{\n\t\t\tif ( !ball.IsValid() || !ball.Alive ) continue;\n\n\t\t\tvar bp = ball.WorldPosition;\n\t\t\tvar r = ball.HasBuff( PowerUpManager.Kind.Magnet ) ? ball.Radius * 2f : ball.Radius;\n\t\t\tvar near = _food.Nearby( bp, r );\n\n\t\t\tfor ( int k = 0; k < near.Count; k++ )\n\t\t\t{\n\t\t\t\tint i = near[k];\n\t\t\t\tif ( !foods[i].Alive ) continue;\n\n\t\t\t\tvar dx = foods[i].Pos.x - bp.x;\n\t\t\t\tvar dy = foods[i].Pos.y - bp.y;\n\t\t\t\tif ( dx * dx + dy * dy > r * r ) continue;\n\n\t\t\t\tif ( _food.TryEat( i, ball.OwnerSteamId ) )\n\t\t\t\t{\n\t\t\t\t\tball.Mass = MathF.Min( GameConfig.MaxMass, ball.Mass + GameConfig.FoodMass );\n\t\t\t\t\tNeonRenderer.Spark( new Vector3( foods[i].Pos.x, foods[i].Pos.y, 0f ), foods[i].ColorIndex );\n\t\t\t\t\tif ( GameSfx.IsMine( ball.OwnerSteamId ) )\n\t\t\t\t\t\tGameSfx.EatFood( new Vector3( foods[i].Pos.x, foods[i].Pos.y, 0f ) );\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// \u8d28\u91cf\u8870\u51cf\uff08v0.7.8.24 \u7403\u7403\u5927\u4f5c\u6218\u624b\u611f\uff09\uff1a\u5927\u7403\u6301\u7eed\u6389\u91cd\uff0c\u538b\u5236\u6eda\u96ea\u7403\u3001\u903c\u5927\u54e5\u4e0d\u505c\u884c\u52a8\u3002\n\t\t// \u5c0f\u7403\u514d\u8870\u51cf\uff1b\u5206\u8eab\u540c\u7387\uff08TickCells\uff09\u3002Mass \u662f [Sync(FromHost)]\uff0c\u8870\u51cf\u503c\u81ea\u52a8\u4e0b\u53d1\u3002\n\t\t// \u9886\u571f\u6a21\u5f0f\uff08M7 \u7528\u6237\u5b9a\u7a3f\u2461\uff09\uff1a\u5df1\u65b9\u7403\u5728\u5df1\u65b9\u5927\u672c\u8425\u5185\u514d\u8870\u51cf\uff08\u56de\u5bb6\u4fdd\u517b\u7684\u62c9\u626f\u70b9\uff09\n\t\tforeach ( var b in _balls )\n\t\t{\n\t\t\tif ( !b.IsValid() || !b.Alive ) continue;\n\t\t\tif ( b.Mass > GameConfig.MassDecayMinMass\n\t\t\t\t&& !( GameConfig.TerritoryHqStopsDecay && TerritoryManager.IsOwnHq( b.TeamIndex, b.WorldPosition ) ) )\n\t\t\t\tb.Mass = MathF.Max( GameConfig.MassDecayMinMass, b.Mass - b.Mass * GameConfig.MassDecayPerSecond * Time.Delta );\n\t\t}\n\n\t\t// \u654c\u8425\u5b89\u5168\u5c4b\uff08M7.2\uff09\uff1a\u654c\u65b9\u7403\u95ef\u5927\u672c\u8425\u6301\u7eed\u6389\u91cd\u2014\u2014\u9a71\u8d76\u800c\u4e0d\u5904\u5211\uff08\u4e0b\u9650\u51fa\u751f\u8d28\u91cf\uff09\n\t\tforeach ( var b in _balls )\n\t\t{\n\t\t\tif ( !b.IsValid() || !b.Alive ) continue;\n\t\t\tif ( TerritoryManager.InHostileHq( b.TeamIndex, b.WorldPosition ) )\n\t\t\t\tb.Mass = MathF.Max( GameConfig.StartMass, b.Mass - b.Mass * GameConfig.TerritoryHqHurtPerSecond * Time.Delta );\n\t\t}\n\n\t\t// \u7403 \u00d7 \u7403\uff1a\u89e6\u78b0\u5373\u5403\uff08\u8d28\u91cf\u6bd4\u95e8\u69db\uff1b\u62a4\u76fe\u671f/\u6b7b\u4ea1\u514d\u6218\uff09\u3002\n\t\t// \u6b7b\u4ea1\u4e0d\u9500\u6bc1\u5bf9\u8c61\u2014\u2014MarkDead \u540e\u539f\u5730\u590d\u7528\uff08\u8fdb\u884c\u4e2d NetworkSpawn \u4e0d\u590d\u5236\u5230\u5df2\u8fde\u63a5\u5ba2\u6237\u7aef\uff0c\u5b9e\u6d4b\uff09\n\t\tfor ( int i = 0; i < _balls.Count; i++ )\n\t\t{\n\t\t\tvar a = _balls[i];\n\t\t\tif ( !a.IsValid() || !a.Alive ) continue;\n\n\t\t\tfor ( int j = i + 1; j < _balls.Count; j++ )\n\t\t\t{\n\t\t\t\tvar b = _balls[j];\n\t\t\t\tif ( !b.IsValid() || !b.Alive ) continue;\n\n\t\t\t\tvar bigger = a.Mass >= b.Mass ? a : b;\n\t\t\t\tvar smaller = bigger == a ? b : a;\n\t\t\t\tif ( SameTeam( a, b ) ) continue;   // \u961f\u53cb\u514d\u4f24\uff08\u56e2\u961f\u8d5b\uff0cv0.6.5.0\uff09\n\t\t\t\tif ( bigger.Mass < smaller.Mass * GameConfig.EatRatio ) continue;\n\t\t\t\tif ( smaller.IsProtected || bigger.IsProtected ) continue;   // \u62a4\u76fe\u671f\u53cc\u5411\u514d\u6218\n\t\t\t\tif ( smaller.HasBuff( PowerUpManager.Kind.Shield ) ) continue;   // \u9053\u5177\u62a4\u76fe\uff1a\u4e0d\u53ef\u88ab\u5403\uff08\u4e0d\u9632\u5c16\u523a\uff09\n\n\t\t\t\tvar d = bigger.WorldPosition.Distance( smaller.WorldPosition );\n\t\t\t\tif ( d >= bigger.Radius + smaller.Radius ) continue;   // \u89e6\u78b0\u5373\u5403\uff08\u7528\u6237\u5b9a\u7a3f\uff09\n\n\t\t\t\t// \u541e\u5e76\u5c01\u9876\uff1a\u4e0d\u8bbe\u4e0a\u9650\u7684\u8bdd\u8fde\u9501\u541e\u566c\u4f1a\u8ba9\u534a\u5f84\u8d85\u8fc7\u573a\u5730\u4e00\u534a\uff0c\n\t\t\t\t// \u8fb9\u754c\u94b3\u5236 min>max \u629b\u5f02\u5e38\uff08\u6bcf\u5e27\u5237\u5c4f\uff09\u4e14\u91cd\u751f\u8005\u79d2\u88ab\u5403\uff08\u6b7b\u4ea1\u5faa\u73af\uff09\u2014\u2014\u5b9e\u6d4b\n\t\t\t\tfloat gained = smaller.Mass;\n\t\t\t\tbigger.Mass = MathF.Min( GameConfig.MaxMass, bigger.Mass + gained );\n\t\t\t\tSpawnPopFx( smaller );\n\t\t\t\t// \u541e\u7403\u97f3\u53ea\u5bf9\u53cc\u65b9\u64ad\uff08\u5403\u8005 + \u88ab\u5403\u8005\uff09+ \u5168\u573a\"\u8c01\u5403\u4e86\u8c01\"\u64ad\u62a5\uff08\u53cc\u7aef\u7edf\u4e00\u5165\u53e3\uff09\n\t\t\t\tNotifyBallEaten( bigger.OwnerSteamId, smaller.OwnerSteamId, gained );\n\n\t\t\t\t// agar \u591a\u8eab\u4f53\u89c4\u5219\uff08v0.6.2.0 \u7528\u6237\u5b9a\u7a3f\uff09\uff1a\u5355\u4e2a\u8eab\u4f53\u6b7b\u4ea1\u4e0d\u5bfc\u81f4\u73a9\u5bb6\u6b7b\u4ea1\u2014\u2014\n\t\t\t\t// \u4e3b\u7403\u88ab\u5403\u65f6\u6700\u5927\u7684\u5206\u8eab\u5347\u7ea7\u6210\u65b0\u4e3b\u7403\uff0c\u5168\u90e8\u8eab\u4f53\u6b7b\u5149\u624d\u7b97\u6b7b\n\t\t\t\tif ( TryPromoteFromPieces( smaller ) )\n\t\t\t\t{\n\t\t\t\t\tGameLog.Info( $\"[game] body lost: {smaller.PlayerName} <- {bigger.PlayerName} (promoted)\" );\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tsmaller.MarkDead();\n\t\t\t\tGameLog.Info( \"[game] eaten: \" + smaller.PlayerName + \" <- \" + bigger.PlayerName );\n\t\t\t}\n\t\t}\n\n\t\t// ---- \u5206\u8eab/\u5b62\u5b50\u53c2\u4e0e\u5403\uff08M3\uff09----\n\n\t\t// \u5206\u8eab \u00d7 \u98df\u7269\uff08\u540c\u6b3e\u7a7a\u95f4\u54c8\u5e0c\u7c97\u7b5b\uff09\n\t\tforeach ( var c in _cells )\n\t\t{\n\t\t\tif ( c.PieceKind != CellPiece.Kind.SplitPiece ) continue;\n\n\t\t\tvar cp = c.Pos;\n\t\t\tvar cr = c.Radius;\n\t\t\tvar near = _food.Nearby( cp, cr );\n\n\t\t\tfor ( int k = 0; k < near.Count; k++ )\n\t\t\t{\n\t\t\t\tint i = near[k];\n\t\t\t\tif ( !foods[i].Alive ) continue;\n\n\t\t\t\tvar dx = foods[i].Pos.x - cp.x;\n\t\t\t\tvar dy = foods[i].Pos.y - cp.y;\n\t\t\t\tif ( dx * dx + dy * dy > cr * cr ) continue;\n\n\t\t\t\tif ( _food.TryEat( i, c.OwnerSteamId ) )\n\t\t\t\t{\n\t\t\t\t\tc.Mass = MathF.Min( GameConfig.MaxMass, c.Mass + GameConfig.FoodMass );\n\t\t\t\t\tNeonRenderer.Spark( new Vector3( foods[i].Pos.x, foods[i].Pos.y, 0f ), foods[i].ColorIndex );\n\t\t\t\t\tif ( GameSfx.IsMine( c.OwnerSteamId ) )\n\t\t\t\t\t\tGameSfx.EatFood( new Vector3( foods[i].Pos.x, foods[i].Pos.y, 0f ) );\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// \u961f\u4f0d\u6620\u5c04\uff08\u56e2\u961f\u8d5b\uff09\uff1a\u5b62\u5b50\u53ef\u98df\u5ef6\u8fdf\u5206\u6863 + \u4e0b\u65b9\u7403\u00d7\u5206\u8eab\u514d\u4f24\u5171\u7528\n\t\tvar teamOf = BuildTeamMap();\n\n\t\t// \u5b62\u5b50\u88ab\u541e\uff1a\u81ea\u5df1+\u961f\u53cb 0.2s \u6863\uff08v0.7.8.22/23 \u7528\u6237\u5b9a\u7a3f\uff0c\u7403\u7403\u5927\u4f5c\u6218\u5f0f\u77ed\u65f6\u4e92\u5582\u2014\u2014\n\t\t// \u81ea\u5df1\u9a6c\u4e0a\u56de\u6536\u3001\u5582\u961f\u53cb\u5927\u54e5\u8ddf\u81ea\u98df\u4e00\u6837\u987a\uff09\uff1b\u654c\u4eba 0.5s \u6863\u30020.2s \u53ea\u662f\u51fa\u751f\u51c0\u7a7a\uff1a\n\t\t// \u5927\u7403\u5410\u7684\u5b62\u5b50\u534a\u5f84\u53ef\u8d85\u8fc7 16px \u51fa\u751f\u504f\u79fb\uff0c\u96f6\u5ef6\u8fdf\u4f1a\u88ab\u5410\u51fa\u8005\u51fa\u751f\u540c\u5e27\u5438\u56de\uff0c\u5410\u4e1d\u76f4\u63a5\u5931\u6548\n\t\tfor ( int i = _cells.Count - 1; i >= 0; i-- )\n\t\t{\n\t\t\tvar blob = _cells[i];\n\t\t\tif ( blob.PieceKind != CellPiece.Kind.EjectedMass ) continue;\n\n\t\t\tbool eaten = false;\n\n\t\t\t// \u5b62\u5b50\u5582\u9053\u5177\uff08\"\u8840\u91cf\u6c60\"\uff0cv0.7.4.0 \u7528\u6237\u5b9a\u7a3f\uff09\uff1a\u5b62\u5b50\u78b0\u9053\u5177\u88ab\u5438\u6536\u5145\u5165\u5171\u4eab\u79ef\u5206\u6c60\uff0c\n\t\t\t// \u79ef\u5206 \u2265 \u5582\u98df\u8005\u5f53\u524d\u4f53\u91cd 5% \u5373\u89e6\u53d1\u6548\u679c\uff08\u7acb\u5373\u751f\u6548\uff0c\u4e0d\u5165\u80cc\u5305\uff09\u2014\u2014\u864e\u53e3\u593a\u98df\u3002\n\t\t\t// \u26a0\ufe0f \u5224\u5b9a\u5fc5\u987b\u5728 EjectedEdibleDelay **\u4e4b\u524d**\uff08v0.7.5.0 \u4fee\"\u5582\u4e0d\u4e2d\"bug\uff09\uff1a\n\t\t\t// \u5b62\u5b50\u5410\u51fa\u6ed1\u884c ~355 \u8ddd\u79bb\uff0c0.5s \u53ef\u98df\u671f\u5230\u65f6\u5df2\u98de\u8fc7 236\u2014\u2014\u7ecf\u8fc7\u9053\u5177\u7684\u90a3\u4e00\u5e27\u51e0\u4e4e\n\t\t\t// \u603b\u5728\u5ef6\u8fdf\u671f\u5185\uff0c\u7b49\u5ef6\u8fdf\u8fc7\u4e86\u5b62\u5b50\u65e9\u98de\u8fdc\u4e86\u3002\u5582\u98df\u65e0\u8d28\u91cf\u56de\u6d41\uff0c\u6ca1\u6709\"\u79d2\u5403\u81ea\u5df1\"\u6f0f\u6d1e\u3002\n\t\t\tif ( _power?.Ready ?? false )\n\t\t\t{\n\t\t\t\tvar slots = _power.Slots;\n\t\t\t\tfor ( int s = 0; s < slots.Count; s++ )\n\t\t\t\t{\n\t\t\t\t\tvar slot = slots[s];\n\t\t\t\t\tif ( !slot.Alive ) continue;\n\n\t\t\t\t\tvar pp = new Vector3( slot.Pos.x, slot.Pos.y, 0f );\n\t\t\t\t\tif ( blob.Pos.Distance( pp ) >= GameConfig.PowerUpRadius + 8f ) continue;\n\n\t\t\t\t\teaten = true;\n\t\t\t\t\t_power.Feed( FindBallBySteamId( blob.OwnerSteamId ), s, blob.Mass );\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\n\t\t\t\tif ( eaten )\n\t\t\t\t{\n\t\t\t\t\t_cells.RemoveAt( i );\n\t\t\t\t\t_cellsDirty = true;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tforeach ( var b in _balls )\n\t\t\t{\n\t\t\t\tif ( !b.IsValid() || !b.Alive ) continue;\n\t\t\t\tvar ally = SameSide( blob.OwnerSteamId, b.OwnerSteamId, teamOf );\n\t\t\t\tif ( blob.SinceSpawn < ( ally ? GameConfig.EjectedAllyEdibleDelay : GameConfig.EjectedEdibleDelay ) ) continue;\n\t\t\t\tif ( b.WorldPosition.Distance( blob.Pos ) < b.Radius + blob.Radius )\n\t\t\t\t{\n\t\t\t\t\tb.Mass = MathF.Min( GameConfig.MaxMass, b.Mass + blob.Mass );\n\t\t\t\t\teaten = true;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\tif ( !eaten )\n\t\t\t{\n\t\t\t\tforeach ( var c in _cells )\n\t\t\t\t{\n\t\t\t\t\tif ( c.PieceKind != CellPiece.Kind.SplitPiece || c == blob ) continue;\n\t\t\t\t\tvar ally = SameSide( blob.OwnerSteamId, c.OwnerSteamId, teamOf );\n\t\t\t\t\tif ( blob.SinceSpawn < ( ally ? GameConfig.EjectedAllyEdibleDelay : GameConfig.EjectedEdibleDelay ) ) continue;\n\t\t\t\t\tif ( c.Pos.Distance( blob.Pos ) < c.Radius + blob.Radius )\n\t\t\t\t\t{\n\t\t\t\t\t\tc.Mass = MathF.Min( GameConfig.MaxMass, c.Mass + blob.Mass );\n\t\t\t\t\t\teaten = true;\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif ( eaten )\n\t\t\t{\n\t\t\t\t_cells.RemoveAt( i );\n\t\t\t\t_cellsDirty = true;\n\t\t\t}\n\t\t}\n\n\t\t// \u8de8\u4e3b\u4eba\uff1a\u7403 \u00d7 \u5206\u8eab\uff08\u540c\u4e3b\u4eba\u78b0\u649e\u8d70 TickCells \u7684\u5408\u5e76\uff0c\u4e0d\u5728\u8fd9\u91cc\uff09\n\t\t// \u961f\u53cb\u514d\u4f24\uff08v0.6.5.0\uff09\uff1a\u56e2\u961f\u8d5b\u4e0b\u5206\u8eab\u4e0d\u4e0e\u961f\u53cb\u7684\u4e3b\u7403\u4e92\u5403\u2014\u2014teamOf \u5df2\u5728\u4e0a\u65b9\u5efa\u597d\n\n\t\tfor ( int i = 0; i < _balls.Count; i++ )\n\t\t{\n\t\t\tvar ball = _balls[i];\n\t\t\tif ( !ball.IsValid() || !ball.Alive ) continue;\n\t\t\tif ( ball.IsProtected ) continue;   // \u62a4\u76fe\u671f\u53cc\u5411\u514d\u6218\uff08\u5206\u8eab\u6ca1\u6709\u62a4\u76fe\uff0c\u53ea\u4f1a\u88ab\u5403\uff09\n\n\t\t\tfor ( int j = _cells.Count - 1; j >= 0; j-- )\n\t\t\t{\n\t\t\t\tvar c = _cells[j];\n\t\t\t\tif ( c.PieceKind != CellPiece.Kind.SplitPiece ) continue;\n\t\t\t\tif ( c.OwnerSteamId == ball.OwnerSteamId ) continue;\n\t\t\t\tif ( MatchState.IsTeam\n\t\t\t\t\t&& teamOf.TryGetValue( c.OwnerSteamId, out var pt )\n\t\t\t\t\t&& pt == ball.TeamIndex ) continue;   // \u961f\u53cb\u7684\u5206\u8eab/\u4e3b\u7403\u4e92\u4e0d\u5403\n\n\t\t\t\t// \u7403\u541e\u5206\u8eab\n\t\t\t\tif ( ball.Mass >= c.Mass * GameConfig.EatRatio\n\t\t\t\t\t&& ball.WorldPosition.Distance( c.Pos ) < ball.Radius + c.Radius )\n\t\t\t\t{\n\t\t\t\t\tball.Mass = MathF.Min( GameConfig.MaxMass, ball.Mass + c.Mass );\n\t\t\t\t\t_cells.RemoveAt( j );\n\t\t\t\t\t_cellsDirty = true;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// \u5206\u8eab\u53cd\u541e\u4e3b\u7403\uff08v0.6.2.0 agar \u89c4\u5219\uff09\uff1a\u53ea\u635f\u5931\u4e3b\u7403\u8fd9\u4e2a\u8eab\u4f53\u2014\u2014\u6700\u5927\u5206\u8eab\u5347\u7ea7\u6210\u65b0\u4e3b\u7403\n\t\t\t\t// \u5e26\u76fe\u4e3b\u7403\u514d\u75ab\uff08v0.7.3.0\uff09\uff1a\u62a4\u76fe\u8fde\"\u88ab\u5206\u8eab\u541e\"\u4e5f\u6321\u4f4f\n\t\t\t\tif ( !ball.HasBuff( PowerUpManager.Kind.Shield )\n\t\t\t\t\t&& c.Mass >= ball.Mass * GameConfig.EatRatio\n\t\t\t\t\t&& c.Pos.Distance( ball.WorldPosition ) < c.Radius + ball.Radius )\n\t\t\t\t{\n\t\t\t\t\tfloat gained = ball.Mass;\n\t\t\t\t\tc.Mass = MathF.Min( GameConfig.MaxMass, c.Mass + gained );\n\t\t\t\t\tSpawnPopFx( ball );\n\t\t\t\t\tNotifyBallEaten( c.OwnerSteamId, ball.OwnerSteamId, gained );\n\n\t\t\t\t\tif ( TryPromoteFromPieces( ball ) )\n\t\t\t\t\t{\n\t\t\t\t\t\tGameLog.Info( $\"[game] body lost: {ball.PlayerName} <- cell#{c.Id} (promoted)\" );\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tball.MarkDead();\n\t\t\t\t\t\tif ( !ball.IsBot ) PopPiecesOf( ball.OwnerSteamId );\n\t\t\t\t\t\tGameLog.Info( \"[game] eaten: \" + ball.PlayerName + \" <- cell#\" + c.Id );\n\t\t\t\t\t}\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// \u8de8\u4e3b\u4eba\uff1a\u5206\u8eab \u00d7 \u5206\u8eab\uff08i \u5347\u5e8f\u904d\u5386\uff1b\u88ab\u541e\u7684\u81ea\u5df1\u8981\u9000\u7d22\u5f15\uff09\n\t\tfor ( int i = 0; i < _cells.Count; i++ )\n\t\t{\n\t\t\tvar a = _cells[i];\n\t\t\tif ( a.PieceKind != CellPiece.Kind.SplitPiece ) continue;\n\n\t\t\tfor ( int j = _cells.Count - 1; j > i; j-- )\n\t\t\t{\n\t\t\t\tvar b = _cells[j];\n\t\t\t\tif ( b.PieceKind != CellPiece.Kind.SplitPiece ) continue;\n\t\t\t\tif ( b.OwnerSteamId == a.OwnerSteamId ) continue;\n\t\t\t\tif ( MatchState.IsTeam\n\t\t\t\t\t&& teamOf.TryGetValue( a.OwnerSteamId, out var ta )\n\t\t\t\t\t&& teamOf.TryGetValue( b.OwnerSteamId, out var tb )\n\t\t\t\t\t&& ta == tb ) continue;   // \u961f\u53cb\u5206\u8eab\u4e92\u4e0d\u5403\uff08v0.6.5.0\uff09\n\n\t\t\t\tvar big = a.Mass >= b.Mass ? a : b;\n\t\t\t\tvar small = big == a ? b : a;\n\t\t\t\tif ( big.Mass < small.Mass * GameConfig.EatRatio ) continue;\n\t\t\t\tif ( big.Pos.Distance( small.Pos ) >= big.Radius + small.Radius ) continue;\n\n\t\t\t\tbig.Mass = MathF.Min( GameConfig.MaxMass, big.Mass + small.Mass );\n\t\t\t\tNeonRenderer.Puff( small.Pos, small.ColorIndex );   // \u88ab\u541e\u53cd\u9988\uff08v0.7.8.92\uff09\uff1ahost \u4fa7\uff1b\u5ba2\u6237\u7aef\u7531\u5feb\u7167\u79fb\u9664\u7edf\u4e00\u8865\n\t\t\t\t_cellsDirty = true;\n\t\t\t\tif ( small == a )\n\t\t\t\t{\n\t\t\t\t\t_cells.RemoveAt( i );\n\t\t\t\t\ti--;   // \u4e0b\u4e00\u4e2a\u5143\u7d20\u79fb\u4f4d\u8865\u4f4d\uff0c\u5916\u5c42 i++ \u540e\u6b63\u597d\u6307\u5411\u5b83\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\t_cells.RemoveAt( j );\n\t\t\t}\n\t\t}\n\t}\n\n\t/// <summary> bot \u6b7b\u4ea1\u540e\u539f\u5730\u590d\u6d3b\uff08\u4e0d\u518d\u9500\u6bc1\u91cd\u5efa\u2014\u2014\u8fdb\u884c\u4e2d NetworkSpawn \u4e0d\u590d\u5236\u5230\u5df2\u8fde\u63a5\u5ba2\u6237\u7aef\uff0c\u5b9e\u6d4b\uff09\n\t/// \u26a0\ufe0f \u86f0\u4f0f\u7403\uff08Dormant\uff09\u4e0d\u662f\u6b7b\u4ea1\u2014\u2014\u590d\u6d3b\u5b83=\u51ed\u7a7a\u591a\u51fa\u65e0\u961f FFA bot\uff08v0.7.8.25 \u4fee\uff09 </summary>\n\tvoid TickBots()\n\t{\n\t\tforeach ( var b in _balls )\n\t\t{\n\t\t\tif ( !b.IsValid() || !b.IsBot || b.Alive ) continue;\n\t\t\tif ( b.Dormant ) continue;   // \u86f0\u4f0f\u5907\u7528\u7403\uff1a\u7b49\u5f00\u5c40\u6fc0\u6d3b\uff0c\u7edd\u4e0d\u81ea\u52a8\u590d\u6d3b\n\t\t\tif ( b.SinceDeath < GameConfig.BotRespawnSeconds ) continue;\n\n\t\t\t// \u9886\u571f\u6a21\u5f0f bot \u81ea\u52a8\u4e0a\u524d\u7ebf\uff08M7.2\uff09\uff1a\u6ca1\u524d\u7ebf\u683c\u65f6 TryFrontSpawn \u5185\u90e8\u56de\u843d\u5927\u672c\u8425\n\t\t\tRespawnBall( b, frontLine: MatchState.IsTerritory );\n\t\t}\n\t}\n\n\tvoid TickRespawn()\n\t{\n\t\tif ( !LocalBall.IsValid() ) return;\n\t\tif ( LocalBall.Alive ) return;\n\t\tif ( !Input.Pressed( \"Jump\" ) ) return;\n\n\t\tTryRespawnLocal();\n\t}\n\n\t/// <summary> \u672c\u673a\u91cd\u751f\u8bf7\u6c42\uff08\u7a7a\u683c / \u6b7b\u4ea1\u9762\u677f\u70b9\u51fb\u5171\u7528\uff09\uff1a\u6743\u5a01\u7aef\u76f4\u63a5\u539f\u5730\u590d\u6d3b\uff1b\n\t/// \u5ba2\u6237\u7aef\u53d1\u9759\u6001 RPC\uff08host \u7aef Rpc.Caller \u627e\u56de\u8fde\u63a5\uff0cAlive \u540c\u6b65\u4e0b\u53d1\uff09\u3002\n\t/// \u9886\u571f\u6a21\u5f0f frontLine=true \u590d\u6d3b\u5230\u6700\u8fd1\u5df1\u65b9\u5360\u9886\u683c\uff08M7.2 \u4e8c\u9009\u4e00\uff0c\u9ed8\u8ba4\u5927\u672c\u8425\uff09\u3002\n\t/// \u83dc\u5355/\u623f\u95f4\u9636\u6bb5\u4e0d\u53d7\u7406\uff08\u86f0\u4f0f\u7403\u662f\u6b7b\u7684\uff0c\u4f46\u6bd4\u8d5b\u6ca1\u5f00\u59cb\uff1b\u7a7a\u683c\u5728\u7ed3\u7b97\u9762\u677f\u53e6\u6709\u7528\u9014\uff09 </summary>\n\tpublic void TryRespawnLocal( bool frontLine = false )\n\t{\n\t\tif ( !_gameStarted || MatchState.MatchOver ) return;\n\t\tif ( !LocalBall.IsValid() || LocalBall.Alive ) return;\n\n\t\tGameLog.Info( \"[game] local respawn requested\" );\n\t\tif ( NetworkManager.IsAuthority )\n\t\t{\n\t\t\tRespawnBall( LocalBall, frontLine: frontLine );\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// \u8fdc\u7a0b\u7403\u7684\u4f4d\u7f6e\u7531 owner \u81ea\u5df1\u7b97\uff08Alive \u7ffb\u8f6c\u540e\u672c\u5730\u6362\u4f4d\uff09\u2014\u2014\u524d\u7ebf\u504f\u597d\u968f\u70b9\u51fb\u5b58\u672c\u673a\uff0chost \u4f20\u53c2\u65e0\u610f\u4e49\n\t\t\t_pendingFrontRespawn = frontLine;\n\t\t\tNetworkManager.RequestRespawn( frontLine );\n\t\t}\n\t}\n\n\t/// <summary> \u53d6\u8d70\u672c\u673a\u4e0b\u6b21\u590d\u6d3b\u7684\"\u524d\u7ebf\"\u504f\u597d\uff08owner \u6362\u4f4d\u65f6\u7528\uff1b\u6bcf\u6b21\u91cd\u751f\u8bf7\u6c42\u90fd\u4f1a\u91cd\u5199\uff0c\u6d88\u8d39\u5373\u6e05\uff09 </summary>\n\tpublic bool ConsumeFrontRespawn()\n\t{\n\t\tvar v = _pendingFrontRespawn;\n\t\t_pendingFrontRespawn = false;\n\t\treturn v;\n\t}\n\n\t/// <summary> \u539f\u5730\u590d\u6d3b\u4e00\u9897\u7403\uff1a\u91cd\u7f6e\u6570\u503c + Alive=true\uff08\u540c\u6b65\u4e0b\u53d1\uff1b\u5bf9\u8c61\u6c38\u4e0d\u9500\u6bc1\uff09\u3002\n\t/// teamIndex \u2265 0 \u65f6\u91cd\u5199\u961f\u4f0d\u53f7\uff08\u5f00\u5c40\u6fc0\u6d3b\u65f6\u7684\u56e2\u961f\u5206\u914d\uff1b\u666e\u901a\u8d5b -1 \u4e0d\u52a8\uff09\u3002\n\t/// \u9886\u571f\u6a21\u5f0f frontLine=true \u590d\u6d3b\u5230\u6700\u8fd1\u5df1\u65b9\u5360\u9886\u683c\uff08M7.2 \u4e8c\u9009\u4e00\uff09\uff0c\u5426\u5219\u5927\u672c\u8425\u3002\n\t/// cue=false \u7528\u4e8e\u5f00\u5c40\u6279\u91cf\u6fc0\u6d3b\uff08\u63d0\u793a\u97f3\u53ea\u7528\u4e8e\u6bd4\u8d5b\u4e2d\u7684\u590d\u6d3b\uff0cv0.7.8.11\uff09 </summary>\n\tvoid RespawnBall( Ball ball, int teamIndex = -1, bool cue = true, bool frontLine = false )\n\t{\n\t\tball.Mass = GameConfig.StartMass;\n\t\t// \u56e2\u961f\u8d5b\u515c\u5e95\uff08v0.7.8.25\uff09\uff1a\u65e0\u961f\u7403\uff08\u86f0\u4f0f\u8bef\u590d\u6d3b/\u5386\u53f2\u5f02\u5e38\u6001\uff09\u590d\u6d3b\u65f6\u8865\u7f16\u961f\uff0c\u7edd\u4e0d\u5e26 T=-1 \u4e0a\u573a\n\t\tif ( teamIndex < 0 && MatchState.IsTeam && ball.TeamIndex < 0 )\n\t\t\tteamIndex = NextTeamIndex();\n\t\tball.Init( ball.IsBot, teamIndex );   // \u91cd\u7f6e\u4fdd\u62a4\u671f/\u901f\u5ea6/\u989c\u8272\uff08\u540c\u6b65\u4e0b\u53d1\uff09\n\t\tball.Dormant = false;\n\t\tball.Alive = true;\n\n\t\t// \u9886\u571f\u804c\u4e1a\uff08M7.4\uff09\uff1a\u7cfb\u7edf\u968f\u673a\u5206\u914d\uff0c\u6bcf\u6b21\u590d\u6d3b\u91cd\u63b7\u2014\u2014\"\u7531\u7cfb\u7edf\u51b3\u5b9a\"\u4e14\u65e0\u8de8\u5c40\u6b8b\u7559\u6001\n\t\tif ( MatchState.IsTerritory )\n\t\t\tball.TerritoryClass = (byte)Game.Random.Int( 0, 3 );\n\n\t\tClassSkillManager.ResetBall( ball );   // \u804c\u4e1a\u91cd\u63b7\u4e86\u5c31\u628a\u65e7\u51b7\u5374\u6e05\u6389\uff08v0.7.8.87\uff09\uff0c\u5426\u5219\u65b0\u804c\u4e1a\u6309\u4e0d\u52a8\n\n\t\t// host \u81ea\u5df1\u7684\u7403/bot\uff1a\u76f4\u63a5\u6362\u4f4d\uff1b\u771f\u4eba\u5ba2\u6237\u7aef\u7684\u7403\uff1a\u8be5\u5ba2\u6237\u7aef\u5728 Alive\u2192true \u65f6\u81ea\u884c\u968f\u673a\u6362\u4f4d\uff08owner \u6a21\u62df\uff09\n\t\tif ( ball.IsBot || ball.OwnerSteamId == Game.SteamId.Value )\n\t\t{\n\t\t\t// nearPos \u53d6\u6b7b\u4ea1\u4f4d\u7f6e\uff08\u6b64\u523b\u8fd8\u6ca1\u88ab\u8986\u76d6\uff09\u2014\u2014\u524d\u7ebf\u590d\u6d3b\u6311\u6700\u8fd1\u7684\u5360\u9886\u683c\uff08M7.2\uff09\n\t\t\tball.WorldPosition = RandomSpawnPos( ball.TeamIndex, frontLine, ball.WorldPosition );\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// \u8fdc\u7a0b\u7403\uff1ahost \u4e0d\u4ee3\u6362\u4f4d\uff0cowner \u6536\u5230 Alive \u540e\u81ea\u884c\u77ac\u79fb\u2014\u2014\u5f00\u4f4d\u79fb\u6821\u9a8c\u5bbd\u9650\u671f\uff0c\n\t\t\t// \u5426\u5219 owner \u7684\u5e72\u7ea7\u522b\u4f4d\u7f6e\u66f4\u65b0\u4f1a\u88ab TickMovementValidation \u6c38\u4e45\u62c9\u56de\u65e7\u5904\uff08v0.7.8.33 \u4fee\uff09\n\t\t\t_teleportGrace[ball.Id] = 0;\n\t\t}\n\n\t\tGameLog.Info( $\"[net] respawned '{ball.PlayerName}'\" );\n\n\t\tif ( cue ) CueRespawn( ball.OwnerSteamId, ball.TeamIndex );\n\t}\n\n\t/// <summary> \u672c\u673a\u662f\u5426\u8be5\u542c\u5230\u8fd9\u58f0\u91cd\u751f\uff1a**\u4ec5\u961f\u53cb**\u2014\u2014\u81ea\u5df1\u90a3\u6761\u7531 Ball owner \u6a21\u62df\u5206\u652f\u64ad\uff0c\u8fd9\u91cc\u8df3\u8fc7\u9632\u53e0\u97f3 </summary>\n\tbool ShouldHearRespawn( long steamId, int teamIndex )\n\t{\n\t\tif ( GameSfx.IsMine( steamId ) ) return false;\n\t\tif ( !MatchState.IsTeam || teamIndex < 0 ) return false;\n\n\t\tvar local = LocalBall;\n\t\treturn local.IsValid() && local.TeamIndex >= 0 && local.TeamIndex == teamIndex;\n\t}\n\n\t/// <summary> \u91cd\u751f\u63d0\u793a\u97f3\uff08v0.7.8.11\uff09\uff1abot/\u654c\u65b9\u91cd\u751f\u662f\u566a\u97f3\uff0c\u53ea\u8ba9\"\u81ea\u5df1+\u961f\u53cb\"\u542c\u89c1\u2014\u2014\n\t/// host \u672c\u5730\u5224\u5b9a + RespawnCue \u5e7f\u64ad\uff0c\u5404\u7aef\u540c\u4e00\u6761\"\u961f\u53cb\u624d\u54cd\"\u89c4\u5219 </summary>\n\tvoid CueRespawn( long steamId, int teamIndex )\n\t{\n\t\tif ( ShouldHearRespawn( steamId, teamIndex ) )\n\t\t\tGameSfx.Respawn( Vector3.Zero );\n\t\tif ( Networking.IsActive ) NetworkManager.RespawnCue( steamId, teamIndex );\n\t}\n\n\t/// <summary> \u5ba2\u6237\u7aef\u843d\u5730\uff08RespawnCue \u5e7f\u64ad\uff09 </summary>\n\tpublic void OnRespawnCueRemote( long steamId, int teamIndex )\n\t{\n\t\tif ( NetworkManager.IsAuthority ) return;   // host \u5df2\u5728 CueRespawn \u64ad\u8fc7\uff08\u6321\u5e7f\u64ad\u56de\u58f0\uff09\n\t\tif ( ShouldHearRespawn( steamId, teamIndex ) )\n\t\t\tGameSfx.Respawn( Vector3.Zero );\n\t}\n\n\t// ---- \u6bd4\u8d5b\u8fdb\u7a0b\uff08M5\uff09\uff1ahost \u6743\u5a01\u5012\u8ba1\u65f6\uff0c\u5f52\u96f6\u5e7f\u64ad\u7ed3\u7b97\u699c ----\n\n\tvoid TickMatch()\n\t{\n\t\tif ( MatchState.MatchOver ) return;\n\n\t\t// \u53cc\u7aef\u672c\u5730\u9012\u51cf\uff1ahost \u662f\u6743\u5a01\uff0c\u5ba2\u6237\u7aef 1Hz \u6821\u65f6\u540e\u81ea\u5df1\u7eed\u8d70\uff08\u89c2\u611f\u5e73\u6ed1\uff09\n\t\tMatchState.TimeLeft = MathF.Max( 0f, MatchState.TimeLeft - Time.Delta );\n\n\t\tif ( !NetworkManager.IsAuthority ) return;\n\n\t\tif ( _sinceMatchTick > 1f )\n\t\t{\n\t\t\t_sinceMatchTick = 0;\n\t\t\tNetworkManager.MatchTick( MatchState.TimeLeft );\n\t\t}\n\n\t\tif ( MatchState.TimeLeft <= 0f ) EndMatch();\n\t}\n\n\t/// <summary> \u6bd4\u8d5b\u7ed3\u675f\uff08host\uff09\uff1a\u6309\u603b\u8d28\u91cf\uff08\u4e3b\u7403+\u5206\u8eab\uff09\u6392\u7ed3\u7b97\u699c\uff0c\u5e7f\u64ad + \u672c\u5730\u843d\u5730\u3002\n\t/// \u83dc\u5355\u80cc\u666f\u6f14\u793a\u8d5b\u65f6\u95f4\u5230\u5c31\u5c31\u5730\u91cd\u5f00\uff0c\u4e0d\u8d70\u7ed3\u7b97\u3002 </summary>\n\tvoid EndMatch()\n\t{\n\t\tif ( MatchState.MatchOver ) return;\n\n\t\tif ( _menuDemo )\n\t\t{\n\t\t\tMatchState.ActivateForMatch();   // \u6f14\u793a\u8d5b\u5faa\u73af\u91cd\u5f00\uff08\u65e0\u8fde\u63a5\uff0cRPC \u7a7a\u53d1\u65e0\u5bb3\uff09\n\t\t\tGameLog.Info( \"[menu] demo match restarted\" );\n\t\t\treturn;\n\t\t}\n\n\t\tvar rows = new List<ScoreWire>();\n\t\tforeach ( var b in _balls )\n\t\t{\n\t\t\tif ( !b.IsValid() ) continue;\n\t\t\t// \u53ea\u6536\u672c\u5c40\u771f\u6b63\u5728\u573a\u7684\u4eba\uff1a\u86f0\u4f0f\u7403\uff08\u623f\u95f4\u9884\u7559\u3001\u672c\u5c40\u672a\u6fc0\u6d3b\uff09\u4e0e\u5c38\u4f53\u4e0d\u8fdb\u699c\u3002\n\t\t\t// \u26a0\ufe0f v0.7.8.105 \u4fee\uff08\u5ba1\u8ba1 #2\uff09\uff1a\u539f\u5b9e\u73b0\u53ea\u67e5 IsValid\uff0c\u800c StartMatchFromLobby \u53ea\u91cd\u7f6e\n\t\t\t// **\u88ab\u6fc0\u6d3b**\u7684 bot\uff0c\u591a\u4f59\u7684\u90a3\u4e9b\u8d70 Alive=false;Dormant=true \u65f6**\u4fdd\u7559\u4e0a\u5c40\u8d28\u91cf**\u2014\u2014\n\t\t\t// \u4e8e\u662f\u628a\u623f\u95f4 PLAYERS \u8c03\u5c0f\u540e\uff0c\u672a\u6fc0\u6d3b\u7684\u7403\u5e26\u7740\u51e0\u5343\u8d28\u91cf\u53c2\u4e0e\u6392\u540d\uff1a\u62a2\u5360 arr[0] \u663e\u793a\u4e3a\n\t\t\t// \"bot \u593a\u51a0\"\u3001\u62ff\u6389\u672c\u673a champion/firstwin \u6210\u5c31\u3001\u5e76\u628a\u771f\u5b9e\u73a9\u5bb6\u7684\u540d\u6b21\u5956\u52b1\u6574\u4f53\u538b\u4f4e\n\t\t\t// \uff08GameXp \u7684 1st/2~3rd/4~10th \u6863\uff09\u3002HUD \u65e9\u5df2\u8fc7\u6ee4 Alive\uff08GameHud \u6392\u884c\u699c\uff09\uff0c\u6b64\u5904\u5bf9\u9f50\u3002\n\t\t\tif ( !b.Alive || b.Dormant ) continue;\n\t\t\trows.Add( new ScoreWire\n\t\t\t{\n\t\t\t\tSteamId = b.OwnerSteamId,\n\t\t\t\tName = b.PlayerName,\n\t\t\t\tMass = TotalMassFor( b ),\n\t\t\t\tTeam = (byte)Math.Max( 0, b.TeamIndex ),\n\t\t\t\tBot = b.IsBot,\n\t\t\t} );\n\t\t}\n\t\tvar arr = rows.ToArray();\n\t\tArray.Sort( arr, ( x, y ) => y.Mass.CompareTo( x.Mass ) );\n\n\t\t// \u9886\u571f\u6a21\u5f0f\uff08M7.4 \u79ef\u5206\u5236\uff09\uff1a\u540d\u6b21\u6309\u5360\u65d7\u79ef\u5206\uff08\u5f81\u670d=\u5360\u6ee1\u5168\u90e8\u53ef\u5360\u9886\u683c\uff09\uff0c\u79ef\u5206\u540c\u5206\u6bd4\u683c\u6570\u518d\u6bd4\u8d28\u91cf\uff1b\n\t\t// \u7ec8\u5c40\u5feb\u7167\u5148\u884c\u2014\u2014\u5ba2\u6237\u7aef\u7ed3\u7b97\u6309\u9886\u571f\u80dc\u8d1f\u663e\u793a\uff0c\u6162\u4e00\u62cd\u5c31\u4f1a\u6309\u65e7\u683c\u6570\u5224\u9519\u51a0\u519b\n\t\tif ( MatchState.IsTerritory )\n\t\t{\n\t\t\tNetworkManager.TerritoryState( TerritoryManager.Snapshot(), TerritoryManager.PointsWire() );\n\t\t\tArray.Sort( arr, ( x, y ) =>\n\t\t\t{\n\t\t\t\tvar byPts = TerritoryManager.Points( y.Team ).CompareTo( TerritoryManager.Points( x.Team ) );\n\t\t\t\tif ( byPts != 0 ) return byPts;\n\t\t\t\tvar byCells = TerritoryManager.OwnedCount( y.Team ).CompareTo( TerritoryManager.OwnedCount( x.Team ) );\n\t\t\t\treturn byCells != 0 ? byCells : y.Mass.CompareTo( x.Mass );\n\t\t\t} );\n\t\t}\n\n\t\tNetworkManager.MatchOver( arr );   // \u5ba2\u6237\u7aef\u5c55\u793a\u7528\n\t\tOnMatchOverRemote( arr );          // host \u7acb\u5373\u843d\u5730\uff08\u5e7f\u64ad\u56de\u58f0\u88ab MatchOver \u5b88\u536b\u6321\u6389\uff09\n\n\t\tGameLog.Info( $\"[match] over \u2014 {arr.Length} players, winner: {( arr.Length > 0 ? arr[0].Name : \"?\" )}\" );\n\t}\n\n\t/// <summary> \u6bd4\u8d5b\u7ed3\u675f\u843d\u5730\uff08\u5168\u7aef\uff09\uff1a\u51bb\u7ed3 + \u7ed3\u7b97\u9762\u677f\uff1b\u672c\u673a\u6210\u7ee9\u4e0a\u4e91\u3001\u672c\u5730\u699c\u843d\u76d8\uff08\u5404\u7aef\u5404\u81ea\u505a\uff09\u3002\n\t/// 10 \u79d2\u540e\u81ea\u52a8\u56de\u5927\u5385\uff08\u591a\u4eba\uff09/\u4e3b\u83dc\u5355\uff08\u5355\u673a\uff09\uff0c\u89c1 AfterSettlement\u3002 </summary>\n\tpublic void OnMatchOverRemote( ScoreWire[] standings )\n\t{\n\t\tif ( MatchState.MatchOver ) return;   // host \u5e7f\u64ad\u56de\u58f0\u9632\u91cd\u5165\n\t\tMatchState.MatchOver = true;\n\t\t_sinceMatchOver = 0;\n\n\t\tvar local = LocalBall;\n\t\t_endBoard?.Show( standings,\n\t\t\tlocal.IsValid() ? local.OwnerSteamId : 0,\n\t\t\tlocal.IsValid() ? local.PlayerName : \"\" );\n\t\tLocalBoard.Record( standings );\n\t\tScoreUploader.SubmitOwn( this );\n\t\tbool won = GameAchievements.Settlement( standings, local.IsValid() ? local.OwnerSteamId : 0 );   // \u6210\u5c31\uff1a\u593a\u51a0/\u767b\u9876\n\t\tGameXp.Settlement( standings, local.IsValid() ? local.OwnerSteamId : 0, won );                    // \u7ecf\u9a8c\u7ed3\u7b97\u4e0a\u62a5\uff08v0.7.8.19\uff09\n\t\tGameLog.Info( $\"[match] settlement shown ({standings?.Length ?? 0} rows)\" );\n\t}\n\n\t/// <summary> \u541e\u7403\u901a\u77e5\uff08host\uff09\uff1a\u5e7f\u64ad\u5ba2\u6237\u7aef + \u672c\u673a\u64ad\u62a5/\u97f3\u6548 </summary>\n\tvoid NotifyBallEaten( long eaterSteamId, long eatenSteamId, float massGained )\n\t{\n\t\tif ( Networking.IsActive ) NetworkManager.BallEaten( eaterSteamId, eatenSteamId, massGained );   // \u65e0\u4f1a\u8bdd\uff08\u83dc\u5355\u6f14\u793a\u8d5b\uff09\u4e0d\u5e7f\u64ad\n\t\tOnBallEaten( eaterSteamId, eatenSteamId, massGained );\n\t}\n\n\t/// <summary> \u541e\u7403\u4e8b\u4ef6\uff08\u53cc\u7aef\u7edf\u4e00\u5165\u53e3\uff09\uff1a\u5168\u573a\"\u8c01\u5403\u4e86\u8c01\"\u64ad\u62a5\uff1b\u97f3\u6548\u53ea\u5bf9\u53c2\u4e0e\u7684\u53cc\u65b9\uff1b\n\t/// \u51fb\u6740\u8005=\u672c\u673a\u65f6\u5f39\u4e2a\u4eba\u9ad8\u5149\u6a2a\u5e45\uff08v0.7.6.0\uff0c\u91d1\u8272\u827a\u672f\u5b57\uff09 </summary>\n\tpublic void OnBallEaten( long eaterSteamId, long eatenSteamId, float massGained )\n\t{\n\t\tvar eater = FindBallBySteamId( eaterSteamId );\n\t\tvar eaten = FindBallBySteamId( eatenSteamId );\n\n\t\t// \u541e\u7403\u6ed1\u5165\uff08v0.7.8.28 juice\uff09\uff1a\u53cc\u7aef\u7edf\u4e00\u5165\u53e3\u2014\u2014\u88ab\u5403\u8005\u7f29\u56e2\u6ed1\u8fdb\u5403\u8005\u5634\u91cc\u3002\n\t\t// \u5206\u8eab\u53cd\u541e\u4e3b\u7403\u65f6 eater \u662f\u5176\u4e3b\u7403\uff08\u53d6\u4e0d\u5230\u5177\u4f53\u5206\u8eab\uff09\uff0c\u6ed1\u5411\u5176\u4e3b\u961f\u8eab\u4f53\uff0c\u89c2\u611f\u4e3a\"\u88ab\u6536\u7f16\"\n\t\tif ( eater.IsValid() && eaten.IsValid() )\n\t\t\tNeonRenderer.Swallow( eaten.WorldPosition, eaten.ColorIndex, eater.WorldPosition, eaten.Radius );\n\n\t\tif ( GameSfx.IsMine( eaterSteamId ) || GameSfx.IsMine( eatenSteamId ) )\n\t\t\tGameSfx.EatBall( eaten.IsValid() ? eaten.WorldPosition : Vector3.Zero );\n\n\t\tif ( GameSfx.IsMine( eaterSteamId ) )\n\t\t{\n\t\t\t// \u51fb\u6740\u9ad8\u5149\u4e09\u8fde\uff08v0.7.7.0\uff09\uff1a\u8fde\u51fb\u6a2a\u5e45\uff08\u6587\u6848/\u5b57\u53f7\u968f\u6863\u4f4d\uff09+ \u5347\u8c03\u97f3 + \u5c4f\u5e55\u9707\u52a8\n\t\t\tint combo = GameSfx.RegisterKill();\n\t\t\t_hud?.AddKillBanner( combo, eaten?.PlayerName ?? \"???\", massGained );\n\t\t\tGameSfx.ComboSound( combo );\n\t\t\t_camera?.Shake( MathF.Min( 8f + combo * 1.5f, 14f ) );\n\n\t\t\tGameAchievements.LocalKill( combo, massGained, eatenSteamId );   // \u6210\u5c31\uff1a\u51fb\u6740\u65cf\uff08v0.7.8.14\uff09\n\t\t}\n\t\telse if ( GameSfx.IsMine( eatenSteamId ) )\n\t\t{\n\t\t\tGameAchievements.LocalDeath( eaterSteamId );   // \u6210\u5c31\uff1a\u9996\u6b21\u88ab\u5403 + \u8bb0\u4ec7\u540d\u5355\n\t\t}\n\n\t\t_hud?.AddKillFeed( eater?.PlayerName ?? \"???\",\n\t\t\teaten?.PlayerName ?? \"???\",\n\t\t\tmassGained, eaterSteamId, eatenSteamId );\n\t}\n\n\t/// <summary> host \u4f4d\u79fb\u6821\u9a8c\uff1a\u8fdc\u7aef\u6a21\u62df\u7684\u7403\u5355\u5e27\u4f4d\u79fb\u8d85\u7406\u8bba\u4e0a\u9650 3 \u500d\u5373\u62c9\u56de\uff08\u53cd\u77ac\u79fb\u6a61\u76ae\u7b4b\uff09\u3002\n\t/// \u5408\u6cd5\u77ac\u79fb\uff08\u91cd\u751f\u6362\u4f4d/\u5206\u8eab\u664b\u5347\uff09\u6709 TeleportGraceSeconds \u5bbd\u9650\u2014\u2014\u7a97\u53e3\u5185\u653e\u884c owner \u7684\n\t/// \u5e72\u7ea7\u522b\u4f4d\u7f6e\u66f4\u65b0\u5e76\u6301\u7eed\u951a\u5b9a _lastPos\uff0c\u7a97\u53e3\u4e00\u8fc7\u6062\u590d\u5e38\u89c4\u6821\u9a8c\uff08v0.7.8.33 \u4fee\"\u664b\u5347/\u91cd\u751f\u540e\n\t/// host \u7aef\u7403\u4f4d\u88ab\u9489\u6b7b\u5728\u65e7\u5904\"\uff1a\u6b64\u524d _lastPos \u4ece\u4e0d\u968f\u77ac\u79fb\u91cd\u7f6e\uff0cowner \u7684\u6bcf\u6b21\u66f4\u65b0\u90fd\u88ab\u62c9\u56de\uff09 </summary>\n\tvoid TickMovementValidation()\n\t{\n\t\tforeach ( var b in _balls )\n\t\t{\n\t\t\tif ( !b.IsValid() ) continue;\n\n\t\t\tvar key = b.Id;\n\t\t\tvar last = _lastPos.TryGetValue( key, out var lp ) ? lp : b.WorldPosition;\n\n\t\t\tbool grace = _teleportGrace.TryGetValue( key, out var g );\n\t\t\tif ( grace && g > GameConfig.TeleportGraceSeconds )\n\t\t\t{\n\t\t\t\t_teleportGrace.Remove( key );\n\t\t\t\tgrace = false;\n\t\t\t}\n\n\t\t\t// \u6821\u9a8c\u5bf9\u8c61\u7528 IsRemoteOwned\uff08v0.7.8.105\uff0c\u5ba1\u8ba1 #9/#10\uff09\uff1a\u5b83\u662f host \u4fa7\u751f\u6210\u610f\u56fe\u6807\u8bb0\uff0c\n\t\t\t// \u624d\u662f\"\u8fd9\u9897\u7403\u5f52\u8fdc\u7aef\u6a21\u62df\"\u7684\u53ef\u9760\u4fe1\u53f7\u3002\u539f\u6765\u7528 `b.OwnerSteamId != mySteamId` \u5224\u5b9a\uff0c\n\t\t\t// \u800c\u540c\u673a\u53cc\u5f00\u65f6\u8fde\u63a5\u662f\u5408\u6210 SteamId\u3001\u4e0e Game.SteamId \u5bf9\u4e0d\u4e0a\uff08\u672c\u6587\u4ef6\u591a\u5904\u5b9e\u6d4b\u6ce8\u91ca\uff09\u2014\u2014\n\t\t\t// \u90a3\u79cd\u573a\u666f\u4e0b\u8fdc\u7aef\u7403\u88ab\u5f53\u4f5c\"\u672c\u673a\u7684\u7403\"\u8df3\u8fc7\u6821\u9a8c\uff0c\u53cd\u4f5c\u5f0a\u5bf9\u6070\u597d\u5f00\u4e86\u53cc\u5f00\u7684\u4f5c\u5f0a\u8005\u5b8c\u5168\u5931\u6548\u3002\n\t\t\t// bot \u7684 IsRemoteOwned \u6052 false\uff08SpawnBall \u91cc owner \u4e3a null\uff09\uff0c\u5929\u7136\u6392\u9664\u3002\n\t\t\tif ( !grace && b.IsRemoteOwned )\n\t\t\t{\n\t\t\t\t// \u5355\u6b65\u4f4d\u79fb\u4e0a\u9650 = \u7406\u8bba\u901f\u5ea6 \u00d7 3\uff08\u539f\u6ce8\u91ca\u5373\"\u8d85\u7406\u8bba\u4e0a\u9650 3 \u500d\u5373\u62c9\u56de\"\uff09\u3002\n\t\t\t\t// \u26a0\ufe0f \u53bb\u6389\u539f\u6765\u7684 `+ 16f` \u5e38\u6570\u9879\uff08v0.7.8.105\uff0c\u5ba1\u8ba1 #10\uff09\uff1a\u90a3\u4e2a\u5e38\u6570\u662f**\u6309\u5e27**\u52a0\u7684\uff0c\n\t\t\t\t// \u800c\u6b64\u5904 Time.Delta \u73b0\u5728\u662f\u56fa\u5b9a 1/50 \u6b65\u957f\u2014\u201460fps \u4e0b 16 \u5355\u4f4d\u672c\u8eab\u5df2\u22481.4 \u500d\u5408\u6cd5\u4f4d\u79fb\uff0c\n\t\t\t\t// \u5408\u8ba1\u628a\u5bb9\u5dee\u62ac\u5230\u7ea6 4.8 \u500d\uff0c\u7b49\u4e8e\u5141\u8bb8\u6301\u7eed\u8d85\u901f\u3002\u56fa\u5b9a\u6b65\u4e0b 3 \u500d\u4f59\u91cf\u5df2\u80fd\u5438\u6536\n\t\t\t\t// \u8fdc\u7aef\u53d1\u5305\u5408\u5e76\uff08\u22483 \u6b65\u5408\u5e76\u4f4d\u79fb\uff09\u7684\u6296\u52a8\u3002\n\t\t\t\tvar maxMove = b.Speed * Time.Delta * 3f;\n\t\t\t\tif ( b.WorldPosition.Distance( last ) > maxMove )\n\t\t\t\t\tb.WorldPosition = last;\n\t\t\t}\n\n\t\t\t_lastPos[key] = b.WorldPosition;\n\n\t\t\t// \u8f6c\u5411\u65b9\u5411\uff08\u5206\u8eab\u8ddf\u968f\u7528\uff09\uff1a\u53d6\u672c\u5e27\u4f4d\u79fb\u65b9\u5411\u5e73\u6ed1\uff1b\u9759\u6b62\u65f6\u4fdd\u6301\u539f\u65b9\u5411\u3002\n\t\t\t// \u5bbd\u9650\u671f\u5185\u4f4d\u79fb\u662f\u77ac\u79fb\u8df3\u53d8\uff0c\u4e0d\u4ee3\u8868\u771f\u5b9e\u822a\u5411\uff0c\u8df3\u8fc7\n\t\t\tif ( grace ) continue;\n\n\t\t\tvar delta = b.WorldPosition - last;\n\t\t\tif ( delta.Length > 0.1f )\n\t\t\t{\n\t\t\t\tvar n = new Vector3( delta.x, delta.y, 0f ).Normal;\n\t\t\t\tvar prev = _steerDir.TryGetValue( key, out var sd ) ? sd : new Vector2( n.x, n.y );\n\t\t\t\t_steerDir[key] = new Vector2( prev.x + ( n.x - prev.x ) * 0.4f, prev.y + ( n.y - prev.y ) * 0.4f );\n\t\t\t}\n\t\t}\n\t}\n\n\tint CountPlayers( bool isBot )\n\t{\n\t\tint n = 0;\n\t\tforeach ( var b in _balls )\n\t\t{\n\t\t\tif ( b.IsValid() && b.IsBot == isBot ) n++;\n\t\t}\n\t\treturn n;\n\t}\n\n\t/// <summary>\n\t/// \u626b\u63cf\u573a\u666f\u91cc\u7684 Ball \u5e76\u6536\u7f16\u8fdb\u5217\u8868\uff08\u7f51\u7edc\u5bf9\u8c61\u7684 OnStart \u4e0d\u53ef\u9760\uff0c\u5b9e\u6d4b\u4e0d\u89e6\u53d1\uff09\u3002\n\t/// \u672c\u673a\u7403\u5224\u5b9a\u7528\u6240\u6709\u6743\u5b57\u6bb5\uff1a\u975e\u4ee3\u7406 + \u975ebot + OwnerSteamId \u662f\u81ea\u5df1\u7684 SteamId\u3002\n\t/// </summary>\n\tvoid ScanBalls()\n\t{\n\t\t// \u5ba2\u6237\u7aef\uff1a\u5feb\u7167\u4e16\u754c\u6839\u81ea\u6108\u542f\u7528\uff08\u9996\u6b21 Tick \u53ef\u80fd\u65e9\u4e8e\u5feb\u7167\u5e94\u7528\u5b8c\u6210\uff0cEnsureWorld \u6251\u7a7a\u65f6\u515c\u4f4f\uff09\n\t\tif ( !NetworkManager.IsAuthority )\n\t\t{\n\t\t\tAdoptSnapshotWorld();\n\t\t}\n\n\t\tforeach ( var b in Scene.GetAllComponents<Ball>() )\n\t\t{\n\t\t\tif ( !b.IsValid() ) continue;\n\n\t\t\t// \u5b58\u91cf bot \u81ea\u6108\uff1av0.5.1.3 \u4e4b\u524d\u751f\u6210\u7684 bot OwnerSteamId=0\uff08\u5206\u8eab\u5934\u50cf/\u540d\u724c\u65e0\u6cd5\u5f52\u5c5e\uff09\u2014\u2014\n\t\t\t// host \u8865\u53d1\u552f\u4e00\u5047 id\uff08[Sync] \u81ea\u52a8\u4e0b\u53d1\u5ba2\u6237\u7aef\uff09\uff0c\u70ed\u91cd\u8f7d/\u8001\u4f1a\u8bdd\u91cc\u7684 bot \u4e5f\u88ab\u8986\u76d6\n\t\t\tif ( NetworkManager.IsAuthority && b.IsBot && b.OwnerSteamId < GameConfig.BotSteamIdBase )\n\t\t\t{\n\t\t\t\tb.OwnerSteamId = NextBotSteamId();\n\t\t\t\tGameLog.Info( $\"[game] legacy bot '{b.PlayerName}' assigned steam id {b.OwnerSteamId}\" );\n\t\t\t}\n\n\t\t\tif ( !_balls.Contains( b ) )\n\t\t\t{\n\t\t\t\t_balls.Add( b );\n\t\t\t}\n\n\t\t\t// \u672c\u673a\u7403 = \u81ea\u5df1\u62e5\u6709\u7684\u90a3\u9897\uff08IsProxy=false \u5373\u6240\u6709\u6743\uff0c\u5929\u7136\u53ef\u9760\uff09\u3002\n\t\t\t// \u4e0d\u8981\u7528 OwnerSteamId \u5339\u914d\uff1a\u540c\u673a\u53cc\u5f00\u65f6\u8fde\u63a5\u662f\u5408\u6210 ID\uff0c\u4e0e Game.SteamId \u5bf9\u4e0d\u4e0a\uff08\u5b9e\u6d4b\u8e29\u5751\uff09\u3002\n\t\t\t// \u26a0\ufe0f \u6bcf\u6b21\u626b\u63cf\u90fd\u8981\u65ad\u8a00\uff08\u4e0d\u80fd\u53ea\u5728\u65b0\u6536\u7f16\u65f6\uff09\uff1a\u5ba2\u6237\u7aef\u7684 Ball.OnStart \u8dd1\u5728\u5feb\u7167\u671f\uff0c\n\t\t\t// \u65e9\u4e8e\u672c\u7aef EnsureWorld \u5efa\u76f8\u673a\uff0c\u9996\u6b21 SetLocalBall \u65f6 _camera \u4e3a null \u88ab\u8df3\u8fc7\uff1b\n\t\t\t// SetTarget \u5185\u90e8\u6709\u540c\u7403\u5b88\u536b\uff0c\u91cd\u590d\u8c03\u7528\u96f6\u5f00\u9500\u3002\n\t\t\tif ( !b.IsProxy && !b.IsBot )\n\t\t\t{\n\t\t\t\tSetLocalBall( b );\n\t\t\t}\n\t\t}\n\n\t\tfor ( int i = _balls.Count - 1; i >= 0; i-- )\n\t\t{\n\t\t\tif ( !_balls[i].IsValid() ) _balls.RemoveAt( i );\n\t\t}\n\t}\n\n\t/// <summary> \u7403\u6ce8\u518c\uff08Ball.OnStart \u5728\u6240\u6709\u673a\u5668\u4e0a\u8c03\u7528\uff1bhost \u751f\u6210\u4e0e\u5ba2\u6237\u7aef\u590d\u5236\u7edf\u4e00\u5165\u53e3\uff09 </summary>\n\tpublic void RegisterBall( Ball ball )\n\t{\n\t\tif ( !_balls.Contains( ball ) ) _balls.Add( ball );\n\t}\n\n\t/// <summary> \u6807\u8bb0\u672c\u673a\u62e5\u6709\u7684\u7403\uff0c\u5e76\u8ba9\u8ddf\u968f\u76f8\u673a\u9501\u5b9a\u5b83\u3002\n\t/// host \u4e0a\u4e3a\u8fdc\u7a0b\u8fde\u63a5\u751f\u6210\u7684\u7403\uff08IsRemoteOwned\uff09\u7edd\u4e0d\u5145\u5f53\u672c\u673a\u7403\u2014\u2014\u540c\u8d26\u53f7\u53cc\u5f00\u65f6\n\t/// \u5b83\u4eec\u4e0e host \u7403\u540c\u540d\u540c SteamId\uff0c\u552f\u4e00\u53ef\u9760\u7684\u533a\u522b\u5c31\u662f\u751f\u6210\u610f\u56fe\uff08\u5b9e\u6d4b\u8e29\u5751\uff09\u3002 </summary>\n\tpublic void SetLocalBall( Ball ball )\n\t{\n\t\tif ( ball is null || ball.IsRemoteOwned ) return;\n\n\t\tLocalBall = ball;\n\t\t_camera?.SetTarget( ball );\n\t}\n\n\t/// <summary> \u573a\u5730\u5185\u968f\u673a\u51fa\u751f\u70b9\uff1a**agar \u5f0f\u5b89\u5168\u51fa\u751f**\u2014\u2014\u91c7\u6837\u591a\u70b9\uff0c\u9009\u79bb\"\u80fd\u5403\u6389\u51fa\u751f\u8d28\u91cf\"\u7684\n\t/// \u6700\u8fd1\u5a01\u80c1\u6700\u8fdc\u7684\u70b9\u3002\u968f\u673a\u88f8\u751f\u4f1a\u88ab\u8def\u8fc7\u7684\u4e2d bot \u79d2\u5403\uff08\u5b9e\u6d4b\uff1a\u52a0\u5165 1.6s \u88ab\u5403\uff09 </summary>\n\t/// <summary> \u573a\u5730\u5185\u51fa\u751f\u70b9\uff1a\u8d70\u6e38\u620f\u7cfb\u7edf\u7684\u5b89\u5168\u91c7\u6837\u3002\u9886\u571f\u6a21\u5f0f\uff08M7\uff09\u7ed9\u961f\u53f7\u65f6\u5728\u5927\u672c\u8425\u683c\u5185\u91c7\u6837\uff08\u51fa\u751f\u5373\u56de\u8425\uff09\u3002\n\t/// teamIndex \u5c0f\u4e8e 0 \u6216\u975e\u9886\u571f\u6a21\u5f0f = \u5168\u573a\u968f\u673a\uff08\u65e2\u6709\u884c\u4e3a\uff09 </summary>\n\t/// <summary> \u573a\u5730\u5185\u51fa\u751f\u70b9\uff1a\u8d70\u6e38\u620f\u7cfb\u7edf\u7684\u5b89\u5168\u91c7\u6837\u3002\u9886\u571f\u6a21\u5f0f\uff08M7\uff09\u7ed9\u961f\u53f7\u65f6\u5728\u5927\u672c\u8425\u683c\u5185\u91c7\u6837\uff1b\n\t/// frontLine=true\uff08M7.2 \u590d\u6d3b\u4e8c\u9009\u4e00\uff09\u6539\u6700\u8fd1\u5df1\u65b9\u5360\u9886\u683c\uff08\u6ca1\u6709\u524d\u7ebf\u683c\u56de\u843d\u5927\u672c\u8425\uff09\u3002\n\t/// teamIndex \u5c0f\u4e8e 0 \u6216\u975e\u9886\u571f\u6a21\u5f0f = \u5168\u573a\u968f\u673a\uff08\u65e2\u6709\u884c\u4e3a\uff09 </summary>\n\tpublic Vector3 RandomSpawnPos( int teamIndex = -1, bool frontLine = false, Vector3 nearPos = default )\n\t{\n\t\tvar half = GameConfig.ArenaHalfSize - 128f;\n\n\t\tfloat minX = -half, maxX = half, minY = -half, maxY = half;\n\t\tif ( MatchState.IsTerritory && teamIndex >= 0 )\n\t\t{\n\t\t\tif ( frontLine && TerritoryManager.TryFrontSpawn( teamIndex, nearPos, out var front ) )\n\t\t\t\treturn front;\n\t\t\tTerritoryManager.HqBounds( teamIndex, out minX, out maxX, out minY, out maxY );\n\t\t}\n\n\t\tVector3 best = new Vector3( Game.Random.Float( minX, maxX ), Game.Random.Float( minY, maxY ), 0f );\n\t\tvar bestScore = float.MinValue;\n\n\t\tfor ( int i = 0; i < GameConfig.SpawnSafeSamples; i++ )\n\t\t{\n\t\t\tvar p = new Vector3( Game.Random.Float( minX, maxX ), Game.Random.Float( minY, maxY ), 0f );\n\t\t\tvar score = float.MaxValue;\n\n\t\t\tforeach ( var b in _balls )\n\t\t\t{\n\t\t\t\tif ( !b.IsValid() || !b.Alive ) continue;\n\t\t\t\tif ( b.Mass < GameConfig.StartMass * GameConfig.EatRatio ) continue;   // \u5403\u4e0d\u52a8\u51fa\u751f\u7403\u7684\u5ffd\u7565\n\n\t\t\t\tvar d = p.Distance( b.WorldPosition ) - b.Radius;\n\t\t\t\tif ( d < score ) score = d;\n\t\t\t}\n\n\t\t\tif ( score > bestScore )\n\t\t\t{\n\t\t\t\tbestScore = score;\n\t\t\t\tbest = p;\n\t\t\t}\n\t\t}\n\t\treturn best;\n\t}\n\n\tstatic string LocalPlayerName()\n\t{\n\t\ttry { return Connection.Local?.DisplayName ?? \"You\"; }\n\t\tcatch { return \"You\"; }\n\t}\n\n\t// ---- \u5206\u8eab/\u5b62\u5b50\uff1ahost \u6743\u5a01\u6a21\u62df\uff08M3\uff09----\n\n\t/// <summary>\n\t/// \u5206\u8eab/\u5b62\u5b50\u6bcf\u5e27\u6a21\u62df\uff1a\u51b2\u91cf\u8870\u51cf \u2192 \u8ddf\u968f\u4e3b\u4eba\u8f6c\u5411 \u2192 \u573a\u5730\u94b3\u5236 \u2192 \u5408\u5e76\u68c0\u67e5 \u2192 \u6e05\u7406\u3002\n\t/// \u5b62\u5b50\u65e0\u8f6c\u5411\uff08\u6ed1\u884c\u5230\u505c\uff09\uff0c\u5206\u8eab\u6309\u4e3b\u4eba\u7403\u7684\u5e73\u6ed1\u8f6c\u5411\u65b9\u5411\u8ddf\u968f\u79fb\u52a8\u3002\n\t/// </summary>\n\tvoid TickCells()\n\t{\n\t\tif ( _cells.Count == 0 ) return;\n\n\t\tvar dt = Time.Delta;\n\n\t\tfor ( int i = _cells.Count - 1; i >= 0; i-- )\n\t\t{\n\t\t\tvar c = _cells[i];\n\n\t\t\t// \u4e3b\u4eba\u7403\u5f15\u7528\u5931\u6548\uff08\u70ed\u91cd\u8f7d\u6362\u7a0b\u5e8f\u96c6\u5bf9\u8c61 / \u4e3b\u4eba\u771f\u6ca1\u4e86\uff09\u2192 \u5148\u6309 SteamId \u91cd\u6302\u81ea\u6108\n\t\t\t// \uff08v0.7.8.29\uff1a\u70ed\u91cd\u8f7d\u4f1a\u628a\u7ec4\u4ef6\u6362\u6210\u65b0\u7a0b\u5e8f\u96c6\u5bf9\u8c61\uff0c\u65e7 OwnerBall \u5f15\u7528\u5168\u5931\u6548\uff0c\n\t\t\t// \u65e7\u903b\u8f91\u76f4\u63a5\u6d88\u6563 = \u6bcf\u6b21\u70ed\u91cd\u8f7d\u5206\u8eab\u51ed\u7a7a\u84b8\u53d1\uff09\uff1b\u91cd\u6302\u4e0d\u4e0a\u624d\u6d88\u6563\uff08\u5b62\u5b50\u4e2d\u6027\u5b58\u6d3b\uff0c\u4e0d\u6e05\u7406\uff09\n\t\t\tif ( c.PieceKind != CellPiece.Kind.EjectedMass\n\t\t\t\t&& ( !c.OwnerBall.IsValid() || !c.OwnerBall.Alive ) )\n\t\t\t{\n\t\t\t\tvar re = FindBallBySteamId( c.OwnerSteamId );\n\t\t\t\tif ( ( !re.IsValid() || !re.Alive ) && _sinceCellHeal > 0.5f )\n\t\t\t\t{\n\t\t\t\t\t_sinceCellHeal = 0;\n\t\t\t\t\tScanBalls();   // \u70ed\u91cd\u8f7d\u7a97\u53e3\uff1a\u4e3b\u4eba\u7403\u5176\u5b9e\u5728\u573a\u666f\u91cc\uff08\u65b0\u7a0b\u5e8f\u96c6\u5bf9\u8c61\uff09\uff0c\u7acb\u523b\u6536\u7f16\u518d\u8bd5\n\t\t\t\t\tre = FindBallBySteamId( c.OwnerSteamId );\n\t\t\t\t}\n\t\t\t\tif ( re.IsValid() && re.Alive )\n\t\t\t\t{\n\t\t\t\t\tc.OwnerBall = re;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\t_cells.RemoveAt( i );\n\t\t\t\t\t_cellsDirty = true;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// \u5c16\u523a\u5206\u8eab\u5230\u671f\uff08v0.7.4.0 \u7528\u6237\u5b9a\u7a3f B\uff09\uff1a\u53d8\u56de\u666e\u901a\u5206\u8eab\u2014\u2014\u8d28\u91cf\u4fdd\u7559\uff0c\n\t\t\t// \u6309\u8d28\u91cf\u53d6\u5408\u5e76\u51b7\u5374\uff08v0.7.8.27 \u66f2\u7ebf\uff09\u540e\u6309\u666e\u901a\u5206\u8eab\u89c4\u5219\u5408\u4f53\u56de\u6536\uff1b\u6b64\u540e\u4e0d\u518d\u662f\u6b66\u5668\u3002\n\t\t\t// MaxLife \u8986\u76d6\uff08M7.4 \u62a4\u536b\u56fa\u5b88\u5c16\u523a 8s\uff09\uff1a\u5230\u671f\u540c\u65f6\u89e3\u9664\u951a\u5b9a\n\t\t\tif ( c.PieceKind == CellPiece.Kind.SpikeMinion\n\t\t\t\t&& c.SinceSpawn > ( c.MaxLife > 0f ? c.MaxLife : GameConfig.SpikeMinionLife ) )\n\t\t\t{\n\t\t\t\tc.PieceKind = CellPiece.Kind.SplitPiece;\n\t\t\t\tc.Anchored = false;\n\t\t\t\tc.SinceSpawn = 0;\n\t\t\t\tc.MergeCooldown = GameConfig.MergeCooldownFor( c.Mass );\n\t\t\t\tNeonRenderer.Puff( c.Pos, c.ColorIndex );\n\t\t\t\t// \u4e3b\u4eba\u63d0\u793a\uff08v0.7.8.92\uff09\uff1a\u5c16\u523a\u5230\u671f\"\u53d8\u56de\u5206\u8eab\"\u662f\u5b9a\u7a3f\u8bbe\u8ba1\uff0c\u4f46\u4e0d\u63d0\u793a\u7684\u8bdd\u89c2\u611f\u5c31\u662f\n\t\t\t\t// \"\u6211\u7684\u5c16\u523a\u83ab\u540d\u5176\u5999\u53d8\u6210\u4e86\u5206\u8eab\u7403\"\u2014\u2014\u53ea\u7ed9\u4e3b\u4eba\u81ea\u5df1\u51fa\u6a2a\u5e45\n\t\t\t\tif ( GameSfx.IsMine( c.OwnerSteamId ) )\n\t\t\t\t\tCircleroyaleGame.Current?.Hud?.AddBanner( \"SPIKE \u2192 CLONE\",\n\t\t\t\t\t\tPowerUpManager.ColorOf( (byte)PowerUpManager.Kind.Spike ) );\n\t\t\t\t_cellsDirty = true;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// \u8d28\u91cf\u8870\u51cf\uff08v0.7.8.24 \u7403\u7403\u5927\u4f5c\u6218\u624b\u611f\uff09\uff1a\u5206\u8eab\u4e0e\u7403\u540c\u7387\u6301\u7eed\u6389\u91cd\uff08\u5b62\u5b50\u4e0d\u8870\u51cf\uff09\n\t\t\tif ( c.PieceKind != CellPiece.Kind.EjectedMass && c.Mass > GameConfig.MassDecayMinMass )\n\t\t\t{\n\t\t\t\tc.Mass = MathF.Max( GameConfig.MassDecayMinMass, c.Mass - c.Mass * GameConfig.MassDecayPerSecond * dt );\n\t\t\t}\n\n\t\t\tc.Impulse *= MathF.Exp( -( c.PieceKind == CellPiece.Kind.EjectedMass\n\t\t\t\t? GameConfig.EjectImpulseDecay : GameConfig.SplitImpulseDecay ) * dt );\n\n\t\t\tif ( c.PieceKind != CellPiece.Kind.EjectedMass )\n\t\t\t{\n\t\t\t\tvar dir = SteerDirOf( c.OwnerBall );\n\t\t\t\tvar target = new Vector3( dir.x, dir.y, 0f ) * c.Speed;\n\n\t\t\t\t// \u5012\u6d41\u5f15\u529b\uff08\u627918\u2463\uff0c\"\u79d2\u5408\"\u7684\u7269\u7406\u57fa\u7840\uff09\uff1a\u5206\u8eab\u6bd4\u4e3b\u7403\u91cd\uff08\u5410\u7403\u51cf\u91cd\u53ef\u5236\u9020\u4f53\u91cd\u5dee\uff09\n\t\t\t\t// \u6216\u51fa\u751f\u8d85\u8fc7 PieceHomeAfterSeconds \u2192 \u81ea\u52a8\u8f6c\u5411\u4e3b\u7403\u6eda\u56de\uff0c\u4e0e\u6447\u6746\u8f93\u5165\u6309\u6743\u91cd\u6df7\u5408\u3002\n\t\t\t\t// \u5c16\u523a\u5206\u8eab\u6392\u9664\u2014\u201420 \u79d2\u6b66\u5668\u671f\u7edd\u4e0d\u5012\u6d41\u5408\u4f53\uff08\u7528\u6237\u5b9a\u7a3f A/B\uff09\n\t\t\t\tvar owner = c.OwnerBall;\n\t\t\t\tif ( owner.IsValid() && owner.Alive && c.PieceKind == CellPiece.Kind.SplitPiece && c.IsMergeReady\n\t\t\t\t\t&& ( c.Mass > owner.Mass || c.SinceSpawn > GameConfig.PieceHomeAfterSeconds ) )\n\t\t\t\t{\n\t\t\t\t\tvar to = owner.WorldPosition - c.Pos;\n\t\t\t\t\tif ( to.Length > 1f )\n\t\t\t\t\t{\n\t\t\t\t\t\tto = to.Normal;\n\t\t\t\t\t\tvar blended = new Vector3(\n\t\t\t\t\t\t\tto.x * GameConfig.PieceAttractWeight + dir.x,\n\t\t\t\t\t\t\tto.y * GameConfig.PieceAttractWeight + dir.y, 0f );\n\t\t\t\t\t\tif ( blended.Length > 0.001f )\n\t\t\t\t\t\t\ttarget = blended.Normal * c.Speed;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// \u5206\u8eab\u8f6c\u5411\u54cd\u5e94\u540c\u6837\u968f\u8d28\u91cf\u8870\u51cf\uff08\u4e0e\u4e3b\u7403\u540c\u66f2\u7ebf\uff0c\u5927\u5206\u8eab\u4e00\u6837\u7b28\u62d9\uff09\n\t\t\t\tvar turn = MathF.Max( GameConfig.MinTurnSpeed,\n\t\t\t\t\tGameConfig.BaseTurnSpeed * MathF.Pow( GameConfig.StartMass / c.Mass, GameConfig.TurnCurve ) );\n\t\t\t\tvar t = 1f - MathF.Exp( -turn * dt );\n\t\t\t\tc.SteerVel = Vector3.Lerp( c.SteerVel, target, t );\n\t\t\t}\n\n\t\t\t// \u62a4\u536b\u56fa\u5b88\u5c16\u523a\uff08M7.4\uff09\uff1a\u951a\u5b9a\u5206\u8eab\u539f\u5730\u4e0d\u52a8\uff08\u4e0d\u8ddf\u968f\u4e3b\u4eba\u3001\u65e0\u8f6c\u5411\uff09\n\t\t\tif ( c.Anchored ) c.SteerVel = Vector3.Zero;\n\n\t\t\tvar pos = c.Pos + ( c.Impulse + c.SteerVel ) * dt;\n\t\t\tvar half = MathF.Max( 0f, GameConfig.ArenaHalfSize - c.Radius );\n\t\t\tpos.x = Math.Clamp( pos.x, -half, half );\n\t\t\tpos.y = Math.Clamp( pos.y, -half, half );\n\t\t\tpos.z = 0f;\n\t\t\tc.Pos = pos;\n\n\t\t\t// \u9886\u571f\u5582\u65d7\uff08M7\uff09\uff1a\u5b62\u5b50\u843d\u8fdb\u65d7\u5708\u5373\u8ba1\u5206\u5e76\u6d88\u8017\uff08\u9a7b\u519b\u6a21\u578b\uff0chost \u6743\u5a01\uff09\u3002\n\t\t\t// \u6ca1\u8fdb\u65d7\u5708\u6216\u89c4\u5219\u4e0d\u5141\u8bb8\uff08\u975e\u90bb\u63a5/\u5927\u672c\u8425\uff09\u65f6 Feed \u8fd4\u56de false\uff0c\u5b62\u5b50\u7ee7\u7eed\u5f53\u666e\u901a\u5b62\u5b50\u98de\uff0c\u8d28\u91cf\u4e0d\u767d\u8d39\n\t\t\tif ( c.PieceKind == CellPiece.Kind.EjectedMass && MatchState.IsTerritory )\n\t\t\t{\n\t\t\t\tif ( TerritoryManager.Feed( c.TeamIndex, c.Pos, c.Mass ) )\n\t\t\t\t{\n\t\t\t\t\t_cells.RemoveAt( i );\n\t\t\t\t\t_cellsDirty = true;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// \u540c\u4e3b\u4eba\u5408\u5e76\uff08\u51b7\u5374\u6309\u8d28\u91cf\u66f2\u7ebf v0.7.8.27\uff1a\u5c0f\u5206\u8eab 1s\u3001\u5927\u5206\u8eab\u6700\u957f 14s\uff09\uff1a**\u78b0\u5230\u5373\u5408\u5e76**\uff08\u5706\u7f18\u76f8\u89e6\uff0c\n\t\t// \u4e0d\u518d\u8981\u6c42\u5d4c\u8fdb\u5706\u5fc3\u2014\u2014\u7528\u6237\u5b9e\u6d4b\u53cd\u9988\uff1a\u9760\u5728\u4e00\u8d77\u4e0d\u52a8\u4e0d\u7b97\uff0c\u89c2\u611f\u5c31\u662f\"\u4e0d\u80fd\u5408\u5e76\"\uff09\u3002\u4f18\u5148\u5e76\u56de\u4e3b\u7403\uff0c\u5176\u6b21\u5927\u5206\u8eab\u541e\u5c0f\u5206\u8eab\u3002\n\t\t// \u26a0\ufe0f \u5185\u5c42 j \u53ea\u5411\u4e0a\u626b\uff08j > i\uff09\uff1aj \u6389\u5230 i \u4ee5\u4e0b\u518d RemoveAt(i) \u4f1a\u7d22\u5f15\u8d8a\u754c\n\t\t//\uff08\u4e0b\u65b9\u5143\u7d20\u88ab\u5220\u540e i \u5df2\u6f02\u79fb\uff0c\u5b9e\u6d4b ArgumentOutOfRangeException \u5237\u5c4f\uff09\n\t\tfor ( int i = _cells.Count - 1; i >= 0; i-- )\n\t\t{\n\t\t\tvar a = _cells[i];\n\t\t\tif ( a.PieceKind != CellPiece.Kind.SplitPiece || !a.IsMergeReady ) continue;\n\n\t\t\tvar owner = a.OwnerBall;\n\t\t\tif ( owner.IsValid() && owner.Alive && owner.WorldPosition.Distance( a.Pos ) < owner.Radius + a.Radius )\n\t\t\t{\n\t\t\t\towner.Mass = MathF.Min( GameConfig.MaxMass, owner.Mass + a.Mass );\n\t\t\t\t_cells.RemoveAt( i );\n\t\t\t\t_cellsDirty = true;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tfor ( int j = i - 1; j >= 0; j-- )\n\t\t\t{\n\t\t\t\tvar b = _cells[j];\n\t\t\t\tif ( b.PieceKind != CellPiece.Kind.SplitPiece ) continue;\n\t\t\t\tif ( b.OwnerSteamId != a.OwnerSteamId || !b.IsMergeReady ) continue;\n\n\t\t\t\tvar big = a.Mass >= b.Mass ? a : b;\n\t\t\t\tvar small = big == a ? b : a;\n\t\t\t\tif ( big.Pos.Distance( small.Pos ) >= big.Radius + small.Radius ) continue;\n\n\t\t\t\tbig.Mass = MathF.Min( GameConfig.MaxMass, big.Mass + small.Mass );\n\t\t\t\t_cells.RemoveAt( j );   // \u53ea\u5220 j < i \u7684\uff0ca \u7684\u7d22\u5f15 i \u6052\u5b9a\u5b89\u5168\n\t\t\t\t_cellsDirty = true;\n\t\t\t\tbreak;   // \u6bcf\u5e27\u6bcf\u5206\u8eab\u5e76\u4e00\u4e2a\uff0c\u4e0b\u4e00\u5e27\u7ee7\u7eed\n\t\t\t}\n\t\t}\n\n\t\t// \u654c\u65b9\u5206\u8eab\u76f8\u4e92\u63a8\u6324\uff08v0.7.8.24 \u7403\u7403\u5927\u4f5c\u6218\u624b\u611f\uff09\uff1a\u5403\u4e0d\u52a8=\u5b9e\u5fc3\uff0c\u6309\u8d28\u91cf\u6743\u91cd\u4e92\u76f8\u8ba9\u4f4d\u3002\n\t\t// \u540c\u4e3b\u4eba\u8d70\u4e0a\u65b9\u5408\u5e76\uff1b\u56e2\u961f\u8d5b\u961f\u53cb\u53ef\u91cd\u53e0\uff1b\u5c16\u523a\u5206\u8eab\u662f\u6b66\u5668\u4e0d\u53c2\u4e0e\uff08\u78b0\u5230\u8d70\u6b66\u5668\u5224\u5b9a\uff09\n\t\tvar pushTeamOf = BuildTeamMap();\n\t\tfor ( int i = 0; i < _cells.Count; i++ )\n\t\t{\n\t\t\tvar a = _cells[i];\n\t\t\tif ( a.PieceKind != CellPiece.Kind.SplitPiece ) continue;\n\t\t\tfor ( int j = i + 1; j < _cells.Count; j++ )\n\t\t\t{\n\t\t\t\tvar b = _cells[j];\n\t\t\t\tif ( b.PieceKind != CellPiece.Kind.SplitPiece ) continue;\n\t\t\t\tif ( a.OwnerSteamId == b.OwnerSteamId ) continue;\n\t\t\t\tif ( MatchState.IsTeam\n\t\t\t\t\t&& pushTeamOf.TryGetValue( a.OwnerSteamId, out var at )\n\t\t\t\t\t&& pushTeamOf.TryGetValue( b.OwnerSteamId, out var bt )\n\t\t\t\t\t&& at == bt ) continue;\n\t\t\t\t// \u80fd\u5403\u5c31\u5403\uff08TickEating \u5904\u7406\uff09\uff0c\u4e0d\u63a8\u2014\u2014\u5426\u5219\u63a8\u6324\u4f1a\u628a\u53ef\u541e\u7684\u5bf9\u624b\u6321\u5f00\uff0c\u5927\u5206\u8eab\u6c38\u8fdc\u5403\u4e0d\u6389\u5c0f\u5206\u8eab\n\t\t\t\tvar hi = MathF.Max( a.Mass, b.Mass );\n\t\t\t\tvar lo = MathF.Min( a.Mass, b.Mass );\n\t\t\t\tif ( hi >= lo * GameConfig.EatRatio ) continue;\n\n\t\t\t\tvar dx = b.Pos.x - a.Pos.x;\n\t\t\t\tvar dy = b.Pos.y - a.Pos.y;\n\t\t\t\tvar rr = a.Radius + b.Radius;\n\t\t\t\tvar d2 = dx * dx + dy * dy;\n\t\t\t\tif ( d2 >= rr * rr ) continue;\n\t\t\t\tvar d = MathF.Sqrt( MathF.Max( d2, 0.0001f ) );\n\t\t\t\tvar push = MathF.Min( rr - d, GameConfig.PushMaxStep );\n\t\t\t\tvar nx = d > 0.001f ? dx / d : 1f;\n\t\t\t\tvar ny = d > 0.001f ? dy / d : 0f;\n\t\t\t\tvar wa = b.Mass / ( a.Mass + b.Mass );   // \u8d8a\u91cd\u8ba9\u5f97\u8d8a\u5c11\n\t\t\t\ta.Pos = new Vector3( a.Pos.x - nx * push * wa, a.Pos.y - ny * push * wa, 0f );\n\t\t\t\tb.Pos = new Vector3( b.Pos.x + nx * push * ( 1f - wa ), b.Pos.y + ny * push * ( 1f - wa ), 0f );\n\t\t\t\t_cellsDirty = true;\n\t\t\t}\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// \u6267\u884c\u5206\u88c2\uff08host \u6743\u5a01\uff1bowner \u672c\u5730\u76f4\u8c03 / \u5ba2\u6237\u7aef\u7ecf RequestSplit RPC\uff09\u3002\n\t/// **agar \u6807\u51c6\u5168\u5458\u5206\u88c2\uff08v0.7.1.0 \u7528\u6237\u9700\u6c42\uff09**\uff1a\u7a7a\u683c = \u4e3b\u7403 + \u6bcf\u4e2a\u591f\u91cd\u7684\u5206\u8eab**\u540c\u65f6\u5bf9\u534a\u5206\u88c2**\uff0c\n\t/// \u65b0\u8eab\u4f53**\u5404\u81ea\u671d\u5149\u6807\u5f39\u51fa**\uff08v0.7.8.7 \u624b\u611f\uff1aaimPoint \u4e3a\u5149\u6807\u4e16\u754c\u5750\u6807\uff0c\u4e3b\u7403\u4e0e\u6bcf\u4e2a\u5206\u8eab\n\t/// \u72ec\u7acb\u7b97\u6307\u5411\uff0cagar \u6807\u51c6\u6247\u5f62\u94fa\u5f00\uff09\u2014\u2014\u8fde\u6309\u53ef\u5feb\u901f\u94fa\u5f00\u5230\u5206\u8eab\u4e0a\u9650\uff081\u21922\u21924\u21928\u219216 \u53ea\u8981 4 \u6309\uff09\u3002\n\t/// \u6bcf\u9897\u8eab\u4f53\u72ec\u7acb\u8fc7\u8d28\u91cf\u95e8\u69db\uff08\u5bf9\u534a\u540e\u4e0d\u51fb\u7a7f\u8d28\u91cf\u4e0b\u9650 1\uff09\uff0c\u69fd\u6ee1\u5373\u505c\u3002\n\t/// </summary>\n\tpublic void DoSplit( Ball ball, Vector2 aimPoint )\n\t{\n\t\tif ( !GameConfig.EnableSplit || !NetworkManager.IsAuthority ) return;\n\t\tif ( ball is null || !ball.IsValid() || !ball.Alive ) return;\n\t\tif ( ball.Mass < GameConfig.SplitMinMass ) return;\n\t\tif ( ClassSkillManager.IsTankSurging( ball ) ) return;   // \u5766\u514b\u7206\u53d1\u671f\u7981\u5206\u88c2\uff08\u4e34\u65f6\u8d28\u91cf\u5206\u88c2=\u5957\u73b0\uff0cM7.4\uff09\n\n\t\tvar mainDir = FallbackDir( ball, DirTo( ball.WorldPosition, aimPoint ) );\n\n\t\t// \u26a0\ufe0f \u5148\u6355\u83b7\u65e7\u5206\u8eab\u6570\u91cf\u518d\u751f\u6210\u4e3b\u7403\u65b0\u5206\u8eab\uff1aSpawnPiece \u662f\u5c3e\u90e8\u8ffd\u52a0\uff0c\u82e5\u5faa\u73af\u4ece\n\t\t// _cells.Count-1 \u8d77\u6b65\u4f1a\u7b2c\u4e00\u8f6e\u5c31\u904d\u5386\u5230\u521a\u5f39\u51fa\u7684\u65b0\u5206\u8eab\u628a\u5b83\u518d\u5288\u4e00\u6b21\u2014\u2014\n\t\t// \u4e00\u6b21\u7a7a\u683c\u51fa 3 \u9897\uff08\u672c\u4f53+\u65b0\u5206\u8eab+\u65b0\u5206\u8eab\u7684\u5b6a\u751f\uff09\uff0c\u5b9e\u6d4b v0.7.8.26 \u4fee\n\t\tint oldCount = _cells.Count;\n\n\t\t// \u4e3b\u7403\u5bf9\u534a\n\t\tif ( CountPiecesOf( ball.OwnerSteamId ) < GameConfig.MaxSplitPieces )\n\t\t{\n\t\t\tvar pieceMass = ball.Mass * 0.5f;\n\t\t\tball.Mass -= pieceMass;\n\t\t\tSpawnPiece( ball, mainDir, pieceMass );\n\t\t\tNeonRenderer.Puff( ball.WorldPosition, ball.ColorIndex );\n\t\t}\n\n\t\t// \u6bcf\u4e2a\u591f\u91cd\u7684**\u65e7**\u5206\u8eab\u4e5f\u5bf9\u534a\uff08**\u5404\u81ea\u671d\u5149\u6807\u5f39\u51fa**\uff1b\u5012\u5e8f\u53ea\u904d\u5386\u5230 oldCount\uff0c\n\t\t// \u672c\u8f6e\u65b0\u751f\u6210\u7684\u4e3b\u7403\u5206\u8eab/\u5b6a\u751f\u4e00\u5f8b\u4e0d\u53c2\u4e0e\u518d\u5206\u88c2\uff09\u3002\n\t\t// \u65b0\u5206\u8eab\u540c\u6837\u8d34\u7740\u6bcd\u5206\u8eab**\u5916\u7f18**\u751f\u6210\uff08v0.7.2.0\uff09\uff0c\u5426\u5219 1s \u51b7\u5374\u4e00\u5230\u5c31\u88ab\u5438\u56de\u53bb\n\t\tfor ( int i = oldCount - 1; i >= 0; i-- )\n\t\t{\n\t\t\tvar c = _cells[i];\n\t\t\tif ( c.PieceKind != CellPiece.Kind.SplitPiece || c.OwnerSteamId != ball.OwnerSteamId ) continue;\n\t\t\tif ( c.Mass < GameConfig.SplitMinMass * 2f ) continue;\n\t\t\tif ( CountPiecesOf( ball.OwnerSteamId ) >= GameConfig.MaxSplitPieces ) break;\n\n\t\t\tvar half = c.Mass * 0.5f;\n\t\t\tc.Mass -= half;\n\t\t\tvar twinRadius = GameConfig.StartRadius * MathF.Sqrt( half / GameConfig.StartMass );\n\t\t\tvar dir = DirOr( c.Pos, aimPoint, mainDir );\n\n\t\t\tvar twin = new CellPiece\n\t\t\t{\n\t\t\t\tId = _nextCellId++,\n\t\t\t\tPieceKind = CellPiece.Kind.SplitPiece,\n\t\t\t\tOwnerSteamId = c.OwnerSteamId,\n\t\t\t\tOwnerBall = c.OwnerBall,\n\t\t\t\tColorIndex = c.ColorIndex,\n\t\t\t\tMass = half,\n\t\t\t\tPos = c.Pos + new Vector3( dir.x, dir.y, 0f ) * ( c.Radius + twinRadius ),\n\t\t\t\tDrawPos = c.Pos,\n\t\t\t\tImpulse = new Vector3( dir.x, dir.y, 0f ) * GameConfig.SplitImpulseSpeed,\n\t\t\t\tSinceSpawn = 0,\n\t\t\t\tMergeCooldown = GameConfig.MergeCooldownFor( half ),   // v0.7.8.27 \u66f2\u7ebf\n\t\t\t};\n\t\t\tClampToArena( twin );\n\t\t\t_cells.Add( twin );\n\t\t\t_cellsDirty = true;\n\t\t}\n\n\t\tGameLog.Info( $\"[game] split-all '{ball.PlayerName}'\" );\n\t}\n\n\t/// <summary> \u751f\u6210\u4e00\u4e2a\u5206\u88c2\u5206\u8eab\uff08\u8c03\u7528\u65b9\u5df2\u6263\u4e3b\u7403\u8d28\u91cf\uff09\u3002\n\t/// **\u8d34\u7740\u4e3b\u7403\u5916\u7f18\u751f\u6210\uff08v0.7.2.0\uff09**\uff1a\u539f 0.4\u00d7\u534a\u5f84\u5728\u7403\u4f53\u5185\u90e8\uff0c\u5927\u7403\u5206\u8eab\u98de\u4e0d\u51fa\u5408\u5e76\u5438\u9644\u8303\u56f4\uff0c\n\t/// 1s \u51b7\u5374\u4e00\u5230\u5c31\u88ab\u5438\u56de\u4e3b\u7403\u2014\u2014\u73a9\u5bb6\u5b9e\u9645\u6c38\u8fdc\u53ea\u6709\u4e00\u9897\u8eab\u4f53\uff0c\u4e3b\u7403\u4e00\u6b7b\u65e0\u8eab\u53ef\u664b\u5347\u76f4\u63a5\u771f\u6b7b </summary>\n\tCellPiece SpawnPiece( Ball ball, Vector2 dir, float mass )\n\t{\n\t\tvar pieceRadius = GameConfig.StartRadius * MathF.Sqrt( mass / GameConfig.StartMass );\n\t\tvar c = new CellPiece\n\t\t{\n\t\t\tId = _nextCellId++,\n\t\t\tPieceKind = CellPiece.Kind.SplitPiece,\n\t\t\tOwnerSteamId = ball.OwnerSteamId,\n\t\t\tOwnerBall = ball,\n\t\t\tColorIndex = ball.ColorIndex,\n\t\t\tMass = mass,\n\t\t\tPos = ball.WorldPosition + new Vector3( dir.x, dir.y, 0f ) * ( ball.Radius + pieceRadius ),\n\t\t\tDrawPos = ball.WorldPosition,\n\t\t\tImpulse = new Vector3( dir.x, dir.y, 0f ) * GameConfig.SplitImpulseSpeed,\n\t\t\tSinceSpawn = 0,\n\t\t\tMergeCooldown = GameConfig.MergeCooldownFor( mass ),   // v0.7.8.27 \u66f2\u7ebf\uff1a\u5206\u8eab\u8d8a\u5927\u5408\u56de\u8d8a\u4e45\n\t\t};\n\t\tClampToArena( c );\n\t\t_cells.Add( c );\n\t\t_cellsDirty = true;\n\t\treturn c;\n\t}\n\n\t/// <summary>\n\t/// \u649e\u523a\u70b8\u88c2\uff1a\u8fde\u73af\u5f3a\u5236\u5bf9\u534a\u5206\u88c2\uff0c\u76f4\u5230\u5206\u8eab\u69fd\u6ee1\u6216\u8d28\u91cf\u6389\u5230\u5206\u88c2\u95e8\u69db\u4ee5\u4e0b\u3002\n\t/// \u8fd4\u56de\u662f\u5426\u771f\u70b8\u4e86\u4e1c\u897f\u2014\u2014\u5927\u7403\u505c\u5728\u523a\u4e0a\u4f1a\u6bcf\u5e27\u89e6\u53d1\uff0c\u9760\u5b83\u6321\u4f4f\u7279\u6548/\u65e5\u5fd7\u5237\u5c4f\u3002\n\t/// </summary>\n\tbool DoBurst( Ball ball )\n\t{\n\t\tvar slots = GameConfig.MaxSplitPieces - CountPiecesOf( ball.OwnerSteamId );\n\t\tif ( slots <= 0 || ball.Mass < GameConfig.SplitMinMass * 2f ) return false;\n\n\t\tint n = 0;\n\t\twhile ( n < slots && ball.Mass >= GameConfig.SplitMinMass * 2f )\n\t\t{\n\t\t\tvar pieceMass = ball.Mass * 0.5f;\n\t\t\tball.Mass -= pieceMass;\n\t\t\tvar p = SpawnPiece( ball, RandomDir(), pieceMass );\n\t\t\tp.MergeCooldown = GameConfig.SpikePieceMergeCooldown;   // \u649e\u523a\u788e\u7247\uff1a\u77ed\u51b7\u5374\uff0c\u4e3b\u4eba\u53ef\u4ee5\u5f88\u5feb\u5403\u56de\u6765\n\t\t\tn++;\n\t\t}\n\n\t\tSpawnPopFx( ball );\n\t\tGameLog.Info( $\"[game] burst on spike: '{ball.PlayerName}' into {n} pieces\" );\n\t\treturn true;\n\t}\n\n\t/// <summary> \u5927\u5206\u8eab\u649e\u523a\uff1a\u5bf9\u534a\u88c2\u6210\u4e24\u4e2a\uff08\u5404\u81ea\u91cd\u7f6e\u5408\u5e76\u51b7\u5374\uff09\uff1b\u69fd\u6ee1\u5219\u88ab\u523a\u5438\u6536 </summary>\n\tvoid BurstPiece( CellPiece c )\n\t{\n\t\tvar slots = GameConfig.MaxSplitPieces - CountPiecesOf( c.OwnerSteamId );\n\t\tif ( slots <= 0 )\n\t\t{\n\t\t\t_cells.Remove( c );\n\t\t\t_cellsDirty = true;\n\t\t\treturn;\n\t\t}\n\n\t\tvar dir = RandomDir();\n\t\tvar half = c.Mass * 0.5f;\n\n\t\tc.Mass = half;\n\t\tc.SinceSpawn = 0;\n\t\tc.MergeCooldown = GameConfig.SpikePieceMergeCooldown;\n\t\tc.Impulse = new Vector3( dir.x, dir.y, 0f ) * GameConfig.SplitImpulseSpeed;\n\n\t\tvar d2 = -dir;\n\t\tvar other = new CellPiece\n\t\t{\n\t\t\tId = _nextCellId++,\n\t\t\tPieceKind = CellPiece.Kind.SplitPiece,\n\t\t\tOwnerSteamId = c.OwnerSteamId,\n\t\t\tOwnerBall = c.OwnerBall,\n\t\t\tColorIndex = c.ColorIndex,\n\t\t\tMass = half,\n\t\t\tPos = c.Pos + new Vector3( d2.x, d2.y, 0f ) * ( c.Radius * 0.5f ),\n\t\t\tDrawPos = c.Pos,\n\t\t\tImpulse = new Vector3( d2.x, d2.y, 0f ) * GameConfig.SplitImpulseSpeed,\n\t\t\tSinceSpawn = 0,\n\t\t\tMergeCooldown = GameConfig.SpikePieceMergeCooldown,\n\t\t};\n\t\tClampToArena( other );\n\t\t_cells.Add( other );\n\t\t_cellsDirty = true;\n\n\t\tNeonRenderer.Pop( c.Pos, c.ColorIndex, c.Radius * 2f );\n\t\tif ( GameSfx.IsMine( c.OwnerSteamId ) )\n\t\t\tGameSfx.Pop( c.Pos );\n\t\tNetworkManager.PoppedEffect( c.Pos, (byte)c.ColorIndex, c.Radius * 2f, c.OwnerSteamId );\n\t}\n\n\t/// <summary>\n\t/// \u5c16\u523a\u5224\u5b9a\uff08host\uff0cv0.5.1.4 \u7528\u6237\u5b9a\u7a3f agar \u75c5\u6bd2\u89c4\u5219\uff09\uff1a\n\t/// - \u7ec6\u80de**\u6bd4\u523a\u5c0f**\uff1a\u78b0\u5230\u5373\u88ab\u5c16\u523a\u5403\u6389\uff08\u73a9\u5bb6\u6b7b\u4ea1 / \u5206\u8eab\u6d88\u5931\uff09\uff1b\n\t/// - \u7ec6\u80de**\u6bd4\u523a\u5927**\uff1a\u628a\u5c16\u523a\u5403\u6389\uff08+SpikeMass\uff09\u5e76\u5f3a\u5236\u89e6\u53d1\u5206\u88c2\uff08DoBurst \u8fde\u73af\u70b8\uff09\uff0c\u5c16\u523a\u5728\u522b\u5904\u91cd\u751f\uff08\u573a\u4e0a\u6052 12 \u9897\uff09\uff1b\n\t/// - \u5b62\u5b50\u78b0\u523a\u82af\u88ab\u5438\u6536\uff08\u5582\u523a\uff09\u3002\u51fa\u751f\u4fdd\u62a4\u671f\u5185\u514d\u75ab\u5c16\u523a\u3002\n\t/// </summary>\n\tvoid TickSpikes()\n\t{\n\t\tvar spikes = _spikes;\n\t\tif ( spikes is null ) return;\n\n\t\tvar touch = GameConfig.SpikeRadius * GameConfig.SpikeTouchFactor;\n\n\t\t// \u7403 \u00d7 \u523a\n\t\tforeach ( var b in _balls )\n\t\t{\n\t\t\tif ( !b.IsValid() || !b.Alive || b.IsProtected ) continue;   // \u62a4\u76fe\u671f\u514d\u75ab\u5c16\u523a\n\n\t\t\tvar bp = b.WorldPosition;\n\t\t\tfor ( int i = 0; i < spikes.Length; i++ )\n\t\t\t{\n\t\t\t\tvar dx = spikes[i].x - bp.x;\n\t\t\t\tvar dy = spikes[i].y - bp.y;\n\t\t\t\tvar rr = b.Radius + touch;\n\t\t\t\tif ( dx * dx + dy * dy > rr * rr ) continue;\n\n\t\t\t\tif ( b.Radius > GameConfig.SpikeRadius )\n\t\t\t\t{\n\t\t\t\t\t// \u5403\u523a\uff1a+100 \u8d28\u91cf + \u5f3a\u5236\u5206\u88c2\uff0c\u523a\u91cd\u751f\u5230\u65b0\u4f4d\u7f6e\n\t\t\t\t\tb.Mass = MathF.Min( GameConfig.MaxMass, b.Mass + GameConfig.SpikeMass );\n\t\t\t\t\tDoBurst( b );\n\t\t\t\t\tRespawnSpike( i );\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\t// \u88ab\u523a\u5403\uff08v0.6.2.0 agar \u89c4\u5219\uff09\uff1a\u53ea\u635f\u5931\u8fd9\u4e2a\u8eab\u4f53\u2014\u2014\u6700\u5927\u5206\u8eab\u5347\u7ea7\u6210\u65b0\u4e3b\u7403\n\t\t\t\t\tSpawnPopFx( b );\n\n\t\t\t\t\tif ( TryPromoteFromPieces( b ) )\n\t\t\t\t\t{\n\t\t\t\t\t\tGameLog.Info( $\"[game] body lost to spike: {b.PlayerName} (promoted)\" );\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tb.MarkDead();\n\t\t\t\t\t\tif ( !b.IsBot ) PopPiecesOf( b.OwnerSteamId );\n\t\t\t\t\t\tGameLog.Info( \"[game] eaten by spike: \" + b.PlayerName );\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\t// \u5206\u8eab \u00d7 \u523a\uff08\u540c\u89c4\u5219\uff1b\u5012\u5e8f\u904d\u5386\u2014\u2014\u88ab\u5403\u8981\u79fb\u9664\uff09\n\t\tfor ( int i = _cells.Count - 1; i >= 0; i-- )\n\t\t{\n\t\t\tvar c = _cells[i];\n\t\t\tif ( c.PieceKind != CellPiece.Kind.SplitPiece ) continue;\n\n\t\t\tfor ( int sIdx = 0; sIdx < spikes.Length; sIdx++ )\n\t\t\t{\n\t\t\t\tvar dx = spikes[sIdx].x - c.Pos.x;\n\t\t\t\tvar dy = spikes[sIdx].y - c.Pos.y;\n\t\t\t\tvar rr = c.Radius + touch;\n\t\t\t\tif ( dx * dx + dy * dy > rr * rr ) continue;\n\n\t\t\t\tif ( c.Radius > GameConfig.SpikeRadius )\n\t\t\t\t{\n\t\t\t\t\tc.Mass = MathF.Min( GameConfig.MaxMass, c.Mass + GameConfig.SpikeMass );\n\t\t\t\t\tBurstPiece( c );   // \u5f3a\u5236\u5bf9\u534a\u88c2\uff08\u788e\u7247\u77ed\u51b7\u5374\uff0c\u4e3b\u4eba\u80fd\u5feb\u901f\u5403\u56de\uff09\n\t\t\t\t\tRespawnSpike( sIdx );\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tNeonRenderer.Puff( c.Pos, c.ColorIndex );\n\t\t\t\t\t_cells.RemoveAt( i );\n\t\t\t\t\t_cellsDirty = true;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\t// \u5b62\u5b50\u5582\u523a\uff08v0.7.8.24 \u7403\u7403\u5927\u4f5c\u6218\u5f0f\uff09\uff1a\u5438\u6536\u5145\u80fd \u2192 \u5582\u6ee1\u7206\u5f00\u5c04\u523a\u3002\u523a\u4e0d\u6362\u4f4d\u4e0d\u91cd\u751f\uff0c\n\t\t// \u5582\u98df\u91cf\u5f52\u96f6\u91cd\u65b0\u6512\u2014\u2014\"\u5728\u654c\u4eba\u8eab\u8fb9\u517b\u523a\"\u7684\u6218\u672f\u6838\u5fc3\n\t\tfor ( int i = _cells.Count - 1; i >= 0; i-- )\n\t\t{\n\t\t\tvar c = _cells[i];\n\t\t\tif ( c.PieceKind != CellPiece.Kind.EjectedMass ) continue;\n\n\t\t\tfor ( int sIdx = 0; sIdx < spikes.Length; sIdx++ )\n\t\t\t{\n\t\t\t\tvar dx = spikes[sIdx].x - c.Pos.x;\n\t\t\t\tvar dy = spikes[sIdx].y - c.Pos.y;\n\t\t\t\tif ( dx * dx + dy * dy > touch * touch ) continue;\n\n\t\t\t\tif ( _spikeFed is not null && (uint)sIdx < (uint)_spikeFed.Length )\n\t\t\t\t{\n\t\t\t\t\t_spikeFed[sIdx] += c.Mass;\n\t\t\t\t\tNetworkManager.SpikeFed( sIdx, _spikeFed[sIdx] );   // \u5ba2\u6237\u7aef\u955c\u50cf\uff08\u523a\u957f\u5927\u663e\u793a\u7528\uff09\n\t\t\t\t\tif ( _spikeFed[sIdx] >= GameConfig.SpikeGrowTarget )\n\t\t\t\t\t\tBurstSpike( sIdx );\n\t\t\t\t}\n\n\t\t\t\t_cells.RemoveAt( i );\n\t\t\t\t_cellsDirty = true;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\tTickSpines();\n\t\tTickSpikeMinions( spikes );\n\t}\n\n\t/// <summary> \u523a\u7206\uff08host \u6743\u5a01\uff0cv0.7.8.24\uff09\uff1a\u5582\u98df\u91cf\u6e05\u96f6 + \u5747\u5300\u89d2\u5ea6\u5c04\u51fa\u5f39\u5e55\uff08\u547d\u4e2d\u5728 TickSpines\uff09\u3002\n\t/// \u89d2\u5ea6\u516c\u5f0f SpineDir \u4e0e\u5ba2\u6237\u7aef OnSpikeBurstRemote \u5171\u7528\uff08\u786e\u5b9a\u6027\uff0c\u89c6\u89c9\u5f39\u9053=\u6743\u5a01\u5f39\u9053\uff09 </summary>\n\tvoid BurstSpike( int index )\n\t{\n\t\tif ( _spikes is null || _spikeFed is null || (uint)index >= (uint)_spikes.Length ) return;\n\t\t_spikeFed[index] = 0f;\n\n\t\tvar pos = _spikes[index];\n\t\tvar n = GameConfig.SpikeBurstCount;\n\t\tfor ( int k = 0; k < n; k++ )\n\t\t{\n\t\t\t_spines.Add( new SpikeSpine\n\t\t\t{\n\t\t\t\tPos = new Vector2( pos.x, pos.y ),\n\t\t\t\tVel = SpineDir( k, n, index ) * GameConfig.SpineSpeed,\n\t\t\t\tSinceSpawn = 0,   // TimeSince \u9ed8\u8ba4\u503c\u22600\uff08\u8bb0\u5f55\u65f6\u523b 0 \u2192 \u8bfb\u51fa\u5de8\u5927\u503c\u7acb\u5373\u8fc7\u671f\uff09\uff0c\u5fc5\u987b\u663e\u5f0f\u5f52\u96f6\n\t\t\t} );\n\t\t}\n\t\tNetworkManager.SpikeBurst( index );   // \u5168\u7aef\u7206\u6548\uff1b\u5ba2\u6237\u7aef\u5728\u540c\u6b3e\u89d2\u5ea6\u751f\u6210\u89c6\u89c9\u5f39\u5e55\n\t}\n\n\t/// <summary> \u5f39\u5e55\u65b9\u5411\uff08\u786e\u5b9a\u6027\u516c\u5f0f\uff0chost \u6743\u5a01\u4e0e\u5ba2\u6237\u7aef\u89c6\u89c9\u5171\u7528\uff09\uff1a\u5747\u5300\u5706\u5468 + \u6309\u523a\u5e8f\u53f7\u9519\u5f00 </summary>\n\tstatic Vector2 SpineDir( int k, int n, int index )\n\t{\n\t\tvar ang = k / (float)n * MathF.PI * 2f + index * 0.7f;\n\t\treturn new Vector2( MathF.Cos( ang ), MathF.Sin( ang ) );\n\t}\n\n\t/// <summary> \u523a\u7206\u5f39\u5e55\u63a8\u8fdb\uff08host \u6743\u5a01\uff09\uff1a\u547d\u4e2d\u7403/\u5206\u8eab\u6263\u8d28\u91cf\uff08\u62a4\u76fe\u4e0d\u9632\u5c16\u523a\u65cf\uff1b\u51fa\u751f\u4fdd\u62a4\u514d\u75ab\uff09\uff0c\n\t/// \u5230\u671f\u6d88\u6563\u3002\u4e2d\u7acb\u4f24\u5bb3\u2014\u2014\u547d\u4e2d\u8c01\u90fd\u6263\uff08\u5305\u62ec\u5582\u523a\u8005\u81ea\u5df1\uff09\uff0c\u5f39\u5e55\u672c\u4f53\u6d88\u5931 </summary>\n\tvoid TickSpines()\n\t{\n\t\tvar dt = Time.Delta;\n\t\tfor ( int i = _spines.Count - 1; i >= 0; i-- )\n\t\t{\n\t\t\tvar s = _spines[i];\n\t\t\tif ( s.SinceSpawn > GameConfig.SpineLife ) { _spines.RemoveAt( i ); continue; }\n\t\t\ts.Pos += s.Vel * dt;\n\n\t\t\tbool consumed = false;\n\n\t\t\tforeach ( var b in _balls )\n\t\t\t{\n\t\t\t\tif ( !b.IsValid() || !b.Alive || b.IsProtected ) continue;\n\t\t\t\tvar dx = b.WorldPosition.x - s.Pos.x;\n\t\t\t\tvar dy = b.WorldPosition.y - s.Pos.y;\n\t\t\t\tvar rr = b.Radius + GameConfig.SpineRadius;\n\t\t\t\tif ( dx * dx + dy * dy >= rr * rr ) continue;\n\n\t\t\t\tb.Mass = MathF.Max( GameConfig.StartMass, b.Mass * ( 1f - GameConfig.SpineHitMassFraction ) );\n\t\t\t\tNetworkManager.SpineHit( s.Pos.x, s.Pos.y, (byte)Math.Clamp( b.ColorIndex, 0, 255 ) );\n\t\t\t\tconsumed = true;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tif ( consumed ) { _spines.RemoveAt( i ); continue; }\n\n\t\t\tfor ( int j = _cells.Count - 1; j >= 0; j-- )\n\t\t\t{\n\t\t\t\tvar c = _cells[j];\n\t\t\t\tif ( c.PieceKind != CellPiece.Kind.SplitPiece ) continue;\n\t\t\t\tvar dx = c.Pos.x - s.Pos.x;\n\t\t\t\tvar dy = c.Pos.y - s.Pos.y;\n\t\t\t\tvar rr = c.Radius + GameConfig.SpineRadius;\n\t\t\t\tif ( dx * dx + dy * dy >= rr * rr ) continue;\n\n\t\t\t\tc.Mass = MathF.Max( GameConfig.StartMass, c.Mass * ( 1f - GameConfig.SpineHitMassFraction ) );\n\t\t\t\t_cellsDirty = true;\n\t\t\t\tNetworkManager.SpineHit( s.Pos.x, s.Pos.y, (byte)Math.Clamp( c.ColorIndex, 0, 255 ) );\n\t\t\t\t_spines.RemoveAt( i );\n\t\t\t\tconsumed = true;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tif ( consumed ) continue;\n\t\t}\n\t}\n\n\t/// <summary> \u523a\u7206\u5f39\u5e55\uff08\u5ba2\u6237\u7aef\u955c\u50cf\uff09\uff1a\u7eaf\u89c6\u89c9\u5f39\u9053\u63a8\u8fdb + \u5230\u671f\u6d88\u6563\uff0c\u4e0d\u5224\u547d\u4e2d\uff08\u6263\u8d28\u91cf\u7531 host \u6743\u5a01\u4e0b\u53d1\uff09 </summary>\n\tvoid TickSpinesVisual()\n\t{\n\t\tvar dt = Time.Delta;\n\t\tfor ( int i = _spines.Count - 1; i >= 0; i-- )\n\t\t{\n\t\t\tvar s = _spines[i];\n\t\t\tif ( s.SinceSpawn > GameConfig.SpineLife ) { _spines.RemoveAt( i ); continue; }\n\t\t\ts.Pos += s.Vel * dt;\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// \u5c16\u523a\u5206\u8eab\u5224\u5b9a\uff08v0.7.4.0\uff09\uff1a\u654c\u65b9\u73a9\u5bb6/\u5206\u8eab\u78b0\u5230\u6309\u91ce\u523a\u89c4\u5219\u2014\u2014\u6bd4\u523a\u5927\u88ab\u70b8\uff08\u8fde\u73af\u5bf9\u534a+\u523a\u88ab\u5403\uff09\uff0c\n\t/// \u6bd4\u523a\u5c0f\u88ab\u523a\u5403\uff08\u6b7b/\u664b\u5347\uff0c\u523a\u4e0d\u6d88\u8017\uff09\u3002**\u540c\u4e3b\u4eba\u4e0e\u56e2\u961f\u8d5b\u961f\u53cb\u514d\u75ab**\uff08\u6b66\u5668\u4e0d\u4f24\u81ea\u5df1\u4eba\uff09\u3002\n\t/// \u70b8\u6389\"\u6bd4\u81ea\u5df1\uff08\u4e3b\u4eba\uff09\u5927\u7684\u7403\"\u2192 \u4e3b\u4eba\u5956 3 \u79d2\u65e0\u654c\uff08\u7528\u6237\u5b9a\u7a3f C\uff1a\u6b66\u5668\u4e13\u7ed9\u5c0f\u7403\u53cd\u5927\u7403\uff0c\n\t/// \u5927\u7403\u7528\u5b83\u70b8\u5c0f\u7403\u96f6\u5956\u52b1\uff09\u3002\u5224\u5b9a\u82af\u4e0e\u91ce\u523a\u540c\u6b3e\uff08SpikeRadius \u00d7 SpikeTouchFactor\uff09\u3002\n\t/// </summary>\n\tvoid TickSpikeMinions( Vector2[] spikes )\n\t{\n\t\tvar touch = GameConfig.SpikeRadius * GameConfig.SpikeTouchFactor;\n\t\tvar teamOf = BuildTeamMap();\n\n\t\tfor ( int i = _cells.Count - 1; i >= 0; i-- )\n\t\t{\n\t\t\tvar minion = _cells[i];\n\t\t\tif ( minion.PieceKind != CellPiece.Kind.SpikeMinion ) continue;\n\n\t\t\tvar owner = minion.OwnerBall;\n\t\t\tbool ownerAlive = owner.IsValid() && owner.Alive;\n\n\t\t\tbool consumed = false;\n\n\t\t\t// \u654c\u65b9\u7403 \u00d7 \u5c16\u523a\u5206\u8eab\n\t\t\tforeach ( var b in _balls )\n\t\t\t{\n\t\t\t\tif ( !b.IsValid() || !b.Alive ) continue;\n\t\t\t\tif ( b == owner ) continue;   // \u4e3b\u4eba\u514d\u75ab\n\t\t\t\tif ( MatchState.IsTeam && ownerAlive && SameTeam( b, owner ) ) continue;\n\n\t\t\t\tvar d = b.WorldPosition.Distance( minion.Pos );\n\t\t\t\tif ( d >= b.Radius + touch ) continue;\n\n\t\t\t\tif ( b.Radius > GameConfig.SpikeRadius )\n\t\t\t\t{\n\t\t\t\t\t// \u5927\u7403\u88ab\u70b8\uff1a\u5403\u6389\u5c16\u523a\u5206\u8eab\u7684\u8d28\u91cf + \u8fde\u73af\u5bf9\u534a\u70b8\u88c2\uff1b\n\t\t\t\t\t// \u70b8\u6389\"\u6bd4\u4e3b\u4eba\u5927\u7684\u7403\" \u2192 \u4e3b\u4eba 3s \u65e0\u654c\uff08\u590d\u7528\u62a4\u76fe buff\uff09\n\t\t\t\t\tfloat gained = minion.Mass;\n\t\t\t\t\tb.Mass = MathF.Min( GameConfig.MaxMass, b.Mass + gained );\n\t\t\t\t\tDoBurst( b );\n\t\t\t\t\tNotifyBallEaten( minion.OwnerSteamId, b.OwnerSteamId, gained );\n\n\t\t\t\t\tif ( ownerAlive && b.Mass > owner.Mass )\n\t\t\t\t\t{\n\t\t\t\t\t\towner.ApplyBuff( (byte)PowerUpManager.Kind.Shield, GameConfig.SpikeMinionRewardSeconds );\n\t\t\t\t\t\tGameAchievements.SpikeMinionBlast( minion.OwnerSteamId );   // \u6210\u5c31\uff1a\u4ee5\u5c0f\u535a\u5927\uff08v0.7.8.13\uff09\n\t\t\t\t\t\tGameLog.Info( $\"[game] spike minion burst '{b.PlayerName}' -> '{owner.PlayerName}' invincible {GameConfig.SpikeMinionRewardSeconds}s\" );\n\t\t\t\t\t}\n\n\t\t\t\t\tNeonRenderer.Pop( minion.Pos, minion.ColorIndex, minion.Radius * 3f );\n\t\t\t\t\tif ( Networking.IsActive ) NetworkManager.PoppedEffect( minion.Pos, (byte)minion.ColorIndex, minion.Radius * 3f, minion.OwnerSteamId );\n\t\t\t\t\tconsumed = true;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\t// \u6bd4\u523a\u5c0f\uff1a\u88ab\u523a\u5403\uff08\u523a\u4fdd\u7559\uff0c\u4e0e\u91ce\u523a\u4e00\u81f4\uff09\n\t\t\t\t\tSpawnPopFx( b );\n\t\t\t\t\tif ( !TryPromoteFromPieces( b ) )\n\t\t\t\t\t{\n\t\t\t\t\t\tb.MarkDead();\n\t\t\t\t\t\tif ( !b.IsBot ) PopPiecesOf( b.OwnerSteamId );\n\t\t\t\t\t}\n\t\t\t\t\tGameLog.Info( $\"[game] eaten by spike minion: {b.PlayerName}\" );\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\t// \u654c\u65b9\u5206\u8eab \u00d7 \u5c16\u523a\u5206\u8eab\uff08\u5927\u5206\u8eab\u5403\u523a+\u70b8\u88c2\uff0c\u5c0f\u5206\u8eab\u88ab\u5403\uff09\n\t\t\tif ( !consumed )\n\t\t\t{\n\t\t\t\tfor ( int j = _cells.Count - 1; j >= 0; j-- )\n\t\t\t\t{\n\t\t\t\t\tvar c = _cells[j];\n\t\t\t\t\tif ( c.PieceKind != CellPiece.Kind.SplitPiece || c == minion ) continue;\n\t\t\t\t\tif ( c.OwnerSteamId == minion.OwnerSteamId ) continue;   // \u4e3b\u4eba\u514d\u75ab\n\t\t\t\t\tif ( MatchState.IsTeam\n\t\t\t\t\t\t&& teamOf.TryGetValue( c.OwnerSteamId, out var pt )\n\t\t\t\t\t\t&& ownerAlive && owner.TeamIndex >= 0 && pt == owner.TeamIndex ) continue;\n\n\t\t\t\t\tif ( c.Pos.Distance( minion.Pos ) >= c.Radius + touch ) continue;\n\n\t\t\t\t\tif ( c.Radius > GameConfig.SpikeRadius )\n\t\t\t\t\t{\n\t\t\t\t\t\tc.Mass = MathF.Min( GameConfig.MaxMass, c.Mass + minion.Mass );\n\t\t\t\t\t\tBurstPiece( c );\n\t\t\t\t\t\tNeonRenderer.Pop( minion.Pos, minion.ColorIndex, minion.Radius * 3f );\n\t\t\t\t\t\tconsumed = true;\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tNeonRenderer.Puff( c.Pos, c.ColorIndex );\n\t\t\t\t\t\t_cells.Remove( c );   // \u6309\u5f15\u7528\u79fb\u9664\uff1aBurstPiece \u5185\u90e8\u53ef\u80fd\u5df2\u5220\u8fc7\u5143\u7d20\uff0c\u7d22\u5f15\u4e0d\u53ef\u4fe1\uff08v0.7.8.87\uff09\n\t\t\t\t\t\t_cellsDirty = true;\n\t\t\t\t\t\tif ( j < i ) i--;   // \u5df2\u79fb\u9664\u7684\u5206\u8eab\u5728\u5f53\u524d\u904d\u5386\u4f4d\u4e4b\u524d\uff0c\u8865\u7d22\u5f15\n\t\t\t\t\t}\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif ( consumed )\n\t\t\t{\n\t\t\t\t// \u6309\u5f15\u7528\u79fb\u9664\uff08v0.7.8.87 \u4fee\uff09\uff1a\u4e0a\u9762 BurstPiece \u5728\u81ea\u5df1\u4eba\u6ee1 16 \u5206\u8eab\u65f6\u4e5f\u4f1a\u4ece _cells \u5220\u5143\u7d20\uff0c\n\t\t\t\t// \u539f\u6765\u6309\u7d22\u5f15 RemoveAt(i) \u4f1a\u5220\u9519\u8eab\u4f53\u6216\u8d8a\u754c\u629b\u5f02\u5e38\n\t\t\t\t_cells.Remove( minion );\n\t\t\t\t_cellsDirty = true;\n\t\t\t\ti = Math.Min( i, _cells.Count );   // \u79fb\u9664\u540e\u5217\u8868\u53d8\u77ed\uff0c\u94b3\u5230\u6709\u6548\u8303\u56f4\uff08\u5faa\u73af\u8fd8\u4f1a\u518d --\uff09\n\t\t\t}\n\t\t}\n\t}\n\n\t/// <summary> \u523a\u88ab\u5403\u6389\u540e\u5728\u522b\u5904\u91cd\u751f\uff08\u907f\u5f00\u4e2d\u5fc3\u51fa\u751f\u533a\uff09\uff0c\u5168\u91cf\u5e7f\u64ad\uff0812 \u4e2a\u4f4d\u7f6e\u5c0f\u5305\uff0c\u5e42\u7b49\uff09 </summary>\n\tvoid RespawnSpike( int index )\n\t{\n\t\t_spikes[index] = RandomSpikePos();\n\t\tif ( Networking.IsActive ) NetworkManager.SpikesFull( _spikes );   // \u65e0\u4f1a\u8bdd\u4e0d\u5e7f\u64ad\n\t}\n\n\tVector2 RandomSpikePos()\n\t{\n\t\tvar half = GameConfig.ArenaHalfSize - GameConfig.FoodMargin - GameConfig.SpikeRadius;\n\t\tVector2 p;\n\t\tdo\n\t\t{\n\t\t\tp = new Vector2( Game.Random.Float( -half, half ), Game.Random.Float( -half, half ) );\n\t\t}\n\t\twhile ( p.Length < 512f );   // \u4e2d\u5fc3\u533a\u7559\u7a7a\uff08\u51fa\u751f/\u91cd\u751f\u4e0d\u81f3\u4e8e\u8138\u4e0a\u6709\u523a\uff09\n\t\treturn p;\n\t}\n\n\t/// <summary> host \u751f\u6210\u5c16\u523a\uff08\u907f\u5f00\u4e2d\u5fc3\u51fa\u751f\u533a\uff09\u5e76\u5e7f\u64ad\uff1b\u65b0\u8fde\u63a5\u8d70 OnActive \u5b9a\u5411\u8865\u53d1 </summary>\n\tvoid GenerateSpikes()\n\t{\n\t\t_spikes = new Vector2[GameConfig.SpikeCount];\n\t\t_spikeFed = new float[GameConfig.SpikeCount];\n\t\t_spines.Clear();\n\t\tfor ( int i = 0; i < _spikes.Length; i++ )\n\t\t{\n\t\t\t_spikes[i] = RandomSpikePos();\n\t\t}\n\t\tif ( Networking.IsActive ) NetworkManager.SpikesFull( _spikes );   // \u65e0\u4f1a\u8bdd\uff08\u83dc\u5355\u6f14\u793a\u8d5b\uff09\u4e0d\u5e7f\u64ad\n\t\tGameLog.Info( $\"[game] spikes generated: {_spikes.Length}\" );\n\t}\n\n\t/// <summary>\n\t/// \u6267\u884c\u5410\u5b62\u5b50\uff08host \u6743\u5a01\uff0c**agar \u6807\u51c6\u591a\u8eab\u4f53\u5582\u7403**\uff09\uff1a\u5bf9\u73a9\u5bb6\u7684\u6bcf\u4e00\u4e2a\u8eab\u4f53\u2014\u2014\u4e3b\u7403\u548c\u6240\u6709\n\t/// \u591f\u8d28\u91cf\u7684\u5206\u8eab\u2014\u2014\u5404\u5410\u4e00\u9897\u4e2d\u6027\u5b62\u5b50\uff08v0.7.8.7 \u624b\u611f\uff1aaimPoint \u4e3a\u5149\u6807\u4e16\u754c\u5750\u6807\uff0c\n\t/// \u6bcf\u9897\u8eab\u4f53**\u72ec\u7acb\u671d\u5149\u6807**\u5f39\u51fa\uff0c\u5b62\u5b50\u6247\u5f62\u6563\u5f00\uff09\u3002\u6309\u4f4f\u8fde\u5410\u3002\n\t/// </summary>\n\tpublic void DoEject( Ball ball, Vector2 aimPoint )\n\t{\n\t\tif ( !GameConfig.EnableEject || !NetworkManager.IsAuthority ) return;\n\t\tif ( ball is null || !ball.IsValid() || !ball.Alive ) return;\n\t\tif ( _sinceEject.TryGetValue( ball.Id, out var t ) && t < GameConfig.EjectCooldownSeconds ) return;\n\t\tif ( !HasEjectMass( ball ) ) return;   // \u8d28\u91cf\u4e0d\u591f\u5c31\u522b\u5403\u8fd9\u6b21\u8282\u6d41\uff08v0.7.8.87\uff1a\u539f\u6765\u5148\u8bb0\u51b7\u5374\uff0c\u767d\u541e 0.15s\uff09\n\t\t_sinceEject[ball.Id] = 0;\n\n\t\tvar mainDir = FallbackDir( ball, DirTo( ball.WorldPosition, aimPoint ) );\n\n\t\tEjectFrom( ball, mainDir );\n\n\t\t// \u4e24\u9636\u6bb5\uff08\u5148\u6536\u96c6\u540e\u751f\u6210\uff09\uff1aSpawnBlob \u8d85\u5b62\u5b50\u4e0a\u9650\u4f1a _cells.Remove \u6324\u6389\u6700\u8001\u4e00\u9897\uff0c\n\t\t// \u8fb9\u904d\u5386 _cells \u8fb9\u751f\u6210 = \"Collection was modified\"\uff08\u5b9e\u6d4b\u70b8 bot \u5582\u5927\u54e5\u8def\u5f84\uff0cv0.7.8.8 \u4fee\uff09\n\t\tList<CellPiece> ejectors = null;\n\n\t\tforeach ( var c in _cells )\n\t\t{\n\t\t\tif ( c.PieceKind != CellPiece.Kind.SplitPiece || c.OwnerSteamId != ball.OwnerSteamId ) continue;\n\t\t\tif ( EjectCost( c.Mass ) <= 0f ) continue;\n\n\t\t\t( ejectors ??= new List<CellPiece>() ).Add( c );\n\t\t}\n\n\t\tif ( ejectors is null ) return;\n\n\t\tforeach ( var c in ejectors )\n\t\t{\n\t\t\tvar cost = EjectCost( c.Mass );   // \u4e24\u9636\u6bb5\u95f4\u8d28\u91cf\u4e0d\u4f1a\u88ab\u6539\uff0c\u91cd\u7b97\u5373\u5f97\n\t\t\tif ( cost <= 0f ) continue;\n\n\t\t\tc.Mass -= cost;\n\t\t\tSpawnBlob( c.Pos, DirOr( c.Pos, aimPoint, mainDir ), c.Radius, c.OwnerSteamId, ball.TeamIndex, c.ColorIndex, cost * GameConfig.EjectBlobEfficiency );\n\t\t}\n\t}\n\n\t/// <summary> \u4e3b\u7403\u662f\u5426\u8fd8\u6709\u53ef\u5410\u8d28\u91cf\uff08\u65e0\u5206\u8eab\u65f6\u51b3\u5b9a\u8fd9\u6b21\u6309\u952e\u7b97\u4e0d\u7b97\u4e00\u6b21\u6709\u6548\u6295\u63b7\uff1bv0.7.8.87\uff09 </summary>\n\tstatic bool HasEjectMass( Ball ball ) => EjectCost( ball.Mass ) > 0f;\n\n\t/// <summary> \u8eab\u4f53 \u2192 \u5149\u6807\u7684\u5355\u4f4d\u6307\u5411\uff1b\u5149\u6807\u6b63\u8d34\u8be5\u8eab\u4f53\u65f6\u8fd4\u56de fallback\uff08\u4e3b\u7403\u65b9\u5411\uff09 </summary>\n\tstatic Vector2 DirOr( Vector3 from, Vector2 aimPoint, Vector2 fallback )\n\t{\n\t\tvar d = DirTo( from, aimPoint );\n\t\treturn d.Length > 0.001f ? d : fallback;\n\t}\n\n\t/// <summary> \u8eab\u4f53 \u2192 \u7784\u51c6\u70b9\u7684\u5355\u4f4d\u6307\u5411\uff1b\u6b63\u8d34\u65f6\u8fd4\u56de Zero\uff08\u8c03\u7528\u65b9\u8d70 FallbackDir \u515c\u5e95\uff09 </summary>\n\tstatic Vector2 DirTo( Vector3 from, Vector2 aimPoint )\n\t{\n\t\tvar dx = aimPoint.x - from.x;\n\t\tvar dy = aimPoint.y - from.y;\n\t\tvar len = MathF.Sqrt( dx * dx + dy * dy );\n\t\treturn len > 0.001f ? new Vector2( dx / len, dy / len ) : Vector2.Zero;\n\t}\n\n\t/// <summary> \u8be5\u8d28\u91cf\u5410\u4e00\u9897\u7684\u6210\u672c\uff08\u7ea6 1% \u968f\u4f53\u91cf\u7f29\u653e\uff0c\u4e0b\u9650 1\uff09\uff1b\u8fd4\u56de 0 = \u4f1a\u51fb\u7a7f\u8d28\u91cf\u4e0b\u9650 1\uff0c\u4e0d\u5141\u8bb8 </summary>\n\tstatic float EjectCost( float mass )\n\t{\n\t\tvar cost = MathF.Max( GameConfig.EjectMinCost, mass * GameConfig.EjectMassPercent );\n\t\treturn mass - cost >= GameConfig.EjectMinMass ? cost : 0f;\n\t}\n\n\t/// <summary> \u4e3b\u7403\u5410\u51fa\u4e00\u9897\u5b62\u5b50 </summary>\n\tvoid EjectFrom( Ball ball, Vector2 dir )\n\t{\n\t\tvar cost = EjectCost( ball.Mass );\n\t\tif ( cost <= 0f ) return;\n\n\t\tball.Mass -= cost;\n\t\tSpawnBlob( ball.WorldPosition, dir, ball.Radius, ball.OwnerSteamId, ball.TeamIndex, ball.ColorIndex, cost * GameConfig.EjectBlobEfficiency );\n\t}\n\n\t/// <summary> \u751f\u6210\u4e00\u9897\u4e2d\u6027\u5b62\u5b50\uff08\u8d28\u91cf=\u6d88\u8017\u00d7\u8fd4\u8fd8\u7387\uff09\u5e76\u505a\u603b\u91cf\u4e0a\u9650\u6536\u655b\uff08\u6324\u6389\u6700\u8001\uff09\u3002\n\t/// team = \u5410\u7403\u8005\u961f\u4f0d\uff08\u9886\u571f\u5582\u65d7\u8ba1\u5206\u6309\u961f\u4f0d\uff1bbot \u4e0d\u6309 OwnerSteamId \u67e5\u2014\u2014bot \u7684 SteamId \u5168\u662f 0 \u4f1a\u4e32\u961f\uff09 </summary>\n\tvoid SpawnBlob( Vector3 pos, Vector2 dir, float radius, long owner, int team, int colorIndex, float mass )\n\t{\n\t\tvar c = new CellPiece\n\t\t{\n\t\t\tId = _nextCellId++,\n\t\t\tPieceKind = CellPiece.Kind.EjectedMass,\n\t\t\tOwnerSteamId = owner,\n\t\t\tTeamIndex = team,\n\t\t\tColorIndex = colorIndex,\n\t\t\tMass = mass,\n\t\t\tPos = pos + new Vector3( dir.x, dir.y, 0f ) * ( radius + 16f ),\n\t\t\tDrawPos = pos,\n\t\t\tImpulse = new Vector3( dir.x, dir.y, 0f ) * GameConfig.EjectImpulseSpeed,\n\t\t\tSinceSpawn = 0,\n\t\t};\n\t\tClampToArena( c );\n\t\t_cells.Add( c );\n\t\t_cellsDirty = true;\n\t\tNeonRenderer.Puff( c.Pos, colorIndex );\n\n\t\t// \u5b62\u5b50\u603b\u91cf\u4e0a\u9650\uff1a\u6324\u6389\u6700\u8001\u7684\u4e00\u9897\uff08TimeSince \u8d8a\u5927\u8d8a\u8001\uff09\n\t\tint blobs = 0;\n\t\tCellPiece oldest = null;\n\t\tforeach ( var x in _cells )\n\t\t{\n\t\t\tif ( x.PieceKind != CellPiece.Kind.EjectedMass ) continue;\n\t\t\tblobs++;\n\t\t\tif ( oldest is null || x.SinceSpawn > oldest.SinceSpawn ) oldest = x;\n\t\t}\n\t\tif ( blobs > GameConfig.MaxEjectedBlobs && oldest is not null )\n\t\t{\n\t\t\t_cells.Remove( oldest );\n\t\t}\n\t}\n\n\t/// <summary> host \u2192 \u5168\u4f53\uff1a\u5206\u8eab/\u5b62\u5b50\u72b6\u6001\u5feb\u7167\uff08\u5168\u91cf\u66ff\u6362\u5f0f\uff0c\u91cf\u5c0f\u9891\u7387\u4f4e\uff0c\u4e0d\u505a\u589e\u91cf\uff09 </summary>\n\tvoid BroadcastCells()\n\t{\n\t\t_cellsDirty = false;\n\n\t\tvar wires = new CellWire[_cells.Count];\n\t\tfor ( int i = 0; i < _cells.Count; i++ )\n\t\t{\n\t\t\tvar c = _cells[i];\n\t\t\twires[i] = new CellWire\n\t\t\t{\n\t\t\t\tId = c.Id,\n\t\t\t\tKind = (byte)c.PieceKind,\n\t\t\t\tColorIndex = (byte)c.ColorIndex,\n\t\t\t\tOwner = c.OwnerSteamId,\n\t\t\t\tX = c.Pos.x,\n\t\t\t\tY = c.Pos.y,\n\t\t\t\tMass = c.Mass,\n\t\t\t};\n\t\t}\n\t\tNetworkManager.CellsState( wires );\n\t}\n\n\t/// <summary> \u5ba2\u6237\u7aef\u5e94\u7528\u5feb\u7167\uff1a\u6309 Id \u5c31\u5730\u66f4\u65b0\uff08\u4fdd\u7559 DrawPos \u5e73\u6ed1\uff09\uff0c\u65b0\u589e\u8865\u5165\uff0c\u6d88\u5931\u79fb\u9664\u3002\n\t/// \u7ecf\u7f13\u51b2\u5b57\u5178 O(n+m)\uff08\u539f\u53cc\u91cd\u5168\u626b O(n\u00d7m)\uff0c\u6ee1\u914d 800+ \u5206\u8eab/\u5b62\u5b50\u65f6\u662f 15Hz \u70ed\u8def\u5f84\uff09 </summary>\n\tpublic void OnCellsStateRemote( CellWire[] wires )\n\t{\n\t\tif ( NetworkManager.IsAuthority ) return;   // host \u56de\u58f0\u6321\u6389\n\n\t\t_wireById.Clear();\n\t\tif ( wires is not null )\n\t\t{\n\t\t\tforeach ( var w in wires )\n\t\t\t\t_wireById[w.Id] = w;\n\t\t}\n\n\t\tfor ( int i = _cells.Count - 1; i >= 0; i-- )\n\t\t{\n\t\t\tvar c = _cells[i];\n\t\t\tif ( !_wireById.TryGetValue( c.Id, out var w ) )\n\t\t\t{\n\t\t\t\t// \u6d88\u5931\u4e5f\u8981\u7ed9\u53cd\u9988\uff08v0.7.8.92\uff09\uff1a\u88ab\u541e/\u5e76\u56de/\u4e3b\u4eba\u9635\u4ea1\u5728\u5ba2\u6237\u7aef\u53ea\u662f\u4ece\u5feb\u7167\u91cc\u9759\u9ed8\u6d88\u5931\uff0c\n\t\t\t\t// \u89c2\u611f\u5c31\u662f\"\u5206\u8eab\u51ed\u7a7a\u6d88\u5931\"\u2014\u2014\u539f\u5730\u8865\u4e00\u6735\u5c0f\u55b7\u53d1\uff08\u5408\u5e76\u56de\u6536\u4e5f\u4f1a\u51fa\uff0c\u5c5e\u53ef\u63a5\u53d7\u7684\u5438\u6536\u53cd\u9988\uff09\n\t\t\t\tNeonRenderer.Puff( c.DrawPos, c.ColorIndex );\n\t\t\t\t_cells.RemoveAt( i );\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tc.Pos = new Vector3( w.X, w.Y, 0f );\n\t\t\tc.Mass = w.Mass;\n\t\t\tc.ColorIndex = w.ColorIndex;\n\t\t\tc.OwnerSteamId = w.Owner;\n\t\t\tc.PieceKind = (CellPiece.Kind)w.Kind;\n\t\t}\n\n\t\t_cellIdScratch.Clear();\n\t\tforeach ( var c in _cells )\n\t\t\t_cellIdScratch.Add( c.Id );\n\n\t\tforeach ( var kv in _wireById )\n\t\t{\n\t\t\tif ( _cellIdScratch.Contains( kv.Key ) ) continue;\n\n\t\t\tvar w = kv.Value;\n\t\t\tvar pos = new Vector3( w.X, w.Y, 0f );\n\t\t\t_cells.Add( new CellPiece\n\t\t\t{\n\t\t\t\tId = w.Id,\n\t\t\t\tPieceKind = (CellPiece.Kind)w.Kind,\n\t\t\t\tColorIndex = w.ColorIndex,\n\t\t\t\tOwnerSteamId = w.Owner,\n\t\t\t\tMass = w.Mass,\n\t\t\t\tPos = pos,\n\t\t\t\tDrawPos = pos,\n\t\t\t} );\n\t\t\tNeonRenderer.Puff( pos, w.ColorIndex );   // \u5ba2\u6237\u7aef\u8865\u4e0a\u751f\u6210\u55b7\u53d1\uff08host \u5df2\u5728\u672c\u5730\u653e\u8fc7\uff09\n\t\t}\n\t}\n\n\t/// <summary> \u6b7b\u4ea1\u7206\u88c2\u7279\u6548\uff1ahost \u672c\u5730\u751f\u6210 + \u5e7f\u64ad\uff08\u5ba2\u6237\u7aef\u7ecf PoppedEffect RPC \u751f\u6210\u540c\u6b3e\uff09\u3002\n\t/// \u7206\u88c2\u97f3\u53ea\u64ad\"\u81ea\u5df1\u6b7b\u4ea1/\u7206\u88c2\"\u7684\uff08\u89c6\u89c9\u7279\u6548\u4ecd\u5168\u573a\u64ad\uff0c\u58f0\u97f3\u6536\u7a84\u5230\u672c\u4eba\uff09 </summary>\n\tvoid SpawnPopFx( Ball ball )\n\t{\n\t\tvar pos = ball.WorldPosition;\n\t\tNeonRenderer.Pop( pos, ball.ColorIndex, ball.Radius );\n\t\tif ( GameSfx.IsMine( ball.OwnerSteamId ) )\n\t\t\tGameSfx.Pop( pos );\n\t\tif ( Networking.IsActive ) NetworkManager.PoppedEffect( pos, (byte)ball.ColorIndex, ball.Radius, ball.OwnerSteamId );   // \u65e0\u4f1a\u8bdd\u4e0d\u5e7f\u64ad\n\t}\n\n\t/// <summary> \u6e05\u6389\u67d0\u4e3b\u4eba\u7684\u5168\u90e8\u5206\u8eab\uff08\u4e3b\u7403\u6b7b\u4ea1/\u65ad\u7ebf\u65f6\u8c03\u7528\uff1b\u5b62\u5b50\u4fdd\u7559\uff09 </summary>\n\tpublic void PopPiecesOf( long ownerSteamId )\n\t{\n\t\tfor ( int i = _cells.Count - 1; i >= 0; i-- )\n\t\t{\n\t\t\tvar c = _cells[i];\n\t\t\tif ( c.PieceKind != CellPiece.Kind.SplitPiece || c.OwnerSteamId != ownerSteamId ) continue;\n\t\t\t_cells.RemoveAt( i );\n\t\t\t_cellsDirty = true;\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// \u5c16\u523a\u5206\u8eab\uff08v0.7.4.0 \u9053\u5177\u6548\u679c\uff09\uff1a\u4ece\u4e3b\u4eba\u73b0\u6263 SpikeMinionMassPercent \u7684\u8d28\u91cf\u751f\u6210\u4e00\u9897\u8ddf\u968f\u5206\u8eab\uff0c\n\t/// 20 \u79d2\u5185\u662f\"\u523a\"\u2014\u2014\u654c\u65b9\u73a9\u5bb6\u78b0\u5230\u6309\u5c16\u523a\u89c4\u5219\u5904\u7406\uff08\u5927\u7403\u88ab\u70b8/\u5c0f\u7403\u6b7b\uff09\uff0c\u5230\u671f\u53d8\u56de\u666e\u901a\u5206\u8eab\u5408\u4f53\u56de\u6536\u8d28\u91cf\u3002\n\t/// \u751f\u6210\u5373\u5e26\u4e3b\u4eba\u8f6c\u5411\u8ddf\u968f\uff08TickCells \u7684 SpikeMinion \u5206\u652f\uff09\u3002\n\t/// </summary>\n\t/// <summary> \u62a4\u536b\u56fa\u5b88\u5c16\u523a\uff08M7.4 \u804c\u4e1a\uff09\uff1a\u539f\u5730\u751f\u6210\u951a\u5b9a\u5c16\u523a\u5206\u8eab\uff088 \u79d2\uff0c\u4e0d\u6263\u4e3b\u4eba\u8d28\u91cf\uff09\uff0c\n\t/// \u654c\u65b9\u89e6\u78b0\u8d70\u5c16\u523a\u5206\u8eab\u65e2\u6709\u5224\u5b9a\uff1b\u5230\u671f\u53d8\u56de\u666e\u901a\u5206\u8eab\u5f00\u59cb\u8ddf\u968f\u4e3b\u4eba </summary>\n\tpublic void SpawnGuardSpike( Ball ball )\n\t{\n\t\tif ( ball is null || !ball.IsValid() || !ball.Alive ) return;\n\n\t\tvar c = new CellPiece\n\t\t{\n\t\t\tId = _nextCellId++,\n\t\t\tPieceKind = CellPiece.Kind.SpikeMinion,\n\t\t\tOwnerSteamId = ball.OwnerSteamId,\n\t\t\tOwnerBall = ball,\n\t\t\tTeamIndex = ball.TeamIndex,\n\t\t\tColorIndex = ball.ColorIndex,\n\t\t\tMass = MathF.Max( 10f, ball.Mass * 0.15f ),\n\t\t\tPos = ball.WorldPosition,\n\t\t\tDrawPos = ball.WorldPosition,\n\t\t\tSinceSpawn = 0,\n\t\t\tAnchored = true,\n\t\t\tMaxLife = GameConfig.ClassGuardLife,\n\t\t};\n\t\t_cells.Add( c );\n\t\t_cellsDirty = true;\n\t\tNeonRenderer.Puff( c.Pos, c.ColorIndex );\n\t}\n\n\tpublic void SpawnSpikeMinion( Ball ball )\n\t{\n\t\tif ( ball is null || !ball.IsValid() || !ball.Alive ) return;\n\n\t\tvar mass = MathF.Max( 10f, ball.Mass * GameConfig.SpikeMinionMassPercent );\n\t\tmass = MathF.Min( mass, ball.Mass - 1f );\n\t\tif ( mass < 10f ) return;   // \u8d28\u91cf\u592a\u4f4e\uff08\u6781\u5c0f\u7403\uff09\u6263\u4e0d\u51fa 10\uff0c\u653e\u5f03\uff08\u9053\u5177\u767d\u62ff\u2014\u2014\u53ef\u63a5\u53d7\u7684\u6781\u7aef\u8fb9\u754c\uff09\n\n\t\tball.Mass -= mass;\n\n\t\tvar dir = RandomDir();\n\t\tvar c = new CellPiece\n\t\t{\n\t\t\tId = _nextCellId++,\n\t\t\tPieceKind = CellPiece.Kind.SpikeMinion,\n\t\t\tOwnerSteamId = ball.OwnerSteamId,\n\t\t\tOwnerBall = ball,\n\t\t\tColorIndex = ball.ColorIndex,\n\t\t\tMass = mass,\n\t\t\tPos = ball.WorldPosition + new Vector3( dir.x, dir.y, 0f ) * ( ball.Radius + 40f ),\n\t\t\tDrawPos = ball.WorldPosition,\n\t\t\tSinceSpawn = 0,\n\t\t};\n\t\tClampToArena( c );\n\t\t_cells.Add( c );\n\t\t_cellsDirty = true;\n\n\t\tNeonRenderer.Puff( c.Pos, ball.ColorIndex );\n\t\tGameLog.Info( $\"[game] spike minion spawned for '{ball.PlayerName}' (mass {mass:0})\" );\n\t}\n\n\t/// <summary>\n\t/// agar \u591a\u8eab\u4f53\u89c4\u5219\uff08v0.6.2.0 \u7528\u6237\u5b9a\u7a3f\uff09\uff1a\u4e3b\u7403\u88ab\u5403/\u649e\u523a\u65f6\uff0c\u628a\u6700\u5927\u7684\u5206\u8eab\"\u5347\u7ea7\"\u6210\u65b0\u4e3b\u7403\n\t/// \u2014\u2014\u7403\u5bf9\u8c61\u539f\u5730\u7eed\u547d\uff08\u8d28\u91cf/\u4f4d\u7f6e\u6362\u6210\u8be5\u5206\u8eab\u7684\uff09\uff0c\u73a9\u5bb6\u7ee7\u7eed\u73a9\uff1b\u5168\u90e8\u8eab\u4f53\u6b7b\u5149\u624d\u7b97\u771f\u6b7b\u3002\n\t/// \u4e3b\u7403\u4e0e\u5206\u8eab\u5728\u540c\u4e00 tick \u5185\u5b8c\u6210\u4ea4\u63a5\uff0cAlive \u4ece\u4e0d\u7ffb\u8f6c\uff0c\u5ba2\u6237\u7aef\u4e0d\u4f1a\u770b\u5230\u6b7b\u4ea1/\u91cd\u7f6e\u3002\n\t/// \u8fd4\u56de\u662f\u5426\u6210\u529f\uff08\u6ca1\u6709\u5206\u8eab = \u771f\u6b7b\u4ea1\uff0c\u8d70\u539f MarkDead \u6d41\u7a0b\uff09\u3002\n\t/// </summary>\n\tbool TryPromoteFromPieces( Ball ball )\n\t{\n\t\tif ( ball.IsBot ) return false;   // bot \u4e0d\u5206\u88c2\uff0c\u65e0\u5206\u8eab\u53ef\u664b\u5347\n\n\t\tCellPiece best = null;\n\t\tforeach ( var c in _cells )\n\t\t{\n\t\t\tif ( c.PieceKind != CellPiece.Kind.SplitPiece || c.OwnerSteamId != ball.OwnerSteamId ) continue;\n\t\t\tif ( best is null || c.Mass > best.Mass ) best = c;\n\t\t}\n\t\tif ( best is null ) return false;\n\n\t\tfloat mass = best.Mass;\n\t\tVector3 pos = best.Pos;\n\t\t_cells.Remove( best );\n\t\t_cellsDirty = true;\n\n\t\tball.Mass = mass;\n\t\tball.Alive = true;\n\t\tball.WorldPosition = pos;   // host \u81ea\u5df1\u7684\u7403\uff1aowner \u6a21\u62df\u4ece\u8fd9\u91cc\u7ee7\u7eed\n\t\t_lastPos[ball.Id] = pos;   // \u4f4d\u79fb\u6821\u9a8c\u951a\u70b9\u540c\u6b65\u77ac\u79fb\uff0c\u5426\u5219\u4e0b\u4e00\u5e27\u88ab\u5f53\u77ac\u79fb\u4f5c\u5f0a\u62c9\u56de\uff08v0.7.8.33 \u4fee\uff09\n\n\t\t// \u8fdc\u7a0b\u73a9\u5bb6\u7684\u7403\u5f52\u5176\u4e3b\u4eba\u6a21\u62df\uff0c\u4f4d\u7f6e\u8981\u901a\u77e5\u4e3b\u4eba\u77ac\u79fb\u8fc7\u53bb\uff08\u5426\u5219\u4e0b\u4e00\u5e27\u88ab\u4e3b\u4eba\u7684\u6a21\u62df\u5199\u56de\u65e7\u4f4d\u7f6e\uff09\n\t\tif ( ball.IsRemoteOwned || ball.OwnerSteamId != Game.SteamId.Value )\n\t\t{\n\t\t\tforeach ( var conn in Connection.All )\n\t\t\t{\n\t\t\t\tif ( conn is null || !conn.IsActive ) continue;\n\t\t\t\tif ( conn.SteamId.Value != ball.OwnerSteamId ) continue;\n\n\t\t\t\tusing ( Rpc.FilterInclude( conn ) )\n\t\t\t\t{\n\t\t\t\t\tNetworkManager.MainPromoted( pos.x, pos.y );\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\tGameLog.Info( $\"[game] promoted piece -> main: {ball.PlayerName} mass={mass:0}\" );\n\t\treturn true;\n\t}\n\n\t/// <summary>\n\t/// \u89c6\u89d2\u7126\u70b9\uff08v0.6.2.0 \u7528\u6237\u5b9a\u7a3f\"\u89c6\u89d2\u8ddf\u968f\u6700\u5927\u7684\u5206\u8eab\"\uff09\uff1a\u4e3b\u7403\u4e0e\u81ea\u5df1\u6240\u6709\u5206\u8eab\u4e2d\n\t/// \u8d28\u91cf\u6700\u5927\u7684\u90a3\u4e2a\u8eab\u4f53\u7684\u4f4d\u7f6e/\u8d28\u91cf\u3002\n\t/// </summary>\n\tpublic Vector3 FocusPositionOf( Ball ball, out float biggestMass )\n\t{\n\t\tbiggestMass = ball.IsValid() ? ball.Mass : 0f;\n\t\tvar pos = ball.IsValid() ? ball.WorldPosition : Vector3.Zero;\n\t\tif ( !ball.IsValid() ) return pos;\n\n\t\tforeach ( var c in _cells )\n\t\t{\n\t\t\tif ( c.PieceKind != CellPiece.Kind.SplitPiece || c.OwnerSteamId != ball.OwnerSteamId ) continue;\n\t\t\tif ( c.Mass > biggestMass )\n\t\t\t{\n\t\t\t\tbiggestMass = c.Mass;\n\t\t\t\tpos = c.Pos;\n\t\t\t}\n\t\t}\n\t\treturn pos;\n\t}\n\n\tint CountPiecesOf( long ownerSteamId )\n\t{\n\t\tint n = 0;\n\t\tforeach ( var c in _cells )\n\t\t{\n\t\t\tif ( c.PieceKind == CellPiece.Kind.SplitPiece && c.OwnerSteamId == ownerSteamId ) n++;\n\t\t}\n\t\treturn n;\n\t}\n\n\t/// <summary> \u8be5\u73a9\u5bb6\u662f\u5426\u8fd8\u6709\u5206\u8eab\u62a4\u4f53\uff08\u4e3b\u7403\u88ab\u5403\u4fdd\u62a4 + \u62a4\u76fe\u865a\u73af\u6e32\u67d3\u7528\uff09 </summary>\n\tpublic bool HasPieces( long ownerSteamId ) => CountPiecesOf( ownerSteamId ) > 0;\n\n\t/// <summary> \u73a9\u5bb6\u603b\u8d28\u91cf = \u4e3b\u7403 + \u5168\u90e8\u5206\u8eab\uff08\u6392\u884c\u699c/HUD \u7528\uff1b\u5206\u8eab\u7684\u8d28\u91cf\u4e5f\u662f\u771f\u5b9e\u6218\u529b\uff09 </summary>\n\tpublic float TotalMassFor( Ball ball )\n\t{\n\t\tif ( ball is null || !ball.IsValid() ) return 0f;\n\n\t\tfloat m = ball.Mass;\n\t\tvar sid = ball.OwnerSteamId;\n\t\tforeach ( var c in _cells )\n\t\t{\n\t\t\tif ( c.PieceKind == CellPiece.Kind.SplitPiece && c.OwnerSteamId == sid ) m += c.Mass;\n\t\t}\n\t\treturn m;\n\t}\n\n\tVector2 SteerDirOf( Ball ball )\n\t{\n\t\tif ( !ball.IsValid() ) return Vector2.Zero;\n\t\treturn _steerDir.TryGetValue( ball.Id, out var d ) ? d : Vector2.Zero;\n\t}\n\n\t/// <summary> \u5f39\u5c04\u65b9\u5411\uff1a\u4f18\u5148\u7528\u8f93\u5165\u65b9\u5411\uff0c\u6ca1\u7ed9\uff08\u9759\u6b62\u5f00\u706b\uff09\u5c31\u6cbf\u7528\u7403\u7684\u79fb\u52a8\u65b9\u5411\uff0c\u518d\u9000\u968f\u673a </summary>\n\tVector2 FallbackDir( Ball ball, Vector2 dir )\n\t{\n\t\tif ( dir.Length > 0.001f ) return dir.Normal;\n\t\tvar d = SteerDirOf( ball );\n\t\tif ( d.Length > 0.001f ) return d.Normal;\n\n\t\treturn RandomDir();\n\t}\n\n\t/// <summary> \u5747\u5300\u968f\u673a\u5355\u4f4d\u65b9\u5411 </summary>\n\tVector2 RandomDir()\n\t{\n\t\tvar a = Game.Random.Float( 0f, MathF.PI * 2f );\n\t\treturn new Vector2( MathF.Cos( a ), MathF.Sin( a ) );\n\t}\n\n\tvoid ClampToArena( CellPiece c )\n\t{\n\t\tvar half = MathF.Max( 0f, GameConfig.ArenaHalfSize - c.Radius );\n\t\tc.Pos.x = Math.Clamp( c.Pos.x, -half, half );\n\t\tc.Pos.y = Math.Clamp( c.Pos.y, -half, half );\n\t\tc.Pos.z = 0f;\n\t}\n\n\t// ---- \u4e16\u754c\u642d\u5efa ----\n\n\t/// <summary>\n\t/// UI \u5bf9\u8d26\uff08v0.6.4.5\uff09\uff1a\u7531\u72b6\u6001\u6807\u5fd7**\u63a8\u5bfc**\u5404\u9762\u677f\u5e94\u6709\u7684\u53ef\u89c1\u6027\uff0c\u6bcf\u5e27\u5f3a\u5236\u6267\u884c\u3002\n\t/// \u4fee\"\u5ba2\u6237\u7aef\u52a0\u5165\u540e UI \u5168\u6d88\u5931 / \u623f\u95f4\u548c\u4e3b\u83dc\u5355\u53e0\u5728\u4e00\u8d77\"\u2014\u2014\u6b64\u524d Show/Hide \u5168\u9760\u5404\u8def\u5f84\u81ea\u89c9\u8c03\u7528\uff0c\n\t/// \u4efb\u4f55\u4e00\u6761\u8def\u5f84\u6f0f\u8c03\uff08\u4e2d\u9014\u52a0\u5165\u3001\u70ed\u91cd\u8f7d\u91cd\u5efa\u9762\u677f\u3001\u6b8b\u7559\u5012\u8ba1\u65f6\u5361\u4f4f\u6062\u590d\u5206\u652f\uff09\u5c31\u6c38\u8fdc\u505c\u5728\u574f\u754c\u9762\u3002\n\t/// </summary>\n\tvoid ReconcileUi()\n\t{\n\t\tbool inSession = Networking.IsActive;\n\n\t\t// \u6b8b\u7559\u52a0\u5165\u5012\u8ba1\u65f6\u4f1a\u8ba9\u5ba2\u6237\u7aef\u6062\u590d\u5206\u652f\uff08_joinCountdown<0 \u624d\u8d70\uff09\u6c38\u4e45\u5931\u6548\u2014\u2014\u5bf9\u5c40\u5df2\u4e0d\u5728\u5c31\u6e05\u6389\n\t\tif ( _joinCountdown >= 0f && ( !_matchRunning || _gameStarted || !inSession ) )\n\t\t\t_joinCountdown = -1f;\n\n\t\tif ( _gameStarted )\n\t\t{\n\t\t\t_menu?.Hide();\n\t\t\t_lobby?.Hide();\n\t\t\t_browser?.Hide();\n\t\t\tif ( !MatchState.MatchOver )\n\t\t\t{\n\t\t\t\t// \u5bf9\u5c40\u4e2d HUD \u5fc5\u987b\u5728\uff1a\u6ca1\u5efa\u8fc7\u5c31\u5efa\uff08\u70ed\u91cd\u8f7d\u91cd\u5efa\u4e16\u754c\u540e\u5f15\u7528\u5931\u6548 \u2192 \u91cd\u5efa\u81ea\u6108\uff09\n\t\t\t\tif ( _hud.IsValid() )\n\t\t\t\t{\n\t\t\t\t\tif ( !_hud.GameObject.Enabled ) _hud.GameObject.Enabled = true;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tCreateHud();\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tif ( _lobbyOpen )\n\t\t{\n\t\t\t_menu?.Hide();\n\t\t\t_lobby?.ShowHost();   // \u5e42\u7b49\uff1a\u5df2\u663e\u793a\u4e0d\u91cd\u7f6e\u72b6\u6001\n\t\t\treturn;\n\t\t}\n\n\t\tif ( inSession && !NetworkManager.IsAuthority )\n\t\t{\n\t\t\t// \u5ba2\u6237\u7aef\u5728\u4f1a\u8bdd\u91cc\u4f46\u6ca1\u8fdb\u5bf9\u5c40\uff1a\u5fc5\u987b\u505c\u5728\u623f\u95f4\u9875\uff08\u7b49\u5f85 / JOIN GAME \u6309\u94ae / 3-2-1\uff09\n\t\t\t_menu?.Hide();\n\t\t\t_browser?.Hide();\n\t\t\tif ( !_lobbyWaiting )\n\t\t\t{\n\t\t\t\tEnterClientWaiting();\n\t\t\t\tNetworkManager.RequestMatchState();\n\t\t\t}\n\t\t\t_lobby?.ShowClient();\n\t\t\treturn;\n\t\t}\n\n\t\tif ( !inSession && NetworkManager.IsReconnecting )\n\t\t{\n\t\t\t// \u6389\u7ebf\u81ea\u52a8\u91cd\u8fde\u4e2d\uff1a\u623f\u95f4\u9875\u7559\u5728\u539f\u5730\u663e\u793a \"CONNECTION LOST \u2014 RECONNECTING\"\uff0c\n\t\t\t// \u91cd\u8bd5\u8017\u5c3d OnReconnectFailed \u624d\u56de\u4e3b\u83dc\u5355\uff08v0.6.4.6\uff09\n\t\t\t_lobby?.ShowClient();\n\t\t\treturn;\n\t\t}\n\n\t\t_lobby?.Hide();\n\n\t\t// \u7ec8\u6001\u4fdd\u8bc1\uff08v0.6.8.2\uff09\uff1a\u65e0\u4f1a\u8bdd\u4e14\u4e0d\u5728\u4efb\u4f55\u6d41\u7a0b \u2192 **\u4e3b\u83dc\u5355\u5fc5\u987b\u53ef\u89c1**\u3002\n\t\t// \u4e4b\u524d\u53ea Hide \u623f\u95f4\u4e0d\u4eae\u83dc\u5355\u2014\u2014\u4efb\u4f55\u8def\u5f84\u6f0f\u6389 Show\uff08\u70ed\u91cd\u8f7d\u91cd\u5efa\u3001\u5f02\u5e38\u4e2d\u65ad\uff09\u5c31\u662f\u5168\u7a7a\u5c4f\n\t\tif ( _browser.IsValid() && _browser.IsOpen ) return;   // \u623f\u95f4\u5217\u8868\u9875\u5f00\u7740\uff1a\u4e3b\u83dc\u5355\u8ba9\u4f4d\n\t\tif ( _settings.IsValid() && _settings.IsOpen ) return;   // \u8bbe\u7f6e\u9875\u5f00\u7740\uff1a\u4e3b\u83dc\u5355\u8ba9\u4f4d\uff08v0.7.8.16\uff09\n\t\tif ( _rankings.IsValid() && _rankings.IsOpen ) return;   // \u6392\u884c\u9875\u5f00\u7740\uff1a\u4e3b\u83dc\u5355\u8ba9\u4f4d\uff08v0.7.8.19\uff09\n\t\tif ( _menu.IsValid() && !_menu.GameObject.Enabled ) _menu.Show();\n\t}\n\n\tvoid EnsureWorld()\n\t{\n\t\tif ( _worldReady ) return;\n\t\t_worldReady = true;\n\n\t\t// \u590d\u7528\u65e7\u7684\u4e16\u754c\u6839\uff08\u70ed\u91cd\u8f7d\u65f6\u907f\u514d\u5806\u53e0\u591a\u5957\u4e16\u754c\uff09\uff0c\u5e76\u6e05\u6389\u5176\u52a8\u6001\u5b50\u7269\u4f53\u3002\n\t\t// \u6ce8\u610f\uff1aPlay \u505c\u6b62\u540e static \u5f15\u7528\u53ef\u80fd\u6307\u5411\u5df2\u5378\u8f7d\u7684\u65e7\u573a\u666f\u5bf9\u8c61\uff08IsValid \u4ecd\u4e3a\u771f\uff09\uff0c\n\t\t// \u5fc5\u987b\u6821\u9a8c\u573a\u666f\u5f52\u5c5e\uff0c\u5426\u5219\u5411\u65e7\u573a\u666f\u6839\u6302\u5b50\u7269\u4f53\u76f4\u63a5\u65ad\u8a00\u5931\u8d25\n\t\tif ( _sharedRoot.IsValid() && _sharedRoot.Scene == Scene )\n\t\t{\n\t\t\t_root = _sharedRoot;\n\t\t\tforeach ( var child in _root.Children.ToArray() )\n\t\t\t{\n\t\t\t\tif ( child.IsValid() ) child.Destroy();\n\t\t\t}\n\t\t\t_balls.Clear();\n\t\t\t_lastPos.Clear();\n\t\t\t_teleportGrace.Clear();\n\t\t\t_cells.Clear();\n\t\t\t_steerDir.Clear();\n\t\t\t_sinceEject.Clear();\n\t\t\t_nextCellId = 1;\n\t\t\tLocalBall = null;\n\t\t}\n\t\telse\n\t\t{\n\t\t\t_sharedRoot = null;\n\t\t\t// \u547d\u540d\u907f\u5f00 \"CircleroyaleGame\"\uff1a\u5ba2\u6237\u7aef\u4e0a\u5feb\u7167\u4e16\u754c\u91cc host \u7684\u4e16\u754c\u6839\u4e5f\u53eb\u8fd9\u4e2a\u540d\n\t\t\t_root = new GameObject( true, \"CircleroyaleGameLocal\" );\n\t\t\t_sharedRoot = _root;\n\t\t}\n\n\t\tif ( NetworkManager.IsAuthority )\n\t\t{\n\t\t\t// \u5c4f\u853d\u573a\u666f\u91cc\u6a21\u677f\u7559\u4e0b\u7684\u6240\u6709\u5bf9\u8c61\uff08\u793a\u4f8b\u76f8\u673a\u3001\u65b9\u5757\u7b49\uff09\uff0c\u53ea\u8dd1\u6211\u4eec\u751f\u6210\u7684\u4e16\u754c\u3002\n\t\t\t// Scene \u672c\u8eab\u5c31\u662f\u6839 GameObject\uff0c\u9876\u5c42\u5bf9\u8c61\u90fd\u5728\u5b83\u7684 Children \u91cc\u3002\n\t\t\t// \u26a0\ufe0f \u8c41\u514d\u542b ScreenPanel \u7684\u9876\u5c42\u7269\u4f53\uff1aUI GO \u82e5\u56e0 Parent= \u9759\u9ed8\u5931\u6548\u53d8\u6210\u9876\u5c42\u5b64\u513f\uff0c\n\t\t\t// \u7981\u7528\u5b83\u4eec\u4f1a\u5f15\u53d1\u7981\u7528\u2194\u542f\u7528\u5faa\u73af\uff08PanelComponent \u6bcf\u6b21\u91cd\u65b0\u542f\u7528\u91cd\u5efa\u9762\u677f\u6811=\u7206\u95ea\uff09\n\t\t\tforeach ( var go in Scene.Children )\n\t\t\t{\n\t\t\t\tif ( !go.IsValid() || go == _root ) continue;\n\t\t\t\tvar hasUi = false;\n\t\t\t\tforeach ( var sp in go.GetComponentsInChildren<ScreenPanel>( true ) ) { hasUi = true; break; }\n\t\t\t\tif ( hasUi ) continue;\n\t\t\t\tgo.Enabled = false;\n\t\t\t}\n\n\t\t\tCreateBackdrop();\n\t\t\tCreateNeon();\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// \u5ba2\u6237\u7aef\uff1a**\u76f4\u63a5\u542f\u7528\u5feb\u7167\u4e16\u754c\u6839**\uff08host \u7684\u4e16\u754c\uff0c\u7403\u90fd\u5728\u91cc\u9762\uff09\uff0c\u53ea\u5265\u6389\u5b83\u81ea\u5e26\u7684\n\t\t\t// \u76f8\u673a/HUD\uff08\u7528\u672c\u673a\u7684\uff0c\u907f\u514d\u53cc\u76f8\u673a\u53cc HUD\uff09\u3002\u26a0\ufe0f \u4e0d\u80fd\u8d70\"\u628a\u7403 reparent \u5230\u672c\u673a\u6839\"\n\t\t\t// \u7684\u8def\u7ebf\u2014\u2014\u5f15\u64ce\u4e0d\u5141\u8bb8\u5ba2\u6237\u7aef\u6539 host \u62e5\u6709\u5bf9\u8c61\u7684\u5c42\u7ea7\uff0cParent= \u9759\u9ed8\u5931\u6548\uff0c\u7403\u6c38\u8fdc\n\t\t\t// Active=false\uff1a\u5934\u50cf\u4e0d\u6e32\u67d3\u3001OnUpdate \u4e0d\u8dd1\uff08\u6b7b\u4ea1\u5934\u50cf\u9690\u85cf\u5931\u6548\u2192\"\u5c38\u4f53\u5708\"\uff09\n\t\t\t// \u2014\u20142026-09-05 \u63a2\u9488\u5b9e\u6d4b\uff08actFalse=32\uff0c\u81ea\u6108\u626b\u63cf\u6bcf\u79d2\u8dd1\u4e5f\u65e0\u6d4e\u4e8e\u4e8b\uff09\u3002\n\t\t\tAdoptSnapshotWorld();\n\t\t}\n\n\t\tCreateCamera();\n\t\tCreateMenu();\n\t\tCreateLobbyPanel();\n\t\tCreateRoomBrowser();\n\t\tCreateSettingsPanel();\n\t\tCreateRankingsPanel();\n\n\t\t// \u98df\u7269\u7ba1\u7406\u4e0e\u8054\u673a\u7ba1\u7406\uff1a**\u6bcf\u53f0\u673a\u5668\u4e00\u4efd\u672c\u5730\u5b9e\u4f8b\uff0c\u81ea\u8eab\u4e0d\u8054\u7f51**\u3002\n\t\t// \u98df\u7269\u72b6\u6001\u7ecf\u9759\u6001 RPC \u540c\u6b65\uff08FoodFull \u5b9a\u5411\u5168\u91cf / FoodEaten / FoodRespawned \u5e7f\u64ad\u589e\u91cf\uff09\uff1b\n\t\t// \u6b64\u524d\u6302\u5728\u7f51\u7edc\u5bf9\u8c61\uff08GameNet\uff09\u4e0a\u7684 NetList \u65e0\u6cd5\u590d\u5236\u5230\u5ba2\u6237\u7aef\uff0c\u98df\u7269/\u91cd\u751f\u5168\u5e9f\u2014\u2014\u5b9e\u6d4b\u3002\n\t\t// M4 \u4e3b\u83dc\u5355\uff1a\u58f3\u5230\u6b64\u4e3a\u6b62\u2014\u2014\u5efa\u5927\u5385\u8fd8\u662f\u8fde\u4eba\u7531\u83dc\u5355\u70b9\u51fb\u51b3\u5b9a\uff08\u65e7\u6d41\u7a0b\u5f00\u673a\u81ea\u52a8\u5efa\u5927\u5385\uff0c\n\t\t// \u53cc\u5f00\u7b2c\u4e8c\u5b9e\u4f8b\u5fc5\u987b\u8d76\u5728 CreateLobby \u5b8c\u6210\u524d\u8fde\u4e0a\u624d\u6709\"\u5ba2\u6237\u7aef\"\u8eab\u4efd\uff0c\u5929\u7136\u7ade\u6001\uff09\u3002\n\t\t_food = new FoodManager();\n\t\t_power = new PowerUpManager();\n\t\t_net = _root.AddComponent<NetworkManager>();\n\t\tGameMusic.PlayMenu();   // \u83dc\u5355/\u5927\u5385\u9636\u6bb5\u64ad journey\uff082026-09-09 \u7528\u6237\u5b9a\u7a3f\uff09\n\n\t\t// \u4e3b\u83dc\u5355\u80cc\u666f\u6f14\u793a\u8d5b\uff08v0.6.3.0\uff09\uff1a\u79bb\u7ebf authority \u8dd1\u4e00\u573a\u7eaf bot \u5bf9\u6218\u5f53\u80cc\u666f\n\t\tif ( NetworkManager.IsAuthority && !Networking.IsActive )\n\t\t{\n\t\t\tStartMenuDemo();\n\t\t}\n\t\telse if ( !NetworkManager.IsAuthority && Networking.IsActive )\n\t\t{\n\t\t\t// \u5ba2\u6237\u7aef\u5728\u4f1a\u8bdd\u91cc\uff08\u91cd\u8fde/\u70ed\u91cd\u8f7d/\u81ea\u52a8\u91cd\u8fde\u540e\u91cd\u5efa\u4e16\u754c\uff09\uff1a\u4e0d\u4eae\u4e3b\u83dc\u5355\u2014\u2014\n\t\t\t// \u76f4\u63a5\u56de\u623f\u95f4\u7b49\u5f85\u6216\u5bf9\u5c40\uff08\u5411 host \u8981\u5f53\u524d\u72b6\u6001\uff0c\u89c1 RequestMatchState\uff09\n\t\t\t_menu?.Hide();\n\t\t\tEnterClientWaiting();\n\t\t\t_lobby?.ShowClient();\n\t\t\tNetworkManager.RequestMatchState();\n\t\t}\n\t}\n\n\t/// <summary> \u4e3b\u83dc\u5355\u52a8\u4f5c\uff080=\u5efa\u670d 1=\u5feb\u901f\u52a0\u5165 2=\u4eba\u673a\u5bf9\u6218 3=\u9000\u51fa\uff09\u3002\n\t/// HOST / VS BOTS \u90fd\u5148\u8fdb**\u623f\u95f4**\uff08M5 \u4e8c\u7ea7\u754c\u9762\uff09\uff1a\u8c03\u53c2\u3001\u770b\u73a9\u5bb6\u3001\u70b9 START \u624d\u5f00\u5c40 </summary>\n\tpublic void MenuAction( int index )\n\t{\n\t\tif ( _gameStarted || _lobbyOpen ) return;\n\n\t\tswitch ( index )\n\t\t{\n\t\t\tcase 0:   // HOST\uff1a\u672c\u673a\u5f53\u623f\u4e3b\uff08\u5efa\u516c\u5f00\u5927\u5385\uff0c\u522b\u4eba\u53ef\u52a0\u5165\uff09\u2192 \u8fdb\u623f\u95f4\n\t\t\t\tStopMenuDemo();\n\t\t\t\tOpenHostLobby();\n\t\t\t\tGameLog.Info( \"[menu] host game (room)\" );\n\t\t\t\t_net.RunHostFlow();\n\t\t\t\tbreak;\n\n\t\t\tcase 1:   // JOIN\uff1a\u623f\u95f4\u5217\u8868\u9875\uff08v0.6.7.0\uff09\u2014\u2014\u5217\u51fa Steam \u516c\u5f00\u5927\u5385\u70b9\u9009\u52a0\u5165\uff1bJOIN LOCAL \u662f\u540c\u673a\u8c03\u8bd5\u515c\u5e95\n\t\t\t\tStopMenuDemo();\n\t\t\t\t_menu?.Hide();\n\t\t\t\t_browser?.Show();\n\t\t\t\tbreak;\n\n\t\t\tcase 2:   // VS BOTS\uff1a\u4eba\u673a\u5bf9\u6218\uff08\u4e0d\u5efa\u5927\u5385\uff0c\u79bb\u7ebf\u623f\u95f4\uff0c\u4e00\u6837\u8c03\u53c2\u540e START\uff09\n\t\t\t\tStopMenuDemo();\n\t\t\t\tOpenHostLobby();\n\t\t\t\tGameLog.Info( \"[menu] vs bots (offline room)\" );\n\t\t\t\t_net.RunHostFlow( createLobby: false );\n\t\t\t\tbreak;\n\n\t\t\tcase 3:   // QUIT\uff1a\u7f16\u8f91\u5668\u91cc\u9000\u4e0d\u51fa\u5e94\u7528\uff0c\u53ea\u63d0\u793a\n\t\t\t\tif ( Game.IsEditor )\n\t\t\t\t\t_menu?.SetStatus( \"QUIT IS STANDALONE-ONLY (THIS IS THE EDITOR)\" );\n\t\t\t\telse\n\t\t\t\t\tGame.Close();\n\t\t\t\tbreak;\n\n\t\t\tcase 4:   // SETTINGS\uff1a\u8bbe\u7f6e\u9875\uff08\u6cdb\u5149\u5f00\u5173/\u97f3\u4e50\u97f3\u91cf\uff1bBACK \u56de\u83dc\u5355\uff09\n\t\t\t\t_menu?.Hide();\n\t\t\t\t_settings?.Show();\n\t\t\t\tbreak;\n\n\t\t\tcase 5:   // RANKINGS\uff1a\u5168\u7403 XP \u6392\u884c\u9875\uff08v0.7.8.19\uff1bBACK \u56de\u83dc\u5355\uff09\n\t\t\t\t_menu?.Hide();\n\t\t\t\t_rankings?.Show();\n\t\t\t\tbreak;\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// \u4e3b\u83dc\u5355\u80cc\u666f\u6f14\u793a\u8d5b\uff08v0.6.3.0 \u7528\u6237\u9700\u6c42\uff09\uff1aauthority \u79bb\u7ebf\u8dd1\u4e00\u573a\u7eaf bot \u5bf9\u6218\u5f53\u83dc\u5355\u80cc\u666f\uff0c\n\t/// \u955c\u5934\u89c2\u6218\u6700\u5927 bot\uff1b\u89c4\u5219\u968f\u83dc\u5355\u8bbe\u7f6e\uff0c\u65f6\u95f4\u5230\u5c31\u5730\u91cd\u5f00\u3002\u8fdb\u623f\u95f4/\u52a0\u5165\u65f6 StopMenuDemo \u6536\u573a\u3002\n\t/// </summary>\n\tvoid StartMenuDemo()\n\t{\n\t\tif ( _menuDemo ) return;\n\t\t_menuDemo = true;\n\n\t\tGameConfig.ArenaHalfSize = Math.Clamp( GameConfig.ConvarArenaSize, 1024f, 12288f );\n\t\tMatchState.ActivateForMatch();   // \u6309\u5f53\u524d\u83dc\u5355\u8bbe\u7f6e\u8dd1\uff08\u65e0\u8fde\u63a5\uff0cRPC \u7a7a\u53d1\u65e0\u5bb3\uff09\n\t\t_food.Init();\n\t\tGenerateSpikes();\n\t\t_power.Init();\n\n\t\tvar target = Math.Clamp( MatchState.PlayerTarget, 2, 64 );\n\t\tfor ( int i = 0; i < target; i++ )\n\t\t{\n\t\t\tSpawnBall( RandomSpawnPos(), isBot: true, owner: null );\n\t\t}\n\t\tGameLog.Info( $\"[menu] demo bots started ({target})\" );\n\t}\n\n\t/// <summary> \u6536\u6f14\u793a\u8d5b\uff1a\u6e05 bot/\u5206\u8eab\uff0c\u91cd\u7f6e\u521d\u59cb\u5316\u6807\u8bb0\uff08\u771f\u5b9e\u5f00\u5c40\u7684 OnLobbyOpened \u4f1a\u91cd\u65b0\u6295\u98df\uff09 </summary>\n\tvoid StopMenuDemo()\n\t{\n\t\tif ( !_menuDemo ) return;\n\t\t_menuDemo = false;\n\n\t\tforeach ( var b in _balls.ToArray() )\n\t\t{\n\t\t\tif ( b.IsValid() ) b.GameObject.Destroy();\n\t\t}\n\t\t_balls.Clear();\n\t\t_cells.Clear();\n\t\t_lastPos.Clear();\n\t\t_teleportGrace.Clear();\n\t\t_steerDir.Clear();\n\t\t_sinceEject.Clear();\n\t\t_power?.Reset();\n\t\t_networkReady = false;\n\t\tGameLog.Info( \"[menu] demo stopped\" );\n\t}\n\n\t/// <summary> host \u5f00\u623f\uff1a\u85cf\u83dc\u5355\u3001\u4eae\u623f\u95f4\u754c\u9762\uff08\u86f0\u4f0f\u7403\u7531 OnLobbyOpened \u5728\u4f1a\u8bdd\u5c31\u7eea\u540e\u751f\u6210\uff09 </summary>\n\tvoid OpenHostLobby()\n\t{\n\t\t_lobbyOpen = true;\n\n\t\t// \u5f00\u65b0\u623f\u9ed8\u8ba4\u9886\u571f\u6218\u4e89\uff08v0.7.8.85 \u7528\u6237\u5b9a\u7a3f\uff09\uff1a\u663e\u5f0f\u5f52\u4f4d\u800c\u4e0d\u662f\u53ea\u9760\u9759\u6001\u521d\u59cb\u5316\u5668\u2014\u2014\n\t\t// \u70ed\u91cd\u8f7d\u4fdd\u7559\u65e7\u9759\u6001\u503c\uff0c\u5f00\u623f\u65f6\u91cd\u8bbe\u624d\u80fd\u81ea\u6108\uff1b\u540c\u4e00\u623f\u95f4\u8fde\u7eed\u591a\u5c40\n\t\t// \uff08P \u9000\u5c40/\u7ed3\u7b97\u56de\u623f\uff09\u4e0d\u8d70\u8fd9\u91cc\uff0c\u73a9\u5bb6\u6539\u8fc7\u7684\u9009\u62e9\u4fdd\u7559\n\t\tMatchState.PendingMode = MatchState.Mode.Territory;\n\n\t\t_menu?.Hide();\n\t\t_lobby?.ShowHost();\n\t}\n\n\t/// <summary> \u83dc\u5355\u72b6\u6001\u884c\uff08NetworkManager \u7684\u5f02\u6b65\u6d41\u7a0b\u4e5f\u7528\u5b83\u62a5\u8fdb\u5ea6\uff09 </summary>\n\tpublic void MenuStatus( string text ) => _menu?.SetStatus( text );\n\n\t/// <summary> \u5f00\u5c40\u516c\u5171\u5c3e\u5df4\uff1a\u85cf\u83dc\u5355\u3001\u4e0a HUD\uff08\u56de\u83dc\u5355\u540e\u518d\u5f00\u5c40\u590d\u7528\u5df2\u6709 HUD\uff0c\u4e0d\u91cd\u590d\u5efa\uff09\u3001\u5207\u6218\u6597\u66f2 </summary>\n\tvoid StartGame()\n\t{\n\t\t_joinRequested = false;\n\t\t_menu?.Hide();\n\n\t\tif ( _hud.IsValid() )\n\t\t{\n\t\t\t_hud.GameObject.Enabled = true;\n\t\t\tif ( _endBoard.IsValid() ) _endBoard.Hide();\n\t\t}\n\t\telse\n\t\t{\n\t\t\tCreateHud();\n\t\t}\n\n\t\tGameMusic.PlayBattle();\n\t}\n\n\t/// <summary>\n\t/// \u5ba2\u6237\u7aef\uff1a\u542f\u7528\u5feb\u7167\u4e16\u754c\u6839\uff08\u5e42\u7b49\uff1bEnsureWorld \u8c03\u4e00\u6b21 + ScanBalls \u6bcf\u79d2\u81ea\u6108\u2014\u2014\n\t/// \u9996\u6b21 Tick \u53ef\u80fd\u65e9\u4e8e\u5feb\u7167\u5e94\u7528\u5b8c\u6210\uff0c\u6251\u7a7a\u540e\u9760\u81ea\u6108\u515c\u4f4f\uff09\u3002\n\t/// \u542b\u7403\u5bf9\u8c61\u7684\u9876\u5c42 GO \u89c6\u4e3a\u5feb\u7167\u4e16\u754c\uff1a\u542f\u7528\u4e4b\uff0c\u5e76\u5265\u6389\u5176\u76f8\u673a/HUD \u5b50\u7269\u4f53\uff1b\u5176\u4f59\u9876\u5c42\u5bf9\u8c61\n\t/// \uff08\u6a21\u677f\u6b8b\u7559\uff09\u7167\u65e7\u7981\u7528\u3002\n\t/// </summary>\n\tvoid AdoptSnapshotWorld()\n\t{\n\t\tforeach ( var go in Scene.Children.ToArray() )\n\t\t{\n\t\t\tif ( !go.IsValid() || go == _root ) continue;\n\n\t\t\tvar hasBalls = false;\n\t\t\tforeach ( var b in go.GetComponentsInChildren<Ball>( true ) )\n\t\t\t{\n\t\t\t\thasBalls = true;\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tif ( !hasBalls )\n\t\t\t{\n\t\t\t\t// \u26a0\ufe0f \u53ea\u7981\u7528\"\u771f\u6b63\u7684\u6a21\u677f\u6b8b\u7559\"\u2014\u2014\u542b ScreenPanel \u7684\u662f\u6211\u4eec\u81ea\u5df1\u7684 UI \u9762\u677f\uff08\u5ba2\u6237\u7aef\u4e0a UI GO\n\t\t\t\t// \u53ef\u80fd\u56e0 Parent= \u9759\u9ed8\u5931\u6548\u53d8\u6210\u9876\u5c42\u5b64\u513f\uff09\uff0c\u7981\u7528\u5b83\u4eec\u4f1a\u89e6\u53d1\u6bcf\u79d2\u7981\u7528\u2194\u542f\u7528\u5faa\u73af\uff1a\n\t\t\t\t// PanelComponent \u6bcf\u6b21\u91cd\u65b0\u542f\u7528\u90fd\u91cd\u5efa\u9762\u677f\u6811 = HUD \u6bcf\u79d2\u6574\u6811\u91cd\u5efa = \u5ba2\u6237\u7aef\u7206\u95ea\uff08\u5b9e\u6d4b\uff09\n\t\t\t\tvar hasUi = false;\n\t\t\t\tforeach ( var sp in go.GetComponentsInChildren<ScreenPanel>( true ) ) { hasUi = true; break; }\n\t\t\t\tif ( hasUi )\n\t\t\t\t{\n\t\t\t\t\tif ( _strayUiLogged.Add( go.Name ) )\n\t\t\t\t\t\tLog.Info( $\"[game] keep stray top-level UI '{go.Name}' (no balls, has ScreenPanel)\" );\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tif ( go.Enabled )\n\t\t\t\t{\n\t\t\t\t\tLog.Info( $\"[game] disable stray top-level '{go.Name}' (no balls)\" );\n\t\t\t\t\tgo.Enabled = false;\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif ( !go.Enabled )\n\t\t\t{\n\t\t\t\tgo.Enabled = true;\n\t\t\t\tGameLog.Info( \"[game] snapshot world root enabled\" );\n\t\t\t}\n\n\t\t\tforeach ( var child in go.Children.ToArray() )\n\t\t\t{\n\t\t\t\tif ( !child.IsValid() ) continue;\n\t\t\t\t// \u5265\u6389 host \u5feb\u7167\u91cc\u81ea\u5e26\u7684\u76f8\u673a\u4e0e\u5168\u90e8 UI \u9762\u677f\uff08\u4f1a\u968f\u5feb\u7167\u590d\u5236\u8fc7\u6765\uff0c\u5ba2\u6237\u7aef\u7559\u7740\u5c31\u662f\n\t\t\t\t// \u53cc\u4efd\u754c\u9762/host \u89c6\u89d2\u7684\u9875\u9762\uff09\u2014\u2014\u53ea\u7528\u672c\u673a\u81ea\u5df1\u5efa\u7684\u90a3\u5957\u3002\n\t\t\t\t// \u26a0\ufe0f \u65b0\u589e UI \u9762\u677f\u5fc5\u987b\u540c\u6b65\u52a0\u8fdb\u8fd9\u4e2a\u540d\u5355\uff08RoomBrowser/Settings/Rankings \u66fe\u6f0f\uff0cv0.7.8.33 \u8865\uff09\n\t\t\t\tif ( child.Name == \"Camera\" || child.Name == \"Hud\" || child.Name == \"MainMenu\"\n\t\t\t\t\t|| child.Name == \"LobbyPanel\" || child.Name == \"EndBoard\"\n\t\t\t\t\t|| child.Name == \"RoomBrowser\" || child.Name == \"SettingsPanel\" || child.Name == \"RankingsPanel\" )\n\t\t\t\t{\n\t\t\t\t\tvar name = child.Name;\n\t\t\t\t\t// \u8bca\u65ad\uff08\u4e34\u65f6\uff09\uff1aLog.Info \u5ba2\u6237\u7aef\u4e5f\u6253\u5370\u2014\u2014\u786e\u8ba4\u5265\u79bb\u5230\u5e95\u5220\u7684\u662f\u8c01\u7684 UI\uff08\u5bbf\u4e3b\u5feb\u7167 vs \u672c\u673a\u7684\uff09\n\t\t\t\t\tLog.Info( $\"[game] stripped snapshot '{name}' (parent='{go.Name}')\" );\n\t\t\t\t\tchild.Destroy();\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// \u623f\u95f4\u5c31\u7eea\uff08host \u5efa\u5b8c\u5927\u5385 / \u79bb\u7ebf\u623f\u95f4\u65f6\u8c03\u7528\uff0cM5\uff09\uff1a\u751f\u6210**\u86f0\u4f0f\u7403**\u2014\u2014host \u7403 + \u6ee1\u7f16\u9884\u5907 bot\uff0c\n\t/// Alive=false \u4e0d\u53ef\u89c1\u4e0d\u6a21\u62df\u3002\u5f15\u64ce\u7ea6\u675f\uff1a\"\u8fdb\u884c\u4e2d NetworkSpawn \u4e0d\u590d\u5236\u5230\u5df2\u8fde\u63a5\u5ba2\u6237\u7aef\"\uff08\u5b9e\u6d4b\uff09\uff0c\n\t/// \u6240\u6709\u7403\u5fc5\u987b\u5728\u5404\u7aef\u521d\u59cb\u5feb\u7167\u524d\u5c31\u5b58\u5728\uff0c\u5f00\u5c40\uff08StartMatchFromLobby\uff09\u53ea\u6fc0\u6d3b\u3001\u4e0d\u518d\u65b0\u5efa\u3002\n\t/// \u98df\u7269/\u5c16\u523a\u662f\u7eaf RPC \u6570\u636e\uff08\u4e0d\u7ecf\u5feb\u7167\uff09\uff0c\u5f00\u5c40\u65f6\u624d\u751f\u6210\u3002\u5ba2\u6237\u7aef\u4e0d\u8c03\u7528\u3002\n\t/// </summary>\n\tpublic void OnLobbyOpened()\n\t{\n\t\tif ( _networkReady ) return;\n\t\t_networkReady = true;\n\n\t\tGameLog.Info( $\"[game] CircleRoyale {GameConfig.Version} lobby opened auth={NetworkManager.IsAuthority}\" );\n\t\tif ( !NetworkManager.IsAuthority ) return;\n\n\t\t// \u623f\u95f4\u8bbe\u7f6e\uff1aconvar \u8986\u5199\u9ed8\u8ba4\u503c\uff08arena \u7ecf Replicated \u540c\u6b65\u7ed9\u5ba2\u6237\u7aef\uff09\n\t\tGameConfig.ArenaHalfSize = Math.Clamp( GameConfig.ConvarArenaSize, 1024f, 12288f );\n\n\t\tSpawnBall( Vector3.Zero, isBot: false, owner: null, dormant: true );\n\t\tfor ( int i = 1; i < GameConfig.LobbyReserveBalls; i++ )\n\t\t{\n\t\t\tSpawnBall( RandomSpawnPos(), isBot: true, owner: null, dormant: true );\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// \u623f\u95f4\u5185 START\uff08host\uff09\uff1a\u89c4\u5219\u751f\u6548\u3001\u6295\u98df/\u5c16\u523a\u3001\u6fc0\u6d3b\u86f0\u4f0f\u7403\uff08\u8fdc\u7a0b\u771f\u4eba/host/bot \u8865\u9f50\u5230\u76ee\u6807\u6570\uff09\u3001\n\t/// \u7ed9\u7b49\u5728\u623f\u95f4\u91cc\u7684\u5ba2\u6237\u7aef\u5b9a\u5411\u8865\u53d1\u98df\u7269\u5c16\u523a\u3001\u5e7f\u64ad\u5f00\u5c40\u3002\u961f\u4f0d\u5206\u914d\u4e5f\u5728\u8fd9\u91cc\u505a\uff08\u6b64\u523b\u6a21\u5f0f\u624d\u786e\u5b9a\uff09\u3002\n\t/// </summary>\n\tpublic void StartMatchFromLobby()\n\t{\n\t\tif ( !_lobbyOpen || _gameStarted ) return;\n\t\t_lobbyOpen = false;\n\t\t_gameStarted = true;\n\n\t\tMatchState.ActivateForMatch();\n\t\t_teamSeq = 0;\n\t\tClearMatchState();   // \u5f00\u5c40\u4e5f\u6e05\u4e00\u904d\uff08v0.7.8.87\uff09\uff1a\u4ece\u83dc\u5355\u76f4\u63a5\u5f00\u7684\u7b2c\u4e00\u5c40\u3001\u4eba\u673a\u5c40\u4e0d\u7ecf\u8fc7 ResetToLobby\n\n\t\t_food.Init();\n\t\tGenerateSpikes();\n\t\t_power.Init();\n\n\t\t// \u6bcf\u5c40\u7403\u7ec4\u590d\u4f4d\uff08\u7403\u5bf9\u8c61\u6c38\u4e0d\u9500\u6bc1\u2014\u2014v0.6.4.2 \u8fde\u7eed\u591a\u5c40\uff09\uff1a\u771f\u4eba\u7403\uff08\u8fdc\u7a0b+host\uff09\u5168\u90e8\u590d\u4f4d\uff0c\n\t\t// bot \u6309\u5269\u4f59\u540d\u989d\u6fc0\u6d3b\u3001\u591a\u4f59\u7684\u86f0\u4f0f\uff1b\u4e0a\u4e00\u5c40\u7684\u8d28\u91cf/\u751f\u6b7b\u6001\u4e00\u5e76\u6e05\u96f6\n\t\tint remotes = 0;\n\t\tforeach ( var b in _balls )\n\t\t{\n\t\t\tif ( !b.IsValid() ) continue;\n\t\t\tif ( !b.IsRemoteOwned && b.IsBot ) continue;   // bot \u8d70\u4e0b\u9762\u7684\u540d\u989d\u8865\u9f50\n\n\t\t\tb.TeamIndex = -1;   // \u6e05\u4e0a\u4e00\u5c40\u6b8b\u7559\u961f\u4f0d\u53f7\uff08v0.7.8.87\uff09\uff1a\u975e\u56e2\u961f\u5c40\u5fc5\u987b\u5f52 -1\uff0c\u5426\u5219\u540c\u53f7\u4e92\u4e0d\u5403\n\t\t\tRespawnBall( b, NextTeamIndex(), cue: false );\n\t\t\tif ( b.IsRemoteOwned ) remotes++;\n\t\t}\n\n\t\tint botsWanted = Math.Clamp( MatchState.PlayerTarget - 1 - remotes, 0, GameConfig.LobbyReserveBalls - 1 );\n\t\tint bots = 0;\n\t\tforeach ( var b in _balls )\n\t\t{\n\t\t\tif ( !b.IsValid() || !b.IsBot ) continue;\n\n\t\t\tb.TeamIndex = -1;   // \u540c\u4e0a\uff1abot \u7403\u4e5f\u662f\u8de8\u5c40\u590d\u7528\u7684\uff08\u86f0\u4f0f\u7684\u4e5f\u8981\u6e05\uff0c\u907f\u514d\u4e0b\u6b21\u6fc0\u6d3b\u5e26\u65e7\u961f\u53f7\uff09\n\n\t\t\tif ( bots < botsWanted )\n\t\t\t{\n\t\t\t\tRespawnBall( b, NextTeamIndex(), cue: false );\n\t\t\t\tbots++;\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tb.Alive = false;   // \u591a\u4f59 bot \u86f0\u4f0f\uff08\u4e0d\u53ef\u89c1\u4e0d\u6a21\u62df\uff09\n\t\t\t\tb.Dormant = true;\n\t\t\t\tb.Mass = GameConfig.StartMass;   // \u26a0\ufe0f \u5fc5\u987b\u6e05\u96f6\uff08v0.7.8.105\uff0c\u5ba1\u8ba1 #2\uff09\uff1a\n\t\t\t\t// \u86f0\u4f0f\u7403\u8de8\u5c40\u590d\u7528\u4e14\u4e0d\u53c2\u4e0e\u6a21\u62df\uff0c\u4fdd\u7559\u4e0a\u5c40\u8d28\u91cf\u4f1a\u6c61\u67d3\u7ed3\u7b97\u6392\u540d\uff08\u89c1 EndMatch \u6ce8\u91ca\uff09\uff0c\n\t\t\t\t// \u4e5f\u4f1a\u8ba9\u5b83\u88ab\u6fc0\u6d3b\u65f6\u4ee5\u5de8\u7403\u5f00\u5c40\n\t\t\t}\n\t\t}\n\n\t\t// \u7b49\u5728\u623f\u95f4\u91cc\u7684\u5ba2\u6237\u7aef\u6ca1\u6536\u5230\u8fc7\u98df\u7269/\u5c16\u523a\uff08OnActive \u65f6\u8fd8\u6ca1\u751f\u6210\uff09\uff1a\u9010\u4e00\u5b9a\u5411\u8865\u53d1\n\t\tvar foods = _food.Foods;\n\t\tvar spikes = _spikes;\n\t\tvar powerWires = _power.ToWireForSync();\n\t\tforeach ( var conn in Connection.All )\n\t\t{\n\t\t\tif ( conn is null || !conn.IsActive ) continue;\n\t\t\tif ( Connection.Local is not null && conn.Id == Connection.Local.Id ) continue;\n\n\t\t\tusing ( Rpc.FilterInclude( conn ) )\n\t\t\t{\n\t\t\t\tif ( foods is not null ) NetworkManager.FoodFull( foods );\n\t\t\t\tif ( spikes is not null ) NetworkManager.SpikesFull( spikes );\n\t\t\t\tif ( powerWires is not null ) NetworkManager.PowerUpFull( powerWires );\n\t\t\t}\n\t\t}\n\n\t\tif ( Networking.IsActive ) NetworkManager.MatchStarted();   // \u65e0\u4f1a\u8bdd\u4e0d\u5e7f\u64ad\n\t\tOnMatchStartedLocal();\n\n\t\tGameLog.Info( $\"[match] started \u2014 mode={MatchState.CurrentMode} target={MatchState.PlayerTarget} remotes={remotes} bots={bots}\" );\n\t}\n\n\t/// <summary> \u56e2\u961f\u8d5b\u6309\u5757\u5206\u961f\uff08v0.6.5.0 \u4fee bug\uff1a\u539f %TeamSize \u8f6e\u8f6c=\u53ea\u6709 TeamSize \u4e2a\u961f\u3001\u6bcf\u961f\u5341\u51e0\u4eba\uff1b\n\t/// \u7528\u6237\u8bed\u4e49\u662f\"\u6bcf\u961f N \u4eba\"\u2192 48 \u4eba 3 \u4eba\u961f\u5e94\u5206\u51fa 16 \u961f\uff09\u3002\u666e\u901a\u8d5b\u6052 -1 </summary>\n\tint NextTeamIndex()\n\t{\n\t\tif ( !MatchState.IsTeam ) return -1;\n\t\tvar team = _teamSeq++ / MatchState.TeamSize;\n\t\t// \u9886\u571f\u56fa\u5b9a 4 \u961f\uff08v0.7.8.35\uff09\uff1a\u771f\u4eba\u8d85\u7f16\uff08\u5927\u4e8e 16\uff09\u65f6\u6324\u8fdb\u65e2\u6709\u961f\uff0c\u7edd\u4e0d\u5f00\u7b2c 5 \u961f\u2014\u2014\u5927\u672c\u8425\u53ea\u6709\u56db\u89d2\n\t\treturn MatchState.IsTerritory ? Math.Min( team, 3 ) : team;\n\t}\n\n\t/// <summary> \u4e24\u9897\u7403\u662f\u5426\u540c\u961f\uff08TeamIndex \u5c0f\u4e8e 0 = \u666e\u901a\u8d5b/\u672a\u5206\u961f\uff0c\u4e0d\u7b97\u540c\u961f\uff09\u3002\n\t/// \u26a0\ufe0f \u5fc5\u987b\u5e26 MatchState.IsTeam \u95e8\u7981\uff08v0.7.8.87 \u4fee\uff09\uff1a\u7403\u5bf9\u8c61\u8de8\u5c40\u590d\u7528\uff0c\u56e2\u961f/\u9886\u571f\u5c40\u7559\u4e0b\u7684\n\t/// TeamIndex \u4f1a\u4e00\u76f4\u6302\u5728\u7403\u4e0a\uff0c\u666e\u901a\u8d5b\u91cc\"\u540c\u53f7\u5373\u540c\u961f\"\u4f1a\u8ba9\u6574\u7ec4\u4eba\u4e92\u76f8\u5403\u4e0d\u52a8\uff08\u63a8\u6324\u4ecd\u751f\u6548\uff0c\u53ea\u63a8\u4e0d\u5403\uff09 </summary>\n\tstatic bool SameTeam( Ball a, Ball b ) =>\n\t\tMatchState.IsTeam && a.TeamIndex >= 0 && a.TeamIndex == b.TeamIndex;\n\n\t/// <summary> \u63a8\u6324\u7528\uff1a\u4e24\u4e2a SteamId \u662f\u5426\u540c\u9635\u8425\uff08\u540c\u4e00 SteamId=\u81ea\u5df1\u7684\u8eab\u4f53\uff1b\u56e2\u961f\u8d5b\u6309\u7403\u4e0a\u7684 TeamIndex \u67e5\uff09\u3002\n\t/// \u540c\u9635\u8425\u4e0d\u63a8\u6324\uff08BoB \u60ef\u4f8b\uff1a\u81ea\u5df1\u548c\u961f\u53cb\u53ef\u91cd\u53e0\uff09 </summary>\n\tpublic bool SameTeamSteam( long a, long b )\n\t{\n\t\tif ( a == b ) return true;\n\t\tif ( !MatchState.IsTeam ) return false;\n\t\tvar ta = TeamIndexOfSteam( a );\n\t\treturn ta >= 0 && ta == TeamIndexOfSteam( b );\n\t}\n\n\t/// <summary> \u56e2\u961f\u8d5b\u9635\u8425\u6620\u5c04\uff08SteamId \u2192 \u961f\u4f0d\u53f7\uff09\uff1a\u8fd4\u56de**\u590d\u7528\u7f13\u51b2**\u2014\u2014\u6bcf\u5e27 TickEating/TickCells/\n\t/// TickSpikeMinions \u4e09\u5904\u5148\u540e\u6784\u5efa\u4f7f\u7528\uff0c\u8c03\u7528\u65b9\u5f53\u5e27\u7528\u5b8c\u5373\u5f03\uff0c\u4e0d\u5f97\u8de8\u65b9\u6cd5/\u8de8\u5e27\u6301\u6709\uff1b\n\t/// \u666e\u901a\u8d5b\u8fd4\u56de\u7a7a\u6620\u5c04 </summary>\n\tDictionary<long, int> BuildTeamMap()\n\t{\n\t\t_teamScratch.Clear();\n\t\tif ( !MatchState.IsTeam ) return _teamScratch;\n\n\t\tforeach ( var b in _balls )\n\t\t{\n\t\t\tif ( b.IsValid() && b.TeamIndex >= 0 ) _teamScratch[b.OwnerSteamId] = b.TeamIndex;\n\t\t}\n\t\treturn _teamScratch;\n\t}\n\n\tint TeamIndexOfSteam( long sid )\n\t{\n\t\tforeach ( var b in _balls )\n\t\t{\n\t\t\tif ( b.IsValid() && b.OwnerSteamId == sid && b.TeamIndex >= 0 ) return b.TeamIndex;\n\t\t}\n\t\treturn -1;\n\t}\n\n\t/// <summary> eater \u4e0e\u5b62\u5b50\u4e3b\u4eba\u662f\u5426\u540c\u9635\u8425\uff08v0.7.8.23\uff09\uff1a\u540c\u4e00 SteamId\uff08\u81ea\u5df1\uff09\uff0c\n\t/// \u6216\u56e2\u961f\u8d5b\u4e0b\u67e5 teamOf \u540c\u961f\u3002\u540c\u9635\u8425\u8d70 0.2s \u5feb\u6863\uff0c\u654c\u4eba 0.5s\u3002\n\t/// \u6ce8\uff1abot \u7684 OwnerSteamId \u5168\u662f 0\u2014\u2014bot \u4e4b\u95f4\u7684 blob \u6052\u5224\"\u81ea\u5df1\"\u5feb\u6863\uff08\u65e2\u6709 SteamId 0 \u8bbe\u8ba1\u7684\u65e2\u6709\u6a21\u7cca\uff0c\u65e0\u5bb3\uff09 </summary>\n\tstatic bool SameSide( long blobOwner, long eater, Dictionary<long, int> teamOf )\n\t{\n\t\tif ( eater == blobOwner ) return true;\n\t\treturn MatchState.IsTeam\n\t\t\t&& teamOf.TryGetValue( eater, out var et )\n\t\t\t&& teamOf.TryGetValue( blobOwner, out var bt )\n\t\t\t&& et == bt;\n\t}\n\n\t/// <summary> \u5f00\u5c40\u843d\u5730\uff08\u672c\u673a\uff09\uff1a\u51fa\u623f\u95f4\u3001\u4e0a HUD\u3001\u5207\u6218\u6597\u66f2\uff08host \u76f4\u8c03\uff1b\u5ba2\u6237\u7aef\u7ecf RPC\uff09\u3002\n\t/// \u5ba2\u6237\u7aef\u5fc5\u987b\u5728\u8fd9\u91cc\u7f6e _gameStarted\u2014\u2014host \u7684\u7f6e\u4f4d\u5728 MenuAction \u91cc\uff0c\u5ba2\u6237\u7aef\u6ca1\u6709\u90a3\u6761\u8def\u5f84\uff0c\n\t/// \u4e0d\u7f6e\u4f4d\u7684\u8bdd Tick \u7684\"\u8fde\u4e0a\u4e86\u5c31\u8fdb\u623f\u95f4\"\u5224\u5b9a\u4f1a\u5728 MatchStarted \u4e4b\u540e\u628a\u623f\u95f4\u9875\u76d6\u56de\u6e38\u620f\u4e0a\uff08\u5b9e\u6d4b\uff09\u3002</summary>\n\tvoid OnMatchStartedLocal()\n\t{\n\t\t_lobbyWaiting = false;\n\t\t_lobbyHeartbeatArmed = false;   // \u51fa\u623f\u95f4\uff1a\u623f\u95f4\u5fc3\u8df3\u8ba9\u4f4d\u7ed9\u5bf9\u5c40\u5fc3\u8df3\uff08MatchTick\uff09\n\t\t_lobby?.Hide();\n\t\tStartGame();\n\t\t_gameStarted = true;\n\t\t_joinRequested = false;\n\t}\n\n\t/// <summary> \u5ba2\u6237\u7aef\uff1a\u623f\u4e3b\u70b9\u4e86 START\uff08host \u56de\u58f0\u7528 IsAuthority \u6321\u6389\u2014\u2014host \u5df2\u76f4\u8c03\uff09 </summary>\n\tpublic void OnMatchStartedRemote()\n\t{\n\t\tif ( NetworkManager.IsAuthority ) return;\n\t\tGameLog.Info( \"[match] host started the game\" );\n\n\t\t// \u7528\u6237\u5b9a\u7a3f\uff08v0.6.4.1\uff09\uff1a\u5728\u623f\u95f4\u91cc\u7684\u5ba2\u6237\u7aef**\u4e0d\u81ea\u52a8\u8fdb\u5c40**\u2014\u2014\u623f\u95f4\u9875\u4eae\u8d77 JOIN GAME \u6309\u94ae\uff0c\n\t\t// \u7531\u73a9\u5bb6\u70b9\u51fb\u540e\u8d70 3 \u79d2\u52a0\u5165\u5012\u8ba1\u65f6\u518d\u8fdb\u5165\uff1b\u4e0d\u5728\u623f\u95f4\uff08\u5f02\u5e38\u6001\uff09\u5219\u76f4\u63a5\u8fdb\n\t\tif ( _lobbyWaiting )\n\t\t{\n\t\t\t_matchRunning = true;\n\t\t\t_lobby?.SetMatchRunning();\n\t\t\treturn;\n\t\t}\n\t\tOnMatchStartedLocal();\n\t}\n\n\t/// <summary> host \u53d6\u6d88\u5bf9\u5c40\uff08P \u952e\u5feb\u901f\u9000\u5c40\u7684\u5e7f\u64ad\u843d\u5730\uff0c\u4ec5\u5ba2\u6237\u7aef\uff09\uff1a\u56de\u623f\u95f4\u7b49\u5f85\u9875 </summary>\n\tpublic void OnMatchCancelledRemote()\n\t{\n\t\tif ( NetworkManager.IsAuthority ) return;   // host \u81ea\u5df1\u5df2\u76f4\u63a5\u5904\u7406\n\t\tGameLog.Info( \"[match] host cancelled the match \u2014 back to lobby\" );\n\t\tResetToLobby();\n\t}\n\n\t/// <summary> \u5ba2\u6237\u7aef\u70b9\u51fb\u623f\u95f4\u9875\u7684 JOIN GAME\uff1a\u5bf9\u5c40\u8fdb\u884c\u4e2d \u2192 3 \u79d2\u52a0\u5165\u5012\u8ba1\u65f6 \u2192 \u8fdb\u5165 </summary>\n\tpublic void ClientJoinMatch()\n\t{\n\t\tif ( !_matchRunning || _gameStarted || _joinCountdown >= 0f ) return;\n\t\tif ( !_lobbyWaiting ) return;\n\n\t\t_joinCountdown = GameConfig.JoinCountdownSeconds;\n\t\t_lobby?.ShowJoinCountdown( GameConfig.JoinCountdownSeconds );\n\t\tGameLog.Info( \"[match] client joining via button\" );\n\t}\n\n\t/// <summary> \u81ea\u52a8\u91cd\u8fde watchdog \u53d1\u8d77\u91cd\u8fde\uff08NetworkManager \u8c03\u7528\uff09\uff1a\u91cd\u65b0\u8d70\u6807\u51c6\u52a0\u5165\u6d41 </summary>\n\tpublic void NotifyReconnectStarted()\n\t{\n\t\t_joinRequested = true;\n\t\t_sinceJoinRequest = 0;\n\t}\n\n\t// ---- \u52a0\u5165\u6d41\u8f85\u52a9\uff08\u623f\u95f4\u5217\u8868\u9875 v0.6.7.0\uff09----\n\n\t/// <summary> \u5ba2\u6237\u7aef\u53d1\u8d77\u52a0\u5165\uff08\u70b9\u623f\u95f4/\u5feb\u901f\u52a0\u5165/\u672c\u5730\u76f4\u8fde\u5171\u7528\uff09\uff1a\u7f6e\u8bf7\u6c42\u6807\uff0cTick \u6355\u83b7\u4f1a\u8bdd\u6fc0\u6d3b\u6536\u5c3e </summary>\n\tpublic void OnJoinAttemptStarted()\n\t{\n\t\t_joinRequested = true;\n\t\t_sinceJoinRequest = 0;\n\t}\n\n\t/// <summary> \u52a0\u5165\u5931\u8d25\u63d0\u793a\uff1a\u6d4f\u89c8\u5668\u9875\u5f00\u7740\u663e\u793a\u5728\u90a3\uff0c\u4e3b\u83dc\u5355\u72b6\u6001\u884c\u662f\u515c\u5e95\u3002\n\t/// \u26a0\ufe0f \u5fc5\u987b\u540c\u65f6\u590d\u4f4d\u623f\u95f4\u5217\u8868\u9875\u7684 _joining\uff08v0.7.8.105\uff0c\u5ba1\u8ba1 #4\uff09\uff1a\u5b83\u53ea\u5728 Show/Hide \u91cc\u6e05\uff0c\n\t/// \u5931\u8d25\u540e\u4e00\u76f4\u4e3a true \u2192 \u6574\u4e2a\u623f\u95f4\u9875\u6240\u6709\u884c\u4e0e REFRESH/JOIN LOCAL \u5168\u90e8\u70b9\u51fb\u5931\u6548\u3002 </summary>\n\tpublic void OnJoinAttemptFailed( string message )\n\t{\n\t\t_browser?.OnJoinDenied();\n\t\t_browser?.SetStatus( message );\n\t}\n\n\t/// <summary> \u540c\u673a\u8c03\u8bd5\u76f4\u8fde\uff08\u623f\u95f4\u5217\u8868\u9875 JOIN LOCAL\uff09\uff1alocal \u5730\u5740 = \u672c\u673a host </summary>\n\tpublic void JoinLocalRoom()\n\t{\n\t\tOnJoinAttemptStarted();\n\t\t_net?.StartJoin( GameConfig.DefaultJoinAddress );\n\t\t_browser?.SetStatus( \"CONNECTING TO LOCAL HOST ...\" );\n\t}\n\n\t/// <summary> \u623f\u95f4\u5217\u8868\u9875\u8fd4\u56de\u4e3b\u83dc\u5355\uff08BACK\uff09 </summary>\n\tpublic void CloseBrowser()\n\t{\n\t\t_browser?.Hide();\n\t\t_menu?.Show();\n\n\t\t// JOIN \u6253\u5f00\u5217\u8868\u65f6 StopMenuDemo \u6e05\u6389\u4e86\u80cc\u666f\u6f14\u793a\u8d5b\u2014\u2014\u56de\u83dc\u5355\u5fc5\u987b\u91cd\u65b0\u5f00\u8d77\u6765\uff0c\u5426\u5219\u80cc\u666f\u4e0d\u52a8\uff08\u7528\u6237\u5b9e\u6d4b\uff09\n\t\tif ( !_gameStarted && !_lobbyOpen && NetworkManager.IsAuthority && !Networking.IsActive )\n\t\t\tStartMenuDemo();\n\t}\n\n\t/// <summary> \u8bbe\u7f6e\u9875\u6536\u8d77\uff08SettingsPanel BACK \u8c03\u7528\uff09\uff1a\u56de\u4e3b\u83dc\u5355 </summary>\n\tpublic void CloseSettings()\n\t{\n\t\t_settings?.Hide();\n\t\t_menu?.Show();\n\t}\n\n\t/// <summary> \u6392\u884c\u9875\u6536\u8d77\uff08RankingsPanel BACK \u8c03\u7528\uff09\uff1a\u56de\u4e3b\u83dc\u5355 </summary>\n\tpublic void CloseRankings()\n\t{\n\t\t_rankings?.Hide();\n\t\t_menu?.Show();\n\t}\n\n\t/// <summary> \u5ba2\u6237\u7aef\u6389\u7ebf\uff0c\u81ea\u52a8\u91cd\u8fde\u5f00\u59cb\uff08NetworkManager \u8c03\u7528\uff09\uff1a\u754c\u9762\u7559\u5728\u539f\u5730 + \u623f\u95f4\u9875\u72b6\u6001\u884c\u63d0\u793a </summary>\n\tpublic void OnDisconnectRecoveryStarted()\n\t{\n\t\tif ( _lobbyWaiting ) _lobby?.SetConnectionLost();\n\t\tLog.Warning( \"[net] connection lost \u2014 staying put, auto reconnecting\" );\n\t}\n\n\t/// <summary> \u81ea\u52a8\u91cd\u8fde\u8017\u5c3d\uff08NetworkManager \u8c03\u7528\uff09\uff1a\u56de\u4e3b\u83dc\u5355\uff08v0.6.4.6 \u7528\u6237\u5b9a\u7a3f\uff09 </summary>\n\tpublic void OnReconnectFailed()\n\t{\n\t\tResetToMenu();\n\t\t_menu?.SetStatus( \"CONNECTION LOST \u2014 COULD NOT REACH HOST\" );\n\t}\n\n\t// ---- \u623f\u95f4\u4fe1\u606f\u540c\u6b65\uff08host \u2192 \u7b49\u5f85\u4e2d\u7684\u5ba2\u6237\u7aef\uff09----\n\n\t/// <summary> \u623f\u95f4\u73a9\u5bb6\u540d\u5355\uff1ahost \u81ea\u5df1 + \u6240\u6709\u6d3b\u8dc3\u8fde\u63a5\uff08\u987a\u5e8f\u5373\u5217\u8868\u987a\u5e8f\uff0c0 \u53f7\u662f\u623f\u4e3b\uff09 </summary>\n\tpublic List<string> LobbyPlayerNames()\n\t{\n\t\tvar list = new List<string>();\n\t\ttry\n\t\t{\n\t\t\tif ( Connection.Local is not null ) list.Add( Connection.Local.DisplayName );\n\n\t\t\tforeach ( var c in Connection.All )\n\t\t\t{\n\t\t\t\tif ( c is null || !c.IsActive ) continue;\n\t\t\t\tif ( Connection.Local is not null && c.Id == Connection.Local.Id ) continue;\n\t\t\t\tlist.Add( c.DisplayName );\n\t\t\t}\n\t\t}\n\t\tcatch\n\t\t{\n\t\t\t// \u4f1a\u8bdd\u672a\u5c31\u7eea\u65f6\u7ed9\u7a7a\u540d\u5355\uff0c\u754c\u9762\u663e\u793a\u5360\u4f4d\n\t\t}\n\t\treturn list;\n\t}\n\n\t/// <summary> \u540d\u5355/\u53c2\u6570\u53d8\u4e86\uff08\u7f51\u7edc\u7ebf\u7a0b\u53ea\u7f6e\u810f\u6807\uff0c\u4e3b\u7ebf\u7a0b Tick \u7edf\u4e00\u5e7f\u64ad\uff09 </summary>\n\tpublic void MarkLobbyDirty() => _lobbyListDirty = true;\n\n\tvoid BroadcastLobbyState()\n\t{\n\t\tNetworkManager.LobbyState( (byte)MatchState.PendingMode, (int)MatchState.PendingDuration,\n\t\t\tMatchState.PendingPlayerTarget, LobbyPlayerNames().ToArray() );\n\t}\n\n\t/// <summary> \u5ba2\u6237\u7aef\u5e94\u7528\u623f\u4e3b\u63a8\u9001\u7684\u623f\u95f4\u4fe1\u606f\uff08host \u56de\u58f0\u7528 IsAuthority \u6321\u6389\uff09 </summary>\n\tpublic void OnLobbyStateRemote( byte mode, int duration, int players, string[] names )\n\t{\n\t\tif ( NetworkManager.IsAuthority ) return;\n\n\t\t// \u623f\u95f4\u5fc3\u8df3\uff1ahost \u6bcf 3s \u63a8\u4e00\u6b21\uff08\u540d\u5355\u6ca1\u53d8\u4e5f\u63a8\uff09\uff0c\u6536\u5230\u5373\u5f52\u96f6\n\t\tif ( _lobbyWaiting )\n\t\t{\n\t\t\t_lobbyHeartbeatArmed = true;\n\t\t\t_sinceLobbyHeartbeat = 0;\n\t\t}\n\n\t\t_lobbyMode = mode;\n\t\t_lobbyDuration = duration;\n\t\t_lobbyPlayers = players;\n\t\t_lobbyNames = names ?? Array.Empty<string>();\n\t\t_lobby?.UpdateFromHost( mode, duration, players, names );\n\t}\n\n\t/// <summary> \u5ba2\u6237\u7aef\uff1a\u4e3b\u7403\u88ab\u5403\uff0c\u6700\u5927\u5206\u8eab\u5df2\u664b\u5347\u6210\u65b0\u4e3b\u7403\u2014\u2014\u628a\u7403\u77ac\u79fb\u5230\u664b\u5347\u4f4d\u7f6e\u7ee7\u7eed\u6a21\u62df\n\t/// \uff08\u8d28\u91cf\u7ecf [Sync] \u5230\u8fbe\uff1bhost \u56de\u58f0\u7528 IsAuthority \u6321\u6389\uff09\u3002 </summary>\n\tpublic void OnMainPromotedRemote( float x, float y )\n\t{\n\t\tif ( NetworkManager.IsAuthority ) return;\n\n\t\tvar ball = LocalBall;\n\t\tif ( !ball.IsValid() ) return;\n\n\t\tball.TeleportTo( new Vector3( x, y, 0f ) );\n\t\tGameLog.Info( $\"[game] main promoted -> continue at ({x:0},{y:0})\" );\n\t}\n\n\t/// <summary> host\uff1a\u6807\u8bb0\u4e00\u6b21\u5408\u6cd5\u77ac\u79fb\uff08\u4f4d\u79fb\u6821\u9a8c\u951a\u5230\u65b0\u4f4d\u7f6e + \u5f00\u5bbd\u9650\u671f\uff09\u2014\u2014\u6307\u6325\u5b98\u6280\u80fd/\u664b\u5347\u5171\u7528\u8bed\u4e49 </summary>\n\tpublic void MarkTeleport( Ball ball, Vector3 pos )\n\t{\n\t\t_lastPos[ball.Id] = pos;\n\t\t_teleportGrace[ball.Id] = 0;\n\t}\n\n\t/// <summary> \u9759\u6001 RPC \u843d\u5730\uff1a\u6307\u6325\u5b98\u6280\u80fd\u4f20\u9001\u2014\u2014\u672c\u673a\u7403\u77ac\u79fb\u5230\u961f\u53cb\u8eab\u8fb9\uff08host \u5df2\u5148\u951a\u5b9a\u6821\u9a8c\u4f4d\uff09 </summary>\n\tpublic void OnClassTeleportRemote( long steamId, float x, float y )\n\t{\n\t\tif ( NetworkManager.IsAuthority ) return;\n\n\t\t// \u8eab\u4efd\u5224\u5b9a\u8d70\u672c\u673a\u7403\u7684 OwnerSteamId\uff08v0.7.8.87 \u4fee\uff09\uff1a\u53d1\u5305\u65b9\u7528\u7684\u662f Connection.SteamId\uff0c\n\t\t// \u540c\u673a\u53cc\u5f00\u65f6\u5b83\u4e0e Game.SteamId \u5bf9\u4e0d\u4e0a\uff08\u672c\u6587\u4ef6\u4e0e NetworkManager \u90fd\u6709\u8be5\u5b9e\u6d4b\u6ce8\u91ca\uff09\uff0c\n\t\t// \u539f\u6765\u6309 Game.SteamId \u6bd4\u5bf9\u4f1a\u8ba9\u53cc\u5f00\u5ba2\u6237\u7aef\u7684\u4f20\u9001\u9759\u9ed8\u5931\u6548\n\t\tvar ball = LocalBall;\n\t\tif ( !ball.IsValid() ) return;\n\t\tif ( ball.OwnerSteamId != steamId ) return;\n\n\t\tball.TeleportTo( new Vector3( x, y, 0f ) );\n\t\tGameLog.Info( $\"[class] commander teleport -> ({x:0},{y:0})\" );\n\t}\n\n\t/// <summary> host\uff1a\u4e3a\u6307\u5b9a\u73a9\u5bb6\u65bd\u653e\u804c\u4e1a\u6280\u80fd\uff08\u5ba2\u6237\u7aef RPC \u5165\u53e3\uff1b\u51b7\u5374/\u6761\u4ef6\u5728\u7ba1\u7406\u5668\u5185\u6821\u9a8c\uff09 </summary>\n\tpublic void CastClassSkillFor( long steamId )\n\t{\n\t\tforeach ( var b in _balls )\n\t\t{\n\t\t\tif ( b.IsValid() && !b.IsBot && b.Alive && b.OwnerSteamId == steamId )\n\t\t\t{\n\t\t\t\tClassSkillManager.Cast( b );\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t}\n\n\t/// <summary> \u9759\u6001 RPC \u843d\u5730\uff1a\u4e3a\u6307\u5b9a\u8fde\u63a5\u539f\u5730\u590d\u6d3b\u5176\u7403\uff08\u6309 OwnerSteamId \u5339\u914d\uff09\u3002\n\t/// \u26a0\ufe0f \u95e8\u7981\uff08v0.7.8.105\uff0c\u5ba1\u8ba1 #6\uff09\uff1a\u5fc5\u987b\"\u5bf9\u5c40\u8fdb\u884c\u4e2d\u4e14\u672a\u7ed3\u7b97\"\uff0c\u4e14\u4e0d\u78b0\u86f0\u4f0f\u7403\u3002\n\t/// \u6b64\u524d\u53ea\u67e5\u8fde\u63a5\u6709\u6548\u2014\u2014\u623f\u95f4\u671f\u6bcf\u9897\u7403\u90fd\u662f Alive=false,Dormant=true\uff0c\u4efb\u4f55\u5ba2\u6237\u7aef\u90fd\u80fd\u5728 host\n\t/// \u70b9 START \u4e4b\u524d\u8c03\u7528 RequestRespawn \u628a\u81ea\u5df1\u590d\u6d3b\u5e76\u5206\u5230\u961f\u4f0d\uff1b\u7ed3\u7b97\u671f\u540c\u7406\uff08\u51bb\u7ed3\u88ab\u7ed5\u8fc7\uff09\u3002\n\t/// \u5ba2\u6237\u7aef\u4fa7 TryRespawnLocal \u672c\u6765\u6709\u95e8\u7981\uff0c\u4f46 RPC \u5165\u53e3\u5b8c\u5168\u7ed5\u8fc7\u4e86\u5b83\u3002 </summary>\n\tpublic void RespawnConnection( Connection conn, bool frontLine = false )\n\t{\n\t\tif ( conn is null || !conn.IsActive ) return;\n\t\tif ( !_gameStarted || MatchState.MatchOver ) return;\n\n\t\tvar steamId = conn.SteamId.Value;\n\t\tforeach ( var b in _balls )\n\t\t{\n\t\t\tif ( b.IsValid() && !b.IsBot && !b.Alive && !b.Dormant && b.OwnerSteamId == steamId )\n\t\t\t{\n\t\t\t\tRespawnBall( b, frontLine: frontLine );\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\tGameLog.Info( $\"[net] respawn request: no dead ball for '{conn.DisplayName}'\" );\n\t}\n\n\t// ---- \u98df\u7269\u8fdc\u7aef\u4e8b\u4ef6\u5e94\u7528\uff08\u4ec5\u5ba2\u6237\u7aef\uff1bhost \u7684\u5e7f\u64ad\u56de\u58f0\u7528 IsAuthority \u6321\u6389\uff09----\n\n\tpublic void OnFoodEatenRemote( int index, long eaterSteamId )\n\t{\n\t\tif ( NetworkManager.IsAuthority ) return;\n\n\t\t// \u5e94\u7528\u524d\u5148\u53d6\u4f4d\u7f6e/\u989c\u8272\u653e\u706b\u82b1\uff08ApplyEaten \u4f1a\u7f6e Alive=false\uff0c\u4f46\u6570\u636e\u4ecd\u5728\u2014\u2014\u53d6\u66f4\u4fdd\u9669\uff09\n\t\tvar fs = _food?.Foods;\n\t\tif ( fs is not null && (uint)index < (uint)fs.Length )\n\t\t{\n\t\t\tvar pos = new Vector3( fs[index].Pos.x, fs[index].Pos.y, 0f );\n\t\t\tNeonRenderer.Spark( pos, fs[index].ColorIndex );\n\t\t\tif ( GameSfx.IsMine( eaterSteamId ) )\n\t\t\t\tGameSfx.EatFood( pos );\n\t\t}\n\n\t\t_food?.ApplyEaten( index );\n\t}\n\n\tpublic void OnFoodRespawnedRemote( int index, FoodData food )\n\t{\n\t\tif ( NetworkManager.IsAuthority ) return;\n\t\t_food?.ApplyRespawned( index, food );\n\t}\n\n\tpublic void OnFoodFullRemote( FoodData[] foods )\n\t{\n\t\tif ( NetworkManager.IsAuthority ) return;\n\t\t_food?.ApplyFull( foods );\n\t\tGameLog.Info( $\"[game] food full sync received: {foods.Length}\" );\n\t}\n\n\t/// <summary> \u5ba2\u6237\u7aef\u5e94\u7528\u7eff\u523a\u5168\u91cf\uff08\u9759\u6001\u6570\u636e\uff0c\u6536\u5230\u5373\u7528\uff09 </summary>\n\tpublic void OnSpikesFullRemote( Vector2[] spikes )\n\t{\n\t\tif ( NetworkManager.IsAuthority ) return;\n\t\t_spikes = spikes;\n\n\t\t// \u5582\u98df\u91cf\u955c\u50cf\uff1a**\u957f\u5ea6\u4e00\u81f4\u5c31\u4fdd\u7559**\uff08v0.7.8.87 \u4fee\uff09\u3002SpikesFull \u5728\u6bcf\u6b21\u523a\u88ab\u5403/\u91cd\u5237\u65f6\u90fd\u4f1a\u91cd\u53d1\n\t\t// \uff08\u53ea\u5e26\u4f4d\u7f6e\u3001\u4e0d\u5e26\u5582\u98df\u91cf\uff09\uff0c\u539f\u6765\u65e0\u6761\u4ef6\u5f52\u96f6 \u2192 \u5ba2\u6237\u7aef\u523a\u7684\u5582\u517b\u8fdb\u5ea6\u88ab\u53cd\u590d\u62b9\u6389\u3001\u4e0e host \u957f\u671f\u4e0d\u4e00\u81f4\uff1b\n\t\t// \u53ea\u6709\u957f\u5ea6\u53d8\u4e86\uff08\u65b0\u5c40/\u9996\u6b21\u540c\u6b65\uff09\u624d\u91cd\u5efa\n\t\tvar n = spikes is null ? 0 : spikes.Length;\n\t\tif ( _spikeFed is null || _spikeFed.Length != n ) _spikeFed = new float[n];\n\t\t// \u4e0d\u518d\u6e05 _spines\uff1a\u5168\u91cf\u540c\u6b65\u6bcf\u6735\u523a\u91cd\u5237\u90fd\u4f1a\u53d1\u4e00\u6b21\uff0c\u6e05\u5f39\u5e55\u4f1a\u628a\u5728\u98de\u7684\u523a\u7206\u62b9\u6389\uff08v0.7.8.87\uff09\uff1b\n\t\t// \u8de8\u5c40\u7684\u65e7\u5f39\u5e55\u7531\u5f00\u5c40\u6e05\u7406\u8def\u5f84\uff08StartMatchFromLobby / ResetToLobby\uff09\u8d1f\u8d23\n\t}\n\n\t// ---- \u5582\u523a/\u523a\u7206\u8fdc\u7aef\u4e8b\u4ef6\uff08v0.7.8.24\uff09----\n\n\t/// <summary> \u5ba2\u6237\u7aef\u955c\u50cf\uff1a\u67d0\u6735\u523a\u7684\u7d2f\u8ba1\u5582\u98df\u91cf\uff08host \u5df2\u76f4\u63a5\u5199\u5165\uff0c\u56de\u58f0\u6321\u6389\uff09 </summary>\n\tpublic void OnSpikeFedRemote( int index, float fed )\n\t{\n\t\tif ( NetworkManager.IsAuthority ) return;\n\t\tif ( _spikeFed is not null && (uint)index < (uint)_spikeFed.Length ) _spikeFed[index] = fed;\n\t}\n\n\t/// <summary> \u5168\u7aef\u7206\u6548\uff1b\u5ba2\u6237\u7aef\u7528\u4e0e host \u76f8\u540c\u7684\u786e\u5b9a\u6027\u89d2\u5ea6\u751f\u6210\u89c6\u89c9\u5f39\u5e55\uff08host \u7684\u6743\u5a01\u5f39\u5e55\u5728 BurstSpike \u5df2\u751f\u6210\uff09 </summary>\n\tpublic void OnSpikeBurstRemote( int index )\n\t{\n\t\tif ( _spikes is null || (uint)index >= (uint)_spikes.Length ) return;\n\t\tvar pos = _spikes[index];\n\n\t\tif ( NetworkManager.IsAuthority )\n\t\t{\n\t\t\tGameSfx.Pop( new Vector3( pos.x, pos.y, 0f ) );   // host \u7206\u6548\u58f0\uff08\u6743\u5a01\u5f39\u5e55\u5df2\u5728 BurstSpike \u751f\u6210\uff0c\u6e32\u67d3\u76f4\u63a5\u8bfb\uff09\n\t\t\treturn;\n\t\t}\n\n\t\tif ( _spikeFed is not null && (uint)index < (uint)_spikeFed.Length ) _spikeFed[index] = 0f;\n\t\tvar n = GameConfig.SpikeBurstCount;\n\t\tfor ( int k = 0; k < n; k++ )\n\t\t{\n\t\t\t_spines.Add( new SpikeSpine\n\t\t\t{\n\t\t\t\tPos = new Vector2( pos.x, pos.y ),\n\t\t\t\tVel = SpineDir( k, n, index ) * GameConfig.SpineSpeed,\n\t\t\t\tSinceSpawn = 0,   // \u540c BurstSpike\uff1aTimeSince \u5fc5\u987b\u663e\u5f0f\u5f52\u96f6\n\t\t\t} );\n\t\t}\n\t\tGameSfx.Pop( new Vector3( pos.x, pos.y, 0f ) );\n\t}\n\n\t/// <summary> \u523a\u7206\u5f39\u5e55\u547d\u4e2d\u7279\u6548\uff08\u5168\u7aef\uff1aBroadcast \u672c\u5730\u4e5f\u6267\u884c\uff0chost \u4e0d\u9884\u64ad\uff09 </summary>\n\tpublic void OnSpineHitRemote( float x, float y, byte colorIndex )\n\t{\n\t\tvar pos = new Vector3( x, y, 0f );\n\t\tNeonRenderer.Puff( pos, colorIndex );\n\t\tGameSfx.Pop( pos );\n\t}\n\n\t// ---- \u9053\u5177\u8fdc\u7aef\u4e8b\u4ef6\u5e94\u7528\uff08\u4ec5\u5ba2\u6237\u7aef\uff1bhost \u7684\u5e7f\u64ad\u56de\u58f0\u7528 IsAuthority \u6321\u6389\uff09----\n\n\tpublic void OnPowerUpFullRemote( PowerUpWire[] wires )\n\t{\n\t\tif ( NetworkManager.IsAuthority ) return;\n\t\t_power?.ApplyFull( wires );\n\t}\n\n\tpublic void OnPowerUpPickedRemote( int slot, byte kind, long pickerSteamId )\n\t{\n\t\tif ( NetworkManager.IsAuthority ) return;\n\n\t\t// \u5e94\u7528\u524d\u5148\u53d6\u4f4d\u7f6e/\u79cd\u7c7b\u653e\u706b\u82b1\uff08ApplyPicked \u4f1a\u7f6e\u7a7a\u69fd\uff09\n\t\tif ( _power?.Ready ?? false )\n\t\t{\n\t\t\tvar slots = _power.Slots;\n\t\t\tif ( (uint)slot < (uint)slots.Count )\n\t\t\t{\n\t\t\t\tvar s = slots[slot];\n\t\t\t\tNeonRenderer.Spark( new Vector3( s.Pos.x, s.Pos.y, 0f ), PowerUpManager.ColorOf( kind ) );\n\t\t\t\tif ( GameSfx.IsMine( pickerSteamId ) )\n\t\t\t\t{\n\t\t\t\t\tGameSfx.EatFood( new Vector3( s.Pos.x, s.Pos.y, 0f ) );\n\t\t\t\t\tGameAchievements.PowerStored();   // \u6210\u5c31\uff1a\u7d2f\u8ba1\u6361 5 \u4e2a\u9053\u5177\u5165\u5305\uff08\u5ba2\u6237\u7aef\u4fa7\u8ba1\u6570\uff09\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t_power?.ApplyPicked( slot );\n\t}\n\n\tpublic void OnPowerUpRespawnedRemote( int slot, byte kind, float x, float y )\n\t{\n\t\tif ( NetworkManager.IsAuthority ) return;\n\t\t_power?.ApplyRespawned( slot, kind, new Vector2( x, y ) );\n\t}\n\n\t/// <summary> \u5ba2\u6237\u7aef\u4e2a\u4eba\u9ad8\u5149\u6a2a\u5e45\u843d\u5730\uff08host \u5df2\u672c\u5730\u64ad\u8fc7\uff0c\u56de\u58f0\u6321\u6389\uff1b\u53ea\u64ad\"\u662f\u6211\u7684\"\u90a3\u6761\uff09 </summary>\n\tpublic void OnPowerBannerRemote( byte kind, long ownerSteamId, byte mode )\n\t{\n\t\tif ( NetworkManager.IsAuthority ) return;\n\t\tif ( !GameSfx.IsMine( ownerSteamId ) ) return;\n\n\t\tvar text = mode == 2 ? $\"{PowerUpManager.NameOf( kind )} READY (Q)\"   // Q \u5145\u80fd\u5230\u70b9\uff08v0.7.8.31\uff09\n\t\t\t: mode == 0 ? $\"{PowerUpManager.NameOf( kind )} STORED\" : GameHud.ActivationText( kind );\n\t\t_hud?.AddBanner( text, GameHud.KindColor( kind ) );\n\t\tGameSfx.Pickup();\n\n\t\tif ( mode == 1 ) GameAchievements.FeedTriggered( ownerSteamId );   // \u6210\u5c31\uff1a\u9996\u6b21\u5582\u98df\u89e6\u53d1\uff08mode1=\u6fc0\u6d3b/\u5582\u517b\uff09\n\t}\n\n\t/// <summary> \u5ba2\u6237\u7aef\u5e94\u7528\u6b7b\u4ea1\u7206\u88c2\u7279\u6548\uff08host \u5df2\u5728\u672c\u5730\u751f\u6210\u8fc7\uff0c\u56de\u58f0\u7528 IsAuthority \u6321\u6389\uff09\u3002\n\t/// \u89c6\u89c9\u5168\u573a\u64ad\uff1b\u7206\u88c2\u97f3\u53ea\u64ad\u81ea\u5df1\u6b7b\u4ea1/\u7206\u88c2\u7684\uff08ownerSteamId \u5339\u914d\u672c\u673a\uff09 </summary>\n\tpublic void OnPoppedEffectRemote( Vector3 pos, byte colorIndex, float radius, long ownerSteamId )\n\t{\n\t\tif ( NetworkManager.IsAuthority ) return;\n\t\tNeonRenderer.Pop( pos, colorIndex, radius );\n\t\tif ( GameSfx.IsMine( ownerSteamId ) )\n\t\t\tGameSfx.Pop( pos );\n\t}\n\n\t/// <summary> \u5ba2\u6237\u7aef\u541e\u7403\u4e8b\u4ef6\uff08host \u56de\u58f0\u7528 IsAuthority \u6321\u6389\u2014\u2014host \u5df2\u5728 NotifyBallEaten \u76f4\u8c03\u8fc7\uff09 </summary>\n\tpublic void OnBallEatenRemote( long eaterSteamId, long eatenSteamId, float massGained )\n\t{\n\t\tif ( NetworkManager.IsAuthority ) return;\n\t\tOnBallEaten( eaterSteamId, eatenSteamId, massGained );\n\t}\n\n\tvoid CreateBackdrop()\n\t{\n\t\tvar go = new GameObject( true, \"GridBackdrop\" );\n\t\tgo.Parent = _root;\n\t\tgo.AddComponent<GridBackdrop>();\n\t\tgo.AddComponent<TerritoryRenderer>();   // \u9886\u571f\u683c\u5e95\u8272/\u8fb9\u754c/\u65d7\u6807\uff08M7\uff1b\u975e\u9886\u571f\u6a21\u5f0f\u81ea\u6e05\u7a7a\u4e0d\u753b\uff09\n\t}\n\n\t/// <summary> \u5168\u573a\u666f\u4e00\u4e2a\u7684\u50cf\u7d20\u6279\u91cf\u753b\u7b14\uff1a\u6bcf\u5e27\u628a\u6240\u6709\u7403/\u5206\u8eab\u5199\u8fdb\u5404\u6279\u6b21\u9876\u70b9\uff08v0.7.8.40 \u5206\u8eab\u5934\u50cf\u4e5f\u5e76\u5165\uff09 </summary>\n\tvoid CreateNeon()\n\t{\n\t\tvar go = new GameObject( true, \"Neon\" );\n\t\tgo.Parent = _root;\n\t\tgo.AddComponent<NeonRenderer>();\n\t}\n\n\tvoid CreateCamera()\n\t{\n\t\t// \u9632\u5fa1\uff1a\u70ed\u91cd\u8f7d/\u91cd\u5165\u53ef\u80fd\u6b8b\u7559\u65e7\u76f8\u673a\u2014\u2014\u4e24\u4e2a IsMainCamera \u4f1a\u62a2\u6e32\u67d3\uff0c\u800c\u65e7\u7684\u90a3\u4e2a\n\t\t// \u59ff\u6001\u5df2\u6b7b\uff08NeonCamera \u7f13\u5b58\u7684 _target \u5931\u6548\u65e9\u9000\uff09\uff0c\u6e32\u67d3\u9009\u4e2d\u5b83\u5c31\u662f\u753b\u9762\u50f5\u6b7b\u4e0d\u8ddf\u968f\n\t\tforeach ( var c in Scene.GetAllComponents<CameraComponent>().ToArray() )\n\t\t{\n\t\t\tif ( c.IsValid() && c.GameObject.IsValid() )\n\t\t\t{\n\t\t\t\tGameLog.Info( \"[game] destroying stale camera\" );\n\t\t\t\tc.GameObject.Destroy();\n\t\t\t}\n\t\t}\n\n\t\tvar go = new GameObject( true, \"Camera\" );\n\t\tgo.Parent = _root;\n\n\t\tvar cam = go.AddComponent<CameraComponent>();\n\t\tcam.IsMainCamera = true;\n\t\tcam.Priority = -100;\n\t\tcam.Orthographic = true;\n\t\tcam.OrthographicHeight = GameConfig.CameraBaseView;\n\t\tcam.ClearFlags = ClearFlags.All;\n\t\tcam.BackgroundColor = new Color( 0.918f, 0.957f, 0.988f );   // #EAF4FC \u6d45\u84dd\u5e95\uff08\u50cf\u7d20\u98ce\uff0cv0.7.8.36\uff09\n\t\tcam.EnablePostProcessing = true;\n\n\t\t// Bloom \u4fdd\u7559\u7ec4\u4ef6\uff08cr_bloom convar/\u8bbe\u7f6e\u9875\u8fd8\u7ba1\u5b83\uff09\uff0c\u9ed8\u8ba4\u5173\u2014\u2014\u52a0\u8272\u6cdb\u5149\u4f1a\u7cca\u6389\u7cd6\u679c\u8272\u4e0e\u50cf\u7d20\u9897\u7c92\n\t\tvar bloom = go.AddComponent<Bloom>();\n\t\tbloom.Mode = SceneCamera.BloomAccessor.BloomMode.Additive;\n\t\tbloom.Strength = 1.5f;\n\t\tbloom.Threshold = 0.55f;\n\n\t\t// \u50cf\u7d20\u98ce\u53bb\u6389 ACES \u8272\u8c03\u6620\u5c04\uff08\u4f1a\u628a\u9a6c\u5361\u9f99\u8272\u538b\u7070\uff09\uff0c\u4fdd\u6301\u8d34\u56fe\u539f\u8272\n\t\t_camera = go.AddComponent<NeonCamera>();\n\t\t_camera.Cam = cam;\n\t}\n\n\tvoid CreateHud()\n\t{\n\t\tvar go = new GameObject( true, \"Hud\" );\n\t\tgo.Parent = _root;\n\t\t// \u8bca\u65ad\uff08\u4e34\u65f6\uff0c\u5ba2\u6237\u7aef\u7206\u95ea\uff09\uff1aParent= \u53ef\u80fd\u9759\u9ed8\u5931\u6548\uff08\u76ee\u6807\u662f host \u62e5\u6709\u5bf9\u8c61\u65f6\uff09\u2014\u2014\u56de\u8bfb\u5b9e\u9645\u7236\u7269\u4f53\n\t\tvar rootName = _root.IsValid() ? _root.Name : \"NULL\";\n\t\tvar realParent = go.Parent.IsValid() ? go.Parent.Name : \"NONE\";\n\t\tint ballCount = 0;\n\t\tif ( _root.IsValid() ) foreach ( var b in _root.GetComponentsInChildren<Ball>( true ) ) ballCount++;\n\t\tLog.Info( $\"[game] CreateHud: _root='{rootName}' ballsUnderRoot={ballCount} hudParentNow='{realParent}'\" );\n\t\tgo.AddComponent<ScreenPanel>();\n\t\t_hud = go.AddComponent<GameHud>();\n\n\t\t// \u7ed3\u7b97\u9762\u677f\uff08M5\uff09\uff1a\u72ec\u7acb ScreenPanel \u53e0\u5728 HUD \u4e4b\u4e0a\uff0c\u5e73\u65f6\u9690\u85cf\n\t\tvar ego = new GameObject( true, \"EndBoard\" );\n\t\tego.Parent = _root;\n\t\tego.AddComponent<ScreenPanel>();\n\t\t_endBoard = ego.AddComponent<EndBoard>();\n\t}\n\n\t/// <summary> \u4e3b\u83dc\u5355\uff08\u5f00\u5c40\u540e\u7531 StartGame \u7981\u7528\u6574\u4e2a GameObject\uff09 </summary>\n\tvoid CreateMenu()\n\t{\n\t\tvar go = new GameObject( true, \"MainMenu\" );\n\t\tgo.Parent = _root;\n\t\tgo.AddComponent<ScreenPanel>();\n\t\t_menu = go.AddComponent<MainMenu>();\n\t}\n\n\t/// <summary> \u6e38\u620f\u623f\u95f4\uff08M5 \u4e8c\u7ea7\u754c\u9762\uff09\uff1aHOST/VS BOTS \u5f00\u623f\u540e\u663e\u793a\uff0cSTART/\u8fdb\u5c40\u540e\u9690\u85cf </summary>\n\tvoid CreateLobbyPanel()\n\t{\n\t\tvar go = new GameObject( true, \"LobbyPanel\" );\n\t\tgo.Parent = _root;\n\t\tgo.AddComponent<ScreenPanel>();\n\t\t_lobby = go.AddComponent<LobbyPanel>();\n\t}\n\n\t/// <summary> \u8bbe\u7f6e\u9875\uff08v0.7.8.16 \u4e8c\u7ea7\u754c\u9762\uff09\uff1a\u4e3b\u83dc\u5355 [4] \u8fdb\u5165\u2014\u2014\u6cdb\u5149\u5f00\u5173/\u97f3\u4e50\u97f3\u91cf\uff0cBACK \u56de\u83dc\u5355 </summary>\n\tvoid CreateSettingsPanel()\n\t{\n\t\tvar go = new GameObject( true, \"SettingsPanel\" );\n\t\tgo.Parent = _root;\n\t\tgo.AddComponent<ScreenPanel>();\n\t\t_settings = go.AddComponent<SettingsPanel>();\n\t}\n\n\t/// <summary> \u6392\u884c\u9875\uff08v0.7.8.19 \u4e8c\u7ea7\u754c\u9762\uff09\uff1a\u4e3b\u83dc\u5355 [5] \u8fdb\u5165\u2014\u2014\u5168\u7403 XP \u699c\uff0cBACK \u56de\u83dc\u5355 </summary>\n\tvoid CreateRankingsPanel()\n\t{\n\t\tvar go = new GameObject( true, \"RankingsPanel\" );\n\t\tgo.Parent = _root;\n\t\tgo.AddComponent<ScreenPanel>();\n\t\t_rankings = go.AddComponent<RankingsPanel>();\n\t}\n\n\t/// <summary> \u623f\u95f4\u5217\u8868\u9875\uff08v0.6.7.0\uff09\uff1a\u4e3b\u83dc\u5355 [2] JOIN GAME \u6253\u5f00\uff0c\u5217\u51fa Steam \u516c\u5f00\u5927\u5385 </summary>\n\tvoid CreateRoomBrowser()\n\t{\n\t\tvar go = new GameObject( true, \"RoomBrowser\" );\n\t\tgo.Parent = _root;\n\t\tgo.AddComponent<ScreenPanel>();\n\t\t_browser = go.AddComponent<RoomBrowser>();\n\t}\n\n\t/// <summary>\n\t/// \u751f\u6210\u4e00\u9897\u7403\u3002M0 \u76f4\u63a5\u4ee3\u7801\u5efa\u5b9e\u4f53\uff1bM2 \u8d77\u771f\u4eba\u7403\u4f18\u5148\u7528 Ball \u9884\u5236\u4f53\n\t/// \uff08\u7528\u6237\u5728\u7f16\u8f91\u5668\u521b\u5efa\uff0c\u4ee3\u7801\u6309\u8def\u5f84 Clone\uff0c\u7f3a\u5931\u65f6\u56de\u9000\u4ee3\u7801\u6784\u5efa\uff09\u3002\n\t/// </summary>\n\t/// <summary>\n\t/// \u751f\u6210\u4e00\u9897\u7403\uff08\u4ec5\u6743\u5a01\u7aef\u8c03\u7528\uff09\uff1a\u4f18\u5148\u514b\u9686\u7528\u6237\u521b\u5efa\u7684 ball.prefab\uff08\u6839\u8282\u70b9\u9700\u6302 Ball\uff09\uff0c\n\t/// \u7f3a\u5931\u56de\u9000\u7eaf\u4ee3\u7801\u6784\u5efa\u3002owner=null \u2192 host \u62e5\u6709\uff08\u623f\u4e3b\u81ea\u5df1/bot\uff09\uff1b\n\t/// \u4f20\u8fde\u63a5 \u2192 \u8be5\u5ba2\u6237\u7aef\u62e5\u6709\uff0c\u79fb\u52a8\u7531\u5176\u672c\u5730\u6a21\u62df\uff08owner \u6a21\u62df\u67b6\u6784\uff09\u3002\n\t/// dormant=true\uff1a\u623f\u95f4\u86f0\u4f0f\u7403\uff08Alive=false \u4e0d\u53ef\u89c1\uff0c\u5f00\u5c40\u7531 StartMatchFromLobby \u6fc0\u6d3b\uff09\u2014\u2014\n\t/// \u5f15\u64ce\"\u8fdb\u884c\u4e2d NetworkSpawn \u4e0d\u590d\u5236\u5230\u5df2\u8fde\u63a5\u5ba2\u6237\u7aef\"\uff0c\u7403\u5fc5\u987b\u5728\u5404\u7aef\u5feb\u7167\u524d\u5b58\u5728\u3002\n\t/// </summary>\n\tpublic Ball SpawnBall( Vector3 position, bool isBot, Connection owner = null, bool dormant = false )\n\t{\n\t\t// \u7ed9\u8fdc\u7a0b\u8fde\u63a5\u751f\u6210\u7684\u7403\u5fc5\u987b\u7f51\u7edc\u5316\u624d\u6709\u610f\u4e49\uff1a\u4f1a\u8bdd\u672a\u6fc0\u6d3b\u65f6 NetworkSpawn \u4f1a\u88ab\u8df3\u8fc7\uff0c\n\t\t// \u7403\u6ca6\u4e3a host \u672c\u5730\u5e7d\u7075\u7403\uff08\u4e0d\u5e7f\u64ad \u2192 \u5ba2\u6237\u7aef\u6536\u4e0d\u5230\uff1bproxy=false \u2192 \u5e72\u6270\u672c\u673a\u7403\u5224\u5b9a\uff09\u2014\u2014\u5b9e\u6d4b\u5927\u5751\u3002\n\t\t// OnActive \u7ade\u6001\uff08\u5927\u5385\u91cd\u5efa\u4e2d\u89e6\u53d1\u56de\u8c03\uff09\u5c31\u843d\u5728\u8fd9\u91cc\uff0c\u76f4\u63a5\u62d2\u7edd\u751f\u6210\u3002\n\t\tif ( owner is not null && !Networking.IsActive )\n\t\t{\n\t\t\tLog.Warning( \"[game] SpawnBall: session not active, refuse ghost ball for remote owner\" );\n\t\t\treturn null;\n\t\t}\n\n\t\tGameObject go = null;\n\t\tBall ball = null;\n\n\t\tvar prefab = PrefabFile.Load( GameConfig.BallPrefabPath );\n\t\tif ( prefab is not null )\n\t\t{\n\t\t\tgo = GameObject.Clone( GameConfig.BallPrefabPath, new Transform( position ), _root );\n\t\t\tball = go.Components.Get<Ball>();\n\t\t\tif ( ball is null )\n\t\t\t{\n\t\t\t\tgo.Destroy();   // \u9884\u5236\u4f53\u6839\u8282\u70b9\u6ca1\u6302 Ball\uff0c\u89c6\u4e3a\u65e0\u6548\u914d\u7f6e\uff0c\u56de\u9000\u4ee3\u7801\u6784\u5efa\n\t\t\t\tgo = null;\n\t\t\t}\n\t\t}\n\n\t\tif ( go is null )\n\t\t{\n\t\t\tgo = new GameObject( true, isBot ? \"Bot\" : \"Player\" );\n\t\t\tgo.Parent = _root;\n\t\t\tgo.WorldPosition = position;\n\t\t\tball = go.AddComponent<Ball>();\n\t\t}\n\n\t\tball.PlayerName = isBot ? NextBotName() : ( owner?.DisplayName ?? LocalPlayerName() );\n\t\tball.OwnerSteamId = owner?.SteamId.Value ?? ( isBot ? NextBotSteamId() : Game.SteamId.Value );\n\t\tball.IsRemoteOwned = owner is not null;   // host \u4fa7\uff1a\u8fdc\u7a0b\u8fde\u63a5\u7684\u7403\u4e0d\u53c2\u4e0e\u672c\u673a\u7403\u5224\u5b9a\n\t\t// \u961f\u4f0d\u5206\u914d\uff1a\u86f0\u4f0f\u7403\uff08\u623f\u95f4\u9636\u6bb5\uff09\u4e0d\u5360\u961f\u53f7\uff0c\u5f00\u5c40\u6fc0\u6d3b\u65f6\u7edf\u4e00\u6309\u5757\u5206\u961f\uff1b\n\t\t// \u5bf9\u5c40\u4e2d\u9014\u52a0\u5165\u7684\u7403\uff08dormant=false \u4e14\u6bd4\u8d5b\u5df2\u5f00\u59cb\uff09\u8865\u5206\u5230\u4e0b\u4e00\u4e2a\u5757\uff08\u56e2\u961f\u8d5b\uff0cv0.6.5.0\uff09\n\t\tball.Init( isBot, !dormant && _gameStarted && MatchState.IsTeam ? NextTeamIndex() : -1 );\n\t\tball.Dormant = dormant;\n\t\tif ( dormant ) ball.Alive = false;   // \u5fc5\u987b\u5728 NetworkSpawn \u4e4b\u524d\u7f6e false\uff0c\u968f\u521d\u59cb\u5feb\u7167\u4e0b\u53d1\n\n\t\tif ( isBot ) go.AddComponent<BotBrain>();\n\n\t\t// host \u4fa7\u76f4\u63a5\u6ce8\u518c\uff08Ball.OnStart \u5728\u7f51\u7edc\u5bf9\u8c61\u4e0a\u4e0d\u89e6\u53d1\uff0c\u89c1 ScanBalls \u515c\u5e95\u6ce8\u91ca\uff09\n\t\t_balls.Add( ball );\n\t\tif ( !isBot && owner is null )\n\t\t\tSetLocalBall( ball );\n\n\t\tgo.NetworkMode = NetworkMode.Object;\n\t\tif ( Networking.IsActive )\n\t\t\tgo.NetworkSpawn( owner );\n\n\t\treturn ball;\n\t}\n\n\t/// <summary> \u53d6\u4e00\u4e2a\u672a\u88ab\u5360\u7528\u7684 bot \u540d\uff08\u4ece\u6e38\u6807\u8d77\u626b\uff0c\u8df3\u8fc7\u573a\u4e0a\u4efb\u4f55\u7403\u2014\u2014\u542b\u86f0\u4f0f/\u5c38\u4f53\u2014\u2014\u6b63\u7528\u7740\u7684\uff1b\n\t/// 48 \u6ee1\u7f16\u5c0f\u4e8e 64 \u540d\u5b57\u6c60\uff0c\u6b63\u5e38\u6c38\u8fdc\u53d6\u5f97\u5230\uff1b\u6c60\u5b50\u771f\u88ab\u5360\u6ee1\u624d\u5faa\u73af\u590d\u7528\u515c\u5e95\uff09 </summary>\n\tstring NextBotName()\n\t{\n\t\tfor ( int i = 0; i < GameConfig.BotNames.Length; i++ )\n\t\t{\n\t\t\tvar name = GameConfig.BotNames[( _botNameIndex + i ) % GameConfig.BotNames.Length];\n\t\t\tif ( FindBallByPlayerName( name ) is null )\n\t\t\t{\n\t\t\t\t_botNameIndex += i + 1;\n\t\t\t\treturn name;\n\t\t\t}\n\t\t}\n\t\treturn GameConfig.BotNames[_botNameIndex++ % GameConfig.BotNames.Length];\n\t}\n\n\t/// <summary> \u6309\u540d\u5b57\u627e\u7403\uff08bot \u53d6\u540d\u67e5\u91cd\u7528\uff09 </summary>\n\tpublic Ball FindBallByPlayerName( string name )\n\t{\n\t\tforeach ( var b in _balls )\n\t\t{\n\t\t\tif ( b.IsValid() && b.PlayerName == name ) return b;\n\t\t}\n\t\treturn null;\n\t}\n\n\tint _botSeq;   // bot \u72ec\u7acb\u5047 SteamId \u5e8f\u5217\uff08\u914d\u5408 BotSteamIdBase\uff09\n\n\t/// <summary> \u5206\u914d\u4e00\u4e2a\u672a\u88ab\u5360\u7528\u7684 bot \u5047 SteamId\uff08\u70ed\u91cd\u8f7d\u4f1a\u91cd\u7f6e _botSeq\uff0c\u626b\u63cf\u67e5\u91cd\u9632\u78b0\u649e\uff09 </summary>\n\tlong NextBotSteamId()\n\t{\n\t\tvar id = GameConfig.BotSteamIdBase + _botSeq++;\n\t\twhile ( FindBallBySteamId( id ) is not null )\n\t\t\tid = GameConfig.BotSteamIdBase + _botSeq++;\n\t\treturn id;\n\t}\n\n\t/// <summary> \u6309 SteamId \u627e\u7403\uff08\u5206\u8eab\u5934\u50cf/\u540d\u724c\u5f52\u5c5e\u89e3\u6790\u7528\uff1bbot \u73b0\u5728\u5404\u6709\u72ec\u7acb\u5047 SteamId\uff09 </summary>\n\tpublic Ball FindBallBySteamId( long steamId )\n\t{\n\t\tforeach ( var b in _balls )\n\t\t{\n\t\t\tif ( b.IsValid() && b.OwnerSteamId == steamId ) return b;\n\t\t}\n\t\treturn null;\n\t}\n\n\t/// <summary> \u5168\u573a\u603b\u8d28\u91cf\uff08\u4e3b\u7403+\u5206\u8eab\uff09\u6700\u9ad8\u7684\u6d3b\u7403\u2014\u2014\u83dc\u5355\u6f14\u793a\u8d5b\u89c2\u6218\u955c\u5934/\u6b7b\u4ea1\u89c2\u6218\u7528 </summary>\n\tpublic Ball TopMassBall()\n\t{\n\t\tBall best = null;\n\t\tfloat bestMass = -1f;\n\t\tforeach ( var b in _balls )\n\t\t{\n\t\t\tif ( !b.IsValid() || !b.Alive ) continue;\n\n\t\t\tvar m = TotalMassFor( b );\n\t\t\tif ( m <= bestMass ) continue;\n\n\t\t\tbestMass = m;\n\t\t\tbest = b;\n\t\t}\n\t\treturn best;\n\t}\n\n\t/// <summary> \u7403\u9500\u6bc1\u65f6\u7531 Ball.OnDestroy \u56de\u8c03\u6ce8\u9500 </summary>\n\tpublic void RemoveBall( Ball ball )\n\t{\n\t\t_balls.Remove( ball );\n\t\t_lastPos.Remove( ball.Id );\n\t\t_teleportGrace.Remove( ball.Id );\n\t\t_steerDir.Remove( ball.Id );\n\t\t_sinceEject.Remove( ball.Id );\n\t\tif ( LocalBall == ball ) LocalBall = null;\n\t}\n\n\t/// <summary>\n\t/// \u7ed3\u7b97\u7ed3\u675f\uff08\u81ea\u52a8 10 \u79d2 / \u7a7a\u683c\u63d0\u524d\uff09\uff1a\u591a\u4eba\u56de **host \u7684\u623f\u95f4**\uff08\u4f1a\u8bdd\u4fdd\u7559\uff0c\u7b49\u4e0b\u4e00\u5c40\uff09\uff0c\n\t/// \u5355\u673a\u56de\u4e3b\u83dc\u5355\u3002\n\t/// </summary>\n\tpublic void AfterSettlement()\n\t{\n\t\tif ( Networking.IsActive )\n\t\t\tResetToLobby();\n\t\telse\n\t\t\tResetToMenu();\n\t}\n\n\t/// <summary>\n\t/// \u56de\u5230\u623f\u95f4\uff08\u591a\u4eba\uff0cv0.6.4.2 \u7528\u6237\u5b9a\u7a3f\uff09\uff1a**\u4f1a\u8bdd\u4fdd\u7559\u4e0d\u6e05**\u2014\u2014host \u56de\u623f\u95f4\u63a7\u5236\u7aef\n\t/// \uff08\u53ef\u8c03\u53c2\u3001\u518d START \u5f00\u4e0b\u4e00\u5c40\uff09\uff0c\u5ba2\u6237\u7aef\u56de\u623f\u95f4\u7b49\u5f85\u9875\uff08RequestMatchState \u62c9\u73a9\u5bb6\u5217\u8868\uff09\u3002\n\t/// \u4e0a\u4e00\u5c40\u7684\u7403\u7559\u7740\u5f53\u80cc\u666f\uff08\u4e0b\u5c40 START \u65f6\u7edf\u4e00\u9500\u6bc1\u91cd\u5efa\uff09\uff0c\u6a21\u62df\u5df2\u51bb\u7ed3\u4e0d\u4f1a\u7ee7\u7eed\u5403\u3002\n\t/// </summary>\n\t/// <summary>\n\t/// \u5c40\u95f4\u6e05\u7406\uff08v0.7.8.87\uff09\uff1a\u4e00\u5c40\u7ed3\u675f/\u56de\u5927\u5385\u65f6\uff0c\u51e1\u662f\"\u6309\u7403\u8bb0\u8d26\"\u6216\"\u672c\u5c40\u4e34\u65f6\"\u7684\u72b6\u6001\u90fd\u8981\u6e05\u3002\n\t/// **\u7403\u5bf9\u8c61\u662f\u8de8\u5c40\u590d\u7528\u7684**\uff08\u6c38\u4e0d\u9500\u6bc1\u3001\u4e0b\u5c40\u539f\u5730\u590d\u6d3b\uff09\uff0c\u6240\u4ee5\u4e0d\u6e05\u7684\u540e\u679c\u90fd\u4f1a\u6f0f\u5230\u4e0b\u4e00\u5c40\uff1a\n\t/// \u5206\u8eab/\u5b62\u5b50\u88ab\u7ee7\u7eed\u6a21\u62df\uff08\u5f00\u5c40\u767d\u9001\u8d28\u91cf\uff09\u3001\u4f4d\u79fb\u6821\u9a8c\u951a\u70b9/\u77ac\u79fb\u5bbd\u9650\u9519\u4f4d\u3001\n\t/// \u804c\u4e1a\u51b7\u5374\u4e0e Q \u5145\u80fd\u6b8b\u7559\uff08\u5f00\u5c40\u6309\u4e0d\u52a8 / \u767d\u9001\u6280\u80fd\uff09\u3001\u8fde\u51fb\u4e0e\u51fb\u6740\u8ba1\u6570\u4e32\u5c40\u3001\n\t/// \u523a\u7206\u5f39\u5e55\u6b8b\u7559\u3001\u524d\u7ebf\u590d\u6d3b\u504f\u597d\u6b8b\u7559\u3002\u56de\u4e3b\u83dc\u5355\u7684 ResetToMenu \u5df2\u6709\u4e00\u4efd\u66f4\u5f7b\u5e95\u7684\u6e05\u7406\uff0c\u8fd9\u91cc\u5bf9\u9f50\u3002\n\t/// </summary>\n\tvoid ClearMatchState()\n\t{\n\t\t_cells.Clear();\n\t\t_cellsDirty = true;\n\t\t_lastPos.Clear();\n\t\t_teleportGrace.Clear();\n\t\t_steerDir.Clear();\n\t\t_sinceEject.Clear();\n\t\t_spines.Clear();\n\t\t_pendingFrontRespawn = false;\n\n\t\tClassSkillManager.ResetAll();\n\t\tGameSfx.ResetMatchStats();\n\t\tGameXp.ResetMatchKills();\n\t\tGameHud.ResetHints();\n\t}\n\n\tpublic void ResetToLobby()\n\t{\n\t\tMatchState.Reset();\n\t\t_matchRunning = false;\n\t\t_joinCountdown = -1f;\n\t\t_gameStarted = false;\n\n\t\tClearMatchState();   // \u5c40\u95f4\u6e05\u7406\uff08v0.7.8.87\uff09\uff1a\u7403\u5bf9\u8c61\u8de8\u5c40\u590d\u7528\uff0c\u89c1\u65b9\u6cd5\u6ce8\u91ca\n\n\t\t_endBoard?.Hide();\n\t\tif ( _hud.IsValid() ) _hud.GameObject.Enabled = false;\n\t\t_menu?.Hide();\n\n\t\tif ( NetworkManager.IsAuthority )\n\t\t{\n\t\t\t_lobbyOpen = true;\n\t\t\t_lobby?.ShowHost();\n\t\t\t_net?.ResetSession();\n\t\t\tMarkLobbyDirty();   // \u5e7f\u64ad\u623f\u95f4\u73a9\u5bb6\u5217\u8868\n\t\t}\n\t\telse\n\t\t{\n\t\t\tEnterClientWaiting();\n\t\t\t_browser?.Hide();\n\t\t\t_lobby?.ShowClient();\n\t\t\tNetworkManager.RequestMatchState();\n\t\t}\n\n\t\tGameMusic.PlayMenu();   // \u56de\u5927\u5385\u56de\u5230\u83dc\u5355\u66f2\n\t\tGameLog.Info( $\"[match] back to lobby (auth={NetworkManager.IsAuthority})\" );\n\t}\n\n\t/// <summary>\n\t/// \u7ed3\u7b97\u540e\u56de\u4e3b\u83dc\u5355\uff08M5\uff09\uff1a\u65ad\u7f51\u3001\u6e05\u573a\uff08\u7403\u9500\u6bc1/\u5206\u8eab\u6e05\u7a7a/\u6bd4\u8d5b\u6001\u590d\u4f4d\uff09\u3001\u83dc\u5355\u91cd\u51fa\uff1b\n\t/// \u4e16\u754c\u58f3\uff08\u7f51\u683c/\u76f8\u673a\uff09\u7559\u7740\u5f53\u83dc\u5355\u80cc\u666f\uff0c\u4e0b\u6b21\u5f00\u5c40\u6cbf\u7528\uff08StartGame \u590d\u7528 HUD\uff09\u3002\n\t/// </summary>\n\tpublic void ResetToMenu()\n\t{\n\t\ttry\n\t\t{\n\t\t\tif ( Networking.IsActive ) Networking.Disconnect();\n\t\t}\n\t\tcatch\n\t\t{\n\t\t\t// \u65ad\u4e0d\u5f00\u4e5f\u65e0\u59a8\uff1ahost \u4fa7\u4e0b\u5c40\u6cbf\u7528\u65e7\u4f1a\u8bdd\u7167\u5e38\u80fd\u5f00\n\t\t}\n\n\t\tforeach ( var b in _balls.ToArray() )\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tif ( b.IsValid() ) b.GameObject.Destroy();\n\t\t\t}\n\t\t\tcatch ( Exception e )\n\t\t\t{\n\t\t\t\t// \u5ba2\u6237\u7aef\u9500\u6bc1 host \u62e5\u6709\u7684\u5bf9\u8c61\u53ef\u80fd\u88ab\u5f15\u64ce\u62d2\u7edd\u2014\u2014\u4e0d\u80fd\u8ba9\u5b83\u4e2d\u65ad\u56de\u83dc\u5355\u6d41\u7a0b\uff08\u5b9e\u6d4b\u8fc7\u5361\u4f4f\u4e0d\u8fd4\u56de\uff09\n\t\t\t\tLog.Warning( $\"[menu] destroy ball failed: {e.Message}\" );\n\t\t\t}\n\t\t}\n\t\t_balls.Clear();\n\t\t_cells.Clear();\n\t\t_lastPos.Clear();\n\t\t_teleportGrace.Clear();\n\t\t_steerDir.Clear();\n\t\t_sinceEject.Clear();\n\t\t_power?.Reset();\n\t\tClearMatchState();   // \u9759\u6001\u8ba1\u6570/\u5f15\u5bfc\u51b7\u5374/\u804c\u4e1a\u51b7\u5374\u4e00\u5e76\u6e05\uff08v0.7.8.87\uff09\n\t\t_teamSeq = 0;\n\t\tLocalBall = null;\n\n\t\t_gameStarted = false;\n\t\t_lobbyOpen = false;\n\t\t_lobbyWaiting = false;\n\t\t_lobbyHeartbeatArmed = false;\n\t\t_lobbyListDirty = false;\n\t\t_joinRequested = false;\n\t\t_networkReady = false;\n\t\t_joinCountdown = -1f;\n\t\t_matchRunning = false;\n\t\tMatchState.Reset();\n\n\t\t_net?.ResetSession();\n\t\t_lobby?.Hide();\n\t\t_browser?.Hide();\n\t\t_endBoard?.Hide();\n\t\tif ( _hud.IsValid() ) _hud.GameObject.Enabled = false;\n\t\t_menu?.Show();\n\t\tGameMusic.PlayMenu();\n\t\tGameLog.Info( \"[menu] back to menu\" );\n\n\t\t// \u83dc\u5355\u80cc\u666f\u6f14\u793a\u8d5b\u91cd\u65b0\u5f00\u8d77\u6765\n\t\tif ( NetworkManager.IsAuthority && !Networking.IsActive )\n\t\t\tStartMenuDemo();\n\t}\n}"
        },
        {
            "Ident": "slfy.circleroyale",
            "Path": "Rendering/NeonCamera.cs",
            "FileName": "NeonCamera.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 380090,
            "IsPrivate": false,
            "Code": "using System;\n\n/// <summary>\n/// \u4fef\u89c6\u8ddf\u968f\u76f8\u673a\uff1a\u6b63\u4ea4\u6295\u5f71\u3001\u6307\u6570\u5e73\u6ed1\u8ddf\u968f\u672c\u5730\u7403\uff1b\u89c6\u53e3\u9ad8\u5ea6\u968f\u8d28\u91cf\u589e\u5927\uff08\u955c\u5934\u62c9\u8fdc\uff09\u3002\n/// \u76f8\u673a\u671d\u5411\u56fa\u5b9a\u5782\u76f4\u5411\u4e0b\uff0c\u9f20\u6807\u2192\u4e16\u754c\u65b9\u5411\u7684\u6620\u5c04\u89c1 BallInput\u3002\n/// </summary>\npublic sealed class NeonCamera : Component\n{\n\tconst float FollowSpeed = 6f;\n\tconst float ZoomSpeed = 2.5f;\n\n\t/// <summary> \u7531 CircleroyaleGame \u6ce8\u5165\uff08\u907f\u514d\u6bcf\u5e27 GetComponent\uff09 </summary>\n\tpublic CameraComponent Cam { get; set; }\n\n\tBall _target;\n\tBall _spectate;      // \u6b7b\u4ea1\u89c2\u6218\u76ee\u6807\uff08\u672c\u5730\u7403\u6d3b\u7740\u65f6\u6052\u4e3a null\uff09\n\tVector3 _focus;\n\tfloat _shakeAmp;     // \u5c4f\u5e55\u9707\u52a8\u5e45\u5ea6\uff08\u51fb\u6740/\u653e\u6280\u80fd\u89e6\u53d1\uff0c\u6307\u6570\u8870\u51cf\uff0cv0.7.7.0\uff09\n\tBloom _bloom;        // \u6cdb\u5149\u7ec4\u4ef6\uff08cr_bloom convar \u5373\u65f6\u5f00\u5173\uff0cv0.7.8.15\uff09\n\n\t/// <summary> \u89e6\u53d1\u5c4f\u5e55\u8f7b\u5fae\u9707\u52a8\uff08\u51fb\u6740\u8fde\u64ad/\u653e\u6280\u80fd\u7684\u9ad8\u5149\u53cd\u9988\uff1b\u5e45\u5ea6\u6309\u4e8b\u4ef6\u5b9a\uff0c\u81ea\u52a8\u6307\u6570\u8870\u51cf\uff09 </summary>\n\tpublic void Shake( float amplitude ) => _shakeAmp = MathF.Max( _shakeAmp, amplitude );\n\n\tprotected override void OnStart()\n\t{\n\t\tbase.OnStart();\n\n\t\t// \u7acb\u5373\u6446\u597d\u9ed8\u8ba4\u4fef\u89c6\u59ff\u6001\uff08\u7126\u70b9=\u573a\u5730\u4e2d\u5fc3\uff09\u3002\u6ca1\u6709\u672c\u673a\u7403\u4e4b\u524d OnUpdate \u4f1a\u56e0\n\t\t// !_target.IsValid() \u76f4\u63a5 return\u2014\u2014\u76f8\u673a\u4fdd\u6301\u5f15\u64ce\u9ed8\u8ba4\u59ff\u6001\uff08\u539f\u70b9\u6c34\u5e73\u770b\uff09\uff0c\n\t\t// \u6574\u4e2a\u573a\u5730\u5e73\u9762\u4fa7\u89c6\u584c\u6210\u4e00\u6761\u7ebf\uff1a\u4e3b\u83dc\u5355\u80cc\u666f\u5b9e\u6d4b\u5c31\u662f\u8fd9\u6837\uff08\u5ba2\u6237\u7aef\u7ade\u6001\u671f\u540c\u6837\u4e2d\u62db\uff09\n\t\tApplyPose();\n\t\tif ( Cam.IsValid() )\n\t\t{\n\t\t\tCam.OrthographicHeight = GameConfig.CameraBaseView;\n\t\t\t_bloom = Cam.GetComponent<Bloom>();\n\t\t}\n\t}\n\n\tpublic void SetTarget( Ball ball )\n\t{\n\t\tif ( _target == ball ) return;   // \u540c\u4e00\u9897\u7403\uff1a\u4e0d\u91cd\u590d SnapTo\uff08\u626b\u63cf\u4f1a\u53cd\u590d\u65ad\u8a00\uff09\n\t\t_target = ball;\n\t\tSnapTo();\n\t}\n\n\tpublic void SnapTo()\n\t{\n\t\tif ( !_target.IsValid() ) return;\n\t\t_focus = _target.WorldPosition;\n\t\t_focus.z = 0f;\n\t\tApplyPose();\n\t\tif ( Cam.IsValid() ) Cam.OrthographicHeight = ViewForMass( _target.Mass );\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tbase.OnUpdate();\n\n\t\t// \u6cdb\u5149\u5f00\u5173\uff08v0.7.8.15\uff09\uff1a\u63a7\u5236\u53f0 cr_bloom 0/1 \u5373\u65f6\u751f\u6548\uff08\u5e42\u7b49\u8d4b\u503c\uff0c\u65e0\u6296\u52a8\uff09\uff1b\n\t\t// \u70ed\u91cd\u8f7d\u6362\u7a0b\u5e8f\u96c6\u540e\u7f13\u5b58\u5931\u6548 \u2192 \u60f0\u6027\u91cd\u53d6\n\t\tif ( Cam.IsValid() )\n\t\t{\n\t\t\tif ( !_bloom.IsValid() ) _bloom = Cam.GetComponent<Bloom>();\n\t\t\tif ( _bloom.IsValid() ) _bloom.Enabled = GameConfig.ConvarBloom;\n\t\t}\n\n\t\t// \u81ea\u6108\uff1a\u70ed\u91cd\u8f7d\u4f1a\u628a\u573a\u666f\u7ec4\u4ef6\u6362\u6210\u65b0\u7a0b\u5e8f\u96c6\u5bf9\u8c61\uff0c\u7f13\u5b58\u7684 _target \u53ef\u80fd\u53d8\u6210\u5931\u6548\u5f15\u7528\u2014\u2014\n\t\t// OnUpdate \u9996\u884c\u65e9\u9000\u4f1a\u8ba9\u76f8\u673a\u6c38\u4e45\u51bb\u7ed3\uff08\u5b9e\u6d4b\uff1a\u5ba2\u6237\u7aef\u590d\u6d3b\u7740\u3001\u5206\u88c2\u7740\uff0c\u955c\u5934\u50f5\u6b7b\u4e0d\u8ddf\uff09\u3002\n\t\t// \u4ece\u6e38\u620f\u7cfb\u7edf\u91cd\u65b0\u8ba4\u9886\u672c\u673a\u7403\uff1b\u8fde\u672c\u673a\u7403\u90fd\u6ca1\u6709\u4e5f\u4fdd\u6301\u4fef\u89c6\u59ff\u6001\uff0c\u7edd\u4e0d\u50f5\u6b7b\n\t\tif ( !_target.IsValid() )\n\t\t{\n\t\t\tvar local = CircleroyaleGame.Current?.LocalBall;\n\t\t\tif ( local.IsValid() )\n\t\t\t{\n\t\t\t\tSetTarget( local );\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\t// \u83dc\u5355\u80cc\u666f\u6f14\u793a\u8d5b\uff08v0.6.3.0\uff09\uff1a\u6ca1\u6709\u672c\u673a\u7403\u65f6\u89c2\u6218\u5168\u573a\u6700\u5927\u8eab\u4f53\u7684 bot\u3002\n\t\t\t\t// \u7c98\u6ede\u540c\u6b7b\u4ea1\u89c2\u6218\uff1a\u5f53\u524d\u5bf9\u8c61\u8fd8\u6d3b\u7740\u4e14\u6311\u6218\u8005\u6ca1\u9886\u5148 30% \u2192 \u4e0d\u6362\u955c\u5934\n\t\t\t\tvar g = CircleroyaleGame.Current;\n\t\t\t\tvar best = g?.TopMassBall();\n\n\t\t\t\tif ( _spectate.IsValid() && _spectate.Alive\n\t\t\t\t\t&& best.IsValid() && best != _spectate && g is not null )\n\t\t\t\t{\n\t\t\t\t\tif ( g.TotalMassFor( best ) <= g.TotalMassFor( _spectate ) * GameConfig.SpectateOvertakeRatio )\n\t\t\t\t\t\tbest = _spectate;\n\t\t\t\t}\n\n\t\t\t\tif ( best.IsValid() )\n\t\t\t\t{\n\t\t\t\t\t_spectate = best;\n\t\t\t\t\tvar tp = 1f - MathF.Exp( -FollowSpeed * Time.Delta );\n\t\t\t\t\tvar fp = g.FocusPositionOf( best, out var fm );\n\t\t\t\t\t_focus = Vector3.Lerp( _focus, fp, tp );\n\t\t\t\t\t_focus.z = 0f;\n\t\t\t\t\tif ( Cam.IsValid() )\n\t\t\t\t\t{\n\t\t\t\t\t\tCam.OrthographicHeight = MathX.Lerp( Cam.OrthographicHeight, ViewForMass( fm ),\n\t\t\t\t\t\t\t1f - MathF.Exp( -ZoomSpeed * Time.Delta ) );\n\t\t\t\t\t}\n\t\t\t\t\tApplyPose();\n\t\t\t\t\tUpdateTagPositions( g );   // \u59ff\u6001\u4e0e\u53d8\u7126\u90fd\u5199\u5b8c\uff0c\u672c\u5e27\u6295\u5f71\u4e00\u6b21\u5373\u53ef\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tApplyPose();\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\t// \u6b7b\u4ea1\u89c2\u6218\uff08M4\uff09\uff1a\u672c\u5730\u7403\u9635\u4ea1\u540e\u955c\u5934\u8ddf\u4f4f\u5f53\u524d\u699c\u9996\uff0c\u91cd\u751f\u77ac\u95f4\u5f39\u56de\u81ea\u5df1\n\t\tvar cur = SpectateTarget();\n\n\t\t// \u89c6\u89d2\u8ddf\u968f\u6700\u5927\u7684\u5206\u8eab\uff08v0.6.2.0 \u7528\u6237\u5b9a\u7a3f\uff09\uff1a\u7126\u70b9\u4e0e\u53d8\u7126\u57fa\u51c6\u90fd\u53d6\n\t\t// \"\u4e3b\u7403 + \u81ea\u5df1\u5206\u8eab\"\u4e2d\u8d28\u91cf\u6700\u5927\u7684\u8eab\u4f53\n\t\tvar game = CircleroyaleGame.Current;\n\t\tVector3 focusPos = cur.WorldPosition;\n\t\tfloat followMass = cur.Mass;\n\t\tif ( game is not null )\n\t\t\tfocusPos = game.FocusPositionOf( cur, out followMass );\n\n\t\tvar t = 1f - MathF.Exp( -FollowSpeed * Time.Delta );\n\t\t_focus = Vector3.Lerp( _focus, focusPos, t );\n\t\t_focus.z = 0f;\n\n\t\t// \u53d8\u7126\u5148\u5199\uff08\u6295\u5f71\u7528\u7684 OrthoHeight \u5fc5\u987b\u662f\u672c\u5e27\u6700\u7ec8\u503c\uff09\uff0c\u518d\u6446\u4f4d\u3001\u518d\u6295\u5f71\u2014\u2014\u6bcf\u5e27\u4e00\u6b21\uff0c\n\t\t// \u4e0d\u91cd\u590d\u8c03\uff08ApplyPose \u6bcf\u591a\u8c03\u4e00\u6b21 shake \u8870\u51cf\u5c31\u5feb\u4e00\u500d\uff0c\u540d\u724c\u91cd\u590d\u6295\u5f71\u4e5f\u662f\u767d\u505a\uff09\n\t\tif ( Cam.IsValid() )\n\t\t{\n\t\t\tvar tz = 1f - MathF.Exp( -ZoomSpeed * Time.Delta );\n\t\t\tCam.OrthographicHeight = MathX.Lerp( Cam.OrthographicHeight, ViewWithCells( cur, followMass ), tz );\n\t\t}\n\n\t\tApplyPose();\n\n\t\tif ( Cam.IsValid() )\n\t\t\tUpdateTagPositions( game );\n\t}\n\n\t/// <summary>\n\t/// \u6b7b\u4ea1\u89c2\u6218\u76ee\u6807\uff1a\u56e2\u961f\u8d5b\u4f18\u5148\u8ddf**\u6700\u5927\u7684\u5b58\u6d3b\u961f\u53cb**\uff08v0.7.8.28 \u7528\u6237\u5b9a\u7a3f\u2014\u2014\"\u6211\u6b7b\u4e86\u961f\u4f0d\u8fd8\u5728\u6253\"\uff0c\n\t/// \u8ddf\u7740\u5927\u54e5\u770b\u6218\u5c40\uff1b\u961f\u53cb\u5168\u706d\u624d\u56de\u843d\u5168\u573a\u699c\u9996\uff09\u3002\u5e26\u7c98\u6ede\uff08v0.6.3.1\uff09\u2014\u2014\u51e0\u4e2a\u76ee\u6807\u4ea4\u66ff\u9886\u5148\u65f6\n\t/// \u4e0d\u518d\u6bcf\u6b21\u53cd\u8d85\u90fd\u5207\u955c\u5934\uff0c\u6311\u6218\u8005\u987b\u9886\u5148 SpectateOvertakeRatio \u624d\u63a5\u7ba1\u3002\n\t/// </summary>\n\tBall SpectateTarget()\n\t{\n\t\tif ( _target.Alive )\n\t\t{\n\t\t\tif ( _spectate.IsValid() )\n\t\t\t{\n\t\t\t\t_spectate = null;\n\t\t\t\tSpectateName = null;\n\t\t\t\tSnapTo();   // \u91cd\u751f\uff1a\u955c\u5934\u77ac\u79fb\u56de\u81ea\u5df1\uff08\u4e0d\u4ece\u699c\u9996\u4f4d\u7f6e\u957f\u8ddd\u79bb\u6ed1\u8fc7\u6765\uff09\n\t\t\t}\n\t\t\treturn _target;\n\t\t}\n\n\t\tvar game = CircleroyaleGame.Current;\n\t\tBall best = null;\n\n\t\t// \u56e2\u961f\u8d5b\u961f\u53cb\u4f18\u5148\uff08\u6700\u5927\u5b58\u6d3b\u961f\u53cb\uff09\uff1b\u6ca1\u6709\u624d\u770b\u5168\u573a\u699c\u9996\n\t\tif ( MatchState.IsTeam && _target.TeamIndex >= 0 && game is not null )\n\t\t{\n\t\t\tfloat bestMass = -1f;\n\t\t\tforeach ( var b in game.Balls )\n\t\t\t{\n\t\t\t\tif ( !b.IsValid() || !b.Alive || b == _target ) continue;\n\t\t\t\tif ( b.TeamIndex != _target.TeamIndex ) continue;\n\t\t\t\tvar m = game.TotalMassFor( b );\n\t\t\t\tif ( m > bestMass ) { bestMass = m; best = b; }\n\t\t\t}\n\t\t}\n\t\tif ( !best.IsValid() )\n\t\t\tbest = game?.TopMassBall();\n\n\t\t// \u7c98\u6ede\uff1a\u5f53\u524d\u89c2\u6218\u5bf9\u8c61\u8fd8\u6d3b\u7740\uff0c\u4e14\u6311\u6218\u8005\u6ca1\u9886\u5148\u5230\u9608\u503c \u2192 \u4e0d\u6362\u955c\u5934\n\t\tif ( _spectate.IsValid() && _spectate.Alive\n\t\t\t&& best.IsValid() && best != _spectate && game is not null )\n\t\t{\n\t\t\tif ( game.TotalMassFor( best ) <= game.TotalMassFor( _spectate ) * GameConfig.SpectateOvertakeRatio )\n\t\t\t\treturn _spectate;\n\t\t}\n\n\t\t_spectate = best.IsValid() ? best : _target;\n\t\tSpectateName = _spectate.IsValid() && _spectate != _target ? _spectate.PlayerName : null;\n\t\treturn _spectate;\n\t}\n\n\t/// <summary> \u5f53\u524d\u6b7b\u4ea1\u89c2\u6218\u76ee\u6807\u540d\uff08\u672c\u673a\u6d3b\u7740=null\uff09\uff0cHUD \u6b7b\u4ea1\u9762\u677f\u63d0\u793a\u7528 </summary>\n\tpublic string SpectateName { get; private set; }\n\n\t/// <summary>\n\t/// \u89c6\u53e3\u9ad8\u5ea6\uff1a\u6309\"\u6700\u5927\u8eab\u4f53\"\u8d28\u91cf\u53d6\u57fa\u51c6\uff1b\u6709\u81ea\u5bb6\u5206\u8eab\u65f6\u62c9\u8fdc\u5230\u80fd\u76d6\u4f4f\u6700\u8fdc\u7684\u90a3\u4e2a\n\t/// \uff08\u5206\u88c2\u540e\u7ec6\u80de\u51b2\u51fa\u5c4f\u5e55\u5916\u5c31\u770b\u4e0d\u89c1\u4e86\uff0c\u53d6\u666f\u8981\u628a\u81ea\u5bb6\u5206\u8eab\u5305\u8fdb\u6765\uff09\u3002\n\t/// \u5ba2\u6237\u7aef\u5339\u914d\u7528 OwnerSteamId\uff1a\u5206\u8eab\u4e0e\u4e3b\u7403\u5e26\u540c\u4e00\u4e2a\u540c\u6b65\u503c\uff0c\u53cc\u5f00\u5408\u6210 ID \u4e5f\u80fd\u5bf9\u4e0a\u3002\n\t/// </summary>\n\tfloat ViewWithCells( Ball cur, float baseMass )\n\t{\n\t\tvar view = ViewForMass( baseMass );\n\n\t\tvar game = CircleroyaleGame.Current;\n\t\tif ( game is null ) return view;\n\n\t\tvar sid = cur.OwnerSteamId;\n\t\tfloat maxDist = 0f;\n\t\tforeach ( var c in game.Cells )\n\t\t{\n\t\t\tif ( c.PieceKind != CellPiece.Kind.SplitPiece || c.OwnerSteamId != sid ) continue;\n\t\t\tmaxDist = MathF.Max( maxDist, cur.WorldPosition.Distance( c.DrawPos ) );\n\t\t}\n\n\t\tif ( maxDist > 0f )\n\t\t\tview = Math.Clamp( ( cur.Radius + maxDist ) * 2.6f, view, GameConfig.CameraViewMax );\n\n\t\treturn view;\n\t}\n\n\tvoid ApplyPose()\n\t{\n\t\t// \u5c4f\u5e55\u9707\u52a8\uff08v0.7.7.0\uff09\uff1a\u6bcf\u6b21\u6446\u4f4d\u53e0\u52a0\u4e00\u4e2a\u968f\u673a\u65b9\u5411\u504f\u79fb\uff0c\u5e45\u5ea6\u6307\u6570\u8870\u51cf\u5230\u96f6\u2014\u2014\n\t\t// \u6446\u4f4d\u672c\u8eab\u9010\u5e27\u91cd\u5199\uff08\u4e0d\u542b\u6b8b\u91cf\uff09\uff0c\u9707\u5b8c\u81ea\u52a8\u5f52\u4f4d\uff1b\u540d\u724c\u6295\u5f71\u7528\u540c\u4e00\u76f8\u673a\u4f4d\u7f6e\uff0c\u6296\u52a8\u540c\u6b65\u4e00\u81f4\n\t\tvar shake = Vector3.Zero;\n\t\tif ( _shakeAmp > 0.1f )\n\t\t{\n\t\t\tvar a = Game.Random.Float( 0f, MathF.PI * 2f );\n\t\t\tshake = new Vector3( MathF.Cos( a ), MathF.Sin( a ), 0f )\n\t\t\t\t* _shakeAmp * Game.Random.Float( 0.4f, 1f );\n\t\t\t_shakeAmp *= MathF.Exp( -6f * Time.Delta );\n\t\t}\n\n\t\tGameObject.WorldPosition = _focus + Vector3.Up * GameConfig.CameraHeight + shake;\n\t\tGameObject.WorldRotation = Rotation.LookAt( Vector3.Down, Vector3.Forward );\n\t}\n\n\t/// <summary> \u76f8\u673a\u59ff\u6001\u521a\u5199\u5b8c\uff0c\u7acb\u523b\u8ba9 HUD \u7528\u540c\u4e00\u4efd\u76f8\u673a\u6570\u636e\u91cd\u6295\u540d\u724c\uff08\u96f6\u5e27\u95f4\u5dee\uff09 </summary>\n\tvoid UpdateTagPositions( CircleroyaleGame game )\n\t{\n\t\tif ( game is null ) return;\n\t\tvar ortho = Cam.IsValid() ? Cam.OrthographicHeight : 0f;\n\t\tgame.Hud?.UpdateTagPositions( game, GameObject.WorldPosition, ortho );\n\t}\n\n\tstatic float ViewForMass( float mass )\n\t{\n\t\treturn Math.Clamp( GameConfig.CameraBaseView * MathF.Sqrt( mass / GameConfig.StartMass ),\n\t\t\tGameConfig.CameraViewMin, GameConfig.CameraViewMax );\n\t}\n}"
        },
        {
            "Ident": "slfy.circleroyale",
            "Path": "Rendering/NeonRenderer.cs",
            "FileName": "NeonRenderer.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 380090,
            "IsPrivate": false,
            "Code": "using System;\n\n/// <summary>\n/// \u50cf\u7d20\u6279\u6e32\u67d3\uff08v0.7.8.36 \u6574\u4f53\u66ff\u6362\u9713\u8679\u7ebf\u6846\u98ce\uff09\uff1a\u6bcf\u5e27\u91cd\u5199\u5404\u6279\u6b21\u9876\u70b9\uff0c\u5168\u573a\u5b9e\u4f53\u7ea6 7 \u4e2a DrawCall\u3002\n/// \u8d34\u56fe\u6279\u6b21\u8d70 line.shader \u7684 UV \u91c7\u6837 + \u9876\u70b9\u8272 tint\uff08D_BLEND=0 \u666e\u901a alpha\uff09\uff1a\n/// \u7403/\u5206\u8eab/\u5b62\u5b50 = ball_body.png \u767d\u6a21 tint\uff1b\u98df\u7269 = food.png \u516d\u8272\u6761\u5e26\uff1b\u91ce\u523a/\u5c16\u523a\u5206\u8eab = spike.png\uff1b\n/// \u523a\u7206\u5f39\u5e55 = spine.png\uff08\u6309\u901f\u5ea6\u65cb\u8f6c\uff09\uff1b\u9053\u5177 = powerups.png \u56fe\u6807\u53e0\u7a0b\u5e8f\u5316\u83f1\u5f62\u5e95\u5ea7\u3002\n/// \u8868\u60c5/\u5934\u50cf\uff08v0.7.8.40 \u4ece SpriteRenderer \u8fc1\u5165\u2014\u2014sprite \u901a\u9053\u753b\u5728 OverlayWithDepth \u6279\u6b21\u4e4b\u524d\uff0c\n/// \u4f1a\u88ab\u5b9e\u5fc3\u8eab\u4f53 quad \u76d6\u4f4f\uff09\uff1abot = face_0..5 \u6309 Mood \u9009\u6279\uff0c\u771f\u4eba = SetAvatar \u767b\u8bb0\u7684\u6bcf SteamId \u8d34\u56fe\u6279\u6b21\u3002\n/// \u7ebf/\u4e09\u89d2\u6279\u6b21\uff08\u666e\u901a alpha\uff09\uff1a\u692d\u5706\u6295\u5f71\u3001CPU \u7c92\u5b50\u65b9\u5757\u3001\u7784\u51c6/\u8fb9\u7f18\u7bad\u5934\u3002\n/// \u7279\u6548\u5165\u53e3\uff08Pop/Spark/Puff/Swallow\uff09\u7b7e\u540d\u4e0d\u53d8\uff0c\u8c03\u7528\u65b9\u96f6\u6539\u52a8\u3002\n/// </summary>\npublic sealed class NeonRenderer : Component\n{\n\tSceneDynamicObject _shapeLines;   // \u7ebf\u6bb5\uff08\u666e\u901a alpha\uff09\uff1a\u9053\u5177\u5e95\u5ea7\u63cf\u8fb9/\u8fb9\u7f18\u7bad\u5934\u5c3e\u6746\n\tSceneDynamicObject _shapeTris;    // \u4e09\u89d2\u5f62\uff08\u666e\u901a alpha\uff09\uff1a\u6295\u5f71/\u7c92\u5b50/\u7784\u51c6\u7bad\u5934/\u83f1\u5f62\u586b\u5145\n\tSceneDynamicObject _bodies;       // ball_body.png\uff1a\u7403/\u5206\u8eab/\u5b62\u5b50\u8eab\u4f53\uff08\u9876\u70b9\u8272 tint\uff09\n\tSceneDynamicObject _food;         // food.png\uff1a\u7cd6\u8c46\uff08UV \u53d6\u8272\u683c\uff09\n\tSceneDynamicObject _spikes;       // spike.png\uff1a\u91ce\u523a + \u5c16\u523a\u5206\u8eab\n\tSceneDynamicObject _spines;       // spine.png\uff1a\u523a\u7206\u5f39\u5e55\uff08\u65cb\u8f6c\uff09\n\tSceneDynamicObject _icons;        // powerups.png\uff1a\u9053\u5177\u56fe\u6807\uff08UV \u53d6\u683c\uff09\n\tSceneDynamicObject _rings;        // shield_ring.png\uff1a\u62a4\u76fe buff \u865a\u7ebf\u73af\uff08\u767d\u6a21 tint\uff0c\u7ed5\u7403\u6162\u8f6c\uff0cv0.7.8.87\uff09\n\tSceneDynamicObject[] _faces;      // face_0..5\uff1abot \u8868\u60c5\uff08Mood \u9009\u6279\uff0cv0.7.8.40\uff09\n\n\t/// <summary> ball_body.png \u91cc\u7403\u4f53\u5185\u5bb9\u5360\u753b\u5e03\u6bd4\u4f8b\uff08process.py fit margin 0.06\uff09\u2014\u2014\u8d34\u56fe\u76f4\u5f84\u2192\u4e16\u754c\u76f4\u5f84\u6362\u7b97\u7528 </summary>\n\tconst float BodyContentFraction = 0.9375f;\n\n\t/// <summary> face_N.png \u91cc\u8138\u90e8\u5185\u5bb9\u5360\u753b\u5e03\u6bd4\u4f8b\u2014\u2014\u5934\u50cf\u76f4\u5f84\u2192\u4e16\u754c\u76f4\u5f84\u6362\u7b97\u7528 </summary>\n\tconst float FaceContentFraction = 0.9f;\n\n\t/// <summary>\n\t/// \u26a0\ufe0f powerups.png \u56fe\u96c6\u683c\u5b50\u987a\u5e8f\u662f\u7f8e\u672f\u5b9a\u7684\uff1aspeed/magnet/**shield/mass**/spike\uff0c\n\t/// \u4e0e Kind \u679a\u4e3e\uff08\u2026Mass=2, Shield=3\u2026\uff09\u4e0d\u540c\u5e8f\u2014\u2014\u76f4\u63a5\u62ff Kind \u5f53\u683c\u5b50\u53f7\u4f1a\u628a\u76fe\u548c\u8d28\u91cf\u7f50\n\t/// \u753b\u53cd\uff08v0.7.8.92 \u7528\u6237\u5b9e\u6d4b\"\u770b\u5230\u7684\u548c\u6361\u5230\u7684\u4e0d\u4e00\u6837\"\uff09\u3002\u4e0b\u6807=Kind\uff0c\u503c=\u56fe\u96c6\u683c\u5b50\u53f7\u3002\n\t/// </summary>\n\tstatic readonly int[] PowerKindToCell = { 0, 1, 3, 2, 4 };\n\n\tsealed class AvatarBatch\n\t{\n\t\tpublic SceneDynamicObject Dyno;\n\t\tpublic Texture Tex;\n\t\tpublic Material Mat;   // \u5b58\u6863\u7528\uff08\u8bca\u65ad\u8bfb\uff09\n\t}\n\n\t/// <summary> \u771f\u4eba Steam \u5934\u50cf\u6279\u6b21\uff08\u6bcf SteamId \u4e00\u4e2a\u72ec\u7acb\u8d34\u56fe\u6279\u6b21\uff09 </summary>\n\treadonly Dictionary<long, AvatarBatch> _avatars = new();\n\n\t/// <summary> \u5934\u50cf\u767b\u8bb0\u8868\uff08Ball.SetAvatar \u5199\uff0cOnUpdate \u6d88\u8d39\uff1b\u6e32\u67d3\u5668\u672a\u5c31\u7eea/\u70ed\u91cd\u8f7d\u671f\u95f4\u5728\u6b64\u5f85\u547d\uff09 </summary>\n\tstatic readonly Dictionary<long, Texture> _pendingAvatars = new();\n\n\t/// <summary> \u5168\u5c40\u5f15\u7528\uff08\u7279\u6548\u751f\u6210\u5165\u53e3 NeonRenderer.Pop/Spark/Puff \u8d70\u9759\u6001\u8c03\u7528\uff09 </summary>\n\tpublic static NeonRenderer Instance { get; private set; }\n\n\t/// <summary> \u5e27\u7387\u8868\uff08v0.7.8.104\uff0ccr_perf \u6e32\u67d3 A/B \u6d4b\u91cf\u7528\uff09\uff1a1 \u79d2\u7a97\u53e3\u5e73\u5747 </summary>\n\tpublic static float DebugFps { get; private set; }\n\tstatic int _fpsFrames;\n\tstatic TimeSince _fpsWindow = 999f;\n\n\t/// <summary> \u8c03\u8bd5 A/B\uff08cr_perf \u6e32\u67d3\u51cf\u8d1f\u5b9a\u4f4d\uff09\uff1a\u6574\u4f53\u9690\u85cf/\u6062\u590d\u672c\u6e32\u67d3\u5668\u5168\u90e8\u6279\u6b21 </summary>\n\tpublic void SetDebugVisible( bool visible )\n\t{\n\t\tforeach ( var d in new[] { _shapeLines, _shapeTris, _bodies, _food, _spikes, _spines, _icons, _rings } )\n\t\t\tif ( d is not null ) d.RenderingEnabled = visible;\n\t\tif ( _faces is not null )\n\t\t\tforeach ( var f in _faces )\n\t\t\t\tif ( f is not null ) f.RenderingEnabled = visible;\n\t\tforeach ( var a in _avatars.Values )\n\t\t\tif ( a.Dyno is not null ) a.Dyno.RenderingEnabled = visible;\n\t}\n\n\tprotected override void OnStart()\n\t{\n\t\tbase.OnStart();\n\n\t\tInstance = this;\n\t\t_shapeLines = CreateShapeBatch();\n\t\t_shapeTris = CreateShapeBatch();\n\t\t_bodies = CreateTexBatch( \"ui/pixel/ball_body.png\" );\n\t\t_food = CreateTexBatch( \"ui/pixel/food.png\" );\n\t\t_spikes = CreateTexBatch( \"ui/pixel/spike.png\" );\n\t\t_spines = CreateTexBatch( \"ui/pixel/spine.png\" );\n\t\t_icons = CreateTexBatch( \"ui/pixel/powerups.png\" );\n\t\t_rings = CreateTexBatch( \"ui/pixel/shield_ring.png\" );\n\t\t_faces = new SceneDynamicObject[6];\n\t\tfor ( int i = 0; i < 6; i++ )\n\t\t\t_faces[i] = CreateTexBatch( $\"ui/pixel/face_{i}.png\", topmost: true );   // 0\u5f00\u5fc3 1\u5174\u594b 2\u60ca\u6050 3\u7729\u6655 4\u778c\u7761 5\u5f97\u610f\n\t}\n\n\tprotected override void OnDestroy()\n\t{\n\t\tbase.OnDestroy();\n\n\t\t// \u26a0\ufe0f \u4e16\u754c\u91cd\u5efa\u53ea\u9500\u6bc1\u5b50 GameObject\uff0c\u4e0d\u4f1a\u9500\u6bc1\u7ec4\u4ef6\u521b\u5efa\u7684\u539f\u751f\u52a8\u6001\u6279\u6b21\u2014\u2014\u9057\u7559\u6279\u6b21\u4f1a\u5e26\u7740\n\t\t// \u6700\u540e\u4e00\u5e27\u51e0\u4f55\u7ee7\u7eed\u6e32\u67d3\uff0c\u4e14\u65e7\u6750\u8d28\u88ab\u56de\u6536\u540e\u5f15\u64ce\u56de\u9000\u9519\u8bef\u6750\u8d28\uff1a\u6ee1\u5c4f\u7ea2\u51e0\u4f55 + \u65b0\u7ba1\u7ebf\u521b\u5efa\u5d29\u6e83\n\t\t// \uff08\u5b9e\u6d4b\u7ea2\u7ebf/\u7ea2\u5757 + rendersystemvulkan \u56fa\u5b9a\u504f\u79fb 0x471a0\uff09\u3002\u7ec4\u4ef6\u9500\u6bc1\u65f6\u5fc5\u987b\u663e\u5f0f Delete\u3002\n\t\tDeleteBatch( _shapeLines );\n\t\tDeleteBatch( _shapeTris );\n\t\tDeleteBatch( _bodies );\n\t\tDeleteBatch( _food );\n\t\tDeleteBatch( _spikes );\n\t\tDeleteBatch( _spines );\n\t\tDeleteBatch( _icons );\n\t\tDeleteBatch( _rings );\n\t\tif ( _faces is not null )\n\t\t\tforeach ( var f in _faces ) DeleteBatch( f );\n\t\tforeach ( var av in _avatars.Values ) DeleteBatch( av.Dyno );\n\t\tforeach ( var sp in _avatarSpare ) DeleteBatch( sp.Dyno );\n\n\t\tif ( Instance == this ) Instance = null;\n\t}\n\n\tstatic void DeleteBatch( SceneDynamicObject dyno )\n\t{\n\t\tif ( dyno is null ) return;\n\t\ttry { dyno.Delete(); } catch { }\n\t}\n\n\t/// <summary> \u6750\u8d28\u5f3a\u5f15\u7528\u6c60\uff1a\u6750\u8d28\u82e5\u53ea\u88ab\u539f\u751f\u5bf9\u8c61\u5f15\u7528\uff0c\u6258\u7ba1\u5305\u88c5\u4e00\u65e6\u88ab GC\uff0c\u539f\u751f\u6750\u8d28\u968f\u4e4b\u56de\u6536\uff0c\n\t/// \u573a\u666f\u5bf9\u8c61\u5c31\u753b\u6210\u5f15\u64ce\u9519\u8bef\u6750\u8d28\uff08\u7ea2\u51e0\u4f55 = \u5b9e\u6d4b\u7684\"\u4f1a\u8bdd\u5185\u8dd1\u51e0\u5206\u949f\u53d8\u7ea2\"\u4e3b\u56e0\u4e4b\u4e00\uff09\u3002\u521b\u5efa\u7684\u6750\u8d28\u5168\u90e8\u7559\u6863\u3002 </summary>\n\treadonly List<Material> _mats = new();\n\n\tMaterial TrackMat( Material m )\n\t{\n\t\tif ( m is not null ) _mats.Add( m );\n\t\treturn m;\n\t}\n\n\tSceneDynamicObject CreateShapeBatch( bool topmost = false )\n\t{\n\t\tvar dyno = new SceneDynamicObject( Scene.SceneWorld );\n\t\tdyno.Material = TrackMat( LineMaterial.Create() );\n\t\tdyno.Flags.CastShadows = false;\n\t\t// \u5934\u50cf/\u8868\u60c5\u6279\u6b21\u653e OverlayWithoutDepth\uff08\u6bd4 OverlayWithDepth \u665a\u4e00\u6bb5\u7ed8\u5236\uff0c\u65e0\u6df1\u5ea6\u6d4b\u8bd5\uff09\uff1a\n\t\t// OverlayWithDepth \u5185\u90e8\u5bf9\u5404 dyno \u7684\u6392\u5e8f\u4e0d\u53d7\u9876\u70b9 z \u63a7\u5236\uff08\u5b9e\u6d4b\u8868\u60c5\u88ab z=1.0 \u7684\u8eab\u4f53\u76d6\u4f4f\uff09\n\t\tdyno.RenderLayer = topmost ? SceneRenderLayer.OverlayWithoutDepth : SceneRenderLayer.OverlayWithDepth;\n\t\tdyno.Attributes.SetCombo( \"D_BLEND\", 0 );   // \u666e\u901a alpha \u6df7\u5408\uff08\u50cf\u7d20\u98ce\u65e0\u52a0\u8272\u53d1\u5149\uff09\n\t\treturn dyno;\n\t}\n\n\tsealed class TexBatch\n\t{\n\t\tpublic SceneDynamicObject Dyno;\n\t\tpublic string Path;\n\t\tpublic Texture Tex;\n\t\tpublic Material Mat;   // \u5b58\u6863\u7528\uff08Dyno.Material \u53ea\u5199\u4e0d\u53ef\u8bfb\uff09\uff0cDumpTexState \u8bca\u65ad\u8bfb\n\t}\n\n\t/// <summary> \u8d34\u56fe\u6279\u6b21\u767b\u8bb0\uff08\u8def\u5f84\u7559\u5e95\u2014\u2014\u70ed\u91cd\u8f7d\u91cd\u5efa\u4e16\u754c\u65f6 Texture.Load \u53ef\u80fd\u77ac\u65f6\u5931\u8d25\uff0c\n\t/// OnUpdate \u91cc 1s \u8282\u6d41\u8865\u8f7d\u81ea\u6108\uff0c\u5426\u5219\u6574\u573a\u6e32\u67d3\u6210\u9519\u8bef\u7ea2\u7eb9\u7406\uff0c\u5b9e\u6d4b\uff09 </summary>\n\treadonly List<TexBatch> _texBatches = new();\n\n\tTimeSince _sinceTexHeal = 1f;\n\n\tSceneDynamicObject CreateTexBatch( string texturePath, bool topmost = false )\n\t{\n\t\tvar dyno = CreateShapeBatch( topmost );\n\n\t\tvar batch = new TexBatch { Dyno = dyno, Path = texturePath };\n\t\t_texBatches.Add( batch );\n\t\tLoadTex( batch );\n\n\t\treturn dyno;\n\t}\n\n\tvoid LoadTex( TexBatch b )\n\t{\n\t\tb.Tex = Texture.Load( b.Path, false );\n\t\tif ( b.Tex is null )\n\t\t\tLog.Warning( $\"[pixel] texture not ready: {b.Path} (1s \u540e\u81ea\u52a8\u91cd\u8bd5)\" );\n\n\t\tb.Mat = TrackMat( MakeTexMaterial( b.Tex, pixel: true ) );   // SceneDynamicObject.Material \u53ea\u5199\uff0c\u5148\u5907\u597d\u6750\u8d28\u518d\u8d4b\u503c\n\t\tb.Dyno.Material = b.Mat;\n\t}\n\n\t/// <summary> \u8bca\u65ad\uff08\u4e34\u65f6\uff0c\u7ea2\u5c4f\u6392\u67e5\u7528\uff09\uff1a\u5404\u8d34\u56fe\u6279\u6b21\u73b0\u5b58\u8d34\u56fe vs \u73b0\u573a\u91cd\u8f7d vs \u6750\u8d28\u7ed1\u5b9a\u5b9e\u51b5\u3002\n\t/// \u6392\u67e5\u5b8c\u53ef\u968f cr_tex \u5de5\u5177\u4e00\u8d77\u5220\u3002 </summary>\n\tpublic static string DumpTexState()\n\t{\n\t\tvar inst = Instance;\n\t\tif ( inst is null || !inst.IsValid() ) return \"no NeonRenderer instance\";\n\n\t\tvar sb = new System.Text.StringBuilder();\n\t\tsb.AppendLine( $\"batches={inst._texBatches.Count} avatars={inst._avatars.Count} spare={inst._avatarSpare.Count}\" );\n\n\t\tstatic string Tx( Texture t ) => t is null\n\t\t\t? \"NULL\"\n\t\t\t: $\"err={t.IsError} loaded={t.IsLoaded} {t.Width}x{t.Height} mips={t.Mips} fmt={t.ImageFormat}\";\n\n\t\tforeach ( var b in inst._texBatches )\n\t\t{\n\t\t\tvar fresh = Texture.Load( b.Path, false );\n\t\t\tvar mat = b.Mat;\n\t\t\tvar matInfo = mat is null\n\t\t\t\t? \"mat=NULL\"\n\t\t\t\t: $\"mat={mat.Name} matValid={mat.IsValid()} shValid={( mat.Shader is null ? \"?\" : mat.Shader.IsValid().ToString() )}\";\n\t\t\tsb.AppendLine( $\"{b.Path}: now[{Tx( b.Tex )}] fresh[{Tx( fresh )}] {matInfo}\" );\n\t\t}\n\n\t\tint n = 0;\n\t\tforeach ( var kv in inst._avatars )\n\t\t{\n\t\t\tsb.AppendLine( $\"avatar {kv.Key}: now[{Tx( kv.Value.Tex )}]\" );\n\t\t\tif ( ++n >= 8 ) { sb.AppendLine( $\"\u2026 +{inst._avatars.Count - n} more\" ); break; }\n\t\t}\n\n\t\t// \u73b0\u573a\u63a2\u9488\uff1a\u6b64\u523b\u65b0\u5efa\u4e00\u4e2a line.shader \u6750\u8d28\uff0c\u62a5\u544a\u6750\u8d28/\u7740\u8272\u5668/\u8d34\u56fe\u7ed1\u5b9a\u662f\u5426\u53ef\u7528\n\t\t// \uff08\u7ea2\u5c4f = \u6240\u6709\u6279\u6b21\u56de\u9000\u9519\u8bef\u6750\u8d28\u65f6\uff0c\u8fd9\u91cc\u80fd\u76f4\u63a5\u6307\u51fa\u662f\u6750\u8d28\u521b\u5efa\u5931\u8d25\u8fd8\u662f\u7ed1\u5b9a\u5931\u8d25\uff09\n\t\tvar probe = Material.Create( \"cr_probe\", \"shaders/line.shader\" );\n\t\tif ( probe is null )\n\t\t\tsb.AppendLine( \"probe: Material.Create -> NULL\" );\n\t\telse\n\t\t{\n\t\t\tvar sh = probe.Shader;\n\t\t\tsb.AppendLine( $\"probe: valid={probe.IsValid()} shaderName='{probe.ShaderName}' shader={( sh is null ? \"NULL\" : sh.IsValid().ToString() )} first={( probe.FirstTexture is null ? \"NULL\" : \"tex\" )}\" );\n\t\t\tvar probeTex = Texture.Load( \"ui/pixel/food.png\", false );\n\t\t\tbool setOk = false;\n\t\t\ttry { setOk = probe.Set( \"g_tColor\", probeTex ); } catch ( System.Exception e ) { sb.AppendLine( $\"probe set threw: {e.GetType().Name}\" ); }\n\t\t\tsb.AppendLine( $\"probe set g_tColor -> {setOk}\" );\n\t\t}\n\t\treturn sb.ToString();\n\t}\n\n\t// \u2014\u2014 \u4e34\u65f6\u8bca\u65ad\uff08\u7ea2\u5c4f\u6392\u67e5\uff09\uff1a\u6bcf 30s \u81ea\u68c0\u4e00\u6b21\"\u6750\u8d28\u94fe\"\u5065\u5eb7\uff08\u6750\u8d28/\u7740\u8272\u5668\u6709\u6548 + \u8d34\u56fe\u53ef\u7ed1\u5b9a +\n\t//    \u7eb9\u7406\u975e\u9519\u8bef\u7eb9\u7406\uff09\uff0c\u53ea\u5728\u72b6\u6001\u7ffb\u8f6c\u65f6\u6253\u65e5\u5fd7\u2014\u2014\u6545\u969c\u53d1\u751f\u7684\u65f6\u523b\u4f1a\u81ea\u5df1\u5199\u8fdb\u65e5\u5fd7\u6587\u4ef6\u3002\u6392\u67e5\u5b8c\u5220\u9664\u3002\u2014\u2014\n\tstatic Material _probeMat;\n\tstatic int _probeState = -1;   // -1 \u672a\u521d\u59cb\u5316\uff1b0 OK\uff1b1 FAIL\n\tTimeSince _sinceProbe = 30f;\n\n\tvoid ProbeMaterialHealth()\n\t{\n\t\tif ( _sinceProbe < 30f ) return;\n\t\t_sinceProbe = 0;\n\n\t\tif ( _probeMat is null )\n\t\t\t_probeMat = Material.Create( \"cr_probe\", \"shaders/line.shader\" );\n\n\t\tvar tex = Texture.Load( \"ui/pixel/food.png\", false );\n\t\tbool matOk = _probeMat is not null && _probeMat.IsValid();\n\t\tbool shOk = matOk && _probeMat.Shader is not null && _probeMat.Shader.IsValid();\n\t\tbool setOk = false;\n\t\tif ( matOk ) { try { setOk = _probeMat.Set( \"g_tColor\", tex ); } catch { } }\n\t\tbool texOk = tex is not null && !tex.IsError;\n\t\tbool ok = matOk && shOk && setOk && texOk;\n\n\t\tint state = ok ? 0 : 1;\n\t\tif ( state != _probeState )\n\t\t{\n\t\t\t_probeState = state;\n\t\t\tGameLog.Info( $\"[pixel] material probe {( ok ? \"OK\" : \"FAIL\" )} (mat={matOk} shader={shOk} set={setOk} tex={texOk})\" );\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// \u8d34\u56fe\u6279\u6b21\u6750\u8d28\uff08static\uff1aTerritoryRenderer \u7684\u65d7/\u57ce\u5821\u8d34\u56fe\u6279\u6b21\u540c\u6b3e\u590d\u7528\uff09\uff1a\u72ec\u7acb\u5b9e\u4f8b\u3002\n\t/// pixel=true \u6362 **Point/Nearest \u70b9\u91c7\u6837**\uff08v0.7.8.93\uff09\uff1a\u50cf\u7d20\u8d34\u56fe\u653e\u5927\u5341\u51e0\u500d\u5fc5\u987b\u6700\u8fd1\u90bb\uff0c\n\t/// \u53cc\u7ebf\u6027\u4f1a\u628a\u50cf\u7d20\u9897\u7c92\u7cca\u6389\u2014\u2014\u65e7\u5b9e\u73b0\u610f\u56fe\u5c31\u662f point \u91c7\u6837\uff0c\u4f46 SamplerIndex \u5199\u6b7b 0\uff08\u9ed8\u8ba4\n\t/// bilinear \u69fd\u4f4d\uff09\u4ece\u672a\u751f\u6548\u3002\u8d70 line.shader \u7684 Bindless::GetSampler(SamplerIndex)\uff0c\u69fd\u4f4d\u7531\n\t/// SamplerState.Index \u73b0\u53d6\uff08\u5f15\u64ce\u5185\u90e8\u6709\u7f13\u5b58\uff09\u3002\u5934\u50cf\u7b49\u7167\u7247\u4fdd\u6301\u9ed8\u8ba4 bilinear\uff08\u70b9\u91c7\u6837\u4f1a\u952f\u9f7f\uff09\u3002\n\t/// SamplerIndex \u8bbe\u4e0d\u8fdb\u53bb\u4e0d\u81f4\u547d\uff08\u56de\u9000\u9ed8\u8ba4\u91c7\u6837\uff0c\u53ea\u662f\u7cca\uff09\u3002\n\t/// </summary>\n\tpublic static Material MakeTexMaterial( Texture tex, bool pixel = false )\n\t{\n\t\tvar mat = LineMaterial.Create();\n\t\ttry\n\t\t{\n\t\t\tif ( pixel )\n\t\t\t\tmat.Set( \"SamplerIndex\", PixelSamplerIndex() );\n\t\t\telse\n\t\t\t\tmat.Set( \"SamplerIndex\", 0 );\n\t\t}\n\t\tcatch { }\n\t\tif ( tex is not null ) mat.Set( \"g_tColor\", tex );\n\t\treturn mat;\n\t}\n\n\tstatic int? _pointSamplerIndex;\n\n\t/// <summary> Point \u91c7\u6837\u7684 bindless \u69fd\u4f4d\uff08\u61d2\u53d6\uff1a\u9759\u6001\u521d\u59cb\u5316\u4f1a\u5728\u6e32\u67d3\u8bbe\u5907\u5c31\u7eea\u524d\u8dd1\uff0c\u4e13\u7528\u670d\u52a1\u5668\u4e0a\u4f1a\u70b8\uff09 </summary>\n\tstatic int PixelSamplerIndex()\n\t{\n\t\t_pointSamplerIndex ??= new Sandbox.Rendering.SamplerState { Filter = Sandbox.Rendering.FilterMode.Point }.Index;\n\t\treturn _pointSamplerIndex.Value;\n\t}\n\n\t/// <summary> \u771f\u4eba\u5934\u50cf\u767b\u8bb0\uff08Ball \u5404\u7aef\u81ea\u53d6 Steam \u5934\u50cf\u540e\u8c03\uff1b\u6bcf SteamId \u4e00\u4e2a\u72ec\u7acb\u8d34\u56fe\u6279\u6b21\uff09 </summary>\n\tpublic static void SetAvatar( long steamId, Texture tex )\n\t{\n\t\tif ( steamId == 0 || tex is null ) return;\n\t\t_pendingAvatars[steamId] = tex;\n\t}\n\n\t/// <summary> \u6d88\u5316\u5934\u50cf\u767b\u8bb0\uff1a\u7f3a\u6279\u6b21\u8865\u5efa\u3001\u8d34\u56fe\u53d8\u4e86\u6362\u6750\u8d28\uff08\u6e32\u67d3\u5668\u672a\u5c31\u7eea\u65f6\u7684\u767b\u8bb0\u7559\u5230\u4e0b\u4e00\u5e27\u751f\u6548\uff09 </summary>\n\tvoid SyncAvatars()\n\t{\n\t\tforeach ( var kv in _pendingAvatars )\n\t\t{\n\t\t\tif ( _avatars.TryGetValue( kv.Key, out var av ) && av.Tex == kv.Value ) continue;\n\n\t\t\tif ( av is null )\n\t\t\t{\n\t\t\t\tav = TakeAvatarBatch();\n\t\t\t\t_avatars[kv.Key] = av;\n\t\t\t}\n\t\t\tav.Mat = TrackMat( MakeTexMaterial( kv.Value ) );\n\t\t\tav.Dyno.Material = av.Mat;\n\t\t\tav.Tex = kv.Value;\n\t\t}\n\t}\n\n\treadonly HashSet<long> _avatarSeen = new();\n\treadonly List<long> _avatarDrop = new();\n\treadonly List<AvatarBatch> _avatarSpare = new();   // \u56de\u6536\u6765\u590d\u7528\u7684\u7a7a\u6279\u6b21\uff08\u907f\u514d\u73b0\u5220\u539f\u751f\u5bf9\u8c61\uff09\n\tTimeSince _sinceAvatarPrune = 5f;\n\n\t/// <summary> \u53d6\u4e00\u4e2a\u53ef\u7528\u6279\u6b21\uff1a\u4f18\u5148\u590d\u7528\u56de\u6536\u6c60\uff0c\u6c60\u7a7a\u624d\u65b0\u5efa\uff08v0.7.8.87\uff09 </summary>\n\tAvatarBatch TakeAvatarBatch()\n\t{\n\t\tif ( _avatarSpare.Count > 0 )\n\t\t{\n\t\t\tvar spare = _avatarSpare[^1];\n\t\t\t_avatarSpare.RemoveAt( _avatarSpare.Count - 1 );\n\t\t\treturn spare;\n\t\t}\n\t\treturn new AvatarBatch { Dyno = CreateShapeBatch( topmost: true ) };\n\t}\n\n\t/// <summary>\n\t/// \u56de\u6536\u4e0d\u5728\u573a\u73a9\u5bb6\u7684\u5934\u50cf\u6279\u6b21\uff08v0.7.8.87\uff09\uff1a`_avatars` \u662f\"\u6bcf SteamId \u4e00\u4e2a\u52a8\u6001\u6279\u6b21 + \u6750\u8d28\"\u7684\u91cd\u8d44\u6e90\uff0c\n\t/// \u4e0d\u56de\u6536\u4f1a\u968f\"\u8fd9\u53f0\u673a\u5668\u89c1\u8fc7\u7684\u73a9\u5bb6\u603b\u6570\"\u4e00\u76f4\u6da8\u3002\u8d34\u56fe\u767b\u8bb0\u7559\u5728\u9759\u6001 `_pendingAvatars`\uff08\u5f88\u8f7b\uff09\uff0c\n\t/// \u540c\u4e00\u73a9\u5bb6\u518d\u5165\u573a\u65f6 SyncAvatars \u4f1a\u590d\u7528\u6279\u6b21\u91cd\u5efa\u3002\n\t/// \u26a0\ufe0f \u53ea\u6e05\u51e0\u4f55\u3001\u8fdb\u56de\u6536\u6c60\uff0c**\u4e0d\u8c03\u7528 SceneObject.Delete()**\uff1a\u6279\u6b21\u662f\"\u6bcf\u5e27 Clear + \u91cd\u65b0 Init\"\u9a71\u52a8\u7684\uff0c\n\t/// \u5728\u6e38\u620f\u5faa\u73af\u91cc\u9500\u6bc1\u539f\u751f\u573a\u666f\u5bf9\u8c61\u98ce\u9669\u9ad8\uff08\u6e05\u51e0\u4f55\u5373\u53ef\u8ba9\u8131\u7ba1\u6279\u6b21\u4e0d\u518d\u663e\u793a\uff0c\u907f\u514d\u5e7d\u7075\u5934\u50cf\uff09\n\t/// </summary>\n\tvoid PruneAvatars( CircleroyaleGame game )\n\t{\n\t\tif ( _avatars.Count == 0 || _sinceAvatarPrune < 5f ) return;\n\t\t_sinceAvatarPrune = 0;\n\n\t\t_avatarSeen.Clear();\n\t\tforeach ( var b in game.Balls )\n\t\t{\n\t\t\tif ( b.IsValid() && !b.IsBot ) _avatarSeen.Add( b.OwnerSteamId );   // \u6b7b\u4ea1\u5f85\u91cd\u751f\u7684\u7403\u4ecd\u7b97\u5728\u573a\n\t\t}\n\n\t\t_avatarDrop.Clear();\n\t\tforeach ( var kv in _avatars )\n\t\t{\n\t\t\tif ( !_avatarSeen.Contains( kv.Key ) ) _avatarDrop.Add( kv.Key );\n\t\t}\n\n\t\tforeach ( var k in _avatarDrop )\n\t\t{\n\t\t\tvar av = _avatars[k];\n\t\t\t_avatars.Remove( k );\n\t\t\tif ( av?.Dyno is null ) continue;\n\n\t\t\tav.Dyno.Clear();   // \u6e05\u6389\u6700\u540e\u4e00\u5e27\u7684\u51e0\u4f55\uff1a\u8131\u7ba1\u540e\u6ca1\u4eba\u518d\u6e05\uff0c\u7559\u7740\u5c31\u662f\u573a\u666f\u91cc\u7684\u5e7d\u7075\u5934\u50cf\n\t\t\tav.Tex = null;\n\t\t\tif ( _avatarSpare.Count < 24 ) _avatarSpare.Add( av );\n\t\t}\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tbase.OnUpdate();\n\t\tif ( !Game.IsPlaying ) return;\n\n\t\t// \u5e27\u7387\u8868\uff08v0.7.8.104\uff0ccr_perf A/B \u6d4b\u91cf\u7528\uff09\n\t\t_fpsFrames++;\n\t\tif ( _fpsWindow > 1f )\n\t\t{\n\t\t\tDebugFps = _fpsFrames / _fpsWindow;\n\t\t\t_fpsFrames = 0;\n\t\t\t_fpsWindow = 0;\n\t\t}\n\n\t\tvar game = CircleroyaleGame.Current;\n\t\tif ( game is null || _shapeTris is null || _bodies is null || _faces is null ) return;\n\n\t\t_shapeLines.Clear();\n\t\t_shapeLines.Init( Graphics.PrimitiveType.Lines );\n\t\t_shapeTris.Clear();\n\t\t_shapeTris.Init( Graphics.PrimitiveType.Triangles );\n\t\t_bodies.Clear();\n\t\t_bodies.Init( Graphics.PrimitiveType.Triangles );\n\t\t_food.Clear();\n\t\t_food.Init( Graphics.PrimitiveType.Triangles );\n\t\t_spikes.Clear();\n\t\t_spikes.Init( Graphics.PrimitiveType.Triangles );\n\t\t_spines.Clear();\n\t\t_spines.Init( Graphics.PrimitiveType.Triangles );\n\t\t_icons.Clear();\n\t\t_icons.Init( Graphics.PrimitiveType.Triangles );\n\t\t_rings.Clear();\n\t\t_rings.Init( Graphics.PrimitiveType.Triangles );\n\n\t\tSyncAvatars();\n\t\tPruneAvatars( game );\n\n\t\t// \u8d34\u56fe\u81ea\u6108\uff081s \u8282\u6d41\uff09\uff1a\u70ed\u91cd\u8f7d/\u8d44\u4ea7\u5237\u65b0\u671f\u95f4\u52a0\u8f7d\u5931\u8d25\u7684\u6279\u6b21\u8865\u8f7d\n\t\tif ( _sinceTexHeal > 1f )\n\t\t{\n\t\t\t_sinceTexHeal = 0;\n\t\t\tforeach ( var b in _texBatches )\n\t\t\t{\n\t\t\t\tif ( b.Tex is not null ) continue;\n\t\t\t\tLoadTex( b );\n\t\t\t}\n\t\t}\n\n\t\tProbeMaterialHealth();\n\n\t\tforeach ( var f in _faces )\n\t\t{\n\t\t\tf.Clear();\n\t\t\tf.Init( Graphics.PrimitiveType.Triangles );\n\t\t}\n\t\tforeach ( var av in _avatars.Values )\n\t\t{\n\t\t\tav.Dyno.Clear();\n\t\t\tav.Dyno.Init( Graphics.PrimitiveType.Triangles );\n\t\t}\n\n\t\tUpdateParticles();\n\t\tUpdateWallPulses( Time.Delta );\n\n\t\t// ---- \u7403\uff1a\u692d\u5706\u6295\u5f71 + tint \u767d\u6a21 + \u51fa\u751f\u4fdd\u62a4\u95ea\u70c1 ----\n\t\tforeach ( var ball in game.Balls )\n\t\t{\n\t\t\tif ( !ball.IsValid() || !ball.Alive ) continue;\n\n\t\t\tvar c = ball.WorldPosition;\n\t\t\tvar r = ball.Radius;\n\n\t\t\t// \u6295\u5f71\u753b\u5728\u811a\u4e0b\uff1a\u5c4f\u5e55\u4e0b\u65b9 = \u4e16\u754c -X\uff08\u76f8\u673a 90\u00b0 \u6eda\u8f6c\uff09\uff0c\u6a2a\u957f\u7ad6\u6241\n\t\t\tAddShadowEllipse( c + new Vector3( -r * 0.12f, 0f, 0.25f ), r * 0.32f, r * 0.72f );\n\n\t\t\t// \u51fa\u751f\u4fdd\u62a4\uff1a\u6574\u7403\u65b9\u6ce2\u95ea\u70c1\uff08\u6697\u76f8 35%\uff09\uff0c\u95ea\u70c1\u5728\u8eab\u4f53 tint \u4e0a\uff0c\u5934\u50cf\u4fdd\u6301\u53ef\u89c1\n\t\t\tvar blink = 1f;\n\t\t\tif ( ball.IsProtected )\n\t\t\t\tblink = MathF.Sin( Time.Now * GameConfig.SpawnBlinkHz * MathF.PI * 2f ) > 0f ? 1f : 0.35f;\n\t\t\tvar tint = new Color( ball.NeonColor.r, ball.NeonColor.g, ball.NeonColor.b, blink );\n\n\t\t\tAddQuad( _bodies, c, r / BodyContentFraction, r / BodyContentFraction, 0f,\n\t\t\t\tnew Vector4( 0f, 0f, 1f, 1f ), tint, 1.0f );\n\n\t\t\t// \u62a4\u76fe buff\uff08M6.5\uff09\uff1a\u767d\u6a21\u865a\u7ebf\u73af\u67d3\u9053\u5177\u84dd\uff0c\u7ed5\u7403\u6162\u8f6c\uff08v0.7.8.87 \u63a5 shield_ring.png\uff09\n\t\t\tif ( ball.HasBuff( PowerUpManager.Kind.Shield ) )\n\t\t\t\tAddQuad( _rings, c, r * 1.32f, r * 1.32f, Time.Now * 0.7f,\n\t\t\t\t\tnew Vector4( 0f, 0f, 1f, 1f ), PowerUpManager.ColorOf( (byte)PowerUpManager.Kind.Shield ), 1.05f );\n\n\t\t\t// \u5934\u50cf\uff08v0.7.8.40\uff09\uff1abot=\u8868\u60c5\u8138\uff08Mood \u9009\u6279\uff09\uff0c\u771f\u4eba=Steam \u5934\u50cf\uff1b\u76f4\u5f84=\u534a\u5f84\uff0c\u753b\u5728\u8eab\u4f53\u4e4b\u4e0a\n\t\t\tDrawAvatar( c, r, ball.IsBot, ball.Mood, ball.OwnerSteamId, 1.1f );\n\n\t\t\t// \u7784\u51c6\u7bad\u5934\uff08v0.7.0.0\uff09\uff1a\u672c\u673a\u7403\u5916\u7f18\u767d\u8272\u5c0f\u4e09\u89d2\u6307\u5411\u5149\u6807\uff0c\u4ec5\u5bf9\u5c40\u4e2d\u663e\u793a\n\t\t\tif ( game.IsMatchStarted && ball == game.LocalBall && ball.AimDir.Length > 0.01f )\n\t\t\t\tWriteAimArrow( ball );\n\t\t}\n\n\t\t// ---- \u5206\u8eab/\u5b62\u5b50/\u5c16\u523a\u5206\u8eab\uff08\u7eaf\u6570\u636e\u5b9e\u4f53\uff09\uff1atint \u767d\u6a21 / \u523a\u8d34\u56fe\uff0c\u51fa\u751f\u5f39\u8df3\u4fdd\u7559 ----\n\t\tforeach ( var c in game.Cells )\n\t\t{\n\t\t\tvar color = GameConfig.Palette[Math.Clamp( c.ColorIndex, 0, GameConfig.Palette.Length - 1 )];\n\t\t\tvar pos = new Vector3( c.DrawPos.x, c.DrawPos.y, 0f );\n\t\t\tvar r = c.Radius;\n\n\t\t\t// \u51fa\u751f\u5f39\u8df3\uff08v0.7.8.28 juice\uff09\uff1a\u65b0 id \u9996\u5e27\u8bb0\u65f6\u523b\uff0c0.3s \u5185\u7f29\u653e\u8109\u51b2\u3002\u5ba2\u6237\u7aef\u955c\u50cf\u7528\"\u9996\u89c1 id\"\u5224\u5b9a\uff0c\u53cc\u7aef\u7edf\u4e00\n\t\t\tif ( !_seenCells.Contains( c.Id ) )\n\t\t\t{\n\t\t\t\t_seenCells.Add( c.Id );\n\t\t\t\t_cellBorn[c.Id] = 0;\n\t\t\t\tif ( _seenCells.Count > 512 ) PruneCellFx( game );\n\t\t\t}\n\t\t\tif ( _cellBorn.TryGetValue( c.Id, out var born ) && born < 0.3f )\n\t\t\t{\n\t\t\t\tvar phase = born / 0.3f;\n\t\t\t\tr *= phase < 0.3f\n\t\t\t\t\t? MathX.Lerp( 0.55f, 1.14f, phase / 0.3f )\n\t\t\t\t\t: MathX.Lerp( 1.14f, 1f, ( phase - 0.3f ) / 0.7f );\n\t\t\t}\n\n\t\t\tif ( c.PieceKind == CellPiece.Kind.SpikeMinion )\n\t\t\t{\n\t\t\t\t// \u5c16\u523a\u5206\u8eab\uff08v0.7.4.0\uff09\uff1a\u523a\u65cf\u8d34\u56fe\uff08\u7d2b\u8272=\u662f\u523a\uff09\uff0c\u5c3a\u5bf8\u4e0d\u5c0f\u4e8e\u91ce\u523a\u89c2\u611f\n\t\t\t\tvar sr = MathF.Max( r, 40f );\n\t\t\t\tAddQuad( _spikes, pos, sr, sr, 0f, new Vector4( 0f, 0f, 1f, 1f ), Color.White, 0.65f );\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\t// \u5206\u8eab/\u5b62\u5b50\uff1a\u540c\u4e00\u5f20\u767d\u6a21 tint\uff08\u5b62\u5b50\u7565\u7f29\uff0c\u89c2\u611f=\u5c0f\u7cd6\u8c46\u56e2\uff09\n\t\t\t\tvar scale = c.PieceKind == CellPiece.Kind.EjectedMass ? 0.85f : 1f;\n\t\t\t\tvar half = r * scale / BodyContentFraction;\n\t\t\t\tvar z = c.PieceKind == CellPiece.Kind.EjectedMass ? 0.8f : 0.9f;\n\t\t\t\tAddQuad( _bodies, pos, half, half, 0f, new Vector4( 0f, 0f, 1f, 1f ), color, z );\n\n\t\t\t\t// \u5206\u8eab\u5934\u50cf\uff08v0.7.8.40 \u81ea PieceAvatarLayer \u5e76\u5165\uff09\uff1a\u53ea\u7ed9 SplitPiece\uff0c\u76f4\u5f84=\u5206\u8eab\u534a\u5f84\n\t\t\t\tif ( c.PieceKind == CellPiece.Kind.SplitPiece )\n\t\t\t\t{\n\t\t\t\t\tvar owner = game.FindBallBySteamId( c.OwnerSteamId );\n\t\t\t\t\tDrawAvatar( pos, r, owner.IsValid() && owner.IsBot,\n\t\t\t\t\t\towner.IsValid() ? owner.Mood : (byte)0, c.OwnerSteamId, 0.95f );\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// ---- \u91ce\u523a\uff1a\u5582\u98df\u957f\u5927\u540e\u76f4\u63a5\u7f29\u653e\u8d34\u56fe ----\n\t\tvar spikes = game.Spikes;\n\t\tif ( spikes is not null )\n\t\t{\n\t\t\tfor ( int i = 0; i < spikes.Length; i++ )\n\t\t\t{\n\t\t\t\tvar grow = 1f + GameConfig.SpikeGrowVisual * Math.Clamp( game.SpikeFedAt( i ) / GameConfig.SpikeGrowTarget, 0f, 1f );\n\t\t\t\tvar half = GameConfig.SpikeRadius * grow;\n\t\t\t\tAddQuad( _spikes, new Vector3( spikes[i].x, spikes[i].y, 0f ), half, half, 0f,\n\t\t\t\t\tnew Vector4( 0f, 0f, 1f, 1f ), Color.White, 0.6f );\n\t\t\t}\n\t\t}\n\n\t\t// ---- \u523a\u7206\u5f39\u5e55\uff08v0.7.8.24\uff09\uff1a\u523a\u5f39\u8d34\u56fe\u6309\u901f\u5ea6\u65b9\u5411\u65cb\u8f6c\uff0c\u968f\u5bff\u547d\u6de1\u51fa ----\n\t\tforeach ( var sp in game.Spines )\n\t\t{\n\t\t\tvar remain = 1f - Math.Clamp( sp.SinceSpawn / GameConfig.SpineLife, 0f, 1f );\n\t\t\tvar dir = sp.Vel;\n\t\t\tif ( dir.Length < 0.001f ) continue;\n\t\t\tvar rot = MathF.Atan2( dir.y, dir.x );\n\t\t\tvar tint = new Color( 1f, 1f, 1f, 0.35f + 0.65f * remain );\n\t\t\tAddQuad( _spines, new Vector3( sp.Pos.x, sp.Pos.y, 0f ), 17f, 17f, rot,\n\t\t\t\tnew Vector4( 0f, 0f, 1f, 1f ), tint, 1.2f );\n\t\t}\n\n\t\t// ---- \u9053\u5177\uff08v0.7.3.0\uff09\uff1a\u65cb\u8f6c\u83f1\u5f62\u5e95\u5ea7\uff08\u666e\u901a alpha\uff09+ \u79cd\u7c7b\u56fe\u6807\u8d34\u56fe ----\n\t\tvar power = game.PowerUps;\n\t\tif ( power is not null && power.Ready )\n\t\t{\n\t\t\tvar spin = Time.Now * 1.6f;\n\t\t\tvar breathe = 1f + MathF.Sin( Time.Now * 3f ) * 0.12f;\n\n\t\t\tforeach ( var s in power.Slots )\n\t\t\t{\n\t\t\t\tif ( !s.Alive ) continue;\n\n\t\t\t\tvar pos = new Vector3( s.Pos.x, s.Pos.y, 0f );\n\t\t\t\tvar kindColor = PowerUpManager.ColorOf( s.Kind );\n\t\t\t\tvar r = GameConfig.PowerUpRadius * breathe;\n\n\t\t\t\tWriteDiamond( pos, r, spin, kindColor );\n\n\t\t\t\tvar k = PowerKindToCell[Math.Clamp( s.Kind % 5, 0, 4 )];\n\t\t\t\tvar uv = new Vector4( k / 5f, 0f, ( k + 1 ) / 5f, 1f );\n\t\t\t\tAddQuad( _icons, pos + Vector3.Up * 1.3f, r * 1.35f, r * 1.35f, MathF.PI * 0.5f, uv, Color.White, 1.3f );\n\t\t\t}\n\t\t}\n\n\t\t// ---- \u98df\u7269\uff1a\u7cd6\u8c46\u8d34\u56fe\u6309\u8272\u53f7\u53d6 UV \u683c\uff08\u89c6\u53e3\u5254\u9664\u4fdd\u7559\uff09 ----\n\t\tvar foods = game.Food?.Foods;\n\t\tif ( foods is not null )\n\t\t{\n\t\t\t// \u89c6\u53e3\u5254\u9664\uff08v0.6.3.0\uff09\uff1a4140 \u9897\u98df\u7269\u53ea\u6709 ~20% \u5728\u753b\u9762\u91cc\u3002\n\t\t\t// \u26a0\ufe0f \u8f74\u5411\uff08v0.7.8.87 \u4fee\uff09\uff1a\u76f8\u673a\u56fa\u5b9a 90\u00b0 \u6eda\u8f6c\u2014\u2014\u5c4f\u5e55\u6a2a\u5411=\u4e16\u754c Y\u3001\u5c4f\u5e55\u7eb5\u5411=\u4e16\u754c X\n\t\t\t// \uff08\u89c1 GameHud.UpdateTagPositions \u540c\u4e00\u5957\u6295\u5f71\u5f0f\uff09\uff0c\u6240\u4ee5\"\u6309\u5bbd\u9ad8\u6bd4\u653e\u5f00\u7684\u6a2a\u5411\u4f59\u91cf\"\u5fc5\u987b\u7ed9 Y\u3001\n\t\t\t// \u7a84\u4f59\u91cf\u7ed9 X\u3002\u6b64\u524d\u4e24\u8f74\u5199\u53cd\uff1a\u5c4f\u5e55\u5de6\u53f3\u4e24\u4fa7\u4ecd\u53ef\u89c6\u8303\u56f4\u5185\u7684\u98df\u7269\u88ab\u5254\u6389\uff08\u89c2\u611f\u5c31\u662f\u7f3a\u98df\u7269\uff09\n\t\t\tvar fr = GameConfig.FoodRadius;\n\t\t\tvar cullCx = Scene.Camera.WorldPosition.x;\n\t\t\tvar cullCy = Scene.Camera.WorldPosition.y;\n\t\t\tvar cullAspect = MathF.Max( 2.4f, Screen.Height > 0f ? Screen.Width / Screen.Height : 2.4f );\n\t\t\tvar cullHx = Scene.Camera.OrthographicHeight * 0.5f + 64f;                 // \u4e16\u754c X = \u5c4f\u5e55\u7eb5\u5411\uff08\u7a84\uff09\n\t\t\tvar cullHy = Scene.Camera.OrthographicHeight * 0.5f * cullAspect + 64f;    // \u4e16\u754c Y = \u5c4f\u5e55\u6a2a\u5411\uff08\u5bbd\uff0c\u8d85\u5bbd\u5c4f\u4e0d\u7f3a\u5de6\u53f3\u98df\u7269\uff09\n\n\t\t\tfor ( int i = 0; i < foods.Length; i++ )\n\t\t\t{\n\t\t\t\tif ( !foods[i].Alive ) continue;\n\n\t\t\t\tvar fx = foods[i].Pos.x;\n\t\t\t\tvar fy = foods[i].Pos.y;\n\t\t\t\tif ( MathF.Abs( fx - cullCx ) > cullHx || MathF.Abs( fy - cullCy ) > cullHy ) continue;\n\n\t\t\t\tvar k = foods[i].ColorIndex % 6;\n\t\t\t\tvar uv = new Vector4( k / 6f, 0f, ( k + 1 ) / 6f, 1f );\n\t\t\t\tAddQuad( _food, new Vector3( fx, fy, 0f ), fr / 0.95f, fr / 0.95f, 0f, uv, Color.White, 0.55f );\n\t\t\t}\n\t\t}\n\n\t\tDrawParticles();\n\t\tDrawWallPulses();\n\t\tDrawEdgeArrows();\n\t}\n\n\t// ---- \u5c4f\u5e55\u8fb9\u7f18\u961f\u53cb\u6307\u793a\u7bad\u5934\uff08v0.7.8.12\uff09\uff1aGameHud \u540d\u724c\u91cd\u6295\u65f6\u5199\u5165\u8bf7\u6c42\uff0c\u672c\u7ec4\u4ef6 OnUpdate \u672b\u5c3e\u6d88\u8d39 ----\n\n\tsealed class EdgeArrowReq\n\t{\n\t\tpublic Vector3 Pos;      // \u8d34\u8fb9\u70b9\uff08\u4e16\u754c\uff09\n\t\tpublic Vector2 Dir;      // \u6307\u5411\u961f\u53cb\u771f\u5b9e\u65b9\u4f4d\uff08\u4e16\u754c\uff0c\u5355\u4f4d\u5411\u91cf\uff09\n\t\tpublic Color Color;\n\t\tpublic float Size;       // \u4e16\u754c\u5355\u4f4d\uff08\u6309\u6bcf\u50cf\u7d20\u4e16\u754c\u5355\u4f4d\u6298\u7b97\uff0c\u4fdd\u6301\u50cf\u7d20\u5927\u5c0f\u6052\u5b9a\uff09\n\t}\n\n\tstatic readonly List<EdgeArrowReq> _edgeArrows = new();\n\n\t/// <summary> \u8bf7\u6c42\u4e00\u652f\u8fb9\u7f18\u7bad\u5934\uff08\u8d34\u8fb9\u4e16\u754c\u70b9 + \u6307\u5411 + \u989c\u8272\uff09\u3002OnUpdate \u6d88\u8d39\u540e\u6e05\u7a7a\uff0c\u665a\u4e00\u5e27\u65e0\u611f </summary>\n\tpublic static void EdgeArrow( Vector3 pos, Vector2 dir, Color color, float size )\n\t{\n\t\t_edgeArrows.Add( new EdgeArrowReq { Pos = pos, Dir = dir, Color = color, Size = size } );\n\t}\n\n\t/// <summary> \u5b9e\u5fc3\u4e09\u89d2\u7bad\u5934 + \u77ed\u5c3e\u6746\uff0c\u671d\u5411 Dir </summary>\n\tvoid DrawEdgeArrows()\n\t{\n\t\tfor ( int i = 0; i < _edgeArrows.Count; i++ )\n\t\t{\n\t\t\tvar r = _edgeArrows[i];\n\t\t\tvar d = new Vector3( r.Dir.x, r.Dir.y, 0f );\n\t\t\tvar perp = new Vector3( -r.Dir.y, r.Dir.x, 0f );\n\t\t\tvar len = r.Size;\n\t\t\tvar halfW = len * 0.5f;\n\n\t\t\tvar tip = r.Pos + d * len;\n\t\t\tvar b1 = r.Pos + perp * halfW;\n\t\t\tvar b2 = r.Pos - perp * halfW;\n\n\t\t\tAddTri( _shapeTris, b1, b2, tip, r.Color * 0.9f );\n\n\t\t\t// \u5c3e\u6746\uff1a\u4ece\u7bad\u5934\u5411\u5c4f\u5185\u5ef6\u4f38\u4e00\u5c0f\u6bb5\uff0c\u63d0\u793a\"\u65b9\u5411\"\u800c\u975e\"\u4f4d\u7f6e\u70b9\"\n\t\t\tvar t0 = r.Pos - d * len * 0.6f;\n\t\t\t_shapeLines.AddVertex( new Vertex( t0, r.Color * 0.5f ) );\n\t\t\t_shapeLines.AddVertex( new Vertex( r.Pos, r.Color * 0.5f ) );\n\t\t}\n\t\t_edgeArrows.Clear();\n\t}\n\n\t// ---- CPU \u7c92\u5b50\uff08M3 \u8868\u73b0\u529b\uff09\uff1a\u666e\u901a alpha \u6279\u6b21\u65b9\u5757\u7c92\u5b50 ----\n\n\t/// <summary> \u7784\u51c6\u7bad\u5934\uff08v0.7.0.0\uff09\uff1a\u672c\u673a\u7403\u5916\u7f18\u7684\u767d\u8272\u5b9e\u5fc3\u5c0f\u4e09\u89d2\uff0c\u6307\u5411\u5149\u6807\uff08\u5206\u88c2/\u5410\u5b62\u5b50\u65b9\u5411\uff09 </summary>\n\tvoid WriteAimArrow( Ball ball )\n\t{\n\t\tvar dir = ball.AimDir;\n\t\tvar c = ball.WorldPosition;\n\t\tvar r = ball.Radius;\n\n\t\tvar len = Math.Clamp( r * 0.22f, 18f, 64f );   // \u7bad\u5934\u957f\u5ea6\u968f\u7403\u5f84\u5fae\u7f29\u653e\n\t\tvar halfW = len * 0.42f;\n\t\tvar gap = r * 1.04f;\n\n\t\tvar dir3 = new Vector3( dir.x, dir.y, 0f );\n\t\tvar perp = new Vector3( -dir.y, dir.x, 0f );\n\n\t\tvar baseC = c + dir3 * gap;\n\t\tvar tip = c + dir3 * ( gap + len );\n\t\tvar p1 = baseC + perp * halfW;\n\t\tvar p2 = baseC - perp * halfW;\n\n\t\tAddTri( _shapeTris, p1, p2, tip, new Color( 1f, 1f, 1f, 0.55f ) );\n\t}\n\n\t/// <summary> \u5355\u4e2a\u5b9e\u5fc3\u4e09\u89d2 </summary>\n\tvoid AddTri( SceneDynamicObject batch, Vector3 a, Vector3 b, Vector3 c, Color color )\n\t{\n\t\tbatch.AddVertex( new Vertex( a, color ) );\n\t\tbatch.AddVertex( new Vertex( b, color ) );\n\t\tbatch.AddVertex( new Vertex( c, color ) );\n\t}\n\n\t/// <summary> \u8d34\u56fe\u56db\u8fb9\u5f62\uff08\u4e24\u4e2a\u4e09\u89d2\uff09\uff1acenter \u4e2d\u5fc3\u3001half \u534a\u5bbd/\u534a\u9ad8\u3001rot \u5f27\u5ea6\u3001uv=(x0,y0,x1,y1) </summary>\n\tstatic void AddQuad( SceneDynamicObject b, Vector3 center, float halfX, float halfY, float rot, Vector4 uv, Color color, float z )\n\t{\n\t\tvar cos = MathF.Cos( rot );\n\t\tvar sin = MathF.Sin( rot );\n\t\tvar rx = cos * halfX; var ry = sin * halfX;      // right \u8f74\n\t\tvar ux = -sin * halfY; var uy = cos * halfY;     // up \u8f74\n\n\t\tvar v0 = new Vertex( new Vector3( center.x - rx - ux, center.y - ry - uy, z ), new Vector4( uv.x, uv.y, 0f, 0f ), color );\n\t\tvar v1 = new Vertex( new Vector3( center.x + rx - ux, center.y + ry - uy, z ), new Vector4( uv.z, uv.y, 0f, 0f ), color );\n\t\tvar v2 = new Vertex( new Vector3( center.x + rx + ux, center.y + ry + uy, z ), new Vector4( uv.z, uv.w, 0f, 0f ), color );\n\t\tvar v3 = new Vertex( new Vector3( center.x - rx + ux, center.y - ry + uy, z ), new Vector4( uv.x, uv.w, 0f, 0f ), color );\n\n\t\tb.AddVertex( v0 ); b.AddVertex( v1 ); b.AddVertex( v2 );\n\t\tb.AddVertex( v0 ); b.AddVertex( v2 ); b.AddVertex( v3 );\n\t}\n\n\t/// <summary> \u5934\u50cf/\u8868\u60c5\uff1a\u76f4\u5f84=\u7403\u534a\u5f84\uff08\u5360\u76f4\u5f84 50%\uff0c\u7528\u6237\u6307\u5b9a\uff09\uff1bbot \u4ece face_N \u6279\u6b21\u53d6\uff08Mood \u9009\u683c\uff09\uff0c\n\t/// \u771f\u4eba\u4ece Steam \u5934\u50cf\u6279\u6b21\u53d6\u3002\u6279\u6b21\u5728 OverlayWithoutDepth \u5c42\uff0c\u5929\u7136\u753b\u5728\u8eab\u4f53\uff08OverlayWithDepth\uff09\u4e4b\u4e0a\u3002\n\t/// rot=\u03c0/2\uff1a\u76f8\u673a 90\u00b0 \u6eda\u8f6c\u4e0b\u8d34\u56fe u \u8f74\u671d\u5c4f\u5e55\u7ad6\u76f4\uff0c\u4e0d\u8f6c\u7684\u8bdd\u8138\u662f\u6a2a\u8eba\u7684\uff08\u98df\u7269/\u7403\u4f53\u7b49\u5706\u5f62\u770b\u4e0d\u51fa\u6765\uff09 </summary>\n\tvoid DrawAvatar( Vector3 c, float radius, bool bot, byte mood, long steamId, float z )\n\t{\n\t\tvar half = radius * 0.5f / FaceContentFraction;\n\n\t\tif ( bot )\n\t\t{\n\t\t\tvar k = Math.Clamp( (int)mood, 0, 5 );\n\t\t\tAddQuad( _faces[k], c, half, half, MathF.PI * 0.5f, new Vector4( 0f, 0f, 1f, 1f ), Color.White, z );\n\t\t}\n\t\telse if ( _avatars.TryGetValue( steamId, out var av ) )\n\t\t{\n\t\t\tAddQuad( av.Dyno, c, half, half, MathF.PI * 0.5f, new Vector4( 0f, 0f, 1f, 1f ), Color.White, z );\n\t\t}\n\t}\n\n\t/// <summary> \u692d\u5706\u6295\u5f71\uff08\u4e16\u754c XY \u5e73\u9762\uff1aax=\u4e16\u754c X \u534a\u8f74=\u5c4f\u5e55\u7ad6\u5411\uff0cay=\u4e16\u754c Y \u534a\u8f74=\u5c4f\u5e55\u6a2a\u5411\uff09 </summary>\n\tvoid AddShadowEllipse( Vector3 center, float ax, float ay, int segments = 14 )\n\t{\n\t\tsegments = Math.Max( 6, segments );\n\t\tvar prev = center + new Vector3( ax, 0f, 0f );\n\t\tfor ( int i = 1; i <= segments; i++ )\n\t\t{\n\t\t\tvar a = i * MathF.PI * 2f / segments;\n\t\t\tvar p = center + new Vector3( MathF.Cos( a ) * ax, MathF.Sin( a ) * ay, 0f );\n\t\t\tAddTri( _shapeTris, center, prev, p, new Color( 0.59f, 0.67f, 0.82f, 0.30f ) );\n\t\t\tprev = p;\n\t\t}\n\t}\n\n\tsealed class Particle\n\t{\n\t\tpublic Vector3 Pos;\n\t\tpublic Vector3 Vel;\n\t\tpublic float Life;\n\t\tpublic float MaxLife;\n\t\tpublic float Size;\n\t\tpublic Color Color;\n\t\tpublic Vector3 Target;   // Seek=true\uff1a\u5438\u5f15\u76ee\u6807\uff08\u541e\u7403\u6ed1\u8fdb\u5403\u8005\u5634\u91cc\uff09\n\t\tpublic bool Seek;\n\t}\n\n\treadonly List<Particle> _particles = new();\n\treadonly HashSet<int> _seenCells = new();                    // \u51fa\u751f\u5f39\u8df3\uff1a\u89c1\u8fc7\u7684\u5206\u8eab/\u5b62\u5b50 id\uff08\u5ba2\u6237\u7aef\u955c\u50cf\u4e0d\u5e26 SinceSpawn\uff0c\u7528\"\u9996\u89c1\"\u53cc\u7aef\u7edf\u4e00\u5224\u5b9a\uff09\n\treadonly Dictionary<int, TimeSince> _cellBorn = new();\n\n\t/// <summary> \u7206\u88c2\uff08\u6b7b\u4ea1/\u649e\u523a\uff09\uff1a\u4e2d\u5927\u7c92\u5b50\u5f84\u5411\u98de\u6563 </summary>\n\tpublic static void Pop( Vector3 pos, int colorIndex, float radius )\n\t{\n\t\tSpawn( pos, colorIndex, 26, 90f + radius * 0.9f, 0.45f, 0.9f, 3f, 9f );\n\t}\n\n\t/// <summary> \u706b\u82b1\uff08\u5403\u98df\u7269\uff09\uff1a\u51e0\u9897\u5c0f\u788e\u5c51\u4e00\u95ea </summary>\n\tpublic static void Spark( Vector3 pos, int colorIndex )\n\t{\n\t\tSpawn( pos, colorIndex, 5, 100f, 0.2f, 0.4f, 2f, 4f );\n\t}\n\n\t/// <summary> \u706b\u82b1\uff08\u81ea\u5b9a\u4e49\u8272\uff0c\u9053\u5177\u62fe\u53d6\u7528\uff09\uff1a\u79cd\u7c7b\u8272\u4e0d\u5728\u8c03\u8272\u677f\u91cc\uff0c\u76f4\u63a5\u7ed9 Color </summary>\n\tpublic static void Spark( Vector3 pos, Color color )\n\t{\n\t\tSpawnColored( pos, color, 10, 140f, 0.25f, 0.5f, 2f, 5f );\n\t}\n\n\t/// <summary> \u55b7\u53d1\uff08\u5206\u88c2/\u5410\u5b62\u5b50\u751f\u6210\u77ac\u95f4\uff09 </summary>\n\tpublic static void Puff( Vector3 pos, int colorIndex )\n\t{\n\t\tSpawn( pos, colorIndex, 8, 150f, 0.25f, 0.5f, 2f, 5f );\n\t}\n\n\t/// <summary> \u541e\u7403\u6ed1\u5165\uff08v0.7.8.28 juice\uff09\uff1a\u88ab\u5403\u8005\u7f29\u6210\u4e00\u56e2\u3001\u52a0\u901f\u6ed1\u8fdb\u5403\u8005\u5634\u91cc\uff0c\u5c3a\u5bf8\u968f\u5bff\u547d\u6536\u7f29\u5f52\u96f6 </summary>\n\tpublic static void Swallow( Vector3 pos, int colorIndex, Vector3 target, float radius )\n\t{\n\t\tvar inst = Instance;\n\t\tif ( inst is null || !inst.IsValid() ) return;\n\n\t\tif ( inst._particles.Count >= GameConfig.FxMaxParticles )\n\t\t\tinst._particles.RemoveAt( 0 );\n\n\t\tvar life = 0.3f;\n\t\tinst._particles.Add( new Particle\n\t\t{\n\t\t\tPos = pos,\n\t\t\tTarget = target,\n\t\t\tSeek = true,\n\t\t\tLife = life,\n\t\t\tMaxLife = life,\n\t\t\tSize = MathF.Max( 10f, radius * 0.9f ),\n\t\t\tColor = GameConfig.Palette[Math.Clamp( colorIndex, 0, GameConfig.Palette.Length - 1 )],\n\t\t} );\n\t\tSpawn( pos, colorIndex, 4, 120f, 0.15f, 0.3f, 2f, 4f );   // \u5c11\u91cf\u788e\u5c51\u70b9\u7f00\n\t}\n\n\tstatic void Spawn( Vector3 pos, int colorIndex, int count, float maxSpeed, float minLife, float maxLife, float minSize, float maxSize )\n\t{\n\t\tSpawnColored( pos, GameConfig.Palette[Math.Clamp( colorIndex, 0, GameConfig.Palette.Length - 1 )],\n\t\t\tcount, maxSpeed, minLife, maxLife, minSize, maxSize );\n\t}\n\n\tstatic void SpawnColored( Vector3 pos, Color color, int count, float maxSpeed, float minLife, float maxLife, float minSize, float maxSize )\n\t{\n\t\tvar inst = Instance;\n\t\tif ( inst is null || !inst.IsValid() ) return;\n\n\t\tfor ( int i = 0; i < count; i++ )\n\t\t{\n\t\t\tif ( inst._particles.Count >= GameConfig.FxMaxParticles )\n\t\t\t\tinst._particles.RemoveAt( 0 );   // \u6324\u6389\u6700\u8001\u7684\uff0c\u4fdd\u5e27\u7387\u4f18\u5148\n\n\t\t\tvar a = Game.Random.Float( 0f, MathF.PI * 2f );\n\t\t\tvar sp = maxSpeed * Game.Random.Float( 0.35f, 1f );\n\t\t\tvar life = Game.Random.Float( minLife, maxLife );\n\t\t\tinst._particles.Add( new Particle\n\t\t\t{\n\t\t\t\tPos = pos + new Vector3( Game.Random.Float( -6f, 6f ), Game.Random.Float( -6f, 6f ), 0f ),\n\t\t\t\tVel = new Vector3( MathF.Cos( a ) * sp, MathF.Sin( a ) * sp, 0f ),\n\t\t\t\tLife = life,\n\t\t\t\tMaxLife = life,\n\t\t\t\tSize = Game.Random.Float( minSize, maxSize ),\n\t\t\t\tColor = color,\n\t\t\t} );\n\t\t}\n\t}\n\n\tvoid UpdateParticles()\n\t{\n\t\tvar dt = Time.Delta;\n\t\tfor ( int i = _particles.Count - 1; i >= 0; i-- )\n\t\t{\n\t\t\tvar p = _particles[i];\n\t\t\tp.Life -= dt;\n\t\t\tif ( p.Life <= 0f )\n\t\t\t{\n\t\t\t\t_particles.RemoveAt( i );\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif ( p.Seek )\n\t\t\t{\n\t\t\t\tp.Pos = Vector3.Lerp( p.Pos, p.Target, 1f - MathF.Exp( -11f * dt ) );   // \u6307\u6570\u5438\u5411\u5403\u8005\u5634\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tp.Pos += p.Vel * dt;\n\t\t\t\tp.Vel *= MathF.Exp( -3.5f * dt );   // \u963b\u5c3c\uff0c\u98de\u6563\u540e\u51cf\u901f\u60ac\u6d6e\u6d88\u6563\n\t\t\t}\n\t\t}\n\t}\n\n\t/// <summary> \u65b9\u5757\u7c92\u5b50\uff08\u50cf\u7d20\u98ce\uff09\uff1a\u8f74\u5bf9\u9f50\u5c0f\u65b9\u5757\uff0c\u5c3a\u5bf8/\u900f\u660e\u5ea6\u968f\u5bff\u547d\u8870\u51cf </summary>\n\tvoid DrawParticles()\n\t{\n\t\tforeach ( var p in _particles )\n\t\t{\n\t\t\tvar a = p.Life / p.MaxLife;\n\t\t\tvar size = p.Size * ( 0.5f + a * 0.5f );\n\t\t\tvar col = new Color( p.Color.r, p.Color.g, p.Color.b, ( 0.25f + a * 0.75f ) * 0.9f );\n\t\t\tAddQuad( _shapeTris, p.Pos, size * 0.5f, size * 0.5f, 0f, new Vector4( 0f, 0f, 1f, 1f ), col, 1.4f );\n\t\t}\n\t}\n\n\t// ---- \u649e\u5899\u8109\u51b2\uff08v0.7.8.78\uff09\uff1a\u7403\u88ab\u56f4\u5899\u94b3\u4f4f\u65f6\uff0c\u63a5\u89e6\u70b9\u4e00\u5708\u5c0f\u65b9\u5757\u5411\u5916\u6269\u6563\u6de1\u51fa\u2014\u2014\"\u5899\u6321\u4f4f\u4e86\"\u7684\u53cd\u9988 ----\n\n\tconst float WallPulseLife = 0.4f;\n\n\tsealed class WallPulseFx\n\t{\n\t\tpublic Vector3 Pos;\n\t\tpublic float Age;\n\t\tpublic Color Color;\n\t}\n\n\treadonly List<WallPulseFx> _wallPulses = new();\n\n\t/// <summary> \u56f4\u5899\u53d7\u51fb\u8109\u51b2\uff1a\u63a5\u89e6\u70b9 + \u649e\u7403\u989c\u8272\uff08\u7a0b\u5e8f\u5316\u65b9\u5757\u73af\uff1b\u65e5\u540e\u53ef\u6362\u8d34\u56fe\u73af\uff09 </summary>\n\tpublic static void WallPulse( Vector3 pos, Color color )\n\t{\n\t\tvar inst = Instance;\n\t\tif ( inst is null || !inst.IsValid() ) return;\n\n\t\tif ( inst._wallPulses.Count >= 24 ) inst._wallPulses.RemoveAt( 0 );\n\t\tinst._wallPulses.Add( new WallPulseFx { Pos = pos, Age = 0f, Color = color } );\n\t}\n\n\tvoid UpdateWallPulses( float dt )\n\t{\n\t\tfor ( int i = _wallPulses.Count - 1; i >= 0; i-- )\n\t\t{\n\t\t\t_wallPulses[i].Age += dt;\n\t\t\tif ( _wallPulses[i].Age >= WallPulseLife ) _wallPulses.RemoveAt( i );\n\t\t}\n\t}\n\n\tvoid DrawWallPulses()\n\t{\n\t\tforeach ( var w in _wallPulses )\n\t\t{\n\t\t\tvar t = w.Age / WallPulseLife;\n\t\t\tvar r = 8f + t * 54f;                              // \u534a\u5f84\u5916\u6269\n\t\t\tvar alpha = ( 1f - t ) * 0.85f;                    // \u6e10\u9690\n\t\t\tvar size = 8f - t * 3f;                            // \u65b9\u5757\u968f\u6269\u6563\u7565\u7f29\n\t\t\tvar col = new Color( w.Color.r, w.Color.g, w.Color.b, alpha );\n\n\t\t\tfor ( int s = 0; s < 14; s++ )\n\t\t\t{\n\t\t\t\tvar a = s * MathF.PI * 2f / 14f;\n\t\t\t\tvar p = w.Pos + new Vector3( MathF.Cos( a ) * r, MathF.Sin( a ) * r, 0f );\n\t\t\t\tAddQuad( _shapeTris, p, size * 0.5f, size * 0.5f, 0f, new Vector4( 0f, 0f, 1f, 1f ), col, 1.4f );\n\t\t\t}\n\t\t}\n\t}\n\n\t/// <summary> \u51fa\u751f\u5f39\u8df3\u6ce8\u518c\u8868\u8d85\u9650\u65f6\u6574\u4f53\u91cd\u5efa\uff08\u5f53\u524d\u6d3b\u7740\u7684 id \u91cd\u65b0\u7acb\u518c\uff0c\u91cd\u7f6e\u77ac\u95f4\u8f7b\u5fae\u518d\u5f39\u4e00\u6b21\uff0c\u7f55\u89c1\u53ef\u63a5\u53d7\uff09 </summary>\n\tvoid PruneCellFx( CircleroyaleGame game )\n\t{\n\t\t_seenCells.Clear();\n\t\t_cellBorn.Clear();\n\t\tforeach ( var c in game.Cells )\n\t\t\t_seenCells.Add( c.Id );\n\t}\n\n\t/// <summary> \u9053\u5177\u83f1\u5f62\u5e95\u5ea7\uff1a\u65cb\u8f6c\u7684\u6b63\u65b9\u5f62\uff08\u534a\u900f\u660e\u586b\u5145 + \u63cf\u8fb9\uff09\uff0c\u50cf\u7d20\u98ce\u4e0b\u4f5c\u56fe\u6807\u7684\u886c\u5e95 </summary>\n\tvoid WriteDiamond( Vector3 c, float r, float spin, Color color )\n\t{\n\t\tvar pts = new Vector3[4];\n\t\tfor ( int i = 0; i < 4; i++ )\n\t\t{\n\t\t\tvar a = spin + i * MathF.PI * 0.5f;\n\t\t\tpts[i] = c + new Vector3( MathF.Cos( a ) * r, MathF.Sin( a ) * r, 0.7f );\n\t\t}\n\n\t\tfor ( int i = 0; i < 4; i++ )\n\t\t{\n\t\t\tAddTri( _shapeTris, c + Vector3.Up * 0.7f, pts[i], pts[( i + 1 ) % 4], color * 0.35f );\n\t\t\t_shapeLines.AddVertex( new Vertex( pts[i], color ) );\n\t\t\t_shapeLines.AddVertex( new Vertex( pts[( i + 1 ) % 4], color ) );\n\t\t}\n\t}\n}"
        },
        {
            "Ident": "slfy.circleroyale",
            "Path": "Rendering/TerritoryRenderer.cs",
            "FileName": "TerritoryRenderer.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 380090,
            "IsPrivate": false,
            "Code": "using System;\n\n/// <summary>\n/// \u9886\u571f\u6218\u4e89\u5730\u9762\u6e32\u67d3\uff08M7\uff0cv0.7.8.79 \u50cf\u7d20\u98ce\u91cd\u5199\uff09\uff1a\u5360\u9886\u683c = \u961f\u8272\u5e95 + \u7c97\u8fb9\u6846 + \u65d7\u70b9\u65d7\u5b50\uff08flag.png\n/// \u767d\u6a21\u67d3\u961f\u8272\uff09\uff0c\u5927\u672c\u8425 = \u57ce\u5821\uff08castle.png \u767d\u6a21\u67d3\u961f\u8272\uff09\uff1b\u4e89\u593a\u4e2d\u7684\u4e2d\u7acb\u683c = \u767d\u8fb9\u6846 + \u4e89\u593a\u65b9\u8272\u8fdb\u5ea6\u6761\uff1b\n/// \u5206\u6570\u8d8a\u9ad8\u5e95\u8272\u8d8a\u5b9e\u3002\u72b6\u6001\u7248\u672c\u53d8\u5316\u624d\u91cd\u5efa\u51e0\u4f55\uff0816 \u683c\u9876\u70b9\u91cf\u6781\u5c0f\uff09\uff1b\u8d34\u56fe\u6279\u6b21 1s \u81ea\u6108\u8865\u8f7d\n/// \uff08NeonRenderer \u540c\u6b3e\uff09\u3002CreateBackdrop \u5148\u4e8e Neon \u5efa\u672c\u7ec4\u4ef6 \u2192 \u540c\u5c42\u7ed8\u5236\u5728\u7403/\u98df\u7269\u4e4b\u4e0b\u3002\u975e\u9886\u571f\u6a21\u5f0f\u6e05\u7a7a\u3002\n/// \u26a0\ufe0f \u76f8\u673a 90\u00b0 \u6eda\u8f6c\uff1a\u65d7/\u57ce\u5821\u8fd9\u7c7b\u6709\u65b9\u5411\u7684\u8d34\u56fe\u5fc5\u987b rot=\u03c0/2 \u624d\u5c4f\u5e55\u6b63\u7acb\uff08\u8868\u60c5/\u5934\u50cf\u540c\u6b3e\u6559\u8bad\uff09\u3002\n/// </summary>\npublic sealed class TerritoryRenderer : Component\n{\n\t/// <summary> \u5168\u5c40\u5f15\u7528\uff08cr_perf \u6e32\u67d3 A/B \u7528\uff1bNeonRenderer.Instance \u540c\u6b3e\uff09 </summary>\n\tpublic static TerritoryRenderer Instance { get; private set; }\n\n\tSceneDynamicObject _shape;    // \u683c\u5e95/\u7c97\u8fb9\u6846/\u8fdb\u5ea6\u6761\uff08\u666e\u901a alpha \u4e09\u89d2\u5f62\uff09\n\tSceneDynamicObject _flags;    // flag.png\uff08\u767d\u6a21 tint\uff09\n\tSceneDynamicObject _castles;  // castle.png\uff08\u767d\u6a21 tint\uff09\n\tint _builtVersion = -1;\n\tfloat _builtHalf = -1f;\n\tTimeSince _sinceRebuild = 999f;   // \u91cd\u5efa\u8282\u6d41\uff08\u5582\u65d7\u9ad8\u5cf0 host 5Hz \u5feb\u7167\uff0c\u5168\u6e05\u91cd\u5199\u4e09\u6279\u51e0\u4f55\u8fc7\u9891\u4f1a\u95ea\uff09\n\n\tsealed class TexBatch\n\t{\n\t\tpublic SceneDynamicObject Dyno;\n\t\tpublic string Path;\n\t\tpublic Texture Tex;\n\t\tpublic Material Mat;   // \u6750\u8d28\u5f3a\u5f15\u7528\uff08\u6258\u7ba1\u5305\u88c5\u88ab GC \u4f1a\u8fde\u5e26\u56de\u6536\u539f\u751f\u6750\u8d28 \u2192 \u7ea2\u51e0\u4f55\uff0c\u5b9e\u6d4b\uff09\n\t}\n\n\t/// <summary> \u6750\u8d28\u5f3a\u5f15\u7528\u6c60\uff1a\u6750\u8d28\u53ea\u88ab\u539f\u751f\u5bf9\u8c61\u5f15\u7528\u65f6\uff0c\u6258\u7ba1\u5305\u88c5\u88ab GC \u540e\u539f\u751f\u6750\u8d28\u968f\u4e4b\u56de\u6536\uff0c\n\t/// \u573a\u666f\u5bf9\u8c61\u753b\u6210\u5f15\u64ce\u9519\u8bef\u6750\u8d28\uff08\u7ea2\u51e0\u4f55\uff09\u3002\u6240\u6709\u521b\u5efa\u7684\u6750\u8d28\u5728\u6b64\u7559\u6863\u3002 </summary>\n\treadonly List<Material> _mats = new();\n\n\tMaterial TrackMat( Material m )\n\t{\n\t\tif ( m is not null ) _mats.Add( m );\n\t\treturn m;\n\t}\n\n\treadonly List<TexBatch> _texBatches = new();\n\tTimeSince _sinceTexHeal = 1f;\n\n\t/// <summary> \u8c03\u8bd5 A/B\uff08v0.7.8.104\uff0ccr_perf \u6e32\u67d3\u51cf\u8d1f\u5b9a\u4f4d\uff09\uff1a\u9690\u85cf/\u6062\u590d\u5168\u90e8\u9886\u571f\u6279\u6b21\n\t/// \uff08\u7ec4\u4ef6 Enabled \u5173\u4e0d\u6389\u5df2\u6784\u5efa\u7684\u539f\u751f\u6279\u6b21\u2014\u2014SceneObject.RenderingEnabled \u624d\u662f\u771f\u5f00\u5173\uff09 </summary>\n\tpublic void SetDebugVisible( bool visible )\n\t{\n\t\tif ( _shape is not null ) _shape.RenderingEnabled = visible;\n\t\tif ( _flags is not null ) _flags.RenderingEnabled = visible;\n\t\tif ( _castles is not null ) _castles.RenderingEnabled = visible;\n\t\tforeach ( var b in _texBatches )\n\t\t\tif ( b.Dyno is not null ) b.Dyno.RenderingEnabled = visible;\n\t}\n\n\tconst float BorderThickness = 18f;         // \u683c\u8fb9\u6846\u7c97\u7ec6\uff08\u4e16\u754c\u5355\u4f4d\uff0c\u50cf\u7d20\u7c97\u8fb9\u89c2\u611f\uff09\n\tconst float FlagWorldHeight = 400f;        // \u65d7\u5b50\u4e16\u754c\u9ad8\u5ea6\uff08\u7ea6 1/7.5 \u683c\u5bbd\uff09\n\tconst float CastleWorldFraction = 0.30f;   // \u57ce\u5821\u5360\u683c\u5bbd\u6bd4\u4f8b\n\tconst float BarWidth = 380f;               // \u8fdb\u5ea6\u6761\u5bbd\n\tconst float BarHeight = 36f;               // \u8fdb\u5ea6\u6761\u9ad8\n\tconst float FlagTexAspect = 32f / 52f;     // flag.png \u5bbd\u9ad8\u6bd4\n\n\tprotected override void OnStart()\n\t{\n\t\tbase.OnStart();\n\t\tInstance = this;\n\t\t_shape = CreateShapeBatch();\n\t\t_flags = CreateTexBatch( \"ui/pixel/flag.png\" );\n\t\t_castles = CreateTexBatch( \"ui/pixel/castle.png\" );\n\t}\n\n\tprotected override void OnDestroy()\n\t{\n\t\tbase.OnDestroy();\n\n\t\t// \u7ec4\u4ef6\u9500\u6bc1\u65f6\u5fc5\u987b\u663e\u5f0f\u5220\u9664\u539f\u751f\u6279\u6b21\uff1a\u4e16\u754c\u91cd\u5efa\u53ea\u9500\u6bc1\u5b50 GameObject\uff0c\u9057\u7559\u6279\u6b21\u4f1a\u5e26\u6700\u540e\u4e00\u5e27\n\t\t// \u51e0\u4f55\u7ee7\u7eed\u6e32\u67d3\uff0c\u65e7\u6750\u8d28\u56de\u6536\u540e\u56de\u9000\u9519\u8bef\u6750\u8d28\u2014\u2014\u6ee1\u5c4f\u7ea2\u51e0\u4f55 + Vulkan \u7ba1\u7ebf\u5d29\u6e83\u7684\u5143\u51f6\uff08\u5b9e\u6d4b\uff09\n\t\tDeleteBatch( _shape );\n\t\tDeleteBatch( _flags );\n\t\tDeleteBatch( _castles );\n\t}\n\n\tstatic void DeleteBatch( SceneDynamicObject dyno )\n\t{\n\t\tif ( dyno is null ) return;\n\t\ttry { dyno.Delete(); } catch { }\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tbase.OnUpdate();\n\t\tif ( !Game.IsPlaying ) return;\n\n\t\tvar half = GameConfig.ArenaHalfSize;\n\n\t\tif ( TerritoryManager.Active )\n\t\t{\n\t\t\t// \u91cd\u5efa\u8282\u6d41\uff08\u22650.25s\uff09\uff1a\u5582\u65d7\u9ad8\u5cf0 host 5Hz \u5feb\u7167\uff0c\u5168\u6e05\u91cd\u5199\u4e09\u6279\u51e0\u4f55\u8fc7\u9891\u4f1a\u95ea\uff1b\n\t\t\t// \u9519\u8fc7\u7684\u7248\u672c\u4e0d\u8ffd\uff0c\u4e0b\u4e00\u62cd\u81ea\u7136\u753b\u4e0a\uff08\u8fdb\u5ea6\u5dee 1/5 \u683c\u5206\u4e0d\u6e05\uff09\n\t\t\tif ( ( _builtVersion != TerritoryManager.StateVersion || MathF.Abs( _builtHalf - half ) > 0.5f )\n\t\t\t\t&& _sinceRebuild > 0.25f )\n\t\t\t\tRebuild( half );\n\t\t}\n\t\telse if ( _builtVersion >= 0 )\n\t\t{\n\t\t\tClear();\n\t\t}\n\n\t\t// \u8d34\u56fe\u81ea\u6108\uff081s \u8282\u6d41\uff09\uff1a\u70ed\u91cd\u8f7d/\u8d44\u4ea7\u5237\u65b0\u671f\u95f4\u52a0\u8f7d\u5931\u8d25\u8865\u8f7d\uff0c\u5426\u5219\u6574\u573a\u9519\u8bef\u7ea2\u7eb9\u7406\uff08NeonRenderer \u5b9e\u6d4b\uff09\n\t\tif ( _sinceTexHeal > 1f )\n\t\t{\n\t\t\t_sinceTexHeal = 0;\n\t\t\tforeach ( var b in _texBatches )\n\t\t\t{\n\t\t\t\tif ( b.Tex is not null ) continue;\n\t\t\t\tLoadTex( b );\n\t\t\t}\n\t\t}\n\t}\n\n\tvoid Clear()\n\t{\n\t\t_builtVersion = -1;\n\t\tif ( _shape.IsValid() ) _shape.Clear();\n\t\tif ( _flags.IsValid() ) _flags.Clear();\n\t\tif ( _castles.IsValid() ) _castles.Clear();\n\t}\n\n\tSceneDynamicObject CreateShapeBatch()\n\t{\n\t\tvar dyno = new SceneDynamicObject( Scene.SceneWorld );\n\t\tdyno.Material = TrackMat( LineMaterial.Create() );\n\t\tdyno.Flags.CastShadows = false;\n\t\tdyno.RenderLayer = SceneRenderLayer.OverlayWithDepth;\n\t\tdyno.Attributes.SetCombo( \"D_BLEND\", 0 );   // \u50cf\u7d20\u98ce\u666e\u901a alpha\uff08\u52a0\u8272\u53d1\u5149\u5df2\u968f\u9713\u8679\u98ce\u5e9f\u5f03\uff09\n\t\treturn dyno;\n\t}\n\n\tSceneDynamicObject CreateTexBatch( string path )\n\t{\n\t\tvar b = new TexBatch { Dyno = CreateShapeBatch(), Path = path };\n\t\t_texBatches.Add( b );\n\t\tLoadTex( b );\n\t\treturn b.Dyno;\n\t}\n\n\tvoid LoadTex( TexBatch b )\n\t{\n\t\tb.Tex = Texture.Load( b.Path, false );\n\t\tif ( b.Tex is null )\n\t\t\tLog.Warning( $\"[pixel] texture not ready: {b.Path} (1s \u540e\u81ea\u52a8\u91cd\u8bd5)\" );\n\t\tb.Mat = TrackMat( NeonRenderer.MakeTexMaterial( b.Tex, pixel: true ) );   // Material \u53ea\u5199\uff1a\u5148\u5907\u597d\u518d\u8d4b\n\t\tb.Dyno.Material = b.Mat;\n\t}\n\n\tvoid Rebuild( float half )\n\t{\n\t\t_builtVersion = TerritoryManager.StateVersion;\n\t\t_builtHalf = half;\n\t\t_sinceRebuild = 0;\n\n\t\t// \u8bca\u65ad\uff08\u4e34\u65f6\uff0c\u5ba2\u6237\u7aef HUD \u7206\u95ea\u6392\u67e5\uff09\uff1a\u8bb0\u5f55\u6bcf\u6b21\u91cd\u5efa\u7684\u65f6\u523b\u4e0e\u7248\u672c\uff0c\u7528 Log\uff08\u5ba2\u6237\u7aef\u65e5\u5fd7\u4e5f\u6253\uff09\n\t\tLog.Info( $\"[terr] rebuild v={TerritoryManager.StateVersion} half={half:0}\" );\n\n\t\t_shape.Clear();\n\t\t_shape.Init( Graphics.PrimitiveType.Triangles );\n\t\t_flags.Clear();\n\t\t_flags.Init( Graphics.PrimitiveType.Triangles );\n\t\t_castles.Clear();\n\t\t_castles.Init( Graphics.PrimitiveType.Triangles );\n\n\t\tvar grid = GameConfig.TerritoryGrid;\n\t\tvar s = TerritoryManager.CellSize;\n\t\tvar flagHalfH = FlagWorldHeight * 0.5f;\n\t\tvar flagHalfW = flagHalfH * FlagTexAspect;\n\t\tvar castleHalf = s * CastleWorldFraction * 0.5f;\n\n\t\tfor ( int cy = 0; cy < grid; cy++ )\n\t\t{\n\t\t\tfor ( int cx = 0; cx < grid; cx++ )\n\t\t\t{\n\t\t\t\tTerritoryManager.CellState( cx, cy, out var owner, out var scoreTeam, out var score );\n\t\t\t\tvar frac = Math.Clamp( score / GameConfig.TerritoryCaptureScore, 0f, 1f );\n\t\t\t\tvar center = TerritoryManager.CellCenter( cx, cy );\n\n\t\t\t\tvar x0 = -half + cx * s;\n\t\t\t\tvar x1 = -half + ( cx + 1 ) * s;\n\t\t\t\tvar y0 = -half + cy * s;\n\t\t\t\tvar y1 = -half + ( cy + 1 ) * s;\n\t\t\t\tvar isHq = IsHq( cx, cy );\n\n\t\t\t\t// \u4e3b\u8272\uff1a\u5f52\u5c5e\u65b9\u961f\u8272\uff1b\u4e89\u593a\u4e2d\u4e2d\u7acb=\u767d\u8fb9\u6846+\u4e89\u593a\u65b9\u8fdb\u5ea6\uff1b\u65e0\u4eba\u95ee\u6d25=\u7070\u65d7\n\t\t\t\tColor teamColor;\n\t\t\t\tbool contested = false;\n\t\t\t\tif ( owner >= 0 )\n\t\t\t\t\tteamColor = MatchState.TeamColor( owner );\n\t\t\t\telse if ( scoreTeam >= 0 && score > 0f )\n\t\t\t\t{\n\t\t\t\t\tteamColor = MatchState.TeamColor( scoreTeam );\n\t\t\t\t\tcontested = true;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t\tteamColor = new Color( 0.62f, 0.65f, 0.73f );\n\n\t\t\t\tif ( owner >= 0 || contested )\n\t\t\t\t{\n\t\t\t\t\t// \u683c\u5e95\uff1a\u8d8a\u6ee1\u8d8a\u5b9e\uff08\u4e89\u593a\u4e2d\u66f4\u6de1\u2014\u2014\u8fd8\u6ca1\u5230\u624b\uff09\n\t\t\t\t\tvar fillA = owner >= 0 ? 0.16f + 0.18f * frac : 0.07f + 0.10f * frac;\n\t\t\t\t\tvar fill = new Color( teamColor.r, teamColor.g, teamColor.b, fillA );\n\t\t\t\t\tAddQuadShape( x0 + BorderThickness, y0 + BorderThickness, x1 - BorderThickness, y1 - BorderThickness, 0.20f, fill );\n\n\t\t\t\t\t// \u7c97\u8fb9\u6846\uff08\u50cf\u7d20\u9886\u5730\u611f\uff1b\u4e89\u593a\u4e2d=\u767d\u8272\u865a\u4f4d\u4ee5\u5f85\uff09\n\t\t\t\t\tvar lineC = owner >= 0\n\t\t\t\t\t\t? new Color( teamColor.r, teamColor.g, teamColor.b, 0.85f )\n\t\t\t\t\t\t: new Color( 1f, 1f, 1f, 0.55f );\n\t\t\t\t\tAddBorder( x0, y0, x1, y1, 0.30f, lineC );\n\n\t\t\t\t\t// \u8fdb\u5ea6\u6761\uff08\u9a7b\u519b/\u5360\u9886\u8fdb\u5ea6\uff0c\u65d7/\u57ce\u5821\u4e0b\u65b9\uff09\n\t\t\t\t\tif ( frac > 0.01f )\n\t\t\t\t\t{\n\t\t\t\t\t\tvar barTop = center.y + FlagWorldHeight * 0.62f + BarHeight;\n\t\t\t\t\t\tAddQuadShape( center.x - BarWidth * 0.5f, barTop - BarHeight, center.x + BarWidth * 0.5f, barTop, 0.35f,\n\t\t\t\t\t\t\tnew Color( 0.16f, 0.15f, 0.22f, 0.5f ) );\n\t\t\t\t\t\tAddQuadShape( center.x - BarWidth * 0.5f, barTop - BarHeight, center.x - BarWidth * 0.5f + BarWidth * frac, barTop, 0.35f,\n\t\t\t\t\t\t\tnew Color( teamColor.r, teamColor.g, teamColor.b, 0.95f ) );\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// \u65d7/\u57ce\u5821\uff1a\u5927\u672c\u8425=\u57ce\u5821\uff0c\u666e\u901a\u683c=\u65d7\u5b50\uff08rot=\u03c0/2 \u5bf9\u9f50\u5c4f\u5e55\u6b63\u7acb\uff09\n\t\t\t\tColor spriteTint = owner >= 0 ? new Color( teamColor.r, teamColor.g, teamColor.b, 1f )\n\t\t\t\t\t: contested ? new Color( 1f, 1f, 1f, 1f )\n\t\t\t\t\t: new Color( 0.72f, 0.74f, 0.82f, 0.45f );\n\n\t\t\t\tif ( isHq )\n\t\t\t\t\tAddTexQuad( _castles, center, castleHalf, castleHalf, MathF.PI * 0.5f, spriteTint, 0.52f );\n\t\t\t\telse\n\t\t\t\t\tAddTexQuad( _flags, center, flagHalfW, flagHalfH, MathF.PI * 0.5f, spriteTint, 0.52f );\n\t\t\t}\n\t\t}\n\t}\n\n\tstatic bool IsHq( int cx, int cy )\n\t{\n\t\tfor ( var t = 0; t < 4; t++ )\n\t\t{\n\t\t\tvar hq = TerritoryManager.HqCell( t );\n\t\t\tif ( hq.Cx == cx && hq.Cy == cy ) return true;\n\t\t}\n\t\treturn false;\n\t}\n\n\t// ---- \u9876\u70b9\u5199\u5165\uff08shape=\u7eaf\u8272 quad\uff1btex=UV quad\uff0c\u5199\u6cd5\u540c NeonRenderer.AddQuad\uff09----\n\n\tvoid AddQuadShape( float x0, float y0, float x1, float y1, float z, Color color )\n\t{\n\t\tvar a = new Vertex( new Vector3( x0, y0, z ), color );\n\t\tvar b = new Vertex( new Vector3( x1, y0, z ), color );\n\t\tvar c = new Vertex( new Vector3( x1, y1, z ), color );\n\t\tvar d = new Vertex( new Vector3( x0, y1, z ), color );\n\t\t_shape.AddVertex( a ); _shape.AddVertex( b ); _shape.AddVertex( c );\n\t\t_shape.AddVertex( a ); _shape.AddVertex( c ); _shape.AddVertex( d );\n\t}\n\n\tvoid AddBorder( float x0, float y0, float x1, float y1, float z, Color color )\n\t{\n\t\t// \u56db\u6761\u7c97\u8fb9\u5411\u5185\u94fa\uff0c\u4e0d\u8d8a\u8fc7\u683c\u754c\n\t\tAddQuadShape( x0, y0, x1, y0 + BorderThickness, z, color );\n\t\tAddQuadShape( x0, y1 - BorderThickness, x1, y1, z, color );\n\t\tAddQuadShape( x0, y0 + BorderThickness, x0 + BorderThickness, y1 - BorderThickness, z, color );\n\t\tAddQuadShape( x1 - BorderThickness, y0 + BorderThickness, x1, y1 - BorderThickness, z, color );\n\t}\n\n\tvoid AddTexQuad( SceneDynamicObject batch, Vector3 center, float halfX, float halfY, float rot, Color color, float z )\n\t{\n\t\tvar cos = MathF.Cos( rot );\n\t\tvar sin = MathF.Sin( rot );\n\t\tvar rx = cos * halfX; var ry = sin * halfX;      // right \u8f74\n\t\tvar ux = -sin * halfY; var uy = cos * halfY;     // up \u8f74\n\n\t\tvar v0 = new Vertex( new Vector3( center.x - rx - ux, center.y - ry - uy, z ), new Vector4( 0f, 0f, 0f, 0f ), color );\n\t\tvar v1 = new Vertex( new Vector3( center.x + rx - ux, center.y + ry - uy, z ), new Vector4( 1f, 0f, 0f, 0f ), color );\n\t\tvar v2 = new Vertex( new Vector3( center.x + rx + ux, center.y + ry + uy, z ), new Vector4( 1f, 1f, 0f, 0f ), color );\n\t\tvar v3 = new Vertex( new Vector3( center.x - rx + ux, center.y - ry + uy, z ), new Vector4( 0f, 1f, 0f, 0f ), color );\n\n\t\tbatch.AddVertex( v0 ); batch.AddVertex( v1 ); batch.AddVertex( v2 );\n\t\tbatch.AddVertex( v0 ); batch.AddVertex( v2 ); batch.AddVertex( v3 );\n\t}\n}"
        },
        {
            "Ident": "slfy.circleroyale",
            "Path": "Bot/BotStats.cs",
            "FileName": "BotStats.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 380090,
            "IsPrivate": false,
            "Code": "using System;\nusing System.Collections.Generic;\nusing System.Text;\n\n/// <summary>\n/// bot \u884c\u4e3a\u7edf\u8ba1\uff08v0.7.8.95\uff0c\u8c03\u53c2\u7528\uff09\uff1ahost \u4fa7\u6309\u7403 Id \u8bb0\u5404\u884c\u4e3a\u7684\u89e6\u53d1\u8ba1\u6570\uff0c\n/// \u7f16\u8f91\u5668 MCP `cr_botstats` \u8bfb\u53d6\u3002\u7eaf\u5185\u5b58\u3001\u4e0d\u8054\u7f51\uff1b\u70ed\u91cd\u8f7d\u4f1a\u6e05\u96f6\uff08\u53ef\u63a5\u53d7\uff09\u3002\n/// \u76ee\u7684\uff1a\u8c03 bot \u6570\u503c\u6709\u6570\u636e\u53ef\u770b\uff08\u9003\u8dd1/\u8ffd\u730e/\u5582\u4eba/\u5582\u65d7/\u6280\u80fd\u7684\u5206\u5e03\uff09\uff0c\u4e0d\u9760\u8089\u773c\u731c\u3002\n/// </summary>\npublic static class BotStats\n{\n\t/// <summary> \u4e00\u53ea bot \u7684\u7d2f\u8ba1\u8ba1\u6570\uff08Difficulty/Archetype \u5b58 byte \u907f\u514d\u6e38\u620f\u7a0b\u5e8f\u96c6\u7c7b\u578b\u51fa\u73b0\u5728\u952e\u4e0a\uff09 </summary>\n\tpublic sealed class Entry\n\t{\n\t\tpublic string Name = \"\";\n\t\tpublic int Team = -1;\n\t\tpublic byte Difficulty;\n\t\tpublic byte Archetype;\n\t\tpublic TimeSince Seen;   // \u6700\u540e\u4e00\u6b21\u51b3\u7b56\u65f6\u523b\uff0830s \u4e0d\u6d3b\u8dc3\u7684\u6761\u76ee\u4e0d\u518d\u8f93\u51fa\uff09\n\t\tpublic long Decisions, Flees, Kites, Bequests, Hunts, SplitEscapes, SplitHunts, FeedMates, FeedFlags, SpikeBombs, Skills;\n\t}\n\n\tstatic readonly Dictionary<Guid, Entry> _byId = new();\n\n\t/// <summary> \u6761\u76ee\u6570\u4e0a\u9650\uff1a\u7403\u5bf9\u8c61\u4f1a\u88ab\u9500\u6bc1\u91cd\u5efa\uff08\u83dc\u5355\u6f14\u793a\u8d5b\u6536\u573a\u3001\u56de\u83dc\u5355\u6e05\u573a\uff09\uff0c\u800c _byId \u6309 Ball.Id\n\t/// \u7d2f\u79ef\u3001\u6761\u76ee\u6c38\u4e0d\u5931\u6548\u2014\u2014\u957f\u4f1a\u8bdd\u4f1a\u628a\"\u8fd9\u53f0\u673a\u5668\u89c1\u8fc7\u7684\u6240\u6709 bot \u7403\"\u90fd\u7559\u5728\u5b57\u5178\u91cc\u3002\u8d85\u8fc7\u4e0a\u9650\u65f6\n\t/// \u6e05\u6389\u4e45\u672a\u51b3\u7b56\uff08Seen > PruneAfterSeconds\uff09\u7684\u6761\u76ee\uff08v0.7.8.105\uff09\u3002 </summary>\n\tconst int MaxEntries = 256;\n\tconst float PruneAfterSeconds = 60f;\n\n\t/// <summary> \u56de\u6536\u4e45\u672a\u51b3\u7b56\u7684\u6761\u76ee\uff08Track \u5185\u6309\u9700\u89e6\u53d1\uff0c\u4e0d\u505a\u6bcf\u5e27\u626b\u63cf\uff09 </summary>\n\tstatic readonly List<Guid> _pruneScratch = new();\n\n\tstatic void PruneStale()\n\t{\n\t\tif ( _byId.Count <= MaxEntries ) return;\n\n\t\t_pruneScratch.Clear();\n\t\tforeach ( var kv in _byId )\n\t\t{\n\t\t\tif ( kv.Value.Seen > PruneAfterSeconds ) _pruneScratch.Add( kv.Key );\n\t\t}\n\n\t\tforeach ( var k in _pruneScratch ) _byId.Remove( k );\n\t}\n\n\t/// <summary> \u53d6/\u5efa\u4e00\u53ea bot \u7684\u8ba1\u6570\u6761\u76ee\uff08BotBrain \u6bcf\u6b21\u51b3\u7b56\u8c03\u4e00\u6b21\uff0c\u987a\u5e26\u5237\u65b0\u540d\u5b57/\u961f\u4f0d/\u6d3b\u8dc3\u65f6\u523b\uff09 </summary>\n\tpublic static Entry Track( Ball ball, byte difficulty, byte archetype )\n\t{\n\t\tif ( ball is null || !ball.IsValid() ) return null;\n\t\tPruneStale();   // \u8d85\u4e0a\u9650\u65f6\u6e05\u4e45\u672a\u51b3\u7b56\u7684\u6761\u76ee\uff0c\u9632\u5b57\u5178\u968f\"\u89c1\u8fc7\u7684 bot \u7403\u603b\u6570\"\u65e0\u754c\u589e\u957f\n\t\tif ( !_byId.TryGetValue( ball.Id, out var e ) )\n\t\t{\n\t\t\te = new Entry();\n\t\t\t_byId[ball.Id] = e;\n\t\t}\n\t\te.Name = ball.PlayerName;\n\t\te.Team = ball.TeamIndex;\n\t\te.Difficulty = difficulty;\n\t\te.Archetype = archetype;\n\t\te.Seen = 0;\n\t\treturn e;\n\t}\n\n\tpublic static string Dump()\n\t{\n\t\t// \u53ea\u8f93\u51fa 30 \u79d2\u5185\u8fd8\u6d3b\u7740\u7684 bot\u2014\u2014\u8de8\u5c40\u6b8b\u7559\u7684\u65e7\u7403\u6761\u76ee\u4e0d\u518d\u5237\u5c4f\n\t\tif ( _byId.Count == 0 ) return \"no bot stats yet\";\n\t\tvar sb = new StringBuilder();\n\t\tint shown = 0;\n\t\tsb.AppendLine( \"name | team | diff | archetype | dec | flee | kite | bequest | hunt | spEsc | spHunt | feed | feedFlag | bomb | skill\" );\n\t\tforeach ( var e in _byId.Values )\n\t\t{\n\t\t\tif ( e.Seen > 30f ) continue;\n\t\t\tshown++;\n\t\t\tsb.AppendLine(\n\t\t\t\t$\"{e.Name} | T{e.Team} | {(BotDifficulty)e.Difficulty} | {(BotArchetype)e.Archetype} | \" +\n\t\t\t\t$\"{e.Decisions} | {e.Flees} | {e.Kites} | {e.Bequests} | {e.Hunts} | {e.SplitEscapes} | {e.SplitHunts} | \" +\n\t\t\t\t$\"{e.FeedMates} | {e.FeedFlags} | {e.SpikeBombs} | {e.Skills}\" );\n\t\t}\n\t\tsb.Insert( 0, $\"bots={shown} (of {_byId.Count} tracked)\\n\" );\n\t\treturn sb.ToString();\n\t}\n}\n"
        },
        {
            "Ident": "slfy.circleroyale",
            "Path": "UI/EndBoard.cs",
            "FileName": "EndBoard.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 380090,
            "IsPrivate": false,
            "Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox.UI;\n\n/// <summary>\n/// \u7ed3\u7b97\u9762\u677f\uff08M5\uff09\uff1a\u6bd4\u8d5b\u7ed3\u675f\u5168\u5c4f\u7ed3\u7b97\u2014\u2014\u672c\u5c40\u6392\u884c\uff08\u9010\u884c\u6ed1\u5165\u52a8\u753b\uff09+ \u5168\u670d\u6392\u884c\n/// \uff08\u4e91\u7aef\u4f18\u5148 ScoreUploader\uff0c\u7a7a\u4e86\u964d\u7ea7 LocalBoard \u672c\u5730\u699c\uff09+ \u56de\u83dc\u5355\u6309\u94ae\u3002\n/// \u7eaf C# Panel + \u540c\u76ee\u5f55 EndBoard.cs.scss\uff1b\u663e\u793a\u671f\u95f4\u5168\u573a\u6a21\u62df\u51bb\u7ed3\uff08MatchState.MatchOver\uff09\u3002\n/// </summary>\npublic sealed class EndBoard : PanelComponent\n{\n\tLabel _title;\n\tLabel _winner;\n\tImage _crown;    // \u51a0\u519b\u7687\u51a0\uff08v0.7.8.38 \u50cf\u7d20\u98ce\uff09\uff1a\u60ac\u5728 winner \u884c\u4e0a\u65b9\u5c45\u4e2d\n\tLabel _matchHead;\n\tLabel[] _matchRows;\n\tLabel _selfRow;\n\tLabel _globalHead;\n\tLabel[] _globalRows;\n\tLabel _globalNote;\n\tLabel _back;\n\n\tbool _built;\n\tbool _shown;\n\tTimeSince _sinceShow;\n\tint _backSecs = -1;   // \u8fd4\u56de\u6309\u94ae\u4e0a\u6b21\u663e\u793a\u7684\u79d2\u6570\uff08\u53d8\u4e86\u624d\u91cd\u62fc\u6587\u672c\uff0cv0.7.8.87\uff09\n\n\tScoreWire[] _standings;\n\tlong _mySteamId;\n\n\t// \u9010\u884c reveal \u52a8\u753b\uff1a\u884c\u7d22\u5f15 \u2192 \u51fa\u573a\u65f6\u523b\uff08\u79d2\uff09\uff0c\u900f\u660e\u5ea6/\u4f4d\u79fb\u5728 0.25s \u5185\u6e10\u5165\n\tconst float MatchRowDelay = 0.45f;\n\tconst float MatchRowStagger = 0.22f;\n\tconst float GlobalDelay = 3.4f;\n\tconst float GlobalStagger = 0.16f;\n\n\tprotected override void OnStart()\n\t{\n\t\tbase.OnStart();\n\t\tBuildUi();\n\t\tHide();\n\t}\n\n\tvoid BuildUi()\n\t{\n\t\tvar root = Panel;\n\t\tif ( root is null ) return;\n\t\tif ( _built && _title.IsValid() && root.ChildrenCount > 0 ) return;\n\n\t\troot.DeleteChildren( true );\n\n\t\troot.Style.Position = PositionMode.Absolute;\n\t\troot.Style.Left = 0f;\n\t\troot.Style.Top = 0f;\n\t\troot.Style.Right = 0f;\n\t\troot.Style.Bottom = 0f;\n\t\troot.Style.BackgroundColor = new Color( 0.008f, 0.012f, 0.05f, 0.88f );\n\t\troot.Style.PointerEvents = PointerEvents.All;\n\n\t\t// \u6807\u9898/\u51a0\u519b/\u56de\u83dc\u5355\u5c45\u4e2d\uff1b\u672c\u5c40\u6392\u884c\uff08\u5de6\u5217\uff09\u4e0e\u5168\u670d\u6392\u884c\uff08\u53f3\u5217\uff09\u5e76\u6392\uff08v0.6.3.0 \u7528\u6237\u5b9a\u7a3f\uff09\n\t\t_title = NewRow( root, 88f, 360f, 1200f, \"eb-title\" );\n\t\t_title.Text = \"MATCH OVER\";\n\n\t\t_winner = NewRow( root, 166f, 360f, 1200f, \"eb-winner\" );\n\n\t\t_crown = new Image();\n\t\t_crown.Texture = Texture.Load( \"ui/pixel/crown.png\" );\n\t\t_crown.Style.Position = PositionMode.Absolute;\n\t\t_crown.Style.Width = 22f;\n\t\t_crown.Style.Height = 22f;\n\t\t_crown.Style.Display = DisplayMode.None;\n\t\troot.AddChild( _crown );\n\n\t\t_matchHead = NewRow( root, 244f, 260f, 580f, \"eb-head\" );\n\t\t_matchHead.Text = \"MATCH RANKING\";\n\n\t\t// \u672c\u5c40\u6392\u884c\uff1a10 \u884c + \u699c\u5916\u81ea\u8eab\u884c\uff08\u5de6\u5217\uff09\n\t\t_matchRows = new Label[GameConfig.LeaderboardRows];\n\t\tfor ( int i = 0; i < _matchRows.Length; i++ )\n\t\t{\n\t\t\t_matchRows[i] = NewRow( root, 276f + i * 28f, 260f, 580f, \"eb-row\" );\n\t\t}\n\n\t\t_selfRow = NewRow( root, 276f + _matchRows.Length * 28f, 260f, 580f, \"eb-row self\" );\n\t\t_selfRow.Style.Display = DisplayMode.None;\n\n\t\t_globalHead = NewRow( root, 244f, 1060f, 580f, \"eb-head global\" );\n\t\t_globalHead.Text = \"GLOBAL TOP 10\";\n\n\t\t_globalRows = new Label[10];\n\t\tfor ( int i = 0; i < _globalRows.Length; i++ )\n\t\t{\n\t\t\t_globalRows[i] = NewRow( root, 276f + i * 28f, 1060f, 580f, \"eb-row small\" );\n\t\t}\n\n\t\t_globalNote = NewRow( root, 276f + _globalRows.Length * 28f, 1060f, 580f, \"eb-note\" );\n\n\t\t_back = NewRow( root, 620f, 360f, 1200f, \"eb-back\" );\n\t\t_back.Text = Networking.IsActive ? \"RETURN TO LOBBY \u2014 [SPACE]\" : \"BACK TO MENU \u2014 [SPACE]\";\n\t\t_back.AddEventListener( \"onclick\", () => OnBackClicked() );\n\n\t\t_built = true;\n\t}\n\n\t/// <summary> \u5efa\u4e00\u884c Label\uff1aleft/width \u662f 1920 \u8bbe\u8ba1\u7a3f\u5750\u6807\uff0c\u6362\u7b97\u6210\"\u4e2d\u5fc3\u951a\u5b9a + \u76f8\u5bf9\u504f\u79fb\"\u2014\u2014\n\t/// \u4efb\u610f\u5206\u8fa8\u7387\u4e0b\u6574\u4f53\u4fdd\u6301\u5c45\u4e2d\uff08left:960 \u5b9a\u4f4d\u5728\u7a84\u7a97\u53e3\u4f1a\u6574\u4f53\u504f\u53f3\uff0c\u5b9e\u6d4b\uff09 </summary>\n\tLabel NewRow( Panel root, float top, float left, float width, string classes )\n\t{\n\t\tvar l = new Label() { Classes = classes };\n\t\tl.Style.Position = PositionMode.Absolute;\n\t\tl.Style.Left = Length.Percent( 50f );\n\t\tl.Style.MarginLeft = left - 960f;\n\t\tl.Style.Width = width;\n\t\tl.Style.Top = top;\n\t\troot.AddChild( l );\n\t\treturn l;\n\t}\n\n\t/// <summary> \u6bd4\u8d5b\u7ed3\u675f\u5c55\u793a\u7ed3\u7b97\uff08CircleroyaleGame.OnMatchOverRemote \u8c03\u7528\uff0c\u53cc\u7aef\uff09 </summary>\n\tpublic void Show( ScoreWire[] standings, long mySteamId, string myName )\n\t{\n\t\t_standings = standings;\n\t\t_mySteamId = mySteamId;\n\n\t\tBuildUi();\n\t\tif ( !_built ) return;\n\n\t\t_sinceShow = 0;\n\t\t_backSecs = -1;   // \u5f3a\u5236\u91cd\u62fc\u8fd4\u56de\u6309\u94ae\u6587\u672c\uff08v0.7.8.87\uff09\n\t\t_shown = true;\n\t\tPanel.Style.Display = DisplayMode.Flex;\n\n\t\t_winner.Text = WinnerText( standings );\n\n\t\tFillMatchRows();\n\t\tFillGlobalPlaceholder();\n\t\tScoreUploader.FetchGlobal( ApplyGlobal );\n\t}\n\n\t/// <summary> \u56de\u5927\u5385/\u4e3b\u83dc\u5355\uff08\u5b9e\u4f8b\u65b9\u6cd5\uff1a\u70ed\u91cd\u8f7d\u53ef\u91cd\u6620\u5c04\uff09 </summary>\n\tvoid OnBackClicked() => CircleroyaleGame.Current?.AfterSettlement();\n\n\t/// <summary> \u56de\u5927\u5385\u65f6\u6536\u8d77\uff08\u9762\u677f\u968f GameObject \u590d\u7528\uff0c\u4e0b\u6b21\u5f00\u5c40\u518d\u5c55\u793a\uff09 </summary>\n\tpublic void Hide()\n\t{\n\t\t_shown = false;\n\t\tif ( Panel.IsValid() ) Panel.Style.Display = DisplayMode.None;\n\t}\n\n\t// ---- \u5185\u5bb9\u586b\u5145 ----\n\n\tstring WinnerText( ScoreWire[] rows )\n\t{\n\t\tif ( rows is null || rows.Length == 0 ) return \"\";\n\n\t\tif ( !MatchState.IsTeam )\n\t\t\treturn $\"WINNER \u2014 {rows[0].Name}   ({rows[0].Mass:0})\";\n\n\t\t// \u9886\u571f\u6218\u4e89\uff08M7.4 \u79ef\u5206\u5236\uff09\uff1a\u80dc\u8d1f\u6309\u5360\u65d7\u79ef\u5206\uff08host \u6392\u5e8f\u540c\u6e90\uff0c\u8fd9\u91cc\u53ea\u53d6\u6587\u6848\uff09\n\t\tif ( MatchState.IsTerritory )\n\t\t{\n\t\t\tint bestT = -1;\n\t\t\tfloat bestPts = -1f;\n\t\t\tfor ( var t = 0; t < 4; t++ )\n\t\t\t{\n\t\t\t\tvar pts = TerritoryManager.Points( t );\n\t\t\t\tif ( pts > bestPts )\n\t\t\t\t{\n\t\t\t\t\tbestPts = pts;\n\t\t\t\t\tbestT = t;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn $\"TEAM {bestT + 1} WINS   ({bestPts:0} PTS)\";\n\t\t}\n\n\t\t// \u56e2\u961f\u8d5b\uff1a\u80dc\u8d1f\u6309\u961f\u4f0d 3 \u4eba\u603b\u5206\n\t\tvar totals = new Dictionary<int, float>();\n\t\tforeach ( var r in rows )\n\t\t{\n\t\t\ttotals.TryGetValue( r.Team, out var m );\n\t\t\ttotals[r.Team] = m + r.Mass;\n\t\t}\n\n\t\tint best = -1;\n\t\tfloat bestMass = -1f;\n\t\tforeach ( var kv in totals )\n\t\t{\n\t\t\tif ( kv.Value > bestMass )\n\t\t\t{\n\t\t\t\tbestMass = kv.Value;\n\t\t\t\tbest = kv.Key;\n\t\t\t}\n\t\t}\n\t\treturn $\"TEAM {best + 1} WINS   ({bestMass:0})\";\n\t}\n\n\tvoid FillMatchRows()\n\t{\n\t\tvar rows = _standings;\n\t\tif ( rows is null || rows.Length == 0 )\n\t\t{\n\t\t\t// \u65e0\u540d\u5355\uff08\u53d6\u6d88\u5c40/\u7a7a\u7ed3\u7b97\uff09\uff1a\u6e05\u884c\u800c\u4e0d\u662f NRE\uff08v0.7.8.87\uff1bWinnerText \u65e9\u5df2\u6309 null \u9632\u8fc7\uff09\n\t\t\tfor ( int i = 0; i < _matchRows.Length; i++ )\n\t\t\t{\n\t\t\t\tif ( _matchRows[i].IsValid() && _matchRows[i].Text.Length > 0 ) _matchRows[i].Text = \"\";\n\t\t\t}\n\t\t\tif ( _selfRow.IsValid() && _selfRow.Style.Display != DisplayMode.None ) _selfRow.Style.Display = DisplayMode.None;\n\t\t\treturn;\n\t\t}\n\n\t\tint selfRank = -1;\n\t\tfor ( int i = 0; i < rows.Length; i++ )\n\t\t{\n\t\t\tif ( rows[i].SteamId == _mySteamId && !rows[i].Bot )\n\t\t\t{\n\t\t\t\tselfRank = i;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\tfor ( int i = 0; i < _matchRows.Length; i++ )\n\t\t{\n\t\t\tvar row = _matchRows[i];\n\t\t\tif ( row is null || !row.IsValid() ) continue;   // \u8fc7\u671f\u5f15\u7528\u4e0d\u53ef\u5199\uff08v0.7.8.87\uff09\n\n\t\t\tif ( i >= rows.Length )\n\t\t\t{\n\t\t\t\trow.Text = \"\";\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tvar r = rows[i];\n\t\t\trow.Text = RowText( $\"{i + 1}.\", r.Name, $\"{r.Mass:0}\", r.Team );\n\n\t\t\tbool isSelf = r.SteamId == _mySteamId && !r.Bot;\n\t\t\trow.Style.FontColor = isSelf ? new Color( 0.898f, 0.282f, 0.553f ) : new Color( 0.29f, 0.23f, 0.39f, 0.85f );\n\t\t}\n\n\t\t// \u699c\u5916\u81ea\u8eab\u884c\n\t\tbool showSelf = selfRank >= _matchRows.Length;\n\t\tif ( _selfRow.IsValid() )\n\t\t{\n\t\t\t_selfRow.Style.Display = showSelf ? DisplayMode.Flex : DisplayMode.None;\n\t\t\tif ( showSelf )\n\t\t\t{\n\t\t\t\tvar r = rows[selfRank];\n\t\t\t\t_selfRow.Text = RowText( $\"#{selfRank + 1}.\", r.Name, $\"{r.Mass:0}\", r.Team );\n\t\t\t}\n\t\t}\n\t}\n\n\t/// <summary> \u884c\u6587\u672c\uff08\u56e2\u961f\u8d5b\u5e26 T# \u524d\u7f00\uff1b\u666e\u901a\u8d5b\u53ea\u6709\u540d\u6b21/\u540d\u5b57/\u5206\u6570\uff09 </summary>\n\tstring RowText( string rank, string name, string mass, byte team )\n\t{\n\t\treturn MatchState.IsTeam ? $\"{rank} [T{team + 1}] {name}   {mass}\" : $\"{rank}  {name}   {mass}\";\n\t}\n\n\t/// <summary> \u5168\u670d\u699c\u5148\u5360\u4f4d\"\u52a0\u8f7d\u4e2d\"\uff0c\u4e91\u7aef\u56de\u6765\u540e\u586b\u5145\uff08\u7a7a\u4e86\u964d\u7ea7\u672c\u5730\u699c\uff09 </summary>\n\tvoid FillGlobalPlaceholder()\n\t{\n\t\t_globalRows[0].Text = \"LOADING ...\";\n\t\tfor ( int i = 1; i < _globalRows.Length; i++ )\n\t\t\t_globalRows[i].Text = \"\";\n\t\t_globalNote.Text = \"\";\n\t}\n\n\tvoid ApplyGlobal( List<( string Name, float Mass )> entries )\n\t{\n\t\tif ( !_shown ) return;\n\t\tif ( _globalRows is null || _globalRows[0] is null || !_globalRows[0].IsValid() ) return;\n\n\t\tif ( entries is null )\n\t\t{\n\t\t\t// \u4e91\u7aef\u4e0d\u53ef\u7528\uff08\u672a\u53d1\u5e03\u5305\u662f\u5e38\u6001\uff09\uff1a\u964d\u7ea7\u672c\u673a\u6301\u4e45\u5316\u699c\n\t\t\tentries = LocalBoard.Top( _globalRows.Length );\n\t\t\t_globalNote.Text = entries is not null ? \"OFFLINE BOARD (THIS MACHINE)\" : \"NO RECORDS YET\";\n\t\t}\n\t\telse\n\t\t{\n\t\t\t_globalNote.Text = \"BEST SINGLE-MATCH SCORE\";\n\t\t}\n\n\t\tfor ( int i = 0; i < _globalRows.Length; i++ )\n\t\t{\n\t\t\tif ( entries is null || i >= entries.Count )\n\t\t\t{\n\t\t\t\t_globalRows[i].Text = \"\";\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\t_globalRows[i].Text = $\"{i + 1}.  {entries[i].Name}   {entries[i].Mass:0}\";\n\t\t}\n\t}\n\n\t// ---- \u9010\u5e27\uff1areveal \u52a8\u753b + \u5149\u6807 + \u7a7a\u683c\u56de\u83dc\u5355 ----\n\n\tprotected override void OnUpdate()\n\t{\n\t\tbase.OnUpdate();\n\n\t\tBuildUi();\n\t\tif ( !_shown ) return;\n\n\t\tif ( Panel.IsValid() && Panel.Style.Display != DisplayMode.Flex ) Panel.Style.Display = DisplayMode.Flex;   // \u540c\u503c\u5b88\u536b\uff08v0.7.8.87\uff09\n\t\tMouse.Visibility = MouseVisibility.Visible;   // \u7ed3\u7b97\u671f\u6309\u94ae\u8981\u80fd\u70b9\n\n\t\t// \u7a7a\u683c\u63d0\u524d\u8fd4\u56de\uff08\u6309\u94ae\u6587\u6848\u5199\u7740 [SPACE]\uff09\uff1a\u653e\u5728\u5165\u573a\u52a8\u753b\u4e4b\u524d\uff0c\u6309\u4e0b\u5373\u54cd\u5e94\u3002\n\t\t// MatchOver \u540e Tick/Ball.OnUpdate \u90fd\u4e0d\u518d\u8dd1\uff0c\u8fd9\u91cc\u4e0d\u63a5\u5c31\u6ca1\u4eba\u63a5\u4e86\u3002\n\t\t// BallInput.Typing \u5b88\u536b\uff1a\u804a\u5929\u6846\u805a\u7126\u65f6\u6309\u952e\u4f1a\u65e0\u5dee\u522b\u8fdb InputContext\uff0c\u6253\u5b57\u6253\u7a7a\u683c\u4e0d\u80fd\u9000\u5c40\n\t\tif ( !BallInput.Typing && Input.Pressed( \"Jump\" ) )\n\t\t{\n\t\t\tCircleroyaleGame.Current?.AfterSettlement();\n\t\t\treturn;\n\t\t}\n\n\t\tfloat t = _sinceShow;\n\t\tAnimate( _title, t, 0.05f );\n\t\tAnimate( _winner, t, 0.2f );\n\t\tAnimate( _matchHead, t, MatchRowDelay - 0.2f );\n\n\t\tfor ( int i = 0; i < _matchRows.Length; i++ )\n\t\t\tAnimate( _matchRows[i], t, MatchRowDelay + i * MatchRowStagger );\n\n\t\tAnimate( _selfRow, t, MatchRowDelay + _matchRows.Length * MatchRowStagger );\n\t\tAnimate( _globalHead, t, GlobalDelay - 0.2f );\n\n\t\tfor ( int i = 0; i < _globalRows.Length; i++ )\n\t\t\tAnimate( _globalRows[i], t, GlobalDelay + i * GlobalStagger );\n\n\t\tAnimate( _globalNote, t, GlobalDelay + _globalRows.Length * GlobalStagger );\n\t\tAnimate( _back, t, GlobalDelay + _globalRows.Length * GlobalStagger + 0.3f );\n\n\t\t// \u7687\u51a0\u60ac\u5728\u51a0\u519b\u884c\u6b63\u4e0a\u65b9\uff08winner \u662f\u5b9a\u5bbd\u5c45\u4e2d\u884c\uff0c\u6587\u5b57\u4e2d\u5fc3\u6052 960\uff1b\u900f\u660e\u5ea6\u968f\u5176\u5165\u573a\u52a8\u753b\uff09\n\t\tif ( _crown.IsValid() )\n\t\t{\n\t\t\tvar show = _winner.IsValid() && !string.IsNullOrEmpty( _winner.Text );\n\t\t\tvar crownMode = show ? DisplayMode.Flex : DisplayMode.None;\n\t\t\tif ( _crown.Style.Display != crownMode ) _crown.Style.Display = crownMode;\n\t\t\tif ( show )\n\t\t\t{\n\t\t\t\t// \u4f4d\u7f6e/\u951a\u70b9\u5e38\u91cf\u53ea\u5728\u9996\u5e27\u5199\u4e00\u6b21\uff08\u6bcf\u5e27\u540c\u503c\u5199=\u5e03\u5c40\u91cd\u7b97\uff0cv0.7.8.87\uff09\uff1bTop \u662f\u6d6e\u52a8\u52a8\u753b\uff0c\u6309\u9608\u503c\u5199\n\t\t\t\tif ( !SamePx( _crown.Style.MarginLeft, -11f ) )\n\t\t\t\t{\n\t\t\t\t\t_crown.Style.Left = Length.Percent( 50f );   // \u51a0\u519b\u884c\u4e2d\u5fc3\u951a\u5b9a\u540e\u7687\u51a0\u8ddf\u968f\u9762\u677f\u4e2d\u5fc3\n\t\t\t\t\t_crown.Style.MarginLeft = -11f;             // 22px \u5bbd\u7684\u534a\u4e2a\n\t\t\t\t}\n\n\t\t\t\tvar top = 138f + MathF.Sin( t * 3f ) * 4f;\n\t\t\t\tif ( !SamePx( _crown.Style.Top, top ) ) _crown.Style.Top = top;\n\t\t\t\tif ( !SameFloat( _crown.Style.Opacity, _winner.Style.Opacity ?? 1f ) ) _crown.Style.Opacity = _winner.Style.Opacity;\n\t\t\t}\n\t\t}\n\n\t\t// \u81ea\u52a8\u8fd4\u56de\uff08v0.6.4.2\uff09\uff1a\u7ed3\u7b97\u663e\u793a 10 \u79d2\u540e\u56de\u5927\u5385\uff08\u591a\u4eba\uff09/\u4e3b\u83dc\u5355\uff08\u5355\u673a\uff09\uff0c\u7a7a\u683c\u53ef\u63d0\u524d\n\t\tvar remain = GameConfig.SettlementAutoReturnSeconds - _sinceShow;\n\t\tif ( remain <= 0f )\n\t\t{\n\t\t\tCircleroyaleGame.Current?.AfterSettlement();\n\t\t\treturn;\n\t\t}\n\n\t\tif ( _back.IsValid() )\n\t\t{\n\t\t\t// \u79d2\u6570\u53d8\u4e86\u624d\u91cd\u62fc\u6587\u672c\uff08\u6bcf\u5e27\u63d2\u503c=\u6bcf\u5e27\u4e00\u6b21\u5b57\u7b26\u4e32\u5206\u914d + \u5e03\u5c40\u5199\uff0cv0.7.8.87\uff09\n\t\t\tvar secs = (int)MathF.Ceiling( remain );\n\t\t\tif ( secs != _backSecs )\n\t\t\t{\n\t\t\t\t_backSecs = secs;\n\t\t\t\t_back.Text = Networking.IsActive\n\t\t\t\t\t? $\"RETURN TO LOBBY IN {secs} \u2014 [SPACE NOW]\"\n\t\t\t\t\t: $\"BACK TO MENU IN {secs} \u2014 [SPACE NOW]\";\n\t\t\t}\n\t\t}\n\t}\n\n\t/// <summary> \u5355\u884c\u6e10\u5165\uff1a\u51fa\u573a\u524d Opacity 0\uff1b0.25s \u5185\u900f\u660e\u5ea6 0\u21921\u3001PaddingLeft 36px\u21920\uff08\u89c6\u89c9\u4e0a\u4ece\u53f3\u6ed1\u5230\u4f4d\uff09 </summary>\n\tvoid Animate( Label row, float now, float revealAt )\n\t{\n\t\tif ( row is null || !row.IsValid() ) return;\n\n\t\tfloat k = Math.Clamp( ( now - revealAt ) / 0.25f, 0f, 1f );\n\n\t\t// \u52a8\u753b\u8dd1\u5b8c\uff08k=1\uff09\u540e\u4e0d\u518d\u5199\u6837\u5f0f\uff1a\u7ed3\u7b97\u8981\u6302 10 \u79d2\uff0c\u540c\u503c\u5199\u4f1a\u4e00\u76f4\u5f3a\u8feb\u5e03\u5c40\uff08v0.7.8.87\uff09\n\t\tif ( k >= 1f )\n\t\t{\n\t\t\tif ( !SameFloat( row.Style.Opacity, 1f ) ) row.Style.Opacity = 1f;\n\t\t\tif ( !SamePx( row.Style.PaddingLeft, 0f ) ) row.Style.PaddingLeft = 0f;\n\t\t\treturn;\n\t\t}\n\n\t\tif ( k <= 0f )\n\t\t{\n\t\t\tif ( !SameFloat( row.Style.Opacity, 0f ) ) row.Style.Opacity = 0f;\n\t\t\treturn;\n\t\t}\n\n\t\trow.Style.Opacity = k;   // \u52a8\u753b\u8fdb\u884c\u4e2d\u9010\u5e27\u53d8\u5316\uff0c\u5fc5\u987b\u5199\n\t\trow.Style.PaddingLeft = ( 1f - k ) * 36f;\n\t}\n\n\t// ---- \u6837\u5f0f\u5c5e\u6027\u90fd\u662f nullable\uff08Length?/float?\uff09\uff1a\u540c\u503c\u5b88\u536b\u7684 null \u5b89\u5168\u6bd4\u8f83\uff08\"\u672a\u8bbe\u7f6e\"\u7b97\u4e0d\u540c \u2192 \u9700\u8981\u5199\uff09 ----\n\n\t/// <summary> \u50cf\u7d20\u957f\u5ea6\u662f\u5426\u5df2\u7b49\u4e8e px\uff08\u672a\u8bbe\u7f6e/\u5355\u4f4d\u4e0d\u662f px \u90fd\u7b97\u4e0d\u540c\uff09 </summary>\n\tstatic bool SamePx( Length? len, float px ) =>\n\t\tlen.HasValue && len.Value.Unit == LengthUnit.Pixels && MathF.Abs( len.Value.Value - px ) < 0.5f;\n\n\t/// <summary> \u6d6e\u70b9\u6837\u5f0f\uff08Opacity \u7b49\uff09\u662f\u5426\u5df2\u7b49\u4e8e\u76ee\u6807\u503c </summary>\n\tstatic bool SameFloat( float? v, float target, float eps = 0.01f ) =>\n\t\tv.HasValue && MathF.Abs( v.Value - target ) < eps;\n}\n"
        },
        {
            "Ident": "slfy.circleroyale",
            "Path": "Game/ClassSkillManager.cs",
            "FileName": "ClassSkillManager.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 380090,
            "IsPrivate": false,
            "Code": "using System;\nusing System.Collections.Generic;\n\n/// <summary>\n/// \u804c\u4e1a\u6280\u80fd\u7ba1\u7406\u5668\uff08M7.4\uff0c\u9886\u571f\u6a21\u5f0f\u4e13\u5c5e\uff09\uff1aQ \u952e\u65bd\u653e\u7684\u56db\u804c\u4e1a\u51b7\u5374\u6280\u80fd\uff0chost \u6743\u5a01\u3002\n/// \u6307\u6325\u5b98=\u4f20\u9001\u5230\u6700\u5927\u961f\u53cb\u8eab\u8fb9\uff08\u8fdc\u7a0b\u73a9\u5bb6\u7ecf ClassTeleport \u5e7f\u64ad\u77ac\u79fb\uff09\uff1b\u5de5\u5175=6 \u79d2\u5927\u52a0\u901f\uff08\u590d\u7528 Speed buff\uff09\uff1b\n/// \u62a4\u536b=\u539f\u5730\u951a\u5b9a\u5c16\u523a 8 \u79d2\uff08\u590d\u7528\u5c16\u523a\u5206\u8eab\u78b0\u649e\u5224\u5b9a\uff09\uff1b\u5766\u514b=+40% \u5f53\u524d\u8d28\u91cf 10 \u79d2\uff0c\u5230\u671f\u56de\u6536\u3001\u671f\u95f4\u7981\u5206\u88c2\u3002\n/// \u51b7\u5374\u6309\u7403 Id \u8bb0\uff08\u771f\u4eba=\u672c\u7403\uff1bbot \u5404\u7403\u72ec\u7acb\uff09\u3002\u5ba2\u6237\u7aef\u51b7\u5374\u4e3a\u672c\u5730\u4f30\u7b97\uff0c\n/// host \u51b7\u5374\u672a\u597d\u65f6\u9759\u9ed8\u62d2\u7edd\uff08\u65e0\u526f\u4f5c\u7528\uff09\u3002\u5766\u514b\u56de\u6536\u8868\u6301\u6709 Ball \u5f15\u7528\uff0c\u70ed\u91cd\u8f7d\u6362\u7a0b\u5e8f\u96c6\u5bf9\u8c61\u4f1a\u5931\u6548\u2014\u2014\n/// \u5c4a\u65f6\u8be5\u6b21\u56de\u6536\u9759\u9ed8\u6d88\u5931\uff08\u5bf9\u5c40\u672c\u8eab\u4e5f\u4f1a\u88ab\u70ed\u91cd\u8f7d\u6253\u65ad\uff09\uff0c\u53ef\u63a5\u53d7\u7684\u8fb9\u754c\u3002\n/// </summary>\npublic static class ClassSkillManager\n{\n\tsealed class Surge\n\t{\n\t\tpublic Ball Ball;\n\t\tpublic float Added;\n\t\tpublic TimeSince Since;\n\t}\n\n\tstatic readonly List<Surge> _surges = new();\n\tstatic readonly Dictionary<Guid, TimeSince> _cd = new();   // \u7403 Id \u2192 \u4e0a\u6b21\u65bd\u653e\uff08\u6309\u7403\u4e0d\u6309 SteamId\u2014\u2014bot \u7684 SteamId \u5168 0 \u4f1a\u5168\u961f\u5171\u4eab\u4e00\u4e2a\u51b7\u5374\uff09\n\n\tpublic static string NameOf( byte cls ) => cls switch\n\t{\n\t\t0 => \"COMMANDER\",\n\t\t1 => \"ENGINEER\",\n\t\t2 => \"GUARD\",\n\t\t3 => \"TANK\",\n\t\t_ => \"\",\n\t};\n\n\t/// <summary> HUD \u77ed\u6807\uff08Q \u94ae\u5c31\u7eea\u6001\u663e\u793a\uff09 </summary>\n\tpublic static string TagOf( byte cls ) => cls switch\n\t{\n\t\t0 => \"CMD\",\n\t\t1 => \"ENG\",\n\t\t2 => \"GRD\",\n\t\t3 => \"TNK\",\n\t\t_ => \"\",\n\t};\n\n\tpublic static float CooldownOf( byte cls ) => cls switch\n\t{\n\t\t0 => GameConfig.ClassCdCommander,\n\t\t1 => GameConfig.ClassCdEngineer,\n\t\t2 => GameConfig.ClassCdGuard,\n\t\t3 => GameConfig.ClassCdTank,\n\t\t_ => 999f,\n\t};\n\n\t/// <summary> \u7403\u590d\u6d3b\u65f6\u6e05\u51b7\u5374\uff08v0.7.8.87\uff09\uff1a\u590d\u6d3b\u4f1a\u91cd\u63b7\u804c\u4e1a\uff0c\u4f46\u51b7\u5374\u6309\u7403 Id \u8bb0\u3001\u7403\u5bf9\u8c61\u53c8\u8de8\u5c40\u590d\u7528\u2014\u2014\n\t/// \u4e0d\u6e05\u7684\u8bdd\u521a\u590d\u6d3b Q \u663e\u793a\u7740\u65b0\u804c\u4e1a\u5374\u6309\u4e0d\u52a8\uff08\u4e0a\u4e00\u6761\u547d/\u4e0a\u4e00\u5c40\u7684 45~50s \u51b7\u5374\u8fd8\u6302\u7740\uff09 </summary>\n\tpublic static void ResetBall( Ball ball )\n\t{\n\t\tif ( ball.IsValid() ) _cd.Remove( ball.Id );\n\t}\n\n\t/// <summary> \u5c40\u95f4\u6e05\u8d26\uff08v0.7.8.87\uff09\uff1a\u6e05\u5168\u90e8\u51b7\u5374 + \u672a\u56de\u6536\u7684\u5766\u514b\u4e34\u65f6\u8d28\u91cf\u8bb0\u8d26\n\t/// \uff08\u8de8\u5c40\u6b8b\u7559\u4f1a\u5728\u65b0\u5c40\u628a\u91cd\u751f\u7403\u7684\u8d28\u91cf\u53c8\u6263\u4e00\u904d\uff09 </summary>\n\tpublic static void ResetAll()\n\t{\n\t\t_cd.Clear();\n\t\t_surges.Clear();\n\t}\n\n\t/// <summary> \u5766\u514b\u7206\u53d1\u671f\u5185\u7981\u5206\u88c2\uff08\u4e34\u65f6\u8d28\u91cf\u5206\u88c2=\u5957\u73b0\u6c38\u4e45\u8d28\u91cf\uff09 </summary>\n\tpublic static bool IsTankSurging( Ball ball )\n\t{\n\t\tif ( ball is null || !ball.IsValid() ) return false;\n\t\tforeach ( var s in _surges )\n\t\t{\n\t\t\tif ( s.Ball == ball ) return true;\n\t\t}\n\t\treturn false;\n\t}\n\n\t/// <summary> host \u6743\u5a01\u65bd\u653e\uff1a\u51b7\u5374/\u5b58\u6d3b/\u6a21\u5f0f\u6821\u9a8c\u540e\u6267\u884c\uff1b\u8fd4\u56de\u662f\u5426\u771f\u7684\u653e\u4e86\uff08\u6ca1\u961f\u53cb\u7684\u6307\u6325\u5b98\u4e0d\u8fdb\u51b7\u5374\uff09 </summary>\n\tpublic static bool Cast( Ball ball )\n\t{\n\t\tif ( !MatchState.IsTerritory ) return false;\n\t\tif ( ball is null || !ball.IsValid() || !ball.Alive ) return false;\n\t\tif ( ball.TerritoryClass > 3 ) return false;\n\n\t\tvar sid = ball.Id;   // \u51b7\u5374\u6309\u7403\u8eab\u8bb0\uff08\u771f\u4eba=\u672c\u7403\uff1bbot \u5404\u7403\u72ec\u7acb\uff0c\u4e0d\u53d7 SteamId \u5168 0 \u5f71\u54cd\uff09\n\t\tif ( _cd.TryGetValue( sid, out var since ) && since < CooldownOf( ball.TerritoryClass ) ) return false;\n\n\t\tswitch ( ball.TerritoryClass )\n\t\t{\n\t\t\tcase 0:\n\t\t\t\tif ( !CastCommander( ball ) ) return false;\n\t\t\t\tbreak;\n\t\t\tcase 1:\n\t\t\t\tball.ApplyBuff( (byte)PowerUpManager.Kind.Speed, GameConfig.ClassEngineerSeconds );\n\t\t\t\tbreak;\n\t\t\tcase 2:\n\t\t\t\tCircleroyaleGame.Current?.SpawnGuardSpike( ball );\n\t\t\t\tbreak;\n\t\t\tcase 3:\n\t\t\t\tCastTank( ball );\n\t\t\t\tbreak;\n\t\t}\n\n\t\t_cd[sid] = 0;\n\t\tGameLog.Info( $\"[class] {NameOf( ball.TerritoryClass )} cast by '{ball.PlayerName}'\" );\n\t\treturn true;\n\t}\n\n\tstatic bool CastCommander( Ball ball )\n\t{\n\t\tvar game = CircleroyaleGame.Current;\n\t\tif ( game is null ) return false;\n\n\t\tBall best = null;\n\t\tforeach ( var b in game.Balls )\n\t\t{\n\t\t\tif ( !b.IsValid() || !b.Alive || b == ball ) continue;\n\t\t\tif ( b.TeamIndex < 0 || b.TeamIndex != ball.TeamIndex ) continue;\n\t\t\tif ( best is null || b.Mass > best.Mass ) best = b;\n\t\t}\n\t\tif ( best is null ) return false;   // \u5b64\u8eab\u65e0\u961f\u53cb\uff1a\u6280\u80fd\u4e0d\u653e\u3001\u4e0d\u8fdb\u51b7\u5374\n\n\t\tvar pos = best.WorldPosition;\n\t\tif ( ball.IsBot || ball.OwnerSteamId == Game.SteamId.Value )\n\t\t{\n\t\t\tball.TeleportTo( pos );           // host \u672c\u673a\u7403/bot\uff1a\u76f4\u63a5\u77ac\u79fb\n\t\t\tgame.MarkTeleport( ball, pos );\n\t\t}\n\t\telse\n\t\t{\n\t\t\tgame.MarkTeleport( ball, pos );   // \u8fdc\u7a0b\u7403\uff1ahost \u5148\u951a\u5b9a\u6821\u9a8c\u4f4d\uff0cowner \u7aef\u6536\u5e7f\u64ad\u81ea\u884c\u77ac\u79fb\n\t\t\tNetworkManager.ClassTeleport( ball.OwnerSteamId, pos.x, pos.y );\n\t\t}\n\t\treturn true;\n\t}\n\n\tstatic void CastTank( Ball ball )\n\t{\n\t\tvar added = MathF.Max( GameConfig.ClassTankMassMin, ball.Mass * GameConfig.ClassTankMassFraction );\n\t\tball.Mass = MathF.Min( GameConfig.MaxMass, ball.Mass + added );\n\t\t_surges.Add( new Surge { Ball = ball, Added = added, Since = 0 } );\n\t}\n\n\t/// <summary> host \u6bcf\u5e27\uff08TickTerritory \u8c03\uff09\uff1a\u5766\u514b\u4e34\u65f6\u8d28\u91cf\u5230\u671f\u56de\u6536\uff08\u4e0b\u9650\u51fa\u751f\u8d28\u91cf\uff1b\u7403\u6ca1\u4e86\u5c31\u4e0d\u8865\uff09 </summary>\n\tpublic static void Tick()\n\t{\n\t\tfor ( int i = _surges.Count - 1; i >= 0; i-- )\n\t\t{\n\t\t\tvar s = _surges[i];\n\t\t\tif ( s.Since < GameConfig.ClassTankSeconds ) continue;\n\n\t\t\tvar b = s.Ball;\n\t\t\tif ( b.IsValid() && b.Alive )\n\t\t\t\tb.Mass = MathF.Max( GameConfig.StartMass, b.Mass - s.Added );\n\t\t\t_surges.RemoveAt( i );\n\t\t}\n\t}\n}"
        },
        {
            "Ident": "slfy.circleroyale",
            "Path": "Game/PowerUpManager.cs",
            "FileName": "PowerUpManager.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 380090,
            "IsPrivate": false,
            "Code": "using System;\nusing System.Collections.Generic;\n\n/// <summary>\n/// \u9053\u5177\u7ba1\u7406\uff08v0.7.3.0\uff09\uff1a**host \u6743\u5a01 + \u9759\u6001 RPC \u540c\u6b65**\uff0c\u4e0e FoodManager \u540c\u6b3e\u6a21\u5f0f\n/// \uff08\u7eaf\u6570\u636e\u5b9e\u4f53\u4e0d\u8d70\u7f51\u7edc\u5bf9\u8c61\u2014\u2014\u8fd0\u884c\u65f6 live create \u4e0d\u590d\u5236\u5230\u5ba2\u6237\u7aef\uff0c\u5b9e\u6d4b\uff09\u3002\n/// \u56fa\u5b9a\u69fd\u4f4d\u5236\uff1a\u573a\u4e0a\u6052 PowerUpOnField \u4e2a\u69fd\uff0c\u88ab\u6361\u540e\u540c\u69fd\u5ef6\u65f6\u91cd\u5237\uff08\u6362\u79cd\u7c7b\u6362\u4f4d\u7f6e\uff09\uff0c\n/// \u5168\u91cf\u540c\u6b65\u53ea\u6709\u4e00\u4e2a\u5c0f\u6570\u7ec4\uff0c\u4e2d\u9014\u52a0\u5165/\u70ed\u91cd\u8f7d\u6062\u590d\u90fd\u4fbf\u5b9c\u3002\n/// \u62fe\u53d6\u4e3b\u7403\u4e0e\u5206\u8eab\u90fd\u7b97\uff08v0.7.8.6\uff0c\u5b58\u4e3b\u4eba\u80cc\u5305\uff09\uff1b\u6548\u679c\u5728\u62fe\u53d6\u77ac\u95f4\u5e94\u7528\u5230 Ball\uff08buff \u6302\u73a9\u5bb6\uff0c\u8d28\u91cf\u7f50\u5373\u65f6\u52a0\u8d28\u91cf\uff09\u3002\n/// Init/Tick \u4ec5\u6743\u5a01\u7aef\u8c03\u7528\uff1bApply* \u4ec5\u4f9b\u5ba2\u6237\u7aef\u5e94\u7528\u8fdc\u7aef\u4e8b\u4ef6\u3002\n/// </summary>\npublic sealed class PowerUpManager\n{\n\t/// <summary> \u9053\u5177\u79cd\u7c7b\uff08HUD/\u6e32\u67d3/\u6548\u679c switch \u5171\u7528\uff1bNone=255 \u4f5c Ball.BuffKind \u7684\"\u65e0 buff\"\u54e8\u5175\uff09 </summary>\n\tpublic enum Kind : byte\n\t{\n\t\tSpeed = 0,    // \u26a1 \u52a0\u901f\uff1a8s \u79fb\u901f \u00d71.4\uff08\u9ec4\uff09\n\t\tMagnet = 1,   // \ud83e\uddf2 \u78c1\u94c1\uff1a8s \u5438\u98df\u534a\u5f84\u7ffb\u500d\uff08\u9752\uff09\n\t\tMass = 2,     // \ud83d\udc8a \u8d28\u91cf\u7f50\uff1a+300 \u5373\u65f6\uff08\u54c1\u7ea2\uff09\n\t\tShield = 3,   // \ud83d\udee1 \u62a4\u76fe\uff1a5s \u4e0d\u53ef\u88ab\u5927\u7403\u5403\uff08\u84dd\uff09\n\t\tSpike = 4,    // \ud83d\udca5 \u5c16\u523a\u5206\u8eab\uff1a\u751f\u6210\u8ddf\u968f\u4e3b\u4eba 20s \u7684\u5c16\u523a\uff08\u7d2b\u7eff\uff09\uff0c\u649e\u7834\u6bd4\u81ea\u5df1\u5927\u7684\u7403\u5956 3s \u65e0\u654c\n\t}\n\n\tpublic const byte None = 255;\n\n\t/// <summary> \u69fd\u4f4d\u6e32\u67d3\u8272\uff08\u4e0e\u9713\u8679\u8c03\u8272\u677f\u72ec\u7acb\u2014\u2014\u9053\u5177\u8981\u4e00\u773c\u548c\u98df\u7269\u533a\u5206\u5f00\uff09 </summary>\n\tpublic static Color ColorOf( byte kind )\n\t{\n\t\tswitch ( kind )\n\t\t{\n\t\t\tcase (byte)Kind.Speed: return new Color( 1.00f, 0.85f, 0.25f );\n\t\t\tcase (byte)Kind.Magnet: return new Color( 0.30f, 0.90f, 1.00f );\n\t\t\tcase (byte)Kind.Mass: return new Color( 1.00f, 0.40f, 0.70f );\n\t\t\tcase (byte)Kind.Shield: return new Color( 0.40f, 0.55f, 1.00f );\n\t\t\tcase (byte)Kind.Spike: return new Color( 0.55f, 1.00f, 0.45f );   // \u4eae\u7eff\uff08\u91ce\u523a\u7d2b=\u969c\u788d\uff0c\u7eff\u8272=\u53ef\u64cd\u7eb5\u7684\u6b66\u5668\uff09\n\t\t\tdefault: return new Color( 0.6f, 0.6f, 0.6f );\n\t\t}\n\t}\n\n\t/// <summary> HUD/\u56fe\u4f8b\u7528\u7684\u9053\u5177\u540d </summary>\n\tpublic static string NameOf( byte kind )\n\t{\n\t\tswitch ( kind )\n\t\t{\n\t\t\tcase (byte)Kind.Speed: return \"SPEED\";\n\t\t\tcase (byte)Kind.Magnet: return \"MAGNET\";\n\t\t\tcase (byte)Kind.Shield: return \"SHIELD\";\n\t\t\tcase (byte)Kind.Spike: return \"SPIKE\";\n\t\t\tcase (byte)Kind.Mass: return \"+MASS\";\n\t\t\tdefault: return \"\";\n\t\t}\n\t}\n\n\t/// <summary> \u9053\u5177\u69fd\uff08\u6e32\u67d3\u4e0e HUD \u8bfb\uff1bhost \u6743\u5a01\uff0c\u5ba2\u6237\u7aef\u4e3a\u4e8b\u4ef6\u955c\u50cf\uff09 </summary>\n\tpublic sealed class Slot\n\t{\n\t\tpublic bool Alive;\n\t\tpublic byte Kind;\n\t\tpublic Vector2 Pos;\n\t\tpublic TimeSince SincePicked;   // \u4ec5 host\uff1a\u91cd\u5237\u8ba1\u65f6\n\t}\n\n\treadonly List<Slot> _slots = new();\n\n\t/// <summary> \u5582\u98df\u5171\u4eab\u79ef\u5206\u6c60\uff08\u6309\u69fd\uff0c\u4ec5 host\uff09\uff1a\u8c01\u5410\u4e1d\u90fd\u5f80\u91cc\u5145\uff0c\u79ef\u5206 \u2265 \u672c\u6b21\u5582\u98df\u8005\u4f53\u91cd\u00d7PowerUpFeedRatio \u5373\u89e6\u53d1\u7ed9\u5582\u98df\u8005\u2014\u2014\n\t/// \u5927\u7403\u5582\u5230\u4e00\u534a\u4f1a\u88ab\u5c0f\u7403\u51e0\u53e3\u62a2\u8d70\uff08\u864e\u53e3\u593a\u98df\uff0cv0.7.4.0 \u7528\u6237\u5b9a\u7a3f\uff09 </summary>\n\tfloat[] _feedScores = Array.Empty<float>();\n\n\t/// <summary> Q \u6280\u80fd\u5145\u80fd\u8ba1\u65f6\uff08\u6309 SteamId\uff0c\u4ec5 host\uff1bv0.7.8.30 Q \u6539\u968f\u65f6\u95f4\u5145\u80fd\uff0cE \u4ecd\u62fe\u53d6\uff09 </summary>\n\treadonly Dictionary<long, TimeSince> _qCharge = new();\n\n\t/// <summary> Q \u69fd\u5f53\u524d\u6301\u6709\u6280\u80fd\u7684\u7403\uff08\u51b7\u5374\u6682\u505c\u6807\u8bb0\uff0c\u68c0\u6d4b\"\u653e\u8d70\"\u7684\u8f6c\u53d8\uff09 </summary>\n\treadonly HashSet<long> _qHeld = new();\n\n\t/// <summary> \u5168\u90e8\u69fd\u4f4d\uff08NeonRenderer \u6bcf\u5e27\u8bfb\uff0c\u5404\u7aef\u5185\u5bb9\u4e00\u81f4\uff09 </summary>\n\tpublic IReadOnlyList<Slot> Slots => _slots;\n\n\t/// <summary> \u5ba2\u6237\u7aef/\u70ed\u91cd\u8f7d\u540e\u672c\u5730\u5217\u8868\u672a\u5c31\u7eea\uff08\u4e0d\u6e32\u67d3\u4e5f\u4e0d\u8bef\u5224\uff09 </summary>\n\tpublic bool Ready => _slots.Count > 0;\n\n\t/// <summary> host\uff1a\u751f\u6210\u5168\u90e8\u69fd\u4f4d\u5e76\u5e7f\u64ad\u5168\u91cf\uff08\u5f00\u5c40/\u6f14\u793a\u8d5b\u8c03\u7528\uff09 </summary>\n\tpublic void Init()\n\t{\n\t\t_slots.Clear();\n\t\tClearCharge();   // \u5c40\u95f4\u6e05\u8d26\uff08v0.7.8.87\uff09\uff1a\u89c1 ClearCharge \u6ce8\u91ca\n\t\tfor ( int i = 0; i < GameConfig.PowerUpOnField; i++ )\n\t\t{\n\t\t\t_slots.Add( new Slot\n\t\t\t{\n\t\t\t\tAlive = true,\n\t\t\t\tKind = RandomKind(),\n\t\t\t\tPos = RandomPos(),\n\t\t\t} );\n\t\t}\n\n\t\tif ( Networking.IsActive ) NetworkManager.PowerUpFull( ToWire() );\n\t\t_feedScores = new float[_slots.Count];\n\t}\n\n\t/// <summary> \u6e05\u7a7a\uff08\u56de\u4e3b\u83dc\u5355\uff1b\u5ba2\u6237\u7aef\u7ecf PowerUpFull \u7a7a\u6570\u7ec4\u540c\u6b65\uff09 </summary>\n\tpublic void Reset()\n\t{\n\t\t_slots.Clear();\n\t\t_feedScores = Array.Empty<float>();\n\t\tClearCharge();\n\t}\n\n\t/// <summary> \u6e05 Q \u5145\u80fd/\u6301\u6709\u65f6\u957f\u8bb0\u8d26\uff08v0.7.8.87\uff09\uff1a`_qCharge` \u6309 SteamId \u8bb0\u800c `TimeSince` \u4e00\u76f4\u5728\u8d70\uff0c\n\t/// \u4e0d\u968f\u5c40\u6e05\u7a7a\u7684\u8bdd\uff0c\u4e0a\u4e00\u5c40\u5145\u80fd\u5230\u4e00\u534a\u7684\u73a9\u5bb6\u5728\u65b0\u5c40\u5f00\u5c40\u51e0\u4e4e\u7acb\u523b\u767d\u62ff\u4e00\u4e2a\u6280\u80fd </summary>\n\tvoid ClearCharge()\n\t{\n\t\t_qCharge.Clear();\n\t\t_qHeld.Clear();\n\t}\n\n\t/// <summary> host\uff1a\u62fe\u53d6\u5224\u5b9a\uff08\u4e3b\u7403 + \u5206\u8eab\u5171\u7528\uff09+ \u5230\u671f\u69fd\u4f4d\u91cd\u5237\u3002\u4ec5\u6743\u5a01\u7aef\u6bcf\u5e27\u8c03\u7528\u3002\n\t/// v0.7.5.0 \u8d77\u62fe\u53d6**\u5b58\u5165\u80cc\u5305**\uff08bot \u4f8b\u5916\uff1a\u62fe\u53d6\u5373\u7528\uff09\u2014\u2014\u80cc\u5305\u6ee1\u5219\u9053\u5177\u7559\u5728\u573a\u4e0a\u4e0d\u88ab\u6361\uff1b\n\t/// v0.7.8.6 \u8d77\u5206\u8eab\uff08SplitPiece\uff09\u4e5f\u8d70\u540c\u4e00\u5224\u5b9a\uff0c\u6361\u5230\u5b58\u4e3b\u4eba\u80cc\u5305 </summary>\n\tpublic void Tick( List<Ball> balls, List<CellPiece> cells = null )\n\t{\n\t\tif ( _slots.Count == 0 ) return;\n\n\t\tforeach ( var ball in balls )\n\t\t{\n\t\t\tif ( !ball.IsValid() || !ball.Alive ) continue;\n\t\t\tPickupPass( ball, ball.WorldPosition, ball.Radius );\n\t\t}\n\n\t\t// \u5206\u8eab\u62fe\u53d6\uff08v0.7.8.6\uff09\uff1a\u6361\u5230\u5b58\u4e3b\u4eba\u80cc\u5305\uff08\u540c\u4e00 Q/E \u80cc\u5305\uff09\uff1b\u5b62\u5b50\u4e0d\u6361\uff08\u4e2d\u6027\u98df\u7269\u56e2\uff09\uff0c\n\t\t// \u5c16\u523a\u5206\u8eab\u4e0d\u6361\uff08\u6b66\u5668\u4e0d\u517c\u804c\u53d6\u8d27\uff09\u3002\u4e3b\u4eba\u6b7b\u4ea1\u7684\u5206\u8eab\u4e0b\u4e00\u5e27 TickCells \u624d\u6e05\uff0c\u8fd9\u91cc\u987a\u5e26\u8fc7\u6ee4\u3002\n\t\t// \u26a0\ufe0f \u5012\u5e8f for\uff1abot \u62ff\u7740\uff0820s \u540e\u8f6c\u6b63\u7684\uff09\u5c16\u523a\u5206\u8eab\u8e29\u5230\u5c16\u523a\u9053\u5177 \u2192 \u62fe\u53d6\u5373\u7528 \u2192\n\t\t// SpawnSpikeMinion \u5f80 _cells \u8ffd\u52a0\u2014\u2014foreach \u679a\u4e3e\u4e2d\u6539\u96c6\u5408\u4f1a\u70b8\uff08v0.7.8.20 \u4fee\uff09\n\t\tif ( cells is not null )\n\t\t{\n\t\t\tfor ( int i = cells.Count - 1; i >= 0; i-- )\n\t\t\t{\n\t\t\t\tvar c = cells[i];\n\t\t\t\tif ( c.PieceKind != CellPiece.Kind.SplitPiece ) continue;\n\t\t\t\tif ( !c.OwnerBall.IsValid() || !c.OwnerBall.Alive ) continue;\n\t\t\t\tPickupPass( c.OwnerBall, c.Pos, c.Radius );\n\t\t\t}\n\t\t}\n\n\t\t// \u7a7a\u69fd\u91cd\u5237\n\t\tfor ( int i = 0; i < _slots.Count; i++ )\n\t\t{\n\t\t\tvar s = _slots[i];\n\t\t\tif ( s.Alive || s.SincePicked < GameConfig.PowerUpRefillSeconds ) continue;\n\n\t\t\ts.Alive = true;\n\t\t\ts.Kind = RandomKind();\n\t\t\ts.Pos = RandomPos();\n\t\t\tif ( i < _feedScores.Length ) _feedScores[i] = 0f;   // \u91cd\u5237\u79ef\u5206\u6e05\u96f6\uff08\u57f9\u517b\u8fdb\u5ea6\u4e0d\u4f5c\u6570\uff09\n\t\t\tif ( Networking.IsActive ) NetworkManager.PowerUpRespawned( i, s.Kind, s.Pos.x, s.Pos.y );\n\t\t}\n\n\t\t// Q \u6280\u80fd\u968f\u65f6\u95f4\u5145\u80fd\uff08v0.7.8.30 \u7528\u6237\u5b9a\u7a3f\uff09\uff1a\u771f\u4eba\u7403 Q \u69fd\u7a7a\u65f6\u6309**\u5f53\u524d\u8d28\u91cf**\u8ba1\u65f6\u5145\u80fd\n\t\t// \uff0815~60s\uff0c\u8d8a\u8f7b\u8d8a\u5feb\uff09\uff0c\u5230\u70b9\u81ea\u52a8\u53d1\u4e00\u4e2a\u968f\u673a\u6280\u80fd\u3002bot \u62fe\u53d6\u5373\u7528\u65e0\u80cc\u5305\uff0c\u4e0d\u53c2\u4e0e\uff1b\n\t\t// \u6301\u6709\u672a\u653e\u65f6\u51b7\u5374\u6682\u505c\uff08\u6280\u80fd\u8eba\u5728\u69fd\u91cc\u7b49\u6309\u952e\uff09\u3002\u8ba1\u65f6\u6309 SteamId \u8bb0\uff08\u8de8\u70ed\u91cd\u8f7d\u7a33\u5b9a\uff09\u3002\n\t\t// \u9886\u571f\u6a21\u5f0f\uff08M7.4\uff09\uff1aQ \u952e\u8ba9\u7ed9\u804c\u4e1a\u6280\u80fd\u2014\u2014\u5145\u80fd pass \u51bb\u7ed3\u6e05\u96f6\uff0cPowerA \u4fdd\u6301\u7a7a\u69fd\n\t\tif ( MatchState.IsTerritory )\n\t\t{\n\t\t\t_qCharge.Clear();\n\t\t\t_qHeld.Clear();\n\t\t\treturn;\n\t\t}\n\t\tforeach ( var b in balls )\n\t\t{\n\t\t\tif ( !b.IsValid() || !b.Alive || b.IsBot ) continue;\n\n\t\t\tvar sid = b.OwnerSteamId;\n\t\t\tif ( b.PowerA != None )\n\t\t\t{\n\t\t\t\t_qHeld.Add( sid );   // \u6280\u80fd\u5f85\u653e\uff1a\u51b7\u5374\u6682\u505c\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif ( _qHeld.Remove( sid ) )\n\t\t\t{\n\t\t\t\t_qCharge[sid] = 0;   // \u521a\u653e\u8d70/\u590d\u6d3b\u6e05\u7a7a\uff1a\u51b7\u5374\u4ece\u73b0\u5728\u8d77\u7b97\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif ( !_qCharge.TryGetValue( sid, out var ts ) )\n\t\t\t{\n\t\t\t\t_qCharge[sid] = 0;   // \u9996\u89c1\uff08\u5165\u5c40\uff09\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif ( ts < GameConfig.QSkillCooldownFor( b.Mass ) ) continue;\n\n\t\t\t_qCharge[sid] = 0;\n\t\t\tb.PowerA = RandomKind();\n\t\t\tif ( GameSfx.IsMine( b.OwnerSteamId ) )\n\t\t\t{\n\t\t\t\tGameSfx.Pickup();\n\t\t\t\tCircleroyaleGame.Current?.Hud?.AddBanner( $\"{NameOf( b.PowerA )} READY (Q)\", ColorOf( b.PowerA ) );\n\t\t\t}\n\t\t\t// \u5ba2\u6237\u7aef\u73a9\u5bb6\u7684 Q \u5230\u70b9\uff1a\u6a2a\u5e45+\u97f3\u6548\u8d70\u5e7f\u64ad\uff08mode2\uff09\uff0chost \u56de\u58f0\u7531 OnPowerBannerRemote \u7684 IsAuthority \u6321\u6389\uff08v0.7.8.31\uff09\n\t\t\tif ( Networking.IsActive ) NetworkManager.PowerBanner( b.PowerA, b.OwnerSteamId, 2 );\n\t\t\tGameLog.Info( $\"[game] q skill granted: {NameOf( b.PowerA )} -> {b.PlayerName}\" );\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// \u4e00\u4e2a\"\u62fe\u53d6\u4f53\"\uff08\u4e3b\u7403\u6216\u5206\u8eab\uff09\u626b\u4e00\u904d\u69fd\u4f4d\u3002bot \u62fe\u53d6\u5373\u7528\uff1b\u771f\u4eba\u5b58\u5165 Q/E \u80cc\u5305\n\t/// \uff08\u6548\u679c\u7b49\u6309\u952e\u91ca\u653e\uff09\uff0c\u80cc\u5305\u6ee1\u5219\u9053\u5177\u7559\u5728\u573a\u4e0a\u3002\u5224\u5b9a\u5708\u5c01\u9876\u53cd\u9a6c\u592a\uff08\u5927\u7403\u8981\u523b\u610f\u78be\u4e0a\u53bb\uff09\u3002\n\t/// </summary>\n\tvoid PickupPass( Ball receiver, Vector3 pos, float radius )\n\t{\n\t\t// \u62fe\u53d6\u5224\u5b9a\u5c01\u9876\uff08v0.7.4.0 \u53cd\u9a6c\u592a\uff09\uff1a\u5927\u7403\u4e0d\u518d\"\u8def\u8fc7\u987a\u8d70\"\u2014\u2014\u5224\u5b9a\u5708\u6309\u5c01\u9876\u534a\u5f84\u7b97\uff0c\n\t\t// \u5fc5\u987b\u523b\u610f\u78be\u5230\u9053\u5177\u6b63\u4e0a\u65b9\u624d\u5403\u5f97\u5230\uff1b\u5c0f\u7403\u53cd\u800c\u8f7b\u677e\n\t\tvar rr = MathF.Min( radius, GameConfig.PowerUpPickRadiusCap ) + GameConfig.PowerUpRadius;\n\n\t\tfor ( int i = 0; i < _slots.Count; i++ )\n\t\t{\n\t\t\tvar s = _slots[i];\n\t\t\tif ( !s.Alive ) continue;\n\n\t\t\tvar dx = s.Pos.x - pos.x;\n\t\t\tvar dy = s.Pos.y - pos.y;\n\t\t\tif ( dx * dx + dy * dy > rr * rr ) continue;\n\n\t\t\tif ( receiver.IsBot )\n\t\t\t{\n\t\t\t\tConsume( i, receiver );   // bot \u4e0d\u56e4\uff1a\u62fe\u53d6\u5373\u89e6\u53d1\u6548\u679c\uff08banner \u7531 Consume \u5185 IsMine \u6321\u6389\uff09\n\t\t\t}\n\t\t\telse if ( receiver.StorePower( s.Kind ) >= 0 )\n\t\t\t{\n\t\t\t\t// \u771f\u4eba\uff1a\u5b58\u5165 Q/E \u80cc\u5305\u69fd\uff08\u6548\u679c\u7b49\u73a9\u5bb6\u6309\u952e\u91ca\u653e\uff09\uff0c\u9053\u5177\u69fd\u91cd\u5237\n\t\t\t\ts.Alive = false;\n\t\t\t\ts.SincePicked = 0;\n\n\t\t\t\tNeonRenderer.Spark( new Vector3( s.Pos.x, s.Pos.y, 0f ), ColorOf( s.Kind ) );\n\t\t\t\tif ( GameSfx.IsMine( receiver.OwnerSteamId ) )\n\t\t\t\t{\n\t\t\t\t\tGameSfx.EatFood( new Vector3( s.Pos.x, s.Pos.y, 0f ) );\n\t\t\t\t\tGameAchievements.PowerStored();   // \u6210\u5c31\uff1a\u7d2f\u8ba1\u6361 5 \u4e2a\u5165\u5305\uff08host \u672c\u673a\u4fa7\uff09\n\t\t\t\t}\n\t\t\t\tif ( Networking.IsActive ) NetworkManager.PowerUpPicked( i, s.Kind, receiver.OwnerSteamId );\n\n\t\t\t\tShowBanner( receiver, s.Kind, stored: true );\n\t\t\t\tGameLog.Info( $\"[game] powerup stored: {NameOf( s.Kind )} -> {receiver.PlayerName}\" );\n\t\t\t}\n\t\t\t// \u80cc\u5305\u6ee1\uff1a\u4e0d\u6d88\u8017\uff0c\u9053\u5177\u7559\u5728\u573a\u4e0a\uff08\u4e0b\u4e00\u9897\u7403/\u4e0b\u4e2a\u5e27\u518d\u5224\uff09\n\t\t}\n\t}\n\n\t/// <summary> \u6d88\u8017\u9053\u5177\u5e76\u89e6\u53d1\u6548\u679c\uff08bot \u62fe\u53d6\u5373\u7528 / \u5582\u98df\u89e6\u53d1\u5171\u7528\u8def\u5f84\uff1b\u69fd\u91cd\u5237\uff09 </summary>\n\tvoid Consume( int index, Ball receiver )\n\t{\n\t\tif ( (uint)index >= (uint)_slots.Count ) return;\n\t\tvar s = _slots[index];\n\t\tif ( !s.Alive ) return;\n\n\t\ts.Alive = false;\n\t\ts.SincePicked = 0;\n\t\tif ( index < _feedScores.Length ) _feedScores[index] = 0f;\n\n\t\tApplyEffect( receiver, s.Kind );\n\t\tNeonRenderer.Spark( new Vector3( s.Pos.x, s.Pos.y, 0f ), ColorOf( s.Kind ) );\n\t\tif ( GameSfx.IsMine( receiver.OwnerSteamId ) )\n\t\t\tGameSfx.EatFood( new Vector3( s.Pos.x, s.Pos.y, 0f ) );\n\n\t\tShowBanner( receiver, s.Kind, stored: false );\n\t\tif ( Networking.IsActive ) NetworkManager.PowerUpPicked( index, s.Kind, receiver.OwnerSteamId );\n\t}\n\n\t/// <summary>\n\t/// \u4e2a\u4eba\u9ad8\u5149\u6a2a\u5e45\uff08v0.7.6.0\uff09\uff1ahost \u672c\u5730\u64ad\u81ea\u5df1\u7684 + \u5e7f\u64ad\u7ed9\u5176\u4ed6\u7aef\uff08\u5ba2\u6237\u7aef\u6309 IsMine \u518d\u663e\u793a\uff09\u3002\n\t/// stored=true \"XXX STORED\"\uff08\u6361\u5230\u5165\u80cc\u5305\uff09/ false \u6fc0\u6d3b\u6587\u6848\uff08\u5582\u517b\u89e6\u53d1/bot \u62fe\u53d6\u5373\u7528\uff09\u3002\n\t/// v0.7.7.0\uff1a\u6a2a\u5e45\u914d\u5957\"\u53ee\u549a\"\u97f3\uff08\u672c\u673a\u8033\u6735\uff09\u3002\n\t/// </summary>\n\tvoid ShowBanner( Ball receiver, byte kind, bool stored )\n\t{\n\t\tvar text = stored ? $\"{NameOf( kind )} STORED\" : GameHud.ActivationText( kind );\n\t\tif ( GameSfx.IsMine( receiver.OwnerSteamId ) )\n\t\t{\n\t\t\tCircleroyaleGame.Current?.Hud?.AddBanner( text, ColorOf( kind ) );\n\t\t\tGameSfx.Pickup();\n\t\t}\n\n\t\tif ( Networking.IsActive ) NetworkManager.PowerBanner( kind, receiver.OwnerSteamId, (byte)( stored ? 0 : 1 ) );\n\t}\n\n\t/// <summary>\n\t/// host\uff1a\u5b62\u5b50\u5582\u9053\u5177\uff08\"\u8840\u91cf\u6c60\"\uff09\u2014\u2014\u5171\u4eab\u79ef\u5206\u6c60\u6309\u69fd\u7d2f\u8ba1\uff08\u8c01\u5410\u4e1d\u90fd\u5f80\u91cc\u5145\uff09\uff0c\n\t/// \u79ef\u5206 \u2265 \u672c\u6b21\u5582\u98df\u8005\u5f53\u524d\u4f53\u91cd \u00d7 PowerUpFeedRatio \u5373\u89e6\u53d1\u6548\u679c\u7ed9\u5582\u98df\u8005\uff08**\u7acb\u5373\u751f\u6548\uff0c\u4e0d\u5165\u80cc\u5305**\uff09\u3002\n\t/// \u5927\u7403\u8981\u5582\u5230 5%\u00d7\u5927\u4f53\u91cd\u624d\u8f6e\u5230\u81ea\u5df1\u8fbe\u6807\uff0c\u5c0f\u7403\u51e0\u53e3\u5c31\u591f\u2014\u2014\u4e2d\u9014\u622a\u80e1\u5373\"\u864e\u53e3\u593a\u98df\"\u3002\n\t/// feeder \u65e0\u6548\uff08\u65ad\u7ebf/\u6b7b\u4ea1\u77ac\u95f4\uff09\u53ea\u5145\u80fd\u4e0d\u5224\u5b9a\u3002\n\t/// </summary>\n\tpublic void Feed( Ball feeder, int index, float blobMass )\n\t{\n\t\tif ( (uint)index >= (uint)_slots.Count || !_slots[index].Alive ) return;\n\t\tif ( index >= _feedScores.Length ) Array.Resize( ref _feedScores, _slots.Count );\n\n\t\t_feedScores[index] += blobMass;\n\n\t\tif ( feeder is null || !feeder.IsValid() || !feeder.Alive ) return;\n\t\tif ( _feedScores[index] < feeder.Mass * GameConfig.PowerUpFeedRatio ) return;\n\n\t\tvar kind = _slots[index].Kind;\n\t\tConsume( index, feeder );\n\t\tGameAchievements.FeedTriggered( feeder.OwnerSteamId );   // \u6210\u5c31\uff1a\u9996\u6b21\u5582\u98df\u89e6\u53d1\uff08host \u672c\u673a\u4fa7\uff09\n\t\tGameLog.Info( $\"[game] powerup fed to trigger: {NameOf( kind )} -> {feeder.PlayerName}\" );\n\t}\n\n\t/// <summary> \u9053\u5177\u6548\u679c\uff08host \u6743\u5a01\uff1b\u8d28\u91cf\u7f50\u5373\u65f6\u5230\u8d26\uff0c\u5176\u4f59\u6302 buff\uff0c\u5c16\u523a\u5206\u8eab\u5728\u6e38\u620f\u7cfb\u7edf\u751f\u6210\uff09\u3002\n\t/// \u62fe\u53d6\u5b58\u80cc\u5305\uff08v0.7.5.0\uff09\uff1a\u771f\u4eba\u6309\u952e UsePower \u65f6\u624d\u8d70\u5230\u8fd9\u91cc </summary>\n\tpublic static void ApplyEffect( Ball ball, byte kind )\n\t{\n\t\tswitch ( kind )\n\t\t{\n\t\t\tcase (byte)Kind.Speed:\n\t\t\t\tball.ApplyBuff( kind, GameConfig.PowerUpSpeedSeconds );\n\t\t\t\tbreak;\n\t\t\tcase (byte)Kind.Magnet:\n\t\t\t\tball.ApplyBuff( kind, GameConfig.PowerUpMagnetSeconds );\n\t\t\t\tbreak;\n\t\t\tcase (byte)Kind.Shield:\n\t\t\t\tball.ApplyBuff( kind, GameConfig.PowerUpShieldSeconds );\n\t\t\t\tbreak;\n\t\t\tcase (byte)Kind.Mass:\n\t\t\t\tball.Mass = MathF.Min( GameConfig.MaxMass, ball.Mass + GameConfig.PowerUpMassBonus );\n\t\t\t\tbreak;\n\t\t\tcase (byte)Kind.Spike:\n\t\t\t\tCircleroyaleGame.Current?.SpawnSpikeMinion( ball );\n\t\t\t\tbreak;\n\t\t}\n\n\t\tGameLog.Info( $\"[game] powerup picked: {NameOf( kind )} -> {ball.PlayerName}\" );\n\t}\n\n\tbyte RandomKind() => (byte)Game.Random.Float( 0f, 4.999f );\n\n\tVector2 RandomPos()\n\t{\n\t\tvar half = GameConfig.ArenaHalfSize - GameConfig.PowerUpMargin;\n\t\treturn new Vector2( Game.Random.Float( -half, half ), Game.Random.Float( -half, half ) );\n\t}\n\n\t/// <summary> \u5168\u91cf\u5feb\u7167\uff08\u5f00\u5c40\u8865\u53d1/\u65b0\u8fde\u63a5\u5b9a\u5411\u7528\uff1bhost \u4fa7\u8c03\u7528\uff09 </summary>\n\tpublic PowerUpWire[] ToWireForSync() => ToWire();\n\n\tPowerUpWire[] ToWire()\n\t{\n\t\tvar wires = new PowerUpWire[_slots.Count];\n\t\tfor ( int i = 0; i < _slots.Count; i++ )\n\t\t{\n\t\t\tvar s = _slots[i];\n\t\t\twires[i] = new PowerUpWire\n\t\t\t{\n\t\t\t\tKind = s.Alive ? s.Kind : None,\n\t\t\t\tX = s.Pos.x,\n\t\t\t\tY = s.Pos.y,\n\t\t\t};\n\t\t}\n\t\treturn wires;\n\t}\n\n\t// ---- \u5ba2\u6237\u7aef\u5e94\u7528\uff08\u8fdc\u7aef\u4e8b\u4ef6 \u2192 \u672c\u5730\u69fd\u4f4d\uff09----\n\n\tpublic void ApplyFull( PowerUpWire[] wires )\n\t{\n\t\t_slots.Clear();\n\t\tif ( wires is null ) return;\n\n\t\tforeach ( var w in wires )\n\t\t{\n\t\t\t_slots.Add( new Slot\n\t\t\t{\n\t\t\t\tAlive = w.Kind != None,\n\t\t\t\tKind = w.Kind,\n\t\t\t\tPos = new Vector2( w.X, w.Y ),\n\t\t\t} );\n\t\t}\n\t}\n\n\tpublic void ApplyPicked( int index )\n\t{\n\t\tif ( (uint)index >= (uint)_slots.Count ) return;   // \u70ed\u91cd\u8f7d\u540e\u672a\u540c\u6b65\u5168\u91cf\u524d\uff0c\u5ffd\u7565\u589e\u91cf\n\t\t_slots[index].Alive = false;\n\t}\n\n\tpublic void ApplyRespawned( int index, byte kind, Vector2 pos )\n\t{\n\t\tif ( (uint)index >= (uint)_slots.Count ) return;\n\n\t\tvar s = _slots[index];\n\t\ts.Alive = true;\n\t\ts.Kind = kind;\n\t\ts.Pos = pos;\n\t}\n}"
        },
        {
            "Ident": "slfy.circleroyale",
            "Path": "UI/MainMenu.cs",
            "FileName": "MainMenu.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 380090,
            "IsPrivate": false,
            "Code": "using Sandbox.UI;\n\n/// <summary>\n/// \u4e3b\u83dc\u5355\uff08M4\uff1b2026-09-09 \u53c2\u8003\u624b\u6e38\u5e03\u5c40\u91cd\u6392\uff0c\u7528\u6237\u5b9a\u7a3f\uff09\uff1a\u4e2d\u592e\u5927 logo + \u4e09\u6309\u94ae\u6a2a\u6392\n/// \uff08CREATE GAME / JOIN GAME / VS BOTS\uff09\uff0c\u5de6\u4e0a\u73a9\u5bb6\u8d44\u6599\u5361\uff08\u5934\u50cf/\u540d\u5b57/\u7b49\u7ea7\uff09\uff0c\n/// \u53f3\u4e0a\u8bbe\u7f6e/\u6392\u884c\u699c\u5c0f\u6309\u94ae\uff1bQUIT \u6309\u9700\u6c42\u79fb\u9664\uff08\u7f16\u8f91\u5668\u505c Play \u5373\u9000\u51fa\uff0cstandalone \u518d\u52a0\u56de\uff09\u3002\n/// \u70b9\u51fb + \u952e\u76d8\u5feb\u6377\u952e\u53cc\u901a\u9053\uff08\u7f16\u8f91\u5668\u5185\u5d4c\u89c6\u53e3\u53ef\u80fd\u6536\u4e0d\u5230\u9f20\u6807\u70b9\u51fb\uff0c\u952e\u76d8\u4fdd\u5e95\uff09\uff1a\n/// 1=\u5efa\u670d 2=\u52a0\u5165 3=\u4eba\u673a 4=\u8bbe\u7f6e\u9875 5(\u6570\u5b57\u952e)=\u6392\u884c\u699c\u3002\n/// \u6837\u5f0f\u5728\u540c\u76ee\u5f55 MainMenu.cs.scss\uff08\u6309\"\u7c7b\u540d.cs.scss\"\u81ea\u52a8\u52a0\u8f7d\uff09\u3002\n/// </summary>\npublic sealed class MainMenu : PanelComponent\n{\n\tLabel _status;\n\tLabel _level;\n\tImage _avatar;\n\tTimeSince _sinceAvatarTry = 1f;\n\tbool _built;\n\n\tprotected override void OnStart()\n\t{\n\t\tbase.OnStart();\n\t\tBuildUi();\n\t}\n\n\tvoid BuildUi()\n\t{\n\t\tvar root = Panel;\n\t\tif ( root is null ) return;\n\n\t\t// \u5df2\u5efa\u597d\u4e14\u5f15\u7528\u6709\u6548 \u2192 \u4e0d\u91cd\u590d\u5efa\uff08\u70ed\u91cd\u8f7d\u534a\u6b8b\u65f6\u6574\u4f53\u6e05\u7a7a\u91cd\u5efa\uff09\n\t\tif ( _built && _status.IsValid() && root.ChildrenCount > 0 ) return;\n\n\t\troot.DeleteChildren( true );\n\n\t\t// \u5de6\u4e0a\uff1a\u73a9\u5bb6\u8d44\u6599\u5361\uff08\u5934\u50cf / \u540d\u5b57 / \u7b49\u7ea7\uff09\n\t\tvar profile = new Panel() { Classes = \"profile\" };\n\t\troot.AddChild( profile );\n\n\t\t_avatar = new Image() { Classes = \"profile-avatar\" };\n\t\tprofile.AddChild( _avatar );\n\n\t\tvar info = new Panel() { Classes = \"profile-info\" };\n\t\tprofile.AddChild( info );\n\n\t\tvar name = new Label() { Classes = \"profile-name\" };\n\t\tname.Text = Sandbox.Utility.Steam.PersonaName;\n\t\tinfo.AddChild( name );\n\n\t\t_level = new Label() { Classes = \"profile-level\" };\n\t\tinfo.AddChild( _level );\n\n\t\t// \u53f3\u4e0a\uff1a\u8bbe\u7f6e / \u6392\u884c\u699c\uff08\u56fe\u6807\u5c0f\u6309\u94ae\uff1aGear / Trophy\uff09\n\t\tvar actions = new Panel() { Classes = \"top-actions\" };\n\t\troot.AddChild( actions );\n\t\tAddIconButton( actions, \"ui/skin/ic_gear.png\", \"settings\" );\n\t\tAddIconButton( actions, \"ui/skin/ic_trophy.png\", \"rankings\" );\n\n\t\t// \u4e2d\u592e\u5927 logo\uff08\u526f\u6807\u9898\u5df2\u6309\u9700\u6c42\u79fb\u9664\uff09\n\t\tvar title = new Label() { Classes = \"title\" };\n\t\ttitle.Text = \"CIRCLEROYALE\";\n\t\troot.AddChild( title );\n\n\t\t// \u4e09\u4e2a\u4e3b\u6309\u94ae\u6a2a\u6392\uff08v0.7.8.43 \u53c2\u8003\u624b\u6e38\u5e03\u5c40\uff09\n\t\tvar row = new Panel() { Classes = \"menu-row\" };\n\t\troot.AddChild( row );\n\t\tAddButton( row, \"CREATE GAME\", \"host\" );\n\t\tAddButton( row, \"JOIN GAME\", \"join\" );\n\t\tAddButton( row, \"VS BOTS\", \"bots\" );\n\n\t\t_status = new Label() { Classes = \"status\" };\n\t\troot.AddChild( _status );\n\n\t\tvar ver = new Label() { Classes = \"version\" };\n\t\tver.Text = GameConfig.Version;\n\t\troot.AddChild( ver );\n\n\t\tvar hint = new Label() { Classes = \"hint\" };\n\t\thint.Text = \"WASD MOVE \u2014 SPACE SPLIT \u2014 R FEED\";\n\t\troot.AddChild( hint );\n\n\t\tTryLoadAvatar();\n\t\t_built = true;\n\t}\n\n\tvoid AddButton( Panel parent, string text, string id )\n\t{\n\t\tvar b = new Label() { Classes = $\"btn {id}\" };\n\t\tb.Text = text;\n\t\t// lambda \u5fc5\u987b\u6355\u83b7 this\uff08\u8c03\u5b9e\u4f8b\u65b9\u6cd5\uff09\u2014\u2014\u65e0\u6355\u83b7\u7684\u9759\u6001 lambda \u5728\u70ed\u91cd\u8f7d\u540e\u65e0\u6cd5\u91cd\u6620\u5c04\uff0c\n\t\t// \u70b9\u51fb\u4f1a\u629b NoMatchLambda \u5f02\u5e38\uff08\u5b9e\u6d4b\uff1a\u6309\u94ae\u5168\u53d8\"\u70b9\u4e86\u6ca1\u53cd\u5e94\"\uff09\n\t\tb.AddEventListener( \"onclick\", () => OnMenuButton( id ) );\n\t\tparent.AddChild( b );\n\t}\n\n\t/// <summary> \u56fe\u6807\u5c0f\u6309\u94ae\uff08\u53f3\u4e0a\u89d2\u8bbe\u7f6e/\u6392\u884c\u699c\uff09\uff1adobou \u69fd\u5e95\u56fe + \u50cf\u7d20\u56fe\u6807\u3002\n\t/// \u56fe\u6807\u4e0d\u5b9a\u4f4d\u4e0d\u8bbe\u5c3a\u5bf8\uff0c\u5168\u4ea4\u7ed9 scss\uff08.icon-btn \u7684 flex \u5c45\u4e2d + .icon-btn-img \u7684\u5bbd\u9ad8\uff09\u2014\u2014\n\t/// \u4e4b\u524d\u56fe\u6807\u7edd\u5bf9\u5b9a\u4f4d\uff0c\u53c2\u7167\u7269\u662f\u6574\u4e2a\u53f3\u4e0a\u89d2\u5bb9\u5668\u800c\u4e0d\u662f\u6309\u94ae\uff0c\u4e24\u4e2a\u56fe\u6807\u53e0\u5728\u4e00\u8d77\uff08\u5b9e\u6d4b\uff09\uff1b\n\t/// C# \u5185\u8054\u6837\u5f0f\u4f1a\u538b\u8fc7\u6837\u5f0f\u8868\uff0c\u8fd9\u91cc\u8bbe\u4e86\u5c3a\u5bf8\u7528\u6237\u5728 scss \u91cc\u5c31\u6539\u4e0d\u52a8\u4e86 </summary>\n\tvoid AddIconButton( Panel parent, string iconPath, string id )\n\t{\n\t\tvar b = new Panel() { Classes = \"icon-btn\" };\n\t\tvar icon = new Image() { Classes = \"icon-btn-img\" };\n\t\ticon.Texture = Texture.Load( iconPath );\n\t\ticon.Style.PointerEvents = PointerEvents.None;   // \u70b9\u51fb\u7a7f\u900f\u5230\u6309\u94ae\n\t\tb.AddChild( icon );\n\t\tb.AddEventListener( \"onclick\", () => OnMenuButton( id ) );   // \u6355\u83b7 this\uff1a\u70ed\u91cd\u8f7d\u53ef\u91cd\u6620\u5c04\n\t\tparent.AddChild( b );\n\t}\n\n\t/// <summary> \u672c\u673a\u73a9\u5bb6\u5934\u50cf\uff1aSteam \u62c9\u53d6\uff08\u4e0b\u8f7d\u5f02\u6b65\uff0cOnUpdate 1s \u8282\u6d41\u91cd\u8bd5\uff09\uff0c\u5931\u8d25\u843d\u5360\u4f4d\u56fe </summary>\n\tvoid TryLoadAvatar()\n\t{\n\t\tif ( _avatar is null || !IsValid ) return;\n\n\t\tvar sid = (long)(ulong)Sandbox.Utility.Steam.SteamId;\n\t\tvar tex = sid != 0 ? Texture.LoadAvatar( sid, 128 ) : null;\n\t\t_avatar.Texture = tex ?? Texture.Load( GameConfig.AvatarPlaceholderPath, false );\n\t}\n\n\t/// <summary> \u83dc\u5355\u6309\u94ae\u70b9\u51fb\uff08\u5b9e\u4f8b\u65b9\u6cd5\uff1a\u70ed\u91cd\u8f7d\u53ef\u91cd\u6620\u5c04\uff09 </summary>\n\tvoid OnMenuButton( string id )\n\t{\n\t\tGameSfx.UiClick();\n\t\tCircleroyaleGame.Current?.MenuAction( id switch { \"host\" => 0, \"join\" => 1, \"bots\" => 2, \"settings\" => 4, \"rankings\" => 5, _ => 3 } );\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tbase.OnUpdate();\n\n\t\t// \u5f3a\u5236\u5149\u6807\u53ef\u89c1\u53ef\u4ea4\u4e92\uff08\u9762\u677f\u9ed8\u8ba4 PointerEvents=None\uff0cAuto \u6a21\u5f0f\u4e0b\u6ca1\u4eba\"\u60f3\u8981\u9f20\u6807\"\u5149\u6807\u5c31\u6ca1\u4e86\uff09\uff1b\n\t\t// Hide() \u65f6\u6062\u590d Auto\u3002\u653e\u5728 OnUpdate \u6bcf\u5e27\u65ad\u8a00\uff0c\u4efb\u4f55\u6837\u5f0f\u7ea7\u8054\u610f\u5916\u90fd\u515c\u5f97\u4f4f\n\t\tMouse.Visibility = MouseVisibility.Visible;\n\n\t\tBuildUi();\n\n\t\t// \u952e\u76d8\u4fdd\u5e95\uff08\u7f16\u8f91\u5668\u5185\u5d4c\u89c6\u53e3\u9f20\u6807\u4e8b\u4ef6\u4e0d\u53ef\u9760\uff0c\u952e\u76d8\u4e00\u5b9a\u80fd\u8fdb\u6765\uff09\uff1a\n\t\t// [4] \u8bbe\u7f6e\u9875\uff1b\u6570\u5b57 5 \u76f4\u8bfb\uff08Slot5 \u672a\u5728 Input \u914d\u7f6e\u91cc\uff09= \u6392\u884c\u699c\uff1bQUIT \u5df2\u79fb\u9664\n\t\tif ( Input.Pressed( \"Slot1\" ) ) CircleroyaleGame.Current?.MenuAction( 0 );\n\t\tif ( Input.Pressed( \"Slot2\" ) ) CircleroyaleGame.Current?.MenuAction( 1 );\n\t\tif ( Input.Pressed( \"Slot3\" ) ) CircleroyaleGame.Current?.MenuAction( 2 );\n\t\tif ( Input.Pressed( \"Slot4\" ) ) CircleroyaleGame.Current?.MenuAction( 4 );\n\t\tif ( Input.Keyboard.Pressed( \"5\" ) ) CircleroyaleGame.Current?.MenuAction( 5 );\n\n\t\t// \u7b49\u7ea7\u89d2\u6807\uff08\u4e91\u7aef XP \u5f02\u6b65\u52a0\u8f7d\uff0cGameXp \u5185\u90e8\u8282\u6d41\u81ea\u6108\u91cd\u8bd5\uff09\n\t\tGameXp.EnsureLocalXp();\n\t\tSetText( _level, GameXp.LevelText() );\n\n\t\t// \u5934\u50cf\u6ca1\u62c9\u5230\uff08Steam \u4e0b\u8f7d\u5f02\u6b65\uff09\u6bcf\u79d2\u91cd\u8bd5\u4e00\u6b21\uff0c\u62c9\u5230\u5373\u505c\n\t\tif ( _avatar.IsValid() && _avatar.Texture is null && _sinceAvatarTry > 1f )\n\t\t{\n\t\t\t_sinceAvatarTry = 0;\n\t\t\tTryLoadAvatar();\n\t\t}\n\t}\n\n\tstatic void SetText( Label l, string text )\n\t{\n\t\tif ( l is null || !l.IsValid() || l.Text == text ) return;\n\t\tl.Text = text;\n\t}\n\n\t/// <summary> \u72b6\u6001\u884c\uff08\u8fde\u63a5\u4e2d / \u5931\u8d25\u91cd\u8bd5\u63d0\u793a\uff09 </summary>\n\tpublic void SetStatus( string text )\n\t{\n\t\tif ( _status.IsValid() ) _status.Text = text;\n\t}\n\n\t/// <summary> \u5f00\u5c40\u540e\u9690\u85cf\u83dc\u5355\uff08Panel \u65e0 Enabled\uff0c\u76f4\u63a5\u7981\u6389\u6574\u4e2a GameObject\uff09\uff0c\u5e76\u628a\u5149\u6807\u8fd8\u7ed9\u6e38\u620f </summary>\n\tpublic void Hide()\n\t{\n\t\tif ( !GameObject.Enabled ) return;   // \u5df2\u9690\u85cf\uff1a\u522b\u52a8\u5149\u6807\uff08ReconcileUi \u6bcf\u5e27\u8c03\uff0c\u623f\u95f4\u9875\u8981\u5149\u6807\u53ef\u70b9\uff09\n\t\tMouse.Visibility = MouseVisibility.Auto;\n\t\tGameObject.Enabled = false;\n\t}\n\n\t/// <summary> \u4ece\u623f\u95f4/\u7ed3\u7b97\u56de\u83dc\u5355\uff08M5\uff09\uff1a\u91cd\u65b0\u663e\u793a\uff08OnUpdate \u6062\u590d\u5149\u6807\u65ad\u8a00\uff09 </summary>\n\tpublic void Show()\n\t{\n\t\tGameObject.Enabled = true;\n\t\tBuildUi();\n\t\tif ( _status.IsValid() ) _status.Text = \"\";\n\t}\n}"
        },
        {
            "Ident": "slfy.circleroyale",
            "Path": "ui/settingspanel.cs.scss",
            "FileName": "settingspanel.cs.scss",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 380090,
            "IsPrivate": false,
            "Code": "SettingsPanel {\n\tposition: absolute;\n\tleft: 0;\n\ttop: 0;\n\tright: 0;\n\tbottom: 0;\n\tfont-family: PixelFusion;\n\tcolor: #4a3b63;\n\tpointer-events: all;\n\tbackground-color: rgba( 234, 244, 252, 0.88 );   // \u6d45\u84dd\u78e8\u7802\uff08\u50cf\u7d20\u98ce\uff09\n\n\t.sp-title {\n\t\tposition: absolute;\n\t\tleft: 0;\n\t\tright: 0;\n\t\ttop: 88px;\n\t\tfont-size: 48px;\n\t\tfont-weight: 800;\n\t\tletter-spacing: 10px;\n\t\ttext-align: center;\n\t\tcolor: #f272a7;\n\t\ttext-shadow: 0 4px 0 #4a3b63;\n\t}\n\n\t.sp-hint {\n\t\tposition: absolute;\n\t\tleft: 0;\n\t\tright: 0;\n\t\ttop: 178px;\n\t\tfont-size: 13px;\n\t\tletter-spacing: 3px;\n\t\ttext-align: center;\n\t\tcolor: rgba( 74, 59, 99, 0.6 );\n\t}\n\n\t// \u8bbe\u7f6e\u884c\uff08v0.7.8.42\uff09\uff1adobou \u5976\u6cb9\u6309\u94ae\u4e5d\u5bab\u683c\uff0c600 \u5bbd\u5c45\u4e2d\uff08\u4f4d\u7f6e\u7531 C# \u8bbe left:center\uff09\n\t.sp-row {\n\t\theight: 54px;\n\t\tpadding-top: 8px;   // \u7528\u6237\u8981\u6c42\uff1a\u6587\u5b57\u4e0b\u79fb 8 \u50cf\u7d20\n\t\tfont-size: 20px;\n\t\tfont-weight: 700;\n\t\tletter-spacing: 3px;\n\t\ttext-align: center;\n\t\tcolor: #4a3b63;\n\t\tborder-image: url( ui/skin/btn_white.png ) 52 fill / 26px stretch;\n\t\tborder-image-tint: #ceecee;   // \u6d45\u7eff\u5e95\uff08\u4e0e UiKit \u836f\u4e38\u4e00\u81f4\uff09\n\t\tcursor: pointer;\n\t\tpointer-events: all;\n\t}\n\n\t.sp-row:hover {\n\t\tborder-image-tint: #e4f7dd;\n\t\tcolor: #277e4b;\n\t}\n\n\t// \u8fd4\u56de\u6309\u94ae\uff08v0.7.8.49\uff09\uff1aUiKit \u836f\u4e38\u6309\u94ae\uff08\u5916\u89c2\u5728 UiKit.cs.scss\uff09\uff0c\u8fd9\u91cc\u53ea\u7ba1\u6446\u653e\n\t.sp-back {\n\t\tposition: absolute;\n\t\tleft: 50%;\n\t\tmargin-left: -110px;   // \u81ea\u5df1\u5bbd\u5ea6 220 \u7684\u4e00\u534a\uff0c\u4efb\u610f\u5206\u8fa8\u7387\u6c34\u5e73\u5c45\u4e2d\n\t\ttop: 848px;\n\t\twidth: 220px;\n\t\tpadding-top: 8px;\n\t}\n}"
        },
        {
            "Ident": "slfy.circleroyale",
            "Path": "Game/GameAchievements.cs",
            "FileName": "GameAchievements.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 380090,
            "IsPrivate": false,
            "Code": "using System;\nusing System.Collections.Generic;\n\n/// <summary>\n/// \u6210\u5c31\u5bf9\u63a5\uff08v0.7.8.14\uff0c\u5171 25 \u6761\uff09\uff1a\u6210\u5c31\u672c\u4f53\u5728 sbox.game \u6e38\u620f\u5305\u9875\u9762 Services > Achievements \u914d\u7f6e\n/// \uff08ident/\u6807\u9898/\u63cf\u8ff0/\u56fe\u6807/\u5206\u503c/\u89e3\u9501\u6a21\u5f0f\uff09\uff0c\u4ee3\u7801\u53ea\u6309 ident \u89e6\u53d1 Manual \u6210\u5c31\u3002\n/// \u5f15\u64ce\u884c\u4e3a\uff08\u6e90\u7801 AchievementCollection.ManualUnlock\uff09\uff1aident \u672a\u914d\u7f6e/\u5df2\u89e3\u9501/\u975e Manual \u65f6\n/// \u9759\u9ed8\u8df3\u8fc7\u2014\u2014\u5148\u5199\u4ee3\u7801\u540e\u914d\u9875\u9762\uff0c\u914d\u597d\u5373\u751f\u6548\uff1b\u6bcf\u6b21\u4e8b\u4ef6\u90fd\u8c03 Unlock\uff0c\u7531\u5f15\u64ce\u53bb\u91cd\u3002\n/// Unlock \u53ea\u5bf9**\u672c\u673a\u73a9\u5bb6**\u751f\u6548\uff08\u4e13\u7528\u670d\u52a1\u5668\u81ea\u52a8 no-op\uff09\u2014\u2014\u6240\u6709\u89e6\u53d1\u70b9\u5fc5\u987b\u5148 GameSfx.IsMine\n/// \u5224\u5b9a\"\u662f\u4e0d\u662f\u672c\u673a\u8d5a\u5230\u7684\"\uff0chost \u4e0d\u66ff\u5ba2\u6237\u7aef\u89e3\u9501\u3002\n/// \u7d2f\u8ba1\u578b\u6210\u5c31\uff08\u5403\u98df\u7269\u603b\u6570\u7b49\uff09\u8d70\u5305\u9875\u9762 Stat \u7ed1\u5b9a\uff08Stats.Increment \u5373\u53ef\uff09\uff0c\u4e0d\u8d70\u8fd9\u91cc\u3002\n/// \u8fdb\u7a0b\u5185\u8ba1\u6570\u5668\uff08\u51fb\u6740\u6570/\u6361\u5305\u6570\uff09\u91cd\u542f\u4f1a\u6e05\u96f6\u2014\u2014\u4e00\u6b21\u6027\u6210\u5c31\u89e3\u9501\u7531\u4e91\u7aef\u53bb\u91cd\uff0c\u53ea\u5f71\u54cd\"\u665a\u51e0\u5c40\u89e3\u9501\"\u3002\n/// </summary>\npublic static class GameAchievements\n{\n\t// ---- \u9996\u6279 5 \u6761\uff08v0.7.8.13\uff09----\n\tpublic const string FirstBlood = \"first_blood\";     // \u9996\u6b21\u5403\u6389\u4e00\u540d\u73a9\u5bb6\n\tpublic const string Godlike = \"godlike\";            // \u5355\u5c40\u4e94\u8fde\u6740\n\tpublic const string SpikeBlast = \"spike_blast\";     // \u5c16\u523a\u5206\u8eab\u70b8\u6389\u6bd4\u81ea\u5df1\u5927\u7684\u7403\n\tpublic const string TeamWinner = \"team_winner\";     // \u56e2\u961f\u8d5b\u6240\u5728\u961f\u593a\u51a0\n\tpublic const string Champion = \"champion\";          // \u4e2a\u4eba\u7b2c\u4e00\u540d\u7ed3\u7b97\n\n\t// ---- \u7b2c\u4e8c\u6279 20 \u6761\uff08v0.7.8.14\uff09----\n\tpublic const string DoubleKill = \"double_kill\";     // \u5355\u5c40\u53cc\u8fde\u6740\n\tpublic const string TripleKill = \"triple_kill\";     // \u5355\u5c40\u4e09\u8fde\u6740\n\tpublic const string Rampage = \"rampage\";            // \u5355\u5c40\u56db\u8fde\u6740\n\tpublic const string BigPrey = \"big_prey\";           // \u5355\u53e3\u541e\u6389\u5927\u7403\n\tpublic const string Revenge = \"revenge\";            // \u5403\u6389\u4e0a\u6b21\u5403\u6389\u81ea\u5df1\u7684\u73a9\u5bb6\n\tpublic const string Hunter10 = \"hunter_10\";         // \u7d2f\u8ba1\u5403 10 \u540d\u73a9\u5bb6\n\tpublic const string Hunter50 = \"hunter_50\";         // \u7d2f\u8ba1\u5403 50 \u540d\u73a9\u5bb6\n\tpublic const string HalfTon = \"half_ton\";           // \u4e00\u6761\u547d\u603b\u8d28\u91cf 500\n\tpublic const string OneTon = \"one_ton\";             // \u4e00\u6761\u547d\u603b\u8d28\u91cf 1000\n\tpublic const string TwoTon = \"two_ton\";             // \u4e00\u6761\u547d\u603b\u8d28\u91cf 2000\n\tpublic const string FiveTon = \"five_ton\";           // \u4e00\u6761\u547d\u603b\u8d28\u91cf 5000\n\tpublic const string FirstTool = \"first_tool\";       // \u9996\u6b21\u4f7f\u7528\u9053\u5177\n\tpublic const string FullBag = \"full_bag\";           // \u4e24\u683c\u80cc\u5305\u540c\u65f6\u6709\u8d27\n\tpublic const string PowerHoarder = \"power_hoarder\"; // \u7d2f\u8ba1\u6361 5 \u4e2a\u9053\u5177\u5165\u5305\n\tpublic const string FirstFeed = \"first_feed\";       // \u9996\u6b21\u5582\u98df\u89e6\u53d1\u9053\u5177\n\tpublic const string SpikeOp = \"spike_op\";           // \u9996\u6b21\u653e\u51fa\u5c16\u523a\u5206\u8eab\n\tpublic const string OctoSplit = \"octo_split\";       // \u540c\u65f6\u62e5\u6709 8 \u9897\u8eab\u4f53\n\tpublic const string Untouchable = \"untouchable\";    // \u4e00\u6761\u547d\u5b58\u6d3b 5 \u5206\u949f\n\tpublic const string FirstDeath = \"first_death\";     // \u7b2c\u4e00\u6b21\u88ab\u5403\n\tpublic const string FirstWin = \"first_win\";         // \u9996\u6b21\u83b7\u80dc\n\n\tconst float BigPreyMass = 800f;   // \u5355\u53e3\u541e\u5165\u8d28\u91cf\u95e8\u69db\uff08\u8fd4\u8fd8\u524d\u7684\u53e3\u5f84\uff0c\u5bf9\u5e94 ~1000 \u8d28\u91cf\u7684\u7403\uff09\n\n\t/// <summary> \u4e00\u6761\u547d\u5185\u7684\u8d28\u91cf\u91cc\u7a0b\u7891\uff08\u4e0e MassSteps \u4e00\u4e00\u5bf9\u5e94\u89e3\u9501 HalfTon/OneTon/TwoTon/FiveTon\uff09 </summary>\n\tstatic readonly float[] MassSteps = { 500f, 1000f, 2000f, 5000f };\n\n\tstatic int _lifetimeKills;     // \u8fdb\u7a0b\u5185\u7d2f\u8ba1\u51fb\u6740\n\tstatic int _lifetimePickups;   // \u8fdb\u7a0b\u5185\u7d2f\u8ba1\u9053\u5177\u5165\u5305\n\tstatic long _lastKiller;       // \u6700\u8fd1\u5403\u6389\u672c\u673a\u7684\u73a9\u5bb6\uff08\u590d\u4ec7\u540d\u5355\uff09\n\n\t/// <summary> \u672c\u673a\u51fb\u6740\uff08\u6bcf\u6b21\u5403\u7403\u90fd\u8c03\uff0c\u5f15\u64ce\u5bf9\u5df2\u89e3\u9501\u9759\u9ed8\u53bb\u91cd\uff09\uff1b\n\t/// combo = \u672c\u5c40\u8fde\u6740\u6570\uff08GameSfx 10s \u7a97\u53e3\uff09\uff0cmassGained = \u672c\u53e3\u541e\u5165\u8d28\u91cf </summary>\n\tpublic static void LocalKill( int combo, float massGained, long eatenSteamId )\n\t{\n\t\tGameXp.OnLocalKill();   // \u7ecf\u9a8c\u8bb0\u8d26\uff1a\u672c\u5c40\u51fb\u6740\uff08v0.7.8.19\uff09\n\n\t\tUnlock( FirstBlood );\n\t\tif ( combo >= 2 ) Unlock( DoubleKill );\n\t\tif ( combo >= 3 ) Unlock( TripleKill );\n\t\tif ( combo >= 4 ) Unlock( Rampage );\n\t\tif ( combo >= 5 ) Unlock( Godlike );\n\n\t\tif ( massGained >= BigPreyMass ) Unlock( BigPrey );\n\t\tif ( eatenSteamId != 0 && eatenSteamId == _lastKiller ) Unlock( Revenge );\n\n\t\t_lifetimeKills++;\n\t\tif ( _lifetimeKills >= 10 ) Unlock( Hunter10 );\n\t\tif ( _lifetimeKills >= 50 ) Unlock( Hunter50 );\n\t}\n\n\t/// <summary> \u672c\u673a\u88ab\u5403\uff1a\u53c2\u4e0e\u5956 + \u8bb0\u4ec7\uff08\u4e4b\u540e\u5403\u6389\u4ed6\u89e3\u9501 Revenge\uff09 </summary>\n\tpublic static void LocalDeath( long killerSteamId )\n\t{\n\t\t_lastKiller = killerSteamId;\n\t\tUnlock( FirstDeath );\n\t}\n\n\t/// <summary>\n\t/// \u6bcf\u5e27\u4f53\u68c0\uff08GameHud.OnUpdate \u8c03\uff0c\u5168\u7aef\uff09\uff1a\u4e00\u6761\u547d\u8d28\u91cf\u91cc\u7a0b\u7891 / 8 \u9897\u8eab\u4f53 / \u5b58\u6d3b 5 \u5206\u949f / \u80cc\u5305\u53cc\u6ee1\u3002\n\t/// \u95e8\u69db\u5224\u5b9a\u6bcf\u5e27\u91cd\u590d\u89e6\u53d1\u7531\u5f15\u64ce\u53bb\u91cd\uff0c\u65e0\u9700\u672c\u5730\u72b6\u6001\u3002\n\t/// </summary>\n\tpublic static void MatchTick( CircleroyaleGame game )\n\t{\n\t\tif ( !game.IsMatchStarted || MatchState.MatchOver ) return;\n\n\t\tvar local = game.LocalBall;\n\t\tif ( !local.IsValid() || !local.Alive ) return;\n\n\t\tvar mass = game.TotalMassFor( local );\n\t\tif ( mass >= MassSteps[0] ) Unlock( HalfTon );\n\t\tif ( mass >= MassSteps[1] ) Unlock( OneTon );\n\t\tif ( mass >= MassSteps[2] ) Unlock( TwoTon );\n\t\tif ( mass >= MassSteps[3] ) Unlock( FiveTon );\n\n\t\tint pieces = 0;\n\t\tforeach ( var c in game.Cells )\n\t\t{\n\t\t\tif ( c.PieceKind == CellPiece.Kind.SplitPiece && c.OwnerSteamId == local.OwnerSteamId ) pieces++;\n\t\t}\n\t\tif ( pieces >= 8 ) Unlock( OctoSplit );\n\n\t\tif ( local.LifeSeconds > 300f ) Unlock( Untouchable );\n\n\t\t// \u80cc\u5305\u53cc\u6ee1\uff08PowerA/B \u662f [Sync]\uff0c255=\u7a7a\uff09\u2014\u2014\u540c\u6b65\u5230\u4f4d\u624d\u89e6\u53d1\uff0c\u65e0\u65f6\u5e8f\u7ade\u6001\n\t\tif ( local.PowerA != 255 && local.PowerB != 255 ) Unlock( FullBag );\n\t}\n\n\t/// <summary> \u672c\u673a\u6309 Q/E \u91ca\u653e\u9053\u5177\uff08UsePowerSlot \u6a2a\u5e45\u5904\u8c03\uff09\uff1a\u9996\u6b21\u7528\u9053\u5177 / \u9996\u6b21\u653e\u5c16\u523a </summary>\n\tpublic static void PowerUsed( byte kind )\n\t{\n\t\tUnlock( FirstTool );\n\t\tif ( kind == (byte)PowerUpManager.Kind.Spike ) Unlock( SpikeOp );\n\t}\n\n\t/// <summary> \u672c\u673a\u6361\u5230\u9053\u5177\u5165\u5305\uff08host \u672c\u673a\u62fe\u53d6\u4e0e\u5ba2\u6237\u7aef\u5e7f\u64ad\u843d\u5730\u4e24\u6761\u8def\u90fd\u4f1a\u8c03\uff09\uff1a\u7d2f\u8ba1 5 \u4e2a </summary>\n\tpublic static void PowerStored()\n\t{\n\t\tif ( ++_lifetimePickups >= 5 ) Unlock( PowerHoarder );\n\t}\n\n\t/// <summary> \u5582\u98df\u89e6\u53d1\u9053\u5177\uff08host \u7684 Feed \u5224\u5b9a + \u5ba2\u6237\u7aef PowerBanner \u843d\u5730\uff0c\u4e24\u8def\u90fd\u8fc7 IsMine\uff09 </summary>\n\tpublic static void FeedTriggered( long feederSteamId )\n\t{\n\t\tif ( GameSfx.IsMine( feederSteamId ) )\n\t\t\tUnlock( FirstFeed );\n\t}\n\n\t/// <summary> \u5c16\u523a\u5206\u8eab\u70b8\u6389\u6bd4\u4e3b\u4eba\u66f4\u5927\u7684\u7403\uff08host \u5224\u5b9a\uff1bowner=\u5c16\u523a\u4e3b\u4eba\uff0c\u5e7f\u64ad\u5230\u5404\u7aef\u540e\u672c\u673a\u5224\u5b9a\uff09 </summary>\n\tpublic static void SpikeMinionBlast( long ownerSteamId )\n\t{\n\t\tif ( GameSfx.IsMine( ownerSteamId ) )\n\t\t\tUnlock( SpikeBlast );\n\t\tif ( Networking.IsActive )\n\t\t\tNetworkManager.SpikeReward( ownerSteamId );\n\t}\n\n\t/// <summary> \u5c16\u523a\u5956\u52b1\u5e7f\u64ad\u843d\u5730\uff08\u5ba2\u6237\u7aef\uff1bhost \u5df2\u5728 SpikeMinionBlast \u5224\u8fc7\uff0c\u6321\u56de\u58f0\uff09 </summary>\n\tpublic static void OnSpikeRewardRemote( long ownerSteamId )\n\t{\n\t\tif ( NetworkManager.IsAuthority ) return;\n\t\tif ( GameSfx.IsMine( ownerSteamId ) )\n\t\t\tUnlock( SpikeBlast );\n\t}\n\n\t/// <summary> \u6bd4\u8d5b\u7ed3\u7b97\uff08\u5168\u7aef\u5404\u81ea\u5224\u5b9a\uff09\uff1a\u56e2\u961f\u8d5b=\u672c\u961f\u603b\u8d28\u91cf\u7b2c\u4e00\uff08\u5e76\u5217\u4e5f\u7b97\uff09\uff1b\u666e\u901a\u8d5b=\u4e2a\u4eba\u7b2c\u4e00\u540d\u3002\n\t/// \u8fd4\u56de\u662f\u5426\u83b7\u80dc\uff08GameXp \u7ed3\u7b97\u7ecf\u9a8c\u7528\uff09 </summary>\n\tpublic static bool Settlement( ScoreWire[] standings, long localSteamId )\n\t{\n\t\tif ( standings is null || standings.Length == 0 ) return false;\n\n\t\tbool won = false;\n\n\t\tif ( MatchState.IsTeam )\n\t\t{\n\t\t\tvar local = CircleroyaleGame.Current?.LocalBall;\n\t\t\tif ( local.IsValid() && local.TeamIndex >= 0 )\n\t\t\t{\n\t\t\t\tvar totals = new Dictionary<int, float>();\n\t\t\t\tforeach ( var row in standings )\n\t\t\t\t{\n\t\t\t\t\tvar t = totals.TryGetValue( row.Team, out var m ) ? m : 0f;\n\t\t\t\t\ttotals[row.Team] = t + row.Mass;\n\t\t\t\t}\n\n\t\t\t\tvar mine = totals.GetValueOrDefault( local.TeamIndex );\n\t\t\t\tvar best = -1f;\n\t\t\t\tforeach ( var kv in totals )\n\t\t\t\t{\n\t\t\t\t\tif ( kv.Value > best ) best = kv.Value;\n\t\t\t\t}\n\n\t\t\t\tif ( mine >= best )\n\t\t\t\t{\n\t\t\t\t\tUnlock( TeamWinner );\n\t\t\t\t\twon = true;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\telse if ( standings[0].SteamId == localSteamId )\n\t\t{\n\t\t\tUnlock( Champion );\n\t\t\twon = true;\n\t\t}\n\n\t\tif ( won ) Unlock( FirstWin );\n\t\treturn won;\n\t}\n\n\tstatic void Unlock( string ident ) => Sandbox.Services.Achievements.Unlock( ident );\n}"
        },
        {
            "Ident": "slfy.circleroyale",
            "Path": "Game/ScoreUploader.cs",
            "FileName": "ScoreUploader.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 380090,
            "IsPrivate": false,
            "Code": "using System;\nusing System.Collections.Generic;\nusing System.Threading.Tasks;\n\n/// <summary>\n/// \u6210\u7ee9\u4e0a\u4f20\u4e0e\u5168\u670d\u699c\u8bfb\u53d6\uff08M5\uff09\u3002\u4e91\u7aef\u8d70 Sandbox.Services\uff1a\n/// \u4e0a\u4f20 = Stats.SetValue(\"cr_match_mass\", \u672c\u5c40\u603b\u8d28\u91cf) + Flush\uff1b\n/// \u8bfb\u53d6 = Leaderboards.GetFromStat \u805a\u5408 Max\uff08\u5168\u670d\u6700\u9ad8\u5355\u5c40\uff09\u3002\n/// \u26a0\ufe0f \u672a\u53d1\u5e03\u7684\u672c\u5730\u5305\u540e\u7aef\u6ca1\u6709\u5305\u8eab\u4efd\uff0cRefresh \u5185\u90e8\u541e 404\uff08Entries \u4fdd\u6301\u7a7a\uff09\u2014\u2014\n/// \u5c55\u793a\u5c42\u636e\u6b64\u81ea\u52a8\u964d\u7ea7\u5230 LocalBoard \u7684\u672c\u5730\u6301\u4e45\u5316\u699c\u3002\n/// </summary>\npublic static class ScoreUploader\n{\n\t/// <summary> \u6392\u884c\u699c\u7edf\u8ba1\u540d\uff08\u805a\u5408 Max = \u5355\u5c40\u6700\u9ad8\u603b\u8d28\u91cf\uff09 </summary>\n\tpublic const string StatName = \"cr_match_mass\";\n\n\t/// <summary> \u672c\u673a\u6210\u7ee9\u4e0a\u4e91\uff08\u6309\u767b\u5f55\u7528\u6237\u8bb0\u8d26\uff1b\u62ff\u4e0d\u5230\u672c\u673a\u7403\u5c31\u9759\u9ed8\u8df3\u8fc7\uff09 </summary>\n\tpublic static void SubmitOwn( CircleroyaleGame game )\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar local = game?.LocalBall;\n\t\t\tif ( !local.IsValid() ) return;\n\n\t\t\tvar mass = game.TotalMassFor( local );\n\t\t\tSandbox.Services.Stats.SetValue( StatName, mass );\n\t\t\tSandbox.Services.Stats.Flush();\n\t\t\tGameLog.Info( $\"[score] uploaded {mass:0} ({StatName})\" );\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tGameLog.Info( $\"[score] upload failed: {e.Message}\" );\n\t\t}\n\t}\n\n\t/// <summary> \u62c9\u5168\u670d\u699c Top10\uff08\u5f02\u6b65\uff1b\u7a7a/\u5931\u8d25\u56de\u8c03 null\u2014\u2014\u5c55\u793a\u5c42\u964d\u7ea7\u672c\u5730\u699c\uff09 </summary>\n\tpublic static async void FetchGlobal( Action<List<( string Name, float Mass )>> done )\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar board = Sandbox.Services.Leaderboards.GetFromStat( StatName );\n\t\t\tboard.SetAggregationMax();\n\t\t\tboard.SetSortDescending();\n\t\t\tboard.FilterByNone();\n\t\t\tboard.MaxEntries = 10;\n\t\t\tawait board.Refresh();\n\n\t\t\tvar list = new List<( string Name, float Mass )>();\n\t\t\tforeach ( var e in board.Entries )\n\t\t\t\tlist.Add( ( e.DisplayName, (float)e.Value ) );\n\n\t\t\tdone( list.Count > 0 ? list : null );\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tGameLog.Info( $\"[score] global board fetch failed: {e.Message}\" );\n\t\t\tdone( null );\n\t\t}\n\t}\n}\n"
        },
        {
            "Ident": "slfy.circleroyale",
            "Path": "Player/Ball.cs",
            "FileName": "Ball.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 380090,
            "IsPrivate": false,
            "Code": "using System;\n\n/// <summary>\n/// \u7403\uff08\u771f\u4eba/bot \u5171\u7528\uff09\uff1a**owner \u672c\u5730\u6a21\u62df\u79fb\u52a8**\uff08\u771f\u4eba=\u952e\u76d8\u8f93\u5165\uff0cbot=host \u7684 BotBrain\uff09\uff0c\n/// \u6743\u5a01\u6570\u503c\uff08Mass/IsBot/ColorIndex/PlayerName/OwnerSteamId\uff09\u7531 host \u7ecf [Sync(FromHost)] \u4e0b\u53d1\uff0c\n/// \u4f4d\u7f6e\u968f\u7f51\u7edc transform \u81ea\u52a8\u540c\u6b65\uff08owner \u2192 \u5176\u4ed6\u7aef\uff09\u3002\n/// \u89c6\u89c9\uff1a\u7403\u4f53/\u8868\u60c5/\u5934\u50cf\u5168\u7531 NeonRenderer \u6279\u91cf\u7ed8\u5236\uff08\u8868\u60c5\u6309 Mood \u9009 face_N \u6279\u6b21\uff1b\n/// \u771f\u4eba Steam \u5934\u50cf\u5404\u7aef\u6309 OwnerSteamId \u81ea\u53d6\u540e\u7ecf NeonRenderer.SetAvatar \u767b\u8bb0\uff09\u3002\n/// </summary>\npublic sealed class Ball : Component\n{\n\t[Sync( SyncFlags.FromHost )] public float Mass { get; set; } = GameConfig.StartMass;\n\n\t[Sync( SyncFlags.FromHost )] public bool IsBot { get; set; }\n\n\t[Sync( SyncFlags.FromHost )] public int ColorIndex { get; set; }\n\n\t[Sync( SyncFlags.FromHost )] public string PlayerName { get; set; } = \"Player\";\n\n\t/// <summary> \u62e5\u6709\u8005 SteamId\uff08bot \u4e3a 0\uff09\uff1bhost \u5199\u5165\uff0c\u5404\u7aef\u636e\u6b64\u52a0\u8f7d\u5bf9\u5e94\u5934\u50cf </summary>\n\t[Sync( SyncFlags.FromHost )] public long OwnerSteamId { get; set; }\n\n\t/// <summary> \u961f\u4f0d\u53f7\uff08\u56e2\u961f\u8d5b 0..N-1\uff0c\u666e\u901a\u8d5b -1\uff09\uff1bhost \u5f00\u5c40\u8f6e\u8f6c\u5206\u914d\uff0c\u91cd\u751f\u4e0d\u53d8 </summary>\n\t[Sync( SyncFlags.FromHost )] public int TeamIndex { get; set; } = -1;\n\n\t/// <summary> \u86f0\u4f0f\u5907\u7528\u7403\uff08v0.7.8.25\uff09\uff1a\u623f\u95f4\u9884\u7559/\u672c\u5c40\u591a\u4f59\u7684 bot\u3002Alive=false \u4e0e\"\u6b7b\u4ea1\"\u65e0\u6cd5\u533a\u5206\uff0c\n\t/// TickBots \u66fe\u628a 16 \u9897\u86f0\u4f0f\u7403\u5f53\u5c38\u4f53\u539f\u5730\u590d\u6d3b\u6210\u65e0\u961f FFA bot \u704c\u8fdb T1\u2014\u2014\u5fc5\u987b\u6709\u72ec\u7acb\u6807\u8bb0 </summary>\n\t[Sync( SyncFlags.FromHost )] public bool Dormant { get; set; }\n\n\t/// <summary> \u4e3b\u4eba\u8d26\u53f7\u7b49\u7ea7\uff08v0.7.8.19\uff09\uff1a\u5404\u7aef\u7b97\u597d\u5199\u8fdb**\u81ea\u5df1\u7684\u7403**\uff08\u666e\u901a [Sync]=owner \u53ef\u5199\uff09\uff0c\n\t/// \u5168\u573a\u540d\u724c\u53ef\u89c1\uff1bbot \u6052 0 \u4e0d\u663e\u793a </summary>\n\t[Sync] public int AccountLevel { get; set; }\n\n\t// ---- \u9053\u5177 buff\uff08v0.7.3.0\uff09\uff1ahost \u6743\u5a01\u5199\u5165\uff0c\u5404\u7aef\u5c55\u793a/\u6548\u679c\u5171\u7528\u3002\n\t// Kind \u6b8b\u7559\u65e7\u503c\u6ca1\u5173\u7cfb\uff0cHasBuff \u53ea\u770b _sinceBuff < BuffDuration\uff1b\n\t// serial \u81ea\u589e\u4fdd\u8bc1\"\u540c\u7c7b buff \u518d\u6361\u4e00\u6b21\"\u4e5f\u89e6\u53d1 [Change]\uff08\u5ba2\u6237\u7aef\u91cd\u7f6e\u672c\u5730\u8ba1\u65f6\uff09 ----\n\n\t[Sync( SyncFlags.FromHost )] public byte BuffKind { get; set; } = 255;\n\n\t[Sync( SyncFlags.FromHost )] public float BuffDuration { get; set; }\n\n\t[Sync( SyncFlags.FromHost ), Change( nameof( OnBuffSynced ) )] public byte BuffSerial { get; set; }\n\n\t/// <summary> bot \u8868\u60c5\u6863\uff08v0.7.8.36 \u50cf\u7d20\u98ce\uff09\uff1a0=\u5f00\u5fc3 1=\u5174\u594b(\u8ffd\u730e) 2=\u60ca\u6050(\u9003\u8dd1) 3=\u7729\u6655 4=\u778c\u7761 5=\u5f97\u610f(\u738b\u724c\u8ffd\u730e)\u3002\n\t/// host \u7684 BotBrain \u5199\u5165\uff08FromHost\uff09\uff0cNeonRenderer \u6bcf\u5e27\u6309 Mood \u73b0\u753b\u8868\u60c5\u8138\uff1b\u771f\u4eba\u7403\u4e0d\u53c2\u4e0e </summary>\n\t[Sync( SyncFlags.FromHost )] public byte Mood { get; set; }\n\n\tTimeSince _sinceBuff = 999f;\n\n\t// ---- \u9053\u5177\u80cc\u5305\uff08v0.7.5.0 \u4e3b\u52a8\u4f7f\u7528\uff09\uff1a\u62fe\u53d6\u5b58\u5165 Q/E \u4e24\u683c\uff0c\u6309\u952e\u91ca\u653e\u6548\u679c\u3002\n\t// 255 = \u7a7a\u69fd\uff1b\u6b7b\u4ea1/\u91cd\u751f\u6e05\u7a7a\uff08\u673a\u4f1a\u4e0d\u8de8\u547d\u643a\u5e26\uff09 ----\n\n\t[Sync( SyncFlags.FromHost )] public byte PowerA { get; set; } = 255;\n\n\t[Sync( SyncFlags.FromHost )] public byte PowerB { get; set; } = 255;\n\n\t/// <summary> \u5404\u7aef\u6536\u5230 buff \u53d8\u5316\uff08\u542b host \u672c\u5730\u5199\u5165\uff09\u5373\u91cd\u7f6e\u672c\u5730\u8ba1\u65f6\u2014\u2014\u5ba2\u6237\u7aef\u636e\u6b64\u5012\u7b97\u5269\u4f59\u65f6\u95f4 </summary>\n\tvoid OnBuffSynced() => _sinceBuff = 0;\n\n\t/// <summary> host\uff1a\u7ed9\u8fd9\u9897\u7403\u4e0a buff\uff08\u8d28\u91cf\u7f50\u7b49\u5373\u65f6\u6548\u679c\u4e0d\u8d70\u8fd9\u91cc\uff09 </summary>\n\tpublic void ApplyBuff( byte kind, float duration )\n\t{\n\t\tBuffKind = kind;\n\t\tBuffDuration = duration;\n\t\tBuffSerial++;\n\t\t_sinceBuff = 0;\n\t}\n\n\t/// <summary> buff \u662f\u5426\u751f\u6548\u4e2d\uff08host \u6743\u5a01\u5224\u5b9a\u4e0e\u5ba2\u6237\u7aef\u5c55\u793a\u540c\u4e00\u516c\u5f0f\uff0c\u5ba2\u6237\u7aef\u81ea\u7136\u8fc7\u671f\u65e0\u9700\u901a\u77e5\uff09 </summary>\n\tpublic bool HasBuff( PowerUpManager.Kind kind ) =>\n\t\tBuffKind == (byte)kind && _sinceBuff < BuffDuration;\n\n\t/// <summary> \u5f53\u524d buff \u5269\u4f59\u79d2\u6570\uff08HUD \u5012\u8ba1\u65f6\u7528\uff1b\u65e0 buff \u4e3a 0\uff09 </summary>\n\tpublic float BuffLeft => BuffKind == 255 ? 0f : MathF.Max( 0f, BuffDuration - _sinceBuff );\n\n\t/// <summary> \u80cc\u5305\u7b2c slot \u683c\uff080=Q \u69fd 1=E \u69fd\uff09\u7684\u9053\u5177\u79cd\u7c7b\uff1b255=\u7a7a </summary>\n\tpublic byte PowerSlot( int slot ) => slot == 0 ? PowerA : PowerB;\n\n\t/// <summary> \u62fe\u53d6\u5165\u5305\uff08v0.7.8.30 \u8d77**\u53ea\u8fdb E \u69fd**\u2014\u2014Q \u6539\u4e3a\u968f\u65f6\u95f4\u5145\u80fd\u7684\u6280\u80fd\u4f4d\uff0c\u4e0d\u518d\u63a5\u6536\u62fe\u53d6\uff09\uff1b\n\t/// E \u6ee1\u8fd4\u56de -1\uff08\u9053\u5177\u7559\u5728\u573a\u4e0a\uff09 </summary>\n\tpublic int StorePower( byte kind )\n\t{\n\t\tif ( PowerB == 255 ) { PowerB = kind; return 1; }\n\t\treturn -1;\n\t}\n\n\t/// <summary> host\uff1a\u4f7f\u7528\u80cc\u5305\u9053\u5177\u2014\u2014\u6e05\u69fd\u5e76\u89e6\u53d1\u6548\u679c\uff08PowerUpManager.ApplyEffect\uff09 </summary>\n\tpublic void UsePower( int slot )\n\t{\n\t\tvar kind = PowerSlot( slot );\n\t\tif ( kind == 255 ) return;\n\n\t\tif ( slot == 0 ) PowerA = 255;\n\t\telse PowerB = 255;\n\n\t\tPowerUpManager.ApplyEffect( this, kind );\n\t}\n\n\t/// <summary> \u6309\u952e\u91ca\u653e\u80cc\u5305\u69fd\uff08pressed \u4e3a true \u65f6\u5904\u7406\uff09\uff1a\u672c\u5730\u5148\u64ad\u6fc0\u6d3b\u6a2a\u5e45+\u97f3\u6548+\u8f7b\u9707\uff0c\n\t/// \u518d\u8d70\u6743\u5a01/RPC \u8def\u5f84 </summary>\n\tvoid UsePowerSlot( int slot, bool pressed )\n\t{\n\t\tif ( !pressed ) return;\n\n\t\tvar kind = PowerSlot( slot );\n\t\tif ( kind == 255 ) return;   // \u7a7a\u69fd\uff1a\u6309\u952e\u65e0\u6548\uff08\u4e0d\u64ad\u6a2a\u5e45\u4e0d\u53d1\u8bf7\u6c42\uff09\n\n\t\tCircleroyaleGame.Current?.Hud?.AddBanner( GameHud.ActivationText( kind ), GameHud.KindColor( kind ) );\n\t\tGameSfx.Pickup();\n\t\tCircleroyaleGame.Current?.Camera?.Shake( 5f );   // \u653e\u6280\u80fd\u8f7b\u9707\uff08\u51fb\u6740\u9707\u66f4\u731b\uff0c\u968f combo \u52a0\u7801\uff09\n\t\tGameAchievements.PowerUsed( kind );   // \u6210\u5c31\uff1a\u9996\u6b21\u7528\u9053\u5177 / \u9996\u6b21\u653e\u5c16\u523a\uff08v0.7.8.14\uff09\n\n\t\tif ( NetworkManager.IsAuthority )\n\t\t\tUsePower( slot );\n\t\telse\n\t\t\tRequestUsePower( slot );\n\t}\n\n\t/// <summary>\n\t/// \u662f\u5426\u5b58\u6d3b\u3002**\u7403\u5bf9\u8c61\u6c38\u4e0d\u9500\u6bc1**\uff1a\u6b7b\u4ea1 = Alive=false\uff08\u540c\u6b65\u5b57\u6bb5\uff0c\u6e32\u67d3/AI/\u5224\u5b9a\u5168\u90e8\u8df3\u8fc7\uff09\uff0c\n\t/// \u91cd\u751f = host \u7f6e\u56de true\u3002\u7ed5\u5f00\"\u8fdb\u884c\u4e2d NetworkSpawn \u4e0d\u590d\u5236\u5230\u5df2\u8fde\u63a5\u5ba2\u6237\u7aef\"\u7684\u5f15\u64ce\u884c\u4e3a\uff08\u5b9e\u6d4b\u5927\u5751\uff09\u3002\n\t/// </summary>\n\t[Sync( SyncFlags.FromHost )]\n\tpublic bool Alive { get; set; } = true;\n\n\tpublic float Radius => GameConfig.StartRadius * MathF.Sqrt( Mass / GameConfig.StartMass );\n\n\t/// <summary> \u5f53\u524d\u901f\u5ea6\uff08v0.7.8.32\uff1abot \u62e6\u622a\u9884\u5224\u6309\u5b83\u6253\u63d0\u524d\u91cf\uff1b\u53ea\u8bfb\uff0cBallInput \u5185\u90e8\u7ef4\u62a4\uff09 </summary>\n\tpublic Vector3 Velocity => _velocity;\n\n\t/// <summary> \u672c\u6761\u547d\u5df2\u5b58\u6d3b\u79d2\u6570\uff08\u6210\u5c31\"\u5b58\u6d3b 5 \u5206\u949f\"\u7528\uff1b\u91cd\u751f\u65f6 _sinceSpawn \u5f52\u96f6\uff09 </summary>\n\tpublic float LifeSeconds => _sinceSpawn;\n\n\t/// <summary> \u672c\u7403\u7684\u9713\u8679\u8272\uff08\u7531 ColorIndex \u4ece\u8c03\u8272\u677f\u53d6\uff09 </summary>\n\tpublic Color NeonColor => GameConfig.Palette[Math.Clamp( ColorIndex, 0, GameConfig.Palette.Length - 1 )];\n\n\t/// <summary> \u79fb\u901f\uff1a\u8d28\u91cf\u66f2\u7ebf \u00d7 \u52a0\u901f buff\uff08\u26a1 \u65f6 \u00d71.4\uff1bSpeed \u5c5e\u6027\u5404\u7aef\u540c\u7b97\uff0cowner \u6a21\u62df\u4e0e host \u6821\u9a8c\u4e00\u81f4\uff09 </summary>\n\tpublic float Speed\n\t{\n\t\tget\n\t\t{\n\t\t\tvar s = MathF.Max( GameConfig.MinSpeed,\n\t\t\t\tGameConfig.BaseSpeed * MathF.Pow( GameConfig.StartMass / Mass, GameConfig.SpeedCurve ) );\n\t\t\tif ( HasBuff( PowerUpManager.Kind.Speed ) ) s *= GameConfig.PowerUpSpeedBoost;\n\t\t\t// \u654c\u8425\u5927\u672c\u8425\u51cf\u901f\uff08M7.2 \u5b89\u5168\u5c4b\uff09\uff1bInHostileHq \u5bf9\u65e0\u961f/\u975e\u9886\u571f\u6052 false\n\t\t\tif ( TerritoryManager.InHostileHq( TeamIndex, WorldPosition ) ) s *= GameConfig.TerritoryHqSlowFactor;\n\t\t\treturn s;\n\t\t}\n\t}\n\n\t/// <summary> \u662f\u5426\u51fa\u751f\u4fdd\u62a4\u4e2d\uff08\u865a\u73af\u62a4\u76fe\u8868\u73b0\uff0c\u53cc\u5411\u514d\u6218\uff09 </summary>\n\tpublic bool IsProtected => _sinceSpawn < GameConfig.SpawnProtectSeconds;\n\n\t/// <summary> \u9886\u571f\u804c\u4e1a\uff08M7.4\uff0chost \u968f\u673a\u5206\u914d\u968f\u590d\u6d3b\u91cd\u63b7\uff1b255=\u65e0\u30020 \u6307\u6325\u5b98 1 \u5de5\u5175 2 \u62a4\u536b 3 \u5766\u514b\uff09 </summary>\n\t[Sync( SyncFlags.FromHost )]\n\tpublic byte TerritoryClass { get; set; } = 255;\n\n\tTimeSince _sinceClassCast = 999f;   // \u672c\u5730\u51b7\u5374\u4f30\u7b97\uff08host \u6743\u5a01\u51b7\u5374\u5728 ClassSkillManager\uff0c\u672a\u597d\u65f6\u9759\u9ed8\u62d2\u7edd\uff09\n\n\t/// <summary> \u672c\u5730\u4f30\u7b97\u7684\u804c\u4e1a\u51b7\u5374\u5269\u4f59\u79d2\uff08HUD Q \u94ae\u5012 sweep \u7528\uff09 </summary>\n\tpublic float ClassCooldownRemaining =>\n\t\tTerritoryClass > 3 ? 0f : MathF.Max( 0f, ClassSkillManager.CooldownOf( TerritoryClass ) - _sinceClassCast );\n\n\t/// <summary> Q \u65bd\u653e\u804c\u4e1a\u6280\u80fd\uff08M7.4 \u9886\u571f\uff09\uff1a\u672c\u5730\u5373\u65f6\u53cd\u9988+\u51b7\u5374\u4f30\u7b97\u8d77\u8868\uff1bhost \u76f4\u8c03 / \u5ba2\u6237\u7aef\u53d1 RPC </summary>\n\tvoid TryCastClassSkill()\n\t{\n\t\tif ( !Alive || TerritoryClass > 3 ) return;\n\t\tif ( ClassCooldownRemaining > 0f ) return;\n\n\t\tCircleroyaleGame.Current?.Hud?.AddBanner( ClassSkillManager.NameOf( TerritoryClass ) + \"!\", new Color( 0.98f, 0.83f, 0.35f ) );\n\t\tGameSfx.Pickup();\n\t\tCircleroyaleGame.Current?.Camera?.Shake( 5f );\n\t\t_sinceClassCast = 0;   // \u672c\u5730\u4f30\u7b97\u8d77\u8868\n\n\t\tif ( NetworkManager.IsAuthority )\n\t\t\tClassSkillManager.Cast( this );\n\t\telse\n\t\t\tNetworkManager.RequestClassSkill();\n\t}\n\n\t/// <summary> \u7403\u4f53\u80cc\u666f\u586b\u5145\u8272\uff1a\u771f\u4eba=\u5934\u50cf\u4e3b\u8272\u53d6\u6837\uff0cbot=\u9713\u8679\u914d\u8272 </summary>\n\tpublic Color BackgroundColor => _avatarColor ?? NeonColor;\n\n\tVector3 _velocity;\n\tTimeSince _sinceSpawn;\n\tTimeSince _sinceWallHit = 999f;    // \u649e\u5899\u8109\u51b2\u8282\u6d41\uff08\u8d34\u5899\u6ed1\u884c\u4e0d\u8fde\u7206\uff09\n\tColor? _avatarColor;\n\tVector2 _botDir;\n\tVector2 _lastAimDir = Vector2.Zero;     // \u5149\u6807\u7784\u51c6\u65b9\u5411\uff08\u5206\u88c2/\u5410\u5b62\u5b50\u7528\uff0cv0.7.0.0\uff09\n\n\t/// <summary> \u5f53\u524d\u7784\u51c6\u65b9\u5411\uff08\u672c\u673a\u7403\uff1a\u6e32\u67d3\u5916\u7f18\u7bad\u5934\u7528\uff1bbot \u6052 Zero \u4e0d\u753b\uff09 </summary>\n\tpublic Vector2 AimDir => _lastAimDir;\n\tTimeSince _sinceEject;                  // \u5ba2\u6237\u7aef\u6309\u4f4f\u8fde\u5410\u7684\u8282\u6d41\uff08host \u8fd8\u6709\u540c\u6b3e\u6821\u9a8c\uff09\n\tTimeSince _sinceAvatarRetry = 1f;   // \u9996\u5e27\u7acb\u5373\u5c1d\u8bd5\n\tint _avatarRetryCount;\n\tbool _avatarReady;\n\tbool _wasAlive = true;\n\n\t/// <summary> \u6b7b\u4ea1\u65f6\u523b\uff08host \u8bb0\u5f55\uff0c\u7528\u4e8e bot \u539f\u5730\u590d\u6d3b\u7684\u5ef6\u65f6\uff09 </summary>\n\tpublic TimeSince SinceDeath { get; private set; }\n\n\t/// <summary>\n\t/// host \u4fa7\u6807\u8bb0\uff1a\u8fd9\u9897\u7403\u662f\u4e3a\u8fdc\u7a0b\u8fde\u63a5\u751f\u6210\u7684\uff08\u4ec5\u6743\u5a01\u7aef\u6709\u610f\u4e49\uff0c\u4e0d\u8054\u7f51\uff09\u3002\n\t/// ScanBalls/SetLocalBall \u7528\u5b83\u628a\u8fdc\u7a0b\u7403\uff08\u542b\u7ade\u6001\u4e0b\u672a\u7f51\u7edc\u5316\u7684\u5e7d\u7075\u7403\uff09\u6392\u9664\u51fa\u672c\u673a\u7403\u5019\u9009\uff0c\n\t/// \u5426\u5219\u540c\u8d26\u53f7\u53cc\u5f00\u65f6\u5e7d\u7075\u7403\u4f1a\u9876\u6389 host \u81ea\u5df1\u7684\u7403\u3001\u76f8\u673a\u6765\u56de\u8df3\uff08\u5b9e\u6d4b\uff09\u3002\n\t/// </summary>\n\tpublic bool IsRemoteOwned { get; set; }\n\n\t/// <summary> host \u4fa7\u6807\u8bb0\u6b7b\u4ea1\uff08\u5bf9\u8c61\u4fdd\u7559\uff0c\u539f\u5730\u590d\u7528\uff09 </summary>\n\tpublic void MarkDead()\n\t{\n\t\tAlive = false;\n\t\tSinceDeath = 0;\n\t\t_velocity = Vector3.Zero;\n\t}\n\n\t/// <summary> \u91cd\u7f6e\u51fa\u751f\u4fdd\u62a4\u8ba1\u65f6\uff08host \u5728\u73a9\u5bb6\u771f\u6b63\u5165\u5c40\u65f6\u8c03\u7528\u2014\u2014pre-snapshot \u7403\u7684\n\t/// \u4fdd\u62a4\u671f\u5728\u63e1\u624b\u671f\u95f4\u5c31\u7a7a\u8dd1\u5b8c\u4e86\uff0c\u5b9e\u6d4b\u52a0\u5165 1.6s \u5373\u88ab\u5403\uff1bIsProtected \u6309\u5404\u7aef\u672c\u5730\u8ba1\u65f6\u5224\u5b9a\uff0c\n\t/// host \u91cd\u7f6e\u5373\u6062\u590d\u6743\u5a01\u5224\u5b9a\uff0c\u5ba2\u6237\u7aef\u81ea\u5df1\u7684\u8ba1\u65f6\u968f\u5feb\u7167\u5e94\u7528\u53e6\u7b97\uff09 </summary>\n\tpublic void RefreshSpawnProtection() => _sinceSpawn = 0;\n\n\t/// <summary> \u77ac\u79fb\uff08\u5206\u8eab\u664b\u5347\u4e3b\u7403\u65f6\u7528\uff09\uff1a\u4f4d\u7f6e\u5f52 owner \u6a21\u62df\uff0cowner \u7aef\u5fc5\u987b\u81ea\u5df1\u642c\u8fc7\u53bb\uff1b\n\t/// \u4e0d\u89e6\u53d1\u4fdd\u62a4\u671f/\u91cd\u64ad\u97f3\u6548\u2014\u2014\u8fd9\u662f\u8eab\u4efd\u5ef6\u7eed\uff0c\u4e0d\u662f\u91cd\u751f </summary>\n\tpublic void TeleportTo( Vector3 pos )\n\t{\n\t\tWorldPosition = new Vector3( pos.x, pos.y, 0f );\n\t\t_velocity = Vector3.Zero;\n\t}\n\n\t/// <summary> host \u4fa7\u521d\u59cb\u5316\uff08\u4ec5\u6743\u5a01\u7aef\u8c03\u7528\uff1b\u5ba2\u6237\u7aef\u5b57\u6bb5\u5168\u90e8\u8d70\u7f51\u7edc\u540c\u6b65\uff09\u3002\n\t/// teamIndex \u2265 0 \u65f6\u540c\u65f6\u5199\u5165\u961f\u4f0d\u53f7\uff1b\u56e2\u961f\u8d5b\u989c\u8272\u9501\u961f\u4f0d\u8272\uff0c\u666e\u901a\u8d5b\u968f\u673a\u9713\u8679\u8272 </summary>\n\tpublic void Init( bool isBot, int teamIndex = -1 )\n\t{\n\t\tIsBot = isBot;\n\t\tif ( teamIndex >= 0 ) TeamIndex = teamIndex;\n\n\t\tColorIndex = MatchState.IsTeam\n\t\t\t? ( ( TeamIndex % GameConfig.Palette.Length ) + GameConfig.Palette.Length ) % GameConfig.Palette.Length\n\t\t\t: (int)Game.Random.Float( 0f, GameConfig.Palette.Length - 0.001f );\n\n\t\t_sinceSpawn = 0;\n\t\t_velocity = Vector3.Zero;\n\n\t\t// \u9053\u5177 buff \u4e0e\u80cc\u5305\u968f\u91cd\u751f\u6e05\u6389\uff08\u673a\u4f1a\u4e0d\u8de8\u547d\u643a\u5e26\uff09\n\t\tBuffKind = 255;\n\t\tBuffDuration = 0f;\n\t\tPowerA = 255;\n\t\tPowerB = 255;\n\t}\n\n\t/// <summary> bot \u7531 BotBrain \u6bcf\u5e27\u5199\u5165\u7684\u79fb\u52a8\u65b9\u5411\uff08\u771f\u4eba\u8d70 BallInput\uff0c\u4e0d\u7528\u8fd9\u4e2a\uff09 </summary>\n\tpublic void SetBotDirection( Vector2 dir ) => _botDir = dir;\n\n\tprotected override void OnStart()\n\t{\n\t\tbase.OnStart();\n\n\t\t// \u6240\u6709\u673a\u5668\u7edf\u4e00\u4ece\u7f51\u7edc\u5bf9\u8c61\u7684 OnStart \u6ce8\u518c\uff08host \u751f\u6210 / \u5ba2\u6237\u7aef\u968f\u590d\u5236\u51fa\u73b0\uff09\n\t\tCircleroyaleGame.Current?.RegisterBall( this );\n\t\tif ( !IsProxy && !IsBot )\n\t\t\tCircleroyaleGame.Current?.SetLocalBall( this );\n\n\t\tTryLoadAvatar();\n\t}\n\n\t/// <summary>\n\t/// \u52a0\u8f7d\u5934\u50cf\uff08\u771f\u4eba\u6309 OwnerSteamId \u62c9 Steam \u5934\u50cf\uff1a\u4e0b\u8f7d\u6709\u5ef6\u8fdf\uff0c\u5931\u8d25\u6bcf\u79d2\u91cd\u8bd5\uff0c15 \u6b21\u540e\u8f6c\u5360\u4f4d\u56fe\uff1b\n\t/// \u62ff\u5230\u540e\u7ecf NeonRenderer.SetAvatar \u767b\u8bb0\u72ec\u7acb\u8d34\u56fe\u6279\u6b21\uff09\u3002bot \u8868\u60c5\u8138\u7531 NeonRenderer \u6309 Mood\n\t/// \u4ece face_0..5 \u73b0\u753b\uff0c\u4e0d\u5728\u8fd9\u91cc\u52a0\u8f7d\u3002\n\t/// </summary>\n\tvoid TryLoadAvatar()\n\t{\n\t\tif ( IsBot )\n\t\t{\n\t\t\t_avatarReady = true;\n\t\t\treturn;\n\t\t}\n\n\t\tif ( OwnerSteamId == 0 ) return;   // \u540c\u6b65\u672a\u5230\uff0c\u7b49\u4e0b\u4e00\u8f6e\u91cd\u8bd5\n\n\t\tvar avatar = Texture.LoadAvatar( OwnerSteamId, 128 );\n\t\tif ( avatar is null ) return;\n\n\t\tSetAvatarTexture( avatar );\n\t\t_avatarReady = true;\n\t}\n\n\t/// <summary> \u5934\u50cf\u8d34\u56fe\u767b\u8bb0\u8fdb NeonRenderer\uff08\u6e32\u67d3\u5668\u672a\u5c31\u7eea\u65f6\u7531\u5176\u9759\u6001\u8868\u5f85\u547d\uff0c\u4e0b\u4e00\u5e27\u751f\u6548\uff09 </summary>\n\tvoid SetAvatarTexture( Texture tex )\n\t{\n\t\tif ( tex is null )\n\t\t{\n\t\t\tLog.Warning( $\"avatar load failed: '{PlayerName}' steam={OwnerSteamId}\" );\n\t\t\treturn;\n\t\t}\n\n\t\tNeonRenderer.SetAvatar( OwnerSteamId, tex );\n\t\t_avatarColor = SampleAverageColor( tex );\n\t}\n\n\t/// <summary> \u5934\u50cf\u4e3b\u8272\uff1a\u5168\u56fe\u4e0d\u900f\u660e\u50cf\u7d20\u7684\u5747\u503c\uff08\u7528\u4f5c\u7403\u5e95\u586b\u5145\u8272\uff09 </summary>\n\tstatic Color? SampleAverageColor( Texture tex )\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar pixels = tex.GetPixels( 0 );\n\t\t\tif ( pixels is null || pixels.Length == 0 ) return null;\n\n\t\t\tfloat r = 0f, g = 0f, b = 0f;\n\t\t\tint n = 0;\n\t\t\tfor ( int i = 0; i < pixels.Length; i++ )\n\t\t\t{\n\t\t\t\tvar p = pixels[i];\n\t\t\t\tif ( p.a < 32 ) continue;\n\t\t\t\tr += p.r;\n\t\t\t\tg += p.g;\n\t\t\t\tb += p.b;\n\t\t\t\tn++;\n\t\t\t}\n\t\t\tif ( n == 0 ) return null;\n\n\t\t\treturn new Color( r / n / 255f, g / n / 255f, b / n / 255f );\n\t\t}\n\t\tcatch\n\t\t{\n\t\t\treturn null;\n\t\t}\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tbase.OnUpdate();\n\n\t\t// \u5934\u50cf\u91cd\u8bd5\uff1a\u6bcf\u79d2\u4e00\u6b21\uff0c15 \u6b21\u672a\u62ff\u5230\u8f6c\u5360\u4f4d\u56fe\uff08Steam \u5934\u50cf\u4e0b\u8f7d\u6162\u662f\u5e38\u6001\uff09\n\t\tif ( !_avatarReady && _sinceAvatarRetry > 1f )\n\t\t{\n\t\t\t_sinceAvatarRetry = 0;\n\t\t\tif ( _avatarRetryCount++ >= 15 )\n\t\t\t{\n\t\t\t\tSetAvatarTexture( Texture.Load( GameConfig.AvatarPlaceholderPath, false ) );\n\t\t\t\t_avatarReady = true;\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tTryLoadAvatar();\n\t\t\t}\n\t\t}\n\n\t\t// ---- \u5206\u88c2/\u5410\u5b62\u5b50\u8f93\u5165\uff08M4.11\uff1a\u6bcf\u5e27\u8def\u5f84\uff0c\u4ec5\u672c\u673a\u771f\u4eba\u3001\u6d3b\u7403\uff09----\n\t\t// \u6743\u5a01\u7aef\u76f4\u63a5\u6267\u884c\uff1b\u5ba2\u6237\u7aef\u53d1\u5b9e\u4f8b RPC\uff08[Rpc.Host]\uff0chost \u4fa7\u6821\u9a8c\u540e\u6267\u884c\uff0c\u5206\u8eab\u662f host \u6743\u5a01\u6570\u636e\uff09\u3002\n\t\t// \u97f3\u6548\u5728\u6309\u952e\u77ac\u95f4\u5373\u64ad\uff08\u672c\u5730\u5373\u65f6\u53cd\u9988\uff1bhost \u6821\u9a8c\u4e0d\u8fc7\u9876\u591a\u7a7a\u54cd\u4e00\u6b21\uff09\n\t\tif ( IsProxy || IsRemoteOwned || IsBot || !Alive ) return;\n\t\tif ( MatchState.MatchOver ) return;   // \u6bd4\u8d5b\u7ed3\u675f\uff1a\u5168\u573a\u51bb\u7ed3\uff0c\u8f93\u5165\u4e0d\u54cd\u5e94\n\n\t\t// \u505c\u5728\u7b49\u5f85\u9875\uff08P \u9000\u5c40/\u6389\u7ebf\u6062\u590d\uff09\u4e14\u4e0d\u5728\u5bf9\u5c40\u65f6\u505c\u6b62\u64cd\u63a7\uff1a\u7403\u7559\u5728\u539f\u5730\uff0c\u9632\u9694\u5c4f\u76f2\u5f00\u3002\n\t\t// \u26a0\ufe0f \u5fc5\u987b\u5e26 !IsMatchStarted\uff1a\u53ea\u8981\u8fd8\u5728\u5bf9\u5c40\uff08_gameStarted\uff09\u5c31\u7edd\u4e0d\u7981\u8f93\u5165\u2014\u2014\n\t\t// _lobbyWaiting \u82e5\u56e0\u5f02\u5e38\u6b8b\u7559\u4e3a true\uff0c\u4f1a\u628a\u5bf9\u5c40\u4e2d\u7684\u79fb\u52a8/\u5206\u88c2/\u5410\u5b62\u5b50/\u7784\u51c6\u5168\u9501\u6b7b\n\t\t// \uff08\u5b9e\u6d4b\uff1a\u4e0d\u80fd\u5206\u8eab/\u5410\u4e1d\u3001\u7784\u51c6\u7bad\u5934\u51bb\u5728\u65e7\u65b9\u5411\uff09\n\t\t{\n\t\t\tvar g = CircleroyaleGame.Current;\n\t\t\tif ( g is not null && g.IsClientWaiting && !g.IsMatchStarted )\n\t\t\t{\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\t// \u7784\u51c6\uff08v0.7.0.0 \u7528\u6237\u9700\u6c42\uff09\uff1a_lastAimDir \u4f9b\u6e32\u67d3\u7784\u51c6\u7ebf\u7528\uff1b\u52a8\u4f5c\u65b9\u5411\u7531 host \u6309\u8eab\u4f53\u9010\u4e2a\u7b97\n\t\tvar aimDir = BallInput.GetAimDir( Scene.Camera, WorldPosition );\n\t\tif ( aimDir.Length > 0.001f ) _lastAimDir = aimDir;\n\n\t\t// \u9f20\u6807\u952e\u5e76\u884c\uff08v0.7.4.1 \u7528\u6237\u9700\u6c42\uff09\uff1a\u53f3\u952e\u5206\u88c2\u3001\u5de6\u952e\u5410\u4e1d\uff08\"Attack1\"/\"Attack2\" \u4e3a\u5f15\u64ce\u5185\u5efa\u6309\u94ae\uff0c\n\t\t// \u5b98\u65b9\u9879\u76ee\u540c\u6b3e\u7528\u6cd5\uff09\uff1b\u7a7a\u683c/R \u4fdd\u7559\uff0c\u4e24\u5957\u952e\u4f4d\u5171\u5b58\u3002\n\t\t// v0.7.8.7 \u624b\u611f\uff1a\u4f20**\u5149\u6807\u4e16\u754c\u5750\u6807**\u800c\u975e\u65b9\u5411\u2014\u2014host \u5bf9\u6bcf\u4e2a\u8eab\u4f53\u5355\u72ec\u7b97\u6307\u5411\uff08\u5206\u8eab\u4e5f\u671d\u5149\u6807\uff09\n\t\tvar aimPoint = BallInput.GetAimPoint( Scene.Camera );\n\n\t\tif ( GameConfig.EnableSplit && ( Input.Pressed( \"Jump\" ) || Input.Pressed( \"Attack2\" ) ) )\n\t\t{\n\t\t\tif ( Mass >= GameConfig.SplitMinMass ) GameSfx.Split( WorldPosition );\n\n\t\t\tif ( NetworkManager.IsAuthority )\n\t\t\t\tCircleroyaleGame.Current?.DoSplit( this, aimPoint );\n\t\t\telse\n\t\t\t\tRequestSplit( aimPoint );\n\t\t}\n\n\t\tif ( GameConfig.EnableEject\n\t\t\t&& ( Input.Down( \"Reload\" ) || Input.Down( \"Attack1\" ) )\n\t\t\t&& _sinceEject > GameConfig.EjectCooldownSeconds )\n\t\t{\n\t\t\t_sinceEject = 0;\n\t\t\tif ( Mass >= 2f ) GameSfx.Eject( WorldPosition );   // \u81f3\u5c11\u80fd\u5410\u6700\u5c0f\u4e00\u9897\uff08\u4e0b\u9650 1 + \u6d88\u8017 1\uff09\n\n\t\t\tif ( NetworkManager.IsAuthority )\n\t\t\t\tCircleroyaleGame.Current?.DoEject( this, aimPoint );\n\t\t\telse\n\t\t\t\tRequestEject( aimPoint );\n\t\t}\n\n\t\t// \u9053\u5177\u4e3b\u52a8\u4f7f\u7528\uff08v0.7.5.0\uff09\uff1aQ/E \u91ca\u653e\u80cc\u5305\u91cc\u7684\u9053\u5177\u2014\u2014\u6743\u5a01\u7aef\u76f4\u63a5\u89e6\u53d1\uff0c\u5ba2\u6237\u7aef\u53d1 RPC\u3002\n\t\t// banner \u6309\u952e\u5373\u64ad\uff08\u672c\u5730\u5373\u65f6\u53cd\u9988\uff0c\u4e0d\u7b49 host \u5f80\u8fd4\uff09\uff1b\u69fd\u4f4d\u7a7a\u5219\u4ec0\u4e48\u90fd\u4e0d\u53d1\u751f\n\t\t\t// Q \u952e\uff08M7.4\uff09\uff1a\u9886\u571f\u6a21\u5f0f=\u804c\u4e1a\u6280\u80fd\uff08\u51b7\u5374\u5236\uff0cPowerA \u65f6\u95f4\u5145\u80fd pass \u5728\u9886\u571f\u4e0b\u51bb\u7ed3\uff09\uff1b\n\t\t\t// \u5176\u4ed6\u6a21\u5f0f=\u5145\u80fd\u9053\u5177\u69fd\uff08\u65e2\u6709\u884c\u4e3a\uff09\u3002E \u952e\u9053\u5177\u7167\u65e7\n\t\t\t// \u6587\u672c\u8f93\u5165\uff08\u804a\u5929\u6846\uff09\u6301\u952e\u76d8\u7126\u70b9\u65f6\u4e00\u5f8b\u4e0d\u8bfb\u6309\u952e\uff1a\u5f15\u64ce\u628a\u6309\u952e\u65e0\u5dee\u522b\u4e22\u8fdb\u6240\u6709 InputContext\uff0c\n\t\t\t// \u4e0d\u6321\u7684\u8bdd\u804a\u5929\u91cc\u6253 Q/E \u4f1a\u76f4\u63a5\u653e\u804c\u4e1a\u6280/\u9053\u5177\n\t\t\tif ( !BallInput.Typing )\n\t\t\t{\n\t\t\t\tif ( MatchState.IsTerritory )\n\t\t\t\t{\n\t\t\t\t\tif ( Input.Keyboard.Pressed( \"Q\" ) ) TryCastClassSkill();\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tUsePowerSlot( 0, Input.Keyboard.Pressed( \"Q\" ) );\n\t\t\t\t}\n\t\t\t\tUsePowerSlot( 1, Input.Keyboard.Pressed( \"E\" ) );\n\t\t\t}\n\t}\n\n\tprotected override void OnFixedUpdate()\n\t{\n\t\tif ( IsProxy ) return;   // \u53ea\u6709\u62e5\u6709\u8005\uff08\u771f\u4eba\uff09\u6216 host\uff08bot\uff09\u6a21\u62df\n\t\tif ( IsRemoteOwned ) return;   // host \u4fa7\u8fdc\u7a0b\u7403\u7531\u5176\u4e3b\u4eba\u6a21\u62df\uff1b\u672a\u7f51\u7edc\u5316\u7684\u5e7d\u7075\u7403\u7edd\u4e0d\u672c\u5730\u6a21\u62df\n\n\t\t// \u6b7b\u4ea1\u72b6\u6001\uff1a\u505c\u4f4f\u4e0d\u52a8\uff08\u5bf9\u8c61\u4fdd\u7559\uff09\uff1bAlive \u7ffb\u56de true \u65f6\u672c\u5730\u91cd\u751f\uff08\u968f\u673a\u6362\u4f4d+\u4fdd\u62a4\u671f\uff09\n\t\tif ( !Alive )\n\t\t{\n\t\t\t_velocity = Vector3.Zero;\n\t\t\t_wasAlive = false;\n\t\t\treturn;\n\t\t}\n\t\tif ( !_wasAlive )\n\t\t{\n\t\t\t_wasAlive = true;\n\t\t\t_sinceSpawn = 0;          // \u590d\u6d3b\u4fdd\u62a4\u671f\uff08\u672c\u5730\u8868\u73b0\uff1b\u6743\u5a01\u4fdd\u62a4\u671f\u5728 host \u7684 Init \u91cc\uff09\n\t\t\t_velocity = Vector3.Zero;\n\t\t\t// owner \u81ea\u5df1\u968f\u673a\u6362\u4f4d\uff08\u4f4d\u7f6e\u5f52 owner \u6a21\u62df\uff09\uff1b\u9886\u571f\u6a21\u5f0f\u9ed8\u8ba4\u56de\u5927\u672c\u8425\uff0c\n\t\t\t// \u6b7b\u4ea1\u9762\u677f\u70b9 FRONT\uff08M7.2\uff09\u5219\u6362\u5230\u6700\u8fd1\u5df1\u65b9\u5360\u9886\u683c\u2014\u2014\u504f\u597d\u968f\u70b9\u51fb\u5b58\u672c\u673a\uff0c\u6b64\u5904\u6d88\u8d39\n\t\t\tWorldPosition = RandomSpawnPos( TeamIndex, CircleroyaleGame.Current?.ConsumeFrontRespawn() ?? false, WorldPosition );\n\t\t\tif ( GameSfx.IsMine( OwnerSteamId ) )   // \u53ea\u64ad\u672c\u673a\u7403\uff1a\u5f00\u5c40 32 \u4e2a bot \u540c\u5e27\u751f\u6210\u66fe\u53e0 33 \u5c42\u97f3\uff08v0.7.8.10 \u4fee\uff09\n\t\t\t\tGameSfx.Respawn( WorldPosition );\n\t\t\tGameSfx.ResetEatCount();   // \u4e0b\u4e00\u6761\u547d\u5403\u98df\u7269\u97f3\u4ece 0 \u91cd\u65b0\u8ba1\n\t\t\tGameSfx.ResetCombo();      // \u8fde\u51fb\u6e05\u96f6\uff08\u65b0\u7684\u4e00\u6761\u547d\u91cd\u65b0\u8d77\u7b97\uff09\n\t\t\tGameLog.Info( $\"[ball] local respawn '{PlayerName}'\" );\n\t\t}\n\n\t\t// \u6bd4\u8d5b\u7ed3\u675f\uff1a\u5168\u573a\u51bb\u7ed3\uff08\u7ed3\u7b97\u9762\u677f\u63a5\u7ba1\uff0c\u8c01\u4e5f\u4e0d\u8bb8\u518d\u52a8\uff09\n\t\tif ( MatchState.MatchOver )\n\t\t{\n\t\t\t_velocity = Vector3.Zero;\n\t\t\treturn;\n\t\t}\n\n\t\t// \u771f\u4eba = \u672c\u5730\u8f93\u5165\uff08owner \u6a21\u62df\uff09\uff1bbot = BotBrain \u5199\u5165\u7684\u65b9\u5411\u3002\n\t\t// \u5206\u88c2/\u5410\u5b62\u5b50\u7684\u8f93\u5165\u5224\u5b9a\u5df2\u79fb\u5230 OnUpdate\uff08\u6bcf\u5e27\u8def\u5f84\uff09\u2014\u2014Input \u6309\u952e\u8fb9\u6cbf\u6309\u6e32\u67d3\u5e27\u5237\u65b0\uff0c\n\t\t// \u56fa\u5b9a\u6b65\u8fdb\u7684 FixedUpdate \u53ef\u80fd\u9519\u8fc7 Pressed \u8fb9\u6cbf\uff08\u6309\u4e86\u6ca1\u53cd\u5e94\u7684\u9690\u60a3\uff09\n\t\tvar dir = IsBot ? _botDir : BallInput.GetMoveDir( Scene.Camera );\n\n\t\t// \u901f\u5ea6\u4e0e\u8f6c\u5411\u90fd\u968f\u8d28\u91cf\u8870\u51cf\uff08\u627918\u2460\uff09\uff1a\u4f53\u79ef\u8d8a\u5927\u6781\u901f\u8d8a\u4f4e\u3001\u60ef\u6027\u8d8a\u5f3a\u8f6c\u5411\u8d8a\u7b28\u62d9\uff0c\n\t\t// \u5927\u7403\u5fc5\u987b\u63d0\u524d\u9884\u5224\u8d70\u4f4d\u3002\u54cd\u5e94\u65f6\u95f4\u5e38\u6570 = BaseTurnSpeed \u00d7 (StartMass/Mass)^TurnCurve\n\t\tvar turn = MathF.Max( GameConfig.MinTurnSpeed,\n\t\t\tGameConfig.BaseTurnSpeed * MathF.Pow( GameConfig.StartMass / Mass, GameConfig.TurnCurve ) );\n\t\tvar t = 1f - MathF.Exp( -turn * Time.Delta );\n\t\t_velocity = Vector3.Lerp( _velocity, dir * Speed, t );\n\n\t\tvar pos = WorldPosition + _velocity * Time.Delta;\n\n\t\t// \u573a\u5730\u8fb9\u754c\u94b3\u5236\uff08\u534a\u5f84\u5f02\u5e38\u6da8\u7834\u573a\u5730\u65f6 half \u515c\u5e95\u4e3a 0\uff0c\u7edd\u4e0d\u8ba9 Clamp \u7684 min>max \u629b\u5f02\u5e38\uff09\n\t\tvar half = MathF.Max( 0f, GameConfig.ArenaHalfSize - Radius );\n\t\tvar preX = pos.x;\n\t\tvar preY = pos.y;\n\t\tpos.x = Math.Clamp( pos.x, -half, half );\n\t\tpos.y = Math.Clamp( pos.y, -half, half );\n\t\tpos.z = 0f;\n\n\t\tWorldPosition = pos;\n\n\t\t// \u649e\u5899\u8109\u51b2\uff08v0.7.8.78\uff09\uff1a\u94b3\u5236\u771f\u628a\u4f4d\u7f6e\u6309\u4f4f + \u649e\u51fb\u901f\u5ea6\u591f + \u8282\u6d41\uff0c\u63a5\u89e6\u70b9\u6765\u4e00\u5708\u6269\u6563\u73af\u3002\n\t\t// \u5404\u7aef\u672c\u5730\u89e6\u53d1\uff08\u771f\u4eba owner \u673a / bot host \u673a\uff09\uff0c\u7279\u6548\u4e0d\u505a\u7f51\u7edc\u5e7f\u64ad\n\t\tif ( _sinceWallHit > GameConfig.WallHitFxCooldown )\n\t\t{\n\t\t\tVector3 contact;\n\t\t\tvar hit = false;\n\n\t\t\tif ( preX != pos.x && MathF.Abs( _velocity.x ) > GameConfig.WallHitFxMinSpeed )\n\t\t\t{\n\t\t\t\thit = true;\n\t\t\t\tcontact = new Vector3( MathF.Sign( pos.x ) * ( GameConfig.ArenaHalfSize - 2f ), pos.y, 0f );\n\t\t\t}\n\t\t\telse if ( preY != pos.y && MathF.Abs( _velocity.y ) > GameConfig.WallHitFxMinSpeed )\n\t\t\t{\n\t\t\t\thit = true;\n\t\t\t\tcontact = new Vector3( pos.x, MathF.Sign( pos.y ) * ( GameConfig.ArenaHalfSize - 2f ), 0f );\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tcontact = Vector3.Zero;\n\t\t\t}\n\n\t\t\tif ( hit )\n\t\t\t{\n\t\t\t\t_sinceWallHit = 0;\n\t\t\t\tNeonRenderer.WallPulse( contact, NeonColor );\n\t\t\t}\n\t\t}\n\n\t\t// \u7403\u4f53\u63a8\u6324\uff08v0.7.8.24 \u7403\u7403\u5927\u4f5c\u6218\u624b\u611f\uff09\uff1a\u5403\u4e0d\u52a8\u5bf9\u65b9=\u5b9e\u5fc3\u2014\u2014\u63a8\u51fa\u4e0e\u654c\u65b9\u7684\u91cd\u53e0\n\t\tApplyPush( half );\n\t}\n\n\t/// <summary>\n\t/// \u7403\u4f53\u63a8\u6324\uff08v0.7.8.24 \u7403\u7403\u5927\u4f5c\u6218\u624b\u611f\uff09\uff1a**\u5404\u81ea\u628a\u81ea\u5df1\u63a8\u51fa\u91cd\u53e0**\u2014\u2014\u5bf9\u65b9 bot/host \u7403\u8d70\u540c\u4ee3\u7801\u8def\u5f84\u3001\n\t/// \u771f\u4eba\u7403\u7531\u5176\u4e3b\u4eba\u63a8\uff0c\u53cc\u65b9\u6309\u8d28\u91cf\u6743\u91cd\u8ba9\u4f4d\uff0c\u4e00\u5e27\u5185\u6536\u655b\u5230\u5206\u79bb\uff0c\u4e0d\u9700\u8981\u4f4d\u7f6e\u6743\u5a01\u4ef2\u88c1\u3002\n\t/// \u961f\u53cb\u4e0e\u81ea\u5df1\u7684\u8eab\u4f53\u4e0d\u63a8\uff08BoB \u60ef\u4f8b\u53ef\u91cd\u53e0\uff09\uff1b\u80fd\u5403/\u88ab\u5403\u65f6\u4e0d\u63a8\uff08\u5403\u5f52 host \u6743\u5a01\uff0c\u5403\u4e86\u5c31\u4e0d\u5b58\u5728\u91cd\u53e0\uff09\uff1b\n\t/// \u5c16\u523a\u5206\u8eab\u662f\u6b66\u5668\u4e0d\u63a8\uff08\u78b0\u5230\u8d70\u6b66\u5668\u5224\u5b9a\uff09\uff1b\u5b62\u5b50\u4e0d\u63a8\u3002\u4f4d\u7f6e\u662f owner \u6743\u5a01\uff0c\u63a8\u5b8c\u81ea\u52a8\u540c\u6b65\n\t/// </summary>\n\tvoid ApplyPush( float half )\n\t{\n\t\tvar game = CircleroyaleGame.Current;\n\t\tif ( game is null ) return;\n\n\t\tforeach ( var b in game.Balls )\n\t\t{\n\t\t\tif ( b is null || !b.IsValid() || !b.Alive || b == this ) continue;\n\t\t\tif ( b.OwnerSteamId == OwnerSteamId ) continue;\n\t\t\tif ( game.SameTeamSteam( OwnerSteamId, b.OwnerSteamId ) ) continue;\n\n\t\t\tvar hi = MathF.Max( Mass, b.Mass );\n\t\t\tvar lo = MathF.Min( Mass, b.Mass );\n\t\t\tif ( hi >= lo * GameConfig.EatRatio ) continue;   // \u80fd\u5403\u5c31\u5403\uff08host \u6743\u5a01\uff09\uff0c\u4e0d\u63a8\n\n\t\t\tvar dx = WorldPosition.x - b.WorldPosition.x;\n\t\t\tvar dy = WorldPosition.y - b.WorldPosition.y;\n\t\t\tvar rr = Radius + b.Radius;\n\t\t\tvar d2 = dx * dx + dy * dy;\n\t\t\tif ( d2 >= rr * rr ) continue;\n\t\t\tvar d = MathF.Sqrt( MathF.Max( d2, 0.0001f ) );\n\t\t\tvar nx = d > 0.001f ? dx / d : 1f;\n\t\t\tvar ny = d > 0.001f ? dy / d : 0f;\n\t\t\t// \u5b8c\u5168\u91cd\u5408\u515c\u5e95\uff1a\u63a8\u6ee1 rr \u7684\u4e00\u534a\uff1b\u6b63\u5e38\u60c5\u51b5\u63a8\"\u91cd\u53e0 \u00d7 \u5bf9\u65b9\u8d28\u91cf\u5360\u6bd4\"\uff08\u8d8a\u91cd\u8ba9\u5f97\u8d8a\u5c11\uff09\n\t\t\tvar push = d > 0.001f ? MathF.Min( rr - d, GameConfig.PushMaxStep ) * ( b.Mass / ( Mass + b.Mass ) ) : rr * 0.5f;\n\n\t\t\tWorldPosition = new Vector3(\n\t\t\t\tMath.Clamp( WorldPosition.x + nx * push, -half, half ),\n\t\t\t\tMath.Clamp( WorldPosition.y + ny * push, -half, half ), 0f );\n\t\t}\n\n\t\t// \u654c\u65b9\u5206\u8eab=\u5b9e\u5fc3\u969c\u788d\uff08\u81ea\u5df1\u4e0d\u52a8\uff0c\u6211\u8ba9\u8def\u5168\u989d\u63a8\uff1b\u80fd\u5403\u65f6\u4e0d\u63a8\u2014\u2014host \u4f1a\u5403\u6389\u5b83\uff09\n\t\tforeach ( var c in game.Cells )\n\t\t{\n\t\t\tif ( c.PieceKind != CellPiece.Kind.SplitPiece ) continue;\n\t\t\tif ( c.OwnerSteamId == OwnerSteamId ) continue;\n\t\t\tif ( game.SameTeamSteam( OwnerSteamId, c.OwnerSteamId ) ) continue;\n\t\t\tif ( Mass >= c.Mass * GameConfig.EatRatio ) continue;\n\n\t\t\tvar dx = WorldPosition.x - c.Pos.x;\n\t\t\tvar dy = WorldPosition.y - c.Pos.y;\n\t\t\tvar rr = Radius + c.Radius;\n\t\t\tvar d2 = dx * dx + dy * dy;\n\t\t\tif ( d2 >= rr * rr ) continue;\n\t\t\tvar d = MathF.Sqrt( MathF.Max( d2, 0.0001f ) );\n\t\t\tvar nx = d > 0.001f ? dx / d : 1f;\n\t\t\tvar ny = d > 0.001f ? dy / d : 0f;\n\t\t\tvar push = d > 0.001f ? MathF.Min( rr - d, GameConfig.PushMaxStep ) : rr * 0.5f;\n\n\t\t\tWorldPosition = new Vector3(\n\t\t\t\tMath.Clamp( WorldPosition.x + nx * push, -half, half ),\n\t\t\t\tMath.Clamp( WorldPosition.y + ny * push, -half, half ), 0f );\n\t\t}\n\t}\n\n\t/// <summary> \u573a\u5730\u5185\u51fa\u751f\u70b9\uff08owner \u590d\u6d3b\u65f6\u672c\u5730\u9009\u4f4d\uff09\uff1a\u8d70\u6e38\u620f\u7cfb\u7edf\u7684\u5b89\u5168\u91c7\u6837\uff0c\u65e0\u7cfb\u7edf\u65f6\u7eaf\u968f\u673a\u515c\u5e95\u3002\n\t/// \u9886\u571f\u6a21\u5f0f\uff08M7\uff09\uff1a\u6709\u961f\u53f7\u5c31\u56de\u5df1\u65b9\u5927\u672c\u8425\u91c7\u6837\uff1bfrontLine=true\uff08M7.2 \u4e8c\u9009\u4e00\uff09\u6362\u6700\u8fd1\u5df1\u65b9\u5360\u9886\u683c </summary>\n\tstatic Vector3 RandomSpawnPos( int teamIndex = -1, bool frontLine = false, Vector3 nearPos = default )\n\t{\n\t\tvar game = CircleroyaleGame.Current;\n\t\tif ( game is not null ) return game.RandomSpawnPos( teamIndex, frontLine, nearPos );\n\n\t\tif ( MatchState.IsTerritory && teamIndex >= 0 )\n\t\t{\n\t\t\tif ( frontLine && TerritoryManager.TryFrontSpawn( teamIndex, nearPos, out var front ) )\n\t\t\t\treturn front;\n\t\t\tTerritoryManager.HqBounds( teamIndex, out var minX, out var maxX, out var minY, out var maxY );\n\t\t\treturn new Vector3( Game.Random.Float( minX, maxX ), Game.Random.Float( minY, maxY ), 0f );\n\t\t}\n\n\t\tvar half = GameConfig.ArenaHalfSize - 128f;\n\t\treturn new Vector3( Game.Random.Float( -half, half ), Game.Random.Float( -half, half ), 0f );\n\t}\n\n\t// ---- \u5206\u88c2/\u5410\u5b62\u5b50 RPC\uff08M3\uff09\uff1a\u5ba2\u6237\u7aef \u2192 host\uff0chost \u6821\u9a8c\u5f52\u5c5e\u540e\u6267\u884c\u3002\n\t// v0.7.8.7\uff1a\u53c2\u6570\u8bed\u4e49\u6539\u4e3a**\u5149\u6807\u4e16\u754c\u5750\u6807**\uff08aimPoint\uff09\uff0chost \u9010\u8eab\u4f53\u7b97\u6307\u5411 ----\n\n\t[Rpc.Host]\n\tpublic void RequestSplit( Vector2 aimPoint )\n\t{\n\t\tvar caller = Rpc.Caller;\n\t\tif ( caller is not null && caller.SteamId.Value != OwnerSteamId ) return;   // \u53ea\u80fd\u64cd\u4f5c\u81ea\u5df1\u7684\u7403\n\n\t\tCircleroyaleGame.Current?.DoSplit( this, aimPoint );\n\t}\n\n\t[Rpc.Host]\n\tpublic void RequestEject( Vector2 aimPoint )\n\t{\n\t\tvar caller = Rpc.Caller;\n\t\tif ( caller is not null && caller.SteamId.Value != OwnerSteamId ) return;\n\n\t\tCircleroyaleGame.Current?.DoEject( this, aimPoint );\n\t}\n\n\t[Rpc.Host]\n\tpublic void RequestUsePower( int slot )\n\t{\n\t\tvar caller = Rpc.Caller;\n\t\tif ( caller is not null && caller.SteamId.Value != OwnerSteamId ) return;   // \u53ea\u80fd\u64cd\u4f5c\u81ea\u5df1\u7684\u7403\n\n\t\tUsePower( slot );\n\t}\n\n\tprotected override void OnDestroy()\n\t{\n\t\tbase.OnDestroy();\n\t\tCircleroyaleGame.Current?.RemoveBall( this );\n\t}\n}"
        },
        {
            "Ident": "slfy.circleroyale",
            "Path": "Player/CellPiece.cs",
            "FileName": "CellPiece.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 380090,
            "IsPrivate": false,
            "Code": "using System;\n\n/// <summary>\n/// \u5206\u88c2\u7ec6\u80de / \u5410\u51fa\u7684\u5b62\u5b50\uff08**\u7eaf\u6570\u636e\u5b9e\u4f53\uff0c\u4e0d\u662f GameObject**\uff09\u3002\n/// \u4e3a\u4ec0\u4e48\u4e0d\u8d70\u7f51\u7edc\u5bf9\u8c61\uff1a\u8fd0\u884c\u65f6 live create \u5e7f\u64ad\u5230\u5df2\u8fde\u63a5\u5ba2\u6237\u7aef\u7684\u53ef\u9760\u6027\u672a\u9a8c\u8bc1\uff08M2 \u9057\u7559\uff09\uff0c\n/// \u800c\u98df\u7269\u540c\u6b3e\"\u9759\u6001 RPC \u5feb\u7167\"\u662f\u672c\u9879\u76ee\u5b9e\u6d4b 100% \u53ef\u9760\u7684\u552f\u4e00\u540c\u6b65\u901a\u9053\u3002\n/// host \u6743\u5a01\u6a21\u62df\uff08\u51b2\u91cf\u8870\u51cf + \u8ddf\u968f\u4e3b\u4eba\u8f6c\u5411\uff09\uff0c\u72b6\u6001\u7ecf CellsState \u9759\u6001 RPC \u6309 CellSyncInterval\n/// \u5e7f\u64ad\uff1b\u5404\u7aef\u672c\u5730\u955c\u50cf\u5217\u8868\u53ea\u8d1f\u8d23\u6e32\u67d3\u4e0e\u5e73\u6ed1\uff08DrawPos \u8ffd Pos\uff0c\u5ba2\u6237\u7aef\u628a 15Hz \u5feb\u7167\u63d2\u987a\uff09\u3002\n/// </summary>\npublic sealed class CellPiece\n{\n\tpublic enum Kind : byte\n\t{\n\t\tSplitPiece = 0,   // \u5206\u88c2\u51fa\u7684\u7ec6\u80de\uff08\u8ddf\u968f\u4e3b\u4eba\u65b9\u5411\u79fb\u52a8\uff0c\u51b7\u5374\u540e\u5e76\u56de\uff09\n\t\tEjectedMass = 1,  // \u5410\u51fa\u7684\u5b62\u5b50\uff08\u4e2d\u6027\u98df\u7269\u56e2\uff0c\u8c01\u90fd\u80fd\u5403\uff09\n\t\tSpikeMinion = 2,  // \u5c16\u523a\u5206\u8eab\uff08v0.7.4.0 \u9053\u5177\u751f\u6210\uff09\uff1a\u8ddf\u968f\u4e3b\u4eba 20s\uff0c\u5230\u671f\u53d8\u56de\u666e\u901a\u5206\u8eab\uff1b\u654c\u65b9\u6309\u5c16\u523a\u89c4\u5219\u5224\u5b9a\n\t}\n\n\tpublic int Id;\n\n\tpublic Kind PieceKind;\n\n\t/// <summary> \u4e3b\u4eba SteamId\uff08bot \u4e3a 0\uff1bbot \u4e0d\u5206\u88c2\uff0c\u6b64\u5b57\u6bb5\u4ec5\u771f\u4eba\u6709\u503c\uff09 </summary>\n\tpublic long OwnerSteamId;\n\n\t/// <summary> \u4e3b\u4eba\u961f\u4f0d\u53f7\uff08-1 \u65e0\u961f\uff1bhost \u51fa\u751f\u65f6\u8bb0\u4e00\u6b21\uff0c\u9886\u571f\u5582\u65d7\u8ba1\u5206\u7528\uff0c\u514d\u9010\u5e27\u67e5\u8868\uff09 </summary>\n\tpublic int TeamIndex = -1;\n\n\t/// <summary> \u951a\u5b9a\u4e0d\u52a8\uff08M7.4 \u62a4\u536b\u56fa\u5b88\u5c16\u523a\uff09\uff1a\u4e0d\u8ddf\u968f\u4e3b\u4eba\u3001\u65e0\u8f6c\u5411 </summary>\n\tpublic bool Anchored;\n\n\t/// <summary> \u5b58\u5728\u65f6\u957f\u8986\u76d6\uff08\u79d2\uff1b\u5c0f\u4e8e 0 = \u7528 Kind \u9ed8\u8ba4\u5bff\u547d\uff09\u2014\u2014\u62a4\u536b\u5c16\u523a 8s / \u9053\u5177\u5c16\u523a 20s\uff08M7.4\uff09 </summary>\n\tpublic float MaxLife = -1f;\n\n\tpublic int ColorIndex;\n\n\tpublic float Mass;\n\n\t/// <summary> \u6743\u5a01\u4f4d\u7f6e\uff08host \u6bcf\u5e27\u63a8\u8fdb\uff1b\u5ba2\u6237\u7aef = \u6700\u8fd1\u4e00\u6b21\u5feb\u7167\u503c\uff0c15Hz \u8df3\u53d8\uff09 </summary>\n\tpublic Vector3 Pos;\n\n\t/// <summary> \u6e32\u67d3\u4f4d\u7f6e\uff1a\u5411 Pos \u6307\u6570\u5e73\u6ed1\uff08host \u9010\u5e27\u6743\u5a01\u76f4\u63a5\u8d34\u9f50\uff1b\u5ba2\u6237\u7aef\u63d2\u503c\uff09 </summary>\n\tpublic Vector3 DrawPos;\n\n\t/// <summary> \u5f39\u5c04\u51b2\u91cf\uff08\u5206\u88c2/\u5410\u51fa\u77ac\u95f4\uff09\uff0c\u968f\u65f6\u95f4\u6307\u6570\u8870\u51cf </summary>\n\tpublic Vector3 Impulse;\n\n\t/// <summary> \u8ddf\u968f\u4e3b\u4eba\u79fb\u52a8\u65b9\u5411\u7684\u901f\u5ea6\u5206\u91cf\uff08\u4ec5 host \u6a21\u62df\uff09 </summary>\n\tpublic Vector3 SteerVel;\n\n\t/// <summary> \u51fa\u751f\u65f6\u523b\uff1a\u5408\u5e76\u51b7\u5374\uff08\u5206\u8eab\uff09\u4e0e\u53ef\u98df\u5ef6\u8fdf\uff08\u5b62\u5b50\uff09\u90fd\u770b\u5b83 </summary>\n\tpublic TimeSince SinceSpawn;\n\n\t/// <summary> \u4ec5 host\uff1a\u4e3b\u4eba\u7403\u5f15\u7528\uff08\u5206\u8eab\u6e05\u7406\u3001\u8f6c\u5411\u65b9\u5411\u6765\u6e90\uff09\uff1b\u5b62\u5b50\u6052\u4e3a null\uff08\u4e2d\u6027\u5b58\u6d3b\uff09 </summary>\n\tpublic Ball OwnerBall;\n\n\t/// <summary> \u534a\u5f84\u6cbf\u7528\u7403\u7684\u8d28\u91cf\u5b9a\u5f8b\uff1ar = StartRadius \u00d7 \u221a(mass/StartMass) </summary>\n\tpublic float Radius => GameConfig.StartRadius * MathF.Sqrt( MathF.Max( 1f, Mass ) / GameConfig.StartMass );\n\n\t/// <summary> \u79fb\u52a8\u901f\u5ea6\u6cbf\u7528\u7403\u7684\u901f\u5ea6\u66f2\u7ebf\uff1a\u8d8a\u5927\u8d8a\u6162 </summary>\n\tpublic float Speed => MathF.Max( GameConfig.MinSpeed,\n\t\tGameConfig.BaseSpeed * MathF.Pow( GameConfig.StartMass / MathF.Max( 1f, Mass ), GameConfig.SpeedCurve ) );\n\n\t/// <summary> \u5408\u5e76\u51b7\u5374\u65f6\u957f\uff08\u79d2\uff09\uff1a\u4e3b\u52a8\u5206\u88c2\u6309\u8d28\u91cf\u66f2\u7ebf\uff08MergeCooldownFor\uff09\uff1b\u649e\u523a\u788e\u7247\u77ed\u51b7\u5374\uff08SpikePieceMergeCooldown\uff09 </summary>\n\tpublic float MergeCooldown = GameConfig.MergeCooldownBase;\n\n\t/// <summary> \u5408\u5e76\u51b7\u5374\u662f\u5426\u5df2\u8fc7\uff08\u4ec5\u5206\u8eab\u6709\u610f\u4e49\uff09 </summary>\n\tpublic bool IsMergeReady => SinceSpawn > MergeCooldown;\n}\n\n/// <summary>\n/// \u523a\u7206\u5f39\u5e55\uff08v0.7.8.24\uff09\uff1a\u5582\u523a\u5582\u6ee1\u540e\u4ece\u523a\u5c04\u51fa\u7684\u5c16\u523a\u5f39\u3002host \u6743\u5a01\u6a21\u62df\uff08\u542b\u547d\u4e2d\u5224\u5b9a\uff09\uff0c\n/// \u5ba2\u6237\u7aef\u4e3a\u7eaf\u89c6\u89c9\u5f39\u9053\u955c\u50cf\uff08SpineDir \u516c\u5f0f\u53cc\u7aef\u4e00\u81f4\uff0c\u89c6\u89c9\u5f39\u9053=\u6743\u5a01\u5f39\u9053\uff09\u3002\n/// </summary>\npublic sealed class SpikeSpine\n{\n\tpublic Vector2 Pos;\n\n\tpublic Vector2 Vel;\n\n\t/// <summary> \u51fa\u751f\u65f6\u523b\uff08SpineLife \u5230\u671f\u6d88\u6563\uff09 </summary>\n\tpublic TimeSince SinceSpawn;\n}\n\n/// <summary> CellsState RPC \u7684\u7ebf\u683c\u5f0f\uff08\u5168 blittable \u5b57\u6bb5\uff0c\u540c FoodData \u6a21\u5f0f\uff09 </summary>\npublic struct CellWire\n{\n\tpublic int Id;\n\tpublic byte Kind;        // CellPiece.Kind\n\tpublic byte ColorIndex;\n\tpublic long Owner;       // OwnerSteamId\n\tpublic float X;\n\tpublic float Y;\n\tpublic float Mass;\n}\n"
        },
        {
            "Ident": "slfy.circleroyale",
            "Path": "ui/lobbypanel.cs.scss",
            "FileName": "lobbypanel.cs.scss",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 380090,
            "IsPrivate": false,
            "Code": "LobbyPanel {\n\tposition: absolute;\n\tleft: 0;\n\ttop: 0;\n\tright: 0;\n\tbottom: 0;\n\tfont-family: PixelFusion;\n\tcolor: #4a3b63;\n\tpointer-events: all;\n\tbackground-color: rgba( 234, 244, 252, 0.88 );   // \u6d45\u84dd\u78e8\u7802\uff08\u50cf\u7d20\u98ce\uff09\n\n\t// \u7248\u5f0f\uff08\u53c2\u8003\u56fe\uff0c2026-09-09 \u7528\u6237\u5b9a\u7a3f\uff09\uff1a\u5de6\u680f\u73a9\u5bb6\u5217\u8868\uff08\u951a\u5c4f\u5e55\u5de6\uff09/ \u53f3\u680f\u53c2\u6570\u8bbe\u7f6e + \u5927\u5f00\u59cb\u6309\u94ae\u3002\n\t// \u53f3\u680f\u4e00\u5f8b right \u951a\u5b9a\uff08\u7528\u6237\u7a97\u53e3\u8bbe\u8ba1\u5bbd ~1800 \u2260 1920\uff0c\u56fa\u5b9a left \u5b9a\u4f4d\u4f1a\u9876\u51fa\u53f3\u7f18\uff09\n\n\t.lp-title {\n\t\tposition: absolute;\n\t\tleft: 0;\n\t\tright: 0;\n\t\ttop: 80px;\n\t\tfont-size: 100px;\n\t\tfont-weight: 800;\n\t\tletter-spacing: 10px;\n\t\ttext-align: center;\n\t\tcolor: #f272a7;\n\t\ttext-shadow: 0 4px 0 #4a3b63;\n\t}\n\n\t.lp-status {\n\t\tposition: absolute;\n\t\tleft: 0;\n\t\tright: 0;\n\t\ttop: 215px;\n\t\tfont-size: 32px;\n\t\tfont-weight: 700;\n\t\tletter-spacing: 4px;\n\t\ttext-align: center;\n\t\tcolor: #d98a00;\n\t\ttext-shadow: 0 3px 0 rgba( 74, 59, 99, 0.25 );\n\t}\n\n\t// \u4e24\u680f\u5c0f\u8282\u6807\u9898\uff08players \u5de6\u680f / settings \u53f3\u680f\uff0c\u4f4d\u7f6e\u5206\u5199\uff09\n\t.lp-head {\n\t\tposition: absolute;\n\t\ttop: 278px;\n\t\tfont-size: 13px;\n\t\tletter-spacing: 4px;\n\t\ttext-align: center;\n\t\tcolor: rgba( 74, 59, 99, 0.45 );\n\t}\n\n\t.lp-head.players {\n\t\tleft: 80px;\n\t\twidth: 600px;\n\t}\n\n\t.lp-head.settings {\n\t\tright: 60px;\n\t\twidth: 1040px;\n\t}\n\n\t// \u53c2\u6570\u8bbe\u7f6e\u724c\uff08\u53f3\u680f\u7eb5\u6392\u4e09\u5f20\uff1bTop \u7531\u4ee3\u7801\u6309\u884c\u7ed9\uff09\n\t.lp-setting {\n\t\tposition: absolute;\n\t\tright: 60px;\n\t\twidth: 1040px;\n\t\tfont-size: 30px;\n\t\tletter-spacing: 2px;\n\t\ttext-align: center;\n\t\tcolor: #4a3b63;\n\t\tborder-image: url( ui/skin/note.png ) 40 fill / 5px stretch;\n\t\tpadding: 14px 10px;\n\t\tcursor: pointer;\n\t\tpointer-events: all;\n\t}\n\n\t.lp-setting:hover {\n\t\tcolor: #e5487e;\n\t\tborder-image-tint: #ffe4f0;\n\t}\n\n\t// \u8bbe\u7f6e\u53ef\u70b9\u63d0\u793a\uff08\u4ec5 host\uff09\uff1a\u724c\u5806\u4e0b\u65b9\u91d1\u8272\u5f15\u5bfc\u5c0f\u5b57\n\t.lp-setting-hint {\n\t\tposition: absolute;\n\t\tright: 60px;\n\t\twidth: 1040px;\n\t\ttop: 658px;\n\t\tfont-size: 24px;\n\t\tletter-spacing: 4px;\n\t\ttext-align: center;\n\t\tcolor: rgba( 217, 138, 0, 0.75 );\n\t}\n\n\t// \u73a9\u5bb6\u5217\u8868\u6eda\u52a8\u5bb9\u5668\uff08\u539f\u751f\u7f51\u9875\u5f0f\u6eda\u52a8\uff1a\u6eda\u8f6e + \u6eda\u52a8\u6761\uff0c\u6ea2\u51fa\u624d\u51fa\u73b0\uff09\n\t.lp-players-scroll {\n\t\tposition: absolute;\n\t\tleft: 92px;\n\t\ttop: 322px;\n\t\twidth: 576px;\n\t\theight: 616px;\n\t\tflex-direction: column;   // \u26a0\ufe0f s&box \u9ed8\u8ba4 row\uff0c\u4e0d\u52a0\u5c31\u662f\u6a2a\u6392\n\t\toverflow-y: scroll;\n\t\tscrollbar-width: 10px;   // \u2605 \u6eda\u52a8\u6761\u7c97\u7ec6\uff08\u7cbe\u4fee\u6539\u8fd9\u91cc\uff09\n\t\tscrollbar-color: rgba( 74, 59, 99, 0.45 ) transparent;   // \u2605 \u6ed1\u5757\u8272 \u8f68\u9053\u8272\n\t\tpadding-right: 4px;\n\t\tpointer-events: all;   // \u9762\u677f\u9ed8\u8ba4 None\uff1a\u4e0d\u6253\u5f00\u6536\u4e0d\u5230\u6eda\u8f6e\n\t}\n\n\t// \u73a9\u5bb6\u884c\u724c\uff08\u6d41\u5185\u5355\u5217\uff0cnote.png \u5c0f\u724c\uff09\n\t.lp-player {\n\t\tflex-shrink: 0;   // \u4e0d\u8bb8\u538b\u7f29\uff1a\u538b\u4e86\u5c31\u4e0d\u6ea2\u51fa\u3001\u6eda\u52a8\u6761\u6c38\u8fdc\u51fa\u4e0d\u6765\n\t\tmargin: 0 0 8px 0;\n\t\tfont-size: 30px;\n\t\tletter-spacing: 1px;\n\t\ttext-align: left;\n\t\tcolor: #4a3b63;\n\t\tborder-image: url( ui/skin/note.png ) 28 fill / 5px stretch;\n\t\tpadding: 8px 50px;\n\t}\n\n\t// \u73a9\u5bb6\u5217\u8868\u5bb9\u5668\u5e95\uff08dobou InventoryHotbar \u4e5d\u5bab\u683c\uff0c\u7528\u6237\u6307\u5b9a\uff09\uff1a\n\t// \u7f69\u4f4f\u5de6\u680f\u4e24\u5b50\u5217\uff08\u884c\u724c x80..680 / y320..925\uff09\uff0c\u56db\u5468\u7559 ~20px \u5185\u886c\n\t.lp-players-bg {\n\t\tposition: absolute;\n\t\tleft: 60px;\n\t\ttop: 300px;\n\t\twidth: 640px;\n\t\theight: 660px;\n\t\tborder-image: url( ui/skin/hotbar.png ) 60 fill / 30px stretch;\n\t}\n\n\t// BOTS \u8865\u4f4d\u63d0\u793a\uff1a\u8bbe\u7f6e\u724c\u5806\u4e0b\u3001\u6309\u94ae\u4e0a\n\t.lp-note {\n\t\tposition: absolute;\n\t\tright: 60px;\n\t\twidth: 1040px;\n\t\ttop: 700px;\n\t\tfont-size: 20px;\n\t\tletter-spacing: 3px;\n\t\ttext-align: center;\n\t\tcolor: rgba(21, 9, 54, 0.4);\n\t}\n\n\t// \u9053\u5177\u56fe\u4f8b\u5df2\u6309\u7528\u6237\u8981\u6c42\u6574\u4e2a\u79fb\u9664\uff082026-09-09\uff09\u2014\u2014Q/E \u7528\u6cd5\u5f00\u5c40\u540e HUD \u91cc\u8fd8\u6709\u63d0\u793a\n\n\t.lp-countdown {\n\t\tposition: absolute;\n\t\tleft: 0;\n\t\tright: 0;\n\t\ttop: 600px;\n\t\tfont-family: PixelFusion;\n\t\tfont-size: 40px;\n\t\tfont-weight: 800;\n\t\tletter-spacing: 8px;\n\t\ttext-align: center;\n\t\tcolor: #f272a7;\n\t\ttext-shadow: 0 4px 0 #4a3b63;\n\t}\n\n\t// \u4e0b\u9762\u4e09\u9897\u5168\u662f UiKit \u836f\u4e38\uff08\u5916\u89c2\u5728 Assets/ui/cruikit.scss \u7684 .cr-pill\uff09\uff0c\u8fd9\u91cc\u53ea\u7ba1\u6446\u653e\u3002\n\t// START/JOIN \u540c\u4e00\u69fd\u4f4d\uff08\u540c\u4e00\u65f6\u523b\u53ea\u663e\u793a\u4e00\u4e2a\uff09\uff0c\u4e0e\u8bbe\u7f6e\u724c\u540c\u4e2d\u8f74\uff08right:60+1040 \u2192 \u4e2d\u8f74 = \u53f3\u7f18-580\uff09\n\t.lp-start {\n\t\tposition: absolute;\n\t\tright: 400px;\n\t\ttop: 750px;\n\t\twidth: 360px;\n\t\tpadding-top: 8px;\n\t}\n\n\t.lp-join {\n\t\tposition: absolute;\n\t\tright: 400px;\n\t\ttop: 850px;\n\t\twidth: 360px;\n\t\tpadding-top: 8px;\n\t}\n\n\t.lp-leave {\n\t\tposition: absolute;\n\t\tright: 470px;\n\t\ttop: 950px;\n\t\twidth: 220px;\n\t\tpadding-top: 8px;\n\t}\n}\n"
        },
        {
            "Ident": "slfy.circleroyale",
            "Path": "ui/rankingspanel.cs.scss",
            "FileName": "rankingspanel.cs.scss",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 380090,
            "IsPrivate": false,
            "Code": "RankingsPanel {\n\tposition: absolute;\n\tleft: 0;\n\ttop: 0;\n\tright: 0;\n\tbottom: 0;\n\tfont-family: PixelFusion;\n\tcolor: #4a3b63;\n\tpointer-events: all;\n\tbackground-color: rgba( 234, 244, 252, 0.88 );   // \u6d45\u84dd\u78e8\u7802\uff08\u50cf\u7d20\u98ce\uff09\n\n\t.rk-title {\n\t\tposition: absolute;\n\t\tleft: 0;\n\t\tright: 0;\n\t\ttop: 80px;\n\t\tfont-size: 100px;\n\t\tfont-weight: 800;\n\t\tletter-spacing: 10px;\n\t\ttext-align: center;\n\t\tcolor: #f272a7;\n\t\ttext-shadow: 0 4px 0 #4a3b63;\n\t}\n\n\t.rk-status {\n\t\tposition: absolute;\n\t\tleft: 0;\n\t\tright: 0;\n\t\ttop: 460px;   // \u7a7a\u5361\u6b63\u4e2d\uff1a\u8bfb\u53d6\u4e2d/\u65e0\u6570\u636e/\u5931\u8d25\u4e09\u79cd\u72b6\u6001\u90fd\u663e\u793a\u5728\u8fd9\uff08\u884c\u724c\u4e4b\u540e\u521b\u5efa\uff0c\u538b\u5728\u7a7a\u724c\u4e0a\u5c42\uff09\n\t\tfont-size: 32px;\n\t\tfont-weight: 700;\n\t\tletter-spacing: 4px;\n\t\ttext-align: center;\n\t\tcolor: #d98a00;\n\t\ttext-shadow: 0 3px 0 rgba( 74, 59, 99, 0.25 );\n\t}\n\n\t.rk-head {\n\t\tposition: absolute;\n\t\tleft: 50%;\n\t\tmargin-left: -550px;\n\t\twidth: 1100px;\n\t\ttop: 250px;\n\t\tfont-size: 12px;\n\t\tletter-spacing: 2px;\n\t\ttext-align: left;\n\t\tcolor: rgba( 74, 59, 99, 0.45 );\n\t}\n\n\t// \u6392\u884c\u699c\u6eda\u52a8\u5bb9\u5668\uff08\u539f\u751f\u7f51\u9875\u5f0f\u6eda\u52a8\uff1a\u6eda\u8f6e + \u6eda\u52a8\u6761\uff0c\u6ea2\u51fa\u624d\u51fa\u73b0\uff09\n\t// \u884c\u724c\u6d41\u5185\u5355\u5217\uff1b\u5bbd 1600 / \u8d77\u70b9 188 \u662f\u7528\u6237\u8c03\u6821\u503c\u7684\u8fc1\u79fb\n\t.rk-list {\n\t\tposition: absolute;\n\t\tleft: 50%;\n\t\tmargin-left: -800px;\n\t\twidth: 1600px;\n\t\ttop: 180px;\n\t\theight: 770px;   // \u2605 \u5217\u8868\u89c6\u53e3\u9ad8\u5ea6\uff08\u7cbe\u4fee\u6539\u8fd9\u91cc\uff1b\u5e95\u5230 BACK \u4e0a\u65b9\u4e3a\u6b62\uff09\n\t\tflex-direction: column;   // \u26a0\ufe0f s&box \u9ed8\u8ba4 row\uff0c\u4e0d\u52a0\u5c31\u662f\u6a2a\u6392\n\t\toverflow-y: scroll;\n\t\tscrollbar-width: 10px;   // \u2605 \u6eda\u52a8\u6761\u7c97\u7ec6\n\t\tscrollbar-color: rgba( 74, 59, 99, 0.45 ) transparent;\n\t\tpointer-events: all;   // \u9762\u677f\u9ed8\u8ba4 None\uff1a\u4e0d\u6253\u5f00\u6536\u4e0d\u5230\u6eda\u8f6e\n\t}\n\n\t.rk-row {\n\t\tflex-shrink: 0;   // \u4e0d\u8bb8\u538b\u7f29\uff1a\u538b\u4e86\u5c31\u4e0d\u6ea2\u51fa\u3001\u6eda\u52a8\u6761\u6c38\u8fdc\u51fa\u4e0d\u6765\n\t\tmargin: 0 0 6px 0;   // \u2605 \u884c\u8ddd\uff08\u539f top \u8d77\u70b9\u5f0f\u884c\u8ddd\u4f5c\u5e9f\uff0c\u6539\u8fd9\u91cc\uff09\n\t\tfont-size: 50px;\n\t\tletter-spacing: 10px;\n\t\ttext-align: left;\n\t\tcolor: #4a3b63;\n\t\tborder-image: url( ui/skin/note.png ) 50 fill / 5px stretch;\n\t\tpadding: 20px 100px;   // \u2605 \u884c\u5185\u6587\u5b57\u4e0e\u724c\u8fb9\u7f18\u7684\u8ddd\u79bb\uff08\u7cbe\u4fee\u6539\u8fd9\u91cc\uff09\n\t}\n\n\t.rk-self {\n\t\tposition: absolute;\n\t\tleft: 0;\n\t\tright: 0;\n\t\ttop: 860px;\n\t\tfont-size: 32px;\n\t\tfont-weight: 700;\n\t\tletter-spacing: 3px;\n\t\ttext-align: center;\n\t\tcolor: #e5488d;\n\t}\n\n\t// \u8fd4\u56de\u6309\u94ae\uff1aUiKit \u836f\u4e38\uff08\u5916\u89c2\u5728 Assets/ui/cruikit.scss\uff09\uff0c\u8fd9\u91cc\u53ea\u7ba1\u6446\u653e\n\t.rk-back {\n\t\tposition: absolute;\n\t\tleft: 50%;\n\t\tmargin-left: -110px;   // \u81ea\u5df1\u5bbd\u5ea6 220 \u7684\u4e00\u534a\uff0c\u4efb\u610f\u5206\u8fa8\u7387\u6c34\u5e73\u5c45\u4e2d\n\t\ttop: 980px;\n\t\twidth: 220px;\n\t\tpadding-top: 8px;\n\t}\n}"
        },
        {
            "Ident": "slfy.circleroyale",
            "Path": "ui/mainmenu.cs.scss",
            "FileName": "mainmenu.cs.scss",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 380090,
            "IsPrivate": false,
            "Code": "MainMenu {\n\tposition: absolute;\n\tleft: 0;\n\ttop: 0;\n\tright: 0;\n\tbottom: 0;\n\tfont-family: PixelFusion;\n\tcolor: #4a3b63;\n\tpointer-events: all;   // \u26a0\ufe0f \u53ea\u8ba4 auto/none/all\uff1a\u5199 normal \u975e\u6cd5\u88ab\u5ffd\u7565\uff08\u9762\u677f\u9ed8\u8ba4 None=\u9f20\u6807\u5168\u6b7b\uff09\n\n\t// \u5b98\u65b9\u9879\u76ee\u624b\u6cd5\uff1aflex \u5217 + align-items \u5c45\u4e2d\uff08\u4efb\u610f\u5206\u8fa8\u7387\u6c34\u5e73\u5c45\u4e2d\uff09\u3002\n\t// \u26a0\ufe0f s&box \u9ed8\u8ba4 flex-direction \u662f row\u2014\u2014\u4e0d\u663e\u5f0f\u5199 column \u7684\u8bdd\u5b50\u5143\u7d20\u5168\u90e8\u6a2a\u6392\uff08\u5b9e\u6d4b\uff09\n\tflex-direction: column;\n\talign-items: center;\n\n\t// ---- \u5de6\u4e0a\uff1a\u73a9\u5bb6\u8d44\u6599\u5361\uff08\u5934\u50cf / \u540d\u5b57 / \u7b49\u7ea7\uff09 ----\n\t.profile {\n\t\tposition: absolute;\n\t\tleft: 20px;\n\t\ttop: 16px;\n\t}\n\n\t.profile-avatar {\n\t\twidth: 72px;\n\t\theight: 72px;\n\t\tborder: 3px solid #4a3b63;\n\t\tborder-radius: 12px;\n\t\tbackground-color: rgba( 255, 255, 255, 0.75 );\n\t}\n\n\t.profile-info {\n\t\tposition: absolute;\n\t\tleft: 84px;\n\t\ttop: 0;\n\t\tbottom: 0;\n\t\tflex-direction: column;   // \u26a0\ufe0f \u9ed8\u8ba4 row\uff1a\u540d\u5b57\u548c\u7b49\u7ea7\u4f1a\u6324\u6210\u4e00\u884c\n\t\tjustify-content: center;   // \u5782\u76f4\u5c45\u4e2d\u4e8e\u5934\u50cf\u9ad8\u5ea6\n\t}\n\n\t.profile-name {\n\t\tfont-size: 20px;\n\t\tfont-weight: 100;\n\t\tletter-spacing: 1px;\n\t\tcolor: #4a3b63;\n\t\ttext-shadow: 0 2px 0 rgba( 255, 255, 255, 0.9 );\n\t}\n\n\t.profile-level {\n\t\tmargin-top: 6px;\n\t\tfont-size: 12px;\n\t\tfont-weight: 700;\n\t\tletter-spacing: 2px;\n\t\tcolor: #d98a00;\n\t}\n\n\t// ---- \u53f3\u4e0a\uff1a\u8bbe\u7f6e / \u6392\u884c\u699c\uff08\u56fe\u6807\u5c0f\u6309\u94ae\uff0c\u6a2a\u6392\uff09 ----\n\t.top-actions {\n\t\tposition: absolute;\n\t\tright: 40px;\n\t\ttop: 40px;\n\t\tflex-direction: row;\n\t}\n\n\t.icon-btn {\n\t\twidth: 100px;\n\t\talign-items: center;      // \u56fe\u6807\u6c34\u5e73\u5c45\u4e2d\n\t\tjustify-content: center;  // \u56fe\u6807\u5782\u76f4\u5c45\u4e2d\n\t\theight: 100px;\n\t\tmargin-left: 24px;   // \u4e24\u9897\u6309\u94ae\u62c9\u5f00\u95f4\u8ddd\uff0c\u522b\u7cca\u6210\u4e00\u56e2\n\t\tborder-image: url( ui/skin/slot.png ) 30 fill / 10px stretch;\n\t\tcursor: pointer;\n\t\tpointer-events: all;\n\t}\n\n\t.icon-btn:hover {\n\t\tborder-image-tint: #ffd6e8;\n\t}\n\n\t.icon-btn-img {\n\t\t// \u26a0\ufe0f \u522b\u5199 position/left/top\u2014\u2014.icon-btn \u6ca1\u6709 position \u65f6\u767e\u5206\u6bd4\u89e3\u6790\u5230 .top-actions \u5bb9\u5668\uff0c\n\t\t// \u4e24\u9897\u56fe\u6807\u4f1a\u53e0\u5230\u5bb9\u5668\u6b63\u4e2d\u3002\u5c45\u4e2d\u7531 .icon-btn \u7684 align/justify-center \u5b8c\u6210\uff0c\u8fd9\u91cc\u53ea\u7ba1\u5c3a\u5bf8\n\t\twidth: 50px;\n\t\theight: 50px;\n\t}\n\n\t// ---- \u4e2d\u592e\u5927 logo ----\n\t.title {\n\t\twidth: 100%;\n\t\tmargin-top: 150px;\n\t\tfont-size: 196px;\n\t\tfont-weight: 800;\n\t\tletter-spacing: 8px;\n\t\tcolor: #f272a7;   // \u7cd6\u679c\u7c89 logo\n\t\ttext-shadow: 0 6px 0 #4a3b63, 0 11px 0 rgba( 74, 59, 99, 0.35 );\n\t\ttext-align: center;\n\t}\n\n\t// \u526f\u6807\u9898\u5df2\u6309\u9700\u6c42\u79fb\u9664\uff08v0.7.8.44\uff09\n\n\t// ---- \u4e09\u4e2a\u4e3b\u6309\u94ae\u6a2a\u6392\uff08\u53c2\u8003\u624b\u6e38\u5e03\u5c40\uff1b\u7c89 / \u84dd / \u6a59\uff09 ----\n\t.menu-row {\n\t\tmargin-top: 400px;\n\t\tflex-direction: row;\n\t\talign-items: center;\n\t}\n\n\t.menu-row .btn {\n\t\twidth: 250px;\n\t\theight: 64px;\n\t\tmargin-top: 10px;\n\t\tmargin-left: 30px;\n\t\tfont-size: 25px;\n\t\tfont-weight: 300;\n\t\tletter-spacing: 5px;\n\t\tcolor: #4a3b63;\n\t\tpadding-top: 8px;\n\t\ttext-align: center;\n\t\tborder-image: url( ui/skin/btn_white.png ) 52 fill / 26px stretch;\n\t\tcursor: pointer;\n\t\tpointer-events: all;\n\t}\n\n\t.menu-row .btn.host { margin-left: 0; border-image-tint: #ffb9d0; }   // \u7c89=\u5efa\u670d\n\t.menu-row .btn.join { border-image-tint: #b8d9ff; }                   // \u84dd=\u52a0\u5165\n\t.menu-row .btn.bots { border-image-tint: #ffcf9e; }                   // \u6a59=\u4eba\u673a\n\n\t.menu-row .btn:hover {\n\t\tborder-image-tint: #ffffff;\n\t\tcolor: #e5487e;\n\t}\n\n\t// ---- \u89d2\u843d\u56fa\u5b9a\u4ef6 ----\n\t.status {\n\t\tposition: absolute;\n\t\tleft: 0;\n\t\tright: 0;\n\t\tbottom: 52px;\n\t\tfont-size: 120px;\n\t\tletter-spacing: 2px;\n\t\tcolor: #d98a00;\n\t\ttext-align: center;\n\t}\n\n\t.version {\n\t\tposition: absolute;\n\t\tright: 20px;\n\t\tbottom: 14px;\n\t\tfont-size: 12px;\n\t\tletter-spacing: 2px;\n\t\tcolor: rgba( 74, 59, 99, 0.4 );\n\t}\n\n\t.hint {\n\t\tposition: absolute;\n\t\tleft: 30px;\n\t\tbottom: 14px;\n\t\tfont-size: 12px;\n\t\tletter-spacing: 2px;\n\t\tcolor: rgba( 74, 59, 99, 0.5 );\n\t}\n}"
        },
        {
            "Ident": "slfy.circleroyale",
            "Path": "Game/FoodManager.cs",
            "FileName": "FoodManager.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 380090,
            "IsPrivate": false,
            "Code": "using System;\n\n/// <summary>\n/// \u98df\u7269\u7ba1\u7406\uff08**\u6bcf\u53f0\u673a\u5668\u4e00\u4efd\u672c\u5730\u5b9e\u4f8b\uff0c\u4e0d\u8d70\u5bf9\u8c61\u590d\u5236**\u2014\u2014NetList \u6302\u5bf9\u8c61\u65e0\u6cd5\u590d\u5236\u5230\u5ba2\u6237\u7aef\uff0c\u5b9e\u6d4b\uff09\uff1a\n/// host \u6743\u5a01\u751f\u6210/\u5403/\u91cd\u751f\uff1b\u72b6\u6001\u53d8\u5316\u7ecf NetworkManager \u7684\u9759\u6001 RPC \u5e7f\u64ad\uff0c\u5404\u7aef\u5e94\u7528\u5230\u672c\u5730\u5217\u8868\u3002\n/// Init/TryEat/Tick \u4ec5\u6743\u5a01\u7aef\u8c03\u7528\uff1bApply* \u4ec5\u4f9b\u5ba2\u6237\u7aef\u5e94\u7528\u8fdc\u7aef\u4e8b\u4ef6\u3002\n/// \u7a7a\u95f4\u54c8\u5e0c\uff08v0.6.3.0\uff09\uff1a3 \u500d\u573a\u5730 4140 \u9897\u98df\u7269\u540e\uff0c\u5403\u5224\u5b9a O(\u7403\u6570\u00d7\u98df\u7269)\u224820 \u4e07\u6b21/\u5e27\u6210\u4e3a\u74f6\u9888\u2014\u2014\n/// 512 \u5355\u4f4d\u6876\u67e5\u8be2\u628a\u6bcf\u7403\u68c0\u67e5\u91cf\u4ece 4140 \u964d\u5230\u51e0\u5341\u3002\n/// </summary>\npublic sealed class FoodManager\n{\n\tFoodData[] _foods;\n\tTimeSince[] _eatenAt;                           // \u4ec5 host\uff08\u91cd\u751f\u8ba1\u65f6\uff09\n\treadonly List<int> _pendingRespawn = new();     // \u4ec5 host\n\n\t// ---- \u7a7a\u95f4\u54c8\u5e0c\uff08\u4ec5 host \u5403\u5224\u5b9a\u7528\uff1b\u5ba2\u6237\u7aef\u65e0\u5403\u5224\u5b9a\u4e0d\u5efa\u6876\uff09----\n\tconst float BucketSize = 512f;\n\tDictionary<int, List<int>> _buckets;\n\tint _cols;\n\treadonly List<int> _nearResults = new();\n\n\t/// <summary> \u5b58\u6d3b\u98df\u7269\u6570 </summary>\n\tpublic int AliveCount { get; private set; }\n\n\t/// <summary> \u5168\u91cf\u98df\u7269\uff08NeonRenderer \u6bcf\u5e27\u8bfb\uff1b\u5404\u7aef\u5185\u5bb9\u4e00\u81f4\uff09 </summary>\n\tpublic FoodData[] Foods => _foods;\n\n\tint BucketKey( Vector2 p )\n\t{\n\t\tvar half = GameConfig.ArenaHalfSize;\n\t\tint bx = Math.Clamp( (int) ( ( p.x + half ) / BucketSize ), 0, _cols - 1 );\n\t\tint by = Math.Clamp( (int) ( ( p.y + half ) / BucketSize ), 0, _cols - 1 );\n\t\treturn by * _cols + bx;\n\t}\n\n\tvoid BucketAdd( int index ) => BucketAddAt( index, _foods[index].Pos );\n\n\tvoid BucketAddAt( int index, Vector2 p )\n\t{\n\t\tint key = BucketKey( p );\n\t\tif ( !_buckets.TryGetValue( key, out var list ) )\n\t\t{\n\t\t\tlist = new List<int>( 16 );\n\t\t\t_buckets[key] = list;\n\t\t}\n\t\tlist.Add( index );\n\t}\n\n\tvoid BucketMove( int index, Vector2 from )\n\t{\n\t\tint oldKey = BucketKey( from );\n\t\tif ( _buckets.TryGetValue( oldKey, out var list ) )\n\t\t\tlist.Remove( index );\n\n\t\tBucketAdd( index );\n\t}\n\n\t/// <summary> \u5706\u5f62\u8303\u56f4\u5185\u7684\u5b58\u6d3b\u98df\u7269\u4e0b\u6807\uff08\u8fd1\u4f3c\uff1a\u6309\u6876\u7c97\u7b5b\uff0c\u8c03\u7528\u65b9\u9700\u7cbe\u786e\u590d\u6838\u8ddd\u79bb\uff09\u3002\n\t/// \u8fd4\u56de\u5185\u90e8\u590d\u7528\u5217\u8868\uff0c\u4e0b\u4e00\u6b21\u8c03\u7528\u5373\u5931\u6548\u3002\u4ec5 host\uff08\u5403\u5224\u5b9a\u6743\u5a01\u7aef\uff09\u3002 </summary>\n\tpublic List<int> Nearby( Vector2 pos, float radius )\n\t{\n\t\t_nearResults.Clear();\n\t\tif ( _buckets is null || _foods is null ) return _nearResults;\n\n\t\tint minBx = Math.Clamp( (int) ( ( pos.x - radius + GameConfig.ArenaHalfSize ) / BucketSize ), 0, _cols - 1 );\n\t\tint maxBx = Math.Clamp( (int) ( ( pos.x + radius + GameConfig.ArenaHalfSize ) / BucketSize ), 0, _cols - 1 );\n\t\tint minBy = Math.Clamp( (int) ( ( pos.y - radius + GameConfig.ArenaHalfSize ) / BucketSize ), 0, _cols - 1 );\n\t\tint maxBy = Math.Clamp( (int) ( ( pos.y + radius + GameConfig.ArenaHalfSize ) / BucketSize ), 0, _cols - 1 );\n\n\t\tfor ( int by = minBy; by <= maxBy; by++ )\n\t\t{\n\t\t\tfor ( int bx = minBx; bx <= maxBx; bx++ )\n\t\t\t{\n\t\t\t\tif ( !_buckets.TryGetValue( by * _cols + bx, out var list ) ) continue;\n\n\t\t\t\tforeach ( var idx in list )\n\t\t\t\t{\n\t\t\t\t\tif ( _foods[idx].Alive ) _nearResults.Add( idx );\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn _nearResults;\n\t}\n\n\t/// <summary> host \u6295\u98df\uff08\u6743\u5a01\u7aef\uff1b\u968f\u540e\u7ecf FoodFull RPC \u53d1\u7ed9\u65b0\u8fde\u63a5\uff09 </summary>\n\tpublic void Init()\n\t{\n\t\t_foods = new FoodData[GameConfig.FoodCount];\n\t\t_eatenAt = new TimeSince[GameConfig.FoodCount];\n\t\t_pendingRespawn.Clear();\n\t\t_buckets = new Dictionary<int, List<int>>( 1024 );\n\t\t_cols = Math.Max( 1, (int) ( GameConfig.ArenaHalfSize * 2f / BucketSize ) + 1 );\n\n\t\tfor ( int i = 0; i < _foods.Length; i++ )\n\t\t{\n\t\t\t_foods[i] = MakeFood();\n\t\t\tBucketAdd( i );\n\t\t}\n\t\tAliveCount = _foods.Length;\n\t}\n\n\t/// <summary> \u6bcf\u5e27\uff1a\u628a\u5230\u671f\u7684\u91cd\u751f\u4f4d\u91cd\u65b0\u6295\u98df\u5e76\u5e7f\u64ad\uff08\u4ec5\u6743\u5a01\u7aef\uff09 </summary>\n\tpublic void Tick()\n\t{\n\t\tfor ( int i = _pendingRespawn.Count - 1; i >= 0; i-- )\n\t\t{\n\t\t\tint idx = _pendingRespawn[i];\n\t\t\tif ( _eatenAt[idx] < GameConfig.FoodRespawnSeconds ) continue;\n\n\t\t\tvar oldPos = _foods[idx].Pos;\n\t\t\t_foods[idx] = MakeFood();\n\t\t\t_pendingRespawn.RemoveAt( i );\n\t\t\tAliveCount++;\n\t\t\tBucketMove( idx, oldPos );\n\t\t\tif ( Networking.IsActive ) NetworkManager.FoodRespawned( idx, _foods[idx] );   // \u65e0\u4f1a\u8bdd\uff08\u83dc\u5355\u6f14\u793a\u8d5b\uff09\u4e0d\u5e7f\u64ad\n\t\t}\n\t}\n\n\t/// <summary> \u5c1d\u8bd5\u5403\u6389\u6307\u5b9a\u98df\u7269\uff1b\u6210\u529f\u8fd4\u56de true\uff08\u8c03\u7528\u65b9\u636e\u6b64\u52a0\u8d28\u91cf\uff09\u3002\u4ec5\u6743\u5a01\u7aef\uff0c\u5185\u90e8\u5e7f\u64ad\u3002\n\t/// eaterSteamId \u968f\u5e7f\u64ad\u4e0b\u53d1\uff0c\u5ba2\u6237\u7aef\u636e\u6b64\u5224\u65ad\u662f\u4e0d\u662f\u81ea\u5df1\u7684\u5634\uff08\u64ad\u5403\u97f3\uff09 </summary>\n\tpublic bool TryEat( int index, long eaterSteamId = 0 )\n\t{\n\t\tif ( (uint)index >= (uint)_foods.Length || !_foods[index].Alive ) return false;\n\n\t\t_foods[index].Alive = false;\n\t\t_eatenAt[index] = 0;\n\t\t_pendingRespawn.Add( index );\n\t\tAliveCount--;\n\t\tif ( Networking.IsActive ) NetworkManager.FoodEaten( index, eaterSteamId );   // \u65e0\u4f1a\u8bdd\u4e0d\u5e7f\u64ad\n\t\treturn true;\n\t}\n\n\tFoodData MakeFood()\n\t{\n\t\tvar half = GameConfig.ArenaHalfSize - GameConfig.FoodMargin;\n\t\treturn new FoodData\n\t\t{\n\t\t\tPos = new Vector2( Game.Random.Float( -half, half ), Game.Random.Float( -half, half ) ),\n\t\t\tColorIndex = (byte)Game.Random.Float( 0f, GameConfig.Palette.Length - 0.001f ),\n\t\t\tAlive = true,\n\t\t\tType = 0,\n\t\t};\n\t}\n\n\t// ---- \u5ba2\u6237\u7aef\u5e94\u7528\uff08\u8fdc\u7aef\u4e8b\u4ef6 \u2192 \u672c\u5730\u5217\u8868\uff09----\n\n\tpublic void ApplyFull( FoodData[] foods )\n\t{\n\t\t_foods = foods;\n\t\tAliveCount = 0;\n\t\tforeach ( var f in _foods )\n\t\t{\n\t\t\tif ( f.Alive ) AliveCount++;\n\t\t}\n\t}\n\n\tpublic void ApplyEaten( int index )\n\t{\n\t\tif ( _foods is null ) return;   // \u70ed\u91cd\u8f7d\u540e\u65b0\u5b9e\u4f8b\u672a\u540c\u6b65\u5168\u91cf\u524d\uff0c\u5ffd\u7565\u589e\u91cf\n\t\tif ( (uint)index >= (uint)_foods.Length || !_foods[index].Alive ) return;\n\n\t\tvar f = _foods[index];\n\t\tf.Alive = false;\n\t\t_foods[index] = f;\n\t\tAliveCount--;\n\t}\n\n\tpublic void ApplyRespawned( int index, FoodData food )\n\t{\n\t\tif ( _foods is null ) return;\n\t\tif ( (uint)index >= (uint)_foods.Length ) return;\n\n\t\t// \u5df2\u6d3b\u7740\uff08\u91cd\u590d/\u4e71\u5e8f\u6295\u9012\uff09\u4e5f\u66f4\u65b0\u6570\u636e\u4f46\u4e0d\u91cd\u590d\u8ba1\u6570\u2014\u2014AliveCount \u53ea\u5728\u771f\u590d\u71c3\u65f6 +1\n\t\tif ( !_foods[index].Alive ) AliveCount++;\n\t\t_foods[index] = food;\n\t}\n}"
        },
        {
            "Ident": "slfy.circleroyale",
            "Path": "Game/TerritoryManager.cs",
            "FileName": "TerritoryManager.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 380090,
            "IsPrivate": false,
            "Code": "using System;\n\n/// <summary>\n/// \u9886\u571f\u6218\u4e89\u6a21\u5f0f\uff08M7\uff0c\u7528\u6237\u5b9a\u7a3f V2.1\uff09\uff1a4\u00d74 \u683c\uff0c\u56db\u89d2\u5927\u672c\u8425\u5f00\u5c40\u5c5e\u5df1\u65b9\u4e14\u6c38\u4e0d\u6ca6\u9677\u3002\n/// \u9a7b\u519b\u6a21\u578b\u5360\u9886\uff1a\u5410\u7403\u8fdb\u65d7\u5708\u8ba1\u5206\u2014\u2014\u4e2d\u7acb\u683c\u5582\u6ee1 100 \u53d8\u8272\u5360\u9886\uff08\u9a7b\u519b\u6ee1 100\uff09\uff1b\n/// \u654c\u65b9\u5410\u7403\u5148\u5543\u9a7b\u519b\uff0c\u5543\u5149\u56de\u4e2d\u7acb\uff08\u6ea2\u51fa\u8f6c\u4e3a\u81ea\u5df1\u8fdb\u5ea6\uff09\u3002\u65e0\u81ea\u52a8\u8870\u51cf\u3001\u65e0\u8865\u7ed9\u7ebf\uff1b\n/// \u53ea\u80fd\u62a2\u4e0e\u5df1\u65b9\u9886\u571f 4 \u90bb\u63a5\u7684\u683c\uff08\u5927\u672c\u8425\u514d\u75ab\u654c\u65b9\u5582\u98df\uff09\u3002\n/// host \u6743\u5a01\u7ed3\u7b97\uff08TickCells \u5410\u7403\u547d\u4e2d\u5373\u8bb0 + 5Hz \u53d8\u5316\u5feb\u7167\u5e7f\u64ad\uff09\uff1b\u5ba2\u6237\u7aef ApplyRemote \u53ea\u8bfb\u3002\n/// \u683c\u5b50\u51e0\u4f55\u7531 ArenaHalfSize \u51b3\u5b9a\uff08Replicated \u4e0b\u53d1\uff0c\u53cc\u7aef\u4e00\u81f4\uff09\uff0c\u65e0\u989d\u5916\u7f51\u7edc\u72b6\u6001\u3002\n/// </summary>\npublic static class TerritoryManager\n{\n\tconst int TeamCount = 4;\n\n\tstruct Cell\n\t{\n\t\tpublic int Owner;      // \u5360\u9886\u961f\uff08-1 = \u4e2d\u7acb\uff09\n\t\tpublic int ScoreTeam;  // \u5206\u6570\u5f52\u5c5e\u961f\uff08\u4e2d\u7acb\u683c\u7684\u4e89\u593a\u8fdb\u5ea6\u65b9\uff1b-1 = \u65e0\uff09\n\t\tpublic float Score;    // \u4e2d\u7acb\u683c = \u8be5\u961f\u7684\u5360\u9886\u8fdb\u5ea6\uff1b\u5df1\u65b9\u683c = \u9a7b\u519b\u503c\n\t}\n\n\tstatic readonly Cell[] _cells = new Cell[GameConfig.TerritoryGrid * GameConfig.TerritoryGrid];\n\tstatic readonly int[] _owned = new int[TeamCount];\n\tstatic readonly float[] _points = new float[TeamCount];   // \u5360\u65d7\u79ef\u5206\uff08\u6bcf\u65d7\u6bcf 3s +1\uff0c\u7528\u6237\u5b9a\u7a3f\uff09\n\tstatic float _pointTimer;                                 // \u79ef\u5206\u53d1\u653e\u8282\u62cd\n\tstatic int _conquerTeam = -1;   // \u5148\u5230\u5f81\u670d\u80dc\u5229\u7684\u961f\uff08TickTerritory \u8bfb\uff0cEndMatch \u540e\u968f Reset \u6e05\uff09\n\tstatic int _sentVersion;        // \u5df2\u5e7f\u64ad\u8fc7\u7684\u7248\u672c\u53f7\uff08host \u8282\u6d41\uff1a\u53d8\u4e86\u624d\u53d1\uff09\n\n\tpublic static bool Active => MatchState.IsTerritory;\n\n\t/// <summary> \u72b6\u6001\u7248\u672c\u53f7\uff1a\u4efb\u4f55\u683c\u53d8\u5316 +1\uff08TerritoryRenderer \u636e\u6b64\u91cd\u5efa\u51e0\u4f55\uff09 </summary>\n\tpublic static int StateVersion { get; private set; }\n\n\t/// <summary> host\uff1a\u81ea\u4e0a\u6b21\u5feb\u7167\u540e\u72b6\u6001\u6709\u53d8\uff08TickTerritory \u8282\u6d41\u5e7f\u64ad\u7528\uff09 </summary>\n\tpublic static bool NeedsSync => StateVersion != _sentVersion;\n\n\t/// <summary> \u89c4\u5219\u751f\u6548\u70b9\u8c03\u7528\uff08MatchState.Apply\uff09\uff1a\u5168\u7aef\u590d\u4f4d\u4e3a\u5f00\u5c40\u6001\u2014\u2014\u56db\u89d2\u5927\u672c\u8425\u5c5e\u5df1\u65b9\u6ee1\u9a7b\u519b\uff0c\u5176\u4f59\u5168\u4e2d\u7acb </summary>\n\tpublic static void Reset()\n\t{\n\t\tfor ( int i = 0; i < _cells.Length; i++ )\n\t\t{\n\t\t\t_cells[i].Owner = -1;\n\t\t\t_cells[i].ScoreTeam = -1;\n\t\t\t_cells[i].Score = 0f;\n\t\t}\n\n\t\tfor ( var t = 0; t < TeamCount; t++ )\n\t\t{\n\t\t\tvar ( cx, cy ) = HqCell( t );\n\t\t\tref var c = ref _cells[cy * GameConfig.TerritoryGrid + cx];\n\t\t\tc.Owner = t;\n\t\t\tc.ScoreTeam = t;\n\t\t\tc.Score = GameConfig.TerritoryCaptureScore;\n\t\t}\n\n\t\t_conquerTeam = -1;\n\t\t_sentVersion = 0;\n\t\t_pointTimer = 0f;\n\t\tfor ( var t = 0; t < TeamCount; t++ ) _points[t] = 0f;\n\t\tRecount();\n\t\tStateVersion++;\n\t}\n\n\t/// <summary> \u961f\u4f0d\u7684\u5927\u672c\u8425\u683c\uff08\u56fa\u5b9a\u89d2\u6620\u5c04\uff1a0 \u5de6\u4e0a\u30011 \u53f3\u4e0a\u30012 \u53f3\u4e0b\u30013 \u5de6\u4e0b\uff09 </summary>\n\tpublic static ( int Cx, int Cy ) HqCell( int team ) => team switch\n\t{\n\t\t0 => ( 0, 0 ),\n\t\t1 => ( GameConfig.TerritoryGrid - 1, 0 ),\n\t\t2 => ( GameConfig.TerritoryGrid - 1, GameConfig.TerritoryGrid - 1 ),\n\t\t_ => ( 0, GameConfig.TerritoryGrid - 1 ),\n\t};\n\n\tstatic bool IsHq( int cx, int cy )\n\t{\n\t\tfor ( var t = 0; t < TeamCount; t++ )\n\t\t{\n\t\t\tvar hq = HqCell( t );\n\t\t\tif ( hq.Cx == cx && hq.Cy == cy ) return true;\n\t\t}\n\t\treturn false;\n\t}\n\n\t// ---- \u51e0\u4f55\uff08\u53cc\u7aef\u4e00\u81f4\uff0c\u53ea\u4f9d\u8d56 ArenaHalfSize\uff09----\n\n\tpublic static float CellSize => GameConfig.ArenaHalfSize * 2f / GameConfig.TerritoryGrid;\n\n\tpublic static Vector3 CellCenter( int cx, int cy )\n\t{\n\t\tvar half = GameConfig.ArenaHalfSize;\n\t\tvar s = CellSize;\n\t\treturn new Vector3( -half + ( cx + 0.5f ) * s, -half + ( cy + 0.5f ) * s, 0f );\n\t}\n\n\tpublic static void CellOf( Vector3 pos, out int cx, out int cy )\n\t{\n\t\tvar half = GameConfig.ArenaHalfSize;\n\t\tvar s = CellSize;\n\t\tcx = Math.Clamp( (int)MathF.Floor( ( pos.x + half ) / s ), 0, GameConfig.TerritoryGrid - 1 );\n\t\tcy = Math.Clamp( (int)MathF.Floor( ( pos.y + half ) / s ), 0, GameConfig.TerritoryGrid - 1 );\n\t}\n\n\t/// <summary> \u961f\u4f0d\u5927\u672c\u8425\u5185\u7684\u51fa\u751f\u91c7\u6837\u8303\u56f4\uff08\u8d34\u8fb9\u5185\u7f29\uff0c\u522b\u51fa\u751f\u5230\u65d7\u5708\u8fb9\u4e0a\u6328\u6253\uff09 </summary>\n\tpublic static void HqBounds( int team, out float minX, out float maxX, out float minY, out float maxY )\n\t{\n\t\tvar ( cx, cy ) = HqCell( team );\n\t\tvar half = GameConfig.ArenaHalfSize;\n\t\tvar s = CellSize;\n\t\tvar inset = s * 0.28f;\n\t\tminX = -half + cx * s + inset;\n\t\tmaxX = -half + ( cx + 1 ) * s - inset;\n\t\tminY = -half + cy * s + inset;\n\t\tmaxY = -half + ( cy + 1 ) * s - inset;\n\t}\n\n\t/// <summary> \u7403\u662f\u5426\u5728\u5df1\u65b9\u5927\u672c\u8425\u5185\uff08\u5927\u4f53\u578b\u8870\u51cf\u8c41\u514d\u5224\u5b9a\uff0cBall \u8870\u51cf\u5904\u8c03\u7528\uff09 </summary>\n\tpublic static bool IsOwnHq( int team, Vector3 pos )\n\t{\n\t\tif ( !Active || team < 0 ) return false;\n\t\tCellOf( pos, out var cx, out var cy );\n\t\tvar hq = HqCell( team );\n\t\treturn cx == hq.Cx && cy == hq.Cy;\n\t}\n\n\t/// <summary> \u7403\u662f\u5426\u5728\u654c\u8425\u5927\u672c\u8425\u5185\uff08M7.2 \u5b89\u5168\u5c4b\uff1a\u51cf\u901f+\u6389\u91cd\uff1b\u65e0\u961f/\u975e\u9886\u571f\u6052 false\uff09 </summary>\n\tpublic static bool InHostileHq( int team, Vector3 pos )\n\t{\n\t\tif ( !Active || team < 0 ) return false;\n\t\tCellOf( pos, out var cx, out var cy );\n\t\tref var c = ref _cells[cy * GameConfig.TerritoryGrid + cx];\n\t\treturn IsHq( cx, cy ) && c.Owner >= 0 && c.Owner != team;\n\t}\n\n\t/// <summary> \u662f\u5426\u5b58\u5728\u524d\u7ebf\u683c\uff08\u5927\u672c\u8425\u4ee5\u5916\u7684\u5df1\u65b9\u5360\u9886\u683c\u2014\u2014\u6b7b\u4ea1\u9762\u677f FRONT \u6309\u94ae\u4eae\u6697\u7528\uff09 </summary>\n\tpublic static bool HasFrontCell( int team ) => TryFrontSpawn( team, default, out _ );\n\n\t/// <summary> \u8be5\u4f4d\u7f6e\u7684\u65d7\u662f\u5426\u5141\u8bb8\u8be5\u961f\u52a8\u4f5c\uff08\u5df1\u65b9\u683c\uff0c\u6216\u90bb\u63a5\u5df1\u65b9\u9886\u571f\u7684\u975e\u5927\u672c\u8425\u683c\uff09\u2014\u2014UI \u5f15\u5bfc\u7528\uff1b\n\t/// \u4e0e Feed \u7684\u5165\u4fb5\u95e8\u540c\u4e00\u89c4\u5219 </summary>\n\tpublic static bool CanActOn( int team, Vector3 pos )\n\t{\n\t\tCellOf( pos, out var cx, out var cy );\n\t\treturn CanActOnCell( cx, cy, team );\n\t}\n\n\tpublic static bool CanActOnCell( int cx, int cy, int team )\n\t{\n\t\tif ( !Active || team < 0 ) return false;\n\t\tref var c = ref _cells[cy * GameConfig.TerritoryGrid + cx];\n\t\treturn c.Owner == team || ( !IsHq( cx, cy ) && AdjacentToOwned( cx, cy, team ) );\n\t}\n\n\t/// <summary> \u524d\u7ebf\u590d\u6d3b\u70b9\uff08M7.2 \u590d\u6d3b\u4e8c\u9009\u4e00\uff09\uff1a\u79bb nearPos \u6700\u8fd1\u7684\u5df1\u65b9\u975e\u5927\u672c\u8425\u5360\u9886\u683c\u5185\u968f\u673a\u70b9\uff1b\n\t/// \u6ca1\u6709\u524d\u7ebf\u683c\u8fd4\u56de false\uff08\u53ea\u5269\u5927\u672c\u8425\u53ef\u590d\u6d3b\uff09 </summary>\n\tpublic static bool TryFrontSpawn( int team, Vector3 nearPos, out Vector3 pos )\n\t{\n\t\tpos = default;\n\t\tif ( !Active || team < 0 ) return false;\n\n\t\tvar best = float.MaxValue;\n\t\tvar found = false;\n\t\tvar grid = GameConfig.TerritoryGrid;\n\t\tfor ( int cy = 0; cy < grid; cy++ )\n\t\t{\n\t\t\tfor ( int cx = 0; cx < grid; cx++ )\n\t\t\t{\n\t\t\t\tref var c = ref _cells[cy * grid + cx];\n\t\t\t\tif ( c.Owner != team || IsHq( cx, cy ) ) continue;\n\t\t\t\tvar d = CellCenter( cx, cy ).Distance( nearPos );\n\t\t\t\tif ( d < best )\n\t\t\t\t{\n\t\t\t\t\tbest = d;\n\t\t\t\t\tpos = CellCenter( cx, cy );\n\t\t\t\t\tfound = true;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif ( !found ) return false;\n\n\t\tvar s = CellSize;\n\t\tpos += new Vector3( Game.Random.Float( -s * 0.18f, s * 0.18f ), Game.Random.Float( -s * 0.18f, s * 0.18f ), 0f );\n\t\treturn true;\n\t}\n\n\t// ---- \u5582\u65d7\u7ed3\u7b97\uff08host \u6743\u5a01\uff09----\n\n\t/// <summary> \u4e00\u9897\u5b62\u5b50\u843d\u8fdb\u65d7\u5708\uff08TickCells \u5410\u7403\u547d\u4e2d\u8c03\u7528\uff09\u3002\n\t/// \u8fd4\u56de\u662f\u5426\u6d88\u8017\u8be5\u5b62\u5b50\uff1a\u8fdb\u65d7\u5708\u4e14\u89c4\u5219\u5141\u8bb8\u5c31\u6d88\u8017\u8ba1\u5206\uff1b\u6ca1\u8fdb\u5708\u6216\u89c4\u5219\u4e0d\u5141\u8bb8\uff08\u975e\u90bb\u63a5/\u5927\u672c\u8425\uff09\n\t/// \u5219 false\uff0c\u5b62\u5b50\u7ee7\u7eed\u5f53\u666e\u901a\u5b62\u5b50\u98de\uff0c\u5410\u51fa\u7684\u8d28\u91cf\u4e0d\u767d\u8d39 </summary>\n\tpublic static bool Feed( int team, Vector3 pos, float blobMass )\n\t{\n\t\tif ( !Active || team < 0 || blobMass <= 0f ) return false;\n\n\t\tCellOf( pos, out var cx, out var cy );\n\t\tif ( CellCenter( cx, cy ).Distance( pos ) >= GameConfig.TerritoryFlagRadius ) return false;   // \u6ca1\u8fdb\u65d7\u5708\n\n\t\tref var c = ref _cells[cy * GameConfig.TerritoryGrid + cx];\n\n\t\t// \u5165\u4fb5\u89c4\u5219\uff1a\u52a8\u522b\u4eba/\u65e0\u4e3b\u7684\u683c\uff0c\u5fc5\u987b\u4e0e\u5df1\u65b9\u9886\u571f 4 \u90bb\u63a5\uff08\u7a33\u624e\u7a33\u6253\uff0c\u65e0\u98de\u5730\uff09\n\t\tif ( c.Owner != team && ( !AdjacentToOwned( cx, cy, team ) || IsHq( cx, cy ) ) )\n\t\t\treturn false;\n\n\t\tvar pts = MathF.Min( blobMass * GameConfig.TerritoryScorePerMass, GameConfig.TerritoryScoreCapPerBlob );\n\t\tif ( pts <= 0f ) return false;\n\n\t\tvar changed = false;\n\n\t\tif ( c.Owner == team )\n\t\t{\n\t\t\t// \u5df1\u65b9\u683c\uff1a\u8865\u9a7b\u519b\uff08\u65e0\u81ea\u52a8\u8870\u51cf\uff0c\u53ea\u4f1a\u88ab\u654c\u65b9\u5543\u6389\u540e\u91cd\u5360\uff09\n\t\t\tif ( c.Score < GameConfig.TerritoryCaptureScore )\n\t\t\t{\n\t\t\t\tc.Score = MathF.Min( GameConfig.TerritoryCaptureScore, c.Score + pts );\n\t\t\t\tchanged = true;\n\t\t\t}\n\t\t}\n\t\telse if ( c.Owner < 0 )\n\t\t{\n\t\t\t// \u4e2d\u7acb\u683c\uff1a\u65e0\u4e3b\u8fdb\u5ea6\u5f52\u81ea\u5df1\u7d2f\u52a0\uff1b\u6709\u522b\u4eba\u7684\u8fdb\u5ea6\u5148\u5543\uff0c\u5543\u5149\u8f6c\u4e3a\u81ea\u5df1\u7d2f\u52a0\uff08\u6ea2\u51fa\u8ba1\u5165\uff09\n\t\t\tif ( c.ScoreTeam < 0 || c.ScoreTeam == team )\n\t\t\t{\n\t\t\t\tc.ScoreTeam = team;\n\t\t\t\tc.Score += pts;\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tc.Score -= pts;\n\t\t\t\tif ( c.Score <= 0f )\n\t\t\t\t{\n\t\t\t\t\tc.ScoreTeam = team;\n\t\t\t\t\tc.Score = -c.Score;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif ( c.Score >= GameConfig.TerritoryCaptureScore )\n\t\t\t{\n\t\t\t\tc.Owner = team;\n\t\t\t\tc.Score = GameConfig.TerritoryCaptureScore;\n\t\t\t\t_owned[team]++;\n\t\t\t\tchanged = true;\n\t\t\t\tNeonRenderer.Puff( CellCenter( cx, cy ), team );\n\t\t\t\tGameLog.Info( $\"[territory] team {team + 1} captured cell ({cx},{cy}) \u2014 {_owned[team]}/{GameConfig.TerritoryWinCells}\" );\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tchanged = true;\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// \u654c\u65b9\u683c\uff1a\u5148\u5543\u9a7b\u519b\uff0c\u5543\u5149\u56de\u4e2d\u7acb\uff08\u6ea2\u51fa\u8f6c\u4e3a\u81ea\u5df1\u8fdb\u5ea6\uff0c\u63a5\u7740\u5582\u5c31\u80fd\u987a\u52bf\u5360\u9886\uff09\n\t\t\tc.Score -= pts;\n\t\t\tif ( c.Score <= 0f )\n\t\t\t{\n\t\t\t\t_owned[c.Owner]--;\n\t\t\t\tc.Owner = -1;\n\t\t\t\tc.ScoreTeam = team;\n\t\t\t\tc.Score = -c.Score;\n\t\t\t\tNeonRenderer.Puff( CellCenter( cx, cy ), team );\n\t\t\t\tGameLog.Info( $\"[territory] team {team + 1} neutralized cell ({cx},{cy})\" );\n\t\t\t}\n\t\t\tchanged = true;\n\t\t}\n\n\t\tif ( changed )\n\t\t{\n\t\t\tStateVersion++;\n\t\t\tif ( _conquerTeam < 0 && _owned[team] >= GameConfig.TerritoryWinCells )\n\t\t\t{\n\t\t\t\t_conquerTeam = team;\n\t\t\t\tGameLog.Info( $\"[territory] team {team + 1} reaches {GameConfig.TerritoryWinCells} cells \u2014 conquest!\" );\n\t\t\t}\n\t\t}\n\t\treturn true;\n\t}\n\n\tstatic bool AdjacentToOwned( int cx, int cy, int team )\n\t{\n\t\treturn Owns( cx - 1, cy, team ) || Owns( cx + 1, cy, team )\n\t\t\t|| Owns( cx, cy - 1, team ) || Owns( cx, cy + 1, team );\n\t}\n\n\tstatic bool Owns( int cx, int cy, int team )\n\t{\n\t\tif ( cx < 0 || cy < 0 || cx >= GameConfig.TerritoryGrid || cy >= GameConfig.TerritoryGrid ) return false;\n\t\treturn _cells[cy * GameConfig.TerritoryGrid + cx].Owner == team;\n\t}\n\n\tstatic void Recount()\n\t{\n\t\tfor ( var i = 0; i < _owned.Length; i++ ) _owned[i] = 0;\n\t\tforeach ( var c in _cells )\n\t\t{\n\t\t\tif ( c.Owner >= 0 && c.Owner < _owned.Length ) _owned[c.Owner]++;\n\t\t}\n\t}\n\n\t/// <summary> \u67d0\u961f\u5f53\u524d\u5360\u9886\u683c\u6570\uff08HUD/\u7ed3\u7b97/\u80dc\u5229\u5224\u5b9a\u5171\u7528\uff09 </summary>\n\tpublic static int OwnedCount( int team ) => team >= 0 && team < TeamCount ? _owned[team] : 0;\n\n\t/// <summary> host \u6bcf\u5e27\uff1a\u5360\u65d7\u79ef\u5206\u53d1\u653e\uff08\u7528\u6237\u5b9a\u7a3f\u2014\u2014\u6bcf\u5360\u9886\u4e00\u65d7\u6bcf 3 \u79d2 +1 \u5206\uff1b\u70b9\u6570\u53ea\u5728 host \u7d2f\u79ef\uff0c\u968f\u5feb\u7167\u4e0b\u53d1\uff09 </summary>\n\tpublic static void TickPoints( float dt )\n\t{\n\t\tif ( !Active ) return;\n\t\t_pointTimer += dt;\n\t\tvar step = GameConfig.TerritoryPointSeconds;\n\t\twhile ( _pointTimer >= step )\n\t\t{\n\t\t\t_pointTimer -= step;\n\t\t\tfor ( var t = 0; t < TeamCount; t++ ) _points[t] += _owned[t];\n\t\t}\n\t}\n\n\t/// <summary> \u67d0\u961f\u5f53\u524d\u5360\u65d7\u79ef\u5206\uff08\u80dc\u5229\u5224\u5b9a/HUD/\u7ed3\u7b97\u5171\u7528\uff1b\u5ba2\u6237\u7aef\u7ecf TerritoryState \u5feb\u7167\u540c\u6b65\uff09 </summary>\n\tpublic static float Points( int team ) => team >= 0 && team < TeamCount ? _points[team] : 0f;\n\n\t/// <summary> \u5355\u683c\u72b6\u6001\u8bfb\u53d6\uff08TerritoryRenderer \u6bcf\u6b21\u91cd\u5efa\u9010\u683c\u53d6\uff09 </summary>\n\tpublic static void CellState( int cx, int cy, out int owner, out int scoreTeam, out float score )\n\t{\n\t\tif ( cx < 0 || cy < 0 || cx >= GameConfig.TerritoryGrid || cy >= GameConfig.TerritoryGrid )\n\t\t{\n\t\t\towner = -1;\n\t\t\tscoreTeam = -1;\n\t\t\tscore = 0f;\n\t\t\treturn;\n\t\t}\n\t\tref var c = ref _cells[cy * GameConfig.TerritoryGrid + cx];\n\t\towner = c.Owner;\n\t\tscoreTeam = c.ScoreTeam;\n\t\tscore = c.Score;\n\t}\n\n\t/// <summary> \u8fbe\u6210\u5f81\u670d\u80dc\u5229\u7684\u961f\u4f0d\uff08-1 = \u5c1a\u65e0\uff09\uff1bhost \u68c0\u67e5\u5230\u5373 EndMatch </summary>\n\tpublic static int ConquerWinner => _conquerTeam;\n\n\t// ---- \u7f51\u7edc\u540c\u6b65 ----\n\n\t/// <summary> host\uff1a\u5f53\u524d\u56db\u961f\u5360\u65d7\u79ef\u5206\uff08\u4e0e\u683c\u5b50\u5feb\u7167\u540c\u62cd\u4e0b\u53d1\uff09 </summary>\n\tpublic static float[] PointsWire()\n\t{\n\t\tvar arr = new float[TeamCount];\n\t\tfor ( var t = 0; t < TeamCount; t++ ) arr[t] = _points[t];\n\t\treturn arr;\n\t}\n\n\t/// <summary> host\uff1a\u5f53\u524d 16 \u683c\u72b6\u6001\u5feb\u7167\uff08\u53d1\u51fa\u5373\u8bb0\u5f55\u7248\u672c\uff0cNeedsSync \u5f52\u96f6\uff09 </summary>\n\tpublic static TerritoryWire[] Snapshot()\n\t{\n\t\tvar wires = new TerritoryWire[_cells.Length];\n\t\tfor ( int i = 0; i < _cells.Length; i++ )\n\t\t{\n\t\t\twires[i] = new TerritoryWire\n\t\t\t{\n\t\t\t\tOwner = _cells[i].Owner < 0 ? (byte)255 : (byte)_cells[i].Owner,\n\t\t\t\tScoreTeam = _cells[i].ScoreTeam < 0 ? (byte)255 : (byte)_cells[i].ScoreTeam,\n\t\t\t\tScore = _cells[i].Score,\n\t\t\t};\n\t\t}\n\t\t_sentVersion = StateVersion;\n\t\treturn wires;\n\t}\n\n\t/// <summary> \u5ba2\u6237\u7aef\u5e94\u7528 host \u5feb\u7167\uff08host \u56de\u58f0\u7531 IsAuthority \u6321\u6389\uff1b\u91cd\u590d\u5e94\u7528\u5e42\u7b49\uff09\u3002\n\t/// points = \u56db\u961f\u5360\u65d7\u79ef\u5206\uff08null \u5bb9\u9519\uff1a\u65e7\u5305/\u7a7a\u5305\u53ea\u5237\u683c\u5b50\uff09 </summary>\n\tpublic static void ApplyRemote( TerritoryWire[] wires, float[] points )\n\t{\n\t\tif ( NetworkManager.IsAuthority ) return;\n\t\tif ( wires is null || wires.Length != _cells.Length ) return;\n\n\t\t// \u9010\u9879\u6bd4\u5bf9\uff0c\u5b8c\u5168\u6ca1\u53d8\u5c31\u8df3\u8fc7\u2014\u2014host \u6bcf 2s \u4fdd\u6d3b\u5feb\u7167\u5e26\u76f8\u540c\u6570\u636e\uff0c\n\t\t// \u65e0\u8111 StateVersion++ \u4f1a\u8ba9\u5ba2\u6237\u7aef\u9886\u571f\u51e0\u4f55\u9891\u7e41\u91cd\u5efa\uff08\u95ea\uff0cv0.7.8.86 \u4fee\uff09\n\t\tvar changed = false;\n\t\tfor ( int i = 0; i < _cells.Length; i++ )\n\t\t{\n\t\t\tvar owner = wires[i].Owner == 255 ? -1 : wires[i].Owner;\n\t\t\tvar scoreTeam = wires[i].ScoreTeam == 255 ? -1 : wires[i].ScoreTeam;\n\t\t\tvar score = wires[i].Score;\n\t\t\tref var c = ref _cells[i];\n\t\t\tif ( c.Owner == owner && c.ScoreTeam == scoreTeam && c.Score == score ) continue;\n\t\t\tc.Owner = owner;\n\t\t\tc.ScoreTeam = scoreTeam;\n\t\t\tc.Score = score;\n\t\t\tchanged = true;\n\t\t}\n\t\tif ( points is not null )\n\t\t{\n\t\t\tfor ( var t = 0; t < TeamCount && t < points.Length; t++ )\n\t\t\t{\n\t\t\t\tif ( _points[t] != points[t] )\n\t\t\t\t{\n\t\t\t\t\t_points[t] = points[t];\n\t\t\t\t\tchanged = true;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif ( !changed ) return;\n\t\tRecount();\n\t\tStateVersion++;\n\t}\n}\n\n/// <summary> \u9886\u571f\u683c\u7f51\u7edc\u5feb\u7167\u7ebf\u683c\u5f0f\uff08255 = \u4e2d\u7acb/\u65e0\u5f52\u5c5e\uff1b\u4e0e CellWire/ScoreWire \u540c\u9876\u5c42\u60ef\u4f8b\uff09 </summary>\npublic struct TerritoryWire\n{\n\tpublic byte Owner;\n\tpublic byte ScoreTeam;\n\tpublic float Score;\n}"
        },
        {
            "Ident": "slfy.circleroyale",
            "Path": "UI/LobbyPanel.cs",
            "FileName": "LobbyPanel.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 380090,
            "IsPrivate": false,
            "Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox.UI;\n\n/// <summary>\n/// \u6e38\u620f\u623f\u95f4/\u5927\u5385\uff08M5 \u4e8c\u7ea7\u754c\u9762\uff09\uff1aHOST / VS BOTS \u70b9\u5f00\u540e\u8fdb\u5165\u2014\u2014host \u53ef\u8c03\u6bd4\u8d5b\u53c2\u6570\u3001\u770b\u73a9\u5bb6\u5217\u8868\u3001\n/// \u70b9 START \u5f00\u5c40\uff1b\u5ba2\u6237\u7aef JOIN \u8fde\u4e0a\u540e\u8fdb\u540c\u4e00\u754c\u9762\uff0c\u53c2\u6570\u4e0e\u73a9\u5bb6\u5217\u8868\u7531 host \u7ecf LobbyState RPC \u63a8\u9001\uff0c\n/// \u663e\u793a\"\u7b49\u5f85\u623f\u4e3b\u5f00\u59cb\"\uff0c\u623f\u4e3b\u5f00\u5c40\u540e\u7ecf MatchStarted RPC \u81ea\u52a8\u8fdb\u6e38\u620f\u3002\n/// \u4e3b\u83dc\u5355\u7684\u8bbe\u7f6e\u533a\u5df2\u79fb\u5230\u8fd9\u91cc\uff08\u623f\u95f4\u624d\u662f\u8bbe\u7f6e\u53c2\u6570\u7684\u5730\u65b9\uff09\u3002\n/// \u7eaf C# Panel + \u540c\u76ee\u5f55 LobbyPanel.cs.scss\u3002\n/// </summary>\npublic sealed class LobbyPanel : PanelComponent\n{\n\tLabel _status;\n\tLabel[] _settings;       // 0=\u6a21\u5f0f 1=\u65f6\u957f 2=\u4eba\u6570\uff08host \u53ef\u70b9\u51fb\u5faa\u73af\uff0cclient \u53ea\u8bfb\u5c55\u793a\uff09\n\tLabel _settingHint;      // \"CLICK A SETTING TO CYCLE IT\"\uff08\u4ec5 host\uff0cv0.7.8.0\uff09\n\tLabel[] _playerRows;     // \u73a9\u5bb6\u5217\u8868\uff1a\u5355\u5217\uff08\u6eda\u52a8\u5bb9\u5668\u5185\uff0c\u5bb9\u91cf = MaxPlayerRows\uff09\n\tPanel _playersScroll;    // \u73a9\u5bb6\u5217\u8868\u539f\u751f\u6eda\u52a8\u5bb9\u5668\uff08overflow-y + \u6eda\u8f6e\uff09\n\tLabel _botsNote;\n\tLabel _start;\n\tLabel _leave;\n\tLabel _join;             // \u5ba2\u6237\u7aef\"JOIN GAME\"\u6309\u94ae\uff1a\u5bf9\u5c40\u8fdb\u884c\u4e2d\u65f6\u663e\u793a\uff0c\u70b9\u51fb\u540e 3-2-1 \u8fdb\u5165\uff08v0.6.4.1\uff09\n\tLabel _countdown;        // \u52a0\u5165\u5012\u8ba1\u65f6\uff08\u5ba2\u6237\u7aef\u6536\u5230 host \u5f00\u5c40\u6307\u4ee4\u540e 3-2-1\uff0cv0.6.4.0\uff09\n\tbool _matchRunning;      // \u5ba2\u6237\u7aef\u89c6\u89d2\uff1ahost \u7684\u5bf9\u5c40\u8fdb\u884c\u4e2d\uff08JOIN GAME \u6309\u94ae\u4eae\u8d77\uff09\n\tbool _modeGlowActive;    // \u6a21\u5f0f\u884c\u547c\u5438\u8272\u6fc0\u6d3b\u4e2d\uff08\u5207\u56de\u5ba2\u6237\u7aef\u8eab\u4efd\u65f6\u6062\u590d scss \u57fa\u8272\u4e00\u6b21\uff09\n\n\tbool _built;\n\tbool _retryOpen;         // Panel \u672a\u5c31\u7eea\u65f6\u7684\u5f00\u95e8\u91cd\u8bd5\uff08EnsureWorld \u4f1a\u5148\u4e8e OnStart \u8c03\u7528 ShowClient/ShowHost\uff09\n\tbool _isHost;\n\tbool _shown;\n\tbool _connectionLost;    // \u6389\u7ebf\u91cd\u8fde\u4e2d\uff1a\u72b6\u6001\u884c\u9501\u5b9a\u63d0\u793a\uff08\u5426\u5219\u6bcf\u5e27 RefreshStatus \u4f1a\u8986\u76d6\u6389\uff09\n\tTimeSince _sinceListRefresh;\n\n\t// \u5ba2\u6237\u7aef\uff1a\u623f\u4e3b\u63a8\u9001\u7684\u623f\u95f4\u4fe1\u606f\uff08LobbyState RPC\uff09\n\tbyte _clientMode;\n\tint _clientDuration;\n\tint _clientPlayers;\n\tList<string> _clientNames = new List<string>();\n\n\tconst int MaxPlayerRows = 64;   // = MaxPlayers\uff08sbproj\uff09\uff0c\u6eda\u52a8\u5217\u8868\u88c5\u5f97\u4e0b\u6ee1\u623f\n\n\t// \u6392\u7248\u9884\u89c8\uff082026-09-09 \u7528\u6237\u8c03\u6837\u5f0f\u7528\uff09\uff1a\u5217\u8868\u663e\u793a\u4e00\u6279\u5047\u73a9\u5bb6\u540d\u3002\u5df2\u8c03\u5b8c\u5173\u95ed\uff1b\u4e0b\u6b21\u9884\u89c8\u518d\u6539 true\n\t// \uff08static readonly \u4e0d\u7528 const\uff1aconst \u7684 true/false \u90fd\u4f1a\u628a\u5206\u652f\u5224\u6210\u4e0d\u53ef\u8fbe\u4ee3\u7801\uff0c\u62a5 CS0162\uff09\n\tstatic readonly bool ShowSamplePlayers = false;\n\tstatic readonly string[] SamplePlayers =\n\t{\n\t\t\"FLYBIRD\", \"KITTEN_92\", \"\u5c0f\ufffd\ufffd\ufffd\u9e1f\", \"ProEater\", \"SNACKLORD\", \"\u6c64\u5706\", \"Wobble\", \"XIAO_LONG\",\n\t\t\"Mochi\", \"\u5927\u7403\u738b\", \"pixel_cat\", \"Doodle\", \"\u5e03\u4e01\", \"GULP\", \"\u6ce5\u9cc5\", \"Bubbles\",\n\t\t\"\u963f\u7fd4\", \"Tofu\", \"MUNCHER\", \"\u4e91\u6735\",\n\t};\n\n\tprotected override void OnStart()\n\t{\n\t\tbase.OnStart();\n\t\tBuildUi();\n\t\tHide();\n\t}\n\n\tvoid BuildUi()\n\t{\n\t\tvar root = Panel;\n\t\tif ( root is null ) return;\n\t\tif ( _built && _status.IsValid() && root.ChildrenCount > 0 ) return;\n\n\t\troot.DeleteChildren( true );\n\n\t\troot.Style.Position = PositionMode.Absolute;\n\t\troot.Style.Left = 0f;\n\t\troot.Style.Top = 0f;\n\t\troot.Style.Right = 0f;\n\t\troot.Style.Bottom = 0f;\n\t\troot.Style.BackgroundColor = new Color( 0.918f, 0.957f, 0.988f, 0.82f );   // \u6d45\u84dd\u78e8\u7802\uff08\u50cf\u7d20\u98ce\u5b9a\u7a3f\uff1b\u65e7\u6df1\u7070\u906e\u7f69\u5728\u6d45\u8272\u4e16\u754c\u4e0a\u4e00\u5768\u810f\u7070\uff0c\u7528\u6237\u5acc\u4e11\uff09\n\t\troot.Style.PointerEvents = PointerEvents.All;\n\n\t\tUiKit.AttachStyles( root );   // \u5171\u4eab\u90e8\u4ef6\u6837\u5f0f\uff08.cr-pill \u836f\u4e38\u6309\u94ae\uff09\n\n\t\tvar title = new Label() { Classes = \"lp-title\" };\n\t\ttitle.Text = \"GAME LOBBY\";\n\t\troot.AddChild( title );\n\n\t\t_status = new Label() { Classes = \"lp-status\" };\n\t\troot.AddChild( _status );\n\n\t\tvar settingsHead = new Label() { Classes = \"lp-head settings\" };\n\t\tsettingsHead.Text = \"MATCH SETTINGS\";\n\t\troot.AddChild( settingsHead );\n\n\t\t// \u8bbe\u7f6e\u724c\u7eb5\u6392\uff08\u53c2\u8003\u56fe\uff1a\u53f3\u680f\u4e09\u5f20\u6a2a\u724c\u4ece\u4e0a\u5230\u4e0b\uff09\uff0chost \u53ef\u70b9\u51fb\u5faa\u73af\uff0cclient \u53ea\u8bfb\u5c55\u793a\n\t\t_settings = new Label[3];\n\t\tfor ( int i = 0; i < _settings.Length; i++ )\n\t\t{\n\t\t\tvar l = new Label() { Classes = \"lp-setting\" };\n\t\t\tl.Style.Top = 330f + i * 88f;   // \u2605 \u8bbe\u7f6e\u724c\u8d77\u70b9 + \u884c\u8ddd\uff08\u7cbe\u4fee\u6539\u8fd9\u91cc\uff09\n\t\t\tint index = i;\n\t\t\tl.AddEventListener( \"onclick\", () => CycleSetting( index ) );\n\t\t\troot.AddChild( l );\n\t\t\t_settings[i] = l;\n\t\t}\n\n\t\t// \u8bbe\u7f6e\u53ef\u70b9\u63d0\u793a\uff08v0.7.8.0\uff09\uff1a\u4ec5 host \u53ef\u89c1\u2014\u2014\u914d\u5408\u6a21\u5f0f\u884c\u547c\u5438\u91d1\u5149\uff0c\u8ba9\u73a9\u5bb6\u77e5\u9053\u80fd\u6539\u6a21\u5f0f\n\t\t_settingHint = new Label() { Classes = \"lp-setting-hint\" };\n\t\t_settingHint.Text = \"CLICK A SETTING TO CYCLE IT\";\n\t\troot.AddChild( _settingHint );\n\n\t\t// \u9053\u5177\u56fe\u4f8b\u5df2\u6309\u7528\u6237\u8981\u6c42\u6574\u4e2a\u79fb\u9664\uff082026-09-09\uff09\u2014\u2014Q/E \u7528\u6cd5\u5f00\u5c40\u540e HUD \u91cc\u8fd8\u6709\u63d0\u793a\n\n\t\tvar playersHead = new Label() { Classes = \"lp-head players\" };\n\t\tplayersHead.Text = \"PLAYERS\";\n\t\troot.AddChild( playersHead );\n\n\t\t// \u73a9\u5bb6\u5217\u8868\u5bb9\u5668\u5e95\uff08dobou InventoryHotbar \u4e5d\u5bab\u683c\uff0c\u7528\u6237\u6307\u5b9a 2026-09-09\uff09\uff1a\n\t\t// \u5148\u4e8e\u884c\u724c\u521b\u5efa\u538b\u5728\u5e95\u5c42\uff1b\u7a7a\u5217\u8868\u65f6\u4e5f\u662f\u4e00\u4e2a\u6709\u6846\u7684\u6574\u6d01\u9762\u677f\uff0c\u4e0d\u518d\u662f\u88f8\u7559\u767d\n\t\tvar playersBg = new Panel() { Classes = \"lp-players-bg\" };\n\t\troot.AddChild( playersBg );\n\n\t\t// \u73a9\u5bb6\u5217\u8868\uff1a\u5355\u5217\u6d41\u5185\u6392\u5217\u88c5\u8fdb\u539f\u751f\u6eda\u52a8\u5bb9\u5668\uff08overflow-y + \u6eda\u8f6e\uff0c\u7f51\u9875\u5f0f\u6eda\u52a8\u6761\uff09\uff0c\n\t\t// \u5bb9\u91cf\u63d0\u5230 64\uff08= MaxPlayers\uff09\uff1bhotbar \u5e95\u6846\u53ea\u662f\u88c5\u9970\uff0c\u6eda\u52a8\u53d1\u751f\u5728\u5b83\u4e0a\u9762\u7684\u900f\u660e\u5bb9\u5668\u91cc\n\t\t_playersScroll = new ScrollSoundPanel() { Classes = \"lp-players-scroll\" };\n\t\troot.AddChild( _playersScroll );\n\n\t\t_playerRows = new Label[MaxPlayerRows];\n\t\tfor ( int i = 0; i < _playerRows.Length; i++ )\n\t\t{\n\t\t\tvar row = new Label() { Classes = \"lp-player\" };\n\t\t\t_playersScroll.AddChild( row );\n\t\t\t_playerRows[i] = row;\n\t\t}\n\n\t\t_botsNote = new Label() { Classes = \"lp-note\" };\n\t\t_botsNote.Text = \"BOTS FILL REMAINING SLOTS AT START\";\n\t\troot.AddChild( _botsNote );\n\n\t\t// \u52a0\u5165\u5012\u8ba1\u65f6\uff08\u5ba2\u6237\u7aef\uff09\uff1a\u6536\u5230 host \u5f00\u5c40\u6307\u4ee4\u540e\u663e\u793a 3-2-1\n\t\t_countdown = new Label() { Classes = \"lp-countdown\" };\n\t\t_countdown.Style.Display = DisplayMode.None;\n\t\troot.AddChild( _countdown );\n\n\t\t// \u4e09\u4e2a\u6309\u94ae\u5168\u8d70 UiKit \u836f\u4e38\uff08\u5916\u89c2\u5728 Assets/ui/cruikit.scss \u7684 .cr-pill\uff09\uff0c\u8fd9\u91cc\u53ea\u7ba1\u6446\u653e\u7c7b\n\t\t_join = UiKit.PillButton( root, \"JOIN GAME\", \"lp-join\", () => OnJoinClicked() );\n\n\t\t_start = UiKit.PillButton( root, \"START GAME\", \"lp-start\", () => OnStartClicked() );\n\n\t\t_leave = UiKit.PillButton( root, \"LEAVE\", \"lp-leave\", () => OnLeaveClicked() );\n\n\t\t_built = true;\n\t}\n\n\t/// <summary> host \u8fdb\u5165\u623f\u95f4\uff08HOST / VS BOTS \u5f00\u623f\u540e\u8c03\u7528\uff09 </summary>\n\tpublic void ShowHost()\n\t{\n\t\t_isHost = true;\n\t\tOpen();\n\t}\n\n\t/// <summary> \u5ba2\u6237\u7aef\u8fde\u5165\u540e\u8fdb\u5165\u623f\u95f4\uff08\u7b49\u5f85\u623f\u4e3b\u5f00\u5c40\uff1b\u5217\u8868\u7b49 LobbyState \u63a8\u9001\uff09 </summary>\n\tpublic void ShowClient()\n\t{\n\t\t_isHost = false;\n\t\tOpen();\n\t}\n\n\tvoid Open()\n\t{\n\t\tBuildUi();\n\t\tif ( !_built )\n\t\t{\n\t\t\t_retryOpen = true;   // Panel \u672a\u5c31\u7eea\uff08EnsureWorld \u7acb\u5373\u8c03 ShowClient/ShowHost \u65f6\uff09\uff0cOnUpdate \u91cd\u8bd5\n\t\t\treturn;\n\t\t}\n\n\t\t// \u9762\u677f\u88ab\u9500\u6bc1\u4f46 _built \u8fd8\u6b8b\u7559 true\uff08\u4e16\u754c\u91cd\u5efa/\u7ec4\u4ef6\u7981\u7528\u540e\uff09\u2014\u2014\u91cd\u5efa\uff0c\u522b\u78b0 null Panel\uff1a\n\t\t// \u6bcf\u5e27\u5f02\u5e38\u4f1a\u6f0f\u5f15\u64ce\"\u6bcf\u5e27\u5185\u5b58\u6808\"\uff0c\u51e0\u5206\u949f\u540e\u5d29\u7f16\u8f91\u5668\uff08\u5b9e\u6d4b\u6700\u8d35\u7684\u4e00\u8bfe\uff09\n\t\tif ( Panel is null || !Panel.IsValid() )\n\t\t{\n\t\t\t_built = false;\n\t\t\t_retryOpen = true;\n\t\t\treturn;\n\t\t}\n\n\t\t_retryOpen = false;\n\t\tPanel.Style.Display = DisplayMode.Flex;\n\t\tif ( _shown )\n\t\t{\n\t\t\t// \u5df2\u663e\u793a\uff1a\u4e0d\u91cd\u7f6e\u72b6\u6001\uff08ReconcileUi \u6bcf\u5e27\u8c03\uff0cJOIN \u6309\u94ae/\u5012\u8ba1\u65f6\u4e0d\u80fd\u88ab\u6e05\u6389\uff09\u3002\n\t\t\t// \u4f46\u72b6\u6001\u884c\u8981\u6062\u590d\u2014\u2014\u6389\u7ebf\u91cd\u8fde\u6210\u529f\u56de\u5230\u623f\u95f4\u65f6\uff0c\"CONNECTION LOST\" \u4e0d\u80fd\u4e00\u76f4\u6302\u7740\n\t\t\tif ( !_isHost ) RefreshStatus();\n\t\t\treturn;\n\t\t}\n\t\t_shown = true;\n\t\t_matchRunning = false;   // \u6bcf\u6b21\u8fdb\u623f\u95f4\u91cd\u7f6e\uff08MatchStarted \u5230\u8fbe\u540e\u518d\u4eae JOIN \u6309\u94ae\uff09\n\t\tHideJoinCountdown();\n\t\t_sinceListRefresh = 1f;   // \u9996\u5e27\u7acb\u5373\u5237\n\t\tRefreshAll();\n\t}\n\n\t/// <summary> \u6536\u8d77\uff08\u5f00\u5c40 / \u56de\u83dc\u5355\uff09 </summary>\n\tpublic void Hide()\n\t{\n\t\t_shown = false;\n\t\t_retryOpen = false;\n\t\tif ( Panel.IsValid() ) Panel.Style.Display = DisplayMode.None;\n\t}\n\n\t/// <summary> \u5ba2\u6237\u7aef\u5e94\u7528\u623f\u4e3b\u63a8\u9001\u7684\u623f\u95f4\u4fe1\u606f\uff08CircleroyaleGame.OnLobbyStateRemote \u8f6c\u53d1\uff09 </summary>\n\tpublic void UpdateFromHost( byte mode, int duration, int players, string[] names )\n\t{\n\t\t_connectionLost = false;   // host \u6709\u6d88\u606f = \u8fde\u63a5\u6d3b\u7740\uff0c\u89e3\u9664\u6389\u7ebf\u63d0\u793a\n\t\t_clientMode = mode;\n\t\t_clientDuration = duration;\n\t\t_clientPlayers = players;\n\t\t_clientNames = names is not null ? new List<string>( names ) : new List<string>();\n\n\t\tif ( _shown && !_isHost )\n\t\t{\n\t\t\tRefreshSettings();\n\t\t\tRefreshPlayers();\n\t\t\tRefreshButtons();   // \u6389\u7ebf\u6062\u590d\u540e\u628a\u5e38\u9a7b\u6309\u94ae\u6446\u56de\u6765\uff08WAITING/JOIN \u6587\u6848\u4e5f\u9760\u5b83\u5237\uff09\n\t\t}\n\t}\n\n\t/// <summary> \u663e\u793a\u52a0\u5165\u5012\u8ba1\u65f6\uff08CircleroyaleGame \u6bcf\u5e27\u8c03\u7528\uff09 </summary>\n\tpublic void ShowJoinCountdown( float secondsLeft )\n\t{\n\t\tif ( _countdown is null || !_countdown.IsValid() ) return;\n\n\t\t_countdown.Style.Display = DisplayMode.Flex;\n\t\t_countdown.Text = $\"JOINING MATCH IN {MathF.Ceiling( MathF.Max( 0f, secondsLeft ) ):0}\";\n\n\t\t// \u5012\u8ba1\u65f6\u671f\u95f4\u6536\u8d77 JOIN \u6309\u94ae\n\t\tif ( _join.IsValid() ) _join.Style.Display = DisplayMode.None;\n\t}\n\n\t/// <summary> \u6536\u8d77\u52a0\u5165\u5012\u8ba1\u65f6 </summary>\n\tpublic void HideJoinCountdown()\n\t{\n\t\tif ( _countdown.IsValid() ) _countdown.Style.Display = DisplayMode.None;\n\t}\n\n\t/// <summary> \u5ba2\u6237\u7aef\u89c6\u89d2\uff1ahost \u7684\u5bf9\u5c40\u8fdb\u884c\u4e2d\uff08\u623f\u95f4\u9875\u4eae\u8d77 JOIN GAME \u6309\u94ae\uff09 </summary>\n\tpublic void SetMatchRunning()\n\t{\n\t\t_matchRunning = true;\n\t\tRefreshButtons();\n\n\t\tSetText( _status, \"MATCH IN PROGRESS \u2014 JOIN NOW\" );\n\t}\n\n\t/// <summary> \u6587\u672c\u9632\u6296\uff08v0.7.1.2\uff09\uff1a\u503c\u6ca1\u53d8\u5c31\u4e0d\u8d4b\u2014\u2014Label.Text \u6bcf\u5e27\u91cd\u8bbe\uff08\u54ea\u6015\u540c\u503c\uff09\u4f1a\u89e6\u53d1\u5e03\u5c40\n\t/// \u91cd\u7b97\uff1bReconcileUi \u6bcf\u5e27\u8c03 ShowClient\u2192RefreshStatus \u628a\u72b6\u6001\u884c\u53cd\u590d\u91cd\u5237 = UI \u4e00\u95ea\u4e00\u95ea\uff08\u5b9e\u6d4b\uff09 </summary>\n\tstatic void SetText( Label l, string text )\n\t{\n\t\tif ( !l.IsValid() || l.Text == text ) return;\n\t\tl.Text = text;\n\t}\n\n\t/// <summary> \u6389\u7ebf\u81ea\u52a8\u91cd\u8fde\u4e2d\uff08CircleroyaleGame.OnDisconnectRecoveryStarted\uff09\uff1a\u72b6\u6001\u884c\u63d0\u793a + \u6536\u6389 JOIN \u6309\u94ae </summary>\n\tpublic void SetConnectionLost()\n\t{\n\t\t_connectionLost = true;\n\t\tSetText( _status, \"CONNECTION LOST \u2014 RECONNECTING ...\" );\n\t\tif ( _join.IsValid() ) _join.Style.Display = DisplayMode.None;\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tbase.OnUpdate();\n\n\t\tBuildUi();\n\n\t\t// EnsureWorld \u5148\u4e8e OnStart \u8c03\u8fc7 ShowClient/ShowHost \u4e14 Panel \u672a\u5c31\u7eea \u2192 \u6b64\u523b\u8865\u5f00\n\t\tif ( _retryOpen && !_shown )\n\t\t{\n\t\t\tOpen();\n\t\t\treturn;\n\t\t}\n\n\t\tif ( !_shown ) return;\n\n\t\tif ( Panel.IsValid() && Panel.Style.Display != DisplayMode.Flex ) Panel.Style.Display = DisplayMode.Flex;   // \u540c\u503c\u5b88\u536b\uff08v0.7.8.87\uff09\n\t\tMouse.Visibility = MouseVisibility.Visible;   // \u6309\u94ae/\u8bbe\u7f6e\u9879\u8981\u80fd\u70b9\n\n\t\t// host\uff1a\u73a9\u5bb6\u8fdb\u51fa\u3001\u8bbe\u7f6e\u53d8\u5316\u672c\u5730\u5c31\u80fd\u770b\u5230\uff0c0.5s \u8f6e\u5237\uff1bclient \u5217\u8868\u9760 LobbyState \u63a8\u9001\n\t\tif ( _isHost && _sinceListRefresh > 0.5f )\n\t\t{\n\t\t\t_sinceListRefresh = 0;\n\t\t\tRefreshPlayers();\n\t\t\tRefreshStatus();\n\t\t}\n\n\t\t// \u6a21\u5f0f\u884c\u547c\u5438\u91d1\u5149\uff08v0.7.8.0\uff0c\u4ec5 host\uff09\uff1a\u8bbe\u7f6e\u884c\u662f\u53ef\u70b9\u7684\uff0c\u7528\u5149\u6548\u5f15\u5bfc\u73a9\u5bb6\u53bb\u6539\u6a21\u5f0f\u3002\n\t\t// \u989c\u8272\u52a8\u753b\u6bcf\u5e27\u5728\u53d8\uff08Sin \u8fde\u7eed\uff09\uff0c\u4e0d\u78b0 Text \u4e0d\u89e6\u53d1\u5e03\u5c40\u91cd\u7b97\uff1b\u5ba2\u6237\u7aef\u6062\u590d\u9759\u6001\u57fa\u8272\n\t\tif ( _settings is not null && _settings[0].IsValid() )\n\t\t{\n\t\t\tif ( _isHost )\n\t\t\t{\n\t\t\t\tvar glow = 0.72f + 0.28f * MathF.Sin( Time.Now * 3.2f );\n\t\t\t\t_settings[0].Style.FontColor = new Color( 1f * glow, 0.76f * glow, 0.30f * glow );\n\t\t\t\t_modeGlowActive = true;\n\t\t\t}\n\t\t\telse if ( _modeGlowActive )\n\t\t\t{\n\t\t\t\t_settings[0].Style.FontColor = new Color( 0.29f, 0.23f, 0.39f );   // scss .lp-setting \u57fa\u8272\uff08\u6885\u7d2b #4a3b63\uff09\n\t\t\t\t_modeGlowActive = false;\n\t\t\t}\n\t\t}\n\t}\n\n\tvoid RefreshAll()\n\t{\n\t\tRefreshSettings();\n\t\tRefreshPlayers();\n\t\tRefreshStatus();\n\t\tRefreshButtons();\n\t}\n\n\tvoid RefreshStatus()\n\t{\n\t\tif ( _isHost )\n\t\t{\n\t\t\tvar names = CircleroyaleGame.Current?.LobbyPlayerNames();\n\t\t\tint n = names?.Count ?? 1;\n\t\t\tSetText( _status, $\"ROOM OPEN \u2014 {n} PLAYER{( n > 1 ? \"S\" : \"\" )} IN LOBBY\" );\n\t\t}\n\t\telse if ( _connectionLost )\n\t\t{\n\t\t\tSetText( _status, \"CONNECTION LOST \u2014 RECONNECTING ...\" );\n\t\t}\n\t\telse\n\t\t{\n\t\t\tSetText( _status, _matchRunning ? \"MATCH IN PROGRESS \u2014 JOIN NOW\" : \"WAITING FOR HOST TO START ...\" );\n\t\t}\n\t}\n\n\tvoid RefreshSettings()\n\t{\n\t\tif ( _settings is null || _settings[0] is null || !_settings[0].IsValid() ) return;\n\n\t\tbyte mode;\n\t\tfloat duration;\n\t\tint players;\n\n\t\tif ( _isHost )\n\t\t{\n\t\t\tmode = (byte)MatchState.PendingMode;\n\t\t\tduration = MatchState.PendingDuration;\n\t\t\tplayers = MatchState.PendingPlayerTarget;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tmode = _clientMode;\n\t\t\tduration = _clientDuration;\n\t\t\tplayers = _clientPlayers;\n\t\t}\n\n\t\tSetText( _settings[0], (MatchState.Mode)mode switch\n\t\t{\n\t\t\tMatchState.Mode.Ffa => \"MODE \u2014 FREE FOR ALL\",\n\t\t\tMatchState.Mode.Team2 => \"MODE \u2014 TEAM (2 / TEAM)\",\n\t\t\tMatchState.Mode.Team3 => \"MODE \u2014 TEAM (3 / TEAM)\",\n\t\t\tMatchState.Mode.Territory => \"MODE \u2014 TERRITORY WAR\",\n\t\t\t_ => \"MODE \u2014 TEAM (4 / TEAM)\",\n\t\t} );\n\n\t\tSetText( _settings[1], duration < 60f\n\t\t\t? $\"TIME \u2014 {duration:0} SEC\"\n\t\t\t: $\"TIME \u2014 {duration / 60f:0} MIN\" );\n\n\t\t// \u9886\u571f\u6a21\u5f0f\u4eba\u6570\u56fa\u5b9a 4 \u961f \u00d7 4 \u4eba\uff08host Apply \u5f3a\u5236 16\uff09\uff0c\u83dc\u5355\u9009\u7684 PLAYERS \u65e0\u6548\u2014\u2014\u660e\u793a\u907f\u514d\u56f0\u60d1\n\t\tSetText( _settings[2], (MatchState.Mode)mode == MatchState.Mode.Territory ? \"PLAYERS \u2014 16 (4 TEAMS)\" : $\"PLAYERS \u2014 {players}\" );\n\t}\n\n\tvoid RefreshPlayers()\n\t{\n\t\tif ( _playerRows is null || _playerRows[0] is null || !_playerRows[0].IsValid() ) return;\n\n\t\tList<string> names;\n\t\tif ( _isHost )\n\t\t{\n\t\t\tnames = CircleroyaleGame.Current?.LobbyPlayerNames() ?? new List<string>();\n\t\t}\n\t\telse\n\t\t{\n\t\t\tnames = _clientNames;\n\t\t}\n\n\t\tif ( ShowSamplePlayers )\n\t\t\tnames = new List<string>( SamplePlayers );   // \u6392\u7248\u9884\u89c8\uff1a\u5047\u540d\u66ff\u4ee3\u771f\u5b9e\u5217\u8868\uff08\u770b\u5b8c\u5173\u6389\uff09\n\n\t\tif ( names.Count == 0 ) names = new List<string> { \"...\" };\n\n\t\tfor ( int i = 0; i < _playerRows.Length; i++ )\n\t\t{\n\t\t\tvar row = _playerRows[i];\n\t\t\tif ( !row.IsValid() ) continue;\n\n\t\t\tif ( i >= names.Count )\n\t\t\t{\n\t\t\t\tSetText( row, \"\" );\n\t\t\t\tGameHud.SetDisplay( row, DisplayMode.None );   // \u7a7a\u4f4d\u4e0d\u753b\u836f\u7247\u6761\uff08\u6ee1\u5c4f\u7a7a\u6761\u5f88\u96be\u770b\uff0c\u5b9e\u6d4b\u622a\u56fe\uff09\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tGameHud.SetDisplay( row, DisplayMode.Flex );\n\t\t\tvar tag = i == 0 ? \"  (HOST)\" : \"\";\n\t\t\tSetText( row, $\"{i + 1}.  {names[i]}{tag}\" );\n\t\t\t// \u540c\u503c\u5b88\u536b\uff1a\u5ba2\u6237\u7aef\u6bcf\u6536\u5230\u4e00\u6b21 LobbyState \u5fc3\u8df3\uff083s\uff09\u5c31\u5168\u91cf\u91cd\u5237\u672c\u5217\u8868\uff0c\n\t\t\t// \u88f8\u5199 Display/FontColor = \u6bcf 3 \u79d2 64 \u884c\u5e03\u5c40\u91cd\u7b97 = \u5ba2\u6237\u7aef\u5927\u5385\u5468\u671f\u6027\u7206\u95ea\uff08\u5b9e\u6d4b\uff09\n\t\t\tvar c = i == 0 ? new Color( 0.898f, 0.282f, 0.553f ) : new Color( 0.29f, 0.23f, 0.39f, 0.85f );\n\t\t\tif ( row.Style.FontColor != c ) row.Style.FontColor = c;\n\t\t}\n\t}\n\n\tvoid RefreshButtons()\n\t{\n\t\t// host \u6709 START\uff1b\u5ba2\u6237\u7aef\u7b2c\u4e8c\u9897\u6309\u94ae\u5e38\u9a7b\uff08\u7528\u6237\u9700\u6c42 2026-09-09\uff09\uff1a\u672a\u5f00\u5c40\u663e\u793a WAITING FOR HOST\n\t\t// \uff08\u70b9\u4e86\u4e0d\u54cd\u5e94\uff09\uff0c\u5f00\u5c40\u4eae\u6210 JOIN GAME\u3002LEAVE \u4e24\u8fb9\u90fd\u6709\u3002Display \u540c\u503c\u5b88\u536b\uff08\u968f RefreshAll \u6bcf 3s/0.5s \u5168\u91cf\u91cd\u5237\uff09\n\t\tif ( _start.IsValid() )\n\t\t\tGameHud.SetDisplay( _start, _isHost ? DisplayMode.Flex : DisplayMode.None );\n\n\t\tif ( _join.IsValid() )\n\t\t{\n\t\t\tGameHud.SetDisplay( _join, !_isHost ? DisplayMode.Flex : DisplayMode.None );\n\t\t\tSetText( _join, _matchRunning ? \"JOIN GAME\" : \"WAITING FOR HOST\" );\n\t\t}\n\n\t\t// \u8bbe\u7f6e\u53ef\u70b9\u63d0\u793a\uff08v0.7.8.0\uff09\uff1a\u53ea\u6709 host \u80fd\u6539\u8bbe\u7f6e\uff0c\u63d0\u793a\u4e5f\u53ea\u7ed9 host\n\t\tif ( _settingHint.IsValid() )\n\t\t\tGameHud.SetDisplay( _settingHint, _isHost ? DisplayMode.Flex : DisplayMode.None );\n\t}\n\n\t/// <summary> JOIN GAME \u70b9\u51fb\uff08\u5b9e\u4f8b\u65b9\u6cd5\uff1a\u70ed\u91cd\u8f7d\u53ef\u91cd\u6620\u5c04\uff09\u3002WAITING FOR HOST \u72b6\u6001\u70b9\u4e86\u4e0d\u54cd\u5e94 </summary>\n\tvoid OnJoinClicked()\n\t{\n\t\tif ( !_matchRunning ) return;\n\t\tCircleroyaleGame.Current?.ClientJoinMatch();\n\t}\n\n\t/// <summary> START \u70b9\u51fb </summary>\n\tvoid OnStartClicked() => CircleroyaleGame.Current?.StartMatchFromLobby();\n\n\t/// <summary> LEAVE \u70b9\u51fb </summary>\n\tvoid OnLeaveClicked() => CircleroyaleGame.Current?.ResetToMenu();\n\n\t// ---- host \u8c03\u53c2\uff08\u4e0e\u4e3b\u83dc\u5355\u65f6\u4ee3\u7684\u5faa\u73af\u903b\u8f91\u4e00\u81f4\uff09----\n\n\tvoid CycleSetting( int index )\n\t{\n\t\tif ( !_isHost ) return;\n\n\t\tGameSfx.UiClick();   // \u70b9\u4e86\u771f\u4f1a\u6539\u53c2\u6570\u624d\u54cd\uff08client \u53ea\u8bfb\uff0c\u70b9\u4e86\u4e0d\u54cd\uff09\n\n\t\tswitch ( index )\n\t\t{\n\t\t\tcase 0:\n\t\t\t\t// \u6a21\u5f0f 5 \u6863\u5faa\u73af\uff08v0.7.8.34\uff09\uff1a\u666e\u901a\u8d5b \u2192 2 \u4eba\u961f \u2192 3 \u4eba\u961f \u2192 4 \u4eba\u961f \u2192 \u9886\u571f\u6218\u4e89\n\t\t\t\tMatchState.PendingMode = (MatchState.Mode) ( ( (int)MatchState.PendingMode + 1 ) % 5 );\n\t\t\t\tbreak;\n\n\t\t\tcase 1:\n\t\t\t\tMatchState.PendingDuration = NextChoice( GameConfig.MatchDurationChoices, MatchState.PendingDuration );\n\t\t\t\tbreak;\n\n\t\t\tcase 2:\n\t\t\t\tMatchState.PendingPlayerTarget = NextIntChoice( GameConfig.PlayerTargetChoices, MatchState.PendingPlayerTarget );\n\t\t\t\tbreak;\n\t\t}\n\n\t\tRefreshSettings();\n\t\tCircleroyaleGame.Current?.MarkLobbyDirty();   // \u8ba9\u7b49\u5f85\u4e2d\u7684\u5ba2\u6237\u7aef\u770b\u5230\u65b0\u53c2\u6570\n\t}\n\n\tstatic float NextChoice( float[] choices, float current )\n\t{\n\t\tfor ( int i = 0; i < choices.Length; i++ )\n\t\t{\n\t\t\tif ( MathF.Abs( choices[i] - current ) < 0.5f )\n\t\t\t\treturn choices[( i + 1 ) % choices.Length];\n\t\t}\n\t\treturn choices[0];\n\t}\n\n\tstatic int NextIntChoice( int[] choices, int current )\n\t{\n\t\tfor ( int i = 0; i < choices.Length; i++ )\n\t\t{\n\t\t\tif ( choices[i] == current )\n\t\t\t\treturn choices[( i + 1 ) % choices.Length];\n\t\t}\n\t\treturn choices[0];\n\t}\n}\n"
        }
    ]
}