@ludra/core 0.5.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -9,3 +9,4 @@ export * from "./world.ts";
9
9
  export * from "./simulate.ts";
10
10
  export * from "./assertions.ts";
11
11
  export * from "./ascii.ts";
12
+ export * from "./drawcheck.ts";
package/src/postfx.ts ADDED
@@ -0,0 +1,110 @@
1
+ import type { PostFxDef } from "./types.ts";
2
+
3
+ /** Declared by the engine for every postfx shader, so shaders only write `main`. */
4
+ const ENGINE_HEADER = `precision mediump float;
5
+ uniform sampler2D u_image;
6
+ uniform vec2 u_resolution;
7
+ uniform float u_time;
8
+ varying vec2 v_uv;
9
+ `;
10
+
11
+ const VERTEX_SHADER = `attribute vec2 a_position;
12
+ varying vec2 v_uv;
13
+ void main() {
14
+ v_uv = a_position * 0.5 + 0.5;
15
+ gl_Position = vec4(a_position, 0.0, 1.0);
16
+ }`;
17
+
18
+ /**
19
+ * Runs a scene's GLSL fragment shader over each rendered frame. The game draws
20
+ * into an offscreen 2D canvas; this uploads it as a texture and draws it
21
+ * through the shader onto a WebGL canvas.
22
+ */
23
+ export class PostFx {
24
+ readonly canvas: HTMLCanvasElement;
25
+ private readonly gl: WebGLRenderingContext;
26
+ private readonly program: WebGLProgram;
27
+ private readonly texture: WebGLTexture;
28
+ private readonly uniforms: Array<{ location: WebGLUniformLocation | null; value: number }>;
29
+ private readonly time: WebGLUniformLocation | null;
30
+
31
+ /** Throws with the GLSL compiler's message if the shader doesn't compile. */
32
+ constructor(width: number, height: number, source: string, def: PostFxDef) {
33
+ this.canvas = document.createElement("canvas");
34
+ this.canvas.width = width;
35
+ this.canvas.height = height;
36
+ // preserveDrawingBuffer keeps the frame readable for screenshots.
37
+ const gl = this.canvas.getContext("webgl", { preserveDrawingBuffer: true, premultipliedAlpha: false });
38
+ if (!gl) throw new Error("postfx needs WebGL, which this browser doesn't provide.");
39
+ this.gl = gl;
40
+
41
+ const custom = Object.keys(def.uniforms ?? {});
42
+ // #line 1 makes compiler errors point at lines of the shader file itself.
43
+ const header = ENGINE_HEADER + custom.map((name) => `uniform float ${name};\n`).join("") + "#line 1\n";
44
+ this.program = link(gl, VERTEX_SHADER, header + stripDeclarations(source, ["u_image", "u_resolution", "u_time", "v_uv", ...custom]), def.shader);
45
+ gl.useProgram(this.program);
46
+
47
+ const buffer = gl.createBuffer();
48
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
49
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
50
+ const position = gl.getAttribLocation(this.program, "a_position");
51
+ gl.enableVertexAttribArray(position);
52
+ gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
53
+
54
+ this.texture = gl.createTexture()!;
55
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
56
+ for (const [key, value] of [
57
+ [gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE],
58
+ [gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE],
59
+ [gl.TEXTURE_MIN_FILTER, gl.LINEAR],
60
+ [gl.TEXTURE_MAG_FILTER, gl.LINEAR],
61
+ ] as const) {
62
+ gl.texParameteri(gl.TEXTURE_2D, key, value);
63
+ }
64
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
65
+
66
+ gl.uniform1i(gl.getUniformLocation(this.program, "u_image"), 0);
67
+ gl.uniform2f(gl.getUniformLocation(this.program, "u_resolution"), width, height);
68
+ this.time = gl.getUniformLocation(this.program, "u_time");
69
+ this.uniforms = custom.map((name) => ({ location: gl.getUniformLocation(this.program, name), value: def.uniforms![name]! }));
70
+ }
71
+
72
+ /** Draws `source` (the frame the game just rendered) through the shader. */
73
+ render(source: HTMLCanvasElement, time: number): void {
74
+ const gl = this.gl;
75
+ gl.viewport(0, 0, this.canvas.width, this.canvas.height);
76
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
77
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
78
+ gl.uniform1f(this.time, time);
79
+ for (const u of this.uniforms) gl.uniform1f(u.location, u.value);
80
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
81
+ }
82
+ }
83
+
84
+ /** Drops the shader's own declarations of names the engine already declares, so either style compiles. */
85
+ function stripDeclarations(source: string, names: string[]): string {
86
+ return source
87
+ .split("\n")
88
+ .map((line) => (/^\s*(uniform|varying)\b/.test(line) && names.some((name) => new RegExp(`\\b${name}\\b`).test(line)) ? "" : line))
89
+ .join("\n");
90
+ }
91
+
92
+ function link(gl: WebGLRenderingContext, vertex: string, fragment: string, path: string): WebGLProgram {
93
+ const program = gl.createProgram()!;
94
+ gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, vertex, "the engine's vertex shader"));
95
+ gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, fragment, path));
96
+ gl.linkProgram(program);
97
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(`${path} failed to link: ${gl.getProgramInfoLog(program)}`);
98
+ return program;
99
+ }
100
+
101
+ function compile(gl: WebGLRenderingContext, type: number, source: string, path: string): WebGLShader {
102
+ const shader = gl.createShader(type)!;
103
+ gl.shaderSource(shader, source);
104
+ gl.compileShader(shader);
105
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
106
+ const log = String(gl.getShaderInfoLog(shader)).trim();
107
+ throw new Error(`${path} failed to compile: ${log}`);
108
+ }
109
+ return shader;
110
+ }
package/src/script.ts CHANGED
@@ -30,7 +30,9 @@ export interface ScriptEntity {
30
30
  export interface SpawnDef {
31
31
  /** Generated when left out. */
32
32
  id?: string;
33
- components: Record<string, Record<string, unknown>>;
33
+ /** Start from a prefab; `components` then only needs what differs (usually the transform). */
34
+ prefab?: string;
35
+ components?: Record<string, Record<string, unknown>>;
34
36
  }
35
37
 
36
38
  export interface ScriptWorld {
@@ -39,6 +41,16 @@ export interface ScriptWorld {
39
41
  readonly width: number;
40
42
  readonly height: number;
41
43
  score: number;
44
+ /** Times a player has died so far. */
45
+ readonly deaths: number;
46
+ /** True once the level is complete. */
47
+ readonly won: boolean;
48
+ /** Lives left, or undefined when the scene has unlimited retries. Writable: an extra life is `world.lives += 1`. */
49
+ lives: number | undefined;
50
+ /** True once the game is over (out of lives, or world.lose()). */
51
+ readonly lost: boolean;
52
+ /** The scene this one hands over to (a won goal's next, or world.goto), once it does. */
53
+ readonly exit: string | undefined;
42
54
  get(id: string): ScriptEntity | undefined;
43
55
  /** Every entity, or only those with the given component. */
44
56
  all(component?: string): ScriptEntity[];
@@ -50,6 +62,14 @@ export interface ScriptWorld {
50
62
  kill(entity: ScriptEntity | string): void;
51
63
  /** Completes the level, like reaching a goal. */
52
64
  win(): void;
65
+ /** Ends the game as lost (a time limit ran out, the base was destroyed...). Fires "gameover". */
66
+ lose(): void;
67
+ /**
68
+ * Moves on to another scene, e.g. world.goto("scenes/level-1.scene.json") from a title
69
+ * screen. The current scene stops; the browser loads the next one, keeping score,
70
+ * deaths and lives. Fires "goto" (tests can assert events.goto).
71
+ */
72
+ goto(scene: string): void;
53
73
  /** Fires a custom event that tests can count (`events.<name>`) and `effects` can react to. */
54
74
  emit(name: string, data?: unknown): void;
55
75
  /** A number in [0, 1). Seeded by `world.seed`, so runs stay deterministic. Never use Math.random. */
@@ -73,6 +93,50 @@ export interface ScriptContext {
73
93
  dt: number;
74
94
  }
75
95
 
96
+ /** A game event as draw hooks see it. Custom events carry the name passed to world.emit. */
97
+ export interface DrawEvent {
98
+ /** Built-in type ("collect", "die", "bounce"...) or the custom event's name. */
99
+ name: string;
100
+ time: number;
101
+ x: number;
102
+ y: number;
103
+ /** The entity that caused it. */
104
+ entity: string;
105
+ /** Custom events: what the script passed to world.emit. Built-in ones: their details, e.g. { checkpoint: "lantern" } or { item: "coin-1", score: 1 }. */
106
+ data?: unknown;
107
+ }
108
+
109
+ /**
110
+ * What `draw` and `drawUI` receive. `g` is the real Canvas 2D context, so
111
+ * anything the canvas can do is allowed: gradients, glow (shadowBlur), blend
112
+ * modes (globalCompositeOperation), paths, text, filters, images.
113
+ *
114
+ * Draw hooks only paint. `self` and `world` are read-only here (changing the
115
+ * game must happen in `update` so tests see it); keep animation memory in `local`.
116
+ */
117
+ export interface DrawContext {
118
+ g: CanvasRenderingContext2D;
119
+ self: ScriptEntity;
120
+ world: ScriptWorld;
121
+ params: Record<string, unknown>;
122
+ /** Simulation time in seconds. Animate with it so screenshots stay reproducible. */
123
+ time: number;
124
+ /** Per-entity memory for presentation (trails, tweens). Not part of the game state and not testable. */
125
+ local: Record<string, unknown>;
126
+ /** Visible area: canvas size, camera position (world px at the top-left) and zoom. */
127
+ view: { width: number; height: number; cameraX: number; cameraY: number; zoom: number };
128
+ /** Events that happened in the last `seconds` (default 0.5), optionally only those named `name`. Newest last. */
129
+ recent(name?: string, seconds?: number): DrawEvent[];
130
+ /** An image from the project, e.g. "assets/glow.png". Images in sprites or in string script params are preloaded. */
131
+ image(path: string): HTMLImageElement | undefined;
132
+ /** A particle burst through the engine's particle system (same fields as effects particles). */
133
+ burst(x: number, y: number, particles?: Record<string, unknown>): void;
134
+ /** Shakes the screen by `amount` px (decays on its own). */
135
+ shake(amount: number): void;
136
+ /** A number in [0, 1) for visual variety. Seeded, independent of world.random, so it never affects the game. */
137
+ random(): number;
138
+ }
139
+
76
140
  /**
77
141
  * The default export of a script file. All hooks are optional.
78
142
  *
@@ -90,9 +154,20 @@ export interface Script {
90
154
  update?(ctx: ScriptContext): void;
91
155
  /** Runs when this entity starts touching another one (after physics). */
92
156
  touch?(ctx: ScriptContext, other: ScriptEntity): void;
157
+ /**
158
+ * Paints this entity in world space, every rendered frame, right after its sprite
159
+ * (or instead of one: an entity without a sprite is drawn only by this hook).
160
+ * Order follows `sprite.layer` (0 without a sprite). Browser only; tests never call it.
161
+ */
162
+ draw?(ctx: DrawContext): void;
163
+ /**
164
+ * Paints in screen space, above the world. If any script has `drawUI`, the engine's
165
+ * default HUD and "Level complete" screen are turned off: the game draws its own.
166
+ */
167
+ drawUI?(ctx: DrawContext): void;
93
168
  }
94
169
 
95
- export const SCRIPT_HOOKS = ["start", "update", "touch"] as const;
170
+ export const SCRIPT_HOOKS = ["start", "update", "touch", "draw", "drawUI"] as const;
96
171
 
97
172
  /** Printed by `ludra components script`. Keep in sync with the interfaces above. */
98
173
  export const SCRIPT_API_REFERENCE = `A script file default-exports hooks. Only "import type" is allowed: scripts run without a bundler.
@@ -102,22 +177,43 @@ export const SCRIPT_API_REFERENCE = `A script file default-exports hooks. Only "
102
177
  start(ctx) {}, // once, on the entity's first frame
103
178
  update(ctx) {}, // every frame, after player input, before physics
104
179
  touch(ctx, other) {}, // when this entity starts touching another body (after physics)
180
+ draw(ctx) {}, // paint this entity in world space (browser only, every rendered frame)
181
+ drawUI(ctx) {}, // paint screen-space UI; replaces the default HUD and win screen
105
182
  } satisfies Script;
106
183
 
107
184
  ctx.self the entity: id, x, y, vx, vy (read/write); grounded, width, height, exists (read);
108
185
  state (object kept between frames, testable as "<id>.state.<key>");
109
186
  has("component"), touches(otherOrId)
110
- ctx.world time, frame, width, height, score (read/write)
187
+ ctx.world time, frame, width, height, deaths, won, lost, exit (read); score, lives (read/write)
111
188
  get(id), all(component?), player()
112
- spawn({ id?, components }) -> entity destroy(entityOrId)
189
+ spawn({ id?, prefab?, components? }) -> entity destroy(entityOrId)
113
190
  kill(entityOrId) players respawn, others are destroyed
114
- win() completes the level
191
+ win() completes the level lose() game over
192
+ goto(scenePath) moves on to another scene (title -> level 1, a door to the next room)
115
193
  emit(name, data?) custom event: counted by "events.<name>" in tests, usable as an effects key
116
194
  random() deterministic, seeded by world.seed. Never use Math.random or Date
117
195
  ctx.input held(action), pressed(action) actions: left right up down jump action
118
196
  ctx.params the "params" object from the script component
119
197
  ctx.dt seconds per frame (1/60)
120
198
 
199
+ Drawing: draw and drawUI get the real Canvas 2D context, so there are no limits on looks.
200
+ ctx.g CanvasRenderingContext2D: gradients, shadowBlur glow, globalCompositeOperation ("lighter" for
201
+ additive light), paths, text with any loaded font, ctx.g.filter, drawImage...
202
+ draw: origin is the world (use self.x/self.y); drawUI: origin is the screen top-left
203
+ ctx.time simulation seconds; animate with it so screenshots are reproducible
204
+ ctx.local per-entity presentation memory (trails, tweens); not game state
205
+ ctx.view width, height, cameraX, cameraY, zoom
206
+ ctx.recent(name?, seconds = 0.5) recent events [{ name, time, x, y, entity, data }]: flash on hit, pop scores
207
+ ctx.image(path) a loaded image (sprite images and string params ending in .svg/.png/... are preloaded)
208
+ ctx.burst(x, y, particles?) ctx.shake(px) ctx.random() presentation-only helpers
209
+ self/world/params as in update, but read-only: changing the game in a draw hook is an error.
210
+
211
+ Scene-level presentation (all optional):
212
+ "fonts": { "Orbitron": "google", "Pixel": "assets/pixel.woff2" } load fonts for ctx.g.font
213
+ "postfx": { "shader": "shaders/crt.frag", "uniforms": { "strength": 0.6 } }
214
+ a GLSL ES 1.0 fragment shader over the whole frame. It gets: uniform sampler2D u_image;
215
+ uniform vec2 u_resolution; uniform float u_time; varying vec2 v_uv; plus each uniform as a float.
216
+
121
217
  Scripts in scripts/ are loaded even if no entity uses them, so world.spawn can attach them.
122
218
  Errors name the file, entity, hook and frame.`;
123
219
 
package/src/types.ts CHANGED
@@ -2,7 +2,7 @@ export type Action = "left" | "right" | "up" | "down" | "jump" | "action";
2
2
 
3
3
  export const ACTIONS: readonly Action[] = ["left", "right", "up", "down", "jump", "action"];
4
4
 
5
- export const EVENT_TYPES = ["jump", "land", "collect", "die", "stomp", "win"] as const;
5
+ export const EVENT_TYPES = ["jump", "land", "collect", "die", "stomp", "win", "bounce", "checkpoint", "gameover", "goto"] as const;
6
6
  export type GameEventType = (typeof EVENT_TYPES)[number];
7
7
 
8
8
  /** What happens visually and audibly when a game event fires. See `EFFECT_FIELDS`. */
@@ -12,9 +12,18 @@ export interface EffectDef {
12
12
  shake?: number;
13
13
  }
14
14
 
15
+ export interface PostFxDef {
16
+ /** Path to a GLSL ES 1.0 fragment shader, e.g. "shaders/crt.frag". */
17
+ shader: string;
18
+ /** Extra float uniforms, e.g. { "strength": 0.6 } becomes `uniform float strength;`. */
19
+ uniforms?: Record<string, number>;
20
+ }
21
+
15
22
  /** An entity as written in a scene file. */
16
23
  export interface EntityDef {
17
24
  id: string;
25
+ /** Name of a prefab to start from. Gone after validation: its components are merged in. */
26
+ prefab?: string;
18
27
  components: Record<string, Record<string, unknown>>;
19
28
  }
20
29
 
@@ -27,6 +36,14 @@ export interface WorldSettings {
27
36
  background?: string;
28
37
  /** Seed for `world.random()` in scripts. Defaults to 1. */
29
38
  seed?: number;
39
+ /** Lives before game over. Leave out for unlimited retries. */
40
+ lives?: number;
41
+ }
42
+
43
+ /** A reusable entity template: `prefabs/<name>.prefab.json` or the scene's `prefabs` map. */
44
+ export interface PrefabDef {
45
+ description?: string;
46
+ components: Record<string, Record<string, unknown>>;
30
47
  }
31
48
 
32
49
  /** A scene file (`*.scene.json`). */
@@ -38,6 +55,19 @@ export interface SceneDef {
38
55
  * Keys are built-in event types or names scripts pass to `world.emit`.
39
56
  */
40
57
  effects?: Partial<Record<GameEventType, EffectDef>> & Record<string, EffectDef | undefined>;
58
+ /**
59
+ * Fonts that draw hooks can use in `ctx.g.font`: family name → "google" (loaded from
60
+ * Google Fonts) or a font file in the project (.woff2, .woff, .ttf, .otf).
61
+ */
62
+ fonts?: Record<string, string>;
63
+ /** A GLSL fragment shader applied to every rendered frame. Presentation only. */
64
+ postfx?: PostFxDef;
65
+ /**
66
+ * Entity templates by name. Entities use one with `"prefab": "<name>"` and override
67
+ * components; scripts spawn them with `world.spawn({ prefab: "<name>" })`. The CLI
68
+ * adds every `prefabs/*.prefab.json` file here.
69
+ */
70
+ prefabs?: Record<string, PrefabDef>;
41
71
  entities: EntityDef[];
42
72
  }
43
73
 
package/src/validate.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { COMPONENT_NAMES, COMPONENTS, isComponentName, isFrameList, PARTICLE_FIELDS, type ComponentName, type FieldSpec } from "./components.ts";
2
- import { AUDIO_EXTENSIONS, EFFECT_FIELDS, IMAGE_EXTENSIONS, isSoundPreset, SOUND_PRESETS } from "./effects.ts";
2
+ import { AUDIO_EXTENSIONS, EFFECT_FIELDS, FONT_EXTENSIONS, IMAGE_EXTENSIONS, isSoundPreset, SOUND_PRESETS } from "./effects.ts";
3
3
  import { EVENT_TYPES, type Diagnostic, type SceneDef } from "./types.ts";
4
4
 
5
5
  export interface ValidationResult {
@@ -18,7 +18,7 @@ const REQUIRED_EXAMPLES: Partial<Record<ComponentName, string>> = {
18
18
  * Checks a parsed scene file. Every diagnostic names the exact JSON path and,
19
19
  * where possible, a fix an agent can apply without guessing.
20
20
  */
21
- export function validateScene(raw: unknown): ValidationResult {
21
+ export function validateScene(input: unknown): ValidationResult {
22
22
  const diagnostics: Diagnostic[] = [];
23
23
  const error = (code: string, path: string, message: string, fix?: string) =>
24
24
  diagnostics.push({ severity: "error", code, path, message, fix });
@@ -26,13 +26,17 @@ export function validateScene(raw: unknown): ValidationResult {
26
26
  diagnostics.push({ severity: "warning", code, path, message, fix });
27
27
  const result = (): ValidationResult => {
28
28
  const ok = !diagnostics.some((d) => d.severity === "error");
29
- return { ok, diagnostics, scene: ok ? (raw as SceneDef) : undefined };
29
+ return { ok, diagnostics, scene: ok ? (scene as SceneDef) : undefined };
30
30
  };
31
31
 
32
- if (!isObject(raw)) {
32
+ let scene: unknown = input;
33
+ if (!isObject(input)) {
33
34
  error("E_SCENE_NOT_OBJECT", "$", "A scene must be a JSON object.", 'Start from { "entities": [] }.');
34
35
  return result();
35
36
  }
37
+ // Prefabs become plain entities first, so everything below sees the final components.
38
+ const raw = expandPrefabs(input, error);
39
+ scene = raw;
36
40
 
37
41
  if (raw.world !== undefined) {
38
42
  if (!isObject(raw.world)) {
@@ -50,10 +54,16 @@ export function validateScene(raw: unknown): ValidationResult {
50
54
  if (raw.world.background !== undefined && typeof raw.world.background !== "string") {
51
55
  error("E_INVALID_WORLD", "world.background", `world.background must be a CSS color string, got ${describe(raw.world.background)}.`);
52
56
  }
57
+ const lives = raw.world.lives;
58
+ if (lives !== undefined && (typeof lives !== "number" || !Number.isInteger(lives) || lives < 1)) {
59
+ error("E_INVALID_WORLD", "world.lives", `world.lives must be a whole number of at least 1, got ${describe(lives)}.`, "Leave it out for unlimited retries.");
60
+ }
53
61
  }
54
62
  }
55
63
 
56
64
  if (raw.effects !== undefined) validateEffects(raw.effects, error, warn);
65
+ if (raw.fonts !== undefined) validateFonts(raw.fonts, error);
66
+ if (raw.postfx !== undefined) validatePostFx(raw.postfx, error);
57
67
 
58
68
  if (!Array.isArray(raw.entities)) {
59
69
  error("E_NO_ENTITIES", "entities", 'A scene needs an "entities" array.', 'Add "entities": [].');
@@ -63,6 +73,7 @@ export function validateScene(raw: unknown): ValidationResult {
63
73
  const ids = new Map<string, number>();
64
74
  let hasPlayer = false;
65
75
  let hasDynamic = false;
76
+ const worldGravity = isObject(raw.world) && typeof raw.world.gravity === "number" ? raw.world.gravity : 1800;
66
77
  let hasSolid = false;
67
78
  let availableScore = 0;
68
79
  const goals: { path: string; label: string; requireScore: number }[] = [];
@@ -150,6 +161,22 @@ export function validateScene(raw: unknown): ValidationResult {
150
161
 
151
162
  const body = isObject(components.body) ? components.body : undefined;
152
163
  const bodyType = body?.type ?? "static";
164
+ if ("topdown" in components && body && bodyType === "static") {
165
+ error(
166
+ "E_TOPDOWN_NEEDS_MOVING_BODY",
167
+ `${path}.components.body.type`,
168
+ `Entity ${label} has "topdown" but a static body, so it can never move.`,
169
+ 'Use "type": "dynamic" to collide with walls, or "kinematic" to pass through them (a paddle or a ship).',
170
+ );
171
+ }
172
+ if ("topdown" in components && "platformer" in components) {
173
+ error(
174
+ "E_TWO_CONTROLLERS",
175
+ `${path}.components`,
176
+ `Entity ${label} has both "platformer" and "topdown", which would fight over its movement.`,
177
+ "Keep one: platformer for side-view games with gravity, topdown for top-down games, paddles and ships.",
178
+ );
179
+ }
153
180
  if ("platformer" in components && body && bodyType !== "dynamic") {
154
181
  error(
155
182
  "E_PLATFORMER_NEEDS_DYNAMIC_BODY",
@@ -181,8 +208,10 @@ export function validateScene(raw: unknown): ValidationResult {
181
208
  if (isObject(components.hazard) && components.hazard.stompable === true) availableScore += numberOr(components.hazard.score, 0);
182
209
  if (isObject(components.goal)) goals.push({ path: `${path}.components.goal.requireScore`, label, requireScore: numberOr(components.goal.requireScore, 0) });
183
210
 
184
- if ("platformer" in components) hasPlayer = true;
185
- if (body && bodyType === "dynamic") hasDynamic = true;
211
+ if ("platformer" in components || "topdown" in components) hasPlayer = true;
212
+ // Only bodies that gravity actually pulls down need something to stand on.
213
+ const falls = body && bodyType === "dynamic" && !("topdown" in components) && body.gravityScale !== 0;
214
+ if (falls && worldGravity !== 0) hasDynamic = true;
186
215
  if (body && bodyType !== "dynamic" && body.sensor !== true) hasSolid = true;
187
216
  });
188
217
 
@@ -205,7 +234,7 @@ export function validateScene(raw: unknown): ValidationResult {
205
234
  }
206
235
  }
207
236
  if (!hasPlayer) {
208
- warn("W_NO_PLAYER", "entities", 'No entity has a "platformer" component, so nothing responds to input.');
237
+ warn("W_NO_PLAYER", "entities", 'No entity has a "platformer" or "topdown" component, so nothing responds to input (fine if scripts read input).');
209
238
  }
210
239
 
211
240
  return result();
@@ -216,8 +245,69 @@ const SENSOR_COMPONENTS: [ComponentName, string][] = [
216
245
  ["collectible", "players would bump into it instead of picking it up"],
217
246
  ["hazard", "players would stand on it instead of being hurt"],
218
247
  ["goal", "players would bump into it instead of reaching it"],
248
+ ["checkpoint", "players would bump into it instead of passing through"],
219
249
  ];
220
250
 
251
+ /**
252
+ * Replaces `"prefab": "<name>"` entities with the prefab's components, with the
253
+ * entity's own components merged over them (field by field; `null` removes a
254
+ * component). Returns a new scene object; reports unknown prefabs.
255
+ */
256
+ function expandPrefabs(raw: Record<string, unknown>, error: Report): Record<string, unknown> {
257
+ const prefabs = raw.prefabs;
258
+ if (prefabs !== undefined) {
259
+ if (!isObject(prefabs)) {
260
+ error("E_PREFABS_NOT_OBJECT", "prefabs", '"prefabs" must map names to { "components": { ... } }.');
261
+ return raw;
262
+ }
263
+ for (const [name, prefab] of Object.entries(prefabs)) {
264
+ if (!isObject(prefab) || !isObject(prefab.components)) {
265
+ error("E_INVALID_PREFAB", `prefabs.${name}`, `Prefab "${name}" needs a "components" object.`, 'Use { "components": { "body": { ... }, "sprite": { ... } } }.');
266
+ continue;
267
+ }
268
+ for (const component of Object.keys(prefab.components)) {
269
+ if (!isComponentName(component)) {
270
+ const suggestion = closestName(component, COMPONENT_NAMES);
271
+ error("E_UNKNOWN_COMPONENT", `prefabs.${name}.components.${component}`, `Unknown component "${component}" in prefab "${name}".`, suggestion ? `Did you mean "${suggestion}"?` : undefined);
272
+ }
273
+ }
274
+ }
275
+ }
276
+ if (!Array.isArray(raw.entities)) return raw;
277
+ const known = isObject(prefabs) ? prefabs : {};
278
+ const entities = raw.entities.map((entity: unknown, i: number) => {
279
+ if (!isObject(entity) || entity.prefab === undefined) return entity;
280
+ const name = entity.prefab;
281
+ const prefab = typeof name === "string" ? known[name] : undefined;
282
+ if (!isObject(prefab) || !isObject(prefab.components)) {
283
+ const names = Object.keys(known);
284
+ const suggestion = typeof name === "string" ? closestName(name, names) : undefined;
285
+ error(
286
+ "E_UNKNOWN_PREFAB",
287
+ `entities[${i}].prefab`,
288
+ `Prefab ${describe(name)} does not exist.`,
289
+ suggestion ? `Did you mean "${suggestion}"?` : names.length > 0 ? `Prefabs: ${names.join(", ")}.` : "Create prefabs/<name>.prefab.json with { \"components\": { ... } }.",
290
+ );
291
+ return entity;
292
+ }
293
+ const { prefab: _, ...rest } = entity;
294
+ return { ...rest, components: mergeComponents(prefab.components, isObject(entity.components) ? entity.components : {}) };
295
+ });
296
+ return { ...raw, entities };
297
+ }
298
+
299
+ /** A prefab's components with overrides merged in, one level deep. `null` drops a component. */
300
+ export function mergeComponents(base: Record<string, unknown>, overrides: Record<string, unknown>): Record<string, Record<string, unknown>> {
301
+ const merged: Record<string, Record<string, unknown>> = {};
302
+ for (const [name, value] of Object.entries(base)) if (isObject(value)) merged[name] = structuredClone(value);
303
+ for (const [name, value] of Object.entries(overrides)) {
304
+ if (value === null) delete merged[name];
305
+ else if (isObject(value) && isObject(merged[name])) merged[name] = { ...merged[name], ...structuredClone(value) };
306
+ else merged[name] = structuredClone(value) as Record<string, unknown>;
307
+ }
308
+ return merged;
309
+ }
310
+
221
311
  function numberOr(value: unknown, fallback: number): number {
222
312
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
223
313
  }
@@ -263,6 +353,35 @@ function validateFields(
263
353
  }
264
354
  }
265
355
 
356
+ function validateFonts(fonts: unknown, error: Report): void {
357
+ const example = 'Use "fonts": { "Orbitron": "google", "Pixel": "assets/pixel.woff2" }.';
358
+ if (!isObject(fonts)) return void error("E_FONTS_NOT_OBJECT", "fonts", '"fonts" must map font family names to "google" or a font file.', example);
359
+ for (const [family, src] of Object.entries(fonts)) {
360
+ const ok = typeof src === "string" && (src === "google" || FONT_EXTENSIONS.some((ext) => src.toLowerCase().endsWith(ext)));
361
+ if (!ok) {
362
+ error("E_INVALID_FONT", `fonts.${family}`, `fonts.${family} must be "google" or a font file (${FONT_EXTENSIONS.join(", ")}), got ${describe(src)}.`, example);
363
+ }
364
+ }
365
+ }
366
+
367
+ function validatePostFx(postfx: unknown, error: Report): void {
368
+ const example = 'Use "postfx": { "shader": "shaders/crt.frag", "uniforms": { "strength": 0.5 } }.';
369
+ if (!isObject(postfx)) return void error("E_POSTFX_NOT_OBJECT", "postfx", '"postfx" must be an object.', example);
370
+ if (typeof postfx.shader !== "string" || postfx.shader === "") {
371
+ error("E_POSTFX_SHADER", "postfx.shader", "postfx.shader must be the path of a GLSL fragment shader.", example);
372
+ }
373
+ if (postfx.uniforms !== undefined) {
374
+ if (!isObject(postfx.uniforms)) return void error("E_POSTFX_UNIFORMS", "postfx.uniforms", "postfx.uniforms must map names to numbers.", example);
375
+ for (const [name, value] of Object.entries(postfx.uniforms)) {
376
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || name.startsWith("u_")) {
377
+ error("E_POSTFX_UNIFORMS", `postfx.uniforms.${name}`, `"${name}" is not a usable uniform name. Use a GLSL identifier that doesn't start with "u_" (reserved for the engine).`);
378
+ } else if (typeof value !== "number" || !Number.isFinite(value)) {
379
+ error("E_POSTFX_UNIFORMS", `postfx.uniforms.${name}`, `postfx.uniforms.${name} must be a number, got ${describe(value)}.`);
380
+ }
381
+ }
382
+ }
383
+ }
384
+
266
385
  function validateEffects(effects: unknown, error: Report, warn: Report): void {
267
386
  if (!isObject(effects)) {
268
387
  error("E_EFFECTS_NOT_OBJECT", "effects", '"effects" must map event names to effects.', 'Use "effects": { "collect": { "sound": "coin" } }.');