@driftengine/tools 4.8.1 → 4.8.3

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 CHANGED
@@ -4,7 +4,7 @@ The editor's panels, as something a shipped game can carry: an inspector, a cons
4
4
  a network panel, with the command stack that makes their edits undoable, and the overlay that puts
5
5
  them on screen on a key.
6
6
 
7
- **5,393 bytes gzipped**, measured by `scripts/size-gate.test.mjs` against
7
+ **5,780 bytes gzipped**, measured by `scripts/size-gate.test.mjs` against
8
8
  `scripts/fixtures/size/tools-only.ts`. Optional — nothing in `@driftengine/core` imports it, so a
9
9
  game that never asks for these pays nothing.
10
10
 
@@ -30,14 +30,32 @@ undo stack" has no worst offender. A game carrying these panels carries that rul
30
30
  nothing extra.
31
31
 
32
32
  ```ts
33
- import { createUndoStack, inspectorPanel, createInspectorView } from '@driftengine/tools';
33
+ import {
34
+ createSelection,
35
+ createUndoStack,
36
+ entitiesInspectable,
37
+ selectOnly,
38
+ selectedEntities,
39
+ setFieldCommand,
40
+ } from '@driftengine/tools';
34
41
 
35
42
  const undo = createUndoStack(200);
36
- const view = createInspectorView({});
37
- const command = inspectorPanel.route(world, view, event);
43
+ const selection = createSelection();
44
+ selectOnly(selection, ball);
45
+ /* One edit to every selected entity, as one entry: `undo.undo()` takes it back. */
46
+ const command = setFieldCommand(
47
+ entitiesInspectable(world, [Bounce]),
48
+ selectedEntities(selection),
49
+ 'Bounce',
50
+ ['amount'],
51
+ [0.6],
52
+ );
38
53
  if (command !== null) undo.push(command);
39
54
  ```
40
55
 
56
+ The inspector panel shows a selection's fields and edits none of them yet — its `route` answers
57
+ nothing — so a game edits through `setFieldCommand` as above, from whatever key or control it likes.
58
+
41
59
  ## The overlay, because a panel that nothing mounts is a tree nobody sees
42
60
 
43
61
  Every panel above builds a `UiNode` tree and stops there. For 4.0.0 the half that turns a tree into
package/dist/console.js CHANGED
@@ -133,10 +133,15 @@ export const consolePanel = {
133
133
  id: 'console',
134
134
  title: 'Console',
135
135
  build(world, view, root) {
136
+ /* The window is the height the panel was given, where it was given one: the overlay sets it on
137
+ the root before it builds. `viewHeight` is what a root with no exact height falls back to. */
138
+ if (typeof root.height === 'number' && root.height > 0)
139
+ view.viewHeight = root.height;
136
140
  view.shown = filteredEntries(world.log, view.filter);
137
141
  if (view.shown.length === 0) {
138
142
  emptyPanel(root, 'Nothing logged');
139
143
  view.scrollY = 0;
144
+ root.scrollY = 0;
140
145
  return;
141
146
  }
142
147
  root.children.length = 0;
@@ -153,6 +158,8 @@ export const consolePanel = {
153
158
  /* The follow happens here and not on append, because only a build knows how tall the list is. */
154
159
  if (view.pinned)
155
160
  view.scrollY = maxScroll(view, view.shown.length);
161
+ /* And onto the root, which is what ui2d offsets the rows by: the scroll that is drawn. */
162
+ root.scrollY = view.scrollY;
156
163
  },
157
164
  route(_world, view, event) {
158
165
  if (event.kind === 'wheel') {
@@ -162,6 +169,7 @@ export const consolePanel = {
162
169
  const limit = maxScroll(view, view.shown.length);
163
170
  view.scrollY = Math.min(limit, Math.max(0, view.scrollY + event.dy));
164
171
  view.pinned = view.scrollY >= limit;
172
+ view.root.scrollY = view.scrollY;
165
173
  return null;
166
174
  }
167
175
  if (event.kind !== 'pointer' || event.phase !== 'up' || event.button !== 0)
package/dist/index.d.ts CHANGED
@@ -35,4 +35,4 @@ export type { NetworkReadout, NetworkView, NetworkWorld, RollbackHistory } from
35
35
  export type { SessionLike, SessionRecorder } from './network.ts';
36
36
  export { componentDivergence, createNetworkView, createRollbackHistory, createSessionRecorder, networkPanel, observeSession, pushRollback, rollbackValues, sessionReadout, } from './network.ts';
37
37
  export type { OverlayPainter, OverlaySite, PanelBinding, ToolsOverlay, ToolsOverlayOptions, } from './overlay.ts';
38
- export { bindPanel, createToolsOverlay, paintOverlay } from './overlay.ts';
38
+ export { PANEL_TITLE_HEIGHT, bindPanel, createToolsOverlay, paintOverlay } from './overlay.ts';
package/dist/index.js CHANGED
@@ -7,4 +7,4 @@ export { MIXED, addComponentCommand, createInspectorView, entitiesInspectable, f
7
7
  export { DISPLAY_LIMIT, appendLog, consolePanel, createConsoleView, createLogFilter, createLogRing, displayText, filteredEntries, jumpToSourceCommand, logEntries, } from './console.js';
8
8
  export { createFrameHistory, createGpuPassTimings, createProfilerView, formatMs, historyValues, occupancy, profilerPanel, profilerRows, pushFrame, recordGpuSample, } from './profiler.js';
9
9
  export { componentDivergence, createNetworkView, createRollbackHistory, createSessionRecorder, networkPanel, observeSession, pushRollback, rollbackValues, sessionReadout, } from './network.js';
10
- export { bindPanel, createToolsOverlay, paintOverlay } from './overlay.js';
10
+ export { PANEL_TITLE_HEIGHT, bindPanel, createToolsOverlay, paintOverlay } from './overlay.js';
@@ -30,7 +30,10 @@ export interface InspectorRow {
30
30
  readonly component: string;
31
31
  /** What the row is called: a field's name, or the prefix the vector's three share. */
32
32
  readonly label: string;
33
- /** The declared type, verbatim. Empty for a vector, which is three of them. */
33
+ /**
34
+ * The declared type, verbatim. For a vector, the type its three parts share, or empty where they
35
+ * do not share one.
36
+ */
34
37
  readonly type: string;
35
38
  readonly kind: RowKind;
36
39
  readonly enumName: string;
package/dist/inspector.js CHANGED
@@ -104,10 +104,11 @@ export function inspectorRows(world, entities) {
104
104
  continue;
105
105
  emitted.add(part.prefix);
106
106
  const slots = vectors.get(part.prefix);
107
+ const types = new Set(fields.filter((one) => slots.includes(one.name)).map((one) => one.type));
107
108
  rows.push({
108
109
  component,
109
110
  label: part.prefix,
110
- type: '',
111
+ type: types.size === 1 ? field.type : '',
111
112
  kind: 'vector',
112
113
  enumName: '',
113
114
  fields: slots,
@@ -227,12 +228,32 @@ export function rowText(row) {
227
228
  if (row.value === MIXED)
228
229
  return `${row.label} —`;
229
230
  if (row.kind === 'vector') {
230
- const parts = row.value.map((part) => (part === MIXED ? '—' : String(part)));
231
+ const parts = row.value.map((part) => part === MIXED ? '—' : valueText(part, row.type));
231
232
  return `${row.label} ${parts.join(', ')}`;
232
233
  }
233
234
  if (row.kind === 'boolean')
234
235
  return `${row.label} ${row.value ? 'true' : 'false'}`;
235
- return `${row.label} ${String(row.value)}`;
236
+ return `${row.label} ${valueText(row.value, row.type)}`;
237
+ }
238
+ /**
239
+ * One value as text: an `f32` as the shortest decimal that stores as the same `f32`.
240
+ *
241
+ * **What was written rather than what is stored.** A `Float32Array` hands back the nearest double,
242
+ * so a bounce of 0.55 reads `0.550000011920929`; the digits past the seventh are the storage, and
243
+ * the shortest decimal that rounds back to the same `f32` is what anybody wrote. Nine significant
244
+ * digits always round-trip a single, so the search ends there. An `f64` and anything else keep
245
+ * `String`, whose own answer is already the shortest for a double.
246
+ */
247
+ function valueText(value, type) {
248
+ if (type !== 'f32' || typeof value !== 'number' || !Number.isFinite(value))
249
+ return String(value);
250
+ const stored = Math.fround(value);
251
+ for (let digits = 1; digits < 9; digits += 1) {
252
+ const shorter = Number(stored.toPrecision(digits));
253
+ if (Math.fround(shorter) === stored)
254
+ return String(shorter);
255
+ }
256
+ return String(Number(stored.toPrecision(9)));
236
257
  }
237
258
  export const inspectorPanel = {
238
259
  id: 'inspector',
package/dist/network.d.ts CHANGED
@@ -62,14 +62,25 @@ export declare const networkPanel: Panel<NetworkWorld, NetworkView>;
62
62
  * Structural rather than imported, so nothing here loads `@driftengine/network` at run time and a
63
63
  * consumer whose session is its own shape can still be read.
64
64
  */
65
- export interface SessionLike {
65
+ /**
66
+ * A session as the network panel reads it, which a `LockstepSession` is as it stands.
67
+ *
68
+ * **The depth by either name**: `rewindDepth` is what a session answers, and `loop.depth` is the
69
+ * shape a game built by hand before it did — from the session's fields and the `RewindLoop` it
70
+ * made — which still reads as it did.
71
+ */
72
+ export type SessionLike = SessionFields & ({
73
+ readonly rewindDepth: number;
74
+ } | {
75
+ readonly loop: {
76
+ readonly depth: number;
77
+ };
78
+ });
79
+ interface SessionFields {
66
80
  readonly participants: number;
67
81
  readonly inputDelay: number;
68
82
  /** The disagreement the session is currently holding, or null. Latched, not an event. */
69
83
  readonly desync: Desync | null;
70
- readonly loop: {
71
- readonly depth: number;
72
- };
73
84
  }
74
85
  /** What a recorder keeps: the disagreements it has seen, oldest first. */
75
86
  export interface SessionRecorder {
@@ -98,3 +109,4 @@ export declare function observeSession(recorder: SessionRecorder, session: Sessi
98
109
  * passing zero is saying it has not measured, which the panel renders as such.
99
110
  */
100
111
  export declare function sessionReadout(session: SessionLike, recorder: SessionRecorder, snapshotBytes: number, componentHashes?: NetworkReadout['componentHashes']): NetworkReadout;
112
+ export {};
package/dist/network.js CHANGED
@@ -156,7 +156,7 @@ export function sessionReadout(session, recorder, snapshotBytes, componentHashes
156
156
  inputDelay: session.inputDelay,
157
157
  participants: session.participants,
158
158
  snapshotBytes,
159
- snapshotCount: session.loop.depth,
159
+ snapshotCount: 'rewindDepth' in session ? session.rewindDepth : session.loop.depth,
160
160
  desyncs: recorder.desyncs,
161
161
  componentHashes,
162
162
  };
package/dist/overlay.d.ts CHANGED
@@ -60,6 +60,11 @@ export interface ToolsOverlay {
60
60
  invalidate(): void;
61
61
  dispose(): void;
62
62
  }
63
+ /**
64
+ * How tall the title row above every panel is, in the overlay's pixels. A panel's own space starts
65
+ * below it, so a host hit-testing a site by hand reads the content from `site.y` plus this.
66
+ */
67
+ export declare const PANEL_TITLE_HEIGHT = 18;
63
68
  export declare function createToolsOverlay(options: ToolsOverlayOptions): ToolsOverlay;
64
69
  /**
65
70
  * Draw the overlay, or draw nothing because it is closed.
package/dist/overlay.js CHANGED
@@ -41,6 +41,18 @@ export function bindPanel(panel, world, view) {
41
41
  };
42
42
  }
43
43
  const DEFAULT_WIDTH = 320;
44
+ /**
45
+ * How tall the title row above every panel is, in the overlay's pixels. A panel's own space starts
46
+ * below it, so a host hit-testing a site by hand reads the content from `site.y` plus this.
47
+ */
48
+ export const PANEL_TITLE_HEIGHT = 18;
49
+ /**
50
+ * What every panel is drawn on, and its title on: dark and nearly opaque, so the rows read over a
51
+ * bright scene. The overlay carried no background until 4.8.2 and a host laid one under each site.
52
+ */
53
+ const PANEL_BACKGROUND = new Float32Array([0.063, 0.071, 0.094, 0.9]);
54
+ const TITLE_BACKGROUND = new Float32Array([0.122, 0.133, 0.176, 0.95]);
55
+ const TITLE_TINT = new Float32Array([0.62, 0.66, 0.75, 1]);
44
56
  const DEFAULT_KEY = 'F3';
45
57
  export function createToolsOverlay(options) {
46
58
  const root = createUiNode({ name: 'tools-overlay', width: 'grow', height: 'grow' });
@@ -50,6 +62,7 @@ export function createToolsOverlay(options) {
50
62
  const columnWidth = options.width ?? DEFAULT_WIDTH;
51
63
  const panels = options.panels;
52
64
  const roots = new Map();
65
+ const titles = new Map();
53
66
  /* Pooled and refilled: this is read every frame the overlay is open and must not allocate. */
54
67
  const sites = [];
55
68
  let width = 0;
@@ -78,17 +91,33 @@ export function createToolsOverlay(options) {
78
91
  const top = each * at;
79
92
  const tall = at === panels.length - 1 ? height - top : each;
80
93
  let panelRoot = roots.get(panel.id);
81
- if (panelRoot === undefined) {
94
+ let title = titles.get(panel.id);
95
+ if (panelRoot === undefined || title === undefined) {
82
96
  panelRoot = createPanelRoot(panel);
97
+ panelRoot.background = PANEL_BACKGROUND;
83
98
  roots.set(panel.id, panelRoot);
99
+ title = createUiNode({
100
+ text: panel.title,
101
+ background: TITLE_BACKGROUND,
102
+ tint: TITLE_TINT,
103
+ name: `title:${panel.id}`,
104
+ });
105
+ titles.set(panel.id, title);
84
106
  }
107
+ /* The title above the panel's own space, which starts below it: a panel never learns it has
108
+ one, and its rows hit-test exactly as they did. */
109
+ title.absolute = true;
110
+ title.x = 0;
111
+ title.y = top;
112
+ title.width = w;
113
+ title.height = Math.min(PANEL_TITLE_HEIGHT, tall);
85
114
  panelRoot.absolute = true;
86
115
  panelRoot.x = 0;
87
- panelRoot.y = top;
116
+ panelRoot.y = top + PANEL_TITLE_HEIGHT;
88
117
  panelRoot.width = w;
89
- panelRoot.height = tall;
118
+ panelRoot.height = Math.max(0, tall - PANEL_TITLE_HEIGHT);
90
119
  panel.build(panelRoot);
91
- root.children.push(panelRoot);
120
+ root.children.push(title, panelRoot);
92
121
  sites.push({ id: panel.id, x: 0, y: top, w, h: tall });
93
122
  }
94
123
  layoutUiTree(root, x, 0, w, height);
@@ -149,13 +178,14 @@ export function createToolsOverlay(options) {
149
178
  if (!visible)
150
179
  return false;
151
180
  if (event.kind === 'key') {
152
- if (event.ctrl && event.key === 'z') {
181
+ /* Either case: a browser reports Ctrl+Shift+Z with the key `'Z'`, shift having raised it. */
182
+ if (event.ctrl && (event.key === 'z' || event.key === 'Z')) {
153
183
  const moved = event.shift ? undo.redo() : undo.undo();
154
184
  if (moved)
155
185
  dirty = true;
156
186
  return moved;
157
187
  }
158
- if (event.ctrl && event.key === 'y') {
188
+ if (event.ctrl && (event.key === 'y' || event.key === 'Y')) {
159
189
  const moved = undo.redo();
160
190
  if (moved)
161
191
  dirty = true;
@@ -174,8 +204,18 @@ export function createToolsOverlay(options) {
174
204
  const panel = panelWith(site.id);
175
205
  if (panel === null)
176
206
  return false;
207
+ const contentTop = site.y + PANEL_TITLE_HEIGHT;
208
+ /* The title row is the overlay's, not the panel's: it scrolls nothing and routes nothing. */
209
+ if (event.y < contentTop)
210
+ return event.kind === 'wheel';
177
211
  /* In the panel's own space: a panel never learns where on screen it is. */
178
- return take(panel.route(shift(event, -(root.rect.x + site.x), -site.y)));
212
+ const handled = take(panel.route(shift(event, -(root.rect.x + site.x), -contentTop)));
213
+ if (event.kind !== 'wheel')
214
+ return handled;
215
+ /* A wheel over a panel scrolls the panel and is the overlay's whether or not it made a
216
+ command, or the page scrolls under the panel as well. Laid out again, for the new scroll. */
217
+ dirty = true;
218
+ return true;
179
219
  },
180
220
  invalidate() {
181
221
  dirty = true;
@@ -263,11 +303,13 @@ function paintNode(painter, node) {
263
303
  if (node.clip)
264
304
  painter.unclip();
265
305
  }
306
+ /** `#rrggbb`, or `#rrggbbaa` where the colour is not opaque, which the painter's contract allows. */
266
307
  function cssColour(rgba) {
267
308
  if (rgba === null)
268
309
  return '#ffffff';
269
310
  let out = '#';
270
- for (let at = 0; at < 3; at += 1) {
311
+ const channels = (rgba[3] ?? 1) < 1 ? 4 : 3;
312
+ for (let at = 0; at < channels; at += 1) {
271
313
  const channel = Math.round(Math.min(1, Math.max(0, rgba[at] ?? 0)) * 255);
272
314
  out += channel.toString(16).padStart(2, '0');
273
315
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/tools",
3
- "version": "4.8.1",
3
+ "version": "4.8.3",
4
4
  "description": "The editor panels a shipped game can carry, and the command stack that makes their edits undoable",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -27,11 +27,11 @@
27
27
  ],
28
28
  "sideEffects": false,
29
29
  "peerDependencies": {
30
- "@driftengine/core": "4.8.1",
31
- "@driftengine/editor": "4.8.1",
32
- "@driftengine/network": "4.8.1",
33
- "@driftengine/texture": "4.8.1",
34
- "@driftengine/ui2d": "4.8.1"
30
+ "@driftengine/core": "4.8.3",
31
+ "@driftengine/editor": "4.8.3",
32
+ "@driftengine/network": "4.8.3",
33
+ "@driftengine/texture": "4.8.3",
34
+ "@driftengine/ui2d": "4.8.3"
35
35
  },
36
36
  "author": "Drift Technologies",
37
37
  "repository": {
package/src/console.ts CHANGED
@@ -215,10 +215,14 @@ export const consolePanel: Panel<ConsoleWorld, ConsoleView> = {
215
215
  title: 'Console',
216
216
 
217
217
  build(world, view, root): void {
218
+ /* The window is the height the panel was given, where it was given one: the overlay sets it on
219
+ the root before it builds. `viewHeight` is what a root with no exact height falls back to. */
220
+ if (typeof root.height === 'number' && root.height > 0) view.viewHeight = root.height;
218
221
  view.shown = filteredEntries(world.log, view.filter);
219
222
  if (view.shown.length === 0) {
220
223
  emptyPanel(root, 'Nothing logged');
221
224
  view.scrollY = 0;
225
+ root.scrollY = 0;
222
226
  return;
223
227
  }
224
228
 
@@ -238,6 +242,8 @@ export const consolePanel: Panel<ConsoleWorld, ConsoleView> = {
238
242
  }
239
243
  /* The follow happens here and not on append, because only a build knows how tall the list is. */
240
244
  if (view.pinned) view.scrollY = maxScroll(view, view.shown.length);
245
+ /* And onto the root, which is what ui2d offsets the rows by: the scroll that is drawn. */
246
+ root.scrollY = view.scrollY;
241
247
  },
242
248
 
243
249
  route(_world, view, event): Command | null {
@@ -248,6 +254,7 @@ export const consolePanel: Panel<ConsoleWorld, ConsoleView> = {
248
254
  const limit = maxScroll(view, view.shown.length);
249
255
  view.scrollY = Math.min(limit, Math.max(0, view.scrollY + event.dy));
250
256
  view.pinned = view.scrollY >= limit;
257
+ view.root.scrollY = view.scrollY;
251
258
  return null;
252
259
  }
253
260
  if (event.kind !== 'pointer' || event.phase !== 'up' || event.button !== 0) return null;
package/src/index.ts CHANGED
@@ -142,4 +142,4 @@ export type {
142
142
  ToolsOverlay,
143
143
  ToolsOverlayOptions,
144
144
  } from './overlay.ts';
145
- export { bindPanel, createToolsOverlay, paintOverlay } from './overlay.ts';
145
+ export { PANEL_TITLE_HEIGHT, bindPanel, createToolsOverlay, paintOverlay } from './overlay.ts';
package/src/inspector.ts CHANGED
@@ -48,7 +48,10 @@ export interface InspectorRow {
48
48
  readonly component: string;
49
49
  /** What the row is called: a field's name, or the prefix the vector's three share. */
50
50
  readonly label: string;
51
- /** The declared type, verbatim. Empty for a vector, which is three of them. */
51
+ /**
52
+ * The declared type, verbatim. For a vector, the type its three parts share, or empty where they
53
+ * do not share one.
54
+ */
52
55
  readonly type: string;
53
56
  readonly kind: RowKind;
54
57
  readonly enumName: string;
@@ -153,10 +156,13 @@ export function inspectorRows(
153
156
  if (emitted.has(part.prefix)) continue;
154
157
  emitted.add(part.prefix);
155
158
  const slots = vectors.get(part.prefix) as string[];
159
+ const types = new Set(
160
+ fields.filter((one) => slots.includes(one.name)).map((one) => one.type),
161
+ );
156
162
  rows.push({
157
163
  component,
158
164
  label: part.prefix,
159
- type: '',
165
+ type: types.size === 1 ? field.type : '',
160
166
  kind: 'vector',
161
167
  enumName: '',
162
168
  fields: slots,
@@ -307,11 +313,32 @@ export function createInspectorView(options: {
307
313
  export function rowText(row: InspectorRow): string {
308
314
  if (row.value === MIXED) return `${row.label} —`;
309
315
  if (row.kind === 'vector') {
310
- const parts = (row.value as unknown[]).map((part) => (part === MIXED ? '—' : String(part)));
316
+ const parts = (row.value as unknown[]).map((part) =>
317
+ part === MIXED ? '—' : valueText(part, row.type),
318
+ );
311
319
  return `${row.label} ${parts.join(', ')}`;
312
320
  }
313
321
  if (row.kind === 'boolean') return `${row.label} ${row.value ? 'true' : 'false'}`;
314
- return `${row.label} ${String(row.value)}`;
322
+ return `${row.label} ${valueText(row.value, row.type)}`;
323
+ }
324
+
325
+ /**
326
+ * One value as text: an `f32` as the shortest decimal that stores as the same `f32`.
327
+ *
328
+ * **What was written rather than what is stored.** A `Float32Array` hands back the nearest double,
329
+ * so a bounce of 0.55 reads `0.550000011920929`; the digits past the seventh are the storage, and
330
+ * the shortest decimal that rounds back to the same `f32` is what anybody wrote. Nine significant
331
+ * digits always round-trip a single, so the search ends there. An `f64` and anything else keep
332
+ * `String`, whose own answer is already the shortest for a double.
333
+ */
334
+ function valueText(value: unknown, type: string): string {
335
+ if (type !== 'f32' || typeof value !== 'number' || !Number.isFinite(value)) return String(value);
336
+ const stored = Math.fround(value);
337
+ for (let digits = 1; digits < 9; digits += 1) {
338
+ const shorter = Number(stored.toPrecision(digits));
339
+ if (Math.fround(shorter) === stored) return String(shorter);
340
+ }
341
+ return String(Number(stored.toPrecision(9)));
315
342
  }
316
343
 
317
344
  export const inspectorPanel: Panel<InspectorWorld, InspectorView> = {
package/src/network.ts CHANGED
@@ -190,12 +190,21 @@ export const networkPanel: Panel<NetworkWorld, NetworkView> = {
190
190
  * Structural rather than imported, so nothing here loads `@driftengine/network` at run time and a
191
191
  * consumer whose session is its own shape can still be read.
192
192
  */
193
- export interface SessionLike {
193
+ /**
194
+ * A session as the network panel reads it, which a `LockstepSession` is as it stands.
195
+ *
196
+ * **The depth by either name**: `rewindDepth` is what a session answers, and `loop.depth` is the
197
+ * shape a game built by hand before it did — from the session's fields and the `RewindLoop` it
198
+ * made — which still reads as it did.
199
+ */
200
+ export type SessionLike = SessionFields &
201
+ ({ readonly rewindDepth: number } | { readonly loop: { readonly depth: number } });
202
+
203
+ interface SessionFields {
194
204
  readonly participants: number;
195
205
  readonly inputDelay: number;
196
206
  /** The disagreement the session is currently holding, or null. Latched, not an event. */
197
207
  readonly desync: Desync | null;
198
- readonly loop: { readonly depth: number };
199
208
  }
200
209
 
201
210
  /** What a recorder keeps: the disagreements it has seen, oldest first. */
@@ -246,7 +255,7 @@ export function sessionReadout(
246
255
  inputDelay: session.inputDelay,
247
256
  participants: session.participants,
248
257
  snapshotBytes,
249
- snapshotCount: session.loop.depth,
258
+ snapshotCount: 'rewindDepth' in session ? session.rewindDepth : session.loop.depth,
250
259
  desyncs: recorder.desyncs,
251
260
  componentHashes,
252
261
  };
package/src/overlay.ts CHANGED
@@ -111,6 +111,20 @@ export interface ToolsOverlay {
111
111
  }
112
112
 
113
113
  const DEFAULT_WIDTH = 320;
114
+
115
+ /**
116
+ * How tall the title row above every panel is, in the overlay's pixels. A panel's own space starts
117
+ * below it, so a host hit-testing a site by hand reads the content from `site.y` plus this.
118
+ */
119
+ export const PANEL_TITLE_HEIGHT = 18;
120
+
121
+ /**
122
+ * What every panel is drawn on, and its title on: dark and nearly opaque, so the rows read over a
123
+ * bright scene. The overlay carried no background until 4.8.2 and a host laid one under each site.
124
+ */
125
+ const PANEL_BACKGROUND = new Float32Array([0.063, 0.071, 0.094, 0.9]);
126
+ const TITLE_BACKGROUND = new Float32Array([0.122, 0.133, 0.176, 0.95]);
127
+ const TITLE_TINT = new Float32Array([0.62, 0.66, 0.75, 1]);
114
128
  const DEFAULT_KEY = 'F3';
115
129
 
116
130
  export function createToolsOverlay(options: ToolsOverlayOptions): ToolsOverlay {
@@ -122,6 +136,7 @@ export function createToolsOverlay(options: ToolsOverlayOptions): ToolsOverlay {
122
136
 
123
137
  const panels = options.panels;
124
138
  const roots = new Map<string, UiNode>();
139
+ const titles = new Map<string, UiNode>();
125
140
  /* Pooled and refilled: this is read every frame the overlay is open and must not allocate. */
126
141
  const sites: OverlaySite[] = [];
127
142
 
@@ -154,17 +169,33 @@ export function createToolsOverlay(options: ToolsOverlayOptions): ToolsOverlay {
154
169
  const tall = at === panels.length - 1 ? height - top : each;
155
170
 
156
171
  let panelRoot = roots.get(panel.id);
157
- if (panelRoot === undefined) {
172
+ let title = titles.get(panel.id);
173
+ if (panelRoot === undefined || title === undefined) {
158
174
  panelRoot = createPanelRoot(panel);
175
+ panelRoot.background = PANEL_BACKGROUND;
159
176
  roots.set(panel.id, panelRoot);
177
+ title = createUiNode({
178
+ text: panel.title,
179
+ background: TITLE_BACKGROUND,
180
+ tint: TITLE_TINT,
181
+ name: `title:${panel.id}`,
182
+ });
183
+ titles.set(panel.id, title);
160
184
  }
185
+ /* The title above the panel's own space, which starts below it: a panel never learns it has
186
+ one, and its rows hit-test exactly as they did. */
187
+ title.absolute = true;
188
+ title.x = 0;
189
+ title.y = top;
190
+ title.width = w;
191
+ title.height = Math.min(PANEL_TITLE_HEIGHT, tall);
161
192
  panelRoot.absolute = true;
162
193
  panelRoot.x = 0;
163
- panelRoot.y = top;
194
+ panelRoot.y = top + PANEL_TITLE_HEIGHT;
164
195
  panelRoot.width = w;
165
- panelRoot.height = tall;
196
+ panelRoot.height = Math.max(0, tall - PANEL_TITLE_HEIGHT);
166
197
  panel.build(panelRoot);
167
- root.children.push(panelRoot);
198
+ root.children.push(title, panelRoot);
168
199
  sites.push({ id: panel.id, x: 0, y: top, w, h: tall });
169
200
  }
170
201
 
@@ -226,12 +257,13 @@ export function createToolsOverlay(options: ToolsOverlayOptions): ToolsOverlay {
226
257
  if (!visible) return false;
227
258
 
228
259
  if (event.kind === 'key') {
229
- if (event.ctrl && event.key === 'z') {
260
+ /* Either case: a browser reports Ctrl+Shift+Z with the key `'Z'`, shift having raised it. */
261
+ if (event.ctrl && (event.key === 'z' || event.key === 'Z')) {
230
262
  const moved = event.shift ? undo.redo() : undo.undo();
231
263
  if (moved) dirty = true;
232
264
  return moved;
233
265
  }
234
- if (event.ctrl && event.key === 'y') {
266
+ if (event.ctrl && (event.key === 'y' || event.key === 'Y')) {
235
267
  const moved = undo.redo();
236
268
  if (moved) dirty = true;
237
269
  return moved;
@@ -246,8 +278,16 @@ export function createToolsOverlay(options: ToolsOverlayOptions): ToolsOverlay {
246
278
  if (event.kind === 'pointer' && event.phase === 'down') focused = site.id;
247
279
  const panel = panelWith(site.id);
248
280
  if (panel === null) return false;
281
+ const contentTop = site.y + PANEL_TITLE_HEIGHT;
282
+ /* The title row is the overlay's, not the panel's: it scrolls nothing and routes nothing. */
283
+ if (event.y < contentTop) return event.kind === 'wheel';
249
284
  /* In the panel's own space: a panel never learns where on screen it is. */
250
- return take(panel.route(shift(event, -(root.rect.x + site.x), -site.y)));
285
+ const handled = take(panel.route(shift(event, -(root.rect.x + site.x), -contentTop)));
286
+ if (event.kind !== 'wheel') return handled;
287
+ /* A wheel over a panel scrolls the panel and is the overlay's whether or not it made a
288
+ command, or the page scrolls under the panel as well. Laid out again, for the new scroll. */
289
+ dirty = true;
290
+ return true;
251
291
  },
252
292
 
253
293
  invalidate(): void {
@@ -331,10 +371,12 @@ function paintNode(painter: OverlayPainter, node: UiNode): void {
331
371
  if (node.clip) painter.unclip();
332
372
  }
333
373
 
374
+ /** `#rrggbb`, or `#rrggbbaa` where the colour is not opaque, which the painter's contract allows. */
334
375
  function cssColour(rgba: Float32Array | null): string {
335
376
  if (rgba === null) return '#ffffff';
336
377
  let out = '#';
337
- for (let at = 0; at < 3; at += 1) {
378
+ const channels = (rgba[3] ?? 1) < 1 ? 4 : 3;
379
+ for (let at = 0; at < channels; at += 1) {
338
380
  const channel = Math.round(Math.min(1, Math.max(0, rgba[at] ?? 0)) * 255);
339
381
  out += channel.toString(16).padStart(2, '0');
340
382
  }