@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/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
|
|
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
|
-
|
|
19
|
-
| { kind: "
|
|
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:
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
99
|
-
|
|
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 {
|
|
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
|
|
122
|
-
return { source, pass: false, actual, time, message: `${label} is
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
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
|
-
/**
|
|
35
|
-
|
|
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
|
|