Game/StageLostEffect.cs
namespace Monolith;

/// <summary>
/// What losing a stage looks and sounds like.
///
/// Until now, losing produced a line in the console and nothing else. `StageResetAt` was being
/// SET by the manager and read by absolutely nothing, so the stage silently rebuilt underneath
/// you and the most likely reading was that the game had glitched. A failure state you cannot
/// perceive is not a failure state, it is a bug report waiting to happen.
///
/// Devil Daggers gets away with brutal deaths because the death is unmistakable and the restart
/// is instant (GOALS 6b rule 4). We take nothing from you but time, so the loss has to be
/// *legible* rather than punishing: the screen goes red, the world thumps, and you are back in
/// it within a second, knowing exactly what happened and who did it.
/// </summary>
public sealed class StageLostEffect : Component
{
	/// <summary>Seconds the red wash takes to fall away.</summary>
	[Property] public float FlashSeconds { get; set; } = 1.4f;

	/// <summary>How far the camera is kicked at the moment of the hit.</summary>
	[Property] public float ShakeStrength { get; set; } = 26f;

	[Property] public float ShakeSeconds { get; set; } = 0.65f;

	private Vignette vignette;
	private float lastHandledReset = -999f;

	/// <summary>0 to 1 while the loss is still being announced. Read by the HUD for its banner.</summary>
	public static float Intensity { get; private set; }

	protected override void OnUpdate()
	{
		// Frozen while a blocking screen or the pause menu is up. See GameTime.
		if ( GameTime.Paused )
			return;

		// Looked up lazily rather than in OnAwake. Component construction order is not something
		// worth depending on for a lookup this cheap, and getting it wrong would fail silently
		// as "the red flash never happens", which is exactly the class of bug this file exists
		// to fix in the first place.
		vignette ??= Components.Get<Vignette>();

		var manager = MonolithManager.Instance;
		if ( !manager.IsValid() )
			return;

		// Fires once per reset. Comparing against the stored timestamp rather than using an
		// event keeps this a pure reader: the manager does not need to know the effect exists.
		if ( manager.StageResetAt > lastHandledReset )
		{
			lastHandledReset = manager.StageResetAt;
			Announce();
		}

		float since = GameTime.Now - lastHandledReset;
		Intensity = Math.Clamp( 1f - since / FlashSeconds, 0f, 1f );

		ApplyWash();
		ApplyShake( since );
	}

	private void Announce()
	{
		var position = WorldPosition;

		// Two layers: a deep detonation for weight, and a sting on top so it cuts through
		// whatever else is playing. One sound is an event; two is an announcement.
		Audio.Play( Audio.Explosion, position, 1f, 0.45f );
		Audio.Play( Audio.Alert, position, 0.8f, 0.55f );

		// A third, flat layer that does not obey distance. The other two are placed in the world,
		// so losing a stage while standing far from the shape was oddly quiet for the worst thing
		// that can happen to you.
		Audio.StageLost();

		Log.Info( "STAGE LOST." );
	}

	/// <summary>
	/// Drives the existing Vignette red and then hands it back. Reusing the component the retro
	/// look already installed means the flash costs nothing extra and cannot fight it.
	/// </summary>
	private void ApplyWash()
	{
		if ( !vignette.IsValid() )
			return;

		if ( Intensity <= 0f )
		{
			vignette.Intensity = Tuning.RetroVignette;
			vignette.Color = Color.Black;
			return;
		}

		// Eased so the peak is a hard slam and the tail is a slow bleed, rather than a linear
		// fade that reads as a UI transition.
		float curve = Intensity * Intensity;

		vignette.Intensity = Tuning.RetroVignette + curve * 0.75f;
		vignette.Color = Color.Lerp( Color.Black, new Color( 0.55f, 0.02f, 0.02f ), curve );
	}

	/// <summary>
	/// Current camera kick, ADDED by <see cref="PlayerAvatar"/> when it places the boom.
	///
	/// Published rather than applied directly: PlayerAvatar overwrites the camera's local
	/// position every single frame in OnPreRender, so anything written here would be silently
	/// discarded. Whoever owns a transform has to own all of it.
	/// </summary>
	public static Vector3 ShakeOffset { get; private set; }

	private void ApplyShake( float since )
	{
		if ( since > ShakeSeconds )
		{
			ShakeOffset = Vector3.Zero;
			return;
		}

		// Squared falloff and a fresh random direction each frame, so it reads as an impact
		// rather than a wobble.
		float falloff = 1f - (since / ShakeSeconds);

		ShakeOffset = Vector3.Random.Normal * ShakeStrength * falloff * falloff;
	}
}