Console commands for editing and inspecting navigation links (NavLink) in the NZombies game. Provides commands to list, place, remove, test and batch-generate nav links, plus tuning commands for traversal behaviour and logging intent.
using Sandbox;
using Sandbox.Navigation;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// Console access to nav links — place, list, remove, and prove one is being
/// used. Every button in the tool panel has an equivalent here.
/// </summary>
public static class NavLinkCommands
{
static MapEditor Editor
=> Game.ActiveScene?.GetAllComponents<MapEditor>().FirstOrDefault();
static NZPlayer Player
=> NZPlayer.Local;
/// <summary>What links exist and what they do: `nz_navlinks`.</summary>
[ConCmd( "nz_navlinks" )]
public static void List()
{
var list = ActiveConfig.Current.NavLinks;
if ( list.Count == 0 )
{
Log.Info( "[nz-navlink] none placed. The generated navmesh already covers "
+ "everything walkable — links are for drops, jumps and one-way routes." );
return;
}
for ( int i = 0; i < list.Count; i++ )
{
var l = list[i];
Log.Info( $"[nz-navlink] [{i}] {(l.BiDirectional ? "two-way" : "one-way ->"),-10} "
+ $"{l.Length,5:0}u"
+ (l.Fall > 8f ? $" drop {l.Fall,4:0}u" : l.Fall < -8f ? $" climb {-l.Fall,4:0}u" : " level")
+ $" r{l.Radius:0} flag {DoorLinks.Display( l.Link )}"
+ (l.Walk ? " WALK" : " ")
+ $" {l.A} -> {l.B}" );
}
var mgr = NavLinkManager.Instance;
Log.Info( $"[nz-navlink] {list.Count} configured, "
+ $"{(mgr.IsValid() ? mgr.Built.ToString() : "?")} built in the world" );
}
/// <summary>
/// Drop a link from where you stand to where you are looking:
/// `nz_navlink_here [twoway] [radius]`.
///
/// ⚠️ START IS YOUR FEET, END IS THE CROSSHAIR — that ordering is the whole
/// command. Stand on the ledge, look at the floor below, run it. Reversed,
/// you get a link that walks zombies UP the wall.
/// </summary>
[ConCmd( "nz_navlink_here" )]
public static void Here( string twoway = "", float radius = 32f )
{
var ed = Editor;
var p = Player;
if ( !ed.IsValid() || !p.IsValid() ) { Log.Warning( "[nz-navlink] no editor/player" ); return; }
var from = p.WorldPosition;
var rot = p.Components.Get<PlayerController>()?.EyeAngles.ToRotation() ?? p.WorldRotation;
var eye = from + Vector3.Up * 64f;
var tr = Game.ActiveScene.Trace.Ray( eye, eye + rot.Forward * 4096f ).Run();
if ( !tr.Hit )
{
Log.Warning( "[nz-navlink] nothing under the crosshair to land on" );
return;
}
var spot = new NavLinkSpot
{
A = from,
B = tr.HitPosition,
BiDirectional = twoway is "1" or "true" or "twoway" or "two",
Radius = MathF.Max( 8f, radius ),
};
ActiveConfig.Current.NavLinks.Add( spot );
NavLinkManager.Ensure( Game.ActiveScene )?.Rebuild();
Log.Info( $"[nz-navlink] built {spot.Length:0}u"
+ (spot.Fall > 8f ? $", dropping {spot.Fall:0}u" : "")
+ $", {(spot.BiDirectional ? "TWO-WAY" : "one-way A->B")}" );
List();
}
/// <summary>
/// Mark where you stand as droppable: `nz_drop [radius]`.
///
/// Stand ON the ledge, near the edge, and run it. The landing is found by
/// tracing outward and down — you do not aim at it.
/// </summary>
[ConCmd( "nz_drop" )]
public static void Drop( float radius = 48f )
{
var p = Player;
if ( !p.IsValid() ) { Log.Warning( "[nz-drop] no player" ); return; }
if ( NavLinkManager.BuildDrop( p.WorldPosition, radius ) is null ) return;
// ⚠️ Verified immediately rather than trusted. BuildDrop snaps both ends,
// so this should always pass — and the day it does not is the day the
// snapping assumption broke, which is worth hearing about at once.
Test( ActiveConfig.Current.NavLinks.Count - 1 );
}
/// <summary>
/// What the vertical gate is doing right now: `nz_vertical [enable] [release]`.
///
/// ⚠️ Reports the MODE PER ZOMBIE, not a global setting. The whole point of
/// the gate is that it differs per zombie — one on your floor is barred from
/// both link types while one two storeys down is not, at the same instant.
/// A single global readout could not show the thing being tuned.
/// </summary>
[ConCmd( "nz_vertical" )]
public static void Vertical( float enable = 0f, float release = 0f )
{
if ( enable > 0f ) ZombieAI.VerticalEnable = enable;
if ( release > 0f ) ZombieAI.VerticalRelease = release;
if ( ZombieAI.VerticalRelease >= ZombieAI.VerticalEnable )
Log.Warning( "[nz-vert] ⚠ release >= enable — that is NO hysteresis, and a "
+ "zombie sitting on the boundary will flip its filter every think." );
Log.Info( $"[nz-vert] enable at {ZombieAI.VerticalEnable:0}u, "
+ $"release at {ZombieAI.VerticalRelease:0}u" );
var all = ZombieAI.All.Where( z => z.IsValid() ).ToList();
if ( all.Count == 0 ) { Log.Info( "[nz-vert] no zombies alive" ); return; }
int up = 0, down = 0, level = 0;
foreach ( var z in all.Take( 12 ) )
{
var dz = z.Target.IsValid()
? z.Target.WorldPosition.z - z.WorldPosition.z
: 0f;
Log.Info( $"[nz-vert] {z.GameObject.Name,-10} dz {dz,7:0}u -> "
+ (z.VerticalMode > 0 ? "JUMP allowed"
: z.VerticalMode < 0 ? "DROP allowed" : "level, neither") );
}
foreach ( var z in all )
{
if ( z.VerticalMode > 0 ) up++;
else if ( z.VerticalMode < 0 ) down++;
else level++;
}
Log.Info( $"[nz-vert] {all.Count} alive — {up} may jump, {down} may drop, "
+ $"{level} level (barred from both)" );
}
/// <summary>
/// Speed of a link crossing: `nz_cross_speed [n]`. Above 1 is faster.
///
/// ⚠️ Scales the CLIP's playback, and the body is lerped over the clip's
/// duration — so this moves the animation and the travel together. Changing
/// one without the other is what makes a traversal skate.
/// </summary>
[ConCmd( "nz_cross_speed" )]
public static void CrossSpeed( float up = 0f, float down = 0f )
{
// ⚠️ Ceiling is 16, not 6 — a climb wants 8 and the old cap would have
// silently clamped it to 6 while reporting success.
if ( up > 0f ) ZombieAI.CrossSpeedUp = Math.Clamp( up, 0.25f, 16f );
if ( down > 0f ) ZombieAI.CrossSpeedDown = Math.Clamp( down, 0.25f, 16f );
Log.Info( $"[nz-cross] clip rate — up x{ZombieAI.CrossSpeedUp:0.##} "
+ $"down x{ZombieAI.CrossSpeedDown:0.##}"
+ (up > 0f || down > 0f ? "" : " — nz_cross_speed <up> <down>") );
}
/// <summary>
/// Crossing speed in UNITS PER SECOND: `nz_cross_ups [up] [down]`.
///
/// ⚠️ THIS IS THE ONE THAT MATTERS NOW. Duration is distance / this, so a long
/// jump takes proportionally longer than a short one and both move at the same
/// speed. `nz_cross_speed` only scales the CLIP and is a fallback for when this
/// is 0.
/// </summary>
[ConCmd( "nz_cross_ups" )]
public static void CrossUnits( float up = 0f, float down = 0f )
{
if ( up > 0f ) ZombieAI.CrossUnitsUp = Math.Clamp( up, 0f, 6000f );
if ( down > 0f ) ZombieAI.CrossUnitsDown = Math.Clamp( down, 0f, 6000f );
Log.Info( $"[nz-cross] up {ZombieAI.CrossUnitsUp:0} u/s "
+ $"down {ZombieAI.CrossUnitsDown:0} u/s"
+ (up > 0f || down > 0f ? "" : " — nz_cross_ups <up> <down>") );
// A worked example beats a unit, because "700 u/s" means nothing until you
// know what it does to the jump you are actually looking at.
Log.Info( $"[nz-cross] a 128u climb takes "
+ $"{(ZombieAI.CrossUnitsUp > 1f ? 128f / ZombieAI.CrossUnitsUp : 0f):0.00}s" );
}
/// <summary>
/// Shape of a crossing: `nz_cross_bias [n]`. 1 = straight, higher = squarer.
///
/// Climb goes up the face first then across the top; a drop steps out then
/// falls. Both axes still finish together, so the landing point never moves.
/// </summary>
[ConCmd( "nz_cross_bias" )]
public static void CrossBias( float value = 0f )
{
if ( value > 0f ) ZombieAI.CrossBias = Math.Clamp( value, 1f, 8f );
Log.Info( $"[nz-cross] bias {ZombieAI.CrossBias:0.##}"
+ (ZombieAI.CrossBias <= 1.01f ? " (straight line)" : " (up then across)")
+ (value > 0f ? "" : " — nz_cross_bias <n> to change") );
}
/// <summary>
/// Force the climb clip: `nz_cross_clip [name]`. No argument lists the
/// candidates; `nz_cross_clip -` goes back to picking by height.
///
/// ⚠️ EXISTS BECAUSE PICKING A TRAVERSAL CLIP IS A LOOKING PROBLEM, NOT A
/// READING ONE. Twelve were imported and their names say nothing about how much
/// the root travels — which is the property that matters here, since the walker
/// has no motion extraction and a clip's own rise stacks on the lerp.
/// </summary>
[ConCmd( "nz_cross_clip" )]
public static void CrossClip( string name = "" )
{
string[] candidates =
{
"nz_base_zombie_jump_up_start",
"nz_base_zombie_jump_up_loop",
"nz_base_zombie_jump_up_finish",
"nz_base_zombie_jump_up_small_loop",
"nz_base_zombie_jump_up_small_finish",
"nz_base_zombie_jump_2_mantle_finish",
"nz_base_zombie_alcove_traverse_40",
"nz_base_zombie_alcove_traverse_56",
"nz_base_zombie_alcove_traverse_96",
"nz_trav_run_jump_up_128",
"nz_trav_run_jump_up_128_quick",
};
if ( name == "-" )
{
WalkerTraverse.ForcedClimbClip = "";
Log.Info( "[nz-cross] back to picking by height" );
return;
}
if ( string.IsNullOrWhiteSpace( name ) )
{
Log.Info( $"[nz-cross] forced clip: "
+ (string.IsNullOrWhiteSpace( WalkerTraverse.ForcedClimbClip )
? "(none — picked by height)" : WalkerTraverse.ForcedClimbClip) );
foreach ( var c in candidates ) Log.Info( $"[nz-cross] {c}" );
Log.Info( "[nz-cross] nz_cross_clip <name> · nz_cross_clip - to reset" );
return;
}
WalkerTraverse.ForcedClimbClip = name;
Log.Info( $"[nz-cross] every climb now plays '{name}'" );
}
/// <summary>Remove one by index: `nz_navlink_remove <i>`.</summary>
[ConCmd( "nz_navlink_remove" )]
public static void Remove( int index = -1 )
{
var list = ActiveConfig.Current.NavLinks;
if ( index < 0 || index >= list.Count )
{
Log.Warning( $"[nz-navlink] index out of range — {list.Count} placed (nz_navlinks)" );
return;
}
list.RemoveAt( index );
NavLinkManager.Ensure( Game.ActiveScene )?.Rebuild();
Log.Info( $"[nz-navlink] removed [{index}] — {list.Count} left" );
}
/// <summary>
/// `nz_navlink_walk` reports; `nz_navlink_walk 1` arms the TOOL, so the next link you place is a
/// walk-through one. Mirrors the "Walk through" switch in the Q menu.
///
/// ⚠️ THIS ARMS THE TOOL, IT DOES NOT CONVERT ANYTHING. Links already placed keep their jump and
/// drop clips — `nz_navlink_walk_all` is the one that changes them.
/// </summary>
[ConCmd( "nz_navlink_walk" )]
public static void WalkToggle( int on = -1 )
{
// ⚠️ THIS FILE'S OWN `Editor`, which is the same lookup `ToolSettings` uses. The panel and
// this command must agree about which MapEditor they are setting, or the Q-menu switch and
// the console set different objects' flags.
var e = Editor;
if ( !e.IsValid() ) { Log.Warning( "[nz-navlink] no map editor — enter Creative" ); return; }
if ( on >= 0 ) e.NavLinkWalk = on != 0;
Log.Info( $"[nz-navlink] next link: {( e.NavLinkWalk ? "WALK THROUGH — no clip, normal speed" : "jump/drop clips (normal)" )}" );
}
/// <summary>
/// `nz_navlink_walk_all <0|1>` — set or clear the walk flag on EVERY link on this map, then
/// rebuild.
///
/// ⛔ THE ONLY WAY TO FIX LINKS THAT ARE ALREADY PLACED, and a map built before the flag existed
/// is entirely such links. Without this, converting them means deleting and re-clicking every
/// one, which on a platform-heavy map is the whole reason the flag was wanted.
///
/// ⚠️ ALL OR NOTHING, ON PURPOSE. Per-link editing is what `nz_navlinks` indices plus
/// `nz_navlink_remove` already give you; a map either wants its links seamless or it does not,
/// and a half-converted set is very hard to reason about from a console listing.
/// </summary>
[ConCmd( "nz_navlink_walk_all" )]
public static void WalkAll( int on = 1 )
{
var list = ActiveConfig.Current.NavLinks;
if ( list.Count == 0 ) { Log.Info( "[nz-navlink] none placed" ); return; }
var want = on != 0;
int changed = 0;
foreach ( var l in list )
{
if ( l.Walk == want ) continue;
l.Walk = want;
changed++;
}
// ⛔ A REBUILD IS NOT OPTIONAL. The flag is captured into each link's LinkEntered handler
// when the object is built, precisely so a later config edit cannot change a live link's
// behaviour behind your back — which means the config alone changes nothing until here.
NavLinkManager.Ensure( Game.ActiveScene )?.Rebuild();
Log.Info( $"[nz-navlink] {changed} of {list.Count} link(s) set to "
+ ( want ? "WALK THROUGH" : "jump/drop clips" ) + " — rebuilt" );
Log.Info( "[nz-navlink] save the map config to keep it" );
}
/// <summary>
/// `nz_nav_bridge [maxGap] [spacing] [extent] [apply]` — find small GAPS between platforms and
/// span them with walk links.
///
/// ⛔ A NAV MESH CANNOT BRIDGE A GAP AND NO SETTING MAKES IT. `AgentStepSize` joins surfaces that
/// are vertically apart but horizontally touching; two platforms with air between them are two
/// islands, and Recast has no "jump across" parameter to raise. A link is the only mechanism, so
/// the only question is whether a human places every one of them by hand.
///
/// ⛔ AND ON A PLATFORM MAP THAT IS NOT A REASONABLE ASK. Basalt already carries 22 hand-placed
/// links for its big traversals; the gaps this is for are the many small ones between adjacent
/// platforms, and every one that is missed is a zombie standing still at an edge.
///
/// **How it decides.** A cell is a gap crossing when it is walkable, the cells immediately
/// beyond it are NOT, and a walkable cell appears again within `maxGap` — with both sides at
/// similar height. ⚠️ **THE SOLID RUN IN THE MIDDLE IS THE WHOLE TEST**: without it every pair
/// of walkable cells within range looks like a gap, and the map fills with links laid across
/// open floor the mesh already covers.
///
/// ⚠️ **AND THEN IT ASKS THE PATHFINDER**, because "not connected in a straight line" is not
/// "not connected". Two platforms either side of a gap are usually joined by a long way round,
/// and bridging those is exactly the point — but a gap you can already walk around in two paces
/// is not worth a link. Anything reachable in under `DetourFactor` times the direct distance is
/// skipped.
///
/// ⚠️ `apply 0` reports what it WOULD build and changes nothing. Run that first: this writes
/// into the map config, and an over-eager scan is tedious to undo by hand.
/// </summary>
[ConCmd( "nz_nav_bridge" )]
public static void Bridge( float maxGap = 72f, float spacing = 32f, float extent = 2400f,
int apply = 1, float probe = 4f )
{
var scene = Game.ActiveScene;
var nav = scene?.NavMesh;
if ( nav is null ) { Log.Warning( "[nz-bridge] scene has no NavMesh" ); return; }
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Warning( "[nz-bridge] no player to scan around" ); return; }
// ⛔ A MESH THAT IS STILL BUILDING ANSWERS QUESTIONS WRONG, AND CONFIDENTLY. Tiles come
// online over several frames, so an early scan finds holes that are about to fill in — every
// probe looks successful and the gaps are fiction.
if ( nav.IsGenerating || nav.IsDirty )
{
Log.Warning( "[nz-bridge] the navmesh is still generating — wait a moment and run it"
+ " again. Scanning now would find holes that are about to fill in." );
return;
}
spacing = Math.Clamp( spacing, 8f, 128f );
maxGap = Math.Clamp( maxGap, 8f, 512f );
extent = Math.Clamp( extent, 128f, 8000f );
probe = Math.Clamp( probe, 2f, spacing );
int half = (int)( extent / spacing );
int w = half * 2 + 1;
int steps = (int)MathF.Ceiling( maxGap / spacing ) + 1;
var origin = p.WorldPosition;
// ── 1. every walkable surface in every column ────────────────────────
//
// ⛔ TRACED PER COLUMN, NOT PROBED AT THE PLAYER'S HEIGHT. The old scan asked
// `GetClosestPoint` about a point at the PLAYER'S z, so which surface a column resolved to
// moved when the player moved — two runs minutes apart found 85 gaps and then 104 on the
// same map, and links written from one run could not be reproduced by the next. Tracing
// down the column asks the MAP, and the map does not move.
//
// ⚠️ EVERY FLOOR IN THE COLUMN, NOT THE TOP ONE. A single trace from above finds the roof of
// the highest platform and nothing underneath it, which on a stacked map is most of the
// level. Repeated traces walk down through them.
var cols = new List<Vector3>[w * w];
int surfaces = 0;
for ( int y = 0; y < w; y++ )
for ( int x = 0; x < w; x++ )
{
var at = origin + new Vector3( ( x - half ) * spacing, ( y - half ) * spacing, 0f );
var list = Column( scene, nav, at, spacing );
cols[y * w + x] = list;
surfaces += list.Count;
}
Log.Info( $"[nz-bridge] {surfaces} walkable surface(s) in {w * w} columns at {spacing:0}u" );
// ── 2. gaps ──────────────────────────────────────────────────────────
var found = new List<(Vector3 A, Vector3 B)>();
for ( int y = 0; y < w; y++ )
for ( int x = 0; x < w; x++ )
{
var here = cols[y * w + x];
if ( here.Count == 0 ) continue;
Scan( x, y, 1, 0 );
Scan( x, y, 0, 1 );
}
void Scan( int x, int y, int dx, int dy )
{
var here = cols[y * w + x];
for ( int k = 1; k <= steps; k++ )
{
int nx = x + dx * k, ny = y + dy * k;
if ( nx >= w || ny >= w ) return;
var there = cols[ny * w + nx];
if ( there.Count == 0 ) continue;
foreach ( var a in here )
foreach ( var b in there )
{
// Same floor only — a neighbour 200u below is a different storey.
if ( MathF.Abs( a.z - b.z ) > NavLinkManager.LevelBand ) continue;
if ( a.Distance( b ) > maxGap + spacing ) continue;
// ⛔ THE SEGMENT IS SUB-PROBED, WHICH IS THE WHOLE FIX. The old test only saw a
// gap when an intermediate CELL was off the mesh, so it was blind to any hole
// narrower than its own spacing — and the hole that started this was 24u under a
// 32u grid. Walking the line at `probe` finds it whatever the grid is, and hands
// back the mesh points either side of it rather than two cell centres.
if ( !Hole( nav, a, b, probe, out var from, out var to ) ) continue;
if ( from.Distance( to ) > maxGap ) continue;
found.Add( (from, to) );
}
// A neighbouring column with floor at this level ends the search along this axis —
// anything further is behind it.
return;
}
}
// ── 3. worth a link? ─────────────────────────────────────────────────
var keep = new List<(Vector3 A, Vector3 B)>();
int detoured = 0, already = 0, dupes = 0;
foreach ( var (a, b) in found )
{
if ( keep.Any( k => k.A.Distance( a ) < probe && k.B.Distance( b ) < probe
|| k.A.Distance( b ) < probe && k.B.Distance( a ) < probe ) ) { dupes++; continue; }
if ( NearExisting( a, b, spacing ) ) { already++; continue; }
var path = nav.CalculatePath( new CalculatePathRequest { Start = a, Target = b } );
var list = path.Points;
float len = 0f;
if ( list is not null )
for ( int n = 1; n < list.Count; n++ )
len += list[n].Position.Distance( list[n - 1].Position );
var direct = a.Distance( b );
var ends = list is not null && list.Count > 0 ? list[^1].Position : a;
// ⚠️ A PATH THAT STOPS SHORT IS NOT A PATH. CalculatePath returns a partial route with a
// healthy-looking length when the target is unreachable, so the shortfall has to be
// checked or every genuinely severed gap reads as "already connected".
bool reachable = list is not null && list.Count > 0
&& ends.Distance( b ) < spacing;
if ( reachable && len < direct * DetourFactor ) { detoured++; continue; }
keep.Add( (a, b) );
}
Log.Info( $"[nz-bridge] {found.Count} gap(s) found — {dupes} duplicate, {already} already"
+ $" spanned, {detoured} walkable round, {keep.Count} to bridge" );
if ( keep.Count == 0 ) return;
if ( apply == 0 )
{
foreach ( var (a, b) in keep.Take( 20 ) )
Log.Info( $"[nz-bridge] would bridge {a.Distance( b ):0}u {a:0} -> {b:0}" );
if ( keep.Count > 20 ) Log.Info( $"[nz-bridge] ... and {keep.Count - 20} more" );
Log.Info( "[nz-bridge] nothing changed — run with apply 1 to build them" );
return;
}
int rejected = 0, flagged = 0;
foreach ( var (a, b) in keep )
{
// ⛔ CHECKED AGAINST THE BUILDER'S OWN TOLERANCE, at the moment of writing. Both ends
// came off the mesh a moment ago, but "was on the mesh" is not the test that matters:
// `NavLinkManager.SnapEnd` refuses anything further than `SnapTolerance` and builds a
// dead link anyway. Writing known-broken data into the map config is not something a
// generator gets to do quietly.
if ( OffMesh( nav, a ) > NavLinkManager.SnapTolerance
|| OffMesh( nav, b ) > NavLinkManager.SnapTolerance )
{
rejected++;
continue;
}
// ⛔ TWO ONE-WAY LINKS, NOT ONE BIDIRECTIONAL ONE — the per-agent gate forbids by AREA
// and an area has no direction, so a two-way link cannot be gated one way. A gap is
// crossed both ways, so it needs both halves.
//
// ⚠️ `Walk = true`. These are steps across, not leaps; a jump clip on a 24u slot reads
// as a zombie vaulting a crack.
// ⛔ A LINK THAT CROSSES A BARRIER INHERITS THAT BARRIER'S FLAG, OR IT IS A HOLE IN THE
// MAP'S PROGRESSION. The scan bridges a gap whether or not debris is standing in it —
// which is what was asked for, and right, because the gap is real and will still be
// there once the door is bought. But an unflagged link across a closed barrier is a
// route through it: zombies walk into the starting room through a door nobody paid for,
// and the only symptom is that the map is suddenly much harder.
//
// ⚠️ SO THE LINK IS GATED EXACTLY AS THE BARRIER IS, by carrying the same flag. When the
// barrier opens the flag opens, and the link comes up with it.
var flag = DebrisFlag( a, b );
if ( !DoorLinks.IsUnlinked( flag ) ) flagged++;
ActiveConfig.Current.NavLinks.Add( new NavLinkSpot
{
A = a, B = b, BiDirectional = false, Radius = spacing, Walk = true, Link = flag,
} );
ActiveConfig.Current.NavLinks.Add( new NavLinkSpot
{
A = b, B = a, BiDirectional = false, Radius = spacing, Walk = true, Link = flag,
} );
}
NavLinkManager.Ensure( scene )?.Rebuild();
Log.Info( $"[nz-bridge] built {( keep.Count - rejected ) * 2} walk link(s) across"
+ $" {keep.Count - rejected} gap(s)"
+ ( rejected > 0 ? $", {rejected} rejected as off-mesh" : "" )
+ ( flagged > 0 ? $", {flagged} gated behind a barrier's flag" : "" )
+ $" — {ActiveConfig.Current.NavLinks.Count} links total" );
Log.Info( "[nz-bridge] nz_navlink_check to verify · save the map config to keep them" );
}
/// <summary>
/// Every walkable surface in one vertical column, top to bottom.
///
/// ⚠️ THE TRACE FINDS FLOOR; THE MESH DECIDES WHETHER IT IS WALKABLE. A downward ray hits crates,
/// railings, rubble and the tops of walls just as happily as a floor, so each hit is snapped to
/// the navmesh and kept only when the mesh agrees it is there. That filter is free and it is
/// what stops a scan bridging along the top of a fence.
/// </summary>
static List<Vector3> Column( Scene scene, NavMesh nav, Vector3 at, float tol )
{
var found = new List<Vector3>();
var from = at.WithZ( 16384f );
for ( int i = 0; i < 8; i++ )
{
var tr = scene.Trace.Ray( from, at.WithZ( -16384f ) ).Run();
if ( !tr.Hit ) break;
var on = nav.GetClosestPoint( tr.HitPosition );
if ( on is not null
&& ( on.Value - tr.HitPosition ).WithZ( 0 ).Length <= tol
&& MathF.Abs( on.Value.z - tr.HitPosition.z ) <= 32f )
{
// ⚠️ THE MESH POINT IS KEPT, NOT THE TRACE POINT. A link end has to be ON the mesh
// or the builder refuses it, and the two differ by up to the tolerance above.
if ( !found.Any( f => MathF.Abs( f.z - on.Value.z ) < 8f ) )
found.Add( on.Value );
}
// ⚠️ Step BELOW the surface just hit, or the next trace hits it again forever.
from = tr.HitPosition - Vector3.Up * 4f;
if ( from.z < -16000f ) break;
}
return found;
}
/// <summary>
/// Walk the straight line between two mesh points looking for a break, and hand back the mesh
/// points either side of it.
///
/// ⚠️ THE ENDS ARE THE LAST GOOD SAMPLES, NOT THE INPUTS. Bridging cell centre to cell centre
/// spans the hole AND the walkable ground either side of it, which makes every link longer than
/// the gap it crosses and drags its `ConnectionRadius` over floor that never needed one.
/// </summary>
static bool Hole( NavMesh nav, Vector3 a, Vector3 b, float step, out Vector3 from, out Vector3 to )
{
from = a;
to = b;
var dist = a.Distance( b );
int n = (int)( dist / step );
if ( n < 2 ) return false;
bool inHole = false;
for ( int i = 0; i <= n; i++ )
{
var at = Vector3.Lerp( a, b, (float)i / n );
var on = nav.GetClosestPoint( at );
bool ok = on is not null
&& ( on.Value - at ).WithZ( 0 ).Length <= step
&& MathF.Abs( on.Value.z - at.z ) <= 32f;
if ( ok && !inHole ) from = on.Value; // last good point before a hole
else if ( !ok ) inHole = true;
else if ( ok && inHole ) { to = on.Value; return true; }
}
return false;
}
/// <summary>
/// How much longer the existing route may be before a gap is worth bridging.
///
/// ⚠️ NOT 1, AND NOT HUGE. At 1 every gap is bridged including ones you step around in two
/// paces; very high and a zombie that would have taken the long way round now cuts the corner,
/// which changes how a map PLAYS rather than fixing it. 4 means "the way round is four times
/// further than stepping across".
/// </summary>
public static float DetourFactor { get; set; } = 4f;
/// <summary>
/// The flag of the barrier a link passes through, or `Unlinked` when it crosses open ground.
///
/// ⚠️ THE WHOLE SEGMENT IS SAMPLED, NOT JUST ITS ENDS. The ends of a bridge are by construction
/// on the navmesh and therefore OUTSIDE the barrier — a barrier blocks the mesh, which is what
/// made the hole. Testing them would find nothing, every time.
///
/// ⚠️ FINELY, because a barrier can be thinner than the gap it stands in. 2u costs nothing here
/// and a coarse walk steps straight over a plank.
///
/// ⚠️ FIRST MATCH WINS, and overlapping barriers with different flags are a map authoring
/// problem rather than something to resolve here — picking "the most restrictive" would need an
/// ordering over flags that the link system does not have.
/// </summary>
static string DebrisFlag( Vector3 a, Vector3 b )
{
var list = ActiveConfig.Current?.Debris;
if ( list is null || list.Count == 0 ) return DoorLinks.Unlinked;
var dist = a.Distance( b );
int n = MathF.Max( 2f, dist / 2f ).FloorToInt();
for ( int i = 0; i <= n; i++ )
{
var at = Vector3.Lerp( a, b, (float)i / n );
foreach ( var d in list )
{
if ( DoorLinks.IsUnlinked( d.Link ) ) continue;
if ( d.Contains( at ) ) return d.Link;
}
}
return DoorLinks.Unlinked;
}
/// <summary>Is there already a link spanning roughly this gap? Either direction counts.</summary>
static bool NearExisting( Vector3 a, Vector3 b, float tol )
{
foreach ( var l in ActiveConfig.Current.NavLinks )
{
if ( l.A.Distance( a ) < tol && l.B.Distance( b ) < tol ) return true;
if ( l.A.Distance( b ) < tol && l.B.Distance( a ) < tol ) return true;
}
return false;
}
/// <summary>How far a point is from the navmesh. `float.MaxValue` when there is no mesh near
/// it at all, so a caller comparing against a tolerance does the right thing either way.</summary>
static float OffMesh( NavMesh nav, Vector3 at )
{
var on = nav.GetClosestPoint( at );
return on is null ? float.MaxValue : at.Distance( on.Value );
}
/// <summary>
/// `nz_navlink_check [prune]` — measure every link's ends against the LIVE navmesh.
///
/// ⛔ THE WARNING HAS BEEN NAMING THIS COMMAND AND IT DID NOT EXIST. `NavLinkManager.SnapEnd`
/// tells you to run `nz_navlink_check` when an end is too far from the mesh to snap; nothing
/// answered. So the one question the warning raises — "which of my links are dead, and how
/// dead?" — could only be answered by reading a wall of build-time spam.
///
/// ⛔ AND A DEAD LINK IS INVISIBLE IN EVERY OTHER WAY. `nz_navlinks` lists it, the marker draws,
/// the config counts it, and zombies simply never use it. The only symptom is a route nobody
/// takes, which looks exactly like a pathing problem somewhere else.
///
/// ⚠️ `prune 1` REMOVES the broken ones and rebuilds. It does not save — check the count first.
///
/// ⚠️ ASK IT ONLY WHEN THE MESH HAS SETTLED. Mid-regenerate, ends that are perfectly fine read
/// as far off the mesh because their tile has not been built yet, and pruning then deletes good
/// links. It refuses while `IsGenerating` for exactly that reason.
/// </summary>
[ConCmd( "nz_navlink_check" )]
public static void Check( int prune = 0 )
{
var scene = Game.ActiveScene;
var nav = scene?.NavMesh;
if ( nav is null ) { Log.Warning( "[nz-navlink] scene has no NavMesh" ); return; }
if ( nav.IsGenerating || nav.IsDirty )
{
Log.Warning( "[nz-navlink] the navmesh is still generating — every end reads as off-mesh"
+ " until its tile lands. Wait a moment and run it again." );
return;
}
var list = ActiveConfig.Current.NavLinks;
if ( list.Count == 0 ) { Log.Info( "[nz-navlink] none placed" ); return; }
var tol = NavLinkManager.SnapTolerance;
var bad = new List<NavLinkSpot>();
float worst = 0f;
foreach ( var l in list )
{
var da = OffMesh( nav, l.A );
var db = OffMesh( nav, l.B );
var far = MathF.Max( da, db );
if ( far <= tol ) continue;
bad.Add( l );
if ( far < float.MaxValue ) worst = MathF.Max( worst, far );
}
Log.Info( $"[nz-navlink] {list.Count - bad.Count} of {list.Count} link(s) have both ends"
+ $" within {tol:0}u of the mesh" );
if ( bad.Count == 0 ) return;
Log.Warning( $"[nz-navlink] {bad.Count} DEAD link(s) — worst end {worst:0}u off"
+ " (these are listed, drawn, counted, and never used)" );
foreach ( var l in bad.Take( 12 ) )
Log.Info( $"[nz-navlink] A {OffMesh( nav, l.A ),6:0}u B {OffMesh( nav, l.B ),6:0}u"
+ $" {l.A:0} -> {l.B:0}" );
if ( bad.Count > 12 ) Log.Info( $"[nz-navlink] ... and {bad.Count - 12} more" );
if ( prune == 0 )
{
Log.Info( "[nz-navlink] nothing changed — nz_navlink_check 1 removes them" );
return;
}
foreach ( var l in bad ) list.Remove( l );
NavLinkManager.Ensure( scene )?.Rebuild();
Log.Info( $"[nz-navlink] pruned {bad.Count} — {list.Count} left. Save the config to keep it." );
}
/// <summary>Remove the lot: `nz_navlink_clear`.</summary>
[ConCmd( "nz_navlink_clear" )]
public static void ClearAll()
{
var n = ActiveConfig.Current.NavLinks.Count;
ActiveConfig.Current.NavLinks.Clear();
NavLinkManager.Ensure( Game.ActiveScene )?.Rebuild();
Log.Info( $"[nz-navlink] removed all {n}" );
}
/// <summary>
/// Is a link actually reachable and used? `nz_navlink_test [i]`.
///
/// ⚠️ Asks the NAVMESH, not the config. A link can be placed perfectly and do
/// nothing because one of its ends is not on the mesh — off the edge of the
/// generated floor, inside a blocker, or in mid-air. That failure is
/// invisible: the marker draws, the config lists it, and zombies simply never
/// use it. This is the check that tells them apart.
/// </summary>
/// <summary>
/// `nz_navlink_intent [start|stop|reset]` — record every link entry to a csv on disk.
///
/// ⛔ THE NUMBER THAT SAYS WHETHER ACCIDENTAL CROSSINGS ARE REAL, and it has to be a FILE. The
/// accidental entries are precisely the ones that now do nothing, so there is nothing on screen
/// to watch; and the fix for them (a narrower ConnectionRadius) is per-link, which a pair of
/// totals cannot point at. A row per entry names the link.
///
/// no argument — report what is being recorded and the running totals
/// start — open a new numbered csv and begin recording
/// stop — flush and close, keeping the file
/// reset — stop recording AND delete every log, for a clean slate
/// </summary>
[ConCmd( "nz_navlink_intent" )]
public static void Intent( string arg = "" )
{
switch ( arg.ToLowerInvariant() )
{
case "start":
NavLinkLog.Start();
return;
case "stop":
NavLinkLog.Stop();
return;
case "reset":
NavLinkLog.Reset();
return;
case "":
break;
default:
Log.Warning( $"[nz-navlink] unknown argument '{arg}' — start, stop or reset" );
return;
}
var ok = NavLinkLog.Wanted;
var no = NavLinkLog.Refused;
var total = ok + no;
Log.Info( NavLinkLog.Active
? $"[nz-navlink] RECORDING to {NavLinkLog.Path} — {NavLinkLog.Rows} row(s)"
: "[nz-navlink] not recording (nz_navlink_intent start)" );
Log.Info( $"[nz-navlink] link entries: {ok} on a route, {no} refused as accidental"
+ (total > 0 ? $" ({100f * no / total:0}% accidental)" : " — none yet") );
if ( no > ok && total > 8 )
Log.Warning( "[nz-navlink] more entries are accidental than deliberate — the pickup"
+ " radius is likely too wide for these links (nz_navlinks shows each radius)" );
}
[ConCmd( "nz_navlink_test" )]
public static void Test( int index = 0 )
{
var list = ActiveConfig.Current.NavLinks;
if ( index < 0 || index >= list.Count )
{
Log.Warning( $"[nz-navlink] index out of range — {list.Count} placed" );
return;
}
var nav = Game.ActiveScene?.NavMesh;
if ( nav is null ) { Log.Warning( "[nz-navlink] no navmesh" ); return; }
var l = list[index];
// GetClosestPoint returns where the mesh actually is. Far from the stated
// end means that end is hanging off the walkable surface.
var onA = nav.GetClosestPoint( l.A );
var onB = nav.GetClosestPoint( l.B );
float da = onA.HasValue ? onA.Value.Distance( l.A ) : -1f;
float db = onB.HasValue ? onB.Value.Distance( l.B ) : -1f;
Log.Info( $"[nz-navlink] [{index}] A is {da:0.0}u from the navmesh, "
+ $"B is {db:0.0}u from it" );
if ( da < 0f || da > 64f )
Log.Warning( "[nz-navlink] ⚠ END A IS NOT ON THE MESH — the link cannot be "
+ "entered. Move it onto walkable floor." );
if ( db < 0f || db > 64f )
Log.Warning( "[nz-navlink] ⚠ END B IS NOT ON THE MESH — zombies would arrive "
+ "nowhere. Move it onto walkable floor." );
if ( da >= 0f && da <= 64f && db >= 0f && db <= 64f )
Log.Info( "[nz-navlink] both ends are on the mesh — the link is usable." );
}
}