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;
}
}