@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/dist/world.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { resolveComponent, } from "./components.js";
2
+ import { getScript, ScriptError, } from "./script.js";
2
3
  import { FIXED_DT } from "./time.js";
4
+ import { EVENT_TYPES } from "./types.js";
3
5
  import { validateScene } from "./validate.js";
4
6
  export class SceneValidationError extends Error {
5
7
  diagnostics;
@@ -19,6 +21,10 @@ const STOMP_BOUNCE = 0.6;
19
21
  const STOMP_TOLERANCE = 8;
20
22
  /** A platformer this far below the bottom of the world has fallen out of it. */
21
23
  const FALL_MARGIN = 100;
24
+ /** Bodies this close count as touching for `touch` hooks, so solids (doors, walls) can be touched too. */
25
+ const TOUCH_MARGIN = 1;
26
+ /** Impacts slower than this (px/s, after bounce) stop instead of bouncing. */
27
+ const MIN_BOUNCE_SPEED = 40;
22
28
  const EPSILON = 1e-4;
23
29
  /**
24
30
  * The simulation. It advances only through `step`, one fixed frame at a time,
@@ -38,19 +44,31 @@ export class World {
38
44
  /** Set when a player reaches a goal. The simulation stops changing after that. */
39
45
  won = false;
40
46
  previousInput = new Set();
41
- constructor(scene) {
47
+ scripts;
48
+ seed;
49
+ spawnCount = 0;
50
+ handles = new WeakMap();
51
+ scriptApi;
52
+ scriptInput = { held: () => false, pressed: () => false };
53
+ /** The script hook currently running, for error messages and event attribution. */
54
+ current;
55
+ /** True while a draw hook runs: the game can't be changed then. */
56
+ drawing = false;
57
+ constructor(scene, options = {}) {
42
58
  this.width = scene.world?.width ?? 960;
43
59
  this.height = scene.world?.height ?? 540;
44
60
  this.gravity = scene.world?.gravity ?? DEFAULT_GRAVITY;
45
61
  this.background = scene.world?.background ?? "#1d2333";
46
- this.entities = scene.entities.map(createEntity);
62
+ this.seed = scene.world?.seed ?? 1;
63
+ this.scripts = options.scripts;
64
+ this.entities = scene.entities.map((def) => this.createEntity(def));
47
65
  }
48
66
  /** Validates raw scene JSON and builds a world, throwing on errors. */
49
- static fromJSON(raw) {
67
+ static fromJSON(raw, options = {}) {
50
68
  const { scene, diagnostics } = validateScene(raw);
51
69
  if (!scene)
52
70
  throw new SceneValidationError(diagnostics);
53
- return new World(scene);
71
+ return new World(scene, options);
54
72
  }
55
73
  get time() {
56
74
  return this.frame * FIXED_DT;
@@ -66,8 +84,15 @@ export class World {
66
84
  if (this.won)
67
85
  return;
68
86
  this.updatePlatformers(input, pressed);
87
+ this.updateTopdown(input);
88
+ this.updateScripts(input, pressed);
89
+ if (this.won)
90
+ return;
69
91
  this.updatePatrols();
70
92
  this.updateBodies();
93
+ this.updateTouches();
94
+ if (this.won)
95
+ return;
71
96
  this.updateCollectibles();
72
97
  this.updateHazards();
73
98
  this.updateGoals();
@@ -82,6 +107,9 @@ export class World {
82
107
  vy: e.body?.vy ?? 0,
83
108
  grounded: e.body?.grounded ?? false,
84
109
  };
110
+ // A JSON copy, so later frames can't change an earlier snapshot.
111
+ if (Object.keys(e.state).length > 0)
112
+ entities[e.id].state = JSON.parse(JSON.stringify(e.state));
85
113
  }
86
114
  return { frame: this.frame, time: this.time, score: this.score, deaths: this.deaths, won: this.won, entities };
87
115
  }
@@ -89,15 +117,327 @@ export class World {
89
117
  this.events.push({ ...event, frame: this.frame, time: this.time });
90
118
  }
91
119
  remove(entity) {
92
- this.entities.splice(this.entities.indexOf(entity), 1);
120
+ const index = this.entities.indexOf(entity);
121
+ if (index >= 0)
122
+ this.entities.splice(index, 1);
123
+ entity.removed = true;
124
+ }
125
+ // ---------------------------------------------------------------- scripts
126
+ createEntity(def) {
127
+ const entity = createEntity(def);
128
+ const component = def.components.script;
129
+ if (component) {
130
+ const resolved = resolveComponent("script", component);
131
+ const module = getScript(this.scripts, resolved.src);
132
+ if (!module) {
133
+ throw new ScriptError({ src: resolved.src, entity: def.id, hook: "load", frame: this.frame }, "this script is not loaded. Hosts pass loaded modules as new World(scene, { scripts }); the CLI and browser runtime do this for you.");
134
+ }
135
+ entity.script = { ...resolved, params: { ...resolved.params }, module, started: false, touching: new Set() };
136
+ }
137
+ return entity;
138
+ }
139
+ updateScripts(held, pressed) {
140
+ this.scriptInput = { held: (action) => held.has(action), pressed: (action) => pressed.has(action) };
141
+ // Iterate over a copy: scripts may spawn or destroy entities.
142
+ for (const e of [...this.entities]) {
143
+ if (!e.script || e.removed)
144
+ continue;
145
+ if (!e.script.started) {
146
+ e.script.started = true;
147
+ this.callHook(e, "start");
148
+ }
149
+ if (!e.removed && !this.won)
150
+ this.callHook(e, "update");
151
+ if (this.won)
152
+ return;
153
+ }
154
+ }
155
+ /** Calls `touch` on scripted entities that started overlapping or touching another body this frame. */
156
+ updateTouches() {
157
+ for (const e of [...this.entities]) {
158
+ if (!e.script?.module.touch || !e.body || e.removed)
159
+ continue;
160
+ const now = new Set();
161
+ for (const other of this.entities) {
162
+ if (other !== e && other.body && overlaps(e, other, TOUCH_MARGIN))
163
+ now.add(other.id);
164
+ }
165
+ for (const id of now) {
166
+ const other = this.get(id);
167
+ if (other && !e.script.touching.has(id) && !e.removed && !this.won)
168
+ this.callHook(e, "touch", this.handle(other));
169
+ }
170
+ e.script.touching = now;
171
+ }
172
+ }
173
+ callHook(entity, hook, other) {
174
+ const script = entity.script;
175
+ const fn = script.module[hook];
176
+ if (!fn)
177
+ return;
178
+ const ctx = { self: this.handle(entity), world: this.api(), input: this.scriptInput, params: script.params, dt: FIXED_DT };
179
+ this.run(entity, hook, () => fn.call(script.module, ctx, other));
180
+ }
181
+ /**
182
+ * Runs an entity's `draw` or `drawUI` hook. Called by the renderer, never by the
183
+ * simulation. `self` and `world` are read-only for the duration.
184
+ */
185
+ callDrawHook(entity, hook, extras) {
186
+ const script = entity.script;
187
+ const fn = script?.module[hook];
188
+ if (!script || !fn || entity.removed)
189
+ return;
190
+ const ctx = { ...extras, self: this.handle(entity), world: this.api(), params: script.params };
191
+ this.drawing = true;
192
+ try {
193
+ this.run(entity, hook, () => fn.call(script.module, ctx));
194
+ }
195
+ finally {
196
+ this.drawing = false;
197
+ }
198
+ }
199
+ /** True when any live entity's script has the given hook. */
200
+ hasHook(hook) {
201
+ return this.entities.some((e) => !e.removed && typeof e.script?.module[hook] === "function");
202
+ }
203
+ run(entity, hook, call) {
204
+ const script = entity.script;
205
+ this.current = { entity, hook };
206
+ try {
207
+ call();
208
+ }
209
+ catch (err) {
210
+ if (err instanceof ScriptError)
211
+ throw err;
212
+ const message = err instanceof Error ? err.message : String(err);
213
+ throw new ScriptError({ src: script.src, entity: entity.id, hook, frame: this.frame }, message, { cause: err });
214
+ }
215
+ finally {
216
+ this.current = undefined;
217
+ }
218
+ }
219
+ /** Draw hooks only paint, so the browser can't drift from what tests verified. */
220
+ guard(what) {
221
+ if (this.drawing) {
222
+ 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.`);
223
+ }
93
224
  }
225
+ /** An error caused by how a script used the API, reported against the running script. */
226
+ misuse(message) {
227
+ const running = this.current;
228
+ return new ScriptError({ src: running?.entity.script?.src ?? "?", entity: running?.entity.id ?? "?", hook: running?.hook ?? "?", frame: this.frame }, message);
229
+ }
230
+ /** A stable script-facing view of an entity. */
231
+ handle(e) {
232
+ let handle = this.handles.get(e);
233
+ if (handle)
234
+ return handle;
235
+ const world = this;
236
+ handle = {
237
+ get id() {
238
+ return e.id;
239
+ },
240
+ get x() {
241
+ return e.transform.x;
242
+ },
243
+ set x(value) {
244
+ world.guard("Setting x");
245
+ e.transform.x = value;
246
+ },
247
+ get y() {
248
+ return e.transform.y;
249
+ },
250
+ set y(value) {
251
+ world.guard("Setting y");
252
+ e.transform.y = value;
253
+ },
254
+ get vx() {
255
+ return e.body?.vx ?? 0;
256
+ },
257
+ set vx(value) {
258
+ world.guard("Setting vx");
259
+ if (e.body)
260
+ e.body.vx = value;
261
+ },
262
+ get vy() {
263
+ return e.body?.vy ?? 0;
264
+ },
265
+ set vy(value) {
266
+ world.guard("Setting vy");
267
+ if (e.body)
268
+ e.body.vy = value;
269
+ },
270
+ get grounded() {
271
+ return e.body?.grounded ?? false;
272
+ },
273
+ get width() {
274
+ return e.body?.width ?? e.sprite?.width ?? 0;
275
+ },
276
+ get height() {
277
+ return e.body?.height ?? e.sprite?.height ?? 0;
278
+ },
279
+ get exists() {
280
+ return !e.removed;
281
+ },
282
+ get state() {
283
+ // A frozen copy while drawing, so a draw hook can't write game state by accident.
284
+ return world.drawing ? Object.freeze({ ...e.state }) : e.state;
285
+ },
286
+ has(component) {
287
+ return component === "transform" || (component in e && e[component] !== undefined);
288
+ },
289
+ touches(other) {
290
+ const target = world.get(typeof other === "string" ? other : other.id);
291
+ return !!target && target !== e && !e.removed && overlaps(e, target, TOUCH_MARGIN);
292
+ },
293
+ };
294
+ this.handles.set(e, handle);
295
+ return handle;
296
+ }
297
+ resolve(target) {
298
+ return this.get(typeof target === "string" ? target : target.id);
299
+ }
300
+ /** The `world` object scripts receive. */
301
+ api() {
302
+ if (this.scriptApi)
303
+ return this.scriptApi;
304
+ const world = this;
305
+ this.scriptApi = {
306
+ get time() {
307
+ return world.time;
308
+ },
309
+ get frame() {
310
+ return world.frame;
311
+ },
312
+ get width() {
313
+ return world.width;
314
+ },
315
+ get height() {
316
+ return world.height;
317
+ },
318
+ get score() {
319
+ return world.score;
320
+ },
321
+ get deaths() {
322
+ return world.deaths;
323
+ },
324
+ get won() {
325
+ return world.won;
326
+ },
327
+ set score(value) {
328
+ world.guard("Setting score");
329
+ world.score = value;
330
+ },
331
+ get: (id) => {
332
+ const e = world.get(id);
333
+ return e ? world.handle(e) : undefined;
334
+ },
335
+ all: (component) => world.entities.filter((e) => !component || world.handle(e).has(component)).map((e) => world.handle(e)),
336
+ player: () => {
337
+ const e = world.entities.find(isPlayer);
338
+ return e ? world.handle(e) : undefined;
339
+ },
340
+ spawn: (def) => {
341
+ world.guard("world.spawn");
342
+ return world.spawn(def);
343
+ },
344
+ destroy: (target) => {
345
+ world.guard("world.destroy");
346
+ const e = world.resolve(target);
347
+ if (e)
348
+ world.remove(e);
349
+ },
350
+ kill: (target) => {
351
+ world.guard("world.kill");
352
+ const e = world.resolve(target);
353
+ if (!e)
354
+ return;
355
+ if (isPlayer(e))
356
+ world.kill(e, world.current?.entity.id ?? "script");
357
+ else
358
+ world.remove(e);
359
+ },
360
+ win: () => {
361
+ world.guard("world.win");
362
+ if (world.won)
363
+ return;
364
+ const by = world.current?.entity;
365
+ world.won = true;
366
+ world.emit({ type: "win", entity: by?.id ?? "script", goal: by?.id ?? "script", x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
367
+ },
368
+ emit: (name, data) => {
369
+ world.guard("world.emit");
370
+ if (typeof name !== "string" || name === "")
371
+ throw world.misuse("world.emit needs a non-empty event name.");
372
+ if (EVENT_TYPES.includes(name)) {
373
+ throw world.misuse(`"${name}" is a built-in event and can't be emitted by scripts. Pick another name, e.g. "${name}-custom".`);
374
+ }
375
+ const by = world.current?.entity;
376
+ world.emit({ type: "custom", name, entity: by?.id ?? "script", data, x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
377
+ },
378
+ random: () => {
379
+ world.guard("world.random (it advances the game's seed; use ctx.random in draw hooks)");
380
+ return world.random();
381
+ },
382
+ };
383
+ return this.scriptApi;
384
+ }
385
+ spawn(def) {
386
+ if (typeof def !== "object" || def === null || typeof def.components !== "object") {
387
+ throw this.misuse('world.spawn needs { components: { ... } }, e.g. { components: { transform: { x: 0, y: 0 } } }.');
388
+ }
389
+ const id = def.id ?? `${this.current?.entity.id ?? "spawned"}-${++this.spawnCount}`;
390
+ if (this.get(id))
391
+ throw this.misuse(`world.spawn: an entity with id "${id}" already exists. Leave id out to get a unique one.`);
392
+ const { diagnostics } = validateScene({ entities: [{ id, components: def.components }] });
393
+ const errors = diagnostics.filter((d) => d.severity === "error" && d.code !== "E_GOAL_UNREACHABLE");
394
+ if (errors.length > 0) {
395
+ const details = errors.map((d) => `${d.path.replace("entities[0]", id)}: ${d.message}${d.fix ? ` Fix: ${d.fix}` : ""}`);
396
+ throw this.misuse(`world.spawn got an invalid entity. ${details.join(" ")}`);
397
+ }
398
+ const entity = this.createEntity({ id, components: def.components });
399
+ this.entities.push(entity);
400
+ return this.handle(entity);
401
+ }
402
+ /** mulberry32, seeded by world.seed. */
403
+ random() {
404
+ this.seed = (this.seed + 0x6d2b79f5) | 0;
405
+ let t = this.seed;
406
+ t = Math.imul(t ^ (t >>> 15), t | 1);
407
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
408
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
409
+ }
410
+ // ------------------------------------------------------------ built-ins
411
+ /** Entities the player controls: platformers and topdown movers. */
94
412
  players() {
95
- return this.entities.filter((e) => e.platformer && e.body);
413
+ return this.entities.filter(isPlayer);
414
+ }
415
+ updateTopdown(held) {
416
+ for (const e of this.entities) {
417
+ const t = e.topdown;
418
+ const b = e.body;
419
+ if (!t || !b)
420
+ continue;
421
+ let dx = (held.has("right") ? 1 : 0) - (held.has("left") ? 1 : 0);
422
+ let dy = (held.has("down") ? 1 : 0) - (held.has("up") ? 1 : 0);
423
+ if (t.directions === "x")
424
+ dy = 0;
425
+ if (t.directions === "y")
426
+ dx = 0;
427
+ if (t.directions === "4" && dx !== 0)
428
+ dy = 0;
429
+ // Diagonals move at the same speed as straight lines.
430
+ const length = Math.hypot(dx, dy) || 1;
431
+ b.vx = (dx / length) * t.speed;
432
+ b.vy = (dy / length) * t.speed;
433
+ }
96
434
  }
97
435
  updatePlatformers(held, pressed) {
98
- for (const e of this.players()) {
436
+ for (const e of this.entities) {
99
437
  const p = e.platformer;
100
438
  const b = e.body;
439
+ if (!p || !b)
440
+ continue;
101
441
  const direction = (held.has("right") ? 1 : 0) - (held.has("left") ? 1 : 0);
102
442
  b.vx = direction * p.speed;
103
443
  // Timers get a tiny extra margin so a value of 0 still allows a jump on the exact frame.
@@ -111,11 +451,15 @@ export class World {
111
451
  b.standingOn = undefined;
112
452
  p.bufferTimer = 0;
113
453
  p.coyoteTimer = 0;
454
+ p.jumping = true;
114
455
  this.emit({ type: "jump", entity: e.id, ...feet(e) });
115
456
  }
116
- else if (p.variableJump && !held.has("jump") && b.vy < -p.jumpSpeed * JUMP_CUT) {
457
+ else if (p.jumping && p.variableJump && !held.has("jump") && b.vy < -p.jumpSpeed * JUMP_CUT) {
458
+ // Only the player's own jumps are cut short; launches from pads, stomps or scripts are not.
117
459
  b.vy = -p.jumpSpeed * JUMP_CUT;
118
460
  }
461
+ if (b.vy >= 0)
462
+ p.jumping = false;
119
463
  p.coyoteTimer = Math.max(0, p.coyoteTimer - FIXED_DT);
120
464
  p.bufferTimer = Math.max(0, p.bufferTimer - FIXED_DT);
121
465
  }
@@ -142,15 +486,27 @@ export class World {
142
486
  updateBodies() {
143
487
  // Kinematic bodies move first, so anything standing on them can follow.
144
488
  const moved = new Map();
489
+ const walls = this.entities.filter((e) => e.body?.type === "static" && !e.body.sensor);
145
490
  for (const e of this.entities) {
146
491
  const b = e.body;
147
492
  if (!b || b.type !== "kinematic")
148
493
  continue;
149
494
  const { x, y } = e.transform;
150
495
  e.transform.x += b.vx * FIXED_DT;
496
+ // A player-steered kinematic body (a paddle) stops at walls; scripted movers pass through.
497
+ if (e.topdown)
498
+ for (const wall of walls)
499
+ if (overlaps(e, wall))
500
+ e.transform.x = wall.transform.x + (Math.sign(e.transform.x - wall.transform.x) * (wall.body.width + b.width)) / 2;
151
501
  e.transform.y += b.vy * FIXED_DT;
502
+ if (e.topdown)
503
+ for (const wall of walls)
504
+ if (overlaps(e, wall))
505
+ e.transform.y = wall.transform.y + (Math.sign(e.transform.y - wall.transform.y) * (wall.body.height + b.height)) / 2;
152
506
  if (e.patrol)
153
507
  clampToPatrol(e, e.patrol);
508
+ if (e.topdown?.clampToWorld)
509
+ this.clampToWorld(e);
154
510
  moved.set(e, { dx: e.transform.x - x, dy: e.transform.y - y });
155
511
  }
156
512
  const solids = this.entities.filter((e) => e.body && !e.body.sensor && e.body.type !== "dynamic");
@@ -163,7 +519,9 @@ export class World {
163
519
  e.transform.x += carried.dx;
164
520
  e.transform.y += carried.dy;
165
521
  }
166
- b.vy = Math.min(b.vy + this.gravity * b.gravityScale * FIXED_DT, MAX_FALL_SPEED);
522
+ // Top-down movers ignore gravity; everything else falls.
523
+ if (!e.topdown)
524
+ b.vy = Math.min(b.vy + this.gravity * b.gravityScale * FIXED_DT, MAX_FALL_SPEED);
167
525
  const wasGrounded = b.grounded;
168
526
  b.grounded = false;
169
527
  b.standingOn = undefined;
@@ -177,27 +535,48 @@ export class World {
177
535
  e.transform.x = solid.transform.x - half;
178
536
  else if (b.vx < 0)
179
537
  e.transform.x = solid.transform.x + half;
180
- b.vx = 0;
538
+ b.vx = this.rebound(e, solid, b.vx);
181
539
  }
182
540
  e.transform.y += b.vy * FIXED_DT;
183
541
  for (const solid of solids) {
184
542
  if (!overlaps(e, solid))
185
543
  continue;
186
544
  const half = (solid.body.height + b.height) / 2;
187
- if (b.vy > 0) {
545
+ const falling = b.vy > 0;
546
+ if (b.vy > 0)
188
547
  e.transform.y = solid.transform.y - half;
548
+ else if (b.vy < 0)
549
+ e.transform.y = solid.transform.y + half;
550
+ b.vy = this.rebound(e, solid, b.vy);
551
+ // A body that stopped on top of something is standing on it; one that bounced off is airborne.
552
+ if (falling && b.vy === 0) {
189
553
  b.grounded = true;
190
554
  b.standingOn = solid;
191
555
  }
192
- else if (b.vy < 0) {
193
- e.transform.y = solid.transform.y + half;
194
- }
195
- b.vy = 0;
196
556
  }
557
+ if (e.topdown?.clampToWorld)
558
+ this.clampToWorld(e);
197
559
  if (b.grounded && !wasGrounded)
198
560
  this.emit({ type: "land", entity: e.id, ...feet(e) });
199
561
  }
200
562
  }
563
+ /**
564
+ * The velocity along one axis after hitting `solid`: 0, or reversed and scaled by `bounce`.
565
+ * Slow impacts stop instead, so resting bodies don't jitter.
566
+ */
567
+ rebound(e, solid, velocity) {
568
+ const bounce = e.body.bounce;
569
+ if (bounce <= 0 || Math.abs(velocity) * bounce < MIN_BOUNCE_SPEED)
570
+ return 0;
571
+ this.emit({ type: "bounce", entity: e.id, other: solid.id, ...e.transform });
572
+ return -velocity * bounce;
573
+ }
574
+ clampToWorld(e) {
575
+ const halfWidth = (e.body?.width ?? 0) / 2;
576
+ const halfHeight = (e.body?.height ?? 0) / 2;
577
+ e.transform.x = Math.min(this.width - halfWidth, Math.max(halfWidth, e.transform.x));
578
+ e.transform.y = Math.min(this.height - halfHeight, Math.max(halfHeight, e.transform.y));
579
+ }
201
580
  updateCollectibles() {
202
581
  const players = this.players();
203
582
  for (const item of this.entities.filter((e) => e.collectible && e.body)) {
@@ -219,7 +598,7 @@ export class World {
219
598
  for (const hazard of this.entities.filter((e) => e.hazard && e.body)) {
220
599
  if (!overlaps(player, hazard))
221
600
  continue;
222
- if (hazard.hazard.stompable && isStomp(player, hazard)) {
601
+ if (hazard.hazard.stompable && player.platformer && isStomp(player, hazard)) {
223
602
  this.remove(hazard);
224
603
  b.vy = -player.platformer.jumpSpeed * STOMP_BOUNCE;
225
604
  this.score += hazard.hazard.score;
@@ -246,23 +625,26 @@ export class World {
246
625
  /** Sends a player back to where it started. Collected items stay collected. */
247
626
  kill(player, cause) {
248
627
  const b = player.body;
249
- const p = player.platformer;
250
628
  this.deaths++;
251
629
  this.emit({ type: "die", entity: player.id, cause, ...player.transform });
252
- player.transform.x = p.spawnX;
253
- player.transform.y = p.spawnY;
630
+ player.transform.x = player.spawn?.x ?? player.transform.x;
631
+ player.transform.y = player.spawn?.y ?? player.transform.y;
254
632
  b.vx = 0;
255
633
  b.vy = 0;
256
634
  b.grounded = false;
257
635
  b.standingOn = undefined;
258
- p.coyoteTimer = 0;
259
- p.bufferTimer = 0;
636
+ const p = player.platformer;
637
+ if (p) {
638
+ p.coyoteTimer = 0;
639
+ p.bufferTimer = 0;
640
+ p.jumping = false;
641
+ }
260
642
  }
261
643
  }
262
644
  function createEntity(def) {
263
645
  const c = def.components;
264
646
  const transform = c.transform ? resolveComponent("transform", c.transform) : { x: 0, y: 0 };
265
- const entity = { id: def.id, transform };
647
+ const entity = { id: def.id, state: {}, transform };
266
648
  if (c.body)
267
649
  entity.body = { ...resolveComponent("body", c.body), grounded: false };
268
650
  if (c.sprite)
@@ -272,10 +654,13 @@ function createEntity(def) {
272
654
  ...resolveComponent("platformer", c.platformer),
273
655
  coyoteTimer: 0,
274
656
  bufferTimer: 0,
275
- spawnX: transform.x,
276
- spawnY: transform.y,
657
+ jumping: false,
277
658
  };
278
659
  }
660
+ if (c.topdown)
661
+ entity.topdown = resolveComponent("topdown", c.topdown);
662
+ if (entity.platformer || entity.topdown)
663
+ entity.spawn = { x: transform.x, y: transform.y };
279
664
  if (c.collectible)
280
665
  entity.collectible = resolveComponent("collectible", c.collectible);
281
666
  if (c.patrol)
@@ -290,6 +675,9 @@ function createEntity(def) {
290
675
  entity.camera = resolveComponent("camera", c.camera);
291
676
  return entity;
292
677
  }
678
+ function isPlayer(e) {
679
+ return !!(e.platformer || e.topdown) && !!e.body;
680
+ }
293
681
  function feet(e) {
294
682
  return { x: e.transform.x, y: e.transform.y + (e.body?.height ?? 0) / 2 };
295
683
  }
@@ -308,10 +696,13 @@ function isStomp(player, enemy) {
308
696
  const enemyTop = enemy.transform.y - enemy.body.height / 2;
309
697
  return previousBottom <= enemyTop + STOMP_TOLERANCE;
310
698
  }
311
- /** Strict overlap: bodies that merely touch (e.g. standing on the ground) do not overlap. */
312
- export function overlaps(a, b) {
699
+ /**
700
+ * Strict overlap: bodies that merely touch (e.g. standing on the ground) do not overlap.
701
+ * A positive `margin` grows both boxes, so touching counts too.
702
+ */
703
+ export function overlaps(a, b, margin = 0) {
313
704
  if (!a.body || !b.body)
314
705
  return false;
315
- return (Math.abs(a.transform.x - b.transform.x) < (a.body.width + b.body.width) / 2 - EPSILON &&
316
- Math.abs(a.transform.y - b.transform.y) < (a.body.height + b.body.height) / 2 - EPSILON);
706
+ return (Math.abs(a.transform.x - b.transform.x) < (a.body.width + b.body.width) / 2 + margin - EPSILON &&
707
+ Math.abs(a.transform.y - b.transform.y) < (a.body.height + b.body.height) / 2 + margin - EPSILON);
317
708
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ludra/core",
3
- "version": "0.4.0",
3
+ "version": "0.7.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);