Editor component for the map authoring tool, handling the tool gun actions and state. It tracks the active tool and many per-tool settings, traces the aim, routes left/right click and console-driven placement to specific Add/Remove methods, measures and builds polygonal blocks (debris/walls/rooms/fog), and updates editor overlays and managers.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// The tool gun's brain — what happens when a placeable tool is selected and
/// you click.
///
/// One component holds the active tool and routes LMB/RMB to it. Selecting a
/// tool in the Q menu sets ActiveTool; clearing it puts you back to normal.
///
/// ⚠️ PLACE AND REMOVE ARE PUBLIC METHODS, and input only calls them. That is
/// deliberate: nobody can hold a mouse button over MCP, so without a
/// programmatic entry point this feature could not be tested at all until
/// someone was sitting at the machine. Commands drive the same code path the
/// mouse does, so testing it proves the real thing.
/// </summary>
public sealed class MapEditor : Component
{
/// <summary>
/// Which placeable is armed. Empty means the tool gun is idle.
///
/// ⚠️ CHANGING TOOL THROWS AWAY A HALF-DRAWN FOOTPRINT. Debris and invisible
/// walls share the corner list, and what gets BUILT is decided by whichever
/// tool is armed when the last click lands — so clicking three corners with
/// one tool, switching, and clicking the fourth would silently produce the
/// other kind of thing. Cheaper to lose three clicks than to place a
/// permanent wall where a buyable door was meant to go.
/// </summary>
[Property]
public string ActiveTool
{
get => _activeTool;
set
{
if ( _activeTool == value ) return;
_activeTool = value;
ResetCorners();
}
}
string _activeTool = "";
/// <summary>How far the tool reaches.</summary>
[Property] public float Reach { get; set; } = 4096f;
/// <summary>Removal radius — how close your aim must land to a marker.</summary>
[Property] public float RemoveRadius { get; set; } = 48f;
// ── zombie spawn tool settings ───────────────────────────────────────────
/// <summary>
/// Flag the next zombie spawn is placed with.
///
/// ⚠️ BLANK OR "0" MEANS UNLINKED — eligible from the start. That is the
/// right default: a map with every spawn linked has nowhere to spawn on
/// round 1 and the wave stalls immediately.
///
/// ⚠️ Normalised in the SETTER, so every route in — the settings panel, a
/// console command, the component inspector — stores the same thing. "0",
/// " 0 " and "" are one flag, and only one spelling should ever reach a
/// saved config.
/// </summary>
[Property]
public string SpawnLink
{
get => _spawnLink;
set => _spawnLink = DoorLinks.Clean( value );
}
string _spawnLink = DoorLinks.Unlinked;
/// <summary>Earliest round the next zombie spawn may be used. 0/1 = always.</summary>
[Property] public int SpawnActiveRound { get; set; }
/// <summary>Gate the next zombie spawn on the electricity being on.</summary>
[Property] public bool SpawnRequiresPower { get; set; }
/// <summary>Which special the next special spawner will be stamped with.
/// Stamped at placement like Link and ActiveRound, for the same reason: you
/// know what belongs here while standing here.</summary>
[Property] public string SpecialEnemy { get; set; } = SpecialEnemies.Hellhound;
/// <summary>
/// Which boss a boss spawner places.
///
/// ⚠️ A SECOND FIELD RATHER THAN REUSING `SpecialEnemy`, because the two rosters are
/// different sets - see `SpecialEnemies.BossNames` / `NonBossNames`. Sharing one field would let
/// a boss spawner be pointed at a hellhound, or a special spawner at Brutus.
/// </summary>
[Property] public string BossEnemy { get; set; } = SpecialEnemies.Brutus;
// ── debris tool settings ─────────────────────────────────────────────────
//
// The barrier tool needs a link and a price per placement, and there is no
// per-tool settings panel yet. Held here so the Q menu button and the
// console command place identical barriers, and set with nz_debris_set.
/// <summary>Flag the next barrier opens. ⚠️ Never blank or "0" — both mean
/// "already open", so the barrier would gate nothing and buying it would be
/// a no-op. Normalised in the setter, same as SpawnLink.</summary>
[Property]
public string DebrisLink
{
get => _debrisLink;
set => _debrisLink = DoorLinks.Clean( value );
}
string _debrisLink = "1";
/// <summary>Cost of the next barrier. (The original's default price.)</summary>
[Property] public int DebrisPrice { get; set; } = 1000;
/// <summary>Gate the next barrier on the electricity. ⚠️ Combined with
/// Price 0 this means "opens ITSELF when the power comes on", not "free to
/// buy" — see Debris.RequiresPower.</summary>
[Property] public bool DebrisRequiresPower { get; set; }
/// <summary>
/// Can a player buy the next barrier? ⚠️ Off does NOT make it free or open
/// — it removes the offer entirely, so the barrier only opens when another
/// one carrying the same flag is bought. See Debris.Buyable.
/// </summary>
[Property] public bool DebrisBuyable { get; set; } = true;
/// <summary>How to describe the next barrier's cost in a log line — a
/// non-buyable one has no price worth quoting.</summary>
string DebrisCostWord => DebrisBuyable ? $"{DebrisPrice} points" : "NOT BUYABLE";
/// <summary>
/// Copy the model of whatever you aim at, instead of using a dev box.
///
/// ⚠️ This COPIES the model — it does not adopt the object. Adopting a map's
/// own prop needs an identity that survives save/load, and the bsp's child
/// GameObjects are regenerated per load, so a stored id would dangle. A copy
/// looks the same and works on any map.
/// </summary>
[Property] public bool DebrisCopyAimedModel { get; set; } = true;
/// <summary>Fallback barrier model when nothing is copied.</summary>
[Property] public string DebrisModel { get; set; } = "models/dev/box.vmdl";
/// <summary>Surface for the next barrier. Empty = untextured.</summary>
[Property] public string DebrisMaterial { get; set; }
= "materials/concrete/concretewall052a.vmat";
/// <summary>
/// Build barriers as BLOCKS from clicked corners, rather than placing a prop.
///
/// The default, because a map needs a barrier shaped like its doorway and a
/// prop only fits if the map happens to contain one the right shape.
/// </summary>
[Property] public bool DebrisBlockMode { get; set; } = true;
/// <summary>
/// FALLBACK height only — the height is normally CLICKED.
///
/// ⚠️ This used to be the height, typed into the settings panel. It is now
/// what gets used when there is no click to measure: `nz_debris_build` with
/// no argument, and a height click that lands below the footprint. Clicking
/// it is better because a doorway's height is something you can SEE in the
/// map and cannot easily guess in units.
/// </summary>
[Property] public float DebrisHeight { get; set; } = 128f;
/// <summary>How many corners close a footprint. 4 by default; 2 is enough
/// for a plain rectangle and is accepted early via nz_debris_build.
/// ⚠️ The HEIGHT click is not one of these — a 4-corner block takes five
/// clicks, four on the floor and one at the top.</summary>
[Property] public int DebrisCorners { get; set; } = 4;
/// <summary>
/// Barricade height, in units. 44 is a vault height — see BarricadeSpot.
///
/// ⚠️ Settable but deliberately NOT clicked, unlike a debris block's height.
/// The whole point of a barricade is that it can be climbed, so its height is
/// a tool SETTING a mapper changes on purpose rather than a value they hit by
/// aiming a click a bit high.
/// </summary>
[Property] public int BarricadeHeight { get; set; } = 44;
/// <summary>Boards a new barricade starts with. The entity caps at 6.</summary>
[Property] public int BarricadeBoards { get; set; } = 6;
/// <summary>Next nav link is usable both ways. Off = A to B only, which is
/// what a drop wants — see NavLinkSpot.BiDirectional.</summary>
[Property] public bool NavLinkTwoWay { get; set; }
/// <summary>Mouth width of the next nav link — also the WIDTH of a drop zone,
/// i.e. how much of the ledge zombies may go over.</summary>
[Property] public float NavLinkRadius { get; set; } = 48f;
/// <summary>Next link allows JUMPING from the LOWER point to the HIGHER one.</summary>
[Property] public bool NavLinkJump { get; set; } = true;
/// <summary>Next link allows DROPPING from the HIGHER point to the LOWER one.</summary>
[Property] public bool NavLinkDrop { get; set; } = true;
/// <summary>Next link is crossed by WALKING — no jump or drop clip, normal speed, no waiting at
/// either end. See <see cref="NavLinkSpot.Walk"/>.
///
/// ⚠️ OFF BY DEFAULT, because the flag is for links that only exist to join geometry the mesh
/// would not. A real ledge still wants its jump: turning this on everywhere would have zombies
/// gliding up walls at walking pace.</summary>
[Property] public bool NavLinkWalk { get; set; } = false;
/// <summary>One click makes a DROP ZONE: stand on the ledge, click, and the
/// landing is found by tracing.
///
/// ⚠️ ON BY DEFAULT. Marking a ledge as droppable is the common job by a wide
/// margin, and it is the one the two-click flow does worst — clicking the
/// lower end by eye is how you get a link that never connects. Turn it off for
/// a link whose far end is somewhere a trace would not find, like across a
/// gap or up onto a roof.</summary>
[Property] public bool NavLinkDropMode { get; set; } = true;
/// <summary>Price a newly placed mystery box is authored with.</summary>
[Property] public int BoxPrice { get; set; } = 950;
/// <summary>May a newly placed box spot hold the box at round one?</summary>
[Property] public bool BoxCanStart { get; set; } = true;
/// <summary>
/// How far a block extends BELOW its lowest corner, so it always reaches the
/// walkable surface. Larger than the navmesh agent step (24) on purpose —
/// see BuildBlock.
/// </summary>
public const float Sink = 40f;
// ── invisible wall tool settings ─────────────────────────────────────────
//
// Only two, because the whole point of this tool is that it has none of the
// others: no price, no flag, no power gate. Height and corner count are
// shared with the debris tool deliberately — it is the same clicking flow.
/// <summary>Draw the next barrier, or leave it solid-but-unseen. ⚠️ ON by
/// default, unlike WallVisible — see Debris.Visible.</summary>
[Property] public bool DebrisVisible { get; set; } = true;
/// <summary>Draw the next wall, or leave it invisible. ⚠️ Off by default —
/// see InvisibleWall.Visible.</summary>
[Property] public bool WallVisible { get; set; }
/// <summary>
/// Stamp the next wall as blocking the horde too. ⚠️ Off by default — see
/// InvisibleWall.BlocksZombies.
///
/// ⚠️ STAMPED AT BUILD, like WallVisible and every other setting on this tool.
/// Changing it does not alter walls already placed; `nz_wall_zombies <index>` is
/// what edits an existing one.
/// </summary>
[Property] public bool WallBlocksZombies { get; set; }
// ── Damage wall, stamped at build ────────────────────────────────────────
/// <summary>Damage dealt to each player inside, per tick.</summary>
[Property] public float DamageWallDamage { get; set; } = 20f;
/// <summary>Seconds between ticks.
///
/// ⚠️ The two are a PAIR — 20 every 1s and 200 every 10s are not the same wall even
/// though the rate matches, because the first is survivable at a run and the second
/// kills anyone unlucky with the phase.</summary>
[Property] public float DamageWallInterval { get; set; } = 1f;
/// <summary>Draw it while authoring. Whether a PLAYER sees it is DamageWallShown.</summary>
[Property] public bool DamageWallVisible { get; set; } = true;
/// <summary>
/// Leave it drawn in a round — DamageWall.VisibleInGame.
///
/// ⚠️ OFF, like the config field, so nothing about an existing flow changes. Turning it on
/// is how a hazard you are MEANT to see gets made: a lava pool, a pit of steam, an electric
/// floor. Left off, the wall is exactly what it has always been — an invisible killbox.
///
/// ⚠️ STAMPED AT BUILD, like every other setting on this tool. `nz_dmgwall_show` is what
/// edits the walls already placed.
/// </summary>
[Property] public bool DamageWallShown { get; set; }
/// <summary>
/// Surface for the next damage wall. Used whenever it is drawn at all.
///
/// ⛔ ITS OWN FIELD, NOT `WallMaterial`. That one belongs to the invisible-wall tool and
/// its list is brick and concrete — the surfaces a boundary wants. A damage wall wants the
/// opposite: something that announces itself. Sharing one field would mean every switch
/// between the two tools silently retextured the other.
/// </summary>
[Property] public string DamageWallMaterial { get; set; } = "materials/nz/lava.vmat";
/// <summary>Surface for the next wall. Only used when it is visible.</summary>
[Property] public string WallMaterial { get; set; }
= "materials/concrete/concretewall052a.vmat";
// ── Light, stamped at build ───────────────────────────────────────────
/// <summary>Colour of the next light, as hex. The panel's swatch row writes this.</summary>
[Property] public string LightColor { get; set; } = "#ffeeb8";
/// <summary>Multiplier on that colour — a swatch can pick a hue but not a level.</summary>
[Property] public float LightBrightness { get; set; } = 1f;
/// <summary>How far it reaches.</summary>
[Property] public float LightRadius { get; set; } = 400f;
/// <summary>⛔ OFF BY DEFAULT. Shadows are the single biggest cost in this engine — see
/// `LightBake` and the 3.5x fps swing canyon_labs paid for 71 of them.</summary>
[Property] public bool LightShadows { get; set; }
/// <summary>Only lit once the power is on.</summary>
[Property] public bool LightRequiresPower { get; set; }
// ── fog area ─────────────────────────────────────────────────────────────
/// <summary>Soot is a warm near-black. A neutral grey reads as mist, not ash.</summary>
[Property] public string FogColor { get; set; } = "#2a2320";
/// <summary>
/// 0 to 1, and it wants to stay low.
///
/// ⚠️ THE NUMBER THAT LOOKS TOO SMALL IS THE RIGHT ONE. Fog reads as atmosphere well below
/// where it reads as a value worth typing — 0.18 is already visible across a room, 0.5 is a
/// smoke grenade. "Something light" was the request, and this default is under what feels right.
/// </summary>
[Property] public float FogDensity { get; set; } = 0.18f;
/// <summary>
/// How far inside the drawn boundary the haze reaches full strength, in units.
///
/// ⚠️ A DISTANCE, NOT A FRACTION — the shape is a drawn polygon now, and a fraction of it would
/// mean a 40-unit fade in a doorway and a 900-unit one in a hall. The height comes from the
/// second click, so there is no separate height setting any more.
/// </summary>
[Property] public float FogFeather { get; set; } = 200f;
// ── room zone ────────────────────────────────────────────────────────────
/// <summary>
/// The name stamped on the next room zone — what the top left says once a player walks into it (`RoomNames`). A zone
/// already drawn is renamed in Settings → Map → Room names, or with `nz_room_zone_name`.
/// </summary>
[Property] public string RoomZoneName { get; set; } = "";
// ── Sound, stamped at build ──────────────────────────────────────────
/// <summary>The `.sound` EVENT for the next sound — not an audio file. See SoundSpot.</summary>
[Property] public string SoundEventPath { get; set; } = "";
[Property] public float SoundSpotVolume { get; set; } = 1f;
[Property] public float SoundSpotDistance { get; set; } = 1000f;
/// <summary>Seconds between re-triggers for the next sound. 0 plays it once.
///
/// ⚠️ MATCH THE CLIP'S LENGTH — `SoundEvent` exposes no duration, so nothing can work it out
/// for you. The editor logs it on import.</summary>
[Property] public float SoundSpotRepeat { get; set; } = 20f;
// ── Springboard, stamped at build — and onto one clicked inside (AddSpringboardAt) ──────────────────────
float? _springStrength, _springRadius;
bool? _springSafe;
/// <summary>
/// Upward speed of the next springboard, in units per second. 700, the perk's; see SpringboardSpot.Strength.
///
/// ⚠️ NULLABLE-BACKED, as the statics here are (INSTRUCTIONS §1): a hotload can bring these to an editor that already
/// exists without running an initialiser, and `= 700f` would then read 0, a pad that throws nobody.
/// </summary>
[Property] public float SpringboardStrength { get => _springStrength ?? 700f; set => _springStrength = value; }
/// <summary>How wide the next springboard is, flat, in units. 48, the perk's.</summary>
[Property] public float SpringboardRadius { get => _springRadius ?? 48f; set => _springRadius = value; }
/// <summary>No fall damage on the landing after the next springboard's throw. On.</summary>
[Property] public bool SpringboardSafeLanding { get => _springSafe ?? true; set => _springSafe = value; }
/// <summary>Corners clicked so far. Cleared when the block is built or reset.</summary>
readonly System.Collections.Generic.List<Vector3> _corners = new();
/// <summary>
/// The footprint is closed and the NEXT click sets the height.
///
/// ⚠️ A mode, not a number, because the same mouse button now means two
/// different things — another corner, or the top of the wall. The overlay
/// says which one it is currently listening for; without that, a click that
/// "did nothing" and a click that silently built a barrier look the same.
/// </summary>
bool _awaitingHeight;
/// <summary>True while the tool wants a height click rather than a corner.</summary>
public bool AwaitingHeight => _awaitingHeight;
public int PendingCorners => _corners.Count;
public bool HasTool => !string.IsNullOrEmpty( ActiveTool );
protected override void OnUpdate()
{
// ⚠️ BEFORE EVERY RETURN: the markers' countdown must see every frame the overlay does, or the two drift apart and
// the markers flicker (`MarkerLife`).
_markerLife -= Time.Delta;
// ⚠️ Only in creative. Placing spawns mid-round would be chaos, and the
// markers are creative-only anyway.
if ( !NZGame.IsCreative ) return;
// ⛔ ONLY THE INPUT IS GATED ON HAVING A TOOL — THE MARKERS ARE NOT.
//
// `if ( !HasTool ) return;` used to sit at the top of this method, which
// meant walking into creative showed an EMPTY MAP: every spawn, barrier
// and switch in the config was invisible until you happened to arm a tool.
// Reported as "otherwise theres nothing placed until i select a tool" —
// and it reads as a lost config, not as a hidden overlay.
//
// Holstering is not a request to hide what is already placed. The setting
// for that is PreviewMode, which exists precisely so it can be ASKED for.
if ( HasTool )
{
// In block mode LMB collects corners instead of placing immediately —
// the same "LMB corners · R reset" flow the original's wall tools use.
if ( Input.Pressed( "Attack1" ) )
{
// ⚠️ Clicking an EXISTING barrier edits it rather than building
// another. Without this, retagging a door meant deleting and
// rebuilding it — losing a footprint you had clicked out by hand
// just to change a price.
if ( ActiveTool == NZTools.Debris && ApplyToAimedDebris() ) { }
else if ( ActiveTool == NZTools.Debris && DebrisBlockMode )
{
// Say what was under the crosshair when we chose to build
// instead of edit. "It made a new wall" and "it did not
// recognise the wall I was pointing at" look identical from the
// outside and are completely different faults.
if ( DebrisDebug ) ReportAimedForEdit();
AddCorner();
}
// ⚠️ ALWAYS CORNERS — an invisible wall has no prop mode. There is
// no such thing as copying a map prop's model for something whose
// default state is not being drawn at all.
else if ( ActiveTool == NZTools.InvisibleWall ) AddCorner();
else if ( ActiveTool == NZTools.DamageWall ) AddCorner();
else if ( ActiveTool == NZTools.RoomZone ) AddCorner();
// ⚠️ TWO POINTS, NO HEIGHT CLICK. A barricade is a LINE, and its
// height is fixed on purpose — see BarricadeSpot.Height. Sharing
// AddCorner would drag in the four-corner count and the height
// prompt, neither of which applies.
else if ( ActiveTool == NZTools.Barricade ) AddBarricadePoint();
else if ( ActiveTool == NZTools.NavLink ) AddNavLinkPoint();
// ⚠️ ONE CLICK. A box is an object you stand at, not a span you
// build — so it goes through Place() like a spawn marker rather
// than through the corner flow.
else if ( ActiveTool == NZTools.MysteryBox ) AddMysteryBox();
else if ( ActiveTool == NZTools.PackAPunch ) AddPackAPunch();
else if ( ActiveTool == NZTools.Wunderfizz ) AddWunderfizz();
else if ( ActiveTool == NZTools.PerkMachine ) AddPerkMachine();
else if ( ActiveTool == NZTools.Teleporter ) AddTeleporterPoint();
else if ( ActiveTool == NZTools.Springboard ) AddSpringboard();
else if ( ActiveTool == NZTools.MapLight ) AddMapLight();
else if ( ActiveTool == NZTools.FogArea ) AddCorner();
else if ( ActiveTool == NZTools.SoundSpot ) AddSoundSpot();
else if ( ActiveTool == NZTools.SoulBox ) AddSoulBox();
else if ( ActiveTool == NZTools.AmmoBox ) AddAmmoBox();
else if ( ActiveTool == NZTools.BuyableEnding ) AddEnding();
else if ( ActiveTool == NZTools.Misery ) AddMisery();
else if ( ActiveTool == NZTools.Clue ) AddClue();
else if ( ActiveTool == NZTools.Pressable ) AddPressable();
else if ( ActiveTool == NZTools.Shootable ) AddShootable();
else if ( ActiveTool == NZTools.TradeTable ) AddTradeTable();
else if ( ActiveTool == NZTools.BuildTable ) AddBuildTable();
else if ( ActiveTool == NZTools.BuildPart ) AddBuildPart();
else if ( ActiveTool == NZTools.Arsenal ) AddArsenal();
else Place();
}
if ( Input.Pressed( "Attack2" ) ) RemoveAimed();
if ( Input.Pressed( "Reload" ) ) ResetCorners();
}
// Placing still works in preview — only the overlays stop drawing.
// ⛔ TEN TIMES A SECOND, NOT EVERY FRAME — see `MarkerLife`. Hidden, the next showing draws at once.
if ( !NZGame.ShowAuthoringVisuals ) _markerLife = -1f;
else if ( _markerLife < 0f )
{
DrawMarkers();
_markerLife = MarkerLife;
}
// ⛔ DRAWN HERE, NOT AS THE LAST LINE OF DrawDebris, WHICH IS WHERE IT
// USED TO LIVE. That put the corners you are CURRENTLY CLICKING at the
// end of the pass that draws ALREADY-PLACED barriers — six unrelated draw
// calls deep, behind a config lookup and a per-item loop. Anything that
// stopped that pass early took your in-progress footprint with it, which
// is what "sometimes the orange nodes stop being visible" was.
//
// Pending corners are TOOL STATE, not a marker for something placed. They
// belong on their own line where nothing else can gate them.
//
// ⚠️ And NOT under ShowAuthoringVisuals either. Preview mode hides placed
// markers so the map can be judged as a player sees it — but a footprint
// you have half-clicked is unsaved work, and hiding it makes it look
// thrown away. Toggling preview mid-build must not cost you four clicks.
// ⚠️ …EXCEPT IN PHOTO MODE (`nz_photo`), which is the map alone — the corners come back with it
if ( Scene.DebugOverlay is not null && !MapPhotoMode.On )
DrawPendingCorners( Scene.DebugOverlay );
}
/// <summary>
/// The editor component, creating it on the player if there is none.
///
/// ⛔ THE MARKERS CANNOT DRAW FROM A COMPONENT THAT DOES NOT EXIST. This used
/// to be created lazily by whatever touched it first — which in practice was
/// arming a tool from the Q menu — so a fresh creative session had no
/// MapEditor at all and therefore no overlay, no matter what the drawing code
/// did. Same shape as DebrisManager.Ensure, and for the same reason.
/// </summary>
public static MapEditor Ensure()
{
var p = NZPlayer.Local;
return p?.Components.GetOrCreate<MapEditor>();
}
// ── placing ──────────────────────────────────────────────────────────────
/// <summary>
/// Where the tool is pointing, or null if it hit nothing.
///
/// ⚠️ <paramref name="quiet"/> exists for the PER-FRAME callers. The height
/// preview traces every frame while the tool waits for a height click, and
/// the miss branch logs — pointing at the sky for two seconds would put a
/// hundred identical lines in the console and bury whatever came before.
/// A miss is only worth reporting when a click asked for something.
/// </summary>
public SceneTraceResult? AimTrace( bool quiet = false )
{
var cam = Scene.Camera;
// ⚠️ Distinguish the two failures. Both used to log "aiming at nothing",
// which is misleading — a missing camera and a ray into the sky need
// completely different fixes, and one of them is a bug.
if ( !cam.IsValid() )
{
if ( !quiet ) Log.Warning( "[nz] tool: no active camera — cannot aim" );
return null;
}
var from = cam.WorldPosition;
var to = from + cam.WorldRotation.Forward * Reach;
var tr = Scene.Trace.Ray( from, to )
.IgnoreGameObjectHierarchy( GameObject )
.Run();
if ( !tr.Hit )
{
if ( !quiet )
Log.Info( $"[nz] tool: ray from {from} hit nothing within {Reach}" );
return null;
}
return tr;
}
/// <summary>
/// Place the active tool's object where you are aiming.
///
/// ⚠️ Yaw faces the PLACER, not away. Stand in a room, point at the far
/// wall, and someone spawning there looks back into the room rather than at
/// the wall — which is what you want and the opposite of the obvious
/// implementation.
/// </summary>
public bool Place()
{
var hit = AimTrace();
if ( hit is null ) { Log.Info( "[nz] tool: aiming at nothing" ); return false; }
// Remember what is under the crosshair so the debris tool can copy its
// model. Only meaningful for an AIMED placement — an offset placement
// has no crosshair target, so this stays null and the fallback is used.
_aimedModel = ModelNameOf( hit.Value.GameObject );
// ⚠️ THE NORMAL GOES WITH THE POINT. Without it a tool can only know
// WHERE you clicked, never WHAT you clicked on — and "lie flat against
// that surface" is a question only the normal can answer.
var placed = PlaceAt( hit.Value.HitPosition, hit.Value.Normal );
_aimedModel = null;
return placed;
}
/// <summary>Model of the thing under the crosshair during the current
/// Place() call. Null when placing by offset.</summary>
string _aimedModel;
static string ModelNameOf( GameObject go )
{
var r = go?.Components.Get<ModelRenderer>( FindMode.EverythingInSelfAndAncestors );
return r.IsValid() && r.Model is not null && !string.IsNullOrWhiteSpace( r.Model.Name )
? r.Model.Name
: null;
}
/// <summary>
/// Place at a specific point: on the clicked surface when that is a floor, else dropped to the floor below.
///
/// ⚠️ Anything not on a floor is floor-traced. A point picked in mid-air — from a capped reach,
/// or a ray that clipped a railing — would otherwise become a spawn point
/// hanging in space, which is worse than no spawn point at all because it
/// looks placed.
/// </summary>
public bool PlaceAt( Vector3 point, Vector3? normal = null )
{
// ⛔ ON THE SURFACE THAT WAS CLICKED. The drop to the floor used to start 96 units OVER the click, so a click on a
// floor under a table, a shelf, a stair or a low ceiling landed ON TOP of that instead — or in mid-air, when the
// trace began inside a ceiling slab (the user, 2026-10-01: "sometimes it appears in the surface above it instead of
// the surface i clicked"). A floor-like surface (a floor, a step, a ramp) IS the floor: the click point stands.
//
// ⚠️ A WALL OR A CEILING STILL DROPS TO THE FLOOR, from just off the surface rather than from 96 units up. Only a
// placement with no surface at all (`nz_place`, `nz_place_offset`) keeps the 96-unit lift, which an offset point on
// stairs rising ahead of the player needs to land on the step rather than inside it.
Vector3 pos;
if ( normal is Vector3 n && n.z >= 0.7f )
pos = point;
else
{
var from = normal is Vector3 w ? point + w * 4f : point + Vector3.Up * 96f;
var down = Scene.Trace
.Ray( from, from - Vector3.Up * 4100f )
.IgnoreGameObjectHierarchy( GameObject )
.Run();
pos = down.Hit && !down.StartedSolid ? down.HitPosition : point;
}
var yaw = Rotation.LookAt( (WorldPosition - pos).WithZ( 0 ) ).Yaw();
switch ( ActiveTool )
{
// ⚠️ THESE TWO ARE HANDLED IN THE CLICK BRANCH AS WELL, and they need to
// be here too: left-click routes straight to Add*(), but `nz_place` and
// `nz_place_offset` come through PlaceAt, and without a case they fell to
// the default and silently did nothing. The mystery box had that gap
// already — armed from the Q menu it placed on click and ignored the
// console entirely.
case NZTools.PackAPunch:
return AddPackAPunchAt( pos, normal ?? Vector3.Up );
case NZTools.MysteryBox:
return AddMysteryBoxAt( pos, normal ?? Vector3.Up );
// ⚠️ A CASE HERE AS WELL AS THE CLICK PATH. Console placement comes
// through PlaceAt, and a tool without a case falls to the default and
// silently does nothing — the gap the mystery box already had.
case NZTools.Arsenal:
return AddArsenalAt( pos, normal ?? Vector3.Up );
case NZTools.PlayerSpawn:
ActiveConfig.Current.PlayerSpawns.Add( new SpawnPoint( pos, yaw ) );
Log.Info( $"[nz] player spawn placed at {pos} facing {yaw:0}"
+ $" ({ActiveConfig.Current.PlayerSpawns.Count} total)" );
return true;
// ⚠️ ON THE CLICK, FACING OUT OF THE SURFACE — the wall buy's placement below, since the first four slots were
// wall buys. `point`, not the floor-traced `pos`.
case NZTools.HexSlot:
return HexSlotManager.PlaceOn( point, normal );
case NZTools.WallBuy:
{
// ⛔ THE CLICK POINT, NOT THE FLOOR — same as the power switch
// below, and for the same reason: a wallbuy is WALL-MOUNTED, so
// dropping it to the ground under the wall puts it where nobody
// pointed. `point`, not `pos`.
//
// ⚠️ Faces OUT along the surface normal. Facing along the aim
// direction would bury the display model inside the wall.
var wpos = point + (normal ?? Vector3.Up) * 1.5f;
var wrot = normal is null
? Rotation.FromYaw( yaw )
: Rotation.LookAt( normal.Value );
var mgr = WallBuyManager.Ensure();
if ( mgr is null ) { Log.Warning( "[nz] no wallbuy manager" ); return true; }
var buy = mgr.Place( wpos, wrot,
ToolSettings.WallBuyWeapon, ToolSettings.WallBuyPrice,
ToolSettings.WallBuyRarity );
Log.Info( $"[nz] weapon buy placed: {Rarity.NameFor( buy.Rarity )}"
+ $" {buy.WeaponName} @ {buy.Price}"
+ $" (ammo {buy.AmmoPrice})"
+ (normal is null ? " — no surface, facing yaw" : " flat to the surface")
+ $" ({mgr.All.Count} total)" );
return true;
}
case NZTools.PowerSwitch:
{
// ⛔ ON THE CLICK, NOT ON THE FLOOR. This used to take the
// floor-traced `pos` and lift it half a box — so a switch aimed
// at a wall dropped to the ground below and stood there, which is
// never where anyone pointed. A power box is WALL-MOUNTED; the
// point you clicked is the point it goes.
//
// ⚠️ `point`, deliberately, not `pos`. They are the same only
// when you happen to be aiming at the floor.
var sw = new PowerSwitch
{
Yaw = yaw,
Position = point,
Normal = normal ?? Vector3.Zero,
};
// A command-driven placement has no surface to read, so it keeps
// the old floor-and-lift behaviour rather than being left flat.
if ( normal is null )
sw.Position = pos + Vector3.Up * (sw.Size.z * 0.5f);
var wasOn = Power.IsOn;
ActiveConfig.Current.PowerSwitches.Add( sw );
PowerManager.Instance?.Rebuild();
Log.Info( $"[nz] power switch placed at {sw.Position}"
+ (sw.HasNormal
? $" flat to the surface (normal {sw.Normal})"
: $" facing {yaw:0} (no surface — dropped to the floor)")
+ $" ({ActiveConfig.Current.PowerSwitches.Count} total)" );
// ⚠️ The FIRST switch turns the map's power off by itself, because
// Power.IsOn is derived from whether one exists. Silence here
// reads as "placing a prop broke every door".
if ( wasOn && !Power.IsOn )
Log.Info( "[nz] ⚠ first switch — power is now OFF until it is used" );
return true;
}
case NZTools.ZombieSpawn:
ActiveConfig.Current.ZombieSpawns.Add( new SpawnPoint( pos, yaw )
{
// Stamped at placement, not patched in afterwards. You know
// which side of a door you are standing on WHILE placing;
// working it out later from a list of coordinates is guesswork.
Link = SpawnLink,
ActiveRound = SpawnActiveRound,
RequiresPower = SpawnRequiresPower,
} );
Log.Info( $"[nz] zombie spawn placed at {pos} facing {yaw:0}"
+ $" link {SpawnLink}"
+ (DoorLinks.IsUnlinked( SpawnLink ) ? " (always open)" : "")
+ (SpawnActiveRound > 1 ? $" from round {SpawnActiveRound}" : "")
+ $" ({ActiveConfig.Current.ZombieSpawns.Count} total)" );
return true;
case NZTools.SpecialSpawn:
ActiveConfig.Current.SpecialSpawns.Add( new SpawnPoint( pos, yaw )
{
Special = SpecialEnemies.IsKnown( SpecialEnemy )
? SpecialEnemy
: SpecialEnemies.Fallback,
Link = SpawnLink,
ActiveRound = SpawnActiveRound,
RequiresPower = SpawnRequiresPower,
} );
Log.Info( $"[nz] special spawn ({SpecialEnemy}) placed at {pos} facing {yaw:0}"
+ $" link {SpawnLink}"
+ (DoorLinks.IsUnlinked( SpawnLink ) ? " (always open)" : "")
+ $" ({ActiveConfig.Current.SpecialSpawns.Count} total)" );
// ⚠️ Placing one is what ENABLES special rounds — IsSpecialRound
// returns false with no spawners, so an untouched map never has
// one. Say so on the first, or the setting looks broken.
if ( ActiveConfig.Current.SpecialSpawns.Count == 1 )
{
var sp = ActiveConfig.Current.Specials;
Log.Info( $"[nz] ⚠ first special spawn — special rounds are now live "
+ $"(round {sp.FirstRound}, then every {sp.RoundInterval})" );
}
return true;
case NZTools.BossSpawn:
// ⚠️ THE ROSTER IS FILTERED TO BOSSES, so a mis-set picker cannot drop a hellhound on
// a boss point. `IsBossName` asks the VARIANT rather than the filename, which is what
// `SpecialEnemies` insists on for anything that is a rule rather than a loot roll.
ActiveConfig.Current.BossSpawns.Add( new SpawnPoint( pos, yaw )
{
Special = SpecialEnemies.IsBossName( BossEnemy )
? BossEnemy
: SpecialEnemies.BossFallback,
Link = SpawnLink,
ActiveRound = SpawnActiveRound,
RequiresPower = SpawnRequiresPower,
} );
Log.Info( $"[nz] boss spawn ({BossEnemy}) placed at {pos} facing {yaw:0}"
+ $" link {SpawnLink}"
+ (DoorLinks.IsUnlinked( SpawnLink ) ? " (always open)" : "")
+ (SpawnActiveRound > 1 ? $" from round {SpawnActiveRound}" : "")
+ $" ({ActiveConfig.Current.BossSpawns.Count} total)" );
// ⚠️ THE SAME ANNOUNCEMENT THE FIRST SPECIAL SPAWN MAKES, for the reason its own
// comment gives: with none placed the round type can never fire, so an untouched map
// never has one and the setting reads as broken until you are told.
if ( ActiveConfig.Current.BossSpawns.Count == 1 )
Log.Info( "[nz] → first boss spawn — bosses can now appear. Nothing schedules"
+ " them yet; `nz_boss_spawn` places one by hand." );
// ⛔ AND A BOSS NEEDS FLOOR SPACE, WHICH A SPAWN POINT CANNOT CHECK FOR YOU. Brutus is
// 80 units tall with a 22-unit body radius against a walker's much smaller capsule, so a
// point that works for a special can still wedge him. Warned here because the editor is
// where the mistake is made and it stays invisible until one actually spawns.
Log.Info( "[nz] ⚠ bosses are large (Brutus: 80u tall, 22u radius) — place these"
+ " in open floor, not doorways or windows" );
return true;
// ⚠️ The two barrier modes want DIFFERENT positions.
//
// A BLOCK is authored from clicked corners and spans a gap, so it
// belongs exactly where it was built — floor-dropping would sink a
// wall into the ground.
//
// A PROP rests on the ground like any other object, so it gets the
// dropped position. Without this an offset-placed prop hangs in the
// air, and a nav blocker that does not reach the floor blocks
// nothing at all.
case NZTools.Debris:
return PlaceDebris( DebrisBlockMode ? point : pos, yaw );
// ⚠️ There is no single-point placement for a wall — it is drawn, not
// dropped. Say so, rather than falling through to "not implemented
// yet", which reads as a missing feature instead of a wrong verb.
case NZTools.InvisibleWall:
case NZTools.DamageWall:
case NZTools.RoomZone:
Log.Warning( $"[nz] a {NZTools.NameFor( ActiveTool ).ToLower()} is DRAWN, not "
+ $"placed — click {DebrisCorners} corners then once more at the height you "
+ "want (nz_corner_at / nz_build over the console)" );
return false;
default:
Log.Warning( $"[nz] tool '{ActiveTool}' is not implemented yet" );
return false;
}
}
/// <summary>
/// Add a barrier at a point, using the tool's current link and price.
///
/// ⚠️ Does NOT re-trace. It used to, which quietly broke offset placement:
/// nz_place_offset computed a point and PlaceDebris threw it away in favour
/// of wherever the camera happened to point, so every offset barrier landed
/// in the same spot.
/// </summary>
bool PlaceDebris( Vector3 pos, float yaw )
{
// Copy the aimed model when there was one, so a barrier can look like
// the map's own props instead of a grey box.
var model = DebrisCopyAimedModel && !string.IsNullOrWhiteSpace( _aimedModel )
? _aimedModel
: DebrisModel;
if ( DoorLinks.IsUnlinked( DebrisLink ) )
Log.Warning( "[nz] blank/0 is always-open — this barrier will gate nothing" );
ActiveConfig.Current.Debris.Add( new Debris
{
Position = pos,
Yaw = yaw,
Model = model,
Material = DebrisMaterial,
Visible = DebrisVisible,
Link = DebrisLink,
Price = DebrisPrice,
RequiresPower = DebrisRequiresPower,
Buyable = DebrisBuyable,
} );
Log.Info( $"[nz] debris #{ActiveConfig.Current.Debris.Count - 1} placed at {pos}"
+ $" link {DebrisLink}, {DebrisCostWord}, {model}"
+ (DebrisRequiresPower ? " needs power" : "") );
DebrisManager.Ensure( Scene )?.Rebuild();
return true;
}
/// <summary>Remove the marker you are aiming at — not the nearest one to
/// you, which would delete things behind your back.</summary>
public bool RemoveAimed()
{
var hit = AimTrace();
if ( hit is null ) return false;
var at = hit.Value.HitPosition;
// Debris and switches live in their own lists with their own types, so
// they cannot share the SpawnPoint path below. ⚠️ Anything with its own
// list needs a case HERE as well as in PlaceAt — a tool that places but
// cannot remove looks like RMB is broken rather than unimplemented.
if ( ActiveTool == NZTools.Debris ) return RemoveDebrisNear( at );
if ( ActiveTool == NZTools.PowerSwitch ) return RemoveSwitchNear( at );
if ( ActiveTool == NZTools.InvisibleWall ) return RemoveWallNear( at, hit.Value.GameObject );
if ( ActiveTool == NZTools.DamageWall ) return RemoveDamageWallNear( at );
if ( ActiveTool == NZTools.FogArea ) return RemoveFogAreaNear( at );
if ( ActiveTool == NZTools.RoomZone ) return RemoveRoomZoneNear( at );
if ( ActiveTool == NZTools.Barricade ) return RemoveBarricadeNear( at );
if ( ActiveTool == NZTools.NavLink ) return RemoveNavLinkNear( at );
if ( ActiveTool == NZTools.MysteryBox ) return RemoveMysteryBoxNear( at );
if ( ActiveTool == NZTools.PackAPunch ) return RemovePackAPunchNear( at );
if ( ActiveTool == NZTools.Wunderfizz ) return RemoveWunderfizzNear( at );
if ( ActiveTool == NZTools.PerkMachine ) return RemovePerkMachineNear( at );
if ( ActiveTool == NZTools.Teleporter ) return RemoveTeleporterNear( at );
if ( ActiveTool == NZTools.Springboard ) return RemoveSpringboardNear( at );
if ( ActiveTool == NZTools.MapLight ) return RemoveMapLightNear( at );
if ( ActiveTool == NZTools.SoundSpot ) return RemoveSoundSpotNear( at );
if ( ActiveTool == NZTools.SoulBox ) return RemoveSoulBoxNear( at );
if ( ActiveTool == NZTools.AmmoBox ) return RemoveAmmoBoxNear( at );
if ( ActiveTool == NZTools.BuyableEnding ) return RemoveEndingNear( at );
if ( ActiveTool == NZTools.Misery ) return RemoveMiseryNear( at );
if ( ActiveTool == NZTools.Clue ) return RemoveClueNear( at );
if ( ActiveTool == NZTools.Pressable ) return RemovePressableNear( at );
if ( ActiveTool == NZTools.Shootable ) return RemoveShootableNear( at );
if ( ActiveTool == NZTools.TradeTable ) return RemoveTradeTableNear( at );
if ( ActiveTool == NZTools.BuildTable ) return RemoveBuildTableNear( at );
if ( ActiveTool == NZTools.BuildPart ) return RemoveBuildPartNear( at );
if ( ActiveTool == NZTools.Arsenal ) return RemoveArsenalNear( at );
if ( ActiveTool == NZTools.WallBuy ) return RemoveWallBuyNear( at );
if ( ActiveTool == NZTools.HexSlot ) return HexSlotManager.RemoveNear( at, RemoveRadius );
var list = ListFor( ActiveTool );
if ( list is null ) return false;
var found = list
.Select( ( s, i ) => (s, i, d: s.Position.Distance( at )) )
.Where( x => x.d <= RemoveRadius )
.OrderBy( x => x.d )
.FirstOrDefault();
if ( found.s is null )
{
Log.Info( $"[nz] no {NZTools.NameFor( ActiveTool ).ToLower()} under the crosshair" );
return false;
}
list.RemoveAt( found.i );
Log.Info( $"[nz] {NZTools.NameFor( ActiveTool ).ToLower()} removed ({list.Count} left)" );
return true;
}
/// <summary>
/// If the crosshair is on a standing barrier, push the tool's current
/// settings onto it and report true. False means "not aiming at one" —
/// which is the signal to build a new one instead.
///
/// Applies link, price and material only. NOT size or position: those were
/// authored by clicking a footprint, and silently resizing a wall because
/// you clicked it would be the opposite of an edit.
/// </summary>
/// <summary>Log what the click landed on when it built instead of edited.
/// Off by default — it fires on every corner click.</summary>
[Property] public bool DebrisDebug { get; set; }
void ReportAimedForEdit()
{
var hit = AimTrace();
if ( hit is null ) { Log.Info( "[nz] edit-check: aiming at nothing" ); return; }
var go = hit.Value.GameObject;
var m = DebrisManager.Instance;
Log.Info( $"[nz] edit-check: hit '{go?.Name ?? "null"}'"
+ $" parent '{go?.Parent?.Name ?? "none"}'"
+ $" manager {(m.IsValid() ? "ok" : "MISSING")}"
+ $" index {(m.IsValid() ? m.IndexOfObject( go ) : -1)}"
+ " -> building a new one" );
}
public bool ApplyToAimedDebris()
{
var m = DebrisManager.Instance;
if ( !m.IsValid() ) return false;
var hit = AimTrace();
if ( hit is null ) return false;
var index = m.IndexOfObject( hit.Value.GameObject );
if ( index < 0 ) return false;
var list = ActiveConfig.Current.Debris;
if ( index >= list.Count ) return false;
var d = list[index];
var before = $"flag {DoorLinks.Display( d.Link )}, {d.Price}pts"
+ (d.RequiresPower ? ", power" : "") + (d.Buyable ? "" : ", no buy");
// ⚠️ EVERY TOOL SETTING, not a subset. RequiresPower was added to the
// tool later and never added here, so clicking a wall applied the flag,
// price and material and silently left the power gate as it was — the
// one setting whose effect you cannot see on the marker.
d.Link = DebrisLink;
d.Price = DebrisPrice;
d.Material = DebrisMaterial;
d.RequiresPower = DebrisRequiresPower;
d.Buyable = DebrisBuyable;
Log.Info( $"[nz] debris #{index}: {before} -> flag {DoorLinks.Display( d.Link )}, "
+ $"{d.Price}pts{(d.RequiresPower ? ", power" : "")}{(d.Buyable ? "" : ", no buy")}"
+ " (unsaved — nz_save to keep it)" );
// Rebuild so a material change is visible immediately — an edit you
// cannot see is indistinguishable from one that did not happen.
m.Rebuild();
return true;
}
// ── block building ───────────────────────────────────────────────────────
/// <summary>
/// Add a footprint corner where you are aiming — or, once the footprint is
/// closed, set the height from where you are aiming.
/// </summary>
public bool AddCorner()
{
var hit = AimTrace();
if ( hit is null )
{
// ⚠️ Says which click was lost. Aiming at nothing while placing the
// FOURTH corner and aiming at nothing while setting the height fail
// identically and mean different things — the second usually means
// the player aimed at open sky instead of at the wall.
Log.Info( _awaitingHeight
? "[nz] height: aiming at nothing — aim at the wall (or the ceiling) "
+ "at the height you want the barrier to reach"
: "[nz] corner: aiming at nothing" );
return false;
}
return AddCornerAt( hit.Value.HitPosition );
}
/// <summary>Add a corner at an explicit point. Separate from AddCorner so a
/// command can drive it — nobody can click four points over MCP.</summary>
public bool AddCornerAt( Vector3 at )
{
// The footprint is already closed, so this click is the HEIGHT.
if ( _awaitingHeight ) return SetHeightAt( at );
_corners.Add( at );
Log.Info( $"[nz] corner {_corners.Count}/{DebrisCorners} at {at}" );
if ( _corners.Count < DebrisCorners ) return true;
_awaitingHeight = true;
Log.Info( "[nz] footprint closed — now click at the HEIGHT you want, "
+ "somewhere level with the top of the barrier (the top of the doorway, "
+ "say). R starts over." );
return true;
}
/// <summary>
/// Close the block, taking its height from a clicked point.
///
/// ⛔ MEASURED FROM THE LOWEST CORNER, not from the click's own footing. The
/// block's base is already the lowest corner (see BuildBlock), so measuring
/// from anything else would give a barrier whose top is not where you clicked
/// — which is the one thing this whole flow exists to guarantee.
/// </summary>
public bool SetHeightAt( Vector3 at )
{
if ( _corners.Count < 2 )
{
Log.Warning( "[nz] no footprint to raise — click the corners first" );
_awaitingHeight = false;
return false;
}
float groundZ = float.MaxValue;
foreach ( var c in _corners )
groundZ = MathF.Min( groundZ, c.z );
float height = at.z - groundZ;
// ⚠️ NOT SILENTLY SUBSTITUTED. Falling back to DebrisHeight here would
// build a barrier of a height nobody asked for, and it would look like the
// click had worked. Keep the footprint pending so the next click can fix
// it rather than making them re-place four corners.
if ( height <= 1f )
{
Log.Warning( $"[nz] that point is only {height:0} above the footprint's "
+ "base — aim higher up the wall. Corners kept; click again." );
return false;
}
_awaitingHeight = false;
return BuildBlock( height );
}
public void ResetCorners()
{
if ( _corners.Count == 0 && !_awaitingHeight ) return;
Log.Info( $"[nz] cleared {_corners.Count} pending corners" );
_corners.Clear();
// ⚠️ Cleared with the corners. Left set, the first click of the NEXT
// barrier would be read as a height for a footprint that no longer exists.
_awaitingHeight = false;
}
/// <summary>
/// A measured block: where it sits, which way it faces, how big its box is,
/// and the polygon it was drawn as.
///
/// ⚠️ EXISTS SO THE INVISIBLE-WALL TOOL CANNOT DRIFT FROM THE DEBRIS TOOL.
/// "It works exactly like the debris one" was the requirement, and the only
/// way to keep that true a month from now is for both to run the SAME
/// measurement rather than two copies that start identical.
/// </summary>
public readonly record struct BlockShape(
Vector3 Position, float Yaw, Vector3 Size,
System.Collections.Generic.List<Vector2> Footprint );
/// <summary>
/// Turn the pending corners into whatever the armed tool builds.
///
/// Debris by default; an invisible wall when that tool is armed. The geometry
/// is identical either way — see MeasureBlock.
/// </summary>
public bool BuildBlock( float height )
{
if ( !MeasureBlock( height, out var shape ) ) return false;
bool built = ActiveTool switch
{
NZTools.InvisibleWall => AddInvisibleWall( shape ),
NZTools.DamageWall => AddDamageWall( shape ),
NZTools.FogArea => AddFogArea( shape ),
NZTools.RoomZone => AddRoomZone( shape ),
_ => AddDebris( shape ),
};
if ( !built ) return false;
_corners.Clear();
// ⚠️ Also cleared here, not only in ResetCorners — nz_build can close a
// block while the tool is waiting for a height click, and leaving the flag
// set would eat the first corner of the next one.
_awaitingHeight = false;
return true;
}
/// <summary>
/// Measure the pending corners into a block of the given height.
///
/// ⚠️ ORIENTED, not axis-aligned. An axis-aligned box is simpler but wrong for
/// the common case: a doorway at 30° would get a bounding box far wider than
/// the gap, sealing the wall on either side of it too.
///
/// ⚠️ AND THE ORIENTATION IS THE TIGHTEST ONE, NOT THE FIRST EDGE. Yaw used to
/// come from corner 0 -> corner 1, which made the result depend on the ORDER
/// the corners were clicked in — see the block in the body.
/// </summary>
bool MeasureBlock( float height, out BlockShape shape )
{
shape = default;
if ( _corners.Count < 2 )
{
Log.Warning( $"[nz] need at least 2 corners, have {_corners.Count}" );
return false;
}
if ( height <= 1f ) { Log.Warning( "[nz] height must be > 1" ); return false; }
// ⛔ ORIENTATION IS CHOSEN, NOT ASSUMED FROM THE FIRST EDGE.
//
// This used to take yaw from corner 0 -> corner 1 and measure everything
// else in that frame. That makes CLICK ORDER decide the shape: start with
// two corners that happen to run diagonally across a doorway and the
// remaining corners fall outside that axis, so the bounding box balloons
// out to span the diagonal. Reported exactly as "it always makes a
// rectangle with the 2 furthest from each other" — which is precisely what
// a box measured along the wrong axis looks like.
//
// ⚠️ EVERY EDGE IS A CANDIDATE, SMALLEST FOOTPRINT WINS. The minimum-area
// enclosing rectangle of a point set is always aligned with one of the
// edges of its convex hull (rotating calipers), so trying each edge
// between clicked points and keeping the tightest is exact, not a
// heuristic. With four corners that is six candidates — free.
//
// Result: the same four clicks give the same block in any order, and a
// doorway at any angle gets a block the width of the doorway.
float yaw = 0f;
var localMin = new Vector3( float.MaxValue );
var localMax = new Vector3( float.MinValue );
float bestArea = float.MaxValue;
for ( int i = 0; i < _corners.Count; i++ )
for ( int j = i + 1; j < _corners.Count; j++ )
{
var edge = (_corners[j] - _corners[i]).WithZ( 0 );
// Two clicks in the same spot describe no direction — skip rather
// than feed a zero vector to LookAt and get a NaN rotation.
if ( edge.Length <= 1f ) continue;
float tryYaw = Rotation.LookAt( edge ).Yaw();
var tryRot = Rotation.FromYaw( tryYaw );
var min = new Vector3( float.MaxValue );
var max = new Vector3( float.MinValue );
foreach ( var c in _corners )
{
var local = tryRot.Inverse * c;
min = Vector3.Min( min, local );
max = Vector3.Max( max, local );
}
// Footprint only — height is applied later and is the same whichever
// way the block is turned, so including it would not change the
// ranking but would hide a degenerate footprint behind a tall box.
float area = (max.x - min.x) * (max.y - min.y);
if ( area >= bestArea ) continue;
bestArea = area;
yaw = tryYaw;
localMin = min;
localMax = max;
}
// Every candidate edge was degenerate — all the clicks landed on the same
// spot. Fall back to the axis-aligned measurement rather than building
// from uninitialised extents.
if ( bestArea == float.MaxValue )
{
var rot0 = Rotation.FromYaw( 0f );
localMin = new Vector3( float.MaxValue );
localMax = new Vector3( float.MinValue );
foreach ( var c in _corners )
{
var local = rot0.Inverse * c;
localMin = Vector3.Min( localMin, local );
localMax = Vector3.Max( localMax, local );
}
}
var rot = Rotation.FromYaw( yaw );
float groundZ = float.MaxValue;
foreach ( var c in _corners )
groundZ = MathF.Min( groundZ, c.z );
// A two-click footprint has no depth — give it some, or the block is an
// invisible plane you can see straight through edge-on.
var size = localMax - localMin;
size.x = MathF.Max( size.x, 8f );
size.y = MathF.Max( size.y, 8f );
// ⚠️ SUNK BELOW THE FLOOR ON PURPOSE. The base is the lowest corner, but
// a corner that landed on a crate or a kerb puts that base ABOVE the
// walkable surface — and a nav blocker floating even a few units clear
// of the floor does not block anything, because the navmesh generates
// underneath it. Measured: a block resting 2u high left the path
// completely unchanged. Sinking by more than the agent step (24) makes
// the block bite into the floor whatever the corners hit.
size.z = height + Sink;
// Centre on the footprint horizontally, and sit it on the lowest corner
// with the sink hanging below.
var localCentre = (localMin + localMax) * 0.5f;
var centre = rot * localCentre;
centre.z = groundZ + height * 0.5f - Sink * 0.5f;
// ⛔ THE ACTUAL CORNERS, NOT JUST THEIR BOUNDING BOX. `size` above is kept
// because nav rebuilding and the remove radius both want a box, but the
// barrier is BUILT from these points — so four corners describing an L or
// a wedge produce an L or a wedge instead of the rectangle around them.
//
// ⚠️ Stored in the barrier's own frame: rotated into the block's yaw and
// measured from its centre, so the polygon travels with Position/Yaw and
// needs no world coordinates saved alongside it.
//
// ⚠️ Only worth keeping for 3+ corners. Two clicks describe a line, which
// has no interior to extrude — those stay plain boxes, which is exactly
// what the two-corner flow was always for.
// ⚠️ IN CLICK ORDER, deliberately — that IS the drawing. Sorting the
// corners (by angle around the centre, say) would look tidier and would
// quietly convexify every shape, turning an L back into the rectangle
// this change exists to stop producing.
//
// The cost of honouring click order is that clicking a doorway's corners
// diagonally draws a bowtie, which has no interior. Caught below rather
// than built into a mess.
var footprint = new System.Collections.Generic.List<Vector2>();
if ( _corners.Count >= 3 )
{
foreach ( var c in _corners )
{
var local = (rot.Inverse * c) - localCentre;
footprint.Add( new Vector2( local.x, local.y ) );
}
if ( !DebrisMesh.IsSimple( footprint ) )
{
Log.Warning( "[nz] those corners cross over themselves — clicked "
+ "diagonally rather than around the shape. Built as a plain box; "
+ "press R and click the corners in order around the edge to get "
+ "the drawn shape." );
footprint.Clear();
}
// ⚠️ SAID UP FRONT, because the alternative is finding out as a pathing
// bug on a map you have already built. The barrier LOOKS right — it is
// the nav volume that cannot follow it.
//
// ⚠️ NOT WARNED FOR AN INVISIBLE WALL. That tool does not touch the
// navmesh at all, so a dent costs it nothing — and a warning about a
// problem the thing you are building cannot have is how people learn
// to ignore warnings.
else if ( ActiveTool != NZTools.InvisibleWall && ActiveTool != NZTools.DamageWall
&& ActiveTool != NZTools.RoomZone && !DebrisMesh.IsConvex( footprint ) )
{
Log.Warning( "[nz] this shape has a dent in it. The barrier is built "
+ "to the outline, but the nav block is a box, so zombies will "
+ "not path through the notch either. Two barriers side by side "
+ "if the gap needs to stay walkable." );
}
}
shape = new BlockShape( centre, yaw, size, footprint );
return true;
}
/// <summary>Record a debris barrier from a measured block.</summary>
// ── mystery box ──────────────────────────────────────────────────────────
/// <summary>Drop a box where you are looking, facing you.</summary>
public bool AddMysteryBox()
{
var hit = AimTrace();
if ( hit is null )
{
Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
return false;
}
return AddMysteryBoxAt( hit.Value.HitPosition, hit.Value.Normal );
}
/// <summary>Place one explicitly, so a command can drive it.</summary>
public bool AddMysteryBoxAt( Vector3 at ) => AddMysteryBoxAt( at, Vector3.Up );
/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
public bool AddMysteryBoxAt( Vector3 at, Vector3 normal )
{
// ⚠️ Faces the placer, like the barricade tool and the original's own
// tool do — you author it from where the player will approach it.
var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;
ActiveConfig.Current.Boxes.Add( new MysteryBoxSpot
{
Position = at,
Yaw = yaw,
Cost = Math.Max( 0, BoxPrice ),
CanStart = BoxCanStart,
Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
} );
MysteryBoxManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] mystery box placed at {at} facing {yaw:0}° "
+ $"({ActiveConfig.Current.Boxes.Count} total)" );
return true;
}
/// <summary>Remove the box nearest a point. Right-click, via RemoveAimed.</summary>
bool RemoveMysteryBoxNear( Vector3 at )
{
var list = ActiveConfig.Current.Boxes;
if ( list.Count == 0 ) return false;
int best = -1;
float bestDist = 120f;
for ( int i = 0; i < list.Count; i++ )
{
float d = list[i].Position.Distance( at );
if ( d >= bestDist ) continue;
bestDist = d;
best = i;
}
if ( best < 0 ) return false;
list.RemoveAt( best );
MysteryBoxManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] mystery box removed ({list.Count} left)" );
return true;
}
// ── pack-a-punch ─────────────────────────────────────────────────────────
/// <summary>
/// Drop a Pack-a-Punch where you are looking, facing you.
///
/// ⚠️ DELIBERATELY THE SAME FLOW AS THE MYSTERY BOX — one click, faces the
/// placer, floor normal stored. Both are floor-standing props placed the same
/// way, and a second placement convention is a second set of slope bugs.
/// </summary>
public bool AddPackAPunch()
{
var hit = AimTrace();
if ( hit is null )
{
Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
return false;
}
return AddPackAPunchAt( hit.Value.HitPosition, hit.Value.Normal );
}
/// <summary>Place one explicitly, so a command can drive it.</summary>
public bool AddPackAPunchAt( Vector3 at ) => AddPackAPunchAt( at, Vector3.Up );
/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
public bool AddPackAPunchAt( Vector3 at, Vector3 normal )
{
// ⚠️ Refuses a surface too steep to stand on rather than mounting the
// machine sideways on it. `nz_pap` learned this the hard way — a machine
// ended up on a wall with up=(-0.82,0.57,0), and nothing about the result
// says "you aimed at a wall".
if ( !normal.IsNearlyZero() && normal.Normal.z <= 0.7f )
{
Log.Info( "[nz] that surface is too steep for a Pack-a-Punch — aim at the floor" );
return false;
}
var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;
ActiveConfig.Current.PackAPunches.Add( new PackAPunchSpot
{
Position = at,
Yaw = yaw,
Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
} );
PackAPunchManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] Pack-a-Punch placed at {at} facing {yaw:0}° "
+ $"({ActiveConfig.Current.PackAPunches.Count} total)" );
return true;
}
// ── Wunderfizz options, stamped at placement ─────────────────────────────
//
// ⚠️ Stamped onto the spot rather than read live, the same as every other
// placeable here. You know what a machine is FOR while you are standing where
// it goes; working it out later from a list of coordinates is guesswork.
/// <summary>Cost of the first roll.</summary>
[Property] public int WunderfizzPrice { get; set; } = 2500;
/// <summary>Added to the price after each roll. 0 = flat.</summary>
[Property] public int WunderfizzIncrement { get; set; } = 500;
/// <summary>Earliest round it can be used.</summary>
[Property] public int WunderfizzStartRound { get; set; }
/// <summary>Needs the power on.</summary>
[Property] public bool WunderfizzRequiresPower { get; set; } = true;
/// <summary>Cost of an extra perk slot here. 0 = not offered.</summary>
[Property] public int WunderfizzSlotPrice { get; set; } = 10000;
/// <summary>Added to the slot price for every extra slot the player has already
/// bought. 0 = flat.
///
/// ⚠️ Same shape as `WunderfizzIncrement`, deliberately — the roll price and the
/// slot price now escalate by the same rule, so a mapper who has set one already
/// knows what this one does.</summary>
[Property] public int WunderfizzSlotIncrement { get; set; }
/// <summary>Wunderfizz from the crosshair. Mirrors AddPackAPunch.</summary>
public bool AddWunderfizz()
{
var hit = AimTrace();
if ( hit is null )
{
Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
return false;
}
return AddWunderfizzAt( hit.Value.HitPosition, hit.Value.Normal );
}
/// <summary>Place one explicitly, so a command can drive it.</summary>
public bool AddWunderfizzAt( Vector3 at ) => AddWunderfizzAt( at, Vector3.Up );
/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
public bool AddWunderfizzAt( Vector3 at, Vector3 normal )
{
// ⚠️ Same steep-surface refusal as Pack-a-Punch, and for the reason that one
// records: a machine mounted sideways on a wall looks like a placement that
// worked, and nothing about the result says you aimed at a wall.
if ( !normal.IsNearlyZero() && normal.Normal.z <= 0.7f )
{
Log.Info( "[nz] that surface is too steep for a Wunderfizz — aim at the floor" );
return false;
}
var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;
ActiveConfig.Current.Wunderfizzes.Add( new WunderfizzSpot
{
Position = at,
Yaw = yaw,
Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
BasePrice = WunderfizzPrice,
PriceIncrement = WunderfizzIncrement,
StartRound = WunderfizzStartRound,
RequiresPower = WunderfizzRequiresPower,
PerkSlotPrice = WunderfizzSlotPrice,
PerkSlotIncrement = WunderfizzSlotIncrement,
Link = SpawnLink,
} );
WunderfizzManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] Wunderfizz placed at {at} facing {yaw:0}° "
+ $"{WunderfizzPrice} points "
+ $"({ActiveConfig.Current.Wunderfizzes.Count} total)" );
return true;
}
// ── Ammo box and trading table ───────────────────────────────────────────
//
// ⚠️ NO OPTIONS STAMPED AT PLACEMENT, unlike the Wunderfizz above. The ammo box's prices are
// GLOBAL (`AmmoBoxSettings`, edited in Q > Settings > Ammo box) because they key off the
// weapon's Pack-a-Punch tier rather than off which box you walked to, and the trading table is
// free. Both spots carry position, facing, floor normal and a door flag - nothing else to ask.
// ── Perk machine options, stamped at placement ───────────────────────────
/// <summary>
/// Which perk the next machine sells, as a PerkRegistry id.
///
/// ⚠️ AN ID, NOT AN INDEX. The settings row shows names and the config stores ids, so an
/// index would silently repoint every placed machine the day a perk is added to the middle
/// of PerkRegistry.All.
/// </summary>
[Property] public string PerkMachinePerk { get; set; } = "jugg";
/// <summary>Cost of the next machine, flat. -1 = auto, the Wunderfizz's price
/// (PerkMachine.PriceFor).</summary>
[Property] public int PerkMachinePrice { get; set; } = -1;
/// <summary>Earliest round the next machine can be used.</summary>
[Property] public int PerkMachineStartRound { get; set; }
/// <summary>Needs the power on. ON BY DEFAULT, matching the Wunderfizz and the original.</summary>
[Property] public bool PerkMachineRequiresPower { get; set; } = true;
/// <summary>The perk the tool will place, resolved. Null if the id is unknown.</summary>
public PerkRegistry.Perk PerkMachinePerkInfo => PerkRegistry.Find( PerkMachinePerk );
/// <summary>Perk machine from the crosshair. Mirrors AddWunderfizz.</summary>
public bool AddPerkMachine()
{
var hit = AimTrace();
if ( hit is null )
{
Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
return false;
}
return AddPerkMachineAt( hit.Value.HitPosition, hit.Value.Normal );
}
/// <summary>Place one explicitly, so a command can drive it.</summary>
public bool AddPerkMachineAt( Vector3 at ) => AddPerkMachineAt( at, Vector3.Up );
/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
public bool AddPerkMachineAt( Vector3 at, Vector3 normal )
{
// ⛔ REFUSED BEFORE ANYTHING IS WRITTEN. A spot whose perk has no machine model saves
// happily and then builds nothing, which reads as the placement having failed silently.
var perk = PerkMachinePerkInfo;
if ( perk is null )
{
Log.Warning( $"[nz] '{PerkMachinePerk}' is not a perk — nothing placed" );
return false;
}
if ( string.IsNullOrEmpty( PerkMachineManager.ModelFor( perk.Id ) ) )
{
Log.Warning( $"[nz] {perk.Name} has no machine model — nothing placed" );
return false;
}
// ⚠️ Same steep-surface refusal as the Wunderfizz: a machine mounted sideways on a wall
// looks like a placement that worked, and nothing about the result says you aimed at a wall.
if ( !normal.IsNearlyZero() && normal.Normal.z <= 0.7f )
{
Log.Info( "[nz] that surface is too steep for a perk machine — aim at the floor" );
return false;
}
var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;
ActiveConfig.Current.PerkMachines.Add( new PerkMachineSpot
{
Position = at,
Yaw = yaw,
Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
PerkId = perk.Id,
Price = PerkMachinePrice,
StartRound = PerkMachineStartRound,
RequiresPower = PerkMachineRequiresPower,
Link = SpawnLink,
} );
PerkMachineManager.Ensure( Scene )?.Rebuild();
var price = PerkMachinePrice >= 0
? $"{PerkMachinePrice} points"
: $"the Wunderfizz's price ({Wunderfizz.PerkPriceRule()})";
Log.Info( $"[nz] {perk.Name} machine placed at {at} facing {yaw:0}° "
+ $"{price}{(PerkMachineRequiresPower ? ", needs power" : "")} "
+ $"({ActiveConfig.Current.PerkMachines.Count} total)" );
return true;
}
/// <summary>Remove the perk machine nearest a point. Right-click, via RemoveAimed.</summary>
bool RemovePerkMachineNear( Vector3 at )
{
var list = ActiveConfig.Current.PerkMachines;
if ( list.Count == 0 ) return false;
int best = -1;
float bestDist = 140f;
for ( int i = 0; i < list.Count; i++ )
{
var d = at.Distance( list[i].Position );
if ( d >= bestDist ) continue;
bestDist = d;
best = i;
}
if ( best < 0 ) { Log.Info( "[nz] no perk machine near there" ); return false; }
var name = PerkRegistry.Find( list[best].PerkId )?.Name ?? list[best].PerkId;
list.RemoveAt( best );
PerkMachineManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] removed the {name} machine ({list.Count} left)" );
return true;
}
// ── Teleporter options, stamped at placement ─────────────────────────────
/// <summary>Cost per trip for the next teleporter. 0 = free.</summary>
[Property] public int TeleporterPrice { get; set; }
/// <summary>Seconds the next teleporter takes to recharge. 0 = no cooldown.
/// ⚠️ 20 is upstream's own default — see TeleporterSpot.Cooldown.</summary>
[Property] public float TeleporterCooldown { get; set; } = 20f;
/// <summary>Seconds between the press and departure. ⚠️ 2.5 is upstream's own.</summary>
[Property] public float TeleporterWarmup { get; set; } = 2.5f;
/// <summary>Seconds spent in transit, frozen and blind. ⚠️ 4 is upstream's own.</summary>
[Property] public float TeleporterTransit { get; set; } = 4f;
/// <summary>How wide the next pad is — and therefore who counts as standing on it.
/// ⚠️ 176 is measured from the pad model — see TeleporterSpot.PadSize.</summary>
[Property] public float TeleporterPadSize { get; set; } = 176f;
/// <summary>How thick the next placeholder box is. Above ~18 it becomes a kerb you have to
/// jump; 12 matches the real pad.</summary>
[Property] public float TeleporterPadHeight { get; set; } = 12f;
/// <summary>Needs the power on. ⚠️ OFF by default — see TeleporterSpot.RequiresPower.</summary>
[Property] public bool TeleporterRequiresPower { get; set; }
/// <summary>The next pad's model. Empty = the placeholder box.</summary>
[Property] public string TeleporterModel { get; set; } = "models/moo/_codz_ports_props/t5/zm/zombie_teleporter_pad/moo_codz_zm_teleporter_pad.vmdl";
/// <summary>Surface for the placeholder pad box. Only used when Model is empty.</summary>
[Property] public string TeleporterMaterial { get; set; } = "materials/metal/metalwall048a.vmat";
/// <summary>
/// Teleporter point from the crosshair — the PAD first, then the destination.
///
/// ⚠️ Uses the shared `_corners` buffer, the same as the nav link and the block tools, so the
/// pending markers already draw and R already clears it. A private two-point buffer would be a
/// second thing to reset and a second thing to forget to draw.
/// </summary>
public bool AddTeleporterPoint()
{
var hit = AimTrace();
if ( hit is null )
{
Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
return false;
}
return AddTeleporterPointAt( hit.Value.HitPosition );
}
/// <summary>Place a point explicitly, so a command can drive it.</summary>
public bool AddTeleporterPointAt( Vector3 at )
{
_corners.Add( at );
if ( _corners.Count < 2 )
{
Log.Info( $"[nz] teleporter pad at {at} — now click where it SENDS you" );
return true;
}
var a = _corners[0];
var b = _corners[1];
_corners.Clear();
_awaitingHeight = false;
// ⛔ REFUSED, NOT SILENTLY BUILT. A teleporter whose ends are the same spot looks placed
// and does nothing, which is the worst of both — it reads as the destination click having
// been missed rather than as a rejected placement.
if ( a.Distance( b ) < 16f )
{
Log.Warning( "[nz] both ends are the same spot — a teleporter needs to go "
+ "somewhere. Points cleared; start again." );
return false;
}
// ⚠️ FACING ALONG A -> B, so you step out looking the way you travelled. Arriving facing
// backwards is disorienting in a way that reads as the teleporter being broken.
var yaw = (b - a).WithZ( 0 ).EulerAngles.yaw;
ActiveConfig.Current.Teleporters.Add( new TeleporterSpot
{
A = a,
B = b,
Yaw = yaw,
PadSize = TeleporterPadSize,
PadHeight = TeleporterPadHeight,
Model = TeleporterModel,
Material = TeleporterMaterial,
Price = TeleporterPrice,
Cooldown = TeleporterCooldown,
WarmupTime = TeleporterWarmup,
TransitTime = TeleporterTransit,
RequiresPower = TeleporterRequiresPower,
Link = SpawnLink,
} );
TeleporterManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] teleporter placed: {a} -> {b}, {a.Distance( b ):0}u"
+ (TeleporterPrice > 0 ? $", {TeleporterPrice} points" : ", free")
+ $", {TeleporterWarmup:0.#}s warmup + {TeleporterTransit:0.#}s transit"
+ (TeleporterCooldown > 0f ? $", {TeleporterCooldown:0}s cooldown" : "")
+ (TeleporterRequiresPower ? ", needs power" : "")
+ $" ({ActiveConfig.Current.Teleporters.Count} total)" );
return true;
}
/// <summary>
/// Remove the teleporter nearest a point. Right-click, via RemoveAimed.
///
/// ⚠️ MATCHES ON EITHER END. You will right-click whichever end you happen to be standing at,
/// and only ever being able to delete from the pad end is a rule nothing on screen states.
/// </summary>
bool RemoveTeleporterNear( Vector3 at )
{
var list = ActiveConfig.Current.Teleporters;
if ( list.Count == 0 ) return false;
int best = -1;
float bestDist = 140f;
for ( int i = 0; i < list.Count; i++ )
{
var d = MathF.Min( at.Distance( list[i].A ), at.Distance( list[i].B ) );
if ( d >= bestDist ) continue;
bestDist = d;
best = i;
}
if ( best < 0 ) { Log.Info( "[nz] no teleporter near there" ); return false; }
list.RemoveAt( best );
TeleporterManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] removed teleporter #{best} ({list.Count} left)" );
return true;
}
// ── Soul box options, stamped at placement ───────────────────────────────
/// <summary>Kills needed to fill the next box. ⚠️ 20 is what upstream's map scripts use.</summary>
[Property] public int SoulBoxTarget { get; set; } = 20;
/// <summary>How far a kill still counts. ⚠️ 500 is upstream's own default.</summary>
[Property] public float SoulBoxRange { get; set; } = 500f;
/// <summary>Needs the power on to take souls.</summary>
[Property] public bool SoulBoxRequiresPower { get; set; }
/// <summary>Drop a random powerup when the box fills. ⚠️ Per BOX — five boxes pay five.</summary>
[Property] public bool SoulBoxPowerup { get; set; } = true;
/// <summary>The next box's model. Empty = the placeholder box.</summary>
[Property] public string SoulBoxModel { get; set; } = "models/zmb/bo2/tomb/zm_tm_soul_box.vmdl";
/// <summary>How wide and tall the placeholder box is. Only used when Model is empty.</summary>
[Property] public float SoulBoxSize { get; set; } = 48f;
[Property] public float SoulBoxHeight { get; set; } = 48f;
/// <summary>Surface for the placeholder box.</summary>
[Property] public string SoulBoxMaterial { get; set; } = "materials/metal/metalwall048a.vmat";
/// <summary>Soul box from the crosshair. Mirrors AddWunderfizz.</summary>
public bool AddSoulBox()
{
var hit = AimTrace();
if ( hit is null )
{
Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
return false;
}
return AddSoulBoxAt( hit.Value.HitPosition, hit.Value.Normal );
}
/// <summary>Place one explicitly, so a command can drive it.</summary>
public bool AddSoulBoxAt( Vector3 at ) => AddSoulBoxAt( at, Vector3.Up );
/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
public bool AddSoulBoxAt( Vector3 at, Vector3 normal )
{
var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;
ActiveConfig.Current.SoulBoxes.Add( new SoulBoxSpot
{
Position = at,
Yaw = yaw,
Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
Target = SoulBoxTarget,
Range = SoulBoxRange,
RequiresPower = SoulBoxRequiresPower,
Powerup = SoulBoxPowerup,
Model = SoulBoxModel,
Size = SoulBoxSize,
Height = SoulBoxHeight,
Material = SoulBoxMaterial,
Link = SpawnLink,
} );
SoulBoxManager.Ensure( Scene )?.Rebuild();
// ⚠️ SAYS HOW MANY BOXES NOW SHARE THIS FLAG, which is the number that decides when it
// opens. Placing the fourth of five and being told only "placed" leaves the one fact the
// author is tracking off the screen.
var sameFlag = ActiveConfig.Current.SoulBoxes
.Count( b => DoorLinks.Same( b.Link, SpawnLink ) );
Log.Info( $"[nz] soul box placed at {at} {SoulBoxTarget} kills within {SoulBoxRange:0}u"
+ (SoulBoxRequiresPower ? ", needs power" : "")
+ (SoulBoxPowerup ? ", drops a powerup" : "")
+ $" · flag {DoorLinks.Display( SpawnLink )}"
+ (DoorLinks.IsUnlinked( SpawnLink ) ? " (gates nothing)" : $" — {sameFlag} box(es) on it")
+ $" ({ActiveConfig.Current.SoulBoxes.Count} total)" );
return true;
}
/// <summary>Remove the soul box nearest a point. Right-click, via RemoveAimed.</summary>
bool RemoveSoulBoxNear( Vector3 at )
{
var list = ActiveConfig.Current.SoulBoxes;
if ( list.Count == 0 ) return false;
int best = -1;
float bestDist = 140f;
for ( int i = 0; i < list.Count; i++ )
{
var d = at.Distance( list[i].Position );
if ( d >= bestDist ) continue;
bestDist = d;
best = i;
}
if ( best < 0 ) { Log.Info( "[nz] no soul box near there" ); return false; }
var flag = list[best].Link;
list.RemoveAt( best );
SoulBoxManager.Ensure( Scene )?.Rebuild();
// ⚠️ THE REMAINING COUNT ON THAT FLAG IS THE POINT. Removing one box makes its flag open a
// kill-count sooner, and removing the LAST one makes it never open at all.
var left = list.Count( b => DoorLinks.Same( b.Link, flag ) );
Log.Info( $"[nz] removed soul box #{best} (flag {DoorLinks.Display( flag )}"
+ $" now has {left} box(es)) · {list.Count} total" );
return true;
}
/// <summary>Ammo box from the crosshair. Mirrors AddWunderfizz.</summary>
/// <summary>
/// Place a light where you are aiming.
///
/// ⚠️ LIFTED OFF THE SURFACE. A light sitting exactly on a wall lights almost nothing — half
/// its sphere is inside the geometry — and reads as a light that does not work. 16 units along
/// the surface normal puts it in the room.
///
/// ⚠️ AND NO STEEP-SURFACE REFUSAL, unlike every machine placeable. A light on a wall or a
/// ceiling is the normal case, not a mistake.
/// </summary>
public bool AddMapLight()
{
var hit = AimTrace();
if ( hit is null )
{
Log.Info( "[nz] nothing under the crosshair — aim at a surface" );
return false;
}
var n = hit.Value.Normal.IsNearlyZero() ? Vector3.Up : hit.Value.Normal.Normal;
return AddMapLightAt( hit.Value.HitPosition + n * 16f );
}
/// <summary>Place one explicitly, so a command can drive it.</summary>
public bool AddMapLightAt( Vector3 at )
{
ActiveConfig.Current.Lights.Add( new MapLight
{
Position = at,
Color = LightColor,
Brightness = LightBrightness,
Radius = LightRadius,
Shadows = LightShadows,
RequiresPower = LightRequiresPower,
} );
MapLightManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] light placed at {at} {LightColor} x{LightBrightness:0.##}"
+ $" radius {LightRadius:0}"
+ ( LightShadows ? " ⚠ SHADOWS ON — the expensive kind" : "" )
+ $" ({ActiveConfig.Current.Lights.Count} total)" );
return true;
}
/// <summary>
/// Build a fog area from a drawn footprint.
///
/// ⛔ A DRAWN VOLUME, AND IT USED TO BE A SPHERE WITH A RADIUS. Asked for as *"not a radius, but
/// instead like the walls, an area i can create"* — and the sphere was wrong for the job in a way
/// worth recording: map spaces are rooms and corridors, and the only sphere that covers a corridor
/// is one that also covers everything on the other side of its walls. Every area was either too
/// small for the space or leaking into the next one.
///
/// ⚠️ SAME `MeasureBlock` AS THE WALLS AND THE DEBRIS, deliberately. The requirement is that it
/// works exactly like the wall tool, and the only way that stays true is for both to run the same
/// measurement rather than two copies that begin identical — the reason `BlockShape` exists at
/// all.
/// </summary>
bool AddFogArea( BlockShape shape )
{
ActiveConfig.Current.Fog.Add( new FogArea
{
Position = shape.Position,
Yaw = shape.Yaw,
Size = shape.Size,
Footprint = shape.Footprint,
Color = FogColor,
Density = FogDensity,
Feather = FogFeather,
} );
FogAreaManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] fog area built {shape.Footprint.Count}-sided footprint,"
+ $" {shape.Size.z:0} tall at {shape.Position} yaw {shape.Yaw:0}"
+ $" {FogColor} d{FogDensity:0.##} feather {FogFeather:0}u"
+ $" ({ActiveConfig.Current.Fog.Count} total)" );
return true;
}
/// <summary>Remove the fog area whose centre is nearest a point.</summary>
bool RemoveFogAreaNear( Vector3 at )
{
var list = ActiveConfig.Current.Fog;
if ( list.Count == 0 ) return false;
var best = -1;
var bestD = float.MaxValue;
for ( int i = 0; i < list.Count; i++ )
{
// ⚠️ MEASURED AGAINST THE BOX, NOT THE CENTRE. A fog area can be a long corridor, and
// its centre can be fifty units from where you are standing inside it — aiming at the
// part you can see and removing something across the map is the obvious failure.
var half = list[i].Size * 0.5f;
var local = list[i].Rotation.Inverse * ( at - list[i].Position );
var d = new Vector3(
MathF.Max( 0f, MathF.Abs( local.x ) - half.x ),
MathF.Max( 0f, MathF.Abs( local.y ) - half.y ),
MathF.Max( 0f, MathF.Abs( local.z ) - half.z ) ).Length;
if ( d >= bestD ) continue;
bestD = d;
best = i;
}
if ( best < 0 || bestD > 256f )
{
Log.Info( "[nz] no fog area near there" );
return false;
}
list.RemoveAt( best );
FogAreaManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] removed fog area #{best} ({list.Count} left)" );
return true;
}
/// <summary>
/// Record a room zone from a measured block — the fog area's and the walls' own measurement, so it is drawn exactly as they
/// are. ⚠️ NO BODY: no collider, no nav, nothing drawn in a round. A zone is a name for a space, tested by containment
/// (`RoomZone.Contains`) on every machine for its own player, and the host tells the others at once (`RoomZones.Sync`).
/// </summary>
bool AddRoomZone( BlockShape s )
{
var name = RoomZoneName?.Trim() ?? "";
ActiveConfig.Current.RoomZones.Add( new RoomZone
{
Position = s.Position,
Yaw = s.Yaw,
Size = s.Size,
Footprint = s.Footprint,
Name = name,
} );
RoomZones.Sync();
Log.Info( $"[nz] room zone #{ActiveConfig.Current.RoomZones.Count - 1} drawn " + Describe( s )
+ (name.Length > 0 ? $" '{name}'" : " ⚠ NO NAME — it names nothing until it has one (nz_room_zone_name)") );
return true;
}
/// <summary>Remove the room zone nearest a point — measured against its box, as the fog area's is.</summary>
bool RemoveRoomZoneNear( Vector3 at )
{
var list = ActiveConfig.Current.RoomZones;
if ( list.Count == 0 ) return false;
var best = -1;
var bestD = float.MaxValue;
for ( int i = 0; i < list.Count; i++ )
{
var half = list[i].Size * 0.5f;
var local = list[i].Rotation.Inverse * ( at - list[i].Position );
var d = new Vector3(
MathF.Max( 0f, MathF.Abs( local.x ) - half.x ),
MathF.Max( 0f, MathF.Abs( local.y ) - half.y ),
MathF.Max( 0f, MathF.Abs( local.z ) - half.z ) ).Length;
if ( d >= bestD ) continue;
bestD = d;
best = i;
}
if ( best < 0 || bestD > 256f )
{
Log.Info( "[nz] no room zone near there" );
return false;
}
var gone = list[best];
list.RemoveAt( best );
RoomZones.Sync();
Log.Info( $"[nz] removed room zone #{best} '{gone.Name}' ({list.Count} left)" );
return true;
}
bool RemoveMapLightNear( Vector3 at )
{
var list = ActiveConfig.Current.Lights;
if ( list.Count == 0 ) return false;
int best = -1;
float bestDist = 140f;
for ( int i = 0; i < list.Count; i++ )
{
var d = at.Distance( list[i].Position );
if ( d >= bestDist ) continue;
bestDist = d;
best = i;
}
if ( best < 0 ) { Log.Info( "[nz] no light near there" ); return false; }
list.RemoveAt( best );
MapLightManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] light removed ({list.Count} left)" );
return true;
}
/// <summary>Place a looping sound where you are aiming.</summary>
public bool AddSoundSpot()
{
var hit = AimTrace();
if ( hit is null )
{
Log.Info( "[nz] nothing under the crosshair — aim at a surface" );
return false;
}
var n = hit.Value.Normal.IsNearlyZero() ? Vector3.Up : hit.Value.Normal.Normal;
return AddSoundSpotAt( hit.Value.HitPosition + n * 16f );
}
/// <summary>Place one explicitly, so a command can drive it.</summary>
public bool AddSoundSpotAt( Vector3 at )
{
// ⚠️ PLACED EVEN WITH NO EVENT CHOSEN, and it says so rather than refusing. Positioning
// first and picking the sound afterwards is a normal way to work, and a tool that silently
// did nothing when the picker was empty would read as broken.
if ( string.IsNullOrWhiteSpace( SoundEventPath ) )
Log.Info( "[nz] no sound chosen yet — placed silent, pick one in the tool panel "
+ "or with nz_sound_set" );
ActiveConfig.Current.Sounds.Add( new SoundSpot
{
Position = at,
Sound = SoundEventPath,
Volume = SoundSpotVolume,
Distance = SoundSpotDistance,
Repeat = SoundSpotRepeat > 0f,
RepeatMin = SoundSpotRepeat,
RepeatMax = SoundSpotRepeat,
} );
SoundSpotManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] sound placed at {at} '{SoundEventPath}'"
+ $" vol {SoundSpotVolume:0.##} dist {SoundSpotDistance:0}"
+ $" ({ActiveConfig.Current.Sounds.Count} total)" );
return true;
}
bool RemoveSoundSpotNear( Vector3 at )
{
var list = ActiveConfig.Current.Sounds;
if ( list.Count == 0 ) return false;
int best = -1;
float bestDist = 140f;
for ( int i = 0; i < list.Count; i++ )
{
var d = at.Distance( list[i].Position );
if ( d >= bestDist ) continue;
bestDist = d;
best = i;
}
if ( best < 0 ) { Log.Info( "[nz] no sound near there" ); return false; }
list.RemoveAt( best );
SoundSpotManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] sound removed ({list.Count} left)" );
return true;
}
/// <summary>
/// Place a springboard where you are aiming, on the floor. Aimed inside one already placed, it gives that one the panel's
/// strength, radius and landing rule instead: tuning a pad is a click, not a remove and a re-place.
/// </summary>
public bool AddSpringboard()
{
var hit = AimTrace();
if ( hit is null )
{
Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
return false;
}
return AddSpringboardAt( hit.Value.HitPosition, hit.Value.Normal );
}
/// <summary>Place one, or re-set the one under the point, explicitly — so a command can drive it.</summary>
public bool AddSpringboardAt( Vector3 at, Vector3 normal )
{
// ⚠️ The steep-surface refusal the floor machines here use: a pad on a wall throws nobody, and nothing about it says so.
if ( !normal.IsNearlyZero() && normal.Normal.z <= 0.7f )
{
Log.Info( "[nz] that surface is too steep for a springboard — aim at the floor" );
return false;
}
var list = ActiveConfig.Current.Springboards ??= new();
var strength = MathF.Max( 0f, SpringboardStrength );
var radius = MathF.Max( 8f, SpringboardRadius );
var up = SpringboardSystem.ApexHeight( strength, Scene );
var landing = SpringboardSafeLanding ? "" : " ⚠ fall damage on the landing";
var i = SpringboardSystem.PadAt( list, at );
if ( i >= 0 )
{
list[i].Strength = strength;
list[i].Radius = radius;
list[i].SafeLanding = SpringboardSafeLanding;
Log.Info( $"[nz] springboard #{i} re-set — {strength:0} u/s, about {up:0} up, radius {radius:0}{landing}" );
return true;
}
list.Add( new SpringboardSpot
{
Position = at,
Strength = strength,
Radius = radius,
SafeLanding = SpringboardSafeLanding,
} );
Log.Info( $"[nz] springboard placed at {at} — {strength:0} u/s, about {up:0} up, radius {radius:0}{landing}"
+ $" ({list.Count} total)" );
return true;
}
/// <summary>Remove the springboard under the crosshair: the one whose circle holds the point, else the nearest within 140.</summary>
bool RemoveSpringboardNear( Vector3 at )
{
var list = ActiveConfig.Current.Springboards;
var best = SpringboardSystem.PadAt( list, at );
// Not inside one: the nearest middle within 140, as the other point placeables here remove.
if ( best < 0 && list is not null )
{
var bestDist = 140f;
for ( int i = 0; i < list.Count; i++ )
{
var d = at.Distance( list[i].Position );
if ( d >= bestDist ) continue;
bestDist = d;
best = i;
}
}
if ( best < 0 ) { Log.Info( "[nz] no springboard near there" ); return false; }
list.RemoveAt( best );
Log.Info( $"[nz] springboard #{best} removed ({list.Count} left)" );
return true;
}
public bool AddAmmoBox()
{
var hit = AimTrace();
if ( hit is null )
{
Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
return false;
}
return AddAmmoBoxAt( hit.Value.HitPosition, hit.Value.Normal );
}
/// <summary>Place one explicitly, so a command can drive it.</summary>
public bool AddAmmoBoxAt( Vector3 at ) => AddAmmoBoxAt( at, Vector3.Up );
/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
public bool AddAmmoBoxAt( Vector3 at, Vector3 normal )
{
// ⚠️ The same steep-surface refusal every machine here uses: one mounted sideways on a wall
// looks like a placement that worked, and nothing about the result says you aimed at a wall.
if ( !normal.IsNearlyZero() && normal.Normal.z <= 0.7f )
{
Log.Info( "[nz] that surface is too steep for an ammo box — aim at the floor" );
return false;
}
var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;
ActiveConfig.Current.AmmoBoxes.Add( new AmmoBoxSpot
{
Position = at,
Yaw = yaw,
Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
Link = SpawnLink,
} );
AmmoBoxManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] ammo box placed at {at} facing {yaw:0}° "
+ $"({ActiveConfig.Current.AmmoBoxes.Count} total)" );
return true;
}
bool RemoveAmmoBoxNear( Vector3 at )
{
var list = ActiveConfig.Current.AmmoBoxes;
if ( list.Count == 0 ) return false;
int best = -1;
float bestDist = 140f;
for ( int i = 0; i < list.Count; i++ )
{
var d = at.Distance( list[i].Position );
if ( d >= bestDist ) continue;
bestDist = d;
best = i;
}
if ( best < 0 ) { Log.Info( "[nz] no ammo box near there" ); return false; }
list.RemoveAt( best );
AmmoBoxManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] ammo box removed ({list.Count} left)" );
return true;
}
// ── Buyable ending, stamped at placement ─────────────────────────────────
/// <summary>What the ending costs. The original's default.</summary>
[Property] public int EndingPrice { get; set; } = 500;
// ⛔ NO EDITOR FIELDS FOR model / keep-playing / reward-perks / perma-perks /
// game-over text / power. The tool is deliberately three settings — price, prompt,
// starting round — so these are no longer stamped at placement and each spot takes
// its `EndingSpot` default (the teddy bear, and every flag off, which is an ending
// that simply ends the run).
//
// ⚠️ THE FIELDS AND THEIR BEHAVIOUR STAY ON `EndingSpot`, and `BuyableEnding.Buy`
// still honours them. They are reachable from a saved config and from a future row;
// deleting working, tested behaviour to shorten a panel would be the wrong trade, and
// a config that already carries them keeps working.
/// <summary>The line on the use prompt.</summary>
[Property] public string EndingHint { get; set; } = "End game";
/// <summary>Earliest round it can be used.</summary>
[Property] public int EndingStartRound { get; set; }
// ── Pressable, stamped at placement ─────────────────────────────────────
/// <summary>Flags that must ALL be set for the next pressable to be available.
/// Comma-separated; blank = available from the start.</summary>
[Property] public string EggRequired { get; set; } = "";
/// <summary>Flags the next pressable SETS on completion. Comma-separated.</summary>
[Property] public string EggReward { get; set; } = "";
/// <summary>Flags that, if ANY is set, make the next pressable unavailable.</summary>
[Property] public string EggExcluded { get; set; } = "";
/// <summary>General-flag (door link) opened on completion. Blank = none.</summary>
[Property] public string EggGeneralReward { get; set; } = "";
/// <summary>Which STEP of the easter egg the next pressable belongs to. Everything sharing a
/// number is one step and completes or resets as a unit. 0 = on its own.</summary>
[Property] public int EggStepNumber { get; set; }
/// <summary>Exact presses needed. 0 or 1 = a single press.</summary>
[Property] public int EggRepeat { get; set; }
/// <summary>Hold this long instead of tapping. 0 = a tap.</summary>
[Property] public float PressHold { get; set; }
/// <summary>How a failed attempt is locked out: "Seconds" or "Next round".
///
/// ⚠️ A STRING HERE, AN ENUM IN THE CONFIG, translated at placement — the same shape as
/// `SpecialEnemy` and `TeleporterModel`. The tool panel's picker rows work in the label the
/// mapper reads, and the saved map should not carry a display string.</summary>
[Property] public string EggRetryMode { get; set; } = ToolSettings.RetryModes[0];
/// <summary>Retry delay after a failed attempt. Only used when EggRetryMode is "Seconds".</summary>
[Property] public float EggCooldown { get; set; }
/// <summary>Seconds to finish the whole step once anyone starts it. 0 = no limit.</summary>
[Property] public float EggWindow { get; set; }
/// <summary>Becomes available this long after its Required flags are met.</summary>
[Property] public float EggTimed { get; set; }
/// <summary>Progress resets on the round turn.</summary>
[Property] public bool EggResetOnRound { get; set; }
/// <summary>Pressable from the crosshair.</summary>
public bool AddPressable()
{
var hit = AimTrace();
if ( hit is null )
{
Log.Info( "[nz] nothing under the crosshair — aim at a wall or the floor" );
return false;
}
return AddPressableAt( hit.Value.HitPosition, hit.Value.Normal );
}
/// <summary>Place one explicitly, so a command can drive it.</summary>
public bool AddPressableAt( Vector3 at ) => AddPressableAt( at, Vector3.Up );
/// <summary>Place one with an explicit surface normal.
///
/// ⛔ NO STEEP-SURFACE REFUSAL — a button belongs on a WALL more often than a floor.</summary>
public bool AddPressableAt( Vector3 at, Vector3 normal )
{
var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;
var spot = new PressableSpot
{
Position = at,
Yaw = yaw,
Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
// ⚠️ THE SHARED `SpawnLink`, not a Press-specific one. Every placeable in this
// project stamps its door flag from the same editor field, so a mapper types a
// flag once and places a spawner, a wall buy and a button behind the same door.
Link = SpawnLink,
HoldSeconds = PressHold,
};
StampEggConditions( spot );
ActiveConfig.Current.Pressables.Add( spot );
PressableManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] pressable placed at {at}"
+ $" flag {DoorLinks.Display( spot.Link )}"
+ ( EggStepNumber > 0 ? $" step {EggStepNumber}" : "" )
+ $" needs [{string.Join( ",", spot.Step.Required )}]"
+ $" gives [{string.Join( ",", spot.Step.Reward )}]"
+ ( EggRepeat > 1 ? $" x{EggRepeat}" : "" )
+ ( PressHold > 0f ? $" hold {PressHold:0.#}s" : "" )
+ $" ({ActiveConfig.Current.Pressables.Count} total)" );
return true;
}
/// <summary>The next clue's colour, opacity folded into its alpha.
///
/// ⛔ COLOUR AND OPACITY ARE TWO CONTROLS AND ONE VALUE. A mapper thinks "make it red"
/// and "make it faint" separately, but `Color` carries alpha — so the hex sets RGB and
/// the opacity slider overwrites A. Exposing an 8-digit hex instead would make
/// transparency something you compute rather than something you drag.
///
/// ⚠️ AN UNPARSEABLE HEX FALLS BACK TO WHITE AND SAYS SO. Silently drawing black text on
/// a dark wall is indistinguishable from the clue not rendering at all.</summary>
Color ClueTint()
{
var c = Color.White;
if ( !string.IsNullOrWhiteSpace( ClueColor ) )
{
if ( Color.TryParse( ClueColor, out var parsed ) ) c = parsed;
else Log.Warning( $"[nz] clue colour '{ClueColor}' is not a hex like #ffeeb8 — using white" );
}
return c.WithAlpha( ClueOpacity.Clamp( 0f, 1f ) );
}
/// <summary>Comma-separated flag names, trimmed, blanks dropped.</summary>
public static List<string> SplitFlags( string csv )
=> string.IsNullOrWhiteSpace( csv )
? new List<string>()
: csv.Split( ',' ).Select( f => f.Trim() ).Where( f => f.Length > 0 ).ToList();
bool RemovePressableNear( Vector3 at )
{
var list = ActiveConfig.Current.Pressables;
if ( list.Count == 0 ) return false;
int best = -1;
float bestDist = 140f;
for ( int i = 0; i < list.Count; i++ )
{
var d = at.Distance( list[i].Position );
if ( d >= bestDist ) continue;
bestDist = d;
best = i;
}
if ( best < 0 ) { Log.Info( "[nz] no pressable near there" ); return false; }
list.RemoveAt( best );
PressableManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] pressable removed ({list.Count} left)" );
return true;
}
/// <summary>Must be using this weapon to trigger an interactable. A substring of the class
/// name, so "awm" matches "nz_awm". Blank = any.</summary>
[Property] public string EggWeapon { get; set; } = "";
/// <summary>Must own this perk to trigger an interactable. Blank = none needed.</summary>
[Property] public string EggPerk { get; set; } = "";
// ── Shootable, stamped at placement ────────────────────────────────
/// <summary>The next shootable must be hit with a Pack-a-Punched weapon.</summary>
[Property] public bool ShootRequiresPaP { get; set; }
/// <summary>Shootable from the crosshair.</summary>
public bool AddShootable()
{
var hit = AimTrace();
if ( hit is null )
{
Log.Info( "[nz] nothing under the crosshair — aim at a wall or the floor" );
return false;
}
return AddShootableAt( hit.Value.HitPosition, hit.Value.Normal );
}
/// <summary>Place one explicitly, so a command can drive it.</summary>
public bool AddShootableAt( Vector3 at ) => AddShootableAt( at, Vector3.Up );
/// <summary>Place one with an explicit surface normal.</summary>
public bool AddShootableAt( Vector3 at, Vector3 normal )
{
var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;
var spot = new ShootableSpot
{
Position = at,
Yaw = yaw,
Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
Link = SpawnLink,
RequiresPaP = ShootRequiresPaP,
};
StampEggConditions( spot );
ActiveConfig.Current.Shootables.Add( spot );
ShootableManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] shootable placed at {at}"
+ $" flag {DoorLinks.Display( spot.Link )}"
+ ( EggStepNumber > 0 ? $" step {EggStepNumber}" : "" )
+ $" needs [{string.Join( ",", spot.Step.Required )}]"
+ $" gives [{string.Join( ",", spot.Step.Reward )}]"
+ ( ShootRequiresPaP ? " PaP only" : "" )
+ $" ({ActiveConfig.Current.Shootables.Count} total)" );
return true;
}
bool RemoveShootableNear( Vector3 at )
{
var list = ActiveConfig.Current.Shootables;
if ( list.Count == 0 ) return false;
int best = -1;
float bestDist = 140f;
for ( int i = 0; i < list.Count; i++ )
{
var d = at.Distance( list[i].Position );
if ( d >= bestDist ) continue;
bestDist = d;
best = i;
}
if ( best < 0 ) { Log.Info( "[nz] no shootable near there" ); return false; }
list.RemoveAt( best );
ShootableManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] shootable removed ({list.Count} left)" );
return true;
}
/// <summary>
/// Stamp the shared easter-egg conditions onto a freshly placed interactable.
///
/// ⛔ ONE METHOD BECAUSE THE FIELDS ARE SHARED. The tool panel edits one set of `Egg*`
/// properties whichever interactable is selected — the same way every placeable in this
/// project stamps its door flag from one `SpawnLink` — so a mapper types a step number and
/// its flags ONCE and can then place a button and a shootable into that same step. Two
/// parallel sets would have made building one step out of mixed parts a retyping exercise.
/// </summary>
void StampEggConditions( EggSpot spot )
{
spot.RepeatCount = EggRepeat;
spot.CooldownSeconds = EggCooldown;
spot.Retry = EggRetryMode == ToolSettings.RetryModes[1] ? EggRetry.NextRound : EggRetry.Seconds;
spot.TimeWindow = EggWindow;
spot.TimedDelay = EggTimed;
spot.ResetOnRound = EggResetOnRound;
spot.RequiredWeapon = EggWeapon ?? "";
spot.RequiredPerk = EggPerk ?? "";
// ⚠️ SPLIT FROM COMMA-SEPARATED TEXT because the tool panel has no list editor. Empty
// entries are dropped so "a, b," does not register a nameless flag — a blank flag would be
// permanently unset and would lock the step for ever with nothing on screen to say why.
spot.Step.Required = SplitFlags( EggRequired );
spot.Step.Reward = SplitFlags( EggReward );
spot.Step.Excluded = SplitFlags( EggExcluded );
spot.Step.GeneralReward = EggGeneralReward ?? "";
spot.Step.StepNumber = EggStepNumber;
}
// ── Clue, stamped at placement ───────────────────────────────────────────
/// <summary>What the next clue says.</summary>
[Property] public string ClueText { get; set; } = "";
/// <summary>Optional image path for the next clue.</summary>
[Property] public string ClueImage { get; set; } = "";
/// <summary>Panel size in world units. Big by default — see ClueSpot.Size.</summary>
[Property] public Vector2 ClueSize { get; set; } = new( 1000f, 1000f );
/// <summary>Text colour as hex, e.g. #ffeeb8. Blank or unparseable = white.</summary>
[Property] public string ClueColor { get; set; } = "#ffeeb8";
/// <summary>Text opacity, 0 (invisible) to 1 (solid).</summary>
[Property] public float ClueOpacity { get; set; } = 1f;
/// <summary>Text size, in panel units.</summary>
[Property] public float ClueFontSize { get; set; } = 16f;
/// <summary>Clue from the crosshair.</summary>
public bool AddClue()
{
var hit = AimTrace();
if ( hit is null )
{
Log.Info( "[nz] nothing under the crosshair — aim at a wall or the floor" );
return false;
}
return AddClueAt( hit.Value.HitPosition, hit.Value.Normal );
}
/// <summary>Place one explicitly, so a command can drive it.</summary>
public bool AddClueAt( Vector3 at ) => AddClueAt( at, Vector3.Up );
/// <summary>Place one with an explicit surface normal.
///
/// ⛔ NO STEEP-SURFACE REFUSAL. A clue is a wall panel or a sign — a wall is its
/// PRIMARY home, not an edge case, and refusing one would leave the tool able to place
/// only the least useful kind.</summary>
public bool AddClueAt( Vector3 at, Vector3 normal )
{
var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;
ActiveConfig.Current.Clues.Add( new ClueSpot
{
Position = at,
Yaw = yaw,
Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
Text = ClueText,
Image = ClueManager.ResolveImage( ClueImage ), // a bare name saved as its clue-folder path
Size = ClueSize,
FontSize = ClueFontSize,
Tint = ClueTint(),
} );
ClueManager.Ensure( Scene )?.Rebuild();
var flat = normal.IsNearlyZero() || normal.Normal.z > 0.7f;
Log.Info( $"[nz] clue placed at {at} · {( flat ? "flat on the floor" : "mounted to the surface" )}"
+ ( string.IsNullOrWhiteSpace( ClueText ) ? " (no text yet — nz_clue_text)" : $" '{ClueText}'" )
+ $" ({ActiveConfig.Current.Clues.Count} total)" );
return true;
}
bool RemoveClueNear( Vector3 at )
{
var list = ActiveConfig.Current.Clues;
if ( list.Count == 0 ) return false;
int best = -1;
float bestDist = 140f;
for ( int i = 0; i < list.Count; i++ )
{
var d = at.Distance( list[i].Position );
if ( d >= bestDist ) continue;
bestDist = d;
best = i;
}
if ( best < 0 ) { Log.Info( "[nz] no clue near there" ); return false; }
list.RemoveAt( best );
ClueManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] clue removed ({list.Count} left)" );
return true;
}
/// <summary>Size of the placeholder box, in units.</summary>
[Property] public Vector3 MiserySize { get; set; } = new( 32f, 32f, 48f );
/// <summary>Misery device from the crosshair.</summary>
public bool AddMisery()
{
var hit = AimTrace();
if ( hit is null )
{
Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
return false;
}
return AddMiseryAt( hit.Value.HitPosition, hit.Value.Normal );
}
/// <summary>Place one explicitly, so a command can drive it.</summary>
public bool AddMiseryAt( Vector3 at ) => AddMiseryAt( at, Vector3.Up );
/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
public bool AddMiseryAt( Vector3 at, Vector3 normal )
{
// ⚠️ The same steep-surface refusal every standing machine here uses.
if ( !normal.IsNearlyZero() && normal.Normal.z <= 0.7f )
{
Log.Info( "[nz] that surface is too steep for a misery device — aim at the floor" );
return false;
}
var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;
ActiveConfig.Current.Miseries.Add( new MiserySpot
{
Position = at,
Yaw = yaw,
Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
Size = MiserySize,
} );
MiseryDeviceManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] misery device placed at {at} facing {yaw:0}° "
+ $"({ActiveConfig.Current.Miseries.Count} total)" );
return true;
}
bool RemoveMiseryNear( Vector3 at )
{
var list = ActiveConfig.Current.Miseries;
if ( list.Count == 0 ) return false;
int best = -1;
float bestDist = 140f;
for ( int i = 0; i < list.Count; i++ )
{
var d = at.Distance( list[i].Position );
if ( d >= bestDist ) continue;
bestDist = d;
best = i;
}
if ( best < 0 ) { Log.Info( "[nz] no misery device near there" ); return false; }
list.RemoveAt( best );
MiseryDeviceManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] misery device removed ({list.Count} left)" );
return true;
}
/// <summary>Buyable ending from the crosshair.</summary>
public bool AddEnding()
{
var hit = AimTrace();
if ( hit is null )
{
Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
return false;
}
return AddEndingAt( hit.Value.HitPosition, hit.Value.Normal );
}
/// <summary>Place one explicitly, so a command can drive it.</summary>
public bool AddEndingAt( Vector3 at ) => AddEndingAt( at, Vector3.Up );
/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
public bool AddEndingAt( Vector3 at, Vector3 normal )
{
// ⛔ NO STEEP-SURFACE REFUSAL, UNLIKE EVERY OTHER MACHINE HERE. The Wunderfizz, the
// Pack-a-Punch, the perk machines and the ammo box all refuse a wall, because each
// is one authored prop that only reads correctly standing on the ground. The ending
// is not a machine — it is whatever prop the map points it at, and the exit is as
// likely to be a hatch, a radio or a panel on a wall as a bear on the floor.
//
// ⚠️ `BuyableEndingManager.OnSurface` is what makes this safe: it mounts flush and
// faces OUT of a wall, and stands upright on a floor. Removing the refusal without
// that would lay the prop on its side.
var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;
ActiveConfig.Current.Endings.Add( new EndingSpot
{
Position = at,
Yaw = yaw,
Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
Price = EndingPrice,
Hint = EndingHint,
StartRound = EndingStartRound,
// ⚠️ `Link` IS STILL STAMPED even though the panel has no Flag row. `SpawnLink`
// is SHARED editor state that every placeable reads, set from whichever tool
// panel last showed it — so the flag a mapper set for a door still applies here,
// exactly as it does for a wallbuy or an ammo box. Dropping the stamp would make
// this the one placeable that silently ignores the flag they just set.
Link = SpawnLink,
} );
BuyableEndingManager.Ensure( Scene )?.Rebuild();
// ⚠️ SAYS WHICH SURFACE IT READ, because floor and wall place differently and the
// only thing that decides is a normal nobody can see. "Placed" alone would leave a
// mapper guessing why one bear stands up and the next is flat against a wall.
var flat = normal.IsNearlyZero() || normal.Normal.z > 0.7f;
Log.Info( $"[nz] buyable ending placed at {at} facing {yaw:0}° · {EndingPrice} points"
+ $" · {( flat ? "upright on the floor" : "mounted flat to the surface" )}"
+ $" ({ActiveConfig.Current.Endings.Count} total)" );
return true;
}
bool RemoveEndingNear( Vector3 at )
{
var list = ActiveConfig.Current.Endings;
if ( list.Count == 0 ) return false;
int best = -1;
float bestDist = 140f;
for ( int i = 0; i < list.Count; i++ )
{
var d = at.Distance( list[i].Position );
if ( d >= bestDist ) continue;
bestDist = d;
best = i;
}
if ( best < 0 ) { Log.Info( "[nz] no buyable ending near there" ); return false; }
list.RemoveAt( best );
BuyableEndingManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] buyable ending removed ({list.Count} left)" );
return true;
}
// ══ building table ═════════════════════════════════════════
//
// ⚠️ A PARALLEL OF THE TRADING TABLE'S THREE METHODS, NOT A SHARED GENERIC ONE. They look
// identical today and will not stay that way — a building table is going to want to know which
// build it serves, and the one thing worse than two similar methods is one method with a flag
// threaded through it for a case that only half applies.
/// <summary>
/// Which piece the build-part tool places next, 1 to 3.
/// </summary>
///
/// ⚠️ A MODE ON THE TOOL RATHER THAN THREE TOOLS. The three are placed in one pass while
/// walking a map, and three menu entries would mean three trips through the Q menu to lay out
/// one puzzle. `nz_buildpart_set` changes it without leaving the map.
public static int BuildPartNext { get; set; } = 1;
/// <summary>Build part from the crosshair.</summary>
public bool AddBuildPart()
{
var hit = AimTrace();
if ( hit is null )
{
Log.Info( "[nz-build] aim at a surface" );
return false;
}
return AddBuildPartAt( hit.Value.HitPosition );
}
public bool AddBuildPartAt( Vector3 at )
{
if ( !BuildParts.Valid( BuildPartNext ) )
{
Log.Info( $"[nz-build] part {BuildPartNext} is not 1..{BuildParts.Count}" );
return false;
}
// ⚠️ NO FLOOR-NORMAL TILT, UNLIKE THE TABLES. A bench has to sit flat because it is
// furniture; a dropped part looks better upright wherever it lands, and tilting it into a
// slope mostly buries one end.
var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;
ActiveConfig.Current.BuildParts.Add( new BuildPartSpot
{
Position = at,
Yaw = yaw,
Part = BuildPartNext,
Link = SpawnLink,
RequiresPower = SpawnRequiresPower,
} );
BuildPartManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz-build] {BuildParts.Name( BuildPartNext )} placed at {at} "
+ $"({ActiveConfig.Current.BuildParts.Count} part(s) total)" );
return true;
}
bool RemoveBuildPartNear( Vector3 at )
{
var list = ActiveConfig.Current.BuildParts;
if ( list.Count == 0 ) return false;
int best = -1;
float bestDist = 140f;
for ( int i = 0; i < list.Count; i++ )
{
var d = at.Distance( list[i].Position );
if ( d >= bestDist ) continue;
bestDist = d;
best = i;
}
if ( best < 0 ) { Log.Info( "[nz-build] no build part near there" ); return false; }
var was = BuildParts.Name( list[best].Part );
list.RemoveAt( best );
BuildPartManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz-build] {was} removed ({list.Count} left)" );
return true;
}
/// <summary>
/// Does a placed building table keep handing its weapon out. Off = one time.
/// </summary>
///
/// ⚠️ A PLACEMENT DEFAULT ON THE EDITOR, like `SpawnLink` and `SpawnRequiresPower`. It is read
/// once when the bench is placed and then belongs to that bench, so changing it here does not
/// disturb tables already on the map.
[Property] public bool BuildTablePermanent { get; set; }
/// <summary>Building table from the crosshair.</summary>
public bool AddBuildTable()
{
var hit = AimTrace();
if ( hit is null )
{
Log.Info( "[nz-build] aim at the floor" );
return false;
}
return AddBuildTableAt( hit.Value.HitPosition, hit.Value.Normal );
}
public bool AddBuildTableAt( Vector3 at ) => AddBuildTableAt( at, Vector3.Up );
/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
public bool AddBuildTableAt( Vector3 at, Vector3 normal )
{
// ⚠️ THE SAME 0.7 THE TRADING TABLE USES — about 45°. A bench on a steeper face than
// that leans far enough to look placed by accident, and the two sharing a mesh should
// share the limit that mesh implies.
if ( !normal.IsNearlyZero() && normal.Normal.z <= 0.7f )
{
Log.Info( "[nz-build] that surface is too steep for a building table — aim at the floor" );
return false;
}
var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;
ActiveConfig.Current.BuildTables.Add( new BuildTableSpot
{
Position = at,
Yaw = yaw,
Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
Link = SpawnLink,
RequiresPower = SpawnRequiresPower,
Permanent = BuildTablePermanent,
} );
BuildTableManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz-build] building table placed at {at} facing {yaw:0}° "
+ $"({ActiveConfig.Current.BuildTables.Count} total)" );
return true;
}
bool RemoveBuildTableNear( Vector3 at )
{
var list = ActiveConfig.Current.BuildTables;
if ( list.Count == 0 ) return false;
int best = -1;
float bestDist = 140f;
for ( int i = 0; i < list.Count; i++ )
{
var d = at.Distance( list[i].Position );
if ( d >= bestDist ) continue;
bestDist = d;
best = i;
}
if ( best < 0 ) { Log.Info( "[nz-build] no building table near there" ); return false; }
list.RemoveAt( best );
BuildTableManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz-build] building table removed ({list.Count} left)" );
return true;
}
/// <summary>Trading table from the crosshair.</summary>
public bool AddTradeTable()
{
var hit = AimTrace();
if ( hit is null )
{
Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
return false;
}
return AddTradeTableAt( hit.Value.HitPosition, hit.Value.Normal );
}
/// <summary>Place one explicitly, so a command can drive it.</summary>
public bool AddTradeTableAt( Vector3 at ) => AddTradeTableAt( at, Vector3.Up );
/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
public bool AddTradeTableAt( Vector3 at, Vector3 normal )
{
if ( !normal.IsNearlyZero() && normal.Normal.z <= 0.7f )
{
Log.Info( "[nz] that surface is too steep for a trading table — aim at the floor" );
return false;
}
var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;
ActiveConfig.Current.TradeTables.Add( new TradeTableSpot
{
Position = at,
Yaw = yaw,
Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
Link = SpawnLink,
} );
TradeTableManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] trading table placed at {at} facing {yaw:0}° "
+ $"({ActiveConfig.Current.TradeTables.Count} total)" );
return true;
}
bool RemoveTradeTableNear( Vector3 at )
{
var list = ActiveConfig.Current.TradeTables;
if ( list.Count == 0 ) return false;
int best = -1;
float bestDist = 140f;
for ( int i = 0; i < list.Count; i++ )
{
var d = at.Distance( list[i].Position );
if ( d >= bestDist ) continue;
bestDist = d;
best = i;
}
if ( best < 0 ) { Log.Info( "[nz] no trading table near there" ); return false; }
list.RemoveAt( best );
TradeTableManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] trading table removed ({list.Count} left)" );
return true;
}
// ── Arsenal options, stamped at placement ─────────────────────────────
//
// ⚠️ STAMPED AT PLACEMENT, like every other placeable's options. Editing these
// changes what the NEXT machine is placed with and leaves existing ones alone —
// which is what a mapper expects from a tool panel, and is why the config stores
// the values per spot rather than reading them live.
/// <summary>Salvage for armor tier 1. ARSENAL_REMAKE.md §4.3 locks 700/2000/5000.</summary>
[Property] public int ArsenalTier1Price { get; set; } = 700;
[Property] public int ArsenalTier2Price { get; set; } = 2000;
[Property] public int ArsenalTier3Price { get; set; } = 5000;
[Property] public int ArsenalStartRound { get; set; }
[Property] public bool ArsenalRequiresPower { get; set; } = true;
/// <summary>Arsenal from the crosshair. Mirrors AddWunderfizz.</summary>
public bool AddArsenal()
{
var hit = AimTrace();
if ( hit is null )
{
Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
return false;
}
return AddArsenalAt( hit.Value.HitPosition, hit.Value.Normal );
}
/// <summary>Place one explicitly, so a command can drive it.</summary>
public bool AddArsenalAt( Vector3 at ) => AddArsenalAt( at, Vector3.Up );
/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
public bool AddArsenalAt( Vector3 at, Vector3 normal )
{
// ⚠️ Same steep-surface refusal as the other machines: one mounted sideways
// on a wall looks like a placement that worked, and nothing about the result
// says you aimed at a wall.
if ( !normal.IsNearlyZero() && normal.Normal.z <= 0.7f )
{
Log.Info( "[nz] that surface is too steep for an Arsenal — aim at the floor" );
return false;
}
var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;
ActiveConfig.Current.Arsenals.Add( new ArsenalSpot
{
Position = at,
Yaw = yaw,
Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
ArmorTier1Price = ArsenalTier1Price,
ArmorTier2Price = ArsenalTier2Price,
ArmorTier3Price = ArsenalTier3Price,
StartRound = ArsenalStartRound,
RequiresPower = ArsenalRequiresPower,
Link = SpawnLink,
} );
ArsenalManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] Arsenal placed at {at} facing {yaw:0}° — armor "
+ $"{ArsenalTier1Price}/{ArsenalTier2Price}/{ArsenalTier3Price} salvage "
+ $"({ActiveConfig.Current.Arsenals.Count} total)" );
return true;
}
/// <summary>Remove the Arsenal nearest a point. Right-click, via RemoveAimed.</summary>
bool RemoveArsenalNear( Vector3 at )
{
var list = ActiveConfig.Current.Arsenals;
if ( list.Count == 0 ) return false;
int best = -1;
float bestDist = 140f;
for ( int i = 0; i < list.Count; i++ )
{
var d = at.Distance( list[i].Position );
if ( d >= bestDist ) continue;
bestDist = d;
best = i;
}
if ( best < 0 ) { Log.Info( "[nz] no Arsenal near there" ); return false; }
list.RemoveAt( best );
ArsenalManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] Arsenal removed ({list.Count} left)" );
return true;
}
/// <summary>Remove the Wunderfizz nearest a point. Right-click, via RemoveAimed.</summary>
bool RemoveWunderfizzNear( Vector3 at )
{
var list = ActiveConfig.Current.Wunderfizzes;
if ( list.Count == 0 ) return false;
int best = -1;
float bestDist = 140f;
for ( int i = 0; i < list.Count; i++ )
{
var d = at.Distance( list[i].Position );
if ( d >= bestDist ) continue;
bestDist = d;
best = i;
}
if ( best < 0 ) { Log.Info( "[nz] no Wunderfizz near there" ); return false; }
list.RemoveAt( best );
WunderfizzManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] Wunderfizz removed ({list.Count} left)" );
return true;
}
/// <summary>Remove the machine nearest a point. Right-click, via RemoveAimed.</summary>
bool RemovePackAPunchNear( Vector3 at )
{
var list = ActiveConfig.Current.PackAPunches;
if ( list.Count == 0 ) return false;
int best = -1;
float bestDist = 140f;
for ( int i = 0; i < list.Count; i++ )
{
float d = list[i].Position.Distance( at );
if ( d >= bestDist ) continue;
bestDist = d;
best = i;
}
if ( best < 0 ) return false;
list.RemoveAt( best );
PackAPunchManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] Pack-a-Punch removed ({list.Count} left)" );
return true;
}
/// <summary>Where the author is standing, for facing placed objects at them.</summary>
Vector3 PlacerPosition()
{
var p = NZPlayer.Local;
return p.IsValid() ? p.WorldPosition : WorldPosition;
}
// ── barricades ───────────────────────────────────────────────────────────
/// <summary>
/// Click one, click two, done.
///
/// ⚠️ Reuses `_corners` rather than adding a second list — ResetCorners (R)
/// and the pending-count readout then work on barricades for free, and there
/// is only ever one pending shape because only one tool is active.
/// </summary>
public bool AddBarricadePoint()
{
// ⚠️ AimTrace, the same helper AddCorner uses — it returns null when the
// crosshair is on nothing, which must be reported rather than silently
// dropping a point at the origin.
var hit = AimTrace();
if ( hit is null )
{
Log.Info( "[nz] nothing under the crosshair — aim at the floor or a wall" );
return false;
}
return AddBarricadePointAt( hit.Value.HitPosition );
}
/// <summary>Add a barricade point explicitly, so a command can drive it.</summary>
public bool AddBarricadePointAt( Vector3 at )
{
_corners.Add( at );
if ( _corners.Count < 2 )
{
Log.Info( $"[nz] barricade point 1/2 at {at} — click the other end" );
return true;
}
var a = _corners[0];
var b = _corners[1];
_corners.Clear();
_awaitingHeight = false;
// ⚠️ Rejected rather than clamped. A zero-length barricade is a click
// someone did not mean to make, and building a degenerate one that cannot
// be seen is worse than saying so.
if ( a.Distance( b ) < 8f )
{
Log.Warning( "[nz] those two points are basically the same spot — "
+ "a barricade needs a run. Points cleared; start again." );
return false;
}
var spot = new BarricadeSpot
{
A = a,
B = b,
Height = MathF.Max( 8f, BarricadeHeight ),
Boards = Math.Clamp( BarricadeBoards, 0, Barricade.MaxPlanks ),
};
ActiveConfig.Current.Barricades.Add( spot );
BarricadeManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] barricade built — {spot.Length:0}u long, {spot.Height:0}u tall, "
+ $"{spot.Boards} boards ({ActiveConfig.Current.Barricades.Count} total)" );
return true;
}
/// <summary>Nav link point from the crosshair. Mirrors AddBarricadePoint.</summary>
public bool AddNavLinkPoint()
{
var hit = AimTrace();
if ( hit is null )
{
Log.Info( "[nz] nothing under the crosshair — aim at the ledge, then the floor" );
return false;
}
return AddNavLinkPointAt( hit.Value.HitPosition );
}
/// <summary>
/// Second click finishes a nav link: A is where you stand on the LEDGE, B is
/// where you want them to land.
///
/// ⚠️ Deliberately NOT floor-dropped like a barricade corner. The whole point
/// of a drop link is that its two ends are at different heights, and snapping
/// both to the floor under the crosshair would collapse every link to a
/// horizontal one — which the mesh could already path anyway.
/// </summary>
public bool AddNavLinkPointAt( Vector3 at )
{
// One click, no second point: the landing is traced for. See
// NavLinkManager.BuildDrop.
if ( NavLinkDropMode )
{
_corners.Clear();
return NavLinkManager.BuildDrop( at, NavLinkRadius, walk: NavLinkWalk ) is not null;
}
_corners.Add( at );
if ( _corners.Count < 2 )
{
Log.Info( $"[nz] nav link A at {at} — now click where they come OUT" );
return true;
}
var a = _corners[0];
var b = _corners[1];
_corners.Clear();
_awaitingHeight = false;
if ( a.Distance( b ) < 8f )
{
Log.Warning( "[nz] both ends are the same spot — a link needs to go "
+ "somewhere. Points cleared; start again." );
return false;
}
if ( !NavLinkJump && !NavLinkDrop )
{
Log.Warning( "[nz] both Jump and Drop are off — that link would do nothing. "
+ "Turn at least one on." );
return false;
}
// ⚠️ LOW AND HIGH FROM THE GEOMETRY, not from click order. "Jump" means
// bottom to top and "drop" means top to bottom no matter which end you
// clicked first — clicking the ledge before the floor must not silently
// invert what the toggles mean.
var low = a.z <= b.z ? a : b;
var high = a.z <= b.z ? b : a;
var r = MathF.Max( 8f, NavLinkRadius );
int made = 0;
// ⛔ TWO SEPARATE ONE-WAY LINKS, NEVER ONE BIDIRECTIONAL ONE. The per-agent
// gate forbids by AREA and an area has no direction, so a two-way link
// tagged "jump" is also the way down and closing it closes both. Jump and
// drop have to be independently gateable, which means separate links.
if ( NavLinkJump )
{
ActiveConfig.Current.NavLinks.Add( new NavLinkSpot
{
A = low, B = high, BiDirectional = false, Radius = r, Link = SpawnLink,
Walk = NavLinkWalk,
} );
made++;
}
if ( NavLinkDrop )
{
ActiveConfig.Current.NavLinks.Add( new NavLinkSpot
{
A = high, B = low, BiDirectional = false, Radius = r, Link = SpawnLink,
Walk = NavLinkWalk,
} );
made++;
}
NavLinkManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] nav link: {high.z - low.z:0}u apart — "
+ (NavLinkJump ? "JUMP up" : "") + (NavLinkJump && NavLinkDrop ? " + " : "")
+ (NavLinkDrop ? "DROP down" : "")
+ (NavLinkWalk ? " · WALK (no clip, normal speed)" : "")
+ $" ({made} link(s), {ActiveConfig.Current.NavLinks.Count} total)" );
return true;
}
/// <summary>Remove the nav link nearest a point. Right-click, via RemoveAimed.</summary>
bool RemoveNavLinkNear( Vector3 at )
{
var spot = NavLinkManager.Nearest( at );
if ( spot is null ) { Log.Info( "[nz] no nav link near there" ); return false; }
ActiveConfig.Current.NavLinks.Remove( spot );
NavLinkManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] nav link removed ({ActiveConfig.Current.NavLinks.Count} left)" );
return true;
}
/// <summary>Remove the barricade nearest a point. Right-click, via RemoveAimed.</summary>
bool RemoveBarricadeNear( Vector3 at )
{
var list = ActiveConfig.Current.Barricades;
if ( list.Count == 0 ) return false;
int best = -1;
float bestDist = 120f; // same generous grab radius the other removers use
for ( int i = 0; i < list.Count; i++ )
{
float d = list[i].Centre.Distance( at );
if ( d >= bestDist ) continue;
bestDist = d;
best = i;
}
if ( best < 0 ) return false;
list.RemoveAt( best );
BarricadeManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] barricade removed ({list.Count} left)" );
return true;
}
bool AddDebris( BlockShape s )
{
if ( DoorLinks.IsUnlinked( DebrisLink ) )
Log.Warning( "[nz] blank/0 is always-open — this barrier will gate nothing" );
ActiveConfig.Current.Debris.Add( new Debris
{
Position = s.Position,
Yaw = s.Yaw,
Size = s.Size,
Footprint = s.Footprint,
Material = DebrisMaterial,
Visible = DebrisVisible,
Link = DebrisLink,
Price = DebrisPrice,
RequiresPower = DebrisRequiresPower,
Buyable = DebrisBuyable,
} );
Log.Info( $"[nz] debris #{ActiveConfig.Current.Debris.Count - 1} built "
+ Describe( s )
+ $" link {DebrisLink}, {DebrisCostWord}"
+ (DebrisRequiresPower ? " needs power" : "") );
DebrisManager.Ensure( Scene )?.Rebuild();
return true;
}
/// <summary>
/// Record an invisible wall from a measured block.
///
/// ⚠️ NO LINK, NO PRICE, NO POWER — and no warning about any of them. Those
/// three are the whole difference between this and AddDebris; everything
/// above this point is shared.
/// </summary>
bool AddDamageWall( BlockShape s )
{
ActiveConfig.Current.DamageWalls.Add( new DamageWall
{
Position = s.Position,
Yaw = s.Yaw,
Size = s.Size,
Footprint = s.Footprint,
Damage = DamageWallDamage,
Interval = DamageWallInterval,
Visible = DamageWallVisible,
VisibleInGame = DamageWallShown,
Material = DamageWallMaterial,
} );
// ⚠️ PRINTS THE RATE AS WELL AS THE PAIR. "20 every 1s" and "200 every 10s" are the
// same 20/s and completely different walls to walk through, so the derived number
// alone would mislead — but without it nobody compares two walls at a glance.
var dps = DamageWallInterval > 0f ? DamageWallDamage / DamageWallInterval : 0f;
Log.Info( $"[nz] damage wall #{ActiveConfig.Current.DamageWalls.Count - 1} built "
+ Describe( s )
+ $" {DamageWallDamage:0.#} dmg every {DamageWallInterval:0.##}s (~{dps:0.#}/s)"
+ " NO COLLISION · "
// ⚠️ SAYS WHICH OF THE TWO IT IS. "drawn in creative only" was true of every damage
// wall ever built and is now true of only some, so a line that still claimed it would
// be the one place a mapper checks to find out what they just made.
+ ( DamageWallShown
? $"VISIBLE IN GAME, {DamageWallMaterial}"
: "drawn in creative only" ) );
DamageWallManager.Ensure( Scene )?.Rebuild();
return true;
}
bool RemoveDamageWallNear( Vector3 at )
{
var list = ActiveConfig.Current.DamageWalls;
if ( list.Count == 0 ) return false;
int best = -1;
float bestDist = float.MaxValue;
for ( int i = 0; i < list.Count; i++ )
{
// ⚠️ MEASURED AGAINST THE BOX, not a fixed radius. A damage wall can be a long
// corridor; a radius that worked for a small one would make a big one
// unselectable except from its centre.
var d = at.Distance( list[i].Position );
var reach = MathF.Max( 64f, list[i].Size.Length * 0.5f );
if ( d > reach || d >= bestDist ) continue;
bestDist = d;
best = i;
}
if ( best < 0 ) { Log.Info( "[nz] no damage wall near there" ); return false; }
list.RemoveAt( best );
DamageWallManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] damage wall removed ({list.Count} left)" );
return true;
}
bool AddInvisibleWall( BlockShape s )
{
ActiveConfig.Current.InvisibleWalls.Add( new InvisibleWall
{
Position = s.Position,
Yaw = s.Yaw,
Size = s.Size,
Footprint = s.Footprint,
Visible = WallVisible,
Material = WallMaterial,
BlocksZombies = WallBlocksZombies,
} );
Log.Info( $"[nz] invisible wall #{ActiveConfig.Current.InvisibleWalls.Count - 1} built "
+ Describe( s )
+ (WallVisible ? " VISIBLE" : " invisible")
// ⛔ THE OLD TEXT SAID "does not block nav" UNCONDITIONALLY, which is now a lie
// half the time — and it is the one line that tells the builder what they just
// made. A stale confirmation is worse than none: it is read as verification.
+ (WallBlocksZombies
? " solid · BLOCKS ZOMBIES (baked into the navmesh)"
: " solid · player-only, horde walks through") );
InvisibleWallManager.Ensure( Scene )?.Rebuild();
return true;
}
/// <summary>Shared one-line description of a built block, so debris and walls
/// report themselves in the same words.</summary>
static string Describe( BlockShape s )
=> (s.Footprint.Count >= 3
? $"{s.Footprint.Count}-sided footprint, {s.Size.z:0} tall"
: $"{s.Size.x:0}x{s.Size.y:0}x{s.Size.z:0}")
+ $" at {s.Position} yaw {s.Yaw:0}";
/// <summary>Remove the barrier nearest the crosshair. Wider radius than a
/// spawn marker because a barrier's origin can sit well inside a big
/// model — you aim at the wall, not at its pivot.</summary>
bool RemoveDebrisNear( Vector3 at )
{
var list = ActiveConfig.Current.Debris;
var found = list
.Select( ( d, i ) => (d, i, dist: d.Position.Distance( at )) )
.Where( x => x.dist <= RemoveRadius * 2f )
.OrderBy( x => x.dist )
.FirstOrDefault();
if ( found.d is null )
{
Log.Info( "[nz] no debris under the crosshair" );
return false;
}
list.RemoveAt( found.i );
DebrisManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] debris removed ({list.Count} left)" );
return true;
}
/// <summary>
/// Remove the invisible wall under the crosshair: the one the ray hit, or else the one
/// whose centre marker is nearest the aim point.
///
/// ⛔ THE WALL THE RAY HIT COMES FIRST. This used to test only the distance from the aim
/// point to each wall's centre, which works on a small wall and never on a big one: the
/// aim trace is stopped by a wall's collider, so the point lands on the wall's FACE, and
/// on a 550-wide block that face is 275 units from a centre the test wanted within 96.
/// The wall took every click — corners landed on its faces — while RMB answered "no
/// invisible wall under the crosshair" dozens of times. (city_uprising_d, 2026-09-26.)
///
/// ⚠️ THE MARKER STAYS AS THE FALLBACK, for what the old test was for: a wall you cannot
/// see, removed by aiming at the marker drawn at its centre. Same wide radius as debris,
/// and for the same reason: you aim at the wall, not at its pivot.
///
/// ⚠️ A WALL NEITHER CAN REACH — one you are standing inside — goes by number:
/// nz_wall_list, then nz_wall_remove <number>.
/// </summary>
bool RemoveWallNear( Vector3 at, GameObject aimed = null )
{
var list = ActiveConfig.Current.InvisibleWalls;
var m = InvisibleWallManager.Instance;
var index = m.IsValid() ? m.IndexOfObject( aimed ) : -1;
if ( index < 0 || index >= list.Count )
{
var found = list
.Select( ( w, i ) => (w, i, dist: w.Position.Distance( at )) )
.Where( x => x.dist <= RemoveRadius * 2f )
.OrderBy( x => x.dist )
.FirstOrDefault();
if ( found.w is null )
{
Log.Info( "[nz] no invisible wall under the crosshair"
+ (list.Count > 0 ? " (nz_wall_list numbers them, nz_wall_remove <number> removes one)" : "") );
return false;
}
index = found.i;
}
var gone = list[index];
list.RemoveAt( index );
InvisibleWallManager.Ensure( Scene )?.Rebuild();
Log.Info( $"[nz] invisible wall #{index} removed: {WallCommands.Shape( gone )} ({list.Count} left)" );
return true;
}
/// <summary>The config list a tool edits. One lookup so placing, removing
/// and drawing can never disagree about which list a tool owns.</summary>
static System.Collections.Generic.List<SpawnPoint> ListFor( string tool ) => tool switch
{
NZTools.PlayerSpawn => ActiveConfig.Current.PlayerSpawns,
NZTools.ZombieSpawn => ActiveConfig.Current.ZombieSpawns,
NZTools.SpecialSpawn => ActiveConfig.Current.SpecialSpawns,
NZTools.BossSpawn => ActiveConfig.Current.BossSpawns,
_ => null,
};
/// <summary>
/// Remove the power switch nearest the crosshair.
///
/// ⚠️ Removing the LAST switch turns the map's power back ON, because
/// Power.IsOn is derived from whether one exists. Said out loud, or deleting
/// a prop silently unlocks every door that was gated behind it.
/// </summary>
/// <summary>
/// Delete the wallbuy nearest the aim point.
///
/// ⚠️ NEAREST WITHIN A RADIUS, not "whatever the ray struck". A wallbuy's
/// display model is small and often flush to a wall, so an exact-hit test
/// makes RMB feel broken whenever you clip the wall behind it instead — the
/// same reason the debris and switch removers work this way.
/// </summary>
bool RemoveWallBuyNear( Vector3 at )
{
var mgr = WallBuyManager.Ensure();
if ( mgr is null ) return false;
var found = mgr.All
.Select( b => (b, d: b.WorldPosition.Distance( at )) )
.Where( x => x.d <= RemoveRadius )
.OrderBy( x => x.d )
.ToList();
if ( found.Count == 0 ) return false;
var hit = found[0].b;
// ⛔ THE CONFIG ENTRY TOO, NOT JUST THE OBJECT. Destroying the scene object
// alone made the wallbuy come back on the next rebuild, which is worse
// than not deleting it — it looks removed until something reloads.
//
// ⚠️ Matched by POSITION rather than by identity: the config holds data,
// not components, so there is no reference between the two.
var cfg = ActiveConfig.Current.WallBuys;
var idx = cfg.FindIndex( w => w.Position.Distance( hit.WorldPosition ) < 4f );
if ( idx >= 0 ) cfg.RemoveAt( idx );
Log.Info( $"[nz] weapon buy removed: {hit.WeaponName} @ {hit.Price}"
+ $" ({cfg.Count} left)" );
hit.GameObject.Destroy();
return true;
}
bool RemoveSwitchNear( Vector3 at )
{
var list = ActiveConfig.Current.PowerSwitches;
var found = list
.Select( ( s, i ) => (s, i, d: s.Position.Distance( at )) )
.Where( x => x.d <= RemoveRadius * 2f )
.OrderBy( x => x.d )
.FirstOrDefault();
if ( found.s is null )
{
Log.Info( "[nz] no power switch under the crosshair" );
return false;
}
var wasOn = Power.IsOn;
list.RemoveAt( found.i );
PowerManager.Instance?.Rebuild();
Log.Info( $"[nz] power switch #{found.i} removed ({list.Count} left)" );
if ( !wasOn && Power.IsOn )
Log.Info( "[nz] ⚠ that was the LAST switch — power is now permanently ON" );
return true;
}
// ── markers ──────────────────────────────────────────────────────────────
/// <summary>
/// How long one drawing of the placed markers lasts, in seconds — and so how often they are all drawn again: ten
/// times a second, not every frame.
///
/// ⛔ EVERY OVERLAY DRAW IS A NEW NATIVE OBJECT — a `SceneDynamicObject` for a line or a box, a sphere, a text, a model —
/// and each takes a handle from the engine's `HandleIndex`, a counter that only goes up and never gives one back.
/// Drawn every frame, basalt's markers took ~2,000 a frame, and on 2026-09-26, after six and a half hours in the editor,
/// the counter passed int.MaxValue: from then every new collider, light and model failed to create ("Interop Object
/// should not be null", "Unable to create mesh shape") and every freed one logged "FreeHandle is -2147…", thousands a
/// frame, at 1 fps. Only a restart recovers. `nz_handles` (the editor's `HandleCensus`) counts them.
///
/// ⚠️ SEAMLESS BECAUSE IT KEEPS THE OVERLAY'S OWN CLOCK. A timed draw starts with exactly this life, and at the start of
/// each update the overlay takes `Time.Delta` off it and removes it once below zero (`DebugOverlaySystem.RemoveExpired`).
/// `_markerLife` makes the same subtraction every frame, so the frame the old drawing goes is the frame the new one is
/// made: no gap, and no second copy doubling the see-through spheres and boxes.
/// </summary>
const float MarkerLife = 0.1f;
/// <summary>The life left on the markers drawn last, counted down with the overlay's (<see cref="MarkerLife"/>). Below zero: draw now.</summary>
float _markerLife = -1f;
/// <summary>
/// Draw every placed marker.
///
/// Debug overlay rather than models: no assets needed, and each placeable
/// type gets its own colour AND shape, which is what makes a map full of
/// mixed markers readable. Creative only — these never render in a round.
/// </summary>
void DrawMarkers()
{
var d = Scene.DebugOverlay;
if ( d is null ) return;
// ⚠️ EVERY DRAW BELOW LASTS `MarkerLife`, NOT ONE FRAME: they are drawn again only when it runs out.
// Same person-sized box for both, distinguished by COLOUR — blue for
// player, green for zombie. Both mark where a body will stand, so a
// shared silhouette reads as "someone spawns here" and the colour says
// who. It also shows the footprint honestly: a sphere did not.
// ⛔ EACH PASS IS ISOLATED. These used to be eight bare calls in a row, so
// an exception in ANY of them silently took every pass BELOW it with it —
// and which markers vanished depended only on which pass threw first.
// That is the whole of "sometimes the orange debris nodes are not there,
// sometimes it is the wall tool": one fault, presenting differently
// depending on the order.
//
// The comment on DrawPendingCorners already records this exact failure
// being found once. Fixing it for one caller and leaving the other seven
// in a shared fate-line is why it came back.
//
// ⚠️ AND IT REPORTS. A silent catch would turn "everything vanishes" into
// "one thing vanishes", which is better but still blind — the log names
// the pass and the exception, so the real cause is one line away instead
// of another three-guess hunt.
Safe( "spawns/player", () => DrawSpawns( d, ActiveConfig.Current.PlayerSpawns, NZTools.PlayerSpawn ) );
Safe( "spawns/zombie", () => DrawSpawns( d, ActiveConfig.Current.ZombieSpawns, NZTools.ZombieSpawn ) );
Safe( "spawns/special", () => DrawSpawns( d, ActiveConfig.Current.SpecialSpawns, NZTools.SpecialSpawn ) );
Safe( "spawns/boss", () => DrawSpawns( d, ActiveConfig.Current.BossSpawns, NZTools.BossSpawn ) );
Safe( "spawn links", () => DrawSpawnLinks( d ) );
Safe( "debris", () => DrawDebris( d ) );
Safe( "invisible walls", () => DrawInvisibleWalls( d ) );
Safe( "switches", () => DrawSwitches( d ) );
Safe( "nav links", () => DrawNavLinks( d ) );
Safe( "teleporters", () => DrawTeleporters( d ) );
Safe( "springboards", () => DrawSpringboards( d ) );
Safe( "soul boxes", () => DrawSoulBoxes( d ) );
// ⛔ THE THREE MAP OBJECTS HAD NO MARKER AT ALL, AND THAT IS WHY THEY COULD NOT BE FOUND.
// Reported as "sometimes i cannot see the nodes i place" — and "sometimes" was the shape of
// it: every OTHER placeable draws, so the gap looked intermittent rather than total. A light
// is findable only because it emits light, and a dim one is not; a sound spot and a fog area
// emit nothing a camera can see, so they were invisible in the one mode built for placing
// them. Nothing was broken about the placement — there was simply nothing to look at.
Safe( "lights", () => DrawMapLights( d ) );
Safe( "sound spots", () => DrawSoundSpots( d ) );
Safe( "fog areas", () => DrawFogAreas( d ) );
Safe( "room zones", () => DrawRoomZones( d ) );
}
/// <summary>
/// Placed lights — a small ball at the source, a wire sphere at the reach.
///
/// ⚠️ THE REACH IS THE POINT, NOT THE POSITION. A light's radius is the number that decides
/// whether it does anything where you wanted it to, and it is the one thing you cannot judge by
/// looking at the lit room — an over-large radius looks the same as a correct one until it
/// washes a neighbouring space.
/// </summary>
void DrawMapLights( DebugOverlaySystem d )
{
var list = ActiveConfig.Current.Lights;
for ( int i = 0; i < list.Count; i++ )
{
var l = list[i];
// Its own colour, so a swatch mistake is visible on the marker rather than only in the
// lighting — a light authored black is otherwise indistinguishable from one not built.
var col = Color.Parse( l.Color ) ?? Color.White;
d.Sphere( new Sphere( l.Position, 10f ), col, MarkerLife, global::Transform.Zero, false );
d.Sphere( new Sphere( l.Position, l.Radius ), col.WithAlpha( 0.06f ), MarkerLife,
global::Transform.Zero, false );
d.Text( l.Position + Vector3.Up * 20f,
$"#{i} light · {l.Color} x{l.Brightness:0.##} · {l.Radius:0}u"
+ ( l.Shadows ? " · ⚠ SHADOWS" : "" )
+ ( l.RequiresPower ? " · power" : "" ), duration: MarkerLife );
}
}
/// <summary>
/// Placed looping sounds — a ball at the emitter, a wire sphere at the falloff.
///
/// ⚠️ AND THE CAP MAKES THE SPHERE MATTER MORE THAN IT LOOKS. Only the nearest few spots are
/// ever audible, so two placed close together are mostly one spot with a spare — which is
/// obvious drawn and invisible otherwise.
/// </summary>
void DrawSoundSpots( DebugOverlaySystem d )
{
var col = NZTools.ColorFor( NZTools.SoundSpot );
var list = ActiveConfig.Current.Sounds;
for ( int i = 0; i < list.Count; i++ )
{
var sp = list[i];
d.Sphere( new Sphere( sp.Position, 10f ), col, MarkerLife, global::Transform.Zero, false );
d.Sphere( new Sphere( sp.Position, sp.Distance ), col.WithAlpha( 0.05f ), MarkerLife,
global::Transform.Zero, false );
var name = string.IsNullOrWhiteSpace( sp.Sound )
? "⚠ NO SOUND SET"
: sp.Sound.Split( '/' )[^1].Replace( ".sound", "" );
d.Text( sp.Position + Vector3.Up * 20f,
$"#{i} sound · {name}\nvol {sp.Volume:0.##} · {sp.Distance:0}u", duration: MarkerLife );
}
}
/// <summary>
/// Drawn fog areas — the outline of the prism, and how deep the fade runs.
///
/// ⛔ THIS IS THE ONLY WAY TO SEE ONE FROM OUTSIDE. The fog itself is a global GradientFog
/// blended by where you stand, so an area is genuinely invisible until you walk into it — the
/// marker is not a convenience here, it is the whole of the authoring feedback.
/// </summary>
void DrawFogAreas( DebugOverlaySystem d )
{
var list = ActiveConfig.Current.Fog;
for ( int i = 0; i < list.Count; i++ )
{
var f = list[i];
var col = Color.Parse( f.Color ) ?? Color.White;
// ⚠️ LIFTED OFF THE AUTHORED COLOUR. Soot is a near-black, and a near-black outline on a
// dark map is not a marker. The tint still says which area is which; the marker has to be
// legible first.
var wire = Color.Lerp( col, Color.White, 0.65f );
var half = f.Size.z * 0.5f;
if ( f.HasFootprint )
{
var fp = f.Footprint;
for ( int e = 0; e < fp.Count; e++ )
{
var a = fp[e];
var b = fp[( e + 1 ) % fp.Count];
var a0 = f.Position + f.Rotation * new Vector3( a.x, a.y, -half );
var a1 = f.Position + f.Rotation * new Vector3( a.x, a.y, half );
var b0 = f.Position + f.Rotation * new Vector3( b.x, b.y, -half );
var b1 = f.Position + f.Rotation * new Vector3( b.x, b.y, half );
d.Line( a0, b0, wire, MarkerLife, global::Transform.Zero, false );
d.Line( a1, b1, wire, MarkerLife, global::Transform.Zero, false );
d.Line( a0, a1, wire.WithAlpha( 0.5f ), MarkerLife, global::Transform.Zero, false );
}
}
else
{
d.Box( new BBox( -f.Size * 0.5f, f.Size * 0.5f ), wire, MarkerLife,
new global::Transform( f.Position, f.Rotation ), false );
}
d.Text( f.Position + Vector3.Up * ( half + 14f ),
$"#{i} fog · {f.Color} · density {f.Density:0.##}"
+ $"\n{f.Size.z:0} tall · feather {f.Feather:0}u"
+ ( f.Density <= 0.001f ? " · ⚠ DENSITY 0" : "" ),
size: 13f, flags: TextFlag.Center, duration: MarkerLife );
}
}
/// <summary>
/// Room zones — the drawn outline in the tool's pink, and the name over it: a zone is a name for a space, so the name is the
/// marker. ⚠️ ONE WITH NO NAME SAYS SO — it names nothing until it has one.
/// </summary>
void DrawRoomZones( DebugOverlaySystem d )
{
var list = ActiveConfig.Current.RoomZones;
if ( list is null ) return;
var wire = NZTools.ColorFor( NZTools.RoomZone );
for ( int i = 0; i < list.Count; i++ )
{
var z = list[i];
if ( z is null ) continue;
var half = z.Size.z * 0.5f;
if ( z.HasFootprint )
{
var fp = z.Footprint;
for ( int e = 0; e < fp.Count; e++ )
{
var a = fp[e];
var b = fp[( e + 1 ) % fp.Count];
var a0 = z.Position + z.Rotation * new Vector3( a.x, a.y, -half );
var a1 = z.Position + z.Rotation * new Vector3( a.x, a.y, half );
var b0 = z.Position + z.Rotation * new Vector3( b.x, b.y, -half );
var b1 = z.Position + z.Rotation * new Vector3( b.x, b.y, half );
d.Line( a0, b0, wire, MarkerLife, global::Transform.Zero, false );
d.Line( a1, b1, wire, MarkerLife, global::Transform.Zero, false );
d.Line( a0, a1, wire.WithAlpha( 0.5f ), MarkerLife, global::Transform.Zero, false );
}
}
else
{
d.Box( new BBox( -z.Size * 0.5f, z.Size * 0.5f ), wire, MarkerLife,
new global::Transform( z.Position, z.Rotation ), false );
}
var name = z.Name?.Trim() ?? "";
d.Text( z.Position + Vector3.Up * ( half + 14f ),
$"#{i} room zone · {(name.Length > 0 ? name : "⚠ NO NAME")}",
size: 15f, flags: TextFlag.Center, duration: MarkerLife );
}
}
/// <summary>`nz_markers_why` — why the editor markers are not on screen.
///
/// ⛔ EVERY GATE IN ONE LINE. Invisible markers have at least five unrelated
/// causes — not creative, preview mode on, no editor component, no debug
/// overlay, or a draw pass throwing — and from inside the game they all look
/// identical: an empty room. Guessing between them has cost this project a
/// session already. This asks the build what is actually true.</summary>
[ConCmd( "nz_markers_why" )]
public static void WhyCmd()
{
var scene = Game.ActiveScene;
var ed = scene?.GetAllComponents<MapEditor>().FirstOrDefault();
Log.Info( "[nz-editor] why markers may be hidden:" );
Log.Info( $"[nz-editor] creative {NZGame.IsCreative} {(NZGame.IsCreative ? "" : "<-- markers only draw in creative (nz_creative)")}" );
Log.Info( $"[nz-editor] preview mode {NZGame.PreviewMode} {(NZGame.PreviewMode ? "<-- HIDING THEM (nz_preview 0)" : "")}" );
Log.Info( $"[nz-editor] -> visuals on {NZGame.ShowAuthoringVisuals}" );
Log.Info( $"[nz-editor] MapEditor {(ed.IsValid() ? $"present, enabled={ed.Enabled}" : "MISSING <-- nothing draws at all")}" );
Log.Info( $"[nz-editor] DebugOverlay {(scene?.DebugOverlay is not null ? "ok" : "NULL <-- nothing draws at all")}" );
if ( _drawFaults.Count == 0 )
{
Log.Info( "[nz-editor] draw faults none since load" );
return;
}
foreach ( var (pass, since) in _drawFaults )
Log.Info( $"[nz-editor] draw fault '{pass}' threw {(float)since:0.#}s ago <-- THAT pass has no markers" );
}
/// <summary>Run one marker pass so a fault in it cannot blank the others.
///
/// ⚠️ THROTTLED PER PASS, not per frame. This runs every frame — an unguarded
/// Log.Warning here would push thousands of identical lines a second and bury
/// the very message it exists to surface.</summary>
static readonly Dictionary<string, RealTimeSince> _drawFaults = new();
static void Safe( string pass, Action draw )
{
try
{
draw();
}
catch ( Exception e )
{
if ( _drawFaults.TryGetValue( pass, out var since ) && since < 5f ) return;
_drawFaults[pass] = 0f;
Log.Warning( $"[nz-editor] marker pass '{pass}' threw — its markers are missing, the rest still draw: {e.Message}" );
}
}
/// <summary>
/// Cyan arrows for the nav links.
///
/// ⚠️ DRAWN AS AN ARROW, not a line. Direction is the single most important
/// thing about a link and the one a line cannot show — a one-way drop and a
/// one-way climb look identical otherwise, and getting them backwards means
/// zombies walking up a sheer wall.
/// </summary>
void DrawNavLinks( DebugOverlaySystem d )
{
var col = NZTools.ColorFor( NZTools.NavLink );
foreach ( var l in ActiveConfig.Current.NavLinks )
{
d.Sphere( new Sphere( l.A, 8f ), col, MarkerLife, global::Transform.Zero, false );
d.Sphere( new Sphere( l.B, 8f ), col, MarkerLife, global::Transform.Zero, false );
d.Line( l.A, l.B, col, MarkerLife, global::Transform.Zero, false );
// The head: two short strokes back from B, so the arrow reads from
// any angle rather than only side-on.
var dir = (l.B - l.A).Normal;
var back = l.B - dir * 20f;
var side = dir.Cross( Vector3.Up ).Normal * 8f;
if ( side.Length < 0.1f ) side = Vector3.Forward * 8f;
d.Line( l.B, back + side, col, MarkerLife, global::Transform.Zero, false );
d.Line( l.B, back - side, col, MarkerLife, global::Transform.Zero, false );
if ( l.BiDirectional )
{
var fwd = l.A + dir * 20f;
d.Line( l.A, fwd + side, col, MarkerLife, global::Transform.Zero, false );
d.Line( l.A, fwd - side, col, MarkerLife, global::Transform.Zero, false );
}
d.Text( (l.A + l.B) * 0.5f + Vector3.Up * 10f,
(l.BiDirectional ? "two-way" : "one-way")
+ (l.Fall > 8f ? $" · drop {l.Fall:0}u" : ""),
size: 13f, flags: TextFlag.Center, duration: MarkerLife );
}
}
/// <summary>
/// Mark each power switch, and say what the map's power is doing.
///
/// The block itself is already visible in the world, so this adds the thing
/// the block cannot show: whether the power is on, and that removing the
/// last switch would turn it on permanently.
/// </summary>
/// <summary>
/// Mark each teleporter's DESTINATION and the trip it makes.
///
/// ⚠️ The pad is already a solid box in the world, so this draws the half the pad cannot show:
/// where it goes. Without the arrow a destination is an invisible point somewhere else on the
/// map, and the only way to find out which pad owns it is to stand on one.
/// </summary>
void DrawTeleporters( DebugOverlaySystem d )
{
var col = NZTools.ColorFor( NZTools.Teleporter );
var list = ActiveConfig.Current.Teleporters;
for ( int i = 0; i < list.Count; i++ )
{
var t = list[i];
d.Sphere( new Sphere( t.B, 12f ), col, MarkerLife, global::Transform.Zero, false );
d.Line( t.A, t.B, col, MarkerLife, global::Transform.Zero, false );
// The head: two short strokes back from B, so the arrow reads from any angle rather
// than only side-on. Same shape as DrawNavLinks, deliberately.
var dir = (t.B - t.A).Normal;
var back = t.B - dir * 20f;
var side = dir.Cross( Vector3.Up ).Normal * 8f;
if ( side.Length < 0.1f ) side = Vector3.Forward * 8f;
d.Line( t.B, back + side, col, MarkerLife, global::Transform.Zero, false );
d.Line( t.B, back - side, col, MarkerLife, global::Transform.Zero, false );
d.Text( t.A + Vector3.Up * 24f,
$"#{i} teleporter"
+ (t.Price > 0 ? $" · {t.Price:N0}" : " · free")
+ (t.Cooldown > 0f ? $" · {t.Cooldown:0}s" : "")
+ (t.RequiresPower ? " · power" : "")
+ $" · flag {DoorLinks.Display( t.Link )}",
size: 13f, flags: TextFlag.Center, duration: MarkerLife );
d.Text( t.B + Vector3.Up * 24f, $"#{i} exit", size: 13f, flags: TextFlag.Center, duration: MarkerLife );
}
}
/// <summary>
/// Springboards — the circle that throws, and an arrow up to the height it throws to.
///
/// ⛔ THIS IS THE ONLY WAY TO SEE ONE. A springboard draws nothing in a round, by request, so without the marker a placed
/// pad could be found only by walking onto it.
/// </summary>
void DrawSpringboards( DebugOverlaySystem d )
{
var list = ActiveConfig.Current.Springboards;
if ( list is null ) return;
var col = NZTools.ColorFor( NZTools.Springboard );
const int segments = 24;
for ( int i = 0; i < list.Count; i++ )
{
var s = list[i];
var c = s.Position + Vector3.Up * 2f; // a hair above the floor, so the ring is not lost in it
for ( int k = 0; k < segments; k++ )
{
var a0 = MathF.PI * 2f * k / segments;
var a1 = MathF.PI * 2f * (k + 1) / segments;
d.Line( c + new Vector3( MathF.Cos( a0 ), MathF.Sin( a0 ), 0f ) * s.Radius,
c + new Vector3( MathF.Cos( a1 ), MathF.Sin( a1 ), 0f ) * s.Radius,
col, MarkerLife, global::Transform.Zero, false );
}
// The throw: a line up to the height it reaches, with a head on it that reads from any side.
var up = SpringboardSystem.ApexHeight( s.Strength, Scene );
var top = c + Vector3.Up * up;
d.Line( c, top, col, MarkerLife, global::Transform.Zero, false );
d.Line( top, top + new Vector3( 10f, 0f, -14f ), col, MarkerLife, global::Transform.Zero, false );
d.Line( top, top + new Vector3( -10f, 0f, -14f ), col, MarkerLife, global::Transform.Zero, false );
d.Line( top, top + new Vector3( 0f, 10f, -14f ), col, MarkerLife, global::Transform.Zero, false );
d.Line( top, top + new Vector3( 0f, -10f, -14f ), col, MarkerLife, global::Transform.Zero, false );
d.Text( c + Vector3.Up * 24f,
$"#{i} springboard · {s.Strength:0} u/s, about {up:0} up · radius {s.Radius:0}"
+ (s.SafeLanding ? "" : " · ⚠ fall damage"),
size: 13f, flags: TextFlag.Center, duration: MarkerLife );
}
}
/// <summary>
/// Mark each soul box, its RANGE, and how far its flag has got.
///
/// ⛔ THE RANGE CIRCLE IS THE WHOLE POINT OF THIS OVERLAY. 500 units is over 40 feet and the box
/// itself is 48 — so the area that actually collects souls is invisible, ten times the size of
/// the thing you placed, and the only way to find out whether two boxes overlap is to draw it.
/// </summary>
void DrawSoulBoxes( DebugOverlaySystem d )
{
var col = NZTools.ColorFor( NZTools.SoulBox );
var list = ActiveConfig.Current.SoulBoxes;
for ( int i = 0; i < list.Count; i++ )
{
var b = list[i];
// ⚠️ A SPHERE, NOT A FLAT RING. The kill test is a plain distance so it reaches upward
// and downward too, and a floor circle would claim a box ignores the walkway above it.
d.Sphere( new Sphere( b.Position, b.Range ), col.WithAlpha( 0.25f ), MarkerLife,
global::Transform.Zero, false );
var live = SoulBoxManager.All().FirstOrDefault( x => x.Index == i );
var fill = live is null ? "" : $" · {live.Current}/{live.Target}";
var group = list.Count( x => DoorLinks.Same( x.Link, b.Link ) );
d.Text( b.Position + Vector3.Up * (b.Height + 16f),
$"#{i} soul box · {b.Target} kills / {b.Range:0}u{fill}"
+ (b.RequiresPower ? " · power" : "")
+ (b.Powerup ? " · powerup" : "")
+ $"\nflag {DoorLinks.Display( b.Link )}"
+ (DoorLinks.IsUnlinked( b.Link )
? " (gates nothing)"
: $" · ALL {group} must fill"),
size: 13f, flags: TextFlag.Center, duration: MarkerLife );
}
}
void DrawSwitches( DebugOverlaySystem d )
{
var list = ActiveConfig.Current.PowerSwitches;
var col = NZTools.ColorFor( NZTools.PowerSwitch );
// ⚠️ THE SIZE THE SWITCHES ACTUALLY ARE, from the manager, falling back to
// the config's placeholder when nothing is standing. Drawing the config's
// 24x24x48 over a prop that is nothing like it would misreport what you
// placed — the one thing an authoring marker must not do.
var live = PowerManager.Instance?.SwitchSize ?? default;
for ( int i = 0; i < list.Count; i++ )
{
var s = list[i];
var size = live.IsNearZeroLength ? s.Size : live;
// ⚠️ ROTATED WITH THE SWITCH, not axis-aligned. Now that a switch can
// be mounted flat on any surface, an upright world-space box would
// report a placement the prop does not have — and the marker is how
// you check the alignment landed, so it has to be the alignment.
d.Box( new BBox( -size * 0.5f, size * 0.5f ), col, MarkerLife,
new global::Transform( s.Position, s.Rotation ), false );
d.Text( s.Position + Vector3.Up * (size.z * 0.5f + 12f),
$"#{i} power {(Power.IsOn ? "ON" : "OFF")}",
color: col, size: 13f, flags: TextFlag.Center, duration: MarkerLife );
}
}
/// <summary>
/// Label each zombie spawn with its link and whether it is live right now.
///
/// Without this a map of identical green boxes says nothing about which door
/// owns which spawn — and getting that wrong is invisible until a wave
/// stalls or the horde comes from a room you have not opened.
///
/// Greyed when gated, so the set that is actually feeding the current wave
/// reads at a glance.
/// </summary>
void DrawSpawnLinks( DebugOverlaySystem d )
{
var list = ActiveConfig.Current.ZombieSpawns;
if ( list.Count == 0 ) return;
int round = RoundManager.Instance?.Round ?? 1;
var live = NZTools.ColorFor( NZTools.ZombieSpawn );
var gated = new Color( 0.55f, 0.55f, 0.55f, 1f );
for ( int i = 0; i < list.Count; i++ )
{
var s = list[i];
var why = s.Blocker( round, Power.IsOn );
var ok = string.IsNullOrEmpty( why );
var label = DoorLinks.IsUnlinked( s.Link )
? $"#{i} open"
: $"#{i} flag {s.Link}";
if ( !ok ) label += $"\n{why}";
d.Text( s.Position + Vector3.Up * 84f, label,
color: ok ? live : gated, size: 13f, flags: TextFlag.Center, duration: MarkerLife );
}
}
/// <summary>
/// Barrier markers — amber, and WIDER THAN TALL, unlike the person-sized
/// spawn boxes. A barrier fills a gap rather than marking where someone
/// stands, so a different silhouette says which kind of thing it is before
/// the colour does.
///
/// Already-bought barriers are skipped: they are gone from the world, and
/// leaving a marker floating where one used to be reads as a bug.
/// </summary>
void DrawDebris( DebugOverlaySystem d )
{
var col = NZTools.ColorFor( NZTools.Debris );
var fill = col.WithAlpha( 0.22f );
var cube = Model.Cube.Bounds.Size;
var list = ActiveConfig.Current.Debris;
var mgr = DebrisManager.Instance;
for ( int i = 0; i < list.Count; i++ )
{
var item = list[i];
// ⛔ MARKED IF IT IS STANDING, NOT IF ITS LINK IS SHUT. This used to
// skip anything `DoorLinks.IsOpen`, which is runtime game state — so
// after a round in which you bought a door, coming back to creative
// left the barrier unmarked. It is worse than it sounds now that
// creative builds every barrier regardless: the wall is in front of
// you and has no price, no flag and no outline.
//
// A bought barrier is destroyed, so "is it standing" still hides the
// marker of one you buy mid-session — which is what the link check was
// really for.
if ( mgr is not null )
{
if ( !mgr.IsStanding( i ) ) continue;
}
else if ( DoorLinks.IsOpen( item.Link ) ) continue;
// A block outlines its ACTUAL dimensions; a prop gets a nominal box.
// Drawing a fixed size over a built block would misreport what you
// made, which is the one thing an authoring marker must not do.
var size = item.IsBlock ? item.Size : new Vector3( 52f, 52f, 56f );
var centre = item.IsBlock
? item.Position
: item.Position + Vector3.Up * (size.z * 0.5f);
// ⚠️ A SHAPED BARRIER GETS ITS OWN OUTLINE, not its bounding box. The
// marker exists to report what you built — and for an L-shaped barrier
// the enclosing box is exactly the wrong answer, since covering the
// dent is the thing the author is checking for.
if ( item.HasFootprint )
{
// ⛔ FILLED, not just wireframed. The amber faces are what make a
// marker read as a SOLID BARRIER rather than a floating outline —
// I shipped shaped barriers as bare lines and they came straight
// back as "it should have the collision and nav block on", because
// a wireframe is exactly what an inert marker looks like.
//
// The fill is the real built model, borrowed from the manager, so
// the faces you see are the faces the player walks into.
var shape = mgr?.ShapeOf( i );
if ( shape is not null )
{
// Same transform the renderer uses: the prism runs from z=0 up,
// the barrier's origin is its middle.
var at = item.Position + item.Rotation * new Vector3( 0f, 0f, -size.z * 0.5f );
d.Model( shape, fill, MarkerLife, new global::Transform( at, item.Rotation ),
false, false );
}
DrawFootprint( d, item, size.z, col );
}
else
{
var scale = new Vector3( size.x / cube.x, size.y / cube.y, size.z / cube.z );
var xf = new global::Transform( centre, item.Rotation ).WithScale( scale );
d.Model( Model.Cube, fill, MarkerLife, xf, false, false );
// Rotated outline, so a block placed at an angle reads as angled.
d.Box( new BBox( -size * 0.5f, size * 0.5f ), col, MarkerLife,
new global::Transform( centre, item.Rotation ), false );
}
// The price and flag ARE the barrier's identity while building —
// two amber boxes are indistinguishable without them.
//
// The power state belongs here too: "0 points" reads as a free door
// until you know it needs power, at which point it is a shutter that
// opens itself. Those are different objects and they looked alike.
var label = $"flag {DoorLinks.Display( item.Link )}\n{item.Price}";
if ( item.RequiresPower )
label += item.Price == 0 ? " ⚡ opens on power" : " ⚡ needs power";
d.Text( centre + Vector3.Up * (size.z * 0.5f + 12f),
label, size: 14f, flags: TextFlag.Center, duration: MarkerLife );
}
}
/// <summary>
/// Violet outlines for the invisible walls.
///
/// ⛔ THE ONLY WAY TO SEE ONE. A debris marker decorates a wall you can
/// already see; this marker IS the wall as far as the author is concerned —
/// without it an invisible wall cannot be found, judged or removed, and a
/// map ends up with duplicates stacked in the same doorway because there was
/// no way to tell one had already been placed.
///
/// ⚠️ Drawn for the visible ones too, so the two kinds sit in one list and
/// the outline means "a wall is here" rather than "a wall you cannot see is
/// here". The label says which.
/// </summary>
void DrawInvisibleWalls( DebugOverlaySystem d )
{
var list = ActiveConfig.Current.InvisibleWalls;
if ( list.Count == 0 ) return;
var col = NZTools.ColorFor( NZTools.InvisibleWall );
var fill = col.WithAlpha( 0.22f );
var cube = Model.Cube.Bounds.Size;
var mgr = InvisibleWallManager.Instance;
for ( int i = 0; i < list.Count; i++ )
{
var w = list[i];
var size = w.Size;
if ( w.HasFootprint )
{
var shape = mgr?.ShapeOf( i );
if ( shape is not null )
{
var at = w.Position + w.Rotation * new Vector3( 0f, 0f, -size.z * 0.5f );
d.Model( shape, fill, MarkerLife, new global::Transform( at, w.Rotation ),
false, false );
}
DrawWallOutline( d, w, size.z, col );
}
else
{
var scale = new Vector3( size.x / cube.x, size.y / cube.y, size.z / cube.z );
d.Model( Model.Cube, fill, MarkerLife,
new global::Transform( w.Position, w.Rotation ).WithScale( scale ),
false, false );
d.Box( new BBox( -size * 0.5f, size * 0.5f ), col, MarkerLife,
new global::Transform( w.Position, w.Rotation ), false );
}
// ⚠️ Says INVISIBLE or VISIBLE outright. Two violet outlines are
// otherwise identical, and which one the player will actually see is
// the only setting this tool has.
d.Text( w.Position + Vector3.Up * (size.z * 0.5f + 12f),
w.Visible ? "wall · visible" : "wall · invisible",
size: 13f, flags: TextFlag.Center, duration: MarkerLife );
}
}
/// <summary>The drawn outline of a shaped wall. Same wireframe as a shaped
/// barrier — one shape language for anything built from corners.</summary>
static void DrawWallOutline( DebugOverlaySystem d, InvisibleWall w, float height, Color col )
{
var half = height * 0.5f;
var fp = w.Footprint;
for ( int i = 0; i < fp.Count; i++ )
{
var a = fp[i];
var b = fp[(i + 1) % fp.Count];
var a0 = w.Position + w.Rotation * new Vector3( a.x, a.y, -half );
var a1 = w.Position + w.Rotation * new Vector3( a.x, a.y, half );
var b0 = w.Position + w.Rotation * new Vector3( b.x, b.y, -half );
var b1 = w.Position + w.Rotation * new Vector3( b.x, b.y, half );
d.Line( a0, b0, col, MarkerLife, global::Transform.Zero, false );
d.Line( a1, b1, col, MarkerLife, global::Transform.Zero, false );
d.Line( a0, a1, col, MarkerLife, global::Transform.Zero, false );
}
}
/// <summary>
/// Wireframe the drawn outline of a shaped barrier — top ring, bottom ring
/// and a post at each corner.
///
/// Deliberately the same look as DrawPendingCorners, so what you clicked and
/// what got built are directly comparable instead of being two different
/// kinds of marker you have to translate between.
/// </summary>
static void DrawFootprint( DebugOverlaySystem d, Debris item, float height, Color col )
{
var half = height * 0.5f;
var fp = item.Footprint;
for ( int i = 0; i < fp.Count; i++ )
{
var a = fp[i];
var b = fp[(i + 1) % fp.Count];
// Local XY is measured in the barrier's own yaw, so rotate it back out
// before offsetting from Position — the same transform the mesh uses.
var a0 = item.Position + item.Rotation * new Vector3( a.x, a.y, -half );
var a1 = item.Position + item.Rotation * new Vector3( a.x, a.y, half );
var b0 = item.Position + item.Rotation * new Vector3( b.x, b.y, -half );
var b1 = item.Position + item.Rotation * new Vector3( b.x, b.y, half );
d.Line( a0, b0, col, MarkerLife, global::Transform.Zero, false );
d.Line( a1, b1, col, MarkerLife, global::Transform.Zero, false );
d.Line( a0, a1, col, MarkerLife, global::Transform.Zero, false );
}
}
/// <summary>
/// The footprint being clicked out, before it becomes a block.
///
/// Without this you are placing invisible points and only find out the
/// shape was wrong after it has been built.
/// </summary>
void DrawPendingCorners( DebugOverlaySystem d )
{
if ( _corners.Count == 0 ) return;
var col = NZTools.ColorFor( NZTools.Debris );
for ( int i = 0; i < _corners.Count; i++ )
{
d.Sphere( new Sphere( _corners[i], 6f ), col, 0f, global::Transform.Zero, false );
if ( i > 0 )
d.Line( _corners[i - 1], _corners[i], col, 0f, global::Transform.Zero, false );
}
// Close the loop once there are enough points to imply a shape, and show
// how tall it will end up.
if ( _corners.Count >= 3 )
d.Line( _corners[^1], _corners[0], col, 0f, global::Transform.Zero, false );
if ( !_awaitingHeight )
{
d.Text( _corners[0] + Vector3.Up * 24f,
$"{_corners.Count}/{DebrisCorners} corners",
size: 14f, flags: TextFlag.Center );
return;
}
// ⚠️ TRACED PER FRAME, and only here. The height is now clicked, so the
// author needs to see what they are about to get BEFORE committing —
// "click at the height you want" is not much of an instruction without a
// preview of where that lands. Confined to creative, with the debris tool
// armed, between the last corner and the height click.
var aim = AimTrace( quiet: true );
float groundZ = float.MaxValue;
foreach ( var c in _corners )
groundZ = MathF.Min( groundZ, c.z );
float height = aim is null ? 0f : aim.Value.HitPosition.z - groundZ;
if ( height > 1f )
{
for ( int i = 0; i < _corners.Count; i++ )
{
var a = _corners[i].WithZ( groundZ );
var b = _corners[(i + 1) % _corners.Count].WithZ( groundZ );
d.Line( a, a + Vector3.Up * height, col, 0f, global::Transform.Zero, false );
d.Line( a + Vector3.Up * height, b + Vector3.Up * height, col, 0f,
global::Transform.Zero, false );
}
}
d.Text( _corners[0] + Vector3.Up * 24f,
height > 1f
? $"{_corners.Count} corners\nclick to set height {height:0}"
: $"{_corners.Count} corners\naim higher — click sets the height",
size: 14f, flags: TextFlag.Center );
}
/// <summary>
/// ⚠️ DEPTH TESTED — the trailing `overlay` argument is false.
///
/// Left on, markers draw through walls and floors, and a map with a few
/// dozen of them becomes unreadable: you cannot tell which room a spawn is
/// actually in, and markers two floors down clutter the one you are
/// standing in. Occluding them is what makes the overlay useful for
/// judging a room rather than just confirming a marker exists.
///
/// Each box is drawn twice, a hair apart, to thicken the wireframe — the
/// debug overlay has no line width and a single pass reads as faint.
/// </summary>
static void DrawSpawns( DebugOverlaySystem d,
System.Collections.Generic.List<SpawnPoint> list, string tool )
{
var col = NZTools.ColorFor( tool );
// Translucent fill + solid wireframe. The fill gives the marker volume
// so it reads as a body-sized space rather than a floating outline; the
// wire keeps its edges crisp where the fill washes out against a bright
// floor. Model.Cube is a built-in unit cube, scaled by the transform.
var fill = col.WithAlpha( 0.25f );
foreach ( var s in list )
{
var min = s.Position + new Vector3( -14, -14, 0 );
var max = s.Position + new Vector3( 14, 14, 72 );
var centre = (min + max) * 0.5f;
// ⚠️ Scale from the cube's OWN bounds, not a guessed unit size. I
// assumed Model.Cube was 100 units and divided by that; it is not,
// so the fill came out smaller than its outline. Deriving it means
// the fill matches the wireframe whatever the model measures.
var cube = Model.Cube.Bounds.Size;
var want = new Vector3( 28f, 28f, 72f );
var scale = new Vector3(
cube.x > 0f ? want.x / cube.x : 1f,
cube.y > 0f ? want.y / cube.y : 1f,
cube.z > 0f ? want.z / cube.z : 1f );
d.Model( Model.Cube, fill, MarkerLife,
new global::Transform( centre, Rotation.Identity ).WithScale( scale ),
false, false );
d.Box( new BBox( min, max ), col, MarkerLife, global::Transform.Zero, false );
DrawFacing( d, s, col );
}
}
/// <summary>A line out of the front, so facing reads at a glance. Position
/// alone does not tell you which way someone will be looking, and that is
/// half the reason the marker exists.</summary>
static void DrawFacing( DebugOverlaySystem d, SpawnPoint s, Color color )
{
var from = s.Position + Vector3.Up * 36f;
var to = from + s.Rotation.Forward * 40f;
d.Line( from, to, color, MarkerLife, global::Transform.Zero, false );
d.Line( from + Vector3.Up * 0.6f, to + Vector3.Up * 0.6f,
color, MarkerLife, global::Transform.Zero, false );
}
}