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
5 changes: 4 additions & 1 deletion game/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions game/src/player/body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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]);
Expand Down
30 changes: 23 additions & 7 deletions game/src/player/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, Phaser.Input.Keyboard.Key>;
this.keys = scene.input.keyboard!.addKeys('A,D,W,S,LEFT,RIGHT,UP,DOWN,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 @@ -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;
Expand All @@ -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;
Expand All @@ -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'}`);
}
}
70 changes: 70 additions & 0 deletions game/src/player/traversal.test.ts
Original file line number Diff line number Diff line change
@@ -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<CollisionData>('../../../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);
}
});
});
55 changes: 55 additions & 0 deletions game/src/player/traversal.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading