UI/LevelEditorHud.razor
@using System
@using System.Linq
@using Sandbox
@using Sandbox.UI
@inherits PanelComponent
@namespace BlockParty
@attribute [StyleSheet( "LevelEditorHud.razor.scss" )]
<root>
@* Full-screen world-interaction canvas (sits behind the toolbars). Its active/hover state is
forwarded to the stage each frame; right-click deletes under the cursor. *@
<LevelCanvasInput class="canvas" @ref="Canvas" onrightclick=@OnRightClick></LevelCanvasInput>
<LevelEditorShortcutInput class="shortcut-input" Stage=@Stage @ref="ShortcutInput"></LevelEditorShortcutInput>
<PixelTooltip @ref="Tooltip"></PixelTooltip>
@if ( Stage is not null )
{
@* Floating width/height readout, anchored over the obstacle in the level (not in a side panel):
shown while dragging out a new obstacle and while an obstacle is selected/moved. *@
@if ( DimVisible() )
{
<div class="dim-label" style="@DimStyle()">@DimText()</div>
}
<div class="panel tools">
<div class="section">
<div class="toolgrid">
<button class="tool @ToolActive( LevelEditorStage.EditTool.Select )" onclick=@Click( () => SetTool( LevelEditorStage.EditTool.Select ) )
onmouseover=@Tip( "Click to select, drag to move; Shift-click/drag adds or removes from the selection, hold X for cardinal directions, drag a face to resize" )><div class="ticon">near_me</div><span>Select <span class="shortcut">(E)</span></span></button>
<button class="tool @ToolActive( LevelEditorStage.EditTool.Spawn )" onclick=@Click( () => SetTool( LevelEditorStage.EditTool.Spawn ) )
onmouseover=@Tip( "Place the slots this level's blocks spawn in" )><div class="ticon">grid_view</div><span>Block Spawn <span class="shortcut">(B)</span></span></button>
<button class="tool @ToolActive( LevelEditorStage.EditTool.Player )" onclick=@Click( () => SetTool( LevelEditorStage.EditTool.Player ) )
onmouseover=@Tip( "Place player starts — a run picks one of them" )><div class="ticon">person</div><span>Player Spawn <span class="shortcut">(P)</span></span></button>
<button class="tool @ToolActive( LevelEditorStage.EditTool.Obstacle )" onclick=@Click( () => SetTool( LevelEditorStage.EditTool.Obstacle ) )
onmouseover=@Tip( "Drag out a solid interior wall" )><div class="ticon">crop_square</div><span>Obstacle <span class="shortcut">(Q)</span></span></button>
<button class="tool @ToolActive( LevelEditorStage.EditTool.Coin )" onclick=@Click( () => SetTool( LevelEditorStage.EditTool.Coin ) )
onmouseover=@Tip( "Place coin pickups (each is worth score)" )><div class="ticon">paid</div><span>Coin <span class="shortcut">(O)</span></span></button>
<button class="tool @ToolActive( LevelEditorStage.EditTool.Spike )" onclick=@Click( () => SetTool( LevelEditorStage.EditTool.Spike ) )
onmouseover=@Tip( "Click a wall or obstacle face to spike it" )><div class="ticon">change_history</div><span>Spikes <span class="shortcut">(K)</span></span></button>
@if ( Stage.Level.AlternatePlayfieldEnabled )
{
<button class="tool @ToolActive( LevelEditorStage.EditTool.AlternatePattern )" onclick=@Click( () => SetTool( LevelEditorStage.EditTool.AlternatePattern ) )
onmouseover=@Tip( "Drag out regions that use the alternate pattern" )><div class="ticon">texture</div><span>Alt Pattern</span></button>
}
</div>
<div class="row toggles">
<button class="toggle @On( Stage.MirrorX )" onclick=@Click( ToggleMirrorX )
onmouseover=@Tip( "Mirror placing/editing left-right across the arena's vertical centre line (obstacles, spawns, coins, spikes)" )><span>Mirror X <span class="shortcut">(M)</span></span></button>
<button class="toggle @On( Stage.MirrorY )" onclick=@Click( ToggleMirrorY )
onmouseover=@Tip( "Mirror placing/editing up-down across the arena's horizontal centre line (obstacles, spawns, coins, spikes)" )><span>Mirror Y <span class="shortcut">(Shift+M)</span></span></button>
</div>
<div class="row">
<button class="danger" onclick=@Click( () => Stage.DeleteSelected() )
onmouseover=@Tip( "Remove everything currently selected (Del)" )>Delete Selected</button>
</div>
<label class="sel">@SelectionText()</label>
@if ( !string.IsNullOrEmpty( SpikeSizeWarning() ) )
{
<label class="error">@SpikeSizeWarning()</label>
}
@if ( Stage?.Level.StartsComplete() == true )
{
<label class="error" onmouseover=@Tip( "Every block spawns already at max phase, so the run is won the instant it starts" )>All blocks start maxed: instant win</label>
}
</div>
<div class="section">
<div class="row toggles">
<button class="toggle @On( EditorPrefs.Current.ShowGrid )" onclick=@Click( ToggleGrid )
onmouseover=@Tip( "Show a snapping grid over the arena" )>Show Grid</button>
<button class="grid-reset" onclick=@Click( ResetGrid )
onmouseover=@Tip( "Reset grid size, opacity and origin to defaults" )><div class="micon">refresh</div></button>
<button class="toggle @On( Stage.SnapActive ) @(Stage.SnapInvertHeld ? "disabled" : "")" onclick=@Click( ToggleSnap )
onmouseover=@Tip( "Snap dragged geometry to nearby edges and visible grid lines; hold Alt to invert" )><span>Snap <span class="shortcut">(Alt)</span></span></button>
</div>
@if ( EditorPrefs.Current.ShowGrid )
{
<div class="row grid-row">
<label class="glabel">Size (@GridSizeStr())</label>
<div class="slider-wrap">
<PixelSlider Compact=@(true) [email protected] Min=@(2f) Max=@(120f) Step=@(1f) OnValueChanged=@((float v) => SetGridSize( v ))></PixelSlider>
</div>
</div>
<div class="row grid-row">
<label class="glabel">Opacity (@GridOpacityStr())</label>
<div class="slider-wrap">
<PixelSlider Compact=@(true) [email protected] Min=@(0f) Max=@(100f) Step=@(1f) OnValueChanged=@((float v) => SetGridOpacity( v ))></PixelSlider>
</div>
</div>
<div class="row toggles">
<button class="toggle @On( EditorPrefs.Current.GridSmart )" onclick=@Click( ToggleGridSmart )
onmouseover=@Tip( "Mirror lines inward from both walls, so wall-flush geometry lands on a line" )>Smart Grid</button>
</div>
@if ( !EditorPrefs.Current.GridSmart )
{
<div class="row toggles">
<button class="toggle @On( EditorPrefs.Current.GridCenterCell )" onclick=@Click( ToggleGridCenterCell )
onmouseover=@Tip( "Put the arena centre inside a cell instead of on a line crossing" )>Center on Cell</button>
</div>
@if ( !EditorPrefs.Current.GridCenterCell )
{
<div class="row toggles">
<button class="toggle @On( EditorPrefs.Current.GridBottomLeftOrigin )" onclick=@Click( ToggleGridBottomLeftOrigin )
onmouseover=@Tip( "Start lines at the bottom-left corner instead of the arena centre" )>Start at Bottom Left</button>
</div>
}
<div class="row grid-row">
<label class="glabel">Offset X (@GridOffsetStr( GridOffsetValue( true ) ))</label>
<div class="slider-wrap">
<PixelSlider Compact=@(true) Value=@GridOffsetValue( true ) Min=@(-GridOffsetLimit()) Max=@GridOffsetLimit() Step=@(1f) OnValueChanged=@((float v) => SetGridOffset( true, v ))></PixelSlider>
</div>
</div>
<div class="row grid-row">
<label class="glabel">Offset Y (@GridOffsetStr( GridOffsetValue( false ) ))</label>
<div class="slider-wrap">
<PixelSlider Compact=@(true) Value=@GridOffsetValue( false ) Min=@(-GridOffsetLimit()) Max=@GridOffsetLimit() Step=@(1f) OnValueChanged=@((float v) => SetGridOffset( false, v ))></PixelSlider>
</div>
</div>
}
}
</div>
</div>
<div class="panel inspector">
<div class="row top-save-row">
<button class="save @(LevelMetaMissing ? "disabled" : "")" @ref="SaveProjectButton"
onclick=@Click( SaveLevel, SfxType.MenuStart )
onmouseover=@Tip( Game.IsEditor
? "Write this level to its project JSON and reload it live"
: "Save this level as a JSON file in your local data folder" )>Save Level</button>
@* Workshop publish: saves, then opens the engine's Steam publish window directly — that
window is itself the confirm step (nothing uploads until the player submits there).
Available in the DEV editor too for pipeline testing — those items are stamped with
the editor's local.* ident, so they never appear in the published game's browser. *@
<button class="publish @(LevelMetaMissing ? "disabled" : "")"
onclick=@Click( PublishLevel, SfxType.MenuStart )
onmouseover=@Tip( Game.IsEditor
? "Publish this level to the Steam Workshop (dev-ident item, invisible to the published game)"
: "Publish this level to the Steam Workshop so other players can play it" )>Publish</button>
</div>
@if ( !string.IsNullOrEmpty( Stage.LastError ) )
{
<label class="error">@Stage.LastError</label>
}
else if ( !string.IsNullOrEmpty( Stage.PublishStatus ) )
{
<label class="publish-status">@Stage.PublishStatus</label>
}
<div class="section">
<div class="level-fields">
@* The id turns red while it contains interior spaces — level_save refuses those (ids
travel as single console tokens through the map-edit ConCmds). *@
<div class="field"><label>Id</label><EditorTextEntry class="@(IdHasWhitespace ? "invalid" : "")" [email protected] OnTextEdited=@((string v) => OnId( v )) @ref="IdEntry"></EditorTextEntry></div>
<div class="field"><label>Name</label><EditorTextEntry [email protected] OnTextEdited=@((string v) => OnName( v ))></EditorTextEntry></div>
</div>
<div class="level-fields">
<div class="field"><label>Character</label><EditorTextEntry class="@(ForcedCharacterWorks ? "character-match" : "")" Value=@ForcedCharacterStr() OnTextEdited=@((string v) => OnForcedCharacter( v )) @ref="CharacterEntry"></EditorTextEntry></div>
<div class="field"><label>Music</label><EditorTextEntry class="@MusicClass()" [email protected] [email protected] OnTextEdited=@((string v) => OnMusic( v )) @ref="MusicEntry"></EditorTextEntry><button class="music-rand" onclick=@Click( RandomizeMusic )
onmouseover=@Tip( Game.IsEditor ? "Random song no other level uses" : "Random song" )><div class="micon">casino</div></button></div>
</div>
<div class="row grid-row">
<label class="glabel">Music Vol (@MusicVolumeStr())</label>
<div class="slider-wrap">
<PixelSlider Compact=@(true) [email protected] Min=@(0f) Max=@(1.5f) Step=@(0.05f) OnValueChanged=@((float v) => SetMusicVolume( v ))></PixelSlider>
</div>
</div>
<div class="level-fields">
@* Test flag is dev-only: it routes the save into Assets/levels/test, which doesn't
exist for a standalone player's local files. *@
@if ( Game.IsEditor )
{
<div class="field"><label>Test Level</label><PixelCheckbox [email protected] OnValueChanged=@((bool v) => { Stage.Level.IsTest = v; })></PixelCheckbox></div>
}
<div class="delete-level-actions">
@if ( _deleteConfirming )
{
<button class="danger" onclick=@Click( ConfirmDeleteLevel, SfxType.MenuStart )
onmouseover=@Tip( "Delete the project JSON for good" )>Confirm Delete</button>
<button onclick=@Click( CancelDeleteLevel )
onmouseover=@Tip( "Keep the level" )>Cancel</button>
}
else
{
<button class="danger @(LevelDeleteUnavailable ? "disabled" : "")" @ref="DeleteLevelButton"
onclick=@Click( BeginDeleteLevel )
onmouseover=@Tip( Game.IsEditor
? "Delete this level's project JSON file (asks to confirm)"
: "Delete this level's local JSON file (asks to confirm)" )>Delete Level</button>
}
</div>
</div>
</div>
<div class="section">
<div class="import-row">
<button onclick=@Click( DoLoad, SfxType.MenuStart )
onmouseover=@Tip( "Open the project level whose id is typed below" )>Load Level ID</button>
<EditorTextEntry Value=@_loadId OnTextEdited=@((string v) => { _loadId = v; })></EditorTextEntry>
<button onclick=@Click( DoImport, SfxType.MenuStart )
onmouseover=@Tip( "Replace this level with the level JSON pasted below" )>Import JSON</button>
<EditorTextEntry class="multiline" Multiline=@true Value=@_pasteText OnTextEdited=@((string v) => { _pasteText = v; })></EditorTextEntry>
<button class="copy-json" onclick=@Click( () => Stage.CopyJson(), SfxType.MenuStart )
onmouseover=@Tip( "Copy this level's JSON to the clipboard" )><div class="micon">content_copy</div></button>
</div>
</div>
@* Daily-challenge obstacle templates (Assets/daily-templates/*.json), in their own tinted
section: SAVE stores the current LAYOUT under the typed name; LOAD replaces the layout
with the named template (blocks and cosmetics are kept, so templates can seed map levels
too); DEL (confirmed) removes the named template's project JSON. Dev-only: templates are
authored content, so a standalone build hides the whole section. *@
@if ( Game.IsEditor )
{
<div class="section template-section">
<div class="import-row">
@if ( _templateDeleteConfirming )
{
<button class="danger" onclick=@Click( ConfirmDeleteTemplate, SfxType.MenuStart )
onmouseover=@Tip( "Delete the named template's JSON for good" )>Confirm Delete</button>
<button onclick=@Click( CancelDeleteTemplate )
onmouseover=@Tip( "Keep the template" )>Cancel</button>
}
else
{
<button class="save @(TemplateNameMissing ? "disabled" : "")" @ref="TemplateSaveButton"
onclick=@Click( DoSaveTemplate, SfxType.MenuStart )
onmouseover=@Tip( "Save this layout as a daily obstacle template, under the name typed below" )>Save</button>
<button class="@(TemplateNameMissing ? "disabled" : "")" @ref="TemplateLoadButton"
onclick=@Click( DoLoadTemplate, SfxType.MenuStart )
onmouseover=@Tip( "Replace the layout with the named template; blocks and colours are kept" )>Load</button>
<button class="danger @(TemplateNameMissing ? "disabled" : "")" @ref="TemplateDelButton"
onclick=@Click( BeginDeleteTemplate )
onmouseover=@Tip( "Delete the named daily template (asks to confirm)" )>Del</button>
<button class="@On( Stage.Level.DailyTemplateMirrorX ) @(TemplateNameMissing ? "disabled" : "")" @ref="TemplateMirrorButton"
onclick=@Click( () => { Stage.Level.DailyTemplateMirrorX = !Stage.Level.DailyTemplateMirrorX; } )
onmouseover=@Tip( "Let a day also spawn this template mirrored left-to-right" )>Mirror</button>
<button class="@On( Stage.Level.DailyTemplateAuthoredSpikes ) @(TemplateNameMissing ? "disabled" : "")" @ref="TemplateSpikesButton"
onclick=@Click( () => { Stage.Level.DailyTemplateAuthoredSpikes = !Stage.Level.DailyTemplateAuthoredSpikes; } )
onmouseover=@Tip( "Spike exactly as authored every day; off = each spiked surface rolls a 10% chance per day" )>Spikes</button>
<EditorTextEntry class="tweight" [email protected]( System.Globalization.CultureInfo.InvariantCulture )
OnTextEdited=@((string v) => OnTemplateWeight( v ))
onmouseover=@Tip( "Relative pick weight among templates (1 = normal, 0 = never)" )></EditorTextEntry>
}
<EditorTextEntry class="@TemplateNameClass()" Placeholder="obstacle template" [email protected]
OnTextEdited=@((string v) => OnTemplateName( v )) @ref="TemplateNameEntry"
onmouseover=@Tip( "Name of the daily template to save as, load or delete" )></EditorTextEntry>
</div>
</div>
}
<div class="section">
<div class="row toggles">
<button class="toggle @On( !Stage.Level.UsePool )" onclick=@Click( () => SetMode( false ) )
onmouseover=@Tip( "Spawn exactly the blocks listed below" )>Exact List</button>
<button class="toggle @On( Stage.Level.UsePool )" onclick=@Click( () => SetMode( true ) )
onmouseover=@Tip( "Draw Count blocks at random from a pool of types each run" )>Pool + Count</button>
</div>
<div class="row toggles">
<button class="toggle @On( Stage.Level.ShuffleTypes )" onclick=@Click( ToggleShuffle )
onmouseover=@Tip( "Scatter the block types across spawn slots; off = slot order" )>Shuffle</button>
</div>
@if ( Stage.Level.Count > Stage.Level.FreeSpawnCount )
{
<label class="error">@Stage.Level.Count blocks but only @(Stage.Level.FreeSpawnCount) unpinned spawns</label>
}
@if ( !string.IsNullOrEmpty( Stage.Level.PoolValidationError ) )
{
<label class="error">@Stage.Level.PoolValidationError</label>
}
@if ( Stage.Level.UsePool )
{
<div class="row count">
<label>Count</label>
<button onclick=@Click( () => IncCount( -1 ) )
onmouseover=@Tip( "One fewer random block" )>-</button>
<label class="num">@Stage.Level.BlockCount</label>
<button onclick=@Click( () => IncCount( 1 ) )
onmouseover=@Tip( "One more random block" )>+</button>
</div>
<div class="row count">
<label>Start Phase</label>
<button onclick=@Click( () => IncPoolPhase( -1 ) )
onmouseover=@Tip( "Every drawn block starts one phase lower" )>-</button>
<label class="num">P@(Stage.Level.PoolStartPhase)</label>
<button onclick=@Click( () => IncPoolPhase( 1 ) )
onmouseover=@Tip( "Every drawn block starts one phase higher (worth more score)" )>+</button>
</div>
<div class="chips">
@for ( int i = 0; i < Stage.Level.Pool.Count; i++ )
{
var idx = i;
var poolType = Stage.Level.Pool[idx];
<button class="chip bicon" style="@BlockIconStyle( poolType )" onclick=@Click( () => RemovePool( idx ) )
onmouseover=@Tip( () => PoolChipTip( idx ) )><span class="x">✕</span></button>
}
</div>
}
else
{
<div class="blocklist">
@for ( int i = 0; i < Stage.Level.Blocks.Count; i++ )
{
var idx = i;
var blk = Stage.Level.Blocks[idx];
<div class="blockrow">
<div class="btype-icon" style="@BlockIconStyle( blk.Type, blk.Phase )"
onmouseover=@Tip( () => BlockRowTip( idx ) )></div>
<div class="phase">
<button onclick=@Click( () => CyclePhase( idx, -1 ) )
onmouseover=@Tip( "Start this block one phase lower" )>-</button>
<label class="num">P@(blk.Phase)</label>
<button onclick=@Click( () => CyclePhase( idx, 1 ) )
onmouseover=@Tip( "Start this block one phase higher (worth more score)" )>+</button>
</div>
@if ( blk.Phase < Block.NUM_PHASES - 1 )
{
<div class="sides">
<button class="sbtn @SideOn( idx, Direction.Left )" onclick=@Click( () => ToggleBlockSide( idx, Direction.Left ) )
onmouseover=@Tip( "Start with the left face already pressed" )>L</button>
<button class="sbtn @SideOn( idx, Direction.Right )" onclick=@Click( () => ToggleBlockSide( idx, Direction.Right ) )
onmouseover=@Tip( "Start with the right face already pressed" )>R</button>
<button class="sbtn @SideOn( idx, Direction.Up )" onclick=@Click( () => ToggleBlockSide( idx, Direction.Up ) )
onmouseover=@Tip( "Start with the top face already pressed" )>U</button>
<button class="sbtn @SideOn( idx, Direction.Down )" onclick=@Click( () => ToggleBlockSide( idx, Direction.Down ) )
onmouseover=@Tip( "Start with the bottom face already pressed" )>D</button>
</div>
}
<button class="rm" onclick=@Click( () => RemoveBlock( idx ) )
onmouseover=@Tip( "Remove this block from the level" )>✕</button>
</div>
}
</div>
}
<div class="chips add">
@foreach ( var t in System.Enum.GetValues<BlockType>() )
{
var bt = t;
<button class="chip add bicon" style="@BlockIconStyle( bt )" onclick=@Click( () => AddBlock( bt ) )
onmouseover=@Tip( () => AddBlockTip( bt ) )></button>
}
</div>
</div>
@if ( Stage.SelectedKind == LevelEditorStage.SelKind.Spawn )
{
<div class="section">
<div class="row grid-row">
<label class="glabel">Start Direction</label>
<div class="sides">
<button class="sbtn @SpawnDirectionOn( Direction.None )" onclick=@Click( () => SetSpawnDirection( Direction.None ) )
onmouseover=@Tip( "The block here picks a random first direction" )>Random</button>
<button class="sbtn @SpawnDirectionOn( Direction.Left )" onclick=@Click( () => SetSpawnDirection( Direction.Left ) )
onmouseover=@Tip( "The block here always sets off left" )>L</button>
<button class="sbtn @SpawnDirectionOn( Direction.Right )" onclick=@Click( () => SetSpawnDirection( Direction.Right ) )
onmouseover=@Tip( "The block here always sets off right" )>R</button>
<button class="sbtn @SpawnDirectionOn( Direction.Up )" onclick=@Click( () => SetSpawnDirection( Direction.Up ) )
onmouseover=@Tip( "The block here always sets off upward" )>U</button>
<button class="sbtn @SpawnDirectionOn( Direction.Down )" onclick=@Click( () => SetSpawnDirection( Direction.Down ) )
onmouseover=@Tip( "The block here always sets off downward" )>D</button>
</div>
</div>
<div class="row grid-row">
<label class="glabel">Turning</label>
<div class="sides">
<button class="sbtn @SpawnTurnModeOn( TurnMode.Free )" onclick=@Click( () => SetSpawnTurnMode( TurnMode.Free ) )
onmouseover=@Tip( "The block here picks directions freely after stopping (may reverse)" )>Free</button>
<button class="sbtn @SpawnTurnModeOn( TurnMode.Always )" onclick=@Click( () => SetSpawnTurnMode( TurnMode.Always ) )
onmouseover=@Tip( "After stopping, take an open perpendicular direction instead of reversing" )>Turn</button>
<button class="sbtn @SpawnTurnModeOn( TurnMode.Clockwise )" onclick=@Click( () => SetSpawnTurnMode( TurnMode.Clockwise ) )
onmouseover=@Tip( "Keep heading the same way when blocked; turn clockwise only at a walled-off corner — cycles a loop clockwise" )>CW</button>
<button class="sbtn @SpawnTurnModeOn( TurnMode.CounterClockwise )" onclick=@Click( () => SetSpawnTurnMode( TurnMode.CounterClockwise ) )
onmouseover=@Tip( "Keep heading the same way when blocked; turn counter-clockwise only at a walled-off corner — cycles a loop counter-clockwise" )>CCW</button>
</div>
</div>
@{ var pin = Stage.Level.SpawnPin( Stage.SelectedIndex ); }
@if ( pin is not null )
{
<div class="blocklist">
<div class="blockrow">
<div class="btype-icon" style="@BlockIconStyle( pin.Type, pin.Phase )"
onmouseover=@Tip( SpawnPinTip )></div>
<div class="phase">
<button onclick=@Click( () => CyclePinPhase( -1 ) )
onmouseover=@Tip( "Start the pinned block one phase lower" )>-</button>
<label class="num">P@(pin.Phase)</label>
<button onclick=@Click( () => CyclePinPhase( 1 ) )
onmouseover=@Tip( "Start the pinned block one phase higher (worth more score)" )>+</button>
</div>
@if ( pin.Phase < Block.NUM_PHASES - 1 )
{
<div class="sides">
<button class="sbtn @PinSideOn( Direction.Left )" onclick=@Click( () => TogglePinSide( Direction.Left ) )
onmouseover=@Tip( "Start with the left face already pressed" )>L</button>
<button class="sbtn @PinSideOn( Direction.Right )" onclick=@Click( () => TogglePinSide( Direction.Right ) )
onmouseover=@Tip( "Start with the right face already pressed" )>R</button>
<button class="sbtn @PinSideOn( Direction.Up )" onclick=@Click( () => TogglePinSide( Direction.Up ) )
onmouseover=@Tip( "Start with the top face already pressed" )>U</button>
<button class="sbtn @PinSideOn( Direction.Down )" onclick=@Click( () => TogglePinSide( Direction.Down ) )
onmouseover=@Tip( "Start with the bottom face already pressed" )>D</button>
</div>
}
<button class="rm" onclick=@Click( ClearSpawnPin )
onmouseover=@Tip( "Unpin — this spawn goes back to taking a random block" )>✕</button>
</div>
</div>
}
<div class="row toggles">
<button class="toggle @On( _pinArming )" onclick=@Click( TogglePinArm )
onmouseover=@Tip( () => _pinArming ? "Cancel pinning" : "Pin a block to this spawn: click SET, then a block type" )>SET</button>
</div>
<label class="hint">@(_pinArming ? "Now click a block type above to pin it to this spawn." : "A pinned block ALWAYS spawns here — extra, on top of the block count.")</label>
@if ( Stage.Level.SpawnBlockedInEveryDirection( Stage.SelectedIndex ) )
{
<div class="row grid-row ability-row">
<label class="glabel">Ability Interval (s)</label>
<EditorTextEntry Value=@SpawnAbilityIntervalStr() OnTextEdited=@((string v) => SetSpawnAbilityInterval( v ))></EditorTextEntry>
</div>
<div class="row grid-row ability-row">
<label class="glabel">Random +/- (s)</label>
<EditorTextEntry Value=@SpawnAbilityRandomnessStr() OnTextEdited=@((string v) => SetSpawnAbilityRandomness( v ))></EditorTextEntry>
</div>
<div class="row grid-row ability-row">
<label class="glabel">Start Offset (s)</label>
<EditorTextEntry Value=@SpawnAbilityStartOffsetStr() OnTextEdited=@((string v) => SetSpawnAbilityStartOffset( v ))></EditorTextEntry>
</div>
<div class="row grid-row ability-row" onmouseover=@Tip( "Wind/Magnet only: lane loops Interval on / this long off (0 = permanently on)" )>
<label class="glabel">Eyes Closed (s)</label>
<EditorTextEntry Value=@SpawnAbilityClosedTimeStr() OnTextEdited=@((string v) => SetSpawnAbilityClosedTime( v ))></EditorTextEntry>
</div>
}
</div>
}
@if ( Stage.SelectionIsObstacles )
{
<div class="section">
<div class="row toggles">
<button class="toggle @On( Stage.SelectedObstacleBlocksVision )" onclick=@Click( () => Stage.ToggleSelectedObstacleVision() )
onmouseover=@Tip( "Casts a shadow from the player — hides whatever is behind it" )><span>Blocks Vision <span class="shortcut">(V)</span></span></button>
<button class="toggle @On( Stage.SelectedObstacleIsFence )" onclick=@Click( () => Stage.ToggleSelectedObstacleFence() )
onmouseover=@Tip( "Solid only to blocks — the player, lasers and fields pass through" )><span>Fence <span class="shortcut">(F)</span></span></button>
<button class="toggle @On( Stage.SelectedObstacleIsGlass )" onclick=@Click( () => Stage.ToggleSelectedObstacleGlass() )
onmouseover=@Tip( "Solid only to the player — blocks and fields pass through" )><span>Glass <span class="shortcut">(G)</span></span></button>
</div>
</div>
}
<div class="section">
@* <div class="row refreshrow">
<button class="mini" onclick=@Click( () => Stage.RefreshEnvironmentColors() )><div class="micon">refresh</div></button>
</div> *@
<div class="field"><label>Wall</label><EditorTextEntry Value=@ColStr( Stage.Level.WallColor ) Placeholder="58, 64, 76" OnTextEdited=@((string v) => SetCol( 0, v ))></EditorTextEntry></div>
<div class="field"><label>OOB</label><EditorTextEntry Value=@ColStr( Stage.Level.OutOfBoundsColor ) Placeholder="0, 32, 40" OnTextEdited=@((string v) => SetCol( 1, v ))></EditorTextEntry></div>
@if ( Stage.Level.AlternatePlayfieldEnabled )
{
<div class="level-fields checker-fields">
<div class="field"><label>Checker 1</label><EditorTextEntry Value=@ColStr( Stage.Level.CheckerboardColor ) Placeholder="0, 189, 196" OnTextEdited=@((string v) => SetCol( 2, v ))></EditorTextEntry></div>
<div class="field"><label>Checker 2</label><EditorTextEntry Value=@ColStr( Stage.Level.CheckerboardSecondColor ) Placeholder=@DerivedCheckerColStr( false ) OnTextEdited=@((string v) => SetCol( 7, v ))></EditorTextEntry></div>
</div>
<div class="level-fields checker-fields">
<div class="field"><label>Alt Checker 1</label><EditorTextEntry Value=@ColStr( Stage.Level.AlternateCheckerboardColor ) Placeholder="0, 140, 148" OnTextEdited=@((string v) => SetCol( 4, v ))></EditorTextEntry></div>
<div class="field"><label>Alt Checker 2</label><EditorTextEntry Value=@ColStr( Stage.Level.AlternateCheckerboardSecondColor ) Placeholder=@DerivedCheckerColStr( true ) OnTextEdited=@((string v) => SetCol( 8, v ))></EditorTextEntry></div>
</div>
}
else
{
<div class="level-fields checker-fields">
<div class="field"><label>Checker 1</label><EditorTextEntry Value=@ColStr( Stage.Level.CheckerboardColor ) Placeholder="0, 189, 196" OnTextEdited=@((string v) => SetCol( 2, v ))></EditorTextEntry></div>
<div class="field"><label>Checker 2</label><EditorTextEntry Value=@ColStr( Stage.Level.CheckerboardSecondColor ) Placeholder=@DerivedCheckerColStr( false ) OnTextEdited=@((string v) => SetCol( 7, v ))></EditorTextEntry></div>
</div>
}
@if ( Stage.Level.FenceObstacles.Count > 0 )
{
<div class="field"><label>Fence</label><EditorTextEntry Value=@ColStr( Stage.Level.FenceColor ) Placeholder="166, 120, 52" OnTextEdited=@((string v) => SetCol( 5, v ))></EditorTextEntry></div>
}
@if ( Stage.Level.GlassObstacles.Count > 0 )
{
<div class="field"><label>Glass</label><EditorTextEntry Value=@ColStr( Stage.Level.GlassColor ) Placeholder="200, 148, 236" OnTextEdited=@((string v) => SetCol( 6, v ))></EditorTextEntry></div>
}
<div class="row count environment-choice">
<label>Pattern</label>
<button onclick=@Click( () => CyclePattern( -1, false ) )
onmouseover=@Tip( "Previous background pattern" )>‹</button>
<label class="num">@Stage.Level.PlayfieldPattern</label>
<button onclick=@Click( () => CyclePattern( 1, false ) )
onmouseover=@Tip( "Next background pattern" )>›</button>
</div>
<div class="row grid-row">
<label class="glabel">Cell Scale (@Stage.Level.PlayfieldCellScale)</label>
<div class="slider-wrap">
<PixelSlider Compact=@(true) [email protected] Min=@(1f) Max=@(8f) Step=@(1f) OnValueChanged=@((float v) => SetCellScale( v, false ))></PixelSlider>
</div>
</div>
@if ( Stage.Level.PlayfieldPattern == PlayfieldPattern.Custom )
{
<div class="custom-pattern-editor">
<div class="row grid-row pattern-dimension">
<label class="glabel">Width (@PatternWidth( false ))</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) Value=@PatternWidth( false ) Min=@(1f) Max=@(8f) Step=@(1f) OnValueChanged=@((float v) => SetPatternWidth( v, false ))></PixelSlider></div>
</div>
<div class="row grid-row pattern-dimension">
<label class="glabel">Height (@PatternHeight( false ))</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) Value=@PatternHeight( false ) Min=@(1f) Max=@(8f) Step=@(1f) OnValueChanged=@((float v) => SetPatternHeight( v, false ))></PixelSlider></div>
</div>
<CustomPatternGrid Rows=@PatternRows( false ) GridWidth=@PatternWidth( false ) GridHeight=@PatternHeight( false ) OnCellPaint=@PaintPrimaryPatternCell></CustomPatternGrid>
</div>
}
@if ( Stage.Level.AlternatePlayfieldEnabled )
{
<div class="row count environment-choice alternate-choice">
<label>Alt Pattern</label>
<button onclick=@Click( () => CyclePattern( -1, true ) )
onmouseover=@Tip( "Previous pattern for the painted regions" )>‹</button>
<label class="num">@Stage.Level.AlternatePlayfieldPattern</label>
<button onclick=@Click( () => CyclePattern( 1, true ) )
onmouseover=@Tip( "Next pattern for the painted regions" )>›</button>
</div>
<div class="row grid-row alternate-choice">
<label class="glabel">Alt Cell Scale (@Stage.Level.AlternatePlayfieldCellScale)</label>
<div class="slider-wrap">
<PixelSlider Compact=@(true) [email protected] Min=@(1f) Max=@(8f) Step=@(1f) OnValueChanged=@((float v) => SetCellScale( v, true ))></PixelSlider>
</div>
</div>
@if ( Stage.Level.AlternatePlayfieldPattern == PlayfieldPattern.Custom )
{
<div class="custom-pattern-editor alternate-choice">
<div class="row grid-row pattern-dimension">
<label class="glabel">Alt Width (@PatternWidth( true ))</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) Value=@PatternWidth( true ) Min=@(1f) Max=@(8f) Step=@(1f) OnValueChanged=@((float v) => SetPatternWidth( v, true ))></PixelSlider></div>
</div>
<div class="row grid-row pattern-dimension">
<label class="glabel">Alt Height (@PatternHeight( true ))</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) Value=@PatternHeight( true ) Min=@(1f) Max=@(8f) Step=@(1f) OnValueChanged=@((float v) => SetPatternHeight( v, true ))></PixelSlider></div>
</div>
<CustomPatternGrid Rows=@PatternRows( true ) GridWidth=@PatternWidth( true ) GridHeight=@PatternHeight( true ) OnCellPaint=@PaintAlternatePatternCell></CustomPatternGrid>
</div>
}
}
<div class="row environment-randomizers">
<button class="alt-pattern-toggle @On( Stage.Level.AlternatePlayfieldEnabled )" onclick=@Click( ToggleAlternatePlayfield )
onmouseover=@Tip( () => Stage.Level.AlternatePlayfieldEnabled
? "Turn off the second background pattern"
: "Add a second background pattern you can paint regions with" )><div class="micon">texture</div></button>
<button onclick=@Click( ResetEnvironmentToClassic )
onmouseover=@Tip( "Back to the classic colours, pattern and background" )><div class="micon">refresh</div></button>
<button onclick=@Click( () => AdjustEnvironmentBrightness( 0.88f ) )
onmouseover=@Tip( "Darken every environment colour" )><div class="micon">keyboard_arrow_left</div></button>
<button onclick=@Click( () => AdjustEnvironmentBrightness( 1.14f ) )
onmouseover=@Tip( "Brighten every environment colour" )><div class="micon">keyboard_arrow_right</div></button>
<button onclick=@Click( RandomizeEnvironmentColors )
onmouseover=@Tip( "Roll a new environment palette" )><div class="micon">palette</div></button>
<button onclick=@Click( () => AdjustEnvironmentSaturation( 0.82f ) )
onmouseover=@Tip( "Drain colour out of the environment" )><div class="micon">keyboard_arrow_down</div></button>
<button onclick=@Click( () => AdjustEnvironmentSaturation( 1.22f ) )
onmouseover=@Tip( "Push more colour into the environment" )><div class="micon">keyboard_arrow_up</div></button>
<button onclick=@Click( RandomizeEnvironmentPattern )
onmouseover=@Tip( "Roll a new background pattern" )><div class="micon">shuffle</div></button>
</div>
<div class="row toggles">
<button class="toggle @On( !BgUse3() )" onclick=@Click( () => SetBgMode( false ) )
onmouseover=@Tip( "One background colour; the three shades derive from it" )>Bg: Base</button>
<button class="toggle @On( BgUse3() )" onclick=@Click( () => SetBgMode( true ) )
onmouseover=@Tip( "Set all three background-block shades by hand" )>Bg: 3 Colors</button>
</div>
@if ( BgUse3() )
{
<div class="field"><label>Bg 1 (front)</label><EditorTextEntry Value=@Bg3Str( 0 ) Placeholder="0, 202, 206" OnTextEdited=@((string v) => SetBg3( 0, v ))></EditorTextEntry></div>
<div class="field"><label>Bg 2</label><EditorTextEntry Value=@Bg3Str( 1 ) Placeholder="0, 192, 198" OnTextEdited=@((string v) => SetBg3( 1, v ))></EditorTextEntry></div>
<div class="field"><label>Bg 3 (back)</label><EditorTextEntry Value=@Bg3Str( 2 ) Placeholder="0, 182, 187" OnTextEdited=@((string v) => SetBg3( 2, v ))></EditorTextEntry></div>
}
else
{
<div class="field"><label>Bg Block</label><EditorTextEntry Value=@ColStr( Stage.Level.BackgroundBlockColor ) Placeholder="0, 202, 206" OnTextEdited=@((string v) => SetCol( 3, v ))></EditorTextEntry></div>
}
<div class="row grid-row">
<label class="glabel">Bg Size (@BgScaleStr())</label>
<div class="slider-wrap">
<PixelSlider Compact=@(true) [email protected] Min=@(0.25f) Max=@(1.5f) Step=@(0.05f) OnValueChanged=@((float v) => SetBgScale( v ))></PixelSlider>
</div>
</div>
<div class="row grid-row">
<label class="glabel">Density (@BgDensityStr())</label>
<div class="slider-wrap">
<PixelSlider Compact=@(true) [email protected] Min=@(0.25f) Max=@(2f) Step=@(0.05f) OnValueChanged=@((float v) => SetBgDensity( v ))></PixelSlider>
</div>
</div>
<div class="row grid-row">
<label class="glabel">Opacity (@BgOpacityStr())</label>
<div class="slider-wrap">
<PixelSlider Compact=@(true) [email protected] Min=@(0f) Max=@(1f) Step=@(0.05f) OnValueChanged=@((float v) => SetBgOpacity( v ))></PixelSlider>
</div>
</div>
<div class="row grid-row">
<label class="glabel">Drift (@BgSpeedStr())</label>
<div class="slider-wrap">
<PixelSlider Compact=@(true) [email protected] Min=@(0f) Max=@(3f) Step=@(0.1f) OnValueChanged=@((float v) => SetBgSpeed( v ))></PixelSlider>
</div>
</div>
<div class="row count environment-choice">
<label>Drift Bias</label>
<button onclick=@Click( () => CycleBgBias( -1 ) )
onmouseover=@Tip( "Previous direction for the background blocks to drift" )>‹</button>
<label class="num">@Stage.Level.BackgroundDriftBias</label>
<button onclick=@Click( () => CycleBgBias( 1 ) )
onmouseover=@Tip( "Next direction for the background blocks to drift" )>›</button>
</div>
@* Background particle ambience (falling squares — rain/snow/embers). The whole settings
cluster stays hidden until the feature is enabled for this level. *@
<div class="row toggles">
<button class="toggle @On( Stage.Level.BackgroundParticlesEnabled )" onclick=@Click( ToggleBackgroundParticles )
onmouseover=@Tip( "Falling-square ambience — rain, snow or embers" )>Bg Particles</button>
</div>
@if ( Stage.Level.BackgroundParticlesEnabled )
{
<div class="row count environment-choice">
<label>Edge</label>
<button onclick=@Click( () => CycleParticleEdge( -1 ) )
onmouseover=@Tip( "Previous arena edge for particles to fall from" )>‹</button>
<label class="num">@ParticleEdgeLabel()</label>
<button onclick=@Click( () => CycleParticleEdge( 1 ) )
onmouseover=@Tip( "Next arena edge for particles to fall from" )>›</button>
</div>
<div class="field"><label>Particle</label><EditorTextEntry Value=@ColStr( Stage.Level.BackgroundParticleColor ) Placeholder="188, 216, 226" OnTextEdited=@((string v) => SetParticleCol( 0, v ))></EditorTextEntry></div>
<div class="row grid-row">
<label class="glabel">Opacity (@($"{Stage.Level.BackgroundParticleOpacity:0.00}"))</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) [email protected] Min=@(0.05f) Max=@(1f) Step=@(0.05f) OnValueChanged=@((float v) => SetParticleValue( ParticleSetting.Opacity, v ))></PixelSlider></div>
</div>
<div class="row grid-row">
<label class="glabel">Size Min (@Stage.Level.BackgroundParticleSizeMin)</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) [email protected] Min=@(1f) Max=@(8f) Step=@(1f) OnValueChanged=@((float v) => SetParticleValue( ParticleSetting.SizeMin, v ))></PixelSlider></div>
</div>
<div class="row grid-row">
<label class="glabel">Size Max (@Stage.Level.BackgroundParticleSizeMax)</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) [email protected] Min=@(1f) Max=@(8f) Step=@(1f) OnValueChanged=@((float v) => SetParticleValue( ParticleSetting.SizeMax, v ))></PixelSlider></div>
</div>
<div class="row grid-row">
<label class="glabel">Angle (@($"{Stage.Level.BackgroundParticleAngle:0}"))</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) [email protected] Min=@(-75f) Max=@(75f) Step=@(1f) OnValueChanged=@((float v) => SetParticleValue( ParticleSetting.Angle, v ))></PixelSlider></div>
</div>
<div class="row grid-row">
<label class="glabel">Angle Rng (@($"±{Stage.Level.BackgroundParticleAngleRange:0}"))</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) [email protected] Min=@(0f) Max=@(90f) Step=@(1f) OnValueChanged=@((float v) => SetParticleValue( ParticleSetting.AngleRange, v ))></PixelSlider></div>
</div>
<div class="row grid-row">
<label class="glabel">Speed Min (@($"{Stage.Level.BackgroundParticleSpeedMin:0}"))</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) [email protected] Min=@(5f) Max=@(300f) Step=@(5f) OnValueChanged=@((float v) => SetParticleValue( ParticleSetting.SpeedMin, v ))></PixelSlider></div>
</div>
<div class="row grid-row">
<label class="glabel">Speed Max (@($"{Stage.Level.BackgroundParticleSpeedMax:0}"))</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) [email protected] Min=@(5f) Max=@(300f) Step=@(5f) OnValueChanged=@((float v) => SetParticleValue( ParticleSetting.SpeedMax, v ))></PixelSlider></div>
</div>
<div class="row grid-row">
<label class="glabel">Gravity (@($"{Stage.Level.BackgroundParticleGravity:0}"))</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) [email protected] Min=@(-400f) Max=@(400f) Step=@(5f) OnValueChanged=@((float v) => SetParticleValue( ParticleSetting.Gravity, v ))></PixelSlider></div>
</div>
<div class="row grid-row">
<label class="glabel">Rate (@($"{Stage.Level.BackgroundParticleSpawnRate:0.0}/s"))</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) [email protected] Min=@(0f) Max=@(60f) Step=@(0.5f) OnValueChanged=@((float v) => SetParticleValue( ParticleSetting.Rate, v ))></PixelSlider></div>
</div>
<div class="row grid-row">
<label class="glabel">Trail (@(Stage.Level.BackgroundParticleTrailLength == 0 ? "off" : Stage.Level.BackgroundParticleTrailLength.ToString()))</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) [email protected] Min=@(0f) Max=@(8f) Step=@(1f) OnValueChanged=@((float v) => SetParticleValue( ParticleSetting.TrailLength, v ))></PixelSlider></div>
</div>
@if ( Stage.Level.BackgroundParticleTrailLength > 0 )
{
<div class="field"><label>Trail Color</label><EditorTextEntry Value=@ColStr( Stage.Level.BackgroundParticleTrailColor ) Placeholder="blank = particle color" OnTextEdited=@((string v) => SetParticleCol( 1, v ))></EditorTextEntry></div>
<div class="row toggles">
<button class="toggle @On( Stage.Level.BackgroundParticleTrailFade )" onclick=@Click( () => SetParticleTrailFade( true ) )
onmouseover=@Tip( "Trail fades out along its length" )>Trail: Fade</button>
<button class="toggle @On( !Stage.Level.BackgroundParticleTrailFade )" onclick=@Click( () => SetParticleTrailFade( false ) )
onmouseover=@Tip( "Trail stays one solid colour" )>Trail: Solid</button>
</div>
}
<div class="row toggles">
<button class="toggle @On( Stage.Level.BackgroundParticleImpactEnabled )" onclick=@Click( ToggleParticleImpact )
onmouseover=@Tip( "Spray a few specks where a particle hits something" )>Impact Spray</button>
</div>
@if ( Stage.Level.BackgroundParticleImpactEnabled )
{
<div class="field"><label>Impact Color</label><EditorTextEntry Value=@ColStr( Stage.Level.BackgroundParticleImpactColor ) Placeholder="blank = particle color" OnTextEdited=@((string v) => SetParticleCol( 2, v ))></EditorTextEntry></div>
<div class="row grid-row">
<label class="glabel">Imp Count Min (@Stage.Level.BackgroundParticleImpactCountMin)</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) [email protected] Min=@(0f) Max=@(10f) Step=@(1f) OnValueChanged=@((float v) => SetParticleValue( ParticleSetting.ImpactCountMin, v ))></PixelSlider></div>
</div>
<div class="row grid-row">
<label class="glabel">Imp Count Max (@Stage.Level.BackgroundParticleImpactCountMax)</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) [email protected] Min=@(0f) Max=@(10f) Step=@(1f) OnValueChanged=@((float v) => SetParticleValue( ParticleSetting.ImpactCountMax, v ))></PixelSlider></div>
</div>
<div class="row grid-row">
<label class="glabel">Imp Speed Min (@($"{Stage.Level.BackgroundParticleImpactSpeedMin:0}"))</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) [email protected] Min=@(5f) Max=@(150f) Step=@(5f) OnValueChanged=@((float v) => SetParticleValue( ParticleSetting.ImpactSpeedMin, v ))></PixelSlider></div>
</div>
<div class="row grid-row">
<label class="glabel">Imp Speed Max (@($"{Stage.Level.BackgroundParticleImpactSpeedMax:0}"))</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) [email protected] Min=@(5f) Max=@(150f) Step=@(5f) OnValueChanged=@((float v) => SetParticleValue( ParticleSetting.ImpactSpeedMax, v ))></PixelSlider></div>
</div>
<div class="row grid-row">
<label class="glabel">Imp Size Min (@Stage.Level.BackgroundParticleImpactSizeMin)</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) [email protected] Min=@(1f) Max=@(8f) Step=@(1f) OnValueChanged=@((float v) => SetParticleValue( ParticleSetting.ImpactSizeMin, v ))></PixelSlider></div>
</div>
<div class="row grid-row">
<label class="glabel">Imp Size Max (@Stage.Level.BackgroundParticleImpactSizeMax)</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) [email protected] Min=@(1f) Max=@(8f) Step=@(1f) OnValueChanged=@((float v) => SetParticleValue( ParticleSetting.ImpactSizeMax, v ))></PixelSlider></div>
</div>
<div class="row grid-row">
<label class="glabel">Imp Gravity (@($"{Stage.Level.BackgroundParticleImpactGravity:0}"))</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) [email protected] Min=@(-400f) Max=@(400f) Step=@(5f) OnValueChanged=@((float v) => SetParticleValue( ParticleSetting.ImpactGravity, v ))></PixelSlider></div>
</div>
<div class="row grid-row">
<label class="glabel">Imp Spray (@($"±{Stage.Level.BackgroundParticleImpactAngleRange:0}"))</label>
<div class="slider-wrap"><PixelSlider Compact=@(true) [email protected] Min=@(0f) Max=@(90f) Step=@(1f) OnValueChanged=@((float v) => SetParticleValue( ParticleSetting.ImpactAngleRange, v ))></PixelSlider></div>
</div>
}
}
@* <label class="hint tutorial">Colors use r,g,b (0-255) · blank = default</label> *@
</div>
</div>
@* Square home button in the bottom-left letterbox: returns to the main menu. *@
<button class="square-btn i-home home-button" onclick=@Click( () => Stage.ExitToTitle(), SfxType.MenuStart )
onmouseover=@Tip( "Leave the editor for the title screen" )>
<div class="img"></div>
</button>
@* Square back button, stacked above the home button: returns to the workshop screen the editor
is launched from. *@
<button class="square-btn i-back back-button" onclick=@Click( () => Stage.ExitToWorkshop(), SfxType.MenuStart )
onmouseover=@Tip( "Back to the workshop screen" )>
<div class="img"></div>
</button>
@* Square level-browser button immediately left of Test Play in the left letterbox: pops up the
load-level overlay. *@
<button class="square-btn i-browse browse-button" onclick=@Click( ToggleBrowser, SfxType.MenuStart )
onmouseover=@Tip( () => _browserOpen ? "Close the level browser" : "Browse levels and load one" )>
<div class="img"></div>
</button>
@* Level-browser overlay (opaque, instant): pick a level to load. Mounted only while open so it
appears/disappears with no transition. *@
@if ( _browserOpen )
{
<LevelBrowserOverlay OnPick=@LoadBrowserLevel
OnPickTemplate=@LoadBrowserTemplate
OnClose=@OnLevelBrowserClosed @ref="LevelBrowser"></LevelBrowserOverlay>
}
@* Square clear button, just right of the home button: resets the editor to a fresh default level. *@
<button class="square-btn i-clear clear-button" onclick=@Click( ClearLevel, SfxType.MenuStart )
onmouseover=@Tip( "Start over on a fresh level — discards unsaved work" )>
<div class="img"></div>
</button>
@* Square test-play button at the bottom-right of the LEFT letterbox (just left of the play square).
Runs the current unsaved edits; Backspace exits back to the editor. *@
<button class="square-btn i-play test-play-button" onclick=@Click( () => Stage.TestPlay(), SfxType.MenuStart )
onmouseover=@Tip( "Play these unsaved edits; Backspace returns here" )>
<div class="img"></div>
</button>
@* Square reload button on the right side of the LEFT letterbox, directly above the character
picker: reloads character defs, the level map, and daily templates without an editor restart.
Dev-only — in a standalone build there are no project files to re-read. *@
@if ( Game.IsEditor )
{
<button class="square-btn i-reload reload-characters-button"
onclick=@Click( ReloadEditorContent, SfxType.MenuStart )
onmouseover=@Tip( "Re-read character defs, the level map, and daily templates from disk" )>
<div class="img"></div>
</button>
}
@* Character picker in the LEFT letterbox, above the test-play button. Selects which character the
player-spawn marker shows and which character a Test Play run uses. The choice is also saved as
the player's most recently selected character. *@
<div class="character-slot">
<CharacterPicker GetSelectedId=@(() => Stage.SelectedCharacterId) OnPick=@OnPickCharacter
ScreenBottom=@(130f) HostMarginRight=@(580f)></CharacterPicker>
</div>
}
</root>
@code
{
public LevelEditorStage Stage { get; set; }
private LevelCanvasInput Canvas { get; set; }
private LevelEditorShortcutInput ShortcutInput { get; set; }
private LevelBrowserOverlay LevelBrowser { get; set; }
private PixelTooltip Tooltip { get; set; }
private string _loadId = "";
private string _pasteText = "";
private bool _browserOpen;
private bool _deleteConfirming;
private bool _templateDeleteConfirming;
private bool _previousCanvasPrimaryHeld;
private Action _afterEditorFieldBlur;
public void ResetTextFields()
{
_loadId = "";
_pasteText = "";
_deleteConfirming = false;
_templateDeleteConfirming = false;
ClearTemplateNameTyped();
StateHasChanged();
}
public void TickShortcutRepeat( float dt ) => ShortcutInput?.TickRepeat( dt );
protected override void OnUpdate()
{
if ( _afterEditorFieldBlur is not null && InputFocus.Current is not EditorTextEntry )
{
var action = _afterEditorFieldBlur;
_afterEditorFieldBlur = null;
action();
}
if ( Stage is null || Canvas is null )
{
Tooltip?.Hide();
return;
}
// The shortcut sink owns the keyboard whenever no text field does. A HUD button click hands
// engine focus to the button (KeyboardFocusGuard then drops it), which would otherwise leave the
// shortcuts dead until the next canvas press.
var focused = InputFocus.Next ?? InputFocus.Current;
if ( ShortcutInput is not null && focused != ShortcutInput && focused is not TextEntry )
ShortcutInput.Focus();
if ( ShortcutInput is not null ) ShortcutInput.Suspended = _browserOpen;
UpdateTooltipHover();
bool primaryHeld = Canvas.PrimaryHeld && Canvas.HasActive;
if ( primaryHeld && !_previousCanvasPrimaryHeld ) ShortcutInput?.Focus();
Stage.SetPointer( primaryHeld, Canvas.HasHovered );
_previousCanvasPrimaryHeld = primaryHeld;
}
private void OnRightClick() => Stage?.SecondaryClick();
private void ToggleBrowser()
{
if ( _browserOpen )
{
if ( LevelBrowser is not null )
LevelBrowser.RequestClose();
else
OnLevelBrowserClosed();
return;
}
_browserOpen = true;
}
private void OnLevelBrowserClosed()
{
_browserOpen = false;
LevelBrowser = null;
StateHasChanged();
}
private void AfterEditorFieldBlur( Action action )
{
if ( InputFocus.Current is EditorTextEntry && ShortcutInput is not null )
{
_afterEditorFieldBlur = action;
ShortcutInput.Focus();
return;
}
action();
}
private void LoadBrowserLevel( string id )
=> AfterEditorFieldBlur( () => { Stage?.LoadFromLevel( id ); ClearTemplateNameTyped(); } );
private void LoadBrowserTemplate( string id )
=> AfterEditorFieldBlur( () => { Stage?.LoadDailyTemplate( id ); ClearTemplateNameTyped(); } );
private void ClearLevel() => AfterEditorFieldBlur( ClearLevelNow );
private void ClearLevelNow()
{
if ( Stage is null ) return;
if ( _browserOpen )
{
if ( LevelBrowser is not null ) LevelBrowser.RequestClose();
else OnLevelBrowserClosed();
}
Stage.ResetToDefault(); // rolls the fresh level's cosmetics itself
}
private void BeginDeleteLevel()
{
_deleteConfirming = true;
StateHasChanged();
}
private void CancelDeleteLevel()
{
_deleteConfirming = false;
StateHasChanged();
}
private void ConfirmDeleteLevel()
{
_deleteConfirming = false;
Stage?.DeleteFromProject();
StateHasChanged();
}
// Keep the editor preview/test character in sync with the persisted selection used by other pickers.
private void OnPickCharacter( string id )
{
if ( Stage is null ) return;
Stage.SelectedCharacterId = id;
Settings.Current.SelectedCharacterId = id;
Settings.Save();
Stage.Rebuild();
}
// Wraps a button action with a snappy UI click sound so every editor button gives audible feedback.
// Returns the wrapped Action so it drops straight into onclick=@Click(...). Small controls use the
// short MenuBlip (default); primary/confirm actions pass MenuStart, stage transitions Enter/LeaveGame.
private static System.Action Click( System.Action a, SfxType sfx = SfxType.MenuBlip )
=> () => { Audio.PlaySfx( sfx, 0.7f ); a?.Invoke(); };
// The block-type buttons show baked face art instead of text; the type NAME surfaces as a hover
// tooltip instead. Uses the per-type UI stills (sprites/ui/blocks/*) — the add/pool buttons
// default to the awake phase-0 face (_p0; the dim grey tally body is baked separately as
// {type}_tally.png), while list rows pass the block's configured start phase so the icon
// matches the palette it will actually spawn with.
private static string BlockIconStyle( BlockType type, int phase = 0 )
{
string name = type.ToString().ToLowerInvariant();
string image = type == BlockType.Mimic ? "mimic_preview" : $"{name}_p{Math.Clamp( phase, 0, Block.NUM_PHASES - 1 )}";
return $"background-image:url( sprites/ui/blocks/{image}.png );";
}
/// <summary>Tooltip for a block-type button in the add row — what THIS click will actually do, which
/// depends on the pool/exact mode and on whether SET has armed a spawn pin.</summary>
private string AddBlockTip( BlockType type )
{
if ( _pinArming && Stage?.SelectedKind == LevelEditorStage.SelKind.Spawn )
return $"Pin a {type} to the selected spawn";
return Stage?.Level.UsePool == true ? $"Add {type} to the pool" : $"Add a {type} block";
}
// The three tips below are resolved from their SLOT, not from the type/block captured when the row
// was built: removing an entry shifts the list and razor reuses the same panel for whatever lands in
// the slot next, so a captured value would describe the wrong entry. Out-of-range = no tooltip.
private string PoolChipTip( int index )
=> Stage is not null && index >= 0 && index < Stage.Level.Pool.Count
? $"{Stage.Level.Pool[index]} — click to remove from the pool"
: null;
private string BlockRowTip( int index )
=> Stage is not null && index >= 0 && index < Stage.Level.Blocks.Count
? $"{Stage.Level.Blocks[index].Type}, starting at phase {Stage.Level.Blocks[index].Phase}"
: null;
private string SpawnPinTip()
=> SelectedPin is EditorBlock pin
? $"{pin.Type} pinned to this spawn, starting at phase {pin.Phase}"
: null;
// ── hover tooltips ───────────────────────────────────────────────────────────
// Every control that wants a tip carries onmouseover=@Tip( "..." ), which records the control and
// its text here; the per-frame poll below just validates the stored panel is STILL hovered. That
// covers mouseout, deletion and HUD rebuilds without a matching mouseout handler on each button,
// and scales to the whole toolbar (a per-@ref polling chain would not). Because the markup is only
// rebuilt on a BuildHash change, state-dependent text (a toggle's on/off wording) is fine here.
private Panel _tipPanel;
private System.Func<string> _tipText;
/// <summary>Fixed tooltip text.</summary>
private System.Action<PanelEvent> Tip( string text )
=> e => { _tipPanel = e.This; _tipText = () => text; };
/// <summary>Tooltip text that can change while the control STAYS hovered, resolved per frame rather
/// than snapshotted on hover. Needed because mouseover only fires when the hovered panel changes
/// (PanelInput.SetHovered): clicking a hovered toggle — or a keyboard edit — rebuilds the markup and
/// re-attaches this handler without re-running it, so a snapshot would keep describing the state the
/// control has just left. Rule of thumb: any tip that isn't a literal string belongs on this overload.</summary>
private System.Action<PanelEvent> Tip( System.Func<string> text )
=> e => { _tipPanel = e.This; _tipText = text; };
private void UpdateTooltipHover()
{
string text = _tipPanel.IsValid() && _tipPanel.HasHovered ? _tipText?.Invoke() : null;
if ( string.IsNullOrEmpty( text ) ) Tooltip?.Hide();
else Tooltip?.Show( text );
}
// ── floating dimension readout (anchored over the obstacle in the level) ──────
private bool DimVisible()
{
if ( Stage is null ) return false;
var d = Stage.CurrentDimReadout();
return d.Visible && Stage.WorldToScreenFraction( d.WorldPos, out _ );
}
private string DimStyle()
{
var d = Stage.CurrentDimReadout();
if ( !d.Visible || !Stage.WorldToScreenFraction( d.WorldPos, out var f ) )
return "display:none;";
return $"left:{Pct( f.x )}; top:{Pct( f.y )};";
}
private string DimText()
{
var d = Stage.CurrentDimReadout();
return $"{(int)MathF.Round( d.Width )} × {(int)MathF.Round( d.Height )}";
}
private static string Pct( float frac01 )
=> ( frac01 * 100f ).ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture ) + "%";
// Quantised readout so the HUD repaints as the box changes during a drag (BuildHash otherwise only
// keys on list counts / selection index, which don't change while resizing or moving).
private int DimHash()
{
if ( Stage is null ) return 0;
var d = Stage.CurrentDimReadout();
if ( !d.Visible ) return 0;
return HashCode.Combine( (int)MathF.Round( d.Width ), (int)MathF.Round( d.Height ),
(int)MathF.Round( d.WorldPos.x * 2f ), (int)MathF.Round( d.WorldPos.y * 2f ) );
}
private string ToolActive( LevelEditorStage.EditTool t ) => Stage != null && Stage.Tool == t ? "on" : "";
private string On( bool b ) => b ? "on" : "";
private void SetTool( LevelEditorStage.EditTool t )
{
Stage.Tool = t;
Stage.Rebuild();
}
private string SelectionText()
{
if ( Stage is null || !Stage.HasSelection ) return "Nothing selected";
if ( Stage.SelectionCount > 1 ) return $"Selected: {Stage.SelectionCount} objects";
if ( Stage.SelectedKind == LevelEditorStage.SelKind.Obstacle &&
Stage.SelectedIndex >= 0 && Stage.SelectedIndex < Stage.Level.Obstacles.Count )
{
var obstacle = Stage.Level.Obstacles[Stage.SelectedIndex];
return $"Selected: Obstacle #{Stage.SelectedIndex} ({(int)MathF.Round( obstacle.Width )}, {(int)MathF.Round( obstacle.Height )})";
}
return $"Selected: {Stage.SelectedKind} #{Stage.SelectedIndex}";
}
// Warn when a spiked obstacle face needs an overlapping final tile. A 1px remainder is supported
// as a plain tail; remainders 2-4 clamp/overlap the last tooth (see GameStage.BuildTiledFace).
// Horizontal faces tile along width, vertical faces along height. Arena walls are untiled.
private string SpikeSizeWarning()
{
if ( Stage is null ) return "";
string ids = "";
var seen = new System.Collections.Generic.HashSet<int>();
foreach ( var s in Stage.Level.SpikedObstacleSides )
{
if ( s.Obstacle < 0 || s.Obstacle >= Stage.Level.Obstacles.Count || seen.Contains( s.Obstacle ) )
continue;
var r = Stage.Level.Obstacles[s.Obstacle];
foreach ( var dir in s.Sides )
{
float len = ( dir == Direction.Up || dir == Direction.Down ) ? r.Width : r.Height;
if ( LevelEditorStage.SpikeFaceSizeUneven( len ) )
{
seen.Add( s.Obstacle );
ids += ( ids.Length > 0 ? ", " : "" ) + "#" + s.Obstacle;
break;
}
}
}
return ids.Length == 0 ? "" : $"Spiked obstacle {ids} not ÷5 — spikes space unevenly";
}
// ── meta ─────────────────────────────────────────────────────────────────────
private EditorTextEntry IdEntry { get; set; }
private Panel SaveProjectButton { get; set; }
private Panel DeleteLevelButton { get; set; }
private Panel TemplateSaveButton { get; set; }
private Panel TemplateLoadButton { get; set; }
private Panel TemplateDelButton { get; set; }
private Panel TemplateMirrorButton { get; set; }
private Panel TemplateSpikesButton { get; set; }
/// <summary>Save Level / Delete Level stay inert while the level has no id or name, so a
/// fresh or template-seeded document must be deliberately named before it can touch project files.</summary>
private bool LevelMetaMissing
=> Stage is null
|| string.IsNullOrWhiteSpace( Stage.Level.Id )
|| string.IsNullOrWhiteSpace( Stage.Level.Name );
/// <summary>Delete Level additionally stays inert unless the id names the file this document was
/// loaded from or last saved as — the only file the stage's delete will remove (same claim rule as
/// Save's overwrite), so a typed or unsaved-renamed id can't reach the confirm step only to be refused.</summary>
private bool LevelDeleteUnavailable
=> LevelMetaMissing || !Stage.OwnsProjectFile( Stage.Level.Id?.Trim() );
/// <summary>Template Save/Load/Del/Mirror/Spikes stay inert while the template name box is blank
/// (blank = sanitizes to nothing, which is exactly what the stage's template commands refuse).</summary>
private bool TemplateNameMissing
=> Stage is null || string.IsNullOrEmpty( DailyTemplates.SanitizeId( Stage.Level.DailyTemplateName ) );
// Live enable/disable: the HUD doesn't rebuild per keystroke (BuildHash doesn't sample these
// texts), so flip the class directly on the buttons; the markup class covers initial render.
private void RefreshProjectButtons()
{
SaveProjectButton?.SetClass( "disabled", LevelMetaMissing );
DeleteLevelButton?.SetClass( "disabled", LevelDeleteUnavailable );
}
/// <summary>The typed template name already belongs to a saved template, so Save would OVERWRITE
/// it. Only counts once the box has been TYPED in since the editor opened (or since a template was
/// loaded/saved) — a name that arrived by loading a template names its own file, so red would be noise.</summary>
private EditorTextEntry TemplateNameEntry { get; set; }
private bool _templateNameTyped;
private bool TemplateNameIsDupe
=> _templateNameTyped && Stage is not null
&& DailyTemplates.Find( DailyTemplates.SanitizeId( Stage.Level.DailyTemplateName ) ) is not null;
private string TemplateNameClass() => TemplateNameIsDupe ? "template-dupe" : "";
/// <summary>Drop the typed-in mark and the red tint — the name in the box is no longer the user's
/// own typing (a template load/save just filled or claimed it, or the document was replaced).</summary>
private void ClearTemplateNameTyped()
{
_templateNameTyped = false;
TemplateNameEntry?.SetClass( "template-dupe", false );
}
private void RefreshTemplateButtons()
{
bool missing = TemplateNameMissing;
TemplateSaveButton?.SetClass( "disabled", missing );
TemplateLoadButton?.SetClass( "disabled", missing );
TemplateDelButton?.SetClass( "disabled", missing );
TemplateMirrorButton?.SetClass( "disabled", missing );
TemplateSpikesButton?.SetClass( "disabled", missing );
}
/// <summary>Whether the current id would be refused by level_save for containing spaces.
/// Leading/trailing whitespace doesn't count — the save trims it.</summary>
private bool IdHasWhitespace
{
get
{
var id = Stage?.Level?.Id?.Trim() ?? "";
foreach ( var c in id )
if ( char.IsWhiteSpace( c ) )
return true;
return false;
}
}
// Saving normalizes the document id to the trimmed file name; the box doesn't rebuild per
// keystroke, so mirror the stored id back into it (the click already blurred the entry).
private void SaveLevel()
{
Stage.SaveToProject();
SyncIdEntry();
}
private void PublishLevel()
{
Stage.PublishToWorkshop();
SyncIdEntry();
}
private void SyncIdEntry()
{
if ( IdEntry is not null && IdEntry.Text != Stage.Level.Id )
IdEntry.Text = Stage.Level.Id;
}
private void OnId( string v )
{
Stage.Level.Id = v ?? "";
_deleteConfirming = false;
// Live validity tint: the HUD doesn't rebuild per keystroke (BuildHash doesn't sample the id
// text), so flip the class directly on the entry; the markup class covers initial render.
IdEntry?.SetClass( "invalid", IdHasWhitespace );
RefreshProjectButtons();
}
private void OnName( string v )
{
Stage.Level.Name = v ?? "";
RefreshProjectButtons();
}
// Music field tint (live, like the Character field: the HUD doesn't rebuild per keystroke).
// Light blue = the typed name resolves to a real song; red = it resolves but another mapped level
// already overrides its music with that same track (usually an accident, matching the picker's
// red music badge). The red dupe tint is a DEV concern only — a standalone player authoring their
// own level has no reason to care that a shipped level uses the same song.
private EditorTextEntry MusicEntry { get; set; }
private string MusicClass()
{
string music = Stage?.Level?.Music;
if ( string.IsNullOrWhiteSpace( music ) || !Audio.MusicExists( music ) ) return "";
return Game.IsEditor && MusicUsedElsewhere( music ) ? "music-dupe" : "music-match";
}
/// <summary>True when a mapped level other than the one being edited overrides its music with the
/// same track (names resolve to a path, so "song" and "song.ogg" count as one).</summary>
private bool MusicUsedElsewhere( string music )
{
string path = Audio.ResolveMusicPath( music );
string id = Stage?.Level?.Id?.Trim();
foreach ( var node in LevelMap.Nodes )
{
string other = node.Level?.Music;
if ( string.IsNullOrWhiteSpace( other ) ) continue;
if ( string.Equals( node.LevelId, id, StringComparison.OrdinalIgnoreCase ) ) continue;
if ( string.Equals( Audio.ResolveMusicPath( other ), path, StringComparison.OrdinalIgnoreCase ) )
return true;
}
return false;
}
private void OnMusic( string v )
{
Stage.Level.Music = v ?? "";
string tint = MusicClass();
MusicEntry?.SetClass( "music-match", tint == "music-match" );
MusicEntry?.SetClass( "music-dupe", tint == "music-dupe" );
Stage.RefreshMusic();
}
/// <summary>Dice button: drop a random song into the Music field. Songs come from a shuffle bag:
/// every song in a fresh random order, no repeats until the whole library has been dealt (the bag
/// refills, reshuffled, when it runs dry and whenever the editor reopens). In the dev editor the
/// bag prefers songs no OTHER mapped level already overrides its music with (so a new level gets a
/// fresh track), falling back to the next bagged song once every free one is taken. The menu
/// default song is never dealt.</summary>
private void RandomizeMusic()
{
var songs = Audio.RandomizableMusicNames();
if ( songs.Count == 0 ) return;
string current = Stage?.Level?.Music;
string currentPath = string.IsNullOrWhiteSpace( current ) ? null : Audio.ResolveMusicPath( current );
string picked = TakeFromMusicBag( songs, currentPath );
if ( picked is null ) return;
OnMusic( picked );
// The bound Value never stomps a focused entry, and clicking the button doesn't blur it.
if ( MusicEntry.IsValid() )
{
MusicEntry.Text = picked;
MusicEntry.Blur();
}
}
/// <summary>Pops the next song off the shuffle bag, refilling it when empty. Prefers the earliest
/// bagged song that also passes the dev-editor "unused by other levels" filter; songs that fail it
/// stay bagged for a later pass. Never hands back the song already in the box unless it's the only
/// one there is.</summary>
private string TakeFromMusicBag( List<string> songs, string currentPath )
{
bool IsCurrent( string song ) => currentPath is not null
&& string.Equals( Audio.ResolveMusicPath( song ), currentPath, StringComparison.OrdinalIgnoreCase );
// Two attempts: the second runs after a bag holding nothing but the current song is dumped.
for ( int attempt = 0; attempt < 2; attempt++ )
{
if ( _musicBag.Count == 0 ) RefillMusicBag( songs, currentPath );
int fallback = -1, free = -1;
for ( int i = 0; i < _musicBag.Count; i++ )
{
if ( IsCurrent( _musicBag[i] ) ) continue;
if ( fallback < 0 ) fallback = i;
if ( Game.IsEditor && MusicUsedElsewhere( _musicBag[i] ) ) continue;
free = i;
break;
}
int take = free >= 0 ? free : fallback;
if ( take < 0 ) { _musicBag.Clear(); continue; }
string picked = _musicBag[take];
_musicBag.RemoveAt( take );
return picked;
}
return songs[0]; // single-song library, already in the box
}
/// <summary>Refills the bag with every song in a fresh random order, nudging the song currently in
/// the box off the front so a new pass can't repeat it back-to-back.</summary>
private void RefillMusicBag( List<string> songs, string currentPath )
{
_musicBag.Clear();
_musicBag.AddRange( songs );
for ( int i = _musicBag.Count - 1; i > 0; i-- )
{
int j = _musicRandom.Next( i + 1 );
(_musicBag[i], _musicBag[j]) = (_musicBag[j], _musicBag[i]);
}
if ( _musicBag.Count > 1 && currentPath is not null
&& string.Equals( Audio.ResolveMusicPath( _musicBag[0] ), currentPath, StringComparison.OrdinalIgnoreCase ) )
{
int swap = 1 + _musicRandom.Next( _musicBag.Count - 1 );
(_musicBag[0], _musicBag[swap]) = (_musicBag[swap], _musicBag[0]);
}
}
// Shuffle bag of songs still undealt this pass — per hud, so a fresh editor session reshuffles.
private readonly List<string> _musicBag = new();
// Authoring-only randomness — deliberately NOT the sim/cosmetic Rng streams.
private static readonly Random _musicRandom = new();
private string MusicVolumeStr() => $"{Stage.Level.MusicVolume:0.00}x";
private void SetMusicVolume( float value )
{
Stage.Level.MusicVolume = Math.Clamp( value, 0f, 1.5f );
Stage.RefreshMusic();
}
// The Character box keeps whatever was TYPED — an exact (case-insensitive) character id OR
// display name both work, and the level stores the resolved ID either way (a display name never
// reaches the JSON, but nothing rewrites the typed text back to the id). The typed text belongs
// to the CURRENT Level instance: loads and undo/redo swap the instance, resyncing the box from
// the stored id — shown as the character's display name when it resolves, so players never see
// raw ids. While the text resolves, .character-match tints it (same colour as the picker search).
private string _forcedCharacterText;
private EditorLevel _forcedCharacterLevel;
private EditorTextEntry CharacterEntry { get; set; }
private string ForcedCharacterStr()
{
if ( !ReferenceEquals( _forcedCharacterLevel, Stage?.Level ) )
{
_forcedCharacterLevel = Stage?.Level;
_forcedCharacterText = Characters.TryGet( Stage?.Level?.ForcedCharacterId, out var character )
? character.Name.ToLowerInvariant()
: Stage?.Level?.ForcedCharacterId ?? "";
}
return _forcedCharacterText;
}
private bool ForcedCharacterWorks => Characters.TryGetByIdOrName( ForcedCharacterStr(), out _ );
private void OnForcedCharacter( string value )
{
_forcedCharacterLevel = Stage?.Level;
_forcedCharacterText = value ?? "";
Stage.Level.ForcedCharacterId = Characters.TryGetByIdOrName( value, out var character )
? character.Id
: string.IsNullOrWhiteSpace( value ) ? null : value.Trim();
// Live tint, same pattern as the id's invalid class (the HUD doesn't rebuild per keystroke).
CharacterEntry?.SetClass( "character-match", character is not null );
Stage.Rebuild();
}
private void ToggleShuffle()
{
Stage.Level.ShuffleTypes = !Stage.Level.ShuffleTypes;
Stage.Rebuild();
}
// ── grid overlay (persisted editor prefs) ─────────────────────────────────────
private void ToggleMirrorX()
{
Stage.MirrorX = !Stage.MirrorX;
Stage.Rebuild();
}
private void ToggleMirrorY()
{
Stage.MirrorY = !Stage.MirrorY;
Stage.Rebuild();
}
private void ToggleGrid()
{
EditorPrefs.Current.ShowGrid = !EditorPrefs.Current.ShowGrid;
EditorPrefs.Save();
Stage.Rebuild();
}
private void ToggleSnap()
{
if ( Stage.SnapInvertHeld ) return;
EditorPrefs.Current.SnapEnabled = !EditorPrefs.Current.SnapEnabled;
EditorPrefs.Save();
Stage.Rebuild();
}
private void ResetGrid()
{
var settings = EditorPrefs.Current;
settings.GridOffsetX = 0f;
settings.GridOffsetY = 0f;
settings.GridSize = 40f;
settings.GridOpacity = 20f;
settings.GridSmart = false;
settings.GridCenterCell = false;
settings.GridBottomLeftOrigin = false;
EditorPrefs.Save();
Stage.Rebuild();
}
private void SetGridSize( float v )
{
EditorPrefs.Current.GridSize = Math.Clamp( v, 2f, 120f );
EditorPrefs.Save();
Stage.Rebuild();
}
private void SetGridOpacity( float v )
{
EditorPrefs.Current.GridOpacity = Math.Clamp( v, 0f, 100f );
EditorPrefs.Save();
Stage.Rebuild();
}
private void ToggleGridSmart()
{
EditorPrefs.Current.GridSmart = !EditorPrefs.Current.GridSmart;
EditorPrefs.Save();
Stage.Rebuild();
}
private void ToggleGridCenterCell()
{
EditorPrefs.Current.GridCenterCell = !EditorPrefs.Current.GridCenterCell;
EditorPrefs.Save();
Stage.Rebuild();
}
private void ToggleGridBottomLeftOrigin()
{
EditorPrefs.Current.GridBottomLeftOrigin = !EditorPrefs.Current.GridBottomLeftOrigin;
EditorPrefs.Save();
Stage.Rebuild();
}
private void SetGridOffset( bool xAxis, float value )
{
float offset = Math.Clamp( value, -GridOffsetLimit(), GridOffsetLimit() );
if ( xAxis ) EditorPrefs.Current.GridOffsetX = offset;
else EditorPrefs.Current.GridOffsetY = offset;
EditorPrefs.Save();
Stage.Rebuild();
}
private static string GridSizeStr() => ( (int)EditorPrefs.Current.GridSize ).ToString() + "px";
private static string GridOpacityStr() => ( (int)EditorPrefs.Current.GridOpacity ).ToString() + "%";
private static float GridOffsetLimit() => MathF.Max( 2f, EditorPrefs.Current.GridSize );
private static float GridOffsetValue( bool xAxis )
{
float value = xAxis ? EditorPrefs.Current.GridOffsetX : EditorPrefs.Current.GridOffsetY;
return float.IsFinite( value ) ? value % GridOffsetLimit() : 0f;
}
private static string GridOffsetStr( float value )
=> value.ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture );
// ── blocks ───────────────────────────────────────────────────────────────────
private void SetMode( bool usePool )
{
Stage.Level.UsePool = usePool;
Stage.Rebuild();
}
private void IncCount( int delta )
{
Stage.Level.BlockCount = Math.Max( 1, Stage.Level.BlockCount + delta );
}
private void AddBlock( BlockType t )
{
// SET-armed: the click pins this type to the selected spawn instead of adding a block.
if ( _pinArming )
{
_pinArming = false;
if ( Stage.SelectedKind == LevelEditorStage.SelKind.Spawn )
{
Stage.Level.SetSpawnPin( Stage.SelectedIndex, new EditorBlock( t ) );
Stage.Rebuild();
return;
}
}
if ( Stage.Level.UsePool ) Stage.Level.Pool.Add( t );
else Stage.Level.Blocks.Add( new EditorBlock( t ) );
Stage.Rebuild();
}
private void RemoveBlock( int i )
{
if ( i >= 0 && i < Stage.Level.Blocks.Count ) Stage.Level.Blocks.RemoveAt( i );
Stage.Rebuild();
}
private void RemovePool( int i )
{
if ( i >= 0 && i < Stage.Level.Pool.Count ) Stage.Level.Pool.RemoveAt( i );
}
// Per-block starting phase / pre-pressed sides (exact mode).
private void CyclePhase( int i, int delta )
{
if ( i < 0 || i >= Stage.Level.Blocks.Count ) return;
var b = Stage.Level.Blocks[i];
b.Phase = Math.Clamp( b.Phase + delta, 0, Block.NUM_PHASES - 1 );
if ( b.Phase >= Block.NUM_PHASES - 1 ) b.Pressed.Clear(); // maxed blocks have no pressable sides
Stage.Rebuild();
}
private void ToggleBlockSide( int i, Direction dir )
{
if ( i < 0 || i >= Stage.Level.Blocks.Count ) return;
var b = Stage.Level.Blocks[i];
if ( b.Phase >= Block.NUM_PHASES - 1 ) return;
if ( !b.Pressed.Remove( dir ) ) b.Pressed.Add( dir );
}
private string SideOn( int i, Direction dir )
=> i >= 0 && i < Stage.Level.Blocks.Count && Stage.Level.Blocks[i].Pressed.Contains( dir ) ? "on" : "";
private void IncPoolPhase( int delta )
{
Stage.Level.PoolStartPhase = Math.Clamp( Stage.Level.PoolStartPhase + delta, 0, Block.NUM_PHASES - 1 );
}
// ── pinned block on the selected spawn ─────────────────────────────────────────────────────────
// SET arms one click: the next block-type button pins that type here (see AddBlock) instead of
// adding to the level's block list. The pinned block then edits like a normal block row.
private bool _pinArming;
private void TogglePinArm()
{
_pinArming = !_pinArming && Stage?.SelectedKind == LevelEditorStage.SelKind.Spawn;
}
private EditorBlock SelectedPin => Stage?.SelectedKind == LevelEditorStage.SelKind.Spawn
? Stage.Level.SpawnPin( Stage.SelectedIndex )
: null;
private void CyclePinPhase( int delta )
{
if ( SelectedPin is not EditorBlock pin ) return;
pin.Phase = Math.Clamp( pin.Phase + delta, 0, Block.NUM_PHASES - 1 );
if ( pin.Phase >= Block.NUM_PHASES - 1 ) pin.Pressed.Clear(); // maxed blocks have no pressable sides
Stage.Rebuild();
}
private void TogglePinSide( Direction dir )
{
if ( SelectedPin is not EditorBlock pin || pin.Phase >= Block.NUM_PHASES - 1 ) return;
if ( !pin.Pressed.Remove( dir ) ) pin.Pressed.Add( dir );
}
private string PinSideOn( Direction dir )
=> SelectedPin?.Pressed.Contains( dir ) == true ? "on" : "";
private void SetSpawnDirection( Direction direction )
{
if ( Stage?.SelectedKind != LevelEditorStage.SelKind.Spawn ) return;
Stage.Level.SetSpawnDirection( Stage.SelectedIndex, direction );
Stage.Rebuild();
}
private string SpawnDirectionOn( Direction direction )
=> Stage?.SelectedKind == LevelEditorStage.SelKind.Spawn
&& Stage.Level.SpawnDirection( Stage.SelectedIndex ) == direction ? "on" : "";
private string SpawnTurnModeOn( TurnMode mode )
=> Stage?.SelectedKind == LevelEditorStage.SelKind.Spawn
&& Stage.Level.SpawnTurnMode( Stage.SelectedIndex ) == mode ? "on" : "";
private void SetSpawnTurnMode( TurnMode mode )
{
if ( Stage?.SelectedKind != LevelEditorStage.SelKind.Spawn ) return;
Stage.Level.SetSpawnTurnMode( Stage.SelectedIndex, mode );
Stage.Rebuild();
}
private void ClearSpawnPin()
{
if ( Stage?.SelectedKind != LevelEditorStage.SelKind.Spawn ) return;
Stage.Level.SetSpawnPin( Stage.SelectedIndex, null );
Stage.Rebuild();
}
private string SpawnAbilityIntervalStr() => $"{Stage.Level.SpawnAbilityInterval( Stage.SelectedIndex ):0.##}";
private string SpawnAbilityRandomnessStr() => $"{Stage.Level.SpawnAbilityRandomness( Stage.SelectedIndex ):0.##}";
private string SpawnAbilityStartOffsetStr() => $"{Stage.Level.SpawnAbilityStartOffset( Stage.SelectedIndex ):0.##}";
private string SpawnAbilityClosedTimeStr() => $"{Stage.Level.SpawnAbilityClosedTime( Stage.SelectedIndex ):0.##}";
private void SetSpawnAbilityInterval( string value )
{
if ( Stage?.SelectedKind != LevelEditorStage.SelKind.Spawn ) return;
if ( !float.TryParse( value, out float parsed ) ) return;
Stage.Level.SetSpawnAbilityTimer( Stage.SelectedIndex, Math.Clamp( parsed, 0.25f, 600f ),
Stage.Level.SpawnAbilityRandomness( Stage.SelectedIndex ),
Stage.Level.SpawnAbilityStartOffset( Stage.SelectedIndex ),
Stage.Level.SpawnAbilityClosedTime( Stage.SelectedIndex ) );
Stage.Rebuild();
}
private void SetSpawnAbilityRandomness( string value )
{
if ( Stage?.SelectedKind != LevelEditorStage.SelKind.Spawn ) return;
if ( !float.TryParse( value, out float parsed ) ) return;
Stage.Level.SetSpawnAbilityTimer( Stage.SelectedIndex, Stage.Level.SpawnAbilityInterval( Stage.SelectedIndex ),
Math.Clamp( parsed, 0f, 10f ),
Stage.Level.SpawnAbilityStartOffset( Stage.SelectedIndex ),
Stage.Level.SpawnAbilityClosedTime( Stage.SelectedIndex ) );
Stage.Rebuild();
}
private void SetSpawnAbilityStartOffset( string value )
{
if ( Stage?.SelectedKind != LevelEditorStage.SelKind.Spawn ) return;
if ( !float.TryParse( value, out float parsed ) ) return;
Stage.Level.SetSpawnAbilityTimer( Stage.SelectedIndex, Stage.Level.SpawnAbilityInterval( Stage.SelectedIndex ),
Stage.Level.SpawnAbilityRandomness( Stage.SelectedIndex ), parsed,
Stage.Level.SpawnAbilityClosedTime( Stage.SelectedIndex ) );
Stage.Rebuild();
}
private void SetSpawnAbilityClosedTime( string value )
{
if ( Stage?.SelectedKind != LevelEditorStage.SelKind.Spawn ) return;
if ( !float.TryParse( value, out float parsed ) ) return;
Stage.Level.SetSpawnAbilityTimer( Stage.SelectedIndex, Stage.Level.SpawnAbilityInterval( Stage.SelectedIndex ),
Stage.Level.SpawnAbilityRandomness( Stage.SelectedIndex ),
Stage.Level.SpawnAbilityStartOffset( Stage.SelectedIndex ),
Math.Clamp( parsed, 0f, 600f ) );
Stage.Rebuild();
}
// A signature of the per-block start configs + spawn pins + pool phase so toggling them repaints
// the HUD (they don't change any list COUNT, which the rest of BuildHash keys on).
private int BlocksConfigHash()
{
if ( Stage is null ) return 0;
var h = new HashCode();
foreach ( var b in Stage.Level.Blocks )
{
h.Add( b.Type );
h.Add( b.Phase );
foreach ( var d in b.Pressed ) h.Add( d );
h.Add( int.MinValue ); // separator so {L} and {L,R} differ from reordering
}
foreach ( var pin in Stage.Level.SpawnPins )
{
h.Add( pin?.Type );
h.Add( pin?.Phase ?? -1 );
if ( pin is not null ) foreach ( var d in pin.Pressed ) h.Add( d );
h.Add( int.MinValue );
}
h.Add( _pinArming );
foreach ( var timer in Stage.Level.SpawnAbilityTimers )
{
h.Add( timer?.Interval ?? Block.DEFAULT_ABILITY_INTERVAL );
h.Add( timer?.Randomness ?? 0f );
h.Add( timer?.StartOffset ?? 0f );
}
// Per-slot start direction + turn mode: the inspector's only feedback for these is the
// pressed state of their own buttons, so without them here a click changes the level but
// leaves the button looking untouched until some unrelated edit repaints the panel.
foreach ( var direction in Stage.Level.SpawnDirections ) h.Add( direction );
foreach ( var turnMode in Stage.Level.SpawnTurnModes ) h.Add( turnMode );
h.Add( Stage.Level.PoolStartPhase );
return h.ToHashCode();
}
// ── palette (shared logic lives in LevelCosmeticsRandomizer) ─────────────────
private static readonly System.Random _environmentRandom = new();
private static Color DefaultWallColor => LevelCosmeticsRandomizer.DefaultWallColor;
private static Color DefaultOutOfBoundsColor => LevelCosmeticsRandomizer.DefaultOutOfBoundsColor;
private static Color DefaultCheckerboardColor => LevelCosmeticsRandomizer.DefaultCheckerboardColor;
private static Color DefaultBackgroundBlockColor => LevelCosmeticsRandomizer.DefaultBackgroundBlockColor;
private static string ColStr( Color? c )
{
if ( !c.HasValue ) return "";
var v = c.Value;
return $"{B( v.r )},{B( v.g )},{B( v.b )}";
}
private string DerivedCheckerColStr( bool alternate )
{
Color primary = alternate
? Stage.Level.AlternateCheckerboardColor ?? Stage.Level.CheckerboardColor ?? DefaultCheckerboardColor
: Stage.Level.CheckerboardColor ?? DefaultCheckerboardColor;
const float factor = 235f / 255f;
return ColStr( new Color( primary.r * factor, primary.g * factor, primary.b * factor, primary.a ) );
}
private static int B( float v ) => (int)MathF.Round( Math.Clamp( v, 0f, 1f ) * 255f );
private void SetCol( int which, string v )
{
if ( !string.IsNullOrWhiteSpace( v ) && !TryParseCol( v, out _ ) ) return;
Color? col = string.IsNullOrWhiteSpace( v ) ? null : ParseCol( v );
switch ( which )
{
case 0: Stage.Level.WallColor = col; break;
case 1: Stage.Level.OutOfBoundsColor = col; break;
case 2: Stage.Level.CheckerboardColor = col; break;
case 3: Stage.Level.BackgroundBlockColor = col; break;
case 4: Stage.Level.AlternateCheckerboardColor = col; break;
case 5: Stage.Level.FenceColor = col; break;
case 6: Stage.Level.GlassColor = col; break;
case 7: Stage.Level.CheckerboardSecondColor = col; break;
case 8: Stage.Level.AlternateCheckerboardSecondColor = col; break;
}
// Wall/OOB and the fence/glass colours are baked into the schematic's quads — rebuild for those.
RefreshEnvironment( rebuildSchematic: which <= 1 || which is 5 or 6 );
StateHasChanged();
}
private static bool TryParseCol( string v, out Color color )
{
color = default;
var p = v.Split( ',', StringSplitOptions.TrimEntries );
if ( p.Length != 3 ) return false;
if ( !float.TryParse( p[0], out var r ) || !float.TryParse( p[1], out var g ) || !float.TryParse( p[2], out var b ) )
return false;
color = new Color( r / 255f, g / 255f, b / 255f );
return true;
}
private static Color? ParseCol( string v )
{
if ( string.IsNullOrWhiteSpace( v ) ) return null;
return TryParseCol( v, out var color ) ? color : null;
}
// Background blocks: single base colour (auto-derives 3 stepped shades) vs 3 explicit shades.
private bool BgUse3() => Stage != null && Stage.Level.BackgroundBlockColors is { Count: 3 };
private void SetBgMode( bool use3 )
{
if ( use3 )
{
if ( Stage.Level.BackgroundBlockColors is not { Count: 3 } )
{
var b = Stage.Level.BackgroundBlockColor ?? new Color( 0f, 202f / 255f, 206f / 255f );
Stage.Level.BackgroundBlockColors = new System.Collections.Generic.List<Color> { StepSrgb( b, 1f ), StepSrgb( b, 0.9f ), StepSrgb( b, 0.8f ) };
}
}
else
{
Stage.Level.BackgroundBlockColors = null;
}
RefreshEnvironment();
}
private static Color StepSrgb( Color c, float f ) => LevelCosmeticsRandomizer.StepSrgb( c, f );
private void ResetEnvironmentToClassic()
{
if ( Stage is null ) return;
Stage.Level.WallColor = null;
Stage.Level.OutOfBoundsColor = null;
Stage.Level.CheckerboardColor = null;
Stage.Level.CheckerboardSecondColor = null;
Stage.Level.BackgroundBlockColor = null;
Stage.Level.BackgroundBlockColors = null;
Stage.Level.BackgroundBlockScale = 1f;
Stage.Level.BackgroundBlockDensity = 1f;
Stage.Level.BackgroundBlockOpacity = 1f;
Stage.Level.BackgroundDriftSpeed = 1f;
Stage.Level.BackgroundDriftBias = BackgroundDriftBias.None;
Stage.Level.PlayfieldPattern = PlayfieldPattern.Checker;
Stage.Level.PlayfieldCellScale = 1;
Stage.Level.PlayfieldPatternRows = null;
Stage.Level.AlternatePlayfieldEnabled = false;
Stage.Level.AlternateCheckerboardColor = null;
Stage.Level.AlternateCheckerboardSecondColor = null;
Stage.Level.AlternatePlayfieldPattern = PlayfieldPattern.Checker;
Stage.Level.AlternatePlayfieldCellScale = 1;
Stage.Level.AlternatePlayfieldPatternRows = null;
Stage.Level.AlternatePlayfieldRects.Clear();
if ( Stage.Tool == LevelEditorStage.EditTool.AlternatePattern ) Stage.Tool = LevelEditorStage.EditTool.Select;
Stage.Level.BackgroundParticlesEnabled = false;
Stage.Level.BackgroundParticleEdge = Direction.Up;
Stage.Level.BackgroundParticleColor = null;
Stage.Level.BackgroundParticleOpacity = 1f;
Stage.Level.BackgroundParticleSizeMin = 1;
Stage.Level.BackgroundParticleSizeMax = 2;
Stage.Level.BackgroundParticleAngle = 0f;
Stage.Level.BackgroundParticleAngleRange = 0f;
Stage.Level.BackgroundParticleSpeedMin = 90f;
Stage.Level.BackgroundParticleSpeedMax = 140f;
Stage.Level.BackgroundParticleGravity = 60f;
Stage.Level.BackgroundParticleSpawnRate = 12f;
Stage.Level.BackgroundParticleTrailLength = 0;
Stage.Level.BackgroundParticleTrailColor = null;
Stage.Level.BackgroundParticleTrailFade = true;
Stage.Level.BackgroundParticleImpactEnabled = false;
Stage.Level.BackgroundParticleImpactCountMin = 2;
Stage.Level.BackgroundParticleImpactCountMax = 4;
Stage.Level.BackgroundParticleImpactSpeedMin = 15f;
Stage.Level.BackgroundParticleImpactSpeedMax = 40f;
Stage.Level.BackgroundParticleImpactSizeMin = 1;
Stage.Level.BackgroundParticleImpactSizeMax = 2;
Stage.Level.BackgroundParticleImpactGravity = 150f;
Stage.Level.BackgroundParticleImpactAngleRange = 60f;
Stage.Level.BackgroundParticleImpactColor = null;
RefreshEnvironment( rebuildSchematic: true );
}
private void AdjustEnvironmentBrightness( float factor )
{
if ( Stage is null ) return;
Stage.Level.WallColor = ScaleSrgb( Stage.Level.WallColor ?? DefaultWallColor, factor );
Stage.Level.OutOfBoundsColor = ScaleSrgb( Stage.Level.OutOfBoundsColor ?? DefaultOutOfBoundsColor, factor );
Stage.Level.CheckerboardColor = ScaleSrgb( Stage.Level.CheckerboardColor ?? DefaultCheckerboardColor, factor );
if ( Stage.Level.CheckerboardSecondColor.HasValue )
Stage.Level.CheckerboardSecondColor = ScaleSrgb( Stage.Level.CheckerboardSecondColor.Value, factor );
if ( Stage.Level.AlternatePlayfieldEnabled )
{
Stage.Level.AlternateCheckerboardColor = ScaleSrgb( Stage.Level.AlternateCheckerboardColor ?? DefaultCheckerboardColor, factor );
if ( Stage.Level.AlternateCheckerboardSecondColor.HasValue )
Stage.Level.AlternateCheckerboardSecondColor = ScaleSrgb( Stage.Level.AlternateCheckerboardSecondColor.Value, factor );
}
Stage.Level.BackgroundBlockColor = ScaleSrgb( Stage.Level.BackgroundBlockColor ?? DefaultBackgroundBlockColor, factor );
if ( Stage.Level.BackgroundBlockColors is { Count: 3 } colors )
{
for ( int i = 0; i < colors.Count; i++ )
colors[i] = ScaleSrgb( colors[i], factor );
}
RefreshEnvironment( rebuildSchematic: true );
}
private static Color ScaleSrgb( Color color, float factor ) => LevelCosmeticsRandomizer.ScaleSrgb( color, factor );
private void AdjustEnvironmentSaturation( float factor )
{
if ( Stage is null ) return;
Stage.Level.WallColor = ScaleSaturation( Stage.Level.WallColor ?? DefaultWallColor, factor );
Stage.Level.OutOfBoundsColor = ScaleSaturation( Stage.Level.OutOfBoundsColor ?? DefaultOutOfBoundsColor, factor );
Stage.Level.CheckerboardColor = ScaleSaturation( Stage.Level.CheckerboardColor ?? DefaultCheckerboardColor, factor );
if ( Stage.Level.CheckerboardSecondColor.HasValue )
Stage.Level.CheckerboardSecondColor = ScaleSaturation( Stage.Level.CheckerboardSecondColor.Value, factor );
if ( Stage.Level.AlternatePlayfieldEnabled )
{
Stage.Level.AlternateCheckerboardColor = ScaleSaturation( Stage.Level.AlternateCheckerboardColor ?? DefaultCheckerboardColor, factor );
if ( Stage.Level.AlternateCheckerboardSecondColor.HasValue )
Stage.Level.AlternateCheckerboardSecondColor = ScaleSaturation( Stage.Level.AlternateCheckerboardSecondColor.Value, factor );
}
Stage.Level.BackgroundBlockColor = ScaleSaturation( Stage.Level.BackgroundBlockColor ?? DefaultBackgroundBlockColor, factor );
if ( Stage.Level.BackgroundBlockColors is { Count: 3 } colors )
{
for ( int i = 0; i < colors.Count; i++ )
colors[i] = ScaleSaturation( colors[i], factor );
}
RefreshEnvironment( rebuildSchematic: true );
}
private static Color ScaleSaturation( Color color, float factor ) => LevelCosmeticsRandomizer.ScaleSaturation( color, factor );
private void RandomizeEnvironmentColors()
{
if ( Stage is null ) return;
LevelCosmeticsRandomizer.RollColors( _environmentRandom, Stage.Level.AlternatePlayfieldEnabled, BgUse3() ).ApplyTo( Stage.Level );
RefreshEnvironment( rebuildSchematic: true );
}
private void RandomizeEnvironmentPattern()
{
if ( Stage is null ) return;
LevelCosmeticsRandomizer.RollPattern( _environmentRandom, Stage.Level.AlternatePlayfieldEnabled ).ApplyTo( Stage.Level );
RefreshEnvironment();
}
private string BgScaleStr() => $"{Stage.Level.BackgroundBlockScale:0.00}x";
private void SetBgScale( float value )
{
Stage.Level.BackgroundBlockScale = Math.Clamp( value, 0.25f, 1.5f );
RefreshEnvironment();
}
private string BgDensityStr() => $"{Stage.Level.BackgroundBlockDensity:0.00}x";
private string BgOpacityStr() => $"{Stage.Level.BackgroundBlockOpacity:0.00}";
private string BgSpeedStr() => $"{Stage.Level.BackgroundDriftSpeed:0.0}x";
private void SetBgDensity( float value )
{
Stage.Level.BackgroundBlockDensity = Math.Clamp( value, 0.25f, 2f );
RefreshEnvironment();
}
private void SetBgOpacity( float value )
{
Stage.Level.BackgroundBlockOpacity = Math.Clamp( value, 0f, 1f );
RefreshEnvironment();
}
private void SetBgSpeed( float value )
{
Stage.Level.BackgroundDriftSpeed = Math.Clamp( value, 0f, 3f );
RefreshEnvironment();
}
private void CycleBgBias( int delta )
{
var values = Enum.GetValues<BackgroundDriftBias>();
int index = Array.IndexOf( values, Stage.Level.BackgroundDriftBias );
Stage.Level.BackgroundDriftBias = values[(index + delta + values.Length) % values.Length];
RefreshEnvironment();
}
// ── background particle ambience ─────────────────────────────────────────────
// These setters deliberately use Stage.RefreshBackgroundParticles() (settings re-read only, live
// squares keep flying) instead of RefreshEnvironment(), which would re-roll every drifting
// background block's position on each slider tick.
private enum ParticleSetting
{
Opacity, SizeMin, SizeMax, Angle, AngleRange, SpeedMin, SpeedMax, Gravity, Rate, TrailLength,
ImpactCountMin, ImpactCountMax, ImpactSpeedMin, ImpactSpeedMax, ImpactSizeMin, ImpactSizeMax,
ImpactGravity, ImpactAngleRange,
}
private void ToggleBackgroundParticles()
{
Stage.Level.BackgroundParticlesEnabled = !Stage.Level.BackgroundParticlesEnabled;
Stage.RefreshBackgroundParticles();
}
private void ToggleParticleImpact()
{
Stage.Level.BackgroundParticleImpactEnabled = !Stage.Level.BackgroundParticleImpactEnabled;
Stage.RefreshBackgroundParticles();
}
private static readonly Direction[] ParticleEdges = { Direction.Up, Direction.Down, Direction.Left, Direction.Right };
private void CycleParticleEdge( int delta )
{
int index = Array.IndexOf( ParticleEdges, Stage.Level.BackgroundParticleEdge );
if ( index < 0 ) index = 0;
Stage.Level.BackgroundParticleEdge = ParticleEdges[(index + delta + ParticleEdges.Length) % ParticleEdges.Length];
Stage.RefreshBackgroundParticles();
}
private string ParticleEdgeLabel() => Stage.Level.BackgroundParticleEdge switch
{
Direction.Down => "Bottom",
Direction.Left => "Left",
Direction.Right => "Right",
_ => "Top",
};
/// <summary>0 = particle, 1 = trail, 2 = impact. Blank clears back to the inherit-default.</summary>
private void SetParticleCol( int which, string v )
{
if ( !string.IsNullOrWhiteSpace( v ) && !TryParseCol( v, out _ ) ) return;
Color? col = string.IsNullOrWhiteSpace( v ) ? null : ParseCol( v );
switch ( which )
{
case 0: Stage.Level.BackgroundParticleColor = col; break;
case 1: Stage.Level.BackgroundParticleTrailColor = col; break;
case 2: Stage.Level.BackgroundParticleImpactColor = col; break;
}
Stage.RefreshBackgroundParticles();
}
private void SetParticleTrailFade( bool fade )
{
Stage.Level.BackgroundParticleTrailFade = fade;
Stage.RefreshBackgroundParticles();
}
private void SetParticleValue( ParticleSetting which, float v )
{
var level = Stage.Level;
switch ( which )
{
case ParticleSetting.Opacity: level.BackgroundParticleOpacity = Math.Clamp( v, 0.05f, 1f ); break;
// The size/speed pairs keep min <= max by dragging the other end along.
case ParticleSetting.SizeMin:
level.BackgroundParticleSizeMin = Math.Clamp( (int)MathF.Round( v ), 1, 8 );
level.BackgroundParticleSizeMax = Math.Max( level.BackgroundParticleSizeMax, level.BackgroundParticleSizeMin );
break;
case ParticleSetting.SizeMax:
level.BackgroundParticleSizeMax = Math.Clamp( (int)MathF.Round( v ), 1, 8 );
level.BackgroundParticleSizeMin = Math.Min( level.BackgroundParticleSizeMin, level.BackgroundParticleSizeMax );
break;
case ParticleSetting.Angle: level.BackgroundParticleAngle = Math.Clamp( v, -75f, 75f ); break;
case ParticleSetting.AngleRange: level.BackgroundParticleAngleRange = Math.Clamp( v, 0f, 90f ); break;
case ParticleSetting.SpeedMin:
level.BackgroundParticleSpeedMin = Math.Clamp( v, 5f, 300f );
level.BackgroundParticleSpeedMax = Math.Max( level.BackgroundParticleSpeedMax, level.BackgroundParticleSpeedMin );
break;
case ParticleSetting.SpeedMax:
level.BackgroundParticleSpeedMax = Math.Clamp( v, 5f, 300f );
level.BackgroundParticleSpeedMin = Math.Min( level.BackgroundParticleSpeedMin, level.BackgroundParticleSpeedMax );
break;
case ParticleSetting.Gravity: level.BackgroundParticleGravity = Math.Clamp( v, -400f, 400f ); break;
case ParticleSetting.Rate: level.BackgroundParticleSpawnRate = Math.Clamp( v, 0f, 60f ); break;
case ParticleSetting.TrailLength: level.BackgroundParticleTrailLength = Math.Clamp( (int)MathF.Round( v ), 0, 8 ); break;
case ParticleSetting.ImpactCountMin:
level.BackgroundParticleImpactCountMin = Math.Clamp( (int)MathF.Round( v ), 0, 10 );
level.BackgroundParticleImpactCountMax = Math.Max( level.BackgroundParticleImpactCountMax, level.BackgroundParticleImpactCountMin );
break;
case ParticleSetting.ImpactCountMax:
level.BackgroundParticleImpactCountMax = Math.Clamp( (int)MathF.Round( v ), 0, 10 );
level.BackgroundParticleImpactCountMin = Math.Min( level.BackgroundParticleImpactCountMin, level.BackgroundParticleImpactCountMax );
break;
case ParticleSetting.ImpactSpeedMin:
level.BackgroundParticleImpactSpeedMin = Math.Clamp( v, 5f, 150f );
level.BackgroundParticleImpactSpeedMax = Math.Max( level.BackgroundParticleImpactSpeedMax, level.BackgroundParticleImpactSpeedMin );
break;
case ParticleSetting.ImpactSpeedMax:
level.BackgroundParticleImpactSpeedMax = Math.Clamp( v, 5f, 150f );
level.BackgroundParticleImpactSpeedMin = Math.Min( level.BackgroundParticleImpactSpeedMin, level.BackgroundParticleImpactSpeedMax );
break;
case ParticleSetting.ImpactSizeMin:
level.BackgroundParticleImpactSizeMin = Math.Clamp( (int)MathF.Round( v ), 1, 8 );
level.BackgroundParticleImpactSizeMax = Math.Max( level.BackgroundParticleImpactSizeMax, level.BackgroundParticleImpactSizeMin );
break;
case ParticleSetting.ImpactSizeMax:
level.BackgroundParticleImpactSizeMax = Math.Clamp( (int)MathF.Round( v ), 1, 8 );
level.BackgroundParticleImpactSizeMin = Math.Min( level.BackgroundParticleImpactSizeMin, level.BackgroundParticleImpactSizeMax );
break;
case ParticleSetting.ImpactGravity: level.BackgroundParticleImpactGravity = Math.Clamp( v, -400f, 400f ); break;
case ParticleSetting.ImpactAngleRange: level.BackgroundParticleImpactAngleRange = Math.Clamp( v, 0f, 90f ); break;
}
Stage.RefreshBackgroundParticles();
}
private void ToggleAlternatePlayfield()
{
bool enabled = !Stage.Level.AlternatePlayfieldEnabled;
Stage.Level.AlternatePlayfieldEnabled = enabled;
if ( enabled && !Stage.Level.AlternateCheckerboardColor.HasValue )
{
Stage.Level.AlternateCheckerboardColor = ScaleSrgb( Stage.Level.CheckerboardColor ?? DefaultCheckerboardColor, 0.72f );
Stage.Level.AlternatePlayfieldPattern = PlayfieldPattern.HorizontalStripes;
Stage.Level.AlternatePlayfieldCellScale = Math.Max( 2, Stage.Level.PlayfieldCellScale );
}
if ( !enabled && Stage.Tool == LevelEditorStage.EditTool.AlternatePattern )
Stage.Tool = LevelEditorStage.EditTool.Select;
RefreshEnvironment( rebuildSchematic: true );
}
private void CyclePattern( int delta, bool alternate )
{
var values = Enum.GetValues<PlayfieldPattern>();
PlayfieldPattern current = alternate ? Stage.Level.AlternatePlayfieldPattern : Stage.Level.PlayfieldPattern;
PlayfieldPattern next = values[(Array.IndexOf( values, current ) + delta + values.Length) % values.Length];
if ( alternate )
{
Stage.Level.AlternatePlayfieldPattern = next;
if ( next == PlayfieldPattern.Custom && Stage.Level.AlternatePlayfieldPatternRows is null )
Stage.Level.AlternatePlayfieldPatternRows = new System.Collections.Generic.List<string> { "10", "01" };
}
else
{
Stage.Level.PlayfieldPattern = next;
if ( next == PlayfieldPattern.Custom && Stage.Level.PlayfieldPatternRows is null )
Stage.Level.PlayfieldPatternRows = new System.Collections.Generic.List<string> { "10", "01" };
}
RefreshEnvironment();
}
private void SetCellScale( float value, bool alternate )
{
int scale = Math.Clamp( (int)MathF.Round( value ), 1, 8 );
if ( alternate ) Stage.Level.AlternatePlayfieldCellScale = scale;
else Stage.Level.PlayfieldCellScale = scale;
RefreshEnvironment();
}
private System.Collections.Generic.IReadOnlyList<string> PatternRows( bool alternate )
{
if ( alternate )
return Stage.Level.AlternatePlayfieldPatternRows is { } alternateRows ? alternateRows : Array.Empty<string>();
return Stage.Level.PlayfieldPatternRows is { } rows ? rows : Array.Empty<string>();
}
private int PatternWidth( bool alternate )
{
var rows = PatternRows( alternate );
return rows.Count == 0 ? 2 : Math.Clamp( rows.Max( row => row?.Length ?? 0 ), 1, 8 );
}
private int PatternHeight( bool alternate )
=> Math.Clamp( PatternRows( alternate ).Count, 1, 8 );
private void SetPatternWidth( float value, bool alternate )
=> ResizePattern( (int)MathF.Round( value ), PatternHeight( alternate ), alternate );
private void SetPatternHeight( float value, bool alternate )
=> ResizePattern( PatternWidth( alternate ), (int)MathF.Round( value ), alternate );
private void PaintPrimaryPatternCell( int row, int column, bool filled )
=> SetPatternCell( row, column, alternate: false, filled );
private void PaintAlternatePatternCell( int row, int column, bool filled )
=> SetPatternCell( row, column, alternate: true, filled );
// Normalize first, then test against what we're about to write: the grid can paint from a Rows
// snapshot one frame behind the live level, so the "already that value?" check has to read the same
// rectangular rows the write does — and a stale coordinate must miss the bounds, not index past them.
private void SetPatternCell( int row, int column, bool alternate, bool filled )
{
var rows = ResizePatternRows( PatternWidth( alternate ), PatternHeight( alternate ), alternate );
if ( row < 0 || row >= rows.Count || column < 0 || column >= rows[row].Length ) return;
if ( (rows[row][column] == '1') == filled ) return;
var cells = rows[row].ToCharArray();
cells[column] = filled ? '1' : '0';
rows[row] = new string( cells );
SetPatternRows( rows, alternate );
}
private void ResizePattern( int width, int height, bool alternate )
=> SetPatternRows( ResizePatternRows( width, height, alternate ), alternate );
private System.Collections.Generic.List<string> ResizePatternRows( int width, int height, bool alternate )
{
width = Math.Clamp( width, 1, 8 );
height = Math.Clamp( height, 1, 8 );
var source = PatternRows( alternate );
var rows = new System.Collections.Generic.List<string>( height );
for ( int row = 0; row < height; row++ )
{
string current = row < source.Count ? source[row] ?? "" : "";
string normalized = new string( current.Select( cell => cell == '1' ? '1' : '0' ).Take( width ).ToArray() );
rows.Add( normalized.PadRight( width, '0' ) );
}
return rows;
}
private void SetPatternRows( System.Collections.Generic.List<string> rows, bool alternate )
{
if ( alternate ) Stage.Level.AlternatePlayfieldPatternRows = rows;
else Stage.Level.PlayfieldPatternRows = rows;
RefreshEnvironment();
StateHasChanged();
}
private void RefreshEnvironment( bool rebuildSchematic = false )
{
Stage.RefreshEnvironmentColors();
if ( rebuildSchematic ) Stage.Rebuild();
}
private string Bg3Str( int i )
=> Stage.Level.BackgroundBlockColors is { Count: 3 } list ? ColStr( list[i] ) : "";
private void SetBg3( int i, string v )
{
if ( Stage.Level.BackgroundBlockColors is not { Count: 3 } list ) return;
if ( !TryParseCol( v, out var color ) ) return;
list[i] = color;
RefreshEnvironment();
}
// ── import / export ──────────────────────────────────────────────────────────
private void DoLoad()
{
if ( !string.IsNullOrWhiteSpace( _loadId ) )
{
var id = _loadId.Trim();
AfterEditorFieldBlur( () => Stage.LoadFromLevel( id ) );
}
}
private void DoImport() => AfterEditorFieldBlur( () => Stage.ImportText( _pasteText ) );
private void DoSaveTemplate()
{
Stage.SaveDailyTemplate();
ClearTemplateNameTyped(); // the name is this document's own template now, not a collision
}
private void DoLoadTemplate()
=> AfterEditorFieldBlur( () => { Stage.LoadDailyTemplate(); ClearTemplateNameTyped(); } );
private void OnTemplateName( string v )
{
Stage.Level.DailyTemplateName = v ?? "";
_templateDeleteConfirming = false; // a pending delete must not survive a name change
_templateNameTyped = true;
// Live dupe tint: the HUD doesn't rebuild per keystroke (BuildHash doesn't sample this text),
// so flip the class directly on the entry; the markup class covers initial render.
TemplateNameEntry?.SetClass( "template-dupe", TemplateNameIsDupe );
RefreshTemplateButtons();
}
private void BeginDeleteTemplate()
{
_templateDeleteConfirming = true;
StateHasChanged();
}
private void CancelDeleteTemplate()
{
_templateDeleteConfirming = false;
StateHasChanged();
}
private void ConfirmDeleteTemplate()
{
_templateDeleteConfirming = false;
Stage?.DeleteDailyTemplate();
TemplateNameEntry?.SetClass( "template-dupe", TemplateNameIsDupe ); // gone = no longer a dupe
StateHasChanged();
}
private void ReloadEditorContent()
{
Sandbox.ConsoleSystem.Run( "reload_characters" );
Sandbox.ConsoleSystem.Run( "reload_map" );
Sandbox.ConsoleSystem.Run( "daily_clear_cache" );
}
private void OnTemplateWeight( string v )
{
if ( float.TryParse( v, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out float weight ) )
Stage.Level.DailyTemplateWeight = Math.Max( 0f, weight );
}
private int EnvironmentHash() => Stage is null ? 0 : HashCode.Combine(
HashCode.Combine( Stage.Level.BackgroundBlockScale, Stage.Level.BackgroundBlockDensity, Stage.Level.BackgroundBlockOpacity,
Stage.Level.BackgroundDriftSpeed, Stage.Level.BackgroundDriftBias, Stage.Level.PlayfieldPattern, Stage.Level.PlayfieldCellScale,
PatternRowsHash( false ) ),
HashCode.Combine( Stage.Level.AlternatePlayfieldEnabled, Stage.Level.AlternatePlayfieldPattern,
Stage.Level.AlternatePlayfieldCellScale, Stage.Level.AlternatePlayfieldRects.Count, PatternRowsHash( true ) ),
ParticleSettingsHash(),
Stage.Level.MusicVolume );
private int ParticleSettingsHash()
{
var h = new HashCode();
h.Add( Stage.Level.BackgroundParticlesEnabled );
h.Add( Stage.Level.BackgroundParticleEdge );
h.Add( Stage.Level.BackgroundParticleOpacity );
h.Add( Stage.Level.BackgroundParticleSizeMin );
h.Add( Stage.Level.BackgroundParticleSizeMax );
h.Add( Stage.Level.BackgroundParticleAngle );
h.Add( Stage.Level.BackgroundParticleAngleRange );
h.Add( Stage.Level.BackgroundParticleSpeedMin );
h.Add( Stage.Level.BackgroundParticleSpeedMax );
h.Add( Stage.Level.BackgroundParticleGravity );
h.Add( Stage.Level.BackgroundParticleSpawnRate );
h.Add( Stage.Level.BackgroundParticleTrailLength );
h.Add( Stage.Level.BackgroundParticleTrailFade );
h.Add( Stage.Level.BackgroundParticleImpactEnabled );
h.Add( Stage.Level.BackgroundParticleImpactCountMin );
h.Add( Stage.Level.BackgroundParticleImpactCountMax );
h.Add( Stage.Level.BackgroundParticleImpactSpeedMin );
h.Add( Stage.Level.BackgroundParticleImpactSpeedMax );
h.Add( Stage.Level.BackgroundParticleImpactSizeMin );
h.Add( Stage.Level.BackgroundParticleImpactSizeMax );
h.Add( Stage.Level.BackgroundParticleImpactGravity );
h.Add( Stage.Level.BackgroundParticleImpactAngleRange );
return h.ToHashCode();
}
private int PatternRowsHash( bool alternate )
{
var hash = new HashCode();
foreach ( string row in PatternRows( alternate ) ) hash.Add( row );
return hash.ToHashCode();
}
protected override int BuildHash() => System.HashCode.Combine(
Stage?.Tool,
HashCode.Combine( Stage?.SelectedKind, Stage?.SelectedIndex, Stage?.SelectionCount, Stage?.Level.UsePool, Stage?.Level.BlockCount, Stage?.Level.IsTest, _deleteConfirming, _templateDeleteConfirming ),
HashCode.Combine( Stage?.Level.Blocks.Count, Stage?.Level.Pool.Count, Stage?.Level.Obstacles.Count, Stage?.Level.SpawnPositions.Count, Stage?.Level.VisionObstacles.Count, Stage?.Level.ShuffleTypes, Stage?.SelectedObstacleBlocksVision, HashCode.Combine( BgUse3(), Stage?.Level.FenceObstacles.Count, Stage?.SelectedObstacleIsFence, Stage?.Level.GlassObstacles.Count, Stage?.SelectedObstacleIsGlass ) ),
BlocksConfigHash(),
HashCode.Combine( HashCode.Combine( EditorPrefs.Current.ShowGrid, EditorPrefs.Current.SnapEnabled, Stage?.SnapInvertHeld, EditorPrefs.Current.GridSize,
EditorPrefs.Current.GridOpacity, EditorPrefs.Current.GridSmart ), EditorPrefs.Current.GridCenterCell,
EditorPrefs.Current.GridBottomLeftOrigin, EditorPrefs.Current.GridOffsetX, EditorPrefs.Current.GridOffsetY,
Stage?.MirrorX, Stage?.MirrorY, Stage?.SelectionIsObstacles ),
Stage?.LastError,
HashCode.Combine( DimHash(), _browserOpen, Stage?.LevelRevision, EnvironmentHash(), Stage?.PublishStatus, Stage?.Level.ProjectSourceId ),
HashCode.Combine( SpikeSizeWarning(), Stage?.Level.StartsComplete() ) );
}