From c70ced9bf5d29682b93f5238b5c5e30f85dbda4a Mon Sep 17 00:00:00 2001 From: Vatsal2005 <83007841+Vatsal2005@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:19:46 +0200 Subject: [PATCH] feat: follow the hero with directional camera lead Ease toward a speed-based horizontal lead and the player feet vertically, while clamping to the complete chunk grid. Retain precise smoothing state but render the camera on whole pixels. Reuse grid bounds in the stage renderer and preserve the accepted export parallax values. Keep manual panning available in development inspection mode. --- game/README.md | 5 +++++ game/src/core/scaffold.ts | 11 +++++++++-- game/src/stage/camera.test.ts | 37 +++++++++++++++++++++++++++++++++++ game/src/stage/camera.ts | 32 ++++++++++++++++++++++++++++++ game/src/stage/render.ts | 8 +++----- 5 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 game/src/stage/camera.test.ts create mode 100644 game/src/stage/camera.ts 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; }