@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/dist/ascii.js
CHANGED
|
@@ -39,8 +39,8 @@ export function renderAscii(world, options = {}) {
|
|
|
39
39
|
["G", (e) => !!e.goal],
|
|
40
40
|
["^", (e) => !!e.hazard && e.body.type !== "kinematic"],
|
|
41
41
|
["E", (e) => !!e.hazard && e.body.type === "kinematic"],
|
|
42
|
-
["D", (e) => e.body.type === "dynamic" && !e.platformer],
|
|
43
|
-
["@", (e) => !!e.platformer],
|
|
42
|
+
["D", (e) => e.body.type === "dynamic" && !e.platformer && !e.topdown],
|
|
43
|
+
["@", (e) => !!(e.platformer || e.topdown)],
|
|
44
44
|
];
|
|
45
45
|
for (const [char, test] of passes) {
|
|
46
46
|
for (const e of withBody.filter(test))
|
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,8 +1,17 @@
|
|
|
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";
|
|
4
12
|
import { InputScript } from "./input.js";
|
|
5
13
|
import { ParticleSystem } from "./particles.js";
|
|
14
|
+
import { PostFx } from "./postfx.js";
|
|
6
15
|
import { FIXED_DT } from "./time.js";
|
|
7
16
|
import { World } from "./world.js";
|
|
8
17
|
const KEYMAP = {
|
|
@@ -23,6 +32,89 @@ const KEYMAP = {
|
|
|
23
32
|
const MAX_FRAME_TIME = 0.25;
|
|
24
33
|
/** Screen shake decays by this factor every step. */
|
|
25
34
|
const SHAKE_DECAY = 0.88;
|
|
35
|
+
/** Imports the game's script modules, keyed by their path in the scene. */
|
|
36
|
+
async function loadScripts(scene, options) {
|
|
37
|
+
const base = options.scriptBase ?? options.assetBase ?? "";
|
|
38
|
+
const paths = options.scriptPaths ?? collectAssetPaths(scene).filter((ref) => ref.kind === "script").map((ref) => ref.path);
|
|
39
|
+
const scripts = new Map();
|
|
40
|
+
await Promise.all([...new Set(paths)].map(async (path) => {
|
|
41
|
+
const module = (await import(__rewriteRelativeImportExtension(base + path)));
|
|
42
|
+
if (!module.default)
|
|
43
|
+
throw new Error(`Script ${path} has no default export.`);
|
|
44
|
+
scripts.set(path, module.default);
|
|
45
|
+
}));
|
|
46
|
+
return scripts;
|
|
47
|
+
}
|
|
48
|
+
/** Fonts load from Google Fonts or project files; a slow or offline font never blocks the game for long. */
|
|
49
|
+
async function loadFonts(scene, assetBase) {
|
|
50
|
+
const fonts = Object.entries(scene.fonts ?? {});
|
|
51
|
+
if (fonts.length === 0)
|
|
52
|
+
return;
|
|
53
|
+
const load = async ([family, src]) => {
|
|
54
|
+
if (src === "google") {
|
|
55
|
+
const name = encodeURIComponent(family).replace(/%20/g, "+");
|
|
56
|
+
// Not every family has every weight, and Google rejects the request if one is missing.
|
|
57
|
+
for (const weights of [":wght@400;500;600;700;800;900", ":wght@400;700", ""]) {
|
|
58
|
+
const response = await fetch(`https://fonts.googleapis.com/css2?family=${name}${weights}&display=block`);
|
|
59
|
+
if (!response.ok)
|
|
60
|
+
continue;
|
|
61
|
+
const style = document.createElement("style");
|
|
62
|
+
style.textContent = await response.text();
|
|
63
|
+
document.head.append(style);
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
await Promise.all(["400", "700", "900"].map((weight) => document.fonts.load(`${weight} 16px "${family}"`)));
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const face = new FontFace(family, `url("${assetBase}${src}")`);
|
|
70
|
+
// FontFaceSet.add is missing from some TypeScript DOM typings.
|
|
71
|
+
document.fonts.add(await face.load());
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const all = Promise.all(fonts.map((font) => load(font).catch((err) => console.warn(`[ludra] could not load font "${font[0]}": ${err}`))));
|
|
75
|
+
await Promise.race([all, new Promise((resolve) => setTimeout(resolve, 4000))]);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Loads everything a scene needs and sets up its canvases. With `postfx`, `canvas`
|
|
79
|
+
* becomes the offscreen source and a WebGL canvas takes its place in the page.
|
|
80
|
+
*/
|
|
81
|
+
async function prepare(canvas, scene, options) {
|
|
82
|
+
const ctx = canvas.getContext("2d");
|
|
83
|
+
if (!ctx)
|
|
84
|
+
throw new Error("Canvas 2D context is not available.");
|
|
85
|
+
const assetBase = options.assetBase ?? "";
|
|
86
|
+
const shaderPath = scene.postfx?.shader;
|
|
87
|
+
const [images, scripts, shader] = await Promise.all([
|
|
88
|
+
loadImages(scene, assetBase),
|
|
89
|
+
loadScripts(scene, options),
|
|
90
|
+
shaderPath
|
|
91
|
+
? fetch(assetBase + shaderPath).then(async (r) => (r.ok ? r.text() : new Error(`could not load ${shaderPath} (${r.status})`)))
|
|
92
|
+
: undefined,
|
|
93
|
+
loadFonts(scene, assetBase),
|
|
94
|
+
]);
|
|
95
|
+
const stage = { ctx, output: canvas, images, scripts, present: () => { } };
|
|
96
|
+
if (scene.postfx && shader !== undefined) {
|
|
97
|
+
try {
|
|
98
|
+
if (shader instanceof Error)
|
|
99
|
+
throw shader;
|
|
100
|
+
const fx = new PostFx(canvas.width, canvas.height, shader, scene.postfx);
|
|
101
|
+
fx.canvas.id = canvas.id;
|
|
102
|
+
fx.canvas.className = canvas.className;
|
|
103
|
+
fx.canvas.style.cssText = canvas.style.cssText;
|
|
104
|
+
canvas.replaceWith(fx.canvas);
|
|
105
|
+
stage.output = fx.canvas;
|
|
106
|
+
stage.present = (time) => fx.render(canvas, time);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
stage.error = `postfx: ${message(err)}`;
|
|
110
|
+
console.error("[ludra]", err);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return stage;
|
|
114
|
+
}
|
|
115
|
+
function message(err) {
|
|
116
|
+
return err instanceof Error ? err.message : String(err);
|
|
117
|
+
}
|
|
26
118
|
/**
|
|
27
119
|
* Runs a scene in a canvas with keyboard controls, images, particles and
|
|
28
120
|
* sound. The simulation uses the same fixed step as headless runs, so what
|
|
@@ -30,17 +122,15 @@ const SHAKE_DECAY = 0.88;
|
|
|
30
122
|
* Resolves (after images load) to a function that stops the game.
|
|
31
123
|
*/
|
|
32
124
|
export async function startGame(canvas, scene, options = {}) {
|
|
33
|
-
const ctx = canvas
|
|
34
|
-
|
|
35
|
-
throw new Error("Canvas 2D context is not available.");
|
|
36
|
-
const assetBase = options.assetBase ?? "";
|
|
37
|
-
const images = await loadImages(scene, assetBase);
|
|
38
|
-
const sounds = new SoundPlayer(assetBase);
|
|
125
|
+
const { ctx, images, scripts, present, error } = await prepare(canvas, scene, options);
|
|
126
|
+
const sounds = new SoundPlayer(options.assetBase ?? "");
|
|
39
127
|
const view = new View(scene, images);
|
|
40
|
-
let world = new World(scene);
|
|
128
|
+
let world = new World(scene, { scripts });
|
|
129
|
+
let crash = error;
|
|
41
130
|
const restart = () => {
|
|
42
|
-
world = new World(scene);
|
|
131
|
+
world = new World(scene, { scripts });
|
|
43
132
|
view.reset();
|
|
133
|
+
crash = error;
|
|
44
134
|
};
|
|
45
135
|
const held = new Set();
|
|
46
136
|
// Keys pressed since the last step. A tap shorter than one frame still reaches the simulation.
|
|
@@ -76,13 +166,31 @@ export async function startGame(canvas, scene, options = {}) {
|
|
|
76
166
|
const frame = (now) => {
|
|
77
167
|
accumulator += Math.min(MAX_FRAME_TIME, (now - last) / 1000);
|
|
78
168
|
last = now;
|
|
79
|
-
while (accumulator >= FIXED_DT) {
|
|
80
|
-
|
|
169
|
+
while (accumulator >= FIXED_DT && !crash) {
|
|
170
|
+
try {
|
|
171
|
+
world.step(tapped.size > 0 ? new Set([...held, ...tapped]) : held);
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
// A script threw: freeze the game and show why, instead of failing silently.
|
|
175
|
+
crash = message(err);
|
|
176
|
+
console.error("[ludra]", err);
|
|
177
|
+
}
|
|
81
178
|
tapped.clear();
|
|
82
179
|
view.step(world, (sound) => sounds.play(sound));
|
|
83
180
|
accumulator -= FIXED_DT;
|
|
84
181
|
}
|
|
85
|
-
|
|
182
|
+
try {
|
|
183
|
+
view.draw(ctx, world, sounds.muted);
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
// A draw hook threw. Same treatment as a simulation error.
|
|
187
|
+
if (!crash)
|
|
188
|
+
console.error("[ludra]", err);
|
|
189
|
+
crash ??= message(err);
|
|
190
|
+
}
|
|
191
|
+
if (crash)
|
|
192
|
+
drawCrash(ctx, crash);
|
|
193
|
+
present(world.time);
|
|
86
194
|
raf = requestAnimationFrame(frame);
|
|
87
195
|
};
|
|
88
196
|
raf = requestAnimationFrame(frame);
|
|
@@ -100,12 +208,11 @@ export async function startGame(canvas, scene, options = {}) {
|
|
|
100
208
|
* `ludra play --screenshot`.
|
|
101
209
|
*/
|
|
102
210
|
export async function captureFrames(canvas, scene, options) {
|
|
103
|
-
const ctx = canvas
|
|
104
|
-
if (
|
|
105
|
-
throw new Error(
|
|
106
|
-
const images = await loadImages(scene, options.assetBase ?? "");
|
|
211
|
+
const { ctx, output, images, scripts, present, error } = await prepare(canvas, scene, options);
|
|
212
|
+
if (error)
|
|
213
|
+
throw new Error(error);
|
|
107
214
|
const view = new View(scene, images);
|
|
108
|
-
const world = new World(scene);
|
|
215
|
+
const world = new World(scene, { scripts });
|
|
109
216
|
const script = InputScript.parse(options.inputs ?? "");
|
|
110
217
|
const wanted = new Set(options.frames);
|
|
111
218
|
const last = Math.max(0, ...options.frames);
|
|
@@ -113,7 +220,8 @@ export async function captureFrames(canvas, scene, options) {
|
|
|
113
220
|
for (let frame = 0;; frame++) {
|
|
114
221
|
if (wanted.has(frame)) {
|
|
115
222
|
view.draw(ctx, world, false);
|
|
116
|
-
|
|
223
|
+
present(world.time);
|
|
224
|
+
captured.push({ frame, time: frame * FIXED_DT, png: output.toDataURL("image/png") });
|
|
117
225
|
}
|
|
118
226
|
if (frame >= last)
|
|
119
227
|
break;
|
|
@@ -122,6 +230,30 @@ export async function captureFrames(canvas, scene, options) {
|
|
|
122
230
|
}
|
|
123
231
|
return captured;
|
|
124
232
|
}
|
|
233
|
+
function drawCrash(ctx, message) {
|
|
234
|
+
const { width, height } = ctx.canvas;
|
|
235
|
+
ctx.fillStyle = "rgba(20, 0, 8, 0.85)";
|
|
236
|
+
ctx.fillRect(0, 0, width, height);
|
|
237
|
+
ctx.fillStyle = "#ff9b9b";
|
|
238
|
+
ctx.textAlign = "left";
|
|
239
|
+
ctx.font = "700 22px system-ui, sans-serif";
|
|
240
|
+
ctx.fillText("Script error", 32, 56);
|
|
241
|
+
ctx.font = "15px ui-monospace, monospace";
|
|
242
|
+
// Wrap the message to the canvas width.
|
|
243
|
+
let line = "";
|
|
244
|
+
let y = 92;
|
|
245
|
+
for (const word of message.split(" ")) {
|
|
246
|
+
if (ctx.measureText(line + word).width > width - 64) {
|
|
247
|
+
ctx.fillText(line, 32, y);
|
|
248
|
+
line = "";
|
|
249
|
+
y += 22;
|
|
250
|
+
}
|
|
251
|
+
line += `${word} `;
|
|
252
|
+
}
|
|
253
|
+
ctx.fillText(line, 32, y);
|
|
254
|
+
ctx.fillStyle = "rgba(255, 255, 255, 0.6)";
|
|
255
|
+
ctx.fillText("Fix the script; the page reloads on save. R restarts.", 32, y + 40);
|
|
256
|
+
}
|
|
125
257
|
async function loadImages(scene, assetBase) {
|
|
126
258
|
const images = new Map();
|
|
127
259
|
const paths = new Set(collectAssetPaths(scene).filter((ref) => ref.kind === "image").map((ref) => ref.path));
|
|
@@ -145,6 +277,8 @@ class View {
|
|
|
145
277
|
facing = new Map();
|
|
146
278
|
emitTimers = new Map();
|
|
147
279
|
frameCache = new Map();
|
|
280
|
+
/** ctx.local for each entity's draw hooks. */
|
|
281
|
+
local = new Map();
|
|
148
282
|
shake = 0;
|
|
149
283
|
handledEvents = 0;
|
|
150
284
|
scene;
|
|
@@ -157,13 +291,14 @@ class View {
|
|
|
157
291
|
this.particles.clear();
|
|
158
292
|
this.facing.clear();
|
|
159
293
|
this.emitTimers.clear();
|
|
294
|
+
this.local.clear();
|
|
160
295
|
this.shake = 0;
|
|
161
296
|
this.handledEvents = 0;
|
|
162
297
|
}
|
|
163
298
|
/** Runs once per simulation step: turns new events into effects and advances particles. */
|
|
164
299
|
step(world, playSound) {
|
|
165
300
|
for (const event of world.events.slice(this.handledEvents)) {
|
|
166
|
-
const effect = this.scene.effects?.[event.type];
|
|
301
|
+
const effect = this.scene.effects?.[event.type === "custom" ? event.name : event.type];
|
|
167
302
|
if (!effect)
|
|
168
303
|
continue;
|
|
169
304
|
if (effect.particles)
|
|
@@ -195,7 +330,7 @@ class View {
|
|
|
195
330
|
ctx.imageSmoothingEnabled = false;
|
|
196
331
|
ctx.fillStyle = world.background;
|
|
197
332
|
ctx.fillRect(0, 0, viewWidth, viewHeight);
|
|
198
|
-
const target = world.entities.find((e) => e.camera) ?? world.entities.find((e) => e.platformer);
|
|
333
|
+
const target = world.entities.find((e) => e.camera) ?? world.entities.find((e) => e.platformer || e.topdown);
|
|
199
334
|
const zoom = target?.camera?.zoom ?? 1;
|
|
200
335
|
const visibleWidth = viewWidth / zoom;
|
|
201
336
|
const visibleHeight = viewHeight / zoom;
|
|
@@ -205,14 +340,27 @@ class View {
|
|
|
205
340
|
cameraX += (this.particles.random() - 0.5) * 2 * this.shake;
|
|
206
341
|
cameraY += (this.particles.random() - 0.5) * 2 * this.shake;
|
|
207
342
|
}
|
|
343
|
+
const viewInfo = { width: viewWidth, height: viewHeight, cameraX, cameraY, zoom };
|
|
208
344
|
ctx.save();
|
|
209
345
|
ctx.scale(zoom, zoom);
|
|
210
346
|
ctx.translate(-cameraX, -cameraY);
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
347
|
+
const layer = (e) => e.sprite?.layer ?? 0;
|
|
348
|
+
const visible = world.entities.filter((e) => e.sprite || e.script?.module.draw).sort((a, b) => layer(a) - layer(b));
|
|
349
|
+
for (const e of visible) {
|
|
350
|
+
if (e.sprite)
|
|
351
|
+
this.drawSprite(ctx, e, world.time);
|
|
352
|
+
if (e.script?.module.draw)
|
|
353
|
+
this.drawHook(ctx, world, e, "draw", viewInfo);
|
|
354
|
+
}
|
|
214
355
|
this.particles.draw(ctx);
|
|
215
356
|
ctx.restore();
|
|
357
|
+
if (world.hasHook("drawUI")) {
|
|
358
|
+
// The game draws its own interface.
|
|
359
|
+
for (const e of [...world.entities])
|
|
360
|
+
if (e.script?.module.drawUI)
|
|
361
|
+
this.drawHook(ctx, world, e, "drawUI", viewInfo);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
216
364
|
ctx.textAlign = "left";
|
|
217
365
|
ctx.font = "600 18px system-ui, sans-serif";
|
|
218
366
|
ctx.fillStyle = "#ffffff";
|
|
@@ -233,6 +381,31 @@ class View {
|
|
|
233
381
|
ctx.fillText(`Score ${world.score} · ${winTime.toFixed(1)}s · Press R to play again`, viewWidth / 2, viewHeight / 2 + 30);
|
|
234
382
|
}
|
|
235
383
|
}
|
|
384
|
+
drawHook(ctx, world, e, hook, view) {
|
|
385
|
+
let local = this.local.get(e.id);
|
|
386
|
+
if (!local)
|
|
387
|
+
this.local.set(e.id, (local = {}));
|
|
388
|
+
ctx.save();
|
|
389
|
+
try {
|
|
390
|
+
world.callDrawHook(e, hook, {
|
|
391
|
+
g: ctx,
|
|
392
|
+
time: world.time,
|
|
393
|
+
local,
|
|
394
|
+
view,
|
|
395
|
+
recent: (name, seconds = 0.5) => recentEvents(world, name, seconds),
|
|
396
|
+
image: (path) => this.images.get(path),
|
|
397
|
+
burst: (x, y, particles = {}) => this.particles.burst(x, y, particles),
|
|
398
|
+
shake: (amount) => {
|
|
399
|
+
this.shake = Math.max(this.shake, amount);
|
|
400
|
+
},
|
|
401
|
+
random: () => this.particles.random(),
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
finally {
|
|
405
|
+
// Whatever the hook changed (transform, alpha, blend mode, filter) ends with it.
|
|
406
|
+
ctx.restore();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
236
409
|
drawSprite(ctx, e, time) {
|
|
237
410
|
const sprite = e.sprite;
|
|
238
411
|
const width = sprite.width || e.body?.width || 16;
|
|
@@ -300,6 +473,21 @@ class View {
|
|
|
300
473
|
return [0];
|
|
301
474
|
}
|
|
302
475
|
}
|
|
476
|
+
function recentEvents(world, name, seconds) {
|
|
477
|
+
const since = world.time - seconds;
|
|
478
|
+
const result = [];
|
|
479
|
+
// Events are in time order, so walk back until they get too old.
|
|
480
|
+
for (let i = world.events.length - 1; i >= 0; i--) {
|
|
481
|
+
const event = world.events[i];
|
|
482
|
+
if (event.time < since)
|
|
483
|
+
break;
|
|
484
|
+
const eventName = event.type === "custom" ? event.name : event.type;
|
|
485
|
+
if (name !== undefined && eventName !== name)
|
|
486
|
+
continue;
|
|
487
|
+
result.push({ name: eventName, time: event.time, x: event.x, y: event.y, entity: event.entity, data: event.type === "custom" ? event.data : undefined });
|
|
488
|
+
}
|
|
489
|
+
return result.reverse();
|
|
490
|
+
}
|
|
303
491
|
function animationState(e) {
|
|
304
492
|
const b = e.body;
|
|
305
493
|
if (!b)
|
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", "topdown", "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>;
|
|
@@ -49,6 +55,7 @@ export interface Body {
|
|
|
49
55
|
height: number;
|
|
50
56
|
sensor: boolean;
|
|
51
57
|
gravityScale: number;
|
|
58
|
+
bounce: number;
|
|
52
59
|
vx: number;
|
|
53
60
|
vy: number;
|
|
54
61
|
}
|
|
@@ -82,6 +89,10 @@ export interface ParticleSpec {
|
|
|
82
89
|
export interface Emitter extends Omit<ParticleSpec, "count"> {
|
|
83
90
|
rate: number;
|
|
84
91
|
}
|
|
92
|
+
export interface ScriptComponent {
|
|
93
|
+
src: string;
|
|
94
|
+
params: Record<string, unknown>;
|
|
95
|
+
}
|
|
85
96
|
export interface Platformer {
|
|
86
97
|
speed: number;
|
|
87
98
|
jumpSpeed: number;
|
|
@@ -89,6 +100,11 @@ export interface Platformer {
|
|
|
89
100
|
jumpBuffer: number;
|
|
90
101
|
variableJump: boolean;
|
|
91
102
|
}
|
|
103
|
+
export interface Topdown {
|
|
104
|
+
speed: number;
|
|
105
|
+
directions: "8" | "4" | "x" | "y";
|
|
106
|
+
clampToWorld: boolean;
|
|
107
|
+
}
|
|
92
108
|
export interface Collectible {
|
|
93
109
|
score: number;
|
|
94
110
|
}
|