Components/PodiumStage.cs

Component that manages the podium sequence. It spawns visual-only clones of the top 3 cars, creates nameplate UI, destroys real cars on host when clones are shown, and tracks the active PodiumStage instance.

NetworkingFile Access
using Machines.Events;
using Machines.GameModes;
using Machines.Player;
using Machines.UI;

namespace Machines.Components;

/// <summary>
/// Orchestrates the podium sequence; start disabled, game mode enables on podium state.
/// </summary>
public sealed class PodiumStage : Component, IGameStateChanged
{
	/// <summary>
	/// The currently active podium stage (null if none active).
	/// </summary>
	public static PodiumStage Current { get; private set; }

	/// <summary>
	/// Empty child the podium camera blends toward.
	/// </summary>
	[Property]
	public GameObject CameraTarget { get; set; }

	/// <summary>
	/// Cloned car visuals currently on the podium.
	/// </summary>
	private readonly List<GameObject> _clones = new();

	protected override void OnEnabled()
	{
		Current = this;

		// PodiumCamera takes over automatically (highest priority).
	}

	protected override void OnStart()
	{
		SpawnCarClones();
	}

	protected override void OnDisabled()
	{
		DestroyCarClones();

		if ( Current == this )
			Current = null;
	}

	public void OnGameStateChanged( GameModeState oldState, GameModeState newState )
	{
		// Clean up if podium ends while still active.
		if ( oldState == GameModeState.Podium && newState != GameModeState.Podium )
		{
			DestroyCarClones();
		}
	}

	private void SpawnCarClones()
	{
		DestroyCarClones();

		var mode = BaseGameMode.Current;
		if ( mode is not RaceMode race )
			return;

		var standings = race.GetComponent<RaceStandings>();
		if ( !standings.IsValid() )
			return;

		var ordered = standings.GetStandings()
			.Where( s => !s.IsGhost )
			.OrderBy( s => s.Position )
			.Take( 3 )
			.ToList();

		for ( int i = 0; i < ordered.Count; i++ )
		{
			var standing = ordered[i];
			var podiumPosition = i + 1;

			var spot = PodiumSpot.ForPosition( podiumPosition );
			if ( !spot.IsValid() )
				continue;

			// Real cars may already be destroyed by the time a client's podium enables.
			var model = ResolveCarModel( standing );
			if ( !model.IsValid() )
				continue;

			var clone = CreateCarVisual( spot, standing, model, i );
			if ( clone.IsValid() )
				_clones.Add( clone );
		}

		// Clones are showing; real cars aren't needed again (scene reloads after podium).
		DestroyRealCars();
	}

	/// <summary>
	/// Destroy all real race cars once clones are shown; host-only, destruction replicates to clients.
	/// </summary>
	private void DestroyRealCars()
	{
		if ( Networking.IsActive && !Networking.IsHost )
			return;

		foreach ( var car in Scene.GetAllComponents<Car>().ToList() )
		{
			if ( car.IsValid() )
				car.GameObject.Destroy();
		}
	}

	private void DestroyCarClones()
	{
		foreach ( var clone in _clones )
		{
			if ( clone.IsValid() )
				clone.Destroy();
		}

		_clones.Clear();
	}

	/// <summary>
	/// Resolves the clone's model from connection user data; bots and unpicked players get the first car resource.
	/// </summary>
	private static Model ResolveCarModel( RaceStandings.Standing standing )
	{
		Resources.CarResource resource = null;

		if ( !standing.IsBot )
		{
			var connection = Connection.All.FirstOrDefault( c => c.SteamId.ValueUnsigned == standing.SteamId );
			var path = connection?.GetUserData( "preferred_car" );
			if ( !string.IsNullOrEmpty( path ) )
				resource = ResourceLibrary.Get<Resources.CarResource>( path );
		}

		resource ??= ResourceLibrary.GetAll<Resources.CarResource>().OrderBy( r => r.ResourcePath ).FirstOrDefault();
		return resource?.Model;
	}

	/// <summary>
	/// Creates a visual-only clone of a car at the given podium spot with a nameplate.
	/// </summary>
	private GameObject CreateCarVisual( PodiumSpot spot, RaceStandings.Standing standing, Model model, int placementIndex )
	{
		var go = new GameObject( true, $"PodiumCar_{spot.Position}" );
		go.SetParent( spot.GameObject, false );

		var renderer = go.AddComponent<ModelRenderer>();
		renderer.Model = model;
		renderer.Tint = standing.Color;

		// Nameplate above the car.
		var nameplateGo = new GameObject( true, $"PodiumNameplate_{spot.Position}" );
		nameplateGo.Parent = go;
		nameplateGo.LocalPosition = Vector3.Up * 32f;
		nameplateGo.LocalRotation = Rotation.Identity;

		var worldPanel = nameplateGo.AddComponent<WorldPanel>();
		worldPanel.RenderScale = 0.25f;
		worldPanel.LookAtCamera = true;

		// Wide enough for long names (default is 512).
		worldPanel.PanelSize = new Vector2( 2048, 512 );

		var nameplate = nameplateGo.AddComponent<Nameplate>();
		nameplate.PlayerName = standing.Name;
		nameplate.Color = standing.Color;
		nameplate.PlacementIndex = placementIndex;

		return go;
	}
}