HookGun.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>Held utility that launches a latching hook and pulls the player to the hit point.</summary>
public sealed class HookGun : Component
{
	private enum HookState
	{
		Idle,
		Flying,
		Pulling,
	}

	/// <summary>Transform at the barrel; the hook spawns here in VR.</summary>
	[Property] public GameObject MuzzleTransform { get; set; }

	/// <summary>Prefab cloned when the hook is fired.</summary>
	[Property] public GameObject HookPrefab { get; set; }

	/// <summary>Hook travel speed in world units per second.</summary>
	[Property] public float HookSpeed { get; set; } = 2500f;

	/// <summary>Max distance the hook can travel before it retracts.</summary>
	[Property] public float MaxRange { get; set; } = 2500f;

	/// <summary>Player pull speed toward the latch point in world units per second.</summary>
	[Property, Group( "Grapple" )]
	public float PullSpeed { get; set; } = 700f;

	/// <summary>Distance from the latch point at which the pull stops yanking.</summary>
	[Property, Group( "Grapple" )]
	public float StopDistance { get; set; } = 48f;

	/// <summary>Line drawn from the muzzle to the hook tip / latch point.</summary>
	[Property] public LineRenderer Rope { get; set; }

	[Sync] private bool RopeActive { get; set; }
	[Sync] private Vector3 RopeEnd { get; set; }

	private HookState _state = HookState.Idle;
	private HookProjectile _activeHook;
	private Vector3 _attachPoint;
	private bool _ownedGrapple;

	private bool IsHeld =>
		GameObject.Components.GetAll<GrabPoint>( FindMode.InDescendants ).Any( g => g.IsGrabbed );

	protected override void OnUpdate()
	{
		if ( !IsProxy )
		{
			if ( _state != HookState.Idle && !IsHeld )
				Retract();
			else if ( _ownedGrapple && !IsPlayerStillGrappling() )
				Retract();
		}

		UpdateRopeEnd();
		UpdateRopeVisual();
	}

	protected override void OnDestroy()
	{
		if ( !IsProxy )
			Retract();
	}

	/// <summary>Launch the hook when idle, or retract it while flying / pulling.</summary>
	public void Fire()
	{
		if ( IsProxy )
			return;

		if ( _state == HookState.Idle )
			Launch();
		else
			Retract();
	}

	public void OnAttached( Vector3 hitPosition )
	{
		if ( IsProxy || _state != HookState.Flying )
			return;

		_attachPoint = hitPosition;
		_state = HookState.Pulling;
		RopeActive = true;
		RopeEnd = hitPosition;

		var playerController = ResolvePlayerController();
		if ( playerController.IsValid() )
		{
			playerController.BeginGrapple( hitPosition, PullSpeed, StopDistance );
			_ownedGrapple = true;
		}
	}

	public void OnHookMissed()
	{
		if ( IsProxy )
			return;

		if ( _state != HookState.Flying )
			return;

		_activeHook = null;
		_state = HookState.Idle;
		RopeActive = false;
	}

	private void Launch()
	{
		if ( HookPrefab is null || MuzzleTransform is null )
			return;

		Vector3 spawnPosition;
		Vector3 shootDirection;

		if ( Game.IsRunningInVR )
		{
			spawnPosition = MuzzleTransform.WorldPosition;
			shootDirection = MuzzleTransform.WorldRotation.Forward;
		}
		else
		{
			if ( PlayerViewModel.Instance?.Camera is null )
				return;

			var ray = PlayerViewModel.Instance.GetAimRay( Vector2.Zero );
			spawnPosition = ray.Position;
			shootDirection = ray.Forward;
		}

		var hookObject = HookPrefab.Clone( spawnPosition, Rotation.LookAt( shootDirection ) );
		var projectile = hookObject.Components.Get<HookProjectile>();
		if ( projectile is null )
		{
			hookObject.Destroy();
			return;
		}

		projectile.Velocity = shootDirection.Normal * HookSpeed;
		projectile.WeaponObject = GameObject;
		projectile.OwnerGun = this;
		projectile.SpawnOrigin = spawnPosition;
		projectile.MaxRange = MaxRange;

		_activeHook = projectile;
		_state = HookState.Flying;
		RopeActive = true;
		RopeEnd = spawnPosition;
	}

	private void Retract()
	{
		if ( _activeHook.IsValid() )
		{
			var hook = _activeHook;
			_activeHook = null;
			hook.OwnerGun = null;
			hook.GameObject.Destroy();
		}

		_activeHook = null;
		EndOwnedGrapple();
		_state = HookState.Idle;
		RopeActive = false;
	}

	private void EndOwnedGrapple()
	{
		if ( !_ownedGrapple )
			return;

		var playerController = ResolvePlayerController();
		if ( playerController.IsValid() )
			playerController.EndGrapple();

		_ownedGrapple = false;
	}

	private bool IsPlayerStillGrappling()
	{
		var playerController = ResolvePlayerController();
		return playerController.IsValid() && playerController.IsGrappling;
	}

	private void UpdateRopeEnd()
	{
		if ( IsProxy || !RopeActive )
			return;

		if ( _state == HookState.Pulling )
		{
			RopeEnd = _attachPoint;

			var playerController = ResolvePlayerController();
			if ( playerController.IsValid() && _ownedGrapple )
				playerController.GrappleTarget = _attachPoint;

			return;
		}

		if ( _activeHook.IsValid() )
			RopeEnd = _activeHook.WorldPosition;
	}

	private void UpdateRopeVisual()
	{
		if ( Rope is null )
			return;

		var ropeStart = ResolveRopeStart();
		if ( ropeStart is null )
			return;

		Rope.Enabled = RopeActive;
		if ( !RopeActive )
			return;

		Rope.UseVectorPoints = true;
		Rope.VectorPoints = new List<Vector3>
		{
			ropeStart.WorldPosition,
			RopeEnd,
		};
	}

	/// <summary>
	/// Local desktop owner: viewmodel muzzle. Everyone else (including other players on your client): world weapon muzzle.
	/// </summary>
	private GameObject ResolveRopeStart()
	{
		if ( !IsProxy && !Game.IsRunningInVR )
		{
			var viewMuzzle = PlayerViewModel.Instance?.Muzzle;
			if ( viewMuzzle.IsValid() )
				return viewMuzzle;
		}

		return MuzzleTransform;
	}

	private PlayerCharacterController ResolvePlayerController()
	{
		var fromHierarchy = GetComponentInParent<PlayerCharacterController>();
		if ( fromHierarchy.IsValid() )
			return fromHierarchy;

		if ( Player.CurrentPlayer.IsValid() )
			return Player.CurrentPlayer.Components.Get<PlayerCharacterController>();

		return null;
	}
}