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