@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/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.won) return;
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.won) return;
234
+ if (this.over) return;
200
235
  this.updatePatrols();
201
236
  this.updateBodies();
202
237
  this.updateTouches();
203
- if (this.won) return;
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.won) this.callHook(e, "update");
264
- if (this.won) return;
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
- (fn as (ctx: ScriptContext, other?: ScriptEntity) => void).call(script.module, ctx, other);
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
- return e.state;
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((candidate) => candidate.platformer);
502
+ const e = world.entities.find(isPlayer);
405
503
  return e ? world.handle(e) : undefined;
406
504
  },
407
- spawn: (def) => world.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.platformer && e.body) world.kill(e, world.current?.entity.id ?? "script");
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: () => world.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: def.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: def.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((e) => e.platformer && e.body);
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.players()) {
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
- b.vy = Math.min(b.vy + this.gravity * b.gravityScale * FIXED_DT, MAX_FALL_SPEED);
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 = 0;
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
- if (b.vy > 0) {
562
- e.transform.y = solid.transform.y - half;
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
- player.transform.x = p.spawnX;
626
- player.transform.y = p.spawnY;
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.coyoteTimer = 0;
632
- p.bufferTimer = 0;
633
- p.jumping = false;
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
  }