@ludra/core 0.5.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/browser.js +151 -19
- package/dist/components.d.ts +7 -1
- package/dist/components.js +22 -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 +16 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/postfx.d.ts +18 -0
- package/dist/postfx.js +100 -0
- package/dist/script.d.ts +64 -2
- package/dist/script.js +22 -2
- package/dist/types.d.ts +14 -1
- package/dist/types.js +1 -1
- package/dist/validate.js +48 -4
- package/dist/world.d.ts +32 -4
- package/dist/world.js +148 -24
- package/package.json +1 -1
- package/src/ascii.ts +2 -2
- package/src/browser.ts +156 -17
- package/src/components.ts +30 -1
- package/src/drawcheck.ts +80 -0
- package/src/effects.ts +15 -1
- package/src/index.ts +1 -0
- package/src/postfx.ts +110 -0
- package/src/script.ts +80 -2
- package/src/types.ts +15 -1
- package/src/validate.ts +54 -4
- package/src/world.ts +144 -28
package/dist/world.js
CHANGED
|
@@ -23,6 +23,8 @@ const STOMP_TOLERANCE = 8;
|
|
|
23
23
|
const FALL_MARGIN = 100;
|
|
24
24
|
/** Bodies this close count as touching for `touch` hooks, so solids (doors, walls) can be touched too. */
|
|
25
25
|
const TOUCH_MARGIN = 1;
|
|
26
|
+
/** Impacts slower than this (px/s, after bounce) stop instead of bouncing. */
|
|
27
|
+
const MIN_BOUNCE_SPEED = 40;
|
|
26
28
|
const EPSILON = 1e-4;
|
|
27
29
|
/**
|
|
28
30
|
* The simulation. It advances only through `step`, one fixed frame at a time,
|
|
@@ -50,6 +52,8 @@ export class World {
|
|
|
50
52
|
scriptInput = { held: () => false, pressed: () => false };
|
|
51
53
|
/** The script hook currently running, for error messages and event attribution. */
|
|
52
54
|
current;
|
|
55
|
+
/** True while a draw hook runs: the game can't be changed then. */
|
|
56
|
+
drawing = false;
|
|
53
57
|
constructor(scene, options = {}) {
|
|
54
58
|
this.width = scene.world?.width ?? 960;
|
|
55
59
|
this.height = scene.world?.height ?? 540;
|
|
@@ -80,6 +84,7 @@ export class World {
|
|
|
80
84
|
if (this.won)
|
|
81
85
|
return;
|
|
82
86
|
this.updatePlatformers(input, pressed);
|
|
87
|
+
this.updateTopdown(input);
|
|
83
88
|
this.updateScripts(input, pressed);
|
|
84
89
|
if (this.won)
|
|
85
90
|
return;
|
|
@@ -171,9 +176,35 @@ export class World {
|
|
|
171
176
|
if (!fn)
|
|
172
177
|
return;
|
|
173
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;
|
|
174
205
|
this.current = { entity, hook };
|
|
175
206
|
try {
|
|
176
|
-
|
|
207
|
+
call();
|
|
177
208
|
}
|
|
178
209
|
catch (err) {
|
|
179
210
|
if (err instanceof ScriptError)
|
|
@@ -185,6 +216,12 @@ export class World {
|
|
|
185
216
|
this.current = undefined;
|
|
186
217
|
}
|
|
187
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
|
+
}
|
|
224
|
+
}
|
|
188
225
|
/** An error caused by how a script used the API, reported against the running script. */
|
|
189
226
|
misuse(message) {
|
|
190
227
|
const running = this.current;
|
|
@@ -204,18 +241,21 @@ export class World {
|
|
|
204
241
|
return e.transform.x;
|
|
205
242
|
},
|
|
206
243
|
set x(value) {
|
|
244
|
+
world.guard("Setting x");
|
|
207
245
|
e.transform.x = value;
|
|
208
246
|
},
|
|
209
247
|
get y() {
|
|
210
248
|
return e.transform.y;
|
|
211
249
|
},
|
|
212
250
|
set y(value) {
|
|
251
|
+
world.guard("Setting y");
|
|
213
252
|
e.transform.y = value;
|
|
214
253
|
},
|
|
215
254
|
get vx() {
|
|
216
255
|
return e.body?.vx ?? 0;
|
|
217
256
|
},
|
|
218
257
|
set vx(value) {
|
|
258
|
+
world.guard("Setting vx");
|
|
219
259
|
if (e.body)
|
|
220
260
|
e.body.vx = value;
|
|
221
261
|
},
|
|
@@ -223,6 +263,7 @@ export class World {
|
|
|
223
263
|
return e.body?.vy ?? 0;
|
|
224
264
|
},
|
|
225
265
|
set vy(value) {
|
|
266
|
+
world.guard("Setting vy");
|
|
226
267
|
if (e.body)
|
|
227
268
|
e.body.vy = value;
|
|
228
269
|
},
|
|
@@ -239,7 +280,8 @@ export class World {
|
|
|
239
280
|
return !e.removed;
|
|
240
281
|
},
|
|
241
282
|
get state() {
|
|
242
|
-
|
|
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;
|
|
243
285
|
},
|
|
244
286
|
has(component) {
|
|
245
287
|
return component === "transform" || (component in e && e[component] !== undefined);
|
|
@@ -276,7 +318,14 @@ export class World {
|
|
|
276
318
|
get score() {
|
|
277
319
|
return world.score;
|
|
278
320
|
},
|
|
321
|
+
get deaths() {
|
|
322
|
+
return world.deaths;
|
|
323
|
+
},
|
|
324
|
+
get won() {
|
|
325
|
+
return world.won;
|
|
326
|
+
},
|
|
279
327
|
set score(value) {
|
|
328
|
+
world.guard("Setting score");
|
|
280
329
|
world.score = value;
|
|
281
330
|
},
|
|
282
331
|
get: (id) => {
|
|
@@ -285,25 +334,31 @@ export class World {
|
|
|
285
334
|
},
|
|
286
335
|
all: (component) => world.entities.filter((e) => !component || world.handle(e).has(component)).map((e) => world.handle(e)),
|
|
287
336
|
player: () => {
|
|
288
|
-
const e = world.entities.find(
|
|
337
|
+
const e = world.entities.find(isPlayer);
|
|
289
338
|
return e ? world.handle(e) : undefined;
|
|
290
339
|
},
|
|
291
|
-
spawn: (def) =>
|
|
340
|
+
spawn: (def) => {
|
|
341
|
+
world.guard("world.spawn");
|
|
342
|
+
return world.spawn(def);
|
|
343
|
+
},
|
|
292
344
|
destroy: (target) => {
|
|
345
|
+
world.guard("world.destroy");
|
|
293
346
|
const e = world.resolve(target);
|
|
294
347
|
if (e)
|
|
295
348
|
world.remove(e);
|
|
296
349
|
},
|
|
297
350
|
kill: (target) => {
|
|
351
|
+
world.guard("world.kill");
|
|
298
352
|
const e = world.resolve(target);
|
|
299
353
|
if (!e)
|
|
300
354
|
return;
|
|
301
|
-
if (e
|
|
355
|
+
if (isPlayer(e))
|
|
302
356
|
world.kill(e, world.current?.entity.id ?? "script");
|
|
303
357
|
else
|
|
304
358
|
world.remove(e);
|
|
305
359
|
},
|
|
306
360
|
win: () => {
|
|
361
|
+
world.guard("world.win");
|
|
307
362
|
if (world.won)
|
|
308
363
|
return;
|
|
309
364
|
const by = world.current?.entity;
|
|
@@ -311,6 +366,7 @@ export class World {
|
|
|
311
366
|
world.emit({ type: "win", entity: by?.id ?? "script", goal: by?.id ?? "script", x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
|
|
312
367
|
},
|
|
313
368
|
emit: (name, data) => {
|
|
369
|
+
world.guard("world.emit");
|
|
314
370
|
if (typeof name !== "string" || name === "")
|
|
315
371
|
throw world.misuse("world.emit needs a non-empty event name.");
|
|
316
372
|
if (EVENT_TYPES.includes(name)) {
|
|
@@ -319,7 +375,10 @@ export class World {
|
|
|
319
375
|
const by = world.current?.entity;
|
|
320
376
|
world.emit({ type: "custom", name, entity: by?.id ?? "script", data, x: by?.transform.x ?? 0, y: by?.transform.y ?? 0 });
|
|
321
377
|
},
|
|
322
|
-
random: () =>
|
|
378
|
+
random: () => {
|
|
379
|
+
world.guard("world.random (it advances the game's seed; use ctx.random in draw hooks)");
|
|
380
|
+
return world.random();
|
|
381
|
+
},
|
|
323
382
|
};
|
|
324
383
|
return this.scriptApi;
|
|
325
384
|
}
|
|
@@ -349,13 +408,36 @@ export class World {
|
|
|
349
408
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
350
409
|
}
|
|
351
410
|
// ------------------------------------------------------------ built-ins
|
|
411
|
+
/** Entities the player controls: platformers and topdown movers. */
|
|
352
412
|
players() {
|
|
353
|
-
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
|
+
}
|
|
354
434
|
}
|
|
355
435
|
updatePlatformers(held, pressed) {
|
|
356
|
-
for (const e of this.
|
|
436
|
+
for (const e of this.entities) {
|
|
357
437
|
const p = e.platformer;
|
|
358
438
|
const b = e.body;
|
|
439
|
+
if (!p || !b)
|
|
440
|
+
continue;
|
|
359
441
|
const direction = (held.has("right") ? 1 : 0) - (held.has("left") ? 1 : 0);
|
|
360
442
|
b.vx = direction * p.speed;
|
|
361
443
|
// Timers get a tiny extra margin so a value of 0 still allows a jump on the exact frame.
|
|
@@ -404,15 +486,27 @@ export class World {
|
|
|
404
486
|
updateBodies() {
|
|
405
487
|
// Kinematic bodies move first, so anything standing on them can follow.
|
|
406
488
|
const moved = new Map();
|
|
489
|
+
const walls = this.entities.filter((e) => e.body?.type === "static" && !e.body.sensor);
|
|
407
490
|
for (const e of this.entities) {
|
|
408
491
|
const b = e.body;
|
|
409
492
|
if (!b || b.type !== "kinematic")
|
|
410
493
|
continue;
|
|
411
494
|
const { x, y } = e.transform;
|
|
412
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;
|
|
413
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;
|
|
414
506
|
if (e.patrol)
|
|
415
507
|
clampToPatrol(e, e.patrol);
|
|
508
|
+
if (e.topdown?.clampToWorld)
|
|
509
|
+
this.clampToWorld(e);
|
|
416
510
|
moved.set(e, { dx: e.transform.x - x, dy: e.transform.y - y });
|
|
417
511
|
}
|
|
418
512
|
const solids = this.entities.filter((e) => e.body && !e.body.sensor && e.body.type !== "dynamic");
|
|
@@ -425,7 +519,9 @@ export class World {
|
|
|
425
519
|
e.transform.x += carried.dx;
|
|
426
520
|
e.transform.y += carried.dy;
|
|
427
521
|
}
|
|
428
|
-
|
|
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);
|
|
429
525
|
const wasGrounded = b.grounded;
|
|
430
526
|
b.grounded = false;
|
|
431
527
|
b.standingOn = undefined;
|
|
@@ -439,27 +535,48 @@ export class World {
|
|
|
439
535
|
e.transform.x = solid.transform.x - half;
|
|
440
536
|
else if (b.vx < 0)
|
|
441
537
|
e.transform.x = solid.transform.x + half;
|
|
442
|
-
b.vx =
|
|
538
|
+
b.vx = this.rebound(e, solid, b.vx);
|
|
443
539
|
}
|
|
444
540
|
e.transform.y += b.vy * FIXED_DT;
|
|
445
541
|
for (const solid of solids) {
|
|
446
542
|
if (!overlaps(e, solid))
|
|
447
543
|
continue;
|
|
448
544
|
const half = (solid.body.height + b.height) / 2;
|
|
449
|
-
|
|
545
|
+
const falling = b.vy > 0;
|
|
546
|
+
if (b.vy > 0)
|
|
450
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) {
|
|
451
553
|
b.grounded = true;
|
|
452
554
|
b.standingOn = solid;
|
|
453
555
|
}
|
|
454
|
-
else if (b.vy < 0) {
|
|
455
|
-
e.transform.y = solid.transform.y + half;
|
|
456
|
-
}
|
|
457
|
-
b.vy = 0;
|
|
458
556
|
}
|
|
557
|
+
if (e.topdown?.clampToWorld)
|
|
558
|
+
this.clampToWorld(e);
|
|
459
559
|
if (b.grounded && !wasGrounded)
|
|
460
560
|
this.emit({ type: "land", entity: e.id, ...feet(e) });
|
|
461
561
|
}
|
|
462
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
|
+
}
|
|
463
580
|
updateCollectibles() {
|
|
464
581
|
const players = this.players();
|
|
465
582
|
for (const item of this.entities.filter((e) => e.collectible && e.body)) {
|
|
@@ -481,7 +598,7 @@ export class World {
|
|
|
481
598
|
for (const hazard of this.entities.filter((e) => e.hazard && e.body)) {
|
|
482
599
|
if (!overlaps(player, hazard))
|
|
483
600
|
continue;
|
|
484
|
-
if (hazard.hazard.stompable && isStomp(player, hazard)) {
|
|
601
|
+
if (hazard.hazard.stompable && player.platformer && isStomp(player, hazard)) {
|
|
485
602
|
this.remove(hazard);
|
|
486
603
|
b.vy = -player.platformer.jumpSpeed * STOMP_BOUNCE;
|
|
487
604
|
this.score += hazard.hazard.score;
|
|
@@ -508,18 +625,20 @@ export class World {
|
|
|
508
625
|
/** Sends a player back to where it started. Collected items stay collected. */
|
|
509
626
|
kill(player, cause) {
|
|
510
627
|
const b = player.body;
|
|
511
|
-
const p = player.platformer;
|
|
512
628
|
this.deaths++;
|
|
513
629
|
this.emit({ type: "die", entity: player.id, cause, ...player.transform });
|
|
514
|
-
player.transform.x =
|
|
515
|
-
player.transform.y =
|
|
630
|
+
player.transform.x = player.spawn?.x ?? player.transform.x;
|
|
631
|
+
player.transform.y = player.spawn?.y ?? player.transform.y;
|
|
516
632
|
b.vx = 0;
|
|
517
633
|
b.vy = 0;
|
|
518
634
|
b.grounded = false;
|
|
519
635
|
b.standingOn = undefined;
|
|
520
|
-
p
|
|
521
|
-
p
|
|
522
|
-
|
|
636
|
+
const p = player.platformer;
|
|
637
|
+
if (p) {
|
|
638
|
+
p.coyoteTimer = 0;
|
|
639
|
+
p.bufferTimer = 0;
|
|
640
|
+
p.jumping = false;
|
|
641
|
+
}
|
|
523
642
|
}
|
|
524
643
|
}
|
|
525
644
|
function createEntity(def) {
|
|
@@ -536,10 +655,12 @@ function createEntity(def) {
|
|
|
536
655
|
coyoteTimer: 0,
|
|
537
656
|
bufferTimer: 0,
|
|
538
657
|
jumping: false,
|
|
539
|
-
spawnX: transform.x,
|
|
540
|
-
spawnY: transform.y,
|
|
541
658
|
};
|
|
542
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 };
|
|
543
664
|
if (c.collectible)
|
|
544
665
|
entity.collectible = resolveComponent("collectible", c.collectible);
|
|
545
666
|
if (c.patrol)
|
|
@@ -554,6 +675,9 @@ function createEntity(def) {
|
|
|
554
675
|
entity.camera = resolveComponent("camera", c.camera);
|
|
555
676
|
return entity;
|
|
556
677
|
}
|
|
678
|
+
function isPlayer(e) {
|
|
679
|
+
return !!(e.platformer || e.topdown) && !!e.body;
|
|
680
|
+
}
|
|
557
681
|
function feet(e) {
|
|
558
682
|
return { x: e.transform.x, y: e.transform.y + (e.body?.height ?? 0) / 2 };
|
|
559
683
|
}
|
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);
|
package/src/browser.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { parseFrames, type Sprite } from "./components.ts";
|
|
|
3
3
|
import { collectAssetPaths } from "./effects.ts";
|
|
4
4
|
import { InputScript } from "./input.ts";
|
|
5
5
|
import { ParticleSystem } from "./particles.ts";
|
|
6
|
-
import
|
|
6
|
+
import { PostFx } from "./postfx.ts";
|
|
7
|
+
import type { DrawContext, DrawEvent, Script } from "./script.ts";
|
|
7
8
|
import { FIXED_DT } from "./time.ts";
|
|
8
9
|
import type { Action, SceneDef } from "./types.ts";
|
|
9
10
|
import { World, type Entity } from "./world.ts";
|
|
@@ -52,6 +53,88 @@ async function loadScripts(scene: SceneDef, options: GameOptions): Promise<Map<s
|
|
|
52
53
|
return scripts;
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/** Fonts load from Google Fonts or project files; a slow or offline font never blocks the game for long. */
|
|
57
|
+
async function loadFonts(scene: SceneDef, assetBase: string): Promise<void> {
|
|
58
|
+
const fonts = Object.entries(scene.fonts ?? {});
|
|
59
|
+
if (fonts.length === 0) return;
|
|
60
|
+
const load = async ([family, src]: [string, string]) => {
|
|
61
|
+
if (src === "google") {
|
|
62
|
+
const name = encodeURIComponent(family).replace(/%20/g, "+");
|
|
63
|
+
// Not every family has every weight, and Google rejects the request if one is missing.
|
|
64
|
+
for (const weights of [":wght@400;500;600;700;800;900", ":wght@400;700", ""]) {
|
|
65
|
+
const response = await fetch(`https://fonts.googleapis.com/css2?family=${name}${weights}&display=block`);
|
|
66
|
+
if (!response.ok) continue;
|
|
67
|
+
const style = document.createElement("style");
|
|
68
|
+
style.textContent = await response.text();
|
|
69
|
+
document.head.append(style);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
await Promise.all(["400", "700", "900"].map((weight) => document.fonts.load(`${weight} 16px "${family}"`)));
|
|
73
|
+
} else {
|
|
74
|
+
const face = new FontFace(family, `url("${assetBase}${src}")`);
|
|
75
|
+
// FontFaceSet.add is missing from some TypeScript DOM typings.
|
|
76
|
+
(document.fonts as unknown as Set<FontFace>).add(await face.load());
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const all = Promise.all(
|
|
80
|
+
fonts.map((font) => load(font).catch((err) => console.warn(`[ludra] could not load font "${font[0]}": ${err}`))),
|
|
81
|
+
);
|
|
82
|
+
await Promise.race([all, new Promise((resolve) => setTimeout(resolve, 4000))]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface Stage {
|
|
86
|
+
/** Where the game draws. With postfx this is an offscreen canvas. */
|
|
87
|
+
ctx: CanvasRenderingContext2D;
|
|
88
|
+
/** The canvas players see. */
|
|
89
|
+
output: HTMLCanvasElement;
|
|
90
|
+
images: Map<string, HTMLImageElement>;
|
|
91
|
+
scripts: Map<string, Script>;
|
|
92
|
+
/** Shows the drawn frame: runs the postfx shader, or does nothing without one. */
|
|
93
|
+
present(time: number): void;
|
|
94
|
+
/** Why postfx couldn't start (the game then runs without it and shows this message). */
|
|
95
|
+
error?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Loads everything a scene needs and sets up its canvases. With `postfx`, `canvas`
|
|
100
|
+
* becomes the offscreen source and a WebGL canvas takes its place in the page.
|
|
101
|
+
*/
|
|
102
|
+
async function prepare(canvas: HTMLCanvasElement, scene: SceneDef, options: GameOptions): Promise<Stage> {
|
|
103
|
+
const ctx = canvas.getContext("2d");
|
|
104
|
+
if (!ctx) throw new Error("Canvas 2D context is not available.");
|
|
105
|
+
const assetBase = options.assetBase ?? "";
|
|
106
|
+
const shaderPath = scene.postfx?.shader;
|
|
107
|
+
const [images, scripts, shader] = await Promise.all([
|
|
108
|
+
loadImages(scene, assetBase),
|
|
109
|
+
loadScripts(scene, options),
|
|
110
|
+
shaderPath
|
|
111
|
+
? fetch(assetBase + shaderPath).then(async (r) => (r.ok ? r.text() : new Error(`could not load ${shaderPath} (${r.status})`)))
|
|
112
|
+
: undefined,
|
|
113
|
+
loadFonts(scene, assetBase),
|
|
114
|
+
]);
|
|
115
|
+
const stage: Stage = { ctx, output: canvas, images, scripts, present: () => {} };
|
|
116
|
+
if (scene.postfx && shader !== undefined) {
|
|
117
|
+
try {
|
|
118
|
+
if (shader instanceof Error) throw shader;
|
|
119
|
+
const fx = new PostFx(canvas.width, canvas.height, shader, scene.postfx);
|
|
120
|
+
fx.canvas.id = canvas.id;
|
|
121
|
+
fx.canvas.className = canvas.className;
|
|
122
|
+
fx.canvas.style.cssText = canvas.style.cssText;
|
|
123
|
+
canvas.replaceWith(fx.canvas);
|
|
124
|
+
stage.output = fx.canvas;
|
|
125
|
+
stage.present = (time) => fx.render(canvas, time);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
stage.error = `postfx: ${message(err)}`;
|
|
128
|
+
console.error("[ludra]", err);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return stage;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function message(err: unknown): string {
|
|
135
|
+
return err instanceof Error ? err.message : String(err);
|
|
136
|
+
}
|
|
137
|
+
|
|
55
138
|
/**
|
|
56
139
|
* Runs a scene in a canvas with keyboard controls, images, particles and
|
|
57
140
|
* sound. The simulation uses the same fixed step as headless runs, so what
|
|
@@ -59,19 +142,16 @@ async function loadScripts(scene: SceneDef, options: GameOptions): Promise<Map<s
|
|
|
59
142
|
* Resolves (after images load) to a function that stops the game.
|
|
60
143
|
*/
|
|
61
144
|
export async function startGame(canvas: HTMLCanvasElement, scene: SceneDef, options: GameOptions = {}): Promise<() => void> {
|
|
62
|
-
const ctx = canvas
|
|
63
|
-
|
|
64
|
-
const assetBase = options.assetBase ?? "";
|
|
65
|
-
const [images, scripts] = await Promise.all([loadImages(scene, assetBase), loadScripts(scene, options)]);
|
|
66
|
-
const sounds = new SoundPlayer(assetBase);
|
|
145
|
+
const { ctx, images, scripts, present, error } = await prepare(canvas, scene, options);
|
|
146
|
+
const sounds = new SoundPlayer(options.assetBase ?? "");
|
|
67
147
|
const view = new View(scene, images);
|
|
68
148
|
|
|
69
149
|
let world = new World(scene, { scripts });
|
|
70
|
-
let crash: string | undefined;
|
|
150
|
+
let crash: string | undefined = error;
|
|
71
151
|
const restart = () => {
|
|
72
152
|
world = new World(scene, { scripts });
|
|
73
153
|
view.reset();
|
|
74
|
-
crash =
|
|
154
|
+
crash = error;
|
|
75
155
|
};
|
|
76
156
|
|
|
77
157
|
const held = new Set<Action>();
|
|
@@ -110,15 +190,22 @@ export async function startGame(canvas: HTMLCanvasElement, scene: SceneDef, opti
|
|
|
110
190
|
world.step(tapped.size > 0 ? new Set([...held, ...tapped]) : held);
|
|
111
191
|
} catch (err) {
|
|
112
192
|
// A script threw: freeze the game and show why, instead of failing silently.
|
|
113
|
-
crash =
|
|
193
|
+
crash = message(err);
|
|
114
194
|
console.error("[ludra]", err);
|
|
115
195
|
}
|
|
116
196
|
tapped.clear();
|
|
117
197
|
view.step(world, (sound) => sounds.play(sound));
|
|
118
198
|
accumulator -= FIXED_DT;
|
|
119
199
|
}
|
|
120
|
-
|
|
200
|
+
try {
|
|
201
|
+
view.draw(ctx, world, sounds.muted);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
// A draw hook threw. Same treatment as a simulation error.
|
|
204
|
+
if (!crash) console.error("[ludra]", err);
|
|
205
|
+
crash ??= message(err);
|
|
206
|
+
}
|
|
121
207
|
if (crash) drawCrash(ctx, crash);
|
|
208
|
+
present(world.time);
|
|
122
209
|
raf = requestAnimationFrame(frame);
|
|
123
210
|
};
|
|
124
211
|
raf = requestAnimationFrame(frame);
|
|
@@ -152,9 +239,8 @@ export interface CapturedFrame {
|
|
|
152
239
|
* `ludra play --screenshot`.
|
|
153
240
|
*/
|
|
154
241
|
export async function captureFrames(canvas: HTMLCanvasElement, scene: SceneDef, options: CaptureOptions): Promise<CapturedFrame[]> {
|
|
155
|
-
const ctx = canvas
|
|
156
|
-
if (
|
|
157
|
-
const [images, scripts] = await Promise.all([loadImages(scene, options.assetBase ?? ""), loadScripts(scene, options)]);
|
|
242
|
+
const { ctx, output, images, scripts, present, error } = await prepare(canvas, scene, options);
|
|
243
|
+
if (error) throw new Error(error);
|
|
158
244
|
const view = new View(scene, images);
|
|
159
245
|
const world = new World(scene, { scripts });
|
|
160
246
|
const script = InputScript.parse(options.inputs ?? "");
|
|
@@ -165,7 +251,8 @@ export async function captureFrames(canvas: HTMLCanvasElement, scene: SceneDef,
|
|
|
165
251
|
for (let frame = 0; ; frame++) {
|
|
166
252
|
if (wanted.has(frame)) {
|
|
167
253
|
view.draw(ctx, world, false);
|
|
168
|
-
|
|
254
|
+
present(world.time);
|
|
255
|
+
captured.push({ frame, time: frame * FIXED_DT, png: output.toDataURL("image/png") });
|
|
169
256
|
}
|
|
170
257
|
if (frame >= last) break;
|
|
171
258
|
world.step(script.at(frame));
|
|
@@ -228,6 +315,8 @@ class View {
|
|
|
228
315
|
private readonly facing = new Map<string, number>();
|
|
229
316
|
private readonly emitTimers = new Map<string, number>();
|
|
230
317
|
private readonly frameCache = new Map<string, number[]>();
|
|
318
|
+
/** ctx.local for each entity's draw hooks. */
|
|
319
|
+
private readonly local = new Map<string, Record<string, unknown>>();
|
|
231
320
|
private shake = 0;
|
|
232
321
|
private handledEvents = 0;
|
|
233
322
|
private readonly scene: SceneDef;
|
|
@@ -242,6 +331,7 @@ class View {
|
|
|
242
331
|
this.particles.clear();
|
|
243
332
|
this.facing.clear();
|
|
244
333
|
this.emitTimers.clear();
|
|
334
|
+
this.local.clear();
|
|
245
335
|
this.shake = 0;
|
|
246
336
|
this.handledEvents = 0;
|
|
247
337
|
}
|
|
@@ -278,7 +368,7 @@ class View {
|
|
|
278
368
|
ctx.fillStyle = world.background;
|
|
279
369
|
ctx.fillRect(0, 0, viewWidth, viewHeight);
|
|
280
370
|
|
|
281
|
-
const target = world.entities.find((e) => e.camera) ?? world.entities.find((e) => e.platformer);
|
|
371
|
+
const target = world.entities.find((e) => e.camera) ?? world.entities.find((e) => e.platformer || e.topdown);
|
|
282
372
|
const zoom = target?.camera?.zoom ?? 1;
|
|
283
373
|
const visibleWidth = viewWidth / zoom;
|
|
284
374
|
const visibleHeight = viewHeight / zoom;
|
|
@@ -289,14 +379,25 @@ class View {
|
|
|
289
379
|
cameraY += (this.particles.random() - 0.5) * 2 * this.shake;
|
|
290
380
|
}
|
|
291
381
|
|
|
382
|
+
const viewInfo = { width: viewWidth, height: viewHeight, cameraX, cameraY, zoom };
|
|
292
383
|
ctx.save();
|
|
293
384
|
ctx.scale(zoom, zoom);
|
|
294
385
|
ctx.translate(-cameraX, -cameraY);
|
|
295
|
-
const
|
|
296
|
-
|
|
386
|
+
const layer = (e: Entity) => e.sprite?.layer ?? 0;
|
|
387
|
+
const visible = world.entities.filter((e) => e.sprite || e.script?.module.draw).sort((a, b) => layer(a) - layer(b));
|
|
388
|
+
for (const e of visible) {
|
|
389
|
+
if (e.sprite) this.drawSprite(ctx, e, world.time);
|
|
390
|
+
if (e.script?.module.draw) this.drawHook(ctx, world, e, "draw", viewInfo);
|
|
391
|
+
}
|
|
297
392
|
this.particles.draw(ctx);
|
|
298
393
|
ctx.restore();
|
|
299
394
|
|
|
395
|
+
if (world.hasHook("drawUI")) {
|
|
396
|
+
// The game draws its own interface.
|
|
397
|
+
for (const e of [...world.entities]) if (e.script?.module.drawUI) this.drawHook(ctx, world, e, "drawUI", viewInfo);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
300
401
|
ctx.textAlign = "left";
|
|
301
402
|
ctx.font = "600 18px system-ui, sans-serif";
|
|
302
403
|
ctx.fillStyle = "#ffffff";
|
|
@@ -319,6 +420,30 @@ class View {
|
|
|
319
420
|
}
|
|
320
421
|
}
|
|
321
422
|
|
|
423
|
+
private drawHook(ctx: CanvasRenderingContext2D, world: World, e: Entity, hook: "draw" | "drawUI", view: DrawContext["view"]): void {
|
|
424
|
+
let local = this.local.get(e.id);
|
|
425
|
+
if (!local) this.local.set(e.id, (local = {}));
|
|
426
|
+
ctx.save();
|
|
427
|
+
try {
|
|
428
|
+
world.callDrawHook(e, hook, {
|
|
429
|
+
g: ctx,
|
|
430
|
+
time: world.time,
|
|
431
|
+
local,
|
|
432
|
+
view,
|
|
433
|
+
recent: (name, seconds = 0.5) => recentEvents(world, name, seconds),
|
|
434
|
+
image: (path) => this.images.get(path),
|
|
435
|
+
burst: (x, y, particles = {}) => this.particles.burst(x, y, particles),
|
|
436
|
+
shake: (amount) => {
|
|
437
|
+
this.shake = Math.max(this.shake, amount);
|
|
438
|
+
},
|
|
439
|
+
random: () => this.particles.random(),
|
|
440
|
+
});
|
|
441
|
+
} finally {
|
|
442
|
+
// Whatever the hook changed (transform, alpha, blend mode, filter) ends with it.
|
|
443
|
+
ctx.restore();
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
322
447
|
private drawSprite(ctx: CanvasRenderingContext2D, e: Entity, time: number): void {
|
|
323
448
|
const sprite = e.sprite!;
|
|
324
449
|
const width = sprite.width || e.body?.width || 16;
|
|
@@ -390,6 +515,20 @@ class View {
|
|
|
390
515
|
|
|
391
516
|
type AnimationState = "idle" | "run" | "jump" | "fall";
|
|
392
517
|
|
|
518
|
+
function recentEvents(world: World, name: string | undefined, seconds: number): DrawEvent[] {
|
|
519
|
+
const since = world.time - seconds;
|
|
520
|
+
const result: DrawEvent[] = [];
|
|
521
|
+
// Events are in time order, so walk back until they get too old.
|
|
522
|
+
for (let i = world.events.length - 1; i >= 0; i--) {
|
|
523
|
+
const event = world.events[i]!;
|
|
524
|
+
if (event.time < since) break;
|
|
525
|
+
const eventName = event.type === "custom" ? event.name : event.type;
|
|
526
|
+
if (name !== undefined && eventName !== name) continue;
|
|
527
|
+
result.push({ name: eventName, time: event.time, x: event.x, y: event.y, entity: event.entity, data: event.type === "custom" ? event.data : undefined });
|
|
528
|
+
}
|
|
529
|
+
return result.reverse();
|
|
530
|
+
}
|
|
531
|
+
|
|
393
532
|
function animationState(e: Entity): AnimationState {
|
|
394
533
|
const b = e.body;
|
|
395
534
|
if (!b) return "idle";
|