diff --git a/game/README.md b/game/README.md index 72d9af1..dc1cd0c 100644 --- a/game/README.md +++ b/game/README.md @@ -36,3 +36,9 @@ production reports the error and substitutes a bundled actor or plain stage plat 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. + +Press **F2** to toggle the authored collision overlay (docs/04 colours). It uses +the same world polylines and boxes that the controller consumes. Collision files +are fetched next to the exported manifest, validated against the chunk and state, +then shifted from chunk-local pixels by the manifest's grid coordinates. Original +metadata is retained; reserved ledges and glide lifts do not become solid surfaces. diff --git a/game/src/core/scaffold.ts b/game/src/core/scaffold.ts index 3b64b7b..80f26ca 100644 --- a/game/src/core/scaffold.ts +++ b/game/src/core/scaffold.ts @@ -2,6 +2,8 @@ import Phaser from 'phaser'; import { Assets, BOARDS } from '../stage/assets'; import { drawStage } from '../stage/render'; import type { StageManifest, RegionState } from '../stage/manifest'; +import { loadCollisions, type CollisionChunk } from '../stage/collision'; +import { collisionDebug } from '../stage/collision-debug'; // Bootstrap-only scenes live in lane B until lane A provides story/UI scenes. export class Boot extends Phaser.Scene { @@ -17,15 +19,17 @@ export class Preload extends Phaser.Scene { const state: RegionState = query.get('state') === 'clean' ? 'clean' : 'anomaly'; const assets = new Assets(this); const manifest = await assets.stage(region, state); + const collisions = manifest.chunks[0].id === 'fallback' ? [] : await loadCollisions(manifest, state); for (const board of BOARDS) await assets.board(board); - this.scene.start('Stage', { manifest, state }); + this.scene.start('Stage', { manifest, state, collisions }); } } export class Stage extends Phaser.Scene { constructor() { super('Stage'); } - create(data: { manifest: StageManifest; state: RegionState }) { + create(data: { manifest: StageManifest; state: RegionState; collisions: CollisionChunk[] }) { drawStage(this, data.manifest, data.state); + collisionDebug(this, data.collisions); 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')) { diff --git a/game/src/stage/collision-debug.ts b/game/src/stage/collision-debug.ts new file mode 100644 index 0000000..0b857b2 --- /dev/null +++ b/game/src/stage/collision-debug.ts @@ -0,0 +1,30 @@ +import type Phaser from 'phaser'; +import { BOXES, LINES, POINTS, type CollisionChunk } from './collision'; + +const COLOURS = { walk: 0x28dc3c, slide: 0x965a28, oneWay: 0xffdc00, climb: 0x286eff, + solid: 0xe61e1e, noGo: 0x000000, hazard: 0xff8c00, interact: 0xaa3cdc, + spawn: 0xffffff, ledge: 0x00e6e6, glideLift: 0xaad2ff }; + +/** F2 toggles the authoring legend over the same world geometry used by physics. */ +export function collisionDebug(scene: Phaser.Scene, chunks: CollisionChunk[]) { + const graphics = scene.add.graphics().setDepth(10000).setVisible(false); + for (const { world } of chunks) { + for (const key of BOXES) for (const b of world[key]) { + graphics.fillStyle(COLOURS[key], 0.27).fillRect(b.x, b.y, b.w, b.h); + graphics.lineStyle(3, COLOURS[key]).strokeRect(b.x, b.y, b.w, b.h); + } + for (const key of LINES) for (const line of world[key]) { + graphics.lineStyle(5, COLOURS[key]).beginPath().moveTo(...line[0]); + for (const p of line.slice(1)) graphics.lineTo(...p); + graphics.strokePath(); + } + for (const key of POINTS) for (const p of world[key]) { + graphics.fillStyle(COLOURS[key]).fillCircle(p.x, p.y, 6); + if (key === 'spawn' && p.kind === 'player') graphics.lineStyle(2, 0xffffff).strokeRect(p.x - 14, p.y - 76, 28, 76); + } + } + const toggle = () => graphics.setVisible(!graphics.visible); + scene.input.keyboard?.on('keydown-F2', toggle); + scene.events.once('shutdown', () => scene.input.keyboard?.off('keydown-F2', toggle)); + return graphics; +} diff --git a/game/src/stage/collision.test.ts b/game/src/stage/collision.test.ts new file mode 100644 index 0000000..6c637dc --- /dev/null +++ b/game/src/stage/collision.test.ts @@ -0,0 +1,82 @@ +import { expect, it, vi } from 'vitest'; +import { loadCollisions, parseCollision, placeCollision, BOXES, LINES, type CollisionData } from './collision'; +import type { StageManifest } from './manifest'; + +const fixtures = import.meta.glob('../../../art/stages/{rusty-creek,hidden-garden}/*.collision.json', + { eager: true, import: 'default' }); +function fixture(chunk = 'rc-0-0', state: 'clean' | 'anomaly' = 'anomaly'): CollisionData { + const region = chunk.startsWith('hg-') ? 'hidden-garden' : 'rusty-creek'; + const data = fixtures[`../../../art/stages/${region}/${chunk}.${state}.collision.json`]; + if (!data) throw new Error(`Missing committed fixture ${chunk}.${state}`); + return structuredClone(data); +} + +it('reads every committed jam collision file without discarding encounter metadata', () => { + for (const state of ['clean', 'anomaly'] as const) for (let x = 0; x < 3; x++) { + const id = `rc-${x}-0`, raw = fixture(id, state); + expect(parseCollision(raw, id, state)).toEqual(raw); + } + const garden = fixture('hg-0-0', 'clean'); + expect(parseCollision(garden, 'hg-0-0', 'clean')).toEqual(garden); + const creek = parseCollision(fixture(), 'rc-0-0', 'anomaly'); + expect(creek.interact.find(item => item.id === 'chair')?.item).toBe('chair'); +}); + +it('places polylines, boxes, spawns and patrol bounds on a four-direction grid', () => { + const local = parseCollision(fixture(), 'rc-0-0', 'anomaly'), before = structuredClone(local); + const { world } = placeCollision(local, [2160, -720]); + expect(world.walk[0][0]).toEqual([2160, -259]); + expect(world.spawn.find(p => p.id === 'rover-1')).toMatchObject({ x: 2920, y: -212, patrol: [2800, 3040] }); + expect(world.interact.find(p => p.id === 'chair')).toMatchObject({ x: 2476, y: -295, item: 'chair' }); + expect(local).toEqual(before); +}); + +it('rejects missing categories, wrong identities and non-integer/outside points', () => { + for (const change of [(d: CollisionData) => { delete (d as Partial).walk; }, (d: CollisionData) => { d.chunk = 'rc-2-0'; }, + (d: CollisionData) => { d.state = 'clean'; }, (d: CollisionData) => { d.spawn[0].x = 0.5; }, + (d: CollisionData) => { d.walk[0][0][1] = 721; }]) { + const raw = fixture(); change(raw); + expect(() => parseCollision(raw, 'rc-0-0', 'anomaly')).toThrow('Invalid collision'); + } +}); + +it('rejects malformed lines, boxes, hazard damage and duplicate ids', () => { + for (const change of [(d: CollisionData) => { d.walk[0].reverse(); }, (d: CollisionData) => { d.solid[0].w = 0; }, + (d: CollisionData) => { d.climb[0].kind = ''; }, (d: CollisionData) => { d.spawn[0].id = d.climb[0].id; }, + (d: CollisionData) => { d.hazard.push({ x: 1, y: 1, w: 10, h: 10, damage: 0 }); }]) { + const raw = fixture(); change(raw); + expect(() => parseCollision(raw, 'rc-0-0', 'anomaly')).toThrow('Invalid collision'); + } +}); + +it('retains reserved ledge and glide data without converting either to ground or walls', () => { + const raw = fixture(); raw.ledge.push({ x: 200, y: 100 }); + raw.glideLift.push({ x: 300, y: 200, w: 50, h: 100 }); + const { world } = placeCollision(parseCollision(raw, 'rc-0-0', 'anomaly'), [2160, 0]); + expect(world.ledge).toEqual([{ x: 2360, y: 100 }]); + expect(world.glideLift[0].x).toBe(2460); + for (const key of LINES) expect(world[key]).toHaveLength(raw[key].length); + expect(world.solid).toHaveLength(raw.solid.length); +}); + +function manifest(): StageManifest { + return { region: 'rusty-creek', chunkSize: [2160, 720], chunks: [0, 1].map(x => ({ + id: `rc-${x}-0`, x, y: 0, states: { anomaly: { layers: [], collision: `rc-${x}-0.anomaly.collision.json` } }, + })) }; +} + +it('fetches the collision files beside the manifest and builds world coordinates', async () => { + const request = vi.fn(async (url: string | URL | Request) => new Response(JSON.stringify( + fixture(String(url).includes('rc-1-0') ? 'rc-1-0' : 'rc-0-0')))); + const chunks = await loadCollisions(manifest(), 'anomaly', request); + expect(request).toHaveBeenCalledWith('/assets/stages/rusty-creek/rc-0-0.anomaly.collision.json'); + expect(chunks[1].origin).toEqual([2160, 0]); + for (const key of BOXES) expect(chunks[1].world[key]).toHaveLength(chunks[1].local[key].length); +}); + +it('refuses a missing collision file instead of inferring physics from artwork', async () => { + const request = vi.fn(async () => new Response('', { status: 404 })); + await expect(loadCollisions(manifest(), 'anomaly', request)).rejects.toThrow('HTTP 404'); + const input = manifest(); input.chunks[0].states.anomaly!.collision = null; + await expect(loadCollisions(input, 'anomaly', request)).rejects.toThrow('Missing collision:'); +}); diff --git a/game/src/stage/collision.ts b/game/src/stage/collision.ts new file mode 100644 index 0000000..b7b72a8 --- /dev/null +++ b/game/src/stage/collision.ts @@ -0,0 +1,88 @@ +import { assetFile, type StageManifest, type RegionState } from './manifest'; +import { stageBase } from './assets'; + +export type Point = [number, number]; +export interface Marker { x: number; y: number; id?: string; kind?: string; [key: string]: unknown } +export interface Box extends Marker { w: number; h: number } +export const LINES = ['walk', 'slide', 'oneWay'] as const; +export const BOXES = ['climb', 'solid', 'noGo', 'hazard', 'interact', 'glideLift'] as const; +export const POINTS = ['spawn', 'ledge'] as const; +export type Geometry = Record + & Record & Record; +export type CollisionData = Geometry & { chunk: string; state: RegionState; size: [number, number] }; +export interface CollisionChunk { local: CollisionData; origin: Point; world: Geometry } + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(`Invalid collision: ${message}`); +} +const object = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); +const integer = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v); +const kind = (v: unknown): v is string => typeof v === 'string' && /^[a-z][a-z0-9-]*$/.test(v); + +/** Read authored shapes only. Reserved ledges and glide lifts never become solids. */ +export function parseCollision(value: unknown, chunk: string, state: RegionState): CollisionData { + assert(object(value) && value.chunk === chunk && value.state === state, `${chunk}.${state} identity`); + assert(Array.isArray(value.size) && value.size.length === 2 && value.size[0] === 2160 && value.size[1] === 720, 'size'); + const inside = (x: unknown, y: unknown) => { + assert(integer(x) && integer(y) && x >= 0 && x <= 2160 && y >= 0 && y <= 720, 'integer point outside chunk'); + }; + const ids = new Set(); + for (const key of [...LINES, ...BOXES, ...POINTS]) { + const entries = value[key]; + assert(Array.isArray(entries), `${key} must be an array`); + if ((LINES as readonly string[]).includes(key)) { + for (const line of entries) { + assert(Array.isArray(line) && line.length >= 2, `${key} polyline`); + for (const p of line) { assert(Array.isArray(p) && p.length === 2, `${key} point`); inside(p[0], p[1]); } + assert(line.every((p, i) => i === 0 || p[0] > line[i - 1][0]), `${key} must run left to right`); + } + continue; + } + for (const item of entries) { + assert(object(item), `${key} object`); inside(item.x, item.y); + if ((BOXES as readonly string[]).includes(key)) { + assert(integer(item.w) && integer(item.h) && item.w > 0 && item.h > 0, `${key} box dimensions`); + inside(Number(item.x) + item.w, Number(item.y) + item.h); + } + if (key === 'climb' || key === 'interact' || key === 'spawn') assert(kind(item.kind), `${key} kind`); + if (key === 'interact' || key === 'spawn') assert(typeof item.id === 'string' && item.id.length > 0, `${key} id`); + if (item.id !== undefined) { + assert(typeof item.id === 'string' && item.id.length > 0 && !ids.has(item.id), 'duplicate/invalid id'); + ids.add(item.id); + } + if (key === 'hazard') assert(integer(item.damage) && item.damage > 0, 'hazard damage'); + } + } + return value as unknown as CollisionData; +} + +/** Grid offsets apply to geometry and local horizontal patrol bounds, not metadata. */ +export function placeCollision(local: CollisionData, origin: Point): CollisionChunk { + const [dx, dy] = origin; + const marker = (item: T): T => ({ ...item, x: item.x + dx, y: item.y + dy }); + const world = Object.fromEntries([ + ...LINES.map(key => [key, local[key].map(line => line.map(([x, y]): Point => [x + dx, y + dy]))]), + ...BOXES.map(key => [key, local[key].map(marker)]), + ...POINTS.map(key => [key, local[key].map(item => { + const placed = marker(item); + if (key === 'spawn' && Array.isArray(item.patrol) && item.patrol.every(v => typeof v === 'number')) { + placed.patrol = item.patrol.map(x => x + dx); + } + return placed; + })]), + ]) as Geometry; + return { local, origin, world }; +} + +export async function loadCollisions(manifest: StageManifest, state: RegionState, + request: typeof fetch = (...args) => fetch(...args)): Promise { + return Promise.all(manifest.chunks.map(async chunk => { + const file = chunk.states[state]?.collision; + if (!assetFile(file)) throw new Error(`Missing collision: ${manifest.region}/${chunk.id}.${state}`); + const url = stageBase(manifest.region) + file; + const response = await request(url); + if (!response.ok) throw new Error(`Missing collision ${url}: HTTP ${response.status}`); + return placeCollision(parseCollision(await response.json(), chunk.id, state), + [chunk.x * manifest.chunkSize[0], chunk.y * manifest.chunkSize[1]]); + })); +}