Player component implementing a melee knife swing for NZombies. Handles input, timing (wind-up and recovery), viewmodel animation, swept-sphere tracing to find damageable targets, applying damage scaled by round and augments, sounds, and several developer console commands for testing and diagnostics.
using Sandbox;
using System.Linq;
namespace NZombies;
/// <summary>
/// THE KNIFE — V to swing, whatever you are holding.
///
/// Ported from the original's `nzSpecialWeapons:AddKnife`. There the knife is a real
/// weapon that gets deployed for ~0.7s and holstered back, because GMod's only way to
/// show a different viewmodel is to swap weapons. We have no knife viewmodel and no
/// reason to disturb the inventory, so this is a swing in place: a trace, damage, a
/// cooldown.
///
/// ⛔ DAMAGE IS THE TARGET'S MAX HEALTH DIVIDED BY THE ROUND, which makes the knife
/// take exactly N hits on round N — one on round 1, five on round 5. That is OURS,
/// not the original's flat 300, and it is a better rule for the same reason a flat
/// number is a worse one: zombie health scales every round, so any constant is a
/// one-hit-kill early and a rounding error later. This degrades on a line the player
/// can feel and predict.
///
/// ⚠️ NO LUNGE. The original yanks you toward the target at 800 u/s until you are
/// within 32 units. Deliberately skipped: it seizes the movement controller, and this
/// project already has a barricade vault and a downed crawl competing for that.
/// </summary>
public sealed class Knife : Component
{
/// <summary>How far the swing reaches. Roughly a zombie's own attack range.</summary>
[Property] public float Range { get; set; } = 80f;
/// <summary>
/// How wide the swing is, as a trace radius.
///
/// ⚠️ A SPHERE, NOT A RAY. A hitscan line demands you be looking exactly at a
/// zombie, and a knife that misses a body filling half the screen reads as broken
/// — the original does not even trace, it hands the swing to a weapon whose bash
/// has its own generous box.
/// </summary>
[Property] public float Radius { get; set; } = 18f;
/// <summary>
/// Seconds between swings. The original's `AttackHolsterTime` for the BO1 knife.
///
/// ⚠️ MUST NOT BE SHORTER THAN <see cref="PreSwing"/> + <see cref="PostSwing"/>,
/// or a second swing starts while the first is still on screen — the knife would
/// snap back to frame 0 mid-arc and the strike of the first swing would land
/// during the second. Clamped in <see cref="Swing"/> rather than trusted.
/// </summary>
[Property] public float Cooldown { get; set; } = 0.75f;
/// <summary>
/// Seconds from the keypress to the moment the blade actually connects.
///
/// ⛔ THE TRACE DOES NOT FIRE ON THE KEYPRESS. ARC9's own knife declares
/// `PreBashTime = 0.25` / `PostBashTime = 0.5`, and that split is the whole feel
/// of a melee weapon: the wind-up is a commitment you cannot take back, and the
/// recovery is the punish for missing. Damage on the press instead makes the
/// animation pure decoration — the hit registers before the blade has moved, so
/// a zombie dies to a knife that is still behind the player's shoulder.
/// </summary>
[Property] public float PreSwing { get; set; } = 0.25f;
/// <summary>Seconds of recovery after the blade connects. ARC9's `PostBashTime`.</summary>
[Property] public float PostSwing { get; set; } = 0.5f;
/// <summary>
/// The clip played for a swing.
///
/// ⚠️ `swipe` is what the BO1 model calls it, and ARC9 maps its `bash` to exactly
/// this ("bash" -> Source `swipe`, Time 1). The model also carries `stab`, which
/// ARC9 uses for its heavy bash and backstab — we have neither.
/// </summary>
/// <summary>The viewmodel sequence a swing plays.
///
/// ⛔ "melee", NOT "swipe" — THE MODEL CHANGED AND THE NAMES WENT WITH IT. The
/// ARC9 SOG knife this replaced carried nine sequences named
/// idle/idle_long/draw/draw_fast/holster/holster_fast/swipe/stab/sprint_loop; the
/// TFA BO2 knife carries three, named draw/melee/stick. A stale "swipe" here does
/// not error — `KnifeViewModel.Show` looks the name up in `SequenceNames` and
/// simply finds nothing — so the knife would swing silently and invisibly with
/// every other part of the feature working.
///
/// ⚠️ Still a `[Property]`, so a future knife with different names is a field edit
/// rather than a rebuild.</summary>
[Property] public string SwingAnim { get; set; } = "melee";
/// <summary>The sequence played when the target is inside <see cref="StabRange"/>.
///
/// ⚠️ "stick" is what the BO2 model calls its stab — the third of its three
/// sequences (draw / melee / stick). Named for the animation, not for the verb.</summary>
[Property] public string StabAnim { get; set; } = "stick";
/// <summary>Inside this distance a swing becomes a stab. 0 disables it.
///
/// ⚠️ MEASURED TO THE HIT, not to the zombie's origin, so a big body counts as
/// close when its shoulder is close — which is what "really close" looks like
/// from behind the camera.
///
/// ⚠️ Under half the 80u reach, so the stab is genuinely point-blank rather than
/// the default swing for anything in front of you.</summary>
[Property] public float StabRange { get; set; } = 36f;
/// <summary>
/// A bonus applied to the FINAL figure, after max-health-over-round.
///
/// ⛔ ON TOP OF THE RESULT, NOT FOLDED INTO THE DIVISION. 0.1 means a swing that
/// would take 20% of a zombie's health takes 22%. That edge is what stops the
/// rule landing exactly on a whole number of hits: without it a round-5 zombie
/// needs precisely 5 swings and the fifth leaves it on 0.0 health, which is a
/// coin-flip against float rounding. With it the last swing always overkills.
/// </summary>
[Property] public float Bonus { get; set; } = 0.1f;
TimeUntil _ready;
// ── the swing in progress ────────────────────────────────────────────────
bool _swinging;
bool _struck;
TimeSince _sinceSwing;
/// <summary>Is the swing off cooldown?</summary>
public bool Ready => _ready;
/// <summary>Is a swing on screen right now?</summary>
public bool Swinging => _swinging;
/// <summary>Bled out: no body in the world and the camera on somebody else (`SpectateOthers`), so no swing either (2026-10-05).</summary>
bool SittingOut => Components.Get<NZPlayer>() is { IsOutOfRound: true };
KnifeViewModel ViewModel => Components.GetOrCreate<KnifeViewModel>();
protected override void OnUpdate()
{
// ⛔ NO KNIFE IN CREATIVE, AND THIS IS A MARKER FIX, NOT A GAMEPLAY ONE.
// The first swing creates the viewmodel camera (KnifeViewModel.EnsureCamera)
// at Priority 2 with RenderTags { ViewModel, Light }. From then on it is
// the highest-priority camera in the scene and it draws ONLY viewmodel
// geometry — so every world-space DebugOverlay mark vanishes: spawn
// markers, debris outlines, the barrier corner nodes, nav links, navmesh
// dots. That is the "sometimes the orange nodes stop being visible" bug.
//
// ⚠️ Lowering that camera's priority is NOT the alternative — it takes the
// player's movement with it, confirmed in play. Not creating the camera in
// the first place is the one lever that costs nothing, and Creative is
// exactly where the knife is useless and the markers matter.
//
// ⚠️ ONLY STOPS IT BEING MADE. A camera created during a round survives
// into Creative — nz_vm_clear removes it without restarting.
if ( NZGame.IsCreative ) { TickSwing(); return; }
if ( Input.Pressed( "Knife" ) && !SittingOut ) Swing();
TickSwing();
}
/// <summary>
/// Advance the swing: strike at <see cref="PreSwing"/>, put the knife away at
/// the end.
///
/// ⚠️ DRIVEN FROM A CLOCK, NOT FROM THE ANIMATION'S OWN PROGRESS. Reading
/// `Sequence.Time` would tie the damage to the clip, which is the more elegant
/// idea and the wrong one here: the model may be missing (the swing still has to
/// work), the clip's length is whatever the BO1 animator chose, and ARC9's
/// 0.25/0.5 split is a TUNED gameplay value that happens to sit inside it. Tying
/// them together would mean a re-export could silently change the timing of the
/// hit.
/// </summary>
void TickSwing()
{
if ( !_swinging ) return;
// ⛔ RE-ASSERTED EVERY FRAME, NOT SET ONCE AT THE START. A weapon that
// DEPLOYS mid-swing sets its own `ShouldDraw = true` on the way in — so a
// gun handed over, bought off a wall or returned from Pack-a-Punch during
// those 0.75s puts itself back on screen next to the knife. Hiding is a
// state that holds for the duration, not an event at the start of it.
ShowGuns( false );
if ( !_struck && _sinceSwing >= PreSwing )
{
_struck = true;
var msg = Strike();
if ( !string.IsNullOrEmpty( msg ) ) Log.Info( $"[nz] {msg}" );
}
if ( _sinceSwing < PreSwing + PostSwing ) return;
_swinging = false;
ViewModel.Hide();
ShowGuns( true );
}
/// <summary>
/// Hide or restore the held weapon's viewmodel for the duration of the swing.
///
/// ⛔ VIA `ShouldDraw`, NOT BY DISABLING THE WEAPON. Disabling is how this project
/// expresses HOLSTERING — it runs `OnCarryStop`, tears down attachment HUD
/// elements and re-runs deploy on the way back — and putting a gun through all
/// of that twice for a 0.75s animation is how the two-slot inventory broke the
/// first time. `ShouldDraw` is the handler's own "is this on screen" flag and
/// costs nothing to flip.
///
/// ⚠️ RESTORES EVERY WEAPON, HIDES ONLY THE ACTIVE ONE. If the player switches
/// slots mid-swing, restoring just the one we hid would strand the other
/// invisible until something else redeployed it. Setting the flag on a holstered
/// weapon is harmless: its handler is disabled, so nothing draws, and re-deploy
/// sets the flag itself.
/// </summary>
void ShowGuns( bool show )
{
var inv = Components.Get<NZInventory>( FindMode.EverythingInSelf );
if ( !inv.IsValid() ) return;
foreach ( var go in inv.Weapons )
{
if ( !show && go != inv.Active ) continue;
// ⚠️ `EverythingInSelf` — a holstered weapon is DISABLED, and the plain
// `Get<T>()` silently skips disabled components. That omission has cost
// this project a free Pack-a-Punch level and an unbuyable wall gun.
var wep = go.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf );
var handler = wep?.ViewModelHandler;
if ( handler.IsValid() ) handler.ShouldDraw = show;
}
}
/// <summary>
/// Swing once. Returns what happened, for the log and the console command.
///
/// ⚠️ WORKS WHILE DOWNED, which is the original's own rule — the button handler
/// reads `(ply:GetNotDowned() or id == "knife")`, making the knife the one special
/// weapon a crawling player still has. Taking it away would leave a downed player
/// with literally nothing to do.
/// </summary>
public string Swing()
{
if ( !_ready ) return "";
// ⛔ THE COOLDOWN CANNOT BE SHORTER THAN THE SWING. Taken as the larger of
// the two rather than trusting the property: `Cooldown` is [Property] and
// editable in the inspector, and a value below Pre+Post would let a second
// swing begin mid-arc — the knife snapping to frame 0 while the first
// strike is still pending.
_ready = System.MathF.Max( Cooldown, PreSwing + PostSwing );
var player = Components.Get<NZPlayer>();
if ( !player.IsValid() ) return "no player";
var controller = Components.Get<PlayerController>();
var eye = controller?.EyePosition ?? WorldPosition + Vector3.Up * 64f;
// ⛔ THE SWING CANCELS A RELOAD IN PROGRESS. Without this the reload kept running
// invisibly behind the knife — `ShowGuns( false )` hides the weapon, not its timer — so
// the magazine filled during a swing the player made INSTEAD of reloading, and a gun
// hidden mid-reload came back either already loaded or snapping through the end of a
// clip it had finished playing to nobody.
//
// ⚠️ HERE, NOT IN `Strike`. The damage lands `PreSwing` later and may miss entirely;
// the decision to knife is made at the swing, and that is what interrupts the reload —
// the same argument the `b_attack` gesture below is placed on.
//
// ⚠️ `EverythingInSelf` FOR THE SAME REASON `ShowGuns` USES IT — a holstered weapon is
// disabled and a plain Get skips it. The ACTIVE weapon is the one reloading, but the
// lookup has to be able to see a disabled component to find it at all.
var active = Components.Get<NZInventory>( FindMode.EverythingInSelf )?.Active;
var heldWep = active.IsValid()
? active.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf )
: null;
if ( heldWep.IsValid() && heldWep.IsReloading )
{
heldWep.CancelAnyReload();
Log.Info( $"[nz-knife] cancelled {heldWep.DisplayName}'s reload" );
}
NZSound.Play( NZSound.Knife, eye );
_swinging = true;
_struck = false;
_sinceSwing = 0f;
// ⚠️ A PANZER'S CLAW (2026-10-06): the knife is the way out of it — every swing counts, hit or miss (`PanzerGrab.OnKnife`)
var grab = Components.Get<PanzerGrab>( FindMode.EverythingInSelf );
if ( grab.IsValid() ) grab.OnKnife();
// ⚠️ THE MIMIC'S TENTACLE (2026-10-07): the same way out of it, its own count (`MimicGrab.OnKnife`)
var tentacle = Components.Get<MimicGrab>( FindMode.EverythingInSelf );
if ( tentacle.IsValid() ) tentacle.OnKnife();
// ⛔ THE SWING EXISTED ONLY ON THE VIEWMODEL. `ViewModel.Show` below picks the stab or the
// swing clip and plays it in first person; the body did nothing, so to everybody else a
// knifing player just stood still and zombies died. `b_attack` is the engine's own gesture
// for this — Sandbox.Engine.xml documents it as what a weapon fires on the holder "once per
// trigger pull".
//
// ⚠️ SENT FROM THE SWING, NOT FROM `Strike`. The damage lands PreSwing later and may miss;
// the gesture is the swing itself and should play either way. The comment below already
// says the animation is a PREDICTION — this is the same prediction, shared.
//
// ⛔ A SWING, NOT A BARE `b_attack` (2026-10-05): alone it played the GUN's attack, the body still being in the gun's hold,
// so a third-person knife was a recoiling rifle. `ThirdPersonWeapon.MeleeGesture` is the melee hold, the knife in the hand
// and the swing, on every machine.
NZNet.PlayerAnim( GameObject.Id, ThirdPersonWeapon.MeleeGesture );
ShowGuns( false );
// ⛔ THE ANIMATION IS CHOSEN HERE AND THE DAMAGE LANDS `PreSwing` LATER, SO
// THIS IS A PREDICTION. A zombie can close the gap or step out in those 0.25s
// and the clip will already be playing — unavoidable, because an animation
// cannot be picked after the fact. The probe deliberately runs against the
// SAME rule `Strike` will use, so the two disagree only when something moved,
// never because they measure differently.
//
// ⚠️ COSTS ONE EXTRA TRACE PER SWING, which is a sweep against a handful of
// bodies once every 0.75s at most. Caching the result to reuse in `Strike`
// was rejected: it would freeze the target list at wind-up and let you damage
// a zombie that had already walked away.
var fwd = (controller?.EyeAngles.ToRotation() ?? WorldRotation).Forward;
var close = StabRange > 0f
&& Probe( Range * WidowAugments.RangeScale( player ), eye, fwd,
out var probeHit, out _, out _ ).IsValid()
&& probeHit.Distance <= StabRange;
ViewModel.Show( close ? StabAnim : SwingAnim, PreSwing + PostSwing );
// ⚠️ The OUTCOME is not known yet — the blade lands in PreSwing seconds, and
// `Strike` logs what it finds when it gets there.
return "knife — swinging";
}
/// <summary>
/// The blade connecting: trace, damage, impact sound. Called once per swing,
/// <see cref="PreSwing"/> seconds in.
///
/// ⚠️ AIM IS READ HERE, NOT AT THE KEYPRESS. The player can still turn during
/// the wind-up, and a melee weapon that locks its direction the instant you press
/// the key feels like it fires backwards when you are circling a horde — which is
/// the whole reason you are knifing in the first place.
/// </summary>
/// <summary>
/// The nearest DAMAGEABLE thing in the swing volume, or null. World geometry is
/// passed through rather than stopping the sweep.
///
/// ⛔ `RunAll`, AND THE WORLD IS SKIPPED RATHER THAN ALLOWED TO STOP THE SWING.
/// "Knifing with my back to a wall hits nothing" was this: the swing is a swept
/// SPHERE of radius 18, and a sphere sweep that BEGINS inside geometry starts
/// solid and resolves at fraction 0. Standing against a wall puts that wall
/// within 18 units of the eye, so EVERY swing resolved against the wall at zero
/// distance and the zombie in front was never reached.
///
/// ⚠️ `WithoutTags` DID NOT COVER THIS AND COULD NOT. That list is what BULLETS
/// pass through — barricades, playerclip, triggers — and an ordinary solid wall
/// is deliberately not in it. It fixed swinging through boards; it does nothing
/// for a wall you are pressed against.
///
/// ⚠️ "IGNORE THE WORLD" IS EXPRESSED AS "HAS NO `Health`", NOT AS A TAG LIST.
/// Tags would need `solid`/`world` reliably authored on every map and never on a
/// zombie; Collision.config shows both carrying overlapping tags. "Can it be
/// damaged" is the property the knife actually cares about, and it stays correct
/// for props, ragdolls and anything added later.
///
/// ⛔ ONE COPY, TWO CALLERS. `Swing` asks it which animation to play and `Strike`
/// asks it what to damage. A second trace written for the animation would be a
/// second definition of "what counts as a hit" — and the two would drift, so the
/// knife would stab at things it then failed to cut.
/// </summary>
Health Probe( float reach, Vector3 eye, Vector3 fwd,
out SceneTraceResult hit, out SceneTraceResult firstWorld, out bool anyWorld )
{
hit = default;
firstWorld = default;
anyWorld = false;
var hits = Scene.Trace
// ⚠ m5 LONG REACH SCALES THE TRACE, not the `Range` property. That is an
// authored `[Property]`, so assigning it would leave the longer reach behind
// after the perk was lost — the trap `NZInventory.HolsterTime` documents.
.Ray( eye, eye + fwd * reach )
.Radius( Radius )
// ⛔ THE SAME IGNORE LIST THE BULLETS USE, NOT A COPY OF IT. Reusing
// Weapon.BulletTraceIgnoreTags means anything later marked shoot-through is
// knife-through automatically, rather than needing to be remembered twice.
.WithoutTags( SWB.Base.Weapon.BulletTraceIgnoreTags )
// ⛔ NEVER OTHER PLAYERS. The knife damaged whatever had a `Health` component in
// front of it, and a team-mate has one. User: *"players can damage eachother by
// knifing."*
//
// ⚠️ EXCLUDED FROM THE TRACE RATHER THAN CHECKED AT THE HIT. If the swing merely
// refused to damage a player it would still STOP on them, so a team-mate standing
// between you and a zombie would armour it. Passing through is the behaviour a co-op
// game wants and it removes the friendly-fire case at the same time.
//
// ⚠️ `IgnoreGameObjectHierarchy` ALREADY COVERED MY OWN BODY and nothing else — it
// takes one hierarchy, and the other player is a different one.
.WithoutTags( "player" )
.IgnoreGameObjectHierarchy( GameObject )
.RunAll();
// ⚠️ SORTED BY DISTANCE EXPLICITLY. `RunAll` is not documented to return hits
// in order, and "nearest zombie" is not a detail — an unsorted list would make
// the knife pick whichever of two overlapping zombies the physics scene
// happened to list first, which is stable enough to look correct in testing
// and wrong in a horde.
foreach ( var h in hits.OrderBy( h => h.Distance ) )
{
if ( !h.Hit || !h.GameObject.IsValid() ) continue;
// ⚠️ Ancestors too: the collider hit is usually a hitbox or a child body,
// not the object carrying Health. The same lookup the bullet path uses.
var found = h.GameObject.Components
.Get<Health>( FindMode.EverythingInSelfAndAncestors );
if ( found.IsValid() )
{
hit = h;
return found;
}
if ( !anyWorld )
{
firstWorld = h;
anyWorld = true;
}
}
return null;
}
string Strike()
{
var player = Components.Get<NZPlayer>();
if ( !player.IsValid() ) return "no player";
var controller = Components.Get<PlayerController>();
var eye = controller?.EyePosition ?? WorldPosition + Vector3.Up * 64f;
var fwd = (controller?.EyeAngles.ToRotation() ?? WorldRotation).Forward;
// ⚠️ THE TRACE RULE LIVES ON `Probe`, NOT HERE — world geometry is passed
// through rather than stopping the sweep, and `Swing` uses the same call to pick
// between the swing and the stab. See its remarks for why a wall used to eat
// every swing.
var hp = Probe( Range * WidowAugments.RangeScale( player ), eye, fwd,
out var tr, out var worldHit, out var hitWorld );
if ( !hp.IsValid() )
{
// ⚠️ A MISS KEEPS THE WHOOSH AND ADDS NOTHING. Air is the one outcome with no
// impact — the swing sound already played, which is the feedback that the
// input registered.
if ( !hitWorld ) return "knife — missed";
// ⛔ HITTING THE WORLD USES THE SURFACE'S OWN IMPACT, not a knife sound.
// Every material already carries the right noise and the bullet path
// already asks for it — a single generic "clang" would make wood, metal
// and concrete indistinguishable, when the surface system is sitting
// right there knowing the answer.
SWB.Base.Weapon.CreateBulletImpact( worldHit );
return $"knife — hit {worldHit.GameObject.Name}"
+ $" ({worldHit.Surface?.ResourceName ?? "?"})";
}
// ⚠️ Flesh, LAYERED OVER the swing rather than replacing it — the whoosh
// already happened, and cutting it short to substitute an impact is what
// makes melee feel disconnected from the animation.
NZSound.Play( NZSound.KnifeFlesh, tr.HitPosition );
// ⚠ M2 ASSASSIN AND m2 HEAVY HANDS BOTH LAND HERE, on the one figure the swing uses,
// so the cleave below carries the same number rather than recomputing it.
// ⚠️ AND PISTOL WHIP'S ×3 (handgun tier 3, 2026-10-04) while a gun that owns it is in hand, multiplied with Heavy Hands.
float damage = WidowAugments.KnifeDamage( player, DamageFor( hp ), hp.Max ) * ClassTech.MeleeScale( player );
// ⛔ `Attacker` WAS NEVER SET, AND THAT WAS A REAL BUG. Without it every on-kill augment
// saw a null killer, so a knife kill credited nobody — Widow's own m3 and m4 could not
// have worked, and neither could Deadshot's or Vigor's on a melee kill. Found while
// wiring this perk; it has been true for as long as the knife has existed.
var info = new DamageInfo
{
Damage = damage,
Attacker = GameObject,
Position = tr.HitPosition,
};
// ⛔ TAGGED `melee`, WHICH IS THE WHOLE INTEGRATION. Health.LastHitWasMelee
// reads this tag and the scoring already pays `PointsKillKnife` (130) off it
// — both were written months ago with a comment saying "any future knife just
// adds melee". Without the tag the knife scores like a bullet.
info.Tags?.Add( "melee" );
hp.OnDamage( info );
// ⚠ AFTER the damage, so a cleaved zombie cannot be webbed by a swing that killed the
// primary target first — and so `LastHitWasMelee` is already latched for the kill hooks.
WidowAugments.OnKnifeHit( player, tr.GameObject, tr.HitPosition, damage );
// ⚠ Napalm Nectar's m3 Hot Blade. Beside Widow's hook because both are "what a knife hit
// does beyond damage", and both need the swing to have already landed.
FireAugments.OnKnifeHit( player, tr.GameObject, tr.HitPosition );
return $"knife — {damage:0} to {tr.GameObject.Name} "
+ $"({hp.Current:0}/{hp.Max:0} left)";
}
/// <summary>
/// Max health divided by the current round.
///
/// ⛔ ROUND, NOT A DAMAGE CONSTANT. On round N the knife takes exactly N hits,
/// whatever the health curve is doing — so it is a guaranteed opener on round 1
/// and a last resort by round 10, with no separate number to retune when zombie
/// health changes.
///
/// ⚠️ Round clamped to at least 1. It reads 0 in the lobby and before the first
/// round starts, and dividing by that is either an exception or an infinity —
/// both of which would first show up as a knife that deletes a boss.
/// </summary>
public float DamageFor( Health target )
{
int round = System.Math.Max( 1, RoundManager.Instance?.Round ?? 1 );
return target.Max / round * (1f + Bonus);
}
// ── commands ─────────────────────────────────────────────────────────────
static Knife Of()
{
var player = NZPlayer.Local;
return player.IsValid() ? player.Components.GetOrCreate<Knife>() : null;
}
/// <summary>
/// Swing from the console: `nz_knife [world|air]`.
///
/// ⚠️ AIMS AT THE NEAREST ZOMBIE BY DEFAULT, because a swing that misses proves
/// nothing about the part worth testing — the damage, the melee tag and the flesh
/// sound all live behind a hit. `nz_pap_shoot` needed exactly this and for the
/// same reason: a miss and a broken feature look identical in the log.
///
/// ⛔ THE MODES EXIST BECAUSE THE KNIFE HAS THREE OUTCOMES AND ONLY ONE OF THEM
/// WAS EVER REACHABLE FROM THE CONSOLE. `world` looks at the floor and `air`
/// looks at the sky, so hit / world / miss can each be fired on demand. The
/// sounds were wrong for weeks behind a command that could only ever produce a
/// hit — a diagnostic that can reach one branch is a diagnostic that certifies
/// one branch.
/// </summary>
[ConCmd( "nz_knife" )]
public static void Cmd( string mode = "" )
{
var k = Of();
if ( k is null ) { Log.Warning( "[nz] no player" ); return; }
var player = NZPlayer.Local;
var controller = player?.Components.Get<PlayerController>();
mode = mode?.ToLowerInvariant() ?? "";
if ( mode is "world" or "air" )
{
// Straight down finds the floor on any map; straight up finds nothing on
// almost all of them. No zombie is moved — the point is to MISS one.
if ( controller.IsValid() )
controller.EyeAngles = new Angles( mode == "world" ? 89f : -89f, 0f, 0f );
var m = k.Swing();
Log.Info( string.IsNullOrEmpty( m ) ? "[nz] knife — on cooldown" : $"[nz] {m}" );
return;
}
var z = ZombieAI.All
.Where( x => x.IsValid() && x.State != ZombieState.Dead )
.OrderBy( x => x.WorldPosition.DistanceSquared( player.WorldPosition ) )
.FirstOrDefault();
if ( z.IsValid() && controller.IsValid() )
{
// Chest height, and the zombie is brought into reach — the command exists
// to test the swing, not the walk.
var eye = controller.EyePosition;
z.WorldPosition = eye + controller.EyeAngles.ToRotation().Forward * (k.Range * 0.5f)
- Vector3.Up * 40f;
var target = z.WorldPosition + Vector3.Up * 40f;
controller.EyeAngles = Rotation.LookAt( (target - eye).Normal ).Angles();
}
var msg = k.Swing();
Log.Info( string.IsNullOrEmpty( msg ) ? "[nz] knife — on cooldown" : $"[nz] {msg}" );
}
/// <summary>
/// Everything about the swing right now: `nz_knife_state`.
///
/// ⛔ PRINTS THE GUN'S `ShouldDraw` PER SLOT, which is the half a screenshot
/// cannot tell you apart. A frame showing the gun and no knife has two possible
/// causes — the hide failed, or the blade is simply mid-wind-up and off camera —
/// and they look identical. This distinguishes them.
/// </summary>
[ConCmd( "nz_knife_state" )]
public static void State()
{
var k = Of();
if ( k is null ) { Log.Warning( "[nz] no player" ); return; }
Log.Info( $"[nz] knife: swinging={k._swinging} struck={k._struck} "
+ $"t={(float)k._sinceSwing:0.##}s of {k.PreSwing + k.PostSwing:0.##}s "
+ $"(strike at {k.PreSwing:0.##})" );
var inv = k.Components.Get<NZInventory>( FindMode.EverythingInSelf );
if ( !inv.IsValid() ) { Log.Warning( "[nz] no inventory" ); return; }
int n = 0;
foreach ( var go in inv.Weapons )
{
var wep = go.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf );
var h = wep?.ViewModelHandler;
Log.Info( $"[nz] slot {n++}: {go.Name}"
+ $" active={(go == inv.Active)}"
+ $" weapon={(wep is null ? "NULL" : "ok")}"
+ $" handler={(h is null ? "NULL" : $"ShouldDraw={h.ShouldDraw}")}"
+ $" renderer={(wep?.ViewModelRenderer is null ? "NULL" : wep.ViewModelRenderer.RenderType.ToString())}" );
}
if ( n == 0 ) Log.Info( "[nz] inventory is EMPTY — nothing to hide" );
}
/// <summary>
/// Stretch or restore the swing: `nz_knife_time [pre] [post]`, no args to reset.
///
/// ⛔ EXISTS TO MAKE A 0.75s EVENT OBSERVABLE. Every check I can make costs a
/// round trip slower than the whole swing, so at shipping speed the knife is
/// always either not-yet-started or already-finished — I would be certifying the
/// end state and calling it the animation. Stretched to 3s + 6s the wind-up,
/// strike and recovery can each be sampled, and the numbers found there are the
/// same numbers at speed.
///
/// ⚠️ Also the tuning knob: `PreSwing` is how long the blade takes to land, which
/// is the single value that decides whether the knife feels heavy or twitchy.
/// </summary>
[ConCmd( "nz_knife_time" )]
public static void SetTiming( float pre = 0.25f, float post = 0.5f )
{
var k = Of();
if ( k is null ) { Log.Warning( "[nz] no player" ); return; }
k.PreSwing = pre;
k.PostSwing = post;
Log.Info( $"[nz] knife timing: {pre:0.##}s wind-up + {post:0.##}s recovery "
+ $"= {pre + post:0.##}s per swing" );
}
/// <summary>
/// What a swing would do right now: `nz_knife_info`.
///
/// ⚠️ Prints the HITS-TO-KILL, not just the damage. "417 damage" says nothing on
/// its own — the whole point of the rule is how many swings it takes, and that is
/// the number worth checking against the round.
/// </summary>
[ConCmd( "nz_knife_info" )]
public static void Info()
{
var k = Of();
if ( k is null ) { Log.Warning( "[nz] no player" ); return; }
int round = System.Math.Max( 1, RoundManager.Instance?.Round ?? 1 );
var z = ZombieAI.All.FirstOrDefault( x => x.IsValid() && x.State != ZombieState.Dead );
var hp = z.IsValid() ? z.Components.Get<Health>() : null;
if ( !hp.IsValid() )
{
Log.Info( $"[nz] knife: round {round}, reach {k.Range:0}u r{k.Radius:0}, "
+ $"{k.Cooldown:0.##}s between swings — no zombie to measure against" );
return;
}
float dmg = k.DamageFor( hp );
Log.Info( $"[nz] knife: round {round}, {dmg:0} damage vs {hp.Max:0} max "
+ $"= {System.MathF.Ceiling( hp.Max / System.MathF.Max( dmg, 1f ) ):0} hit(s) to kill" );
}
/// <summary>
/// `nz_knife_trace` — every hit in the swing volume, in order, saying which one the
/// knife takes and which are passed through.
///
/// ⛔ THIS IS THE COMMAND THAT WOULD HAVE FOUND THE WALL BUG IN ONE RUN. From the
/// player's side "the knife does nothing here" fits a cooldown, damage, animation or
/// trace problem equally well. One line reading `[0] SOLID world 0.0u` answers it.
///
/// ⚠️ IT DOES NOT SWING — no cooldown spent, no damage dealt — so it can be run
/// standing in the exact spot that misbehaves.
/// </summary>
[ConCmd( "nz_knife_trace" )]
public static void TraceCmd()
{
var k = Of();
if ( k is null ) { Log.Warning( "[nz-knife] no player" ); return; }
var controller = k.Components.Get<PlayerController>();
var eye = controller?.EyePosition ?? k.WorldPosition + Vector3.Up * 64f;
var fwd = (controller?.EyeAngles.ToRotation() ?? k.WorldRotation).Forward;
var hits = k.Scene.Trace
.Ray( eye, eye + fwd * k.Range )
.Radius( k.Radius )
.WithoutTags( SWB.Base.Weapon.BulletTraceIgnoreTags )
.IgnoreGameObjectHierarchy( k.GameObject )
.RunAll()
.OrderBy( h => h.Distance )
.ToArray();
Log.Info( $"[nz-knife] sweep reach {k.Range:0}u radius {k.Radius:0}u"
+ $" · stab inside {k.StabRange:0}u ({k.StabAnim}), else {k.SwingAnim}"
+ $" · {hits.Length} hit(s)" );
if ( hits.Length == 0 )
{
Log.Info( "[nz-knife] nothing in the volume — a swing here would MISS" );
return;
}
var taken = false;
for ( int i = 0; i < hits.Length; i++ )
{
var h = hits[i];
if ( !h.Hit || !h.GameObject.IsValid() ) { Log.Info( $"[nz-knife] [{i}] <invalid>" ); continue; }
var hp = h.GameObject.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
// ⚠️ `StartedSolid` IS PRINTED because it is the whole mechanism. Seeing
// `SOLID` next to `0.0u` turns "the knife is broken" into "the sphere starts
// inside the wall".
// ⚠️ Says WHICH ANIMATION this hit would produce, because "the stab never
// plays" and "the stab range is wrong" look identical from the player's
// side — one number next to the distance separates them.
var mark = hp.IsValid()
? ( taken
? " damageable"
: $" <- TAKEN, plays {( h.Distance <= k.StabRange ? k.StabAnim : k.SwingAnim )}" )
: " passed through (no Health)";
if ( hp.IsValid() ) taken = true;
Log.Info( $"[nz-knife] [{i}] {h.GameObject.Name,-26} {h.Distance,6:0.0}u"
+ $"{( h.StartedSolid ? " SOLID" : " " )}"
+ $" {h.Surface?.ResourceName ?? "-",-14}{mark}" );
}
if ( !taken )
Log.Info( "[nz-knife] no damageable hit — a swing here would only clang" );
}
}