@driftengine/editor 3.61.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 +9 -0
- package/README.md +134 -0
- package/dist/editorHost.d.ts +108 -0
- package/dist/editorHost.js +217 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +16 -0
- package/dist/inspector.d.ts +103 -0
- package/dist/inspector.js +219 -0
- package/dist/panel.d.ts +43 -0
- package/dist/panel.js +124 -0
- package/dist/sceneTree.d.ts +74 -0
- package/dist/sceneTree.js +117 -0
- package/package.json +59 -0
- package/src/editorHost.ts +239 -0
- package/src/index.ts +26 -0
- package/src/inspector.ts +276 -0
- package/src/panel.ts +129 -0
- package/src/sceneTree.ts +148 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
|
|
2
|
+
/**
|
|
3
|
+
* `@driftengine/editor` — the model of a scene editor.
|
|
4
|
+
*
|
|
5
|
+
* A tree of what exists, an inspector over what is selected, and play-in-editor. No pixels: the
|
|
6
|
+
* panel builders produce `@driftengine/ui2d` nodes and the consumer draws them, the way `DebugLines`
|
|
7
|
+
* produces segments and `drawLines` draws them.
|
|
8
|
+
*
|
|
9
|
+
* **A package rather than part of core**, because it imports `@driftengine/entities` and core does
|
|
10
|
+
* not — that package imports no other engine package, deliberately, and having core reach it to
|
|
11
|
+
* serve an editor would invert that for every game that never opens one.
|
|
12
|
+
*/
|
|
13
|
+
export { SceneTree } from './sceneTree.ts';
|
|
14
|
+
export type { TreeRow } from './sceneTree.ts';
|
|
15
|
+
export { Inspector, fieldKindOf } from './inspector.ts';
|
|
16
|
+
export type { FieldKind, InspectorField } from './inspector.ts';
|
|
17
|
+
export { EditorHost } from './editorHost.ts';
|
|
18
|
+
export type { EditorMode, EditorWorld } from './editorHost.ts';
|
|
19
|
+
export { FIELD_ROW_PREFIX, TREE_ROW_PREFIX, buildInspectorPanel, buildTreePanel, formatValue, rowIndexOf, } from './panel.ts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
|
|
2
|
+
/**
|
|
3
|
+
* `@driftengine/editor` — the model of a scene editor.
|
|
4
|
+
*
|
|
5
|
+
* A tree of what exists, an inspector over what is selected, and play-in-editor. No pixels: the
|
|
6
|
+
* panel builders produce `@driftengine/ui2d` nodes and the consumer draws them, the way `DebugLines`
|
|
7
|
+
* produces segments and `drawLines` draws them.
|
|
8
|
+
*
|
|
9
|
+
* **A package rather than part of core**, because it imports `@driftengine/entities` and core does
|
|
10
|
+
* not — that package imports no other engine package, deliberately, and having core reach it to
|
|
11
|
+
* serve an editor would invert that for every game that never opens one.
|
|
12
|
+
*/
|
|
13
|
+
export { SceneTree } from './sceneTree.js';
|
|
14
|
+
export { Inspector, fieldKindOf } from './inspector.js';
|
|
15
|
+
export { EditorHost } from './editorHost.js';
|
|
16
|
+
export { FIELD_ROW_PREFIX, TREE_ROW_PREFIX, buildInspectorPanel, buildTreePanel, formatValue, rowIndexOf, } from './panel.js';
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What is selected, field by field, and how to write one back.
|
|
3
|
+
*
|
|
4
|
+
* **Reflection and nothing else.** A component already describes itself — `ComponentType.schema`
|
|
5
|
+
* carries `{ id, name, type }` per field, and `World.read`/`World.write` take a field by name — so
|
|
6
|
+
* an inspector over an entity is a walk of that schema. It needs no registry, no decorators and no
|
|
7
|
+
* code per component, which is the property that makes it work for a component a `.drs` file
|
|
8
|
+
* declared and this package has never heard of.
|
|
9
|
+
*
|
|
10
|
+
* **A node's transform is the other half**, and it is written back through `setPosition`, `setScale`
|
|
11
|
+
* and `markMoved` rather than into the arrays: writing an array leaves `worldMatrix` stale and the
|
|
12
|
+
* node keeps drawing where it used to be, silently and with a plausible matrix.
|
|
13
|
+
*/
|
|
14
|
+
import type { SceneNode } from '@driftengine/core';
|
|
15
|
+
import type { ComponentType, Entity, World } from '@driftengine/entities';
|
|
16
|
+
/**
|
|
17
|
+
* What a field *is*, derived from its declared type rather than from what a read returned.
|
|
18
|
+
*
|
|
19
|
+
* A read cannot tell them apart: `bool` arrives as 0 or 1, an `enum:` discriminant as an integer,
|
|
20
|
+
* and an `Entity` as a number. The declaration can, and it is what a panel needs to know whether to
|
|
21
|
+
* draw a checkbox, a stepper or a menu.
|
|
22
|
+
*/
|
|
23
|
+
export type FieldKind = 'number' | 'integer' | 'boolean' | 'text' | 'entity' | 'enum';
|
|
24
|
+
export interface InspectorField {
|
|
25
|
+
/** What this field belongs to: a component's name, or `transform` for a node. */
|
|
26
|
+
group: string;
|
|
27
|
+
/** The field's own name, as it was declared. */
|
|
28
|
+
label: string;
|
|
29
|
+
/** The declared type, verbatim — `f32`, `enum:Mood`, `Entity`. */
|
|
30
|
+
type: string;
|
|
31
|
+
kind: FieldKind;
|
|
32
|
+
/** For `enum`, the name after the colon. Empty otherwise; this package has no variant list. */
|
|
33
|
+
enumName: string;
|
|
34
|
+
/** Whether the field is an option, which the store spells as an `option:` prefix. */
|
|
35
|
+
optional: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* What the store holds, not a friendlier version of it.
|
|
38
|
+
*
|
|
39
|
+
* **A `bool` column is a `Uint8Array`, so a boolean field reads back as 0 or 1.** `kind` is what
|
|
40
|
+
* tells a panel to draw a checkbox; converting the value here would make this disagree with
|
|
41
|
+
* `world.read` for one type out of thirteen, which is a worse surprise than the number.
|
|
42
|
+
*/
|
|
43
|
+
value: unknown;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The kind a declared type is inspected as.
|
|
47
|
+
*
|
|
48
|
+
* **An enum is reported as an enum and carries its name**, which is as far as this package can
|
|
49
|
+
* honestly go: the variant list belongs to the module that declared it, and `defineComponent` is
|
|
50
|
+
* handed `enum:Mood` — a name and no variants. A panel that has the list shows the names; one that
|
|
51
|
+
* does not shows the integer, which is what the discriminant is.
|
|
52
|
+
*/
|
|
53
|
+
export declare function fieldKindOf(type: string): {
|
|
54
|
+
kind: FieldKind;
|
|
55
|
+
enumName: string;
|
|
56
|
+
optional: boolean;
|
|
57
|
+
};
|
|
58
|
+
export declare class Inspector {
|
|
59
|
+
private readonly pool;
|
|
60
|
+
private readonly live;
|
|
61
|
+
private node;
|
|
62
|
+
private world;
|
|
63
|
+
private entity;
|
|
64
|
+
private types;
|
|
65
|
+
/** The fields of whatever is being shown. Reused between calls; copy what you keep. */
|
|
66
|
+
get fields(): readonly InspectorField[];
|
|
67
|
+
/** What is being shown: a `SceneNode`, an `Entity`, or nothing. */
|
|
68
|
+
get showing(): 'node' | 'entity' | 'nothing';
|
|
69
|
+
showNothing(): void;
|
|
70
|
+
showNode(node: SceneNode): void;
|
|
71
|
+
/**
|
|
72
|
+
* Show an entity's components — every one of `types` it actually has.
|
|
73
|
+
*
|
|
74
|
+
* The types are passed rather than discovered for the reason `serializeWorld` gives: a world holds
|
|
75
|
+
* a store for every type anything ever asked it about, empty ones included, so what to show is a
|
|
76
|
+
* decision rather than everything that happens to exist.
|
|
77
|
+
*/
|
|
78
|
+
showEntity(world: World, entity: Entity, types: readonly ComponentType[]): void;
|
|
79
|
+
/**
|
|
80
|
+
* Re-read the values without rebuilding the list.
|
|
81
|
+
*
|
|
82
|
+
* The list is stable while the selection is and the values are not, so a panel holding a row by
|
|
83
|
+
* index keeps holding the same field across a frame. Rebuilding every frame would also allocate.
|
|
84
|
+
*/
|
|
85
|
+
refresh(): void;
|
|
86
|
+
/**
|
|
87
|
+
* Write a field back. `false` where the index is not a field, or the value is the wrong shape.
|
|
88
|
+
*
|
|
89
|
+
* **A refusal rather than a throw**, because the caller is a panel inside a frame and a typed box
|
|
90
|
+
* that rejects what was typed into it is an ordinary thing for a panel to have to show.
|
|
91
|
+
*/
|
|
92
|
+
set(index: number, value: unknown): boolean;
|
|
93
|
+
private readTransform;
|
|
94
|
+
/**
|
|
95
|
+
* Write one transform field.
|
|
96
|
+
*
|
|
97
|
+
* **Through the setters, and `markMoved` for the rotation**, because writing `position` directly
|
|
98
|
+
* leaves `worldMatrix` describing where the node used to be — with no error, a plausible matrix,
|
|
99
|
+
* and geometry drawn in the wrong place until something else happens to move it.
|
|
100
|
+
*/
|
|
101
|
+
private writeTransform;
|
|
102
|
+
private take;
|
|
103
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `option:f32` is an option of `f32`. Mirrors `optionInner` in the entity store.
|
|
3
|
+
*
|
|
4
|
+
* **A prefix and not a `?` suffix**, which is worth a line because the suffix is the obvious guess
|
|
5
|
+
* and it is wrong: the store's own comment explains that a field type is a *key* compared for
|
|
6
|
+
* equality by a migration, so the option is spelled into the key rather than carried beside it.
|
|
7
|
+
*/
|
|
8
|
+
function unwrapOption(type) {
|
|
9
|
+
return type.startsWith('option:')
|
|
10
|
+
? { inner: type.slice('option:'.length), optional: true }
|
|
11
|
+
: { inner: type, optional: false };
|
|
12
|
+
}
|
|
13
|
+
const INTEGERS = new Set(['i8', 'i16', 'i32', 'i64', 'u8', 'u16', 'u32', 'u64']);
|
|
14
|
+
/**
|
|
15
|
+
* The kind a declared type is inspected as.
|
|
16
|
+
*
|
|
17
|
+
* **An enum is reported as an enum and carries its name**, which is as far as this package can
|
|
18
|
+
* honestly go: the variant list belongs to the module that declared it, and `defineComponent` is
|
|
19
|
+
* handed `enum:Mood` — a name and no variants. A panel that has the list shows the names; one that
|
|
20
|
+
* does not shows the integer, which is what the discriminant is.
|
|
21
|
+
*/
|
|
22
|
+
export function fieldKindOf(type) {
|
|
23
|
+
const { inner, optional } = unwrapOption(type);
|
|
24
|
+
if (inner.startsWith('enum:'))
|
|
25
|
+
return { kind: 'enum', enumName: inner.slice(5), optional };
|
|
26
|
+
if (inner === 'bool')
|
|
27
|
+
return { kind: 'boolean', enumName: '', optional };
|
|
28
|
+
if (inner === 'String')
|
|
29
|
+
return { kind: 'text', enumName: '', optional };
|
|
30
|
+
if (inner === 'Entity')
|
|
31
|
+
return { kind: 'entity', enumName: '', optional };
|
|
32
|
+
if (INTEGERS.has(inner))
|
|
33
|
+
return { kind: 'integer', enumName: '', optional };
|
|
34
|
+
return { kind: 'number', enumName: '', optional };
|
|
35
|
+
}
|
|
36
|
+
/** The ten fields a node's transform has, in the order they read. */
|
|
37
|
+
const TRANSFORM = [
|
|
38
|
+
['position.x', 0, 'position'],
|
|
39
|
+
['position.y', 1, 'position'],
|
|
40
|
+
['position.z', 2, 'position'],
|
|
41
|
+
['rotation.x', 0, 'rotation'],
|
|
42
|
+
['rotation.y', 1, 'rotation'],
|
|
43
|
+
['rotation.z', 2, 'rotation'],
|
|
44
|
+
['scale.x', 0, 'scale'],
|
|
45
|
+
['scale.y', 1, 'scale'],
|
|
46
|
+
['scale.z', 2, 'scale'],
|
|
47
|
+
];
|
|
48
|
+
/** A rotation's `w`, which has no axis and so is not in the table above. */
|
|
49
|
+
const ROTATION_W = 'rotation.w';
|
|
50
|
+
export class Inspector {
|
|
51
|
+
pool = [];
|
|
52
|
+
live = [];
|
|
53
|
+
node = null;
|
|
54
|
+
world = null;
|
|
55
|
+
entity = null;
|
|
56
|
+
types = [];
|
|
57
|
+
/** The fields of whatever is being shown. Reused between calls; copy what you keep. */
|
|
58
|
+
get fields() {
|
|
59
|
+
return this.live;
|
|
60
|
+
}
|
|
61
|
+
/** What is being shown: a `SceneNode`, an `Entity`, or nothing. */
|
|
62
|
+
get showing() {
|
|
63
|
+
if (this.node !== null)
|
|
64
|
+
return 'node';
|
|
65
|
+
return this.entity !== null ? 'entity' : 'nothing';
|
|
66
|
+
}
|
|
67
|
+
showNothing() {
|
|
68
|
+
this.node = null;
|
|
69
|
+
this.world = null;
|
|
70
|
+
this.entity = null;
|
|
71
|
+
this.types = [];
|
|
72
|
+
this.live.length = 0;
|
|
73
|
+
}
|
|
74
|
+
showNode(node) {
|
|
75
|
+
this.showNothing();
|
|
76
|
+
this.node = node;
|
|
77
|
+
this.refresh();
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Show an entity's components — every one of `types` it actually has.
|
|
81
|
+
*
|
|
82
|
+
* The types are passed rather than discovered for the reason `serializeWorld` gives: a world holds
|
|
83
|
+
* a store for every type anything ever asked it about, empty ones included, so what to show is a
|
|
84
|
+
* decision rather than everything that happens to exist.
|
|
85
|
+
*/
|
|
86
|
+
showEntity(world, entity, types) {
|
|
87
|
+
this.showNothing();
|
|
88
|
+
this.world = world;
|
|
89
|
+
this.entity = entity;
|
|
90
|
+
this.types = types;
|
|
91
|
+
this.refresh();
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Re-read the values without rebuilding the list.
|
|
95
|
+
*
|
|
96
|
+
* The list is stable while the selection is and the values are not, so a panel holding a row by
|
|
97
|
+
* index keeps holding the same field across a frame. Rebuilding every frame would also allocate.
|
|
98
|
+
*/
|
|
99
|
+
refresh() {
|
|
100
|
+
this.live.length = 0;
|
|
101
|
+
if (this.node !== null) {
|
|
102
|
+
this.readTransform(this.node);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (this.world === null || this.entity === null)
|
|
106
|
+
return;
|
|
107
|
+
for (const type of this.types) {
|
|
108
|
+
if (!this.world.has(this.entity, type))
|
|
109
|
+
continue;
|
|
110
|
+
for (const field of type.schema.fields) {
|
|
111
|
+
const { kind, enumName, optional } = fieldKindOf(field.type);
|
|
112
|
+
const at = this.take();
|
|
113
|
+
at.group = type.name;
|
|
114
|
+
at.label = field.name;
|
|
115
|
+
at.type = field.type;
|
|
116
|
+
at.kind = kind;
|
|
117
|
+
at.enumName = enumName;
|
|
118
|
+
at.optional = optional;
|
|
119
|
+
at.value = this.world.read(this.entity, type, field.name);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Write a field back. `false` where the index is not a field, or the value is the wrong shape.
|
|
125
|
+
*
|
|
126
|
+
* **A refusal rather than a throw**, because the caller is a panel inside a frame and a typed box
|
|
127
|
+
* that rejects what was typed into it is an ordinary thing for a panel to have to show.
|
|
128
|
+
*/
|
|
129
|
+
set(index, value) {
|
|
130
|
+
const field = this.live[index];
|
|
131
|
+
if (field === undefined)
|
|
132
|
+
return false;
|
|
133
|
+
if (this.node !== null)
|
|
134
|
+
return this.writeTransform(this.node, field, value);
|
|
135
|
+
if (this.world === null || this.entity === null)
|
|
136
|
+
return false;
|
|
137
|
+
const type = this.types.find((candidate) => candidate.name === field.group);
|
|
138
|
+
if (type === undefined)
|
|
139
|
+
return false;
|
|
140
|
+
if (field.kind !== 'text' &&
|
|
141
|
+
field.kind !== 'entity' &&
|
|
142
|
+
typeof value !== 'number' &&
|
|
143
|
+
typeof value !== 'boolean') {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
this.world.write(this.entity, type, field.label, value);
|
|
147
|
+
field.value = this.world.read(this.entity, type, field.label);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
readTransform(node) {
|
|
151
|
+
for (const [label, axis, part] of TRANSFORM) {
|
|
152
|
+
const at = this.take();
|
|
153
|
+
at.group = 'transform';
|
|
154
|
+
at.label = label;
|
|
155
|
+
at.type = 'f32';
|
|
156
|
+
at.kind = 'number';
|
|
157
|
+
at.enumName = '';
|
|
158
|
+
at.optional = false;
|
|
159
|
+
at.value = node[part][axis] ?? 0;
|
|
160
|
+
}
|
|
161
|
+
const w = this.take();
|
|
162
|
+
w.group = 'transform';
|
|
163
|
+
w.label = ROTATION_W;
|
|
164
|
+
w.type = 'f32';
|
|
165
|
+
w.kind = 'number';
|
|
166
|
+
w.enumName = '';
|
|
167
|
+
w.optional = false;
|
|
168
|
+
w.value = node.rotation[3] ?? 1;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Write one transform field.
|
|
172
|
+
*
|
|
173
|
+
* **Through the setters, and `markMoved` for the rotation**, because writing `position` directly
|
|
174
|
+
* leaves `worldMatrix` describing where the node used to be — with no error, a plausible matrix,
|
|
175
|
+
* and geometry drawn in the wrong place until something else happens to move it.
|
|
176
|
+
*/
|
|
177
|
+
writeTransform(node, field, value) {
|
|
178
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
179
|
+
return false;
|
|
180
|
+
const [part, component] = field.label.split('.');
|
|
181
|
+
const axis = component === 'x' ? 0 : component === 'y' ? 1 : component === 'z' ? 2 : 3;
|
|
182
|
+
if (part === 'position') {
|
|
183
|
+
const next = [node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0];
|
|
184
|
+
next[axis] = value;
|
|
185
|
+
node.setPosition(next[0], next[1], next[2]);
|
|
186
|
+
}
|
|
187
|
+
else if (part === 'scale') {
|
|
188
|
+
const next = [node.scale[0] ?? 1, node.scale[1] ?? 1, node.scale[2] ?? 1];
|
|
189
|
+
next[axis] = value;
|
|
190
|
+
node.setScale(next[0], next[1], next[2]);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
node.rotation[axis] = value;
|
|
194
|
+
node.markMoved();
|
|
195
|
+
}
|
|
196
|
+
field.value = value;
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
take() {
|
|
200
|
+
const at = this.live.length;
|
|
201
|
+
const existing = this.pool[at];
|
|
202
|
+
if (existing !== undefined) {
|
|
203
|
+
this.live.push(existing);
|
|
204
|
+
return existing;
|
|
205
|
+
}
|
|
206
|
+
const made = {
|
|
207
|
+
group: '',
|
|
208
|
+
label: '',
|
|
209
|
+
type: '',
|
|
210
|
+
kind: 'number',
|
|
211
|
+
enumName: '',
|
|
212
|
+
optional: false,
|
|
213
|
+
value: 0,
|
|
214
|
+
};
|
|
215
|
+
this.pool.push(made);
|
|
216
|
+
this.live.push(made);
|
|
217
|
+
return made;
|
|
218
|
+
}
|
|
219
|
+
}
|
package/dist/panel.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { UiNode } from '@driftengine/ui2d';
|
|
2
|
+
import type { Inspector } from './inspector.ts';
|
|
3
|
+
import type { SceneTree } from './sceneTree.ts';
|
|
4
|
+
/** The prefix a tree row's name carries, and the one an inspector field's carries. */
|
|
5
|
+
export declare const TREE_ROW_PREFIX = "row:";
|
|
6
|
+
export declare const FIELD_ROW_PREFIX = "field:";
|
|
7
|
+
/**
|
|
8
|
+
* Which row a node is, or `-1` for a node that is not one.
|
|
9
|
+
*
|
|
10
|
+
* Takes the node `routeUiPointer` returned, so a caller writes
|
|
11
|
+
* `const at = rowIndexOf(routeUiPointer(...), TREE_ROW_PREFIX)` and has the row it clicked.
|
|
12
|
+
*/
|
|
13
|
+
export declare function rowIndexOf(node: UiNode | null, prefix: string): number;
|
|
14
|
+
/**
|
|
15
|
+
* Fill `into` with one row per line of the tree's last rebuild.
|
|
16
|
+
*
|
|
17
|
+
* **The indent is padding rather than a spacer node**, which halves the node count and means a hit
|
|
18
|
+
* test on a row covers the indent too — clicking to the left of a deep row's label selects that row,
|
|
19
|
+
* which is what every tree does and what a spacer would have broken.
|
|
20
|
+
*
|
|
21
|
+
* **Nothing here marks the selected row and `UiNode` grows no field for it.** A `selected` flag on
|
|
22
|
+
* every node in every interface in every game, to serve a panel most of them never build, is the
|
|
23
|
+
* same trade this package refused when it put node names in `SceneTree` instead of on `SceneNode`.
|
|
24
|
+
* `tree.rowOf(tree.selected)` is the index, the caller already has the tree, and how a selected row
|
|
25
|
+
* looks is a decision about a consumer's palette rather than about an editor.
|
|
26
|
+
*/
|
|
27
|
+
export declare function buildTreePanel(tree: SceneTree, into: UiNode): number;
|
|
28
|
+
/**
|
|
29
|
+
* Fill `into` with one row per inspector field.
|
|
30
|
+
*
|
|
31
|
+
* The label carries the group where a field belongs to a component, because two components can
|
|
32
|
+
* declare the same field name and a panel showing `health` twice with no way to tell them apart is
|
|
33
|
+
* worse than one that is a little wordier.
|
|
34
|
+
*/
|
|
35
|
+
export declare function buildInspectorPanel(inspector: Inspector, into: UiNode): number;
|
|
36
|
+
/**
|
|
37
|
+
* A value as a row shows it.
|
|
38
|
+
*
|
|
39
|
+
* **A boolean reads as a word and an enum as its number**, which is as far as this can honestly go:
|
|
40
|
+
* the variant list belongs to the module that declared the enum, and a component is handed
|
|
41
|
+
* `enum:Mood` — a name and no variants. A consumer holding the list formats its own.
|
|
42
|
+
*/
|
|
43
|
+
export declare function formatValue(value: unknown, kind: string): string;
|
package/dist/panel.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tree and the inspector as `UiNode` trees a consumer lays out, routes and draws.
|
|
3
|
+
*
|
|
4
|
+
* **Nodes and not pixels.** `@driftengine/ui2d` already has the layout engine, the hit test, the
|
|
5
|
+
* focus order and the pointer router; what was missing was anything turning what an editor *holds*
|
|
6
|
+
* into nodes for it. That is the same shape `DebugLines` has against `drawLines`, one level up.
|
|
7
|
+
*
|
|
8
|
+
* **Text is set and not drawn.** `drawUiTree` reports a node with text to a `UiContentSink` and
|
|
9
|
+
* draws none itself, because core already has two text renderers and ui2d's own header calls that
|
|
10
|
+
* the honest seam. This does the same thing: the row carries the string, the consumer's sink draws
|
|
11
|
+
* it with whichever renderer it uses.
|
|
12
|
+
*
|
|
13
|
+
* **Rows are rebuilt in place.** A builder that made fresh nodes every frame would allocate in a
|
|
14
|
+
* frame path, which `AGENTS.md` forbids. These reuse the children they made last time and only ever
|
|
15
|
+
* grow, so a panel settles after the largest tree it has shown.
|
|
16
|
+
*
|
|
17
|
+
* **Every row carries a name**, `row:<index>` or `field:<index>`, so `routeUiPointer` hands back the
|
|
18
|
+
* node that was activated and `rowIndexOf` turns it into the row. That is one parse rather than a
|
|
19
|
+
* map from node to row that has to be kept in step with the tree.
|
|
20
|
+
*/
|
|
21
|
+
import { addUiChild, createUiNode } from '@driftengine/ui2d';
|
|
22
|
+
/** How far one level of depth indents a tree row, in layout units. */
|
|
23
|
+
const INDENT = 12;
|
|
24
|
+
/** The prefix a tree row's name carries, and the one an inspector field's carries. */
|
|
25
|
+
export const TREE_ROW_PREFIX = 'row:';
|
|
26
|
+
export const FIELD_ROW_PREFIX = 'field:';
|
|
27
|
+
/**
|
|
28
|
+
* Which row a node is, or `-1` for a node that is not one.
|
|
29
|
+
*
|
|
30
|
+
* Takes the node `routeUiPointer` returned, so a caller writes
|
|
31
|
+
* `const at = rowIndexOf(routeUiPointer(...), TREE_ROW_PREFIX)` and has the row it clicked.
|
|
32
|
+
*/
|
|
33
|
+
export function rowIndexOf(node, prefix) {
|
|
34
|
+
if (node === null || !node.name.startsWith(prefix))
|
|
35
|
+
return -1;
|
|
36
|
+
const at = Number.parseInt(node.name.slice(prefix.length), 10);
|
|
37
|
+
return Number.isInteger(at) && at >= 0 ? at : -1;
|
|
38
|
+
}
|
|
39
|
+
/** A row that exists, reused; or a new one appended. */
|
|
40
|
+
function rowAt(into, index, prefix) {
|
|
41
|
+
const existing = into.children[index];
|
|
42
|
+
if (existing !== undefined) {
|
|
43
|
+
existing.hidden = false;
|
|
44
|
+
return existing;
|
|
45
|
+
}
|
|
46
|
+
const made = createUiNode({
|
|
47
|
+
direction: 'row',
|
|
48
|
+
width: 'grow',
|
|
49
|
+
height: 18,
|
|
50
|
+
interactive: true,
|
|
51
|
+
focusable: true,
|
|
52
|
+
name: `${prefix}${index}`,
|
|
53
|
+
});
|
|
54
|
+
addUiChild(into, made);
|
|
55
|
+
return made;
|
|
56
|
+
}
|
|
57
|
+
/** Rows past what is needed are hidden rather than removed, so the pool survives. */
|
|
58
|
+
function hideFrom(into, index) {
|
|
59
|
+
for (let at = index; at < into.children.length; at++) {
|
|
60
|
+
into.children[at].hidden = true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Fill `into` with one row per line of the tree's last rebuild.
|
|
65
|
+
*
|
|
66
|
+
* **The indent is padding rather than a spacer node**, which halves the node count and means a hit
|
|
67
|
+
* test on a row covers the indent too — clicking to the left of a deep row's label selects that row,
|
|
68
|
+
* which is what every tree does and what a spacer would have broken.
|
|
69
|
+
*
|
|
70
|
+
* **Nothing here marks the selected row and `UiNode` grows no field for it.** A `selected` flag on
|
|
71
|
+
* every node in every interface in every game, to serve a panel most of them never build, is the
|
|
72
|
+
* same trade this package refused when it put node names in `SceneTree` instead of on `SceneNode`.
|
|
73
|
+
* `tree.rowOf(tree.selected)` is the index, the caller already has the tree, and how a selected row
|
|
74
|
+
* looks is a decision about a consumer's palette rather than about an editor.
|
|
75
|
+
*/
|
|
76
|
+
export function buildTreePanel(tree, into) {
|
|
77
|
+
const rows = tree.rows;
|
|
78
|
+
for (let at = 0; at < rows.length; at++) {
|
|
79
|
+
const row = rows[at];
|
|
80
|
+
const node = rowAt(into, at, TREE_ROW_PREFIX);
|
|
81
|
+
node.paddingLeft = row.depth * INDENT;
|
|
82
|
+
node.text = row.hasChildren ? `${row.expanded ? '-' : '+'} ${row.name}` : ` ${row.name}`;
|
|
83
|
+
}
|
|
84
|
+
hideFrom(into, rows.length);
|
|
85
|
+
return rows.length;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Fill `into` with one row per inspector field.
|
|
89
|
+
*
|
|
90
|
+
* The label carries the group where a field belongs to a component, because two components can
|
|
91
|
+
* declare the same field name and a panel showing `health` twice with no way to tell them apart is
|
|
92
|
+
* worse than one that is a little wordier.
|
|
93
|
+
*/
|
|
94
|
+
export function buildInspectorPanel(inspector, into) {
|
|
95
|
+
const fields = inspector.fields;
|
|
96
|
+
for (let at = 0; at < fields.length; at++) {
|
|
97
|
+
const field = fields[at];
|
|
98
|
+
const node = rowAt(into, at, FIELD_ROW_PREFIX);
|
|
99
|
+
const label = field.group === 'transform' ? field.label : `${field.group}.${field.label}`;
|
|
100
|
+
node.text = `${label} ${formatValue(field.value, field.kind)}`;
|
|
101
|
+
}
|
|
102
|
+
hideFrom(into, fields.length);
|
|
103
|
+
return fields.length;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* A value as a row shows it.
|
|
107
|
+
*
|
|
108
|
+
* **A boolean reads as a word and an enum as its number**, which is as far as this can honestly go:
|
|
109
|
+
* the variant list belongs to the module that declared the enum, and a component is handed
|
|
110
|
+
* `enum:Mood` — a name and no variants. A consumer holding the list formats its own.
|
|
111
|
+
*/
|
|
112
|
+
export function formatValue(value, kind) {
|
|
113
|
+
if (value === null || value === undefined)
|
|
114
|
+
return '—';
|
|
115
|
+
if (kind === 'boolean')
|
|
116
|
+
return value ? 'true' : 'false';
|
|
117
|
+
if (kind === 'text')
|
|
118
|
+
return String(value);
|
|
119
|
+
if (typeof value !== 'number')
|
|
120
|
+
return String(value);
|
|
121
|
+
if (kind === 'integer' || kind === 'enum' || kind === 'entity')
|
|
122
|
+
return String(Math.trunc(value));
|
|
123
|
+
return Number.isInteger(value) ? value.toFixed(1) : value.toFixed(3);
|
|
124
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A flattened, collapsible, named view of a `SceneNode` hierarchy.
|
|
3
|
+
*
|
|
4
|
+
* **A view and not a second scene graph.** The hierarchy belongs to `@driftengine/core`; this holds
|
|
5
|
+
* what an editor needs on top of it — which rows are showing, which is selected, and what each one
|
|
6
|
+
* is called — and nothing that a game running without an editor should carry.
|
|
7
|
+
*
|
|
8
|
+
* **The names live here, and that is the one real decision in this file.** `SceneNode` has none. A
|
|
9
|
+
* string on every node would be paid by every game, in every serialised scene and every allocation
|
|
10
|
+
* of a node, to serve an editor most of them never open. A map here is paid by whoever builds a
|
|
11
|
+
* tree.
|
|
12
|
+
*/
|
|
13
|
+
import type { SceneNode } from '@driftengine/core';
|
|
14
|
+
/** One line of the flattened tree. Reused between rebuilds; copy what you keep. */
|
|
15
|
+
export interface TreeRow {
|
|
16
|
+
node: SceneNode;
|
|
17
|
+
/** How deep, with the root at zero. What a panel indents by. */
|
|
18
|
+
depth: number;
|
|
19
|
+
/** Whether this node has children at all, which is what draws a twisty. */
|
|
20
|
+
hasChildren: boolean;
|
|
21
|
+
/** Whether its children are showing. Meaningless when `hasChildren` is false. */
|
|
22
|
+
expanded: boolean;
|
|
23
|
+
name: string;
|
|
24
|
+
}
|
|
25
|
+
export declare class SceneTree {
|
|
26
|
+
/** What is selected, or null. Assigning is fine; nothing here caches it. */
|
|
27
|
+
selected: SceneNode | null;
|
|
28
|
+
private readonly names;
|
|
29
|
+
private readonly collapsed;
|
|
30
|
+
/**
|
|
31
|
+
* Rows, reused.
|
|
32
|
+
*
|
|
33
|
+
* `rowCount` is how many of them are live, so a rebuild that produces fewer rows than the last
|
|
34
|
+
* one does not allocate and does not have to clear the tail. `rows` slices to the live ones.
|
|
35
|
+
*/
|
|
36
|
+
private readonly pool;
|
|
37
|
+
private rowCount;
|
|
38
|
+
private readonly live;
|
|
39
|
+
/** The rows the last `rebuild` produced, in the order they draw. */
|
|
40
|
+
get rows(): readonly TreeRow[];
|
|
41
|
+
/** What a node is called. Nodes are `node` until something names them. */
|
|
42
|
+
name(node: SceneNode): string;
|
|
43
|
+
setName(node: SceneNode, name: string): void;
|
|
44
|
+
/** Whether a node's children are showing. Everything starts expanded. */
|
|
45
|
+
expanded(node: SceneNode): boolean;
|
|
46
|
+
setExpanded(node: SceneNode, expanded: boolean): void;
|
|
47
|
+
toggle(node: SceneNode): void;
|
|
48
|
+
/**
|
|
49
|
+
* Walk the hierarchy into rows, honouring collapse.
|
|
50
|
+
*
|
|
51
|
+
* A collapsed node contributes its own row and nothing beneath it, which is what collapsing is;
|
|
52
|
+
* its children keep whatever expansion state they had, so expanding a branch again shows what was
|
|
53
|
+
* showing before rather than a flattened one.
|
|
54
|
+
*
|
|
55
|
+
* Cheap enough to call every frame, and it allocates nothing once the deepest tree it has seen
|
|
56
|
+
* has been seen.
|
|
57
|
+
*/
|
|
58
|
+
rebuild(root: SceneNode): void;
|
|
59
|
+
/** Where a node sits in the last rebuild's rows, or `-1`. */
|
|
60
|
+
rowOf(node: SceneNode): number;
|
|
61
|
+
/**
|
|
62
|
+
* Move a node under a new parent. `false` where the hierarchy refuses.
|
|
63
|
+
*
|
|
64
|
+
* **The refusal is `SceneNode`'s and is not re-implemented here**, which was checked rather than
|
|
65
|
+
* assumed: `attachChild` already rejects a node as its own child and any ancestor as its own
|
|
66
|
+
* descendant. What this adds is answering rather than throwing, because the caller is a drag in a
|
|
67
|
+
* frame and an exception there takes down the frame somebody is dragging in.
|
|
68
|
+
*/
|
|
69
|
+
reparent(child: SceneNode, parent: SceneNode): boolean;
|
|
70
|
+
/** Forget a node's name and expansion, for a node that has left the tree. */
|
|
71
|
+
forget(node: SceneNode): void;
|
|
72
|
+
private visit;
|
|
73
|
+
private rowAt;
|
|
74
|
+
}
|