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;
}
}