Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions game/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,7 @@ metadata is retained; reserved ledges and glide lifts do not become solid surfac
Walk with A/D or left/right arrows, or a gamepad stick/D-pad. Holding the same
direction for 0.4 seconds accelerates from 150.5 to 258 px/s (3.5 to 6 m/s).
F2 shows speed, hold time, position and the 28×76 player body. The hero currently
uses the idle atlas pose; animation switching is delivered by #31. Jump, slopes
and ladders follow in their own issues.
uses the idle atlas pose on the ground; full animation switching is delivered by #31.
Space/K or gamepad A/Cross jumps, with about 52px tap height and 96px held height,
0.1s coyote time and 0.12s buffering. Landing uses the drawn landing pose; falls
of at least 152px use the four-frame hard landing. Slopes and ladders follow separately.
3 changes: 2 additions & 1 deletion game/src/player/body.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Box, Geometry } from '../stage/collision';
import { GRAVITY } from './jump';
export const BODY_WIDTH = 28;
export const BODY_HEIGHT = 76;
export interface Body { x: number; y: number; vx: number; vy: number; grounded: boolean }
Expand All @@ -19,7 +20,7 @@ export function stepBody(body: Body, geometry: Geometry[], dt: number) {
body.x = wall.x + wall.w + 14; body.vx = 0;
}
}
body.vy = Math.min(900, body.vy + 1200 * dt);
body.vy = Math.min(900, body.vy + GRAVITY * dt);
let nextY = body.y + body.vy * dt;
body.grounded = false;
for (const world of geometry) {
Expand Down
52 changes: 52 additions & 0 deletions game/src/player/jump.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { jump, jumpState, landing } from './jump';
import { stepBody, type Body } from './body';
import type { Geometry } from '../stage/collision';
const floor: Geometry = { walk: [[[0, 500], [1000, 500]]], slide: [], oneWay: [], solid: [],
climb: [], noGo: [], hazard: [], interact: [], spawn: [], ledge: [], glideLift: [] };
const body = (): Body => ({ x: 100, y: 500, vx: 0, vy: 0, grounded: true });
const dt = 1 / 120;

describe('controlled jump', () => {
const height = (releaseAt: number) => {
const b = body(), state = jumpState(b.y);
for (let i = 0; i < 160; i++) {
jump(b, state, i < releaseAt, dt);
const grounded = b.grounded;
stepBody(b, [floor], dt); landing(b, state, grounded, dt);
}
expect(b.y).toBe(500); expect(b.grounded).toBe(true);
return state.height;
};
it('gives about 52px on tap and 96px held, with intermediate control', () => {
expect(height(2)).toBeCloseTo(52, -1);
expect(height(120)).toBeCloseTo(96, 0);
expect(height(25)).toBeGreaterThan(65); expect(height(25)).toBeLessThan(96);
});
it('allows coyote jumps for 0.1s but rejects later presses', () => {
for (const [wait, accepted] of [[9, true], [14, false]] as const) {
const b = body(), state = jumpState(b.y); jump(b, state, false, dt); b.grounded = false;
for (let i = 0; i < wait; i++) jump(b, state, false, dt);
jump(b, state, true, dt); expect(b.vy < 0).toBe(accepted);
}
});
it('buffers a press just before landing, expires it, and never repeats a held jump', () => {
for (const [wait, accepted] of [[8, true], [16, false]] as const) {
const b = { ...body(), grounded: false }, state = jumpState(500);
jump(b, state, true, dt);
for (let i = 0; i < wait; i++) jump(b, state, true, dt);
b.grounded = true; jump(b, state, true, dt); expect(b.vy < 0).toBe(accepted);
}
const b = body(), state = jumpState(500); let launches = 0;
for (let i = 0; i < 240; i++) {
jump(b, state, true, dt); if (b.vy === -485) launches++;
const grounded = b.grounded; stepBody(b, [floor], dt); landing(b, state, grounded, dt);
}
expect(launches).toBe(1);
});
it('selects the hard landing after a fall of at least two body heights', () => {
const b = body(), state = jumpState(300);
landing(b, state, false, dt); expect(state.hard).toBe(true); expect(state.landing).toBe(4 / 12);
state.peak = 450; landing(b, state, false, dt); expect(state.hard).toBe(false);
});
});
40 changes: 40 additions & 0 deletions game/src/player/jump.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Body } from './body';
export const COYOTE = 0.1;
export const BUFFER = 0.12;
export const GRAVITY = 1200;
export const TAP_HEIGHT = 52;
export const HOLD_HEIGHT = 96;
export const JUMP_SPEED = 485; // Semi-implicit 120Hz integration reaches about 96px.
export interface Jump {
coyote: number; buffer: number; wasDown: boolean; active: boolean;
origin: number; peak: number; height: number; landing: number; hard: boolean;
}
export const jumpState = (y: number): Jump => ({ coyote: 0, buffer: 0, wasDown: false, active: false,
origin: y, peak: y, height: 0, landing: 0, hard: false });

export function jump(body: Body, state: Jump, down: boolean, dt: number) {
state.coyote = body.grounded ? COYOTE : Math.max(0, state.coyote - dt);
state.buffer = down && !state.wasDown ? BUFFER : Math.max(0, state.buffer - dt);
state.wasDown = down;
if (state.buffer > 1e-9 && state.coyote > 1e-9) {
body.vy = -JUMP_SPEED; body.grounded = false;
state.coyote = 0; state.buffer = 0; state.active = true;
state.origin = body.y; state.peak = body.y; state.height = 0; state.landing = 0;
}
if (!down && state.active && body.vy < 0) {
// Early releases share a 52px minimum rise; later releases stop at their current height.
const remainingRise = Math.max(0, TAP_HEIGHT - (state.origin - body.y));
body.vy = Math.max(body.vy, -Math.sqrt(2 * GRAVITY * remainingRise));
}
}

export function landing(body: Body, state: Jump, wasGrounded: boolean, dt: number) {
state.landing = Math.max(0, state.landing - dt);
state.peak = Math.min(state.peak, body.y);
if (state.active) state.height = Math.max(state.height, state.origin - body.y);
if (!wasGrounded && body.grounded) {
state.hard = body.y - state.peak >= 152; // Two body heights; tune with the route playtest.
state.landing = state.hard ? 4 / 12 : 2 / 12;
state.active = false; state.peak = body.y;
}
}
17 changes: 15 additions & 2 deletions game/src/player/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Phaser from 'phaser';
import type { CollisionChunk } from '../stage/collision';
import { BODY_HEIGHT, BODY_WIDTH, overlaps, stepBody, type Body } from './body';
import { horizontal, move, movement } from './movement';
import { jump, jumpState, landing } from './jump';

export class Player {
readonly body: Body;
Expand All @@ -13,11 +14,13 @@ export class Player {
private showDebug = false;
private lastSpeed = 0;
private spawn;
private air;
constructor(private scene: Phaser.Scene, private chunks: CollisionChunk[]) {
this.spawn = chunks.flatMap(c => c.world.spawn).find(p => p.kind === 'player') ?? { x: 120, y: 461 };
this.body = { x: this.spawn.x, y: this.spawn.y, vx: 0, vy: 0, grounded: true };
this.air = jumpState(this.body.y);
this.sprite = scene.add.sprite(this.body.x, this.body.y, 'hero-move', 'idle-00').setOrigin(0.5, 1).setDepth(100);
this.keys = scene.input.keyboard!.addKeys('A,D,LEFT,RIGHT') as Record<string, Phaser.Input.Keyboard.Key>;
this.keys = scene.input.keyboard!.addKeys('A,D,LEFT,RIGHT,SPACE,K') as Record<string, Phaser.Input.Keyboard.Key>;
this.debug = scene.add.graphics().setDepth(10001);
this.label = scene.add.text(20, 80, '', { fontFamily: 'monospace', fontSize: '18px', backgroundColor: '#17242d' })
.setScrollFactor(0).setDepth(10002).setVisible(false);
Expand All @@ -29,24 +32,34 @@ export class Player {
const pad = this.scene.input.gamepad?.getAll().find(p => p.connected);
const axis = horizontal(this.keys.A.isDown || this.keys.LEFT.isDown, this.keys.D.isDown || this.keys.RIGHT.isDown,
pad?.leftStick.x, pad?.left, pad?.right);
const jumpDown = this.keys.SPACE.isDown || this.keys.K.isDown || !!pad?.A;
// Substeps keep wall and floor contacts stable during slow rendered frames.
let remaining = Math.min(milliseconds / 1000, 0.1);
while (remaining > 1e-9) {
const dt = Math.min(remaining, 1 / 120);
move(this.motion, axis, dt); this.body.vx = this.motion.vx;
jump(this.body, this.air, jumpDown, dt);
const wasGrounded = this.body.grounded;
stepBody(this.body, this.chunks.map(c => c.world), dt);
landing(this.body, this.air, wasGrounded, dt);
this.motion.vx = this.body.vx;
remaining -= dt;
}
if (this.body.y > 900 || this.chunks.some(c => c.world.noGo.some(box => overlaps(this.body, box)))) {
Object.assign(this.body, { x: this.spawn.x, y: this.spawn.y, vx: 0, vy: 0, grounded: true });
this.motion = movement();
this.air = jumpState(this.body.y);
}
const pose = this.air.landing > 0 ? this.air.hard
? `hard-landing-${String(Math.min(3, Math.floor((4 / 12 - this.air.landing) * 12))).padStart(2, '0')}`
: 'jump-08' : !this.body.grounded ? this.body.vy < 0 ? 'jump-02' : 'fall-00' : null;
if (pose && this.scene.textures.get('hero-air').has(pose)) this.sprite.setTexture('hero-air', pose);
else this.sprite.setTexture('hero-move', 'idle-00');
this.sprite.setPosition(Math.round(this.body.x), Math.round(this.body.y)).setFlipX(this.motion.facing < 0);
if (axis) this.lastSpeed = Math.abs(this.body.vx);
this.debug.clear();
if (this.showDebug) this.debug.lineStyle(2, 0xffffff).strokeRect(this.body.x - BODY_WIDTH / 2,
this.body.y - BODY_HEIGHT, BODY_WIDTH, BODY_HEIGHT);
this.label.setText(`vx ${this.body.vx.toFixed(1)} px/s | last ${this.lastSpeed.toFixed(1)} (${(this.lastSpeed / 43).toFixed(2)} m/s)\nheld ${this.motion.held.toFixed(2)}s | x ${this.body.x.toFixed(1)} y ${this.body.y.toFixed(1)}`);
this.label.setText(`vx ${this.body.vx.toFixed(1)} px/s | last ${this.lastSpeed.toFixed(1)} (${(this.lastSpeed / 43).toFixed(2)} m/s)\nheld ${this.motion.held.toFixed(2)}s | x ${this.body.x.toFixed(1)} y ${this.body.y.toFixed(1)}\njump ${this.air.height.toFixed(1)}px | landing ${this.air.hard ? 'hard' : 'normal'}`);
}
}
Loading