UI/MapBrowser.razor

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.

NetworkingFile Access
@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 ) );
}