@ludra/core 0.4.0 → 0.5.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.
@@ -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,3 +1,11 @@
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";
@@ -23,6 +31,19 @@ const KEYMAP = {
23
31
  const MAX_FRAME_TIME = 0.25;
24
32
  /** Screen shake decays by this factor every step. */
25
33
  const SHAKE_DECAY = 0.88;
34
+ /** Imports the game's script modules, keyed by their path in the scene. */
35
+ async function loadScripts(scene, options) {
36
+ const base = options.scriptBase ?? options.assetBase ?? "";
37
+ const paths = options.scriptPaths ?? collectAssetPaths(scene).filter((ref) => ref.kind === "script").map((ref) => ref.path);
38
+ const scripts = new Map();
39
+ await Promise.all([...new Set(paths)].map(async (path) => {
40
+ const module = (await import(__rewriteRelativeImportExtension(base + path)));
41
+ if (!module.default)
42
+ throw new Error(`Script ${path} has no default export.`);
43
+ scripts.set(path, module.default);
44
+ }));
45
+ return scripts;
46
+ }
26
47
  /**
27
48
  * Runs a scene in a canvas with keyboard controls, images, particles and
28
49
  * sound. The simulation uses the same fixed step as headless runs, so what
@@ -34,13 +55,15 @@ export async function startGame(canvas, scene, options = {}) {
34
55
  if (!ctx)
35
56
  throw new Error("Canvas 2D context is not available.");
36
57
  const assetBase = options.assetBase ?? "";
37
- const images = await loadImages(scene, assetBase);
58
+ const [images, scripts] = await Promise.all([loadImages(scene, assetBase), loadScripts(scene, options)]);
38
59
  const sounds = new SoundPlayer(assetBase);
39
60
  const view = new View(scene, images);
40
- let world = new World(scene);
61
+ let world = new World(scene, { scripts });
62
+ let crash;
41
63
  const restart = () => {
42
- world = new World(scene);
64
+ world = new World(scene, { scripts });
43
65
  view.reset();
66
+ crash = undefined;
44
67
  };
45
68
  const held = new Set();
46
69
  // Keys pressed since the last step. A tap shorter than one frame still reaches the simulation.
@@ -76,13 +99,22 @@ export async function startGame(canvas, scene, options = {}) {
76
99
  const frame = (now) => {
77
100
  accumulator += Math.min(MAX_FRAME_TIME, (now - last) / 1000);
78
101
  last = now;
79
- while (accumulator >= FIXED_DT) {
80
- world.step(tapped.size > 0 ? new Set([...held, ...tapped]) : held);
102
+ while (accumulator >= FIXED_DT && !crash) {
103
+ try {
104
+ world.step(tapped.size > 0 ? new Set([...held, ...tapped]) : held);
105
+ }
106
+ catch (err) {
107
+ // A script threw: freeze the game and show why, instead of failing silently.
108
+ crash = err instanceof Error ? err.message : String(err);
109
+ console.error("[ludra]", err);
110
+ }
81
111
  tapped.clear();
82
112
  view.step(world, (sound) => sounds.play(sound));
83
113
  accumulator -= FIXED_DT;
84
114
  }
85
115
  view.draw(ctx, world, sounds.muted);
116
+ if (crash)
117
+ drawCrash(ctx, crash);
86
118
  raf = requestAnimationFrame(frame);
87
119
  };
88
120
  raf = requestAnimationFrame(frame);
@@ -103,9 +135,9 @@ export async function captureFrames(canvas, scene, options) {
103
135
  const ctx = canvas.getContext("2d");
104
136
  if (!ctx)
105
137
  throw new Error("Canvas 2D context is not available.");
106
- const images = await loadImages(scene, options.assetBase ?? "");
138
+ const [images, scripts] = await Promise.all([loadImages(scene, options.assetBase ?? ""), loadScripts(scene, options)]);
107
139
  const view = new View(scene, images);
108
- const world = new World(scene);
140
+ const world = new World(scene, { scripts });
109
141
  const script = InputScript.parse(options.inputs ?? "");
110
142
  const wanted = new Set(options.frames);
111
143
  const last = Math.max(0, ...options.frames);
@@ -122,6 +154,30 @@ export async function captureFrames(canvas, scene, options) {
122
154
  }
123
155
  return captured;
124
156
  }
157
+ function drawCrash(ctx, message) {
158
+ const { width, height } = ctx.canvas;
159
+ ctx.fillStyle = "rgba(20, 0, 8, 0.85)";
160
+ ctx.fillRect(0, 0, width, height);
161
+ ctx.fillStyle = "#ff9b9b";
162
+ ctx.textAlign = "left";
163
+ ctx.font = "700 22px system-ui, sans-serif";
164
+ ctx.fillText("Script error", 32, 56);
165
+ ctx.font = "15px ui-monospace, monospace";
166
+ // Wrap the message to the canvas width.
167
+ let line = "";
168
+ let y = 92;
169
+ for (const word of message.split(" ")) {
170
+ if (ctx.measureText(line + word).width > width - 64) {
171
+ ctx.fillText(line, 32, y);
172
+ line = "";
173
+ y += 22;
174
+ }
175
+ line += `${word} `;
176
+ }
177
+ ctx.fillText(line, 32, y);
178
+ ctx.fillStyle = "rgba(255, 255, 255, 0.6)";
179
+ ctx.fillText("Fix the script; the page reloads on save. R restarts.", 32, y + 40);
180
+ }
125
181
  async function loadImages(scene, assetBase) {
126
182
  const images = new Map();
127
183
  const paths = new Set(collectAssetPaths(scene).filter((ref) => ref.kind === "image").map((ref) => ref.path));
@@ -163,7 +219,7 @@ class View {
163
219
  /** Runs once per simulation step: turns new events into effects and advances particles. */
164
220
  step(world, playSound) {
165
221
  for (const event of world.events.slice(this.handledEvents)) {
166
- const effect = this.scene.effects?.[event.type];
222
+ const effect = this.scene.effects?.[event.type === "custom" ? event.name : event.type];
167
223
  if (!effect)
168
224
  continue;
169
225
  if (effect.particles)
@@ -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", "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>;
@@ -82,6 +88,10 @@ export interface ParticleSpec {
82
88
  export interface Emitter extends Omit<ParticleSpec, "count"> {
83
89
  rate: number;
84
90
  }
91
+ export interface ScriptComponent {
92
+ src: string;
93
+ params: Record<string, unknown>;
94
+ }
85
95
  export interface Platformer {
86
96
  speed: number;
87
97
  jumpSpeed: number;
@@ -8,6 +8,7 @@ export const COMPONENT_NAMES = [
8
8
  "hazard",
9
9
  "goal",
10
10
  "emitter",
11
+ "script",
11
12
  "camera",
12
13
  ];
13
14
  /** Shared by the `emitter` component and the particles of scene `effects`. Purely visual. */
@@ -118,6 +119,14 @@ export const COMPONENTS = {
118
119
  ...Object.fromEntries(Object.entries(PARTICLE_FIELDS).filter(([field]) => field !== "count")),
119
120
  },
120
121
  },
122
+ script: {
123
+ description: "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.",
124
+ requires: ["transform"],
125
+ fields: {
126
+ src: { type: "string", description: 'Script path relative to the project, e.g. "scripts/chase.ts".' },
127
+ params: { type: "object", default: {}, description: "Values passed to the script as ctx.params, so one script can serve many entities." },
128
+ },
129
+ },
121
130
  camera: {
122
131
  description: "The browser camera follows this entity. Without it, the camera follows the first platformer.",
123
132
  requires: ["transform"],
package/dist/effects.d.ts CHANGED
@@ -13,7 +13,7 @@ export interface AssetReference {
13
13
  path: string;
14
14
  /** JSON path of the field that references it. */
15
15
  at: string;
16
- kind: "image" | "audio";
16
+ kind: "image" | "audio" | "script";
17
17
  }
18
18
  /** Every file a scene points at, so tools can check that they exist. */
19
19
  export declare function collectAssetPaths(scene: SceneDef): AssetReference[];
package/dist/effects.js CHANGED
@@ -21,6 +21,9 @@ export function collectAssetPaths(scene) {
21
21
  const image = entity.components.sprite?.image;
22
22
  if (typeof image === "string" && image !== "")
23
23
  refs.push({ path: image, at: `entities[${i}].components.sprite.image`, kind: "image" });
24
+ const src = entity.components.script?.src;
25
+ if (typeof src === "string" && src !== "")
26
+ refs.push({ path: src, at: `entities[${i}].components.script.src`, kind: "script" });
24
27
  });
25
28
  for (const [event, effect] of Object.entries(scene.effects ?? {})) {
26
29
  const sound = effect?.sound;
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export * from "./components.ts";
4
4
  export * from "./effects.ts";
5
5
  export * from "./validate.ts";
6
6
  export * from "./input.ts";
7
+ export * from "./script.ts";
7
8
  export * from "./world.ts";
8
9
  export * from "./simulate.ts";
9
10
  export * from "./assertions.ts";
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ export * from "./components.js";
4
4
  export * from "./effects.js";
5
5
  export * from "./validate.js";
6
6
  export * from "./input.js";
7
+ export * from "./script.js";
7
8
  export * from "./world.js";
8
9
  export * from "./simulate.js";
9
10
  export * from "./assertions.js";
@@ -0,0 +1,108 @@
1
+ import type { Action, Diagnostic } from "./types.ts";
2
+ /**
3
+ * A game object as scripts see it. Reading and writing `x`, `y`, `vx` and
4
+ * `vy` changes the entity directly.
5
+ */
6
+ export interface ScriptEntity {
7
+ readonly id: string;
8
+ x: number;
9
+ y: number;
10
+ /** Velocity in px/s. Always 0 for entities without a body. */
11
+ vx: number;
12
+ vy: number;
13
+ readonly grounded: boolean;
14
+ readonly width: number;
15
+ readonly height: number;
16
+ /** False once the entity has been destroyed or collected. */
17
+ readonly exists: boolean;
18
+ /**
19
+ * Data kept between frames. Use JSON values only. Tests can read it as
20
+ * `<id>.state.<key>`, for example `"door.state.open == true"`.
21
+ */
22
+ readonly state: Record<string, unknown>;
23
+ has(component: string): boolean;
24
+ /** True when this entity overlaps or touches the other one. */
25
+ touches(other: ScriptEntity | string): boolean;
26
+ }
27
+ export interface SpawnDef {
28
+ /** Generated when left out. */
29
+ id?: string;
30
+ components: Record<string, Record<string, unknown>>;
31
+ }
32
+ export interface ScriptWorld {
33
+ readonly time: number;
34
+ readonly frame: number;
35
+ readonly width: number;
36
+ readonly height: number;
37
+ score: number;
38
+ get(id: string): ScriptEntity | undefined;
39
+ /** Every entity, or only those with the given component. */
40
+ all(component?: string): ScriptEntity[];
41
+ /** The first entity with a `platformer` component. */
42
+ player(): ScriptEntity | undefined;
43
+ spawn(def: SpawnDef): ScriptEntity;
44
+ destroy(entity: ScriptEntity | string): void;
45
+ /** Players die and respawn; anything else is destroyed. */
46
+ kill(entity: ScriptEntity | string): void;
47
+ /** Completes the level, like reaching a goal. */
48
+ win(): void;
49
+ /** Fires a custom event that tests can count (`events.<name>`) and `effects` can react to. */
50
+ emit(name: string, data?: unknown): void;
51
+ /** A number in [0, 1). Seeded by `world.seed`, so runs stay deterministic. Never use Math.random. */
52
+ random(): number;
53
+ }
54
+ export interface ScriptInput {
55
+ /** True while the action is held. */
56
+ held(action: Action): boolean;
57
+ /** True only on the frame the action is pressed. */
58
+ pressed(action: Action): boolean;
59
+ }
60
+ export interface ScriptContext {
61
+ self: ScriptEntity;
62
+ world: ScriptWorld;
63
+ input: ScriptInput;
64
+ /** The `params` object from the entity's `script` component. */
65
+ params: Record<string, unknown>;
66
+ /** Seconds per frame (1/60). */
67
+ dt: number;
68
+ }
69
+ /**
70
+ * The default export of a script file. All hooks are optional.
71
+ *
72
+ * ```ts
73
+ * import type { Script } from "@ludra/core";
74
+ * export default {
75
+ * update({ self, world, params }) { ... },
76
+ * } satisfies Script;
77
+ * ```
78
+ */
79
+ export interface Script {
80
+ /** Runs once, on the entity's first frame. */
81
+ start?(ctx: ScriptContext): void;
82
+ /** Runs every frame, after player input and before physics. */
83
+ update?(ctx: ScriptContext): void;
84
+ /** Runs when this entity starts touching another one (after physics). */
85
+ touch?(ctx: ScriptContext, other: ScriptEntity): void;
86
+ }
87
+ export declare const SCRIPT_HOOKS: readonly ["start", "update", "touch"];
88
+ /** Printed by `ludra components script`. Keep in sync with the interfaces above. */
89
+ export declare const SCRIPT_API_REFERENCE = "A script file default-exports hooks. Only \"import type\" is allowed: scripts run without a bundler.\n\n import type { Script } from \"@ludra/core\";\n export default {\n start(ctx) {}, // once, on the entity's first frame\n update(ctx) {}, // every frame, after player input, before physics\n touch(ctx, other) {}, // when this entity starts touching another body (after physics)\n } satisfies Script;\n\nctx.self the entity: id, x, y, vx, vy (read/write); grounded, width, height, exists (read);\n state (object kept between frames, testable as \"<id>.state.<key>\");\n has(\"component\"), touches(otherOrId)\nctx.world time, frame, width, height, score (read/write)\n get(id), all(component?), player()\n spawn({ id?, components }) -> entity destroy(entityOrId)\n kill(entityOrId) players respawn, others are destroyed\n win() completes the level\n emit(name, data?) custom event: counted by \"events.<name>\" in tests, usable as an effects key\n random() deterministic, seeded by world.seed. Never use Math.random or Date\nctx.input held(action), pressed(action) actions: left right up down jump action\nctx.params the \"params\" object from the script component\nctx.dt seconds per frame (1/60)\n\nScripts in scripts/ are loaded even if no entity uses them, so world.spawn can attach them.\nErrors name the file, entity, hook and frame.";
90
+ export type ScriptModules = ReadonlyMap<string, Script> | Readonly<Record<string, Script>>;
91
+ export declare function getScript(modules: ScriptModules | undefined, src: string): Script | undefined;
92
+ /** A script threw, or was used wrongly. Names the file, entity, hook and frame. */
93
+ export declare class ScriptError extends Error {
94
+ readonly src: string;
95
+ readonly entity: string;
96
+ readonly hook: string;
97
+ readonly frame: number;
98
+ constructor(details: {
99
+ src: string;
100
+ entity: string;
101
+ hook: string;
102
+ frame: number;
103
+ }, message: string, options?: ErrorOptions);
104
+ }
105
+ /** Checks that a loaded module looks like a script. */
106
+ export declare function validateScriptModule(module: unknown, src: string): Diagnostic[];
107
+ /** Scripts run in the engine without a bundler, so they cannot import runtime code. */
108
+ export declare function findRuntimeImports(source: string): string[];
package/dist/script.js ADDED
@@ -0,0 +1,87 @@
1
+ import { closestName } from "./validate.js";
2
+ export const SCRIPT_HOOKS = ["start", "update", "touch"];
3
+ /** Printed by `ludra components script`. Keep in sync with the interfaces above. */
4
+ export const SCRIPT_API_REFERENCE = `A script file default-exports hooks. Only "import type" is allowed: scripts run without a bundler.
5
+
6
+ import type { Script } from "@ludra/core";
7
+ export default {
8
+ start(ctx) {}, // once, on the entity's first frame
9
+ update(ctx) {}, // every frame, after player input, before physics
10
+ touch(ctx, other) {}, // when this entity starts touching another body (after physics)
11
+ } satisfies Script;
12
+
13
+ ctx.self the entity: id, x, y, vx, vy (read/write); grounded, width, height, exists (read);
14
+ state (object kept between frames, testable as "<id>.state.<key>");
15
+ has("component"), touches(otherOrId)
16
+ ctx.world time, frame, width, height, score (read/write)
17
+ get(id), all(component?), player()
18
+ spawn({ id?, components }) -> entity destroy(entityOrId)
19
+ kill(entityOrId) players respawn, others are destroyed
20
+ win() completes the level
21
+ emit(name, data?) custom event: counted by "events.<name>" in tests, usable as an effects key
22
+ random() deterministic, seeded by world.seed. Never use Math.random or Date
23
+ ctx.input held(action), pressed(action) actions: left right up down jump action
24
+ ctx.params the "params" object from the script component
25
+ ctx.dt seconds per frame (1/60)
26
+
27
+ Scripts in scripts/ are loaded even if no entity uses them, so world.spawn can attach them.
28
+ Errors name the file, entity, hook and frame.`;
29
+ export function getScript(modules, src) {
30
+ if (!modules)
31
+ return undefined;
32
+ return modules instanceof Map ? modules.get(src) : modules[src];
33
+ }
34
+ /** A script threw, or was used wrongly. Names the file, entity, hook and frame. */
35
+ export class ScriptError extends Error {
36
+ src;
37
+ entity;
38
+ hook;
39
+ frame;
40
+ constructor(details, message, options) {
41
+ super(`${details.src} (${details.hook} on entity "${details.entity}", frame ${details.frame}): ${message}`, options);
42
+ this.src = details.src;
43
+ this.entity = details.entity;
44
+ this.hook = details.hook;
45
+ this.frame = details.frame;
46
+ }
47
+ }
48
+ /** Checks that a loaded module looks like a script. */
49
+ export function validateScriptModule(module, src) {
50
+ const diagnostics = [];
51
+ const script = module?.default;
52
+ if (typeof script !== "object" || script === null) {
53
+ diagnostics.push({
54
+ severity: "error",
55
+ code: "E_SCRIPT_NO_DEFAULT_EXPORT",
56
+ path: src,
57
+ message: `${src} must default-export an object with script hooks.`,
58
+ fix: "Use: export default { update({ self, world }) { ... } } satisfies Script;",
59
+ });
60
+ return diagnostics;
61
+ }
62
+ for (const [key, value] of Object.entries(script)) {
63
+ if (!SCRIPT_HOOKS.includes(key)) {
64
+ const suggestion = closestName(key, SCRIPT_HOOKS);
65
+ diagnostics.push({
66
+ severity: "warning",
67
+ code: "W_SCRIPT_UNKNOWN_HOOK",
68
+ path: `${src} → ${key}`,
69
+ message: `"${key}" is not a script hook, so it is never called.`,
70
+ fix: `${suggestion ? `Did you mean "${suggestion}"? ` : ""}Hooks are ${SCRIPT_HOOKS.join(", ")}. Put helpers outside the exported object.`,
71
+ });
72
+ }
73
+ else if (typeof value !== "function") {
74
+ diagnostics.push({ severity: "error", code: "E_SCRIPT_HOOK_NOT_FUNCTION", path: `${src} → ${key}`, message: `"${key}" must be a function.` });
75
+ }
76
+ }
77
+ return diagnostics;
78
+ }
79
+ /** Scripts run in the engine without a bundler, so they cannot import runtime code. */
80
+ export function findRuntimeImports(source) {
81
+ const imports = [];
82
+ for (const match of source.matchAll(/^\s*import\s+(?!type\b)([^;\n]*?)\s+from\s+["']([^"']+)["']/gm))
83
+ imports.push(match[2]);
84
+ for (const match of source.matchAll(/^\s*import\s+["']([^"']+)["']/gm))
85
+ imports.push(match[1]);
86
+ return imports;
87
+ }
package/dist/types.d.ts CHANGED
@@ -20,13 +20,18 @@ export interface WorldSettings {
20
20
  /** Downward acceleration in px/s². Defaults to 1800. */
21
21
  gravity?: number;
22
22
  background?: string;
23
+ /** Seed for `world.random()` in scripts. Defaults to 1. */
24
+ seed?: number;
23
25
  }
24
26
  /** A scene file (`*.scene.json`). */
25
27
  export interface SceneDef {
26
28
  name?: string;
27
29
  world?: WorldSettings;
28
- /** Feedback for game events, e.g. `{ "collect": { "sound": "coin" } }`. Visual and audio only. */
29
- effects?: Partial<Record<GameEventType, EffectDef>>;
30
+ /**
31
+ * Feedback for game events, e.g. `{ "collect": { "sound": "coin" } }`. Visual and audio only.
32
+ * Keys are built-in event types or names scripts pass to `world.emit`.
33
+ */
34
+ effects?: Partial<Record<GameEventType, EffectDef>> & Record<string, EffectDef | undefined>;
30
35
  entities: EntityDef[];
31
36
  }
32
37
  export interface Diagnostic {
@@ -10,3 +10,5 @@ export interface ValidationResult {
10
10
  * where possible, a fix an agent can apply without guessing.
11
11
  */
12
12
  export declare function validateScene(raw: unknown): ValidationResult;
13
+ /** Returns the candidate within a small edit distance of `input`, if any. */
14
+ export declare function closestName(input: string, candidates: readonly string[]): string | undefined;