UI/LevelBrowserOverlay.razor
@using System
@using System.Linq
@using System.Collections.Generic
@using Sandbox
@using Sandbox.UI
@inherits Panel
@namespace BlockParty
@attribute [StyleSheet( "LevelBrowserOverlay.razor.scss" )]
@* Shared level-browser overlay. Editor mode offers four toggleable, locally-persisted views:
1. MAP — only the mapped levels, laid out in the true level-select map layout with connector
lines (scrollable + zoomable via the search row's zoom bar), every node shown unlocked.
2. OTHER — unflagged levels not present on the map.
3. TEST — test-flagged levels not present on the map.
4. TEMPLATES — the daily-challenge obstacle templates, previewed in the CURRENT editor level's
colour scheme (a pick keeps that scheme too — templates are layout-only). Sortable
A-Z / newest first / highest weight first; a pick loads the template into the editor.
The Other/Test views can be sorted alphabetically or by newest file first.
Leaderboard mode always uses the map view. Clicking a level reports its id and closes the overlay.
The map view also has an EDIT mode (editor picker + Game.IsEditor only — never Leaderboard mode):
clicking a node replaces its level, + buttons on empty grid cells adjacent to a node add a new
side level there, and + buttons ON the connection lines insert a level between two nodes (the
nodes beyond shift over to make room). Each opens an inner picker (the same tile grid, over
unmapped levels); the pick is written to Assets/levelmap.json by the editor-assembly map writer
and the map hot-rebuilds. *@
<root class="level-browser">
@* Opaque backdrop: clicking it (outside the window) backs out of the edit picker, else dismisses
the overlay. *@
<div class="backdrop" onclick=@Click( BackdropClicked )></div>
<PixelTooltip @ref="TooltipLayer"></PixelTooltip>
<div class="window">
<div class="topbar">
<label class="title">@TitleText</label>
@if ( _editAction != EditAction.None )
{
@* Inner edit picker: choose which unmapped pool to pick from, or back out to the map. *@
<div class="modes">
<button class="mode @(!_editPickTest ? "on" : "")" onclick=@Click( () => SetEditPickTest( false ) )
onmouseover=@Tip( "Pick from unmapped normal levels" )>Other</button>
<button class="mode @(_editPickTest ? "on" : "")" onclick=@Click( () => SetEditPickTest( true ) )
onmouseover=@Tip( "Pick from unmapped test levels" )>Test</button>
<button class="mode back" onclick=@Click( CancelEdit )
onmouseover=@Tip( "Back to the map" )>Back</button>
</div>
}
else if ( Mode == PickerMode.Editor && Game.IsEditor )
{
@* Dev views: the map plus unmapped project levels, test levels and daily templates.
A standalone build shows NO tabs — the picker is always the grid of the player's
local levels (see CurrentView). *@
<div class="modes">
<button class="mode @(CurrentView == BrowserView.Map ? "on" : "")" onclick=@Click( () => SetMode( BrowserView.Map ) )
onmouseover=@Tip( "Map levels" )>Map</button>
<button class="mode @(CurrentView == BrowserView.Other ? "on" : "")" onclick=@Click( () => SetMode( BrowserView.Other ) )
onmouseover=@Tip( "Levels not on map" )>Other</button>
<button class="mode @(CurrentView == BrowserView.Test ? "on" : "")" onclick=@Click( () => SetMode( BrowserView.Test ) )
onmouseover=@Tip( "Test levels" )>Test</button>
<button class="mode @(CurrentView == BrowserView.Templates ? "on" : "")" onclick=@Click( () => SetMode( BrowserView.Templates ) )
onmouseover=@Tip( "Daily-challenge obstacle templates" )>Templates</button>
@if ( MapMode )
{
<button class="mode edit @(_editMode ? "on" : "")" onclick=@Click( ToggleEditMode )
onmouseover=@Tip( () => _editMode
? "Stop editing map"
: "Edit map" )>Edit</button>
}
</div>
}
<button class="close" onclick=@Click( Close )>
<div class="icon">close</div>
</button>
</div>
@* Search row (every view, both picker modes): a fixed-size filter box at the top-left under
the title, live case-insensitive substring match on level name or id (templates: id only).
Typing a character's exact id or display name ALSO matches levels forcing that character
(and tints the text); in the editor picker a block type's exact name ("sad", "wind") likewise
matches levels spawning that block (its own tint — character wins when a name is both, e.g.
"mimic"). A red X clears it. The sort toggle shares this row on the sortable grid views. *@
<div class="searchbar">
<div class="search-box">
<EditorTextEntry class=@("search-entry" + ( SearchCharacter is not null ? " character-match" : SearchBlockType is not null ? " block-match" : "" )) Value=@SearchText Placeholder="SEARCH"
OnTextEdited=@((string v) => OnSearchEdited( v )) @ref="SearchEntry"></EditorTextEntry>
@if ( HasSearch )
{
<button class="search-clear" onclick=@Click( ClearSearchBox )
onmouseover=@Tip( "Clear search" )><div class="icon">close</div></button>
}
</div>
@if ( ZoomVisible )
{
@* Map-mode zoom bar: scales the whole map layout so it can be taken in at once (out)
or read block-by-block (in). Shares the search row's right side with the sort
toggle — never both at once (the sort only shows on grid views). *@
<div class="zoombar">
<button class="zoom-btn" onclick=@Click( () => SetZoom( MapZoom - ZOOM_STEP ) )
onmouseover=@Tip( "Zoom the map out" )><div class="icon">remove</div></button>
<PixelSlider Compact=@true Min=@(ZOOM_MIN * 100f) Max=@(ZOOM_MAX * 100f) Step=@(5f)
Value=@(MapZoom * 100f) OnValueChanged=@((float v) => SetZoom( v / 100f ))></PixelSlider>
<button class="zoom-btn" onclick=@Click( () => SetZoom( MapZoom + ZOOM_STEP ) )
onmouseover=@Tip( "Zoom the map in" )><div class="icon">add</div></button>
<label class="zoom-label">@(( MapZoom * 100f ).ToString( "0" ))%</label>
</div>
}
else if ( SortVisible )
{
@if ( TemplatesVisible )
{
@* One button cycling the three template sorts (A-Z → Recent → Weight); shows the
CURRENT mode, like the Other/Test toggle beside it shows the current order. *@
<button class="sort on" onclick=@Click( CycleTemplateSort )
onmouseover=@Tip( () => TemplateSortTip )>@TemplateSortLabel</button>
}
else
{
<button class="sort on" onclick=@Click( ToggleRecentFirst )
onmouseover=@Tip( () => RecentFirst ? "Sorted newest first" : "Sorted alphabetically" )>@(RecentFirst ? "Recent" : "A-Z")</button>
}
}
</div>
@if ( GridVisible )
{
@* Windowed tiles (big grids only — see TileWindowingActive): every tile renders as a
fixed-size SHELL (face frame + caption), but the expensive face contents (the
LevelNodePreview subtree) only exist for shells inside the measured scroll window
(see UpdateTileWindow) — the engine tick/render walks visit every live panel every
frame, so panel COUNT is the cost, not what's drawn. *@
<div class="grid" @ref="Grid">
@{ int tileIndex = 0; }
@if ( TemplatesVisible )
{
@foreach ( var template in GridTemplates )
{
var t = template;
bool tileVisible = TileBodyVisible( tileIndex++ );
<div class="tile" onclick=@Click( () => TemplatePicked( t.Id ), SfxType.MenuStart )
onmouseover=@Tip( () => TemplateTip( t ) )>
<div class="face">
@if ( tileVisible )
{
<LevelNodePreview Level=@TemplateDef( t ) CullOffscreen=@true></LevelNodePreview>
@* Pick weight in the preview's top-right corner (kept off the name line);
mirror-capable templates (AllowMirrorX) get a swap glyph in the top-left;
always-spiked templates (AuthoredSpikes) get a spike glyph in the bottom-right;
templates with too few free spawn slots for the biggest daily block roll
get a red warning in the bottom-left. *@
<label class="weight">@t.Weight</label>
@if ( t.AllowMirrorX )
{
<label class="mirror">swap_horiz</label>
}
@if ( t.AuthoredSpikes )
{
<label class="authored-spikes">change_history</label>
}
@if ( FreeSpawnSlots( t ) < MaxDailyBlockCount )
{
<label class="slots-warning">warning</label>
}
}
</div>
<div class="caption">
<label class="name">@t.Id</label>
</div>
</div>
}
}
else
{
@foreach ( var lvl in GridLevels )
{
var l = lvl;
bool tileVisible = TileBodyVisible( tileIndex++ );
@* Editor picker only: hovering a level tile names its ID (the caption shows the
display name). The leaderboard picker's search grid stays tooltip-free. *@
<div class="tile" onclick=@Click( () => TilePicked( l.Id ), SfxType.MenuStart )
onmouseover=@Tip( Mode == PickerMode.Editor ? l.Id : null )>
<div class="face">
@if ( tileVisible )
{
<LevelNodePreview Level=@l CullOffscreen=@true></LevelNodePreview>
@if ( Characters.TryGet( l.ForcedCharacterId, out var forcedCharacter ) )
{
<div class="tile-forced-character @(forcedCharacter.Partner is null ? "" : "paired")" style="@TileForcedCharacterStyle( forcedCharacter )">
<div class="icon-glow"></div>
@if ( forcedCharacter.Partner is CharacterDef tilePartner )
{
<div class="sprite rear" style="@ForcedCharacterSpriteStyle( tilePartner )"></div>
}
<div class="sprite front" style="@ForcedCharacterSpriteStyle( forcedCharacter )"></div>
</div>
}
}
</div>
<div class="caption">
<label class="name" style="@GridNameStyle( l.Name )">@l.Name</label>
</div>
</div>
}
}
@if ( NoResults )
{
<div class="no-results"><label>NO RESULTS</label></div>
}
</div>
}
else
{
<div class="map-scroll" @ref="MapScroll">
<div class="map" style="@MapCanvasStyle()" @ref="MapCanvas">
@* Connector lines first so the opaque node squares draw over them. *@
@foreach ( var conn in LevelMap.Connections )
{
<div class="line" style="@LineStyle( conn )"></div>
}
@{ int mapNodeIndex = 0; }
@foreach ( var node in LevelMap.Nodes )
{
var n = node;
bool previewVisible = _mapPreviewWindow.Contains( mapNodeIndex++ );
@* Map-node tooltip: EDIT mode explains the swap; otherwise the EDITOR picker
shows the node's level ID (the label above shows the display name). The
leaderboard picker's map stays tooltip-free. *@
<div class="node-wrap @(n.IsMajor ? "major" : "side")" style="@NodeStyle( n )"
onclick=@Click( () => NodeClicked( n ), SfxType.MenuStart )
onmouseover=@Tip( () => EditModeActive ? $"Swap {n.Name} out for another level"
: Mode == PickerMode.Editor ? n.LevelId : null )>
<div class="node-name" style="@NodeNameWrapStyle()"><label style="@NodeNameStyle( n )">@n.Name</label></div>
<div class="face" style="@NodeFaceStyle()">
@if ( previewVisible )
{
<LevelNodePreview [email protected] CullOffscreen=@true></LevelNodePreview>
}
@* Forced character as a small badge inside the preview's lower-left (same
presentation as the Other/Test tiles), not beside the node like the
level-select screen — keeps the map compact in both picker modes. *@
@if ( Characters.TryGet( n.Level?.ForcedCharacterId, out var forcedCharacter ) )
{
<div class="tile-forced-character @(forcedCharacter.Partner is null ? "" : "paired")" style="@NodeForcedCharacterStyle( n, forcedCharacter )">
<div class="icon-glow"></div>
@if ( forcedCharacter.Partner is CharacterDef nodePartner )
{
<div class="sprite rear" style="@ForcedCharacterSpriteStyle( nodePartner )"></div>
}
<div class="sprite front" style="@ForcedCharacterSpriteStyle( forcedCharacter )"></div>
</div>
}
@* Music-override badge in the preview's lower-right (editor map only), red when
another mapped level plays the same track. *@
@if ( MusicBadgesVisible && !string.IsNullOrWhiteSpace( n.Level?.Music ) )
{
<label class="music-badge @(MusicShared( n ) ? "dupe" : "")" style="@MusicIconStyle">music_note</label>
}
</div>
</div>
}
@* EDIT mode: + buttons sit near-flush against every empty side of every node — a
cell shared by several nodes gets one + on EACH, and the added level CONNECTS to
the + button's anchor node; plus one above the top major to extend the spine.
X buttons straddle the top-right corner of deletable nodes. *@
@if ( EditModeActive )
{
@foreach ( var cell in EmptyAdjacentCells() )
{
var c = cell;
<div class="add-cell" style="@AddCellStyle( c.Node, c.Dx, c.Dy )"
onclick=@Click( () => BeginAddSide( c.Node, c.X, c.Y ) )
onmouseover=@Tip( () => $"Add a side level here, connected to {c.Node.Name}" )>
<div class="icon" style="@AddIconStyle">add</div>
</div>
}
@if ( LevelMap.Nodes.LastOrDefault( m => m.IsMajor ) is MapNode topMajor )
{
<div class="add-cell" style="@AddCellStyle( topMajor, 0, 1 )"
onclick=@Click( BeginAddMajor )
onmouseover=@Tip( "Extend the spine with a new major level" )>
<div class="icon" style="@AddIconStyle">add</div>
</div>
}
@* + buttons ON the connection lines: insert a level BETWEEN the two nodes. A
spine segment inserts a new major (everything above shifts up); a side
connection shifts the outward half of the group one cell over and the new
level takes the vacated cell. Only shown where the insert will succeed. *@
@foreach ( var conn in LevelMap.Connections )
{
var c = conn;
if ( CanInsertOn( c ) )
{
<div class="add-cell insert" style="@InsertStyle( c )"
onclick=@Click( () => BeginInsert( c ) )
onmouseover=@Tip( () => InsertTip( c ) )>
<div class="icon" style="@AddIconStyle">add</div>
</div>
}
}
@foreach ( var node in LevelMap.Nodes )
{
var n = node;
if ( CanDeleteNode( n ) )
{
<div class="delete-node" style="@DeleteNodeStyle( n )"
onclick=@Click( () => DeleteNode( n ) )
onmouseover=@Tip( () => $"Take {n.Name} off the map (the level file is kept)" )>
<div class="icon" style="@DeleteIconStyle">close</div>
</div>
}
}
}
</div>
</div>
}
</div>
</root>
@code
{
public enum PickerMode { Editor, Leaderboard }
private enum BrowserView { Other, Map, Test, Templates }
private enum TemplateSortMode { AtoZ, Recent, Weight }
[Parameter] public PickerMode Mode { get; set; } = PickerMode.Editor;
[Parameter] public Action<string> OnPick { get; set; }
/// <summary>Editor picker only: a Templates-view pick reports the template id here instead of
/// <see cref="OnPick"/> (the HUD loads it into the open level).</summary>
[Parameter] public Action<string> OnPickTemplate { get; set; }
[Parameter] public Action OnClose { get; set; }
// Wraps a button action with a snappy UI click sound (mode toggles / close use the short MenuBlip,
// picking a level passes MenuStart). Returns the wrapped Action for onclick=@Click(...).
private static Action Click( Action a, SfxType sfx = SfxType.MenuBlip )
=> () => { Audio.PlaySfx( sfx, 0.7f ); a?.Invoke(); };
// ── hover tooltips (same pattern as LevelEditorHud) ───────────────────────────────────────────────
// Controls record themselves + their text via onmouseover=@Tip( "..." ); the Tick poll below just
// validates the stored panel is still hovered, so no mouseout handler is needed and text that
// depends on state (edit mode, sort order) is picked up on the next rebuild. A null/empty tip means
// "no tooltip here" — used by the map nodes, which only explain themselves in EDIT mode.
// Named TooltipLayer, not Tooltip — Panel already has a Tooltip member of its own.
private PixelTooltip TooltipLayer { get; set; }
private Panel _tipPanel;
private Func<string> _tipText;
/// <summary>Fixed tooltip text.</summary>
private 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 — mouseover only fires when the hovered panel changes, so clicking a
/// hovered toggle (Edit, the sort order) re-attaches this handler without re-running it and a
/// snapshot would keep describing the state just left. Any non-literal tip belongs here.</summary>
private Action<PanelEvent> Tip( 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 ) ) TooltipLayer?.Hide();
else TooltipLayer?.Show( text );
}
// The map-mode scroll region (null while in grid mode). Its offset is remembered for THIS play
// session on the GameManager (GameManager.LevelBrowserScroll), so moving between screens restores it,
// while a NEW play session (a fresh GameManager) opens centred + at the bottom.
private Panel MapScroll { get; set; }
private bool _scrollApplied;
private bool _recentTimestampsRefreshed;
private bool _templateTimestampsRefreshed;
// Re-apply the initial offset for a few frames once the viewport is laid out (not latched on the first
// valid frame) so a one-frame layout settle can't leave it half-applied.
private int _scrollSettleFrames;
private const int SCROLL_SETTLE_FRAMES = 4;
// Map-layout constants in 1080-reference px (this ScreenPanel uses AutoScreenScale=ConsistentHeight,
// so the same coordinate space as LevelSelectScreen — node positions/sizes carry over 1:1 at
// MapZoom 1; the zoom bar multiplies everything below through the layout maths).
private const float MAJOR_SIZE = 132f;
private const float SIDE_SIZE = 84f;
private const float LINE_THICKNESS = 10f;
// Forced-character badge base sizes: grid tiles (150px) and map nodes (132/84px), scaled to fit.
private const float TILE_CHAR_SIZE = 40f;
private const float MAJOR_NODE_CHAR_SIZE = 36f;
private const float SIDE_NODE_CHAR_SIZE = 26f;
// Padding around the map bounds so edge nodes (and their labels) aren't clipped by the scroll area.
private const float MAP_PAD = 120f;
// Map-mode zoom (editor picker only — the leaderboard map stays 1:1): one factor multiplied
// through ALL the layout maths (node sizes, spacing, lines, labels, edit buttons), 1 = the
// level-select scale. Out far enough to take in the whole map, in far enough to read a level's
// block types off its preview. Panel-local like the search string — the overlay unmounts on
// close, so every open starts back at 100% (deliberately NOT persisted).
private const float ZOOM_MIN = 0.4f;
private const float ZOOM_MAX = 2f;
private const float ZOOM_STEP = 0.1f;
private float _mapZoom = 1f;
private float MapZoom => Mode == PickerMode.Editor && Game.IsEditor ? _mapZoom : 1f;
// The zoom bar shows whenever the map LAYOUT itself is on screen: any grid takeover (the inner
// edit picker, a map-view search) hides it along with the map.
private bool ZoomVisible => Mode == PickerMode.Editor && Game.IsEditor && MapMode && !GridVisible;
/// <summary>Zoom-bar change: rescale the map around the viewport CENTRE — the centre's map point
/// is converted back to unzoomed map coords with the OLD factor, banked as the (zoom-1) session
/// scroll target, and applied by Tick's settle loop at the new zoom (a direct ScrollOffset write
/// would clamp against the not-yet-relaid-out canvas when zooming in).</summary>
private void SetZoom( float zoom )
{
zoom = Math.Clamp( zoom, ZOOM_MIN, ZOOM_MAX );
float oldZoom = MapZoom;
if ( MathF.Abs( zoom - oldZoom ) < 0.001f ) return;
var mgr = GameManager.Instance;
if ( MapScroll.IsValid() && mgr is not null )
{
float scale = MapScroll.ScaleFromScreen;
float viewW = MapScroll.Box.Rect.Width * scale;
float viewH = MapScroll.Box.Rect.Height * scale;
if ( scale > 0f && viewW > 1f )
{
var offset = MapScroll.ScrollOffset * scale; // screen px → reference px
mgr.LevelBrowserScroll = NormalizeScroll( offset, viewW, viewH, oldZoom );
mgr.LevelBrowserMapShown = true;
_scrollApplied = false;
_scrollSettleFrames = 0;
}
}
_mapZoom = zoom;
StateHasChanged();
}
// The session scroll bank (GameManager.LevelBrowserScroll) always holds the offset AS IF the map
// were at zoom 1 — really "which map point sits at the viewport centre". The zoom resets to 100%
// every open, so a raw zoomed offset would restore to the wrong part of the map; normalising at
// save time and re-projecting at restore time keeps the centred point fixed across zoom changes,
// remounts AND reopens alike.
private Vector2 NormalizeScroll( Vector2 offset, float viewW, float viewH, float zoom )
{
float centreX = ( offset.x + viewW * 0.5f - HalfSpanAt( zoom ) ) / zoom;
float centreY = ( offset.y + viewH * 0.5f - MapPad ) / zoom;
return new Vector2( centreX + HalfSpanAt( 1f ) - viewW * 0.5f, centreY + MapPad - viewH * 0.5f );
}
private Vector2 DenormalizeScroll( Vector2 banked, float viewW, float viewH, float zoom )
{
float centreX = banked.x + viewW * 0.5f - HalfSpanAt( 1f );
float centreY = banked.y + viewH * 0.5f - MapPad;
return new Vector2( centreX * zoom + HalfSpanAt( zoom ) - viewW * 0.5f, centreY * zoom + MapPad - viewH * 0.5f );
}
private BrowserView CurrentView
{
get
{
if ( Mode == PickerMode.Leaderboard )
return BrowserView.Map;
// The standalone editor picker has no tabs at all: it's ALWAYS the grid of the player's
// local levels (BrowserView.Other lists them there) — the map and the dev views are
// editor-only, whatever view preference an editor session may have persisted.
if ( !Game.IsEditor )
return BrowserView.Other;
return EditorPrefs.Current.LevelBrowserMapMode ? BrowserView.Map
: EditorPrefs.Current.LevelBrowserTemplatesMode ? BrowserView.Templates
: EditorPrefs.Current.LevelBrowserTestMode ? BrowserView.Test : BrowserView.Other;
}
}
private bool MapMode => CurrentView == BrowserView.Map;
private bool RecentFirst => EditorPrefs.Current.LevelBrowserOtherRecentFirst;
private TemplateSortMode TemplateSort
=> (TemplateSortMode)Math.Clamp( EditorPrefs.Current.LevelBrowserTemplateSort, 0, 2 );
// ── map EDIT mode (editor picker + Game.IsEditor only) ────────────────────────────────────────────
// _editMode toggles the map's edit affordances (node click = replace, + cells = add, + on a
// connection line = insert between). A pending action switches the window to the inner tile-grid
// picker over unmapped levels (Other/Test pools via _editPickTest); picking dispatches the
// editor-assembly map writer ConCmd, which rewrites Assets/levelmap.json and hot-rebuilds LevelMap.
private enum EditAction { None, Replace, Add, AddMajor, InsertMajor, InsertSide }
private bool _editMode;
private EditAction _editAction;
private string _editTargetId; // Replace: the node being replaced / InsertMajor: the major above the insertion
private string _editMajorId; // Add + InsertSide: the owning major...
private int _editCellX, _editCellY; // ...and the target cell (Add) or the connection's FROM cell (InsertSide)
private int _editDx, _editDy; // InsertSide: the connection's step from the FROM cell
private int _editFromDx, _editFromDy; // Add: step from the new cell toward the + button's anchor node (its predecessor)
private bool _editPickTest; // inner picker pool: Other (false) or Test (true)
private bool EditModeActive => _editMode && MapMode && Mode == PickerMode.Editor && Game.IsEditor
&& _editAction == EditAction.None && !SearchOverridesMap;
// The tile grid shows for the Other/Test/Templates views, for the edit picker (which overrides
// the map) AND for a map-view search (which lists the matching mapped levels as tiles — a map
// layout with filtered-out nodes would be full of holes). The edit picker always picks from the
// LEVEL pools, never the templates.
private bool GridVisible => _editAction != EditAction.None || !MapMode || SearchOverridesMap;
// Sorting applies to the unmapped Other/Test pools and the templates; the map's search grid keeps
// map order and the leaderboard picker has no sort at all.
private bool SortVisible => Mode == PickerMode.Editor && GridVisible && !SearchOverridesMap;
private bool TemplatesVisible => _editAction == EditAction.None && CurrentView == BrowserView.Templates;
// ── search filter (every view, both picker modes) ─────────────────────────────────────────────────
// Live, case-insensitive substring match on level NAME or ID (templates match on id only — their
// id is their display name; the LEADERBOARD picker matches names only, ids being dev-facing).
// The string lives on this panel, which unmounts on close — so every open (either picker mode)
// starts blank, including after picking a level. Within one open it survives tab switches and the
// map ↔ search-grid swap; leaving the inner edit picker and turning EDIT mode on clear it
// explicitly (see ClearSearchBox) so the map view can come back.
private string SearchText { get; set; } = "";
private EditorTextEntry SearchEntry { get; set; }
/// <summary>Clear the search filter — the state AND the box itself. Every clear (the red X, edit
/// picker exits, the EDIT toggle) comes through here. The state change routes through
/// <see cref="OnSearchEdited"/> so the map view's scroll bank/restore applies; the box then needs
/// stomping separately because TextEntry.Value refuses to apply while the entry HAS FOCUS (engine
/// behaviour: a bound value never stomps text mid-edit) and clicking a tile or button doesn't
/// blur it — .Text writes unconditionally, and the Blur stops the next keystroke re-filtering.</summary>
private void ClearSearchBox()
{
OnSearchEdited( "" );
if ( SearchEntry.IsValid() )
{
SearchEntry.Text = "";
SearchEntry.Blur();
}
}
private bool HasSearch => !string.IsNullOrEmpty( SearchText );
/// <summary>Searching the map view swaps the map layout for a plain tile grid of the MATCHING
/// mapped levels; clearing the box restores the map (and its banked scroll offset).</summary>
private bool SearchOverridesMap => MapMode && _editAction == EditAction.None && HasSearch;
/// <summary>The character the search text names EXACTLY (case-insensitive id or display name),
/// or null. While live, levels forcing that character match too (both picker modes) and the
/// entry text tints via .character-match.</summary>
private CharacterDef SearchCharacter
=> Characters.TryGetByIdOrName( SearchText, out var character ) ? character : null;
/// <summary>The block type the search text names EXACTLY (case-insensitive enum name — "sad",
/// "wind", "straightlaser"), or null. While live, levels spawning that block (exact list, pool
/// or pinned) match too and the entry text tints via .block-match. EDITOR picker only — type
/// names are dev-facing, like ids.</summary>
private BlockType? SearchBlockType
{
get
{
if ( Mode != PickerMode.Editor || !HasSearch ) return null;
foreach ( var type in System.Enum.GetValues<BlockType>() )
if ( string.Equals( type.ToString(), SearchText.Trim(), StringComparison.OrdinalIgnoreCase ) )
return type;
return null;
}
}
/// <summary>Whether the level can spawn the block type: in its exact list, its random pool, or
/// pinned to a spawn slot.</summary>
private static bool LevelHasBlock( LevelDef l, BlockType type )
=> ( l.Blocks?.Contains( type ) ?? false )
|| ( l.Pool?.Contains( type ) ?? false )
|| ( l.SpawnPins?.Any( p => p is not null && p.Type == type ) ?? false );
// Ids only count for the EDITOR picker — they're dev-facing, so the leaderboard picker matches
// display names alone (a player searching "cave" shouldn't hit a level whose ID happens to match).
private bool MatchesSearch( LevelDef l ) => !HasSearch
|| ( l.Name?.Contains( SearchText, StringComparison.OrdinalIgnoreCase ) ?? false )
|| ( Mode == PickerMode.Editor && ( l.Id?.Contains( SearchText, StringComparison.OrdinalIgnoreCase ) ?? false ) )
|| ( SearchCharacter is not null && l.ForcedCharacterId == SearchCharacter.Id )
|| ( SearchBlockType is BlockType blockType && LevelHasBlock( l, blockType ) );
private bool MatchesSearch( string id )
=> !HasSearch || ( id?.Contains( SearchText, StringComparison.OrdinalIgnoreCase ) ?? false );
private bool NoResults => HasSearch && ( TemplatesVisible ? !GridTemplates.Any() : !GridLevels.Any() );
private void OnSearchEdited( string value )
{
value ??= "";
if ( value == SearchText ) return;
if ( MapMode && _editAction == EditAction.None )
{
// The map panel unmounts while a search is active (the grid takes over) — bank its scroll
// on the way out and re-apply it when the search clears, same as the inner edit picker.
if ( !HasSearch && value.Length > 0 ) SaveScroll();
else if ( HasSearch && value.Length == 0 ) { _scrollApplied = false; _scrollSettleFrames = 0; }
}
SearchText = value;
StateHasChanged();
}
private string TitleText => _editAction switch
{
EditAction.Replace => "REPLACE LEVEL",
EditAction.Add => "ADD LEVEL",
EditAction.AddMajor => "ADD MAJOR LEVEL",
EditAction.InsertMajor or EditAction.InsertSide => "INSERT LEVEL",
_ => Mode != PickerMode.Editor ? "SELECT LEVEL"
: TemplatesVisible ? "LOAD TEMPLATE" : "LOAD LEVEL",
};
private IEnumerable<LevelDef> GridLevels
{
get
{
// Map view + search: the grid lists the MAPPED levels that match, in map order (this is
// only reached through SearchOverridesMap — without a search the map layout renders instead).
if ( _editAction == EditAction.None && MapMode )
return LevelMap.Nodes.Select( n => n.Level ).Where( l => l is not null && MatchesSearch( l ) );
return NonMapLevelsFor( _editAction != EditAction.None ? _editPickTest : CurrentView == BrowserView.Test )
.Where( MatchesSearch );
}
}
private IEnumerable<LevelDef> NonMapLevelsFor( bool test )
{
// Dev picker: unmapped project levels split by the test flag. Standalone: the grid is the
// LOCAL view — only the player's own data-folder levels, whatever their test flag (unmapped
// shipped levels are dev work-in-progress and stay hidden).
var levels = Levels.BrowserLevels.Where( l => l is not null && LevelMap.Find( l.Id ) is null
&& ( Game.IsEditor ? l.IsTest == test : l.IsLocal ) );
return RecentFirst
? levels.OrderByDescending( l => l.SourceFileTimeUtc ).ThenBy( l => l.Name ?? l.Id, StringComparer.OrdinalIgnoreCase )
: levels.OrderBy( l => l.Name ?? l.Id, StringComparer.OrdinalIgnoreCase );
}
private void SetMode( BrowserView view )
{
if ( Mode != PickerMode.Editor ) return;
if ( CurrentView == view ) return;
if ( MapMode ) SaveScroll();
EditorPrefs.Current.LevelBrowserMapMode = view == BrowserView.Map;
EditorPrefs.Current.LevelBrowserTestMode = view == BrowserView.Test;
EditorPrefs.Current.LevelBrowserTemplatesMode = view == BrowserView.Templates;
EditorPrefs.Save();
_scrollApplied = false; // re-restore the offset when the map panel remounts
_scrollSettleFrames = 0;
StateHasChanged();
}
private void ToggleRecentFirst()
{
if ( Mode != PickerMode.Editor ) return;
var recentFirst = !EditorPrefs.Current.LevelBrowserOtherRecentFirst;
if ( EditorPrefs.Current.LevelBrowserOtherRecentFirst == recentFirst ) return;
EditorPrefs.Current.LevelBrowserOtherRecentFirst = recentFirst;
EditorPrefs.Save();
if ( recentFirst ) RefreshRecentTimestamps();
StateHasChanged();
}
// ── Templates view: sorting + previews ────────────────────────────────────────────────────────────
private string TemplateSortLabel => TemplateSort switch
{
TemplateSortMode.Recent => "Recent",
TemplateSortMode.Weight => "Weight",
_ => "A-Z",
};
private string TemplateSortTip => TemplateSort switch
{
TemplateSortMode.Recent => "Sorted newest first — click for highest weight first",
TemplateSortMode.Weight => "Sorted highest weight first — click for A-Z",
_ => "Sorted A-Z — click for newest first",
};
private void CycleTemplateSort()
{
if ( Mode != PickerMode.Editor ) return;
var next = (TemplateSortMode)( ( (int)TemplateSort + 1 ) % 3 );
EditorPrefs.Current.LevelBrowserTemplateSort = (int)next;
EditorPrefs.Save();
if ( next == TemplateSortMode.Recent ) RefreshTemplateTimestamps();
StateHasChanged();
}
private IEnumerable<ObstacleTemplate> GridTemplates => ( TemplateSort switch
{
TemplateSortMode.Recent => DailyTemplates.All
.OrderByDescending( t => TemplateTime( t.Id ) ).ThenBy( t => t.Id, StringComparer.OrdinalIgnoreCase ),
TemplateSortMode.Weight => DailyTemplates.All
.OrderByDescending( t => t.Weight ).ThenBy( t => t.Id, StringComparer.OrdinalIgnoreCase ),
_ => DailyTemplates.All.OrderBy( t => t.Id, StringComparer.OrdinalIgnoreCase ),
} ).Where( t => MatchesSearch( t.Id ) );
private static long TemplateTime( string id )
=> EditorPrefs.Current.LevelBrowserTemplateTimes is { } times && times.TryGetValue( id, out var time ) ? time : 0L;
// The largest block count a daily can roll: the top BlockCountWeights entry plus the ExtraBlocks
// modifier's +1 (see DailyLevelGenerator's count roll). The generator clamps the roll to a
// template's FREE spawn slots, so a template below this silently swallows the big rolls — the
// grid flags those with a red warning (pinned slots don't count; pins spawn on top of the roll).
private static int MaxDailyBlockCount => DailyGenConfig.BlockCountWeights.Max( e => e.Count ) + 1;
private static int FreeSpawnSlots( ObstacleTemplate t )
=> t.SpawnPositions.Length - t.SpawnPins.Count( p => p is not null );
private static string TemplateTip( ObstacleTemplate t )
{
int free = FreeSpawnSlots( t );
return free < MaxDailyBlockCount
? $"Only {free} free spawn slot{( free == 1 ? "" : "s" )} — a daily can roll up to {MaxDailyBlockCount} blocks"
: "Load this layout; blocks and colours are kept";
}
// Per-template preview defs: the template's LAYOUT dressed in the CURRENT editor level's colour
// scheme (templates are layout-only, and a pick keeps the open level's cosmetics — so the tiles
// show exactly what a pick produces). The base def and per-template defs are cached for the
// overlay's lifetime: the editor level can't change while the overlay covers the editor, and
// LevelNodePreview rebuilds per reference anyway.
private readonly Dictionary<string, LevelDef> _templateDefs = new();
private LevelDef _templateBase;
private LevelDef TemplateBase => _templateBase ??=
( GameManager.Instance?.Stage as LevelEditorStage )?.Level?.ToLevelDef() ?? new LevelDef();
private LevelDef TemplateDef( ObstacleTemplate t )
{
if ( _templateDefs.TryGetValue( t.Id, out var cached ) ) return cached;
var b = TemplateBase;
var def = new LevelDef
{
Id = $"daily-template-{t.Id}",
Name = t.Id,
// Layout from the template (same mapping as DailyTemplates.ApplyToEditor + ToLevelDef).
Obstacles = t.Obstacles,
VisionBlockers = t.VisionObstacles.Where( i => i >= 0 && i < t.Obstacles.Length ).Select( i => t.Obstacles[i] ).ToArray(),
Fences = t.FenceObstacles.Where( i => i >= 0 && i < t.Obstacles.Length ).Select( i => t.Obstacles[i] ).ToArray(),
Glass = t.GlassObstacles.Where( i => i >= 0 && i < t.Obstacles.Length ).Select( i => t.Obstacles[i] ).ToArray(),
SpikedWalls = t.SpikeableWalls,
SpikedObstacleSides = t.SpikeableObstacleSides
.Where( s => s.Obstacle >= 0 && s.Obstacle < t.Obstacles.Length )
.Select( s => new ObstacleSpikeSpec( t.Obstacles[s.Obstacle], s.Sides ) )
.ToArray(),
SpawnPositions = t.SpawnPositions,
SpawnPins = t.SpawnPins,
PlayerSpawns = t.PlayerSpawns,
Coins = t.Coins,
AlternatePlayfieldRects = t.AlternatePlayfieldRects,
AlternatePlayfieldEnabled = t.AlternatePlayfieldRects.Length > 0,
// Cosmetics from the open editor level.
WallColor = b.WallColor,
OutOfBoundsColor = b.OutOfBoundsColor,
CheckerboardColor = b.CheckerboardColor,
CheckerboardSecondColor = b.CheckerboardSecondColor,
BackgroundBlockColor = b.BackgroundBlockColor,
BackgroundBlockColors = b.BackgroundBlockColors,
FenceColor = b.FenceColor,
GlassColor = b.GlassColor,
BackgroundBlockScale = b.BackgroundBlockScale,
BackgroundBlockDensity = b.BackgroundBlockDensity,
BackgroundBlockOpacity = b.BackgroundBlockOpacity,
BackgroundDriftSpeed = b.BackgroundDriftSpeed,
BackgroundDriftBias = b.BackgroundDriftBias,
PlayfieldPattern = b.PlayfieldPattern,
PlayfieldCellScale = b.PlayfieldCellScale,
PlayfieldPatternRows = b.PlayfieldPatternRows,
AlternateCheckerboardColor = b.AlternateCheckerboardColor,
AlternateCheckerboardSecondColor = b.AlternateCheckerboardSecondColor,
AlternatePlayfieldPattern = b.AlternatePlayfieldPattern,
AlternatePlayfieldCellScale = b.AlternatePlayfieldCellScale,
AlternatePlayfieldPatternRows = b.AlternatePlayfieldPatternRows,
};
_templateDefs[t.Id] = def;
return def;
}
private void TemplatePicked( string id )
{
OnPickTemplate?.Invoke( id );
OnClose?.Invoke();
}
private void RefreshRecentTimestamps()
{
if ( _recentTimestampsRefreshed ) return;
_recentTimestampsRefreshed = true;
// The file-stat command lives in the editor assembly; standalone local levels get their
// timestamps stamped into EditorPrefs at save time instead (see LocalLevels.Save).
if ( Game.IsEditor ) ConsoleSystem.Run( "level_refresh_timestamps" );
StateHasChanged();
}
private void RefreshTemplateTimestamps()
{
if ( _templateTimestampsRefreshed ) return;
_templateTimestampsRefreshed = true;
if ( Game.IsEditor ) ConsoleSystem.Run( "daily_template_refresh_timestamps" );
StateHasChanged();
}
private void Pick( string id )
{
SaveScroll();
OnPick?.Invoke( id );
OnClose?.Invoke();
}
public void RequestClose() => Close();
private void Close()
{
SaveScroll();
OnClose?.Invoke();
}
// ── EDIT-mode handlers ────────────────────────────────────────────────────────────────────────────
private void ToggleEditMode()
{
_editMode = !_editMode;
// Turning EDIT mode ON with a map-tab search active would look dead: the edit affordances only
// exist on the map layout, which the search grid replaces. Clear it so the editable map
// appears immediately.
if ( _editMode )
ClearSearchBox();
StateHasChanged();
}
/// <summary>Map node click: in EDIT mode it opens the replacement picker; otherwise it picks the
/// level as before.</summary>
private void NodeClicked( MapNode n )
{
if ( !EditModeActive )
{
Pick( n.LevelId );
return;
}
SaveScroll(); // the map unmounts while the inner picker shows; restore its scroll on return
_editAction = EditAction.Replace;
_editTargetId = n.LevelId;
StateHasChanged();
}
/// <summary>A + cell click: open the add picker for grid cell (<paramref name="x"/>,<paramref name="y"/>)
/// of <paramref name="anchor"/>'s major. The anchor (the node the + hugs) becomes the new level's
/// predecessor, so the drawn connection follows the button that was clicked.</summary>
private void BeginAddSide( MapNode anchor, int x, int y )
{
SaveScroll();
_editAction = EditAction.Add;
_editMajorId = anchor.Major.LevelId;
_editCellX = x;
_editCellY = y;
_editFromDx = anchor.CellX - x;
_editFromDy = anchor.CellY - y;
StateHasChanged();
}
/// <summary>The + above the top major: open the add picker for a new spine-extending major.</summary>
private void BeginAddMajor()
{
SaveScroll();
_editAction = EditAction.AddMajor;
StateHasChanged();
}
/// <summary>A connection-line + click: open the insert picker for that connection — a spine
/// segment inserts a new major below the upper one, a side connection inserts into the side grid
/// (the outward half shifts a cell over).</summary>
private void BeginInsert( MapConnection conn )
{
SaveScroll();
if ( conn.To.IsMajor )
{
_editAction = EditAction.InsertMajor;
_editTargetId = conn.To.LevelId;
}
else
{
_editAction = EditAction.InsertSide;
_editMajorId = conn.To.Major.LevelId;
_editCellX = conn.From.CellX;
_editCellY = conn.From.CellY;
_editDx = conn.To.CellX - conn.From.CellX;
_editDy = conn.To.CellY - conn.From.CellY;
}
StateHasChanged();
}
private void CancelEdit()
{
_editAction = EditAction.None;
// Any search text was typed for the INNER picker's grid — an edit action can only start from
// the rendered map, which a map-view search replaces with the grid, so the search was empty on
// the way in. Clear it so the map actually returns (whether the edit committed or backed out);
// otherwise SearchOverridesMap keeps the window stuck in grid mode.
ClearSearchBox();
_scrollApplied = false; // re-restore the saved map offset when the map panel remounts
_scrollSettleFrames = 0;
StateHasChanged();
}
private void SetEditPickTest( bool test )
{
if ( _editPickTest == test ) return;
_editPickTest = test;
StateHasChanged();
}
/// <summary>Tile click in the grid: normally picks the level to load; with a pending edit it
/// commits the edit instead — the ConCmd lives in the editor assembly (LevelMapJsonWriter), the
/// only place that can write Assets/levelmap.json. Level ids are single console-safe tokens, so
/// the string command form is fine (same pattern as daily_template_save).</summary>
private void TilePicked( string id )
{
switch ( _editAction )
{
case EditAction.None:
Pick( id );
return;
case EditAction.Replace:
ConsoleSystem.Run( $"map_replace_level {_editTargetId} {id}" );
break;
case EditAction.Add:
ConsoleSystem.Run( $"map_add_side {_editMajorId} {_editCellX} {_editCellY} {id} {_editFromDx} {_editFromDy}" );
break;
case EditAction.AddMajor:
ConsoleSystem.Run( $"map_add_major {id}" );
break;
case EditAction.InsertMajor:
ConsoleSystem.Run( $"map_insert_major {_editTargetId} {id}" );
break;
case EditAction.InsertSide:
ConsoleSystem.Run( $"map_insert_side {_editMajorId} {_editCellX} {_editCellY} {_editDx} {_editDy} {id}" );
break;
}
CancelEdit();
}
/// <summary>Backdrop click: back out of the inner edit picker if it's open, else dismiss.</summary>
private void BackdropClicked()
{
if ( _editAction != EditAction.None )
{
CancelEdit();
return;
}
Close();
}
/// <summary>One + button per (node, empty adjacent cell) pair, rendered near-flush against the
/// node's edge. A cell shared by several nodes gets a + on EACH of them — they target the same
/// cell but the anchor node becomes the new level's predecessor, so which + you click decides
/// which connection is drawn. Column 0 is reserved for the spine, and adjacency never crosses
/// into another major's group (cells are per-major, matching how the map builds).</summary>
private static IEnumerable<(MapNode Node, int X, int Y, int Dx, int Dy)> EmptyAdjacentCells()
{
var occupied = new HashSet<(MapNode major, int x, int y)>();
foreach ( var n in LevelMap.Nodes )
occupied.Add( (n.Major, n.CellX, n.CellY) );
foreach ( var n in LevelMap.Nodes )
{
foreach ( var (dx, dy) in CELL_NEIGHBOURS )
{
int x = n.CellX + dx, y = n.CellY + dy;
if ( x == 0 ) continue; // spine column
if ( occupied.Contains( (n.Major, x, y) ) ) continue;
yield return (n, x, y, dx, dy);
}
}
}
private static readonly (int dx, int dy)[] CELL_NEIGHBOURS = { (1, 0), (-1, 0), (0, 1), (0, -1) };
/// <summary>Whether EDIT mode offers an insert + on this connection. Spine segments always take
/// one (the majors above just shift up); a side connection needs the outward half of the group to
/// shift a cell over without landing on the spine column or stranding anyone — mirrors the
/// map_insert_side validation (shared <see cref="LevelMap.CanInsertSideCell"/>), so the + only
/// shows where the insert will actually succeed.</summary>
private static bool CanInsertOn( MapConnection conn )
{
if ( conn.To.IsMajor )
return true;
var cells = LevelMap.Nodes
.Where( m => !m.IsMajor && m.Major == conn.To.Major )
.Select( m => (m.CellX, m.CellY) )
.ToList();
return LevelMap.CanInsertSideCell( cells, conn.From.CellX, conn.From.CellY,
conn.To.CellX - conn.From.CellX, conn.To.CellY - conn.From.CellY, out _, out _ );
}
private static string InsertTip( MapConnection conn ) => conn.To.IsMajor
? $"Insert a new major level between {conn.From.Name} and {conn.To.Name} — the spine above shifts up"
: $"Insert a level between {conn.From.Name} and {conn.To.Name} — the levels beyond shift over";
/// <summary>Whether EDIT mode offers an X on this node. A side level can go whenever the gap it
/// leaves can be closed — mid-chain deletes slide the outward half of the group one cell back
/// (shared <see cref="LevelMap.CanRemoveSideCell"/>, the inverse of the insert shift, preferring
/// the node's own predecessor axis so junctions close along the drawn connection); a major must
/// have no side levels left and never be the last one on the spine. Mirrors the map_remove_level
/// validation — and the candidate SET is preference-independent — so the X only shows where the
/// delete will actually succeed.</summary>
private static bool CanDeleteNode( MapNode n )
{
if ( n.IsMajor )
return LevelMap.Nodes.Count( m => m.IsMajor ) > 1
&& !LevelMap.Nodes.Any( m => !m.IsMajor && m.Major == n );
var cells = LevelMap.Nodes
.Where( m => !m.IsMajor && m.Major == n.Major && m != n )
.Select( m => (m.CellX, m.CellY) )
.ToList();
var pred = n.Predecessor;
return LevelMap.CanRemoveSideCell( cells, n.CellX, n.CellY,
pred is null ? 0 : n.CellX - pred.CellX, pred is null ? 0 : n.CellY - pred.CellY, out _, out _ );
}
private static void DeleteNode( MapNode n )
=> ConsoleSystem.Run( $"map_remove_level {n.LevelId}" );
// Persist the live map scroll offset for THIS play session on the GameManager (in-memory, reference
// px, normalised to zoom 1 — see NormalizeScroll). ScrollOffset is SCREEN px, so × ScaleFromScreen
// → reference px (window-size independent).
private void SaveScroll()
{
if ( MapScroll is null ) return;
var mgr = GameManager.Instance;
if ( mgr is null ) return;
float scale = MapScroll.ScaleFromScreen;
float viewW = MapScroll.Box.Rect.Width * scale;
float viewH = MapScroll.Box.Rect.Height * scale;
mgr.LevelBrowserScroll = NormalizeScroll( MapScroll.ScrollOffset * scale, viewW, viewH, MapZoom );
mgr.LevelBrowserMapShown = true;
}
// Set the map scroll once the viewport is laid out. FIRST view of a play session (a fresh
// GameManager) → centre the spine (it sits at CanvasW/2) + pin to the BOTTOM; LATER views this
// session → restore the in-session offset. Re-applied for a few frames so a late layout settle can't
// strand a half-applied offset.
public override void Tick()
{
base.Tick();
UpdateTooltipHover();
UpdateTileWindow();
UpdateMapPreviewWindow();
if ( Mode == PickerMode.Editor && GridVisible )
{
if ( TemplatesVisible && TemplateSort == TemplateSortMode.Recent ) RefreshTemplateTimestamps();
else if ( !TemplatesVisible && RecentFirst ) RefreshRecentTimestamps();
}
if ( !MapMode || MapScroll is null || _scrollApplied ) return;
// Wait until the scroll VIEWPORT is laid out: its width drives the horizontal-centre maths and,
// once it (and its explicitly-sized .map child) are laid out, the panel's scroll range is known so
// a bottom-clamp actually takes. Before that, any offset we set clamps against a stale (0) range.
float scale = MapScroll.ScaleFromScreen; // screen px × scale = reference px
float viewW = MapScroll.Box.Rect.Width * scale; // viewport width in reference px
if ( scale <= 0f || viewW <= 1f ) return;
var mgr = GameManager.Instance;
// IMPORTANT: ScrollOffset is in SCREEN px, but the canvas / centre maths are in REFERENCE px, so
// divide the reference-px target by the scale before assigning. A large Y clamps to the max scroll
// (bottom = start of the progression). The banked offset is stored at zoom 1 (see NormalizeScroll)
// and re-projected here at the CURRENT zoom.
bool firstShow = mgr is null || !mgr.LevelBrowserMapShown;
float viewH = MapScroll.Box.Rect.Height * scale;
float targetXRef = MathF.Max( 0f, CanvasW * 0.5f - viewW * 0.5f );
Vector2 target = firstShow
? new Vector2( targetXRef / scale, 1_000_000f )
: DenormalizeScroll( mgr.LevelBrowserScroll, viewW, viewH, MapZoom ) / scale;
MapScroll.ScrollOffset = target;
// Re-apply for a few frames so any one-frame layout settle can't leave it half-applied, then latch.
if ( ++_scrollSettleFrames >= SCROLL_SETTLE_FRAMES )
{
_scrollApplied = true;
if ( mgr is not null ) mgr.LevelBrowserMapShown = true;
}
}
// ── windowed tiles: only shells near the scroll viewport instantiate their preview contents ──────
// The engine's per-frame tick and render-descriptor walks visit EVERY live panel, scrolled out of
// view or not, so a 250-template grid at ~60 panels per preview stutters on panel count alone.
// The grid measures its tile SHELLS (always rendered, fixed size) against the viewport and keeps
// a contiguous index window. Windowing only pays off on BIG grids — small ones build fully in a
// frame anyway and windowing costs a re-render per scroll step — so it arms for the template
// library always, for the Other view (the standalone player-level library) past 100 levels, and
// never for Test, the leaderboard picker, the edit picker, or a map search. The map uses its
// own two-dimensional preview window below.
private Panel MapCanvas { get; set; }
private HashSet<int> _mapPreviewWindow = new();
private HashSet<int> _nextMapPreviewWindow = new();
// Keep node shells, labels and edit controls mounted so layout and scroll restoration do not
// depend on previews. As with the grid, only the expensive preview subtrees are windowed.
private void UpdateMapPreviewWindow()
{
_nextMapPreviewWindow.Clear();
// Wait for the initial scroll restore before creating thumbnails at the opening position.
if ( !GridVisible && _scrollApplied && MapScroll.IsValid() && MapCanvas.IsValid()
&& MapScroll.Box.Rect.Height > 0f )
{
float margin = TILE_PREFETCH * MapScroll.ScaleToScreen;
var view = MapScroll.Box.Rect.Grow( margin, margin );
int index = 0;
foreach ( var child in MapCanvas.Children )
{
if ( !child.HasClass( "node-wrap" ) ) continue;
if ( child.Box.Rect.Width > 0f && view.IsInside( child.Box.Rect ) )
_nextMapPreviewWindow.Add( index );
index++;
}
}
if ( _mapPreviewWindow.SetEquals( _nextMapPreviewWindow ) ) return;
( _mapPreviewWindow, _nextMapPreviewWindow ) = ( _nextMapPreviewWindow, _mapPreviewWindow );
StateHasChanged();
}
// One row of grid tiles, in reference px: contents build this far outside the viewport so they
// exist by the time they scroll in.
private const float TILE_PREFETCH = 200f;
private const int TILE_WINDOW_MIN_LEVELS = 100;
private Panel Grid { get; set; }
private int _tileWindowFirst, _tileWindowLast = -1; // inclusive shell-index window; empty until measured
private bool _tileWindowingActive; // cached once per tick — see TileWindowingActive
// Evaluated once per tick (the Other branch counts the whole pool): the cached flag is what
// TileBodyVisible reads per tile at render time. A view switch renders one frame on the stale
// flag/window and corrects on the next tick's measure.
private bool TileWindowingActive => TemplatesVisible
|| ( Mode == PickerMode.Editor && _editAction == EditAction.None && !MapMode
&& CurrentView == BrowserView.Other && OtherLevelCount > TILE_WINDOW_MIN_LEVELS );
// The Other view's pool size (same filter as NonMapLevelsFor, minus the sort — this runs per tick).
private static int OtherLevelCount => Levels.BrowserLevels.Count( l => l is not null
&& LevelMap.Find( l.Id ) is null && ( Game.IsEditor ? !l.IsTest : l.IsLocal ) );
private bool TileBodyVisible( int index )
=> !_tileWindowingActive || ( index >= _tileWindowFirst && index <= _tileWindowLast );
// The empty window on a fresh grid (nothing measured until the shells are laid out) is what makes
// opening fast: the first render is shells only, the visible previews build on the next tick.
private void UpdateTileWindow()
{
bool active = TileWindowingActive;
int first = 0, last = -1;
if ( GridVisible && active && Grid is { } grid && grid.Box.Rect.Height > 0f )
{
float margin = TILE_PREFETCH * grid.ScaleToScreen;
var view = grid.Box.Rect.Grow( 0f, margin );
int index = 0;
foreach ( var child in grid.Children )
{
if ( !child.HasClass( "tile" ) ) continue;
if ( child.Box.Rect.Height > 0f && view.IsInside( child.Box.Rect ) )
{
if ( last < 0 ) first = index;
last = index;
}
index++;
}
}
if ( active == _tileWindowingActive && first == _tileWindowFirst && last == _tileWindowLast ) return;
_tileWindowingActive = active;
_tileWindowFirst = first;
_tileWindowLast = last;
StateHasChanged();
}
// ── map-mode layout (map space is +Y up; flip to CSS top-down and offset by the map bounds) ──────
// The canvas is centred on the SPINE (the major-node column): its half-width is the larger of the
// two side-chain extents plus padding, so the spine sits at the horizontal CANVAS CENTRE even when
// one side chain is far longer than the other. This leaves empty room on the shorter side so the
// spine can always be scrolled to the viewport centre (otherwise a long left chain pins the spine
// to the right, un-centrable). Y is unchanged (top-anchored flip).
// EDIT mode pads a little extra so near-flush + buttons (and corner X buttons) on the outermost
// nodes aren't clipped by the canvas edge.
private float MapPad => EditModeActive ? MAP_PAD + 48f : MAP_PAD;
private float SpineX => LevelMap.First?.Pos.x ?? ( ( LevelMap.MinX + LevelMap.MaxX ) * 0.5f );
private float HalfSpanAt( float zoom ) => MathF.Max( SpineX - LevelMap.MinX, LevelMap.MaxX - SpineX ) * zoom + MapPad;
private float HalfSpan => HalfSpanAt( MapZoom );
private float CanvasW => HalfSpan * 2f;
private float CanvasH => ( LevelMap.MaxY - LevelMap.MinY ) * MapZoom + MapPad * 2f;
private float LocalX( float mapX ) => ( mapX - SpineX ) * MapZoom + HalfSpan; // spine → canvas centre
private float LocalY( float mapY ) => ( LevelMap.MaxY - mapY ) * MapZoom + MapPad; // flip Y
// Explicit opacity also clears the old reveal gate on panels retained through hot reload.
private string MapCanvasStyle() => $"width:{Px( CanvasW )};height:{Px( CanvasH )};opacity:1;";
private string NodeStyle( MapNode n )
{
float size = ( n.IsMajor ? MAJOR_SIZE : SIDE_SIZE ) * MapZoom;
float left = LocalX( n.Pos.x ) - size / 2f;
float top = LocalY( n.Pos.y ) - size / 2f;
return $"left:{Px( left )};top:{Px( top )};width:{Px( size )};height:{Px( size )};";
}
// The node frame's border lives in the stylesheet at 1:1 (4px); zoom re-emits it inline, floored
// so a far-out zoom on a small window can't thin the frame away entirely.
private string NodeFaceStyle() => $"border-width:{Px( MathF.Max( 2f, 4f * MapZoom ) )};";
private const float ADD_CELL_SIZE = 44f;
private const float ADD_CELL_GAP = 4f; // near-flush: a hairline gap off the node's edge
private const float DELETE_SIZE = 32f;
/// <summary>A + button hugging <paramref name="n"/>'s edge on the (<paramref name="dx"/>,
/// <paramref name="dy"/>) side (cell space, +Y up — flipped into canvas Y here).</summary>
private string AddCellStyle( MapNode n, int dx, int dy )
{
float half = ( n.IsMajor ? MAJOR_SIZE : SIDE_SIZE ) / 2f * MapZoom;
float size = ADD_CELL_SIZE * MapZoom;
float dist = half + ADD_CELL_GAP * MapZoom + size / 2f;
float left = LocalX( n.Pos.x ) + dx * dist - size / 2f;
float top = LocalY( n.Pos.y ) - dy * dist - size / 2f;
return $"left:{Px( left )};top:{Px( top )};width:{Px( size )};height:{Px( size )};";
}
/// <summary>An insert + button centred on the connection line between two nodes.</summary>
private string InsertStyle( MapConnection conn )
{
float size = ADD_CELL_SIZE * MapZoom;
float cx = ( LocalX( conn.From.Pos.x ) + LocalX( conn.To.Pos.x ) ) / 2f;
float cy = ( LocalY( conn.From.Pos.y ) + LocalY( conn.To.Pos.y ) ) / 2f;
return $"left:{Px( cx - size / 2f )};top:{Px( cy - size / 2f )};width:{Px( size )};height:{Px( size )};";
}
/// <summary>An X button straddling the node's top-right corner.</summary>
private string DeleteNodeStyle( MapNode n )
{
float half = ( n.IsMajor ? MAJOR_SIZE : SIDE_SIZE ) / 2f * MapZoom;
float size = DELETE_SIZE * MapZoom;
float left = LocalX( n.Pos.x ) + half - size / 2f;
float top = LocalY( n.Pos.y ) - half - size / 2f;
return $"left:{Px( left )};top:{Px( top )};width:{Px( size )};height:{Px( size )};";
}
// The EDIT-mode button glyphs are sized in the stylesheet at 1:1 (30/20px); zoom re-emits them
// inline so they track their (scaled) buttons.
private string AddIconStyle => $"font-size:{Px( 30f * MapZoom )};";
private string DeleteIconStyle => $"font-size:{Px( 20f * MapZoom )};";
// Music-override badges: EDITOR map only (the leaderboard map stays clean). Glyph sized in the
// stylesheet at 1:1 (18px) and re-emitted inline so it tracks the node's zoom.
private bool MusicBadgesVisible => Mode == PickerMode.Editor && Game.IsEditor;
private string MusicIconStyle => $"font-size:{Px( 18f * MapZoom )};";
/// <summary>True when another mapped level overrides its music with the SAME track — usually an
/// accident, so both badges go red.</summary>
private static bool MusicShared( MapNode n )
{
string music = n.Level?.Music?.Trim();
if ( string.IsNullOrWhiteSpace( music ) ) return false;
int count = 0;
foreach ( var other in LevelMap.Nodes )
{
if ( !string.Equals( other.Level?.Music?.Trim(), music, StringComparison.OrdinalIgnoreCase ) ) continue;
if ( ++count > 1 ) return true;
}
return false;
}
/// <summary>Forced-character badge inside a map node's preview (lower-left, like the grid tiles);
/// sized to the node tier. The character image goes on the badge's .sprite child (see
/// <see cref="ForcedCharacterSpriteStyle"/>) so the .icon-glow sibling can render behind it.</summary>
private string NodeForcedCharacterStyle( MapNode n, CharacterDef character )
{
float size = ( n.IsMajor ? MAJOR_NODE_CHAR_SIZE : SIDE_NODE_CHAR_SIZE ) * character.PreviewScale * MapZoom;
return $"width:{Px( size )};height:{Px( size )};";
}
private static string ForcedCharacterSpriteStyle( CharacterDef character )
=> $"background-image:url( {character.PreviewImage} );";
private string NodeNameStyle( MapNode n )
{
// Estimate Roboto's proportional glyph widths so labels fit on first paint without a resize pop.
// The fit is computed at 1:1 (the label and its node scale together), THEN zoomed — with the
// zoomed line-height alongside so a zoomed-in font isn't clipped by the stylesheet's 21px.
const float minimumFontSize = 9f;
const float horizontalPadding = 12f;
float maximumFontSize = n.IsMajor ? 18f : 12f;
float availableWidth = (n.IsMajor ? MAJOR_SIZE : SIDE_SIZE) - horizontalPadding;
float textWidthAtOnePixel = (n.Name ?? "").Sum( CharacterWidthEm );
float fontSize = textWidthAtOnePixel > 0f
? Math.Clamp( availableWidth / textWidthAtOnePixel, minimumFontSize, maximumFontSize )
: maximumFontSize;
fontSize *= MapZoom;
return $"font-size:{Px( fontSize )};line-height:{Px( fontSize * 1.17f )};";
}
// The name pill's geometry lives in the stylesheet at 1:1 (top -29 / height 25 / padding 2 6);
// zoom re-emits it inline so the pill tracks its node instead of dwarfing a zoomed-out one.
private string NodeNameWrapStyle()
=> $"top:{Px( -29f * MapZoom )};height:{Px( 25f * MapZoom )};padding:{Px( 2f * MapZoom )} {Px( 6f * MapZoom )};";
private static string GridNameStyle( string name )
{
const float minimumFontSize = 9f;
const float maximumFontSize = 18f;
const float availableWidth = 150f;
float textWidthAtOnePixel = (name ?? "").Sum( CharacterWidthEm );
float fontSize = textWidthAtOnePixel > 0f
? Math.Clamp( availableWidth / textWidthAtOnePixel, minimumFontSize, maximumFontSize )
: maximumFontSize;
return $"font-size:{Px( fontSize )};";
}
private static float CharacterWidthEm( char character )
{
if ( char.IsWhiteSpace( character ) ) return 0.28f;
if ( "ilI.,'!:;|".Contains( character ) ) return 0.28f;
if ( "mwMW@%&".Contains( character ) ) return 0.88f;
if ( char.IsUpper( character ) ) return 0.66f;
return 0.54f;
}
private static string TileForcedCharacterStyle( CharacterDef character )
{
float markerSize = TILE_CHAR_SIZE * character.PreviewScale;
return $"width:{Px( markerSize )};height:{Px( markerSize )};";
}
private string LineStyle( MapConnection conn )
{
float thickness = MathF.Max( 4f, LINE_THICKNESS * MapZoom );
float ax = LocalX( conn.From.Pos.x ), ay = LocalY( conn.From.Pos.y );
float bx = LocalX( conn.To.Pos.x ), by = LocalY( conn.To.Pos.y );
if ( conn.From.Pos.x == conn.To.Pos.x )
{
// Vertical (spine).
float top = MathF.Min( ay, by ), height = MathF.Abs( ay - by );
return $"left:{Px( ax - thickness / 2f )};top:{Px( top )};width:{Px( thickness )};height:{Px( height )};";
}
// Horizontal (side chain).
float leftH = MathF.Min( ax, bx ), width = MathF.Abs( ax - bx );
return $"left:{Px( leftH )};top:{Px( ay - thickness / 2f )};width:{Px( width )};height:{Px( thickness )};";
}
private static string Px( float v ) => v.ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture ) + "px";
// Repaint when the mode toggles, the root-level set changes (a reload), the map is rebuilt by an
// edit (Revision — a replace keeps the node COUNT unchanged), the edit-mode state moves, the
// search string edits, or the template set / sort / timestamps change. The lists are otherwise
// static for the overlay's lifetime.
protected override int BuildHash() => System.HashCode.Combine( Mode, CurrentView, RecentFirst, OtherLevelsHash(), Levels.BrowserLevels.Count, LevelMap.Nodes.Count,
System.HashCode.Combine( LevelMap.Revision, _editMode, _editAction, _editPickTest, SearchText, MapZoom ),
System.HashCode.Combine( TemplateSort, TemplatesHash() ) );
private static long OtherLevelsHash()
{
long hash = 17;
foreach ( var level in Levels.BrowserLevels )
{
if ( level is null || LevelMap.Find( level.Id ) is not null ) continue;
hash = hash * 31 + level.SourceFileTimeUtc;
}
return hash;
}
private static long TemplatesHash()
{
long hash = 17;
foreach ( var template in DailyTemplates.All )
{
hash = hash * 31 + ( template.Id?.GetHashCode() ?? 0 );
hash = hash * 31 + template.Weight.GetHashCode();
hash = hash * 31 + TemplateTime( template.Id );
}
return hash;
}
}