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
6 changes: 6 additions & 0 deletions game/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 6 additions & 2 deletions game/src/core/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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')) {
Expand Down
30 changes: 30 additions & 0 deletions game/src/stage/collision-debug.ts
Original file line number Diff line number Diff line change
@@ -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;
}
82 changes: 82 additions & 0 deletions game/src/stage/collision.test.ts
Original file line number Diff line number Diff line change
@@ -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<CollisionData>('../../../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<CollisionData>).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:');
});
88 changes: 88 additions & 0 deletions game/src/stage/collision.ts
Original file line number Diff line number Diff line change
@@ -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<typeof LINES[number], Point[][]>
& Record<typeof BOXES[number], Box[]> & Record<typeof POINTS[number], Marker[]>;
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<string, unknown> => 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<string>();
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 = <T extends 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<CollisionChunk[]> {
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]]);
}));
}
Loading