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