Game/PlayerMovement.cs
namespace Monolith;

/// <summary>
/// Grounded Quake/Devil Daggers movement: gravity, a solid arena floor, strafe-jumping and a
/// double jump. Flight is gone.
///
/// The three rules that make bunny hopping work here, and which are easy to get wrong:
///
/// 1. **Ground friction is skipped on the frame you jump.** If friction runs before the jump
///    impulse, every landing scrubs your speed and chaining hops feels like wading. Buffering
///    the jump so it fires the instant you touch down is what makes the chain possible at all.
/// 2. **Speed comes from hop TIMING, not from steering.** A press inside
///    <see cref="Tuning.PerfectHopWindow"/> of landing is worth roughly five times a sloppy one.
/// 3. **Air movement steers without accelerating.** Above ground speed the wish direction only
///    rotates your velocity; its magnitude is preserved exactly.
///
/// Rules 2 and 3 are a deliberate correction. This was originally a faithful Quake
/// implementation, where speed comes from air-strafing. The reference does not work that way:
/// "strafing does not affect your speed in any way" (GOALS 6d). Quake rewards steering, Devil
/// Daggers rewards timing, and we are aiming at the latter.
/// </summary>
public sealed class PlayerMovement : Component
{
	[Property] public float EyeHeight { get; set; } = 64f;

	[Property] public float Gravity { get; set; } = 1400f;
	[Property] public float GroundSpeed { get; set; } = 420f;

	/// <summary>
	/// How hard you push off the ground. Low, so reaching top speed takes a beat rather than
	/// happening the instant you touch a key. Half of "skating" is that you cannot start
	/// instantly, not just that you cannot stop.
	/// </summary>
	[Property] public float GroundAccel { get; set; } = 6.5f;

	/// <summary>
	/// Ground drag. **This is the ice.** At the old 6.5 you stopped almost the moment you let
	/// go, which made every direction change free and momentum irrelevant. Low enough that you
	/// slide well past where you released, so committing to a direction is a real decision and
	/// carrying speed through a turn is worth doing.
	/// </summary>
	[Property] public float Friction { get; set; } = 2.4f;

	[Property] public float JumpPower { get; set; } = 480f;

	/// <summary>Extra mid-air jumps. One gives the double jump.</summary>
	[Property] public int AirJumps { get; set; } = 1;

	/// <summary>
	/// Burst added along your current direction of travel when you take the second jump.
	///
	/// Was 620 when it only fired on a deliberate sideways press. Now it fires on EVERY moving
	/// double jump, so it is tuned down: stacked on top of a sprint it still comfortably clears
	/// a tracking beam, which is the job it has to do.
	/// </summary>
	[Property] public float StrafeJumpImpulse { get; set; } = 470f;

	/// <summary>
	/// How fast you can turn your velocity in the air, as a lerp rate per second.
	///
	/// This replaced <c>AirControl</c>, which was the Quake air-accel constant and existed
	/// specifically to let strafing add speed. Speed now comes only from hop timing, so this
	/// governs steering alone. High enough to dodge with, and it can never change your speed.
	/// </summary>
	[Property] public float AirSteerRate { get; set; } = 4.5f;

	[Property] public float AirAccel { get; set; } = 90f;

	/// <summary>How long after leaving the ground a jump still counts as a ground jump.</summary>
	[Property] public float CoyoteTime { get; set; } = 0.12f;

	/// <summary>How early a jump press is remembered so it fires the moment you land.</summary>
	[Property] public float JumpBuffer { get; set; } = 0.16f;

	public Vector3 Velocity { get; private set; }
	public bool IsGrounded { get; private set; }

	/// <summary>
	/// 0 to 1: how far into the hop-speed range you currently are. Drives the FOV widening and
	/// is the readout the whole timing mechanic hangs off.
	/// </summary>
	public float HopCharge
	{
		get
		{
			float ceiling = GroundSpeed * Tuning.HopSpeedMax;
			float over = HorizontalSpeed - GroundSpeed;

			return ceiling <= GroundSpeed
				? 0f
				: Math.Clamp( over / (ceiling - GroundSpeed), 0f, 1f );
		}
	}

	/// <summary>True for a moment after a perfectly timed hop, for the HUD and for feel.</summary>
	public bool JustNailedHop => timeSincePerfectHop < 0.25f;

	private GameTimeSince timeSincePerfectHop = 99f;

	/// <summary>
	/// Multiplier on ground speed, applied by whatever is currently holding you. Set every frame
	/// by the hazards themselves, so nothing has to remember to clear it: an Anchor that dies
	/// simply stops writing to it.
	/// </summary>
	public float SpeedMultiplier { get; set; } = 1f;

	/// <summary>
	/// True briefly after a hit. Cosmetic only now: it drives feedback, and deliberately does
	/// NOT gate firing. See the note in Miner.UpdatePlayerFire.
	/// </summary>
	public bool IsStaggered => timeSinceStagger < Tuning.StaggerSeconds;

	private GameTimeSince timeSinceStagger = 99f;

	/// <summary>
	/// Called when something hits you. It scrubs your momentum, which breaks a hop chain, and
	/// drops your Resonance, which is real income. It does NOT stop you shooting: taking away
	/// agency is worse than taking away time or progress, and an unexplained dead trigger reads
	/// as a broken game rather than as a penalty (GOALS 6b).
	/// </summary>
	/// <summary>
	/// Throws the player. Used by the rocket jump, and by anything else that should move you
	/// rather than merely slow you down.
	/// </summary>
	/// <remarks>
	/// Clears the grounded flag and refunds the air jump on the way out. Without the refund a
	/// rocket jump would silently consume your double jump the instant it launched you, which
	/// would make the two techniques mutually exclusive rather than something you chain. Chaining
	/// them is the entire point of adding this.
	/// </remarks>
	public void AddImpulse( Vector3 impulse, bool refundAirJump = true )
	{
		Velocity += impulse;

		if ( impulse.z > 1f )
		{
			IsGrounded = false;
			timeSinceGrounded = 99f;
		}

		if ( refundAirJump )
			AirJumpsLeft = AirJumps;
	}

	public void Stagger()
	{
		// Already reeling. Being chain-staggered into helplessness is the one way this could
		// become genuinely unfair, so extra hits during the window do nothing.
		if ( IsStaggered )
			return;

		timeSinceStagger = 0f;

		var flat = Velocity.WithZ( 0 ) * Tuning.StaggerSpeedKept;
		Velocity = flat.WithZ( Velocity.z );

		if ( PlayerProgress.Local.IsValid() )
			PlayerProgress.Local.BreakResonance();

		Audio.Play( Audio.GenericHit, WorldPosition, 0.8f, 0.55f );
	}

	/// <summary>
	/// Predicted seconds until landing, from the current fall. This is what makes the timing
	/// window testable at all: without it, "just before landing" cannot be measured.
	/// </summary>
	private float TimeToLand()
	{
		if ( IsGrounded ) return 0f;

		float height = WorldPosition.z - (FloorZ + EyeHeight);
		if ( height <= 0f ) return 0f;

		// Solving 0 = h + vt - gt^2/2 for the positive root.
		float v = Velocity.z;
		float g = Gravity;

		float disc = v * v + 2f * g * height;
		if ( disc <= 0f ) return 99f;

		return (v + MathF.Sqrt( disc )) / g;
	}

	/// <summary>Horizontal speed, which is the number that matters for bunny hopping.</summary>
	public float HorizontalSpeed => Velocity.WithZ( 0 ).Length;

	public int AirJumpsLeft { get; private set; }

	/// <summary>World Z of the arena floor. Set by the manager to sit under the shape.</summary>
	public float FloorZ { get; set; }

	/// <summary>Centre of the walled arena in XY. Set alongside <see cref="FloorZ"/>.</summary>
	public Vector3 ArenaCentre { get; set; }

	/// <summary>
	/// Half-width of the arena. Set per stage by the manager rather than read from Tuning, so
	/// the walls can grow with the shape. A fixed 2600 left the shared Monolith's barriers
	/// orbiting outside the walls, where the player could never reach them.
	/// </summary>
	public float ArenaHalfExtent { get; set; } = Tuning.ArenaMinHalfExtent;

	private Angles viewAngles;
	private GameTimeSince timeSinceGrounded = 99f;
	private GameTimeSince timeSinceJumpPressed = 99f;
	private bool jumpHeldLastFrame;

	/// <summary>Whether the buffered jump press was made inside the perfect-hop window.</summary>
	private bool pressedPerfect;

	protected override void OnStart()
	{
		viewAngles = WorldRotation.Angles();
		AirJumpsLeft = AirJumps;
	}

	/// <summary>Adopt an externally set rotation without the next look update snapping it back.</summary>
	public void SnapTo( Rotation rotation )
	{
		viewAngles = rotation.Angles();
		viewAngles.roll = 0f;
		WorldRotation = viewAngles.ToRotation();
		Velocity = Vector3.Zero;
	}

	/// <summary>Drops the player onto the floor at a sensible spot facing the shape.</summary>
	public void PlaceOnFloor( Vector3 position, Rotation lookAt )
	{
		WorldPosition = position.WithZ( FloorZ + EyeHeight );
		SnapTo( lookAt );
	}

	protected override void OnUpdate()
	{
		// Frozen while a blocking screen or the pause menu is up. See GameTime.
		if ( GameTime.Paused )
			return;

		if ( Hud.CursorVisible )
		{
			// Panel open: keep simulating physics so you do not hang in mid-air, take no input.
			Move( Vector3.Zero, false, WorldRotation );
			jumpHeldLastFrame = false;
			return;
		}

		UpdateLook();

		bool jumpDown = Input.Down( "Jump" );

		if ( jumpDown && !jumpHeldLastFrame )
		{
			timeSinceJumpPressed = 0;

			// Judged at PRESS time, not at jump time. The jump buffer means a press can fire
			// several frames later once you touch down, and by then "how close to landing were
			// you" is unanswerable. This is the entire timing mechanic, so it has to be sampled
			// at the moment the player actually acted.
			pressedPerfect = !IsGrounded && TimeToLand() <= Tuning.PerfectHopWindow;
		}

		jumpHeldLastFrame = jumpDown;

		var move = Input.AnalogMove;
		var rot = WorldRotation;

		// Wish direction is horizontal only. Looking up must not slow you down.
		var wish = (rot.Forward.WithZ( 0 ).Normal * move.x)
			+ (rot.Left.WithZ( 0 ).Normal * move.y);

		if ( wish.Length > 1f )
			wish = wish.Normal;

		Move( wish, Input.Down( "Run" ), rot );
	}

	private void UpdateLook()
	{
		var look = Input.AnalogLook;

		viewAngles.pitch = Math.Clamp( viewAngles.pitch + look.pitch, -89f, 89f );
		viewAngles.yaw += look.yaw;
		viewAngles.roll = 0f;

		WorldRotation = viewAngles.ToRotation();
	}

	private void Move( Vector3 wish, bool sprinting, Rotation rot )
	{
		float dt = Time.Delta;
		var velocity = Velocity;

		bool wasGrounded = IsGrounded;
		IsGrounded = WorldPosition.z <= FloorZ + EyeHeight + 0.5f && velocity.z <= 0.01f;

		if ( IsGrounded )
		{
			timeSinceGrounded = 0;

			if ( !wasGrounded )
				AirJumpsLeft = AirJumps;
		}

		bool canGroundJump = IsGrounded || timeSinceGrounded < CoyoteTime;
		bool wantsJump = timeSinceJumpPressed < JumpBuffer;

		// Jump BEFORE friction. Applying friction first is what kills a hop chain.
		if ( wantsJump && canGroundJump )
		{
			// THE HOP. Speed comes from WHEN you press, not from where you steer.
			//
			// A press inside PerfectHopWindow of landing is worth roughly five times a sloppy
			// one, and both beat not jumping at all. Crucially the boost goes along your CURRENT
			// TRAVEL, so a chain builds on itself and a hop taken while stationary does nothing:
			// the mechanic rewards keeping a line, which is what makes a Spotter's arc something
			// you can outrun rather than something you tank.
			bool perfect = pressedPerfect;
			pressedPerfect = false;

			var travel = velocity.WithZ( 0 );

			if ( travel.Length > 30f )
			{
				float boost = perfect ? Tuning.PerfectHopBoost : Tuning.SloppyHopBoost;
				float ceiling = GroundSpeed * Tuning.HopSpeedMax;

				// Only ADD up to the ceiling. Clamping the total afterwards would silently eat a
				// perfect hop taken at top speed, which is exactly when it feels most earned.
				float allowed = MathF.Max( 0f, ceiling - travel.Length );

				velocity += travel.Normal * MathF.Min( boost, allowed );
			}

			if ( perfect )
			{
				timeSincePerfectHop = 0f;
				Audio.Play( Audio.Jump, WorldPosition, 0.55f, 1.35f );
			}
			else
			{
				Audio.Play( Audio.Land, WorldPosition, 0.28f, 0.9f );
			}

			velocity.z = JumpPower;
			timeSinceJumpPressed = 99f;
			timeSinceGrounded = 99f;
			IsGrounded = false;
		}
		else if ( wantsJump && AirJumpsLeft > 0 )
		{
			// Double jump. Reset vertical speed first so it always feels the same whether you
			// are rising or already falling.
			velocity.z = JumpPower * 0.92f;

			// DASH: the second jump always throws you along your CURRENT DIRECTION OF TRAVEL.
			//
			// It used to require a purely sideways input, gated on a dot product against your
			// facing. That meant the dodge only existed if you happened to be doing something
			// deliberate and unusual at the moment you needed it, and it did nothing at all if
			// you were running forwards. Converting your existing momentum means it is always
			// there, which is what makes it the thing you reach for to break a beam.
			var travel = velocity.WithZ( 0 );

			// Falls back to the stick when you are standing still, so a standing double jump
			// still goes where you are asking rather than nowhere.
			var direction = travel.Length > 40f
				? travel.Normal
				: wish.WithZ( 0 );

			if ( direction.Length > 0.1f )
			{
				velocity += direction.Normal * StrafeJumpImpulse;
				velocity.z = JumpPower * 0.62f;
			}

			AirJumpsLeft--;
			timeSinceJumpPressed = 99f;
		}
		else if ( IsGrounded )
		{
			velocity = ApplyFriction( velocity, dt );
		}

		float hold = MathF.Max( 0.15f, SpeedMultiplier );
		float speed = GroundSpeed * (sprinting ? 1.45f : 1f) * hold;

		// A HOLD MUST DRAG YOU DOWN, not merely cap what you are aiming for.
		//
		// Lowering the wish speed alone did nothing noticeable, because Accelerate only ADDS up
		// to the wish speed: a player already moving faster than the cap simply keeps their
		// velocity, and with the ice-pass friction at 2.4 they coast almost forever. The tether
		// was attached, the multiplier was applied, and nothing happened.
		if ( hold < 0.99f )
		{
			var flat = velocity.WithZ( 0 );
			float ceiling = GroundSpeed * hold;

			if ( flat.Length > ceiling )
			{
				// Eased rather than clamped, so being tethered feels like being dragged back
				// instead of hitting a wall.
				float scrubbed = MathX.Lerp( flat.Length, ceiling,
					MathF.Min( 1f, Tuning.HoldDragRate * dt ) );

				velocity = (flat.Normal * scrubbed).WithZ( velocity.z );
			}
		}

		// Reset AFTER use, so whatever is holding you has to keep asserting it every frame.
		// A hazard that dies mid-frame therefore releases you automatically.
		SpeedMultiplier = 1f;

		velocity = IsGrounded
			? Accelerate( velocity, wish, speed, GroundAccel, dt )
			: AirMove( velocity, wish, speed, dt );

		if ( !IsGrounded )
			velocity.z -= Gravity * dt;

		var position = WorldPosition + velocity * dt;

		// The floor is solid. Nothing else in the arena is.
		float floorTop = FloorZ + EyeHeight;
		if ( position.z <= floorTop )
		{
			position.z = floorTop;
			if ( velocity.z < 0f ) velocity.z = 0f;
		}

		// Walls. Clamp position and kill only the velocity component into the wall, so sliding
		// along one keeps your speed instead of stopping you dead.
		float limit = MathF.Max( 200f, ArenaHalfExtent - 24f );

		float localX = position.x - ArenaCentre.x;
		float localY = position.y - ArenaCentre.y;

		if ( MathF.Abs( localX ) > limit )
		{
			position.x = ArenaCentre.x + MathF.Sign( localX ) * limit;
			if ( MathF.Sign( velocity.x ) == MathF.Sign( localX ) ) velocity.x = 0f;
		}

		if ( MathF.Abs( localY ) > limit )
		{
			position.y = ArenaCentre.y + MathF.Sign( localY ) * limit;
			if ( MathF.Sign( velocity.y ) == MathF.Sign( localY ) ) velocity.y = 0f;
		}

		WorldPosition = position;
		Velocity = velocity;
	}

	private Vector3 ApplyFriction( Vector3 velocity, float dt )
	{
		var horizontal = velocity.WithZ( 0 );
		float speed = horizontal.Length;

		if ( speed < 0.1f )
			return velocity.WithZ( velocity.z );

		float drop = speed * Friction * dt;
		float scale = MathF.Max( 0f, speed - drop ) / speed;

		return (horizontal * scale).WithZ( velocity.z );
	}

	/// <summary>
	/// Air movement: **steer, do not accelerate.**
	///
	/// This is the second half of getting off the Quake model. Quake lets an air strafe add speed
	/// without limit, which is why the old code capped wish speed at a small air-control
	/// constant. The reference does not work that way:
	///
	///   "strafing does not affect your speed in any way" (GOALS 6d)
	///
	/// So above ground speed the wish direction only ROTATES your velocity, preserving its
	/// magnitude exactly. You retain full control of where you are going and none at all of how
	/// fast, which puts every bit of speed back on the hop timing where it belongs.
	///
	/// Below ground speed it still accelerates normally, otherwise jumping from a standstill
	/// would leave you drifting with no way to build up, which is neither game's behaviour.
	/// </summary>
	private Vector3 AirMove( Vector3 velocity, Vector3 wishDir, float wishSpeed, float dt )
	{
		var flat = velocity.WithZ( 0 );
		float speed = flat.Length;

		if ( speed < wishSpeed )
			return Accelerate( velocity, wishDir, wishSpeed, AirAccel, dt );

		if ( wishDir.LengthSquared < 0.001f || speed < 1f )
			return velocity;

		var wanted = wishDir.Normal * speed;
		var steered = Vector3.Lerp( flat, wanted, MathF.Min( 1f, AirSteerRate * dt ) );

		// Re-normalised to the ORIGINAL speed. Lerping two vectors of equal length shortens the
		// result, so without this a hard turn would quietly bleed speed and air control would
		// become a hidden brake.
		if ( steered.Length > 0.01f )
			steered = steered.Normal * speed;

		return steered.WithZ( velocity.z );
	}

	/// <summary>
	/// Quake acceleration. Only the part of the wish direction you are not already travelling
	/// in is added, so on the ground this simply reaches top speed.
	/// </summary>
	private static Vector3 Accelerate( Vector3 velocity, Vector3 wishDir, float wishSpeed, float accel, float dt )
	{
		if ( wishDir.LengthSquared < 0.001f )
			return velocity;

		float current = Vector3.Dot( velocity, wishDir );
		float add = wishSpeed - current;

		if ( add <= 0f )
			return velocity;

		float accelSpeed = MathF.Min( accel * wishSpeed * dt, add );
		return velocity + wishDir * accelSpeed;
	}
}