Player/SonicDaze.cs

Player component that applies a temporary daze effect: slows the local player and requests screen blur. It stores duration, strength curve (hold then fade), and exposes static helpers to apply, clear, and query speed/blur. Also provides console commands to self-daze and clear all dazes.

File AccessNetworking
using Sandbox;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// WHAT A SONIC WAVE LEAVES BEHIND — the player is slowed hard and their vision goes soft.
///
/// ⛔ THE SHRIEKER DOES NOT DAMAGE YOU, IT TAKES YOUR LEGS AND YOUR EYES. That is the whole enemy,
/// and it is why this is a status rather than a hit: being at 35% speed with a blurred screen while
/// a horde closes is worse than losing health, and unlike health it is not something Juggernog or a
/// medkit answers. The only answers are killing the Shrieker or not being where the wave is going.
///
/// ⚠️ A COMPONENT ON THE PLAYER, NOT A STATIC. Every other player-side modifier in this project is
/// read off the player — `PerkEffects.SpeedMultiplier` is a product of component lookups — and a
/// static would mean one global daze shared by everybody in a four-player game. It also removes
/// itself, so nothing has to remember to clean up.
///
/// ⚠️ AND IT IS APPLIED LOCALLY ON EACH MACHINE, not replicated. `SonicWave` is broadcast and every
/// machine runs its own copy along the same straight line at the same speed, so each one reaches the
/// same verdict about its own local player — which is better than host authority here, because the
/// thing being modified is movement, and movement is owned by the client doing the moving.
/// </summary>
public sealed class SonicDaze : Component
{
	/// <summary>
	/// What the player's speed is multiplied by at full strength.
	///
	/// ⚠️ 0.35 IS "A LOT SLOWER" AND IS MEANT TO BE ALARMING, but it is deliberately not 0. A
	/// player who cannot move at all is a player watching their own death, which is a worse
	/// experience than a hard fight — they should always be able to back out of a room, just not
	/// outrun anything while doing it.
	/// </summary>
	[Property] public float SlowTo { get; set; } = 0.35f;

	/// <summary>How blurred the screen gets at full strength, in pixels of backdrop blur.</summary>
	[Property] public float BlurPixels { get; set; } = 11f;

	/// <summary>How long the whole thing lasts.</summary>
	[Property] public float Seconds { get; set; } = 4f;

	TimeUntil _until;

	/// <summary>
	/// 1 while it is at full strength, easing to 0 as it wears off.
	///
	/// ⛔ IT HOLDS FOR THE FIRST HALF AND FADES OVER THE SECOND, rather than decaying from the
	/// moment it lands. A linear decay makes the strongest instant the one you never see — you are
	/// already recovering before you have registered being hit — and a hard snap back at the end
	/// reads as a bug. Holding then easing gives the hit a weight and the recovery a shape.
	/// </summary>
	public float Strength
	{
		get
		{
			var life = MathF.Max( 0.05f, Seconds );
			var left = (float)_until;

			if ( left <= 0f ) return 0f;

			var fade = life * 0.5f;
			return left >= fade ? 1f : ( left / fade ).Clamp( 0f, 1f );
		}
	}

	protected override void OnUpdate()
	{
		// ⚠️ IT DELETES ITSELF. The alternative is every reader checking whether the thing it found
		// has expired, which is the shape that eventually gets one reader wrong.
		if ( _until <= 0f ) Destroy();
	}

	/// <summary>
	/// Daze a player, or extend a daze they already have.
	///
	/// ⚠️ IT EXTENDS RATHER THAN STACKS, AND ONLY UPWARDS — upstream's `UpdateDuration` refuses to
	/// shorten an existing effect (`if self.statusEnd - CurTime() > newtime then return end`) and
	/// this keeps that. Two Shriekers screaming should not compound into a ten-second blind; the
	/// longer of the two is the honest answer and it keeps the worst case bounded.
	/// </summary>
	public static void Apply( NZPlayer player, float seconds, float slowTo = -1f )
	{
		if ( !player.IsValid() || seconds <= 0f ) return;

		var d = player.Components.GetOrCreate<SonicDaze>();

		if ( slowTo >= 0f ) d.SlowTo = slowTo;

		if ( (float)d._until < seconds )
		{
			d.Seconds = seconds;
			d._until = seconds;
		}
	}

	/// <summary>
	/// The speed multiplier a dazed player is walking under — 1 when they are not.
	///
	/// ⚠️ READ BY `PerkEffects.SpeedMultiplier`, which is the single term NZPlayer's walk and run
	/// AND Stamina's sprint all multiply by. Applying it at any one of those three would leave the
	/// other two untouched, so a dazed player could still sprint away at full speed.
	/// </summary>
	public static float SpeedScale( NZPlayer player )
	{
		if ( !player.IsValid() ) return 1f;

		var d = player.Components.Get<SonicDaze>( FindMode.EverythingInSelf );
		if ( !d.IsValid() ) return 1f;

		// Lerp UP from the slow toward 1 as it wears off, so the legs come back with the eyes.
		return MathX.Lerp( 1f, MathF.Max( 0.05f, d.SlowTo ), d.Strength );
	}

	/// <summary>How much blur the local player's screen wants, in pixels. 0 when clear.</summary>
	public static float BlurFor( NZPlayer player )
	{
		if ( !player.IsValid() ) return 0f;

		var d = player.Components.Get<SonicDaze>( FindMode.EverythingInSelf );

		return d.IsValid() ? d.BlurPixels * d.Strength : 0f;
	}

	// ── commands ─────────────────────────────────────────────────────────────

	/// <summary>
	/// `nz_daze [seconds] [slowto]` — daze yourself, to look at it without a Shrieker.
	///
	/// ⚠️ THE ONLY WAY TO SEE THE EFFECT ON DEMAND. The wave has to be fired by a live Shrieker at
	/// a target in a 90–325u band, which is a lot of setup for "is the blur the right strength" —
	/// and a look you cannot get easily is a look nobody takes.
	/// </summary>
	[ConCmd( "nz_daze" )]
	public static void DazeCmd( float seconds = 4f, float slowTo = 0.35f )
	{
		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz-daze] no local player" ); return; }

		Apply( p, seconds, slowTo );

		Log.Info( $"[nz-daze] {seconds:0.#}s at x{slowTo:0.##} speed"
			+ $" — blur {BlurFor( p ):0.#}px, speed x{SpeedScale( p ):0.##}" );
	}

	/// <summary>`nz_daze_clear` — end it now.</summary>
	[ConCmd( "nz_daze_clear" )]
	public static void ClearCmd()
	{
		var n = Game.ActiveScene?.GetAllComponents<SonicDaze>().ToList();

		foreach ( var d in n ?? Enumerable.Empty<SonicDaze>().ToList() ) d.Destroy();

		Log.Info( $"[nz-daze] cleared {n?.Count ?? 0}" );
	}
}