lobby/LobbyNetworkSpawner.cs

Component used in the lobby scene to spawn player GameObjects and synchronize leaderboard state. It creates a lobby on first load when needed, spawns players for connections on OnActive and OnStart, picks spawn positions with jitter, and broadcasts a leaderboard snapshot to newly joined clients.

Networking
using System.Linq;
using System.Threading.Tasks;
using Sandbox;

/// <summary>
/// Drop-in replacement for <see cref="Sandbox.NetworkHelper"/> in the lobby scene.
/// Handles three spawn cases:
/// <list type="bullet">
/// <item>Initial host startup — creates the lobby and (via <see cref="OnActive"/>) spawns the host.</item>
/// <item>A client joining a live lobby — spawns their player via <see cref="OnActive"/>.</item>
/// <item>Game→lobby scene reload — host-only <see cref="OnStart"/> pass iterates <see cref="Connection.All"/>
/// and spawns a player for any connection that doesn't already have one. Needed because the
/// per-client <c>Scene.Load</c> swap used by <c>VictoryManager.BroadcastLoadLobbyScene</c>
/// doesn't re-fire <see cref="Component.INetworkListener.OnActive"/> for existing connections.</item>
/// </list>
/// </summary>
public sealed class LobbyNetworkSpawner : Component, Component.INetworkListener
{
    /// <summary>Create a lobby on first load if one isn't already active.</summary>
    [Property] public bool StartServer { get; set; } = true;

    /// <summary>Prefab cloned for each connected player.</summary>
    [Property] public GameObject PlayerPrefab { get; set; }

    /// <summary>Size of the box (centered on each <see cref="SpawnPoint"/>) that players are randomly spawned in.</summary>
    [Property] public Vector3 SpawnAreaSize { get; set; } = new Vector3( 150f, 150f, 0f );

    /// <summary>Draw the spawn jitter box around each <see cref="SpawnPoint"/> in the editor.</summary>
    [Property] public bool ShouldDrawSpawnGizmo { get; set; } = false;

    // Async, runs before OnStart. On the host's very first lobby load Networking isn't
    // active yet — we create the lobby here. Game→lobby reloads skip this branch because
    // Networking is already active by then.
    protected override async Task OnLoad()
    {
        if ( Scene.IsEditor ) return;

        if ( StartServer && !Networking.IsActive )
        {
            LoadingScreen.Title = "Creating Lobby";
            await Task.DelayRealtimeSeconds( 0.1f );
            Networking.CreateLobby( new() );
        }
    }

    // Handles transition from game scene to lobby scene to spawn players.
    // The per-client scene swap doesn't re-fire OnActive for connections that were already joined, 
    // so the host spawns any missing players here.
    protected override void OnStart()
    {
        if ( !Networking.IsHost ) return;
        if ( !Networking.IsActive ) return;

        foreach ( Connection client in Connection.All )
        {
            if ( HasPlayerControllerFor( client ) ) continue;
            Log.Info( $"LobbyNetworkSpawner: OnStart for {client}, spawning player." );
            SpawnFor( client );
        }
    }

    // Handles the initial bootup and any client joining a live lobby to spawn the player and synchronize the leaderboard.
    // Existing connections on a scene reload are covered by OnStart instead.
    public void OnActive( Connection channel )
    {
        // Guard against double-spawn — OnStart may have already covered this connection.
        if ( !HasPlayerControllerFor( channel ) )
        {
            Log.Info( $"LobbyNetworkSpawner: OnActive for {channel}, spawning player." );
            SpawnFor( channel );
        }

        // Send the current leaderboard just to the new joiner so they aren't stuck at 0/0
        // for players who racked up wins before they joined.
        if ( Networking.IsHost )
        {
            (string[] ids, int[] counts, string previousWinnerId) = Leaderboard.Snapshot();
            using ( Rpc.FilterInclude( channel ) )
            {
                BroadcastLeaderboardSnapshot( ids, counts, previousWinnerId );
            }
        }
    }

    [Rpc.Broadcast]
    private void BroadcastLeaderboardSnapshot( string[] ids, int[] counts, string previousWinnerId )
    {
        Leaderboard.ApplySnapshot( ids, counts, previousWinnerId );
        RefreshAllCrowns();
    }

    private void RefreshAllCrowns()
    {
        foreach ( PlayerReadyState state in Scene.GetAllComponents<PlayerReadyState>() )
        {
            state.RefreshCrown();
        }
    }

    // Returns true if the given connection already has a REAL player controller in
    // the scene. Bots are host-owned, so without the BotController exclusion the host would
    // appear to already have a player as soon as any bot spawned — and their real
    // player would never get created.
    private bool HasPlayerControllerFor( Connection client )
    {
        return Scene.GetAllComponents<PlayerController>()
            .Any( pc => pc.IsValid()
                && pc.Network.Active
                && pc.Network.OwnerId == client.Id
                && !pc.IsBot() );
    }

    // Spawn a player for the given connection. The host owns this logic, and the engine
    // will automatically replicate the new player to the client.
    private void SpawnFor( Connection client )
    {
        if ( !PlayerPrefab.IsValid() ) return;

        Transform spawnTransform = FindSpawnLocation().WithScale( 1f );
        GameObject player = PlayerPrefab.Clone( spawnTransform, name: $"Player - {client.Name}" );
        player.NetworkSpawn( client );
    }

    // Pick a random spawn point and jitter it inside SpawnAreaSize.
    public Transform FindSpawnLocation()
    {
        SpawnPoint[] points = Scene.GetAllComponents<SpawnPoint>().ToArray();
        if ( points.Length == 0 ) return WorldTransform;

        SpawnPoint anchor = Game.Random.FromArray( points );
        Transform anchorTransform = anchor.WorldTransform;

        Vector3 half = SpawnAreaSize * 0.5f;
        Vector3 offset = new Vector3(
            Game.Random.Float( -half.x, half.x ),
            Game.Random.Float( -half.y, half.y ),
            Game.Random.Float( -half.z, half.z )
        );
        return anchorTransform.WithPosition( anchorTransform.Position + offset );
    }

    // Debug element that draws a gizmo around the spawn area to visualize the area.
    protected override void DrawGizmos()
    {
        if ( !ShouldDrawSpawnGizmo ) return;
        if ( SpawnAreaSize.IsNearZeroLength ) return;

        SpawnPoint[] points = Scene.GetAllComponents<SpawnPoint>().ToArray();
        if ( points.Length == 0 ) return;

        Gizmo.Draw.Color = Gizmo.IsSelected ? Color.Cyan : Color.Cyan.WithAlpha( 0.4f );
        BBox box = BBox.FromPositionAndSize( Vector3.Zero, SpawnAreaSize );

        foreach ( SpawnPoint point in points )
        {
            using ( Gizmo.Scope( "lobby-spawn-area", point.WorldTransform ) )
            {
                Gizmo.Draw.LineBBox( box );
            }
        }
    }
}