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();
	}
}