@ludra/core 0.5.0 → 0.8.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 +5 -1
- package/dist/assertions.js +8 -4
- package/dist/browser.d.ts +8 -1
- package/dist/browser.js +282 -30
- package/dist/components.d.ts +8 -1
- package/dist/components.js +33 -1
- package/dist/drawcheck.d.ts +7 -0
- package/dist/drawcheck.js +81 -0
- package/dist/effects.d.ts +3 -2
- package/dist/effects.js +20 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/postfx.d.ts +18 -0
- package/dist/postfx.js +100 -0
- package/dist/script.d.ts +82 -3
- package/dist/script.js +25 -4
- package/dist/types.d.ts +29 -1
- package/dist/types.js +1 -1
- package/dist/validate.d.ts +3 -1
- package/dist/validate.js +120 -7
- package/dist/world.d.ts +67 -4
- package/dist/world.js +249 -35
- package/package.json +1 -1
- package/src/ascii.ts +2 -2
- package/src/assertions.ts +12 -4
- package/src/browser.ts +286 -30
- package/src/components.ts +43 -1
- package/src/drawcheck.ts +80 -0
- package/src/effects.ts +18 -2
- package/src/index.ts +1 -0
- package/src/postfx.ts +110 -0
- package/src/script.ts +101 -5
- package/src/types.ts +31 -1
- package/src/validate.ts +126 -7
- package/src/world.ts +247 -39
package/src/world.ts
CHANGED
|
@@ -10,11 +10,13 @@ import {
|
|
|
10
10
|
type Platformer,
|
|
11
11
|
type ScriptComponent,
|
|
12
12
|
type Sprite,
|
|
13
|
+
type Topdown,
|
|
13
14
|
type Transform,
|
|
14
15
|
} from "./components.ts";
|
|
15
16
|
import {
|
|
16
17
|
getScript,
|
|
17
18
|
ScriptError,
|
|
19
|
+
type DrawContext,
|
|
18
20
|
type Script,
|
|
19
21
|
type ScriptContext,
|
|
20
22
|
type ScriptEntity,
|
|
@@ -25,7 +27,7 @@ import {
|
|
|
25
27
|
} from "./script.ts";
|
|
26
28
|
import { FIXED_DT } from "./time.ts";
|
|
27
29
|
import { EVENT_TYPES, type Action, type Diagnostic, type EntityDef, type SceneDef } from "./types.ts";
|
|
28
|
-
import { validateScene } from "./validate.ts";
|
|
30
|
+
import { mergeComponents, validateScene } from "./validate.ts";
|
|
29
31
|
|
|
30
32
|
export interface BodyState extends Body {
|
|
31
33
|
grounded: boolean;
|
|
@@ -38,8 +40,6 @@ export interface PlatformerState extends Platformer {
|
|
|
38
40
|
/** True from a player-initiated jump until the apex, so only those jumps are cut short on release. */
|
|
39
41
|
jumping: boolean;
|
|
40
42
|
bufferTimer: number;
|
|
41
|
-
spawnX: number;
|
|
42
|
-
spawnY: number;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export interface PatrolState extends Patrol {
|
|
@@ -65,10 +65,14 @@ export interface Entity {
|
|
|
65
65
|
body?: BodyState;
|
|
66
66
|
sprite?: Sprite;
|
|
67
67
|
platformer?: PlatformerState;
|
|
68
|
+
topdown?: Topdown;
|
|
69
|
+
/** Where a player respawns after dying. */
|
|
70
|
+
spawn?: { x: number; y: number };
|
|
68
71
|
collectible?: Collectible;
|
|
69
72
|
patrol?: PatrolState;
|
|
70
73
|
hazard?: Hazard;
|
|
71
74
|
goal?: Goal;
|
|
75
|
+
checkpoint?: Record<string, never>;
|
|
72
76
|
emitter?: Emitter;
|
|
73
77
|
camera?: Camera;
|
|
74
78
|
}
|
|
@@ -81,6 +85,10 @@ export type GameEvent = { frame: number; time: number; x: number; y: number } &
|
|
|
81
85
|
| { type: "die"; entity: string; cause: string }
|
|
82
86
|
| { type: "stomp"; entity: string; enemy: string; score: number }
|
|
83
87
|
| { type: "win"; entity: string; goal: string }
|
|
88
|
+
| { type: "bounce"; entity: string; other: string }
|
|
89
|
+
| { type: "checkpoint"; entity: string; checkpoint: string }
|
|
90
|
+
| { type: "gameover"; entity: string; cause: string }
|
|
91
|
+
| { type: "goto"; entity: string; scene: string }
|
|
84
92
|
/** Fired by a script with world.emit(name, data). */
|
|
85
93
|
| { type: "custom"; name: string; entity: string; data?: unknown }
|
|
86
94
|
);
|
|
@@ -100,6 +108,8 @@ export interface EntitySnapshot {
|
|
|
100
108
|
export interface WorldOptions {
|
|
101
109
|
/** Loaded script modules, keyed by the `src` path used in the scene. */
|
|
102
110
|
scripts?: ScriptModules;
|
|
111
|
+
/** Values carried over from the previous scene, so a run keeps its score and lives. */
|
|
112
|
+
carry?: { score?: number; deaths?: number; lives?: number };
|
|
103
113
|
}
|
|
104
114
|
|
|
105
115
|
export interface WorldSnapshot {
|
|
@@ -108,6 +118,9 @@ export interface WorldSnapshot {
|
|
|
108
118
|
score: number;
|
|
109
119
|
deaths: number;
|
|
110
120
|
won: boolean;
|
|
121
|
+
lost: boolean;
|
|
122
|
+
/** Lives left; undefined when the scene has unlimited retries. */
|
|
123
|
+
lives?: number;
|
|
111
124
|
entities: Record<string, EntitySnapshot>;
|
|
112
125
|
}
|
|
113
126
|
|
|
@@ -133,6 +146,8 @@ const STOMP_TOLERANCE = 8;
|
|
|
133
146
|
const FALL_MARGIN = 100;
|
|
134
147
|
/** Bodies this close count as touching for `touch` hooks, so solids (doors, walls) can be touched too. */
|
|
135
148
|
const TOUCH_MARGIN = 1;
|
|
149
|
+
/** Impacts slower than this (px/s, after bounce) stop instead of bouncing. */
|
|
150
|
+
const MIN_BOUNCE_SPEED = 40;
|
|
136
151
|
const EPSILON = 1e-4;
|
|
137
152
|
|
|
138
153
|
/**
|
|
@@ -152,6 +167,14 @@ export class World {
|
|
|
152
167
|
deaths = 0;
|
|
153
168
|
/** Set when a player reaches a goal. The simulation stops changing after that. */
|
|
154
169
|
won = false;
|
|
170
|
+
/** Set when the game is over: out of lives, or a script called world.lose(). */
|
|
171
|
+
lost = false;
|
|
172
|
+
/** Lives left, or undefined for unlimited retries. */
|
|
173
|
+
lives: number | undefined;
|
|
174
|
+
/** The scene to play next (a won goal's `next`, or world.goto). Hosts that can load scenes move on to it. */
|
|
175
|
+
exit: string | undefined;
|
|
176
|
+
private readonly prefabs: Record<string, { components: Record<string, Record<string, unknown>> }>;
|
|
177
|
+
private readonly reached = new Set<string>();
|
|
155
178
|
private previousInput: ReadonlySet<Action> = new Set();
|
|
156
179
|
private readonly scripts: ScriptModules | undefined;
|
|
157
180
|
private seed: number;
|
|
@@ -161,6 +184,8 @@ export class World {
|
|
|
161
184
|
private scriptInput: ScriptInput = { held: () => false, pressed: () => false };
|
|
162
185
|
/** The script hook currently running, for error messages and event attribution. */
|
|
163
186
|
private current: { entity: Entity; hook: string } | undefined;
|
|
187
|
+
/** True while a draw hook runs: the game can't be changed then. */
|
|
188
|
+
private drawing = false;
|
|
164
189
|
|
|
165
190
|
constructor(scene: SceneDef, options: WorldOptions = {}) {
|
|
166
191
|
this.width = scene.world?.width ?? 960;
|
|
@@ -169,6 +194,10 @@ export class World {
|
|
|
169
194
|
this.background = scene.world?.background ?? "#1d2333";
|
|
170
195
|
this.seed = scene.world?.seed ?? 1;
|
|
171
196
|
this.scripts = options.scripts;
|
|
197
|
+
this.prefabs = scene.prefabs ?? {};
|
|
198
|
+
this.score = options.carry?.score ?? 0;
|
|
199
|
+
this.deaths = options.carry?.deaths ?? 0;
|
|
200
|
+
this.lives = options.carry?.lives ?? scene.world?.lives;
|
|
172
201
|
this.entities = scene.entities.map((def) => this.createEntity(def));
|
|
173
202
|
}
|
|
174
203
|
|
|
@@ -183,6 +212,11 @@ export class World {
|
|
|
183
212
|
return this.frame * FIXED_DT;
|
|
184
213
|
}
|
|
185
214
|
|
|
215
|
+
/** True once nothing more happens in this scene: won, lost, or moving on to another scene. */
|
|
216
|
+
get over(): boolean {
|
|
217
|
+
return this.won || this.lost || this.exit !== undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
186
220
|
get(id: string): Entity | undefined {
|
|
187
221
|
return this.entities.find((e) => e.id === id);
|
|
188
222
|
}
|
|
@@ -192,17 +226,20 @@ export class World {
|
|
|
192
226
|
const pressed = new Set([...input].filter((action) => !this.previousInput.has(action)));
|
|
193
227
|
this.previousInput = new Set(input);
|
|
194
228
|
this.frame++;
|
|
195
|
-
if (this.
|
|
229
|
+
if (this.over) return;
|
|
196
230
|
|
|
197
231
|
this.updatePlatformers(input, pressed);
|
|
232
|
+
this.updateTopdown(input);
|
|
198
233
|
this.updateScripts(input, pressed);
|
|
199
|
-
if (this.
|
|
234
|
+
if (this.over) return;
|
|
200
235
|
this.updatePatrols();
|
|
201
236
|
this.updateBodies();
|
|
202
237
|
this.updateTouches();
|
|
203
|
-
if (this.
|
|
238
|
+
if (this.over) return;
|
|
204
239
|
this.updateCollectibles();
|
|
240
|
+
this.updateCheckpoints();
|
|
205
241
|
this.updateHazards();
|
|
242
|
+
if (this.over) return;
|
|
206
243
|
this.updateGoals();
|
|
207
244
|
}
|
|
208
245
|
|
|
@@ -219,7 +256,7 @@ export class World {
|
|
|
219
256
|
// A JSON copy, so later frames can't change an earlier snapshot.
|
|
220
257
|
if (Object.keys(e.state).length > 0) entities[e.id]!.state = JSON.parse(JSON.stringify(e.state));
|
|
221
258
|
}
|
|
222
|
-
return { frame: this.frame, time: this.time, score: this.score, deaths: this.deaths, won: this.won, entities };
|
|
259
|
+
return { frame: this.frame, time: this.time, score: this.score, deaths: this.deaths, won: this.won, lost: this.lost, lives: this.lives, entities };
|
|
223
260
|
}
|
|
224
261
|
|
|
225
262
|
private emit(event: EventPayload): void {
|
|
@@ -260,8 +297,8 @@ export class World {
|
|
|
260
297
|
e.script.started = true;
|
|
261
298
|
this.callHook(e, "start");
|
|
262
299
|
}
|
|
263
|
-
if (!e.removed && !this.
|
|
264
|
-
if (this.
|
|
300
|
+
if (!e.removed && !this.over) this.callHook(e, "update");
|
|
301
|
+
if (this.over) return;
|
|
265
302
|
}
|
|
266
303
|
}
|
|
267
304
|
|
|
@@ -286,9 +323,36 @@ export class World {
|
|
|
286
323
|
const fn = script.module[hook];
|
|
287
324
|
if (!fn) return;
|
|
288
325
|
const ctx: ScriptContext = { self: this.handle(entity), world: this.api(), input: this.scriptInput, params: script.params, dt: FIXED_DT };
|
|
326
|
+
this.run(entity, hook, () => (fn as (ctx: ScriptContext, other?: ScriptEntity) => void).call(script.module, ctx, other));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Runs an entity's `draw` or `drawUI` hook. Called by the renderer, never by the
|
|
331
|
+
* simulation. `self` and `world` are read-only for the duration.
|
|
332
|
+
*/
|
|
333
|
+
callDrawHook(entity: Entity, hook: "draw" | "drawUI", extras: Omit<DrawContext, "self" | "world" | "params">): void {
|
|
334
|
+
const script = entity.script;
|
|
335
|
+
const fn = script?.module[hook];
|
|
336
|
+
if (!script || !fn || entity.removed) return;
|
|
337
|
+
const ctx: DrawContext = { ...extras, self: this.handle(entity), world: this.api(), params: script.params };
|
|
338
|
+
this.drawing = true;
|
|
339
|
+
try {
|
|
340
|
+
this.run(entity, hook, () => fn.call(script.module, ctx));
|
|
341
|
+
} finally {
|
|
342
|
+
this.drawing = false;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** True when any live entity's script has the given hook. */
|
|
347
|
+
hasHook(hook: "draw" | "drawUI"): boolean {
|
|
348
|
+
return this.entities.some((e) => !e.removed && typeof e.script?.module[hook] === "function");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private run(entity: Entity, hook: string, call: () => void): void {
|
|
352
|
+
const script = entity.script!;
|
|
289
353
|
this.current = { entity, hook };
|
|
290
354
|
try {
|
|
291
|
-
|
|
355
|
+
call();
|
|
292
356
|
} catch (err) {
|
|
293
357
|
if (err instanceof ScriptError) throw err;
|
|
294
358
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -298,6 +362,13 @@ export class World {
|
|
|
298
362
|
}
|
|
299
363
|
}
|
|
300
364
|
|
|
365
|
+
/** Draw hooks only paint, so the browser can't drift from what tests verified. */
|
|
366
|
+
private guard(what: string): void {
|
|
367
|
+
if (this.drawing) {
|
|
368
|
+
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.`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
301
372
|
/** An error caused by how a script used the API, reported against the running script. */
|
|
302
373
|
private misuse(message: string): ScriptError {
|
|
303
374
|
const running = this.current;
|
|
@@ -320,24 +391,28 @@ export class World {
|
|
|
320
391
|
return e.transform.x;
|
|
321
392
|
},
|
|
322
393
|
set x(value: number) {
|
|
394
|
+
world.guard("Setting x");
|
|
323
395
|
e.transform.x = value;
|
|
324
396
|
},
|
|
325
397
|
get y() {
|
|
326
398
|
return e.transform.y;
|
|
327
399
|
},
|
|
328
400
|
set y(value: number) {
|
|
401
|
+
world.guard("Setting y");
|
|
329
402
|
e.transform.y = value;
|
|
330
403
|
},
|
|
331
404
|
get vx() {
|
|
332
405
|
return e.body?.vx ?? 0;
|
|
333
406
|
},
|
|
334
407
|
set vx(value: number) {
|
|
408
|
+
world.guard("Setting vx");
|
|
335
409
|
if (e.body) e.body.vx = value;
|
|
336
410
|
},
|
|
337
411
|
get vy() {
|
|
338
412
|
return e.body?.vy ?? 0;
|
|
339
413
|
},
|
|
340
414
|
set vy(value: number) {
|
|
415
|
+
world.guard("Setting vy");
|
|
341
416
|
if (e.body) e.body.vy = value;
|
|
342
417
|
},
|
|
343
418
|
get grounded() {
|
|
@@ -353,7 +428,8 @@ export class World {
|
|
|
353
428
|
return !e.removed;
|
|
354
429
|
},
|
|
355
430
|
get state() {
|
|
356
|
-
|
|
431
|
+
// A frozen copy while drawing, so a draw hook can't write game state by accident.
|
|
432
|
+
return world.drawing ? Object.freeze({ ...e.state }) : e.state;
|
|
357
433
|
},
|
|
358
434
|
has(component: string) {
|
|
359
435
|
return component === "transform" || (component in e && (e as unknown as Record<string, unknown>)[component] !== undefined);
|
|
@@ -391,7 +467,29 @@ export class World {
|
|
|
391
467
|
get score() {
|
|
392
468
|
return world.score;
|
|
393
469
|
},
|
|
470
|
+
get deaths() {
|
|
471
|
+
return world.deaths;
|
|
472
|
+
},
|
|
473
|
+
get won() {
|
|
474
|
+
return world.won;
|
|
475
|
+
},
|
|
476
|
+
get lost() {
|
|
477
|
+
return world.lost;
|
|
478
|
+
},
|
|
479
|
+
get exit() {
|
|
480
|
+
return world.exit;
|
|
481
|
+
},
|
|
482
|
+
get lives() {
|
|
483
|
+
return world.lives;
|
|
484
|
+
},
|
|
485
|
+
set lives(value: number | undefined) {
|
|
486
|
+
world.guard("Setting lives");
|
|
487
|
+
if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value))) throw world.misuse("world.lives must be a number.");
|
|
488
|
+
world.lives = value;
|
|
489
|
+
if (value !== undefined && value <= 0) world.gameOver(world.current?.entity.id ?? "script", "script");
|
|
490
|
+
},
|
|
394
491
|
set score(value: number) {
|
|
492
|
+
world.guard("Setting score");
|
|
395
493
|
world.score = value;
|
|
396
494
|
},
|
|
397
495
|
get: (id) => {
|
|
@@ -401,27 +499,46 @@ export class World {
|
|
|
401
499
|
all: (component) =>
|
|
402
500
|
world.entities.filter((e) => !component || world.handle(e).has(component)).map((e) => world.handle(e)),
|
|
403
501
|
player: () => {
|
|
404
|
-
const e = world.entities.find(
|
|
502
|
+
const e = world.entities.find(isPlayer);
|
|
405
503
|
return e ? world.handle(e) : undefined;
|
|
406
504
|
},
|
|
407
|
-
spawn: (def) =>
|
|
505
|
+
spawn: (def) => {
|
|
506
|
+
world.guard("world.spawn");
|
|
507
|
+
return world.spawn(def);
|
|
508
|
+
},
|
|
408
509
|
destroy: (target) => {
|
|
510
|
+
world.guard("world.destroy");
|
|
409
511
|
const e = world.resolve(target);
|
|
410
512
|
if (e) world.remove(e);
|
|
411
513
|
},
|
|
412
514
|
kill: (target) => {
|
|
515
|
+
world.guard("world.kill");
|
|
413
516
|
const e = world.resolve(target);
|
|
414
517
|
if (!e) return;
|
|
415
|
-
if (e
|
|
518
|
+
if (isPlayer(e)) world.kill(e, world.current?.entity.id ?? "script");
|
|
416
519
|
else world.remove(e);
|
|
417
520
|
},
|
|
418
521
|
win: () => {
|
|
522
|
+
world.guard("world.win");
|
|
419
523
|
if (world.won) return;
|
|
420
524
|
const by = world.current?.entity;
|
|
421
525
|
world.won = true;
|
|
422
526
|
world.emit({ type: "win", entity: by?.id ?? "script", goal: by?.id ?? "script", x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
|
|
423
527
|
},
|
|
528
|
+
lose: () => {
|
|
529
|
+
world.guard("world.lose");
|
|
530
|
+
world.gameOver(world.current?.entity.id ?? "script", "script");
|
|
531
|
+
},
|
|
532
|
+
goto: (scene) => {
|
|
533
|
+
world.guard("world.goto");
|
|
534
|
+
if (typeof scene !== "string" || scene === "") throw world.misuse('world.goto needs a scene path, e.g. "scenes/level-1.scene.json".');
|
|
535
|
+
if (world.exit !== undefined) return;
|
|
536
|
+
world.exit = scene;
|
|
537
|
+
const by = world.current?.entity;
|
|
538
|
+
world.emit({ type: "goto", entity: by?.id ?? "script", scene, x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
|
|
539
|
+
},
|
|
424
540
|
emit: (name, data) => {
|
|
541
|
+
world.guard("world.emit");
|
|
425
542
|
if (typeof name !== "string" || name === "") throw world.misuse("world.emit needs a non-empty event name.");
|
|
426
543
|
if ((EVENT_TYPES as readonly string[]).includes(name)) {
|
|
427
544
|
throw world.misuse(`"${name}" is a built-in event and can't be emitted by scripts. Pick another name, e.g. "${name}-custom".`);
|
|
@@ -429,24 +546,36 @@ export class World {
|
|
|
429
546
|
const by = world.current?.entity;
|
|
430
547
|
world.emit({ type: "custom", name, entity: by?.id ?? "script", data, x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
|
|
431
548
|
},
|
|
432
|
-
random: () =>
|
|
549
|
+
random: () => {
|
|
550
|
+
world.guard("world.random (it advances the game's seed; use ctx.random in draw hooks)");
|
|
551
|
+
return world.random();
|
|
552
|
+
},
|
|
433
553
|
};
|
|
434
554
|
return this.scriptApi;
|
|
435
555
|
}
|
|
436
556
|
|
|
437
557
|
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 } } }.');
|
|
558
|
+
if (typeof def !== "object" || def === null || (def.prefab === undefined && typeof def.components !== "object")) {
|
|
559
|
+
throw this.misuse('world.spawn needs { components: { ... } } or { prefab: "name" }, e.g. { prefab: "bullet", components: { transform: { x: 0, y: 0 } } }.');
|
|
560
|
+
}
|
|
561
|
+
let components = def.components ?? {};
|
|
562
|
+
if (def.prefab !== undefined) {
|
|
563
|
+
const prefab = this.prefabs[def.prefab];
|
|
564
|
+
if (!prefab) {
|
|
565
|
+
const names = Object.keys(this.prefabs);
|
|
566
|
+
throw this.misuse(`world.spawn: prefab "${def.prefab}" does not exist.${names.length > 0 ? ` Prefabs: ${names.join(", ")}.` : " Create prefabs/<name>.prefab.json."}`);
|
|
567
|
+
}
|
|
568
|
+
components = mergeComponents(prefab.components, components);
|
|
440
569
|
}
|
|
441
570
|
const id = def.id ?? `${this.current?.entity.id ?? "spawned"}-${++this.spawnCount}`;
|
|
442
571
|
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
|
|
572
|
+
const { diagnostics } = validateScene({ entities: [{ id, components }] });
|
|
444
573
|
const errors = diagnostics.filter((d) => d.severity === "error" && d.code !== "E_GOAL_UNREACHABLE");
|
|
445
574
|
if (errors.length > 0) {
|
|
446
575
|
const details = errors.map((d) => `${d.path.replace("entities[0]", id)}: ${d.message}${d.fix ? ` Fix: ${d.fix}` : ""}`);
|
|
447
576
|
throw this.misuse(`world.spawn got an invalid entity. ${details.join(" ")}`);
|
|
448
577
|
}
|
|
449
|
-
const entity = this.createEntity({ id, components
|
|
578
|
+
const entity = this.createEntity({ id, components });
|
|
450
579
|
this.entities.push(entity);
|
|
451
580
|
return this.handle(entity);
|
|
452
581
|
}
|
|
@@ -462,14 +591,33 @@ export class World {
|
|
|
462
591
|
|
|
463
592
|
// ------------------------------------------------------------ built-ins
|
|
464
593
|
|
|
594
|
+
/** Entities the player controls: platformers and topdown movers. */
|
|
465
595
|
private players(): Entity[] {
|
|
466
|
-
return this.entities.filter(
|
|
596
|
+
return this.entities.filter(isPlayer);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private updateTopdown(held: ReadonlySet<Action>): void {
|
|
600
|
+
for (const e of this.entities) {
|
|
601
|
+
const t = e.topdown;
|
|
602
|
+
const b = e.body;
|
|
603
|
+
if (!t || !b) continue;
|
|
604
|
+
let dx = (held.has("right") ? 1 : 0) - (held.has("left") ? 1 : 0);
|
|
605
|
+
let dy = (held.has("down") ? 1 : 0) - (held.has("up") ? 1 : 0);
|
|
606
|
+
if (t.directions === "x") dy = 0;
|
|
607
|
+
if (t.directions === "y") dx = 0;
|
|
608
|
+
if (t.directions === "4" && dx !== 0) dy = 0;
|
|
609
|
+
// Diagonals move at the same speed as straight lines.
|
|
610
|
+
const length = Math.hypot(dx, dy) || 1;
|
|
611
|
+
b.vx = (dx / length) * t.speed;
|
|
612
|
+
b.vy = (dy / length) * t.speed;
|
|
613
|
+
}
|
|
467
614
|
}
|
|
468
615
|
|
|
469
616
|
private updatePlatformers(held: ReadonlySet<Action>, pressed: ReadonlySet<Action>): void {
|
|
470
|
-
for (const e of this.
|
|
471
|
-
const p = e.platformer
|
|
472
|
-
const b = e.body
|
|
617
|
+
for (const e of this.entities) {
|
|
618
|
+
const p = e.platformer;
|
|
619
|
+
const b = e.body;
|
|
620
|
+
if (!p || !b) continue;
|
|
473
621
|
|
|
474
622
|
const direction = (held.has("right") ? 1 : 0) - (held.has("left") ? 1 : 0);
|
|
475
623
|
b.vx = direction * p.speed;
|
|
@@ -517,13 +665,18 @@ export class World {
|
|
|
517
665
|
private updateBodies(): void {
|
|
518
666
|
// Kinematic bodies move first, so anything standing on them can follow.
|
|
519
667
|
const moved = new Map<Entity, { dx: number; dy: number }>();
|
|
668
|
+
const walls = this.entities.filter((e) => e.body?.type === "static" && !e.body.sensor);
|
|
520
669
|
for (const e of this.entities) {
|
|
521
670
|
const b = e.body;
|
|
522
671
|
if (!b || b.type !== "kinematic") continue;
|
|
523
672
|
const { x, y } = e.transform;
|
|
524
673
|
e.transform.x += b.vx * FIXED_DT;
|
|
674
|
+
// A player-steered kinematic body (a paddle) stops at walls; scripted movers pass through.
|
|
675
|
+
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;
|
|
525
676
|
e.transform.y += b.vy * FIXED_DT;
|
|
677
|
+
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;
|
|
526
678
|
if (e.patrol) clampToPatrol(e, e.patrol);
|
|
679
|
+
if (e.topdown?.clampToWorld) this.clampToWorld(e);
|
|
527
680
|
moved.set(e, { dx: e.transform.x - x, dy: e.transform.y - y });
|
|
528
681
|
}
|
|
529
682
|
|
|
@@ -539,7 +692,8 @@ export class World {
|
|
|
539
692
|
e.transform.y += carried.dy;
|
|
540
693
|
}
|
|
541
694
|
|
|
542
|
-
|
|
695
|
+
// Top-down movers ignore gravity; everything else falls.
|
|
696
|
+
if (!e.topdown) b.vy = Math.min(b.vy + this.gravity * b.gravityScale * FIXED_DT, MAX_FALL_SPEED);
|
|
543
697
|
const wasGrounded = b.grounded;
|
|
544
698
|
b.grounded = false;
|
|
545
699
|
b.standingOn = undefined;
|
|
@@ -551,27 +705,47 @@ export class World {
|
|
|
551
705
|
const half = (solid.body!.width + b.width) / 2;
|
|
552
706
|
if (b.vx > 0) e.transform.x = solid.transform.x - half;
|
|
553
707
|
else if (b.vx < 0) e.transform.x = solid.transform.x + half;
|
|
554
|
-
b.vx =
|
|
708
|
+
b.vx = this.rebound(e, solid, b.vx);
|
|
555
709
|
}
|
|
556
710
|
|
|
557
711
|
e.transform.y += b.vy * FIXED_DT;
|
|
558
712
|
for (const solid of solids) {
|
|
559
713
|
if (!overlaps(e, solid)) continue;
|
|
560
714
|
const half = (solid.body!.height + b.height) / 2;
|
|
561
|
-
|
|
562
|
-
|
|
715
|
+
const falling = b.vy > 0;
|
|
716
|
+
if (b.vy > 0) e.transform.y = solid.transform.y - half;
|
|
717
|
+
else if (b.vy < 0) e.transform.y = solid.transform.y + half;
|
|
718
|
+
b.vy = this.rebound(e, solid, b.vy);
|
|
719
|
+
// A body that stopped on top of something is standing on it; one that bounced off is airborne.
|
|
720
|
+
if (falling && b.vy === 0) {
|
|
563
721
|
b.grounded = true;
|
|
564
722
|
b.standingOn = solid;
|
|
565
|
-
} else if (b.vy < 0) {
|
|
566
|
-
e.transform.y = solid.transform.y + half;
|
|
567
723
|
}
|
|
568
|
-
b.vy = 0;
|
|
569
724
|
}
|
|
570
725
|
|
|
726
|
+
if (e.topdown?.clampToWorld) this.clampToWorld(e);
|
|
571
727
|
if (b.grounded && !wasGrounded) this.emit({ type: "land", entity: e.id, ...feet(e) });
|
|
572
728
|
}
|
|
573
729
|
}
|
|
574
730
|
|
|
731
|
+
/**
|
|
732
|
+
* The velocity along one axis after hitting `solid`: 0, or reversed and scaled by `bounce`.
|
|
733
|
+
* Slow impacts stop instead, so resting bodies don't jitter.
|
|
734
|
+
*/
|
|
735
|
+
private rebound(e: Entity, solid: Entity, velocity: number): number {
|
|
736
|
+
const bounce = e.body!.bounce;
|
|
737
|
+
if (bounce <= 0 || Math.abs(velocity) * bounce < MIN_BOUNCE_SPEED) return 0;
|
|
738
|
+
this.emit({ type: "bounce", entity: e.id, other: solid.id, ...e.transform });
|
|
739
|
+
return -velocity * bounce;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
private clampToWorld(e: Entity): void {
|
|
743
|
+
const halfWidth = (e.body?.width ?? 0) / 2;
|
|
744
|
+
const halfHeight = (e.body?.height ?? 0) / 2;
|
|
745
|
+
e.transform.x = Math.min(this.width - halfWidth, Math.max(halfWidth, e.transform.x));
|
|
746
|
+
e.transform.y = Math.min(this.height - halfHeight, Math.max(halfHeight, e.transform.y));
|
|
747
|
+
}
|
|
748
|
+
|
|
575
749
|
private updateCollectibles(): void {
|
|
576
750
|
const players = this.players();
|
|
577
751
|
for (const item of this.entities.filter((e) => e.collectible && e.body)) {
|
|
@@ -592,7 +766,7 @@ export class World {
|
|
|
592
766
|
}
|
|
593
767
|
for (const hazard of this.entities.filter((e) => e.hazard && e.body)) {
|
|
594
768
|
if (!overlaps(player, hazard)) continue;
|
|
595
|
-
if (hazard.hazard!.stompable && isStomp(player, hazard)) {
|
|
769
|
+
if (hazard.hazard!.stompable && player.platformer && isStomp(player, hazard)) {
|
|
596
770
|
this.remove(hazard);
|
|
597
771
|
b.vy = -player.platformer!.jumpSpeed * STOMP_BOUNCE;
|
|
598
772
|
this.score += hazard.hazard!.score;
|
|
@@ -611,26 +785,55 @@ export class World {
|
|
|
611
785
|
const player = this.players().find((p) => overlaps(p, goal));
|
|
612
786
|
if (!player) continue;
|
|
613
787
|
this.won = true;
|
|
788
|
+
if (goal.goal!.next) this.exit = goal.goal!.next;
|
|
614
789
|
this.emit({ type: "win", entity: player.id, goal: goal.id, ...goal.transform });
|
|
615
790
|
return;
|
|
616
791
|
}
|
|
617
792
|
}
|
|
618
793
|
|
|
794
|
+
/** A touched checkpoint becomes the player's respawn point, standing on the checkpoint's bottom edge. */
|
|
795
|
+
private updateCheckpoints(): void {
|
|
796
|
+
for (const checkpoint of this.entities.filter((e) => e.checkpoint && e.body)) {
|
|
797
|
+
for (const player of this.players()) {
|
|
798
|
+
const key = `${player.id}>${checkpoint.id}`;
|
|
799
|
+
if (this.reached.has(key) || !overlaps(player, checkpoint)) continue;
|
|
800
|
+
this.reached.add(key);
|
|
801
|
+
const bottom = checkpoint.transform.y + checkpoint.body!.height / 2;
|
|
802
|
+
player.spawn = { x: checkpoint.transform.x, y: player.platformer ? bottom - player.body!.height / 2 : checkpoint.transform.y };
|
|
803
|
+
this.emit({ type: "checkpoint", entity: player.id, checkpoint: checkpoint.id, ...checkpoint.transform });
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
private gameOver(entity: string, cause: string): void {
|
|
809
|
+
if (this.lost) return;
|
|
810
|
+
this.lost = true;
|
|
811
|
+
const e = this.get(entity);
|
|
812
|
+
this.emit({ type: "gameover", entity, cause, x: e?.transform.x ?? 0, y: e?.transform.y ?? 0 });
|
|
813
|
+
}
|
|
814
|
+
|
|
619
815
|
/** Sends a player back to where it started. Collected items stay collected. */
|
|
620
816
|
private kill(player: Entity, cause: string): void {
|
|
621
817
|
const b = player.body!;
|
|
622
|
-
const p = player.platformer!;
|
|
623
818
|
this.deaths++;
|
|
624
819
|
this.emit({ type: "die", entity: player.id, cause, ...player.transform });
|
|
625
|
-
|
|
626
|
-
|
|
820
|
+
if (this.lives !== undefined) {
|
|
821
|
+
this.lives--;
|
|
822
|
+
// Out of lives: the player stays where it fell and the game is over.
|
|
823
|
+
if (this.lives <= 0) return this.gameOver(player.id, cause);
|
|
824
|
+
}
|
|
825
|
+
player.transform.x = player.spawn?.x ?? player.transform.x;
|
|
826
|
+
player.transform.y = player.spawn?.y ?? player.transform.y;
|
|
627
827
|
b.vx = 0;
|
|
628
828
|
b.vy = 0;
|
|
629
829
|
b.grounded = false;
|
|
630
830
|
b.standingOn = undefined;
|
|
631
|
-
p
|
|
632
|
-
p
|
|
633
|
-
|
|
831
|
+
const p = player.platformer;
|
|
832
|
+
if (p) {
|
|
833
|
+
p.coyoteTimer = 0;
|
|
834
|
+
p.bufferTimer = 0;
|
|
835
|
+
p.jumping = false;
|
|
836
|
+
}
|
|
634
837
|
}
|
|
635
838
|
}
|
|
636
839
|
|
|
@@ -646,19 +849,24 @@ function createEntity(def: EntityDef): Entity {
|
|
|
646
849
|
coyoteTimer: 0,
|
|
647
850
|
bufferTimer: 0,
|
|
648
851
|
jumping: false,
|
|
649
|
-
spawnX: transform.x,
|
|
650
|
-
spawnY: transform.y,
|
|
651
852
|
};
|
|
652
853
|
}
|
|
854
|
+
if (c.topdown) entity.topdown = resolveComponent<Topdown>("topdown", c.topdown);
|
|
855
|
+
if (entity.platformer || entity.topdown) entity.spawn = { x: transform.x, y: transform.y };
|
|
653
856
|
if (c.collectible) entity.collectible = resolveComponent<Collectible>("collectible", c.collectible);
|
|
654
857
|
if (c.patrol) entity.patrol = { ...resolveComponent<Patrol>("patrol", c.patrol), originX: transform.x, originY: transform.y };
|
|
655
858
|
if (c.hazard) entity.hazard = resolveComponent<Hazard>("hazard", c.hazard);
|
|
656
859
|
if (c.goal) entity.goal = resolveComponent<Goal>("goal", c.goal);
|
|
860
|
+
if (c.checkpoint) entity.checkpoint = {};
|
|
657
861
|
if (c.emitter) entity.emitter = resolveComponent<Emitter>("emitter", c.emitter);
|
|
658
862
|
if (c.camera) entity.camera = resolveComponent<Camera>("camera", c.camera);
|
|
659
863
|
return entity;
|
|
660
864
|
}
|
|
661
865
|
|
|
866
|
+
function isPlayer(e: Entity): boolean {
|
|
867
|
+
return !!(e.platformer || e.topdown) && !!e.body;
|
|
868
|
+
}
|
|
869
|
+
|
|
662
870
|
function feet(e: Entity): { x: number; y: number } {
|
|
663
871
|
return { x: e.transform.x, y: e.transform.y + (e.body?.height ?? 0) / 2 };
|
|
664
872
|
}
|