From d5584232ee506ae8934192a9cac59de98f5476e3 Mon Sep 17 00:00:00 2001 From: Vatsal2005 <83007841+Vatsal2005@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:58:40 +0200 Subject: [PATCH] feat: add one-way decks and ladder climbing Use yellow polylines as one-way supports and let Down plus Jump drop through. Align slow climbing with authored blue zones and their nearby deck heights, hold on release and expose the attack lock while climbing. Handle Down arriving before Jump at a ladder top and prevent immediate ladder grabs after detaching. Exercise every real Creek climb zone and the input-order regression in tests. --- game/README.md | 5 ++- game/src/player/body.ts | 4 +- game/src/player/player.ts | 30 +++++++++---- game/src/player/traversal.test.ts | 70 +++++++++++++++++++++++++++++++ game/src/player/traversal.ts | 55 ++++++++++++++++++++++++ 5 files changed, 154 insertions(+), 10 deletions(-) create mode 100644 game/src/player/traversal.test.ts create mode 100644 game/src/player/traversal.ts diff --git a/game/README.md b/game/README.md index 90e078e..9b6bbc3 100644 --- a/game/README.md +++ b/game/README.md @@ -51,4 +51,7 @@ Space/K or gamepad A/Cross jumps, with about 52px tap height and 96px held heigh 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. Authored walk polylines follow slopes through 40°; steeper surfaces accelerate downhill, with jumping still available. -F2 shows the contact slope and slide state. One-way platforms and ladders follow separately. +F2 shows the contact slope and slide state. Yellow one-way decks catch falling feet; +Down + Jump drops through them. W/S, up/down arrows or gamepad up/down enter and +move along blue climb zones at 70px/s. Release to hold, move sideways or jump to +leave. `Player.canAttack` is false while climbing. Reserved ledge points stay inactive. diff --git a/game/src/player/body.ts b/game/src/player/body.ts index 63c9977..ce479b9 100644 --- a/game/src/player/body.ts +++ b/game/src/player/body.ts @@ -9,7 +9,7 @@ export function overlaps(body: Body, box: Box) { } /** Feet origin and continuous polylines; one-way policy follows separately. */ -export function stepBody(body: Body, geometry: Geometry[], dt: number) { +export function stepBody(body: Body, geometry: Geometry[], dt: number, ignoreOneWay = false) { const oldX = body.x, oldY = body.y; const wasGrounded = body.grounded; const oldSlope = body.slope ?? 0; @@ -33,7 +33,7 @@ export function stepBody(body: Body, geometry: Geometry[], dt: number) { body.sliding = false; body.slope = 0; for (const world of geometry) { - for (const line of [...world.walk, ...world.slide]) for (let i = 1; i < line.length; i++) { + for (const line of [...world.walk, ...world.slide, ...(ignoreOneWay ? [] : world.oneWay)]) for (let i = 1; i < line.length; i++) { const [a, b] = [line[i - 1], line[i]]; if (body.x < a[0] || body.x > b[0]) continue; const slope = (b[1] - a[1]) / (b[0] - a[0]); diff --git a/game/src/player/player.ts b/game/src/player/player.ts index c6abb0a..eafe85a 100644 --- a/game/src/player/player.ts +++ b/game/src/player/player.ts @@ -3,6 +3,7 @@ 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'; +import { climb, dropThrough, traversal } from './traversal'; export class Player { readonly body: Body; @@ -15,12 +16,14 @@ export class Player { private lastSpeed = 0; private spawn; private air; + private traversal = traversal(); + get canAttack() { return this.traversal.ladder === null; } 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,SPACE,K') as Record; + this.keys = scene.input.keyboard!.addKeys('A,D,W,S,LEFT,RIGHT,UP,DOWN,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); @@ -33,15 +36,26 @@ export class Player { 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; + const vertical = horizontal(this.keys.W.isDown || this.keys.UP.isDown, this.keys.S.isDown || this.keys.DOWN.isDown, + pad?.leftStick.y, pad?.up, pad?.down); + const geometry = this.chunks.map(c => c.world); // 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); - if (!this.body.sliding) this.body.vx = this.motion.vx; - jump(this.body, this.air, jumpDown, dt); + const pressed = jumpDown && !this.air.wasDown; + const dropped = vertical > 0 && pressed && dropThrough(this.body, this.traversal, geometry); + const climbing = climb(this.body, this.traversal, geometry, vertical, axis, pressed, dt); const wasGrounded = this.body.grounded; - stepBody(this.body, this.chunks.map(c => c.world), dt); + if (dropped || climbing) { + this.air.wasDown = jumpDown; this.air.buffer = 0; this.air.coyote = 0; this.air.active = false; + this.motion = movement(); + } else { + move(this.motion, axis, dt); + if (!this.body.sliding) this.body.vx = this.motion.vx; + jump(this.body, this.air, jumpDown, dt); + } + if (!climbing) stepBody(this.body, geometry, dt, this.traversal.drop > 0); landing(this.body, this.air, wasGrounded, dt); this.motion.vx = this.body.vx; remaining -= dt; @@ -50,8 +64,10 @@ export class Player { Object.assign(this.body, { x: this.spawn.x, y: this.spawn.y, vx: 0, vy: 0, grounded: true, sliding: false, slope: 0 }); this.motion = movement(); this.air = jumpState(this.body.y); + this.traversal = traversal(); } - const pose = this.air.landing > 0 ? this.air.hard + const pose = this.traversal.ladder ? `ladder-${String(Math.floor(this.body.y / 13) % 4).padStart(2, '0')}` + : 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.sliding ? `fall-${String(Math.floor(this.scene.time.now * 12 / 1000) % 3).padStart(2, '0')}` : !this.body.grounded ? this.body.vy < 0 ? 'jump-02' : 'fall-00' : null; @@ -62,6 +78,6 @@ export class Player { 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)}\njump ${this.air.height.toFixed(1)}px | ${this.body.sliding ? 'slide' : 'walk'} slope ${(Math.atan(this.body.slope ?? 0) * 180 / Math.PI).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 | ${this.traversal.ladder ? 'climb' : this.body.sliding ? 'slide' : 'walk'} slope ${(Math.atan(this.body.slope ?? 0) * 180 / Math.PI).toFixed(1)}° | land ${this.air.hard ? 'hard' : 'normal'}`); } } diff --git a/game/src/player/traversal.test.ts b/game/src/player/traversal.test.ts new file mode 100644 index 0000000..1189eaa --- /dev/null +++ b/game/src/player/traversal.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { stepBody, type Body } from './body'; +import { climb, dropThrough, traversal } from './traversal'; +import type { CollisionData, Geometry } from '../stage/collision'; +const dt = 1 / 120; +const world: Geometry = { walk: [[[0, 500], [1000, 500]]], slide: [], oneWay: [[[100, 350], [300, 350]]], + climb: [{ x: 180, y: 353, w: 30, h: 147, kind: 'ladder' }], solid: [], + noGo: [], hazard: [], interact: [], spawn: [], ledge: [], glideLift: [] }; +const body = (y: number): Body => ({ x: 195, y, vx: 0, vy: 0, grounded: true }); +describe('vertical traversal', () => { + it('jumps upward through yellow lines and lands on top when falling', () => { + const b = { ...body(400), grounded: false, vy: -485 }; + let crossed = false; + for (let i = 0; i < 120; i++) { + stepBody(b, [world], dt); if (b.y < 350) crossed = true; + } + expect(crossed).toBe(true); expect(b.y).toBe(350); expect(b.grounded).toBe(true); + }); + it('drops through a yellow support without disabling the green floor', () => { + const b = body(350), state = traversal(); + expect(dropThrough(b, state, [world])).toBe(true); + for (let i = 0; i < 120; i++) { + climb(b, state, [world], 0, 0, false, dt); + stepBody(b, [world], dt, state.drop > 0); + } + expect(b.y).toBe(500); expect(dropThrough(b, state, [world])).toBe(false); + }); + it('handles Down slightly before Jump at a ladder top without grabbing it again', () => { + const b = body(350), state = traversal(); + climb(b, state, [world], 1, 0, false, dt); + expect(state.ladder).not.toBeNull(); + expect(dropThrough(b, state, [world])).toBe(true); + expect(climb(b, state, [world], 1, 0, true, dt)).toBe(false); + expect(state.ladder).toBeNull(); + }); + it('climbs slowly, holds position on release, and exits at the deck height', () => { + const b = body(500), state = traversal(); + expect(climb(b, state, [world], -1, 0, false, dt)).toBe(true); + expect(b.y).toBeCloseTo(500 - 70 * dt); expect(state.ladder).not.toBeNull(); + const held = b.y; + for (let i = 0; i < 30; i++) climb(b, state, [world], 0, 0, false, dt); + expect(b.y).toBe(held); + for (let i = 0; i < 300; i++) climb(b, state, [world], -1, 0, false, dt); + expect(b.y).toBe(350); expect(b.grounded).toBe(true); expect(state.ladder).toBeNull(); + for (let i = 0; i < 300; i++) climb(b, state, [world], 1, 0, false, dt); + expect(b.y).toBe(500); expect(state.ladder).toBeNull(); + }); + it('releases a ladder on horizontal input or jump and ignores reserved ledges', () => { + for (const jumping of [false, true]) { + const b = body(400), state = traversal(); climb(b, state, [world], -1, 0, false, dt); + expect(climb(b, state, [world], 0, jumping ? 0 : 1, jumping, dt)).toBe(false); + expect(state.ladder).toBeNull(); expect(b.grounded).toBe(jumping); + expect(climb(b, state, [world], -1, 0, false, dt)).toBe(false); + } + const b = body(400), state = traversal(); + climb(b, state, [{ ...world, climb: [], ledge: [{ x: 195, y: 400 }] }], -1, 0, false, dt); + expect(state.ladder).toBeNull(); + }); + it('traverses every authored Creek climb zone in both directions', () => { + const files = import.meta.glob('../../../art/stages/rusty-creek/*.collision.json', { eager: true, import: 'default' }); + for (const source of Object.values(files)) for (const box of source.climb) { + const b = { ...body(box.y + box.h), x: box.x + box.w / 2 }, state = traversal(); + const geometry = { ...source, climb: [box] }; + for (let i = 0; i < 1500; i++) climb(b, state, [geometry], -1, 0, false, dt); + expect(b.y).toBeGreaterThanOrEqual(box.y - 8); expect(b.y).toBeLessThanOrEqual(box.y + 8); + for (let i = 0; i < 1500; i++) climb(b, state, [geometry], 1, 0, false, dt); + expect(b.y).toBeGreaterThanOrEqual(box.y + box.h - 8); expect(b.y).toBeLessThanOrEqual(box.y + box.h + 8); + } + }); +}); diff --git a/game/src/player/traversal.ts b/game/src/player/traversal.ts new file mode 100644 index 0000000..0ffde80 --- /dev/null +++ b/game/src/player/traversal.ts @@ -0,0 +1,55 @@ +import type { Box, Geometry } from '../stage/collision'; +import type { Body } from './body'; +export const CLIMB_SPEED = 70; +export interface Traversal { ladder: Box | null; drop: number; detach: number } +export const traversal = (): Traversal => ({ ladder: null, drop: 0, detach: 0 }); + +function surfaceNear(geometry: Geometry[], x: number, y: number) { + const candidates: number[] = []; + for (const world of geometry) for (const line of [...world.walk, ...world.oneWay]) { + for (let i = 1; i < line.length; i++) { + const [a, b] = [line[i - 1], line[i]]; + if (x < a[0] || x > b[0]) continue; + const height = a[1] + (x - a[0]) * (b[1] - a[1]) / (b[0] - a[0]); + if (Math.abs(height - y) <= 8) candidates.push(height); + } + } + return candidates.sort((a, b) => Math.abs(a - y) - Math.abs(b - y))[0] ?? y; +} + +export function dropThrough(body: Body, state: Traversal, geometry: Geometry[]) { + if (!body.grounded && !state.ladder) return false; + const support = geometry.some(g => g.oneWay.some(line => line.slice(1).some((b, i) => { + const a = line[i]; + return body.x >= a[0] && body.x <= b[0] + && Math.abs(body.y - (a[1] + (body.x - a[0]) * (b[1] - a[1]) / (b[0] - a[0]))) < (state.ladder ? 8 : 0.1); + }))); + if (!support) return false; + state.ladder = null; state.drop = 0.22; body.y += 1; body.vy = 30; body.grounded = false; + return true; +} + +/** Returns true while the ladder owns movement, including a stationary hold. */ +export function climb(body: Body, state: Traversal, geometry: Geometry[], vertical: number, horizontal: number, + jumpPressed: boolean, dt: number) { + state.drop = Math.max(0, state.drop - dt); + state.detach = Math.max(0, state.detach - dt); + if (state.ladder && (jumpPressed || Math.abs(horizontal) > 0.2)) { + state.ladder = null; state.detach = 0.2; body.grounded = jumpPressed; return false; + } + if (!state.ladder && vertical && Math.abs(horizontal) <= 0.2 && !jumpPressed && state.drop === 0 && state.detach === 0) { + state.ladder = geometry.flatMap(g => g.climb).find(b => body.x + 14 > b.x && body.x - 14 < b.x + b.w + && body.y >= b.y - 4 && body.y <= b.y + b.h + 8) ?? null; + } + if (!state.ladder) return false; + const ladder = state.ladder; + const x = ladder.x + ladder.w / 2; + const top = surfaceNear(geometry, x, ladder.y); + const bottom = surfaceNear(geometry, x, ladder.y + ladder.h); + body.x = x; body.vx = 0; body.vy = 0; body.sliding = false; body.slope = 0; body.grounded = false; + body.y = Math.max(top, Math.min(bottom, body.y + vertical * CLIMB_SPEED * dt)); + if (vertical < 0 && body.y <= top || vertical > 0 && body.y >= bottom) { + state.ladder = null; body.grounded = true; + } + return true; +}