@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/src/browser.ts
CHANGED
|
@@ -6,8 +6,8 @@ import { ParticleSystem } from "./particles.ts";
|
|
|
6
6
|
import { PostFx } from "./postfx.ts";
|
|
7
7
|
import type { DrawContext, DrawEvent, Script } from "./script.ts";
|
|
8
8
|
import { FIXED_DT } from "./time.ts";
|
|
9
|
-
import type { Action, SceneDef } from "./types.ts";
|
|
10
|
-
import { World, type Entity } from "./world.ts";
|
|
9
|
+
import type { Action, PostFxDef, SceneDef } from "./types.ts";
|
|
10
|
+
import { World, type Entity, type GameEvent } from "./world.ts";
|
|
11
11
|
|
|
12
12
|
const KEYMAP: Record<string, Action> = {
|
|
13
13
|
ArrowLeft: "left",
|
|
@@ -36,6 +36,11 @@ export interface GameOptions {
|
|
|
36
36
|
scriptBase?: string;
|
|
37
37
|
/** Scripts to load. Defaults to those the scene references; include any that `world.spawn` uses. */
|
|
38
38
|
scriptPaths?: readonly string[];
|
|
39
|
+
/**
|
|
40
|
+
* Loads another scene by path, for goals with `next` and `world.goto`. Without it the
|
|
41
|
+
* game stays on its first scene. Throw with a readable message if the scene is invalid.
|
|
42
|
+
*/
|
|
43
|
+
loadScene?: (path: string) => Promise<SceneDef>;
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
/** Imports the game's script modules, keyed by their path in the scene. */
|
|
@@ -82,76 +87,153 @@ async function loadFonts(scene: SceneDef, assetBase: string): Promise<void> {
|
|
|
82
87
|
await Promise.race([all, new Promise((resolve) => setTimeout(resolve, 4000))]);
|
|
83
88
|
}
|
|
84
89
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
/** Shows the drawn frame: runs the postfx shader, or does nothing without one. */
|
|
93
|
-
present(time: number): void;
|
|
94
|
-
/** Why postfx couldn't start (the game then runs without it and shows this message). */
|
|
95
|
-
error?: string;
|
|
90
|
+
/** A scene's postfx shader source, or why it couldn't be fetched. */
|
|
91
|
+
async function loadShader(scene: SceneDef, assetBase: string): Promise<string | Error | undefined> {
|
|
92
|
+
const path = scene.postfx?.shader;
|
|
93
|
+
if (!path) return undefined;
|
|
94
|
+
const response = await fetch(assetBase + path).catch((err: unknown) => err as Error);
|
|
95
|
+
if (response instanceof Error) return response;
|
|
96
|
+
return response.ok ? response.text() : new Error(`could not load ${path} (${response.status})`);
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
100
|
+
* The canvas players see. The game always draws into the 2D canvas; when a scene
|
|
101
|
+
* has `postfx`, a WebGL canvas running its shader takes the 2D canvas's place in
|
|
102
|
+
* the page, and goes away again for a scene without one.
|
|
101
103
|
*/
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
class Screen {
|
|
105
|
+
readonly ctx: CanvasRenderingContext2D;
|
|
106
|
+
output: HTMLCanvasElement;
|
|
107
|
+
private readonly canvas: HTMLCanvasElement;
|
|
108
|
+
private fx: PostFx | undefined;
|
|
109
|
+
|
|
110
|
+
constructor(canvas: HTMLCanvasElement) {
|
|
111
|
+
const ctx = canvas.getContext("2d");
|
|
112
|
+
if (!ctx) throw new Error("Canvas 2D context is not available.");
|
|
113
|
+
this.ctx = ctx;
|
|
114
|
+
this.canvas = canvas;
|
|
115
|
+
this.output = canvas;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Switches to a scene's shader, or to none. Returns why the shader can't run, if it can't. */
|
|
119
|
+
use(postfx: PostFxDef | undefined, shader: string | Error | undefined): string | undefined {
|
|
120
|
+
let fx: PostFx | undefined;
|
|
121
|
+
let error: string | undefined;
|
|
122
|
+
if (postfx && shader !== undefined) {
|
|
123
|
+
try {
|
|
124
|
+
if (shader instanceof Error) throw shader;
|
|
125
|
+
fx = new PostFx(this.canvas.width, this.canvas.height, shader, postfx);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
error = `postfx: ${message(err)}`;
|
|
128
|
+
console.error("[ludra]", err);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const target = fx?.canvas ?? this.canvas;
|
|
132
|
+
if (target !== this.output) {
|
|
133
|
+
target.id = this.canvas.id;
|
|
134
|
+
target.className = this.canvas.className;
|
|
135
|
+
target.style.cssText = this.canvas.style.cssText;
|
|
136
|
+
this.output.replaceWith(target);
|
|
137
|
+
this.output = target;
|
|
138
|
+
}
|
|
139
|
+
this.fx = fx;
|
|
140
|
+
return error;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Shows the frame the game just drew, through the shader if there is one. */
|
|
144
|
+
present(time: number): void {
|
|
145
|
+
this.fx?.render(this.canvas, time);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** What a scene needs before it can be drawn. */
|
|
150
|
+
async function loadScene(scene: SceneDef, options: GameOptions, scripts: Map<string, Script>) {
|
|
105
151
|
const assetBase = options.assetBase ?? "";
|
|
106
|
-
const
|
|
107
|
-
const [images, scripts, shader] = await Promise.all([
|
|
152
|
+
const [images, more, shader] = await Promise.all([
|
|
108
153
|
loadImages(scene, assetBase),
|
|
109
154
|
loadScripts(scene, options),
|
|
110
|
-
|
|
111
|
-
? fetch(assetBase + shaderPath).then(async (r) => (r.ok ? r.text() : new Error(`could not load ${shaderPath} (${r.status})`)))
|
|
112
|
-
: undefined,
|
|
155
|
+
loadShader(scene, assetBase),
|
|
113
156
|
loadFonts(scene, assetBase),
|
|
114
157
|
]);
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
try {
|
|
118
|
-
if (shader instanceof Error) throw shader;
|
|
119
|
-
const fx = new PostFx(canvas.width, canvas.height, shader, scene.postfx);
|
|
120
|
-
fx.canvas.id = canvas.id;
|
|
121
|
-
fx.canvas.className = canvas.className;
|
|
122
|
-
fx.canvas.style.cssText = canvas.style.cssText;
|
|
123
|
-
canvas.replaceWith(fx.canvas);
|
|
124
|
-
stage.output = fx.canvas;
|
|
125
|
-
stage.present = (time) => fx.render(canvas, time);
|
|
126
|
-
} catch (err) {
|
|
127
|
-
stage.error = `postfx: ${message(err)}`;
|
|
128
|
-
console.error("[ludra]", err);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
return stage;
|
|
158
|
+
for (const [path, script] of more) scripts.set(path, script);
|
|
159
|
+
return { images, shader };
|
|
132
160
|
}
|
|
133
161
|
|
|
134
162
|
function message(err: unknown): string {
|
|
135
163
|
return err instanceof Error ? err.message : String(err);
|
|
136
164
|
}
|
|
137
165
|
|
|
166
|
+
/** Seconds the win screen shows before the next scene, and the fade into a world.goto scene. */
|
|
167
|
+
const WIN_PAUSE = 1.6;
|
|
168
|
+
const GOTO_FADE = 0.35;
|
|
169
|
+
|
|
138
170
|
/**
|
|
139
171
|
* Runs a scene in a canvas with keyboard controls, images, particles and
|
|
140
172
|
* sound. The simulation uses the same fixed step as headless runs, so what
|
|
141
173
|
* tests verify is what players get; everything added here is presentation.
|
|
174
|
+
* With `options.loadScene`, won goals with `next` and `world.goto` move on
|
|
175
|
+
* to other scenes, keeping score, deaths and lives.
|
|
142
176
|
* Resolves (after images load) to a function that stops the game.
|
|
143
177
|
*/
|
|
144
|
-
export async function startGame(canvas: HTMLCanvasElement,
|
|
145
|
-
const
|
|
178
|
+
export async function startGame(canvas: HTMLCanvasElement, firstScene: SceneDef, options: GameOptions = {}): Promise<() => void> {
|
|
179
|
+
const screen = new Screen(canvas);
|
|
180
|
+
const ctx = screen.ctx;
|
|
146
181
|
const sounds = new SoundPlayer(options.assetBase ?? "");
|
|
147
|
-
const
|
|
182
|
+
const scripts = new Map<string, Script>();
|
|
148
183
|
|
|
149
|
-
|
|
150
|
-
let
|
|
184
|
+
type Carry = { score: number; deaths: number; lives: number | undefined };
|
|
185
|
+
let scene = firstScene;
|
|
186
|
+
let startCarry: Carry | undefined;
|
|
187
|
+
let view: View;
|
|
188
|
+
let world: World;
|
|
189
|
+
let crash: string | undefined;
|
|
190
|
+
let sceneError: string | undefined;
|
|
191
|
+
/** Real time when the world asked to move on, while waiting to load the next scene. */
|
|
192
|
+
let exitSince: number | undefined;
|
|
193
|
+
let loading = false;
|
|
194
|
+
|
|
195
|
+
const enter = async (next: SceneDef, carry: Carry | undefined) => {
|
|
196
|
+
const { images, shader } = await loadScene(next, options, scripts);
|
|
197
|
+
scene = next;
|
|
198
|
+
startCarry = carry;
|
|
199
|
+
view = new View(next, images);
|
|
200
|
+
sceneError = screen.use(next.postfx, shader);
|
|
201
|
+
crash = sceneError;
|
|
202
|
+
exitSince = undefined;
|
|
203
|
+
try {
|
|
204
|
+
world = new World(next, { scripts, carry });
|
|
205
|
+
} catch (err) {
|
|
206
|
+
crash = message(err);
|
|
207
|
+
world = new World({ ...next, entities: [] });
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
await enter(firstScene, undefined);
|
|
211
|
+
|
|
212
|
+
// Game over starts the whole game again; otherwise R restarts the current scene as it began.
|
|
151
213
|
const restart = () => {
|
|
152
|
-
|
|
214
|
+
if (loading) return;
|
|
215
|
+
if (world.lost && scene !== firstScene) {
|
|
216
|
+
loading = true;
|
|
217
|
+
void enter(firstScene, undefined).finally(() => (loading = false));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
world = new World(scene, { scripts, carry: startCarry });
|
|
153
221
|
view.reset();
|
|
154
|
-
crash =
|
|
222
|
+
crash = sceneError;
|
|
223
|
+
exitSince = undefined;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const moveOn = (path: string) => {
|
|
227
|
+
if (!options.loadScene) return;
|
|
228
|
+
loading = true;
|
|
229
|
+
const carry = { score: world.score, deaths: world.deaths, lives: world.lives };
|
|
230
|
+
options
|
|
231
|
+
.loadScene(path)
|
|
232
|
+
.then((next) => enter(next, carry))
|
|
233
|
+
.catch((err: unknown) => {
|
|
234
|
+
crash = `Could not load the next scene "${path}": ${message(err)}`;
|
|
235
|
+
})
|
|
236
|
+
.finally(() => (loading = false));
|
|
155
237
|
};
|
|
156
238
|
|
|
157
239
|
const held = new Set<Action>();
|
|
@@ -185,7 +267,7 @@ export async function startGame(canvas: HTMLCanvasElement, scene: SceneDef, opti
|
|
|
185
267
|
const frame = (now: number) => {
|
|
186
268
|
accumulator += Math.min(MAX_FRAME_TIME, (now - last) / 1000);
|
|
187
269
|
last = now;
|
|
188
|
-
while (accumulator >= FIXED_DT && !crash) {
|
|
270
|
+
while (accumulator >= FIXED_DT && !crash && !loading) {
|
|
189
271
|
try {
|
|
190
272
|
world.step(tapped.size > 0 ? new Set([...held, ...tapped]) : held);
|
|
191
273
|
} catch (err) {
|
|
@@ -197,15 +279,30 @@ export async function startGame(canvas: HTMLCanvasElement, scene: SceneDef, opti
|
|
|
197
279
|
view.step(world, (sound) => sounds.play(sound));
|
|
198
280
|
accumulator -= FIXED_DT;
|
|
199
281
|
}
|
|
282
|
+
|
|
283
|
+
// Moving on: let the win screen show, or fade out for a world.goto, then load.
|
|
284
|
+
let fade = 0;
|
|
285
|
+
if (world.exit !== undefined && options.loadScene && !crash) {
|
|
286
|
+
exitSince ??= now;
|
|
287
|
+
const pause = world.won ? WIN_PAUSE : GOTO_FADE;
|
|
288
|
+
const elapsed = (now - exitSince) / 1000;
|
|
289
|
+
fade = Math.min(1, Math.max(0, (elapsed - (pause - GOTO_FADE)) / GOTO_FADE));
|
|
290
|
+
if (elapsed >= pause && !loading) moveOn(world.exit);
|
|
291
|
+
}
|
|
292
|
+
|
|
200
293
|
try {
|
|
201
|
-
view.draw(ctx, world, sounds.muted);
|
|
294
|
+
view.draw(ctx, world, sounds.muted, options.loadScene !== undefined);
|
|
202
295
|
} catch (err) {
|
|
203
296
|
// A draw hook threw. Same treatment as a simulation error.
|
|
204
297
|
if (!crash) console.error("[ludra]", err);
|
|
205
298
|
crash ??= message(err);
|
|
206
299
|
}
|
|
300
|
+
if (fade > 0 || loading) {
|
|
301
|
+
ctx.fillStyle = `rgba(0, 0, 0, ${loading ? 1 : fade})`;
|
|
302
|
+
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
|
303
|
+
}
|
|
207
304
|
if (crash) drawCrash(ctx, crash);
|
|
208
|
-
present(world.time);
|
|
305
|
+
screen.present(world.time);
|
|
209
306
|
raf = requestAnimationFrame(frame);
|
|
210
307
|
};
|
|
211
308
|
raf = requestAnimationFrame(frame);
|
|
@@ -239,7 +336,10 @@ export interface CapturedFrame {
|
|
|
239
336
|
* `ludra play --screenshot`.
|
|
240
337
|
*/
|
|
241
338
|
export async function captureFrames(canvas: HTMLCanvasElement, scene: SceneDef, options: CaptureOptions): Promise<CapturedFrame[]> {
|
|
242
|
-
const
|
|
339
|
+
const screen = new Screen(canvas);
|
|
340
|
+
const scripts = new Map<string, Script>();
|
|
341
|
+
const { images, shader } = await loadScene(scene, options, scripts);
|
|
342
|
+
const error = screen.use(scene.postfx, shader);
|
|
243
343
|
if (error) throw new Error(error);
|
|
244
344
|
const view = new View(scene, images);
|
|
245
345
|
const world = new World(scene, { scripts });
|
|
@@ -250,9 +350,9 @@ export async function captureFrames(canvas: HTMLCanvasElement, scene: SceneDef,
|
|
|
250
350
|
|
|
251
351
|
for (let frame = 0; ; frame++) {
|
|
252
352
|
if (wanted.has(frame)) {
|
|
253
|
-
view.draw(ctx, world, false);
|
|
254
|
-
present(world.time);
|
|
255
|
-
captured.push({ frame, time: frame * FIXED_DT, png: output.toDataURL("image/png") });
|
|
353
|
+
view.draw(screen.ctx, world, false, false);
|
|
354
|
+
screen.present(world.time);
|
|
355
|
+
captured.push({ frame, time: frame * FIXED_DT, png: screen.output.toDataURL("image/png") });
|
|
256
356
|
}
|
|
257
357
|
if (frame >= last) break;
|
|
258
358
|
world.step(script.at(frame));
|
|
@@ -322,9 +422,15 @@ class View {
|
|
|
322
422
|
private readonly scene: SceneDef;
|
|
323
423
|
private readonly images: Map<string, HTMLImageElement>;
|
|
324
424
|
|
|
425
|
+
/** Whether the scene can award points at all, so the default HUD can leave the score out. */
|
|
426
|
+
private readonly scoring: boolean;
|
|
427
|
+
|
|
325
428
|
constructor(scene: SceneDef, images: Map<string, HTMLImageElement>) {
|
|
326
429
|
this.scene = scene;
|
|
327
430
|
this.images = images;
|
|
431
|
+
this.scoring = scene.entities.some(
|
|
432
|
+
(e) => e.components.collectible || (e.components.hazard && Number(e.components.hazard.score) > 0) || Number(e.components.goal?.requireScore) > 0,
|
|
433
|
+
);
|
|
328
434
|
}
|
|
329
435
|
|
|
330
436
|
reset(): void {
|
|
@@ -362,7 +468,8 @@ class View {
|
|
|
362
468
|
this.shake = this.shake < 0.5 ? 0 : this.shake * SHAKE_DECAY;
|
|
363
469
|
}
|
|
364
470
|
|
|
365
|
-
|
|
471
|
+
/** `continues`: the host loads next scenes, so a won scene with `next` says so instead of offering a replay. */
|
|
472
|
+
draw(ctx: CanvasRenderingContext2D, world: World, muted: boolean, continues: boolean): void {
|
|
366
473
|
const { width: viewWidth, height: viewHeight } = ctx.canvas;
|
|
367
474
|
ctx.imageSmoothingEnabled = false;
|
|
368
475
|
ctx.fillStyle = world.background;
|
|
@@ -398,25 +505,32 @@ class View {
|
|
|
398
505
|
return;
|
|
399
506
|
}
|
|
400
507
|
|
|
508
|
+
// Only what this scene uses: no "Score 0" in a game without points.
|
|
509
|
+
const parts: string[] = [];
|
|
510
|
+
if (world.lives !== undefined) parts.push(`${"♥".repeat(Math.max(0, Math.min(world.lives, 10)))}${world.lives > 10 ? ` ${world.lives}` : ""}`);
|
|
511
|
+
if (this.scoring || world.score !== 0) parts.push(`Score ${world.score}`);
|
|
512
|
+
if (world.lives === undefined) parts.push(`Deaths ${world.deaths}`);
|
|
401
513
|
ctx.textAlign = "left";
|
|
402
514
|
ctx.font = "600 18px system-ui, sans-serif";
|
|
403
515
|
ctx.fillStyle = "#ffffff";
|
|
404
|
-
ctx.fillText(
|
|
516
|
+
ctx.fillText(parts.join(" "), 16, 30);
|
|
405
517
|
ctx.textAlign = "right";
|
|
406
518
|
ctx.font = "500 13px system-ui, sans-serif";
|
|
407
519
|
ctx.fillStyle = "rgba(255, 255, 255, 0.6)";
|
|
408
520
|
ctx.fillText(`M: sound ${muted ? "off" : "on"} · R: restart`, viewWidth - 16, 28);
|
|
409
521
|
|
|
410
|
-
if (world.won) {
|
|
522
|
+
if (world.won || world.lost) {
|
|
411
523
|
ctx.fillStyle = "rgba(0, 0, 0, 0.55)";
|
|
412
524
|
ctx.fillRect(0, 0, viewWidth, viewHeight);
|
|
413
525
|
ctx.fillStyle = "#ffffff";
|
|
414
526
|
ctx.textAlign = "center";
|
|
415
527
|
ctx.font = "700 44px system-ui, sans-serif";
|
|
416
|
-
ctx.fillText("Level complete!", viewWidth / 2, viewHeight / 2 - 10);
|
|
528
|
+
ctx.fillText(world.won ? "Level complete!" : "Game over", viewWidth / 2, viewHeight / 2 - 10);
|
|
417
529
|
ctx.font = "500 18px system-ui, sans-serif";
|
|
418
|
-
const
|
|
419
|
-
|
|
530
|
+
const endTime = world.events.find((e) => e.type === "win" || e.type === "gameover")?.time ?? world.time;
|
|
531
|
+
const next = world.won && world.exit !== undefined && continues ? "Get ready…" : `Press R to ${world.won ? "play again" : "try again"}`;
|
|
532
|
+
const score = this.scoring || world.score !== 0 ? `Score ${world.score} · ` : "";
|
|
533
|
+
ctx.fillText(`${score}${endTime.toFixed(1)}s · ${next}`, viewWidth / 2, viewHeight / 2 + 30);
|
|
420
534
|
}
|
|
421
535
|
}
|
|
422
536
|
|
|
@@ -524,7 +638,10 @@ function recentEvents(world: World, name: string | undefined, seconds: number):
|
|
|
524
638
|
if (event.time < since) break;
|
|
525
639
|
const eventName = event.type === "custom" ? event.name : event.type;
|
|
526
640
|
if (name !== undefined && eventName !== name) continue;
|
|
527
|
-
|
|
641
|
+
// Custom events carry what the script passed; built-in ones their own details (item, enemy, checkpoint...).
|
|
642
|
+
const { type: _type, frame: _frame, time, x, y, entity, ...details } = event as GameEvent & { data?: unknown };
|
|
643
|
+
const data = event.type === "custom" ? event.data : details;
|
|
644
|
+
result.push({ name: eventName, time, x, y, entity, data });
|
|
528
645
|
}
|
|
529
646
|
return result.reverse();
|
|
530
647
|
}
|
package/src/components.ts
CHANGED
|
@@ -25,6 +25,7 @@ export const COMPONENT_NAMES = [
|
|
|
25
25
|
"patrol",
|
|
26
26
|
"hazard",
|
|
27
27
|
"goal",
|
|
28
|
+
"checkpoint",
|
|
28
29
|
"emitter",
|
|
29
30
|
"script",
|
|
30
31
|
"camera",
|
|
@@ -155,8 +156,19 @@ export const COMPONENTS: Record<ComponentName, ComponentSpec> = {
|
|
|
155
156
|
requires: ["transform", "body"],
|
|
156
157
|
fields: {
|
|
157
158
|
requireScore: { type: "number", min: 0, default: 0, description: "Score needed before the goal accepts the player." },
|
|
159
|
+
next: {
|
|
160
|
+
type: "string",
|
|
161
|
+
default: "",
|
|
162
|
+
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.',
|
|
163
|
+
},
|
|
158
164
|
},
|
|
159
165
|
},
|
|
166
|
+
checkpoint: {
|
|
167
|
+
description:
|
|
168
|
+
"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.",
|
|
169
|
+
requires: ["transform", "body"],
|
|
170
|
+
fields: {},
|
|
171
|
+
},
|
|
160
172
|
emitter: {
|
|
161
173
|
description: "Continuously emits visual particles from the entity (torches, sparkles, smoke). It does not affect gameplay.",
|
|
162
174
|
requires: ["transform"],
|
|
@@ -268,6 +280,7 @@ export interface Hazard {
|
|
|
268
280
|
|
|
269
281
|
export interface Goal {
|
|
270
282
|
requireScore: number;
|
|
283
|
+
next: string;
|
|
271
284
|
}
|
|
272
285
|
|
|
273
286
|
export interface Camera {
|
package/src/effects.ts
CHANGED
|
@@ -19,7 +19,7 @@ export const EFFECT_FIELDS: Record<string, FieldSpec> = {
|
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
export const EFFECTS_DESCRIPTION =
|
|
22
|
-
'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.';
|
|
22
|
+
'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.';
|
|
23
23
|
|
|
24
24
|
export function isSoundPreset(sound: string): sound is SoundPreset {
|
|
25
25
|
return (SOUND_PRESETS as readonly string[]).includes(sound);
|
|
@@ -30,7 +30,7 @@ export interface AssetReference {
|
|
|
30
30
|
path: string;
|
|
31
31
|
/** JSON path of the field that references it. */
|
|
32
32
|
at: string;
|
|
33
|
-
kind: "image" | "audio" | "script" | "font" | "shader";
|
|
33
|
+
kind: "image" | "audio" | "script" | "font" | "shader" | "scene";
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/** Every file a scene points at, so tools can check that they exist. */
|
|
@@ -39,6 +39,8 @@ export function collectAssetPaths(scene: SceneDef): AssetReference[] {
|
|
|
39
39
|
scene.entities.forEach((entity, i) => {
|
|
40
40
|
const image = entity.components.sprite?.image;
|
|
41
41
|
if (typeof image === "string" && image !== "") refs.push({ path: image, at: `entities[${i}].components.sprite.image`, kind: "image" });
|
|
42
|
+
const next = entity.components.goal?.next;
|
|
43
|
+
if (typeof next === "string" && next !== "") refs.push({ path: next, at: `entities[${i}].components.goal.next`, kind: "scene" });
|
|
42
44
|
const src = entity.components.script?.src;
|
|
43
45
|
if (typeof src === "string" && src !== "") refs.push({ path: src, at: `entities[${i}].components.script.src`, kind: "script" });
|
|
44
46
|
// Images named in script params are preloaded for draw hooks (ctx.image).
|
package/src/script.ts
CHANGED
|
@@ -30,7 +30,9 @@ export interface ScriptEntity {
|
|
|
30
30
|
export interface SpawnDef {
|
|
31
31
|
/** Generated when left out. */
|
|
32
32
|
id?: string;
|
|
33
|
-
components
|
|
33
|
+
/** Start from a prefab; `components` then only needs what differs (usually the transform). */
|
|
34
|
+
prefab?: string;
|
|
35
|
+
components?: Record<string, Record<string, unknown>>;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
export interface ScriptWorld {
|
|
@@ -43,6 +45,12 @@ export interface ScriptWorld {
|
|
|
43
45
|
readonly deaths: number;
|
|
44
46
|
/** True once the level is complete. */
|
|
45
47
|
readonly won: boolean;
|
|
48
|
+
/** Lives left, or undefined when the scene has unlimited retries. Writable: an extra life is `world.lives += 1`. */
|
|
49
|
+
lives: number | undefined;
|
|
50
|
+
/** True once the game is over (out of lives, or world.lose()). */
|
|
51
|
+
readonly lost: boolean;
|
|
52
|
+
/** The scene this one hands over to (a won goal's next, or world.goto), once it does. */
|
|
53
|
+
readonly exit: string | undefined;
|
|
46
54
|
get(id: string): ScriptEntity | undefined;
|
|
47
55
|
/** Every entity, or only those with the given component. */
|
|
48
56
|
all(component?: string): ScriptEntity[];
|
|
@@ -54,6 +62,14 @@ export interface ScriptWorld {
|
|
|
54
62
|
kill(entity: ScriptEntity | string): void;
|
|
55
63
|
/** Completes the level, like reaching a goal. */
|
|
56
64
|
win(): void;
|
|
65
|
+
/** Ends the game as lost (a time limit ran out, the base was destroyed...). Fires "gameover". */
|
|
66
|
+
lose(): void;
|
|
67
|
+
/**
|
|
68
|
+
* Moves on to another scene, e.g. world.goto("scenes/level-1.scene.json") from a title
|
|
69
|
+
* screen. The current scene stops; the browser loads the next one, keeping score,
|
|
70
|
+
* deaths and lives. Fires "goto" (tests can assert events.goto).
|
|
71
|
+
*/
|
|
72
|
+
goto(scene: string): void;
|
|
57
73
|
/** Fires a custom event that tests can count (`events.<name>`) and `effects` can react to. */
|
|
58
74
|
emit(name: string, data?: unknown): void;
|
|
59
75
|
/** A number in [0, 1). Seeded by `world.seed`, so runs stay deterministic. Never use Math.random. */
|
|
@@ -86,6 +102,7 @@ export interface DrawEvent {
|
|
|
86
102
|
y: number;
|
|
87
103
|
/** The entity that caused it. */
|
|
88
104
|
entity: string;
|
|
105
|
+
/** Custom events: what the script passed to world.emit. Built-in ones: their details, e.g. { checkpoint: "lantern" } or { item: "coin-1", score: 1 }. */
|
|
89
106
|
data?: unknown;
|
|
90
107
|
}
|
|
91
108
|
|
|
@@ -167,11 +184,12 @@ export const SCRIPT_API_REFERENCE = `A script file default-exports hooks. Only "
|
|
|
167
184
|
ctx.self the entity: id, x, y, vx, vy (read/write); grounded, width, height, exists (read);
|
|
168
185
|
state (object kept between frames, testable as "<id>.state.<key>");
|
|
169
186
|
has("component"), touches(otherOrId)
|
|
170
|
-
ctx.world time, frame, width, height, deaths, won (read); score (read/write)
|
|
187
|
+
ctx.world time, frame, width, height, deaths, won, lost, exit (read); score, lives (read/write)
|
|
171
188
|
get(id), all(component?), player()
|
|
172
|
-
spawn({ id?, components }) -> entity destroy(entityOrId)
|
|
189
|
+
spawn({ id?, prefab?, components? }) -> entity destroy(entityOrId)
|
|
173
190
|
kill(entityOrId) players respawn, others are destroyed
|
|
174
|
-
win() completes the level
|
|
191
|
+
win() completes the level lose() game over
|
|
192
|
+
goto(scenePath) moves on to another scene (title -> level 1, a door to the next room)
|
|
175
193
|
emit(name, data?) custom event: counted by "events.<name>" in tests, usable as an effects key
|
|
176
194
|
random() deterministic, seeded by world.seed. Never use Math.random or Date
|
|
177
195
|
ctx.input held(action), pressed(action) actions: left right up down jump action
|
package/src/types.ts
CHANGED
|
@@ -2,7 +2,7 @@ export type Action = "left" | "right" | "up" | "down" | "jump" | "action";
|
|
|
2
2
|
|
|
3
3
|
export const ACTIONS: readonly Action[] = ["left", "right", "up", "down", "jump", "action"];
|
|
4
4
|
|
|
5
|
-
export const EVENT_TYPES = ["jump", "land", "collect", "die", "stomp", "win", "bounce"] as const;
|
|
5
|
+
export const EVENT_TYPES = ["jump", "land", "collect", "die", "stomp", "win", "bounce", "checkpoint", "gameover", "goto"] as const;
|
|
6
6
|
export type GameEventType = (typeof EVENT_TYPES)[number];
|
|
7
7
|
|
|
8
8
|
/** What happens visually and audibly when a game event fires. See `EFFECT_FIELDS`. */
|
|
@@ -22,6 +22,8 @@ export interface PostFxDef {
|
|
|
22
22
|
/** An entity as written in a scene file. */
|
|
23
23
|
export interface EntityDef {
|
|
24
24
|
id: string;
|
|
25
|
+
/** Name of a prefab to start from. Gone after validation: its components are merged in. */
|
|
26
|
+
prefab?: string;
|
|
25
27
|
components: Record<string, Record<string, unknown>>;
|
|
26
28
|
}
|
|
27
29
|
|
|
@@ -34,6 +36,14 @@ export interface WorldSettings {
|
|
|
34
36
|
background?: string;
|
|
35
37
|
/** Seed for `world.random()` in scripts. Defaults to 1. */
|
|
36
38
|
seed?: number;
|
|
39
|
+
/** Lives before game over. Leave out for unlimited retries. */
|
|
40
|
+
lives?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A reusable entity template: `prefabs/<name>.prefab.json` or the scene's `prefabs` map. */
|
|
44
|
+
export interface PrefabDef {
|
|
45
|
+
description?: string;
|
|
46
|
+
components: Record<string, Record<string, unknown>>;
|
|
37
47
|
}
|
|
38
48
|
|
|
39
49
|
/** A scene file (`*.scene.json`). */
|
|
@@ -52,6 +62,12 @@ export interface SceneDef {
|
|
|
52
62
|
fonts?: Record<string, string>;
|
|
53
63
|
/** A GLSL fragment shader applied to every rendered frame. Presentation only. */
|
|
54
64
|
postfx?: PostFxDef;
|
|
65
|
+
/**
|
|
66
|
+
* Entity templates by name. Entities use one with `"prefab": "<name>"` and override
|
|
67
|
+
* components; scripts spawn them with `world.spawn({ prefab: "<name>" })`. The CLI
|
|
68
|
+
* adds every `prefabs/*.prefab.json` file here.
|
|
69
|
+
*/
|
|
70
|
+
prefabs?: Record<string, PrefabDef>;
|
|
55
71
|
entities: EntityDef[];
|
|
56
72
|
}
|
|
57
73
|
|
package/src/validate.ts
CHANGED
|
@@ -18,7 +18,7 @@ const REQUIRED_EXAMPLES: Partial<Record<ComponentName, string>> = {
|
|
|
18
18
|
* Checks a parsed scene file. Every diagnostic names the exact JSON path and,
|
|
19
19
|
* where possible, a fix an agent can apply without guessing.
|
|
20
20
|
*/
|
|
21
|
-
export function validateScene(
|
|
21
|
+
export function validateScene(input: unknown): ValidationResult {
|
|
22
22
|
const diagnostics: Diagnostic[] = [];
|
|
23
23
|
const error = (code: string, path: string, message: string, fix?: string) =>
|
|
24
24
|
diagnostics.push({ severity: "error", code, path, message, fix });
|
|
@@ -26,13 +26,17 @@ export function validateScene(raw: unknown): ValidationResult {
|
|
|
26
26
|
diagnostics.push({ severity: "warning", code, path, message, fix });
|
|
27
27
|
const result = (): ValidationResult => {
|
|
28
28
|
const ok = !diagnostics.some((d) => d.severity === "error");
|
|
29
|
-
return { ok, diagnostics, scene: ok ? (
|
|
29
|
+
return { ok, diagnostics, scene: ok ? (scene as SceneDef) : undefined };
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
let scene: unknown = input;
|
|
33
|
+
if (!isObject(input)) {
|
|
33
34
|
error("E_SCENE_NOT_OBJECT", "$", "A scene must be a JSON object.", 'Start from { "entities": [] }.');
|
|
34
35
|
return result();
|
|
35
36
|
}
|
|
37
|
+
// Prefabs become plain entities first, so everything below sees the final components.
|
|
38
|
+
const raw = expandPrefabs(input, error);
|
|
39
|
+
scene = raw;
|
|
36
40
|
|
|
37
41
|
if (raw.world !== undefined) {
|
|
38
42
|
if (!isObject(raw.world)) {
|
|
@@ -50,6 +54,10 @@ export function validateScene(raw: unknown): ValidationResult {
|
|
|
50
54
|
if (raw.world.background !== undefined && typeof raw.world.background !== "string") {
|
|
51
55
|
error("E_INVALID_WORLD", "world.background", `world.background must be a CSS color string, got ${describe(raw.world.background)}.`);
|
|
52
56
|
}
|
|
57
|
+
const lives = raw.world.lives;
|
|
58
|
+
if (lives !== undefined && (typeof lives !== "number" || !Number.isInteger(lives) || lives < 1)) {
|
|
59
|
+
error("E_INVALID_WORLD", "world.lives", `world.lives must be a whole number of at least 1, got ${describe(lives)}.`, "Leave it out for unlimited retries.");
|
|
60
|
+
}
|
|
53
61
|
}
|
|
54
62
|
}
|
|
55
63
|
|
|
@@ -237,8 +245,69 @@ const SENSOR_COMPONENTS: [ComponentName, string][] = [
|
|
|
237
245
|
["collectible", "players would bump into it instead of picking it up"],
|
|
238
246
|
["hazard", "players would stand on it instead of being hurt"],
|
|
239
247
|
["goal", "players would bump into it instead of reaching it"],
|
|
248
|
+
["checkpoint", "players would bump into it instead of passing through"],
|
|
240
249
|
];
|
|
241
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Replaces `"prefab": "<name>"` entities with the prefab's components, with the
|
|
253
|
+
* entity's own components merged over them (field by field; `null` removes a
|
|
254
|
+
* component). Returns a new scene object; reports unknown prefabs.
|
|
255
|
+
*/
|
|
256
|
+
function expandPrefabs(raw: Record<string, unknown>, error: Report): Record<string, unknown> {
|
|
257
|
+
const prefabs = raw.prefabs;
|
|
258
|
+
if (prefabs !== undefined) {
|
|
259
|
+
if (!isObject(prefabs)) {
|
|
260
|
+
error("E_PREFABS_NOT_OBJECT", "prefabs", '"prefabs" must map names to { "components": { ... } }.');
|
|
261
|
+
return raw;
|
|
262
|
+
}
|
|
263
|
+
for (const [name, prefab] of Object.entries(prefabs)) {
|
|
264
|
+
if (!isObject(prefab) || !isObject(prefab.components)) {
|
|
265
|
+
error("E_INVALID_PREFAB", `prefabs.${name}`, `Prefab "${name}" needs a "components" object.`, 'Use { "components": { "body": { ... }, "sprite": { ... } } }.');
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
for (const component of Object.keys(prefab.components)) {
|
|
269
|
+
if (!isComponentName(component)) {
|
|
270
|
+
const suggestion = closestName(component, COMPONENT_NAMES);
|
|
271
|
+
error("E_UNKNOWN_COMPONENT", `prefabs.${name}.components.${component}`, `Unknown component "${component}" in prefab "${name}".`, suggestion ? `Did you mean "${suggestion}"?` : undefined);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (!Array.isArray(raw.entities)) return raw;
|
|
277
|
+
const known = isObject(prefabs) ? prefabs : {};
|
|
278
|
+
const entities = raw.entities.map((entity: unknown, i: number) => {
|
|
279
|
+
if (!isObject(entity) || entity.prefab === undefined) return entity;
|
|
280
|
+
const name = entity.prefab;
|
|
281
|
+
const prefab = typeof name === "string" ? known[name] : undefined;
|
|
282
|
+
if (!isObject(prefab) || !isObject(prefab.components)) {
|
|
283
|
+
const names = Object.keys(known);
|
|
284
|
+
const suggestion = typeof name === "string" ? closestName(name, names) : undefined;
|
|
285
|
+
error(
|
|
286
|
+
"E_UNKNOWN_PREFAB",
|
|
287
|
+
`entities[${i}].prefab`,
|
|
288
|
+
`Prefab ${describe(name)} does not exist.`,
|
|
289
|
+
suggestion ? `Did you mean "${suggestion}"?` : names.length > 0 ? `Prefabs: ${names.join(", ")}.` : "Create prefabs/<name>.prefab.json with { \"components\": { ... } }.",
|
|
290
|
+
);
|
|
291
|
+
return entity;
|
|
292
|
+
}
|
|
293
|
+
const { prefab: _, ...rest } = entity;
|
|
294
|
+
return { ...rest, components: mergeComponents(prefab.components, isObject(entity.components) ? entity.components : {}) };
|
|
295
|
+
});
|
|
296
|
+
return { ...raw, entities };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** A prefab's components with overrides merged in, one level deep. `null` drops a component. */
|
|
300
|
+
export function mergeComponents(base: Record<string, unknown>, overrides: Record<string, unknown>): Record<string, Record<string, unknown>> {
|
|
301
|
+
const merged: Record<string, Record<string, unknown>> = {};
|
|
302
|
+
for (const [name, value] of Object.entries(base)) if (isObject(value)) merged[name] = structuredClone(value);
|
|
303
|
+
for (const [name, value] of Object.entries(overrides)) {
|
|
304
|
+
if (value === null) delete merged[name];
|
|
305
|
+
else if (isObject(value) && isObject(merged[name])) merged[name] = { ...merged[name], ...structuredClone(value) };
|
|
306
|
+
else merged[name] = structuredClone(value) as Record<string, unknown>;
|
|
307
|
+
}
|
|
308
|
+
return merged;
|
|
309
|
+
}
|
|
310
|
+
|
|
242
311
|
function numberOr(value: unknown, fallback: number): number {
|
|
243
312
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
244
313
|
}
|