s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
link Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=slfy.circleroyale&take=20
Showing code results for query:
*
(49 total matches found)
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();
}
}
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 );
}
}
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 ) );
}
}
}
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 );
}
}
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();
}
}
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;
}
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 );
}
}
}
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;
}
}
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 = "";
}
}
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;
}
}
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 );
}
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 );
}
}
}
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 );
}
}
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;
}
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;
}
}
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;
}
}
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 );
}
}
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;
}
}
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;
}
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"
}
]
}