@ludra/core 0.3.1 → 0.5.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 +15 -7
- package/dist/assertions.js +60 -23
- package/dist/browser.d.ts +23 -0
- package/dist/browser.js +92 -6
- package/dist/components.d.ts +11 -1
- package/dist/components.js +9 -0
- package/dist/effects.d.ts +1 -1
- package/dist/effects.js +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/script.d.ts +108 -0
- package/dist/script.js +87 -0
- package/dist/types.d.ts +7 -2
- package/dist/validate.d.ts +2 -0
- package/dist/validate.js +19 -7
- package/dist/world.d.ts +59 -6
- package/dist/world.js +278 -11
- package/package.json +1 -1
- package/src/assertions.ts +68 -29
- package/src/browser.ts +104 -6
- package/src/components.ts +18 -1
- package/src/effects.ts +3 -1
- package/src/index.ts +1 -0
- package/src/script.ts +184 -0
- package/src/types.ts +7 -2
- package/src/validate.ts +31 -7
- package/src/world.ts +306 -13
package/src/browser.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { SoundPlayer } from "./audio.ts";
|
|
2
2
|
import { parseFrames, type Sprite } from "./components.ts";
|
|
3
3
|
import { collectAssetPaths } from "./effects.ts";
|
|
4
|
+
import { InputScript } from "./input.ts";
|
|
4
5
|
import { ParticleSystem } from "./particles.ts";
|
|
6
|
+
import type { Script } from "./script.ts";
|
|
5
7
|
import { FIXED_DT } from "./time.ts";
|
|
6
8
|
import type { Action, SceneDef } from "./types.ts";
|
|
7
9
|
import { World, type Entity } from "./world.ts";
|
|
@@ -29,6 +31,25 @@ const SHAKE_DECAY = 0.88;
|
|
|
29
31
|
export interface GameOptions {
|
|
30
32
|
/** URL prefix for image and audio paths in the scene. Defaults to "". */
|
|
31
33
|
assetBase?: string;
|
|
34
|
+
/** URL prefix the scene's script paths are imported from. Defaults to `assetBase`. */
|
|
35
|
+
scriptBase?: string;
|
|
36
|
+
/** Scripts to load. Defaults to those the scene references; include any that `world.spawn` uses. */
|
|
37
|
+
scriptPaths?: readonly string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Imports the game's script modules, keyed by their path in the scene. */
|
|
41
|
+
async function loadScripts(scene: SceneDef, options: GameOptions): Promise<Map<string, Script>> {
|
|
42
|
+
const base = options.scriptBase ?? options.assetBase ?? "";
|
|
43
|
+
const paths = options.scriptPaths ?? collectAssetPaths(scene).filter((ref) => ref.kind === "script").map((ref) => ref.path);
|
|
44
|
+
const scripts = new Map<string, Script>();
|
|
45
|
+
await Promise.all(
|
|
46
|
+
[...new Set(paths)].map(async (path) => {
|
|
47
|
+
const module = (await import(base + path)) as { default?: Script };
|
|
48
|
+
if (!module.default) throw new Error(`Script ${path} has no default export.`);
|
|
49
|
+
scripts.set(path, module.default);
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
return scripts;
|
|
32
53
|
}
|
|
33
54
|
|
|
34
55
|
/**
|
|
@@ -41,14 +62,16 @@ export async function startGame(canvas: HTMLCanvasElement, scene: SceneDef, opti
|
|
|
41
62
|
const ctx = canvas.getContext("2d");
|
|
42
63
|
if (!ctx) throw new Error("Canvas 2D context is not available.");
|
|
43
64
|
const assetBase = options.assetBase ?? "";
|
|
44
|
-
const images = await loadImages(scene, assetBase);
|
|
65
|
+
const [images, scripts] = await Promise.all([loadImages(scene, assetBase), loadScripts(scene, options)]);
|
|
45
66
|
const sounds = new SoundPlayer(assetBase);
|
|
46
67
|
const view = new View(scene, images);
|
|
47
68
|
|
|
48
|
-
let world = new World(scene);
|
|
69
|
+
let world = new World(scene, { scripts });
|
|
70
|
+
let crash: string | undefined;
|
|
49
71
|
const restart = () => {
|
|
50
|
-
world = new World(scene);
|
|
72
|
+
world = new World(scene, { scripts });
|
|
51
73
|
view.reset();
|
|
74
|
+
crash = undefined;
|
|
52
75
|
};
|
|
53
76
|
|
|
54
77
|
const held = new Set<Action>();
|
|
@@ -82,13 +105,20 @@ export async function startGame(canvas: HTMLCanvasElement, scene: SceneDef, opti
|
|
|
82
105
|
const frame = (now: number) => {
|
|
83
106
|
accumulator += Math.min(MAX_FRAME_TIME, (now - last) / 1000);
|
|
84
107
|
last = now;
|
|
85
|
-
while (accumulator >= FIXED_DT) {
|
|
86
|
-
|
|
108
|
+
while (accumulator >= FIXED_DT && !crash) {
|
|
109
|
+
try {
|
|
110
|
+
world.step(tapped.size > 0 ? new Set([...held, ...tapped]) : held);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
// A script threw: freeze the game and show why, instead of failing silently.
|
|
113
|
+
crash = err instanceof Error ? err.message : String(err);
|
|
114
|
+
console.error("[ludra]", err);
|
|
115
|
+
}
|
|
87
116
|
tapped.clear();
|
|
88
117
|
view.step(world, (sound) => sounds.play(sound));
|
|
89
118
|
accumulator -= FIXED_DT;
|
|
90
119
|
}
|
|
91
120
|
view.draw(ctx, world, sounds.muted);
|
|
121
|
+
if (crash) drawCrash(ctx, crash);
|
|
92
122
|
raf = requestAnimationFrame(frame);
|
|
93
123
|
};
|
|
94
124
|
raf = requestAnimationFrame(frame);
|
|
@@ -101,6 +131,74 @@ export async function startGame(canvas: HTMLCanvasElement, scene: SceneDef, opti
|
|
|
101
131
|
};
|
|
102
132
|
}
|
|
103
133
|
|
|
134
|
+
export interface CaptureOptions extends GameOptions {
|
|
135
|
+
/** Input script, e.g. `"right:1s, jump:0.4s"`. See `InputScript`. */
|
|
136
|
+
inputs?: string;
|
|
137
|
+
/** Simulation frames to capture (60 per second). Frame 0 is the starting state. */
|
|
138
|
+
frames: readonly number[];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface CapturedFrame {
|
|
142
|
+
frame: number;
|
|
143
|
+
time: number;
|
|
144
|
+
/** PNG as a data URL. */
|
|
145
|
+
png: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Plays a scene without a clock or keyboard and returns the requested frames
|
|
150
|
+
* as PNGs. Same renderer, effects and particles as `startGame`, stepped
|
|
151
|
+
* deterministically, so a frame always looks the same. Used by
|
|
152
|
+
* `ludra play --screenshot`.
|
|
153
|
+
*/
|
|
154
|
+
export async function captureFrames(canvas: HTMLCanvasElement, scene: SceneDef, options: CaptureOptions): Promise<CapturedFrame[]> {
|
|
155
|
+
const ctx = canvas.getContext("2d");
|
|
156
|
+
if (!ctx) throw new Error("Canvas 2D context is not available.");
|
|
157
|
+
const [images, scripts] = await Promise.all([loadImages(scene, options.assetBase ?? ""), loadScripts(scene, options)]);
|
|
158
|
+
const view = new View(scene, images);
|
|
159
|
+
const world = new World(scene, { scripts });
|
|
160
|
+
const script = InputScript.parse(options.inputs ?? "");
|
|
161
|
+
const wanted = new Set(options.frames);
|
|
162
|
+
const last = Math.max(0, ...options.frames);
|
|
163
|
+
const captured: CapturedFrame[] = [];
|
|
164
|
+
|
|
165
|
+
for (let frame = 0; ; frame++) {
|
|
166
|
+
if (wanted.has(frame)) {
|
|
167
|
+
view.draw(ctx, world, false);
|
|
168
|
+
captured.push({ frame, time: frame * FIXED_DT, png: canvas.toDataURL("image/png") });
|
|
169
|
+
}
|
|
170
|
+
if (frame >= last) break;
|
|
171
|
+
world.step(script.at(frame));
|
|
172
|
+
view.step(world, () => {});
|
|
173
|
+
}
|
|
174
|
+
return captured;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function drawCrash(ctx: CanvasRenderingContext2D, message: string): void {
|
|
178
|
+
const { width, height } = ctx.canvas;
|
|
179
|
+
ctx.fillStyle = "rgba(20, 0, 8, 0.85)";
|
|
180
|
+
ctx.fillRect(0, 0, width, height);
|
|
181
|
+
ctx.fillStyle = "#ff9b9b";
|
|
182
|
+
ctx.textAlign = "left";
|
|
183
|
+
ctx.font = "700 22px system-ui, sans-serif";
|
|
184
|
+
ctx.fillText("Script error", 32, 56);
|
|
185
|
+
ctx.font = "15px ui-monospace, monospace";
|
|
186
|
+
// Wrap the message to the canvas width.
|
|
187
|
+
let line = "";
|
|
188
|
+
let y = 92;
|
|
189
|
+
for (const word of message.split(" ")) {
|
|
190
|
+
if (ctx.measureText(line + word).width > width - 64) {
|
|
191
|
+
ctx.fillText(line, 32, y);
|
|
192
|
+
line = "";
|
|
193
|
+
y += 22;
|
|
194
|
+
}
|
|
195
|
+
line += `${word} `;
|
|
196
|
+
}
|
|
197
|
+
ctx.fillText(line, 32, y);
|
|
198
|
+
ctx.fillStyle = "rgba(255, 255, 255, 0.6)";
|
|
199
|
+
ctx.fillText("Fix the script; the page reloads on save. R restarts.", 32, y + 40);
|
|
200
|
+
}
|
|
201
|
+
|
|
104
202
|
async function loadImages(scene: SceneDef, assetBase: string): Promise<Map<string, HTMLImageElement>> {
|
|
105
203
|
const images = new Map<string, HTMLImageElement>();
|
|
106
204
|
const paths = new Set(collectAssetPaths(scene).filter((ref) => ref.kind === "image").map((ref) => ref.path));
|
|
@@ -151,7 +249,7 @@ class View {
|
|
|
151
249
|
/** Runs once per simulation step: turns new events into effects and advances particles. */
|
|
152
250
|
step(world: World, playSound: (sound: string) => void): void {
|
|
153
251
|
for (const event of world.events.slice(this.handledEvents)) {
|
|
154
|
-
const effect = this.scene.effects?.[event.type];
|
|
252
|
+
const effect = this.scene.effects?.[event.type === "custom" ? event.name : event.type];
|
|
155
253
|
if (!effect) continue;
|
|
156
254
|
if (effect.particles) this.particles.burst(event.x, event.y, effect.particles);
|
|
157
255
|
if (effect.sound) playSound(effect.sound);
|
package/src/components.ts
CHANGED
|
@@ -4,7 +4,9 @@ export type FieldSpec =
|
|
|
4
4
|
| { type: "boolean"; default?: boolean; description: string }
|
|
5
5
|
| { type: "enum"; values: readonly string[]; default?: string; description: string }
|
|
6
6
|
/** Spritesheet frame indices: "0", "1-4" or "0,2,4-6". Empty = not used. */
|
|
7
|
-
| { type: "frames"; default?: string; description: string }
|
|
7
|
+
| { type: "frames"; default?: string; description: string }
|
|
8
|
+
/** Any JSON object. */
|
|
9
|
+
| { type: "object"; default?: Record<string, unknown>; description: string };
|
|
8
10
|
|
|
9
11
|
export interface ComponentSpec {
|
|
10
12
|
description: string;
|
|
@@ -23,6 +25,7 @@ export const COMPONENT_NAMES = [
|
|
|
23
25
|
"hazard",
|
|
24
26
|
"goal",
|
|
25
27
|
"emitter",
|
|
28
|
+
"script",
|
|
26
29
|
"camera",
|
|
27
30
|
] as const;
|
|
28
31
|
export type ComponentName = (typeof COMPONENT_NAMES)[number];
|
|
@@ -140,6 +143,15 @@ export const COMPONENTS: Record<ComponentName, ComponentSpec> = {
|
|
|
140
143
|
...Object.fromEntries(Object.entries(PARTICLE_FIELDS).filter(([field]) => field !== "count")),
|
|
141
144
|
},
|
|
142
145
|
},
|
|
146
|
+
script: {
|
|
147
|
+
description:
|
|
148
|
+
"Custom behavior written in TypeScript or JavaScript: a file that default-exports hooks (start, update, touch). Use it for anything the built-in components don't do: chasing, doors and keys, timers, shooting, boss phases. Run `ludra components script` for the API.",
|
|
149
|
+
requires: ["transform"],
|
|
150
|
+
fields: {
|
|
151
|
+
src: { type: "string", description: 'Script path relative to the project, e.g. "scripts/chase.ts".' },
|
|
152
|
+
params: { type: "object", default: {}, description: "Values passed to the script as ctx.params, so one script can serve many entities." },
|
|
153
|
+
},
|
|
154
|
+
},
|
|
143
155
|
camera: {
|
|
144
156
|
description: "The browser camera follows this entity. Without it, the camera follows the first platformer.",
|
|
145
157
|
requires: ["transform"],
|
|
@@ -197,6 +209,11 @@ export interface Emitter extends Omit<ParticleSpec, "count"> {
|
|
|
197
209
|
rate: number;
|
|
198
210
|
}
|
|
199
211
|
|
|
212
|
+
export interface ScriptComponent {
|
|
213
|
+
src: string;
|
|
214
|
+
params: Record<string, unknown>;
|
|
215
|
+
}
|
|
216
|
+
|
|
200
217
|
export interface Platformer {
|
|
201
218
|
speed: number;
|
|
202
219
|
jumpSpeed: number;
|
package/src/effects.ts
CHANGED
|
@@ -29,7 +29,7 @@ export interface AssetReference {
|
|
|
29
29
|
path: string;
|
|
30
30
|
/** JSON path of the field that references it. */
|
|
31
31
|
at: string;
|
|
32
|
-
kind: "image" | "audio";
|
|
32
|
+
kind: "image" | "audio" | "script";
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/** Every file a scene points at, so tools can check that they exist. */
|
|
@@ -38,6 +38,8 @@ export function collectAssetPaths(scene: SceneDef): AssetReference[] {
|
|
|
38
38
|
scene.entities.forEach((entity, i) => {
|
|
39
39
|
const image = entity.components.sprite?.image;
|
|
40
40
|
if (typeof image === "string" && image !== "") refs.push({ path: image, at: `entities[${i}].components.sprite.image`, kind: "image" });
|
|
41
|
+
const src = entity.components.script?.src;
|
|
42
|
+
if (typeof src === "string" && src !== "") refs.push({ path: src, at: `entities[${i}].components.script.src`, kind: "script" });
|
|
41
43
|
});
|
|
42
44
|
for (const [event, effect] of Object.entries(scene.effects ?? {})) {
|
|
43
45
|
const sound = effect?.sound;
|
package/src/index.ts
CHANGED
package/src/script.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { Action, Diagnostic } from "./types.ts";
|
|
2
|
+
import { closestName } from "./validate.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A game object as scripts see it. Reading and writing `x`, `y`, `vx` and
|
|
6
|
+
* `vy` changes the entity directly.
|
|
7
|
+
*/
|
|
8
|
+
export interface ScriptEntity {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
/** Velocity in px/s. Always 0 for entities without a body. */
|
|
13
|
+
vx: number;
|
|
14
|
+
vy: number;
|
|
15
|
+
readonly grounded: boolean;
|
|
16
|
+
readonly width: number;
|
|
17
|
+
readonly height: number;
|
|
18
|
+
/** False once the entity has been destroyed or collected. */
|
|
19
|
+
readonly exists: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Data kept between frames. Use JSON values only. Tests can read it as
|
|
22
|
+
* `<id>.state.<key>`, for example `"door.state.open == true"`.
|
|
23
|
+
*/
|
|
24
|
+
readonly state: Record<string, unknown>;
|
|
25
|
+
has(component: string): boolean;
|
|
26
|
+
/** True when this entity overlaps or touches the other one. */
|
|
27
|
+
touches(other: ScriptEntity | string): boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SpawnDef {
|
|
31
|
+
/** Generated when left out. */
|
|
32
|
+
id?: string;
|
|
33
|
+
components: Record<string, Record<string, unknown>>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ScriptWorld {
|
|
37
|
+
readonly time: number;
|
|
38
|
+
readonly frame: number;
|
|
39
|
+
readonly width: number;
|
|
40
|
+
readonly height: number;
|
|
41
|
+
score: number;
|
|
42
|
+
get(id: string): ScriptEntity | undefined;
|
|
43
|
+
/** Every entity, or only those with the given component. */
|
|
44
|
+
all(component?: string): ScriptEntity[];
|
|
45
|
+
/** The first entity with a `platformer` component. */
|
|
46
|
+
player(): ScriptEntity | undefined;
|
|
47
|
+
spawn(def: SpawnDef): ScriptEntity;
|
|
48
|
+
destroy(entity: ScriptEntity | string): void;
|
|
49
|
+
/** Players die and respawn; anything else is destroyed. */
|
|
50
|
+
kill(entity: ScriptEntity | string): void;
|
|
51
|
+
/** Completes the level, like reaching a goal. */
|
|
52
|
+
win(): void;
|
|
53
|
+
/** Fires a custom event that tests can count (`events.<name>`) and `effects` can react to. */
|
|
54
|
+
emit(name: string, data?: unknown): void;
|
|
55
|
+
/** A number in [0, 1). Seeded by `world.seed`, so runs stay deterministic. Never use Math.random. */
|
|
56
|
+
random(): number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ScriptInput {
|
|
60
|
+
/** True while the action is held. */
|
|
61
|
+
held(action: Action): boolean;
|
|
62
|
+
/** True only on the frame the action is pressed. */
|
|
63
|
+
pressed(action: Action): boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ScriptContext {
|
|
67
|
+
self: ScriptEntity;
|
|
68
|
+
world: ScriptWorld;
|
|
69
|
+
input: ScriptInput;
|
|
70
|
+
/** The `params` object from the entity's `script` component. */
|
|
71
|
+
params: Record<string, unknown>;
|
|
72
|
+
/** Seconds per frame (1/60). */
|
|
73
|
+
dt: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The default export of a script file. All hooks are optional.
|
|
78
|
+
*
|
|
79
|
+
* ```ts
|
|
80
|
+
* import type { Script } from "@ludra/core";
|
|
81
|
+
* export default {
|
|
82
|
+
* update({ self, world, params }) { ... },
|
|
83
|
+
* } satisfies Script;
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
export interface Script {
|
|
87
|
+
/** Runs once, on the entity's first frame. */
|
|
88
|
+
start?(ctx: ScriptContext): void;
|
|
89
|
+
/** Runs every frame, after player input and before physics. */
|
|
90
|
+
update?(ctx: ScriptContext): void;
|
|
91
|
+
/** Runs when this entity starts touching another one (after physics). */
|
|
92
|
+
touch?(ctx: ScriptContext, other: ScriptEntity): void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const SCRIPT_HOOKS = ["start", "update", "touch"] as const;
|
|
96
|
+
|
|
97
|
+
/** Printed by `ludra components script`. Keep in sync with the interfaces above. */
|
|
98
|
+
export const SCRIPT_API_REFERENCE = `A script file default-exports hooks. Only "import type" is allowed: scripts run without a bundler.
|
|
99
|
+
|
|
100
|
+
import type { Script } from "@ludra/core";
|
|
101
|
+
export default {
|
|
102
|
+
start(ctx) {}, // once, on the entity's first frame
|
|
103
|
+
update(ctx) {}, // every frame, after player input, before physics
|
|
104
|
+
touch(ctx, other) {}, // when this entity starts touching another body (after physics)
|
|
105
|
+
} satisfies Script;
|
|
106
|
+
|
|
107
|
+
ctx.self the entity: id, x, y, vx, vy (read/write); grounded, width, height, exists (read);
|
|
108
|
+
state (object kept between frames, testable as "<id>.state.<key>");
|
|
109
|
+
has("component"), touches(otherOrId)
|
|
110
|
+
ctx.world time, frame, width, height, score (read/write)
|
|
111
|
+
get(id), all(component?), player()
|
|
112
|
+
spawn({ id?, components }) -> entity destroy(entityOrId)
|
|
113
|
+
kill(entityOrId) players respawn, others are destroyed
|
|
114
|
+
win() completes the level
|
|
115
|
+
emit(name, data?) custom event: counted by "events.<name>" in tests, usable as an effects key
|
|
116
|
+
random() deterministic, seeded by world.seed. Never use Math.random or Date
|
|
117
|
+
ctx.input held(action), pressed(action) actions: left right up down jump action
|
|
118
|
+
ctx.params the "params" object from the script component
|
|
119
|
+
ctx.dt seconds per frame (1/60)
|
|
120
|
+
|
|
121
|
+
Scripts in scripts/ are loaded even if no entity uses them, so world.spawn can attach them.
|
|
122
|
+
Errors name the file, entity, hook and frame.`;
|
|
123
|
+
|
|
124
|
+
export type ScriptModules = ReadonlyMap<string, Script> | Readonly<Record<string, Script>>;
|
|
125
|
+
|
|
126
|
+
export function getScript(modules: ScriptModules | undefined, src: string): Script | undefined {
|
|
127
|
+
if (!modules) return undefined;
|
|
128
|
+
return modules instanceof Map ? modules.get(src) : (modules as Record<string, Script>)[src];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A script threw, or was used wrongly. Names the file, entity, hook and frame. */
|
|
132
|
+
export class ScriptError extends Error {
|
|
133
|
+
readonly src: string;
|
|
134
|
+
readonly entity: string;
|
|
135
|
+
readonly hook: string;
|
|
136
|
+
readonly frame: number;
|
|
137
|
+
|
|
138
|
+
constructor(details: { src: string; entity: string; hook: string; frame: number }, message: string, options?: ErrorOptions) {
|
|
139
|
+
super(`${details.src} (${details.hook} on entity "${details.entity}", frame ${details.frame}): ${message}`, options);
|
|
140
|
+
this.src = details.src;
|
|
141
|
+
this.entity = details.entity;
|
|
142
|
+
this.hook = details.hook;
|
|
143
|
+
this.frame = details.frame;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Checks that a loaded module looks like a script. */
|
|
148
|
+
export function validateScriptModule(module: unknown, src: string): Diagnostic[] {
|
|
149
|
+
const diagnostics: Diagnostic[] = [];
|
|
150
|
+
const script = (module as { default?: unknown } | null)?.default;
|
|
151
|
+
if (typeof script !== "object" || script === null) {
|
|
152
|
+
diagnostics.push({
|
|
153
|
+
severity: "error",
|
|
154
|
+
code: "E_SCRIPT_NO_DEFAULT_EXPORT",
|
|
155
|
+
path: src,
|
|
156
|
+
message: `${src} must default-export an object with script hooks.`,
|
|
157
|
+
fix: "Use: export default { update({ self, world }) { ... } } satisfies Script;",
|
|
158
|
+
});
|
|
159
|
+
return diagnostics;
|
|
160
|
+
}
|
|
161
|
+
for (const [key, value] of Object.entries(script)) {
|
|
162
|
+
if (!(SCRIPT_HOOKS as readonly string[]).includes(key)) {
|
|
163
|
+
const suggestion = closestName(key, SCRIPT_HOOKS);
|
|
164
|
+
diagnostics.push({
|
|
165
|
+
severity: "warning",
|
|
166
|
+
code: "W_SCRIPT_UNKNOWN_HOOK",
|
|
167
|
+
path: `${src} → ${key}`,
|
|
168
|
+
message: `"${key}" is not a script hook, so it is never called.`,
|
|
169
|
+
fix: `${suggestion ? `Did you mean "${suggestion}"? ` : ""}Hooks are ${SCRIPT_HOOKS.join(", ")}. Put helpers outside the exported object.`,
|
|
170
|
+
});
|
|
171
|
+
} else if (typeof value !== "function") {
|
|
172
|
+
diagnostics.push({ severity: "error", code: "E_SCRIPT_HOOK_NOT_FUNCTION", path: `${src} → ${key}`, message: `"${key}" must be a function.` });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return diagnostics;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Scripts run in the engine without a bundler, so they cannot import runtime code. */
|
|
179
|
+
export function findRuntimeImports(source: string): string[] {
|
|
180
|
+
const imports: string[] = [];
|
|
181
|
+
for (const match of source.matchAll(/^\s*import\s+(?!type\b)([^;\n]*?)\s+from\s+["']([^"']+)["']/gm)) imports.push(match[2]!);
|
|
182
|
+
for (const match of source.matchAll(/^\s*import\s+["']([^"']+)["']/gm)) imports.push(match[1]!);
|
|
183
|
+
return imports;
|
|
184
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -25,14 +25,19 @@ export interface WorldSettings {
|
|
|
25
25
|
/** Downward acceleration in px/s². Defaults to 1800. */
|
|
26
26
|
gravity?: number;
|
|
27
27
|
background?: string;
|
|
28
|
+
/** Seed for `world.random()` in scripts. Defaults to 1. */
|
|
29
|
+
seed?: number;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
/** A scene file (`*.scene.json`). */
|
|
31
33
|
export interface SceneDef {
|
|
32
34
|
name?: string;
|
|
33
35
|
world?: WorldSettings;
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Feedback for game events, e.g. `{ "collect": { "sound": "coin" } }`. Visual and audio only.
|
|
38
|
+
* Keys are built-in event types or names scripts pass to `world.emit`.
|
|
39
|
+
*/
|
|
40
|
+
effects?: Partial<Record<GameEventType, EffectDef>> & Record<string, EffectDef | undefined>;
|
|
36
41
|
entities: EntityDef[];
|
|
37
42
|
}
|
|
38
43
|
|
package/src/validate.ts
CHANGED
|
@@ -44,6 +44,9 @@ export function validateScene(raw: unknown): ValidationResult {
|
|
|
44
44
|
error("E_INVALID_WORLD", `world.${key}`, `world.${key} must be a non-negative number, got ${describe(value)}.`);
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
+
if (raw.world.seed !== undefined && (typeof raw.world.seed !== "number" || !Number.isFinite(raw.world.seed))) {
|
|
48
|
+
error("E_INVALID_WORLD", "world.seed", `world.seed must be a number, got ${describe(raw.world.seed)}.`);
|
|
49
|
+
}
|
|
47
50
|
if (raw.world.background !== undefined && typeof raw.world.background !== "string") {
|
|
48
51
|
error("E_INVALID_WORLD", "world.background", `world.background must be a CSS color string, got ${describe(raw.world.background)}.`);
|
|
49
52
|
}
|
|
@@ -94,7 +97,7 @@ export function validateScene(raw: unknown): ValidationResult {
|
|
|
94
97
|
for (const [name, value] of Object.entries(components)) {
|
|
95
98
|
const componentPath = `${path}.components.${name}`;
|
|
96
99
|
if (!isComponentName(name)) {
|
|
97
|
-
const suggestion =
|
|
100
|
+
const suggestion = closestName(name, COMPONENT_NAMES);
|
|
98
101
|
error(
|
|
99
102
|
"E_UNKNOWN_COMPONENT",
|
|
100
103
|
componentPath,
|
|
@@ -135,6 +138,16 @@ export function validateScene(raw: unknown): ValidationResult {
|
|
|
135
138
|
);
|
|
136
139
|
}
|
|
137
140
|
|
|
141
|
+
const script = isObject(components.script) ? components.script : undefined;
|
|
142
|
+
if (script && typeof script.src === "string" && !hasExtension(script.src, SCRIPT_EXTENSIONS)) {
|
|
143
|
+
error(
|
|
144
|
+
"E_INVALID_SCRIPT_PATH",
|
|
145
|
+
`${path}.components.script.src`,
|
|
146
|
+
`"${script.src}" on entity ${label} is not a script file.`,
|
|
147
|
+
'Use a path ending in .ts or .js, e.g. "scripts/chase.ts".',
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
138
151
|
const body = isObject(components.body) ? components.body : undefined;
|
|
139
152
|
const bodyType = body?.type ?? "static";
|
|
140
153
|
if ("platformer" in components && body && bodyType !== "dynamic") {
|
|
@@ -229,7 +242,7 @@ function validateFields(
|
|
|
229
242
|
if (ignore.includes(field)) continue;
|
|
230
243
|
const spec = fields[field];
|
|
231
244
|
if (!spec) {
|
|
232
|
-
const suggestion =
|
|
245
|
+
const suggestion = closestName(field, Object.keys(fields));
|
|
233
246
|
warn(
|
|
234
247
|
"W_UNKNOWN_FIELD",
|
|
235
248
|
`${path}.${field}`,
|
|
@@ -258,9 +271,16 @@ function validateEffects(effects: unknown, error: Report, warn: Report): void {
|
|
|
258
271
|
for (const [event, effect] of Object.entries(effects)) {
|
|
259
272
|
const path = `effects.${event}`;
|
|
260
273
|
if (!(EVENT_TYPES as readonly string[]).includes(event)) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
274
|
+
// Any other name is a custom event that scripts fire with world.emit(). Flag likely typos of built-in ones.
|
|
275
|
+
const suggestion = closestName(event, EVENT_TYPES);
|
|
276
|
+
if (suggestion) {
|
|
277
|
+
warn(
|
|
278
|
+
"W_EVENT_LOOKS_LIKE_TYPO",
|
|
279
|
+
path,
|
|
280
|
+
`"${event}" is not a built-in event, so it only fires if a script calls world.emit("${event}").`,
|
|
281
|
+
`Did you mean "${suggestion}"? Built-in events: ${EVENT_TYPES.join(", ")}.`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
264
284
|
}
|
|
265
285
|
if (!isObject(effect)) {
|
|
266
286
|
error("E_EFFECT_NOT_OBJECT", path, `effects.${event} must be an object.`, `Use { "sound": "blip", "particles": { "count": 8 } }.`);
|
|
@@ -268,7 +288,7 @@ function validateEffects(effects: unknown, error: Report, warn: Report): void {
|
|
|
268
288
|
}
|
|
269
289
|
validateFields(EFFECT_FIELDS, effect, path, `effects.${event}`, "", error, warn, ["particles"]);
|
|
270
290
|
if (typeof effect.sound === "string" && effect.sound !== "" && !isSoundPreset(effect.sound) && !hasExtension(effect.sound, AUDIO_EXTENSIONS)) {
|
|
271
|
-
const suggestion =
|
|
291
|
+
const suggestion = closestName(effect.sound, SOUND_PRESETS);
|
|
272
292
|
error(
|
|
273
293
|
"E_INVALID_SOUND",
|
|
274
294
|
`${path}.sound`,
|
|
@@ -286,6 +306,8 @@ function validateEffects(effects: unknown, error: Report, warn: Report): void {
|
|
|
286
306
|
}
|
|
287
307
|
}
|
|
288
308
|
|
|
309
|
+
const SCRIPT_EXTENSIONS = [".ts", ".js"] as const;
|
|
310
|
+
|
|
289
311
|
function hasExtension(path: string, extensions: readonly string[]): boolean {
|
|
290
312
|
const lower = path.toLowerCase();
|
|
291
313
|
return extensions.some((ext) => lower.endsWith(ext));
|
|
@@ -307,6 +329,8 @@ function checkField(spec: FieldSpec, value: unknown): string | undefined {
|
|
|
307
329
|
: `must be one of ${spec.values.map((v) => `"${v}"`).join(", ")}, got ${describe(value)}.`;
|
|
308
330
|
case "frames":
|
|
309
331
|
return typeof value === "string" && isFrameList(value) ? undefined : `must be a frame list like "0", "1-4" or "0,2-3", got ${describe(value)}.`;
|
|
332
|
+
case "object":
|
|
333
|
+
return isObject(value) ? undefined : `must be an object like { "speed": 80 }, got ${describe(value)}.`;
|
|
310
334
|
}
|
|
311
335
|
}
|
|
312
336
|
|
|
@@ -326,7 +350,7 @@ function describe(value: unknown): string {
|
|
|
326
350
|
}
|
|
327
351
|
|
|
328
352
|
/** Returns the candidate within a small edit distance of `input`, if any. */
|
|
329
|
-
function
|
|
353
|
+
export function closestName(input: string, candidates: readonly string[]): string | undefined {
|
|
330
354
|
let best: string | undefined;
|
|
331
355
|
let bestDistance = Math.max(2, Math.floor(input.length / 3)) + 1;
|
|
332
356
|
for (const candidate of candidates) {
|