Static helper for walker traversal animations. It maps authored climb heights to animation clip names, selects nearest clip for a requested height, can force a specific clip, computes playback rate to stretch a chosen clip to cover an actual climb height, and exposes a dropdown clip list for falls.
using System;
using System.Collections.Generic;
namespace NZombies;
/// <summary>
/// Climb and drop clips, for crossing a NavMeshLink.
///
/// ⛔ A SEPARATE FILE FROM WalkerAnimations ON PURPOSE. That one is GENERATED by
/// Tools/build_walker_vmdl.py and says so at the top — anything hand-added there
/// is silently lost the next time the walker model is rebuilt, which is a change
/// that vanishes rather than one that breaks.
/// </summary>
public static class WalkerTraverse
{
/// <summary>
/// Climbs, keyed by the height they were AUTHORED for.
///
/// ⚠️ AUTHORED HEIGHTS, NOT A RANGE. Each was made for one specific rise, so
/// the nearest is picked and its playback rate stretched to cover the real
/// height — the same trick the mantle already uses for obstacle heights.
///
/// ⛔ THE `alcove_traverse_*` AND `trav_run_jump_up_128` CLIPS WERE HERE AND
/// ARE GONE. They are MANTLES — a climb up and over with a lot of authored
/// vertical travel in the root — and the walker has NO motion extraction on
/// any clip (checked: 283 AnimFile nodes, 0 ExtractMotion). So the clip's own
/// rise moved the mesh ON TOP of the lerp already raising the object, and the
/// zombie ended up well above the ledge it was climbing onto.
///
/// ⚠️ Extraction cannot simply be switched on: locomotion DEPENDS on the root
/// travel being present — playback rate is matched to the clip's authored
/// ground speed (see ApplyGroundSpeed). Stripping it globally would fix this
/// and break walking. Choosing clips that barely translate is the fix that
/// costs nothing elsewhere.
///
/// ⚠️ These are the JUMP clips, which is also what was asked for: the launch
/// and the landing, not a mantle.
///
/// ⚠️ THE SET ALSO CONTAINS `nz_base_zombie_jump_up_start` / `_loop` /
/// `_finish`, WHICH IS THE PROPER ANSWER FOR ARBITRARY HEIGHTS: hold the loop
/// while the body rises, then play the finish onto the ledge. That needs a
/// three-phase state machine and a way to know when to leave the loop. This
/// table is the single-clip version, reusing the PlayAction path the barricade
/// vault already proves. Move to the loop rig once crossings are known good —
/// the clips are imported and waiting.
/// </summary>
public static readonly (float Height, string Clip)[] ClimbUp =
{
(48f, "nz_base_zombie_jump_up_small_finish"),
(96f, "nz_base_zombie_jump_up_start"),
(128f, "nz_base_zombie_jump_up_finish"),
};
/// <summary>Force one clip for every climb, for trying them without a rebuild.
/// Blank uses the table. See `nz_cross_clip`.</summary>
public static string ForcedClimbClip { get; set; } = "";
/// <summary>The authored entry nearest a real height.</summary>
static (float Height, string Clip) Nearest( float height )
{
var best = ClimbUp[0];
var bestGap = MathF.Abs( height - best.Height );
foreach ( var c in ClimbUp )
{
var gap = MathF.Abs( height - c.Height );
if ( gap >= bestGap ) continue;
bestGap = gap;
best = c;
}
return best;
}
/// <summary>Clip list for a climb of this height. A list of one, because
/// PlayAction takes a list and picks at random from it.</summary>
public static List<string> ClimbForHeight( float height )
=> new() { string.IsNullOrWhiteSpace( ForcedClimbClip )
? Nearest( height ).Clip
: ForcedClimbClip };
/// <summary>
/// Playback rate so the chosen clip covers the ACTUAL height.
///
/// ⚠️ A rate BELOW 1 makes a short clip cover a tall climb by playing slower,
/// which is the right way round: the body has further to travel in the same
/// authored motion. Clamped so a wildly mismatched height cannot freeze the
/// clip or run it into a blur.
/// </summary>
public static float ClimbRate( float height )
{
// ⛔ A FORCED CLIP IS NOT THE CLIP THE RATE WAS COMPUTED FOR. The stretch
// below assumes the clip picked by height; force a different one and it is
// still stretched by the ORIGINAL entry's ratio, so a short clip covering a
// tall climb plays in slow motion. Reported as "the landing part always
// takes too long" — the tail is simply where a uniformly slowed clip is
// most visible. A forced clip plays at its authored speed.
if ( !string.IsNullOrWhiteSpace( ForcedClimbClip ) ) return 1f;
var authored = Nearest( height ).Height;
if ( authored < 1f || height < 1f ) return 1f;
// ⚠️ FLOOR RAISED FROM 0.35 TO 0.6. At 0.35 a clip runs at a third of its
// authored speed, which does not read as a bigger climb — it reads as a
// zombie in treacle, and the LANDING is where that is most obvious because
// it is the part with the least vertical motion to distract from it.
return Math.Clamp( authored / height, 0.6f, 2f );
}
/// <summary>
/// Dropping off a ledge. One clip at any height.
///
/// ⚠️ Unlike a climb, a fall reads the same whether it is 60u or 200u — the
/// body is passive either way — so there is nothing to pick between and no
/// rate to stretch.
/// </summary>
public static readonly List<string> DropDown = new()
{
"nz_base_zombie_jump_down",
};
}