Zombies/WalkerTraverse.cs

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.

File Access
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",
	};
}