@ludra/core 0.7.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/browser.ts CHANGED
@@ -6,8 +6,8 @@ import { ParticleSystem } from "./particles.ts";
6
6
  import { PostFx } from "./postfx.ts";
7
7
  import type { DrawContext, DrawEvent, Script } from "./script.ts";
8
8
  import { FIXED_DT } from "./time.ts";
9
- import type { Action, SceneDef } from "./types.ts";
10
- import { World, type Entity } from "./world.ts";
9
+ import type { Action, PostFxDef, SceneDef } from "./types.ts";
10
+ import { World, type Entity, type GameEvent } from "./world.ts";
11
11
 
12
12
  const KEYMAP: Record<string, Action> = {
13
13
  ArrowLeft: "left",
@@ -36,6 +36,11 @@ export interface GameOptions {
36
36
  scriptBase?: string;
37
37
  /** Scripts to load. Defaults to those the scene references; include any that `world.spawn` uses. */
38
38
  scriptPaths?: readonly string[];
39
+ /**
40
+ * Loads another scene by path, for goals with `next` and `world.goto`. Without it the
41
+ * game stays on its first scene. Throw with a readable message if the scene is invalid.
42
+ */
43
+ loadScene?: (path: string) => Promise<SceneDef>;
39
44
  }
40
45
 
41
46
  /** Imports the game's script modules, keyed by their path in the scene. */
@@ -82,76 +87,153 @@ async function loadFonts(scene: SceneDef, assetBase: string): Promise<void> {
82
87
  await Promise.race([all, new Promise((resolve) => setTimeout(resolve, 4000))]);
83
88
  }
84
89
 
85
- interface Stage {
86
- /** Where the game draws. With postfx this is an offscreen canvas. */
87
- ctx: CanvasRenderingContext2D;
88
- /** The canvas players see. */
89
- output: HTMLCanvasElement;
90
- images: Map<string, HTMLImageElement>;
91
- scripts: Map<string, Script>;
92
- /** Shows the drawn frame: runs the postfx shader, or does nothing without one. */
93
- present(time: number): void;
94
- /** Why postfx couldn't start (the game then runs without it and shows this message). */
95
- error?: string;
90
+ /** A scene's postfx shader source, or why it couldn't be fetched. */
91
+ async function loadShader(scene: SceneDef, assetBase: string): Promise<string | Error | undefined> {
92
+ const path = scene.postfx?.shader;
93
+ if (!path) return undefined;
94
+ const response = await fetch(assetBase + path).catch((err: unknown) => err as Error);
95
+ if (response instanceof Error) return response;
96
+ return response.ok ? response.text() : new Error(`could not load ${path} (${response.status})`);
96
97
  }
97
98
 
98
99
  /**
99
- * Loads everything a scene needs and sets up its canvases. With `postfx`, `canvas`
100
- * becomes the offscreen source and a WebGL canvas takes its place in the page.
100
+ * The canvas players see. The game always draws into the 2D canvas; when a scene
101
+ * has `postfx`, a WebGL canvas running its shader takes the 2D canvas's place in
102
+ * the page, and goes away again for a scene without one.
101
103
  */
102
- async function prepare(canvas: HTMLCanvasElement, scene: SceneDef, options: GameOptions): Promise<Stage> {
103
- const ctx = canvas.getContext("2d");
104
- if (!ctx) throw new Error("Canvas 2D context is not available.");
104
+ class Screen {
105
+ readonly ctx: CanvasRenderingContext2D;
106
+ output: HTMLCanvasElement;
107
+ private readonly canvas: HTMLCanvasElement;
108
+ private fx: PostFx | undefined;
109
+
110
+ constructor(canvas: HTMLCanvasElement) {
111
+ const ctx = canvas.getContext("2d");
112
+ if (!ctx) throw new Error("Canvas 2D context is not available.");
113
+ this.ctx = ctx;
114
+ this.canvas = canvas;
115
+ this.output = canvas;
116
+ }
117
+
118
+ /** Switches to a scene's shader, or to none. Returns why the shader can't run, if it can't. */
119
+ use(postfx: PostFxDef | undefined, shader: string | Error | undefined): string | undefined {
120
+ let fx: PostFx | undefined;
121
+ let error: string | undefined;
122
+ if (postfx && shader !== undefined) {
123
+ try {
124
+ if (shader instanceof Error) throw shader;
125
+ fx = new PostFx(this.canvas.width, this.canvas.height, shader, postfx);
126
+ } catch (err) {
127
+ error = `postfx: ${message(err)}`;
128
+ console.error("[ludra]", err);
129
+ }
130
+ }
131
+ const target = fx?.canvas ?? this.canvas;
132
+ if (target !== this.output) {
133
+ target.id = this.canvas.id;
134
+ target.className = this.canvas.className;
135
+ target.style.cssText = this.canvas.style.cssText;
136
+ this.output.replaceWith(target);
137
+ this.output = target;
138
+ }
139
+ this.fx = fx;
140
+ return error;
141
+ }
142
+
143
+ /** Shows the frame the game just drew, through the shader if there is one. */
144
+ present(time: number): void {
145
+ this.fx?.render(this.canvas, time);
146
+ }
147
+ }
148
+
149
+ /** What a scene needs before it can be drawn. */
150
+ async function loadScene(scene: SceneDef, options: GameOptions, scripts: Map<string, Script>) {
105
151
  const assetBase = options.assetBase ?? "";
106
- const shaderPath = scene.postfx?.shader;
107
- const [images, scripts, shader] = await Promise.all([
152
+ const [images, more, shader] = await Promise.all([
108
153
  loadImages(scene, assetBase),
109
154
  loadScripts(scene, options),
110
- shaderPath
111
- ? fetch(assetBase + shaderPath).then(async (r) => (r.ok ? r.text() : new Error(`could not load ${shaderPath} (${r.status})`)))
112
- : undefined,
155
+ loadShader(scene, assetBase),
113
156
  loadFonts(scene, assetBase),
114
157
  ]);
115
- const stage: Stage = { ctx, output: canvas, images, scripts, present: () => {} };
116
- if (scene.postfx && shader !== undefined) {
117
- try {
118
- if (shader instanceof Error) throw shader;
119
- const fx = new PostFx(canvas.width, canvas.height, shader, scene.postfx);
120
- fx.canvas.id = canvas.id;
121
- fx.canvas.className = canvas.className;
122
- fx.canvas.style.cssText = canvas.style.cssText;
123
- canvas.replaceWith(fx.canvas);
124
- stage.output = fx.canvas;
125
- stage.present = (time) => fx.render(canvas, time);
126
- } catch (err) {
127
- stage.error = `postfx: ${message(err)}`;
128
- console.error("[ludra]", err);
129
- }
130
- }
131
- return stage;
158
+ for (const [path, script] of more) scripts.set(path, script);
159
+ return { images, shader };
132
160
  }
133
161
 
134
162
  function message(err: unknown): string {
135
163
  return err instanceof Error ? err.message : String(err);
136
164
  }
137
165
 
166
+ /** Seconds the win screen shows before the next scene, and the fade into a world.goto scene. */
167
+ const WIN_PAUSE = 1.6;
168
+ const GOTO_FADE = 0.35;
169
+
138
170
  /**
139
171
  * Runs a scene in a canvas with keyboard controls, images, particles and
140
172
  * sound. The simulation uses the same fixed step as headless runs, so what
141
173
  * tests verify is what players get; everything added here is presentation.
174
+ * With `options.loadScene`, won goals with `next` and `world.goto` move on
175
+ * to other scenes, keeping score, deaths and lives.
142
176
  * Resolves (after images load) to a function that stops the game.
143
177
  */
144
- export async function startGame(canvas: HTMLCanvasElement, scene: SceneDef, options: GameOptions = {}): Promise<() => void> {
145
- const { ctx, images, scripts, present, error } = await prepare(canvas, scene, options);
178
+ export async function startGame(canvas: HTMLCanvasElement, firstScene: SceneDef, options: GameOptions = {}): Promise<() => void> {
179
+ const screen = new Screen(canvas);
180
+ const ctx = screen.ctx;
146
181
  const sounds = new SoundPlayer(options.assetBase ?? "");
147
- const view = new View(scene, images);
182
+ const scripts = new Map<string, Script>();
148
183
 
149
- let world = new World(scene, { scripts });
150
- let crash: string | undefined = error;
184
+ type Carry = { score: number; deaths: number; lives: number | undefined };
185
+ let scene = firstScene;
186
+ let startCarry: Carry | undefined;
187
+ let view: View;
188
+ let world: World;
189
+ let crash: string | undefined;
190
+ let sceneError: string | undefined;
191
+ /** Real time when the world asked to move on, while waiting to load the next scene. */
192
+ let exitSince: number | undefined;
193
+ let loading = false;
194
+
195
+ const enter = async (next: SceneDef, carry: Carry | undefined) => {
196
+ const { images, shader } = await loadScene(next, options, scripts);
197
+ scene = next;
198
+ startCarry = carry;
199
+ view = new View(next, images);
200
+ sceneError = screen.use(next.postfx, shader);
201
+ crash = sceneError;
202
+ exitSince = undefined;
203
+ try {
204
+ world = new World(next, { scripts, carry });
205
+ } catch (err) {
206
+ crash = message(err);
207
+ world = new World({ ...next, entities: [] });
208
+ }
209
+ };
210
+ await enter(firstScene, undefined);
211
+
212
+ // Game over starts the whole game again; otherwise R restarts the current scene as it began.
151
213
  const restart = () => {
152
- world = new World(scene, { scripts });
214
+ if (loading) return;
215
+ if (world.lost && scene !== firstScene) {
216
+ loading = true;
217
+ void enter(firstScene, undefined).finally(() => (loading = false));
218
+ return;
219
+ }
220
+ world = new World(scene, { scripts, carry: startCarry });
153
221
  view.reset();
154
- crash = error;
222
+ crash = sceneError;
223
+ exitSince = undefined;
224
+ };
225
+
226
+ const moveOn = (path: string) => {
227
+ if (!options.loadScene) return;
228
+ loading = true;
229
+ const carry = { score: world.score, deaths: world.deaths, lives: world.lives };
230
+ options
231
+ .loadScene(path)
232
+ .then((next) => enter(next, carry))
233
+ .catch((err: unknown) => {
234
+ crash = `Could not load the next scene "${path}": ${message(err)}`;
235
+ })
236
+ .finally(() => (loading = false));
155
237
  };
156
238
 
157
239
  const held = new Set<Action>();
@@ -185,7 +267,7 @@ export async function startGame(canvas: HTMLCanvasElement, scene: SceneDef, opti
185
267
  const frame = (now: number) => {
186
268
  accumulator += Math.min(MAX_FRAME_TIME, (now - last) / 1000);
187
269
  last = now;
188
- while (accumulator >= FIXED_DT && !crash) {
270
+ while (accumulator >= FIXED_DT && !crash && !loading) {
189
271
  try {
190
272
  world.step(tapped.size > 0 ? new Set([...held, ...tapped]) : held);
191
273
  } catch (err) {
@@ -197,15 +279,30 @@ export async function startGame(canvas: HTMLCanvasElement, scene: SceneDef, opti
197
279
  view.step(world, (sound) => sounds.play(sound));
198
280
  accumulator -= FIXED_DT;
199
281
  }
282
+
283
+ // Moving on: let the win screen show, or fade out for a world.goto, then load.
284
+ let fade = 0;
285
+ if (world.exit !== undefined && options.loadScene && !crash) {
286
+ exitSince ??= now;
287
+ const pause = world.won ? WIN_PAUSE : GOTO_FADE;
288
+ const elapsed = (now - exitSince) / 1000;
289
+ fade = Math.min(1, Math.max(0, (elapsed - (pause - GOTO_FADE)) / GOTO_FADE));
290
+ if (elapsed >= pause && !loading) moveOn(world.exit);
291
+ }
292
+
200
293
  try {
201
- view.draw(ctx, world, sounds.muted);
294
+ view.draw(ctx, world, sounds.muted, options.loadScene !== undefined);
202
295
  } catch (err) {
203
296
  // A draw hook threw. Same treatment as a simulation error.
204
297
  if (!crash) console.error("[ludra]", err);
205
298
  crash ??= message(err);
206
299
  }
300
+ if (fade > 0 || loading) {
301
+ ctx.fillStyle = `rgba(0, 0, 0, ${loading ? 1 : fade})`;
302
+ ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
303
+ }
207
304
  if (crash) drawCrash(ctx, crash);
208
- present(world.time);
305
+ screen.present(world.time);
209
306
  raf = requestAnimationFrame(frame);
210
307
  };
211
308
  raf = requestAnimationFrame(frame);
@@ -239,7 +336,10 @@ export interface CapturedFrame {
239
336
  * `ludra play --screenshot`.
240
337
  */
241
338
  export async function captureFrames(canvas: HTMLCanvasElement, scene: SceneDef, options: CaptureOptions): Promise<CapturedFrame[]> {
242
- const { ctx, output, images, scripts, present, error } = await prepare(canvas, scene, options);
339
+ const screen = new Screen(canvas);
340
+ const scripts = new Map<string, Script>();
341
+ const { images, shader } = await loadScene(scene, options, scripts);
342
+ const error = screen.use(scene.postfx, shader);
243
343
  if (error) throw new Error(error);
244
344
  const view = new View(scene, images);
245
345
  const world = new World(scene, { scripts });
@@ -250,9 +350,9 @@ export async function captureFrames(canvas: HTMLCanvasElement, scene: SceneDef,
250
350
 
251
351
  for (let frame = 0; ; frame++) {
252
352
  if (wanted.has(frame)) {
253
- view.draw(ctx, world, false);
254
- present(world.time);
255
- captured.push({ frame, time: frame * FIXED_DT, png: output.toDataURL("image/png") });
353
+ view.draw(screen.ctx, world, false, false);
354
+ screen.present(world.time);
355
+ captured.push({ frame, time: frame * FIXED_DT, png: screen.output.toDataURL("image/png") });
256
356
  }
257
357
  if (frame >= last) break;
258
358
  world.step(script.at(frame));
@@ -322,9 +422,15 @@ class View {
322
422
  private readonly scene: SceneDef;
323
423
  private readonly images: Map<string, HTMLImageElement>;
324
424
 
425
+ /** Whether the scene can award points at all, so the default HUD can leave the score out. */
426
+ private readonly scoring: boolean;
427
+
325
428
  constructor(scene: SceneDef, images: Map<string, HTMLImageElement>) {
326
429
  this.scene = scene;
327
430
  this.images = images;
431
+ this.scoring = scene.entities.some(
432
+ (e) => e.components.collectible || (e.components.hazard && Number(e.components.hazard.score) > 0) || Number(e.components.goal?.requireScore) > 0,
433
+ );
328
434
  }
329
435
 
330
436
  reset(): void {
@@ -362,7 +468,8 @@ class View {
362
468
  this.shake = this.shake < 0.5 ? 0 : this.shake * SHAKE_DECAY;
363
469
  }
364
470
 
365
- draw(ctx: CanvasRenderingContext2D, world: World, muted: boolean): void {
471
+ /** `continues`: the host loads next scenes, so a won scene with `next` says so instead of offering a replay. */
472
+ draw(ctx: CanvasRenderingContext2D, world: World, muted: boolean, continues: boolean): void {
366
473
  const { width: viewWidth, height: viewHeight } = ctx.canvas;
367
474
  ctx.imageSmoothingEnabled = false;
368
475
  ctx.fillStyle = world.background;
@@ -398,25 +505,32 @@ class View {
398
505
  return;
399
506
  }
400
507
 
508
+ // Only what this scene uses: no "Score 0" in a game without points.
509
+ const parts: string[] = [];
510
+ if (world.lives !== undefined) parts.push(`${"♥".repeat(Math.max(0, Math.min(world.lives, 10)))}${world.lives > 10 ? ` ${world.lives}` : ""}`);
511
+ if (this.scoring || world.score !== 0) parts.push(`Score ${world.score}`);
512
+ if (world.lives === undefined) parts.push(`Deaths ${world.deaths}`);
401
513
  ctx.textAlign = "left";
402
514
  ctx.font = "600 18px system-ui, sans-serif";
403
515
  ctx.fillStyle = "#ffffff";
404
- ctx.fillText(`Score ${world.score} Deaths ${world.deaths}`, 16, 30);
516
+ ctx.fillText(parts.join(" "), 16, 30);
405
517
  ctx.textAlign = "right";
406
518
  ctx.font = "500 13px system-ui, sans-serif";
407
519
  ctx.fillStyle = "rgba(255, 255, 255, 0.6)";
408
520
  ctx.fillText(`M: sound ${muted ? "off" : "on"} · R: restart`, viewWidth - 16, 28);
409
521
 
410
- if (world.won) {
522
+ if (world.won || world.lost) {
411
523
  ctx.fillStyle = "rgba(0, 0, 0, 0.55)";
412
524
  ctx.fillRect(0, 0, viewWidth, viewHeight);
413
525
  ctx.fillStyle = "#ffffff";
414
526
  ctx.textAlign = "center";
415
527
  ctx.font = "700 44px system-ui, sans-serif";
416
- ctx.fillText("Level complete!", viewWidth / 2, viewHeight / 2 - 10);
528
+ ctx.fillText(world.won ? "Level complete!" : "Game over", viewWidth / 2, viewHeight / 2 - 10);
417
529
  ctx.font = "500 18px system-ui, sans-serif";
418
- const winTime = world.events.find((e) => e.type === "win")?.time ?? world.time;
419
- ctx.fillText(`Score ${world.score} · ${winTime.toFixed(1)}s · Press R to play again`, viewWidth / 2, viewHeight / 2 + 30);
530
+ const endTime = world.events.find((e) => e.type === "win" || e.type === "gameover")?.time ?? world.time;
531
+ const next = world.won && world.exit !== undefined && continues ? "Get ready…" : `Press R to ${world.won ? "play again" : "try again"}`;
532
+ const score = this.scoring || world.score !== 0 ? `Score ${world.score} · ` : "";
533
+ ctx.fillText(`${score}${endTime.toFixed(1)}s · ${next}`, viewWidth / 2, viewHeight / 2 + 30);
420
534
  }
421
535
  }
422
536
 
@@ -524,7 +638,10 @@ function recentEvents(world: World, name: string | undefined, seconds: number):
524
638
  if (event.time < since) break;
525
639
  const eventName = event.type === "custom" ? event.name : event.type;
526
640
  if (name !== undefined && eventName !== name) continue;
527
- result.push({ name: eventName, time: event.time, x: event.x, y: event.y, entity: event.entity, data: event.type === "custom" ? event.data : undefined });
641
+ // Custom events carry what the script passed; built-in ones their own details (item, enemy, checkpoint...).
642
+ const { type: _type, frame: _frame, time, x, y, entity, ...details } = event as GameEvent & { data?: unknown };
643
+ const data = event.type === "custom" ? event.data : details;
644
+ result.push({ name: eventName, time, x, y, entity, data });
528
645
  }
529
646
  return result.reverse();
530
647
  }
package/src/components.ts CHANGED
@@ -25,6 +25,7 @@ export const COMPONENT_NAMES = [
25
25
  "patrol",
26
26
  "hazard",
27
27
  "goal",
28
+ "checkpoint",
28
29
  "emitter",
29
30
  "script",
30
31
  "camera",
@@ -155,8 +156,19 @@ export const COMPONENTS: Record<ComponentName, ComponentSpec> = {
155
156
  requires: ["transform", "body"],
156
157
  fields: {
157
158
  requireScore: { type: "number", min: 0, default: 0, description: "Score needed before the goal accepts the player." },
159
+ next: {
160
+ type: "string",
161
+ default: "",
162
+ description: 'Scene to play after this one is won, e.g. "scenes/level-2.scene.json". The browser moves on and keeps score, deaths and lives; each scene is tested on its own.',
163
+ },
158
164
  },
159
165
  },
166
+ checkpoint: {
167
+ description:
168
+ "When a player touches it, that player respawns here after dying (standing on the checkpoint's bottom edge). Fires a checkpoint event once. Its body must be a sensor.",
169
+ requires: ["transform", "body"],
170
+ fields: {},
171
+ },
160
172
  emitter: {
161
173
  description: "Continuously emits visual particles from the entity (torches, sparkles, smoke). It does not affect gameplay.",
162
174
  requires: ["transform"],
@@ -268,6 +280,7 @@ export interface Hazard {
268
280
 
269
281
  export interface Goal {
270
282
  requireScore: number;
283
+ next: string;
271
284
  }
272
285
 
273
286
  export interface Camera {
package/src/effects.ts CHANGED
@@ -19,7 +19,7 @@ export const EFFECT_FIELDS: Record<string, FieldSpec> = {
19
19
  };
20
20
 
21
21
  export const EFFECTS_DESCRIPTION =
22
- 'Scene-level "effects" map a game event (jump, land, collect, die, stomp, win) to feedback: a particle burst at the event position, a sound and a screen shake. They never affect gameplay.';
22
+ 'Scene-level "effects" map a game event (jump, land, collect, die, stomp, win, bounce, checkpoint, gameover, goto, or a custom one) to feedback: a particle burst at the event position, a sound and a screen shake. They never affect gameplay.';
23
23
 
24
24
  export function isSoundPreset(sound: string): sound is SoundPreset {
25
25
  return (SOUND_PRESETS as readonly string[]).includes(sound);
@@ -30,7 +30,7 @@ export interface AssetReference {
30
30
  path: string;
31
31
  /** JSON path of the field that references it. */
32
32
  at: string;
33
- kind: "image" | "audio" | "script" | "font" | "shader";
33
+ kind: "image" | "audio" | "script" | "font" | "shader" | "scene";
34
34
  }
35
35
 
36
36
  /** Every file a scene points at, so tools can check that they exist. */
@@ -39,6 +39,8 @@ export function collectAssetPaths(scene: SceneDef): AssetReference[] {
39
39
  scene.entities.forEach((entity, i) => {
40
40
  const image = entity.components.sprite?.image;
41
41
  if (typeof image === "string" && image !== "") refs.push({ path: image, at: `entities[${i}].components.sprite.image`, kind: "image" });
42
+ const next = entity.components.goal?.next;
43
+ if (typeof next === "string" && next !== "") refs.push({ path: next, at: `entities[${i}].components.goal.next`, kind: "scene" });
42
44
  const src = entity.components.script?.src;
43
45
  if (typeof src === "string" && src !== "") refs.push({ path: src, at: `entities[${i}].components.script.src`, kind: "script" });
44
46
  // Images named in script params are preloaded for draw hooks (ctx.image).
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 {
@@ -43,6 +45,12 @@ export interface ScriptWorld {
43
45
  readonly deaths: number;
44
46
  /** True once the level is complete. */
45
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;
46
54
  get(id: string): ScriptEntity | undefined;
47
55
  /** Every entity, or only those with the given component. */
48
56
  all(component?: string): ScriptEntity[];
@@ -54,6 +62,14 @@ export interface ScriptWorld {
54
62
  kill(entity: ScriptEntity | string): void;
55
63
  /** Completes the level, like reaching a goal. */
56
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;
57
73
  /** Fires a custom event that tests can count (`events.<name>`) and `effects` can react to. */
58
74
  emit(name: string, data?: unknown): void;
59
75
  /** A number in [0, 1). Seeded by `world.seed`, so runs stay deterministic. Never use Math.random. */
@@ -86,6 +102,7 @@ export interface DrawEvent {
86
102
  y: number;
87
103
  /** The entity that caused it. */
88
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 }. */
89
106
  data?: unknown;
90
107
  }
91
108
 
@@ -167,11 +184,12 @@ export const SCRIPT_API_REFERENCE = `A script file default-exports hooks. Only "
167
184
  ctx.self the entity: id, x, y, vx, vy (read/write); grounded, width, height, exists (read);
168
185
  state (object kept between frames, testable as "<id>.state.<key>");
169
186
  has("component"), touches(otherOrId)
170
- ctx.world time, frame, width, height, deaths, won (read); score (read/write)
187
+ ctx.world time, frame, width, height, deaths, won, lost, exit (read); score, lives (read/write)
171
188
  get(id), all(component?), player()
172
- spawn({ id?, components }) -> entity destroy(entityOrId)
189
+ spawn({ id?, prefab?, components? }) -> entity destroy(entityOrId)
173
190
  kill(entityOrId) players respawn, others are destroyed
174
- 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)
175
193
  emit(name, data?) custom event: counted by "events.<name>" in tests, usable as an effects key
176
194
  random() deterministic, seeded by world.seed. Never use Math.random or Date
177
195
  ctx.input held(action), pressed(action) actions: left right up down jump action
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", "bounce"] 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`. */
@@ -22,6 +22,8 @@ export interface PostFxDef {
22
22
  /** An entity as written in a scene file. */
23
23
  export interface EntityDef {
24
24
  id: string;
25
+ /** Name of a prefab to start from. Gone after validation: its components are merged in. */
26
+ prefab?: string;
25
27
  components: Record<string, Record<string, unknown>>;
26
28
  }
27
29
 
@@ -34,6 +36,14 @@ export interface WorldSettings {
34
36
  background?: string;
35
37
  /** Seed for `world.random()` in scripts. Defaults to 1. */
36
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>>;
37
47
  }
38
48
 
39
49
  /** A scene file (`*.scene.json`). */
@@ -52,6 +62,12 @@ export interface SceneDef {
52
62
  fonts?: Record<string, string>;
53
63
  /** A GLSL fragment shader applied to every rendered frame. Presentation only. */
54
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>;
55
71
  entities: EntityDef[];
56
72
  }
57
73
 
package/src/validate.ts CHANGED
@@ -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,6 +54,10 @@ 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
 
@@ -237,8 +245,69 @@ const SENSOR_COMPONENTS: [ComponentName, string][] = [
237
245
  ["collectible", "players would bump into it instead of picking it up"],
238
246
  ["hazard", "players would stand on it instead of being hurt"],
239
247
  ["goal", "players would bump into it instead of reaching it"],
248
+ ["checkpoint", "players would bump into it instead of passing through"],
240
249
  ];
241
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
+
242
311
  function numberOr(value: unknown, fallback: number): number {
243
312
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
244
313
  }