@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/dist/assertions.d.ts +15 -7
- package/dist/assertions.js +60 -23
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +64 -8
- package/dist/components.d.ts +11 -1
- package/dist/components.js +9 -0
- package/dist/effects.d.ts +1 -1
- package/dist/effects.js +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/script.d.ts +108 -0
- package/dist/script.js +87 -0
- package/dist/types.d.ts +7 -2
- package/dist/validate.d.ts +2 -0
- package/dist/validate.js +19 -7
- package/dist/world.d.ts +59 -6
- package/dist/world.js +278 -11
- package/package.json +1 -1
- package/src/assertions.ts +68 -29
- package/src/browser.ts +62 -8
- package/src/components.ts +18 -1
- package/src/effects.ts +3 -1
- package/src/index.ts +1 -0
- package/src/script.ts +184 -0
- package/src/types.ts +7 -2
- package/src/validate.ts +31 -7
- package/src/world.ts +306 -13
package/dist/assertions.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { type
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 {};
|
package/dist/assertions.js
CHANGED
|
@@ -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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
subject = { kind: "
|
|
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
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
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 {
|
|
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
|
|
91
|
-
return { source, pass: false, actual, time, message: `${label} is
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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)
|
package/dist/components.d.ts
CHANGED
|
@@ -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;
|
package/dist/components.js
CHANGED
|
@@ -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
package/dist/index.js
CHANGED
package/dist/script.d.ts
ADDED
|
@@ -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
|
-
/**
|
|
29
|
-
|
|
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 {
|
package/dist/validate.d.ts
CHANGED
|
@@ -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;
|