@ludra/core 0.4.0 → 0.7.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 +15 -7
- package/dist/assertions.js +60 -23
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +210 -22
- package/dist/components.d.ts +17 -1
- package/dist/components.js +31 -1
- package/dist/drawcheck.d.ts +7 -0
- package/dist/drawcheck.js +81 -0
- package/dist/effects.d.ts +2 -1
- package/dist/effects.js +19 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/postfx.d.ts +18 -0
- package/dist/postfx.js +100 -0
- package/dist/script.d.ts +170 -0
- package/dist/script.js +107 -0
- package/dist/types.d.ts +21 -3
- package/dist/types.js +1 -1
- package/dist/validate.d.ts +2 -0
- package/dist/validate.js +67 -11
- package/dist/world.d.ts +89 -8
- package/dist/world.js +419 -28
- package/package.json +1 -1
- package/src/ascii.ts +2 -2
- package/src/assertions.ts +68 -29
- package/src/browser.ts +212 -19
- package/src/components.ts +48 -2
- package/src/drawcheck.ts +80 -0
- package/src/effects.ts +17 -1
- package/src/index.ts +2 -0
- package/src/postfx.ts +110 -0
- package/src/script.ts +262 -0
- package/src/types.ts +22 -3
- package/src/validate.ts +85 -11
- package/src/world.ts +443 -34
package/dist/types.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type Action = "left" | "right" | "up" | "down" | "jump" | "action";
|
|
2
2
|
export declare const ACTIONS: readonly Action[];
|
|
3
|
-
export declare const EVENT_TYPES: readonly ["jump", "land", "collect", "die", "stomp", "win"];
|
|
3
|
+
export declare const EVENT_TYPES: readonly ["jump", "land", "collect", "die", "stomp", "win", "bounce"];
|
|
4
4
|
export type GameEventType = (typeof EVENT_TYPES)[number];
|
|
5
5
|
/** What happens visually and audibly when a game event fires. See `EFFECT_FIELDS`. */
|
|
6
6
|
export interface EffectDef {
|
|
@@ -8,6 +8,12 @@ export interface EffectDef {
|
|
|
8
8
|
sound?: string;
|
|
9
9
|
shake?: number;
|
|
10
10
|
}
|
|
11
|
+
export interface PostFxDef {
|
|
12
|
+
/** Path to a GLSL ES 1.0 fragment shader, e.g. "shaders/crt.frag". */
|
|
13
|
+
shader: string;
|
|
14
|
+
/** Extra float uniforms, e.g. { "strength": 0.6 } becomes `uniform float strength;`. */
|
|
15
|
+
uniforms?: Record<string, number>;
|
|
16
|
+
}
|
|
11
17
|
/** An entity as written in a scene file. */
|
|
12
18
|
export interface EntityDef {
|
|
13
19
|
id: string;
|
|
@@ -20,13 +26,25 @@ export interface WorldSettings {
|
|
|
20
26
|
/** Downward acceleration in px/s². Defaults to 1800. */
|
|
21
27
|
gravity?: number;
|
|
22
28
|
background?: string;
|
|
29
|
+
/** Seed for `world.random()` in scripts. Defaults to 1. */
|
|
30
|
+
seed?: number;
|
|
23
31
|
}
|
|
24
32
|
/** A scene file (`*.scene.json`). */
|
|
25
33
|
export interface SceneDef {
|
|
26
34
|
name?: string;
|
|
27
35
|
world?: WorldSettings;
|
|
28
|
-
/**
|
|
29
|
-
|
|
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>;
|
|
41
|
+
/**
|
|
42
|
+
* Fonts that draw hooks can use in `ctx.g.font`: family name → "google" (loaded from
|
|
43
|
+
* Google Fonts) or a font file in the project (.woff2, .woff, .ttf, .otf).
|
|
44
|
+
*/
|
|
45
|
+
fonts?: Record<string, string>;
|
|
46
|
+
/** A GLSL fragment shader applied to every rendered frame. Presentation only. */
|
|
47
|
+
postfx?: PostFxDef;
|
|
30
48
|
entities: EntityDef[];
|
|
31
49
|
}
|
|
32
50
|
export interface Diagnostic {
|
package/dist/types.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export const ACTIONS = ["left", "right", "up", "down", "jump", "action"];
|
|
2
|
-
export const EVENT_TYPES = ["jump", "land", "collect", "die", "stomp", "win"];
|
|
2
|
+
export const EVENT_TYPES = ["jump", "land", "collect", "die", "stomp", "win", "bounce"];
|
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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { COMPONENT_NAMES, COMPONENTS, isComponentName, isFrameList, PARTICLE_FIELDS } from "./components.js";
|
|
2
|
-
import { AUDIO_EXTENSIONS, EFFECT_FIELDS, IMAGE_EXTENSIONS, isSoundPreset, SOUND_PRESETS } from "./effects.js";
|
|
2
|
+
import { AUDIO_EXTENSIONS, EFFECT_FIELDS, FONT_EXTENSIONS, IMAGE_EXTENSIONS, isSoundPreset, SOUND_PRESETS } from "./effects.js";
|
|
3
3
|
import { EVENT_TYPES } from "./types.js";
|
|
4
4
|
const REQUIRED_EXAMPLES = {
|
|
5
5
|
transform: '{ "x": 0, "y": 0 }',
|
|
@@ -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
|
}
|
|
@@ -39,6 +42,10 @@ export function validateScene(raw) {
|
|
|
39
42
|
}
|
|
40
43
|
if (raw.effects !== undefined)
|
|
41
44
|
validateEffects(raw.effects, error, warn);
|
|
45
|
+
if (raw.fonts !== undefined)
|
|
46
|
+
validateFonts(raw.fonts, error);
|
|
47
|
+
if (raw.postfx !== undefined)
|
|
48
|
+
validatePostFx(raw.postfx, error);
|
|
42
49
|
if (!Array.isArray(raw.entities)) {
|
|
43
50
|
error("E_NO_ENTITIES", "entities", 'A scene needs an "entities" array.', 'Add "entities": [].');
|
|
44
51
|
return result();
|
|
@@ -46,6 +53,7 @@ export function validateScene(raw) {
|
|
|
46
53
|
const ids = new Map();
|
|
47
54
|
let hasPlayer = false;
|
|
48
55
|
let hasDynamic = false;
|
|
56
|
+
const worldGravity = isObject(raw.world) && typeof raw.world.gravity === "number" ? raw.world.gravity : 1800;
|
|
49
57
|
let hasSolid = false;
|
|
50
58
|
let availableScore = 0;
|
|
51
59
|
const goals = [];
|
|
@@ -73,7 +81,7 @@ export function validateScene(raw) {
|
|
|
73
81
|
for (const [name, value] of Object.entries(components)) {
|
|
74
82
|
const componentPath = `${path}.components.${name}`;
|
|
75
83
|
if (!isComponentName(name)) {
|
|
76
|
-
const suggestion =
|
|
84
|
+
const suggestion = closestName(name, COMPONENT_NAMES);
|
|
77
85
|
error("E_UNKNOWN_COMPONENT", componentPath, `Unknown component "${name}" on entity ${label}.`, `${suggestion ? `Did you mean "${suggestion}"? ` : ""}Run \`ludra components\` to list every component.`);
|
|
78
86
|
continue;
|
|
79
87
|
}
|
|
@@ -97,8 +105,18 @@ export function validateScene(raw) {
|
|
|
97
105
|
if (sprite && typeof sprite.image === "string" && sprite.image !== "" && !hasExtension(sprite.image, IMAGE_EXTENSIONS)) {
|
|
98
106
|
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
107
|
}
|
|
108
|
+
const script = isObject(components.script) ? components.script : undefined;
|
|
109
|
+
if (script && typeof script.src === "string" && !hasExtension(script.src, SCRIPT_EXTENSIONS)) {
|
|
110
|
+
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".');
|
|
111
|
+
}
|
|
100
112
|
const body = isObject(components.body) ? components.body : undefined;
|
|
101
113
|
const bodyType = body?.type ?? "static";
|
|
114
|
+
if ("topdown" in components && body && bodyType === "static") {
|
|
115
|
+
error("E_TOPDOWN_NEEDS_MOVING_BODY", `${path}.components.body.type`, `Entity ${label} has "topdown" but a static body, so it can never move.`, 'Use "type": "dynamic" to collide with walls, or "kinematic" to pass through them (a paddle or a ship).');
|
|
116
|
+
}
|
|
117
|
+
if ("topdown" in components && "platformer" in components) {
|
|
118
|
+
error("E_TWO_CONTROLLERS", `${path}.components`, `Entity ${label} has both "platformer" and "topdown", which would fight over its movement.`, "Keep one: platformer for side-view games with gravity, topdown for top-down games, paddles and ships.");
|
|
119
|
+
}
|
|
102
120
|
if ("platformer" in components && body && bodyType !== "dynamic") {
|
|
103
121
|
error("E_PLATFORMER_NEEDS_DYNAMIC_BODY", `${path}.components.body.type`, `Entity ${label} has "platformer" but its body is "${String(bodyType)}", so it can never move.`, 'Set "type": "dynamic" on its body.');
|
|
104
122
|
}
|
|
@@ -116,9 +134,11 @@ export function validateScene(raw) {
|
|
|
116
134
|
availableScore += numberOr(components.hazard.score, 0);
|
|
117
135
|
if (isObject(components.goal))
|
|
118
136
|
goals.push({ path: `${path}.components.goal.requireScore`, label, requireScore: numberOr(components.goal.requireScore, 0) });
|
|
119
|
-
if ("platformer" in components)
|
|
137
|
+
if ("platformer" in components || "topdown" in components)
|
|
120
138
|
hasPlayer = true;
|
|
121
|
-
|
|
139
|
+
// Only bodies that gravity actually pulls down need something to stand on.
|
|
140
|
+
const falls = body && bodyType === "dynamic" && !("topdown" in components) && body.gravityScale !== 0;
|
|
141
|
+
if (falls && worldGravity !== 0)
|
|
122
142
|
hasDynamic = true;
|
|
123
143
|
if (body && bodyType !== "dynamic" && body.sensor !== true)
|
|
124
144
|
hasSolid = true;
|
|
@@ -132,7 +152,7 @@ export function validateScene(raw) {
|
|
|
132
152
|
}
|
|
133
153
|
}
|
|
134
154
|
if (!hasPlayer) {
|
|
135
|
-
warn("W_NO_PLAYER", "entities", 'No entity has a "platformer" component, so nothing responds to input.');
|
|
155
|
+
warn("W_NO_PLAYER", "entities", 'No entity has a "platformer" or "topdown" component, so nothing responds to input (fine if scripts read input).');
|
|
136
156
|
}
|
|
137
157
|
return result();
|
|
138
158
|
}
|
|
@@ -155,7 +175,7 @@ function validateFields(fields, value, path, owner, where, error, warn, ignore =
|
|
|
155
175
|
continue;
|
|
156
176
|
const spec = fields[field];
|
|
157
177
|
if (!spec) {
|
|
158
|
-
const suggestion =
|
|
178
|
+
const suggestion = closestName(field, Object.keys(fields));
|
|
159
179
|
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
180
|
continue;
|
|
161
181
|
}
|
|
@@ -170,6 +190,37 @@ function validateFields(fields, value, path, owner, where, error, warn, ignore =
|
|
|
170
190
|
}
|
|
171
191
|
}
|
|
172
192
|
}
|
|
193
|
+
function validateFonts(fonts, error) {
|
|
194
|
+
const example = 'Use "fonts": { "Orbitron": "google", "Pixel": "assets/pixel.woff2" }.';
|
|
195
|
+
if (!isObject(fonts))
|
|
196
|
+
return void error("E_FONTS_NOT_OBJECT", "fonts", '"fonts" must map font family names to "google" or a font file.', example);
|
|
197
|
+
for (const [family, src] of Object.entries(fonts)) {
|
|
198
|
+
const ok = typeof src === "string" && (src === "google" || FONT_EXTENSIONS.some((ext) => src.toLowerCase().endsWith(ext)));
|
|
199
|
+
if (!ok) {
|
|
200
|
+
error("E_INVALID_FONT", `fonts.${family}`, `fonts.${family} must be "google" or a font file (${FONT_EXTENSIONS.join(", ")}), got ${describe(src)}.`, example);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function validatePostFx(postfx, error) {
|
|
205
|
+
const example = 'Use "postfx": { "shader": "shaders/crt.frag", "uniforms": { "strength": 0.5 } }.';
|
|
206
|
+
if (!isObject(postfx))
|
|
207
|
+
return void error("E_POSTFX_NOT_OBJECT", "postfx", '"postfx" must be an object.', example);
|
|
208
|
+
if (typeof postfx.shader !== "string" || postfx.shader === "") {
|
|
209
|
+
error("E_POSTFX_SHADER", "postfx.shader", "postfx.shader must be the path of a GLSL fragment shader.", example);
|
|
210
|
+
}
|
|
211
|
+
if (postfx.uniforms !== undefined) {
|
|
212
|
+
if (!isObject(postfx.uniforms))
|
|
213
|
+
return void error("E_POSTFX_UNIFORMS", "postfx.uniforms", "postfx.uniforms must map names to numbers.", example);
|
|
214
|
+
for (const [name, value] of Object.entries(postfx.uniforms)) {
|
|
215
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || name.startsWith("u_")) {
|
|
216
|
+
error("E_POSTFX_UNIFORMS", `postfx.uniforms.${name}`, `"${name}" is not a usable uniform name. Use a GLSL identifier that doesn't start with "u_" (reserved for the engine).`);
|
|
217
|
+
}
|
|
218
|
+
else if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
219
|
+
error("E_POSTFX_UNIFORMS", `postfx.uniforms.${name}`, `postfx.uniforms.${name} must be a number, got ${describe(value)}.`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
173
224
|
function validateEffects(effects, error, warn) {
|
|
174
225
|
if (!isObject(effects)) {
|
|
175
226
|
error("E_EFFECTS_NOT_OBJECT", "effects", '"effects" must map event names to effects.', 'Use "effects": { "collect": { "sound": "coin" } }.');
|
|
@@ -178,9 +229,11 @@ function validateEffects(effects, error, warn) {
|
|
|
178
229
|
for (const [event, effect] of Object.entries(effects)) {
|
|
179
230
|
const path = `effects.${event}`;
|
|
180
231
|
if (!EVENT_TYPES.includes(event)) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
232
|
+
// Any other name is a custom event that scripts fire with world.emit(). Flag likely typos of built-in ones.
|
|
233
|
+
const suggestion = closestName(event, EVENT_TYPES);
|
|
234
|
+
if (suggestion) {
|
|
235
|
+
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(", ")}.`);
|
|
236
|
+
}
|
|
184
237
|
}
|
|
185
238
|
if (!isObject(effect)) {
|
|
186
239
|
error("E_EFFECT_NOT_OBJECT", path, `effects.${event} must be an object.`, `Use { "sound": "blip", "particles": { "count": 8 } }.`);
|
|
@@ -188,7 +241,7 @@ function validateEffects(effects, error, warn) {
|
|
|
188
241
|
}
|
|
189
242
|
validateFields(EFFECT_FIELDS, effect, path, `effects.${event}`, "", error, warn, ["particles"]);
|
|
190
243
|
if (typeof effect.sound === "string" && effect.sound !== "" && !isSoundPreset(effect.sound) && !hasExtension(effect.sound, AUDIO_EXTENSIONS)) {
|
|
191
|
-
const suggestion =
|
|
244
|
+
const suggestion = closestName(effect.sound, SOUND_PRESETS);
|
|
192
245
|
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
246
|
}
|
|
194
247
|
if (effect.particles !== undefined) {
|
|
@@ -201,6 +254,7 @@ function validateEffects(effects, error, warn) {
|
|
|
201
254
|
}
|
|
202
255
|
}
|
|
203
256
|
}
|
|
257
|
+
const SCRIPT_EXTENSIONS = [".ts", ".js"];
|
|
204
258
|
function hasExtension(path, extensions) {
|
|
205
259
|
const lower = path.toLowerCase();
|
|
206
260
|
return extensions.some((ext) => lower.endsWith(ext));
|
|
@@ -223,6 +277,8 @@ function checkField(spec, value) {
|
|
|
223
277
|
: `must be one of ${spec.values.map((v) => `"${v}"`).join(", ")}, got ${describe(value)}.`;
|
|
224
278
|
case "frames":
|
|
225
279
|
return typeof value === "string" && isFrameList(value) ? undefined : `must be a frame list like "0", "1-4" or "0,2-3", got ${describe(value)}.`;
|
|
280
|
+
case "object":
|
|
281
|
+
return isObject(value) ? undefined : `must be an object like { "speed": 80 }, got ${describe(value)}.`;
|
|
226
282
|
}
|
|
227
283
|
}
|
|
228
284
|
function fixFor(spec) {
|
|
@@ -241,7 +297,7 @@ function describe(value) {
|
|
|
241
297
|
return value === undefined ? "nothing" : JSON.stringify(value);
|
|
242
298
|
}
|
|
243
299
|
/** Returns the candidate within a small edit distance of `input`, if any. */
|
|
244
|
-
function
|
|
300
|
+
export function closestName(input, candidates) {
|
|
245
301
|
let best;
|
|
246
302
|
let bestDistance = Math.max(2, Math.floor(input.length / 3)) + 1;
|
|
247
303
|
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 type
|
|
1
|
+
import { type Body, type Camera, type Collectible, type Emitter, type Goal, type Hazard, type Patrol, type Platformer, type ScriptComponent, type Sprite, type Topdown, type Transform } from "./components.ts";
|
|
2
|
+
import { type DrawContext, 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,20 +8,37 @@ 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
|
-
spawnX: number;
|
|
12
|
-
spawnY: number;
|
|
13
14
|
}
|
|
14
15
|
export interface PatrolState extends Patrol {
|
|
15
16
|
originX: number;
|
|
16
17
|
originY: number;
|
|
17
18
|
}
|
|
19
|
+
export interface ScriptState extends ScriptComponent {
|
|
20
|
+
module: Script;
|
|
21
|
+
started: boolean;
|
|
22
|
+
/** Ids of entities touched last frame, so `touch` fires only when touching starts. */
|
|
23
|
+
touching: Set<string>;
|
|
24
|
+
}
|
|
18
25
|
export interface Entity {
|
|
19
26
|
id: string;
|
|
27
|
+
/** Script-owned data, kept between frames and included in snapshots. */
|
|
28
|
+
state: Record<string, unknown>;
|
|
29
|
+
/** Set once destroyed or collected, so stale script handles report `exists: false`. */
|
|
30
|
+
removed?: boolean;
|
|
31
|
+
script?: ScriptState;
|
|
20
32
|
transform: Transform;
|
|
21
33
|
body?: BodyState;
|
|
22
34
|
sprite?: Sprite;
|
|
23
35
|
platformer?: PlatformerState;
|
|
36
|
+
topdown?: Topdown;
|
|
37
|
+
/** Where a player respawns after dying. */
|
|
38
|
+
spawn?: {
|
|
39
|
+
x: number;
|
|
40
|
+
y: number;
|
|
41
|
+
};
|
|
24
42
|
collectible?: Collectible;
|
|
25
43
|
patrol?: PatrolState;
|
|
26
44
|
hazard?: Hazard;
|
|
@@ -58,6 +76,17 @@ export type GameEvent = {
|
|
|
58
76
|
type: "win";
|
|
59
77
|
entity: string;
|
|
60
78
|
goal: string;
|
|
79
|
+
} | {
|
|
80
|
+
type: "bounce";
|
|
81
|
+
entity: string;
|
|
82
|
+
other: string;
|
|
83
|
+
}
|
|
84
|
+
/** Fired by a script with world.emit(name, data). */
|
|
85
|
+
| {
|
|
86
|
+
type: "custom";
|
|
87
|
+
name: string;
|
|
88
|
+
entity: string;
|
|
89
|
+
data?: unknown;
|
|
61
90
|
});
|
|
62
91
|
export interface EntitySnapshot {
|
|
63
92
|
x: number;
|
|
@@ -65,6 +94,12 @@ export interface EntitySnapshot {
|
|
|
65
94
|
vx: number;
|
|
66
95
|
vy: number;
|
|
67
96
|
grounded: boolean;
|
|
97
|
+
/** Present when a script stored data on the entity. */
|
|
98
|
+
state?: Record<string, unknown>;
|
|
99
|
+
}
|
|
100
|
+
export interface WorldOptions {
|
|
101
|
+
/** Loaded script modules, keyed by the `src` path used in the scene. */
|
|
102
|
+
scripts?: ScriptModules;
|
|
68
103
|
}
|
|
69
104
|
export interface WorldSnapshot {
|
|
70
105
|
frame: number;
|
|
@@ -96,9 +131,19 @@ export declare class World {
|
|
|
96
131
|
/** Set when a player reaches a goal. The simulation stops changing after that. */
|
|
97
132
|
won: boolean;
|
|
98
133
|
private previousInput;
|
|
99
|
-
|
|
134
|
+
private readonly scripts;
|
|
135
|
+
private seed;
|
|
136
|
+
private spawnCount;
|
|
137
|
+
private readonly handles;
|
|
138
|
+
private scriptApi;
|
|
139
|
+
private scriptInput;
|
|
140
|
+
/** The script hook currently running, for error messages and event attribution. */
|
|
141
|
+
private current;
|
|
142
|
+
/** True while a draw hook runs: the game can't be changed then. */
|
|
143
|
+
private drawing;
|
|
144
|
+
constructor(scene: SceneDef, options?: WorldOptions);
|
|
100
145
|
/** Validates raw scene JSON and builds a world, throwing on errors. */
|
|
101
|
-
static fromJSON(raw: unknown): World;
|
|
146
|
+
static fromJSON(raw: unknown, options?: WorldOptions): World;
|
|
102
147
|
get time(): number;
|
|
103
148
|
get(id: string): Entity | undefined;
|
|
104
149
|
/** Advances one frame with the given actions held down. */
|
|
@@ -106,16 +151,52 @@ export declare class World {
|
|
|
106
151
|
snapshot(): WorldSnapshot;
|
|
107
152
|
private emit;
|
|
108
153
|
private remove;
|
|
154
|
+
private createEntity;
|
|
155
|
+
private updateScripts;
|
|
156
|
+
/** Calls `touch` on scripted entities that started overlapping or touching another body this frame. */
|
|
157
|
+
private updateTouches;
|
|
158
|
+
private callHook;
|
|
159
|
+
/**
|
|
160
|
+
* Runs an entity's `draw` or `drawUI` hook. Called by the renderer, never by the
|
|
161
|
+
* simulation. `self` and `world` are read-only for the duration.
|
|
162
|
+
*/
|
|
163
|
+
callDrawHook(entity: Entity, hook: "draw" | "drawUI", extras: Omit<DrawContext, "self" | "world" | "params">): void;
|
|
164
|
+
/** True when any live entity's script has the given hook. */
|
|
165
|
+
hasHook(hook: "draw" | "drawUI"): boolean;
|
|
166
|
+
private run;
|
|
167
|
+
/** Draw hooks only paint, so the browser can't drift from what tests verified. */
|
|
168
|
+
private guard;
|
|
169
|
+
/** An error caused by how a script used the API, reported against the running script. */
|
|
170
|
+
private misuse;
|
|
171
|
+
/** A stable script-facing view of an entity. */
|
|
172
|
+
private handle;
|
|
173
|
+
private resolve;
|
|
174
|
+
/** The `world` object scripts receive. */
|
|
175
|
+
private api;
|
|
176
|
+
private spawn;
|
|
177
|
+
/** mulberry32, seeded by world.seed. */
|
|
178
|
+
private random;
|
|
179
|
+
/** Entities the player controls: platformers and topdown movers. */
|
|
109
180
|
private players;
|
|
181
|
+
private updateTopdown;
|
|
110
182
|
private updatePlatformers;
|
|
111
183
|
/** Points each patrolling body toward the end of its path it is heading for. */
|
|
112
184
|
private updatePatrols;
|
|
113
185
|
private updateBodies;
|
|
186
|
+
/**
|
|
187
|
+
* The velocity along one axis after hitting `solid`: 0, or reversed and scaled by `bounce`.
|
|
188
|
+
* Slow impacts stop instead, so resting bodies don't jitter.
|
|
189
|
+
*/
|
|
190
|
+
private rebound;
|
|
191
|
+
private clampToWorld;
|
|
114
192
|
private updateCollectibles;
|
|
115
193
|
private updateHazards;
|
|
116
194
|
private updateGoals;
|
|
117
195
|
/** Sends a player back to where it started. Collected items stay collected. */
|
|
118
196
|
private kill;
|
|
119
197
|
}
|
|
120
|
-
/**
|
|
121
|
-
|
|
198
|
+
/**
|
|
199
|
+
* Strict overlap: bodies that merely touch (e.g. standing on the ground) do not overlap.
|
|
200
|
+
* A positive `margin` grows both boxes, so touching counts too.
|
|
201
|
+
*/
|
|
202
|
+
export declare function overlaps(a: Entity, b: Entity, margin?: number): boolean;
|