@ludra/core 0.4.0 → 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 +4 -0
- package/dist/browser.js +64 -8
- 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 +62 -8
- 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/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;
|
package/dist/world.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { resolveComponent, } from "./components.js";
|
|
2
|
+
import { getScript, ScriptError, } from "./script.js";
|
|
2
3
|
import { FIXED_DT } from "./time.js";
|
|
4
|
+
import { EVENT_TYPES } from "./types.js";
|
|
3
5
|
import { validateScene } from "./validate.js";
|
|
4
6
|
export class SceneValidationError extends Error {
|
|
5
7
|
diagnostics;
|
|
@@ -19,6 +21,8 @@ const STOMP_BOUNCE = 0.6;
|
|
|
19
21
|
const STOMP_TOLERANCE = 8;
|
|
20
22
|
/** A platformer this far below the bottom of the world has fallen out of it. */
|
|
21
23
|
const FALL_MARGIN = 100;
|
|
24
|
+
/** Bodies this close count as touching for `touch` hooks, so solids (doors, walls) can be touched too. */
|
|
25
|
+
const TOUCH_MARGIN = 1;
|
|
22
26
|
const EPSILON = 1e-4;
|
|
23
27
|
/**
|
|
24
28
|
* The simulation. It advances only through `step`, one fixed frame at a time,
|
|
@@ -38,19 +42,29 @@ export class World {
|
|
|
38
42
|
/** Set when a player reaches a goal. The simulation stops changing after that. */
|
|
39
43
|
won = false;
|
|
40
44
|
previousInput = new Set();
|
|
41
|
-
|
|
45
|
+
scripts;
|
|
46
|
+
seed;
|
|
47
|
+
spawnCount = 0;
|
|
48
|
+
handles = new WeakMap();
|
|
49
|
+
scriptApi;
|
|
50
|
+
scriptInput = { held: () => false, pressed: () => false };
|
|
51
|
+
/** The script hook currently running, for error messages and event attribution. */
|
|
52
|
+
current;
|
|
53
|
+
constructor(scene, options = {}) {
|
|
42
54
|
this.width = scene.world?.width ?? 960;
|
|
43
55
|
this.height = scene.world?.height ?? 540;
|
|
44
56
|
this.gravity = scene.world?.gravity ?? DEFAULT_GRAVITY;
|
|
45
57
|
this.background = scene.world?.background ?? "#1d2333";
|
|
46
|
-
this.
|
|
58
|
+
this.seed = scene.world?.seed ?? 1;
|
|
59
|
+
this.scripts = options.scripts;
|
|
60
|
+
this.entities = scene.entities.map((def) => this.createEntity(def));
|
|
47
61
|
}
|
|
48
62
|
/** Validates raw scene JSON and builds a world, throwing on errors. */
|
|
49
|
-
static fromJSON(raw) {
|
|
63
|
+
static fromJSON(raw, options = {}) {
|
|
50
64
|
const { scene, diagnostics } = validateScene(raw);
|
|
51
65
|
if (!scene)
|
|
52
66
|
throw new SceneValidationError(diagnostics);
|
|
53
|
-
return new World(scene);
|
|
67
|
+
return new World(scene, options);
|
|
54
68
|
}
|
|
55
69
|
get time() {
|
|
56
70
|
return this.frame * FIXED_DT;
|
|
@@ -66,8 +80,14 @@ export class World {
|
|
|
66
80
|
if (this.won)
|
|
67
81
|
return;
|
|
68
82
|
this.updatePlatformers(input, pressed);
|
|
83
|
+
this.updateScripts(input, pressed);
|
|
84
|
+
if (this.won)
|
|
85
|
+
return;
|
|
69
86
|
this.updatePatrols();
|
|
70
87
|
this.updateBodies();
|
|
88
|
+
this.updateTouches();
|
|
89
|
+
if (this.won)
|
|
90
|
+
return;
|
|
71
91
|
this.updateCollectibles();
|
|
72
92
|
this.updateHazards();
|
|
73
93
|
this.updateGoals();
|
|
@@ -82,6 +102,9 @@ export class World {
|
|
|
82
102
|
vy: e.body?.vy ?? 0,
|
|
83
103
|
grounded: e.body?.grounded ?? false,
|
|
84
104
|
};
|
|
105
|
+
// A JSON copy, so later frames can't change an earlier snapshot.
|
|
106
|
+
if (Object.keys(e.state).length > 0)
|
|
107
|
+
entities[e.id].state = JSON.parse(JSON.stringify(e.state));
|
|
85
108
|
}
|
|
86
109
|
return { frame: this.frame, time: this.time, score: this.score, deaths: this.deaths, won: this.won, entities };
|
|
87
110
|
}
|
|
@@ -89,8 +112,243 @@ export class World {
|
|
|
89
112
|
this.events.push({ ...event, frame: this.frame, time: this.time });
|
|
90
113
|
}
|
|
91
114
|
remove(entity) {
|
|
92
|
-
this.entities.
|
|
115
|
+
const index = this.entities.indexOf(entity);
|
|
116
|
+
if (index >= 0)
|
|
117
|
+
this.entities.splice(index, 1);
|
|
118
|
+
entity.removed = true;
|
|
119
|
+
}
|
|
120
|
+
// ---------------------------------------------------------------- scripts
|
|
121
|
+
createEntity(def) {
|
|
122
|
+
const entity = createEntity(def);
|
|
123
|
+
const component = def.components.script;
|
|
124
|
+
if (component) {
|
|
125
|
+
const resolved = resolveComponent("script", component);
|
|
126
|
+
const module = getScript(this.scripts, resolved.src);
|
|
127
|
+
if (!module) {
|
|
128
|
+
throw new ScriptError({ src: resolved.src, entity: def.id, hook: "load", frame: this.frame }, "this script is not loaded. Hosts pass loaded modules as new World(scene, { scripts }); the CLI and browser runtime do this for you.");
|
|
129
|
+
}
|
|
130
|
+
entity.script = { ...resolved, params: { ...resolved.params }, module, started: false, touching: new Set() };
|
|
131
|
+
}
|
|
132
|
+
return entity;
|
|
133
|
+
}
|
|
134
|
+
updateScripts(held, pressed) {
|
|
135
|
+
this.scriptInput = { held: (action) => held.has(action), pressed: (action) => pressed.has(action) };
|
|
136
|
+
// Iterate over a copy: scripts may spawn or destroy entities.
|
|
137
|
+
for (const e of [...this.entities]) {
|
|
138
|
+
if (!e.script || e.removed)
|
|
139
|
+
continue;
|
|
140
|
+
if (!e.script.started) {
|
|
141
|
+
e.script.started = true;
|
|
142
|
+
this.callHook(e, "start");
|
|
143
|
+
}
|
|
144
|
+
if (!e.removed && !this.won)
|
|
145
|
+
this.callHook(e, "update");
|
|
146
|
+
if (this.won)
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Calls `touch` on scripted entities that started overlapping or touching another body this frame. */
|
|
151
|
+
updateTouches() {
|
|
152
|
+
for (const e of [...this.entities]) {
|
|
153
|
+
if (!e.script?.module.touch || !e.body || e.removed)
|
|
154
|
+
continue;
|
|
155
|
+
const now = new Set();
|
|
156
|
+
for (const other of this.entities) {
|
|
157
|
+
if (other !== e && other.body && overlaps(e, other, TOUCH_MARGIN))
|
|
158
|
+
now.add(other.id);
|
|
159
|
+
}
|
|
160
|
+
for (const id of now) {
|
|
161
|
+
const other = this.get(id);
|
|
162
|
+
if (other && !e.script.touching.has(id) && !e.removed && !this.won)
|
|
163
|
+
this.callHook(e, "touch", this.handle(other));
|
|
164
|
+
}
|
|
165
|
+
e.script.touching = now;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
callHook(entity, hook, other) {
|
|
169
|
+
const script = entity.script;
|
|
170
|
+
const fn = script.module[hook];
|
|
171
|
+
if (!fn)
|
|
172
|
+
return;
|
|
173
|
+
const ctx = { self: this.handle(entity), world: this.api(), input: this.scriptInput, params: script.params, dt: FIXED_DT };
|
|
174
|
+
this.current = { entity, hook };
|
|
175
|
+
try {
|
|
176
|
+
fn.call(script.module, ctx, other);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
if (err instanceof ScriptError)
|
|
180
|
+
throw err;
|
|
181
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
182
|
+
throw new ScriptError({ src: script.src, entity: entity.id, hook, frame: this.frame }, message, { cause: err });
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
this.current = undefined;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** An error caused by how a script used the API, reported against the running script. */
|
|
189
|
+
misuse(message) {
|
|
190
|
+
const running = this.current;
|
|
191
|
+
return new ScriptError({ src: running?.entity.script?.src ?? "?", entity: running?.entity.id ?? "?", hook: running?.hook ?? "?", frame: this.frame }, message);
|
|
93
192
|
}
|
|
193
|
+
/** A stable script-facing view of an entity. */
|
|
194
|
+
handle(e) {
|
|
195
|
+
let handle = this.handles.get(e);
|
|
196
|
+
if (handle)
|
|
197
|
+
return handle;
|
|
198
|
+
const world = this;
|
|
199
|
+
handle = {
|
|
200
|
+
get id() {
|
|
201
|
+
return e.id;
|
|
202
|
+
},
|
|
203
|
+
get x() {
|
|
204
|
+
return e.transform.x;
|
|
205
|
+
},
|
|
206
|
+
set x(value) {
|
|
207
|
+
e.transform.x = value;
|
|
208
|
+
},
|
|
209
|
+
get y() {
|
|
210
|
+
return e.transform.y;
|
|
211
|
+
},
|
|
212
|
+
set y(value) {
|
|
213
|
+
e.transform.y = value;
|
|
214
|
+
},
|
|
215
|
+
get vx() {
|
|
216
|
+
return e.body?.vx ?? 0;
|
|
217
|
+
},
|
|
218
|
+
set vx(value) {
|
|
219
|
+
if (e.body)
|
|
220
|
+
e.body.vx = value;
|
|
221
|
+
},
|
|
222
|
+
get vy() {
|
|
223
|
+
return e.body?.vy ?? 0;
|
|
224
|
+
},
|
|
225
|
+
set vy(value) {
|
|
226
|
+
if (e.body)
|
|
227
|
+
e.body.vy = value;
|
|
228
|
+
},
|
|
229
|
+
get grounded() {
|
|
230
|
+
return e.body?.grounded ?? false;
|
|
231
|
+
},
|
|
232
|
+
get width() {
|
|
233
|
+
return e.body?.width ?? e.sprite?.width ?? 0;
|
|
234
|
+
},
|
|
235
|
+
get height() {
|
|
236
|
+
return e.body?.height ?? e.sprite?.height ?? 0;
|
|
237
|
+
},
|
|
238
|
+
get exists() {
|
|
239
|
+
return !e.removed;
|
|
240
|
+
},
|
|
241
|
+
get state() {
|
|
242
|
+
return e.state;
|
|
243
|
+
},
|
|
244
|
+
has(component) {
|
|
245
|
+
return component === "transform" || (component in e && e[component] !== undefined);
|
|
246
|
+
},
|
|
247
|
+
touches(other) {
|
|
248
|
+
const target = world.get(typeof other === "string" ? other : other.id);
|
|
249
|
+
return !!target && target !== e && !e.removed && overlaps(e, target, TOUCH_MARGIN);
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
this.handles.set(e, handle);
|
|
253
|
+
return handle;
|
|
254
|
+
}
|
|
255
|
+
resolve(target) {
|
|
256
|
+
return this.get(typeof target === "string" ? target : target.id);
|
|
257
|
+
}
|
|
258
|
+
/** The `world` object scripts receive. */
|
|
259
|
+
api() {
|
|
260
|
+
if (this.scriptApi)
|
|
261
|
+
return this.scriptApi;
|
|
262
|
+
const world = this;
|
|
263
|
+
this.scriptApi = {
|
|
264
|
+
get time() {
|
|
265
|
+
return world.time;
|
|
266
|
+
},
|
|
267
|
+
get frame() {
|
|
268
|
+
return world.frame;
|
|
269
|
+
},
|
|
270
|
+
get width() {
|
|
271
|
+
return world.width;
|
|
272
|
+
},
|
|
273
|
+
get height() {
|
|
274
|
+
return world.height;
|
|
275
|
+
},
|
|
276
|
+
get score() {
|
|
277
|
+
return world.score;
|
|
278
|
+
},
|
|
279
|
+
set score(value) {
|
|
280
|
+
world.score = value;
|
|
281
|
+
},
|
|
282
|
+
get: (id) => {
|
|
283
|
+
const e = world.get(id);
|
|
284
|
+
return e ? world.handle(e) : undefined;
|
|
285
|
+
},
|
|
286
|
+
all: (component) => world.entities.filter((e) => !component || world.handle(e).has(component)).map((e) => world.handle(e)),
|
|
287
|
+
player: () => {
|
|
288
|
+
const e = world.entities.find((candidate) => candidate.platformer);
|
|
289
|
+
return e ? world.handle(e) : undefined;
|
|
290
|
+
},
|
|
291
|
+
spawn: (def) => world.spawn(def),
|
|
292
|
+
destroy: (target) => {
|
|
293
|
+
const e = world.resolve(target);
|
|
294
|
+
if (e)
|
|
295
|
+
world.remove(e);
|
|
296
|
+
},
|
|
297
|
+
kill: (target) => {
|
|
298
|
+
const e = world.resolve(target);
|
|
299
|
+
if (!e)
|
|
300
|
+
return;
|
|
301
|
+
if (e.platformer && e.body)
|
|
302
|
+
world.kill(e, world.current?.entity.id ?? "script");
|
|
303
|
+
else
|
|
304
|
+
world.remove(e);
|
|
305
|
+
},
|
|
306
|
+
win: () => {
|
|
307
|
+
if (world.won)
|
|
308
|
+
return;
|
|
309
|
+
const by = world.current?.entity;
|
|
310
|
+
world.won = true;
|
|
311
|
+
world.emit({ type: "win", entity: by?.id ?? "script", goal: by?.id ?? "script", x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
|
|
312
|
+
},
|
|
313
|
+
emit: (name, data) => {
|
|
314
|
+
if (typeof name !== "string" || name === "")
|
|
315
|
+
throw world.misuse("world.emit needs a non-empty event name.");
|
|
316
|
+
if (EVENT_TYPES.includes(name)) {
|
|
317
|
+
throw world.misuse(`"${name}" is a built-in event and can't be emitted by scripts. Pick another name, e.g. "${name}-custom".`);
|
|
318
|
+
}
|
|
319
|
+
const by = world.current?.entity;
|
|
320
|
+
world.emit({ type: "custom", name, entity: by?.id ?? "script", data, x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
|
|
321
|
+
},
|
|
322
|
+
random: () => world.random(),
|
|
323
|
+
};
|
|
324
|
+
return this.scriptApi;
|
|
325
|
+
}
|
|
326
|
+
spawn(def) {
|
|
327
|
+
if (typeof def !== "object" || def === null || typeof def.components !== "object") {
|
|
328
|
+
throw this.misuse('world.spawn needs { components: { ... } }, e.g. { components: { transform: { x: 0, y: 0 } } }.');
|
|
329
|
+
}
|
|
330
|
+
const id = def.id ?? `${this.current?.entity.id ?? "spawned"}-${++this.spawnCount}`;
|
|
331
|
+
if (this.get(id))
|
|
332
|
+
throw this.misuse(`world.spawn: an entity with id "${id}" already exists. Leave id out to get a unique one.`);
|
|
333
|
+
const { diagnostics } = validateScene({ entities: [{ id, components: def.components }] });
|
|
334
|
+
const errors = diagnostics.filter((d) => d.severity === "error" && d.code !== "E_GOAL_UNREACHABLE");
|
|
335
|
+
if (errors.length > 0) {
|
|
336
|
+
const details = errors.map((d) => `${d.path.replace("entities[0]", id)}: ${d.message}${d.fix ? ` Fix: ${d.fix}` : ""}`);
|
|
337
|
+
throw this.misuse(`world.spawn got an invalid entity. ${details.join(" ")}`);
|
|
338
|
+
}
|
|
339
|
+
const entity = this.createEntity({ id, components: def.components });
|
|
340
|
+
this.entities.push(entity);
|
|
341
|
+
return this.handle(entity);
|
|
342
|
+
}
|
|
343
|
+
/** mulberry32, seeded by world.seed. */
|
|
344
|
+
random() {
|
|
345
|
+
this.seed = (this.seed + 0x6d2b79f5) | 0;
|
|
346
|
+
let t = this.seed;
|
|
347
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
348
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
349
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
350
|
+
}
|
|
351
|
+
// ------------------------------------------------------------ built-ins
|
|
94
352
|
players() {
|
|
95
353
|
return this.entities.filter((e) => e.platformer && e.body);
|
|
96
354
|
}
|
|
@@ -111,11 +369,15 @@ export class World {
|
|
|
111
369
|
b.standingOn = undefined;
|
|
112
370
|
p.bufferTimer = 0;
|
|
113
371
|
p.coyoteTimer = 0;
|
|
372
|
+
p.jumping = true;
|
|
114
373
|
this.emit({ type: "jump", entity: e.id, ...feet(e) });
|
|
115
374
|
}
|
|
116
|
-
else if (p.variableJump && !held.has("jump") && b.vy < -p.jumpSpeed * JUMP_CUT) {
|
|
375
|
+
else if (p.jumping && p.variableJump && !held.has("jump") && b.vy < -p.jumpSpeed * JUMP_CUT) {
|
|
376
|
+
// Only the player's own jumps are cut short; launches from pads, stomps or scripts are not.
|
|
117
377
|
b.vy = -p.jumpSpeed * JUMP_CUT;
|
|
118
378
|
}
|
|
379
|
+
if (b.vy >= 0)
|
|
380
|
+
p.jumping = false;
|
|
119
381
|
p.coyoteTimer = Math.max(0, p.coyoteTimer - FIXED_DT);
|
|
120
382
|
p.bufferTimer = Math.max(0, p.bufferTimer - FIXED_DT);
|
|
121
383
|
}
|
|
@@ -257,12 +519,13 @@ export class World {
|
|
|
257
519
|
b.standingOn = undefined;
|
|
258
520
|
p.coyoteTimer = 0;
|
|
259
521
|
p.bufferTimer = 0;
|
|
522
|
+
p.jumping = false;
|
|
260
523
|
}
|
|
261
524
|
}
|
|
262
525
|
function createEntity(def) {
|
|
263
526
|
const c = def.components;
|
|
264
527
|
const transform = c.transform ? resolveComponent("transform", c.transform) : { x: 0, y: 0 };
|
|
265
|
-
const entity = { id: def.id, transform };
|
|
528
|
+
const entity = { id: def.id, state: {}, transform };
|
|
266
529
|
if (c.body)
|
|
267
530
|
entity.body = { ...resolveComponent("body", c.body), grounded: false };
|
|
268
531
|
if (c.sprite)
|
|
@@ -272,6 +535,7 @@ function createEntity(def) {
|
|
|
272
535
|
...resolveComponent("platformer", c.platformer),
|
|
273
536
|
coyoteTimer: 0,
|
|
274
537
|
bufferTimer: 0,
|
|
538
|
+
jumping: false,
|
|
275
539
|
spawnX: transform.x,
|
|
276
540
|
spawnY: transform.y,
|
|
277
541
|
};
|
|
@@ -308,10 +572,13 @@ function isStomp(player, enemy) {
|
|
|
308
572
|
const enemyTop = enemy.transform.y - enemy.body.height / 2;
|
|
309
573
|
return previousBottom <= enemyTop + STOMP_TOLERANCE;
|
|
310
574
|
}
|
|
311
|
-
/**
|
|
312
|
-
|
|
575
|
+
/**
|
|
576
|
+
* Strict overlap: bodies that merely touch (e.g. standing on the ground) do not overlap.
|
|
577
|
+
* A positive `margin` grows both boxes, so touching counts too.
|
|
578
|
+
*/
|
|
579
|
+
export function overlaps(a, b, margin = 0) {
|
|
313
580
|
if (!a.body || !b.body)
|
|
314
581
|
return false;
|
|
315
|
-
return (Math.abs(a.transform.x - b.transform.x) < (a.body.width + b.body.width) / 2 - EPSILON &&
|
|
316
|
-
Math.abs(a.transform.y - b.transform.y) < (a.body.height + b.body.height) / 2 - EPSILON);
|
|
582
|
+
return (Math.abs(a.transform.x - b.transform.x) < (a.body.width + b.body.width) / 2 + margin - EPSILON &&
|
|
583
|
+
Math.abs(a.transform.y - b.transform.y) < (a.body.height + b.body.height) / 2 + margin - EPSILON);
|
|
317
584
|
}
|