@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/dist/validate.js CHANGED
@@ -32,6 +32,9 @@ export function validateScene(raw) {
32
32
  error("E_INVALID_WORLD", `world.${key}`, `world.${key} must be a non-negative number, got ${describe(value)}.`);
33
33
  }
34
34
  }
35
+ if (raw.world.seed !== undefined && (typeof raw.world.seed !== "number" || !Number.isFinite(raw.world.seed))) {
36
+ error("E_INVALID_WORLD", "world.seed", `world.seed must be a number, got ${describe(raw.world.seed)}.`);
37
+ }
35
38
  if (raw.world.background !== undefined && typeof raw.world.background !== "string") {
36
39
  error("E_INVALID_WORLD", "world.background", `world.background must be a CSS color string, got ${describe(raw.world.background)}.`);
37
40
  }
@@ -73,7 +76,7 @@ export function validateScene(raw) {
73
76
  for (const [name, value] of Object.entries(components)) {
74
77
  const componentPath = `${path}.components.${name}`;
75
78
  if (!isComponentName(name)) {
76
- const suggestion = closest(name, COMPONENT_NAMES);
79
+ const suggestion = closestName(name, COMPONENT_NAMES);
77
80
  error("E_UNKNOWN_COMPONENT", componentPath, `Unknown component "${name}" on entity ${label}.`, `${suggestion ? `Did you mean "${suggestion}"? ` : ""}Run \`ludra components\` to list every component.`);
78
81
  continue;
79
82
  }
@@ -97,6 +100,10 @@ export function validateScene(raw) {
97
100
  if (sprite && typeof sprite.image === "string" && sprite.image !== "" && !hasExtension(sprite.image, IMAGE_EXTENSIONS)) {
98
101
  error("E_INVALID_IMAGE", `${path}.components.sprite.image`, `"${sprite.image}" on entity ${label} is not an image file.`, `Use a path ending in ${IMAGE_EXTENSIONS.join(", ")}. SVG is easiest: you can write it as text.`);
99
102
  }
103
+ const script = isObject(components.script) ? components.script : undefined;
104
+ if (script && typeof script.src === "string" && !hasExtension(script.src, SCRIPT_EXTENSIONS)) {
105
+ error("E_INVALID_SCRIPT_PATH", `${path}.components.script.src`, `"${script.src}" on entity ${label} is not a script file.`, 'Use a path ending in .ts or .js, e.g. "scripts/chase.ts".');
106
+ }
100
107
  const body = isObject(components.body) ? components.body : undefined;
101
108
  const bodyType = body?.type ?? "static";
102
109
  if ("platformer" in components && body && bodyType !== "dynamic") {
@@ -155,7 +162,7 @@ function validateFields(fields, value, path, owner, where, error, warn, ignore =
155
162
  continue;
156
163
  const spec = fields[field];
157
164
  if (!spec) {
158
- const suggestion = closest(field, Object.keys(fields));
165
+ const suggestion = closestName(field, Object.keys(fields));
159
166
  warn("W_UNKNOWN_FIELD", `${path}.${field}`, `"${owner}" has no field "${field}", so it is ignored.`, suggestion ? `Did you mean "${suggestion}"?` : `Valid fields: ${Object.keys(fields).join(", ")}.`);
160
167
  continue;
161
168
  }
@@ -178,9 +185,11 @@ function validateEffects(effects, error, warn) {
178
185
  for (const [event, effect] of Object.entries(effects)) {
179
186
  const path = `effects.${event}`;
180
187
  if (!EVENT_TYPES.includes(event)) {
181
- const suggestion = closest(event, EVENT_TYPES);
182
- error("E_UNKNOWN_EVENT", path, `There is no "${event}" event.`, `${suggestion ? `Did you mean "${suggestion}"? ` : ""}Events: ${EVENT_TYPES.join(", ")}.`);
183
- continue;
188
+ // Any other name is a custom event that scripts fire with world.emit(). Flag likely typos of built-in ones.
189
+ const suggestion = closestName(event, EVENT_TYPES);
190
+ if (suggestion) {
191
+ warn("W_EVENT_LOOKS_LIKE_TYPO", path, `"${event}" is not a built-in event, so it only fires if a script calls world.emit("${event}").`, `Did you mean "${suggestion}"? Built-in events: ${EVENT_TYPES.join(", ")}.`);
192
+ }
184
193
  }
185
194
  if (!isObject(effect)) {
186
195
  error("E_EFFECT_NOT_OBJECT", path, `effects.${event} must be an object.`, `Use { "sound": "blip", "particles": { "count": 8 } }.`);
@@ -188,7 +197,7 @@ function validateEffects(effects, error, warn) {
188
197
  }
189
198
  validateFields(EFFECT_FIELDS, effect, path, `effects.${event}`, "", error, warn, ["particles"]);
190
199
  if (typeof effect.sound === "string" && effect.sound !== "" && !isSoundPreset(effect.sound) && !hasExtension(effect.sound, AUDIO_EXTENSIONS)) {
191
- const suggestion = closest(effect.sound, SOUND_PRESETS);
200
+ const suggestion = closestName(effect.sound, SOUND_PRESETS);
192
201
  error("E_INVALID_SOUND", `${path}.sound`, `"${effect.sound}" is neither a sound preset nor an audio file.`, `${suggestion ? `Did you mean "${suggestion}"? ` : ""}Presets: ${SOUND_PRESETS.join(", ")}. Files must end in ${AUDIO_EXTENSIONS.join(", ")}.`);
193
202
  }
194
203
  if (effect.particles !== undefined) {
@@ -201,6 +210,7 @@ function validateEffects(effects, error, warn) {
201
210
  }
202
211
  }
203
212
  }
213
+ const SCRIPT_EXTENSIONS = [".ts", ".js"];
204
214
  function hasExtension(path, extensions) {
205
215
  const lower = path.toLowerCase();
206
216
  return extensions.some((ext) => lower.endsWith(ext));
@@ -223,6 +233,8 @@ function checkField(spec, value) {
223
233
  : `must be one of ${spec.values.map((v) => `"${v}"`).join(", ")}, got ${describe(value)}.`;
224
234
  case "frames":
225
235
  return typeof value === "string" && isFrameList(value) ? undefined : `must be a frame list like "0", "1-4" or "0,2-3", got ${describe(value)}.`;
236
+ case "object":
237
+ return isObject(value) ? undefined : `must be an object like { "speed": 80 }, got ${describe(value)}.`;
226
238
  }
227
239
  }
228
240
  function fixFor(spec) {
@@ -241,7 +253,7 @@ function describe(value) {
241
253
  return value === undefined ? "nothing" : JSON.stringify(value);
242
254
  }
243
255
  /** Returns the candidate within a small edit distance of `input`, if any. */
244
- function closest(input, candidates) {
256
+ export function closestName(input, candidates) {
245
257
  let best;
246
258
  let bestDistance = Math.max(2, Math.floor(input.length / 3)) + 1;
247
259
  for (const candidate of candidates) {
package/dist/world.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { type Body, type Camera, type Collectible, type Emitter, type Goal, type Hazard, type Patrol, type Platformer, type Sprite, type Transform } from "./components.ts";
2
- import type { Action, Diagnostic, SceneDef } from "./types.ts";
1
+ import { type Body, type Camera, type Collectible, type Emitter, type Goal, type Hazard, type Patrol, type Platformer, type ScriptComponent, type Sprite, type Transform } from "./components.ts";
2
+ import { type Script, type ScriptModules } from "./script.ts";
3
+ import { type Action, type Diagnostic, type SceneDef } from "./types.ts";
3
4
  export interface BodyState extends Body {
4
5
  grounded: boolean;
5
6
  /** The solid this body is standing on, so moving platforms can carry it. */
@@ -7,6 +8,8 @@ export interface BodyState extends Body {
7
8
  }
8
9
  export interface PlatformerState extends Platformer {
9
10
  coyoteTimer: number;
11
+ /** True from a player-initiated jump until the apex, so only those jumps are cut short on release. */
12
+ jumping: boolean;
10
13
  bufferTimer: number;
11
14
  spawnX: number;
12
15
  spawnY: number;
@@ -15,8 +18,19 @@ export interface PatrolState extends Patrol {
15
18
  originX: number;
16
19
  originY: number;
17
20
  }
21
+ export interface ScriptState extends ScriptComponent {
22
+ module: Script;
23
+ started: boolean;
24
+ /** Ids of entities touched last frame, so `touch` fires only when touching starts. */
25
+ touching: Set<string>;
26
+ }
18
27
  export interface Entity {
19
28
  id: string;
29
+ /** Script-owned data, kept between frames and included in snapshots. */
30
+ state: Record<string, unknown>;
31
+ /** Set once destroyed or collected, so stale script handles report `exists: false`. */
32
+ removed?: boolean;
33
+ script?: ScriptState;
20
34
  transform: Transform;
21
35
  body?: BodyState;
22
36
  sprite?: Sprite;
@@ -58,6 +72,13 @@ export type GameEvent = {
58
72
  type: "win";
59
73
  entity: string;
60
74
  goal: string;
75
+ }
76
+ /** Fired by a script with world.emit(name, data). */
77
+ | {
78
+ type: "custom";
79
+ name: string;
80
+ entity: string;
81
+ data?: unknown;
61
82
  });
62
83
  export interface EntitySnapshot {
63
84
  x: number;
@@ -65,6 +86,12 @@ export interface EntitySnapshot {
65
86
  vx: number;
66
87
  vy: number;
67
88
  grounded: boolean;
89
+ /** Present when a script stored data on the entity. */
90
+ state?: Record<string, unknown>;
91
+ }
92
+ export interface WorldOptions {
93
+ /** Loaded script modules, keyed by the `src` path used in the scene. */
94
+ scripts?: ScriptModules;
68
95
  }
69
96
  export interface WorldSnapshot {
70
97
  frame: number;
@@ -96,9 +123,17 @@ export declare class World {
96
123
  /** Set when a player reaches a goal. The simulation stops changing after that. */
97
124
  won: boolean;
98
125
  private previousInput;
99
- constructor(scene: SceneDef);
126
+ private readonly scripts;
127
+ private seed;
128
+ private spawnCount;
129
+ private readonly handles;
130
+ private scriptApi;
131
+ private scriptInput;
132
+ /** The script hook currently running, for error messages and event attribution. */
133
+ private current;
134
+ constructor(scene: SceneDef, options?: WorldOptions);
100
135
  /** Validates raw scene JSON and builds a world, throwing on errors. */
101
- static fromJSON(raw: unknown): World;
136
+ static fromJSON(raw: unknown, options?: WorldOptions): World;
102
137
  get time(): number;
103
138
  get(id: string): Entity | undefined;
104
139
  /** Advances one frame with the given actions held down. */
@@ -106,6 +141,21 @@ export declare class World {
106
141
  snapshot(): WorldSnapshot;
107
142
  private emit;
108
143
  private remove;
144
+ private createEntity;
145
+ private updateScripts;
146
+ /** Calls `touch` on scripted entities that started overlapping or touching another body this frame. */
147
+ private updateTouches;
148
+ private callHook;
149
+ /** An error caused by how a script used the API, reported against the running script. */
150
+ private misuse;
151
+ /** A stable script-facing view of an entity. */
152
+ private handle;
153
+ private resolve;
154
+ /** The `world` object scripts receive. */
155
+ private api;
156
+ private spawn;
157
+ /** mulberry32, seeded by world.seed. */
158
+ private random;
109
159
  private players;
110
160
  private updatePlatformers;
111
161
  /** Points each patrolling body toward the end of its path it is heading for. */
@@ -117,5 +167,8 @@ export declare class World {
117
167
  /** Sends a player back to where it started. Collected items stay collected. */
118
168
  private kill;
119
169
  }
120
- /** Strict overlap: bodies that merely touch (e.g. standing on the ground) do not overlap. */
121
- export declare function overlaps(a: Entity, b: Entity): boolean;
170
+ /**
171
+ * Strict overlap: bodies that merely touch (e.g. standing on the ground) do not overlap.
172
+ * A positive `margin` grows both boxes, so touching counts too.
173
+ */
174
+ export declare function overlaps(a: Entity, b: Entity, margin?: number): boolean;
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,8 @@ 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;
22
26
  const EPSILON = 1e-4;
23
27
  /**
24
28
  * The simulation. It advances only through `step`, one fixed frame at a time,
@@ -38,19 +42,29 @@ export class World {
38
42
  /** Set when a player reaches a goal. The simulation stops changing after that. */
39
43
  won = false;
40
44
  previousInput = new Set();
41
- constructor(scene) {
45
+ scripts;
46
+ seed;
47
+ spawnCount = 0;
48
+ handles = new WeakMap();
49
+ scriptApi;
50
+ scriptInput = { held: () => false, pressed: () => false };
51
+ /** The script hook currently running, for error messages and event attribution. */
52
+ current;
53
+ constructor(scene, options = {}) {
42
54
  this.width = scene.world?.width ?? 960;
43
55
  this.height = scene.world?.height ?? 540;
44
56
  this.gravity = scene.world?.gravity ?? DEFAULT_GRAVITY;
45
57
  this.background = scene.world?.background ?? "#1d2333";
46
- this.entities = scene.entities.map(createEntity);
58
+ this.seed = scene.world?.seed ?? 1;
59
+ this.scripts = options.scripts;
60
+ this.entities = scene.entities.map((def) => this.createEntity(def));
47
61
  }
48
62
  /** Validates raw scene JSON and builds a world, throwing on errors. */
49
- static fromJSON(raw) {
63
+ static fromJSON(raw, options = {}) {
50
64
  const { scene, diagnostics } = validateScene(raw);
51
65
  if (!scene)
52
66
  throw new SceneValidationError(diagnostics);
53
- return new World(scene);
67
+ return new World(scene, options);
54
68
  }
55
69
  get time() {
56
70
  return this.frame * FIXED_DT;
@@ -66,8 +80,14 @@ export class World {
66
80
  if (this.won)
67
81
  return;
68
82
  this.updatePlatformers(input, pressed);
83
+ this.updateScripts(input, pressed);
84
+ if (this.won)
85
+ return;
69
86
  this.updatePatrols();
70
87
  this.updateBodies();
88
+ this.updateTouches();
89
+ if (this.won)
90
+ return;
71
91
  this.updateCollectibles();
72
92
  this.updateHazards();
73
93
  this.updateGoals();
@@ -82,6 +102,9 @@ export class World {
82
102
  vy: e.body?.vy ?? 0,
83
103
  grounded: e.body?.grounded ?? false,
84
104
  };
105
+ // A JSON copy, so later frames can't change an earlier snapshot.
106
+ if (Object.keys(e.state).length > 0)
107
+ entities[e.id].state = JSON.parse(JSON.stringify(e.state));
85
108
  }
86
109
  return { frame: this.frame, time: this.time, score: this.score, deaths: this.deaths, won: this.won, entities };
87
110
  }
@@ -89,8 +112,243 @@ export class World {
89
112
  this.events.push({ ...event, frame: this.frame, time: this.time });
90
113
  }
91
114
  remove(entity) {
92
- this.entities.splice(this.entities.indexOf(entity), 1);
115
+ const index = this.entities.indexOf(entity);
116
+ if (index >= 0)
117
+ this.entities.splice(index, 1);
118
+ entity.removed = true;
119
+ }
120
+ // ---------------------------------------------------------------- scripts
121
+ createEntity(def) {
122
+ const entity = createEntity(def);
123
+ const component = def.components.script;
124
+ if (component) {
125
+ const resolved = resolveComponent("script", component);
126
+ const module = getScript(this.scripts, resolved.src);
127
+ if (!module) {
128
+ 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.");
129
+ }
130
+ entity.script = { ...resolved, params: { ...resolved.params }, module, started: false, touching: new Set() };
131
+ }
132
+ return entity;
133
+ }
134
+ updateScripts(held, pressed) {
135
+ this.scriptInput = { held: (action) => held.has(action), pressed: (action) => pressed.has(action) };
136
+ // Iterate over a copy: scripts may spawn or destroy entities.
137
+ for (const e of [...this.entities]) {
138
+ if (!e.script || e.removed)
139
+ continue;
140
+ if (!e.script.started) {
141
+ e.script.started = true;
142
+ this.callHook(e, "start");
143
+ }
144
+ if (!e.removed && !this.won)
145
+ this.callHook(e, "update");
146
+ if (this.won)
147
+ return;
148
+ }
149
+ }
150
+ /** Calls `touch` on scripted entities that started overlapping or touching another body this frame. */
151
+ updateTouches() {
152
+ for (const e of [...this.entities]) {
153
+ if (!e.script?.module.touch || !e.body || e.removed)
154
+ continue;
155
+ const now = new Set();
156
+ for (const other of this.entities) {
157
+ if (other !== e && other.body && overlaps(e, other, TOUCH_MARGIN))
158
+ now.add(other.id);
159
+ }
160
+ for (const id of now) {
161
+ const other = this.get(id);
162
+ if (other && !e.script.touching.has(id) && !e.removed && !this.won)
163
+ this.callHook(e, "touch", this.handle(other));
164
+ }
165
+ e.script.touching = now;
166
+ }
167
+ }
168
+ callHook(entity, hook, other) {
169
+ const script = entity.script;
170
+ const fn = script.module[hook];
171
+ if (!fn)
172
+ return;
173
+ const ctx = { self: this.handle(entity), world: this.api(), input: this.scriptInput, params: script.params, dt: FIXED_DT };
174
+ this.current = { entity, hook };
175
+ try {
176
+ fn.call(script.module, ctx, other);
177
+ }
178
+ catch (err) {
179
+ if (err instanceof ScriptError)
180
+ throw err;
181
+ const message = err instanceof Error ? err.message : String(err);
182
+ throw new ScriptError({ src: script.src, entity: entity.id, hook, frame: this.frame }, message, { cause: err });
183
+ }
184
+ finally {
185
+ this.current = undefined;
186
+ }
187
+ }
188
+ /** An error caused by how a script used the API, reported against the running script. */
189
+ misuse(message) {
190
+ const running = this.current;
191
+ return new ScriptError({ src: running?.entity.script?.src ?? "?", entity: running?.entity.id ?? "?", hook: running?.hook ?? "?", frame: this.frame }, message);
93
192
  }
193
+ /** A stable script-facing view of an entity. */
194
+ handle(e) {
195
+ let handle = this.handles.get(e);
196
+ if (handle)
197
+ return handle;
198
+ const world = this;
199
+ handle = {
200
+ get id() {
201
+ return e.id;
202
+ },
203
+ get x() {
204
+ return e.transform.x;
205
+ },
206
+ set x(value) {
207
+ e.transform.x = value;
208
+ },
209
+ get y() {
210
+ return e.transform.y;
211
+ },
212
+ set y(value) {
213
+ e.transform.y = value;
214
+ },
215
+ get vx() {
216
+ return e.body?.vx ?? 0;
217
+ },
218
+ set vx(value) {
219
+ if (e.body)
220
+ e.body.vx = value;
221
+ },
222
+ get vy() {
223
+ return e.body?.vy ?? 0;
224
+ },
225
+ set vy(value) {
226
+ if (e.body)
227
+ e.body.vy = value;
228
+ },
229
+ get grounded() {
230
+ return e.body?.grounded ?? false;
231
+ },
232
+ get width() {
233
+ return e.body?.width ?? e.sprite?.width ?? 0;
234
+ },
235
+ get height() {
236
+ return e.body?.height ?? e.sprite?.height ?? 0;
237
+ },
238
+ get exists() {
239
+ return !e.removed;
240
+ },
241
+ get state() {
242
+ return e.state;
243
+ },
244
+ has(component) {
245
+ return component === "transform" || (component in e && e[component] !== undefined);
246
+ },
247
+ touches(other) {
248
+ const target = world.get(typeof other === "string" ? other : other.id);
249
+ return !!target && target !== e && !e.removed && overlaps(e, target, TOUCH_MARGIN);
250
+ },
251
+ };
252
+ this.handles.set(e, handle);
253
+ return handle;
254
+ }
255
+ resolve(target) {
256
+ return this.get(typeof target === "string" ? target : target.id);
257
+ }
258
+ /** The `world` object scripts receive. */
259
+ api() {
260
+ if (this.scriptApi)
261
+ return this.scriptApi;
262
+ const world = this;
263
+ this.scriptApi = {
264
+ get time() {
265
+ return world.time;
266
+ },
267
+ get frame() {
268
+ return world.frame;
269
+ },
270
+ get width() {
271
+ return world.width;
272
+ },
273
+ get height() {
274
+ return world.height;
275
+ },
276
+ get score() {
277
+ return world.score;
278
+ },
279
+ set score(value) {
280
+ world.score = value;
281
+ },
282
+ get: (id) => {
283
+ const e = world.get(id);
284
+ return e ? world.handle(e) : undefined;
285
+ },
286
+ all: (component) => world.entities.filter((e) => !component || world.handle(e).has(component)).map((e) => world.handle(e)),
287
+ player: () => {
288
+ const e = world.entities.find((candidate) => candidate.platformer);
289
+ return e ? world.handle(e) : undefined;
290
+ },
291
+ spawn: (def) => world.spawn(def),
292
+ destroy: (target) => {
293
+ const e = world.resolve(target);
294
+ if (e)
295
+ world.remove(e);
296
+ },
297
+ kill: (target) => {
298
+ const e = world.resolve(target);
299
+ if (!e)
300
+ return;
301
+ if (e.platformer && e.body)
302
+ world.kill(e, world.current?.entity.id ?? "script");
303
+ else
304
+ world.remove(e);
305
+ },
306
+ win: () => {
307
+ if (world.won)
308
+ return;
309
+ const by = world.current?.entity;
310
+ world.won = true;
311
+ world.emit({ type: "win", entity: by?.id ?? "script", goal: by?.id ?? "script", x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
312
+ },
313
+ emit: (name, data) => {
314
+ if (typeof name !== "string" || name === "")
315
+ throw world.misuse("world.emit needs a non-empty event name.");
316
+ if (EVENT_TYPES.includes(name)) {
317
+ throw world.misuse(`"${name}" is a built-in event and can't be emitted by scripts. Pick another name, e.g. "${name}-custom".`);
318
+ }
319
+ const by = world.current?.entity;
320
+ world.emit({ type: "custom", name, entity: by?.id ?? "script", data, x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
321
+ },
322
+ random: () => world.random(),
323
+ };
324
+ return this.scriptApi;
325
+ }
326
+ spawn(def) {
327
+ if (typeof def !== "object" || def === null || typeof def.components !== "object") {
328
+ throw this.misuse('world.spawn needs { components: { ... } }, e.g. { components: { transform: { x: 0, y: 0 } } }.');
329
+ }
330
+ const id = def.id ?? `${this.current?.entity.id ?? "spawned"}-${++this.spawnCount}`;
331
+ if (this.get(id))
332
+ throw this.misuse(`world.spawn: an entity with id "${id}" already exists. Leave id out to get a unique one.`);
333
+ const { diagnostics } = validateScene({ entities: [{ id, components: def.components }] });
334
+ const errors = diagnostics.filter((d) => d.severity === "error" && d.code !== "E_GOAL_UNREACHABLE");
335
+ if (errors.length > 0) {
336
+ const details = errors.map((d) => `${d.path.replace("entities[0]", id)}: ${d.message}${d.fix ? ` Fix: ${d.fix}` : ""}`);
337
+ throw this.misuse(`world.spawn got an invalid entity. ${details.join(" ")}`);
338
+ }
339
+ const entity = this.createEntity({ id, components: def.components });
340
+ this.entities.push(entity);
341
+ return this.handle(entity);
342
+ }
343
+ /** mulberry32, seeded by world.seed. */
344
+ random() {
345
+ this.seed = (this.seed + 0x6d2b79f5) | 0;
346
+ let t = this.seed;
347
+ t = Math.imul(t ^ (t >>> 15), t | 1);
348
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
349
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
350
+ }
351
+ // ------------------------------------------------------------ built-ins
94
352
  players() {
95
353
  return this.entities.filter((e) => e.platformer && e.body);
96
354
  }
@@ -111,11 +369,15 @@ export class World {
111
369
  b.standingOn = undefined;
112
370
  p.bufferTimer = 0;
113
371
  p.coyoteTimer = 0;
372
+ p.jumping = true;
114
373
  this.emit({ type: "jump", entity: e.id, ...feet(e) });
115
374
  }
116
- else if (p.variableJump && !held.has("jump") && b.vy < -p.jumpSpeed * JUMP_CUT) {
375
+ else if (p.jumping && p.variableJump && !held.has("jump") && b.vy < -p.jumpSpeed * JUMP_CUT) {
376
+ // Only the player's own jumps are cut short; launches from pads, stomps or scripts are not.
117
377
  b.vy = -p.jumpSpeed * JUMP_CUT;
118
378
  }
379
+ if (b.vy >= 0)
380
+ p.jumping = false;
119
381
  p.coyoteTimer = Math.max(0, p.coyoteTimer - FIXED_DT);
120
382
  p.bufferTimer = Math.max(0, p.bufferTimer - FIXED_DT);
121
383
  }
@@ -257,12 +519,13 @@ export class World {
257
519
  b.standingOn = undefined;
258
520
  p.coyoteTimer = 0;
259
521
  p.bufferTimer = 0;
522
+ p.jumping = false;
260
523
  }
261
524
  }
262
525
  function createEntity(def) {
263
526
  const c = def.components;
264
527
  const transform = c.transform ? resolveComponent("transform", c.transform) : { x: 0, y: 0 };
265
- const entity = { id: def.id, transform };
528
+ const entity = { id: def.id, state: {}, transform };
266
529
  if (c.body)
267
530
  entity.body = { ...resolveComponent("body", c.body), grounded: false };
268
531
  if (c.sprite)
@@ -272,6 +535,7 @@ function createEntity(def) {
272
535
  ...resolveComponent("platformer", c.platformer),
273
536
  coyoteTimer: 0,
274
537
  bufferTimer: 0,
538
+ jumping: false,
275
539
  spawnX: transform.x,
276
540
  spawnY: transform.y,
277
541
  };
@@ -308,10 +572,13 @@ function isStomp(player, enemy) {
308
572
  const enemyTop = enemy.transform.y - enemy.body.height / 2;
309
573
  return previousBottom <= enemyTop + STOMP_TOLERANCE;
310
574
  }
311
- /** Strict overlap: bodies that merely touch (e.g. standing on the ground) do not overlap. */
312
- export function overlaps(a, b) {
575
+ /**
576
+ * Strict overlap: bodies that merely touch (e.g. standing on the ground) do not overlap.
577
+ * A positive `margin` grows both boxes, so touching counts too.
578
+ */
579
+ export function overlaps(a, b, margin = 0) {
313
580
  if (!a.body || !b.body)
314
581
  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);
582
+ return (Math.abs(a.transform.x - b.transform.x) < (a.body.width + b.body.width) / 2 + margin - EPSILON &&
583
+ Math.abs(a.transform.y - b.transform.y) < (a.body.height + b.body.height) / 2 + margin - EPSILON);
317
584
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ludra/core",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Ludra engine runtime: scene format, validation, deterministic simulation and rendering.",
5
5
  "license": "MIT",
6
6
  "repository": {