diff --git a/game/README.md b/game/README.md index 9b6bbc3..ca342ff 100644 --- a/game/README.md +++ b/game/README.md @@ -55,3 +55,8 @@ F2 shows the contact slope and slide state. Yellow one-way decks catch falling f 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. + +The camera leads movement by up to 120px and eases vertical changes, clamped to +the complete chunk grid. Rendered camera coordinates stay on whole pixels; layers +retain the export's scroll factors. Development `?inspect` keeps manual drag panning +instead of following the hero, for collision review. diff --git a/game/src/core/scaffold.ts b/game/src/core/scaffold.ts index 1292e06..40ac39e 100644 --- a/game/src/core/scaffold.ts +++ b/game/src/core/scaffold.ts @@ -5,6 +5,7 @@ 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'; +import { cameraFollow } from '../stage/camera'; // Bootstrap-only scenes live in lane B until lane A provides story/UI scenes. export class Boot extends Phaser.Scene { @@ -28,13 +29,16 @@ export class Preload extends Phaser.Scene { export class Stage extends Phaser.Scene { private player?: Player; + private follow?: ReturnType; 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.follow = import.meta.env.DEV && new URLSearchParams(location.search).has('inspect') + ? undefined : cameraFollow(this, data.manifest); this.scene.launch('UI'); - // Development inspection only; camera follow is implemented separately in #32. + // Inspection mode keeps manual panning for annotation review. if (import.meta.env.DEV && new URLSearchParams(location.search).has('inspect')) { this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => { if (pointer.isDown) { @@ -44,7 +48,10 @@ export class Stage extends Phaser.Scene { }); } } - update(_time: number, delta: number) { this.player?.update(delta); } + update(_time: number, delta: number) { + this.player?.update(delta); + if (this.player) this.follow?.(this.player.body, delta); + } } export class UI extends Phaser.Scene { diff --git a/game/src/stage/camera.test.ts b/game/src/stage/camera.test.ts new file mode 100644 index 0000000..f38717a --- /dev/null +++ b/game/src/stage/camera.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { follow, gridBounds } from './camera'; +import type { StageManifest } from './manifest'; +const bounds = { x: 0, y: 0, width: 6480, height: 1440 }; +describe('camera follow', () => { + it('leads in the move direction and smoothly follows vertical changes', () => { + const state = { x: 1000, y: 0, lead: 0 }; + const first = follow(state, { x: 2000, y: 1000, vx: 258 }, bounds, 1 / 60); + expect(first.x).toBeGreaterThan(1000); expect(first.y).toBeGreaterThan(0); expect(first.y).toBeLessThan(539); + for (let i = 0; i < 240; i++) follow(state, { x: 2000, y: 1000, vx: 258 }, bounds, 1 / 60); + expect(state.lead).toBeCloseTo(120); expect(state.x).toBeCloseTo(1480, 0); + for (let i = 0; i < 240; i++) follow(state, { x: 2000, y: 1000, vx: -258 }, bounds, 1 / 60); + expect(state.lead).toBeCloseTo(-120); expect(state.x).toBeCloseTo(1240, 0); + }); + it('clamps all edges and returns whole pixels, including negative chunks', () => { + const negative = { x: -2160, y: -720, width: 6480, height: 2160 }; + for (const value of [-10000, 10000]) { + const state = { x: value, y: value, lead: 0 }; + const result = follow(state, { x: value, y: value, vx: 258 }, negative, 0.1); + expect(Number.isInteger(result.x)).toBe(true); expect(Number.isInteger(result.y)).toBe(true); + expect(result.x).toBeGreaterThanOrEqual(-2160); expect(result.x).toBeLessThanOrEqual(3040); + expect(result.y).toBeGreaterThanOrEqual(-720); expect(result.y).toBeLessThanOrEqual(720); + } + }); + it('derives bounds from a four-direction chunk grid', () => { + const manifest = { chunkSize: [2160, 720], chunks: [{ x: -1, y: -1 }, { x: 1, y: 1 }] } as StageManifest; + expect(gridBounds(manifest)).toEqual({ x: -2160, y: -720, width: 6480, height: 2160 }); + }); + it('keeps static vertical follow independent of rendered frame rate', () => { + const run = (fps: number) => { + const state = { x: 0, y: 0, lead: 0 }; + for (let i = 0; i < fps; i++) follow(state, { x: 2000, y: 1000, vx: 0 }, bounds, 1 / fps); + return state; + }; + expect(run(30).x).toBeCloseTo(run(120).x, 7); expect(run(30).y).toBeCloseTo(run(120).y, 7); + }); +}); diff --git a/game/src/stage/camera.ts b/game/src/stage/camera.ts new file mode 100644 index 0000000..c25fa6c --- /dev/null +++ b/game/src/stage/camera.ts @@ -0,0 +1,32 @@ +import type Phaser from 'phaser'; +import type { StageManifest } from './manifest'; +export interface Bounds { x: number; y: number; width: number; height: number } +export interface Follow { x: number; y: number; lead: number } +export interface Target { x: number; y: number; vx: number } +export function gridBounds(manifest: StageManifest): Bounds { + const xs = manifest.chunks.map(c => c.x * manifest.chunkSize[0]); + const ys = manifest.chunks.map(c => c.y * manifest.chunkSize[1]); + return { x: Math.min(...xs), y: Math.min(...ys), + width: Math.max(...xs) - Math.min(...xs) + manifest.chunkSize[0], + height: Math.max(...ys) - Math.min(...ys) + manifest.chunkSize[1] }; +} +const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v)); +const ease = (from: number, to: number, rate: number, dt: number) => from + (to - from) * (1 - Math.exp(-rate * dt)); +/** Keep fractional smoothing state, but only expose integer camera coordinates. */ +export function follow(state: Follow, target: Target, bounds: Bounds, dt: number, width = 1280, height = 720) { + state.lead = ease(state.lead, clamp(target.vx / 258, -1, 1) * 120, 7, dt); + const maxX = bounds.x + Math.max(0, bounds.width - width); + const maxY = bounds.y + Math.max(0, bounds.height - height); + state.x = clamp(ease(state.x, clamp(target.x - width / 2 + state.lead, bounds.x, maxX), 8, dt), bounds.x, maxX); + state.y = clamp(ease(state.y, clamp(target.y - height * 0.64, bounds.y, maxY), 5, dt), bounds.y, maxY); + return { x: Math.round(state.x), y: Math.round(state.y) }; +} +export function cameraFollow(scene: Phaser.Scene, manifest: StageManifest) { + const camera = scene.cameras.main, bounds = gridBounds(manifest); + camera.roundPixels = true; + const state = { x: camera.scrollX, y: camera.scrollY, lead: 0 }; + return (target: Target, milliseconds: number) => { + const position = follow(state, target, bounds, Math.min(milliseconds / 1000, 0.1), camera.width, camera.height); + camera.setScroll(position.x, position.y); + }; +} diff --git a/game/src/stage/render.ts b/game/src/stage/render.ts index 7eafa08..1e8375f 100644 --- a/game/src/stage/render.ts +++ b/game/src/stage/render.ts @@ -1,15 +1,13 @@ import type Phaser from 'phaser'; import { stageLayers, type StageManifest, type RegionState } from './manifest'; import { textureKey } from './assets'; +import { gridBounds } from './camera'; /** z describes the art role; manifest list order describes alpha composition. */ export function drawStage(scene: Phaser.Scene, manifest: StageManifest, state: RegionState) { const images = stageLayers(manifest, state).map(layer => scene.add.image(layer.x, layer.y, textureKey(manifest.region, layer.file)).setOrigin(0).setScrollFactor(layer.scroll).setDepth(layer.order)); - const xs = manifest.chunks.map(c => c.x * manifest.chunkSize[0]); - const ys = manifest.chunks.map(c => c.y * manifest.chunkSize[1]); - scene.cameras.main.setBounds(Math.min(...xs), Math.min(...ys), - Math.max(...xs) - Math.min(...xs) + manifest.chunkSize[0], - Math.max(...ys) - Math.min(...ys) + manifest.chunkSize[1]); + const bounds = gridBounds(manifest); + scene.cameras.main.setBounds(bounds.x, bounds.y, bounds.width, bounds.height); return images; }