Component that implements a sprint-into-crouch slide for players. It starts, ticks, chains (Banana Colada perk), and stops slides, adjusts controller values, plays surface-dependent sounds, exposes console commands and an OnSlideStart hook for perks.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// Sprint-into-crouch slide.
///
/// ⛔ THE COMMENT HERE USED TO SAY GMOD nZOMBIES HAD NO SLIDE. That was WRONG —
/// `gamemode/sliding/` is a full subsystem (4 files, 17KB). The claim came from a
/// truncated grep, not from the source. See Docs/SLIDING.md.
///
/// ⚠️ ORIGINAL VALUES: slidespeed **1.3**, duration **0.6s**, cooldown **0.4s**
/// (map settings). Ours are 1.45 / 0.85 / 0.55 — same shape, slower and longer,
/// and worth retuning toward the original.
///
/// ⚠️ STILL MISSING vs the original: slide stamina, per-player slide speed, the
/// BO3/BO4 difficulty toggles, the `wos_CSlide` animation and surface sounds.
///
/// ⚠️ Exists mainly so PERKS can hang off it — Banana Colada trails a slide and
/// PhD Flopper detonates out of one. `IsSliding` and `OnSlideStart` are the hooks
/// those will use.
/// </summary>
public sealed class Slide : Component
{
// ── tuning ───────────────────────────────────────────────────────────────
// ⚠️ STATIC, so the console commands can tune them live for every player at
// once. Statics that start EMPTY or wrong do not survive a hotload — these
// carry their defaults inline, which does.
/// <summary>Slide at all.
///
/// ⚠️ NOT `Enabled` — that is `Component.Enabled`, and shadowing it means
/// "turn the slide off" and "disable the component" become the same word with
/// two different meanings.</summary>
public static bool SlideEnabled { get; set; } = true;
// ⛔ READ FROM THE CONFIG, NOT HELD AS STATICS. They were statics while the
// slide was being proven; keeping them there would mean a mapper's saved
// settings and the live values could disagree, with the console silently
// winning. Player Settings is the single source now and the commands below
// write THROUGH to it.
/// <summary>Launch speed as a multiple of run speed.</summary>
public static float SpeedMultiplier
{
get => ActiveConfig.Player.SlideSpeed;
set => ActiveConfig.Player.SlideSpeed = value;
}
/// <summary>How long a slide lasts, seconds.</summary>
public static float Duration
{
get => ActiveConfig.Player.SlideDuration;
set => ActiveConfig.Player.SlideDuration = value;
}
/// <summary>Wait before another slide, seconds.</summary>
public static float Cooldown
{
get => ActiveConfig.Player.SlideCooldown;
set => ActiveConfig.Player.SlideCooldown = value;
}
/// <summary>Fraction of run speed you must already be doing to start one.</summary>
public static float MinSpeedFraction { get; set; } = 0.7f;
// ── state ────────────────────────────────────────────────────────────────
public bool IsSliding { get; private set; }
/// <summary>
/// How fast this player is going under their OWN power right now, horizontally: the slide's launch speed while one runs and for
/// half a second after (the brakes come back over a few frames), the stashed chain speed while a Banana Colada chain is armed in
/// the air, else 0 (nothing beyond running).
///
/// ⚠️ FOR ShoveGuard (2026-10-03), so a boosted slide is never taken for an ejection. Stamin-Up's Slide Boost with Banana Colada's
/// coasting launches at 1,001 u/s, over that guard's 1,000 ceiling.
/// </summary>
public float OwnSpeed
=> IsSliding || _sinceEnd < OwnSpeedGrace ? _launchSpeed
: _chainUntil > 0f ? _chainSpeed
: 0f;
/// <summary>Seconds after a slide ends that its speed still counts as the player's own (see `OwnSpeed`).</summary>
const float OwnSpeedGrace = 0.5f;
/// <summary>Fires when a slide begins — (player, direction). For the perks.</summary>
public static Action<NZPlayer, Vector3> OnSlideStart { get; set; }
TimeSince _sinceStart;
TimeSince _sinceEnd;
Vector3 _dir;
float _launchSpeed;
/// <summary>
/// The effective duration of the slide in progress. Captured at Start rather than
/// read per frame, so Banana Colada's multiplier cannot change under a slide that
/// is already running — which would make the ease-out jump.
/// </summary>
float _duration;
/// <summary>True while this slide should coast at launch speed instead of decaying.</summary>
bool _keepMomentum;
// ── BANANA COLADA'S CHAIN ────────────────────────────────────────────────
/// <summary>
/// Speed and direction stashed when a slide left the ground, so a crouch in mid-air
/// can resume it on landing.
///
/// ⚠️ STASHED AT TAKEOFF, NOT READ ON LANDING. Reading velocity when you touch down
/// would hand back whatever the fall did to it — air drag, a wall clip, a slope — and
/// the chain would lose speed for reasons the player cannot see. Remembering the
/// number is what makes "maintains all momentum" true rather than approximately true.
/// </summary>
Vector3 _chainDir;
float _chainSpeed;
/// <summary>Runs from the moment a chainable slide left the ground.</summary>
TimeUntil _chainUntil;
/// <summary>Set while crouch is held airborne; consumed on landing.</summary>
bool _chainQueued;
/// <summary>
/// This slide came from `nz_slide go` / `nz_slide back` rather than from input.
///
/// ⛔ EXISTS ONLY TO EXEMPT THE CONSOLE FROM THE RELEASE CHECK. A forced slide is
/// started by someone who is not touching the keyboard, so "crouch is not held" is true
/// on its very first frame — without this the two diagnostic commands would stop one
/// frame after starting and report zero travel, which is exactly the symptom they exist
/// to rule out.
/// </summary>
bool _forced;
/// <summary>
/// Why the last slide ended, for the Stop log.
///
/// ⚠️ AN INSTRUMENT FOR THE CHAIN SPECIFICALLY. "The chain does not work" has three
/// causes that look identical in play — the slide ran its timer out (nothing to chain),
/// the player never left the ground (the controller refused the jump), or the crouch
/// press missed the window. Only the first two are visible from here, and printing
/// which one happened is the difference between one test and five.
/// </summary>
string _endReason = "";
Vector3 _startPos;
float _savedDucked;
float _savedFriction;
float _savedBrake;
PlayerController _c;
NZPlayer _nz;
protected override void OnUpdate()
{
// ⛔ MY BODY ONLY (2026-10-05). A slide is begun by THIS machine's crouch key, and this ran on every body in the scene, so
// a crouch also slid any teammate who happened to be running, on this screen alone. Pu4DinFL7's log (2026-10-05 00:38)
// ends two slides on the same frame ten times: one press, two bodies. Everything that reads `IsSliding` asks about its
// own body (ShoveGuard, the weapon tech), and each machine slides its own player.
if ( !PlayerPresence.Mine( GameObject ) ) return;
_c ??= Components.Get<PlayerController>();
_nz ??= Components.Get<NZPlayer>();
if ( !_c.IsValid() ) return;
if ( IsSliding ) Tick();
else TryStart();
}
/// <summary>Can a slide begin this frame.</summary>
void TryStart()
{
if ( !SlideEnabled ) return;
if ( _nz.IsValid() && _nz.IsDown ) return;
// ⛔ THE CHAIN IS CHECKED BEFORE THE COOLDOWN AND BEFORE THE SPEED GATE, and it
// has to be. A jump out of a slide lands inside the cooldown almost by definition,
// and the speed gate wants `RunSpeed * 0.7` on the ground — neither is true in the
// frame you touch down. Running the chain through the normal path would mean it
// worked only on the rare landing that happened to satisfy both.
if ( TryChain() ) return;
if ( _sinceEnd < Cooldown ) return;
// ⛔ ON THE PRESS, NOT WHILE HELD. Crouch is a movement key people hold —
// starting on Down would re-trigger every frame you stayed crouched, and
// the slide would never actually end while the key was down.
if ( !Input.Pressed( "Duck" ) ) return;
if ( !_c.IsOnGround ) return;
// ⚠️ SPEED, not "is the run key held". Judging by the key would let you
// slide from a standstill by tapping both together; judging by actual
// velocity means a slide always comes out of real momentum.
var vel = _c.Velocity.WithZ( 0f );
var speed = vel.Length;
if ( speed < _c.RunSpeed * MinSpeedFraction ) return;
Start( vel.Normal, speed );
}
/// <summary>
/// Banana Colada's chain: a crouch in mid-air resumes the slide you jumped out of.
///
/// ⚠️ THE CROUCH IS CAPTURED IN THE AIR, THE SLIDE RESUMES ON LANDING. Those are two
/// different frames and conflating them is the obvious bug here — requiring the key to
/// be HELD at the moment of touchdown would refuse every input that came a fraction
/// early, which is most of them.
///
/// ⚠️ `Input.Down`, NOT `Pressed`. Jumping straight out of a slide means crouch was
/// already held when you took off, so demanding a fresh press would force the player to
/// release and re-press in mid-air — an input nobody would guess at and one that fights
/// the thing it is chaining out of.
///
/// ⚠️ Reading a held key is only safe because the WINDOW gates it. `_chainUntil` is
/// opened solely by a live slide leaving the ground early, so a player who happens to be
/// crouch-holding through an ordinary fall has no window and cannot chain off it.
///
/// ⚠️ NOTHING HERE MAKES THE JUMP HAPPEN, and that is deliberate. Whether
/// `PlayerController` allows a jump while crouch is HELD is engine behaviour this
/// project cannot read, and applying our own upward impulse would double the jump on
/// the branch where the controller allows it anyway. So the chain only reacts to the
/// player having left the ground; if a held crouch turns out to block the jump, the
/// Stop log will say "timer" instead of "airborne" and that is the fix's starting
/// point — not a guess baked in now.
/// </summary>
bool TryChain()
{
if ( _chainUntil <= 0f ) return false;
if ( !_c.IsOnGround )
{
if ( Input.Down( "Duck" ) ) _chainQueued = true;
return false;
}
if ( !_chainQueued ) return false;
// ⚠️ Cleared BEFORE starting, not after. Start() can end up back here through
// Stop() on the same frame in edge cases, and a queue that survives its own
// consumption is an infinite slide.
_chainQueued = false;
_chainUntil = 0f;
Start( _chainDir, _chainSpeed );
Log.Info( $"[nz-slide] chained — resumed at {_chainSpeed:0.#} u/s" );
return true;
}
void Start( Vector3 dir, float speed, bool forced = false )
{
IsSliding = true;
_forced = forced;
_sinceStart = 0f;
_dir = dir;
_startPos = WorldPosition;
// ⚠️ BOTH PERK EFFECTS ARE RESOLVED HERE, ONCE. Asking per frame would let the
// perk being lost mid-slide change the curve underneath the player, and a slide
// that suddenly starts decaying reads as a bug rather than as a consequence.
// ⚠️ Seeded, not left over. Stop() is public and other systems call it — without
// this the log would attribute an external stop to whatever ended the last slide.
_endReason = "external";
var banana = PerkEffects.HasBanana( _nz );
_duration = Duration * (banana ? PerkEffects.BananaSlideDuration : 1f);
_keepMomentum = banana;
// ⛔ INPUT DRIVING STAYS ON. Turning it off does NOT hand us the movement —
// nothing then consumes WishVelocity at all, and the probe read actual=21
// against wish=493 for the whole slide while the player stood still.
//
// ⛔ A SLIDE IS ONE IMPULSE WITH THE BRAKES OFF, not a velocity held every
// frame. The controller decelerates toward its wish using GroundFriction
// and BrakePower; with no keys held that wish is zero, which is what kept
// killing the launch. Zero them for the slide and the body coasts.
_savedFriction = _c.GroundFriction;
_savedBrake = _c.BrakePower;
_c.GroundFriction = 0f;
_c.BrakePower = 0f;
// ⛔ THE DUCKED SPEED CAP IS LIFTED FOR THE SLIDE. The controller clamps
// horizontal movement to `DuckedSpeed` whenever IsDucking is set — and a
// slide MUST duck, that is its whole pose. With the stock cap the probe
// read wish=493 / actual=21: the wish was being honoured and then clamped
// to a crouch-walk. Restored on Stop.
_savedDucked = _c.DuckedSpeed;
// ⚠️ Off RUN SPEED rather than current speed, so every slide is the same
// length. This is the one thing worth keeping from the GMod dive, which
// launches off `runspeed` for exactly that reason.
//
// ⛔ COMPUTED BEFORE THE IMPULSE USES IT. It was assigned AFTER, so the
// launch ran on the PREVIOUS slide's speed — zero on the very first one.
// Tick() re-asserts the right speed a frame later, which is exactly why
// this hid: every slide looked correct except its first frame.
// ⚠️ A CHAIN KEEPS ITS OWN SPEED. Recomputing from RunSpeed here would quietly
// reset every link to the same value, so a chain would neither gain nor lose speed
// but would also ignore whatever the first slide actually achieved.
// ⛔ STAMIN-UP'S m2 SCALES THE MULTIPLIER, NOT `Slide.SpeedMultiplier` ITSELF. That
// property writes through to `ActiveConfig.Player.SlideSpeed`, which is SHARED — the
// same trap Banana Colada's duration documents. A per-slide factor cannot leak into
// the map's saved settings.
//
// ⚠️ SPEED ONLY. The original's Slide Boost also held slide stamina full and halved
// the cooldown; both were dropped, so this perk moves slide SPEED and Banana Colada
// moves slide DURATION and chaining. Two perks on one mechanic, no overlap.
// ⚠️ AND THE MATCH'S SLIDE SPEED (the lobby's Difficulty, 2026-10-05), per slide for the perk's reason above
var slideBoost = SpeedMultiplier * StaminUpAugments.SlideSpeedMultiplier( _nz ) * Difficulty.SlideSpeed;
_launchSpeed = speed > 0f && _keepMomentum
? MathF.Max( speed, _c.RunSpeed * slideBoost )
: _c.RunSpeed * slideBoost;
var b = _c.Body;
if ( b.IsValid() )
b.Velocity = dir * _launchSpeed + Vector3.Up * b.Velocity.z;
OnSlideStart?.Invoke( _nz, dir );
// ⛔ CALLED DIRECTLY, NOT THROUGH `OnSlideStart`, AND THAT IS DELIBERATE. That event is a
// STATIC delegate, so it survives a hotload with its subscriber list intact — a `+=`
// anywhere would re-subscribe on every reload and PhD would detonate twice, then three
// times, then four. §1 with a delegate instead of a value, and silent: nothing about a
// doubled blast says "you subscribed twice".
//
// ⚠ The event stays for anything outside this assembly that wants it; this file already
// calls into `PerkEffects` and `StaminUpAugments` directly, so one more is consistent.
PhdAugments.OnSlideStarted( _nz );
// ⚠️ TUMBLEWEED (revolver tier 3, 2026-10-04): a round into the gun in hand, called directly for the reason above.
ClassTech.OnSlideStarted( _nz );
PlaySlideSound( start: true );
}
// ══ sound ═════════════════════════════════════════════════════════════════
/// <summary>Play the `end/` sample when a slide finishes.
///
/// ⛔ A DELIBERATE ADDITION, NOT A PORT. GMod declares `end_sounds` at the top of
/// `gamemode/sliding/sh_hooks.lua` and references it NOWHERE ELSE in the entire
/// gamemode — the four samples ship and never play. They were imported and given
/// cues here anyway, so they are wired; set this false for the original's
/// behaviour.</summary>
public static bool EndSound { get; set; } = true;
/// <summary>Map dirt/mud/sand to `slide_mud_b`.
///
/// ⛔ ALSO NOT IN THE ORIGINAL. `slide_sounds` has no `MAT_DIRT` entry, so
/// `slide_mud_b.wav` is dead content in GMod — but a matching
/// `slide_mud_b_end.wav` ships too, which reads as an omission rather than a
/// decision.</summary>
public static bool MudSound { get; set; } = true;
/// <summary>How far down to look for the surface being slid on.</summary>
public static float SurfaceProbe { get; set; } = 48f;
/// <summary>
/// The slide's sound, chosen by the surface under the player.
///
/// Ported from `SlideSurfaceSound` in `gamemode/sliding/sh_hooks.lua`: a trace
/// straight down, the material it lands on picks the sample, and it plays ONCE.
/// The original never loops it and never fades it out.
///
/// ⛔ EVERY CUE AND EVERY .sound ASSET FOR THIS ALREADY EXISTED AND NOTHING
/// CALLED THEM. `NZSound` carries eleven `Slide*` constants and the compiled
/// assets are all present — `grep -rn "NZSound.Slide"` returned nothing. The
/// audio was imported and never wired, which is the whole of "sliding has no
/// sound".
/// </summary>
void PlaySlideSound( bool start )
{
if ( !start && !EndSound ) return;
var at = WorldPosition;
var tr = Scene.Trace
.Ray( at + Vector3.Up * 8f, at + Vector3.Down * SurfaceProbe )
.IgnoreGameObjectHierarchy( GameObject )
.Run();
var surface = tr.Hit ? (tr.Surface?.ResourceName ?? "") : "";
NZSound.Play( start ? CueFor( surface ) : EndCueFor( surface ), at );
// ⚠️ LAYERED, NOT SUBSTITUTED — the original plays the ground's sample and
// THEN adds water on top whenever `ply:WaterLevel() > 0`, so sliding through
// a puddle on metal is metal AND water. Replacing it would lose the surface
// underneath, which is the half that tells you where you are.
if ( start && InWater() && CueFor( surface ) != NZSound.SlideWater )
NZSound.Play( NZSound.SlideWater, at );
}
/// <summary>Which start cue a surface name maps to.
///
/// ⚠️ MATCHED ON SUBSTRING, not on a fixed list of exact names. s&box surfaces
/// are dotted and open-ended (`metal`, `metal.sheet`, `metal.grate`), so an
/// equality test against "metal" would silently fall through to the default on
/// most real level geometry. GMod gets the aliasing free by mapping MAT_VENT and
/// MAT_GRATE onto MAT_METAL; this is the same idea against a different scheme.
///
/// ⚠️ IT NEVER RETURNS NOTHING. An unrecognised surface is `slide_00`, exactly
/// what `slide_sounds[tr.MatType] or slide_sounds[0]` does. A silent slide would
/// read as the feature being broken rather than as an unmapped material.</summary>
public static string CueFor( string surface )
{
if ( string.IsNullOrWhiteSpace( surface ) ) return NZSound.Slide;
var s = surface.ToLowerInvariant();
// ⚠️ WATER FIRST. A water surface is often also named for what is under it,
// and MAT_SLOSH wins in the original too.
if ( s.Contains( "water" ) || s.Contains( "slosh" ) ) return NZSound.SlideWater;
if ( s.Contains( "snow" ) || s.Contains( "ice" ) ) return NZSound.SlideSnow;
if ( s.Contains( "grass" ) || s.Contains( "foliage" ) ) return NZSound.SlideGrass;
if ( s.Contains( "metal" ) || s.Contains( "grate" ) || s.Contains( "vent" ) )
return NZSound.SlideMetal;
if ( MudSound && (s.Contains( "dirt" ) || s.Contains( "mud" )
|| s.Contains( "sand" ) || s.Contains( "gravel" )) )
return NZSound.SlideMud;
return NZSound.Slide;
}
/// <summary>The matching end cue.
///
/// ⚠️ THERE ARE ONLY FOUR. GMod ships end samples for grass, metal, mud and the
/// default — snow and water have none. Those fall back to `slide_00_end` rather
/// than going silent, which is the same rule the start table uses.</summary>
public static string EndCueFor( string surface )
=> CueFor( surface ) switch
{
NZSound.SlideGrass => NZSound.SlideEndGrass,
NZSound.SlideMetal => NZSound.SlideEndMetal,
NZSound.SlideMud => NZSound.SlideEndMud,
_ => NZSound.SlideEnd,
};
/// <summary>Is the player in water? The original's `ply:WaterLevel() > 0`.
///
/// ⚠️ "Unknown = no". There is no WaterLevel on PlayerController, so this asks
/// the tag set; if a map never tags water the layered splash simply does not
/// play and the ground sample still does.</summary>
bool InWater()
=> GameObject.Tags.Has( "water" )
|| (_c.IsValid() && _c.Body.IsValid() && _c.Body.Tags.Has( "water" ));
void Tick()
{
// ⛔ LETTING GO OF CROUCH ENDS THE SLIDE, ON EVERY SLIDE INCLUDING A CHAINED ONE.
// The timer is the MAXIMUM length, not a commitment — a slide you cannot cancel is a
// slide that carries you into a horde you have already seen.
//
// ⚠️ `Input.Down`, and it must be. `Released` fires once and would be missed on any
// frame the slide did not tick; asking whether the key is down each frame cannot
// miss, and is also correct if focus is lost mid-slide.
//
// ⚠️ DOES NOT ARM A CHAIN. Chaining is paid for by giving up the rest of the slide
// to a jump; releasing crouch is just stopping, and letting it bank momentum too
// would make the jump pointless.
if ( !_forced && !Input.Down( "Duck" ) )
{
_endReason = "crouch released";
Stop();
return;
}
// ⚠️ FORCED EVERY FRAME. PlayerController recomputes IsDucking from input
// in its own update, so setting it once at the start is undone the moment
// the player is not holding crouch — the same trap the bleedout crawl hit.
_c.IsDucking = true;
var t = ((float)_sinceStart / MathF.Max( 0.05f, _duration )).Clamp( 0f, 1f );
// Ease out: quick launch, long settle. t² decays slowly at first and
// steeply at the end, which reads as friction rather than a ramp.
// ⛔ BANANA COLADA COASTS — NO DECAY AT ALL. The base slide eases from launch
// speed down to ducked speed; with the perk the launch speed simply holds for the
// whole (already longer) slide. That is what "maintains all momentum" means, and
// it is why a chain does not bleed speed across jumps: each link starts at the
// speed the last one ended.
var floor = _c.DuckedSpeed;
var speed = _keepMomentum
? _launchSpeed
: MathX.Lerp( _launchSpeed, floor, t * t );
// ⛔ DRIVEN THROUGH WishVelocity, which is what the controller actually
// moves on. `PlayerController.Velocity` is READ ONLY, and writing the
// rigidbody directly loses the race with the controller's own move step.
//
// ⚠️ Horizontal only — vertical is left to gravity, or a slide would hold
// you off a slope.
_c.DuckedSpeed = MathF.Max( _savedDucked, speed );
// ⚠️ The decay is applied to the BODY, re-asserting the coast each frame
// so the ease-out curve is what shapes the slide rather than engine drag.
var bd = _c.Body;
if ( bd.IsValid() )
bd.Velocity = _dir * speed + Vector3.Up * bd.Velocity.z;
// ⚠️ Ends on LEAVING THE GROUND too, not just the timer — sliding off a
// ledge should drop you, not carry you through the air at launch speed.
if ( t >= 1f || !_c.IsOnGround )
{
_endReason = _c.IsOnGround ? "timer" : "airborne";
// ⛔ A CHAIN IS ARMED ONLY BY LEAVING THE GROUND EARLY. A slide that ran its
// timer out has spent itself and must not be resumable, or crouch-spamming on
// flat ground would be permanent free speed. Jumping out of a live slide is
// the cost: you give up the rest of it to keep the momentum.
if ( _keepMomentum && t < 1f && !_c.IsOnGround )
{
_chainDir = _dir;
_chainSpeed = speed;
_chainUntil = PerkEffects.BananaChainWindow;
_chainQueued = false;
_endReason = "airborne — chain armed";
}
Stop();
}
}
public void Stop()
{
if ( !IsSliding ) return;
IsSliding = false;
_sinceEnd = 0f;
// ⚠️ BEFORE the controller values are handed back, so the trace still runs
// from where the slide actually ended rather than after anything else has
// had a chance to move the player.
PlaySlideSound( start: false );
// ⚠️ Handed straight back, and to the SAVED value rather than a hardcoded
// true — a slide that began during noclip must not switch input driving
// back on underneath it.
if ( _c.IsValid() )
{
if ( _savedDucked > 0f ) _c.DuckedSpeed = _savedDucked;
_c.GroundFriction = _savedFriction;
_c.BrakePower = _savedBrake;
}
// ⚠️ An INSTRUMENT, not decoration. "Did the slide move the player" cannot
// be answered from the console any other way — the forced-slide command
// prints that it fired whether or not a single unit of travel happened.
var dist = WorldPosition.Distance( _startPos );
Log.Info( $"[nz-slide] ended ({_endReason}) — {dist:0.#} units in {(float)_sinceStart:0.##}s"
+ $" of {_duration:0.##}s (launch {_launchSpeed:0.#} u/s{(_keepMomentum ? ", coasting" : "")})" );
}
// ── console ──────────────────────────────────────────────────────────────
static Slide Find()
=> Game.ActiveScene?.GetAllComponents<Slide>().FirstOrDefault();
/// <summary>`nz_slide` — report, or force one for testing.</summary>
[ConCmd( "nz_slide" )]
public static void SlideCmd( string arg = "" )
{
var s = Find();
if ( !s.IsValid() ) { Log.Warning( "[nz-slide] no player" ); return; }
if ( arg == "back" )
{
// ⚠️ Exists because a forced slide launches along EYE FORWARD, and the
// creative spawn faces a fence — every measurement so far may have been
// a slide straight into geometry rather than a slide that does nothing.
var back = s._c.IsValid() ? -s._c.EyeAngles.Forward.WithZ( 0f ).Normal : Vector3.Backward;
s.Start( back, s._c.RunSpeed, forced: true );
Log.Info( "[nz-slide] forced BACKWARD" );
return;
}
if ( arg == "go" )
{
// ⚠️ Forces one regardless of speed so the slide can be SEEN from the
// console without having to be sprinting when the command lands.
var fwd = s._c.IsValid() ? s._c.EyeAngles.Forward.WithZ( 0f ).Normal : Vector3.Forward;
s.Start( fwd, s._c.RunSpeed, forced: true );
Log.Info( "[nz-slide] forced" );
return;
}
Log.Info( $"[nz-slide] enabled {SlideEnabled} · sliding {s.IsSliding} · x{SpeedMultiplier:0.##} run speed · {Duration:0.##}s · {Cooldown:0.##}s cooldown · needs {MinSpeedFraction:P0} of run speed" );
}
/// <summary>`nz_slide_set [mult] [duration] [cooldown]`.</summary>
[ConCmd( "nz_slide_set" )]
public static void SetCmd( float mult = 0f, float duration = 0f, float cooldown = 0f )
{
if ( mult > 0f ) SpeedMultiplier = mult;
if ( duration > 0f ) Duration = duration;
if ( cooldown > 0f ) Cooldown = cooldown;
Log.Info( $"[nz-slide] x{SpeedMultiplier:0.##} · {Duration:0.##}s · {Cooldown:0.##}s cooldown" );
}
/// <summary>`nz_slide_enable [0/1]`.</summary>
[ConCmd( "nz_slide_enable" )]
public static void EnableCmd( int on = -1 )
{
if ( on >= 0 ) SlideEnabled = on != 0;
Log.Info( $"[nz-slide] {(SlideEnabled ? "on" : "off")}" );
}
/// <summary>
/// `nz_slide_sound [play]` — the raw surface under you, the cue it maps to, and the
/// whole table. `nz_slide_sound 1` plays it.
///
/// ⛔ IT PRINTS THE RAW SURFACE NAME. "The slide sound is wrong here" is either the
/// surface not being what you think or the mapping not catching it, and those are
/// indistinguishable by ear. `concrete.polished -> default` separates them at a glance.
/// </summary>
[ConCmd( "nz_slide_sound" )]
public static void SoundCmd( int play = 0 )
{
var scene = Game.ActiveScene;
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Warning( "[nz-slide] no player" ); return; }
var at = p.WorldPosition;
var tr = scene.Trace
.Ray( at + Vector3.Up * 8f, at + Vector3.Down * SurfaceProbe )
.IgnoreGameObjectHierarchy( p.GameObject )
.Run();
var surface = tr.Hit ? (tr.Surface?.ResourceName ?? "") : "";
Log.Info( $"[nz-slide] under you: '{(surface == "" ? "<nothing hit>" : surface)}'"
+ $" -> start {CueFor( surface )} · end {EndCueFor( surface )}" );
Log.Info( $"[nz-slide] end sound {(EndSound ? "ON (not in GMod)" : "off")}"
+ $" · mud mapping {(MudSound ? "ON (not in GMod)" : "off")}"
+ $" · probe {SurfaceProbe:0}u" );
// ⚠️ Printed as MATCH SUBSTRINGS, not example names — the mapping is `Contains`,
// and listing "metal" as an exact name would suggest `metal.grate` is unmapped.
foreach ( var (match, cue) in new[]
{
("water / slosh", NZSound.SlideWater),
("snow / ice", NZSound.SlideSnow),
("grass / foliage", NZSound.SlideGrass),
("metal / grate / vent", NZSound.SlideMetal),
("dirt / mud / sand / gravel", NZSound.SlideMud),
("anything else", NZSound.Slide),
} )
Log.Info( $"[nz-slide] {match,-28} {cue}" );
if ( play > 0 )
{
NZSound.Play( CueFor( surface ), at );
Log.Info( $"[nz-slide] played {CueFor( surface )}" );
}
}
}