@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/src/browser.ts
CHANGED
|
@@ -3,10 +3,11 @@ import { parseFrames, type Sprite } from "./components.ts";
|
|
|
3
3
|
import { collectAssetPaths } from "./effects.ts";
|
|
4
4
|
import { InputScript } from "./input.ts";
|
|
5
5
|
import { ParticleSystem } from "./particles.ts";
|
|
6
|
-
import
|
|
6
|
+
import { PostFx } from "./postfx.ts";
|
|
7
|
+
import type { DrawContext, DrawEvent, Script } from "./script.ts";
|
|
7
8
|
import { FIXED_DT } from "./time.ts";
|
|
8
|
-
import type { Action, SceneDef } from "./types.ts";
|
|
9
|
-
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";
|
|
10
11
|
|
|
11
12
|
const KEYMAP: Record<string, Action> = {
|
|
12
13
|
ArrowLeft: "left",
|
|
@@ -35,6 +36,11 @@ export interface GameOptions {
|
|
|
35
36
|
scriptBase?: string;
|
|
36
37
|
/** Scripts to load. Defaults to those the scene references; include any that `world.spawn` uses. */
|
|
37
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>;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
/** Imports the game's script modules, keyed by their path in the scene. */
|
|
@@ -52,26 +58,182 @@ async function loadScripts(scene: SceneDef, options: GameOptions): Promise<Map<s
|
|
|
52
58
|
return scripts;
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
/** Fonts load from Google Fonts or project files; a slow or offline font never blocks the game for long. */
|
|
62
|
+
async function loadFonts(scene: SceneDef, assetBase: string): Promise<void> {
|
|
63
|
+
const fonts = Object.entries(scene.fonts ?? {});
|
|
64
|
+
if (fonts.length === 0) return;
|
|
65
|
+
const load = async ([family, src]: [string, string]) => {
|
|
66
|
+
if (src === "google") {
|
|
67
|
+
const name = encodeURIComponent(family).replace(/%20/g, "+");
|
|
68
|
+
// Not every family has every weight, and Google rejects the request if one is missing.
|
|
69
|
+
for (const weights of [":wght@400;500;600;700;800;900", ":wght@400;700", ""]) {
|
|
70
|
+
const response = await fetch(`https://fonts.googleapis.com/css2?family=${name}${weights}&display=block`);
|
|
71
|
+
if (!response.ok) continue;
|
|
72
|
+
const style = document.createElement("style");
|
|
73
|
+
style.textContent = await response.text();
|
|
74
|
+
document.head.append(style);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
await Promise.all(["400", "700", "900"].map((weight) => document.fonts.load(`${weight} 16px "${family}"`)));
|
|
78
|
+
} else {
|
|
79
|
+
const face = new FontFace(family, `url("${assetBase}${src}")`);
|
|
80
|
+
// FontFaceSet.add is missing from some TypeScript DOM typings.
|
|
81
|
+
(document.fonts as unknown as Set<FontFace>).add(await face.load());
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const all = Promise.all(
|
|
85
|
+
fonts.map((font) => load(font).catch((err) => console.warn(`[ludra] could not load font "${font[0]}": ${err}`))),
|
|
86
|
+
);
|
|
87
|
+
await Promise.race([all, new Promise((resolve) => setTimeout(resolve, 4000))]);
|
|
88
|
+
}
|
|
89
|
+
|
|
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})`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
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.
|
|
103
|
+
*/
|
|
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>) {
|
|
151
|
+
const assetBase = options.assetBase ?? "";
|
|
152
|
+
const [images, more, shader] = await Promise.all([
|
|
153
|
+
loadImages(scene, assetBase),
|
|
154
|
+
loadScripts(scene, options),
|
|
155
|
+
loadShader(scene, assetBase),
|
|
156
|
+
loadFonts(scene, assetBase),
|
|
157
|
+
]);
|
|
158
|
+
for (const [path, script] of more) scripts.set(path, script);
|
|
159
|
+
return { images, shader };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function message(err: unknown): string {
|
|
163
|
+
return err instanceof Error ? err.message : String(err);
|
|
164
|
+
}
|
|
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
|
+
|
|
55
170
|
/**
|
|
56
171
|
* Runs a scene in a canvas with keyboard controls, images, particles and
|
|
57
172
|
* sound. The simulation uses the same fixed step as headless runs, so what
|
|
58
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.
|
|
59
176
|
* Resolves (after images load) to a function that stops the game.
|
|
60
177
|
*/
|
|
61
|
-
export async function startGame(canvas: HTMLCanvasElement,
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
const sounds = new SoundPlayer(assetBase);
|
|
67
|
-
const view = new View(scene, images);
|
|
178
|
+
export async function startGame(canvas: HTMLCanvasElement, firstScene: SceneDef, options: GameOptions = {}): Promise<() => void> {
|
|
179
|
+
const screen = new Screen(canvas);
|
|
180
|
+
const ctx = screen.ctx;
|
|
181
|
+
const sounds = new SoundPlayer(options.assetBase ?? "");
|
|
182
|
+
const scripts = new Map<string, Script>();
|
|
68
183
|
|
|
69
|
-
|
|
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;
|
|
70
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.
|
|
71
213
|
const restart = () => {
|
|
72
|
-
|
|
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 });
|
|
73
221
|
view.reset();
|
|
74
|
-
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));
|
|
75
237
|
};
|
|
76
238
|
|
|
77
239
|
const held = new Set<Action>();
|
|
@@ -105,20 +267,42 @@ export async function startGame(canvas: HTMLCanvasElement, scene: SceneDef, opti
|
|
|
105
267
|
const frame = (now: number) => {
|
|
106
268
|
accumulator += Math.min(MAX_FRAME_TIME, (now - last) / 1000);
|
|
107
269
|
last = now;
|
|
108
|
-
while (accumulator >= FIXED_DT && !crash) {
|
|
270
|
+
while (accumulator >= FIXED_DT && !crash && !loading) {
|
|
109
271
|
try {
|
|
110
272
|
world.step(tapped.size > 0 ? new Set([...held, ...tapped]) : held);
|
|
111
273
|
} catch (err) {
|
|
112
274
|
// A script threw: freeze the game and show why, instead of failing silently.
|
|
113
|
-
crash =
|
|
275
|
+
crash = message(err);
|
|
114
276
|
console.error("[ludra]", err);
|
|
115
277
|
}
|
|
116
278
|
tapped.clear();
|
|
117
279
|
view.step(world, (sound) => sounds.play(sound));
|
|
118
280
|
accumulator -= FIXED_DT;
|
|
119
281
|
}
|
|
120
|
-
|
|
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
|
+
|
|
293
|
+
try {
|
|
294
|
+
view.draw(ctx, world, sounds.muted, options.loadScene !== undefined);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
// A draw hook threw. Same treatment as a simulation error.
|
|
297
|
+
if (!crash) console.error("[ludra]", err);
|
|
298
|
+
crash ??= message(err);
|
|
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
|
+
}
|
|
121
304
|
if (crash) drawCrash(ctx, crash);
|
|
305
|
+
screen.present(world.time);
|
|
122
306
|
raf = requestAnimationFrame(frame);
|
|
123
307
|
};
|
|
124
308
|
raf = requestAnimationFrame(frame);
|
|
@@ -152,9 +336,11 @@ export interface CapturedFrame {
|
|
|
152
336
|
* `ludra play --screenshot`.
|
|
153
337
|
*/
|
|
154
338
|
export async function captureFrames(canvas: HTMLCanvasElement, scene: SceneDef, options: CaptureOptions): Promise<CapturedFrame[]> {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
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);
|
|
343
|
+
if (error) throw new Error(error);
|
|
158
344
|
const view = new View(scene, images);
|
|
159
345
|
const world = new World(scene, { scripts });
|
|
160
346
|
const script = InputScript.parse(options.inputs ?? "");
|
|
@@ -164,8 +350,9 @@ export async function captureFrames(canvas: HTMLCanvasElement, scene: SceneDef,
|
|
|
164
350
|
|
|
165
351
|
for (let frame = 0; ; frame++) {
|
|
166
352
|
if (wanted.has(frame)) {
|
|
167
|
-
view.draw(ctx, world, false);
|
|
168
|
-
|
|
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") });
|
|
169
356
|
}
|
|
170
357
|
if (frame >= last) break;
|
|
171
358
|
world.step(script.at(frame));
|
|
@@ -228,20 +415,29 @@ class View {
|
|
|
228
415
|
private readonly facing = new Map<string, number>();
|
|
229
416
|
private readonly emitTimers = new Map<string, number>();
|
|
230
417
|
private readonly frameCache = new Map<string, number[]>();
|
|
418
|
+
/** ctx.local for each entity's draw hooks. */
|
|
419
|
+
private readonly local = new Map<string, Record<string, unknown>>();
|
|
231
420
|
private shake = 0;
|
|
232
421
|
private handledEvents = 0;
|
|
233
422
|
private readonly scene: SceneDef;
|
|
234
423
|
private readonly images: Map<string, HTMLImageElement>;
|
|
235
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
|
+
|
|
236
428
|
constructor(scene: SceneDef, images: Map<string, HTMLImageElement>) {
|
|
237
429
|
this.scene = scene;
|
|
238
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
|
+
);
|
|
239
434
|
}
|
|
240
435
|
|
|
241
436
|
reset(): void {
|
|
242
437
|
this.particles.clear();
|
|
243
438
|
this.facing.clear();
|
|
244
439
|
this.emitTimers.clear();
|
|
440
|
+
this.local.clear();
|
|
245
441
|
this.shake = 0;
|
|
246
442
|
this.handledEvents = 0;
|
|
247
443
|
}
|
|
@@ -272,13 +468,14 @@ class View {
|
|
|
272
468
|
this.shake = this.shake < 0.5 ? 0 : this.shake * SHAKE_DECAY;
|
|
273
469
|
}
|
|
274
470
|
|
|
275
|
-
|
|
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 {
|
|
276
473
|
const { width: viewWidth, height: viewHeight } = ctx.canvas;
|
|
277
474
|
ctx.imageSmoothingEnabled = false;
|
|
278
475
|
ctx.fillStyle = world.background;
|
|
279
476
|
ctx.fillRect(0, 0, viewWidth, viewHeight);
|
|
280
477
|
|
|
281
|
-
const target = world.entities.find((e) => e.camera) ?? world.entities.find((e) => e.platformer);
|
|
478
|
+
const target = world.entities.find((e) => e.camera) ?? world.entities.find((e) => e.platformer || e.topdown);
|
|
282
479
|
const zoom = target?.camera?.zoom ?? 1;
|
|
283
480
|
const visibleWidth = viewWidth / zoom;
|
|
284
481
|
const visibleHeight = viewHeight / zoom;
|
|
@@ -289,33 +486,75 @@ class View {
|
|
|
289
486
|
cameraY += (this.particles.random() - 0.5) * 2 * this.shake;
|
|
290
487
|
}
|
|
291
488
|
|
|
489
|
+
const viewInfo = { width: viewWidth, height: viewHeight, cameraX, cameraY, zoom };
|
|
292
490
|
ctx.save();
|
|
293
491
|
ctx.scale(zoom, zoom);
|
|
294
492
|
ctx.translate(-cameraX, -cameraY);
|
|
295
|
-
const
|
|
296
|
-
|
|
493
|
+
const layer = (e: Entity) => e.sprite?.layer ?? 0;
|
|
494
|
+
const visible = world.entities.filter((e) => e.sprite || e.script?.module.draw).sort((a, b) => layer(a) - layer(b));
|
|
495
|
+
for (const e of visible) {
|
|
496
|
+
if (e.sprite) this.drawSprite(ctx, e, world.time);
|
|
497
|
+
if (e.script?.module.draw) this.drawHook(ctx, world, e, "draw", viewInfo);
|
|
498
|
+
}
|
|
297
499
|
this.particles.draw(ctx);
|
|
298
500
|
ctx.restore();
|
|
299
501
|
|
|
502
|
+
if (world.hasHook("drawUI")) {
|
|
503
|
+
// The game draws its own interface.
|
|
504
|
+
for (const e of [...world.entities]) if (e.script?.module.drawUI) this.drawHook(ctx, world, e, "drawUI", viewInfo);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
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}`);
|
|
300
513
|
ctx.textAlign = "left";
|
|
301
514
|
ctx.font = "600 18px system-ui, sans-serif";
|
|
302
515
|
ctx.fillStyle = "#ffffff";
|
|
303
|
-
ctx.fillText(
|
|
516
|
+
ctx.fillText(parts.join(" "), 16, 30);
|
|
304
517
|
ctx.textAlign = "right";
|
|
305
518
|
ctx.font = "500 13px system-ui, sans-serif";
|
|
306
519
|
ctx.fillStyle = "rgba(255, 255, 255, 0.6)";
|
|
307
520
|
ctx.fillText(`M: sound ${muted ? "off" : "on"} · R: restart`, viewWidth - 16, 28);
|
|
308
521
|
|
|
309
|
-
if (world.won) {
|
|
522
|
+
if (world.won || world.lost) {
|
|
310
523
|
ctx.fillStyle = "rgba(0, 0, 0, 0.55)";
|
|
311
524
|
ctx.fillRect(0, 0, viewWidth, viewHeight);
|
|
312
525
|
ctx.fillStyle = "#ffffff";
|
|
313
526
|
ctx.textAlign = "center";
|
|
314
527
|
ctx.font = "700 44px system-ui, sans-serif";
|
|
315
|
-
ctx.fillText("Level complete!", viewWidth / 2, viewHeight / 2 - 10);
|
|
528
|
+
ctx.fillText(world.won ? "Level complete!" : "Game over", viewWidth / 2, viewHeight / 2 - 10);
|
|
316
529
|
ctx.font = "500 18px system-ui, sans-serif";
|
|
317
|
-
const
|
|
318
|
-
|
|
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);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private drawHook(ctx: CanvasRenderingContext2D, world: World, e: Entity, hook: "draw" | "drawUI", view: DrawContext["view"]): void {
|
|
538
|
+
let local = this.local.get(e.id);
|
|
539
|
+
if (!local) this.local.set(e.id, (local = {}));
|
|
540
|
+
ctx.save();
|
|
541
|
+
try {
|
|
542
|
+
world.callDrawHook(e, hook, {
|
|
543
|
+
g: ctx,
|
|
544
|
+
time: world.time,
|
|
545
|
+
local,
|
|
546
|
+
view,
|
|
547
|
+
recent: (name, seconds = 0.5) => recentEvents(world, name, seconds),
|
|
548
|
+
image: (path) => this.images.get(path),
|
|
549
|
+
burst: (x, y, particles = {}) => this.particles.burst(x, y, particles),
|
|
550
|
+
shake: (amount) => {
|
|
551
|
+
this.shake = Math.max(this.shake, amount);
|
|
552
|
+
},
|
|
553
|
+
random: () => this.particles.random(),
|
|
554
|
+
});
|
|
555
|
+
} finally {
|
|
556
|
+
// Whatever the hook changed (transform, alpha, blend mode, filter) ends with it.
|
|
557
|
+
ctx.restore();
|
|
319
558
|
}
|
|
320
559
|
}
|
|
321
560
|
|
|
@@ -390,6 +629,23 @@ class View {
|
|
|
390
629
|
|
|
391
630
|
type AnimationState = "idle" | "run" | "jump" | "fall";
|
|
392
631
|
|
|
632
|
+
function recentEvents(world: World, name: string | undefined, seconds: number): DrawEvent[] {
|
|
633
|
+
const since = world.time - seconds;
|
|
634
|
+
const result: DrawEvent[] = [];
|
|
635
|
+
// Events are in time order, so walk back until they get too old.
|
|
636
|
+
for (let i = world.events.length - 1; i >= 0; i--) {
|
|
637
|
+
const event = world.events[i]!;
|
|
638
|
+
if (event.time < since) break;
|
|
639
|
+
const eventName = event.type === "custom" ? event.name : event.type;
|
|
640
|
+
if (name !== undefined && eventName !== name) continue;
|
|
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 });
|
|
645
|
+
}
|
|
646
|
+
return result.reverse();
|
|
647
|
+
}
|
|
648
|
+
|
|
393
649
|
function animationState(e: Entity): AnimationState {
|
|
394
650
|
const b = e.body;
|
|
395
651
|
if (!b) return "idle";
|
package/src/components.ts
CHANGED
|
@@ -20,10 +20,12 @@ export const COMPONENT_NAMES = [
|
|
|
20
20
|
"body",
|
|
21
21
|
"sprite",
|
|
22
22
|
"platformer",
|
|
23
|
+
"topdown",
|
|
23
24
|
"collectible",
|
|
24
25
|
"patrol",
|
|
25
26
|
"hazard",
|
|
26
27
|
"goal",
|
|
28
|
+
"checkpoint",
|
|
27
29
|
"emitter",
|
|
28
30
|
"script",
|
|
29
31
|
"camera",
|
|
@@ -65,6 +67,12 @@ export const COMPONENTS: Record<ComponentName, ComponentSpec> = {
|
|
|
65
67
|
height: { type: "number", min: 1, description: "Collider height in pixels." },
|
|
66
68
|
sensor: { type: "boolean", default: false, description: "Sensors detect overlaps but never block movement (coins, triggers)." },
|
|
67
69
|
gravityScale: { type: "number", default: 1, description: "Multiplier on world gravity for dynamic bodies." },
|
|
70
|
+
bounce: {
|
|
71
|
+
type: "number",
|
|
72
|
+
min: 0,
|
|
73
|
+
default: 0,
|
|
74
|
+
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).",
|
|
75
|
+
},
|
|
68
76
|
vx: { type: "number", default: 0, description: "Initial horizontal velocity in px/s." },
|
|
69
77
|
vy: { type: "number", default: 0, description: "Initial vertical velocity in px/s." },
|
|
70
78
|
},
|
|
@@ -102,8 +110,23 @@ export const COMPONENTS: Record<ComponentName, ComponentSpec> = {
|
|
|
102
110
|
variableJump: { type: "boolean", default: true, description: "Releasing jump early cuts the jump short." },
|
|
103
111
|
},
|
|
104
112
|
},
|
|
113
|
+
topdown: {
|
|
114
|
+
description:
|
|
115
|
+
"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).",
|
|
116
|
+
requires: ["transform", "body"],
|
|
117
|
+
fields: {
|
|
118
|
+
speed: { type: "number", min: 0, default: 200, description: "Move speed in px/s." },
|
|
119
|
+
directions: {
|
|
120
|
+
type: "enum",
|
|
121
|
+
values: ["8", "4", "x", "y"],
|
|
122
|
+
default: "8",
|
|
123
|
+
description: '"8": any direction (diagonals keep the same speed). "4": up/down/left/right only. "x" or "y": one axis.',
|
|
124
|
+
},
|
|
125
|
+
clampToWorld: { type: "boolean", default: true, description: "Keep the entity inside the world bounds." },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
105
128
|
collectible: {
|
|
106
|
-
description: "Removed when a platformer
|
|
129
|
+
description: "Removed when a player (platformer or topdown) touches it and adds to the score. Its body must be a sensor.",
|
|
107
130
|
requires: ["transform", "body"],
|
|
108
131
|
fields: {
|
|
109
132
|
score: { type: "number", default: 1, description: "Points added when collected." },
|
|
@@ -133,8 +156,19 @@ export const COMPONENTS: Record<ComponentName, ComponentSpec> = {
|
|
|
133
156
|
requires: ["transform", "body"],
|
|
134
157
|
fields: {
|
|
135
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
|
+
},
|
|
136
164
|
},
|
|
137
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
|
+
},
|
|
138
172
|
emitter: {
|
|
139
173
|
description: "Continuously emits visual particles from the entity (torches, sparkles, smoke). It does not affect gameplay.",
|
|
140
174
|
requires: ["transform"],
|
|
@@ -172,6 +206,7 @@ export interface Body {
|
|
|
172
206
|
height: number;
|
|
173
207
|
sensor: boolean;
|
|
174
208
|
gravityScale: number;
|
|
209
|
+
bounce: number;
|
|
175
210
|
vx: number;
|
|
176
211
|
vy: number;
|
|
177
212
|
}
|
|
@@ -222,6 +257,12 @@ export interface Platformer {
|
|
|
222
257
|
variableJump: boolean;
|
|
223
258
|
}
|
|
224
259
|
|
|
260
|
+
export interface Topdown {
|
|
261
|
+
speed: number;
|
|
262
|
+
directions: "8" | "4" | "x" | "y";
|
|
263
|
+
clampToWorld: boolean;
|
|
264
|
+
}
|
|
265
|
+
|
|
225
266
|
export interface Collectible {
|
|
226
267
|
score: number;
|
|
227
268
|
}
|
|
@@ -239,6 +280,7 @@ export interface Hazard {
|
|
|
239
280
|
|
|
240
281
|
export interface Goal {
|
|
241
282
|
requireScore: number;
|
|
283
|
+
next: string;
|
|
242
284
|
}
|
|
243
285
|
|
|
244
286
|
export interface Camera {
|
package/src/drawcheck.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { ScriptError, type ScriptModules } from "./script.ts";
|
|
2
|
+
import type { Diagnostic, SceneDef } from "./types.ts";
|
|
3
|
+
import { World } from "./world.ts";
|
|
4
|
+
|
|
5
|
+
/** Simulation frames at which `checkDrawHooks` paints: the start, and after things have moved. */
|
|
6
|
+
const CHECK_FRAMES = [0, 30, 120];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A stand-in for CanvasRenderingContext2D that accepts every call and property,
|
|
10
|
+
* so draw hooks can run outside a browser. It catches the hook's own mistakes
|
|
11
|
+
* (typos, undefined values, changing the game while drawing), not what it looks like.
|
|
12
|
+
*/
|
|
13
|
+
function mockCanvas(): CanvasRenderingContext2D {
|
|
14
|
+
const handler: ProxyHandler<() => unknown> = {
|
|
15
|
+
get: (_target, key) => (key === Symbol.toPrimitive ? () => 0 : key === "then" ? undefined : any),
|
|
16
|
+
set: () => true,
|
|
17
|
+
apply: () => any,
|
|
18
|
+
};
|
|
19
|
+
const any: unknown = new Proxy(() => undefined, handler);
|
|
20
|
+
return any as CanvasRenderingContext2D;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Runs every `draw` and `drawUI` hook against a mock canvas at a few points of
|
|
25
|
+
* an idle run, and reports hooks that throw. Used by `ludra check`.
|
|
26
|
+
*/
|
|
27
|
+
export function checkDrawHooks(scene: SceneDef, scripts: ScriptModules): Diagnostic[] {
|
|
28
|
+
let world: World;
|
|
29
|
+
try {
|
|
30
|
+
world = new World(scene, { scripts });
|
|
31
|
+
} catch {
|
|
32
|
+
return []; // Load problems are reported elsewhere.
|
|
33
|
+
}
|
|
34
|
+
const reported = new Set<string>();
|
|
35
|
+
const diagnostics: Diagnostic[] = [];
|
|
36
|
+
const g = mockCanvas();
|
|
37
|
+
const view = { width: world.width, height: world.height, cameraX: 0, cameraY: 0, zoom: 1 };
|
|
38
|
+
const locals = new Map<string, Record<string, unknown>>();
|
|
39
|
+
|
|
40
|
+
for (let frame = 0; frame <= CHECK_FRAMES.at(-1)!; frame++) {
|
|
41
|
+
if (CHECK_FRAMES.includes(frame)) {
|
|
42
|
+
for (const e of [...world.entities]) {
|
|
43
|
+
for (const hook of ["draw", "drawUI"] as const) {
|
|
44
|
+
if (!e.script?.module[hook]) continue;
|
|
45
|
+
if (!locals.has(e.id)) locals.set(e.id, {});
|
|
46
|
+
try {
|
|
47
|
+
world.callDrawHook(e, hook, {
|
|
48
|
+
g,
|
|
49
|
+
time: world.time,
|
|
50
|
+
local: locals.get(e.id)!,
|
|
51
|
+
view,
|
|
52
|
+
recent: () => [],
|
|
53
|
+
image: () => undefined,
|
|
54
|
+
burst: () => {},
|
|
55
|
+
shake: () => {},
|
|
56
|
+
random: () => 0.5,
|
|
57
|
+
});
|
|
58
|
+
} catch (err) {
|
|
59
|
+
const key = `${e.script.src}:${hook}`;
|
|
60
|
+
if (reported.has(key)) continue;
|
|
61
|
+
reported.add(key);
|
|
62
|
+
diagnostics.push({
|
|
63
|
+
severity: "error",
|
|
64
|
+
code: "E_DRAW_HOOK",
|
|
65
|
+
path: `${e.script.src} → ${hook}`,
|
|
66
|
+
message: err instanceof ScriptError ? err.message : String(err),
|
|
67
|
+
fix: "Draw hooks run every rendered frame in the browser. Fix the error; ctx.image() can return undefined, so check it before drawing.",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
world.step(new Set());
|
|
75
|
+
} catch {
|
|
76
|
+
break; // Simulation errors surface in play and test.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return diagnostics;
|
|
80
|
+
}
|
package/src/effects.ts
CHANGED
|
@@ -7,6 +7,7 @@ export type SoundPreset = (typeof SOUND_PRESETS)[number];
|
|
|
7
7
|
|
|
8
8
|
export const AUDIO_EXTENSIONS = [".wav", ".mp3", ".ogg"] as const;
|
|
9
9
|
export const IMAGE_EXTENSIONS = [".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp"] as const;
|
|
10
|
+
export const FONT_EXTENSIONS = [".woff2", ".woff", ".ttf", ".otf"] as const;
|
|
10
11
|
|
|
11
12
|
export const EFFECT_FIELDS: Record<string, FieldSpec> = {
|
|
12
13
|
sound: {
|
|
@@ -18,7 +19,7 @@ export const EFFECT_FIELDS: Record<string, FieldSpec> = {
|
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
export const EFFECTS_DESCRIPTION =
|
|
21
|
-
'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.';
|
|
22
23
|
|
|
23
24
|
export function isSoundPreset(sound: string): sound is SoundPreset {
|
|
24
25
|
return (SOUND_PRESETS as readonly string[]).includes(sound);
|
|
@@ -29,7 +30,7 @@ export interface AssetReference {
|
|
|
29
30
|
path: string;
|
|
30
31
|
/** JSON path of the field that references it. */
|
|
31
32
|
at: string;
|
|
32
|
-
kind: "image" | "audio" | "script";
|
|
33
|
+
kind: "image" | "audio" | "script" | "font" | "shader" | "scene";
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
/** Every file a scene points at, so tools can check that they exist. */
|
|
@@ -38,12 +39,27 @@ export function collectAssetPaths(scene: SceneDef): AssetReference[] {
|
|
|
38
39
|
scene.entities.forEach((entity, i) => {
|
|
39
40
|
const image = entity.components.sprite?.image;
|
|
40
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" });
|
|
41
44
|
const src = entity.components.script?.src;
|
|
42
45
|
if (typeof src === "string" && src !== "") refs.push({ path: src, at: `entities[${i}].components.script.src`, kind: "script" });
|
|
46
|
+
// Images named in script params are preloaded for draw hooks (ctx.image).
|
|
47
|
+
const params = entity.components.script?.params;
|
|
48
|
+
if (params && typeof params === "object") {
|
|
49
|
+
for (const [key, value] of Object.entries(params)) {
|
|
50
|
+
if (typeof value === "string" && IMAGE_EXTENSIONS.some((ext) => value.toLowerCase().endsWith(ext))) {
|
|
51
|
+
refs.push({ path: value, at: `entities[${i}].components.script.params.${key}`, kind: "image" });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
43
55
|
});
|
|
44
56
|
for (const [event, effect] of Object.entries(scene.effects ?? {})) {
|
|
45
57
|
const sound = effect?.sound;
|
|
46
58
|
if (typeof sound === "string" && sound !== "" && !isSoundPreset(sound)) refs.push({ path: sound, at: `effects.${event}.sound`, kind: "audio" });
|
|
47
59
|
}
|
|
60
|
+
for (const [family, src] of Object.entries(scene.fonts ?? {})) {
|
|
61
|
+
if (typeof src === "string" && src !== "google") refs.push({ path: src, at: `fonts.${family}`, kind: "font" });
|
|
62
|
+
}
|
|
63
|
+
if (typeof scene.postfx?.shader === "string" && scene.postfx.shader !== "") refs.push({ path: scene.postfx.shader, at: "postfx.shader", kind: "shader" });
|
|
48
64
|
return refs;
|
|
49
65
|
}
|