diff --git a/game/README.md b/game/README.md index bb9ef12..5ca6960 100644 --- a/game/README.md +++ b/game/README.md @@ -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. diff --git a/game/src/player/body.ts b/game/src/player/body.ts index 4b03a29..a94e933 100644 --- a/game/src/player/body.ts +++ b/game/src/player/body.ts @@ -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 } @@ -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) { diff --git a/game/src/player/jump.test.ts b/game/src/player/jump.test.ts new file mode 100644 index 0000000..5abc28b --- /dev/null +++ b/game/src/player/jump.test.ts @@ -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); + }); +}); diff --git a/game/src/player/jump.ts b/game/src/player/jump.ts new file mode 100644 index 0000000..834b6e0 --- /dev/null +++ b/game/src/player/jump.ts @@ -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; + } +} diff --git a/game/src/player/player.ts b/game/src/player/player.ts index 3f560f6..a7ae3d7 100644 --- a/game/src/player/player.ts +++ b/game/src/player/player.ts @@ -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; @@ -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; + this.keys = scene.input.keyboard!.addKeys('A,D,LEFT,RIGHT,SPACE,K') as Record; 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); @@ -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'}`); } }