MultiplayerSession.cs
using Sandbox;
using Sandbox.Network;
using System;
namespace PaintballBaddies;
// Native lobby, connection ownership and host-owned six-seat match.
// Ordinary comment avoids duplicate Description metadata across generated partial declarations.
public sealed partial class MultiplayerSession : Component, Component.INetworkListener
{
public static MultiplayerSession Find(Scene scene)=>scene.GetAllComponents<MultiplayerSession>().FirstOrDefault();
public static bool Online=>Networking.IsActive;
public static bool Authority=>!Online || Networking.IsHost;
public static PlayerController LocalPlayer(Scene scene)=>scene.GetAllComponents<PlayerController>()
.FirstOrDefault(x=>!x.IsProxy && (!Online || x.Components.Get<NetworkPawn>().IsValid()));
[Sync(SyncFlags.FromHost)] public bool InRound { get; private set; }
[Sync(SyncFlags.FromHost)] public float Remaining { get; private set; }
[Sync(SyncFlags.FromHost)] public int Minutes { get; private set; }=3;
[Sync(SyncFlags.FromHost)] public int ActiveMinutes { get; private set; }=3;
[Sync(SyncFlags.FromHost)] public string Status { get; private set; }="LOBBY";
[Sync(SyncFlags.FromHost)] public int RoundId { get; private set; }
[Sync(SyncFlags.FromHost)] public int MarkerSoundAssignments { get; private set; }=-1;
public const string Protocol="bvs-beta-1";
[Sync(SyncFlags.FromHost)] public bool PublicLobby {get;private set;}=true;
public string Notice { get; private set; }="";
public string JoinAddress { get; set; }="";
public List<LobbyInformation> Lobbies { get; private set; }=new();
private bool registered;
private bool hadNetworkSession;
private float reconcileTime;
private bool pendingRoundStart;
private readonly HashSet<Guid> disconnected=new();
private static readonly Vector3[] Spawns={new(-630,0,4),new(570,-400,4),new(570,0,4),new(570,400,4),new(-650,1250,4),new(650,1250,4)};
public IEnumerable<NetworkPawn> Players=>Scene.GetAllComponents<NetworkPawn>().Where(x=>x.GameObject.Active);
public IEnumerable<NetworkBot> Bots=>Scene.GetAllComponents<NetworkBot>().Where(x=>x.GameObject.Active);
public ArenaMatch.Standing[] Standings=>Scene.GetAllComponents<PaintballCombatant>()
.Where(x=>x.Components.Get<NetworkPawn>().IsValid() || x.Components.Get<NetworkBot>().IsValid())
.Select(x=>new ArenaMatch.Standing(x.Components.Get<PlayerProfile>()?.DisplayName ??
RosterSelection.Names[x.Components.Get<ArenaOpponent>()?.CharacterIndex ?? 0],IsCooperative ? x.PopHits : x.HitsLanded,x.PaintHits,
x.GameObject==LocalPlayer(Scene)?.GameObject,x.KnockdownsLanded,x.KnockdownsReceived,x.Components.Get<NetworkBot>().IsValid(),
x.TargetsPopped,x.PopScore,IsCooperative,x.SausageKills,x.SausageHeadshots,x.OverdriveDowns,IsOverdrive)).OrderByDescending(x=>x.Points).ThenBy(x=>IsCooperative ? 0 : x.Received).ThenBy(x=>x.Name).ToArray();
protected override void OnStart()
{
ArenaSelection.Restore(this);
if(Authority && MarkerSoundAssignments<0)MarkerSoundAssignments=CharacterMarkerSound.Roll();
if(!PaintImpactSystem.Find(Scene).IsValid())Components.Create<PaintImpactSystem>();
if(Online)TrainingRange.CreateLocalHud(Scene);
}
public void Host(bool publicGame=true)
{
if(Online)return;
CancelPlaySearch();
var controls=Scene.GetAllComponents<PaintballControls>().FirstOrDefault();
controls?.StartPractice();
Minutes=(int)(LocalPlayer(Scene)?.Components.Get<ArenaMatch>()?.RoundDuration ?? 180)/60;
try
{
PublicLobby=publicGame;
Networking.CreateLobby(new LobbyConfig{Name=$"Baddies vs Sausages / {ArenaMap.DisplayTitle(Scene)}",MaxPlayers=6,Hidden=false,Privacy=publicGame ? LobbyPrivacy.Public : LobbyPrivacy.FriendsOnly,
DestroyWhenHostLeaves=true});
Networking.SetData("pb_protocol",Protocol);
Networking.SetData("pb_access",publicGame ? "public" : "friends");
}
catch(Exception){Notice="Could not create a lobby. Check the Steam connection and try again.";return;}
Scene.GetAllComponents<TrainingRange>().FirstOrDefault()?.SetTargetsVisible(false);
foreach(var old in Scene.GetAllComponents<PlayerController>().Where(x=>!x.Components.Get<NetworkPawn>().IsValid()).ToArray())old.GameObject.Destroy();
foreach(var shield in Scene.GetAllComponents<ArenaShield>().ToArray())shield.GameObject.Destroy();
Notice=publicGame ? "Starting your public match. AI fills empty seats; people can join during play."
: "Starting your friends-only match. AI fills empty seats.";
}
public void Join(ulong id)
{
if((Online && !CanQuickPlay) || Networking.IsConnecting)return;
if(id==0){Notice="Enter a valid host Steam ID or lobby ID.";return;}
CancelPlaySearch();
// Keep the menu visible while connecting so Cancel and Quit stay reachable.
var controls=Scene.GetAllComponents<PaintballControls>().FirstOrDefault();
if(controls.IsValid() && !controls.Open)controls.Toggle();
Notice="Connecting to the host...";
Networking.Connect(id);
}
public void JoinTyped()
{
if(ulong.TryParse(JoinAddress.Trim(),out var id)){Join(id);return;}
Notice="Enter the host Steam ID or a lobby ID.";
}
public void Leave()=>Game.Disconnect();
public bool AcceptConnection(Connection channel,ref string reason)
{
if(Players.Count()+pendingSpawnSeats.Count>=6){reason="All six player seats are occupied.";return false;}
return true;
}
public void OnActive(Connection channel)
{
if(!Networking.IsHost)return;
disconnected.Remove(channel.Id);
SpawnPlayer(channel);
}
public void OnDisconnected(Connection channel)
{
if(!Networking.IsHost)return;
disconnected.Add(channel.Id);
pendingSpawnSeats.Remove(channel.Id);spawnRetryAfter.Remove(channel.Id);
foreach(var pawn in Players.Where(x=>x.ConnectionId==channel.Id).ToArray())pawn.GameObject.Destroy();
reconcileTime=0;
}
private readonly Dictionary<Guid,float> spawnRetryAfter=new();
private readonly Dictionary<Guid,int> pendingSpawnSeats=new();
private void SpawnPlayer(Connection channel)
{
if(Players.Any(x=>x.ConnectionId==channel.Id) || spawnRetryAfter.GetValueOrDefault(channel.Id)>RealTime.Now)return;
if(channel!=Connection.Host)channel.CanSpawnObjects=false;
if(Players.Count()>=6)return;
var occupied=Players.Select(x=>x.Seat).Concat(pendingSpawnSeats.Where(x=>x.Key!=channel.Id).Select(x=>x.Value)).ToArray();
// The host rolls once from characters not held by another human.
// Seat replication keeps outfit, paint, AI replacement and spawning consistent.
var available=Enumerable.Range(0,RosterSelection.Names.Length).Where(x=>!occupied.Contains(x)).ToArray();
if(available.Length==0)return;
if(!pendingSpawnSeats.TryGetValue(channel.Id,out int seat))
pendingSpawnSeats[channel.Id]=seat=available[Game.Random.Int(0,available.Length-1)];
var placement=JoiningStart(seat);
if(placement is null)
{
// Keep its AI active and retain this character reservation. The
// normal connection reconciliation retries when a safe start clears.
spawnRetryAfter[channel.Id]=RealTime.Now+.5f;
return;
}
var spawn=placement.Position;
var go=GameObject.Clone("prefabs/network_baddie.prefab",new Transform(spawn),startEnabled:false,name:$"Player {seat+1}");
// Initialize ownership and game state before enabling OnStart handlers.
// A failed editor hotload can leave an incomplete cached prefab. Do not
// enable half a pawn or retry every frame, flashing camera ownership.
// The clone is still disabled here, so look components up including disabled ones:
// enabled-only lookups return nothing on an inactive object.
var pawn=go.Components.Get<NetworkPawn>(true);
if(!pawn.IsValid() || !go.Components.Get<PlayerController>(true).IsValid() || !go.Components.Get<PaintballCombatant>(true).IsValid() || !go.Components.Get<PlayerProfile>(true).IsValid())
{
spawnRetryAfter[channel.Id]=RealTime.Now+5;
Notice="Player could not load. Restart the local game to retry.";
Log.Warning("PLAYER_PREFAB_INCOMPLETE: player creation deferred; no partial pawn enabled.");go.Destroy();return;
}
foreach(var bot in Bots.Where(x=>x.Seat==seat).ToArray())bot.GameObject.Destroy();
pawn.Seat=seat;pawn.ConnectionId=channel.Id;
pawn.JoinPosition=spawn;pawn.JoinFacing=placement.Rotation.Angles();
go.Components.Get<PaintballCombatant>(true).Team=seat+10;
go.Components.Get<PaintballCombatant>(true).AcceptHits=InRound || Status=="LOBBY";
go.Components.Get<PlayerProfile>(true).DisplayName=PlayerProfile.CleanName(channel.DisplayName);
go.NetworkMode=NetworkMode.Object;go.Enabled=true;go.Network.SetOwnerTransfer(OwnerTransfer.Fixed);go.NetworkSpawn(channel);
pendingSpawnSeats.Remove(channel.Id);spawnRetryAfter.Remove(channel.Id);
if(InRound)pawn.ResetForRound(spawn,RoundId,placement.Rotation.Angles());
reconcileTime=0;
}
protected override void OnUpdate()
{
TickPopRiot();TickOverdrive();
if(Online)hadNetworkSession=true;
else if(hadNetworkSession && !Networking.IsConnecting)
{
// Client AI is simulated by its host. Once that connection ends,
// exit the orphaned scene rather than leave stationary replicas.
hadNetworkSession=false;
Log.Info("PAINTBALL_CONNECTION_ENDED returning to game menu");
Game.Disconnect();
return;
}
if(!Online || !Networking.IsHost)return;
if(!registered)
{
registered=true;
foreach(var old in Scene.GetAllComponents<PlayerController>().Where(x=>!x.Components.Get<NetworkPawn>().IsValid()).ToArray())old.GameObject.Destroy();
Scene.GetAllComponents<TrainingRange>().FirstOrDefault()?.SetTargetsVisible(false);
// Platform-created lobbies need the same discovery data as in-game hosts.
Networking.SetData("pb_protocol",Protocol);
Networking.SetData("pb_access",PublicLobby ? "public" : "friends");
Networking.SetData("pb_mode",ModeKey(Mode));
Networking.SetData("pb_minutes",Minutes.ToString());
Networking.SetData("pb_arena",ArenaSelection.Current(Scene));
GameObject.NetworkMode=NetworkMode.Object;GameObject.Network.SetOwnerTransfer(OwnerTransfer.Fixed);GameObject.NetworkSpawn();
}
foreach(var connection in Connection.All.Where(x=>x.IsActive && !disconnected.Contains(x.Id)))SpawnPlayer(connection);
if(pendingRoundStart && string.IsNullOrEmpty(SpawnReadinessMessage))BeginRound();
// Hosting is asynchronous. Start only after the local pawn and welcome
// UI have initialized, including lobbies created by s&box's game browser.
// RoundId keeps time-up/results and subsequent restarts host-controlled.
if(RoundId==0 && Status=="LOBBY")
{
var host=Players.FirstOrDefault(x=>x.ConnectionId==Connection.Host?.Id);
var controls=Scene.GetAllComponents<PaintballControls>().FirstOrDefault();
if(controls.IsValid() && !controls.Open && host.IsValid()
&& host.Components.Get<CitizenPlayerPresentation>()?.IsReady==true
&& host.Components.Get<ArenaMatch>()?.PlayerState.IsValid()==true)
BeginRound();
}
reconcileTime-=Time.Delta;
if(reconcileTime<=0){reconcileTime=1;ReconcileBots();EnsureShields();}
if(!InRound)return;
// Cold cloud assets and a rebuilding doorway navmesh must not consume
// the match timer before enemies can enter the arena safely.
if(IsOverdrive && (!OverdriveAssets.Ready || !string.IsNullOrEmpty(SpawnReadinessMessage)) && Remaining>0)return;
var previous=Remaining;Remaining=MathF.Max(0,Remaining-Time.Delta);
if(ArenaMatch.CountdownCue(previous,Remaining) is {} cue)NetworkEffects.Sound($"sounds/paintball/{cue}.sound",Vector3.Zero,Guid.Empty);
if(Remaining<=0)
{
InRound=false;Status="TIME UP";
if(IsPopRiot && string.IsNullOrEmpty(RiotResult))RiotResult=RiotLives>0
? $"TEAM SURVIVED · {Math.Max(0,RiotWave-(RiotBreather>0 ? 0 : 1))} WAVES CLEARED!" : "TEAM OUT OF LIVES — ONE MORE ROUND?";
ClearRiotTargets();
foreach(var enemy in Scene.GetAllComponents<OverdriveEnemy>().ToArray())enemy.GameObject.Destroy();
if(IsOverdrive && string.IsNullOrEmpty(OverdriveResult))OverdriveResult="OUT OF TIME · TRY ANOTHER BUILD";
foreach(var state in Scene.GetAllComponents<PaintballCombatant>())state.AcceptHits=false;
foreach(var pawn in Players)pawn.EndRound();
foreach(var bot in Bots){bot.Components.Get<NavMeshAgent>()?.Stop();bot.Components.Get<ArenaOpponent>().CombatEnabled=false;}
foreach(var ball in Scene.GetAllComponents<OpponentPaintball>().ToArray())ball.GameObject.Destroy();
}
}
[Rpc.Host]
public async void SetMinutes(int minutes)
{
var caller=Rpc.Caller;
await GameTask.MainThread();
if(!this.IsValid() || caller!=Connection.Host || !ArenaMatch.SupportsRoundMinutes(minutes))return;
Minutes=minutes;
Networking.SetData("pb_minutes",Minutes.ToString());
}
[Rpc.Host]
public async void StartRound()
{
var caller=Rpc.Caller;
await GameTask.MainThread();
if(!this.IsValid() || caller!=Connection.Host)return;
BeginRound();
}
private void BeginRound()
{
if(!Networking.IsHost)return;
if(Mode==ArenaGameMode.Overdrive && !string.IsNullOrEmpty(SpawnReadinessMessage))
{pendingRoundStart=true;Notice=SpawnReadinessMessage;return;}
pendingRoundStart=false;
MarkerSoundAssignments=CharacterMarkerSound.Roll(MarkerSoundAssignments);
RoundId++;ActiveMinutes=Minutes;ActiveMode=Mode;Remaining=ActiveMinutes*60;Status=ModeTitle(ActiveMode);InRound=true;
StartPopRiotRound();StartOverdriveRound();RollRoundStarts();
foreach(var bot in Bots.ToArray())bot.GameObject.Destroy();
foreach(var ball in Scene.GetAllComponents<OpponentPaintball>().ToArray())ball.GameObject.Destroy();
foreach(var shield in Scene.GetAllComponents<ArenaShield>().ToArray())shield.GameObject.Destroy();
foreach(var pawn in Players)
{
pawn.Components.Get<PaintballCombatant>().ResetPaintHits();
pawn.ResetForRound(ArenaMap.PlayerSpawn(Scene,pawn.Seat,Spawns[pawn.Seat]),RoundId,ArenaMap.SpawnRotation(Scene,pawn.Seat,Rotation.Identity).Angles());
}
Scene.NavMesh.IsEnabled=true;Scene.NavMesh.IncludeStaticBodies=true;Scene.NavMesh.IncludeKeyframedBodies=false;
Scene.NavMesh.AgentHeight=67;Scene.NavMesh.AgentRadius=17;Scene.NavMesh.SetDirty();
ReconcileBots();EnsureShields();
Notice="AI fills empty seats. Joining players replace AI; departing players are replaced by AI.";
Log.Info($"PAINTBALL_ROUND_STARTED round={RoundId} humans={Players.Count()} ai={Bots.Count()} minutes={Minutes}");
NetworkEffects.ClearPaint();NetworkEffects.Sound("sounds/paintball/round_start.sound",Vector3.Zero,Guid.Empty);
}
internal void RestoreArenaSelection(ArenaGameMode mode,int minutes,bool publicLobby)
{
Mode=mode;Minutes=ArenaMatch.SupportsRoundMinutes(minutes) ? minutes : 3;PublicLobby=publicLobby;
}
private void ReconcileBots()
{
if(!InRound)return;
var humanSeats=Players.Select(x=>x.Seat).ToArray();
foreach(var bot in Bots.Where(x=>humanSeats.Contains(x.Seat)).ToArray())bot.GameObject.Destroy();
foreach(int seat in Enumerable.Range(0,6).Where(x=>!humanSeats.Contains(x)))
{
if(Bots.Any(x=>x.Seat==seat))continue;
var go=new GameObject(false,$"Opponent {RosterSelection.Names[seat].ToLowerInvariant()}"){WorldPosition=ArenaMap.PlayerSpawn(Scene,seat,Spawns[seat])};
go.Components.Create<NetworkBot>().Seat=seat;
var state=go.Components.Create<PaintballCombatant>();state.Team=seat+10;state.StayInMatch=true;
go.Components.Create<CloseCombat>();go.Components.Create<CharacterPaint>();go.Components.Create<CharacterVoice>();
var bot=go.Components.Create<ArenaOpponent>();bot.Character=RosterSelection.Names[seat].ToLowerInvariant();bot.CombatEnabled=true;bot.ContestMode=true;
go.Components.Create<CitizenOpponentPresentation>();
go.NetworkMode=NetworkMode.Object;go.Enabled=true;go.Network.SetOwnerTransfer(OwnerTransfer.Fixed);go.NetworkSpawn();
}
}
private void EnsureShields()
{
for(int i=Scene.GetAllComponents<ArenaShield>().Count();i<2;i++)
{
var go=new GameObject(false,"Arena shield");go.Components.Create<ArenaShield>();
go.NetworkMode=NetworkMode.Object;go.Enabled=true;go.Network.SetOwnerTransfer(OwnerTransfer.Fixed);go.NetworkSpawn();
}
}
}