@volter/editor-game 0.5.66 → 0.5.67
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/audio-unlock.service.ts +2 -2
- package/contributions/autoplay.service.ts +1 -1
- package/contributions/bridge.command.ts +3 -3
- package/contributions/canvas/component-board.service.ts +16 -0
- package/contributions/canvas/design-time-mount.service.ts +45 -0
- package/contributions/canvas-story-capture.service.ts +12 -0
- package/contributions/edit-mode-audio.service.ts +2 -2
- package/contributions/edit-mode-networking.service.ts +1 -1
- package/contributions/gameplay.command.ts +4 -4
- package/contributions/generation.service.ts +1 -1
- package/contributions/godot.style.ts +26 -6
- package/contributions/godot.view.ts +6 -0
- package/contributions/ingest.service.ts +2 -2
- package/contributions/instances.command.ts +1 -1
- package/contributions/navmesh.menu.ts +1 -1
- package/contributions/network-observer.service.ts +14 -0
- package/contributions/play.command.ts +10 -4
- package/contributions/react/component-board.service.ts +1 -1
- package/contributions/react/design-time-mount.service.ts +1 -1
- package/contributions/scene-document.service.ts +3 -3
- package/contributions/state-watch.menu.ts +1 -1
- package/contributions/state-watch.utility.tsx +1 -1
- package/contributions/team-playtest.service.ts +4 -4
- package/contributions/three/component-board.service.ts +1 -1
- package/contributions/three/component-verbs.command.ts +6 -6
- package/contributions/three/three-authoring.service.ts +8 -5
- package/contributions/three-story-capture.service.ts +12 -0
- package/contributions/unity.style.ts +17 -7
- package/contributions/unity.view.ts +6 -0
- package/contributions/unreal.style.ts +10 -2
- package/contributions/unreal.view.ts +5 -0
- package/package.json +20 -10
- package/src/asset-budget/AssetBudgetPanel.tsx +1 -1
- package/src/asset-budget/asset-budget-model.ts +1 -1
- package/src/audio/AudioDebuggerPanel.tsx +1 -1
- package/src/bridge/dispatch.ts +14 -14
- package/src/bridge/live-frames.ts +1 -1
- package/src/bridge/screenshot.ts +3 -3
- package/src/build/BuildProfilesPanel.tsx +3 -3
- package/src/canvas/canvas-board/CanvasBoardDocument.tsx +676 -0
- package/src/canvas/canvas-board/canvas-board-model.ts +407 -0
- package/src/canvas/canvas-board/canvas-component-board.ts +56 -0
- package/src/canvas/canvas-design-mount.ts +524 -0
- package/src/canvas/design-time-canvas-mount.ts +79 -0
- package/src/coverage/live-authoring-surface.ts +5 -5
- package/src/coverage/live-project-verbs.ts +1 -1
- package/src/coverage/native-system-coverage.ts +5 -5
- package/src/coverage/root-coverage.ts +1 -1
- package/src/coverage/session-coverage.ts +3 -3
- package/src/design-system-stories/ApplicationChrome.stories.tsx +5 -5
- package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +7 -7
- package/src/edit-mode/edit-mode-audio.ts +4 -4
- package/src/edit-mode/edit-mode-networking.ts +2 -2
- package/src/game-document/GameCaptureFrameButton.tsx +1 -1
- package/src/game-document/GameDocument.tsx +2 -2
- package/src/game-document/GamePanel.tsx +4 -4
- package/src/game-document/InstanceInspectorPicker.tsx +2 -2
- package/src/game-document/crowd-debug.ts +1 -1
- package/src/game-document/device-preview.ts +10 -11
- package/src/game-document/physics-debug.ts +1 -1
- package/src/generation/GenerationActivity.tsx +3 -3
- package/src/generation/generation-documents.tsx +5 -5
- package/src/generation/generation-jobs.ts +1 -1
- package/src/host/adapter-runtime-bindings.ts +96 -5
- package/src/host/authoring/babylon-authoring-adapter.ts +6 -6
- package/src/host/authoring/gesture-persist.ts +1 -1
- package/src/host/authoring/ingest-data-writer.ts +1 -1
- package/src/host/authoring/ingest-source-persistence.ts +4 -4
- package/src/host/authoring/mounted-authoring.ts +4 -4
- package/src/host/authoring/phaser-live-authoring-adapter.ts +4 -4
- package/src/host/authoring/pixi-authoring-adapter.ts +40 -15
- package/src/host/authoring/pixi-creation-site-write-target.ts +2 -2
- package/src/host/authoring/pixi-live-write-target.ts +4 -4
- package/src/host/authoring/pixi-source-identity.ts +3 -3
- package/src/host/authoring/pixi-source-write-target.ts +1614 -0
- package/src/host/authoring/pixi-still-presentation.ts +1 -1
- package/src/host/authoring/pixi-structure-history.ts +2 -2
- package/src/host/authoring/pixi-transform-channels.ts +16 -14
- package/src/host/authoring/source-persistence-backend.ts +3 -3
- package/src/host/authoring/struct-write-pipe.ts +1 -1
- package/src/host/binding-resolver.ts +8 -9
- package/src/host/browser-transpile.ts +1 -1
- package/src/host/canvas-entry-runtime.ts +58 -47
- package/src/host/canvas-preview-frames.ts +482 -0
- package/src/host/components/CameraAuthoringOverlay.tsx +1 -1
- package/src/host/components/HeaderTelemetry.tsx +4 -4
- package/src/host/components/PixiIsolationSceneContent.tsx +10 -10
- package/src/host/components/ThreeIsolationSceneContent.tsx +3 -3
- package/src/host/components/frame-debugger-model.ts +2 -2
- package/src/host/components/header-telemetry-model.ts +2 -2
- package/src/host/components/scene-document.tsx +14 -14
- package/src/host/components/utility-view-state.ts +1 -1
- package/src/host/components/world-root-stage-binding.tsx +12 -12
- package/src/host/components/world-root-stage.ts +70 -49
- package/src/host/coverage/system-adapter-coverage.ts +3 -4
- package/src/host/design-system-stories/fixtures/editor-runtime.tsx +9 -11
- package/src/host/document-preview-three.ts +1 -1
- package/src/host/entry-adjudication.ts +6 -6
- package/src/host/game-css-scope-transform.ts +4 -0
- package/src/host/game-realm-page.ts +1 -1
- package/src/host/gameplay-export.ts +25 -14
- package/src/host/gameplay-recording.ts +5 -5
- package/src/host/gated-globals.ts +2 -2
- package/src/host/history/json-history-resource.ts +1 -1
- package/src/host/projection/pixi.ts +24 -2
- package/src/host/r3f-entry-runtime.ts +65 -34
- package/src/host/react-mount-runtime.ts +7 -48
- package/src/host/realm-services.ts +1 -1
- package/src/host/roots/canvas-root.tsx +361 -0
- package/src/host/roots/r3f-root.tsx +473 -0
- package/src/host/roots/react-root.ts +9 -43
- package/src/host/served-bundle-runtime-modules.ts +1 -19
- package/src/host/server-log-bridge.ts +2 -2
- package/src/host/stories/mounted-story-viewport-source.ts +1 -1
- package/src/host/stories/pixi-story-model.ts +30 -0
- package/src/host/stories/story-media-captures.ts +46 -0
- package/src/host/stories/story-media-presence.ts +3 -3
- package/src/host/stories/story-pixi-preview.ts +408 -0
- package/src/host/stories/story-three-preview.ts +806 -0
- package/src/host/stories/three-story-captures.ts +35 -0
- package/src/host/story-three-preview-runtime.ts +56 -0
- package/src/host/use-active-performance-source.ts +2 -2
- package/src/host/viewport-pose-memory.ts +1 -1
- package/src/host/viewport-root-presentation.ts +6 -5
- package/src/ingest/active-ingest.ts +1 -1
- package/src/ingest/authoring/ingest-dom-surface-authoring.ts +4 -4
- package/src/ingest/authoring/ingest-root-adapter.ts +8 -8
- package/src/ingest/deferred-ingest-play.ts +6 -6
- package/src/ingest/discovery-public-ingest.ts +2 -2
- package/src/ingest/ingest-boot-viewport.ts +2 -2
- package/src/ingest/ingest-canvas-scene-document.tsx +8 -8
- package/src/ingest/ingest-canvas-scene.ts +3 -3
- package/src/ingest/ingest-evidence-hook.ts +1 -1
- package/src/ingest/ingest-frame-snapshot.ts +1 -1
- package/src/ingest/ingest-play-control.ts +1 -1
- package/src/ingest/ingest-render-debug.ts +10 -10
- package/src/ingest/ingest-siblings.ts +13 -25
- package/src/ingest/module-mode.ts +14 -14
- package/src/ingest/mount-canvas-ingest-root.ts +21 -21
- package/src/ingest/mount-coverage.ts +2 -2
- package/src/ingest/mount-dom-ingest-root.ts +11 -11
- package/src/ingest/mount-ingest-root.ts +8 -8
- package/src/ingest/mount-three-ingest-root.ts +8 -8
- package/src/ingest/resolve-canvas.ts +1 -1
- package/src/ingest/served-html-boot.ts +1 -1
- package/src/ingest/surface-canvas.ts +1 -1
- package/src/ingest/unmount-ingest-root.ts +5 -5
- package/src/navmesh/navmesh-handler.ts +24 -16
- package/src/network/NetworkInspectorPanel.tsx +449 -14
- package/src/network/network-inspector-model.ts +14 -2
- package/src/play/play-log-events.ts +1 -1
- package/src/play/play-mode.ts +76 -133
- package/src/play/play-recording.ts +1 -1
- package/src/play/react-play-live-authoring.ts +3 -3
- package/src/play/run-selection.ts +93 -0
- package/src/play-bar/PlayBar.tsx +20 -39
- package/src/profiler/FrameDebuggerPanel.tsx +1 -1
- package/src/profiler/PerformancePanel.tsx +3 -3
- package/src/react/design-time-react-mount.ts +23 -65
- package/src/react/dom-authoring-adapter.ts +9 -9
- package/src/react/react-inspector-section.tsx +5 -5
- package/src/react/react-world-authoring-adapter.ts +11 -11
- package/src/react/story-documents/story-documents.tsx +9 -9
- package/src/react/ui-board-document.tsx +7 -7
- package/src/react/ui-component-board.ts +2 -2
- package/src/runtime/adapter/audio-meter.ts +21 -0
- package/src/runtime/adapter/first-party-audio-system.ts +230 -0
- package/src/runtime/adapter/ingest/contract-debug-adapter.ts +114 -0
- package/src/runtime/adapter/ingest/contract-system-adapters.ts +256 -0
- package/src/runtime/adapter/ingest/merge-debug-adapters.ts +197 -0
- package/src/runtime/adapter/ingest/observation-debug-adapter.ts +162 -0
- package/src/runtime/adapter/ingest/upstream-pin.ts +51 -0
- package/src/runtime/adapter/native-debug-module.ts +498 -0
- package/src/runtime/audio/bus-mixer.ts +161 -0
- package/src/runtime/audio/pose-guard.ts +80 -0
- package/src/runtime/core/frame-pacing.ts +126 -0
- package/src/runtime/core/game-loop.ts +225 -0
- package/src/runtime/core/game-scoped-slot.ts +28 -0
- package/src/runtime/core/seeded-random.ts +162 -0
- package/src/runtime/core/sim-clock.ts +391 -0
- package/src/runtime/core/system-runner.ts +269 -0
- package/src/runtime/core/types.ts +104 -0
- package/src/runtime/create-runtime.ts +1128 -0
- package/src/runtime/debug-bridge.ts +570 -0
- package/src/runtime/debug-registry.ts +899 -0
- package/src/runtime/dev/chrome-trace.ts +153 -0
- package/src/runtime/dev/instruments.ts +403 -0
- package/src/runtime/dev/logger.ts +119 -0
- package/src/runtime/dev/performance-profiler.ts +367 -0
- package/src/runtime/dev/register-render-vitals.ts +276 -0
- package/src/runtime/dev/render-census.ts +354 -0
- package/src/runtime/dev/render-debug-adapter.ts +218 -0
- package/src/runtime/dev/render-memory.ts +226 -0
- package/src/runtime/dev/render-vitals.ts +338 -0
- package/src/runtime/dev/static-batch-advisor.ts +188 -0
- package/src/runtime/dev/webgl-frame-capture.ts +366 -0
- package/src/runtime/dev/webgl-gpu-timer.ts +53 -0
- package/src/runtime/dev-build.ts +47 -0
- package/src/runtime/game.ts +1636 -0
- package/src/runtime/gameplay-rng-trap.ts +135 -0
- package/src/runtime/host-context.ts +64 -0
- package/src/runtime/input-router.ts +169 -0
- package/src/runtime/mount-manifest.ts +480 -0
- package/src/runtime/pixi/authoring.ts +706 -0
- package/src/runtime/pixi/ingest.ts +116 -0
- package/src/runtime/pixi/physics-registry.ts +49 -0
- package/src/runtime/pixi/render-pass-bracket.ts +117 -0
- package/src/runtime/pixi/scene-capture.ts +179 -0
- package/src/runtime/pixi/system-adapters.ts +69 -0
- package/src/runtime/playtest.ts +22 -0
- package/src/runtime/presentation.ts +141 -0
- package/src/runtime/render-control.ts +642 -0
- package/src/runtime/render-seed.ts +77 -0
- package/src/runtime/run-ticks-settled.ts +73 -0
- package/src/runtime/setup/setup-audio.ts +72 -0
- package/src/services/audio-pose-guard.ts +2 -2
- package/src/services/game-audio.ts +152 -0
- package/src/services/game-network.ts +657 -0
- package/src/services/game-physics.ts +334 -0
- package/src/state-watch/StateWatchPanel.tsx +1 -1
- package/src/three/authoring/camera-runtime-inspector-section.tsx +3 -2
- package/src/three/authoring/constraint-inspector-section.tsx +6 -5
- package/src/three/authoring/model-asset-inspector-section.tsx +7 -6
- package/src/three/authoring/oid-source-persistence.ts +7 -7
- package/src/three/authoring/r3f-design-session.ts +58 -54
- package/src/three/authoring/r3f-source-authoring-adapter.ts +95 -91
- package/src/three/authoring/reflection-probe-inspector-section.tsx +3 -2
- package/src/three/authoring/three-authoring-adapter.ts +29 -29
- package/src/three/component-verbs/extract-menu.ts +7 -6
- package/src/three/component-verbs/fork-menu.ts +7 -6
- package/src/three/component-verbs/internals-menu.ts +2 -2
- package/src/three/story-documents/three-story-documents.tsx +13 -13
- package/src/three/three-board/ThreeBoardDocument.tsx +13 -12
- package/src/three/three-board/board-scene.ts +6 -6
- package/src/three/three-board/three-component-board.ts +2 -2
- package/src/services/game-audio-unlock.ts +0 -48
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
import type { Container } from 'pixi.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AuthoringAdapter-2D — the Pixi analog of the editor AuthoringAdapter /
|
|
5
|
+
* ThreeAuthoringAdapter, implemented DIRECTLY over a live PixiJS display tree
|
|
6
|
+
* (no fabricated document — the anti-shim rule). Used by the editor to
|
|
7
|
+
* inspect + edit Pixi display-tree entities (first-party OR an ingested unmodified game),
|
|
8
|
+
* with stable structural-path ids so edits re-bind after the tree rebuilds.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface EditorNode2D {
|
|
12
|
+
id: string;
|
|
13
|
+
label: string;
|
|
14
|
+
kind: 'sprite' | 'animatedsprite' | 'text' | 'container' | string;
|
|
15
|
+
parentId: string | null;
|
|
16
|
+
childIds: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Transform2DValue {
|
|
20
|
+
position: [number, number];
|
|
21
|
+
rotation: number;
|
|
22
|
+
scale: [number, number];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Property2D {
|
|
26
|
+
path: string;
|
|
27
|
+
label: string;
|
|
28
|
+
type: 'string' | 'number' | 'boolean' | 'vec2' | 'color';
|
|
29
|
+
value: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** An in-memory edit overlay keyed by structural-path id (2D). */
|
|
33
|
+
export interface Override2D {
|
|
34
|
+
label?: string;
|
|
35
|
+
alpha?: number;
|
|
36
|
+
position?: [number, number];
|
|
37
|
+
rotation?: number;
|
|
38
|
+
scale?: [number, number];
|
|
39
|
+
tint?: number;
|
|
40
|
+
visible?: boolean;
|
|
41
|
+
}
|
|
42
|
+
export type Overlay2D = Record<string, Override2D>;
|
|
43
|
+
|
|
44
|
+
function kindOf(o: Container): string {
|
|
45
|
+
const ctor = o.constructor?.name?.toLowerCase() ?? 'container';
|
|
46
|
+
if (ctor.includes('animatedsprite')) return 'animatedsprite';
|
|
47
|
+
if (ctor.includes('nineslice')) return 'nineslicesprite';
|
|
48
|
+
if (ctor.includes('tiling')) return 'tilingsprite';
|
|
49
|
+
if (ctor.includes('sprite')) return 'sprite';
|
|
50
|
+
if (ctor.includes('text')) return 'text';
|
|
51
|
+
if (ctor.includes('graphics')) return 'graphics';
|
|
52
|
+
return 'container';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Hierarchy row text — the Pixi sibling of three's `object.name || object.type`.
|
|
57
|
+
*
|
|
58
|
+
* Authored `.label` first. Then a GAME name: a field, a bag key, or a class
|
|
59
|
+
* that owns this `.view`. Stock Pixi types (`Sprite`, `Graphics`, `_Container`)
|
|
60
|
+
* are not names — they used to win and hide every owner. Then the texture
|
|
61
|
+
* alias (`Sprite.from('satellite')`). Then a remaining game constructor
|
|
62
|
+
* (`TitleScreen`, `PrimaryButton`). Bare `new Container()` still reads as
|
|
63
|
+
* `container` — plus its child count when it has children, because ten
|
|
64
|
+
* unnamed siblings that all read `container` name nothing at all; the
|
|
65
|
+
* editor loads games that never labelled anything and the panel still has
|
|
66
|
+
* to be legible. The count is DISPLAY only (row ids keep using raw
|
|
67
|
+
* `.label`) and rides only this last fallthrough: a row that earned a real
|
|
68
|
+
* name is never decorated.
|
|
69
|
+
*/
|
|
70
|
+
function displayLabel(o: Container): string {
|
|
71
|
+
const authored = authoredLabel(o);
|
|
72
|
+
if (authored) return authored;
|
|
73
|
+
const owner = viewOwnerLabel(o);
|
|
74
|
+
if (owner) return owner;
|
|
75
|
+
const asset = textureLabel(o);
|
|
76
|
+
if (asset) return asset;
|
|
77
|
+
const ctor = usefulCtorName(o);
|
|
78
|
+
if (ctor) return ctor;
|
|
79
|
+
const kind = kindOf(o);
|
|
80
|
+
const childCount = o.children?.length ?? 0;
|
|
81
|
+
return childCount > 0 ? `${kind} (${childCount})` : kind;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Pixi v8 stamps Sprite/Graphics `.label` with the class name. That is not an
|
|
86
|
+
* authored name.
|
|
87
|
+
*
|
|
88
|
+
* The minified-constructor guard is about the SAME leak: a shipped build's
|
|
89
|
+
* `_Container` becomes `e`, and that `e` is a class name wearing a label's
|
|
90
|
+
* clothes. It is only that leak when the label IS this object's own class
|
|
91
|
+
* name — `container.label = 'fx'` (or 'ui', 'hp') is a name the game
|
|
92
|
+
* actually authored, and a game gets to be terse.
|
|
93
|
+
*/
|
|
94
|
+
function authoredLabel(o: Container): string | undefined {
|
|
95
|
+
const authored = o.label?.trim();
|
|
96
|
+
if (!authored) return undefined;
|
|
97
|
+
if (isMinifiedCtor(authored) && authored !== o.constructor?.name) return authored;
|
|
98
|
+
if (isGenericPixiCtor(authored)) return undefined;
|
|
99
|
+
return authored;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isMinifiedCtor(name: string): boolean {
|
|
103
|
+
return name.length <= 2 && name === name.toLowerCase();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Pixi's own display types. The shipped build prefixes them `_`. */
|
|
107
|
+
const STOCK_PIXI_CTOR = new Set([
|
|
108
|
+
'container',
|
|
109
|
+
'displayobject',
|
|
110
|
+
'sprite',
|
|
111
|
+
'animatedsprite',
|
|
112
|
+
'nineslicesprite',
|
|
113
|
+
'tilingsprite',
|
|
114
|
+
'text',
|
|
115
|
+
'bitmaptext',
|
|
116
|
+
'htmltext',
|
|
117
|
+
'graphics',
|
|
118
|
+
'mesh',
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
function isGenericPixiCtor(name: string): boolean {
|
|
122
|
+
if (isMinifiedCtor(name) || name === 'Object' || name === 'Function') return true;
|
|
123
|
+
return STOCK_PIXI_CTOR.has(name.replace(/^_+/, '').toLowerCase());
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function textureLabel(object: Container): string | undefined {
|
|
127
|
+
const texture = (object as Container & { texture?: { label?: string } }).texture;
|
|
128
|
+
const raw = texture?.label?.trim();
|
|
129
|
+
if (!raw || /^(_?texture)?(empty|white)$/i.test(raw)) return undefined;
|
|
130
|
+
const leaf = raw.split('/').pop() ?? raw;
|
|
131
|
+
return leaf.replace(/\.[a-z0-9]+$/i, '') || undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function usefulCtorName(value: object): string | undefined {
|
|
135
|
+
const name = value.constructor?.name;
|
|
136
|
+
if (!name || isGenericPixiCtor(name)) return undefined;
|
|
137
|
+
return name.replace(/^_+/, '') || undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function fieldLabel(key: string): string | undefined {
|
|
141
|
+
const trimmed = key.replace(/^_+/, '');
|
|
142
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isPixiDisplay(value: object): boolean {
|
|
146
|
+
return (
|
|
147
|
+
'addChild' in value &&
|
|
148
|
+
typeof (value as { addChild?: unknown }).addChild === 'function' &&
|
|
149
|
+
'children' in value
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Bubbo-style owner, identity only. TitleScreen keeps `this._cannon` and
|
|
155
|
+
* parents `this._cannon.view`. A grouping node is the field itself
|
|
156
|
+
* (`this._bottomAnimContainer`). A pool or atlas lives in a bag — Array,
|
|
157
|
+
* Map, Set, or an object literal (`_decor[]`, `allSystems`, `_parts[id]`).
|
|
158
|
+
* Scan each ancestor's own fields first — a flood walk of Pixi internals
|
|
159
|
+
* never reached `_cannon` inside the budget.
|
|
160
|
+
*/
|
|
161
|
+
function viewOwnerLabel(view: Container): string | undefined {
|
|
162
|
+
const search: OwnerSearch = { seen: new WeakSet<object>(), budget: OWNER_SEARCH_BUDGET };
|
|
163
|
+
let host: unknown = view.parent;
|
|
164
|
+
for (let hops = 0; hops < 8 && host && typeof host === 'object'; hops++) {
|
|
165
|
+
if (search.budget <= 0) return undefined;
|
|
166
|
+
const found = labelFromHolder(host, view, search) ?? findHeldOwner(host, view, 2, search);
|
|
167
|
+
if (found) return found;
|
|
168
|
+
host = (host as { parent?: unknown }).parent;
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* THE CEILING ON ONE NODE'S OWNER HUNT — what keeps a hierarchy read LINEAR in
|
|
175
|
+
* the size of the tree instead of quadratic in it.
|
|
176
|
+
*
|
|
177
|
+
* The hunt below is a search of the GAME's object graph, and a game's object
|
|
178
|
+
* graph is small: `this._cannon`, `allSystems.get('hud')`, `_decor[3]` are all
|
|
179
|
+
* found within a few dozen examinations. What is not small is anything the
|
|
180
|
+
* search can leak into — Pixi's own live render graph is the measured one, and
|
|
181
|
+
* naming a key blacklist is a fix for the leak you already found, not for the
|
|
182
|
+
* class. So every examination is charged against this budget and the search
|
|
183
|
+
* simply gives up when it runs out: the node then falls through to its texture
|
|
184
|
+
* alias / constructor / kind name, which is the same honest answer it gets
|
|
185
|
+
* today whenever the hunt finds nothing.
|
|
186
|
+
*
|
|
187
|
+
* MEASURED (2026-08-20, `packages/editor/scripts/scale-harness`): entering play
|
|
188
|
+
* on a 20 000-node `@pixi/react` world blocked the main thread for 199.5
|
|
189
|
+
* SECONDS in one synchronous run, 69% of all profiler samples inside this
|
|
190
|
+
* search, because `parentRenderGroup` reaches an array of every node in the
|
|
191
|
+
* tree and the search walked it once per unlabeled node. `vgai play`,
|
|
192
|
+
* `screenshot` and `stop` all timed out against a tab that was heartbeating
|
|
193
|
+
* normally the whole time.
|
|
194
|
+
*/
|
|
195
|
+
const OWNER_SEARCH_BUDGET = 2_000;
|
|
196
|
+
|
|
197
|
+
/** One node's owner hunt: what it has already looked at, and what it has left. */
|
|
198
|
+
interface OwnerSearch {
|
|
199
|
+
readonly seen: WeakSet<object>;
|
|
200
|
+
/** Counts DOWN. Zero ends the hunt for this node, wherever it has reached. */
|
|
201
|
+
budget: number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Charge one examination. `false` means the hunt is over. */
|
|
205
|
+
function spend(search: OwnerSearch): boolean {
|
|
206
|
+
if (search.budget <= 0) return false;
|
|
207
|
+
search.budget -= 1;
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isGameFieldKey(key: string): boolean {
|
|
212
|
+
// Skip display-list indices (`0`, `1`) and Pixi internals (`children`).
|
|
213
|
+
// The membership test is on the UNDERSCORED-STRIPPED key because that is how
|
|
214
|
+
// Pixi v8 spells most of them (`_position`, `_bounds`, `_texture`), and a
|
|
215
|
+
// set listing the bare names was silently matching none of those.
|
|
216
|
+
if (!/^_?[A-Za-z][A-Za-zA-Z0-9]*$/.test(key)) return false;
|
|
217
|
+
return !PIXI_INTERNAL_KEY.has(key.replace(/^_+/, ''));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Field names that belong to Pixi (or to its EventEmitter base), never to the
|
|
222
|
+
* game. Read off a live `pixi.js` v8 `Container`/`Sprite`'s own keys, so this
|
|
223
|
+
* is a transcription rather than a guess — but it is the OPTIMIZATION, not the
|
|
224
|
+
* correctness boundary: {@link OWNER_SEARCH_BUDGET} is what bounds the search
|
|
225
|
+
* on a Pixi version whose internals this list has never heard of. Spending the
|
|
226
|
+
* budget on plausible game fields instead of on the render graph is what keeps
|
|
227
|
+
* the heuristic's hit rate while it is bounded.
|
|
228
|
+
*
|
|
229
|
+
* `view` is deliberately absent: `.view` is the very field the hunt is looking
|
|
230
|
+
* for.
|
|
231
|
+
*/
|
|
232
|
+
const PIXI_INTERNAL_KEY = new Set([
|
|
233
|
+
'parent',
|
|
234
|
+
'children',
|
|
235
|
+
'transform',
|
|
236
|
+
'position',
|
|
237
|
+
'origin',
|
|
238
|
+
'scale',
|
|
239
|
+
'pivot',
|
|
240
|
+
'skew',
|
|
241
|
+
'anchor',
|
|
242
|
+
'worldTransform',
|
|
243
|
+
'localTransform',
|
|
244
|
+
'groupTransform',
|
|
245
|
+
'relativeGroupTransform',
|
|
246
|
+
// The render graph — the measured leak. `parentRenderGroup` is on every
|
|
247
|
+
// container once a world has rendered and reaches every node in the tree.
|
|
248
|
+
'renderGroup',
|
|
249
|
+
'parentRenderGroup',
|
|
250
|
+
'parentRenderGroupIndex',
|
|
251
|
+
'parentRenderLayer',
|
|
252
|
+
'relativeRenderGroupDepth',
|
|
253
|
+
'renderPipeId',
|
|
254
|
+
'instructionSet',
|
|
255
|
+
'childrenToUpdate',
|
|
256
|
+
'childrenRenderablesToUpdate',
|
|
257
|
+
'effects',
|
|
258
|
+
'filters',
|
|
259
|
+
'mask',
|
|
260
|
+
'bounds',
|
|
261
|
+
'boundsArea',
|
|
262
|
+
'visualBounds',
|
|
263
|
+
'gpuData',
|
|
264
|
+
'texture',
|
|
265
|
+
'events',
|
|
266
|
+
'eventsCount',
|
|
267
|
+
'updateFlags',
|
|
268
|
+
]);
|
|
269
|
+
|
|
270
|
+
function labelFromHolder(host: object, view: Container, search: OwnerSearch): string | undefined {
|
|
271
|
+
if (search.seen.has(host)) return undefined;
|
|
272
|
+
search.seen.add(host);
|
|
273
|
+
const asBag = nameInBag(host, view, undefined, search);
|
|
274
|
+
if (asBag) return asBag;
|
|
275
|
+
for (const [key, value] of Object.entries(host as Record<string, unknown>)) {
|
|
276
|
+
if (!spend(search)) return undefined;
|
|
277
|
+
if (!isGameFieldKey(key)) continue;
|
|
278
|
+
const named = nameIfHoldsView(host, key, value, view);
|
|
279
|
+
if (named) return named;
|
|
280
|
+
const nested = nameInBag(value, view, key, search);
|
|
281
|
+
if (nested) return nested;
|
|
282
|
+
}
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function nameInBag(
|
|
287
|
+
value: unknown,
|
|
288
|
+
view: Container,
|
|
289
|
+
fieldKey: string | undefined,
|
|
290
|
+
search: OwnerSearch,
|
|
291
|
+
): string | undefined {
|
|
292
|
+
const entries = bagEntries(value);
|
|
293
|
+
if (!entries) return undefined;
|
|
294
|
+
for (const [itemKey, item] of entries) {
|
|
295
|
+
if (!spend(search)) return undefined;
|
|
296
|
+
const named = nameBagItem(value, item, usableBagKey(itemKey) ?? fieldKey, view, search);
|
|
297
|
+
if (named) return named;
|
|
298
|
+
}
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function nameBagItem(
|
|
303
|
+
bag: unknown,
|
|
304
|
+
item: unknown,
|
|
305
|
+
key: string | undefined,
|
|
306
|
+
view: Container,
|
|
307
|
+
search: OwnerSearch,
|
|
308
|
+
): string | undefined {
|
|
309
|
+
if (key) {
|
|
310
|
+
const named = nameIfHoldsView(
|
|
311
|
+
item && typeof item === 'object' ? item : (bag as object),
|
|
312
|
+
key,
|
|
313
|
+
item,
|
|
314
|
+
view,
|
|
315
|
+
);
|
|
316
|
+
if (named) return named;
|
|
317
|
+
} else if (item && typeof item === 'object' && (item as { view?: unknown }).view === view) {
|
|
318
|
+
const named = usefulCtorName(item);
|
|
319
|
+
if (named) return named;
|
|
320
|
+
}
|
|
321
|
+
if (item && typeof item === 'object' && !isPixiDisplay(item)) {
|
|
322
|
+
return labelFromHolder(item, view, search);
|
|
323
|
+
}
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function usableBagKey(key: string): string | undefined {
|
|
328
|
+
if (!key || /^\d+$/.test(key)) return undefined;
|
|
329
|
+
return isGameFieldKey(key) || key.includes('-') ? (fieldLabel(key) ?? key) : undefined;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function* indexedBagEntries(value: ArrayLike<unknown>): Generator<[string, unknown]> {
|
|
333
|
+
for (let index = 0; index < value.length; index++) yield [String(index), value[index]];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function* mapBagEntries(value: Map<unknown, unknown>): Generator<[string, unknown]> {
|
|
337
|
+
for (const [key, item] of value.entries()) yield [String(key), item];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function* setBagEntries(value: Set<unknown>): Generator<[string, unknown]> {
|
|
341
|
+
let index = 0;
|
|
342
|
+
for (const item of value) {
|
|
343
|
+
yield [String(index), item];
|
|
344
|
+
index += 1;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* A bag's entries, LAZILY.
|
|
350
|
+
*
|
|
351
|
+
* Lazy because the caller is budgeted ({@link OWNER_SEARCH_BUDGET}) and must be
|
|
352
|
+
* able to stop: materializing the pairs first made a 20 000-element array cost
|
|
353
|
+
* 20 000 allocations before the first one was even looked at, which is a cost
|
|
354
|
+
* no budget above it can decline.
|
|
355
|
+
*/
|
|
356
|
+
function bagEntries(value: unknown): Iterable<readonly [string, unknown]> | undefined {
|
|
357
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
358
|
+
// Game arrays are allowed to be exotic subclasses. Do not dispatch through
|
|
359
|
+
// their overridable `map`: one shipped Pixi game returns its elements rather
|
|
360
|
+
// than `[key, value]` pairs there, which made hierarchy labeling throw while
|
|
361
|
+
// destructuring the result. The authoring seam treats game objects as
|
|
362
|
+
// untrusted observations, so normalize with indexed reads of our own.
|
|
363
|
+
if (Array.isArray(value)) return indexedBagEntries(value as ArrayLike<unknown>);
|
|
364
|
+
if (value instanceof Map) return mapBagEntries(value);
|
|
365
|
+
if (value instanceof Set) return setBagEntries(value);
|
|
366
|
+
if (Object.getPrototypeOf(value) !== Object.prototype) return undefined;
|
|
367
|
+
return Object.entries(value as Record<string, unknown>);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function nameIfHoldsView(
|
|
371
|
+
host: object,
|
|
372
|
+
key: string,
|
|
373
|
+
value: unknown,
|
|
374
|
+
view: Container,
|
|
375
|
+
): string | undefined {
|
|
376
|
+
if (value === view) {
|
|
377
|
+
const field = fieldLabel(key);
|
|
378
|
+
if (field && field.toLowerCase() !== 'view') {
|
|
379
|
+
return usefulCtorName(value as object) ?? field;
|
|
380
|
+
}
|
|
381
|
+
return usefulCtorName(value as object) ?? usefulCtorName(host) ?? field;
|
|
382
|
+
}
|
|
383
|
+
if (value && typeof value === 'object' && (value as { view?: unknown }).view === view) {
|
|
384
|
+
return usefulCtorName(value) ?? fieldLabel(key);
|
|
385
|
+
}
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function findHeldOwner(
|
|
390
|
+
held: object,
|
|
391
|
+
view: Container,
|
|
392
|
+
depth: number,
|
|
393
|
+
search: OwnerSearch,
|
|
394
|
+
): string | undefined {
|
|
395
|
+
const direct = labelFromHolder(held, view, search);
|
|
396
|
+
if (direct) return direct;
|
|
397
|
+
if (depth <= 0) return undefined;
|
|
398
|
+
// BY KEY, exactly like `labelFromHolder`'s own scan. Iterating `Object.values`
|
|
399
|
+
// here was the leak that made a hierarchy read quadratic: `parentRenderGroup`
|
|
400
|
+
// sits on EVERY container once a world has rendered, and descending into it
|
|
401
|
+
// reaches `childrenToUpdate[depth].list` — an array of every node in the
|
|
402
|
+
// tree — so the search for one node's owner walked the whole tree, for every
|
|
403
|
+
// unlabeled node. `isGameFieldKey` already knows which keys belong to Pixi
|
|
404
|
+
// rather than to the game; this scan simply has to ask it too.
|
|
405
|
+
for (const [key, value] of Object.entries(held as Record<string, unknown>)) {
|
|
406
|
+
if (!spend(search)) return undefined;
|
|
407
|
+
if (!isGameFieldKey(key)) continue;
|
|
408
|
+
if (!value || typeof value !== 'object' || isPixiDisplay(value)) continue;
|
|
409
|
+
const found = findHeldOwner(value, view, depth - 1, search);
|
|
410
|
+
if (found) return found;
|
|
411
|
+
}
|
|
412
|
+
return undefined;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* WHERE A ROW'S ID COMES FROM — the identity axis of the canvas authoring
|
|
417
|
+
* surface.
|
|
418
|
+
*
|
|
419
|
+
* The tree walk is the same whatever authored the tree; what differs is
|
|
420
|
+
* whether the display objects carry an authored address. A first-party
|
|
421
|
+
* `@pixi/react` world's containers do (the `data-oid` the editor's transform
|
|
422
|
+
* stamps, which the reconciler lands on the instance), and keying on it is
|
|
423
|
+
* what makes selection survive a remount. A tree the editor did NOT author has
|
|
424
|
+
* no such address, so its ids are structural paths — stable for as long as the
|
|
425
|
+
* tree's shape is, which is the best that graph can honestly offer.
|
|
426
|
+
*
|
|
427
|
+
* Both are the SAME adapter over the SAME surface; only this collaborator
|
|
428
|
+
* changes. {@link STRUCTURAL_CANVAS_IDENTITY} is the default.
|
|
429
|
+
*/
|
|
430
|
+
export interface CanvasIdentity {
|
|
431
|
+
/** Called once at the start of every walk — where an implementation that
|
|
432
|
+
* disambiguates repeats (one JSX element rendered ninety times) resets its
|
|
433
|
+
* per-walk counters. */
|
|
434
|
+
beginWalk?(): void;
|
|
435
|
+
/** The id for one display object at one position in the tree. */
|
|
436
|
+
idFor(object: Container, path: readonly number[]): string;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Deterministic structural-path ids: `w2d:<path>:<kind>:<label>`. */
|
|
440
|
+
export const STRUCTURAL_CANVAS_IDENTITY: CanvasIdentity = {
|
|
441
|
+
idFor: (object, path) => `w2d:${path.join('/')}:${kindOf(object)}:${object.label ?? ''}`,
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
/** One remembered row name, with every input it was derived from. */
|
|
445
|
+
interface NameMemo {
|
|
446
|
+
readonly label: string;
|
|
447
|
+
readonly kind: string;
|
|
448
|
+
readonly parent: Container | null;
|
|
449
|
+
readonly type: unknown;
|
|
450
|
+
readonly own: string | null;
|
|
451
|
+
readonly texture: unknown;
|
|
452
|
+
readonly childCount: number;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export class AuthoringAdapter2D {
|
|
456
|
+
private readonly byId = new Map<string, Container>();
|
|
457
|
+
private overlay: Overlay2D = {};
|
|
458
|
+
private dirty = false;
|
|
459
|
+
private readonly identity: CanvasIdentity;
|
|
460
|
+
/**
|
|
461
|
+
* THE ROW-NAME MEMO — what makes a hierarchy read linear instead of a
|
|
462
|
+
* reflective flood walk per node per read.
|
|
463
|
+
*
|
|
464
|
+
* {@link displayLabel} is not a field read. For anything the game did not
|
|
465
|
+
* label itself it calls `viewOwnerLabel`, which climbs eight ancestors and
|
|
466
|
+
* reflects over each one's own fields, its bags, and two levels of whatever
|
|
467
|
+
* those hold — a deliberate search for the game object that owns this
|
|
468
|
+
* `.view`. Measured 2026-08-20 on a 1822-node `@pixi/react` canvas world,
|
|
469
|
+
* with the tab VISIBLE and the world rendering, one full projection walk
|
|
470
|
+
* cost ~800ms, essentially all of it here: once a world has rendered, the
|
|
471
|
+
* ancestors' own fields include Pixi's live render-group graph, so the
|
|
472
|
+
* search walks that too and finds nothing, every time, for every node.
|
|
473
|
+
*
|
|
474
|
+
* And it was asked four times per node per derivation — the projection
|
|
475
|
+
* walk, the conformance probe and the hierarchy facet each re-ask — which
|
|
476
|
+
* is how a status snapshot reached 115,433 `toNode` calls on one tree.
|
|
477
|
+
*
|
|
478
|
+
* ## What invalidates a remembered name
|
|
479
|
+
*
|
|
480
|
+
* Every input the derivation reads OFF THE OBJECT: its authored `.label`,
|
|
481
|
+
* its class, its texture, its parent, and how many children it has. Any of
|
|
482
|
+
* those changing re-derives — so a rename, a reparent, a retexture, and a
|
|
483
|
+
* sibling added or removed are all seen. What a remembered name does NOT
|
|
484
|
+
* re-notice is a game reassigning an ANCESTOR's field to point at this view
|
|
485
|
+
* after the fact, with the tree otherwise untouched; `viewOwnerLabel` is a
|
|
486
|
+
* naming heuristic, not a verdict, and the alternative is the 800ms walk
|
|
487
|
+
* above on every read of the tree.
|
|
488
|
+
*/
|
|
489
|
+
private readonly names = new WeakMap<Container, NameMemo>();
|
|
490
|
+
|
|
491
|
+
constructor(
|
|
492
|
+
private readonly root: Container,
|
|
493
|
+
opts: { overlay?: Overlay2D; identity?: CanvasIdentity } = {},
|
|
494
|
+
) {
|
|
495
|
+
if (opts.overlay) this.overlay = opts.overlay;
|
|
496
|
+
this.identity = opts.identity ?? STRUCTURAL_CANVAS_IDENTITY;
|
|
497
|
+
this.refresh();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Walk the live tree assigning deterministic structural-path ids.
|
|
502
|
+
*
|
|
503
|
+
* Separate from {@link refresh} because the two are wanted at different rates: the ids have to be
|
|
504
|
+
* current whenever the tree is READ (a world that mounts its content asynchronously — a
|
|
505
|
+
* translated port waiting on its resources, anything behind Suspense — commits real nodes after
|
|
506
|
+
* the adapter was handed its root, and an unindexed node is one the hierarchy cannot name), while
|
|
507
|
+
* re-applying the edit overlay is a WRITE and must happen only when something asked for it.
|
|
508
|
+
*/
|
|
509
|
+
reindex(): { count: number; sprites: number } {
|
|
510
|
+
this.byId.clear();
|
|
511
|
+
this.identity.beginWalk?.();
|
|
512
|
+
let count = 0;
|
|
513
|
+
let sprites = 0;
|
|
514
|
+
const visit = (o: Container, path: readonly number[]): void => {
|
|
515
|
+
const id = this.identity.idFor(o, path);
|
|
516
|
+
(o as Container & { __authId?: string }).__authId = id;
|
|
517
|
+
this.byId.set(id, o);
|
|
518
|
+
count++;
|
|
519
|
+
if (kindOf(o).includes('sprite')) sprites++;
|
|
520
|
+
o.children.forEach((c, i) => {
|
|
521
|
+
visit(c as Container, [...path, i]);
|
|
522
|
+
});
|
|
523
|
+
};
|
|
524
|
+
this.root.children.forEach((c, i) => {
|
|
525
|
+
visit(c as Container, [i]);
|
|
526
|
+
});
|
|
527
|
+
return { count, sprites };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** {@link reindex}, then reapply every saved overlay edit to the live tree. */
|
|
531
|
+
refresh(): { count: number; sprites: number } {
|
|
532
|
+
const counts = this.reindex();
|
|
533
|
+
for (const [id, ov] of Object.entries(this.overlay)) this.applyOverride(id, ov);
|
|
534
|
+
return counts;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
roots(): EditorNode2D[] {
|
|
538
|
+
return this.root.children.map((c) => this.toNode(c as Container));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
node(id: string): EditorNode2D | null {
|
|
542
|
+
const o = this.byId.get(id);
|
|
543
|
+
return o ? this.toNode(o) : null;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
displayObject(id: string): Container | null {
|
|
547
|
+
return this.byId.get(id) ?? null;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** This object's row name, derived once and remembered until one of the
|
|
551
|
+
* inputs it is derived from changes. See {@link names}. */
|
|
552
|
+
private nameOf(o: Container): NameMemo {
|
|
553
|
+
const cached = this.names.get(o);
|
|
554
|
+
const parent = o.parent ?? null;
|
|
555
|
+
const type = o.constructor;
|
|
556
|
+
const own = o.label ?? null;
|
|
557
|
+
const texture = (o as Container & { texture?: unknown }).texture ?? null;
|
|
558
|
+
const childCount = o.children?.length ?? 0;
|
|
559
|
+
if (
|
|
560
|
+
cached &&
|
|
561
|
+
cached.parent === parent &&
|
|
562
|
+
cached.type === type &&
|
|
563
|
+
cached.own === own &&
|
|
564
|
+
cached.texture === texture &&
|
|
565
|
+
cached.childCount === childCount
|
|
566
|
+
) {
|
|
567
|
+
return cached;
|
|
568
|
+
}
|
|
569
|
+
const derived: NameMemo = {
|
|
570
|
+
label: displayLabel(o),
|
|
571
|
+
kind: kindOf(o),
|
|
572
|
+
parent,
|
|
573
|
+
type,
|
|
574
|
+
own,
|
|
575
|
+
texture,
|
|
576
|
+
childCount,
|
|
577
|
+
};
|
|
578
|
+
this.names.set(o, derived);
|
|
579
|
+
return derived;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private toNode(o: Container): EditorNode2D {
|
|
583
|
+
const self = o as Container & { __authId?: string };
|
|
584
|
+
const { label, kind } = this.nameOf(o);
|
|
585
|
+
return {
|
|
586
|
+
id: self.__authId ?? '',
|
|
587
|
+
label,
|
|
588
|
+
kind,
|
|
589
|
+
parentId: (o.parent as (Container & { __authId?: string }) | null)?.__authId ?? null,
|
|
590
|
+
childIds: o.children.map((c) => (c as Container & { __authId?: string }).__authId ?? ''),
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// --- Transforms ---
|
|
595
|
+
getTransform(id: string): Transform2DValue | null {
|
|
596
|
+
const o = this.byId.get(id);
|
|
597
|
+
// A tracked node isn't guaranteed to be a real PixiJS display object with
|
|
598
|
+
// a transform (e.g. test doubles / non-positional fixture nodes walked
|
|
599
|
+
// via the composite adapter's overlay path) — treat a missing position
|
|
600
|
+
// the same as a missing node: no transform to report, not a crash.
|
|
601
|
+
if (!o || !o.position || !o.scale) return null;
|
|
602
|
+
return {
|
|
603
|
+
position: [o.position.x, o.position.y],
|
|
604
|
+
rotation: o.rotation,
|
|
605
|
+
scale: [o.scale.x, o.scale.y],
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
setTransform(id: string, t: Partial<Transform2DValue>): void {
|
|
610
|
+
const o = this.byId.get(id);
|
|
611
|
+
if (!o) return;
|
|
612
|
+
if (t.position) o.position.set(t.position[0], t.position[1]);
|
|
613
|
+
if (t.rotation !== undefined) o.rotation = t.rotation;
|
|
614
|
+
if (t.scale) o.scale.set(t.scale[0], t.scale[1]);
|
|
615
|
+
this.overlay[id] ??= {};
|
|
616
|
+
const ov = this.overlay[id];
|
|
617
|
+
if (t.position) ov.position = t.position;
|
|
618
|
+
if (t.rotation !== undefined) ov.rotation = t.rotation;
|
|
619
|
+
if (t.scale) ov.scale = t.scale;
|
|
620
|
+
this.dirty = true;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// --- Inspector (reflected fields) ---
|
|
624
|
+
properties(id: string): Property2D[] {
|
|
625
|
+
const o = this.byId.get(id);
|
|
626
|
+
if (!o) return [];
|
|
627
|
+
const props: Property2D[] = [
|
|
628
|
+
{ path: 'label', label: 'Name', type: 'string', value: o.label ?? '' },
|
|
629
|
+
{ path: 'visible', label: 'Visible', type: 'boolean', value: o.visible },
|
|
630
|
+
{ path: 'alpha', label: 'Alpha', type: 'number', value: o.alpha },
|
|
631
|
+
];
|
|
632
|
+
if ('tint' in o)
|
|
633
|
+
props.push({
|
|
634
|
+
path: 'tint',
|
|
635
|
+
label: 'Tint',
|
|
636
|
+
type: 'color',
|
|
637
|
+
value: (o as { tint: number }).tint,
|
|
638
|
+
});
|
|
639
|
+
return props;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
set(id: string, path: string, value: unknown): void {
|
|
643
|
+
const o = this.byId.get(id) as (Container & Record<string, unknown>) | undefined;
|
|
644
|
+
if (!o) return;
|
|
645
|
+
o[path] = value;
|
|
646
|
+
this.overlay[id] ??= {};
|
|
647
|
+
if (path === 'label') this.overlay[id].label = value as string;
|
|
648
|
+
if (path === 'alpha') this.overlay[id].alpha = value as number;
|
|
649
|
+
if (path === 'tint') this.overlay[id].tint = value as number;
|
|
650
|
+
if (path === 'visible') this.overlay[id].visible = value as boolean;
|
|
651
|
+
this.dirty = true;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
private applyOverride(id: string, ov: Override2D): void {
|
|
655
|
+
const o = this.byId.get(id);
|
|
656
|
+
if (!o) return;
|
|
657
|
+
if (ov.label !== undefined) o.label = ov.label;
|
|
658
|
+
if (ov.alpha !== undefined) o.alpha = ov.alpha;
|
|
659
|
+
if (ov.position) o.position.set(ov.position[0], ov.position[1]);
|
|
660
|
+
if (ov.rotation !== undefined) o.rotation = ov.rotation;
|
|
661
|
+
if (ov.scale) o.scale.set(ov.scale[0], ov.scale[1]);
|
|
662
|
+
if (ov.tint !== undefined && 'tint' in o) (o as { tint: number }).tint = ov.tint;
|
|
663
|
+
if (ov.visible !== undefined) o.visible = ov.visible;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Re-apply a saved/external overlay onto the (already-walked) live tree, merging
|
|
668
|
+
* it into the in-memory overlay so a later `serializeOverlay()`/save preserves it
|
|
669
|
+
* and a later `refresh()` re-applies it again. Ids absent from the current tree
|
|
670
|
+
* are skipped (orphaned, not an error) — the 2D analog of
|
|
671
|
+
* `ThreeAuthoringAdapter.applyOverlay` (T3.2 slice 3). Used both for the initial
|
|
672
|
+
* reload (constructor `opts.overlay`) and for `PersistenceProvider.applyExternal`
|
|
673
|
+
* (re-apply a changed overlay file without a full remount).
|
|
674
|
+
*/
|
|
675
|
+
applyOverlay(overlay: Overlay2D): { applied: number; orphaned: number } {
|
|
676
|
+
let applied = 0;
|
|
677
|
+
let orphaned = 0;
|
|
678
|
+
for (const [id, ov] of Object.entries(overlay)) {
|
|
679
|
+
if (!this.byId.has(id)) {
|
|
680
|
+
orphaned++;
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
this.applyOverride(id, ov);
|
|
684
|
+
this.overlay[id] = { ...this.overlay[id], ...ov };
|
|
685
|
+
applied++;
|
|
686
|
+
}
|
|
687
|
+
return { applied, orphaned };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
isDirty(): boolean {
|
|
691
|
+
return this.dirty;
|
|
692
|
+
}
|
|
693
|
+
serializeOverlay(): Overlay2D {
|
|
694
|
+
return this.overlay;
|
|
695
|
+
}
|
|
696
|
+
/** Replace, rather than merge, overlay state after an exact history restore. */
|
|
697
|
+
replaceOverlay(overlay: Overlay2D): void {
|
|
698
|
+
this.overlay = structuredClone(overlay);
|
|
699
|
+
for (const [id, ov] of Object.entries(this.overlay)) this.applyOverride(id, ov);
|
|
700
|
+
this.dirty = true;
|
|
701
|
+
}
|
|
702
|
+
/** Clear the dirty flag after a successful save (mirrors ThreeAuthoringAdapter). */
|
|
703
|
+
markClean(): void {
|
|
704
|
+
this.dirty = false;
|
|
705
|
+
}
|
|
706
|
+
}
|