diff --git a/game/README.md b/game/README.md index 20bd3e7..72d9af1 100644 --- a/game/README.md +++ b/game/README.md @@ -27,3 +27,12 @@ JSON-hash atlases with `idle-00` frames and feet pivots `(0.5, 1)`, plus matchin 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. + +Stage exports from lane A belong at `public/assets/stages//stage.json`. +The loader preserves their list order and scroll factors, caches shared plates, +and loads the named sprite boards from `public/assets/sprites/`. Animation tables +are cached as `.anims`. Development rejects missing assets with their URL; +production reports the error and substitutes a bundled actor or plain stage plate. +Use `?region=rusty-creek&state=anomaly&inspect` on the dev URL to drag-pan the stage. +`state=clean` selects the calm version. Export the assets before starting dev; +the loader does not generate or copy art. diff --git a/game/src/core/scaffold.ts b/game/src/core/scaffold.ts index 45e1996..3b64b7b 100644 --- a/game/src/core/scaffold.ts +++ b/game/src/core/scaffold.ts @@ -1,6 +1,7 @@ import Phaser from 'phaser'; - -export const PLACEHOLDERS = ['hero', 'dexter', 'rover', 'crab', 'drone', 'jenny'] as const; +import { Assets, BOARDS } from '../stage/assets'; +import { drawStage } from '../stage/render'; +import type { StageManifest, RegionState } from '../stage/manifest'; // Bootstrap-only scenes live in lane B until lane A provides story/UI scenes. export class Boot extends Phaser.Scene { @@ -10,19 +11,32 @@ export class Boot extends Phaser.Scene { export class Preload extends Phaser.Scene { constructor() { super('Preload'); } - preload() { - for (const board of PLACEHOLDERS) { - const path = `/assets/placeholder/${board}`; - this.load.atlas(board, `${path}.png`, `${path}.json`); - this.load.json(`${board}.anims`, `${path}.anims.json`); - } + async create() { + const query = new URLSearchParams(location.search); + const region = query.get('region') ?? 'rusty-creek'; + const state: RegionState = query.get('state') === 'clean' ? 'clean' : 'anomaly'; + const assets = new Assets(this); + const manifest = await assets.stage(region, state); + for (const board of BOARDS) await assets.board(board); + this.scene.start('Stage', { manifest, state }); } - create() { this.scene.start('Stage'); } } export class Stage extends Phaser.Scene { constructor() { super('Stage'); } - create() { this.scene.launch('UI'); } + create(data: { manifest: StageManifest; state: RegionState }) { + drawStage(this, data.manifest, data.state); + 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')) { + this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => { + if (pointer.isDown) { + this.cameras.main.scrollX -= pointer.x - pointer.prevPosition.x; + this.cameras.main.scrollY -= pointer.y - pointer.prevPosition.y; + } + }); + } + } } export class UI extends Phaser.Scene { diff --git a/game/src/stage/assets.test.ts b/game/src/stage/assets.test.ts new file mode 100644 index 0000000..fc8c7ae --- /dev/null +++ b/game/src/stage/assets.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest'; +import type Phaser from 'phaser'; +import { Assets } from './assets'; +import { parseManifest, stageLayers } from './manifest'; + +const manifest = () => ({ region: 'rusty-creek', chunkSize: [2160, 720], + chunks: [0, 1, 2].map(x => ({ id: `rc-${x}-0`, x, y: 0, states: { anomaly: { + layers: [1, 2, 3, 5, 6, 7, 4].map((z, n) => ({ id: `layer-${n}`, z, + scroll: z === 1 ? 0.2 : z === 7 ? 1.15 : 1, file: z === 1 ? 'shared.png' : `${x}-${z}.png` })), + collision: `rc-${x}-0.anomaly.collision.json` } } })) }); +const clips = { idle: { frames: 1, fps: 12, loop: true } }; +const atlas = { frames: { 'idle-00': { frame: { x: 0, y: 0, w: 28, h: 80 }, pivot: { x: 0.5, y: 1 } } } }; + +function harness(missing = '') { + const textures = new Set(), queued: string[] = []; + const complete: (() => void)[] = []; + const image = vi.fn((key: string) => queued.push(key)); + const loadAtlas = vi.fn((key: string) => queued.push(key)); + const scene = { load: { image, atlas: loadAtlas, on: vi.fn(), off: vi.fn(), + once: (_: string, fn: () => void) => complete.push(fn), + start: () => { queued.splice(0).forEach(key => textures.add(key)); complete.shift()?.(); } }, + textures: { exists: (key: string) => textures.has(key), remove: (key: string) => textures.delete(key) }, + cache: { json: { add: vi.fn(), get: () => clips } } }; + const request = vi.fn(async (url: string | URL | Request) => ({ ok: !String(url).includes(missing || '\0'), status: 404, + json: async () => String(url).endsWith('stage.json') ? manifest() : String(url).endsWith('anims.json') ? clips : atlas })); + return { scene: scene as unknown as Phaser.Scene, request: request as unknown as typeof fetch, image, loadAtlas }; +} + +describe('stage manifest', () => { + it('keeps all 21 layers in blend order, offsets chunks and preserves scroll', () => { + const layers = stageLayers(parseManifest(manifest()), 'anomaly'); + expect(layers).toHaveLength(21); + expect(layers.slice(0, 7).map(l => l.z)).toEqual([1, 2, 3, 5, 6, 7, 4]); + expect(layers[7].x).toBe(2160); expect(layers[14].x).toBe(4320); + expect(layers[0].scroll).toBe(0.2); expect(layers[5].scroll).toBe(1.15); + }); + it('handles negative grid rows and rejects missing states', () => { + const input = manifest(); input.chunks[0].y = -1; + const stage = parseManifest(input); + expect(stageLayers(stage, 'anomaly')[0].y).toBe(-720); + expect(() => stageLayers(stage, 'clean')).toThrow('Missing'); + }); + it('rejects duplicate cells and unsafe file paths', () => { + const input = manifest(); input.chunks[1].x = 0; + expect(() => parseManifest(input)).toThrow('duplicate grid'); + input.chunks[1].x = 1; input.chunks[0].states.anomaly.layers[0].file = '../private.png'; + expect(() => parseManifest(input)).toThrow('layer id/file'); + }); +}); + +describe('asset loading', () => { + it('does not bind the native browser fetch receiver to the asset loader', async () => { + const h = harness(); + vi.stubGlobal('fetch', function(this: unknown, ...args: Parameters) { + expect(this).not.toBeInstanceOf(Assets); + return h.request(...args); + }); + try { await new Assets(h.scene, true).board('hero-move'); } + finally { vi.unstubAllGlobals(); } + }); + it('loads shared stage plates once and reuses already loaded textures', async () => { + const h = harness(), assets = new Assets(h.scene, true, h.request); + await assets.stage('rusty-creek', 'anomaly'); + expect(h.image).toHaveBeenCalledTimes(19); + await assets.stage('rusty-creek', 'anomaly'); + expect(h.image).toHaveBeenCalledTimes(19); + }); + it('loads any named production board using the exporter contract', async () => { + const h = harness(); + expect(await new Assets(h.scene, true, h.request).board('hero-move')).toEqual(clips); + expect(h.loadAtlas).toHaveBeenCalledWith('hero-move', '/assets/sprites/hero-move.png', atlas); + expect(h.scene.cache.json.add).toHaveBeenCalledWith('hero-move.anims', clips); + }); + it('rejects missing assets loudly in development', async () => { + const h = harness('/sprites/'); + await expect(new Assets(h.scene, true, h.request).board('hero-move')).rejects.toThrow('Missing asset'); + expect(h.loadAtlas).not.toHaveBeenCalled(); + }); + it('rejects a missing stage manifest in development', async () => { + const h = harness('stage.json'); + await expect(new Assets(h.scene, true, h.request).stage('rusty-creek', 'anomaly')) + .rejects.toThrow('/assets/stages/rusty-creek/stage.json'); + }); + it('provides a stage plate when the production manifest is missing', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const h = harness('stage.json'); + const stage = await new Assets(h.scene, false, h.request).stage('rusty-creek', 'anomaly'); + expect(stageLayers(stage, 'anomaly')[0].file).toBe('missing.png'); + expect(h.image).toHaveBeenCalledWith('rusty-creek:missing.png', '/assets/stages/rusty-creek/missing.png'); + } finally { log.mockRestore(); } + }); + it('uses a bundled placeholder only after a production board fails', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const h = harness('/sprites/'); + await new Assets(h.scene, false, h.request).board('enemy-rover'); + expect(h.loadAtlas).toHaveBeenCalledWith('enemy-rover', '/assets/placeholder/rover.png', atlas); + expect(log).toHaveBeenCalled(); + } finally { log.mockRestore(); } + }); +}); diff --git a/game/src/stage/assets.ts b/game/src/stage/assets.ts new file mode 100644 index 0000000..c1ef035 --- /dev/null +++ b/game/src/stage/assets.ts @@ -0,0 +1,94 @@ +import type Phaser from 'phaser'; +import { parseManifest, stageLayers, type StageManifest, type RegionState } from './manifest'; + +export const BOARDS = ['hero-move', 'hero-run', 'hero-idle', 'hero-air', 'hero-hurt', 'hero-death', + 'chair-side', 'chair-overhead', 'chair-block', 'enemy-rover', 'enemy-crab', 'enemy-drone', 'fx'] as const; +export interface AnimationClip { frames: number; fps: number; loop: boolean } +export type AnimationTable = Record; +export const stageBase = (region: string) => `/assets/stages/${region}/`; +export const textureKey = (region: string, file: string) => `${region}:${file}`; + +export class Assets { + constructor(private scene: Phaser.Scene, private strict = import.meta.env.DEV, + private request: typeof fetch = (...args) => fetch(...args)) {} + + private async json(url: string): Promise { + const response = await this.request(url); + if (!response.ok) throw new Error(`Missing asset ${url}: HTTP ${response.status}`); + return response.json(); + } + + private queue(add: () => void): Promise { + return new Promise((resolve, reject) => { + const errors: string[] = []; + const failed = (file: Phaser.Loader.File) => errors.push(file.key); + this.scene.load.on('loaderror', failed); + this.scene.load.once('complete', () => { + this.scene.load.off('loaderror', failed); + errors.length ? reject(new Error(`Failed assets: ${errors.join(', ')}`)) : resolve(); + }); + add(); + this.scene.load.start(); + }); + } + + async stage(region: string, state: RegionState): Promise { + if (!/^[a-z][a-z0-9-]*$/.test(region)) throw new Error('Invalid region id'); + let manifest: StageManifest; + try { + manifest = parseManifest(await this.json(`${stageBase(region)}stage.json`)); + if (manifest.region !== region) throw new Error(`Region mismatch: ${region}`); + stageLayers(manifest, state); + } catch (error) { + if (this.strict) throw error; + console.error(error); + manifest = { region, chunkSize: [2160, 720], chunks: [{ id: 'fallback', x: 0, y: 0, + states: { [state]: { layers: [{ id: 'missing-stage', z: 5, scroll: 1, file: 'missing.png' }], collision: null } } }] }; + } + const files = [...new Set(stageLayers(manifest, state).map(layer => layer.file))]; + const missing = files.filter(file => !this.scene.textures.exists(textureKey(region, file))); + if (missing.length) { + try { + await this.queue(() => missing.forEach(file => this.scene.load.image(textureKey(region, file), stageBase(region) + file))); + } catch (error) { + if (this.strict) throw error; + console.error(error); + for (const file of missing) if (!this.scene.textures.exists(textureKey(region, file))) { + const texture = this.scene.textures.createCanvas(textureKey(region, file), ...manifest.chunkSize); + if (texture) { texture.context.fillStyle = '#76516f'; texture.context.fillRect(0, 0, ...manifest.chunkSize); texture.refresh(); } + } + } + } + return manifest; + } + + private async atlas(board: string, base: string): Promise { + const atlas = await this.json(`${base}.json`); + const clips = await this.json(`${base}.anims.json`) as AnimationTable; + const frames = (atlas as { frames?: Record }).frames; + if (!frames || !clips || !Object.keys(clips).length) throw new Error(`Invalid atlas ${board}`); + for (const [name, clip] of Object.entries(clips)) { + if (!Number.isInteger(clip.frames) || clip.frames < 1 || !Number.isFinite(clip.fps) + || clip.fps <= 0 || typeof clip.loop !== 'boolean') throw new Error(`Invalid clip ${board}:${name}`); + for (let n = 0; n < clip.frames; n++) if (!frames[`${name}-${String(n).padStart(2, '0')}`]) { + throw new Error(`Missing frame ${board}:${name}-${n}`); + } + } + await this.queue(() => this.scene.load.atlas(board, `${base}.png`, atlas as object)); + this.scene.cache.json.add(`${board}.anims`, clips); + return clips; + } + + async board(board: string): Promise { + if (!/^[a-z][a-z0-9-]*$/.test(board)) throw new Error('Invalid board id'); + if (this.scene.textures.exists(board)) return this.scene.cache.json.get(`${board}.anims`); + try { return await this.atlas(board, `/assets/sprites/${board}`); } + catch (error) { + if (this.strict) throw error; + console.error(error); + const fallback = board.startsWith('enemy-') ? board.slice(6) : board === 'fx' ? 'dexter' : 'hero'; + if (this.scene.textures.exists(board)) this.scene.textures.remove(board); + return this.atlas(board, `/assets/placeholder/${fallback}`); + } + } +} diff --git a/game/src/stage/manifest.ts b/game/src/stage/manifest.ts new file mode 100644 index 0000000..266eb8e --- /dev/null +++ b/game/src/stage/manifest.ts @@ -0,0 +1,52 @@ +export type RegionState = 'clean' | 'anomaly'; +export interface StageLayer { id: string; z: number; scroll: number; file: string } +export interface ChunkState { layers: StageLayer[]; collision: string | null } +export interface StageChunk { id: string; x: number; y: number; states: Partial> } +export interface StageManifest { region: string; chunkSize: [number, number]; chunks: StageChunk[] } + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(`Invalid stage manifest: ${message}`); +} +const object = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); +export const assetFile = (value: unknown): value is string => typeof value === 'string' + && /^[a-zA-Z0-9_.\/-]+$/.test(value) && !value.startsWith('/') + && value.split('/').every(part => part !== '..' && part !== '.' && part !== ''); + +/** Validate the stage export without reordering its authored blend sequence. */ +export function parseManifest(value: unknown): StageManifest { + assert(object(value), 'expected an object'); + assert(typeof value.region === 'string' && /^[a-z][a-z0-9-]*$/.test(value.region), 'region'); + assert(Array.isArray(value.chunkSize) && value.chunkSize.length === 2 + && value.chunkSize[0] === 2160 && value.chunkSize[1] === 720, 'chunkSize must be 2160×720'); + assert(Array.isArray(value.chunks) && value.chunks.length > 0, 'chunks'); + const ids = new Set(), grid = new Set(); + for (const chunk of value.chunks) { + assert(object(chunk) && typeof chunk.id === 'string' && chunk.id.length > 0, 'chunk id'); + assert(!ids.has(chunk.id), `duplicate chunk ${chunk.id}`); ids.add(chunk.id); + assert(Number.isInteger(chunk.x) && Number.isInteger(chunk.y), 'integer grid coordinates'); + const cell = `${chunk.x},${chunk.y}`; + assert(!grid.has(cell), `duplicate grid cell ${cell}`); grid.add(cell); + assert(object(chunk.states) && Object.keys(chunk.states).length > 0, 'chunk states'); + for (const [name, state] of Object.entries(chunk.states)) { + assert(name === 'clean' || name === 'anomaly', `unknown state ${name}`); + assert(object(state) && Array.isArray(state.layers) && state.layers.length > 0, `${chunk.id}.${name} layers`); + assert(state.collision === null || assetFile(state.collision), 'collision file'); + for (const layer of state.layers) { + assert(object(layer) && typeof layer.id === 'string' && assetFile(layer.file), 'layer id/file'); + assert(Number.isInteger(layer.z) && Number(layer.z) >= 0 && Number(layer.z) <= 7, 'layer z'); + assert(typeof layer.scroll === 'number' && Number.isFinite(layer.scroll) && layer.scroll >= 0, 'scroll'); + } + } + } + return value as unknown as StageManifest; +} + +export function stageLayers(manifest: StageManifest, state: RegionState) { + return manifest.chunks.flatMap(chunk => { + const content = chunk.states[state]; + if (!content) throw new Error(`Missing ${manifest.region}/${chunk.id}.${state}`); + return content.layers.map((layer, order) => ({ ...layer, order, + x: chunk.x * manifest.chunkSize[0], y: chunk.y * manifest.chunkSize[1] })); + }); +} diff --git a/game/src/stage/render.ts b/game/src/stage/render.ts new file mode 100644 index 0000000..7eafa08 --- /dev/null +++ b/game/src/stage/render.ts @@ -0,0 +1,15 @@ +import type Phaser from 'phaser'; +import { stageLayers, type StageManifest, type RegionState } from './manifest'; +import { textureKey } from './assets'; + +/** 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]); + return images; +}