From 93c9874aede4b9dfb203391e5b666cc2227954e2 Mon Sep 17 00:00:00 2001 From: Vatsal2005 <83007841+Vatsal2005@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:44:52 +0200 Subject: [PATCH] feat: add variable jumps and landing poses Give jump presses a short coyote window and input buffer, and tune tap and held rises to the documented stage scale. Track apex height in the debug display so the result can be measured during play. Use the authored landing poses at contact, with a four-frame hard landing for drops of two body heights. Keep sprite dimensions fixed. --- game/README.md | 6 +++-- game/src/player/body.ts | 3 ++- game/src/player/jump.test.ts | 52 ++++++++++++++++++++++++++++++++++++ game/src/player/jump.ts | 40 +++++++++++++++++++++++++++ game/src/player/player.ts | 17 ++++++++++-- 5 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 game/src/player/jump.test.ts create mode 100644 game/src/player/jump.ts 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'}`); } }