A component that implements camera shake for the local player using a single decaying "trauma" value and occasional temporary "rumble" floors. It applies and then removes eye-angle offsets each frame via NZPlayer.ApplyEyeAnglesOffset, provides static helpers for distance-falloff punches, a rumble API, and console commands to inspect and directly test/apply shakes.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// Camera shake, as trauma that decays.
///
/// ⛔ IT MOVES `EyeAngles`, BECAUSE THAT IS THE ONE ROUTE IN THIS CODEBASE THAT DEMONSTRABLY WORKS.
/// Two earlier attempts did not, and both failed silently:
///
/// 1. Writing `Scene.Camera.WorldRotation` in `OnPreRender`. That camera is a ROOT object driven
/// entirely by s&box's `PlayerController` — nothing in this project writes it — so the write
/// raced the controller for the same field in the same frame and lost.
/// 2. Writing `PlayerController.CameraOffset`. Race-free in principle, since the controller reads
/// it rather than writing it, and still produced nothing visible.
///
/// Weapon recoil has been shaking the view correctly the whole time, via
/// `NZPlayer.ApplyEyeAnglesOffset` → `Controller.EyeAngles += offset` (`Weapon.Shoot.cs:757`). Using
/// the mechanism the project already proves beats reasoning about which one ought to work.
///
/// ⛔ EACH FRAME REMOVES THE PREVIOUS FRAME'S OFFSET BEFORE ADDING ITS OWN, so the shake nets to
/// ZERO aim drift. Without that, every punch would permanently walk the player's view — a hundred
/// footsteps would leave them staring at the sky. Recoil does exactly this: `Weapon.Getters.cs:451`
/// applies the negative to recover.
///
/// ⛔ TRAUMA, NOT A LIST OF SHAKES. One 0-1 number that decays. Two footsteps landing together
/// reinforce instead of fighting, and it cannot leak — nothing has to remember to stop.
///
/// ⚠️ LINEAR IN TRAUMA. An earlier version squared it "so the falloff reads better" and made every
/// shake invisible — see `OnUpdate`. Decay already gives the trail; the square gave nothing to trail
/// from.
///
/// ⚠️ THE RECOVERY SUBTRACTS WHAT LANDED, NOT WHAT WAS ASKED FOR — see `OnUpdate`. `PitchClamp`
/// silently shortens an offset near vertical, and at a 8.8° swing that band is wide enough to walk
/// the player's view away from where they are pointing.
/// </summary>
public sealed class CameraShake : Component
{
/// <summary>Global multiplier. 0 disables shake entirely — an accessibility lever.</summary>
public static float Scale { get; set; } = 1f;
/// <summary>Degrees of swing at full trauma.</summary>
/// <summary>
/// Degrees of swing at full trauma. ⚠️ ×12 ON REQUEST from the first values that were actually
/// visible (2.2 / 1.4 / 3.0) — ×4, then ×3 again — this is a deliberate, asked-for level of aggression, not a
/// derived one. `nz_shake_scale` scales all three at once.
/// </summary>
public static float MaxPitch { get; set; } = 26.4f;
public static float MaxYaw { get; set; } = 16.8f;
public static float MaxRoll { get; set; } = 36.0f;
/// <summary>Trauma lost per second. 3 ≈ a third of a second from full.</summary>
public static float Decay { get; set; } = 3f;
float _trauma;
Angles _applied;
NZPlayer _player;
/// <summary>How shaken the view is right now, 0-1. For the report.</summary>
public float Trauma => _trauma;
// Diagnostics — "no shake" has several causes that look identical from outside.
public int Ticks;
public int AppliedFrames;
public float LastSwing;
public void Add( float amount )
=> _trauma = MathX.Clamp( _trauma + MathF.Max( 0f, amount ), 0f, 1f );
// ── the held rumble ─────────────────────────────────────────────────────────────────────────────────────────────────────
float _rumbleLevel, _rumbleLength;
TimeSince _rumbleSince;
/// <summary>
/// The local player's held rumble now (<see cref="Rumble"/>), 0-1 — a tremor's, never a punch's: for the lights that flicker
/// when the ground shakes (`MapTremor`). ⚠️ 0 ONCE A QUARTER SECOND OLD, so a shake destroyed mid-rumble cannot leave the lights
/// flickering forever.
/// </summary>
public static float RumbleNow => RealTime.Now - _rumbleAt < 0.25f ? _rumbleNow : 0f;
static float _rumbleNow, _rumbleAt;
/// <summary>
/// Hold the local player's view shaking — a tremor rather than a thump: trauma kept up to <paramref name="level"/> for
/// <paramref name="seconds"/>, easing in over the first fifth and out over the last half. The spawn-in's (`SpawnTremor`).
///
/// ⚠️ A FLOOR, NOT AN ADD. A punch landing during it still lands on top, and the rumble cannot pile up past its own level.
/// </summary>
public static void Rumble( float level, float seconds )
{
if ( level <= 0f || seconds <= 0f ) return;
var player = NZPlayer.Local;
if ( !player.IsValid() ) return;
var sh = player.Components.GetOrCreate<CameraShake>();
sh._rumbleLevel = MathX.Clamp( level, 0f, 1f );
sh._rumbleLength = seconds;
sh._rumbleSince = 0f;
}
/// <summary>What a rumble holds the trauma at now: up over its first fifth, held, down over its last half; 0 once over.</summary>
float RumbleFloor()
{
if ( _rumbleLength <= 0f ) return 0f;
var t = (float)_rumbleSince / _rumbleLength;
if ( t >= 1f )
{
_rumbleLength = 0f;
return 0f;
}
var up = MathX.Clamp( t / 0.2f, 0f, 1f );
var down = MathX.Clamp( (1f - t) / 0.5f, 0f, 1f );
return _rumbleLevel * MathF.Min( up, down );
}
protected override void OnUpdate()
{
Ticks++;
_player ??= Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors );
if ( !_player.IsValid() ) return;
var ctrl = _player.Components.Get<PlayerController>( FindMode.EverythingInSelfAndDescendants );
if ( !ctrl.IsValid() ) return;
// ⛔ RECOVER FIRST, ALWAYS — including on the frame trauma hits zero, or the last offset
// stays on the player's view forever.
if ( _applied != default )
{
// ⚠️ NEGATED FIELD BY FIELD — `Angles` has no unary minus. `Weapon.Getters.cs:451` writes
// its recovery the same way for the same reason.
_player.ApplyEyeAnglesOffset(
new Angles( -_applied.pitch, -_applied.yaw, -_applied.roll ) );
_applied = default;
}
// ⚠️ A RUMBLE HOLDS THE TRAUMA UP FOR ITS LENGTH (`Rumble`) — the decay below would take it away in a third of a second
var floor = RumbleFloor();
if ( _trauma < floor ) _trauma = floor;
if ( _player == NZPlayer.Local )
{
_rumbleNow = floor;
_rumbleAt = RealTime.Now;
}
if ( _trauma <= 0f ) return;
_trauma = MathF.Max( 0f, _trauma - Time.Delta * MathF.Max( 0.01f, Decay ) );
if ( Scale <= 0f ) return;
// ⛔ LINEAR IN TRAUMA, NOT SQUARED, AND THAT SQUARE IS WHY THREE VERSIONS LOOKED BROKEN.
// `nz_shake 0.5` meant 0.5² = 0.25 of a 1.6° roll — 0.4° for a sixth of a second, which is
// not a subtle shake, it is no shake. A Brutus footstep was 0.28² = 0.078, or 0.125°.
//
// ⚠️ THE MECHANISM WAS NEVER THE PROBLEM. `nz_kick 15` moved the view correctly on the first
// try, which is what finally separated "the route does not work" from "the numbers are too
// small to see" — two failures that are indistinguishable on screen.
//
// ⚠️ DISTANCE FALLOFF IS STILL SQUARED, in `Punch`. That one is about spatial feel — near
// versus far — and it is applied to the trauma being ADDED, not to the swing. Squaring both
// is what compounded into nothing.
var s = _trauma * Scale;
var want = new Angles(
Game.Random.Float( -1f, 1f ) * MaxPitch * s,
Game.Random.Float( -1f, 1f ) * MaxYaw * s,
Game.Random.Float( -1f, 1f ) * MaxRoll * s );
LastSwing = MathF.Abs( want.roll );
AppliedFrames++;
// ⛔ RECORD WHAT LANDED, NOT WHAT WAS ASKED FOR. `PlayerController.PitchClamp` refuses an
// offset that would take the view past vertical, so near the top or bottom of the look range
// the pitch applied is SMALLER than the pitch requested. Subtracting the requested value next
// frame would then remove more than was ever added, and the view would walk away from where
// the player is pointing — a little every frame, for as long as the shake lasts.
//
// ⚠️ IT DID NOT MATTER AT 2.2°, AND IT DOES AT 8.8°. The clamp only bites within one swing of
// vertical; quadrupling the swing quadruples the band where this goes wrong.
var before = ctrl.EyeAngles;
_player.ApplyEyeAnglesOffset( want );
var after = ctrl.EyeAngles;
_applied = new Angles( after.pitch - before.pitch,
after.yaw - before.yaw, after.roll - before.roll );
}
/// <summary>
/// Shake the local player's view for something happening at <paramref name="from"/>, falling off
/// with distance.
///
/// ⚠️ FALLOFF IS SQUARED, so a boss two rooms away is a hint and one behind you is a thump. A
/// linear falloff makes distant footsteps far too present — at half the range it would still be
/// half strength, and a heavy step you can feel from 300 units stops meaning anything.
///
/// ⚠️ IT RETURNS QUIETLY OUTSIDE THE RANGE rather than adding zero trauma, so the common case —
/// every zombie in the level stepping — costs one distance check.
/// </summary>
public static void Punch( Vector3 from, float strength, float range )
{
if ( strength <= 0f || range <= 0f || Scale <= 0f ) return;
var player = NZPlayer.Local;
if ( !player.IsValid() ) return;
var dist = player.WorldPosition.Distance( from );
if ( dist >= range ) return;
var falloff = 1f - dist / range;
player.Components.GetOrCreate<CameraShake>().Add( strength * falloff * falloff );
}
/// <summary>
/// `nz_shake [strength]` — punch the view, and report what the LAST punch actually did.
///
/// ⚠️ RUN IT TWICE. The counters are printed before this punch has had a frame to run, so the
/// second call reports the first one's result. A count read in the same breath as the thing that
/// increments it always reads zero.
/// </summary>
[ConCmd( "nz_shake" )]
public static void ShakeCmd( float strength = 0.5f )
{
var player = NZPlayer.Local;
if ( !player.IsValid() ) { Log.Warning( "[nz-shake] no player" ); return; }
var sh = player.Components.GetOrCreate<CameraShake>();
Log.Info( $"[nz-shake] +{strength:0.##} trauma · scale {Scale:0.##}"
+ $" · max {MaxPitch:0.##}/{MaxYaw:0.##}/{MaxRoll:0.##}° · decay {Decay:0.##}/s" );
Log.Info( $"[nz-shake] since last: {sh.Ticks} tick(s),"
+ $" applied {sh.AppliedFrames} frame(s), last swing {sh.LastSwing:0.###}°" );
if ( sh.Ticks == 0 )
Log.Warning( "[nz-shake] ⛔ OnUpdate NEVER RAN — the component is not ticking at all" );
else if ( sh.AppliedFrames == 0 )
Log.Warning( "[nz-shake] ⛔ it ticks but never applied — no NZPlayer found on it,"
+ " or trauma decayed unseen" );
sh.Ticks = 0;
sh.AppliedFrames = 0;
sh.Add( strength );
}
/// <summary>
/// `nz_kick [degrees]` — ONE big eye-angle offset, right now. No component, no trauma, no decay.
///
/// ⛔ THE POINT IS TO TEST THE MECHANISM, NOT THE FEATURE. Three shake implementations have now
/// produced nothing visible, and every one of them had a component, a decay curve, a falloff and
/// a per-frame recovery between the console and the screen. This has none of that: it is the
/// exact line weapon recoil uses, called once, with a number far too large to miss.
///
/// If the view jumps, the mechanism works and the bug is in `CameraShake`. If it does not,
/// `ApplyEyeAnglesOffset` cannot be driven from a ConCmd and every version so far was doomed.
/// </summary>
[ConCmd( "nz_kick" )]
public static void KickCmd( float degrees = 15f )
{
var player = NZPlayer.Local;
if ( !player.IsValid() ) { Log.Warning( "[nz-kick] no player" ); return; }
var c = player.Components.Get<PlayerController>( FindMode.EverythingInSelfAndDescendants );
if ( !c.IsValid() )
{
Log.Warning( "[nz-kick] ⛔ NO PlayerController FOUND ON THE PLAYER — this is the answer:"
+ " ApplyEyeAnglesOffset returns silently without one, so every shake so far did"
+ " nothing and said nothing" );
return;
}
var before = c.EyeAngles;
player.ApplyEyeAnglesOffset( new Angles( -degrees, 0f, 0f ) );
var after = c.EyeAngles;
Log.Info( $"[nz-kick] eye angles {before.pitch:0.0} -> {after.pitch:0.0} pitch"
+ $" (asked for {-degrees:0.0})" );
if ( MathF.Abs( after.pitch - before.pitch ) < 0.01f )
Log.Warning( "[nz-kick] ⛔ THE WRITE DID NOT STICK — EyeAngles is being recomputed,"
+ " so this route cannot work at all" );
}
/// <summary>`nz_shake_scale <n>` — 0 turns shake off entirely.</summary>
[ConCmd( "nz_shake_scale" )]
public static void ScaleCmd( float scale = 1f )
{
Scale = MathF.Max( 0f, scale );
Log.Info( $"[nz-shake] scale {Scale:0.##}{(Scale <= 0f ? " — shake OFF" : "")}" );
}
}