@vulfram/engine 0.14.8-alpha → 0.17.1-alpha
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 +106 -0
- package/package.json +55 -4
- package/src/core.ts +14 -0
- package/src/ecs.ts +1 -0
- package/src/engine/api.ts +234 -23
- package/src/engine/bridge/dispatch.ts +265 -40
- package/src/engine/bridge/guards.ts +4 -1
- package/src/engine/bridge/protocol.ts +69 -52
- package/src/engine/ecs/index.ts +185 -42
- package/src/engine/state.ts +133 -2
- package/src/engine/systems/command-intent.ts +153 -3
- package/src/engine/systems/constraint-solve.ts +167 -0
- package/src/engine/systems/core-command-builder.ts +9 -268
- package/src/engine/systems/diagnostics.ts +20 -19
- package/src/engine/systems/index.ts +3 -1
- package/src/engine/systems/input-mirror.ts +101 -3
- package/src/engine/systems/resource-upload.ts +96 -44
- package/src/engine/systems/response-decode.ts +69 -15
- package/src/engine/systems/scene-sync.ts +306 -0
- package/src/engine/systems/ui-bridge.ts +360 -0
- package/src/engine/systems/utils.ts +43 -1
- package/src/engine/systems/world-lifecycle.ts +37 -102
- package/src/engine/window/manager.ts +168 -0
- package/src/engine/world/entities.ts +821 -78
- package/src/engine/world/mount.ts +174 -0
- package/src/engine/world/types.ts +71 -0
- package/src/engine/world/world-ui.ts +266 -0
- package/src/engine/world/world3d.ts +280 -0
- package/src/index.ts +30 -1
- package/src/mount.ts +2 -0
- package/src/types/cmds/audio.ts +73 -48
- package/src/types/cmds/camera.ts +12 -8
- package/src/types/cmds/environment.ts +9 -3
- package/src/types/cmds/geometry.ts +13 -14
- package/src/types/cmds/index.ts +198 -168
- package/src/types/cmds/light.ts +12 -11
- package/src/types/cmds/material.ts +9 -11
- package/src/types/cmds/model.ts +17 -15
- package/src/types/cmds/realm.ts +25 -0
- package/src/types/cmds/system.ts +19 -0
- package/src/types/cmds/target.ts +82 -0
- package/src/types/cmds/texture.ts +13 -3
- package/src/types/cmds/ui.ts +220 -0
- package/src/types/cmds/window.ts +41 -204
- package/src/types/events/index.ts +4 -1
- package/src/types/events/pointer.ts +42 -13
- package/src/types/events/system.ts +144 -30
- package/src/types/events/ui.ts +21 -0
- package/src/types/index.ts +1 -0
- package/src/types/json.ts +15 -0
- package/src/window.ts +8 -0
- package/src/world-ui.ts +2 -0
- package/src/world3d.ts +10 -0
- package/tsconfig.json +0 -29
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { KeyCode } from '../../types/events/keyboard';
|
|
2
|
+
import { enqueueCommand } from '../bridge/dispatch';
|
|
3
|
+
import type {
|
|
4
|
+
InputStateComponent,
|
|
5
|
+
Intent,
|
|
6
|
+
System,
|
|
7
|
+
UiFieldsetScope,
|
|
8
|
+
UiFocusableNode,
|
|
9
|
+
UiFormScope,
|
|
10
|
+
UiStateComponent,
|
|
11
|
+
} from '../ecs';
|
|
12
|
+
|
|
13
|
+
const WORLD_ENTITY_ID = 0;
|
|
14
|
+
|
|
15
|
+
function ensureUiState(world: Parameters<System>[0]): UiStateComponent {
|
|
16
|
+
let worldStore = world.components.get(WORLD_ENTITY_ID);
|
|
17
|
+
if (!worldStore) {
|
|
18
|
+
worldStore = new Map();
|
|
19
|
+
world.components.set(WORLD_ENTITY_ID, worldStore);
|
|
20
|
+
world.entities.add(WORLD_ENTITY_ID);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let uiState = worldStore.get('UiState') as UiStateComponent | undefined;
|
|
24
|
+
if (!uiState) {
|
|
25
|
+
uiState = {
|
|
26
|
+
type: 'UiState',
|
|
27
|
+
forms: new Map(),
|
|
28
|
+
fieldsets: new Map(),
|
|
29
|
+
nodes: new Map(),
|
|
30
|
+
focusByWindow: new Map(),
|
|
31
|
+
};
|
|
32
|
+
worldStore.set('UiState', uiState);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return uiState;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getInputState(world: Parameters<System>[0]): InputStateComponent | undefined {
|
|
39
|
+
const worldStore = world.components.get(WORLD_ENTITY_ID);
|
|
40
|
+
return worldStore?.get('InputState') as InputStateComponent | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fieldsetKey(formId: string, fieldsetId: string): string {
|
|
44
|
+
return `${formId}::${fieldsetId}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function canFocusNode(
|
|
48
|
+
form: UiFormScope,
|
|
49
|
+
node: UiFocusableNode,
|
|
50
|
+
fieldsets: Map<string, UiFieldsetScope>,
|
|
51
|
+
): boolean {
|
|
52
|
+
if (form.disabled || node.disabled || node.tabIndex < 0) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (form.activeFieldsetId && node.fieldsetId !== form.activeFieldsetId) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (node.fieldsetId) {
|
|
61
|
+
const fieldset = fieldsets.get(fieldsetKey(form.formId, node.fieldsetId));
|
|
62
|
+
if (fieldset?.disabled && fieldset.legendNodeId !== node.nodeId) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sortFocusables(a: UiFocusableNode, b: UiFocusableNode): number {
|
|
71
|
+
const aPositive = a.tabIndex > 0;
|
|
72
|
+
const bPositive = b.tabIndex > 0;
|
|
73
|
+
if (aPositive !== bPositive) return aPositive ? -1 : 1;
|
|
74
|
+
|
|
75
|
+
if (aPositive && bPositive && a.tabIndex !== b.tabIndex) {
|
|
76
|
+
return a.tabIndex - b.tabIndex;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (a.orderHint !== b.orderHint) {
|
|
80
|
+
return a.orderHint - b.orderHint;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return a.nodeId - b.nodeId;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolveTargetForm(
|
|
87
|
+
uiState: UiStateComponent,
|
|
88
|
+
windowId: number,
|
|
89
|
+
formId?: string,
|
|
90
|
+
): UiFormScope | undefined {
|
|
91
|
+
if (formId) {
|
|
92
|
+
const form = uiState.forms.get(formId);
|
|
93
|
+
if (form && form.windowId === windowId && !form.disabled) {
|
|
94
|
+
return form;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const focused = uiState.focusByWindow.get(windowId);
|
|
99
|
+
if (focused) {
|
|
100
|
+
const form = uiState.forms.get(focused.formId);
|
|
101
|
+
if (form && !form.disabled) {
|
|
102
|
+
return form;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const forms = Array.from(uiState.forms.values())
|
|
107
|
+
.filter((f) => f.windowId === windowId && !f.disabled)
|
|
108
|
+
.sort((a, b) => a.formId.localeCompare(b.formId));
|
|
109
|
+
|
|
110
|
+
return forms[0];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resolveNextNode(
|
|
114
|
+
uiState: UiStateComponent,
|
|
115
|
+
windowId: number,
|
|
116
|
+
backwards: boolean,
|
|
117
|
+
formId?: string,
|
|
118
|
+
): { form: UiFormScope; nodeId: number } | null {
|
|
119
|
+
const form = resolveTargetForm(uiState, windowId, formId);
|
|
120
|
+
if (!form) return null;
|
|
121
|
+
|
|
122
|
+
const candidates = Array.from(uiState.nodes.values())
|
|
123
|
+
.filter((node) => node.formId === form.formId)
|
|
124
|
+
.filter((node) => canFocusNode(form, node, uiState.fieldsets))
|
|
125
|
+
.sort(sortFocusables);
|
|
126
|
+
|
|
127
|
+
if (candidates.length === 0) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const focused = uiState.focusByWindow.get(windowId);
|
|
132
|
+
const activeNodeId =
|
|
133
|
+
focused?.formId === form.formId ? focused.nodeId : form.activeNodeId;
|
|
134
|
+
const currentIndex = candidates.findIndex((node) => node.nodeId === activeNodeId);
|
|
135
|
+
|
|
136
|
+
let nextIndex = currentIndex;
|
|
137
|
+
if (currentIndex < 0) {
|
|
138
|
+
nextIndex = backwards ? candidates.length - 1 : 0;
|
|
139
|
+
} else {
|
|
140
|
+
nextIndex = backwards ? currentIndex - 1 : currentIndex + 1;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const cycleMode = form.cycleMode;
|
|
144
|
+
if (nextIndex < 0 || nextIndex >= candidates.length) {
|
|
145
|
+
if (cycleMode === 'clamp') {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
nextIndex = nextIndex < 0 ? candidates.length - 1 : 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const target = candidates[nextIndex];
|
|
152
|
+
if (!target) return null;
|
|
153
|
+
return { form, nodeId: target.nodeId };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function applyFocus(uiState: UiStateComponent, windowId: number, formId: string, nodeId: number): void {
|
|
157
|
+
uiState.focusByWindow.set(windowId, { formId, nodeId });
|
|
158
|
+
const form = uiState.forms.get(formId);
|
|
159
|
+
if (form) {
|
|
160
|
+
form.activeNodeId = nodeId;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function processUiIntent(
|
|
165
|
+
worldId: number,
|
|
166
|
+
uiState: UiStateComponent,
|
|
167
|
+
intent: Intent,
|
|
168
|
+
): boolean {
|
|
169
|
+
switch (intent.type) {
|
|
170
|
+
case 'ui-theme-define':
|
|
171
|
+
enqueueCommand(worldId, 'cmd-ui-theme-define', intent.args);
|
|
172
|
+
return true;
|
|
173
|
+
case 'ui-theme-dispose':
|
|
174
|
+
enqueueCommand(worldId, 'cmd-ui-theme-dispose', intent.args);
|
|
175
|
+
return true;
|
|
176
|
+
case 'ui-document-create':
|
|
177
|
+
enqueueCommand(worldId, 'cmd-ui-document-create', intent.args);
|
|
178
|
+
return true;
|
|
179
|
+
case 'ui-document-dispose':
|
|
180
|
+
enqueueCommand(worldId, 'cmd-ui-document-dispose', intent.args);
|
|
181
|
+
return true;
|
|
182
|
+
case 'ui-document-set-rect':
|
|
183
|
+
enqueueCommand(worldId, 'cmd-ui-document-set-rect', intent.args);
|
|
184
|
+
return true;
|
|
185
|
+
case 'ui-document-set-theme':
|
|
186
|
+
enqueueCommand(worldId, 'cmd-ui-document-set-theme', intent.args);
|
|
187
|
+
return true;
|
|
188
|
+
case 'ui-document-get-tree':
|
|
189
|
+
enqueueCommand(worldId, 'cmd-ui-document-get-tree', intent.args);
|
|
190
|
+
return true;
|
|
191
|
+
case 'ui-document-get-layout-rects':
|
|
192
|
+
enqueueCommand(worldId, 'cmd-ui-document-get-layout-rects', intent.args);
|
|
193
|
+
return true;
|
|
194
|
+
case 'ui-apply-ops':
|
|
195
|
+
enqueueCommand(worldId, 'cmd-ui-apply-ops', intent.args);
|
|
196
|
+
return true;
|
|
197
|
+
case 'ui-debug-set':
|
|
198
|
+
enqueueCommand(worldId, 'cmd-ui-debug-set', intent.args);
|
|
199
|
+
return true;
|
|
200
|
+
case 'ui-focus-set': {
|
|
201
|
+
enqueueCommand(worldId, 'cmd-ui-focus-set', intent.args);
|
|
202
|
+
const form = resolveTargetForm(uiState, intent.args.windowId);
|
|
203
|
+
if (form && form.realmId === intent.args.realmId && form.documentId === intent.args.documentId) {
|
|
204
|
+
applyFocus(uiState, intent.args.windowId, form.formId, intent.args.nodeId ?? 0);
|
|
205
|
+
}
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
case 'ui-focus-get':
|
|
209
|
+
enqueueCommand(worldId, 'cmd-ui-focus-get', intent.args);
|
|
210
|
+
return true;
|
|
211
|
+
case 'ui-event-trace-set':
|
|
212
|
+
enqueueCommand(worldId, 'cmd-ui-event-trace-set', intent.args);
|
|
213
|
+
return true;
|
|
214
|
+
case 'ui-image-create-from-buffer':
|
|
215
|
+
enqueueCommand(worldId, 'cmd-ui-image-create-from-buffer', intent.args);
|
|
216
|
+
return true;
|
|
217
|
+
case 'ui-image-dispose':
|
|
218
|
+
enqueueCommand(worldId, 'cmd-ui-image-dispose', intent.args);
|
|
219
|
+
return true;
|
|
220
|
+
case 'ui-clipboard-paste':
|
|
221
|
+
enqueueCommand(worldId, 'cmd-ui-clipboard-paste', intent.args);
|
|
222
|
+
return true;
|
|
223
|
+
case 'ui-screenshot-reply':
|
|
224
|
+
enqueueCommand(worldId, 'cmd-ui-screenshot-reply', intent.args);
|
|
225
|
+
return true;
|
|
226
|
+
case 'ui-access-kit-action-request':
|
|
227
|
+
enqueueCommand(worldId, 'cmd-ui-access-kit-action-request', intent.args);
|
|
228
|
+
return true;
|
|
229
|
+
case 'ui-form-upsert': {
|
|
230
|
+
const existing = uiState.forms.get(intent.form.formId);
|
|
231
|
+
uiState.forms.set(intent.form.formId, {
|
|
232
|
+
formId: intent.form.formId,
|
|
233
|
+
windowId: intent.form.windowId,
|
|
234
|
+
realmId: intent.form.realmId,
|
|
235
|
+
documentId: intent.form.documentId,
|
|
236
|
+
disabled: intent.form.disabled ?? false,
|
|
237
|
+
cycleMode: intent.form.cycleMode ?? 'wrap',
|
|
238
|
+
activeFieldsetId: intent.form.activeFieldsetId,
|
|
239
|
+
activeNodeId: existing?.activeNodeId,
|
|
240
|
+
});
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
case 'ui-form-dispose': {
|
|
244
|
+
uiState.forms.delete(intent.formId);
|
|
245
|
+
for (const [key, fieldset] of uiState.fieldsets) {
|
|
246
|
+
if (fieldset.formId === intent.formId) {
|
|
247
|
+
uiState.fieldsets.delete(key);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
for (const [nodeId, node] of uiState.nodes) {
|
|
251
|
+
if (node.formId === intent.formId) {
|
|
252
|
+
uiState.nodes.delete(nodeId);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
for (const [windowId, focus] of uiState.focusByWindow) {
|
|
256
|
+
if (focus.formId === intent.formId) {
|
|
257
|
+
uiState.focusByWindow.delete(windowId);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
case 'ui-fieldset-upsert': {
|
|
263
|
+
uiState.fieldsets.set(fieldsetKey(intent.fieldset.formId, intent.fieldset.fieldsetId), {
|
|
264
|
+
formId: intent.fieldset.formId,
|
|
265
|
+
fieldsetId: intent.fieldset.fieldsetId,
|
|
266
|
+
disabled: intent.fieldset.disabled ?? false,
|
|
267
|
+
legendNodeId: intent.fieldset.legendNodeId,
|
|
268
|
+
});
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
case 'ui-fieldset-dispose':
|
|
272
|
+
uiState.fieldsets.delete(fieldsetKey(intent.formId, intent.fieldsetId));
|
|
273
|
+
return true;
|
|
274
|
+
case 'ui-focusable-upsert':
|
|
275
|
+
uiState.nodes.set(intent.focusable.nodeId, {
|
|
276
|
+
formId: intent.focusable.formId,
|
|
277
|
+
nodeId: intent.focusable.nodeId,
|
|
278
|
+
tabIndex: intent.focusable.tabIndex ?? 0,
|
|
279
|
+
fieldsetId: intent.focusable.fieldsetId,
|
|
280
|
+
disabled: intent.focusable.disabled ?? false,
|
|
281
|
+
orderHint: intent.focusable.orderHint ?? intent.focusable.nodeId,
|
|
282
|
+
});
|
|
283
|
+
return true;
|
|
284
|
+
case 'ui-focusable-dispose':
|
|
285
|
+
uiState.nodes.delete(intent.nodeId);
|
|
286
|
+
return true;
|
|
287
|
+
case 'ui-focus-next': {
|
|
288
|
+
const next = resolveNextNode(
|
|
289
|
+
uiState,
|
|
290
|
+
intent.windowId,
|
|
291
|
+
intent.backwards ?? false,
|
|
292
|
+
intent.formId,
|
|
293
|
+
);
|
|
294
|
+
if (!next) return true;
|
|
295
|
+
|
|
296
|
+
enqueueCommand(worldId, 'cmd-ui-focus-set', {
|
|
297
|
+
windowId: next.form.windowId,
|
|
298
|
+
realmId: next.form.realmId,
|
|
299
|
+
documentId: next.form.documentId,
|
|
300
|
+
nodeId: next.nodeId,
|
|
301
|
+
});
|
|
302
|
+
applyFocus(uiState, next.form.windowId, next.form.formId, next.nodeId);
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
default:
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Bridges UI-oriented intents into core UI commands and maintains focus state.
|
|
312
|
+
*
|
|
313
|
+
* It also provides default keyboard Tab navigation when explicit
|
|
314
|
+
* `ui-focus-next` intents are not emitted by the application.
|
|
315
|
+
*/
|
|
316
|
+
export const UiBridgeSystem: System = (world, context) => {
|
|
317
|
+
const uiState = ensureUiState(world);
|
|
318
|
+
const intentsToRemove: number[] = [];
|
|
319
|
+
let explicitFocusNavigation = false;
|
|
320
|
+
|
|
321
|
+
for (let i = 0; i < world.pendingIntents.length; i++) {
|
|
322
|
+
const intent = world.pendingIntents[i];
|
|
323
|
+
if (!intent) continue;
|
|
324
|
+
|
|
325
|
+
if (intent.type === 'ui-focus-next') {
|
|
326
|
+
explicitFocusNavigation = true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (processUiIntent(context.worldId, uiState, intent)) {
|
|
330
|
+
intentsToRemove.push(i);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (!explicitFocusNavigation) {
|
|
335
|
+
const inputState = getInputState(world);
|
|
336
|
+
if (inputState?.keysJustPressed.has(KeyCode.Tab)) {
|
|
337
|
+
const backwards =
|
|
338
|
+
inputState.keysPressed.has(KeyCode.ShiftLeft) ||
|
|
339
|
+
inputState.keysPressed.has(KeyCode.ShiftRight);
|
|
340
|
+
const focusWindowId = world.primaryWindowId;
|
|
341
|
+
if (focusWindowId !== undefined) {
|
|
342
|
+
const next = resolveNextNode(uiState, focusWindowId, backwards);
|
|
343
|
+
if (next) {
|
|
344
|
+
enqueueCommand(context.worldId, 'cmd-ui-focus-set', {
|
|
345
|
+
windowId: next.form.windowId,
|
|
346
|
+
realmId: next.form.realmId,
|
|
347
|
+
documentId: next.form.documentId,
|
|
348
|
+
nodeId: next.nodeId,
|
|
349
|
+
});
|
|
350
|
+
applyFocus(uiState, next.form.windowId, next.form.formId, next.nodeId);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
for (let i = intentsToRemove.length - 1; i >= 0; i--) {
|
|
357
|
+
const idx = intentsToRemove[i];
|
|
358
|
+
if (idx !== undefined) world.pendingIntents.splice(idx, 1);
|
|
359
|
+
}
|
|
360
|
+
};
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
import type { TransformComponent } from '../ecs';
|
|
10
10
|
import type { WorldState } from '../state';
|
|
11
11
|
|
|
12
|
+
/** Converts array-like numeric values into a fixed-length tuple buffer. */
|
|
12
13
|
export function toTuple(value: ArrayLike<number>, length: number): number[] {
|
|
13
14
|
const result = new Array<number>(length);
|
|
14
15
|
for (let i = 0; i < length; i++) {
|
|
@@ -17,16 +18,19 @@ export function toTuple(value: ArrayLike<number>, length: number): number[] {
|
|
|
17
18
|
return result;
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
/** Normalizes arbitrary 2D vector input to a strict tuple. */
|
|
20
22
|
export function toVec2(value: ArrayLike<number>): [number, number] {
|
|
21
23
|
const result = toTuple(value, 2);
|
|
22
24
|
return [result[0] ?? 0, result[1] ?? 0];
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
/** Normalizes arbitrary 3D vector input to a strict tuple. */
|
|
25
28
|
export function toVec3(value: ArrayLike<number>): [number, number, number] {
|
|
26
29
|
const result = toTuple(value, 3);
|
|
27
30
|
return [result[0] ?? 0, result[1] ?? 0, result[2] ?? 0];
|
|
28
31
|
}
|
|
29
32
|
|
|
33
|
+
/** Normalizes arbitrary 4D vector input to a strict tuple. */
|
|
30
34
|
export function toVec4(value: ArrayLike<number>): [number, number, number, number] {
|
|
31
35
|
const result = toTuple(value, 4);
|
|
32
36
|
return [
|
|
@@ -37,10 +41,12 @@ export function toVec4(value: ArrayLike<number>): [number, number, number, numbe
|
|
|
37
41
|
];
|
|
38
42
|
}
|
|
39
43
|
|
|
44
|
+
/** Normalizes arbitrary quaternion-like input to a strict `[x, y, z, w]` tuple. */
|
|
40
45
|
export function toQuat(value: ArrayLike<number>): [number, number, number, number] {
|
|
41
46
|
return toVec4(value);
|
|
42
47
|
}
|
|
43
48
|
|
|
49
|
+
/** Normalizes standard-material option payload for command serialization. */
|
|
44
50
|
export function normalizeStandardOptions(options: StandardOptions): StandardOptions {
|
|
45
51
|
const normalized: StandardOptions = {
|
|
46
52
|
...options,
|
|
@@ -56,6 +62,7 @@ export function normalizeStandardOptions(options: StandardOptions): StandardOpti
|
|
|
56
62
|
return normalized;
|
|
57
63
|
}
|
|
58
64
|
|
|
65
|
+
/** Normalizes PBR-material option payload for command serialization. */
|
|
59
66
|
export function normalizePbrOptions(options: PbrOptions): PbrOptions {
|
|
60
67
|
return {
|
|
61
68
|
...options,
|
|
@@ -64,6 +71,7 @@ export function normalizePbrOptions(options: PbrOptions): PbrOptions {
|
|
|
64
71
|
};
|
|
65
72
|
}
|
|
66
73
|
|
|
74
|
+
/** Normalizes polymorphic material options into strict tuple-backed values. */
|
|
67
75
|
export function normalizeMaterialOptions(
|
|
68
76
|
options: MaterialOptions | undefined,
|
|
69
77
|
): MaterialOptions | undefined {
|
|
@@ -80,6 +88,7 @@ export function normalizeMaterialOptions(
|
|
|
80
88
|
};
|
|
81
89
|
}
|
|
82
90
|
|
|
91
|
+
/** Normalizes primitive options that contain vector payloads. */
|
|
83
92
|
export function normalizePrimitiveOptions(options: PrimitiveOptions): PrimitiveOptions {
|
|
84
93
|
if (options.type === 'cube') {
|
|
85
94
|
const content = options.content as CubeOptions;
|
|
@@ -114,7 +123,11 @@ export function normalizePrimitiveOptions(options: PrimitiveOptions): PrimitiveO
|
|
|
114
123
|
return options;
|
|
115
124
|
}
|
|
116
125
|
|
|
117
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Resolves the local transform matrix for an entity, without applying constraints
|
|
128
|
+
* (for example parent hierarchy composition).
|
|
129
|
+
*/
|
|
130
|
+
export function getEntityLocalTransformMatrix(
|
|
118
131
|
world: WorldState,
|
|
119
132
|
entityId: number,
|
|
120
133
|
): mat4 {
|
|
@@ -145,3 +158,32 @@ export function getEntityTransformMatrix(
|
|
|
145
158
|
}
|
|
146
159
|
return m;
|
|
147
160
|
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Returns the latest constraint-resolved world matrix for an entity.
|
|
164
|
+
* Falls back to local transform when no resolved matrix is cached yet.
|
|
165
|
+
*/
|
|
166
|
+
export function getResolvedEntityTransformMatrix(
|
|
167
|
+
world: WorldState,
|
|
168
|
+
entityId: number,
|
|
169
|
+
): mat4 {
|
|
170
|
+
const resolved = world.resolvedEntityTransforms.get(entityId);
|
|
171
|
+
if (!resolved) {
|
|
172
|
+
return getEntityLocalTransformMatrix(world, entityId);
|
|
173
|
+
}
|
|
174
|
+
return mat4.clone(resolved);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Compares 4x4 matrices using an absolute epsilon per component. */
|
|
178
|
+
export function mat4EqualsApprox(
|
|
179
|
+
a: ArrayLike<number>,
|
|
180
|
+
b: ArrayLike<number>,
|
|
181
|
+
epsilon = 1e-6,
|
|
182
|
+
): boolean {
|
|
183
|
+
for (let i = 0; i < 16; i++) {
|
|
184
|
+
if (Math.abs((a[i] ?? 0) - (b[i] ?? 0)) > epsilon) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import type { EnvironmentConfig } from '../../types/cmds/environment';
|
|
2
2
|
import type { ShadowConfig } from '../../types/cmds/shadow';
|
|
3
|
-
import type { WindowState } from '../../types/kinds';
|
|
4
3
|
import { enqueueCommand } from '../bridge/dispatch';
|
|
5
4
|
import type { System } from '../ecs';
|
|
6
|
-
import {
|
|
5
|
+
import { toVec3, toVec4 } from './utils';
|
|
7
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Applies world-scoped lifecycle/configuration intents.
|
|
9
|
+
*
|
|
10
|
+
* Covered intents:
|
|
11
|
+
* - environment configuration
|
|
12
|
+
* - shadow configuration
|
|
13
|
+
* - notification dispatch
|
|
14
|
+
*/
|
|
8
15
|
export const WorldLifecycleSystem: System = (world, context) => {
|
|
9
16
|
const intentsToRemove: number[] = [];
|
|
10
17
|
|
|
@@ -12,103 +19,7 @@ export const WorldLifecycleSystem: System = (world, context) => {
|
|
|
12
19
|
const intent = world.pendingIntents[i];
|
|
13
20
|
if (!intent) continue;
|
|
14
21
|
|
|
15
|
-
if (intent.type === '
|
|
16
|
-
const cmd = {
|
|
17
|
-
windowId: context.worldId,
|
|
18
|
-
title: intent.props.title,
|
|
19
|
-
size: toVec2(intent.props.size),
|
|
20
|
-
position: toVec2(intent.props.position),
|
|
21
|
-
borderless: intent.props.borderless ?? false,
|
|
22
|
-
resizable: intent.props.resizable ?? true,
|
|
23
|
-
transparent: intent.props.transparent ?? false,
|
|
24
|
-
initialState: intent.props.initialState ?? ('windowed' as WindowState),
|
|
25
|
-
} as const;
|
|
26
|
-
const payload: typeof cmd & { canvasId?: string } = { ...cmd };
|
|
27
|
-
if (intent.props.canvasId !== undefined) {
|
|
28
|
-
payload.canvasId = intent.props.canvasId;
|
|
29
|
-
}
|
|
30
|
-
enqueueCommand(context.worldId, 'cmd-window-create', payload);
|
|
31
|
-
intentsToRemove.push(i);
|
|
32
|
-
} else if (intent.type === 'close-window') {
|
|
33
|
-
enqueueCommand(context.worldId, 'cmd-window-close', {
|
|
34
|
-
windowId: context.worldId,
|
|
35
|
-
});
|
|
36
|
-
intentsToRemove.push(i);
|
|
37
|
-
} else if (intent.type === 'update-window') {
|
|
38
|
-
const p = intent.props;
|
|
39
|
-
if (p.title !== undefined) {
|
|
40
|
-
enqueueCommand(context.worldId, 'cmd-window-set-title', {
|
|
41
|
-
windowId: context.worldId,
|
|
42
|
-
title: p.title,
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
if (p.position !== undefined) {
|
|
46
|
-
enqueueCommand(context.worldId, 'cmd-window-set-position', {
|
|
47
|
-
windowId: context.worldId,
|
|
48
|
-
position: p.position,
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
if (p.size !== undefined) {
|
|
52
|
-
enqueueCommand(context.worldId, 'cmd-window-set-size', {
|
|
53
|
-
windowId: context.worldId,
|
|
54
|
-
size: p.size,
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
if (p.state !== undefined) {
|
|
58
|
-
enqueueCommand(context.worldId, 'cmd-window-set-state', {
|
|
59
|
-
windowId: context.worldId,
|
|
60
|
-
state: p.state,
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
if (p.resizable !== undefined) {
|
|
64
|
-
enqueueCommand(context.worldId, 'cmd-window-set-resizable', {
|
|
65
|
-
windowId: context.worldId,
|
|
66
|
-
resizable: p.resizable,
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
if (p.decorations !== undefined) {
|
|
70
|
-
enqueueCommand(context.worldId, 'cmd-window-set-decorations', {
|
|
71
|
-
windowId: context.worldId,
|
|
72
|
-
decorations: p.decorations,
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
if (p.cursorVisible !== undefined) {
|
|
76
|
-
enqueueCommand(context.worldId, 'cmd-window-set-cursor-visible', {
|
|
77
|
-
windowId: context.worldId,
|
|
78
|
-
visible: p.cursorVisible,
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
if (p.cursorGrab !== undefined) {
|
|
82
|
-
enqueueCommand(context.worldId, 'cmd-window-set-cursor-grab', {
|
|
83
|
-
windowId: context.worldId,
|
|
84
|
-
mode: p.cursorGrab,
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
if (p.icon !== undefined) {
|
|
88
|
-
enqueueCommand(context.worldId, 'cmd-window-set-icon', {
|
|
89
|
-
windowId: context.worldId,
|
|
90
|
-
bufferId: p.icon,
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
if (p.cursorIcon !== undefined) {
|
|
94
|
-
enqueueCommand(context.worldId, 'cmd-window-set-cursor-icon', {
|
|
95
|
-
windowId: context.worldId,
|
|
96
|
-
icon: p.cursorIcon,
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
intentsToRemove.push(i);
|
|
100
|
-
} else if (intent.type === 'request-attention') {
|
|
101
|
-
enqueueCommand(context.worldId, 'cmd-window-request-attention', {
|
|
102
|
-
windowId: context.worldId,
|
|
103
|
-
attentionType: intent.attentionType,
|
|
104
|
-
});
|
|
105
|
-
intentsToRemove.push(i);
|
|
106
|
-
} else if (intent.type === 'focus-window') {
|
|
107
|
-
enqueueCommand(context.worldId, 'cmd-window-focus', {
|
|
108
|
-
windowId: context.worldId,
|
|
109
|
-
});
|
|
110
|
-
intentsToRemove.push(i);
|
|
111
|
-
} else if (intent.type === 'configure-environment') {
|
|
22
|
+
if (intent.type === 'configure-environment') {
|
|
112
23
|
const config = intent.config as EnvironmentConfig;
|
|
113
24
|
const payload: EnvironmentConfig = {
|
|
114
25
|
msaa: {
|
|
@@ -124,6 +35,7 @@ export const WorldLifecycleSystem: System = (world, context) => {
|
|
|
124
35
|
skyColor: toVec3(config.skybox.skyColor),
|
|
125
36
|
cubemapTextureId: config.skybox.cubemapTextureId ?? null,
|
|
126
37
|
},
|
|
38
|
+
clearColor: toVec4(config.clearColor ?? [0, 0, 0, 0]),
|
|
127
39
|
post: {
|
|
128
40
|
filterEnabled: config.post.filterEnabled,
|
|
129
41
|
filterExposure: config.post.filterExposure,
|
|
@@ -157,12 +69,35 @@ export const WorldLifecycleSystem: System = (world, context) => {
|
|
|
157
69
|
bloomScatter: config.post.bloomScatter,
|
|
158
70
|
},
|
|
159
71
|
};
|
|
160
|
-
enqueueCommand(context.worldId, 'cmd-environment-
|
|
161
|
-
|
|
72
|
+
enqueueCommand(context.worldId, 'cmd-environment-upsert', {
|
|
73
|
+
environmentId: context.worldId,
|
|
162
74
|
config: payload,
|
|
163
75
|
});
|
|
76
|
+
// Keep current realm->target bindings synchronized with this environment.
|
|
77
|
+
for (const binding of world.targetLayerBindings.values()) {
|
|
78
|
+
binding.environmentId = context.worldId;
|
|
79
|
+
if (world.coreRealmId === undefined) continue;
|
|
80
|
+
enqueueCommand(context.worldId, 'cmd-target-layer-upsert', {
|
|
81
|
+
realmId: world.coreRealmId,
|
|
82
|
+
targetId: binding.targetId,
|
|
83
|
+
layout: binding.layout,
|
|
84
|
+
cameraId: binding.cameraId,
|
|
85
|
+
environmentId: context.worldId,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
164
88
|
intentsToRemove.push(i);
|
|
165
89
|
} else if (intent.type === 'configure-shadows') {
|
|
90
|
+
let windowId = world.primaryWindowId;
|
|
91
|
+
if (windowId === undefined) {
|
|
92
|
+
for (const boundWindowId of world.targetWindowBindings.values()) {
|
|
93
|
+
windowId = boundWindowId;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (windowId === undefined) {
|
|
98
|
+
windowId = world.realmCreateArgs.hostWindowId;
|
|
99
|
+
}
|
|
100
|
+
if (windowId === undefined) continue;
|
|
166
101
|
const c = intent.config || {};
|
|
167
102
|
const config: ShadowConfig = {
|
|
168
103
|
tileResolution: c.tileResolution ?? 1024,
|
|
@@ -174,7 +109,7 @@ export const WorldLifecycleSystem: System = (world, context) => {
|
|
|
174
109
|
normalBias: c.normalBias ?? 0.01,
|
|
175
110
|
};
|
|
176
111
|
enqueueCommand(context.worldId, 'cmd-shadow-configure', {
|
|
177
|
-
windowId
|
|
112
|
+
windowId,
|
|
178
113
|
config,
|
|
179
114
|
});
|
|
180
115
|
intentsToRemove.push(i);
|