Weapons/Projectiles/Sticky.cs

A sticky projectile component for a game. It handles attachment to hit objects on collision, disables physics when attached, updates its world position relative to the attached object, visual spin while in flight, and an obsolete Explode method that spawns an explosion prefab, plays a sound, traces damage components and dispatches damage events.

NetworkingFile Access
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();
	}
}