@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/src/console.ts ADDED
@@ -0,0 +1,260 @@
1
+ /**
2
+ * The log, filtered, with a way back to the line that produced it.
3
+ *
4
+ * **Scrolling up to read something must not be yanked away by the next log line.** That is the
5
+ * whole point of this panel: a console that always jumps to the bottom is unreadable during the
6
+ * thing being debugged, because that is exactly when lines arrive fastest. So the view follows the
7
+ * newest entry *only while it was already at the bottom*, and leaving the bottom stops it.
8
+ *
9
+ * **A repeat collapses into a count.** A warning inside a frame loop arrives sixty times a second,
10
+ * and without this the whole ring becomes one message and everything before it is gone — which is
11
+ * precisely what somebody is looking for when a warning starts repeating.
12
+ *
13
+ * **A long entry is truncated for display and kept whole underneath.** A single line of a hundred
14
+ * kilobytes — a serialised object, a shader source — would otherwise be the panel.
15
+ */
16
+ import { addUiChild, createUiNode } from '@driftengine/ui2d';
17
+ import type { UiNode } from '@driftengine/ui2d';
18
+ import type { Command } from './command.ts';
19
+ import { createPanelRoot, emptyPanel, type Panel } from './panel.ts';
20
+
21
+ export type Severity = 'debug' | 'info' | 'warn' | 'error';
22
+
23
+ /** Ascending, so a minimum keeps everything at least as severe. */
24
+ const RANK: Readonly<Record<Severity, number>> = { debug: 0, info: 1, warn: 2, error: 3 };
25
+
26
+ export interface LogEntry {
27
+ severity: Severity;
28
+ text: string;
29
+ /** How many identical entries this one stands for. One for a single occurrence. */
30
+ count: number;
31
+ /** Where it came from, or empty. */
32
+ file: string;
33
+ line: number;
34
+ }
35
+
36
+ /** A fixed window of entries. Pooled, because a log is appended to forever. */
37
+ export interface LogRing {
38
+ readonly pool: LogEntry[];
39
+ readonly capacity: number;
40
+ head: number;
41
+ filled: number;
42
+ }
43
+
44
+ export function createLogRing(capacity: number): LogRing {
45
+ const size = Math.max(1, capacity);
46
+ return {
47
+ pool: Array.from({ length: size }, () => ({
48
+ severity: 'info' as Severity,
49
+ text: '',
50
+ count: 0,
51
+ file: '',
52
+ line: 0,
53
+ })),
54
+ capacity: size,
55
+ head: 0,
56
+ filled: 0,
57
+ };
58
+ }
59
+
60
+ /** The newest entry, or null. What a repeat is compared against. */
61
+ function newest(log: LogRing): LogEntry | null {
62
+ if (log.filled === 0) return null;
63
+ return log.pool[(log.head + log.capacity - 1) % log.capacity] as LogEntry;
64
+ }
65
+
66
+ /**
67
+ * Append, collapsing an exact repeat of the newest entry into its count.
68
+ *
69
+ * **Against the newest only**, and not against everything in the window. Collapsing against any
70
+ * earlier entry would reorder the log — the repeat would appear where the first one was, out of
71
+ * sequence with whatever happened in between, and the sequence is what a log is for.
72
+ */
73
+ export function appendLog(
74
+ log: LogRing,
75
+ severity: Severity,
76
+ text: string,
77
+ file = '',
78
+ line = 0,
79
+ ): void {
80
+ const last = newest(log);
81
+ if (
82
+ last !== null &&
83
+ last.severity === severity &&
84
+ last.text === text &&
85
+ last.file === file &&
86
+ last.line === line
87
+ ) {
88
+ last.count += 1;
89
+ return;
90
+ }
91
+
92
+ const entry = log.pool[log.head] as LogEntry;
93
+ entry.severity = severity;
94
+ entry.text = text;
95
+ entry.count = 1;
96
+ entry.file = file;
97
+ entry.line = line;
98
+ log.head = (log.head + 1) % log.capacity;
99
+ if (log.filled < log.capacity) log.filled += 1;
100
+ }
101
+
102
+ /** The window, oldest first. */
103
+ export function logEntries(log: LogRing): LogEntry[] {
104
+ const first = log.filled < log.capacity ? 0 : log.head;
105
+ const out: LogEntry[] = [];
106
+ for (let at = 0; at < log.filled; at += 1) {
107
+ out.push(log.pool[(first + at) % log.capacity] as LogEntry);
108
+ }
109
+ return out;
110
+ }
111
+
112
+ export interface LogFilter {
113
+ minSeverity: Severity;
114
+ /** Matched case-insensitively against the entry's text. Empty matches everything. */
115
+ text: string;
116
+ }
117
+
118
+ export function createLogFilter(): LogFilter {
119
+ return { minSeverity: 'debug', text: '' };
120
+ }
121
+
122
+ export function filteredEntries(log: LogRing, filter: LogFilter): LogEntry[] {
123
+ const floor = RANK[filter.minSeverity];
124
+ const needle = filter.text.toLowerCase();
125
+ return logEntries(log).filter(
126
+ (entry) =>
127
+ RANK[entry.severity] >= floor && (needle === '' || entry.text.toLowerCase().includes(needle)),
128
+ );
129
+ }
130
+
131
+ /** How much of one entry a row shows. Past this the rest is still on the entry. */
132
+ export const DISPLAY_LIMIT = 200;
133
+
134
+ export function displayText(entry: LogEntry): string {
135
+ const body =
136
+ entry.text.length > DISPLAY_LIMIT ? `${entry.text.slice(0, DISPLAY_LIMIT)}…` : entry.text;
137
+ return entry.count > 1 ? `${body} (${entry.count})` : body;
138
+ }
139
+
140
+ /** Where a caller is looking. The console asks to move it; the caller owns it. */
141
+ export interface SourceCursor {
142
+ file: string;
143
+ line: number;
144
+ }
145
+
146
+ /**
147
+ * Ask to look at a place in a file.
148
+ *
149
+ * **A command, so going back is the same gesture as any other undo.** Whether a caller puts it on
150
+ * the undo stack or merely applies it is the caller's decision — the console knows how to describe
151
+ * the jump and not what navigation should cost.
152
+ */
153
+ export function jumpToSourceCommand(
154
+ cursor: SourceCursor,
155
+ file: string,
156
+ line: number,
157
+ ): Command | null {
158
+ if (file === '') return null;
159
+ if (cursor.file === file && cursor.line === line) return null;
160
+ const was = { file: cursor.file, line: cursor.line };
161
+ return {
162
+ label: `Go to ${file}:${line}`,
163
+ apply: (): void => {
164
+ cursor.file = file;
165
+ cursor.line = line;
166
+ },
167
+ revert: (): void => {
168
+ cursor.file = was.file;
169
+ cursor.line = was.line;
170
+ },
171
+ };
172
+ }
173
+
174
+ export interface ConsoleWorld {
175
+ readonly log: LogRing;
176
+ }
177
+
178
+ export interface ConsoleView {
179
+ readonly root: UiNode;
180
+ readonly filter: LogFilter;
181
+ /** Where the caller is looking. Read by `route` to refuse a jump that goes nowhere. */
182
+ readonly cursor: SourceCursor;
183
+ rowHeight: number;
184
+ viewHeight: number;
185
+ scrollY: number;
186
+ /** Whether the view follows the newest entry. True while it is at the bottom. */
187
+ pinned: boolean;
188
+ /** What the last build showed, so a click finds the entry rather than the row. */
189
+ shown: LogEntry[];
190
+ }
191
+
192
+ export function createConsoleView(options: {
193
+ rowHeight?: number;
194
+ viewHeight?: number;
195
+ }): ConsoleView {
196
+ return {
197
+ root: createPanelRoot(consolePanel),
198
+ filter: createLogFilter(),
199
+ cursor: { file: '', line: 0 },
200
+ rowHeight: options.rowHeight ?? 16,
201
+ viewHeight: options.viewHeight ?? 200,
202
+ scrollY: 0,
203
+ pinned: true,
204
+ shown: [],
205
+ };
206
+ }
207
+
208
+ /** The furthest down the view can go. Zero where the content is shorter than the view. */
209
+ function maxScroll(view: ConsoleView, rows: number): number {
210
+ return Math.max(0, rows * view.rowHeight - view.viewHeight);
211
+ }
212
+
213
+ export const consolePanel: Panel<ConsoleWorld, ConsoleView> = {
214
+ id: 'console',
215
+ title: 'Console',
216
+
217
+ build(world, view, root): void {
218
+ view.shown = filteredEntries(world.log, view.filter);
219
+ if (view.shown.length === 0) {
220
+ emptyPanel(root, 'Nothing logged');
221
+ view.scrollY = 0;
222
+ return;
223
+ }
224
+
225
+ root.children.length = 0;
226
+ for (let at = 0; at < view.shown.length; at += 1) {
227
+ const entry = view.shown[at] as LogEntry;
228
+ addUiChild(
229
+ root,
230
+ createUiNode({
231
+ width: 'grow',
232
+ height: view.rowHeight,
233
+ text: displayText(entry),
234
+ interactive: true,
235
+ name: `log:${at}`,
236
+ }),
237
+ );
238
+ }
239
+ /* The follow happens here and not on append, because only a build knows how tall the list is. */
240
+ if (view.pinned) view.scrollY = maxScroll(view, view.shown.length);
241
+ },
242
+
243
+ route(_world, view, event): Command | null {
244
+ if (event.kind === 'wheel') {
245
+ /* `+= dy`, which is `scrollBy`'s convention in `@driftengine/ui2d`: positive goes down the
246
+ content. Inverting it here would make this the one scrollable thing in the editor that
247
+ went the other way. */
248
+ const limit = maxScroll(view, view.shown.length);
249
+ view.scrollY = Math.min(limit, Math.max(0, view.scrollY + event.dy));
250
+ view.pinned = view.scrollY >= limit;
251
+ return null;
252
+ }
253
+ if (event.kind !== 'pointer' || event.phase !== 'up' || event.button !== 0) return null;
254
+
255
+ const at = Math.floor((event.y + view.scrollY) / view.rowHeight);
256
+ const entry = view.shown[at];
257
+ if (entry === undefined) return null;
258
+ return jumpToSourceCommand(view.cursor, entry.file, entry.line);
259
+ },
260
+ };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Which of two sets of component hashes disagree.
3
+ *
4
+ * **Here rather than beside the divergence search, because a shipped game gets this half.** The
5
+ * search walks a timeline, which only the editor has; comparing what two peers hashed is what an
6
+ * in-game network panel does the moment a rollback session desynchronises, and that is a panel a
7
+ * game carries. `editor/src/pie/divergence.ts` holds the search and imports these.
8
+ */
9
+ /**
10
+ * Which components two sides hash differently, into `out`. Returns how many.
11
+ *
12
+ * A component only one side has is a divergence too, and the loudest kind: it means one peer
13
+ * created or destroyed something the other did not.
14
+ */
15
+ export function divergentComponents(
16
+ ours: ReadonlyMap<string, string>,
17
+ theirs: ReadonlyMap<string, string>,
18
+ out: string[],
19
+ ): number {
20
+ out.length = 0;
21
+ for (const [name, hash] of ours) {
22
+ if (theirs.get(name) !== hash) out.push(name);
23
+ }
24
+ for (const name of theirs.keys()) {
25
+ if (!ours.has(name)) out.push(name);
26
+ }
27
+ return out.length;
28
+ }
29
+
30
+ /**
31
+ * Whether the difference is in something nobody hashed.
32
+ *
33
+ * The honest answer when the whole-world fingerprints disagree and every recorded component
34
+ * agrees: the state that differs was not in the breakdown. Reporting agreement there would be a
35
+ * panel saying everything is fine on a frame that is provably not.
36
+ */
37
+ export function divergenceIsUnaccounted(differing: number, worldsDiffer: boolean): boolean {
38
+ return worldsDiffer && differing === 0;
39
+ }
package/src/index.ts ADDED
@@ -0,0 +1,115 @@
1
+ /*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
2
+ /**
3
+ * The editor panels a shipped game can carry.
4
+ *
5
+ * **The argument is the split, not the panels.** An inspector, a console, a profiler and a network
6
+ * panel are what somebody wants while a game is running — on a tester's machine, on a console
7
+ * devkit, in a build nobody can attach a debugger to. A scene tree and an asset browser are not:
8
+ * they need a project, and a shipped game has none. So those two stayed in the editor and these
9
+ * four became a package, and **a game that never imports this package pays nothing at all** — which
10
+ * is a claim the size gate makes rather than a claim this paragraph makes.
11
+ *
12
+ * **The command stack came with them, because the alternative is an in-game inspector that cannot
13
+ * undo.** `Panel.route` returns a `Command` or nothing, which is what makes "a panel mutates only
14
+ * through commands" a signature rather than a convention; a game carrying the panels carries that
15
+ * rule too, and gets undo for free.
16
+ *
17
+ * **`Selection` came with them for the same reason**: the inspector's whole subject is what is
18
+ * selected, and three panels sharing one selection by reference is how the editor already works.
19
+ */
20
+ export type { Command, UndoStack } from './command.ts';
21
+ export { createUndoStack } from './command.ts';
22
+
23
+ export type { Panel, UiEvent } from './panel.ts';
24
+ export {
25
+ PANEL_PREFIX,
26
+ createPanelRoot,
27
+ emptyPanel,
28
+ keyEvent,
29
+ panelIdOf,
30
+ pointerEvent,
31
+ treeShape,
32
+ wheelEvent,
33
+ } from './panel.ts';
34
+
35
+ export type { Selection } from './selection.ts';
36
+ export {
37
+ addToSelection,
38
+ clearSelection,
39
+ createSelection,
40
+ isSelected,
41
+ primarySelection,
42
+ selectOnly,
43
+ selectRange,
44
+ selectedEntities,
45
+ toggleSelection,
46
+ } from './selection.ts';
47
+
48
+ export { divergenceIsUnaccounted, divergentComponents } from './divergence.ts';
49
+
50
+ /*
51
+ * The four panels a game carries. The scene tree and the asset browser are deliberately not here:
52
+ * both need a project on disk, and a shipped game has none.
53
+ */
54
+ export type {
55
+ InspectableWorld,
56
+ InspectorRow,
57
+ InspectorView,
58
+ InspectorWorld,
59
+ RowKind,
60
+ } from './inspector.ts';
61
+ export {
62
+ MIXED,
63
+ addComponentCommand,
64
+ createInspectorView,
65
+ fieldEditable,
66
+ inspectorPanel,
67
+ inspectorRows,
68
+ removeComponentCommand,
69
+ rowText,
70
+ setFieldCommand,
71
+ } from './inspector.ts';
72
+
73
+ export type {
74
+ ConsoleView,
75
+ ConsoleWorld,
76
+ LogEntry,
77
+ LogFilter,
78
+ LogRing,
79
+ Severity,
80
+ SourceCursor,
81
+ } from './console.ts';
82
+ export {
83
+ DISPLAY_LIMIT,
84
+ appendLog,
85
+ consolePanel,
86
+ createConsoleView,
87
+ createLogFilter,
88
+ createLogRing,
89
+ displayText,
90
+ filteredEntries,
91
+ jumpToSourceCommand,
92
+ logEntries,
93
+ } from './console.ts';
94
+
95
+ export type { FrameHistory, ProfilerRow, ProfilerView, ProfilerWorld } from './profiler.ts';
96
+ export {
97
+ createFrameHistory,
98
+ createProfilerView,
99
+ formatMs,
100
+ historyValues,
101
+ occupancy,
102
+ profilerPanel,
103
+ profilerRows,
104
+ pushFrame,
105
+ } from './profiler.ts';
106
+
107
+ export type { NetworkReadout, NetworkView, NetworkWorld, RollbackHistory } from './network.ts';
108
+ export {
109
+ componentDivergence,
110
+ createNetworkView,
111
+ createRollbackHistory,
112
+ networkPanel,
113
+ pushRollback,
114
+ rollbackValues,
115
+ } from './network.ts';