Weapons/Components/HealBeamComponent.cs

Two components for a heal-beam weapon. HealBeamBeam is a small visual component that follows the target and orients between the weapon parent and the healed player. HealBeamComponent handles input, finding friendly targets, spawning/destroying the visual beam prefab, charging/uber logic, periodic healing via dispatching HealingRequestEvent, and a melee attack via DamageRequestEvent.

NetworkingFile Access
using Sandbox;
using Sandbox.Events;

namespace KOTH;

public sealed class HealBeamBeam : Component
{
	public PlayerPawn HealTarget { get; set; }

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

		if (!HealTarget.IsValid())
		{
			Log.Warning("ohno");
			return;
		}

		WorldPosition = (GameObject.Parent.WorldPosition + HealTarget.WorldPosition) / 2;
		WorldPosition += Vector3.Up * 25;
		WorldRotation = Rotation.LookAt(HealTarget.WorldPosition - GameObject.Parent.WorldPosition);
	}
}

public sealed class HealBeamComponent : InputWeaponComponent
{
	[Property, Category("Healing")] public float TimePerOneHeal { get; set; } = .45f;
	[Property, Category("Healing")] public float MaxHealDistance { get; set; } = 330f;
	[Property, Category("Healing")] public float MaxCharge { get; set; } = 200f;
	[Property, Category("Healing")] public float ChargeBuildRate { get; set; } = .03f;
	[Property, Category("Healing")] public float ChargeDegradeRate { get; set; } = .05f;
	[Property, Category("Healing")] public GameObject HealBeamBeamPrefab { get; set; }

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

	[Sync] public PlayerPawn HealTarget { get; private set; }
	[Sync] public bool IsUbered { get; private set; } = false;
	[Sync] public float Charge { get; private set; } = 0f;
	public PlayerPawn PlayerPawn { get => Equipment.Owner; }
	private HealBeamBeam HealBeamBeam { get; set; }

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

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

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

		if (HealTarget.IsValid())
		{
			if (!HealBeamBeam.IsValid())
			{
				CloneConfig CloneConfig = new();
				CloneConfig.StartEnabled = true;
				CloneConfig.Parent = GameObject;
				HealBeamBeam = HealBeamBeamPrefab.Clone(CloneConfig).GetComponent<HealBeamBeam>();
			}

			HealBeamBeam.HealTarget = HealTarget;
		}
		else if (HealBeamBeam.IsValid())
		{
			HealBeamBeam.DestroyGameObject();
		}

		if (!IsProxy)
		{
			TryDoHealingToTarget();

			if (IsUbered)
			{
				Charge -= ChargeDegradeRate;

				if (Charge <= 0)
				{
					Charge = 0;
					IsUbered = false;
				}
			}
		}
	}

	private TimeSince TimeSinceBeamHealingDone = new();
	protected override void OnInputUpdate()
	{
		if (!PlayerPawn.IsValid())
		{
			Log.Error($"local player pawn not valid on heal beam component {this}");
			return; // NOTE : early return
		}

		if (IsProxy)
		{
			return;
		}

		bool HealInput = IsDown();
		bool UberInput = Input.Pressed("attack2");
		bool MeleeInput = Input.Pressed("attack3");

		if (UberInput)
		{
			if (!IsUbered && Charge >= 100)
			{
				IsUbered = true;
			}
		}

		if (IsUbered)
		{
			Equipment?.Owner?.DamageComponent.BroadcastUber();
			HealTarget?.DamageComponent.BroadcastUber();
		}

		if (!HealInput)
		{
			// disconnect beam if the inputs been gone long enough, or if we're meleeing
			if (HealTarget.IsValid() && TimeSinceBeamHealingDone > .1f || MeleeInput)
			{
				HealTarget = null;
			}

			if (MeleeInput)
			{
				MeleeSwing();
			}

			return; // NOTE : early return
		}

		// if we've got a heal target keep healing them
		if (HealTarget.IsValid())
		{
			return; // NOTE : early return
		}

		var FriendlyPawn = GetFriendlyTargetIfAny();

		Equipment.ViewModel?.ModelRenderer?.Set("b_jump", true);

		if (FriendlyPawn.IsValid())
		{
			HealTarget = FriendlyPawn;
			TimeSinceBeamHealingDone = 0;
		}
	}

	////////////////////////////////////////////////////////////////////////////////////////////////////
	
	private bool MeleeSwing()
	{
		if (TimeSinceLastAttemptedHit < FireRate)
		{
			return false;
		}

		Equipment.ViewModel?.ModelRenderer?.Set("b_attack", true);
		
		var EnemyPawn = GetEnemyTargetIfAny();
		if (EnemyPawn.IsValid() && EnemyPawn.IsAlive)
		{
			TryToHitTarget(EnemyPawn);
		}

		TimeSinceLastAttemptedHit = 0;
		return true;
	}

	private TimeSince TimeSinceLastAttemptedHit = new();
	private void TryToHitTarget(PlayerPawn EnemyPawn)
	{
		FDamageRequest DamageRequest = new()
		{
			TargetDamageComponent = EnemyPawn.DamageComponent,
			AttackerPlayerPawn = PlayerPawn,
			TargetPlayerPawn = EnemyPawn,
			DamageOrigin = PlayerPawn.WorldPosition,
			TargetOrigin = EnemyPawn.CenterPosition,
			BaseDamage = BaseDamage,
			BaseKnockbackStrength = KnockbackStrength,
			DirectImpact = true,
			DamageType = EDamageType.Melee,
		};
		Scene.Dispatch(new DamageRequestEvent(DamageRequest));
	}

	private PlayerPawn GetEnemyTargetIfAny()
	{
		List<PlayerPawn> UniqueTargets = GetAllPlayerPawnsIntront(PlayerPawn.AimRay, 200, 10);
		return UniqueTargets.Any() ? UniqueTargets.First() : null;
	}

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

	private void TryDoHealingToTarget()
	{
		// if our target is invalid then remove our heal taget
		if (!HealTarget.IsValid() || !IsDown())
		{
			HealTarget = null;
			return; // NOTE : early return
		}

		var TargetToPlayerDistance = HealTarget.WorldPosition.Distance(PlayerPawn.WorldPosition);
		if (TargetToPlayerDistance > MaxHealDistance || !HealTarget.IsAlive)
		{
			HealTarget = null;
			return; // NOTE : early return
		}

		if (!IsUbered && Charge <= MaxCharge)
		{
			Charge = Math.Min(ChargeBuildRate + Charge, MaxCharge);
		}
		
		if (TimeSinceBeamHealingDone >= TimePerOneHeal)
		{
			FHealingRequest HealingRequest = new()
			{
				TargetDamageComponent = HealTarget.DamageComponent,
				TargetPlayerPawn = HealTarget,
				HealerPlayerPawn = PlayerPawn,
				BaseHealing = 1,
				HealingOrigin = PlayerPawn.WorldPosition,
				AllowOverheal = true,
				HealingType = EHealingType.Continuous,
			};
			Scene.Dispatch(new HealingRequestEvent(HealingRequest));

			TimeSinceBeamHealingDone = 0;
		}
	}

	const float PlayerSwitchDelay = .4f;
	private PlayerPawn GetFriendlyTargetIfAny()
	{
		// TODO : fix
		if (TimeSinceBeamHealingDone < PlayerSwitchDelay)
		{
			return null;
		}

		foreach (var Target in GetAllPlayerPawnsIntront(PlayerPawn.AimRay, 280, 25))
		{
			if (Target.Team == Player.Team)
			{
				return Target;
			}
		}

		return null;
	}

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

	public List<PlayerPawn> GetAllPlayerPawnsIntront(Ray AimRay, int Distance, int WiderOffset = 0)
	{
		// calc 3 rays to check for collision 
		// TODO : make this a cone

		List<PlayerPawn> PlayerPawns = new();

		for (var Offset = -1; Offset <= 1; ++Offset)
		{
			Vector3 OffsetVec = Vector3.Cross(AimRay.Forward, Vector3.Up * Offset).Normal;
			Vector3 StartingLocation = AimRay.Position + (OffsetVec * WiderOffset);
			
			var PlayerPawnTrace = Scene.Trace.Ray(StartingLocation, StartingLocation + (AimRay.Forward * Distance))
			.WithTag("player")
			.WithoutTags("hill")
			.WithoutTags("consumeable")
			.RunAll();

			foreach (var Hit in PlayerPawnTrace)
			{
				var Target = Hit.GameObject?.Root;
				if (!Target.IsValid())
				{
					continue;
				}

				var TargetPlayerPawn = Target.Root.Components.Get<PlayerPawn>();
				if (!TargetPlayerPawn.IsValid() || TargetPlayerPawn == PlayerPawn)
				{
					continue;
				}

				if (!PlayerPawns.Contains(TargetPlayerPawn))
				{
					PlayerPawns.Add(TargetPlayerPawn);
				}
			}
		}

		return PlayerPawns;
	}
}