@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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BUILD PLAYER (`workspace.document`) — the project's last build, run as a
|
|
3
|
+
* player runs it: its own `dist/`, served by the editor server, in a frame
|
|
4
|
+
* with nothing of the editor inside it. Build Profiles' Build And Run opens
|
|
5
|
+
* it, as Unity's Build And Run launches the player and Godot's Web export
|
|
6
|
+
* runs in a browser; each run reloads the frame.
|
|
7
|
+
*
|
|
8
|
+
* What the build prints reaches the editor's console, prefixed
|
|
9
|
+
* `[Build Player]`, as a Godot export run with remote debug reports into the
|
|
10
|
+
* editor's debugger: its console calls, and its uncaught errors. The capture
|
|
11
|
+
* door photographs the build itself (`framed-document-capture.ts`).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ToolContributionProps } from '@volter/editor-sdk/contributions';
|
|
15
|
+
import { registerFramedCapture } from '@volter/editor-sdk/kit/framed-document-capture';
|
|
16
|
+
import { useSyncExternalStore } from 'react';
|
|
17
|
+
import { BUILD_PLAYER_URL, buildRunCount, subscribeBuildRuns } from '../src/build/build-session';
|
|
18
|
+
|
|
19
|
+
export const point = 'workspace.document';
|
|
20
|
+
export const title = 'Build Player';
|
|
21
|
+
|
|
22
|
+
const FORWARDED = ['log', 'info', 'warn', 'error'] as const;
|
|
23
|
+
|
|
24
|
+
const HOOKED = Symbol('build-player-output');
|
|
25
|
+
|
|
26
|
+
/** Hook the player's document once it exists: the build's module scripts run
|
|
27
|
+
* before `load`, after its HTML is parsed and while their bundle is fetched,
|
|
28
|
+
* so the frame is watched from insertion until its document is the build's. */
|
|
29
|
+
function watchFrame(frame: HTMLIFrameElement | null): void {
|
|
30
|
+
if (!frame) return;
|
|
31
|
+
const tick = () => {
|
|
32
|
+
if (!frame.isConnected) return;
|
|
33
|
+
const player = frame.contentWindow as (Window & typeof globalThis & { [HOOKED]?: true }) | null;
|
|
34
|
+
if (player?.location.pathname === new URL(BUILD_PLAYER_URL, location.href).pathname) {
|
|
35
|
+
if (!player[HOOKED]) {
|
|
36
|
+
forwardOutput(player);
|
|
37
|
+
registerFramedCapture(frame, {
|
|
38
|
+
container: () => player.document.getElementById('game-canvas') ?? player.document.body,
|
|
39
|
+
canvasFrame: (canvas) => copyAfterRender(player, canvas),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
if (player.document.readyState === 'complete') return;
|
|
43
|
+
}
|
|
44
|
+
setTimeout(tick, 0);
|
|
45
|
+
};
|
|
46
|
+
tick();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A copy of `canvas` taken in the player's next frame, after the game's own
|
|
50
|
+
* frame callbacks (registered a frame earlier) have drawn it. */
|
|
51
|
+
function copyAfterRender(player: Window & typeof globalThis, canvas: HTMLCanvasElement): Promise<CanvasImageSource | null> {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
player.requestAnimationFrame(() => {
|
|
54
|
+
const copy = player.document.createElement('canvas');
|
|
55
|
+
copy.width = canvas.width;
|
|
56
|
+
copy.height = canvas.height;
|
|
57
|
+
const context = copy.getContext('2d');
|
|
58
|
+
if (!context) return resolve(null);
|
|
59
|
+
context.drawImage(canvas, 0, 0);
|
|
60
|
+
resolve(copy);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function forwardOutput(player: Window & typeof globalThis & { [HOOKED]?: true }): void {
|
|
66
|
+
player[HOOKED] = true;
|
|
67
|
+
for (const level of FORWARDED) {
|
|
68
|
+
const own = player.console[level].bind(player.console);
|
|
69
|
+
player.console[level] = (...args: unknown[]) => {
|
|
70
|
+
own(...args);
|
|
71
|
+
console[level]('[Build Player]', ...args);
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
player.addEventListener('error', (event) => {
|
|
75
|
+
console.error('[Build Player] Uncaught', event.error ?? event.message);
|
|
76
|
+
});
|
|
77
|
+
player.addEventListener('unhandledrejection', (event) => {
|
|
78
|
+
console.error('[Build Player] Unhandled rejection', event.reason);
|
|
79
|
+
});
|
|
80
|
+
console.info(`[Build Player] Running ${player.document.title || 'the build'}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default function BuildPlayerDocument(_props: ToolContributionProps) {
|
|
84
|
+
const run = useSyncExternalStore(subscribeBuildRuns, buildRunCount);
|
|
85
|
+
return (
|
|
86
|
+
<iframe
|
|
87
|
+
key={run}
|
|
88
|
+
title="Build Player"
|
|
89
|
+
data-build-player={run}
|
|
90
|
+
src={`${BUILD_PLAYER_URL}?run=${run}`}
|
|
91
|
+
ref={watchFrame}
|
|
92
|
+
style={{ width: '100%', height: '100%', border: 0, display: 'block', background: '#000' }}
|
|
93
|
+
/>
|
|
94
|
+
);
|
|
95
|
+
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@volter/editor-game",
|
|
3
3
|
"author": "Volter AI, Inc.",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
|
-
"version": "0.5.
|
|
5
|
+
"version": "0.5.68",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
8
8
|
},
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"./contributions/autoplay.service.ts",
|
|
43
43
|
"./contributions/bridge.command.ts",
|
|
44
44
|
"./contributions/build-profiles.document.tsx",
|
|
45
|
+
"./contributions/build-player.document.tsx",
|
|
45
46
|
"./contributions/build-progress.status.tsx",
|
|
46
47
|
"./contributions/build.action.ts",
|
|
47
48
|
"./contributions/build.command.ts",
|
|
@@ -100,11 +101,11 @@
|
|
|
100
101
|
"serving": "./dist-node/serving.mjs"
|
|
101
102
|
},
|
|
102
103
|
"dependencies": {
|
|
103
|
-
"@volter/editor-sdk": "0.5.
|
|
104
|
-
"@volter/editor-project": "0.5.
|
|
105
|
-
"@volter/editor-threejs": "0.5.
|
|
106
|
-
"@volter/threejs-runtime": "0.5.
|
|
107
|
-
"@volter/game-runtime": "0.5.
|
|
104
|
+
"@volter/editor-sdk": "0.5.68",
|
|
105
|
+
"@volter/editor-project": "0.5.68",
|
|
106
|
+
"@volter/editor-threejs": "0.5.68",
|
|
107
|
+
"@volter/threejs-runtime": "0.5.68",
|
|
108
|
+
"@volter/game-runtime": "0.5.68",
|
|
108
109
|
"@colyseus/msgpackr": "^1.11.3",
|
|
109
110
|
"@colyseus/schema": "^4.0.14",
|
|
110
111
|
"@colyseus/sdk": "^0.17.34",
|
|
@@ -137,7 +138,7 @@
|
|
|
137
138
|
"xstate": "^5.32.4",
|
|
138
139
|
"zod": "^4.3.6",
|
|
139
140
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
|
140
|
-
"@volter/editor-react": "0.5.
|
|
141
|
+
"@volter/editor-react": "0.5.68"
|
|
141
142
|
},
|
|
142
143
|
"peerDependencies": {
|
|
143
144
|
"@sparkjsdev/spark": "^2.1.0",
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
33
33
|
import {
|
|
34
34
|
type BuildTarget,
|
|
35
|
+
buildAndRun,
|
|
35
36
|
buildSessionVersion,
|
|
36
37
|
cancelBuild,
|
|
37
38
|
getBuildSession,
|
|
@@ -331,6 +332,16 @@ export function BuildProfilesPanel() {
|
|
|
331
332
|
>
|
|
332
333
|
{phase === 'done' ? 'Build Again' : 'Build'}
|
|
333
334
|
</Button>
|
|
335
|
+
<Button
|
|
336
|
+
data-testid="build-profiles-build-and-run"
|
|
337
|
+
onClick={() => {
|
|
338
|
+
if (!selected) return;
|
|
339
|
+
if (phase === 'done') resetBuildSession();
|
|
340
|
+
void buildAndRun(selected);
|
|
341
|
+
}}
|
|
342
|
+
>
|
|
343
|
+
Build And Run
|
|
344
|
+
</Button>
|
|
334
345
|
</>
|
|
335
346
|
)}
|
|
336
347
|
</div>
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from '../host/api/configurations';
|
|
18
18
|
import type { EditorHostOutputDiagnostic } from '@volter/editor-sdk/host';
|
|
19
19
|
import { editorHost } from '@volter/editor-sdk/host';
|
|
20
|
+
import { BASE } from '@volter/editor-sdk/kit/api-base';
|
|
20
21
|
import type { BuildReport } from '@volter/editor-sdk/session/build-report';
|
|
21
22
|
import { useEffect, useState } from 'react';
|
|
22
23
|
import { formatBytes } from './format-bytes';
|
|
@@ -34,6 +35,40 @@ export function openBuildProfilesDocument(): boolean {
|
|
|
34
35
|
return editorHost().workspace.openContributedDocument(BUILD_PROFILES_DOCUMENT_ID);
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/** The Build Player document's contribution id (`build-player.document.tsx`). */
|
|
39
|
+
export const BUILD_PLAYER_DOCUMENT_ID = 'build-player.document';
|
|
40
|
+
|
|
41
|
+
/** Where the editor server serves the project's built `dist/` (`routes/configurations.ts`). */
|
|
42
|
+
export const BUILD_PLAYER_URL = `${BASE}/build/web/index.html`;
|
|
43
|
+
|
|
44
|
+
let _runs = 0;
|
|
45
|
+
const _runListeners = new Set<() => void>();
|
|
46
|
+
|
|
47
|
+
/** How many times the build has been run: the player reloads on each. */
|
|
48
|
+
export function buildRunCount(): number {
|
|
49
|
+
return _runs;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function subscribeBuildRuns(fn: () => void): () => void {
|
|
53
|
+
_runListeners.add(fn);
|
|
54
|
+
return () => {
|
|
55
|
+
_runListeners.delete(fn);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Run the last build in the Build Player document, fresh. */
|
|
60
|
+
export function runBuild(): boolean {
|
|
61
|
+
_runs++;
|
|
62
|
+
for (const fn of _runListeners) fn();
|
|
63
|
+
return editorHost().workspace.openContributedDocument(BUILD_PLAYER_DOCUMENT_ID);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Unity's Build And Run: build, and run the result when the build succeeds. */
|
|
67
|
+
export async function buildAndRun(target: BuildTarget): Promise<void> {
|
|
68
|
+
await startBuild(target);
|
|
69
|
+
if (_session.phase === 'done' && _session.result?.ok) runBuild();
|
|
70
|
+
}
|
|
71
|
+
|
|
37
72
|
/** A build-role configuration's id (ARCHITECTURE-CORE §The project model). */
|
|
38
73
|
export type BuildTarget = string;
|
|
39
74
|
export type BuildPhase = 'idle' | 'building' | 'done';
|
|
@@ -74,7 +74,8 @@ import {
|
|
|
74
74
|
} from '@volter/editor-sdk/kit/workspace-document-registry';
|
|
75
75
|
import { storyBoardPresentation } from '@volter/editor-sdk/kit/stories/story-presentation';
|
|
76
76
|
import { getProjectStoryModules, subscribeProjectStoryModules } from '@volter/editor-sdk/kit/stories/story-registry';
|
|
77
|
-
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
77
|
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
78
|
+
import type { AuthoringAdapter, EditorNode } from '@volter/editor-project/adapter';
|
|
78
79
|
import {
|
|
79
80
|
buildCanvasBoard,
|
|
80
81
|
type CanvasBoard,
|
|
@@ -113,6 +114,8 @@ interface CanvasBoardPresentation {
|
|
|
113
114
|
zoomAt(clientX: number, clientY: number): boolean;
|
|
114
115
|
/** The exhibit the board is currently showing as active, for the Inspector. */
|
|
115
116
|
activeStoryId(): string | null;
|
|
117
|
+
/** Select one frame by its id, as a click on it does (the layers list's row). */
|
|
118
|
+
selectStory(storyId: string): boolean;
|
|
116
119
|
dispose(): void;
|
|
117
120
|
}
|
|
118
121
|
|
|
@@ -131,6 +134,53 @@ interface CanvasBoardPresentation {
|
|
|
131
134
|
* An ungrouped exhibit (no district) carries no title and falls back to the
|
|
132
135
|
* substrate's own module-path derivation, exactly like an untitled dom story.
|
|
133
136
|
*/
|
|
137
|
+
/**
|
|
138
|
+
* THE BOARD'S LAYERS — Figma's layers panel lists a page's frames; this adapter hands the
|
|
139
|
+
* Hierarchy the board's frames the same way, each with its district beside its name. It owns no
|
|
140
|
+
* transform, inspector or structure: a frame is chosen here, and choosing one is the same act as
|
|
141
|
+
* clicking it on the board.
|
|
142
|
+
*/
|
|
143
|
+
function canvasBoardAdapter(
|
|
144
|
+
board: CanvasBoard,
|
|
145
|
+
selected: () => string | null,
|
|
146
|
+
select: (storyId: string) => void,
|
|
147
|
+
): AuthoringAdapter & { notify(): void } {
|
|
148
|
+
const listeners = new Set<() => void>();
|
|
149
|
+
const nodes: EditorNode[] = boardRows(board).map((row) => ({
|
|
150
|
+
id: row.id,
|
|
151
|
+
label: row.label,
|
|
152
|
+
...(row.title ? { secondaryLabel: row.title } : {}),
|
|
153
|
+
kind: 'frame',
|
|
154
|
+
typeLabel: 'Frame',
|
|
155
|
+
parentId: null,
|
|
156
|
+
childIds: [],
|
|
157
|
+
}));
|
|
158
|
+
return {
|
|
159
|
+
capabilities: { transform: false, inspectorFields: false, persist: false },
|
|
160
|
+
hierarchy: {
|
|
161
|
+
roots: () => nodes,
|
|
162
|
+
node: (id) => nodes.find((node) => node.id === id) ?? null,
|
|
163
|
+
},
|
|
164
|
+
selection: {
|
|
165
|
+
get: () => {
|
|
166
|
+
const id = selected();
|
|
167
|
+
return id ? [id] : [];
|
|
168
|
+
},
|
|
169
|
+
set: (ids) => {
|
|
170
|
+
const id = ids.at(-1);
|
|
171
|
+
if (id) select(id);
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
subscribe(listener) {
|
|
175
|
+
listeners.add(listener);
|
|
176
|
+
return () => listeners.delete(listener);
|
|
177
|
+
},
|
|
178
|
+
notify() {
|
|
179
|
+
for (const listener of listeners) listener();
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
134
184
|
function boardRows(board: CanvasBoard): ReactStoryBoardStory[] {
|
|
135
185
|
return [
|
|
136
186
|
...board.exhibits.map((exhibit) => ({
|
|
@@ -305,6 +355,13 @@ function presentCanvasBoard(
|
|
|
305
355
|
activeStoryId(): string | null {
|
|
306
356
|
return selectedStoryId;
|
|
307
357
|
},
|
|
358
|
+
selectStory(storyId: string): boolean {
|
|
359
|
+
if (!rows.some((row) => row.id === storyId)) return false;
|
|
360
|
+
selectedStoryId = storyId;
|
|
361
|
+
storyBoard?.activate(storyId, false);
|
|
362
|
+
onSelectionChanged?.();
|
|
363
|
+
return true;
|
|
364
|
+
},
|
|
308
365
|
zoomAt(clientX: number, clientY: number): boolean {
|
|
309
366
|
return storyBoard?.zoomAtClientPoint(clientX, clientY) ?? false;
|
|
310
367
|
},
|
|
@@ -446,13 +503,29 @@ function CanvasBoardContent({ active }: WorkspaceDocumentContentProps) {
|
|
|
446
503
|
// never reach the no-selection seam at all
|
|
447
504
|
// (`workspace-document-registry.ts`'s `WorkspaceDocumentSelection`, and
|
|
448
505
|
// `components/AssetEditorShell.tsx`, which registers the same pair).
|
|
506
|
+
// The layers list: the board's frames through their own adapter. The node stays null so the
|
|
507
|
+
// Inspector keeps describing the chosen frame through the subject published below.
|
|
508
|
+
const boardAdapter = useMemo(
|
|
509
|
+
() =>
|
|
510
|
+
board
|
|
511
|
+
? canvasBoardAdapter(
|
|
512
|
+
board,
|
|
513
|
+
() => presentationRef.current?.activeStoryId() ?? null,
|
|
514
|
+
(storyId) => presentationRef.current?.selectStory(storyId),
|
|
515
|
+
)
|
|
516
|
+
: null,
|
|
517
|
+
[board],
|
|
518
|
+
);
|
|
519
|
+
useEffect(() => {
|
|
520
|
+
boardAdapter?.notify();
|
|
521
|
+
}, [boardAdapter, selectedStoryId]);
|
|
449
522
|
useEffect(() => {
|
|
450
523
|
if (!active) return;
|
|
451
524
|
return registerWorkspaceDocumentSelection(CANVAS_COMPONENTS_DOCUMENT_ID, () => ({
|
|
452
|
-
adapter:
|
|
525
|
+
adapter: boardAdapter,
|
|
453
526
|
nodeId: null,
|
|
454
527
|
}));
|
|
455
|
-
}, [active]);
|
|
528
|
+
}, [active, boardAdapter]);
|
|
456
529
|
useEffect(() => {
|
|
457
530
|
if (!active || !selectedExhibit) return;
|
|
458
531
|
return publishDocumentInspectionSubject(CANVAS_COMPONENTS_DOCUMENT_ID, () => ({
|
|
@@ -82,8 +82,10 @@ import {
|
|
|
82
82
|
readContainerAuthoringLabel,
|
|
83
83
|
} from './pixi-source-identity';
|
|
84
84
|
import { CanvasStructureHistory } from './pixi-structure-history';
|
|
85
|
+
import { PIXI_CREATABLE_KINDS } from './pixi-creatable-kinds';
|
|
85
86
|
import { fromNeutralTransform, toNeutralTransform } from './pixi-transform-channels';
|
|
86
87
|
import { resolvesLiveOnly, runWritePipe } from '@volter/editor-sdk/kit/write-pipe';
|
|
88
|
+
import { editorHost } from '@volter/editor-sdk/host';
|
|
87
89
|
|
|
88
90
|
/**
|
|
89
91
|
* What a write target is handed at construction — the live view it edits
|
|
@@ -302,12 +304,19 @@ export interface PixiAuthoringOptions {
|
|
|
302
304
|
* `Texture.WHITE` and a size; a graphics gets a drawn rect): a node the author
|
|
303
305
|
* cannot see is indistinguishable from a create that silently failed.
|
|
304
306
|
*/
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
307
|
+
/** The project-local section holding each canvas world's locked and grouped label paths. */
|
|
308
|
+
const EDIT_LOCKS_SECTION = 'canvasEditLocks';
|
|
309
|
+
type EditLocks = Record<string, { readonly locked: readonly string[]; readonly grouped: readonly string[] }>;
|
|
310
|
+
|
|
311
|
+
function readEditLocks(): EditLocks {
|
|
312
|
+
return editorHost().projectLocalState.read<EditLocks>(EDIT_LOCKS_SECTION) ?? {};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function writeEditLocks(world: string, locks: EditLocks[string]): void {
|
|
316
|
+
editorHost().projectLocalState.write(EDIT_LOCKS_SECTION, { ...readEditLocks(), [world]: locks });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const CANVAS_CREATABLE_KINDS = PIXI_CREATABLE_KINDS;
|
|
311
320
|
|
|
312
321
|
/** What a canvas world can take from the asset browser: an image becomes a
|
|
313
322
|
* Sprite. A model or an audio file has no display object to become here. */
|
|
@@ -417,6 +426,19 @@ function createDisplayObject(pixi: CanvasPixiNamespace, kind: string): Container
|
|
|
417
426
|
return new pixi.Text({ text: 'Text', style: { fill: 0xffffff, fontSize: 24 } });
|
|
418
427
|
case 'graphics':
|
|
419
428
|
return new pixi.Graphics().rect(0, 0, 100, 100).fill(0xffffff);
|
|
429
|
+
case 'animatedSprite': {
|
|
430
|
+
const animated = new pixi.AnimatedSprite([pixi.Texture.WHITE]);
|
|
431
|
+
animated.setSize(64, 64);
|
|
432
|
+
return animated;
|
|
433
|
+
}
|
|
434
|
+
case 'tilingSprite':
|
|
435
|
+
return new pixi.TilingSprite({ texture: pixi.Texture.WHITE, width: 100, height: 100 });
|
|
436
|
+
case 'nineSliceSprite':
|
|
437
|
+
return new pixi.NineSliceSprite({ texture: pixi.Texture.WHITE, width: 100, height: 100 });
|
|
438
|
+
case 'bitmapText':
|
|
439
|
+
return new pixi.BitmapText({ text: 'BitmapText', style: { fill: 0xffffff, fontSize: 24 } });
|
|
440
|
+
case 'htmlText':
|
|
441
|
+
return new pixi.HTMLText({ text: 'HTMLText', style: { fill: 0xffffff, fontSize: 24 } });
|
|
420
442
|
default:
|
|
421
443
|
return null;
|
|
422
444
|
}
|
|
@@ -490,6 +512,35 @@ interface CanvasBoxEditSession {
|
|
|
490
512
|
readonly id: string;
|
|
491
513
|
readonly transform: Transform;
|
|
492
514
|
readonly rect: { x: number; y: number; width: number; height: number };
|
|
515
|
+
/** The parent's world matrix's linear part (Pixi's a, b, c, d), when the node has a parent. */
|
|
516
|
+
readonly parentLinear?: { a: number; b: number; c: number; d: number };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* The local turn, in degrees, that turns the node's own x axis by `degrees` on screen. Godot measures
|
|
521
|
+
* a rotate drag in the parent's space; a turn added straight to the local rotation goes backwards
|
|
522
|
+
* under a mirrored parent and by the wrong amount under an unevenly scaled one.
|
|
523
|
+
*/
|
|
524
|
+
function localTurn(session: CanvasBoxEditSession, degrees: number): number {
|
|
525
|
+
const m = session.parentLinear;
|
|
526
|
+
if (!m) return degrees;
|
|
527
|
+
const det = m.a * m.d - m.b * m.c;
|
|
528
|
+
if (Math.abs(det) < 1e-9) return degrees;
|
|
529
|
+
const [, , z, w] = session.transform.rotation;
|
|
530
|
+
const r = 2 * Math.atan2(z, w);
|
|
531
|
+
// The node's x axis on screen, turned by the drag, taken back into the parent's space.
|
|
532
|
+
const vx = m.a * Math.cos(r) + m.c * Math.sin(r);
|
|
533
|
+
const vy = m.b * Math.cos(r) + m.d * Math.sin(r);
|
|
534
|
+
const t = (degrees * Math.PI) / 180;
|
|
535
|
+
const wx = vx * Math.cos(t) - vy * Math.sin(t);
|
|
536
|
+
const wy = vx * Math.sin(t) + vy * Math.cos(t);
|
|
537
|
+
const ux = (m.d * wx - m.c * wy) / det;
|
|
538
|
+
const uy = (-m.b * wx + m.a * wy) / det;
|
|
539
|
+
// A long drag passes half a turn: keep the answer beside the turn's own direction in that space.
|
|
540
|
+
const base = det < 0 ? -t : t;
|
|
541
|
+
const raw = Math.atan2(uy, ux) - r - base;
|
|
542
|
+
const wrapped = Math.atan2(Math.sin(raw), Math.cos(raw));
|
|
543
|
+
return ((base + wrapped) * 180) / Math.PI;
|
|
493
544
|
}
|
|
494
545
|
|
|
495
546
|
function boxPatchChannels(patch: Record<string, number>): TransformChannel[] {
|
|
@@ -538,7 +589,7 @@ function transformForBoxPatch(
|
|
|
538
589
|
scale: [...session.transform.scale],
|
|
539
590
|
};
|
|
540
591
|
const rotate = patch['rotate'];
|
|
541
|
-
if (rotate !== undefined) next.rotation = rotateTransformZ(session.transform, rotate);
|
|
592
|
+
if (rotate !== undefined) next.rotation = rotateTransformZ(session.transform, localTurn(session, rotate));
|
|
542
593
|
const width = patch['width'];
|
|
543
594
|
if (width !== undefined && session.rect.width > 0) {
|
|
544
595
|
next.scale[0] = session.transform.scale[0] * Math.max(0.0001, width / session.rect.width);
|
|
@@ -669,10 +720,17 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
|
|
|
669
720
|
| undefined;
|
|
670
721
|
private listeners = new Set<() => void>();
|
|
671
722
|
private boxEditSessions = new Map<string, CanvasBoxEditSession>();
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
*
|
|
675
|
-
|
|
723
|
+
/**
|
|
724
|
+
* Lock (a click passes through the node) and Godot's Group (a click inside a grouped node selects
|
|
725
|
+
* the group), held by each node's label path in this world so they survive the remount a source
|
|
726
|
+
* write causes and the next session. Kept per checkout, as Unity keeps scene pickability per
|
|
727
|
+
* user; Godot writes them into the scene file instead.
|
|
728
|
+
*/
|
|
729
|
+
private lockedPaths = new Set<string>();
|
|
730
|
+
private groupedPaths = new Set<string>();
|
|
731
|
+
private readonly editLockWorld: string;
|
|
732
|
+
/** Each node's label path, cleared whenever the tree is re-projected. */
|
|
733
|
+
private pathKeys = new Map<string, string>();
|
|
676
734
|
|
|
677
735
|
constructor(
|
|
678
736
|
private readonly root: Container,
|
|
@@ -728,8 +786,10 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
|
|
|
728
786
|
const hit = this.projector.pick(clientX, clientY, (id) => this.isPickLocked(id));
|
|
729
787
|
return hit ? this.groupOf(hit) : hit;
|
|
730
788
|
},
|
|
731
|
-
candidates: (clientX, clientY) =>
|
|
732
|
-
this.projector.candidates(clientX, clientY, (id) =>
|
|
789
|
+
candidates: (clientX, clientY, options) =>
|
|
790
|
+
this.projector.candidates(clientX, clientY, (id) =>
|
|
791
|
+
options?.includeLocked ? false : this.isPickLocked(id),
|
|
792
|
+
),
|
|
733
793
|
};
|
|
734
794
|
this.rects = {
|
|
735
795
|
rect: (id) => this.projector.rect(id),
|
|
@@ -750,7 +810,18 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
|
|
|
750
810
|
this.target.provenance.source === 'source-code' && this.target.persistence !== undefined,
|
|
751
811
|
};
|
|
752
812
|
this.target.bind({ a2d: this.a2d, store, journal: opts.journal, notify: () => this.notify() });
|
|
753
|
-
this.
|
|
813
|
+
this.editLockWorld = opts.journal.id;
|
|
814
|
+
const saved = readEditLocks()[this.editLockWorld];
|
|
815
|
+
this.lockedPaths = new Set(saved?.locked ?? []);
|
|
816
|
+
this.groupedPaths = new Set(saved?.grouped ?? []);
|
|
817
|
+
const structure = this.target.structure ?? this.liveStructure;
|
|
818
|
+
// A placed creation reaches the structure in the new node's PARENT's own space: the target
|
|
819
|
+
// writes `x`/`y` as authored, and only this adapter knows the frame `rects` answer in.
|
|
820
|
+
this.structure = {
|
|
821
|
+
...structure,
|
|
822
|
+
create: (kind, parentId, at) =>
|
|
823
|
+
structure.create(kind, parentId, at ? this.pointInParent(parentId ?? null, at) : undefined),
|
|
824
|
+
};
|
|
754
825
|
this.assetDrop =
|
|
755
826
|
this.target.assetDrop ??
|
|
756
827
|
({
|
|
@@ -851,6 +922,7 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
|
|
|
851
922
|
queueMicrotask(() => {
|
|
852
923
|
this.structureNotifyQueued = false;
|
|
853
924
|
this.projector.reproject();
|
|
925
|
+
this.pathKeys.clear();
|
|
854
926
|
this.watchStructure(this.root);
|
|
855
927
|
this.target.onReindex();
|
|
856
928
|
this.notify();
|
|
@@ -951,7 +1023,7 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
|
|
|
951
1023
|
// degrade — a half-answer would be a menu item that silently does nothing.
|
|
952
1024
|
|
|
953
1025
|
private readonly liveStructure: StructureProvider = {
|
|
954
|
-
create: (kind, parentId) => this.createStructuralNode(kind, parentId ?? null),
|
|
1026
|
+
create: (kind, parentId, at) => this.createStructuralNode(kind, parentId ?? null, at),
|
|
955
1027
|
remove: (id) => this.removeStructuralNodes([id]),
|
|
956
1028
|
removeMany: (ids) => this.removeStructuralNodes(ids),
|
|
957
1029
|
duplicate: (id) => this.duplicateStructuralNode(id),
|
|
@@ -1015,6 +1087,7 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
|
|
|
1015
1087
|
*/
|
|
1016
1088
|
private afterStructuralChange(): void {
|
|
1017
1089
|
this.projector.reproject();
|
|
1090
|
+
this.pathKeys.clear();
|
|
1018
1091
|
this.watchStructure(this.root);
|
|
1019
1092
|
this.target.onReindex();
|
|
1020
1093
|
this.notify();
|
|
@@ -1031,7 +1104,22 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
|
|
|
1031
1104
|
* same turn. The live-only report is the other half of the answer and rides
|
|
1032
1105
|
* back in `ack` (see `StructuralIdWrite`) instead of being fired `void`.
|
|
1033
1106
|
*/
|
|
1034
|
-
|
|
1107
|
+
/** A point in the `rects` frame, in the space of the container a new child of `parentId` joins. */
|
|
1108
|
+
private pointInParent(
|
|
1109
|
+
parentId: string | null,
|
|
1110
|
+
at: { readonly x: number; readonly y: number },
|
|
1111
|
+
): { x: number; y: number } | undefined {
|
|
1112
|
+
const parent = this.containerFor(parentId);
|
|
1113
|
+
if (!parent || typeof parent.updateLocalTransform !== 'function') return undefined;
|
|
1114
|
+
const local = this.localToRootMatrix(parent).clone().invert().apply({ x: at.x, y: at.y });
|
|
1115
|
+
return { x: local.x, y: local.y };
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
private createStructuralNode(
|
|
1119
|
+
kind: string,
|
|
1120
|
+
parentId: string | null,
|
|
1121
|
+
at?: { readonly x: number; readonly y: number },
|
|
1122
|
+
): StructuralIdWrite {
|
|
1035
1123
|
const parent = this.containerFor(parentId);
|
|
1036
1124
|
if (!parent) {
|
|
1037
1125
|
return {
|
|
@@ -1047,6 +1135,7 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
|
|
|
1047
1135
|
};
|
|
1048
1136
|
}
|
|
1049
1137
|
node.label = defaultLabelFor(kind);
|
|
1138
|
+
if (at) node.position.set(at.x, at.y);
|
|
1050
1139
|
const label = `Create ${node.label}`;
|
|
1051
1140
|
this.structureHistory.track(parent);
|
|
1052
1141
|
this.structureHistory.track(node);
|
|
@@ -1280,7 +1369,13 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
|
|
|
1280
1369
|
this.boxEditSessions.delete(id);
|
|
1281
1370
|
return;
|
|
1282
1371
|
}
|
|
1283
|
-
|
|
1372
|
+
const parent = this.projector.object(id)?.parent?.worldTransform;
|
|
1373
|
+
this.boxEditSessions.set(id, {
|
|
1374
|
+
id,
|
|
1375
|
+
rect,
|
|
1376
|
+
transform: this.transforms.get(id),
|
|
1377
|
+
...(parent ? { parentLinear: { a: parent.a, b: parent.b, c: parent.c, d: parent.d } } : {}),
|
|
1378
|
+
});
|
|
1284
1379
|
this.transforms.beginEdit?.(id);
|
|
1285
1380
|
},
|
|
1286
1381
|
apply: (id, patch) => this.applyBoxEdit(id, patch),
|
|
@@ -1514,15 +1609,19 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
|
|
|
1514
1609
|
],
|
|
1515
1610
|
get: (id, path) =>
|
|
1516
1611
|
path === 'locked'
|
|
1517
|
-
? this.
|
|
1612
|
+
? this.lockedPaths.has(this.pathKeyOf(id))
|
|
1518
1613
|
: path === 'grouped'
|
|
1519
|
-
? this.
|
|
1614
|
+
? this.groupedPaths.has(this.pathKeyOf(id))
|
|
1520
1615
|
: this.target.get(id, path),
|
|
1521
1616
|
set: (id, path, value) => {
|
|
1522
1617
|
if (path === 'locked' || path === 'grouped') {
|
|
1523
|
-
const set = path === 'locked' ? this.
|
|
1524
|
-
if (value === true) set.add(id);
|
|
1525
|
-
else set.delete(id);
|
|
1618
|
+
const set = path === 'locked' ? this.lockedPaths : this.groupedPaths;
|
|
1619
|
+
if (value === true) set.add(this.pathKeyOf(id));
|
|
1620
|
+
else set.delete(this.pathKeyOf(id));
|
|
1621
|
+
writeEditLocks(this.editLockWorld, {
|
|
1622
|
+
locked: [...this.lockedPaths],
|
|
1623
|
+
grouped: [...this.groupedPaths],
|
|
1624
|
+
});
|
|
1526
1625
|
this.notify();
|
|
1527
1626
|
return;
|
|
1528
1627
|
}
|
|
@@ -1532,12 +1631,36 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
|
|
|
1532
1631
|
remove: (id, path) => this.target.remove?.(id, path),
|
|
1533
1632
|
};
|
|
1534
1633
|
|
|
1634
|
+
/** `id`'s label path from its world's root: each step the authored label and its place among
|
|
1635
|
+
* same-labelled siblings, so it holds across line edits and reloads (a rename drops it). */
|
|
1636
|
+
private pathKeyOf(id: string): string {
|
|
1637
|
+
const known = this.pathKeys.get(id);
|
|
1638
|
+
if (known !== undefined) return known;
|
|
1639
|
+
const labelOf = (nodeId: string): string => this.projector.object(nodeId)?.label ?? '';
|
|
1640
|
+
const segments: string[] = [];
|
|
1641
|
+
let current: string | null = id;
|
|
1642
|
+
while (current) {
|
|
1643
|
+
const node = this.projector.node(current);
|
|
1644
|
+
if (!node) break;
|
|
1645
|
+
const siblings = node.parentId
|
|
1646
|
+
? (this.projector.node(node.parentId)?.childIds ?? [])
|
|
1647
|
+
: this.projector.roots().map((root) => root.id);
|
|
1648
|
+
const label = labelOf(current);
|
|
1649
|
+
const place = siblings.filter((sibling) => labelOf(sibling) === label).indexOf(current);
|
|
1650
|
+
segments.unshift(`${label}#${Math.max(0, place)}`);
|
|
1651
|
+
current = node.parentId ?? null;
|
|
1652
|
+
}
|
|
1653
|
+
const key = segments.join('/');
|
|
1654
|
+
this.pathKeys.set(id, key);
|
|
1655
|
+
return key;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1535
1658
|
/** The outermost grouped node that contains `id`, or `id` itself when none does. */
|
|
1536
1659
|
private groupOf(id: string): string {
|
|
1537
1660
|
let group = id;
|
|
1538
1661
|
let current: string | null = id;
|
|
1539
1662
|
while (current) {
|
|
1540
|
-
if (this.
|
|
1663
|
+
if (this.groupedPaths.has(this.pathKeyOf(current))) group = current;
|
|
1541
1664
|
current = this.projector.node(current)?.parentId ?? null;
|
|
1542
1665
|
}
|
|
1543
1666
|
return group;
|
|
@@ -1546,7 +1669,7 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
|
|
|
1546
1669
|
private isPickLocked(id: string): boolean {
|
|
1547
1670
|
let current: string | null = id;
|
|
1548
1671
|
while (current) {
|
|
1549
|
-
if (this.
|
|
1672
|
+
if (this.lockedPaths.has(this.pathKeyOf(current))) return true;
|
|
1550
1673
|
current = this.projector.node(current)?.parentId ?? null;
|
|
1551
1674
|
}
|
|
1552
1675
|
return false;
|
|
@@ -1565,8 +1688,7 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
|
|
|
1565
1688
|
|
|
1566
1689
|
dispose(): void {
|
|
1567
1690
|
this.boxEditSessions.clear();
|
|
1568
|
-
this.
|
|
1569
|
-
this.groupedIds.clear();
|
|
1691
|
+
this.pathKeys.clear();
|
|
1570
1692
|
for (const object of this.watchedForStructure) {
|
|
1571
1693
|
object.off('childAdded', this.onStructureChanged);
|
|
1572
1694
|
object.off('childRemoved', this.onStructureChanged);
|