Pourcontroller.cs
using System;
using System.Threading.Tasks;
using Sandbox;

/// <summary>
/// Pour detection using REAL physics droplets. Spawns many small
/// Rigidbody spheres per second from BottleTip with adjustable spread.
/// </summary>
public sealed class PourController : Component
{
    [Property] public GameObject BottleTip { get; set; }
    [Property] public GameObject MouthTarget { get; set; }
    [Property] public GameObject ChestAnchor { get; set; }

    [Property] public float PourThresholdPositive { get; set; } = 100.0f;
    [Property] public float PourThresholdNegative { get; set; } = 50.0f;

    [Property] public float PourRate { get; set; } = 20.0f;
    [Property] public float FillGoal { get; set; } = 100.0f;
    [Property] public float PourBudget { get; set; } = 200.0f;

    [Property] public GameObject DropletPrefab { get; set; }
    [Property] public float SpawnRate { get; set; } = 30.0f;
    [Property] public float DropletSpeed { get; set; } = 40.0f;
    [Property] public float SpawnOffset { get; set; } = 4.0f;

    [Property] public GameObject SplashPrefab { get; set; }
    [Property] public float SplashDuration { get; set; } = 0.3f;
    [Property] public Angles SplashRotation { get; set; } = new Angles(0, 0, 0);

    [Property] public GameObject SwallowPrefab { get; set; }
    [Property] public float SwallowDuration { get; set; } = 0.2f;
    [Property] public Angles SwallowRotation { get; set; } = new Angles(0, 0, 0);

    [Header("Audio")]
    [Property] public SoundEvent PourLoopSound { get; set; }
    [Property] public SoundEvent HitLoopSound { get; set; }
    [Property] public SoundEvent MissSound { get; set; }

    [Header("Stream Spread / Variance")]
    [Property, Range(0f, 10f)] public float SpreadAmount { get; set; } = 3.0f;

    public float FillProgress { get; private set; }
    public float TotalPoured { get; private set; }
    public bool IsPouring { get; private set; }

    private CupTiltController _tilt;
    private float _spawnTimer;
    private SoundHandle _activePourSound;
    private SoundHandle _activeHitSound;
    private SoundHandle _activeMissSound;
    private TimeSince _timeSinceLastHit = 999f;
    private TimeSince _timeSinceLastMiss = 999f;

    // Every droplet currently in flight. Tracked so a round reset can
    // clean them all up - without this, a droplet spawned right before
    // FillGoal/PourBudget is hit can still be falling when the round
    // ends, and if it lands afterward it reports a hit/miss against the
    // NEW round's already-reset numbers instead of vanishing cleanly.
    private readonly List<GameObject> _activeDroplets = new();

    public void ResetRound()
    {
        FillProgress = 0;
        TotalPoured = 0;
        _timeSinceLastHit = 999f;
        _timeSinceLastMiss = 999f;

        foreach ( var droplet in _activeDroplets )
        {
            if ( droplet.IsValid() )
                droplet.Destroy();
        }

        _activeDroplets.Clear();

        if ( _activePourSound != null )
        {
            _activePourSound.Stop();
            _activePourSound = null;
        }

        if ( _activeHitSound != null )
        {
            _activeHitSound.Stop();
            _activeHitSound = null;
        }

        if ( _activeMissSound != null )
        {
            _activeMissSound.Stop();
            _activeMissSound = null;
        }
    }

    protected override void OnStart()
    {
        _tilt = Components.Get<CupTiltController>();
    }

    protected override void OnUpdate()
    {
        if ( BottleTip is null || ChestAnchor is null || _tilt is null )
            return;

        IsPouring = _tilt.TiltAngle >= PourThresholdPositive
            || _tilt.TiltAngle <= -PourThresholdNegative;

        // Handle Pour Loop Audio
        if ( IsPouring )
        {
            if ( _activePourSound == null || !_activePourSound.IsPlaying )
            {
                if ( PourLoopSound != null )
                {
                    _activePourSound = Sound.Play( PourLoopSound, BottleTip.Transform.Position );
                }
            }

            if ( _activePourSound != null && BottleTip != null )
            {
                _activePourSound.Position = BottleTip.Transform.Position;
            }
        }
        else
        {
            if ( _activePourSound != null )
            {
                _activePourSound.Stop();
                _activePourSound = null;
            }
        }

        // Handle Hit Loop Audio
        bool isHittingTarget = _timeSinceLastHit < 0.4f;

        if ( isHittingTarget )
        {
            if ( _activeHitSound == null || !_activeHitSound.IsPlaying )
            {
                if ( HitLoopSound != null )
                {
                    var soundPos = MouthTarget != null ? MouthTarget.Transform.Position : WorldPosition;
                    _activeHitSound = Sound.Play( HitLoopSound, soundPos );
                }
            }

            if ( _activeHitSound != null && MouthTarget != null )
            {
                _activeHitSound.Position = MouthTarget.Transform.Position;
            }
        }
        else
        {
            if ( _activeHitSound != null )
            {
                _activeHitSound.Stop();
                _activeHitSound = null;
            }
        }

        // Handle Miss Loop Audio - same throttled-loop pattern as the hit
        // sound above, instead of firing a brand new one-shot per droplet.
        // With SpawnRate this high, one-shot-per-miss stacked dozens of
        // overlapping copies during a sustained miss streak.
        bool isMissingTarget = _timeSinceLastMiss < 0.2f;

        if ( isMissingTarget )
        {
            if ( _activeMissSound == null || !_activeMissSound.IsPlaying )
            {
                if ( MissSound != null && BottleTip != null )
                {
                    _activeMissSound = Sound.Play( MissSound, BottleTip.Transform.Position );
                }
            }

            if ( _activeMissSound != null && BottleTip != null )
            {
                _activeMissSound.Position = BottleTip.Transform.Position;
            }
        }
        else
        {
            if ( _activeMissSound != null )
            {
                _activeMissSound.Stop();
                _activeMissSound = null;
            }
        }

        if ( !IsPouring )
        {
            _spawnTimer = 0;
            return;
        }

        TotalPoured = MathF.Min( PourBudget, TotalPoured + PourRate * Time.Delta );

        _spawnTimer += Time.Delta;
        var spawnInterval = 1f / MathF.Max( 0.01f, SpawnRate );

        if ( _spawnTimer >= spawnInterval )
        {
            _spawnTimer = 0;
            SpawnDroplet();
        }
    }

    private void SpawnDroplet()
    {
        if ( DropletPrefab is null )
            return;

        var pourDirection = BottleTip.Transform.Rotation.Forward;
        var spawnPos = BottleTip.Transform.Position + pourDirection * SpawnOffset;

        var droplet = DropletPrefab.Clone( spawnPos );
        droplet.Enabled = true;

        _activeDroplets.Add( droplet );

        var script = droplet.Components.Get<Droplet>();

        if ( script is not null )
        {
            script.Pour = this;
            script.MouthTarget = MouthTarget;
            script.FillAmount = PourRate / SpawnRate;
        }

        var rigidbody = droplet.Components.Get<Rigidbody>();

        if ( rigidbody is not null )
        {
            var randomSpread = new Vector3(
                Game.Random.Float( -SpreadAmount, SpreadAmount ),
                Game.Random.Float( -SpreadAmount, SpreadAmount ),
                Game.Random.Float( -SpreadAmount, SpreadAmount )
            );

            rigidbody.Velocity = (pourDirection * DropletSpeed) + randomSpread;
        }
    }

    private async void DestroyAfterDelay( GameObject obj, float delaySeconds )
    {
        await Task.Delay( (int)(delaySeconds * 1000) );
        
        if ( obj.IsValid() )
        {
            obj.Destroy();
        }
    }

    public void RegisterDropletHit( float fillAmount, Vector3 hitPoint, bool wasHit )
    {
        if ( wasHit )
        {
            _timeSinceLastHit = 0f;
            FillProgress = MathF.Min( FillGoal, FillProgress + fillAmount );

            if ( SwallowPrefab is not null && MouthTarget is not null )
            {
                var swallow = SwallowPrefab.Clone( MouthTarget.Transform.Position, SwallowRotation.ToRotation() );
                DestroyAfterDelay( swallow, SwallowDuration );
            }

            return;
        }

        _timeSinceLastMiss = 0f;

        if ( SplashPrefab is not null )
        {
            var splash = SplashPrefab.Clone( hitPoint, SplashRotation.ToRotation() );
            DestroyAfterDelay( splash, SplashDuration );
        }
    }
}