@ludra/core 0.4.0 → 0.5.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/validate.ts CHANGED
@@ -44,6 +44,9 @@ export function validateScene(raw: unknown): ValidationResult {
44
44
  error("E_INVALID_WORLD", `world.${key}`, `world.${key} must be a non-negative number, got ${describe(value)}.`);
45
45
  }
46
46
  }
47
+ if (raw.world.seed !== undefined && (typeof raw.world.seed !== "number" || !Number.isFinite(raw.world.seed))) {
48
+ error("E_INVALID_WORLD", "world.seed", `world.seed must be a number, got ${describe(raw.world.seed)}.`);
49
+ }
47
50
  if (raw.world.background !== undefined && typeof raw.world.background !== "string") {
48
51
  error("E_INVALID_WORLD", "world.background", `world.background must be a CSS color string, got ${describe(raw.world.background)}.`);
49
52
  }
@@ -94,7 +97,7 @@ export function validateScene(raw: unknown): ValidationResult {
94
97
  for (const [name, value] of Object.entries(components)) {
95
98
  const componentPath = `${path}.components.${name}`;
96
99
  if (!isComponentName(name)) {
97
- const suggestion = closest(name, COMPONENT_NAMES);
100
+ const suggestion = closestName(name, COMPONENT_NAMES);
98
101
  error(
99
102
  "E_UNKNOWN_COMPONENT",
100
103
  componentPath,
@@ -135,6 +138,16 @@ export function validateScene(raw: unknown): ValidationResult {
135
138
  );
136
139
  }
137
140
 
141
+ const script = isObject(components.script) ? components.script : undefined;
142
+ if (script && typeof script.src === "string" && !hasExtension(script.src, SCRIPT_EXTENSIONS)) {
143
+ error(
144
+ "E_INVALID_SCRIPT_PATH",
145
+ `${path}.components.script.src`,
146
+ `"${script.src}" on entity ${label} is not a script file.`,
147
+ 'Use a path ending in .ts or .js, e.g. "scripts/chase.ts".',
148
+ );
149
+ }
150
+
138
151
  const body = isObject(components.body) ? components.body : undefined;
139
152
  const bodyType = body?.type ?? "static";
140
153
  if ("platformer" in components && body && bodyType !== "dynamic") {
@@ -229,7 +242,7 @@ function validateFields(
229
242
  if (ignore.includes(field)) continue;
230
243
  const spec = fields[field];
231
244
  if (!spec) {
232
- const suggestion = closest(field, Object.keys(fields));
245
+ const suggestion = closestName(field, Object.keys(fields));
233
246
  warn(
234
247
  "W_UNKNOWN_FIELD",
235
248
  `${path}.${field}`,
@@ -258,9 +271,16 @@ function validateEffects(effects: unknown, error: Report, warn: Report): void {
258
271
  for (const [event, effect] of Object.entries(effects)) {
259
272
  const path = `effects.${event}`;
260
273
  if (!(EVENT_TYPES as readonly string[]).includes(event)) {
261
- const suggestion = closest(event, EVENT_TYPES);
262
- error("E_UNKNOWN_EVENT", path, `There is no "${event}" event.`, `${suggestion ? `Did you mean "${suggestion}"? ` : ""}Events: ${EVENT_TYPES.join(", ")}.`);
263
- continue;
274
+ // Any other name is a custom event that scripts fire with world.emit(). Flag likely typos of built-in ones.
275
+ const suggestion = closestName(event, EVENT_TYPES);
276
+ if (suggestion) {
277
+ warn(
278
+ "W_EVENT_LOOKS_LIKE_TYPO",
279
+ path,
280
+ `"${event}" is not a built-in event, so it only fires if a script calls world.emit("${event}").`,
281
+ `Did you mean "${suggestion}"? Built-in events: ${EVENT_TYPES.join(", ")}.`,
282
+ );
283
+ }
264
284
  }
265
285
  if (!isObject(effect)) {
266
286
  error("E_EFFECT_NOT_OBJECT", path, `effects.${event} must be an object.`, `Use { "sound": "blip", "particles": { "count": 8 } }.`);
@@ -268,7 +288,7 @@ function validateEffects(effects: unknown, error: Report, warn: Report): void {
268
288
  }
269
289
  validateFields(EFFECT_FIELDS, effect, path, `effects.${event}`, "", error, warn, ["particles"]);
270
290
  if (typeof effect.sound === "string" && effect.sound !== "" && !isSoundPreset(effect.sound) && !hasExtension(effect.sound, AUDIO_EXTENSIONS)) {
271
- const suggestion = closest(effect.sound, SOUND_PRESETS);
291
+ const suggestion = closestName(effect.sound, SOUND_PRESETS);
272
292
  error(
273
293
  "E_INVALID_SOUND",
274
294
  `${path}.sound`,
@@ -286,6 +306,8 @@ function validateEffects(effects: unknown, error: Report, warn: Report): void {
286
306
  }
287
307
  }
288
308
 
309
+ const SCRIPT_EXTENSIONS = [".ts", ".js"] as const;
310
+
289
311
  function hasExtension(path: string, extensions: readonly string[]): boolean {
290
312
  const lower = path.toLowerCase();
291
313
  return extensions.some((ext) => lower.endsWith(ext));
@@ -307,6 +329,8 @@ function checkField(spec: FieldSpec, value: unknown): string | undefined {
307
329
  : `must be one of ${spec.values.map((v) => `"${v}"`).join(", ")}, got ${describe(value)}.`;
308
330
  case "frames":
309
331
  return typeof value === "string" && isFrameList(value) ? undefined : `must be a frame list like "0", "1-4" or "0,2-3", got ${describe(value)}.`;
332
+ case "object":
333
+ return isObject(value) ? undefined : `must be an object like { "speed": 80 }, got ${describe(value)}.`;
310
334
  }
311
335
  }
312
336
 
@@ -326,7 +350,7 @@ function describe(value: unknown): string {
326
350
  }
327
351
 
328
352
  /** Returns the candidate within a small edit distance of `input`, if any. */
329
- function closest(input: string, candidates: readonly string[]): string | undefined {
353
+ export function closestName(input: string, candidates: readonly string[]): string | undefined {
330
354
  let best: string | undefined;
331
355
  let bestDistance = Math.max(2, Math.floor(input.length / 3)) + 1;
332
356
  for (const candidate of candidates) {
package/src/world.ts CHANGED
@@ -8,11 +8,23 @@ import {
8
8
  type Hazard,
9
9
  type Patrol,
10
10
  type Platformer,
11
+ type ScriptComponent,
11
12
  type Sprite,
12
13
  type Transform,
13
14
  } from "./components.ts";
15
+ import {
16
+ getScript,
17
+ ScriptError,
18
+ type Script,
19
+ type ScriptContext,
20
+ type ScriptEntity,
21
+ type ScriptInput,
22
+ type ScriptModules,
23
+ type ScriptWorld,
24
+ type SpawnDef,
25
+ } from "./script.ts";
14
26
  import { FIXED_DT } from "./time.ts";
15
- import type { Action, Diagnostic, EntityDef, SceneDef } from "./types.ts";
27
+ import { EVENT_TYPES, type Action, type Diagnostic, type EntityDef, type SceneDef } from "./types.ts";
16
28
  import { validateScene } from "./validate.ts";
17
29
 
18
30
  export interface BodyState extends Body {
@@ -23,6 +35,8 @@ export interface BodyState extends Body {
23
35
 
24
36
  export interface PlatformerState extends Platformer {
25
37
  coyoteTimer: number;
38
+ /** True from a player-initiated jump until the apex, so only those jumps are cut short on release. */
39
+ jumping: boolean;
26
40
  bufferTimer: number;
27
41
  spawnX: number;
28
42
  spawnY: number;
@@ -33,8 +47,20 @@ 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;
@@ -55,6 +81,8 @@ export type GameEvent = { frame: number; time: number; x: number; y: number } &
55
81
  | { type: "die"; entity: string; cause: string }
56
82
  | { type: "stomp"; entity: string; enemy: string; score: number }
57
83
  | { type: "win"; entity: string; goal: string }
84
+ /** Fired by a script with world.emit(name, data). */
85
+ | { type: "custom"; name: string; entity: string; data?: unknown }
58
86
  );
59
87
 
60
88
  type EventPayload = GameEvent extends infer E ? (E extends GameEvent ? Omit<E, "frame" | "time"> : never) : never;
@@ -65,6 +93,13 @@ export interface EntitySnapshot {
65
93
  vx: number;
66
94
  vy: number;
67
95
  grounded: boolean;
96
+ /** Present when a script stored data on the entity. */
97
+ state?: Record<string, unknown>;
98
+ }
99
+
100
+ export interface WorldOptions {
101
+ /** Loaded script modules, keyed by the `src` path used in the scene. */
102
+ scripts?: ScriptModules;
68
103
  }
69
104
 
70
105
  export interface WorldSnapshot {
@@ -96,6 +131,8 @@ const STOMP_BOUNCE = 0.6;
96
131
  const STOMP_TOLERANCE = 8;
97
132
  /** A platformer this far below the bottom of the world has fallen out of it. */
98
133
  const FALL_MARGIN = 100;
134
+ /** Bodies this close count as touching for `touch` hooks, so solids (doors, walls) can be touched too. */
135
+ const TOUCH_MARGIN = 1;
99
136
  const EPSILON = 1e-4;
100
137
 
101
138
  /**
@@ -116,20 +153,30 @@ export class World {
116
153
  /** Set when a player reaches a goal. The simulation stops changing after that. */
117
154
  won = false;
118
155
  private previousInput: ReadonlySet<Action> = new Set();
119
-
120
- constructor(scene: SceneDef) {
156
+ private readonly scripts: ScriptModules | undefined;
157
+ private seed: number;
158
+ private spawnCount = 0;
159
+ private readonly handles = new WeakMap<Entity, ScriptEntity>();
160
+ private scriptApi: ScriptWorld | undefined;
161
+ private scriptInput: ScriptInput = { held: () => false, pressed: () => false };
162
+ /** The script hook currently running, for error messages and event attribution. */
163
+ private current: { entity: Entity; hook: string } | undefined;
164
+
165
+ constructor(scene: SceneDef, options: WorldOptions = {}) {
121
166
  this.width = scene.world?.width ?? 960;
122
167
  this.height = scene.world?.height ?? 540;
123
168
  this.gravity = scene.world?.gravity ?? DEFAULT_GRAVITY;
124
169
  this.background = scene.world?.background ?? "#1d2333";
125
- this.entities = scene.entities.map(createEntity);
170
+ this.seed = scene.world?.seed ?? 1;
171
+ this.scripts = options.scripts;
172
+ this.entities = scene.entities.map((def) => this.createEntity(def));
126
173
  }
127
174
 
128
175
  /** Validates raw scene JSON and builds a world, throwing on errors. */
129
- static fromJSON(raw: unknown): World {
176
+ static fromJSON(raw: unknown, options: WorldOptions = {}): World {
130
177
  const { scene, diagnostics } = validateScene(raw);
131
178
  if (!scene) throw new SceneValidationError(diagnostics);
132
- return new World(scene);
179
+ return new World(scene, options);
133
180
  }
134
181
 
135
182
  get time(): number {
@@ -148,8 +195,12 @@ export class World {
148
195
  if (this.won) return;
149
196
 
150
197
  this.updatePlatformers(input, pressed);
198
+ this.updateScripts(input, pressed);
199
+ if (this.won) return;
151
200
  this.updatePatrols();
152
201
  this.updateBodies();
202
+ this.updateTouches();
203
+ if (this.won) return;
153
204
  this.updateCollectibles();
154
205
  this.updateHazards();
155
206
  this.updateGoals();
@@ -165,6 +216,8 @@ export class World {
165
216
  vy: e.body?.vy ?? 0,
166
217
  grounded: e.body?.grounded ?? false,
167
218
  };
219
+ // A JSON copy, so later frames can't change an earlier snapshot.
220
+ if (Object.keys(e.state).length > 0) entities[e.id]!.state = JSON.parse(JSON.stringify(e.state));
168
221
  }
169
222
  return { frame: this.frame, time: this.time, score: this.score, deaths: this.deaths, won: this.won, entities };
170
223
  }
@@ -174,9 +227,241 @@ export class World {
174
227
  }
175
228
 
176
229
  private remove(entity: Entity): void {
177
- this.entities.splice(this.entities.indexOf(entity), 1);
230
+ const index = this.entities.indexOf(entity);
231
+ if (index >= 0) this.entities.splice(index, 1);
232
+ entity.removed = true;
233
+ }
234
+
235
+ // ---------------------------------------------------------------- scripts
236
+
237
+ private createEntity(def: EntityDef): Entity {
238
+ const entity = createEntity(def);
239
+ const component = def.components.script;
240
+ if (component) {
241
+ const resolved = resolveComponent<ScriptComponent>("script", component);
242
+ const module = getScript(this.scripts, resolved.src);
243
+ if (!module) {
244
+ throw new ScriptError(
245
+ { src: resolved.src, entity: def.id, hook: "load", frame: this.frame },
246
+ "this script is not loaded. Hosts pass loaded modules as new World(scene, { scripts }); the CLI and browser runtime do this for you.",
247
+ );
248
+ }
249
+ entity.script = { ...resolved, params: { ...resolved.params }, module, started: false, touching: new Set() };
250
+ }
251
+ return entity;
252
+ }
253
+
254
+ private updateScripts(held: ReadonlySet<Action>, pressed: ReadonlySet<Action>): void {
255
+ this.scriptInput = { held: (action) => held.has(action), pressed: (action) => pressed.has(action) };
256
+ // Iterate over a copy: scripts may spawn or destroy entities.
257
+ for (const e of [...this.entities]) {
258
+ if (!e.script || e.removed) continue;
259
+ if (!e.script.started) {
260
+ e.script.started = true;
261
+ this.callHook(e, "start");
262
+ }
263
+ if (!e.removed && !this.won) this.callHook(e, "update");
264
+ if (this.won) return;
265
+ }
266
+ }
267
+
268
+ /** Calls `touch` on scripted entities that started overlapping or touching another body this frame. */
269
+ private updateTouches(): void {
270
+ for (const e of [...this.entities]) {
271
+ if (!e.script?.module.touch || !e.body || e.removed) continue;
272
+ const now = new Set<string>();
273
+ for (const other of this.entities) {
274
+ if (other !== e && other.body && overlaps(e, other, TOUCH_MARGIN)) now.add(other.id);
275
+ }
276
+ for (const id of now) {
277
+ const other = this.get(id);
278
+ if (other && !e.script.touching.has(id) && !e.removed && !this.won) this.callHook(e, "touch", this.handle(other));
279
+ }
280
+ e.script.touching = now;
281
+ }
282
+ }
283
+
284
+ private callHook(entity: Entity, hook: "start" | "update" | "touch", other?: ScriptEntity): void {
285
+ const script = entity.script!;
286
+ const fn = script.module[hook];
287
+ if (!fn) return;
288
+ const ctx: ScriptContext = { self: this.handle(entity), world: this.api(), input: this.scriptInput, params: script.params, dt: FIXED_DT };
289
+ this.current = { entity, hook };
290
+ try {
291
+ (fn as (ctx: ScriptContext, other?: ScriptEntity) => void).call(script.module, ctx, other);
292
+ } catch (err) {
293
+ if (err instanceof ScriptError) throw err;
294
+ const message = err instanceof Error ? err.message : String(err);
295
+ throw new ScriptError({ src: script.src, entity: entity.id, hook, frame: this.frame }, message, { cause: err });
296
+ } finally {
297
+ this.current = undefined;
298
+ }
299
+ }
300
+
301
+ /** An error caused by how a script used the API, reported against the running script. */
302
+ private misuse(message: string): ScriptError {
303
+ const running = this.current;
304
+ return new ScriptError(
305
+ { src: running?.entity.script?.src ?? "?", entity: running?.entity.id ?? "?", hook: running?.hook ?? "?", frame: this.frame },
306
+ message,
307
+ );
308
+ }
309
+
310
+ /** A stable script-facing view of an entity. */
311
+ private handle(e: Entity): ScriptEntity {
312
+ let handle = this.handles.get(e);
313
+ if (handle) return handle;
314
+ const world = this;
315
+ handle = {
316
+ get id() {
317
+ return e.id;
318
+ },
319
+ get x() {
320
+ return e.transform.x;
321
+ },
322
+ set x(value: number) {
323
+ e.transform.x = value;
324
+ },
325
+ get y() {
326
+ return e.transform.y;
327
+ },
328
+ set y(value: number) {
329
+ e.transform.y = value;
330
+ },
331
+ get vx() {
332
+ return e.body?.vx ?? 0;
333
+ },
334
+ set vx(value: number) {
335
+ if (e.body) e.body.vx = value;
336
+ },
337
+ get vy() {
338
+ return e.body?.vy ?? 0;
339
+ },
340
+ set vy(value: number) {
341
+ if (e.body) e.body.vy = value;
342
+ },
343
+ get grounded() {
344
+ return e.body?.grounded ?? false;
345
+ },
346
+ get width() {
347
+ return e.body?.width ?? e.sprite?.width ?? 0;
348
+ },
349
+ get height() {
350
+ return e.body?.height ?? e.sprite?.height ?? 0;
351
+ },
352
+ get exists() {
353
+ return !e.removed;
354
+ },
355
+ get state() {
356
+ return e.state;
357
+ },
358
+ has(component: string) {
359
+ return component === "transform" || (component in e && (e as unknown as Record<string, unknown>)[component] !== undefined);
360
+ },
361
+ touches(other: ScriptEntity | string) {
362
+ const target = world.get(typeof other === "string" ? other : other.id);
363
+ return !!target && target !== e && !e.removed && overlaps(e, target, TOUCH_MARGIN);
364
+ },
365
+ };
366
+ this.handles.set(e, handle);
367
+ return handle;
368
+ }
369
+
370
+ private resolve(target: ScriptEntity | string): Entity | undefined {
371
+ return this.get(typeof target === "string" ? target : target.id);
372
+ }
373
+
374
+ /** The `world` object scripts receive. */
375
+ private api(): ScriptWorld {
376
+ if (this.scriptApi) return this.scriptApi;
377
+ const world = this;
378
+ this.scriptApi = {
379
+ get time() {
380
+ return world.time;
381
+ },
382
+ get frame() {
383
+ return world.frame;
384
+ },
385
+ get width() {
386
+ return world.width;
387
+ },
388
+ get height() {
389
+ return world.height;
390
+ },
391
+ get score() {
392
+ return world.score;
393
+ },
394
+ set score(value: number) {
395
+ world.score = value;
396
+ },
397
+ get: (id) => {
398
+ const e = world.get(id);
399
+ return e ? world.handle(e) : undefined;
400
+ },
401
+ all: (component) =>
402
+ world.entities.filter((e) => !component || world.handle(e).has(component)).map((e) => world.handle(e)),
403
+ player: () => {
404
+ const e = world.entities.find((candidate) => candidate.platformer);
405
+ return e ? world.handle(e) : undefined;
406
+ },
407
+ spawn: (def) => world.spawn(def),
408
+ destroy: (target) => {
409
+ const e = world.resolve(target);
410
+ if (e) world.remove(e);
411
+ },
412
+ kill: (target) => {
413
+ const e = world.resolve(target);
414
+ if (!e) return;
415
+ if (e.platformer && e.body) world.kill(e, world.current?.entity.id ?? "script");
416
+ else world.remove(e);
417
+ },
418
+ win: () => {
419
+ if (world.won) return;
420
+ const by = world.current?.entity;
421
+ world.won = true;
422
+ world.emit({ type: "win", entity: by?.id ?? "script", goal: by?.id ?? "script", x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
423
+ },
424
+ emit: (name, data) => {
425
+ if (typeof name !== "string" || name === "") throw world.misuse("world.emit needs a non-empty event name.");
426
+ if ((EVENT_TYPES as readonly string[]).includes(name)) {
427
+ throw world.misuse(`"${name}" is a built-in event and can't be emitted by scripts. Pick another name, e.g. "${name}-custom".`);
428
+ }
429
+ const by = world.current?.entity;
430
+ world.emit({ type: "custom", name, entity: by?.id ?? "script", data, x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
431
+ },
432
+ random: () => world.random(),
433
+ };
434
+ return this.scriptApi;
435
+ }
436
+
437
+ private spawn(def: SpawnDef): ScriptEntity {
438
+ if (typeof def !== "object" || def === null || typeof def.components !== "object") {
439
+ throw this.misuse('world.spawn needs { components: { ... } }, e.g. { components: { transform: { x: 0, y: 0 } } }.');
440
+ }
441
+ const id = def.id ?? `${this.current?.entity.id ?? "spawned"}-${++this.spawnCount}`;
442
+ if (this.get(id)) throw this.misuse(`world.spawn: an entity with id "${id}" already exists. Leave id out to get a unique one.`);
443
+ const { diagnostics } = validateScene({ entities: [{ id, components: def.components }] });
444
+ const errors = diagnostics.filter((d) => d.severity === "error" && d.code !== "E_GOAL_UNREACHABLE");
445
+ if (errors.length > 0) {
446
+ const details = errors.map((d) => `${d.path.replace("entities[0]", id)}: ${d.message}${d.fix ? ` Fix: ${d.fix}` : ""}`);
447
+ throw this.misuse(`world.spawn got an invalid entity. ${details.join(" ")}`);
448
+ }
449
+ const entity = this.createEntity({ id, components: def.components });
450
+ this.entities.push(entity);
451
+ return this.handle(entity);
178
452
  }
179
453
 
454
+ /** mulberry32, seeded by world.seed. */
455
+ private random(): number {
456
+ this.seed = (this.seed + 0x6d2b79f5) | 0;
457
+ let t = this.seed;
458
+ t = Math.imul(t ^ (t >>> 15), t | 1);
459
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
460
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
461
+ }
462
+
463
+ // ------------------------------------------------------------ built-ins
464
+
180
465
  private players(): Entity[] {
181
466
  return this.entities.filter((e) => e.platformer && e.body);
182
467
  }
@@ -199,10 +484,13 @@ export class World {
199
484
  b.standingOn = undefined;
200
485
  p.bufferTimer = 0;
201
486
  p.coyoteTimer = 0;
487
+ p.jumping = true;
202
488
  this.emit({ type: "jump", entity: e.id, ...feet(e) });
203
- } else if (p.variableJump && !held.has("jump") && b.vy < -p.jumpSpeed * JUMP_CUT) {
489
+ } else if (p.jumping && p.variableJump && !held.has("jump") && b.vy < -p.jumpSpeed * JUMP_CUT) {
490
+ // Only the player's own jumps are cut short; launches from pads, stomps or scripts are not.
204
491
  b.vy = -p.jumpSpeed * JUMP_CUT;
205
492
  }
493
+ if (b.vy >= 0) p.jumping = false;
206
494
 
207
495
  p.coyoteTimer = Math.max(0, p.coyoteTimer - FIXED_DT);
208
496
  p.bufferTimer = Math.max(0, p.bufferTimer - FIXED_DT);
@@ -342,13 +630,14 @@ export class World {
342
630
  b.standingOn = undefined;
343
631
  p.coyoteTimer = 0;
344
632
  p.bufferTimer = 0;
633
+ p.jumping = false;
345
634
  }
346
635
  }
347
636
 
348
637
  function createEntity(def: EntityDef): Entity {
349
638
  const c = def.components;
350
639
  const transform = c.transform ? resolveComponent<Transform>("transform", c.transform) : { x: 0, y: 0 };
351
- const entity: Entity = { id: def.id, transform };
640
+ const entity: Entity = { id: def.id, state: {}, transform };
352
641
  if (c.body) entity.body = { ...resolveComponent<Body>("body", c.body), grounded: false };
353
642
  if (c.sprite) entity.sprite = resolveComponent<Sprite>("sprite", c.sprite);
354
643
  if (c.platformer) {
@@ -356,6 +645,7 @@ function createEntity(def: EntityDef): Entity {
356
645
  ...resolveComponent<Platformer>("platformer", c.platformer),
357
646
  coyoteTimer: 0,
358
647
  bufferTimer: 0,
648
+ jumping: false,
359
649
  spawnX: transform.x,
360
650
  spawnY: transform.y,
361
651
  };
@@ -387,11 +677,14 @@ function isStomp(player: Entity, enemy: Entity): boolean {
387
677
  return previousBottom <= enemyTop + STOMP_TOLERANCE;
388
678
  }
389
679
 
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 {
680
+ /**
681
+ * Strict overlap: bodies that merely touch (e.g. standing on the ground) do not overlap.
682
+ * A positive `margin` grows both boxes, so touching counts too.
683
+ */
684
+ export function overlaps(a: Entity, b: Entity, margin = 0): boolean {
392
685
  if (!a.body || !b.body) return false;
393
686
  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
687
+ Math.abs(a.transform.x - b.transform.x) < (a.body.width + b.body.width) / 2 + margin - EPSILON &&
688
+ Math.abs(a.transform.y - b.transform.y) < (a.body.height + b.body.height) / 2 + margin - EPSILON
396
689
  );
397
690
  }