Stages/LevelEditorStage.cs
using System;
using System.Collections.Generic;
using System.Linq;
namespace BlockParty;
/// <summary>
/// EDITOR-ONLY level editor. Started with the <c>level_editor</c> ConCmd (see
/// <see cref="OpenLevelEditor"/>), it swaps to a self-contained stage where a level is authored
/// visually: drag obstacles, place block-spawn positions and the player spawn, toggle wall + obstacle-
/// face spikes, flag which obstacles block vision, pick the block set, and edit the palette — then
/// export a compact round-trip string and/or a paste-ready C# <c>LevelDef</c> for <c>Levels.cs</c>.
///
/// Rendering is a LIGHTWEIGHT SCHEMATIC (flat overlay quads at 1:1 arena scale via
/// <see cref="StageBase.CreateChild"/> + the shared pixel sprite), not the full game arena — it is
/// an authoring aid, not a preview of exact in-game visuals. The simulation is frozen
/// (<see cref="BlocksSimulation"/> = true); all interaction runs in <see cref="TickMenu"/>, driven
/// by pointer state the <see cref="LevelEditorHud"/> forwards from a full-screen canvas panel.
/// </summary>
public sealed class LevelEditorStage : StageBase
{
public enum EditTool { Select, Obstacle, Spawn, Player, Coin, Spike, AlternatePattern }
public enum SelKind { None, Obstacle, Spawn, Player, Coin, AlternatePattern }
private readonly record struct Selection( SelKind Kind, int Index );
/// <summary>The level being authored (mutable). The HUD mutates this directly and calls
/// <see cref="Rebuild"/> / <see cref="RefreshPalette"/>.</summary>
public EditorLevel Level { get; set; } = new();
public int LevelRevision { get; private set; }
private EditTool _tool = EditTool.Select;
public EditTool Tool
{
get => _tool;
set
{
if ( _tool == value ) return;
bool crossedAlternateBoundary = _tool == EditTool.AlternatePattern || value == EditTool.AlternatePattern;
_tool = value;
if ( crossedAlternateBoundary ) Select( SelKind.None, -1 );
}
}
/// <summary>Mirror-editing toggles (HUD tools panel): MirrorX doubles edits across the VERTICAL
/// centre line (left↔right), MirrorY across the horizontal one (up↕down); both = 4-way. Affects
/// place/create, click-select, move, resize, delete and spike toggles for obstacles and the
/// block-spawn/player/coin markers.</summary>
public bool MirrorX { get; set; }
public bool MirrorY { get; set; }
private bool MirrorActive => MirrorX || MirrorY;
/// <summary>The character selected in the editor. Seeded from the persisted global choice when the
/// editor opens (see <see cref="OpenLevelEditor"/> / the test-play round-trip); the HUD picker also
/// saves its changes as the player's most recent selection. The player-spawn marker renders as this
/// character and a Test Play run is played as this character.</summary>
public string SelectedCharacterId { get; set; } = Settings.Current.SelectedCharacterId;
private readonly HashSet<Selection> _selection = new();
private Selection _primarySelection = new( SelKind.None, -1 );
/// <summary>The selected item exposed to single-item inspectors. Multi-selection intentionally
/// reports no single kind/index so per-object controls cannot accidentally mutate only one item.</summary>
public SelKind SelectedKind => _selection.Count == 1 ? _primarySelection.Kind : SelKind.None;
public int SelectedIndex => _selection.Count == 1 ? _primarySelection.Index : -1;
public int SelectionCount => _selection.Count;
public bool HasSelection => _selection.Count > 0;
/// <summary>Last non-fatal import error, surfaced by the HUD.</summary>
public string LastError { get; private set; } = "";
// ── undo/redo ───────────────────────────────────────────────────────────────────────────────────
// Whole-level JSON snapshots in the manager-owned history (so it survives test-play round trips).
// Explicit checkpoints cover the stage's own operations; a slow poll in TickMenu catches the many
// scattered HUD edits (inspector fields, palette buttons, block list) without touching each handler.
private EditorUndoHistory UndoHistory => Manager.LevelEditorUndoHistory;
private const float UNDO_POLL_INTERVAL = 0.25f;
private float _undoPollDelay;
// One physical Ctrl+Z can reach us through BOTH the focused shortcut panel and the raw-keyboard
// hotkey path (mirroring the duplicated Ctrl+C/V handling); a short debounce prevents double-steps.
private const float UNDO_ACTION_DEBOUNCE = 0.1f;
private float _editorClock;
private float _lastUndoActionTime = float.MinValue;
// ── schematic geometry / palette ────────────────────────────────────────────────────────────────
private GameObject _schematicRoot;
// Animated sprite markers live OUTSIDE _schematicRoot (which is destroyed wholesale on every
// Rebuild) and are reused, so redrawing the schematic can't restart their animations. See MarkerPool.
private GameObject _markerRoot;
private readonly MarkerPool _coinMarkers = new( "coin" );
private readonly MarkerPool _playerMarkers = new( "player" );
private readonly MarkerPool _blockMarkers = new( "block" );
private LevelEditorHud _hud;
private const float MIN_RECT = 4f;
// Resize is easy to grab from outside the obstacle, while only a narrow strip inside each face is
// reserved for it. That leaves a usable move target through the middle of even a 4px-thin obstacle.
private const float RESIZE_FACE_OUTER_HIT_DISTANCE = 5f;
private const float RESIZE_FACE_INNER_HIT_DISTANCE = 2f;
// Half-extent of a block-spawn's 40px block (matches the DrawSpawns / HitSpawn markers), used to
// edge-snap a spawn like a 40×40 obstacle.
private const float SPAWN_HALF = 20f;
// Player-spawn snapping / border / hit-test use the ACTUAL player hitbox (COLLISION_SIZE 8×10) so the
// player snaps flush to walls exactly where it will rest in game (the 12px visual sprite is bigger).
private static readonly float PLAYER_HALF_W = Player.COLLISION_SIZE.x / 2f; // 4
private static readonly float PLAYER_HALF_H = Player.COLLISION_SIZE.y / 2f; // 5
// Coin half-extents (the 4x6 pickup art IS the hitbox). Hit-testing uses a wider tolerance —
// the marker is tiny.
private const float COIN_HALF_W = Coin.ART_W / 2f; // 2
private const float COIN_HALF_H = Coin.ART_H / 2f; // 3
private const float COIN_HIT_TOLERANCE = 4f;
private const int DEPTH_FILL = Globals.DEPTH_BLOCK; // obstacle fills
private const int DEPTH_MARKER = Globals.DEPTH_PLAYER; // spawn / player markers
private const int DEPTH_WALLS = Globals.DEPTH_WALL; // walls, borders, spike strips
private const float DEPTH_VISION = Globals.DEPTH_VISION_COVER; // vision-blocking obstacle cue (fractional layer — see Globals)
private const int DEPTH_OVERLAY = 20; // selection / drag preview (above walls)
private static readonly Color WallGrey = LevelCosmeticsRandomizer.DefaultWallColor;
private static readonly Color SpikeRed = new( 0.86f, 0.22f, 0.22f );
// Highlights a spiked OBSTACLE face whose along-length isn't a multiple of the 5px spike tile
// (uneven teeth / corner z-fighting — see FaceSizeUneven / the HUD warning).
private static readonly Color SpikeOrange = new( 1f, 0.55f, 0.1f );
private static readonly Color SpikeYellow = new( 1f, 0.9f, 0.15f );
// Thickness of the schematic spike-indicator strips (thin so they don't swamp the wall borders).
private const float SPIKE_STRIP_THICK = 2f;
private const int SPIKE_CHILD_ORDER = -1;
private const float SPIKE_PULSE_SPEED = 6f;
private readonly record struct SpikeIndicator( SpriteRenderer Renderer, Color LowColor );
private readonly List<SpikeIndicator> _spikeIndicators = new();
private float _spikePulseTime;
private static readonly Color SelectionBlue = new( 0.5f, 0.78f, 1f, 0.5f );
private static readonly Color SelectionWhite = new( 0.88f, 0.96f, 1f, 0.82f );
private const float SELECTION_HALO_PADDING_MIN = 2f;
private const float SELECTION_HALO_PADDING_MAX = 2.6f;
private const float SELECTION_HALO_THICK = 0.5f;
private const int SELECTION_CHILD_ORDER = -2;
private const float SELECTION_PULSE_SPEED = 3.5f;
private readonly record struct SelectionIndicator( SpriteRenderer Renderer, RectF Bounds, Direction Side );
private readonly List<SelectionIndicator> _selectionIndicators = new();
private float _selectionPulseTime;
private static readonly Color SpawnFill = new( 1f, 1f, 1f, 0.16f );
private static readonly Color SpawnOutline = new( 1f, 1f, 1f, 0.55f );
private static readonly Color CoinYellow = new( 221 / 255f, 221 / 255f, 153 / 255f );
private static readonly Color VisionFill = new( 0.2f, 0.8f, 0.95f, 0.16f );
// Thin border around the exact player-spawn hitbox, independent of the larger selection halo.
private static readonly Color PlayerBorderColor = new( 1f, 1f, 1f, 0.15f );
private static readonly Color VisionOutline = new( 0.32f, 0.85f, 1f, 0.85f );
private static readonly Color PreviewFill = new( 0.4f, 1f, 0.6f, 0.35f );
private static readonly Color PreviewOutline = new( 0.4f, 1f, 0.6f, 0.9f );
private static readonly Color MoveHoverFill = new( 0.25f, 1f, 0.58f, 0.16f );
private static readonly Color MoveHoverOutline = new( 0.35f, 1f, 0.68f, 0.95f );
private static readonly Color ResizeHoverColor = new( 1f, 0.72f, 0.18f, 0.95f );
// ── pointer state (forwarded from the HUD canvas) ───────────────────────────────────────────────
private enum ObstacleHoverIntent { None, Move, Resize }
private bool _primaryHeld, _prevPrimaryHeld, _hovering;
private bool _snapInvertHeld;
private bool _cardinalDragHeld;
private Vector2 _hoverLogical;
private Vector2 _pointerDownLogical;
private bool _pointerDragged;
private bool _spikeToggledOnPointerDown;
private bool _dragging;
private bool _creatingRect; // dragging out a new obstacle rect
private bool _creatingAlternateRect;
private bool _marqueeSelecting;
private bool _marqueeAdditive;
// The element an additive (Shift/Ctrl) click landed on, and whether it was already selected —
// releasing without dragging then TOGGLES it out of the selection (see PointerUp).
private Selection _additiveClickSelection = new( SelKind.None, -1 );
private bool _additiveClickWasSelected;
private Vector2 _dragStart, _dragCurrent;
private SelKind _moveKind = SelKind.None;
private int _moveIndex = -1;
private Vector2 _grabOffset;
private Direction _resizeSide = Direction.None;
private RectF _resizeOriginRect;
private float _resizeGrabOffset;
private ObstacleHoverIntent _obstacleHoverIntent;
private SelKind _hoverRectKind = SelKind.None;
private int _hoverObstacleIndex = -1;
private Direction _hoverResizeSide = Direction.None;
private readonly Dictionary<Selection, Vector2> _moveOriginCenters = new();
private readonly Dictionary<Selection, RectF> _moveOriginRects = new();
// Mirror bookkeeping for the operation in flight: selected obstacles that sit exactly at a mirror
// image of the move anchor (delta gets flipped per axis), and the twins a group resize re-syncs.
private readonly Dictionary<Selection, (bool FlipX, bool FlipY)> _moveMirrorRelations = new();
private readonly List<(int Index, bool FlipX, bool FlipY)> _resizeMirrorPartners = new();
private readonly record struct CopiedObstacle( RectF Rect, Direction[] Spikes, bool BlocksVision, bool IsFence, bool IsGlass );
private readonly record struct CopiedSpawn( Vector2 Position, EditorBlock Pin, float AbilityInterval, float AbilityRandomness,
float AbilityStartOffset, float AbilityClosedTime, Direction Direction, TurnMode TurnMode );
private readonly List<CopiedObstacle> _copiedObstacles = new();
private readonly List<CopiedSpawn> _copiedSpawns = new();
private readonly List<Vector2> _copiedPlayers = new();
private readonly List<Vector2> _copiedCoins = new();
private readonly List<RectF> _copiedAlternateRects = new();
// Separate bounds per clipboard: the two survive alongside each other, so one paste's anchor must
// not be left pointing at the other's copied extent.
private RectF _copiedBounds;
private RectF _copiedAlternateBounds;
public LevelEditorStage( GameManager manager ) : base( manager ) { }
/// <summary>Console command: open the level editor on an optional level id. Dev-only — players
/// reach the editor through the menus (<see cref="OpenLevelEditor"/>), never the console.</summary>
[ConCmd( "level_editor" )]
public static void OpenLevelEditorCmd( string levelId = "" )
{
if ( !Game.IsEditor ) return;
OpenLevelEditor( levelId );
}
/// <summary>Open the level editor. Optional level id loads an existing level for editing.</summary>
public static void OpenLevelEditor( string levelId = "" )
{
var mgr = GameManager.Instance;
if ( mgr is null || mgr.IsTransitioning ) return;
var stage = new LevelEditorStage( mgr );
bool explicitLevel = !string.IsNullOrWhiteSpace( levelId );
if ( !explicitLevel && mgr.TryRestoreLevelEditorSession( out var sessionLevel, out var sessionTool, out var sessionCharacterId ) )
{
stage.Level = sessionLevel;
stage.Tool = sessionTool;
stage.SelectedCharacterId = sessionCharacterId ?? Settings.Current.SelectedCharacterId;
}
else
{
var requestedLevelId = explicitLevel ? levelId : EditorPrefs.Current.LastLevelId;
bool loaded = false;
if ( !string.IsNullOrWhiteSpace( requestedLevelId ) )
{
var def = Levels.Get( requestedLevelId );
if ( def is not null )
{
stage.Level = EditorLevel.FromLevelDef( def );
RememberOpenedLevel( def.Id );
loaded = true;
}
}
// Nothing to restore or load: the editor opens on a BRAND-NEW empty level, so roll its
// cosmetics + song (as Clear Level does) instead of always starting on the same default teal.
if ( !loaded ) stage.RandomizeEnvironment();
// A fresh document (no restored session): undo history never crosses documents.
mgr.LevelEditorUndoHistory.Reset( stage.Level.ToJsonString( fullAuthoringState: true ), stage.Level.ProjectSourceId );
}
mgr.TransitionToStage( stage );
}
// ── palette (read the model; camera clear updates live via RefreshPalette) ───────────────────────
protected override Color EffectiveClearColor => Level.OutOfBoundsColor ?? Manager.ClearColor;
protected override string PlayfieldSprite => Level.CheckerboardColor.HasValue ? "sprites/background_neutral.sprite" : "sprites/background.sprite";
protected override Color PlayfieldTint => Level.CheckerboardColor.HasValue ? GammaToLinear( Level.CheckerboardColor.Value ) : Color.White;
protected override PlayfieldPattern PlayfieldPatternKind => Level.PlayfieldPattern;
protected override int PlayfieldCellScale => Level.PlayfieldCellScale;
protected override System.Collections.Generic.IReadOnlyList<string> PlayfieldPatternRows => Level.PlayfieldPatternRows;
protected override Color PlayfieldPatternColor => Level.CheckerboardColor ?? base.PlayfieldPatternColor;
protected override Color? PlayfieldPatternSecondColor => Level.CheckerboardSecondColor;
protected override bool AlternatePlayfieldEnabled => Level.AlternatePlayfieldEnabled;
protected override Color AlternatePlayfieldColor => Level.AlternateCheckerboardColor ?? PlayfieldPatternColor;
protected override Color? AlternatePlayfieldSecondColor => Level.AlternateCheckerboardSecondColor;
protected override PlayfieldPattern AlternatePlayfieldPatternKind => Level.AlternatePlayfieldPattern;
protected override int AlternatePlayfieldCellScale => Level.AlternatePlayfieldCellScale;
protected override System.Collections.Generic.IReadOnlyList<string> AlternatePlayfieldPatternRows => Level.AlternatePlayfieldPatternRows;
protected override System.Collections.Generic.IReadOnlyList<RectF> AlternatePlayfieldRects => Level.AlternatePlayfieldRects;
protected override Color? BackgroundBlockBaseColor => Level.BackgroundBlockColor;
protected override System.Collections.Generic.IReadOnlyList<Color> BackgroundBlockColors => Level.BackgroundBlockColors;
protected override float BackgroundBlockScale => Level.BackgroundBlockScale;
protected override float BackgroundBlockDensity => Level.BackgroundBlockDensity;
protected override float BackgroundBlockOpacity => Level.BackgroundBlockOpacity;
protected override float BackgroundDriftSpeed => Level.BackgroundDriftSpeed;
protected override BackgroundDriftBias BackgroundBlockDriftBias => Level.BackgroundDriftBias;
// --- background particle ambience: previewed live in the editor (ticked from TickMenu, since
// the editor's fixed-step sim is frozen). Solids come straight from the editable level, so a
// dragged obstacle re-shapes the weather immediately. --------------------------------------------
protected override BackgroundParticleSettings BuildBackgroundParticleSettings()
=> BackgroundParticleSettings.FromEditorLevel( Level );
protected override void CollectBackgroundParticleStaticSolids( System.Collections.Generic.List<RectF> solids )
{
// Ordinary obstacles AND glass, never fences — mirrors GameStage's split of Level.Obstacles.
for ( int i = 0; i < Level.Obstacles.Count; i++ )
if ( !Level.FenceObstacles.Contains( i ) )
solids.Add( Level.Obstacles[i] );
}
/// <summary>Re-read the particle settings without restarting the live weather (or re-rolling the
/// drifting background blocks, which a full <see cref="StageBase.RefreshEnvironment"/> would do) —
/// the HUD calls this per slider tick while the particle settings are being dragged.</summary>
public void RefreshBackgroundParticles() => InvalidateBackgroundParticleSettings();
public override bool BlocksSimulation => true;
protected override void OnEnter()
{
RefreshMusic();
SpawnBackgroundBlocks();
CreateHud();
// Seed the undo baseline if the history is brand-new (a restored session / test-play return
// keeps its existing chain — the manager owns the history precisely so it survives those).
UndoHistory.EnsureSeeded( Level.ToJsonString( fullAuthoringState: true ), Level.ProjectSourceId );
Rebuild();
}
protected override void OnExit()
{
Manager.RememberLevelEditorSession( Level, Tool, SelectedCharacterId );
}
private void CreateHud()
{
var ui = CreateChild( "UI" );
var screen = ui.Components.Create<ScreenPanel>();
screen.ZIndex = 100;
_hud = ui.Components.Create<LevelEditorHud>();
_hud.Stage = this;
}
/// <summary>Exit the editor back to the title screen.</summary>
public void ExitToTitle()
{
if ( Manager.IsTransitioning ) return;
Manager.TransitionToStage( new TitleStage( Manager ) );
}
/// <summary>Exit the editor back to the workshop screen — where the editor is launched from.</summary>
public void ExitToWorkshop()
{
if ( Manager.IsTransitioning ) return;
Manager.TransitionToStage( new WorkshopStage( Manager ) );
}
/// <summary>Reset the editor to a fresh, empty level with rolled cosmetics (discards the current
/// unsaved work). The roll lands BEFORE the undo checkpoint so the new document's baseline is the
/// look on screen.</summary>
public void ResetToDefault()
{
CheckpointOutgoingDocument();
Level = new EditorLevel();
RandomizeEnvironment();
RefreshMusic();
LevelRevision++;
RememberOpenedLevel( "" );
Select( SelKind.None, -1 );
LastError = "";
_hud?.ResetTextFields();
RefreshPalette();
RefreshEnvironment();
Rebuild();
CheckpointUndo( newDocument: true ); // a reset is undoable — Ctrl+Z rescues discarded work
}
/// <summary>Roll a random palette + background pattern + song onto <see cref="Level"/> — the pair the
/// HUD's palette/shuffle buttons roll, plus a uniform pick from the music library (no "unused by
/// other levels" preference, unlike the HUD's dice). Mutates the model only; callers that roll on a
/// LIVE editor must refresh (palette, environment, music) afterwards.</summary>
private void RandomizeEnvironment()
{
var rng = new Random();
bool alternate = Level.AlternatePlayfieldEnabled;
LevelCosmeticsRandomizer.RollColors( rng, alternate, Level.BackgroundBlockColors is { Count: 3 } ).ApplyTo( Level );
LevelCosmeticsRandomizer.RollPattern( rng, alternate ).ApplyTo( Level );
var songs = Audio.RandomizableMusicNames(); // empty until the library mounts: keep the default
if ( songs.Count > 0 ) Level.Music = songs[rng.Next( songs.Count )];
}
// ── pointer plumbing ────────────────────────────────────────────────────────────────────────────
/// <summary>Forwarded each frame by the HUD: whether the world canvas is being pressed and hovered.</summary>
public void SetPointer( bool primaryHeld, bool hovering )
{
_primaryHeld = primaryHeld;
_hovering = hovering;
}
public bool SnapInvertHeld => _snapInvertHeld;
public bool SnapActive => EditorPrefs.Current.SnapEnabled != _snapInvertHeld;
public void SetSnapInvertHeld( bool held ) => _snapInvertHeld = held;
public void SetCardinalDragHeld( bool held ) => _cardinalDragHeld = held;
public Vector2 ScreenToLogical( Vector2 screen )
{
if ( !Manager.Camera.IsValid() ) return Vector2.Zero;
var ray = Manager.Camera.ScreenPixelToRay( screen );
return new Vector2( ray.Position.x, ray.Position.y );
}
/// <summary>Project a logical (world) point to a normalised [0,1] screen fraction (0,0 = top-left)
/// for the HUD to anchor a floating overlay to it. False if the camera is invalid.
///
/// Uses the no-out <c>PointToScreenNormal</c> (→ <c>ToScreenNormal</c> = WorldToView + Remap), NOT
/// the <c>out bool isBehind</c> overload: that overload routes through <c>ToScreenWithDirection</c>,
/// whose ORTHOGRAPHIC branch is broken (`(x+2)/2` instead of `(x+1)/2`) and lands the point in the
/// wrong screen quadrant. Our top-down ortho camera has everything at z≈0 in front, so we don't
/// need the behind flag.</summary>
public bool WorldToScreenFraction( Vector2 world, out Vector2 frac )
{
frac = default;
if ( !Manager.Camera.IsValid() ) return false;
var n = Manager.Camera.PointToScreenNormal( new Vector3( world.x, world.y, 0f ) );
frac = new Vector2( n.x, n.y );
return true;
}
/// <summary>A width/height readout for the HUD's floating dimension label: the obstacle rect being
/// dragged out (create), or the currently-selected obstacle (which also covers a move-in-progress,
/// since the obstacle stays selected while dragged). Anchored at the rect's top-centre in world
/// space. Not visible for spawn/player selections.</summary>
public readonly record struct DimReadout( bool Visible, Vector2 WorldPos, float Width, float Height );
public DimReadout CurrentDimReadout()
{
if ( _creatingRect || _creatingAlternateRect )
{
RectF cr = CreateRect();
if ( cr.Width >= 1f && cr.Height >= 1f )
return new DimReadout( true, new Vector2( ( cr.Left + cr.Right ) / 2f, cr.Top ), cr.Width, cr.Height );
return default;
}
if ( SelectedKind == SelKind.Obstacle && SelectedIndex >= 0 && SelectedIndex < Level.Obstacles.Count )
{
var r = Level.Obstacles[SelectedIndex];
return new DimReadout( true, new Vector2( ( r.Left + r.Right ) / 2f, r.Top ), r.Width, r.Height );
}
if ( SelectedKind == SelKind.AlternatePattern && SelectedIndex >= 0 && SelectedIndex < Level.AlternatePlayfieldRects.Count )
{
var r = Level.AlternatePlayfieldRects[SelectedIndex];
return new DimReadout( true, new Vector2( ( r.Left + r.Right ) / 2f, r.Top ), r.Width, r.Height );
}
return default;
}
public override void TickMenu( float dt )
{
UpdateSpikePulse( dt );
UpdateSelectionPulse( dt );
_editorClock += dt;
UndoHistory.Advance( dt );
// Live ambience preview: the editor's fixed-step sim never runs, so drive the level's
// background particles from the per-frame menu tick instead.
TickBackgroundParticles( dt );
if ( !Manager.Camera.IsValid() ) return;
_hud?.TickShortcutRepeat( dt );
_hoverLogical = ScreenToLogical( Mouse.Position );
bool down = _primaryHeld;
bool justDown = down && !_prevPrimaryHeld;
bool justUp = !down && _prevPrimaryHeld;
if ( justDown )
{
_pointerDownLogical = _hoverLogical;
_pointerDragged = false;
PointerDown( _hoverLogical );
}
else if ( down )
{
Vector2 delta = _hoverLogical - _pointerDownLogical;
if ( delta.LengthSquared > 1f ) _pointerDragged = true;
if ( _dragging && _pointerDragged ) PointerDrag( _hoverLogical );
}
else if ( justUp ) PointerUp( _hoverLogical );
// Keyboard hotkeys use the RAW keyboard (no project input bindings). Gated on hovering the
// world canvas + not dragging so they never fire while typing in an inspector TextEntry
// (whose backspace/arrows must edit the text, not the selection).
if ( !_dragging && _hovering )
HandleHotkeys();
UpdateObstacleHover();
// Catch HUD-driven edits (inspector fields, palette buttons, block list, …) without touching
// every handler: when no pointer operation is in flight, checkpoint any drift since the last
// snapshot. Rapid bursts (typing in a field) coalesce into a single undo entry.
_undoPollDelay -= dt;
if ( !down && !_dragging && _undoPollDelay <= 0f )
{
_undoPollDelay = UNDO_POLL_INTERVAL;
CheckpointUndo( "hud" );
}
_prevPrimaryHeld = down;
}
private void HandleHotkeys()
{
if ( CtrlHeld && Input.Keyboard.Pressed( "A" ) )
{
SelectAll();
return;
}
if ( CtrlHeld && Input.Keyboard.Pressed( "C" ) )
{
CopySelection();
return;
}
if ( CtrlHeld && Input.Keyboard.Pressed( "V" ) )
{
PasteSelection();
return;
}
if ( CtrlHeld && Input.Keyboard.Pressed( "Z" ) )
{
if ( ShiftHeld ) Redo();
else Undo();
return;
}
if ( CtrlHeld && Input.Keyboard.Pressed( "Y" ) )
{
Redo();
return;
}
if ( Input.Keyboard.Pressed( "DELETE" ) || Input.Keyboard.Pressed( "BACKSPACE" ) )
{
DeleteSelected();
return;
}
}
private void UpdateObstacleHover()
{
ObstacleHoverIntent intent = ObstacleHoverIntent.None;
SelKind hoverKind = SelKind.None;
int obstacleIndex = -1;
Direction resizeSide = Direction.None;
if ( _hovering && !_dragging && !AdditiveHeld && Tool is EditTool.Select or EditTool.AlternatePattern )
{
if ( TryHitSelectedRectFace( _hoverLogical, out hoverKind, out obstacleIndex, out resizeSide ) )
intent = ObstacleHoverIntent.Resize;
else if ( Tool == EditTool.AlternatePattern )
{
obstacleIndex = HitAlternateRect( _hoverLogical );
if ( obstacleIndex >= 0 )
{
intent = ObstacleHoverIntent.Move;
hoverKind = SelKind.AlternatePattern;
}
}
else
{
var (kind, index) = HitAny( _hoverLogical );
if ( kind == SelKind.Obstacle )
{
intent = ObstacleHoverIntent.Move;
hoverKind = kind;
obstacleIndex = index;
}
}
}
if ( intent == _obstacleHoverIntent && hoverKind == _hoverRectKind
&& obstacleIndex == _hoverObstacleIndex && resizeSide == _hoverResizeSide )
return;
_obstacleHoverIntent = intent;
_hoverRectKind = hoverKind;
_hoverObstacleIndex = obstacleIndex;
_hoverResizeSide = resizeSide;
Rebuild();
}
/// <summary>Copy the current selection. The alternate-pattern tool keeps its OWN clipboard: its
/// selection can never mix with obstacles/spawns/players/coins, so a copy (or a paste that falls back
/// to copying) in one tool must not discard what the other tool put on the clipboard.</summary>
public bool CopySelection()
{
if ( _selection.Count == 0 ) return false;
return Tool == EditTool.AlternatePattern ? CopyAlternatePatternSelection() : CopyLevelObjectSelection();
}
private bool CopyAlternatePatternSelection()
{
_copiedAlternateRects.Clear();
bool haveBounds = false;
foreach ( var selected in _selection.Where( s => s.Kind == SelKind.AlternatePattern ).OrderBy( s => s.Index ) )
{
if ( selected.Index < 0 || selected.Index >= Level.AlternatePlayfieldRects.Count ) continue;
_copiedAlternateRects.Add( Level.AlternatePlayfieldRects[selected.Index] );
RectF bounds = SelectionBounds( selected );
_copiedAlternateBounds = haveBounds ? Union( _copiedAlternateBounds, bounds ) : bounds;
haveBounds = true;
}
Log.Info( $"Copied {_copiedAlternateRects.Count} alternate pattern rect(s)." );
return haveBounds;
}
private bool CopyLevelObjectSelection()
{
_copiedObstacles.Clear();
_copiedSpawns.Clear();
_copiedPlayers.Clear();
_copiedCoins.Clear();
bool haveBounds = false;
foreach ( var selected in _selection.OrderBy( s => s.Kind ).ThenBy( s => s.Index ) )
{
switch ( selected.Kind )
{
case SelKind.Obstacle when selected.Index >= 0 && selected.Index < Level.Obstacles.Count:
_copiedObstacles.Add( new CopiedObstacle(
Level.Obstacles[selected.Index],
Level.SpikedObstacleSides.FirstOrDefault( spec => spec.Obstacle == selected.Index )?.Sides.ToArray() ?? Array.Empty<Direction>(),
Level.ObstacleBlocksVision( selected.Index ),
Level.ObstacleIsFence( selected.Index ),
Level.ObstacleIsGlass( selected.Index ) ) );
break;
case SelKind.Spawn when selected.Index >= 0 && selected.Index < Level.SpawnPositions.Count:
// Snapshot the pin at copy time — the clipboard must not track later edits to the
// source pin (phase cycles / side toggles between copy and paste).
_copiedSpawns.Add( new CopiedSpawn( Level.SpawnPositions[selected.Index], Level.SpawnPin( selected.Index )?.Clone(),
Level.SpawnAbilityInterval( selected.Index ), Level.SpawnAbilityRandomness( selected.Index ),
Level.SpawnAbilityStartOffset( selected.Index ), Level.SpawnAbilityClosedTime( selected.Index ),
Level.SpawnDirection( selected.Index ), Level.SpawnTurnMode( selected.Index ) ) );
break;
case SelKind.Player when selected.Index >= 0 && selected.Index < Level.PlayerSpawns.Count:
_copiedPlayers.Add( Level.PlayerSpawns[selected.Index] );
break;
case SelKind.Coin when selected.Index >= 0 && selected.Index < Level.Coins.Count:
_copiedCoins.Add( Level.Coins[selected.Index] );
break;
}
RectF bounds = SelectionBounds( selected );
_copiedBounds = haveBounds ? Union( _copiedBounds, bounds ) : bounds;
haveBounds = true;
}
Log.Info( $"Copied {_selection.Count} editor object(s)." );
return haveBounds;
}
public bool CopySelectedObstacle() => CopySelection();
public void PasteSelection()
{
if ( Tool == EditTool.AlternatePattern )
{
PasteAlternatePatternSelection();
return;
}
if ( _copiedObstacles.Count == 0 && _copiedSpawns.Count == 0 && _copiedPlayers.Count == 0
&& _copiedCoins.Count == 0 && !CopySelection() ) return;
// Snap the delta to whole pixels: SnapPoint lands the hover on the grid, but subtracting the
// copied CENTRE reintroduces a half-pixel for odd-sized rects (a 25px-wide obstacle has a *.5
// centre), which would paste every copy off the pixel grid. Rounding the delta keeps integer
// sources integer and preserves relative layout across a multi-object paste.
Vector2 delta = ClampCopiedDelta( Snap( SnapPoint( _hoverLogical ) - RectCenter( _copiedBounds ) ) );
Select( SelKind.None, -1 );
foreach ( var copied in _copiedObstacles )
{
Level.Obstacles.Add( Translate( copied.Rect, delta.x, delta.y ) );
int index = Level.Obstacles.Count - 1;
foreach ( Direction side in copied.Spikes ) Level.ToggleObstacleSpike( index, side );
if ( copied.BlocksVision ) Level.ToggleObstacleVision( index );
if ( copied.IsFence ) Level.ToggleObstacleFence( index );
if ( copied.IsGlass ) Level.ToggleObstacleGlass( index );
AddSelection( new Selection( SelKind.Obstacle, index ) );
}
foreach ( var copied in _copiedSpawns )
{
int index = Level.AddSpawn( copied.Position + delta );
// Clone per paste so repeated pastes (and the clipboard) don't share one editable
// pinned-block instance.
Level.SetSpawnPin( index, copied.Pin?.Clone() );
Level.SetSpawnAbilityTimer( index, copied.AbilityInterval, copied.AbilityRandomness, copied.AbilityStartOffset,
copied.AbilityClosedTime );
Level.SetSpawnDirection( index, copied.Direction );
Level.SetSpawnTurnMode( index, copied.TurnMode );
AddSelection( new Selection( SelKind.Spawn, index ) );
}
foreach ( var copied in _copiedPlayers )
{
// Pasting a player spawn ADDS one (spawns are a multi-candidate list now).
Level.PlayerSpawns.Add( copied + delta );
AddSelection( new Selection( SelKind.Player, Level.PlayerSpawns.Count - 1 ) );
}
foreach ( var copied in _copiedCoins )
{
Level.Coins.Add( copied + delta );
AddSelection( new Selection( SelKind.Coin, Level.Coins.Count - 1 ) );
}
Tool = EditTool.Select;
Rebuild();
CheckpointUndo();
}
private void PasteAlternatePatternSelection()
{
if ( _copiedAlternateRects.Count == 0 && !CopySelection() ) return;
Vector2 delta = ClampCopiedRectDelta(
Snap( SnapPoint( _hoverLogical ) - RectCenter( _copiedAlternateBounds ) ), _copiedAlternateBounds );
Select( SelKind.None, -1 );
foreach ( RectF copied in _copiedAlternateRects )
{
Level.AlternatePlayfieldRects.Add( ClampRect( Translate( copied, delta.x, delta.y ) ) );
AddSelection( new Selection( SelKind.AlternatePattern, Level.AlternatePlayfieldRects.Count - 1 ) );
}
RefreshAlternatePlayfield();
Rebuild();
CheckpointUndo();
}
public void PasteObstacle() => PasteSelection();
/// <summary>Keep a pasted rect group inside the arena. Used by the alternate-pattern clipboard, which
/// is rects only — it must NOT fold in the marker insets <see cref="ClampCopiedDelta"/> reads, since
/// those lists now hold a live level-object clipboard rather than being cleared by the alt copy.</summary>
private static Vector2 ClampCopiedRectDelta( Vector2 delta, RectF bounds )
=> new( Math.Clamp( delta.x, -bounds.Left, Arena.WIDTH - bounds.Right ),
Math.Clamp( delta.y, -bounds.Bottom, Arena.HEIGHT - bounds.Top ) );
private Vector2 ClampCopiedDelta( Vector2 delta )
{
float minDx = -_copiedBounds.Left, maxDx = Arena.WIDTH - _copiedBounds.Right;
float minDy = -_copiedBounds.Bottom, maxDy = Arena.HEIGHT - _copiedBounds.Top;
foreach ( var spawn in _copiedSpawns )
{
minDx = MathF.Max( minDx, Arena.WALL_SIZE + SPAWN_HALF - spawn.Position.x );
maxDx = MathF.Min( maxDx, Arena.WIDTH - Arena.WALL_SIZE - SPAWN_HALF - spawn.Position.x );
minDy = MathF.Max( minDy, Arena.WALL_SIZE + SPAWN_HALF - spawn.Position.y );
maxDy = MathF.Min( maxDy, Arena.HEIGHT - Arena.WALL_SIZE - SPAWN_HALF - spawn.Position.y );
}
foreach ( var copied in _copiedPlayers )
{
minDx = MathF.Max( minDx, Arena.WALL_SIZE + PLAYER_HALF_W - copied.x );
maxDx = MathF.Min( maxDx, Arena.WIDTH - Arena.WALL_SIZE - PLAYER_HALF_W - copied.x );
minDy = MathF.Max( minDy, Arena.WALL_SIZE + PLAYER_HALF_H - copied.y );
maxDy = MathF.Min( maxDy, Arena.HEIGHT - Arena.WALL_SIZE - PLAYER_HALF_H - copied.y );
}
foreach ( var copied in _copiedCoins )
{
minDx = MathF.Max( minDx, Arena.WALL_SIZE + COIN_HALF_W - copied.x );
maxDx = MathF.Min( maxDx, Arena.WIDTH - Arena.WALL_SIZE - COIN_HALF_W - copied.x );
minDy = MathF.Max( minDy, Arena.WALL_SIZE + COIN_HALF_H - copied.y );
maxDy = MathF.Min( maxDy, Arena.HEIGHT - Arena.WALL_SIZE - COIN_HALF_H - copied.y );
}
return new Vector2( Math.Clamp( delta.x, minDx, maxDx ), Math.Clamp( delta.y, minDy, maxDy ) );
}
public void NudgeSelected( int dx, int dy )
{
if ( _dragging || _selection.Count == 0 ) return;
// Nudges keep mirror twins mirrored: partners of the primary get the axis-flipped delta.
RefreshMoveMirrorRelations( _primarySelection );
Vector2 delta = ClampSelectionDelta( new Vector2( dx, dy ) );
foreach ( var selected in _selection )
{
// Mirror twins of the primary follow with the axis-flipped delta, staying mirrored.
Vector2 elementDelta = MirroredDelta( selected, delta );
switch ( selected.Kind )
{
case SelKind.Spawn when selected.Index >= 0 && selected.Index < Level.SpawnPositions.Count:
Level.SpawnPositions[selected.Index] += elementDelta;
break;
case SelKind.Player when selected.Index >= 0 && selected.Index < Level.PlayerSpawns.Count:
Level.PlayerSpawns[selected.Index] += elementDelta;
break;
case SelKind.Coin when selected.Index >= 0 && selected.Index < Level.Coins.Count:
Level.Coins[selected.Index] += elementDelta;
break;
case SelKind.Obstacle when selected.Index >= 0 && selected.Index < Level.Obstacles.Count:
// Re-snap to the pixel grid after translating so arrow keys also RESCUE an
// obstacle that's already off-grid (integer nudges alone can't leave a *.5 edge).
Level.Obstacles[selected.Index] = SnapRectPixel(
Translate( Level.Obstacles[selected.Index], elementDelta.x, elementDelta.y ) );
break;
case SelKind.AlternatePattern when Tool == EditTool.AlternatePattern
&& selected.Index >= 0 && selected.Index < Level.AlternatePlayfieldRects.Count:
Level.AlternatePlayfieldRects[selected.Index] = SnapRectPixel(
Translate( Level.AlternatePlayfieldRects[selected.Index], delta.x, delta.y ) );
break;
}
}
_moveMirrorRelations.Clear();
if ( Tool == EditTool.AlternatePattern ) RefreshAlternatePlayfield();
Rebuild();
// Coalesced: a burst of arrow presses reverts as ONE undo step (a >1s pause starts a new one).
CheckpointUndo( "nudge" );
}
private Vector2 ClampSelectionDelta( Vector2 delta, bool useMoveOrigins = false )
{
float minDx = float.NegativeInfinity, maxDx = float.PositiveInfinity;
float minDy = float.NegativeInfinity, maxDy = float.PositiveInfinity;
foreach ( var selected in _selection )
{
RectF bounds = useMoveOrigins ? MoveOriginBounds( selected ) : SelectionBounds( selected );
float insetX = selected.Kind == SelKind.Player ? Arena.WALL_SIZE : 0f;
float insetY = selected.Kind == SelKind.Player ? Arena.WALL_SIZE : 0f;
if ( selected.Kind is SelKind.Spawn or SelKind.Coin ) insetX = insetY = Arena.WALL_SIZE;
// A mirror twin moves with the FLIPPED delta on a mirrored axis, and the arena is symmetric,
// so the anchor's own clamp already covers it there — the unflipped clamp would be wrong.
bool skipX = false, skipY = false;
if ( _moveMirrorRelations.TryGetValue( selected, out var relation ) )
{
skipX = relation.FlipX;
skipY = relation.FlipY;
}
if ( !skipX )
{
minDx = MathF.Max( minDx, insetX - bounds.Left );
maxDx = MathF.Min( maxDx, Arena.WIDTH - insetX - bounds.Right );
}
if ( !skipY )
{
minDy = MathF.Max( minDy, insetY - bounds.Bottom );
maxDy = MathF.Min( maxDy, Arena.HEIGHT - insetY - bounds.Top );
}
}
return new Vector2( Math.Clamp( delta.x, minDx, maxDx ), Math.Clamp( delta.y, minDy, maxDy ) );
}
private RectF MoveOriginBounds( Selection selected )
{
if ( (selected.Kind is SelKind.Obstacle or SelKind.AlternatePattern) && _moveOriginRects.TryGetValue( selected, out var rect ) )
return rect;
if ( !_moveOriginCenters.TryGetValue( selected, out var center ) )
return SelectionBounds( selected );
return selected.Kind switch
{
SelKind.Spawn => BoxAt( center, SPAWN_HALF, SPAWN_HALF ),
SelKind.Player => BoxAt( center, PLAYER_HALF_W, PLAYER_HALF_H ),
SelKind.Coin => BoxAt( center, COIN_HALF_W, COIN_HALF_H ),
_ => SelectionBounds( selected ),
};
}
private static Vector2 Snap( Vector2 v ) => new( MathF.Round( v.x ), MathF.Round( v.y ) );
// ── grid snapping (active while the alignment grid overlay is shown) ─────────────────────────────
// When the editor grid is visible, points snap to the nearest grid LINE matching DrawGrid, so
// dragged/created geometry lands on the lines the author sees. When off, use 1px integer snapping.
private bool GridSnapActive => SnapActive && EditorPrefs.Current.ShowGrid;
private static float GridLineOrigin( float gridSize, bool xAxis )
{
var settings = EditorPrefs.Current;
float offset = xAxis ? settings.GridOffsetX : settings.GridOffsetY;
offset = float.IsFinite( offset ) ? offset % gridSize : 0f;
if ( !settings.GridCenterCell && settings.GridBottomLeftOrigin )
return Arena.WALL_SIZE + offset;
float dimension = xAxis ? Arena.WIDTH : Arena.HEIGHT;
// Whole-pixel origin: "Center on Cell" with an ODD grid size lands lines on x.5, and snapped
// geometry inherits those half-pixels (the game has no sub-pixel geometry anywhere else).
return MathF.Round( dimension / 2f + ( settings.GridCenterCell ? gridSize / 2f : 0f ) + offset );
}
private static float GridSnapAxis( float v, bool xAxis )
{
var s = EditorPrefs.Current;
float gs = MathF.Max( 2f, s.GridSize );
if ( s.GridSmart ) return SmartGridSnapAxis( v, gs, xAxis );
float origin = GridLineOrigin( gs, xAxis );
// Rounded so a fractional grid size can't hand out sub-pixel snap targets (origin is already whole).
float line = MathF.Round( origin + MathF.Round( ( v - origin ) / gs ) * gs );
// Only pull to the line when the point is already CLOSE to it — don't force geometry to exactly
// fill cells; otherwise keep the free 1px position so off-grid placement is still possible.
return MathF.Abs( line - v ) <= GRID_SNAP_DIST ? line : MathF.Round( v );
}
// How close (logical px) a dragged point must be to a grid line before it snaps to it.
private const float GRID_SNAP_DIST = 4f;
// ── smart grid ────────────────────────────────────────────────────────────────────────────────────
// Two lattices mirrored inward from the playable edges (the INNER wall faces), so a line always sits
// wherever a block flush against either wall would land. Clamping keeps candidates inside the playable
// span; both clamp targets are themselves lattice lines (each wall face is the other lattice's k=0).
private static float SmartGridSnapAxis( float v, float gs, bool xAxis )
{
float dim = xAxis ? Arena.WIDTH : Arena.HEIGHT;
float lo = Arena.WALL_SIZE, hi = dim - Arena.WALL_SIZE;
float fromLo = Math.Clamp( lo + MathF.Round( ( v - lo ) / gs ) * gs, lo, hi );
float fromHi = Math.Clamp( hi - MathF.Round( ( hi - v ) / gs ) * gs, lo, hi );
// Rounded like GridSnapAxis — a fractional grid size must not produce sub-pixel snap targets.
float line = MathF.Round( MathF.Abs( fromLo - v ) <= MathF.Abs( fromHi - v ) ? fromLo : fromHi );
return MathF.Abs( line - v ) <= GRID_SNAP_DIST ? line : MathF.Round( v );
}
/// <summary>Sorted, deduplicated smart-grid line coordinates for one axis: multiples of the grid
/// size walked inward from both inner wall faces, merged where the mirrored lattices coincide.</summary>
private static List<float> SmartGridLines( float gs, bool xAxis )
{
float dim = xAxis ? Arena.WIDTH : Arena.HEIGHT;
float lo = Arena.WALL_SIZE, hi = dim - Arena.WALL_SIZE;
var raw = new List<float>();
for ( float p = lo; p <= hi + 0.01f; p += gs ) raw.Add( p );
for ( float p = hi; p >= lo - 0.01f; p -= gs ) raw.Add( p );
raw.Sort();
var lines = new List<float>();
foreach ( float p in raw )
if ( lines.Count == 0 || p - lines[^1] > 0.01f )
lines.Add( p );
return lines;
}
private static Vector2 SnapToGrid( Vector2 v ) => new( GridSnapAxis( v.x, true ), GridSnapAxis( v.y, false ) );
/// <summary>Snap a point to the grid when it's shown, else to the 1px pixel grid. Holding Alt
/// temporarily inverts the persisted snap setting.</summary>
private Vector2 SnapPoint( Vector2 v )
{
if ( !SnapActive ) return Snap( v );
return GridSnapActive ? SnapToGrid( v ) : Snap( v );
}
/// <summary>Whether Ctrl is held (raw keyboard). Used for additive selection and editor shortcuts.
/// Checks several button-code spellings because the engine's name for
/// the control keys isn't the Source-style LCONTROL/RCONTROL (the tools KeyBind widget + the default
/// "Duck" action use "ctrl"/"CTRL"/"RCTRL"); unknown names resolve to INVALID and safely return false.</summary>
private static bool CtrlHeld =>
Input.Keyboard.Down( "CTRL" ) || Input.Keyboard.Down( "RCTRL" ) ||
Input.Keyboard.Down( "LCONTROL" ) || Input.Keyboard.Down( "RCONTROL" );
/// <summary>Whether Shift is held (raw keyboard). Same multiple-spellings defence as
/// <see cref="CtrlHeld"/>; unknown names resolve to INVALID and safely return false.</summary>
private static bool ShiftHeld =>
Input.Keyboard.Down( "SHIFT" ) || Input.Keyboard.Down( "RSHIFT" ) || Input.Keyboard.Down( "LSHIFT" );
// Modifier state forwarded by the HUD's focused shortcut panel (same pattern as Alt/X): the RAW
// keyboard reads return false while UI input is suppressed/focused, so panel ButtonEvents are the
// reliable source. The raw checks stay in as a fallback for when the panel doesn't have focus.
private bool _shiftModifierHeld;
private bool _ctrlModifierHeld;
public void SetShiftModifierHeld( bool held ) => _shiftModifierHeld = held;
public void SetCtrlModifierHeld( bool held ) => _ctrlModifierHeld = held;
/// <summary>Additive-selection modifier: Shift or Ctrl. A modified click toggles the element in
/// and out of the selection; a modified marquee toggles everything it touches.</summary>
private bool AdditiveHeld => _shiftModifierHeld || _ctrlModifierHeld || CtrlHeld || ShiftHeld;
private void PointerDown( Vector2 logical )
{
_spikeToggledOnPointerDown = false;
switch ( Tool )
{
case EditTool.AlternatePattern:
if ( !AdditiveHeld && TryHitSelectedRectFace( logical, out SelKind alternateResizeKind,
out int alternateResizeIndex, out Direction alternateResizeSide ) )
{
BeginResize( alternateResizeKind, alternateResizeIndex, alternateResizeSide, logical );
break;
}
int alternateHit = HitAlternateRect( logical );
if ( alternateHit >= 0 )
{
var clicked = new Selection( SelKind.AlternatePattern, alternateHit );
bool additive = AdditiveHeld;
_additiveClickSelection = additive ? clicked : new Selection( SelKind.None, -1 );
_additiveClickWasSelected = additive && _selection.Contains( clicked );
if ( additive && !_additiveClickWasSelected ) AddSelection( clicked );
BeginMove( clicked.Kind, clicked.Index, logical, preserveSelection: additive || _selection.Contains( clicked ) );
}
else
{
_dragging = true;
_creatingAlternateRect = true;
_dragStart = logical;
_dragCurrent = logical;
if ( !AdditiveHeld ) Select( SelKind.None, -1 );
Rebuild();
}
break;
case EditTool.Obstacle:
_dragging = true;
_creatingRect = true;
// Keep the drag corners RAW; the whole rect is snapped (flush→grid→pixel) in CreateRect so
// flush-to-wall wins over grid (a grid-snapped corner would read as an exact flush match).
_dragStart = logical;
_dragCurrent = logical;
Rebuild();
break;
case EditTool.Spawn:
{
int hit = HitSpawn( logical );
if ( hit < 0 )
{
hit = Level.AddSpawn( ClampSpawn( SnapBoxCenter( logical, SPAWN_HALF, SPAWN_HALF, -1 ) ) );
AddMirroredMarkers( SelKind.Spawn, hit );
}
BeginMove( SelKind.Spawn, hit, logical );
break;
}
case EditTool.Player:
{
// Like the Spawn tool: clicking an existing player spawn grabs it, clicking empty
// space ADDS one (two are ordered for Twins; three or more are chosen randomly).
int hit = HitPlayer( logical );
if ( hit < 0 )
{
Level.PlayerSpawns.Add( ClampInside( SnapBoxCenter( logical, PLAYER_HALF_W, PLAYER_HALF_H, -1 ) ) );
hit = Level.PlayerSpawns.Count - 1;
AddMirroredMarkers( SelKind.Player, hit );
}
BeginMove( SelKind.Player, hit, logical );
break;
}
case EditTool.Coin:
{
int hit = HitCoin( logical );
if ( hit < 0 )
{
Level.Coins.Add( ClampCoin( SnapBoxCenter( logical, COIN_HALF_W, COIN_HALF_H, -1 ) ) );
hit = Level.Coins.Count - 1;
AddMirroredMarkers( SelKind.Coin, hit );
}
BeginMove( SelKind.Coin, hit, logical );
break;
}
case EditTool.Spike:
_spikeToggledOnPointerDown = ToggleNearestSpike( logical );
Rebuild();
break;
case EditTool.Select:
{
if ( !AdditiveHeld && TryHitSelectedRectFace( logical, out SelKind resizeKind, out int resizeIndex,
out Direction resizeSide ) )
{
BeginResize( resizeKind, resizeIndex, resizeSide, logical );
break;
}
var (k, idx) = HitAny( logical );
if ( k != SelKind.None )
{
var clicked = new Selection( k, idx );
bool additive = AdditiveHeld;
_additiveClickSelection = additive ? clicked : new Selection( SelKind.None, -1 );
_additiveClickWasSelected = additive && _selection.Contains( clicked );
if ( additive && !_additiveClickWasSelected ) AddSelection( clicked );
BeginMove( k, idx, logical, preserveSelection: additive || _selection.Contains( clicked ) );
}
else
{
_dragging = true;
_marqueeSelecting = true;
_marqueeAdditive = AdditiveHeld;
_dragStart = logical;
_dragCurrent = logical;
if ( !_marqueeAdditive ) Select( SelKind.None, -1 );
Rebuild();
}
break;
}
}
}
private void BeginMove( SelKind kind, int index, Vector2 logical, bool preserveSelection = false )
{
if ( !preserveSelection ) Select( kind, index );
// A plain (non-additive) click pulls the element's mirror twins into the selection so they
// move, resize and delete together. Ctrl-clicks stay surgical.
if ( !preserveSelection )
foreach ( var partner in MirrorPartners( new Selection( kind, index ) ) )
AddSelection( partner.Selection );
_dragging = true;
_moveKind = kind;
_moveIndex = index;
_grabOffset = ElementCenter( kind, index ) - logical;
_moveOriginCenters.Clear();
_moveOriginRects.Clear();
foreach ( var selected in _selection )
{
_moveOriginCenters[selected] = ElementCenter( selected.Kind, selected.Index );
if ( selected.Kind == SelKind.Obstacle && selected.Index >= 0 && selected.Index < Level.Obstacles.Count )
_moveOriginRects[selected] = Level.Obstacles[selected.Index];
if ( selected.Kind == SelKind.AlternatePattern && selected.Index >= 0 && selected.Index < Level.AlternatePlayfieldRects.Count )
_moveOriginRects[selected] = Level.AlternatePlayfieldRects[selected.Index];
}
RefreshMoveMirrorRelations( new Selection( kind, index ) );
Rebuild();
}
private void BeginResize( SelKind kind, int index, Direction side, Vector2 logical )
{
_dragging = true;
_moveKind = kind;
_moveIndex = index;
_resizeSide = side;
_resizeOriginRect = SelectionBounds( new Selection( kind, index ) );
_resizeGrabOffset = side is Direction.Left or Direction.Right
? ResizeEdgeCoord( _resizeOriginRect, side ) - logical.x
: ResizeEdgeCoord( _resizeOriginRect, side ) - logical.y;
// Mirror twins selected alongside the resized obstacle follow the resize as mirror images.
_resizeMirrorPartners.Clear();
if ( kind == SelKind.Obstacle )
foreach ( var partner in MirrorPartnerObstacles( index ) )
if ( _selection.Contains( new Selection( SelKind.Obstacle, partner.Index ) ) )
_resizeMirrorPartners.Add( partner );
Rebuild();
}
private void PointerDrag( Vector2 logical )
{
if ( _creatingRect || _creatingAlternateRect )
{
if ( logical != _dragCurrent ) { _dragCurrent = logical; Rebuild(); }
return;
}
if ( _marqueeSelecting )
{
if ( logical != _dragCurrent ) { _dragCurrent = logical; Rebuild(); }
return;
}
if ( _resizeSide != Direction.None )
{
ResizeSelectedRect( logical );
Rebuild();
return;
}
Vector2 rawCenter = logical + _grabOffset;
bool cardinalMove = TryGetCardinalMoveAxis( rawCenter, out var moveOrigin, out bool horizontalMove );
if ( cardinalMove ) rawCenter = ConstrainMoveCenter( rawCenter, moveOrigin, horizontalMove );
if ( _selection.Count > 1 )
{
var anchor = new Selection( _moveKind, _moveIndex );
if ( !_moveOriginCenters.TryGetValue( anchor, out var anchorOrigin ) ) return;
// Whole-pixel delta: an odd-sized anchor has its centre at x.5, so the raw difference is
// half-pixel-fractional and would shift EVERY selected element onto half-pixels.
Vector2 delta = Snap( SnapPoint( rawCenter ) - anchorOrigin );
if ( cardinalMove ) delta = horizontalMove ? new Vector2( delta.x, 0f ) : new Vector2( 0f, delta.y );
delta = ClampSelectionDelta( delta, useMoveOrigins: true );
foreach ( var selected in _selection )
{
if ( !_moveOriginCenters.TryGetValue( selected, out var center ) ) continue;
// Mirror twins of the anchor follow with the axis-flipped delta, staying mirrored.
Vector2 elementDelta = MirroredDelta( selected, delta );
switch ( selected.Kind )
{
case SelKind.Spawn when selected.Index >= 0 && selected.Index < Level.SpawnPositions.Count:
Level.SpawnPositions[selected.Index] = center + elementDelta;
break;
case SelKind.Player when selected.Index >= 0 && selected.Index < Level.PlayerSpawns.Count:
Level.PlayerSpawns[selected.Index] = center + elementDelta;
break;
case SelKind.Coin when selected.Index >= 0 && selected.Index < Level.Coins.Count:
Level.Coins[selected.Index] = center + elementDelta;
break;
case SelKind.Obstacle when _moveOriginRects.TryGetValue( selected, out var rect):
Level.Obstacles[selected.Index] = Translate( rect, elementDelta.x, elementDelta.y );
break;
case SelKind.AlternatePattern when _moveOriginRects.TryGetValue( selected, out var alternateRect ):
Level.AlternatePlayfieldRects[selected.Index] = Translate( alternateRect, delta.x, delta.y );
break;
}
}
if ( _selection.Any( selected => selected.Kind == SelKind.AlternatePattern ) ) RefreshAlternatePlayfield();
Rebuild();
return;
}
switch ( _moveKind )
{
case SelKind.Spawn:
if ( _moveIndex >= 0 && _moveIndex < Level.SpawnPositions.Count )
{
Vector2 center = ClampSpawn( SnapBoxCenter( rawCenter, SPAWN_HALF, SPAWN_HALF, _moveIndex ) );
Level.SpawnPositions[_moveIndex] = cardinalMove
? ConstrainMoveCenter( center, moveOrigin, horizontalMove )
: center;
}
break;
case SelKind.Player:
if ( _moveIndex >= 0 && _moveIndex < Level.PlayerSpawns.Count )
{
Vector2 center = ClampInside( SnapBoxCenter( rawCenter, PLAYER_HALF_W, PLAYER_HALF_H, -1 ) );
Level.PlayerSpawns[_moveIndex] = cardinalMove
? ConstrainMoveCenter( center, moveOrigin, horizontalMove )
: center;
}
break;
case SelKind.Coin:
if ( _moveIndex >= 0 && _moveIndex < Level.Coins.Count )
{
Vector2 center = ClampCoin( SnapBoxCenter( rawCenter, COIN_HALF_W, COIN_HALF_H, -1 ) );
Level.Coins[_moveIndex] = cardinalMove
? ConstrainMoveCenter( center, moveOrigin, horizontalMove )
: center;
}
break;
case SelKind.Obstacle:
if ( _moveIndex >= 0 && _moveIndex < Level.Obstacles.Count )
{
// Snap PRIORITY: flush to a wall/obstacle edge FIRST, then grid, then pixel (G =
// pixel only). Feed the RAW centre so grid pre-snapping can't pull the obstacle out of
// a wall's flush range before the flush check runs.
RectF moved = RecenteredRect( Level.Obstacles[_moveIndex], rawCenter );
moved = SnapRectTranslate( moved, _moveIndex );
moved = ClampRect( moved );
Level.Obstacles[_moveIndex] = cardinalMove
? RecenteredRect( moved, ConstrainMoveCenter( RectCenter( moved ), moveOrigin, horizontalMove ) )
: moved;
}
break;
case SelKind.AlternatePattern:
if ( _moveIndex >= 0 && _moveIndex < Level.AlternatePlayfieldRects.Count )
{
RectF moved = RecenteredRect( Level.AlternatePlayfieldRects[_moveIndex], rawCenter );
moved = SnapRectTranslate( moved, -1, ignoreAlternate: _moveIndex, includeAlternate: true );
moved = ClampRect( moved );
Level.AlternatePlayfieldRects[_moveIndex] = cardinalMove
? RecenteredRect( moved, ConstrainMoveCenter( RectCenter( moved ), moveOrigin, horizontalMove ) )
: moved;
RefreshAlternatePlayfield();
}
break;
}
Rebuild();
}
private bool TryGetCardinalMoveAxis( Vector2 rawCenter, out Vector2 origin, out bool horizontal )
{
origin = default;
var anchor = new Selection( _moveKind, _moveIndex );
if ( !_cardinalDragHeld || !_moveOriginCenters.TryGetValue( anchor, out origin ) )
{
horizontal = false;
return false;
}
Vector2 delta = rawCenter - origin;
horizontal = MathF.Abs( delta.x ) >= MathF.Abs( delta.y );
return true;
}
private static Vector2 ConstrainMoveCenter( Vector2 center, Vector2 origin, bool horizontal )
=> horizontal ? new Vector2( center.x, origin.y ) : new Vector2( origin.x, center.y );
private void PointerUp( Vector2 logical )
{
if ( _creatingAlternateRect )
{
_dragCurrent = logical;
RectF rect = CreateRect();
if ( rect.Width >= 1f && rect.Height >= 1f )
{
Level.AlternatePlayfieldRects.Add( ClampRect( rect ) );
Select( SelKind.AlternatePattern, Level.AlternatePlayfieldRects.Count - 1 );
RefreshAlternatePlayfield();
}
}
else if ( _creatingRect )
{
_dragCurrent = logical;
// Snap PRIORITY: flush to a nearby obstacle edge / arena wall FIRST (so composite obstacles
// land EXACTLY edge-to-edge — the game only merges walls within FLUSH_EPS=0.5px), then grid,
// then pixel. Snap disabled = pixel only.
RectF r = CreateRect();
if ( r.Width >= MIN_RECT && r.Height >= MIN_RECT )
{
Level.Obstacles.Add( ClampRect( r ) );
Select( SelKind.Obstacle, Level.Obstacles.Count - 1 );
AddMirroredObstacles( Level.Obstacles.Count - 1 );
}
}
else if ( _pointerDragged && !_marqueeSelecting )
{
NormalizeDraggedRectEdges();
}
if ( _marqueeSelecting )
{
Tool = EditTool.Select;
if ( _pointerDragged ) SelectMarquee( Normalize( _dragStart, logical ), _marqueeAdditive );
else if ( !_marqueeAdditive ) Select( SelKind.None, -1 );
}
else if ( !_pointerDragged && _additiveClickSelection.Kind != SelKind.None && _additiveClickWasSelected )
{
RemoveSelection( _additiveClickSelection );
}
if ( Tool != EditTool.AlternatePattern && !_pointerDragged && !_spikeToggledOnPointerDown && HitAny( _pointerDownLogical ).Item1 == SelKind.None )
{
Tool = EditTool.Select;
Select( SelKind.None, -1 );
}
_dragging = false;
_creatingRect = false;
_creatingAlternateRect = false;
_marqueeSelecting = false;
_marqueeAdditive = false;
_additiveClickSelection = new Selection( SelKind.None, -1 );
_additiveClickWasSelected = false;
_spikeToggledOnPointerDown = false;
_moveKind = SelKind.None;
_moveIndex = -1;
_resizeSide = Direction.None;
_resizeOriginRect = default;
_resizeGrabOffset = 0f;
_moveOriginCenters.Clear();
_moveOriginRects.Clear();
_moveMirrorRelations.Clear();
_resizeMirrorPartners.Clear();
Rebuild();
// One entry per pointer operation (create/move/resize/spike-toggle/spawn-add), not per frame.
CheckpointUndo();
}
/// <summary>Right-click: delete whatever is under the cursor without changing the active tool.</summary>
public void SecondaryClick()
{
if ( Tool == EditTool.AlternatePattern )
{
int alternate = HitAlternateRect( ScreenToLogical( Mouse.Position ) );
if ( alternate < 0 ) return;
Level.AlternatePlayfieldRects.RemoveAt( alternate );
Select( SelKind.None, -1 );
RefreshAlternatePlayfield();
Rebuild();
CheckpointUndo();
return;
}
var (k, idx) = HitAny( ScreenToLogical( Mouse.Position ) );
if ( k == SelKind.None ) return;
// Right-click delete takes the element's mirror twins with it (descending so indices hold).
var doomed = MirrorPartners( new Selection( k, idx ) ).Select( partner => partner.Selection.Index )
.Append( idx ).OrderByDescending( i => i );
foreach ( int index in doomed )
{
switch ( k )
{
case SelKind.Obstacle: Level.RemoveObstacleAt( index ); break;
case SelKind.Spawn: Level.RemoveSpawnAt( index ); break;
case SelKind.Player: Level.RemovePlayerSpawnAt( index ); break; // refuses the last one
case SelKind.Coin: Level.RemoveCoinAt( index ); break;
}
}
Select( SelKind.None, -1 );
Rebuild();
CheckpointUndo();
}
public void DeleteSelected()
{
foreach ( int index in _selection.Where( s => s.Kind == SelKind.Obstacle ).Select( s => s.Index ).OrderByDescending( i => i ) )
Level.RemoveObstacleAt( index );
foreach ( int index in _selection.Where( s => s.Kind == SelKind.Spawn ).Select( s => s.Index ).OrderByDescending( i => i ) )
Level.RemoveSpawnAt( index );
// Player spawns delete like everything else, EXCEPT the last one: a level always keeps at
// least one, even when every player spawn is selected at once (RemovePlayerSpawnAt refuses).
foreach ( int index in _selection.Where( s => s.Kind == SelKind.Player ).Select( s => s.Index ).OrderByDescending( i => i ) )
Level.RemovePlayerSpawnAt( index );
foreach ( int index in _selection.Where( s => s.Kind == SelKind.Coin ).Select( s => s.Index ).OrderByDescending( i => i ) )
Level.RemoveCoinAt( index );
bool deletedAlternate = false;
if ( Tool == EditTool.AlternatePattern )
{
foreach ( int index in _selection.Where( s => s.Kind == SelKind.AlternatePattern ).Select( s => s.Index ).OrderByDescending( i => i ) )
{
if ( index < 0 || index >= Level.AlternatePlayfieldRects.Count ) continue;
Level.AlternatePlayfieldRects.RemoveAt( index );
deletedAlternate = true;
}
}
Select( SelKind.None, -1 );
if ( deletedAlternate ) RefreshAlternatePlayfield();
Rebuild();
CheckpointUndo();
}
// ── undo/redo (Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y) ──────────────────────────────────────────────────
/// <summary>Record the current level state as an undo entry. No-op when nothing changed, so the
/// discrete-operation call sites and the HUD poll can both call it generously. Snapshots use the
/// FULL authoring serialization (both modes' block lists, disabled alt-playfield settings) so a
/// history step can't silently wipe inactive-mode work. The session-only ProjectSourceId rides along
/// so undoing a reset/import/template load gives back the right to update the original file;
/// <paramref name="newDocument"/> flags those three, which replace the document rather than edit it.</summary>
private void CheckpointUndo( string coalesceKey = null, bool newDocument = false )
=> UndoHistory.Checkpoint( Level.ToJsonString( fullAuthoringState: true ), Level.ProjectSourceId, coalesceKey, newDocument );
/// <summary>Capture the open document's LAST state right before a reset/paste-import/template load
/// replaces it. Pointer and hotkey edits checkpoint themselves, so anything still unrecorded is HUD
/// field drift the poll hasn't caught yet — without this, undoing the replacement would restore a
/// snapshot up to a poll interval stale. Same "hud" key as the poll, so it merges into a typing burst.</summary>
private void CheckpointOutgoingDocument() => CheckpointUndo( "hud" );
public void Undo() => TryHistoryStep( redo: false );
public void Redo() => TryHistoryStep( redo: true );
private void TryHistoryStep( bool redo )
{
// Ignored mid-drag: the drag bookkeeping indexes the live lists, and the whole drag checkpoints
// as one entry on pointer-up anyway. The debounce stops the two hotkey paths double-stepping.
if ( _dragging || _editorClock - _lastUndoActionTime < UNDO_ACTION_DEBOUNCE ) return;
_lastUndoActionTime = _editorClock;
string json = Level.ToJsonString( fullAuthoringState: true );
ApplyHistorySnapshot( redo ? UndoHistory.Redo( json, Level.ProjectSourceId ) : UndoHistory.Undo( json, Level.ProjectSourceId ) );
}
private void ApplyHistorySnapshot( EditorUndoHistory.Entry? entry )
{
if ( entry is null ) return;
var restored = EditorLevel.FromJsonString( entry.Value.Json, out _ );
if ( restored is null ) return;
// The source-file identity travels with the entry: undoing a reset/paste-import/template load
// hands back the original document, which must stay saveable over its own file.
restored.ProjectSourceId = entry.Value.SourceId;
// The daily-template authoring fields are session-only (never serialized), so carry the live
// values across — undo/redo shouldn't blank the template name box.
restored.DailyTemplateName = Level.DailyTemplateName;
restored.DailyTemplateMirrorX = Level.DailyTemplateMirrorX;
restored.DailyTemplateAuthoredSpikes = Level.DailyTemplateAuthoredSpikes;
restored.DailyTemplateWeight = Level.DailyTemplateWeight;
Level = restored;
// The reopen pointer is part of the document identity too: it tracks the live source file
// everywhere else (open, save, reset, import, template load), so a step has to move it back.
RememberOpenedLevel( Level.ProjectSourceId ?? "" );
RefreshMusic();
LevelRevision++; // refreshes the HUD's inspector fields, same as load/import
Select( SelKind.None, -1 );
LastError = "";
RefreshPalette();
RefreshEnvironment();
Rebuild();
// Deserializing normalizes a few fields (clamps and similar), so the live level may
// serialize differently from the snapshot we just applied — re-anchor the history baseline to
// the real state so the TickMenu poll doesn't read that drift as a new edit and clear redo.
UndoHistory.Rebase( Level.ToJsonString( fullAuthoringState: true ), Level.ProjectSourceId );
}
public void SelectAll()
{
Select( SelKind.None, -1 );
if ( Tool == EditTool.AlternatePattern )
{
for ( int i = 0; i < Level.AlternatePlayfieldRects.Count; i++ )
AddSelection( new Selection( SelKind.AlternatePattern, i ) );
Rebuild();
return;
}
for ( int i = 0; i < Level.Obstacles.Count; i++ )
AddSelection( new Selection( SelKind.Obstacle, i ) );
for ( int i = 0; i < Level.SpawnPositions.Count; i++ )
AddSelection( new Selection( SelKind.Spawn, i ) );
for ( int i = 0; i < Level.PlayerSpawns.Count; i++ )
AddSelection( new Selection( SelKind.Player, i ) );
for ( int i = 0; i < Level.Coins.Count; i++ )
AddSelection( new Selection( SelKind.Coin, i ) );
Tool = EditTool.Select;
Rebuild();
}
private void Select( SelKind kind, int index )
{
_selection.Clear();
_primarySelection = new Selection( kind, index );
if ( kind != SelKind.None ) _selection.Add( _primarySelection );
}
private void AddSelection( Selection selected )
{
if ( selected.Kind == SelKind.None ) return;
_selection.Add( selected );
_primarySelection = selected;
}
private void RemoveSelection( Selection selected )
{
_selection.Remove( selected );
_primarySelection = _selection.Count > 0 ? _selection.Last() : new Selection( SelKind.None, -1 );
}
private void SelectMarquee( RectF marquee, bool additive )
{
if ( !additive ) Select( SelKind.None, -1 );
// An additive (Shift/Ctrl) marquee TOGGLES: unselected items inside it join the selection,
// already-selected ones leave it — so a second sweep over a chunk deselects that chunk.
void Toggle( Selection hit )
{
if ( additive && _selection.Contains( hit ) ) RemoveSelection( hit );
else AddSelection( hit );
}
for ( int i = 0; i < Level.Obstacles.Count; i++ )
if ( RectsTouch( marquee, Level.Obstacles[i] ) ) Toggle( new Selection( SelKind.Obstacle, i ) );
for ( int i = 0; i < Level.SpawnPositions.Count; i++ )
if ( RectsTouch( marquee, BoxAt( Level.SpawnPositions[i], SPAWN_HALF, SPAWN_HALF ) ) ) Toggle( new Selection( SelKind.Spawn, i ) );
for ( int i = 0; i < Level.PlayerSpawns.Count; i++ )
if ( RectsTouch( marquee, BoxAt( Level.PlayerSpawns[i], PLAYER_HALF_W, PLAYER_HALF_H ) ) )
Toggle( new Selection( SelKind.Player, i ) );
for ( int i = 0; i < Level.Coins.Count; i++ )
if ( RectsTouch( marquee, BoxAt( Level.Coins[i], COIN_HALF_W, COIN_HALF_H ) ) )
Toggle( new Selection( SelKind.Coin, i ) );
}
/// <summary>Indices of every selected obstacle (empty when none / other kinds selected).</summary>
private int[] SelectedObstacleIndices()
=> _selection.Where( s => s.Kind == SelKind.Obstacle && s.Index >= 0 && s.Index < Level.Obstacles.Count )
.Select( s => s.Index ).ToArray();
/// <summary>True when the selection is non-empty and contains only obstacles. Gates the HUD's
/// obstacle-flag toggles, so they stay usable for a multi-selection (e.g. a mirror group).</summary>
public bool SelectionIsObstacles
=> _selection.Count > 0
&& _selection.All( s => s.Kind == SelKind.Obstacle && s.Index >= 0 && s.Index < Level.Obstacles.Count );
/// <summary>Toggle Blocks Vision across the selected obstacles. If any selected obstacle doesn't
/// block vision, all start blocking; otherwise all stop (same any→all rule as Fence/Glass).</summary>
public void ToggleSelectedObstacleVision()
{
int[] indices = SelectedObstacleIndices();
if ( indices.Length == 0 ) return;
bool enable = indices.Any( i => !Level.ObstacleBlocksVision( i ) );
foreach ( int index in indices )
if ( Level.ObstacleBlocksVision( index ) != enable ) Level.ToggleObstacleVision( index );
Rebuild();
CheckpointUndo();
}
/// <summary>Whether every selected obstacle is flagged to block vision (false when none selected).
/// For the HUD toggle's on/off state.</summary>
public bool SelectedObstacleBlocksVision
{
get
{
int[] indices = SelectedObstacleIndices();
return indices.Length > 0 && indices.All( Level.ObstacleBlocksVision );
}
}
/// <summary>Toggle Fence across the selected obstacles. If any selected obstacle is not a fence,
/// all become fences; otherwise all fences are cleared. Turning it on strips spikes and the vision
/// flag (see <see cref="EditorLevel.ToggleObstacleFence"/>).</summary>
public void ToggleSelectedObstacleFence()
{
int[] indices = SelectedObstacleIndices();
if ( indices.Length == 0 ) return;
bool enable = indices.Any( i => !Level.ObstacleIsFence( i ) );
foreach ( int index in indices )
if ( Level.ObstacleIsFence( index ) != enable ) Level.ToggleObstacleFence( index );
Rebuild();
CheckpointUndo();
}
/// <summary>Whether every selected obstacle is flagged as a fence (false when none selected).
/// For the HUD toggle's on/off state.</summary>
public bool SelectedObstacleIsFence
{
get
{
int[] indices = SelectedObstacleIndices();
return indices.Length > 0 && indices.All( Level.ObstacleIsFence );
}
}
/// <summary>Toggle Glass across the selected obstacles. If any selected obstacle is not glass,
/// all become glass; otherwise all glass flags are cleared. Enabling clears fence/vision flags;
/// authored spikes are kept (see <see cref="EditorLevel.ToggleObstacleGlass"/>).</summary>
public void ToggleSelectedObstacleGlass()
{
int[] indices = SelectedObstacleIndices();
if ( indices.Length == 0 ) return;
bool enable = indices.Any( i => !Level.ObstacleIsGlass( i ) );
foreach ( int index in indices )
if ( Level.ObstacleIsGlass( index ) != enable ) Level.ToggleObstacleGlass( index );
Rebuild();
CheckpointUndo();
}
/// <summary>Whether every selected obstacle is flagged as glass (false when none selected).
/// For the HUD toggle's on/off state.</summary>
public bool SelectedObstacleIsGlass
{
get
{
int[] indices = SelectedObstacleIndices();
return indices.Length > 0 && indices.All( Level.ObstacleIsGlass );
}
}
// ── hit testing / geometry helpers ──────────────────────────────────────────────────────────────
private Vector2 ElementCenter( SelKind kind, int index ) => kind switch
{
SelKind.Spawn when index >= 0 && index < Level.SpawnPositions.Count => Level.SpawnPositions[index],
SelKind.Player when index >= 0 && index < Level.PlayerSpawns.Count => Level.PlayerSpawns[index],
SelKind.Coin when index >= 0 && index < Level.Coins.Count => Level.Coins[index],
SelKind.Obstacle when index >= 0 && index < Level.Obstacles.Count => RectCenter( Level.Obstacles[index] ),
SelKind.AlternatePattern when index >= 0 && index < Level.AlternatePlayfieldRects.Count => RectCenter( Level.AlternatePlayfieldRects[index] ),
_ => Vector2.Zero,
};
private RectF SelectionBounds( Selection selected ) => selected.Kind switch
{
SelKind.Obstacle when selected.Index >= 0 && selected.Index < Level.Obstacles.Count => Level.Obstacles[selected.Index],
SelKind.Spawn when selected.Index >= 0 && selected.Index < Level.SpawnPositions.Count => BoxAt( Level.SpawnPositions[selected.Index], SPAWN_HALF, SPAWN_HALF ),
SelKind.Player when selected.Index >= 0 && selected.Index < Level.PlayerSpawns.Count => BoxAt( Level.PlayerSpawns[selected.Index], PLAYER_HALF_W, PLAYER_HALF_H ),
SelKind.Coin when selected.Index >= 0 && selected.Index < Level.Coins.Count => BoxAt( Level.Coins[selected.Index], COIN_HALF_W, COIN_HALF_H ),
SelKind.AlternatePattern when selected.Index >= 0 && selected.Index < Level.AlternatePlayfieldRects.Count => Level.AlternatePlayfieldRects[selected.Index],
_ => default,
};
private static RectF BoxAt( Vector2 center, float halfWidth, float halfHeight )
=> new( center.x - halfWidth, center.y - halfHeight, center.x + halfWidth, center.y + halfHeight );
private int HitSpawn( Vector2 p )
{
for ( int i = Level.SpawnPositions.Count - 1; i >= 0; i-- )
{
var c = Level.SpawnPositions[i];
if ( MathF.Abs( p.x - c.x ) <= 20f && MathF.Abs( p.y - c.y ) <= 20f )
return i;
}
return -1;
}
private int HitPlayer( Vector2 p )
{
for ( int i = Level.PlayerSpawns.Count - 1; i >= 0; i-- )
{
var c = Level.PlayerSpawns[i];
if ( MathF.Abs( p.x - c.x ) <= PLAYER_HALF_W && MathF.Abs( p.y - c.y ) <= PLAYER_HALF_H )
return i;
}
return -1;
}
private int HitCoin( Vector2 p )
{
for ( int i = Level.Coins.Count - 1; i >= 0; i-- )
{
var c = Level.Coins[i];
if ( MathF.Abs( p.x - c.x ) <= COIN_HIT_TOLERANCE && MathF.Abs( p.y - c.y ) <= COIN_HIT_TOLERANCE )
return i;
}
return -1;
}
/// <summary>Hit-test the resize grab bands of the single selected RECT (obstacle or alternate-pattern
/// region). Reports the kind alongside the index so callers resize whatever is actually selected
/// rather than whatever their tool assumes — the two index different lists. On a miss everything is
/// reported empty (None / -1 / None).</summary>
private bool TryHitSelectedRectFace( Vector2 p, out SelKind kind, out int index, out Direction side )
{
kind = SelKind.None;
index = -1;
side = Direction.None;
if ( SelectedKind is not (SelKind.Obstacle or SelKind.AlternatePattern) )
return TryHitMirrorGroupFace( p, ref kind, ref index, ref side );
if ( SelectedIndex < 0 || SelectedIndex >= RectCount( SelectedKind ) ) return false;
// The alternate-pattern tool resizes AND draws, so its grab zone must not reach into empty space:
// a press just outside the selected rect has to start a NEW rect — the usual flow is drawing a
// band right up against the one just finished (which is still selected) — not resize the old one.
// Obstacles keep the generous outward reach; the Select tool has no create-drag to conflict with.
float outerReach = SelectedKind == SelKind.AlternatePattern ? 0f : RESIZE_FACE_OUTER_HIT_DISTANCE;
RectF r = SelectionBounds( _primarySelection );
float best = RESIZE_FACE_OUTER_HIT_DISTANCE;
Direction hitSide = Direction.None;
void Consider( Direction candidate, float distance )
{
if ( distance > best ) return;
best = distance;
hitSide = candidate;
}
Consider( Direction.Left, ResizeFaceDistance( p, r, Direction.Left, outerReach ) );
Consider( Direction.Right, ResizeFaceDistance( p, r, Direction.Right, outerReach ) );
Consider( Direction.Down, ResizeFaceDistance( p, r, Direction.Down, outerReach ) );
Consider( Direction.Up, ResizeFaceDistance( p, r, Direction.Up, outerReach ) );
if ( hitSide == Direction.None ) return false;
kind = SelectedKind;
index = SelectedIndex;
side = hitSide;
return true;
}
/// <summary>A mirror group is still resizable even though the multi-selection reports no single
/// selected rect: hit-test every member's faces and resize whichever face is grabbed (the other
/// twins follow as mirror images in <see cref="ResizeSelectedRect"/>).</summary>
private bool TryHitMirrorGroupFace( Vector2 p, ref SelKind kind, ref int index, ref Direction side )
{
if ( !TryGetMirrorGroupIndices( out var members ) ) return false;
float best = RESIZE_FACE_OUTER_HIT_DISTANCE;
foreach ( int member in members )
{
RectF r = Level.Obstacles[member];
foreach ( var candidate in Globals.GetAllDirections() )
{
float distance = ResizeFaceDistance( p, r, candidate, RESIZE_FACE_OUTER_HIT_DISTANCE );
if ( distance > best ) continue;
best = distance;
kind = SelKind.Obstacle;
index = member;
side = candidate;
}
}
return side != Direction.None;
}
/// <summary>How many rects a rect-shaped selection kind indexes into (0 for the marker kinds).</summary>
private int RectCount( SelKind kind ) => kind switch
{
SelKind.Obstacle => Level.Obstacles.Count,
SelKind.AlternatePattern => Level.AlternatePlayfieldRects.Count,
_ => 0,
};
/// <summary>Distance from the point to one face's grab band, or MaxValue when it misses. The band
/// runs <paramref name="outerReach"/> px outside the face and up to
/// <see cref="RESIZE_FACE_INNER_HIT_DISTANCE"/> px inside it (never more than a quarter of the rect,
/// so a thin rect keeps a draggable middle).</summary>
private static float ResizeFaceDistance( Vector2 point, RectF rect, Direction side, float outerReach )
{
const float alongTolerance = 2f;
float inwardDistance;
float thickness;
switch ( side )
{
case Direction.Left:
if ( point.y < rect.Bottom - alongTolerance || point.y > rect.Top + alongTolerance ) return float.MaxValue;
inwardDistance = point.x - rect.Left;
thickness = rect.Width;
break;
case Direction.Right:
if ( point.y < rect.Bottom - alongTolerance || point.y > rect.Top + alongTolerance ) return float.MaxValue;
inwardDistance = rect.Right - point.x;
thickness = rect.Width;
break;
case Direction.Down:
if ( point.x < rect.Left - alongTolerance || point.x > rect.Right + alongTolerance ) return float.MaxValue;
inwardDistance = point.y - rect.Bottom;
thickness = rect.Height;
break;
case Direction.Up:
if ( point.x < rect.Left - alongTolerance || point.x > rect.Right + alongTolerance ) return float.MaxValue;
inwardDistance = rect.Top - point.y;
thickness = rect.Height;
break;
default:
return float.MaxValue;
}
float innerLimit = MathF.Min( RESIZE_FACE_INNER_HIT_DISTANCE, thickness * 0.25f );
return inwardDistance >= -outerReach && inwardDistance <= innerLimit
? MathF.Abs( inwardDistance )
: float.MaxValue;
}
private (SelKind, int) HitAny( Vector2 p )
{
int sp = HitSpawn( p );
if ( sp >= 0 ) return (SelKind.Spawn, sp);
int player = HitPlayer( p );
if ( player >= 0 ) return (SelKind.Player, player);
int coin = HitCoin( p );
if ( coin >= 0 ) return (SelKind.Coin, coin);
for ( int i = Level.Obstacles.Count - 1; i >= 0; i-- )
if ( Level.Obstacles[i].Touches( p ) ) return (SelKind.Obstacle, i);
return (SelKind.None, -1);
}
private int HitAlternateRect( Vector2 point )
{
for ( int i = Level.AlternatePlayfieldRects.Count - 1; i >= 0; i-- )
if ( Level.AlternatePlayfieldRects[i].Touches( point ) ) return i;
return -1;
}
private static Vector2 RectCenter( RectF r ) => new( ( r.Left + r.Right ) / 2f, ( r.Bottom + r.Top ) / 2f );
private static RectF Normalize( Vector2 a, Vector2 b )
=> new( MathF.Min( a.x, b.x ), MathF.Min( a.y, b.y ), MathF.Max( a.x, b.x ), MathF.Max( a.y, b.y ) );
private static RectF RecenteredRect( RectF r, Vector2 center )
{
float hw = r.Width / 2f, hh = r.Height / 2f;
return new RectF( center.x - hw, center.y - hh, center.x + hw, center.y + hh );
}
private static RectF Translate( RectF r, float dx, float dy ) => new( r.Left + dx, r.Bottom + dy, r.Right + dx, r.Top + dy );
private static float ResizeEdgeCoord( RectF r, Direction side ) => side switch
{
Direction.Left => r.Left,
Direction.Right => r.Right,
Direction.Down => r.Bottom,
Direction.Up => r.Top,
_ => 0f,
};
private static RectF Union( RectF a, RectF b )
=> new( MathF.Min( a.Left, b.Left ), MathF.Min( a.Bottom, b.Bottom ), MathF.Max( a.Right, b.Right ), MathF.Max( a.Top, b.Top ) );
private static bool RectsTouch( RectF a, RectF b )
=> a.Left <= b.Right && a.Right >= b.Left && a.Bottom <= b.Top && a.Top >= b.Bottom;
// ── mirroring: optional symmetric editing across the arena centre lines ──────────────────────────
// Nothing persistent links mirror twins — partners are re-found at each operation as the NEAREST
// same-kind element within MIRROR_PAIR_DIST of the mirror image (an exact twin always wins), so
// hand-built symmetry from before these tools that's a pixel or two off still pairs. Breaking
// symmetry further (or toggling the modes off) simply dissolves the pairing.
private const float MIRROR_MATCH_EPS = 0.25f;
// Pairing tolerance for FINDING a twin (per edge / per axis). Creation dedupe stays strict
// (MIRROR_MATCH_EPS) — a near-twin shouldn't suppress adding an exact one.
private const float MIRROR_PAIR_DIST = 3f;
private static RectF MirrorRectX( RectF r ) => new( Arena.WIDTH - r.Right, r.Bottom, Arena.WIDTH - r.Left, r.Top );
private static RectF MirrorRectY( RectF r ) => new( r.Left, Arena.HEIGHT - r.Top, r.Right, Arena.HEIGHT - r.Bottom );
private static RectF MirrorRect( RectF r, bool flipX, bool flipY )
{
if ( flipX ) r = MirrorRectX( r );
if ( flipY ) r = MirrorRectY( r );
return r;
}
/// <summary>The mirror images the active toggles produce: X, Y, and (with both on) the 180° combo.</summary>
private IEnumerable<(bool FlipX, bool FlipY)> ActiveMirrorFlips()
{
if ( MirrorX ) yield return (true, false);
if ( MirrorY ) yield return (false, true);
if ( MirrorX && MirrorY ) yield return (true, true);
}
private static bool RectsAlmostEqual( RectF a, RectF b )
=> MathF.Abs( a.Left - b.Left ) <= MIRROR_MATCH_EPS && MathF.Abs( a.Bottom - b.Bottom ) <= MIRROR_MATCH_EPS
&& MathF.Abs( a.Right - b.Right ) <= MIRROR_MATCH_EPS && MathF.Abs( a.Top - b.Top ) <= MIRROR_MATCH_EPS;
// Chebyshev-style distances used for loose twin pairing: the worst edge / axis offset.
private static float RectMirrorDistance( RectF a, RectF b )
=> MathF.Max( MathF.Max( MathF.Abs( a.Left - b.Left ), MathF.Abs( a.Right - b.Right ) ),
MathF.Max( MathF.Abs( a.Bottom - b.Bottom ), MathF.Abs( a.Top - b.Top ) ) );
private static float PointMirrorDistance( Vector2 a, Vector2 b )
=> MathF.Max( MathF.Abs( a.x - b.x ), MathF.Abs( a.y - b.y ) );
/// <summary>The obstacle best matching the (flipX,flipY) mirror image of <paramref name="rect"/>
/// within <see cref="MIRROR_PAIR_DIST"/>, or -1. The closest match wins (exact beats loose); a
/// rect straddling its centre line is its own twin and pairs with nothing.</summary>
private int FindMirrorObstacle( RectF rect, bool flipX, bool flipY, int excluded, HashSet<int> claimed = null )
{
RectF mirrored = MirrorRect( rect, flipX, flipY );
if ( RectsAlmostEqual( mirrored, rect ) ) return -1; // sits on the centre line — its own twin
int best = -1;
float bestDistance = MIRROR_PAIR_DIST + 0.001f;
for ( int i = Level.Obstacles.Count - 1; i >= 0; i-- )
{
if ( i == excluded || claimed?.Contains( i ) == true ) continue;
float distance = RectMirrorDistance( Level.Obstacles[i], mirrored );
if ( distance < bestDistance )
{
bestDistance = distance;
best = i;
}
}
return best;
}
/// <summary>Obstacles pairing as mirror twins of this obstacle under the active flips (nearest
/// match within tolerance per flip; each obstacle claimed by at most one flip).</summary>
private IEnumerable<(int Index, bool FlipX, bool FlipY)> MirrorPartnerObstacles( int index )
{
if ( !MirrorActive || index < 0 || index >= Level.Obstacles.Count ) yield break;
RectF rect = Level.Obstacles[index];
var claimed = new HashSet<int>();
foreach ( var (flipX, flipY) in ActiveMirrorFlips() )
{
int partner = FindMirrorObstacle( rect, flipX, flipY, index, claimed );
if ( partner < 0 ) continue;
claimed.Add( partner );
yield return (partner, flipX, flipY);
}
}
/// <summary>Add mirror copies of a just-created obstacle and pull them into the selection with it.
/// A copy that would land exactly on an existing obstacle (the source included) is skipped.</summary>
private void AddMirroredObstacles( int sourceIndex )
{
if ( !MirrorActive ) return;
RectF source = Level.Obstacles[sourceIndex];
foreach ( var (flipX, flipY) in ActiveMirrorFlips() )
{
RectF mirrored = MirrorRect( source, flipX, flipY );
bool exists = false;
for ( int i = 0; i < Level.Obstacles.Count && !exists; i++ )
exists = RectsAlmostEqual( Level.Obstacles[i], mirrored );
if ( exists ) continue;
Level.Obstacles.Add( mirrored );
AddSelection( new Selection( SelKind.Obstacle, Level.Obstacles.Count - 1 ) );
}
}
private static Vector2 MirrorPoint( Vector2 p, bool flipX, bool flipY )
=> new( flipX ? Arena.WIDTH - p.x : p.x, flipY ? Arena.HEIGHT - p.y : p.y );
private static bool PointsAlmostEqual( Vector2 a, Vector2 b )
=> MathF.Abs( a.x - b.x ) <= MIRROR_MATCH_EPS && MathF.Abs( a.y - b.y ) <= MIRROR_MATCH_EPS;
/// <summary>A face's direction under a mirror flip (X swaps Left↔Right, Y swaps Up↔Down).</summary>
private static Direction MirrorDirection( Direction dir, bool flipX, bool flipY )
{
if ( flipX && dir == Direction.Left ) return Direction.Right;
if ( flipX && dir == Direction.Right ) return Direction.Left;
if ( flipY && dir == Direction.Up ) return Direction.Down;
if ( flipY && dir == Direction.Down ) return Direction.Up;
return dir;
}
/// <summary>The centre-position list a point-marker selection kind indexes (null for rect kinds).</summary>
private List<Vector2> MarkerPositions( SelKind kind ) => kind switch
{
SelKind.Spawn => Level.SpawnPositions,
SelKind.Player => Level.PlayerSpawns,
SelKind.Coin => Level.Coins,
_ => null,
};
/// <summary>Same-kind elements sitting exactly at one of the active mirror images of this one —
/// obstacles by rect equality, spawn/player/coin markers by centre equality. Empty for other kinds.</summary>
private IEnumerable<(Selection Selection, bool FlipX, bool FlipY)> MirrorPartners( Selection source )
{
if ( !MirrorActive ) yield break;
if ( source.Kind == SelKind.Obstacle )
{
foreach ( var (index, flipX, flipY) in MirrorPartnerObstacles( source.Index ) )
yield return (new Selection( SelKind.Obstacle, index ), flipX, flipY);
yield break;
}
var positions = MarkerPositions( source.Kind );
if ( positions is null || source.Index < 0 || source.Index >= positions.Count ) yield break;
Vector2 p = positions[source.Index];
var claimed = new HashSet<int>();
foreach ( var (flipX, flipY) in ActiveMirrorFlips() )
{
Vector2 mirrored = MirrorPoint( p, flipX, flipY );
if ( PointsAlmostEqual( mirrored, p ) ) continue; // sits on the centre line — its own twin
int best = -1;
float bestDistance = MIRROR_PAIR_DIST + 0.001f;
for ( int i = positions.Count - 1; i >= 0; i-- )
{
if ( i == source.Index || claimed.Contains( i ) ) continue;
float distance = PointMirrorDistance( positions[i], mirrored );
if ( distance < bestDistance )
{
bestDistance = distance;
best = i;
}
}
if ( best < 0 ) continue;
claimed.Add( best );
yield return (new Selection( source.Kind, best ), flipX, flipY);
}
}
/// <summary>Add mirror copies of a just-placed marker (block spawn / player spawn / coin). A copy
/// landing exactly on an existing marker of the same kind is skipped. The placement's BeginMove
/// runs right after and pulls the copies into the selection as regular mirror partners.</summary>
private void AddMirroredMarkers( SelKind kind, int sourceIndex )
{
if ( !MirrorActive ) return;
var positions = MarkerPositions( kind );
if ( positions is null || sourceIndex < 0 || sourceIndex >= positions.Count ) return;
Vector2 source = positions[sourceIndex];
foreach ( var (flipX, flipY) in ActiveMirrorFlips() )
{
Vector2 mirrored = MirrorPoint( source, flipX, flipY );
bool exists = false;
for ( int i = 0; i < positions.Count && !exists; i++ )
exists = PointsAlmostEqual( positions[i], mirrored );
if ( exists ) continue;
// Spawns carry parallel pin/timer lists — always add through AddSpawn to keep them aligned.
if ( kind == SelKind.Spawn ) Level.AddSpawn( mirrored );
else positions.Add( mirrored );
}
}
/// <summary>Rebuild the anchor-relative mirror relations that flip move deltas: a selected element
/// sitting exactly at a mirror image of the anchor follows with the axis-flipped delta.</summary>
private void RefreshMoveMirrorRelations( Selection anchor )
{
_moveMirrorRelations.Clear();
foreach ( var (selection, flipX, flipY) in MirrorPartners( anchor ) )
if ( _selection.Contains( selection ) ) _moveMirrorRelations[selection] = (flipX, flipY);
}
private Vector2 MirroredDelta( Selection selected, Vector2 delta )
=> _moveMirrorRelations.TryGetValue( selected, out var relation )
? new Vector2( relation.FlipX ? -delta.x : delta.x, relation.FlipY ? -delta.y : delta.y )
: delta;
/// <summary>Console command: dump the mirror toggles plus every obstacle's and marker's
/// mirror-partner table (and which expected twins are MISSING and where) to the log — for
/// diagnosing why a click didn't co-select an expected twin. Editor-only.</summary>
[ConCmd( "mirror_debug" )]
public static void MirrorDebugCmd()
{
if ( !Game.IsEditor ) return;
if ( GameManager.Instance?.Stage is not LevelEditorStage stage )
{
Log.Info( "mirror_debug: level editor not open" );
return;
}
stage.DumpMirrorDebug();
}
private void DumpMirrorDebug()
{
Log.Info( $"mirror_debug: MirrorX={MirrorX} MirrorY={MirrorY} selection={_selection.Count}" );
string FlipTag( bool fx, bool fy ) => $"{(fx ? "X" : "")}{(fy ? "Y" : "")}";
for ( int i = 0; i < Level.Obstacles.Count; i++ )
{
var r = Level.Obstacles[i];
var partners = MirrorPartnerObstacles( i ).Select( p => $"#{p.Index}[{FlipTag( p.FlipX, p.FlipY )}]" ).ToArray();
string missing = "";
foreach ( var (flipX, flipY) in ActiveMirrorFlips() )
{
RectF m = MirrorRect( r, flipX, flipY );
if ( RectsAlmostEqual( m, r ) ) continue;
bool found = false;
for ( int j = 0; j < Level.Obstacles.Count && !found; j++ )
found = j != i && RectsAlmostEqual( Level.Obstacles[j], m );
if ( !found ) missing += $" MISSING {FlipTag( flipX, flipY )}-twin at ({m.Left},{m.Bottom},{m.Right},{m.Top})";
}
Log.Info( $" obstacle #{i} ({r.Left},{r.Bottom},{r.Right},{r.Top}) -> {(partners.Length > 0 ? string.Join( " ", partners ) : "no partners")}{missing}" );
}
void DumpMarkers( SelKind kind )
{
var positions = MarkerPositions( kind );
for ( int i = 0; i < positions.Count; i++ )
{
var partners = MirrorPartners( new Selection( kind, i ) )
.Select( p => $"#{p.Selection.Index}[{FlipTag( p.FlipX, p.FlipY )}]" ).ToArray();
Log.Info( $" {kind} #{i} ({positions[i].x},{positions[i].y}) -> {(partners.Length > 0 ? string.Join( " ", partners ) : "no partners")}" );
}
}
DumpMarkers( SelKind.Spawn );
DumpMarkers( SelKind.Player );
DumpMarkers( SelKind.Coin );
}
/// <summary>True when the multi-selection is exactly one obstacle plus its mirror twins — such a
/// group keeps its resize bands (any member's face can be grabbed and the twins follow).</summary>
private bool TryGetMirrorGroupIndices( out List<int> members )
{
members = null;
if ( !MirrorActive || _selection.Count < 2 ) return false;
foreach ( var s in _selection )
if ( s.Kind != SelKind.Obstacle || s.Index < 0 || s.Index >= Level.Obstacles.Count ) return false;
var partners = MirrorPartnerObstacles( _primarySelection.Index ).Select( partner => partner.Index ).ToHashSet();
foreach ( var s in _selection )
if ( s.Index != _primarySelection.Index && !partners.Contains( s.Index ) ) return false;
members = _selection.Select( s => s.Index ).ToList();
return true;
}
// ── edge snapping (obstacles): snap edges to nearby obstacle edges + arena walls so flush
// composites land within FLUSH_EPS, which is what the game needs to merge their walls ─────────────
private const float EDGE_SNAP = 4f;
// Candidate edge coordinates to snap to: the arena inner walls (always) + only those OTHER
// obstacles that actually overlap on the PERPENDICULAR axis, so a distant obstacle that merely
// shares an edge coordinate (but is nowhere near) can't pull the drag toward it.
private static bool SpansOverlap( float a0, float a1, float b0, float b1 )
=> a0 <= b1 + EDGE_SNAP && b0 <= a1 + EDGE_SNAP;
private System.Collections.Generic.List<float> EdgeXs( RectF r, int ignore, int ignoreSpawn = -1, bool includeSpawns = false,
int ignoreAlternate = -1, bool includeAlternate = false )
{
// Inner wall faces FIRST so a tie prefers flush-in-playable-space over the outer arena boundary.
var xs = new System.Collections.Generic.List<float> { Arena.WALL_SIZE, Arena.WIDTH - Arena.WALL_SIZE, 0f, Arena.WIDTH };
for ( int i = 0; i < Level.Obstacles.Count; i++ )
{
if ( i == ignore ) continue;
var o = Level.Obstacles[i];
if ( !SpansOverlap( o.Bottom, o.Top, r.Bottom, r.Top ) ) continue;
xs.Add( o.Left );
xs.Add( o.Right );
}
if ( includeAlternate )
{
for ( int i = 0; i < Level.AlternatePlayfieldRects.Count; i++ )
{
if ( i == ignoreAlternate ) continue;
var alternate = Level.AlternatePlayfieldRects[i];
if ( !SpansOverlap( alternate.Bottom, alternate.Top, r.Bottom, r.Top ) ) continue;
xs.Add( alternate.Left );
xs.Add( alternate.Right );
}
}
// Block spawns snap flush to each other (and the player to them): add other spawn boxes' X edges.
if ( includeSpawns )
{
for ( int i = 0; i < Level.SpawnPositions.Count; i++ )
{
if ( i == ignoreSpawn ) continue;
var c = Level.SpawnPositions[i];
if ( !SpansOverlap( c.y - SPAWN_HALF, c.y + SPAWN_HALF, r.Bottom, r.Top ) ) continue;
xs.Add( c.x - SPAWN_HALF );
xs.Add( c.x + SPAWN_HALF );
}
}
return xs;
}
private System.Collections.Generic.List<float> EdgeYs( RectF r, int ignore, int ignoreSpawn = -1, bool includeSpawns = false,
int ignoreAlternate = -1, bool includeAlternate = false )
{
// Inner wall faces FIRST so a tie prefers flush-in-playable-space over the outer arena boundary.
var ys = new System.Collections.Generic.List<float> { Arena.WALL_SIZE, Arena.HEIGHT - Arena.WALL_SIZE, 0f, Arena.HEIGHT };
for ( int i = 0; i < Level.Obstacles.Count; i++ )
{
if ( i == ignore ) continue;
var o = Level.Obstacles[i];
if ( !SpansOverlap( o.Left, o.Right, r.Left, r.Right ) ) continue;
ys.Add( o.Bottom );
ys.Add( o.Top );
}
if ( includeAlternate )
{
for ( int i = 0; i < Level.AlternatePlayfieldRects.Count; i++ )
{
if ( i == ignoreAlternate ) continue;
var alternate = Level.AlternatePlayfieldRects[i];
if ( !SpansOverlap( alternate.Left, alternate.Right, r.Left, r.Right ) ) continue;
ys.Add( alternate.Bottom );
ys.Add( alternate.Top );
}
}
// Block spawns snap flush to each other (and the player to them): add other spawn boxes' Y edges.
if ( includeSpawns )
{
for ( int i = 0; i < Level.SpawnPositions.Count; i++ )
{
if ( i == ignoreSpawn ) continue;
var c = Level.SpawnPositions[i];
if ( !SpansOverlap( c.x - SPAWN_HALF, c.x + SPAWN_HALF, r.Left, r.Right ) ) continue;
ys.Add( c.y - SPAWN_HALF );
ys.Add( c.y + SPAWN_HALF );
}
}
return ys;
}
// Try to snap a coordinate flush to the NEAREST candidate edge within EDGE_SNAP. Returns false when
// none is close enough, so the caller can fall back to grid / pixel.
private static bool TrySnapCoord( float v, System.Collections.Generic.List<float> cands, out float snapped )
{
snapped = v; float bestD = EDGE_SNAP; bool found = false;
foreach ( var c in cands )
{
float d = MathF.Abs( c - v );
if ( d < bestD ) { bestD = d; snapped = c; found = true; }
}
return found;
}
// Per-edge snap PRIORITY: flush to a wall/obstacle edge FIRST, then grid (if shown), then pixel.
private float SnapEdgeCoord( float v, System.Collections.Generic.List<float> cands, bool xAxis )
{
if ( !SnapActive ) return MathF.Round( v );
if ( TrySnapCoord( v, cands, out float e ) ) return e;
return GridSnapActive ? GridSnapAxis( v, xAxis ) : MathF.Round( v );
}
private static RectF SnapRectPixel( RectF r )
=> new RectF( MathF.Round( r.Left ), MathF.Round( r.Bottom ), MathF.Round( r.Right ), MathF.Round( r.Top ) );
private const float PIXEL_NORMALIZE_EPSILON = 0.001f;
private static float NormalizeNearPixel( float value )
{
float rounded = MathF.Round( value );
return MathF.Abs( value - rounded ) <= PIXEL_NORMALIZE_EPSILON ? rounded : value;
}
private static RectF NormalizeRectEdges( RectF r )
=> new( NormalizeNearPixel( r.Left ), NormalizeNearPixel( r.Bottom ),
NormalizeNearPixel( r.Right ), NormalizeNearPixel( r.Top ) );
private void NormalizeDraggedRectEdges()
{
bool normalizedAlternate = false;
foreach ( var selected in _selection )
{
if ( selected.Kind == SelKind.Obstacle && selected.Index >= 0 && selected.Index < Level.Obstacles.Count )
Level.Obstacles[selected.Index] = NormalizeRectEdges( Level.Obstacles[selected.Index] );
if ( selected.Kind == SelKind.AlternatePattern && selected.Index >= 0 && selected.Index < Level.AlternatePlayfieldRects.Count )
{
Level.AlternatePlayfieldRects[selected.Index] = NormalizeRectEdges( Level.AlternatePlayfieldRects[selected.Index] );
normalizedAlternate = true;
}
}
// The drag rendered the pre-normalized rect, so re-point the renderers at the committed edges.
if ( normalizedAlternate ) RefreshAlternatePlayfield();
}
// Snap each edge independently (used on CREATE — lets the new rect resize to meet a neighbour exactly).
// Snap disabled = pixel only (no flush / no grid). Otherwise flush-to-edge FIRST, then grid, then pixel.
private RectF SnapRectEdges( RectF r, int ignore, int ignoreAlternate = -1, bool includeAlternate = false )
{
if ( !SnapActive ) return SnapRectPixel( r );
var xs = EdgeXs( r, ignore, ignoreAlternate: ignoreAlternate, includeAlternate: includeAlternate );
var ys = EdgeYs( r, ignore, ignoreAlternate: ignoreAlternate, includeAlternate: includeAlternate );
return new RectF( SnapEdgeCoord( r.Left, xs, true ), SnapEdgeCoord( r.Bottom, ys, false ),
SnapEdgeCoord( r.Right, xs, true ), SnapEdgeCoord( r.Top, ys, false ) );
}
// Snap by TRANSLATION (used on MOVE — preserves size). When enabled, flush-to-edge
// FIRST (either edge lands on a candidate), else grid, else pixel — per axis. When includeSpawns is
// set, other block-spawn boxes are flush candidates too (ignoreSpawn excludes the one being moved).
private RectF SnapRectTranslate( RectF r, int ignore, bool includeSpawns = false, int ignoreSpawn = -1,
int ignoreAlternate = -1, bool includeAlternate = false )
{
if ( !SnapActive ) return SnapRectPixel( r );
if ( !TryBestDelta( r.Left, r.Right,
EdgeXs( r, ignore, ignoreSpawn, includeSpawns, ignoreAlternate, includeAlternate ), out float dx ) )
dx = ( GridSnapActive ? GridSnapAxis( r.Left, true ) : MathF.Round( r.Left ) ) - r.Left;
if ( !TryBestDelta( r.Bottom, r.Top,
EdgeYs( r, ignore, ignoreSpawn, includeSpawns, ignoreAlternate, includeAlternate ), out float dy ) )
dy = ( GridSnapActive ? GridSnapAxis( r.Bottom, false ) : MathF.Round( r.Bottom ) ) - r.Bottom;
return new RectF( r.Left + dx, r.Bottom + dy, r.Right + dx, r.Top + dy );
}
private void ResizeSelectedRect( Vector2 logical )
{
bool alternate = _moveKind == SelKind.AlternatePattern;
int count = alternate ? Level.AlternatePlayfieldRects.Count : Level.Obstacles.Count;
if ( _moveIndex < 0 || _moveIndex >= count ) return;
RectF origin = _resizeOriginRect;
float minSize = alternate ? 1f : MIN_RECT;
float raw = (_resizeSide is Direction.Left or Direction.Right ? logical.x : logical.y) + _resizeGrabOffset;
RectF candidate = _resizeSide switch
{
Direction.Left => new RectF( Math.Clamp( raw, 0f, origin.Right - minSize ), origin.Bottom, origin.Right, origin.Top ),
Direction.Right => new RectF( origin.Left, origin.Bottom, Math.Clamp( raw, origin.Left + minSize, Arena.WIDTH ), origin.Top ),
Direction.Down => new RectF( origin.Left, Math.Clamp( raw, 0f, origin.Top - minSize ), origin.Right, origin.Top ),
Direction.Up => new RectF( origin.Left, origin.Bottom, origin.Right, Math.Clamp( raw, origin.Bottom + minSize, Arena.HEIGHT ) ),
_ => origin,
};
float snapped = _resizeSide is Direction.Left or Direction.Right
? (!SnapActive ? MathF.Round( raw ) : SnapEdgeCoord( raw,
EdgeXs( candidate, alternate ? -1 : _moveIndex, ignoreAlternate: alternate ? _moveIndex : -1, includeAlternate: alternate ), true ))
: (!SnapActive ? MathF.Round( raw ) : SnapEdgeCoord( raw,
EdgeYs( candidate, alternate ? -1 : _moveIndex, ignoreAlternate: alternate ? _moveIndex : -1, includeAlternate: alternate ), false ));
RectF resized = _resizeSide switch
{
Direction.Left => new RectF( Math.Clamp( snapped, 0f, origin.Right - minSize ), origin.Bottom, origin.Right, origin.Top ),
Direction.Right => new RectF( origin.Left, origin.Bottom, Math.Clamp( snapped, origin.Left + minSize, Arena.WIDTH ), origin.Top ),
Direction.Down => new RectF( origin.Left, Math.Clamp( snapped, 0f, origin.Top - minSize ), origin.Right, origin.Top ),
Direction.Up => new RectF( origin.Left, origin.Bottom, origin.Right, Math.Clamp( snapped, origin.Bottom + minSize, Arena.HEIGHT ) ),
_ => origin,
};
if ( alternate )
{
Level.AlternatePlayfieldRects[_moveIndex] = resized;
RefreshAlternatePlayfield();
}
else
{
Level.Obstacles[_moveIndex] = resized;
// Mirror twins grabbed together follow the resize as exact mirror images.
foreach ( var (partner, flipX, flipY) in _resizeMirrorPartners )
if ( partner != _moveIndex && partner >= 0 && partner < Level.Obstacles.Count )
Level.Obstacles[partner] = MirrorRect( resized, flipX, flipY );
}
}
// The obstacle being dragged out, snapped from the RAW drag corners (flush→grid→pixel per edge).
// Used for the live preview, the dimension readout, and the final placement so they all agree.
private RectF CreateRect() => _creatingAlternateRect
? SnapRectEdges( Normalize( _dragStart, _dragCurrent ), -1, includeAlternate: true )
: SnapRectEdges( Normalize( _dragStart, _dragCurrent ), -1 );
// Snap a marker CENTRE by treating it as a box of the given half-extents and edge-snapping it like
// an obstacle (flush→grid→pixel, size preserved), then taking the centre back — so the marker's EDGES
// align to walls/obstacles/other spawns rather than a grid line running through its centre.
// ignoreSpawn excludes the spawn being moved from the flush candidates (-1 = none).
private Vector2 SnapBoxCenter( Vector2 center, float halfW, float halfH, int ignoreSpawn )
{
var box = new RectF( center.x - halfW, center.y - halfH, center.x + halfW, center.y + halfH );
box = SnapRectTranslate( box, -1, includeSpawns: true, ignoreSpawn: ignoreSpawn );
return RectCenter( box );
}
// Best single translation that lands either edge (lo/hi) flush on a candidate within EDGE_SNAP.
// False when none is close enough (so the caller can fall back to grid / pixel).
private static bool TryBestDelta( float lo, float hi, System.Collections.Generic.List<float> cands, out float delta )
{
delta = 0f; float bestD = EDGE_SNAP; bool found = false;
foreach ( var c in cands )
{
float d1 = c - lo;
if ( MathF.Abs( d1 ) < bestD ) { bestD = MathF.Abs( d1 ); delta = d1; found = true; }
float d2 = c - hi;
if ( MathF.Abs( d2 ) < bestD ) { bestD = MathF.Abs( d2 ); delta = d2; found = true; }
}
return found;
}
private static RectF ClampRect( RectF r )
{
float l = Math.Clamp( r.Left, 0f, Arena.WIDTH );
float b = Math.Clamp( r.Bottom, 0f, Arena.HEIGHT );
float rt = Math.Clamp( r.Right, 0f, Arena.WIDTH );
float t = Math.Clamp( r.Top, 0f, Arena.HEIGHT );
return new RectF( MathF.Min( l, rt ), MathF.Min( b, t ), MathF.Max( l, rt ), MathF.Max( b, t ) );
}
private static Vector2 ClampInside( Vector2 v )
=> new( Math.Clamp( v.x, Arena.WALL_SIZE, Arena.WIDTH - Arena.WALL_SIZE ),
Math.Clamp( v.y, Arena.WALL_SIZE, Arena.HEIGHT - Arena.WALL_SIZE ) );
private static Vector2 ClampCoin( Vector2 v )
=> new( Math.Clamp( v.x, Arena.WALL_SIZE + COIN_HALF_W, Arena.WIDTH - Arena.WALL_SIZE - COIN_HALF_W ),
Math.Clamp( v.y, Arena.WALL_SIZE + COIN_HALF_H, Arena.HEIGHT - Arena.WALL_SIZE - COIN_HALF_H ) );
private static Vector2 ClampSpawn( Vector2 v )
=> new( Math.Clamp( v.x, Arena.WALL_SIZE + 20f, Arena.WIDTH - Arena.WALL_SIZE - 20f ),
Math.Clamp( v.y, Arena.WALL_SIZE + 20f, Arena.HEIGHT - Arena.WALL_SIZE - 20f ) );
// ── spike toggling: pick the nearest spikeable face (arena wall or obstacle) ─────────────────────
private bool ToggleNearestSpike( Vector2 p )
{
const float MAX = 12f;
float best = MAX;
Action apply = null;
void Consider( float dist, Action act )
{
if ( dist < best ) { best = dist; apply = act; }
}
// Arena walls — inner edges.
Consider( DistToVSeg( p, Arena.WALL_SIZE, 0, Arena.HEIGHT ), () => ToggleWallMirrored( Direction.Left ) );
Consider( DistToVSeg( p, Arena.WIDTH - Arena.WALL_SIZE, 0, Arena.HEIGHT ), () => ToggleWallMirrored( Direction.Right ) );
Consider( DistToHSeg( p, Arena.WALL_SIZE, 0, Arena.WIDTH ), () => ToggleWallMirrored( Direction.Down ) );
Consider( DistToHSeg( p, Arena.HEIGHT - Arena.WALL_SIZE, 0, Arena.WIDTH ), () => ToggleWallMirrored( Direction.Up ) );
// Obstacle faces. Fences can never be spiked, so the spike tool skips them entirely.
for ( int i = 0; i < Level.Obstacles.Count; i++ )
{
if ( Level.ObstacleIsFence( i ) ) continue;
var r = Level.Obstacles[i];
int oi = i;
Consider( DistToVSeg( p, r.Left, r.Bottom, r.Top ), () => ToggleObstacleSpikeMirrored( oi, Direction.Left ) );
Consider( DistToVSeg( p, r.Right, r.Bottom, r.Top ), () => ToggleObstacleSpikeMirrored( oi, Direction.Right ) );
Consider( DistToHSeg( p, r.Bottom, r.Left, r.Right ), () => ToggleObstacleSpikeMirrored( oi, Direction.Down ) );
Consider( DistToHSeg( p, r.Top, r.Left, r.Right ), () => ToggleObstacleSpikeMirrored( oi, Direction.Up ) );
}
if ( apply is null ) return false;
apply();
return true;
}
private void ToggleWall( Direction dir )
{
if ( Level.SpikedWalls.Contains( dir ) ) Level.SpikedWalls.Remove( dir );
else Level.SpikedWalls.Add( dir );
}
/// <summary>Toggle a wall's spikes plus the walls the active mirrors map it to, each at most once
/// (with both mirrors on, X and XY map a side wall to the same opposite wall — toggling it twice
/// would cancel out).</summary>
private void ToggleWallMirrored( Direction dir )
{
ToggleWall( dir );
var done = new HashSet<Direction> { dir };
foreach ( var (flipX, flipY) in ActiveMirrorFlips() )
{
var mirrored = MirrorDirection( dir, flipX, flipY );
if ( done.Add( mirrored ) ) ToggleWall( mirrored );
}
}
/// <summary>Toggle an obstacle face's spikes plus the mirrored faces the active mirrors map it to:
/// the matching face of each mirror-twin obstacle, or the opposite face of the SAME obstacle when
/// it straddles the centre line (its mirror image is itself). Each face toggles at most once.</summary>
private void ToggleObstacleSpikeMirrored( int index, Direction dir )
{
Level.ToggleObstacleSpike( index, dir );
if ( !MirrorActive ) return;
RectF rect = Level.Obstacles[index];
var done = new HashSet<(int, Direction)> { (index, dir) };
foreach ( var (flipX, flipY) in ActiveMirrorFlips() )
{
Direction mirroredDir = MirrorDirection( dir, flipX, flipY );
// A centre-straddling obstacle is its own twin: spike the mirrored face of ITSELF.
int target = RectsAlmostEqual( MirrorRect( rect, flipX, flipY ), rect )
? index
: FindMirrorObstacle( rect, flipX, flipY, index );
if ( target < 0 || Level.ObstacleIsFence( target ) ) continue;
if ( done.Add( (target, mirroredDir) ) ) Level.ToggleObstacleSpike( target, mirroredDir );
}
}
// Distance from point to a vertical segment at x=edge spanning [y0,y1] (large if the point projects
// outside the span), and the horizontal analogue.
private static float DistToVSeg( Vector2 p, float edge, float y0, float y1 )
=> p.y < y0 - 2f || p.y > y1 + 2f ? float.MaxValue : MathF.Abs( p.x - edge );
private static float DistToHSeg( Vector2 p, float edge, float x0, float x1 )
=> p.x < x0 - 2f || p.x > x1 + 2f ? float.MaxValue : MathF.Abs( p.y - edge );
// ── palette refresh ─────────────────────────────────────────────────────────────────────────────
/// <summary>Re-apply the OOB colour to the camera clear and edge-blocker masks live.</summary>
public void RefreshPalette()
{
RefreshClearColor();
}
/// <summary>Switch the live editor preview to the current level's song.</summary>
public void RefreshMusic() => Audio.PlayMusic( Level.MusicVolume, Level.Music );
/// <summary>Rebuild the checkerboard field + drifting background blocks from the current environment
/// settings. Called live by every palette, pattern, size, density and drift editor control.</summary>
public void RefreshEnvironmentColors() => RefreshEnvironment();
// ── import / export ─────────────────────────────────────────────────────────────────────────────
public void LoadFromLevel( string id )
{
var def = Levels.Get( id );
if ( def is null ) { LastError = $"no level '{id}'"; return; }
Level = EditorLevel.FromLevelDef( def );
RefreshMusic();
LevelRevision++;
RememberOpenedLevel( def.Id );
Select( SelKind.None, -1 );
LastError = "";
RefreshEnvironment();
Rebuild();
// Opening another level is a document switch — undo history never crosses documents.
UndoHistory.Reset( Level.ToJsonString( fullAuthoringState: true ), Level.ProjectSourceId );
}
private static void RememberOpenedLevel( string id )
{
if ( EditorPrefs.Current.LastLevelId == id ) return;
EditorPrefs.Current.LastLevelId = id;
EditorPrefs.Save();
}
public bool ImportText( string text )
{
var e = EditorLevel.FromJsonString( text, out var err );
if ( e is null ) { LastError = string.IsNullOrEmpty( err ) ? "parse failed" : err; return false; }
CheckpointOutgoingDocument();
Level = e;
// A paste starts a NEW document, so it never inherits a file claim: LevelDef-shaped text (a
// replay's embedded level) parses through FromLevelDef, which sets ProjectSourceId from the
// pasted id — leaving it would let Save overwrite that project file past the exists guard.
Level.ProjectSourceId = null;
RememberOpenedLevel( "" );
RefreshMusic();
LevelRevision++;
Select( SelKind.None, -1 );
LastError = "";
RefreshEnvironment();
Rebuild();
CheckpointUndo( newDocument: true ); // paste-import replaces the level in place — undoable
return true;
}
public string ExportJson() => Level.ToJsonString();
public void CopyJson() => Sandbox.UI.Clipboard.SetText( ExportJson() );
/// <summary>Save the current LAYOUT as a daily-challenge obstacle template JSON
/// (Assets/daily-templates/{name}.json). Writing the project folder needs the editor
/// assembly, so this dispatches <c>daily_template_save</c> (same pattern as <see cref="SaveToProject"/>).
/// Spiked walls/faces are spike CANDIDATES rolled per-side each day, unless the
/// AuthoredSpikes toggle makes them spiked exactly as authored on every day.</summary>
public void SaveDailyTemplate()
{
var id = DailyTemplates.SanitizeId( Level.DailyTemplateName );
if ( string.IsNullOrEmpty( id ) ) { LastError = "template needs a name"; return; }
LastError = "";
Sandbox.ConsoleSystem.Run(
$"daily_template_save {id} {(Level.DailyTemplateMirrorX ? "true" : "false")} {Math.Max( 0f, Level.DailyTemplateWeight ).ToString( System.Globalization.CultureInfo.InvariantCulture )} {(Level.DailyTemplateAuthoredSpikes ? "true" : "false")}" );
}
/// <summary>Replace the current LAYOUT with a saved daily template's (obstacles, spawns, pins,
/// player, spikes, vision, alt rects) — blocks and cosmetics are kept, so a template can seed a
/// map level too, but the level id/name are CLEARED so an absent-minded Save/Delete can't touch
/// the previously open level. Syncs the template name/mirror/weight authoring fields so a
/// save-after-tweak round-trips them.</summary>
public void LoadDailyTemplate() => LoadDailyTemplate( DailyTemplates.SanitizeId( Level.DailyTemplateName ) );
/// <summary>Same, for an explicit template id — the level browser's Templates tab loads its pick
/// through here. Filling the authoring fields below also populates the inspector's template name
/// box, so the picked template can be re-saved with one click.</summary>
public void LoadDailyTemplate( string id )
{
var template = DailyTemplates.Find( id );
if ( template is null )
{
LastError = string.IsNullOrEmpty( id ) ? "template needs a name" : $"no template '{id}'";
return;
}
CheckpointOutgoingDocument();
DailyTemplates.ApplyToEditor( template, Level );
// Loading a template starts a new unnamed document: blank id/name (and no source-file claim)
// grey out the project Save/Delete buttons until the result is deliberately named, and the
// reopen pointer follows the claim so a later session doesn't reopen the replaced level.
Level.Id = "";
Level.Name = "";
Level.ProjectSourceId = null;
RememberOpenedLevel( "" );
Level.DailyTemplateName = template.Id;
Level.DailyTemplateMirrorX = template.AllowMirrorX;
Level.DailyTemplateAuthoredSpikes = template.AuthoredSpikes;
Level.DailyTemplateWeight = template.Weight;
// Don't leave the alt-pattern tool live on a template that has no alt playfield.
if ( !Level.AlternatePlayfieldEnabled && Tool == EditTool.AlternatePattern )
Tool = EditTool.Select;
LevelRevision++;
Select( SelKind.None, -1 );
LastError = "";
RefreshEnvironment();
Rebuild();
CheckpointUndo( newDocument: true ); // template load replaces the layout in place — undoable
}
/// <summary>Delete the named daily template's project JSON. Deleting the project file needs the
/// editor assembly, so this dispatches <c>daily_template_delete</c> (the inspector's Delete
/// button confirms before calling this).</summary>
public void DeleteDailyTemplate()
{
var id = DailyTemplates.SanitizeId( Level.DailyTemplateName );
if ( string.IsNullOrEmpty( id ) ) { LastError = "template needs a name"; return; }
if ( DailyTemplates.Find( id ) is null ) { LastError = $"no template '{id}'"; return; }
LastError = "";
Sandbox.ConsoleSystem.Run( $"daily_template_delete {id}" );
}
/// <summary>Test-play the current (unsaved) level without writing it to disk. Hands the live editor
/// model to the manager, which runs it as a non-submitting test and returns to a fresh editor with
/// this exact state when the run ends (death / win / Back). See <see cref="GameManager.StartLevelTest"/>.</summary>
public void TestPlay()
{
if ( !ValidateBlockPool() ) return;
Manager.StartLevelTest( Level, Tool, SelectedCharacterId );
}
/// <summary>Validate finite-pool authoring before test play or persistence and surface any error in the HUD.</summary>
public bool ValidateBlockPool()
{
LastError = Level.PoolValidationError ?? "";
return string.IsNullOrEmpty( LastError );
}
/// <summary>Surface an editor command failure in the inspector.</summary>
public void ReportProjectError( string error )
{
LastError = error ?? "";
}
/// <summary>True when <paramref name="id"/> names the project file this document was loaded from
/// or last saved as — the only file Save may overwrite and Delete may remove. An id that was typed
/// or pasted in merely MATCHES a file; it has no claim on it (load the level to get one). Exact
/// match, like every other id lookup (registry, timestamps, reopen pointer): a case-variant id would
/// hit the file on a case-insensitive disk but miss the registry entry.</summary>
public bool OwnsProjectFile( string id )
=> !string.IsNullOrWhiteSpace( id ) && string.Equals( Level.ProjectSourceId, id, System.StringComparison.Ordinal );
/// <summary>Associate this document with its successfully written project file. The undo entries of
/// this document adopt the id too, so undoing a later edit can't drop the newly saved file's identity
/// (which would make the next save refuse with "already exists").</summary>
public void MarkSavedToProject( string id )
{
UndoHistory.RetagSource( id );
Level.ProjectSourceId = id;
LastError = "";
PublishStatus = "";
RememberOpenedLevel( id );
}
// ── workshop publish (standalone only) ─────────────────────────────────────────────────────────
/// <summary>Status line for the Publish flow, shown under the save row. Set only when the engine's
/// publish modal reports success (a cancelled modal reports nothing); any save clears it.</summary>
public string PublishStatus { get; private set; } = "";
/// <summary>Publish the open level to the Steam Workshop (see <see cref="WorkshopLevels.Publish"/>:
/// saves locally first, then hands off to the engine's user-confirmed publish modal). Errors surface
/// through <see cref="LastError"/> like every other project action.</summary>
public void PublishToWorkshop()
{
PublishStatus = "";
WorkshopLevels.Publish( this, fileId => PublishStatus = $"PUBLISHED #{fileId}" );
}
/// <summary>Save the current level to its <c>Assets/levels/{id}.json</c> file, then reload the
/// registry so the edit takes effect immediately. Game code can't write the project folder, so this
/// dispatches the editor-assembly <c>level_save</c> ConCmd (which reads this live stage's
/// <see cref="Level"/> and writes the file). The editor command also applies the saved definition
/// directly to the live registry because the mounted asset filesystem can lag behind the disk write.
/// STANDALONE builds have no editor assembly — there the level saves as a local JSON in the game
/// data folder instead (see <see cref="LocalLevels"/>).</summary>
public void SaveToProject()
{
if ( !ValidateBlockPool() ) return;
if ( LocalLevels.Enabled ) { LocalLevels.Save( this ); return; }
Sandbox.ConsoleSystem.Run( "level_save" );
}
/// <summary>Delete the current level's project JSON through the editor assembly. The command
/// refreshes the live level registry and resets this editor only after a file is removed.
/// STANDALONE builds delete the local data-folder JSON instead (see <see cref="LocalLevels"/>).</summary>
public void DeleteFromProject()
{
LastError = "";
if ( LocalLevels.Enabled ) { LocalLevels.Delete( this ); return; }
Sandbox.ConsoleSystem.Run( "level_delete" );
}
// ── schematic rendering ─────────────────────────────────────────────────────────────────────────
/// <summary>Pool of reusable animated sprite markers for one editor role (coins, player spawns, block
/// spawn previews). <see cref="Rebuild"/> claims them in draw order and <see cref="TrimUnused"/>
/// destroys the leftovers, so a marker that survives a rebuild keeps its GameObject — and with it its
/// animation playback — instead of restarting at frame 0. The engine's <c>Sprite</c> setter and
/// <c>PlayAnimation</c> both no-op when the value is unchanged, so re-applying them every rebuild
/// costs nothing and only a REAL change (pinned block type, character swap) resets one marker.</summary>
private sealed class MarkerPool
{
private readonly List<SpriteRenderer> _renderers = new();
private readonly string _role;
private int _used;
public MarkerPool( string role ) => _role = role;
public void BeginSync() => _used = 0;
/// <summary>Forget every tracked renderer WITHOUT destroying it — for when the stage root (and
/// with it every marker GameObject) has already gone away.</summary>
public void Forget()
{
_renderers.Clear();
_used = 0;
}
public void Claim( GameObject parent, Vector2 center, float z, string spritePath, Vector2 size, string animation, Color? color = null )
{
if ( _used == _renderers.Count )
{
var added = SpriteLayer.Add( parent, spritePath, size, animation );
added.GameObject.Name = _role;
_renderers.Add( added );
}
var renderer = _renderers[_used++];
renderer.Sprite = ResourceLibrary.Get<Sprite>( spritePath );
renderer.Size = size;
renderer.PlayAnimation( animation );
// Always (re)assign the tint: a reused renderer may carry one from its previous claim.
renderer.Color = color ?? Color.White;
renderer.GameObject.WorldPosition = new Vector3( center.x, center.y, z );
}
public void TrimUnused()
{
for ( int i = _renderers.Count - 1; i >= _used; i-- )
{
_renderers[i].GameObject?.Destroy();
_renderers.RemoveAt( i );
}
}
}
public void Rebuild()
{
if ( Root is null ) return;
// Markers outlive the schematic. Recreate their root only when it isn't ours (first build, or a
// root left behind by a previous Enter) and drop the now-dangling renderers with it. The test is
// PARENTAGE, not IsValid/IsDestroyed: Destroy() only queues the object and doesn't mark its
// children until it runs next frame, so a stale root still reports itself perfectly healthy.
if ( _markerRoot is null || _markerRoot.Parent != Root )
{
_markerRoot = CreateChild( "Markers" );
_coinMarkers.Forget();
_playerMarkers.Forget();
_blockMarkers.Forget();
}
_coinMarkers.BeginSync();
_playerMarkers.BeginSync();
_blockMarkers.BeginSync();
_schematicRoot?.Destroy();
_spikeIndicators.Clear();
_selectionIndicators.Clear();
_schematicRoot = CreateChild( "Schematic" );
Color oob = Level.OutOfBoundsColor ?? Manager.ClearColor;
Color wc = Level.WallColor ?? WallGrey;
// Obstacle fills (out-of-bounds colour) — drawn under the walls. Fences and glass draw their
// own see-through looks below instead.
for ( int i = 0; i < Level.Obstacles.Count; i++ )
if ( !Level.ObstacleIsFence( i ) && !Level.ObstacleIsGlass( i ) )
Quad( RectCenter( Level.Obstacles[i] ), RectSize( Level.Obstacles[i] ), oob, DEPTH_FILL );
DrawArenaWalls( wc );
// Obstacle borders + face spikes; fences get the bar look (never spiked), glass the pane
// look (its spikes draw PALER — a player-only hazard blocks slide over).
for ( int i = 0; i < Level.Obstacles.Count; i++ )
{
var r = Level.Obstacles[i];
if ( Level.ObstacleIsFence( i ) )
{
DrawFence( r );
continue;
}
if ( Level.ObstacleIsGlass( i ) )
{
DrawGlass( r );
foreach ( var dir in Globals.GetAllDirections() )
if ( Level.ObstacleSpiked( i, dir ) )
SpikeStrip( r, dir, Pale( FaceSizeUneven( r, dir ) ? SpikeOrange : SpikeRed ) );
continue;
}
Outline( r, wc, Arena.WALL_SIZE, DEPTH_WALLS, inset: true );
foreach ( var dir in Globals.GetAllDirections() )
if ( Level.ObstacleSpiked( i, dir ) )
SpikeStrip( r, dir, FaceSizeUneven( r, dir ) ? SpikeOrange : SpikeRed );
}
DrawSpawns();
DrawPlayer();
DrawCoins();
DrawVision();
DrawAlternatePatternRects();
DrawSelection();
DrawObstacleHover();
DrawCreatePreview();
DrawMirrorGuides();
DrawGrid();
// Whatever the draws above didn't claim no longer exists in the level.
_coinMarkers.TrimUnused();
_playerMarkers.TrimUnused();
_blockMarkers.TrimUnused();
}
/// <summary>White alignment-grid overlay (editor pref, persisted). Drawn last + at the overlay depth
/// with a high child order so it sits over the whole schematic.</summary>
private void DrawGrid()
{
var s = EditorPrefs.Current;
if ( !s.ShowGrid ) return;
float gs = MathF.Max( 2f, s.GridSize );
float a = Math.Clamp( s.GridOpacity / 100f, 0f, 1f );
if ( a <= 0f ) return;
Color col = new( 1f, 1f, 1f, a );
float cx = Arena.WIDTH / 2f, cy = Arena.HEIGHT / 2f;
const int depth = DEPTH_OVERLAY;
const int order = 30; // above selection / create-preview
// The lattice reference is normally the centre (optionally shifted by half a cell), or the
// playable bottom-left corner immediately inside the walls when that origin mode is selected.
float lcx = GridLineOrigin( gs, true ), lcy = GridLineOrigin( gs, false );
// Ultra-fine lines: one LOGICAL (in-game) pixel would render as several monitor pixels — the
// ortho camera maps the 240-unit arena across the whole screen height — giving chunky bars. So
// make the line width a small fraction of a logical unit, scaled by the screen height, so each
// line renders roughly one monitor pixel thin and sits between/through the chunky game pixels.
float screenH = Screen.Height > 1f ? Screen.Height : 1080f;
float lineW = MathF.Max( 0.02f, GRID_LINE_SCREEN_PX * Arena.HEIGHT / screenH );
// Smart grid: symmetric lattices mirrored inward from both inner wall faces, so lines land where
// blocks sit flush against either wall. Ignores the centre/offset origin options.
if ( s.GridSmart )
{
foreach ( float x in SmartGridLines( gs, true ) )
Quad( new Vector2( x, cy ), new Vector2( lineW, Arena.HEIGHT ), col, depth, translucent: true, childOrder: order );
foreach ( float y in SmartGridLines( gs, false ) )
Quad( new Vector2( cx, y ), new Vector2( Arena.WIDTH, lineW ), col, depth, translucent: true, childOrder: order );
return;
}
// Vertical lines, radiating out from the (possibly offset) centre reference.
for ( float x = lcx; x <= Arena.WIDTH + 0.01f; x += gs )
Quad( new Vector2( x, cy ), new Vector2( lineW, Arena.HEIGHT ), col, depth, translucent: true, childOrder: order );
for ( float x = lcx - gs; x >= -0.01f; x -= gs )
Quad( new Vector2( x, cy ), new Vector2( lineW, Arena.HEIGHT ), col, depth, translucent: true, childOrder: order );
// Horizontal lines.
for ( float y = lcy; y <= Arena.HEIGHT + 0.01f; y += gs )
Quad( new Vector2( cx, y ), new Vector2( Arena.WIDTH, lineW ), col, depth, translucent: true, childOrder: order );
for ( float y = lcy - gs; y >= -0.01f; y -= gs )
Quad( new Vector2( cx, y ), new Vector2( Arena.WIDTH, lineW ), col, depth, translucent: true, childOrder: order );
}
// Target on-screen thickness of a grid line, in monitor pixels (converted to a thin fraction of a
// logical unit at draw time). Kept close to 1 for the "ultrafine" look; not an exact 1px measure.
private const float GRID_LINE_SCREEN_PX = 1.25f;
/// <summary>Centre-line guides while a mirror toggle is on: the line(s) geometry reflects across
/// (ultra-fine like the grid, tinted like the create preview so they read as an editing aid).</summary>
private void DrawMirrorGuides()
{
if ( !MirrorActive ) return;
float screenH = Screen.Height > 1f ? Screen.Height : 1080f;
float lineW = MathF.Max( 0.02f, 1.5f * GRID_LINE_SCREEN_PX * Arena.HEIGHT / screenH );
Color col = new( 0.4f, 1f, 0.6f, 0.45f );
if ( MirrorX )
Quad( new Vector2( Arena.WIDTH / 2f, Arena.HEIGHT / 2f ), new Vector2( lineW, Arena.HEIGHT ), col,
DEPTH_OVERLAY, translucent: true, childOrder: 29 );
if ( MirrorY )
Quad( new Vector2( Arena.WIDTH / 2f, Arena.HEIGHT / 2f ), new Vector2( Arena.WIDTH, lineW ), col,
DEPTH_OVERLAY, translucent: true, childOrder: 29 );
}
private void DrawArenaWalls( Color wc )
{
int W = Arena.WIDTH, H = Arena.HEIGHT, ws = Arena.WALL_SIZE;
Quad( new Vector2( ws / 2f, H / 2f ), new Vector2( ws, H ), wc, Globals.DEPTH_ARENA_WALL ); // left
Quad( new Vector2( W - ws / 2f, H / 2f ), new Vector2( ws, H ), wc, Globals.DEPTH_ARENA_WALL ); // right
Quad( new Vector2( W / 2f, ws / 2f ), new Vector2( W, ws ), wc, Globals.DEPTH_ARENA_WALL ); // bottom
Quad( new Vector2( W / 2f, H - ws / 2f ), new Vector2( W, ws ), wc, Globals.DEPTH_ARENA_WALL ); // top
// Arena-wall spikes are a single untiled strip, so they never suffer the ÷5 uneven-spacing issue.
// Pulse them from red like an evenly tiled obstacle face. Centre them on the inner face and draw
// them behind the wall so only the playable half remains visible without obscuring the boundary.
float off = ws;
foreach ( var dir in Level.SpikedWalls )
{
switch ( dir )
{
case Direction.Left: SpikeQuad( new Vector2( off, H / 2f ), new Vector2( SPIKE_STRIP_THICK, H ), SpikeRed, Globals.DEPTH_ARENA_WALL ); break;
case Direction.Right: SpikeQuad( new Vector2( W - off, H / 2f ), new Vector2( SPIKE_STRIP_THICK, H ), SpikeRed, Globals.DEPTH_ARENA_WALL ); break;
case Direction.Down: SpikeQuad( new Vector2( W / 2f, off ), new Vector2( W, SPIKE_STRIP_THICK ), SpikeRed, Globals.DEPTH_ARENA_WALL ); break;
case Direction.Up: SpikeQuad( new Vector2( W / 2f, H - off ), new Vector2( W, SPIKE_STRIP_THICK ), SpikeRed, Globals.DEPTH_ARENA_WALL ); break;
}
}
}
// True when a spiked obstacle face needs an overlapping final tile. Exact multiples and a 1px
// plain tail tile cleanly; remainders 2-4 clamp the final tooth and can look uneven.
// Up/Down faces tile along WIDTH, Left/Right faces along HEIGHT.
public static bool SpikeFaceSizeUneven( float length )
{
float nearestMultiple = MathF.Round( length / 5f ) * 5f;
if ( MathF.Abs( length - nearestMultiple ) <= PIXEL_NORMALIZE_EPSILON ) return false;
return length % 5f > 1.01f;
}
private static bool FaceSizeUneven( RectF r, Direction dir )
{
float len = ( dir == Direction.Up || dir == Direction.Down ) ? r.Width : r.Height;
return SpikeFaceSizeUneven( len );
}
private void SpikeStrip( RectF r, Direction dir, Color color )
{
float cx = ( r.Left + r.Right ) / 2f, cy = ( r.Bottom + r.Top ) / 2f;
float t = SPIKE_STRIP_THICK;
switch ( dir )
{
case Direction.Left: SpikeQuad( new Vector2( r.Left, cy ), new Vector2( t, r.Height ), color ); break;
case Direction.Right: SpikeQuad( new Vector2( r.Right, cy ), new Vector2( t, r.Height ), color ); break;
case Direction.Down: SpikeQuad( new Vector2( cx, r.Bottom ), new Vector2( r.Width, t ), color ); break;
case Direction.Up: SpikeQuad( new Vector2( cx, r.Top ), new Vector2( r.Width, t ), color ); break;
}
}
private void SpikeQuad( Vector2 center, Vector2 size, Color lowColor, float depth = DEPTH_WALLS )
{
var renderer = Quad( center, size, SpikePulseColor( lowColor ), depth, childOrder: SPIKE_CHILD_ORDER );
_spikeIndicators.Add( new SpikeIndicator( renderer, lowColor ) );
}
private void UpdateSpikePulse( float dt )
{
_spikePulseTime += dt;
foreach ( var indicator in _spikeIndicators )
indicator.Renderer.Color = GammaToLinear( SpikePulseColor( indicator.LowColor ) );
}
private Color SpikePulseColor( Color lowColor )
{
float amount = 0.5f - 0.5f * MathF.Cos( _spikePulseTime * SPIKE_PULSE_SPEED );
return Color.Lerp( lowColor, SpikeYellow, amount );
}
private void UpdateSelectionPulse( float dt )
{
_selectionPulseTime += dt;
float amount = SelectionPulseAmount();
float padding = SELECTION_HALO_PADDING_MIN + (SELECTION_HALO_PADDING_MAX - SELECTION_HALO_PADDING_MIN) * amount;
Color color = GammaToLinear( SelectionPulseColor( amount ) );
foreach ( var indicator in _selectionIndicators )
{
var (center, size) = SelectionHaloGeometry( indicator.Bounds, indicator.Side, padding );
var position = indicator.Renderer.GameObject.WorldPosition;
indicator.Renderer.GameObject.WorldPosition = new Vector3( center.x, center.y, position.z );
indicator.Renderer.Size = size;
indicator.Renderer.Color = color;
}
}
private float SelectionPulseAmount()
{
return 0.5f - 0.5f * MathF.Cos( _selectionPulseTime * SELECTION_PULSE_SPEED );
}
private static Color SelectionPulseColor( float amount ) => Color.Lerp( SelectionBlue, SelectionWhite, amount );
private void DrawSpawns()
{
bool showType = !Level.UsePool && !Level.ShuffleTypes;
for ( int i = 0; i < Level.SpawnPositions.Count; i++ )
{
var c = Level.SpawnPositions[i];
var pin = Level.SpawnPin( i );
if ( pin is not null )
BlockMarker( c, pin ); // pinned slot: always show the pinned block
else if ( showType && i < Level.Blocks.Count )
BlockMarker( c, Level.Blocks[i] ); // exact ordered placement preview
else
Quad( c, new Vector2( 40f, 40f ), SpawnFill, DEPTH_MARKER, translucent: true );
var outlineCol = pin is not null ? new Color( 0.4f, 1f, 0.6f, 0.9f ) : SpawnOutline;
Outline( new RectF( c.x - 20f, c.y - 20f, c.x + 20f, c.y + 20f ), outlineCol, 1f, DEPTH_MARKER, inset: true, childOrder: 8 );
}
}
private void DrawCoins()
{
// Schematic coin: the real baked sprite at art size (tiny but exact), plus a faint outline
// one pixel out so the 4x6 marker still reads and hit-targets at a glance.
foreach ( var c in Level.Coins )
{
_coinMarkers.Claim( _markerRoot, c, Globals.DepthToZ( DEPTH_MARKER, 5 ),
"sprites/coin.sprite", new Vector2( Coin.ART_H, Coin.ART_H ), "idle" );
Outline( BoxAt( c, COIN_HALF_W + 1f, COIN_HALF_H + 1f ), CoinYellow.WithAlpha( 0.55f ), 1f,
DEPTH_MARKER, inset: false, childOrder: 6 );
}
}
private void DrawPlayer()
{
// Render the real player sprite at the player's art size so each marker looks exactly like
// the in-game player — using the character currently selected in the editor (what a Test Play
// uses), so the spawn markers preview the actual character that will play. Exactly two Twin
// spawns preview Twin 1 then Twin 2; other multi-spawn sets remain random candidates.
var character = Characters.TryGet( Level.ForcedCharacterId, out var forced )
? forced
: Characters.Get( SelectedCharacterId );
for ( int i = 0; i < Level.PlayerSpawns.Count; i++ )
{
var c = Level.PlayerSpawns[i];
var markerCharacter = i == 1 && Level.PlayerSpawns.Count == 2 && character.Partner is not null
? character.Partner
: character;
_playerMarkers.Claim( _markerRoot, c, Globals.DepthToZ( DEPTH_MARKER, 7 ),
markerCharacter.SpritePath, markerCharacter.ArtSize, "idle" );
// Very thin border of the ACTUAL hitbox (8×10) so its exact bounds + snap position read clearly.
// One logical px renders several monitor px under the ortho cam, so scale the thickness by the
// screen height to keep it ~1 monitor px thin (same trick as the alignment grid). Selection uses a
// separate expanded halo behind the marker, so this exact hitbox border never changes appearance.
float screenH = Screen.Height > 1f ? Screen.Height : 1080f;
float thin = MathF.Max( 0.12f, 1.25f * Arena.HEIGHT / screenH );
var hitbox = new RectF( c.x - PLAYER_HALF_W, c.y - PLAYER_HALF_H, c.x + PLAYER_HALF_W, c.y + PLAYER_HALF_H );
Outline( hitbox, PlayerBorderColor, thin, DEPTH_OVERLAY, inset: false, childOrder: 4 );
}
}
private void DrawVision()
{
// Cue for obstacles flagged to block line of sight: a cyan fill + inner outline over the
// obstacle (no standalone vision rects any more — vision is a per-obstacle flag).
foreach ( var i in Level.VisionObstacles )
{
if ( i < 0 || i >= Level.Obstacles.Count ) continue;
var r = Level.Obstacles[i];
Quad( RectCenter( r ), RectSize( r ), VisionFill, DEPTH_VISION, translucent: true );
Outline( r, VisionOutline, 1f, DEPTH_VISION, inset: true, childOrder: 1 );
}
}
// Fence schematic: faint fill + 1px frame + vertical bars on the same world-aligned 5px grid the
// game uses (GameStage.AddFence), so the editor pixel-matches the in-game look — including the
// level's authored fence colour (border = the colour, bars = its brighter derivative).
private void DrawFence( RectF r )
{
Color border = Level.FenceColor ?? LevelCosmeticsRandomizer.DefaultFenceColor;
Color bars = LevelCosmeticsRandomizer.FenceBarColor( border );
Quad( RectCenter( r ), RectSize( r ), border.WithAlpha( 0.10f ), DEPTH_FILL, translucent: true );
// Bars edge-to-edge (the frame outline paints over their ends via its higher child order),
// so the 5px rhythm continues across the frameless seam of two flush fences — like in-game.
// Bar alpha matches the game's FENCE_BAR_ALPHA.
const float bar = 1f, spacing = 5f;
for ( float x = MathF.Ceiling( r.Left / spacing ) * spacing; x <= r.Right - bar; x += spacing )
Quad( new Vector2( x + bar / 2f, (r.Bottom + r.Top) / 2f ), new Vector2( bar, r.Height ),
bars.WithAlpha( 0.5f ), DEPTH_WALLS, translucent: true );
// Frame alpha matches the game's FENCE_BORDER_ALPHA (Outline goes translucent when a < 1).
Outline( r, border.WithAlpha( 0.85f ), 1f, DEPTH_WALLS, inset: true, childOrder: 1 );
}
// Glass schematic: translucent pane + 1px solid border + sparse continuous diagonal reflection
// streaks — the SAME alphas, thickness and world-aligned rhythm as the game (GameStage's glass
// section), so the editor pixel- and colour-matches the live look.
private void DrawGlass( RectF r )
{
Color glass = Level.GlassColor ?? LevelCosmeticsRandomizer.DefaultGlassColor;
Quad( RectCenter( r ), RectSize( r ), glass.WithAlpha( 0.30f ), DEPTH_FILL, translucent: true );
DrawGlassStreaks( r, glass.WithAlpha( 0.50f ) );
Outline( r, glass, 1f, DEPTH_WALLS, inset: true, childOrder: 1 );
}
// The band ∩ pane rasterized as abutting 1px columns, exactly like GameStage.AddGlassStreaks:
// streaks run fully edge-to-edge with no missing end wedges, never leave the pane, and continue
// seamlessly across composite-pane seams (adjacent panels rasterize the same world-space band).
private void DrawGlassStreaks( RectF r, Color color )
{
const float P = 24f; // world-aligned diagonal rhythm, matching GameStage.GLASS_STREAK_PERIOD
float h = 3f * MathF.Sqrt( 2f ) / 2f; // GameStage.GLASS_STREAK_WIDTH → band |x - y - c| <= h
float cMin = r.Left - r.Top - h, cMax = r.Right - r.Bottom + h;
for ( float c = MathF.Ceiling( cMin / P ) * P; c <= cMax; c += P )
{
float xLo = MathF.Max( r.Left, MathF.Floor( r.Bottom + c - h ) );
float xHi = MathF.Min( r.Right, r.Top + c + h );
for ( float x0 = xLo; x0 < xHi; x0 += 1f )
{
float x1 = MathF.Min( x0 + 1f, r.Right );
float xc = (x0 + x1) / 2f;
float yLo = MathF.Max( r.Bottom, xc - c - h );
float yHi = MathF.Min( r.Top, xc - c + h );
if ( yHi <= yLo ) continue;
Quad( new Vector2( xc, (yLo + yHi) / 2f ), new Vector2( x1 - x0, yHi - yLo ),
color, DEPTH_WALLS, translucent: true );
}
}
}
// The paler spike-strip tint for GLASS faces: still clearly a hazard, but visibly softer than a
// real wall's spikes — the cue that blocks slide over these (they only kill the player).
private static Color Pale( Color spike ) => Color.Lerp( spike, Color.White, 0.45f );
private void DrawAlternatePatternRects()
{
if ( Tool != EditTool.AlternatePattern ) return;
foreach ( RectF rect in Level.AlternatePlayfieldRects )
Outline( rect, new Color( 1f, 0.72f, 0.22f, 0.9f ), 0.5f, DEPTH_OVERLAY, inset: true, childOrder: 8 );
}
private void DrawSelection()
{
foreach ( var selected in _selection )
if ( selected.Kind != SelKind.AlternatePattern || Tool == EditTool.AlternatePattern )
SelectionHalo( selected );
if ( _marqueeSelecting && _pointerDragged )
{
RectF marquee = Normalize( _dragStart, _dragCurrent );
Quad( RectCenter( marquee ), RectSize( marquee ), new Color( 0.45f, 0.72f, 1f, 0.12f ), DEPTH_OVERLAY, translucent: true, childOrder: 10 );
Outline( marquee, new Color( 0.45f, 0.72f, 1f, 0.95f ), 0.5f, DEPTH_OVERLAY, inset: true, childOrder: 11 );
}
}
private void DrawObstacleHover()
{
RectF rect;
if ( _hoverRectKind == SelKind.Obstacle && _hoverObstacleIndex >= 0 && _hoverObstacleIndex < Level.Obstacles.Count )
rect = Level.Obstacles[_hoverObstacleIndex];
else if ( _hoverRectKind == SelKind.AlternatePattern && _hoverObstacleIndex >= 0
&& _hoverObstacleIndex < Level.AlternatePlayfieldRects.Count )
rect = Level.AlternatePlayfieldRects[_hoverObstacleIndex];
else return;
if ( _obstacleHoverIntent == ObstacleHoverIntent.Move )
{
Quad( RectCenter( rect ), RectSize( rect ), MoveHoverFill, DEPTH_OVERLAY, translucent: true, childOrder: 18 );
Outline( rect, MoveHoverOutline, 0.75f, DEPTH_OVERLAY, inset: true, childOrder: 19 );
return;
}
if ( _obstacleHoverIntent != ObstacleHoverIntent.Resize ) return;
const float thickness = 1.5f;
const float overhang = 2f;
float centerX = ( rect.Left + rect.Right ) / 2f;
float centerY = ( rect.Bottom + rect.Top ) / 2f;
(Vector2 center, Vector2 size) = _hoverResizeSide switch
{
Direction.Left => (new Vector2( rect.Left, centerY ), new Vector2( thickness, rect.Height + overhang * 2f )),
Direction.Right => (new Vector2( rect.Right, centerY ), new Vector2( thickness, rect.Height + overhang * 2f )),
Direction.Down => (new Vector2( centerX, rect.Bottom ), new Vector2( rect.Width + overhang * 2f, thickness )),
Direction.Up => (new Vector2( centerX, rect.Top ), new Vector2( rect.Width + overhang * 2f, thickness )),
_ => (Vector2.Zero, Vector2.Zero),
};
if ( size != Vector2.Zero )
Quad( center, size, ResizeHoverColor, DEPTH_OVERLAY, translucent: true, childOrder: 20 );
}
private void SelectionHalo( Selection selected )
{
RectF bounds = SelectionBounds( selected );
int depth = selected.Kind is SelKind.Obstacle or SelKind.AlternatePattern ? DEPTH_WALLS : DEPTH_MARKER;
SelectionHaloQuad( bounds, Direction.Down, depth );
SelectionHaloQuad( bounds, Direction.Up, depth );
SelectionHaloQuad( bounds, Direction.Left, depth );
SelectionHaloQuad( bounds, Direction.Right, depth );
}
private void SelectionHaloQuad( RectF bounds, Direction side, int depth )
{
float amount = SelectionPulseAmount();
float padding = SELECTION_HALO_PADDING_MIN + (SELECTION_HALO_PADDING_MAX - SELECTION_HALO_PADDING_MIN) * amount;
var (center, size) = SelectionHaloGeometry( bounds, side, padding );
var renderer = Quad( center, size, SelectionPulseColor( amount ), depth, translucent: true, childOrder: SELECTION_CHILD_ORDER );
_selectionIndicators.Add( new SelectionIndicator( renderer, bounds, side ) );
}
private static (Vector2 Center, Vector2 Size) SelectionHaloGeometry( RectF bounds, Direction side, float padding )
{
float cx = ( bounds.Left + bounds.Right ) / 2f;
float cy = ( bounds.Bottom + bounds.Top ) / 2f;
float width = bounds.Width + 2f * padding;
float height = bounds.Height + 2f * padding - SELECTION_HALO_THICK;
return side switch
{
Direction.Down => (new Vector2( cx, bounds.Bottom - padding ), new Vector2( width, SELECTION_HALO_THICK )),
Direction.Up => (new Vector2( cx, bounds.Top + padding ), new Vector2( width, SELECTION_HALO_THICK )),
Direction.Left => (new Vector2( bounds.Left - padding, cy ), new Vector2( SELECTION_HALO_THICK, height )),
Direction.Right => (new Vector2( bounds.Right + padding, cy ), new Vector2( SELECTION_HALO_THICK, height )),
_ => (Vector2.Zero, Vector2.Zero),
};
}
private void DrawCreatePreview()
{
if ( !_creatingRect && !_creatingAlternateRect ) return;
RectF r = CreateRect();
if ( r.Width < 1f || r.Height < 1f ) return;
Color fill = _creatingAlternateRect ? new Color( 1f, 0.65f, 0.16f, 0.25f ) : PreviewFill;
Color outline = _creatingAlternateRect ? new Color( 1f, 0.72f, 0.22f, 0.95f ) : PreviewOutline;
Quad( RectCenter( r ), RectSize( r ), fill, DEPTH_OVERLAY, translucent: true );
Outline( r, outline, 1f, DEPTH_OVERLAY, inset: true, childOrder: 1 );
// Fainter previews of the mirror copies the release will create (mirrors of the CLAMPED rect,
// matching what actually lands).
if ( !_creatingRect || !MirrorActive ) return;
RectF clamped = ClampRect( r );
foreach ( var (flipX, flipY) in ActiveMirrorFlips() )
{
RectF mirrored = MirrorRect( clamped, flipX, flipY );
if ( RectsAlmostEqual( mirrored, clamped ) ) continue;
Quad( RectCenter( mirrored ), RectSize( mirrored ), fill.WithAlpha( fill.a * 0.5f ), DEPTH_OVERLAY, translucent: true );
Outline( mirrored, outline.WithAlpha( outline.a * 0.5f ), 1f, DEPTH_OVERLAY, inset: true, childOrder: 1 );
}
}
// ── low-level draw primitives (into _schematicRoot) ─────────────────────────────────────────────
private static Vector2 RectSize( RectF r ) => new( r.Width, r.Height );
private SpriteRenderer Quad( Vector2 center, Vector2 size, Color color, float depth, bool translucent = false, int childOrder = 0 )
{
var go = new GameObject();
go.Name = "q";
go.SetParent( _schematicRoot );
go.WorldPosition = new Vector3( center.x, center.y, depth + childOrder * 0.01f );
var sr = SpriteLayer.Add( go, "sprites/pixel.sprite", size, "idle" );
// A sprite tint is consumed LINEAR; convert so the authored (gamma) colour shows as intended
// and, for the out-of-bounds fill, matches the gamma-space camera clear exactly.
sr.Color = GammaToLinear( color );
if ( translucent )
{
sr.Opaque = false;
sr.AlphaCutoff = 0f;
}
return sr;
}
/// <summary>Four thin quads outlining a rect. <paramref name="inset"/> draws the strips inside the
/// edges (borders); otherwise centred on the edges.</summary>
private void Outline( RectF r, Color color, float thick, float depth, bool inset = true, int childOrder = 0 )
{
float w = r.Width, h = r.Height;
float half = thick / 2f;
float bY = inset ? r.Bottom + half : r.Bottom;
float tY = inset ? r.Top - half : r.Top;
float lX = inset ? r.Left + half : r.Left;
float rX = inset ? r.Right - half : r.Right;
float cx = ( r.Left + r.Right ) / 2f, cy = ( r.Bottom + r.Top ) / 2f;
Quad( new Vector2( cx, bY ), new Vector2( w, thick ), color, depth, translucent: color.a < 1f, childOrder: childOrder );
Quad( new Vector2( cx, tY ), new Vector2( w, thick ), color, depth, translucent: color.a < 1f, childOrder: childOrder );
Quad( new Vector2( lX, cy ), new Vector2( thick, h ), color, depth, translucent: color.a < 1f, childOrder: childOrder );
Quad( new Vector2( rX, cy ), new Vector2( thick, h ), color, depth, translucent: color.a < 1f, childOrder: childOrder );
}
/// <summary>Preview a block exactly as it will spawn: the authored phase's full face (face + mouth +
/// eyes + eyebrows, mirroring <see cref="Block.CreateVisuals"/>/<see cref="Block.SetInitialPhase"/>)
/// plus the four side buttons, pressed where the block authors them pre-pressed. Eyes use the tally's
/// fixed forward "down" gaze (a live block's gaze follows its random move direction). Marker-depth
/// sub-orders 0-4 mirror the block's own layer order; coin/player markers and the spawn outline sit
/// above at 5+.</summary>
private void BlockMarker( Vector2 center, EditorBlock block )
{
string sprite = $"sprites/blocks/{block.Type.ToString().ToLowerInvariant()}.sprite";
var size = new Vector2( 40f, 40f );
int phase = Math.Clamp( block.Phase, 0, Block.NUM_PHASES - 1 );
bool max = phase == Block.NUM_PHASES - 1;
void Layer( string path, string anim, int subOrder, Color? color = null )
=> _blockMarkers.Claim( _markerRoot, center, Globals.DepthToZ( DEPTH_MARKER, subOrder ), path, size, anim, color );
Layer( sprite, $"face_{phase}", 0 );
foreach ( var dir in Globals.GetAllDirections() )
{
// Pre-pressed sides only exist below max phase (a maxed block has no pressable buttons) —
// same rule as Block.SetInitialPhase. Unpressed phase-1 buttons get the live block's tint.
bool pressed = !max && block.Pressed.Contains( dir );
string state = pressed ? $"pressed{phase}" : max ? "max" : $"out{phase}";
Color tint = !pressed && phase == 1 ? Block.PHASE_1_OUT_TINT : Color.White;
Layer( "sprites/sides.sprite", $"{state}_{Globals.GetStringForDirection( dir )}", 1, tint );
}
Layer( sprite, $"mouth_{phase}", 2 );
Layer( sprite, $"eyes_down_{phase}", 3 );
Layer( sprite, $"eyebrows_{phase}", 4 );
}
}