GameManager/PilotGame.cs
/// <summary>
/// Main game manager for FP4. Handles player connections, spawn logic,
/// game mode selection, and global RPC routing.
/// </summary>
public sealed class PilotGame : GameObjectSystem<PilotGame>, Component.INetworkListener, ISceneStartup
{
public static FPGameMode Gamemode => GameSettings.Gamemode;
public static bool MatchEnded { get; private set; } = false;
public static bool MatchWasDraw { get; private set; } = false;
public static string WinnerName { get; private set; } = "";
public static string MatchEndReason { get; private set; } = "";
public static float MatchTimeRemaining { get; private set; } = 0f;
private float _matchStartTime;
private float _lastTimeRemaining;
private float _restartAtTime = -1f;
public PilotGame( Scene scene ) : base( scene )
{
Listen( Stage.FinishUpdate, 0, HostFixedUpdate, "HostFixedUpdate" );
_matchStartTime = Time.Now;
_lastTimeRemaining = GetConfiguredMatchLengthSeconds();
MatchTimeRemaining = _lastTimeRemaining;
}
private void HostFixedUpdate()
{
if ( !Networking.IsHost ) return;
UpdateMatchRules();
foreach ( var pawn in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
pawn.HostFixedUpdate();
}
void ISceneStartup.OnHostInitialize()
{
if ( !Networking.IsActive )
{
Networking.CreateLobby( new Sandbox.Network.LobbyConfig
{
MaxPlayers = 16,
Privacy = Sandbox.Network.LobbyPrivacy.Public
} );
}
}
// -------------------------------------------------------------------------
// INetworkListener
// -------------------------------------------------------------------------
public void OnActive( Connection channel )
{
Log.Info( $"[PilotGame] OnActive: {channel.DisplayName}" );
channel.CanSpawnObjects = false;
var prefab = ResourceLibrary.Get<PrefabFile>( "prefabs/player/player.prefab" );
if ( prefab == null )
{
Log.Error( "[PilotGame] player.prefab not found in ResourceLibrary!" );
return;
}
var playerGo = SceneUtility.GetPrefabScene( prefab )?.Clone();
if ( playerGo == null )
{
Log.Error( "[PilotGame] Failed to clone player prefab!" );
return;
}
playerGo.Name = $"Player ({channel.DisplayName})";
var pawn = playerGo.GetComponent<PlayerPawn>( true );
if ( pawn == null )
{
Log.Error( "[PilotGame] No PlayerPawn found in player prefab!" );
playerGo.Destroy();
return;
}
// Set spawn position BEFORE NetworkSpawn — client receives the correct initial transform.
// Can't set it after since the client owns the Rigidbody and would overwrite any host change.
var spawn = GetRandomSpawnpoint();
playerGo.WorldPosition = spawn.Position;
playerGo.WorldRotation = spawn.Rotation;
playerGo.NetworkSpawn( channel );
// In instagib, skip ship select — auto-assign the locked ship and spawn immediately.
if ( Gamemode == FPGameMode.Instagib )
{
var shipData = ResourceLibrary.Get<ShipData>( "ships/wingsship.ship" );
if ( shipData != null )
{
pawn.Data = shipData;
pawn.HasSelectedShip = true;
pawn.TriggerRespawn( 0.1f );
}
}
// In normal mode: pawn.IsAlive stays false until RequestNewShip is called from ship select.
BroadcastChat( channel.DisplayName, "joined the game." );
Log.Info( $"[PilotGame] Spawned pawn for {channel.DisplayName} — IsAlive={pawn.IsAlive}" );
}
public void OnDisconnected( Connection channel )
{
BroadcastChat( channel.DisplayName, "disconnected." );
}
// -------------------------------------------------------------------------
// Chat
// -------------------------------------------------------------------------
[ConCmd( "fp4_chat" )]
public static void SendChat( string message )
{
if ( !Networking.IsHost ) return;
BroadcastChat( Rpc.Caller.DisplayName, message );
}
[Rpc.Broadcast]
public static void BroadcastChat( string name, string message )
{
ChatEvent.Run( name, message );
}
// -------------------------------------------------------------------------
// Kill Feed
// -------------------------------------------------------------------------
[Rpc.Broadcast]
public static void BroadcastKillMessage( string killer, string victim, string method )
{
KillFeedEvent.Run( killer, victim, method );
}
// -------------------------------------------------------------------------
// Ship selection
// -------------------------------------------------------------------------
[ConCmd( "fp4_newship" )]
public static void PlayerNewShip( string newShip )
{
// Legacy console command - clients should use PlayerPawn.RequestNewShip() instead
// Kept for server admin use
if ( !Networking.IsHost ) return;
var caller = Rpc.Caller;
foreach ( var pawn in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
{
if ( pawn.Network.Owner == caller )
{
pawn.RequestNewShip( newShip );
return;
}
}
}
[ConCmd( "fp4_suicide" )]
public static void DoPlayerSuicide()
{
// Legacy console command - clients should use PlayerPawn.RequestSuicide() instead
if ( !Networking.IsHost ) return;
var caller = Rpc.Caller;
foreach ( var pawn in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
{
if ( pawn.Network.Owner == caller )
{
pawn.TakeDamage( 1000f, null );
return;
}
}
}
[ConCmd( "fp4_addbot" )]
public static void AddBot()
{
if ( !Networking.IsHost ) return;
GameSettings.BotCount++;
GetBotSpawner()?.AddBot();
}
[ConCmd( "fp4_removebot" )]
public static void RemoveBot()
{
if ( !Networking.IsHost ) return;
GameSettings.BotCount = Math.Max( 0, GameSettings.BotCount - 1 );
GetBotSpawner()?.RemoveBot();
}
private static BotSpawner GetBotSpawner() =>
Game.ActiveScene?.GetAllComponents<BotSpawner>().FirstOrDefault();
private void UpdateMatchRules()
{
if ( MatchEnded )
{
if ( _restartAtTime > 0f && Time.Now >= _restartAtTime )
RestartMatch();
return;
}
// Time limit
var matchLengthSeconds = GetConfiguredMatchLengthSeconds();
if ( matchLengthSeconds > 0f )
{
var elapsed = Time.Now - _matchStartTime;
MatchTimeRemaining = MathF.Max( 0f, matchLengthSeconds - elapsed );
if ( MatchTimeRemaining <= 0f )
{
EndMatchFromTopScore( "Time limit reached" );
return;
}
}
else
{
MatchTimeRemaining = 0f;
}
// Score limit
var scoreLimit = Math.Max( 0, GameSettings.MatchScoreLimit );
if ( scoreLimit > 0 )
{
foreach ( var pawn in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
{
if ( pawn == null || !pawn.IsValid() ) continue;
if ( pawn.Score >= scoreLimit )
{
EndMatchFromTopScore( $"Reached {scoreLimit} points" );
return;
}
}
}
}
public static bool CanScoreAndDamage() => !MatchEnded;
private void EndMatchFromTopScore( string reason )
{
var contenders = Game.ActiveScene.GetAllComponents<PlayerPawn>()
.Where( p => p != null && p.IsValid() )
.ToList();
if ( contenders.Count == 0 ) return;
var topScore = contenders.Max( p => p.Score );
var top = contenders.Where( p => p.Score == topScore ).ToList();
if ( top.Count > 1 )
{
EndMatchDraw( reason );
return;
}
EndMatch( top[0], reason );
}
private void EndMatch( PlayerPawn winner, string reason )
{
if ( MatchEnded || winner == null ) return;
MatchEnded = true;
MatchWasDraw = false;
MatchEndReason = reason ?? "Match ended";
WinnerName = winner.IsBot
? (string.IsNullOrWhiteSpace( winner.BotName ) ? "Bot" : winner.BotName)
: (winner.Network?.Owner?.DisplayName ?? "Player");
var delay = Math.Max( 3, GameSettings.MatchRestartDelaySeconds );
_restartAtTime = Time.Now + delay;
// Match winner bonus XP (persistent stat; skipped for bots by AddStat).
winner.AddStat( "xp", 50 );
foreach ( var pawn in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
{
if ( pawn == null || !pawn.IsValid() ) continue;
pawn.OnMatchCompleted( pawn == winner );
}
BroadcastChat( "SYSTEM", $"{WinnerName} wins! {MatchEndReason}" );
}
private void EndMatchDraw( string reason )
{
if ( MatchEnded ) return;
MatchEnded = true;
MatchWasDraw = true;
WinnerName = "";
MatchEndReason = reason ?? "Match ended";
var delay = Math.Max( 3, GameSettings.MatchRestartDelaySeconds );
_restartAtTime = Time.Now + delay;
foreach ( var pawn in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
{
if ( pawn == null || !pawn.IsValid() ) continue;
pawn.OnMatchCompleted( wonMatch: false );
}
BroadcastChat( "SYSTEM", $"Draw! {MatchEndReason}" );
}
private void RestartMatch()
{
_matchStartTime = Time.Now;
_restartAtTime = -1f;
MatchEnded = false;
MatchWasDraw = false;
MatchEndReason = "";
WinnerName = "";
foreach ( var pawn in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
{
if ( pawn == null || !pawn.IsValid() ) continue;
pawn.Kills = 0;
pawn.Deaths = 0;
pawn.Score = 0;
if ( pawn.IsBot )
{
pawn.Respawn();
}
else
{
pawn.TriggerRespawn( 0.1f );
}
}
}
private PlayerPawn FindTopScorer()
{
PlayerPawn best = null;
foreach ( var pawn in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
{
if ( pawn == null || !pawn.IsValid() ) continue;
if ( best == null || pawn.Score > best.Score )
best = pawn;
}
return best;
}
private float GetConfiguredMatchLengthSeconds()
{
var minutes = Math.Max( 0, GameSettings.MatchTimeLimitMinutes );
return minutes * 60f;
}
// -------------------------------------------------------------------------
// Spawn points
// -------------------------------------------------------------------------
/// <summary>Radius around each spawn point within which ships are randomly scattered.</summary>
public static float SpawnScatterRadius { get; set; } = 20f;
public static Transform GetRandomSpawnpoint()
{
var spawnpoints = Game.ActiveScene.GetAllComponents<SpawnPoint>().ToList();
if ( spawnpoints.Count == 0 )
{
Log.Warning( "[PilotGame] No SpawnPoints found — spawning at default position." );
return new Transform( new Vector3( 0, 0, 500f ) );
}
var sp = spawnpoints.OrderBy( _ => Guid.NewGuid() ).First().Transform.World;
// Scatter within a flat radius so ships don't occupy the same point
var angle = Game.Random.Float( 0f, 360f ).DegreeToRadian();
var radius = Game.Random.Float( 0f, SpawnScatterRadius );
var offset = new Vector3( MathF.Cos( angle ) * radius, MathF.Sin( angle ) * radius, 0f );
return new Transform( sp.Position + offset, sp.Rotation );
}
}
/// <summary>Simple event helper for chat messages.</summary>
public static class ChatEvent
{
public static Action<string, string> OnChat;
public static void Run( string name, string msg ) => OnChat?.Invoke( name, msg );
}
/// <summary>Simple event bridge so the kill feed UI can receive kill notifications without a direct type reference.</summary>
public static class KillFeedEvent
{
public static Action<string, string, string> OnKill;
public static void Run( string killer, string victim, string method ) => OnKill?.Invoke( killer, victim, method );
}