managers/VictoryManager.cs

VictoryManager component that controls the post-game victory sequence. It declares the winner, freezes and recenters them on a podium, schedules tile disintegration, spawns confetti bursts and victory music, fades the screen and finally swaps back to a lobby scene.

NetworkingFile Access
using System;
using System.Collections.Generic;
using Sandbox;

/// <summary>
/// Handles the post-game victory sequence: declaring the winner, freezing them on a
/// gold podium, raining confetti, disintegrating the arena, and finally swapping back
/// to the lobby scene. <see cref="GameManager"/> kicks this off via <see cref="BeginVictory"/>
/// once only one player remains.
/// </summary>
public sealed class VictoryManager : Component
{
    [Property] TileManager TileManager { get; set; }
    [Property] public GameObject ConfettiPrefab { get; set; }
    [Property] public SoundEvent ConfettiSound { get; set; }
    [Property] public SoundEvent VictoryMusic { get; set; }
    [Property] public SceneFile SceneToLoadFinish { get; set; }

    /// <summary>Total length of the victory phase, from winner declared to scene swap.</summary>
    public const float ResultsDuration = 7f;
    /// <summary>How long the outward tile-drop wave takes inside <see cref="ResultsDuration"/>.</summary>
    public const float DisintegrationDuration = 5f;
    // Seconds the screen takes to fade to black at the tail of the results window.
    private const float ResultsFadeDuration = 2f;
    // Seconds the victory music ramps down for at the tail of the results window so the scene swap doesn't cut it off abruptly.
    private const float VictoryMusicFadeDuration = 3f;

    [Sync] public bool IsShowingResults { get; private set; } = false;
    [Sync] public GameObject Winner { get; private set; }
    [Sync] public TimeUntil ResultsTimer { get; private set; }

    public static VictoryManager Current { get; private set; }

    // Host-only guard so we only kick off the scene change once.
    private bool _hasFinishedResults = false;

    // Per-client: ensures the closing fade-out fires once during the results window.
    private bool _hasTriggeredResultsFade = false;

    // Per-client: starts the LerpTo on _victoryMusicHandle.Volume once the timer enters the window.
    private bool _hasTriggeredMusicFade = false;

    private SoundHandle _victoryMusicHandle;

    // Host-only: tiles queued to drop during results, sorted outward from the podium.
    private readonly List<(Tile tile, TimeUntil at)> _disintegrationSchedule = new();

    // Confetti bursts queued during results. Populated locally on every client when the
    // host's BroadcastBeginConfetti RPC arrives, then ticked locally to spawn prefab clones.
    // `initialVelocity` is per-burst world-space bias (inward toward the winner + upward)
    // applied to the spawned ParticleEffect so confetti arcs up and over the player.
    private readonly List<(TimeUntil at, Vector3 pos, Vector3 initialVelocity)> _confettiBurstSchedule = new();

    // Per-client: keeps the winner in a celebratory pose during results. Renderer animation
    // params are local-only (not network-synced), so each client sets the special_idle_states
    // enum on its own SkinnedModelRenderer.
    private const int WinnerIdleState = 1;  // citizen animgraph special_idle_states: 0=normal, 1=avatar_menu

    private static readonly Color[] ConfettiColors =
    {
        Color.Parse( "#FF5757" ) ?? Color.Red,
        Color.Parse( "#FFD93D" ) ?? Color.Yellow,
        Color.Parse( "#6BCB77" ) ?? Color.Green,
        Color.Parse( "#4D96FF" ) ?? Color.Blue,
        Color.Parse( "#FF6FFF" ) ?? Color.Magenta,
        Color.White,
    };

    protected override void OnEnabled()
    {
        Current = this;
    }

    protected override void OnDisabled()
    {
        // Stop the victory music handle so it doesn't carry over into the next scene
        if ( _victoryMusicHandle.IsValid() )
        {
            _victoryMusicHandle.Stop();
            _victoryMusicHandle = null;
        }

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

    protected override void OnFixedUpdate()
    {
        // Per-client: hold the winner in their victory pose regardless of host status.
        TickWinnerPose();
        // Per-client: drive the confetti schedule locally (populated by BroadcastBeginConfetti).
        TickConfettiBursts();

        // Per-client: trigger the closing screen fade once the results timer enters the fade window.
        if ( IsShowingResults && !_hasTriggeredResultsFade && (float)ResultsTimer <= ResultsFadeDuration )
        {
            _hasTriggeredResultsFade = true;
            ScreenFade.FadeOut( ResultsFadeDuration );
        }

        // Per-client: ramp the victory music down over the final stretch so the scene swap is more smooth
        // Mirrors the LerpTo pattern in Music.FadeOut — trigger once on entering the window, then
        // lerp every tick until the handle drops out / scene swaps.
        if ( IsShowingResults && !_hasTriggeredMusicFade && (float)ResultsTimer <= VictoryMusicFadeDuration )
        {
            _hasTriggeredMusicFade = true;
        }
        if ( _hasTriggeredMusicFade && _victoryMusicHandle.IsValid() )
        {
            _victoryMusicHandle.Volume = _victoryMusicHandle.Volume.LerpTo( 0f, Time.Delta / VictoryMusicFadeDuration );
        }

        if ( !Networking.IsHost ) return;
        if ( !IsShowingResults ) return;

        TickDisintegration();

        if ( !_hasFinishedResults && ResultsTimer <= 0f )
        {
            _hasFinishedResults = true;
            FinishGame();
        }
    }

    /// <summary>
    /// Host-only. Enter the post-game results phase: declare the winner, freeze the game,
    /// set up the podium tile, and start the timer that eventually swaps back to the lobby scene.
    /// <paramref name="winner"/> may be null (e.g. last two players were eliminated simultaneously).
    /// </summary>
    public void BeginVictory( PlayerController winner )
    {
        if ( !Networking.IsHost ) return;
        if ( IsShowingResults ) return;

        GameObject winnerGameObject = winner.IsValid() ? winner.GameObject : null;

        GameObject podiumGameObject = null;
        if ( winnerGameObject.IsValid() )
        {
            podiumGameObject = SetupPodium( winner );

            // Center the winner on the podium tile so a stray last step can't carry them off the edge.
            // Teleport must run while the rigidbody is still active so the position
            // change propagates through physics/network sync before the freeze lands.
            if ( podiumGameObject.IsValid() )
                BroadcastTeleportWinner( winnerGameObject, podiumGameObject.WorldPosition );

            BroadcastFreezeWinner( winnerGameObject );
        }

        ScheduleDisintegration( podiumGameObject );

        BroadcastResultsBegin( winnerGameObject );

        // Confetti: fan out via RPC so every client populates its own local burst schedule.
        // [Sync] doesn't propagate reliably on this scene-singleton (verified), but
        // [Rpc.Broadcast] bodies do run on clients (same mechanism BroadcastResultsBegin uses).
        if ( winnerGameObject.IsValid() && podiumGameObject.IsValid() )
        {
            Vector3 winnerForward = winnerGameObject.WorldRotation.Forward.WithZ( 0f );
            if ( winnerForward.LengthSquared > 0.001f )
            {
                BroadcastBeginConfetti( podiumGameObject.WorldPosition, winnerForward.Normal );
            }
        }

        string winnerName = winner?.Network?.Owner?.DisplayName ?? "Unknown";
        Log.Info( $"{winnerName} won! Showing results for {ResultsDuration}s." );
    }

    // Find the tile the winner is standing on and convert it into a golden podium. If they
    // were mid-air, spawn a fresh podium tile above the arena center and teleport them onto it.
    // Returns the podium tile's prefab root so callers can exclude it from the disintegration wave.
    private GameObject SetupPodium( PlayerController winner )
    {
        Vector3 winnerPos = winner.WorldPosition;
        // Start well above the player so we're clearly outside their body capsule, trace down
        // past their feet. Ignore both the player root and the separate ColliderObject — the
        // "Colliders" child isn't tagged "player", so WithoutTags alone won't exclude it.
        var trace = Scene.Trace.Ray( winnerPos + Vector3.Up * 200f, winnerPos + Vector3.Down * 60f )
            .WithoutTags( "player" )
            .IgnoreGameObjectHierarchy( winner.GameObject );
        if ( winner.ColliderObject.IsValid() )
            trace = trace.IgnoreGameObjectHierarchy( winner.ColliderObject );
        SceneTraceResult result = trace.Run();

        GameObject podiumGameObject = null;
        if ( result.Hit && result.GameObject.IsValid() )
        {
            // result.GameObject is the TileModelCollider; one level up is the tile prefab root,
            // which contains the Tile component on a sibling child. Don't use .Root — that walks
            // all the way up to the scene-level TileManager and would tint the whole arena.
            GameObject tileRoot = result.GameObject.Parent;
            Tile existingTile = tileRoot?.GetComponentInChildren<Tile>();
            if ( existingTile != null )
            {
                podiumGameObject = tileRoot;
            }
        }

        if ( podiumGameObject == null )
        {
            // Mid-air winner: spawn a podium tile above the arena center. The winner is teleported
            // onto it by BeginVictory, not here, so the snap happens after the freeze.
            podiumGameObject = SpawnPodiumGameObject();
            if ( podiumGameObject == null )
            {
                Log.Warning( "[Results] Could not produce a podium tile for mid-air winner." );
                return null;
            }
        }

        BroadcastConvertToPodium( podiumGameObject );
        return podiumGameObject;
    }

    // Host-only. Build an outward-from-podium drop schedule for every non-podium tile, spread
    // across DisintegrationDuration. Tile.BreakTile is host-authoritative and flips a [Sync]
    // _falling flag, so clients animate the cascade for free.
    private void ScheduleDisintegration( GameObject podiumGameObject )
    {
        _disintegrationSchedule.Clear();
        if ( TileManager == null ) return;

        Vector3 podiumPos = podiumGameObject.IsValid()
            ? podiumGameObject.WorldPosition
            : TileManager.WorldPosition;

        var candidates = new List<(Tile tile, float distance)>();
        foreach ( Tile tile in TileManager.GameObject.GetComponentsInChildren<Tile>() )
        {
            if ( !tile.IsValid() ) continue;
            // Tile lives on a child of the prefab root; comparing parents skips the podium tile.
            if ( podiumGameObject.IsValid() && tile.GameObject.Parent == podiumGameObject ) continue;

            // Use horizontal distance only so every layer ripples outward together,
            // instead of cascading top-layer-first to bottom-layer-last.
            float horizontalDistance = (tile.WorldPosition - podiumPos).WithZ( 0f ).Length;
            candidates.Add( (tile, horizontalDistance) );
        }

        candidates.Sort( ( a, b ) => a.distance.CompareTo( b.distance ) );

        for ( int i = 0; i < candidates.Count; i++ )
        {
            float t = candidates.Count > 1 ? (float)i / (candidates.Count - 1) : 0f;
            TimeUntil dropAt = t * DisintegrationDuration;
            _disintegrationSchedule.Add( (candidates[i].tile, dropAt) );
        }
    }

    // Runs on every client. Holds the winner in the citizen animgraph's avatar_menu pose
    // (hands-on-hips victory stance) for the duration of the results window.
    private void TickWinnerPose()
    {
        if ( !IsShowingResults || !Winner.IsValid() ) return;

        PlayerController pc = Winner.GetComponent<PlayerController>( true );
        if ( pc == null || !pc.Renderer.IsValid() ) return;

        pc.Renderer.Set( "special_idle_states", WinnerIdleState );
    }

    // Fanned out from the host so every client (including host) populates its own local
    // confetti schedule from the same pattern. Plain RPC — [Sync] doesn't propagate on this
    // scene-singleton (verified). Mirror of the BroadcastResultsBegin pattern.
    [Rpc.Broadcast]
    private void BroadcastBeginConfetti( Vector3 podiumPos, Vector3 winnerForward )
    {
        _confettiBurstSchedule.Clear();

        if ( winnerForward.LengthSquared < 0.001f ) return;
        winnerForward = winnerForward.Normal;

        // (delay seconds, yaw offset from "directly behind" in degrees, radius, height)
        // Spawn well behind the winner and at or below podium level so each burst starts
        // near the bottom of the WinnerFocusCam frame, then arcs up into view.
        var pattern = new (float delay, float yaw, float radius, float height)[]
        {
            ( 0.00f,    0f, 110f,  -5f ),  // dead behind
			( 0.60f,  -55f, 120f,   0f ),  // behind-left
			( 1.20f,   55f, 120f,   0f ),  // behind-right
			( 2.00f,    0f,  95f,  10f ),  // closer, slightly higher (over-the-top pop)
			( 2.80f,  -90f, 135f, -10f ),  // hard left flank, low
			( 2.80f,   90f, 135f, -10f ),  // hard right flank, low
			( 3.80f,    0f, 110f,  -5f ),  // final center pop
		};

        foreach ( var (delay, yaw, radius, height) in pattern )
        {
            Vector3 offsetDir = Rotation.FromYaw( yaw ) * (-winnerForward);
            Vector3 pos = podiumPos + offsetDir * radius + Vector3.Up * height;

            // Push each particle horizontally toward the winner (so bursts behind/around
            // them arc inward) and upward (so they rise into the camera frame before
            // drifting back down like paper). World-space — the prefab has LocalSpace=0.
            Vector3 inwardHorizontal = (podiumPos - pos).WithZ( 0f );
            Vector3 inwardDir = inwardHorizontal.LengthSquared > 0.001f ? inwardHorizontal.Normal : Vector3.Zero;
            Vector3 initialVelocity = inwardDir * 260f + Vector3.Up * 240f;

            _confettiBurstSchedule.Add( (delay, pos, initialVelocity) );
        }
    }

    private void TickConfettiBursts()
    {
        if ( _confettiBurstSchedule.Count == 0 ) return;

        for ( int i = _confettiBurstSchedule.Count - 1; i >= 0; i-- )
        {
            var entry = _confettiBurstSchedule[i];
            if ( entry.at <= 0f )
            {
                SpawnConfettiLocally( entry.pos, entry.initialVelocity );
                _confettiBurstSchedule.RemoveAt( i );
            }
        }
    }

    private void TickDisintegration()
    {
        if ( _disintegrationSchedule.Count == 0 ) return;

        for ( int i = _disintegrationSchedule.Count - 1; i >= 0; i-- )
        {
            var entry = _disintegrationSchedule[i];
            if ( !entry.tile.IsValid() )
            {
                _disintegrationSchedule.RemoveAt( i );
                continue;
            }
            if ( entry.at <= 0f )
            {
                entry.tile.BreakTile();
                _disintegrationSchedule.RemoveAt( i );
            }
        }
    }

    private GameObject SpawnPodiumGameObject()
    {
        if ( TileManager == null || !TileManager.TilePrefab.IsValid() ) return null;

        // Raise the podium above the top layer so it doesn't z-fight with the existing center tile.
        Vector3 spawnPos = TileManager.WorldPosition + Vector3.Up * 96f;
        GameObject tileGameObject = TileManager.TilePrefab.Clone( new CloneConfig
        {
            Parent = TileManager.GameObject,
            StartEnabled = true,
            Transform = new Transform( spawnPos )
        } );
        tileGameObject.Name = "Tile_Podium";
        tileGameObject.NetworkSpawn();
        return tileGameObject;
    }

    // Fanned out so every client locally disables the regular Tile behavior on this GameObject
    // and swaps in a PodiumTile component (which tints it gold + resets any in-progress wobble).
    [Rpc.Broadcast]
    private void BroadcastConvertToPodium( GameObject tileGameObject )
    {
        if ( !tileGameObject.IsValid() ) return;

        // Tile lives on a child of the prefab root, so search downward.
        Tile tile = tileGameObject.GetComponentInChildren<Tile>();
        if ( tile != null )
        {
            tile.SetTriggerEnabled( false );
            tile.Enabled = false;
        }

        if ( tileGameObject.GetComponent<PodiumTile>() == null )
        {
            tileGameObject.AddComponent<PodiumTile>();
        }
    }

    // Fanned out so every client sets the winner's local PlayerController flags. Matches the
    // pattern used by PlayerManager.EnablePlayersInput.
    [Rpc.Broadcast]
    private void BroadcastFreezeWinner( GameObject winnerGameObject )
    {
        if ( !winnerGameObject.IsValid() ) return;
        PlayerController pc = winnerGameObject.GetComponent<PlayerController>();
        if ( pc == null ) return;
        pc.UseInputControls = false;
        pc.UseCameraControls = false;
        // Clear any held input — otherwise the last WishVelocity (e.g. W still pressed)
        // keeps driving the controller forward after input is disabled.
        pc.WishVelocity = Vector3.Zero;

        // Disable the controller and freeze rigidbody motion so the winner-hop tick can drive
        // the transform directly without the move modes or physics overriding our position.
        pc.Enabled = false;
        Rigidbody rb = winnerGameObject.GetComponent<Rigidbody>();
        if ( rb != null ) rb.MotionEnabled = false;

        // PlayerController.OnUpdate is what pumps animation params each frame; once we
        // disable it, the last "sprinting" values stick and the winner runs in place.
        // Zero them so the citizen animgraph falls back to idle.
        SkinnedModelRenderer renderer = pc.Renderer;
        if ( renderer.IsValid() )
        {
            renderer.Set( "move_groundspeed", 0f );
            renderer.Set( "move_x", 0f );
            renderer.Set( "move_y", 0f );
            renderer.Set( "move_z", 0f );
            renderer.Set( "move_direction", 0f );
            renderer.Set( "b_grounded", true );
        }
    }

    // Only the owning client actually moves the transform — it owns the player's authority.
    [Rpc.Broadcast]
    private void BroadcastTeleportWinner( GameObject winnerGameObject, Vector3 position )
    {
        if ( !winnerGameObject.IsValid() ) return;

        // Lock input on every client so a held WASD / camera input can't carry the
        // player off the podium during the one-frame gap before BroadcastFreezeWinner.
        PlayerController pc = winnerGameObject.GetComponent<PlayerController>();
        if ( pc != null )
        {
            pc.UseInputControls = false;
            pc.UseCameraControls = false;
            pc.WishVelocity = Vector3.Zero;
        }

        if ( !winnerGameObject.Network.IsOwner ) return;
        winnerGameObject.WorldPosition = position;
    }

    private void SpawnConfettiLocally( Vector3 spawnPos, Vector3 initialVelocity )
    {
        if ( !ConfettiPrefab.IsValid() ) return;

        // One clone per color so each burst contains a mix of colored particles. The clone's
        // ParticleSphereEmitter is single-shot (Loop=false, DestroyOnEnd=true) so each cleans itself up.
        foreach ( Color color in ConfettiColors )
        {
            GameObject clone = ConfettiPrefab.Clone( new CloneConfig
            {
                StartEnabled = true,
                Transform = new Transform( spawnPos, Rotation.Identity )
            } );

            ParticleEffect effect = clone.GetComponent<ParticleEffect>();
            if ( effect != null )
            {
                effect.Tint = color;
                effect.InitialVelocity = initialVelocity;
            }
        }

        if ( ConfettiSound != null )
        {
            SoundHandle handle = Sound.Play( ConfettiSound, spawnPos );
            handle.Volume = 0.2f;
        }
    }

    // Fanned out from the host so every client (including host) sets the same local results
    // state and starts its own timer. Plain RPC instead of [Sync] because [Sync] props on this
    // scene-level component don't propagate to non-host clients in this project's setup.
    [Rpc.Broadcast]
    private void BroadcastResultsBegin( GameObject winnerGameObject )
    {
        IsShowingResults = true;
        Winner = winnerGameObject;
        ResultsTimer = ResultsDuration;

        // Fade out any in-scene music (the game track) so the victory cue can take over
        // cleanly. Music components live on dedicated GameObjects in the scene.
        foreach ( Music music in Scene.GetAllComponents<Music>() )
        {
            music.FadeOut();
        }

        if ( VictoryMusic != null )
        {
            _victoryMusicHandle = Sound.Play( VictoryMusic );
        }
    }

    private void FinishGame()
    {
        if ( !Networking.IsHost ) return;

        var loadOptions = new SceneLoadOptions();
        loadOptions.SetScene( SceneToLoadFinish );
        if ( !Game.ChangeScene( loadOptions ) )
        {
            Log.Error( $"Failed to load scene '{SceneToLoadFinish}'." );
        }
    }
}