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,
float? ForwardSpeed,
float? SidestepSpeed,
float? TurnSpeed);
float? TurnSpeed,
float? JumpExtent = null); // non-null when a jump was triggered this frame
/// <summary>
/// Portal-space state for the player movement controller.
@ -94,6 +95,11 @@ public sealed class PlayerMovementController
/// </summary>
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.
private uint? _prevForwardCmd;
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.
_body.set_local_velocity(new Vector3(localX, localY, savedWorldVz));
// ── 3. Jump ───────────────────────────────────────────────────────────
if (input.Jump)
// ── 3. Jump (charged) ─────────────────────────────────────────────────
// 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)
{
// jump() set_on_walkable(false); now apply the launch velocity.
_motion.LeaveGround();
outJumpExtent = _jumpExtent;
}
_jumpCharging = false;
_jumpExtent = 0f;
}
// ── 4. Integrate physics (gravity, friction, sub-stepping) ────────────
@ -384,6 +408,7 @@ public sealed class PlayerMovementController
TurnCommand: outTurnCmd,
ForwardSpeed: outForwardSpeed,
SidestepSpeed: outSidestepSpeed,
TurnSpeed: outTurnSpeed);
TurnSpeed: outTurnSpeed,
JumpExtent: outJumpExtent);
}
}

View file

@ -1912,6 +1912,13 @@ public sealed class GameWindow : IDisposable
forcePositionSequence: _liveSession.ForcePositionSequence);
_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.

View file

@ -108,8 +108,11 @@ public class PlayerMovementControllerTests
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
var input = new MovementInput(Jump: true);
controller.Update(0.016f, input);
// Charged jump: hold for a full charge (1s dt), then release to fire.
// 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.VerticalVelocity > 0f);
@ -122,8 +125,9 @@ public class PlayerMovementControllerTests
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
// Jump
controller.Update(0.016f, new MovementInput(Jump: true));
// Charged jump: hold for a full charge, then release.
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;
// A few frames of rising