MenuManager.cs
using Sandbox;
using System.Collections.Generic;
/// <summary>
/// Drives the pre-game flow: Menu (camera snapped to MenuStartAnchor,
/// menu overlaid on top) -> fade out -> camera pans to the gameplay
/// position -> Playing. RoundManager and GameHud both check this
/// component's State so they stay dormant/hidden until the pan finishes.
/// </summary>
public sealed class MenuManager : Component
{
public enum MenuState
{
Menu,
FadingOut,
Transitioning,
Playing
}
[Property] public GameObject Camera { get; set; }
[Property] public GameObject MenuStartAnchor { get; set; }
[Property] public GameObject PlayCameraAnchor { get; set; }
// How long the menu overlay takes to fade out (matches the CSS
// transition in MenuHud.razor.scss - keep the two roughly in sync).
[Property] public float FadeOutDuration { get; set; } = 0.8f;
[Property] public float PanDuration { get; set; } = 2.5f;
// --- Audio Properties ---
[Property] public List<Component> AmbientSounds { get; set; }
[Property] public bool StartMuted { get; set; } = false;
[Property] public List<Component> TalkingSounds { get; set; }
[Property] public bool StartTalkingMuted { get; set; } = false;
public MenuState State { get; private set; } = MenuState.Menu;
public bool IsMuted { get; private set; }
public bool IsTalkingMuted { get; private set; }
// Wherever the camera was ORIGINALLY authored in the scene, before
// this script snaps it out to MenuStartAnchor. Used as the pan
// destination if PlayCameraAnchor isn't set.
private Vector3 _originalPosition;
private Rotation _originalRotation;
private Vector3 _panStartPos;
private Rotation _panStartRot;
private Vector3 _panEndPos;
private Rotation _panEndRot;
private TimeSince _fadeTimer;
private TimeSince _panTimer;
protected override void OnStart()
{
Mouse.Visibility = MouseVisibility.Visible;
if ( Camera is not null )
{
_originalPosition = Camera.Transform.Position;
_originalRotation = Camera.Transform.Rotation;
if ( MenuStartAnchor is not null )
{
Camera.Transform.Position = MenuStartAnchor.Transform.Position;
Camera.Transform.Rotation = MenuStartAnchor.Transform.Rotation;
}
}
IsMuted = StartMuted;
ApplyMuteState();
IsTalkingMuted = StartTalkingMuted;
ApplyTalkingMuteState();
}
protected override void OnUpdate()
{
if ( State == MenuState.FadingOut && _fadeTimer > FadeOutDuration )
{
BeginPan();
return;
}
if ( State == MenuState.Transitioning && Camera is not null )
{
var t = System.MathF.Min( 1f, _panTimer / System.MathF.Max( 0.01f, PanDuration ) );
// Smoothstep - eases in and out instead of a linear pan.
var eased = t * t * (3f - 2f * t);
Camera.Transform.Position = Vector3.Lerp( _panStartPos, _panEndPos, eased );
Camera.Transform.Rotation = Rotation.Slerp( _panStartRot, _panEndRot, eased );
if ( t >= 1f )
{
State = MenuState.Playing;
Mouse.Visibility = MouseVisibility.Hidden;
}
}
}
// Called from MenuHud.razor's Play button. Starts the fade - the
// actual camera pan begins once FadeOutDuration has elapsed, so the
// overlay is fully gone before the camera starts moving.
public void StartGame()
{
if ( State != MenuState.Menu )
return;
State = MenuState.FadingOut;
_fadeTimer = 0;
}
private void BeginPan()
{
State = MenuState.Transitioning;
_panTimer = 0;
_panStartPos = Camera?.Transform.Position ?? Vector3.Zero;
_panStartRot = Camera?.Transform.Rotation ?? Rotation.Identity;
if ( PlayCameraAnchor is not null )
{
_panEndPos = PlayCameraAnchor.Transform.Position;
_panEndRot = PlayCameraAnchor.Transform.Rotation;
}
else
{
// No explicit destination set - pan back to wherever the
// camera was originally authored before we snapped it to
// MenuStartAnchor.
_panEndPos = _originalPosition;
_panEndRot = _originalRotation;
}
}
// --- Ambient Music Mute Logic ---
public void ToggleMute()
{
IsMuted = !IsMuted;
ApplyMuteState();
}
private void ApplyMuteState()
{
if ( AmbientSounds is null ) return;
foreach ( var sound in AmbientSounds )
{
if ( sound is null ) continue;
if ( sound is SoundscapeTrigger trigger )
{
trigger.Volume = IsMuted ? 0f : 1f;
}
else
{
sound.Enabled = !IsMuted;
}
}
}
// --- Talking Audio Mute Logic ---
public void ToggleTalkingMute()
{
IsTalkingMuted = !IsTalkingMuted;
ApplyTalkingMuteState();
}
private void ApplyTalkingMuteState()
{
if ( TalkingSounds is null ) return;
foreach ( var sound in TalkingSounds )
{
if ( sound is null ) continue;
if ( sound is SoundscapeTrigger trigger )
{
trigger.Volume = IsTalkingMuted ? 0f : 1f;
}
else
{
sound.Enabled = !IsTalkingMuted;
}
}
}
}