UI/CameraShake.cs

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.

File Access
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 &lt;n&gt;` — 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" : "")}" );
	}
}