A UI panel that draws the TAB-map shown on the scoreboard, loading a map image and room labels from maps/<map>_tabmap.json and placing markers for every player body with a directional nose and names. It loads and rebuilds the frame when the map data changes, lays out room labels to avoid overlap, and updates marker positions and headings each Tick.
using Sandbox;
using Sandbox.UI;
using Sandbox.UI.Construct;
using System;
using System.Collections.Generic;
namespace NZombies;
/// <summary>
/// THE TAB MAP — the whole place from above, under the scoreboard's table, with every player on it. Asked for on 2026-09-29:
/// *"when I open the TAB ui with the scoreboard and Easter egg steps and the zombies left, I also want to see a map of the whole
/// place"*. `Scoreboard.razor` hosts it in the board's map panel, right of the score and the Easter egg (the user's layout,
/// 2026-09-29: *"score on the left, map on the right"*), so it shows exactly when the board does; `nz_score_pin 1` holds it open.
///
/// ⛔ EVERY PLAYER POINTS THE WAY THEY LOOK, AND YOU ARE THE BIG ONE (*"make sure all players can be seen on the map
/// directionally too, and that I am bigger than the others"*). A teammate's heading is its controller's eye yaw, which the
/// engine syncs from its owner (`PlayerController.EyeAngles` is [Sync]); yours is the camera's.
///
/// ⛔ THE PICTURE IS MADE OFFLINE FROM THE MAP'S OWN GEOMETRY, NOT DRAWN HERE. `Sbox nzombies/Tools/basalt_map.py --game` finds
/// the ground a player can walk to in the BSP, cut by hand with its piece picker. Two files a map: `ui/maps/<map>_tabmap.png`
/// and `maps/<map>_tabmap.json` (the world-to-picture transform and the room names). A map without them has no map on its
/// board, and nothing else changes.
///
/// ⚠️ BUILT FROM CODE AND MOVED EVERY FRAME, `PlayerTagsHud`'s way: the board around it rebuilds on every kill, and markers that
/// move sixty times a second must not ask it to. If a rebuild ever takes this panel's children, `Tick` builds them again.
///
/// ⚠️ SIZED TO FIT: as wide as the board, and shorter when the table above leaves too little screen below it (a full lobby).
///
/// ⚠️ THE ROOM NAMES ARE TEXT, NOT PART OF THE PICTURE, so they stay sharp at the size they are drawn and wear the map's font —
/// and are laid out again at the size the frame is drawn, so none meets another (<see cref="Declutter"/>).
/// </summary>
public sealed class TabMap : Panel
{
// ⛔ NULLABLE-BACKED (INSTRUCTIONS §1): a static's initialiser does not run again on a hotload.
/// <summary>`nz_tabmap 0` hides it, `nz_tabmap 1` brings it back (this session).</summary>
public static bool On { get => _on ?? true; set => _on = value; }
static bool? _on;
/// <summary>The HUD units kept free under the map: its panel's foot and a margin to the screen's edge.</summary>
const float Below = 60f;
/// <summary>The least height it shrinks to; a board taller than the screen past that is the lesser evil.</summary>
const float MinHeight = 220f;
/// <summary>How long a map with no TAB map waits before looking again, so a file written while the game runs shows up.</summary>
const float RetryAfter = 5f;
/// <summary>The panel `nz_tabmap` reports on: the last one that ticked.</summary>
static TabMap _last;
static string _availKey, _availTitle;
static bool _avail;
static RealTimeSince _availSince;
static string _lastWarning;
string _key;
TabMapData _data;
TabMapData _builtFor;
RealTimeSince _sinceLoad;
Panel _frame;
float _w, _h;
/// <summary>The room names as built: each label, and where its room is as fractions of the picture.</summary>
readonly List<(Label Label, float U, float V)> _rooms = new();
/// <summary>The frame's size the names were last laid out for (<see cref="Declutter"/>); again when it changes.</summary>
Vector2 _roomsFor;
readonly Dictionary<NZPlayer, Marker> _live = new();
readonly List<NZPlayer> _gone = new();
/// <summary>One player on the map: a dot, the nose that points the local player's way, and a teammate's name.</summary>
sealed class Marker
{
public Panel Root;
public Label Name;
public float Yaw = float.NaN;
}
public TabMap()
{
AddClass( "tabmap" );
}
public override void Tick()
{
base.Tick();
_last = this;
Load();
var shown = On && _data is not null;
SetClass( "none", !shown );
if ( !shown ) return;
// ⚠️ A REBUILD OF THE BOARD MAY TAKE THE CHILDREN BUILT HERE; if it has, they are built again
if ( _frame is null || _frame.Parent != this || !ReferenceEquals( _builtFor, _data ) ) Build();
// ⚠️ NOTHING TO MOVE WHILE THE BOARD IS HIDDEN: `Scoreboard` fades it (`.hidden`, opacity 0) rather than removing it
if ( BoardHidden() ) return;
Fit();
Declutter();
Players();
}
/// <summary>This map's data, read when the map changes (and looked for again every few seconds while there is none).</summary>
void Load()
{
var key = NZMap.Current;
// ⚠️ ONCE PER MAP IN A PUBLISHED COPY (2026-10-05): its files never change, and each look goes through the package
if ( key == _key && (_data is not null || _sinceLoad < RetryAfter || Edition.IsPublished) ) return;
_key = key;
_sinceLoad = 0;
_data = Read( key );
}
/// <summary>The picture and the room names; the markers come back on the next `Players`.</summary>
void Build()
{
_frame?.Delete( true );
_live.Clear();
_w = _h = 0f;
_frame = Add.Panel( "frame" );
_frame.Style.BackgroundImage = Texture.Load( _data.Image );
_rooms.Clear();
_roomsFor = default;
foreach ( var l in _data.Labels ?? new List<TabMapLabel>() )
{
if ( string.IsNullOrWhiteSpace( l?.Name ) ) continue;
var room = _frame.Add.Label( l.Name.ToUpperInvariant(), "room" );
room.Style.Left = Length.Percent( l.U / _data.Width * 100f );
room.Style.Top = Length.Percent( l.V / _data.Height * 100f );
_rooms.Add( (room, l.U / _data.Width, l.V / _data.Height) );
}
_builtFor = _data;
}
bool BoardHidden()
{
for ( var p = Parent; p is not null; p = p.Parent )
if ( p.HasClass( "hidden" ) ) return true;
return false;
}
/// <summary>
/// As wide as the board, and as tall as that makes it — unless the screen below the frame's top, less `Below`, is shorter;
/// then that height, and the width that goes with it.
/// ⚠️ `Box.Rect` IS SCREEN PIXELS, `Style` THE HUD'S UNITS: `ScaleFromScreen` between them (`DragHandle`'s note).
/// </summary>
void Fit()
{
var scale = ScaleFromScreen;
var inner = Box.Rect.Width * scale;
if ( inner < 2f ) return; // not laid out yet
var aspect = _data.Width / _data.Height;
var room = (Screen.Height - _frame.Box.Rect.Top) * scale - Below;
var w = MathF.Min( inner, MathF.Max( MinHeight, room ) * aspect );
var h = w / aspect;
if ( MathF.Abs( w - _w ) < 0.5f && MathF.Abs( h - _h ) < 0.5f ) return;
_w = w;
_h = h;
_frame.Style.Width = Length.Pixels( w );
_frame.Style.Height = Length.Pixels( h );
}
/// <summary>
/// The room names on the frame as it is drawn now: each at its room, kept inside the frame, then a line down or up from any
/// placed before it that it would touch — the first of those that is free, else where it was.
///
/// ⛔ MEASURED HERE, NOT ONLY OFFLINE (2026-09-29, the user: *"they overlap eachother sometimes, like the gateway and crucible,
/// or the sanctum and relay"*). `basalt_map.py` places the names for a map 600 HUD px wide and up, but the frame is as wide as
/// the screen leaves it, and a name stays 13 px whatever the map's size, so on a narrower screen two could still meet.
///
/// ⚠️ ONCE A FRAME SIZE, and only when every name has been laid out: a label's box is its text's own size, which the frame's
/// size does not change. Until then it tries again next tick.
/// </summary>
void Declutter()
{
if ( _rooms.Count == 0 || _w < 2f ) return;
var size = new Vector2( _w, _h );
if ( size == _roomsFor ) return;
const float pad = 2f;
var scale = ScaleFromScreen;
var placed = new List<(float X0, float Y0, float X1, float Y1)>( _rooms.Count );
var at = new (float X, float Y)[_rooms.Count];
for ( var i = 0; i < _rooms.Count; i++ )
{
var (label, u, v) = _rooms[i];
var r = label.Box.Rect;
if ( r.Width < 1f || r.Height < 1f ) return; // not laid out yet
var w = r.Width * scale;
var h = r.Height * scale;
var x = Math.Clamp( u * _w, w / 2 + pad, MathF.Max( w / 2 + pad, _w - w / 2 - pad ) );
var y = Math.Clamp( v * _h, h / 2 + pad, MathF.Max( h / 2 + pad, _h - h / 2 - pad ) );
var chosen = y;
foreach ( var dy in new[] { 0f, h + pad, -(h + pad), 2f * (h + pad), -2f * (h + pad) } )
{
var top = y + dy - h / 2;
if ( dy != 0f && (top < pad || top + h > _h - pad) ) continue;
var free = true;
foreach ( var q in placed )
if ( x - w / 2 < q.X1 + pad && q.X0 < x + w / 2 + pad && top < q.Y1 + pad && q.Y0 < top + h + pad )
{
free = false;
break;
}
if ( !free ) continue;
chosen = y + dy;
break;
}
placed.Add( (x - w / 2, chosen - h / 2, x + w / 2, chosen + h / 2) );
at[i] = (x, chosen);
}
// ⚠️ PIXELS OF THE FRAME NOW, NOT PERCENT: the translate in `.room` still centres each on its point
for ( var i = 0; i < _rooms.Count; i++ )
{
_rooms[i].Label.Style.Left = Length.Pixels( at[i].X );
_rooms[i].Label.Style.Top = Length.Pixels( at[i].Y );
}
_roomsFor = size;
}
/// <summary>A marker for every body in the round; retire the rest; put each where its body stands.</summary>
void Players()
{
var me = NZPlayer.Local;
foreach ( var go in PlayerSpawner.AllBodies() )
{
var p = go.Components.Get<NZPlayer>( FindMode.EverythingInSelf );
if ( !p.IsValid() ) continue;
var want = !p.IsOutOfRound;
if ( want && !_live.ContainsKey( p ) ) _live[p] = NewMarker();
else if ( !want && _live.TryGetValue( p, out var off ) )
{
off.Root.Delete( true );
_live.Remove( p );
}
}
// ⚠️ A BODY CAN LEAVE THE SCENE — a disconnect — and the loop above only visits bodies that still exist
_gone.Clear();
foreach ( var (p, _) in _live )
if ( !p.IsValid() ) _gone.Add( p );
foreach ( var p in _gone )
{
_live[p].Root.Delete( true );
_live.Remove( p );
}
var cam = Game.ActiveScene?.Camera;
var myYaw = cam.IsValid() ? cam.WorldRotation.Yaw() : 0f;
foreach ( var (p, m) in _live )
{
// ⚠️ HELD AT THE PICTURE'S EDGE, NOT DROPPED, when a body is off it: a player who fell out of the map is still somewhere
var at = _data.ToPicture( p.WorldPosition );
m.Root.Style.Left = Length.Percent( Math.Clamp( at.x, 0f, 1f ) * 100f );
m.Root.Style.Top = Length.Percent( Math.Clamp( at.y, 0f, 1f ) * 100f );
var mine = p == me;
m.Root.SetClass( "me", mine );
m.Root.SetClass( "down", p.IsDown );
Face( m, mine ? myYaw : YawOf( p ) );
if ( mine ) continue;
var name = NZPlayers.NameOf( p );
name = string.IsNullOrWhiteSpace( name ) ? "Player" : name;
if ( m.Name.Text != name ) m.Name.Text = name;
}
}
Marker NewMarker()
{
var root = _frame.Add.Panel( "marker" );
root.Add.Panel( "nose" );
root.Add.Panel( "dot" );
var name = root.Add.Label( "", "name" );
return new Marker { Root = root, Name = name };
}
/// <summary>
/// Which way a body looks: its controller's eye yaw, synced from its owner, so a teammate's reads true on every machine;
/// the body's own turn if it has no controller.
/// </summary>
static float YawOf( NZPlayer p )
{
var c = p.Components.Get<PlayerController>( FindMode.EverythingInSelf );
return c.IsValid() ? c.EyeAngles.yaw : p.WorldRotation.Yaw();
}
/// <summary>
/// Turn a marker the way its player looks.
/// ⚠️ A WORLD YAW TURNS THE OTHER WAY ON SCREEN: yaw runs from +x toward +y, which is up the picture, and a screen rotation
/// runs clockwise. The nose points along +x unturned.
/// </summary>
static void Face( Marker m, float yaw )
{
if ( MathF.Abs( yaw - m.Yaw ) < 0.5f ) return;
m.Yaw = yaw;
var t = new PanelTransform();
t.AddRotation( 0f, 0f, -yaw );
m.Root.Style.Transform = t;
}
/// <summary>
/// Has map <paramref name="key"/> a TAB map? For the board, which asks every frame: read again only when the map changes or
/// every few seconds, and the map's name with it (<see cref="Title"/>).
/// </summary>
public static bool AvailableFor( string key )
{
// ⛔ ONCE PER MAP IN A PUBLISHED COPY (2026-10-05). The board asks every frame, and this re-read and re-parsed the map's
// TAB map every five seconds even when it had it, through the package. That's a hitch every five seconds in the lobby and
// in game alike. The editor keeps looking every few seconds, since a TAB map can be written while it runs.
if ( key == _availKey && (_availSince < RetryAfter || Edition.IsPublished) ) return _avail;
_availKey = key;
_availSince = 0;
_avail = Read( key ) is not null;
_availTitle = key;
foreach ( var o in MapLibrary.Originals )
if ( NZMap.KeyFor( o.MapName ) == key && !string.IsNullOrWhiteSpace( o.Name ) ) _availTitle = o.Name;
return _avail;
}
/// <summary>The last map <see cref="AvailableFor"/> looked at, by the name the lobby gives it ("Basalt").</summary>
public static string Title => _availTitle ?? "";
/// <summary>This map's TAB map, or null when it has none or it will not read.</summary>
public static TabMapData Read( string key )
{
if ( string.IsNullOrWhiteSpace( key ) ) return null;
var path = $"maps/{key}_tabmap.json";
try
{
if ( !FileSystem.Mounted.FileExists( path ) ) return null;
var d = FileSystem.Mounted.ReadJson<TabMapData>( path );
if ( d is not null && d.Width > 0f && d.Height > 0f && d.Cell > 0f && d.Scale > 0f && !string.IsNullOrWhiteSpace( d.Image ) )
return d;
Warn( $"[nz-tabmap] {path} is missing its picture, size or transform — no map shown" );
}
catch ( Exception e )
{
Warn( $"[nz-tabmap] {path} would not read ({e.Message}) — no map shown" );
}
return null;
}
/// <summary>A warning said once, not at every retry.</summary>
static void Warn( string message )
{
if ( message == _lastWarning ) return;
_lastWarning = message;
Log.Warning( message );
}
// ── console ──────────────────────────────────────────────────────────────────────────────────────────────────────────
/// <summary>
/// `nz_tabmap [0|1|reload|<map>]` — the map on the TAB board: `0` hides it and `1` shows it (this session); `reload` reads
/// this map's picture and data again, after `basalt_map.py --game` has rewritten them; a map's name reports on that map's
/// files instead of the loaded one's (in the editor, with no map loaded, `nz_tabmap ttt_basalt_d`). Bare: what is loaded,
/// how big it is drawn, and where each player lands on it.
/// </summary>
[ConCmd( "nz_tabmap" )]
public static void Cmd( string what = "" )
{
var key = NZMap.Current;
switch ( what.ToLowerInvariant() )
{
case "": break;
case "0" or "off": On = false; break;
case "1" or "on": On = true; break;
case "reload":
_lastWarning = null;
if ( _last is not null )
{
_last._key = null;
_last._builtFor = null;
}
break;
default: key = what; break;
}
var other = key != NZMap.Current ? $" (not the loaded map, '{NZMap.Current}')" : "";
var data = Read( key );
if ( data is null )
{
Log.Info( $"[nz-tabmap] {(On ? "ON" : "OFF")} · map '{key}'{other} has no TAB map (maps/{key}_tabmap.json) — its board shows none" );
return;
}
var picture = Texture.Load( data.Image );
Log.Info( $"[nz-tabmap] {(On ? "ON" : "OFF")} · map '{key}'{other} · {data.Image} {data.Width:0}x{data.Height:0}"
+ (picture is null ? " ⚠️ THE PICTURE DID NOT LOAD" : "")
+ $" · {data.Labels?.Count ?? 0} room name(s)"
+ (_last is null ? " · no board has built it yet (it does the first time the board exists in a game)"
: $" · on the board: {_last._live.Count} marker(s), drawn {_last._w:0}x{_last._h:0}")
+ (what == "reload" ? " · reloading" : "") );
foreach ( var go in PlayerSpawner.AllBodies() )
{
var p = go.Components.Get<NZPlayer>( FindMode.EverythingInSelf );
if ( !p.IsValid() ) continue;
var at = data.ToPicture( p.WorldPosition );
var off = at.x < 0f || at.x > 1f || at.y < 0f || at.y > 1f;
Log.Info( $"[nz-tabmap] {NZPlayers.NameOf( p ) ?? p.GameObject.Name}{(p == NZPlayer.Local ? " (you)" : "")}"
+ $" at ({p.WorldPosition.x:0}, {p.WorldPosition.y:0}) → {at.x * 100f:0.#}% across, {at.y * 100f:0.#}% down"
+ (off ? " ⚠️ OFF THE PICTURE, held at its edge" : "")
+ (p.IsOutOfRound ? " · out of the round, not drawn" : p.IsDown ? " · down" : "") );
}
}
}
/// <summary>
/// `maps/<map>_tabmap.json`, as `basalt_map.py --game` writes it: the picture, its size in pixels, and the world-to-picture
/// transform — `u = (x - X0) / Cell * Scale`, `v = (Y1 - y) / Cell * Scale`, +y up the picture.
/// </summary>
public sealed class TabMapData
{
public string Map { get; set; }
public string Image { get; set; }
public float Width { get; set; }
public float Height { get; set; }
public float X0 { get; set; }
public float Y1 { get; set; }
public float Cell { get; set; }
public float Scale { get; set; }
public List<TabMapLabel> Labels { get; set; }
/// <summary>A world point on the picture, as fractions of its width and height: 0..1 on it, outside that off it.</summary>
public Vector2 ToPicture( Vector3 world ) => new(
(world.x - X0) / Cell * Scale / Width,
(Y1 - world.y) / Cell * Scale / Height );
}
/// <summary>A room's name and where it goes on the picture, in the picture's pixels.</summary>
public sealed class TabMapLabel
{
public string Name { get; set; }
public float U { get; set; }
public float V { get; set; }
}