A component that renders a separate first-person knife viewmodel for the player. It builds and manages skinned model renderers for the blade and hands, drives a viewmodel camera, plays and times animation sequences, and exposes console commands to position, inspect, show, hide and list animations.
using Sandbox;
using SWB.Base;
using SWB.Shared;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// The knife's own viewmodel — the BO1 S.O.G. knife, shown only while swinging.
///
/// ⛔ NOT AN SWB WEAPON AND NOT IN THE INVENTORY. The obvious build is to make the
/// knife a real <see cref="Weapon"/> and swap slots, which is what GMod does — but
/// GMod does it because a SWEP is its ONLY way to change the viewmodel. We have two
/// weapon slots that the player owns, a wall-buy that reads them, Pack-a-Punch that
/// writes them and an inventory that persists them; routing a 0.75s animation
/// through all of that means every one of those systems has to learn about a weapon
/// that is never carried, bought or upgraded. This is a second renderer instead.
///
/// ⚠️ IT BORROWS SWB's CAMERA rather than making one. `Owner.ViewModelCamera` is
/// created once per player and shared by every weapon (`Weapon.CreateViewModel`);
/// a second camera at the same position with the same tag renders the viewmodel
/// layer twice, which reads as z-fighting on the blade rather than as a duplicate.
/// </summary>
public sealed class KnifeViewModel : Component
{
/// <summary>
/// Where the compiled knife viewmodel lives.
///
/// ⚠️ `knife`, NOT `nz_knife`. The porter strips the `nz_` prefix when it lays
/// the folder down, so `--name nz_knife` produces `weapons/knife/v_knife.vmdl`.
/// Exactly one of the 32 ported weapons kept its prefix (the MPL), and assuming
/// the prefix survives has already cost this project an invisible gun in the
/// mystery box.
/// </summary>
/// ⛔ THE BO2 KNIFE, NOT THE ARC9 SOG ONE. The SOG model is still in the project
/// at `weapons/knife/v_knife.vmdl` with its nine sequences — point this back at it
/// and set `Knife.SwingAnim` to "swipe" to revert in one step.
///
/// ⚠️ THE TWO MODELS DO NOT SHARE SEQUENCE NAMES. SOG has
/// idle/idle_long/draw/draw_fast/holster/holster_fast/swipe/stab/sprint_loop;
/// this has draw/melee/stick. Swapping the path WITHOUT swapping `SwingAnim`
/// leaves the knife silently animation-less — `Show` looks the name up and finds
/// nothing rather than erroring.
public const string ModelPath = "weapons/knife_bo2/v_knife_bo2.vmdl";
/// <summary>
/// Placement offset, live-tunable — right / forward / up, then pitch/yaw/roll.
///
/// ⚠️ STATIC AND CONSOLE-DRIVEN for the same reason `nz_vm_pos` is: this is a
/// hunt for six numbers, and a property on a prefab costs a restart per attempt.
/// Bake them into the defaults once they are right.
/// </summary>
public static Vector3 Offset { get; set; } = new( 0f, 0f, 0f );
public static Angles Rot { get; set; } = new( 0f, 0f, 0f );
/// <summary>
/// Field of view for the knife specifically.
///
/// ⚠️ The Lua says `ViewModelFOVBase = 75`, and ARC9's viewmodel FOV is the same
/// quantity SWB's is — so this is a copied value, not a guessed one.
/// </summary>
public static float Fov { get; set; } = 75f;
/// <summary>
/// The arms, bonemerged onto the knife.
///
/// ⛔ THE KNIFE MESH IS BLADE-ONLY. ARC9's `c_` model carries 85 bones — the arm
/// skeleton is all there and the swipe animates it — but the only MESH in the QC
/// is `sog_knife`, because ARC9 bonemerges its own arms at runtime. Ported
/// as-is, the result is a knife swinging through the air by itself. SWB solves
/// this with `ViewModelHands` and every gun in the project already points at
/// this same file.
/// </summary>
public const string HandsPath = "weapons/hands/v_hands.vmdl";
SkinnedModelRenderer _renderer;
SkinnedModelRenderer _hands;
GameObject _go;
bool _failed;
/// <summary>Is the knife on screen right now?</summary>
public bool Visible => _renderer.IsValid() && _renderer.Enabled;
/// <summary>The sequences the compiled model actually carries, for the log.</summary>
public string[] Sequences
{
get
{
try { return _renderer?.Sequence?.SequenceNames?.ToArray() ?? Array.Empty<string>(); }
catch ( Exception ) { return Array.Empty<string>(); }
}
}
IPlayerBase Owner => Components.Get<NZPlayer>() as IPlayerBase;
/// <summary>
/// Build the renderer on first use.
///
/// ⚠️ RETURNS FALSE RATHER THAN THROWING WHEN THE MODEL IS MISSING, and says so
/// exactly once. The knife's damage, sound and scoring do not depend on having a
/// model, so a missing asset must degrade to the invisible swing we already had
/// — not take the V key down with it while the asset is still being ported.
/// </summary>
bool Ensure()
{
if ( _renderer.IsValid() ) return true;
if ( _failed ) return false;
// ⛔ NEVER ON SOMEBODY ELSE'S BODY. A viewmodel is a FIRST-PERSON object: SWB, the knife
// and the grenade all parent theirs to the OWNER'S BODY rather than to a camera, so yours
// sits where your camera is and looks right — and one built on a remote player's body sits
// at THEIR feet, out in the world, drawn by your viewmodel camera. Measured, one session,
// the two machines at the same instant:
//
// HOST (nikolai) 'Viewmodel - knife' under 'Player (Milhouse)' hands=nikolai_arms
// CLIENT (dempsey) 'Viewmodel - knife' under 'Player (Cifosi)' hands=dempsey_arms
//
// Each machine had built one on the OTHER player, wearing its OWN character's arms. The
// user's description was exact: *"the other arms are really the other player's arms, they
// share the same model and are in the same place."*
//
// ⚠️ IT HAPPENS BECAUSE A SWING IS SEEN BY EVERYONE. `Knife.ViewModel` is
// `GetOrCreate<KnifeViewModel>()`, so any machine that runs the swing on a remote body
// builds a viewmodel there too. The guard belongs HERE, at the one place the object is
// created, rather than at each caller.
if ( PlayerPresence.Theirs( GameObject ) ) return false;
var model = Model.Load( ModelPath );
// ⚠️ `Model.Load` returns the ERROR MODEL, never null, when the path does not
// resolve — checking for null here would pass and put a checkerboard box on
// screen. `IsError` is the real test.
if ( model is null || model.IsError )
{
_failed = true;
Log.Warning( $"[nz] knife viewmodel '{ModelPath}' not found — "
+ "swinging without one. Port the model and this lights up on its own." );
return false;
}
var owner = Owner;
if ( owner is null ) return false;
_go = new GameObject( true, "Viewmodel - knife" );
_go.SetParent( GameObject, false );
_go.Tags.Add( TagsHelper.ViewModel );
_go.NetworkMode = NetworkMode.Never;
_renderer = _go.Components.Create<SkinnedModelRenderer>();
// ⛔ ANIMGRAPH OFF FOR A PORTED MODEL — the same rule the guns needed. With
// the graph on, `Sequence.Name` is ignored and the knife never animates,
// silently, because neither route reports an unknown name.
if ( model.AnimGraph is null ) _renderer.UseAnimGraph = false;
_renderer.Model = model;
_renderer.Enabled = false;
// ⚠️ The arms ride the KNIFE's skeleton via BoneMergeTarget — they have no
// animation of their own, so if the merge silently matches nothing they sit
// at bind pose, which for these arms is spread wide and below the camera.
// That reads as "the hands did not load" rather than as a failed merge.
// ⚠️ THE CHARACTER'S HANDS IF THEY HAVE ONE. The knife and the grenade build their own
// viewmodels rather than going through SWB's, so each needs the override explicitly —
// otherwise picking Takeo changes your gun hands and not the ones holding a grenade.
var handsModel =
PlayerCharacters.HandsFor( Owner as NZPlayer )
?? Model.Load( HandsPath );
if ( handsModel is not null && !handsModel.IsError )
{
_hands = _go.Components.Create<SkinnedModelRenderer>();
_hands.Model = handsModel;
_hands.BoneMergeTarget = _renderer;
_hands.Enabled = false;
}
else
{
Log.Warning( $"[nz] knife hands '{HandsPath}' not found — blade will "
+ "swing on its own" );
}
Log.Info( $"[nz] knife viewmodel ready — animations={model.AnimationCount}"
+ $" hands={(_hands is null ? "NO" : "yes")} (sequences: nz_knife_anims)" );
return true;
}
/// <summary>
/// Show the knife and start a clip.
///
/// ⛔ ENABLED FIRST, THEN THE CLIP — and it must be this order. I originally set
/// the clip first, reasoning that the first frame drawn should be frame 0 of the
/// swipe rather than the bind pose. But `Sequence.Time` has no backing scene
/// object while the renderer is disabled and its SETTER throws
/// `NullReferenceException` — so every swing after the first threw, because the
/// second swing takes the "same clip, rewind it" branch. The bind-pose flash I
/// was avoiding was hypothetical; the crash was real and killed the whole swing.
///
/// ⚠️ Nothing is actually drawn until the end of the frame, so setting the clip
/// immediately after enabling still presents frame 0 — the flash never existed.
/// </summary>
public void Show( string sequence, float fitSeconds = 0f, bool loop = false )
{
if ( !Ensure() ) return;
_renderer.Enabled = true;
if ( _hands.IsValid() ) _hands.Enabled = true;
Play( sequence, loop );
// ⛔ THE CLIP IS STRETCHED TO THE GAMEPLAY WINDOW, NOT THE OTHER WAY ROUND.
// `swipe` measures 1.25s on the live renderer, while the swing window is
// ARC9's tuned 0.25 + 0.5 = 0.75s. Playing it at natural speed would leave
// the blade still travelling for a quarter second after the knife has already
// hit and the player can act again, which reads as input lag. The TIMING is
// the designed value and the animation serves it.
//
// ⚠️ AFTER `Play`, never before — the duration belongs to whichever clip is
// currently selected, so reading it first measures the OUTGOING one.
if ( fitSeconds > 0f )
{
var d = Duration;
if ( d > 0f ) _renderer.PlaybackRate = d / fitSeconds;
}
_renderer.Enabled = true;
}
/// <summary>Take it off screen.</summary>
public void Hide()
{
if ( _renderer.IsValid() ) _renderer.Enabled = false;
if ( _hands.IsValid() ) _hands.Enabled = false;
}
/// <summary>
/// Play a clip by name, validating against the model first.
///
/// ⛔ AN UNKNOWN NAME DROPS THE RENDERER TO THE BIND POSE — it does not error and
/// does not hold the current pose. `Weapon.PlaySequence` carries the same guard
/// and the same scar: a fallback asking for a name the model lacked left a gun
/// permanently sideways.
/// </summary>
public void Play( string name, bool loop = false )
{
if ( !_renderer.IsValid() || string.IsNullOrEmpty( name ) ) return;
try
{
var names = _renderer.Sequence.SequenceNames;
if ( names is not null && !names.Contains( name ) )
{
Log.Warning( $"[nz] knife has no sequence '{name}' — model has: "
+ string.Join( ", ", names ) );
return;
}
}
catch ( Exception )
{
// ⚠️ `SequenceNames` THROWS before the renderer has a scene object, which
// is the case on the frame it is created. Validation is a nicety and must
// never be able to stop the thing it checks.
}
// ⚠️ Rewind when it is already current — assigning the name it already holds
// does nothing, so a second swing would sit frozen on the last frame.
//
// ⛔ THE REWIND THROWS ON A DISABLED RENDERER. `Sequence.Time`'s setter
// dereferences a scene object that does not exist until the renderer is
// enabled, and an NRE here takes the entire swing with it — no knife, no
// strike, no gun restored. Callers enable first; this guard is for the ones
// that forget, since a viewmodel that fails to rewind is a cosmetic bug and
// one that throws is a gameplay bug.
try
{
if ( _renderer.Sequence.Name == name )
_renderer.Sequence.Time = 0f;
else
_renderer.Sequence.Name = name;
// ⚠️ AFTER the name is set, not before — the accessor describes whichever
// clip is currently selected, so setting Looping first configures the
// OUTGOING animation.
_renderer.Sequence.Looping = loop;
}
catch ( Exception )
{
// Not ready this frame. The next Show sets it again.
}
_renderer.PlaybackRate = 1f;
}
/// <summary>
/// How far through the current clip we are, 0-1. Used to time the trace to the
/// blade rather than to a stopwatch.
/// </summary>
public float Progress
{
get
{
if ( !_renderer.IsValid() ) return 1f;
try
{
var d = _renderer.Sequence.Duration;
return d > 0f ? MathX.Clamp( _renderer.Sequence.Time / d, 0f, 1f ) : 1f;
}
catch ( Exception ) { return 1f; }
}
}
/// <summary>Duration of the clip currently loaded, or 0 if unknown.</summary>
public float Duration
{
get
{
if ( !_renderer.IsValid() ) return 0f;
try { return _renderer.Sequence.Duration; }
catch ( Exception ) { return 0f; }
}
}
/// <summary>
/// The camera that draws the viewmodel layer, created if no weapon has made one.
///
/// ⛔ THE KNIFE CANNOT DEPEND ON HOLDING A GUN. `Weapon.CreateViewModel` builds
/// this camera lazily and only the first weapon to deploy does it — so a player
/// with an empty inventory, or one who is DOWNED (the knife is explicitly the one
/// thing a crawling player still has), had nothing rendering the `ViewModel` tag
/// at all. The knife was positioned perfectly and simply not drawn by anything.
///
/// ⚠️ ASSIGNED BACK TO `Owner.ViewModelCamera`, so a weapon deploying later
/// reuses this one instead of making a second. Two cameras at the same position
/// with the same tag render the layer twice.
/// </summary>
CameraComponent EnsureCamera( IPlayerBase owner )
{
if ( owner.ViewModelCamera.IsValid() ) return owner.ViewModelCamera;
var go = new GameObject( true, "ViewModelCamera" );
go.SetParent( owner.GameObject, false );
var cam = go.Components.Create<CameraComponent>();
// ⚠️ These five are copied from `Weapon.CreateViewModel` deliberately — a
// viewmodel camera that clears colour paints over the world, and one at the
// wrong priority draws under it.
cam.ClearFlags = ClearFlags.Depth | ClearFlags.Stencil;
cam.ZNear = 1;
cam.Priority = 2;
cam.TargetEye = StereoTargetEye.RightEye;
cam.RenderTags.Add( new TagSet() { TagsHelper.ViewModel, TagsHelper.Light } );
// ⛔ WITHOUT THIS THE MAIN CAMERA DRAWS THE KNIFE TOO — at world scale, one
// unit from the eye, filling the screen.
if ( owner.Camera.IsValid() )
owner.Camera.RenderExcludeTags.Add( TagsHelper.ViewModel );
owner.ViewModelCamera = cam;
Log.Info( "[nz] knife created the viewmodel camera (no weapon had made one)" );
return cam;
}
protected override void OnUpdate()
{
if ( !_renderer.IsValid() || !_renderer.Enabled ) return;
var owner = Owner;
if ( owner is null ) return;
// Third person has no viewmodel to draw.
if ( !owner.IsFirstPerson ) { Hide(); return; }
var cam = EnsureCamera( owner );
if ( !cam.IsValid() ) return;
// ⛔ THE VIEWMODEL CAMERA IS DRIVEN TO THE SCENE CAMERA HERE, because nothing
// else will when the player is holding nothing. For the guns, each weapon's
// ViewModelHandler does this in its own OnUpdate — so an empty-handed player
// has a viewmodel camera parked whereever it was left, and the knife renders
// correctly into a view of somewhere else.
cam.WorldPosition = Scene.Camera.WorldPosition;
cam.WorldRotation = Scene.Camera.WorldRotation;
cam.FieldOfView = Screen.CreateVerticalFieldOfView( Fov );
// ⛔ VISIBLE, NO SHADOW. `ShadowsOnly` is the INVISIBLE state — the handler
// uses it to hide a holstered gun — so `Off` is what draws the knife.
_renderer.RenderType = ModelRenderer.ShadowRenderType.Off;
if ( _hands.IsValid() ) _hands.RenderType = ModelRenderer.ShadowRenderType.Off;
// ⛔ NEVER WRITE `WorldPosition` HERE — THAT IS THE PLAYER. ViewModelHandler
// does exactly that and is right to: it is a component ON the viewmodel
// object. This one lives on the PLAYER, so the same two lines teleport the
// player to their own eye every frame, which compounds — eye is derived from
// the body, so the body climbs by eye height per frame and the player is in
// orbit within a second. Copying a line from a component with a different
// owner is how a movement bug gets written into a rendering file.
//
// ⚠️ THE CHILD, `_go`, IS THE ONLY THING THIS MAY MOVE.
// ⛔ THE 90° CORRECTION APPLIES HERE TOO. Every ARC9 BO1 viewmodel is
// authored a quarter turn from the axis SWB expects, and the knife is one of
// them — without it the blade sits off screen entirely, which reads as "the
// model failed to load" rather than as a rotation.
//
// ⚠️ COMPOSED as a rotation, never summed into a euler. Adding to the angles
// re-maps what the other offsets mean; that mistake is written up at length
// in ViewModelHandler.
var rot = Scene.Camera.WorldRotation
* Rotation.From( Rot.pitch, Rot.yaw, Rot.roll )
* Rotation.From( ViewModelHandler.VMPitch, ViewModelHandler.VMYaw,
ViewModelHandler.VMRoll );
_go.WorldRotation = rot;
// Position AFTER rotation — the offset is in the weapon's own frame.
_go.WorldPosition = Scene.Camera.WorldPosition
+ Offset.x * rot.Right
+ Offset.y * rot.Forward
+ Offset.z * rot.Up;
}
// ── commands ─────────────────────────────────────────────────────────────
static KnifeViewModel Of()
{
var player = NZPlayer.Local;
return player.IsValid() ? player.Components.GetOrCreate<KnifeViewModel>() : null;
}
/// <summary>Nudge the placement: `nz_knife_vm <x> <y> <z> [pitch] [yaw] [roll]`.</summary>
[ConCmd( "nz_knife_vm" )]
public static void SetPlacement( float x = 0f, float y = 0f, float z = 0f,
float pitch = 0f, float yaw = 0f, float roll = 0f )
{
Offset = new Vector3( x, y, z );
Rot = new Angles( pitch, yaw, roll );
Log.Info( $"[nz] knife vm: pos {x:0.##} {y:0.##} {z:0.##}"
+ $" ang {pitch:0.##} {yaw:0.##} {roll:0.##} fov {Fov:0.#}" );
}
/// <summary>
/// NUDGE all six: `nz_knife_vm_add <x> <y> <z> [p] [y] [r]`.
/// The one you want while converging — typing absolutes means doing the sum in
/// your head every attempt.
/// </summary>
[ConCmd( "nz_knife_vm_add" )]
public static void AddPlacement( float x = 0f, float y = 0f, float z = 0f,
float pitch = 0f, float yaw = 0f, float roll = 0f )
{
// ⚠️ Component-wise rather than `Rot += new Angles(...)`. Angles is a struct
// whose operators are not worth a guess in a file that cannot be compiled
// right now — this reads the same and cannot fail to resolve.
SetPlacement(
Offset.x + x, Offset.y + y, Offset.z + z,
Rot.pitch + pitch, Rot.yaw + yaw, Rot.roll + roll );
}
/// <summary>What the compiled knife can actually play: `nz_knife_anims`.</summary>
[ConCmd( "nz_knife_anims" )]
public static void ListAnims()
{
var vm = Of();
if ( vm is null ) { Log.Warning( "[nz] no player" ); return; }
// ⚠️ Force the build — the list is empty until the renderer exists, and
// "no sequences" would otherwise be indistinguishable from "never shown".
vm.Ensure();
// ⛔ ENABLED BEFORE THE LIST IS READ. `SequenceNames` throws while the
// renderer has no scene object, and the getter swallows that into an empty
// array — so this reported "no sequences (model missing)" on a model with
// nine, and the message blamed the asset for a state problem. Same root as
// the `Sequence.Time` crash: the sequence table does not exist until the
// renderer does.
bool wasEnabled = vm._renderer.Enabled;
string current = null;
vm._renderer.Enabled = true;
var names = vm.Sequences;
if ( names.Length == 0 )
{
vm._renderer.Enabled = wasEnabled;
Log.Info( "[nz] knife: no sequences — the model really does carry none" );
return;
}
Log.Info( $"[nz] knife sequences ({names.Length}):" );
// ⚠️ MEASURED, NOT READ OFF THE QC. The QC declares 30fps and a frame count,
// but the live figure is what the resolver actually plays — `swipe` counts as
// 31 frames at 30fps (1.0s) and reports 1.25s. Only one of those is the truth
// the timing has to fit, and it is this one.
//
// ⛔ The current clip is saved and put back — measuring must not disturb a
// swing in progress.
try
{
current = vm._renderer.Sequence.Name;
foreach ( var n in names )
{
vm._renderer.Sequence.Name = n;
Log.Info( $"[nz] {n,-14} {vm._renderer.Sequence.Duration:0.00}s" );
}
}
catch ( Exception e )
{
Log.Warning( $"[nz] could not measure: {e.Message}" );
}
finally
{
try { if ( current is not null ) vm._renderer.Sequence.Name = current; }
catch ( Exception ) { }
vm._renderer.Enabled = wasEnabled;
}
}
/// <summary>
/// Hold a clip on screen to look at it: `nz_knife_show [sequence]`.
///
/// ⚠️ Exists because a 0.75s swing cannot be judged while it is happening — the
/// placement hunt needs the blade to sit still.
///
/// ⛔ LOOPS, WHICH IS THE ENTIRE POINT. Played once, a clip finishes in about a
/// second and FREEZES ON ITS LAST FRAME — and the last frame of `swipe` is the
/// blade already withdrawn. I dialled placement against that frozen end pose once
/// and was measuring a position the knife only ever holds when the swing is over.
/// Judge placement against a LOOPING `idle`; that is the pose the model is
/// authored around.
/// </summary>
[ConCmd( "nz_knife_show" )]
public static void ShowCmd( string sequence = "idle" )
{
var vm = Of();
if ( vm is null ) { Log.Warning( "[nz] no player" ); return; }
vm.Show( sequence, loop: true );
Log.Info( $"[nz] knife showing '{sequence}' ({vm.Duration:0.##}s, looping) — "
+ "nz_knife_hide to put it away" );
}
/// <summary>
/// Where the blade actually IS: `nz_knife_where`.
///
/// ⛔ MEASURED IN THE CAMERA'S OWN FRAME — right / forward / up — not in world
/// axes. "The knife is at 4521,3579,1185" answers nothing; "the mesh is 41 units
/// forward and 30 up from the eye" is the correction, already in the units
/// `nz_knife_vm` takes. The mystery box needed exactly this and for the same
/// reason: a world-Z reading was meaningless for a box mounted on a wall.
///
/// ⚠️ REPORTS THE MESH CENTRE, NOT THE OBJECT ORIGIN. A ported `c_` viewmodel is
/// not authored around its origin — the ASP and Uzi meshes sit ~50 units above
/// theirs — so an origin sitting exactly on the camera still draws the blade
/// across the room. The two numbers are printed side by side because their
/// DIFFERENCE is the whole diagnosis.
/// </summary>
[ConCmd( "nz_knife_where" )]
public static void Where()
{
var vm = Of();
if ( vm is null ) { Log.Warning( "[nz] no player" ); return; }
if ( !vm.Ensure() ) return;
var cam = Game.ActiveScene?.Camera;
if ( !cam.IsValid() ) { Log.Warning( "[nz] no scene camera" ); return; }
var r = vm._renderer;
var b = r.Bounds;
var rot = cam.WorldRotation;
Vector3 Local( Vector3 world )
{
var d = world - cam.WorldPosition;
return new Vector3( d.Dot( rot.Right ), d.Dot( rot.Forward ), d.Dot( rot.Up ) );
}
var origin = Local( vm._go.WorldPosition );
var centre = Local( b.Center );
Log.Info( $"[nz] knife — enabled={r.Enabled} seq='{r.Sequence.Name}' "
+ $"rate={r.PlaybackRate:0.##}" );
Log.Info( $"[nz] origin right {origin.x:0.#} fwd {origin.y:0.#} up {origin.z:0.#}" );
Log.Info( $"[nz] MESH right {centre.x:0.#} fwd {centre.y:0.#} up {centre.z:0.#}"
+ $" (size {b.Size.Length:0.#}u)" );
Log.Info( $"[nz] offset now {Offset} ang {Rot} — to centre the mesh on the "
+ $"eye: nz_knife_vm {Offset.x - centre.x:0.#} {Offset.y - centre.y:0.#} "
+ $"{Offset.z - centre.z:0.#}" );
// ⛔ THE DECIDING TEST FOR THE ARMS. A BoneMergeTarget that matches NOTHING
// does not error — the arms simply stay at bind pose, which for this set is
// spread wide and below the camera, i.e. invisible rather than visibly wrong.
// "hands=yes" only proves the MODEL loaded. If the merge took, the hands'
// bounds sit on the knife's; if it silently no-oped, they are metres apart.
// `nz_wep_hands` learned this the hard way for the guns.
if ( vm._hands.IsValid() )
{
var h = vm._hands.Bounds;
var gap = h.Center.Distance( b.Center );
Log.Info( $"[nz] hands enabled={vm._hands.Enabled} "
+ $"merged={(vm._hands.BoneMergeTarget is null ? "NO TARGET" : "target set")} "
+ $"gap-to-blade {gap:0.#}u "
+ $"— {(gap < 40f ? "MERGED" : "BIND POSE, merge matched nothing")}" );
}
else
{
Log.Info( "[nz] hands NONE — blade swings on its own" );
}
}
/// <summary>Put it away again: `nz_knife_hide`.</summary>
[ConCmd( "nz_knife_hide" )]
public static void HideCmd()
{
Of()?.Hide();
Log.Info( "[nz] knife hidden" );
}
}