@ludra/core 0.4.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/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))
@@ -1,5 +1,5 @@
1
- import { type GameEventType, type SceneDef } from "./types.ts";
2
- import { type GameEvent, type WorldSnapshot } from "./world.ts";
1
+ import { type SceneDef } from "./types.ts";
2
+ import { type GameEvent, type WorldOptions, type WorldSnapshot } from "./world.ts";
3
3
  declare const OPERATORS: readonly ["<=", ">=", "==", "!=", "<", ">"];
4
4
  type Operator = (typeof OPERATORS)[number];
5
5
  declare const ENTITY_FIELDS: readonly ["x", "y", "vx", "vy", "grounded", "exists"];
@@ -9,19 +9,27 @@ type WorldField = (typeof WORLD_FIELDS)[number];
9
9
  type Subject = {
10
10
  kind: "world";
11
11
  field: WorldField;
12
- } | {
12
+ }
13
+ /** Built-in event types, or custom events fired by scripts with world.emit(name). */
14
+ | {
13
15
  kind: "events";
14
- type: GameEventType;
16
+ name: string;
17
+ builtin: boolean;
15
18
  } | {
16
19
  kind: "entity";
17
20
  id: string;
18
21
  field: EntityField;
22
+ } | {
23
+ kind: "state";
24
+ id: string;
25
+ key: string;
19
26
  };
27
+ type Value = number | boolean | string;
20
28
  export interface Assertion {
21
29
  source: string;
22
30
  subject: Subject;
23
31
  operator: Operator;
24
- expected: number | boolean;
32
+ expected: Value;
25
33
  /** Frame to check at, or "end" for the last frame of the run. */
26
34
  at: number | "end";
27
35
  }
@@ -32,7 +40,7 @@ export declare function parseAssertion(source: string): Assertion;
32
40
  export interface AssertionResult {
33
41
  source: string;
34
42
  pass: boolean;
35
- actual: number | boolean | undefined;
43
+ actual: unknown;
36
44
  time: number;
37
45
  message: string;
38
46
  }
@@ -53,5 +61,5 @@ export interface TestResult {
53
61
  results: AssertionResult[];
54
62
  error?: string;
55
63
  }
56
- export declare function runTest(scene: SceneDef, test: TestCase): TestResult;
64
+ export declare function runTest(scene: SceneDef, test: TestCase, options?: WorldOptions): TestResult;
57
65
  export {};
@@ -8,7 +8,7 @@ const ENTITY_FIELDS = ["x", "y", "vx", "vy", "grounded", "exists"];
8
8
  const WORLD_FIELDS = ["score", "deaths", "won"];
9
9
  export class AssertionSyntaxError extends Error {
10
10
  }
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".';
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".';
12
12
  /** Parses `<subject> <op> <value> [at <time|end>]`. */
13
13
  export function parseAssertion(source) {
14
14
  const match = /^\s*([\w.-]+)\s*(<=|>=|==|!=|<|>)\s*(\S+?)(?:\s+at\s+(\S+))?\s*$/.exec(source);
@@ -16,31 +16,35 @@ export function parseAssertion(source) {
16
16
  throw new AssertionSyntaxError(`Cannot parse assertion "${source}". ${EXAMPLES}`);
17
17
  const [, lhs = "", operator, rhs = "", at] = match;
18
18
  let subject;
19
+ const stateMatch = /^(.+)\.state\.([\w-]+)$/.exec(lhs);
19
20
  if (WORLD_FIELDS.includes(lhs)) {
20
21
  subject = { kind: "world", field: lhs };
21
22
  }
22
- else if (lhs.startsWith("events.")) {
23
- const type = lhs.slice("events.".length);
24
- if (!EVENT_TYPES.includes(type)) {
25
- throw new AssertionSyntaxError(`Unknown event "${type}" in "${source}". Events: ${EVENT_TYPES.join(", ")}.`);
26
- }
27
- subject = { kind: "events", type: type };
23
+ else if (lhs.startsWith("events.") && lhs.length > "events.".length) {
24
+ const name = lhs.slice("events.".length);
25
+ subject = { kind: "events", name, builtin: EVENT_TYPES.includes(name) };
26
+ }
27
+ else if (stateMatch) {
28
+ subject = { kind: "state", id: stateMatch[1], key: stateMatch[2] };
28
29
  }
29
30
  else {
30
31
  const dot = lhs.lastIndexOf(".");
31
32
  const field = lhs.slice(dot + 1);
32
33
  if (dot <= 0 || !ENTITY_FIELDS.includes(field)) {
33
- throw new AssertionSyntaxError(`"${lhs}" is not something Ludra can check. Use ${WORLD_FIELDS.join(", ")}, events.<${EVENT_TYPES.join("|")}> or <entityId>.<${ENTITY_FIELDS.join("|")}>.`);
34
+ throw new AssertionSyntaxError(`"${lhs}" is not something Ludra can check. Use ${WORLD_FIELDS.join(", ")}, events.<name>, <entityId>.<${ENTITY_FIELDS.join("|")}> or <entityId>.state.<key>.`);
34
35
  }
35
36
  subject = { kind: "entity", id: lhs.slice(0, dot), field: field };
36
37
  }
37
38
  let expected;
38
39
  if (rhs === "true" || rhs === "false")
39
40
  expected = rhs === "true";
41
+ else if (/^".*"$/.test(rhs))
42
+ expected = rhs.slice(1, -1);
40
43
  else if (Number.isFinite(Number(rhs)))
41
44
  expected = Number(rhs);
42
- else
43
- throw new AssertionSyntaxError(`Expected a number or true/false after "${operator}" in "${source}", got "${rhs}".`);
45
+ else {
46
+ throw new AssertionSyntaxError(`Expected a number, true/false or a "quoted string" after "${operator}" in "${source}", got ${rhs}.`);
47
+ }
44
48
  let frame = "end";
45
49
  if (at !== undefined && at !== "end") {
46
50
  try {
@@ -62,10 +66,30 @@ export function evaluateAssertion(assertion, snapshot, events) {
62
66
  actual = snapshot[subject.field];
63
67
  label = subject.field;
64
68
  break;
65
- case "events":
66
- actual = events.filter((e) => e.type === subject.type && e.frame <= snapshot.frame).length;
67
- label = `events.${subject.type}`;
69
+ case "events": {
70
+ const matches = (e) => (subject.builtin ? e.type === subject.name : e.type === "custom" && e.name === subject.name);
71
+ actual = events.filter((e) => matches(e) && e.frame <= snapshot.frame).length;
72
+ label = `events.${subject.name}`;
73
+ break;
74
+ }
75
+ case "state": {
76
+ const entity = snapshot.entities[subject.id];
77
+ label = `${subject.id}.state.${subject.key}`;
78
+ if (!entity) {
79
+ return { source, pass: false, actual: undefined, time, message: `entity "${subject.id}" does not exist at ${time.toFixed(2)}s.` };
80
+ }
81
+ actual = entity.state?.[subject.key];
82
+ if (actual === undefined) {
83
+ return {
84
+ source,
85
+ pass: false,
86
+ actual,
87
+ time,
88
+ message: `${label} is not set at ${time.toFixed(2)}s. Scripts set it with self.state.${subject.key} = ...`,
89
+ };
90
+ }
68
91
  break;
92
+ }
69
93
  case "entity": {
70
94
  const entity = snapshot.entities[subject.id];
71
95
  label = `${subject.id}.${subject.field}`;
@@ -85,13 +109,19 @@ export function evaluateAssertion(assertion, snapshot, events) {
85
109
  }
86
110
  }
87
111
  if (typeof actual !== typeof expected) {
88
- return { source, pass: false, actual, time, message: `${label} is a ${typeof actual}, but it is compared with ${JSON.stringify(expected)}.` };
112
+ return {
113
+ source,
114
+ pass: false,
115
+ actual,
116
+ time,
117
+ message: `${label} is ${JSON.stringify(actual)} (a ${typeof actual}), but it is compared with ${JSON.stringify(expected)}.`,
118
+ };
89
119
  }
90
- if (typeof actual === "boolean" && operator !== "==" && operator !== "!=") {
91
- return { source, pass: false, actual, time, message: `${label} is true/false, so only == and != can be used.` };
120
+ if (typeof actual !== "number" && operator !== "==" && operator !== "!=") {
121
+ return { source, pass: false, actual, time, message: `${label} is a ${typeof actual}, so only == and != can be used.` };
92
122
  }
93
123
  const pass = compare(actual, operator, expected);
94
- const shown = typeof actual === "number" ? round(actual) : actual;
124
+ const shown = typeof actual === "number" ? round(actual) : JSON.stringify(actual);
95
125
  return { source, pass, actual, time, message: `${label} = ${shown} at ${time.toFixed(2)}s` };
96
126
  }
97
127
  function compare(a, operator, b) {
@@ -113,7 +143,7 @@ function compare(a, operator, b) {
113
143
  function round(n) {
114
144
  return Math.round(n * 100) / 100;
115
145
  }
116
- export function runTest(scene, test) {
146
+ export function runTest(scene, test, options = {}) {
117
147
  let script;
118
148
  let assertions;
119
149
  let frames;
@@ -134,11 +164,18 @@ export function runTest(scene, test) {
134
164
  }
135
165
  const wanted = new Set(assertions.map((a) => (a.at === "end" ? frames : Math.min(a.at, frames))));
136
166
  const snapshots = new Map();
137
- const world = new World(scene);
138
- simulate(world, script, frames, (w) => {
139
- if (wanted.has(w.frame))
140
- snapshots.set(w.frame, w.snapshot());
141
- });
167
+ let world;
168
+ try {
169
+ world = new World(scene, options);
170
+ simulate(world, script, frames, (w) => {
171
+ if (wanted.has(w.frame))
172
+ snapshots.set(w.frame, w.snapshot());
173
+ });
174
+ }
175
+ catch (err) {
176
+ // 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 };
178
+ }
142
179
  const results = assertions.map((a) => {
143
180
  const frame = a.at === "end" ? frames : Math.min(a.at, frames);
144
181
  return evaluateAssertion(a, snapshots.get(frame), world.events);
package/dist/browser.d.ts CHANGED
@@ -2,6 +2,10 @@ import type { SceneDef } from "./types.ts";
2
2
  export interface GameOptions {
3
3
  /** URL prefix for image and audio paths in the scene. Defaults to "". */
4
4
  assetBase?: string;
5
+ /** URL prefix the scene's script paths are imported from. Defaults to `assetBase`. */
6
+ scriptBase?: string;
7
+ /** Scripts to load. Defaults to those the scene references; include any that `world.spawn` uses. */
8
+ scriptPaths?: readonly string[];
5
9
  }
6
10
  /**
7
11
  * Runs a scene in a canvas with keyboard controls, images, particles and
package/dist/browser.js CHANGED
@@ -1,8 +1,17 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
1
9
  import { SoundPlayer } from "./audio.js";
2
10
  import { parseFrames } from "./components.js";
3
11
  import { collectAssetPaths } from "./effects.js";
4
12
  import { InputScript } from "./input.js";
5
13
  import { ParticleSystem } from "./particles.js";
14
+ import { PostFx } from "./postfx.js";
6
15
  import { FIXED_DT } from "./time.js";
7
16
  import { World } from "./world.js";
8
17
  const KEYMAP = {
@@ -23,6 +32,89 @@ const KEYMAP = {
23
32
  const MAX_FRAME_TIME = 0.25;
24
33
  /** Screen shake decays by this factor every step. */
25
34
  const SHAKE_DECAY = 0.88;
35
+ /** Imports the game's script modules, keyed by their path in the scene. */
36
+ async function loadScripts(scene, options) {
37
+ const base = options.scriptBase ?? options.assetBase ?? "";
38
+ const paths = options.scriptPaths ?? collectAssetPaths(scene).filter((ref) => ref.kind === "script").map((ref) => ref.path);
39
+ const scripts = new Map();
40
+ await Promise.all([...new Set(paths)].map(async (path) => {
41
+ const module = (await import(__rewriteRelativeImportExtension(base + path)));
42
+ if (!module.default)
43
+ throw new Error(`Script ${path} has no default export.`);
44
+ scripts.set(path, module.default);
45
+ }));
46
+ return scripts;
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
+ /**
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.
80
+ */
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.");
85
+ const assetBase = options.assetBase ?? "";
86
+ const shaderPath = scene.postfx?.shader;
87
+ const [images, scripts, shader] = await Promise.all([
88
+ loadImages(scene, assetBase),
89
+ 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,
93
+ loadFonts(scene, assetBase),
94
+ ]);
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;
114
+ }
115
+ function message(err) {
116
+ return err instanceof Error ? err.message : String(err);
117
+ }
26
118
  /**
27
119
  * Runs a scene in a canvas with keyboard controls, images, particles and
28
120
  * sound. The simulation uses the same fixed step as headless runs, so what
@@ -30,17 +122,15 @@ const SHAKE_DECAY = 0.88;
30
122
  * Resolves (after images load) to a function that stops the game.
31
123
  */
32
124
  export async function startGame(canvas, scene, options = {}) {
33
- const ctx = canvas.getContext("2d");
34
- if (!ctx)
35
- throw new Error("Canvas 2D context is not available.");
36
- const assetBase = options.assetBase ?? "";
37
- const images = await loadImages(scene, assetBase);
38
- const sounds = new SoundPlayer(assetBase);
125
+ const { ctx, images, scripts, present, error } = await prepare(canvas, scene, options);
126
+ const sounds = new SoundPlayer(options.assetBase ?? "");
39
127
  const view = new View(scene, images);
40
- let world = new World(scene);
128
+ let world = new World(scene, { scripts });
129
+ let crash = error;
41
130
  const restart = () => {
42
- world = new World(scene);
131
+ world = new World(scene, { scripts });
43
132
  view.reset();
133
+ crash = error;
44
134
  };
45
135
  const held = new Set();
46
136
  // Keys pressed since the last step. A tap shorter than one frame still reaches the simulation.
@@ -76,13 +166,31 @@ export async function startGame(canvas, scene, options = {}) {
76
166
  const frame = (now) => {
77
167
  accumulator += Math.min(MAX_FRAME_TIME, (now - last) / 1000);
78
168
  last = now;
79
- while (accumulator >= FIXED_DT) {
80
- world.step(tapped.size > 0 ? new Set([...held, ...tapped]) : held);
169
+ while (accumulator >= FIXED_DT && !crash) {
170
+ try {
171
+ world.step(tapped.size > 0 ? new Set([...held, ...tapped]) : held);
172
+ }
173
+ catch (err) {
174
+ // A script threw: freeze the game and show why, instead of failing silently.
175
+ crash = message(err);
176
+ console.error("[ludra]", err);
177
+ }
81
178
  tapped.clear();
82
179
  view.step(world, (sound) => sounds.play(sound));
83
180
  accumulator -= FIXED_DT;
84
181
  }
85
- view.draw(ctx, world, sounds.muted);
182
+ try {
183
+ view.draw(ctx, world, sounds.muted);
184
+ }
185
+ catch (err) {
186
+ // A draw hook threw. Same treatment as a simulation error.
187
+ if (!crash)
188
+ console.error("[ludra]", err);
189
+ crash ??= message(err);
190
+ }
191
+ if (crash)
192
+ drawCrash(ctx, crash);
193
+ present(world.time);
86
194
  raf = requestAnimationFrame(frame);
87
195
  };
88
196
  raf = requestAnimationFrame(frame);
@@ -100,12 +208,11 @@ export async function startGame(canvas, scene, options = {}) {
100
208
  * `ludra play --screenshot`.
101
209
  */
102
210
  export async function captureFrames(canvas, scene, options) {
103
- const ctx = canvas.getContext("2d");
104
- if (!ctx)
105
- throw new Error("Canvas 2D context is not available.");
106
- const images = await loadImages(scene, options.assetBase ?? "");
211
+ const { ctx, output, images, scripts, present, error } = await prepare(canvas, scene, options);
212
+ if (error)
213
+ throw new Error(error);
107
214
  const view = new View(scene, images);
108
- const world = new World(scene);
215
+ const world = new World(scene, { scripts });
109
216
  const script = InputScript.parse(options.inputs ?? "");
110
217
  const wanted = new Set(options.frames);
111
218
  const last = Math.max(0, ...options.frames);
@@ -113,7 +220,8 @@ export async function captureFrames(canvas, scene, options) {
113
220
  for (let frame = 0;; frame++) {
114
221
  if (wanted.has(frame)) {
115
222
  view.draw(ctx, world, false);
116
- captured.push({ frame, time: frame * FIXED_DT, png: canvas.toDataURL("image/png") });
223
+ present(world.time);
224
+ captured.push({ frame, time: frame * FIXED_DT, png: output.toDataURL("image/png") });
117
225
  }
118
226
  if (frame >= last)
119
227
  break;
@@ -122,6 +230,30 @@ export async function captureFrames(canvas, scene, options) {
122
230
  }
123
231
  return captured;
124
232
  }
233
+ function drawCrash(ctx, message) {
234
+ const { width, height } = ctx.canvas;
235
+ ctx.fillStyle = "rgba(20, 0, 8, 0.85)";
236
+ ctx.fillRect(0, 0, width, height);
237
+ ctx.fillStyle = "#ff9b9b";
238
+ ctx.textAlign = "left";
239
+ ctx.font = "700 22px system-ui, sans-serif";
240
+ ctx.fillText("Script error", 32, 56);
241
+ ctx.font = "15px ui-monospace, monospace";
242
+ // Wrap the message to the canvas width.
243
+ let line = "";
244
+ let y = 92;
245
+ for (const word of message.split(" ")) {
246
+ if (ctx.measureText(line + word).width > width - 64) {
247
+ ctx.fillText(line, 32, y);
248
+ line = "";
249
+ y += 22;
250
+ }
251
+ line += `${word} `;
252
+ }
253
+ ctx.fillText(line, 32, y);
254
+ ctx.fillStyle = "rgba(255, 255, 255, 0.6)";
255
+ ctx.fillText("Fix the script; the page reloads on save. R restarts.", 32, y + 40);
256
+ }
125
257
  async function loadImages(scene, assetBase) {
126
258
  const images = new Map();
127
259
  const paths = new Set(collectAssetPaths(scene).filter((ref) => ref.kind === "image").map((ref) => ref.path));
@@ -145,6 +277,8 @@ class View {
145
277
  facing = new Map();
146
278
  emitTimers = new Map();
147
279
  frameCache = new Map();
280
+ /** ctx.local for each entity's draw hooks. */
281
+ local = new Map();
148
282
  shake = 0;
149
283
  handledEvents = 0;
150
284
  scene;
@@ -157,13 +291,14 @@ class View {
157
291
  this.particles.clear();
158
292
  this.facing.clear();
159
293
  this.emitTimers.clear();
294
+ this.local.clear();
160
295
  this.shake = 0;
161
296
  this.handledEvents = 0;
162
297
  }
163
298
  /** Runs once per simulation step: turns new events into effects and advances particles. */
164
299
  step(world, playSound) {
165
300
  for (const event of world.events.slice(this.handledEvents)) {
166
- const effect = this.scene.effects?.[event.type];
301
+ const effect = this.scene.effects?.[event.type === "custom" ? event.name : event.type];
167
302
  if (!effect)
168
303
  continue;
169
304
  if (effect.particles)
@@ -195,7 +330,7 @@ class View {
195
330
  ctx.imageSmoothingEnabled = false;
196
331
  ctx.fillStyle = world.background;
197
332
  ctx.fillRect(0, 0, viewWidth, viewHeight);
198
- const target = world.entities.find((e) => e.camera) ?? world.entities.find((e) => e.platformer);
333
+ const target = world.entities.find((e) => e.camera) ?? world.entities.find((e) => e.platformer || e.topdown);
199
334
  const zoom = target?.camera?.zoom ?? 1;
200
335
  const visibleWidth = viewWidth / zoom;
201
336
  const visibleHeight = viewHeight / zoom;
@@ -205,14 +340,27 @@ class View {
205
340
  cameraX += (this.particles.random() - 0.5) * 2 * this.shake;
206
341
  cameraY += (this.particles.random() - 0.5) * 2 * this.shake;
207
342
  }
343
+ const viewInfo = { width: viewWidth, height: viewHeight, cameraX, cameraY, zoom };
208
344
  ctx.save();
209
345
  ctx.scale(zoom, zoom);
210
346
  ctx.translate(-cameraX, -cameraY);
211
- const visible = world.entities.filter((e) => e.sprite).sort((a, b) => a.sprite.layer - b.sprite.layer);
212
- for (const e of visible)
213
- this.drawSprite(ctx, e, world.time);
347
+ const layer = (e) => e.sprite?.layer ?? 0;
348
+ const visible = world.entities.filter((e) => e.sprite || e.script?.module.draw).sort((a, b) => layer(a) - layer(b));
349
+ for (const e of visible) {
350
+ if (e.sprite)
351
+ this.drawSprite(ctx, e, world.time);
352
+ if (e.script?.module.draw)
353
+ this.drawHook(ctx, world, e, "draw", viewInfo);
354
+ }
214
355
  this.particles.draw(ctx);
215
356
  ctx.restore();
357
+ if (world.hasHook("drawUI")) {
358
+ // The game draws its own interface.
359
+ for (const e of [...world.entities])
360
+ if (e.script?.module.drawUI)
361
+ this.drawHook(ctx, world, e, "drawUI", viewInfo);
362
+ return;
363
+ }
216
364
  ctx.textAlign = "left";
217
365
  ctx.font = "600 18px system-ui, sans-serif";
218
366
  ctx.fillStyle = "#ffffff";
@@ -233,6 +381,31 @@ class View {
233
381
  ctx.fillText(`Score ${world.score} · ${winTime.toFixed(1)}s · Press R to play again`, viewWidth / 2, viewHeight / 2 + 30);
234
382
  }
235
383
  }
384
+ drawHook(ctx, world, e, hook, view) {
385
+ let local = this.local.get(e.id);
386
+ if (!local)
387
+ this.local.set(e.id, (local = {}));
388
+ ctx.save();
389
+ try {
390
+ world.callDrawHook(e, hook, {
391
+ g: ctx,
392
+ time: world.time,
393
+ local,
394
+ view,
395
+ recent: (name, seconds = 0.5) => recentEvents(world, name, seconds),
396
+ image: (path) => this.images.get(path),
397
+ burst: (x, y, particles = {}) => this.particles.burst(x, y, particles),
398
+ shake: (amount) => {
399
+ this.shake = Math.max(this.shake, amount);
400
+ },
401
+ random: () => this.particles.random(),
402
+ });
403
+ }
404
+ finally {
405
+ // Whatever the hook changed (transform, alpha, blend mode, filter) ends with it.
406
+ ctx.restore();
407
+ }
408
+ }
236
409
  drawSprite(ctx, e, time) {
237
410
  const sprite = e.sprite;
238
411
  const width = sprite.width || e.body?.width || 16;
@@ -300,6 +473,21 @@ class View {
300
473
  return [0];
301
474
  }
302
475
  }
476
+ function recentEvents(world, name, seconds) {
477
+ const since = world.time - seconds;
478
+ const result = [];
479
+ // Events are in time order, so walk back until they get too old.
480
+ for (let i = world.events.length - 1; i >= 0; i--) {
481
+ const event = world.events[i];
482
+ if (event.time < since)
483
+ break;
484
+ const eventName = event.type === "custom" ? event.name : event.type;
485
+ if (name !== undefined && eventName !== name)
486
+ 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 });
488
+ }
489
+ return result.reverse();
490
+ }
303
491
  function animationState(e) {
304
492
  const b = e.body;
305
493
  if (!b)
@@ -22,6 +22,12 @@ export type FieldSpec = {
22
22
  type: "frames";
23
23
  default?: string;
24
24
  description: string;
25
+ }
26
+ /** Any JSON object. */
27
+ | {
28
+ type: "object";
29
+ default?: Record<string, unknown>;
30
+ description: string;
25
31
  };
26
32
  export interface ComponentSpec {
27
33
  description: string;
@@ -29,7 +35,7 @@ export interface ComponentSpec {
29
35
  /** Fields without a default are required. */
30
36
  fields: Record<string, FieldSpec>;
31
37
  }
32
- export declare const COMPONENT_NAMES: readonly ["transform", "body", "sprite", "platformer", "collectible", "patrol", "hazard", "goal", "emitter", "camera"];
38
+ export declare const COMPONENT_NAMES: readonly ["transform", "body", "sprite", "platformer", "topdown", "collectible", "patrol", "hazard", "goal", "emitter", "script", "camera"];
33
39
  export type ComponentName = (typeof COMPONENT_NAMES)[number];
34
40
  /** Shared by the `emitter` component and the particles of scene `effects`. Purely visual. */
35
41
  export declare const PARTICLE_FIELDS: Record<string, FieldSpec>;
@@ -49,6 +55,7 @@ export interface Body {
49
55
  height: number;
50
56
  sensor: boolean;
51
57
  gravityScale: number;
58
+ bounce: number;
52
59
  vx: number;
53
60
  vy: number;
54
61
  }
@@ -82,6 +89,10 @@ export interface ParticleSpec {
82
89
  export interface Emitter extends Omit<ParticleSpec, "count"> {
83
90
  rate: number;
84
91
  }
92
+ export interface ScriptComponent {
93
+ src: string;
94
+ params: Record<string, unknown>;
95
+ }
85
96
  export interface Platformer {
86
97
  speed: number;
87
98
  jumpSpeed: number;
@@ -89,6 +100,11 @@ export interface Platformer {
89
100
  jumpBuffer: number;
90
101
  variableJump: boolean;
91
102
  }
103
+ export interface Topdown {
104
+ speed: number;
105
+ directions: "8" | "4" | "x" | "y";
106
+ clampToWorld: boolean;
107
+ }
92
108
  export interface Collectible {
93
109
  score: number;
94
110
  }