@ludra/core 0.5.0 → 0.8.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 +5 -1
- package/dist/assertions.js +8 -4
- package/dist/browser.d.ts +8 -1
- package/dist/browser.js +282 -30
- package/dist/components.d.ts +8 -1
- package/dist/components.js +33 -1
- package/dist/drawcheck.d.ts +7 -0
- package/dist/drawcheck.js +81 -0
- package/dist/effects.d.ts +3 -2
- package/dist/effects.js +20 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/postfx.d.ts +18 -0
- package/dist/postfx.js +100 -0
- package/dist/script.d.ts +82 -3
- package/dist/script.js +25 -4
- package/dist/types.d.ts +29 -1
- package/dist/types.js +1 -1
- package/dist/validate.d.ts +3 -1
- package/dist/validate.js +120 -7
- package/dist/world.d.ts +67 -4
- package/dist/world.js +249 -35
- package/package.json +1 -1
- package/src/ascii.ts +2 -2
- package/src/assertions.ts +12 -4
- package/src/browser.ts +286 -30
- package/src/components.ts +43 -1
- package/src/drawcheck.ts +80 -0
- package/src/effects.ts +18 -2
- package/src/index.ts +1 -0
- package/src/postfx.ts +110 -0
- package/src/script.ts +101 -5
- package/src/types.ts +31 -1
- package/src/validate.ts +126 -7
- package/src/world.ts +247 -39
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
|
@@ -4,7 +4,7 @@ declare const OPERATORS: readonly ["<=", ">=", "==", "!=", "<", ">"];
|
|
|
4
4
|
type Operator = (typeof OPERATORS)[number];
|
|
5
5
|
declare const ENTITY_FIELDS: readonly ["x", "y", "vx", "vy", "grounded", "exists"];
|
|
6
6
|
type EntityField = (typeof ENTITY_FIELDS)[number];
|
|
7
|
-
declare const WORLD_FIELDS: readonly ["score", "deaths", "won"];
|
|
7
|
+
declare const WORLD_FIELDS: readonly ["score", "deaths", "won", "lost", "lives"];
|
|
8
8
|
type WorldField = (typeof WORLD_FIELDS)[number];
|
|
9
9
|
type Subject = {
|
|
10
10
|
kind: "world";
|
|
@@ -48,6 +48,8 @@ export declare function evaluateAssertion(assertion: Assertion, snapshot: WorldS
|
|
|
48
48
|
/** One case in a `tests/*.test.json` file. */
|
|
49
49
|
export interface TestCase {
|
|
50
50
|
name: string;
|
|
51
|
+
/** Scene to run, relative to the project. Defaults to the entry scene in ludra.json. */
|
|
52
|
+
scene?: string;
|
|
51
53
|
/** Input script, see `InputScript`. */
|
|
52
54
|
inputs?: string;
|
|
53
55
|
/** Run length, e.g. "3s". Defaults to the inputs plus one second, or the latest `at`. */
|
|
@@ -60,6 +62,8 @@ export interface TestResult {
|
|
|
60
62
|
duration: number;
|
|
61
63
|
results: AssertionResult[];
|
|
62
64
|
error?: string;
|
|
65
|
+
/** Names of the events that fired during the run (built-in types and custom names). */
|
|
66
|
+
events: string[];
|
|
63
67
|
}
|
|
64
68
|
export declare function runTest(scene: SceneDef, test: TestCase, options?: WorldOptions): TestResult;
|
|
65
69
|
export {};
|
package/dist/assertions.js
CHANGED
|
@@ -5,7 +5,7 @@ import { EVENT_TYPES } from "./types.js";
|
|
|
5
5
|
import { World } from "./world.js";
|
|
6
6
|
const OPERATORS = ["<=", ">=", "==", "!=", "<", ">"];
|
|
7
7
|
const ENTITY_FIELDS = ["x", "y", "vx", "vy", "grounded", "exists"];
|
|
8
|
-
const WORLD_FIELDS = ["score", "deaths", "won"];
|
|
8
|
+
const WORLD_FIELDS = ["score", "deaths", "won", "lost", "lives"];
|
|
9
9
|
export class AssertionSyntaxError extends Error {
|
|
10
10
|
}
|
|
11
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".';
|
|
@@ -65,6 +65,9 @@ export function evaluateAssertion(assertion, snapshot, events) {
|
|
|
65
65
|
case "world":
|
|
66
66
|
actual = snapshot[subject.field];
|
|
67
67
|
label = subject.field;
|
|
68
|
+
if (actual === undefined) {
|
|
69
|
+
return { source, pass: false, actual, time, message: `${label} is not set: the scene has unlimited retries. Add "lives" to "world" to count them.` };
|
|
70
|
+
}
|
|
68
71
|
break;
|
|
69
72
|
case "events": {
|
|
70
73
|
const matches = (e) => (subject.builtin ? e.type === subject.name : e.type === "custom" && e.name === subject.name);
|
|
@@ -160,7 +163,7 @@ export function runTest(scene, test, options = {}) {
|
|
|
160
163
|
: Math.max(secondsToFrames(defaultDuration(script)), latestAt);
|
|
161
164
|
}
|
|
162
165
|
catch (err) {
|
|
163
|
-
return { name: test.name, pass: false, duration: 0, results: [], error: err.message };
|
|
166
|
+
return { name: test.name, pass: false, duration: 0, results: [], error: err.message, events: [] };
|
|
164
167
|
}
|
|
165
168
|
const wanted = new Set(assertions.map((a) => (a.at === "end" ? frames : Math.min(a.at, frames))));
|
|
166
169
|
const snapshots = new Map();
|
|
@@ -174,11 +177,12 @@ export function runTest(scene, test, options = {}) {
|
|
|
174
177
|
}
|
|
175
178
|
catch (err) {
|
|
176
179
|
// 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 };
|
|
180
|
+
return { name: test.name, pass: false, duration: frames * FIXED_DT, results: [], error: err.message, events: [] };
|
|
178
181
|
}
|
|
179
182
|
const results = assertions.map((a) => {
|
|
180
183
|
const frame = a.at === "end" ? frames : Math.min(a.at, frames);
|
|
181
184
|
return evaluateAssertion(a, snapshots.get(frame), world.events);
|
|
182
185
|
});
|
|
183
|
-
|
|
186
|
+
const events = [...new Set(world.events.map((e) => (e.type === "custom" ? e.name : e.type)))];
|
|
187
|
+
return { name: test.name, pass: results.every((r) => r.pass), duration: frames * FIXED_DT, results, events };
|
|
184
188
|
}
|
package/dist/browser.d.ts
CHANGED
|
@@ -6,14 +6,21 @@ export interface GameOptions {
|
|
|
6
6
|
scriptBase?: string;
|
|
7
7
|
/** Scripts to load. Defaults to those the scene references; include any that `world.spawn` uses. */
|
|
8
8
|
scriptPaths?: readonly string[];
|
|
9
|
+
/**
|
|
10
|
+
* Loads another scene by path, for goals with `next` and `world.goto`. Without it the
|
|
11
|
+
* game stays on its first scene. Throw with a readable message if the scene is invalid.
|
|
12
|
+
*/
|
|
13
|
+
loadScene?: (path: string) => Promise<SceneDef>;
|
|
9
14
|
}
|
|
10
15
|
/**
|
|
11
16
|
* Runs a scene in a canvas with keyboard controls, images, particles and
|
|
12
17
|
* sound. The simulation uses the same fixed step as headless runs, so what
|
|
13
18
|
* tests verify is what players get; everything added here is presentation.
|
|
19
|
+
* With `options.loadScene`, won goals with `next` and `world.goto` move on
|
|
20
|
+
* to other scenes, keeping score, deaths and lives.
|
|
14
21
|
* Resolves (after images load) to a function that stops the game.
|
|
15
22
|
*/
|
|
16
|
-
export declare function startGame(canvas: HTMLCanvasElement,
|
|
23
|
+
export declare function startGame(canvas: HTMLCanvasElement, firstScene: SceneDef, options?: GameOptions): Promise<() => void>;
|
|
17
24
|
export interface CaptureOptions extends GameOptions {
|
|
18
25
|
/** Input script, e.g. `"right:1s, jump:0.4s"`. See `InputScript`. */
|
|
19
26
|
inputs?: string;
|
package/dist/browser.js
CHANGED
|
@@ -11,6 +11,7 @@ import { parseFrames } from "./components.js";
|
|
|
11
11
|
import { collectAssetPaths } from "./effects.js";
|
|
12
12
|
import { InputScript } from "./input.js";
|
|
13
13
|
import { ParticleSystem } from "./particles.js";
|
|
14
|
+
import { PostFx } from "./postfx.js";
|
|
14
15
|
import { FIXED_DT } from "./time.js";
|
|
15
16
|
import { World } from "./world.js";
|
|
16
17
|
const KEYMAP = {
|
|
@@ -44,26 +45,178 @@ async function loadScripts(scene, options) {
|
|
|
44
45
|
}));
|
|
45
46
|
return scripts;
|
|
46
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
|
+
/** A scene's postfx shader source, or why it couldn't be fetched. */
|
|
78
|
+
async function loadShader(scene, assetBase) {
|
|
79
|
+
const path = scene.postfx?.shader;
|
|
80
|
+
if (!path)
|
|
81
|
+
return undefined;
|
|
82
|
+
const response = await fetch(assetBase + path).catch((err) => err);
|
|
83
|
+
if (response instanceof Error)
|
|
84
|
+
return response;
|
|
85
|
+
return response.ok ? response.text() : new Error(`could not load ${path} (${response.status})`);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The canvas players see. The game always draws into the 2D canvas; when a scene
|
|
89
|
+
* has `postfx`, a WebGL canvas running its shader takes the 2D canvas's place in
|
|
90
|
+
* the page, and goes away again for a scene without one.
|
|
91
|
+
*/
|
|
92
|
+
class Screen {
|
|
93
|
+
ctx;
|
|
94
|
+
output;
|
|
95
|
+
canvas;
|
|
96
|
+
fx;
|
|
97
|
+
constructor(canvas) {
|
|
98
|
+
const ctx = canvas.getContext("2d");
|
|
99
|
+
if (!ctx)
|
|
100
|
+
throw new Error("Canvas 2D context is not available.");
|
|
101
|
+
this.ctx = ctx;
|
|
102
|
+
this.canvas = canvas;
|
|
103
|
+
this.output = canvas;
|
|
104
|
+
}
|
|
105
|
+
/** Switches to a scene's shader, or to none. Returns why the shader can't run, if it can't. */
|
|
106
|
+
use(postfx, shader) {
|
|
107
|
+
let fx;
|
|
108
|
+
let error;
|
|
109
|
+
if (postfx && shader !== undefined) {
|
|
110
|
+
try {
|
|
111
|
+
if (shader instanceof Error)
|
|
112
|
+
throw shader;
|
|
113
|
+
fx = new PostFx(this.canvas.width, this.canvas.height, shader, postfx);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
error = `postfx: ${message(err)}`;
|
|
117
|
+
console.error("[ludra]", err);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const target = fx?.canvas ?? this.canvas;
|
|
121
|
+
if (target !== this.output) {
|
|
122
|
+
target.id = this.canvas.id;
|
|
123
|
+
target.className = this.canvas.className;
|
|
124
|
+
target.style.cssText = this.canvas.style.cssText;
|
|
125
|
+
this.output.replaceWith(target);
|
|
126
|
+
this.output = target;
|
|
127
|
+
}
|
|
128
|
+
this.fx = fx;
|
|
129
|
+
return error;
|
|
130
|
+
}
|
|
131
|
+
/** Shows the frame the game just drew, through the shader if there is one. */
|
|
132
|
+
present(time) {
|
|
133
|
+
this.fx?.render(this.canvas, time);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** What a scene needs before it can be drawn. */
|
|
137
|
+
async function loadScene(scene, options, scripts) {
|
|
138
|
+
const assetBase = options.assetBase ?? "";
|
|
139
|
+
const [images, more, shader] = await Promise.all([
|
|
140
|
+
loadImages(scene, assetBase),
|
|
141
|
+
loadScripts(scene, options),
|
|
142
|
+
loadShader(scene, assetBase),
|
|
143
|
+
loadFonts(scene, assetBase),
|
|
144
|
+
]);
|
|
145
|
+
for (const [path, script] of more)
|
|
146
|
+
scripts.set(path, script);
|
|
147
|
+
return { images, shader };
|
|
148
|
+
}
|
|
149
|
+
function message(err) {
|
|
150
|
+
return err instanceof Error ? err.message : String(err);
|
|
151
|
+
}
|
|
152
|
+
/** Seconds the win screen shows before the next scene, and the fade into a world.goto scene. */
|
|
153
|
+
const WIN_PAUSE = 1.6;
|
|
154
|
+
const GOTO_FADE = 0.35;
|
|
47
155
|
/**
|
|
48
156
|
* Runs a scene in a canvas with keyboard controls, images, particles and
|
|
49
157
|
* sound. The simulation uses the same fixed step as headless runs, so what
|
|
50
158
|
* tests verify is what players get; everything added here is presentation.
|
|
159
|
+
* With `options.loadScene`, won goals with `next` and `world.goto` move on
|
|
160
|
+
* to other scenes, keeping score, deaths and lives.
|
|
51
161
|
* Resolves (after images load) to a function that stops the game.
|
|
52
162
|
*/
|
|
53
|
-
export async function startGame(canvas,
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
let world
|
|
163
|
+
export async function startGame(canvas, firstScene, options = {}) {
|
|
164
|
+
const screen = new Screen(canvas);
|
|
165
|
+
const ctx = screen.ctx;
|
|
166
|
+
const sounds = new SoundPlayer(options.assetBase ?? "");
|
|
167
|
+
const scripts = new Map();
|
|
168
|
+
let scene = firstScene;
|
|
169
|
+
let startCarry;
|
|
170
|
+
let view;
|
|
171
|
+
let world;
|
|
62
172
|
let crash;
|
|
173
|
+
let sceneError;
|
|
174
|
+
/** Real time when the world asked to move on, while waiting to load the next scene. */
|
|
175
|
+
let exitSince;
|
|
176
|
+
let loading = false;
|
|
177
|
+
const enter = async (next, carry) => {
|
|
178
|
+
const { images, shader } = await loadScene(next, options, scripts);
|
|
179
|
+
scene = next;
|
|
180
|
+
startCarry = carry;
|
|
181
|
+
view = new View(next, images);
|
|
182
|
+
sceneError = screen.use(next.postfx, shader);
|
|
183
|
+
crash = sceneError;
|
|
184
|
+
exitSince = undefined;
|
|
185
|
+
try {
|
|
186
|
+
world = new World(next, { scripts, carry });
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
crash = message(err);
|
|
190
|
+
world = new World({ ...next, entities: [] });
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
await enter(firstScene, undefined);
|
|
194
|
+
// Game over starts the whole game again; otherwise R restarts the current scene as it began.
|
|
63
195
|
const restart = () => {
|
|
64
|
-
|
|
196
|
+
if (loading)
|
|
197
|
+
return;
|
|
198
|
+
if (world.lost && scene !== firstScene) {
|
|
199
|
+
loading = true;
|
|
200
|
+
void enter(firstScene, undefined).finally(() => (loading = false));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
world = new World(scene, { scripts, carry: startCarry });
|
|
65
204
|
view.reset();
|
|
66
|
-
crash =
|
|
205
|
+
crash = sceneError;
|
|
206
|
+
exitSince = undefined;
|
|
207
|
+
};
|
|
208
|
+
const moveOn = (path) => {
|
|
209
|
+
if (!options.loadScene)
|
|
210
|
+
return;
|
|
211
|
+
loading = true;
|
|
212
|
+
const carry = { score: world.score, deaths: world.deaths, lives: world.lives };
|
|
213
|
+
options
|
|
214
|
+
.loadScene(path)
|
|
215
|
+
.then((next) => enter(next, carry))
|
|
216
|
+
.catch((err) => {
|
|
217
|
+
crash = `Could not load the next scene "${path}": ${message(err)}`;
|
|
218
|
+
})
|
|
219
|
+
.finally(() => (loading = false));
|
|
67
220
|
};
|
|
68
221
|
const held = new Set();
|
|
69
222
|
// Keys pressed since the last step. A tap shorter than one frame still reaches the simulation.
|
|
@@ -99,22 +252,45 @@ export async function startGame(canvas, scene, options = {}) {
|
|
|
99
252
|
const frame = (now) => {
|
|
100
253
|
accumulator += Math.min(MAX_FRAME_TIME, (now - last) / 1000);
|
|
101
254
|
last = now;
|
|
102
|
-
while (accumulator >= FIXED_DT && !crash) {
|
|
255
|
+
while (accumulator >= FIXED_DT && !crash && !loading) {
|
|
103
256
|
try {
|
|
104
257
|
world.step(tapped.size > 0 ? new Set([...held, ...tapped]) : held);
|
|
105
258
|
}
|
|
106
259
|
catch (err) {
|
|
107
260
|
// A script threw: freeze the game and show why, instead of failing silently.
|
|
108
|
-
crash =
|
|
261
|
+
crash = message(err);
|
|
109
262
|
console.error("[ludra]", err);
|
|
110
263
|
}
|
|
111
264
|
tapped.clear();
|
|
112
265
|
view.step(world, (sound) => sounds.play(sound));
|
|
113
266
|
accumulator -= FIXED_DT;
|
|
114
267
|
}
|
|
115
|
-
|
|
268
|
+
// Moving on: let the win screen show, or fade out for a world.goto, then load.
|
|
269
|
+
let fade = 0;
|
|
270
|
+
if (world.exit !== undefined && options.loadScene && !crash) {
|
|
271
|
+
exitSince ??= now;
|
|
272
|
+
const pause = world.won ? WIN_PAUSE : GOTO_FADE;
|
|
273
|
+
const elapsed = (now - exitSince) / 1000;
|
|
274
|
+
fade = Math.min(1, Math.max(0, (elapsed - (pause - GOTO_FADE)) / GOTO_FADE));
|
|
275
|
+
if (elapsed >= pause && !loading)
|
|
276
|
+
moveOn(world.exit);
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
view.draw(ctx, world, sounds.muted, options.loadScene !== undefined);
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
// A draw hook threw. Same treatment as a simulation error.
|
|
283
|
+
if (!crash)
|
|
284
|
+
console.error("[ludra]", err);
|
|
285
|
+
crash ??= message(err);
|
|
286
|
+
}
|
|
287
|
+
if (fade > 0 || loading) {
|
|
288
|
+
ctx.fillStyle = `rgba(0, 0, 0, ${loading ? 1 : fade})`;
|
|
289
|
+
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
|
290
|
+
}
|
|
116
291
|
if (crash)
|
|
117
292
|
drawCrash(ctx, crash);
|
|
293
|
+
screen.present(world.time);
|
|
118
294
|
raf = requestAnimationFrame(frame);
|
|
119
295
|
};
|
|
120
296
|
raf = requestAnimationFrame(frame);
|
|
@@ -132,10 +308,12 @@ export async function startGame(canvas, scene, options = {}) {
|
|
|
132
308
|
* `ludra play --screenshot`.
|
|
133
309
|
*/
|
|
134
310
|
export async function captureFrames(canvas, scene, options) {
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
311
|
+
const screen = new Screen(canvas);
|
|
312
|
+
const scripts = new Map();
|
|
313
|
+
const { images, shader } = await loadScene(scene, options, scripts);
|
|
314
|
+
const error = screen.use(scene.postfx, shader);
|
|
315
|
+
if (error)
|
|
316
|
+
throw new Error(error);
|
|
139
317
|
const view = new View(scene, images);
|
|
140
318
|
const world = new World(scene, { scripts });
|
|
141
319
|
const script = InputScript.parse(options.inputs ?? "");
|
|
@@ -144,8 +322,9 @@ export async function captureFrames(canvas, scene, options) {
|
|
|
144
322
|
const captured = [];
|
|
145
323
|
for (let frame = 0;; frame++) {
|
|
146
324
|
if (wanted.has(frame)) {
|
|
147
|
-
view.draw(ctx, world, false);
|
|
148
|
-
|
|
325
|
+
view.draw(screen.ctx, world, false, false);
|
|
326
|
+
screen.present(world.time);
|
|
327
|
+
captured.push({ frame, time: frame * FIXED_DT, png: screen.output.toDataURL("image/png") });
|
|
149
328
|
}
|
|
150
329
|
if (frame >= last)
|
|
151
330
|
break;
|
|
@@ -201,18 +380,24 @@ class View {
|
|
|
201
380
|
facing = new Map();
|
|
202
381
|
emitTimers = new Map();
|
|
203
382
|
frameCache = new Map();
|
|
383
|
+
/** ctx.local for each entity's draw hooks. */
|
|
384
|
+
local = new Map();
|
|
204
385
|
shake = 0;
|
|
205
386
|
handledEvents = 0;
|
|
206
387
|
scene;
|
|
207
388
|
images;
|
|
389
|
+
/** Whether the scene can award points at all, so the default HUD can leave the score out. */
|
|
390
|
+
scoring;
|
|
208
391
|
constructor(scene, images) {
|
|
209
392
|
this.scene = scene;
|
|
210
393
|
this.images = images;
|
|
394
|
+
this.scoring = scene.entities.some((e) => e.components.collectible || (e.components.hazard && Number(e.components.hazard.score) > 0) || Number(e.components.goal?.requireScore) > 0);
|
|
211
395
|
}
|
|
212
396
|
reset() {
|
|
213
397
|
this.particles.clear();
|
|
214
398
|
this.facing.clear();
|
|
215
399
|
this.emitTimers.clear();
|
|
400
|
+
this.local.clear();
|
|
216
401
|
this.shake = 0;
|
|
217
402
|
this.handledEvents = 0;
|
|
218
403
|
}
|
|
@@ -246,12 +431,13 @@ class View {
|
|
|
246
431
|
this.particles.update(FIXED_DT);
|
|
247
432
|
this.shake = this.shake < 0.5 ? 0 : this.shake * SHAKE_DECAY;
|
|
248
433
|
}
|
|
249
|
-
|
|
434
|
+
/** `continues`: the host loads next scenes, so a won scene with `next` says so instead of offering a replay. */
|
|
435
|
+
draw(ctx, world, muted, continues) {
|
|
250
436
|
const { width: viewWidth, height: viewHeight } = ctx.canvas;
|
|
251
437
|
ctx.imageSmoothingEnabled = false;
|
|
252
438
|
ctx.fillStyle = world.background;
|
|
253
439
|
ctx.fillRect(0, 0, viewWidth, viewHeight);
|
|
254
|
-
const target = world.entities.find((e) => e.camera) ?? world.entities.find((e) => e.platformer);
|
|
440
|
+
const target = world.entities.find((e) => e.camera) ?? world.entities.find((e) => e.platformer || e.topdown);
|
|
255
441
|
const zoom = target?.camera?.zoom ?? 1;
|
|
256
442
|
const visibleWidth = viewWidth / zoom;
|
|
257
443
|
const visibleHeight = viewHeight / zoom;
|
|
@@ -261,32 +447,80 @@ class View {
|
|
|
261
447
|
cameraX += (this.particles.random() - 0.5) * 2 * this.shake;
|
|
262
448
|
cameraY += (this.particles.random() - 0.5) * 2 * this.shake;
|
|
263
449
|
}
|
|
450
|
+
const viewInfo = { width: viewWidth, height: viewHeight, cameraX, cameraY, zoom };
|
|
264
451
|
ctx.save();
|
|
265
452
|
ctx.scale(zoom, zoom);
|
|
266
453
|
ctx.translate(-cameraX, -cameraY);
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
454
|
+
const layer = (e) => e.sprite?.layer ?? 0;
|
|
455
|
+
const visible = world.entities.filter((e) => e.sprite || e.script?.module.draw).sort((a, b) => layer(a) - layer(b));
|
|
456
|
+
for (const e of visible) {
|
|
457
|
+
if (e.sprite)
|
|
458
|
+
this.drawSprite(ctx, e, world.time);
|
|
459
|
+
if (e.script?.module.draw)
|
|
460
|
+
this.drawHook(ctx, world, e, "draw", viewInfo);
|
|
461
|
+
}
|
|
270
462
|
this.particles.draw(ctx);
|
|
271
463
|
ctx.restore();
|
|
464
|
+
if (world.hasHook("drawUI")) {
|
|
465
|
+
// The game draws its own interface.
|
|
466
|
+
for (const e of [...world.entities])
|
|
467
|
+
if (e.script?.module.drawUI)
|
|
468
|
+
this.drawHook(ctx, world, e, "drawUI", viewInfo);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
// Only what this scene uses: no "Score 0" in a game without points.
|
|
472
|
+
const parts = [];
|
|
473
|
+
if (world.lives !== undefined)
|
|
474
|
+
parts.push(`${"♥".repeat(Math.max(0, Math.min(world.lives, 10)))}${world.lives > 10 ? ` ${world.lives}` : ""}`);
|
|
475
|
+
if (this.scoring || world.score !== 0)
|
|
476
|
+
parts.push(`Score ${world.score}`);
|
|
477
|
+
if (world.lives === undefined)
|
|
478
|
+
parts.push(`Deaths ${world.deaths}`);
|
|
272
479
|
ctx.textAlign = "left";
|
|
273
480
|
ctx.font = "600 18px system-ui, sans-serif";
|
|
274
481
|
ctx.fillStyle = "#ffffff";
|
|
275
|
-
ctx.fillText(
|
|
482
|
+
ctx.fillText(parts.join(" "), 16, 30);
|
|
276
483
|
ctx.textAlign = "right";
|
|
277
484
|
ctx.font = "500 13px system-ui, sans-serif";
|
|
278
485
|
ctx.fillStyle = "rgba(255, 255, 255, 0.6)";
|
|
279
486
|
ctx.fillText(`M: sound ${muted ? "off" : "on"} · R: restart`, viewWidth - 16, 28);
|
|
280
|
-
if (world.won) {
|
|
487
|
+
if (world.won || world.lost) {
|
|
281
488
|
ctx.fillStyle = "rgba(0, 0, 0, 0.55)";
|
|
282
489
|
ctx.fillRect(0, 0, viewWidth, viewHeight);
|
|
283
490
|
ctx.fillStyle = "#ffffff";
|
|
284
491
|
ctx.textAlign = "center";
|
|
285
492
|
ctx.font = "700 44px system-ui, sans-serif";
|
|
286
|
-
ctx.fillText("Level complete!", viewWidth / 2, viewHeight / 2 - 10);
|
|
493
|
+
ctx.fillText(world.won ? "Level complete!" : "Game over", viewWidth / 2, viewHeight / 2 - 10);
|
|
287
494
|
ctx.font = "500 18px system-ui, sans-serif";
|
|
288
|
-
const
|
|
289
|
-
|
|
495
|
+
const endTime = world.events.find((e) => e.type === "win" || e.type === "gameover")?.time ?? world.time;
|
|
496
|
+
const next = world.won && world.exit !== undefined && continues ? "Get ready…" : `Press R to ${world.won ? "play again" : "try again"}`;
|
|
497
|
+
const score = this.scoring || world.score !== 0 ? `Score ${world.score} · ` : "";
|
|
498
|
+
ctx.fillText(`${score}${endTime.toFixed(1)}s · ${next}`, viewWidth / 2, viewHeight / 2 + 30);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
drawHook(ctx, world, e, hook, view) {
|
|
502
|
+
let local = this.local.get(e.id);
|
|
503
|
+
if (!local)
|
|
504
|
+
this.local.set(e.id, (local = {}));
|
|
505
|
+
ctx.save();
|
|
506
|
+
try {
|
|
507
|
+
world.callDrawHook(e, hook, {
|
|
508
|
+
g: ctx,
|
|
509
|
+
time: world.time,
|
|
510
|
+
local,
|
|
511
|
+
view,
|
|
512
|
+
recent: (name, seconds = 0.5) => recentEvents(world, name, seconds),
|
|
513
|
+
image: (path) => this.images.get(path),
|
|
514
|
+
burst: (x, y, particles = {}) => this.particles.burst(x, y, particles),
|
|
515
|
+
shake: (amount) => {
|
|
516
|
+
this.shake = Math.max(this.shake, amount);
|
|
517
|
+
},
|
|
518
|
+
random: () => this.particles.random(),
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
finally {
|
|
522
|
+
// Whatever the hook changed (transform, alpha, blend mode, filter) ends with it.
|
|
523
|
+
ctx.restore();
|
|
290
524
|
}
|
|
291
525
|
}
|
|
292
526
|
drawSprite(ctx, e, time) {
|
|
@@ -356,6 +590,24 @@ class View {
|
|
|
356
590
|
return [0];
|
|
357
591
|
}
|
|
358
592
|
}
|
|
593
|
+
function recentEvents(world, name, seconds) {
|
|
594
|
+
const since = world.time - seconds;
|
|
595
|
+
const result = [];
|
|
596
|
+
// Events are in time order, so walk back until they get too old.
|
|
597
|
+
for (let i = world.events.length - 1; i >= 0; i--) {
|
|
598
|
+
const event = world.events[i];
|
|
599
|
+
if (event.time < since)
|
|
600
|
+
break;
|
|
601
|
+
const eventName = event.type === "custom" ? event.name : event.type;
|
|
602
|
+
if (name !== undefined && eventName !== name)
|
|
603
|
+
continue;
|
|
604
|
+
// Custom events carry what the script passed; built-in ones their own details (item, enemy, checkpoint...).
|
|
605
|
+
const { type: _type, frame: _frame, time, x, y, entity, ...details } = event;
|
|
606
|
+
const data = event.type === "custom" ? event.data : details;
|
|
607
|
+
result.push({ name: eventName, time, x, y, entity, data });
|
|
608
|
+
}
|
|
609
|
+
return result.reverse();
|
|
610
|
+
}
|
|
359
611
|
function animationState(e) {
|
|
360
612
|
const b = e.body;
|
|
361
613
|
if (!b)
|
package/dist/components.d.ts
CHANGED
|
@@ -35,7 +35,7 @@ export interface ComponentSpec {
|
|
|
35
35
|
/** Fields without a default are required. */
|
|
36
36
|
fields: Record<string, FieldSpec>;
|
|
37
37
|
}
|
|
38
|
-
export declare const COMPONENT_NAMES: readonly ["transform", "body", "sprite", "platformer", "collectible", "patrol", "hazard", "goal", "emitter", "script", "camera"];
|
|
38
|
+
export declare const COMPONENT_NAMES: readonly ["transform", "body", "sprite", "platformer", "topdown", "collectible", "patrol", "hazard", "goal", "checkpoint", "emitter", "script", "camera"];
|
|
39
39
|
export type ComponentName = (typeof COMPONENT_NAMES)[number];
|
|
40
40
|
/** Shared by the `emitter` component and the particles of scene `effects`. Purely visual. */
|
|
41
41
|
export declare const PARTICLE_FIELDS: Record<string, FieldSpec>;
|
|
@@ -55,6 +55,7 @@ export interface Body {
|
|
|
55
55
|
height: number;
|
|
56
56
|
sensor: boolean;
|
|
57
57
|
gravityScale: number;
|
|
58
|
+
bounce: number;
|
|
58
59
|
vx: number;
|
|
59
60
|
vy: number;
|
|
60
61
|
}
|
|
@@ -99,6 +100,11 @@ export interface Platformer {
|
|
|
99
100
|
jumpBuffer: number;
|
|
100
101
|
variableJump: boolean;
|
|
101
102
|
}
|
|
103
|
+
export interface Topdown {
|
|
104
|
+
speed: number;
|
|
105
|
+
directions: "8" | "4" | "x" | "y";
|
|
106
|
+
clampToWorld: boolean;
|
|
107
|
+
}
|
|
102
108
|
export interface Collectible {
|
|
103
109
|
score: number;
|
|
104
110
|
}
|
|
@@ -113,6 +119,7 @@ export interface Hazard {
|
|
|
113
119
|
}
|
|
114
120
|
export interface Goal {
|
|
115
121
|
requireScore: number;
|
|
122
|
+
next: string;
|
|
116
123
|
}
|
|
117
124
|
export interface Camera {
|
|
118
125
|
zoom: number;
|
package/dist/components.js
CHANGED
|
@@ -3,10 +3,12 @@ export const COMPONENT_NAMES = [
|
|
|
3
3
|
"body",
|
|
4
4
|
"sprite",
|
|
5
5
|
"platformer",
|
|
6
|
+
"topdown",
|
|
6
7
|
"collectible",
|
|
7
8
|
"patrol",
|
|
8
9
|
"hazard",
|
|
9
10
|
"goal",
|
|
11
|
+
"checkpoint",
|
|
10
12
|
"emitter",
|
|
11
13
|
"script",
|
|
12
14
|
"camera",
|
|
@@ -44,6 +46,12 @@ export const COMPONENTS = {
|
|
|
44
46
|
height: { type: "number", min: 1, description: "Collider height in pixels." },
|
|
45
47
|
sensor: { type: "boolean", default: false, description: "Sensors detect overlaps but never block movement (coins, triggers)." },
|
|
46
48
|
gravityScale: { type: "number", default: 1, description: "Multiplier on world gravity for dynamic bodies." },
|
|
49
|
+
bounce: {
|
|
50
|
+
type: "number",
|
|
51
|
+
min: 0,
|
|
52
|
+
default: 0,
|
|
53
|
+
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).",
|
|
54
|
+
},
|
|
47
55
|
vx: { type: "number", default: 0, description: "Initial horizontal velocity in px/s." },
|
|
48
56
|
vy: { type: "number", default: 0, description: "Initial vertical velocity in px/s." },
|
|
49
57
|
},
|
|
@@ -80,8 +88,22 @@ export const COMPONENTS = {
|
|
|
80
88
|
variableJump: { type: "boolean", default: true, description: "Releasing jump early cuts the jump short." },
|
|
81
89
|
},
|
|
82
90
|
},
|
|
91
|
+
topdown: {
|
|
92
|
+
description: "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).",
|
|
93
|
+
requires: ["transform", "body"],
|
|
94
|
+
fields: {
|
|
95
|
+
speed: { type: "number", min: 0, default: 200, description: "Move speed in px/s." },
|
|
96
|
+
directions: {
|
|
97
|
+
type: "enum",
|
|
98
|
+
values: ["8", "4", "x", "y"],
|
|
99
|
+
default: "8",
|
|
100
|
+
description: '"8": any direction (diagonals keep the same speed). "4": up/down/left/right only. "x" or "y": one axis.',
|
|
101
|
+
},
|
|
102
|
+
clampToWorld: { type: "boolean", default: true, description: "Keep the entity inside the world bounds." },
|
|
103
|
+
},
|
|
104
|
+
},
|
|
83
105
|
collectible: {
|
|
84
|
-
description: "Removed when a platformer
|
|
106
|
+
description: "Removed when a player (platformer or topdown) touches it and adds to the score. Its body must be a sensor.",
|
|
85
107
|
requires: ["transform", "body"],
|
|
86
108
|
fields: {
|
|
87
109
|
score: { type: "number", default: 1, description: "Points added when collected." },
|
|
@@ -109,8 +131,18 @@ export const COMPONENTS = {
|
|
|
109
131
|
requires: ["transform", "body"],
|
|
110
132
|
fields: {
|
|
111
133
|
requireScore: { type: "number", min: 0, default: 0, description: "Score needed before the goal accepts the player." },
|
|
134
|
+
next: {
|
|
135
|
+
type: "string",
|
|
136
|
+
default: "",
|
|
137
|
+
description: 'Scene to play after this one is won, e.g. "scenes/level-2.scene.json". The browser moves on and keeps score, deaths and lives; each scene is tested on its own.',
|
|
138
|
+
},
|
|
112
139
|
},
|
|
113
140
|
},
|
|
141
|
+
checkpoint: {
|
|
142
|
+
description: "When a player touches it, that player respawns here after dying (standing on the checkpoint's bottom edge). Fires a checkpoint event once. Its body must be a sensor.",
|
|
143
|
+
requires: ["transform", "body"],
|
|
144
|
+
fields: {},
|
|
145
|
+
},
|
|
114
146
|
emitter: {
|
|
115
147
|
description: "Continuously emits visual particles from the entity (torches, sparkles, smoke). It does not affect gameplay.",
|
|
116
148
|
requires: ["transform"],
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ScriptModules } from "./script.ts";
|
|
2
|
+
import type { Diagnostic, SceneDef } from "./types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Runs every `draw` and `drawUI` hook against a mock canvas at a few points of
|
|
5
|
+
* an idle run, and reports hooks that throw. Used by `ludra check`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function checkDrawHooks(scene: SceneDef, scripts: ScriptModules): Diagnostic[];
|