@ludra/core 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/world.ts CHANGED
@@ -10,11 +10,13 @@ import {
10
10
  type Platformer,
11
11
  type ScriptComponent,
12
12
  type Sprite,
13
+ type Topdown,
13
14
  type Transform,
14
15
  } from "./components.ts";
15
16
  import {
16
17
  getScript,
17
18
  ScriptError,
19
+ type DrawContext,
18
20
  type Script,
19
21
  type ScriptContext,
20
22
  type ScriptEntity,
@@ -38,8 +40,6 @@ export interface PlatformerState extends Platformer {
38
40
  /** True from a player-initiated jump until the apex, so only those jumps are cut short on release. */
39
41
  jumping: boolean;
40
42
  bufferTimer: number;
41
- spawnX: number;
42
- spawnY: number;
43
43
  }
44
44
 
45
45
  export interface PatrolState extends Patrol {
@@ -65,6 +65,9 @@ export interface Entity {
65
65
  body?: BodyState;
66
66
  sprite?: Sprite;
67
67
  platformer?: PlatformerState;
68
+ topdown?: Topdown;
69
+ /** Where a player respawns after dying. */
70
+ spawn?: { x: number; y: number };
68
71
  collectible?: Collectible;
69
72
  patrol?: PatrolState;
70
73
  hazard?: Hazard;
@@ -81,6 +84,7 @@ export type GameEvent = { frame: number; time: number; x: number; y: number } &
81
84
  | { type: "die"; entity: string; cause: string }
82
85
  | { type: "stomp"; entity: string; enemy: string; score: number }
83
86
  | { type: "win"; entity: string; goal: string }
87
+ | { type: "bounce"; entity: string; other: string }
84
88
  /** Fired by a script with world.emit(name, data). */
85
89
  | { type: "custom"; name: string; entity: string; data?: unknown }
86
90
  );
@@ -133,6 +137,8 @@ const STOMP_TOLERANCE = 8;
133
137
  const FALL_MARGIN = 100;
134
138
  /** Bodies this close count as touching for `touch` hooks, so solids (doors, walls) can be touched too. */
135
139
  const TOUCH_MARGIN = 1;
140
+ /** Impacts slower than this (px/s, after bounce) stop instead of bouncing. */
141
+ const MIN_BOUNCE_SPEED = 40;
136
142
  const EPSILON = 1e-4;
137
143
 
138
144
  /**
@@ -161,6 +167,8 @@ export class World {
161
167
  private scriptInput: ScriptInput = { held: () => false, pressed: () => false };
162
168
  /** The script hook currently running, for error messages and event attribution. */
163
169
  private current: { entity: Entity; hook: string } | undefined;
170
+ /** True while a draw hook runs: the game can't be changed then. */
171
+ private drawing = false;
164
172
 
165
173
  constructor(scene: SceneDef, options: WorldOptions = {}) {
166
174
  this.width = scene.world?.width ?? 960;
@@ -195,6 +203,7 @@ export class World {
195
203
  if (this.won) return;
196
204
 
197
205
  this.updatePlatformers(input, pressed);
206
+ this.updateTopdown(input);
198
207
  this.updateScripts(input, pressed);
199
208
  if (this.won) return;
200
209
  this.updatePatrols();
@@ -286,9 +295,36 @@ export class World {
286
295
  const fn = script.module[hook];
287
296
  if (!fn) return;
288
297
  const ctx: ScriptContext = { self: this.handle(entity), world: this.api(), input: this.scriptInput, params: script.params, dt: FIXED_DT };
298
+ this.run(entity, hook, () => (fn as (ctx: ScriptContext, other?: ScriptEntity) => void).call(script.module, ctx, other));
299
+ }
300
+
301
+ /**
302
+ * Runs an entity's `draw` or `drawUI` hook. Called by the renderer, never by the
303
+ * simulation. `self` and `world` are read-only for the duration.
304
+ */
305
+ callDrawHook(entity: Entity, hook: "draw" | "drawUI", extras: Omit<DrawContext, "self" | "world" | "params">): void {
306
+ const script = entity.script;
307
+ const fn = script?.module[hook];
308
+ if (!script || !fn || entity.removed) return;
309
+ const ctx: DrawContext = { ...extras, self: this.handle(entity), world: this.api(), params: script.params };
310
+ this.drawing = true;
311
+ try {
312
+ this.run(entity, hook, () => fn.call(script.module, ctx));
313
+ } finally {
314
+ this.drawing = false;
315
+ }
316
+ }
317
+
318
+ /** True when any live entity's script has the given hook. */
319
+ hasHook(hook: "draw" | "drawUI"): boolean {
320
+ return this.entities.some((e) => !e.removed && typeof e.script?.module[hook] === "function");
321
+ }
322
+
323
+ private run(entity: Entity, hook: string, call: () => void): void {
324
+ const script = entity.script!;
289
325
  this.current = { entity, hook };
290
326
  try {
291
- (fn as (ctx: ScriptContext, other?: ScriptEntity) => void).call(script.module, ctx, other);
327
+ call();
292
328
  } catch (err) {
293
329
  if (err instanceof ScriptError) throw err;
294
330
  const message = err instanceof Error ? err.message : String(err);
@@ -298,6 +334,13 @@ export class World {
298
334
  }
299
335
  }
300
336
 
337
+ /** Draw hooks only paint, so the browser can't drift from what tests verified. */
338
+ private guard(what: string): void {
339
+ if (this.drawing) {
340
+ 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.`);
341
+ }
342
+ }
343
+
301
344
  /** An error caused by how a script used the API, reported against the running script. */
302
345
  private misuse(message: string): ScriptError {
303
346
  const running = this.current;
@@ -320,24 +363,28 @@ export class World {
320
363
  return e.transform.x;
321
364
  },
322
365
  set x(value: number) {
366
+ world.guard("Setting x");
323
367
  e.transform.x = value;
324
368
  },
325
369
  get y() {
326
370
  return e.transform.y;
327
371
  },
328
372
  set y(value: number) {
373
+ world.guard("Setting y");
329
374
  e.transform.y = value;
330
375
  },
331
376
  get vx() {
332
377
  return e.body?.vx ?? 0;
333
378
  },
334
379
  set vx(value: number) {
380
+ world.guard("Setting vx");
335
381
  if (e.body) e.body.vx = value;
336
382
  },
337
383
  get vy() {
338
384
  return e.body?.vy ?? 0;
339
385
  },
340
386
  set vy(value: number) {
387
+ world.guard("Setting vy");
341
388
  if (e.body) e.body.vy = value;
342
389
  },
343
390
  get grounded() {
@@ -353,7 +400,8 @@ export class World {
353
400
  return !e.removed;
354
401
  },
355
402
  get state() {
356
- return e.state;
403
+ // A frozen copy while drawing, so a draw hook can't write game state by accident.
404
+ return world.drawing ? Object.freeze({ ...e.state }) : e.state;
357
405
  },
358
406
  has(component: string) {
359
407
  return component === "transform" || (component in e && (e as unknown as Record<string, unknown>)[component] !== undefined);
@@ -391,7 +439,14 @@ export class World {
391
439
  get score() {
392
440
  return world.score;
393
441
  },
442
+ get deaths() {
443
+ return world.deaths;
444
+ },
445
+ get won() {
446
+ return world.won;
447
+ },
394
448
  set score(value: number) {
449
+ world.guard("Setting score");
395
450
  world.score = value;
396
451
  },
397
452
  get: (id) => {
@@ -401,27 +456,34 @@ export class World {
401
456
  all: (component) =>
402
457
  world.entities.filter((e) => !component || world.handle(e).has(component)).map((e) => world.handle(e)),
403
458
  player: () => {
404
- const e = world.entities.find((candidate) => candidate.platformer);
459
+ const e = world.entities.find(isPlayer);
405
460
  return e ? world.handle(e) : undefined;
406
461
  },
407
- spawn: (def) => world.spawn(def),
462
+ spawn: (def) => {
463
+ world.guard("world.spawn");
464
+ return world.spawn(def);
465
+ },
408
466
  destroy: (target) => {
467
+ world.guard("world.destroy");
409
468
  const e = world.resolve(target);
410
469
  if (e) world.remove(e);
411
470
  },
412
471
  kill: (target) => {
472
+ world.guard("world.kill");
413
473
  const e = world.resolve(target);
414
474
  if (!e) return;
415
- if (e.platformer && e.body) world.kill(e, world.current?.entity.id ?? "script");
475
+ if (isPlayer(e)) world.kill(e, world.current?.entity.id ?? "script");
416
476
  else world.remove(e);
417
477
  },
418
478
  win: () => {
479
+ world.guard("world.win");
419
480
  if (world.won) return;
420
481
  const by = world.current?.entity;
421
482
  world.won = true;
422
483
  world.emit({ type: "win", entity: by?.id ?? "script", goal: by?.id ?? "script", x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
423
484
  },
424
485
  emit: (name, data) => {
486
+ world.guard("world.emit");
425
487
  if (typeof name !== "string" || name === "") throw world.misuse("world.emit needs a non-empty event name.");
426
488
  if ((EVENT_TYPES as readonly string[]).includes(name)) {
427
489
  throw world.misuse(`"${name}" is a built-in event and can't be emitted by scripts. Pick another name, e.g. "${name}-custom".`);
@@ -429,7 +491,10 @@ export class World {
429
491
  const by = world.current?.entity;
430
492
  world.emit({ type: "custom", name, entity: by?.id ?? "script", data, x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
431
493
  },
432
- random: () => world.random(),
494
+ random: () => {
495
+ world.guard("world.random (it advances the game's seed; use ctx.random in draw hooks)");
496
+ return world.random();
497
+ },
433
498
  };
434
499
  return this.scriptApi;
435
500
  }
@@ -462,14 +527,33 @@ export class World {
462
527
 
463
528
  // ------------------------------------------------------------ built-ins
464
529
 
530
+ /** Entities the player controls: platformers and topdown movers. */
465
531
  private players(): Entity[] {
466
- return this.entities.filter((e) => e.platformer && e.body);
532
+ return this.entities.filter(isPlayer);
533
+ }
534
+
535
+ private updateTopdown(held: ReadonlySet<Action>): void {
536
+ for (const e of this.entities) {
537
+ const t = e.topdown;
538
+ const b = e.body;
539
+ if (!t || !b) continue;
540
+ let dx = (held.has("right") ? 1 : 0) - (held.has("left") ? 1 : 0);
541
+ let dy = (held.has("down") ? 1 : 0) - (held.has("up") ? 1 : 0);
542
+ if (t.directions === "x") dy = 0;
543
+ if (t.directions === "y") dx = 0;
544
+ if (t.directions === "4" && dx !== 0) dy = 0;
545
+ // Diagonals move at the same speed as straight lines.
546
+ const length = Math.hypot(dx, dy) || 1;
547
+ b.vx = (dx / length) * t.speed;
548
+ b.vy = (dy / length) * t.speed;
549
+ }
467
550
  }
468
551
 
469
552
  private updatePlatformers(held: ReadonlySet<Action>, pressed: ReadonlySet<Action>): void {
470
- for (const e of this.players()) {
471
- const p = e.platformer!;
472
- const b = e.body!;
553
+ for (const e of this.entities) {
554
+ const p = e.platformer;
555
+ const b = e.body;
556
+ if (!p || !b) continue;
473
557
 
474
558
  const direction = (held.has("right") ? 1 : 0) - (held.has("left") ? 1 : 0);
475
559
  b.vx = direction * p.speed;
@@ -517,13 +601,18 @@ export class World {
517
601
  private updateBodies(): void {
518
602
  // Kinematic bodies move first, so anything standing on them can follow.
519
603
  const moved = new Map<Entity, { dx: number; dy: number }>();
604
+ const walls = this.entities.filter((e) => e.body?.type === "static" && !e.body.sensor);
520
605
  for (const e of this.entities) {
521
606
  const b = e.body;
522
607
  if (!b || b.type !== "kinematic") continue;
523
608
  const { x, y } = e.transform;
524
609
  e.transform.x += b.vx * FIXED_DT;
610
+ // A player-steered kinematic body (a paddle) stops at walls; scripted movers pass through.
611
+ if (e.topdown) for (const wall of walls) if (overlaps(e, wall)) e.transform.x = wall.transform.x + (Math.sign(e.transform.x - wall.transform.x) * (wall.body!.width + b.width)) / 2;
525
612
  e.transform.y += b.vy * FIXED_DT;
613
+ if (e.topdown) for (const wall of walls) if (overlaps(e, wall)) e.transform.y = wall.transform.y + (Math.sign(e.transform.y - wall.transform.y) * (wall.body!.height + b.height)) / 2;
526
614
  if (e.patrol) clampToPatrol(e, e.patrol);
615
+ if (e.topdown?.clampToWorld) this.clampToWorld(e);
527
616
  moved.set(e, { dx: e.transform.x - x, dy: e.transform.y - y });
528
617
  }
529
618
 
@@ -539,7 +628,8 @@ export class World {
539
628
  e.transform.y += carried.dy;
540
629
  }
541
630
 
542
- b.vy = Math.min(b.vy + this.gravity * b.gravityScale * FIXED_DT, MAX_FALL_SPEED);
631
+ // Top-down movers ignore gravity; everything else falls.
632
+ if (!e.topdown) b.vy = Math.min(b.vy + this.gravity * b.gravityScale * FIXED_DT, MAX_FALL_SPEED);
543
633
  const wasGrounded = b.grounded;
544
634
  b.grounded = false;
545
635
  b.standingOn = undefined;
@@ -551,27 +641,47 @@ export class World {
551
641
  const half = (solid.body!.width + b.width) / 2;
552
642
  if (b.vx > 0) e.transform.x = solid.transform.x - half;
553
643
  else if (b.vx < 0) e.transform.x = solid.transform.x + half;
554
- b.vx = 0;
644
+ b.vx = this.rebound(e, solid, b.vx);
555
645
  }
556
646
 
557
647
  e.transform.y += b.vy * FIXED_DT;
558
648
  for (const solid of solids) {
559
649
  if (!overlaps(e, solid)) continue;
560
650
  const half = (solid.body!.height + b.height) / 2;
561
- if (b.vy > 0) {
562
- e.transform.y = solid.transform.y - half;
651
+ const falling = b.vy > 0;
652
+ if (b.vy > 0) e.transform.y = solid.transform.y - half;
653
+ else if (b.vy < 0) e.transform.y = solid.transform.y + half;
654
+ b.vy = this.rebound(e, solid, b.vy);
655
+ // A body that stopped on top of something is standing on it; one that bounced off is airborne.
656
+ if (falling && b.vy === 0) {
563
657
  b.grounded = true;
564
658
  b.standingOn = solid;
565
- } else if (b.vy < 0) {
566
- e.transform.y = solid.transform.y + half;
567
659
  }
568
- b.vy = 0;
569
660
  }
570
661
 
662
+ if (e.topdown?.clampToWorld) this.clampToWorld(e);
571
663
  if (b.grounded && !wasGrounded) this.emit({ type: "land", entity: e.id, ...feet(e) });
572
664
  }
573
665
  }
574
666
 
667
+ /**
668
+ * The velocity along one axis after hitting `solid`: 0, or reversed and scaled by `bounce`.
669
+ * Slow impacts stop instead, so resting bodies don't jitter.
670
+ */
671
+ private rebound(e: Entity, solid: Entity, velocity: number): number {
672
+ const bounce = e.body!.bounce;
673
+ if (bounce <= 0 || Math.abs(velocity) * bounce < MIN_BOUNCE_SPEED) return 0;
674
+ this.emit({ type: "bounce", entity: e.id, other: solid.id, ...e.transform });
675
+ return -velocity * bounce;
676
+ }
677
+
678
+ private clampToWorld(e: Entity): void {
679
+ const halfWidth = (e.body?.width ?? 0) / 2;
680
+ const halfHeight = (e.body?.height ?? 0) / 2;
681
+ e.transform.x = Math.min(this.width - halfWidth, Math.max(halfWidth, e.transform.x));
682
+ e.transform.y = Math.min(this.height - halfHeight, Math.max(halfHeight, e.transform.y));
683
+ }
684
+
575
685
  private updateCollectibles(): void {
576
686
  const players = this.players();
577
687
  for (const item of this.entities.filter((e) => e.collectible && e.body)) {
@@ -592,7 +702,7 @@ export class World {
592
702
  }
593
703
  for (const hazard of this.entities.filter((e) => e.hazard && e.body)) {
594
704
  if (!overlaps(player, hazard)) continue;
595
- if (hazard.hazard!.stompable && isStomp(player, hazard)) {
705
+ if (hazard.hazard!.stompable && player.platformer && isStomp(player, hazard)) {
596
706
  this.remove(hazard);
597
707
  b.vy = -player.platformer!.jumpSpeed * STOMP_BOUNCE;
598
708
  this.score += hazard.hazard!.score;
@@ -619,18 +729,20 @@ export class World {
619
729
  /** Sends a player back to where it started. Collected items stay collected. */
620
730
  private kill(player: Entity, cause: string): void {
621
731
  const b = player.body!;
622
- const p = player.platformer!;
623
732
  this.deaths++;
624
733
  this.emit({ type: "die", entity: player.id, cause, ...player.transform });
625
- player.transform.x = p.spawnX;
626
- player.transform.y = p.spawnY;
734
+ player.transform.x = player.spawn?.x ?? player.transform.x;
735
+ player.transform.y = player.spawn?.y ?? player.transform.y;
627
736
  b.vx = 0;
628
737
  b.vy = 0;
629
738
  b.grounded = false;
630
739
  b.standingOn = undefined;
631
- p.coyoteTimer = 0;
632
- p.bufferTimer = 0;
633
- p.jumping = false;
740
+ const p = player.platformer;
741
+ if (p) {
742
+ p.coyoteTimer = 0;
743
+ p.bufferTimer = 0;
744
+ p.jumping = false;
745
+ }
634
746
  }
635
747
  }
636
748
 
@@ -646,10 +758,10 @@ function createEntity(def: EntityDef): Entity {
646
758
  coyoteTimer: 0,
647
759
  bufferTimer: 0,
648
760
  jumping: false,
649
- spawnX: transform.x,
650
- spawnY: transform.y,
651
761
  };
652
762
  }
763
+ if (c.topdown) entity.topdown = resolveComponent<Topdown>("topdown", c.topdown);
764
+ if (entity.platformer || entity.topdown) entity.spawn = { x: transform.x, y: transform.y };
653
765
  if (c.collectible) entity.collectible = resolveComponent<Collectible>("collectible", c.collectible);
654
766
  if (c.patrol) entity.patrol = { ...resolveComponent<Patrol>("patrol", c.patrol), originX: transform.x, originY: transform.y };
655
767
  if (c.hazard) entity.hazard = resolveComponent<Hazard>("hazard", c.hazard);
@@ -659,6 +771,10 @@ function createEntity(def: EntityDef): Entity {
659
771
  return entity;
660
772
  }
661
773
 
774
+ function isPlayer(e: Entity): boolean {
775
+ return !!(e.platformer || e.topdown) && !!e.body;
776
+ }
777
+
662
778
  function feet(e: Entity): { x: number; y: number } {
663
779
  return { x: e.transform.x, y: e.transform.y + (e.body?.height ?? 0) / 2 };
664
780
  }