Scene-level collision handler in GameManager that detects dynamic props hitting static world geometry and produces camera shake effects. It queues impacts from physics collisions, filters by speed/mass/cooldown, broadcasts an RPC to clients, and spawns a PropImpactCameraEffect near the scene camera.
/// <summary>
/// Shakes nearby cameras when a physics prop strikes static world geometry.
/// Listening at scene level also covers map props, duplicates and restored saves.
/// </summary>
public sealed partial class GameManager : ISceneCollisionEvents
{
private const float MinimumImpactSpeed = 100f;
private const float MinimumAmplitude = 0.1f;
private const float MaximumAmplitude = 4f;
private const float ShakeRadius = 512f;
private const float ShakeDuration = 0.32f;
private const float ImpactCooldown = 0.1f;
private readonly record struct Impact( Vector3 Position, float Amplitude );
private readonly Dictionary<Prop, Impact> _pending = new();
private readonly Dictionary<Prop, float> _nextImpactTime = new();
private PropImpactCameraEffect _propImpactEffect;
void IScenePhysicsEvents.PostPhysicsStep() => ProcessImpacts();
void ISceneCollisionEvents.OnCollisionHit( Collision collision )
{
if ( Scene.IsEditor || !Networking.IsHost ) return;
if ( collision.Self.IsTrigger || collision.Other.IsTrigger ) return;
// The physics event can report the prop on either side of the contact.
QueueImpact( collision.Self, collision.Other, collision.Contact );
QueueImpact( collision.Other, collision.Self, collision.Contact );
}
private void QueueImpact( CollisionSource source, CollisionSource world, PhysicsContact contact )
{
if ( !source.Body.IsValid() || !world.Body.IsValid() ) return;
if ( source.Body.BodyType != PhysicsBodyType.Dynamic || !source.Body.MotionEnabled ) return;
if ( world.Body.BodyType != PhysicsBodyType.Static ) return;
if ( !source.GameObject.IsValid() ) return;
var prop = source.GameObject.GetComponentInParent<Prop>();
if ( !prop.IsValid() || prop.IsProxy ) return;
// Frozen/static props are still props, rather than part of the world.
if ( world.GameObject.IsValid() && world.GameObject.GetComponentInParent<Prop>().IsValid() ) return;
var speed = MathF.Abs( contact.NormalSpeed );
if ( speed < MinimumImpactSpeed ) return;
// Use the body's current mass so the mass tool and model physics both apply.
// A 50 kg prop hitting at 200 units/sec produces one unit of shake at the impact.
var amplitude = (source.Body.Mass * speed / 10000f).Clamp( 0f, MaximumAmplitude );
if ( amplitude < MinimumAmplitude ) return;
if ( _nextImpactTime.TryGetValue( prop, out var nextTime ) && Time.Now < nextTime ) return;
// Several shapes/substeps can report the same impact; keep only the strongest.
if ( _pending.TryGetValue( prop, out var pending ) && pending.Amplitude >= amplitude ) return;
_pending[prop] = new Impact( contact.Point, amplitude );
}
private void ProcessImpacts()
{
foreach ( var prop in _nextImpactTime.Keys.Where( x => !x.IsValid() || Time.Now >= _nextImpactTime[x] ).ToArray() )
_nextImpactTime.Remove( prop );
foreach ( var (prop, impact) in _pending )
{
BroadcastImpact( impact.Position, impact.Amplitude );
if ( prop.IsValid() )
_nextImpactTime[prop] = Time.Now + ImpactCooldown;
}
_pending.Clear();
}
[Rpc.Broadcast( NetFlags.HostOnly | NetFlags.Unreliable )]
private void BroadcastImpact( Vector3 position, float amplitude )
{
if ( Scene.IsEditor || GamePreferences.Screenshake <= 0f ) return;
var effects = CameraEffectSystem.Get( Scene );
if ( effects is null ) return;
effects.Scale = GamePreferences.Screenshake;
var camera = Scene.Camera;
if ( !camera.IsValid() ) return;
if ( camera.WorldPosition.Distance( position ) >= ShakeRadius ) return;
// A pile of colliding props should produce one readable jolt, not stacked jitter.
if ( _propImpactEffect is { IsDone: false } active )
{
var incomingStrength = amplitude * (1f - camera.WorldPosition.Distance( position ) / ShakeRadius);
var activeStrength = active.Amplitude * active.ScaleFor( camera );
if ( active.TimeAlive < 0.12f && incomingStrength <= activeStrength ) return;
active.Stop();
}
var direction = (position - camera.View.Position).Normal;
_propImpactEffect = effects.Add( new PropImpactCameraEffect
{
Camera = camera,
Epicenter = position,
Radius = ShakeRadius,
Duration = ShakeDuration,
Amplitude = amplitude,
Side = camera.View.Rotation.Right.Dot( direction )
} );
}
}