Game/NetworkManager.cs
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
/// <summary>
/// 联机管理(Component.INetworkListener):**本机本地组件,自身不联网**(此前挂在网络对象上
/// 复制不到客户端,导致食物/重生全废——实测)。食物与重生同步走**静态 RPC**(不依赖任何对象)。
/// 权威端(编辑器直接 Play 或房主):创建公开大厅(24 人)、审批连接、为每个连入的客户端
/// 生成归它所有的球(NetworkSpawn(channel) → 该端本地 IsProxy=false,owner 本地模拟);
/// OnActive 时把当前食物全量定向发给新连接;断线时销毁其球。
/// 客户端:通过静态 RequestRespawn RPC 请求重生(host 用 Rpc.Caller 找回连接)。
/// </summary>
public sealed class NetworkManager : Component, Component.INetworkListener
{
/// <summary> 全局引用 </summary>
public static NetworkManager Instance { get; private set; }
/// <summary> 本机是否权威端:未联网的编辑器 Play 或房主 </summary>
public static bool IsAuthority => !Networking.IsActive || Networking.IsHost;
bool _hosted;
protected override void OnStart()
{
base.OnStart();
Instance = this;
Log.Info( $"[net] OnStart authority={IsAuthority} active={Networking.IsActive} host={Networking.IsHost}" );
}
/// <summary>
/// 未联网则创建公开大厅并生成房主球 + bot;已联网(作为客户端加入)则什么都不生成。
/// 由 CircleroyaleGame.EnsureWorld 显式调用。
/// createLobby=false:**人机对战**——不建大厅直接离线开局(IsAuthority 恒 true,一切照旧)。
/// ⚠️ CreateLobby 异步生效:必须等 Networking.IsActive 翻转后再生成对象,
/// 否则 NetworkSpawn 被跳过、球变成纯本地对象永不复制(实测大坑)。
/// ⚠️ 客户端(已联网非房主)不做任何 host 流程——此前没拦,客户端也会打出
/// "host ball + bots spawned" 一整套误导日志(实测)。
/// </summary>
public void RunHostFlow( bool createLobby = true )
{
if ( !IsAuthority )
{
Log.Info( "[net] client mode — skip host flow" );
return;
}
if ( _hosted ) return; // 防重入
_hosted = true;
RunHostFlowAsync( createLobby );
}
async void RunHostFlowAsync( bool createLobby )
{
try
{
if ( createLobby && !Networking.IsActive )
{
Networking.CreateLobby( new global::Sandbox.Network.LobbyConfig
{
Privacy = global::Sandbox.Network.LobbyPrivacy.Public,
MaxPlayers = GameConfig.MaxPlayers,
// 房间名带房主名——房间列表页展示用(v0.6.7.0)
Name = LobbyDisplayName(),
} );
Log.Info( "[net] lobby creating (Public/24)..." );
// 等会话真正激活(CreateLobby 异步生效),最多 30 秒
int wait = 0;
while ( !Networking.IsActive && wait < 300 )
{
await Task.DelayRealtimeSeconds( 0.1f );
wait++;
}
Log.Info( $"[net] session active={Networking.IsActive} (waited {wait * 0.1f:0.0}s)" );
}
else if ( !createLobby )
{
Log.Info( "[net] offline room (no lobby)" );
}
// M5:开局不再自动——进房间(蛰伏球就位),host 点 START 才 StartMatchFromLobby
CircleroyaleGame.Current?.OnLobbyOpened();
Log.Info( "[net] room ready — waiting for host to start" );
}
catch ( Exception e )
{
Log.Info( $"[net] host flow failed: {e.Message} — 离线兜底" );
CircleroyaleGame.Current?.OnLobbyOpened();
}
}
/// <summary>
/// 主菜单"快速加入"(v0.6.7.0 起为房间列表页服务):查询本游戏的大厅 →
/// 加入人数最多且未满的房间。失败/无房回报浏览器页提示,**不再自动回退连 local**
/// (同机调试直连由页面上的 JOIN LOCAL 按钮显式负责)。
/// 官方同款路径见 addons/menu/Code/MenuHelpers.cs(QueryLobbies + TryConnectSteamId)。
/// </summary>
public async void QuickJoin()
{
EnsureDisconnected();
try
{
var lobbies = await Networking.QueryLobbies();
global::Sandbox.Network.LobbyInformation? best = null;
foreach ( var l in lobbies )
{
if ( l.IsFull ) continue;
if ( best is null || l.Members > best.Value.Members ) best = l;
}
if ( best is not null )
{
var b = best.Value;
Log.Info( $"[net] quick join '{b.Name}' members={b.Members}/{b.MaxMembers}" );
_lastJoinedLobby = b; // 记住目标:掉线自动重连走同一条 Steam 路径
if ( await Networking.TryConnectSteamId( b.LobbyId ) )
return; // 成功:标准加入流接管(Tick 捕获会话激活 → 房间页)
}
}
catch ( Exception e )
{
Log.Warning( $"[net] quick join query failed: {e.Message}" );
}
CircleroyaleGame.Current?.OnJoinAttemptFailed( "NO ROOM JOINED — TRY AGAIN" );
}
/// <summary>
/// 房间列表点击加入(v0.6.7.0):按大厅 SteamId 直连(走 Steam 中继,跨机器可用)。
/// 成功后标准加入流接管;失败回报浏览器页提示。
/// </summary>
public async void JoinLobby( global::Sandbox.Network.LobbyInformation lobby )
{
EnsureDisconnected();
_lastJoinedLobby = lobby; // 记住目标:掉线自动重连走同一条 Steam 路径
CircleroyaleGame.Current?.OnJoinAttemptStarted();
try
{
Log.Info( $"[net] joining lobby '{lobby.Name}' ({lobby.Members}/{lobby.MaxMembers})" );
if ( await Networking.TryConnectSteamId( lobby.LobbyId ) )
return;
Log.Warning( "[net] join lobby failed" );
CircleroyaleGame.Current?.OnJoinAttemptFailed( "COULDN'T JOIN THAT ROOM — TRY AGAIN" );
}
catch ( Exception e )
{
Log.Warning( $"[net] join lobby error: {e.Message}" );
CircleroyaleGame.Current?.OnJoinAttemptFailed( "JOIN ERROR — TRY AGAIN" );
}
}
/// <summary> 连接前掐掉残留会话(否则 TryConnectSteamId/Connect 静默失败——实测坑) </summary>
void EnsureDisconnected()
{
if ( !Networking.IsActive ) return;
try
{
Networking.Disconnect();
}
catch
{
// 断不开也无妨:最坏就是这次加入失败,有超时兜底
}
Log.Info( "[net] dropped stale session before connect" );
}
/// <summary> 公开大厅:默认放行(返回 false 可拒绝连接) </summary>
public bool AcceptConnection( Connection channel, string reason ) => true;
/// <summary> 主菜单"加入":连到指定地址(local = 本机房主)。失败由 CircleroyaleGame 的超时兜底显示 </summary>
public void StartJoin( string address )
{
// 残留会话(上局当过 host/加入没断干净):不掐掉会一直静默失败
// (实测:编辑器当过一次 host 后再点 JOIN,反复刷 "session already active, skip connect")
EnsureDisconnected();
try
{
Networking.Connect( address );
Log.Info( $"[net] connecting to '{address}' ..." );
}
catch ( Exception e )
{
Log.Warning( $"[net] connect to '{address}' failed: {e.Message}" );
}
}
/// <summary> 大厅显示名:CIRCLEROYALE — 房主名(会话未激活时 Local 可能为空,回退常量名) </summary>
string LobbyDisplayName()
{
try
{
var host = Connection.Local?.DisplayName;
return string.IsNullOrWhiteSpace( host ) ? "CIRCLEROYALE" : $"CIRCLEROYALE — {host}";
}
catch
{
return "CIRCLEROYALE";
}
}
/// <summary>
/// 新客户端握手到 Welcome 阶段(仅 host 收到;早于该客户端请求初始快照):
/// 把生成请求**入队**,由主线程 OnUpdate 下一帧生成归它所有的球——球会随初始快照到达客户端。
/// ⚠️ 本回调跑在网络线程:直接 SpawnBall(new GameObject/加载 prefab)会随机抛
/// ".ctor must be called on the main thread!"(线程调度时好时坏,2026-09-05 实测炸穿握手,
/// 客户端跟着在 MountedVPKs 阶段抛 UnauthorizedAccessException 断线)。
/// ⚠️ 也不要放到 OnActive(快照拍完后)才生成:live create 广播到不了刚加入的连接,
/// 而初始快照/定向 RPC 都通(2026-09-05 双开实测)。
/// </summary>
public void OnConnected( Connection channel )
{
Log.Info( $"[net] OnConnected '{channel.DisplayName}' id={channel.Id}" );
// host 自己的本地连接:引擎在 ClientInfo 处直接 return,不会走到这里;双保险
if ( Connection.Local is not null && channel.Id == Connection.Local.Id ) return;
// 主线程下一帧生成(0.1s << 客户端请求快照的 ~2s,球必然进快照)。
// 房间阶段生成的是蛰伏球(Alive=false);名单变化由主线程统一广播 LobbyState
bool lobby = CircleroyaleGame.Current?.IsLobbyOpen ?? false;
_pendingJoins.Add( new PendingJoin( channel, 0f, 0.1f, false, lobby ) );
CircleroyaleGame.Current?.MarkLobbyDirty();
Log.Info( $"[net] pre-snapshot ball queued for '{channel.DisplayName}' (dormant={lobby})" );
}
/// <summary> 该连接是否已有一颗活球(host 侧查表) </summary>
bool HasBallFor( Connection channel )
{
var game = CircleroyaleGame.Current;
if ( game is null ) return false;
var steamId = channel.SteamId.Value;
foreach ( var b in game.Balls )
{
if ( b.IsValid() && !b.IsBot && b.OwnerSteamId == steamId ) return true;
}
return false;
}
/// <summary> 新客户端完全连入(仅 host 收到):食物全量立即定向;无球时兜底延后生成。
/// ⚠️ 实测坑:引擎把 host 自己的本地连接也走一遍完整握手(每场开场都触发本回调),
/// 它对应的球已由 OnNetworkReady 生成,这里再生成就是一颗无人模拟、相机不跟、
/// 死了不重生的多余球(实测:每局开场双 OnStart 'host名')——直接跳过。
/// ⚠️ 另一个实测坑:无论本回调内同步生成、还是延后 0.8s 由主线程生成,live create 广播
/// 都到不了刚加入的连接(快照/定向 RPC 通、唯独 create 丢)——所以球的生成放在更早的
/// OnConnected(进初始快照),这里只兜底竞态。 </summary>
public async void OnActive( Connection channel )
{
Log.Info( $"[net] OnActive '{channel.DisplayName}' id={channel.Id}" );
// host 自己的本地连接:球由 OnNetworkReady 生成,跳过(见类注释)
if ( Connection.Local is not null && channel.Id == Connection.Local.Id )
{
Log.Info( "[net] OnActive: host's own connection — skip (host ball spawned by OnNetworkReady)" );
return;
}
// 热重启竞态防护:等会话激活后再继续(见 RunHostFlow 注释)
int wait = 0;
while ( !Networking.IsActive && wait < 100 )
{
await Task.DelayRealtimeSeconds( 0.1f );
wait++;
}
// 等待期间连接可能已断开;游戏系统也可能已重建
var game = CircleroyaleGame.Current;
if ( game is null )
{
Log.Info( "[net] OnActive: no game, abort" );
return;
}
// 食物全量定向同步给新连接(静态 RPC 定向:实测可靠,立即发)
var foods = game.Food?.Foods;
if ( foods is not null )
{
using ( Rpc.FilterInclude( channel ) )
{
FoodFull( foods );
}
}
// 绿刺位置(静态数据全量)
var spikes = game.Spikes;
if ( spikes is not null )
{
using ( Rpc.FilterInclude( channel ) )
{
SpikesFull( spikes );
}
}
// 房间/对局分支(M5):
// - 房间开着 → 推房间信息(参数 + 玩家名单),客户端亮等待界面
// - 对局已开始 → 同步玩家列表 + 推规则 + 直接通知开局(客户端房间页显示 3 秒加入倒计时后进入)
if ( game.IsLobbyOpen )
{
using ( Rpc.FilterInclude( channel ) )
{
LobbyState( (byte)MatchState.PendingMode, (int)MatchState.PendingDuration,
MatchState.PendingPlayerTarget, game.LobbyPlayerNames().ToArray() );
}
}
else if ( game.IsMatchStarted )
{
using ( Rpc.FilterInclude( channel ) )
{
// 玩家列表先同步(房间页展示),再推规则与开局(客户端走 3 秒加入倒计时)
LobbyState( (byte)MatchState.PendingMode, (int)MatchState.PendingDuration,
MatchState.PendingPlayerTarget, game.LobbyPlayerNames().ToArray() );
MatchSettings( (byte)MatchState.CurrentMode, (int)MatchState.Duration, MatchState.PlayerTarget );
MatchStarted();
}
}
// 玩家真正入局:刷新其球的出生保护——pre-snapshot 球在握手期间生成,3s 保护期
// 在没人操控时就空跑完了(实测:加入 1.6s 即被 bot 吃掉)
foreach ( var b in game.Balls )
{
if ( b.IsValid() && !b.IsBot && b.OwnerSteamId == channel.SteamId.Value )
b.RefreshSpawnProtection();
}
// 球通常已在 OnConnected(快照前)生成;这里只兜底"当时世界未就绪"的竞态
if ( !HasBallFor( channel ) )
{
_pendingJoins.Add( new PendingJoin( channel, 0f, 0.8f, true ) );
Log.Info( $"[net] join queued for '{channel.DisplayName}' (fallback) — no pre-snapshot ball" );
}
}
// ---- 加入者球的延迟生成 ----
readonly struct PendingJoin
{
public readonly Connection Channel;
public readonly TimeSince Since;
public readonly float Delay;
public readonly bool CheckActive;
public readonly bool Dormant; // 入队时是否房间阶段(蛰伏球)
public PendingJoin( Connection channel, float start, float delay, bool checkActive, bool dormant = false )
{
Channel = channel; Since = start; Delay = delay; CheckActive = checkActive; Dormant = dormant;
}
}
readonly List<PendingJoin> _pendingJoins = new();
// ---- 客户端自动重连(M3,兜引擎握手偶发非主线程断言)----
string _serverAddress; // 见过就记:握手早期 HostConnection 就有值,首连失败也拿得到
global::Sandbox.Network.LobbyInformation? _lastJoinedLobby; // 房间列表加入的目标(v0.6.8.1):
// Steam 中继连接的 Host.Address 可能为空,
// 重连改用它记住的大厅目标走 TryConnectSteamId
TimeSince _sinceJoinStuck;
int _reconnectAttempts;
protected override void OnUpdate()
{
base.OnUpdate();
TickDisconnectRecovery();
TickReconnectWatchdog();
if ( _pendingJoins.Count == 0 ) return;
for ( int i = _pendingJoins.Count - 1; i >= 0; i-- )
{
var p = _pendingJoins[i];
if ( p.Since < p.Delay ) continue;
_pendingJoins.RemoveAt( i );
SpawnJoinBall( p.Channel, p.CheckActive, p.Dormant );
}
}
// ---- 客户端掉线自动重连(v0.6.4.6 用户定稿):掉线 → 按 ReconnectRetrySeconds 重试 →
// 耗尽 ReconnectMaxAttempts 次 → 回主菜单 ----
/// <summary> 掉线自动重连进行中(CircleroyaleGame.ReconcileUi 据此把房间页留在原地) </summary>
public static bool IsReconnecting => Instance is not null && Instance._reconnecting;
bool _reconnecting;
TimeSince _sinceReconnectAttempt;
TimeSince _sinceReconnectStart;
/// <summary>
/// 掉线恢复:从会话里掉出来(房间等待/对局中都算)→ 定时重连,耗尽回调 OnReconnectFailed。
/// 重连成功走标准加入流(NotifyReconnectStarted → 房间页 + RequestMatchState)。
/// 菜单上主动断开(LEAVE/结算回菜单)经 ResetSession 清标记,不会触发重连。
/// </summary>
void TickDisconnectRecovery()
{
if ( Instance != this ) return; // 快照带过来的 host 副本不跑本机流程(防双份重连)
if ( Networking.IsActive || Networking.IsHost )
{
if ( !_reconnecting ) return;
_reconnecting = false;
Log.Info( "[net] reconnect: session active again" );
CircleroyaleGame.Current?.NotifyReconnectStarted();
return;
}
if ( !_reconnecting )
{
// 只有"刚才还在会话里"的掉线才重连(菜单上从没连上的归 JOIN FAILED 提示流管)
if ( _serverAddress is null ) return;
if ( !( CircleroyaleGame.Current?.IsClientInSession ?? false ) ) return;
_reconnecting = true;
_reconnectAttempts = 0;
_sinceReconnectStart = 0;
_sinceReconnectAttempt = 999f; // 立即发起首次尝试
Log.Warning( "[net] disconnected from host — auto reconnect starting" );
CircleroyaleGame.Current?.OnDisconnectRecoveryStarted();
return;
}
// 总时长上限(v0.6.8.0):单次 Connect 可能长时间挂在"正在握手"上,
// 不管走到哪一步,超过上限一律放弃回主菜单(防个别尝试挂死导致永远不返回)
if ( _sinceReconnectStart > GameConfig.ReconnectGiveUpSeconds )
{
_reconnecting = false;
Log.Warning( "[net] reconnect window exceeded — giving up, back to menu" );
CircleroyaleGame.Current?.OnReconnectFailed();
return;
}
if ( Networking.IsConnecting ) return; // 上一次连接还在握手,别叠加
if ( _reconnectAttempts >= GameConfig.ReconnectMaxAttempts )
{
_reconnecting = false;
Log.Warning( "[net] reconnect exhausted — giving up, back to menu" );
CircleroyaleGame.Current?.OnReconnectFailed();
return;
}
if ( _sinceReconnectAttempt < GameConfig.ReconnectRetrySeconds ) return;
_reconnectAttempts++;
_sinceReconnectAttempt = 0;
// 首选房间列表记住的大厅目标(Steam 中继连接的 Host.Address 可能为空,实测重连没启动);
// local 直连的旧路径保留(Address 有值就用)
if ( _lastJoinedLobby is not null )
{
var lobby = _lastJoinedLobby.Value;
Log.Warning( $"[net] reconnect attempt {_reconnectAttempts}/{GameConfig.ReconnectMaxAttempts} → steam lobby '{lobby.Name}'" );
_ = Networking.TryConnectSteamId( lobby.LobbyId );
return;
}
Log.Warning( $"[net] reconnect attempt {_reconnectAttempts}/{GameConfig.ReconnectMaxAttempts} → {_serverAddress}" );
try
{
Networking.Connect( _serverAddress );
}
catch ( Exception e )
{
Log.Warning( $"[net] reconnect connect failed: {e.Message}" );
}
}
/// <summary>
/// 客户端握手卡死自动重连:连入后 ReconnectAfterSeconds 秒仍没有自己的球 →
/// 拆掉僵尸连接(重连发起与"耗尽回主菜单"统一由 TickDisconnectRecovery 处理)。
/// 仅客户端跑;成功入局(本机球就位)即复位计数。已入局后的掉线/僵尸态不归它管
/// (host 停 Play 的 TCP 僵尸无代码可救,不在这里空转重试)。
/// </summary>
void TickReconnectWatchdog()
{
if ( Instance != this ) return; // 快照带过来的 host 副本不跑本机流程
if ( !Networking.IsActive || Networking.IsHost ) return;
// 随手记服务器地址(连接对象一出现就有值)
var addr = Connection.Host?.Address;
if ( !string.IsNullOrEmpty( addr ) ) _serverAddress = addr;
var joined = CircleroyaleGame.Current?.LocalBall.IsValid() ?? false;
if ( joined )
{
if ( _reconnectAttempts > 0 ) Log.Info( "[net] auto-reconnect: joined OK" );
_reconnectAttempts = 0;
_sinceJoinStuck = 0;
return;
}
if ( Networking.IsConnecting ) return; // 正在握手,别打断
if ( _serverAddress is null ) return; // 没拿到过地址,无从重连
if ( _reconnectAttempts >= GameConfig.ReconnectMaxAttempts ) return;
if ( _sinceJoinStuck < GameConfig.ReconnectAfterSeconds ) return;
var stuck = _sinceJoinStuck;
_reconnectAttempts++;
_sinceJoinStuck = 0;
Log.Warning( $"[net] join stuck {stuck:0}s without own ball — tearing down for reconnect {_reconnectAttempts}/{GameConfig.ReconnectMaxAttempts}" );
try
{
Networking.Disconnect();
}
catch
{
// 拆不掉也照办(TickDisconnectRecovery 的重试循环兜着)
}
// 重连发起与"耗尽回主菜单"统一交给 TickDisconnectRecovery(!IsActive 分支)
}
/// <summary> 出队后真正生成加入者的球(主线程、普通 tick 上下文)。
/// checkActive:OnActive 兜底路径要查(连接真断了就别生成);OnConnected 阶段
/// **不能查**——Welcome 时 IsActive 本来就是 false,查了必误杀(2026-09-05 实测),
/// 生成早了没关系,真断线由 OnDisconnected 按 SteamId 清理。
/// dormant:房间阶段生成蛰伏球(Alive=false,开局激活),之后要在名单里亮出来。 </summary>
void SpawnJoinBall( Connection channel, bool checkActive, bool dormant = false )
{
if ( checkActive && !channel.IsActive )
{
Log.Info( "[net] join ball: connection gone, skip" );
return;
}
var game = CircleroyaleGame.Current;
if ( game is null )
{
Log.Info( "[net] join ball: no game, skip" );
return;
}
var ball = game.SpawnBall( game.RandomSpawnPos(), isBot: false, owner: channel, dormant: dormant );
Log.Info( $"[net] join ball for '{channel.DisplayName}': ball={( ball is null ? "REFUSED" : "ok" )} dormant={dormant} netRoot={( ball?.GameObject.IsNetworkRoot ?? false )}" );
if ( dormant ) game.MarkLobbyDirty(); // 名单亮出新人(本地面板轮刷也看得到,这里管推送)
}
/// <summary> 断线:销毁其球(销毁会同步到所有端),分身随葬,bot 由 TickBots 回填。
/// 房间阶段还要把名单变化推给剩下的人(置脏标,主线程广播) </summary>
public void OnDisconnected( Connection channel )
{
var game = CircleroyaleGame.Current;
if ( game is null ) return;
foreach ( var b in game.Balls.ToArray() )
{
if ( b.IsValid() && !b.IsBot && b.OwnerSteamId == channel.SteamId.Value )
{
b.GameObject.Destroy();
}
}
game.PopPiecesOf( channel.SteamId.Value );
if ( game.IsLobbyOpen ) game.MarkLobbyDirty();
}
/// <summary> 回主菜单:复位 host 流程与重连看门狗(下次开局重新走 RunHostFlow/OnNetworkReady) </summary>
public void ResetSession()
{
_hosted = false;
_pendingJoins.Clear();
_reconnectAttempts = 0;
_sinceJoinStuck = 0;
_reconnecting = false;
Log.Info( "[net] session reset" );
}
// ---- 食物与重生:静态 RPC(不依赖任何网络对象)----
/// <summary> host 吃判定后广播:某食物被吃(eaterSteamId 供客户端判断是否自己的嘴,播吃音) </summary>
[Rpc.Broadcast]
public static void FoodEaten( int index, long eaterSteamId = 0 ) => CircleroyaleGame.Current?.OnFoodEatenRemote( index, eaterSteamId );
/// <summary> host 重生食物后广播:新位置/颜色 </summary>
[Rpc.Broadcast]
public static void FoodRespawned( int index, FoodData food ) => CircleroyaleGame.Current?.OnFoodRespawnedRemote( index, food );
/// <summary> 新连接加入时定向发送:食物全量 </summary>
[Rpc.Broadcast]
public static void FoodFull( FoodData[] foods ) => CircleroyaleGame.Current?.OnFoodFullRemote( foods );
/// <summary> 绿刺位置(host 生成后广播 + 新连接定向补发;静态数据,此后不变) </summary>
[Rpc.Broadcast]
public static void SpikesFull( Vector2[] spikes ) => CircleroyaleGame.Current?.OnSpikesFullRemote( spikes );
/// <summary> 死亡/爆裂特效广播(不可靠传输——丢一帧特效无所谓,不占可靠通道)。
/// ownerSteamId = 死亡实体的主人,客户端据此判断是否自己死亡/爆裂(只播自己的爆裂音) </summary>
[Rpc.Broadcast( NetFlags.Unreliable )]
public static void PoppedEffect( Vector3 pos, byte colorIndex, float radius, long ownerSteamId ) => CircleroyaleGame.Current?.OnPoppedEffectRemote( pos, colorIndex, radius, ownerSteamId );
/// <summary> 吞球广播(eater/eaten 的 SteamId 供客户端判断自己是否参与,播吞球音 + 击杀播报) </summary>
[Rpc.Broadcast]
public static void BallEaten( long eaterSteamId, long eatenSteamId, float massGained ) => CircleroyaleGame.Current?.OnBallEatenRemote( eaterSteamId, eatenSteamId, massGained );
// ---- 比赛规则与进程(M5)----
/// <summary> 比赛规则下发(host 开局广播 + 新连接定向补发;一次性、幂等) </summary>
[Rpc.Broadcast]
public static void MatchSettings( byte mode, int durationSeconds, int playerTarget ) => MatchState.Apply( mode, durationSeconds, playerTarget );
/// <summary> 倒计时校时(1Hz 不可靠广播;客户端本地每帧续走) </summary>
[Rpc.Broadcast( NetFlags.Unreliable )]
public static void MatchTick( float timeLeft ) => MatchState.ApplyTick( timeLeft );
/// <summary> 比赛结束 + 结算榜(host 排好序广播;各端展示并各自上传成绩) </summary>
[Rpc.Broadcast]
public static void MatchOver( ScoreWire[] standings ) => CircleroyaleGame.Current?.OnMatchOverRemote( standings );
/// <summary> 房主点了 START:客户端出房间进游戏(OnActive 对中途加入者也有定向版) </summary>
[Rpc.Broadcast]
public static void MatchStarted() => CircleroyaleGame.Current?.OnMatchStartedRemote();
/// <summary> host 按 P 快速退局(v0.6.9.0):广播取消——所有客户端一起回房间等待页 </summary>
[Rpc.Broadcast]
public static void MatchCancelled() => CircleroyaleGame.Current?.OnMatchCancelledRemote();
/// <summary> 房间信息推送(参数 + 玩家名单;名单/参数变化与新人 OnActive 定向共用)。
/// names[0] 恒为房主。 </summary>
[Rpc.Broadcast]
public static void LobbyState( byte mode, int durationSeconds, int playerTarget, string[] names ) => CircleroyaleGame.Current?.OnLobbyStateRemote( mode, durationSeconds, playerTarget, names );
/// <summary> 主球被吃后最大分身晋升(v0.6.2.0):定向发给该玩家——把主球瞬移到晋升位置
/// 继续 owner 模拟(质量经 [Sync] 自动同步)。host 自己的球不走此 RPC(本地已直接设位)。 </summary>
[Rpc.Broadcast]
public static void MainPromoted( float x, float y ) => CircleroyaleGame.Current?.OnMainPromotedRemote( x, y );
/// <summary> 客户端请求当前对局状态(v0.6.3.4):进房间/重连/热重载后向 host 要一次——
/// 房间开着推房间信息,对局进行中直接推规则+开局。让任意时刻的客户端状态可幂等恢复。 </summary>
[Rpc.Host]
public static void RequestMatchState()
{
var conn = Rpc.Caller;
if ( conn is null || !conn.IsActive ) return;
var game = CircleroyaleGame.Current;
if ( game is null ) return;
using ( Rpc.FilterInclude( conn ) )
{
if ( game.IsLobbyOpen )
{
LobbyState( (byte) MatchState.PendingMode, (int) MatchState.PendingDuration,
MatchState.PendingPlayerTarget, game.LobbyPlayerNames().ToArray() );
}
else if ( game.IsMatchStarted )
{
MatchSettings( (byte) MatchState.CurrentMode, (int) MatchState.Duration, MatchState.PlayerTarget );
MatchStarted();
}
else
{
// host 也在菜单(会话残留):按菜单设置推一局空房间信息
LobbyState( (byte) MatchState.PendingMode, (int) MatchState.PendingDuration,
MatchState.PendingPlayerTarget, game.LobbyPlayerNames().ToArray() );
}
}
}
/// <summary>
/// host 按固定节拍广播:分身/孢子状态快照(纯数据实体,各端镜像渲染)。
/// 广播对刚加入的连接也有效(与食物增量同款路径,实测可靠)——中途加入也能看到正在飞的分身。
/// </summary>
[Rpc.Broadcast]
public static void CellsState( CellWire[] cells ) => CircleroyaleGame.Current?.OnCellsStateRemote( cells );
/// <summary>
/// 客户端请求重生(静态 RPC):host 用 Rpc.Caller 取回调用者连接并为其原地复活。
/// 不要让客户端自报 SteamId——同机双开时连接是合成 ID,与 Game.SteamId 对不上(实测踩坑)。
/// </summary>
[Rpc.Host]
public static void RequestRespawn()
{
var conn = Rpc.Caller;
Log.Info( $"[net] RequestRespawn from '{conn?.DisplayName ?? "null"}'" ); // 生命周期日志:定位客户端重生链路断点
if ( conn is null || !conn.IsActive ) return;
CircleroyaleGame.Current?.RespawnConnection( conn );
}
/// <summary> 客户端本地食物列表未就绪(热重载/新实例)时,向 host 请求全量(定向回发);
/// 绿刺顺带补发(客户端不清请求,重复收无害——数据幂等) </summary>
[Rpc.Host]
public static void RequestFoodFull()
{
var conn = Rpc.Caller;
if ( conn is null || !conn.IsActive ) return;
var game = CircleroyaleGame.Current;
var foods = game?.Food?.Foods;
if ( foods is not null )
{
using ( Rpc.FilterInclude( conn ) )
{
FoodFull( foods );
}
}
var spikes = game?.Spikes;
if ( spikes is not null )
{
using ( Rpc.FilterInclude( conn ) )
{
SpikesFull( spikes );
}
}
}
}