@ludra/core 0.7.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/assertions.d.ts +5 -1
- package/dist/assertions.js +8 -4
- package/dist/browser.d.ts +8 -1
- package/dist/browser.js +171 -51
- package/dist/components.d.ts +2 -1
- package/dist/components.js +11 -0
- package/dist/effects.d.ts +2 -2
- package/dist/effects.js +4 -1
- package/dist/script.d.ts +19 -2
- package/dist/script.js +4 -3
- package/dist/types.d.ts +16 -1
- package/dist/types.js +1 -1
- package/dist/validate.d.ts +3 -1
- package/dist/validate.js +72 -3
- package/dist/world.d.ts +35 -0
- package/dist/world.js +101 -11
- package/package.json +1 -1
- package/src/assertions.ts +12 -4
- package/src/browser.ts +178 -61
- package/src/components.ts +13 -0
- package/src/effects.ts +4 -2
- package/src/script.ts +22 -4
- package/src/types.ts +17 -1
- package/src/validate.ts +72 -3
- package/src/world.ts +103 -11
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
|
@@ -74,63 +74,149 @@ async function loadFonts(scene, assetBase) {
|
|
|
74
74
|
const all = Promise.all(fonts.map((font) => load(font).catch((err) => console.warn(`[ludra] could not load font "${font[0]}": ${err}`))));
|
|
75
75
|
await Promise.race([all, new Promise((resolve) => setTimeout(resolve, 4000))]);
|
|
76
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
|
+
}
|
|
77
87
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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.
|
|
80
91
|
*/
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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) {
|
|
85
138
|
const assetBase = options.assetBase ?? "";
|
|
86
|
-
const
|
|
87
|
-
const [images, scripts, shader] = await Promise.all([
|
|
139
|
+
const [images, more, shader] = await Promise.all([
|
|
88
140
|
loadImages(scene, assetBase),
|
|
89
141
|
loadScripts(scene, options),
|
|
90
|
-
|
|
91
|
-
? fetch(assetBase + shaderPath).then(async (r) => (r.ok ? r.text() : new Error(`could not load ${shaderPath} (${r.status})`)))
|
|
92
|
-
: undefined,
|
|
142
|
+
loadShader(scene, assetBase),
|
|
93
143
|
loadFonts(scene, assetBase),
|
|
94
144
|
]);
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
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;
|
|
145
|
+
for (const [path, script] of more)
|
|
146
|
+
scripts.set(path, script);
|
|
147
|
+
return { images, shader };
|
|
114
148
|
}
|
|
115
149
|
function message(err) {
|
|
116
150
|
return err instanceof Error ? err.message : String(err);
|
|
117
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;
|
|
118
155
|
/**
|
|
119
156
|
* Runs a scene in a canvas with keyboard controls, images, particles and
|
|
120
157
|
* sound. The simulation uses the same fixed step as headless runs, so what
|
|
121
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.
|
|
122
161
|
* Resolves (after images load) to a function that stops the game.
|
|
123
162
|
*/
|
|
124
|
-
export async function startGame(canvas,
|
|
125
|
-
const
|
|
163
|
+
export async function startGame(canvas, firstScene, options = {}) {
|
|
164
|
+
const screen = new Screen(canvas);
|
|
165
|
+
const ctx = screen.ctx;
|
|
126
166
|
const sounds = new SoundPlayer(options.assetBase ?? "");
|
|
127
|
-
const
|
|
128
|
-
let
|
|
129
|
-
let
|
|
167
|
+
const scripts = new Map();
|
|
168
|
+
let scene = firstScene;
|
|
169
|
+
let startCarry;
|
|
170
|
+
let view;
|
|
171
|
+
let world;
|
|
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.
|
|
130
195
|
const restart = () => {
|
|
131
|
-
|
|
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 });
|
|
132
204
|
view.reset();
|
|
133
|
-
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));
|
|
134
220
|
};
|
|
135
221
|
const held = new Set();
|
|
136
222
|
// Keys pressed since the last step. A tap shorter than one frame still reaches the simulation.
|
|
@@ -166,7 +252,7 @@ export async function startGame(canvas, scene, options = {}) {
|
|
|
166
252
|
const frame = (now) => {
|
|
167
253
|
accumulator += Math.min(MAX_FRAME_TIME, (now - last) / 1000);
|
|
168
254
|
last = now;
|
|
169
|
-
while (accumulator >= FIXED_DT && !crash) {
|
|
255
|
+
while (accumulator >= FIXED_DT && !crash && !loading) {
|
|
170
256
|
try {
|
|
171
257
|
world.step(tapped.size > 0 ? new Set([...held, ...tapped]) : held);
|
|
172
258
|
}
|
|
@@ -179,8 +265,18 @@ export async function startGame(canvas, scene, options = {}) {
|
|
|
179
265
|
view.step(world, (sound) => sounds.play(sound));
|
|
180
266
|
accumulator -= FIXED_DT;
|
|
181
267
|
}
|
|
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
|
+
}
|
|
182
278
|
try {
|
|
183
|
-
view.draw(ctx, world, sounds.muted);
|
|
279
|
+
view.draw(ctx, world, sounds.muted, options.loadScene !== undefined);
|
|
184
280
|
}
|
|
185
281
|
catch (err) {
|
|
186
282
|
// A draw hook threw. Same treatment as a simulation error.
|
|
@@ -188,9 +284,13 @@ export async function startGame(canvas, scene, options = {}) {
|
|
|
188
284
|
console.error("[ludra]", err);
|
|
189
285
|
crash ??= message(err);
|
|
190
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
|
+
}
|
|
191
291
|
if (crash)
|
|
192
292
|
drawCrash(ctx, crash);
|
|
193
|
-
present(world.time);
|
|
293
|
+
screen.present(world.time);
|
|
194
294
|
raf = requestAnimationFrame(frame);
|
|
195
295
|
};
|
|
196
296
|
raf = requestAnimationFrame(frame);
|
|
@@ -208,7 +308,10 @@ export async function startGame(canvas, scene, options = {}) {
|
|
|
208
308
|
* `ludra play --screenshot`.
|
|
209
309
|
*/
|
|
210
310
|
export async function captureFrames(canvas, scene, options) {
|
|
211
|
-
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);
|
|
212
315
|
if (error)
|
|
213
316
|
throw new Error(error);
|
|
214
317
|
const view = new View(scene, images);
|
|
@@ -219,9 +322,9 @@ export async function captureFrames(canvas, scene, options) {
|
|
|
219
322
|
const captured = [];
|
|
220
323
|
for (let frame = 0;; frame++) {
|
|
221
324
|
if (wanted.has(frame)) {
|
|
222
|
-
view.draw(ctx, world, false);
|
|
223
|
-
present(world.time);
|
|
224
|
-
captured.push({ frame, time: frame * FIXED_DT, png: output.toDataURL("image/png") });
|
|
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") });
|
|
225
328
|
}
|
|
226
329
|
if (frame >= last)
|
|
227
330
|
break;
|
|
@@ -283,9 +386,12 @@ class View {
|
|
|
283
386
|
handledEvents = 0;
|
|
284
387
|
scene;
|
|
285
388
|
images;
|
|
389
|
+
/** Whether the scene can award points at all, so the default HUD can leave the score out. */
|
|
390
|
+
scoring;
|
|
286
391
|
constructor(scene, images) {
|
|
287
392
|
this.scene = scene;
|
|
288
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);
|
|
289
395
|
}
|
|
290
396
|
reset() {
|
|
291
397
|
this.particles.clear();
|
|
@@ -325,7 +431,8 @@ class View {
|
|
|
325
431
|
this.particles.update(FIXED_DT);
|
|
326
432
|
this.shake = this.shake < 0.5 ? 0 : this.shake * SHAKE_DECAY;
|
|
327
433
|
}
|
|
328
|
-
|
|
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) {
|
|
329
436
|
const { width: viewWidth, height: viewHeight } = ctx.canvas;
|
|
330
437
|
ctx.imageSmoothingEnabled = false;
|
|
331
438
|
ctx.fillStyle = world.background;
|
|
@@ -361,24 +468,34 @@ class View {
|
|
|
361
468
|
this.drawHook(ctx, world, e, "drawUI", viewInfo);
|
|
362
469
|
return;
|
|
363
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}`);
|
|
364
479
|
ctx.textAlign = "left";
|
|
365
480
|
ctx.font = "600 18px system-ui, sans-serif";
|
|
366
481
|
ctx.fillStyle = "#ffffff";
|
|
367
|
-
ctx.fillText(
|
|
482
|
+
ctx.fillText(parts.join(" "), 16, 30);
|
|
368
483
|
ctx.textAlign = "right";
|
|
369
484
|
ctx.font = "500 13px system-ui, sans-serif";
|
|
370
485
|
ctx.fillStyle = "rgba(255, 255, 255, 0.6)";
|
|
371
486
|
ctx.fillText(`M: sound ${muted ? "off" : "on"} · R: restart`, viewWidth - 16, 28);
|
|
372
|
-
if (world.won) {
|
|
487
|
+
if (world.won || world.lost) {
|
|
373
488
|
ctx.fillStyle = "rgba(0, 0, 0, 0.55)";
|
|
374
489
|
ctx.fillRect(0, 0, viewWidth, viewHeight);
|
|
375
490
|
ctx.fillStyle = "#ffffff";
|
|
376
491
|
ctx.textAlign = "center";
|
|
377
492
|
ctx.font = "700 44px system-ui, sans-serif";
|
|
378
|
-
ctx.fillText("Level complete!", viewWidth / 2, viewHeight / 2 - 10);
|
|
493
|
+
ctx.fillText(world.won ? "Level complete!" : "Game over", viewWidth / 2, viewHeight / 2 - 10);
|
|
379
494
|
ctx.font = "500 18px system-ui, sans-serif";
|
|
380
|
-
const
|
|
381
|
-
|
|
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);
|
|
382
499
|
}
|
|
383
500
|
}
|
|
384
501
|
drawHook(ctx, world, e, hook, view) {
|
|
@@ -484,7 +601,10 @@ function recentEvents(world, name, seconds) {
|
|
|
484
601
|
const eventName = event.type === "custom" ? event.name : event.type;
|
|
485
602
|
if (name !== undefined && eventName !== name)
|
|
486
603
|
continue;
|
|
487
|
-
|
|
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 });
|
|
488
608
|
}
|
|
489
609
|
return result.reverse();
|
|
490
610
|
}
|
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", "topdown", "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>;
|
|
@@ -119,6 +119,7 @@ export interface Hazard {
|
|
|
119
119
|
}
|
|
120
120
|
export interface Goal {
|
|
121
121
|
requireScore: number;
|
|
122
|
+
next: string;
|
|
122
123
|
}
|
|
123
124
|
export interface Camera {
|
|
124
125
|
zoom: number;
|
package/dist/components.js
CHANGED
|
@@ -8,6 +8,7 @@ export const COMPONENT_NAMES = [
|
|
|
8
8
|
"patrol",
|
|
9
9
|
"hazard",
|
|
10
10
|
"goal",
|
|
11
|
+
"checkpoint",
|
|
11
12
|
"emitter",
|
|
12
13
|
"script",
|
|
13
14
|
"camera",
|
|
@@ -130,8 +131,18 @@ export const COMPONENTS = {
|
|
|
130
131
|
requires: ["transform", "body"],
|
|
131
132
|
fields: {
|
|
132
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
|
+
},
|
|
133
139
|
},
|
|
134
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
|
+
},
|
|
135
146
|
emitter: {
|
|
136
147
|
description: "Continuously emits visual particles from the entity (torches, sparkles, smoke). It does not affect gameplay.",
|
|
137
148
|
requires: ["transform"],
|
package/dist/effects.d.ts
CHANGED
|
@@ -7,14 +7,14 @@ export declare const AUDIO_EXTENSIONS: readonly [".wav", ".mp3", ".ogg"];
|
|
|
7
7
|
export declare const IMAGE_EXTENSIONS: readonly [".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp"];
|
|
8
8
|
export declare const FONT_EXTENSIONS: readonly [".woff2", ".woff", ".ttf", ".otf"];
|
|
9
9
|
export declare const EFFECT_FIELDS: Record<string, FieldSpec>;
|
|
10
|
-
export declare const EFFECTS_DESCRIPTION = "Scene-level \"effects\" map a game event (jump, land, collect, die, stomp, win) to feedback: a particle burst at the event position, a sound and a screen shake. They never affect gameplay.";
|
|
10
|
+
export declare const EFFECTS_DESCRIPTION = "Scene-level \"effects\" map a game event (jump, land, collect, die, stomp, win, bounce, checkpoint, gameover, goto, or a custom one) to feedback: a particle burst at the event position, a sound and a screen shake. They never affect gameplay.";
|
|
11
11
|
export declare function isSoundPreset(sound: string): sound is SoundPreset;
|
|
12
12
|
export interface AssetReference {
|
|
13
13
|
/** Path relative to the project, as written in the scene. */
|
|
14
14
|
path: string;
|
|
15
15
|
/** JSON path of the field that references it. */
|
|
16
16
|
at: string;
|
|
17
|
-
kind: "image" | "audio" | "script" | "font" | "shader";
|
|
17
|
+
kind: "image" | "audio" | "script" | "font" | "shader" | "scene";
|
|
18
18
|
}
|
|
19
19
|
/** Every file a scene points at, so tools can check that they exist. */
|
|
20
20
|
export declare function collectAssetPaths(scene: SceneDef): AssetReference[];
|
package/dist/effects.js
CHANGED
|
@@ -11,7 +11,7 @@ export const EFFECT_FIELDS = {
|
|
|
11
11
|
},
|
|
12
12
|
shake: { type: "number", min: 0, default: 0, description: "Screen shake strength in pixels." },
|
|
13
13
|
};
|
|
14
|
-
export const EFFECTS_DESCRIPTION = 'Scene-level "effects" map a game event (jump, land, collect, die, stomp, win) to feedback: a particle burst at the event position, a sound and a screen shake. They never affect gameplay.';
|
|
14
|
+
export const EFFECTS_DESCRIPTION = 'Scene-level "effects" map a game event (jump, land, collect, die, stomp, win, bounce, checkpoint, gameover, goto, or a custom one) to feedback: a particle burst at the event position, a sound and a screen shake. They never affect gameplay.';
|
|
15
15
|
export function isSoundPreset(sound) {
|
|
16
16
|
return SOUND_PRESETS.includes(sound);
|
|
17
17
|
}
|
|
@@ -22,6 +22,9 @@ export function collectAssetPaths(scene) {
|
|
|
22
22
|
const image = entity.components.sprite?.image;
|
|
23
23
|
if (typeof image === "string" && image !== "")
|
|
24
24
|
refs.push({ path: image, at: `entities[${i}].components.sprite.image`, kind: "image" });
|
|
25
|
+
const next = entity.components.goal?.next;
|
|
26
|
+
if (typeof next === "string" && next !== "")
|
|
27
|
+
refs.push({ path: next, at: `entities[${i}].components.goal.next`, kind: "scene" });
|
|
25
28
|
const src = entity.components.script?.src;
|
|
26
29
|
if (typeof src === "string" && src !== "")
|
|
27
30
|
refs.push({ path: src, at: `entities[${i}].components.script.src`, kind: "script" });
|
package/dist/script.d.ts
CHANGED
|
@@ -27,7 +27,9 @@ export interface ScriptEntity {
|
|
|
27
27
|
export interface SpawnDef {
|
|
28
28
|
/** Generated when left out. */
|
|
29
29
|
id?: string;
|
|
30
|
-
components
|
|
30
|
+
/** Start from a prefab; `components` then only needs what differs (usually the transform). */
|
|
31
|
+
prefab?: string;
|
|
32
|
+
components?: Record<string, Record<string, unknown>>;
|
|
31
33
|
}
|
|
32
34
|
export interface ScriptWorld {
|
|
33
35
|
readonly time: number;
|
|
@@ -39,6 +41,12 @@ export interface ScriptWorld {
|
|
|
39
41
|
readonly deaths: number;
|
|
40
42
|
/** True once the level is complete. */
|
|
41
43
|
readonly won: boolean;
|
|
44
|
+
/** Lives left, or undefined when the scene has unlimited retries. Writable: an extra life is `world.lives += 1`. */
|
|
45
|
+
lives: number | undefined;
|
|
46
|
+
/** True once the game is over (out of lives, or world.lose()). */
|
|
47
|
+
readonly lost: boolean;
|
|
48
|
+
/** The scene this one hands over to (a won goal's next, or world.goto), once it does. */
|
|
49
|
+
readonly exit: string | undefined;
|
|
42
50
|
get(id: string): ScriptEntity | undefined;
|
|
43
51
|
/** Every entity, or only those with the given component. */
|
|
44
52
|
all(component?: string): ScriptEntity[];
|
|
@@ -50,6 +58,14 @@ export interface ScriptWorld {
|
|
|
50
58
|
kill(entity: ScriptEntity | string): void;
|
|
51
59
|
/** Completes the level, like reaching a goal. */
|
|
52
60
|
win(): void;
|
|
61
|
+
/** Ends the game as lost (a time limit ran out, the base was destroyed...). Fires "gameover". */
|
|
62
|
+
lose(): void;
|
|
63
|
+
/**
|
|
64
|
+
* Moves on to another scene, e.g. world.goto("scenes/level-1.scene.json") from a title
|
|
65
|
+
* screen. The current scene stops; the browser loads the next one, keeping score,
|
|
66
|
+
* deaths and lives. Fires "goto" (tests can assert events.goto).
|
|
67
|
+
*/
|
|
68
|
+
goto(scene: string): void;
|
|
53
69
|
/** Fires a custom event that tests can count (`events.<name>`) and `effects` can react to. */
|
|
54
70
|
emit(name: string, data?: unknown): void;
|
|
55
71
|
/** A number in [0, 1). Seeded by `world.seed`, so runs stay deterministic. Never use Math.random. */
|
|
@@ -79,6 +95,7 @@ export interface DrawEvent {
|
|
|
79
95
|
y: number;
|
|
80
96
|
/** The entity that caused it. */
|
|
81
97
|
entity: string;
|
|
98
|
+
/** Custom events: what the script passed to world.emit. Built-in ones: their details, e.g. { checkpoint: "lantern" } or { item: "coin-1", score: 1 }. */
|
|
82
99
|
data?: unknown;
|
|
83
100
|
}
|
|
84
101
|
/**
|
|
@@ -148,7 +165,7 @@ export interface Script {
|
|
|
148
165
|
}
|
|
149
166
|
export declare const SCRIPT_HOOKS: readonly ["start", "update", "touch", "draw", "drawUI"];
|
|
150
167
|
/** Printed by `ludra components script`. Keep in sync with the interfaces above. */
|
|
151
|
-
export declare const SCRIPT_API_REFERENCE = "A script file default-exports hooks. Only \"import type\" is allowed: scripts run without a bundler.\n\n import type { Script } from \"@ludra/core\";\n export default {\n start(ctx) {}, // once, on the entity's first frame\n update(ctx) {}, // every frame, after player input, before physics\n touch(ctx, other) {}, // when this entity starts touching another body (after physics)\n draw(ctx) {}, // paint this entity in world space (browser only, every rendered frame)\n drawUI(ctx) {}, // paint screen-space UI; replaces the default HUD and win screen\n } satisfies Script;\n\nctx.self the entity: id, x, y, vx, vy (read/write); grounded, width, height, exists (read);\n state (object kept between frames, testable as \"<id>.state.<key>\");\n has(\"component\"), touches(otherOrId)\nctx.world time, frame, width, height, deaths, won (read); score (read/write)\n get(id), all(component?), player()\n spawn({ id?, components }) -> entity destroy(entityOrId)\n kill(entityOrId) players respawn, others are destroyed\n win() completes the level\n emit(name, data?) custom event: counted by \"events.<name>\" in tests, usable as an effects key\n random() deterministic, seeded by world.seed. Never use Math.random or Date\nctx.input held(action), pressed(action) actions: left right up down jump action\nctx.params the \"params\" object from the script component\nctx.dt seconds per frame (1/60)\n\nDrawing: draw and drawUI get the real Canvas 2D context, so there are no limits on looks.\nctx.g CanvasRenderingContext2D: gradients, shadowBlur glow, globalCompositeOperation (\"lighter\" for\n additive light), paths, text with any loaded font, ctx.g.filter, drawImage...\n draw: origin is the world (use self.x/self.y); drawUI: origin is the screen top-left\nctx.time simulation seconds; animate with it so screenshots are reproducible\nctx.local per-entity presentation memory (trails, tweens); not game state\nctx.view width, height, cameraX, cameraY, zoom\nctx.recent(name?, seconds = 0.5) recent events [{ name, time, x, y, entity, data }]: flash on hit, pop scores\nctx.image(path) a loaded image (sprite images and string params ending in .svg/.png/... are preloaded)\nctx.burst(x, y, particles?) ctx.shake(px) ctx.random() presentation-only helpers\nself/world/params as in update, but read-only: changing the game in a draw hook is an error.\n\nScene-level presentation (all optional):\n \"fonts\": { \"Orbitron\": \"google\", \"Pixel\": \"assets/pixel.woff2\" } load fonts for ctx.g.font\n \"postfx\": { \"shader\": \"shaders/crt.frag\", \"uniforms\": { \"strength\": 0.6 } }\n a GLSL ES 1.0 fragment shader over the whole frame. It gets: uniform sampler2D u_image;\n uniform vec2 u_resolution; uniform float u_time; varying vec2 v_uv; plus each uniform as a float.\n\nScripts in scripts/ are loaded even if no entity uses them, so world.spawn can attach them.\nErrors name the file, entity, hook and frame.";
|
|
168
|
+
export declare const SCRIPT_API_REFERENCE = "A script file default-exports hooks. Only \"import type\" is allowed: scripts run without a bundler.\n\n import type { Script } from \"@ludra/core\";\n export default {\n start(ctx) {}, // once, on the entity's first frame\n update(ctx) {}, // every frame, after player input, before physics\n touch(ctx, other) {}, // when this entity starts touching another body (after physics)\n draw(ctx) {}, // paint this entity in world space (browser only, every rendered frame)\n drawUI(ctx) {}, // paint screen-space UI; replaces the default HUD and win screen\n } satisfies Script;\n\nctx.self the entity: id, x, y, vx, vy (read/write); grounded, width, height, exists (read);\n state (object kept between frames, testable as \"<id>.state.<key>\");\n has(\"component\"), touches(otherOrId)\nctx.world time, frame, width, height, deaths, won, lost, exit (read); score, lives (read/write)\n get(id), all(component?), player()\n spawn({ id?, prefab?, components? }) -> entity destroy(entityOrId)\n kill(entityOrId) players respawn, others are destroyed\n win() completes the level lose() game over\n goto(scenePath) moves on to another scene (title -> level 1, a door to the next room)\n emit(name, data?) custom event: counted by \"events.<name>\" in tests, usable as an effects key\n random() deterministic, seeded by world.seed. Never use Math.random or Date\nctx.input held(action), pressed(action) actions: left right up down jump action\nctx.params the \"params\" object from the script component\nctx.dt seconds per frame (1/60)\n\nDrawing: draw and drawUI get the real Canvas 2D context, so there are no limits on looks.\nctx.g CanvasRenderingContext2D: gradients, shadowBlur glow, globalCompositeOperation (\"lighter\" for\n additive light), paths, text with any loaded font, ctx.g.filter, drawImage...\n draw: origin is the world (use self.x/self.y); drawUI: origin is the screen top-left\nctx.time simulation seconds; animate with it so screenshots are reproducible\nctx.local per-entity presentation memory (trails, tweens); not game state\nctx.view width, height, cameraX, cameraY, zoom\nctx.recent(name?, seconds = 0.5) recent events [{ name, time, x, y, entity, data }]: flash on hit, pop scores\nctx.image(path) a loaded image (sprite images and string params ending in .svg/.png/... are preloaded)\nctx.burst(x, y, particles?) ctx.shake(px) ctx.random() presentation-only helpers\nself/world/params as in update, but read-only: changing the game in a draw hook is an error.\n\nScene-level presentation (all optional):\n \"fonts\": { \"Orbitron\": \"google\", \"Pixel\": \"assets/pixel.woff2\" } load fonts for ctx.g.font\n \"postfx\": { \"shader\": \"shaders/crt.frag\", \"uniforms\": { \"strength\": 0.6 } }\n a GLSL ES 1.0 fragment shader over the whole frame. It gets: uniform sampler2D u_image;\n uniform vec2 u_resolution; uniform float u_time; varying vec2 v_uv; plus each uniform as a float.\n\nScripts in scripts/ are loaded even if no entity uses them, so world.spawn can attach them.\nErrors name the file, entity, hook and frame.";
|
|
152
169
|
export type ScriptModules = ReadonlyMap<string, Script> | Readonly<Record<string, Script>>;
|
|
153
170
|
export declare function getScript(modules: ScriptModules | undefined, src: string): Script | undefined;
|
|
154
171
|
/** A script threw, or was used wrongly. Names the file, entity, hook and frame. */
|
package/dist/script.js
CHANGED
|
@@ -15,11 +15,12 @@ export const SCRIPT_API_REFERENCE = `A script file default-exports hooks. Only "
|
|
|
15
15
|
ctx.self the entity: id, x, y, vx, vy (read/write); grounded, width, height, exists (read);
|
|
16
16
|
state (object kept between frames, testable as "<id>.state.<key>");
|
|
17
17
|
has("component"), touches(otherOrId)
|
|
18
|
-
ctx.world time, frame, width, height, deaths, won (read); score (read/write)
|
|
18
|
+
ctx.world time, frame, width, height, deaths, won, lost, exit (read); score, lives (read/write)
|
|
19
19
|
get(id), all(component?), player()
|
|
20
|
-
spawn({ id?, components }) -> entity destroy(entityOrId)
|
|
20
|
+
spawn({ id?, prefab?, components? }) -> entity destroy(entityOrId)
|
|
21
21
|
kill(entityOrId) players respawn, others are destroyed
|
|
22
|
-
win() completes the level
|
|
22
|
+
win() completes the level lose() game over
|
|
23
|
+
goto(scenePath) moves on to another scene (title -> level 1, a door to the next room)
|
|
23
24
|
emit(name, data?) custom event: counted by "events.<name>" in tests, usable as an effects key
|
|
24
25
|
random() deterministic, seeded by world.seed. Never use Math.random or Date
|
|
25
26
|
ctx.input held(action), pressed(action) actions: left right up down jump action
|
package/dist/types.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type Action = "left" | "right" | "up" | "down" | "jump" | "action";
|
|
2
2
|
export declare const ACTIONS: readonly Action[];
|
|
3
|
-
export declare const EVENT_TYPES: readonly ["jump", "land", "collect", "die", "stomp", "win", "bounce"];
|
|
3
|
+
export declare const EVENT_TYPES: readonly ["jump", "land", "collect", "die", "stomp", "win", "bounce", "checkpoint", "gameover", "goto"];
|
|
4
4
|
export type GameEventType = (typeof EVENT_TYPES)[number];
|
|
5
5
|
/** What happens visually and audibly when a game event fires. See `EFFECT_FIELDS`. */
|
|
6
6
|
export interface EffectDef {
|
|
@@ -17,6 +17,8 @@ export interface PostFxDef {
|
|
|
17
17
|
/** An entity as written in a scene file. */
|
|
18
18
|
export interface EntityDef {
|
|
19
19
|
id: string;
|
|
20
|
+
/** Name of a prefab to start from. Gone after validation: its components are merged in. */
|
|
21
|
+
prefab?: string;
|
|
20
22
|
components: Record<string, Record<string, unknown>>;
|
|
21
23
|
}
|
|
22
24
|
export interface WorldSettings {
|
|
@@ -28,6 +30,13 @@ export interface WorldSettings {
|
|
|
28
30
|
background?: string;
|
|
29
31
|
/** Seed for `world.random()` in scripts. Defaults to 1. */
|
|
30
32
|
seed?: number;
|
|
33
|
+
/** Lives before game over. Leave out for unlimited retries. */
|
|
34
|
+
lives?: number;
|
|
35
|
+
}
|
|
36
|
+
/** A reusable entity template: `prefabs/<name>.prefab.json` or the scene's `prefabs` map. */
|
|
37
|
+
export interface PrefabDef {
|
|
38
|
+
description?: string;
|
|
39
|
+
components: Record<string, Record<string, unknown>>;
|
|
31
40
|
}
|
|
32
41
|
/** A scene file (`*.scene.json`). */
|
|
33
42
|
export interface SceneDef {
|
|
@@ -45,6 +54,12 @@ export interface SceneDef {
|
|
|
45
54
|
fonts?: Record<string, string>;
|
|
46
55
|
/** A GLSL fragment shader applied to every rendered frame. Presentation only. */
|
|
47
56
|
postfx?: PostFxDef;
|
|
57
|
+
/**
|
|
58
|
+
* Entity templates by name. Entities use one with `"prefab": "<name>"` and override
|
|
59
|
+
* components; scripts spawn them with `world.spawn({ prefab: "<name>" })`. The CLI
|
|
60
|
+
* adds every `prefabs/*.prefab.json` file here.
|
|
61
|
+
*/
|
|
62
|
+
prefabs?: Record<string, PrefabDef>;
|
|
48
63
|
entities: EntityDef[];
|
|
49
64
|
}
|
|
50
65
|
export interface Diagnostic {
|
package/dist/types.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export const ACTIONS = ["left", "right", "up", "down", "jump", "action"];
|
|
2
|
-
export const EVENT_TYPES = ["jump", "land", "collect", "die", "stomp", "win", "bounce"];
|
|
2
|
+
export const EVENT_TYPES = ["jump", "land", "collect", "die", "stomp", "win", "bounce", "checkpoint", "gameover", "goto"];
|