feat(movement): spacebar charged jump with skill-based height

Hold spacebar to charge (0→1 over 1s), release to jump. Height from
GetJumpHeight formula using Jump skill via PlayerWeenie. Jump physics
use MotionInterpreter.jump() → LeaveGround() → get_leave_ground_velocity().

JumpExtent is returned in MovementResult (non-null when jump fires this
frame) so GameWindow can log and eventually send the server jump packet.
Double-jump is prevented by jump_is_allowed() checking Contact+OnWalkable
flags before allowing another jump. Tests updated to use charge-then-release
pattern matching the new input model.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-13 23:20:52 +02:00
parent 5cb14da714
commit 0bec5d5296
3 changed files with 46 additions and 10 deletions

View file

@ -31,7 +31,8 @@ public readonly record struct MovementResult(
uint? TurnCommand, uint? TurnCommand,
float? ForwardSpeed, float? ForwardSpeed,
float? SidestepSpeed, float? SidestepSpeed,
float? TurnSpeed); float? TurnSpeed,
float? JumpExtent = null); // non-null when a jump was triggered this frame
/// <summary> /// <summary>
/// Portal-space state for the player movement controller. /// Portal-space state for the player movement controller.
@ -94,6 +95,11 @@ public sealed class PlayerMovementController
/// </summary> /// </summary>
public float VerticalVelocity => _body.Velocity.Z; public float VerticalVelocity => _body.Velocity.Z;
// Jump charge state.
private bool _jumpCharging;
private float _jumpExtent;
private const float JumpChargeRate = 1.0f; // 0→1 over 1 second
// Previous frame's motion commands for change detection. // Previous frame's motion commands for change detection.
private uint? _prevForwardCmd; private uint? _prevForwardCmd;
private uint? _prevSidestepCmd; private uint? _prevSidestepCmd;
@ -246,15 +252,33 @@ public sealed class PlayerMovementController
// Rotation about Z does not affect the Z component, so world Vz == local Vz. // Rotation about Z does not affect the Z component, so world Vz == local Vz.
_body.set_local_velocity(new Vector3(localX, localY, savedWorldVz)); _body.set_local_velocity(new Vector3(localX, localY, savedWorldVz));
// ── 3. Jump ─────────────────────────────────────────────────────────── // ── 3. Jump (charged) ─────────────────────────────────────────────────
if (input.Jump) // Hold spacebar to charge (0→1 over JumpChargeRate seconds).
// Release to execute: jump(extent) validates + sets JumpExtent,
// then LeaveGround() applies the scaled velocity via get_leave_ground_velocity.
float? outJumpExtent = null;
if (input.Jump && _body.OnWalkable)
{ {
var jumpResult = _motion.jump(1.0f); // Spacebar held and on the ground — accumulate charge.
if (!_jumpCharging)
{
_jumpCharging = true;
_jumpExtent = 0f;
}
_jumpExtent = MathF.Min(_jumpExtent + dt * JumpChargeRate, 1.0f);
}
else if (_jumpCharging)
{
// Spacebar released (or left ground during charge) — fire jump.
var jumpResult = _motion.jump(_jumpExtent);
if (jumpResult == WeenieError.None) if (jumpResult == WeenieError.None)
{ {
// jump() set_on_walkable(false); now apply the launch velocity.
_motion.LeaveGround(); _motion.LeaveGround();
outJumpExtent = _jumpExtent;
} }
_jumpCharging = false;
_jumpExtent = 0f;
} }
// ── 4. Integrate physics (gravity, friction, sub-stepping) ──────────── // ── 4. Integrate physics (gravity, friction, sub-stepping) ────────────
@ -384,6 +408,7 @@ public sealed class PlayerMovementController
TurnCommand: outTurnCmd, TurnCommand: outTurnCmd,
ForwardSpeed: outForwardSpeed, ForwardSpeed: outForwardSpeed,
SidestepSpeed: outSidestepSpeed, SidestepSpeed: outSidestepSpeed,
TurnSpeed: outTurnSpeed); TurnSpeed: outTurnSpeed,
JumpExtent: outJumpExtent);
} }
} }

View file

@ -1912,6 +1912,13 @@ public sealed class GameWindow : IDisposable
forcePositionSequence: _liveSession.ForcePositionSequence); forcePositionSequence: _liveSession.ForcePositionSequence);
_liveSession.SendGameAction(body); _liveSession.SendGameAction(body);
} }
if (result.JumpExtent.HasValue)
{
// TODO: send jump packet to server (format needs research from holtburger).
// Local jump physics work without server acknowledgment for now.
Console.WriteLine($"jump: extent={result.JumpExtent.Value:F2}");
}
} }
// Update the player entity's animation cycle to match current motion. // Update the player entity's animation cycle to match current motion.

View file

@ -108,8 +108,11 @@ public class PlayerMovementControllerTests
var controller = new PlayerMovementController(engine); var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
var input = new MovementInput(Jump: true); // Charged jump: hold for a full charge (1s dt), then release to fire.
controller.Update(0.016f, input); // A full charge gives enough Vz that the player clears the 0.05-unit
// ground-snap threshold within the same integration frame.
controller.Update(1.0f, new MovementInput(Jump: true)); // full charge
controller.Update(0.016f, new MovementInput(Jump: false)); // release → jump fires
Assert.True(controller.IsAirborne); Assert.True(controller.IsAirborne);
Assert.True(controller.VerticalVelocity > 0f); Assert.True(controller.VerticalVelocity > 0f);
@ -122,8 +125,9 @@ public class PlayerMovementControllerTests
var controller = new PlayerMovementController(engine); var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
// Jump // Charged jump: hold for a full charge, then release.
controller.Update(0.016f, new MovementInput(Jump: true)); controller.Update(1.0f, new MovementInput(Jump: true)); // full charge
controller.Update(0.016f, new MovementInput(Jump: false)); // release → jump fires
float z1 = controller.Position.Z; float z1 = controller.Position.Z;
// A few frames of rising // A few frames of rising