@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/src/browser.ts CHANGED
@@ -3,10 +3,11 @@ 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
- import type { Action, SceneDef } from "./types.ts";
9
- import { World, type Entity } from "./world.ts";
9
+ import type { Action, PostFxDef, SceneDef } from "./types.ts";
10
+ import { World, type Entity, type GameEvent } from "./world.ts";
10
11
 
11
12
  const KEYMAP: Record<string, Action> = {
12
13
  ArrowLeft: "left",
@@ -35,6 +36,11 @@ export interface GameOptions {
35
36
  scriptBase?: string;
36
37
  /** Scripts to load. Defaults to those the scene references; include any that `world.spawn` uses. */
37
38
  scriptPaths?: readonly string[];
39
+ /**
40
+ * Loads another scene by path, for goals with `next` and `world.goto`. Without it the
41
+ * game stays on its first scene. Throw with a readable message if the scene is invalid.
42
+ */
43
+ loadScene?: (path: string) => Promise<SceneDef>;
38
44
  }
39
45
 
40
46
  /** Imports the game's script modules, keyed by their path in the scene. */
@@ -52,26 +58,182 @@ async function loadScripts(scene: SceneDef, options: GameOptions): Promise<Map<s
52
58
  return scripts;
53
59
  }
54
60
 
61
+ /** Fonts load from Google Fonts or project files; a slow or offline font never blocks the game for long. */
62
+ async function loadFonts(scene: SceneDef, assetBase: string): Promise<void> {
63
+ const fonts = Object.entries(scene.fonts ?? {});
64
+ if (fonts.length === 0) return;
65
+ const load = async ([family, src]: [string, string]) => {
66
+ if (src === "google") {
67
+ const name = encodeURIComponent(family).replace(/%20/g, "+");
68
+ // Not every family has every weight, and Google rejects the request if one is missing.
69
+ for (const weights of [":wght@400;500;600;700;800;900", ":wght@400;700", ""]) {
70
+ const response = await fetch(`https://fonts.googleapis.com/css2?family=${name}${weights}&display=block`);
71
+ if (!response.ok) continue;
72
+ const style = document.createElement("style");
73
+ style.textContent = await response.text();
74
+ document.head.append(style);
75
+ break;
76
+ }
77
+ await Promise.all(["400", "700", "900"].map((weight) => document.fonts.load(`${weight} 16px "${family}"`)));
78
+ } else {
79
+ const face = new FontFace(family, `url("${assetBase}${src}")`);
80
+ // FontFaceSet.add is missing from some TypeScript DOM typings.
81
+ (document.fonts as unknown as Set<FontFace>).add(await face.load());
82
+ }
83
+ };
84
+ const all = Promise.all(
85
+ fonts.map((font) => load(font).catch((err) => console.warn(`[ludra] could not load font "${font[0]}": ${err}`))),
86
+ );
87
+ await Promise.race([all, new Promise((resolve) => setTimeout(resolve, 4000))]);
88
+ }
89
+
90
+ /** A scene's postfx shader source, or why it couldn't be fetched. */
91
+ async function loadShader(scene: SceneDef, assetBase: string): Promise<string | Error | undefined> {
92
+ const path = scene.postfx?.shader;
93
+ if (!path) return undefined;
94
+ const response = await fetch(assetBase + path).catch((err: unknown) => err as Error);
95
+ if (response instanceof Error) return response;
96
+ return response.ok ? response.text() : new Error(`could not load ${path} (${response.status})`);
97
+ }
98
+
99
+ /**
100
+ * The canvas players see. The game always draws into the 2D canvas; when a scene
101
+ * has `postfx`, a WebGL canvas running its shader takes the 2D canvas's place in
102
+ * the page, and goes away again for a scene without one.
103
+ */
104
+ class Screen {
105
+ readonly ctx: CanvasRenderingContext2D;
106
+ output: HTMLCanvasElement;
107
+ private readonly canvas: HTMLCanvasElement;
108
+ private fx: PostFx | undefined;
109
+
110
+ constructor(canvas: HTMLCanvasElement) {
111
+ const ctx = canvas.getContext("2d");
112
+ if (!ctx) throw new Error("Canvas 2D context is not available.");
113
+ this.ctx = ctx;
114
+ this.canvas = canvas;
115
+ this.output = canvas;
116
+ }
117
+
118
+ /** Switches to a scene's shader, or to none. Returns why the shader can't run, if it can't. */
119
+ use(postfx: PostFxDef | undefined, shader: string | Error | undefined): string | undefined {
120
+ let fx: PostFx | undefined;
121
+ let error: string | undefined;
122
+ if (postfx && shader !== undefined) {
123
+ try {
124
+ if (shader instanceof Error) throw shader;
125
+ fx = new PostFx(this.canvas.width, this.canvas.height, shader, postfx);
126
+ } catch (err) {
127
+ error = `postfx: ${message(err)}`;
128
+ console.error("[ludra]", err);
129
+ }
130
+ }
131
+ const target = fx?.canvas ?? this.canvas;
132
+ if (target !== this.output) {
133
+ target.id = this.canvas.id;
134
+ target.className = this.canvas.className;
135
+ target.style.cssText = this.canvas.style.cssText;
136
+ this.output.replaceWith(target);
137
+ this.output = target;
138
+ }
139
+ this.fx = fx;
140
+ return error;
141
+ }
142
+
143
+ /** Shows the frame the game just drew, through the shader if there is one. */
144
+ present(time: number): void {
145
+ this.fx?.render(this.canvas, time);
146
+ }
147
+ }
148
+
149
+ /** What a scene needs before it can be drawn. */
150
+ async function loadScene(scene: SceneDef, options: GameOptions, scripts: Map<string, Script>) {
151
+ const assetBase = options.assetBase ?? "";
152
+ const [images, more, shader] = await Promise.all([
153
+ loadImages(scene, assetBase),
154
+ loadScripts(scene, options),
155
+ loadShader(scene, assetBase),
156
+ loadFonts(scene, assetBase),
157
+ ]);
158
+ for (const [path, script] of more) scripts.set(path, script);
159
+ return { images, shader };
160
+ }
161
+
162
+ function message(err: unknown): string {
163
+ return err instanceof Error ? err.message : String(err);
164
+ }
165
+
166
+ /** Seconds the win screen shows before the next scene, and the fade into a world.goto scene. */
167
+ const WIN_PAUSE = 1.6;
168
+ const GOTO_FADE = 0.35;
169
+
55
170
  /**
56
171
  * Runs a scene in a canvas with keyboard controls, images, particles and
57
172
  * sound. The simulation uses the same fixed step as headless runs, so what
58
173
  * tests verify is what players get; everything added here is presentation.
174
+ * With `options.loadScene`, won goals with `next` and `world.goto` move on
175
+ * to other scenes, keeping score, deaths and lives.
59
176
  * Resolves (after images load) to a function that stops the game.
60
177
  */
61
- 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);
67
- const view = new View(scene, images);
178
+ export async function startGame(canvas: HTMLCanvasElement, firstScene: SceneDef, options: GameOptions = {}): Promise<() => void> {
179
+ const screen = new Screen(canvas);
180
+ const ctx = screen.ctx;
181
+ const sounds = new SoundPlayer(options.assetBase ?? "");
182
+ const scripts = new Map<string, Script>();
68
183
 
69
- let world = new World(scene, { scripts });
184
+ type Carry = { score: number; deaths: number; lives: number | undefined };
185
+ let scene = firstScene;
186
+ let startCarry: Carry | undefined;
187
+ let view: View;
188
+ let world: World;
70
189
  let crash: string | undefined;
190
+ let sceneError: string | undefined;
191
+ /** Real time when the world asked to move on, while waiting to load the next scene. */
192
+ let exitSince: number | undefined;
193
+ let loading = false;
194
+
195
+ const enter = async (next: SceneDef, carry: Carry | undefined) => {
196
+ const { images, shader } = await loadScene(next, options, scripts);
197
+ scene = next;
198
+ startCarry = carry;
199
+ view = new View(next, images);
200
+ sceneError = screen.use(next.postfx, shader);
201
+ crash = sceneError;
202
+ exitSince = undefined;
203
+ try {
204
+ world = new World(next, { scripts, carry });
205
+ } catch (err) {
206
+ crash = message(err);
207
+ world = new World({ ...next, entities: [] });
208
+ }
209
+ };
210
+ await enter(firstScene, undefined);
211
+
212
+ // Game over starts the whole game again; otherwise R restarts the current scene as it began.
71
213
  const restart = () => {
72
- world = new World(scene, { scripts });
214
+ if (loading) return;
215
+ if (world.lost && scene !== firstScene) {
216
+ loading = true;
217
+ void enter(firstScene, undefined).finally(() => (loading = false));
218
+ return;
219
+ }
220
+ world = new World(scene, { scripts, carry: startCarry });
73
221
  view.reset();
74
- crash = undefined;
222
+ crash = sceneError;
223
+ exitSince = undefined;
224
+ };
225
+
226
+ const moveOn = (path: string) => {
227
+ if (!options.loadScene) return;
228
+ loading = true;
229
+ const carry = { score: world.score, deaths: world.deaths, lives: world.lives };
230
+ options
231
+ .loadScene(path)
232
+ .then((next) => enter(next, carry))
233
+ .catch((err: unknown) => {
234
+ crash = `Could not load the next scene "${path}": ${message(err)}`;
235
+ })
236
+ .finally(() => (loading = false));
75
237
  };
76
238
 
77
239
  const held = new Set<Action>();
@@ -105,20 +267,42 @@ export async function startGame(canvas: HTMLCanvasElement, scene: SceneDef, opti
105
267
  const frame = (now: number) => {
106
268
  accumulator += Math.min(MAX_FRAME_TIME, (now - last) / 1000);
107
269
  last = now;
108
- while (accumulator >= FIXED_DT && !crash) {
270
+ while (accumulator >= FIXED_DT && !crash && !loading) {
109
271
  try {
110
272
  world.step(tapped.size > 0 ? new Set([...held, ...tapped]) : held);
111
273
  } catch (err) {
112
274
  // A script threw: freeze the game and show why, instead of failing silently.
113
- crash = err instanceof Error ? err.message : String(err);
275
+ crash = message(err);
114
276
  console.error("[ludra]", err);
115
277
  }
116
278
  tapped.clear();
117
279
  view.step(world, (sound) => sounds.play(sound));
118
280
  accumulator -= FIXED_DT;
119
281
  }
120
- view.draw(ctx, world, sounds.muted);
282
+
283
+ // Moving on: let the win screen show, or fade out for a world.goto, then load.
284
+ let fade = 0;
285
+ if (world.exit !== undefined && options.loadScene && !crash) {
286
+ exitSince ??= now;
287
+ const pause = world.won ? WIN_PAUSE : GOTO_FADE;
288
+ const elapsed = (now - exitSince) / 1000;
289
+ fade = Math.min(1, Math.max(0, (elapsed - (pause - GOTO_FADE)) / GOTO_FADE));
290
+ if (elapsed >= pause && !loading) moveOn(world.exit);
291
+ }
292
+
293
+ try {
294
+ view.draw(ctx, world, sounds.muted, options.loadScene !== undefined);
295
+ } catch (err) {
296
+ // A draw hook threw. Same treatment as a simulation error.
297
+ if (!crash) console.error("[ludra]", err);
298
+ crash ??= message(err);
299
+ }
300
+ if (fade > 0 || loading) {
301
+ ctx.fillStyle = `rgba(0, 0, 0, ${loading ? 1 : fade})`;
302
+ ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
303
+ }
121
304
  if (crash) drawCrash(ctx, crash);
305
+ screen.present(world.time);
122
306
  raf = requestAnimationFrame(frame);
123
307
  };
124
308
  raf = requestAnimationFrame(frame);
@@ -152,9 +336,11 @@ export interface CapturedFrame {
152
336
  * `ludra play --screenshot`.
153
337
  */
154
338
  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)]);
339
+ const screen = new Screen(canvas);
340
+ const scripts = new Map<string, Script>();
341
+ const { images, shader } = await loadScene(scene, options, scripts);
342
+ const error = screen.use(scene.postfx, shader);
343
+ if (error) throw new Error(error);
158
344
  const view = new View(scene, images);
159
345
  const world = new World(scene, { scripts });
160
346
  const script = InputScript.parse(options.inputs ?? "");
@@ -164,8 +350,9 @@ export async function captureFrames(canvas: HTMLCanvasElement, scene: SceneDef,
164
350
 
165
351
  for (let frame = 0; ; frame++) {
166
352
  if (wanted.has(frame)) {
167
- view.draw(ctx, world, false);
168
- captured.push({ frame, time: frame * FIXED_DT, png: canvas.toDataURL("image/png") });
353
+ view.draw(screen.ctx, world, false, false);
354
+ screen.present(world.time);
355
+ captured.push({ frame, time: frame * FIXED_DT, png: screen.output.toDataURL("image/png") });
169
356
  }
170
357
  if (frame >= last) break;
171
358
  world.step(script.at(frame));
@@ -228,20 +415,29 @@ class View {
228
415
  private readonly facing = new Map<string, number>();
229
416
  private readonly emitTimers = new Map<string, number>();
230
417
  private readonly frameCache = new Map<string, number[]>();
418
+ /** ctx.local for each entity's draw hooks. */
419
+ private readonly local = new Map<string, Record<string, unknown>>();
231
420
  private shake = 0;
232
421
  private handledEvents = 0;
233
422
  private readonly scene: SceneDef;
234
423
  private readonly images: Map<string, HTMLImageElement>;
235
424
 
425
+ /** Whether the scene can award points at all, so the default HUD can leave the score out. */
426
+ private readonly scoring: boolean;
427
+
236
428
  constructor(scene: SceneDef, images: Map<string, HTMLImageElement>) {
237
429
  this.scene = scene;
238
430
  this.images = images;
431
+ this.scoring = scene.entities.some(
432
+ (e) => e.components.collectible || (e.components.hazard && Number(e.components.hazard.score) > 0) || Number(e.components.goal?.requireScore) > 0,
433
+ );
239
434
  }
240
435
 
241
436
  reset(): void {
242
437
  this.particles.clear();
243
438
  this.facing.clear();
244
439
  this.emitTimers.clear();
440
+ this.local.clear();
245
441
  this.shake = 0;
246
442
  this.handledEvents = 0;
247
443
  }
@@ -272,13 +468,14 @@ class View {
272
468
  this.shake = this.shake < 0.5 ? 0 : this.shake * SHAKE_DECAY;
273
469
  }
274
470
 
275
- draw(ctx: CanvasRenderingContext2D, world: World, muted: boolean): void {
471
+ /** `continues`: the host loads next scenes, so a won scene with `next` says so instead of offering a replay. */
472
+ draw(ctx: CanvasRenderingContext2D, world: World, muted: boolean, continues: boolean): void {
276
473
  const { width: viewWidth, height: viewHeight } = ctx.canvas;
277
474
  ctx.imageSmoothingEnabled = false;
278
475
  ctx.fillStyle = world.background;
279
476
  ctx.fillRect(0, 0, viewWidth, viewHeight);
280
477
 
281
- const target = world.entities.find((e) => e.camera) ?? world.entities.find((e) => e.platformer);
478
+ const target = world.entities.find((e) => e.camera) ?? world.entities.find((e) => e.platformer || e.topdown);
282
479
  const zoom = target?.camera?.zoom ?? 1;
283
480
  const visibleWidth = viewWidth / zoom;
284
481
  const visibleHeight = viewHeight / zoom;
@@ -289,33 +486,75 @@ class View {
289
486
  cameraY += (this.particles.random() - 0.5) * 2 * this.shake;
290
487
  }
291
488
 
489
+ const viewInfo = { width: viewWidth, height: viewHeight, cameraX, cameraY, zoom };
292
490
  ctx.save();
293
491
  ctx.scale(zoom, zoom);
294
492
  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);
493
+ const layer = (e: Entity) => e.sprite?.layer ?? 0;
494
+ const visible = world.entities.filter((e) => e.sprite || e.script?.module.draw).sort((a, b) => layer(a) - layer(b));
495
+ for (const e of visible) {
496
+ if (e.sprite) this.drawSprite(ctx, e, world.time);
497
+ if (e.script?.module.draw) this.drawHook(ctx, world, e, "draw", viewInfo);
498
+ }
297
499
  this.particles.draw(ctx);
298
500
  ctx.restore();
299
501
 
502
+ if (world.hasHook("drawUI")) {
503
+ // The game draws its own interface.
504
+ for (const e of [...world.entities]) if (e.script?.module.drawUI) this.drawHook(ctx, world, e, "drawUI", viewInfo);
505
+ return;
506
+ }
507
+
508
+ // Only what this scene uses: no "Score 0" in a game without points.
509
+ const parts: string[] = [];
510
+ if (world.lives !== undefined) parts.push(`${"♥".repeat(Math.max(0, Math.min(world.lives, 10)))}${world.lives > 10 ? ` ${world.lives}` : ""}`);
511
+ if (this.scoring || world.score !== 0) parts.push(`Score ${world.score}`);
512
+ if (world.lives === undefined) parts.push(`Deaths ${world.deaths}`);
300
513
  ctx.textAlign = "left";
301
514
  ctx.font = "600 18px system-ui, sans-serif";
302
515
  ctx.fillStyle = "#ffffff";
303
- ctx.fillText(`Score ${world.score} Deaths ${world.deaths}`, 16, 30);
516
+ ctx.fillText(parts.join(" "), 16, 30);
304
517
  ctx.textAlign = "right";
305
518
  ctx.font = "500 13px system-ui, sans-serif";
306
519
  ctx.fillStyle = "rgba(255, 255, 255, 0.6)";
307
520
  ctx.fillText(`M: sound ${muted ? "off" : "on"} · R: restart`, viewWidth - 16, 28);
308
521
 
309
- if (world.won) {
522
+ if (world.won || world.lost) {
310
523
  ctx.fillStyle = "rgba(0, 0, 0, 0.55)";
311
524
  ctx.fillRect(0, 0, viewWidth, viewHeight);
312
525
  ctx.fillStyle = "#ffffff";
313
526
  ctx.textAlign = "center";
314
527
  ctx.font = "700 44px system-ui, sans-serif";
315
- ctx.fillText("Level complete!", viewWidth / 2, viewHeight / 2 - 10);
528
+ ctx.fillText(world.won ? "Level complete!" : "Game over", viewWidth / 2, viewHeight / 2 - 10);
316
529
  ctx.font = "500 18px system-ui, sans-serif";
317
- const winTime = world.events.find((e) => e.type === "win")?.time ?? world.time;
318
- ctx.fillText(`Score ${world.score} · ${winTime.toFixed(1)}s · Press R to play again`, viewWidth / 2, viewHeight / 2 + 30);
530
+ const endTime = world.events.find((e) => e.type === "win" || e.type === "gameover")?.time ?? world.time;
531
+ const next = world.won && world.exit !== undefined && continues ? "Get ready…" : `Press R to ${world.won ? "play again" : "try again"}`;
532
+ const score = this.scoring || world.score !== 0 ? `Score ${world.score} · ` : "";
533
+ ctx.fillText(`${score}${endTime.toFixed(1)}s · ${next}`, viewWidth / 2, viewHeight / 2 + 30);
534
+ }
535
+ }
536
+
537
+ private drawHook(ctx: CanvasRenderingContext2D, world: World, e: Entity, hook: "draw" | "drawUI", view: DrawContext["view"]): void {
538
+ let local = this.local.get(e.id);
539
+ if (!local) this.local.set(e.id, (local = {}));
540
+ ctx.save();
541
+ try {
542
+ world.callDrawHook(e, hook, {
543
+ g: ctx,
544
+ time: world.time,
545
+ local,
546
+ view,
547
+ recent: (name, seconds = 0.5) => recentEvents(world, name, seconds),
548
+ image: (path) => this.images.get(path),
549
+ burst: (x, y, particles = {}) => this.particles.burst(x, y, particles),
550
+ shake: (amount) => {
551
+ this.shake = Math.max(this.shake, amount);
552
+ },
553
+ random: () => this.particles.random(),
554
+ });
555
+ } finally {
556
+ // Whatever the hook changed (transform, alpha, blend mode, filter) ends with it.
557
+ ctx.restore();
319
558
  }
320
559
  }
321
560
 
@@ -390,6 +629,23 @@ class View {
390
629
 
391
630
  type AnimationState = "idle" | "run" | "jump" | "fall";
392
631
 
632
+ function recentEvents(world: World, name: string | undefined, seconds: number): DrawEvent[] {
633
+ const since = world.time - seconds;
634
+ const result: DrawEvent[] = [];
635
+ // Events are in time order, so walk back until they get too old.
636
+ for (let i = world.events.length - 1; i >= 0; i--) {
637
+ const event = world.events[i]!;
638
+ if (event.time < since) break;
639
+ const eventName = event.type === "custom" ? event.name : event.type;
640
+ if (name !== undefined && eventName !== name) continue;
641
+ // Custom events carry what the script passed; built-in ones their own details (item, enemy, checkpoint...).
642
+ const { type: _type, frame: _frame, time, x, y, entity, ...details } = event as GameEvent & { data?: unknown };
643
+ const data = event.type === "custom" ? event.data : details;
644
+ result.push({ name: eventName, time, x, y, entity, data });
645
+ }
646
+ return result.reverse();
647
+ }
648
+
393
649
  function animationState(e: Entity): AnimationState {
394
650
  const b = e.body;
395
651
  if (!b) return "idle";
package/src/components.ts CHANGED
@@ -20,10 +20,12 @@ export const COMPONENT_NAMES = [
20
20
  "body",
21
21
  "sprite",
22
22
  "platformer",
23
+ "topdown",
23
24
  "collectible",
24
25
  "patrol",
25
26
  "hazard",
26
27
  "goal",
28
+ "checkpoint",
27
29
  "emitter",
28
30
  "script",
29
31
  "camera",
@@ -65,6 +67,12 @@ export const COMPONENTS: Record<ComponentName, ComponentSpec> = {
65
67
  height: { type: "number", min: 1, description: "Collider height in pixels." },
66
68
  sensor: { type: "boolean", default: false, description: "Sensors detect overlaps but never block movement (coins, triggers)." },
67
69
  gravityScale: { type: "number", default: 1, description: "Multiplier on world gravity for dynamic bodies." },
70
+ bounce: {
71
+ type: "number",
72
+ min: 0,
73
+ default: 0,
74
+ 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).",
75
+ },
68
76
  vx: { type: "number", default: 0, description: "Initial horizontal velocity in px/s." },
69
77
  vy: { type: "number", default: 0, description: "Initial vertical velocity in px/s." },
70
78
  },
@@ -102,8 +110,23 @@ export const COMPONENTS: Record<ComponentName, ComponentSpec> = {
102
110
  variableJump: { type: "boolean", default: true, description: "Releasing jump early cuts the jump short." },
103
111
  },
104
112
  },
113
+ topdown: {
114
+ description:
115
+ "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).",
116
+ requires: ["transform", "body"],
117
+ fields: {
118
+ speed: { type: "number", min: 0, default: 200, description: "Move speed in px/s." },
119
+ directions: {
120
+ type: "enum",
121
+ values: ["8", "4", "x", "y"],
122
+ default: "8",
123
+ description: '"8": any direction (diagonals keep the same speed). "4": up/down/left/right only. "x" or "y": one axis.',
124
+ },
125
+ clampToWorld: { type: "boolean", default: true, description: "Keep the entity inside the world bounds." },
126
+ },
127
+ },
105
128
  collectible: {
106
- description: "Removed when a platformer entity touches it and adds to the score. Its body must be a sensor.",
129
+ description: "Removed when a player (platformer or topdown) touches it and adds to the score. Its body must be a sensor.",
107
130
  requires: ["transform", "body"],
108
131
  fields: {
109
132
  score: { type: "number", default: 1, description: "Points added when collected." },
@@ -133,8 +156,19 @@ export const COMPONENTS: Record<ComponentName, ComponentSpec> = {
133
156
  requires: ["transform", "body"],
134
157
  fields: {
135
158
  requireScore: { type: "number", min: 0, default: 0, description: "Score needed before the goal accepts the player." },
159
+ next: {
160
+ type: "string",
161
+ default: "",
162
+ 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.',
163
+ },
136
164
  },
137
165
  },
166
+ checkpoint: {
167
+ description:
168
+ "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.",
169
+ requires: ["transform", "body"],
170
+ fields: {},
171
+ },
138
172
  emitter: {
139
173
  description: "Continuously emits visual particles from the entity (torches, sparkles, smoke). It does not affect gameplay.",
140
174
  requires: ["transform"],
@@ -172,6 +206,7 @@ export interface Body {
172
206
  height: number;
173
207
  sensor: boolean;
174
208
  gravityScale: number;
209
+ bounce: number;
175
210
  vx: number;
176
211
  vy: number;
177
212
  }
@@ -222,6 +257,12 @@ export interface Platformer {
222
257
  variableJump: boolean;
223
258
  }
224
259
 
260
+ export interface Topdown {
261
+ speed: number;
262
+ directions: "8" | "4" | "x" | "y";
263
+ clampToWorld: boolean;
264
+ }
265
+
225
266
  export interface Collectible {
226
267
  score: number;
227
268
  }
@@ -239,6 +280,7 @@ export interface Hazard {
239
280
 
240
281
  export interface Goal {
241
282
  requireScore: number;
283
+ next: string;
242
284
  }
243
285
 
244
286
  export interface Camera {
@@ -0,0 +1,80 @@
1
+ import { ScriptError, type ScriptModules } from "./script.ts";
2
+ import type { Diagnostic, SceneDef } from "./types.ts";
3
+ import { World } from "./world.ts";
4
+
5
+ /** Simulation frames at which `checkDrawHooks` paints: the start, and after things have moved. */
6
+ const CHECK_FRAMES = [0, 30, 120];
7
+
8
+ /**
9
+ * A stand-in for CanvasRenderingContext2D that accepts every call and property,
10
+ * so draw hooks can run outside a browser. It catches the hook's own mistakes
11
+ * (typos, undefined values, changing the game while drawing), not what it looks like.
12
+ */
13
+ function mockCanvas(): CanvasRenderingContext2D {
14
+ const handler: ProxyHandler<() => unknown> = {
15
+ get: (_target, key) => (key === Symbol.toPrimitive ? () => 0 : key === "then" ? undefined : any),
16
+ set: () => true,
17
+ apply: () => any,
18
+ };
19
+ const any: unknown = new Proxy(() => undefined, handler);
20
+ return any as CanvasRenderingContext2D;
21
+ }
22
+
23
+ /**
24
+ * Runs every `draw` and `drawUI` hook against a mock canvas at a few points of
25
+ * an idle run, and reports hooks that throw. Used by `ludra check`.
26
+ */
27
+ export function checkDrawHooks(scene: SceneDef, scripts: ScriptModules): Diagnostic[] {
28
+ let world: World;
29
+ try {
30
+ world = new World(scene, { scripts });
31
+ } catch {
32
+ return []; // Load problems are reported elsewhere.
33
+ }
34
+ const reported = new Set<string>();
35
+ const diagnostics: Diagnostic[] = [];
36
+ const g = mockCanvas();
37
+ const view = { width: world.width, height: world.height, cameraX: 0, cameraY: 0, zoom: 1 };
38
+ const locals = new Map<string, Record<string, unknown>>();
39
+
40
+ for (let frame = 0; frame <= CHECK_FRAMES.at(-1)!; frame++) {
41
+ if (CHECK_FRAMES.includes(frame)) {
42
+ for (const e of [...world.entities]) {
43
+ for (const hook of ["draw", "drawUI"] as const) {
44
+ if (!e.script?.module[hook]) continue;
45
+ if (!locals.has(e.id)) locals.set(e.id, {});
46
+ try {
47
+ world.callDrawHook(e, hook, {
48
+ g,
49
+ time: world.time,
50
+ local: locals.get(e.id)!,
51
+ view,
52
+ recent: () => [],
53
+ image: () => undefined,
54
+ burst: () => {},
55
+ shake: () => {},
56
+ random: () => 0.5,
57
+ });
58
+ } catch (err) {
59
+ const key = `${e.script.src}:${hook}`;
60
+ if (reported.has(key)) continue;
61
+ reported.add(key);
62
+ diagnostics.push({
63
+ severity: "error",
64
+ code: "E_DRAW_HOOK",
65
+ path: `${e.script.src} → ${hook}`,
66
+ message: err instanceof ScriptError ? err.message : String(err),
67
+ fix: "Draw hooks run every rendered frame in the browser. Fix the error; ctx.image() can return undefined, so check it before drawing.",
68
+ });
69
+ }
70
+ }
71
+ }
72
+ }
73
+ try {
74
+ world.step(new Set());
75
+ } catch {
76
+ break; // Simulation errors surface in play and test.
77
+ }
78
+ }
79
+ return diagnostics;
80
+ }
package/src/effects.ts CHANGED
@@ -7,6 +7,7 @@ export type SoundPreset = (typeof SOUND_PRESETS)[number];
7
7
 
8
8
  export const AUDIO_EXTENSIONS = [".wav", ".mp3", ".ogg"] as const;
9
9
  export const IMAGE_EXTENSIONS = [".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp"] as const;
10
+ export const FONT_EXTENSIONS = [".woff2", ".woff", ".ttf", ".otf"] as const;
10
11
 
11
12
  export const EFFECT_FIELDS: Record<string, FieldSpec> = {
12
13
  sound: {
@@ -18,7 +19,7 @@ export const EFFECT_FIELDS: Record<string, FieldSpec> = {
18
19
  };
19
20
 
20
21
  export const EFFECTS_DESCRIPTION =
21
- 'Scene-level "effects" map a game event (jump, land, collect, die, stomp, win) to feedback: a particle burst at the event position, a sound and a screen shake. They never affect gameplay.';
22
+ 'Scene-level "effects" map a game event (jump, land, collect, die, stomp, win, bounce, checkpoint, gameover, goto, or a custom one) to feedback: a particle burst at the event position, a sound and a screen shake. They never affect gameplay.';
22
23
 
23
24
  export function isSoundPreset(sound: string): sound is SoundPreset {
24
25
  return (SOUND_PRESETS as readonly string[]).includes(sound);
@@ -29,7 +30,7 @@ export interface AssetReference {
29
30
  path: string;
30
31
  /** JSON path of the field that references it. */
31
32
  at: string;
32
- kind: "image" | "audio" | "script";
33
+ kind: "image" | "audio" | "script" | "font" | "shader" | "scene";
33
34
  }
34
35
 
35
36
  /** Every file a scene points at, so tools can check that they exist. */
@@ -38,12 +39,27 @@ export function collectAssetPaths(scene: SceneDef): AssetReference[] {
38
39
  scene.entities.forEach((entity, i) => {
39
40
  const image = entity.components.sprite?.image;
40
41
  if (typeof image === "string" && image !== "") refs.push({ path: image, at: `entities[${i}].components.sprite.image`, kind: "image" });
42
+ const next = entity.components.goal?.next;
43
+ if (typeof next === "string" && next !== "") refs.push({ path: next, at: `entities[${i}].components.goal.next`, kind: "scene" });
41
44
  const src = entity.components.script?.src;
42
45
  if (typeof src === "string" && src !== "") refs.push({ path: src, at: `entities[${i}].components.script.src`, kind: "script" });
46
+ // Images named in script params are preloaded for draw hooks (ctx.image).
47
+ const params = entity.components.script?.params;
48
+ if (params && typeof params === "object") {
49
+ for (const [key, value] of Object.entries(params)) {
50
+ if (typeof value === "string" && IMAGE_EXTENSIONS.some((ext) => value.toLowerCase().endsWith(ext))) {
51
+ refs.push({ path: value, at: `entities[${i}].components.script.params.${key}`, kind: "image" });
52
+ }
53
+ }
54
+ }
43
55
  });
44
56
  for (const [event, effect] of Object.entries(scene.effects ?? {})) {
45
57
  const sound = effect?.sound;
46
58
  if (typeof sound === "string" && sound !== "" && !isSoundPreset(sound)) refs.push({ path: sound, at: `effects.${event}.sound`, kind: "audio" });
47
59
  }
60
+ for (const [family, src] of Object.entries(scene.fonts ?? {})) {
61
+ if (typeof src === "string" && src !== "google") refs.push({ path: src, at: `fonts.${family}`, kind: "font" });
62
+ }
63
+ if (typeof scene.postfx?.shader === "string" && scene.postfx.shader !== "") refs.push({ path: scene.postfx.shader, at: "postfx.shader", kind: "shader" });
48
64
  return refs;
49
65
  }