@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/src/assertions.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { InputScript } from "./input.ts";
2
2
  import { defaultDuration, simulate } from "./simulate.ts";
3
3
  import { FIXED_DT, parseDuration, secondsToFrames } from "./time.ts";
4
- import { EVENT_TYPES, type GameEventType, type SceneDef } from "./types.ts";
5
- import { World, type GameEvent, type WorldSnapshot } from "./world.ts";
4
+ import { EVENT_TYPES, type SceneDef } from "./types.ts";
5
+ import { World, type GameEvent, type WorldOptions, type WorldSnapshot } from "./world.ts";
6
6
 
7
7
  const OPERATORS = ["<=", ">=", "==", "!=", "<", ">"] as const;
8
8
  type Operator = (typeof OPERATORS)[number];
@@ -15,14 +15,18 @@ type WorldField = (typeof WORLD_FIELDS)[number];
15
15
 
16
16
  type Subject =
17
17
  | { kind: "world"; field: WorldField }
18
- | { kind: "events"; type: GameEventType }
19
- | { kind: "entity"; id: string; field: EntityField };
18
+ /** Built-in event types, or custom events fired by scripts with world.emit(name). */
19
+ | { kind: "events"; name: string; builtin: boolean }
20
+ | { kind: "entity"; id: string; field: EntityField }
21
+ | { kind: "state"; id: string; key: string };
22
+
23
+ type Value = number | boolean | string;
20
24
 
21
25
  export interface Assertion {
22
26
  source: string;
23
27
  subject: Subject;
24
28
  operator: Operator;
25
- expected: number | boolean;
29
+ expected: Value;
26
30
  /** Frame to check at, or "end" for the last frame of the run. */
27
31
  at: number | "end";
28
32
  }
@@ -30,7 +34,7 @@ export interface Assertion {
30
34
  export class AssertionSyntaxError extends Error {}
31
35
 
32
36
  const EXAMPLES =
33
- 'Examples: "player.x > 400", "player.grounded == true at 1s", "score >= 3", "won == true", "deaths == 0", "events.jump == 1 at end", "coin1.exists == false".';
37
+ '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".';
34
38
 
35
39
  /** Parses `<subject> <op> <value> [at <time|end>]`. */
36
40
  export function parseAssertion(source: string): Assertion {
@@ -39,29 +43,32 @@ export function parseAssertion(source: string): Assertion {
39
43
  const [, lhs = "", operator, rhs = "", at] = match;
40
44
 
41
45
  let subject: Subject;
46
+ const stateMatch = /^(.+)\.state\.([\w-]+)$/.exec(lhs);
42
47
  if ((WORLD_FIELDS as readonly string[]).includes(lhs)) {
43
48
  subject = { kind: "world", field: lhs as WorldField };
44
- } else if (lhs.startsWith("events.")) {
45
- const type = lhs.slice("events.".length);
46
- if (!(EVENT_TYPES as readonly string[]).includes(type)) {
47
- throw new AssertionSyntaxError(`Unknown event "${type}" in "${source}". Events: ${EVENT_TYPES.join(", ")}.`);
48
- }
49
- subject = { kind: "events", type: type as GameEventType };
49
+ } else if (lhs.startsWith("events.") && lhs.length > "events.".length) {
50
+ const name = lhs.slice("events.".length);
51
+ subject = { kind: "events", name, builtin: (EVENT_TYPES as readonly string[]).includes(name) };
52
+ } else if (stateMatch) {
53
+ subject = { kind: "state", id: stateMatch[1]!, key: stateMatch[2]! };
50
54
  } else {
51
55
  const dot = lhs.lastIndexOf(".");
52
56
  const field = lhs.slice(dot + 1);
53
57
  if (dot <= 0 || !(ENTITY_FIELDS as readonly string[]).includes(field)) {
54
58
  throw new AssertionSyntaxError(
55
- `"${lhs}" is not something Ludra can check. Use ${WORLD_FIELDS.join(", ")}, events.<${EVENT_TYPES.join("|")}> or <entityId>.<${ENTITY_FIELDS.join("|")}>.`,
59
+ `"${lhs}" is not something Ludra can check. Use ${WORLD_FIELDS.join(", ")}, events.<name>, <entityId>.<${ENTITY_FIELDS.join("|")}> or <entityId>.state.<key>.`,
56
60
  );
57
61
  }
58
62
  subject = { kind: "entity", id: lhs.slice(0, dot), field: field as EntityField };
59
63
  }
60
64
 
61
- let expected: number | boolean;
65
+ let expected: Value;
62
66
  if (rhs === "true" || rhs === "false") expected = rhs === "true";
67
+ else if (/^".*"$/.test(rhs)) expected = rhs.slice(1, -1);
63
68
  else if (Number.isFinite(Number(rhs))) expected = Number(rhs);
64
- else throw new AssertionSyntaxError(`Expected a number or true/false after "${operator}" in "${source}", got "${rhs}".`);
69
+ else {
70
+ throw new AssertionSyntaxError(`Expected a number, true/false or a "quoted string" after "${operator}" in "${source}", got ${rhs}.`);
71
+ }
65
72
 
66
73
  let frame: number | "end" = "end";
67
74
  if (at !== undefined && at !== "end") {
@@ -78,7 +85,7 @@ export function parseAssertion(source: string): Assertion {
78
85
  export interface AssertionResult {
79
86
  source: string;
80
87
  pass: boolean;
81
- actual: number | boolean | undefined;
88
+ actual: unknown;
82
89
  time: number;
83
90
  message: string;
84
91
  }
@@ -86,7 +93,7 @@ export interface AssertionResult {
86
93
  export function evaluateAssertion(assertion: Assertion, snapshot: WorldSnapshot, events: readonly GameEvent[]): AssertionResult {
87
94
  const { subject, operator, expected, source } = assertion;
88
95
  const time = snapshot.time;
89
- let actual: number | boolean | undefined;
96
+ let actual: unknown;
90
97
  let label: string;
91
98
 
92
99
  switch (subject.kind) {
@@ -94,10 +101,30 @@ export function evaluateAssertion(assertion: Assertion, snapshot: WorldSnapshot,
94
101
  actual = snapshot[subject.field];
95
102
  label = subject.field;
96
103
  break;
97
- case "events":
98
- actual = events.filter((e) => e.type === subject.type && e.frame <= snapshot.frame).length;
99
- label = `events.${subject.type}`;
104
+ case "events": {
105
+ const matches = (e: GameEvent) => (subject.builtin ? e.type === subject.name : e.type === "custom" && e.name === subject.name);
106
+ actual = events.filter((e) => matches(e) && e.frame <= snapshot.frame).length;
107
+ label = `events.${subject.name}`;
108
+ break;
109
+ }
110
+ case "state": {
111
+ const entity = snapshot.entities[subject.id];
112
+ label = `${subject.id}.state.${subject.key}`;
113
+ if (!entity) {
114
+ return { source, pass: false, actual: undefined, time, message: `entity "${subject.id}" does not exist at ${time.toFixed(2)}s.` };
115
+ }
116
+ actual = entity.state?.[subject.key];
117
+ if (actual === undefined) {
118
+ return {
119
+ source,
120
+ pass: false,
121
+ actual,
122
+ time,
123
+ message: `${label} is not set at ${time.toFixed(2)}s. Scripts set it with self.state.${subject.key} = ...`,
124
+ };
125
+ }
100
126
  break;
127
+ }
101
128
  case "entity": {
102
129
  const entity = snapshot.entities[subject.id];
103
130
  label = `${subject.id}.${subject.field}`;
@@ -116,14 +143,20 @@ export function evaluateAssertion(assertion: Assertion, snapshot: WorldSnapshot,
116
143
  }
117
144
 
118
145
  if (typeof actual !== typeof expected) {
119
- return { source, pass: false, actual, time, message: `${label} is a ${typeof actual}, but it is compared with ${JSON.stringify(expected)}.` };
146
+ return {
147
+ source,
148
+ pass: false,
149
+ actual,
150
+ time,
151
+ message: `${label} is ${JSON.stringify(actual)} (a ${typeof actual}), but it is compared with ${JSON.stringify(expected)}.`,
152
+ };
120
153
  }
121
- if (typeof actual === "boolean" && operator !== "==" && operator !== "!=") {
122
- return { source, pass: false, actual, time, message: `${label} is true/false, so only == and != can be used.` };
154
+ if (typeof actual !== "number" && operator !== "==" && operator !== "!=") {
155
+ return { source, pass: false, actual, time, message: `${label} is a ${typeof actual}, so only == and != can be used.` };
123
156
  }
124
157
 
125
158
  const pass = compare(actual as number, operator, expected as number);
126
- const shown = typeof actual === "number" ? round(actual) : actual;
159
+ const shown = typeof actual === "number" ? round(actual) : JSON.stringify(actual);
127
160
  return { source, pass, actual, time, message: `${label} = ${shown} at ${time.toFixed(2)}s` };
128
161
  }
129
162
 
@@ -166,7 +199,7 @@ export interface TestResult {
166
199
  error?: string;
167
200
  }
168
201
 
169
- export function runTest(scene: SceneDef, test: TestCase): TestResult {
202
+ export function runTest(scene: SceneDef, test: TestCase, options: WorldOptions = {}): TestResult {
170
203
  let script: InputScript;
171
204
  let assertions: Assertion[];
172
205
  let frames: number;
@@ -187,10 +220,16 @@ export function runTest(scene: SceneDef, test: TestCase): TestResult {
187
220
 
188
221
  const wanted = new Set(assertions.map((a) => (a.at === "end" ? frames : Math.min(a.at, frames))));
189
222
  const snapshots = new Map<number, WorldSnapshot>();
190
- const world = new World(scene);
191
- simulate(world, script, frames, (w) => {
192
- if (wanted.has(w.frame)) snapshots.set(w.frame, w.snapshot());
193
- });
223
+ let world: World;
224
+ try {
225
+ world = new World(scene, options);
226
+ simulate(world, script, frames, (w) => {
227
+ if (wanted.has(w.frame)) snapshots.set(w.frame, w.snapshot());
228
+ });
229
+ } catch (err) {
230
+ // A throwing script fails this test, not the whole run.
231
+ return { name: test.name, pass: false, duration: frames * FIXED_DT, results: [], error: (err as Error).message };
232
+ }
194
233
 
195
234
  const results = assertions.map((a) => {
196
235
  const frame = a.at === "end" ? frames : Math.min(a.at, frames);
package/src/browser.ts CHANGED
@@ -3,6 +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 { PostFx } from "./postfx.ts";
7
+ import type { DrawContext, DrawEvent, Script } from "./script.ts";
6
8
  import { FIXED_DT } from "./time.ts";
7
9
  import type { Action, SceneDef } from "./types.ts";
8
10
  import { World, type Entity } from "./world.ts";
@@ -30,6 +32,107 @@ const SHAKE_DECAY = 0.88;
30
32
  export interface GameOptions {
31
33
  /** URL prefix for image and audio paths in the scene. Defaults to "". */
32
34
  assetBase?: string;
35
+ /** URL prefix the scene's script paths are imported from. Defaults to `assetBase`. */
36
+ scriptBase?: string;
37
+ /** Scripts to load. Defaults to those the scene references; include any that `world.spawn` uses. */
38
+ scriptPaths?: readonly string[];
39
+ }
40
+
41
+ /** Imports the game's script modules, keyed by their path in the scene. */
42
+ async function loadScripts(scene: SceneDef, options: GameOptions): Promise<Map<string, Script>> {
43
+ const base = options.scriptBase ?? options.assetBase ?? "";
44
+ const paths = options.scriptPaths ?? collectAssetPaths(scene).filter((ref) => ref.kind === "script").map((ref) => ref.path);
45
+ const scripts = new Map<string, Script>();
46
+ await Promise.all(
47
+ [...new Set(paths)].map(async (path) => {
48
+ const module = (await import(base + path)) as { default?: Script };
49
+ if (!module.default) throw new Error(`Script ${path} has no default export.`);
50
+ scripts.set(path, module.default);
51
+ }),
52
+ );
53
+ return scripts;
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);
33
136
  }
34
137
 
35
138
  /**
@@ -39,17 +142,16 @@ export interface GameOptions {
39
142
  * Resolves (after images load) to a function that stops the game.
40
143
  */
41
144
  export async function startGame(canvas: HTMLCanvasElement, scene: SceneDef, options: GameOptions = {}): Promise<() => void> {
42
- const ctx = canvas.getContext("2d");
43
- if (!ctx) throw new Error("Canvas 2D context is not available.");
44
- const assetBase = options.assetBase ?? "";
45
- const images = await loadImages(scene, assetBase);
46
- const sounds = new SoundPlayer(assetBase);
145
+ const { ctx, images, scripts, present, error } = await prepare(canvas, scene, options);
146
+ const sounds = new SoundPlayer(options.assetBase ?? "");
47
147
  const view = new View(scene, images);
48
148
 
49
- let world = new World(scene);
149
+ let world = new World(scene, { scripts });
150
+ let crash: string | undefined = error;
50
151
  const restart = () => {
51
- world = new World(scene);
152
+ world = new World(scene, { scripts });
52
153
  view.reset();
154
+ crash = error;
53
155
  };
54
156
 
55
157
  const held = new Set<Action>();
@@ -83,13 +185,27 @@ export async function startGame(canvas: HTMLCanvasElement, scene: SceneDef, opti
83
185
  const frame = (now: number) => {
84
186
  accumulator += Math.min(MAX_FRAME_TIME, (now - last) / 1000);
85
187
  last = now;
86
- while (accumulator >= FIXED_DT) {
87
- world.step(tapped.size > 0 ? new Set([...held, ...tapped]) : held);
188
+ while (accumulator >= FIXED_DT && !crash) {
189
+ try {
190
+ world.step(tapped.size > 0 ? new Set([...held, ...tapped]) : held);
191
+ } catch (err) {
192
+ // A script threw: freeze the game and show why, instead of failing silently.
193
+ crash = message(err);
194
+ console.error("[ludra]", err);
195
+ }
88
196
  tapped.clear();
89
197
  view.step(world, (sound) => sounds.play(sound));
90
198
  accumulator -= FIXED_DT;
91
199
  }
92
- 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
+ }
207
+ if (crash) drawCrash(ctx, crash);
208
+ present(world.time);
93
209
  raf = requestAnimationFrame(frame);
94
210
  };
95
211
  raf = requestAnimationFrame(frame);
@@ -123,11 +239,10 @@ export interface CapturedFrame {
123
239
  * `ludra play --screenshot`.
124
240
  */
125
241
  export async function captureFrames(canvas: HTMLCanvasElement, scene: SceneDef, options: CaptureOptions): Promise<CapturedFrame[]> {
126
- const ctx = canvas.getContext("2d");
127
- if (!ctx) throw new Error("Canvas 2D context is not available.");
128
- const images = await loadImages(scene, options.assetBase ?? "");
242
+ const { ctx, output, images, scripts, present, error } = await prepare(canvas, scene, options);
243
+ if (error) throw new Error(error);
129
244
  const view = new View(scene, images);
130
- const world = new World(scene);
245
+ const world = new World(scene, { scripts });
131
246
  const script = InputScript.parse(options.inputs ?? "");
132
247
  const wanted = new Set(options.frames);
133
248
  const last = Math.max(0, ...options.frames);
@@ -136,7 +251,8 @@ export async function captureFrames(canvas: HTMLCanvasElement, scene: SceneDef,
136
251
  for (let frame = 0; ; frame++) {
137
252
  if (wanted.has(frame)) {
138
253
  view.draw(ctx, world, false);
139
- 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") });
140
256
  }
141
257
  if (frame >= last) break;
142
258
  world.step(script.at(frame));
@@ -145,6 +261,31 @@ export async function captureFrames(canvas: HTMLCanvasElement, scene: SceneDef,
145
261
  return captured;
146
262
  }
147
263
 
264
+ function drawCrash(ctx: CanvasRenderingContext2D, message: string): void {
265
+ const { width, height } = ctx.canvas;
266
+ ctx.fillStyle = "rgba(20, 0, 8, 0.85)";
267
+ ctx.fillRect(0, 0, width, height);
268
+ ctx.fillStyle = "#ff9b9b";
269
+ ctx.textAlign = "left";
270
+ ctx.font = "700 22px system-ui, sans-serif";
271
+ ctx.fillText("Script error", 32, 56);
272
+ ctx.font = "15px ui-monospace, monospace";
273
+ // Wrap the message to the canvas width.
274
+ let line = "";
275
+ let y = 92;
276
+ for (const word of message.split(" ")) {
277
+ if (ctx.measureText(line + word).width > width - 64) {
278
+ ctx.fillText(line, 32, y);
279
+ line = "";
280
+ y += 22;
281
+ }
282
+ line += `${word} `;
283
+ }
284
+ ctx.fillText(line, 32, y);
285
+ ctx.fillStyle = "rgba(255, 255, 255, 0.6)";
286
+ ctx.fillText("Fix the script; the page reloads on save. R restarts.", 32, y + 40);
287
+ }
288
+
148
289
  async function loadImages(scene: SceneDef, assetBase: string): Promise<Map<string, HTMLImageElement>> {
149
290
  const images = new Map<string, HTMLImageElement>();
150
291
  const paths = new Set(collectAssetPaths(scene).filter((ref) => ref.kind === "image").map((ref) => ref.path));
@@ -174,6 +315,8 @@ class View {
174
315
  private readonly facing = new Map<string, number>();
175
316
  private readonly emitTimers = new Map<string, number>();
176
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>>();
177
320
  private shake = 0;
178
321
  private handledEvents = 0;
179
322
  private readonly scene: SceneDef;
@@ -188,6 +331,7 @@ class View {
188
331
  this.particles.clear();
189
332
  this.facing.clear();
190
333
  this.emitTimers.clear();
334
+ this.local.clear();
191
335
  this.shake = 0;
192
336
  this.handledEvents = 0;
193
337
  }
@@ -195,7 +339,7 @@ class View {
195
339
  /** Runs once per simulation step: turns new events into effects and advances particles. */
196
340
  step(world: World, playSound: (sound: string) => void): void {
197
341
  for (const event of world.events.slice(this.handledEvents)) {
198
- const effect = this.scene.effects?.[event.type];
342
+ const effect = this.scene.effects?.[event.type === "custom" ? event.name : event.type];
199
343
  if (!effect) continue;
200
344
  if (effect.particles) this.particles.burst(event.x, event.y, effect.particles);
201
345
  if (effect.sound) playSound(effect.sound);
@@ -224,7 +368,7 @@ class View {
224
368
  ctx.fillStyle = world.background;
225
369
  ctx.fillRect(0, 0, viewWidth, viewHeight);
226
370
 
227
- 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);
228
372
  const zoom = target?.camera?.zoom ?? 1;
229
373
  const visibleWidth = viewWidth / zoom;
230
374
  const visibleHeight = viewHeight / zoom;
@@ -235,14 +379,25 @@ class View {
235
379
  cameraY += (this.particles.random() - 0.5) * 2 * this.shake;
236
380
  }
237
381
 
382
+ const viewInfo = { width: viewWidth, height: viewHeight, cameraX, cameraY, zoom };
238
383
  ctx.save();
239
384
  ctx.scale(zoom, zoom);
240
385
  ctx.translate(-cameraX, -cameraY);
241
- const visible = world.entities.filter((e) => e.sprite).sort((a, b) => a.sprite!.layer - b.sprite!.layer);
242
- 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
+ }
243
392
  this.particles.draw(ctx);
244
393
  ctx.restore();
245
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
+
246
401
  ctx.textAlign = "left";
247
402
  ctx.font = "600 18px system-ui, sans-serif";
248
403
  ctx.fillStyle = "#ffffff";
@@ -265,6 +420,30 @@ class View {
265
420
  }
266
421
  }
267
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
+
268
447
  private drawSprite(ctx: CanvasRenderingContext2D, e: Entity, time: number): void {
269
448
  const sprite = e.sprite!;
270
449
  const width = sprite.width || e.body?.width || 16;
@@ -336,6 +515,20 @@ class View {
336
515
 
337
516
  type AnimationState = "idle" | "run" | "jump" | "fall";
338
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
+
339
532
  function animationState(e: Entity): AnimationState {
340
533
  const b = e.body;
341
534
  if (!b) return "idle";
package/src/components.ts CHANGED
@@ -4,7 +4,9 @@ export type FieldSpec =
4
4
  | { type: "boolean"; default?: boolean; description: string }
5
5
  | { type: "enum"; values: readonly string[]; default?: string; description: string }
6
6
  /** Spritesheet frame indices: "0", "1-4" or "0,2,4-6". Empty = not used. */
7
- | { type: "frames"; default?: string; description: string };
7
+ | { type: "frames"; default?: string; description: string }
8
+ /** Any JSON object. */
9
+ | { type: "object"; default?: Record<string, unknown>; description: string };
8
10
 
9
11
  export interface ComponentSpec {
10
12
  description: string;
@@ -18,11 +20,13 @@ export const COMPONENT_NAMES = [
18
20
  "body",
19
21
  "sprite",
20
22
  "platformer",
23
+ "topdown",
21
24
  "collectible",
22
25
  "patrol",
23
26
  "hazard",
24
27
  "goal",
25
28
  "emitter",
29
+ "script",
26
30
  "camera",
27
31
  ] as const;
28
32
  export type ComponentName = (typeof COMPONENT_NAMES)[number];
@@ -62,6 +66,12 @@ export const COMPONENTS: Record<ComponentName, ComponentSpec> = {
62
66
  height: { type: "number", min: 1, description: "Collider height in pixels." },
63
67
  sensor: { type: "boolean", default: false, description: "Sensors detect overlaps but never block movement (coins, triggers)." },
64
68
  gravityScale: { type: "number", default: 1, description: "Multiplier on world gravity for dynamic bodies." },
69
+ bounce: {
70
+ type: "number",
71
+ min: 0,
72
+ default: 0,
73
+ 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).",
74
+ },
65
75
  vx: { type: "number", default: 0, description: "Initial horizontal velocity in px/s." },
66
76
  vy: { type: "number", default: 0, description: "Initial vertical velocity in px/s." },
67
77
  },
@@ -99,8 +109,23 @@ export const COMPONENTS: Record<ComponentName, ComponentSpec> = {
99
109
  variableJump: { type: "boolean", default: true, description: "Releasing jump early cuts the jump short." },
100
110
  },
101
111
  },
112
+ topdown: {
113
+ description:
114
+ "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).",
115
+ requires: ["transform", "body"],
116
+ fields: {
117
+ speed: { type: "number", min: 0, default: 200, description: "Move speed in px/s." },
118
+ directions: {
119
+ type: "enum",
120
+ values: ["8", "4", "x", "y"],
121
+ default: "8",
122
+ description: '"8": any direction (diagonals keep the same speed). "4": up/down/left/right only. "x" or "y": one axis.',
123
+ },
124
+ clampToWorld: { type: "boolean", default: true, description: "Keep the entity inside the world bounds." },
125
+ },
126
+ },
102
127
  collectible: {
103
- description: "Removed when a platformer entity touches it and adds to the score. Its body must be a sensor.",
128
+ description: "Removed when a player (platformer or topdown) touches it and adds to the score. Its body must be a sensor.",
104
129
  requires: ["transform", "body"],
105
130
  fields: {
106
131
  score: { type: "number", default: 1, description: "Points added when collected." },
@@ -140,6 +165,15 @@ export const COMPONENTS: Record<ComponentName, ComponentSpec> = {
140
165
  ...Object.fromEntries(Object.entries(PARTICLE_FIELDS).filter(([field]) => field !== "count")),
141
166
  },
142
167
  },
168
+ script: {
169
+ description:
170
+ "Custom behavior written in TypeScript or JavaScript: a file that default-exports hooks (start, update, touch). Use it for anything the built-in components don't do: chasing, doors and keys, timers, shooting, boss phases. Run `ludra components script` for the API.",
171
+ requires: ["transform"],
172
+ fields: {
173
+ src: { type: "string", description: 'Script path relative to the project, e.g. "scripts/chase.ts".' },
174
+ params: { type: "object", default: {}, description: "Values passed to the script as ctx.params, so one script can serve many entities." },
175
+ },
176
+ },
143
177
  camera: {
144
178
  description: "The browser camera follows this entity. Without it, the camera follows the first platformer.",
145
179
  requires: ["transform"],
@@ -160,6 +194,7 @@ export interface Body {
160
194
  height: number;
161
195
  sensor: boolean;
162
196
  gravityScale: number;
197
+ bounce: number;
163
198
  vx: number;
164
199
  vy: number;
165
200
  }
@@ -197,6 +232,11 @@ export interface Emitter extends Omit<ParticleSpec, "count"> {
197
232
  rate: number;
198
233
  }
199
234
 
235
+ export interface ScriptComponent {
236
+ src: string;
237
+ params: Record<string, unknown>;
238
+ }
239
+
200
240
  export interface Platformer {
201
241
  speed: number;
202
242
  jumpSpeed: number;
@@ -205,6 +245,12 @@ export interface Platformer {
205
245
  variableJump: boolean;
206
246
  }
207
247
 
248
+ export interface Topdown {
249
+ speed: number;
250
+ directions: "8" | "4" | "x" | "y";
251
+ clampToWorld: boolean;
252
+ }
253
+
208
254
  export interface Collectible {
209
255
  score: number;
210
256
  }