@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/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright 2026 Drift Technologies
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
DriftEngine
|
|
2
|
+
Copyright 2026 Drift Technologies
|
|
3
|
+
|
|
4
|
+
This product includes software developed at Drift Technologies
|
|
5
|
+
(https://github.com/drftrun/driftengine).
|
|
6
|
+
|
|
7
|
+
Licensed under the Apache License, Version 2.0. Section 4(d) of that licence
|
|
8
|
+
requires this NOTICE to be reproduced in any derivative work you distribute,
|
|
9
|
+
in the source, the documentation, or a display generated by the work.
|
package/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# @driftengine/editor
|
|
2
|
+
|
|
3
|
+
The model of a scene editor: what exists, what is selected, and what play mode does to a world.
|
|
4
|
+
|
|
5
|
+
**Cost: 10.3 KB gzipped standalone.** Measured by `scripts/size-gate.test.mjs`, which fails if it
|
|
6
|
+
drifts more than 3% — the number comes from the same floors that gate asserts, so a README quoting a
|
|
7
|
+
stale one is a red suite rather than a thing somebody notices.
|
|
8
|
+
|
|
9
|
+
**Standalone rather than "over core", and that is a measurement.** `Gizmo` is the only value this
|
|
10
|
+
package imports from core; everything else is a type. A bundle of it is 31.8 KB raw against core's
|
|
11
|
+
2.79 MB, and `createRenderer`, `WebGL2RenderingContext` and every shader string are absent from the
|
|
12
|
+
output. A game that opens an editor pays about ten kilobytes for it; a game that never does pays
|
|
13
|
+
nothing.
|
|
14
|
+
|
|
15
|
+
## What it is not
|
|
16
|
+
|
|
17
|
+
**It is not a panel and it draws nothing.** Nothing here writes to the document or issues a draw
|
|
18
|
+
call. `buildTreePanel` and `buildInspectorPanel` produce `@driftengine/ui2d` nodes, which the
|
|
19
|
+
consumer lays out, routes and draws — the same shape `DebugLines` has against `drawLines`, one level
|
|
20
|
+
up. How a row looks is a decision about a consumer's palette rather than about an editor.
|
|
21
|
+
|
|
22
|
+
**It is not a second scene graph.** The hierarchy is `SceneNode`'s and the components are
|
|
23
|
+
`@driftengine/entities`'. This holds a _view_ of them.
|
|
24
|
+
|
|
25
|
+
**It is not an undo system.** `play()` and `stop()` are one coarse step: a snapshot and a restore. A
|
|
26
|
+
command history is a real feature and is not this one.
|
|
27
|
+
|
|
28
|
+
## The tree
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { SceneTree, buildTreePanel, rowIndexOf, TREE_ROW_PREFIX } from '@driftengine/editor';
|
|
32
|
+
|
|
33
|
+
const tree = new SceneTree();
|
|
34
|
+
tree.setName(lampNode, 'lamp');
|
|
35
|
+
tree.rebuild(sceneRoot); // flattens, honouring collapse; allocates nothing after warmup
|
|
36
|
+
|
|
37
|
+
for (const row of tree.rows) {
|
|
38
|
+
// row.node, row.depth, row.hasChildren, row.expanded, row.name
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**The names live here and not on `SceneNode`.** A string on every node would be paid by every game,
|
|
43
|
+
in every serialised scene and every node allocation, to serve an editor most of them never open. A
|
|
44
|
+
map in the tree is paid by whoever builds one.
|
|
45
|
+
|
|
46
|
+
**Reparenting answers rather than throws.** `SceneNode.attachChild` already refuses a self-parent and
|
|
47
|
+
any ancestor; `tree.reparent(child, parent)` returns `false` where it would, because the caller is a
|
|
48
|
+
drag inside a frame and an exception there takes down the frame somebody is dragging in.
|
|
49
|
+
|
|
50
|
+
## The inspector
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { Inspector } from '@driftengine/editor';
|
|
54
|
+
|
|
55
|
+
const inspector = new Inspector();
|
|
56
|
+
inspector.showEntity(world, entity, [Health, Mood]);
|
|
57
|
+
|
|
58
|
+
for (const field of inspector.fields) {
|
|
59
|
+
// field.group, field.label, field.type, field.kind, field.enumName, field.optional, field.value
|
|
60
|
+
}
|
|
61
|
+
inspector.set(0, 55); // false where the value is the wrong shape
|
|
62
|
+
inspector.refresh(); // re-read the values, keep the list
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**It is reflection and nothing else.** A component already describes itself —
|
|
66
|
+
`ComponentType.schema.fields` is `{ id, name, type }`, and `World.read`/`World.write` take a field by
|
|
67
|
+
name — so this is a walk of that schema. There is no registry, no decorator and no code per
|
|
68
|
+
component, which is what makes it work for a component a `.drs` file declared and this package has
|
|
69
|
+
never heard of.
|
|
70
|
+
|
|
71
|
+
**The kind comes from the declared type, because a read cannot answer it.** `bool` arrives as 0 or 1,
|
|
72
|
+
an enum discriminant as an integer, an `Entity` as a number: every one of them is a number by the
|
|
73
|
+
time it reaches you, and only the declaration says whether to draw a checkbox, a stepper or a menu.
|
|
74
|
+
An option is spelled `option:f32` — a prefix, not a `?` suffix, because a field type is a key a
|
|
75
|
+
migration compares for equality.
|
|
76
|
+
|
|
77
|
+
**The value is what the store holds.** A boolean field reads back as 0 or 1, not as `false`;
|
|
78
|
+
converting it here would make `field.value` disagree with `world.read` for one type out of thirteen,
|
|
79
|
+
which is a worse surprise than the number. `formatValue` shows it as a word.
|
|
80
|
+
|
|
81
|
+
**A node's transform is written through the setters.** Writing `node.position` directly leaves
|
|
82
|
+
`worldMatrix` describing where the node once was, with no error and a plausible matrix.
|
|
83
|
+
|
|
84
|
+
## Play-in-editor
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { EditorHost } from '@driftengine/editor';
|
|
88
|
+
|
|
89
|
+
const host = new EditorHost({ world, types: [Health, Mood] });
|
|
90
|
+
startLoop({ simulate, render, shouldSimulate: host.shouldSimulate });
|
|
91
|
+
|
|
92
|
+
host.play(); // snapshots first
|
|
93
|
+
host.pause();
|
|
94
|
+
host.step(); // exactly one tick, then paused
|
|
95
|
+
host.stop(); // clears, restores, and re-finds the selection
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**The loop already had the seam.** `LoopHooks.shouldSimulate` exists so a paused game keeps rendering
|
|
99
|
+
— the world stays on screen behind the menu instead of freezing — and that is exactly what an editor
|
|
100
|
+
wants.
|
|
101
|
+
|
|
102
|
+
**`stop()` clears before it loads, and this is the part that is easy to get wrong.**
|
|
103
|
+
`deserializeWorld` _creates_ entities; it does not replace a world's contents. A stop that only loads
|
|
104
|
+
leaves the world holding both what was authored and what play produced, and the duplication grows
|
|
105
|
+
every time somebody presses stop.
|
|
106
|
+
|
|
107
|
+
**Every entity handle changes across a stop**, because the restored entities are new ones. A
|
|
108
|
+
selection held as a handle is stale the moment play ends — pointing at nothing, or at whatever reused
|
|
109
|
+
the slot, which is worse because it looks like it worked. So a selection is remembered by its index
|
|
110
|
+
in the snapshot, the only identity that survives, and an entity that play _created_ has no index and
|
|
111
|
+
the selection is emptied rather than moved to a stranger.
|
|
112
|
+
|
|
113
|
+
**A snapshot covers the component types it was given and nothing else** — not the node hierarchy, not
|
|
114
|
+
a physics world, not an audio graph. `serializeWorld` makes the same point: a scene is a decision
|
|
115
|
+
about what to save.
|
|
116
|
+
|
|
117
|
+
## Wiring a pointer to a row
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { rowIndexOf, TREE_ROW_PREFIX } from '@driftengine/editor';
|
|
121
|
+
|
|
122
|
+
const activated = routeUiPointer(input, panel, x, y, down);
|
|
123
|
+
const at = rowIndexOf(activated, TREE_ROW_PREFIX);
|
|
124
|
+
if (at >= 0) host.select(tree.rows[at].node);
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Every row carries a name of `row:<index>` or `field:<index>`, so the node the router hands back
|
|
128
|
+
parses straight to the row — one parse rather than a node-to-row map that has to be kept in step.
|
|
129
|
+
|
|
130
|
+
## DriftScript
|
|
131
|
+
|
|
132
|
+
`drift/editor` is bound, under the `editor` effect, which is outside `DETERMINISTIC_EFFECTS`: a gizmo
|
|
133
|
+
and a selection hold a person's pointer part way through moving something, so a `@deterministic`
|
|
134
|
+
system reading one would replay differently. It is the effect `@editor` was designed to pair with.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selection, the gizmo, and play-in-editor.
|
|
3
|
+
*
|
|
4
|
+
* **The loop already has the seam this needs.** `LoopHooks.shouldSimulate` exists so a paused game
|
|
5
|
+
* keeps rendering — the world stays on screen behind the menu instead of freezing on whatever frame
|
|
6
|
+
* it stopped at — and that is exactly what an editor wants. `shouldSimulate` here is handed to
|
|
7
|
+
* `startLoop` and is the whole of pause.
|
|
8
|
+
*
|
|
9
|
+
* ## What makes stop harder than it looks
|
|
10
|
+
*
|
|
11
|
+
* **`deserializeWorld` creates entities.** It does not replace a world's contents; it appends to
|
|
12
|
+
* them. So restoring a snapshot by loading it leaves the world holding both what was authored and
|
|
13
|
+
* what play produced, which is not a restore — it is a duplication that grows every time somebody
|
|
14
|
+
* presses stop. This clears first, walking the same `store.dense` per type that `serializeWorld`
|
|
15
|
+
* walks to decide what is live.
|
|
16
|
+
*
|
|
17
|
+
* **And every entity handle changes across a stop**, because the restored entities are new ones. A
|
|
18
|
+
* selection held as a handle is stale the moment play ends — pointing at nothing, or at whatever
|
|
19
|
+
* reused the slot, which is the worse of the two because it looks like it worked. So an entity
|
|
20
|
+
* selection is remembered by its *index in the snapshot*, which is the only identity that survives,
|
|
21
|
+
* and an entity created during play has no index and the selection is cleared rather than moved to
|
|
22
|
+
* a stranger.
|
|
23
|
+
*
|
|
24
|
+
* **What a snapshot covers is the component types it was given and nothing else.** Not the node
|
|
25
|
+
* hierarchy, not a physics world, not an audio graph. `serializeWorld` makes the same point about
|
|
26
|
+
* types: a scene is a decision about what to save.
|
|
27
|
+
*/
|
|
28
|
+
import { Gizmo } from '@driftengine/core';
|
|
29
|
+
import type { SceneNode } from '@driftengine/core';
|
|
30
|
+
import type { ComponentType, Entity, World } from '@driftengine/entities';
|
|
31
|
+
import { Inspector } from './inspector.ts';
|
|
32
|
+
import { SceneTree } from './sceneTree.ts';
|
|
33
|
+
export type EditorMode = 'edit' | 'play' | 'paused';
|
|
34
|
+
export interface EditorWorld {
|
|
35
|
+
readonly world: World;
|
|
36
|
+
/** What a snapshot covers. See the header: this is a decision, not a discovery. */
|
|
37
|
+
readonly types: readonly ComponentType[];
|
|
38
|
+
}
|
|
39
|
+
export declare class EditorHost {
|
|
40
|
+
readonly tree: SceneTree;
|
|
41
|
+
readonly inspector: Inspector;
|
|
42
|
+
readonly gizmo: Gizmo;
|
|
43
|
+
private state;
|
|
44
|
+
private readonly bound;
|
|
45
|
+
private snapshot;
|
|
46
|
+
/** Where the selected entity sat in the snapshot, so it can be found again after a restore. */
|
|
47
|
+
private selectedIndex;
|
|
48
|
+
private selectedEntity;
|
|
49
|
+
/** Consumed by the next `shouldSimulate`, which is what makes a step exactly one tick. */
|
|
50
|
+
private pendingStep;
|
|
51
|
+
constructor(bound?: EditorWorld | null);
|
|
52
|
+
get mode(): EditorMode;
|
|
53
|
+
/** The entity selected, or null. Cleared by a stop that did not restore it. */
|
|
54
|
+
get entity(): Entity | null;
|
|
55
|
+
/**
|
|
56
|
+
* Hand this to `startLoop` as `shouldSimulate`.
|
|
57
|
+
*
|
|
58
|
+
* **It has a side effect and that is the point**: a pending step is consumed here, so exactly one
|
|
59
|
+
* tick runs and the next call answers false. A step expressed as a duration would advance a
|
|
60
|
+
* variable number of ticks depending on which frame it landed in, which is the opposite of what
|
|
61
|
+
* stepping is for.
|
|
62
|
+
*/
|
|
63
|
+
shouldSimulate: () => boolean;
|
|
64
|
+
/** Select a node, point the gizmo at it, and show its transform. */
|
|
65
|
+
select(node: SceneNode | null): void;
|
|
66
|
+
/**
|
|
67
|
+
* Select an entity, and remember where it sits in the snapshot if one is open.
|
|
68
|
+
*
|
|
69
|
+
* The index is taken now rather than at stop, because at stop the entity may already be gone and
|
|
70
|
+
* there would be nothing left to look it up by.
|
|
71
|
+
*/
|
|
72
|
+
selectEntity(entity: Entity | null): void;
|
|
73
|
+
/** Move the gizmo onto the selection. A selection of nothing leaves it where it was. */
|
|
74
|
+
syncGizmo(): void;
|
|
75
|
+
/**
|
|
76
|
+
* Write the gizmo's transform onto the selection, and refresh what the inspector shows.
|
|
77
|
+
*
|
|
78
|
+
* Through the node's setters, for the reason `inspector.ts` gives: writing the arrays leaves the
|
|
79
|
+
* world matrix stale and the node draws where it used to be.
|
|
80
|
+
*/
|
|
81
|
+
applyGizmo(): void;
|
|
82
|
+
/**
|
|
83
|
+
* Enter play, taking a snapshot first. `false` when there is no world bound to snapshot.
|
|
84
|
+
*
|
|
85
|
+
* Playing from `paused` resumes without re-snapshotting, so pausing does not throw away the state
|
|
86
|
+
* stop would have returned to.
|
|
87
|
+
*/
|
|
88
|
+
play(): boolean;
|
|
89
|
+
pause(): void;
|
|
90
|
+
/** One tick, then paused. Does nothing outside play mode, where there is nothing to step. */
|
|
91
|
+
step(): void;
|
|
92
|
+
/**
|
|
93
|
+
* Leave play and put the world back. `false` when there is nothing to put back.
|
|
94
|
+
*
|
|
95
|
+
* See the header for why this is a clear and then a load rather than a load.
|
|
96
|
+
*/
|
|
97
|
+
stop(): boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Every entity any of the bound types holds, destroyed.
|
|
100
|
+
*
|
|
101
|
+
* The same walk `serializeWorld` does, and for the same reason: a world's live set is what its
|
|
102
|
+
* stores hold, and there is no other enumeration of it. Collected before destroying, because
|
|
103
|
+
* destroying while walking a store's dense array moves entries under the walk.
|
|
104
|
+
*/
|
|
105
|
+
private clearWorld;
|
|
106
|
+
/** Where an entity sits in the snapshot's ordering, or `-1`. */
|
|
107
|
+
private indexInSnapshot;
|
|
108
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selection, the gizmo, and play-in-editor.
|
|
3
|
+
*
|
|
4
|
+
* **The loop already has the seam this needs.** `LoopHooks.shouldSimulate` exists so a paused game
|
|
5
|
+
* keeps rendering — the world stays on screen behind the menu instead of freezing on whatever frame
|
|
6
|
+
* it stopped at — and that is exactly what an editor wants. `shouldSimulate` here is handed to
|
|
7
|
+
* `startLoop` and is the whole of pause.
|
|
8
|
+
*
|
|
9
|
+
* ## What makes stop harder than it looks
|
|
10
|
+
*
|
|
11
|
+
* **`deserializeWorld` creates entities.** It does not replace a world's contents; it appends to
|
|
12
|
+
* them. So restoring a snapshot by loading it leaves the world holding both what was authored and
|
|
13
|
+
* what play produced, which is not a restore — it is a duplication that grows every time somebody
|
|
14
|
+
* presses stop. This clears first, walking the same `store.dense` per type that `serializeWorld`
|
|
15
|
+
* walks to decide what is live.
|
|
16
|
+
*
|
|
17
|
+
* **And every entity handle changes across a stop**, because the restored entities are new ones. A
|
|
18
|
+
* selection held as a handle is stale the moment play ends — pointing at nothing, or at whatever
|
|
19
|
+
* reused the slot, which is the worse of the two because it looks like it worked. So an entity
|
|
20
|
+
* selection is remembered by its *index in the snapshot*, which is the only identity that survives,
|
|
21
|
+
* and an entity created during play has no index and the selection is cleared rather than moved to
|
|
22
|
+
* a stranger.
|
|
23
|
+
*
|
|
24
|
+
* **What a snapshot covers is the component types it was given and nothing else.** Not the node
|
|
25
|
+
* hierarchy, not a physics world, not an audio graph. `serializeWorld` makes the same point about
|
|
26
|
+
* types: a scene is a decision about what to save.
|
|
27
|
+
*/
|
|
28
|
+
import { Gizmo } from '@driftengine/core';
|
|
29
|
+
import { deserializeWorld, serializeWorld } from '@driftengine/entities';
|
|
30
|
+
import { Inspector } from './inspector.js';
|
|
31
|
+
import { SceneTree } from './sceneTree.js';
|
|
32
|
+
export class EditorHost {
|
|
33
|
+
tree = new SceneTree();
|
|
34
|
+
inspector = new Inspector();
|
|
35
|
+
gizmo = new Gizmo();
|
|
36
|
+
state = 'edit';
|
|
37
|
+
bound;
|
|
38
|
+
snapshot = null;
|
|
39
|
+
/** Where the selected entity sat in the snapshot, so it can be found again after a restore. */
|
|
40
|
+
selectedIndex = -1;
|
|
41
|
+
selectedEntity = null;
|
|
42
|
+
/** Consumed by the next `shouldSimulate`, which is what makes a step exactly one tick. */
|
|
43
|
+
pendingStep = false;
|
|
44
|
+
constructor(bound = null) {
|
|
45
|
+
this.bound = bound;
|
|
46
|
+
}
|
|
47
|
+
get mode() {
|
|
48
|
+
return this.state;
|
|
49
|
+
}
|
|
50
|
+
/** The entity selected, or null. Cleared by a stop that did not restore it. */
|
|
51
|
+
get entity() {
|
|
52
|
+
return this.selectedEntity;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Hand this to `startLoop` as `shouldSimulate`.
|
|
56
|
+
*
|
|
57
|
+
* **It has a side effect and that is the point**: a pending step is consumed here, so exactly one
|
|
58
|
+
* tick runs and the next call answers false. A step expressed as a duration would advance a
|
|
59
|
+
* variable number of ticks depending on which frame it landed in, which is the opposite of what
|
|
60
|
+
* stepping is for.
|
|
61
|
+
*/
|
|
62
|
+
shouldSimulate = () => {
|
|
63
|
+
if (this.state === 'play')
|
|
64
|
+
return true;
|
|
65
|
+
if (this.pendingStep) {
|
|
66
|
+
this.pendingStep = false;
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
};
|
|
71
|
+
/** Select a node, point the gizmo at it, and show its transform. */
|
|
72
|
+
select(node) {
|
|
73
|
+
this.tree.selected = node;
|
|
74
|
+
this.selectedEntity = null;
|
|
75
|
+
this.selectedIndex = -1;
|
|
76
|
+
if (node === null) {
|
|
77
|
+
this.inspector.showNothing();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
this.inspector.showNode(node);
|
|
81
|
+
this.syncGizmo();
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Select an entity, and remember where it sits in the snapshot if one is open.
|
|
85
|
+
*
|
|
86
|
+
* The index is taken now rather than at stop, because at stop the entity may already be gone and
|
|
87
|
+
* there would be nothing left to look it up by.
|
|
88
|
+
*/
|
|
89
|
+
selectEntity(entity) {
|
|
90
|
+
this.tree.selected = null;
|
|
91
|
+
this.selectedEntity = entity;
|
|
92
|
+
this.selectedIndex = entity === null ? -1 : this.indexInSnapshot(entity);
|
|
93
|
+
if (entity === null || this.bound === null) {
|
|
94
|
+
this.inspector.showNothing();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
this.inspector.showEntity(this.bound.world, entity, this.bound.types);
|
|
98
|
+
}
|
|
99
|
+
/** Move the gizmo onto the selection. A selection of nothing leaves it where it was. */
|
|
100
|
+
syncGizmo() {
|
|
101
|
+
const node = this.tree.selected;
|
|
102
|
+
if (node === null)
|
|
103
|
+
return;
|
|
104
|
+
this.gizmo.position.set(node.position);
|
|
105
|
+
this.gizmo.rotation.set(node.rotation);
|
|
106
|
+
this.gizmo.scale.set(node.scale);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Write the gizmo's transform onto the selection, and refresh what the inspector shows.
|
|
110
|
+
*
|
|
111
|
+
* Through the node's setters, for the reason `inspector.ts` gives: writing the arrays leaves the
|
|
112
|
+
* world matrix stale and the node draws where it used to be.
|
|
113
|
+
*/
|
|
114
|
+
applyGizmo() {
|
|
115
|
+
const node = this.tree.selected;
|
|
116
|
+
if (node === null)
|
|
117
|
+
return;
|
|
118
|
+
node.setPosition(this.gizmo.position[0], this.gizmo.position[1], this.gizmo.position[2]);
|
|
119
|
+
node.setScale(this.gizmo.scale[0], this.gizmo.scale[1], this.gizmo.scale[2]);
|
|
120
|
+
node.rotation.set(this.gizmo.rotation);
|
|
121
|
+
node.markMoved();
|
|
122
|
+
this.inspector.refresh();
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Enter play, taking a snapshot first. `false` when there is no world bound to snapshot.
|
|
126
|
+
*
|
|
127
|
+
* Playing from `paused` resumes without re-snapshotting, so pausing does not throw away the state
|
|
128
|
+
* stop would have returned to.
|
|
129
|
+
*/
|
|
130
|
+
play() {
|
|
131
|
+
if (this.state === 'paused') {
|
|
132
|
+
this.state = 'play';
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
if (this.bound === null)
|
|
136
|
+
return false;
|
|
137
|
+
this.snapshot = serializeWorld(this.bound.world, this.bound.types);
|
|
138
|
+
if (this.selectedEntity !== null)
|
|
139
|
+
this.selectedIndex = this.indexInSnapshot(this.selectedEntity);
|
|
140
|
+
this.state = 'play';
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
pause() {
|
|
144
|
+
if (this.state === 'play')
|
|
145
|
+
this.state = 'paused';
|
|
146
|
+
}
|
|
147
|
+
/** One tick, then paused. Does nothing outside play mode, where there is nothing to step. */
|
|
148
|
+
step() {
|
|
149
|
+
if (this.state === 'edit')
|
|
150
|
+
return;
|
|
151
|
+
this.state = 'paused';
|
|
152
|
+
this.pendingStep = true;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Leave play and put the world back. `false` when there is nothing to put back.
|
|
156
|
+
*
|
|
157
|
+
* See the header for why this is a clear and then a load rather than a load.
|
|
158
|
+
*/
|
|
159
|
+
stop() {
|
|
160
|
+
if (this.bound === null || this.snapshot === null) {
|
|
161
|
+
this.state = 'edit';
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
this.clearWorld();
|
|
165
|
+
const result = deserializeWorld(this.bound.world, this.snapshot, this.bound.types);
|
|
166
|
+
this.state = 'edit';
|
|
167
|
+
this.pendingStep = false;
|
|
168
|
+
if (!result.loaded)
|
|
169
|
+
return false;
|
|
170
|
+
/* The selection follows its index, which is the only identity that survived. An entity that was
|
|
171
|
+
created during play has none, and the selection is emptied rather than pointed at a stranger. */
|
|
172
|
+
const restored = this.selectedIndex >= 0 ? result.entities[this.selectedIndex] : undefined;
|
|
173
|
+
this.selectEntity(restored ?? null);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Every entity any of the bound types holds, destroyed.
|
|
178
|
+
*
|
|
179
|
+
* The same walk `serializeWorld` does, and for the same reason: a world's live set is what its
|
|
180
|
+
* stores hold, and there is no other enumeration of it. Collected before destroying, because
|
|
181
|
+
* destroying while walking a store's dense array moves entries under the walk.
|
|
182
|
+
*/
|
|
183
|
+
clearWorld() {
|
|
184
|
+
if (this.bound === null)
|
|
185
|
+
return;
|
|
186
|
+
const { world, types } = this.bound;
|
|
187
|
+
const seen = new Set();
|
|
188
|
+
for (const type of types) {
|
|
189
|
+
const store = world.store(type);
|
|
190
|
+
for (let at = 0; at < store.size; at++)
|
|
191
|
+
seen.add(store.dense[at]);
|
|
192
|
+
}
|
|
193
|
+
for (const entity of seen)
|
|
194
|
+
world.destroy(entity);
|
|
195
|
+
}
|
|
196
|
+
/** Where an entity sits in the snapshot's ordering, or `-1`. */
|
|
197
|
+
indexInSnapshot(entity) {
|
|
198
|
+
if (this.bound === null)
|
|
199
|
+
return -1;
|
|
200
|
+
const { world, types } = this.bound;
|
|
201
|
+
const seen = new Set();
|
|
202
|
+
let index = 0;
|
|
203
|
+
for (const type of types) {
|
|
204
|
+
const store = world.store(type);
|
|
205
|
+
for (let at = 0; at < store.size; at++) {
|
|
206
|
+
const candidate = store.dense[at];
|
|
207
|
+
if (seen.has(candidate))
|
|
208
|
+
continue;
|
|
209
|
+
if (candidate === entity)
|
|
210
|
+
return index;
|
|
211
|
+
seen.add(candidate);
|
|
212
|
+
index += 1;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return -1;
|
|
216
|
+
}
|
|
217
|
+
}
|