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 827190c..3a30c7d 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; +}