@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.
@@ -4,7 +4,7 @@ declare const OPERATORS: readonly ["<=", ">=", "==", "!=", "<", ">"];
4
4
  type Operator = (typeof OPERATORS)[number];
5
5
  declare const ENTITY_FIELDS: readonly ["x", "y", "vx", "vy", "grounded", "exists"];
6
6
  type EntityField = (typeof ENTITY_FIELDS)[number];
7
- declare const WORLD_FIELDS: readonly ["score", "deaths", "won"];
7
+ declare const WORLD_FIELDS: readonly ["score", "deaths", "won", "lost", "lives"];
8
8
  type WorldField = (typeof WORLD_FIELDS)[number];
9
9
  type Subject = {
10
10
  kind: "world";
@@ -48,6 +48,8 @@ export declare function evaluateAssertion(assertion: Assertion, snapshot: WorldS
48
48
  /** One case in a `tests/*.test.json` file. */
49
49
  export interface TestCase {
50
50
  name: string;
51
+ /** Scene to run, relative to the project. Defaults to the entry scene in ludra.json. */
52
+ scene?: string;
51
53
  /** Input script, see `InputScript`. */
52
54
  inputs?: string;
53
55
  /** Run length, e.g. "3s". Defaults to the inputs plus one second, or the latest `at`. */
@@ -60,6 +62,8 @@ export interface TestResult {
60
62
  duration: number;
61
63
  results: AssertionResult[];
62
64
  error?: string;
65
+ /** Names of the events that fired during the run (built-in types and custom names). */
66
+ events: string[];
63
67
  }
64
68
  export declare function runTest(scene: SceneDef, test: TestCase, options?: WorldOptions): TestResult;
65
69
  export {};
@@ -5,7 +5,7 @@ import { EVENT_TYPES } from "./types.js";
5
5
  import { World } from "./world.js";
6
6
  const OPERATORS = ["<=", ">=", "==", "!=", "<", ">"];
7
7
  const ENTITY_FIELDS = ["x", "y", "vx", "vy", "grounded", "exists"];
8
- const WORLD_FIELDS = ["score", "deaths", "won"];
8
+ const WORLD_FIELDS = ["score", "deaths", "won", "lost", "lives"];
9
9
  export class AssertionSyntaxError extends Error {
10
10
  }
11
11
  const EXAMPLES = 'Examples: "player.x > 400", "player.grounded == true at 1s", "score >= 3", "won == true", "deaths == 0", "events.jump == 1 at end", "coin1.exists == false", "door.state.open == true", "boss.state.phase == \\"angry\\"", "events.shoot >= 3".';
@@ -65,6 +65,9 @@ export function evaluateAssertion(assertion, snapshot, events) {
65
65
  case "world":
66
66
  actual = snapshot[subject.field];
67
67
  label = subject.field;
68
+ if (actual === undefined) {
69
+ return { source, pass: false, actual, time, message: `${label} is not set: the scene has unlimited retries. Add "lives" to "world" to count them.` };
70
+ }
68
71
  break;
69
72
  case "events": {
70
73
  const matches = (e) => (subject.builtin ? e.type === subject.name : e.type === "custom" && e.name === subject.name);
@@ -160,7 +163,7 @@ export function runTest(scene, test, options = {}) {
160
163
  : Math.max(secondsToFrames(defaultDuration(script)), latestAt);
161
164
  }
162
165
  catch (err) {
163
- return { name: test.name, pass: false, duration: 0, results: [], error: err.message };
166
+ return { name: test.name, pass: false, duration: 0, results: [], error: err.message, events: [] };
164
167
  }
165
168
  const wanted = new Set(assertions.map((a) => (a.at === "end" ? frames : Math.min(a.at, frames))));
166
169
  const snapshots = new Map();
@@ -174,11 +177,12 @@ export function runTest(scene, test, options = {}) {
174
177
  }
175
178
  catch (err) {
176
179
  // A throwing script fails this test, not the whole run.
177
- return { name: test.name, pass: false, duration: frames * FIXED_DT, results: [], error: err.message };
180
+ return { name: test.name, pass: false, duration: frames * FIXED_DT, results: [], error: err.message, events: [] };
178
181
  }
179
182
  const results = assertions.map((a) => {
180
183
  const frame = a.at === "end" ? frames : Math.min(a.at, frames);
181
184
  return evaluateAssertion(a, snapshots.get(frame), world.events);
182
185
  });
183
- return { name: test.name, pass: results.every((r) => r.pass), duration: frames * FIXED_DT, results };
186
+ const events = [...new Set(world.events.map((e) => (e.type === "custom" ? e.name : e.type)))];
187
+ return { name: test.name, pass: results.every((r) => r.pass), duration: frames * FIXED_DT, results, events };
184
188
  }
package/dist/browser.d.ts CHANGED
@@ -6,14 +6,21 @@ export interface GameOptions {
6
6
  scriptBase?: string;
7
7
  /** Scripts to load. Defaults to those the scene references; include any that `world.spawn` uses. */
8
8
  scriptPaths?: readonly string[];
9
+ /**
10
+ * Loads another scene by path, for goals with `next` and `world.goto`. Without it the
11
+ * game stays on its first scene. Throw with a readable message if the scene is invalid.
12
+ */
13
+ loadScene?: (path: string) => Promise<SceneDef>;
9
14
  }
10
15
  /**
11
16
  * Runs a scene in a canvas with keyboard controls, images, particles and
12
17
  * sound. The simulation uses the same fixed step as headless runs, so what
13
18
  * tests verify is what players get; everything added here is presentation.
19
+ * With `options.loadScene`, won goals with `next` and `world.goto` move on
20
+ * to other scenes, keeping score, deaths and lives.
14
21
  * Resolves (after images load) to a function that stops the game.
15
22
  */
16
- export declare function startGame(canvas: HTMLCanvasElement, scene: SceneDef, options?: GameOptions): Promise<() => void>;
23
+ export declare function startGame(canvas: HTMLCanvasElement, firstScene: SceneDef, options?: GameOptions): Promise<() => void>;
17
24
  export interface CaptureOptions extends GameOptions {
18
25
  /** Input script, e.g. `"right:1s, jump:0.4s"`. See `InputScript`. */
19
26
  inputs?: string;
package/dist/browser.js CHANGED
@@ -74,63 +74,149 @@ async function loadFonts(scene, assetBase) {
74
74
  const all = Promise.all(fonts.map((font) => load(font).catch((err) => console.warn(`[ludra] could not load font "${font[0]}": ${err}`))));
75
75
  await Promise.race([all, new Promise((resolve) => setTimeout(resolve, 4000))]);
76
76
  }
77
+ /** A scene's postfx shader source, or why it couldn't be fetched. */
78
+ async function loadShader(scene, assetBase) {
79
+ const path = scene.postfx?.shader;
80
+ if (!path)
81
+ return undefined;
82
+ const response = await fetch(assetBase + path).catch((err) => err);
83
+ if (response instanceof Error)
84
+ return response;
85
+ return response.ok ? response.text() : new Error(`could not load ${path} (${response.status})`);
86
+ }
77
87
  /**
78
- * Loads everything a scene needs and sets up its canvases. With `postfx`, `canvas`
79
- * becomes the offscreen source and a WebGL canvas takes its place in the page.
88
+ * The canvas players see. The game always draws into the 2D canvas; when a scene
89
+ * has `postfx`, a WebGL canvas running its shader takes the 2D canvas's place in
90
+ * the page, and goes away again for a scene without one.
80
91
  */
81
- async function prepare(canvas, scene, options) {
82
- const ctx = canvas.getContext("2d");
83
- if (!ctx)
84
- throw new Error("Canvas 2D context is not available.");
92
+ class Screen {
93
+ ctx;
94
+ output;
95
+ canvas;
96
+ fx;
97
+ constructor(canvas) {
98
+ const ctx = canvas.getContext("2d");
99
+ if (!ctx)
100
+ throw new Error("Canvas 2D context is not available.");
101
+ this.ctx = ctx;
102
+ this.canvas = canvas;
103
+ this.output = canvas;
104
+ }
105
+ /** Switches to a scene's shader, or to none. Returns why the shader can't run, if it can't. */
106
+ use(postfx, shader) {
107
+ let fx;
108
+ let error;
109
+ if (postfx && shader !== undefined) {
110
+ try {
111
+ if (shader instanceof Error)
112
+ throw shader;
113
+ fx = new PostFx(this.canvas.width, this.canvas.height, shader, postfx);
114
+ }
115
+ catch (err) {
116
+ error = `postfx: ${message(err)}`;
117
+ console.error("[ludra]", err);
118
+ }
119
+ }
120
+ const target = fx?.canvas ?? this.canvas;
121
+ if (target !== this.output) {
122
+ target.id = this.canvas.id;
123
+ target.className = this.canvas.className;
124
+ target.style.cssText = this.canvas.style.cssText;
125
+ this.output.replaceWith(target);
126
+ this.output = target;
127
+ }
128
+ this.fx = fx;
129
+ return error;
130
+ }
131
+ /** Shows the frame the game just drew, through the shader if there is one. */
132
+ present(time) {
133
+ this.fx?.render(this.canvas, time);
134
+ }
135
+ }
136
+ /** What a scene needs before it can be drawn. */
137
+ async function loadScene(scene, options, scripts) {
85
138
  const assetBase = options.assetBase ?? "";
86
- const shaderPath = scene.postfx?.shader;
87
- const [images, scripts, shader] = await Promise.all([
139
+ const [images, more, shader] = await Promise.all([
88
140
  loadImages(scene, assetBase),
89
141
  loadScripts(scene, options),
90
- shaderPath
91
- ? fetch(assetBase + shaderPath).then(async (r) => (r.ok ? r.text() : new Error(`could not load ${shaderPath} (${r.status})`)))
92
- : undefined,
142
+ loadShader(scene, assetBase),
93
143
  loadFonts(scene, assetBase),
94
144
  ]);
95
- const stage = { ctx, output: canvas, images, scripts, present: () => { } };
96
- if (scene.postfx && shader !== undefined) {
97
- try {
98
- if (shader instanceof Error)
99
- throw shader;
100
- const fx = new PostFx(canvas.width, canvas.height, shader, scene.postfx);
101
- fx.canvas.id = canvas.id;
102
- fx.canvas.className = canvas.className;
103
- fx.canvas.style.cssText = canvas.style.cssText;
104
- canvas.replaceWith(fx.canvas);
105
- stage.output = fx.canvas;
106
- stage.present = (time) => fx.render(canvas, time);
107
- }
108
- catch (err) {
109
- stage.error = `postfx: ${message(err)}`;
110
- console.error("[ludra]", err);
111
- }
112
- }
113
- return stage;
145
+ for (const [path, script] of more)
146
+ scripts.set(path, script);
147
+ return { images, shader };
114
148
  }
115
149
  function message(err) {
116
150
  return err instanceof Error ? err.message : String(err);
117
151
  }
152
+ /** Seconds the win screen shows before the next scene, and the fade into a world.goto scene. */
153
+ const WIN_PAUSE = 1.6;
154
+ const GOTO_FADE = 0.35;
118
155
  /**
119
156
  * Runs a scene in a canvas with keyboard controls, images, particles and
120
157
  * sound. The simulation uses the same fixed step as headless runs, so what
121
158
  * tests verify is what players get; everything added here is presentation.
159
+ * With `options.loadScene`, won goals with `next` and `world.goto` move on
160
+ * to other scenes, keeping score, deaths and lives.
122
161
  * Resolves (after images load) to a function that stops the game.
123
162
  */
124
- export async function startGame(canvas, scene, options = {}) {
125
- const { ctx, images, scripts, present, error } = await prepare(canvas, scene, options);
163
+ export async function startGame(canvas, firstScene, options = {}) {
164
+ const screen = new Screen(canvas);
165
+ const ctx = screen.ctx;
126
166
  const sounds = new SoundPlayer(options.assetBase ?? "");
127
- const view = new View(scene, images);
128
- let world = new World(scene, { scripts });
129
- let crash = error;
167
+ const scripts = new Map();
168
+ let scene = firstScene;
169
+ let startCarry;
170
+ let view;
171
+ let world;
172
+ let crash;
173
+ let sceneError;
174
+ /** Real time when the world asked to move on, while waiting to load the next scene. */
175
+ let exitSince;
176
+ let loading = false;
177
+ const enter = async (next, carry) => {
178
+ const { images, shader } = await loadScene(next, options, scripts);
179
+ scene = next;
180
+ startCarry = carry;
181
+ view = new View(next, images);
182
+ sceneError = screen.use(next.postfx, shader);
183
+ crash = sceneError;
184
+ exitSince = undefined;
185
+ try {
186
+ world = new World(next, { scripts, carry });
187
+ }
188
+ catch (err) {
189
+ crash = message(err);
190
+ world = new World({ ...next, entities: [] });
191
+ }
192
+ };
193
+ await enter(firstScene, undefined);
194
+ // Game over starts the whole game again; otherwise R restarts the current scene as it began.
130
195
  const restart = () => {
131
- world = new World(scene, { scripts });
196
+ if (loading)
197
+ return;
198
+ if (world.lost && scene !== firstScene) {
199
+ loading = true;
200
+ void enter(firstScene, undefined).finally(() => (loading = false));
201
+ return;
202
+ }
203
+ world = new World(scene, { scripts, carry: startCarry });
132
204
  view.reset();
133
- crash = error;
205
+ crash = sceneError;
206
+ exitSince = undefined;
207
+ };
208
+ const moveOn = (path) => {
209
+ if (!options.loadScene)
210
+ return;
211
+ loading = true;
212
+ const carry = { score: world.score, deaths: world.deaths, lives: world.lives };
213
+ options
214
+ .loadScene(path)
215
+ .then((next) => enter(next, carry))
216
+ .catch((err) => {
217
+ crash = `Could not load the next scene "${path}": ${message(err)}`;
218
+ })
219
+ .finally(() => (loading = false));
134
220
  };
135
221
  const held = new Set();
136
222
  // Keys pressed since the last step. A tap shorter than one frame still reaches the simulation.
@@ -166,7 +252,7 @@ export async function startGame(canvas, scene, options = {}) {
166
252
  const frame = (now) => {
167
253
  accumulator += Math.min(MAX_FRAME_TIME, (now - last) / 1000);
168
254
  last = now;
169
- while (accumulator >= FIXED_DT && !crash) {
255
+ while (accumulator >= FIXED_DT && !crash && !loading) {
170
256
  try {
171
257
  world.step(tapped.size > 0 ? new Set([...held, ...tapped]) : held);
172
258
  }
@@ -179,8 +265,18 @@ export async function startGame(canvas, scene, options = {}) {
179
265
  view.step(world, (sound) => sounds.play(sound));
180
266
  accumulator -= FIXED_DT;
181
267
  }
268
+ // Moving on: let the win screen show, or fade out for a world.goto, then load.
269
+ let fade = 0;
270
+ if (world.exit !== undefined && options.loadScene && !crash) {
271
+ exitSince ??= now;
272
+ const pause = world.won ? WIN_PAUSE : GOTO_FADE;
273
+ const elapsed = (now - exitSince) / 1000;
274
+ fade = Math.min(1, Math.max(0, (elapsed - (pause - GOTO_FADE)) / GOTO_FADE));
275
+ if (elapsed >= pause && !loading)
276
+ moveOn(world.exit);
277
+ }
182
278
  try {
183
- view.draw(ctx, world, sounds.muted);
279
+ view.draw(ctx, world, sounds.muted, options.loadScene !== undefined);
184
280
  }
185
281
  catch (err) {
186
282
  // A draw hook threw. Same treatment as a simulation error.
@@ -188,9 +284,13 @@ export async function startGame(canvas, scene, options = {}) {
188
284
  console.error("[ludra]", err);
189
285
  crash ??= message(err);
190
286
  }
287
+ if (fade > 0 || loading) {
288
+ ctx.fillStyle = `rgba(0, 0, 0, ${loading ? 1 : fade})`;
289
+ ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
290
+ }
191
291
  if (crash)
192
292
  drawCrash(ctx, crash);
193
- present(world.time);
293
+ screen.present(world.time);
194
294
  raf = requestAnimationFrame(frame);
195
295
  };
196
296
  raf = requestAnimationFrame(frame);
@@ -208,7 +308,10 @@ export async function startGame(canvas, scene, options = {}) {
208
308
  * `ludra play --screenshot`.
209
309
  */
210
310
  export async function captureFrames(canvas, scene, options) {
211
- const { ctx, output, images, scripts, present, error } = await prepare(canvas, scene, options);
311
+ const screen = new Screen(canvas);
312
+ const scripts = new Map();
313
+ const { images, shader } = await loadScene(scene, options, scripts);
314
+ const error = screen.use(scene.postfx, shader);
212
315
  if (error)
213
316
  throw new Error(error);
214
317
  const view = new View(scene, images);
@@ -219,9 +322,9 @@ export async function captureFrames(canvas, scene, options) {
219
322
  const captured = [];
220
323
  for (let frame = 0;; frame++) {
221
324
  if (wanted.has(frame)) {
222
- view.draw(ctx, world, false);
223
- present(world.time);
224
- captured.push({ frame, time: frame * FIXED_DT, png: output.toDataURL("image/png") });
325
+ view.draw(screen.ctx, world, false, false);
326
+ screen.present(world.time);
327
+ captured.push({ frame, time: frame * FIXED_DT, png: screen.output.toDataURL("image/png") });
225
328
  }
226
329
  if (frame >= last)
227
330
  break;
@@ -283,9 +386,12 @@ class View {
283
386
  handledEvents = 0;
284
387
  scene;
285
388
  images;
389
+ /** Whether the scene can award points at all, so the default HUD can leave the score out. */
390
+ scoring;
286
391
  constructor(scene, images) {
287
392
  this.scene = scene;
288
393
  this.images = images;
394
+ this.scoring = scene.entities.some((e) => e.components.collectible || (e.components.hazard && Number(e.components.hazard.score) > 0) || Number(e.components.goal?.requireScore) > 0);
289
395
  }
290
396
  reset() {
291
397
  this.particles.clear();
@@ -325,7 +431,8 @@ class View {
325
431
  this.particles.update(FIXED_DT);
326
432
  this.shake = this.shake < 0.5 ? 0 : this.shake * SHAKE_DECAY;
327
433
  }
328
- draw(ctx, world, muted) {
434
+ /** `continues`: the host loads next scenes, so a won scene with `next` says so instead of offering a replay. */
435
+ draw(ctx, world, muted, continues) {
329
436
  const { width: viewWidth, height: viewHeight } = ctx.canvas;
330
437
  ctx.imageSmoothingEnabled = false;
331
438
  ctx.fillStyle = world.background;
@@ -361,24 +468,34 @@ class View {
361
468
  this.drawHook(ctx, world, e, "drawUI", viewInfo);
362
469
  return;
363
470
  }
471
+ // Only what this scene uses: no "Score 0" in a game without points.
472
+ const parts = [];
473
+ if (world.lives !== undefined)
474
+ parts.push(`${"♥".repeat(Math.max(0, Math.min(world.lives, 10)))}${world.lives > 10 ? ` ${world.lives}` : ""}`);
475
+ if (this.scoring || world.score !== 0)
476
+ parts.push(`Score ${world.score}`);
477
+ if (world.lives === undefined)
478
+ parts.push(`Deaths ${world.deaths}`);
364
479
  ctx.textAlign = "left";
365
480
  ctx.font = "600 18px system-ui, sans-serif";
366
481
  ctx.fillStyle = "#ffffff";
367
- ctx.fillText(`Score ${world.score} Deaths ${world.deaths}`, 16, 30);
482
+ ctx.fillText(parts.join(" "), 16, 30);
368
483
  ctx.textAlign = "right";
369
484
  ctx.font = "500 13px system-ui, sans-serif";
370
485
  ctx.fillStyle = "rgba(255, 255, 255, 0.6)";
371
486
  ctx.fillText(`M: sound ${muted ? "off" : "on"} · R: restart`, viewWidth - 16, 28);
372
- if (world.won) {
487
+ if (world.won || world.lost) {
373
488
  ctx.fillStyle = "rgba(0, 0, 0, 0.55)";
374
489
  ctx.fillRect(0, 0, viewWidth, viewHeight);
375
490
  ctx.fillStyle = "#ffffff";
376
491
  ctx.textAlign = "center";
377
492
  ctx.font = "700 44px system-ui, sans-serif";
378
- ctx.fillText("Level complete!", viewWidth / 2, viewHeight / 2 - 10);
493
+ ctx.fillText(world.won ? "Level complete!" : "Game over", viewWidth / 2, viewHeight / 2 - 10);
379
494
  ctx.font = "500 18px system-ui, sans-serif";
380
- const winTime = world.events.find((e) => e.type === "win")?.time ?? world.time;
381
- ctx.fillText(`Score ${world.score} · ${winTime.toFixed(1)}s · Press R to play again`, viewWidth / 2, viewHeight / 2 + 30);
495
+ const endTime = world.events.find((e) => e.type === "win" || e.type === "gameover")?.time ?? world.time;
496
+ const next = world.won && world.exit !== undefined && continues ? "Get ready…" : `Press R to ${world.won ? "play again" : "try again"}`;
497
+ const score = this.scoring || world.score !== 0 ? `Score ${world.score} · ` : "";
498
+ ctx.fillText(`${score}${endTime.toFixed(1)}s · ${next}`, viewWidth / 2, viewHeight / 2 + 30);
382
499
  }
383
500
  }
384
501
  drawHook(ctx, world, e, hook, view) {
@@ -484,7 +601,10 @@ function recentEvents(world, name, seconds) {
484
601
  const eventName = event.type === "custom" ? event.name : event.type;
485
602
  if (name !== undefined && eventName !== name)
486
603
  continue;
487
- result.push({ name: eventName, time: event.time, x: event.x, y: event.y, entity: event.entity, data: event.type === "custom" ? event.data : undefined });
604
+ // Custom events carry what the script passed; built-in ones their own details (item, enemy, checkpoint...).
605
+ const { type: _type, frame: _frame, time, x, y, entity, ...details } = event;
606
+ const data = event.type === "custom" ? event.data : details;
607
+ result.push({ name: eventName, time, x, y, entity, data });
488
608
  }
489
609
  return result.reverse();
490
610
  }
@@ -35,7 +35,7 @@ export interface ComponentSpec {
35
35
  /** Fields without a default are required. */
36
36
  fields: Record<string, FieldSpec>;
37
37
  }
38
- export declare const COMPONENT_NAMES: readonly ["transform", "body", "sprite", "platformer", "topdown", "collectible", "patrol", "hazard", "goal", "emitter", "script", "camera"];
38
+ export declare const COMPONENT_NAMES: readonly ["transform", "body", "sprite", "platformer", "topdown", "collectible", "patrol", "hazard", "goal", "checkpoint", "emitter", "script", "camera"];
39
39
  export type ComponentName = (typeof COMPONENT_NAMES)[number];
40
40
  /** Shared by the `emitter` component and the particles of scene `effects`. Purely visual. */
41
41
  export declare const PARTICLE_FIELDS: Record<string, FieldSpec>;
@@ -119,6 +119,7 @@ export interface Hazard {
119
119
  }
120
120
  export interface Goal {
121
121
  requireScore: number;
122
+ next: string;
122
123
  }
123
124
  export interface Camera {
124
125
  zoom: number;
@@ -8,6 +8,7 @@ export const COMPONENT_NAMES = [
8
8
  "patrol",
9
9
  "hazard",
10
10
  "goal",
11
+ "checkpoint",
11
12
  "emitter",
12
13
  "script",
13
14
  "camera",
@@ -130,8 +131,18 @@ export const COMPONENTS = {
130
131
  requires: ["transform", "body"],
131
132
  fields: {
132
133
  requireScore: { type: "number", min: 0, default: 0, description: "Score needed before the goal accepts the player." },
134
+ next: {
135
+ type: "string",
136
+ default: "",
137
+ 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.',
138
+ },
133
139
  },
134
140
  },
141
+ checkpoint: {
142
+ description: "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.",
143
+ requires: ["transform", "body"],
144
+ fields: {},
145
+ },
135
146
  emitter: {
136
147
  description: "Continuously emits visual particles from the entity (torches, sparkles, smoke). It does not affect gameplay.",
137
148
  requires: ["transform"],
package/dist/effects.d.ts CHANGED
@@ -7,14 +7,14 @@ export declare const AUDIO_EXTENSIONS: readonly [".wav", ".mp3", ".ogg"];
7
7
  export declare const IMAGE_EXTENSIONS: readonly [".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp"];
8
8
  export declare const FONT_EXTENSIONS: readonly [".woff2", ".woff", ".ttf", ".otf"];
9
9
  export declare const EFFECT_FIELDS: Record<string, FieldSpec>;
10
- export declare const EFFECTS_DESCRIPTION = "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.";
10
+ export declare const EFFECTS_DESCRIPTION = "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.";
11
11
  export declare function isSoundPreset(sound: string): sound is SoundPreset;
12
12
  export interface AssetReference {
13
13
  /** Path relative to the project, as written in the scene. */
14
14
  path: string;
15
15
  /** JSON path of the field that references it. */
16
16
  at: string;
17
- kind: "image" | "audio" | "script" | "font" | "shader";
17
+ kind: "image" | "audio" | "script" | "font" | "shader" | "scene";
18
18
  }
19
19
  /** Every file a scene points at, so tools can check that they exist. */
20
20
  export declare function collectAssetPaths(scene: SceneDef): AssetReference[];
package/dist/effects.js CHANGED
@@ -11,7 +11,7 @@ export const EFFECT_FIELDS = {
11
11
  },
12
12
  shake: { type: "number", min: 0, default: 0, description: "Screen shake strength in pixels." },
13
13
  };
14
- export const EFFECTS_DESCRIPTION = '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.';
14
+ export const EFFECTS_DESCRIPTION = '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.';
15
15
  export function isSoundPreset(sound) {
16
16
  return SOUND_PRESETS.includes(sound);
17
17
  }
@@ -22,6 +22,9 @@ export function collectAssetPaths(scene) {
22
22
  const image = entity.components.sprite?.image;
23
23
  if (typeof image === "string" && image !== "")
24
24
  refs.push({ path: image, at: `entities[${i}].components.sprite.image`, kind: "image" });
25
+ const next = entity.components.goal?.next;
26
+ if (typeof next === "string" && next !== "")
27
+ refs.push({ path: next, at: `entities[${i}].components.goal.next`, kind: "scene" });
25
28
  const src = entity.components.script?.src;
26
29
  if (typeof src === "string" && src !== "")
27
30
  refs.push({ path: src, at: `entities[${i}].components.script.src`, kind: "script" });
package/dist/script.d.ts CHANGED
@@ -27,7 +27,9 @@ export interface ScriptEntity {
27
27
  export interface SpawnDef {
28
28
  /** Generated when left out. */
29
29
  id?: string;
30
- components: Record<string, Record<string, unknown>>;
30
+ /** Start from a prefab; `components` then only needs what differs (usually the transform). */
31
+ prefab?: string;
32
+ components?: Record<string, Record<string, unknown>>;
31
33
  }
32
34
  export interface ScriptWorld {
33
35
  readonly time: number;
@@ -39,6 +41,12 @@ export interface ScriptWorld {
39
41
  readonly deaths: number;
40
42
  /** True once the level is complete. */
41
43
  readonly won: boolean;
44
+ /** Lives left, or undefined when the scene has unlimited retries. Writable: an extra life is `world.lives += 1`. */
45
+ lives: number | undefined;
46
+ /** True once the game is over (out of lives, or world.lose()). */
47
+ readonly lost: boolean;
48
+ /** The scene this one hands over to (a won goal's next, or world.goto), once it does. */
49
+ readonly exit: string | undefined;
42
50
  get(id: string): ScriptEntity | undefined;
43
51
  /** Every entity, or only those with the given component. */
44
52
  all(component?: string): ScriptEntity[];
@@ -50,6 +58,14 @@ export interface ScriptWorld {
50
58
  kill(entity: ScriptEntity | string): void;
51
59
  /** Completes the level, like reaching a goal. */
52
60
  win(): void;
61
+ /** Ends the game as lost (a time limit ran out, the base was destroyed...). Fires "gameover". */
62
+ lose(): void;
63
+ /**
64
+ * Moves on to another scene, e.g. world.goto("scenes/level-1.scene.json") from a title
65
+ * screen. The current scene stops; the browser loads the next one, keeping score,
66
+ * deaths and lives. Fires "goto" (tests can assert events.goto).
67
+ */
68
+ goto(scene: string): void;
53
69
  /** Fires a custom event that tests can count (`events.<name>`) and `effects` can react to. */
54
70
  emit(name: string, data?: unknown): void;
55
71
  /** A number in [0, 1). Seeded by `world.seed`, so runs stay deterministic. Never use Math.random. */
@@ -79,6 +95,7 @@ export interface DrawEvent {
79
95
  y: number;
80
96
  /** The entity that caused it. */
81
97
  entity: string;
98
+ /** Custom events: what the script passed to world.emit. Built-in ones: their details, e.g. { checkpoint: "lantern" } or { item: "coin-1", score: 1 }. */
82
99
  data?: unknown;
83
100
  }
84
101
  /**
@@ -148,7 +165,7 @@ export interface Script {
148
165
  }
149
166
  export declare const SCRIPT_HOOKS: readonly ["start", "update", "touch", "draw", "drawUI"];
150
167
  /** Printed by `ludra components script`. Keep in sync with the interfaces above. */
151
- export declare const SCRIPT_API_REFERENCE = "A script file default-exports hooks. Only \"import type\" is allowed: scripts run without a bundler.\n\n import type { Script } from \"@ludra/core\";\n export default {\n start(ctx) {}, // once, on the entity's first frame\n update(ctx) {}, // every frame, after player input, before physics\n touch(ctx, other) {}, // when this entity starts touching another body (after physics)\n draw(ctx) {}, // paint this entity in world space (browser only, every rendered frame)\n drawUI(ctx) {}, // paint screen-space UI; replaces the default HUD and win screen\n } satisfies Script;\n\nctx.self the entity: id, x, y, vx, vy (read/write); grounded, width, height, exists (read);\n state (object kept between frames, testable as \"<id>.state.<key>\");\n has(\"component\"), touches(otherOrId)\nctx.world time, frame, width, height, deaths, won (read); score (read/write)\n get(id), all(component?), player()\n spawn({ id?, components }) -> entity destroy(entityOrId)\n kill(entityOrId) players respawn, others are destroyed\n win() completes the level\n emit(name, data?) custom event: counted by \"events.<name>\" in tests, usable as an effects key\n random() deterministic, seeded by world.seed. Never use Math.random or Date\nctx.input held(action), pressed(action) actions: left right up down jump action\nctx.params the \"params\" object from the script component\nctx.dt seconds per frame (1/60)\n\nDrawing: draw and drawUI get the real Canvas 2D context, so there are no limits on looks.\nctx.g CanvasRenderingContext2D: gradients, shadowBlur glow, globalCompositeOperation (\"lighter\" for\n additive light), paths, text with any loaded font, ctx.g.filter, drawImage...\n draw: origin is the world (use self.x/self.y); drawUI: origin is the screen top-left\nctx.time simulation seconds; animate with it so screenshots are reproducible\nctx.local per-entity presentation memory (trails, tweens); not game state\nctx.view width, height, cameraX, cameraY, zoom\nctx.recent(name?, seconds = 0.5) recent events [{ name, time, x, y, entity, data }]: flash on hit, pop scores\nctx.image(path) a loaded image (sprite images and string params ending in .svg/.png/... are preloaded)\nctx.burst(x, y, particles?) ctx.shake(px) ctx.random() presentation-only helpers\nself/world/params as in update, but read-only: changing the game in a draw hook is an error.\n\nScene-level presentation (all optional):\n \"fonts\": { \"Orbitron\": \"google\", \"Pixel\": \"assets/pixel.woff2\" } load fonts for ctx.g.font\n \"postfx\": { \"shader\": \"shaders/crt.frag\", \"uniforms\": { \"strength\": 0.6 } }\n a GLSL ES 1.0 fragment shader over the whole frame. It gets: uniform sampler2D u_image;\n uniform vec2 u_resolution; uniform float u_time; varying vec2 v_uv; plus each uniform as a float.\n\nScripts in scripts/ are loaded even if no entity uses them, so world.spawn can attach them.\nErrors name the file, entity, hook and frame.";
168
+ export declare const SCRIPT_API_REFERENCE = "A script file default-exports hooks. Only \"import type\" is allowed: scripts run without a bundler.\n\n import type { Script } from \"@ludra/core\";\n export default {\n start(ctx) {}, // once, on the entity's first frame\n update(ctx) {}, // every frame, after player input, before physics\n touch(ctx, other) {}, // when this entity starts touching another body (after physics)\n draw(ctx) {}, // paint this entity in world space (browser only, every rendered frame)\n drawUI(ctx) {}, // paint screen-space UI; replaces the default HUD and win screen\n } satisfies Script;\n\nctx.self the entity: id, x, y, vx, vy (read/write); grounded, width, height, exists (read);\n state (object kept between frames, testable as \"<id>.state.<key>\");\n has(\"component\"), touches(otherOrId)\nctx.world time, frame, width, height, deaths, won, lost, exit (read); score, lives (read/write)\n get(id), all(component?), player()\n spawn({ id?, prefab?, components? }) -> entity destroy(entityOrId)\n kill(entityOrId) players respawn, others are destroyed\n win() completes the level lose() game over\n goto(scenePath) moves on to another scene (title -> level 1, a door to the next room)\n emit(name, data?) custom event: counted by \"events.<name>\" in tests, usable as an effects key\n random() deterministic, seeded by world.seed. Never use Math.random or Date\nctx.input held(action), pressed(action) actions: left right up down jump action\nctx.params the \"params\" object from the script component\nctx.dt seconds per frame (1/60)\n\nDrawing: draw and drawUI get the real Canvas 2D context, so there are no limits on looks.\nctx.g CanvasRenderingContext2D: gradients, shadowBlur glow, globalCompositeOperation (\"lighter\" for\n additive light), paths, text with any loaded font, ctx.g.filter, drawImage...\n draw: origin is the world (use self.x/self.y); drawUI: origin is the screen top-left\nctx.time simulation seconds; animate with it so screenshots are reproducible\nctx.local per-entity presentation memory (trails, tweens); not game state\nctx.view width, height, cameraX, cameraY, zoom\nctx.recent(name?, seconds = 0.5) recent events [{ name, time, x, y, entity, data }]: flash on hit, pop scores\nctx.image(path) a loaded image (sprite images and string params ending in .svg/.png/... are preloaded)\nctx.burst(x, y, particles?) ctx.shake(px) ctx.random() presentation-only helpers\nself/world/params as in update, but read-only: changing the game in a draw hook is an error.\n\nScene-level presentation (all optional):\n \"fonts\": { \"Orbitron\": \"google\", \"Pixel\": \"assets/pixel.woff2\" } load fonts for ctx.g.font\n \"postfx\": { \"shader\": \"shaders/crt.frag\", \"uniforms\": { \"strength\": 0.6 } }\n a GLSL ES 1.0 fragment shader over the whole frame. It gets: uniform sampler2D u_image;\n uniform vec2 u_resolution; uniform float u_time; varying vec2 v_uv; plus each uniform as a float.\n\nScripts in scripts/ are loaded even if no entity uses them, so world.spawn can attach them.\nErrors name the file, entity, hook and frame.";
152
169
  export type ScriptModules = ReadonlyMap<string, Script> | Readonly<Record<string, Script>>;
153
170
  export declare function getScript(modules: ScriptModules | undefined, src: string): Script | undefined;
154
171
  /** A script threw, or was used wrongly. Names the file, entity, hook and frame. */
package/dist/script.js CHANGED
@@ -15,11 +15,12 @@ export const SCRIPT_API_REFERENCE = `A script file default-exports hooks. Only "
15
15
  ctx.self the entity: id, x, y, vx, vy (read/write); grounded, width, height, exists (read);
16
16
  state (object kept between frames, testable as "<id>.state.<key>");
17
17
  has("component"), touches(otherOrId)
18
- ctx.world time, frame, width, height, deaths, won (read); score (read/write)
18
+ ctx.world time, frame, width, height, deaths, won, lost, exit (read); score, lives (read/write)
19
19
  get(id), all(component?), player()
20
- spawn({ id?, components }) -> entity destroy(entityOrId)
20
+ spawn({ id?, prefab?, components? }) -> entity destroy(entityOrId)
21
21
  kill(entityOrId) players respawn, others are destroyed
22
- win() completes the level
22
+ win() completes the level lose() game over
23
+ goto(scenePath) moves on to another scene (title -> level 1, a door to the next room)
23
24
  emit(name, data?) custom event: counted by "events.<name>" in tests, usable as an effects key
24
25
  random() deterministic, seeded by world.seed. Never use Math.random or Date
25
26
  ctx.input held(action), pressed(action) actions: left right up down jump action
package/dist/types.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type Action = "left" | "right" | "up" | "down" | "jump" | "action";
2
2
  export declare const ACTIONS: readonly Action[];
3
- export declare const EVENT_TYPES: readonly ["jump", "land", "collect", "die", "stomp", "win", "bounce"];
3
+ export declare const EVENT_TYPES: readonly ["jump", "land", "collect", "die", "stomp", "win", "bounce", "checkpoint", "gameover", "goto"];
4
4
  export type GameEventType = (typeof EVENT_TYPES)[number];
5
5
  /** What happens visually and audibly when a game event fires. See `EFFECT_FIELDS`. */
6
6
  export interface EffectDef {
@@ -17,6 +17,8 @@ export interface PostFxDef {
17
17
  /** An entity as written in a scene file. */
18
18
  export interface EntityDef {
19
19
  id: string;
20
+ /** Name of a prefab to start from. Gone after validation: its components are merged in. */
21
+ prefab?: string;
20
22
  components: Record<string, Record<string, unknown>>;
21
23
  }
22
24
  export interface WorldSettings {
@@ -28,6 +30,13 @@ export interface WorldSettings {
28
30
  background?: string;
29
31
  /** Seed for `world.random()` in scripts. Defaults to 1. */
30
32
  seed?: number;
33
+ /** Lives before game over. Leave out for unlimited retries. */
34
+ lives?: number;
35
+ }
36
+ /** A reusable entity template: `prefabs/<name>.prefab.json` or the scene's `prefabs` map. */
37
+ export interface PrefabDef {
38
+ description?: string;
39
+ components: Record<string, Record<string, unknown>>;
31
40
  }
32
41
  /** A scene file (`*.scene.json`). */
33
42
  export interface SceneDef {
@@ -45,6 +54,12 @@ export interface SceneDef {
45
54
  fonts?: Record<string, string>;
46
55
  /** A GLSL fragment shader applied to every rendered frame. Presentation only. */
47
56
  postfx?: PostFxDef;
57
+ /**
58
+ * Entity templates by name. Entities use one with `"prefab": "<name>"` and override
59
+ * components; scripts spawn them with `world.spawn({ prefab: "<name>" })`. The CLI
60
+ * adds every `prefabs/*.prefab.json` file here.
61
+ */
62
+ prefabs?: Record<string, PrefabDef>;
48
63
  entities: EntityDef[];
49
64
  }
50
65
  export interface Diagnostic {
package/dist/types.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export const ACTIONS = ["left", "right", "up", "down", "jump", "action"];
2
- export const EVENT_TYPES = ["jump", "land", "collect", "die", "stomp", "win", "bounce"];
2
+ export const EVENT_TYPES = ["jump", "land", "collect", "die", "stomp", "win", "bounce", "checkpoint", "gameover", "goto"];