ButtonMasherPlayerController.cs
using Sandbox;
using System;
using System.Linq;
using System.Threading.Tasks;

public sealed partial class ButtonMasherPlayerController : Component
{
	public enum TeamType
	{
		[Icon( "group" )]
		Player1,

		[Icon( "group" )]
		Player2
	}

	private static readonly Texture TeamLogoRed = Texture.Load( FileSystem.Mounted, "textures/red-icon.png", false );
	private static readonly Texture TeamLogoBlue = Texture.Load( FileSystem.Mounted, "textures/blu-icon.png", false );

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

	[Property]
	public float TracerSpeed { get; set; } = 6000f;

	[Property]
	public int MagazineSize { get; set; } = 12;

	[Property, Sync( SyncFlags.FromHost )]
	public int AmmoInMagazine { get; set; } = 12;

	[Property]
	public float ReloadDuration { get; set; } = 1.5f;

	[Property, Sync( SyncFlags.FromHost ), Change( nameof( OnReloadChanged ) )]
	public bool b_reload { get; set; }

	[Property, Sync( SyncFlags.FromHost ), Change( nameof( OnReloadingChanged ) )]
	public bool b_reloading { get; set; }

	private TimeUntil _reloadFinish;
	private TimeUntil _reloadAnimReset;

	[Property, Sync( SyncFlags.FromHost )]
	public string Name { get; set; } = "Player";

	[Property, Sync( SyncFlags.FromHost )]
	public TeamType Team { get; set; }

	[Property, Sync( SyncFlags.FromHost )]
	public float MaxHealth { get; set; } = 100f;

	[Property, Sync( SyncFlags.FromHost ), Change( nameof( OnHealthChanged ) )]
	public float Health { get; set; } = 100f;

	[Property, Sync( SyncFlags.FromHost ), Change( nameof( OnIsDeadChanged ) )]
	public bool IsDead { get; set; }

	[Property, Sync( SyncFlags.FromHost )]
	public GameObject OriginalSpawn { get; set; }

	[Property, Sync( SyncFlags.FromHost ), Change( nameof( OnAttackChanged ) )]
	public bool b_attack { get; set; }

	[Property]
	public float RespawnDelay { get; set; } = 3f;

	[Property]
	public float InteractRange { get; set; } = 150f;

	[Property]
	public float Attack1Range { get; set; } = 3000f;

	[Property]
	public float AttackDamage { get; set; } = 10f;

	[Property]
	public SoundEvent GunshotSound { get; set; }

	[Property]
	public SoundEvent ReloadSound { get; set; }

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

	public float HealthFraction => MaxHealth <= 0f ? 0f : Health / MaxHealth;

	private TimeUntil _timeUntilRespawn;
	private TimeUntil _attackReset;

	private Vector3? _pendingRespawnPosition;
	private Rotation? _pendingRespawnRotation;

	protected override void OnStart()
	{
		if ( Networking.IsHost )
		{
			Health = MaxHealth;
			IsDead = false;
			b_attack = false;
			b_reload = false;
			b_reloading = false;
			AmmoInMagazine = MagazineSize;

			foreach ( var button in GameObject.Scene.GetAllComponents<Score_Button>() )
			{
				button.SyncScoreToClient( button.Count );
			}
		}

		var modelRenderer = GameObject.Components.Get<SkinnedModelRenderer>();
		if ( modelRenderer != null && GameObject.Components.Get<ModelPhysics>() == null )
		{
			var ragdoll = AddComponent<ModelPhysics>();
			ragdoll.Renderer = modelRenderer;
			ragdoll.Enabled = true;
			ragdoll.MotionEnabled = false;
		}

		UpdateBodyMorph();
	}

	protected override void OnUpdate()
	{
		ApplyPendingRespawnTransform();

		if ( Networking.IsHost )
		{
			if ( IsDead && _timeUntilRespawn <= 0f )
			{
				RespawnOnHost();
			}

			if ( b_attack && _attackReset <= 0f )
			{
				b_attack = false;
			}

			if ( b_reload && _reloadAnimReset <= 0f )
			{
				b_reload = false;
			}

			if ( b_reloading && _reloadFinish <= 0f )
			{
				FinishReloadOnHost();
			}
		}

		if ( !IsLocalOwner() )
			return;

		DrawHud();

		if ( IsDead )
		{
			UpdateDeathCamera();
			return;
		}

		UpdateViewModel();

		if ( Input.Pressed( "use" ) )
		{
			PerformInteraction();
		}

		if ( Input.Pressed( "reload" ) )
		{
			RequestReloadOnHost();
		}

		if ( Input.Pressed( "attack1" ) )
		{
			if ( b_reloading )
				return;

			if ( AmmoInMagazine <= 0 )
			{
				RequestReloadOnHost();
				return;
			}

			var traceResult = PerformWeaponTrace( Attack1Range );

			var burst = FindInHierarchy( GameObject, "Burst" );
			if ( burst == null )
				return;

			var startPos = burst.WorldPosition;
			var endPos = traceResult.EndPosition;

			PlayGunshotSound();
			PlayMuzzleFlash();
			PlayTracerBetweenPoints( startPos, endPos );

			using ( Rpc.FilterExclude( Connection.Local ) )
			{
				BroadcastAttackEffects( startPos, endPos );
			}

			TriggerAttackOnHost();
			RequestDamageFromAttack( traceResult );
			RequestConsumeAmmoOnHost();
		}
	}

	private async void PlayTracerBetweenPoints( Vector3 startPos, Vector3 endPos )
	{
		if ( TracerPrefab == null )
			return;

		if ( startPos.AlmostEqual( endPos ) )
			return;

		var direction = (endPos - startPos).Normal;
		var distance = startPos.Distance( endPos );
		var lifetime = distance / TracerSpeed;

		var tracer = TracerPrefab.Clone( startPos );
		tracer.WorldPosition = startPos;
		tracer.WorldRotation = Rotation.LookAt( direction );

		// If your particle component exposes a way to restart / set parameters, do it here.
		// The prefab itself should already have forward motion configured at TracerSpeed.

		await Task.DelaySeconds( lifetime );

		if ( tracer.IsValid() )
			tracer.Destroy();
	}

	[Rpc.Host]
	private void RequestConsumeAmmoOnHost()
	{
		if ( IsDead || b_reloading )
			return;

		if ( AmmoInMagazine <= 0 )
			return;

		AmmoInMagazine = Math.Max( AmmoInMagazine - 1, 0 );
	}

	[Rpc.Host]
	private void RequestReloadOnHost()
	{
		if ( IsDead )
			return;

		if ( b_reloading )
			return;

		if ( AmmoInMagazine >= MagazineSize )
			return;

		b_reload = false;
		b_reload = true;
		b_reloading = true;

		_reloadAnimReset = 0.05f;
		_reloadFinish = ReloadDuration;

		BroadcastReloadSound();
	}

	private void FinishReloadOnHost()
	{
		if ( !Networking.IsHost )
			return;

		AmmoInMagazine = MagazineSize;
		b_reloading = false;
		b_reload = false;
	}

	private void RequestDamageFromAttack( SceneTraceResult traceResult )
	{
		if ( !traceResult.Hit )
			return;

		var scaledDamage = AttackDamage * GetDamageMultiplier( traceResult );
		RequestDamageOnHost( traceResult.GameObject, scaledDamage );
	}

	[Rpc.Broadcast]
	private void BroadcastAttackEffects( Vector3 startPos, Vector3 endPos )
	{
		PlayGunshotSound();
		PlayMuzzleFlash();
		PlayTracerBetweenPoints( startPos, endPos );
	}

	private float GetDamageMultiplier( SceneTraceResult trace )
	{
		var hitObject = trace.GameObject;
		if ( hitObject == null || !hitObject.IsValid() )
			return 1.0f;

		var name = hitObject.Name.ToLowerInvariant();

		if ( name.Contains( "head" ) || name.Contains( "neck" ) )
			return 2.0f;

		if ( name.Contains( "arm" ) || name.Contains( "hand" ) ||
			 name.Contains( "leg" ) || name.Contains( "foot" ) )
			return 0.8f;

		return 1.0f;
	}

	[Rpc.Host]
	private void RequestDamageOnHost( GameObject hitObject, float damage )
	{
		if ( hitObject == null || !hitObject.IsValid() )
			return;

		var targetPlayer = hitObject.Components.Get<ButtonMasherPlayerController>( FindMode.EverythingInSelfAndAncestors );
		if ( targetPlayer == null || targetPlayer.IsDead )
			return;

		targetPlayer.Health = (targetPlayer.Health - damage).Clamp( 0f, targetPlayer.MaxHealth );
	}

	private bool IsLocalOwner()
	{
		return !IsProxy && GameObject.Scene.Camera is not null;
	}

	private void PlayGunshotSound()
	{
		if ( GunshotSound == null )
			return;

		var vmWeapon = GameObject.Children.FirstOrDefault( x => x.Name == "vm_weapon" );
		if ( vmWeapon != null )
		{
			vmWeapon.PlaySound( GunshotSound );
			return;
		}

		GameObject.PlaySound( GunshotSound );
	}

	private void PlayReloadSound()
	{
		if ( ReloadSound == null )
			return;

		var vmWeapon = GameObject.Children.FirstOrDefault( x => x.Name == "vm_weapon" );
		if ( vmWeapon != null )
		{
			vmWeapon.PlaySound( ReloadSound );
			return;
		}

		GameObject.PlaySound( ReloadSound );
	}

	[Rpc.Broadcast]
	private void BroadcastReloadSound()
	{
		PlayReloadSound();
	}

	private GameObject FindInHierarchy( GameObject root, string name )
	{
		if ( root.Name == name )
			return root;

		foreach ( var child in root.Children )
		{
			var found = FindInHierarchy( child, name );
			if ( found != null )
				return found;
		}

		return null;
	}

	private async void PlayMuzzleFlash()
	{
		if ( MuzzleFlashPrefab == null )
			return;

		var burst = FindInHierarchy( GameObject, "Burst" );
		if ( burst == null )
			return;

		var flash = MuzzleFlashPrefab.Clone( burst.WorldPosition );
		flash.WorldRotation = burst.WorldRotation;
		flash.SetParent( burst );

		await Task.DelaySeconds( 0.15f );

		if ( flash.IsValid() )
		{
			flash.Destroy();
		}
	}

	private void UpdateDeathCamera()
	{
		var camera = GameObject.Scene.Camera;
		if ( camera == null )
			return;

		var bodyChild = GameObject.Children.FirstOrDefault( x => x.Name == "Body" );
		if ( bodyChild == null )
			return;

		var renderer = bodyChild.Components.Get<SkinnedModelRenderer>();

		if ( renderer != null )
		{
			var headAttachment =
				renderer.GetAttachment( "eyes" ) ??
				renderer.GetAttachment( "eye" ) ??
				renderer.GetAttachment( "head" ) ??
				renderer.GetAttachment( "camera" );

			if ( headAttachment.HasValue )
			{
				var tx = headAttachment.Value;
				camera.WorldPosition = tx.Position;
				camera.WorldRotation = tx.Rotation;
				return;
			}
		}

		camera.WorldPosition = bodyChild.WorldPosition + bodyChild.WorldRotation.Up * 64f;
		camera.WorldRotation = bodyChild.WorldRotation;
	}

	private void DrawHud()
	{
		var camera = GameObject.Scene.Camera;
		if ( camera == null )
			return;

		var hud = camera.Hud;
		var screenCenter = new Vector2( Screen.Width * 0.5f, Screen.Height * 0.5f );

		DrawTeamLogo( hud );

		// Score tracking connected to button presses
		long team1Score = GetTeamButtonCount( TeamType.Player1 );
		long team2Score = GetTeamButtonCount( TeamType.Player2 );

		// Red score
		var scope1 = new TextRendering.Scope( $"Red Score:\n{team1Score}", Color.Red, 24 );
		var size1 = scope1.Measure();
		hud.DrawText( scope1, new Vector2( Screen.Width * 0.30f - size1.x * 0.5f, 40 ), TextFlag.Center );

		// Blue score
		var scope2 = new TextRendering.Scope( $"Blue Score:\n{team2Score}", Color.Blue, 24 );
		var size2 = scope2.Measure();
		hud.DrawText( scope2, new Vector2( Screen.Width * 0.75f - size2.x * 0.5f, 40 ), TextFlag.Center );

		var scope3 = new TextRendering.Scope( $"WORK IN PROGRESS", Color.Green, 18 );
		var size3 = scope3.Measure();
		hud.DrawText( scope3, new Vector2( Screen.Width * 1f - size3.x * .5f, 40 ), TextFlag.Center );

		// Crosshair
		hud.DrawRect( new Rect( screenCenter.x - 1, screenCenter.y - 1, 2, 2 ), new Color( 1f, 1f, 1f, 0.7f ) );
		hud.DrawLine( screenCenter - new Vector2( 5, 0 ), screenCenter - new Vector2( 15, 0 ), 1, new Color( 1f, 1f, 1f, 0.5f ) );
		hud.DrawLine( screenCenter + new Vector2( 15, 0 ), screenCenter + new Vector2( 5, 0 ), 1, new Color( 1f, 1f, 1f, 0.5f ) );
		hud.DrawLine( screenCenter - new Vector2( 0, 15 ), screenCenter - new Vector2( 0, 5 ), 1, new Color( 1f, 1f, 1f, 0.5f ) );
		hud.DrawLine( screenCenter + new Vector2( 0, 15 ), screenCenter + new Vector2( 0, 5 ), 1, new Color( 1f, 1f, 1f, 0.5f ) );

		// Ammo counter
		var ammoScope = new TextRendering.Scope( $"{AmmoInMagazine}/{MagazineSize}", Color.White, 28 );
		var ammoSize = ammoScope.Measure();
		hud.DrawText(
			ammoScope,
			new Vector2( Screen.Width - ammoSize.x - 40, Screen.Height - 80 ),
			TextFlag.LeftTop
		);
	}

	private void ApplyPendingRespawnTransform()
	{
		if ( !_pendingRespawnPosition.HasValue || !_pendingRespawnRotation.HasValue )
			return;

		GameObject.WorldPosition = _pendingRespawnPosition.Value;
		GameObject.WorldRotation = _pendingRespawnRotation.Value;

		_pendingRespawnPosition = null;
		_pendingRespawnRotation = null;
	}

	private void UpdateViewModel()
	{
		var vmWeapon = GameObject.Children.FirstOrDefault( c => c.Name == "vm_weapon" );
		if ( vmWeapon == null )
			return;

		var camera = GameObject.Scene.Camera;
		if ( camera == null )
			return;

		vmWeapon.WorldPosition =
			camera.WorldPosition +
			camera.WorldRotation.Forward * 0.5f +
			camera.WorldRotation.Right * 0.3f +
			camera.WorldRotation.Down * 0.3f;

		vmWeapon.WorldRotation = camera.WorldRotation;
	}

	private long GetTeamButtonCount( TeamType team )
	{
		long total = 0;

		foreach ( var button in GameObject.Scene.GetAllComponents<Score_Button>() )
		{
			if ( button.Team == team )
			{
				total += button.Count;
			}
		}

		return total;
	}

	private void DrawTeamLogo( Sandbox.Rendering.HudPainter hud )
	{
		var texture = Team == TeamType.Player1 ? TeamLogoRed : TeamLogoBlue;
		var teamColor = Team == TeamType.Player1 ? Color.Red : Color.Blue;

		if ( texture == null )
		{
			hud.DrawRect( new Rect( 20, 20, 64, 64 ), teamColor );
			return;
		}

		float scale = 0.10f;
		float width = texture.Width * scale;
		float height = texture.Height * scale;

		hud.DrawTexture( texture, new Rect( 20f, 20f, width, height ), Color.White );
	}

	[Rpc.Host]
	private void TriggerAttackOnHost()
	{
		if ( IsDead || b_reloading )
			return;

		b_attack = false;
		b_attack = true;

		_attackReset = 0.02f;
	}

	private void OnAttackChanged( bool oldValue, bool newValue )
	{
		UpdateBodyMorph();
	}

	private void OnReloadChanged( bool oldValue, bool newValue )
	{
		UpdateBodyMorph();
	}

	private void OnReloadingChanged( bool oldValue, bool newValue )
	{
		UpdateBodyMorph();
	}

	private void UpdateBodyMorph()
	{
		var bodyChild = GameObject.Children.FirstOrDefault( x => x.Name == "Body" );
		if ( bodyChild == null )
			return;

		var skinnedRenderer = bodyChild.Components.Get<SkinnedModelRenderer>();
		if ( skinnedRenderer == null )
			return;

		skinnedRenderer.Set( "b_attack", b_attack );
		skinnedRenderer.Set( "b_reload", b_reload );
		skinnedRenderer.Set( "b_reloading", b_reloading );
	}

	private void OnHealthChanged( float oldValue, float newValue )
	{
		if ( Networking.IsHost && !IsDead && newValue <= 0f )
		{
			KillOnHost();
		}
	}

	private void OnIsDeadChanged( bool oldValue, bool newValue )
	{
		if ( !newValue && IsLocalOwner() )
		{
			var camera = GameObject.Scene.Camera;
			if ( camera != null )
			{
				camera.WorldPosition = GameObject.WorldPosition;
				camera.WorldRotation = GameObject.WorldRotation;
			}
		}
	}

	private void KillOnHost()
	{
		if ( !Networking.IsHost || IsDead )
			return;

		b_attack = false;
		b_reload = false;
		b_reloading = false;
		IsDead = true;
		Health = 0f;
		_reloadFinish = 0f;
		_reloadAnimReset = 0f;
		_timeUntilRespawn = RespawnDelay;

		RagdollBroadcast();
	}

	[Rpc.Broadcast]
	private void RagdollBroadcast()
	{
		var parentRagdoll = GameObject.Components.Get<ModelPhysics>();
		if ( parentRagdoll != null )
		{
			parentRagdoll.MotionEnabled = true;
		}

		var bodyChild = GameObject.Children.FirstOrDefault( x => x.Name == "Body" );
		var bodyRagdoll = bodyChild?.Components.Get<ModelPhysics>();
		if ( bodyRagdoll != null )
		{
			bodyRagdoll.MotionEnabled = true;
		}
	}

	[Rpc.Broadcast]
	private void RespawnOnHost()
	{
		AmmoInMagazine = MagazineSize;
		b_reload = false;
		b_reloading = false;
		Health = MaxHealth;
		IsDead = false;
		b_attack = false;
		_reloadFinish = 0f;
		_reloadAnimReset = 0f;

		var parentRagdoll = GameObject.Components.Get<ModelPhysics>();
		if ( parentRagdoll != null )
		{
			parentRagdoll.MotionEnabled = false;
		}

		var bodyChild = GameObject.Children.FirstOrDefault( x => x.Name == "Body" );
		var bodyRagdoll = bodyChild?.Components.Get<ModelPhysics>();
		if ( bodyRagdoll != null )
		{
			bodyRagdoll.MotionEnabled = false;
		}

		if ( OriginalSpawn is not null && OriginalSpawn.IsValid() )
		{
			TeleportToSpawn( OriginalSpawn.WorldPosition, OriginalSpawn.WorldRotation );
		}
	}

	[Rpc.Broadcast]
	private void TeleportToSpawn( Vector3 position, Rotation rotation )
	{
		_pendingRespawnPosition = position;
		_pendingRespawnRotation = rotation;
	}

	private void PerformInteraction()
	{
		var traceResult = PerformTrace( InteractRange );
		if ( !traceResult.Hit )
			return;

		RequestButtonIncrementOnHost( traceResult.GameObject );
	}

	private SceneTraceResult PerformWeaponTrace( float range )
	{
		var camera = GameObject.Scene.Camera;
		var burst = FindInHierarchy( GameObject, "Burst" );

		if ( camera == null || burst == null )
			return default;

		// 1) Camera aim from crosshair
		var aimStart = camera.WorldPosition;
		var aimEnd = aimStart + camera.WorldRotation.Forward * range;

		var aimTrace = GameObject.Scene.Trace
			.Ray( aimStart, aimEnd )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();

		var targetPoint = aimTrace.Hit ? aimTrace.EndPosition : aimEnd;

		// 2) Real shot from muzzle toward target point
		var shotStart = burst.WorldPosition;
		var shotDirection = (targetPoint - shotStart).Normal;
		var shotEnd = shotStart + shotDirection * range;

		return GameObject.Scene.Trace
			.Ray( shotStart, shotEnd )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();
	}

	[Rpc.Host]
	private void RequestButtonIncrementOnHost( GameObject hitObject )
	{
		if ( hitObject == null || !hitObject.IsValid() )
			return;

		var scoreButton = hitObject.Components.Get<Score_Button>( FindMode.EverythingInSelfAndAncestors );
		if ( scoreButton == null )
			return;

		if ( scoreButton.Team != Team )
			return;

		scoreButton.CountIncrement();
	}

	private SceneTraceResult PerformTrace( float range )
	{
		var camera = GameObject.Scene.Camera;
		if ( camera == null )
			return default;

		var startPos = camera.WorldPosition;
		var endPos = startPos + camera.WorldRotation.Forward * range;

		Gizmo.Draw.Line( startPos, endPos );
		DebugOverlay.Line( startPos, endPos, Color.Cyan, 3f );

		return GameObject.Scene.Trace
			.Ray( startPos, endPos )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();
	}
}