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