@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/dist/validate.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface ValidationResult {
|
|
|
9
9
|
* Checks a parsed scene file. Every diagnostic names the exact JSON path and,
|
|
10
10
|
* where possible, a fix an agent can apply without guessing.
|
|
11
11
|
*/
|
|
12
|
-
export declare function validateScene(
|
|
12
|
+
export declare function validateScene(input: unknown): ValidationResult;
|
|
13
|
+
/** A prefab's components with overrides merged in, one level deep. `null` drops a component. */
|
|
14
|
+
export declare function mergeComponents(base: Record<string, unknown>, overrides: Record<string, unknown>): Record<string, Record<string, unknown>>;
|
|
13
15
|
/** Returns the candidate within a small edit distance of `input`, if any. */
|
|
14
16
|
export declare function closestName(input: string, candidates: readonly string[]): string | undefined;
|
package/dist/validate.js
CHANGED
|
@@ -9,18 +9,22 @@ const REQUIRED_EXAMPLES = {
|
|
|
9
9
|
* Checks a parsed scene file. Every diagnostic names the exact JSON path and,
|
|
10
10
|
* where possible, a fix an agent can apply without guessing.
|
|
11
11
|
*/
|
|
12
|
-
export function validateScene(
|
|
12
|
+
export function validateScene(input) {
|
|
13
13
|
const diagnostics = [];
|
|
14
14
|
const error = (code, path, message, fix) => diagnostics.push({ severity: "error", code, path, message, fix });
|
|
15
15
|
const warn = (code, path, message, fix) => diagnostics.push({ severity: "warning", code, path, message, fix });
|
|
16
16
|
const result = () => {
|
|
17
17
|
const ok = !diagnostics.some((d) => d.severity === "error");
|
|
18
|
-
return { ok, diagnostics, scene: ok ?
|
|
18
|
+
return { ok, diagnostics, scene: ok ? scene : undefined };
|
|
19
19
|
};
|
|
20
|
-
|
|
20
|
+
let scene = input;
|
|
21
|
+
if (!isObject(input)) {
|
|
21
22
|
error("E_SCENE_NOT_OBJECT", "$", "A scene must be a JSON object.", 'Start from { "entities": [] }.');
|
|
22
23
|
return result();
|
|
23
24
|
}
|
|
25
|
+
// Prefabs become plain entities first, so everything below sees the final components.
|
|
26
|
+
const raw = expandPrefabs(input, error);
|
|
27
|
+
scene = raw;
|
|
24
28
|
if (raw.world !== undefined) {
|
|
25
29
|
if (!isObject(raw.world)) {
|
|
26
30
|
error("E_WORLD_NOT_OBJECT", "world", '"world" must be an object.', 'Use "world": { "width": 960, "height": 540 }.');
|
|
@@ -38,6 +42,10 @@ export function validateScene(raw) {
|
|
|
38
42
|
if (raw.world.background !== undefined && typeof raw.world.background !== "string") {
|
|
39
43
|
error("E_INVALID_WORLD", "world.background", `world.background must be a CSS color string, got ${describe(raw.world.background)}.`);
|
|
40
44
|
}
|
|
45
|
+
const lives = raw.world.lives;
|
|
46
|
+
if (lives !== undefined && (typeof lives !== "number" || !Number.isInteger(lives) || lives < 1)) {
|
|
47
|
+
error("E_INVALID_WORLD", "world.lives", `world.lives must be a whole number of at least 1, got ${describe(lives)}.`, "Leave it out for unlimited retries.");
|
|
48
|
+
}
|
|
41
49
|
}
|
|
42
50
|
}
|
|
43
51
|
if (raw.effects !== undefined)
|
|
@@ -161,7 +169,68 @@ const SENSOR_COMPONENTS = [
|
|
|
161
169
|
["collectible", "players would bump into it instead of picking it up"],
|
|
162
170
|
["hazard", "players would stand on it instead of being hurt"],
|
|
163
171
|
["goal", "players would bump into it instead of reaching it"],
|
|
172
|
+
["checkpoint", "players would bump into it instead of passing through"],
|
|
164
173
|
];
|
|
174
|
+
/**
|
|
175
|
+
* Replaces `"prefab": "<name>"` entities with the prefab's components, with the
|
|
176
|
+
* entity's own components merged over them (field by field; `null` removes a
|
|
177
|
+
* component). Returns a new scene object; reports unknown prefabs.
|
|
178
|
+
*/
|
|
179
|
+
function expandPrefabs(raw, error) {
|
|
180
|
+
const prefabs = raw.prefabs;
|
|
181
|
+
if (prefabs !== undefined) {
|
|
182
|
+
if (!isObject(prefabs)) {
|
|
183
|
+
error("E_PREFABS_NOT_OBJECT", "prefabs", '"prefabs" must map names to { "components": { ... } }.');
|
|
184
|
+
return raw;
|
|
185
|
+
}
|
|
186
|
+
for (const [name, prefab] of Object.entries(prefabs)) {
|
|
187
|
+
if (!isObject(prefab) || !isObject(prefab.components)) {
|
|
188
|
+
error("E_INVALID_PREFAB", `prefabs.${name}`, `Prefab "${name}" needs a "components" object.`, 'Use { "components": { "body": { ... }, "sprite": { ... } } }.');
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
for (const component of Object.keys(prefab.components)) {
|
|
192
|
+
if (!isComponentName(component)) {
|
|
193
|
+
const suggestion = closestName(component, COMPONENT_NAMES);
|
|
194
|
+
error("E_UNKNOWN_COMPONENT", `prefabs.${name}.components.${component}`, `Unknown component "${component}" in prefab "${name}".`, suggestion ? `Did you mean "${suggestion}"?` : undefined);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!Array.isArray(raw.entities))
|
|
200
|
+
return raw;
|
|
201
|
+
const known = isObject(prefabs) ? prefabs : {};
|
|
202
|
+
const entities = raw.entities.map((entity, i) => {
|
|
203
|
+
if (!isObject(entity) || entity.prefab === undefined)
|
|
204
|
+
return entity;
|
|
205
|
+
const name = entity.prefab;
|
|
206
|
+
const prefab = typeof name === "string" ? known[name] : undefined;
|
|
207
|
+
if (!isObject(prefab) || !isObject(prefab.components)) {
|
|
208
|
+
const names = Object.keys(known);
|
|
209
|
+
const suggestion = typeof name === "string" ? closestName(name, names) : undefined;
|
|
210
|
+
error("E_UNKNOWN_PREFAB", `entities[${i}].prefab`, `Prefab ${describe(name)} does not exist.`, suggestion ? `Did you mean "${suggestion}"?` : names.length > 0 ? `Prefabs: ${names.join(", ")}.` : "Create prefabs/<name>.prefab.json with { \"components\": { ... } }.");
|
|
211
|
+
return entity;
|
|
212
|
+
}
|
|
213
|
+
const { prefab: _, ...rest } = entity;
|
|
214
|
+
return { ...rest, components: mergeComponents(prefab.components, isObject(entity.components) ? entity.components : {}) };
|
|
215
|
+
});
|
|
216
|
+
return { ...raw, entities };
|
|
217
|
+
}
|
|
218
|
+
/** A prefab's components with overrides merged in, one level deep. `null` drops a component. */
|
|
219
|
+
export function mergeComponents(base, overrides) {
|
|
220
|
+
const merged = {};
|
|
221
|
+
for (const [name, value] of Object.entries(base))
|
|
222
|
+
if (isObject(value))
|
|
223
|
+
merged[name] = structuredClone(value);
|
|
224
|
+
for (const [name, value] of Object.entries(overrides)) {
|
|
225
|
+
if (value === null)
|
|
226
|
+
delete merged[name];
|
|
227
|
+
else if (isObject(value) && isObject(merged[name]))
|
|
228
|
+
merged[name] = { ...merged[name], ...structuredClone(value) };
|
|
229
|
+
else
|
|
230
|
+
merged[name] = structuredClone(value);
|
|
231
|
+
}
|
|
232
|
+
return merged;
|
|
233
|
+
}
|
|
165
234
|
function numberOr(value, fallback) {
|
|
166
235
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
167
236
|
}
|
package/dist/world.d.ts
CHANGED
|
@@ -43,6 +43,7 @@ export interface Entity {
|
|
|
43
43
|
patrol?: PatrolState;
|
|
44
44
|
hazard?: Hazard;
|
|
45
45
|
goal?: Goal;
|
|
46
|
+
checkpoint?: Record<string, never>;
|
|
46
47
|
emitter?: Emitter;
|
|
47
48
|
camera?: Camera;
|
|
48
49
|
}
|
|
@@ -80,6 +81,18 @@ export type GameEvent = {
|
|
|
80
81
|
type: "bounce";
|
|
81
82
|
entity: string;
|
|
82
83
|
other: string;
|
|
84
|
+
} | {
|
|
85
|
+
type: "checkpoint";
|
|
86
|
+
entity: string;
|
|
87
|
+
checkpoint: string;
|
|
88
|
+
} | {
|
|
89
|
+
type: "gameover";
|
|
90
|
+
entity: string;
|
|
91
|
+
cause: string;
|
|
92
|
+
} | {
|
|
93
|
+
type: "goto";
|
|
94
|
+
entity: string;
|
|
95
|
+
scene: string;
|
|
83
96
|
}
|
|
84
97
|
/** Fired by a script with world.emit(name, data). */
|
|
85
98
|
| {
|
|
@@ -100,6 +113,12 @@ export interface EntitySnapshot {
|
|
|
100
113
|
export interface WorldOptions {
|
|
101
114
|
/** Loaded script modules, keyed by the `src` path used in the scene. */
|
|
102
115
|
scripts?: ScriptModules;
|
|
116
|
+
/** Values carried over from the previous scene, so a run keeps its score and lives. */
|
|
117
|
+
carry?: {
|
|
118
|
+
score?: number;
|
|
119
|
+
deaths?: number;
|
|
120
|
+
lives?: number;
|
|
121
|
+
};
|
|
103
122
|
}
|
|
104
123
|
export interface WorldSnapshot {
|
|
105
124
|
frame: number;
|
|
@@ -107,6 +126,9 @@ export interface WorldSnapshot {
|
|
|
107
126
|
score: number;
|
|
108
127
|
deaths: number;
|
|
109
128
|
won: boolean;
|
|
129
|
+
lost: boolean;
|
|
130
|
+
/** Lives left; undefined when the scene has unlimited retries. */
|
|
131
|
+
lives?: number;
|
|
110
132
|
entities: Record<string, EntitySnapshot>;
|
|
111
133
|
}
|
|
112
134
|
export declare class SceneValidationError extends Error {
|
|
@@ -130,6 +152,14 @@ export declare class World {
|
|
|
130
152
|
deaths: number;
|
|
131
153
|
/** Set when a player reaches a goal. The simulation stops changing after that. */
|
|
132
154
|
won: boolean;
|
|
155
|
+
/** Set when the game is over: out of lives, or a script called world.lose(). */
|
|
156
|
+
lost: boolean;
|
|
157
|
+
/** Lives left, or undefined for unlimited retries. */
|
|
158
|
+
lives: number | undefined;
|
|
159
|
+
/** The scene to play next (a won goal's `next`, or world.goto). Hosts that can load scenes move on to it. */
|
|
160
|
+
exit: string | undefined;
|
|
161
|
+
private readonly prefabs;
|
|
162
|
+
private readonly reached;
|
|
133
163
|
private previousInput;
|
|
134
164
|
private readonly scripts;
|
|
135
165
|
private seed;
|
|
@@ -145,6 +175,8 @@ export declare class World {
|
|
|
145
175
|
/** Validates raw scene JSON and builds a world, throwing on errors. */
|
|
146
176
|
static fromJSON(raw: unknown, options?: WorldOptions): World;
|
|
147
177
|
get time(): number;
|
|
178
|
+
/** True once nothing more happens in this scene: won, lost, or moving on to another scene. */
|
|
179
|
+
get over(): boolean;
|
|
148
180
|
get(id: string): Entity | undefined;
|
|
149
181
|
/** Advances one frame with the given actions held down. */
|
|
150
182
|
step(input: ReadonlySet<Action>): void;
|
|
@@ -192,6 +224,9 @@ export declare class World {
|
|
|
192
224
|
private updateCollectibles;
|
|
193
225
|
private updateHazards;
|
|
194
226
|
private updateGoals;
|
|
227
|
+
/** A touched checkpoint becomes the player's respawn point, standing on the checkpoint's bottom edge. */
|
|
228
|
+
private updateCheckpoints;
|
|
229
|
+
private gameOver;
|
|
195
230
|
/** Sends a player back to where it started. Collected items stay collected. */
|
|
196
231
|
private kill;
|
|
197
232
|
}
|
package/dist/world.js
CHANGED
|
@@ -2,7 +2,7 @@ import { resolveComponent, } from "./components.js";
|
|
|
2
2
|
import { getScript, ScriptError, } from "./script.js";
|
|
3
3
|
import { FIXED_DT } from "./time.js";
|
|
4
4
|
import { EVENT_TYPES } from "./types.js";
|
|
5
|
-
import { validateScene } from "./validate.js";
|
|
5
|
+
import { mergeComponents, validateScene } from "./validate.js";
|
|
6
6
|
export class SceneValidationError extends Error {
|
|
7
7
|
diagnostics;
|
|
8
8
|
constructor(diagnostics) {
|
|
@@ -43,6 +43,14 @@ export class World {
|
|
|
43
43
|
deaths = 0;
|
|
44
44
|
/** Set when a player reaches a goal. The simulation stops changing after that. */
|
|
45
45
|
won = false;
|
|
46
|
+
/** Set when the game is over: out of lives, or a script called world.lose(). */
|
|
47
|
+
lost = false;
|
|
48
|
+
/** Lives left, or undefined for unlimited retries. */
|
|
49
|
+
lives;
|
|
50
|
+
/** The scene to play next (a won goal's `next`, or world.goto). Hosts that can load scenes move on to it. */
|
|
51
|
+
exit;
|
|
52
|
+
prefabs;
|
|
53
|
+
reached = new Set();
|
|
46
54
|
previousInput = new Set();
|
|
47
55
|
scripts;
|
|
48
56
|
seed;
|
|
@@ -61,6 +69,10 @@ export class World {
|
|
|
61
69
|
this.background = scene.world?.background ?? "#1d2333";
|
|
62
70
|
this.seed = scene.world?.seed ?? 1;
|
|
63
71
|
this.scripts = options.scripts;
|
|
72
|
+
this.prefabs = scene.prefabs ?? {};
|
|
73
|
+
this.score = options.carry?.score ?? 0;
|
|
74
|
+
this.deaths = options.carry?.deaths ?? 0;
|
|
75
|
+
this.lives = options.carry?.lives ?? scene.world?.lives;
|
|
64
76
|
this.entities = scene.entities.map((def) => this.createEntity(def));
|
|
65
77
|
}
|
|
66
78
|
/** Validates raw scene JSON and builds a world, throwing on errors. */
|
|
@@ -73,6 +85,10 @@ export class World {
|
|
|
73
85
|
get time() {
|
|
74
86
|
return this.frame * FIXED_DT;
|
|
75
87
|
}
|
|
88
|
+
/** True once nothing more happens in this scene: won, lost, or moving on to another scene. */
|
|
89
|
+
get over() {
|
|
90
|
+
return this.won || this.lost || this.exit !== undefined;
|
|
91
|
+
}
|
|
76
92
|
get(id) {
|
|
77
93
|
return this.entities.find((e) => e.id === id);
|
|
78
94
|
}
|
|
@@ -81,20 +97,23 @@ export class World {
|
|
|
81
97
|
const pressed = new Set([...input].filter((action) => !this.previousInput.has(action)));
|
|
82
98
|
this.previousInput = new Set(input);
|
|
83
99
|
this.frame++;
|
|
84
|
-
if (this.
|
|
100
|
+
if (this.over)
|
|
85
101
|
return;
|
|
86
102
|
this.updatePlatformers(input, pressed);
|
|
87
103
|
this.updateTopdown(input);
|
|
88
104
|
this.updateScripts(input, pressed);
|
|
89
|
-
if (this.
|
|
105
|
+
if (this.over)
|
|
90
106
|
return;
|
|
91
107
|
this.updatePatrols();
|
|
92
108
|
this.updateBodies();
|
|
93
109
|
this.updateTouches();
|
|
94
|
-
if (this.
|
|
110
|
+
if (this.over)
|
|
95
111
|
return;
|
|
96
112
|
this.updateCollectibles();
|
|
113
|
+
this.updateCheckpoints();
|
|
97
114
|
this.updateHazards();
|
|
115
|
+
if (this.over)
|
|
116
|
+
return;
|
|
98
117
|
this.updateGoals();
|
|
99
118
|
}
|
|
100
119
|
snapshot() {
|
|
@@ -111,7 +130,7 @@ export class World {
|
|
|
111
130
|
if (Object.keys(e.state).length > 0)
|
|
112
131
|
entities[e.id].state = JSON.parse(JSON.stringify(e.state));
|
|
113
132
|
}
|
|
114
|
-
return { frame: this.frame, time: this.time, score: this.score, deaths: this.deaths, won: this.won, entities };
|
|
133
|
+
return { frame: this.frame, time: this.time, score: this.score, deaths: this.deaths, won: this.won, lost: this.lost, lives: this.lives, entities };
|
|
115
134
|
}
|
|
116
135
|
emit(event) {
|
|
117
136
|
this.events.push({ ...event, frame: this.frame, time: this.time });
|
|
@@ -146,9 +165,9 @@ export class World {
|
|
|
146
165
|
e.script.started = true;
|
|
147
166
|
this.callHook(e, "start");
|
|
148
167
|
}
|
|
149
|
-
if (!e.removed && !this.
|
|
168
|
+
if (!e.removed && !this.over)
|
|
150
169
|
this.callHook(e, "update");
|
|
151
|
-
if (this.
|
|
170
|
+
if (this.over)
|
|
152
171
|
return;
|
|
153
172
|
}
|
|
154
173
|
}
|
|
@@ -324,6 +343,23 @@ export class World {
|
|
|
324
343
|
get won() {
|
|
325
344
|
return world.won;
|
|
326
345
|
},
|
|
346
|
+
get lost() {
|
|
347
|
+
return world.lost;
|
|
348
|
+
},
|
|
349
|
+
get exit() {
|
|
350
|
+
return world.exit;
|
|
351
|
+
},
|
|
352
|
+
get lives() {
|
|
353
|
+
return world.lives;
|
|
354
|
+
},
|
|
355
|
+
set lives(value) {
|
|
356
|
+
world.guard("Setting lives");
|
|
357
|
+
if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value)))
|
|
358
|
+
throw world.misuse("world.lives must be a number.");
|
|
359
|
+
world.lives = value;
|
|
360
|
+
if (value !== undefined && value <= 0)
|
|
361
|
+
world.gameOver(world.current?.entity.id ?? "script", "script");
|
|
362
|
+
},
|
|
327
363
|
set score(value) {
|
|
328
364
|
world.guard("Setting score");
|
|
329
365
|
world.score = value;
|
|
@@ -365,6 +401,20 @@ export class World {
|
|
|
365
401
|
world.won = true;
|
|
366
402
|
world.emit({ type: "win", entity: by?.id ?? "script", goal: by?.id ?? "script", x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
|
|
367
403
|
},
|
|
404
|
+
lose: () => {
|
|
405
|
+
world.guard("world.lose");
|
|
406
|
+
world.gameOver(world.current?.entity.id ?? "script", "script");
|
|
407
|
+
},
|
|
408
|
+
goto: (scene) => {
|
|
409
|
+
world.guard("world.goto");
|
|
410
|
+
if (typeof scene !== "string" || scene === "")
|
|
411
|
+
throw world.misuse('world.goto needs a scene path, e.g. "scenes/level-1.scene.json".');
|
|
412
|
+
if (world.exit !== undefined)
|
|
413
|
+
return;
|
|
414
|
+
world.exit = scene;
|
|
415
|
+
const by = world.current?.entity;
|
|
416
|
+
world.emit({ type: "goto", entity: by?.id ?? "script", scene, x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
|
|
417
|
+
},
|
|
368
418
|
emit: (name, data) => {
|
|
369
419
|
world.guard("world.emit");
|
|
370
420
|
if (typeof name !== "string" || name === "")
|
|
@@ -383,19 +433,28 @@ export class World {
|
|
|
383
433
|
return this.scriptApi;
|
|
384
434
|
}
|
|
385
435
|
spawn(def) {
|
|
386
|
-
if (typeof def !== "object" || def === null || typeof def.components !== "object") {
|
|
387
|
-
throw this.misuse('world.spawn needs { components: { ... } }, e.g. { components: { transform: { x: 0, y: 0 } } }.');
|
|
436
|
+
if (typeof def !== "object" || def === null || (def.prefab === undefined && typeof def.components !== "object")) {
|
|
437
|
+
throw this.misuse('world.spawn needs { components: { ... } } or { prefab: "name" }, e.g. { prefab: "bullet", components: { transform: { x: 0, y: 0 } } }.');
|
|
438
|
+
}
|
|
439
|
+
let components = def.components ?? {};
|
|
440
|
+
if (def.prefab !== undefined) {
|
|
441
|
+
const prefab = this.prefabs[def.prefab];
|
|
442
|
+
if (!prefab) {
|
|
443
|
+
const names = Object.keys(this.prefabs);
|
|
444
|
+
throw this.misuse(`world.spawn: prefab "${def.prefab}" does not exist.${names.length > 0 ? ` Prefabs: ${names.join(", ")}.` : " Create prefabs/<name>.prefab.json."}`);
|
|
445
|
+
}
|
|
446
|
+
components = mergeComponents(prefab.components, components);
|
|
388
447
|
}
|
|
389
448
|
const id = def.id ?? `${this.current?.entity.id ?? "spawned"}-${++this.spawnCount}`;
|
|
390
449
|
if (this.get(id))
|
|
391
450
|
throw this.misuse(`world.spawn: an entity with id "${id}" already exists. Leave id out to get a unique one.`);
|
|
392
|
-
const { diagnostics } = validateScene({ entities: [{ id, components
|
|
451
|
+
const { diagnostics } = validateScene({ entities: [{ id, components }] });
|
|
393
452
|
const errors = diagnostics.filter((d) => d.severity === "error" && d.code !== "E_GOAL_UNREACHABLE");
|
|
394
453
|
if (errors.length > 0) {
|
|
395
454
|
const details = errors.map((d) => `${d.path.replace("entities[0]", id)}: ${d.message}${d.fix ? ` Fix: ${d.fix}` : ""}`);
|
|
396
455
|
throw this.misuse(`world.spawn got an invalid entity. ${details.join(" ")}`);
|
|
397
456
|
}
|
|
398
|
-
const entity = this.createEntity({ id, components
|
|
457
|
+
const entity = this.createEntity({ id, components });
|
|
399
458
|
this.entities.push(entity);
|
|
400
459
|
return this.handle(entity);
|
|
401
460
|
}
|
|
@@ -618,15 +677,44 @@ export class World {
|
|
|
618
677
|
if (!player)
|
|
619
678
|
continue;
|
|
620
679
|
this.won = true;
|
|
680
|
+
if (goal.goal.next)
|
|
681
|
+
this.exit = goal.goal.next;
|
|
621
682
|
this.emit({ type: "win", entity: player.id, goal: goal.id, ...goal.transform });
|
|
622
683
|
return;
|
|
623
684
|
}
|
|
624
685
|
}
|
|
686
|
+
/** A touched checkpoint becomes the player's respawn point, standing on the checkpoint's bottom edge. */
|
|
687
|
+
updateCheckpoints() {
|
|
688
|
+
for (const checkpoint of this.entities.filter((e) => e.checkpoint && e.body)) {
|
|
689
|
+
for (const player of this.players()) {
|
|
690
|
+
const key = `${player.id}>${checkpoint.id}`;
|
|
691
|
+
if (this.reached.has(key) || !overlaps(player, checkpoint))
|
|
692
|
+
continue;
|
|
693
|
+
this.reached.add(key);
|
|
694
|
+
const bottom = checkpoint.transform.y + checkpoint.body.height / 2;
|
|
695
|
+
player.spawn = { x: checkpoint.transform.x, y: player.platformer ? bottom - player.body.height / 2 : checkpoint.transform.y };
|
|
696
|
+
this.emit({ type: "checkpoint", entity: player.id, checkpoint: checkpoint.id, ...checkpoint.transform });
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
gameOver(entity, cause) {
|
|
701
|
+
if (this.lost)
|
|
702
|
+
return;
|
|
703
|
+
this.lost = true;
|
|
704
|
+
const e = this.get(entity);
|
|
705
|
+
this.emit({ type: "gameover", entity, cause, x: e?.transform.x ?? 0, y: e?.transform.y ?? 0 });
|
|
706
|
+
}
|
|
625
707
|
/** Sends a player back to where it started. Collected items stay collected. */
|
|
626
708
|
kill(player, cause) {
|
|
627
709
|
const b = player.body;
|
|
628
710
|
this.deaths++;
|
|
629
711
|
this.emit({ type: "die", entity: player.id, cause, ...player.transform });
|
|
712
|
+
if (this.lives !== undefined) {
|
|
713
|
+
this.lives--;
|
|
714
|
+
// Out of lives: the player stays where it fell and the game is over.
|
|
715
|
+
if (this.lives <= 0)
|
|
716
|
+
return this.gameOver(player.id, cause);
|
|
717
|
+
}
|
|
630
718
|
player.transform.x = player.spawn?.x ?? player.transform.x;
|
|
631
719
|
player.transform.y = player.spawn?.y ?? player.transform.y;
|
|
632
720
|
b.vx = 0;
|
|
@@ -669,6 +757,8 @@ function createEntity(def) {
|
|
|
669
757
|
entity.hazard = resolveComponent("hazard", c.hazard);
|
|
670
758
|
if (c.goal)
|
|
671
759
|
entity.goal = resolveComponent("goal", c.goal);
|
|
760
|
+
if (c.checkpoint)
|
|
761
|
+
entity.checkpoint = {};
|
|
672
762
|
if (c.emitter)
|
|
673
763
|
entity.emitter = resolveComponent("emitter", c.emitter);
|
|
674
764
|
if (c.camera)
|
package/package.json
CHANGED
package/src/assertions.ts
CHANGED
|
@@ -10,7 +10,7 @@ type Operator = (typeof OPERATORS)[number];
|
|
|
10
10
|
const ENTITY_FIELDS = ["x", "y", "vx", "vy", "grounded", "exists"] as const;
|
|
11
11
|
type EntityField = (typeof ENTITY_FIELDS)[number];
|
|
12
12
|
|
|
13
|
-
const WORLD_FIELDS = ["score", "deaths", "won"] as const;
|
|
13
|
+
const WORLD_FIELDS = ["score", "deaths", "won", "lost", "lives"] as const;
|
|
14
14
|
type WorldField = (typeof WORLD_FIELDS)[number];
|
|
15
15
|
|
|
16
16
|
type Subject =
|
|
@@ -100,6 +100,9 @@ export function evaluateAssertion(assertion: Assertion, snapshot: WorldSnapshot,
|
|
|
100
100
|
case "world":
|
|
101
101
|
actual = snapshot[subject.field];
|
|
102
102
|
label = subject.field;
|
|
103
|
+
if (actual === undefined) {
|
|
104
|
+
return { source, pass: false, actual, time, message: `${label} is not set: the scene has unlimited retries. Add "lives" to "world" to count them.` };
|
|
105
|
+
}
|
|
103
106
|
break;
|
|
104
107
|
case "events": {
|
|
105
108
|
const matches = (e: GameEvent) => (subject.builtin ? e.type === subject.name : e.type === "custom" && e.name === subject.name);
|
|
@@ -184,6 +187,8 @@ function round(n: number): number {
|
|
|
184
187
|
/** One case in a `tests/*.test.json` file. */
|
|
185
188
|
export interface TestCase {
|
|
186
189
|
name: string;
|
|
190
|
+
/** Scene to run, relative to the project. Defaults to the entry scene in ludra.json. */
|
|
191
|
+
scene?: string;
|
|
187
192
|
/** Input script, see `InputScript`. */
|
|
188
193
|
inputs?: string;
|
|
189
194
|
/** Run length, e.g. "3s". Defaults to the inputs plus one second, or the latest `at`. */
|
|
@@ -197,6 +202,8 @@ export interface TestResult {
|
|
|
197
202
|
duration: number;
|
|
198
203
|
results: AssertionResult[];
|
|
199
204
|
error?: string;
|
|
205
|
+
/** Names of the events that fired during the run (built-in types and custom names). */
|
|
206
|
+
events: string[];
|
|
200
207
|
}
|
|
201
208
|
|
|
202
209
|
export function runTest(scene: SceneDef, test: TestCase, options: WorldOptions = {}): TestResult {
|
|
@@ -215,7 +222,7 @@ export function runTest(scene: SceneDef, test: TestCase, options: WorldOptions =
|
|
|
215
222
|
? secondsToFrames(typeof test.duration === "number" ? test.duration : parseDuration(test.duration))
|
|
216
223
|
: Math.max(secondsToFrames(defaultDuration(script)), latestAt);
|
|
217
224
|
} catch (err) {
|
|
218
|
-
return { name: test.name, pass: false, duration: 0, results: [], error: (err as Error).message };
|
|
225
|
+
return { name: test.name, pass: false, duration: 0, results: [], error: (err as Error).message, events: [] };
|
|
219
226
|
}
|
|
220
227
|
|
|
221
228
|
const wanted = new Set(assertions.map((a) => (a.at === "end" ? frames : Math.min(a.at, frames))));
|
|
@@ -228,12 +235,13 @@ export function runTest(scene: SceneDef, test: TestCase, options: WorldOptions =
|
|
|
228
235
|
});
|
|
229
236
|
} catch (err) {
|
|
230
237
|
// A throwing script fails this test, not the whole run.
|
|
231
|
-
return { name: test.name, pass: false, duration: frames * FIXED_DT, results: [], error: (err as Error).message };
|
|
238
|
+
return { name: test.name, pass: false, duration: frames * FIXED_DT, results: [], error: (err as Error).message, events: [] };
|
|
232
239
|
}
|
|
233
240
|
|
|
234
241
|
const results = assertions.map((a) => {
|
|
235
242
|
const frame = a.at === "end" ? frames : Math.min(a.at, frames);
|
|
236
243
|
return evaluateAssertion(a, snapshots.get(frame)!, world.events);
|
|
237
244
|
});
|
|
238
|
-
|
|
245
|
+
const events = [...new Set(world.events.map((e) => (e.type === "custom" ? e.name : e.type)))];
|
|
246
|
+
return { name: test.name, pass: results.every((r) => r.pass), duration: frames * FIXED_DT, results, events };
|
|
239
247
|
}
|