@volter/editor-game 0.5.67 → 0.5.68
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/contributions/build-player.document.tsx +95 -0
- package/package.json +8 -7
- package/src/build/BuildProfilesPanel.tsx +11 -0
- package/src/build/build-session.ts +35 -0
- package/src/canvas/canvas-board/CanvasBoardDocument.tsx +76 -3
- package/src/host/authoring/pixi-authoring-adapter.ts +148 -26
- package/src/host/authoring/pixi-creatable-kinds.ts +62 -0
- package/src/host/authoring/pixi-source-write-target.ts +114 -19
- package/src/host/components/PixiIsolationSceneContent.tsx +1 -1
- package/src/host/roots/canvas-root.tsx +12 -0
- package/src/host/served-bundle-runtime-modules.ts +2 -0
- package/src/ingest/ingest-canvas-scene-document.tsx +1 -1
- package/src/network/NetworkInspectorPanel.tsx +519 -52
- package/src/network/network-inspector-model.ts +6 -0
- package/src/runtime/create-runtime.ts +1 -1
- package/src/runtime/input-router.ts +24 -11
- package/src/services/game-network.ts +121 -11
|
@@ -90,6 +90,10 @@ export interface NetworkCapabilityMap {
|
|
|
90
90
|
ping: boolean;
|
|
91
91
|
/** The room server's own view (`inspectServer`). */
|
|
92
92
|
server: boolean;
|
|
93
|
+
/** Traffic per replicated entity (`getTrafficByEntity`). */
|
|
94
|
+
entityTraffic: boolean;
|
|
95
|
+
/** Tallying that can be started and stopped (`isRecording`/`setRecording`). */
|
|
96
|
+
recording: boolean;
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
export function deriveNetworkCapabilities(
|
|
@@ -115,6 +119,8 @@ export function deriveNetworkCapabilities(
|
|
|
115
119
|
send: typeof adapter.sendMessage === 'function',
|
|
116
120
|
ping: typeof adapter.ping === 'function',
|
|
117
121
|
server: typeof adapter.inspectServer === 'function',
|
|
122
|
+
entityTraffic: typeof adapter.getTrafficByEntity === 'function',
|
|
123
|
+
recording: typeof adapter.isRecording === 'function' && typeof adapter.setRecording === 'function',
|
|
118
124
|
};
|
|
119
125
|
}
|
|
120
126
|
|
|
@@ -76,26 +76,32 @@ function pickEventInit(original: Event): Record<string, unknown> {
|
|
|
76
76
|
'deltaZ',
|
|
77
77
|
'deltaMode',
|
|
78
78
|
];
|
|
79
|
-
const init: Record<string, unknown> = { bubbles:
|
|
79
|
+
const init: Record<string, unknown> = { bubbles: true, cancelable: true, composed: true };
|
|
80
80
|
for (const key of keys) if (key in src) init[key] = src[key];
|
|
81
81
|
return init;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
|
-
* Forward `original` onto `canvas` as a NEW
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
85
|
+
* Forward `original` onto `canvas` as a NEW event of the same event-type
|
|
86
|
+
* family — so a canvas with `pointer-events:none` (never a native DOM
|
|
87
|
+
* hit-test target) still receives the interaction its own event pipeline
|
|
88
|
+
* (Pixi's `EventSystem`, a raycast-driven three controller, …) listens for.
|
|
89
|
+
* The clone BUBBLES, because Pixi hears `pointermove` on the document and
|
|
90
|
+
* `pointerup` on the window, not on its canvas; the container's capture
|
|
91
|
+
* listener sees every clone on the way down (capture runs whether or not an
|
|
92
|
+
* event bubbles), and {@link forwarded} is what lets it pass rather than be
|
|
93
|
+
* routed again. Re-dispatching the SAME event object is not an option (the
|
|
94
|
+
* DOM forbids re-dispatching an event still being dispatched).
|
|
93
95
|
*
|
|
94
96
|
* Best-effort: environments with no `PointerEvent`/`MouseEvent`/
|
|
95
97
|
* `WheelEvent` global (Node unit tests) skip forwarding silently — those
|
|
96
98
|
* tests exercise the pure claim-resolution logic above instead, which needs
|
|
97
99
|
* no real `Event` objects at all.
|
|
98
100
|
*/
|
|
101
|
+
/** The clones this router dispatched: they bubble through the container's capture listener, which
|
|
102
|
+
* lets them pass instead of routing them again. */
|
|
103
|
+
const forwarded = new WeakSet<Event>();
|
|
104
|
+
|
|
99
105
|
function forwardEvent(canvas: HTMLCanvasElement, original: Event): void {
|
|
100
106
|
const g = globalThis as unknown as {
|
|
101
107
|
PointerEvent?: new (type: string, init?: unknown) => Event;
|
|
@@ -110,7 +116,9 @@ function forwardEvent(canvas: HTMLCanvasElement, original: Event): void {
|
|
|
110
116
|
: (g.MouseEvent ?? g.PointerEvent);
|
|
111
117
|
if (!Ctor || typeof canvas.dispatchEvent !== 'function') return;
|
|
112
118
|
try {
|
|
113
|
-
|
|
119
|
+
const clone = new Ctor(original.type, pickEventInit(original));
|
|
120
|
+
forwarded.add(clone);
|
|
121
|
+
canvas.dispatchEvent(clone);
|
|
114
122
|
} catch {
|
|
115
123
|
// Best-effort forwarding — a construction failure here must never break
|
|
116
124
|
// the (already-delivered) original event's own handling.
|
|
@@ -142,6 +150,7 @@ export function createInputRouter(
|
|
|
142
150
|
const bottomId = stackOrder(entries)[0]?.id;
|
|
143
151
|
|
|
144
152
|
const handler = (evt: Event) => {
|
|
153
|
+
if (forwarded.has(evt)) return;
|
|
145
154
|
const rect = container.getBoundingClientRect?.() ?? { left: 0, top: 0 };
|
|
146
155
|
const clientX = (evt as unknown as { clientX?: number }).clientX ?? 0;
|
|
147
156
|
const clientY = (evt as unknown as { clientY?: number }).clientY ?? 0;
|
|
@@ -150,7 +159,11 @@ export function createInputRouter(
|
|
|
150
159
|
const claimant = resolveClaimingRoot(entries, x, y);
|
|
151
160
|
if (claimant && claimant !== bottomId) {
|
|
152
161
|
const entry = byId.get(claimant);
|
|
153
|
-
if (entry)
|
|
162
|
+
if (entry) {
|
|
163
|
+
// The claim is exclusive: the world beneath never hears a press its HUD took.
|
|
164
|
+
evt.stopPropagation();
|
|
165
|
+
forwardEvent(entry.canvas, evt);
|
|
166
|
+
}
|
|
154
167
|
}
|
|
155
168
|
};
|
|
156
169
|
|
|
@@ -18,8 +18,10 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { pack, unpack } from '@colyseus/msgpackr';
|
|
21
|
-
import { decode, encode, type Iterator } from '@colyseus/schema';
|
|
21
|
+
import { type DataChange, decode, encode, type Iterator } from '@colyseus/schema';
|
|
22
22
|
import { SchemaSerializer } from '@colyseus/sdk';
|
|
23
|
+
import { editorHost } from '@volter/editor-sdk/host';
|
|
24
|
+
import { getPlayStartedAt, isPlayModeActive } from '../play/play-mode';
|
|
23
25
|
import type {
|
|
24
26
|
ConnectionState,
|
|
25
27
|
NetConditioning,
|
|
@@ -99,14 +101,18 @@ interface Mirror {
|
|
|
99
101
|
patches: number;
|
|
100
102
|
patchBytes: number;
|
|
101
103
|
types: Map<string, MutableType>;
|
|
104
|
+
entities: Map<string, MutableEntity>;
|
|
102
105
|
socket: WebSocket;
|
|
103
106
|
/** When the last delayed frame is due, per direction: a later frame never overtakes it. */
|
|
104
107
|
dueIn: number;
|
|
108
|
+
/** The game's own `onmessage`, handed each frame when the conditioned delay lets it through. */
|
|
109
|
+
receive: ((event: MessageEvent) => unknown) | null;
|
|
105
110
|
dueOut: number;
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
const mirrors: Mirror[] = [];
|
|
109
114
|
const mirrorOf = new WeakMap<WebSocket, Mirror>();
|
|
115
|
+
const gameHandlers = new WeakMap<WebSocket, ((this: WebSocket, event: MessageEvent) => unknown) | null>();
|
|
110
116
|
const pingWaiters = new WeakMap<Mirror, (rttMs: number) => void>();
|
|
111
117
|
/** The link conditioner (Unity's network simulator): latency and jitter on every observed room
|
|
112
118
|
* socket, both directions, in order. A WebSocket is reliable and ordered, so loss is not one of
|
|
@@ -147,7 +153,73 @@ function roll(now: number): void {
|
|
|
147
153
|
window0 = { start: now, msgsIn: 0, msgsOut: 0, bytesIn: 0, bytesOut: 0 };
|
|
148
154
|
}
|
|
149
155
|
|
|
156
|
+
interface MutableEntity {
|
|
157
|
+
path: string;
|
|
158
|
+
syncs: number;
|
|
159
|
+
changes: number;
|
|
160
|
+
patchBytesMin: number;
|
|
161
|
+
patchBytesMax: number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Whether traffic and the log are tallied (Godot's profiler Start/Stop). */
|
|
165
|
+
let recording = false;
|
|
166
|
+
|
|
167
|
+
/** The project-local choice Godot calls Autostart: a run starts recording at its first room
|
|
168
|
+
* socket. Off unless chosen, as Godot's is. */
|
|
169
|
+
export const NETWORK_AUTOSTART_SECTION = 'networkProfilerAutostart';
|
|
170
|
+
/** The run (its Play start) Autostart last applied to. */
|
|
171
|
+
let autostartedRun: number | null | undefined;
|
|
172
|
+
|
|
173
|
+
/** Each object of a decoded state by its path from the root: schema fields and collection entries. */
|
|
174
|
+
function statePaths(state: unknown): Map<object, readonly string[]> {
|
|
175
|
+
const paths = new Map<object, readonly string[]>();
|
|
176
|
+
const visit = (value: unknown, path: readonly string[]): void => {
|
|
177
|
+
if (!value || typeof value !== 'object' || paths.has(value)) return;
|
|
178
|
+
paths.set(value, path);
|
|
179
|
+
const metadata = (value as { constructor?: Record<symbol, unknown> }).constructor?.[Symbol.metadata] as
|
|
180
|
+
| Record<string, unknown>
|
|
181
|
+
| undefined;
|
|
182
|
+
if (metadata) {
|
|
183
|
+
for (const key of Object.keys(metadata)) {
|
|
184
|
+
const name = /^\d+$/.test(key) ? (metadata[key] as { name?: string } | undefined)?.name : undefined;
|
|
185
|
+
if (name) visit((value as Record<string, unknown>)[name], [...path, name]);
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
(value as { forEach?: (each: (child: unknown, key: unknown) => void) => void }).forEach?.((child, key) =>
|
|
190
|
+
visit(child, [...path, String(key)]),
|
|
191
|
+
);
|
|
192
|
+
};
|
|
193
|
+
visit(state, []);
|
|
194
|
+
return paths;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Tally one patch against the entities its changes touched: an entity is a root field, or an
|
|
198
|
+
* entry of a root collection (`players.abc`). */
|
|
199
|
+
function recordEntities(mirror: Mirror, changes: readonly DataChange[], size: number): void {
|
|
200
|
+
if (!recording || changes.length === 0) return;
|
|
201
|
+
const paths = statePaths(mirror.serializer?.getState());
|
|
202
|
+
const touched = new Map<string, number>();
|
|
203
|
+
for (const change of changes) {
|
|
204
|
+
const full = [...(paths.get(change.ref as object) ?? []), String(change.dynamicIndex ?? change.field)];
|
|
205
|
+
const entity = full.slice(0, full.length > 1 ? 2 : 1).join('.');
|
|
206
|
+
touched.set(entity, (touched.get(entity) ?? 0) + 1);
|
|
207
|
+
}
|
|
208
|
+
for (const [path, count] of touched) {
|
|
209
|
+
let entry = mirror.entities.get(path);
|
|
210
|
+
if (!entry) {
|
|
211
|
+
entry = { path, syncs: 0, changes: 0, patchBytesMin: size, patchBytesMax: size };
|
|
212
|
+
mirror.entities.set(path, entry);
|
|
213
|
+
}
|
|
214
|
+
entry.syncs += 1;
|
|
215
|
+
entry.changes += count;
|
|
216
|
+
entry.patchBytesMin = Math.min(entry.patchBytesMin, size);
|
|
217
|
+
entry.patchBytesMax = Math.max(entry.patchBytesMax, size);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
150
221
|
function record(mirror: Mirror, direction: 'in' | 'out', type: string, size: number, payload?: unknown): void {
|
|
222
|
+
if (!recording) return;
|
|
151
223
|
const now = Date.now();
|
|
152
224
|
roll(now);
|
|
153
225
|
if (direction === 'in') {
|
|
@@ -210,9 +282,15 @@ function observeIncoming(mirror: Mirror, bytes: Uint8Array): void {
|
|
|
210
282
|
mirror.stateBytes = bytes.byteLength;
|
|
211
283
|
record(mirror, 'in', 'state', bytes.byteLength);
|
|
212
284
|
} else if (code === ROOM_STATE_PATCH) {
|
|
285
|
+
const decoder = mirror.serializer?.decoder;
|
|
286
|
+
let changes: readonly DataChange[] = [];
|
|
287
|
+
if (decoder) decoder.triggerChanges = (all) => (changes = all);
|
|
213
288
|
mirror.serializer?.patch(bytes, it);
|
|
214
|
-
mirror
|
|
215
|
-
|
|
289
|
+
recordEntities(mirror, changes, bytes.byteLength);
|
|
290
|
+
if (recording) {
|
|
291
|
+
mirror.patches += 1;
|
|
292
|
+
mirror.patchBytes += bytes.byteLength;
|
|
293
|
+
}
|
|
216
294
|
record(mirror, 'in', 'patch', bytes.byteLength);
|
|
217
295
|
} else if (code === ROOM_DATA) {
|
|
218
296
|
const type = messageType(bytes, it);
|
|
@@ -300,16 +378,32 @@ function attach(socket: WebSocket, url: string): void {
|
|
|
300
378
|
rttMs: null,
|
|
301
379
|
stateBytes: 0,
|
|
302
380
|
patches: 0,
|
|
381
|
+
entities: new Map(),
|
|
303
382
|
patchBytes: 0,
|
|
304
383
|
types: new Map(),
|
|
305
384
|
socket,
|
|
306
385
|
dueIn: 0,
|
|
307
386
|
dueOut: 0,
|
|
387
|
+
receive: null,
|
|
308
388
|
};
|
|
389
|
+
// Autostart decides a RUN's recording once, at its first room socket; a reconnect inside the
|
|
390
|
+
// same run keeps whatever Start or Stop the person chose.
|
|
391
|
+
// Outside Play there is no run to key on: a socket opening with none live starts one.
|
|
392
|
+
const run = isPlayModeActive() ? getPlayStartedAt() : null;
|
|
393
|
+
const idle = !mirrors.some((other) => other.state === 'connected' || other.state === 'connecting');
|
|
394
|
+
if (run !== autostartedRun || (run === null && idle)) {
|
|
395
|
+
autostartedRun = run;
|
|
396
|
+
recording = editorHost().projectLocalState.read<boolean>(NETWORK_AUTOSTART_SECTION) ?? false;
|
|
397
|
+
}
|
|
309
398
|
mirrors.push(mirror);
|
|
310
399
|
mirrorOf.set(socket, mirror);
|
|
400
|
+
// ONE conditioned delivery per frame: the mirror reads it and the game receives it at the same
|
|
401
|
+
// moment, so the tables and Ping see the link the game sees (both directions of the delay).
|
|
311
402
|
socket.addEventListener('message', (event: MessageEvent) => {
|
|
312
|
-
|
|
403
|
+
conditioned(mirror, 'in', () => {
|
|
404
|
+
if (event.data instanceof ArrayBuffer) observeIncoming(mirror, new Uint8Array(event.data));
|
|
405
|
+
mirror.receive?.(event);
|
|
406
|
+
});
|
|
313
407
|
});
|
|
314
408
|
socket.addEventListener('close', (event: CloseEvent) => {
|
|
315
409
|
mirror.state = 'disconnected';
|
|
@@ -362,18 +456,20 @@ export function installGameNetwork(): void {
|
|
|
362
456
|
attach(this, String(url));
|
|
363
457
|
}
|
|
364
458
|
|
|
365
|
-
// The SDK receives through `onmessage`; a room socket's handler is handed each frame
|
|
366
|
-
//
|
|
459
|
+
// The SDK receives through `onmessage`; a room socket's handler is handed each frame by the
|
|
460
|
+
// mirror's conditioned delivery (`attach`), in the same turn the mirror reads it.
|
|
367
461
|
override set onmessage(handler: ((this: WebSocket, event: MessageEvent) => unknown) | null) {
|
|
368
462
|
const mirror = mirrorOf.get(this);
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
463
|
+
if (!mirror) {
|
|
464
|
+
super.onmessage = handler;
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
gameHandlers.set(this, handler);
|
|
468
|
+
mirror.receive = handler ? (event) => handler.call(this, event) : null;
|
|
373
469
|
}
|
|
374
470
|
|
|
375
471
|
override get onmessage(): ((this: WebSocket, event: MessageEvent) => unknown) | null {
|
|
376
|
-
return super.onmessage;
|
|
472
|
+
return mirrorOf.has(this) ? (gameHandlers.get(this) ?? null) : super.onmessage;
|
|
377
473
|
}
|
|
378
474
|
};
|
|
379
475
|
window.WebSocket = Observed as typeof WebSocket;
|
|
@@ -636,6 +732,7 @@ export const observedGameNetwork: NetworkingAdapter = {
|
|
|
636
732
|
clearTraffic(): void {
|
|
637
733
|
for (const mirror of mirrors) {
|
|
638
734
|
mirror.types.clear();
|
|
735
|
+
mirror.entities.clear();
|
|
639
736
|
mirror.patches = 0;
|
|
640
737
|
mirror.patchBytes = 0;
|
|
641
738
|
}
|
|
@@ -648,6 +745,19 @@ export const observedGameNetwork: NetworkingAdapter = {
|
|
|
648
745
|
const mirror = current();
|
|
649
746
|
return mirror ? [...mirror.types.values()].map(({ type, countIn, countOut, bytesIn, bytesOut }) => ({ type, countIn, countOut, bytesIn, bytesOut })) : [];
|
|
650
747
|
},
|
|
748
|
+
getTrafficByEntity() {
|
|
749
|
+
const mirror = current();
|
|
750
|
+
return mirror
|
|
751
|
+
? [...mirror.entities.values()]
|
|
752
|
+
.map(({ path, syncs, changes, patchBytesMin, patchBytesMax }) => ({ path, syncs, changes, patchBytesMin, patchBytesMax }))
|
|
753
|
+
.sort((a, b) => b.syncs - a.syncs)
|
|
754
|
+
: [];
|
|
755
|
+
},
|
|
756
|
+
isRecording: () => recording,
|
|
757
|
+
setRecording(on: boolean): void {
|
|
758
|
+
recording = on;
|
|
759
|
+
notify();
|
|
760
|
+
},
|
|
651
761
|
getServerConfig() {
|
|
652
762
|
const mirror = current();
|
|
653
763
|
if (!mirror) return null;
|