@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/dist/script.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { closestName } from "./validate.js";
|
|
2
|
+
export const SCRIPT_HOOKS = ["start", "update", "touch"];
|
|
3
|
+
/** Printed by `ludra components script`. Keep in sync with the interfaces above. */
|
|
4
|
+
export const SCRIPT_API_REFERENCE = `A script file default-exports hooks. Only "import type" is allowed: scripts run without a bundler.
|
|
5
|
+
|
|
6
|
+
import type { Script } from "@ludra/core";
|
|
7
|
+
export default {
|
|
8
|
+
start(ctx) {}, // once, on the entity's first frame
|
|
9
|
+
update(ctx) {}, // every frame, after player input, before physics
|
|
10
|
+
touch(ctx, other) {}, // when this entity starts touching another body (after physics)
|
|
11
|
+
} satisfies Script;
|
|
12
|
+
|
|
13
|
+
ctx.self the entity: id, x, y, vx, vy (read/write); grounded, width, height, exists (read);
|
|
14
|
+
state (object kept between frames, testable as "<id>.state.<key>");
|
|
15
|
+
has("component"), touches(otherOrId)
|
|
16
|
+
ctx.world time, frame, width, height, score (read/write)
|
|
17
|
+
get(id), all(component?), player()
|
|
18
|
+
spawn({ id?, components }) -> entity destroy(entityOrId)
|
|
19
|
+
kill(entityOrId) players respawn, others are destroyed
|
|
20
|
+
win() completes the level
|
|
21
|
+
emit(name, data?) custom event: counted by "events.<name>" in tests, usable as an effects key
|
|
22
|
+
random() deterministic, seeded by world.seed. Never use Math.random or Date
|
|
23
|
+
ctx.input held(action), pressed(action) actions: left right up down jump action
|
|
24
|
+
ctx.params the "params" object from the script component
|
|
25
|
+
ctx.dt seconds per frame (1/60)
|
|
26
|
+
|
|
27
|
+
Scripts in scripts/ are loaded even if no entity uses them, so world.spawn can attach them.
|
|
28
|
+
Errors name the file, entity, hook and frame.`;
|
|
29
|
+
export function getScript(modules, src) {
|
|
30
|
+
if (!modules)
|
|
31
|
+
return undefined;
|
|
32
|
+
return modules instanceof Map ? modules.get(src) : modules[src];
|
|
33
|
+
}
|
|
34
|
+
/** A script threw, or was used wrongly. Names the file, entity, hook and frame. */
|
|
35
|
+
export class ScriptError extends Error {
|
|
36
|
+
src;
|
|
37
|
+
entity;
|
|
38
|
+
hook;
|
|
39
|
+
frame;
|
|
40
|
+
constructor(details, message, options) {
|
|
41
|
+
super(`${details.src} (${details.hook} on entity "${details.entity}", frame ${details.frame}): ${message}`, options);
|
|
42
|
+
this.src = details.src;
|
|
43
|
+
this.entity = details.entity;
|
|
44
|
+
this.hook = details.hook;
|
|
45
|
+
this.frame = details.frame;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Checks that a loaded module looks like a script. */
|
|
49
|
+
export function validateScriptModule(module, src) {
|
|
50
|
+
const diagnostics = [];
|
|
51
|
+
const script = module?.default;
|
|
52
|
+
if (typeof script !== "object" || script === null) {
|
|
53
|
+
diagnostics.push({
|
|
54
|
+
severity: "error",
|
|
55
|
+
code: "E_SCRIPT_NO_DEFAULT_EXPORT",
|
|
56
|
+
path: src,
|
|
57
|
+
message: `${src} must default-export an object with script hooks.`,
|
|
58
|
+
fix: "Use: export default { update({ self, world }) { ... } } satisfies Script;",
|
|
59
|
+
});
|
|
60
|
+
return diagnostics;
|
|
61
|
+
}
|
|
62
|
+
for (const [key, value] of Object.entries(script)) {
|
|
63
|
+
if (!SCRIPT_HOOKS.includes(key)) {
|
|
64
|
+
const suggestion = closestName(key, SCRIPT_HOOKS);
|
|
65
|
+
diagnostics.push({
|
|
66
|
+
severity: "warning",
|
|
67
|
+
code: "W_SCRIPT_UNKNOWN_HOOK",
|
|
68
|
+
path: `${src} → ${key}`,
|
|
69
|
+
message: `"${key}" is not a script hook, so it is never called.`,
|
|
70
|
+
fix: `${suggestion ? `Did you mean "${suggestion}"? ` : ""}Hooks are ${SCRIPT_HOOKS.join(", ")}. Put helpers outside the exported object.`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
else if (typeof value !== "function") {
|
|
74
|
+
diagnostics.push({ severity: "error", code: "E_SCRIPT_HOOK_NOT_FUNCTION", path: `${src} → ${key}`, message: `"${key}" must be a function.` });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return diagnostics;
|
|
78
|
+
}
|
|
79
|
+
/** Scripts run in the engine without a bundler, so they cannot import runtime code. */
|
|
80
|
+
export function findRuntimeImports(source) {
|
|
81
|
+
const imports = [];
|
|
82
|
+
for (const match of source.matchAll(/^\s*import\s+(?!type\b)([^;\n]*?)\s+from\s+["']([^"']+)["']/gm))
|
|
83
|
+
imports.push(match[2]);
|
|
84
|
+
for (const match of source.matchAll(/^\s*import\s+["']([^"']+)["']/gm))
|
|
85
|
+
imports.push(match[1]);
|
|
86
|
+
return imports;
|
|
87
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -20,13 +20,18 @@ export interface WorldSettings {
|
|
|
20
20
|
/** Downward acceleration in px/s². Defaults to 1800. */
|
|
21
21
|
gravity?: number;
|
|
22
22
|
background?: string;
|
|
23
|
+
/** Seed for `world.random()` in scripts. Defaults to 1. */
|
|
24
|
+
seed?: number;
|
|
23
25
|
}
|
|
24
26
|
/** A scene file (`*.scene.json`). */
|
|
25
27
|
export interface SceneDef {
|
|
26
28
|
name?: string;
|
|
27
29
|
world?: WorldSettings;
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Feedback for game events, e.g. `{ "collect": { "sound": "coin" } }`. Visual and audio only.
|
|
32
|
+
* Keys are built-in event types or names scripts pass to `world.emit`.
|
|
33
|
+
*/
|
|
34
|
+
effects?: Partial<Record<GameEventType, EffectDef>> & Record<string, EffectDef | undefined>;
|
|
30
35
|
entities: EntityDef[];
|
|
31
36
|
}
|
|
32
37
|
export interface Diagnostic {
|
package/dist/validate.d.ts
CHANGED
|
@@ -10,3 +10,5 @@ export interface ValidationResult {
|
|
|
10
10
|
* where possible, a fix an agent can apply without guessing.
|
|
11
11
|
*/
|
|
12
12
|
export declare function validateScene(raw: unknown): ValidationResult;
|
|
13
|
+
/** Returns the candidate within a small edit distance of `input`, if any. */
|
|
14
|
+
export declare function closestName(input: string, candidates: readonly string[]): string | undefined;
|
package/dist/validate.js
CHANGED
|
@@ -32,6 +32,9 @@ export function validateScene(raw) {
|
|
|
32
32
|
error("E_INVALID_WORLD", `world.${key}`, `world.${key} must be a non-negative number, got ${describe(value)}.`);
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
+
if (raw.world.seed !== undefined && (typeof raw.world.seed !== "number" || !Number.isFinite(raw.world.seed))) {
|
|
36
|
+
error("E_INVALID_WORLD", "world.seed", `world.seed must be a number, got ${describe(raw.world.seed)}.`);
|
|
37
|
+
}
|
|
35
38
|
if (raw.world.background !== undefined && typeof raw.world.background !== "string") {
|
|
36
39
|
error("E_INVALID_WORLD", "world.background", `world.background must be a CSS color string, got ${describe(raw.world.background)}.`);
|
|
37
40
|
}
|
|
@@ -73,7 +76,7 @@ export function validateScene(raw) {
|
|
|
73
76
|
for (const [name, value] of Object.entries(components)) {
|
|
74
77
|
const componentPath = `${path}.components.${name}`;
|
|
75
78
|
if (!isComponentName(name)) {
|
|
76
|
-
const suggestion =
|
|
79
|
+
const suggestion = closestName(name, COMPONENT_NAMES);
|
|
77
80
|
error("E_UNKNOWN_COMPONENT", componentPath, `Unknown component "${name}" on entity ${label}.`, `${suggestion ? `Did you mean "${suggestion}"? ` : ""}Run \`ludra components\` to list every component.`);
|
|
78
81
|
continue;
|
|
79
82
|
}
|
|
@@ -97,6 +100,10 @@ export function validateScene(raw) {
|
|
|
97
100
|
if (sprite && typeof sprite.image === "string" && sprite.image !== "" && !hasExtension(sprite.image, IMAGE_EXTENSIONS)) {
|
|
98
101
|
error("E_INVALID_IMAGE", `${path}.components.sprite.image`, `"${sprite.image}" on entity ${label} is not an image file.`, `Use a path ending in ${IMAGE_EXTENSIONS.join(", ")}. SVG is easiest: you can write it as text.`);
|
|
99
102
|
}
|
|
103
|
+
const script = isObject(components.script) ? components.script : undefined;
|
|
104
|
+
if (script && typeof script.src === "string" && !hasExtension(script.src, SCRIPT_EXTENSIONS)) {
|
|
105
|
+
error("E_INVALID_SCRIPT_PATH", `${path}.components.script.src`, `"${script.src}" on entity ${label} is not a script file.`, 'Use a path ending in .ts or .js, e.g. "scripts/chase.ts".');
|
|
106
|
+
}
|
|
100
107
|
const body = isObject(components.body) ? components.body : undefined;
|
|
101
108
|
const bodyType = body?.type ?? "static";
|
|
102
109
|
if ("platformer" in components && body && bodyType !== "dynamic") {
|
|
@@ -155,7 +162,7 @@ function validateFields(fields, value, path, owner, where, error, warn, ignore =
|
|
|
155
162
|
continue;
|
|
156
163
|
const spec = fields[field];
|
|
157
164
|
if (!spec) {
|
|
158
|
-
const suggestion =
|
|
165
|
+
const suggestion = closestName(field, Object.keys(fields));
|
|
159
166
|
warn("W_UNKNOWN_FIELD", `${path}.${field}`, `"${owner}" has no field "${field}", so it is ignored.`, suggestion ? `Did you mean "${suggestion}"?` : `Valid fields: ${Object.keys(fields).join(", ")}.`);
|
|
160
167
|
continue;
|
|
161
168
|
}
|
|
@@ -178,9 +185,11 @@ function validateEffects(effects, error, warn) {
|
|
|
178
185
|
for (const [event, effect] of Object.entries(effects)) {
|
|
179
186
|
const path = `effects.${event}`;
|
|
180
187
|
if (!EVENT_TYPES.includes(event)) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
188
|
+
// Any other name is a custom event that scripts fire with world.emit(). Flag likely typos of built-in ones.
|
|
189
|
+
const suggestion = closestName(event, EVENT_TYPES);
|
|
190
|
+
if (suggestion) {
|
|
191
|
+
warn("W_EVENT_LOOKS_LIKE_TYPO", path, `"${event}" is not a built-in event, so it only fires if a script calls world.emit("${event}").`, `Did you mean "${suggestion}"? Built-in events: ${EVENT_TYPES.join(", ")}.`);
|
|
192
|
+
}
|
|
184
193
|
}
|
|
185
194
|
if (!isObject(effect)) {
|
|
186
195
|
error("E_EFFECT_NOT_OBJECT", path, `effects.${event} must be an object.`, `Use { "sound": "blip", "particles": { "count": 8 } }.`);
|
|
@@ -188,7 +197,7 @@ function validateEffects(effects, error, warn) {
|
|
|
188
197
|
}
|
|
189
198
|
validateFields(EFFECT_FIELDS, effect, path, `effects.${event}`, "", error, warn, ["particles"]);
|
|
190
199
|
if (typeof effect.sound === "string" && effect.sound !== "" && !isSoundPreset(effect.sound) && !hasExtension(effect.sound, AUDIO_EXTENSIONS)) {
|
|
191
|
-
const suggestion =
|
|
200
|
+
const suggestion = closestName(effect.sound, SOUND_PRESETS);
|
|
192
201
|
error("E_INVALID_SOUND", `${path}.sound`, `"${effect.sound}" is neither a sound preset nor an audio file.`, `${suggestion ? `Did you mean "${suggestion}"? ` : ""}Presets: ${SOUND_PRESETS.join(", ")}. Files must end in ${AUDIO_EXTENSIONS.join(", ")}.`);
|
|
193
202
|
}
|
|
194
203
|
if (effect.particles !== undefined) {
|
|
@@ -201,6 +210,7 @@ function validateEffects(effects, error, warn) {
|
|
|
201
210
|
}
|
|
202
211
|
}
|
|
203
212
|
}
|
|
213
|
+
const SCRIPT_EXTENSIONS = [".ts", ".js"];
|
|
204
214
|
function hasExtension(path, extensions) {
|
|
205
215
|
const lower = path.toLowerCase();
|
|
206
216
|
return extensions.some((ext) => lower.endsWith(ext));
|
|
@@ -223,6 +233,8 @@ function checkField(spec, value) {
|
|
|
223
233
|
: `must be one of ${spec.values.map((v) => `"${v}"`).join(", ")}, got ${describe(value)}.`;
|
|
224
234
|
case "frames":
|
|
225
235
|
return typeof value === "string" && isFrameList(value) ? undefined : `must be a frame list like "0", "1-4" or "0,2-3", got ${describe(value)}.`;
|
|
236
|
+
case "object":
|
|
237
|
+
return isObject(value) ? undefined : `must be an object like { "speed": 80 }, got ${describe(value)}.`;
|
|
226
238
|
}
|
|
227
239
|
}
|
|
228
240
|
function fixFor(spec) {
|
|
@@ -241,7 +253,7 @@ function describe(value) {
|
|
|
241
253
|
return value === undefined ? "nothing" : JSON.stringify(value);
|
|
242
254
|
}
|
|
243
255
|
/** Returns the candidate within a small edit distance of `input`, if any. */
|
|
244
|
-
function
|
|
256
|
+
export function closestName(input, candidates) {
|
|
245
257
|
let best;
|
|
246
258
|
let bestDistance = Math.max(2, Math.floor(input.length / 3)) + 1;
|
|
247
259
|
for (const candidate of candidates) {
|
package/dist/world.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { type Body, type Camera, type Collectible, type Emitter, type Goal, type Hazard, type Patrol, type Platformer, type Sprite, type Transform } from "./components.ts";
|
|
2
|
-
import
|
|
1
|
+
import { type Body, type Camera, type Collectible, type Emitter, type Goal, type Hazard, type Patrol, type Platformer, type ScriptComponent, type Sprite, type Transform } from "./components.ts";
|
|
2
|
+
import { type Script, type ScriptModules } from "./script.ts";
|
|
3
|
+
import { type Action, type Diagnostic, type SceneDef } from "./types.ts";
|
|
3
4
|
export interface BodyState extends Body {
|
|
4
5
|
grounded: boolean;
|
|
5
6
|
/** The solid this body is standing on, so moving platforms can carry it. */
|
|
@@ -7,6 +8,8 @@ export interface BodyState extends Body {
|
|
|
7
8
|
}
|
|
8
9
|
export interface PlatformerState extends Platformer {
|
|
9
10
|
coyoteTimer: number;
|
|
11
|
+
/** True from a player-initiated jump until the apex, so only those jumps are cut short on release. */
|
|
12
|
+
jumping: boolean;
|
|
10
13
|
bufferTimer: number;
|
|
11
14
|
spawnX: number;
|
|
12
15
|
spawnY: number;
|
|
@@ -15,8 +18,19 @@ export interface PatrolState extends Patrol {
|
|
|
15
18
|
originX: number;
|
|
16
19
|
originY: number;
|
|
17
20
|
}
|
|
21
|
+
export interface ScriptState extends ScriptComponent {
|
|
22
|
+
module: Script;
|
|
23
|
+
started: boolean;
|
|
24
|
+
/** Ids of entities touched last frame, so `touch` fires only when touching starts. */
|
|
25
|
+
touching: Set<string>;
|
|
26
|
+
}
|
|
18
27
|
export interface Entity {
|
|
19
28
|
id: string;
|
|
29
|
+
/** Script-owned data, kept between frames and included in snapshots. */
|
|
30
|
+
state: Record<string, unknown>;
|
|
31
|
+
/** Set once destroyed or collected, so stale script handles report `exists: false`. */
|
|
32
|
+
removed?: boolean;
|
|
33
|
+
script?: ScriptState;
|
|
20
34
|
transform: Transform;
|
|
21
35
|
body?: BodyState;
|
|
22
36
|
sprite?: Sprite;
|
|
@@ -58,6 +72,13 @@ export type GameEvent = {
|
|
|
58
72
|
type: "win";
|
|
59
73
|
entity: string;
|
|
60
74
|
goal: string;
|
|
75
|
+
}
|
|
76
|
+
/** Fired by a script with world.emit(name, data). */
|
|
77
|
+
| {
|
|
78
|
+
type: "custom";
|
|
79
|
+
name: string;
|
|
80
|
+
entity: string;
|
|
81
|
+
data?: unknown;
|
|
61
82
|
});
|
|
62
83
|
export interface EntitySnapshot {
|
|
63
84
|
x: number;
|
|
@@ -65,6 +86,12 @@ export interface EntitySnapshot {
|
|
|
65
86
|
vx: number;
|
|
66
87
|
vy: number;
|
|
67
88
|
grounded: boolean;
|
|
89
|
+
/** Present when a script stored data on the entity. */
|
|
90
|
+
state?: Record<string, unknown>;
|
|
91
|
+
}
|
|
92
|
+
export interface WorldOptions {
|
|
93
|
+
/** Loaded script modules, keyed by the `src` path used in the scene. */
|
|
94
|
+
scripts?: ScriptModules;
|
|
68
95
|
}
|
|
69
96
|
export interface WorldSnapshot {
|
|
70
97
|
frame: number;
|
|
@@ -96,9 +123,17 @@ export declare class World {
|
|
|
96
123
|
/** Set when a player reaches a goal. The simulation stops changing after that. */
|
|
97
124
|
won: boolean;
|
|
98
125
|
private previousInput;
|
|
99
|
-
|
|
126
|
+
private readonly scripts;
|
|
127
|
+
private seed;
|
|
128
|
+
private spawnCount;
|
|
129
|
+
private readonly handles;
|
|
130
|
+
private scriptApi;
|
|
131
|
+
private scriptInput;
|
|
132
|
+
/** The script hook currently running, for error messages and event attribution. */
|
|
133
|
+
private current;
|
|
134
|
+
constructor(scene: SceneDef, options?: WorldOptions);
|
|
100
135
|
/** Validates raw scene JSON and builds a world, throwing on errors. */
|
|
101
|
-
static fromJSON(raw: unknown): World;
|
|
136
|
+
static fromJSON(raw: unknown, options?: WorldOptions): World;
|
|
102
137
|
get time(): number;
|
|
103
138
|
get(id: string): Entity | undefined;
|
|
104
139
|
/** Advances one frame with the given actions held down. */
|
|
@@ -106,6 +141,21 @@ export declare class World {
|
|
|
106
141
|
snapshot(): WorldSnapshot;
|
|
107
142
|
private emit;
|
|
108
143
|
private remove;
|
|
144
|
+
private createEntity;
|
|
145
|
+
private updateScripts;
|
|
146
|
+
/** Calls `touch` on scripted entities that started overlapping or touching another body this frame. */
|
|
147
|
+
private updateTouches;
|
|
148
|
+
private callHook;
|
|
149
|
+
/** An error caused by how a script used the API, reported against the running script. */
|
|
150
|
+
private misuse;
|
|
151
|
+
/** A stable script-facing view of an entity. */
|
|
152
|
+
private handle;
|
|
153
|
+
private resolve;
|
|
154
|
+
/** The `world` object scripts receive. */
|
|
155
|
+
private api;
|
|
156
|
+
private spawn;
|
|
157
|
+
/** mulberry32, seeded by world.seed. */
|
|
158
|
+
private random;
|
|
109
159
|
private players;
|
|
110
160
|
private updatePlatformers;
|
|
111
161
|
/** Points each patrolling body toward the end of its path it is heading for. */
|
|
@@ -117,5 +167,8 @@ export declare class World {
|
|
|
117
167
|
/** Sends a player back to where it started. Collected items stay collected. */
|
|
118
168
|
private kill;
|
|
119
169
|
}
|
|
120
|
-
/**
|
|
121
|
-
|
|
170
|
+
/**
|
|
171
|
+
* Strict overlap: bodies that merely touch (e.g. standing on the ground) do not overlap.
|
|
172
|
+
* A positive `margin` grows both boxes, so touching counts too.
|
|
173
|
+
*/
|
|
174
|
+
export declare function overlaps(a: Entity, b: Entity, margin?: number): boolean;
|