@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/src/validate.ts
CHANGED
|
@@ -44,6 +44,9 @@ export function validateScene(raw: unknown): ValidationResult {
|
|
|
44
44
|
error("E_INVALID_WORLD", `world.${key}`, `world.${key} must be a non-negative number, got ${describe(value)}.`);
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
+
if (raw.world.seed !== undefined && (typeof raw.world.seed !== "number" || !Number.isFinite(raw.world.seed))) {
|
|
48
|
+
error("E_INVALID_WORLD", "world.seed", `world.seed must be a number, got ${describe(raw.world.seed)}.`);
|
|
49
|
+
}
|
|
47
50
|
if (raw.world.background !== undefined && typeof raw.world.background !== "string") {
|
|
48
51
|
error("E_INVALID_WORLD", "world.background", `world.background must be a CSS color string, got ${describe(raw.world.background)}.`);
|
|
49
52
|
}
|
|
@@ -94,7 +97,7 @@ export function validateScene(raw: unknown): ValidationResult {
|
|
|
94
97
|
for (const [name, value] of Object.entries(components)) {
|
|
95
98
|
const componentPath = `${path}.components.${name}`;
|
|
96
99
|
if (!isComponentName(name)) {
|
|
97
|
-
const suggestion =
|
|
100
|
+
const suggestion = closestName(name, COMPONENT_NAMES);
|
|
98
101
|
error(
|
|
99
102
|
"E_UNKNOWN_COMPONENT",
|
|
100
103
|
componentPath,
|
|
@@ -135,6 +138,16 @@ export function validateScene(raw: unknown): ValidationResult {
|
|
|
135
138
|
);
|
|
136
139
|
}
|
|
137
140
|
|
|
141
|
+
const script = isObject(components.script) ? components.script : undefined;
|
|
142
|
+
if (script && typeof script.src === "string" && !hasExtension(script.src, SCRIPT_EXTENSIONS)) {
|
|
143
|
+
error(
|
|
144
|
+
"E_INVALID_SCRIPT_PATH",
|
|
145
|
+
`${path}.components.script.src`,
|
|
146
|
+
`"${script.src}" on entity ${label} is not a script file.`,
|
|
147
|
+
'Use a path ending in .ts or .js, e.g. "scripts/chase.ts".',
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
138
151
|
const body = isObject(components.body) ? components.body : undefined;
|
|
139
152
|
const bodyType = body?.type ?? "static";
|
|
140
153
|
if ("platformer" in components && body && bodyType !== "dynamic") {
|
|
@@ -229,7 +242,7 @@ function validateFields(
|
|
|
229
242
|
if (ignore.includes(field)) continue;
|
|
230
243
|
const spec = fields[field];
|
|
231
244
|
if (!spec) {
|
|
232
|
-
const suggestion =
|
|
245
|
+
const suggestion = closestName(field, Object.keys(fields));
|
|
233
246
|
warn(
|
|
234
247
|
"W_UNKNOWN_FIELD",
|
|
235
248
|
`${path}.${field}`,
|
|
@@ -258,9 +271,16 @@ function validateEffects(effects: unknown, error: Report, warn: Report): void {
|
|
|
258
271
|
for (const [event, effect] of Object.entries(effects)) {
|
|
259
272
|
const path = `effects.${event}`;
|
|
260
273
|
if (!(EVENT_TYPES as readonly string[]).includes(event)) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
274
|
+
// Any other name is a custom event that scripts fire with world.emit(). Flag likely typos of built-in ones.
|
|
275
|
+
const suggestion = closestName(event, EVENT_TYPES);
|
|
276
|
+
if (suggestion) {
|
|
277
|
+
warn(
|
|
278
|
+
"W_EVENT_LOOKS_LIKE_TYPO",
|
|
279
|
+
path,
|
|
280
|
+
`"${event}" is not a built-in event, so it only fires if a script calls world.emit("${event}").`,
|
|
281
|
+
`Did you mean "${suggestion}"? Built-in events: ${EVENT_TYPES.join(", ")}.`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
264
284
|
}
|
|
265
285
|
if (!isObject(effect)) {
|
|
266
286
|
error("E_EFFECT_NOT_OBJECT", path, `effects.${event} must be an object.`, `Use { "sound": "blip", "particles": { "count": 8 } }.`);
|
|
@@ -268,7 +288,7 @@ function validateEffects(effects: unknown, error: Report, warn: Report): void {
|
|
|
268
288
|
}
|
|
269
289
|
validateFields(EFFECT_FIELDS, effect, path, `effects.${event}`, "", error, warn, ["particles"]);
|
|
270
290
|
if (typeof effect.sound === "string" && effect.sound !== "" && !isSoundPreset(effect.sound) && !hasExtension(effect.sound, AUDIO_EXTENSIONS)) {
|
|
271
|
-
const suggestion =
|
|
291
|
+
const suggestion = closestName(effect.sound, SOUND_PRESETS);
|
|
272
292
|
error(
|
|
273
293
|
"E_INVALID_SOUND",
|
|
274
294
|
`${path}.sound`,
|
|
@@ -286,6 +306,8 @@ function validateEffects(effects: unknown, error: Report, warn: Report): void {
|
|
|
286
306
|
}
|
|
287
307
|
}
|
|
288
308
|
|
|
309
|
+
const SCRIPT_EXTENSIONS = [".ts", ".js"] as const;
|
|
310
|
+
|
|
289
311
|
function hasExtension(path: string, extensions: readonly string[]): boolean {
|
|
290
312
|
const lower = path.toLowerCase();
|
|
291
313
|
return extensions.some((ext) => lower.endsWith(ext));
|
|
@@ -307,6 +329,8 @@ function checkField(spec: FieldSpec, value: unknown): string | undefined {
|
|
|
307
329
|
: `must be one of ${spec.values.map((v) => `"${v}"`).join(", ")}, got ${describe(value)}.`;
|
|
308
330
|
case "frames":
|
|
309
331
|
return typeof value === "string" && isFrameList(value) ? undefined : `must be a frame list like "0", "1-4" or "0,2-3", got ${describe(value)}.`;
|
|
332
|
+
case "object":
|
|
333
|
+
return isObject(value) ? undefined : `must be an object like { "speed": 80 }, got ${describe(value)}.`;
|
|
310
334
|
}
|
|
311
335
|
}
|
|
312
336
|
|
|
@@ -326,7 +350,7 @@ function describe(value: unknown): string {
|
|
|
326
350
|
}
|
|
327
351
|
|
|
328
352
|
/** Returns the candidate within a small edit distance of `input`, if any. */
|
|
329
|
-
function
|
|
353
|
+
export function closestName(input: string, candidates: readonly string[]): string | undefined {
|
|
330
354
|
let best: string | undefined;
|
|
331
355
|
let bestDistance = Math.max(2, Math.floor(input.length / 3)) + 1;
|
|
332
356
|
for (const candidate of candidates) {
|
package/src/world.ts
CHANGED
|
@@ -8,11 +8,23 @@ import {
|
|
|
8
8
|
type Hazard,
|
|
9
9
|
type Patrol,
|
|
10
10
|
type Platformer,
|
|
11
|
+
type ScriptComponent,
|
|
11
12
|
type Sprite,
|
|
12
13
|
type Transform,
|
|
13
14
|
} from "./components.ts";
|
|
15
|
+
import {
|
|
16
|
+
getScript,
|
|
17
|
+
ScriptError,
|
|
18
|
+
type Script,
|
|
19
|
+
type ScriptContext,
|
|
20
|
+
type ScriptEntity,
|
|
21
|
+
type ScriptInput,
|
|
22
|
+
type ScriptModules,
|
|
23
|
+
type ScriptWorld,
|
|
24
|
+
type SpawnDef,
|
|
25
|
+
} from "./script.ts";
|
|
14
26
|
import { FIXED_DT } from "./time.ts";
|
|
15
|
-
import type
|
|
27
|
+
import { EVENT_TYPES, type Action, type Diagnostic, type EntityDef, type SceneDef } from "./types.ts";
|
|
16
28
|
import { validateScene } from "./validate.ts";
|
|
17
29
|
|
|
18
30
|
export interface BodyState extends Body {
|
|
@@ -23,6 +35,8 @@ export interface BodyState extends Body {
|
|
|
23
35
|
|
|
24
36
|
export interface PlatformerState extends Platformer {
|
|
25
37
|
coyoteTimer: number;
|
|
38
|
+
/** True from a player-initiated jump until the apex, so only those jumps are cut short on release. */
|
|
39
|
+
jumping: boolean;
|
|
26
40
|
bufferTimer: number;
|
|
27
41
|
spawnX: number;
|
|
28
42
|
spawnY: number;
|
|
@@ -33,8 +47,20 @@ export interface PatrolState extends Patrol {
|
|
|
33
47
|
originY: number;
|
|
34
48
|
}
|
|
35
49
|
|
|
50
|
+
export interface ScriptState extends ScriptComponent {
|
|
51
|
+
module: Script;
|
|
52
|
+
started: boolean;
|
|
53
|
+
/** Ids of entities touched last frame, so `touch` fires only when touching starts. */
|
|
54
|
+
touching: Set<string>;
|
|
55
|
+
}
|
|
56
|
+
|
|
36
57
|
export interface Entity {
|
|
37
58
|
id: string;
|
|
59
|
+
/** Script-owned data, kept between frames and included in snapshots. */
|
|
60
|
+
state: Record<string, unknown>;
|
|
61
|
+
/** Set once destroyed or collected, so stale script handles report `exists: false`. */
|
|
62
|
+
removed?: boolean;
|
|
63
|
+
script?: ScriptState;
|
|
38
64
|
transform: Transform;
|
|
39
65
|
body?: BodyState;
|
|
40
66
|
sprite?: Sprite;
|
|
@@ -55,6 +81,8 @@ export type GameEvent = { frame: number; time: number; x: number; y: number } &
|
|
|
55
81
|
| { type: "die"; entity: string; cause: string }
|
|
56
82
|
| { type: "stomp"; entity: string; enemy: string; score: number }
|
|
57
83
|
| { type: "win"; entity: string; goal: string }
|
|
84
|
+
/** Fired by a script with world.emit(name, data). */
|
|
85
|
+
| { type: "custom"; name: string; entity: string; data?: unknown }
|
|
58
86
|
);
|
|
59
87
|
|
|
60
88
|
type EventPayload = GameEvent extends infer E ? (E extends GameEvent ? Omit<E, "frame" | "time"> : never) : never;
|
|
@@ -65,6 +93,13 @@ export interface EntitySnapshot {
|
|
|
65
93
|
vx: number;
|
|
66
94
|
vy: number;
|
|
67
95
|
grounded: boolean;
|
|
96
|
+
/** Present when a script stored data on the entity. */
|
|
97
|
+
state?: Record<string, unknown>;
|
|
98
|
+
}
|
|
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
|
|
|
70
105
|
export interface WorldSnapshot {
|
|
@@ -96,6 +131,8 @@ const STOMP_BOUNCE = 0.6;
|
|
|
96
131
|
const STOMP_TOLERANCE = 8;
|
|
97
132
|
/** A platformer this far below the bottom of the world has fallen out of it. */
|
|
98
133
|
const FALL_MARGIN = 100;
|
|
134
|
+
/** Bodies this close count as touching for `touch` hooks, so solids (doors, walls) can be touched too. */
|
|
135
|
+
const TOUCH_MARGIN = 1;
|
|
99
136
|
const EPSILON = 1e-4;
|
|
100
137
|
|
|
101
138
|
/**
|
|
@@ -116,20 +153,30 @@ export class World {
|
|
|
116
153
|
/** Set when a player reaches a goal. The simulation stops changing after that. */
|
|
117
154
|
won = false;
|
|
118
155
|
private previousInput: ReadonlySet<Action> = new Set();
|
|
119
|
-
|
|
120
|
-
|
|
156
|
+
private readonly scripts: ScriptModules | undefined;
|
|
157
|
+
private seed: number;
|
|
158
|
+
private spawnCount = 0;
|
|
159
|
+
private readonly handles = new WeakMap<Entity, ScriptEntity>();
|
|
160
|
+
private scriptApi: ScriptWorld | undefined;
|
|
161
|
+
private scriptInput: ScriptInput = { held: () => false, pressed: () => false };
|
|
162
|
+
/** The script hook currently running, for error messages and event attribution. */
|
|
163
|
+
private current: { entity: Entity; hook: string } | undefined;
|
|
164
|
+
|
|
165
|
+
constructor(scene: SceneDef, options: WorldOptions = {}) {
|
|
121
166
|
this.width = scene.world?.width ?? 960;
|
|
122
167
|
this.height = scene.world?.height ?? 540;
|
|
123
168
|
this.gravity = scene.world?.gravity ?? DEFAULT_GRAVITY;
|
|
124
169
|
this.background = scene.world?.background ?? "#1d2333";
|
|
125
|
-
this.
|
|
170
|
+
this.seed = scene.world?.seed ?? 1;
|
|
171
|
+
this.scripts = options.scripts;
|
|
172
|
+
this.entities = scene.entities.map((def) => this.createEntity(def));
|
|
126
173
|
}
|
|
127
174
|
|
|
128
175
|
/** Validates raw scene JSON and builds a world, throwing on errors. */
|
|
129
|
-
static fromJSON(raw: unknown): World {
|
|
176
|
+
static fromJSON(raw: unknown, options: WorldOptions = {}): World {
|
|
130
177
|
const { scene, diagnostics } = validateScene(raw);
|
|
131
178
|
if (!scene) throw new SceneValidationError(diagnostics);
|
|
132
|
-
return new World(scene);
|
|
179
|
+
return new World(scene, options);
|
|
133
180
|
}
|
|
134
181
|
|
|
135
182
|
get time(): number {
|
|
@@ -148,8 +195,12 @@ export class World {
|
|
|
148
195
|
if (this.won) return;
|
|
149
196
|
|
|
150
197
|
this.updatePlatformers(input, pressed);
|
|
198
|
+
this.updateScripts(input, pressed);
|
|
199
|
+
if (this.won) return;
|
|
151
200
|
this.updatePatrols();
|
|
152
201
|
this.updateBodies();
|
|
202
|
+
this.updateTouches();
|
|
203
|
+
if (this.won) return;
|
|
153
204
|
this.updateCollectibles();
|
|
154
205
|
this.updateHazards();
|
|
155
206
|
this.updateGoals();
|
|
@@ -165,6 +216,8 @@ export class World {
|
|
|
165
216
|
vy: e.body?.vy ?? 0,
|
|
166
217
|
grounded: e.body?.grounded ?? false,
|
|
167
218
|
};
|
|
219
|
+
// A JSON copy, so later frames can't change an earlier snapshot.
|
|
220
|
+
if (Object.keys(e.state).length > 0) entities[e.id]!.state = JSON.parse(JSON.stringify(e.state));
|
|
168
221
|
}
|
|
169
222
|
return { frame: this.frame, time: this.time, score: this.score, deaths: this.deaths, won: this.won, entities };
|
|
170
223
|
}
|
|
@@ -174,9 +227,241 @@ export class World {
|
|
|
174
227
|
}
|
|
175
228
|
|
|
176
229
|
private remove(entity: Entity): void {
|
|
177
|
-
this.entities.
|
|
230
|
+
const index = this.entities.indexOf(entity);
|
|
231
|
+
if (index >= 0) this.entities.splice(index, 1);
|
|
232
|
+
entity.removed = true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------- scripts
|
|
236
|
+
|
|
237
|
+
private createEntity(def: EntityDef): Entity {
|
|
238
|
+
const entity = createEntity(def);
|
|
239
|
+
const component = def.components.script;
|
|
240
|
+
if (component) {
|
|
241
|
+
const resolved = resolveComponent<ScriptComponent>("script", component);
|
|
242
|
+
const module = getScript(this.scripts, resolved.src);
|
|
243
|
+
if (!module) {
|
|
244
|
+
throw new ScriptError(
|
|
245
|
+
{ src: resolved.src, entity: def.id, hook: "load", frame: this.frame },
|
|
246
|
+
"this script is not loaded. Hosts pass loaded modules as new World(scene, { scripts }); the CLI and browser runtime do this for you.",
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
entity.script = { ...resolved, params: { ...resolved.params }, module, started: false, touching: new Set() };
|
|
250
|
+
}
|
|
251
|
+
return entity;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private updateScripts(held: ReadonlySet<Action>, pressed: ReadonlySet<Action>): void {
|
|
255
|
+
this.scriptInput = { held: (action) => held.has(action), pressed: (action) => pressed.has(action) };
|
|
256
|
+
// Iterate over a copy: scripts may spawn or destroy entities.
|
|
257
|
+
for (const e of [...this.entities]) {
|
|
258
|
+
if (!e.script || e.removed) continue;
|
|
259
|
+
if (!e.script.started) {
|
|
260
|
+
e.script.started = true;
|
|
261
|
+
this.callHook(e, "start");
|
|
262
|
+
}
|
|
263
|
+
if (!e.removed && !this.won) this.callHook(e, "update");
|
|
264
|
+
if (this.won) return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Calls `touch` on scripted entities that started overlapping or touching another body this frame. */
|
|
269
|
+
private updateTouches(): void {
|
|
270
|
+
for (const e of [...this.entities]) {
|
|
271
|
+
if (!e.script?.module.touch || !e.body || e.removed) continue;
|
|
272
|
+
const now = new Set<string>();
|
|
273
|
+
for (const other of this.entities) {
|
|
274
|
+
if (other !== e && other.body && overlaps(e, other, TOUCH_MARGIN)) now.add(other.id);
|
|
275
|
+
}
|
|
276
|
+
for (const id of now) {
|
|
277
|
+
const other = this.get(id);
|
|
278
|
+
if (other && !e.script.touching.has(id) && !e.removed && !this.won) this.callHook(e, "touch", this.handle(other));
|
|
279
|
+
}
|
|
280
|
+
e.script.touching = now;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private callHook(entity: Entity, hook: "start" | "update" | "touch", other?: ScriptEntity): void {
|
|
285
|
+
const script = entity.script!;
|
|
286
|
+
const fn = script.module[hook];
|
|
287
|
+
if (!fn) return;
|
|
288
|
+
const ctx: ScriptContext = { self: this.handle(entity), world: this.api(), input: this.scriptInput, params: script.params, dt: FIXED_DT };
|
|
289
|
+
this.current = { entity, hook };
|
|
290
|
+
try {
|
|
291
|
+
(fn as (ctx: ScriptContext, other?: ScriptEntity) => void).call(script.module, ctx, other);
|
|
292
|
+
} catch (err) {
|
|
293
|
+
if (err instanceof ScriptError) throw err;
|
|
294
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
295
|
+
throw new ScriptError({ src: script.src, entity: entity.id, hook, frame: this.frame }, message, { cause: err });
|
|
296
|
+
} finally {
|
|
297
|
+
this.current = undefined;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** An error caused by how a script used the API, reported against the running script. */
|
|
302
|
+
private misuse(message: string): ScriptError {
|
|
303
|
+
const running = this.current;
|
|
304
|
+
return new ScriptError(
|
|
305
|
+
{ src: running?.entity.script?.src ?? "?", entity: running?.entity.id ?? "?", hook: running?.hook ?? "?", frame: this.frame },
|
|
306
|
+
message,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** A stable script-facing view of an entity. */
|
|
311
|
+
private handle(e: Entity): ScriptEntity {
|
|
312
|
+
let handle = this.handles.get(e);
|
|
313
|
+
if (handle) return handle;
|
|
314
|
+
const world = this;
|
|
315
|
+
handle = {
|
|
316
|
+
get id() {
|
|
317
|
+
return e.id;
|
|
318
|
+
},
|
|
319
|
+
get x() {
|
|
320
|
+
return e.transform.x;
|
|
321
|
+
},
|
|
322
|
+
set x(value: number) {
|
|
323
|
+
e.transform.x = value;
|
|
324
|
+
},
|
|
325
|
+
get y() {
|
|
326
|
+
return e.transform.y;
|
|
327
|
+
},
|
|
328
|
+
set y(value: number) {
|
|
329
|
+
e.transform.y = value;
|
|
330
|
+
},
|
|
331
|
+
get vx() {
|
|
332
|
+
return e.body?.vx ?? 0;
|
|
333
|
+
},
|
|
334
|
+
set vx(value: number) {
|
|
335
|
+
if (e.body) e.body.vx = value;
|
|
336
|
+
},
|
|
337
|
+
get vy() {
|
|
338
|
+
return e.body?.vy ?? 0;
|
|
339
|
+
},
|
|
340
|
+
set vy(value: number) {
|
|
341
|
+
if (e.body) e.body.vy = value;
|
|
342
|
+
},
|
|
343
|
+
get grounded() {
|
|
344
|
+
return e.body?.grounded ?? false;
|
|
345
|
+
},
|
|
346
|
+
get width() {
|
|
347
|
+
return e.body?.width ?? e.sprite?.width ?? 0;
|
|
348
|
+
},
|
|
349
|
+
get height() {
|
|
350
|
+
return e.body?.height ?? e.sprite?.height ?? 0;
|
|
351
|
+
},
|
|
352
|
+
get exists() {
|
|
353
|
+
return !e.removed;
|
|
354
|
+
},
|
|
355
|
+
get state() {
|
|
356
|
+
return e.state;
|
|
357
|
+
},
|
|
358
|
+
has(component: string) {
|
|
359
|
+
return component === "transform" || (component in e && (e as unknown as Record<string, unknown>)[component] !== undefined);
|
|
360
|
+
},
|
|
361
|
+
touches(other: ScriptEntity | string) {
|
|
362
|
+
const target = world.get(typeof other === "string" ? other : other.id);
|
|
363
|
+
return !!target && target !== e && !e.removed && overlaps(e, target, TOUCH_MARGIN);
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
this.handles.set(e, handle);
|
|
367
|
+
return handle;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private resolve(target: ScriptEntity | string): Entity | undefined {
|
|
371
|
+
return this.get(typeof target === "string" ? target : target.id);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** The `world` object scripts receive. */
|
|
375
|
+
private api(): ScriptWorld {
|
|
376
|
+
if (this.scriptApi) return this.scriptApi;
|
|
377
|
+
const world = this;
|
|
378
|
+
this.scriptApi = {
|
|
379
|
+
get time() {
|
|
380
|
+
return world.time;
|
|
381
|
+
},
|
|
382
|
+
get frame() {
|
|
383
|
+
return world.frame;
|
|
384
|
+
},
|
|
385
|
+
get width() {
|
|
386
|
+
return world.width;
|
|
387
|
+
},
|
|
388
|
+
get height() {
|
|
389
|
+
return world.height;
|
|
390
|
+
},
|
|
391
|
+
get score() {
|
|
392
|
+
return world.score;
|
|
393
|
+
},
|
|
394
|
+
set score(value: number) {
|
|
395
|
+
world.score = value;
|
|
396
|
+
},
|
|
397
|
+
get: (id) => {
|
|
398
|
+
const e = world.get(id);
|
|
399
|
+
return e ? world.handle(e) : undefined;
|
|
400
|
+
},
|
|
401
|
+
all: (component) =>
|
|
402
|
+
world.entities.filter((e) => !component || world.handle(e).has(component)).map((e) => world.handle(e)),
|
|
403
|
+
player: () => {
|
|
404
|
+
const e = world.entities.find((candidate) => candidate.platformer);
|
|
405
|
+
return e ? world.handle(e) : undefined;
|
|
406
|
+
},
|
|
407
|
+
spawn: (def) => world.spawn(def),
|
|
408
|
+
destroy: (target) => {
|
|
409
|
+
const e = world.resolve(target);
|
|
410
|
+
if (e) world.remove(e);
|
|
411
|
+
},
|
|
412
|
+
kill: (target) => {
|
|
413
|
+
const e = world.resolve(target);
|
|
414
|
+
if (!e) return;
|
|
415
|
+
if (e.platformer && e.body) world.kill(e, world.current?.entity.id ?? "script");
|
|
416
|
+
else world.remove(e);
|
|
417
|
+
},
|
|
418
|
+
win: () => {
|
|
419
|
+
if (world.won) return;
|
|
420
|
+
const by = world.current?.entity;
|
|
421
|
+
world.won = true;
|
|
422
|
+
world.emit({ type: "win", entity: by?.id ?? "script", goal: by?.id ?? "script", x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
|
|
423
|
+
},
|
|
424
|
+
emit: (name, data) => {
|
|
425
|
+
if (typeof name !== "string" || name === "") throw world.misuse("world.emit needs a non-empty event name.");
|
|
426
|
+
if ((EVENT_TYPES as readonly string[]).includes(name)) {
|
|
427
|
+
throw world.misuse(`"${name}" is a built-in event and can't be emitted by scripts. Pick another name, e.g. "${name}-custom".`);
|
|
428
|
+
}
|
|
429
|
+
const by = world.current?.entity;
|
|
430
|
+
world.emit({ type: "custom", name, entity: by?.id ?? "script", data, x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
|
|
431
|
+
},
|
|
432
|
+
random: () => world.random(),
|
|
433
|
+
};
|
|
434
|
+
return this.scriptApi;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private spawn(def: SpawnDef): ScriptEntity {
|
|
438
|
+
if (typeof def !== "object" || def === null || typeof def.components !== "object") {
|
|
439
|
+
throw this.misuse('world.spawn needs { components: { ... } }, e.g. { components: { transform: { x: 0, y: 0 } } }.');
|
|
440
|
+
}
|
|
441
|
+
const id = def.id ?? `${this.current?.entity.id ?? "spawned"}-${++this.spawnCount}`;
|
|
442
|
+
if (this.get(id)) throw this.misuse(`world.spawn: an entity with id "${id}" already exists. Leave id out to get a unique one.`);
|
|
443
|
+
const { diagnostics } = validateScene({ entities: [{ id, components: def.components }] });
|
|
444
|
+
const errors = diagnostics.filter((d) => d.severity === "error" && d.code !== "E_GOAL_UNREACHABLE");
|
|
445
|
+
if (errors.length > 0) {
|
|
446
|
+
const details = errors.map((d) => `${d.path.replace("entities[0]", id)}: ${d.message}${d.fix ? ` Fix: ${d.fix}` : ""}`);
|
|
447
|
+
throw this.misuse(`world.spawn got an invalid entity. ${details.join(" ")}`);
|
|
448
|
+
}
|
|
449
|
+
const entity = this.createEntity({ id, components: def.components });
|
|
450
|
+
this.entities.push(entity);
|
|
451
|
+
return this.handle(entity);
|
|
178
452
|
}
|
|
179
453
|
|
|
454
|
+
/** mulberry32, seeded by world.seed. */
|
|
455
|
+
private random(): number {
|
|
456
|
+
this.seed = (this.seed + 0x6d2b79f5) | 0;
|
|
457
|
+
let t = this.seed;
|
|
458
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
459
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
460
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ------------------------------------------------------------ built-ins
|
|
464
|
+
|
|
180
465
|
private players(): Entity[] {
|
|
181
466
|
return this.entities.filter((e) => e.platformer && e.body);
|
|
182
467
|
}
|
|
@@ -199,10 +484,13 @@ export class World {
|
|
|
199
484
|
b.standingOn = undefined;
|
|
200
485
|
p.bufferTimer = 0;
|
|
201
486
|
p.coyoteTimer = 0;
|
|
487
|
+
p.jumping = true;
|
|
202
488
|
this.emit({ type: "jump", entity: e.id, ...feet(e) });
|
|
203
|
-
} else if (p.variableJump && !held.has("jump") && b.vy < -p.jumpSpeed * JUMP_CUT) {
|
|
489
|
+
} else if (p.jumping && p.variableJump && !held.has("jump") && b.vy < -p.jumpSpeed * JUMP_CUT) {
|
|
490
|
+
// Only the player's own jumps are cut short; launches from pads, stomps or scripts are not.
|
|
204
491
|
b.vy = -p.jumpSpeed * JUMP_CUT;
|
|
205
492
|
}
|
|
493
|
+
if (b.vy >= 0) p.jumping = false;
|
|
206
494
|
|
|
207
495
|
p.coyoteTimer = Math.max(0, p.coyoteTimer - FIXED_DT);
|
|
208
496
|
p.bufferTimer = Math.max(0, p.bufferTimer - FIXED_DT);
|
|
@@ -342,13 +630,14 @@ export class World {
|
|
|
342
630
|
b.standingOn = undefined;
|
|
343
631
|
p.coyoteTimer = 0;
|
|
344
632
|
p.bufferTimer = 0;
|
|
633
|
+
p.jumping = false;
|
|
345
634
|
}
|
|
346
635
|
}
|
|
347
636
|
|
|
348
637
|
function createEntity(def: EntityDef): Entity {
|
|
349
638
|
const c = def.components;
|
|
350
639
|
const transform = c.transform ? resolveComponent<Transform>("transform", c.transform) : { x: 0, y: 0 };
|
|
351
|
-
const entity: Entity = { id: def.id, transform };
|
|
640
|
+
const entity: Entity = { id: def.id, state: {}, transform };
|
|
352
641
|
if (c.body) entity.body = { ...resolveComponent<Body>("body", c.body), grounded: false };
|
|
353
642
|
if (c.sprite) entity.sprite = resolveComponent<Sprite>("sprite", c.sprite);
|
|
354
643
|
if (c.platformer) {
|
|
@@ -356,6 +645,7 @@ function createEntity(def: EntityDef): Entity {
|
|
|
356
645
|
...resolveComponent<Platformer>("platformer", c.platformer),
|
|
357
646
|
coyoteTimer: 0,
|
|
358
647
|
bufferTimer: 0,
|
|
648
|
+
jumping: false,
|
|
359
649
|
spawnX: transform.x,
|
|
360
650
|
spawnY: transform.y,
|
|
361
651
|
};
|
|
@@ -387,11 +677,14 @@ function isStomp(player: Entity, enemy: Entity): boolean {
|
|
|
387
677
|
return previousBottom <= enemyTop + STOMP_TOLERANCE;
|
|
388
678
|
}
|
|
389
679
|
|
|
390
|
-
/**
|
|
391
|
-
|
|
680
|
+
/**
|
|
681
|
+
* Strict overlap: bodies that merely touch (e.g. standing on the ground) do not overlap.
|
|
682
|
+
* A positive `margin` grows both boxes, so touching counts too.
|
|
683
|
+
*/
|
|
684
|
+
export function overlaps(a: Entity, b: Entity, margin = 0): boolean {
|
|
392
685
|
if (!a.body || !b.body) return false;
|
|
393
686
|
return (
|
|
394
|
-
Math.abs(a.transform.x - b.transform.x) < (a.body.width + b.body.width) / 2 - EPSILON &&
|
|
395
|
-
Math.abs(a.transform.y - b.transform.y) < (a.body.height + b.body.height) / 2 - EPSILON
|
|
687
|
+
Math.abs(a.transform.x - b.transform.x) < (a.body.width + b.body.width) / 2 + margin - EPSILON &&
|
|
688
|
+
Math.abs(a.transform.y - b.transform.y) < (a.body.height + b.body.height) / 2 + margin - EPSILON
|
|
396
689
|
);
|
|
397
690
|
}
|