A Scene component that simulates distant trench artillery shelling. The host schedules random shell events, broadcasts yaw/distance/kind to clients, plays sounds at positions, and applies a camera shake effect (position offset and tilt) with proper lifecycle handling so the shake is applied to composed camera views and undone next tick.
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();
}
}