Game/FloorHazard.cs
namespace Monolith;

/// <summary>
/// The arena floor, divided into tiles that turn lethal on a three second cycle.
///
/// **What it is for.** Every movement tool in this game was optional. Hops, the double jump dash
/// and sprinting all make you faster, and faster was worth something only because a stage is
/// timed. Nothing ever required you to move at a particular moment, so a player who never learned
/// any of it simply cleared stages more slowly. This is the system that makes the movement
/// mandatory: half the floor becomes deadly, you get one second of warning, and standing still is
/// the one thing that is always wrong.
///
/// **The cycle.** Every <see cref="Tuning.FloorPhaseSeconds"/> a fresh 50/50 split is rolled. The
/// tiles that are about to become lethal light amber for <see cref="Tuning.FloorWarnSeconds"/>,
/// then go red and kill. So you are never asked to react instantly: you are asked to be somewhere
/// else by the time the second runs out, which is a movement problem rather than a reflex test.
///
/// **The violet tiles are different.** A small number are lethal permanently and never cycle.
/// They are why the safe half is not simply a place to stand: the route between safe tiles has
/// holes in it, and those holes do not move. Touching one ends the stage immediately with no
/// warning at all, so they never spawn near where a stage drops you.
///
/// **Only while grounded.** Airborne is always safe. That is the whole design: the answer to a
/// lethal floor is to not be on the floor, and every technique the game already has is a way of
/// doing that for longer.
/// </summary>
public sealed class FloorHazard : Component
{
	/// <summary>Tile states, stored per tile in a flat array indexed y * width + x.</summary>
	private enum Tile : byte
	{
		Safe,
		Hot,
		Dead,
	}

	private Tile[] tiles;
	private int width;
	private float tileSize;

	/// <summary>World position of the minimum corner of tile zero.</summary>
	private Vector3 origin;

	private float floorZ;

	private GameObject hotObject, deadObject;
	private ModelRenderer hotRenderer, deadRenderer;

	private GameTimeSince timeSincePhase = 999f;

	/// <summary>Since the stage was built or reset. Drives the arrival grace.</summary>
	private GameTimeSince timeSinceArrival = 999f;

	private bool armed;
	private bool announcedFirstPhase;

	/// <summary>
	/// True while the floor is holding still because a stage has just been built.
	///
	/// **Found in a real log, not by reasoning.** The first playtest showed a stage condensing at
	/// 16:04:26.57 and the floor taking it at 16:04:27.57, one second later to the millisecond.
	/// The player never had a chance: `FitTo` rolled a phase the instant the shape appeared, the
	/// warning ran while the game was still repositioning them, and they were killed standing
	/// exactly where they had been put. A hazard you are placed inside is not difficulty.
	/// </summary>
	private bool Settling => timeSinceArrival < Tuning.FloorStageGraceSeconds;

	/// <summary>True once the warning has elapsed and the hot tiles actually kill.</summary>
	private bool Live => timeSincePhase >= Tuning.FloorWarnSeconds;

	/// <summary>
	/// Lays the grid out under a stage. Called whenever a shape is placed.
	/// </summary>
	/// <param name="stage">
	/// The stage number the PLAYER sees, counting from 1. The caller converts from the 0-based
	/// `Tier`, and that conversion is the entire reason this parameter is documented: the first
	/// version compared `Tier` directly against a constant named "from stage", so a threshold of
	/// 3 actually armed on stage 4 and three full stages went by with no floor at all.
	/// </param>
	public void FitTo( BBox bounds, float halfExtent, int stage, bool inMonolith )
	{
		Clear();
		announcedFirstPhase = false;

		// A grace period, for the same reason the Spotter has one. A player who has just read the
		// tutorial should get to learn what mining feels like before the floor starts trying to
		// kill them. Set FloorHazardFromStage to 1 to have it on from the very first stage.
		armed = inMonolith || stage >= Tuning.FloorHazardFromStage;

		if ( !armed )
		{
			Log.Info( $"[floor] stage {stage}: not armed "
				+ $"(arms at stage {Tuning.FloorHazardFromStage})." );
			return;
		}

		floorZ = bounds.Mins.z;
		tileSize = Tuning.FloorTileSize;

		width = Math.Max( 4, (int)MathF.Ceiling( halfExtent * 2f / tileSize ) );

		// Centred on the arena, so the grid lines up with the walls rather than drifting.
		origin = new Vector3(
			bounds.Center.x - width * tileSize * 0.5f,
			bounds.Center.y - width * tileSize * 0.5f,
			floorZ );

		tiles = new Tile[width * width];

		SeedDeadTiles();
		BuildDeadMesh();

		// Deliberately NOT NewPhase(). Every tile starts safe and the first roll waits out the
		// grace, so arriving in a stage is never itself lethal.
		BuildHotMesh();
		timeSinceArrival = 0f;
		timeSincePhase = 0f;

		int deadCount = 0;
		foreach ( var t in tiles )
			if ( t == Tile.Dead ) deadCount++;

		// Instrumentation, because "I did not see any tiles" and "the tiles are broken" look
		// identical from the outside. This says which one it was.
		Log.Info( $"[floor] stage {stage}: armed. {width}x{width} tiles of {tileSize:0}u, "
			+ $"{deadCount} permanent, first roll in {Tuning.FloorStageGraceSeconds}s." );
	}

	/// <summary>
	/// Scatters the permanently lethal tiles, once per stage.
	/// </summary>
	/// <remarks>
	/// Excluded from a radius around the arena centre and from the outermost ring. The centre
	/// because that is roughly where a stage drops you, and an unavoidable death on arrival is not
	/// difficulty, it is a bug. The outer ring because being shoved into a wall by a rocket jump
	/// should not be fatal for reasons you could not have seen coming.
	/// </remarks>
	private void SeedDeadTiles()
	{
		float centre = (width - 1) * 0.5f;
		float safeRadius = Tuning.FloorDeadSafeRadius / tileSize;

		for ( int y = 0; y < width; y++ )
		{
			for ( int x = 0; x < width; x++ )
			{
				if ( x == 0 || y == 0 || x == width - 1 || y == width - 1 )
					continue;

				float dx = x - centre;
				float dy = y - centre;

				if ( MathF.Sqrt( dx * dx + dy * dy ) < safeRadius )
					continue;

				if ( Game.Random.Float() < Tuning.FloorDeadChance )
					tiles[y * width + x] = Tile.Dead;
			}
		}
	}

	/// <summary>Rolls a fresh half of the floor as hot, leaving the permanent tiles alone.</summary>
	private void NewPhase()
	{
		if ( tiles == null )
			return;

		timeSincePhase = 0f;

		int hot = 0;

		for ( int i = 0; i < tiles.Length; i++ )
		{
			if ( tiles[i] == Tile.Dead )
				continue;

			tiles[i] = Game.Random.Float() < Tuning.FloorHotFraction ? Tile.Hot : Tile.Safe;

			if ( tiles[i] == Tile.Hot ) hot++;
		}

		BuildHotMesh();

		if ( !announcedFirstPhase )
		{
			announcedFirstPhase = true;
			Log.Info( $"[floor] first phase live: {hot} tiles hot, "
				+ $"{Tuning.FloorWarnSeconds}s warning." );
		}
	}

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

		if ( !armed || tiles == null )
			return;

		if ( Settling )
			return;

		if ( timeSincePhase >= Tuning.FloorPhaseSeconds )
			NewPhase();

		Recolour();
		CheckPlayers();
	}

	/// <summary>
	/// The warning, told entirely by colour: amber while it is a threat, red once it is real.
	/// </summary>
	/// <remarks>
	/// Eased rather than switched, and the tint keeps moving after it goes live. A hard colour
	/// swap at exactly one second gives no sense of how long is left, and the entire job of the
	/// warning is to be readable as a countdown.
	/// </remarks>
	private void Recolour()
	{
		if ( !hotRenderer.IsValid() )
			return;

		float warn = Math.Clamp( timeSincePhase / Tuning.FloorWarnSeconds, 0f, 1f );

		// Amber to red, and DIMMER as it arms rather than brighter. Counterintuitive, but the
		// dead slabs below are the brightest thing on the floor, so the cycling tiles have to
		// live in the lower half of the value range or the two compete.
		var colour = Color.Lerp(
			new Color( 1f, 0.58f, 0.06f ),
			new Color( 0.85f, 0.08f, 0.03f ),
			warn );

		// Pulses once it is live, so a lethal tile never sits still and cannot be mistaken at a
		// glance for one that is merely warning.
		if ( Live )
			colour *= 0.7f + 0.3f * MathF.Sin( Time.Now * 14f );

		hotRenderer.Tint = colour;

		// The permanent tiles, deliberately the one thing on screen that is NOT in the palette.
		// Everything else in this game lives in the bone and ember range, so a cold bright violet
		// is the only colour left that cannot be confused with rock, fire, warning or blood. It
		// is also kept far brighter than anything else so it survives the retro downsample, and
		// it breathes slowly rather than flashing: it is a wall, not an alarm.
		if ( deadRenderer.IsValid() )
			deadRenderer.Tint = new Color( 0.85f, 0.35f, 1f )
				* (1.5f + 0.35f * MathF.Sin( Time.Now * 2.2f ));
	}

	/// <summary>
	/// How close the local player is to burning, 0 to 1. Read by the HUD.
	/// </summary>
	/// <remarks>
	/// Static because there is exactly one local player and the HUD has no reference to this
	/// component. Reset by <see cref="Clear"/> so a rebuilt stage never inherits a hot meter.
	/// </remarks>
	public static float BurnFraction { get; private set; }

	/// <summary>Accumulated contact with a live tile, per player, in seconds.</summary>
	private readonly Dictionary<PlayerMovement, float> burn = new();

	private GameTimeSince timeSinceBurnTick;

	/// <summary>
	/// Takes the stage from anyone standing somewhere they should not be.
	/// </summary>
	/// <remarks>
	/// **Touching a live tile is not instant death.** It used to be, and it made the floor feel
	/// arbitrary rather than dangerous: a tile going red under a foot that was already mid-stride
	/// killed you for a decision you had made half a second earlier. Contact now fills a meter,
	/// and leaving empties it.
	///
	/// It empties SLOWER than it fills, which is the part that matters. If it reset the instant
	/// you stepped off, hopping on the spot on a red tile would be survivable forever, since
	/// airborne is already safe. Recovering at a fraction of the burn rate means the answer has to
	/// be actually going somewhere, which is what the whole system is for.
	///
	/// The permanent violet slabs are deliberately still instant. They never cycle, they are
	/// raised so you can see them from anywhere, and being the one thing in the game that offers
	/// no second chance is their entire identity.
	/// </remarks>
	private void CheckPlayers()
	{
		var manager = MonolithManager.Instance;
		if ( !manager.IsValid() )
			return;

		BurnFraction = 0f;

		foreach ( var player in Scene.GetAllComponents<PlayerMovement>() )
		{
			burn.TryGetValue( player, out float held );

			bool onFire = false;

			// Airborne is always safe. This is the entire mechanic.
			if ( player.IsGrounded )
			{
				var tile = TileAt( player.WorldPosition );

				if ( tile == Tile.Dead )
				{
					burn.Remove( player );
					Kill( manager, player, "stepped on a dead tile" );
					return;
				}

				onFire = tile == Tile.Hot && Live;
			}

			held += onFire
				? GameTime.Delta
				: -GameTime.Delta * Tuning.FloorBurnRecoverRate;

			held = Math.Clamp( held, 0f, Tuning.FloorBurnSeconds );
			burn[player] = held;

			float fraction = held / Tuning.FloorBurnSeconds;
			BurnFraction = MathF.Max( BurnFraction, fraction );

			if ( onFire )
				TickBurnAudio( player, fraction );

			if ( held >= Tuning.FloorBurnSeconds )
			{
				burn.Remove( player );
				Kill( manager, player, "burned by the floor" );
				return;
			}
		}
	}

	/// <summary>
	/// A tick that quickens as you burn.
	/// </summary>
	/// <remarks>
	/// The same trick the Crawler uses for footsteps, for the same reason: a meter that only
	/// exists on the HUD is a meter you will not look at while something is chasing you. An
	/// accelerating sound tells you how long you have without asking you to look anywhere.
	/// </remarks>
	private void TickBurnAudio( PlayerMovement player, float fraction )
	{
		float interval = MathX.Lerp( 0.22f, 0.06f, fraction );

		if ( timeSinceBurnTick < interval )
			return;

		timeSinceBurnTick = 0f;
		Audio.Play( Audio.Crumble, player.WorldPosition,
			0.3f + 0.5f * fraction, 0.8f + 0.8f * fraction );
	}

	private void Kill( MonolithManager manager, PlayerMovement player, string reason )
	{
		BurnFraction = 0f;

		Audio.Play( Audio.Explosion, player.WorldPosition, 1f, 0.5f );

		// Rebuilding the stage calls FitTo, which lays a fresh all-safe grid and restarts the
		// grace. So a loss cannot cascade: you are never dropped back onto the tile that just
		// killed you with the clock already run down.
		manager.ResetStageProgress( reason );
	}

	private Tile TileAt( Vector3 world )
	{
		if ( tiles == null )
			return Tile.Safe;

		int x = (int)MathF.Floor( (world.x - origin.x) / tileSize );
		int y = (int)MathF.Floor( (world.y - origin.y) / tileSize );

		if ( x < 0 || y < 0 || x >= width || y >= width )
			return Tile.Safe;

		return tiles[y * width + x];
	}

	// ---------------------------------------------------------------- meshes

	private void BuildHotMesh()
	{
		hotObject ??= MakeChild( "Floor Hot", out hotRenderer );

		if ( hotRenderer.IsValid() )
			hotRenderer.Model = BuildTiles( Tile.Hot );
	}

	private void BuildDeadMesh()
	{
		deadObject ??= MakeChild( "Floor Dead", out deadRenderer );

		if ( deadRenderer.IsValid() )
			deadRenderer.Model = BuildTiles( Tile.Dead );
	}

	private GameObject MakeChild( string name, out ModelRenderer renderer )
	{
		var obj = new GameObject( true, name );
		obj.NetworkMode = NetworkMode.Never;
		obj.Parent = GameObject;
		obj.WorldPosition = Vector3.Zero;

		renderer = obj.AddComponent<ModelRenderer>();
		return obj;
	}

	/// <summary>
	/// Builds one flat quad per tile of the given state.
	/// </summary>
	/// <remarks>
	/// Rebuilt every phase rather than kept as fixed models that get shown and hidden. At a few
	/// hundred quads a rebuild costs microseconds and happens once every three seconds, so the
	/// simpler code wins easily over anything cleverer.
	///
	/// Lifted slightly off the floor so it does not fight the VoidGrid for the same pixels, and
	/// inset so neighbouring tiles read as separate rather than as one field of colour.
	/// </remarks>
	private Model BuildTiles( Tile want )
	{
		var vb = new VertexBuffer();
		vb.Init( true );

		int index = 0;
		bool dead = want == Tile.Dead;

		// The permanent tiles get a bigger gap as well as height, so they read as separate blocks
		// standing in the floor rather than as a painted region of it.
		float inset = tileSize * (dead ? 0.14f : 0.06f);
		float z = floorZ + 2f;
		float height = dead ? Tuning.FloorDeadHeight : 0f;

		for ( int y = 0; y < width; y++ )
		{
			for ( int x = 0; x < width; x++ )
			{
				if ( tiles[y * width + x] != want )
					continue;

				float x0 = origin.x + x * tileSize + inset;
				float y0 = origin.y + y * tileSize + inset;
				float x1 = x0 + tileSize - inset * 2f;
				float y1 = y0 + tileSize - inset * 2f;

				if ( dead )
					AddSlab( vb, ref index, x0, y0, x1, y1, z, height );
				else
					AddQuad( vb, ref index,
						new Vector3( x0, y0, z ),
						new Vector3( x1, y0, z ),
						new Vector3( x1, y1, z ),
						new Vector3( x0, y1, z ) );
			}
		}

		// An empty vertex buffer is not a valid model, and a phase where nothing is hot is
		// entirely possible. Returning null clears the renderer instead of throwing.
		if ( index == 0 )
			return null;

		var mesh = new Mesh( Material.Load( "materials/default.vmat" ) );
		mesh.CreateBuffers( vb );

		return new ModelBuilder().AddMesh( mesh ).Create();
	}

	/// <summary>
	/// A low box: a top face plus four sides.
	///
	/// The sides are the point. A flat quad and a raised one look identical from directly above,
	/// and the whole reason for the height is that the four vertical faces catch the player lamp
	/// at a completely different angle from the floor. That is what makes a dead tile readable at
	/// a glance and from across the arena, where its colour is only a few pixels wide.
	/// </summary>
	private static void AddSlab( VertexBuffer vb, ref int index,
		float x0, float y0, float x1, float y1, float z, float height )
	{
		float top = z + height;

		AddQuad( vb, ref index,
			new Vector3( x0, y0, top ), new Vector3( x1, y0, top ),
			new Vector3( x1, y1, top ), new Vector3( x0, y1, top ) );

		AddQuad( vb, ref index,
			new Vector3( x0, y0, z ), new Vector3( x1, y0, z ),
			new Vector3( x1, y0, top ), new Vector3( x0, y0, top ) );

		AddQuad( vb, ref index,
			new Vector3( x1, y1, z ), new Vector3( x0, y1, z ),
			new Vector3( x0, y1, top ), new Vector3( x1, y1, top ) );

		AddQuad( vb, ref index,
			new Vector3( x1, y0, z ), new Vector3( x1, y1, z ),
			new Vector3( x1, y1, top ), new Vector3( x1, y0, top ) );

		AddQuad( vb, ref index,
			new Vector3( x0, y1, z ), new Vector3( x0, y0, z ),
			new Vector3( x0, y0, top ), new Vector3( x0, y1, top ) );
	}

	private static void AddQuad( VertexBuffer vb, ref int index,
		Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3 )
	{
		var up = Vector3.Up;
		var tangent = (p1 - p0).Normal;

		vb.Add( new Vertex( p0, up, tangent, new Vector4( 0, 0, 0, 0 ) ) );
		vb.Add( new Vertex( p1, up, tangent, new Vector4( 1, 0, 0, 0 ) ) );
		vb.Add( new Vertex( p2, up, tangent, new Vector4( 1, 1, 0, 0 ) ) );
		vb.Add( new Vertex( p3, up, tangent, new Vector4( 0, 1, 0, 0 ) ) );

		vb.AddRawIndex( index + 0 ); vb.AddRawIndex( index + 1 ); vb.AddRawIndex( index + 2 );
		vb.AddRawIndex( index + 0 ); vb.AddRawIndex( index + 2 ); vb.AddRawIndex( index + 3 );

		index += 4;
	}

	private void Clear()
	{
		BurnFraction = 0f;
		burn.Clear();

		hotObject?.Destroy();
		deadObject?.Destroy();

		hotObject = null;
		deadObject = null;
		hotRenderer = null;
		deadRenderer = null;

		tiles = null;
	}

	protected override void OnDestroy() => Clear();
}