UI/PlayerHud/Components/DamageNumbers.razor

A UI Razor component that renders floating damage/heal numbers on the HUD. It tracks active FScreenNumberElement entries, creates or updates them on hit/heal events, computes screen positions and random offsets, and removes expired entries each Tick.

Native Interop
@namespace KOTH.UI
@inherits Panel
@attribute [StyleSheet]

<root>
	@foreach (var DamageNumberElement in DamageNumberElements)
	{
		<label class="damage-number" style="font-size: @(DamageNumberElement.GetSizePx())px; color: @(DamageNumberElement.GetColour().Hex); left: @(DamageNumberElement.GetScreenPos().x * 100)%; top: @(DamageNumberElement.GetScreenPos().y * 100)%; ">
			@(DamageNumberElement)
		</label>
	}
</root>

@code
{
	// TODO : put these in a helper somewhere

	public Vector2 GetScreenPosFromPosition(Vector3 Position, out bool IsBehind)
	{
		return LocalPlayerPawn.Camera.PointToScreenNormal(Position, out IsBehind);
	}

	public Vector2 GetScreenOffset(float Distance = 0f)
	{
		float DistanceFactor = 0.01f; //0.007f * (float)Math.Sqrt(Distance);

		float XOffset = (float)(Random.Shared.NextDouble() * DistanceFactor * 2) - DistanceFactor;
		float YOffset = (float)(Random.Shared.NextDouble() * (0.15f - DistanceFactor)) + DistanceFactor;
		return new Vector2(XOffset, -YOffset);
	}

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

	public static DamageNumbers Instance { get; set; }

	public DamageNumbers()
	{
		Instance = this;
	}

	List<FScreenNumberElement> DamageNumberElements = new();
	PlayerPawn LocalPlayerPawn => PlayerState.Local.IsValid() ? PlayerState.Local.PlayerPawn : null;

	public void OnHit(FDamageTaken DamageGiven)
	{
		if (!LocalPlayerPawn.IsValid() || !LocalPlayerPawn.IsAlive || DamageGiven.AssumedVictimPlayerPawn == LocalPlayerPawn)
		{
			return;
		}

		var Distance = DamageGiven.DamageLocation.Distance(LocalPlayerPawn.WorldPosition);
		var DamageLocation = DamageGiven.DamageType == EDamageType.Projectile && DamageGiven.VictimGameObject.IsValid() ? DamageGiven.VictimGameObject.WorldPosition : DamageGiven.DamageLocation;

		foreach (var Element in DamageNumberElements)
		{
			if (Element.DisplayedTime < -0.1f)
			{
				continue;
			}

			if (Element.TargetPawn == DamageGiven.AssumedVictimPlayerPawn)
			{
				Element.AddDamage(DamageGiven.Damage);
				Element.DisplayedTime = 0;
				Element.Location = DamageLocation;

				return; // NOTE : early return
			}
		}

		// if we haven't return'd then make a new element
		FScreenNumberElement DamageNumberElement = new()
			{
				PlayerPawn = LocalPlayerPawn,
				TargetPawn = DamageGiven.AssumedVictimPlayerPawn,
				ScreenOffset = GetScreenOffset(Distance),
				Damage = DamageGiven.Damage,
				DisplayedTime = 0,
				Location = DamageLocation,
			};

		DamageNumberElements.Add(DamageNumberElement);
	}

	public void OnHit(FHealingReceived HealingReceived)
	{
		if (!LocalPlayerPawn.IsValid() || !LocalPlayerPawn.IsAlive
			|| HealingReceived.TargetPlayerPawn == LocalPlayerPawn || !HealingReceived.TargetPlayerPawn.IsValid()
			|| HealingReceived.HealingType == EHealingType.Continuous || HealingReceived.TargetPlayerPawn.Team != LocalPlayerPawn.Team)
		{
			return;
		}

		var Distance = HealingReceived.TargetPlayerPawn.WorldPosition.Distance(LocalPlayerPawn.WorldPosition);
		var DamageLocation = HealingReceived.TargetPlayerPawn.WorldPosition;

		foreach (var Element in DamageNumberElements)
		{
			if (Element.DisplayedTime < -0.1f)
			{
				continue;
			}

			if (Element.TargetPawn == HealingReceived.TargetPlayerPawn)
			{
				Element.AddDamage(HealingReceived.Heals);
				Element.DisplayedTime = 0;
				Element.Location = DamageLocation;

				return; // NOTE : early return
			}
		}

		// if we haven't return'd then make a new element
		FScreenNumberElement DamageNumberElement = new()
			{
				PlayerPawn = LocalPlayerPawn,
				TargetPawn = HealingReceived.TargetPlayerPawn,
				ScreenOffset = GetScreenOffset(Distance),
				Damage = HealingReceived.Heals,
				DisplayedTime = 0,
				Location = DamageLocation,
				IsDamage = false,
			};

		DamageNumberElements.Add(DamageNumberElement);
	}

	public override void Tick()
	{
		List<FScreenNumberElement> DamageNumbersToRemove = new();

		foreach (var DamageNumber in DamageNumberElements)
		{
			if (DamageNumber.DisplayedTime < -0.66f)
			{
				DamageNumbersToRemove.Add(DamageNumber);
			}
		}

		foreach (var Remove in DamageNumbersToRemove)
		{
			DamageNumberElements.Remove(Remove);
		}
	}

	protected override int BuildHash()
	{
		return HashCode.Combine(Time.Now);
	}
}