@ludra/core 0.3.1 → 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
@@ -10,3 +14,22 @@ export interface GameOptions {
10
14
  * Resolves (after images load) to a function that stops the game.
11
15
  */
12
16
  export declare function startGame(canvas: HTMLCanvasElement, scene: SceneDef, options?: GameOptions): Promise<() => void>;
17
+ export interface CaptureOptions extends GameOptions {
18
+ /** Input script, e.g. `"right:1s, jump:0.4s"`. See `InputScript`. */
19
+ inputs?: string;
20
+ /** Simulation frames to capture (60 per second). Frame 0 is the starting state. */
21
+ frames: readonly number[];
22
+ }
23
+ export interface CapturedFrame {
24
+ frame: number;
25
+ time: number;
26
+ /** PNG as a data URL. */
27
+ png: string;
28
+ }
29
+ /**
30
+ * Plays a scene without a clock or keyboard and returns the requested frames
31
+ * as PNGs. Same renderer, effects and particles as `startGame`, stepped
32
+ * deterministically, so a frame always looks the same. Used by
33
+ * `ludra play --screenshot`.
34
+ */
35
+ export declare function captureFrames(canvas: HTMLCanvasElement, scene: SceneDef, options: CaptureOptions): Promise<CapturedFrame[]>;
package/dist/browser.js CHANGED
@@ -1,6 +1,15 @@
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";
12
+ import { InputScript } from "./input.js";
4
13
  import { ParticleSystem } from "./particles.js";
5
14
  import { FIXED_DT } from "./time.js";
6
15
  import { World } from "./world.js";
@@ -22,6 +31,19 @@ const KEYMAP = {
22
31
  const MAX_FRAME_TIME = 0.25;
23
32
  /** Screen shake decays by this factor every step. */
24
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
+ }
25
47
  /**
26
48
  * Runs a scene in a canvas with keyboard controls, images, particles and
27
49
  * sound. The simulation uses the same fixed step as headless runs, so what
@@ -33,13 +55,15 @@ export async function startGame(canvas, scene, options = {}) {
33
55
  if (!ctx)
34
56
  throw new Error("Canvas 2D context is not available.");
35
57
  const assetBase = options.assetBase ?? "";
36
- const images = await loadImages(scene, assetBase);
58
+ const [images, scripts] = await Promise.all([loadImages(scene, assetBase), loadScripts(scene, options)]);
37
59
  const sounds = new SoundPlayer(assetBase);
38
60
  const view = new View(scene, images);
39
- let world = new World(scene);
61
+ let world = new World(scene, { scripts });
62
+ let crash;
40
63
  const restart = () => {
41
- world = new World(scene);
64
+ world = new World(scene, { scripts });
42
65
  view.reset();
66
+ crash = undefined;
43
67
  };
44
68
  const held = new Set();
45
69
  // Keys pressed since the last step. A tap shorter than one frame still reaches the simulation.
@@ -75,13 +99,22 @@ export async function startGame(canvas, scene, options = {}) {
75
99
  const frame = (now) => {
76
100
  accumulator += Math.min(MAX_FRAME_TIME, (now - last) / 1000);
77
101
  last = now;
78
- while (accumulator >= FIXED_DT) {
79
- 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
+ }
80
111
  tapped.clear();
81
112
  view.step(world, (sound) => sounds.play(sound));
82
113
  accumulator -= FIXED_DT;
83
114
  }
84
115
  view.draw(ctx, world, sounds.muted);
116
+ if (crash)
117
+ drawCrash(ctx, crash);
85
118
  raf = requestAnimationFrame(frame);
86
119
  };
87
120
  raf = requestAnimationFrame(frame);
@@ -92,6 +125,59 @@ export async function startGame(canvas, scene, options = {}) {
92
125
  window.removeEventListener("blur", onBlur);
93
126
  };
94
127
  }
128
+ /**
129
+ * Plays a scene without a clock or keyboard and returns the requested frames
130
+ * as PNGs. Same renderer, effects and particles as `startGame`, stepped
131
+ * deterministically, so a frame always looks the same. Used by
132
+ * `ludra play --screenshot`.
133
+ */
134
+ 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)]);
139
+ const view = new View(scene, images);
140
+ const world = new World(scene, { scripts });
141
+ const script = InputScript.parse(options.inputs ?? "");
142
+ const wanted = new Set(options.frames);
143
+ const last = Math.max(0, ...options.frames);
144
+ const captured = [];
145
+ for (let frame = 0;; frame++) {
146
+ if (wanted.has(frame)) {
147
+ view.draw(ctx, world, false);
148
+ captured.push({ frame, time: frame * FIXED_DT, png: canvas.toDataURL("image/png") });
149
+ }
150
+ if (frame >= last)
151
+ break;
152
+ world.step(script.at(frame));
153
+ view.step(world, () => { });
154
+ }
155
+ return captured;
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
+ }
95
181
  async function loadImages(scene, assetBase) {
96
182
  const images = new Map();
97
183
  const paths = new Set(collectAssetPaths(scene).filter((ref) => ref.kind === "image").map((ref) => ref.path));
@@ -133,7 +219,7 @@ class View {
133
219
  /** Runs once per simulation step: turns new events into effects and advances particles. */
134
220
  step(world, playSound) {
135
221
  for (const event of world.events.slice(this.handledEvents)) {
136
- const effect = this.scene.effects?.[event.type];
222
+ const effect = this.scene.effects?.[event.type === "custom" ? event.name : event.type];
137
223
  if (!effect)
138
224
  continue;
139
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[];