@driftengine/tools 4.0.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/LICENSE +202 -0
- package/NOTICE +29 -0
- package/README.md +54 -0
- package/dist/command.d.ts +44 -0
- package/dist/command.js +53 -0
- package/dist/console.d.ts +75 -0
- package/dist/console.js +175 -0
- package/dist/divergence.d.ts +23 -0
- package/dist/divergence.js +36 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +9 -0
- package/dist/inspector.d.ts +86 -0
- package/dist/inspector.js +265 -0
- package/dist/network.d.ts +58 -0
- package/dist/network.js +120 -0
- package/dist/panel.d.ts +79 -0
- package/dist/panel.js +90 -0
- package/dist/profiler.d.ts +56 -0
- package/dist/profiler.js +142 -0
- package/dist/selection.d.ts +29 -0
- package/dist/selection.js +52 -0
- package/package.json +63 -0
- package/src/command.ts +103 -0
- package/src/console.ts +260 -0
- package/src/divergence.ts +39 -0
- package/src/index.ts +115 -0
- package/src/inspector.ts +350 -0
- package/src/network.ts +185 -0
- package/src/panel.ts +147 -0
- package/src/profiler.ts +197 -0
- package/src/selection.ts +73 -0
package/src/inspector.ts
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The components of whatever is selected, generated from the schema.
|
|
3
|
+
*
|
|
4
|
+
* **Schema-driven and not a drawer per type**, which is the decision that decides whether an editor
|
|
5
|
+
* stays maintainable. A drawer per component type means every new component needs editor work, so
|
|
6
|
+
* components stop being added — and it is the one thing reflection makes unnecessary, because a
|
|
7
|
+
* component already describes itself. `fieldKindOf` in `@driftengine/editor` already turns a
|
|
8
|
+
* declared type into what to draw, and this uses it rather than repeating it.
|
|
9
|
+
*
|
|
10
|
+
* **A value several entities disagree about is marked, never guessed at.** Showing the first
|
|
11
|
+
* entity's is how somebody types into a field, sees nothing change, and silently overwrites the
|
|
12
|
+
* other nine.
|
|
13
|
+
*
|
|
14
|
+
* **A type this cannot edit is shown read-only rather than dropped.** A field that is silently not
|
|
15
|
+
* there is a field somebody spends an afternoon looking for.
|
|
16
|
+
*/
|
|
17
|
+
import { addUiChild, createUiNode } from '@driftengine/ui2d';
|
|
18
|
+
import type { UiNode } from '@driftengine/ui2d';
|
|
19
|
+
import { fieldKindOf, type FieldKind } from '@driftengine/editor';
|
|
20
|
+
import type { Command } from './command.ts';
|
|
21
|
+
import { selectedEntities, type Selection } from './selection.ts';
|
|
22
|
+
import { createPanelRoot, emptyPanel, type Panel } from './panel.ts';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What the inspector needs of a world.
|
|
26
|
+
*
|
|
27
|
+
* **Four reads and three writes**, so a panel can be tested against plain maps — this plan's second
|
|
28
|
+
* constraint — and so the same panel serves `@driftengine/entities`, a `.drs` file's declarations
|
|
29
|
+
* and anything else that can answer these. A caller holding a `World` wraps it in seven lines.
|
|
30
|
+
*/
|
|
31
|
+
export interface InspectableWorld {
|
|
32
|
+
componentsOf(entity: number): readonly string[];
|
|
33
|
+
schemaOf(component: string): readonly { readonly name: string; readonly type: string }[];
|
|
34
|
+
read(entity: number, component: string, field: string): unknown;
|
|
35
|
+
write(entity: number, component: string, field: string, value: unknown): void;
|
|
36
|
+
hasComponent(entity: number, component: string): boolean;
|
|
37
|
+
addComponent(entity: number, component: string): void;
|
|
38
|
+
removeComponent(entity: number, component: string): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What a row's value is when the selected entities disagree about it. */
|
|
42
|
+
export const MIXED = Symbol('mixed');
|
|
43
|
+
|
|
44
|
+
/** A vector row is three number fields; anything else is one field. */
|
|
45
|
+
export type RowKind = FieldKind | 'vector';
|
|
46
|
+
|
|
47
|
+
export interface InspectorRow {
|
|
48
|
+
readonly component: string;
|
|
49
|
+
/** What the row is called: a field's name, or the prefix the vector's three share. */
|
|
50
|
+
readonly label: string;
|
|
51
|
+
/** The declared type, verbatim. Empty for a vector, which is three of them. */
|
|
52
|
+
readonly type: string;
|
|
53
|
+
readonly kind: RowKind;
|
|
54
|
+
readonly enumName: string;
|
|
55
|
+
/** The schema fields this row writes, in order. One, or three for a vector. */
|
|
56
|
+
readonly fields: readonly string[];
|
|
57
|
+
/** The value, `MIXED` where the selection disagrees. An array of three for a vector. */
|
|
58
|
+
readonly value: unknown;
|
|
59
|
+
readonly editable: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The declared types this can edit.
|
|
64
|
+
*
|
|
65
|
+
* **A list rather than `fieldKindOf`'s answer**, because that function's fallback is `number` — it
|
|
66
|
+
* is asked what *kind* a known type is, not whether a type is known, and `MeshHandle` comes back as
|
|
67
|
+
* a number that would then be typed into. The two questions are different and the second one is
|
|
68
|
+
* this file's.
|
|
69
|
+
*/
|
|
70
|
+
const KNOWN = new Set([
|
|
71
|
+
'f32',
|
|
72
|
+
'f64',
|
|
73
|
+
'i8',
|
|
74
|
+
'i16',
|
|
75
|
+
'i32',
|
|
76
|
+
'i64',
|
|
77
|
+
'u8',
|
|
78
|
+
'u16',
|
|
79
|
+
'u32',
|
|
80
|
+
'u64',
|
|
81
|
+
'bool',
|
|
82
|
+
'String',
|
|
83
|
+
'Entity',
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
export function fieldEditable(type: string): boolean {
|
|
87
|
+
const inner = type.startsWith('option:') ? type.slice('option:'.length) : type;
|
|
88
|
+
return KNOWN.has(inner) || inner.startsWith('enum:');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `position.x` is the `x` of a vector called `position`. Anything else is its own row. */
|
|
92
|
+
function vectorPartOf(name: string): { prefix: string; axis: number } | null {
|
|
93
|
+
const dot = name.lastIndexOf('.');
|
|
94
|
+
if (dot <= 0) return null;
|
|
95
|
+
const axis = 'xyz'.indexOf(name.slice(dot + 1));
|
|
96
|
+
return axis < 0 ? null : { prefix: name.slice(0, dot), axis };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** One value across the selection, or `MIXED`. */
|
|
100
|
+
function agreed(
|
|
101
|
+
world: InspectableWorld,
|
|
102
|
+
entities: readonly number[],
|
|
103
|
+
component: string,
|
|
104
|
+
field: string,
|
|
105
|
+
): unknown {
|
|
106
|
+
const first = world.read(entities[0] as number, component, field);
|
|
107
|
+
for (let at = 1; at < entities.length; at += 1) {
|
|
108
|
+
if (world.read(entities[at] as number, component, field) !== first) return MIXED;
|
|
109
|
+
}
|
|
110
|
+
return first;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The rows for a selection: the components every selected entity has, field by field.
|
|
115
|
+
*
|
|
116
|
+
* Components are taken from the first entity and then filtered by the rest, so the order is stable
|
|
117
|
+
* — a row that moved between frames because a set iterated differently is a field somebody clicks
|
|
118
|
+
* and misses.
|
|
119
|
+
*/
|
|
120
|
+
export function inspectorRows(
|
|
121
|
+
world: InspectableWorld,
|
|
122
|
+
entities: readonly number[],
|
|
123
|
+
): InspectorRow[] {
|
|
124
|
+
const first = entities[0];
|
|
125
|
+
if (first === undefined) return [];
|
|
126
|
+
|
|
127
|
+
const shared = world
|
|
128
|
+
.componentsOf(first)
|
|
129
|
+
.filter((component) => entities.every((entity) => world.hasComponent(entity, component)));
|
|
130
|
+
|
|
131
|
+
const rows: InspectorRow[] = [];
|
|
132
|
+
for (const component of shared) {
|
|
133
|
+
const fields = world.schemaOf(component);
|
|
134
|
+
const vectors = new Map<string, string[]>();
|
|
135
|
+
|
|
136
|
+
for (const field of fields) {
|
|
137
|
+
const part = vectorPartOf(field.name);
|
|
138
|
+
if (part === null) continue;
|
|
139
|
+
const slots = vectors.get(part.prefix) ?? ['', '', ''];
|
|
140
|
+
slots[part.axis] = field.name;
|
|
141
|
+
vectors.set(part.prefix, slots);
|
|
142
|
+
}
|
|
143
|
+
/* Two of three is not a vector; it is two fields that happen to be named that way. */
|
|
144
|
+
for (const [prefix, slots] of vectors) {
|
|
145
|
+
if (slots.some((slot) => slot === '')) vectors.delete(prefix);
|
|
146
|
+
}
|
|
147
|
+
const claimed = new Set([...vectors.values()].flat());
|
|
148
|
+
|
|
149
|
+
const emitted = new Set<string>();
|
|
150
|
+
for (const field of fields) {
|
|
151
|
+
const part = vectorPartOf(field.name);
|
|
152
|
+
if (part !== null && claimed.has(field.name)) {
|
|
153
|
+
if (emitted.has(part.prefix)) continue;
|
|
154
|
+
emitted.add(part.prefix);
|
|
155
|
+
const slots = vectors.get(part.prefix) as string[];
|
|
156
|
+
rows.push({
|
|
157
|
+
component,
|
|
158
|
+
label: part.prefix,
|
|
159
|
+
type: '',
|
|
160
|
+
kind: 'vector',
|
|
161
|
+
enumName: '',
|
|
162
|
+
fields: slots,
|
|
163
|
+
value: slots.map((slot) => agreed(world, entities, component, slot)),
|
|
164
|
+
editable: fieldEditable(field.type),
|
|
165
|
+
});
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const { kind, enumName } = fieldKindOf(field.type);
|
|
169
|
+
rows.push({
|
|
170
|
+
component,
|
|
171
|
+
label: field.name,
|
|
172
|
+
type: field.type,
|
|
173
|
+
kind,
|
|
174
|
+
enumName,
|
|
175
|
+
fields: [field.name],
|
|
176
|
+
value: agreed(world, entities, component, field.name),
|
|
177
|
+
editable: fieldEditable(field.type),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return rows;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Write one row's fields across the whole selection, as one entry.
|
|
186
|
+
*
|
|
187
|
+
* **One entry for the whole selection and for all three of a vector's components.** Editing with
|
|
188
|
+
* ten entities selected is one act, and undo that took it back one entity at a time would make the
|
|
189
|
+
* person count. Each entity's own previous value is recorded, so undo does not flatten them onto
|
|
190
|
+
* whatever the first one held.
|
|
191
|
+
*/
|
|
192
|
+
export function setFieldCommand(
|
|
193
|
+
world: InspectableWorld,
|
|
194
|
+
entities: readonly number[],
|
|
195
|
+
component: string,
|
|
196
|
+
fields: readonly string[],
|
|
197
|
+
values: readonly unknown[],
|
|
198
|
+
): Command | null {
|
|
199
|
+
if (entities.length === 0 || fields.length === 0) return null;
|
|
200
|
+
if (!entities.every((entity) => world.hasComponent(entity, component))) return null;
|
|
201
|
+
|
|
202
|
+
const before = entities.map((entity) =>
|
|
203
|
+
fields.map((field) => world.read(entity, component, field)),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
label: `${component}.${stripAxis(fields)}`,
|
|
208
|
+
apply: (): void => {
|
|
209
|
+
for (const entity of entities) {
|
|
210
|
+
fields.forEach((field, at) => world.write(entity, component, field, values[at]));
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
revert: (): void => {
|
|
214
|
+
entities.forEach((entity, row) => {
|
|
215
|
+
fields.forEach((field, at) => {
|
|
216
|
+
world.write(entity, component, field, (before[row] as unknown[])[at]);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A vector's label is what its three fields share, so the undo menu says `position` not `position.x`. */
|
|
224
|
+
function stripAxis(fields: readonly string[]): string {
|
|
225
|
+
const first = fields[0] as string;
|
|
226
|
+
if (fields.length === 1) return first;
|
|
227
|
+
const part = vectorPartOf(first);
|
|
228
|
+
return part === null ? first : part.prefix;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function addComponentCommand(
|
|
232
|
+
world: InspectableWorld,
|
|
233
|
+
entities: readonly number[],
|
|
234
|
+
component: string,
|
|
235
|
+
): Command | null {
|
|
236
|
+
const missing = entities.filter((entity) => !world.hasComponent(entity, component));
|
|
237
|
+
if (missing.length === 0) return null;
|
|
238
|
+
return {
|
|
239
|
+
label: `Add ${component}`,
|
|
240
|
+
apply: (): void => {
|
|
241
|
+
for (const entity of missing) world.addComponent(entity, component);
|
|
242
|
+
},
|
|
243
|
+
revert: (): void => {
|
|
244
|
+
for (const entity of missing) world.removeComponent(entity, component);
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Remove a component, keeping its values so undo can put them back.
|
|
251
|
+
*
|
|
252
|
+
* **The values are read before the removal and written after the restore.** A remove that only
|
|
253
|
+
* remembers "it was there" gives back a component full of zeroes, which reads as undo having
|
|
254
|
+
* worked and is the worse failure: nothing looks broken.
|
|
255
|
+
*/
|
|
256
|
+
export function removeComponentCommand(
|
|
257
|
+
world: InspectableWorld,
|
|
258
|
+
entities: readonly number[],
|
|
259
|
+
component: string,
|
|
260
|
+
): Command | null {
|
|
261
|
+
const present = entities.filter((entity) => world.hasComponent(entity, component));
|
|
262
|
+
if (present.length === 0) return null;
|
|
263
|
+
const fields = world.schemaOf(component).map((field) => field.name);
|
|
264
|
+
const kept = present.map((entity) => fields.map((field) => world.read(entity, component, field)));
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
label: `Remove ${component}`,
|
|
268
|
+
apply: (): void => {
|
|
269
|
+
for (const entity of present) world.removeComponent(entity, component);
|
|
270
|
+
},
|
|
271
|
+
revert: (): void => {
|
|
272
|
+
present.forEach((entity, row) => {
|
|
273
|
+
world.addComponent(entity, component);
|
|
274
|
+
fields.forEach((field, at) => {
|
|
275
|
+
world.write(entity, component, field, (kept[row] as unknown[])[at]);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export interface InspectorWorld {
|
|
283
|
+
readonly world: InspectableWorld;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface InspectorView {
|
|
287
|
+
readonly root: UiNode;
|
|
288
|
+
readonly selection: Selection;
|
|
289
|
+
/** The rows the last build produced, so routing can find a field by index. */
|
|
290
|
+
rows: InspectorRow[];
|
|
291
|
+
rowHeight: number;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function createInspectorView(options: {
|
|
295
|
+
selection: Selection;
|
|
296
|
+
rowHeight?: number;
|
|
297
|
+
}): InspectorView {
|
|
298
|
+
return {
|
|
299
|
+
root: createPanelRoot(inspectorPanel),
|
|
300
|
+
selection: options.selection,
|
|
301
|
+
rows: [],
|
|
302
|
+
rowHeight: options.rowHeight ?? 18,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** What a row shows to the right of its label. `—` is the mark for a disagreement. */
|
|
307
|
+
export function rowText(row: InspectorRow): string {
|
|
308
|
+
if (row.value === MIXED) return `${row.label} —`;
|
|
309
|
+
if (row.kind === 'vector') {
|
|
310
|
+
const parts = (row.value as unknown[]).map((part) => (part === MIXED ? '—' : String(part)));
|
|
311
|
+
return `${row.label} ${parts.join(', ')}`;
|
|
312
|
+
}
|
|
313
|
+
if (row.kind === 'boolean') return `${row.label} ${row.value ? 'true' : 'false'}`;
|
|
314
|
+
return `${row.label} ${String(row.value)}`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export const inspectorPanel: Panel<InspectorWorld, InspectorView> = {
|
|
318
|
+
id: 'inspector',
|
|
319
|
+
title: 'Inspector',
|
|
320
|
+
|
|
321
|
+
build(world, view, root): void {
|
|
322
|
+
const entities = selectedEntities(view.selection);
|
|
323
|
+
view.rows = inspectorRows(world.world, entities);
|
|
324
|
+
if (view.rows.length === 0) {
|
|
325
|
+
emptyPanel(root, entities.length === 0 ? 'Nothing selected' : 'No fields in common');
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
root.children.length = 0;
|
|
329
|
+
for (let at = 0; at < view.rows.length; at += 1) {
|
|
330
|
+
const row = view.rows[at] as InspectorRow;
|
|
331
|
+
addUiChild(
|
|
332
|
+
root,
|
|
333
|
+
createUiNode({
|
|
334
|
+
width: 'grow',
|
|
335
|
+
height: view.rowHeight,
|
|
336
|
+
text: rowText(row),
|
|
337
|
+
interactive: row.editable,
|
|
338
|
+
focusable: row.editable,
|
|
339
|
+
name: `field:${at}`,
|
|
340
|
+
}),
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
route(): Command | null {
|
|
346
|
+
/* Task 5 wires the field widgets in. Until then the inspector shows and does not edit, which
|
|
347
|
+
is a state worth having rather than a half-built router that swallows keys. */
|
|
348
|
+
return null;
|
|
349
|
+
},
|
|
350
|
+
};
|
package/src/network.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshots, rollback depth, and the frame two peers stopped agreeing on.
|
|
3
|
+
*
|
|
4
|
+
* **Nobody else has this panel, because nobody else has the state to put in it.** The one that
|
|
5
|
+
* matters is divergence: `@driftengine/network` already fingerprints every snapshot and reports a
|
|
6
|
+
* `Desync` carrying the tick, the peer and both digests, and nothing has ever shown it to anybody.
|
|
7
|
+
*
|
|
8
|
+
* **An empty divergence list and a missing one must not look the same.** One says the peers agree;
|
|
9
|
+
* the other says nobody checked. Those are the two answers somebody most needs to tell apart, and
|
|
10
|
+
* a panel that renders both as blank space is worse than no panel — it reads as agreement. So no
|
|
11
|
+
* session says "No session", and no divergence says "peers agree" in as many words.
|
|
12
|
+
*
|
|
13
|
+
* **Which component differs is answerable only where somebody hashed per component.** `Fingerprint`
|
|
14
|
+
* digests a whole snapshot, so `Desync` is two sixteen-character strings and nothing finer. Where a
|
|
15
|
+
* caller supplies a per-component breakdown this names the component; where it does not, it says
|
|
16
|
+
* the breakdown is absent rather than showing an empty line that reads as "no component differs".
|
|
17
|
+
*/
|
|
18
|
+
import { addUiChild, createUiNode } from '@driftengine/ui2d';
|
|
19
|
+
import type { UiNode } from '@driftengine/ui2d';
|
|
20
|
+
import type { Desync } from '@driftengine/network';
|
|
21
|
+
import { divergentComponents } from './divergence.ts';
|
|
22
|
+
import type { Command } from './command.ts';
|
|
23
|
+
import { createPanelRoot, emptyPanel, type Panel } from './panel.ts';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What the panel needs of a session.
|
|
27
|
+
*
|
|
28
|
+
* Supplied rather than read off a `LockstepSession`, for the reason the asset index is supplied:
|
|
29
|
+
* an editor attached to a session, a recording or a replay wants the same panel, and only two of
|
|
30
|
+
* those have a session to read.
|
|
31
|
+
*/
|
|
32
|
+
export interface NetworkReadout {
|
|
33
|
+
readonly inputDelay: number;
|
|
34
|
+
readonly participants: number;
|
|
35
|
+
/** Bytes in one snapshot slot. */
|
|
36
|
+
readonly snapshotBytes: number;
|
|
37
|
+
/** How many slots the rewind window holds. */
|
|
38
|
+
readonly snapshotCount: number;
|
|
39
|
+
/** Everything the session has seen disagree, oldest first. */
|
|
40
|
+
readonly desyncs: readonly Desync[];
|
|
41
|
+
/** A per-component breakdown, where the caller has one. See the header. */
|
|
42
|
+
readonly componentHashes: {
|
|
43
|
+
readonly ours: ReadonlyMap<string, string>;
|
|
44
|
+
readonly theirs: ReadonlyMap<string, string>;
|
|
45
|
+
} | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Which components two peers hash differently, in the order they are first seen.
|
|
50
|
+
*
|
|
51
|
+
* **The comparison itself lives in `pie/divergence.ts`**, which is where the divergence search
|
|
52
|
+
* needs it too. Two copies of "which of these hashes disagree" is two places for the rule about a
|
|
53
|
+
* component only one side has to be got wrong, and a panel is the wrong owner of a rule — it uses
|
|
54
|
+
* one. This keeps its own shape, returning a fresh array, because a panel builds a line of text
|
|
55
|
+
* from it once per rebuild and an out-parameter would be ceremony for nothing.
|
|
56
|
+
*/
|
|
57
|
+
export function componentDivergence(
|
|
58
|
+
ours: ReadonlyMap<string, string>,
|
|
59
|
+
theirs: ReadonlyMap<string, string>,
|
|
60
|
+
): string[] {
|
|
61
|
+
const out: string[] = [];
|
|
62
|
+
divergentComponents(ours, theirs, out);
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A fixed window of how deep each recent frame rewound. */
|
|
67
|
+
export interface RollbackHistory {
|
|
68
|
+
readonly depth: Int32Array;
|
|
69
|
+
head: number;
|
|
70
|
+
filled: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createRollbackHistory(capacity: number): RollbackHistory {
|
|
74
|
+
return { depth: new Int32Array(Math.max(1, capacity)), head: 0, filled: 0 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function pushRollback(history: RollbackHistory, depth: number): void {
|
|
78
|
+
const capacity = history.depth.length;
|
|
79
|
+
history.depth[history.head] = depth;
|
|
80
|
+
history.head = (history.head + 1) % capacity;
|
|
81
|
+
if (history.filled < capacity) history.filled += 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function rollbackValues(history: RollbackHistory): number[] {
|
|
85
|
+
const capacity = history.depth.length;
|
|
86
|
+
const first = history.filled < capacity ? 0 : history.head;
|
|
87
|
+
const out: number[] = [];
|
|
88
|
+
for (let at = 0; at < history.filled; at += 1) {
|
|
89
|
+
out.push(history.depth[(first + at) % capacity] as number);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface NetworkWorld {
|
|
95
|
+
/** Null where the editor is not attached to anything. */
|
|
96
|
+
readonly session: NetworkReadout | null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface NetworkView {
|
|
100
|
+
readonly root: UiNode;
|
|
101
|
+
readonly rollback: RollbackHistory;
|
|
102
|
+
rowHeight: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function createNetworkView(options: {
|
|
106
|
+
rowHeight?: number;
|
|
107
|
+
historyLength?: number;
|
|
108
|
+
}): NetworkView {
|
|
109
|
+
return {
|
|
110
|
+
root: createPanelRoot(networkPanel),
|
|
111
|
+
rollback: createRollbackHistory(options.historyLength ?? 60),
|
|
112
|
+
rowHeight: options.rowHeight ?? 18,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Kilobytes to one decimal, which is the resolution a snapshot budget is argued at. */
|
|
117
|
+
function kilobytes(bytes: number): string {
|
|
118
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function lineAt(root: UiNode, index: number, text: string, height: number): void {
|
|
122
|
+
const existing = root.children[index];
|
|
123
|
+
if (existing !== undefined) {
|
|
124
|
+
existing.hidden = false;
|
|
125
|
+
existing.text = text;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
addUiChild(
|
|
129
|
+
root,
|
|
130
|
+
createUiNode({ width: 'grow', height, text, interactive: false, name: `line:${index}` }),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const networkPanel: Panel<NetworkWorld, NetworkView> = {
|
|
135
|
+
id: 'network',
|
|
136
|
+
title: 'Network',
|
|
137
|
+
|
|
138
|
+
build(world, view, root): void {
|
|
139
|
+
const session = world.session;
|
|
140
|
+
if (session === null) {
|
|
141
|
+
emptyPanel(root, 'No session');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const lines = [
|
|
146
|
+
`delay ${session.inputDelay} ticks`,
|
|
147
|
+
`peers ${session.participants}`,
|
|
148
|
+
`snapshots ${session.snapshotCount} of ${kilobytes(session.snapshotBytes)} — ${kilobytes(session.snapshotBytes * session.snapshotCount)}`,
|
|
149
|
+
`rollback ${rollbackValues(view.rollback).join(' ')}`,
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
const first = session.desyncs[0];
|
|
153
|
+
if (first === undefined) {
|
|
154
|
+
lines.push('peers agree on every fingerprint so far');
|
|
155
|
+
} else {
|
|
156
|
+
lines.push(`diverged at tick ${first.tick} against ${first.peer}`);
|
|
157
|
+
lines.push(` ours ${first.ours}`);
|
|
158
|
+
lines.push(` theirs ${first.theirs}`);
|
|
159
|
+
const hashes = session.componentHashes;
|
|
160
|
+
if (hashes === null) {
|
|
161
|
+
lines.push(' no per-component hashes were supplied');
|
|
162
|
+
} else {
|
|
163
|
+
const differing = componentDivergence(hashes.ours, hashes.theirs);
|
|
164
|
+
lines.push(
|
|
165
|
+
differing.length === 0
|
|
166
|
+
? ' every component agrees, so the difference is outside them'
|
|
167
|
+
: ` differing: ${differing.join(', ')}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (let at = 0; at < lines.length; at += 1) {
|
|
173
|
+
lineAt(root, at, lines[at] as string, view.rowHeight);
|
|
174
|
+
}
|
|
175
|
+
for (let at = lines.length; at < root.children.length; at += 1) {
|
|
176
|
+
(root.children[at] as UiNode).hidden = true;
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
route(): Command | null {
|
|
181
|
+
/* Read-only, and deliberately: there is nothing in a session's diagnostics a person should be
|
|
182
|
+
able to edit, and a panel that let them would be editing the evidence. */
|
|
183
|
+
return null;
|
|
184
|
+
},
|
|
185
|
+
};
|
package/src/panel.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape every panel shares.
|
|
3
|
+
*
|
|
4
|
+
* **`route` is handed the world read-only and returns a command or nothing.** That one signature is
|
|
5
|
+
* what makes "a panel mutates only through commands" enforceable rather than aspirational: a panel
|
|
6
|
+
* physically cannot change the world, because it is given no way to. The undo stack is only as good
|
|
7
|
+
* as its worst offender, and this is how there is no worst offender.
|
|
8
|
+
*
|
|
9
|
+
* **The world and the view are two parameters, and the plan had them as one.** Writing the scene
|
|
10
|
+
* tree found out why they cannot be: a drag in progress, which row is hovered, how far the list is
|
|
11
|
+
* scrolled and which branches are open are all things `route` must write, and none of them belongs
|
|
12
|
+
* to the world. With one state object, "never mutates state" is either false or the panel cannot
|
|
13
|
+
* record a drag. Split, the claim is exact — `Readonly<W>` for what the world owns, `V` for what
|
|
14
|
+
* the panel owns — and the frozen-state test below is meaningful rather than true by construction.
|
|
15
|
+
* `Readonly` is shallow and so is not a proof; it is a statement of intent in the place a reader
|
|
16
|
+
* will look, which is the same standing every other rule in this repository has.
|
|
17
|
+
*
|
|
18
|
+
* **`build` is a function of both.** Building twice over the same world and view reaches the same
|
|
19
|
+
* tree, so a panel can be rebuilt whenever anything changes without anybody reasoning about what it
|
|
20
|
+
* was showing before. `treeShape` is how a test says that, and every panel's test uses it.
|
|
21
|
+
*
|
|
22
|
+
* **Nothing to show is a state and not a failure.** A panel with no selection, an asset browser
|
|
23
|
+
* before the project has loaded and a profiler before the first frame all reach `emptyPanel`. An
|
|
24
|
+
* empty dock with an exception behind it is the least useful thing an editor can show.
|
|
25
|
+
*/
|
|
26
|
+
import { addUiChild, createUiNode } from '@driftengine/ui2d';
|
|
27
|
+
import type { UiNode } from '@driftengine/ui2d';
|
|
28
|
+
import type { Command } from './command.ts';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What a panel is asked to deal with.
|
|
32
|
+
*
|
|
33
|
+
* Positions are in the panel's own space — the caller has already subtracted the dock site's
|
|
34
|
+
* origin — so a panel never knows where on screen it is, and a panel moved to another dock behaves
|
|
35
|
+
* identically. `button` is 0 for the primary; a context menu is 2, as every platform spells it.
|
|
36
|
+
*/
|
|
37
|
+
export type UiEvent =
|
|
38
|
+
| {
|
|
39
|
+
readonly kind: 'pointer';
|
|
40
|
+
readonly phase: 'down' | 'move' | 'up';
|
|
41
|
+
readonly x: number;
|
|
42
|
+
readonly y: number;
|
|
43
|
+
readonly button: number;
|
|
44
|
+
}
|
|
45
|
+
| { readonly kind: 'key'; readonly key: string; readonly shift: boolean; readonly ctrl: boolean }
|
|
46
|
+
| {
|
|
47
|
+
readonly kind: 'wheel';
|
|
48
|
+
readonly x: number;
|
|
49
|
+
readonly y: number;
|
|
50
|
+
readonly dx: number;
|
|
51
|
+
readonly dy: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export function pointerEvent(
|
|
55
|
+
phase: 'down' | 'move' | 'up',
|
|
56
|
+
x: number,
|
|
57
|
+
y: number,
|
|
58
|
+
button = 0,
|
|
59
|
+
): UiEvent {
|
|
60
|
+
return { kind: 'pointer', phase, x, y, button };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function keyEvent(key: string, shift = false, ctrl = false): UiEvent {
|
|
64
|
+
return { kind: 'key', key, shift, ctrl };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function wheelEvent(x: number, y: number, dx: number, dy: number): UiEvent {
|
|
68
|
+
return { kind: 'wheel', x, y, dx, dy };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface Panel<W, V> {
|
|
72
|
+
/** Stable for the life of the panel. What the dock layout stores. */
|
|
73
|
+
readonly id: string;
|
|
74
|
+
/** What the dock tab says. */
|
|
75
|
+
readonly title: string;
|
|
76
|
+
/** Fill `root` from the world and the view. Called whenever either may have changed. */
|
|
77
|
+
build(world: Readonly<W>, view: V, root: UiNode): void;
|
|
78
|
+
/**
|
|
79
|
+
* What this event asks of the *world*, or nothing.
|
|
80
|
+
*
|
|
81
|
+
* May write `view` — a drag has to be recorded somewhere, and so do a scroll and a hover. May
|
|
82
|
+
* not write `world`, which is what the command it returns is for.
|
|
83
|
+
*/
|
|
84
|
+
route(world: Readonly<W>, view: V, event: UiEvent): Command | null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The prefix a panel root's name carries, so the dock can recognise one. */
|
|
88
|
+
export const PANEL_PREFIX = 'panel:';
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The node a panel builds into.
|
|
92
|
+
*
|
|
93
|
+
* The identifier lives on the root rather than in a map beside the tree, so the dock reads it off
|
|
94
|
+
* whatever node it is holding and there is nothing to keep in step. It takes the identifier alone
|
|
95
|
+
* rather than a `Panel<S>`, so a caller need not name the state type to make a root. `clip`,
|
|
96
|
+
* because a panel's content is
|
|
97
|
+
* routinely taller than its site, and a panel drawing outside its dock is the first thing anybody
|
|
98
|
+
* notices.
|
|
99
|
+
*/
|
|
100
|
+
export function createPanelRoot(panel: { readonly id: string }): UiNode {
|
|
101
|
+
return createUiNode({
|
|
102
|
+
direction: 'column',
|
|
103
|
+
width: 'grow',
|
|
104
|
+
height: 'grow',
|
|
105
|
+
clip: true,
|
|
106
|
+
name: `${PANEL_PREFIX}${panel.id}`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Which panel a node is the root of, or null. */
|
|
111
|
+
export function panelIdOf(node: UiNode | null): string | null {
|
|
112
|
+
if (node === null || !node.name.startsWith(PANEL_PREFIX)) return null;
|
|
113
|
+
const id = node.name.slice(PANEL_PREFIX.length);
|
|
114
|
+
return id.length > 0 ? id : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Replace a panel's contents with one line saying why there is nothing.
|
|
119
|
+
*
|
|
120
|
+
* Not interactive, because a message is not a control and a person who clicks it and gets a
|
|
121
|
+
* selection has learnt something untrue about the panel.
|
|
122
|
+
*/
|
|
123
|
+
export function emptyPanel(root: UiNode, message: string): void {
|
|
124
|
+
root.children.length = 0;
|
|
125
|
+
addUiChild(
|
|
126
|
+
root,
|
|
127
|
+
createUiNode({ width: 'grow', height: 18, text: message, name: 'empty', interactive: false }),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A tree as a comparable string: structure, text, geometry and name, but never identity.
|
|
133
|
+
*
|
|
134
|
+
* **What it deliberately leaves out is object identity**, because a panel that reuses its pooled
|
|
135
|
+
* rows and one that rebuilt them are the same panel as far as the contract is concerned — the
|
|
136
|
+
* whole point of pooling is that nobody downstream can tell. What it deliberately includes is
|
|
137
|
+
* `hidden`, because a pooled row that is not showing is exactly how a panel shrinks, and two trees
|
|
138
|
+
* differing only there are showing different things.
|
|
139
|
+
*/
|
|
140
|
+
export function treeShape(node: UiNode, depth = 0): string {
|
|
141
|
+
const pad = ' '.repeat(depth);
|
|
142
|
+
const size = `${String(node.width)}x${String(node.height)}`;
|
|
143
|
+
const flags = `${node.hidden ? 'h' : '-'}${node.interactive ? 'i' : '-'}${node.clip ? 'c' : '-'}`;
|
|
144
|
+
const own = `${pad}${node.name || '(unnamed)'} ${size} ${flags} ${node.layer} ${JSON.stringify(node.text)}`;
|
|
145
|
+
const children = node.children.map((child) => treeShape(child, depth + 1));
|
|
146
|
+
return [own, ...children].join('\n');
|
|
147
|
+
}
|