Droplet.cs
using Sandbox;

/// <summary>
/// Attach this to a droplet PREFAB (see setup notes below). Each droplet
/// is a real physics object - it falls under gravity and reports back
/// to PourController exactly where it actually hit, using a genuine
/// collision event rather than any predicted math.
///
/// PREFAB SETUP (do this once):
/// 1. Create a new GameObject, name it "DropletPrefab".
/// 2. Add Component -> Model Renderer. Set Model to a small sphere
///    (search "sphere" in the Asset Browser's Models tab).
/// 3. Add Component -> Sphere Collider. Shrink its radius to match a
///    small droplet size (try 1-2 units).
/// 4. Add Component -> Rigidbody.
/// 5. Add Component -> this script (Droplet).
/// 6. Scale the whole GameObject down small (try 0.15 on all axes)
///    so it visually reads as a droplet, not a bowling ball.
/// 7. DISABLE this GameObject (uncheck it) - it's a template PourController
///    clones from, it should never be active sitting in the scene itself.
/// 8. Drag this GameObject into PourController's new "Droplet Prefab" slot.
/// </summary>
public sealed class Droplet : Component, Component.ICollisionListener
{
    public PourController Pour { get; set; }
    public GameObject MouthTarget { get; set; }

    // How close the actual collision point needs to be to the mouth
    // to count as a hit. Tune this once droplets are actually falling
    // and you can see where they land relative to the mouth visually.
    public float HitRadius { get; set; } = 3.0f;

    // How much FillProgress a single successful droplet is worth.
    public float FillAmount { get; set; } = 1.0f;

    // SAFETY NET - if a droplet never collides with ANYTHING (misses
    // the table, floor, everything), it would otherwise live forever
    // and pile up in the Hierarchy. This forces a self-destruct after
    // a few seconds regardless of collision.
    [Property] public float MaxLifetime { get; set; } = 3.0f;

    [Header("Droplet Sound Effects")]
    [Property] public SoundEvent HitSound { get; set; }

    private TimeSince _timeSinceSpawn;

    protected override void OnStart()
    {
        _timeSinceSpawn = 0;
    }

    protected override void OnUpdate()
    {
        if ( _timeSinceSpawn > MaxLifetime )
            GameObject.Destroy();
    }

    void Component.ICollisionListener.OnCollisionStart( Collision collision )
    {
        // Ignore collisions with OTHER droplets - without this, droplets
        // spawning close together collide with each other almost
        // instantly, which both destroys them right at the spawn point
        // (looks like static clumping instead of falling) AND falsely
        // counts as hits/misses far faster than the real pour rate.
        if ( collision.Other.GameObject?.Components.Get<Droplet>() is not null )
            return;

        var hitPoint = collision.Contact.Point;

        if ( MouthTarget is not null )
        {
            var distance = (hitPoint - MouthTarget.Transform.Position).Length;

            if ( distance <= HitRadius )
            {
                Pour?.RegisterDropletHit( FillAmount, hitPoint, true );

                if ( HitSound != null )
                    Sound.Play( HitSound, hitPoint );

                GameObject.Destroy();
                return;
            }
        }

        Pour?.RegisterDropletHit( 0, hitPoint, false );
        GameObject.Destroy();
    }
}