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&amp;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 );
	}
}