diff --git a/game/README.md b/game/README.md index dc1cd0c..bb9ef12 100644 --- a/game/README.md +++ b/game/README.md @@ -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//stage.json`. The loader preserves their list order and scroll factors, caches shared plates, @@ -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. diff --git a/game/src/core/scaffold.ts b/game/src/core/scaffold.ts index 80f26ca..1292e06 100644 --- a/game/src/core/scaffold.ts +++ b/game/src/core/scaffold.ts @@ -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 { @@ -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')) { @@ -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 { diff --git a/game/src/player/body.ts b/game/src/player/body.ts new file mode 100644 index 0000000..4b03a29 --- /dev/null +++ b/game/src/player/body.ts @@ -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; +} diff --git a/game/src/player/movement.test.ts b/game/src/player/movement.test.ts new file mode 100644 index 0000000..0cd6459 --- /dev/null +++ b/game/src/player/movement.test.ts @@ -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); + }); +}); diff --git a/game/src/player/movement.ts b/game/src/player/movement.ts new file mode 100644 index 0000000..1796607 --- /dev/null +++ b/game/src/player/movement.ts @@ -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); +} diff --git a/game/src/player/player.ts b/game/src/player/player.ts new file mode 100644 index 0000000..3f560f6 --- /dev/null +++ b/game/src/player/player.ts @@ -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; + 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)}`); + } +}