@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/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.won)
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.won)
105
+ if (this.over)
85
106
  return;
86
107
  this.updatePatrols();
87
108
  this.updateBodies();
88
109
  this.updateTouches();
89
- if (this.won)
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.won)
168
+ if (!e.removed && !this.over)
145
169
  this.callHook(e, "update");
146
- if (this.won)
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
- fn.call(script.module, ctx, other);
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
- return e.state;
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((candidate) => candidate.platformer);
373
+ const e = world.entities.find(isPlayer);
289
374
  return e ? world.handle(e) : undefined;
290
375
  },
291
- spawn: (def) => world.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.platformer && e.body)
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: () => world.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: def.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: def.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((e) => e.platformer && e.body);
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.players()) {
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
- b.vy = Math.min(b.vy + this.gravity * b.gravityScale * FIXED_DT, MAX_FALL_SPEED);
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 = 0;
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
- if (b.vy > 0) {
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
- player.transform.x = p.spawnX;
515
- player.transform.y = p.spawnY;
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.coyoteTimer = 0;
521
- p.bufferTimer = 0;
522
- p.jumping = false;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ludra/core",
3
- "version": "0.5.0",
3
+ "version": "0.8.0",
4
4
  "description": "Ludra engine runtime: scene format, validation, deterministic simulation and rendering.",
5
5
  "license": "MIT",
6
6
  "repository": {
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
- return { name: test.name, pass: results.every((r) => r.pass), duration: frames * FIXED_DT, results };
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
  }