@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/src/world.ts
CHANGED
|
@@ -8,11 +8,25 @@ import {
|
|
|
8
8
|
type Hazard,
|
|
9
9
|
type Patrol,
|
|
10
10
|
type Platformer,
|
|
11
|
+
type ScriptComponent,
|
|
11
12
|
type Sprite,
|
|
13
|
+
type Topdown,
|
|
12
14
|
type Transform,
|
|
13
15
|
} from "./components.ts";
|
|
16
|
+
import {
|
|
17
|
+
getScript,
|
|
18
|
+
ScriptError,
|
|
19
|
+
type DrawContext,
|
|
20
|
+
type Script,
|
|
21
|
+
type ScriptContext,
|
|
22
|
+
type ScriptEntity,
|
|
23
|
+
type ScriptInput,
|
|
24
|
+
type ScriptModules,
|
|
25
|
+
type ScriptWorld,
|
|
26
|
+
type SpawnDef,
|
|
27
|
+
} from "./script.ts";
|
|
14
28
|
import { FIXED_DT } from "./time.ts";
|
|
15
|
-
import type
|
|
29
|
+
import { EVENT_TYPES, type Action, type Diagnostic, type EntityDef, type SceneDef } from "./types.ts";
|
|
16
30
|
import { validateScene } from "./validate.ts";
|
|
17
31
|
|
|
18
32
|
export interface BodyState extends Body {
|
|
@@ -23,9 +37,9 @@ export interface BodyState extends Body {
|
|
|
23
37
|
|
|
24
38
|
export interface PlatformerState extends Platformer {
|
|
25
39
|
coyoteTimer: number;
|
|
40
|
+
/** True from a player-initiated jump until the apex, so only those jumps are cut short on release. */
|
|
41
|
+
jumping: boolean;
|
|
26
42
|
bufferTimer: number;
|
|
27
|
-
spawnX: number;
|
|
28
|
-
spawnY: number;
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
export interface PatrolState extends Patrol {
|
|
@@ -33,12 +47,27 @@ 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;
|
|
41
67
|
platformer?: PlatformerState;
|
|
68
|
+
topdown?: Topdown;
|
|
69
|
+
/** Where a player respawns after dying. */
|
|
70
|
+
spawn?: { x: number; y: number };
|
|
42
71
|
collectible?: Collectible;
|
|
43
72
|
patrol?: PatrolState;
|
|
44
73
|
hazard?: Hazard;
|
|
@@ -55,6 +84,9 @@ export type GameEvent = { frame: number; time: number; x: number; y: number } &
|
|
|
55
84
|
| { type: "die"; entity: string; cause: string }
|
|
56
85
|
| { type: "stomp"; entity: string; enemy: string; score: number }
|
|
57
86
|
| { type: "win"; entity: string; goal: string }
|
|
87
|
+
| { type: "bounce"; entity: string; other: string }
|
|
88
|
+
/** Fired by a script with world.emit(name, data). */
|
|
89
|
+
| { type: "custom"; name: string; entity: string; data?: unknown }
|
|
58
90
|
);
|
|
59
91
|
|
|
60
92
|
type EventPayload = GameEvent extends infer E ? (E extends GameEvent ? Omit<E, "frame" | "time"> : never) : never;
|
|
@@ -65,6 +97,13 @@ export interface EntitySnapshot {
|
|
|
65
97
|
vx: number;
|
|
66
98
|
vy: number;
|
|
67
99
|
grounded: boolean;
|
|
100
|
+
/** Present when a script stored data on the entity. */
|
|
101
|
+
state?: Record<string, unknown>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface WorldOptions {
|
|
105
|
+
/** Loaded script modules, keyed by the `src` path used in the scene. */
|
|
106
|
+
scripts?: ScriptModules;
|
|
68
107
|
}
|
|
69
108
|
|
|
70
109
|
export interface WorldSnapshot {
|
|
@@ -96,6 +135,10 @@ const STOMP_BOUNCE = 0.6;
|
|
|
96
135
|
const STOMP_TOLERANCE = 8;
|
|
97
136
|
/** A platformer this far below the bottom of the world has fallen out of it. */
|
|
98
137
|
const FALL_MARGIN = 100;
|
|
138
|
+
/** Bodies this close count as touching for `touch` hooks, so solids (doors, walls) can be touched too. */
|
|
139
|
+
const TOUCH_MARGIN = 1;
|
|
140
|
+
/** Impacts slower than this (px/s, after bounce) stop instead of bouncing. */
|
|
141
|
+
const MIN_BOUNCE_SPEED = 40;
|
|
99
142
|
const EPSILON = 1e-4;
|
|
100
143
|
|
|
101
144
|
/**
|
|
@@ -116,20 +159,32 @@ export class World {
|
|
|
116
159
|
/** Set when a player reaches a goal. The simulation stops changing after that. */
|
|
117
160
|
won = false;
|
|
118
161
|
private previousInput: ReadonlySet<Action> = new Set();
|
|
119
|
-
|
|
120
|
-
|
|
162
|
+
private readonly scripts: ScriptModules | undefined;
|
|
163
|
+
private seed: number;
|
|
164
|
+
private spawnCount = 0;
|
|
165
|
+
private readonly handles = new WeakMap<Entity, ScriptEntity>();
|
|
166
|
+
private scriptApi: ScriptWorld | undefined;
|
|
167
|
+
private scriptInput: ScriptInput = { held: () => false, pressed: () => false };
|
|
168
|
+
/** The script hook currently running, for error messages and event attribution. */
|
|
169
|
+
private current: { entity: Entity; hook: string } | undefined;
|
|
170
|
+
/** True while a draw hook runs: the game can't be changed then. */
|
|
171
|
+
private drawing = false;
|
|
172
|
+
|
|
173
|
+
constructor(scene: SceneDef, options: WorldOptions = {}) {
|
|
121
174
|
this.width = scene.world?.width ?? 960;
|
|
122
175
|
this.height = scene.world?.height ?? 540;
|
|
123
176
|
this.gravity = scene.world?.gravity ?? DEFAULT_GRAVITY;
|
|
124
177
|
this.background = scene.world?.background ?? "#1d2333";
|
|
125
|
-
this.
|
|
178
|
+
this.seed = scene.world?.seed ?? 1;
|
|
179
|
+
this.scripts = options.scripts;
|
|
180
|
+
this.entities = scene.entities.map((def) => this.createEntity(def));
|
|
126
181
|
}
|
|
127
182
|
|
|
128
183
|
/** Validates raw scene JSON and builds a world, throwing on errors. */
|
|
129
|
-
static fromJSON(raw: unknown): World {
|
|
184
|
+
static fromJSON(raw: unknown, options: WorldOptions = {}): World {
|
|
130
185
|
const { scene, diagnostics } = validateScene(raw);
|
|
131
186
|
if (!scene) throw new SceneValidationError(diagnostics);
|
|
132
|
-
return new World(scene);
|
|
187
|
+
return new World(scene, options);
|
|
133
188
|
}
|
|
134
189
|
|
|
135
190
|
get time(): number {
|
|
@@ -148,8 +203,13 @@ export class World {
|
|
|
148
203
|
if (this.won) return;
|
|
149
204
|
|
|
150
205
|
this.updatePlatformers(input, pressed);
|
|
206
|
+
this.updateTopdown(input);
|
|
207
|
+
this.updateScripts(input, pressed);
|
|
208
|
+
if (this.won) return;
|
|
151
209
|
this.updatePatrols();
|
|
152
210
|
this.updateBodies();
|
|
211
|
+
this.updateTouches();
|
|
212
|
+
if (this.won) return;
|
|
153
213
|
this.updateCollectibles();
|
|
154
214
|
this.updateHazards();
|
|
155
215
|
this.updateGoals();
|
|
@@ -165,6 +225,8 @@ export class World {
|
|
|
165
225
|
vy: e.body?.vy ?? 0,
|
|
166
226
|
grounded: e.body?.grounded ?? false,
|
|
167
227
|
};
|
|
228
|
+
// A JSON copy, so later frames can't change an earlier snapshot.
|
|
229
|
+
if (Object.keys(e.state).length > 0) entities[e.id]!.state = JSON.parse(JSON.stringify(e.state));
|
|
168
230
|
}
|
|
169
231
|
return { frame: this.frame, time: this.time, score: this.score, deaths: this.deaths, won: this.won, entities };
|
|
170
232
|
}
|
|
@@ -174,17 +236,324 @@ export class World {
|
|
|
174
236
|
}
|
|
175
237
|
|
|
176
238
|
private remove(entity: Entity): void {
|
|
177
|
-
this.entities.
|
|
239
|
+
const index = this.entities.indexOf(entity);
|
|
240
|
+
if (index >= 0) this.entities.splice(index, 1);
|
|
241
|
+
entity.removed = true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------- scripts
|
|
245
|
+
|
|
246
|
+
private createEntity(def: EntityDef): Entity {
|
|
247
|
+
const entity = createEntity(def);
|
|
248
|
+
const component = def.components.script;
|
|
249
|
+
if (component) {
|
|
250
|
+
const resolved = resolveComponent<ScriptComponent>("script", component);
|
|
251
|
+
const module = getScript(this.scripts, resolved.src);
|
|
252
|
+
if (!module) {
|
|
253
|
+
throw new ScriptError(
|
|
254
|
+
{ src: resolved.src, entity: def.id, hook: "load", frame: this.frame },
|
|
255
|
+
"this script is not loaded. Hosts pass loaded modules as new World(scene, { scripts }); the CLI and browser runtime do this for you.",
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
entity.script = { ...resolved, params: { ...resolved.params }, module, started: false, touching: new Set() };
|
|
259
|
+
}
|
|
260
|
+
return entity;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private updateScripts(held: ReadonlySet<Action>, pressed: ReadonlySet<Action>): void {
|
|
264
|
+
this.scriptInput = { held: (action) => held.has(action), pressed: (action) => pressed.has(action) };
|
|
265
|
+
// Iterate over a copy: scripts may spawn or destroy entities.
|
|
266
|
+
for (const e of [...this.entities]) {
|
|
267
|
+
if (!e.script || e.removed) continue;
|
|
268
|
+
if (!e.script.started) {
|
|
269
|
+
e.script.started = true;
|
|
270
|
+
this.callHook(e, "start");
|
|
271
|
+
}
|
|
272
|
+
if (!e.removed && !this.won) this.callHook(e, "update");
|
|
273
|
+
if (this.won) return;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Calls `touch` on scripted entities that started overlapping or touching another body this frame. */
|
|
278
|
+
private updateTouches(): void {
|
|
279
|
+
for (const e of [...this.entities]) {
|
|
280
|
+
if (!e.script?.module.touch || !e.body || e.removed) continue;
|
|
281
|
+
const now = new Set<string>();
|
|
282
|
+
for (const other of this.entities) {
|
|
283
|
+
if (other !== e && other.body && overlaps(e, other, TOUCH_MARGIN)) now.add(other.id);
|
|
284
|
+
}
|
|
285
|
+
for (const id of now) {
|
|
286
|
+
const other = this.get(id);
|
|
287
|
+
if (other && !e.script.touching.has(id) && !e.removed && !this.won) this.callHook(e, "touch", this.handle(other));
|
|
288
|
+
}
|
|
289
|
+
e.script.touching = now;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private callHook(entity: Entity, hook: "start" | "update" | "touch", other?: ScriptEntity): void {
|
|
294
|
+
const script = entity.script!;
|
|
295
|
+
const fn = script.module[hook];
|
|
296
|
+
if (!fn) return;
|
|
297
|
+
const ctx: ScriptContext = { self: this.handle(entity), world: this.api(), input: this.scriptInput, params: script.params, dt: FIXED_DT };
|
|
298
|
+
this.run(entity, hook, () => (fn as (ctx: ScriptContext, other?: ScriptEntity) => void).call(script.module, ctx, other));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Runs an entity's `draw` or `drawUI` hook. Called by the renderer, never by the
|
|
303
|
+
* simulation. `self` and `world` are read-only for the duration.
|
|
304
|
+
*/
|
|
305
|
+
callDrawHook(entity: Entity, hook: "draw" | "drawUI", extras: Omit<DrawContext, "self" | "world" | "params">): void {
|
|
306
|
+
const script = entity.script;
|
|
307
|
+
const fn = script?.module[hook];
|
|
308
|
+
if (!script || !fn || entity.removed) return;
|
|
309
|
+
const ctx: DrawContext = { ...extras, self: this.handle(entity), world: this.api(), params: script.params };
|
|
310
|
+
this.drawing = true;
|
|
311
|
+
try {
|
|
312
|
+
this.run(entity, hook, () => fn.call(script.module, ctx));
|
|
313
|
+
} finally {
|
|
314
|
+
this.drawing = false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** True when any live entity's script has the given hook. */
|
|
319
|
+
hasHook(hook: "draw" | "drawUI"): boolean {
|
|
320
|
+
return this.entities.some((e) => !e.removed && typeof e.script?.module[hook] === "function");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private run(entity: Entity, hook: string, call: () => void): void {
|
|
324
|
+
const script = entity.script!;
|
|
325
|
+
this.current = { entity, hook };
|
|
326
|
+
try {
|
|
327
|
+
call();
|
|
328
|
+
} catch (err) {
|
|
329
|
+
if (err instanceof ScriptError) throw err;
|
|
330
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
331
|
+
throw new ScriptError({ src: script.src, entity: entity.id, hook, frame: this.frame }, message, { cause: err });
|
|
332
|
+
} finally {
|
|
333
|
+
this.current = undefined;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Draw hooks only paint, so the browser can't drift from what tests verified. */
|
|
338
|
+
private guard(what: string): void {
|
|
339
|
+
if (this.drawing) {
|
|
340
|
+
throw this.misuse(`${what} changes the game, which a draw hook can't do. Change the game in update or touch, and keep visual memory in ctx.local.`);
|
|
341
|
+
}
|
|
178
342
|
}
|
|
179
343
|
|
|
344
|
+
/** An error caused by how a script used the API, reported against the running script. */
|
|
345
|
+
private misuse(message: string): ScriptError {
|
|
346
|
+
const running = this.current;
|
|
347
|
+
return new ScriptError(
|
|
348
|
+
{ src: running?.entity.script?.src ?? "?", entity: running?.entity.id ?? "?", hook: running?.hook ?? "?", frame: this.frame },
|
|
349
|
+
message,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** A stable script-facing view of an entity. */
|
|
354
|
+
private handle(e: Entity): ScriptEntity {
|
|
355
|
+
let handle = this.handles.get(e);
|
|
356
|
+
if (handle) return handle;
|
|
357
|
+
const world = this;
|
|
358
|
+
handle = {
|
|
359
|
+
get id() {
|
|
360
|
+
return e.id;
|
|
361
|
+
},
|
|
362
|
+
get x() {
|
|
363
|
+
return e.transform.x;
|
|
364
|
+
},
|
|
365
|
+
set x(value: number) {
|
|
366
|
+
world.guard("Setting x");
|
|
367
|
+
e.transform.x = value;
|
|
368
|
+
},
|
|
369
|
+
get y() {
|
|
370
|
+
return e.transform.y;
|
|
371
|
+
},
|
|
372
|
+
set y(value: number) {
|
|
373
|
+
world.guard("Setting y");
|
|
374
|
+
e.transform.y = value;
|
|
375
|
+
},
|
|
376
|
+
get vx() {
|
|
377
|
+
return e.body?.vx ?? 0;
|
|
378
|
+
},
|
|
379
|
+
set vx(value: number) {
|
|
380
|
+
world.guard("Setting vx");
|
|
381
|
+
if (e.body) e.body.vx = value;
|
|
382
|
+
},
|
|
383
|
+
get vy() {
|
|
384
|
+
return e.body?.vy ?? 0;
|
|
385
|
+
},
|
|
386
|
+
set vy(value: number) {
|
|
387
|
+
world.guard("Setting vy");
|
|
388
|
+
if (e.body) e.body.vy = value;
|
|
389
|
+
},
|
|
390
|
+
get grounded() {
|
|
391
|
+
return e.body?.grounded ?? false;
|
|
392
|
+
},
|
|
393
|
+
get width() {
|
|
394
|
+
return e.body?.width ?? e.sprite?.width ?? 0;
|
|
395
|
+
},
|
|
396
|
+
get height() {
|
|
397
|
+
return e.body?.height ?? e.sprite?.height ?? 0;
|
|
398
|
+
},
|
|
399
|
+
get exists() {
|
|
400
|
+
return !e.removed;
|
|
401
|
+
},
|
|
402
|
+
get state() {
|
|
403
|
+
// A frozen copy while drawing, so a draw hook can't write game state by accident.
|
|
404
|
+
return world.drawing ? Object.freeze({ ...e.state }) : e.state;
|
|
405
|
+
},
|
|
406
|
+
has(component: string) {
|
|
407
|
+
return component === "transform" || (component in e && (e as unknown as Record<string, unknown>)[component] !== undefined);
|
|
408
|
+
},
|
|
409
|
+
touches(other: ScriptEntity | string) {
|
|
410
|
+
const target = world.get(typeof other === "string" ? other : other.id);
|
|
411
|
+
return !!target && target !== e && !e.removed && overlaps(e, target, TOUCH_MARGIN);
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
this.handles.set(e, handle);
|
|
415
|
+
return handle;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private resolve(target: ScriptEntity | string): Entity | undefined {
|
|
419
|
+
return this.get(typeof target === "string" ? target : target.id);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The `world` object scripts receive. */
|
|
423
|
+
private api(): ScriptWorld {
|
|
424
|
+
if (this.scriptApi) return this.scriptApi;
|
|
425
|
+
const world = this;
|
|
426
|
+
this.scriptApi = {
|
|
427
|
+
get time() {
|
|
428
|
+
return world.time;
|
|
429
|
+
},
|
|
430
|
+
get frame() {
|
|
431
|
+
return world.frame;
|
|
432
|
+
},
|
|
433
|
+
get width() {
|
|
434
|
+
return world.width;
|
|
435
|
+
},
|
|
436
|
+
get height() {
|
|
437
|
+
return world.height;
|
|
438
|
+
},
|
|
439
|
+
get score() {
|
|
440
|
+
return world.score;
|
|
441
|
+
},
|
|
442
|
+
get deaths() {
|
|
443
|
+
return world.deaths;
|
|
444
|
+
},
|
|
445
|
+
get won() {
|
|
446
|
+
return world.won;
|
|
447
|
+
},
|
|
448
|
+
set score(value: number) {
|
|
449
|
+
world.guard("Setting score");
|
|
450
|
+
world.score = value;
|
|
451
|
+
},
|
|
452
|
+
get: (id) => {
|
|
453
|
+
const e = world.get(id);
|
|
454
|
+
return e ? world.handle(e) : undefined;
|
|
455
|
+
},
|
|
456
|
+
all: (component) =>
|
|
457
|
+
world.entities.filter((e) => !component || world.handle(e).has(component)).map((e) => world.handle(e)),
|
|
458
|
+
player: () => {
|
|
459
|
+
const e = world.entities.find(isPlayer);
|
|
460
|
+
return e ? world.handle(e) : undefined;
|
|
461
|
+
},
|
|
462
|
+
spawn: (def) => {
|
|
463
|
+
world.guard("world.spawn");
|
|
464
|
+
return world.spawn(def);
|
|
465
|
+
},
|
|
466
|
+
destroy: (target) => {
|
|
467
|
+
world.guard("world.destroy");
|
|
468
|
+
const e = world.resolve(target);
|
|
469
|
+
if (e) world.remove(e);
|
|
470
|
+
},
|
|
471
|
+
kill: (target) => {
|
|
472
|
+
world.guard("world.kill");
|
|
473
|
+
const e = world.resolve(target);
|
|
474
|
+
if (!e) return;
|
|
475
|
+
if (isPlayer(e)) world.kill(e, world.current?.entity.id ?? "script");
|
|
476
|
+
else world.remove(e);
|
|
477
|
+
},
|
|
478
|
+
win: () => {
|
|
479
|
+
world.guard("world.win");
|
|
480
|
+
if (world.won) return;
|
|
481
|
+
const by = world.current?.entity;
|
|
482
|
+
world.won = true;
|
|
483
|
+
world.emit({ type: "win", entity: by?.id ?? "script", goal: by?.id ?? "script", x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
|
|
484
|
+
},
|
|
485
|
+
emit: (name, data) => {
|
|
486
|
+
world.guard("world.emit");
|
|
487
|
+
if (typeof name !== "string" || name === "") throw world.misuse("world.emit needs a non-empty event name.");
|
|
488
|
+
if ((EVENT_TYPES as readonly string[]).includes(name)) {
|
|
489
|
+
throw world.misuse(`"${name}" is a built-in event and can't be emitted by scripts. Pick another name, e.g. "${name}-custom".`);
|
|
490
|
+
}
|
|
491
|
+
const by = world.current?.entity;
|
|
492
|
+
world.emit({ type: "custom", name, entity: by?.id ?? "script", data, x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
|
|
493
|
+
},
|
|
494
|
+
random: () => {
|
|
495
|
+
world.guard("world.random (it advances the game's seed; use ctx.random in draw hooks)");
|
|
496
|
+
return world.random();
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
return this.scriptApi;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private spawn(def: SpawnDef): ScriptEntity {
|
|
503
|
+
if (typeof def !== "object" || def === null || typeof def.components !== "object") {
|
|
504
|
+
throw this.misuse('world.spawn needs { components: { ... } }, e.g. { components: { transform: { x: 0, y: 0 } } }.');
|
|
505
|
+
}
|
|
506
|
+
const id = def.id ?? `${this.current?.entity.id ?? "spawned"}-${++this.spawnCount}`;
|
|
507
|
+
if (this.get(id)) throw this.misuse(`world.spawn: an entity with id "${id}" already exists. Leave id out to get a unique one.`);
|
|
508
|
+
const { diagnostics } = validateScene({ entities: [{ id, components: def.components }] });
|
|
509
|
+
const errors = diagnostics.filter((d) => d.severity === "error" && d.code !== "E_GOAL_UNREACHABLE");
|
|
510
|
+
if (errors.length > 0) {
|
|
511
|
+
const details = errors.map((d) => `${d.path.replace("entities[0]", id)}: ${d.message}${d.fix ? ` Fix: ${d.fix}` : ""}`);
|
|
512
|
+
throw this.misuse(`world.spawn got an invalid entity. ${details.join(" ")}`);
|
|
513
|
+
}
|
|
514
|
+
const entity = this.createEntity({ id, components: def.components });
|
|
515
|
+
this.entities.push(entity);
|
|
516
|
+
return this.handle(entity);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** mulberry32, seeded by world.seed. */
|
|
520
|
+
private random(): number {
|
|
521
|
+
this.seed = (this.seed + 0x6d2b79f5) | 0;
|
|
522
|
+
let t = this.seed;
|
|
523
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
524
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
525
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ------------------------------------------------------------ built-ins
|
|
529
|
+
|
|
530
|
+
/** Entities the player controls: platformers and topdown movers. */
|
|
180
531
|
private players(): Entity[] {
|
|
181
|
-
return this.entities.filter(
|
|
532
|
+
return this.entities.filter(isPlayer);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private updateTopdown(held: ReadonlySet<Action>): void {
|
|
536
|
+
for (const e of this.entities) {
|
|
537
|
+
const t = e.topdown;
|
|
538
|
+
const b = e.body;
|
|
539
|
+
if (!t || !b) continue;
|
|
540
|
+
let dx = (held.has("right") ? 1 : 0) - (held.has("left") ? 1 : 0);
|
|
541
|
+
let dy = (held.has("down") ? 1 : 0) - (held.has("up") ? 1 : 0);
|
|
542
|
+
if (t.directions === "x") dy = 0;
|
|
543
|
+
if (t.directions === "y") dx = 0;
|
|
544
|
+
if (t.directions === "4" && dx !== 0) dy = 0;
|
|
545
|
+
// Diagonals move at the same speed as straight lines.
|
|
546
|
+
const length = Math.hypot(dx, dy) || 1;
|
|
547
|
+
b.vx = (dx / length) * t.speed;
|
|
548
|
+
b.vy = (dy / length) * t.speed;
|
|
549
|
+
}
|
|
182
550
|
}
|
|
183
551
|
|
|
184
552
|
private updatePlatformers(held: ReadonlySet<Action>, pressed: ReadonlySet<Action>): void {
|
|
185
|
-
for (const e of this.
|
|
186
|
-
const p = e.platformer
|
|
187
|
-
const b = e.body
|
|
553
|
+
for (const e of this.entities) {
|
|
554
|
+
const p = e.platformer;
|
|
555
|
+
const b = e.body;
|
|
556
|
+
if (!p || !b) continue;
|
|
188
557
|
|
|
189
558
|
const direction = (held.has("right") ? 1 : 0) - (held.has("left") ? 1 : 0);
|
|
190
559
|
b.vx = direction * p.speed;
|
|
@@ -199,10 +568,13 @@ export class World {
|
|
|
199
568
|
b.standingOn = undefined;
|
|
200
569
|
p.bufferTimer = 0;
|
|
201
570
|
p.coyoteTimer = 0;
|
|
571
|
+
p.jumping = true;
|
|
202
572
|
this.emit({ type: "jump", entity: e.id, ...feet(e) });
|
|
203
|
-
} else if (p.variableJump && !held.has("jump") && b.vy < -p.jumpSpeed * JUMP_CUT) {
|
|
573
|
+
} else if (p.jumping && p.variableJump && !held.has("jump") && b.vy < -p.jumpSpeed * JUMP_CUT) {
|
|
574
|
+
// Only the player's own jumps are cut short; launches from pads, stomps or scripts are not.
|
|
204
575
|
b.vy = -p.jumpSpeed * JUMP_CUT;
|
|
205
576
|
}
|
|
577
|
+
if (b.vy >= 0) p.jumping = false;
|
|
206
578
|
|
|
207
579
|
p.coyoteTimer = Math.max(0, p.coyoteTimer - FIXED_DT);
|
|
208
580
|
p.bufferTimer = Math.max(0, p.bufferTimer - FIXED_DT);
|
|
@@ -229,13 +601,18 @@ export class World {
|
|
|
229
601
|
private updateBodies(): void {
|
|
230
602
|
// Kinematic bodies move first, so anything standing on them can follow.
|
|
231
603
|
const moved = new Map<Entity, { dx: number; dy: number }>();
|
|
604
|
+
const walls = this.entities.filter((e) => e.body?.type === "static" && !e.body.sensor);
|
|
232
605
|
for (const e of this.entities) {
|
|
233
606
|
const b = e.body;
|
|
234
607
|
if (!b || b.type !== "kinematic") continue;
|
|
235
608
|
const { x, y } = e.transform;
|
|
236
609
|
e.transform.x += b.vx * FIXED_DT;
|
|
610
|
+
// A player-steered kinematic body (a paddle) stops at walls; scripted movers pass through.
|
|
611
|
+
if (e.topdown) for (const wall of walls) if (overlaps(e, wall)) e.transform.x = wall.transform.x + (Math.sign(e.transform.x - wall.transform.x) * (wall.body!.width + b.width)) / 2;
|
|
237
612
|
e.transform.y += b.vy * FIXED_DT;
|
|
613
|
+
if (e.topdown) for (const wall of walls) if (overlaps(e, wall)) e.transform.y = wall.transform.y + (Math.sign(e.transform.y - wall.transform.y) * (wall.body!.height + b.height)) / 2;
|
|
238
614
|
if (e.patrol) clampToPatrol(e, e.patrol);
|
|
615
|
+
if (e.topdown?.clampToWorld) this.clampToWorld(e);
|
|
239
616
|
moved.set(e, { dx: e.transform.x - x, dy: e.transform.y - y });
|
|
240
617
|
}
|
|
241
618
|
|
|
@@ -251,7 +628,8 @@ export class World {
|
|
|
251
628
|
e.transform.y += carried.dy;
|
|
252
629
|
}
|
|
253
630
|
|
|
254
|
-
|
|
631
|
+
// Top-down movers ignore gravity; everything else falls.
|
|
632
|
+
if (!e.topdown) b.vy = Math.min(b.vy + this.gravity * b.gravityScale * FIXED_DT, MAX_FALL_SPEED);
|
|
255
633
|
const wasGrounded = b.grounded;
|
|
256
634
|
b.grounded = false;
|
|
257
635
|
b.standingOn = undefined;
|
|
@@ -263,27 +641,47 @@ export class World {
|
|
|
263
641
|
const half = (solid.body!.width + b.width) / 2;
|
|
264
642
|
if (b.vx > 0) e.transform.x = solid.transform.x - half;
|
|
265
643
|
else if (b.vx < 0) e.transform.x = solid.transform.x + half;
|
|
266
|
-
b.vx =
|
|
644
|
+
b.vx = this.rebound(e, solid, b.vx);
|
|
267
645
|
}
|
|
268
646
|
|
|
269
647
|
e.transform.y += b.vy * FIXED_DT;
|
|
270
648
|
for (const solid of solids) {
|
|
271
649
|
if (!overlaps(e, solid)) continue;
|
|
272
650
|
const half = (solid.body!.height + b.height) / 2;
|
|
273
|
-
|
|
274
|
-
|
|
651
|
+
const falling = b.vy > 0;
|
|
652
|
+
if (b.vy > 0) e.transform.y = solid.transform.y - half;
|
|
653
|
+
else if (b.vy < 0) e.transform.y = solid.transform.y + half;
|
|
654
|
+
b.vy = this.rebound(e, solid, b.vy);
|
|
655
|
+
// A body that stopped on top of something is standing on it; one that bounced off is airborne.
|
|
656
|
+
if (falling && b.vy === 0) {
|
|
275
657
|
b.grounded = true;
|
|
276
658
|
b.standingOn = solid;
|
|
277
|
-
} else if (b.vy < 0) {
|
|
278
|
-
e.transform.y = solid.transform.y + half;
|
|
279
659
|
}
|
|
280
|
-
b.vy = 0;
|
|
281
660
|
}
|
|
282
661
|
|
|
662
|
+
if (e.topdown?.clampToWorld) this.clampToWorld(e);
|
|
283
663
|
if (b.grounded && !wasGrounded) this.emit({ type: "land", entity: e.id, ...feet(e) });
|
|
284
664
|
}
|
|
285
665
|
}
|
|
286
666
|
|
|
667
|
+
/**
|
|
668
|
+
* The velocity along one axis after hitting `solid`: 0, or reversed and scaled by `bounce`.
|
|
669
|
+
* Slow impacts stop instead, so resting bodies don't jitter.
|
|
670
|
+
*/
|
|
671
|
+
private rebound(e: Entity, solid: Entity, velocity: number): number {
|
|
672
|
+
const bounce = e.body!.bounce;
|
|
673
|
+
if (bounce <= 0 || Math.abs(velocity) * bounce < MIN_BOUNCE_SPEED) return 0;
|
|
674
|
+
this.emit({ type: "bounce", entity: e.id, other: solid.id, ...e.transform });
|
|
675
|
+
return -velocity * bounce;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
private clampToWorld(e: Entity): void {
|
|
679
|
+
const halfWidth = (e.body?.width ?? 0) / 2;
|
|
680
|
+
const halfHeight = (e.body?.height ?? 0) / 2;
|
|
681
|
+
e.transform.x = Math.min(this.width - halfWidth, Math.max(halfWidth, e.transform.x));
|
|
682
|
+
e.transform.y = Math.min(this.height - halfHeight, Math.max(halfHeight, e.transform.y));
|
|
683
|
+
}
|
|
684
|
+
|
|
287
685
|
private updateCollectibles(): void {
|
|
288
686
|
const players = this.players();
|
|
289
687
|
for (const item of this.entities.filter((e) => e.collectible && e.body)) {
|
|
@@ -304,7 +702,7 @@ export class World {
|
|
|
304
702
|
}
|
|
305
703
|
for (const hazard of this.entities.filter((e) => e.hazard && e.body)) {
|
|
306
704
|
if (!overlaps(player, hazard)) continue;
|
|
307
|
-
if (hazard.hazard!.stompable && isStomp(player, hazard)) {
|
|
705
|
+
if (hazard.hazard!.stompable && player.platformer && isStomp(player, hazard)) {
|
|
308
706
|
this.remove(hazard);
|
|
309
707
|
b.vy = -player.platformer!.jumpSpeed * STOMP_BOUNCE;
|
|
310
708
|
this.score += hazard.hazard!.score;
|
|
@@ -331,24 +729,27 @@ export class World {
|
|
|
331
729
|
/** Sends a player back to where it started. Collected items stay collected. */
|
|
332
730
|
private kill(player: Entity, cause: string): void {
|
|
333
731
|
const b = player.body!;
|
|
334
|
-
const p = player.platformer!;
|
|
335
732
|
this.deaths++;
|
|
336
733
|
this.emit({ type: "die", entity: player.id, cause, ...player.transform });
|
|
337
|
-
player.transform.x =
|
|
338
|
-
player.transform.y =
|
|
734
|
+
player.transform.x = player.spawn?.x ?? player.transform.x;
|
|
735
|
+
player.transform.y = player.spawn?.y ?? player.transform.y;
|
|
339
736
|
b.vx = 0;
|
|
340
737
|
b.vy = 0;
|
|
341
738
|
b.grounded = false;
|
|
342
739
|
b.standingOn = undefined;
|
|
343
|
-
p
|
|
344
|
-
p
|
|
740
|
+
const p = player.platformer;
|
|
741
|
+
if (p) {
|
|
742
|
+
p.coyoteTimer = 0;
|
|
743
|
+
p.bufferTimer = 0;
|
|
744
|
+
p.jumping = false;
|
|
745
|
+
}
|
|
345
746
|
}
|
|
346
747
|
}
|
|
347
748
|
|
|
348
749
|
function createEntity(def: EntityDef): Entity {
|
|
349
750
|
const c = def.components;
|
|
350
751
|
const transform = c.transform ? resolveComponent<Transform>("transform", c.transform) : { x: 0, y: 0 };
|
|
351
|
-
const entity: Entity = { id: def.id, transform };
|
|
752
|
+
const entity: Entity = { id: def.id, state: {}, transform };
|
|
352
753
|
if (c.body) entity.body = { ...resolveComponent<Body>("body", c.body), grounded: false };
|
|
353
754
|
if (c.sprite) entity.sprite = resolveComponent<Sprite>("sprite", c.sprite);
|
|
354
755
|
if (c.platformer) {
|
|
@@ -356,10 +757,11 @@ function createEntity(def: EntityDef): Entity {
|
|
|
356
757
|
...resolveComponent<Platformer>("platformer", c.platformer),
|
|
357
758
|
coyoteTimer: 0,
|
|
358
759
|
bufferTimer: 0,
|
|
359
|
-
|
|
360
|
-
spawnY: transform.y,
|
|
760
|
+
jumping: false,
|
|
361
761
|
};
|
|
362
762
|
}
|
|
763
|
+
if (c.topdown) entity.topdown = resolveComponent<Topdown>("topdown", c.topdown);
|
|
764
|
+
if (entity.platformer || entity.topdown) entity.spawn = { x: transform.x, y: transform.y };
|
|
363
765
|
if (c.collectible) entity.collectible = resolveComponent<Collectible>("collectible", c.collectible);
|
|
364
766
|
if (c.patrol) entity.patrol = { ...resolveComponent<Patrol>("patrol", c.patrol), originX: transform.x, originY: transform.y };
|
|
365
767
|
if (c.hazard) entity.hazard = resolveComponent<Hazard>("hazard", c.hazard);
|
|
@@ -369,6 +771,10 @@ function createEntity(def: EntityDef): Entity {
|
|
|
369
771
|
return entity;
|
|
370
772
|
}
|
|
371
773
|
|
|
774
|
+
function isPlayer(e: Entity): boolean {
|
|
775
|
+
return !!(e.platformer || e.topdown) && !!e.body;
|
|
776
|
+
}
|
|
777
|
+
|
|
372
778
|
function feet(e: Entity): { x: number; y: number } {
|
|
373
779
|
return { x: e.transform.x, y: e.transform.y + (e.body?.height ?? 0) / 2 };
|
|
374
780
|
}
|
|
@@ -387,11 +793,14 @@ function isStomp(player: Entity, enemy: Entity): boolean {
|
|
|
387
793
|
return previousBottom <= enemyTop + STOMP_TOLERANCE;
|
|
388
794
|
}
|
|
389
795
|
|
|
390
|
-
/**
|
|
391
|
-
|
|
796
|
+
/**
|
|
797
|
+
* Strict overlap: bodies that merely touch (e.g. standing on the ground) do not overlap.
|
|
798
|
+
* A positive `margin` grows both boxes, so touching counts too.
|
|
799
|
+
*/
|
|
800
|
+
export function overlaps(a: Entity, b: Entity, margin = 0): boolean {
|
|
392
801
|
if (!a.body || !b.body) return false;
|
|
393
802
|
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
|
|
803
|
+
Math.abs(a.transform.x - b.transform.x) < (a.body.width + b.body.width) / 2 + margin - EPSILON &&
|
|
804
|
+
Math.abs(a.transform.y - b.transform.y) < (a.body.height + b.body.height) / 2 + margin - EPSILON
|
|
396
805
|
);
|
|
397
806
|
}
|