Static helper for navmesh handling per map. It checks whether the live scene NavMesh matches the current map and agent settings, applies agent settings from the active map config, marks the navmesh dirty to regenerate, and exposes console commands to inspect and modify agent settings and force regeneration.
using Sandbox;
using Sandbox.Navigation;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// A NAV MESH PER MAP — generated, because baking one is not reachable from game code.
///
/// ⛔ THE SCENE'S BakedDataPath WAS ACTIVELY WRONG AND THIS REPLACES IT. `nzombies.scene` carried
/// `BakedDataPath = scenes/countdown_scene_data/navmesh/baked.navdata` — countdown's mesh, hardcoded
/// into the gamemode scene. It is applied at SCENE LOAD, so starting the game on any other map
/// loaded countdown's nav mesh and zombies pathed against geometry from a different map. Switching
/// maps mid-session happened to correct itself because NZMap calls SetDirty(); starting directly on
/// a map did not, and nothing reported which of the two had happened.
///
/// ⛔ AND PER-MAP BAKED FILES ARE NOT POSSIBLE. Two things block it, both found by trying:
/// • `NavMesh.BakeDataToBytes` and `NavMesh.LoadFromBakedData` appear in the engine's XML docs and
/// are INTERNAL — game code cannot call either. Same as SoundHandle.CopyActiveUnfiltered.
/// • `BakedDataPath` is a SCENE property and there is only one scene, so it could only ever name
/// one map's mesh. A map scene's own NavMesh settings are inert: MapInstance loads the map as
/// CHILDREN of the active scene, so the active scene's mesh is the only one that exists.
///
/// ⚠️ SO "PER MAP" MEANS GENERATED PER MAP, and that is a real answer rather than a consolation. It
/// is what canyon has always done; the bug was never that generation was missing, it was that a
/// stale baked file was being loaded over the top of it.
/// </summary>
public static class NavBake
{
/// <summary>The map the live mesh was last generated for, or "" if unknown.</summary>
public static string GeneratedFor { get; private set; } = "";
/// <summary>
/// Regenerate only if the live mesh is not already this map's.
///
/// ⛔ THE COLD-START HOOK, AND IT HAS TO BE IDEMPOTENT. NZMap.Load only runs on a map SWITCH, so
/// starting the game directly on a map never regenerated anything — survivable before only
/// because the scene's baked path was masking it. This is called from NZGame.ShowConfig, which
/// runs on every mode change and config load, so it must cost nothing when already correct:
/// SetDirty on every call would keep a big map permanently rebuilding.
/// </summary>
public static void EnsureFor( string map )
{
// ⛔ THE MAP BEING RIGHT IS NOT ENOUGH — THE AGENT HAS TO MATCH TOO. A map loads its geometry
// first and its config second, so the mesh is generated once with whatever agent the scene
// happens to carry and the config arrives afterwards. Keyed on the map name alone this
// returned early at exactly that moment, and `nz_nav_agent` reported the config asking for
// step 32 next to a live mesh still built at 24 — the settings looked applied and were not.
//
// ⚠️ COMPARING AGAINST THE LIVE MESH, NOT AGAINST A REMEMBERED COPY. The mesh is the thing
// that has to be right, and `nz_nav_agent` can change it underneath us mid-session; a cached
// "last applied" would agree with itself and disagree with the game.
if ( GeneratedFor == (map ?? "") && !string.IsNullOrEmpty( GeneratedFor )
&& !AgentDiffers( Game.ActiveScene?.NavMesh, ActiveConfig.Current?.Nav ) ) return;
Apply( map );
}
/// <summary>Does the live mesh already have what this map asked for? Zero fields are ignored,
/// matching <see cref="NavSettings"/>'s "leave the scene's value alone".</summary>
static bool AgentDiffers( NavMesh nav, NavSettings s )
{
if ( nav is null || s is null || !s.IsSet ) return false;
return (s.StepSize > 0 && nav.AgentStepSize != s.StepSize)
|| (s.MaxSlope > 0f && !nav.AgentMaxSlope.AlmostEqual( s.MaxSlope ))
|| (s.AgentRadius > 0 && nav.AgentRadius != s.AgentRadius)
|| (s.AgentHeight > 0 && nav.AgentHeight != s.AgentHeight);
}
/// <summary>
/// Regenerate the nav mesh for a map, unconditionally.
///
/// ⛔ IT MUST NEVER SILENTLY DO NOTHING. A nav mesh belonging to the previous map looks exactly
/// like a working one until a zombie walks through a wall, so every branch logs.
/// </summary>
public static void Apply( string map )
{
var nav = Game.ActiveScene?.NavMesh;
if ( nav is null )
{
Log.Warning( "[nz-nav] scene has no NavMesh — zombies will not path."
+ " Enable it in the scene's properties." );
return;
}
if ( !nav.IsEnabled )
{
Log.Warning( "[nz-nav] the scene's NavMesh is DISABLED — nothing will generate" );
return;
}
// ⛔ THE AGENT IS SET BEFORE THE REGENERATE, NOT AFTER. These parameters are baked INTO the
// mesh by Recast — they decide which surfaces get linked at all — so changing them on a mesh
// that already exists does nothing until something marks it dirty. Setting them after
// `SetDirty()` would land halfway through a generation that is already using the old ones.
var applied = ApplyAgent( nav, ActiveConfig.Current?.Nav );
nav.SetDirty();
GeneratedFor = map ?? "";
Log.Info( $"[nz-nav] regenerating for '{GeneratedFor}' — ready over the next few frames"
+ " (nz_nav_state to check)" );
if ( applied ) Log.Info( $"[nz-nav] agent from the map config: {ActiveConfig.Current.Nav.Describe()}" );
}
/// <summary>
/// Push a map's agent settings onto the live mesh. Returns true if anything was changed.
///
/// ⚠️ EACH FIELD IS INDEPENDENT AND ZERO MEANS "DO NOT TOUCH". A map that only wants a taller
/// step must not silently inherit this file's idea of a radius — see <see cref="NavSettings"/>.
/// </summary>
public static bool ApplyAgent( NavMesh nav, NavSettings s )
{
if ( nav is null || s is null || !s.IsSet ) return false;
if ( s.StepSize > 0 ) nav.AgentStepSize = s.StepSize;
if ( s.MaxSlope > 0f ) nav.AgentMaxSlope = s.MaxSlope;
if ( s.AgentRadius > 0 ) nav.AgentRadius = s.AgentRadius;
if ( s.AgentHeight > 0 ) nav.AgentHeight = s.AgentHeight;
return true;
}
/// <summary>
/// `nz_nav_agent` reports; `nz_nav_agent <step> [slope] [radius] [height]` sets and REGENERATES.
///
/// ⛔ THIS IS THE ONLY PRACTICAL WAY TO FIND THE RIGHT NUMBER. "Can a zombie get onto that
/// platform" is a question about one piece of geometry, and the answer is a height nobody can
/// read off a map — so stand at the platform, raise the step until they walk up, and write that
/// number into the map config. Guessing it from the editor is how you end up with zombies
/// climbing crates.
///
/// ⚠️ IT DOES NOT PERSIST. It edits the live config in memory so the regenerate picks it up;
/// save the map config to keep it.
///
/// ⚠️ 0 FOR ANY ARGUMENT LEAVES THAT ONE ALONE, matching the config's own convention.
/// </summary>
[ConCmd( "nz_nav_agent" )]
public static void Agent( int step = 0, float slope = 0f, int radius = 0, int height = 0 )
{
var nav = Game.ActiveScene?.NavMesh;
if ( nav is null ) { Log.Warning( "[nz-nav] scene has no NavMesh" ); return; }
var cfg = ActiveConfig.Current;
if ( step > 0 || slope > 0f || radius > 0 || height > 0 )
{
if ( cfg is null ) { Log.Warning( "[nz-nav] no active map config to write to" ); return; }
if ( step > 0 ) cfg.Nav.StepSize = step;
if ( slope > 0f ) cfg.Nav.MaxSlope = slope;
if ( radius > 0 ) cfg.Nav.AgentRadius = radius;
if ( height > 0 ) cfg.Nav.AgentHeight = height;
ApplyAgent( nav, cfg.Nav );
// ⚠️ FORCED, NOT `EnsureFor`. The mesh already belongs to this map, so the idempotent
// path would decide there was nothing to do and the new numbers would never be baked.
nav.SetDirty();
Log.Info( "[nz-nav] regenerating with the new agent — give it a few frames" );
}
Log.Info( $"[nz-nav] live agent: step {nav.AgentStepSize} slope {nav.AgentMaxSlope:0.#}"
+ $" radius {nav.AgentRadius} height {nav.AgentHeight}" );
Log.Info( $"[nz-nav] map config asks for: {( cfg?.Nav?.Describe() ?? "(no config)" )}" );
Log.Info( "[nz-nav] player for comparison: step 18, ground angle 45 — zombies past ~32"
+ " climb things you cannot" );
}
/// <summary>
/// `nz_nav_state` — is the live mesh the RIGHT one for the map that is loaded?
///
/// ⛔ THE QUESTION NOTHING COULD ANSWER BEFORE. "Does the map have a nav mesh" is not the useful
/// question — "does the mesh belong to THIS map" is, and with a hardcoded baked path the answer
/// was silently no on every map but one.
/// </summary>
[ConCmd( "nz_nav_state" )]
public static void State()
{
var nav = Game.ActiveScene?.NavMesh;
var map = NZMap.Current;
if ( nav is null ) { Log.Warning( $"[nz-nav] map '{map}': scene has NO NavMesh" ); return; }
Log.Info( $"[nz-nav] map '{map}'" );
Log.Info( $"[nz-nav] enabled {nav.IsEnabled} dirty {nav.IsDirty}"
+ $" generating {nav.IsGenerating}" );
Log.Info( $"[nz-nav] agent: height {nav.AgentHeight} radius {nav.AgentRadius}"
+ $" step {nav.AgentStepSize} max slope {nav.AgentMaxSlope}" );
// ⚠️ THE MISMATCH LINE IS THE POINT OF THIS COMMAND.
if ( string.IsNullOrEmpty( GeneratedFor ) )
Log.Warning( "[nz-nav] ! generated for: UNKNOWN — the mesh came from the scene's own"
+ " settings, not from a map load. nz_nav_regen to be sure." );
else if ( GeneratedFor != map )
Log.Warning( $"[nz-nav] ! generated for '{GeneratedFor}' but the map is '{map}'"
+ " — WRONG MESH. nz_nav_regen." );
else
Log.Info( $"[nz-nav] generated for this map — correct" );
}
/// <summary>`nz_nav_regen` — force a regeneration for the map that is actually loaded.</summary>
[ConCmd( "nz_nav_regen" )]
public static void Regen() => Apply( NZMap.Current );
}