@ludra/core 0.7.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
@@ -27,7 +27,7 @@ import {
27
27
  } from "./script.ts";
28
28
  import { FIXED_DT } from "./time.ts";
29
29
  import { EVENT_TYPES, type Action, type Diagnostic, type EntityDef, type SceneDef } from "./types.ts";
30
- import { validateScene } from "./validate.ts";
30
+ import { mergeComponents, validateScene } from "./validate.ts";
31
31
 
32
32
  export interface BodyState extends Body {
33
33
  grounded: boolean;
@@ -72,6 +72,7 @@ export interface Entity {
72
72
  patrol?: PatrolState;
73
73
  hazard?: Hazard;
74
74
  goal?: Goal;
75
+ checkpoint?: Record<string, never>;
75
76
  emitter?: Emitter;
76
77
  camera?: Camera;
77
78
  }
@@ -85,6 +86,9 @@ export type GameEvent = { frame: number; time: number; x: number; y: number } &
85
86
  | { type: "stomp"; entity: string; enemy: string; score: number }
86
87
  | { type: "win"; entity: string; goal: string }
87
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 }
88
92
  /** Fired by a script with world.emit(name, data). */
89
93
  | { type: "custom"; name: string; entity: string; data?: unknown }
90
94
  );
@@ -104,6 +108,8 @@ export interface EntitySnapshot {
104
108
  export interface WorldOptions {
105
109
  /** Loaded script modules, keyed by the `src` path used in the scene. */
106
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 };
107
113
  }
108
114
 
109
115
  export interface WorldSnapshot {
@@ -112,6 +118,9 @@ export interface WorldSnapshot {
112
118
  score: number;
113
119
  deaths: number;
114
120
  won: boolean;
121
+ lost: boolean;
122
+ /** Lives left; undefined when the scene has unlimited retries. */
123
+ lives?: number;
115
124
  entities: Record<string, EntitySnapshot>;
116
125
  }
117
126
 
@@ -158,6 +167,14 @@ export class World {
158
167
  deaths = 0;
159
168
  /** Set when a player reaches a goal. The simulation stops changing after that. */
160
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>();
161
178
  private previousInput: ReadonlySet<Action> = new Set();
162
179
  private readonly scripts: ScriptModules | undefined;
163
180
  private seed: number;
@@ -177,6 +194,10 @@ export class World {
177
194
  this.background = scene.world?.background ?? "#1d2333";
178
195
  this.seed = scene.world?.seed ?? 1;
179
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;
180
201
  this.entities = scene.entities.map((def) => this.createEntity(def));
181
202
  }
182
203
 
@@ -191,6 +212,11 @@ export class World {
191
212
  return this.frame * FIXED_DT;
192
213
  }
193
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
+
194
220
  get(id: string): Entity | undefined {
195
221
  return this.entities.find((e) => e.id === id);
196
222
  }
@@ -200,18 +226,20 @@ export class World {
200
226
  const pressed = new Set([...input].filter((action) => !this.previousInput.has(action)));
201
227
  this.previousInput = new Set(input);
202
228
  this.frame++;
203
- if (this.won) return;
229
+ if (this.over) return;
204
230
 
205
231
  this.updatePlatformers(input, pressed);
206
232
  this.updateTopdown(input);
207
233
  this.updateScripts(input, pressed);
208
- if (this.won) return;
234
+ if (this.over) return;
209
235
  this.updatePatrols();
210
236
  this.updateBodies();
211
237
  this.updateTouches();
212
- if (this.won) return;
238
+ if (this.over) return;
213
239
  this.updateCollectibles();
240
+ this.updateCheckpoints();
214
241
  this.updateHazards();
242
+ if (this.over) return;
215
243
  this.updateGoals();
216
244
  }
217
245
 
@@ -228,7 +256,7 @@ export class World {
228
256
  // A JSON copy, so later frames can't change an earlier snapshot.
229
257
  if (Object.keys(e.state).length > 0) entities[e.id]!.state = JSON.parse(JSON.stringify(e.state));
230
258
  }
231
- 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 };
232
260
  }
233
261
 
234
262
  private emit(event: EventPayload): void {
@@ -269,8 +297,8 @@ export class World {
269
297
  e.script.started = true;
270
298
  this.callHook(e, "start");
271
299
  }
272
- if (!e.removed && !this.won) this.callHook(e, "update");
273
- if (this.won) return;
300
+ if (!e.removed && !this.over) this.callHook(e, "update");
301
+ if (this.over) return;
274
302
  }
275
303
  }
276
304
 
@@ -445,6 +473,21 @@ export class World {
445
473
  get won() {
446
474
  return world.won;
447
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
+ },
448
491
  set score(value: number) {
449
492
  world.guard("Setting score");
450
493
  world.score = value;
@@ -482,6 +525,18 @@ export class World {
482
525
  world.won = true;
483
526
  world.emit({ type: "win", entity: by?.id ?? "script", goal: by?.id ?? "script", x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
484
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
+ },
485
540
  emit: (name, data) => {
486
541
  world.guard("world.emit");
487
542
  if (typeof name !== "string" || name === "") throw world.misuse("world.emit needs a non-empty event name.");
@@ -500,18 +555,27 @@ export class World {
500
555
  }
501
556
 
502
557
  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 } } }.');
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);
505
569
  }
506
570
  const id = def.id ?? `${this.current?.entity.id ?? "spawned"}-${++this.spawnCount}`;
507
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.`);
508
- const { diagnostics } = validateScene({ entities: [{ id, components: def.components }] });
572
+ const { diagnostics } = validateScene({ entities: [{ id, components }] });
509
573
  const errors = diagnostics.filter((d) => d.severity === "error" && d.code !== "E_GOAL_UNREACHABLE");
510
574
  if (errors.length > 0) {
511
575
  const details = errors.map((d) => `${d.path.replace("entities[0]", id)}: ${d.message}${d.fix ? ` Fix: ${d.fix}` : ""}`);
512
576
  throw this.misuse(`world.spawn got an invalid entity. ${details.join(" ")}`);
513
577
  }
514
- const entity = this.createEntity({ id, components: def.components });
578
+ const entity = this.createEntity({ id, components });
515
579
  this.entities.push(entity);
516
580
  return this.handle(entity);
517
581
  }
@@ -721,16 +785,43 @@ export class World {
721
785
  const player = this.players().find((p) => overlaps(p, goal));
722
786
  if (!player) continue;
723
787
  this.won = true;
788
+ if (goal.goal!.next) this.exit = goal.goal!.next;
724
789
  this.emit({ type: "win", entity: player.id, goal: goal.id, ...goal.transform });
725
790
  return;
726
791
  }
727
792
  }
728
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
+
729
815
  /** Sends a player back to where it started. Collected items stay collected. */
730
816
  private kill(player: Entity, cause: string): void {
731
817
  const b = player.body!;
732
818
  this.deaths++;
733
819
  this.emit({ type: "die", entity: player.id, cause, ...player.transform });
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
+ }
734
825
  player.transform.x = player.spawn?.x ?? player.transform.x;
735
826
  player.transform.y = player.spawn?.y ?? player.transform.y;
736
827
  b.vx = 0;
@@ -766,6 +857,7 @@ function createEntity(def: EntityDef): Entity {
766
857
  if (c.patrol) entity.patrol = { ...resolveComponent<Patrol>("patrol", c.patrol), originX: transform.x, originY: transform.y };
767
858
  if (c.hazard) entity.hazard = resolveComponent<Hazard>("hazard", c.hazard);
768
859
  if (c.goal) entity.goal = resolveComponent<Goal>("goal", c.goal);
860
+ if (c.checkpoint) entity.checkpoint = {};
769
861
  if (c.emitter) entity.emitter = resolveComponent<Emitter>("emitter", c.emitter);
770
862
  if (c.camera) entity.camera = resolveComponent<Camera>("camera", c.camera);
771
863
  return entity;