@moku-labs/game 0.2.0 → 0.3.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/README.md +2 -0
- package/dist/assets.d.mts +181 -22
- package/dist/assets.mjs +1186 -160
- package/dist/{commands-C0BljsM5.d.mts → commands-DYO22lzi.d.mts} +22 -5
- package/dist/control.d.mts +3 -3
- package/dist/control.mjs +3 -3
- package/dist/{define-CjP_4H0G.d.mts → define-BKWjWgyQ.d.mts} +1 -1
- package/dist/dev-CQYJPmcD.mjs +1183 -0
- package/dist/flatten-BkXuscAV.mjs +100 -0
- package/dist/index.d.mts +85 -31
- package/dist/index.mjs +3208 -490
- package/dist/{session-DmAxY6Ll.mjs → input-CK4nxBO1.mjs} +41 -12
- package/dist/inspect.d.mts +32 -9
- package/dist/inspect.mjs +2 -2
- package/dist/jsx-dev-runtime.d.mts +1 -1
- package/dist/jsx-dev-runtime.mjs +1 -1
- package/dist/jsx-runtime.d.mts +1 -1
- package/dist/jsx-runtime.mjs +1 -1
- package/dist/{run-tlcn1w7y.mjs → run-BfuMLahc.mjs} +489 -213
- package/dist/{runtime-Bn5TXNxP.d.mts → runtime-Bhu442L8.d.mts} +1 -1
- package/dist/{runtime-DRlwxkIv.mjs → runtime-CBZgWgzx.mjs} +1 -57
- package/dist/{sources-BooL2MsX.mjs → sources-C1uGjz4l.mjs} +198 -21
- package/dist/testing.d.mts +5 -5
- package/dist/testing.mjs +5 -4
- package/dist/{types-CxZy7Pi4.d.mts → types-BnWH-75b.d.mts} +95 -21
- package/dist/{types-CqkrEY06.d.mts → types-CVUHPvMB.d.mts} +5 -434
- package/dist/{types-DmhTHRVW.d.mts → types-DtmFVCHf.d.mts} +1 -1
- package/dist/{types-BZ1OLf24.d.mts → types-Dy1xB68L.d.mts} +2318 -1169
- package/dist/{types-X9jXjka3.d.mts → types-VErbiEXy.d.mts} +867 -198
- package/dist/{visual-NPrDkvUk.mjs → visual-DUvvPPCt.mjs} +51 -1
- package/llms.txt +5 -5
- package/package.json +5 -5
- package/dist/component-DGg5DqKK.mjs +0 -44
- package/dist/dev-CcwFrwok.mjs +0 -413
- package/dist/types-C47fKNcn.d.mts +0 -695
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { n as isDev, s as
|
|
2
|
-
import { a as
|
|
1
|
+
import { f as graphHash, n as isDev, s as sheetAlone, t as controlRefused } from "./dev-CQYJPmcD.mjs";
|
|
2
|
+
import { a as isProjectionTarget, i as recordCheat, o as readTarget, r as isTainted, s as defineCommand, t as inputOrEmpty } from "./input-CK4nxBO1.mjs";
|
|
3
3
|
//#region src/plugins/flow/headless.ts
|
|
4
4
|
const noPayload = null;
|
|
5
5
|
/** Seed of a repro that pins no rng state, the seed `saveOf` writes. */
|
|
@@ -219,37 +219,25 @@ const reducedMotionCommand = defineCommand({
|
|
|
219
219
|
//#region src/plugins/input/control.ts
|
|
220
220
|
/**
|
|
221
221
|
* @file input plugin — the input commands of the `/control` door: a drag from one view onto
|
|
222
|
-
* another and a key press. Each goes through `app.input`, so the gate
|
|
223
|
-
* stays clean. Dev builds only: every body starts with the inline dev
|
|
224
|
-
* `define` of `false` drops it, and logs the `moku:dev` marker.
|
|
225
|
-
*
|
|
222
|
+
* another, a trace through many views and a key press. Each goes through `app.input`, so the gate
|
|
223
|
+
* decides and the session stays clean. Dev builds only: every body starts with the inline dev
|
|
224
|
+
* guard, so a bundler `define` of `false` drops it, and logs the `moku:dev` marker. The targets
|
|
225
|
+
* are read with `target.ts`; `readTarget` is re-exported here for the `game.tap` command of ui,
|
|
226
|
+
* which taps a view by the same projection key.
|
|
226
227
|
*/
|
|
227
228
|
/**
|
|
228
|
-
*
|
|
229
|
+
* Reads the cells of a trace out of the JSON a command got.
|
|
229
230
|
*
|
|
230
|
-
* @param value - The JSON
|
|
231
|
-
* @returns
|
|
232
|
-
* @
|
|
233
|
-
* ```ts
|
|
234
|
-
* isProjectionTarget({ projection: "board.items", key: "i5" }); // true
|
|
235
|
-
* ```
|
|
236
|
-
*/
|
|
237
|
-
function isProjectionTarget(value) {
|
|
238
|
-
return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.projection === "string" && typeof value.key === "string";
|
|
239
|
-
}
|
|
240
|
-
/**
|
|
241
|
-
* Reads the projection key of a view out of the JSON a command got.
|
|
242
|
-
*
|
|
243
|
-
* @param value - The JSON: `{ projection, key }`.
|
|
244
|
-
* @returns The target, a fresh object.
|
|
245
|
-
* @throws {Error} When the JSON is not an object with a string `projection` and a string `key`.
|
|
231
|
+
* @param value - The JSON: a list of `{ projection, key }`.
|
|
232
|
+
* @returns The targets in list order, fresh objects.
|
|
233
|
+
* @throws {Error} When the JSON is not a list, or an element is not a projection key.
|
|
246
234
|
*/
|
|
247
|
-
function
|
|
248
|
-
if (
|
|
249
|
-
projection:
|
|
250
|
-
key:
|
|
251
|
-
};
|
|
252
|
-
throw new Error("[game] The
|
|
235
|
+
function readPath(value) {
|
|
236
|
+
if (Array.isArray(value) && value.every((item) => isProjectionTarget(item))) return value.map((target) => ({
|
|
237
|
+
projection: target.projection,
|
|
238
|
+
key: target.key
|
|
239
|
+
}));
|
|
240
|
+
throw new Error("[game] The path is not a list of projection keys.\n Pass views like [{ projection: \"board.cells\", key: \"b3\" }, …].");
|
|
253
241
|
}
|
|
254
242
|
/**
|
|
255
243
|
* Drags one view onto another, both by their projection keys. Answers what `input.drag`
|
|
@@ -277,6 +265,28 @@ const dragCommand = defineCommand({
|
|
|
277
265
|
}
|
|
278
266
|
});
|
|
279
267
|
/**
|
|
268
|
+
* Traces through views by their projection keys, in list order. Answers what `input.trace`
|
|
269
|
+
* answers: whether the gate took the answer.
|
|
270
|
+
*
|
|
271
|
+
* @example
|
|
272
|
+
* ```ts
|
|
273
|
+
* // Spell the first two letters of a word on the grid.
|
|
274
|
+
* const path = [{ projection: "board.cells", key: "b3" }, { projection: "board.cells", key: "c3" }];
|
|
275
|
+
* (await run(app, commands.trace, { path })).value; // true
|
|
276
|
+
* ```
|
|
277
|
+
*/
|
|
278
|
+
const traceCommand = defineCommand({
|
|
279
|
+
id: "game.trace",
|
|
280
|
+
title: "Trace",
|
|
281
|
+
input: { path: "json" },
|
|
282
|
+
effect: "route",
|
|
283
|
+
run: (app, { path }) => {
|
|
284
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
285
|
+
app.log.debug("moku:dev", { command: "game.trace" });
|
|
286
|
+
return app.input.trace(readPath(path));
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
/**
|
|
280
290
|
* Presses a key without a keyboard, with or without Shift. Answers whether a listener handled
|
|
281
291
|
* it.
|
|
282
292
|
*
|
|
@@ -357,189 +367,6 @@ const resumeCommand = defineCommand({
|
|
|
357
367
|
}
|
|
358
368
|
});
|
|
359
369
|
//#endregion
|
|
360
|
-
//#region src/plugins/renderer/control.ts
|
|
361
|
-
/**
|
|
362
|
-
* @file renderer plugin — the renderer commands of the `/control` door: take a picture of the
|
|
363
|
-
* canvas and switch the debug drawing. Dev builds only: every body starts with the inline dev
|
|
364
|
-
* guard, so a bundler `define` of `false` drops it, and logs the `moku:dev` marker.
|
|
365
|
-
*/
|
|
366
|
-
/**
|
|
367
|
-
* A PNG of the whole canvas taken at the end of the next drawn frame, as a data URL.
|
|
368
|
-
* `undefined` while the renderer is inert, as in a headless test.
|
|
369
|
-
*
|
|
370
|
-
* @example
|
|
371
|
-
* ```ts
|
|
372
|
-
* // The editor attaches the screen to a bug report.
|
|
373
|
-
* (await run(app, commands.capture)).value; // "data:image/png;base64,iVBORw0KGgo…"
|
|
374
|
-
* ```
|
|
375
|
-
*/
|
|
376
|
-
const captureCommand = defineCommand({
|
|
377
|
-
id: "game.capture",
|
|
378
|
-
title: "Capture",
|
|
379
|
-
input: {},
|
|
380
|
-
effect: "read",
|
|
381
|
-
run: (app) => {
|
|
382
|
-
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
383
|
-
app.log.debug("moku:dev", { command: "game.capture" });
|
|
384
|
-
return app.renderer.capture();
|
|
385
|
-
}
|
|
386
|
-
});
|
|
387
|
-
/**
|
|
388
|
-
* Switches the debug drawing: `nineSlice` outlines every nine-slice and the lines Pixi cuts its
|
|
389
|
-
* texture at. Answers the switches as they are afterwards.
|
|
390
|
-
*
|
|
391
|
-
* @example
|
|
392
|
-
* ```ts
|
|
393
|
-
* // Check where the settings popup cuts its parchment.
|
|
394
|
-
* (await run(app, commands.debug, { nineSlice: true })).value; // { nineSlice: true }
|
|
395
|
-
* ```
|
|
396
|
-
*/
|
|
397
|
-
const debugCommand = defineCommand({
|
|
398
|
-
id: "game.debug",
|
|
399
|
-
title: "Debug drawing",
|
|
400
|
-
input: { nineSlice: "boolean" },
|
|
401
|
-
effect: "cosmetic",
|
|
402
|
-
run: (app, { nineSlice }) => {
|
|
403
|
-
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
404
|
-
app.log.debug("moku:dev", {
|
|
405
|
-
command: "game.debug",
|
|
406
|
-
nineSlice
|
|
407
|
-
});
|
|
408
|
-
app.renderer.sync.debug.nineSlice(nineSlice);
|
|
409
|
-
return app.renderer.sync.debug.state();
|
|
410
|
-
}
|
|
411
|
-
});
|
|
412
|
-
//#endregion
|
|
413
|
-
//#region src/plugins/time/control.ts
|
|
414
|
-
/**
|
|
415
|
-
* @file time plugin — the time command of the `/control` door: step frames by hand. Dev builds
|
|
416
|
-
* only: the body starts with the inline dev guard, so a bundler `define` of `false` drops it, and
|
|
417
|
-
* logs the `moku:dev` marker.
|
|
418
|
-
*/
|
|
419
|
-
/** The length of one frame at 60 fps, the step a frame count takes by default. */
|
|
420
|
-
const FRAME_MS = 1e3 / 60;
|
|
421
|
-
/**
|
|
422
|
-
* Refuses a frame count that is not a whole number of zero or more.
|
|
423
|
-
*
|
|
424
|
-
* @param frames - The frame count.
|
|
425
|
-
* @throws {Error} When it is negative, fractional or not finite.
|
|
426
|
-
*/
|
|
427
|
-
function checkFrames(frames) {
|
|
428
|
-
if (Number.isInteger(frames) && frames >= 0) return;
|
|
429
|
-
throw new Error(`[game] game.step takes a whole number of frames.\n Pass 0 or a positive whole number, not ${String(frames)}.`);
|
|
430
|
-
}
|
|
431
|
-
/**
|
|
432
|
-
* Runs frames by hand, one `time.step` each, also while the game is paused, and answers the time
|
|
433
|
-
* after them. A frame lasts 1000/60 ms unless `deltaMs` says otherwise.
|
|
434
|
-
*
|
|
435
|
-
* @example
|
|
436
|
-
* ```ts
|
|
437
|
-
* // The editor paused the game on a merge; step three frames to watch the item land.
|
|
438
|
-
* const ran = await run(app, commands.step, { frames: 3 });
|
|
439
|
-
* ran.value; // { delta: 16.666…, elapsed: 50, scale: 1, frame: 3, idle: false }
|
|
440
|
-
* ```
|
|
441
|
-
*/
|
|
442
|
-
const stepCommand = defineCommand({
|
|
443
|
-
id: "game.step",
|
|
444
|
-
title: "Step frames",
|
|
445
|
-
input: {
|
|
446
|
-
frames: "number",
|
|
447
|
-
deltaMs: "number?"
|
|
448
|
-
},
|
|
449
|
-
effect: "cosmetic",
|
|
450
|
-
run: (app, { frames, deltaMs = FRAME_MS }) => {
|
|
451
|
-
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
452
|
-
app.log.debug("moku:dev", {
|
|
453
|
-
command: "game.step",
|
|
454
|
-
frames
|
|
455
|
-
});
|
|
456
|
-
checkFrames(frames);
|
|
457
|
-
for (let frame = 0; frame < frames; frame += 1) app.time.step(deltaMs);
|
|
458
|
-
return app.time.snapshot();
|
|
459
|
-
}
|
|
460
|
-
});
|
|
461
|
-
//#endregion
|
|
462
|
-
//#region src/plugins/ui/control.ts
|
|
463
|
-
/**
|
|
464
|
-
* @file ui plugin — the two ui commands of the `/control` door: a tap on a keyed element or on a
|
|
465
|
-
* view, and the text typed into a field. The tap goes through `app.input`, so the gate decides;
|
|
466
|
-
* the text goes through `app.ui.fill`, which writes a component's local and answers nothing. The
|
|
467
|
-
* session stays clean. Dev builds only: each body starts with the inline dev guard, so a bundler
|
|
468
|
-
* `define` of `false` drops it, and logs the `moku:dev` marker.
|
|
469
|
-
*/
|
|
470
|
-
/**
|
|
471
|
-
* Finds the entity of a keyed ui element on screen.
|
|
472
|
-
*
|
|
473
|
-
* @param ui - The ui API.
|
|
474
|
-
* @param key - The `key` prop of the element.
|
|
475
|
-
* @returns The entity.
|
|
476
|
-
* @throws {Error} When no element with the key is on screen.
|
|
477
|
-
*/
|
|
478
|
-
function elementOf(ui, key) {
|
|
479
|
-
const entity = ui.find(key);
|
|
480
|
-
if (entity !== void 0) return entity;
|
|
481
|
-
throw new Error(`[game] No element with the key "${key}" is on screen.\n Read sources.ui for the keys on screen.`);
|
|
482
|
-
}
|
|
483
|
-
/**
|
|
484
|
-
* Taps a ui element by its key, or a view by its projection key: exactly one of the two. Answers
|
|
485
|
-
* what `input.tap` answers: whether the gate took the answer.
|
|
486
|
-
*
|
|
487
|
-
* @example
|
|
488
|
-
* ```ts
|
|
489
|
-
* // Home rests: tap the Play plank, then the first generator of the board.
|
|
490
|
-
* (await run(app, commands.tap, { key: "play" })).value; // true
|
|
491
|
-
* await run(app, commands.tap, { target: { projection: "board.generators", key: "g1" } });
|
|
492
|
-
* ```
|
|
493
|
-
*/
|
|
494
|
-
const tapCommand = defineCommand({
|
|
495
|
-
id: "game.tap",
|
|
496
|
-
title: "Tap",
|
|
497
|
-
input: {
|
|
498
|
-
key: "string?",
|
|
499
|
-
target: "json?"
|
|
500
|
-
},
|
|
501
|
-
effect: "route",
|
|
502
|
-
run: (app, { key, target }) => {
|
|
503
|
-
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
504
|
-
app.log.debug("moku:dev", {
|
|
505
|
-
command: "game.tap",
|
|
506
|
-
key
|
|
507
|
-
});
|
|
508
|
-
if (key !== void 0 && target === void 0) return app.input.tap(elementOf(app.ui, key));
|
|
509
|
-
if (target !== void 0 && key === void 0) return app.input.tap(readTarget(target));
|
|
510
|
-
throw new Error("[game] game.tap takes a key or a target.\n Pass exactly one of { key } and { target }.");
|
|
511
|
-
}
|
|
512
|
-
});
|
|
513
|
-
/**
|
|
514
|
-
* Types into a text field by its key, the way `app.ui.fill` does: the field becomes the one being
|
|
515
|
-
* edited, the value cut to its `maxLength` is written into it and into the local of its
|
|
516
|
-
* component. Answers what `fill` answers: false for a key that is no live `input`.
|
|
517
|
-
*
|
|
518
|
-
* @example
|
|
519
|
-
* ```ts
|
|
520
|
-
* // The Rename popup rests: type the name, then submit it with the Enter key.
|
|
521
|
-
* (await run(app, commands.fill, { key: "nameField", value: "Alex" })).value; // true
|
|
522
|
-
* await run(app, commands.key, { key: "Enter" }); // the gate takes { intent: "save", payload: { name: "Alex" } }
|
|
523
|
-
* ```
|
|
524
|
-
*/
|
|
525
|
-
const fillCommand = defineCommand({
|
|
526
|
-
id: "game.fill",
|
|
527
|
-
title: "Fill",
|
|
528
|
-
input: {
|
|
529
|
-
key: "string",
|
|
530
|
-
value: "string"
|
|
531
|
-
},
|
|
532
|
-
effect: "route",
|
|
533
|
-
run: (app, { key, value }) => {
|
|
534
|
-
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
535
|
-
app.log.debug("moku:dev", {
|
|
536
|
-
command: "game.fill",
|
|
537
|
-
key
|
|
538
|
-
});
|
|
539
|
-
return app.ui.fill(key, value);
|
|
540
|
-
}
|
|
541
|
-
});
|
|
542
|
-
//#endregion
|
|
543
370
|
//#region src/plugins/flow/json.ts
|
|
544
371
|
/**
|
|
545
372
|
* Tells whether a JSON value is an object.
|
|
@@ -740,6 +567,453 @@ function readRepro(value) {
|
|
|
740
567
|
});
|
|
741
568
|
}
|
|
742
569
|
//#endregion
|
|
570
|
+
//#region src/plugins/renderer/control.ts
|
|
571
|
+
/**
|
|
572
|
+
* @file renderer plugin — the renderer commands of the `/control` door: take a picture of the
|
|
573
|
+
* canvas, with its options, and switch the debug drawing. Dev builds only: every body starts with
|
|
574
|
+
* the inline dev guard, so a bundler `define` of `false` drops it, and logs the `moku:dev` marker.
|
|
575
|
+
*/
|
|
576
|
+
/** The input fields of `game.capture`, in the order its `moku:dev` entry lists them. */
|
|
577
|
+
const CAPTURE_FIELDS = [
|
|
578
|
+
"legend",
|
|
579
|
+
"layers",
|
|
580
|
+
"sheet",
|
|
581
|
+
"diff"
|
|
582
|
+
];
|
|
583
|
+
/** Drawn frames a restored bookmark gets to come to rest at its gate. */
|
|
584
|
+
const REST_FRAMES = 600;
|
|
585
|
+
/** Drawn frames after the rest, so the screen the gate opened is built and drawn. */
|
|
586
|
+
const AFTER_REST_FRAMES = 2;
|
|
587
|
+
/** The length of one frame at 60 fps: the step a paused clock takes per frame, as in `game.step`. */
|
|
588
|
+
const FRAME_MS$1 = 1e3 / 60;
|
|
589
|
+
/**
|
|
590
|
+
* Builds the error of an input field of `game.capture` with the wrong shape.
|
|
591
|
+
*
|
|
592
|
+
* @param field - The field.
|
|
593
|
+
* @param expected - What to pass instead.
|
|
594
|
+
* @returns The error, ready to throw.
|
|
595
|
+
* @example
|
|
596
|
+
* ```ts
|
|
597
|
+
* wrongShape("sheet", "{ frames, everyMs }, two numbers").message;
|
|
598
|
+
* // "[game] game.capture: sheet has the wrong shape.\n Pass { frames, everyMs }, two numbers."
|
|
599
|
+
* ```
|
|
600
|
+
*/
|
|
601
|
+
function wrongShape(field, expected) {
|
|
602
|
+
return /* @__PURE__ */ new Error(`[game] game.capture: ${field} has the wrong shape.\n Pass ${expected}.`);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Reads the `layers` field: a JSON array of strings.
|
|
606
|
+
*
|
|
607
|
+
* @param value - The JSON the editor sent.
|
|
608
|
+
* @returns The layer names.
|
|
609
|
+
* @throws {Error} When it is not an array of strings.
|
|
610
|
+
* @example
|
|
611
|
+
* ```ts
|
|
612
|
+
* readLayers(["board", "hud"]); // ["board", "hud"]
|
|
613
|
+
* ```
|
|
614
|
+
*/
|
|
615
|
+
function readLayers(value) {
|
|
616
|
+
if (Array.isArray(value) && value.every((name) => typeof name === "string")) return value;
|
|
617
|
+
throw wrongShape("layers", "an array of layer names, such as [\"board\", \"hud\"]");
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Reads the `sheet` field: an object of two numbers, `frames` and `everyMs`. Their range is
|
|
621
|
+
* checked by `renderer.capture()`.
|
|
622
|
+
*
|
|
623
|
+
* @param value - The JSON the editor sent.
|
|
624
|
+
* @returns The sheet.
|
|
625
|
+
* @throws {Error} When it is not an object with two numbers.
|
|
626
|
+
* @example
|
|
627
|
+
* ```ts
|
|
628
|
+
* readSheet({ frames: 6, everyMs: 100 }); // { frames: 6, everyMs: 100 }
|
|
629
|
+
* ```
|
|
630
|
+
*/
|
|
631
|
+
function readSheet(value) {
|
|
632
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
633
|
+
const { frames, everyMs } = value;
|
|
634
|
+
if (typeof frames === "number" && typeof everyMs === "number") return {
|
|
635
|
+
frames,
|
|
636
|
+
everyMs
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
throw wrongShape("sheet", "{ frames, everyMs }, two numbers");
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Reads the `diff` field: a bookmark, the value `game.bookmark` answered.
|
|
643
|
+
*
|
|
644
|
+
* @param value - The JSON the editor sent.
|
|
645
|
+
* @returns The bookmark.
|
|
646
|
+
* @throws {Error} When it is not a bookmark.
|
|
647
|
+
*/
|
|
648
|
+
function readDiff(value) {
|
|
649
|
+
try {
|
|
650
|
+
return readBookmark(value);
|
|
651
|
+
} catch {
|
|
652
|
+
throw wrongShape("diff", "a bookmark, the value of game.bookmark");
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Reads the options `game.capture` passes to `renderer.capture()` as they are: only the fields
|
|
657
|
+
* given.
|
|
658
|
+
*
|
|
659
|
+
* @param input - The input of the command.
|
|
660
|
+
* @returns The options.
|
|
661
|
+
* @throws {Error} When `layers` or `sheet` has the wrong shape.
|
|
662
|
+
*/
|
|
663
|
+
function readOptions(input) {
|
|
664
|
+
const options = {};
|
|
665
|
+
if (input.legend !== void 0) options.legend = input.legend;
|
|
666
|
+
if (input.layers !== void 0) options.layers = readLayers(input.layers);
|
|
667
|
+
if (input.sheet !== void 0) options.sheet = readSheet(input.sheet);
|
|
668
|
+
return options;
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* The input fields given: `legend` when true, the others when present.
|
|
672
|
+
*
|
|
673
|
+
* @param input - The input of the command.
|
|
674
|
+
* @returns Their names.
|
|
675
|
+
* @example
|
|
676
|
+
* ```ts
|
|
677
|
+
* givenFields({ legend: true, diff: { path: "home" } }); // ["legend", "diff"]
|
|
678
|
+
* ```
|
|
679
|
+
*/
|
|
680
|
+
function givenFields(input) {
|
|
681
|
+
return CAPTURE_FIELDS.filter((field) => field === "legend" ? input.legend === true : input[field] !== void 0);
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Tells whether the graph waits at a gate with no effect pending.
|
|
685
|
+
*
|
|
686
|
+
* @param state - The flow state.
|
|
687
|
+
* @returns True at rest.
|
|
688
|
+
* @example
|
|
689
|
+
* ```ts
|
|
690
|
+
* atRest({ running: true, path: "home", stack: [], pending: { gate: ["play"] }, mode: "live" }); // true
|
|
691
|
+
* ```
|
|
692
|
+
*/
|
|
693
|
+
function atRest(state) {
|
|
694
|
+
return state.pending.gate !== void 0 && state.pending.fx === void 0;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Waits for the next drawn frame: the end of its `render` phase. A paused clock runs no frame, so
|
|
698
|
+
* the frame is stepped here instead, one `time.step(1000 / 60)`, as `game.step` does.
|
|
699
|
+
*
|
|
700
|
+
* @param app - The app whose frames are waited on.
|
|
701
|
+
* @returns Resolves after that frame.
|
|
702
|
+
*/
|
|
703
|
+
function nextFrame(app) {
|
|
704
|
+
if (app.time.isPaused()) {
|
|
705
|
+
app.time.step(FRAME_MS$1);
|
|
706
|
+
return Promise.resolve();
|
|
707
|
+
}
|
|
708
|
+
return new Promise((resolve) => {
|
|
709
|
+
const off = app.time.onFrame("render", () => {
|
|
710
|
+
off();
|
|
711
|
+
resolve();
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Waits on drawn frames until the graph rests at a gate: at most 600.
|
|
717
|
+
*
|
|
718
|
+
* @param app - The app.
|
|
719
|
+
* @returns True once it rests, false when it still does not after 600 drawn frames.
|
|
720
|
+
*/
|
|
721
|
+
async function waitForRest(app) {
|
|
722
|
+
for (let frame = 0; frame < REST_FRAMES; frame += 1) {
|
|
723
|
+
if (atRest(app.flow.state())) return true;
|
|
724
|
+
await nextFrame(app);
|
|
725
|
+
}
|
|
726
|
+
return atRest(app.flow.state());
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Restores a bookmark and waits until the graph rests at a gate, then two drawn frames more, so
|
|
730
|
+
* the screen of the gate is built and drawn.
|
|
731
|
+
*
|
|
732
|
+
* @param app - The app.
|
|
733
|
+
* @param bookmark - The bookmark to enter.
|
|
734
|
+
* @throws {Error} When the graph does not rest within 600 drawn frames.
|
|
735
|
+
*/
|
|
736
|
+
async function restoreAtRest(app, bookmark) {
|
|
737
|
+
await app.flow.restore(bookmark);
|
|
738
|
+
if (!await waitForRest(app)) throw new Error("[game] game.capture diff: the bookmark did not come to rest in 600 frames.\n Capture a bookmark taken at a gate.");
|
|
739
|
+
for (let frame = 0; frame < AFTER_REST_FRAMES; frame += 1) await nextFrame(app);
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Runs work in the state of a bookmark and comes back to where the game stood, also when the
|
|
743
|
+
* work or the restore fails.
|
|
744
|
+
*
|
|
745
|
+
* @param app - The app.
|
|
746
|
+
* @param bookmark - The bookmark to visit.
|
|
747
|
+
* @param work - What to do there.
|
|
748
|
+
* @returns What the work answered.
|
|
749
|
+
* @throws {Error} When a restore does not come to rest, and whatever the work throws.
|
|
750
|
+
*/
|
|
751
|
+
async function atBookmark(app, bookmark, work) {
|
|
752
|
+
const before = app.flow.bookmark();
|
|
753
|
+
try {
|
|
754
|
+
await restoreAtRest(app, bookmark);
|
|
755
|
+
return await work();
|
|
756
|
+
} finally {
|
|
757
|
+
await restoreAtRest(app, before);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* The diff of `game.capture`: the picture of the bookmark, taken in its state, and the picture of
|
|
762
|
+
* now drawn against it. The command journals itself first, since it replaces the state twice.
|
|
763
|
+
*
|
|
764
|
+
* @param app - The app.
|
|
765
|
+
* @param options - The options passed on: `layers` to both pictures, `legend` to the second.
|
|
766
|
+
* @param diff - The bookmark to compare with.
|
|
767
|
+
* @param raw - The bookmark as the editor sent it, for the journal.
|
|
768
|
+
* @returns The diff picture, or `undefined` while the renderer is inert.
|
|
769
|
+
* @throws {Error} When the graph is not at rest, and when a restore does not come to rest.
|
|
770
|
+
*/
|
|
771
|
+
async function captureDiff(app, options, diff, raw) {
|
|
772
|
+
if (!app.renderer.host.ready()) return void 0;
|
|
773
|
+
if (!atRest(app.flow.state())) throw new Error("[game] game.capture diff needs the game at rest.\n Wait for the gate, then capture.");
|
|
774
|
+
recordCheat(app, "game.capture", { diff: raw }, app.time.snapshot().frame);
|
|
775
|
+
const layers = options.layers === void 0 ? {} : { layers: options.layers };
|
|
776
|
+
const theirs = await atBookmark(app, diff, () => app.renderer.capture(layers));
|
|
777
|
+
return theirs === void 0 ? void 0 : app.renderer.capture({
|
|
778
|
+
...options,
|
|
779
|
+
against: theirs.png
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* A picture of the whole canvas taken at the end of the next drawn frame: `{ png }`, and
|
|
784
|
+
* `{ png, legend }` with `legend: true`. `legend`, `layers` and `sheet` go to `renderer.capture()`
|
|
785
|
+
* as they are. `diff`, a bookmark, is the one option the command runs itself: at rest, it journals
|
|
786
|
+
* itself as a raw write (`tainted` turns true), restores the bookmark and waits for its gate, takes
|
|
787
|
+
* that picture, restores where the game stood and waits again, and answers the pixel diff of now
|
|
788
|
+
* against then. While the game is paused it steps each frame it waits on, 1000/60 ms of game time.
|
|
789
|
+
* `undefined` while the renderer is inert, as in a headless test.
|
|
790
|
+
*
|
|
791
|
+
* @example
|
|
792
|
+
* ```ts
|
|
793
|
+
* // The editor attaches the numbered screen to a bug report.
|
|
794
|
+
* (await run(app, commands.capture, { legend: true })).value;
|
|
795
|
+
* // { png: "data:image/png;base64,…", legend: [{ n: 1, projection: "hud", key: "claim", rect: { x: 1140, y: 2310, w: 567, h: 164 } }] }
|
|
796
|
+
* // What changed since the start of the level: red where the pixels differ.
|
|
797
|
+
* (await run(app, commands.capture, { diff: levelStart })).value; // { png: "data:image/png;base64,…" }
|
|
798
|
+
* ```
|
|
799
|
+
*/
|
|
800
|
+
const captureCommand = defineCommand({
|
|
801
|
+
id: "game.capture",
|
|
802
|
+
title: "Capture",
|
|
803
|
+
input: {
|
|
804
|
+
legend: "boolean?",
|
|
805
|
+
layers: "json?",
|
|
806
|
+
sheet: "json?",
|
|
807
|
+
diff: "json?"
|
|
808
|
+
},
|
|
809
|
+
effect: "read",
|
|
810
|
+
run: (app, input) => {
|
|
811
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
812
|
+
const given = givenFields(input);
|
|
813
|
+
app.log.debug("moku:dev", given.length === 0 ? { command: "game.capture" } : {
|
|
814
|
+
command: "game.capture",
|
|
815
|
+
options: given
|
|
816
|
+
});
|
|
817
|
+
const options = readOptions(input);
|
|
818
|
+
if (input.diff === void 0) return app.renderer.capture(options);
|
|
819
|
+
const diff = readDiff(input.diff);
|
|
820
|
+
if (options.sheet !== void 0) throw sheetAlone();
|
|
821
|
+
return captureDiff(app, options, diff, input.diff);
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
/**
|
|
825
|
+
* Switches the debug drawing: `nineSlice` outlines every nine-slice and the lines Pixi cuts its
|
|
826
|
+
* texture at. Answers the switches as they are afterwards.
|
|
827
|
+
*
|
|
828
|
+
* @example
|
|
829
|
+
* ```ts
|
|
830
|
+
* // Check where the settings popup cuts its parchment.
|
|
831
|
+
* (await run(app, commands.debug, { nineSlice: true })).value; // { nineSlice: true }
|
|
832
|
+
* ```
|
|
833
|
+
*/
|
|
834
|
+
const debugCommand = defineCommand({
|
|
835
|
+
id: "game.debug",
|
|
836
|
+
title: "Debug drawing",
|
|
837
|
+
input: { nineSlice: "boolean" },
|
|
838
|
+
effect: "cosmetic",
|
|
839
|
+
run: (app, { nineSlice }) => {
|
|
840
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
841
|
+
app.log.debug("moku:dev", {
|
|
842
|
+
command: "game.debug",
|
|
843
|
+
nineSlice
|
|
844
|
+
});
|
|
845
|
+
app.renderer.sync.debug.nineSlice(nineSlice);
|
|
846
|
+
return app.renderer.sync.debug.state();
|
|
847
|
+
}
|
|
848
|
+
});
|
|
849
|
+
//#endregion
|
|
850
|
+
//#region src/plugins/time/control.ts
|
|
851
|
+
/**
|
|
852
|
+
* @file time plugin — the time commands of the `/control` door: step frames by hand and set the
|
|
853
|
+
* time scale. Dev builds only: every body starts with the inline dev guard, so a bundler `define`
|
|
854
|
+
* of `false` drops it, and logs the `moku:dev` marker.
|
|
855
|
+
*/
|
|
856
|
+
/** The length of one frame at 60 fps, the step a frame count takes by default. */
|
|
857
|
+
const FRAME_MS = 1e3 / 60;
|
|
858
|
+
/**
|
|
859
|
+
* Refuses a frame count that is not a whole number of zero or more.
|
|
860
|
+
*
|
|
861
|
+
* @param frames - The frame count.
|
|
862
|
+
* @throws {Error} When it is negative, fractional or not finite.
|
|
863
|
+
*/
|
|
864
|
+
function checkFrames(frames) {
|
|
865
|
+
if (Number.isInteger(frames) && frames >= 0) return;
|
|
866
|
+
throw new Error(`[game] game.step takes a whole number of frames.\n Pass 0 or a positive whole number, not ${String(frames)}.`);
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Refuses a time scale that is not a finite number of zero or more. `setScale` clamps silently;
|
|
870
|
+
* the command refuses, so an editor slider cannot pass `NaN`.
|
|
871
|
+
*
|
|
872
|
+
* @param scale - The time scale.
|
|
873
|
+
* @throws {Error} When it is negative or not finite.
|
|
874
|
+
*/
|
|
875
|
+
function checkScale(scale) {
|
|
876
|
+
if (Number.isFinite(scale) && scale >= 0) return;
|
|
877
|
+
throw new Error(`[game] game.timeScale takes a scale of 0 or more.\n Pass a finite number, not ${String(scale)}.`);
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Runs frames by hand, one `time.step` each, also while the game is paused, and answers the time
|
|
881
|
+
* after them. A frame lasts 1000/60 ms unless `deltaMs` says otherwise.
|
|
882
|
+
*
|
|
883
|
+
* @example
|
|
884
|
+
* ```ts
|
|
885
|
+
* // The editor paused the game on a merge; step three frames to watch the item land.
|
|
886
|
+
* const ran = await run(app, commands.step, { frames: 3 });
|
|
887
|
+
* ran.value; // { delta: 16.666…, elapsed: 50, scale: 1, frame: 3, idle: false }
|
|
888
|
+
* ```
|
|
889
|
+
*/
|
|
890
|
+
const stepCommand = defineCommand({
|
|
891
|
+
id: "game.step",
|
|
892
|
+
title: "Step frames",
|
|
893
|
+
input: {
|
|
894
|
+
frames: "number",
|
|
895
|
+
deltaMs: "number?"
|
|
896
|
+
},
|
|
897
|
+
effect: "cosmetic",
|
|
898
|
+
run: (app, { frames, deltaMs = FRAME_MS }) => {
|
|
899
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
900
|
+
app.log.debug("moku:dev", {
|
|
901
|
+
command: "game.step",
|
|
902
|
+
frames
|
|
903
|
+
});
|
|
904
|
+
checkFrames(frames);
|
|
905
|
+
for (let frame = 0; frame < frames; frame += 1) app.time.step(deltaMs);
|
|
906
|
+
return app.time.snapshot();
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
/**
|
|
910
|
+
* Sets the time scale and answers the time after the write. 0 freezes game time while the frames
|
|
911
|
+
* keep running; `game.step` still steps.
|
|
912
|
+
*
|
|
913
|
+
* @example
|
|
914
|
+
* ```ts
|
|
915
|
+
* // The editor slows a merge to a quarter speed to watch the item land.
|
|
916
|
+
* (await run(app, commands.timeScale, { scale: 0.25 })).value.scale; // 0.25
|
|
917
|
+
* ```
|
|
918
|
+
*/
|
|
919
|
+
const timeScaleCommand = defineCommand({
|
|
920
|
+
id: "game.timeScale",
|
|
921
|
+
title: "Time scale",
|
|
922
|
+
input: { scale: "number" },
|
|
923
|
+
effect: "cosmetic",
|
|
924
|
+
run: (app, { scale }) => {
|
|
925
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
926
|
+
app.log.debug("moku:dev", {
|
|
927
|
+
command: "game.timeScale",
|
|
928
|
+
scale
|
|
929
|
+
});
|
|
930
|
+
checkScale(scale);
|
|
931
|
+
app.time.setScale(scale);
|
|
932
|
+
return app.time.snapshot();
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
//#endregion
|
|
936
|
+
//#region src/plugins/ui/control.ts
|
|
937
|
+
/**
|
|
938
|
+
* @file ui plugin — the two ui commands of the `/control` door: a tap on a keyed element or on a
|
|
939
|
+
* view, and the text typed into a field. The tap goes through `app.input`, so the gate decides;
|
|
940
|
+
* the text goes through `app.ui.fill`, which writes a component's local and answers nothing. The
|
|
941
|
+
* session stays clean. Dev builds only: each body starts with the inline dev guard, so a bundler
|
|
942
|
+
* `define` of `false` drops it, and logs the `moku:dev` marker.
|
|
943
|
+
*/
|
|
944
|
+
/**
|
|
945
|
+
* Finds the entity of a keyed ui element on screen.
|
|
946
|
+
*
|
|
947
|
+
* @param ui - The ui API.
|
|
948
|
+
* @param key - The `key` prop of the element.
|
|
949
|
+
* @returns The entity.
|
|
950
|
+
* @throws {Error} When no element with the key is on screen.
|
|
951
|
+
*/
|
|
952
|
+
function elementOf(ui, key) {
|
|
953
|
+
const entity = ui.find(key);
|
|
954
|
+
if (entity !== void 0) return entity;
|
|
955
|
+
throw new Error(`[game] No element with the key "${key}" is on screen.\n Read sources.ui for the keys on screen.`);
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Taps a ui element by its key, or a view by its projection key: exactly one of the two. Answers
|
|
959
|
+
* what `input.tap` answers: whether the gate took the answer.
|
|
960
|
+
*
|
|
961
|
+
* @example
|
|
962
|
+
* ```ts
|
|
963
|
+
* // Home rests: tap the Play plank, then the first generator of the board.
|
|
964
|
+
* (await run(app, commands.tap, { key: "play" })).value; // true
|
|
965
|
+
* await run(app, commands.tap, { target: { projection: "board.generators", key: "g1" } });
|
|
966
|
+
* ```
|
|
967
|
+
*/
|
|
968
|
+
const tapCommand = defineCommand({
|
|
969
|
+
id: "game.tap",
|
|
970
|
+
title: "Tap",
|
|
971
|
+
input: {
|
|
972
|
+
key: "string?",
|
|
973
|
+
target: "json?"
|
|
974
|
+
},
|
|
975
|
+
effect: "route",
|
|
976
|
+
run: (app, { key, target }) => {
|
|
977
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
978
|
+
app.log.debug("moku:dev", {
|
|
979
|
+
command: "game.tap",
|
|
980
|
+
key
|
|
981
|
+
});
|
|
982
|
+
if (key !== void 0 && target === void 0) return app.input.tap(elementOf(app.ui, key));
|
|
983
|
+
if (target !== void 0 && key === void 0) return app.input.tap(readTarget(target));
|
|
984
|
+
throw new Error("[game] game.tap takes a key or a target.\n Pass exactly one of { key } and { target }.");
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
/**
|
|
988
|
+
* Types into a text field by its key, the way `app.ui.fill` does: the field becomes the one being
|
|
989
|
+
* edited, the value cut to its `maxLength` is written into it and into the local of its
|
|
990
|
+
* component. Answers what `fill` answers: false for a key that is no live `input`.
|
|
991
|
+
*
|
|
992
|
+
* @example
|
|
993
|
+
* ```ts
|
|
994
|
+
* // The Rename popup rests: type the name, then submit it with the Enter key.
|
|
995
|
+
* (await run(app, commands.fill, { key: "nameField", value: "Alex" })).value; // true
|
|
996
|
+
* await run(app, commands.key, { key: "Enter" }); // the gate takes { intent: "save", payload: { name: "Alex" } }
|
|
997
|
+
* ```
|
|
998
|
+
*/
|
|
999
|
+
const fillCommand = defineCommand({
|
|
1000
|
+
id: "game.fill",
|
|
1001
|
+
title: "Fill",
|
|
1002
|
+
input: {
|
|
1003
|
+
key: "string",
|
|
1004
|
+
value: "string"
|
|
1005
|
+
},
|
|
1006
|
+
effect: "route",
|
|
1007
|
+
run: (app, { key, value }) => {
|
|
1008
|
+
if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
|
|
1009
|
+
app.log.debug("moku:dev", {
|
|
1010
|
+
command: "game.fill",
|
|
1011
|
+
key
|
|
1012
|
+
});
|
|
1013
|
+
return app.ui.fill(key, value);
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
//#endregion
|
|
743
1017
|
//#region src/plugins/flow/control.ts
|
|
744
1018
|
/**
|
|
745
1019
|
* Enters a bookmark, or a repro's bookmark followed by its route.
|
|
@@ -896,7 +1170,9 @@ const commands = Object.freeze({
|
|
|
896
1170
|
capture: captureCommand,
|
|
897
1171
|
debug: debugCommand,
|
|
898
1172
|
reducedMotion: reducedMotionCommand,
|
|
899
|
-
fill: fillCommand
|
|
1173
|
+
fill: fillCommand,
|
|
1174
|
+
timeScale: timeScaleCommand,
|
|
1175
|
+
trace: traceCommand
|
|
900
1176
|
});
|
|
901
1177
|
//#endregion
|
|
902
1178
|
//#region src/plugins/flow/doors/run.ts
|