@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/dist/assertions.d.ts +5 -1
- package/dist/assertions.js +8 -4
- package/dist/browser.d.ts +8 -1
- package/dist/browser.js +171 -51
- package/dist/components.d.ts +2 -1
- package/dist/components.js +11 -0
- package/dist/effects.d.ts +2 -2
- package/dist/effects.js +4 -1
- package/dist/script.d.ts +19 -2
- package/dist/script.js +4 -3
- package/dist/types.d.ts +16 -1
- package/dist/types.js +1 -1
- package/dist/validate.d.ts +3 -1
- package/dist/validate.js +72 -3
- package/dist/world.d.ts +35 -0
- package/dist/world.js +101 -11
- package/package.json +1 -1
- package/src/assertions.ts +12 -4
- package/src/browser.ts +178 -61
- package/src/components.ts +13 -0
- package/src/effects.ts +4 -2
- package/src/script.ts +22 -4
- package/src/types.ts +17 -1
- package/src/validate.ts +72 -3
- package/src/world.ts +103 -11
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.
|
|
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.
|
|
234
|
+
if (this.over) return;
|
|
209
235
|
this.updatePatrols();
|
|
210
236
|
this.updateBodies();
|
|
211
237
|
this.updateTouches();
|
|
212
|
-
if (this.
|
|
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.
|
|
273
|
-
if (this.
|
|
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
|
|
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
|
|
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;
|