RoundManger.cs
using System;
using System.Threading.Tasks;
using Sandbox;
/// <summary>
/// Full round flow:
/// WaitingToStart - hand/tilt frozen at home position, waiting for
/// left click to begin.
/// Playing - normal control, pour tracked.
///
/// WIN a round: FillProgress reaches FillGoal before TotalPoured runs
/// out. Round advances, interference goes up, resets to WaitingToStart.
/// Whatever landed this round (up to FillGoal) is added to TotalScore.
///
/// LOSE a round: TotalPoured hits PourBudget without reaching FillGoal.
/// Whatever landed THIS round (a partial amount) still gets added to
/// TotalScore, THEN the run ends: TotalScore is checked against the
/// saved HighScore, and everything resets - back to Round 1, zero
/// interference, TotalScore back to 0 for the next run.
///
/// SETUP: Attach to Body. No manual slots - finds MouseTargeting,
/// CupTiltController, PourController automatically. Drag your
/// MenuManager into the "Menu" slot so a round can't be started by
/// clicking through the menu/splash screen or during the camera pan.
/// </summary>
public sealed class RoundManager : Component
{
public enum State
{
WaitingToStart,
Playing
}
[Property] public MenuManager Menu { get; set; }
[Property] public float ArmInterferencePerRound { get; set; } = 0.2f;
[Property] public float WristInterferencePerRound { get; set; } = 0.2f;
[Property] public CameraWobble CameraWobble { get; set; }
[Property] public float CameraWobblePerRound { get; set; } = 0.15f;
[Header("Round Audio & FX")]
[Property] public SoundEvent RoundStartSound { get; set; }
[Property] public SoundEvent RoundCompleteSound { get; set; }
[Property] public SoundEvent RoundFailSound { get; set; }
// Drag your existing "bottle pop" particle GameObject in here - it'll
// get cloned (not moved/reused) at the bottle tip each time a round
// starts, then auto-destroyed after BottlePopDuration.
[Property] public GameObject BottlePopPrefab { get; set; }
[Property] public float BottlePopDuration { get; set; } = 1.0f;
private const string SaveFile = "highscore.json";
public State CurrentState { get; private set; } = State.WaitingToStart;
public int CurrentRound { get; private set; } = 1;
public float ArmInterference { get; private set; } = 0.0f;
public float WristInterference { get; private set; } = 0.0f;
public float CameraWobbleIntensity { get; private set; } = 0.0f;
// This run's running total, across every round so far.
public float TotalScore { get; private set; } = 0;
// Best TotalScore ever achieved, loaded from disk on start.
public float HighScore { get; private set; } = 0;
private MouseTargeting _mouseTargeting;
private CupTiltController _tilt;
private PourController _pour;
protected override void OnStart()
{
_mouseTargeting = Components.Get<MouseTargeting>();
_tilt = Components.Get<CupTiltController>();
_pour = Components.Get<PourController>();
LoadHighScore();
EnterWaitingState();
}
protected override void OnUpdate()
{
if ( _pour is null || _mouseTargeting is null || _tilt is null )
return;
if ( CurrentState == State.WaitingToStart )
{
// Don't let a click on the menu/splash screen (or during the
// camera pan) double up as the round-start click.
if ( Menu is not null && Menu.State != MenuManager.MenuState.Playing )
return;
if ( Input.Pressed( "attack1" ) )
{
CurrentState = State.Playing;
_mouseTargeting.FreezePosition = false;
_tilt.FreezeTilt = false;
var bottleTipPos = _pour.BottleTip is not null ? _pour.BottleTip.Transform.Position : Transform.Position;
if ( RoundStartSound is not null )
Sound.Play( RoundStartSound );
if ( BottlePopPrefab is not null )
{
var pop = BottlePopPrefab.Clone( bottleTipPos );
pop.Enabled = true;
DestroyAfterDelay( pop, BottlePopDuration );
}
Log.Info( $"Round {CurrentRound} started! Arm interference: {ArmInterference:0.00}, Wrist interference: {WristInterference:0.00}" );
}
return;
}
// State.Playing from here down.
if ( _pour.FillProgress >= _pour.FillGoal )
{
TotalScore += _pour.FillProgress;
if ( RoundCompleteSound is not null )
Sound.Play( RoundCompleteSound );
CurrentRound++;
ApplyInterference();
_pour.ResetRound();
EnterWaitingState();
Log.Info( $"Round complete! Score: {TotalScore:0}. Click to start round {CurrentRound}." );
return;
}
if ( _pour.TotalPoured >= _pour.PourBudget )
{
// Whatever landed this round still counts, even on a loss.
TotalScore += _pour.FillProgress;
if ( RoundFailSound is not null )
Sound.Play( RoundFailSound );
Log.Info( $"Ran out of pour - round failed. Final score: {TotalScore:0}." );
if ( TotalScore > HighScore )
{
HighScore = TotalScore;
SaveHighScore();
Log.Info( $"New high score: {HighScore:0}!" );
}
TotalScore = 0;
CurrentRound = 1;
ApplyInterference();
_pour.ResetRound();
EnterWaitingState();
}
}
private void EnterWaitingState()
{
CurrentState = State.WaitingToStart;
_mouseTargeting.ResetToHome();
_mouseTargeting.FreezePosition = true;
_tilt.ResetToHome();
_tilt.FreezeTilt = true;
}
private void ApplyInterference()
{
ArmInterference = MathF.Max( 0, (CurrentRound - 1) * ArmInterferencePerRound );
WristInterference = MathF.Max( 0, (CurrentRound - 1) * WristInterferencePerRound );
CameraWobbleIntensity = MathF.Max( 0, (CurrentRound - 1) * CameraWobblePerRound );
_mouseTargeting.InterferenceIntensity = ArmInterference;
_tilt.InterferenceIntensity = WristInterference;
if ( CameraWobble is not null )
CameraWobble.Intensity = CameraWobbleIntensity;
}
private void LoadHighScore()
{
try
{
if ( FileSystem.Data.FileExists( SaveFile ) )
{
var data = FileSystem.Data.ReadJson<ScoreData>( SaveFile );
HighScore = data?.HighScore ?? 0;
}
}
catch ( Exception e )
{
Log.Warning( $"Failed to load high score: {e.Message}" );
HighScore = 0;
}
}
private void SaveHighScore()
{
try
{
FileSystem.Data.WriteJson( SaveFile, new ScoreData { HighScore = HighScore } );
}
catch ( Exception e )
{
Log.Warning( $"Failed to save high score: {e.Message}" );
}
}
private async void DestroyAfterDelay( GameObject obj, float delaySeconds )
{
await Task.Delay( (int)(delaySeconds * 1000) );
if ( obj.IsValid() )
obj.Destroy();
}
}