Game/MansionGame.cs

Session manager component for the Mansion game. It handles lobby creation, player spawn prefab management, per-connection player GameObject bookkeeping, spawn point selection and seating on floor for the Bathrooms level, and connection lifecycle (OnActive/OnDisconnected).

NetworkingFile Access
using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading.Tasks;
using Sandbox;

namespace BrickJam;

/// <summary>
/// Core game/session manager. Scene-System port of the legacy <c>MansionGame : GameManager</c>.
///
/// In the Entity System this derived from <c>GameManager</c> and used <c>ClientJoined</c>/
/// <c>ClientDisconnect</c>. In the Scene System a session manager is a <see cref="Component"/>
/// that implements <see cref="Component.INetworkListener"/> and spawns a player prefab per
/// <see cref="Connection"/>.
/// </summary>
[Title( "Mansion Game" )]
[Category( "Game" )]
[Icon( "casino" )]
public sealed partial class MansionGame : Component, Component.INetworkListener
{
	public static MansionGame Instance { get; private set; }

	/// <summary>
	/// Create a server (lobby) if we're not already in a network session.
	/// </summary>
	[Property] public bool StartServer { get; set; } = true;

	/// <summary>
	/// The prefab cloned and network-spawned for each connected player.
	/// </summary>
	[Property] public GameObject PlayerPrefab { get; set; }

	/// <summary>
	/// Which level the session is currently in. Replaces the old <c>CurrentLevel.Type</c> lookup
	/// while the full Level subsystem is still being ported. The Level subsystem will own setting this.
	/// </summary>
	// FromHost: the GameManager is an unowned snapshot object and this is written host-side only (SetLevel),
	// so plain [Sync] wouldn't reliably reach clients - which also drives client-side music/level visuals.
	[Property, Sync( SyncFlags.FromHost )] public LevelType CurrentLevelType { get; set; } = LevelType.None;

	public static int Seed { get; set; } = 0;
	public static Random Random { get; set; } = new Random();

	// Server-side bookkeeping of the GameObject spawned for each connection, so we can clean it
	// up on disconnect. Not networked - only meaningful on the host.
	private readonly Dictionary<Guid, GameObject> spawnedPlayers = new();

	protected override void OnAwake()
	{
		Instance = this;
		Upgrading.Creator.Build();
	}

	protected override void OnDestroy()
	{
		if ( Instance == this )
			Instance = null;
	}

	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() );
		}
	}

	public static void ResetRandomSeed()
	{
		if ( !Networking.IsHost ) return;

		Seed = DateTime.UtcNow.Ticks.GetHashCode();
		Random = new Random( Seed );
	}

	/// <summary>
	/// A client has finished loading and entered the game. Host-only.
	/// Replaces the legacy <c>ClientJoined</c>.
	/// </summary>
	public void OnActive( Connection channel )
	{
		Log.Info( $"Player '{channel.DisplayName}' has joined the game" );

		GiveSlot( channel );

		// Joined mid-round past the grace window: spectate until the next level (legacy ClientJoined).
		if ( ShouldSpectateOnJoin )
			SpawnSpectator( channel, GetSpawnPoint() );
		else
			SpawnPlayer( channel );
	}

	/// <summary>
	/// A client has disconnected. Host-only. Replaces the legacy <c>ClientDisconnect</c>.
	/// </summary>
	public void OnDisconnected( Connection channel )
	{
		ReleaseSlot( channel );
		RemoveSpectator( channel );

		if ( spawnedPlayers.Remove( channel.Id, out var go ) && go.IsValid() )
			go.Destroy();
	}

	private void SpawnPlayer( Connection channel )
	{
		if ( !PlayerPrefab.IsValid() )
		{
			Log.Warning( "MansionGame.PlayerPrefab is not set - cannot spawn player." );
			return;
		}

		var spawn = GetSpawnPoint();
		var go = PlayerPrefab.Clone( spawn, name: $"Player - {channel.DisplayName}" );
		go.NetworkSpawn( channel );

		spawnedPlayers[channel.Id] = go;
	}

	public Transform GetSpawnPoint() => GetSpawnPoint( CurrentLevelType );

	public Transform GetSpawnPoint( LevelType level )
	{
		var spawnPoints = Scene.GetAllComponents<PlayerSpawn>()
			.Where( x => x.LevelType == level )
			.ToList();

		var marker = Random.Shared.FromList( spawnPoints, null );

		if ( marker is not null )
		{
			var t = marker.WorldTransform.WithScale( 1f );

			// Bathrooms ONLY: its single PlayerSpawn marker is authored up near the ceiling in bathrooms.vmap,
			// so spawning on it verbatim drops everyone into the roof. Until the marker is moved in Hammer,
			// seat bathroom spawns on the floor by tracing straight DOWN from the marker. Every other level
			// keeps its verbatim marker (placed correctly) - so this can't relocate a good spawn (which is what
			// broke the dungeon last time we floor-snapped globally).
			if ( level == LevelType.Bathrooms )
				t = SeatOnFloor( t );

			return t;
		}

		// Only when a level has NO marker at all: drop into the centre of the level's region rather than the
		// game-manager origin (which is up in the sky for a deep level). Add PlayerSpawn entities for the level.
		Log.Warning( $"[MansionGame] No PlayerSpawn for level '{level}'; using the level's WorldBox centre. " +
			$"Add PlayerSpawn entities with LevelType={level} to the map." );

		var box = CurrentLevel?.WorldBox ?? new BBox( WorldPosition - 128f, WorldPosition + 128f );
		return new Transform( box.Center.WithZ( box.Mins.z ), WorldRotation ).WithScale( 1f );
	}

	/// <summary>
	/// Seat a spawn transform on solid floor by tracing straight down from just above it. A strictly-downward
	/// ray can only hit a top surface, so it never lands the player "inside the roof"; if nothing solid is
	/// below (within the level's WorldBox), the original transform is returned untouched. Used for the
	/// Bathrooms level whose marker sits near the ceiling.
	/// </summary>
	private Transform SeatOnFloor( Transform spawn )
	{
		// Start AT the marker (it sits in open air up near the ceiling) and trace straight down. Starting any
		// higher risks beginning above the ceiling slab and landing the player on top of it.
		var from = spawn.Position;
		var bottomZ = (CurrentLevel?.WorldBox.Mins.z ?? spawn.Position.z - 1024f) - 64f;
		var to = spawn.Position.WithZ( bottomZ );

		var trace = Scene.Trace.Ray( from, to )
			.WithoutTags( "player", "npc", "loot", "nocollide", "trigger", "usable", "door" )
			.Run();

		return trace.Hit
			? spawn.WithPosition( trace.HitPosition + Vector3.Up * 8f )
			: spawn;
	}
}