A Razor UI panel for a fullscreen map selection screen. It renders tabs, map lists, previews, and a loading page; handles hovering, selecting maps, and choosing gamemodes, and calls into NZMap, NZNet, Gamemodes and other game systems to load maps and notify clients.
@using Sandbox;
@using Sandbox.UI;
@using System.Linq;
@using NZombies;
@inherits Panel
@*
MAP SELECT — a fullscreen page over the lobby, laid out like Black Ops' "Mission select" in our own colours
(the user, 2026-10-05: *"open a new page like this that is Fullscreen / With the map names on the left and when I
hover one I see the image on the right and the description bellow it and name above it / Instead of mission select
we have map select / And right bellow it we can choose between main quest or survival tabs"*).
Left: the title, the two tabs, the map names, Back. Right: the map you're pointing at, its name above its picture
and its description below, far right. Behind it all, black for now (the user will bring an image). Hover previews;
a click loads.
⚠️ A child Panel of the lobby, not its own PanelComponent — no GameObject, no scene edit. Same
reason ConfigBrowser is: "The last panel built the other way was added at runtime, never saved,
and silently disappeared on restart."
⛔ ON THE EDITOR'S SIDE IT SELECTS MAPS ONLY: loading, saving and creating configs stay on the lobby's Config buttons.
⛔ ON THE PUBLISHED SIDE A MAP IS CHOSEN WITH A GAMEMODE (2026-10-05). The user: "When I choose a map, it should then replace
the maps on the left with the gamemodes available for that map, these being the config games, when I click one it loads that
gamemode. Meaning no more gamemode option in the lobby." A map click shows its gamemodes in the list (`MapBrowserState.Chosen`);
a gamemode click loads the map and that gamemode (`Gamemodes.PlayOn`); Back goes back to the maps.
*@
@if ( Showing != MapBrowserState.Tab.None )
{
var shown = MapBrowserState.Preview;
var shownMode = MapBrowserState.PreviewMode;
<div class="mapselect">
@* ⛔ THE PAGE *BECOMES* THE PROGRESS PANEL while a map loads, rather than getting an overlay on top of it. Three
attempts at an absolutely-positioned scrim over the old modal rendered as a small floating box in the middle of
the row list. Swapping the content is plain flexbox, cannot mis-measure, and blocks interaction by
construction: the tabs, rows and Back are not on screen to be clicked. ⛔ NO BACK WHILE LOADING, for the same
reason the modal had no ✕: the map is mounting into the live scene underneath. *@
@if ( NZMap.Busy )
{
<div class="loadpage">
<div class="loadpane">
<div class="loadtitle">@LoadTitle</div>
<div class="loadstatus">@LoadStatus</div>
@* ⚠️ TOTAL SIZE AND ELAPSED TIME, NOT "x of y". The transferred bytes are
genuinely unobtainable from game code — see NZMap. *@
@if ( !string.IsNullOrEmpty( NZMap.DownloadDetail ) )
{
<div class="loaddetail">@NZMap.DownloadDetail</div>
}
@* ⚠️ INDETERMINATE ON PURPOSE — a stripe that travels, not a fill that grows.
s&box exposes no download progress for a package mount, so any percentage would be invented. *@
<div class="loadbar"><div class="loadfill"></div></div>
</div>
</div>
}
else
{
<div class="page">
<div class="select">
@if ( Choosing )
{
@* ⛔ THE CHOSEN MAP'S GAMEMODES IN PLACE OF THE MAPS (published side, 2026-10-05, `MapBrowserState.Chosen`). The
title says what is chosen now, the map stands where the tabs were, and Back goes to the maps, not out. *@
<div class="title">Gamemode select</div>
<div class="modeof">@ChosenTitle</div>
<div class="tabhint">@ModeHint</div>
<div class="list">
@if ( Modes.Count == 0 )
{
<div class="empty">This map has no gamemode yet.</div>
}
@foreach ( var mode in Modes )
{
var m = mode;
<div class="row @(m.Name == shownMode?.Name ? "on" : "")"
onmouseover=@( () => PointMode( m ) )
onclick=@( () => { NZSound.PlayUi( NZSound.UiClick ); PlayMode( m ); } )>
<div class="name">@m.Label</div>
@if ( MapBrowserState.IsPlaying( m ) )
{
<div class="badge">Playing</div>
}
</div>
}
</div>
<div class="back" onmouseover=@Hover
onclick=@( () => { NZSound.PlayUi( NZSound.UiClick ); MapBrowserState.BackToMaps(); } )>‹ Maps</div>
}
else
{
<div class="title">Map select</div>
<div class="tabs">
@foreach ( var t in Tabs )
{
var tab = t;
<div class="tab @(Showing == tab ? "on" : "")"
onmouseover=@Hover
onclick=@( () => { NZSound.PlayUi( NZSound.UiClick ); MapBrowserState.Open( tab ); } )>@Label( tab )</div>
}
</div>
<div class="tabhint">@TabHint</div>
<div class="list">
@if ( !Rows.Any() )
{
<div class="empty">@EmptyText</div>
}
@foreach ( var row in Rows )
{
var r = row;
<div class="row @(r.MapName == shown?.MapName ? "on" : "") @(r.IsCurrent ? "current" : "")"
onmouseover=@( () => Point( r ) )
onclick=@( () => { NZSound.PlayUi( NZSound.UiClick ); PickRow( r ); } )>
<div class="name">@r.Title</div>
@if ( r.IsCurrent )
{
<div class="badge">Loaded</div>
}
</div>
}
</div>
<div class="back" onmouseover=@Hover
onclick=@( () => { NZSound.PlayUi( NZSound.UiClick ); Hide(); } )>‹ Back</div>
}
</div>
<div class="detail">
@if ( Choosing && ChosenMap is not null )
{
@* ⚠️ THE MAP STAYS ON THE RIGHT, its name and picture, with the gamemode pointed at under them: its name, and its own
line, or that of the map when it has none (most go by their file name, with no line of their own). *@
<div class="dname">@ChosenMap.Title</div>
<div class="dart" style="background-image: url( @Art( ChosenMap ) );">
@if ( string.IsNullOrEmpty( Art( ChosenMap ) ) )
{
<div class="noimg">@Initial( ChosenMap.Title )</div>
}
</div>
@if ( shownMode is not null )
{
<div class="dmode">@shownMode.Label</div>
<div class="ddesc">@ModeText( shownMode )</div>
<div class="dmeta @(MapBrowserState.IsPlaying( shownMode ) ? "loaded" : "")">@(MapBrowserState.IsPlaying( shownMode ) ? "Playing" : "Click to play")</div>
}
else
{
<div class="ddesc">@ChosenMap.Subtitle</div>
<div class="dmeta unset">No gamemode yet</div>
}
}
else if ( shown is not null )
{
<div class="dname">@shown.Title</div>
@* ⚠️ THE CARD IMAGE FIRST, the lobby art second. The card is a shot chosen to show the map (basalt's
is its lava field); the lobby art is a mood piece for behind the menu. A map with neither gets its
initial, so the picture never becomes a dead hole. *@
<div class="dart" style="background-image: url( @Art( shown ) );">
@if ( string.IsNullOrEmpty( Art( shown ) ) )
{
<div class="noimg">@Initial( shown.Title )</div>
}
</div>
<div class="ddesc">@shown.Subtitle</div>
<div class="dmeta @MetaClass( shown )">@MetaText( shown )</div>
}
</div>
</div>
}
</div>
}
@code
{
static MapBrowserState.Tab Showing => MapBrowserState.Showing;
// ⛔ ONE TAB PER EASTER EGG LINE, THEN SURVIVAL (2026-10-05). Their names, hints and empty texts are `MapBrowserState`'s,
// the one place a cast's name is written.
static MapBrowserState.Tab[] Tabs => new[]
{
MapBrowserState.Tab.Primis,
MapBrowserState.Tab.Immunis,
MapBrowserState.Tab.Survival,
};
static string Label( MapBrowserState.Tab t ) => MapBrowserState.LabelOf( t );
static System.Collections.Generic.List<MapBrowserState.Row> Rows => MapBrowserState.Rows;
static string LoadTitle => string.IsNullOrEmpty( MapBrowserState.Loading )
? "Loading map" : MapBrowserState.Loading;
static string LoadStatus => string.IsNullOrEmpty( NZMap.Status ) ? "Working…" : NZMap.Status;
static string EmptyText => MapBrowserState.EmptyOf( Showing );
/// <summary>One line under the tabs saying what the chosen kind of game is.</summary>
static string TabHint => MapBrowserState.HintOf( Showing );
/// <summary>The previewed map's picture: its card image, else its lobby art, else "".</summary>
static string Art( MapBrowserState.Row r )
=> string.IsNullOrEmpty( r.Thumb ) ? r.Background ?? "" : r.Thumb;
/// <summary>
/// The line under the description.
///
/// ⚠️ "NOT SET UP YET" IS FOR THE HOST'S SAKE, not a developer note: a map with no config has no spawns, so whoever
/// picks it can load it and then never ready up. Saying so before the click beats a lobby that silently refuses.
/// </summary>
static string MetaText( MapBrowserState.Row r )
{
if ( !Edition.IsPublished ) return r.IsCurrent ? "Loaded" : r.Configs == 0 ? "Not set up yet" : "Click to load";
// ⚠️ THE PUBLISHED SIDE: a click opens the map, gamemodes (2026-10-05)
if ( r.Configs == 0 ) return "No gamemode yet";
return r.IsCurrent ? "Loaded · click for its gamemodes" : "Click to choose a gamemode";
}
static string MetaClass( MapBrowserState.Row r )
=> r.IsCurrent ? "loaded" : r.Configs == 0 ? "unset" : "";
/// <summary>First letter, for a map with no picture.</summary>
static string Initial( string title )
=> string.IsNullOrWhiteSpace( title ) ? "?" : title.Trim()[..1].ToUpper();
static void Hover() => NZSound.PlayUi( NZSound.UiHover );
/// <summary>
/// Hovering a name previews it.
///
/// ⚠️ THE SOUND ONLY ON A CHANGE. onmouseover fires again as the cursor crosses the row's own children (the name, the
/// badge), and a hover tick per child reads as a stutter.
/// </summary>
static void Point( MapBrowserState.Row r )
{
if ( MapBrowserState.Hovered == r.MapName ) return;
MapBrowserState.Hovered = r.MapName;
Hover();
}
/// <summary>
/// ⚠️ REFUSES WHILE A LOAD IS IN FLIGHT. Two overlapping map loads would race on one
/// MapInstance, and the loser leaves the scene holding half a map — NZMap.Busy guards it
/// too, but a row that visibly does nothing is worse than one that cannot be clicked.
///
/// ⛔ AWAITS THE LOAD AND STAYS OPEN WHILE IT RUNS. The page is the only thing that CAN report progress, so it has
/// to still be on screen to do it.
///
/// ⚠️ STAYS OPEN ON FAILURE. A bad ident or an unreachable backend leaves the reason where it
/// can be read, rather than closing and looking like the click did nothing.
/// </summary>
static async void Pick( MapBrowserState.Row r )
{
if ( NZMap.Busy || r.IsCurrent ) return;
// ⛔ THE MAP IS THE HOST'S TO CHOOSE. `MULTIPLAYER.md` §3: a client's lobby has three
// controls and none of them is this one. The button is hidden from them later; the gate
// has to be on the ACTION, because a hidden button still has a reachable code path.
if ( NZGame.IsClient )
{
Log.Info( "[nz-map] only the host can change the map" );
return;
}
MapBrowserState.Loading = r.Title;
var ok = await NZMap.Load( r.MapName );
MapBrowserState.Loading = "";
if ( !ok ) return;
// ⚠️ PUSHED AFTER THE HOST'S OWN LOAD, because `NZMap.Load` ends by RESETTING the
// config — so this is the first moment `ActiveConfig.Current` describes the new map
// rather than the old one.
NZNet.SendConfig();
// ⛔ AND THEN THE CLIENTS REJOIN, which is the only way a map reaches them. The host
// must already be on it, which is why this is last.
NZNet.ChangeMapForEveryone( r.MapName );
MapBrowserState.Close();
Mouse.Visibility = MouseVisibility.Hidden;
}
static void Hide()
{
MapBrowserState.Close();
Mouse.Visibility = MouseVisibility.Hidden;
}
// ── the published side: a map, then its gamemode (2026-10-05) ──────────────────────────────
static bool Choosing => MapBrowserState.ChoosingMode;
static MapBrowserState.Row ChosenMap => MapBrowserState.ChosenRow;
static string ChosenTitle => ChosenMap?.Title ?? "";
static System.Collections.Generic.List<Gamemodes.Mode> Modes => MapBrowserState.Modes;
/// <summary>The line under the map in the gamemode list: how many ways there are to play it.</summary>
static string ModeHint
{
get
{
var n = Modes.Count;
if ( n == 0 ) return "Nothing to play on it yet.";
if ( n == 1 ) return "One way to play it.";
return n + " ways to play it.";
}
}
/// <summary>A gamemode, line on the right: its own, or that of the map when it has none.</summary>
static string ModeText( Gamemodes.Mode m )
=> string.IsNullOrWhiteSpace( m.Description ) ? ChosenMap?.Subtitle ?? "" : m.Description;
/// <summary>Pointing at a gamemode describes it on the right. The sound only on a change, as `Point` does it.</summary>
static void PointMode( Gamemodes.Mode m )
{
if ( MapBrowserState.HoveredMode == m.Name ) return;
MapBrowserState.HoveredMode = m.Name;
Hover();
}
/// <summary>
/// A map row clicked: on the published side it opens that map, gamemodes; on the editor side it loads the map, as it always did.
/// </summary>
static void PickRow( MapBrowserState.Row r )
{
if ( Edition.IsPublished ) MapBrowserState.ChooseMap( r.MapName );
else Pick( r );
}
/// <summary>
/// Play the chosen map on this gamemode (`Gamemodes.PlayOn`) and close once it is on.
///
/// ⚠️ THE LOADING PANE SHOWS WHILE A NEW MAP LOADS (`NZMap.Busy`), as for a map pick, named after the map. ⚠️ STAYS OPEN ON
/// FAILURE, as `Pick` does, so the reason can be read. ⛔ THE HOST CHOOSES, refused on the action as well as hidden.
/// </summary>
static async void PlayMode( Gamemodes.Mode m )
{
if ( NZMap.Busy ) return;
if ( NZGame.IsClient )
{
Log.Info( "[nz-map] only the host chooses the map and the gamemode" );
return;
}
var map = ChosenMap;
if ( map is null ) return;
if ( !MapBrowserState.ChosenIsCurrent ) MapBrowserState.Loading = map.Title;
var ok = await Gamemodes.PlayOn( map.MapName, m.Name );
MapBrowserState.Loading = "";
if ( !ok ) return;
MapBrowserState.Close();
Mouse.Visibility = MouseVisibility.Hidden;
}
// ⚠️ EVERYTHING THE MARKUP READS HAS TO BE IN HERE. The hovered map especially: without it the preview never follows
// the cursor. And the load flags: without them the "Loading…" state never appears.
protected override int BuildHash()
=> System.HashCode.Combine(
Showing, MapBrowserState.Hovered,
NZMap.Busy, NZMap.Status, NZMap.CurrentRaw,
MapBrowserState.Loading,
// ⚠️ WHOLE SECONDS, not the raw TimeSince. The raw value changes every frame and would
// rebuild the whole panel 60 times a second; the clock only needs one rebuild per tick.
// Same reasoning as the lobby countdown hash.
// ⛔ ONLY WHILE A DOWNLOAD RUNS (2026-10-05). `DownloadStarted` counts up from the game's start whether or not anything
// downloads, so this redrew the open page every second for nothing. Each redraw read every map's config folder from
// the package: half of the published page's 8 fps.
NZMap.DownloadSize > 0f ? (int)NZMap.DownloadStarted.Relative : -1,
// ⚠️ AND THE PUBLISHED SIDE'S GAMEMODE STEP (2026-10-05): the map it shows, the gamemode pointed at, the one being played
// (its Playing badge), and which side this is. Nested, the call being at its eight.
System.HashCode.Combine( MapBrowserState.Chosen, MapBrowserState.HoveredMode, ActiveConfig.Current?.Name,
ActiveConfig.Current?.Map, Edition.IsPublished ) );
}