@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/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) {
|
|
@@ -23,6 +23,8 @@ const STOMP_TOLERANCE = 8;
|
|
|
23
23
|
const FALL_MARGIN = 100;
|
|
24
24
|
/** Bodies this close count as touching for `touch` hooks, so solids (doors, walls) can be touched too. */
|
|
25
25
|
const TOUCH_MARGIN = 1;
|
|
26
|
+
/** Impacts slower than this (px/s, after bounce) stop instead of bouncing. */
|
|
27
|
+
const MIN_BOUNCE_SPEED = 40;
|
|
26
28
|
const EPSILON = 1e-4;
|
|
27
29
|
/**
|
|
28
30
|
* The simulation. It advances only through `step`, one fixed frame at a time,
|
|
@@ -41,6 +43,14 @@ export class World {
|
|
|
41
43
|
deaths = 0;
|
|
42
44
|
/** Set when a player reaches a goal. The simulation stops changing after that. */
|
|
43
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();
|
|
44
54
|
previousInput = new Set();
|
|
45
55
|
scripts;
|
|
46
56
|
seed;
|
|
@@ -50,6 +60,8 @@ export class World {
|
|
|
50
60
|
scriptInput = { held: () => false, pressed: () => false };
|
|
51
61
|
/** The script hook currently running, for error messages and event attribution. */
|
|
52
62
|
current;
|
|
63
|
+
/** True while a draw hook runs: the game can't be changed then. */
|
|
64
|
+
drawing = false;
|
|
53
65
|
constructor(scene, options = {}) {
|
|
54
66
|
this.width = scene.world?.width ?? 960;
|
|
55
67
|
this.height = scene.world?.height ?? 540;
|
|
@@ -57,6 +69,10 @@ export class World {
|
|
|
57
69
|
this.background = scene.world?.background ?? "#1d2333";
|
|
58
70
|
this.seed = scene.world?.seed ?? 1;
|
|
59
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;
|
|
60
76
|
this.entities = scene.entities.map((def) => this.createEntity(def));
|
|
61
77
|
}
|
|
62
78
|
/** Validates raw scene JSON and builds a world, throwing on errors. */
|
|
@@ -69,6 +85,10 @@ export class World {
|
|
|
69
85
|
get time() {
|
|
70
86
|
return this.frame * FIXED_DT;
|
|
71
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
|
+
}
|
|
72
92
|
get(id) {
|
|
73
93
|
return this.entities.find((e) => e.id === id);
|
|
74
94
|
}
|
|
@@ -77,19 +97,23 @@ export class World {
|
|
|
77
97
|
const pressed = new Set([...input].filter((action) => !this.previousInput.has(action)));
|
|
78
98
|
this.previousInput = new Set(input);
|
|
79
99
|
this.frame++;
|
|
80
|
-
if (this.
|
|
100
|
+
if (this.over)
|
|
81
101
|
return;
|
|
82
102
|
this.updatePlatformers(input, pressed);
|
|
103
|
+
this.updateTopdown(input);
|
|
83
104
|
this.updateScripts(input, pressed);
|
|
84
|
-
if (this.
|
|
105
|
+
if (this.over)
|
|
85
106
|
return;
|
|
86
107
|
this.updatePatrols();
|
|
87
108
|
this.updateBodies();
|
|
88
109
|
this.updateTouches();
|
|
89
|
-
if (this.
|
|
110
|
+
if (this.over)
|
|
90
111
|
return;
|
|
91
112
|
this.updateCollectibles();
|
|
113
|
+
this.updateCheckpoints();
|
|
92
114
|
this.updateHazards();
|
|
115
|
+
if (this.over)
|
|
116
|
+
return;
|
|
93
117
|
this.updateGoals();
|
|
94
118
|
}
|
|
95
119
|
snapshot() {
|
|
@@ -106,7 +130,7 @@ export class World {
|
|
|
106
130
|
if (Object.keys(e.state).length > 0)
|
|
107
131
|
entities[e.id].state = JSON.parse(JSON.stringify(e.state));
|
|
108
132
|
}
|
|
109
|
-
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 };
|
|
110
134
|
}
|
|
111
135
|
emit(event) {
|
|
112
136
|
this.events.push({ ...event, frame: this.frame, time: this.time });
|
|
@@ -141,9 +165,9 @@ export class World {
|
|
|
141
165
|
e.script.started = true;
|
|
142
166
|
this.callHook(e, "start");
|
|
143
167
|
}
|
|
144
|
-
if (!e.removed && !this.
|
|
168
|
+
if (!e.removed && !this.over)
|
|
145
169
|
this.callHook(e, "update");
|
|
146
|
-
if (this.
|
|
170
|
+
if (this.over)
|
|
147
171
|
return;
|
|
148
172
|
}
|
|
149
173
|
}
|
|
@@ -171,9 +195,35 @@ export class World {
|
|
|
171
195
|
if (!fn)
|
|
172
196
|
return;
|
|
173
197
|
const ctx = { self: this.handle(entity), world: this.api(), input: this.scriptInput, params: script.params, dt: FIXED_DT };
|
|
198
|
+
this.run(entity, hook, () => fn.call(script.module, ctx, other));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Runs an entity's `draw` or `drawUI` hook. Called by the renderer, never by the
|
|
202
|
+
* simulation. `self` and `world` are read-only for the duration.
|
|
203
|
+
*/
|
|
204
|
+
callDrawHook(entity, hook, extras) {
|
|
205
|
+
const script = entity.script;
|
|
206
|
+
const fn = script?.module[hook];
|
|
207
|
+
if (!script || !fn || entity.removed)
|
|
208
|
+
return;
|
|
209
|
+
const ctx = { ...extras, self: this.handle(entity), world: this.api(), params: script.params };
|
|
210
|
+
this.drawing = true;
|
|
211
|
+
try {
|
|
212
|
+
this.run(entity, hook, () => fn.call(script.module, ctx));
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
this.drawing = false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** True when any live entity's script has the given hook. */
|
|
219
|
+
hasHook(hook) {
|
|
220
|
+
return this.entities.some((e) => !e.removed && typeof e.script?.module[hook] === "function");
|
|
221
|
+
}
|
|
222
|
+
run(entity, hook, call) {
|
|
223
|
+
const script = entity.script;
|
|
174
224
|
this.current = { entity, hook };
|
|
175
225
|
try {
|
|
176
|
-
|
|
226
|
+
call();
|
|
177
227
|
}
|
|
178
228
|
catch (err) {
|
|
179
229
|
if (err instanceof ScriptError)
|
|
@@ -185,6 +235,12 @@ export class World {
|
|
|
185
235
|
this.current = undefined;
|
|
186
236
|
}
|
|
187
237
|
}
|
|
238
|
+
/** Draw hooks only paint, so the browser can't drift from what tests verified. */
|
|
239
|
+
guard(what) {
|
|
240
|
+
if (this.drawing) {
|
|
241
|
+
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.`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
188
244
|
/** An error caused by how a script used the API, reported against the running script. */
|
|
189
245
|
misuse(message) {
|
|
190
246
|
const running = this.current;
|
|
@@ -204,18 +260,21 @@ export class World {
|
|
|
204
260
|
return e.transform.x;
|
|
205
261
|
},
|
|
206
262
|
set x(value) {
|
|
263
|
+
world.guard("Setting x");
|
|
207
264
|
e.transform.x = value;
|
|
208
265
|
},
|
|
209
266
|
get y() {
|
|
210
267
|
return e.transform.y;
|
|
211
268
|
},
|
|
212
269
|
set y(value) {
|
|
270
|
+
world.guard("Setting y");
|
|
213
271
|
e.transform.y = value;
|
|
214
272
|
},
|
|
215
273
|
get vx() {
|
|
216
274
|
return e.body?.vx ?? 0;
|
|
217
275
|
},
|
|
218
276
|
set vx(value) {
|
|
277
|
+
world.guard("Setting vx");
|
|
219
278
|
if (e.body)
|
|
220
279
|
e.body.vx = value;
|
|
221
280
|
},
|
|
@@ -223,6 +282,7 @@ export class World {
|
|
|
223
282
|
return e.body?.vy ?? 0;
|
|
224
283
|
},
|
|
225
284
|
set vy(value) {
|
|
285
|
+
world.guard("Setting vy");
|
|
226
286
|
if (e.body)
|
|
227
287
|
e.body.vy = value;
|
|
228
288
|
},
|
|
@@ -239,7 +299,8 @@ export class World {
|
|
|
239
299
|
return !e.removed;
|
|
240
300
|
},
|
|
241
301
|
get state() {
|
|
242
|
-
|
|
302
|
+
// A frozen copy while drawing, so a draw hook can't write game state by accident.
|
|
303
|
+
return world.drawing ? Object.freeze({ ...e.state }) : e.state;
|
|
243
304
|
},
|
|
244
305
|
has(component) {
|
|
245
306
|
return component === "transform" || (component in e && e[component] !== undefined);
|
|
@@ -276,7 +337,31 @@ export class World {
|
|
|
276
337
|
get score() {
|
|
277
338
|
return world.score;
|
|
278
339
|
},
|
|
340
|
+
get deaths() {
|
|
341
|
+
return world.deaths;
|
|
342
|
+
},
|
|
343
|
+
get won() {
|
|
344
|
+
return world.won;
|
|
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
|
+
},
|
|
279
363
|
set score(value) {
|
|
364
|
+
world.guard("Setting score");
|
|
280
365
|
world.score = value;
|
|
281
366
|
},
|
|
282
367
|
get: (id) => {
|
|
@@ -285,32 +370,53 @@ export class World {
|
|
|
285
370
|
},
|
|
286
371
|
all: (component) => world.entities.filter((e) => !component || world.handle(e).has(component)).map((e) => world.handle(e)),
|
|
287
372
|
player: () => {
|
|
288
|
-
const e = world.entities.find(
|
|
373
|
+
const e = world.entities.find(isPlayer);
|
|
289
374
|
return e ? world.handle(e) : undefined;
|
|
290
375
|
},
|
|
291
|
-
spawn: (def) =>
|
|
376
|
+
spawn: (def) => {
|
|
377
|
+
world.guard("world.spawn");
|
|
378
|
+
return world.spawn(def);
|
|
379
|
+
},
|
|
292
380
|
destroy: (target) => {
|
|
381
|
+
world.guard("world.destroy");
|
|
293
382
|
const e = world.resolve(target);
|
|
294
383
|
if (e)
|
|
295
384
|
world.remove(e);
|
|
296
385
|
},
|
|
297
386
|
kill: (target) => {
|
|
387
|
+
world.guard("world.kill");
|
|
298
388
|
const e = world.resolve(target);
|
|
299
389
|
if (!e)
|
|
300
390
|
return;
|
|
301
|
-
if (e
|
|
391
|
+
if (isPlayer(e))
|
|
302
392
|
world.kill(e, world.current?.entity.id ?? "script");
|
|
303
393
|
else
|
|
304
394
|
world.remove(e);
|
|
305
395
|
},
|
|
306
396
|
win: () => {
|
|
397
|
+
world.guard("world.win");
|
|
307
398
|
if (world.won)
|
|
308
399
|
return;
|
|
309
400
|
const by = world.current?.entity;
|
|
310
401
|
world.won = true;
|
|
311
402
|
world.emit({ type: "win", entity: by?.id ?? "script", goal: by?.id ?? "script", x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
|
|
312
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
|
+
},
|
|
313
418
|
emit: (name, data) => {
|
|
419
|
+
world.guard("world.emit");
|
|
314
420
|
if (typeof name !== "string" || name === "")
|
|
315
421
|
throw world.misuse("world.emit needs a non-empty event name.");
|
|
316
422
|
if (EVENT_TYPES.includes(name)) {
|
|
@@ -319,24 +425,36 @@ export class World {
|
|
|
319
425
|
const by = world.current?.entity;
|
|
320
426
|
world.emit({ type: "custom", name, entity: by?.id ?? "script", data, x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
|
|
321
427
|
},
|
|
322
|
-
random: () =>
|
|
428
|
+
random: () => {
|
|
429
|
+
world.guard("world.random (it advances the game's seed; use ctx.random in draw hooks)");
|
|
430
|
+
return world.random();
|
|
431
|
+
},
|
|
323
432
|
};
|
|
324
433
|
return this.scriptApi;
|
|
325
434
|
}
|
|
326
435
|
spawn(def) {
|
|
327
|
-
if (typeof def !== "object" || def === null || typeof def.components !== "object") {
|
|
328
|
-
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);
|
|
329
447
|
}
|
|
330
448
|
const id = def.id ?? `${this.current?.entity.id ?? "spawned"}-${++this.spawnCount}`;
|
|
331
449
|
if (this.get(id))
|
|
332
450
|
throw this.misuse(`world.spawn: an entity with id "${id}" already exists. Leave id out to get a unique one.`);
|
|
333
|
-
const { diagnostics } = validateScene({ entities: [{ id, components
|
|
451
|
+
const { diagnostics } = validateScene({ entities: [{ id, components }] });
|
|
334
452
|
const errors = diagnostics.filter((d) => d.severity === "error" && d.code !== "E_GOAL_UNREACHABLE");
|
|
335
453
|
if (errors.length > 0) {
|
|
336
454
|
const details = errors.map((d) => `${d.path.replace("entities[0]", id)}: ${d.message}${d.fix ? ` Fix: ${d.fix}` : ""}`);
|
|
337
455
|
throw this.misuse(`world.spawn got an invalid entity. ${details.join(" ")}`);
|
|
338
456
|
}
|
|
339
|
-
const entity = this.createEntity({ id, components
|
|
457
|
+
const entity = this.createEntity({ id, components });
|
|
340
458
|
this.entities.push(entity);
|
|
341
459
|
return this.handle(entity);
|
|
342
460
|
}
|
|
@@ -349,13 +467,36 @@ export class World {
|
|
|
349
467
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
350
468
|
}
|
|
351
469
|
// ------------------------------------------------------------ built-ins
|
|
470
|
+
/** Entities the player controls: platformers and topdown movers. */
|
|
352
471
|
players() {
|
|
353
|
-
return this.entities.filter(
|
|
472
|
+
return this.entities.filter(isPlayer);
|
|
473
|
+
}
|
|
474
|
+
updateTopdown(held) {
|
|
475
|
+
for (const e of this.entities) {
|
|
476
|
+
const t = e.topdown;
|
|
477
|
+
const b = e.body;
|
|
478
|
+
if (!t || !b)
|
|
479
|
+
continue;
|
|
480
|
+
let dx = (held.has("right") ? 1 : 0) - (held.has("left") ? 1 : 0);
|
|
481
|
+
let dy = (held.has("down") ? 1 : 0) - (held.has("up") ? 1 : 0);
|
|
482
|
+
if (t.directions === "x")
|
|
483
|
+
dy = 0;
|
|
484
|
+
if (t.directions === "y")
|
|
485
|
+
dx = 0;
|
|
486
|
+
if (t.directions === "4" && dx !== 0)
|
|
487
|
+
dy = 0;
|
|
488
|
+
// Diagonals move at the same speed as straight lines.
|
|
489
|
+
const length = Math.hypot(dx, dy) || 1;
|
|
490
|
+
b.vx = (dx / length) * t.speed;
|
|
491
|
+
b.vy = (dy / length) * t.speed;
|
|
492
|
+
}
|
|
354
493
|
}
|
|
355
494
|
updatePlatformers(held, pressed) {
|
|
356
|
-
for (const e of this.
|
|
495
|
+
for (const e of this.entities) {
|
|
357
496
|
const p = e.platformer;
|
|
358
497
|
const b = e.body;
|
|
498
|
+
if (!p || !b)
|
|
499
|
+
continue;
|
|
359
500
|
const direction = (held.has("right") ? 1 : 0) - (held.has("left") ? 1 : 0);
|
|
360
501
|
b.vx = direction * p.speed;
|
|
361
502
|
// Timers get a tiny extra margin so a value of 0 still allows a jump on the exact frame.
|
|
@@ -404,15 +545,27 @@ export class World {
|
|
|
404
545
|
updateBodies() {
|
|
405
546
|
// Kinematic bodies move first, so anything standing on them can follow.
|
|
406
547
|
const moved = new Map();
|
|
548
|
+
const walls = this.entities.filter((e) => e.body?.type === "static" && !e.body.sensor);
|
|
407
549
|
for (const e of this.entities) {
|
|
408
550
|
const b = e.body;
|
|
409
551
|
if (!b || b.type !== "kinematic")
|
|
410
552
|
continue;
|
|
411
553
|
const { x, y } = e.transform;
|
|
412
554
|
e.transform.x += b.vx * FIXED_DT;
|
|
555
|
+
// A player-steered kinematic body (a paddle) stops at walls; scripted movers pass through.
|
|
556
|
+
if (e.topdown)
|
|
557
|
+
for (const wall of walls)
|
|
558
|
+
if (overlaps(e, wall))
|
|
559
|
+
e.transform.x = wall.transform.x + (Math.sign(e.transform.x - wall.transform.x) * (wall.body.width + b.width)) / 2;
|
|
413
560
|
e.transform.y += b.vy * FIXED_DT;
|
|
561
|
+
if (e.topdown)
|
|
562
|
+
for (const wall of walls)
|
|
563
|
+
if (overlaps(e, wall))
|
|
564
|
+
e.transform.y = wall.transform.y + (Math.sign(e.transform.y - wall.transform.y) * (wall.body.height + b.height)) / 2;
|
|
414
565
|
if (e.patrol)
|
|
415
566
|
clampToPatrol(e, e.patrol);
|
|
567
|
+
if (e.topdown?.clampToWorld)
|
|
568
|
+
this.clampToWorld(e);
|
|
416
569
|
moved.set(e, { dx: e.transform.x - x, dy: e.transform.y - y });
|
|
417
570
|
}
|
|
418
571
|
const solids = this.entities.filter((e) => e.body && !e.body.sensor && e.body.type !== "dynamic");
|
|
@@ -425,7 +578,9 @@ export class World {
|
|
|
425
578
|
e.transform.x += carried.dx;
|
|
426
579
|
e.transform.y += carried.dy;
|
|
427
580
|
}
|
|
428
|
-
|
|
581
|
+
// Top-down movers ignore gravity; everything else falls.
|
|
582
|
+
if (!e.topdown)
|
|
583
|
+
b.vy = Math.min(b.vy + this.gravity * b.gravityScale * FIXED_DT, MAX_FALL_SPEED);
|
|
429
584
|
const wasGrounded = b.grounded;
|
|
430
585
|
b.grounded = false;
|
|
431
586
|
b.standingOn = undefined;
|
|
@@ -439,27 +594,48 @@ export class World {
|
|
|
439
594
|
e.transform.x = solid.transform.x - half;
|
|
440
595
|
else if (b.vx < 0)
|
|
441
596
|
e.transform.x = solid.transform.x + half;
|
|
442
|
-
b.vx =
|
|
597
|
+
b.vx = this.rebound(e, solid, b.vx);
|
|
443
598
|
}
|
|
444
599
|
e.transform.y += b.vy * FIXED_DT;
|
|
445
600
|
for (const solid of solids) {
|
|
446
601
|
if (!overlaps(e, solid))
|
|
447
602
|
continue;
|
|
448
603
|
const half = (solid.body.height + b.height) / 2;
|
|
449
|
-
|
|
604
|
+
const falling = b.vy > 0;
|
|
605
|
+
if (b.vy > 0)
|
|
450
606
|
e.transform.y = solid.transform.y - half;
|
|
607
|
+
else if (b.vy < 0)
|
|
608
|
+
e.transform.y = solid.transform.y + half;
|
|
609
|
+
b.vy = this.rebound(e, solid, b.vy);
|
|
610
|
+
// A body that stopped on top of something is standing on it; one that bounced off is airborne.
|
|
611
|
+
if (falling && b.vy === 0) {
|
|
451
612
|
b.grounded = true;
|
|
452
613
|
b.standingOn = solid;
|
|
453
614
|
}
|
|
454
|
-
else if (b.vy < 0) {
|
|
455
|
-
e.transform.y = solid.transform.y + half;
|
|
456
|
-
}
|
|
457
|
-
b.vy = 0;
|
|
458
615
|
}
|
|
616
|
+
if (e.topdown?.clampToWorld)
|
|
617
|
+
this.clampToWorld(e);
|
|
459
618
|
if (b.grounded && !wasGrounded)
|
|
460
619
|
this.emit({ type: "land", entity: e.id, ...feet(e) });
|
|
461
620
|
}
|
|
462
621
|
}
|
|
622
|
+
/**
|
|
623
|
+
* The velocity along one axis after hitting `solid`: 0, or reversed and scaled by `bounce`.
|
|
624
|
+
* Slow impacts stop instead, so resting bodies don't jitter.
|
|
625
|
+
*/
|
|
626
|
+
rebound(e, solid, velocity) {
|
|
627
|
+
const bounce = e.body.bounce;
|
|
628
|
+
if (bounce <= 0 || Math.abs(velocity) * bounce < MIN_BOUNCE_SPEED)
|
|
629
|
+
return 0;
|
|
630
|
+
this.emit({ type: "bounce", entity: e.id, other: solid.id, ...e.transform });
|
|
631
|
+
return -velocity * bounce;
|
|
632
|
+
}
|
|
633
|
+
clampToWorld(e) {
|
|
634
|
+
const halfWidth = (e.body?.width ?? 0) / 2;
|
|
635
|
+
const halfHeight = (e.body?.height ?? 0) / 2;
|
|
636
|
+
e.transform.x = Math.min(this.width - halfWidth, Math.max(halfWidth, e.transform.x));
|
|
637
|
+
e.transform.y = Math.min(this.height - halfHeight, Math.max(halfHeight, e.transform.y));
|
|
638
|
+
}
|
|
463
639
|
updateCollectibles() {
|
|
464
640
|
const players = this.players();
|
|
465
641
|
for (const item of this.entities.filter((e) => e.collectible && e.body)) {
|
|
@@ -481,7 +657,7 @@ export class World {
|
|
|
481
657
|
for (const hazard of this.entities.filter((e) => e.hazard && e.body)) {
|
|
482
658
|
if (!overlaps(player, hazard))
|
|
483
659
|
continue;
|
|
484
|
-
if (hazard.hazard.stompable && isStomp(player, hazard)) {
|
|
660
|
+
if (hazard.hazard.stompable && player.platformer && isStomp(player, hazard)) {
|
|
485
661
|
this.remove(hazard);
|
|
486
662
|
b.vy = -player.platformer.jumpSpeed * STOMP_BOUNCE;
|
|
487
663
|
this.score += hazard.hazard.score;
|
|
@@ -501,25 +677,56 @@ export class World {
|
|
|
501
677
|
if (!player)
|
|
502
678
|
continue;
|
|
503
679
|
this.won = true;
|
|
680
|
+
if (goal.goal.next)
|
|
681
|
+
this.exit = goal.goal.next;
|
|
504
682
|
this.emit({ type: "win", entity: player.id, goal: goal.id, ...goal.transform });
|
|
505
683
|
return;
|
|
506
684
|
}
|
|
507
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
|
+
}
|
|
508
707
|
/** Sends a player back to where it started. Collected items stay collected. */
|
|
509
708
|
kill(player, cause) {
|
|
510
709
|
const b = player.body;
|
|
511
|
-
const p = player.platformer;
|
|
512
710
|
this.deaths++;
|
|
513
711
|
this.emit({ type: "die", entity: player.id, cause, ...player.transform });
|
|
514
|
-
|
|
515
|
-
|
|
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
|
+
}
|
|
718
|
+
player.transform.x = player.spawn?.x ?? player.transform.x;
|
|
719
|
+
player.transform.y = player.spawn?.y ?? player.transform.y;
|
|
516
720
|
b.vx = 0;
|
|
517
721
|
b.vy = 0;
|
|
518
722
|
b.grounded = false;
|
|
519
723
|
b.standingOn = undefined;
|
|
520
|
-
p
|
|
521
|
-
p
|
|
522
|
-
|
|
724
|
+
const p = player.platformer;
|
|
725
|
+
if (p) {
|
|
726
|
+
p.coyoteTimer = 0;
|
|
727
|
+
p.bufferTimer = 0;
|
|
728
|
+
p.jumping = false;
|
|
729
|
+
}
|
|
523
730
|
}
|
|
524
731
|
}
|
|
525
732
|
function createEntity(def) {
|
|
@@ -536,10 +743,12 @@ function createEntity(def) {
|
|
|
536
743
|
coyoteTimer: 0,
|
|
537
744
|
bufferTimer: 0,
|
|
538
745
|
jumping: false,
|
|
539
|
-
spawnX: transform.x,
|
|
540
|
-
spawnY: transform.y,
|
|
541
746
|
};
|
|
542
747
|
}
|
|
748
|
+
if (c.topdown)
|
|
749
|
+
entity.topdown = resolveComponent("topdown", c.topdown);
|
|
750
|
+
if (entity.platformer || entity.topdown)
|
|
751
|
+
entity.spawn = { x: transform.x, y: transform.y };
|
|
543
752
|
if (c.collectible)
|
|
544
753
|
entity.collectible = resolveComponent("collectible", c.collectible);
|
|
545
754
|
if (c.patrol)
|
|
@@ -548,12 +757,17 @@ function createEntity(def) {
|
|
|
548
757
|
entity.hazard = resolveComponent("hazard", c.hazard);
|
|
549
758
|
if (c.goal)
|
|
550
759
|
entity.goal = resolveComponent("goal", c.goal);
|
|
760
|
+
if (c.checkpoint)
|
|
761
|
+
entity.checkpoint = {};
|
|
551
762
|
if (c.emitter)
|
|
552
763
|
entity.emitter = resolveComponent("emitter", c.emitter);
|
|
553
764
|
if (c.camera)
|
|
554
765
|
entity.camera = resolveComponent("camera", c.camera);
|
|
555
766
|
return entity;
|
|
556
767
|
}
|
|
768
|
+
function isPlayer(e) {
|
|
769
|
+
return !!(e.platformer || e.topdown) && !!e.body;
|
|
770
|
+
}
|
|
557
771
|
function feet(e) {
|
|
558
772
|
return { x: e.transform.x, y: e.transform.y + (e.body?.height ?? 0) / 2 };
|
|
559
773
|
}
|
package/package.json
CHANGED
package/src/ascii.ts
CHANGED
|
@@ -48,8 +48,8 @@ export function renderAscii(world: World, options: AsciiOptions = {}): string {
|
|
|
48
48
|
["G", (e) => !!e.goal],
|
|
49
49
|
["^", (e) => !!e.hazard && e.body!.type !== "kinematic"],
|
|
50
50
|
["E", (e) => !!e.hazard && e.body!.type === "kinematic"],
|
|
51
|
-
["D", (e) => e.body!.type === "dynamic" && !e.platformer],
|
|
52
|
-
["@", (e) => !!e.platformer],
|
|
51
|
+
["D", (e) => e.body!.type === "dynamic" && !e.platformer && !e.topdown],
|
|
52
|
+
["@", (e) => !!(e.platformer || e.topdown)],
|
|
53
53
|
];
|
|
54
54
|
for (const [char, test] of passes) {
|
|
55
55
|
for (const e of withBody.filter(test)) paint(e, char);
|
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
|
}
|