@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.
@@ -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
 
@@ -598,7 +598,7 @@ async function mountOneCanvasRoot(
598
598
  id: spec.id,
599
599
  zOrder: spec.zOrder ?? 0,
600
600
  canvas,
601
- hitTest: spec.hitTest,
601
+ hitTest: spec.hitTest ?? (mounted.hitTest ? (x, y) => mounted.hitTest!(x, y) : undefined),
602
602
  },
603
603
  };
604
604
  }
@@ -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: false, cancelable: true, composed: true };
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, non-bubbling event of the same
86
- * event-type family — so a canvas with `pointer-events:none` (never a
87
- * native DOM hit-test target) still receives the interaction its own event
88
- * pipeline (Pixi's `EventSystem`, a raycast-driven three controller, …)
89
- * listens for on ITS canvas. `bubbles:false` is deliberate: it stops the
90
- * clone from re-triggering the container's own capture listener (an
91
- * infinite loop) — re-dispatching the SAME event object is not an option
92
- * either (the DOM forbids re-dispatching an event still being dispatched).
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
- canvas.dispatchEvent(new Ctor(original.type, pickEventInit(original)));
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) forwardEvent(entry.canvas, evt);
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.patches += 1;
215
- mirror.patchBytes += bytes.byteLength;
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
- if (event.data instanceof ArrayBuffer) observeIncoming(mirror, new Uint8Array(event.data));
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
- // after the conditioner's delay. The mirror's own listener reads it on arrival.
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
- super.onmessage =
370
- handler && mirror
371
- ? (event: MessageEvent) => conditioned(mirror, 'in', () => handler.call(this, event))
372
- : handler;
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;