Viewmodel component for a frag grenade. Creates a local skinned model and hands renderers parented to the player, manages a viewmodel camera, plays animation sequences, and updates position/rotation each frame to follow the first-person camera.
using Sandbox;
using SWB.Base;
using SWB.Shared;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// The frag on screen — pin, hold, throw.
///
/// ⚠️ A NEAR-TWIN OF <see cref="KnifeViewModel"/>, deliberately. Both show a
/// non-inventory item on the shared viewmodel camera for a moment and put it away,
/// and every trap in that file applies here: enable the renderer BEFORE touching
/// `Sequence`, never write `WorldPosition` (that is the PLAYER — this component lives
/// on them), and create the viewmodel camera if no weapon has.
///
/// ⛔ THE CLIPS ARE NOT FITTED TO A WINDOW HERE, unlike the knife's swipe and the
/// draw/holster. `pullpin` runs once and then the grenade is simply HELD for as long
/// as the player holds the key — which is an open-ended duration, not a window to
/// stretch a clip across. Squashing `pullpin` into the cook time would make a quick
/// tap play the pin-pull at ten times speed.
/// </summary>
public sealed class GrenadeViewModel : Component
{
public const string ModelPath = "weapons/frag/v_frag.vmdl";
public const string HandsPath = "weapons/hands/v_hands.vmdl";
/// <summary>Placement offset — right / forward / up, then pitch/yaw/roll.</summary>
public static Vector3 Offset { get; set; } = new( 0f, 0f, 0f );
public static Angles Rot { get; set; } = new( 0f, 0f, 0f );
public static float Fov { get; set; } = 75f;
SkinnedModelRenderer _renderer;
SkinnedModelRenderer _hands;
GameObject _go;
bool _failed;
IPlayerBase Owner => Components.Get<NZPlayer>();
public bool Visible => _renderer.IsValid() && _renderer.Enabled;
public string[] Sequences
{
get
{
try { return _renderer?.Sequence?.SequenceNames?.ToArray() ?? Array.Empty<string>(); }
catch ( Exception ) { return Array.Empty<string>(); }
}
}
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 — `IsError` is the test.
if ( model is null || model.IsError )
{
_failed = true;
Log.Warning( $"[nz] grenade viewmodel '{ModelPath}' not found — throwing blind" );
return false;
}
if ( Owner is null ) return false;
_go = new GameObject( true, "Viewmodel - grenade" );
_go.SetParent( GameObject, false );
_go.Tags.Add( TagsHelper.ViewModel );
_go.NetworkMode = NetworkMode.Never;
_renderer = _go.Components.Create<SkinnedModelRenderer>();
if ( model.AnimGraph is null ) _renderer.UseAnimGraph = false;
_renderer.Model = model;
_renderer.Enabled = false;
// ⚠️ 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;
}
Log.Info( $"[nz] grenade viewmodel ready — animations={model.AnimationCount}"
+ $" hands={(_hands is null ? "NO" : "yes")}" );
return true;
}
/// <summary>Show it and start a clip.</summary>
public void Show( string sequence, bool loop = false )
{
if ( !Ensure() ) return;
// ⛔ ENABLED FIRST. `Sequence.Time`'s setter throws on a renderer with no
// scene object, so setting the clip first crashes on every repeat play — the
// knife's second swing found this the hard way.
_renderer.Enabled = true;
if ( _hands.IsValid() ) _hands.Enabled = true;
Play( sequence, loop );
}
public void Hide()
{
if ( _renderer.IsValid() ) _renderer.Enabled = false;
if ( _hands.IsValid() ) _hands.Enabled = false;
}
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] grenade has no sequence '{name}' — has: "
+ string.Join( ", ", names ) );
return;
}
}
catch ( Exception ) { }
try
{
if ( _renderer.Sequence.Name == name )
_renderer.Sequence.Time = 0f;
else
_renderer.Sequence.Name = name;
_renderer.Sequence.Looping = loop;
}
catch ( Exception ) { }
_renderer.PlaybackRate = 1f;
}
/// <summary>Duration of the loaded clip, 0 if unknown.</summary>
public float Duration
{
get
{
if ( !_renderer.IsValid() ) return 0f;
try { return _renderer.Sequence.Duration; }
catch ( Exception ) { return 0f; }
}
}
/// <summary>
/// The viewmodel camera, created when no weapon has made one — a player can throw
/// a grenade with an empty inventory.
/// </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>();
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 } );
if ( owner.Camera.IsValid() )
owner.Camera.RenderExcludeTags.Add( TagsHelper.ViewModel );
owner.ViewModelCamera = cam;
return cam;
}
protected override void OnUpdate()
{
if ( !_renderer.IsValid() || !_renderer.Enabled ) return;
var owner = Owner;
if ( owner is null ) return;
if ( !owner.IsFirstPerson ) { Hide(); return; }
var cam = EnsureCamera( owner );
if ( !cam.IsValid() ) return;
// Nothing else drives this camera when the player holds no weapon.
cam.WorldPosition = Scene.Camera.WorldPosition;
cam.WorldRotation = Scene.Camera.WorldRotation;
cam.FieldOfView = Screen.CreateVerticalFieldOfView( Fov );
_renderer.RenderType = ModelRenderer.ShadowRenderType.Off;
if ( _hands.IsValid() ) _hands.RenderType = ModelRenderer.ShadowRenderType.Off;
// ⛔ `_go`, NEVER `WorldPosition` — this component is on the PLAYER, so writing
// the transform here teleports them to their own eye every frame.
var rot = Scene.Camera.WorldRotation
* Rotation.From( Rot.pitch, Rot.yaw, Rot.roll )
* Rotation.From( ViewModelHandler.VMPitch, ViewModelHandler.VMYaw,
ViewModelHandler.VMRoll );
_go.WorldRotation = rot;
_go.WorldPosition = Scene.Camera.WorldPosition
+ Offset.x * rot.Right
+ Offset.y * rot.Forward
+ Offset.z * rot.Up;
}
// ── commands ─────────────────────────────────────────────────────────────
static GrenadeViewModel Of()
{
var p = NZPlayer.Local;
return p.IsValid() ? p.Components.GetOrCreate<GrenadeViewModel>() : null;
}
/// <summary>Placement: `nz_nade_vm <x> <y> <z> [p] [y] [r]`.</summary>
[ConCmd( "nz_nade_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] grenade vm: pos {x:0.##} {y:0.##} {z:0.##} ang {pitch:0.##} {yaw:0.##} {roll:0.##}" );
}
/// <summary>What it can play, with durations: `nz_nade_anims`.</summary>
[ConCmd( "nz_nade_anims" )]
public static void ListAnims()
{
var vm = Of();
if ( vm is null ) { Log.Warning( "[nz] no player" ); return; }
if ( !vm.Ensure() ) return;
// ⛔ Enabled before the list is read — `SequenceNames` throws without a scene
// object and the getter swallows it into an empty array, which reads as
// "the model has no animations".
var was = vm._renderer.Enabled;
vm._renderer.Enabled = true;
var names = vm.Sequences;
if ( names.Length == 0 )
{
vm._renderer.Enabled = was;
Log.Info( "[nz] grenade: no sequences" );
return;
}
Log.Info( $"[nz] grenade sequences ({names.Length}):" );
string current = null;
try
{
current = vm._renderer.Sequence.Name;
foreach ( var n in names )
{
vm._renderer.Sequence.Name = n;
Log.Info( $"[nz] {n,-12} {vm._renderer.Sequence.Duration:0.00}s" );
}
}
catch ( Exception ) { }
finally
{
try { if ( current is not null ) vm._renderer.Sequence.Name = current; }
catch ( Exception ) { }
vm._renderer.Enabled = was;
}
}
/// <summary>Hold a clip up to look at it: `nz_nade_show [seq]`.</summary>
[ConCmd( "nz_nade_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] grenade showing '{sequence}' ({vm.Duration:0.##}s, looping)" );
}
/// <summary>Put it away: `nz_nade_hide`.</summary>
[ConCmd( "nz_nade_hide" )]
public static void HideCmd()
{
Of()?.Hide();
Log.Info( "[nz] grenade viewmodel hidden" );
}
}