@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/ascii.js +2 -2
- package/dist/assertions.d.ts +15 -7
- package/dist/assertions.js +60 -23
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +210 -22
- package/dist/components.d.ts +17 -1
- package/dist/components.js +31 -1
- package/dist/drawcheck.d.ts +7 -0
- package/dist/drawcheck.js +81 -0
- package/dist/effects.d.ts +2 -1
- package/dist/effects.js +19 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/postfx.d.ts +18 -0
- package/dist/postfx.js +100 -0
- package/dist/script.d.ts +170 -0
- package/dist/script.js +107 -0
- package/dist/types.d.ts +21 -3
- package/dist/types.js +1 -1
- package/dist/validate.d.ts +2 -0
- package/dist/validate.js +67 -11
- package/dist/world.d.ts +89 -8
- package/dist/world.js +419 -28
- package/package.json +1 -1
- package/src/ascii.ts +2 -2
- package/src/assertions.ts +68 -29
- package/src/browser.ts +212 -19
- package/src/components.ts +48 -2
- package/src/drawcheck.ts +80 -0
- package/src/effects.ts +17 -1
- package/src/index.ts +2 -0
- package/src/postfx.ts +110 -0
- package/src/script.ts +262 -0
- package/src/types.ts +22 -3
- package/src/validate.ts +85 -11
- package/src/world.ts +443 -34
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
|
-
|
|
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.
|
|
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.
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
253
|
-
player.transform.y =
|
|
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
|
|
259
|
-
p
|
|
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
|
-
|
|
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
|
-
/**
|
|
312
|
-
|
|
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
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);
|