MultiplayerSession.cs
using Sandbox;
using Sandbox.Network;
using System;
namespace PaintballBaddies;
/// <summary>Native lobby, connection ownership and host-owned six-seat match.</summary>
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 string Status { get; private set; }="LOBBY";
[Sync(SyncFlags.FromHost)] public int RoundId { get; private set; }
public const string Protocol="pb-beta-1";
[Sync(SyncFlags.FromHost)] public bool PublicLobby {get;private set;}=true;
public string Notice { get; private set; }="Host a game or browse available matches.";
public string JoinAddress { get; set; }="";
public List<LobbyInformation> Lobbies { get; private set; }=new();
private bool registered;
private float reconcileTime;
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],x.HitsLanded,x.PaintHits,
x.GameObject==LocalPlayer(Scene)?.GameObject)).OrderByDescending(x=>x.Points).ThenBy(x=>x.Received).ThenBy(x=>x.Name).ToArray();
protected override void OnStart()
{
if(!PaintImpactSystem.Find(Scene).IsValid())Components.Create<PaintImpactSystem>();
if(Online)TrainingRange.CreateLocalHud(Scene);
}
public void Host(bool publicGame=true)
{
if(Online)return;
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=$"Paintball Baddies / {ArenaMap.DisplayTitle(Scene)}",MaxPlayers=6,Hidden=false,Privacy=publicGame ? LobbyPrivacy.Public : LobbyPrivacy.FriendsOnly,
DestroyWhenHostLeaves=true,AutoSwitchToBestHost=false});
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 ? "Public game created. Anyone with this build can join. Start the round when ready."
: "Friends-only game created. Start the round when ready.";
}
public void Join(ulong id)
{
if(Online || Networking.IsConnecting)return;
if(id==0){Notice="Enter a valid host Steam ID or lobby ID.";return;}
Scene.GetAllComponents<PaintballControls>().FirstOrDefault()?.StartPractice();
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()=>Networking.Disconnect();
public bool AcceptConnection(Connection channel,ref string reason)
{
if(Players.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);
foreach(var pawn in Players.Where(x=>x.ConnectionId==channel.Id).ToArray())pawn.GameObject.Destroy();
reconcileTime=0;
}
private void SpawnPlayer(Connection channel)
{
if(Players.Any(x=>x.ConnectionId==channel.Id))return;
if(channel!=Connection.Host)channel.CanSpawnObjects=false;
if(Players.Count()>=6)return;
var occupied=Players.Select(x=>x.Seat).ToArray();
int seat=Enumerable.Range(0,6).FirstOrDefault(x=>!occupied.Contains(x));
foreach(var bot in Bots.Where(x=>x.Seat==seat).ToArray())bot.GameObject.Destroy();
var spawn=ArenaMap.PlayerSpawn(Scene,seat,Spawns[seat]);
var go=GameObject.Clone("prefabs/network_baddie.prefab",new Transform(spawn),startEnabled:false,name:$"Player {seat+1}");
go.Enabled=true;
var pawn=go.Components.Get<NetworkPawn>();pawn.Seat=seat;pawn.ConnectionId=channel.Id;
go.Components.Get<PaintballCombatant>().Team=seat+10;
go.Components.Get<PaintballCombatant>().AcceptHits=InRound || Status=="LOBBY";
go.Components.Get<PlayerProfile>().DisplayName=PlayerProfile.CleanName(channel.DisplayName);
go.NetworkMode=NetworkMode.Object;go.Enabled=true;go.Network.SetOwnerTransfer(OwnerTransfer.Fixed);go.NetworkSpawn(channel);
if(InRound)pawn.ResetForRound(spawn,RoundId);
reconcileTime=0;
}
protected override void OnUpdate()
{
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);
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);
reconcileTime-=Time.Delta;
if(reconcileTime<=0){reconcileTime=1;ReconcileBots();EnsureShields();}
if(!InRound)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";
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 || minutes<1 || minutes>3)return;
Minutes=minutes;
}
[Rpc.Host]
public async void StartRound()
{
var caller=Rpc.Caller;
await GameTask.MainThread();
if(!this.IsValid() || caller!=Connection.Host)return;
RoundId++;Remaining=Minutes*60;Status="PAINT RUSH";InRound=true;
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);
}
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();
NetworkEffects.ClearPaint();NetworkEffects.Sound("sounds/paintball/round_start.sound",Vector3.Zero,Guid.Empty);
}
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();
}
}
}