Component that manages hand-authored navigation links (NavMeshLink) for the NZombies game. It builds links from a config, snaps endpoints to the navmesh, creates runtime link objects and visible authoring markers, handles agent enter/exit events to hand control to ZombieAI, supports a one-click drop-building helper, debug dot/grid drawing, and a console command to tune area cost multipliers.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// NAV LINKS — builds the config's hand-authored navmesh shortcuts.
///
/// ⚠️ WHY THIS EXISTS AT ALL, since s&box generates a navmesh by itself: the
/// generated mesh covers everything you can WALK, and nothing else. GMod's nav
/// editor let you draw connections by hand — one-way, drop-downs, jumps — because
/// its areas are hand-placed rectangles with explicit adjacency. s&box's mesh is
/// Recast: adjacency is DERIVED from which polygons touch, so a 200u drop is two
/// unconnected islands and no amount of regeneration will join them.
///
/// A NavMeshLink is the only way to say "you can get from here to there".
///
/// ⛔ `IsBiDirectional` AND `ConnectionRadius` ARE PLAIN FIELDS, NOT [Property].
/// They do not appear in the inspector and are not serialized with the scene —
/// which is exactly why the whole thing is driven from the config instead. Set
/// them in code, after creating the component, or a link is silently two-way at
/// whatever the engine's default radius is.
/// </summary>
public sealed class NavLinkManager : Component
{
public static NavLinkManager Instance { get; private set; }
/// <summary>The manager, creating it if the scene has none.
///
/// ⚠️ `Ensure`, never `Instance?` — see DebrisManager for the same note and
/// the four call sites that had to be fixed for ignoring it.</summary>
public static NavLinkManager Ensure( Scene scene )
{
if ( Instance.IsValid() ) return Instance;
if ( !scene.IsValid() ) return null;
var go = scene.CreateObject();
go.Name = "Nav Link Manager";
go.Flags |= GameObjectFlags.NotSaved;
return go.Components.Create<NavLinkManager>();
}
/// <summary>Area tag for links that go UP. Assigned to the link so an agent
/// can be told to ignore it — see ZombieAI's vertical gate.</summary>
public const string JumpArea = "nav/jump_up.navarea";
/// <summary>Area tag for links that go DOWN.</summary>
public const string DropArea = "nav/drop_down.navarea";
/// <summary>
/// A level span across a hole. ⛔ DELIBERATELY NOT ONE OF THE TWO THE VERTICAL GATE NAMES — see
/// <see cref="AreaFor"/>. Its cost is barely above walking (1.05) because crossing a gap in the
/// mesh IS walking; the 6x on a climb and 1.4x on a drop are prices for effort that a step
/// across does not involve.
/// </summary>
public const string GapArea = "nav/cross_gap.navarea";
/// <summary>
/// ⛔ ONE-WAY LINKS ONLY, AND THIS IS WHY. The gate works by forbidding an
/// AREA per agent, and an area has no direction — so a two-way link tagged
/// "jump" would also be the way down, and forbidding it would close both. A
/// ledge that should work in both directions is TWO links, one of each tag.
/// </summary>
/// <summary>
/// A traversal within this many units of level is a GAP, not a climb or a drop.
///
/// ⚠️ SET JUST ABOVE THE NAV AGENT'S STEP. A 32u height change is a step as far as a walker is
/// concerned — the mesh would have joined it if the two surfaces touched — so a link spanning
/// one is bridging a HOLE, not staging a descent.
/// </summary>
public static float LevelBand { get; set; } = 34f;
/// <summary>
/// Which area a link is tagged with — and therefore how the per-agent gate treats it.
///
/// ⛔ A LEVEL LINK USED TO BE TAGGED `DropArea`, AND THAT IS WHY GAPS DID NOT WORK. The rule was
/// `B.z > A.z + 8 ? Jump : Drop`, so anything not clearly upward — including a perfectly flat
/// span across a hole — counted as a drop. `ZombieAI.TickVerticalGate` then forbids **both**
/// jump and drop whenever the target is roughly level with the zombie, which is exactly when a
/// horizontal gap needs crossing. The link existed, was listed, drew its marker, and the
/// pathfinder was forbidden from routing through it.
///
/// ⚠️ THAT GATE IS RIGHT ABOUT LEDGES AND WRONG ABOUT GAPS. Refusing to climb or drop while the
/// player is on your level is the behaviour that stops zombies wandering off balconies; a gap
/// closes no vertical distance at all, so the reasoning simply does not reach it. A third area
/// the gate has never heard of is the fix — it forbids only the two it names.
///
/// ⚠️ STILL DECIDED BY GEOMETRY, NOT BY `Walk`. The file has always insisted the direction is a
/// fact about where A and B are, and a flag that could disagree with the geometry is how the
/// gate ends up closing the wrong half of the map. `Walk` says how to ANIMATE the crossing;
/// this says what KIND of traversal it is.
/// </summary>
public static string AreaFor( NavLinkSpot spot )
=> MathF.Abs( spot.B.z - spot.A.z ) <= LevelBand ? GapArea
: spot.B.z > spot.A.z ? JumpArea : DropArea;
/// <summary>Load an area definition by path.
///
/// ⚠️ NOT cached in a static field. ResourceLibrary already caches, and a
/// static holding a resource is the surviving-statics trap (INSTRUCTIONS
/// pattern 1) for no gain.</summary>
public static Sandbox.Engine.Resources.NavMeshAreaDefinition Area( string path )
=> ResourceLibrary.Get<Sandbox.Engine.Resources.NavMeshAreaDefinition>( path );
/// <summary>
/// `nz_nav_cost [jumpUp] [dropDown]` — how dear a link is to path through.
///
/// ⛔ THE COST LIVES ON THE AREA, NOT THE LINK, AND IT WAS 1.0 ON BOTH. NavMeshLink exposes no
/// cost of any kind — only Area, ConnectionRadius, IsBiDirectional and the two endpoints — so a
/// link is priced entirely by its NavMeshAreaDefinition's CostMultiplier. Both shipped at 1.0,
/// which told the pathfinder that hauling yourself up a ledge costs exactly as much as walking
/// the same distance on flat ground. So a link was taken whenever it was geometrically shorter,
/// which is the whole of "zombies do not understand when to use the jump paths".
///
/// ⚠️ THESE NUMBERS ARE A STARTING GUESS. 6x for a climb and 1.4x for a drop are reasoned, not
/// measured — which on this project has meant wrong more often than right. This command exists
/// so they can be dialled while watching, rather than argued about.
///
/// ⛔ THE DROP WENT 2.0 → 1.0 → 1.4, AND THE MIDDLE STEP WAS A MISTAKE WORTH RECORDING. At 2.0
/// zombies walked the long way round rather than drop (user: *"the zombies sometimes prefer to
/// go all the way around the map than to just drop down"*), so it was set to 1.0 — which is
/// exactly the value the paragraph above calls out as the original fault: at 1.0 a link is taken
/// **whenever it is geometrically shorter**, with no preference for solid ground at all.
/// Navigation got worse in the other direction.
///
/// ⚠️ AND THE ASYMMETRY IS THE REAL TRAP, NOT THE NUMBER. A drop at 1.0 against a climb at 6.0
/// makes every ledge a one-way valve: cheap to fall down, six times dearer to come back. Zombies
/// pool at the bottom of the map and take enormous routes to return. Whatever the drop settles
/// at, it wants to be read against `jump_up`, not on its own.
///
/// ⚠️ IT EDITS THE LOADED RESOURCE, so it applies to every link using that area immediately and
/// does NOT persist. Put the value you settle on into Assets/nav/*.navarea.
/// </summary>
[ConCmd( "nz_nav_cost" )]
public static void CostCmd( float jumpUp = -1f, float dropDown = -1f, float barricade = -1f )
{
var up = Area( JumpArea );
var down = Area( DropArea );
// ⚠️ THE BARRICADE AREA IS OPTIONAL HERE, unlike the other two. It is priced the same way
// but it is not this command's reason for existing, and a project whose barricade.navarea
// has not compiled yet should still be able to tune the ledges.
var window = Area( Barricade.CrossArea );
if ( up is null || down is null )
{
Log.Warning( "[nz-nav] area definitions missing — links cannot be priced" );
return;
}
if ( jumpUp >= 0f ) up.CostMultiplier = jumpUp;
if ( dropDown >= 0f ) down.CostMultiplier = dropDown;
if ( barricade >= 0f )
{
if ( window is null )
Log.Warning( $"[nz-nav] '{Barricade.CrossArea}' missing — cannot price crossings" );
else
window.CostMultiplier = barricade;
}
Log.Info( $"[nz-nav] jump_up cost x{up.CostMultiplier:0.##}"
+ $" drop_down cost x{down.CostMultiplier:0.##}"
+ $" barricade x{(window is null ? -1f : window.CostMultiplier):0.##}" );
// ⚠️ A COST CHANGE ONLY AFFECTS PATHS CALCULATED AFTER IT. Anything already walking keeps
// the route it was given, so a nudge looks like it did nothing for a few seconds.
Log.Info( "[nz-nav] applies to NEW paths — existing ones finish on their old route" );
}
protected override void OnEnabled() => Instance = this;
protected override void OnDisabled() { if ( Instance == this ) Instance = null; }
protected override void OnStart() => Rebuild();
readonly List<GameObject> _links = new();
/// <summary>Rate-limits the missing-overlay warning to once per outage.</summary>
bool _warnedNoOverlay;
/// <summary>Most dots drawn in one frame. See the note at the draw site.</summary>
public static int MaxDotsDrawn { get; set; } = 400;
/// <summary>Let a fresh sample re-arm the redraw after it broke.</summary>
public static void ResetDotsBroken() => _dotsBroken = false;
/// <summary>How many are actually in the world right now.</summary>
public int Built => _links.Count( g => g.IsValid() );
public void Clear()
{
foreach ( var g in _links ) g?.Destroy();
_links.Clear();
}
/// <summary>Drop every configured link into the world.</summary>
public void Rebuild()
{
Clear();
var list = ActiveConfig.Current.NavLinks;
foreach ( var spot in list )
{
// ⚠️ Gated on the door flag like a spawn is, so a link into a locked
// area does not hand zombies a route the player has not bought. In
// Creative every link is built regardless — which side of a door you
// are on is a fact about a game in progress, not about the map.
if ( !NZGame.IsCreative && !DoorLinks.IsOpen( spot.Link ) ) continue;
Build( spot );
}
if ( list.Count > 0 )
Log.Info( $"[nz] nav links: {Built} of {list.Count} active" );
}
/// <summary>
/// How far an endpoint may be moved by snapping before the link is called suspect.
///
/// ⚠️ A SNAP THAT TRAVELS FURTHER THAN THIS IS NOT A CORRECTION, IT IS A DIFFERENT PLACE.
/// GetClosestPoint returns the nearest mesh position in ANY direction, so an endpoint authored
/// inside a wall can snap to the room on the far side — moving the link somewhere nobody asked
/// for while reporting success. Beyond this the original point is kept and the link is reported,
/// because a link that is visibly wrong where it was placed is easier to fix than one that
/// silently moved.
/// </summary>
public static float SnapTolerance { get; set; } = 48f;
/// <summary>
/// Pull a link end onto the navmesh, or say why not.
///
/// ⛔ THE SAME TREATMENT BARRICADE CROSSINGS GET, AND FOR THE SAME REASON. BuildDrop already
/// snapped both ends of a one-click drop, with a comment explaining that an unsnapped end "sits
/// in mid-air and the link never connects" — but a link loaded FROM A CONFIG was never snapped
/// again. The navmesh is now generated per map (NavBake), so the geometry an endpoint was
/// authored against is not necessarily the geometry it is loaded against, and an end that has
/// drifted off the mesh produces a link that draws, lists, validates as placed, and is never
/// used. That is indistinguishable from "the zombies do not understand the paths we made".
/// </summary>
Vector3 SnapEnd( Vector3 at, string which, out bool ok )
{
ok = false;
var nav = Scene?.NavMesh;
if ( nav is null || !nav.IsEnabled ) return at;
var on = nav.GetClosestPoint( at );
if ( on is null )
{
Log.Warning( $"[nz-nav] link end {which} at {at:0} is NOT on the navmesh and nothing"
+ " walkable is near it — this link cannot be used" );
return at;
}
var moved = at.Distance( on.Value );
if ( moved > SnapTolerance )
{
Log.Warning( $"[nz-nav] link end {which} at {at:0} would snap {moved:0}u to reach the"
+ $" navmesh — further than {SnapTolerance:0}u, so it was LEFT where it was."
+ " Re-place this link; its end is inside geometry." );
return at;
}
ok = true;
return on.Value;
}
void Build( NavLinkSpot spot )
{
// ⚠️ SNAPPED BEFORE ANYTHING IS BUILT, so the object, the link and the marker all agree about
// where the link actually is. Building at the authored point and snapping afterwards would
// leave the visible marker somewhere the link is not.
var a = SnapEnd( spot.A, "A", out var aOk );
var b = SnapEnd( spot.B, "B", out var bOk );
if ( !aOk || !bOk )
Log.Warning( $"[nz-nav] link '{spot.Link ?? "-"}' {spot.A:0} -> {spot.B:0} is SUSPECT"
+ " — it is being built, but at least one end is not on the mesh (nz_navlink_check)" );
var go = Scene.CreateObject();
go.Name = $"NavLink ({(spot.BiDirectional ? "two-way" : "one-way")}"
+ (spot.Walk ? ", walk" : "") + ")";
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
// ⚠️ The object sits at A and the link is expressed in LOCAL space, so
// moving the object moves the whole link. Start is therefore zero.
go.WorldPosition = a;
go.WorldRotation = Rotation.Identity;
var link = go.Components.Create<NavMeshLink>();
link.LocalStartPosition = Vector3.Zero;
link.LocalEndPosition = b - a;
// ⛔ THE TWO LINES THE INSPECTOR CANNOT SHOW YOU. See the class remarks:
// these are fields, so they are invisible in the editor and absent from
// the scene file. Assigning them here is the only place they get set.
link.IsBiDirectional = spot.BiDirectional;
// ⛔ CLAMPED AGAINST THE LINK'S OWN LENGTH, WHICH IT WAS NOT. This was `max(8, spot.Radius)`
// with a tool default of 48, so a short link could have a pickup sphere reaching most of the
// way to its far end — an agent standing near B could be picked up as though it were at A.
// On a barricade crossing that exact mistake (radius 40 against a 34u offset) made zombies
// re-cross forever, because "which end am I at" had no clean answer. A ledge link is longer
// so it is less acute, but it is the same defect.
//
// ⚠️ 0.4 of the length leaves a clear band in the middle that belongs to neither end.
var len = a.Distance( b );
var maxR = len > 1f ? len * 0.4f : 8f;
link.ConnectionRadius = MathF.Max( 8f, MathF.Min( spot.Radius, maxR ) );
// ⚠️ Tagged by GEOMETRY, not by a field on the spot. The direction is a
// fact about where A and B are; a separate flag could disagree with it,
// and then the gate closes the wrong half of the map.
//
// A two-way link gets the tag its geometry implies and is gated as that
// direction — see AreaFor. Author two one-ways if you want both.
var area = Area( AreaFor( spot ) );
if ( area is not null ) link.Area = area;
else Log.Warning( $"[nz] nav area '{AreaFor( spot )}' missing — this link "
+ "cannot be gated and will always be usable" );
// ⚠️ SUBSCRIBED, NOT ASSIGNED. `+=` compiles whether LinkEntered is an
// event or an Action property, and both shapes exist in this API surface.
// Assignment would work for one and not the other.
//
// ⚠️ UNVERIFIED SIGNATURE. The XML documents `LinkEntered` as "Emitted
// when an agent enters the link" and `OnLinkEntered(NavMeshAgent)` as
// "Called when...", so the delegate takes a NavMeshAgent — but the MCP
// bridge was down when this was written and it has NOT been compiled. If
// the build complains here, this is the line, and the fix is the delegate
// shape, not the logic below it.
// ⚠️ THE SNAPPED ENDS ARE CAPTURED, NOT THE SPOT'S. The crossing animation moves the body to
// one of these points, and the link now lives at the snapped positions — handing the handler
// spot.A/spot.B would land the zombie somewhere the link does not go, which is the stumble
// TickLinkCross's own comment warns about, reintroduced by the snap.
var endA = a;
var endB = b;
// ⚠️ CAPTURED AS A VALUE, NOT AS `spot.Walk`. The handler outlives this call and the config
// list can be edited underneath it; holding the spot would mean a link built one way and
// behaving another after the next edit, without a rebuild.
var walk = spot.Walk;
link.LinkEntered += agent => OnAgentEnteredLink( agent, endA, endB, walk );
link.LinkExited += OnAgentExitedLink;
BuildMarker( go, spot );
_links.Add( go );
}
/// <summary>Show the markers as solid geometry. Off hides them entirely.</summary>
public static bool ShowMarkers { get; set; } = true;
/// <summary>
/// A VISIBLE marker for the link — real ModelRenderers, not DebugOverlay.
///
/// ⛔ DEBUG DRAWS COULD NOT BE RELIED ON. They vanish the moment the knife
/// creates its viewmodel camera (Priority 2, RenderTags { ViewModel, Light }),
/// they only run in Creative, and they are reissued per frame by whatever
/// happens to be alive. Every one of those cost a session this week. A
/// ModelRenderer is drawn by the renderer like anything else in the map — no
/// camera to lose, no per-frame reissue, nothing to gate it.
///
/// ⚠️ Tagged so the nav generator ignores them — a marker that carves a hole
/// in the navmesh beside the link it describes would be a genuinely nasty bug.
/// </summary>
void BuildMarker( GameObject parent, NavLinkSpot spot )
{
if ( !ShowMarkers ) return;
// ⛔️ AUTHORING ONLY. These are aids -- solid boxes sitting in mid-air over every ledge the
// map has a link on -- and a player must never see them. ModelRenderers were chosen over
// DebugOverlay precisely BECAUSE debug draws only run in Creative and kept vanishing; that
// fix worked and took the Creative-only behaviour away with it, which nobody asked for.
//
// ⚠️ ShowAuthoringVisuals, NOT IsCreative. Preview mode exists to judge a map as a player
// sees it WITHOUT leaving Creative, and these were the one marker it failed to hide.
//
// ⛔️ BUT THE GUARD ALONE IS NOT ENOUGH, and that is the real trap here. Every other marker
// is a DebugOverlay draw re-issued each frame, so it stops appearing the instant its condition
// goes false. These are real GameObjects: they persist until something DESTROYS them, and only
// Rebuild does. So both flags that can hide them must also rebuild -- see NZGame.PreviewMode
// and RoundManager.StartGame. Without those two calls this line governs only markers built
// from that moment on, and every one already standing stays standing.
//
// ⚠️ THE LINK ITSELF IS STILL BUILT. Only the marker is skipped, so pathing is completely
// unaffected -- this is a rendering change and nothing else.
if ( !NZGame.ShowAuthoringVisuals ) return;
var box = Model.Load( "models/dev/box.vmdl" );
if ( box is null ) return;
var size = box.Bounds.Size;
var up = spot.B.z > spot.A.z + 8f;
// Cyan for a climb, amber for a fall — the same split the two toggles use.
var col = up ? new Color( 0.2f, 0.95f, 0.95f ) : new Color( 0.95f, 0.6f, 0.2f );
// Both ends, plus a thin bar along the course so the pairing is obvious
// from across a room.
Cube( parent, spot.A - spot.A, new Vector3( 12f ), col, size );
Cube( parent, spot.B - spot.A, new Vector3( 12f ), col, size );
var mid = (spot.B - spot.A) * 0.5f;
var len = spot.A.Distance( spot.B );
var bar = Cube( parent, mid, new Vector3( len, 3f, 3f ), col, size );
if ( bar.IsValid() && len > 1f )
bar.WorldRotation = Rotation.LookAt( (spot.B - spot.A).Normal, Vector3.Up );
}
GameObject Cube( GameObject parent, Vector3 local, Vector3 want, Color col, Vector3 box )
{
var go = Scene.CreateObject();
go.SetParent( parent );
// ⚠️ Local transform set EXPLICITLY, rotation included. SetParent keeps the
// child's WORLD transform, so an unset LocalRotation cancels the parent's —
// the exact bug that left every barricade wall axis-aligned.
go.LocalPosition = local;
go.LocalRotation = Rotation.Identity;
go.LocalScale = new Vector3(
box.x > 0.01f ? want.x / box.x : 1f,
box.y > 0.01f ? want.y / box.y : 1f,
box.z > 0.01f ? want.z / box.z : 1f );
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
// ⛔ The navmesh is generated FROM COLLISION and regenerated whenever a
// barrier is built. These have no collider, but the tag is belt and braces
// — InvisibleWallManager learned that one the hard way.
go.Tags.Add( "nz_nav_ignore" );
var r = go.Components.Create<ModelRenderer>();
r.Model = Model.Load( "models/dev/box.vmdl" );
r.Tint = col;
return go;
}
/// <summary>
/// An agent has stepped onto a link — hand the crossing to its ZombieAI.
///
/// ⚠️ The link's own ends are used, NOT the agent's position. A crowd funnels
/// into a link across the whole ConnectionRadius, so where a given zombie
/// happens to be standing is up to 48u off the line it should travel.
/// </summary>
static void OnAgentEnteredLink( NavMeshAgent agent, Vector3 endA, Vector3 endB, bool walk )
{
if ( !agent.IsValid() ) return;
var z = agent.Components.Get<ZombieAI>( FindMode.EverythingInSelf );
if ( !z.IsValid() ) return;
// ⛔ THE PATHFINDER HAS TO HAVE ASKED FOR THIS. `LinkEntered` says an agent is inside the
// link's ConnectionRadius; it does not by itself say the link is on the agent's route. A
// zombie walking PAST a ledge on its way somewhere else is inside that radius for a few
// frames, and without this it gets picked up and thrown over the edge — a traversal nobody
// asked for, which then costs it the 1s TraverseCooldown as well.
//
// `IsTraversingLink` is the engine's own statement that it parked this agent in the link as
// part of its corridor. Asking it is not a heuristic; it is the difference between "the
// route goes through here" and "somebody stood near it".
//
// ⛔ NOT A DISTANCE-OR-PROGRESS TEST, DELIBERATELY. The obvious alternative — refuse unless
// the far end is closer to the target — is exactly the shape of the vertical gate, whose own
// remarks record why it was turned off: it is a PROXY that refuses genuinely-needed links
// whenever the geometry does not agree with straight-line distance, and its failure mode is
// a zombie frozen with no route at all. A wrong-but-legal crossing is a cost problem
// (`nz_nav_cost`); a refused necessary crossing is a stuck zombie.
// ⚠️ Direction matters and the agent may be going either way on a
// two-way link. Enter from whichever end it is nearer.
//
// ⚠️ WORKED OUT BEFORE THE REFUSAL, not after, so a refused row records which way the
// zombie was ABOUT to go. A refused entry with no direction on it cannot answer whether the
// crossing would have helped, which is the column that separates "radius too wide" from
// "link mispriced".
var pos = agent.WorldPosition;
var forward = pos.Distance( endA ) <= pos.Distance( endB );
var from = forward ? endA : endB;
var to = forward ? endB : endA;
var link = agent.Components.Get<NavMeshLink>( FindMode.EverythingInSelf );
var wanted = agent.IsTraversingLink;
NavLinkLog.Record( wanted, z, pos, from, to,
link?.Area?.ResourceName, link?.ConnectionRadius ?? 0f );
if ( !wanted ) return;
z.BeginLinkCross( from, to, walk );
}
/// <summary>Agent has left a link. The crossing ends itself when its clip
/// finishes, so this only clears the gate's guard.</summary>
static void OnAgentExitedLink( NavMeshAgent agent )
{
if ( !agent.IsValid() ) return;
var z = agent.Components.Get<ZombieAI>( FindMode.EverythingInSelf );
if ( z.IsValid() ) z.OnLink = false;
}
/// <summary>
/// DROP ZONE — one click on a ledge, and this works out where they land.
///
/// ⚠️ THE POINT OF THE ONE-CLICK VERSION. A hand-authored link needs both ends
/// ON the navmesh, and clicking the lower one by eye is exactly the mistake
/// that produces a link which draws, lists, validates as "placed" and is never
/// used — the failure `nz_navlink_test` exists to catch. Finding the landing
/// by tracing, then SNAPPING BOTH ENDS to the mesh, removes the chance to get
/// it wrong rather than reporting it afterwards.
///
/// How it finds the edge: probe outward in a ring from where you stand and
/// trace down from each probe. The direction with the biggest usable fall is
/// the way off the ledge. That is why you stand ON the ledge and click once
/// rather than aiming at where you want them to land.
/// </summary>
/// <param name="from">On the ledge, near its edge.</param>
/// <param name="radius">Width of the zone — how much of the edge is usable.</param>
/// <param name="reach">How far past the edge to look for open air.</param>
/// <returns>The spot, or null with the reason logged.</returns>
public static NavLinkSpot BuildDrop( Vector3 from, float radius = 48f, float reach = 56f,
bool walk = false )
{
var scene = Game.ActiveScene;
var nav = scene?.NavMesh;
if ( nav is null ) { Log.Warning( "[nz-drop] no navmesh" ); return null; }
// ⚠️ SNAP THE TOP END FIRST. Standing near an edge routinely puts you a
// few units past where the mesh actually stops — Recast insets the walkable
// surface from a ledge by the agent radius. An unsnapped top end sits in
// mid-air and the link never connects.
var topOn = nav.GetClosestPoint( from );
if ( topOn is null ) { Log.Warning( "[nz-drop] you are not on the navmesh" ); return null; }
var top = topOn.Value;
Vector3? best = null;
float bestFall = 0f;
// 12 probes at 30° — enough to find an edge you are standing square to or
// diagonally against, cheap enough to run on a click.
for ( int i = 0; i < 12; i++ )
{
var yaw = i * 30f;
var dir = Rotation.FromYaw( yaw ).Forward;
var probe = top + dir * reach;
// From slightly ABOVE, so a probe that lands inside a railing or a
// step still reads the floor rather than the obstruction's side.
var tr = scene.Trace.Ray( probe + Vector3.Up * 24f,
probe + Vector3.Down * 4096f ).Run();
if ( !tr.Hit ) continue;
var fall = top.z - tr.HitPosition.z;
// ⚠️ MINIMUM 40u. Below that it is a step or a slope, and the mesh
// already connects it — adding a link there is noise that competes
// with a perfectly good walkable route.
if ( fall < 40f || fall <= bestFall ) continue;
bestFall = fall;
best = tr.HitPosition;
}
if ( best is null )
{
Log.Warning( "[nz-drop] no drop found around you — stand ON the ledge, "
+ "near its edge. Nothing within 56u falls more than 40u from here." );
return null;
}
// And snap the landing too, for the same reason as the top.
var landOn = nav.GetClosestPoint( best.Value );
if ( landOn is null )
{
Log.Warning( "[nz-drop] found a ledge but the floor below has no navmesh — "
+ "zombies would arrive nowhere. Check nz_nav down there." );
return null;
}
var land = landOn.Value;
if ( land.Distance( best.Value ) > 96f )
{
Log.Warning( $"[nz-drop] the floor below is {land.Distance( best.Value ):0}u "
+ "from the nearest navmesh — that landing is not walkable." );
return null;
}
var spot = new NavLinkSpot
{
A = top,
B = land,
BiDirectional = false, // a drop is one-way by definition
Radius = MathF.Max( 8f, radius ),
Walk = walk,
};
ActiveConfig.Current.NavLinks.Add( spot );
Ensure( scene )?.Rebuild();
Log.Info( $"[nz-drop] drop zone placed — falls {spot.Fall:0}u, "
+ $"{spot.Radius:0}u wide{( spot.Walk ? ", WALK (no drop clip)" : "" )}"
+ $" ({ActiveConfig.Current.NavLinks.Count} links total)" );
return spot;
}
/// <summary>
/// Redraw the sampled navmesh dots.
///
/// ⛔ HERE, ON A COMPONENT THAT ALWAYS EXISTS, because the sample has to be
/// reissued EVERY FRAME — the debug overlay's own duration argument did not
/// hold them (see NavCommands.NavDotPoints). This manager is ensured by
/// NZGame.ShowConfig, so it is alive in both Creative and a round, which is
/// exactly when someone is looking at navmesh coverage.
/// </summary>
/// <summary>Set once the redraw has thrown, so it reports in full and then
/// stops rather than spamming a summary line hundreds of times.</summary>
static bool _dotsBroken;
/// <summary>
/// ⚠️ WRAPPED, AND THE CATCH IS THE POINT. The engine reports this as
/// "Exception when calling 'Update' on NZombies.NavLinkManager" with no line
/// and no message, repeated every frame — 430 times, then 806. That tells you
/// something failed and nothing about what. Catching it here turns one opaque
/// engine line into the exception's own type, message and stack, ONCE, and
/// then disables the redraw so it neither spams nor keeps breaking the rest of
/// this component's update.
/// </summary>
protected override void OnUpdate()
{
if ( _dotsBroken ) return;
try
{
DrawDots();
}
catch ( Exception e )
{
_dotsBroken = true;
NavCommands.NavDotPoints = Array.Empty<Vector3>();
Log.Error( $"[nz-dots] redraw threw — {e.GetType().Name}: {e.Message}" );
Log.Error( $"[nz-dots] {e.StackTrace}" );
Log.Warning( "[nz-dots] redraw DISABLED for this session. nz_nav_dots to "
+ "re-enable once the cause above is fixed." );
}
}
void DrawDots()
{
// ⚠️ ONE READ into a local. The field can be replaced at any time by
// nz_nav_dots; taking the reference once means this frame draws a single
// consistent sample rather than indexing whatever is current per element.
var pts = NavCommands.NavDotPoints;
var grid = NavCommands.NavGridLines;
if ( (pts is null || pts.Length == 0) && (grid is null || grid.Length < 2) ) return;
pts ??= Array.Empty<Vector3>();
grid ??= Array.Empty<Vector3>();
// ⚠️ EVERY EXIT SAYS WHY. The dots vanish on V and neither the noclip code
// (physics only) nor any V binding (there is exactly one) explains it —
// so rather than guess again, each way out of this method is logged once.
// Whichever line appears on the next V press IS the cause.
if ( NavCommands.NavDotsUntil <= 0f )
{
Log.Info( $"[nz-dots] cleared — timer expired ({pts.Length} points, "
+ $"{grid.Length / 2} segments dropped)" );
NavCommands.NavDotPoints = Array.Empty<Vector3>();
NavCommands.NavGridLines = Array.Empty<Vector3>();
return;
}
var d = Scene.DebugOverlay;
if ( d is null )
{
if ( !_warnedNoOverlay )
{
_warnedNoOverlay = true;
Log.Warning( "[nz-dots] Scene.DebugOverlay is NULL — nothing can draw. "
+ "The points are still held; this is a render-side loss." );
}
return;
}
_warnedNoOverlay = false;
var col = new Color( 0.2f, 0.8f, 1f, 1f );
// ⚠️ CAPPED PER FRAME. `nz_nav_dots` samples 1500 by default and this ran
// every one of them through the overlay every frame — which threw, 430
// times, and a throwing OnUpdate stops redrawing, which is what "they
// disappear" actually was. A debug overlay is not a renderer and there is
// no documented budget for it; staying well under any plausible one is
// cheaper than finding out where it is.
//
// ⚠️ The FULL sample is still held — only the draw is capped, so raising
// the count still improves the sample even if not all of it is shown.
int n = Math.Min( pts.Length, MaxDotsDrawn );
for ( int i = 0; i < n; i++ )
d.Sphere( new Sphere( pts[i], 3f ), col, 0f, global::Transform.Zero, false );
// ⚠️ Pairs, and the loop is bounded so a ragged array cannot overrun —
// an IndexOutOfRange in here already cost a session once.
var gridCol = new Color( 0.25f, 1f, 0.45f, 1f );
int segs = Math.Min( grid.Length / 2, MaxDotsDrawn * 4 );
for ( int i = 0; i < segs; i++ )
d.Line( grid[i * 2], grid[i * 2 + 1], gridCol, 0f,
global::Transform.Zero, false );
}
/// <summary>The link nearest a point, or null. For the remove tool.</summary>
public static NavLinkSpot Nearest( Vector3 at, float within = 256f )
{
NavLinkSpot best = null;
float bestDist = within;
foreach ( var l in ActiveConfig.Current.NavLinks )
{
// Distance to the NEARER END, not to the midpoint — a long drop's
// middle is in mid-air, where nobody is ever standing to delete it.
var d = MathF.Min( at.Distance( l.A ), at.Distance( l.B ) );
if ( d >= bestDist ) continue;
bestDist = d;
best = l;
}
return best;
}
}