Game/LevelCosmeticsRandomizer.cs
using System;
using System.Collections.Generic;
using System.Linq;
namespace BlockParty;
/// <summary>
/// Seedable level-cosmetics randomization shared by the level editor's palette/pattern shuffle
/// buttons and the daily-challenge level generator. All draws come from the caller's
/// <see cref="Random"/>; the DRAW ORDER inside each roll method is load-bearing for the daily
/// generator (a reorder changes every generated day) — append new draws at the end only.
/// <see cref="RollPattern"/> is the one roll whose draw COUNT varies (generated Custom tiles draw
/// their own shape parameters), so the daily generator rolls it last of all.
/// </summary>
public static class LevelCosmeticsRandomizer
{
public static readonly Color DefaultWallColor = new( 58f / 255f, 64f / 255f, 76f / 255f );
public static readonly Color DefaultOutOfBoundsColor = new( 0f, 32f / 255f, 40f / 255f );
public static readonly Color DefaultCheckerboardColor = new( 0f, 189f / 255f, 196f / 255f );
public static readonly Color DefaultBackgroundBlockColor = new( 0f, 202f / 255f, 206f / 255f );
/// <summary>Default fence BORDER colour (warm amber). A level authors ONE fence colour; the bars
/// derive from it via <see cref="FenceBarColor"/>.</summary>
public static readonly Color DefaultFenceColor = new( 166f / 255f, 120f / 255f, 52f / 255f );
/// <summary>Default GLASS colour (lavender). A level authors ONE glass colour; the translucent
/// pane, reflection streaks, border and spike-teeth tint all derive from it.</summary>
public static readonly Color DefaultGlassColor = new( 200f / 255f, 148f / 255f, 236f / 255f );
/// <summary>Wall/OOB scheme odds for <see cref="RollColors"/>: chance of the classic muted grey
/// wall, and chance of a vivid COMPLEMENTARY-hue wall. The remainder is the vivid analogous-hue
/// scheme. Tuning these does not disturb the daily generator's draw alignment (the scheme roll's
/// draw count is constant), it only changes which scheme a given day's roll lands on.</summary>
public static float ClassicWallSchemeChance { get; set; } = 0.45f;
public static float ComplementaryWallSchemeChance { get; set; } = 0.25f;
/// <summary>Chance the alternate-playfield region takes a COMPLEMENTARY hue (steered clear of the
/// fence's complement) instead of the subtle darker near-analogous shade. Constant draw count.</summary>
public static float ComplementaryAltPlayfieldChance { get; set; } = 0.35f;
/// <summary>When the alternate region DID take a complementary hue: chance the three drifting
/// background-block layers split across the two playfields instead of the usual stepped shades —
/// one layer in one of the main pattern's two shades, one in one of the alternate's, one blended
/// between them.
/// Constant draw count.</summary>
public static float AltBackgroundSplitChance { get; set; } = 0.30f;
/// <summary>Custom-tile odds for <see cref="RollPattern"/>. With the alternate playfield on:
/// <see cref="CustomPatternChance"/> of rolls put a generated Custom tile in exactly ONE of the two
/// slots (main or alternate, 50/50) and <see cref="BothCustomPatternChance"/> in both; the rest use
/// built-in presets only. Without it, <see cref="SoloCustomPatternChance"/> is the main slot's odds.</summary>
public static float CustomPatternChance { get; set; } = 0.30f;
public static float BothCustomPatternChance { get; set; } = 0.07f;
public static float SoloCustomPatternChance { get; set; } = 0.25f;
/// <summary>KNOB: how much a palette roll's colourfulness may vary from roll to roll. Each roll
/// draws the playfield's OKLab chroma from a band centred on 0.075 (the measured average of the
/// old teal/light-blue rolls — the calm look); this scales the band's WIDTH around that centre,
/// so the centre "calmness" never moves, only how far individual rolls may stray toward
/// more-muted or more-colourful. The background blocks follow automatically (they roll as a
/// delta on top of the playfield chroma). 1 = the shipped band, ±0.020 → chroma 0.055–0.095
/// (slightly wider than the original teal rolls' 0.060–0.090); 0 = every roll equally calm;
/// 2 = 0.035–0.115, noticeably muted-to-vivid. Safe to tweak freely: it never changes the draw
/// count (daily-generator layouts are unaffected — generated days only recolour), and
/// out-of-gamut extremes just clamp back to the calmer boundary colour.</summary>
public static float PaletteChromaSpread { get; set; } = 1f;
public static readonly (PlayfieldPattern Pattern, int MinCellScale, int MaxCellScale, float MinBgScale, float MaxBgScale, float MinDensity, float MaxDensity, float MinDrift, float MaxDrift, BackgroundDriftBias[] Biases, string[] CustomRows)[] PatternPresets =
{
( PlayfieldPattern.Checker, 1, 2, 0.80f, 1.15f, 0.75f, 1.20f, 0.40f, 1.10f, new[] { BackgroundDriftBias.None, BackgroundDriftBias.Horizontal, BackgroundDriftBias.Vertical }, null ),
( PlayfieldPattern.HorizontalStripes, 2, 4, 0.65f, 0.95f, 1.00f, 1.55f, 0.80f, 1.80f, new[] { BackgroundDriftBias.Horizontal, BackgroundDriftBias.Left, BackgroundDriftBias.Right }, null ),
( PlayfieldPattern.VerticalStripes, 2, 4, 0.65f, 0.95f, 1.00f, 1.55f, 0.80f, 1.80f, new[] { BackgroundDriftBias.Vertical, BackgroundDriftBias.Down, BackgroundDriftBias.Up }, null ),
( PlayfieldPattern.Diagonal, 2, 3, 0.75f, 1.10f, 0.85f, 1.30f, 0.90f, 2.10f, new[] { BackgroundDriftBias.None, BackgroundDriftBias.Left, BackgroundDriftBias.Right, BackgroundDriftBias.Down, BackgroundDriftBias.Up }, null ),
( PlayfieldPattern.Dots, 2, 4, 0.90f, 1.35f, 0.55f, 0.95f, 0.40f, 1.20f, new[] { BackgroundDriftBias.None }, null ),
( PlayfieldPattern.Grid, 3, 5, 0.55f, 0.85f, 1.25f, 1.80f, 0.60f, 1.40f, new[] { BackgroundDriftBias.None, BackgroundDriftBias.Horizontal, BackgroundDriftBias.Vertical }, null ),
};
/// <summary>Background-block ranges used when the main slot is a generated Custom tile (Custom
/// tiles have no preset of their own — see <see cref="RollCustomPattern"/>).</summary>
private static readonly (float MinBgScale, float MaxBgScale, float MinDensity, float MaxDensity, float MinDrift, float MaxDrift, BackgroundDriftBias[] Biases) CustomBackgroundPreset =
( 0.70f, 1.10f, 0.80f, 1.30f, 0.50f, 1.60f, (BackgroundDriftBias[])Enum.GetValues( typeof( BackgroundDriftBias ) ) );
/// <summary>Result of one palette roll (the editor's "random colors" button).</summary>
public sealed class ColorRoll
{
/// <summary>The anchor hue (OKLCH degrees) the whole palette was built around — for follow-up
/// cosmetic rolls that want to match or complement the theme (e.g. ambient particles).</summary>
public float Hue;
public Color WallColor;
public Color OutOfBoundsColor;
public Color CheckerboardColor;
public Color? AlternateCheckerboardColor;
/// <summary>The alternate region took a complementary hue (see <see cref="ComplementaryAltPlayfieldChance"/>).</summary>
public bool AlternateHueComplementary;
public Color BackgroundBlockColor;
public List<Color> BackgroundBlockColors;
/// <summary><see cref="BackgroundBlockColors"/> is the main/between/alternate split (see <see cref="AltBackgroundSplitChance"/>).</summary>
public bool BackgroundBlockSplit;
public Color FenceColor;
public Color GlassColor;
public void ApplyTo( EditorLevel level )
{
level.WallColor = WallColor;
level.OutOfBoundsColor = OutOfBoundsColor;
level.CheckerboardColor = CheckerboardColor;
level.CheckerboardSecondColor = null;
if ( AlternateCheckerboardColor.HasValue )
level.AlternateCheckerboardColor = AlternateCheckerboardColor;
level.AlternateCheckerboardSecondColor = null;
level.BackgroundBlockColor = BackgroundBlockColor;
if ( BackgroundBlockColors is not null )
level.BackgroundBlockColors = new List<Color>( BackgroundBlockColors );
level.FenceColor = FenceColor;
level.GlassColor = GlassColor;
}
public void ApplyTo( LevelDef level )
{
level.WallColor = WallColor;
level.OutOfBoundsColor = OutOfBoundsColor;
level.CheckerboardColor = CheckerboardColor;
level.CheckerboardSecondColor = null;
if ( AlternateCheckerboardColor.HasValue )
level.AlternateCheckerboardColor = AlternateCheckerboardColor;
level.AlternateCheckerboardSecondColor = null;
level.BackgroundBlockColor = BackgroundBlockColor;
if ( BackgroundBlockColors is not null )
level.BackgroundBlockColors = new List<Color>( BackgroundBlockColors );
level.FenceColor = FenceColor;
level.GlassColor = GlassColor;
}
}
/// <summary>Result of one pattern roll (the editor's "shuffle pattern" button). Alternate fields
/// are only populated when the roll was made with the alternate playfield enabled.</summary>
public sealed class PatternRoll
{
public PlayfieldPattern PlayfieldPattern;
public int PlayfieldCellScale;
public float BackgroundBlockScale;
public float BackgroundBlockDensity;
public float BackgroundBlockOpacity;
public float BackgroundDriftSpeed;
public BackgroundDriftBias BackgroundDriftBias;
public List<string> PlayfieldPatternRows;
public bool HasAlternate;
public PlayfieldPattern AlternatePlayfieldPattern;
public int AlternatePlayfieldCellScale;
public List<string> AlternatePlayfieldPatternRows;
public void ApplyTo( EditorLevel level )
{
level.PlayfieldPattern = PlayfieldPattern;
level.PlayfieldCellScale = PlayfieldCellScale;
level.BackgroundBlockScale = BackgroundBlockScale;
level.BackgroundBlockDensity = BackgroundBlockDensity;
level.BackgroundBlockOpacity = BackgroundBlockOpacity;
level.BackgroundDriftSpeed = BackgroundDriftSpeed;
level.BackgroundDriftBias = BackgroundDriftBias;
level.PlayfieldPatternRows = PlayfieldPatternRows is null ? null : new List<string>( PlayfieldPatternRows );
if ( HasAlternate )
{
level.AlternatePlayfieldPattern = AlternatePlayfieldPattern;
level.AlternatePlayfieldCellScale = AlternatePlayfieldCellScale;
level.AlternatePlayfieldPatternRows = AlternatePlayfieldPatternRows is null ? null : new List<string>( AlternatePlayfieldPatternRows );
}
}
public void ApplyTo( LevelDef level )
{
level.PlayfieldPattern = PlayfieldPattern;
level.PlayfieldCellScale = PlayfieldCellScale;
level.BackgroundBlockScale = BackgroundBlockScale;
level.BackgroundBlockDensity = BackgroundBlockDensity;
level.BackgroundBlockOpacity = BackgroundBlockOpacity;
level.BackgroundDriftSpeed = BackgroundDriftSpeed;
level.BackgroundDriftBias = BackgroundDriftBias;
level.PlayfieldPatternRows = PlayfieldPatternRows is null ? null : new List<string>( PlayfieldPatternRows );
if ( HasAlternate )
{
level.AlternatePlayfieldPattern = AlternatePlayfieldPattern;
level.AlternatePlayfieldCellScale = AlternatePlayfieldCellScale;
level.AlternatePlayfieldPatternRows = AlternatePlayfieldPatternRows is null ? null : new List<string>( AlternatePlayfieldPatternRows );
}
}
}
/// <summary>Result of one ambient background-particle roll (<see cref="RollBackgroundParticles"/>).
/// The feature-shape fields (trail, impact) are only meaningful when their gate won, and nothing
/// is meaningful when <see cref="Enabled"/> is false — but the roll consumed the same draws either
/// way, so callers can gate on <see cref="Enabled"/> without disturbing draw alignment. Applying
/// always emits a TOP-edge emitter whose trail and impact spray share the square tint (their
/// colour overrides stay null).</summary>
public sealed class ParticleRoll
{
public bool Enabled;
public Color Color;
public float Opacity;
public int SizeMax;
public float Angle;
public float AngleRange;
public float SpeedMin;
public float SpeedMax;
public float Gravity;
public float SpawnRate;
/// <summary>0 = no trail.</summary>
public int TrailLength;
public bool TrailFade;
public bool ImpactEnabled;
public int ImpactCountMin;
public int ImpactCountMax;
public float ImpactSpeedMin;
public float ImpactSpeedMax;
public int ImpactSizeMax;
public float ImpactGravity;
public float ImpactAngleRange;
public void ApplyTo( EditorLevel level )
{
level.BackgroundParticlesEnabled = true;
level.BackgroundParticleEdge = Direction.Up;
level.BackgroundParticleColor = Color;
level.BackgroundParticleOpacity = Opacity;
level.BackgroundParticleSizeMin = 1;
level.BackgroundParticleSizeMax = SizeMax;
level.BackgroundParticleAngle = Angle;
level.BackgroundParticleAngleRange = AngleRange;
level.BackgroundParticleSpeedMin = SpeedMin;
level.BackgroundParticleSpeedMax = SpeedMax;
level.BackgroundParticleGravity = Gravity;
level.BackgroundParticleSpawnRate = SpawnRate;
level.BackgroundParticleTrailLength = TrailLength;
level.BackgroundParticleTrailColor = null;
level.BackgroundParticleTrailFade = TrailFade;
level.BackgroundParticleImpactEnabled = ImpactEnabled;
level.BackgroundParticleImpactCountMin = ImpactCountMin;
level.BackgroundParticleImpactCountMax = ImpactCountMax;
level.BackgroundParticleImpactSpeedMin = ImpactSpeedMin;
level.BackgroundParticleImpactSpeedMax = ImpactSpeedMax;
level.BackgroundParticleImpactSizeMin = 1;
level.BackgroundParticleImpactSizeMax = ImpactSizeMax;
level.BackgroundParticleImpactGravity = ImpactGravity;
level.BackgroundParticleImpactAngleRange = ImpactAngleRange;
level.BackgroundParticleImpactColor = null;
}
public void ApplyTo( LevelDef level )
{
level.BackgroundParticlesEnabled = true;
level.BackgroundParticleEdge = Direction.Up;
level.BackgroundParticleColor = Color;
level.BackgroundParticleOpacity = Opacity;
level.BackgroundParticleSizeMin = 1;
level.BackgroundParticleSizeMax = SizeMax;
level.BackgroundParticleAngle = Angle;
level.BackgroundParticleAngleRange = AngleRange;
level.BackgroundParticleSpeedMin = SpeedMin;
level.BackgroundParticleSpeedMax = SpeedMax;
level.BackgroundParticleGravity = Gravity;
level.BackgroundParticleSpawnRate = SpawnRate;
level.BackgroundParticleTrailLength = TrailLength;
level.BackgroundParticleTrailColor = null;
level.BackgroundParticleTrailFade = TrailFade;
level.BackgroundParticleImpactEnabled = ImpactEnabled;
level.BackgroundParticleImpactCountMin = ImpactCountMin;
level.BackgroundParticleImpactCountMax = ImpactCountMax;
level.BackgroundParticleImpactSpeedMin = ImpactSpeedMin;
level.BackgroundParticleImpactSpeedMax = ImpactSpeedMax;
level.BackgroundParticleImpactSizeMin = 1;
level.BackgroundParticleImpactSizeMax = ImpactSizeMax;
level.BackgroundParticleImpactGravity = ImpactGravity;
level.BackgroundParticleImpactAngleRange = ImpactAngleRange;
level.BackgroundParticleImpactColor = null;
}
}
/// <summary>One coordinated palette around a random hue. Colours are generated in OKLCH
/// (perceptually uniform), not HSV: fixed HSV saturation/value ranges made red/yellow/pink/green
/// rolls read up to twice as bright/colourful as teal or blue ones. Every hue now lands on the
/// same perceived lightness and colourfulness — the OKLCH target ranges below were measured from
/// the old teal/light-blue rolls (the calm ones) so the whole wheel matches them.
/// <paramref name="alternateEnabled"/> also rolls the alternate checkerboard colour;
/// <paramref name="useThreeBgColors"/> emits three stepped background-block shades instead of the
/// single base colour.</summary>
public static ColorRoll RollColors( Random rng, bool alternateEnabled, bool useThreeBgColors )
{
float hue = Range( rng, 0f, 360f );
// One draw mapped symmetrically around the calm-teal chroma centre; PaletteChromaSpread
// widens/narrows the band without moving the centre (see the knob's doc).
float checkerChroma = MathF.Max( 0.075f + (Range( rng, 0f, 1f ) * 2f - 1f) * 0.020f * PaletteChromaSpread, 0f );
float checkerLightness = Range( rng, 0.66f, 0.76f );
float checkerHue = hue + Range( rng, -6f, 6f );
Color checker = OklchToRgb( checkerLightness, checkerChroma, checkerHue );
// Classic wall: near-grey with a whisper of the palette hue, dark enough that block faces
// keep reading over it (matches the old baked-grey-tint walls).
float wallHueJitter = Range( rng, -20f, 20f );
float wallChroma = Range( rng, 0.012f, 0.019f );
float wallLightness = Range( rng, 0.16f, 0.20f );
Color wall = OklchToRgb( wallLightness, wallChroma, hue + wallHueJitter );
float oobHueJitter = Range( rng, -8f, 8f );
float oobChroma = Range( rng, 0.012f, 0.026f );
float oobLightness = Range( rng, 0.22f, 0.28f );
Color outOfBounds = OklchToRgb( oobLightness, oobChroma, hue + oobHueJitter );
float bgHueJitter = Range( rng, -10f, 10f );
float bgChromaDelta = Range( rng, 0.010f, 0.030f );
float bgLightnessDelta = Range( rng, 0.03f, 0.06f );
Color bgBlock = OklchToRgb( checkerLightness + bgLightnessDelta, checkerChroma + bgChromaDelta, hue + bgHueJitter );
var roll = new ColorRoll
{
Hue = hue,
WallColor = wall,
OutOfBoundsColor = outOfBounds,
CheckerboardColor = checker,
BackgroundBlockColor = bgBlock,
};
if ( useThreeBgColors )
{
roll.BackgroundBlockColors = new List<Color>
{
StepSrgb( bgBlock, 1f ),
StepSrgb( bgBlock, Range( rng, 0.88f, 0.94f ) ),
StepSrgb( bgBlock, Range( rng, 0.76f, 0.84f ) ),
};
}
// ── Wall/OOB colour scheme (APPENDED draws — see the class doc's draw-order rule; the count
// is constant so every roll consumes the same draws whichever scheme wins). ──
// "Classic" keeps the muted near-grey wall rolled above. The vivid schemes use WallColor's
// direct-colour semantics for a saturated mid-dark wall that pops against the bright checker:
// usually near the background hue (standing out by value/saturation), sometimes its complement.
// OOB follows the wall hue in both vivid schemes — walls and the obstacle interiors they band
// read as one material — and stays well darker than the band so faces keep reading over fills.
float schemeRoll = Range( rng, 0f, 1f );
float analogousOffset = Range( rng, -28f, 28f );
float complementOffset = 180f + Range( rng, -30f, 30f );
float vividWallChroma = Range( rng, 0.030f, 0.060f );
float vividWallLightness = Range( rng, 0.27f, 0.38f );
float oobHueDrift = Range( rng, -8f, 8f );
float vividOobChroma = Range( rng, 0.010f, 0.022f );
float vividOobLightness = Range( rng, 0.16f, 0.20f );
if ( schemeRoll >= ClassicWallSchemeChance )
{
bool complementary = schemeRoll >= 1f - ComplementaryWallSchemeChance;
float wallHue = hue + ( complementary ? complementOffset : analogousOffset );
roll.WallColor = OklchToRgb( vividWallLightness, vividWallChroma, wallHue );
roll.OutOfBoundsColor = OklchToRgb( vividOobLightness, vividOobChroma, wallHue + oobHueDrift );
}
// ── Fence colour (APPENDED draws — see the class doc's draw-order rule; constant count). ──
// The COMPLEMENT of the checker/background hue, gently saturated (the in-game bars are also
// translucent — a fence should read as an open pen, not more wall) and kept mid-dark against
// the bright checker so the border reads over the playfield by both hue and lightness; the
// bars brighten from it (FenceBarColor) and keep the hue contrast. Rolled even when the
// level has no fences, so the draw count never varies.
float fenceHueJitter = Range( rng, -18f, 18f );
float fenceHue = hue + 180f + fenceHueJitter;
float fenceChroma = Range( rng, 0.035f, 0.065f );
float fenceLightness = Range( rng, 0.46f, 0.54f );
roll.FenceColor = OklchToRgb( fenceLightness, fenceChroma, fenceHue );
// ── Glass colour (APPENDED draws — constant count). A quarter-turn either way from the
// background hue — clearly apart from BOTH the checker and the fence's complement — and
// near-black (with enough chroma that the sliver of tint survives the low lightness) so the
// pane reads as a dark smoked-glass shade against the bright playfield instead of washing
// into it. Rolled even when the level has no glass (constant draw count).
float glassHue = hue + ( Range( rng, 0f, 1f ) < 0.5f ? 90f : -90f ) + Range( rng, -15f, 15f );
float glassChroma = Range( rng, 0.035f, 0.070f );
float glassLightness = Range( rng, 0.17f, 0.22f );
roll.GlassColor = OklchToRgb( glassLightness, glassChroma, glassHue );
// ── Alternate-playfield colour (APPENDED draws — constant count). Usually a slightly darker
// near-analogous shade so the region reads as a subtle patch; sometimes the COMPLEMENT so it
// clearly stands apart. The complement is pushed 25–40° AWAY from the fence's jitter side so a
// fence border never sits on a same-hue alternate region. ──
float altSchemeRoll = Range( rng, 0f, 1f );
float altComplementOffset = Range( rng, 25f, 40f ) * ( fenceHueJitter < 0f ? 1f : -1f );
float altComplementLightnessDelta = Range( rng, 0.08f, 0.13f );
bool altComplementary = alternateEnabled && altSchemeRoll < ComplementaryAltPlayfieldChance;
float altLightness = Math.Clamp( checkerLightness - ( altComplementary ? altComplementLightnessDelta : 0.12f ), 0f, 1f );
float altHue = altComplementary ? hue + 180f + altComplementOffset : hue + 22f;
if ( alternateEnabled )
{
roll.AlternateHueComplementary = altComplementary;
roll.AlternateCheckerboardColor = OklchToRgb( altLightness, checkerChroma, altHue );
}
// ── Split background blocks (APPENDED draws — constant count). Only when the alternate region
// has a complementary hue: one layer takes a main-pattern colour (light or dark cell), one an
// alternate-pattern colour, one sits between them (OKLab blend, chroma held to at least half so
// the middle never collapses to grey). Front→back order is rolled. ──
float bgSplitRoll = Range( rng, 0f, 1f );
bool mainDark = Range( rng, 0f, 1f ) < 0.5f, altDark = Range( rng, 0f, 1f ) < 0.5f, reversed = Range( rng, 0f, 1f ) < 0.5f;
float blend = Range( rng, 0.35f, 0.65f );
if ( altComplementary && bgSplitRoll < AltBackgroundSplitChance )
{
// Every playfield pattern alternates between TWO shades: the level's colour, and a darker
// one the renderer derives from it (StageBase.StepShade — a multiply in GAMMA space). Match
// a shade the same way: StepSrgb's LINEAR multiply lands ~8/255 lighter, and the layer would
// visibly miss the shade it is copying.
const float darkStep = 235f / 255f; // StageBase's derived darker-shade factor
Color mainShade = mainDark ? Step( checker, darkStep ) : checker;
Color altShade = altDark ? Step( roll.AlternateCheckerboardColor.Value, darkStep ) : roll.AlternateCheckerboardColor.Value;
Color between = OklchBlend( checkerLightness, checkerChroma, checkerHue, altLightness, checkerChroma, altHue, blend, minChromaFraction: 0.5f );
if ( mainDark && altDark ) between = Step( between, darkStep );
else if ( mainDark || altDark ) between = Step( between, ( 1f + darkStep ) / 2f );
roll.BackgroundBlockColors = reversed
? new List<Color> { altShade, between, mainShade }
: new List<Color> { mainShade, between, altShade };
roll.BackgroundBlockSplit = true;
}
return roll;
}
/// <summary>One pattern roll. The main slot is either a built-in preset or a generated Custom tile
/// (odds: <see cref="CustomPatternChance"/> and friends); <paramref name="alternateEnabled"/> also
/// rolls the alternate slot, from a different preset than the main pick (or its own Custom tile) so
/// the region reads as a distinct texture. Draw count VARIES (Custom tiles draw their own shape
/// parameters) — callers needing draw alignment must roll this last.</summary>
public static PatternRoll RollPattern( Random rng, bool alternateEnabled )
{
// Which slots go Custom. With the alternate on: "one slot" is common, "both" rare.
float customRoll = Range( rng, 0f, 1f );
float customSlotRoll = Range( rng, 0f, 1f );
bool mainCustom, altCustom = false;
if ( alternateEnabled )
{
bool both = customRoll < BothCustomPatternChance;
bool one = !both && customRoll < BothCustomPatternChance + CustomPatternChance;
mainCustom = both || ( one && customSlotRoll < 0.5f );
altCustom = both || ( one && !mainCustom );
}
else
{
mainCustom = customRoll < SoloCustomPatternChance;
}
var roll = new PatternRoll();
int presetIndex = -1;
if ( mainCustom )
{
var custom = RollCustomPattern( rng );
var bg = CustomBackgroundPreset;
roll.PlayfieldPattern = PlayfieldPattern.Custom;
roll.PlayfieldCellScale = custom.CellScale;
roll.PlayfieldPatternRows = custom.Rows;
roll.BackgroundBlockScale = Snap( Range( rng, bg.MinBgScale, bg.MaxBgScale ), 0.05f );
roll.BackgroundBlockDensity = Snap( Range( rng, bg.MinDensity, bg.MaxDensity ), 0.05f );
roll.BackgroundDriftSpeed = Snap( Range( rng, bg.MinDrift, bg.MaxDrift ), 0.1f );
roll.BackgroundDriftBias = bg.Biases[rng.Next( bg.Biases.Length )];
}
else
{
presetIndex = rng.Next( PatternPresets.Length );
var preset = PatternPresets[presetIndex];
roll.PlayfieldPattern = preset.Pattern;
roll.PlayfieldCellScale = rng.Next( preset.MinCellScale, preset.MaxCellScale + 1 );
roll.BackgroundBlockScale = Snap( Range( rng, preset.MinBgScale, preset.MaxBgScale ), 0.05f );
roll.BackgroundBlockDensity = Snap( Range( rng, preset.MinDensity, preset.MaxDensity ), 0.05f );
roll.BackgroundDriftSpeed = Snap( Range( rng, preset.MinDrift, preset.MaxDrift ), 0.1f );
roll.BackgroundDriftBias = preset.Biases[rng.Next( preset.Biases.Length )];
roll.PlayfieldPatternRows = preset.CustomRows?.ToList();
}
if ( alternateEnabled )
{
roll.HasAlternate = true;
if ( altCustom )
{
var custom = RollCustomPattern( rng );
roll.AlternatePlayfieldPattern = PlayfieldPattern.Custom;
roll.AlternatePlayfieldCellScale = custom.CellScale;
roll.AlternatePlayfieldPatternRows = custom.Rows;
}
else
{
// A different preset index than the main pick (any preset when the main is Custom).
int altIndex = presetIndex < 0 ? rng.Next( PatternPresets.Length ) : rng.Next( PatternPresets.Length - 1 );
if ( presetIndex >= 0 && altIndex >= presetIndex ) altIndex++;
var alt = PatternPresets[altIndex];
roll.AlternatePlayfieldPattern = alt.Pattern;
roll.AlternatePlayfieldCellScale = rng.Next( alt.MinCellScale, alt.MaxCellScale + 1 );
roll.AlternatePlayfieldPatternRows = alt.CustomRows?.ToList();
}
}
// Background-block opacity: not part of the presets — any texture can read anywhere from a
// faint wash to fully solid. Best of two draws, LOWER wins: fully-solid blocks fight the
// playfield pattern, so the distribution leans toward the subtler washes (mean ~0.4).
roll.BackgroundBlockOpacity = Snap( MathF.Min( Range( rng, 0.10f, 1f ), Range( rng, 0.10f, 1f ) ), 0.05f );
return roll;
}
// ── Generated Custom tiles ───────────────────────────────────────────────────────────────────────
/// <summary>A generated Custom tile: '0'/'1' rows (≤ 8×8, row 0 = lowest cell row) plus the cell
/// scale that sizes it on screen.</summary>
public readonly record struct CustomPattern( List<string> Rows, int CellScale );
/// <summary>How many generated Custom tile families <see cref="BuildCustomTile"/> offers.</summary>
private const int CustomTileFamilyCount = 15;
/// <summary>One tile family's cells ([x, y], row 0 = the lowest). Each family draws its own shape
/// parameters (tile size, thickness, spacing, orientation …).
/// A switch, NOT an array of lambdas: s&box remaps delegates across a hotload, and a lambda
/// parked in a static field has no counterpart in the reloaded assembly — it becomes an error
/// delegate that throws "Unable to find matching substitution for a lambda method" when called.</summary>
private static bool[,] BuildCustomTile( int family, Random rng )
{
switch ( family )
{
case 0: // Plus signs: short arms (full-length arms would just be Grid).
{
int n = rng.Next( 2 ) == 0 ? 5 : 7, c = n / 2, arm = rng.Next( 1, c );
return Fill( n, n, ( x, y ) => ( y == c && Math.Abs( x - c ) <= arm ) || ( x == c && Math.Abs( y - c ) <= arm ) );
}
case 1: // Diagonal stripes with a rolled period / duty cycle / direction.
{
int n = rng.Next( 3, 9 ), thick = rng.Next( 1, n - 1 ), dir = rng.Next( 2 ) == 0 ? 1 : -1;
return Fill( n, n, ( x, y ) => ( ( x + dir * y ) % n + n ) % n < thick );
}
case 2: // Chevrons (triangle wave), optionally two cells thick.
{
int w = 4 + 2 * rng.Next( 3 ), h = rng.Next( 2, 5 ), thick = rng.Next( 1, 3 );
return Fill( w, h, ( x, y ) =>
{
int peak = Math.Min( x < w / 2 ? x : w - 1 - x, h - 1 );
return y >= peak && y < peak + thick;
} );
}
case 3: // Bricks: courses of bricks with one-cell mortar, each course shifted.
{
int bw = rng.Next( 2, 5 ), bh = rng.Next( 1, 4 ), shift = rng.Next( 1, bw + 1 );
return Fill( bw + 1, 2 * ( bh + 1 ), ( x, y ) =>
{
int course = y / ( bh + 1 );
if ( y % ( bh + 1 ) == bh ) return true;
return ( x + course * shift ) % ( bw + 1 ) == bw;
} );
}
case 4: // Square dots on a rolled spacing, rolled dot size.
{
int w = rng.Next( 3, 9 ), h = rng.Next( 3, 9 ), size = rng.Next( 1, Math.Max( 2, Math.Min( w, h ) - 1 ) );
return Fill( w, h, ( x, y ) => x < size && y < size );
}
case 5: // Staggered (diamond-lattice) dots.
{
int w = 4 + 2 * rng.Next( 3 ), h = 4 + 2 * rng.Next( 3 ), size = rng.Next( 1, Math.Min( w, h ) / 2 );
return Fill( w, h, ( x, y ) => ( x < size && y < size ) || ( x >= w / 2 && x < w / 2 + size && y >= h / 2 && y < h / 2 + size ) );
}
case 6: // Static: random cells at a rolled density.
{
int w = rng.Next( 4, 9 ), h = rng.Next( 4, 9 );
float density = Range( rng, 0.2f, 0.5f );
var cells = new bool[w, h];
for ( int y = 0; y < h; y++ )
for ( int x = 0; x < w; x++ )
cells[x, y] = Range( rng, 0f, 1f ) < density;
return cells;
}
case 7: // Kaleidoscope: a random quadrant mirrored both ways.
{
int w = 4 + 2 * rng.Next( 3 ), h = 4 + 2 * rng.Next( 3 );
float density = Range( rng, 0.3f, 0.6f );
var quadrant = new bool[w / 2, h / 2];
for ( int y = 0; y < h / 2; y++ )
for ( int x = 0; x < w / 2; x++ )
quadrant[x, y] = Range( rng, 0f, 1f ) < density;
return Fill( w, h, ( x, y ) => quadrant[Math.Min( x, w - 1 - x ), Math.Min( y, h - 1 - y )] );
}
case 8: // Hollow squares separated by a rolled gap.
{
int n = rng.Next( 4, 9 ), gap = n >= 6 ? rng.Next( 1, 3 ) : 1, side = n - gap;
return Fill( n, n, ( x, y ) => x < side && y < side && ( x == 0 || y == 0 || x == side - 1 || y == side - 1 ) );
}
case 9: // Sine waves, optionally two cells thick.
{
int w = rng.Next( 2 ) == 0 ? 6 : 8, h = rng.Next( 3, 6 ), thick = rng.Next( 1, 3 );
return Fill( w, h, ( x, y ) =>
{
int crest = (int)MathF.Round( ( h - 1 ) / 2f * ( 1f + MathF.Sin( MathF.Tau * x / w ) ) );
return y >= crest && y < crest + thick;
} );
}
case 10: // Uneven stripes: on/off runs of different lengths (equal runs are the built-in stripes).
{
int on = rng.Next( 1, 4 ), off = rng.Next( 1, 6 );
if ( off == on ) off = on == 1 ? 2 : on - 1;
int period = on + off;
return rng.Next( 2 ) == 0
? Fill( 1, period, ( x, y ) => y < on )
: Fill( period, 1, ( x, y ) => x < on );
}
case 11: // Sawtooth: stacked right triangles, either handedness.
{
int n = rng.Next( 3, 7 );
bool flip = rng.Next( 2 ) == 0;
return Fill( n, n, ( x, y ) => ( flip ? n - 1 - x : x ) <= y );
}
case 12: // Plaid: an uneven vertical stripe XOR an uneven horizontal one (rectangular checker).
{
int w = rng.Next( 4, 9 ), h = rng.Next( 4, 9 ), a = rng.Next( 1, w ), b = rng.Next( 1, h );
return Fill( w, h, ( x, y ) => ( x < a ) ^ ( y < b ) );
}
case 13: // Crosshatch: both diagonals on a rolled period.
{
int n = rng.Next( 4, 9 );
return Fill( n, n, ( x, y ) => ( x + y ) % n == 0 || ( ( x - y ) % n + n ) % n == 0 );
}
default: // Diamonds, filled or outlined, with a rolled radius.
{
int n = rng.Next( 2 ) == 0 ? 5 : 7, c = n / 2, radius = rng.Next( 1, c + 1 );
bool outline = rng.Next( 2 ) == 0;
return Fill( n, n, ( x, y ) =>
{
int d = Math.Abs( x - c ) + Math.Abs( y - c );
return outline ? d == radius : d <= radius;
} );
}
}
}
/// <summary>One generated Custom tile: a random family, its shape draws, a 50/50 light/dark
/// inversion, and a cell scale that lands the whole tile at roughly 8–40 px.</summary>
public static CustomPattern RollCustomPattern( Random rng )
{
var cells = BuildCustomTile( rng.Next( CustomTileFamilyCount ), rng );
int w = cells.GetLength( 0 ), h = cells.GetLength( 1 );
bool invert = rng.Next( 2 ) == 0;
int ones = 0;
for ( int y = 0; y < h; y++ )
for ( int x = 0; x < w; x++ )
if ( cells[x, y] ) ones++;
if ( ones == 0 || ones == w * h ) cells[0, 0] = !cells[0, 0]; // never a flat tile
var rows = new List<string>( h );
for ( int y = 0; y < h; y++ )
{
var row = new char[w];
for ( int x = 0; x < w; x++ )
row[x] = cells[x, y] ^ invert ? '1' : '0';
rows.Add( new string( row ) );
}
int dim = Math.Max( w, h );
int lo = Math.Clamp( (int)MathF.Ceiling( 8f / dim ), 1, 8 );
int hi = Math.Clamp( 40 / dim, lo, 8 );
return new CustomPattern( rows, rng.Next( lo, hi + 1 ) );
}
private static bool[,] Fill( int w, int h, Func<int, int, bool> cell )
{
var cells = new bool[w, h];
for ( int y = 0; y < h; y++ )
for ( int x = 0; x < w; x++ )
cells[x, y] = cell( x, y );
return cells;
}
/// <summary>One ambient background-particle roll (rain / snow / drifting-square ambience from the
/// TOP edge). <paramref name="enabledChance"/> gates the whole feature; every other draw still
/// happens when the gate loses, so the draw count is constant whichever way it lands (the daily
/// generator's append-only rule). <paramref name="paletteHue"/> is the day's palette anchor
/// (<see cref="ColorRoll.Hue"/>) so the squares can tint to the theme. All emitted values sit
/// safely inside <see cref="BackgroundParticleSettings"/>' defensive clamps.</summary>
public static ParticleRoll RollBackgroundParticles( Random rng, float paletteHue, float enabledChance )
{
var roll = new ParticleRoll { Enabled = Range( rng, 0f, 1f ) < enabledChance };
// Squares are usually single pixels; 2 sometimes, 3 very rarely, never more.
float sizeRoll = Range( rng, 0f, 1f );
roll.SizeMax = sizeRoll < 0.70f ? 1 : sizeRoll < 0.95f ? 2 : 3;
// Tint: usually near the palette hue, sometimes its complement (clearly different but still
// coordinated), often plain white — it matches ANY palette — and rarely black. The OKLCH
// draws happen for every scheme so the winner never changes the draw count; the tint is
// brighter and more colourful than the playfield bands so the squares read over them.
float tintRoll = Range( rng, 0f, 1f );
float tintHueJitter = Range( rng, -14f, 14f );
float tintChroma = Range( rng, 0.045f, 0.095f );
float tintLightness = Range( rng, 0.72f, 0.86f );
if ( tintRoll < 0.40f )
roll.Color = OklchToRgb( tintLightness, tintChroma, paletteHue + tintHueJitter );
else if ( tintRoll < 0.75f )
roll.Color = OklchToRgb( tintLightness, tintChroma, paletteHue + 180f + tintHueJitter );
else if ( tintRoll < 0.90f )
roll.Color = Color.White;
else
roll.Color = Color.Black;
// Emit direction: sometimes perfectly vertical rain, otherwise a tilted fall with
// per-particle scatter (both draws always happen — constant count).
bool straightDown = Range( rng, 0f, 1f ) < 0.30f;
float angle = Range( rng, -30f, 30f );
float angleRange = Range( rng, 6f, 40f );
roll.Angle = straightDown ? 0f : Snap( angle, 1f );
roll.AngleRange = straightDown ? 0f : Snap( angleRange, 1f );
roll.SpawnRate = Snap( Range( rng, 4f, 22f ), 0.5f );
roll.SpeedMin = Snap( Range( rng, 40f, 110f ), 5f );
roll.SpeedMax = roll.SpeedMin + Snap( Range( rng, 20f, 80f ), 5f );
roll.Gravity = Snap( Range( rng, 15f, 160f ), 5f );
roll.Opacity = Snap( Range( rng, 0.30f, 0.90f ), 0.05f );
// Trail: sometimes a streak of 1–6 vacated cells behind each square, usually comet-fading,
// sometimes a solid line. The trail colour stays the square tint (ApplyTo leaves it null).
bool trail = Range( rng, 0f, 1f ) < 0.35f;
int trailLength = rng.Next( 1, 7 );
bool trailSolid = Range( rng, 0f, 1f ) < 0.30f;
roll.TrailLength = trail ? trailLength : 0;
roll.TrailFade = !(trail && trailSolid);
// Impact spray: sometimes squares splash into fragments on the solid that kills them. The
// spray tint is always the square tint (ApplyTo leaves the override null), and its max size
// follows the same usually-1 / sometimes-2 / rarely-3 shape as the squares.
roll.ImpactEnabled = Range( rng, 0f, 1f ) < 0.45f;
float impactSizeRoll = Range( rng, 0f, 1f );
roll.ImpactSizeMax = impactSizeRoll < 0.70f ? 1 : impactSizeRoll < 0.95f ? 2 : 3;
roll.ImpactCountMin = rng.Next( 1, 3 );
roll.ImpactCountMax = roll.ImpactCountMin + rng.Next( 1, 4 );
roll.ImpactSpeedMin = Snap( Range( rng, 10f, 30f ), 5f );
roll.ImpactSpeedMax = roll.ImpactSpeedMin + Snap( Range( rng, 15f, 60f ), 5f );
roll.ImpactGravity = Snap( Range( rng, 60f, 260f ), 10f );
roll.ImpactAngleRange = Snap( Range( rng, 30f, 80f ), 5f );
return roll;
}
public static float Range( Random rng, float min, float max )
=> min + (float)rng.NextDouble() * (max - min);
public static float Snap( float value, float step )
=> MathF.Round( value / step ) * step;
/// <summary>OKLCH → sRGB. OKLCH is perceptually uniform: equal lightness/chroma LOOK equally
/// bright/colourful at every hue (unlike HSV, where fixed S/V made warm hues glare — see
/// <see cref="RollColors"/>). Out-of-gamut requests keep the requested lightness and hue and pull
/// chroma back to the sRGB gamut boundary, so a hue that can't reach the asked-for colourfulness
/// degrades to a calmer colour rather than a different-looking one. Hue is in degrees on the
/// OKLCH wheel (≈25 red, ≈110 yellow, ≈142 green, ≈195 cyan, ≈264 blue, ≈330 pink).</summary>
/// <summary>Blend two OKLCH colours at <paramref name="t"/> through OKLab (the straight perceptual
/// path). Complementary endpoints would meet near grey, so the result's chroma is held to at least
/// <paramref name="minChromaFraction"/> of the endpoints' blended chroma, hue taken from the blend.</summary>
public static Color OklchBlend( float l1, float c1, float h1, float l2, float c2, float h2, float t, float minChromaFraction )
{
float r1 = h1 * MathF.PI / 180f, r2 = h2 * MathF.PI / 180f;
float a = ( 1f - t ) * c1 * MathF.Cos( r1 ) + t * c2 * MathF.Cos( r2 );
float b = ( 1f - t ) * c1 * MathF.Sin( r1 ) + t * c2 * MathF.Sin( r2 );
float l = ( 1f - t ) * l1 + t * l2;
float chroma = MathF.Sqrt( a * a + b * b );
float hue = chroma > 1e-4f ? MathF.Atan2( b, a ) * 180f / MathF.PI : ( t < 0.5f ? h1 : h2 );
chroma = MathF.Max( chroma, minChromaFraction * ( ( 1f - t ) * c1 + t * c2 ) );
return OklchToRgb( l, chroma, hue );
}
public static Color OklchToRgb( float lightness, float chroma, float hue )
{
lightness = Math.Clamp( lightness, 0f, 1f );
chroma = MathF.Max( chroma, 0f );
float radians = ((hue % 360f + 360f) % 360f) * MathF.PI / 180f;
float cos = MathF.Cos( radians );
float sin = MathF.Sin( radians );
if ( TryOklabToLinearRgb( lightness, chroma * cos, chroma * sin, out Color linear ) )
return LinearToGamma( ClampChannels( linear ) );
// Binary-search chroma down to the gamut boundary (chroma 0 is always in gamut: it yields
// the neutral grey of this lightness).
float low = 0f;
float high = chroma;
float grey = lightness * lightness * lightness;
Color best = new( grey, grey, grey );
for ( int i = 0; i < 16; i++ )
{
float mid = (low + high) * 0.5f;
if ( TryOklabToLinearRgb( lightness, mid * cos, mid * sin, out Color candidate ) )
{
low = mid;
best = candidate;
}
else
{
high = mid;
}
}
return LinearToGamma( ClampChannels( best ) );
}
/// <summary>Björn Ottosson's OKLab → linear sRGB. False (with the unclamped colour still emitted)
/// when the result falls outside the sRGB gamut.</summary>
private static bool TryOklabToLinearRgb( float L, float a, float b, out Color linear )
{
float l = L + 0.3963377774f * a + 0.2158037573f * b;
float m = L - 0.1055613458f * a - 0.0638541728f * b;
float s = L - 0.0894841775f * a - 1.2914855480f * b;
l = l * l * l;
m = m * m * m;
s = s * s * s;
float red = 4.0767416621f * l - 3.3077115913f * m + 0.2309699292f * s;
float green = -1.2684380046f * l + 2.6097574011f * m - 0.3413193965f * s;
float blue = -0.0041960863f * l - 0.7034186147f * m + 1.7076147010f * s;
linear = new Color( red, green, blue );
const float epsilon = 1e-4f;
return red >= -epsilon && red <= 1f + epsilon
&& green >= -epsilon && green <= 1f + epsilon
&& blue >= -epsilon && blue <= 1f + epsilon;
}
private static Color ClampChannels( Color c )
=> new( Math.Clamp( c.r, 0f, 1f ), Math.Clamp( c.g, 0f, 1f ), Math.Clamp( c.b, 0f, 1f ), c.a );
public static Color HsvToRgb( float hue, float saturation, float value )
{
hue = (hue % 360f + 360f) % 360f;
saturation = Math.Clamp( saturation, 0f, 1f );
value = Math.Clamp( value, 0f, 1f );
float chroma = value * saturation;
float hueSection = hue / 60f;
float secondary = chroma * (1f - MathF.Abs( hueSection % 2f - 1f ));
float red = 0f;
float green = 0f;
float blue = 0f;
switch ( (int)MathF.Floor( hueSection ) )
{
case 0: red = chroma; green = secondary; break;
case 1: red = secondary; green = chroma; break;
case 2: green = chroma; blue = secondary; break;
case 3: green = secondary; blue = chroma; break;
case 4: red = secondary; blue = chroma; break;
default: red = chroma; blue = secondary; break;
}
float match = value - chroma;
return new Color( red + match, green + match, blue + match );
}
public static void RgbToHsv( Color color, out float hue, out float saturation, out float value )
{
float red = Math.Clamp( color.r, 0f, 1f );
float green = Math.Clamp( color.g, 0f, 1f );
float blue = Math.Clamp( color.b, 0f, 1f );
float max = MathF.Max( red, MathF.Max( green, blue ) );
float min = MathF.Min( red, MathF.Min( green, blue ) );
float delta = max - min;
value = max;
saturation = max <= 0f ? 0f : delta / max;
if ( delta <= 0f )
{
hue = 0f;
return;
}
if ( max == red )
hue = 60f * (((green - blue) / delta) % 6f);
else if ( max == green )
hue = 60f * (((blue - red) / delta) + 2f);
else
hue = 60f * (((red - green) / delta) + 4f);
if ( hue < 0f ) hue += 360f;
}
/// <summary>The fence BAR colour derived from its border colour — the same "brighter version of
/// the border" everywhere a fence is drawn (game, editor schematic, level preview).</summary>
public static Color FenceBarColor( Color border ) => ScaleSrgb( border, 2.1f );
/// <summary>The tint for spike TEETH on a GLASS face: normal teeth are pure white (a hazard reads
/// the same everywhere), glass teeth shade toward the glass colour to signal "player-only" —
/// blocks slide right over them. Shared by the game and the level preview.</summary>
public static Color GlassTeethTint( Color glass ) => Color.Lerp( Color.White, glass, 0.45f );
public static Color Step( Color c, float f ) => new Color( c.r * f, c.g * f, c.b * f, c.a );
public static Color StepSrgb( Color c, float f ) => LinearToGamma( Step( GammaToLinear( c ), f ) );
public static Color ScaleSrgb( Color color, float factor )
{
Color linear = GammaToLinear( color );
Color scaled = new(
Math.Clamp( linear.r * factor, 0f, 1f ),
Math.Clamp( linear.g * factor, 0f, 1f ),
Math.Clamp( linear.b * factor, 0f, 1f ),
color.a );
return LinearToGamma( scaled );
}
public static Color ScaleSaturation( Color color, float factor )
{
RgbToHsv( color, out float hue, out float saturation, out float value );
return HsvToRgb( hue, Math.Clamp( saturation * factor, 0f, 1f ), value ).WithAlpha( color.a );
}
public static Color LinearToGamma( Color color )
{
static float Convert( float value ) => value <= 0.0031308f ? value * 12.92f : 1.055f * MathF.Pow( value, 1f / 2.4f ) - 0.055f;
return new Color( Convert( color.r ), Convert( color.g ), Convert( color.b ), color.a );
}
public static Color GammaToLinear( Color color )
{
static float Convert( float value ) => value <= 0.04045f ? value / 12.92f : MathF.Pow( (value + 0.055f) / 1.055f, 2.4f );
return new Color( Convert( color.r ), Convert( color.g ), Convert( color.b ), color.a );
}
}