@ludra/core 0.4.0 → 0.7.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/ascii.js +2 -2
- package/dist/assertions.d.ts +15 -7
- package/dist/assertions.js +60 -23
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +210 -22
- package/dist/components.d.ts +17 -1
- package/dist/components.js +31 -1
- package/dist/drawcheck.d.ts +7 -0
- package/dist/drawcheck.js +81 -0
- package/dist/effects.d.ts +2 -1
- package/dist/effects.js +19 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/postfx.d.ts +18 -0
- package/dist/postfx.js +100 -0
- package/dist/script.d.ts +170 -0
- package/dist/script.js +107 -0
- package/dist/types.d.ts +21 -3
- package/dist/types.js +1 -1
- package/dist/validate.d.ts +2 -0
- package/dist/validate.js +67 -11
- package/dist/world.d.ts +89 -8
- package/dist/world.js +419 -28
- package/package.json +1 -1
- package/src/ascii.ts +2 -2
- package/src/assertions.ts +68 -29
- package/src/browser.ts +212 -19
- package/src/components.ts +48 -2
- package/src/drawcheck.ts +80 -0
- package/src/effects.ts +17 -1
- package/src/index.ts +2 -0
- package/src/postfx.ts +110 -0
- package/src/script.ts +262 -0
- package/src/types.ts +22 -3
- package/src/validate.ts +85 -11
- package/src/world.ts +443 -34
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,8 @@ 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 { PostFx } from "./postfx.ts";
|
|
7
|
+
import type { DrawContext, DrawEvent, Script } from "./script.ts";
|
|
6
8
|
import { FIXED_DT } from "./time.ts";
|
|
7
9
|
import type { Action, SceneDef } from "./types.ts";
|
|
8
10
|
import { World, type Entity } from "./world.ts";
|
|
@@ -30,6 +32,107 @@ const SHAKE_DECAY = 0.88;
|
|
|
30
32
|
export interface GameOptions {
|
|
31
33
|
/** URL prefix for image and audio paths in the scene. Defaults to "". */
|
|
32
34
|
assetBase?: string;
|
|
35
|
+
/** URL prefix the scene's script paths are imported from. Defaults to `assetBase`. */
|
|
36
|
+
scriptBase?: string;
|
|
37
|
+
/** Scripts to load. Defaults to those the scene references; include any that `world.spawn` uses. */
|
|
38
|
+
scriptPaths?: readonly string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Imports the game's script modules, keyed by their path in the scene. */
|
|
42
|
+
async function loadScripts(scene: SceneDef, options: GameOptions): Promise<Map<string, Script>> {
|
|
43
|
+
const base = options.scriptBase ?? options.assetBase ?? "";
|
|
44
|
+
const paths = options.scriptPaths ?? collectAssetPaths(scene).filter((ref) => ref.kind === "script").map((ref) => ref.path);
|
|
45
|
+
const scripts = new Map<string, Script>();
|
|
46
|
+
await Promise.all(
|
|
47
|
+
[...new Set(paths)].map(async (path) => {
|
|
48
|
+
const module = (await import(base + path)) as { default?: Script };
|
|
49
|
+
if (!module.default) throw new Error(`Script ${path} has no default export.`);
|
|
50
|
+
scripts.set(path, module.default);
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
return scripts;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Fonts load from Google Fonts or project files; a slow or offline font never blocks the game for long. */
|
|
57
|
+
async function loadFonts(scene: SceneDef, assetBase: string): Promise<void> {
|
|
58
|
+
const fonts = Object.entries(scene.fonts ?? {});
|
|
59
|
+
if (fonts.length === 0) return;
|
|
60
|
+
const load = async ([family, src]: [string, string]) => {
|
|
61
|
+
if (src === "google") {
|
|
62
|
+
const name = encodeURIComponent(family).replace(/%20/g, "+");
|
|
63
|
+
// Not every family has every weight, and Google rejects the request if one is missing.
|
|
64
|
+
for (const weights of [":wght@400;500;600;700;800;900", ":wght@400;700", ""]) {
|
|
65
|
+
const response = await fetch(`https://fonts.googleapis.com/css2?family=${name}${weights}&display=block`);
|
|
66
|
+
if (!response.ok) continue;
|
|
67
|
+
const style = document.createElement("style");
|
|
68
|
+
style.textContent = await response.text();
|
|
69
|
+
document.head.append(style);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
await Promise.all(["400", "700", "900"].map((weight) => document.fonts.load(`${weight} 16px "${family}"`)));
|
|
73
|
+
} else {
|
|
74
|
+
const face = new FontFace(family, `url("${assetBase}${src}")`);
|
|
75
|
+
// FontFaceSet.add is missing from some TypeScript DOM typings.
|
|
76
|
+
(document.fonts as unknown as Set<FontFace>).add(await face.load());
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const all = Promise.all(
|
|
80
|
+
fonts.map((font) => load(font).catch((err) => console.warn(`[ludra] could not load font "${font[0]}": ${err}`))),
|
|
81
|
+
);
|
|
82
|
+
await Promise.race([all, new Promise((resolve) => setTimeout(resolve, 4000))]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface Stage {
|
|
86
|
+
/** Where the game draws. With postfx this is an offscreen canvas. */
|
|
87
|
+
ctx: CanvasRenderingContext2D;
|
|
88
|
+
/** The canvas players see. */
|
|
89
|
+
output: HTMLCanvasElement;
|
|
90
|
+
images: Map<string, HTMLImageElement>;
|
|
91
|
+
scripts: Map<string, Script>;
|
|
92
|
+
/** Shows the drawn frame: runs the postfx shader, or does nothing without one. */
|
|
93
|
+
present(time: number): void;
|
|
94
|
+
/** Why postfx couldn't start (the game then runs without it and shows this message). */
|
|
95
|
+
error?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Loads everything a scene needs and sets up its canvases. With `postfx`, `canvas`
|
|
100
|
+
* becomes the offscreen source and a WebGL canvas takes its place in the page.
|
|
101
|
+
*/
|
|
102
|
+
async function prepare(canvas: HTMLCanvasElement, scene: SceneDef, options: GameOptions): Promise<Stage> {
|
|
103
|
+
const ctx = canvas.getContext("2d");
|
|
104
|
+
if (!ctx) throw new Error("Canvas 2D context is not available.");
|
|
105
|
+
const assetBase = options.assetBase ?? "";
|
|
106
|
+
const shaderPath = scene.postfx?.shader;
|
|
107
|
+
const [images, scripts, shader] = await Promise.all([
|
|
108
|
+
loadImages(scene, assetBase),
|
|
109
|
+
loadScripts(scene, options),
|
|
110
|
+
shaderPath
|
|
111
|
+
? fetch(assetBase + shaderPath).then(async (r) => (r.ok ? r.text() : new Error(`could not load ${shaderPath} (${r.status})`)))
|
|
112
|
+
: undefined,
|
|
113
|
+
loadFonts(scene, assetBase),
|
|
114
|
+
]);
|
|
115
|
+
const stage: Stage = { ctx, output: canvas, images, scripts, present: () => {} };
|
|
116
|
+
if (scene.postfx && shader !== undefined) {
|
|
117
|
+
try {
|
|
118
|
+
if (shader instanceof Error) throw shader;
|
|
119
|
+
const fx = new PostFx(canvas.width, canvas.height, shader, scene.postfx);
|
|
120
|
+
fx.canvas.id = canvas.id;
|
|
121
|
+
fx.canvas.className = canvas.className;
|
|
122
|
+
fx.canvas.style.cssText = canvas.style.cssText;
|
|
123
|
+
canvas.replaceWith(fx.canvas);
|
|
124
|
+
stage.output = fx.canvas;
|
|
125
|
+
stage.present = (time) => fx.render(canvas, time);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
stage.error = `postfx: ${message(err)}`;
|
|
128
|
+
console.error("[ludra]", err);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return stage;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function message(err: unknown): string {
|
|
135
|
+
return err instanceof Error ? err.message : String(err);
|
|
33
136
|
}
|
|
34
137
|
|
|
35
138
|
/**
|
|
@@ -39,17 +142,16 @@ export interface GameOptions {
|
|
|
39
142
|
* Resolves (after images load) to a function that stops the game.
|
|
40
143
|
*/
|
|
41
144
|
export async function startGame(canvas: HTMLCanvasElement, scene: SceneDef, options: GameOptions = {}): Promise<() => void> {
|
|
42
|
-
const ctx = canvas
|
|
43
|
-
|
|
44
|
-
const assetBase = options.assetBase ?? "";
|
|
45
|
-
const images = await loadImages(scene, assetBase);
|
|
46
|
-
const sounds = new SoundPlayer(assetBase);
|
|
145
|
+
const { ctx, images, scripts, present, error } = await prepare(canvas, scene, options);
|
|
146
|
+
const sounds = new SoundPlayer(options.assetBase ?? "");
|
|
47
147
|
const view = new View(scene, images);
|
|
48
148
|
|
|
49
|
-
let world = new World(scene);
|
|
149
|
+
let world = new World(scene, { scripts });
|
|
150
|
+
let crash: string | undefined = error;
|
|
50
151
|
const restart = () => {
|
|
51
|
-
world = new World(scene);
|
|
152
|
+
world = new World(scene, { scripts });
|
|
52
153
|
view.reset();
|
|
154
|
+
crash = error;
|
|
53
155
|
};
|
|
54
156
|
|
|
55
157
|
const held = new Set<Action>();
|
|
@@ -83,13 +185,27 @@ export async function startGame(canvas: HTMLCanvasElement, scene: SceneDef, opti
|
|
|
83
185
|
const frame = (now: number) => {
|
|
84
186
|
accumulator += Math.min(MAX_FRAME_TIME, (now - last) / 1000);
|
|
85
187
|
last = now;
|
|
86
|
-
while (accumulator >= FIXED_DT) {
|
|
87
|
-
|
|
188
|
+
while (accumulator >= FIXED_DT && !crash) {
|
|
189
|
+
try {
|
|
190
|
+
world.step(tapped.size > 0 ? new Set([...held, ...tapped]) : held);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
// A script threw: freeze the game and show why, instead of failing silently.
|
|
193
|
+
crash = message(err);
|
|
194
|
+
console.error("[ludra]", err);
|
|
195
|
+
}
|
|
88
196
|
tapped.clear();
|
|
89
197
|
view.step(world, (sound) => sounds.play(sound));
|
|
90
198
|
accumulator -= FIXED_DT;
|
|
91
199
|
}
|
|
92
|
-
|
|
200
|
+
try {
|
|
201
|
+
view.draw(ctx, world, sounds.muted);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
// A draw hook threw. Same treatment as a simulation error.
|
|
204
|
+
if (!crash) console.error("[ludra]", err);
|
|
205
|
+
crash ??= message(err);
|
|
206
|
+
}
|
|
207
|
+
if (crash) drawCrash(ctx, crash);
|
|
208
|
+
present(world.time);
|
|
93
209
|
raf = requestAnimationFrame(frame);
|
|
94
210
|
};
|
|
95
211
|
raf = requestAnimationFrame(frame);
|
|
@@ -123,11 +239,10 @@ export interface CapturedFrame {
|
|
|
123
239
|
* `ludra play --screenshot`.
|
|
124
240
|
*/
|
|
125
241
|
export async function captureFrames(canvas: HTMLCanvasElement, scene: SceneDef, options: CaptureOptions): Promise<CapturedFrame[]> {
|
|
126
|
-
const ctx = canvas
|
|
127
|
-
if (
|
|
128
|
-
const images = await loadImages(scene, options.assetBase ?? "");
|
|
242
|
+
const { ctx, output, images, scripts, present, error } = await prepare(canvas, scene, options);
|
|
243
|
+
if (error) throw new Error(error);
|
|
129
244
|
const view = new View(scene, images);
|
|
130
|
-
const world = new World(scene);
|
|
245
|
+
const world = new World(scene, { scripts });
|
|
131
246
|
const script = InputScript.parse(options.inputs ?? "");
|
|
132
247
|
const wanted = new Set(options.frames);
|
|
133
248
|
const last = Math.max(0, ...options.frames);
|
|
@@ -136,7 +251,8 @@ export async function captureFrames(canvas: HTMLCanvasElement, scene: SceneDef,
|
|
|
136
251
|
for (let frame = 0; ; frame++) {
|
|
137
252
|
if (wanted.has(frame)) {
|
|
138
253
|
view.draw(ctx, world, false);
|
|
139
|
-
|
|
254
|
+
present(world.time);
|
|
255
|
+
captured.push({ frame, time: frame * FIXED_DT, png: output.toDataURL("image/png") });
|
|
140
256
|
}
|
|
141
257
|
if (frame >= last) break;
|
|
142
258
|
world.step(script.at(frame));
|
|
@@ -145,6 +261,31 @@ export async function captureFrames(canvas: HTMLCanvasElement, scene: SceneDef,
|
|
|
145
261
|
return captured;
|
|
146
262
|
}
|
|
147
263
|
|
|
264
|
+
function drawCrash(ctx: CanvasRenderingContext2D, message: string): void {
|
|
265
|
+
const { width, height } = ctx.canvas;
|
|
266
|
+
ctx.fillStyle = "rgba(20, 0, 8, 0.85)";
|
|
267
|
+
ctx.fillRect(0, 0, width, height);
|
|
268
|
+
ctx.fillStyle = "#ff9b9b";
|
|
269
|
+
ctx.textAlign = "left";
|
|
270
|
+
ctx.font = "700 22px system-ui, sans-serif";
|
|
271
|
+
ctx.fillText("Script error", 32, 56);
|
|
272
|
+
ctx.font = "15px ui-monospace, monospace";
|
|
273
|
+
// Wrap the message to the canvas width.
|
|
274
|
+
let line = "";
|
|
275
|
+
let y = 92;
|
|
276
|
+
for (const word of message.split(" ")) {
|
|
277
|
+
if (ctx.measureText(line + word).width > width - 64) {
|
|
278
|
+
ctx.fillText(line, 32, y);
|
|
279
|
+
line = "";
|
|
280
|
+
y += 22;
|
|
281
|
+
}
|
|
282
|
+
line += `${word} `;
|
|
283
|
+
}
|
|
284
|
+
ctx.fillText(line, 32, y);
|
|
285
|
+
ctx.fillStyle = "rgba(255, 255, 255, 0.6)";
|
|
286
|
+
ctx.fillText("Fix the script; the page reloads on save. R restarts.", 32, y + 40);
|
|
287
|
+
}
|
|
288
|
+
|
|
148
289
|
async function loadImages(scene: SceneDef, assetBase: string): Promise<Map<string, HTMLImageElement>> {
|
|
149
290
|
const images = new Map<string, HTMLImageElement>();
|
|
150
291
|
const paths = new Set(collectAssetPaths(scene).filter((ref) => ref.kind === "image").map((ref) => ref.path));
|
|
@@ -174,6 +315,8 @@ class View {
|
|
|
174
315
|
private readonly facing = new Map<string, number>();
|
|
175
316
|
private readonly emitTimers = new Map<string, number>();
|
|
176
317
|
private readonly frameCache = new Map<string, number[]>();
|
|
318
|
+
/** ctx.local for each entity's draw hooks. */
|
|
319
|
+
private readonly local = new Map<string, Record<string, unknown>>();
|
|
177
320
|
private shake = 0;
|
|
178
321
|
private handledEvents = 0;
|
|
179
322
|
private readonly scene: SceneDef;
|
|
@@ -188,6 +331,7 @@ class View {
|
|
|
188
331
|
this.particles.clear();
|
|
189
332
|
this.facing.clear();
|
|
190
333
|
this.emitTimers.clear();
|
|
334
|
+
this.local.clear();
|
|
191
335
|
this.shake = 0;
|
|
192
336
|
this.handledEvents = 0;
|
|
193
337
|
}
|
|
@@ -195,7 +339,7 @@ class View {
|
|
|
195
339
|
/** Runs once per simulation step: turns new events into effects and advances particles. */
|
|
196
340
|
step(world: World, playSound: (sound: string) => void): void {
|
|
197
341
|
for (const event of world.events.slice(this.handledEvents)) {
|
|
198
|
-
const effect = this.scene.effects?.[event.type];
|
|
342
|
+
const effect = this.scene.effects?.[event.type === "custom" ? event.name : event.type];
|
|
199
343
|
if (!effect) continue;
|
|
200
344
|
if (effect.particles) this.particles.burst(event.x, event.y, effect.particles);
|
|
201
345
|
if (effect.sound) playSound(effect.sound);
|
|
@@ -224,7 +368,7 @@ class View {
|
|
|
224
368
|
ctx.fillStyle = world.background;
|
|
225
369
|
ctx.fillRect(0, 0, viewWidth, viewHeight);
|
|
226
370
|
|
|
227
|
-
const target = world.entities.find((e) => e.camera) ?? world.entities.find((e) => e.platformer);
|
|
371
|
+
const target = world.entities.find((e) => e.camera) ?? world.entities.find((e) => e.platformer || e.topdown);
|
|
228
372
|
const zoom = target?.camera?.zoom ?? 1;
|
|
229
373
|
const visibleWidth = viewWidth / zoom;
|
|
230
374
|
const visibleHeight = viewHeight / zoom;
|
|
@@ -235,14 +379,25 @@ class View {
|
|
|
235
379
|
cameraY += (this.particles.random() - 0.5) * 2 * this.shake;
|
|
236
380
|
}
|
|
237
381
|
|
|
382
|
+
const viewInfo = { width: viewWidth, height: viewHeight, cameraX, cameraY, zoom };
|
|
238
383
|
ctx.save();
|
|
239
384
|
ctx.scale(zoom, zoom);
|
|
240
385
|
ctx.translate(-cameraX, -cameraY);
|
|
241
|
-
const
|
|
242
|
-
|
|
386
|
+
const layer = (e: Entity) => e.sprite?.layer ?? 0;
|
|
387
|
+
const visible = world.entities.filter((e) => e.sprite || e.script?.module.draw).sort((a, b) => layer(a) - layer(b));
|
|
388
|
+
for (const e of visible) {
|
|
389
|
+
if (e.sprite) this.drawSprite(ctx, e, world.time);
|
|
390
|
+
if (e.script?.module.draw) this.drawHook(ctx, world, e, "draw", viewInfo);
|
|
391
|
+
}
|
|
243
392
|
this.particles.draw(ctx);
|
|
244
393
|
ctx.restore();
|
|
245
394
|
|
|
395
|
+
if (world.hasHook("drawUI")) {
|
|
396
|
+
// The game draws its own interface.
|
|
397
|
+
for (const e of [...world.entities]) if (e.script?.module.drawUI) this.drawHook(ctx, world, e, "drawUI", viewInfo);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
246
401
|
ctx.textAlign = "left";
|
|
247
402
|
ctx.font = "600 18px system-ui, sans-serif";
|
|
248
403
|
ctx.fillStyle = "#ffffff";
|
|
@@ -265,6 +420,30 @@ class View {
|
|
|
265
420
|
}
|
|
266
421
|
}
|
|
267
422
|
|
|
423
|
+
private drawHook(ctx: CanvasRenderingContext2D, world: World, e: Entity, hook: "draw" | "drawUI", view: DrawContext["view"]): void {
|
|
424
|
+
let local = this.local.get(e.id);
|
|
425
|
+
if (!local) this.local.set(e.id, (local = {}));
|
|
426
|
+
ctx.save();
|
|
427
|
+
try {
|
|
428
|
+
world.callDrawHook(e, hook, {
|
|
429
|
+
g: ctx,
|
|
430
|
+
time: world.time,
|
|
431
|
+
local,
|
|
432
|
+
view,
|
|
433
|
+
recent: (name, seconds = 0.5) => recentEvents(world, name, seconds),
|
|
434
|
+
image: (path) => this.images.get(path),
|
|
435
|
+
burst: (x, y, particles = {}) => this.particles.burst(x, y, particles),
|
|
436
|
+
shake: (amount) => {
|
|
437
|
+
this.shake = Math.max(this.shake, amount);
|
|
438
|
+
},
|
|
439
|
+
random: () => this.particles.random(),
|
|
440
|
+
});
|
|
441
|
+
} finally {
|
|
442
|
+
// Whatever the hook changed (transform, alpha, blend mode, filter) ends with it.
|
|
443
|
+
ctx.restore();
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
268
447
|
private drawSprite(ctx: CanvasRenderingContext2D, e: Entity, time: number): void {
|
|
269
448
|
const sprite = e.sprite!;
|
|
270
449
|
const width = sprite.width || e.body?.width || 16;
|
|
@@ -336,6 +515,20 @@ class View {
|
|
|
336
515
|
|
|
337
516
|
type AnimationState = "idle" | "run" | "jump" | "fall";
|
|
338
517
|
|
|
518
|
+
function recentEvents(world: World, name: string | undefined, seconds: number): DrawEvent[] {
|
|
519
|
+
const since = world.time - seconds;
|
|
520
|
+
const result: DrawEvent[] = [];
|
|
521
|
+
// Events are in time order, so walk back until they get too old.
|
|
522
|
+
for (let i = world.events.length - 1; i >= 0; i--) {
|
|
523
|
+
const event = world.events[i]!;
|
|
524
|
+
if (event.time < since) break;
|
|
525
|
+
const eventName = event.type === "custom" ? event.name : event.type;
|
|
526
|
+
if (name !== undefined && eventName !== name) continue;
|
|
527
|
+
result.push({ name: eventName, time: event.time, x: event.x, y: event.y, entity: event.entity, data: event.type === "custom" ? event.data : undefined });
|
|
528
|
+
}
|
|
529
|
+
return result.reverse();
|
|
530
|
+
}
|
|
531
|
+
|
|
339
532
|
function animationState(e: Entity): AnimationState {
|
|
340
533
|
const b = e.body;
|
|
341
534
|
if (!b) return "idle";
|
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;
|
|
@@ -18,11 +20,13 @@ export const COMPONENT_NAMES = [
|
|
|
18
20
|
"body",
|
|
19
21
|
"sprite",
|
|
20
22
|
"platformer",
|
|
23
|
+
"topdown",
|
|
21
24
|
"collectible",
|
|
22
25
|
"patrol",
|
|
23
26
|
"hazard",
|
|
24
27
|
"goal",
|
|
25
28
|
"emitter",
|
|
29
|
+
"script",
|
|
26
30
|
"camera",
|
|
27
31
|
] as const;
|
|
28
32
|
export type ComponentName = (typeof COMPONENT_NAMES)[number];
|
|
@@ -62,6 +66,12 @@ export const COMPONENTS: Record<ComponentName, ComponentSpec> = {
|
|
|
62
66
|
height: { type: "number", min: 1, description: "Collider height in pixels." },
|
|
63
67
|
sensor: { type: "boolean", default: false, description: "Sensors detect overlaps but never block movement (coins, triggers)." },
|
|
64
68
|
gravityScale: { type: "number", default: 1, description: "Multiplier on world gravity for dynamic bodies." },
|
|
69
|
+
bounce: {
|
|
70
|
+
type: "number",
|
|
71
|
+
min: 0,
|
|
72
|
+
default: 0,
|
|
73
|
+
description: "How much speed a dynamic body keeps when it hits a solid: 0 stops it, 1 bounces it back at full speed (balls, pinballs).",
|
|
74
|
+
},
|
|
65
75
|
vx: { type: "number", default: 0, description: "Initial horizontal velocity in px/s." },
|
|
66
76
|
vy: { type: "number", default: 0, description: "Initial vertical velocity in px/s." },
|
|
67
77
|
},
|
|
@@ -99,8 +109,23 @@ export const COMPONENTS: Record<ComponentName, ComponentSpec> = {
|
|
|
99
109
|
variableJump: { type: "boolean", default: true, description: "Releasing jump early cuts the jump short." },
|
|
100
110
|
},
|
|
101
111
|
},
|
|
112
|
+
topdown: {
|
|
113
|
+
description:
|
|
114
|
+
"Player control seen from above or for vehicles: moves with the arrow keys, no gravity. directions picks 8-way, 4-way, or one axis (\"x\" for a breakout paddle or a shooter ship). Needs a dynamic body (collides with walls) or a kinematic one (passes through, use clampToWorld).",
|
|
115
|
+
requires: ["transform", "body"],
|
|
116
|
+
fields: {
|
|
117
|
+
speed: { type: "number", min: 0, default: 200, description: "Move speed in px/s." },
|
|
118
|
+
directions: {
|
|
119
|
+
type: "enum",
|
|
120
|
+
values: ["8", "4", "x", "y"],
|
|
121
|
+
default: "8",
|
|
122
|
+
description: '"8": any direction (diagonals keep the same speed). "4": up/down/left/right only. "x" or "y": one axis.',
|
|
123
|
+
},
|
|
124
|
+
clampToWorld: { type: "boolean", default: true, description: "Keep the entity inside the world bounds." },
|
|
125
|
+
},
|
|
126
|
+
},
|
|
102
127
|
collectible: {
|
|
103
|
-
description: "Removed when a platformer
|
|
128
|
+
description: "Removed when a player (platformer or topdown) touches it and adds to the score. Its body must be a sensor.",
|
|
104
129
|
requires: ["transform", "body"],
|
|
105
130
|
fields: {
|
|
106
131
|
score: { type: "number", default: 1, description: "Points added when collected." },
|
|
@@ -140,6 +165,15 @@ export const COMPONENTS: Record<ComponentName, ComponentSpec> = {
|
|
|
140
165
|
...Object.fromEntries(Object.entries(PARTICLE_FIELDS).filter(([field]) => field !== "count")),
|
|
141
166
|
},
|
|
142
167
|
},
|
|
168
|
+
script: {
|
|
169
|
+
description:
|
|
170
|
+
"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.",
|
|
171
|
+
requires: ["transform"],
|
|
172
|
+
fields: {
|
|
173
|
+
src: { type: "string", description: 'Script path relative to the project, e.g. "scripts/chase.ts".' },
|
|
174
|
+
params: { type: "object", default: {}, description: "Values passed to the script as ctx.params, so one script can serve many entities." },
|
|
175
|
+
},
|
|
176
|
+
},
|
|
143
177
|
camera: {
|
|
144
178
|
description: "The browser camera follows this entity. Without it, the camera follows the first platformer.",
|
|
145
179
|
requires: ["transform"],
|
|
@@ -160,6 +194,7 @@ export interface Body {
|
|
|
160
194
|
height: number;
|
|
161
195
|
sensor: boolean;
|
|
162
196
|
gravityScale: number;
|
|
197
|
+
bounce: number;
|
|
163
198
|
vx: number;
|
|
164
199
|
vy: number;
|
|
165
200
|
}
|
|
@@ -197,6 +232,11 @@ export interface Emitter extends Omit<ParticleSpec, "count"> {
|
|
|
197
232
|
rate: number;
|
|
198
233
|
}
|
|
199
234
|
|
|
235
|
+
export interface ScriptComponent {
|
|
236
|
+
src: string;
|
|
237
|
+
params: Record<string, unknown>;
|
|
238
|
+
}
|
|
239
|
+
|
|
200
240
|
export interface Platformer {
|
|
201
241
|
speed: number;
|
|
202
242
|
jumpSpeed: number;
|
|
@@ -205,6 +245,12 @@ export interface Platformer {
|
|
|
205
245
|
variableJump: boolean;
|
|
206
246
|
}
|
|
207
247
|
|
|
248
|
+
export interface Topdown {
|
|
249
|
+
speed: number;
|
|
250
|
+
directions: "8" | "4" | "x" | "y";
|
|
251
|
+
clampToWorld: boolean;
|
|
252
|
+
}
|
|
253
|
+
|
|
208
254
|
export interface Collectible {
|
|
209
255
|
score: number;
|
|
210
256
|
}
|