Game/Levels.cs
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
namespace BlockParty;
public enum BackgroundDriftBias
{
None,
Horizontal,
Vertical,
Left,
Right,
Down,
Up,
}
public enum PlayfieldPattern
{
Checker,
HorizontalStripes,
VerticalStripes,
Diagonal,
Dots,
Grid,
Custom,
}
/// <summary>
/// One playable level: which blocks spawn (an exact list, or N random picks from a pool), the
/// candidate spawn positions, and where the player starts. Levels are static C# data (see
/// <see cref="Levels"/>); the <see cref="Id"/> is the stable identity that replays and per-level
/// leaderboard stats are keyed on, so never rename or reuse an id once runs have been recorded
/// against it (editing a level's layout silently breaks its old replays — the desync check will
/// flag them, but they won't reproduce).
/// </summary>
public sealed class LevelDef
{
/// <summary>Stable identity, lowercase kebab-case. Baked into replay payloads and leaderboard
/// stat names.</summary>
public string Id { get; set; }
/// <summary>Display name shown on the level-select list and leaderboard filter.</summary>
public string Name { get; set; }
/// <summary>Optional filename or extensionless OGG name under <c>Assets/music</c>. Blank uses
/// <see cref="Audio.DefaultMusic"/>.</summary>
public string Music { get; set; }
/// <summary>Mix trim for <see cref="Music"/>, balancing a hotter or quieter track against the rest
/// of the mix. Multiplies the player's music setting; 1 = the track's own level.</summary>
public float MusicVolume { get; set; } = 1f;
/// <summary>Editor-only categorization for non-map levels. Test levels appear in the level
/// browser's Test view instead of Other. New and unmarked levels default to false.</summary>
public bool IsTest { get; set; }
/// <summary>Exact blocks to spawn. When set, <see cref="Pool"/>/<see cref="BlockCount"/> are
/// ignored. Duplicate types are allowed.</summary>
public IReadOnlyList<BlockType> Blocks { get; set; }
/// <summary>Random pool: <see cref="BlockCount"/> entries are drawn without replacement. Duplicate
/// types must be listed more than once to permit multiple instances. Used only when
/// <see cref="Blocks"/> is null.</summary>
public IReadOnlyList<BlockType> Pool { get; set; }
/// <summary>How many blocks to draw from <see cref="Pool"/>. Must not exceed the pool entry count.
/// Ignored when <see cref="Blocks"/> is set.</summary>
public int BlockCount { get; set; }
/// <summary>Optional per-block starting state for EXACT mode, aligned index-wise with
/// <see cref="Blocks"/> (entry <c>i</c> configures <c>Blocks[i]</c>). A null list, or a null/default
/// entry, means the classic "spawn at phase 0, no pressed sides". Lets a level start a block partway
/// up. Ignored in pool mode (see <see cref="PoolStartPhase"/>).</summary>
public IReadOnlyList<BlockStart> BlockStarts { get; set; }
/// <summary>Pool mode only: the starting phase applied to EVERY randomly-drawn block (0 =
/// classic). Per-instance pressed sides don't map to random draws, so pool mode only supports a
/// single shared starting phase.</summary>
public int PoolStartPhase { get; set; }
/// <summary>Candidate spawn positions (block centres). Must list at least as many as the level
/// spawns; when there are more, a random subset is chosen (listed order preserved).</summary>
public IReadOnlyList<Vector2> SpawnPositions { get; set; }
/// <summary>Optional per-position PINNED BLOCK, aligned index-wise with <see cref="SpawnPositions"/>
/// (a null entry, or a shorter/absent list, leaves that slot free). A pinned slot ALWAYS spawns
/// its authored block (exact type, starting phase, pre-pressed sides) — independent of, and in
/// addition to, the level's random <see cref="Blocks"/>/<see cref="Pool"/> count, which fills
/// only the free slots. E.g. a phase-2 Bullet enclosed in wall obstacles as a turret.</summary>
public IReadOnlyList<PinnedBlock> SpawnPins { get; set; }
/// <summary>Optional per-position enclosed-ability timing, aligned with <see cref="SpawnPositions"/>.
/// Missing entries use <see cref="Block.DEFAULT_ABILITY_INTERVAL"/> with no randomness or start offset.</summary>
public IReadOnlyList<BlockAbilityTimer> SpawnAbilityTimers { get; set; }
/// <summary>Optional initial travel direction per spawn position. Missing entries or
/// <see cref="Direction.None"/> retain the classic random direction.</summary>
public IReadOnlyList<Direction> SpawnDirections { get; set; }
/// <summary>Optional per-position turn rule (see <see cref="TurnMode"/>): never-reverse, or the
/// CW/CCW track-cycling modes. Null/short entries default to <see cref="TurnMode.Free"/>.</summary>
public IReadOnlyList<TurnMode> SpawnTurnModes { get; set; }
/// <summary>Candidate player starts (at least one). A single entry is the classic fixed spawn;
/// with several, the run picks one from the run Rng at stage enter. Exactly two entries map to
/// Twin 1 then Twin 2; with three or more, Twins pick two distinct random entries. Selection is
/// deterministic per seed, and single-spawn levels consume NO extra draw. Defaults to the classic
/// bottom-left start.</summary>
public IReadOnlyList<Vector2> PlayerSpawns { get; set; } = new[] { new Vector2( 16, 40 ) };
/// <summary>Optional character id forced by this level. A known id overrides the player's selected
/// character when starting the level; null, empty, or an unknown id leaves the selection unchanged.</summary>
public string ForcedCharacterId { get; set; }
/// <summary>Solid interior wall regions (logical-space rects). Empty for the classic square arena.
/// A complex shape is expressed as the union of several rects (e.g. a C-shape = one rect on the
/// floor). Author responsibility: no obstacle may overlap a block <see cref="SpawnPositions"/> or
/// <see cref="PlayerSpawns"/>. Not serialized into replays/codes — reconstructed from the level id,
/// like the block layout.</summary>
public IReadOnlyList<RectF> Obstacles { get; set; } = System.Array.Empty<RectF>();
/// <summary>Arena boundary sides (Left/Right/Up/Down) that start the level permanently spiked:
/// deadly from the first frame, never retract, no warning blink. Every face on a listed side is
/// spiked — including all segments of a side that a flush obstacle splits (e.g. listing
/// <see cref="Direction.Down"/> spikes both floor channels either side of a C-shape plateau).</summary>
public IReadOnlyList<Direction> SpikedWalls { get; set; } = System.Array.Empty<Direction>();
/// <summary>Permanent spikes on obstacle sides. Each entry names one obstacle rect (matched by
/// value against <see cref="Obstacles"/>) plus the outward side normals to spike; every face on a
/// listed side is spiked, including all segments of a side split by a flush neighbour. Author tip:
/// hoist the rect into a local variable so the identical value is shared with <see cref="Obstacles"/>.</summary>
public IReadOnlyList<ObstacleSpikeSpec> SpikedObstacleSides { get; set; } = System.Array.Empty<ObstacleSpikeSpec>();
/// <summary>Optional palette overrides. All null by default, meaning the level keeps the classic
/// teal/grey look. They only apply while this level is being played (the <see cref="GameStage"/>);
/// the score screen and every menu revert to the defaults. See <see cref="StageBase"/> /
/// <see cref="GameStage"/> for where each is consumed.</summary>
/// <summary>Direct displayed colour of the arena + obstacle wall bands and elbow patches. The
/// renderer preserves the baked art's alpha and relative shading. Null keeps the default grey.</summary>
public Color? WallColor { get; set; }
/// <summary>The out-of-bounds colour: fills the camera pillarbox, the edge-blocker masks and the
/// interior obstacle fill (all read as the same "outside the arena" space). Null uses the
/// manager's default clear colour.</summary>
public Color? OutOfBoundsColor { get; set; }
/// <summary>Colour of the play-area checkerboard field. When set, the field is drawn from a neutral
/// (white/grey) baked variant tinted to this colour, giving a subtle two-shade dither in the chosen
/// hue. Null keeps the default teal checkerboard.</summary>
public Color? CheckerboardColor { get; set; }
/// <summary>Optional second colour for the playfield pattern. Null derives the existing subtly
/// darker shade from <see cref="CheckerboardColor"/>.</summary>
public Color? CheckerboardSecondColor { get; set; }
/// <summary>Base colour for the three drifting cosmetic background-block layers; the deeper layers
/// derive stepped-darker shades from it. Null keeps the default teal shades. Ignored when
/// <see cref="BackgroundBlockColors"/> is set.</summary>
public Color? BackgroundBlockColor { get; set; }
/// <summary>Explicit colours for the three drifting background-block layers (front → back). When
/// set it must list exactly three; it takes precedence over <see cref="BackgroundBlockColor"/> and
/// lets a level choose all three shades freely (rather than the auto-derived stepped-darker set).
/// Null falls back to <see cref="BackgroundBlockColor"/> / the default.</summary>
public IReadOnlyList<Color> BackgroundBlockColors { get; set; }
/// <summary>Size multiplier for the drifting cosmetic background blocks. 1 keeps the classic
/// sizes; smaller values give a level a finer-grained backdrop.</summary>
public float BackgroundBlockScale { get; set; } = 1f;
/// <summary>Multiplier for the classic 6/5/4 drifting-block counts.</summary>
public float BackgroundBlockDensity { get; set; } = 1f;
/// <summary>Opacity of the drifting cosmetic background blocks, from invisible to opaque.</summary>
public float BackgroundBlockOpacity { get; set; } = 1f;
/// <summary>Multiplier for background-block acceleration and maximum drift speed.</summary>
public float BackgroundDriftSpeed { get; set; } = 1f;
/// <summary>Optional weighted preference for newly chosen drift directions.</summary>
public BackgroundDriftBias BackgroundDriftBias { get; set; }
/// <summary>Repeating two-colour pixel pattern used by the playfield.</summary>
public PlayfieldPattern PlayfieldPattern { get; set; }
public int PlayfieldCellScale { get; set; } = 1;
public IReadOnlyList<string> PlayfieldPatternRows { get; set; }
/// <summary>Optional alternate playfield appearance painted inside each authored logical rectangle.</summary>
public bool AlternatePlayfieldEnabled { get; set; }
public Color? AlternateCheckerboardColor { get; set; }
public Color? AlternateCheckerboardSecondColor { get; set; }
public PlayfieldPattern AlternatePlayfieldPattern { get; set; }
public int AlternatePlayfieldCellScale { get; set; } = 1;
public IReadOnlyList<string> AlternatePlayfieldPatternRows { get; set; }
public IReadOnlyList<RectF> AlternatePlayfieldRects { get; set; } = System.Array.Empty<RectF>();
/// <summary>Line-of-sight blocking rectangles (logical-space) — the rects of the solid
/// <see cref="Obstacles"/> flagged (per-obstacle, in the editor) to block vision. Each casts an
/// opaque shadow from the player's position, covering whatever is behind it with the wall colour
/// (see <see cref="VisionOccluder"/>). Empty by default (no obstacle blocks vision).</summary>
public IReadOnlyList<RectF> VisionBlockers { get; set; } = System.Array.Empty<RectF>();
/// <summary>FENCE rects — the rects of the <see cref="Obstacles"/> flagged (per-obstacle, in the
/// editor) as fences: solid ONLY to blocks. A fence stops/slams blocks, blocks can't slip,
/// teleport or be summoned into it, and it counts toward enclosing a block — but the player,
/// lasers, hazards (bullets/fireballs/teardrops) and fields (magnet/wind) all pass straight
/// through it. Fences never merge with normal obstacles or arena walls (no face cutting — the
/// wall behind one keeps its full spikeable face), only visually with each other, and can never
/// be spiked. Matched by value against <see cref="Obstacles"/>, like
/// <see cref="VisionBlockers"/>. Empty by default (no obstacle is a fence).</summary>
public IReadOnlyList<RectF> Fences { get; set; } = System.Array.Empty<RectF>();
/// <summary>The level's ONE fence colour: the fence BORDER draws it directly and the bars derive
/// a brighter version (<see cref="LevelCosmeticsRandomizer.FenceBarColor"/>). Null = the default
/// warm amber (<see cref="LevelCosmeticsRandomizer.DefaultFenceColor"/>). Rolled alongside the
/// other palette colours by the cosmetics randomizer (complement of the background hue).</summary>
public Color? FenceColor { get; set; }
/// <summary>GLASS rects — the rects of the <see cref="Obstacles"/> flagged (per-obstacle, in the
/// editor) as glass: solid ONLY to players. Every character collides with a glass panel —
/// including the wall-phasing wrap character, which does NOT wrap across it — while blocks,
/// hazards, lasers, fields and player-launched projectiles all pass straight through. Authored
/// spikes on its sides ARE allowed (player-lethal, drawn slightly tinted since blocks slide over
/// them harmlessly). Glass never merges with normal obstacles or arena walls; two flush glass
/// panels merge visually with each other. Matched by value against <see cref="Obstacles"/>,
/// like <see cref="Fences"/>. Empty by default.</summary>
public IReadOnlyList<RectF> Glass { get; set; } = System.Array.Empty<RectF>();
/// <summary>The level's ONE glass colour: the translucent pane tint, its reflection streaks and
/// its solid 1px border all derive from it. Null = the default lavender
/// (<see cref="LevelCosmeticsRandomizer.DefaultGlassColor"/>). Rolled alongside the other palette
/// colours by the cosmetics randomizer (a quarter-turn from the background hue).</summary>
public Color? GlassColor { get; set; }
/// <summary>Optional decorative FALLING-SQUARE ambience (rain / snow / embers): simple coloured
/// squares emitted from one arena edge that fly under world-vertical gravity and are destroyed by
/// the first solid they meet (interior obstacles, glass, live blocks and the arena walls — the
/// emit edge itself only once first cleared, so returning embers splash on their own floor;
/// never fences), optionally bursting into a small spray along the struck surface's normal.
/// Purely cosmetic: rendered between the drifting backdrop and the fences, driven entirely by the
/// BACKGROUND Rng stream, absent from level previews. The settings below are only meaningful when
/// this is true (the editor hides them otherwise). See <see cref="BackgroundParticle"/>.</summary>
public bool BackgroundParticlesEnabled { get; set; }
/// <summary>Arena edge the particles emit from (<see cref="Direction.Up"/> = the top edge, the
/// default). Spawn positions are drawn uniformly from the edge span, skipping stretches covered
/// by obstacles/glass flush with that edge — the emission line never moves off the true arena
/// edge (unlike solar sunlight, which re-emerges under ceiling-flush obstacles).</summary>
public Direction BackgroundParticleEdge { get; set; } = Direction.Up;
/// <summary>Square colour. Null = the default soft blue-grey
/// (<see cref="BackgroundParticleSettings.DefaultColor"/>).</summary>
public Color? BackgroundParticleColor { get; set; }
/// <summary>Opacity of the squares (and their impact spray), from near-invisible to opaque.</summary>
public float BackgroundParticleOpacity { get; set; } = 1f;
/// <summary>Square side length range in logical pixels; each particle rolls a size in
/// [Min, Max].</summary>
public int BackgroundParticleSizeMin { get; set; } = 1;
public int BackgroundParticleSizeMax { get; set; } = 2;
/// <summary>Degrees the emit direction tilts from straight-inward (positive = counter-clockwise,
/// so a top-edge emitter drifts LEFT on screen for positive values), plus the random tilt applied
/// per particle either side of it.</summary>
public float BackgroundParticleAngle { get; set; }
public float BackgroundParticleAngleRange { get; set; }
/// <summary>Starting speed range in px/s; each particle rolls a speed in [Min, Max].</summary>
public float BackgroundParticleSpeedMin { get; set; } = 90f;
public float BackgroundParticleSpeedMax { get; set; } = 140f;
/// <summary>World-vertical acceleration in px/s², positive = downward — SIGNED and always along
/// world Y regardless of the emit edge, so bottom-edge embers keep rising with a negative value
/// while side-edge rain arcs down with a positive one.</summary>
public float BackgroundParticleGravity { get; set; } = 60f;
/// <summary>Particles emitted per second.</summary>
public float BackgroundParticleSpawnRate { get; set; } = 12f;
/// <summary>Trail length: extra sprites rendered in the grid cells a square most recently
/// vacated, forming a streak — rain lines. 0 = no trail.</summary>
public int BackgroundParticleTrailLength { get; set; }
/// <summary>Trail colour. Null = the square colour (like the impact colour).</summary>
public Color? BackgroundParticleTrailColor { get; set; }
/// <summary>True (default) = trail segments fade toward the tail (comet look); false = the whole
/// streak renders at the particle opacity (solid line).</summary>
public bool BackgroundParticleTrailFade { get; set; } = true;
/// <summary>Impact spray: when enabled, a particle destroyed by a solid bursts into short-lived
/// fading motes sprayed along the struck surface's outward normal — rain splashes. Each impact
/// rolls a fragment count in [CountMin, CountMax] (a 0 roll = none this time), and each fragment
/// rolls its speed and square size from their own [Min, Max] ranges. Gravity follows the same
/// signed world-vertical convention as the main squares; the angle range is the random tilt
/// either side of the normal. A null colour falls back to the square colour.</summary>
public bool BackgroundParticleImpactEnabled { get; set; }
public int BackgroundParticleImpactCountMin { get; set; } = 2;
public int BackgroundParticleImpactCountMax { get; set; } = 4;
public float BackgroundParticleImpactSpeedMin { get; set; } = 15f;
public float BackgroundParticleImpactSpeedMax { get; set; } = 40f;
public int BackgroundParticleImpactSizeMin { get; set; } = 1;
public int BackgroundParticleImpactSizeMax { get; set; } = 2;
public float BackgroundParticleImpactGravity { get; set; } = 150f;
public float BackgroundParticleImpactAngleRange { get; set; } = 60f;
public Color? BackgroundParticleImpactColor { get; set; }
/// <summary>Collectible coin centres (logical-space points). Each spawns a <see cref="Coin"/> —
/// a 4x6 pickup worth <see cref="ScoreCalc.COIN_SCORE"/> — at stage enter. Spawning consumes no
/// authoritative Rng. Author responsibility: don't bury a coin inside a solid obstacle or glass
/// (both are player-solid, so it would be uncollectable). Empty by default.</summary>
public IReadOnlyList<Vector2> Coins { get; set; } = System.Array.Empty<Vector2>();
/// <summary>When true (default), the resolved block types are shuffled before being assigned to
/// the chosen positions (the classic behaviour). False assigns them in listed order for exact
/// authored placement.</summary>
public bool ShuffleTypes { get; set; } = true;
/// <summary>Mounted asset path this level was loaded from, for editor-only browser metadata.</summary>
[JsonIgnore]
public string SourcePath { get; set; }
/// <summary>Best-effort UTC file timestamp used by the editor browser's recent sort.</summary>
[JsonIgnore]
public long SourceFileTimeUtc { get; set; }
/// <summary>True for a player-made level loaded from the local game data folder in a STANDALONE
/// build (see <see cref="LocalLevels"/>), false for every shipped <c>Assets/levels</c> JSON.</summary>
[JsonIgnore]
public bool IsLocal { get; set; }
/// <summary>True for an installed Steam Workshop level (see <see cref="WorkshopLevels"/>). These
/// register under a <c>ws{fileId}</c> id, never appear in editor pickers, and key their leaderboard
/// stat with <see cref="WorkshopContentHash"/>.</summary>
[JsonIgnore]
public bool IsWorkshop { get; set; }
/// <summary>8-hex content hash of the installed workshop level JSON. Part of the leaderboard stat
/// name, so an author republish starts a fresh board; replays are watchable only when the local
/// copy's hash matches the run's recorded hash.</summary>
[JsonIgnore]
public string WorkshopContentHash { get; set; }
/// <summary>8-hex hash identifying this exact level revision, recorded into every run
/// (<see cref="RunData.LevelHash"/>) so a replay greys out instead of silently desyncing when the
/// level it resolves at watch time no longer matches the one it was played on (an edited level,
/// a republished workshop item, a re-rolled daily). Workshop levels reuse
/// <see cref="WorkshopContentHash"/> (the raw installed file, byte-identical on every client);
/// everything else lazily hashes its canonical wire JSON with LF line endings, independent of
/// the serializer's platform newline. Cached — defs never mutate once built, with one exception: Music,
/// which a daily def picks up lazily after generation (DailyLevels.WithMusic) and which changes
/// per-day when songs are added to the pool. Music and every other PURELY VISUAL field are blanked
/// out of the hash (EditorLevel.ClearCosmetics) so it covers sim input alone — otherwise the hash
/// would depend on WHEN it was computed, and re-rolling a palette or growing the song pool would
/// retroactively retire replays whose layouts are identical. Workshop levels are the exception:
/// they hash the raw installed FILE, so a cosmetic-only republish still rolls their board.</summary>
[JsonIgnore]
public string ContentHash
{
get
{
if ( IsWorkshop && !string.IsNullOrEmpty( WorkshopContentHash ) )
return WorkshopContentHash;
// Both must be populated: hotload may preserve a pre-normalization _contentHash alone.
if ( _contentHash is null || _contentHashCrlf is null )
{
var wire = EditorLevel.FromLevelDef( this );
wire.Music = "";
wire.ClearCosmetics();
string json = wire.ToJsonString().Replace( "\r\n", "\n" );
_contentHash = WorkshopLevels.ContentHash( json );
_contentHashCrlf = WorkshopLevels.ContentHash( json.Replace( "\n", "\r\n" ) );
}
return _contentHash;
}
}
[JsonIgnore]
private string _contentHash;
[JsonIgnore]
private string _contentHashCrlf;
/// <summary>Accept both historical serializer newline variants for registry/daily replays.
/// Workshop hashes still identify the exact installed file, without newline normalization.</summary>
public bool MatchesContentHash( string hash )
{
if ( IsWorkshop && !string.IsNullOrEmpty( WorkshopContentHash ) )
return WorkshopContentHash == hash;
return ContentHash == hash || _contentHashCrlf == hash;
}
/// <summary>Number of blocks this level spawns: the random set plus any pinned blocks.</summary>
public int Count => (Blocks?.Count ?? EffectivePoolBlockCount) + PinnedCount;
/// <summary>How many spawn slots carry a pinned block.</summary>
public int PinnedCount => SpawnPins?.Count( p => p is not null ) ?? 0;
private int EffectivePoolBlockCount => System.Math.Clamp( BlockCount, 0, Pool?.Count ?? 0 );
/// <summary>
/// Resolve the concrete spawn set for a fresh run, drawing from the authoritative <see cref="Rng"/>.
/// MUST be called immediately after the run's reseed and consume Rng identically for a given level,
/// so a replay (which reseeds and re-runs this) reproduces the same layout. The outputs are aligned:
/// block <c>i</c> is <paramref name="types"/>[i] with start state <paramref name="configs"/>[i],
/// initial travel <paramref name="directions"/>[i], and enclosed-ability timing
/// <paramref name="abilityTimers"/>[i] at <paramref name="positions"/>[i].
/// </summary>
/// <remarks>Pool entries are consumed as they are selected, so a type can only repeat when the
/// authored pool contains that type more than once. Malformed definitions requesting more entries
/// than are available are capped to the pool size instead of aborting stage entry.</remarks>
public void ResolveSpawns( out List<BlockType> types, out List<Vector2> positions, out List<BlockStart> configs,
out List<BlockAbilityTimer> abilityTimers, out List<Direction> directions, out List<TurnMode> turnModes )
{
// 1. Resolve the block instances (type + optional start config), as parallel lists.
var instTypes = new List<BlockType>();
var instConfigs = new List<BlockStart>();
if ( Blocks is not null )
{
for ( int i = 0; i < Blocks.Count; i++ )
{
instTypes.Add( Blocks[i] );
instConfigs.Add( BlockStartAt( i ) );
}
}
else
{
int drawCount = EffectivePoolBlockCount;
if ( drawCount != BlockCount )
Log.Warning( $"[BlockParty] level '{Id}': requested {BlockCount} blocks from {Pool?.Count ?? 0} pool entries; spawning {drawCount}." );
// Consume pool entries as they are drawn. Repeated types therefore require repeated entries.
var availableTypes = Pool is null ? new List<BlockType>() : new List<BlockType>( Pool );
// Pool mode: every drawn block shares PoolStartPhase (per-instance pressed sides don't map
// to random draws). Reuse ONE config instance (blocks never mutate it).
var poolStart = PoolStartPhase != 0 ? new BlockStart { Phase = PoolStartPhase } : null;
for ( int i = 0; i < drawCount; i++ )
{
int poolIndex = Rng.Int( 0, availableTypes.Count );
instTypes.Add( availableTypes[poolIndex] );
availableTypes.RemoveAt( poolIndex );
instConfigs.Add( poolStart );
}
}
int n = instTypes.Count;
// 2. Candidate slots. Pinned slots ALWAYS spawn their authored block; free slots form the
// candidate pool for the random instances and are randomly reduced to the instance count.
// The no-pin path is byte-identical to the classic random discard (an all-free list maps
// index i -> value i, so the chosen free index equals a plain Rng.Int over the full count).
var slotPos = new List<Vector2>( SpawnPositions ?? (IReadOnlyList<Vector2>)System.Array.Empty<Vector2>() );
var slotPin = new List<PinnedBlock>( slotPos.Count );
var slotTimer = new List<BlockAbilityTimer>( slotPos.Count );
var slotDirection = new List<Direction>( slotPos.Count );
var slotTurnMode = new List<TurnMode>( slotPos.Count );
int free = 0;
for ( int i = 0; i < slotPos.Count; i++ )
{
slotPin.Add( PinAt( i ) );
slotTimer.Add( AbilityTimerAt( i ) );
slotDirection.Add( SpawnDirectionAt( i ) );
slotTurnMode.Add( SpawnTurnModeAt( i ) );
if ( slotPin[i] is null ) free++;
}
while ( free > n )
{
int idx = RandomFreeIndex( slotPin );
slotPos.RemoveAt( idx );
slotPin.RemoveAt( idx );
slotTimer.RemoveAt( idx );
slotDirection.RemoveAt( idx );
slotTurnMode.RemoveAt( idx );
free--;
}
// 3. Shuffle the random instances (types + configs together, consuming Rng exactly like the
// classic types.Shuffle()) and assign them to the surviving free slots in listed order;
// pinned slots emit their authored block. Output stays slot-ordered.
if ( ShuffleTypes ) ShufflePaired( instTypes, instConfigs );
types = new List<BlockType>( slotPos.Count );
configs = new List<BlockStart>( slotPos.Count );
positions = new List<Vector2>( slotPos.Count );
abilityTimers = new List<BlockAbilityTimer>( slotPos.Count );
directions = new List<Direction>( slotPos.Count );
turnModes = new List<TurnMode>( slotPos.Count );
int r = 0;
for ( int s = 0; s < slotPos.Count; s++ )
{
if ( slotPin[s] is PinnedBlock pin )
{
types.Add( pin.Type );
configs.Add( new BlockStart { Phase = pin.Phase, PressedSides = pin.PressedSides } );
positions.Add( slotPos[s] );
abilityTimers.Add( slotTimer[s] );
directions.Add( slotDirection[s] );
turnModes.Add( slotTurnMode[s] );
continue;
}
if ( r >= n ) continue; // more free slots than random blocks (shouldn't happen post-reduce)
types.Add( instTypes[r] );
configs.Add( instConfigs[r] );
positions.Add( slotPos[s] );
abilityTimers.Add( slotTimer[s] );
directions.Add( slotDirection[s] );
turnModes.Add( slotTurnMode[s] );
r++;
}
// Random instances with no free slot left (authoring error: more blocks than free spawns) are
// appended type-only so the caller's "fewer positions than blocks" warning fires.
for ( ; r < n; r++ )
{
types.Add( instTypes[r] );
configs.Add( instConfigs[r] );
}
}
private BlockStart BlockStartAt( int i )
=> BlockStarts is not null && i < BlockStarts.Count ? BlockStarts[i] : null;
private PinnedBlock PinAt( int i )
=> SpawnPins is not null && i < SpawnPins.Count ? SpawnPins[i] : null;
private BlockAbilityTimer AbilityTimerAt( int i )
=> SpawnAbilityTimers is not null && i < SpawnAbilityTimers.Count ? SpawnAbilityTimers[i] : null;
private Direction SpawnDirectionAt( int i )
=> SpawnDirections is not null && i < SpawnDirections.Count ? SpawnDirections[i] : Direction.None;
private TurnMode SpawnTurnModeAt( int i )
=> SpawnTurnModes is not null && i < SpawnTurnModes.Count ? SpawnTurnModes[i] : TurnMode.Free;
/// <summary>Pick a FREE (unpinned) slot index to discard. With no pins present this returns
/// exactly <c>Rng.Int(0, count)</c> (byte-identical to the classic random discard).</summary>
private static int RandomFreeIndex( List<PinnedBlock> pins )
{
int free = 0;
foreach ( var p in pins ) if ( p is null ) free++;
int pick = Rng.Int( 0, free );
for ( int i = 0; i < pins.Count; i++ )
{
if ( pins[i] is not null ) continue;
if ( pick-- == 0 ) return i;
}
return pins.Count - 1; // unreachable (callers only discard while a free slot exists)
}
/// <summary>Fisher-Yates over two parallel lists in lockstep, consuming Rng identically to
/// <see cref="Extensions.Shuffle{T}"/> on a single list (so it preserves the classic type order).</summary>
private static void ShufflePaired( List<BlockType> a, List<BlockStart> b )
{
int n = a.Count;
while ( n > 1 )
{
n--;
int k = Rng.Int( 0, n + 1 );
(a[k], a[n]) = (a[n], a[k]);
(b[k], b[n]) = (b[n], b[k]);
}
}
}
/// <summary>An authored block bound to one spawn position (see <see cref="LevelDef.SpawnPins"/>):
/// it always spawns there with this exact type, starting phase and pre-pressed sides, independent
/// of the level's random block set.</summary>
public sealed class PinnedBlock
{
public BlockType Type { get; set; }
/// <summary>Phase to spawn at (0..<see cref="Block.NUM_PHASES"/>-1). Clamped on apply.</summary>
public int Phase { get; set; }
/// <summary>Sides that start already pressed for the spawn phase.</summary>
public IReadOnlyList<Direction> PressedSides { get; set; } = System.Array.Empty<Direction>();
}
/// <summary>
/// Optional per-block starting state (see <see cref="LevelDef.BlockStarts"/> /
/// <see cref="LevelDef.PoolStartPhase"/>): the phase the block spawns at and the sides that start
/// pre-pressed for that phase. Applied via <see cref="Block.SetInitialPhase(int, IReadOnlyList{Direction})"/>
/// — pre-pressing marks the buttons down without scoring, so the block simply starts partway through
/// its current phase.
/// </summary>
public sealed class BlockStart
{
/// <summary>Phase to spawn at (0..<see cref="Block.NUM_PHASES"/>-1). Clamped on apply.</summary>
public int Phase { get; set; }
/// <summary>Sides that start already pressed for the spawn phase (below max phase). Empty = classic.</summary>
public IReadOnlyList<Direction> PressedSides { get; set; } = System.Array.Empty<Direction>();
}
/// <summary>Cadence for a slam-driven ability while its spawn is blocked on every side. For lane blocks
/// (Wind/Magnet) the same timings drive the lane on/off loop: Interval = lane-on (eyes open) time,
/// <see cref="ClosedTime"/> = lane-off (eyes shut) time.</summary>
public sealed class BlockAbilityTimer
{
public float Interval { get; set; } = Block.DEFAULT_ABILITY_INTERVAL;
public float Randomness { get; set; }
public float StartOffset { get; set; }
public float ClosedTime { get; set; } = Block.DEFAULT_ABILITY_CLOSED_TIME;
}
/// <summary>
/// One obstacle's permanent-spike authoring: the obstacle rect (matched by value against
/// <see cref="LevelDef.Obstacles"/>) and the outward side normals that start the level spiked.
/// See <see cref="LevelDef.SpikedObstacleSides"/>.
/// </summary>
public sealed class ObstacleSpikeSpec
{
/// <summary>The obstacle to spike — must equal one of the level's <see cref="LevelDef.Obstacles"/>
/// rects by value (share the same local variable when authoring).</summary>
public RectF Rect { get; set; }
/// <summary>Outward side normals to permanently spike (Left/Right/Up/Down). A side flush against an
/// arena wall has no face and is silently ignored.</summary>
public IReadOnlyList<Direction> Sides { get; set; } = System.Array.Empty<Direction>();
public ObstacleSpikeSpec() { }
public ObstacleSpikeSpec( RectF rect, params Direction[] sides )
{
Rect = rect;
Sides = sides;
}
}
/// <summary>
/// The static level registry. Ordered as shown on the level-select screen; <see cref="Classic"/>
/// (the original 5-block layout) is always first. Daily-challenge levels are NOT in this registry —
/// they're generated per day (see <see cref="DailyLevels"/>).
/// </summary>
public static class Levels
{
public const string ClassicId = "classic";
public const string TutorialId = "tutorial";
// The registry loads LAZILY on first access and RETRIES until a load actually finds levels, rather
// than eagerly in the static ctor: at boot the static ctor can run BEFORE the mounted filesystem is
// ready, which would cache an empty registry forever (Classic null -> NRE in Leaderboard). `_loaded`
// sticks only once a load finds something, so early accesses retry and a later access (FS ready)
// populates everything. Reload() uses the raw _byId lookup (GetRaw) to avoid re-entering EnsureLoaded.
private static bool _loaded;
private static bool _browserBuilt;
private static LevelDef _classic;
private static IReadOnlyList<LevelDef> _all = System.Array.Empty<LevelDef>();
private static IReadOnlyList<LevelDef> _browserLevels = System.Array.Empty<LevelDef>();
private static int _maxBlockCount;
private static Dictionary<string, LevelDef> _byId = new();
/// <summary>The original game: one of each block type, shuffled across the top row.</summary>
public static LevelDef Classic { get { EnsureLoaded(); return _classic; } }
/// <summary>Every level in level-select / registry order (map-ordered; repro levels excluded).</summary>
public static IReadOnlyList<LevelDef> All { get { EnsureLoaded(); return _all; } }
/// <summary>Every loaded level, for the editor's level browser. Mapped levels are included here for
/// callers that need the complete set, but the picker filters them out of its Other and Test views.</summary>
public static IReadOnlyList<LevelDef> BrowserLevels
{
get
{
EnsureLoaded();
// Self-heal after a hot-reload that ADDED this field without re-running Reload: the registry
// can be populated (from the pre-reload assembly) while _browserLevels is still its empty default.
// _browserBuilt is itself a new field (false after such a reload) so this fires exactly once.
if ( !_browserBuilt && _byId.Count > 0 ) Reload();
return _browserLevels ?? System.Array.Empty<LevelDef>();
}
}
/// <summary>Most blocks any level can spawn — bounds the replay-code block-tally field.</summary>
public static int MaxBlockCount { get { EnsureLoaded(); return _maxBlockCount; } }
/// <summary>Every loaded level (main + test/repro), unordered — for tooling such as the JSON dump
/// (the game normally goes through <see cref="Get"/> / <see cref="All"/>).</summary>
public static IReadOnlyCollection<LevelDef> Registry { get { EnsureLoaded(); return _byId.Values; } }
private static void EnsureLoaded()
{
if ( !_loaded ) Reload();
}
/// <summary>
/// (Re)load every level from <c>Assets/levels/*.json</c> (main) + <c>Assets/levels/test/*.json</c>
/// (repro), building the id lookup, the map-ordered <see cref="All"/> registry and
/// <see cref="MaxBlockCount"/>. Called lazily on first access (retrying until the mount is ready)
/// and again on the <c>reload_levels</c> ConCmd, so JSON edits apply without an editor restart. Run
/// <c>reload_map</c> FIRST if the level-select spine (<c>Assets/levelmap.json</c>) changed, since
/// <see cref="All"/> order derives from it. NOTE: the high-score dropdown caches level names —
/// re-enter it after a reload.
/// </summary>
public static void Reload()
{
var defs = LoadLevelJsonFiles( out var rootIds );
// Build into a LOCAL dict and swap it in at the end, so a concurrent read (e.g. the async
// leaderboard prefetch that touches Levels at boot) never observes a half-populated registry.
var byId = new Dictionary<string, LevelDef>();
foreach ( var d in defs )
if ( d is not null && !string.IsNullOrEmpty( d.Id ) )
{
if ( byId.TryGetValue( d.Id, out var existing ) )
{
Log.Warning( $"[BlockParty] duplicate level id '{d.Id}' in '{d.SourcePath}' ignored; " +
$"already loaded from '{existing.SourcePath}'." );
continue;
}
byId.Add( d.Id, d );
}
// STANDALONE: fold in the player's local level files (the data folder's levels/ directory)
// so a saved creation can be re-opened by id and browsed in the picker's Local view. Shipped
// ids always win — a local file must never shadow map/leaderboard content. Only once the
// MOUNTED levels are in: `_loaded` latches on a non-empty registry, and local files alone
// must not end the boot-time retry while the asset mount is still warming up.
if ( byId.Count > 0 )
{
foreach ( var d in LocalLevels.LoadAll() )
{
// The ws{fileId} namespace belongs to installed workshop levels (folded in below):
// a local file there would win this fold-in and silently break that level's hashed
// boards/replays. LocalLevels.Save refuses creating these; a hand-dropped or pre-guard
// file gets refused here too, loudly.
if ( WorkshopLevels.IsWorkshopId( d.Id ) )
{
Log.Warning( $"[BlockParty] local level file '{d.SourcePath}' uses reserved workshop id '{d.Id}' — ignored (rename the file/id)." );
continue;
}
if ( !byId.ContainsKey( d.Id ) )
{
d.SourceFileTimeUtc = RecentTimeFor( d.Id );
byId.Add( d.Id, d );
}
}
// Installed workshop levels (data folder workshop/{fileId}.json copies) register too, so
// their boards and replays resolve without a Steam round-trip. Existing ids win, same rule.
foreach ( var d in WorkshopLevels.LoadAll() )
if ( !byId.ContainsKey( d.Id ) )
byId.Add( d.Id, d );
}
LevelDef Lookup( string id ) => byId.GetValueOrDefault( string.IsNullOrEmpty( id ) ? ClassicId : id );
// Level-select / registry order comes from the map manifest (each major then its side chains),
// so side levels interleave after their major; map-absent (test/repro) levels stay out of All.
var all = LevelMap.OrderedLevelIds
.Select( Lookup )
.Where( l => l is not null )
.ToList();
// Editor browser list: stable alphabetical order for every loaded level. The picker filters this
// down to non-map levels for its Other/Test views and can optionally re-sort by file timestamp.
var rootSeen = new HashSet<string>();
var browserLevels = new List<LevelDef>();
foreach ( var l in rootIds
.Select( Lookup )
.Where( l => l is not null )
.OrderBy( l => l.Name ?? l.Id, System.StringComparer.OrdinalIgnoreCase ) )
{
if ( rootSeen.Add( l.Id ) ) browserLevels.Add( l );
}
// Then the levels/test repro/test folder, also alphabetical. Workshop levels stay out of every
// picker view — they're browsed and played from the workshop screen only.
browserLevels.AddRange( byId.Values
.Where( l => l is not null && !l.IsWorkshop && !rootIds.Contains( l.Id ) && rootSeen.Add( l.Id ) )
.OrderBy( l => l.Name ?? l.Id, System.StringComparer.OrdinalIgnoreCase ) );
_byId = byId;
_classic = Lookup( ClassicId );
_all = all;
_browserLevels = browserLevels;
_maxBlockCount = MaxBlockCountOf( byId.Values );
// Mark loaded only once we actually found levels, so an early pre-mount call retries later.
_loaded = byId.Count > 0;
_browserBuilt = byId.Count > 0;
// Warn only when the levels folder is mounted but yielded nothing (a real problem) — NOT during
// early boot when the mount isn't ready yet (that path retries silently, so no log spam).
if ( byId.Count == 0 && FileSystem.Mounted.DirectoryExists( "levels" ) )
Log.Warning( "[BlockParty] no valid level JSON under Assets/levels." );
}
/// <summary>Whether the id belongs to a SHIPPED (mounted <c>Assets/levels</c>) level, as opposed to
/// a player's local file or nothing at all. Standalone saves refuse these ids — a local file with a
/// shipped id is skipped at load, so writing one would only strand an unloadable file.</summary>
public static bool IsShippedLevel( string id )
{
EnsureLoaded();
return !string.IsNullOrEmpty( id ) && _byId.TryGetValue( id, out var def ) && !def.IsLocal && !def.IsWorkshop;
}
// Most blocks any NON-workshop level spawns. Workshop levels are excluded because this bounds the
// replay-code block-tally field globally — ws share codes embed their level snapshot (see
// ReplayCodeCodec.Encode), so the codec bounds those from the snapshot instead, and a downloaded
// 100-block level must not raise the accepted bound for every pasted code.
private static int MaxBlockCountOf( IEnumerable<LevelDef> defs )
{
int max = 0;
foreach ( var l in defs )
if ( l is not null && !l.IsWorkshop && l.Count > max ) max = l.Count;
return max;
}
/// <summary>Register (or refresh) an installed workshop level in the live registry, id lookup ONLY:
/// workshop levels never join <see cref="All"/> (map order), the editor browser list, or the
/// <see cref="MaxBlockCount"/> bound. Copy-on-write like <see cref="ApplySavedLevel"/>, but without
/// its browser/timestamp side effects.</summary>
public static void RegisterWorkshop( LevelDef def )
{
if ( def is null || string.IsNullOrWhiteSpace( def.Id ) || !def.IsWorkshop ) return;
EnsureLoaded();
// A shipped/local id can never be overwritten; ws ids are digits-only after the prefix and
// LocalLevels refuses them, so a collision here means something is very wrong.
if ( _byId.TryGetValue( def.Id, out var existing ) && !existing.IsWorkshop )
{
Log.Warning( $"[BlockParty] workshop level id '{def.Id}' collides with a non-workshop level; ignored." );
return;
}
_byId = new Dictionary<string, LevelDef>( _byId ) { [def.Id] = def };
}
/// <summary>Apply a level that the editor assembly just wrote to disk. The asset mount can lag behind
/// the physical project file, so an immediate <see cref="Reload"/> may still read the old definition.</summary>
public static void ApplySavedLevel( LevelDef def )
{
if ( def is null || string.IsNullOrWhiteSpace( def.Id ) ) return;
EnsureLoaded();
def.SourcePath = string.IsNullOrWhiteSpace( def.SourcePath ) ? MountedLevelPathForId( def.Id ) : def.SourcePath;
def.SourceFileTimeUtc = RecentTimeFor( def.Id );
var byId = new Dictionary<string, LevelDef>( _byId ) { [def.Id] = def };
_byId = byId;
if ( _classic?.Id == def.Id ) _classic = def;
_all = _all.Select( level => level.Id == def.Id ? def : level ).ToArray();
var browser = _browserLevels.ToList();
int browserIndex = browser.FindIndex( level => level.Id == def.Id );
if ( browserIndex >= 0 ) browser[browserIndex] = def;
else browser.Add( def );
browser = browser
.OrderBy( level => level.Name ?? level.Id, System.StringComparer.OrdinalIgnoreCase )
.ToList();
_browserLevels = browser;
_maxBlockCount = MaxBlockCountOf( byId.Values );
_browserBuilt = true;
}
/// <summary>Remove a physically deleted level from the live registry. Called after
/// <c>reload_levels</c> because the mounted asset filesystem can briefly retain a deleted file.</summary>
public static void RemoveDeletedLevel( string id )
{
if ( string.IsNullOrWhiteSpace( id ) ) return;
EnsureLoaded();
if ( !_byId.ContainsKey( id ) ) return;
var byId = new Dictionary<string, LevelDef>( _byId );
byId.Remove( id );
_byId = byId;
if ( _classic?.Id == id ) _classic = null;
_all = _all.Where( level => level.Id != id ).ToArray();
_browserLevels = _browserLevels.Where( level => level.Id != id ).ToArray();
_maxBlockCount = MaxBlockCountOf( byId.Values );
_loaded = byId.Count > 0;
_browserBuilt = byId.Count > 0;
}
/// <summary>Refresh editor-browser sort timestamps from <see cref="EditorPrefs"/>. The editor assembly
/// owns the physical file stat calls and writes that metadata into editor settings.</summary>
public static void RefreshBrowserLevelTimesFromPrefs()
{
EnsureLoaded();
foreach ( var level in _byId.Values )
{
if ( level is null || string.IsNullOrEmpty( level.Id ) ) continue;
level.SourceFileTimeUtc = RecentTimeFor( level.Id );
}
}
// Load every level JSON: main levels from Assets/levels/*.json, repro/test levels from
// Assets/levels/test/*.json. Both feed the id lookup; only main levels reach All (via the map).
// The ids loaded from the ROOT folder are reported via <paramref name="rootIds"/> so the editor
// level browser can list root levels while excluding the test/repro folder.
private static List<LevelDef> LoadLevelJsonFiles( out HashSet<string> rootIds )
{
var result = new List<LevelDef>();
int before = result.Count;
LoadLevelDir( "levels", result );
rootIds = new HashSet<string>();
for ( int i = before; i < result.Count; i++ )
if ( result[i] is not null && !string.IsNullOrEmpty( result[i].Id ) )
rootIds.Add( result[i].Id );
LoadLevelDir( "levels/test", result );
return result;
}
private static void LoadLevelDir( string dir, List<LevelDef> into )
{
try
{
if ( !FileSystem.Mounted.DirectoryExists( dir ) )
return;
foreach ( var name in FileSystem.Mounted.FindFile( dir, "*.json" ) )
{
var path = $"{dir}/{name}";
try
{
var e = EditorLevel.FromJsonString( FileSystem.Mounted.ReadAllText( path ), out var err );
if ( e is null ) { Log.Warning( $"[BlockParty] level '{path}' skipped: {err}" ); continue; }
var def = e.ToLevelDef();
def.SourcePath = path;
def.SourceFileTimeUtc = RecentTimeFor( def.Id );
into.Add( def );
}
catch ( System.Exception ex )
{
Log.Warning( $"[BlockParty] level '{path}' skipped: {ex.Message}" );
}
}
}
catch ( System.Exception ex )
{
Log.Warning( $"[BlockParty] scanning '{dir}': {ex.Message}" );
}
}
private static string MountedLevelPathForId( string id )
=> TestLevels.IsTestLevel( id ) ? $"levels/test/{id}.json" : $"levels/{id}.json";
private static long RecentTimeFor( string id )
=> !string.IsNullOrEmpty( id )
&& EditorPrefs.Current.LevelBrowserLevelTimes is not null
&& EditorPrefs.Current.LevelBrowserLevelTimes.TryGetValue( id, out var ticks )
? ticks
: 0;
/// <summary>Console command: reload every level from the <c>Assets/levels</c> JSON files (run
/// <c>reload_map</c> first if the spine changed). Applies edits without an editor restart.</summary>
[ConCmd( "reload_levels" )]
public static void ReloadLevelsCmd()
{
if ( !Game.IsEditor ) return;
Reload();
Log.Info( $"[BlockParty] Reloaded {All.Count} level definitions." );
}
/// <summary>Console command: start any level by id, using the title-screen character. Works for
/// every loaded level (main or test/repro), not just the ones on the map. With no id (or an
/// unknown id) it logs the available ids instead of guessing.</summary>
[ConCmd( "play_level" )]
public static void PlayLevelCmd( string levelId = "" )
{
if ( !Game.IsEditor ) return;
var gm = GameManager.Instance;
if ( gm is null )
{
Log.Warning( "[BlockParty] play_level: no GameManager in the scene." );
return;
}
var def = string.IsNullOrWhiteSpace( levelId ) ? null : Get( levelId.Trim() );
if ( def is null )
{
var ids = string.Join( ", ", Registry.Select( d => d.Id ).OrderBy( s => s ) );
Log.Warning( $"[BlockParty] play_level: unknown level '{levelId}'. Available: {ids}" );
return;
}
gm.StartLevel( def.Id );
Log.Info( $"[BlockParty] Loading level '{def.Id}'." );
}
/// <summary>Resolve a level id to its definition. Null/empty maps to <see cref="Classic"/> (a
/// context with no level, e.g. a daily challenge); an unknown id returns null (an
/// unreplayable/unknown run).</summary>
public static LevelDef Get( string id )
{
// A transient (unsaved) editor TEST level shadows the registry by its id, so a level-editor
// test run resolves the in-progress edits without writing them to disk (see RegisterTransient).
if ( _transient is not null && !string.IsNullOrEmpty( id ) && _transient.Id == id )
return _transient;
EnsureLoaded();
return GetRaw( id );
}
// Transient level used only while the level editor is test-playing an unsaved level. It is NOT in
// the persistent registry (never appears in All / the map / dropdowns) and is cleared when the test
// ends. Get() checks it first so the live run resolves the in-progress edits by id.
private static LevelDef _transient;
/// <summary>Register a transient level that <see cref="Get"/> resolves by its id (shadowing any
/// registered level of the same id), without touching the persistent registry. Used by the level
/// editor's test-play so the run reflects the unsaved edits. Call <see cref="ClearTransient"/> when
/// the test ends.</summary>
public static void RegisterTransient( LevelDef def ) => _transient = def;
/// <summary>Clear the transient test-play level (see <see cref="RegisterTransient"/>).</summary>
public static void ClearTransient() => _transient = null;
// Raw id lookup with NO EnsureLoaded (used inside Reload, which is already loading, to avoid
// re-entrancy). Null/empty -> classic; unknown -> null.
private static LevelDef GetRaw( string id )
=> _byId.GetValueOrDefault( string.IsNullOrEmpty( id ) ? ClassicId : id );
/// <summary>Index of a level id in <see cref="All"/> (for UI dropdowns); classic/unknown → 0.</summary>
public static int IndexOf( string id )
{
var def = Get( id );
var all = All;
for ( int i = 0; i < all.Count; i++ )
if ( all[i] == def )
return i;
return 0;
}
}