@ludra/core 0.4.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
@@ -8,11 +8,25 @@ import {
8
8
  type Hazard,
9
9
  type Patrol,
10
10
  type Platformer,
11
+ type ScriptComponent,
11
12
  type Sprite,
13
+ type Topdown,
12
14
  type Transform,
13
15
  } from "./components.ts";
16
+ import {
17
+ getScript,
18
+ ScriptError,
19
+ type DrawContext,
20
+ type Script,
21
+ type ScriptContext,
22
+ type ScriptEntity,
23
+ type ScriptInput,
24
+ type ScriptModules,
25
+ type ScriptWorld,
26
+ type SpawnDef,
27
+ } from "./script.ts";
14
28
  import { FIXED_DT } from "./time.ts";
15
- import type { Action, Diagnostic, EntityDef, SceneDef } from "./types.ts";
29
+ import { EVENT_TYPES, type Action, type Diagnostic, type EntityDef, type SceneDef } from "./types.ts";
16
30
  import { validateScene } from "./validate.ts";
17
31
 
18
32
  export interface BodyState extends Body {
@@ -23,9 +37,9 @@ export interface BodyState extends Body {
23
37
 
24
38
  export interface PlatformerState extends Platformer {
25
39
  coyoteTimer: number;
40
+ /** True from a player-initiated jump until the apex, so only those jumps are cut short on release. */
41
+ jumping: boolean;
26
42
  bufferTimer: number;
27
- spawnX: number;
28
- spawnY: number;
29
43
  }
30
44
 
31
45
  export interface PatrolState extends Patrol {
@@ -33,12 +47,27 @@ export interface PatrolState extends Patrol {
33
47
  originY: number;
34
48
  }
35
49
 
50
+ export interface ScriptState extends ScriptComponent {
51
+ module: Script;
52
+ started: boolean;
53
+ /** Ids of entities touched last frame, so `touch` fires only when touching starts. */
54
+ touching: Set<string>;
55
+ }
56
+
36
57
  export interface Entity {
37
58
  id: string;
59
+ /** Script-owned data, kept between frames and included in snapshots. */
60
+ state: Record<string, unknown>;
61
+ /** Set once destroyed or collected, so stale script handles report `exists: false`. */
62
+ removed?: boolean;
63
+ script?: ScriptState;
38
64
  transform: Transform;
39
65
  body?: BodyState;
40
66
  sprite?: Sprite;
41
67
  platformer?: PlatformerState;
68
+ topdown?: Topdown;
69
+ /** Where a player respawns after dying. */
70
+ spawn?: { x: number; y: number };
42
71
  collectible?: Collectible;
43
72
  patrol?: PatrolState;
44
73
  hazard?: Hazard;
@@ -55,6 +84,9 @@ export type GameEvent = { frame: number; time: number; x: number; y: number } &
55
84
  | { type: "die"; entity: string; cause: string }
56
85
  | { type: "stomp"; entity: string; enemy: string; score: number }
57
86
  | { type: "win"; entity: string; goal: string }
87
+ | { type: "bounce"; entity: string; other: string }
88
+ /** Fired by a script with world.emit(name, data). */
89
+ | { type: "custom"; name: string; entity: string; data?: unknown }
58
90
  );
59
91
 
60
92
  type EventPayload = GameEvent extends infer E ? (E extends GameEvent ? Omit<E, "frame" | "time"> : never) : never;
@@ -65,6 +97,13 @@ export interface EntitySnapshot {
65
97
  vx: number;
66
98
  vy: number;
67
99
  grounded: boolean;
100
+ /** Present when a script stored data on the entity. */
101
+ state?: Record<string, unknown>;
102
+ }
103
+
104
+ export interface WorldOptions {
105
+ /** Loaded script modules, keyed by the `src` path used in the scene. */
106
+ scripts?: ScriptModules;
68
107
  }
69
108
 
70
109
  export interface WorldSnapshot {
@@ -96,6 +135,10 @@ const STOMP_BOUNCE = 0.6;
96
135
  const STOMP_TOLERANCE = 8;
97
136
  /** A platformer this far below the bottom of the world has fallen out of it. */
98
137
  const FALL_MARGIN = 100;
138
+ /** Bodies this close count as touching for `touch` hooks, so solids (doors, walls) can be touched too. */
139
+ const TOUCH_MARGIN = 1;
140
+ /** Impacts slower than this (px/s, after bounce) stop instead of bouncing. */
141
+ const MIN_BOUNCE_SPEED = 40;
99
142
  const EPSILON = 1e-4;
100
143
 
101
144
  /**
@@ -116,20 +159,32 @@ export class World {
116
159
  /** Set when a player reaches a goal. The simulation stops changing after that. */
117
160
  won = false;
118
161
  private previousInput: ReadonlySet<Action> = new Set();
119
-
120
- constructor(scene: SceneDef) {
162
+ private readonly scripts: ScriptModules | undefined;
163
+ private seed: number;
164
+ private spawnCount = 0;
165
+ private readonly handles = new WeakMap<Entity, ScriptEntity>();
166
+ private scriptApi: ScriptWorld | undefined;
167
+ private scriptInput: ScriptInput = { held: () => false, pressed: () => false };
168
+ /** The script hook currently running, for error messages and event attribution. */
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;
172
+
173
+ constructor(scene: SceneDef, options: WorldOptions = {}) {
121
174
  this.width = scene.world?.width ?? 960;
122
175
  this.height = scene.world?.height ?? 540;
123
176
  this.gravity = scene.world?.gravity ?? DEFAULT_GRAVITY;
124
177
  this.background = scene.world?.background ?? "#1d2333";
125
- this.entities = scene.entities.map(createEntity);
178
+ this.seed = scene.world?.seed ?? 1;
179
+ this.scripts = options.scripts;
180
+ this.entities = scene.entities.map((def) => this.createEntity(def));
126
181
  }
127
182
 
128
183
  /** Validates raw scene JSON and builds a world, throwing on errors. */
129
- static fromJSON(raw: unknown): World {
184
+ static fromJSON(raw: unknown, options: WorldOptions = {}): World {
130
185
  const { scene, diagnostics } = validateScene(raw);
131
186
  if (!scene) throw new SceneValidationError(diagnostics);
132
- return new World(scene);
187
+ return new World(scene, options);
133
188
  }
134
189
 
135
190
  get time(): number {
@@ -148,8 +203,13 @@ export class World {
148
203
  if (this.won) return;
149
204
 
150
205
  this.updatePlatformers(input, pressed);
206
+ this.updateTopdown(input);
207
+ this.updateScripts(input, pressed);
208
+ if (this.won) return;
151
209
  this.updatePatrols();
152
210
  this.updateBodies();
211
+ this.updateTouches();
212
+ if (this.won) return;
153
213
  this.updateCollectibles();
154
214
  this.updateHazards();
155
215
  this.updateGoals();
@@ -165,6 +225,8 @@ export class World {
165
225
  vy: e.body?.vy ?? 0,
166
226
  grounded: e.body?.grounded ?? false,
167
227
  };
228
+ // A JSON copy, so later frames can't change an earlier snapshot.
229
+ if (Object.keys(e.state).length > 0) entities[e.id]!.state = JSON.parse(JSON.stringify(e.state));
168
230
  }
169
231
  return { frame: this.frame, time: this.time, score: this.score, deaths: this.deaths, won: this.won, entities };
170
232
  }
@@ -174,17 +236,324 @@ export class World {
174
236
  }
175
237
 
176
238
  private remove(entity: Entity): void {
177
- this.entities.splice(this.entities.indexOf(entity), 1);
239
+ const index = this.entities.indexOf(entity);
240
+ if (index >= 0) this.entities.splice(index, 1);
241
+ entity.removed = true;
242
+ }
243
+
244
+ // ---------------------------------------------------------------- scripts
245
+
246
+ private createEntity(def: EntityDef): Entity {
247
+ const entity = createEntity(def);
248
+ const component = def.components.script;
249
+ if (component) {
250
+ const resolved = resolveComponent<ScriptComponent>("script", component);
251
+ const module = getScript(this.scripts, resolved.src);
252
+ if (!module) {
253
+ throw new ScriptError(
254
+ { src: resolved.src, entity: def.id, hook: "load", frame: this.frame },
255
+ "this script is not loaded. Hosts pass loaded modules as new World(scene, { scripts }); the CLI and browser runtime do this for you.",
256
+ );
257
+ }
258
+ entity.script = { ...resolved, params: { ...resolved.params }, module, started: false, touching: new Set() };
259
+ }
260
+ return entity;
261
+ }
262
+
263
+ private updateScripts(held: ReadonlySet<Action>, pressed: ReadonlySet<Action>): void {
264
+ this.scriptInput = { held: (action) => held.has(action), pressed: (action) => pressed.has(action) };
265
+ // Iterate over a copy: scripts may spawn or destroy entities.
266
+ for (const e of [...this.entities]) {
267
+ if (!e.script || e.removed) continue;
268
+ if (!e.script.started) {
269
+ e.script.started = true;
270
+ this.callHook(e, "start");
271
+ }
272
+ if (!e.removed && !this.won) this.callHook(e, "update");
273
+ if (this.won) return;
274
+ }
275
+ }
276
+
277
+ /** Calls `touch` on scripted entities that started overlapping or touching another body this frame. */
278
+ private updateTouches(): void {
279
+ for (const e of [...this.entities]) {
280
+ if (!e.script?.module.touch || !e.body || e.removed) continue;
281
+ const now = new Set<string>();
282
+ for (const other of this.entities) {
283
+ if (other !== e && other.body && overlaps(e, other, TOUCH_MARGIN)) now.add(other.id);
284
+ }
285
+ for (const id of now) {
286
+ const other = this.get(id);
287
+ if (other && !e.script.touching.has(id) && !e.removed && !this.won) this.callHook(e, "touch", this.handle(other));
288
+ }
289
+ e.script.touching = now;
290
+ }
291
+ }
292
+
293
+ private callHook(entity: Entity, hook: "start" | "update" | "touch", other?: ScriptEntity): void {
294
+ const script = entity.script!;
295
+ const fn = script.module[hook];
296
+ if (!fn) return;
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!;
325
+ this.current = { entity, hook };
326
+ try {
327
+ call();
328
+ } catch (err) {
329
+ if (err instanceof ScriptError) throw err;
330
+ const message = err instanceof Error ? err.message : String(err);
331
+ throw new ScriptError({ src: script.src, entity: entity.id, hook, frame: this.frame }, message, { cause: err });
332
+ } finally {
333
+ this.current = undefined;
334
+ }
335
+ }
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
+ }
178
342
  }
179
343
 
344
+ /** An error caused by how a script used the API, reported against the running script. */
345
+ private misuse(message: string): ScriptError {
346
+ const running = this.current;
347
+ return new ScriptError(
348
+ { src: running?.entity.script?.src ?? "?", entity: running?.entity.id ?? "?", hook: running?.hook ?? "?", frame: this.frame },
349
+ message,
350
+ );
351
+ }
352
+
353
+ /** A stable script-facing view of an entity. */
354
+ private handle(e: Entity): ScriptEntity {
355
+ let handle = this.handles.get(e);
356
+ if (handle) return handle;
357
+ const world = this;
358
+ handle = {
359
+ get id() {
360
+ return e.id;
361
+ },
362
+ get x() {
363
+ return e.transform.x;
364
+ },
365
+ set x(value: number) {
366
+ world.guard("Setting x");
367
+ e.transform.x = value;
368
+ },
369
+ get y() {
370
+ return e.transform.y;
371
+ },
372
+ set y(value: number) {
373
+ world.guard("Setting y");
374
+ e.transform.y = value;
375
+ },
376
+ get vx() {
377
+ return e.body?.vx ?? 0;
378
+ },
379
+ set vx(value: number) {
380
+ world.guard("Setting vx");
381
+ if (e.body) e.body.vx = value;
382
+ },
383
+ get vy() {
384
+ return e.body?.vy ?? 0;
385
+ },
386
+ set vy(value: number) {
387
+ world.guard("Setting vy");
388
+ if (e.body) e.body.vy = value;
389
+ },
390
+ get grounded() {
391
+ return e.body?.grounded ?? false;
392
+ },
393
+ get width() {
394
+ return e.body?.width ?? e.sprite?.width ?? 0;
395
+ },
396
+ get height() {
397
+ return e.body?.height ?? e.sprite?.height ?? 0;
398
+ },
399
+ get exists() {
400
+ return !e.removed;
401
+ },
402
+ get 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;
405
+ },
406
+ has(component: string) {
407
+ return component === "transform" || (component in e && (e as unknown as Record<string, unknown>)[component] !== undefined);
408
+ },
409
+ touches(other: ScriptEntity | string) {
410
+ const target = world.get(typeof other === "string" ? other : other.id);
411
+ return !!target && target !== e && !e.removed && overlaps(e, target, TOUCH_MARGIN);
412
+ },
413
+ };
414
+ this.handles.set(e, handle);
415
+ return handle;
416
+ }
417
+
418
+ private resolve(target: ScriptEntity | string): Entity | undefined {
419
+ return this.get(typeof target === "string" ? target : target.id);
420
+ }
421
+
422
+ /** The `world` object scripts receive. */
423
+ private api(): ScriptWorld {
424
+ if (this.scriptApi) return this.scriptApi;
425
+ const world = this;
426
+ this.scriptApi = {
427
+ get time() {
428
+ return world.time;
429
+ },
430
+ get frame() {
431
+ return world.frame;
432
+ },
433
+ get width() {
434
+ return world.width;
435
+ },
436
+ get height() {
437
+ return world.height;
438
+ },
439
+ get score() {
440
+ return world.score;
441
+ },
442
+ get deaths() {
443
+ return world.deaths;
444
+ },
445
+ get won() {
446
+ return world.won;
447
+ },
448
+ set score(value: number) {
449
+ world.guard("Setting score");
450
+ world.score = value;
451
+ },
452
+ get: (id) => {
453
+ const e = world.get(id);
454
+ return e ? world.handle(e) : undefined;
455
+ },
456
+ all: (component) =>
457
+ world.entities.filter((e) => !component || world.handle(e).has(component)).map((e) => world.handle(e)),
458
+ player: () => {
459
+ const e = world.entities.find(isPlayer);
460
+ return e ? world.handle(e) : undefined;
461
+ },
462
+ spawn: (def) => {
463
+ world.guard("world.spawn");
464
+ return world.spawn(def);
465
+ },
466
+ destroy: (target) => {
467
+ world.guard("world.destroy");
468
+ const e = world.resolve(target);
469
+ if (e) world.remove(e);
470
+ },
471
+ kill: (target) => {
472
+ world.guard("world.kill");
473
+ const e = world.resolve(target);
474
+ if (!e) return;
475
+ if (isPlayer(e)) world.kill(e, world.current?.entity.id ?? "script");
476
+ else world.remove(e);
477
+ },
478
+ win: () => {
479
+ world.guard("world.win");
480
+ if (world.won) return;
481
+ const by = world.current?.entity;
482
+ world.won = true;
483
+ world.emit({ type: "win", entity: by?.id ?? "script", goal: by?.id ?? "script", x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
484
+ },
485
+ emit: (name, data) => {
486
+ world.guard("world.emit");
487
+ if (typeof name !== "string" || name === "") throw world.misuse("world.emit needs a non-empty event name.");
488
+ if ((EVENT_TYPES as readonly string[]).includes(name)) {
489
+ throw world.misuse(`"${name}" is a built-in event and can't be emitted by scripts. Pick another name, e.g. "${name}-custom".`);
490
+ }
491
+ const by = world.current?.entity;
492
+ world.emit({ type: "custom", name, entity: by?.id ?? "script", data, x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
493
+ },
494
+ random: () => {
495
+ world.guard("world.random (it advances the game's seed; use ctx.random in draw hooks)");
496
+ return world.random();
497
+ },
498
+ };
499
+ return this.scriptApi;
500
+ }
501
+
502
+ private spawn(def: SpawnDef): ScriptEntity {
503
+ if (typeof def !== "object" || def === null || typeof def.components !== "object") {
504
+ throw this.misuse('world.spawn needs { components: { ... } }, e.g. { components: { transform: { x: 0, y: 0 } } }.');
505
+ }
506
+ const id = def.id ?? `${this.current?.entity.id ?? "spawned"}-${++this.spawnCount}`;
507
+ if (this.get(id)) throw this.misuse(`world.spawn: an entity with id "${id}" already exists. Leave id out to get a unique one.`);
508
+ const { diagnostics } = validateScene({ entities: [{ id, components: def.components }] });
509
+ const errors = diagnostics.filter((d) => d.severity === "error" && d.code !== "E_GOAL_UNREACHABLE");
510
+ if (errors.length > 0) {
511
+ const details = errors.map((d) => `${d.path.replace("entities[0]", id)}: ${d.message}${d.fix ? ` Fix: ${d.fix}` : ""}`);
512
+ throw this.misuse(`world.spawn got an invalid entity. ${details.join(" ")}`);
513
+ }
514
+ const entity = this.createEntity({ id, components: def.components });
515
+ this.entities.push(entity);
516
+ return this.handle(entity);
517
+ }
518
+
519
+ /** mulberry32, seeded by world.seed. */
520
+ private random(): number {
521
+ this.seed = (this.seed + 0x6d2b79f5) | 0;
522
+ let t = this.seed;
523
+ t = Math.imul(t ^ (t >>> 15), t | 1);
524
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
525
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
526
+ }
527
+
528
+ // ------------------------------------------------------------ built-ins
529
+
530
+ /** Entities the player controls: platformers and topdown movers. */
180
531
  private players(): Entity[] {
181
- 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
+ }
182
550
  }
183
551
 
184
552
  private updatePlatformers(held: ReadonlySet<Action>, pressed: ReadonlySet<Action>): void {
185
- for (const e of this.players()) {
186
- const p = e.platformer!;
187
- 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;
188
557
 
189
558
  const direction = (held.has("right") ? 1 : 0) - (held.has("left") ? 1 : 0);
190
559
  b.vx = direction * p.speed;
@@ -199,10 +568,13 @@ export class World {
199
568
  b.standingOn = undefined;
200
569
  p.bufferTimer = 0;
201
570
  p.coyoteTimer = 0;
571
+ p.jumping = true;
202
572
  this.emit({ type: "jump", entity: e.id, ...feet(e) });
203
- } else if (p.variableJump && !held.has("jump") && b.vy < -p.jumpSpeed * JUMP_CUT) {
573
+ } else if (p.jumping && p.variableJump && !held.has("jump") && b.vy < -p.jumpSpeed * JUMP_CUT) {
574
+ // Only the player's own jumps are cut short; launches from pads, stomps or scripts are not.
204
575
  b.vy = -p.jumpSpeed * JUMP_CUT;
205
576
  }
577
+ if (b.vy >= 0) p.jumping = false;
206
578
 
207
579
  p.coyoteTimer = Math.max(0, p.coyoteTimer - FIXED_DT);
208
580
  p.bufferTimer = Math.max(0, p.bufferTimer - FIXED_DT);
@@ -229,13 +601,18 @@ export class World {
229
601
  private updateBodies(): void {
230
602
  // Kinematic bodies move first, so anything standing on them can follow.
231
603
  const moved = new Map<Entity, { dx: number; dy: number }>();
604
+ const walls = this.entities.filter((e) => e.body?.type === "static" && !e.body.sensor);
232
605
  for (const e of this.entities) {
233
606
  const b = e.body;
234
607
  if (!b || b.type !== "kinematic") continue;
235
608
  const { x, y } = e.transform;
236
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;
237
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;
238
614
  if (e.patrol) clampToPatrol(e, e.patrol);
615
+ if (e.topdown?.clampToWorld) this.clampToWorld(e);
239
616
  moved.set(e, { dx: e.transform.x - x, dy: e.transform.y - y });
240
617
  }
241
618
 
@@ -251,7 +628,8 @@ export class World {
251
628
  e.transform.y += carried.dy;
252
629
  }
253
630
 
254
- 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);
255
633
  const wasGrounded = b.grounded;
256
634
  b.grounded = false;
257
635
  b.standingOn = undefined;
@@ -263,27 +641,47 @@ export class World {
263
641
  const half = (solid.body!.width + b.width) / 2;
264
642
  if (b.vx > 0) e.transform.x = solid.transform.x - half;
265
643
  else if (b.vx < 0) e.transform.x = solid.transform.x + half;
266
- b.vx = 0;
644
+ b.vx = this.rebound(e, solid, b.vx);
267
645
  }
268
646
 
269
647
  e.transform.y += b.vy * FIXED_DT;
270
648
  for (const solid of solids) {
271
649
  if (!overlaps(e, solid)) continue;
272
650
  const half = (solid.body!.height + b.height) / 2;
273
- if (b.vy > 0) {
274
- 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) {
275
657
  b.grounded = true;
276
658
  b.standingOn = solid;
277
- } else if (b.vy < 0) {
278
- e.transform.y = solid.transform.y + half;
279
659
  }
280
- b.vy = 0;
281
660
  }
282
661
 
662
+ if (e.topdown?.clampToWorld) this.clampToWorld(e);
283
663
  if (b.grounded && !wasGrounded) this.emit({ type: "land", entity: e.id, ...feet(e) });
284
664
  }
285
665
  }
286
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
+
287
685
  private updateCollectibles(): void {
288
686
  const players = this.players();
289
687
  for (const item of this.entities.filter((e) => e.collectible && e.body)) {
@@ -304,7 +702,7 @@ export class World {
304
702
  }
305
703
  for (const hazard of this.entities.filter((e) => e.hazard && e.body)) {
306
704
  if (!overlaps(player, hazard)) continue;
307
- if (hazard.hazard!.stompable && isStomp(player, hazard)) {
705
+ if (hazard.hazard!.stompable && player.platformer && isStomp(player, hazard)) {
308
706
  this.remove(hazard);
309
707
  b.vy = -player.platformer!.jumpSpeed * STOMP_BOUNCE;
310
708
  this.score += hazard.hazard!.score;
@@ -331,24 +729,27 @@ export class World {
331
729
  /** Sends a player back to where it started. Collected items stay collected. */
332
730
  private kill(player: Entity, cause: string): void {
333
731
  const b = player.body!;
334
- const p = player.platformer!;
335
732
  this.deaths++;
336
733
  this.emit({ type: "die", entity: player.id, cause, ...player.transform });
337
- player.transform.x = p.spawnX;
338
- 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;
339
736
  b.vx = 0;
340
737
  b.vy = 0;
341
738
  b.grounded = false;
342
739
  b.standingOn = undefined;
343
- p.coyoteTimer = 0;
344
- p.bufferTimer = 0;
740
+ const p = player.platformer;
741
+ if (p) {
742
+ p.coyoteTimer = 0;
743
+ p.bufferTimer = 0;
744
+ p.jumping = false;
745
+ }
345
746
  }
346
747
  }
347
748
 
348
749
  function createEntity(def: EntityDef): Entity {
349
750
  const c = def.components;
350
751
  const transform = c.transform ? resolveComponent<Transform>("transform", c.transform) : { x: 0, y: 0 };
351
- const entity: Entity = { id: def.id, transform };
752
+ const entity: Entity = { id: def.id, state: {}, transform };
352
753
  if (c.body) entity.body = { ...resolveComponent<Body>("body", c.body), grounded: false };
353
754
  if (c.sprite) entity.sprite = resolveComponent<Sprite>("sprite", c.sprite);
354
755
  if (c.platformer) {
@@ -356,10 +757,11 @@ function createEntity(def: EntityDef): Entity {
356
757
  ...resolveComponent<Platformer>("platformer", c.platformer),
357
758
  coyoteTimer: 0,
358
759
  bufferTimer: 0,
359
- spawnX: transform.x,
360
- spawnY: transform.y,
760
+ jumping: false,
361
761
  };
362
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 };
363
765
  if (c.collectible) entity.collectible = resolveComponent<Collectible>("collectible", c.collectible);
364
766
  if (c.patrol) entity.patrol = { ...resolveComponent<Patrol>("patrol", c.patrol), originX: transform.x, originY: transform.y };
365
767
  if (c.hazard) entity.hazard = resolveComponent<Hazard>("hazard", c.hazard);
@@ -369,6 +771,10 @@ function createEntity(def: EntityDef): Entity {
369
771
  return entity;
370
772
  }
371
773
 
774
+ function isPlayer(e: Entity): boolean {
775
+ return !!(e.platformer || e.topdown) && !!e.body;
776
+ }
777
+
372
778
  function feet(e: Entity): { x: number; y: number } {
373
779
  return { x: e.transform.x, y: e.transform.y + (e.body?.height ?? 0) / 2 };
374
780
  }
@@ -387,11 +793,14 @@ function isStomp(player: Entity, enemy: Entity): boolean {
387
793
  return previousBottom <= enemyTop + STOMP_TOLERANCE;
388
794
  }
389
795
 
390
- /** Strict overlap: bodies that merely touch (e.g. standing on the ground) do not overlap. */
391
- export function overlaps(a: Entity, b: Entity): boolean {
796
+ /**
797
+ * Strict overlap: bodies that merely touch (e.g. standing on the ground) do not overlap.
798
+ * A positive `margin` grows both boxes, so touching counts too.
799
+ */
800
+ export function overlaps(a: Entity, b: Entity, margin = 0): boolean {
392
801
  if (!a.body || !b.body) return false;
393
802
  return (
394
- Math.abs(a.transform.x - b.transform.x) < (a.body.width + b.body.width) / 2 - EPSILON &&
395
- Math.abs(a.transform.y - b.transform.y) < (a.body.height + b.body.height) / 2 - EPSILON
803
+ Math.abs(a.transform.x - b.transform.x) < (a.body.width + b.body.width) / 2 + margin - EPSILON &&
804
+ Math.abs(a.transform.y - b.transform.y) < (a.body.height + b.body.height) / 2 + margin - EPSILON
396
805
  );
397
806
  }