Gameplay/WaterDownwash.cs

Component that scatters water ripples under a hovering object like a helicopter's downwash. Computes falloff from height above the surface, samples the water height via WaterNetwork, and emits ripples at random points inside a radius on a timer.

using System;
using Sandbox;

namespace RedSnail.WaterTool2;

/// <summary>
/// Stirs the water under something hovering above it without touching it - a helicopter's downwash.
/// Ripples land scattered in a patch under the object and build as it descends, stopping entirely
/// once it climbs out of reach.
///
/// Nothing else ripples the water from the air: <see cref="Buoyancy"/> and
/// <see cref="WaterRippleEmitter"/> both wait until something is genuinely in the water. Put this on
/// whatever should disturb the surface from above, and leave it off everything else.
/// </summary>
[Title("Water Downwash")]
[Category("Water Tool 2")]
[Icon("air")]
public sealed class WaterDownwash : Component
{
	/// <summary>
	/// How far above the surface the downwash still reaches. Nothing happens higher than this, and
	/// the effect builds as the object comes down through it.
	/// </summary>
	[Property, Group("Downwash")] public float Reach { get; set; } = 600.0f;

	/// <summary>
	/// Ripple strength just above the surface, falling away to nothing at <see cref="Reach"/>.
	/// </summary>
	[Property, Group("Downwash")] public float Strength { get; set; } = 0.35f;

	/// <summary>
	/// How wide a patch the ripples scatter over. Rotor wash churns an area rather than ringing out
	/// from a point, so each ripple lands somewhere random within this.
	/// </summary>
	[Property, Group("Downwash")] public float Radius { get; set; } = 150.0f;

	/// <summary>
	/// Seconds between ripples. The water holds only 64 at once across the whole scene, so a dense
	/// trail here crowds out the boats and splashes everywhere else.
	/// </summary>
	[Property, Group("Downwash")] public float Interval { get; set; } = 0.06f;

	[Property, Group("Downwash"), Range(20.0f, 400.0f)] public float Wavelength { get; set; } = 90.0f;
	[Property, Group("Downwash"), Range(10.0f, 500.0f)] public float RingWidth { get; set; } = 70.0f;

	/// <summary>The point measured against the surface, from the object - the rotor hub, say.</summary>
	[Property, Group("General")] public Vector3 SampleOffset { get; set; } = Vector3.Zero;

	private float m_Timer;

	private Vector3 SamplePosition => WorldPosition + WorldRotation * SampleOffset;

	/// <summary>
	/// How hard the downwash is hitting the water right now, 0 out of reach and 1 just above the
	/// surface. Useful for driving a spray effect or a rotor sound off the same falloff.
	/// </summary>
	public float Intensity { get; private set; }



	protected override void OnUpdate()
	{
		Intensity = 0.0f;

		// Something carried by something else leaves the rippling to whatever carries it.
		if (GameObject.Parent != Scene)
			return;

		Vector3 position = SamplePosition;
		float waterHeight = WaterNetwork.GetWaterHeightAt(position);

		if (waterHeight <= float.MinValue)
			return;

		float above = position.z - waterHeight;

		// Strictly from the air. Once it is in the water that is buoyancy's and the ripple emitter's
		// business, and doubling up would only eat the ripple budget.
		if (above < 0.0f || above > Reach)
			return;

		// Squared, so the water stays calm over most of the approach and then churns as it settles
		// in, rather than rippling faintly the whole way down.
		float falloff = 1.0f - (above / float.Max(Reach, 0.001f));
		Intensity = falloff * falloff;

		m_Timer -= Time.Delta;

		if (m_Timer > 0.0f)
			return;

		m_Timer = float.Max(Interval, 0.01f);

		// Scattered over the disc rather than always dead centre, so it churns a patch instead of
		// ringing out from one point. Square-rooting the radius keeps the spread even across the
		// area; without it they bunch up in the middle.
		float angle = Game.Random.Float(0.0f, MathF.Tau);
		float distance = Radius * MathF.Sqrt(Game.Random.Float(0.0f, 1.0f));

		Vector3 spot = position + new Vector3(MathF.Cos(angle) * distance, MathF.Sin(angle) * distance, 0.0f);

		WaterNetwork.AddRipple(spot.WithZ(waterHeight), Strength * Intensity, Wavelength, RingWidth);
	}
}