@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/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
- world.step(tapped.size > 0 ? new Set([...held, ...tapped]) : held);
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
@@ -4,6 +4,7 @@ export * from "./components.ts";
4
4
  export * from "./effects.ts";
5
5
  export * from "./validate.ts";
6
6
  export * from "./input.ts";
7
+ export * from "./script.ts";
7
8
  export * from "./world.ts";
8
9
  export * from "./simulate.ts";
9
10
  export * from "./assertions.ts";
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
- /** Feedback for game events, e.g. `{ "collect": { "sound": "coin" } }`. Visual and audio only. */
35
- effects?: Partial<Record<GameEventType, EffectDef>>;
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 = closest(name, COMPONENT_NAMES);
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 = closest(field, Object.keys(fields));
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
- const suggestion = closest(event, EVENT_TYPES);
262
- error("E_UNKNOWN_EVENT", path, `There is no "${event}" event.`, `${suggestion ? `Did you mean "${suggestion}"? ` : ""}Events: ${EVENT_TYPES.join(", ")}.`);
263
- continue;
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 = closest(effect.sound, SOUND_PRESETS);
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 closest(input: string, candidates: readonly string[]): string | undefined {
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) {