@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
|
@@ -0,0 +1,1183 @@
|
|
|
1
|
+
//#region src/plugins/flow/runner/journal.ts
|
|
2
|
+
/** FNV-1a 32-bit offset basis. */
|
|
3
|
+
const OFFSET_BASIS = 2166136261;
|
|
4
|
+
/** FNV-1a 32-bit prime. */
|
|
5
|
+
const PRIME = 16777619;
|
|
6
|
+
/** Separates the parts of a hashed edge, so "a|b" and "a" + "b" never collide. */
|
|
7
|
+
const SEPARATOR = "\0";
|
|
8
|
+
/**
|
|
9
|
+
* Hashes text with FNV-1a, 32 bit. Pure and deterministic: the same text always gives the same
|
|
10
|
+
* eight hexadecimal characters, in every runtime.
|
|
11
|
+
*
|
|
12
|
+
* @param text - The text to hash.
|
|
13
|
+
* @returns Eight lowercase hexadecimal characters.
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* hashText("board/merge"); // "53ceca70"
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
function hashText(text) {
|
|
20
|
+
let hash = OFFSET_BASIS;
|
|
21
|
+
for (const character of text) hash = Math.imul(hash ^ (character.codePointAt(0) ?? 0), PRIME);
|
|
22
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Hashes one edge, so a replay against edited code fails loudly in dev.
|
|
26
|
+
*
|
|
27
|
+
* @param path - Path of the node that produced the outcome.
|
|
28
|
+
* @param outcome - Outcome name.
|
|
29
|
+
* @param next - Rendered edge target.
|
|
30
|
+
* @returns The digest of the three parts together.
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* entryHash("board/merge", "done", "board/awaitIntent"); // "e881176b"
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
function entryHash(path, outcome, next) {
|
|
37
|
+
return hashText([
|
|
38
|
+
path,
|
|
39
|
+
outcome,
|
|
40
|
+
next
|
|
41
|
+
].join(SEPARATOR));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Appends an entry with the next index and its hash, and drops the oldest entries above the limit.
|
|
45
|
+
* The index keeps counting while entries are dropped, so two entries never share a number.
|
|
46
|
+
*
|
|
47
|
+
* @param state - Runner state that holds the journal.
|
|
48
|
+
* @param entry - The taken edge without `index` and `hash`.
|
|
49
|
+
* @param limit - Journal entries kept between checkpoints.
|
|
50
|
+
* @returns The stored entry.
|
|
51
|
+
*/
|
|
52
|
+
function pushEntry(state, entry, limit) {
|
|
53
|
+
const stored = {
|
|
54
|
+
...entry,
|
|
55
|
+
index: state.journalIndex,
|
|
56
|
+
hash: entryHash(entry.path, entry.outcome, entry.next)
|
|
57
|
+
};
|
|
58
|
+
state.journalIndex += 1;
|
|
59
|
+
state.journal.push(stored);
|
|
60
|
+
if (state.journal.length > limit) state.journal.splice(0, state.journal.length - limit);
|
|
61
|
+
return stored;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Clears the journal at a checkpoint. The running index is kept, and so is the array itself: a
|
|
65
|
+
* reader that holds `history()` sees the compaction.
|
|
66
|
+
*
|
|
67
|
+
* @param state - Runner state that holds the journal.
|
|
68
|
+
*/
|
|
69
|
+
function compact(state) {
|
|
70
|
+
state.journal.length = 0;
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/plugins/flow/runner/registry.ts
|
|
74
|
+
/**
|
|
75
|
+
* Creates the empty flow map. It lives in its own function because the plugin's lint rule L5
|
|
76
|
+
* refuses a collection built inside an exported declaration.
|
|
77
|
+
*
|
|
78
|
+
* @returns An empty map of flow id to flow.
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* emptyFlows().size; // 0
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
function emptyFlows() {
|
|
85
|
+
return /* @__PURE__ */ new Map();
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Adds a flow and every flow it reaches by reference to the map. The first flow under an id wins,
|
|
89
|
+
* so a collision is visible to `validateGraph` instead of silently replacing a flow.
|
|
90
|
+
*
|
|
91
|
+
* @param flow - The flow to add.
|
|
92
|
+
* @param flows - The map being filled.
|
|
93
|
+
*/
|
|
94
|
+
function addFlow(flow, flows) {
|
|
95
|
+
if (flows.has(flow.id)) return;
|
|
96
|
+
flows.set(flow.id, flow);
|
|
97
|
+
for (const entry of Object.values(flow.nodes)) if (entry.kind === "flow") addFlow(entry, flows);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Collects every flow reachable from the main flow by reference, plus the registered extras.
|
|
101
|
+
* The main flow is the first entry: `describeGraph` reads it as the entry point of the graph.
|
|
102
|
+
*
|
|
103
|
+
* @param main - The top-level flow.
|
|
104
|
+
* @param extra - Flows added with `register`, not reachable by reference.
|
|
105
|
+
* @returns Flow id to flow, main first.
|
|
106
|
+
* @example
|
|
107
|
+
* ```ts
|
|
108
|
+
* [...collectFlows(mainFlow, []).keys()]; // ["main", "board"]: the main flow first
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
function collectFlows(main, extra) {
|
|
112
|
+
const flows = emptyFlows();
|
|
113
|
+
addFlow(main, flows);
|
|
114
|
+
for (const flow of extra) addFlow(flow, flows);
|
|
115
|
+
return flows;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Renders a position as a path: the node of every frame, outermost first.
|
|
119
|
+
*
|
|
120
|
+
* @param stack - One frame per nesting level.
|
|
121
|
+
* @returns The path, for example `"board/awaitIntent"`; empty without a position.
|
|
122
|
+
* @example
|
|
123
|
+
* ```ts
|
|
124
|
+
* const board = { flow: "main", node: "board", input: null };
|
|
125
|
+
* framePath([board, { flow: "board", node: "merge", input: null }]); // "board/merge"
|
|
126
|
+
* ```
|
|
127
|
+
*/
|
|
128
|
+
function framePath(stack) {
|
|
129
|
+
return stack.map((frame) => frame.node).join("/");
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Picks the flow a path continues in after one entry: the sub-flow itself, or, behind a slot, the
|
|
133
|
+
* first contribution in order that has the next segment as a node. A path keeps node names only,
|
|
134
|
+
* so two contributions of one slot with the same node name cannot be told apart; `validateGraph`
|
|
135
|
+
* warns about that.
|
|
136
|
+
*
|
|
137
|
+
* @param entry - The entry the path just passed.
|
|
138
|
+
* @param next - The next segment of the path.
|
|
139
|
+
* @param contributionsOf - Reads the contributions of a slot, in order.
|
|
140
|
+
* @returns The flow to go on in, or `undefined` when the path ends here.
|
|
141
|
+
*/
|
|
142
|
+
function flowBehind(entry, next, contributionsOf) {
|
|
143
|
+
if (entry.kind === "flow") return entry;
|
|
144
|
+
if (entry.kind !== "slot") return void 0;
|
|
145
|
+
return contributionsOf(entry.name).find((item) => Object.hasOwn(item.flow.nodes, next))?.flow;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Reads no contributions: the default of `findNode` for a caller that has no features API.
|
|
149
|
+
*
|
|
150
|
+
* @returns An empty list.
|
|
151
|
+
* @example
|
|
152
|
+
* ```ts
|
|
153
|
+
* noContributions(); // []
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
function noContributions() {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Finds the node, sub-flow or slot a path names, starting at the main flow. A path descends one
|
|
161
|
+
* sub-flow per segment, and through a slot into the contribution that has the next segment; a
|
|
162
|
+
* segment after a plain node finds nothing.
|
|
163
|
+
*
|
|
164
|
+
* @param main - The top-level flow.
|
|
165
|
+
* @param path - A path as `framePath` renders it.
|
|
166
|
+
* @param contributionsOf - Reads the contributions of a slot, in order. Without it a path ends at
|
|
167
|
+
* a slot.
|
|
168
|
+
* @returns Where the path leads, or `undefined` when no such node exists.
|
|
169
|
+
* @example
|
|
170
|
+
* ```ts
|
|
171
|
+
* findNode(mainFlow, "board/awaitIntent")?.trail;
|
|
172
|
+
* // [{ flow: "main", node: "board" }, { flow: "board", node: "awaitIntent" }]
|
|
173
|
+
* findNode(mainFlow, "afterOrder/show", () => [{ flow: rewardFlow }])?.trail;
|
|
174
|
+
* // [{ flow: "main", node: "afterOrder" }, { flow: "rewardPopup", node: "show" }]
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
function findNode(main, path, contributionsOf = noContributions) {
|
|
178
|
+
const names = path.split("/").filter(Boolean);
|
|
179
|
+
const trail = [];
|
|
180
|
+
let flow = main;
|
|
181
|
+
for (const [step, name] of names.entries()) {
|
|
182
|
+
const entry = flow.nodes[name];
|
|
183
|
+
if (!entry) return void 0;
|
|
184
|
+
trail.push({
|
|
185
|
+
flow: flow.id,
|
|
186
|
+
node: name
|
|
187
|
+
});
|
|
188
|
+
if (step === names.length - 1) return {
|
|
189
|
+
flow,
|
|
190
|
+
name,
|
|
191
|
+
entry,
|
|
192
|
+
trail
|
|
193
|
+
};
|
|
194
|
+
const behind = flowBehind(entry, names[step + 1] ?? "", contributionsOf);
|
|
195
|
+
if (behind === void 0) return void 0;
|
|
196
|
+
flow = behind;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Renders an edge target as a string: a node name, `"exit:win"` or `"map:node"`.
|
|
201
|
+
*
|
|
202
|
+
* @param target - The target as the edge table holds it.
|
|
203
|
+
* @returns The rendered target.
|
|
204
|
+
* @example
|
|
205
|
+
* ```ts
|
|
206
|
+
* renderTarget("home"); // "home"
|
|
207
|
+
* renderTarget({ kind: "exit", outcome: "win" }); // "exit:win"
|
|
208
|
+
* ```
|
|
209
|
+
*/
|
|
210
|
+
function renderTarget(target) {
|
|
211
|
+
if (typeof target === "string") return target;
|
|
212
|
+
return target.kind === "exit" ? `exit:${target.outcome}` : `map:${target.target}`;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Renders the edge table of one flow.
|
|
216
|
+
*
|
|
217
|
+
* @param flow - The flow to render.
|
|
218
|
+
* @returns Node name to outcome name to rendered target.
|
|
219
|
+
* @example
|
|
220
|
+
* ```ts
|
|
221
|
+
* renderEdges(boardFlow).awaitIntent?.leave; // "exit:left"
|
|
222
|
+
* ```
|
|
223
|
+
*/
|
|
224
|
+
function renderEdges(flow) {
|
|
225
|
+
const edges = {};
|
|
226
|
+
for (const [name, row] of Object.entries(flow.edges)) {
|
|
227
|
+
const rendered = {};
|
|
228
|
+
for (const [outcome, target] of Object.entries(row)) rendered[outcome] = renderTarget(target);
|
|
229
|
+
edges[name] = rendered;
|
|
230
|
+
}
|
|
231
|
+
return edges;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Maps every node and flow a feature brought to the name of that feature.
|
|
235
|
+
*
|
|
236
|
+
* @param features - Features API.
|
|
237
|
+
* @returns Node or flow object to feature name.
|
|
238
|
+
*/
|
|
239
|
+
function collectOwners(features) {
|
|
240
|
+
const owners = /* @__PURE__ */ new Map();
|
|
241
|
+
for (const { name, description } of features.all()) {
|
|
242
|
+
for (const node of description.nodes ?? []) owners.set(node, name);
|
|
243
|
+
for (const flow of description.flows ?? []) owners.set(flow, name);
|
|
244
|
+
}
|
|
245
|
+
return owners;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Describes one entry of a flow: its flags, its scene, its outcome names, and the slot, sub-flow
|
|
249
|
+
* or owning feature when it has one.
|
|
250
|
+
*
|
|
251
|
+
* @param flow - The flow that holds the entry.
|
|
252
|
+
* @param name - Name of the entry inside that flow.
|
|
253
|
+
* @param entry - The node, sub-flow or slot.
|
|
254
|
+
* @param owner - Name of the feature that brought it, when a feature did.
|
|
255
|
+
* @returns The entry as JSON.
|
|
256
|
+
* @example
|
|
257
|
+
* ```ts
|
|
258
|
+
* describeNode(mainFlow, "home", home, undefined).rest; // true
|
|
259
|
+
* describeNode(mainFlow, "board", boardFlow, "board").subFlow; // "board"
|
|
260
|
+
* ```
|
|
261
|
+
*/
|
|
262
|
+
function describeNode(flow, name, entry, owner) {
|
|
263
|
+
const node = entry.kind === "node" ? entry : void 0;
|
|
264
|
+
return {
|
|
265
|
+
flow: flow.id,
|
|
266
|
+
node: name,
|
|
267
|
+
rest: node?.rest ?? false,
|
|
268
|
+
over: node?.over ?? false,
|
|
269
|
+
checkpoint: node?.checkpoint ?? false,
|
|
270
|
+
barrier: node?.barrier ?? false,
|
|
271
|
+
outcomes: Object.keys(entry.outcomes),
|
|
272
|
+
...node?.scene === void 0 ? {} : { scene: node.scene },
|
|
273
|
+
...entry.kind === "slot" ? { slot: entry.name } : {},
|
|
274
|
+
...entry.kind === "flow" ? { subFlow: entry.id } : {},
|
|
275
|
+
...owner === void 0 ? {} : { owner }
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Describes every entry of one flow.
|
|
280
|
+
*
|
|
281
|
+
* @param flow - The flow to describe.
|
|
282
|
+
* @param owners - Node or flow object to feature name.
|
|
283
|
+
* @returns Node name to description.
|
|
284
|
+
* @example
|
|
285
|
+
* ```ts
|
|
286
|
+
* describeNodes(boardFlow, new Map()).merge?.outcomes; // ["done", "rejected"]
|
|
287
|
+
* ```
|
|
288
|
+
*/
|
|
289
|
+
function describeNodes(flow, owners) {
|
|
290
|
+
const nodes = {};
|
|
291
|
+
for (const [name, entry] of Object.entries(flow.nodes)) nodes[name] = describeNode(flow, name, entry, owners.get(entry) ?? owners.get(flow));
|
|
292
|
+
return nodes;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Collects the slot names of every flow of the graph. `describe()` and `validate` both read the
|
|
296
|
+
* contributions of exactly these slots.
|
|
297
|
+
*
|
|
298
|
+
* @param flows - Every collected flow by id.
|
|
299
|
+
* @returns The slot names, in the order the graph declares them, each once.
|
|
300
|
+
* @example
|
|
301
|
+
* ```ts
|
|
302
|
+
* slotNames(collectFlows(mainFlow, [])); // ["afterOrder"]
|
|
303
|
+
* ```
|
|
304
|
+
*/
|
|
305
|
+
function slotNames(flows) {
|
|
306
|
+
const names = [];
|
|
307
|
+
for (const flow of flows.values()) for (const entry of Object.values(flow.nodes)) if (entry.kind === "slot" && !names.includes(entry.name)) names.push(entry.name);
|
|
308
|
+
return names;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Renders the whole graph as JSON without running the game: nodes, flags, outcomes, edges,
|
|
312
|
+
* slots and who contributed. Edge targets become `"node"`, `"exit:win"`, `"map:node"`.
|
|
313
|
+
*
|
|
314
|
+
* @param flows - Every collected flow by id, the main flow first, as `collectFlows` returns them.
|
|
315
|
+
* @param features - Features API: owners and slot contributions.
|
|
316
|
+
* @returns The graph as plain JSON.
|
|
317
|
+
*/
|
|
318
|
+
function describeGraph(flows, features) {
|
|
319
|
+
const owners = collectOwners(features);
|
|
320
|
+
const described = {};
|
|
321
|
+
const slots = {};
|
|
322
|
+
for (const flow of flows.values()) described[flow.id] = {
|
|
323
|
+
nodes: describeNodes(flow, owners),
|
|
324
|
+
start: flow.start,
|
|
325
|
+
edges: renderEdges(flow)
|
|
326
|
+
};
|
|
327
|
+
for (const name of slotNames(flows)) slots[name] = features.contributions(name).map(({ feature, flow, order }) => ({
|
|
328
|
+
feature,
|
|
329
|
+
flow: flow.id,
|
|
330
|
+
order
|
|
331
|
+
}));
|
|
332
|
+
return {
|
|
333
|
+
main: [...flows.keys()][0] ?? "",
|
|
334
|
+
flows: described,
|
|
335
|
+
slots
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Compares two object keys without a locale, so the rendering is the same everywhere.
|
|
340
|
+
*
|
|
341
|
+
* @param first - First key.
|
|
342
|
+
* @param second - Second key.
|
|
343
|
+
* @returns A negative number, zero or a positive number.
|
|
344
|
+
* @example
|
|
345
|
+
* ```ts
|
|
346
|
+
* compareKeys("edges", "nodes"); // -1
|
|
347
|
+
* ```
|
|
348
|
+
*/
|
|
349
|
+
function compareKeys(first, second) {
|
|
350
|
+
if (first === second) return 0;
|
|
351
|
+
return first < second ? -1 : 1;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Renders a JSON value with its object keys sorted, so two equal graphs written in a different
|
|
355
|
+
* order hash the same.
|
|
356
|
+
*
|
|
357
|
+
* @param value - Any JSON value.
|
|
358
|
+
* @returns The value as text.
|
|
359
|
+
* @example
|
|
360
|
+
* ```ts
|
|
361
|
+
* stableJson({ b: 1, a: 2 }); // '{"a":2,"b":1}'
|
|
362
|
+
* ```
|
|
363
|
+
*/
|
|
364
|
+
function stableJson(value) {
|
|
365
|
+
if (Array.isArray(value)) return `[${value.map((item) => stableJson(item)).join(",")}]`;
|
|
366
|
+
if (typeof value !== "object" || !value) return JSON.stringify(value) ?? "";
|
|
367
|
+
return `{${Object.entries(value).filter(([, item]) => item !== void 0).toSorted(([first], [second]) => compareKeys(first, second)).map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Hashes a graph description. A bookmark of a plain rest node is accepted only while this hash
|
|
371
|
+
* is unchanged.
|
|
372
|
+
*
|
|
373
|
+
* @param graph - Result of `describeGraph`.
|
|
374
|
+
* @returns Eight lowercase hexadecimal characters.
|
|
375
|
+
* @example
|
|
376
|
+
* ```ts
|
|
377
|
+
* graphHash({ main: "main", flows: {}, slots: {} }); // "cfb16c03"
|
|
378
|
+
* ```
|
|
379
|
+
*/
|
|
380
|
+
function graphHash(graph) {
|
|
381
|
+
return hashText(stableJson(graph));
|
|
382
|
+
}
|
|
383
|
+
//#endregion
|
|
384
|
+
//#region src/plugins/renderer/monitor/picture.ts
|
|
385
|
+
/** Size of the badge number in CSS pixels; the picture draws it times the resolution. */
|
|
386
|
+
const BADGE_FONT_PX = 12;
|
|
387
|
+
/** Padding around the badge number, in picture pixels. */
|
|
388
|
+
const BADGE_PADDING = 2;
|
|
389
|
+
/** The largest sheet width the cells are scaled down to, before the gutters. */
|
|
390
|
+
const SHEET_MAX_WIDTH = 2048;
|
|
391
|
+
/** Gap around and between the cells of a sheet, in picture pixels. */
|
|
392
|
+
const SHEET_GUTTER = 8;
|
|
393
|
+
/** Background of a sheet, shown in the gutters. */
|
|
394
|
+
const SHEET_BACKGROUND = "#202020";
|
|
395
|
+
/** The largest channel delta that still counts as the same pixel: the visual runner's default. */
|
|
396
|
+
const PIXEL_THRESHOLD = 24;
|
|
397
|
+
/** How much of the way to white a matching grey pixel keeps: `255 - (255 - grey) × 0.4`. */
|
|
398
|
+
const FADE_KEEP = .4;
|
|
399
|
+
/** Bytes of one pixel: red, green, blue and alpha. */
|
|
400
|
+
const CHANNELS = 4;
|
|
401
|
+
/** The alpha of a pixel no one sees through: every pixel of the diff picture. */
|
|
402
|
+
const OPAQUE = 255;
|
|
403
|
+
/** The value of a channel in white, where a matching pixel fades to. */
|
|
404
|
+
const WHITE = 255;
|
|
405
|
+
/** A pixel that differs, on the diff picture: opaque `#ff0000`. */
|
|
406
|
+
const DIFF_PIXEL = [
|
|
407
|
+
255,
|
|
408
|
+
0,
|
|
409
|
+
0,
|
|
410
|
+
OPAQUE
|
|
411
|
+
];
|
|
412
|
+
/** The Rec. 601 luma weights: how much red, green and blue make the grey of a pixel. */
|
|
413
|
+
const LUMA = {
|
|
414
|
+
red: .299,
|
|
415
|
+
green: .587,
|
|
416
|
+
blue: .114
|
|
417
|
+
};
|
|
418
|
+
/** Bytes encoded per `String.fromCodePoint` call, well under the argument limit of an engine. */
|
|
419
|
+
const ENCODE_CHUNK = 32768;
|
|
420
|
+
/**
|
|
421
|
+
* Builds the error of two pictures that cannot be compared.
|
|
422
|
+
*
|
|
423
|
+
* @returns The error, ready to throw.
|
|
424
|
+
* @example
|
|
425
|
+
* ```ts
|
|
426
|
+
* differentSizes().message.startsWith("[game] game.capture: the pictures differ in size."); // true
|
|
427
|
+
* ```
|
|
428
|
+
*/
|
|
429
|
+
function differentSizes() {
|
|
430
|
+
return /* @__PURE__ */ new Error("[game] game.capture: the pictures differ in size.\n Capture both at the same canvas size.");
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Brings a box in reference units onto the picture: both corners through `toScreen`, the canvas
|
|
434
|
+
* origin taken off, times the resolution, rounded to whole pixels.
|
|
435
|
+
*
|
|
436
|
+
* @param box - The box in reference units.
|
|
437
|
+
* @param toScreen - The viewport map from reference units to client CSS pixels.
|
|
438
|
+
* @param origin - The top-left corner of the canvas on the page, in CSS pixels.
|
|
439
|
+
* @param resolution - Picture pixels per CSS pixel.
|
|
440
|
+
* @returns The rect in picture pixels.
|
|
441
|
+
* @example
|
|
442
|
+
* ```ts
|
|
443
|
+
* pictureRect({ x: 570, y: 1155, width: 283.5, height: 82 }, point => point, { x: 0, y: 0 }, 2);
|
|
444
|
+
* // { x: 1140, y: 2310, w: 567, h: 164 }
|
|
445
|
+
* ```
|
|
446
|
+
*/
|
|
447
|
+
function pictureRect(box, toScreen, origin, resolution) {
|
|
448
|
+
const topLeft = toScreen({
|
|
449
|
+
x: box.x,
|
|
450
|
+
y: box.y
|
|
451
|
+
});
|
|
452
|
+
const bottomRight = toScreen({
|
|
453
|
+
x: box.x + box.width,
|
|
454
|
+
y: box.y + box.height
|
|
455
|
+
});
|
|
456
|
+
return {
|
|
457
|
+
x: Math.round((topLeft.x - origin.x) * resolution),
|
|
458
|
+
y: Math.round((topLeft.y - origin.y) * resolution),
|
|
459
|
+
w: Math.round((bottomRight.x - topLeft.x) * resolution),
|
|
460
|
+
h: Math.round((bottomRight.y - topLeft.y) * resolution)
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Numbers the views of a legend: sorted by the top edge of their rect, then the left edge, and
|
|
465
|
+
* numbered from 1 in that order.
|
|
466
|
+
*
|
|
467
|
+
* @param found - The views with their address and rect.
|
|
468
|
+
* @returns The numbered entries, a fresh array.
|
|
469
|
+
* @example
|
|
470
|
+
* ```ts
|
|
471
|
+
* numberLegend([{ projection: "hud", key: "claim", rect: { x: 1140, y: 2310, w: 567, h: 164 } }])[0]?.n; // 1
|
|
472
|
+
* ```
|
|
473
|
+
*/
|
|
474
|
+
function numberLegend(found) {
|
|
475
|
+
return found.toSorted((left, right) => left.rect.y - right.rect.y || left.rect.x - right.rect.x).map((entry, index) => ({
|
|
476
|
+
n: index + 1,
|
|
477
|
+
...entry
|
|
478
|
+
}));
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Draws one badge: the label in white on a black box, its top-left corner at the point.
|
|
482
|
+
*
|
|
483
|
+
* @param context - The 2D context of the picture.
|
|
484
|
+
* @param label - The text, a number.
|
|
485
|
+
* @param at - The top-left corner, in picture pixels.
|
|
486
|
+
* @param resolution - Picture pixels per CSS pixel; the label is 12 CSS pixels high.
|
|
487
|
+
*/
|
|
488
|
+
function drawBadge(context, label, at, resolution) {
|
|
489
|
+
const size = BADGE_FONT_PX * resolution;
|
|
490
|
+
context.font = `${String(size)}px sans-serif`;
|
|
491
|
+
context.textBaseline = "top";
|
|
492
|
+
const width = context.measureText(label).width;
|
|
493
|
+
context.fillStyle = "#000000";
|
|
494
|
+
context.fillRect(at.x, at.y, width + 4, size + 4);
|
|
495
|
+
context.fillStyle = "#ffffff";
|
|
496
|
+
context.fillText(label, at.x + BADGE_PADDING, at.y + BADGE_PADDING);
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Draws the badge of every legend entry at the top-left corner of its rect, in legend order.
|
|
500
|
+
*
|
|
501
|
+
* @param context - The 2D context of the picture.
|
|
502
|
+
* @param legend - The numbered entries.
|
|
503
|
+
* @param resolution - Picture pixels per CSS pixel.
|
|
504
|
+
*/
|
|
505
|
+
function drawLegend(context, legend, resolution) {
|
|
506
|
+
for (const entry of legend) drawBadge(context, String(entry.n), entry.rect, resolution);
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Lays a contact sheet out: `ceil(sqrt(frames))` columns, as many rows as needed, every cell the
|
|
510
|
+
* picture scaled by `min(1, 2048 / (columns × width))` with its aspect kept, an 8 px gutter around
|
|
511
|
+
* and between the cells, left to right and top to bottom.
|
|
512
|
+
*
|
|
513
|
+
* @param frames - How many pictures, 2 to 12.
|
|
514
|
+
* @param width - Width of one picture, in pixels.
|
|
515
|
+
* @param height - Height of one picture, in pixels.
|
|
516
|
+
* @returns The grid, the scale, the size of the sheet and the rect of every cell.
|
|
517
|
+
* @example
|
|
518
|
+
* ```ts
|
|
519
|
+
* sheetLayout(5, 400, 300).cells[3]; // { x: 8, y: 316, w: 400, h: 300 }
|
|
520
|
+
* ```
|
|
521
|
+
*/
|
|
522
|
+
function sheetLayout(frames, width, height) {
|
|
523
|
+
const columns = Math.ceil(Math.sqrt(frames));
|
|
524
|
+
const rows = Math.ceil(frames / columns);
|
|
525
|
+
const scale = Math.min(1, SHEET_MAX_WIDTH / (columns * width));
|
|
526
|
+
const w = Math.max(1, Math.round(width * scale));
|
|
527
|
+
const h = Math.max(1, Math.round(height * scale));
|
|
528
|
+
const cells = Array.from({ length: frames }, (_, index) => ({
|
|
529
|
+
x: SHEET_GUTTER + index % columns * (w + SHEET_GUTTER),
|
|
530
|
+
y: SHEET_GUTTER + Math.floor(index / columns) * (h + SHEET_GUTTER),
|
|
531
|
+
w,
|
|
532
|
+
h
|
|
533
|
+
}));
|
|
534
|
+
return {
|
|
535
|
+
columns,
|
|
536
|
+
rows,
|
|
537
|
+
scale,
|
|
538
|
+
width: columns * w + (columns + 1) * SHEET_GUTTER,
|
|
539
|
+
height: rows * h + (rows + 1) * SHEET_GUTTER,
|
|
540
|
+
cells
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* One byte of a picture, 0 past its end.
|
|
545
|
+
*
|
|
546
|
+
* @param data - The RGBA bytes.
|
|
547
|
+
* @param at - The index of the byte.
|
|
548
|
+
* @returns The byte.
|
|
549
|
+
* @example
|
|
550
|
+
* ```ts
|
|
551
|
+
* byteAt(new Uint8ClampedArray([10, 20, 30, 255]), 1); // 20
|
|
552
|
+
* ```
|
|
553
|
+
*/
|
|
554
|
+
function byteAt(data, at) {
|
|
555
|
+
return data[at] ?? 0;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Tells whether two pixels differ: any RGBA channel by more than 24.
|
|
559
|
+
*
|
|
560
|
+
* @param current - The bytes of the current picture.
|
|
561
|
+
* @param earlier - The bytes of the earlier picture.
|
|
562
|
+
* @param at - The index of the pixel's first byte.
|
|
563
|
+
* @returns True when the pixels differ.
|
|
564
|
+
* @example
|
|
565
|
+
* ```ts
|
|
566
|
+
* pixelDiffers(new Uint8ClampedArray([125, 0, 0, 255]), new Uint8ClampedArray([100, 0, 0, 255]), 0); // true
|
|
567
|
+
* ```
|
|
568
|
+
*/
|
|
569
|
+
function pixelDiffers(current, earlier, at) {
|
|
570
|
+
for (let channel = at; channel < at + CHANNELS; channel += 1) if (Math.abs(byteAt(current, channel) - byteAt(earlier, channel)) > PIXEL_THRESHOLD) return true;
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* The grey of a pixel: its red, green and blue by the luma weights.
|
|
575
|
+
*
|
|
576
|
+
* @param data - The RGBA bytes.
|
|
577
|
+
* @param at - The index of the pixel's first byte.
|
|
578
|
+
* @returns The grey, 0 to 255.
|
|
579
|
+
* @example
|
|
580
|
+
* ```ts
|
|
581
|
+
* greyOf(new Uint8ClampedArray([10, 20, 30, 255]), 0); // 18.15
|
|
582
|
+
* ```
|
|
583
|
+
*/
|
|
584
|
+
function greyOf(data, at) {
|
|
585
|
+
return LUMA.red * byteAt(data, at) + LUMA.green * byteAt(data, at + 1) + LUMA.blue * byteAt(data, at + 2);
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Compares two pictures pixel by pixel into the diff picture: `#ff0000` where a pixel differs,
|
|
589
|
+
* the current pixel faded elsewhere (its grey, then `255 - (255 - grey) × 0.4`), always opaque.
|
|
590
|
+
*
|
|
591
|
+
* @param current - The current picture.
|
|
592
|
+
* @param earlier - The earlier picture.
|
|
593
|
+
* @returns The RGBA bytes of the diff picture.
|
|
594
|
+
* @throws {Error} When the two pictures differ in size.
|
|
595
|
+
* @example
|
|
596
|
+
* ```ts
|
|
597
|
+
* const grey = { width: 1, height: 1, data: new Uint8ClampedArray([10, 20, 30, 255]) };
|
|
598
|
+
* [...diffPixels(grey, grey)]; // [160, 160, 160, 255]
|
|
599
|
+
* ```
|
|
600
|
+
*/
|
|
601
|
+
function diffPixels(current, earlier) {
|
|
602
|
+
if (current.width !== earlier.width || current.height !== earlier.height) throw differentSizes();
|
|
603
|
+
const out = new Uint8ClampedArray(current.width * current.height * CHANNELS);
|
|
604
|
+
const now = current.data;
|
|
605
|
+
for (let at = 0; at < out.length; at += CHANNELS) {
|
|
606
|
+
if (pixelDiffers(now, earlier.data, at)) {
|
|
607
|
+
out.set(DIFF_PIXEL, at);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
const faded = WHITE - (WHITE - greyOf(now, at)) * FADE_KEEP;
|
|
611
|
+
out.set([
|
|
612
|
+
faded,
|
|
613
|
+
faded,
|
|
614
|
+
faded,
|
|
615
|
+
OPAQUE
|
|
616
|
+
], at);
|
|
617
|
+
}
|
|
618
|
+
return out;
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Decodes a PNG data URL into a bitmap a 2D context draws.
|
|
622
|
+
*
|
|
623
|
+
* @param url - The `data:image/png;base64,…` URL.
|
|
624
|
+
* @returns The bitmap.
|
|
625
|
+
*/
|
|
626
|
+
async function decodePicture(url) {
|
|
627
|
+
const binary = atob(url.slice(url.indexOf(",") + 1));
|
|
628
|
+
const bytes = Uint8Array.from(binary, (char) => char.codePointAt(0) ?? 0);
|
|
629
|
+
return createImageBitmap(new Blob([bytes], { type: "image/png" }));
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Encodes a canvas as a PNG data URL.
|
|
633
|
+
*
|
|
634
|
+
* @param canvas - The canvas.
|
|
635
|
+
* @returns The `data:image/png;base64,…` URL.
|
|
636
|
+
*/
|
|
637
|
+
async function encodePicture(canvas) {
|
|
638
|
+
const blob = await canvas.convertToBlob({ type: "image/png" });
|
|
639
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
640
|
+
let binary = "";
|
|
641
|
+
for (let start = 0; start < bytes.length; start += ENCODE_CHUNK) binary += String.fromCodePoint(...bytes.subarray(start, start + ENCODE_CHUNK));
|
|
642
|
+
return `data:image/png;base64,${btoa(binary)}`;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Makes a canvas and its 2D context.
|
|
646
|
+
*
|
|
647
|
+
* @param width - Width in pixels.
|
|
648
|
+
* @param height - Height in pixels.
|
|
649
|
+
* @returns The canvas and the context.
|
|
650
|
+
* @throws {Error} When the browser gives no 2D context.
|
|
651
|
+
*/
|
|
652
|
+
function canvasOf(width, height) {
|
|
653
|
+
const canvas = new OffscreenCanvas(width, height);
|
|
654
|
+
const context = canvas.getContext("2d");
|
|
655
|
+
if (context === null) throw new Error("[game] game.capture got no 2D context from OffscreenCanvas.\n Capture in a browser that draws 2D on an OffscreenCanvas.");
|
|
656
|
+
return {
|
|
657
|
+
canvas,
|
|
658
|
+
context
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Draws the badges of a legend on a picture.
|
|
663
|
+
*
|
|
664
|
+
* @param png - The picture.
|
|
665
|
+
* @param legend - The numbered entries, measured on the frame of the picture.
|
|
666
|
+
* @param resolution - Picture pixels per CSS pixel.
|
|
667
|
+
* @returns The PNG data URL of the numbered picture.
|
|
668
|
+
*/
|
|
669
|
+
async function legendPicture(png, legend, resolution) {
|
|
670
|
+
const bitmap = await decodePicture(png);
|
|
671
|
+
const { canvas, context } = canvasOf(bitmap.width, bitmap.height);
|
|
672
|
+
context.drawImage(bitmap, 0, 0);
|
|
673
|
+
drawLegend(context, legend, resolution);
|
|
674
|
+
return encodePicture(canvas);
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Lays several pictures of one canvas out on a contact sheet, each with its frame number. The
|
|
678
|
+
* first picture gives the size of a cell.
|
|
679
|
+
*
|
|
680
|
+
* @param pictures - The PNG data URLs, in frame order.
|
|
681
|
+
* @param resolution - Picture pixels per CSS pixel, for the badges.
|
|
682
|
+
* @returns The PNG data URL of the sheet.
|
|
683
|
+
*/
|
|
684
|
+
async function sheetPicture(pictures, resolution) {
|
|
685
|
+
const [head, ...tail] = pictures;
|
|
686
|
+
const first = await decodePicture(head);
|
|
687
|
+
const bitmaps = [first, ...await Promise.all(tail.map((png) => decodePicture(png)))];
|
|
688
|
+
const layout = sheetLayout(bitmaps.length, first.width, first.height);
|
|
689
|
+
const { canvas, context } = canvasOf(layout.width, layout.height);
|
|
690
|
+
context.fillStyle = SHEET_BACKGROUND;
|
|
691
|
+
context.fillRect(0, 0, layout.width, layout.height);
|
|
692
|
+
for (const [index, bitmap] of bitmaps.entries()) {
|
|
693
|
+
const cell = layout.cells[index];
|
|
694
|
+
if (cell === void 0) continue;
|
|
695
|
+
context.drawImage(bitmap, cell.x, cell.y, cell.w, cell.h);
|
|
696
|
+
drawBadge(context, String(index + 1), cell, resolution);
|
|
697
|
+
}
|
|
698
|
+
return encodePicture(canvas);
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Reads the pixels of a bitmap through a canvas of its size.
|
|
702
|
+
*
|
|
703
|
+
* @param context - A 2D context as large as the bitmap.
|
|
704
|
+
* @param bitmap - The bitmap.
|
|
705
|
+
* @returns Its pixels.
|
|
706
|
+
*/
|
|
707
|
+
function pixelsOf(context, bitmap) {
|
|
708
|
+
context.clearRect(0, 0, bitmap.width, bitmap.height);
|
|
709
|
+
context.drawImage(bitmap, 0, 0);
|
|
710
|
+
return context.getImageData(0, 0, bitmap.width, bitmap.height);
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Draws the pixel diff of the current picture against an earlier one, and the badges of the
|
|
714
|
+
* current frame's legend over it when there is one.
|
|
715
|
+
*
|
|
716
|
+
* @param png - The current picture.
|
|
717
|
+
* @param against - The earlier picture.
|
|
718
|
+
* @param legend - The legend of the current frame, or `undefined`.
|
|
719
|
+
* @param resolution - Picture pixels per CSS pixel.
|
|
720
|
+
* @returns The PNG data URL of the diff picture.
|
|
721
|
+
* @throws {Error} When the two pictures differ in size.
|
|
722
|
+
*/
|
|
723
|
+
async function diffPicture(png, against, legend, resolution) {
|
|
724
|
+
const [current, earlier] = await Promise.all([decodePicture(png), decodePicture(against)]);
|
|
725
|
+
if (current.width !== earlier.width || current.height !== earlier.height) throw differentSizes();
|
|
726
|
+
const { canvas, context } = canvasOf(current.width, current.height);
|
|
727
|
+
const before = pixelsOf(context, earlier);
|
|
728
|
+
const now = pixelsOf(context, current);
|
|
729
|
+
context.putImageData(new ImageData(diffPixels(now, before), current.width, current.height), 0, 0);
|
|
730
|
+
if (legend !== void 0) drawLegend(context, legend, resolution);
|
|
731
|
+
return encodePicture(canvas);
|
|
732
|
+
}
|
|
733
|
+
//#endregion
|
|
734
|
+
//#region src/plugins/renderer/monitor/capture.ts
|
|
735
|
+
/** Drawn frames a sheet waits for game time to grow before it gives up. */
|
|
736
|
+
const MAX_WAIT_FRAMES = 600;
|
|
737
|
+
/** Fewest and most frames of a sheet. */
|
|
738
|
+
const SHEET_FRAMES = {
|
|
739
|
+
min: 2,
|
|
740
|
+
max: 12
|
|
741
|
+
};
|
|
742
|
+
/** Longest spacing of two frames of a sheet, in milliseconds of game time. */
|
|
743
|
+
const SHEET_MAX_MS = 5e3;
|
|
744
|
+
/** The options, in the order the messages name them. */
|
|
745
|
+
const OPTION_NAMES = [
|
|
746
|
+
"legend",
|
|
747
|
+
"layers",
|
|
748
|
+
"sheet",
|
|
749
|
+
"against"
|
|
750
|
+
];
|
|
751
|
+
/**
|
|
752
|
+
* Builds the error of a sheet combined with an option that draws on one picture.
|
|
753
|
+
*
|
|
754
|
+
* @returns The error, ready to throw.
|
|
755
|
+
* @example
|
|
756
|
+
* ```ts
|
|
757
|
+
* sheetAlone().message; // "[game] game.capture takes a sheet on its own.\n Drop legend and against, or drop sheet."
|
|
758
|
+
* ```
|
|
759
|
+
*/
|
|
760
|
+
function sheetAlone() {
|
|
761
|
+
return /* @__PURE__ */ new Error("[game] game.capture takes a sheet on its own.\n Drop legend and against, or drop sheet.");
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Builds the error of a layer name the scene does not declare.
|
|
765
|
+
*
|
|
766
|
+
* @param name - The name asked for.
|
|
767
|
+
* @param scene - The layer names of the scene.
|
|
768
|
+
* @returns The error, ready to throw.
|
|
769
|
+
* @example
|
|
770
|
+
* ```ts
|
|
771
|
+
* layerMissing("sky", ["board", "hud"]).message;
|
|
772
|
+
* // '[game] game.capture: layer "sky" is not in the scene.\n Use one of "board", "hud".'
|
|
773
|
+
* ```
|
|
774
|
+
*/
|
|
775
|
+
function layerMissing(name, scene) {
|
|
776
|
+
const fix = scene.length === 0 ? "Declare the layers of the scene first" : `Use one of ${scene.map((layer) => JSON.stringify(layer)).join(", ")}`;
|
|
777
|
+
return /* @__PURE__ */ new Error(`[game] game.capture: layer "${name}" is not in the scene.\n ${fix}.`);
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Builds the error of an option that needs a 2D canvas where there is none.
|
|
781
|
+
*
|
|
782
|
+
* @param option - The first option given.
|
|
783
|
+
* @returns The error, ready to throw.
|
|
784
|
+
* @example
|
|
785
|
+
* ```ts
|
|
786
|
+
* needsCanvas("legend").message.startsWith("[game] game.capture needs OffscreenCanvas for legend."); // true
|
|
787
|
+
* ```
|
|
788
|
+
*/
|
|
789
|
+
function needsCanvas(option) {
|
|
790
|
+
return /* @__PURE__ */ new Error(`[game] game.capture needs OffscreenCanvas for ${option}.\n Capture in a browser, or leave the option out.`);
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Builds the error of a running sheet whose game time stood still.
|
|
794
|
+
*
|
|
795
|
+
* @returns The error, ready to throw.
|
|
796
|
+
* @example
|
|
797
|
+
* ```ts
|
|
798
|
+
* timeStood().message.startsWith("[game] game.capture sheet: game time did not advance."); // true
|
|
799
|
+
* ```
|
|
800
|
+
*/
|
|
801
|
+
function timeStood() {
|
|
802
|
+
return /* @__PURE__ */ new Error("[game] game.capture sheet: game time did not advance.\n Set game.timeScale above 0 or pause the game.");
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* The options a capture was given: `legend` when true, the others when present.
|
|
806
|
+
*
|
|
807
|
+
* @param options - The options of `capture()`.
|
|
808
|
+
* @returns Their names, in the order the messages name them.
|
|
809
|
+
* @example
|
|
810
|
+
* ```ts
|
|
811
|
+
* givenOptions({ legend: false, layers: ["hud"] }); // ["layers"]
|
|
812
|
+
* ```
|
|
813
|
+
*/
|
|
814
|
+
function givenOptions(options) {
|
|
815
|
+
return OPTION_NAMES.filter((name) => name === "legend" ? options.legend === true : options[name] !== void 0);
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Tells whether a sheet is in range: 2 to 12 whole frames, above 0 and at most 5000 ms apart.
|
|
819
|
+
*
|
|
820
|
+
* @param sheet - The sheet.
|
|
821
|
+
* @returns True when it is.
|
|
822
|
+
* @example
|
|
823
|
+
* ```ts
|
|
824
|
+
* sheetInRange({ frames: 6, everyMs: 100 }); // true
|
|
825
|
+
* ```
|
|
826
|
+
*/
|
|
827
|
+
function sheetInRange(sheet) {
|
|
828
|
+
const { frames, everyMs } = sheet;
|
|
829
|
+
return Number.isInteger(frames) && frames >= SHEET_FRAMES.min && frames <= SHEET_FRAMES.max && Number.isFinite(everyMs) && everyMs > 0 && everyMs <= SHEET_MAX_MS;
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Refuses option values `capture()` cannot draw.
|
|
833
|
+
*
|
|
834
|
+
* @param options - The options of `capture()`.
|
|
835
|
+
* @param scene - The layer names of the scene, `world.projection.layers()`.
|
|
836
|
+
* @throws {Error} When a sheet comes with `legend` or `against`, when a layer is not in the scene
|
|
837
|
+
* or the list is empty, and when a sheet is out of range.
|
|
838
|
+
*/
|
|
839
|
+
function checkCaptureOptions(options, scene) {
|
|
840
|
+
const { legend, layers, sheet, against } = options;
|
|
841
|
+
if (sheet !== void 0 && (legend === true || against !== void 0)) throw sheetAlone();
|
|
842
|
+
if (layers?.length === 0) throw new Error("[game] game.capture takes at least one layer.\n Pass the layer names to draw, or leave layers out.");
|
|
843
|
+
const missing = layers?.find((name) => !scene.includes(name));
|
|
844
|
+
if (missing !== void 0) throw layerMissing(missing, scene);
|
|
845
|
+
if (sheet !== void 0 && !sheetInRange(sheet)) throw new Error("[game] game.capture takes a sheet of 2 to 12 frames, every 1 to 5000 ms.\n Pass { frames, everyMs } in that range.");
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Tells whether a view of a layer is drawn when only some layers are.
|
|
849
|
+
*
|
|
850
|
+
* @param layers - The layers drawn; every layer when `undefined`.
|
|
851
|
+
* @param layer - The layer of the view, or `undefined` when it names none.
|
|
852
|
+
* @returns True when the view is in the picture.
|
|
853
|
+
* @example
|
|
854
|
+
* ```ts
|
|
855
|
+
* drawnIn(["hud"], "board"); // false
|
|
856
|
+
* ```
|
|
857
|
+
*/
|
|
858
|
+
function drawnIn(layers, layer) {
|
|
859
|
+
return layers === void 0 || layer !== void 0 && layers.includes(layer);
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* The layer names of the scene.
|
|
863
|
+
*
|
|
864
|
+
* @param mctx - Domain context of the capture.
|
|
865
|
+
* @returns The names, in draw order.
|
|
866
|
+
*/
|
|
867
|
+
function sceneLayers(mctx) {
|
|
868
|
+
return mctx.ctx.deps.world.projection.layers().map((spec) => spec.name);
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* The layer containers a picture of some layers leaves out.
|
|
872
|
+
*
|
|
873
|
+
* @param mctx - Domain context of the capture.
|
|
874
|
+
* @param layers - The layers drawn; every layer when `undefined`.
|
|
875
|
+
* @returns The containers of the other layers.
|
|
876
|
+
*/
|
|
877
|
+
function hiddenLayers(mctx, layers) {
|
|
878
|
+
const hidden = [];
|
|
879
|
+
if (layers === void 0) return hidden;
|
|
880
|
+
for (const name of sceneLayers(mctx)) {
|
|
881
|
+
const container = layers.includes(name) ? void 0 : mctx.deps.sync.layerContainer(name);
|
|
882
|
+
if (container !== void 0) hidden.push(container);
|
|
883
|
+
}
|
|
884
|
+
return hidden;
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Where the canvas is on the page, so a rect of the viewport becomes a rect of the picture.
|
|
888
|
+
*
|
|
889
|
+
* @param canvas - The canvas of the application.
|
|
890
|
+
* @returns Its top-left corner in CSS pixels; the origin when there is no canvas.
|
|
891
|
+
*/
|
|
892
|
+
function canvasOrigin(canvas) {
|
|
893
|
+
const rect = canvas?.getBoundingClientRect();
|
|
894
|
+
return {
|
|
895
|
+
x: rect?.left ?? 0,
|
|
896
|
+
y: rect?.top ?? 0
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Measures the legend of the frame just drawn: every entity of the world with a projection
|
|
901
|
+
* address and visible bounds, in a layer drawn, placed on the picture and numbered.
|
|
902
|
+
*
|
|
903
|
+
* @param mctx - Domain context of the capture.
|
|
904
|
+
* @param layers - The layers drawn; every layer when `undefined`.
|
|
905
|
+
* @returns The numbered entries.
|
|
906
|
+
*/
|
|
907
|
+
function measureLegend(mctx, layers) {
|
|
908
|
+
const world = mctx.ctx.deps.world;
|
|
909
|
+
const { host, viewport, sync } = mctx.deps;
|
|
910
|
+
const origin = canvasOrigin(host.canvas());
|
|
911
|
+
const resolution = host.resolution();
|
|
912
|
+
const found = [];
|
|
913
|
+
for (const { id } of world.ecs.snapshot().entities) {
|
|
914
|
+
const address = world.projection.keyOf(id);
|
|
915
|
+
const box = address === void 0 ? void 0 : sync.boundsOf(id);
|
|
916
|
+
if (address === void 0 || box === void 0 || !drawnIn(layers, sync.layerOf(id))) continue;
|
|
917
|
+
found.push({
|
|
918
|
+
projection: address.projection,
|
|
919
|
+
key: address.key,
|
|
920
|
+
rect: pictureRect(box, viewport.toScreen, origin, resolution)
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
return numberLegend(found);
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Takes the picture of the frame just drawn. The legend and the extract's render happen now,
|
|
927
|
+
* synchronously, so both show this frame; only the encoding of the PNG comes later.
|
|
928
|
+
*
|
|
929
|
+
* @param mctx - Domain context of the capture.
|
|
930
|
+
* @param take - The layers drawn and whether the legend is measured.
|
|
931
|
+
* @returns The shot, or `undefined` when Pixi could not read the frame.
|
|
932
|
+
*/
|
|
933
|
+
async function takeShot(mctx, take) {
|
|
934
|
+
const legend = take.legend ? measureLegend(mctx, take.layers) : void 0;
|
|
935
|
+
const elapsed = mctx.ctx.deps.time.snapshot().elapsed;
|
|
936
|
+
const png = await mctx.deps.host.extract(hiddenLayers(mctx, take.layers));
|
|
937
|
+
return png === void 0 ? void 0 : {
|
|
938
|
+
png,
|
|
939
|
+
legend,
|
|
940
|
+
elapsed
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Queues a picture for the end of the first drawn frame `due` accepts.
|
|
945
|
+
*
|
|
946
|
+
* @param mctx - Domain context of the capture.
|
|
947
|
+
* @param take - The layers drawn and whether the legend is measured.
|
|
948
|
+
* @param due - True when the frame just drawn is the one to take.
|
|
949
|
+
* @param whilePaused - Takes the shot instead when the game pauses before that frame came.
|
|
950
|
+
* @returns The shot, once that frame was drawn.
|
|
951
|
+
*/
|
|
952
|
+
function queueShot(mctx, take, due, whilePaused) {
|
|
953
|
+
return new Promise((resolve, reject) => {
|
|
954
|
+
mctx.ctx.state.monitor.captures.push({
|
|
955
|
+
...take,
|
|
956
|
+
due,
|
|
957
|
+
whilePaused,
|
|
958
|
+
waited: 0,
|
|
959
|
+
resolve,
|
|
960
|
+
reject
|
|
961
|
+
});
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* The picture of the next drawn frame; at once while the clock is paused, since no frame comes.
|
|
966
|
+
*
|
|
967
|
+
* @param mctx - Domain context of the capture.
|
|
968
|
+
* @param take - The layers drawn and whether the legend is measured.
|
|
969
|
+
* @returns The shot.
|
|
970
|
+
*/
|
|
971
|
+
function nextShot(mctx, take) {
|
|
972
|
+
const now = () => takeShot(mctx, take);
|
|
973
|
+
if (mctx.ctx.deps.time.isPaused()) return now();
|
|
974
|
+
return queueShot(mctx, take, () => true, now);
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Steps the clock by some game time, the way `game.step` does, and takes the frame it drew.
|
|
978
|
+
* Async, so a throwing step rejects the shot instead of throwing at the caller.
|
|
979
|
+
*
|
|
980
|
+
* @param mctx - Domain context of the capture.
|
|
981
|
+
* @param take - The layers drawn and whether the legend is measured.
|
|
982
|
+
* @param stepMs - The game time to step, in milliseconds.
|
|
983
|
+
* @returns The shot.
|
|
984
|
+
*/
|
|
985
|
+
async function steppedShot(mctx, take, stepMs) {
|
|
986
|
+
mctx.ctx.log.debug("moku:dev", {
|
|
987
|
+
command: "renderer.capture",
|
|
988
|
+
step: stepMs
|
|
989
|
+
});
|
|
990
|
+
mctx.ctx.deps.time.step(stepMs);
|
|
991
|
+
return takeShot(mctx, take);
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* The next frame of a sheet, `everyMs` of game time after the last one. A paused clock is stepped
|
|
995
|
+
* by `everyMs` once; a running one is waited for, and stepped by what is left of `everyMs` when
|
|
996
|
+
* the game pauses first, by nothing when an earlier capture stepped the clock past it.
|
|
997
|
+
*
|
|
998
|
+
* @param mctx - Domain context of the capture.
|
|
999
|
+
* @param layers - The layers drawn.
|
|
1000
|
+
* @param everyMs - The spacing of the sheet.
|
|
1001
|
+
* @param since - The game time of the last frame.
|
|
1002
|
+
* @returns The shot.
|
|
1003
|
+
*/
|
|
1004
|
+
function laterShot(mctx, layers, everyMs, since) {
|
|
1005
|
+
const time = mctx.ctx.deps.time;
|
|
1006
|
+
const take = {
|
|
1007
|
+
layers,
|
|
1008
|
+
legend: false
|
|
1009
|
+
};
|
|
1010
|
+
if (time.isPaused()) return steppedShot(mctx, take, everyMs);
|
|
1011
|
+
return queueShot(mctx, take, () => time.snapshot().elapsed - since >= everyMs, () => steppedShot(mctx, take, Math.max(0, since + everyMs - time.snapshot().elapsed)));
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* A cache for one drawn frame: the captures due on it with the same layers and legend share one
|
|
1015
|
+
* shot, so one frame costs one extract per kind of picture.
|
|
1016
|
+
*
|
|
1017
|
+
* @param mctx - Domain context of the capture.
|
|
1018
|
+
* @returns Takes the shot of a kind, or hands back the one taken already.
|
|
1019
|
+
*/
|
|
1020
|
+
function shotsOfFrame(mctx) {
|
|
1021
|
+
const taken = /* @__PURE__ */ new Map();
|
|
1022
|
+
return (take) => {
|
|
1023
|
+
const kind = `${String(take.legend)} ${JSON.stringify(take.layers ?? [])}`;
|
|
1024
|
+
const shot = taken.get(kind) ?? takeShot(mctx, take);
|
|
1025
|
+
taken.set(kind, shot);
|
|
1026
|
+
return shot;
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Keeps a capture waiting for a later frame, until it waited 600 drawn frames.
|
|
1031
|
+
*
|
|
1032
|
+
* @param state - The monitor branch of the plugin state.
|
|
1033
|
+
* @param request - The capture whose frame has not come.
|
|
1034
|
+
*/
|
|
1035
|
+
function waitOn(state, request) {
|
|
1036
|
+
request.waited += 1;
|
|
1037
|
+
if (request.waited >= MAX_WAIT_FRAMES) {
|
|
1038
|
+
request.reject(timeStood());
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
state.captures.push(request);
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Hands the frame just drawn to the captures waiting for it. Called at the end of every drawn
|
|
1045
|
+
* frame.
|
|
1046
|
+
*
|
|
1047
|
+
* @param mctx - Domain context of the capture.
|
|
1048
|
+
*/
|
|
1049
|
+
function serveCaptures(mctx) {
|
|
1050
|
+
const state = mctx.ctx.state.monitor;
|
|
1051
|
+
if (state.captures.length === 0) return;
|
|
1052
|
+
const waiting = state.captures.splice(0);
|
|
1053
|
+
const shots = shotsOfFrame(mctx);
|
|
1054
|
+
for (const request of waiting) if (request.due()) shots(request).then(request.resolve, request.reject);
|
|
1055
|
+
else waitOn(state, request);
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Serves the captures still waiting when the game pauses, since no frame comes to serve them: each
|
|
1059
|
+
* takes its paused path, a plain capture at once and a sheet with `time.step`. It runs one
|
|
1060
|
+
* microtask later, so a pause inside a frame lets that frame end first, as `time.step` throws
|
|
1061
|
+
* inside one; a clock that runs again by then leaves them waiting for its next frame.
|
|
1062
|
+
*
|
|
1063
|
+
* @param state - The monitor branch of the plugin state.
|
|
1064
|
+
* @param time - The time API, asked whether the clock is still paused.
|
|
1065
|
+
*/
|
|
1066
|
+
function servePaused(state, time) {
|
|
1067
|
+
queueMicrotask(() => {
|
|
1068
|
+
if (!time.isPaused()) return;
|
|
1069
|
+
for (const request of state.captures.splice(0)) request.whilePaused().then(request.resolve, request.reject);
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Answers every capture still waiting with `undefined`. Works on the state alone, for `onStop`.
|
|
1074
|
+
*
|
|
1075
|
+
* @param state - The monitor branch of the plugin state.
|
|
1076
|
+
*/
|
|
1077
|
+
function cancelCaptures(state) {
|
|
1078
|
+
for (const request of state.captures.splice(0)) request.resolve();
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Takes the frames of a contact sheet and lays them out.
|
|
1082
|
+
*
|
|
1083
|
+
* @param mctx - Domain context of the capture.
|
|
1084
|
+
* @param layers - The layers drawn.
|
|
1085
|
+
* @param sheet - How many frames, how far apart.
|
|
1086
|
+
* @returns `{ png }` of the sheet, or `undefined` when a frame could not be read.
|
|
1087
|
+
*/
|
|
1088
|
+
async function captureSheet(mctx, layers, sheet) {
|
|
1089
|
+
const first = await nextShot(mctx, {
|
|
1090
|
+
layers,
|
|
1091
|
+
legend: false
|
|
1092
|
+
});
|
|
1093
|
+
if (first === void 0) return void 0;
|
|
1094
|
+
const pictures = [first.png];
|
|
1095
|
+
let since = first.elapsed;
|
|
1096
|
+
while (pictures.length < sheet.frames) {
|
|
1097
|
+
const shot = await laterShot(mctx, layers, sheet.everyMs, since);
|
|
1098
|
+
if (shot === void 0) return void 0;
|
|
1099
|
+
pictures.push(shot.png);
|
|
1100
|
+
since = shot.elapsed;
|
|
1101
|
+
}
|
|
1102
|
+
return { png: await sheetPicture(pictures, mctx.deps.host.resolution()) };
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Draws the answer of one picture: the diff when there is an earlier picture, the badges when
|
|
1106
|
+
* there is a legend, the picture itself otherwise.
|
|
1107
|
+
*
|
|
1108
|
+
* @param mctx - Domain context of the capture.
|
|
1109
|
+
* @param shot - The picture and its legend.
|
|
1110
|
+
* @param against - The earlier picture, or `undefined`.
|
|
1111
|
+
* @returns The answer.
|
|
1112
|
+
*/
|
|
1113
|
+
async function answerOf(mctx, shot, against) {
|
|
1114
|
+
const { legend } = shot;
|
|
1115
|
+
const resolution = mctx.deps.host.resolution();
|
|
1116
|
+
if (against !== void 0) {
|
|
1117
|
+
const png = await diffPicture(shot.png, against, legend, resolution);
|
|
1118
|
+
return legend === void 0 ? { png } : {
|
|
1119
|
+
png,
|
|
1120
|
+
legend
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
if (legend === void 0) return { png: shot.png };
|
|
1124
|
+
return {
|
|
1125
|
+
png: await legendPicture(shot.png, legend, resolution),
|
|
1126
|
+
legend
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Captures the canvas with its options, after the dev guard of `capture()`.
|
|
1131
|
+
*
|
|
1132
|
+
* @param mctx - Domain context of the capture.
|
|
1133
|
+
* @param options - The options of `capture()`.
|
|
1134
|
+
* @returns The answer, or `undefined` while nothing draws and when Pixi could not read a frame.
|
|
1135
|
+
* @throws {Error} When an option is refused, or there is no `OffscreenCanvas` for one.
|
|
1136
|
+
*/
|
|
1137
|
+
async function captureFrame(mctx, options) {
|
|
1138
|
+
checkCaptureOptions(options, sceneLayers(mctx));
|
|
1139
|
+
if (!mctx.deps.host.ready()) return void 0;
|
|
1140
|
+
const given = givenOptions(options);
|
|
1141
|
+
if (given[0] !== void 0 && typeof OffscreenCanvas === "undefined") throw needsCanvas(given[0]);
|
|
1142
|
+
mctx.ctx.log.debug("moku:dev", given.length === 0 ? { command: "renderer.capture" } : {
|
|
1143
|
+
command: "renderer.capture",
|
|
1144
|
+
options: given
|
|
1145
|
+
});
|
|
1146
|
+
if (options.sheet !== void 0) return captureSheet(mctx, options.layers, options.sheet);
|
|
1147
|
+
const shot = await nextShot(mctx, {
|
|
1148
|
+
layers: options.layers,
|
|
1149
|
+
legend: options.legend === true
|
|
1150
|
+
});
|
|
1151
|
+
return shot === void 0 ? void 0 : answerOf(mctx, shot, options.against);
|
|
1152
|
+
}
|
|
1153
|
+
//#endregion
|
|
1154
|
+
//#region src/plugins/flow/doors/dev.ts
|
|
1155
|
+
/**
|
|
1156
|
+
* Reads the dev flag at call time. Undefined means off: production is the default.
|
|
1157
|
+
*
|
|
1158
|
+
* @returns True only when `__MOKU_GAME_DEV__` is exactly `true`.
|
|
1159
|
+
* @example
|
|
1160
|
+
* ```ts
|
|
1161
|
+
* // The dev page set the flag in web/dev.ts before the engine started.
|
|
1162
|
+
* globalThis.__MOKU_GAME_DEV__ = true;
|
|
1163
|
+
* isDev(); // true
|
|
1164
|
+
* ```
|
|
1165
|
+
*/
|
|
1166
|
+
function isDev() {
|
|
1167
|
+
return typeof __MOKU_GAME_DEV__ !== "undefined" && __MOKU_GAME_DEV__ === true;
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Builds the error a control command throws outside a dev build.
|
|
1171
|
+
*
|
|
1172
|
+
* @returns The error, ready to throw.
|
|
1173
|
+
* @example
|
|
1174
|
+
* ```ts
|
|
1175
|
+
* // A production build called a /control command.
|
|
1176
|
+
* controlRefused().message; // "[game] Control commands run in dev builds only.\n Define __MOKU_GAME_DEV__ as true in the dev build."
|
|
1177
|
+
* ```
|
|
1178
|
+
*/
|
|
1179
|
+
function controlRefused() {
|
|
1180
|
+
return /* @__PURE__ */ new Error("[game] Control commands run in dev builds only.\n Define __MOKU_GAME_DEV__ as true in the dev build.");
|
|
1181
|
+
}
|
|
1182
|
+
//#endregion
|
|
1183
|
+
export { serveCaptures as a, collectFlows as c, framePath as d, graphHash as f, pushEntry as h, captureFrame as i, describeGraph as l, compact as m, isDev as n, servePaused as o, slotNames as p, cancelCaptures as r, sheetAlone as s, controlRefused as t, findNode as u };
|