@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/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))
package/dist/browser.js CHANGED
@@ -11,6 +11,7 @@ import { parseFrames } from "./components.js";
11
11
  import { collectAssetPaths } from "./effects.js";
12
12
  import { InputScript } from "./input.js";
13
13
  import { ParticleSystem } from "./particles.js";
14
+ import { PostFx } from "./postfx.js";
14
15
  import { FIXED_DT } from "./time.js";
15
16
  import { World } from "./world.js";
16
17
  const KEYMAP = {
@@ -44,6 +45,76 @@ async function loadScripts(scene, options) {
44
45
  }));
45
46
  return scripts;
46
47
  }
48
+ /** Fonts load from Google Fonts or project files; a slow or offline font never blocks the game for long. */
49
+ async function loadFonts(scene, assetBase) {
50
+ const fonts = Object.entries(scene.fonts ?? {});
51
+ if (fonts.length === 0)
52
+ return;
53
+ const load = async ([family, src]) => {
54
+ if (src === "google") {
55
+ const name = encodeURIComponent(family).replace(/%20/g, "+");
56
+ // Not every family has every weight, and Google rejects the request if one is missing.
57
+ for (const weights of [":wght@400;500;600;700;800;900", ":wght@400;700", ""]) {
58
+ const response = await fetch(`https://fonts.googleapis.com/css2?family=${name}${weights}&display=block`);
59
+ if (!response.ok)
60
+ continue;
61
+ const style = document.createElement("style");
62
+ style.textContent = await response.text();
63
+ document.head.append(style);
64
+ break;
65
+ }
66
+ await Promise.all(["400", "700", "900"].map((weight) => document.fonts.load(`${weight} 16px "${family}"`)));
67
+ }
68
+ else {
69
+ const face = new FontFace(family, `url("${assetBase}${src}")`);
70
+ // FontFaceSet.add is missing from some TypeScript DOM typings.
71
+ document.fonts.add(await face.load());
72
+ }
73
+ };
74
+ const all = Promise.all(fonts.map((font) => load(font).catch((err) => console.warn(`[ludra] could not load font "${font[0]}": ${err}`))));
75
+ await Promise.race([all, new Promise((resolve) => setTimeout(resolve, 4000))]);
76
+ }
77
+ /**
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
+ }
47
118
  /**
48
119
  * Runs a scene in a canvas with keyboard controls, images, particles and
49
120
  * sound. The simulation uses the same fixed step as headless runs, so what
@@ -51,19 +122,15 @@ async function loadScripts(scene, options) {
51
122
  * Resolves (after images load) to a function that stops the game.
52
123
  */
53
124
  export async function startGame(canvas, scene, options = {}) {
54
- const ctx = canvas.getContext("2d");
55
- if (!ctx)
56
- throw new Error("Canvas 2D context is not available.");
57
- const assetBase = options.assetBase ?? "";
58
- const [images, scripts] = await Promise.all([loadImages(scene, assetBase), loadScripts(scene, options)]);
59
- const sounds = new SoundPlayer(assetBase);
125
+ const { ctx, images, scripts, present, error } = await prepare(canvas, scene, options);
126
+ const sounds = new SoundPlayer(options.assetBase ?? "");
60
127
  const view = new View(scene, images);
61
128
  let world = new World(scene, { scripts });
62
- let crash;
129
+ let crash = error;
63
130
  const restart = () => {
64
131
  world = new World(scene, { scripts });
65
132
  view.reset();
66
- crash = undefined;
133
+ crash = error;
67
134
  };
68
135
  const held = new Set();
69
136
  // Keys pressed since the last step. A tap shorter than one frame still reaches the simulation.
@@ -105,16 +172,25 @@ export async function startGame(canvas, scene, options = {}) {
105
172
  }
106
173
  catch (err) {
107
174
  // A script threw: freeze the game and show why, instead of failing silently.
108
- crash = err instanceof Error ? err.message : String(err);
175
+ crash = message(err);
109
176
  console.error("[ludra]", err);
110
177
  }
111
178
  tapped.clear();
112
179
  view.step(world, (sound) => sounds.play(sound));
113
180
  accumulator -= FIXED_DT;
114
181
  }
115
- 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
+ }
116
191
  if (crash)
117
192
  drawCrash(ctx, crash);
193
+ present(world.time);
118
194
  raf = requestAnimationFrame(frame);
119
195
  };
120
196
  raf = requestAnimationFrame(frame);
@@ -132,10 +208,9 @@ export async function startGame(canvas, scene, options = {}) {
132
208
  * `ludra play --screenshot`.
133
209
  */
134
210
  export async function captureFrames(canvas, scene, options) {
135
- const ctx = canvas.getContext("2d");
136
- if (!ctx)
137
- throw new Error("Canvas 2D context is not available.");
138
- const [images, scripts] = await Promise.all([loadImages(scene, options.assetBase ?? ""), loadScripts(scene, options)]);
211
+ const { ctx, output, images, scripts, present, error } = await prepare(canvas, scene, options);
212
+ if (error)
213
+ throw new Error(error);
139
214
  const view = new View(scene, images);
140
215
  const world = new World(scene, { scripts });
141
216
  const script = InputScript.parse(options.inputs ?? "");
@@ -145,7 +220,8 @@ export async function captureFrames(canvas, scene, options) {
145
220
  for (let frame = 0;; frame++) {
146
221
  if (wanted.has(frame)) {
147
222
  view.draw(ctx, world, false);
148
- 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") });
149
225
  }
150
226
  if (frame >= last)
151
227
  break;
@@ -201,6 +277,8 @@ class View {
201
277
  facing = new Map();
202
278
  emitTimers = new Map();
203
279
  frameCache = new Map();
280
+ /** ctx.local for each entity's draw hooks. */
281
+ local = new Map();
204
282
  shake = 0;
205
283
  handledEvents = 0;
206
284
  scene;
@@ -213,6 +291,7 @@ class View {
213
291
  this.particles.clear();
214
292
  this.facing.clear();
215
293
  this.emitTimers.clear();
294
+ this.local.clear();
216
295
  this.shake = 0;
217
296
  this.handledEvents = 0;
218
297
  }
@@ -251,7 +330,7 @@ class View {
251
330
  ctx.imageSmoothingEnabled = false;
252
331
  ctx.fillStyle = world.background;
253
332
  ctx.fillRect(0, 0, viewWidth, viewHeight);
254
- 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);
255
334
  const zoom = target?.camera?.zoom ?? 1;
256
335
  const visibleWidth = viewWidth / zoom;
257
336
  const visibleHeight = viewHeight / zoom;
@@ -261,14 +340,27 @@ class View {
261
340
  cameraX += (this.particles.random() - 0.5) * 2 * this.shake;
262
341
  cameraY += (this.particles.random() - 0.5) * 2 * this.shake;
263
342
  }
343
+ const viewInfo = { width: viewWidth, height: viewHeight, cameraX, cameraY, zoom };
264
344
  ctx.save();
265
345
  ctx.scale(zoom, zoom);
266
346
  ctx.translate(-cameraX, -cameraY);
267
- const visible = world.entities.filter((e) => e.sprite).sort((a, b) => a.sprite.layer - b.sprite.layer);
268
- for (const e of visible)
269
- this.drawSprite(ctx, e, world.time);
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
+ }
270
355
  this.particles.draw(ctx);
271
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
+ }
272
364
  ctx.textAlign = "left";
273
365
  ctx.font = "600 18px system-ui, sans-serif";
274
366
  ctx.fillStyle = "#ffffff";
@@ -289,6 +381,31 @@ class View {
289
381
  ctx.fillText(`Score ${world.score} · ${winTime.toFixed(1)}s · Press R to play again`, viewWidth / 2, viewHeight / 2 + 30);
290
382
  }
291
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
+ }
292
409
  drawSprite(ctx, e, time) {
293
410
  const sprite = e.sprite;
294
411
  const width = sprite.width || e.body?.width || 16;
@@ -356,6 +473,21 @@ class View {
356
473
  return [0];
357
474
  }
358
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
+ }
359
491
  function animationState(e) {
360
492
  const b = e.body;
361
493
  if (!b)
@@ -35,7 +35,7 @@ export interface ComponentSpec {
35
35
  /** Fields without a default are required. */
36
36
  fields: Record<string, FieldSpec>;
37
37
  }
38
- export declare const COMPONENT_NAMES: readonly ["transform", "body", "sprite", "platformer", "collectible", "patrol", "hazard", "goal", "emitter", "script", "camera"];
38
+ export declare const COMPONENT_NAMES: readonly ["transform", "body", "sprite", "platformer", "topdown", "collectible", "patrol", "hazard", "goal", "emitter", "script", "camera"];
39
39
  export type ComponentName = (typeof COMPONENT_NAMES)[number];
40
40
  /** Shared by the `emitter` component and the particles of scene `effects`. Purely visual. */
41
41
  export declare const PARTICLE_FIELDS: Record<string, FieldSpec>;
@@ -55,6 +55,7 @@ export interface Body {
55
55
  height: number;
56
56
  sensor: boolean;
57
57
  gravityScale: number;
58
+ bounce: number;
58
59
  vx: number;
59
60
  vy: number;
60
61
  }
@@ -99,6 +100,11 @@ export interface Platformer {
99
100
  jumpBuffer: number;
100
101
  variableJump: boolean;
101
102
  }
103
+ export interface Topdown {
104
+ speed: number;
105
+ directions: "8" | "4" | "x" | "y";
106
+ clampToWorld: boolean;
107
+ }
102
108
  export interface Collectible {
103
109
  score: number;
104
110
  }
@@ -3,6 +3,7 @@ export const COMPONENT_NAMES = [
3
3
  "body",
4
4
  "sprite",
5
5
  "platformer",
6
+ "topdown",
6
7
  "collectible",
7
8
  "patrol",
8
9
  "hazard",
@@ -44,6 +45,12 @@ export const COMPONENTS = {
44
45
  height: { type: "number", min: 1, description: "Collider height in pixels." },
45
46
  sensor: { type: "boolean", default: false, description: "Sensors detect overlaps but never block movement (coins, triggers)." },
46
47
  gravityScale: { type: "number", default: 1, description: "Multiplier on world gravity for dynamic bodies." },
48
+ bounce: {
49
+ type: "number",
50
+ min: 0,
51
+ default: 0,
52
+ 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).",
53
+ },
47
54
  vx: { type: "number", default: 0, description: "Initial horizontal velocity in px/s." },
48
55
  vy: { type: "number", default: 0, description: "Initial vertical velocity in px/s." },
49
56
  },
@@ -80,8 +87,22 @@ export const COMPONENTS = {
80
87
  variableJump: { type: "boolean", default: true, description: "Releasing jump early cuts the jump short." },
81
88
  },
82
89
  },
90
+ topdown: {
91
+ description: "Player control seen from above or for vehicles: moves with the arrow keys, no gravity. directions picks 8-way, 4-way, or one axis (\"x\" for a breakout paddle or a shooter ship). Needs a dynamic body (collides with walls) or a kinematic one (passes through, use clampToWorld).",
92
+ requires: ["transform", "body"],
93
+ fields: {
94
+ speed: { type: "number", min: 0, default: 200, description: "Move speed in px/s." },
95
+ directions: {
96
+ type: "enum",
97
+ values: ["8", "4", "x", "y"],
98
+ default: "8",
99
+ description: '"8": any direction (diagonals keep the same speed). "4": up/down/left/right only. "x" or "y": one axis.',
100
+ },
101
+ clampToWorld: { type: "boolean", default: true, description: "Keep the entity inside the world bounds." },
102
+ },
103
+ },
83
104
  collectible: {
84
- description: "Removed when a platformer entity touches it and adds to the score. Its body must be a sensor.",
105
+ description: "Removed when a player (platformer or topdown) touches it and adds to the score. Its body must be a sensor.",
85
106
  requires: ["transform", "body"],
86
107
  fields: {
87
108
  score: { type: "number", default: 1, description: "Points added when collected." },
@@ -0,0 +1,7 @@
1
+ import { type ScriptModules } from "./script.ts";
2
+ import type { Diagnostic, SceneDef } from "./types.ts";
3
+ /**
4
+ * Runs every `draw` and `drawUI` hook against a mock canvas at a few points of
5
+ * an idle run, and reports hooks that throw. Used by `ludra check`.
6
+ */
7
+ export declare function checkDrawHooks(scene: SceneDef, scripts: ScriptModules): Diagnostic[];
@@ -0,0 +1,81 @@
1
+ import { ScriptError } from "./script.js";
2
+ import { World } from "./world.js";
3
+ /** Simulation frames at which `checkDrawHooks` paints: the start, and after things have moved. */
4
+ const CHECK_FRAMES = [0, 30, 120];
5
+ /**
6
+ * A stand-in for CanvasRenderingContext2D that accepts every call and property,
7
+ * so draw hooks can run outside a browser. It catches the hook's own mistakes
8
+ * (typos, undefined values, changing the game while drawing), not what it looks like.
9
+ */
10
+ function mockCanvas() {
11
+ const handler = {
12
+ get: (_target, key) => (key === Symbol.toPrimitive ? () => 0 : key === "then" ? undefined : any),
13
+ set: () => true,
14
+ apply: () => any,
15
+ };
16
+ const any = new Proxy(() => undefined, handler);
17
+ return any;
18
+ }
19
+ /**
20
+ * Runs every `draw` and `drawUI` hook against a mock canvas at a few points of
21
+ * an idle run, and reports hooks that throw. Used by `ludra check`.
22
+ */
23
+ export function checkDrawHooks(scene, scripts) {
24
+ let world;
25
+ try {
26
+ world = new World(scene, { scripts });
27
+ }
28
+ catch {
29
+ return []; // Load problems are reported elsewhere.
30
+ }
31
+ const reported = new Set();
32
+ const diagnostics = [];
33
+ const g = mockCanvas();
34
+ const view = { width: world.width, height: world.height, cameraX: 0, cameraY: 0, zoom: 1 };
35
+ const locals = new Map();
36
+ for (let frame = 0; frame <= CHECK_FRAMES.at(-1); frame++) {
37
+ if (CHECK_FRAMES.includes(frame)) {
38
+ for (const e of [...world.entities]) {
39
+ for (const hook of ["draw", "drawUI"]) {
40
+ if (!e.script?.module[hook])
41
+ continue;
42
+ if (!locals.has(e.id))
43
+ locals.set(e.id, {});
44
+ try {
45
+ world.callDrawHook(e, hook, {
46
+ g,
47
+ time: world.time,
48
+ local: locals.get(e.id),
49
+ view,
50
+ recent: () => [],
51
+ image: () => undefined,
52
+ burst: () => { },
53
+ shake: () => { },
54
+ random: () => 0.5,
55
+ });
56
+ }
57
+ catch (err) {
58
+ const key = `${e.script.src}:${hook}`;
59
+ if (reported.has(key))
60
+ 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
+ }
76
+ catch {
77
+ break; // Simulation errors surface in play and test.
78
+ }
79
+ }
80
+ return diagnostics;
81
+ }
package/dist/effects.d.ts CHANGED
@@ -5,6 +5,7 @@ export declare const SOUND_PRESETS: readonly ["jump", "coin", "hit", "explosion"
5
5
  export type SoundPreset = (typeof SOUND_PRESETS)[number];
6
6
  export declare const AUDIO_EXTENSIONS: readonly [".wav", ".mp3", ".ogg"];
7
7
  export declare const IMAGE_EXTENSIONS: readonly [".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp"];
8
+ export declare const FONT_EXTENSIONS: readonly [".woff2", ".woff", ".ttf", ".otf"];
8
9
  export declare const EFFECT_FIELDS: Record<string, FieldSpec>;
9
10
  export declare const EFFECTS_DESCRIPTION = "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.";
10
11
  export declare function isSoundPreset(sound: string): sound is SoundPreset;
@@ -13,7 +14,7 @@ export interface AssetReference {
13
14
  path: string;
14
15
  /** JSON path of the field that references it. */
15
16
  at: string;
16
- kind: "image" | "audio" | "script";
17
+ kind: "image" | "audio" | "script" | "font" | "shader";
17
18
  }
18
19
  /** Every file a scene points at, so tools can check that they exist. */
19
20
  export declare function collectAssetPaths(scene: SceneDef): AssetReference[];
package/dist/effects.js CHANGED
@@ -2,6 +2,7 @@
2
2
  export const SOUND_PRESETS = ["jump", "coin", "hit", "explosion", "powerup", "blip", "thud"];
3
3
  export const AUDIO_EXTENSIONS = [".wav", ".mp3", ".ogg"];
4
4
  export const IMAGE_EXTENSIONS = [".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp"];
5
+ export const FONT_EXTENSIONS = [".woff2", ".woff", ".ttf", ".otf"];
5
6
  export const EFFECT_FIELDS = {
6
7
  sound: {
7
8
  type: "string",
@@ -24,11 +25,26 @@ export function collectAssetPaths(scene) {
24
25
  const src = entity.components.script?.src;
25
26
  if (typeof src === "string" && src !== "")
26
27
  refs.push({ path: src, at: `entities[${i}].components.script.src`, kind: "script" });
28
+ // Images named in script params are preloaded for draw hooks (ctx.image).
29
+ const params = entity.components.script?.params;
30
+ if (params && typeof params === "object") {
31
+ for (const [key, value] of Object.entries(params)) {
32
+ if (typeof value === "string" && IMAGE_EXTENSIONS.some((ext) => value.toLowerCase().endsWith(ext))) {
33
+ refs.push({ path: value, at: `entities[${i}].components.script.params.${key}`, kind: "image" });
34
+ }
35
+ }
36
+ }
27
37
  });
28
38
  for (const [event, effect] of Object.entries(scene.effects ?? {})) {
29
39
  const sound = effect?.sound;
30
40
  if (typeof sound === "string" && sound !== "" && !isSoundPreset(sound))
31
41
  refs.push({ path: sound, at: `effects.${event}.sound`, kind: "audio" });
32
42
  }
43
+ for (const [family, src] of Object.entries(scene.fonts ?? {})) {
44
+ if (typeof src === "string" && src !== "google")
45
+ refs.push({ path: src, at: `fonts.${family}`, kind: "font" });
46
+ }
47
+ if (typeof scene.postfx?.shader === "string" && scene.postfx.shader !== "")
48
+ refs.push({ path: scene.postfx.shader, at: "postfx.shader", kind: "shader" });
33
49
  return refs;
34
50
  }
package/dist/index.d.ts CHANGED
@@ -9,3 +9,4 @@ export * from "./world.ts";
9
9
  export * from "./simulate.ts";
10
10
  export * from "./assertions.ts";
11
11
  export * from "./ascii.ts";
12
+ export * from "./drawcheck.ts";
package/dist/index.js CHANGED
@@ -9,3 +9,4 @@ export * from "./world.js";
9
9
  export * from "./simulate.js";
10
10
  export * from "./assertions.js";
11
11
  export * from "./ascii.js";
12
+ export * from "./drawcheck.js";
@@ -0,0 +1,18 @@
1
+ import type { PostFxDef } from "./types.ts";
2
+ /**
3
+ * Runs a scene's GLSL fragment shader over each rendered frame. The game draws
4
+ * into an offscreen 2D canvas; this uploads it as a texture and draws it
5
+ * through the shader onto a WebGL canvas.
6
+ */
7
+ export declare class PostFx {
8
+ readonly canvas: HTMLCanvasElement;
9
+ private readonly gl;
10
+ private readonly program;
11
+ private readonly texture;
12
+ private readonly uniforms;
13
+ private readonly time;
14
+ /** Throws with the GLSL compiler's message if the shader doesn't compile. */
15
+ constructor(width: number, height: number, source: string, def: PostFxDef);
16
+ /** Draws `source` (the frame the game just rendered) through the shader. */
17
+ render(source: HTMLCanvasElement, time: number): void;
18
+ }
package/dist/postfx.js ADDED
@@ -0,0 +1,100 @@
1
+ /** Declared by the engine for every postfx shader, so shaders only write `main`. */
2
+ const ENGINE_HEADER = `precision mediump float;
3
+ uniform sampler2D u_image;
4
+ uniform vec2 u_resolution;
5
+ uniform float u_time;
6
+ varying vec2 v_uv;
7
+ `;
8
+ const VERTEX_SHADER = `attribute vec2 a_position;
9
+ varying vec2 v_uv;
10
+ void main() {
11
+ v_uv = a_position * 0.5 + 0.5;
12
+ gl_Position = vec4(a_position, 0.0, 1.0);
13
+ }`;
14
+ /**
15
+ * Runs a scene's GLSL fragment shader over each rendered frame. The game draws
16
+ * into an offscreen 2D canvas; this uploads it as a texture and draws it
17
+ * through the shader onto a WebGL canvas.
18
+ */
19
+ export class PostFx {
20
+ canvas;
21
+ gl;
22
+ program;
23
+ texture;
24
+ uniforms;
25
+ time;
26
+ /** Throws with the GLSL compiler's message if the shader doesn't compile. */
27
+ constructor(width, height, source, def) {
28
+ this.canvas = document.createElement("canvas");
29
+ this.canvas.width = width;
30
+ this.canvas.height = height;
31
+ // preserveDrawingBuffer keeps the frame readable for screenshots.
32
+ const gl = this.canvas.getContext("webgl", { preserveDrawingBuffer: true, premultipliedAlpha: false });
33
+ if (!gl)
34
+ throw new Error("postfx needs WebGL, which this browser doesn't provide.");
35
+ this.gl = gl;
36
+ const custom = Object.keys(def.uniforms ?? {});
37
+ // #line 1 makes compiler errors point at lines of the shader file itself.
38
+ const header = ENGINE_HEADER + custom.map((name) => `uniform float ${name};\n`).join("") + "#line 1\n";
39
+ this.program = link(gl, VERTEX_SHADER, header + stripDeclarations(source, ["u_image", "u_resolution", "u_time", "v_uv", ...custom]), def.shader);
40
+ gl.useProgram(this.program);
41
+ const buffer = gl.createBuffer();
42
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
43
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
44
+ const position = gl.getAttribLocation(this.program, "a_position");
45
+ gl.enableVertexAttribArray(position);
46
+ gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
47
+ this.texture = gl.createTexture();
48
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
49
+ for (const [key, value] of [
50
+ [gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE],
51
+ [gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE],
52
+ [gl.TEXTURE_MIN_FILTER, gl.LINEAR],
53
+ [gl.TEXTURE_MAG_FILTER, gl.LINEAR],
54
+ ]) {
55
+ gl.texParameteri(gl.TEXTURE_2D, key, value);
56
+ }
57
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
58
+ gl.uniform1i(gl.getUniformLocation(this.program, "u_image"), 0);
59
+ gl.uniform2f(gl.getUniformLocation(this.program, "u_resolution"), width, height);
60
+ this.time = gl.getUniformLocation(this.program, "u_time");
61
+ this.uniforms = custom.map((name) => ({ location: gl.getUniformLocation(this.program, name), value: def.uniforms[name] }));
62
+ }
63
+ /** Draws `source` (the frame the game just rendered) through the shader. */
64
+ render(source, time) {
65
+ const gl = this.gl;
66
+ gl.viewport(0, 0, this.canvas.width, this.canvas.height);
67
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
68
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
69
+ gl.uniform1f(this.time, time);
70
+ for (const u of this.uniforms)
71
+ gl.uniform1f(u.location, u.value);
72
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
73
+ }
74
+ }
75
+ /** Drops the shader's own declarations of names the engine already declares, so either style compiles. */
76
+ function stripDeclarations(source, names) {
77
+ return source
78
+ .split("\n")
79
+ .map((line) => (/^\s*(uniform|varying)\b/.test(line) && names.some((name) => new RegExp(`\\b${name}\\b`).test(line)) ? "" : line))
80
+ .join("\n");
81
+ }
82
+ function link(gl, vertex, fragment, path) {
83
+ const program = gl.createProgram();
84
+ gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, vertex, "the engine's vertex shader"));
85
+ gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, fragment, path));
86
+ gl.linkProgram(program);
87
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS))
88
+ throw new Error(`${path} failed to link: ${gl.getProgramInfoLog(program)}`);
89
+ return program;
90
+ }
91
+ function compile(gl, type, source, path) {
92
+ const shader = gl.createShader(type);
93
+ gl.shaderSource(shader, source);
94
+ gl.compileShader(shader);
95
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
96
+ const log = String(gl.getShaderInfoLog(shader)).trim();
97
+ throw new Error(`${path} failed to compile: ${log}`);
98
+ }
99
+ return shader;
100
+ }