Player/PlayerPawn.Fx.cs

PlayerPawn visual and effect logic. Handles animation helper updates, weapon animation flags, vfx RPCs for shooting, death/gib spawning, blood and sounds, and reacts to damage and healing game events.

NetworkingFile Access
using KOTH.UI;
using Sandbox;
using Sandbox.Citizen;
using Sandbox.Diagnostics;
using Sandbox.Events;
using System.Numerics;

namespace KOTH;

public partial class PlayerPawn :
	IGameEventHandler<DamageBroadcastEvent>,
	IGameEventHandler<HealingBroadcastEvent>
{
	private void TickVFXs()
	{
		if (!IsAlive /*HACK for proxy characters when body dies b4 health knows*/|| !Body.IsValid())
		{
			return;
		}

		// Assert.True(Body.IsValid());
		Assert.True(AnimationHelper.IsValid());

		Body.WorldRotation = Rotation.FromYaw(EyeAngles.yaw);

		AnimationHelper.WithVelocity(CharacterController.Velocity);
		AnimationHelper.WithWishVelocity(WishVelocity);
		AnimationHelper.WithLook(EyeAngles.Forward, 1, 1, 1.0f);
		AnimationHelper.IsGrounded = IsGrounded;
		AnimationHelper.DuckLevel = IsCrouching ? .5f : 0;
		AnimationHelper.MoveStyle = CitizenAnimationHelper.MoveStyles.Run;
		AnimationHelper.HoldType = CitizenAnimationHelper.HoldTypes.Shotgun;
		AnimationHelper.Handedness = CitizenAnimationHelper.Hand.Both;
		AnimationHelper.IsWeaponLowered = false;
		AnimationHelper.AimBodyWeight = 1f;

		TickWeaponAnimations();

		if (CurrentEquipment.IsValid())
		{
			AnimationHelper.HoldType = CurrentEquipment.HoldType;
			AnimationHelper.Handedness = CurrentEquipment.Handedness;
		}
		else
		{
			AnimationHelper.HoldType = CitizenAnimationHelper.HoldTypes.None;
		}

		if (Networking.IsHost)
		{
			CheckWalkSound(CharacterController.Velocity.WithZ(0).Length);

			CheckUber(DamageComponent.IsUbered);
		}
	}

	const float ShotCooldown = 0.33f;
	private bool ShowShoot = false;
	private void TickWeaponAnimations()
	{
		if (!CurrentEquipment.IsValid())
		{
			return;
		}

		if (TimeSinceLastShot < ShotCooldown && !ShowShoot)
		{
			Body.ModelRenderer.Set("b_attack", true);
			CurrentEquipment.ViewModel?.ModelRenderer.Set("b_attack", true);
			ShowShoot = true;
		}

		bool ShowReload = false;
		if (CurrentEquipment.GetWeaponComponent() is InputWeaponComponent WeaponComp)
		{
			ShowReload = WeaponComp.IsReloading && TimeSinceLastShot > ShotCooldown;

			Body.ModelRenderer.Set("b_reload", ShowReload);
		}

		bool ShowSprint = WishMove == Vector3.Forward && TimeSinceLastShot > 1f;
		if (CurrentEquipment.ViewModel.IsValid())
		{
			CurrentEquipment.ViewModel.ModelRenderer.Set("b_reload", ShowReload);
			CurrentEquipment.ViewModel.ModelRenderer.Set("b_sprint", ShowSprint);
			CurrentEquipment.ViewModel.ModelRenderer.Set("move_bob", IsGrounded ? 0f : 1f);
		}
		Body.ModelRenderer.Set("b_sprint", ShowSprint);
	}

	TimeSince TimeSinceLastShot { get; set; } = 0;

	[Rpc.Broadcast]
	public void VFXOnShoot()
	{
		TimeSinceLastShot = 0;
		ShowShoot = false;
	}

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

	private GameObject GetClosestPlayerGameobject()
	{
		// TODO : this would be fun but its causing issues with base look
		// var ClosestPlayerObject = GetClosestPlayerGameobject();
		//if (ClosestPlayerObject != null)
		//{
		//	AnimationHelper.LookAtEnabled = true;
		//	AnimationHelper.LookAt = ClosestPlayerObject;
		//}
		//else
		//{
		//	AnimationHelper.LookAtEnabled = false;
		//}

		GameObject ClosestPlayerObject = null;
		foreach (var PlayerState in GameNetworkManager.PlayerStates)
		{
			if (!PlayerState.PlayerPawn.IsValid() || !PlayerState.PlayerPawn.IsAlive || PlayerState == PlayerState.Local)
			{
				continue;
			}

			var Distance = Boom.WorldPosition.Distance(PlayerState.PlayerPawn.Boom.WorldPosition);

			if (ClosestPlayerObject == null && Distance < 500f)
			{
				ClosestPlayerObject = PlayerState.PlayerPawn.Boom;
			}
			else if (ClosestPlayerObject.IsValid())
			{
				if (Boom.WorldPosition.Distance(ClosestPlayerObject.WorldPosition) > Distance)
				{
					ClosestPlayerObject = PlayerState.PlayerPawn.Boom;
				}
			}
		}

		return ClosestPlayerObject;
	}

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

	bool UberApplied = false;

	[Rpc.Broadcast]
	private void CheckUber(bool Ubered)
	{
		if (UberApplied && !Ubered)
		{
			Body.ModelRenderer.ClearMaterialOverrides();
			CurrentEquipment?.ModelRenderer.ClearMaterialOverrides();

			UberApplied = false;
		}

		if (!UberApplied && Ubered)
		{
			Body.ModelRenderer.SetMaterial(UberMaterial);
			CurrentEquipment?.ModelRenderer.SetMaterial(UberMaterial);

			UberApplied = true;
		}
	}

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

	[Property] public GameObject DeathBlood { get; set; }

	const float GibMinDamage = 50f; // TODO : should be based on last hp!
	const float GibForce = 66f;

	[Rpc.Broadcast(NetFlags.HostOnly)]
	private void BroadcastLocalPlayerDeath(FDamageTaken DamageTaken)
	{
		if (!Body.IsValid())
		{
			return;
		}

		if (DeathBlood.IsValid())
		{
			DeathBlood.Clone(CenterPosition);
		}

		if (DamageTaken.Damage > GibMinDamage)
		{
			CreateGibs(DamageTaken);
			Body.Destroy();
		}
		else
		{
			Body.Ragdoll(DamageTaken);
			Body.GameObject.SetParent(null, true);
		}
	}

	private void CreateGibs(FDamageTaken DamageTaken)
	{
		if (GibPrefab.IsValid())
		{
			var Gibs = GibPrefab.Clone(WorldPosition);
			foreach (var ChildGib in Gibs.Root.Children)
			{
				var Rigidbody = ChildGib.Components.Get<Rigidbody>();
				if (Rigidbody.IsValid())
				{
					// TODO : explode more outward
					Rigidbody.Velocity = (CenterPosition - DamageTaken.DamageLocation) * GibForce;
				}
			}
			Gibs.NetworkSpawn();
		}
	}

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

	[Property] public GameObject BloodSquirt { get; set; }
	[Property] public SoundEvent HitSound { get; set; }
	[Property] public SoundEvent WalkSound { get; set; }

	public SoundHandle WalkSoundHandle = null;

	[Rpc.Broadcast(NetFlags.HostOnly)]
	void CheckWalkSound(float Velocity)
	{
		if (!WalkSound.IsValid())
		{
			return;
		}

		if (!IsGrounded && WalkSoundHandle.IsValid())
		{
			WalkSoundHandle.Stop();
			WalkSoundHandle = null;
		}

		if (Velocity > 100)
		{
			if (WalkSoundHandle is null || WalkSoundHandle.Finished)
			{
				if (Sound.Play(WalkSound, WorldPosition) is { } SoundHandel)
				{
					WalkSoundHandle = SoundHandel;
				}
			}

			WalkSoundHandle.Position = WorldPosition;
		}
		else if (WalkSoundHandle is not null)
		{
			WalkSoundHandle.Stop();
			WalkSoundHandle = null;
		}
	}

	void IGameEventHandler<DamageBroadcastEvent>.OnGameEvent(DamageBroadcastEvent EventArgs)
	{
		if (EventArgs.DamageEvent.VictimPlayerState?.PlayerPawn == this)
		{
			OnDamageTaken(EventArgs.DamageEvent);
		}

		if (EventArgs.DamageEvent.AttackerPlayerState?.PlayerPawn == this)
		{
			OnDamageGiven(EventArgs.DamageEvent);
		}
	}

	void IGameEventHandler<HealingBroadcastEvent>.OnGameEvent(HealingBroadcastEvent EventArgs)
	{
		OnHealingGiven(EventArgs.HealingRequest);
	}

	[Rpc.Broadcast(NetFlags.HostOnly)]
	public void OnHealingGiven(FHealingReceived HealingDone)
	{
		if (IsLocallyControlled)
		{
			if (HealingDone.TargetPlayerPawn == this)
			{
				UI.Health.Instance?.OnHealing(HealingDone);
			}

			UI.DamageNumbers.Instance?.OnHit(HealingDone);
		}
	}

	[Rpc.Broadcast(NetFlags.HostOnly)]
	public void OnDamageTaken(FDamageTaken DamageTaken)
	{
		if (IsLocallyControlled)
		{
			var DamageLocation = DamageTaken.DamageLocation;
			UI.DamageIndicatorNew.Instance?.OnHit(DamageLocation);
			UI.Health.Instance?.OnDamage(DamageTaken);
		}

		if (BloodSquirt.IsValid())
		{
			BloodSquirt.Clone(DamageTaken.DamageLocation);
		}

		if (HitSound.IsValid())
		{
			if (Sound.Play(HitSound, WorldPosition) is { } SoundHandel)
			{

			}
		}
	}

	[Rpc.Broadcast(NetFlags.HostOnly)]
	public void OnDamageGiven(FDamageTaken DamageGiven)
	{
		if (IsLocallyControlled)
		{
			UI.DamageNumbers.Instance?.OnHit(DamageGiven);
		}
	}
}