From 0693744f5faf7670e1a2709a06ad192e6b0a84be Mon Sep 17 00:00:00 2001 From: Vatsal2005 <83007841+Vatsal2005@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:01:53 +0200 Subject: [PATCH] feat: load exported stages and sprite atlases Read the stage manifest in its authored blend order, position the chunk grid and reuse shared textures with the exported scroll factors. Load the confirmed production board ids and retain their animation tables. Reject missing or malformed assets in development and provide bundled actor or plain stage placeholders in production. Add pure validation and loading tests plus a development camera inspector. --- game/README.md | 9 +++ game/src/core/scaffold.ts | 34 ++++++++---- game/src/stage/assets.test.ts | 102 ++++++++++++++++++++++++++++++++++ game/src/stage/assets.ts | 94 +++++++++++++++++++++++++++++++ game/src/stage/manifest.ts | 52 +++++++++++++++++ game/src/stage/render.ts | 15 +++++ 6 files changed, 296 insertions(+), 10 deletions(-) create mode 100644 game/src/stage/assets.test.ts create mode 100644 game/src/stage/assets.ts create mode 100644 game/src/stage/manifest.ts create mode 100644 game/src/stage/render.ts 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; +}