Weapons/Projectiles/Sticky.cs
using Sandbox.Events;
using System;

namespace KOTH;

public sealed class Sticky : Projectile, IGameEventHandler<ProjectileCollideEvent>
{
	[Property, Group("Explosion")] public float MinDetTime { get; set; } = 0.66f;
	[Property, Group("Explosion")] public GameObject ExplosionPrefab { get; set; }
	[Property, Group("Explosion")] public SoundEvent ExplodeSound { get; set; }

	/////////////////////////////////////////////////////////////////////////////////////

	private bool IsAttached = false;
	private GameObject AttachedGameObject = null;
	private Vector3 InitArmedWorldPosition;
	private Transform InitArmedWorldTransformAttachedObject;

	/////////////////////////////////////////////////////////////////////////////////////

	public TimeSince AliveTime { get; private set; } = new();

	public new void Destroy()
	{
		GameObject?.Root?.Destroy();
	}

	protected override void OnStart()
	{
		base.OnStart();

		AliveTime = 0;

		if (ModelRenderer.IsValid())
		{
			SpinBaseRotation = ModelRenderer.WorldRotation;

			if (GameUtils.GetPlayerState(OwnerPlayerPawn?.Id) is { } PlayerState)
			{
				ModelRenderer.Tint = PlayerState.Team == Team.Terrorist ? Color.Red : Color.Green;
			}
		}

		SpinAngle = Game.Random.Float(0f, 360f);
		SpinAxis = ComputeSpinAxis(Body.IsValid() ? Body.Velocity : WorldRotation.Forward);
	}

	protected override void OnUpdate()
	{
		base.OnUpdate();

		if (IsAttached)
		{
			if (!AttachedGameObject.IsValid())
			{
				GameObject.Root.Destroy();
				return;
			}

			Vector3 GameObjectOffset = AttachedGameObject.WorldPosition - InitArmedWorldTransformAttachedObject.Position;
			GameObject.WorldPosition = InitArmedWorldPosition + GameObjectOffset;
		}

		UpdateSpin();

		if (AliveTime >= MinDetTime && ModelRenderer.IsValid())
		{
			ModelRenderer.Tint = Color.Yellow;
		}
	}

	public void OnGameEvent(ProjectileCollideEvent EventArgs)
	{
		var Rigidbody = GameObject.Root.Components.Get<Rigidbody>();
		if (!Rigidbody.IsValid())
		{
			Log.Warning($"cannot find rigidboy comp on sticky {this}");
		}

		if (!Rigidbody.MotionEnabled)
		{
			return;
		}

		var HitObject = EventArgs.ProjectileCollision.HitObject;

		if (!HitObject.IsValid())
		{
			return;
		}

		Rigidbody.Velocity = Vector3.Zero;
		Rigidbody.MotionEnabled = false;

		IsAttached = true;
		AttachedGameObject = HitObject;
		InitArmedWorldTransformAttachedObject = HitObject.WorldTransform;
		InitArmedWorldPosition = EventArgs.ProjectileCollision.HitLocation;
	}

	const bool SpinInFlight = true;
	const float SpinSpeed = 1900f;        
	const float SpinReferenceSpeed = 2000f;
	const float SpinMaxScale = 2f;
	const bool SpinScalesWithSpeed = true;

	private ModelRenderer CachedModelRenderer;
	private Rigidbody CachedRigidbody;
	private Rotation SpinBaseRotation = Rotation.Identity;
	private Vector3 SpinAxis = Vector3.Right;
	private float SpinAngle = 0f;

	public ModelRenderer ModelRenderer
	{
		get
		{
			if (!CachedModelRenderer.IsValid())
				CachedModelRenderer = GetComponentInChildren<ModelRenderer>();
			return CachedModelRenderer;
		}
	}

	private Rigidbody Body
	{
		get
		{
			if (!CachedRigidbody.IsValid())
				CachedRigidbody = GameObject.Root.Components.Get<Rigidbody>();
			return CachedRigidbody;
		}
	}

	private void UpdateSpin()
	{
		if (!SpinInFlight || IsAttached)
			return;

		var Renderer = ModelRenderer;
		if (!Renderer.IsValid())
			return;

		Vector3 Velocity = Body.IsValid() ? Body.Velocity : Vector3.Zero;
		float Speed = Velocity.Length;

		if (Speed > 1f)
			SpinAxis = ComputeSpinAxis(Velocity);

		float Scale = 1f;
		if (SpinScalesWithSpeed && SpinReferenceSpeed > 0f)
			Scale = MathX.Clamp(Speed / SpinReferenceSpeed, 0f, SpinMaxScale);

		SpinAngle = (SpinAngle + SpinSpeed * Scale * Time.Delta) % 360f;

		Renderer.WorldRotation = Rotation.FromAxis(SpinAxis, SpinAngle) * SpinBaseRotation;
	}

	private static Vector3 ComputeSpinAxis(Vector3 Velocity)
	{
		// horizontal axis perpendicular to travel -> tumbles through its arc
		Vector3 Axis = Vector3.Cross(Velocity.Normal, Vector3.Up);
		return Axis.LengthSquared < 0.0001f ? Vector3.Right : Axis.Normal;
	}

	[Obsolete]
	public void Explode()
	{
		// TODO : this is likely because the object is destroyed on clients before this
		// code can run (race condition). Maybe somehow call async? 
		if (Transform == null)
		{
			Log.Warning("transform null on sticky");
			return;
		}

		if (ExplosionPrefab.IsValid())
		{
			var Explosion = ExplosionPrefab.Clone(WorldPosition);
			if (Explosion.IsValid())
			{
				Explosion.NetworkSpawn();
			}

			if (ExplodeSound != null)
			{
				GameObject.PlaySound(ExplodeSound, false);
			}
		}

		SimulateExplode(out FProjectileCollision ProjectileCollision, Transform.Position);

		foreach (var DamageComponent in ProjectileCollision.TracedDamageComponents)
		{
			if (!DamageComponent.IsValid())
			{
				continue;
			}

			FDamageRequest DamageRequest = new()
			{
				TargetDamageComponent = DamageComponent,
				AttackerPlayerPawn = OwnerPlayerPawn,
				DamageOrigin = ProjectileCollision.HitLocation,
				TargetOrigin = DamageComponent.WorldPosition,
				BaseDamage = BaseDamage,
				BaseKnockbackStrength = BaseKnockbackStrength,
				DamageType = EDamageType.Projectile,
				DamageFalloffType = EDamageFalloffType.Falloff,
				MaxDamageImpactDistance = ExplosionRadius,
			};

			if (DamageComponent.GameObject.GetComponent<PlayerPawn>() is { } PlayerPawn)
			{
				DamageRequest.TargetPlayerPawn = PlayerPawn;
				DamageRequest.TargetOrigin = PlayerPawn.CenterPosition;
			}

			Scene.Dispatch(new DamageRequestEvent(DamageRequest));

		}
		GameObject.Root.Destroy();
	}
}