@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/dist/ascii.js CHANGED
@@ -39,8 +39,8 @@ export function renderAscii(world, options = {}) {
39
39
  ["G", (e) => !!e.goal],
40
40
  ["^", (e) => !!e.hazard && e.body.type !== "kinematic"],
41
41
  ["E", (e) => !!e.hazard && e.body.type === "kinematic"],
42
- ["D", (e) => e.body.type === "dynamic" && !e.platformer],
43
- ["@", (e) => !!e.platformer],
42
+ ["D", (e) => e.body.type === "dynamic" && !e.platformer && !e.topdown],
43
+ ["@", (e) => !!(e.platformer || e.topdown)],
44
44
  ];
45
45
  for (const [char, test] of passes) {
46
46
  for (const e of withBody.filter(test))
@@ -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
@@ -11,6 +11,7 @@ import { parseFrames } from "./components.js";
11
11
  import { collectAssetPaths } from "./effects.js";
12
12
  import { InputScript } from "./input.js";
13
13
  import { ParticleSystem } from "./particles.js";
14
+ import { PostFx } from "./postfx.js";
14
15
  import { FIXED_DT } from "./time.js";
15
16
  import { World } from "./world.js";
16
17
  const KEYMAP = {
@@ -44,26 +45,178 @@ async function loadScripts(scene, options) {
44
45
  }));
45
46
  return scripts;
46
47
  }
48
+ /** Fonts load from Google Fonts or project files; a slow or offline font never blocks the game for long. */
49
+ async function loadFonts(scene, assetBase) {
50
+ const fonts = Object.entries(scene.fonts ?? {});
51
+ if (fonts.length === 0)
52
+ return;
53
+ const load = async ([family, src]) => {
54
+ if (src === "google") {
55
+ const name = encodeURIComponent(family).replace(/%20/g, "+");
56
+ // Not every family has every weight, and Google rejects the request if one is missing.
57
+ for (const weights of [":wght@400;500;600;700;800;900", ":wght@400;700", ""]) {
58
+ const response = await fetch(`https://fonts.googleapis.com/css2?family=${name}${weights}&display=block`);
59
+ if (!response.ok)
60
+ continue;
61
+ const style = document.createElement("style");
62
+ style.textContent = await response.text();
63
+ document.head.append(style);
64
+ break;
65
+ }
66
+ await Promise.all(["400", "700", "900"].map((weight) => document.fonts.load(`${weight} 16px "${family}"`)));
67
+ }
68
+ else {
69
+ const face = new FontFace(family, `url("${assetBase}${src}")`);
70
+ // FontFaceSet.add is missing from some TypeScript DOM typings.
71
+ document.fonts.add(await face.load());
72
+ }
73
+ };
74
+ const all = Promise.all(fonts.map((font) => load(font).catch((err) => console.warn(`[ludra] could not load font "${font[0]}": ${err}`))));
75
+ await Promise.race([all, new Promise((resolve) => setTimeout(resolve, 4000))]);
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
+ }
87
+ /**
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.
91
+ */
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) {
138
+ const assetBase = options.assetBase ?? "";
139
+ const [images, more, shader] = await Promise.all([
140
+ loadImages(scene, assetBase),
141
+ loadScripts(scene, options),
142
+ loadShader(scene, assetBase),
143
+ loadFonts(scene, assetBase),
144
+ ]);
145
+ for (const [path, script] of more)
146
+ scripts.set(path, script);
147
+ return { images, shader };
148
+ }
149
+ function message(err) {
150
+ return err instanceof Error ? err.message : String(err);
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;
47
155
  /**
48
156
  * Runs a scene in a canvas with keyboard controls, images, particles and
49
157
  * sound. The simulation uses the same fixed step as headless runs, so what
50
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.
51
161
  * Resolves (after images load) to a function that stops the game.
52
162
  */
53
- export async function startGame(canvas, scene, options = {}) {
54
- const ctx = canvas.getContext("2d");
55
- if (!ctx)
56
- throw new Error("Canvas 2D context is not available.");
57
- const assetBase = options.assetBase ?? "";
58
- const [images, scripts] = await Promise.all([loadImages(scene, assetBase), loadScripts(scene, options)]);
59
- const sounds = new SoundPlayer(assetBase);
60
- const view = new View(scene, images);
61
- let world = new World(scene, { scripts });
163
+ export async function startGame(canvas, firstScene, options = {}) {
164
+ const screen = new Screen(canvas);
165
+ const ctx = screen.ctx;
166
+ const sounds = new SoundPlayer(options.assetBase ?? "");
167
+ const scripts = new Map();
168
+ let scene = firstScene;
169
+ let startCarry;
170
+ let view;
171
+ let world;
62
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.
63
195
  const restart = () => {
64
- 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 });
65
204
  view.reset();
66
- crash = undefined;
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));
67
220
  };
68
221
  const held = new Set();
69
222
  // Keys pressed since the last step. A tap shorter than one frame still reaches the simulation.
@@ -99,22 +252,45 @@ export async function startGame(canvas, scene, options = {}) {
99
252
  const frame = (now) => {
100
253
  accumulator += Math.min(MAX_FRAME_TIME, (now - last) / 1000);
101
254
  last = now;
102
- while (accumulator >= FIXED_DT && !crash) {
255
+ while (accumulator >= FIXED_DT && !crash && !loading) {
103
256
  try {
104
257
  world.step(tapped.size > 0 ? new Set([...held, ...tapped]) : held);
105
258
  }
106
259
  catch (err) {
107
260
  // A script threw: freeze the game and show why, instead of failing silently.
108
- crash = err instanceof Error ? err.message : String(err);
261
+ crash = message(err);
109
262
  console.error("[ludra]", err);
110
263
  }
111
264
  tapped.clear();
112
265
  view.step(world, (sound) => sounds.play(sound));
113
266
  accumulator -= FIXED_DT;
114
267
  }
115
- view.draw(ctx, world, sounds.muted);
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
+ }
278
+ try {
279
+ view.draw(ctx, world, sounds.muted, options.loadScene !== undefined);
280
+ }
281
+ catch (err) {
282
+ // A draw hook threw. Same treatment as a simulation error.
283
+ if (!crash)
284
+ console.error("[ludra]", err);
285
+ crash ??= message(err);
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
+ }
116
291
  if (crash)
117
292
  drawCrash(ctx, crash);
293
+ screen.present(world.time);
118
294
  raf = requestAnimationFrame(frame);
119
295
  };
120
296
  raf = requestAnimationFrame(frame);
@@ -132,10 +308,12 @@ export async function startGame(canvas, scene, options = {}) {
132
308
  * `ludra play --screenshot`.
133
309
  */
134
310
  export async function captureFrames(canvas, scene, options) {
135
- const ctx = canvas.getContext("2d");
136
- if (!ctx)
137
- throw new Error("Canvas 2D context is not available.");
138
- const [images, scripts] = await Promise.all([loadImages(scene, options.assetBase ?? ""), loadScripts(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);
315
+ if (error)
316
+ throw new Error(error);
139
317
  const view = new View(scene, images);
140
318
  const world = new World(scene, { scripts });
141
319
  const script = InputScript.parse(options.inputs ?? "");
@@ -144,8 +322,9 @@ export async function captureFrames(canvas, scene, options) {
144
322
  const captured = [];
145
323
  for (let frame = 0;; frame++) {
146
324
  if (wanted.has(frame)) {
147
- view.draw(ctx, world, false);
148
- captured.push({ frame, time: frame * FIXED_DT, png: canvas.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") });
149
328
  }
150
329
  if (frame >= last)
151
330
  break;
@@ -201,18 +380,24 @@ class View {
201
380
  facing = new Map();
202
381
  emitTimers = new Map();
203
382
  frameCache = new Map();
383
+ /** ctx.local for each entity's draw hooks. */
384
+ local = new Map();
204
385
  shake = 0;
205
386
  handledEvents = 0;
206
387
  scene;
207
388
  images;
389
+ /** Whether the scene can award points at all, so the default HUD can leave the score out. */
390
+ scoring;
208
391
  constructor(scene, images) {
209
392
  this.scene = scene;
210
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);
211
395
  }
212
396
  reset() {
213
397
  this.particles.clear();
214
398
  this.facing.clear();
215
399
  this.emitTimers.clear();
400
+ this.local.clear();
216
401
  this.shake = 0;
217
402
  this.handledEvents = 0;
218
403
  }
@@ -246,12 +431,13 @@ class View {
246
431
  this.particles.update(FIXED_DT);
247
432
  this.shake = this.shake < 0.5 ? 0 : this.shake * SHAKE_DECAY;
248
433
  }
249
- 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) {
250
436
  const { width: viewWidth, height: viewHeight } = ctx.canvas;
251
437
  ctx.imageSmoothingEnabled = false;
252
438
  ctx.fillStyle = world.background;
253
439
  ctx.fillRect(0, 0, viewWidth, viewHeight);
254
- const target = world.entities.find((e) => e.camera) ?? world.entities.find((e) => e.platformer);
440
+ const target = world.entities.find((e) => e.camera) ?? world.entities.find((e) => e.platformer || e.topdown);
255
441
  const zoom = target?.camera?.zoom ?? 1;
256
442
  const visibleWidth = viewWidth / zoom;
257
443
  const visibleHeight = viewHeight / zoom;
@@ -261,32 +447,80 @@ class View {
261
447
  cameraX += (this.particles.random() - 0.5) * 2 * this.shake;
262
448
  cameraY += (this.particles.random() - 0.5) * 2 * this.shake;
263
449
  }
450
+ const viewInfo = { width: viewWidth, height: viewHeight, cameraX, cameraY, zoom };
264
451
  ctx.save();
265
452
  ctx.scale(zoom, zoom);
266
453
  ctx.translate(-cameraX, -cameraY);
267
- const visible = world.entities.filter((e) => e.sprite).sort((a, b) => a.sprite.layer - b.sprite.layer);
268
- for (const e of visible)
269
- this.drawSprite(ctx, e, world.time);
454
+ const layer = (e) => e.sprite?.layer ?? 0;
455
+ const visible = world.entities.filter((e) => e.sprite || e.script?.module.draw).sort((a, b) => layer(a) - layer(b));
456
+ for (const e of visible) {
457
+ if (e.sprite)
458
+ this.drawSprite(ctx, e, world.time);
459
+ if (e.script?.module.draw)
460
+ this.drawHook(ctx, world, e, "draw", viewInfo);
461
+ }
270
462
  this.particles.draw(ctx);
271
463
  ctx.restore();
464
+ if (world.hasHook("drawUI")) {
465
+ // The game draws its own interface.
466
+ for (const e of [...world.entities])
467
+ if (e.script?.module.drawUI)
468
+ this.drawHook(ctx, world, e, "drawUI", viewInfo);
469
+ return;
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}`);
272
479
  ctx.textAlign = "left";
273
480
  ctx.font = "600 18px system-ui, sans-serif";
274
481
  ctx.fillStyle = "#ffffff";
275
- ctx.fillText(`Score ${world.score} Deaths ${world.deaths}`, 16, 30);
482
+ ctx.fillText(parts.join(" "), 16, 30);
276
483
  ctx.textAlign = "right";
277
484
  ctx.font = "500 13px system-ui, sans-serif";
278
485
  ctx.fillStyle = "rgba(255, 255, 255, 0.6)";
279
486
  ctx.fillText(`M: sound ${muted ? "off" : "on"} · R: restart`, viewWidth - 16, 28);
280
- if (world.won) {
487
+ if (world.won || world.lost) {
281
488
  ctx.fillStyle = "rgba(0, 0, 0, 0.55)";
282
489
  ctx.fillRect(0, 0, viewWidth, viewHeight);
283
490
  ctx.fillStyle = "#ffffff";
284
491
  ctx.textAlign = "center";
285
492
  ctx.font = "700 44px system-ui, sans-serif";
286
- ctx.fillText("Level complete!", viewWidth / 2, viewHeight / 2 - 10);
493
+ ctx.fillText(world.won ? "Level complete!" : "Game over", viewWidth / 2, viewHeight / 2 - 10);
287
494
  ctx.font = "500 18px system-ui, sans-serif";
288
- const winTime = world.events.find((e) => e.type === "win")?.time ?? world.time;
289
- 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);
499
+ }
500
+ }
501
+ drawHook(ctx, world, e, hook, view) {
502
+ let local = this.local.get(e.id);
503
+ if (!local)
504
+ this.local.set(e.id, (local = {}));
505
+ ctx.save();
506
+ try {
507
+ world.callDrawHook(e, hook, {
508
+ g: ctx,
509
+ time: world.time,
510
+ local,
511
+ view,
512
+ recent: (name, seconds = 0.5) => recentEvents(world, name, seconds),
513
+ image: (path) => this.images.get(path),
514
+ burst: (x, y, particles = {}) => this.particles.burst(x, y, particles),
515
+ shake: (amount) => {
516
+ this.shake = Math.max(this.shake, amount);
517
+ },
518
+ random: () => this.particles.random(),
519
+ });
520
+ }
521
+ finally {
522
+ // Whatever the hook changed (transform, alpha, blend mode, filter) ends with it.
523
+ ctx.restore();
290
524
  }
291
525
  }
292
526
  drawSprite(ctx, e, time) {
@@ -356,6 +590,24 @@ class View {
356
590
  return [0];
357
591
  }
358
592
  }
593
+ function recentEvents(world, name, seconds) {
594
+ const since = world.time - seconds;
595
+ const result = [];
596
+ // Events are in time order, so walk back until they get too old.
597
+ for (let i = world.events.length - 1; i >= 0; i--) {
598
+ const event = world.events[i];
599
+ if (event.time < since)
600
+ break;
601
+ const eventName = event.type === "custom" ? event.name : event.type;
602
+ if (name !== undefined && eventName !== name)
603
+ continue;
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 });
608
+ }
609
+ return result.reverse();
610
+ }
359
611
  function animationState(e) {
360
612
  const b = e.body;
361
613
  if (!b)
@@ -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", "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>;
@@ -55,6 +55,7 @@ export interface Body {
55
55
  height: number;
56
56
  sensor: boolean;
57
57
  gravityScale: number;
58
+ bounce: number;
58
59
  vx: number;
59
60
  vy: number;
60
61
  }
@@ -99,6 +100,11 @@ export interface Platformer {
99
100
  jumpBuffer: number;
100
101
  variableJump: boolean;
101
102
  }
103
+ export interface Topdown {
104
+ speed: number;
105
+ directions: "8" | "4" | "x" | "y";
106
+ clampToWorld: boolean;
107
+ }
102
108
  export interface Collectible {
103
109
  score: number;
104
110
  }
@@ -113,6 +119,7 @@ export interface Hazard {
113
119
  }
114
120
  export interface Goal {
115
121
  requireScore: number;
122
+ next: string;
116
123
  }
117
124
  export interface Camera {
118
125
  zoom: number;
@@ -3,10 +3,12 @@ export const COMPONENT_NAMES = [
3
3
  "body",
4
4
  "sprite",
5
5
  "platformer",
6
+ "topdown",
6
7
  "collectible",
7
8
  "patrol",
8
9
  "hazard",
9
10
  "goal",
11
+ "checkpoint",
10
12
  "emitter",
11
13
  "script",
12
14
  "camera",
@@ -44,6 +46,12 @@ export const COMPONENTS = {
44
46
  height: { type: "number", min: 1, description: "Collider height in pixels." },
45
47
  sensor: { type: "boolean", default: false, description: "Sensors detect overlaps but never block movement (coins, triggers)." },
46
48
  gravityScale: { type: "number", default: 1, description: "Multiplier on world gravity for dynamic bodies." },
49
+ bounce: {
50
+ type: "number",
51
+ min: 0,
52
+ default: 0,
53
+ description: "How much speed a dynamic body keeps when it hits a solid: 0 stops it, 1 bounces it back at full speed (balls, pinballs).",
54
+ },
47
55
  vx: { type: "number", default: 0, description: "Initial horizontal velocity in px/s." },
48
56
  vy: { type: "number", default: 0, description: "Initial vertical velocity in px/s." },
49
57
  },
@@ -80,8 +88,22 @@ export const COMPONENTS = {
80
88
  variableJump: { type: "boolean", default: true, description: "Releasing jump early cuts the jump short." },
81
89
  },
82
90
  },
91
+ topdown: {
92
+ description: "Player control seen from above or for vehicles: moves with the arrow keys, no gravity. directions picks 8-way, 4-way, or one axis (\"x\" for a breakout paddle or a shooter ship). Needs a dynamic body (collides with walls) or a kinematic one (passes through, use clampToWorld).",
93
+ requires: ["transform", "body"],
94
+ fields: {
95
+ speed: { type: "number", min: 0, default: 200, description: "Move speed in px/s." },
96
+ directions: {
97
+ type: "enum",
98
+ values: ["8", "4", "x", "y"],
99
+ default: "8",
100
+ description: '"8": any direction (diagonals keep the same speed). "4": up/down/left/right only. "x" or "y": one axis.',
101
+ },
102
+ clampToWorld: { type: "boolean", default: true, description: "Keep the entity inside the world bounds." },
103
+ },
104
+ },
83
105
  collectible: {
84
- description: "Removed when a platformer entity touches it and adds to the score. Its body must be a sensor.",
106
+ description: "Removed when a player (platformer or topdown) touches it and adds to the score. Its body must be a sensor.",
85
107
  requires: ["transform", "body"],
86
108
  fields: {
87
109
  score: { type: "number", default: 1, description: "Points added when collected." },
@@ -109,8 +131,18 @@ export const COMPONENTS = {
109
131
  requires: ["transform", "body"],
110
132
  fields: {
111
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
+ },
112
139
  },
113
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
+ },
114
146
  emitter: {
115
147
  description: "Continuously emits visual particles from the entity (torches, sparkles, smoke). It does not affect gameplay.",
116
148
  requires: ["transform"],
@@ -0,0 +1,7 @@
1
+ import { type ScriptModules } from "./script.ts";
2
+ import type { Diagnostic, SceneDef } from "./types.ts";
3
+ /**
4
+ * Runs every `draw` and `drawUI` hook against a mock canvas at a few points of
5
+ * an idle run, and reports hooks that throw. Used by `ludra check`.
6
+ */
7
+ export declare function checkDrawHooks(scene: SceneDef, scripts: ScriptModules): Diagnostic[];