Game/EditorLevel.cs
using System;
using System.Collections.Generic;
using System.Linq;
namespace BlockParty;
/// <summary>
/// A MUTABLE, editor-friendly mirror of <see cref="LevelDef"/> used by the in-editor
/// <see cref="LevelEditorStage"/>. <see cref="LevelDef"/> is immutable authored data; this class
/// holds the same information as editable lists so the editor can drag/toggle it in place, then
/// convert it via <see cref="ToJson"/> / <see cref="FromJson"/> (and the string forms
/// <see cref="ToJsonString"/> / <see cref="FromJsonString"/>) — the round-trip JSON form stored in
/// the <c>Assets/levels/*.json</c> files and used by the editor clipboard / paste import. The wire
/// shape is <see cref="LevelJson"/> (primitive fields only).
/// </summary>
public sealed class EditorLevel
{
/// <summary>Version of the <see cref="LevelJson"/> wire shape. Parsing is otherwise lenient
/// (missing keys default, unknown enum names skip), which would silently DEGRADE a layout authored
/// by a newer build — fatal for replay determinism once levels travel between players (workshop).
/// The reader refuses a higher <see cref="LevelJson.V"/> outright instead.</summary>
public const int SCHEMA_VERSION = 1;
// A fresh document starts with NO id/name: the project Save/Delete buttons stay inert until the
// level is deliberately named, so scratch work can't overwrite (or delete) a real level.
public string Id = "";
public string Name = "";
public string Music = "";
public float MusicVolume = 1f;
public bool IsTest;
/// <summary>Project level ID this editor document was loaded from or last saved as. Session-only:
/// used to distinguish updating the open level from overwriting a different level with the same ID.</summary>
public string ProjectSourceId;
/// <summary>False = exact <see cref="Blocks"/> list; true = <see cref="Pool"/> + <see cref="BlockCount"/>.</summary>
public bool UsePool = false;
/// <summary>Exact block list (type + optional starting phase / pre-pressed sides per entry).</summary>
public List<EditorBlock> Blocks = new();
/// <summary>Finite random pool. Each entry can be selected once; repeat a type to allow duplicates.</summary>
public List<BlockType> Pool = new();
public int BlockCount = 2;
/// <summary>Pool mode: starting phase applied to every drawn block (0 = classic).</summary>
public int PoolStartPhase = 0;
public List<Vector2> SpawnPositions = new();
/// <summary>Optional per-position PINNED BLOCK (type + phase + pressed sides), kept aligned with
/// <see cref="SpawnPositions"/> (null = free slot). A pinned slot ALWAYS spawns its authored
/// block, independent of and in addition to the random block count. Use <see cref="AddSpawn"/> /
/// <see cref="RemoveSpawnAt"/> so the lists stay in lockstep.</summary>
public List<EditorBlock> SpawnPins = new();
public List<BlockAbilityTimer> SpawnAbilityTimers = new();
public List<Direction> SpawnDirections = new();
public List<TurnMode> SpawnTurnModes = new();
/// <summary>Candidate player starts — always at least one (the editor never deletes the last).
/// The run picks one from the run Rng when there's more than one. Exactly two map to Twin 1 then
/// Twin 2; with three or more, Twins pick two distinct random starts.</summary>
public List<Vector2> PlayerSpawns = new() { new( 20f, 7f ) };
public string ForcedCharacterId;
public List<RectF> Obstacles = new();
public List<Direction> SpikedWalls = new();
/// <summary>Obstacle-face spikes, referencing an obstacle by its INDEX into <see cref="Obstacles"/>
/// (index-based so a spec follows its obstacle when the obstacle is moved/resized in the editor).</summary>
public List<ObstacleSpike> SpikedObstacleSides = new();
/// <summary>Indices into <see cref="Obstacles"/> whose rect also blocks line of sight (an obstacle
/// does NOT block vision by default). Index-based so a flag follows its obstacle when others are
/// added/removed (see <see cref="RemoveObstacleAt"/>); toggled per-obstacle in the editor.</summary>
public List<int> VisionObstacles = new();
/// <summary>Indices into <see cref="Obstacles"/> flagged as FENCES — obstacles solid ONLY to
/// blocks (the player, lasers, hazards and fields pass straight through; spikes are not
/// allowed on them, and they never merge with normal obstacles or arena walls). Mutually
/// exclusive with <see cref="VisionObstacles"/> per obstacle; index-based like the vision
/// flags so a flag follows its obstacle (see <see cref="RemoveObstacleAt"/>).</summary>
public List<int> FenceObstacles = new();
/// <summary>Indices into <see cref="Obstacles"/> flagged as GLASS — obstacles solid ONLY to
/// players (every character collides, including the wrap character; blocks, hazards, lasers,
/// fields and player projectiles pass through). Spikes ARE allowed on glass sides (player-only
/// hazard, tinted teeth). Mutually exclusive with the fence and vision flags per obstacle;
/// index-based like them (see <see cref="RemoveObstacleAt"/>).</summary>
public List<int> GlassObstacles = new();
/// <summary>Collectible coin centres (see <see cref="LevelDef.Coins"/>).</summary>
public List<Vector2> Coins = new();
public bool ShuffleTypes = true;
// ── daily-template authoring state (EDITOR SESSION ONLY — never serialized; lives here so it
// survives stage rebuilds/test-play round trips alongside the layout it describes) ─────────────
public string DailyTemplateName = "";
public bool DailyTemplateMirrorX;
public bool DailyTemplateAuthoredSpikes;
public float DailyTemplateWeight = 1f;
public Color? WallColor;
public Color? OutOfBoundsColor;
public Color? CheckerboardColor;
public Color? CheckerboardSecondColor;
public Color? BackgroundBlockColor;
/// <summary>Fence border colour (bars derive a brighter version); null = default amber.</summary>
public Color? FenceColor;
/// <summary>Glass colour (pane tint, streaks, border and teeth tint derive from it); null = default lavender.</summary>
public Color? GlassColor;
/// <summary>Exactly three, front → back, or null. Overrides <see cref="BackgroundBlockColor"/>.</summary>
public List<Color> BackgroundBlockColors = null;
public float BackgroundBlockScale = 1f;
public float BackgroundBlockDensity = 1f;
public float BackgroundBlockOpacity = 1f;
public float BackgroundDriftSpeed = 1f;
public BackgroundDriftBias BackgroundDriftBias;
public PlayfieldPattern PlayfieldPattern;
public int PlayfieldCellScale = 1;
public List<string> PlayfieldPatternRows = null;
public bool AlternatePlayfieldEnabled;
public Color? AlternateCheckerboardColor;
public Color? AlternateCheckerboardSecondColor;
public PlayfieldPattern AlternatePlayfieldPattern;
public int AlternatePlayfieldCellScale = 1;
public List<string> AlternatePlayfieldPatternRows = null;
public List<RectF> AlternatePlayfieldRects = new();
// ── background particle ambience (see LevelDef.BackgroundParticlesEnabled) ──────────────────────
public bool BackgroundParticlesEnabled;
public Direction BackgroundParticleEdge = Direction.Up;
public Color? BackgroundParticleColor;
public float BackgroundParticleOpacity = 1f;
public int BackgroundParticleSizeMin = 1;
public int BackgroundParticleSizeMax = 2;
public float BackgroundParticleAngle;
public float BackgroundParticleAngleRange;
public float BackgroundParticleSpeedMin = 90f;
public float BackgroundParticleSpeedMax = 140f;
public float BackgroundParticleGravity = 60f;
public float BackgroundParticleSpawnRate = 12f;
public int BackgroundParticleTrailLength;
public Color? BackgroundParticleTrailColor;
public bool BackgroundParticleTrailFade = true;
public bool BackgroundParticleImpactEnabled;
public int BackgroundParticleImpactCountMin = 2;
public int BackgroundParticleImpactCountMax = 4;
public float BackgroundParticleImpactSpeedMin = 15f;
public float BackgroundParticleImpactSpeedMax = 40f;
public int BackgroundParticleImpactSizeMin = 1;
public int BackgroundParticleImpactSizeMax = 2;
public float BackgroundParticleImpactGravity = 150f;
public float BackgroundParticleImpactAngleRange = 60f;
public Color? BackgroundParticleImpactColor;
/// <summary>Reset every PURELY VISUAL field to its default, so what remains is the sim input alone.
/// Used by <see cref="LevelDef.ContentHash"/>: a recorded run is re-validated against the level's
/// hash, and cosmetics must not retire replays whose layout is untouched (the same reason Music is
/// blanked there). Audio counts as cosmetic; the caller blanks Music itself.</summary>
public void ClearCosmetics()
{
MusicVolume = 1f;
WallColor = OutOfBoundsColor = CheckerboardColor = CheckerboardSecondColor = null;
BackgroundBlockColor = FenceColor = GlassColor = null;
BackgroundBlockColors = null;
BackgroundBlockScale = BackgroundBlockDensity = BackgroundBlockOpacity = BackgroundDriftSpeed = 1f;
BackgroundDriftBias = BackgroundDriftBias.None;
PlayfieldPattern = PlayfieldPattern.Checker;
PlayfieldCellScale = 1;
PlayfieldPatternRows = null;
// The alternate playfield is decoration over the SAME arena — its rects gate no collision.
AlternatePlayfieldEnabled = false;
AlternateCheckerboardColor = AlternateCheckerboardSecondColor = null;
AlternatePlayfieldPattern = PlayfieldPattern.Checker;
AlternatePlayfieldCellScale = 1;
AlternatePlayfieldPatternRows = null;
AlternatePlayfieldRects = new List<RectF>();
// Particles run on the Background Rng and never touch the sim.
BackgroundParticlesEnabled = false;
BackgroundParticleEdge = Direction.Up;
BackgroundParticleColor = BackgroundParticleTrailColor = BackgroundParticleImpactColor = null;
BackgroundParticleOpacity = 1f;
BackgroundParticleSizeMin = 1;
BackgroundParticleSizeMax = 2;
BackgroundParticleAngle = BackgroundParticleAngleRange = 0f;
BackgroundParticleSpeedMin = 90f;
BackgroundParticleSpeedMax = 140f;
BackgroundParticleGravity = 60f;
BackgroundParticleSpawnRate = 12f;
BackgroundParticleTrailLength = 0;
BackgroundParticleTrailFade = true;
BackgroundParticleImpactEnabled = false;
BackgroundParticleImpactCountMin = 2;
BackgroundParticleImpactCountMax = 4;
BackgroundParticleImpactSpeedMin = 15f;
BackgroundParticleImpactSpeedMax = 40f;
BackgroundParticleImpactSizeMin = 1;
BackgroundParticleImpactSizeMax = 2;
BackgroundParticleImpactGravity = 150f;
BackgroundParticleImpactAngleRange = 60f;
}
/// <summary>Number of RANDOM blocks this level would spawn (pinned blocks are extra).</summary>
public int Count => UsePool ? BlockCount : Blocks.Count;
/// <summary>Describes an invalid finite-pool count, or null when the block selection is valid.</summary>
public string PoolValidationError
{
get
{
if ( !UsePool ) return null;
if ( BlockCount < 0 ) return $"count {BlockCount} cannot be negative";
int poolCount = Pool?.Count ?? 0;
return BlockCount > poolCount
? $"count {BlockCount} exceeds {poolCount} pool entries; repeat a type in the pool to allow duplicates"
: null;
}
}
/// <summary>Spawn slots without a pinned block — what the random <see cref="Count"/> fills.</summary>
public int FreeSpawnCount => SpawnPositions.Count - SpawnPins.Count( p => p is not null );
/// <summary>True when every block this level spawns starts at max phase: the run is decided as won the
/// instant it begins (GameStage.TryBeginVictory). Pins always spawn; random draws (sharing
/// <see cref="PoolStartPhase"/> in pool mode) are capped by the free slots, as at runtime.</summary>
public bool StartsComplete()
{
int max = Block.NUM_PHASES - 1;
int spawned = 0;
foreach ( var pin in SpawnPins )
{
if ( pin is null ) continue;
if ( pin.Phase < max ) return false;
spawned++;
}
if ( UsePool )
{
int draws = Math.Min( Math.Clamp( BlockCount, 0, Pool.Count ), FreeSpawnCount );
if ( draws > 0 && PoolStartPhase < max ) return false;
spawned += draws;
}
else
{
int placed = Math.Min( Blocks.Count, FreeSpawnCount );
for ( int i = 0; i < placed; i++ )
if ( Blocks[i].Phase < max ) return false;
spawned += placed;
}
return spawned > 0;
}
/// <summary>Add a spawn position, keeping <see cref="SpawnPins"/> aligned (new slot free).
/// Returns the new index.</summary>
public int AddSpawn( Vector2 pos )
{
SpawnPositions.Add( pos );
SpawnPins.Add( null );
SpawnAbilityTimers.Add( null );
SpawnDirections.Add( Direction.None );
SpawnTurnModes.Add( TurnMode.Free );
return SpawnPositions.Count - 1;
}
/// <summary>Remove a spawn position and its aligned pin.</summary>
public void RemoveSpawnAt( int index )
{
if ( index < 0 || index >= SpawnPositions.Count ) return;
SpawnPositions.RemoveAt( index );
if ( index < SpawnPins.Count ) SpawnPins.RemoveAt( index );
if ( index < SpawnAbilityTimers.Count ) SpawnAbilityTimers.RemoveAt( index );
if ( index < SpawnDirections.Count ) SpawnDirections.RemoveAt( index );
if ( index < SpawnTurnModes.Count ) SpawnTurnModes.RemoveAt( index );
}
/// <summary>The block pinned to spawn slot <paramref name="index"/>, or null when free / out of range.</summary>
public EditorBlock SpawnPin( int index )
=> index >= 0 && index < SpawnPins.Count ? SpawnPins[index] : null;
/// <summary>Set (or clear, with null) the pinned block for spawn slot <paramref name="index"/>,
/// padding <see cref="SpawnPins"/> if needed to stay aligned.</summary>
public void SetSpawnPin( int index, EditorBlock pin )
{
if ( index < 0 ) return;
while ( SpawnPins.Count <= index ) SpawnPins.Add( null );
SpawnPins[index] = pin;
}
public Direction SpawnDirection( int index )
=> index >= 0 && index < SpawnDirections.Count ? SpawnDirections[index] : Direction.None;
public void SetSpawnDirection( int index, Direction direction )
{
if ( index < 0 || index >= SpawnPositions.Count ) return;
while ( SpawnDirections.Count <= index ) SpawnDirections.Add( Direction.None );
SpawnDirections[index] = direction;
}
public TurnMode SpawnTurnMode( int index )
=> index >= 0 && index < SpawnTurnModes.Count ? SpawnTurnModes[index] : TurnMode.Free;
public void SetSpawnTurnMode( int index, TurnMode mode )
{
if ( index < 0 || index >= SpawnPositions.Count ) return;
while ( SpawnTurnModes.Count <= index ) SpawnTurnModes.Add( TurnMode.Free );
SpawnTurnModes[index] = mode;
}
public float SpawnAbilityInterval( int index )
=> SpawnAbilityTimerAt( index )?.Interval ?? Block.DEFAULT_ABILITY_INTERVAL;
public float SpawnAbilityRandomness( int index )
=> SpawnAbilityTimerAt( index )?.Randomness ?? 0f;
public float SpawnAbilityStartOffset( int index )
=> SpawnAbilityTimerAt( index )?.StartOffset ?? 0f;
public float SpawnAbilityClosedTime( int index )
=> SpawnAbilityTimerAt( index )?.ClosedTime ?? Block.DEFAULT_ABILITY_CLOSED_TIME;
public void SetSpawnAbilityTimer( int index, float interval, float randomness, float startOffset, float closedTime )
{
if ( index < 0 || index >= SpawnPositions.Count ) return;
while ( SpawnAbilityTimers.Count <= index ) SpawnAbilityTimers.Add( null );
interval = MathF.Max( 0.05f, interval );
randomness = Math.Clamp( randomness, 0f, MathF.Max( 0f, interval - 0.05f ) );
startOffset = Math.Clamp( startOffset, 0f, interval );
closedTime = MathF.Max( 0f, closedTime );
SpawnAbilityTimers[index] = MathF.Abs( interval - Block.DEFAULT_ABILITY_INTERVAL ) < 0.001f
&& randomness < 0.001f && startOffset < 0.001f
&& MathF.Abs( closedTime - Block.DEFAULT_ABILITY_CLOSED_TIME ) < 0.001f
? null
: new BlockAbilityTimer { Interval = interval, Randomness = randomness, StartOffset = startOffset, ClosedTime = closedTime };
}
public bool SpawnBlockedInEveryDirection( int index )
{
if ( index < 0 || index >= SpawnPositions.Count ) return false;
foreach ( Direction direction in Globals.GetAllDirections() )
if ( !SpawnBlocked( index, direction ) ) return false;
return true;
}
bool SpawnBlocked( int index, Direction direction )
{
const float half = 20f;
Vector2 center = SpawnPositions[index] + Globals.GetVectorForDirection( direction );
var probe = new RectF( center.x - half, center.y - half, center.x + half, center.y + half );
if ( direction == Direction.Left && probe.Left < Arena.WALL_SIZE ) return true;
if ( direction == Direction.Right && probe.Right > Arena.WIDTH - Arena.WALL_SIZE ) return true;
if ( direction == Direction.Down && probe.Bottom < Arena.WALL_SIZE ) return true;
if ( direction == Direction.Up && probe.Top > Arena.HEIGHT - Arena.WALL_SIZE ) return true;
for ( int i = 0; i < Obstacles.Count; i++ )
{
if ( ObstacleIsGlass( i ) ) continue; // glass is solid only to players — it can't box a block in
if ( probe.Intersects( Obstacles[i] ) ) return true;
}
return false;
}
BlockAbilityTimer SpawnAbilityTimerAt( int index )
=> index >= 0 && index < SpawnAbilityTimers.Count ? SpawnAbilityTimers[index] : null;
/// <summary>Remove a player spawn — refused when it's the last one (a level always keeps at
/// least one player spawn).</summary>
public void RemovePlayerSpawnAt( int index )
{
if ( index < 0 || index >= PlayerSpawns.Count || PlayerSpawns.Count <= 1 ) return;
PlayerSpawns.RemoveAt( index );
}
public void RemoveCoinAt( int index )
{
if ( index < 0 || index >= Coins.Count ) return;
Coins.RemoveAt( index );
}
/// <summary>Remove an obstacle and keep the obstacle-spike specs consistent (drop specs on the
/// removed obstacle; shift higher indices down).</summary>
public void RemoveObstacleAt( int index )
{
if ( index < 0 || index >= Obstacles.Count ) return;
Obstacles.RemoveAt( index );
SpikedObstacleSides.RemoveAll( s => s.Obstacle == index );
foreach ( var s in SpikedObstacleSides )
if ( s.Obstacle > index ) s.Obstacle--;
// Keep the vision-blocking and fence flags aligned with the shifted obstacle indices.
VisionObstacles.RemoveAll( i => i == index );
for ( int k = 0; k < VisionObstacles.Count; k++ )
if ( VisionObstacles[k] > index ) VisionObstacles[k]--;
FenceObstacles.RemoveAll( i => i == index );
for ( int k = 0; k < FenceObstacles.Count; k++ )
if ( FenceObstacles[k] > index ) FenceObstacles[k]--;
GlassObstacles.RemoveAll( i => i == index );
for ( int k = 0; k < GlassObstacles.Count; k++ )
if ( GlassObstacles[k] > index ) GlassObstacles[k]--;
}
/// <summary>Toggle one outward-normal spike on an obstacle face (adds a spec if needed, removes the
/// spec entirely when its last side is toggled off).</summary>
public void ToggleObstacleSpike( int obstacleIndex, Direction side )
{
var spec = SpikedObstacleSides.FirstOrDefault( s => s.Obstacle == obstacleIndex );
if ( spec is null )
{
SpikedObstacleSides.Add( new ObstacleSpike { Obstacle = obstacleIndex, Sides = { side } } );
return;
}
if ( spec.Sides.Contains( side ) )
{
spec.Sides.Remove( side );
if ( spec.Sides.Count == 0 )
SpikedObstacleSides.Remove( spec );
}
else
{
spec.Sides.Add( side );
}
}
public bool ObstacleSpiked( int obstacleIndex, Direction side )
=> SpikedObstacleSides.Any( s => s.Obstacle == obstacleIndex && s.Sides.Contains( side ) );
/// <summary>True when obstacle <paramref name="index"/> is flagged to block line of sight.</summary>
public bool ObstacleBlocksVision( int index ) => VisionObstacles.Contains( index );
/// <summary>Toggle whether obstacle <paramref name="index"/> blocks line of sight. Turning it on
/// clears the fence and glass flags — those are see-through by definition.</summary>
public void ToggleObstacleVision( int index )
{
if ( index < 0 || index >= Obstacles.Count ) return;
if ( !VisionObstacles.Remove( index ) )
{
VisionObstacles.Add( index );
FenceObstacles.Remove( index );
GlassObstacles.Remove( index );
}
}
/// <summary>True when obstacle <paramref name="index"/> is flagged as a fence (solid only to blocks).</summary>
public bool ObstacleIsFence( int index ) => FenceObstacles.Contains( index );
/// <summary>Toggle whether obstacle <paramref name="index"/> is a fence. Turning it on clears the
/// vision and glass flags (mutually exclusive) and strips any authored spikes — fences can't be
/// spiked (the player never touches a fence, so a spiked face would be meaningless).</summary>
public void ToggleObstacleFence( int index )
{
if ( index < 0 || index >= Obstacles.Count ) return;
if ( !FenceObstacles.Remove( index ) )
{
FenceObstacles.Add( index );
VisionObstacles.Remove( index );
GlassObstacles.Remove( index );
SpikedObstacleSides.RemoveAll( s => s.Obstacle == index );
}
}
/// <summary>True when obstacle <paramref name="index"/> is flagged as glass (solid only to players).</summary>
public bool ObstacleIsGlass( int index ) => GlassObstacles.Contains( index );
/// <summary>Toggle whether obstacle <paramref name="index"/> is glass. Turning it on clears the
/// fence and vision flags (mutually exclusive). Authored spikes are KEPT — glass sides may be
/// spiked (a player-only hazard, drawn with tinted teeth).</summary>
public void ToggleObstacleGlass( int index )
{
if ( index < 0 || index >= Obstacles.Count ) return;
if ( !GlassObstacles.Remove( index ) )
{
GlassObstacles.Add( index );
VisionObstacles.Remove( index );
FenceObstacles.Remove( index );
}
}
// ── conversion to/from the immutable LevelDef ───────────────────────────────────────────────────
public static EditorLevel FromLevelDef( LevelDef d )
{
var e = new EditorLevel
{
Id = d.Id ?? "my-level",
Name = d.Name ?? "MY LEVEL",
Music = d.Music ?? "",
MusicVolume = d.MusicVolume,
IsTest = d.IsTest,
ProjectSourceId = d.Id,
PlayerSpawns = d.PlayerSpawns is { Count: > 0 } ? d.PlayerSpawns.ToList() : new List<Vector2> { new( 16f, 40f ) },
ForcedCharacterId = d.ForcedCharacterId,
ShuffleTypes = d.ShuffleTypes,
WallColor = d.WallColor,
OutOfBoundsColor = d.OutOfBoundsColor,
CheckerboardColor = d.CheckerboardColor,
CheckerboardSecondColor = d.CheckerboardSecondColor,
BackgroundBlockColor = d.BackgroundBlockColor,
FenceColor = d.FenceColor,
GlassColor = d.GlassColor,
BackgroundBlockScale = d.BackgroundBlockScale,
BackgroundBlockDensity = d.BackgroundBlockDensity,
BackgroundBlockOpacity = d.BackgroundBlockOpacity,
BackgroundDriftSpeed = d.BackgroundDriftSpeed,
BackgroundDriftBias = d.BackgroundDriftBias,
PlayfieldPattern = d.PlayfieldPattern,
PlayfieldCellScale = d.PlayfieldCellScale,
PlayfieldPatternRows = d.PlayfieldPatternRows?.ToList(),
AlternatePlayfieldEnabled = d.AlternatePlayfieldEnabled,
AlternateCheckerboardColor = d.AlternateCheckerboardColor,
AlternateCheckerboardSecondColor = d.AlternateCheckerboardSecondColor,
AlternatePlayfieldPattern = d.AlternatePlayfieldPattern,
AlternatePlayfieldCellScale = d.AlternatePlayfieldCellScale,
AlternatePlayfieldPatternRows = d.AlternatePlayfieldPatternRows?.ToList(),
AlternatePlayfieldRects = ( d.AlternatePlayfieldRects ?? Array.Empty<RectF>() ).ToList(),
BackgroundParticlesEnabled = d.BackgroundParticlesEnabled,
BackgroundParticleEdge = d.BackgroundParticleEdge,
BackgroundParticleColor = d.BackgroundParticleColor,
BackgroundParticleOpacity = d.BackgroundParticleOpacity,
BackgroundParticleSizeMin = d.BackgroundParticleSizeMin,
BackgroundParticleSizeMax = d.BackgroundParticleSizeMax,
BackgroundParticleAngle = d.BackgroundParticleAngle,
BackgroundParticleAngleRange = d.BackgroundParticleAngleRange,
BackgroundParticleSpeedMin = d.BackgroundParticleSpeedMin,
BackgroundParticleSpeedMax = d.BackgroundParticleSpeedMax,
BackgroundParticleGravity = d.BackgroundParticleGravity,
BackgroundParticleSpawnRate = d.BackgroundParticleSpawnRate,
BackgroundParticleTrailLength = d.BackgroundParticleTrailLength,
BackgroundParticleTrailColor = d.BackgroundParticleTrailColor,
BackgroundParticleTrailFade = d.BackgroundParticleTrailFade,
BackgroundParticleImpactEnabled = d.BackgroundParticleImpactEnabled,
BackgroundParticleImpactCountMin = d.BackgroundParticleImpactCountMin,
BackgroundParticleImpactCountMax = d.BackgroundParticleImpactCountMax,
BackgroundParticleImpactSpeedMin = d.BackgroundParticleImpactSpeedMin,
BackgroundParticleImpactSpeedMax = d.BackgroundParticleImpactSpeedMax,
BackgroundParticleImpactSizeMin = d.BackgroundParticleImpactSizeMin,
BackgroundParticleImpactSizeMax = d.BackgroundParticleImpactSizeMax,
BackgroundParticleImpactGravity = d.BackgroundParticleImpactGravity,
BackgroundParticleImpactAngleRange = d.BackgroundParticleImpactAngleRange,
BackgroundParticleImpactColor = d.BackgroundParticleImpactColor,
};
if ( d.Blocks is not null )
{
e.UsePool = false;
e.Blocks = new List<EditorBlock>();
for ( int i = 0; i < d.Blocks.Count; i++ )
{
var eb = new EditorBlock( d.Blocks[i] );
var st = d.BlockStarts is not null && i < d.BlockStarts.Count ? d.BlockStarts[i] : null;
if ( st is not null )
{
eb.Phase = st.Phase;
if ( st.PressedSides is not null ) eb.Pressed = st.PressedSides.ToList();
}
e.Blocks.Add( eb );
}
}
else
{
e.UsePool = true;
e.Pool = ( d.Pool ?? Array.Empty<BlockType>() ).ToList();
e.BlockCount = d.BlockCount;
}
e.PoolStartPhase = d.PoolStartPhase;
e.SpawnPositions = ( d.SpawnPositions ?? Array.Empty<Vector2>() ).ToList();
e.SpawnPins = new List<EditorBlock>();
e.SpawnAbilityTimers = new List<BlockAbilityTimer>();
e.SpawnDirections = new List<Direction>();
e.SpawnTurnModes = new List<TurnMode>();
for ( int i = 0; i < e.SpawnPositions.Count; i++ )
{
var pin = d.SpawnPins is not null && i < d.SpawnPins.Count ? d.SpawnPins[i] : null;
e.SpawnPins.Add( pin is null ? null : new EditorBlock( pin.Type ) { Phase = pin.Phase, Pressed = pin.PressedSides.ToList() } );
e.SpawnAbilityTimers.Add( d.SpawnAbilityTimers is not null && i < d.SpawnAbilityTimers.Count ? d.SpawnAbilityTimers[i] : null );
e.SpawnDirections.Add( d.SpawnDirections is not null && i < d.SpawnDirections.Count ? d.SpawnDirections[i] : Direction.None );
e.SpawnTurnModes.Add( d.SpawnTurnModes is not null && i < d.SpawnTurnModes.Count ? d.SpawnTurnModes[i] : TurnMode.Free );
}
e.Obstacles = ( d.Obstacles ?? Array.Empty<RectF>() ).ToList();
e.SpikedWalls = ( d.SpikedWalls ?? Array.Empty<Direction>() ).ToList();
e.Coins = ( d.Coins ?? Array.Empty<Vector2>() ).ToList();
// A LevelDef vision blocker is now the rect of an OBSTACLE flagged to block sight — match each
// back to a DISTINCT obstacle index so duplicate rects don't all collapse onto the first match
// (rects not matching an obstacle are dropped: standalone vision blockers no longer exist).
foreach ( var vr in d.VisionBlockers ?? Array.Empty<RectF>() )
{
for ( int i = 0; i < e.Obstacles.Count; i++ )
if ( RectEquals( e.Obstacles[i], vr ) && !e.VisionObstacles.Contains( i ) )
{
e.VisionObstacles.Add( i );
break;
}
}
// Fence / glass flags: same distinct-index matching as the vision blockers above.
foreach ( var fr in d.Fences ?? Array.Empty<RectF>() )
{
for ( int i = 0; i < e.Obstacles.Count; i++ )
if ( RectEquals( e.Obstacles[i], fr ) && !e.FenceObstacles.Contains( i ) )
{
e.FenceObstacles.Add( i );
break;
}
}
foreach ( var gr in d.Glass ?? Array.Empty<RectF>() )
{
for ( int i = 0; i < e.Obstacles.Count; i++ )
if ( RectEquals( e.Obstacles[i], gr ) && !e.GlassObstacles.Contains( i ) && !e.FenceObstacles.Contains( i ) )
{
e.GlassObstacles.Add( i );
break;
}
}
foreach ( var spec in d.SpikedObstacleSides ?? Array.Empty<ObstacleSpikeSpec>() )
{
int idx = e.Obstacles.FindIndex( r => RectEquals( r, spec.Rect ) );
if ( idx < 0 ) continue; // spec references a rect not in Obstacles — skip (matches game behaviour)
e.SpikedObstacleSides.Add( new ObstacleSpike { Obstacle = idx, Sides = spec.Sides.ToList() } );
}
if ( d.BackgroundBlockColors is { Count: 3 } bgc )
e.BackgroundBlockColors = bgc.ToList();
return e;
}
public LevelDef ToLevelDef()
{
var def = new LevelDef
{
Id = Id,
Name = Name,
Music = string.IsNullOrWhiteSpace( Music ) ? null : Music.Trim(),
MusicVolume = MusicVolume,
IsTest = IsTest,
PlayerSpawns = PlayerSpawns.Count > 0 ? PlayerSpawns.ToArray() : new[] { new Vector2( 16f, 40f ) },
ForcedCharacterId = ForcedCharacterId,
SpawnPositions = SpawnPositions.ToArray(),
SpawnDirections = SpawnDirections.ToArray(),
SpawnTurnModes = SpawnTurnModes.ToArray(),
Obstacles = Obstacles.ToArray(),
SpikedWalls = SpikedWalls.ToArray(),
Coins = Coins.ToArray(),
// Vision blockers / fences / glass are the rects of the obstacles carrying each flag.
VisionBlockers = VisionObstacles.Where( i => i >= 0 && i < Obstacles.Count ).Select( i => Obstacles[i] ).ToArray(),
Fences = FenceObstacles.Where( i => i >= 0 && i < Obstacles.Count ).Select( i => Obstacles[i] ).ToArray(),
Glass = GlassObstacles.Where( i => i >= 0 && i < Obstacles.Count ).Select( i => Obstacles[i] ).ToArray(),
ShuffleTypes = ShuffleTypes,
WallColor = WallColor,
OutOfBoundsColor = OutOfBoundsColor,
CheckerboardColor = CheckerboardColor,
CheckerboardSecondColor = CheckerboardSecondColor,
BackgroundBlockColor = BackgroundBlockColor,
FenceColor = FenceColor,
GlassColor = GlassColor,
BackgroundBlockColors = BackgroundBlockColors?.ToArray(),
BackgroundBlockScale = BackgroundBlockScale,
BackgroundBlockDensity = BackgroundBlockDensity,
BackgroundBlockOpacity = BackgroundBlockOpacity,
BackgroundDriftSpeed = BackgroundDriftSpeed,
BackgroundDriftBias = BackgroundDriftBias,
PlayfieldPattern = PlayfieldPattern,
PlayfieldCellScale = PlayfieldCellScale,
PlayfieldPatternRows = PlayfieldPatternRows?.ToArray(),
AlternatePlayfieldEnabled = AlternatePlayfieldEnabled,
AlternateCheckerboardColor = AlternateCheckerboardColor,
AlternateCheckerboardSecondColor = AlternateCheckerboardSecondColor,
AlternatePlayfieldPattern = AlternatePlayfieldPattern,
AlternatePlayfieldCellScale = AlternatePlayfieldCellScale,
AlternatePlayfieldPatternRows = AlternatePlayfieldPatternRows?.ToArray(),
AlternatePlayfieldRects = AlternatePlayfieldRects.ToArray(),
BackgroundParticlesEnabled = BackgroundParticlesEnabled,
BackgroundParticleEdge = BackgroundParticleEdge,
BackgroundParticleColor = BackgroundParticleColor,
BackgroundParticleOpacity = BackgroundParticleOpacity,
BackgroundParticleSizeMin = BackgroundParticleSizeMin,
BackgroundParticleSizeMax = BackgroundParticleSizeMax,
BackgroundParticleAngle = BackgroundParticleAngle,
BackgroundParticleAngleRange = BackgroundParticleAngleRange,
BackgroundParticleSpeedMin = BackgroundParticleSpeedMin,
BackgroundParticleSpeedMax = BackgroundParticleSpeedMax,
BackgroundParticleGravity = BackgroundParticleGravity,
BackgroundParticleSpawnRate = BackgroundParticleSpawnRate,
BackgroundParticleTrailLength = BackgroundParticleTrailLength,
BackgroundParticleTrailColor = BackgroundParticleTrailColor,
BackgroundParticleTrailFade = BackgroundParticleTrailFade,
BackgroundParticleImpactEnabled = BackgroundParticleImpactEnabled,
BackgroundParticleImpactCountMin = BackgroundParticleImpactCountMin,
BackgroundParticleImpactCountMax = BackgroundParticleImpactCountMax,
BackgroundParticleImpactSpeedMin = BackgroundParticleImpactSpeedMin,
BackgroundParticleImpactSpeedMax = BackgroundParticleImpactSpeedMax,
BackgroundParticleImpactSizeMin = BackgroundParticleImpactSizeMin,
BackgroundParticleImpactSizeMax = BackgroundParticleImpactSizeMax,
BackgroundParticleImpactGravity = BackgroundParticleImpactGravity,
BackgroundParticleImpactAngleRange = BackgroundParticleImpactAngleRange,
BackgroundParticleImpactColor = BackgroundParticleImpactColor,
};
if ( UsePool )
{
def.Pool = Pool.ToArray();
def.BlockCount = BlockCount;
def.PoolStartPhase = PoolStartPhase;
}
else
{
def.Blocks = Blocks.Select( b => b.Type ).ToArray();
// Only attach per-block start configs when at least one block departs from the classic
// phase-0 / no-pressed-sides default (keeps simple levels' data clean).
if ( Blocks.Any( b => b.Phase != 0 || b.Pressed.Count > 0 ) )
def.BlockStarts = Blocks
.Select( b => new BlockStart { Phase = b.Phase, PressedSides = b.Pressed.ToArray() } )
.ToArray();
}
// Pinned blocks — only when at least one slot is pinned.
if ( SpawnPins.Any( p => p is not null ) )
{
var pins = new PinnedBlock[SpawnPositions.Count];
for ( int i = 0; i < pins.Length; i++ )
{
var pin = i < SpawnPins.Count ? SpawnPins[i] : null;
pins[i] = pin is null ? null : new PinnedBlock { Type = pin.Type, Phase = pin.Phase, PressedSides = pin.Pressed.ToArray() };
}
def.SpawnPins = pins;
}
if ( SpawnAbilityTimers.Any( t => t is not null ) )
{
var timers = new BlockAbilityTimer[SpawnPositions.Count];
for ( int i = 0; i < timers.Length; i++ ) timers[i] = SpawnAbilityTimerAt( i );
def.SpawnAbilityTimers = timers;
}
if ( SpawnDirections.Any( d => d != Direction.None ) )
def.SpawnDirections = Enumerable.Range( 0, SpawnPositions.Count ).Select( SpawnDirection ).ToArray();
def.SpikedObstacleSides = SpikedObstacleSides
.Where( s => s.Obstacle >= 0 && s.Obstacle < Obstacles.Count && s.Sides.Count > 0 )
.Select( s => new ObstacleSpikeSpec( Obstacles[s.Obstacle], s.Sides.ToArray() ) )
.ToArray();
return def;
}
// ── JSON serialization (the Assets/levels/*.json files store this DTO shape) ─────────────────────
/// <summary>Build the plain-DTO wire form (primitive fields only, enums as names) written to the
/// level JSON files. <paramref name="fullAuthoringState"/> additionally emits fields the lean wire
/// form drops as inactive (the non-active mode's block list, disabled alt-playfield settings,
/// custom pattern rows under a non-Custom pattern) — used by editor undo snapshots, which must
/// round-trip the whole authoring session, not just what the level currently plays with.</summary>
public LevelJson ToJson( bool fullAuthoringState = false )
{
var j = new LevelJson
{
V = SCHEMA_VERSION,
Id = Id,
Name = Name,
Music = string.IsNullOrWhiteSpace( Music ) ? null : Music.Trim(),
Test = IsTest,
Mode = UsePool ? "pool" : "exact",
BlockCount = BlockCount,
Players = PlayerSpawns.Select( p => new[] { p.x, p.y } ).ToArray(),
Character = string.IsNullOrWhiteSpace( ForcedCharacterId ) ? null : ForcedCharacterId,
Shuffle = ShuffleTypes,
Spawns = SpawnPositions.Select( p => new[] { p.x, p.y } ).ToArray(),
};
if ( MathF.Abs( MusicVolume - 1f ) > 0.001f )
j.MusicVolume = MusicVolume;
if ( SpawnDirections.Any( d => d != Direction.None ) )
j.SpawnDirections = Enumerable.Range( 0, SpawnPositions.Count ).Select( i => SpawnDirection( i ).ToString() ).ToArray();
if ( SpawnTurnModes.Any( mode => mode != TurnMode.Free ) )
j.SpawnTurnModes = Enumerable.Range( 0, SpawnPositions.Count ).Select( i => SpawnTurnMode( i ).ToString() ).ToArray();
if ( UsePool || fullAuthoringState )
{
j.Pool = Pool.Select( b => b.ToString() ).ToArray();
j.PoolStartPhase = PoolStartPhase;
}
if ( !UsePool || fullAuthoringState )
{
j.Blocks = Blocks.Select( b => b.Type.ToString() ).ToArray();
// Parallel, aligned-with-Blocks arrays; emitted only when something is non-default so the
// common case stays as a bare type list.
if ( Blocks.Any( b => b.Phase != 0 ) )
j.BlockPhases = Blocks.Select( b => b.Phase ).ToArray();
if ( Blocks.Any( b => b.Pressed.Count > 0 ) )
j.BlockPressed = Blocks.Select( b => b.Pressed.Select( d => d.ToString() ).ToArray() ).ToArray();
}
if ( Obstacles.Count > 0 ) j.Obstacles = Obstacles.Select( RectArr ).ToArray();
if ( SpikedWalls.Count > 0 ) j.SpikedWalls = SpikedWalls.Select( d => d.ToString() ).ToArray();
if ( Coins.Count > 0 ) j.Coins = Coins.Select( p => new[] { p.x, p.y } ).ToArray();
var specs = SpikedObstacleSides.Where( s => s.Sides.Count > 0 ).ToList();
if ( specs.Count > 0 )
j.SpikedObstacleSides = specs
.Select( s => new ObstacleSpikeJson { Obstacle = s.Obstacle, Sides = s.Sides.Select( d => d.ToString() ).ToArray() } )
.ToArray();
// Store the flagged obstacle indices directly — storing rects was lossy when two obstacles
// shared a rect (every vision rect resolved to the first match on load).
if ( VisionObstacles.Count > 0 )
{
var visionIdx = VisionObstacles.Where( i => i >= 0 && i < Obstacles.Count ).Distinct().ToArray();
if ( visionIdx.Length > 0 ) j.VisionIdx = visionIdx;
}
if ( FenceObstacles.Count > 0 )
{
var fenceIdx = FenceObstacles.Where( i => i >= 0 && i < Obstacles.Count ).Distinct().ToArray();
if ( fenceIdx.Length > 0 ) j.FenceIdx = fenceIdx;
}
if ( GlassObstacles.Count > 0 )
{
var glassIdx = GlassObstacles.Where( i => i >= 0 && i < Obstacles.Count ).Distinct().ToArray();
if ( glassIdx.Length > 0 ) j.GlassIdx = glassIdx;
}
// Pinned blocks — a sparse list (spawn index + type/phase/pressed), emitted only when at
// least one slot is pinned.
if ( SpawnPins.Any( p => p is not null ) )
j.Pins = Enumerable.Range( 0, SpawnPositions.Count )
.Where( i => i < SpawnPins.Count && SpawnPins[i] is not null )
.Select( i => new PinJson
{
Spawn = i,
Type = SpawnPins[i].Type.ToString(),
Phase = SpawnPins[i].Phase,
Pressed = SpawnPins[i].Pressed.Count > 0 ? SpawnPins[i].Pressed.Select( d => d.ToString() ).ToArray() : null,
} )
.ToArray();
if ( SpawnAbilityTimers.Any( t => t is not null && MathF.Abs( t.Interval - Block.DEFAULT_ABILITY_INTERVAL ) >= 0.001f ) )
j.AbilityIntervals = Enumerable.Range( 0, SpawnPositions.Count ).Select( SpawnAbilityInterval ).ToArray();
if ( SpawnAbilityTimers.Any( t => t is not null && t.Randomness >= 0.001f ) )
j.AbilityRandomness = Enumerable.Range( 0, SpawnPositions.Count ).Select( SpawnAbilityRandomness ).ToArray();
if ( SpawnAbilityTimers.Any( t => t is not null && t.StartOffset >= 0.001f ) )
j.AbilityStartOffsets = Enumerable.Range( 0, SpawnPositions.Count ).Select( SpawnAbilityStartOffset ).ToArray();
if ( SpawnAbilityTimers.Any( t => t is not null && MathF.Abs( t.ClosedTime - Block.DEFAULT_ABILITY_CLOSED_TIME ) >= 0.001f ) )
j.AbilityClosedTimes = Enumerable.Range( 0, SpawnPositions.Count ).Select( SpawnAbilityClosedTime ).ToArray();
j.Wall = ColArr( WallColor );
j.Oob = ColArr( OutOfBoundsColor );
j.Checker = ColArr( CheckerboardColor );
j.Checker2 = ColArr( CheckerboardSecondColor );
j.BgBlock = ColArr( BackgroundBlockColor );
j.FenceCol = ColArr( FenceColor );
j.GlassCol = ColArr( GlassColor );
if ( BackgroundBlockColors is { Count: 3 } bgc )
j.BgBlocks = bgc.Select( c => ColArr( c ) ).ToArray();
if ( MathF.Abs( BackgroundBlockScale - 1f ) > 0.001f )
j.BgBlockScale = BackgroundBlockScale;
if ( MathF.Abs( BackgroundBlockDensity - 1f ) > 0.001f )
j.BgBlockDensity = BackgroundBlockDensity;
if ( MathF.Abs( BackgroundBlockOpacity - 1f ) > 0.001f )
j.BgBlockOpacity = BackgroundBlockOpacity;
if ( MathF.Abs( BackgroundDriftSpeed - 1f ) > 0.001f )
j.BgDriftSpeed = BackgroundDriftSpeed;
if ( BackgroundDriftBias != BackgroundDriftBias.None )
j.BgDriftBias = BackgroundDriftBias.ToString();
if ( PlayfieldPattern != PlayfieldPattern.Checker )
j.Pattern = PlayfieldPattern.ToString();
if ( PlayfieldCellScale != 1 )
j.CellScale = PlayfieldCellScale;
if ( ( PlayfieldPattern == PlayfieldPattern.Custom || fullAuthoringState ) && PlayfieldPatternRows is { Count: > 0 } )
j.PatternRows = PlayfieldPatternRows.Take( 8 ).ToArray();
if ( AlternatePlayfieldEnabled || fullAuthoringState )
{
j.AltEnabled = AlternatePlayfieldEnabled;
j.AltChecker = ColArr( AlternateCheckerboardColor );
j.AltChecker2 = ColArr( AlternateCheckerboardSecondColor );
j.AltPattern = AlternatePlayfieldPattern.ToString();
j.AltCellScale = AlternatePlayfieldCellScale;
if ( ( AlternatePlayfieldPattern == PlayfieldPattern.Custom || fullAuthoringState ) && AlternatePlayfieldPatternRows is { Count: > 0 } )
j.AltPatternRows = AlternatePlayfieldPatternRows.Take( 8 ).ToArray();
if ( AlternatePlayfieldRects.Count > 0 )
j.AltRects = AlternatePlayfieldRects.Select( RectArr ).ToArray();
}
if ( BackgroundParticlesEnabled || fullAuthoringState )
{
j.BgParticles = BackgroundParticlesEnabled;
j.BgPartEdge = BackgroundParticleEdge.ToString();
j.BgPartCol = ColArr( BackgroundParticleColor );
j.BgPartOpacity = BackgroundParticleOpacity;
j.BgPartSizeMin = BackgroundParticleSizeMin;
j.BgPartSizeMax = BackgroundParticleSizeMax;
j.BgPartAngle = BackgroundParticleAngle;
j.BgPartAngleRange = BackgroundParticleAngleRange;
j.BgPartSpeedMin = BackgroundParticleSpeedMin;
j.BgPartSpeedMax = BackgroundParticleSpeedMax;
j.BgPartGravity = BackgroundParticleGravity;
j.BgPartRate = BackgroundParticleSpawnRate;
j.BgPartTrail = BackgroundParticleTrailLength;
j.BgPartTrailCol = ColArr( BackgroundParticleTrailColor );
j.BgPartTrailFade = BackgroundParticleTrailFade;
j.BgPartImpact = BackgroundParticleImpactEnabled;
j.BgPartImpactCountMin = BackgroundParticleImpactCountMin;
j.BgPartImpactCountMax = BackgroundParticleImpactCountMax;
j.BgPartImpactSpeedMin = BackgroundParticleImpactSpeedMin;
j.BgPartImpactSpeedMax = BackgroundParticleImpactSpeedMax;
j.BgPartImpactSizeMin = BackgroundParticleImpactSizeMin;
j.BgPartImpactSizeMax = BackgroundParticleImpactSizeMax;
j.BgPartImpactGravity = BackgroundParticleImpactGravity;
j.BgPartImpactAngleRange = BackgroundParticleImpactAngleRange;
j.BgPartImpactCol = ColArr( BackgroundParticleImpactColor );
}
return j;
}
/// <summary>Rebuild an editable level from the JSON DTO (unknown enum names skipped leniently).</summary>
public static EditorLevel FromJson( LevelJson j )
{
var e = new EditorLevel
{
// Blank id/name survive the round trip: they mark an unnamed document (Save/Delete inert),
// and an undo snapshot of one must not resurrect as a saveable "my-level".
Id = j.Id ?? "",
Name = j.Name ?? "",
Music = j.Music ?? "",
MusicVolume = Math.Clamp( j.MusicVolume ?? 1f, 0f, 1.5f ),
IsTest = j.Test,
UsePool = string.Equals( j.Mode, "pool", StringComparison.OrdinalIgnoreCase ),
BlockCount = j.BlockCount,
PoolStartPhase = j.PoolStartPhase,
ForcedCharacterId = string.IsNullOrWhiteSpace( j.Character ) ? null : j.Character,
ShuffleTypes = j.Shuffle,
Pool = ParseBlockNames( j.Pool ),
SpikedWalls = ParseDirNames( j.SpikedWalls ),
};
// Exact blocks: iterate the raw name array so BlockPhases/BlockPressed stay index-aligned even
// if a bad name is skipped.
e.Blocks = new List<EditorBlock>();
var rawBlocks = j.Blocks ?? Array.Empty<string>();
for ( int i = 0; i < rawBlocks.Length; i++ )
{
if ( !Enum.TryParse<BlockType>( rawBlocks[i], true, out var bt ) ) continue;
var eb = new EditorBlock( bt );
if ( j.BlockPhases is not null && i < j.BlockPhases.Length ) eb.Phase = j.BlockPhases[i];
if ( j.BlockPressed is not null && i < j.BlockPressed.Length ) eb.Pressed = ParseDirNames( j.BlockPressed[i] );
e.Blocks.Add( eb );
}
// Candidate player spawns — a single-spawn level is just a one-entry list.
var players = ( j.Players ?? Array.Empty<float[]>() ).Where( a => a is { Length: 2 } ).Select( a => new Vector2( a[0], a[1] ) ).ToList();
if ( players.Count > 0 ) e.PlayerSpawns = players;
e.SpawnPositions = ( j.Spawns ?? Array.Empty<float[]>() ).Where( a => a is { Length: 2 } ).Select( a => new Vector2( a[0], a[1] ) ).ToList();
// Pinned blocks (sparse list of spawn index + type/phase/pressed), plus per-slot ability timers.
e.SpawnPins = new List<EditorBlock>();
e.SpawnAbilityTimers = new List<BlockAbilityTimer>();
e.SpawnDirections = new List<Direction>();
e.SpawnTurnModes = new List<TurnMode>();
for ( int i = 0; i < e.SpawnPositions.Count; i++ )
{
e.SpawnPins.Add( null );
e.SpawnDirections.Add( j.SpawnDirections is not null && i < j.SpawnDirections.Length
&& Enum.TryParse<Direction>( j.SpawnDirections[i], true, out var direction )
&& Enum.IsDefined( direction )
? direction
: Direction.None );
e.SpawnTurnModes.Add( j.SpawnTurnModes is not null && i < j.SpawnTurnModes.Length
&& Enum.TryParse<TurnMode>( j.SpawnTurnModes[i], true, out var turnMode )
&& Enum.IsDefined( turnMode )
? turnMode
: TurnMode.Free );
float interval = j.AbilityIntervals is not null && i < j.AbilityIntervals.Length
? MathF.Max( 0.05f, j.AbilityIntervals[i] )
: Block.DEFAULT_ABILITY_INTERVAL;
float randomness = j.AbilityRandomness is not null && i < j.AbilityRandomness.Length
? Math.Clamp( j.AbilityRandomness[i], 0f, MathF.Max( 0f, interval - 0.05f ) )
: 0f;
float startOffset = j.AbilityStartOffsets is not null && i < j.AbilityStartOffsets.Length
? Math.Clamp( j.AbilityStartOffsets[i], 0f, interval )
: 0f;
float closedTime = j.AbilityClosedTimes is not null && i < j.AbilityClosedTimes.Length
? MathF.Max( 0f, j.AbilityClosedTimes[i] )
: Block.DEFAULT_ABILITY_CLOSED_TIME;
e.SpawnAbilityTimers.Add( MathF.Abs( interval - Block.DEFAULT_ABILITY_INTERVAL ) < 0.001f
&& randomness < 0.001f && startOffset < 0.001f
&& MathF.Abs( closedTime - Block.DEFAULT_ABILITY_CLOSED_TIME ) < 0.001f
? null
: new BlockAbilityTimer { Interval = interval, Randomness = randomness, StartOffset = startOffset, ClosedTime = closedTime } );
}
foreach ( var pin in j.Pins ?? Array.Empty<PinJson>() )
{
if ( pin is null || pin.Spawn < 0 || pin.Spawn >= e.SpawnPins.Count ) continue;
if ( !Enum.TryParse<BlockType>( pin.Type, true, out var pt ) ) continue;
e.SpawnPins[pin.Spawn] = new EditorBlock( pt )
{
Phase = Math.Clamp( pin.Phase, 0, Block.NUM_PHASES - 1 ),
Pressed = ParseDirNames( pin.Pressed ),
};
}
e.Obstacles = ( j.Obstacles ?? Array.Empty<float[]>() ).Where( a => a is { Length: 4 } ).Select( ArrRect ).ToList();
e.Coins = ( j.Coins ?? Array.Empty<float[]>() ).Where( a => a is { Length: 2 } ).Select( a => new Vector2( a[0], a[1] ) ).ToList();
// Vision blockers / fences: the indices of obstacles carrying each flag.
foreach ( var idx in j.VisionIdx ?? Array.Empty<int>() )
if ( idx >= 0 && idx < e.Obstacles.Count && !e.VisionObstacles.Contains( idx ) )
e.VisionObstacles.Add( idx );
foreach ( var idx in j.FenceIdx ?? Array.Empty<int>() )
if ( idx >= 0 && idx < e.Obstacles.Count && !e.FenceObstacles.Contains( idx ) )
e.FenceObstacles.Add( idx );
foreach ( var idx in j.GlassIdx ?? Array.Empty<int>() )
if ( idx >= 0 && idx < e.Obstacles.Count && !e.GlassObstacles.Contains( idx ) && !e.FenceObstacles.Contains( idx ) )
e.GlassObstacles.Add( idx );
foreach ( var s in j.SpikedObstacleSides ?? Array.Empty<ObstacleSpikeJson>() )
e.SpikedObstacleSides.Add( new ObstacleSpike { Obstacle = s.Obstacle, Sides = ParseDirNames( s.Sides ) } );
e.WallColor = ArrCol( j.Wall );
e.OutOfBoundsColor = ArrCol( j.Oob );
e.CheckerboardColor = ArrCol( j.Checker );
e.CheckerboardSecondColor = ArrCol( j.Checker2 );
e.BackgroundBlockColor = ArrCol( j.BgBlock );
e.FenceColor = ArrCol( j.FenceCol );
e.GlassColor = ArrCol( j.GlassCol );
e.BackgroundBlockScale = Math.Clamp( j.BgBlockScale ?? 1f, 0.25f, 1.5f );
e.BackgroundBlockDensity = Math.Clamp( j.BgBlockDensity ?? 1f, 0.25f, 2f );
e.BackgroundBlockOpacity = Math.Clamp( j.BgBlockOpacity ?? 1f, 0f, 1f );
e.BackgroundDriftSpeed = Math.Clamp( j.BgDriftSpeed ?? 1f, 0f, 3f );
if ( Enum.TryParse<BackgroundDriftBias>( j.BgDriftBias, true, out var driftBias ) )
e.BackgroundDriftBias = driftBias;
if ( Enum.TryParse<PlayfieldPattern>( j.Pattern, true, out var pattern ) )
e.PlayfieldPattern = pattern;
e.PlayfieldCellScale = Math.Clamp( j.CellScale ?? 1, 1, 8 );
e.PlayfieldPatternRows = NormalizePatternRows( j.PatternRows );
e.AlternatePlayfieldEnabled = j.AltEnabled;
e.AlternateCheckerboardColor = ArrCol( j.AltChecker );
e.AlternateCheckerboardSecondColor = ArrCol( j.AltChecker2 );
if ( Enum.TryParse<PlayfieldPattern>( j.AltPattern, true, out var alternatePattern ) )
e.AlternatePlayfieldPattern = alternatePattern;
e.AlternatePlayfieldCellScale = Math.Clamp( j.AltCellScale ?? 1, 1, 8 );
e.AlternatePlayfieldPatternRows = NormalizePatternRows( j.AltPatternRows );
e.AlternatePlayfieldRects = ( j.AltRects ?? Array.Empty<float[]>() )
.Where( a => a is { Length: 4 } ).Select( ArrRect ).ToList();
if ( j.BgBlocks is { Length: 3 } )
{
var cols = j.BgBlocks.Select( ArrCol ).Where( c => c.HasValue ).Select( c => c.Value ).ToList();
if ( cols.Count == 3 ) e.BackgroundBlockColors = cols;
}
e.BackgroundParticlesEnabled = j.BgParticles;
if ( Enum.TryParse<Direction>( j.BgPartEdge, true, out var particleEdge )
&& particleEdge is Direction.Up or Direction.Down or Direction.Left or Direction.Right )
e.BackgroundParticleEdge = particleEdge;
e.BackgroundParticleColor = ArrCol( j.BgPartCol );
e.BackgroundParticleOpacity = Math.Clamp( j.BgPartOpacity ?? 1f, 0.05f, 1f );
e.BackgroundParticleSizeMin = Math.Clamp( j.BgPartSizeMin ?? 1, 1, 8 );
e.BackgroundParticleSizeMax = Math.Clamp( j.BgPartSizeMax ?? 2, e.BackgroundParticleSizeMin, 8 );
e.BackgroundParticleAngle = Math.Clamp( j.BgPartAngle ?? 0f, -75f, 75f );
e.BackgroundParticleAngleRange = Math.Clamp( j.BgPartAngleRange ?? 0f, 0f, 90f );
e.BackgroundParticleSpeedMin = Math.Clamp( j.BgPartSpeedMin ?? 90f, 5f, 300f );
e.BackgroundParticleSpeedMax = Math.Clamp( j.BgPartSpeedMax ?? 140f, e.BackgroundParticleSpeedMin, 300f );
e.BackgroundParticleGravity = Math.Clamp( j.BgPartGravity ?? 60f, -400f, 400f );
e.BackgroundParticleSpawnRate = Math.Clamp( j.BgPartRate ?? 12f, 0f, 60f );
e.BackgroundParticleTrailLength = Math.Clamp( j.BgPartTrail ?? 0, 0, 8 );
e.BackgroundParticleTrailColor = ArrCol( j.BgPartTrailCol );
e.BackgroundParticleTrailFade = j.BgPartTrailFade;
e.BackgroundParticleImpactEnabled = j.BgPartImpact;
e.BackgroundParticleImpactCountMin = Math.Clamp( j.BgPartImpactCountMin ?? 2, 0, 10 );
e.BackgroundParticleImpactCountMax = Math.Clamp( j.BgPartImpactCountMax ?? 4, e.BackgroundParticleImpactCountMin, 10 );
e.BackgroundParticleImpactSpeedMin = Math.Clamp( j.BgPartImpactSpeedMin ?? 15f, 5f, 150f );
e.BackgroundParticleImpactSpeedMax = Math.Clamp( j.BgPartImpactSpeedMax ?? 40f, e.BackgroundParticleImpactSpeedMin, 150f );
e.BackgroundParticleImpactSizeMin = Math.Clamp( j.BgPartImpactSizeMin ?? 1, 1, 8 );
e.BackgroundParticleImpactSizeMax = Math.Clamp( j.BgPartImpactSizeMax ?? 2, e.BackgroundParticleImpactSizeMin, 8 );
e.BackgroundParticleImpactGravity = Math.Clamp( j.BgPartImpactGravity ?? 150f, -400f, 400f );
e.BackgroundParticleImpactAngleRange = Math.Clamp( j.BgPartImpactAngleRange ?? 60f, 0f, 90f );
e.BackgroundParticleImpactColor = ArrCol( j.BgPartImpactCol );
return e;
}
/// <summary>Serialize to a JSON string (minified — used for the editor clipboard / paste import).
/// File writes go through the Editor-assembly writer, which pretty-prints the same DTO.</summary>
public string ToJsonString( bool fullAuthoringState = false ) => Json.Serialize( ToJson( fullAuthoringState ) );
/// <summary>Parse the canonical <see cref="LevelJson"/> wire format. Returns null +
/// <paramref name="error"/> on malformed JSON or a refused schema version.</summary>
public static EditorLevel FromJsonString( string text, out string error )
{
error = "";
if ( string.IsNullOrWhiteSpace( text ) ) { error = "empty"; return null; }
string json = text.Trim();
if ( json.EndsWith( ",", StringComparison.Ordinal ) )
json = json[..^1].TrimEnd();
try
{
var j = Json.Deserialize<LevelJson>( json );
if ( j is null ) { error = "parse failed"; return null; }
return FromVersionedJson( j, out error );
}
catch ( Exception ex )
{
error = ex.Message;
return null;
}
}
/// <summary><see cref="FromJson"/> behind the schema-version gate — the entry point for every
/// DTO that came from outside this process (disk files, share codes, the local-replay snapshot).
/// V is REQUIRED (no-legacy-compat: the shipped files were hand-stamped when the field was added;
/// older unstamped data is simply invalid). Newer-than-known versions are refused so the lenient
/// parser can't silently load a degraded layout — a replay-determinism hazard.</summary>
public static EditorLevel FromVersionedJson( LevelJson j, out string error )
{
error = "";
if ( j is null ) { error = "parse failed"; return null; }
if ( j.V is not int fileVersion ) { error = "level file has no schema version (V)"; return null; }
if ( fileVersion > SCHEMA_VERSION ) { error = "level requires a newer game version"; return null; }
return FromJson( j );
}
// ── formatting / parsing helpers ────────────────────────────────────────────────────────────────
private static int Byte255( float v ) => (int)MathF.Round( Math.Clamp( v, 0f, 1f ) * 255f );
private static List<string> NormalizePatternRows( IEnumerable<string> rows )
{
var normalized = (rows ?? Array.Empty<string>())
.Take( 8 )
.Select( row => new string( (row ?? "").Where( c => c == '0' || c == '1' ).Take( 8 ).ToArray() ) )
.Where( row => row.Length > 0 )
.ToList();
if ( normalized.Count == 0 ) return null;
int width = normalized.Max( row => row.Length );
for ( int i = 0; i < normalized.Count; i++ )
normalized[i] = normalized[i].PadRight( width, '0' );
return normalized;
}
private static bool RectEquals( RectF a, RectF b )
=> MathF.Abs( a.Left - b.Left ) < 0.01f && MathF.Abs( a.Bottom - b.Bottom ) < 0.01f
&& MathF.Abs( a.Right - b.Right ) < 0.01f && MathF.Abs( a.Top - b.Top ) < 0.01f;
// ── JSON mapping helpers ────────────────────────────────────────────────────────────────────────
private static List<BlockType> ParseBlockNames( string[] names )
=> ( names ?? Array.Empty<string>() )
.Select( p => Enum.TryParse<BlockType>( p, true, out var b ) ? (BlockType?)b : null )
.Where( b => b.HasValue ).Select( b => b.Value ).ToList();
private static List<Direction> ParseDirNames( string[] names )
=> ( names ?? Array.Empty<string>() )
.Select( p => Enum.TryParse<Direction>( p, true, out var d ) ? (Direction?)d : null )
.Where( d => d.HasValue && d.Value != Direction.None ).Select( d => d.Value ).ToList();
private static float[] RectArr( RectF r ) => new[] { r.Left, r.Bottom, r.Right, r.Top };
private static RectF ArrRect( float[] a ) => new RectF( a[0], a[1], a[2], a[3] );
private static int[] ColArr( Color? c )
=> c.HasValue ? new[] { Byte255( c.Value.r ), Byte255( c.Value.g ), Byte255( c.Value.b ) } : null;
private static Color? ArrCol( int[] a )
=> a is { Length: 3 } ? new Color( a[0] / 255f, a[1] / 255f, a[2] / 255f ) : (Color?)null;
}
/// <summary>Editor-side obstacle-face spike spec: the obstacle INDEX (into
/// <see cref="EditorLevel.Obstacles"/>) plus the outward normals that are spiked.</summary>
public sealed class ObstacleSpike
{
public int Obstacle;
public List<Direction> Sides = new();
}
/// <summary>One block in an <see cref="EditorLevel"/>'s exact list: its type plus an optional authored
/// starting phase and pre-pressed sides (see <see cref="LevelDef.BlockStarts"/>).</summary>
public sealed class EditorBlock
{
public BlockType Type;
/// <summary>Starting phase 0..<see cref="Block.NUM_PHASES"/>-1.</summary>
public int Phase;
/// <summary>Sides that start pre-pressed for the spawn phase.</summary>
public List<Direction> Pressed = new();
public EditorBlock() { }
public EditorBlock( BlockType type ) { Type = type; }
/// <summary>Independent deep copy (own <see cref="Pressed"/> list), so clipboard snapshots and
/// pasted pins never share a mutable instance with their source.</summary>
public EditorBlock Clone() => new( Type )
{
Phase = Phase,
Pressed = Pressed is null ? new() : new List<Direction>( Pressed ),
};
}
/// <summary>The on-disk JSON shape of a level (Assets/levels/*.json). Primitive fields only so it
/// serializes cleanly with any JSON serializer: enums as names, colours as [r,g,b] (0-255), points as
/// [x,y], rects as [left,bottom,right,top]. Mapped to/from <see cref="EditorLevel"/> via
/// <see cref="EditorLevel.ToJson"/> / <see cref="EditorLevel.FromJson"/>.</summary>
public sealed class LevelJson
{
/// <summary>Wire-shape version (see <see cref="EditorLevel.SCHEMA_VERSION"/>). REQUIRED — the
/// reader refuses files without it (nullable only so absence is detectable, not tolerated).</summary>
public int? V { get; set; }
public string Id { get; set; }
public string Name { get; set; }
public string Music { get; set; }
public float? MusicVolume { get; set; }
public bool Test { get; set; }
public string Mode { get; set; }
public string[] Blocks { get; set; }
/// <summary>Exact-mode per-block starting phase, aligned with <see cref="Blocks"/>. Null/omitted =
/// all phase 0.</summary>
public int[] BlockPhases { get; set; }
/// <summary>Exact-mode per-block pre-pressed sides (direction names), aligned with <see cref="Blocks"/>.
/// Null/omitted = none.</summary>
public string[][] BlockPressed { get; set; }
public string[] Pool { get; set; }
public int BlockCount { get; set; }
/// <summary>Pool-mode starting phase for every drawn block. 0 = classic.</summary>
public int PoolStartPhase { get; set; }
public float[][] Spawns { get; set; }
public string[] SpawnDirections { get; set; }
/// <summary>Per-spawn <see cref="TurnMode"/> names, aligned with <see cref="Spawns"/>.
/// Null/omitted = all <see cref="TurnMode.Free"/>.</summary>
public string[] SpawnTurnModes { get; set; }
/// <summary>Pinned blocks — a sparse list (spawn index + type/phase/pressed). Null/omitted = no
/// pins. A pinned slot always spawns its authored block, independent of the random block count.</summary>
public PinJson[] Pins { get; set; }
public float[] AbilityIntervals { get; set; }
public float[] AbilityRandomness { get; set; }
public float[] AbilityStartOffsets { get; set; }
public float[] AbilityClosedTimes { get; set; }
/// <summary>Candidate player spawns [[x,y],...] — a single-spawn level writes a one-entry list;
/// with more than one the run picks a spawn from the run Rng.</summary>
public float[][] Players { get; set; }
public string Character { get; set; }
public float[][] Obstacles { get; set; }
/// <summary>Collectible coin centres [[x,y],...]. Null/omitted = no coins.</summary>
public float[][] Coins { get; set; }
public string[] SpikedWalls { get; set; }
public ObstacleSpikeJson[] SpikedObstacleSides { get; set; }
/// <summary>Obstacle indices flagged to block vision. Indices, not rects — rects couldn't
/// distinguish two obstacles sharing the same rect.</summary>
public int[] VisionIdx { get; set; }
/// <summary>Obstacle indices flagged as fences (solid only to blocks). Index-based like
/// <see cref="VisionIdx"/>.</summary>
public int[] FenceIdx { get; set; }
/// <summary>Obstacle indices flagged as glass (solid only to players). Index-based like
/// <see cref="VisionIdx"/>.</summary>
public int[] GlassIdx { get; set; }
/// <summary>Defaults true to match <see cref="EditorLevel.ShuffleTypes"/> — a level JSON that omits
/// the key must load with shuffling on, not the CLR bool default.</summary>
public bool Shuffle { get; set; } = true;
public int[] Wall { get; set; }
public int[] Oob { get; set; }
public int[] Checker { get; set; }
public int[] Checker2 { get; set; }
public int[] BgBlock { get; set; }
/// <summary>Fence border colour [r,g,b]; bars derive a brighter version. Null = default amber.</summary>
public int[] FenceCol { get; set; }
/// <summary>Glass colour [r,g,b]; pane/streaks/border/teeth tint derive from it. Null = default lavender.</summary>
public int[] GlassCol { get; set; }
public int[][] BgBlocks { get; set; }
public float? BgBlockScale { get; set; }
public float? BgBlockDensity { get; set; }
public float? BgBlockOpacity { get; set; }
public float? BgDriftSpeed { get; set; }
public string BgDriftBias { get; set; }
public string Pattern { get; set; }
public int? CellScale { get; set; }
public string[] PatternRows { get; set; }
public bool AltEnabled { get; set; }
public int[] AltChecker { get; set; }
public int[] AltChecker2 { get; set; }
public string AltPattern { get; set; }
public int? AltCellScale { get; set; }
public string[] AltPatternRows { get; set; }
public float[][] AltRects { get; set; }
/// <summary>Background particle ambience cluster (see <see cref="LevelDef.BackgroundParticlesEnabled"/>).
/// The whole cluster is only emitted while enabled (or in full-authoring undo snapshots).</summary>
public bool BgParticles { get; set; }
public string BgPartEdge { get; set; }
public int[] BgPartCol { get; set; }
public float? BgPartOpacity { get; set; }
public int? BgPartSizeMin { get; set; }
public int? BgPartSizeMax { get; set; }
public float? BgPartAngle { get; set; }
public float? BgPartAngleRange { get; set; }
public float? BgPartSpeedMin { get; set; }
public float? BgPartSpeedMax { get; set; }
public float? BgPartGravity { get; set; }
public float? BgPartRate { get; set; }
public int? BgPartTrail { get; set; }
public int[] BgPartTrailCol { get; set; }
/// <summary>Defaults true so a level JSON that omits the key keeps the fading trail (the
/// original look), not the CLR bool default — same idiom as <see cref="Shuffle"/>.</summary>
public bool BgPartTrailFade { get; set; } = true;
public bool BgPartImpact { get; set; }
public int? BgPartImpactCountMin { get; set; }
public int? BgPartImpactCountMax { get; set; }
public float? BgPartImpactSpeedMin { get; set; }
public float? BgPartImpactSpeedMax { get; set; }
public int? BgPartImpactSizeMin { get; set; }
public int? BgPartImpactSizeMax { get; set; }
public float? BgPartImpactGravity { get; set; }
public float? BgPartImpactAngleRange { get; set; }
public int[] BgPartImpactCol { get; set; }
}
public sealed class ObstacleSpikeJson
{
public int Obstacle { get; set; }
public string[] Sides { get; set; }
}
/// <summary>Wire form of one pinned block: the spawn slot it's bound to plus its authored state.</summary>
public sealed class PinJson
{
public int Spawn { get; set; }
public string Type { get; set; }
public int Phase { get; set; }
public string[] Pressed { get; set; }
}