@ludra/core 0.5.0 → 0.7.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/world.js CHANGED
@@ -23,6 +23,8 @@ const STOMP_TOLERANCE = 8;
23
23
  const FALL_MARGIN = 100;
24
24
  /** Bodies this close count as touching for `touch` hooks, so solids (doors, walls) can be touched too. */
25
25
  const TOUCH_MARGIN = 1;
26
+ /** Impacts slower than this (px/s, after bounce) stop instead of bouncing. */
27
+ const MIN_BOUNCE_SPEED = 40;
26
28
  const EPSILON = 1e-4;
27
29
  /**
28
30
  * The simulation. It advances only through `step`, one fixed frame at a time,
@@ -50,6 +52,8 @@ export class World {
50
52
  scriptInput = { held: () => false, pressed: () => false };
51
53
  /** The script hook currently running, for error messages and event attribution. */
52
54
  current;
55
+ /** True while a draw hook runs: the game can't be changed then. */
56
+ drawing = false;
53
57
  constructor(scene, options = {}) {
54
58
  this.width = scene.world?.width ?? 960;
55
59
  this.height = scene.world?.height ?? 540;
@@ -80,6 +84,7 @@ export class World {
80
84
  if (this.won)
81
85
  return;
82
86
  this.updatePlatformers(input, pressed);
87
+ this.updateTopdown(input);
83
88
  this.updateScripts(input, pressed);
84
89
  if (this.won)
85
90
  return;
@@ -171,9 +176,35 @@ export class World {
171
176
  if (!fn)
172
177
  return;
173
178
  const ctx = { self: this.handle(entity), world: this.api(), input: this.scriptInput, params: script.params, dt: FIXED_DT };
179
+ this.run(entity, hook, () => fn.call(script.module, ctx, other));
180
+ }
181
+ /**
182
+ * Runs an entity's `draw` or `drawUI` hook. Called by the renderer, never by the
183
+ * simulation. `self` and `world` are read-only for the duration.
184
+ */
185
+ callDrawHook(entity, hook, extras) {
186
+ const script = entity.script;
187
+ const fn = script?.module[hook];
188
+ if (!script || !fn || entity.removed)
189
+ return;
190
+ const ctx = { ...extras, self: this.handle(entity), world: this.api(), params: script.params };
191
+ this.drawing = true;
192
+ try {
193
+ this.run(entity, hook, () => fn.call(script.module, ctx));
194
+ }
195
+ finally {
196
+ this.drawing = false;
197
+ }
198
+ }
199
+ /** True when any live entity's script has the given hook. */
200
+ hasHook(hook) {
201
+ return this.entities.some((e) => !e.removed && typeof e.script?.module[hook] === "function");
202
+ }
203
+ run(entity, hook, call) {
204
+ const script = entity.script;
174
205
  this.current = { entity, hook };
175
206
  try {
176
- fn.call(script.module, ctx, other);
207
+ call();
177
208
  }
178
209
  catch (err) {
179
210
  if (err instanceof ScriptError)
@@ -185,6 +216,12 @@ export class World {
185
216
  this.current = undefined;
186
217
  }
187
218
  }
219
+ /** Draw hooks only paint, so the browser can't drift from what tests verified. */
220
+ guard(what) {
221
+ if (this.drawing) {
222
+ throw this.misuse(`${what} changes the game, which a draw hook can't do. Change the game in update or touch, and keep visual memory in ctx.local.`);
223
+ }
224
+ }
188
225
  /** An error caused by how a script used the API, reported against the running script. */
189
226
  misuse(message) {
190
227
  const running = this.current;
@@ -204,18 +241,21 @@ export class World {
204
241
  return e.transform.x;
205
242
  },
206
243
  set x(value) {
244
+ world.guard("Setting x");
207
245
  e.transform.x = value;
208
246
  },
209
247
  get y() {
210
248
  return e.transform.y;
211
249
  },
212
250
  set y(value) {
251
+ world.guard("Setting y");
213
252
  e.transform.y = value;
214
253
  },
215
254
  get vx() {
216
255
  return e.body?.vx ?? 0;
217
256
  },
218
257
  set vx(value) {
258
+ world.guard("Setting vx");
219
259
  if (e.body)
220
260
  e.body.vx = value;
221
261
  },
@@ -223,6 +263,7 @@ export class World {
223
263
  return e.body?.vy ?? 0;
224
264
  },
225
265
  set vy(value) {
266
+ world.guard("Setting vy");
226
267
  if (e.body)
227
268
  e.body.vy = value;
228
269
  },
@@ -239,7 +280,8 @@ export class World {
239
280
  return !e.removed;
240
281
  },
241
282
  get state() {
242
- return e.state;
283
+ // A frozen copy while drawing, so a draw hook can't write game state by accident.
284
+ return world.drawing ? Object.freeze({ ...e.state }) : e.state;
243
285
  },
244
286
  has(component) {
245
287
  return component === "transform" || (component in e && e[component] !== undefined);
@@ -276,7 +318,14 @@ export class World {
276
318
  get score() {
277
319
  return world.score;
278
320
  },
321
+ get deaths() {
322
+ return world.deaths;
323
+ },
324
+ get won() {
325
+ return world.won;
326
+ },
279
327
  set score(value) {
328
+ world.guard("Setting score");
280
329
  world.score = value;
281
330
  },
282
331
  get: (id) => {
@@ -285,25 +334,31 @@ export class World {
285
334
  },
286
335
  all: (component) => world.entities.filter((e) => !component || world.handle(e).has(component)).map((e) => world.handle(e)),
287
336
  player: () => {
288
- const e = world.entities.find((candidate) => candidate.platformer);
337
+ const e = world.entities.find(isPlayer);
289
338
  return e ? world.handle(e) : undefined;
290
339
  },
291
- spawn: (def) => world.spawn(def),
340
+ spawn: (def) => {
341
+ world.guard("world.spawn");
342
+ return world.spawn(def);
343
+ },
292
344
  destroy: (target) => {
345
+ world.guard("world.destroy");
293
346
  const e = world.resolve(target);
294
347
  if (e)
295
348
  world.remove(e);
296
349
  },
297
350
  kill: (target) => {
351
+ world.guard("world.kill");
298
352
  const e = world.resolve(target);
299
353
  if (!e)
300
354
  return;
301
- if (e.platformer && e.body)
355
+ if (isPlayer(e))
302
356
  world.kill(e, world.current?.entity.id ?? "script");
303
357
  else
304
358
  world.remove(e);
305
359
  },
306
360
  win: () => {
361
+ world.guard("world.win");
307
362
  if (world.won)
308
363
  return;
309
364
  const by = world.current?.entity;
@@ -311,6 +366,7 @@ export class World {
311
366
  world.emit({ type: "win", entity: by?.id ?? "script", goal: by?.id ?? "script", x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
312
367
  },
313
368
  emit: (name, data) => {
369
+ world.guard("world.emit");
314
370
  if (typeof name !== "string" || name === "")
315
371
  throw world.misuse("world.emit needs a non-empty event name.");
316
372
  if (EVENT_TYPES.includes(name)) {
@@ -319,7 +375,10 @@ export class World {
319
375
  const by = world.current?.entity;
320
376
  world.emit({ type: "custom", name, entity: by?.id ?? "script", data, x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
321
377
  },
322
- random: () => world.random(),
378
+ random: () => {
379
+ world.guard("world.random (it advances the game's seed; use ctx.random in draw hooks)");
380
+ return world.random();
381
+ },
323
382
  };
324
383
  return this.scriptApi;
325
384
  }
@@ -349,13 +408,36 @@ export class World {
349
408
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
350
409
  }
351
410
  // ------------------------------------------------------------ built-ins
411
+ /** Entities the player controls: platformers and topdown movers. */
352
412
  players() {
353
- return this.entities.filter((e) => e.platformer && e.body);
413
+ return this.entities.filter(isPlayer);
414
+ }
415
+ updateTopdown(held) {
416
+ for (const e of this.entities) {
417
+ const t = e.topdown;
418
+ const b = e.body;
419
+ if (!t || !b)
420
+ continue;
421
+ let dx = (held.has("right") ? 1 : 0) - (held.has("left") ? 1 : 0);
422
+ let dy = (held.has("down") ? 1 : 0) - (held.has("up") ? 1 : 0);
423
+ if (t.directions === "x")
424
+ dy = 0;
425
+ if (t.directions === "y")
426
+ dx = 0;
427
+ if (t.directions === "4" && dx !== 0)
428
+ dy = 0;
429
+ // Diagonals move at the same speed as straight lines.
430
+ const length = Math.hypot(dx, dy) || 1;
431
+ b.vx = (dx / length) * t.speed;
432
+ b.vy = (dy / length) * t.speed;
433
+ }
354
434
  }
355
435
  updatePlatformers(held, pressed) {
356
- for (const e of this.players()) {
436
+ for (const e of this.entities) {
357
437
  const p = e.platformer;
358
438
  const b = e.body;
439
+ if (!p || !b)
440
+ continue;
359
441
  const direction = (held.has("right") ? 1 : 0) - (held.has("left") ? 1 : 0);
360
442
  b.vx = direction * p.speed;
361
443
  // Timers get a tiny extra margin so a value of 0 still allows a jump on the exact frame.
@@ -404,15 +486,27 @@ export class World {
404
486
  updateBodies() {
405
487
  // Kinematic bodies move first, so anything standing on them can follow.
406
488
  const moved = new Map();
489
+ const walls = this.entities.filter((e) => e.body?.type === "static" && !e.body.sensor);
407
490
  for (const e of this.entities) {
408
491
  const b = e.body;
409
492
  if (!b || b.type !== "kinematic")
410
493
  continue;
411
494
  const { x, y } = e.transform;
412
495
  e.transform.x += b.vx * FIXED_DT;
496
+ // A player-steered kinematic body (a paddle) stops at walls; scripted movers pass through.
497
+ if (e.topdown)
498
+ for (const wall of walls)
499
+ if (overlaps(e, wall))
500
+ e.transform.x = wall.transform.x + (Math.sign(e.transform.x - wall.transform.x) * (wall.body.width + b.width)) / 2;
413
501
  e.transform.y += b.vy * FIXED_DT;
502
+ if (e.topdown)
503
+ for (const wall of walls)
504
+ if (overlaps(e, wall))
505
+ e.transform.y = wall.transform.y + (Math.sign(e.transform.y - wall.transform.y) * (wall.body.height + b.height)) / 2;
414
506
  if (e.patrol)
415
507
  clampToPatrol(e, e.patrol);
508
+ if (e.topdown?.clampToWorld)
509
+ this.clampToWorld(e);
416
510
  moved.set(e, { dx: e.transform.x - x, dy: e.transform.y - y });
417
511
  }
418
512
  const solids = this.entities.filter((e) => e.body && !e.body.sensor && e.body.type !== "dynamic");
@@ -425,7 +519,9 @@ export class World {
425
519
  e.transform.x += carried.dx;
426
520
  e.transform.y += carried.dy;
427
521
  }
428
- b.vy = Math.min(b.vy + this.gravity * b.gravityScale * FIXED_DT, MAX_FALL_SPEED);
522
+ // Top-down movers ignore gravity; everything else falls.
523
+ if (!e.topdown)
524
+ b.vy = Math.min(b.vy + this.gravity * b.gravityScale * FIXED_DT, MAX_FALL_SPEED);
429
525
  const wasGrounded = b.grounded;
430
526
  b.grounded = false;
431
527
  b.standingOn = undefined;
@@ -439,27 +535,48 @@ export class World {
439
535
  e.transform.x = solid.transform.x - half;
440
536
  else if (b.vx < 0)
441
537
  e.transform.x = solid.transform.x + half;
442
- b.vx = 0;
538
+ b.vx = this.rebound(e, solid, b.vx);
443
539
  }
444
540
  e.transform.y += b.vy * FIXED_DT;
445
541
  for (const solid of solids) {
446
542
  if (!overlaps(e, solid))
447
543
  continue;
448
544
  const half = (solid.body.height + b.height) / 2;
449
- if (b.vy > 0) {
545
+ const falling = b.vy > 0;
546
+ if (b.vy > 0)
450
547
  e.transform.y = solid.transform.y - half;
548
+ else if (b.vy < 0)
549
+ e.transform.y = solid.transform.y + half;
550
+ b.vy = this.rebound(e, solid, b.vy);
551
+ // A body that stopped on top of something is standing on it; one that bounced off is airborne.
552
+ if (falling && b.vy === 0) {
451
553
  b.grounded = true;
452
554
  b.standingOn = solid;
453
555
  }
454
- else if (b.vy < 0) {
455
- e.transform.y = solid.transform.y + half;
456
- }
457
- b.vy = 0;
458
556
  }
557
+ if (e.topdown?.clampToWorld)
558
+ this.clampToWorld(e);
459
559
  if (b.grounded && !wasGrounded)
460
560
  this.emit({ type: "land", entity: e.id, ...feet(e) });
461
561
  }
462
562
  }
563
+ /**
564
+ * The velocity along one axis after hitting `solid`: 0, or reversed and scaled by `bounce`.
565
+ * Slow impacts stop instead, so resting bodies don't jitter.
566
+ */
567
+ rebound(e, solid, velocity) {
568
+ const bounce = e.body.bounce;
569
+ if (bounce <= 0 || Math.abs(velocity) * bounce < MIN_BOUNCE_SPEED)
570
+ return 0;
571
+ this.emit({ type: "bounce", entity: e.id, other: solid.id, ...e.transform });
572
+ return -velocity * bounce;
573
+ }
574
+ clampToWorld(e) {
575
+ const halfWidth = (e.body?.width ?? 0) / 2;
576
+ const halfHeight = (e.body?.height ?? 0) / 2;
577
+ e.transform.x = Math.min(this.width - halfWidth, Math.max(halfWidth, e.transform.x));
578
+ e.transform.y = Math.min(this.height - halfHeight, Math.max(halfHeight, e.transform.y));
579
+ }
463
580
  updateCollectibles() {
464
581
  const players = this.players();
465
582
  for (const item of this.entities.filter((e) => e.collectible && e.body)) {
@@ -481,7 +598,7 @@ export class World {
481
598
  for (const hazard of this.entities.filter((e) => e.hazard && e.body)) {
482
599
  if (!overlaps(player, hazard))
483
600
  continue;
484
- if (hazard.hazard.stompable && isStomp(player, hazard)) {
601
+ if (hazard.hazard.stompable && player.platformer && isStomp(player, hazard)) {
485
602
  this.remove(hazard);
486
603
  b.vy = -player.platformer.jumpSpeed * STOMP_BOUNCE;
487
604
  this.score += hazard.hazard.score;
@@ -508,18 +625,20 @@ export class World {
508
625
  /** Sends a player back to where it started. Collected items stay collected. */
509
626
  kill(player, cause) {
510
627
  const b = player.body;
511
- const p = player.platformer;
512
628
  this.deaths++;
513
629
  this.emit({ type: "die", entity: player.id, cause, ...player.transform });
514
- player.transform.x = p.spawnX;
515
- player.transform.y = p.spawnY;
630
+ player.transform.x = player.spawn?.x ?? player.transform.x;
631
+ player.transform.y = player.spawn?.y ?? player.transform.y;
516
632
  b.vx = 0;
517
633
  b.vy = 0;
518
634
  b.grounded = false;
519
635
  b.standingOn = undefined;
520
- p.coyoteTimer = 0;
521
- p.bufferTimer = 0;
522
- p.jumping = false;
636
+ const p = player.platformer;
637
+ if (p) {
638
+ p.coyoteTimer = 0;
639
+ p.bufferTimer = 0;
640
+ p.jumping = false;
641
+ }
523
642
  }
524
643
  }
525
644
  function createEntity(def) {
@@ -536,10 +655,12 @@ function createEntity(def) {
536
655
  coyoteTimer: 0,
537
656
  bufferTimer: 0,
538
657
  jumping: false,
539
- spawnX: transform.x,
540
- spawnY: transform.y,
541
658
  };
542
659
  }
660
+ if (c.topdown)
661
+ entity.topdown = resolveComponent("topdown", c.topdown);
662
+ if (entity.platformer || entity.topdown)
663
+ entity.spawn = { x: transform.x, y: transform.y };
543
664
  if (c.collectible)
544
665
  entity.collectible = resolveComponent("collectible", c.collectible);
545
666
  if (c.patrol)
@@ -554,6 +675,9 @@ function createEntity(def) {
554
675
  entity.camera = resolveComponent("camera", c.camera);
555
676
  return entity;
556
677
  }
678
+ function isPlayer(e) {
679
+ return !!(e.platformer || e.topdown) && !!e.body;
680
+ }
557
681
  function feet(e) {
558
682
  return { x: e.transform.x, y: e.transform.y + (e.body?.height ?? 0) / 2 };
559
683
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ludra/core",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Ludra engine runtime: scene format, validation, deterministic simulation and rendering.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/ascii.ts CHANGED
@@ -48,8 +48,8 @@ export function renderAscii(world: World, options: AsciiOptions = {}): string {
48
48
  ["G", (e) => !!e.goal],
49
49
  ["^", (e) => !!e.hazard && e.body!.type !== "kinematic"],
50
50
  ["E", (e) => !!e.hazard && e.body!.type === "kinematic"],
51
- ["D", (e) => e.body!.type === "dynamic" && !e.platformer],
52
- ["@", (e) => !!e.platformer],
51
+ ["D", (e) => e.body!.type === "dynamic" && !e.platformer && !e.topdown],
52
+ ["@", (e) => !!(e.platformer || e.topdown)],
53
53
  ];
54
54
  for (const [char, test] of passes) {
55
55
  for (const e of withBody.filter(test)) paint(e, char);
package/src/browser.ts CHANGED
@@ -3,7 +3,8 @@ import { parseFrames, type Sprite } from "./components.ts";
3
3
  import { collectAssetPaths } from "./effects.ts";
4
4
  import { InputScript } from "./input.ts";
5
5
  import { ParticleSystem } from "./particles.ts";
6
- import type { Script } from "./script.ts";
6
+ import { PostFx } from "./postfx.ts";
7
+ import type { DrawContext, DrawEvent, Script } from "./script.ts";
7
8
  import { FIXED_DT } from "./time.ts";
8
9
  import type { Action, SceneDef } from "./types.ts";
9
10
  import { World, type Entity } from "./world.ts";
@@ -52,6 +53,88 @@ async function loadScripts(scene: SceneDef, options: GameOptions): Promise<Map<s
52
53
  return scripts;
53
54
  }
54
55
 
56
+ /** Fonts load from Google Fonts or project files; a slow or offline font never blocks the game for long. */
57
+ async function loadFonts(scene: SceneDef, assetBase: string): Promise<void> {
58
+ const fonts = Object.entries(scene.fonts ?? {});
59
+ if (fonts.length === 0) return;
60
+ const load = async ([family, src]: [string, string]) => {
61
+ if (src === "google") {
62
+ const name = encodeURIComponent(family).replace(/%20/g, "+");
63
+ // Not every family has every weight, and Google rejects the request if one is missing.
64
+ for (const weights of [":wght@400;500;600;700;800;900", ":wght@400;700", ""]) {
65
+ const response = await fetch(`https://fonts.googleapis.com/css2?family=${name}${weights}&display=block`);
66
+ if (!response.ok) continue;
67
+ const style = document.createElement("style");
68
+ style.textContent = await response.text();
69
+ document.head.append(style);
70
+ break;
71
+ }
72
+ await Promise.all(["400", "700", "900"].map((weight) => document.fonts.load(`${weight} 16px "${family}"`)));
73
+ } else {
74
+ const face = new FontFace(family, `url("${assetBase}${src}")`);
75
+ // FontFaceSet.add is missing from some TypeScript DOM typings.
76
+ (document.fonts as unknown as Set<FontFace>).add(await face.load());
77
+ }
78
+ };
79
+ const all = Promise.all(
80
+ fonts.map((font) => load(font).catch((err) => console.warn(`[ludra] could not load font "${font[0]}": ${err}`))),
81
+ );
82
+ await Promise.race([all, new Promise((resolve) => setTimeout(resolve, 4000))]);
83
+ }
84
+
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;
96
+ }
97
+
98
+ /**
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.
101
+ */
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.");
105
+ const assetBase = options.assetBase ?? "";
106
+ const shaderPath = scene.postfx?.shader;
107
+ const [images, scripts, shader] = await Promise.all([
108
+ loadImages(scene, assetBase),
109
+ 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,
113
+ loadFonts(scene, assetBase),
114
+ ]);
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;
132
+ }
133
+
134
+ function message(err: unknown): string {
135
+ return err instanceof Error ? err.message : String(err);
136
+ }
137
+
55
138
  /**
56
139
  * Runs a scene in a canvas with keyboard controls, images, particles and
57
140
  * sound. The simulation uses the same fixed step as headless runs, so what
@@ -59,19 +142,16 @@ async function loadScripts(scene: SceneDef, options: GameOptions): Promise<Map<s
59
142
  * Resolves (after images load) to a function that stops the game.
60
143
  */
61
144
  export async function startGame(canvas: HTMLCanvasElement, scene: SceneDef, options: GameOptions = {}): Promise<() => void> {
62
- const ctx = canvas.getContext("2d");
63
- if (!ctx) throw new Error("Canvas 2D context is not available.");
64
- const assetBase = options.assetBase ?? "";
65
- const [images, scripts] = await Promise.all([loadImages(scene, assetBase), loadScripts(scene, options)]);
66
- const sounds = new SoundPlayer(assetBase);
145
+ const { ctx, images, scripts, present, error } = await prepare(canvas, scene, options);
146
+ const sounds = new SoundPlayer(options.assetBase ?? "");
67
147
  const view = new View(scene, images);
68
148
 
69
149
  let world = new World(scene, { scripts });
70
- let crash: string | undefined;
150
+ let crash: string | undefined = error;
71
151
  const restart = () => {
72
152
  world = new World(scene, { scripts });
73
153
  view.reset();
74
- crash = undefined;
154
+ crash = error;
75
155
  };
76
156
 
77
157
  const held = new Set<Action>();
@@ -110,15 +190,22 @@ export async function startGame(canvas: HTMLCanvasElement, scene: SceneDef, opti
110
190
  world.step(tapped.size > 0 ? new Set([...held, ...tapped]) : held);
111
191
  } catch (err) {
112
192
  // A script threw: freeze the game and show why, instead of failing silently.
113
- crash = err instanceof Error ? err.message : String(err);
193
+ crash = message(err);
114
194
  console.error("[ludra]", err);
115
195
  }
116
196
  tapped.clear();
117
197
  view.step(world, (sound) => sounds.play(sound));
118
198
  accumulator -= FIXED_DT;
119
199
  }
120
- view.draw(ctx, world, sounds.muted);
200
+ try {
201
+ view.draw(ctx, world, sounds.muted);
202
+ } catch (err) {
203
+ // A draw hook threw. Same treatment as a simulation error.
204
+ if (!crash) console.error("[ludra]", err);
205
+ crash ??= message(err);
206
+ }
121
207
  if (crash) drawCrash(ctx, crash);
208
+ present(world.time);
122
209
  raf = requestAnimationFrame(frame);
123
210
  };
124
211
  raf = requestAnimationFrame(frame);
@@ -152,9 +239,8 @@ export interface CapturedFrame {
152
239
  * `ludra play --screenshot`.
153
240
  */
154
241
  export async function captureFrames(canvas: HTMLCanvasElement, scene: SceneDef, options: CaptureOptions): Promise<CapturedFrame[]> {
155
- const ctx = canvas.getContext("2d");
156
- if (!ctx) throw new Error("Canvas 2D context is not available.");
157
- const [images, scripts] = await Promise.all([loadImages(scene, options.assetBase ?? ""), loadScripts(scene, options)]);
242
+ const { ctx, output, images, scripts, present, error } = await prepare(canvas, scene, options);
243
+ if (error) throw new Error(error);
158
244
  const view = new View(scene, images);
159
245
  const world = new World(scene, { scripts });
160
246
  const script = InputScript.parse(options.inputs ?? "");
@@ -165,7 +251,8 @@ export async function captureFrames(canvas: HTMLCanvasElement, scene: SceneDef,
165
251
  for (let frame = 0; ; frame++) {
166
252
  if (wanted.has(frame)) {
167
253
  view.draw(ctx, world, false);
168
- captured.push({ frame, time: frame * FIXED_DT, png: canvas.toDataURL("image/png") });
254
+ present(world.time);
255
+ captured.push({ frame, time: frame * FIXED_DT, png: output.toDataURL("image/png") });
169
256
  }
170
257
  if (frame >= last) break;
171
258
  world.step(script.at(frame));
@@ -228,6 +315,8 @@ class View {
228
315
  private readonly facing = new Map<string, number>();
229
316
  private readonly emitTimers = new Map<string, number>();
230
317
  private readonly frameCache = new Map<string, number[]>();
318
+ /** ctx.local for each entity's draw hooks. */
319
+ private readonly local = new Map<string, Record<string, unknown>>();
231
320
  private shake = 0;
232
321
  private handledEvents = 0;
233
322
  private readonly scene: SceneDef;
@@ -242,6 +331,7 @@ class View {
242
331
  this.particles.clear();
243
332
  this.facing.clear();
244
333
  this.emitTimers.clear();
334
+ this.local.clear();
245
335
  this.shake = 0;
246
336
  this.handledEvents = 0;
247
337
  }
@@ -278,7 +368,7 @@ class View {
278
368
  ctx.fillStyle = world.background;
279
369
  ctx.fillRect(0, 0, viewWidth, viewHeight);
280
370
 
281
- const target = world.entities.find((e) => e.camera) ?? world.entities.find((e) => e.platformer);
371
+ const target = world.entities.find((e) => e.camera) ?? world.entities.find((e) => e.platformer || e.topdown);
282
372
  const zoom = target?.camera?.zoom ?? 1;
283
373
  const visibleWidth = viewWidth / zoom;
284
374
  const visibleHeight = viewHeight / zoom;
@@ -289,14 +379,25 @@ class View {
289
379
  cameraY += (this.particles.random() - 0.5) * 2 * this.shake;
290
380
  }
291
381
 
382
+ const viewInfo = { width: viewWidth, height: viewHeight, cameraX, cameraY, zoom };
292
383
  ctx.save();
293
384
  ctx.scale(zoom, zoom);
294
385
  ctx.translate(-cameraX, -cameraY);
295
- const visible = world.entities.filter((e) => e.sprite).sort((a, b) => a.sprite!.layer - b.sprite!.layer);
296
- for (const e of visible) this.drawSprite(ctx, e, world.time);
386
+ const layer = (e: Entity) => e.sprite?.layer ?? 0;
387
+ const visible = world.entities.filter((e) => e.sprite || e.script?.module.draw).sort((a, b) => layer(a) - layer(b));
388
+ for (const e of visible) {
389
+ if (e.sprite) this.drawSprite(ctx, e, world.time);
390
+ if (e.script?.module.draw) this.drawHook(ctx, world, e, "draw", viewInfo);
391
+ }
297
392
  this.particles.draw(ctx);
298
393
  ctx.restore();
299
394
 
395
+ if (world.hasHook("drawUI")) {
396
+ // The game draws its own interface.
397
+ for (const e of [...world.entities]) if (e.script?.module.drawUI) this.drawHook(ctx, world, e, "drawUI", viewInfo);
398
+ return;
399
+ }
400
+
300
401
  ctx.textAlign = "left";
301
402
  ctx.font = "600 18px system-ui, sans-serif";
302
403
  ctx.fillStyle = "#ffffff";
@@ -319,6 +420,30 @@ class View {
319
420
  }
320
421
  }
321
422
 
423
+ private drawHook(ctx: CanvasRenderingContext2D, world: World, e: Entity, hook: "draw" | "drawUI", view: DrawContext["view"]): void {
424
+ let local = this.local.get(e.id);
425
+ if (!local) this.local.set(e.id, (local = {}));
426
+ ctx.save();
427
+ try {
428
+ world.callDrawHook(e, hook, {
429
+ g: ctx,
430
+ time: world.time,
431
+ local,
432
+ view,
433
+ recent: (name, seconds = 0.5) => recentEvents(world, name, seconds),
434
+ image: (path) => this.images.get(path),
435
+ burst: (x, y, particles = {}) => this.particles.burst(x, y, particles),
436
+ shake: (amount) => {
437
+ this.shake = Math.max(this.shake, amount);
438
+ },
439
+ random: () => this.particles.random(),
440
+ });
441
+ } finally {
442
+ // Whatever the hook changed (transform, alpha, blend mode, filter) ends with it.
443
+ ctx.restore();
444
+ }
445
+ }
446
+
322
447
  private drawSprite(ctx: CanvasRenderingContext2D, e: Entity, time: number): void {
323
448
  const sprite = e.sprite!;
324
449
  const width = sprite.width || e.body?.width || 16;
@@ -390,6 +515,20 @@ class View {
390
515
 
391
516
  type AnimationState = "idle" | "run" | "jump" | "fall";
392
517
 
518
+ function recentEvents(world: World, name: string | undefined, seconds: number): DrawEvent[] {
519
+ const since = world.time - seconds;
520
+ const result: DrawEvent[] = [];
521
+ // Events are in time order, so walk back until they get too old.
522
+ for (let i = world.events.length - 1; i >= 0; i--) {
523
+ const event = world.events[i]!;
524
+ if (event.time < since) break;
525
+ const eventName = event.type === "custom" ? event.name : event.type;
526
+ 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 });
528
+ }
529
+ return result.reverse();
530
+ }
531
+
393
532
  function animationState(e: Entity): AnimationState {
394
533
  const b = e.body;
395
534
  if (!b) return "idle";