UI/LevelPreview.cs
using System;
using System.Collections.Generic;
namespace BlockParty;
/// <summary>Growth direction of a preview spike strip (None = a plain solid wall band).</summary>
public enum PreviewSpike { None, Up, Down, Left, Right }
/// <summary>
/// One drawable rectangle in a <see cref="LevelPreviewModel"/>, in LOGICAL arena space (0..240,
/// origin bottom-left, +Y up — the same space as <see cref="RectF"/> / the gameplay geometry). The
/// <see cref="LevelNodePreview"/> component converts these to CSS percentages of the 240x240 arena so
/// the schematic scales to any node size.
/// </summary>
public readonly struct PreviewRect
{
public readonly float X, Y, W, H;
public readonly Color Color;
/// <summary><see cref="PreviewSpike.None"/> is a solid wall band, filled flat with <see cref="Color"/>.
/// Otherwise this is a teeth OVERLAY drawn on top of a band rect: it tiles the band-removed teeth art
/// along its edge with no fill, so the band beneath shows through and inter-teeth gaps stay
/// transparent. Teeth draw in <see cref="Tint"/> — white for every normal hazard; GLASS faces
/// shade theirs toward the glass colour (blocks slide over those spikes).</summary>
public readonly PreviewSpike Spike;
/// <summary>Teeth tint for spike overlays (ignored for plain bands). White by default.</summary>
public readonly Color Tint;
public PreviewRect( float x, float y, float w, float h, Color color, PreviewSpike spike = PreviewSpike.None, Color? tint = null )
{
X = x; Y = y; W = w; H = h; Color = color; Spike = spike; Tint = tint ?? Color.White;
}
}
/// <summary>
/// A schematic of a level's arena, used to draw the level-select node so each node visually reads as
/// the level it launches: the play-field / out-of-bounds / wall colours plus the obstacle layout with
/// its combined walls, elbow patches and starting spikes. Built by <see cref="LevelPreview.Build"/>
/// and rendered by <see cref="LevelNodePreview"/>.
/// </summary>
public sealed class LevelPreviewModel
{
/// <summary>Play-field (checkerboard) colour — the node's base fill.</summary>
public Color Background;
/// <summary>Out-of-bounds colour — the interior fill of obstacles.</summary>
public Color OutOfBounds;
/// <summary>Direct wall colour for solid bands and elbow patches.</summary>
public Color Wall;
/// <summary>Decorative drifting-block colours (front → back), drawn as a few static squares
/// behind the walls to hint at the level's moving backdrop.</summary>
public IReadOnlyList<Color> BgBlockColors;
public float BgBlockScale;
public float BgBlockDensity;
public float BgBlockOpacity;
public float BgDriftSpeed;
public BackgroundDriftBias BgDriftBias;
/// <summary>Wall + obstacle primitives, in paint order (arena edge bands, obstacle fills, obstacle
/// face bands, elbow patches, then every spike strip, then the line-of-sight cover).</summary>
public List<PreviewRect> Rects = new();
}
/// <summary>
/// Builds a <see cref="LevelPreviewModel"/> from a <see cref="LevelDef"/>. Mirrors the arena/obstacle
/// wall construction in <see cref="GameStage"/> (flush cutting so abutting obstacles read as one
/// combined wall, elbow patches at the seams, and per-side starting spikes) but emits scaled
/// primitives for the UI instead of world sprites. It is a schematic — wall bands are exaggerated for
/// legibility at node size, and arena edges rely on the obstacle fills painting over them rather than
/// being flush-cut (visually identical for the split case).
/// </summary>
public static class LevelPreview
{
// Defaults matching the classic look (see StageBase / GameManager.ClearColor / the baked wall art).
private static readonly Color DefaultBackground = new Color( 0f, 189f / 255f, 196f / 255f ); // TEAL checkerboard
private static readonly Color DefaultWall = LevelCosmeticsRandomizer.DefaultWallColor;
private static readonly Color DefaultOutOfBounds = new Color( 0f, 32f / 255f, 40f / 255f ); // clear colour
private static readonly Color CoinYellow = new( 221 / 255f, 221 / 255f, 153 / 255f );
private static readonly Color[] DefaultBgBlocks =
{
new Color( 0f, 150f / 255f, 157f / 255f ),
new Color( 0f, 135f / 255f, 142f / 255f ),
new Color( 0f, 120f / 255f, 127f / 255f ),
};
// Exaggerated wall thickness so 2px arena walls stay visible when a 240px arena shrinks to a
// ~108px node. Spikes extend the same amount again inward (the baked art is ~half solid, half
// teeth), so a spiked band is twice as thick as a plain one.
private const float WALL_VIS = 8f;
private const float SPIKE_EXTRA = 8f;
private struct Span { public float Min; public float Max; }
public static LevelPreviewModel Build( LevelDef level )
{
Color wall = level?.WallColor ?? DefaultWall;
var m = new LevelPreviewModel
{
Background = level?.CheckerboardColor ?? DefaultBackground,
OutOfBounds = level?.OutOfBoundsColor ?? DefaultOutOfBounds,
Wall = wall,
BgBlockColors = ResolveBgBlocks( level ),
BgBlockScale = Math.Clamp( level?.BackgroundBlockScale ?? 1f, 0.25f, 1.5f ),
BgBlockDensity = Math.Clamp( level?.BackgroundBlockDensity ?? 1f, 0.25f, 2f ),
BgBlockOpacity = Math.Clamp( level?.BackgroundBlockOpacity ?? 1f, 0f, 1f ),
BgDriftSpeed = Math.Clamp( level?.BackgroundDriftSpeed ?? 1f, 0f, 3f ),
BgDriftBias = level?.BackgroundDriftBias ?? BackgroundDriftBias.None,
};
float W = Arena.WIDTH, H = Arena.HEIGHT, t = WALL_VIS, s = SPIKE_EXTRA;
// Split out the FENCES (solid only to blocks) and GLASS (solid only to players): neither
// merges with arena walls or normal obstacles nor cuts their faces, so every wall/face
// mirror below sees only the real walls. Each is drawn separately with its own see-through
// look (AddFence / AddGlass).
var obstacles = new List<RectF>();
var fenceRects = new List<RectF>();
var glassRects = new List<RectF>();
foreach ( var r in level?.Obstacles ?? System.Array.Empty<RectF>() )
(ContainsRect( level?.Fences, r ) ? fenceRects : ContainsRect( level?.Glass, r ) ? glassRects : obstacles).Add( r );
// Every spike strip is collected here and painted after ALL the solid primitives (see below), so
// nothing drawn later can bury one — mirroring the game, where all teeth sit on
// WallFace.ORDER_TEETH, above every band and elbow patch on ORDER_BAND.
var spikeOverlays = new List<PreviewRect>();
// Arena edges. The BANDS are not flush-cut around obstacles: an obstacle sitting flush against an
// edge is drawn (fill first) over the band, which reproduces a split spiked wall (e.g. a C-shape's
// two floor channels) since the fill masks the covered middle. The TEETH must be cut instead —
// they paint last, so a fill can no longer mask them.
AddArenaEdge( m, level, obstacles, spikeOverlays, Direction.Down, Contains( level?.SpikedWalls, Direction.Down ), W, H, t, s );
AddArenaEdge( m, level, obstacles, spikeOverlays, Direction.Up, Contains( level?.SpikedWalls, Direction.Up ), W, H, t, s );
AddArenaEdge( m, level, obstacles, spikeOverlays, Direction.Left, Contains( level?.SpikedWalls, Direction.Left ), W, H, t, s );
AddArenaEdge( m, level, obstacles, spikeOverlays, Direction.Right, Contains( level?.SpikedWalls, Direction.Right ), W, H, t, s );
// Obstacle fills first, so the face bands (added next) paint on top of every fill.
foreach ( var r in obstacles )
m.Rects.Add( ObstacleFillRect( level, r, m.OutOfBounds ) );
// Obstacle face bands (+ starting spikes), mirroring GameStage.
var faces = new List<FaceSeg>();
foreach ( var r in obstacles )
{
foreach ( var normal in new[] { Direction.Left, Direction.Right, Direction.Down, Direction.Up } )
{
if ( FlushWithArenaWall( r, normal ) )
continue;
bool spiked = ObstacleSideSpiked( level, r, normal );
AddObstacleSide( m, faces, spikeOverlays, obstacles, r, normal, spiked, t, s );
}
}
// Elbow patches in one pass after every face band exists, mirroring
// GameStage.AddObstacleElbowPatches (a corner notch can sit in a rect that is not either
// meeting face's owner — e.g. the staircase corner three flush rects make).
AddElbowPatches( m, obstacles, faces, t );
// Fences: an open frame + vertical bars in the level's fence colour (border = the colour,
// bars = its brighter derivative) — the playfield stays visible through them, so they read
// as "not a wall" at node size too. Flush fence-fence seams drop their frame segment
// (fences only combine with each other), mirroring GameStage.BuildFenceFrameSide.
Color fenceBorder = level?.FenceColor ?? LevelCosmeticsRandomizer.DefaultFenceColor;
Color fenceBar = LevelCosmeticsRandomizer.FenceBarColor( fenceBorder );
// Frame alpha matches the game's FENCE_BORDER_ALPHA (the bars get FENCE_BAR_ALPHA in AddFence).
fenceBorder = fenceBorder.WithAlpha( 0.85f );
var fenceFrames = new List<FaceSeg>();
foreach ( var r in fenceRects )
AddFence( m, fenceRects, r, t, fenceBorder, fenceBar, fenceFrames );
AddFrameElbowPatches( m, fenceRects, fenceFrames, t / 2f, fenceBorder );
// Glass: translucent pane + solid border in the level's glass colour; authored spikes emit
// TINTED teeth overlays (player-only hazard — blocks slide over them), collected with the
// other spike strips so nothing painted later can bury them.
Color glassColor = level?.GlassColor ?? LevelCosmeticsRandomizer.DefaultGlassColor;
var glassBorders = new List<FaceSeg>();
foreach ( var r in glassRects )
AddGlass( m, spikeOverlays, level, glassRects, r, t, s, glassColor, glassBorders );
AddFrameElbowPatches( m, glassRects, glassBorders, t / 2f, glassColor );
// Coin pickups: one yellow dot per coin, slightly oversized (the real 4x6 art shrinks to ~2px
// at node size). Painted before the spikes/vision cover so a coin sitting in a vision shadow
// is hidden here like it is in-game.
foreach ( var c in level?.Coins ?? System.Array.Empty<Vector2>() )
m.Rects.Add( new PreviewRect( c.x - 3f, c.y - 4f, 6f, 8f, CoinYellow ) );
// Paint every spike strip now, above all fills, bands and elbow patches. Thin obstacles can be
// narrower than the exaggerated preview band, so an opposite face emitted later must not cover
// their spikes — nor may an obstacle standing off a spiked arena wall erase that wall's teeth
// (the preview's spikes reach WALL_VIS + SPIKE_EXTRA inward, far further than the real ones).
m.Rects.AddRange( spikeOverlays );
// Line-of-sight cover: the static umbra behind each level-built vision blocker (explicit rects +
// obstacles when the level opts in), cast from the player spawn and filled in the wall colour.
// Painted last so it sits above the walls like the in-game cover. Excludes dynamic BlockShade.
AddVisionCover( m, level );
return m;
}
private static IReadOnlyList<Color> ResolveBgBlocks( LevelDef level )
{
Color[] nominal =
level?.BackgroundBlockColors is { Count: 3 } c ? new[] { c[0], c[1], c[2] }
: level?.BackgroundBlockColor is Color b ? new[] { Shade( b, 1f ), Shade( b, 0.9f ), Shade( b, 0.8f ) }
: DefaultBgBlocks;
// Level-provided block colours and the UI both use sRGB, so authored overrides can be painted
// directly. The built-in fallback predates that convention and remains a linear render value.
if ( level?.BackgroundBlockColors is { Count: 3 } || level?.BackgroundBlockColor is not null ) return nominal;
var outc = new Color[nominal.Length];
for ( int i = 0; i < nominal.Length; i++ )
outc[i] = LinearToGamma( nominal[i] );
return outc;
}
private static Color LinearToGamma( Color c ) => new Color( L2G( c.r ), L2G( c.g ), L2G( c.b ), c.a );
// Standard sRGB linear->gamma transfer (matches what the display applies to the shader's linear output).
private static float L2G( float v ) =>
v <= 0.0031308f ? v * 12.92f : 1.055f * System.MathF.Pow( v, 1f / 2.4f ) - 0.055f;
private static Color Shade( Color c, float f ) => new Color( c.r * f, c.g * f, c.b * f, c.a );
private static void AddArenaEdge( LevelPreviewModel m, LevelDef level, IReadOnlyList<RectF> obstacles, List<PreviewRect> spikeOverlays, Direction side, bool spiked, float W, float H, float t, float s )
{
// Mirror the in-game WallFace: the wall BAND is always the same thickness-t rect (drawn flat in
// the wall colour), and a spiked edge merely ADDS a teeth OVERLAY that extends SPIKE_EXTRA inward.
// The overlay carries only the band-removed teeth art (drawn white), so the coloured band shows
// through beneath it and the gaps between teeth stay transparent. Spikes grow inward.
bool horizontal = side == Direction.Down || side == Direction.Up;
m.Rects.Add( side switch
{
Direction.Down => new PreviewRect( 0f, 0f, W, t, m.Wall ),
Direction.Up => new PreviewRect( 0f, H - t, W, t, m.Wall ),
Direction.Left => new PreviewRect( 0f, 0f, t, H, m.Wall ),
_ => new PreviewRect( W - t, 0f, t, H, m.Wall ), // Right
} );
if ( !spiked ) return;
// One teeth strip per surviving stretch of the side, cut by the obstacles flush against it —
// exactly how GameStage.BuildArenaSide splits the side into independently-spiking faces. An
// obstacle merely NEAR the wall does NOT cut: the game leaves that face whole and its teeth
// overlay the obstacle's body, so the preview's (longer) teeth do the same.
var cuts = FlushArenaSpans( level, obstacles, side );
// An uncut side keeps the classic single full-span strip (the game's baked art spans the corners
// too). A SPLIT side hands its corner squares to the perpendicular bands, like GameStage's
// WALL_SIZE inset: teeth tile by stretching the tooth art across the strip, so a corner sliver
// would otherwise smear one giant tooth over the corner.
float min = cuts.Count == 0 ? 0f : t;
float max = ( horizontal ? W : H ) - min;
foreach ( var seg in SubtractSpans( min, max, cuts ) )
spikeOverlays.Add( ArenaTeethRect( m.Wall, side, seg.Min, seg.Max, W, H, t, s ) );
}
private static PreviewRect ArenaTeethRect( Color wall, Direction side, float min, float max, float W, float H, float t, float s ) => side switch
{
Direction.Down => new PreviewRect( min, 0f, max - min, t + s, wall, PreviewSpike.Up ),
Direction.Up => new PreviewRect( min, H - (t + s), max - min, t + s, wall, PreviewSpike.Down ),
Direction.Left => new PreviewRect( 0f, min, t + s, max - min, wall, PreviewSpike.Right ),
_ => new PreviewRect( W - (t + s), min, t + s, max - min, wall, PreviewSpike.Left ), // Right
};
// The along-spans of the obstacles sitting flush against a boundary side: the stretches where that
// side reads as covered wall rather than spikes (the preview analogue of
// GameStage.FlushObstacleSpans). Vision blockers are excluded there too — they keep the arena band
// (and their fill, see ObstacleFillRect) so they read as separate obstacles.
private static List<Span> FlushArenaSpans( LevelDef level, IReadOnlyList<RectF> obstacles, Direction side )
{
bool horizontal = side == Direction.Down || side == Direction.Up;
var spans = new List<Span>();
foreach ( var r in obstacles )
{
if ( ContainsRect( level?.VisionBlockers, r ) || !FlushWithArenaWall( r, side ) )
continue;
spans.Add( horizontal
? new Span { Min = r.Left, Max = r.Right }
: new Span { Min = r.Bottom, Max = r.Top } );
}
return spans;
}
// An ordinary obstacle's fill runs out to the arena edge on a flush side: in game that side is cut,
// and here the fill painting over the (uncut) edge rect is what reproduces the split look. A VISION
// BLOCKER instead stands ON an intact wall (GameStage paints the band back through its footprint),
// so its fill is clamped INWARD to the band — the exaggerated WALL_VIS one, not the real 2px, or
// the fill would swallow three quarters of the edge and the node would still read as a hole.
private static PreviewRect ObstacleFillRect( LevelDef level, RectF r, Color color )
{
if ( ContainsRect( level?.VisionBlockers, r ) )
{
float l = FlushWithArenaWall( r, Direction.Left ) ? WALL_VIS : r.Left;
float b = FlushWithArenaWall( r, Direction.Down ) ? WALL_VIS : r.Bottom;
float rt = FlushWithArenaWall( r, Direction.Right ) ? Arena.WIDTH - WALL_VIS : r.Right;
float tp = FlushWithArenaWall( r, Direction.Up ) ? Arena.HEIGHT - WALL_VIS : r.Top;
// A blocker thinner than the exaggerated band would invert; collapse instead of flipping.
return new PreviewRect( l, b, Math.Max( rt - l, 0f ), Math.Max( tp - b, 0f ), color );
}
float left = FlushWithArenaWall( r, Direction.Left ) ? 0f : r.Left;
float bottom = FlushWithArenaWall( r, Direction.Down ) ? 0f : r.Bottom;
float right = FlushWithArenaWall( r, Direction.Right ) ? Arena.WIDTH : r.Right;
float top = FlushWithArenaWall( r, Direction.Up ) ? Arena.HEIGHT : r.Top;
return new PreviewRect( left, bottom, right - left, top - bottom, color );
}
private static bool ContainsRect( IReadOnlyList<RectF> rects, RectF rect )
{
if ( rects is null ) return false;
foreach ( var candidate in rects )
if ( candidate.Left == rect.Left && candidate.Bottom == rect.Bottom
&& candidate.Right == rect.Right && candidate.Top == rect.Top )
return true;
return false;
}
/// <summary>One surviving obstacle face segment, recorded so the elbow-patch pass can probe band
/// coverage after ALL bands exist (the preview analogue of GameStage's WallFace list).</summary>
private struct FaceSeg { public Direction Normal; public float Edge, Min, Max; }
private static void AddObstacleSide( LevelPreviewModel m, List<FaceSeg> faces, List<PreviewRect> spikeOverlays, IReadOnlyList<RectF> obstacles, RectF r, Direction normal, bool spiked, float t, float s )
{
bool horizontal = normal == Direction.Up || normal == Direction.Down;
float alongStart = horizontal ? r.Left : r.Bottom;
float alongEnd = horizontal ? r.Right : r.Top;
float edge = normal switch
{
Direction.Left => r.Left,
Direction.Right => r.Right,
Direction.Down => r.Bottom,
_ => r.Top,
};
// The grey band sits INSIDE the body; spikes (if any) grow outward from the face.
foreach ( var seg in SubtractSpans( alongStart, alongEnd, FlushNeighbourSpans( obstacles, r, normal ) ) )
{
AddFaceBand( m, spikeOverlays, r, normal, seg.Min, seg.Max, spiked, m.Wall, t, s );
faces.Add( new FaceSeg { Normal = normal, Edge = edge, Min = seg.Min, Max = seg.Max } );
}
}
// Mirror the in-game WallFace: the wall BAND is always the same thickness-t rect (drawn flat in the
// wall colour), and a spiked face merely ADDS a teeth OVERLAY extending SPIKE_EXTRA outward. The
// overlay carries only the band-removed teeth art (drawn white), so the coloured band shows through
// beneath it and the gaps between teeth stay transparent.
private static void AddFaceBand( LevelPreviewModel m, List<PreviewRect> spikeOverlays, RectF r, Direction normal, float alongMin, float alongMax, bool spiked, Color wall, float t, float s )
{
float len = alongMax - alongMin;
PreviewRect band, teeth;
switch ( normal )
{
case Direction.Left: // body to the right; spikes point left
band = new PreviewRect( r.Left, alongMin, t, len, wall );
teeth = new PreviewRect( r.Left - s, alongMin, t + s, len, wall, PreviewSpike.Left );
break;
case Direction.Right: // body to the left; spikes point right
band = new PreviewRect( r.Right - t, alongMin, t, len, wall );
teeth = new PreviewRect( r.Right - t, alongMin, t + s, len, wall, PreviewSpike.Right );
break;
case Direction.Down: // body above; spikes point down
band = new PreviewRect( alongMin, r.Bottom, len, t, wall );
teeth = new PreviewRect( alongMin, r.Bottom - s, len, t + s, wall, PreviewSpike.Down );
break;
default: // Up: body below; spikes point up
band = new PreviewRect( alongMin, r.Top - t, len, t, wall );
teeth = new PreviewRect( alongMin, r.Top - t, len, t + s, wall, PreviewSpike.Up );
break;
}
m.Rects.Add( band );
if ( spiked ) spikeOverlays.Add( teeth );
}
// Elbow patches (WALL_VIS squares), mirroring GameStage.AddObstacleElbowPatches: at each face
// end, if the band-strip square just past it lies inside an obstacle body and no face's band
// covers it, the fill would poke through the corner notch there — plug it. Probing coverage
// (rather than patching only cut-created segment ends) also catches the staircase corner three
// flush rects make, where both meeting faces run their full sides and the notch belongs to a
// third rect with no faces of its own at that corner.
private static void AddElbowPatches( LevelPreviewModel m, IReadOnlyList<RectF> obstacles, List<FaceSeg> faces, float t )
{
var done = new HashSet<Vector2>(); // the two faces meeting at an L both probe their shared notch
foreach ( var f in faces )
{
bool horizontal = f.Normal == Direction.Up || f.Normal == Direction.Down;
// The band's centre line sits half a band inside the body.
float perpInside = f.Normal == Direction.Left || f.Normal == Direction.Down
? f.Edge + t / 2f
: f.Edge - t / 2f;
TryElbowPatch( m, obstacles, faces, horizontal, f.Min - t / 2f, perpInside, t, done );
TryElbowPatch( m, obstacles, faces, horizontal, f.Max + t / 2f, perpInside, t, done );
}
}
private static void TryElbowPatch( LevelPreviewModel m, IReadOnlyList<RectF> obstacles, List<FaceSeg> faces, bool horizontal, float alongCenter, float perpCenter, float t, HashSet<Vector2> done )
{
var c = horizontal ? new Vector2( alongCenter, perpCenter ) : new Vector2( perpCenter, alongCenter );
if ( !InsideAnyObstacle( obstacles, c ) || CoveredByFaceBand( faces, c, t ) )
return;
if ( done.Add( c ) )
m.Rects.Add( new PreviewRect( c.x - t / 2f, c.y - t / 2f, t, t, m.Wall ) );
}
private static bool InsideAnyObstacle( IReadOnlyList<RectF> obstacles, Vector2 pos )
{
foreach ( var r in obstacles )
if ( pos.x > r.Left && pos.x < r.Right && pos.y > r.Bottom && pos.y < r.Top )
return true;
return false;
}
// Does any face's grey band strip (the t-deep solid part just inside its owner's edge) cover
// this point? (Spikes extend outward and never matter here.)
private static bool CoveredByFaceBand( List<FaceSeg> faces, Vector2 pos, float t )
{
foreach ( var f in faces )
{
if ( f.Normal == Direction.Up || f.Normal == Direction.Down )
{
float inner = f.Normal == Direction.Up ? f.Edge - t : f.Edge;
if ( pos.x >= f.Min && pos.x <= f.Max && pos.y >= inner && pos.y <= inner + t )
return true;
}
else
{
float inner = f.Normal == Direction.Right ? f.Edge - t : f.Edge;
if ( pos.y >= f.Min && pos.y <= f.Max && pos.x >= inner && pos.x <= inner + t )
return true;
}
}
return false;
}
// ---- shared preview dressing -------------------------------------------------------------------
// The decorative layout AROUND the wall schematic (drifting-block squares + the blocks on their
// spawn slots) generated in ONE place, consumed by both renderers: LevelNodePreview draws it as
// animated CSS panels, WorkshopThumbnail rasterises the identical arrangement into the baked
// Steam-page PNG. Sharing the generation (including its PRNG draw ORDER) is what keeps the two
// from drifting apart — a consumer that doesn't need a drawn value must still let it be drawn.
/// <summary>One decorative drifting-block square, in logical arena space (origin bottom-left).</summary>
public readonly struct PreviewBgBlock
{
public readonly Vector2 Pos;
public readonly float Size;
public readonly int Depth;
public readonly Color Color;
/// <summary>Drift velocity (logical units/second). Animated consumers move the square; static
/// ones ignore it — it is drawn from the PRNG either way, keeping the layouts identical.</summary>
public readonly Vector2 Vel;
public PreviewBgBlock( Vector2 pos, float size, int depth, Color color, Vector2 vel )
{
Pos = pos; Size = size; Depth = depth; Color = color; Vel = vel;
}
}
/// <summary>Deterministic drifting-block layout for a level's preview: count, sizes, depths,
/// positions, colours and drift velocities, seeded by the level id (stable across runs), sorted
/// deepest first so lighter, closer squares paint on top.</summary>
public static List<PreviewBgBlock> BuildBgBlocks( LevelDef level, LevelPreviewModel model )
{
var result = new List<PreviewBgBlock>();
var cols = model.BgBlockColors;
if ( cols is null || cols.Count == 0 )
return result;
// Deterministic PRNG seeded by the level Id (stable across runs).
uint state = ((uint)(level?.Id?.GetHashCode() ?? 0)) ^ 0x9E3779B9u;
float Next() // xorshift -> [0,1)
{
state ^= state << 13; state ^= state >> 17; state ^= state << 5;
return (state & 0xFFFFFF) / (float)0x1000000;
}
// Guarantee at least one block of EACH depth (the 3 palette shades) so every preview shows the
// full set, then a few random extras on top.
int baseCount = cols.Count + 2 + (int)(Next() * 4f); // cols.Count + (2..5)
int count = Math.Max( cols.Count, (int)MathF.Round( baseCount * model.BgBlockDensity ) );
for ( int i = 0; i < count; i++ )
{
// Base size (20..52), with an occasional larger block mixed in (up to ~86).
float size = (20f + Next() * 32f) * model.BgBlockScale;
if ( Next() < 0.4f )
size += Next() * 34f * model.BgBlockScale;
// The first cols.Count blocks each take a distinct depth so all shades appear; rest random.
int depth = i < cols.Count ? i : (int)(Next() * cols.Count);
if ( !TryBgBlockPosition( size, depth, result, Next, out var pos ) )
continue;
// Simple cardinal drift: move purely along ONE axis, random direction + gentle speed.
float speed = (14f + Next() * 20f) * model.BgDriftSpeed;
Direction direction = BgDriftDirection( model.BgDriftBias, Next );
Vector2 vel = Globals.GetVectorForDirection( direction ) * speed;
Color color = cols[depth % cols.Count];
result.Add( new PreviewBgBlock( pos, size, depth,
color.WithAlpha( color.a * model.BgBlockOpacity ), vel ) );
}
result.Sort( ( a, b ) => b.Depth.CompareTo( a.Depth ) );
return result;
}
private static bool TryBgBlockPosition( float size, int depth, IReadOnlyList<PreviewBgBlock> blocks, Func<float> next, out Vector2 position )
{
const float clearance = 3f;
for ( int attempt = 0; attempt < 96; attempt++ )
{
var candidate = new Vector2( next() * (Arena.WIDTH - size), next() * (Arena.HEIGHT - size) );
bool clear = true;
foreach ( var block in blocks )
{
if ( block.Depth != depth ) continue;
if ( candidate.x < block.Pos.x + block.Size + clearance && candidate.x + size + clearance > block.Pos.x
&& candidate.y < block.Pos.y + block.Size + clearance && candidate.y + size + clearance > block.Pos.y )
{
clear = false;
break;
}
}
if ( clear ) { position = candidate; return true; }
}
position = default;
return false;
}
private static Direction BgDriftDirection( BackgroundDriftBias bias, Func<float> next )
{
Direction RandomDirection() => (Direction)(1 + (int)(next() * 4f));
if ( bias == BackgroundDriftBias.None || next() >= 0.8f ) return RandomDirection();
if ( bias == BackgroundDriftBias.Horizontal ) return next() < 0.5f ? Direction.Left : Direction.Right;
if ( bias == BackgroundDriftBias.Vertical ) return next() < 0.5f ? Direction.Down : Direction.Up;
return bias switch
{
BackgroundDriftBias.Left => Direction.Left,
BackgroundDriftBias.Right => Direction.Right,
BackgroundDriftBias.Down => Direction.Down,
BackgroundDriftBias.Up => Direction.Up,
_ => RandomDirection(),
};
}
/// <summary>The blocks a preview shows on their spawn slots. Exact levels show their authored set;
/// pool levels show a deterministic sample of the pool rather than consuming gameplay RNG just to
/// draw a thumbnail. Pinned blocks always draw at their own slot; the random set fills the
/// remaining free slots in listed order.</summary>
public static List<(BlockType Type, Vector2 Pos)> BuildSpawnBlocks( LevelDef level )
{
var result = new List<(BlockType, Vector2)>();
if ( level is null )
return result;
var spawns = level.SpawnPositions;
var pinnedSlots = new HashSet<int>();
if ( level.SpawnPins is not null && spawns is { Count: > 0 } )
{
for ( int i = 0; i < level.SpawnPins.Count && i < spawns.Count; i++ )
{
if ( level.SpawnPins[i] is not PinnedBlock pin ) continue;
pinnedSlots.Add( i );
result.Add( (pin.Type, spawns[i]) );
}
}
int randomCount = level.Blocks?.Count ?? Math.Clamp( level.BlockCount, 0, level.Pool?.Count ?? 0 );
int slot = 0;
for ( int i = 0; i < randomCount; i++ )
{
BlockType type;
if ( level.Blocks is { Count: > 0 } exact ) type = exact[i % exact.Count];
else if ( level.Pool is { Count: > 0 } pool ) type = pool[i % pool.Count];
else continue;
Vector2 position;
if ( spawns is { Count: > 0 } )
{
// Advance past pinned slots so random blocks only occupy free ones.
while ( slot < spawns.Count && pinnedSlots.Contains( slot ) ) slot++;
position = slot < spawns.Count ? spawns[slot++] : FallbackBlockPosition( i, randomCount );
}
else
{
position = FallbackBlockPosition( i, randomCount );
}
result.Add( (type, position) );
}
return result;
}
private static Vector2 FallbackBlockPosition( int index, int count )
{
int columns = Math.Min( count, 3 );
int rows = (count + columns - 1) / columns;
int column = index % columns;
int row = index / columns;
return new Vector2(
Arena.WIDTH * (column + 1f) / (columns + 1f),
Arena.HEIGHT * (rows - row) / (rows + 1f) );
}
/// <summary>Preview art for a block type (extensionless name under <c>sprites/ui/blocks</c>).</summary>
public static string BlockPreviewImage( BlockType type ) =>
type == BlockType.Mimic ? "mimic_preview" : $"{type.ToString().ToLowerInvariant()}_p1";
/// <summary>Fill colour a preview block shows while (or in place of) its sprite art.</summary>
public static Color BlockFallbackColor( BlockType type ) =>
BlockFallbackColors[(int)type % BlockFallbackColors.Length];
private static readonly Color[] BlockFallbackColors =
{
new Color( 0.95f, 0.78f, 0.25f ),
new Color( 0.30f, 0.78f, 0.92f ),
new Color( 0.92f, 0.38f, 0.42f ),
new Color( 0.48f, 0.84f, 0.42f ),
new Color( 0.78f, 0.52f, 0.92f ),
};
// ---- geometry helpers (ported from GameStage) -------------------------------------------------
private const float FLUSH_EPS = 0.5f;
private static bool FlushWithArenaWall( RectF r, Direction side ) => side switch
{
Direction.Down => r.Bottom <= Arena.WALL_SIZE + FLUSH_EPS,
Direction.Up => r.Top >= Arena.HEIGHT - Arena.WALL_SIZE - FLUSH_EPS,
Direction.Left => r.Left <= Arena.WALL_SIZE + FLUSH_EPS,
_ => r.Right >= Arena.WIDTH - Arena.WALL_SIZE - FLUSH_EPS,
};
private static List<Span> FlushNeighbourSpans( IReadOnlyList<RectF> obstacles, RectF rect, Direction normal )
{
const float EPS = FLUSH_EPS;
var spans = new List<Span>();
foreach ( var q in obstacles )
{
if ( q.Left == rect.Left && q.Bottom == rect.Bottom && q.Right == rect.Right && q.Top == rect.Top )
continue; // self
bool flush;
float a, b;
switch ( normal )
{
case Direction.Left: flush = q.Right > rect.Left - EPS && q.Right < rect.Left + EPS; a = q.Bottom; b = q.Top; break;
case Direction.Right: flush = q.Left > rect.Right - EPS && q.Left < rect.Right + EPS; a = q.Bottom; b = q.Top; break;
case Direction.Down: flush = q.Top > rect.Bottom - EPS && q.Top < rect.Bottom + EPS; a = q.Left; b = q.Right; break;
default: flush = q.Bottom > rect.Top - EPS && q.Bottom < rect.Top + EPS; a = q.Left; b = q.Right; break;
}
if ( flush )
spans.Add( new Span { Min = a, Max = b } );
}
return spans;
}
// [min,max] minus the cut intervals -> the surviving segments. Segments no longer than FLUSH_EPS are
// DROPPED, like GameStage.SubtractSpans: flushness is EPS-tolerant, so a fractionally-offset rect can
// leave a sub-EPS sliver, and a strip that short stretches its art into an artifact (a lone giant
// tooth, since the teeth tile scales to the strip's span).
private static List<Span> SubtractSpans( float min, float max, List<Span> cuts )
{
cuts.Sort( ( p, q ) => p.Min.CompareTo( q.Min ) );
var result = new List<Span>();
float cursor = min;
foreach ( var c in cuts )
{
float s = c.Min < min ? min : (c.Min > max ? max : c.Min);
float e = c.Max < min ? min : (c.Max > max ? max : c.Max);
if ( e <= cursor ) continue;
if ( s > cursor + FLUSH_EPS ) result.Add( new Span { Min = cursor, Max = s } );
cursor = e > cursor ? e : cursor;
}
if ( cursor < max - FLUSH_EPS ) result.Add( new Span { Min = cursor, Max = max } );
return result;
}
private static bool ObstacleSideSpiked( LevelDef level, RectF rect, Direction normal )
{
if ( level?.SpikedObstacleSides is null )
return false;
foreach ( var spec in level.SpikedObstacleSides )
{
if ( spec.Rect.Left != rect.Left || spec.Rect.Bottom != rect.Bottom
|| spec.Rect.Right != rect.Right || spec.Rect.Top != rect.Top )
continue;
if ( Contains( spec.Sides, normal ) )
return true;
}
return false;
}
private static bool Contains( IReadOnlyList<Direction> list, Direction d )
{
if ( list is null ) return false;
for ( int i = 0; i < list.Count; i++ )
if ( list[i] == d ) return true;
return false;
}
// ---- fences (solid only to blocks; open frame + bars in the level's fence colour) --------------
private static void AddFence( LevelPreviewModel m, IReadOnlyList<RectF> fences, RectF r, float t, Color border, Color barColor, List<FaceSeg> frames )
{
float f = t / 2f; // frame thickness: half a preview wall band — lighter than a wall
float bar = f / 2f; // bar width
const float spacing = 12f; // bar rhythm, world-aligned so bars continue across flush fences
// Bars first, edge-to-edge (the frame rects paint over their ends — later rects draw on
// top), so where a flush fence cuts the frame away (a seam) the bars run straight through
// without breaking rhythm — mirroring GameStage.AddFence, including its FENCE_BAR_ALPHA.
for ( float x = System.MathF.Ceiling( r.Left / spacing ) * spacing; x <= r.Right - bar; x += spacing )
m.Rects.Add( new PreviewRect( x, r.Bottom, bar, r.Height, barColor.WithAlpha( 0.5f ) ) );
foreach ( var normal in new[] { Direction.Left, Direction.Right, Direction.Down, Direction.Up } )
{
bool horizontal = normal == Direction.Up || normal == Direction.Down;
float alongStart = horizontal ? r.Left : r.Bottom;
float alongEnd = horizontal ? r.Right : r.Top;
float edge = normal switch
{
Direction.Left => r.Left,
Direction.Right => r.Right,
Direction.Down => r.Bottom,
_ => r.Top,
};
foreach ( var seg in SubtractSpans( alongStart, alongEnd, FlushKinSpans( fences, r, normal ) ) )
{
m.Rects.Add( normal switch
{
Direction.Down => new PreviewRect( seg.Min, r.Bottom, seg.Max - seg.Min, f, border ),
Direction.Up => new PreviewRect( seg.Min, r.Top - f, seg.Max - seg.Min, f, border ),
Direction.Left => new PreviewRect( r.Left, seg.Min, f, seg.Max - seg.Min, border ),
_ => new PreviewRect( r.Right - f, seg.Min, f, seg.Max - seg.Min, border ),
} );
frames.Add( new FaceSeg { Normal = normal, Edge = edge, Min = seg.Min, Max = seg.Max } );
}
}
}
// ---- glass (solid only to players; translucent pane + solid border + tinted spike teeth) -------
private static void AddGlass( LevelPreviewModel m, List<PreviewRect> spikeOverlays, LevelDef level, IReadOnlyList<RectF> glass, RectF r, float t, float s, Color color, List<FaceSeg> borders )
{
float f = t / 2f; // border thickness: half a preview wall band, like the fence frame
// Pane tint only (same alpha as the game's pane) — the node schematic is too small for the
// in-game diagonal reflection streaks, and its rects are axis-aligned CSS boxes anyway.
m.Rects.Add( new PreviewRect( r.Left, r.Bottom, r.Width, r.Height, color.WithAlpha( 0.30f ) ) );
Color tint = LevelCosmeticsRandomizer.GlassTeethTint( color );
foreach ( var normal in new[] { Direction.Left, Direction.Right, Direction.Down, Direction.Up } )
{
bool horizontal = normal == Direction.Up || normal == Direction.Down;
float alongStart = horizontal ? r.Left : r.Bottom;
float alongEnd = horizontal ? r.Right : r.Top;
// Authored spikes: TINTED teeth overlays, split around flush glass neighbours; a side
// flush with an arena wall is skipped exactly like an obstacle's (buried face).
bool spiked = !FlushWithArenaWall( r, normal ) && ObstacleSideSpiked( level, r, normal );
float edge = normal switch
{
Direction.Left => r.Left,
Direction.Right => r.Right,
Direction.Down => r.Bottom,
_ => r.Top,
};
foreach ( var seg in SubtractSpans( alongStart, alongEnd, FlushKinSpans( glass, r, normal ) ) )
{
float len = seg.Max - seg.Min;
m.Rects.Add( normal switch
{
Direction.Down => new PreviewRect( seg.Min, r.Bottom, len, f, color ),
Direction.Up => new PreviewRect( seg.Min, r.Top - f, len, f, color ),
Direction.Left => new PreviewRect( r.Left, seg.Min, f, len, color ),
_ => new PreviewRect( r.Right - f, seg.Min, f, len, color ),
} );
borders.Add( new FaceSeg { Normal = normal, Edge = edge, Min = seg.Min, Max = seg.Max } );
if ( !spiked ) continue;
spikeOverlays.Add( normal switch
{
Direction.Down => new PreviewRect( seg.Min, r.Bottom - s, len, t + s, color, PreviewSpike.Down, tint ),
Direction.Up => new PreviewRect( seg.Min, r.Top - t, len, t + s, color, PreviewSpike.Up, tint ),
Direction.Left => new PreviewRect( r.Left - s, seg.Min, t + s, len, color, PreviewSpike.Left, tint ),
_ => new PreviewRect( r.Right - t, seg.Min, t + s, len, color, PreviewSpike.Right, tint ),
} );
}
}
}
// Fence-frame / glass-border elbow patches, mirroring GameStage.AddFrameElbowPatches: the strips
// are seam-cut around flush same-kind panels, so at a composite's inner corner two perpendicular
// strips only corner-touch, leaving a frame-sized notch in the outline (in the staircase case the
// notch belongs to a THIRD rect with no strips of its own there). Probe the square just past each
// strip end: patch iff it lies inside some same-kind rect and no strip covers it (a collinear
// continuation across a seam does; open space past a true outer corner is outside every rect).
private static void AddFrameElbowPatches( LevelPreviewModel m, IReadOnlyList<RectF> kin, List<FaceSeg> strips, float f, Color border )
{
var done = new HashSet<Vector2>(); // the two strips meeting at an L both probe their shared notch
foreach ( var seg in strips )
{
bool horizontal = seg.Normal == Direction.Up || seg.Normal == Direction.Down;
// The strip's centre line sits half a frame inside the edge.
float perpInside = seg.Normal == Direction.Left || seg.Normal == Direction.Down
? seg.Edge + f / 2f
: seg.Edge - f / 2f;
TryFrameElbowPatch( m, kin, strips, horizontal, seg.Min - f / 2f, perpInside, f, border, done );
TryFrameElbowPatch( m, kin, strips, horizontal, seg.Max + f / 2f, perpInside, f, border, done );
}
}
private static void TryFrameElbowPatch( LevelPreviewModel m, IReadOnlyList<RectF> kin, List<FaceSeg> strips, bool horizontal, float alongCenter, float perpCenter, float f, Color border, HashSet<Vector2> done )
{
var c = horizontal ? new Vector2( alongCenter, perpCenter ) : new Vector2( perpCenter, alongCenter );
if ( !InsideAnyObstacle( kin, c ) || CoveredByFaceBand( strips, c, f ) )
return;
if ( done.Add( c ) )
m.Rects.Add( new PreviewRect( c.x - f / 2f, c.y - f / 2f, f, f, border ) );
}
// The along-spans of OTHER rects of the same kind (fences or glass — each only combines with its
// own kind) sitting flush against this side (the preview analogue of GameStage.FlushKinNeighbourSpans).
private static List<Span> FlushKinSpans( IReadOnlyList<RectF> fences, RectF rect, Direction normal )
{
const float EPS = FLUSH_EPS;
var spans = new List<Span>();
foreach ( var q in fences )
{
if ( q.Left == rect.Left && q.Bottom == rect.Bottom && q.Right == rect.Right && q.Top == rect.Top )
continue; // self
bool flush;
float a, b;
switch ( normal )
{
case Direction.Left: flush = q.Right > rect.Left - EPS && q.Right < rect.Left + EPS; a = q.Bottom; b = q.Top; break;
case Direction.Right: flush = q.Left > rect.Right - EPS && q.Left < rect.Right + EPS; a = q.Bottom; b = q.Top; break;
case Direction.Down: flush = q.Top > rect.Bottom - EPS && q.Top < rect.Bottom + EPS; a = q.Left; b = q.Right; break;
default: flush = q.Bottom > rect.Top - EPS && q.Bottom < rect.Top + EPS; a = q.Left; b = q.Right; break;
}
if ( flush )
spans.Add( new Span { Min = a, Max = b } );
}
return spans;
}
// ---- line-of-sight cover (static; mirrors VisionOccluder, cast from the player spawn) ----------
// The umbra is a polygon, so it can't be a single axis-aligned PreviewRect; it's rasterised into a
// stack of thin horizontal strips instead. Computed once (in Build), never per frame.
private const float UMBRA_EXTRUDE = 2000f; // extrude far past the arena; the result is arena-clipped
private const float UMBRA_STRIP = 3f; // scanline strip height (logical units)
/// <summary>Add the static vision cover: the umbra behind each vision-flagged obstacle rect, cast
/// from the player spawn and filled in the wall colour. Excludes the dynamic BlockShade block (only
/// static, level-authored blockers).</summary>
private static void AddVisionCover( LevelPreviewModel m, LevelDef level )
{
if ( level is null ) return;
// Multi-spawn levels cast the schematic's shadow from the FIRST candidate (which one the run
// picks isn't known until stage enter; the preview just needs a representative light source).
Vector2 spawn = level.PlayerSpawns is { Count: > 0 } spawns ? spawns[0] : new Vector2( 16, 40 );
if ( level.VisionBlockers is not null )
{
// Vision blockers are solid obstacle rects; the game insets the caster ~1.5px so the umbra
// base starts inside the spike band. The preview's walls/spikes are EXAGGERATED (WALL_VIS vs
// real WALL_SIZE), so scale the inset by the same factor or the enlarged far-face spikes poke
// out below the shadow.
float inset = 1.5f * ( WALL_VIS / Arena.WALL_SIZE );
foreach ( var r in level.VisionBlockers )
AddUmbra( m, new RectF( r.Left + inset, r.Bottom + inset, r.Right - inset, r.Top - inset ), spawn );
}
}
private static void AddUmbra( LevelPreviewModel m, RectF r, Vector2 light )
{
// Player inside the blocker: no meaningful shadow.
if ( light.x > r.Left && light.x < r.Right && light.y > r.Bottom && light.y < r.Top )
return;
// One shadow quad per BACK-FACING edge, extruded away from the light (mirrors
// VisionOccluder.AppendBlockerShadow). Their union is the umbra and — unlike a convex hull of the
// edge points, which would bridge the box's corner and cover HALF the box — no quad ever covers
// the blocker itself (each lies entirely on the outward side of its edge). Strips from different
// quads may overlap, which is harmless (same colour).
var corners = new[] { r.BottomLeft, r.BottomRight, r.TopRight, r.TopLeft };
var quad = new List<Vector2>( 4 );
var clipped = new List<Vector2>();
for ( int i = 0; i < 4; i++ )
{
Vector2 a = corners[i], b = corners[( i + 1 ) % 4];
Vector2 edge = b - a;
Vector2 outward = new Vector2( edge.y, -edge.x );
Vector2 mid = ( a + b ) * 0.5f;
if ( Vector2.Dot( outward, mid - light ) <= 0f )
continue; // front-facing edge casts no umbra
quad.Clear();
quad.Add( a );
quad.Add( b );
quad.Add( ExtrudeUmbra( b, light ) );
quad.Add( ExtrudeUmbra( a, light ) );
ClipToArenaPoly( quad, clipped );
RasterizeConvex( m.Rects, clipped, m.Wall );
}
}
private static Vector2 ExtrudeUmbra( Vector2 p, Vector2 light )
{
Vector2 dir = p - light;
float len = dir.Length;
if ( len < 0.0001f ) return p;
return p + dir / len * UMBRA_EXTRUDE;
}
// Rasterise a convex polygon into horizontal strips: one PreviewRect per scanline row (2
// intersections per row for a convex polygon give the row's x-span).
private static void RasterizeConvex( List<PreviewRect> outRects, List<Vector2> poly, Color color )
{
if ( poly.Count < 3 ) return;
float yMin = float.PositiveInfinity, yMax = float.NegativeInfinity;
foreach ( var p in poly ) { if ( p.y < yMin ) yMin = p.y; if ( p.y > yMax ) yMax = p.y; }
yMin = System.MathF.Max( yMin, 0f );
yMax = System.MathF.Min( yMax, Arena.HEIGHT );
int n = poly.Count;
for ( float y = yMin; y < yMax; y += UMBRA_STRIP )
{
float rowY = y + UMBRA_STRIP * 0.5f;
float xMin = float.PositiveInfinity, xMax = float.NegativeInfinity;
for ( int i = 0; i < n; i++ )
{
Vector2 a = poly[i], b = poly[( i + 1 ) % n];
if ( ( a.y <= rowY ) == ( b.y <= rowY ) )
continue; // edge doesn't straddle this row
float x = a.x + ( b.x - a.x ) * ( ( rowY - a.y ) / ( b.y - a.y ) );
if ( x < xMin ) xMin = x;
if ( x > xMax ) xMax = x;
}
if ( xMax > xMin )
{
float h = System.MathF.Min( UMBRA_STRIP, yMax - y );
outRects.Add( new PreviewRect( xMin, y, xMax - xMin, h, color ) );
}
}
}
// Sutherland–Hodgman clip of a convex polygon to the arena rect [0,W]x[0,H].
private static void ClipToArenaPoly( List<Vector2> input, List<Vector2> result )
{
var a = new List<Vector2>( input );
var b = new List<Vector2>();
ClipArenaEdge( a, b, 0 );
ClipArenaEdge( b, a, 1 );
ClipArenaEdge( a, b, 2 );
ClipArenaEdge( b, a, 3 );
result.Clear();
result.AddRange( a );
}
private static void ClipArenaEdge( List<Vector2> src, List<Vector2> dst, int edge )
{
dst.Clear();
int n = src.Count;
if ( n == 0 ) return;
Vector2 prev = src[n - 1];
bool prevIn = ArenaInside( prev, edge );
for ( int i = 0; i < n; i++ )
{
Vector2 cur = src[i];
bool curIn = ArenaInside( cur, edge );
if ( curIn )
{
if ( !prevIn ) dst.Add( ArenaIntersect( prev, cur, edge ) );
dst.Add( cur );
}
else if ( prevIn )
{
dst.Add( ArenaIntersect( prev, cur, edge ) );
}
prev = cur; prevIn = curIn;
}
}
private static bool ArenaInside( Vector2 p, int edge ) => edge switch
{
0 => p.x >= 0f,
1 => p.x <= Arena.WIDTH,
2 => p.y >= 0f,
_ => p.y <= Arena.HEIGHT,
};
private static Vector2 ArenaIntersect( Vector2 a, Vector2 b, int edge )
{
float t = edge switch
{
0 => ( 0f - a.x ) / ( b.x - a.x ),
1 => ( Arena.WIDTH - a.x ) / ( b.x - a.x ),
2 => ( 0f - a.y ) / ( b.y - a.y ),
_ => ( Arena.HEIGHT - a.y ) / ( b.y - a.y ),
};
return a + ( b - a ) * t;
}
}