Player/GrenadeViewModel.cs

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.

File Access
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 &lt;x&gt; &lt;y&gt; &lt;z&gt; [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" );
	}
}