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