Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions game/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<region>/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 `<board>.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.
34 changes: 24 additions & 10 deletions game/src/core/scaffold.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand Down
102 changes: 102 additions & 0 deletions game/src/stage/assets.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>(), 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<typeof fetch>) {
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(); }
});
});
94 changes: 94 additions & 0 deletions game/src/stage/assets.ts
Original file line number Diff line number Diff line change
@@ -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<string, AnimationClip>;
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<unknown> {
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<void> {
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<StageManifest> {
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<AnimationTable> {
const atlas = await this.json(`${base}.json`);
const clips = await this.json(`${base}.anims.json`) as AnimationTable;
const frames = (atlas as { frames?: Record<string, unknown> }).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<AnimationTable> {
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}`);
}
}
}
52 changes: 52 additions & 0 deletions game/src/stage/manifest.ts
Original file line number Diff line number Diff line change
@@ -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<Record<RegionState, ChunkState>> }
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<string, unknown> =>
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<string>(), grid = new Set<string>();
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] }));
});
}
15 changes: 15 additions & 0 deletions game/src/stage/render.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading