Weapons/GaussGun/GaussWeapon.cs
using Sandbox.Rendering;
using Sandbox.Utility;

public class GaussWeapon : BaseBulletWeapon
{
	/// <summary>
	/// Having this as its own event separate from weapon events until we have a reason to use it for another weapon
	/// </summary>
	public interface IGaussWeaponEvents : ISceneEvent<IGaussWeaponEvents>
	{
		/// <summary>
		/// Called when we consume a bit of ammo
		/// </summary>
		void OnConsumedAmmo();
	}

	/// <summary>
	/// Shot frequency delay
	/// </summary>
	[Property] public float TimeBetweenShots { get; set; } = 0.1f;

	/// <summary>
	/// How much damage
	/// </summary>
	[Property] public float Damage { get; set; } = 12.0f;

	/// <summary>
	/// How many units deep can geometry be for us to penetrate directly through it.
	/// </summary>
	[Property] public float PenetrationThickness { get; set; } = 32f;

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

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

	/// <summary>
	/// What looping sound should we play while charging the gun
	/// </summary>
	[Property, Feature( "Charge" )]
	public SoundEvent ChargeSoundEvent { get; set; }

	/// <summary>
	/// A curve to get a damage value directly from ChargePower 
	/// </summary>
	[Property, Feature( "Charge" )]
	public Curve ChargeDamage { get; set; }

	/// <summary>
	/// How much force is applied when we shoot a charged shot
	/// </summary>
	[Property, Feature( "Charge" )]
	public Curve ChargeForce { get; set; }

	/// <summary>
	/// How frequently does the ammo drain whilst charging the gauss gun
	/// </summary>
	[Property, Feature( "Charge" )]
	public float ChargeAmmoDrainFrequency { get; set; } = 0.2f;

	/// <summary>
	/// How long do we charge for until the gun overloads
	/// </summary>
	[Property, Feature( "Overload" )]
	public float OverloadTime { get; set; } = 5f;

	/// <summary>
	/// How much damage to inflict on the player if the gun overloads
	/// </summary>
	[Property, Feature( "Overload" )]
	public float OverloadDamage { get; set; } = 20f;

	[Property, Feature( "Overload" )]
	public SoundEvent OverloadSound { get; set; }

	/// <summary>
	/// The charge loop sound handle, we handle its lifetime.
	/// </summary>
	SoundHandle chargeHandle;

	/// <summary>
	/// Normalized charge power between 0 and 1
	/// </summary>
	float chargePower = 0;

	/// <summary>
	/// Is this gun charging up to shoot?
	/// </summary>
	bool isCharging = false;

	/// <summary>
	/// How long has it been since we started charging?
	/// </summary>
	TimeSince timeSinceChargeStart = 0;

	/// <summary>
	/// How long since we drained ammo while charged?
	/// </summary>
	TimeSince timeSinceAmmoTick;

	protected override void OnDisabled()
	{
		StopCharge();
		base.OnDisabled();
	}

	[Rpc.Host]
	private void HurtSelf()
	{
		if ( !Owner.IsValid() ) return;

		Owner.OnDamage( new DamageInfo( OverloadDamage, Owner.GameObject, GameObject ) );
		Sound.Play( OverloadSound, WorldPosition );
	}

	public override void OnControl( Player player )
	{
		base.OnControl( player );

		if ( isCharging )
		{
			if ( chargeHandle is not null )
				chargeHandle.Position = WorldPosition;

			if ( timeSinceChargeStart > OverloadTime )
			{
				StopCharge();
				HurtSelf();

				if ( !player.Controller.ThirdPerson && player.IsLocalPlayer )
				{
					var target = new Vector3( Random.Shared.Float( -10, -15 ), Random.Shared.Float( -25, 0 ), 0 );

					new Sandbox.CameraNoise.Punch( target, 1.0f, 3, 0.5f );
					new Sandbox.CameraNoise.Shake( 0.3f, 1.2f );
				}

				return;
			}

			if ( chargePower < 1f && timeSinceAmmoTick > ChargeAmmoDrainFrequency )
			{
				timeSinceAmmoTick = 0;

				if ( !TakeAmmo( 1 ) )
				{
					ShootCharged( player );
					return;
				}

				IGaussWeaponEvents.PostToGameObject( GameObject.Root, x => x.OnConsumedAmmo() );
			}

			chargePower += 0.5f * Time.Delta;
			chargePower = chargePower.Clamp( 0, 1 );
			chargeHandle.Pitch = chargePower.Remap( 0, 1, 0.5f, 1.2f );

			if ( !player.Controller.ThirdPerson && player.IsLocalPlayer )
			{
				new Sandbox.CameraNoise.Shake( 0.3f * chargePower.Remap( 0, 1, 0.5f, 3f ), Time.Delta );
			}
		}

		if ( !isCharging && Input.Down( "attack1" ) )
		{
			Shoot( player );
		}

		if ( Input.Pressed( "Attack2" ) )
		{
			if ( !TakeAmmo( 1 ) )
			{
				TryAutoReload();
				return;
			}
			else if ( CanShoot() )
			{
				timeSinceChargeStart = 0;
				isCharging = true;
				chargeHandle?.Stop();
				chargeHandle = Sound.Play( ChargeSoundEvent );

				StartAttack();
			}
		}

		if ( Input.Released( "Attack2" ) && isCharging )
		{
			StopAttack();
			ShootCharged( player );
		}
	}

	public override bool IsInUse() => isCharging;

	/// <summary>
	/// Shoot a charged shot
	/// </summary>
	/// <param name="player"></param>
	void ShootCharged( Player player )
	{
		ShootBullet( player, ChargeDamage.Evaluate( chargePower ), true, true );

		// Fling the player 
		var controller = player.GetComponent<PlayerController>();

		// Should expose all these to properties
		controller.Jump( -player.EyeTransform.ForwardRay.Forward * ChargeForce.Evaluate( chargePower ) );

		StopCharge();
	}

	/// <summary>
	/// Stop charging the gun
	/// </summary>
	void StopCharge()
	{
		isCharging = false;
		chargeHandle?.Stop();
		chargePower = 0;
	}

	/// <summary>
	/// Constructs a trace and returns it, doesn't run it yet!
	/// </summary>
	SceneTrace GetBulletTrace( Player player, Vector3 start, Vector3 end, float radius )
	{
		return Scene.Trace.Ray( start, end )
			.IgnoreGameObjectHierarchy( player.GameObject )
			.WithCollisionRules( "bullet" )
			.UseHitboxes()
			.Size( radius );
	}

	/// <summary>
	/// Runs a trace with all the data we have supplied it, and returns the result
	/// </summary>
	IEnumerable<SceneTraceResult> GetShootTraceResults( Player player )
	{
		var hits = new List<SceneTraceResult>();

		var start = player.EyeTransform.Position;
		var rot = Rotation.LookAt( player.EyeTransform.Forward );

		var forward = rot.Forward.WithAimCone( 2 );

		var original = GetBulletTrace( player, start, player.EyeTransform.Position + forward * 4096f, 2f )
						.RunAll();


		if ( original.Count() < 1 )
		{
			hits.Add( GetBulletTrace( player, start, player.EyeTransform.Position + forward * 4096f, 2f ).Run() );
			return hits;
		}

		// Run through and fix the start positions for the traces
		// By using the last end position as the start
		var startPos = original.ElementAt( 0 ).StartPosition;
		List<SceneTraceResult> fixedPath = new();
		for ( int i = 0; i < original.Count(); i++ )
		{
			var el = original.ElementAt( i );

			fixedPath.Add( el with { StartPosition = startPos } );
			startPos = el.EndPosition;
		}

		var entries = new List<(SceneTraceResult Trace, float Thickness)>();

		// Then, trace backwards from the end so we can get exit points and thickness
		for ( int i = fixedPath.Count - 1; i >= 0; i-- )
		{
			var el = fixedPath.ElementAt( i );

			// Do a trace back, from the end position to the start, this'll give us the LAST entry's exit point.
			var backTrace = GetBulletTrace( player, el.EndPosition, el.StartPosition, 2f )
							.Run();

			var impact = backTrace.EndPosition;

			// From that, we can calculate the surface thickness
			float thickness = (el.StartPosition - impact).Length;

			// Return the element starting at the exit point, it's more useful that way.
			el = el with { StartPosition = impact };
			entries.Insert( 0, (el, thickness) );
		}

		// Thickness detection
		{
			var thickness = 0f;
			foreach ( var el in entries )
			{
				thickness += el.Thickness;
				if ( thickness >= PenetrationThickness )
					break;

				hits.Add( el.Trace );
			}
		}

		return hits
			.Where( x => x.Hit && x.Distance > 0f );
	}

	void Shoot( Player player )
	{
		if ( !CanShoot() || !TakeAmmo( 1 ) )
		{
			TryAutoReload();
			return;
		}

		ShootBullet( player, Damage );
	}

	public void ShootBullet( Player player, float damage, bool isCharged = false, bool shouldRicochet = true )
	{
		AddShootDelay( TimeBetweenShots );

		bool shouldPenetrate = isCharged;

		IGaussWeaponEvents.PostToGameObject( GameObject.Root, x => x.OnConsumedAmmo() );

		int count = 0;
		var traces = GetShootTraceResults( player );

		foreach ( var tr in traces )
		{
			ShootEffects( tr.EndPosition, false, tr.Normal, tr.GameObject, tr.Surface, count > 0 ? tr.StartPosition : null );
			TraceAttack( TraceAttackInfo.From( tr, damage, damage > 70 ? [DamageTags.GibAlways] : null ) );
			count++;

			if ( player.IsLocalPlayer )
			{
				HitMarker.CreateFromTrace( tr );
			}

			if ( tr.Hit )
			{
				SpecialImpactEffects( tr.EndPosition, tr.Normal, tr.GameObject, tr.Surface );
			}

			if ( !shouldPenetrate ) break;
		}

		TimeSinceShoot = 0;

		if ( shouldRicochet && traces.Any() )
		{
			// Grab the last sufficient trace, we only want to ricochet at the end
			var tr = traces.Last();

			var reflectDir = Vector3.Reflect( tr.Direction, tr.Normal ).Normal;
			var angle = reflectDir.Angle( tr.Direction );

			// Some acute angle
			if ( angle < 45f )
			{
				tr = GetBulletTrace( player, tr.EndPosition, tr.EndPosition + (reflectDir * 4096), 2f ).Run();

				ShootEffects( tr.EndPosition, false, tr.Normal, tr.GameObject, tr.Surface, count > 0 ? tr.StartPosition : null );
				TraceAttack( TraceAttackInfo.From( tr, damage ) );

				if ( player.IsLocalPlayer )
				{
					HitMarker.CreateFromTrace( tr );
				}

				if ( tr.Hit )
				{
					SpecialImpactEffects( tr.EndPosition, tr.Normal, tr.GameObject, tr.Surface );
				}
			}
		}

		player.Controller.EyeAngles += new Angles( Random.Shared.Float( -0.2f, -0.3f ), Random.Shared.Float( -0.1f, 0.1f ), 0 );

		if ( !player.Controller.ThirdPerson && player.IsLocalPlayer )
		{
			var target = new Vector3( Random.Shared.Float( -10, -15 ), Random.Shared.Float( -10, 0 ), 0 );
			target *= chargePower.Remap( 0, 1, 1, 10 );

			new Sandbox.CameraNoise.Punch( target, 1.0f, 3, 0.5f );
			new Sandbox.CameraNoise.Shake( 0.3f, 1.2f );
		}
	}

	public override void DrawCrosshair( HudPainter hud, Vector2 center )
	{
		var tss = TimeSinceShoot.Relative.Remap( 0, 0.2f, 1, 0 );

		var gap = 6 + Easing.EaseOut( tss ) * 32;
		var len = 6;
		var w = 2;

		Color color = !CanShoot() ? UI.CrosshairInactive : UI.CrosshairActive;

		hud.DrawLine( center + Vector2.Left * (len + gap) * 2, center + Vector2.Left * gap * 2, w, color );
		hud.DrawLine( center - Vector2.Left * (len + gap) * 2, center - Vector2.Left * gap * 2, w, color );
		hud.DrawLine( center + Vector2.Up * (len + gap), center + Vector2.Up * gap, w, color );
		hud.DrawLine( center - Vector2.Up * (len + gap), center - Vector2.Up * gap, w, color );
	}

	[Rpc.Broadcast]
	public void SpecialImpactEffects( Vector3 hitpoint, Vector3 normal, GameObject hitObject, Surface hitSurface )
	{
		if ( Application.IsDedicatedServer ) return;

		if ( ImpactEffect is null )
			return;

		var impact = ImpactEffect.Clone();
		impact.WorldPosition = hitpoint + normal;
		impact.WorldRotation = Rotation.LookAt( normal ) * new Angles( 90, 0, 0 );
		impact.SetParent( hitObject, true );
	}
}