Code/TrenchShelling.cs

Component that runs periodic distant and near artillery effects for the trench map. The host schedules randomized shell events, broadcasts yaw/distance/type to clients, plays spatial sounds, and applies a camera shake effect (position offset and tilt) that is synchronized across machines and applied/restored around the engine's camera composition stages.

NetworkingFile Access
using Sandbox;
using System;

/// <summary>
/// Distant artillery for the trench map. Every Interval (a fresh random wait between the min and
/// max, read from the Inspector each time a shell goes off) the host picks a shell: usually far
/// off, a random yaw and a distance in the Far range; sometimes (Near Chance) a near one in the
/// Near range. The host tells every machine the yaw, the distance and the kind, and each machine
/// places the shell at that yaw and distance from its own camera, so every player gets the same
/// near or far shell wherever they stand: an explosion sound in 3D at that spot (the far event is
/// pitched down and softened, the near one is as it comes) and a camera shake sized by the
/// distance, a soft rumble of about a second for a far one, a hard kick and a rolling two second
/// decay plus a low rumble layer for a near one. No damage, nothing happens to balls, no particles.
/// Runs from scene load, so the menu and the lobby hear it too. Place one anywhere; its position
/// does not matter, and only one instance keeps the clock.
///
/// The shake is smooth noise, new every frame: layered sines on position (up to the shake
/// strength in units) and on pitch, yaw and roll (up to Shake Angle, scaled by the strength), with
/// a fast attack and an exponential decay. It reaches whichever camera is active. The engine
/// composes each camera's view once per tick after Update, through the camera modifiers, and
/// the shake is one of those (after the fly-over and the free camera). But the fly-over, the
/// free camera and the menu orbit write the camera's transform in PreRender, after that
/// composition, and the engine then renders from the raw transform and drops the composed view.
/// So at the very end of the tick (the FinishUpdate stage, after every PreRender) the
/// <see cref="TrenchShellingSystem"/> checks whether the active camera's transform changed after
/// composition and, if it did, adds the same offset to the transform itself, undoing it again at
/// the start of the next tick so nothing accumulates.
/// </summary>
[Title( "Trench Shelling" ), Category( "Golf" ), Icon( "flare" )]
public sealed class TrenchShelling : Component, ICameraModifier
{
	/// <summary>The instance that keeps the clock and shakes the camera; the first enabled one.</summary>
	public static TrenchShelling Current { get; private set; }

	/// <summary>Shortest wait between shells, seconds.</summary>
	[Property, Group( "Timing" ), Range( 1f, 300f ), Title( "Interval Min" )]
	public float IntervalMin { get; set; } = 20f;

	/// <summary>Longest wait between shells, seconds.</summary>
	[Property, Group( "Timing" ), Range( 1f, 300f ), Title( "Interval Max" )]
	public float IntervalMax { get; set; } = 60f;

	/// <summary>Closest a far shell lands, units from each player's camera.</summary>
	[Property, Group( "Distance" ), Range( 500f, 20000f ), Title( "Far Distance Min" )]
	public float FarMin { get; set; } = 2000f;

	/// <summary>Furthest a far shell lands, units from each player's camera.</summary>
	[Property, Group( "Distance" ), Range( 500f, 20000f ), Title( "Far Distance Max" )]
	public float FarMax { get; set; } = 6000f;

	/// <summary>Chance a shell is a near one, 0 to 1.</summary>
	[Property, Group( "Distance" ), Range( 0f, 1f ), Title( "Near Chance" )]
	public float NearChance { get; set; } = 0.15f;

	/// <summary>Closest a near shell lands, units from each player's camera.</summary>
	[Property, Group( "Distance" ), Range( 50f, 5000f ), Title( "Near Distance Min" )]
	public float NearMin { get; set; } = 300f;

	/// <summary>Furthest a near shell lands, units from each player's camera.</summary>
	[Property, Group( "Distance" ), Range( 50f, 5000f ), Title( "Near Distance Max" )]
	public float NearMax { get; set; } = 800f;

	/// <summary>The far shell: pitched down and softened. Empty uses sounds/trench/shell_far.</summary>
	[Property, Group( "Sound" ), Title( "Far Sound" )]
	public SoundEvent FarSound { get; set; }

	/// <summary>The near shell. Empty uses sounds/trench/shell_near.</summary>
	[Property, Group( "Sound" ), Title( "Near Sound" )]
	public SoundEvent NearSound { get; set; }

	/// <summary>Low rumble layered under a near shell. Empty uses sounds/trench/shell_rumble; None for none.</summary>
	[Property, Group( "Sound" ), Title( "Rumble Sound" )]
	public SoundEvent RumbleSound { get; set; }

	/// <summary>Volume of every shell sound, 0 to 2.</summary>
	[Property, Group( "Sound" ), Range( 0f, 2f )]
	public float Volume { get; set; } = 1f;

	/// <summary>Shake of a far shell for a camera right next to it, world units.</summary>
	[Property, Group( "Shake" ), Range( 0f, 10f ), Title( "Far Shake" )]
	public float FarShake { get; set; } = 1.2f;

	/// <summary>How long a far shell rumbles, seconds.</summary>
	[Property, Group( "Shake" ), Range( 0f, 10f ), Title( "Far Shake Time" )]
	public float FarShakeTime { get; set; } = 1f;

	/// <summary>Shake of a near shell for a camera right next to it, world units.</summary>
	[Property, Group( "Shake" ), Range( 0f, 10f ), Title( "Near Shake" )]
	public float NearShake { get; set; } = 6f;

	/// <summary>How long a near shell shakes, seconds.</summary>
	[Property, Group( "Shake" ), Range( 0f, 10f ), Title( "Near Shake Time" )]
	public float NearShakeTime { get; set; } = 2f;

	/// <summary>Speed of the shake noise, wobbles per second.</summary>
	[Property, Group( "Shake" ), Range( 1f, 60f ), Title( "Shake Frequency" )]
	public float ShakeFrequency { get; set; } = 9f;

	/// <summary>Tilt of the view at full Near Shake strength, degrees; smaller shakes tilt less in proportion.</summary>
	[Property, Group( "Shake" ), Range( 0f, 10f ), Title( "Shake Angle" )]
	public float ShakeAngle { get; set; } = 2f;

	/// <summary>Distance from the camera at which a shell's shake has faded to nothing, units.</summary>
	[Property, Group( "Shake" ), Range( 500f, 30000f ), Title( "Shake Range" )]
	public float ShakeRange { get; set; } = 9000f;

	const string FarSoundPath = "sounds/trench/shell_far.sound";
	const string NearSoundPath = "sounds/trench/shell_near.sound";
	const string RumbleSoundPath = "sounds/trench/shell_rumble.sound";

	// The host's clock. A TimeUntil survives a hotload and counts in scene time.
	TimeUntil _nextShell;
	bool _scheduled;

	// The shake in progress.
	float _amplitude;        // units of position shake at the start
	float _started;          // Time.Now it began
	float _duration;
	bool _near;
	Vector3 _kickDirection;  // the near shell's first hard knock
	int _shell;              // counts shells, so the camera line is logged once each
	int _loggedShell;

	// The camera the view was composed for this tick, and its raw transform at that moment.
	CameraComponent _composedCamera;
	Vector3 _composedPosition;
	Rotation _composedRotation;
	bool _composedThisTick;

	// An offset written into a camera's transform at the end of a tick, taken out again at the start of the next.
	CameraComponent _rawCamera;
	Vector3 _rawOffset;
	Rotation _rawTilt = Rotation.Identity;
	bool _rawApplied;

	protected override void OnEnabled()
	{
		if ( !Current.IsValid() || Current.Scene != Scene ) Current = this;
	}

	protected override void OnDisabled()
	{
		RestoreEarly();
		if ( Current == this ) Current = null;
	}

	protected override void OnStart()
	{
		FarSound ??= ResourceLibrary.Get<SoundEvent>( FarSoundPath );
		NearSound ??= ResourceLibrary.Get<SoundEvent>( NearSoundPath );
		RumbleSound ??= ResourceLibrary.Get<SoundEvent>( RumbleSoundPath );
	}

	protected override void OnUpdate()
	{
		// One instance keeps the clock, on the host; everyone else only hears what it sends.
		if ( Scene.IsEditor || Current != this || !Networking.IsHost ) return;

		if ( !_scheduled )
		{
			Schedule();
			return;
		}

		if ( _nextShell > 0f ) return;
		Schedule();

		var near = Game.Random.Float( 0f, 1f ) < NearChance;
		var distance = near
			? Game.Random.Float( Math.Min( NearMin, NearMax ), Math.Max( NearMin, NearMax ) )
			: Game.Random.Float( Math.Min( FarMin, FarMax ), Math.Max( FarMin, FarMax ) );
		var yaw = Game.Random.Float( 0f, 360f );

		ShellRpc( yaw, distance, near );
	}

	/// <summary>A fresh random wait, from the Inspector values as they are right now.</summary>
	void Schedule()
	{
		var min = Math.Min( IntervalMin, IntervalMax );
		var max = Math.Max( IntervalMin, IntervalMax );
		var wait = Game.Random.Float( min, max );
		_nextShell = wait;
		_scheduled = true;
		Log.Info( $"TrenchShelling: next shell in {wait:0} s (interval {min:0} to {max:0})" );
	}

	/// <summary>A shell lands at this yaw and distance from this machine's own camera: the sound there and a shake sized by the distance.</summary>
	[Rpc.Broadcast]
	void ShellRpc( float yaw, float distance, bool near )
	{
		var camera = Scene.Camera;
		var origin = camera.IsValid() ? camera.WorldPosition : WorldPosition;
		var position = origin + Rotation.FromYaw( yaw ).Forward * distance;

		var sound = near ? NearSound : FarSound;
		if ( sound is not null && Volume > 0f )
		{
			var handle = Sound.Play( sound, position );
			if ( handle.IsValid() )
			{
				handle.Volume *= Volume;
				handle.TargetMixer = GolfMixers.Shelling ?? handle.TargetMixer;
			}
		}

		if ( near && RumbleSound is not null && Volume > 0f )
		{
			var rumble = Sound.Play( RumbleSound, position );
			if ( rumble.IsValid() )
			{
				rumble.Volume *= Volume;
				rumble.TargetMixer = GolfMixers.Shelling ?? rumble.TargetMixer;
			}
		}

		var falloff = 1f - Math.Clamp( distance / Math.Max( 1f, ShakeRange ), 0f, 1f );
		var amplitude = (near ? NearShake : FarShake) * falloff;
		var duration = near ? NearShakeTime : FarShakeTime;
		Log.Info( $"TrenchShelling: {( near ? "near" : "far" )} shell at {distance:0} units, yaw {yaw:0}, shake {amplitude:0.00} for {duration:0.0} s" );
		if ( amplitude <= 0.01f || duration <= 0f ) return;

		// The shake lives on the one instance that applies it. A stronger shell replaces a weaker
		// shake in progress; a weaker one is dropped while a stronger one still runs.
		var target = Current.IsValid() && Current.Scene == Scene ? Current : this;
		target.StartShake( amplitude, duration, near );
	}

	void StartShake( float amplitude, float duration, bool near )
	{
		if ( Remaining > 0f && _amplitude * Envelope( Time.Now - _started ) > amplitude ) return;

		_amplitude = amplitude;
		_duration = duration;
		_started = Time.Now;
		_near = near;
		_kickDirection = (Vector3.Random.WithZ( 0f ).Normal * 0.7f + Vector3.Down * 0.7f).Normal;
		_shell++;
	}

	/// <summary>Seconds of the current shake still to run, 0 when there is none.</summary>
	float Remaining => _duration <= 0f ? 0f : Math.Max( 0f, _duration - (Time.Now - _started) );

	/// <summary>
	/// Strength over the life of a shake, 0 to 1: a fast attack, then an exponential decay that is
	/// nearly gone by the end. Near shells hit at once and roll off; far ones swell in and fade.
	/// </summary>
	float Envelope( float age )
	{
		if ( age < 0f || _duration <= 0f || age >= _duration ) return 0f;
		var attack = _near ? 0.04f : 0.2f;
		var tau = _duration / 4f;
		if ( age < attack ) return age / attack;
		return MathF.Exp( -(age - attack) / tau );
	}

	/// <summary>Three layered sines with unrelated rates: smooth, never repeating within a shake, no steps.</summary>
	static float Noise( float t, float seed )
	{
		return MathF.Sin( t + seed ) * 0.55f + MathF.Sin( t * 1.83f + seed * 1.7f + 0.9f ) * 0.3f + MathF.Sin( t * 3.11f + seed * 2.3f + 2.1f ) * 0.15f;
	}

	/// <summary>The shake right now: a position offset in units and a tilt of the view.</summary>
	bool CurrentOffset( out Vector3 offset, out Rotation tilt )
	{
		offset = Vector3.Zero;
		tilt = Rotation.Identity;
		if ( Remaining <= 0f ) return false;

		var age = Time.Now - _started;
		var strength = _amplitude * Envelope( age );
		var t = Time.Now * ShakeFrequency * MathF.PI * 2f;

		offset = new Vector3( Noise( t, 1f ), Noise( t, 2f ), Noise( t, 3f ) ) * strength;

		// The near shell's hard first knock, gone in a quarter of a second.
		if ( _near )
			offset += _kickDirection * _amplitude * MathF.Exp( -age / 0.12f );

		var degrees = ShakeAngle * strength / Math.Max( 0.01f, NearShake );
		tilt = new Angles( Noise( t, 4f ) * degrees, Noise( t, 5f ) * degrees, Noise( t, 6f ) * degrees * 0.5f ).ToRotation();
		return true;
	}

	// ------------------------------------------------------------ reaching the active camera

	// After the fly-over (100) and the free camera (200), so the offset lands on the finished view.
	int ICameraModifier.CameraOrder => 900;

	void ICameraModifier.ModifyCamera( CameraComponent camera, ref CameraView view )
	{
		if ( Current != this ) return;
		if ( !camera.IsValid() ) return;

		// Remember what the camera's own transform was when its view was composed.
		_composedCamera = camera;
		_composedPosition = camera.WorldPosition;
		_composedRotation = camera.WorldRotation;
		_composedThisTick = true;

		if ( !CurrentOffset( out var offset, out var tilt ) ) return;
		view.Position += offset;
		view.Rotation *= tilt;
	}

	/// <summary>
	/// End of the tick, after every PreRender: if the active camera's transform was rewritten
	/// after its view was composed (the fly-over, the free camera, the menu orbit), the engine
	/// renders from that transform, so the shake goes into the transform too.
	/// </summary>
	public void ApplyLate()
	{
		var composed = _composedThisTick;
		_composedThisTick = false;

		var camera = composed && _composedCamera.IsValid() ? _composedCamera : Scene.Camera;
		if ( !camera.IsValid() ) return;
		if ( !CurrentOffset( out var offset, out var tilt ) ) return;

		var untouched = composed && camera == _composedCamera
			&& camera.WorldPosition == _composedPosition && camera.WorldRotation == _composedRotation;

		if ( untouched )
		{
			if ( _loggedShell != _shell )
			{
				_loggedShell = _shell;
				Log.Info( $"TrenchShelling: shake on camera '{camera.GameObject.Name}' through its composed view" );
			}
			return;
		}

		camera.WorldPosition += offset;
		camera.WorldRotation *= tilt;
		_rawCamera = camera;
		_rawOffset = offset;
		_rawTilt = tilt;
		_rawApplied = true;

		if ( _loggedShell != _shell )
		{
			_loggedShell = _shell;
			Log.Info( $"TrenchShelling: shake on camera '{camera.GameObject.Name}' through its transform (rewritten after composition)" );
		}
	}

	/// <summary>Start of the next tick: take last tick's offset back out of the transform, so nothing accumulates.</summary>
	public void RestoreEarly()
	{
		if ( !_rawApplied ) return;
		_rawApplied = false;
		if ( !_rawCamera.IsValid() ) return;

		_rawCamera.WorldPosition -= _rawOffset;
		_rawCamera.WorldRotation *= _rawTilt.Inverse;
	}
}

/// <summary>
/// Runs the shelling shake at the two ends of the tick: takes last tick's transform offset out at
/// StartUpdate, before anything else, and puts this tick's in at FinishUpdate, after every
/// component's PreRender, which is the last point before the frame renders.
/// </summary>
public sealed class TrenchShellingSystem : GameObjectSystem<TrenchShellingSystem>
{
	public TrenchShellingSystem( Scene scene ) : base( scene )
	{
		Listen( Stage.StartUpdate, -1000, RestoreEarly, "TrenchShelling.RestoreEarly" );
		Listen( Stage.FinishUpdate, 1000, ApplyLate, "TrenchShelling.ApplyLate" );
	}

	void RestoreEarly()
	{
		var shelling = TrenchShelling.Current;
		if ( shelling.IsValid() && shelling.Scene == Scene ) shelling.RestoreEarly();
	}

	void ApplyLate()
	{
		var shelling = TrenchShelling.Current;
		if ( shelling.IsValid() && shelling.Scene == Scene ) shelling.ApplyLate();
	}
}