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.
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;
}
}