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
8 changes: 7 additions & 1 deletion game/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ JSON-hash atlases with `idle-00` frames and feet pivots `(0.5, 1)`, plus matchin
`.anims.json` files. Hero: 28×76 body in an 80 px frame; Dexter: 20 px;
rover: 25 px; crab: 40 px; drone: 45 px; Jenny: 200×70.
They are development stand-ins for the [production contracts](../docs/13-PRODUCTION-PIPELINE.md),
and are never copied to `art/`. No gameplay, HUD, or collision is included yet.
and are never copied to `art/`.

Stage exports from lane A belong at `public/assets/stages/<region>/stage.json`.
The loader preserves their list order and scroll factors, caches shared plates,
Expand All @@ -42,3 +42,9 @@ the same world polylines and boxes that the controller consumes. Collision files
are fetched next to the exported manifest, validated against the chunk and state,
then shifted from chunk-local pixels by the manifest's grid coordinates. Original
metadata is retained; reserved ledges and glide lifts do not become solid surfaces.

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.
4 changes: 4 additions & 0 deletions game/src/core/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { drawStage } from '../stage/render';
import type { StageManifest, RegionState } from '../stage/manifest';
import { loadCollisions, type CollisionChunk } from '../stage/collision';
import { collisionDebug } from '../stage/collision-debug';
import { Player } from '../player/player';

// Bootstrap-only scenes live in lane B until lane A provides story/UI scenes.
export class Boot extends Phaser.Scene {
Expand All @@ -26,10 +27,12 @@ export class Preload extends Phaser.Scene {
}

export class Stage extends Phaser.Scene {
private player?: Player;
constructor() { super('Stage'); }
create(data: { manifest: StageManifest; state: RegionState; collisions: CollisionChunk[] }) {
drawStage(this, data.manifest, data.state);
collisionDebug(this, data.collisions);
this.player = new Player(this, data.collisions);
this.scene.launch('UI');
// Development inspection only; camera follow is implemented separately in #32.
if (import.meta.env.DEV && new URLSearchParams(location.search).has('inspect')) {
Expand All @@ -41,6 +44,7 @@ export class Stage extends Phaser.Scene {
});
}
}
update(_time: number, delta: number) { this.player?.update(delta); }
}

export class UI extends Phaser.Scene {
Expand Down
43 changes: 43 additions & 0 deletions game/src/player/body.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Box, Geometry } from '../stage/collision';
export const BODY_WIDTH = 28;
export const BODY_HEIGHT = 76;
export interface Body { x: number; y: number; vx: number; vy: number; grounded: boolean }
export function overlaps(body: Body, box: Box) {
return body.x + 14 > box.x && body.x - 14 < box.x + box.w
&& body.y > box.y && body.y - BODY_HEIGHT < box.y + box.h;
}

/** Feet origin, swept wall/floor checks. Slope and one-way policies follow separately. */
export function stepBody(body: Body, geometry: Geometry[], dt: number) {
const oldX = body.x, oldY = body.y;
body.x += body.vx * dt;
for (const world of geometry) for (const wall of world.solid) {
if (oldY <= wall.y || oldY - BODY_HEIGHT >= wall.y + wall.h) continue;
if (body.vx > 0 && oldX + 14 <= wall.x && body.x + 14 > wall.x) {
body.x = wall.x - 14; body.vx = 0;
} else if (body.vx < 0 && oldX - 14 >= wall.x + wall.w && body.x - 14 < wall.x + wall.w) {
body.x = wall.x + wall.w + 14; body.vx = 0;
}
}
body.vy = Math.min(900, body.vy + 1200 * dt);
let nextY = body.y + body.vy * dt;
body.grounded = false;
for (const world of geometry) {
for (const line of world.walk) for (let i = 1; i < line.length; i++) {
const [a, b] = [line[i - 1], line[i]];
if (a[1] !== b[1] || body.x < a[0] || body.x > b[0]) continue;
if (body.vy >= 0 && oldY <= a[1] + 0.01 && nextY >= a[1]) {
nextY = a[1]; body.vy = 0; body.grounded = true;
}
}
for (const wall of world.solid) {
if (body.x + 14 <= wall.x || body.x - 14 >= wall.x + wall.w) continue;
if (body.vy >= 0 && oldY <= wall.y && nextY >= wall.y) {
nextY = wall.y; body.vy = 0; body.grounded = true;
} else if (body.vy < 0 && oldY - BODY_HEIGHT >= wall.y + wall.h && nextY - BODY_HEIGHT < wall.y + wall.h) {
nextY = wall.y + wall.h + BODY_HEIGHT; body.vy = 0;
}
}
}
body.y = nextY;
}
56 changes: 56 additions & 0 deletions game/src/player/movement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { horizontal, move, movement, RUN_SPEED, WALK_SPEED } from './movement';
import { stepBody, type Body } from './body';
import type { Geometry } from '../stage/collision';

describe('walk/run controls', () => {
it('reaches walking speed before 0.4s and then running speed', () => {
const state = movement();
for (let i = 0; i < 20; i++) move(state, 1, 1 / 60);
expect(state.vx).toBe(WALK_SPEED);
for (let i = 0; i < 10; i++) move(state, 1, 1 / 60);
expect(state.vx).toBe(RUN_SPEED);
});
it('decelerates to rest and resets run timing on release or reversal', () => {
const state = movement();
for (let i = 0; i < 60; i++) move(state, 1, 1 / 60);
move(state, 0, 1 / 60);
expect(state.vx).toBeGreaterThan(0); expect(state.vx).toBeLessThan(RUN_SPEED);
for (let i = 0; i < 10; i++) move(state, 0, 1 / 60);
expect(state.vx).toBe(0); expect(state.held).toBe(0);
move(state, -1, 1 / 60);
expect(state.facing).toBe(-1); expect(state.held).toBeCloseTo(1 / 60);
move(state, 1, 1 / 60); expect(state.held).toBeCloseTo(1 / 60);
});
it('handles keyboard opposites, stick dead zone and D-pad priority', () => {
expect(horizontal(true, false)).toBe(-1); expect(horizontal(false, true)).toBe(1);
expect(horizontal(true, true, 1)).toBe(0); expect(horizontal(false, false, 0.19)).toBe(0);
expect(horizontal(false, false, -1)).toBe(-1); expect(horizontal(false, false, 0.6)).toBeCloseTo(0.5);
expect(horizontal(false, false, 1, true)).toBe(-1);
});
it('is consistent across rendered frame rates', () => {
const run = (fps: number) => {
const state = movement(); let x = 0;
for (let i = 0; i < fps; i++) { move(state, 1, 1 / fps); x += state.vx / fps; }
return x;
};
expect(Math.abs(run(60) - run(120))).toBeLessThan(3);
});
});

describe('body foundation', () => {
const world: Geometry = { walk: [[[0, 500], [1000, 500]]], slide: [], oneWay: [], solid: [],
climb: [], noGo: [], hazard: [], interact: [], spawn: [], ledge: [], glideLift: [] };
it('keeps feet on the authored floor and catches a fall', () => {
const body: Body = { x: 100, y: 450, vx: 150, vy: 0, grounded: false };
for (let i = 0; i < 60; i++) stepBody(body, [world], 1 / 120);
expect(body.y).toBe(500); expect(body.grounded).toBe(true); expect(body.x).toBeCloseTo(175);
});
it('stops the 28px body at a swept wall and lands on box tops', () => {
const geometry = { ...world, solid: [{ x: 200, y: 300, w: 20, h: 200 }] };
const body: Body = { x: 180, y: 500, vx: 258, vy: 0, grounded: true };
stepBody(body, [geometry], 0.1); expect(body.x).toBe(186); expect(body.vx).toBe(0);
Object.assign(body, { x: 210, y: 290, vx: 0, vy: 100 });
stepBody(body, [geometry], 0.1); expect(body.y).toBe(300); expect(body.grounded).toBe(true);
});
});
25 changes: 25 additions & 0 deletions game/src/player/movement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export const WALK_SPEED = 3.5 * 43;
export const RUN_SPEED = 6 * 43;
export const RUN_DELAY = 0.4;
export interface Movement { vx: number; held: number; direction: number; facing: -1 | 1 }
export const movement = (): Movement => ({ vx: 0, held: 0, direction: 0, facing: 1 });
export const approach = (value: number, target: number, amount: number) =>
value + Math.sign(target - value) * Math.min(Math.abs(target - value), amount);

/** Dead zone prevents stick drift from starting the run timer. D-pad is digital. */
export function horizontal(left: boolean, right: boolean, stick = 0, padLeft = false, padRight = false) {
const keys = Number(right) - Number(left);
const pad = Number(padRight) - Number(padLeft);
if (left || right) return keys;
if (padLeft || padRight) return pad;
return Math.abs(stick) <= 0.2 ? 0 : Math.sign(stick) * Math.min(1, (Math.abs(stick) - 0.2) / 0.8);
}

export function move(state: Movement, axis: number, dt: number) {
const direction = Math.sign(axis);
state.held = direction === 0 ? 0 : direction === state.direction ? state.held + dt : dt;
state.direction = direction;
if (direction) state.facing = direction as -1 | 1;
const speed = state.held + 1e-9 >= RUN_DELAY ? RUN_SPEED : WALK_SPEED;
state.vx = approach(state.vx, axis * speed, (direction ? 1500 : 2000) * dt);
}
52 changes: 52 additions & 0 deletions game/src/player/player.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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';

export class Player {
readonly body: Body;
readonly sprite: Phaser.GameObjects.Sprite;
private motion = movement();
private keys;
private debug: Phaser.GameObjects.Graphics;
private label: Phaser.GameObjects.Text;
private showDebug = false;
private lastSpeed = 0;
private spawn;
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.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<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);
const toggle = () => { this.showDebug = !this.showDebug; this.label.setVisible(this.showDebug); };
scene.input.keyboard?.on('keydown-F2', toggle);
scene.events.once('shutdown', () => scene.input.keyboard?.off('keydown-F2', toggle));
}
update(milliseconds: number) {
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);
// 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;
stepBody(this.body, this.chunks.map(c => c.world), 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.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)}`);
}
}
Loading