@jgengine/editor 0.10.0
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/CHANGELOG.md +325 -0
- package/dist/AssetBrowser.d.ts +20 -0
- package/dist/AssetBrowser.js +49 -0
- package/dist/CatalogsPanel.d.ts +10 -0
- package/dist/CatalogsPanel.js +41 -0
- package/dist/CollectionsPanel.d.ts +10 -0
- package/dist/CollectionsPanel.js +35 -0
- package/dist/DebugDraw.d.ts +17 -0
- package/dist/DebugDraw.js +233 -0
- package/dist/EditorApp.d.ts +26 -0
- package/dist/EditorApp.js +333 -0
- package/dist/EditorCameraDriver.d.ts +5 -0
- package/dist/EditorCameraDriver.js +18 -0
- package/dist/EditorChrome.d.ts +22 -0
- package/dist/EditorChrome.js +559 -0
- package/dist/EditorContextMenu.d.ts +11 -0
- package/dist/EditorContextMenu.js +12 -0
- package/dist/InspectorPanel.d.ts +14 -0
- package/dist/InspectorPanel.js +110 -0
- package/dist/MaterialDropZone.d.ts +10 -0
- package/dist/MaterialDropZone.js +77 -0
- package/dist/OutlinerPanel.d.ts +14 -0
- package/dist/OutlinerPanel.js +84 -0
- package/dist/PerfProbe.d.ts +5 -0
- package/dist/PerfProbe.js +34 -0
- package/dist/PrefabsPanel.d.ts +13 -0
- package/dist/PrefabsPanel.js +42 -0
- package/dist/RuntimePlayBridge.d.ts +18 -0
- package/dist/RuntimePlayBridge.js +140 -0
- package/dist/ScatterPreview.d.ts +11 -0
- package/dist/ScatterPreview.js +30 -0
- package/dist/SchemaInspector.d.ts +19 -0
- package/dist/SchemaInspector.js +78 -0
- package/dist/SelectionGizmo.d.ts +25 -0
- package/dist/SelectionGizmo.js +455 -0
- package/dist/StandaloneEditor.d.ts +44 -0
- package/dist/StandaloneEditor.js +92 -0
- package/dist/TerrainPanel.d.ts +7 -0
- package/dist/TerrainPanel.js +59 -0
- package/dist/TerrainSculpt.d.ts +14 -0
- package/dist/TerrainSculpt.js +415 -0
- package/dist/TriggerInspector.d.ts +13 -0
- package/dist/TriggerInspector.js +60 -0
- package/dist/agent/AgentPanel.d.ts +13 -0
- package/dist/agent/AgentPanel.js +156 -0
- package/dist/agent/context.d.ts +27 -0
- package/dist/agent/context.js +39 -0
- package/dist/agent/endpoint.d.ts +68 -0
- package/dist/agent/endpoint.js +186 -0
- package/dist/agent/toolBridge.d.ts +35 -0
- package/dist/agent/toolBridge.js +93 -0
- package/dist/agent/turn.d.ts +71 -0
- package/dist/agent/turn.js +113 -0
- package/dist/chromeFields.d.ts +15 -0
- package/dist/chromeFields.js +13 -0
- package/dist/chromeStyles.d.ts +3 -0
- package/dist/chromeStyles.js +3 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +19 -0
- package/dist/mcp/bridgeServer.d.ts +13 -0
- package/dist/mcp/bridgeServer.js +1 -0
- package/dist/mcp/loadGameCatalogs.d.ts +24 -0
- package/dist/mcp/loadGameCatalogs.js +87 -0
- package/dist/mcp/rpcRequest.d.ts +22 -0
- package/dist/mcp/rpcRequest.js +97 -0
- package/dist/mcp/tools.d.ts +8 -0
- package/dist/mcp/tools.js +764 -0
- package/dist/outlinerModel.d.ts +76 -0
- package/dist/outlinerModel.js +95 -0
- package/dist/perfMarks.d.ts +25 -0
- package/dist/perfMarks.js +36 -0
- package/dist/session.d.ts +400 -0
- package/dist/session.js +1032 -0
- package/dist/uiStore.d.ts +108 -0
- package/dist/uiStore.js +128 -0
- package/dist/useF2Chord.d.ts +2 -0
- package/dist/useF2Chord.js +32 -0
- package/dist/useStoreSelector.d.ts +31 -0
- package/dist/useStoreSelector.js +51 -0
- package/dist/viewportContextMenu.d.ts +24 -0
- package/dist/viewportContextMenu.js +32 -0
- package/package.json +58 -0
- package/skills/jgengine/SKILL.md +823 -0
- package/skills/jgengine/api.md +416 -0
- package/skills/jgengine/capabilities.md +21 -0
- package/skills/jgengine/reference-cartridge.md +72 -0
- package/skills/jgengine/reference-orphans.md +9 -0
- package/skills/jgengine/reference-systems.md +60 -0
- package/skills/jgengine-editor/SKILL.md +382 -0
- package/skills/jgengine-editor/api.md +416 -0
- package/skills/jgengine-editor/capabilities.md +29 -0
- package/skills/jgengine-editor/reference.md +64 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useThree } from "@react-three/fiber";
|
|
2
|
+
import { memo, useEffect } from "react";
|
|
3
|
+
/** Smoothly pans the orbit camera to the editor host's focus target when it changes. */
|
|
4
|
+
export const EditorCameraDriver = memo(function EditorCameraDriver({ api }) {
|
|
5
|
+
const controls = useThree((state) => state.controls);
|
|
6
|
+
const camera = useThree((state) => state.camera);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
return api.subscribeFocus((target) => {
|
|
9
|
+
if (target === null || controls === null || controls.target === undefined)
|
|
10
|
+
return;
|
|
11
|
+
const previous = controls.target.clone();
|
|
12
|
+
controls.target.set(target.x, target.y, target.z);
|
|
13
|
+
camera.position.add(controls.target.clone().sub(previous));
|
|
14
|
+
controls.update();
|
|
15
|
+
});
|
|
16
|
+
}, [api, camera, controls]);
|
|
17
|
+
return null;
|
|
18
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type EditorSession } from "@jgengine/core/editor/index";
|
|
2
|
+
import { type EditorAssetEntry } from "./AssetBrowser.js";
|
|
3
|
+
import type { EditorHostApi } from "./session.js";
|
|
4
|
+
import { type EditorUiStore } from "./uiStore.js";
|
|
5
|
+
/**
|
|
6
|
+
* The full editor UI shell — toolbar, left panels (outliner/prefabs/sets/layers), viewport overlays,
|
|
7
|
+
* the selector-subscribed {@link InspectorPanel}, the embedded {@link AgentPanel}, and the asset
|
|
8
|
+
* browser — wired to the session, UI store, and host RPC. Mounted by `EditorApp`; not a game-author entry point.
|
|
9
|
+
*/
|
|
10
|
+
export declare function EditorChrome({ gameId, session, api, assets, ui, baselineJson, save, }: {
|
|
11
|
+
gameId: string;
|
|
12
|
+
session: EditorSession;
|
|
13
|
+
api: EditorHostApi;
|
|
14
|
+
assets: readonly EditorAssetEntry[];
|
|
15
|
+
ui: EditorUiStore;
|
|
16
|
+
baselineJson?: string;
|
|
17
|
+
save?: (json: string) => Promise<{
|
|
18
|
+
ok: boolean;
|
|
19
|
+
path?: string;
|
|
20
|
+
error?: string;
|
|
21
|
+
}>;
|
|
22
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { editorDocumentSize, extractEditorFragment, listEditorKinds, WELL_KNOWN_MARKER_KINDS, } from "@jgengine/core/editor/index";
|
|
4
|
+
import { VEGETATION_VOLUME_KIND } from "@jgengine/core/world/vegetation";
|
|
5
|
+
import { scatterRegionEstimate, SCATTER_PATH_KIND } from "@jgengine/core/world/scatterRegion";
|
|
6
|
+
import { listSceneKinds } from "@jgengine/core/scene/sceneKinds";
|
|
7
|
+
import { AssetBrowser } from "./AssetBrowser.js";
|
|
8
|
+
import { AgentPanel } from "./agent/AgentPanel.js";
|
|
9
|
+
import { CatalogsPanel } from "./CatalogsPanel.js";
|
|
10
|
+
import { CollectionsPanel } from "./CollectionsPanel.js";
|
|
11
|
+
import { EditorContextMenu } from "./EditorContextMenu.js";
|
|
12
|
+
import { OutlinerPanel } from "./OutlinerPanel.js";
|
|
13
|
+
import { PrefabsPanel } from "./PrefabsPanel.js";
|
|
14
|
+
import { buildEditorContextMenu, } from "./viewportContextMenu.js";
|
|
15
|
+
import { buildOutlinerGroups } from "./outlinerModel.js";
|
|
16
|
+
import { newPlacementId } from "./uiStore.js";
|
|
17
|
+
import { useF2Chord } from "./useF2Chord.js";
|
|
18
|
+
import { BTN, MICRO } from "./chromeStyles.js";
|
|
19
|
+
import { TerrainPanel } from "./TerrainPanel.js";
|
|
20
|
+
import { InspectorPanel } from "./InspectorPanel.js";
|
|
21
|
+
const PERF_POLL_MS = 500;
|
|
22
|
+
const ADD_VOLUME_ENTRIES = [
|
|
23
|
+
{ label: "Zone (sphere)", tool: { tool: "volume", kind: "zone", shape: "sphere" } },
|
|
24
|
+
{ label: "Zone (box)", tool: { tool: "volume", kind: "zone", shape: "box" } },
|
|
25
|
+
{ label: "Zone (cylinder)", tool: { tool: "volume", kind: "zone", shape: "cylinder" } },
|
|
26
|
+
{ label: "Aggro range", tool: { tool: "volume", kind: "aggro", shape: "sphere" } },
|
|
27
|
+
{ label: "Leash range", tool: { tool: "volume", kind: "leash", shape: "sphere" } },
|
|
28
|
+
{ label: "Discover area", tool: { tool: "volume", kind: "discover", shape: "sphere" } },
|
|
29
|
+
{ label: "Capture area", tool: { tool: "volume", kind: "capture", shape: "cylinder" } },
|
|
30
|
+
{ label: "Vegetation (box)", tool: { tool: "volume", kind: VEGETATION_VOLUME_KIND, shape: "box" } },
|
|
31
|
+
{ label: "Vegetation (circle)", tool: { tool: "volume", kind: VEGETATION_VOLUME_KIND, shape: "sphere" } },
|
|
32
|
+
];
|
|
33
|
+
const SNAP_MODES = [
|
|
34
|
+
{ mode: "ground", label: "Snap: ground" },
|
|
35
|
+
{ mode: "grid", label: "Snap: grid" },
|
|
36
|
+
{ mode: "off", label: "Snap: off" },
|
|
37
|
+
];
|
|
38
|
+
function usePerfSample(api) {
|
|
39
|
+
const [sample, setSample] = useState(null);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const timer = setInterval(() => setSample(api.getPerf()), PERF_POLL_MS);
|
|
42
|
+
return () => clearInterval(timer);
|
|
43
|
+
}, [api]);
|
|
44
|
+
return sample;
|
|
45
|
+
}
|
|
46
|
+
function formatTriangles(count) {
|
|
47
|
+
if (count >= 1_000_000)
|
|
48
|
+
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
49
|
+
if (count >= 1_000)
|
|
50
|
+
return `${(count / 1_000).toFixed(0)}k`;
|
|
51
|
+
return String(count);
|
|
52
|
+
}
|
|
53
|
+
let clipboardFragment = null;
|
|
54
|
+
const SHORTCUTS = [
|
|
55
|
+
{ keys: "W / E / R", action: "Move · rotate · scale gizmo" },
|
|
56
|
+
{ keys: "T", action: "Toggle terrain sculpt tool" },
|
|
57
|
+
{ keys: "F", action: "Frame selection (or whole scene)" },
|
|
58
|
+
{ keys: "G", action: "Toggle reference grid" },
|
|
59
|
+
{ keys: "N", action: "Cycle instances of selected row" },
|
|
60
|
+
{ keys: "Arrows", action: "Nudge selection on X/Z (Shift ×5)" },
|
|
61
|
+
{ keys: "PgUp / PgDn", action: "Nudge selection on Y (Shift ×5)" },
|
|
62
|
+
{ keys: "Ctrl+A", action: "Select all visible objects" },
|
|
63
|
+
{ keys: "Ctrl+C / X / V", action: "Copy · cut · paste selection" },
|
|
64
|
+
{ keys: "Ctrl+D", action: "Duplicate selection" },
|
|
65
|
+
{ keys: "Ctrl+Z / Y", action: "Undo · redo" },
|
|
66
|
+
{ keys: "Ctrl+B", action: "Asset browser" },
|
|
67
|
+
{ keys: "Delete", action: "Remove selection" },
|
|
68
|
+
{ keys: "Enter / Esc", action: "Finish · cancel path drawing" },
|
|
69
|
+
{ keys: "Shift+click", action: "Multi-select · keep placing" },
|
|
70
|
+
{ keys: "RMB drag", action: "Orbit camera" },
|
|
71
|
+
{ keys: "MMB drag", action: "Pan camera" },
|
|
72
|
+
{ keys: "RMB click", action: "Context menu" },
|
|
73
|
+
{ keys: "F2+E", action: "Toggle editor ↔ play" },
|
|
74
|
+
{ keys: "F2+D", action: "Engine devtools" },
|
|
75
|
+
{ keys: "?", action: "This help" },
|
|
76
|
+
];
|
|
77
|
+
function downloadText(filename, text) {
|
|
78
|
+
const blob = new Blob([text], { type: "application/json" });
|
|
79
|
+
const url = URL.createObjectURL(blob);
|
|
80
|
+
const anchor = document.createElement("a");
|
|
81
|
+
anchor.href = url;
|
|
82
|
+
anchor.download = filename;
|
|
83
|
+
anchor.click();
|
|
84
|
+
URL.revokeObjectURL(url);
|
|
85
|
+
}
|
|
86
|
+
function useDocumentSave(session, save) {
|
|
87
|
+
const [saveState, setSaveState] = useState("idle");
|
|
88
|
+
const [saveError, setSaveError] = useState(null);
|
|
89
|
+
const savedDocRef = useRef(session.getState().document);
|
|
90
|
+
const dirty = session.getState().document !== savedDocRef.current;
|
|
91
|
+
const doSave = () => {
|
|
92
|
+
if (save === undefined || saveState === "saving")
|
|
93
|
+
return;
|
|
94
|
+
const document = session.getState().document;
|
|
95
|
+
setSaveState("saving");
|
|
96
|
+
void save(session.exportJson(true)).then((result) => {
|
|
97
|
+
if (result.ok) {
|
|
98
|
+
savedDocRef.current = document;
|
|
99
|
+
setSaveError(null);
|
|
100
|
+
setSaveState("saved");
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
setSaveError(result.error ?? "save failed");
|
|
104
|
+
setSaveState("error");
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
return { available: save !== undefined, dirty, saveState, saveError, doSave };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The full editor UI shell — toolbar, left panels (outliner/prefabs/sets/layers), viewport overlays,
|
|
112
|
+
* the selector-subscribed {@link InspectorPanel}, the embedded {@link AgentPanel}, and the asset
|
|
113
|
+
* browser — wired to the session, UI store, and host RPC. Mounted by `EditorApp`; not a game-author entry point.
|
|
114
|
+
*/
|
|
115
|
+
export function EditorChrome({ gameId, session, api, assets, ui, baselineJson, save, }) {
|
|
116
|
+
const [, setTick] = useState(0);
|
|
117
|
+
const [activePanel, setActivePanel] = useState("outliner");
|
|
118
|
+
const [leftOpen, setLeftOpen] = useState(true);
|
|
119
|
+
const [rightOpen, setRightOpen] = useState(true);
|
|
120
|
+
const [agentOpen, setAgentOpen] = useState(false);
|
|
121
|
+
const [bottomOpen, setBottomOpen] = useState(false);
|
|
122
|
+
const [addOpen, setAddOpen] = useState(false);
|
|
123
|
+
const [helpOpen, setHelpOpen] = useState(false);
|
|
124
|
+
const [toast, setToast] = useState(null);
|
|
125
|
+
const toastTimer = useRef(null);
|
|
126
|
+
const notify = useCallback((text, tone = "info") => {
|
|
127
|
+
if (toastTimer.current !== null)
|
|
128
|
+
clearTimeout(toastTimer.current);
|
|
129
|
+
setToast({ text, tone });
|
|
130
|
+
toastTimer.current = setTimeout(() => setToast(null), 2600);
|
|
131
|
+
}, []);
|
|
132
|
+
useEffect(() => () => {
|
|
133
|
+
if (toastTimer.current !== null)
|
|
134
|
+
clearTimeout(toastTimer.current);
|
|
135
|
+
}, []);
|
|
136
|
+
const importInputRef = useRef(null);
|
|
137
|
+
const state = session.getState();
|
|
138
|
+
const visibility = api.getVisibility();
|
|
139
|
+
const perf = usePerfSample(api);
|
|
140
|
+
const uiState = ui.getState();
|
|
141
|
+
const gizmoMode = uiState.gizmoMode;
|
|
142
|
+
const docSave = useDocumentSave(session, save);
|
|
143
|
+
const docSaveRef = useRef(docSave);
|
|
144
|
+
docSaveRef.current = docSave;
|
|
145
|
+
const outlinerGroups = useMemo(() => buildOutlinerGroups(state.document), [state.document]);
|
|
146
|
+
const outlinerGroupsRef = useRef(outlinerGroups);
|
|
147
|
+
outlinerGroupsRef.current = outlinerGroups;
|
|
148
|
+
const sceneStats = useMemo(() => {
|
|
149
|
+
const doc = state.document;
|
|
150
|
+
const objects = doc.markers.length + doc.volumes.length + doc.paths.length + doc.annotations.length;
|
|
151
|
+
let foliage = 0;
|
|
152
|
+
for (const path of doc.paths)
|
|
153
|
+
if (path.kind === SCATTER_PATH_KIND)
|
|
154
|
+
foliage += scatterRegionEstimate(path).count;
|
|
155
|
+
return { objects, foliage };
|
|
156
|
+
}, [state.document]);
|
|
157
|
+
const showPanel = (panel) => {
|
|
158
|
+
setActivePanel(panel);
|
|
159
|
+
if (panel === "assets")
|
|
160
|
+
setBottomOpen(true);
|
|
161
|
+
else
|
|
162
|
+
setLeftOpen(true);
|
|
163
|
+
};
|
|
164
|
+
useF2Chord("KeyE", () => api.setMode("play"));
|
|
165
|
+
useEffect(() => session.subscribe(() => setTick((value) => value + 1)), [session]);
|
|
166
|
+
useEffect(() => ui.subscribe(() => setTick((value) => value + 1)), [ui]);
|
|
167
|
+
const copySelection = useCallback(() => {
|
|
168
|
+
const current = session.getState();
|
|
169
|
+
if (current.selection.length === 0)
|
|
170
|
+
return 0;
|
|
171
|
+
const fragment = extractEditorFragment(current.document, current.selection);
|
|
172
|
+
const count = editorDocumentSize(fragment);
|
|
173
|
+
if (count === 0)
|
|
174
|
+
return 0;
|
|
175
|
+
clipboardFragment = fragment;
|
|
176
|
+
if (typeof navigator !== "undefined" && navigator.clipboard !== undefined) {
|
|
177
|
+
void navigator.clipboard.writeText(JSON.stringify(fragment, null, 2)).catch(() => { });
|
|
178
|
+
}
|
|
179
|
+
return count;
|
|
180
|
+
}, [session]);
|
|
181
|
+
const closeContextMenu = useCallback(() => {
|
|
182
|
+
if (ui.getState().contextMenu !== null)
|
|
183
|
+
ui.patch({ contextMenu: null });
|
|
184
|
+
}, [ui]);
|
|
185
|
+
const runContextAction = useCallback((action) => {
|
|
186
|
+
const menu = ui.getState().contextMenu;
|
|
187
|
+
closeContextMenu();
|
|
188
|
+
const current = session.getState();
|
|
189
|
+
const selection = current.selection;
|
|
190
|
+
const ground = menu?.ground ?? null;
|
|
191
|
+
switch (action.id) {
|
|
192
|
+
case "frame": {
|
|
193
|
+
const id = selection[0] ?? menu?.hitId;
|
|
194
|
+
if (id !== undefined && id !== null)
|
|
195
|
+
api.handle({ method: "camera_goto", id });
|
|
196
|
+
else
|
|
197
|
+
api.handle({ method: "camera_frame" });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
case "frameAll":
|
|
201
|
+
api.handle({ method: "camera_frame" });
|
|
202
|
+
return;
|
|
203
|
+
case "duplicate":
|
|
204
|
+
if (selection.length > 0)
|
|
205
|
+
session.dispatch({ type: "duplicate", ids: selection });
|
|
206
|
+
return;
|
|
207
|
+
case "delete":
|
|
208
|
+
if (selection.length > 0)
|
|
209
|
+
session.dispatch({ type: "removeMany", ids: selection });
|
|
210
|
+
return;
|
|
211
|
+
case "copy": {
|
|
212
|
+
const count = copySelection();
|
|
213
|
+
if (count > 0)
|
|
214
|
+
notify(`Copied ${count} object${count === 1 ? "" : "s"}`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
case "paste":
|
|
218
|
+
if (clipboardFragment === null)
|
|
219
|
+
return;
|
|
220
|
+
{
|
|
221
|
+
const count = editorDocumentSize(clipboardFragment);
|
|
222
|
+
session.dispatch({
|
|
223
|
+
type: "addFragment",
|
|
224
|
+
fragment: clipboardFragment,
|
|
225
|
+
offset: { x: 2, y: 0, z: 2 },
|
|
226
|
+
});
|
|
227
|
+
notify(`Pasted ${count} object${count === 1 ? "" : "s"}`);
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
case "createPrefab":
|
|
231
|
+
if (selection.length === 0)
|
|
232
|
+
return;
|
|
233
|
+
{
|
|
234
|
+
const id = `prefab_${Date.now().toString(36)}`;
|
|
235
|
+
const name = `Prefab ${current.document.prefabs.length + 1}`;
|
|
236
|
+
api.handle({ method: "create_prefab", id, name, ids: [...selection] });
|
|
237
|
+
showPanel("prefabs");
|
|
238
|
+
notify(`Created prefab “${name}”`);
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
case "addMarker":
|
|
242
|
+
if (ground !== null) {
|
|
243
|
+
session.dispatch({
|
|
244
|
+
type: "addMarker",
|
|
245
|
+
marker: {
|
|
246
|
+
id: newPlacementId("player_spawn"),
|
|
247
|
+
kind: "player_spawn",
|
|
248
|
+
position: ground,
|
|
249
|
+
label: "player_spawn",
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
ui.startPlacement({ tool: "marker", kind: "player_spawn" });
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
case "addVolume":
|
|
258
|
+
if (ground !== null) {
|
|
259
|
+
session.dispatch({
|
|
260
|
+
type: "addVolume",
|
|
261
|
+
volume: {
|
|
262
|
+
id: newPlacementId("zone"),
|
|
263
|
+
kind: "zone",
|
|
264
|
+
shape: "sphere",
|
|
265
|
+
center: ground,
|
|
266
|
+
radius: 10,
|
|
267
|
+
label: "zone",
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
ui.startPlacement({ tool: "volume", kind: "zone", shape: "sphere" });
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
case "addPath":
|
|
276
|
+
ui.startPlacement({ tool: "path", kind: "route" });
|
|
277
|
+
if (ground !== null)
|
|
278
|
+
ui.pushDraftPoint(ground);
|
|
279
|
+
return;
|
|
280
|
+
case "addNote":
|
|
281
|
+
if (ground !== null) {
|
|
282
|
+
session.dispatch({
|
|
283
|
+
type: "addNote",
|
|
284
|
+
note: { id: newPlacementId("note"), text: "New note", position: ground },
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
ui.startPlacement({ tool: "note" });
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
case "openAssets":
|
|
292
|
+
showPanel("assets");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
}, [api, closeContextMenu, copySelection, notify, session, ui]);
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
const onKey = (event) => {
|
|
298
|
+
if (event.defaultPrevented)
|
|
299
|
+
return;
|
|
300
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
|
|
301
|
+
event.preventDefault();
|
|
302
|
+
docSaveRef.current.doSave();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const target = event.target;
|
|
306
|
+
const isTyping = target instanceof HTMLInputElement ||
|
|
307
|
+
target instanceof HTMLTextAreaElement ||
|
|
308
|
+
(target instanceof HTMLElement && target.isContentEditable);
|
|
309
|
+
if (isTyping) {
|
|
310
|
+
if (event.key === "Escape" && target instanceof HTMLElement)
|
|
311
|
+
target.blur();
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z") {
|
|
315
|
+
event.preventDefault();
|
|
316
|
+
session.dispatch({ type: event.shiftKey ? "redo" : "undo" });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "y") {
|
|
320
|
+
event.preventDefault();
|
|
321
|
+
session.dispatch({ type: "redo" });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "b") {
|
|
325
|
+
event.preventDefault();
|
|
326
|
+
showPanel("assets");
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "a") {
|
|
330
|
+
event.preventDefault();
|
|
331
|
+
const state = session.getState();
|
|
332
|
+
const visibility = api.getVisibility();
|
|
333
|
+
const ids = [
|
|
334
|
+
...state.document.markers.filter((m) => visibility[m.kind] !== false).map((m) => m.id),
|
|
335
|
+
...state.document.volumes.filter((v) => visibility[v.kind] !== false).map((v) => v.id),
|
|
336
|
+
...state.document.paths.filter((p) => visibility[p.kind] !== false).map((p) => p.id),
|
|
337
|
+
...(visibility["note"] !== false ? state.document.annotations.map((n) => n.id) : []),
|
|
338
|
+
];
|
|
339
|
+
if (ids.length > 0)
|
|
340
|
+
session.dispatch({ type: "select", ids });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c") {
|
|
344
|
+
const count = copySelection();
|
|
345
|
+
if (count > 0) {
|
|
346
|
+
event.preventDefault();
|
|
347
|
+
notify(`Copied ${count} object${count === 1 ? "" : "s"}`);
|
|
348
|
+
}
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "x") {
|
|
352
|
+
const count = copySelection();
|
|
353
|
+
if (count > 0) {
|
|
354
|
+
event.preventDefault();
|
|
355
|
+
session.dispatch({ type: "removeMany", ids: session.getState().selection });
|
|
356
|
+
notify(`Cut ${count} object${count === 1 ? "" : "s"}`);
|
|
357
|
+
}
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v") {
|
|
361
|
+
if (clipboardFragment === null)
|
|
362
|
+
return;
|
|
363
|
+
event.preventDefault();
|
|
364
|
+
const count = editorDocumentSize(clipboardFragment);
|
|
365
|
+
session.dispatch({ type: "addFragment", fragment: clipboardFragment, offset: { x: 2, y: 0, z: 2 } });
|
|
366
|
+
notify(`Pasted ${count} object${count === 1 ? "" : "s"}`);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "d") {
|
|
370
|
+
event.preventDefault();
|
|
371
|
+
const selection = session.getState().selection;
|
|
372
|
+
if (selection.length > 0)
|
|
373
|
+
session.dispatch({ type: "duplicate", ids: selection });
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (event.key === "Escape") {
|
|
377
|
+
setHelpOpen(false);
|
|
378
|
+
const current = ui.getState();
|
|
379
|
+
if (current.contextMenu !== null) {
|
|
380
|
+
ui.patch({ contextMenu: null });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (current.placement !== null || current.pathDraft.length > 0) {
|
|
384
|
+
ui.cancelPlacement();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (current.pathPoint !== null) {
|
|
388
|
+
ui.patch({ pathPoint: null });
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (session.getState().selection.length > 0)
|
|
392
|
+
session.dispatch({ type: "clearSelection" });
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (event.key === "Enter" && ui.getState().placement?.tool === "path") {
|
|
396
|
+
event.preventDefault();
|
|
397
|
+
ui.commitPathDraft(session);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (event.key === "Delete" || event.key === "Backspace") {
|
|
401
|
+
const selection = session.getState().selection;
|
|
402
|
+
if (selection.length > 0)
|
|
403
|
+
session.dispatch({ type: "removeMany", ids: selection });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (event.key === "?" || event.key === "F1") {
|
|
407
|
+
event.preventDefault();
|
|
408
|
+
setHelpOpen((value) => !value);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (event.key === "f" || event.key === "F") {
|
|
412
|
+
const selected = session.getState().selection[0];
|
|
413
|
+
if (selected !== undefined)
|
|
414
|
+
api.handle({ method: "camera_goto", id: selected });
|
|
415
|
+
else
|
|
416
|
+
api.handle({ method: "camera_frame" });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (event.key === "ArrowUp" ||
|
|
420
|
+
event.key === "ArrowDown" ||
|
|
421
|
+
event.key === "ArrowLeft" ||
|
|
422
|
+
event.key === "ArrowRight" ||
|
|
423
|
+
event.key === "PageUp" ||
|
|
424
|
+
event.key === "PageDown") {
|
|
425
|
+
const selection = session.getState().selection;
|
|
426
|
+
if (selection.length === 0)
|
|
427
|
+
return;
|
|
428
|
+
event.preventDefault();
|
|
429
|
+
const step = Math.max(0.5, ui.getState().gridSize) * (event.shiftKey ? 5 : 1);
|
|
430
|
+
const delta = event.key === "ArrowUp"
|
|
431
|
+
? { x: 0, y: 0, z: -step }
|
|
432
|
+
: event.key === "ArrowDown"
|
|
433
|
+
? { x: 0, y: 0, z: step }
|
|
434
|
+
: event.key === "ArrowLeft"
|
|
435
|
+
? { x: -step, y: 0, z: 0 }
|
|
436
|
+
: event.key === "ArrowRight"
|
|
437
|
+
? { x: step, y: 0, z: 0 }
|
|
438
|
+
: event.key === "PageUp"
|
|
439
|
+
? { x: 0, y: step, z: 0 }
|
|
440
|
+
: { x: 0, y: -step, z: 0 };
|
|
441
|
+
session.dispatch({ type: "translate", ids: selection, delta }, { coalesce: `nudge:${selection.join(",")}` });
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (event.key === "w" || event.key === "W")
|
|
445
|
+
ui.patch({ gizmoMode: "translate" });
|
|
446
|
+
if (event.key === "e" || event.key === "E")
|
|
447
|
+
ui.patch({ gizmoMode: "rotate" });
|
|
448
|
+
if (event.key === "r" || event.key === "R")
|
|
449
|
+
ui.patch({ gizmoMode: "scale" });
|
|
450
|
+
if (event.key === "g" || event.key === "G")
|
|
451
|
+
ui.patch({ showGrid: !ui.getState().showGrid });
|
|
452
|
+
if (event.key === "t" || event.key === "T")
|
|
453
|
+
ui.setTool(ui.getState().tool === "terrain" ? "select" : "terrain");
|
|
454
|
+
if (event.key === "n" || event.key === "N") {
|
|
455
|
+
const selected = session.getState().selection[0];
|
|
456
|
+
if (selected === undefined)
|
|
457
|
+
return;
|
|
458
|
+
const row = outlinerGroupsRef.current
|
|
459
|
+
.flatMap((group) => group.rows)
|
|
460
|
+
.find((candidate) => candidate.ids.includes(selected));
|
|
461
|
+
if (row === undefined || row.ids.length < 2)
|
|
462
|
+
return;
|
|
463
|
+
const next = row.ids[(row.ids.indexOf(selected) + 1) % row.ids.length];
|
|
464
|
+
session.dispatch({ type: "select", ids: [next] });
|
|
465
|
+
api.handle({ method: "camera_goto", id: next });
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
window.addEventListener("keydown", onKey);
|
|
469
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
470
|
+
}, [session, ui, api, notify, copySelection]);
|
|
471
|
+
const kinds = useMemo(() => listEditorKinds(state.document), [state.document]);
|
|
472
|
+
const allKinds = useMemo(() => [
|
|
473
|
+
...new Set([
|
|
474
|
+
...kinds.markers,
|
|
475
|
+
...kinds.volumes,
|
|
476
|
+
...kinds.paths,
|
|
477
|
+
...(state.document.annotations.length > 0 ? ["note"] : []),
|
|
478
|
+
]),
|
|
479
|
+
].sort(), [kinds, state.document.annotations.length]);
|
|
480
|
+
const studioKinds = useMemo(() => listSceneKinds().filter((definition) => definition.addCategory !== undefined), []);
|
|
481
|
+
const placeAsset = (entry) => {
|
|
482
|
+
const focus = api.getFocusTarget();
|
|
483
|
+
const focused = session.getState();
|
|
484
|
+
const focusedId = focused.selection[0];
|
|
485
|
+
const selected = focused.document.markers.find((marker) => marker.id === focusedId)?.position ??
|
|
486
|
+
focused.document.volumes.find((volume) => volume.id === focusedId)?.center;
|
|
487
|
+
const position = focus ?? selected ?? { x: 0, y: 0, z: 0 };
|
|
488
|
+
api.handle({
|
|
489
|
+
method: "place_asset",
|
|
490
|
+
id: entry.id,
|
|
491
|
+
kind: entry.kind === "model" ? "prop" : entry.kind,
|
|
492
|
+
x: position.x,
|
|
493
|
+
y: position.y,
|
|
494
|
+
z: position.z,
|
|
495
|
+
});
|
|
496
|
+
};
|
|
497
|
+
const importFile = (file) => {
|
|
498
|
+
void file.text().then((text) => {
|
|
499
|
+
try {
|
|
500
|
+
session.dispatch({ type: "importJson", json: text });
|
|
501
|
+
const doc = session.getState().document;
|
|
502
|
+
notify(`Imported ${editorDocumentSize(doc)} objects from ${file.name}`);
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
notify(`Import failed: ${error instanceof Error ? error.message : "invalid JSON"}`, "error");
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
};
|
|
509
|
+
const copyExportJson = () => {
|
|
510
|
+
const json = session.exportJson(true);
|
|
511
|
+
if (typeof navigator !== "undefined" && navigator.clipboard !== undefined) {
|
|
512
|
+
navigator.clipboard.writeText(json).then(() => notify("Document JSON copied to clipboard"), () => notify("Clipboard unavailable — use Export instead", "error"));
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
notify("Clipboard unavailable — use Export instead", "error");
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
const currentJson = useMemo(() => session.exportJson(true), [session, state.document]);
|
|
519
|
+
const dirty = baselineJson !== undefined && currentJson !== baselineJson;
|
|
520
|
+
const startPlacement = (tool) => {
|
|
521
|
+
setAddOpen(false);
|
|
522
|
+
ui.startPlacement(tool);
|
|
523
|
+
};
|
|
524
|
+
const placement = uiState.placement;
|
|
525
|
+
const placementHint = placement === null
|
|
526
|
+
? null
|
|
527
|
+
: placement.tool === "path"
|
|
528
|
+
? `Drawing ${placement.kind}: click to add points (${uiState.pathDraft.length}) · Enter finish · Esc cancel`
|
|
529
|
+
: `Placing ${placement.tool === "note" ? "note" : placement.kind}: click the world · Shift-click places more · Esc cancel`;
|
|
530
|
+
return (_jsxs("div", { className: "pointer-events-none absolute inset-0 z-50 flex flex-col text-xs text-neutral-100", children: [_jsxs("header", { className: "pointer-events-auto flex h-11 shrink-0 items-center gap-1.5 border-b border-white/[0.08] bg-gradient-to-b from-[#15171c]/95 to-[#0d0f13]/95 px-2 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] backdrop-blur-md", children: [_jsx("button", { type: "button", className: "rounded-md bg-gradient-to-br from-cyan-400/15 to-indigo-500/15 px-2 py-1 font-bold tracking-widest text-cyan-300 ring-1 ring-inset ring-cyan-400/25 transition-colors hover:from-cyan-400/25 hover:to-indigo-500/25", onClick: () => setLeftOpen((value) => !value), "aria-label": "Toggle hierarchy panel", children: "JG" }), _jsxs("span", { className: "hidden text-neutral-500 sm:inline", children: [gameId, dirty ? _jsx("span", { className: "ml-1 text-amber-400", title: "Unsaved edits \u2014 Export to save", children: "\u25CF" }) : null] }), _jsx("div", { className: "mx-1 h-5 w-px bg-white/[0.07]" }), _jsx("div", { className: "flex items-center gap-0.5 rounded-lg bg-black/40 p-0.5 ring-1 ring-inset ring-white/[0.06]", children: ["select", "terrain"].map((tool) => (_jsx("button", { type: "button", className: `rounded-md px-2.5 py-1 capitalize transition-colors ${uiState.tool === tool ? "bg-amber-500/90 text-white shadow-sm shadow-amber-950/50" : "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"}`, onClick: () => ui.setTool(tool), children: tool }, tool))) }), _jsx("div", { className: "h-5 w-px bg-white/[0.07]" }), _jsx("div", { className: `flex items-center gap-0.5 rounded-lg bg-black/40 p-0.5 ring-1 ring-inset ring-white/[0.06] ${uiState.tool === "terrain" ? "opacity-40" : ""}`, children: ["translate", "rotate", "scale"].map((mode) => (_jsxs("button", { type: "button", className: `rounded-md px-2.5 py-1 capitalize transition-colors ${gizmoMode === mode ? "bg-cyan-500/90 text-white shadow-sm shadow-cyan-950/50" : "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"}`, onClick: () => ui.patch({ gizmoMode: mode }), children: [mode, " ", _jsx("kbd", { className: `ml-0.5 font-sans text-[9px] ${gizmoMode === mode ? "text-cyan-100/80" : "text-neutral-500"}`, children: mode === "translate" ? "W" : mode === "rotate" ? "E" : "R" })] }, mode))) }), _jsx("div", { className: "h-5 w-px bg-white/[0.07]" }), _jsxs("div", { className: "relative", children: [_jsx("button", { type: "button", className: `rounded-md px-2.5 py-1 transition-colors ${addOpen || placement !== null ? "bg-cyan-500/90 text-white shadow-sm shadow-cyan-950/50" : "bg-white/[0.04] ring-1 ring-inset ring-white/[0.06] hover:bg-white/10"}`, onClick: () => setAddOpen((value) => !value), children: "+ Add" }), addOpen ? (_jsxs("div", { className: "absolute left-0 top-9 z-50 max-h-[60vh] w-56 overflow-auto rounded-xl border border-white/10 bg-[#111318]/95 p-1.5 shadow-2xl shadow-black/60 backdrop-blur-md", children: [_jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO}`, children: "Markers" }), WELL_KNOWN_MARKER_KINDS.map((kind) => (_jsx("button", { type: "button", className: "block w-full rounded-md px-2 py-1 text-left text-neutral-300 transition-colors hover:bg-cyan-500/15 hover:text-cyan-100", onClick: () => startPlacement({ tool: "marker", kind }), children: kind }, kind))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO}`, children: "Volumes" }), ADD_VOLUME_ENTRIES.map((entry) => (_jsx("button", { type: "button", className: "block w-full rounded-md px-2 py-1 text-left text-neutral-300 transition-colors hover:bg-cyan-500/15 hover:text-cyan-100", onClick: () => startPlacement(entry.tool), children: entry.label }, entry.label))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO}`, children: "Studios" }), studioKinds.map((definition) => (_jsxs("button", { type: "button", className: "block w-full rounded-md px-2 py-1 text-left transition-colors hover:bg-emerald-500/15 hover:text-emerald-100", style: { color: definition.accent ?? "#34d399" }, onClick: () => startPlacement(definition.target === "path"
|
|
531
|
+
? { tool: "path", kind: definition.kind }
|
|
532
|
+
: definition.target === "volume"
|
|
533
|
+
? { tool: "volume", kind: definition.kind, shape: "box" }
|
|
534
|
+
: { tool: "marker", kind: definition.kind }), children: [definition.label, definition.target === "path" ? (definition.pathShape === "line" ? " (draw line)" : " (lasso)") : ""] }, definition.kind))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO}`, children: "Other" }), _jsx("button", { type: "button", className: "block w-full rounded-md px-2 py-1 text-left text-neutral-300 transition-colors hover:bg-cyan-500/15 hover:text-cyan-100", onClick: () => startPlacement({ tool: "path", kind: "route" }), children: "Draw path (route)" }), _jsx("button", { type: "button", className: "block w-full rounded-md px-2 py-1 text-left text-neutral-300 transition-colors hover:bg-cyan-500/15 hover:text-cyan-100", onClick: () => startPlacement({ tool: "path", kind: "road" }), children: "Draw road" }), _jsx("button", { type: "button", className: "block w-full rounded-md px-2 py-1 text-left text-neutral-300 transition-colors hover:bg-cyan-500/15 hover:text-cyan-100", onClick: () => startPlacement({ tool: "note" }), children: "Note" })] })) : null] }), _jsxs("button", { type: "button", className: BTN, onClick: () => {
|
|
535
|
+
const at = SNAP_MODES.findIndex((entry) => entry.mode === uiState.snapMode);
|
|
536
|
+
ui.patch({ snapMode: SNAP_MODES[(at + 1) % SNAP_MODES.length].mode });
|
|
537
|
+
}, children: [SNAP_MODES.find((entry) => entry.mode === uiState.snapMode)?.label, uiState.snapMode === "grid" ? ` ${uiState.gridSize}` : ""] }), uiState.snapMode === "grid" ? (_jsx("button", { type: "button", className: BTN, onClick: () => ui.patch({ gridSize: uiState.gridSize >= 8 ? 0.5 : uiState.gridSize * 2 }), children: "\u00B1" })) : null, _jsx("button", { type: "button", className: `rounded-md px-2 py-1 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/15 ${uiState.showGrid ? "bg-white/10 text-neutral-200" : "bg-white/[0.03] text-neutral-500"}`, onClick: () => ui.patch({ showGrid: !uiState.showGrid }), children: "Grid G" }), _jsx("div", { className: "h-5 w-px bg-white/[0.07]" }), _jsx("button", { type: "button", className: BTN, onClick: () => api.handle({ method: "camera_frame" }), children: "Frame all" }), _jsx("button", { type: "button", className: `${BTN} disabled:opacity-40`, onClick: () => session.dispatch({ type: "undo" }), disabled: !session.canUndo(), children: "Undo" }), _jsx("button", { type: "button", className: `${BTN} disabled:opacity-40`, onClick: () => session.dispatch({ type: "redo" }), disabled: !session.canRedo(), children: "Redo" }), _jsx("button", { type: "button", className: BTN, onClick: () => showPanel("assets"), children: "Assets" }), _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [_jsx("button", { type: "button", className: BTN, onClick: () => setRightOpen((value) => !value), children: "Inspector" }), _jsx("button", { type: "button", className: `rounded-md px-2 py-1 ring-1 ring-inset transition-colors ${agentOpen
|
|
538
|
+
? "bg-violet-500/25 text-violet-100 ring-violet-400/40"
|
|
539
|
+
: "bg-white/[0.04] text-neutral-300 ring-white/[0.06] hover:bg-white/10 hover:text-neutral-100"}`, onClick: () => setAgentOpen((value) => !value), title: "Embedded agent panel \u2014 same RPC/undo as the GUI", children: "Agent" }), _jsx("button", { type: "button", className: BTN, onClick: () => api.setMode("walk"), children: "Walk" }), _jsx("button", { type: "button", className: "rounded-md bg-gradient-to-b from-emerald-500 to-emerald-600 px-3 py-1 font-semibold text-white shadow-md shadow-emerald-950/50 transition-colors hover:from-emerald-400 hover:to-emerald-500", onClick: () => api.setMode("play"), children: "\u25B6 Play" }), _jsx("button", { type: "button", className: BTN, onClick: () => importInputRef.current?.click(), children: "Import" }), _jsx("input", { ref: importInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: (event) => {
|
|
540
|
+
const file = event.target.files?.[0];
|
|
541
|
+
if (file !== undefined)
|
|
542
|
+
importFile(file);
|
|
543
|
+
event.target.value = "";
|
|
544
|
+
} }), docSave.available ? (_jsx("button", { type: "button", className: `rounded-md px-3 py-1 font-medium transition-colors ${docSave.saveState === "error"
|
|
545
|
+
? "bg-rose-600/90 text-white hover:bg-rose-500"
|
|
546
|
+
: docSave.dirty
|
|
547
|
+
? "bg-gradient-to-b from-cyan-400 to-cyan-600 text-white shadow-md shadow-cyan-950/50 hover:from-cyan-300 hover:to-cyan-500"
|
|
548
|
+
: "bg-white/[0.04] text-neutral-400 ring-1 ring-inset ring-white/[0.06] hover:bg-white/10"}`, onClick: docSave.doSave, disabled: docSave.saveState === "saving", title: docSave.saveError ?? "Write the scene to Games/<id>/src/editor.scene.json (Ctrl+S)", children: docSave.saveState === "saving"
|
|
549
|
+
? "Saving…"
|
|
550
|
+
: docSave.saveState === "error"
|
|
551
|
+
? "Save failed"
|
|
552
|
+
: docSave.dirty
|
|
553
|
+
? "Save"
|
|
554
|
+
: "Saved ✓" })) : null, _jsx("button", { type: "button", className: "rounded-md bg-cyan-500/20 px-2.5 py-1 font-medium text-cyan-200 ring-1 ring-inset ring-cyan-400/30 transition-colors hover:bg-cyan-500/30", onClick: () => { downloadText(`${gameId}-editor.json`, session.exportJson(true)); notify(`Exported ${gameId}-editor.json`); }, children: "Export" }), _jsx("button", { type: "button", className: BTN, title: "Copy document JSON to clipboard", onClick: copyExportJson, children: "\u29C9" }), _jsx("button", { type: "button", className: `rounded-md px-2 py-1 ring-1 ring-inset ring-white/[0.06] transition-colors ${helpOpen ? "bg-white/15 text-neutral-100" : "bg-white/[0.04] hover:bg-white/10"}`, title: "Keyboard shortcuts (?)", onClick: () => setHelpOpen((value) => !value), children: "?" })] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [leftOpen ? (_jsxs("aside", { className: "pointer-events-auto flex w-72 min-w-56 max-w-[42vw] resize-x flex-col overflow-hidden border-r border-white/[0.08] bg-[#0d0f13]/95 backdrop-blur-md", children: [_jsxs("div", { className: "flex items-center gap-1 border-b border-white/[0.08] p-2", children: [_jsx("button", { type: "button", className: `rounded-md px-2 py-1 font-medium transition-colors ${activePanel === "outliner" ? "bg-white/10 text-neutral-100" : "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"}`, onClick: () => showPanel("outliner"), children: "Hierarchy" }), _jsxs("button", { type: "button", className: `rounded-md px-2 py-1 font-medium transition-colors ${activePanel === "collections" ? "bg-white/10 text-neutral-100" : "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"}`, onClick: () => showPanel("collections"), children: ["Sets ", state.document.collections.length] }), _jsxs("button", { type: "button", className: `rounded-md px-2 py-1 font-medium transition-colors ${activePanel === "prefabs" ? "bg-white/10 text-neutral-100" : "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"}`, onClick: () => showPanel("prefabs"), children: ["Prefabs ", state.document.prefabs.length] }), _jsxs("button", { type: "button", className: `rounded-md px-2 py-1 font-medium transition-colors ${activePanel === "catalogs" ? "bg-white/10 text-neutral-100" : "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"}`, onClick: () => showPanel("catalogs"), children: ["Data ", api.getCatalogDefinitions().length] }), _jsxs("button", { type: "button", className: "rounded-md px-2 py-1 text-neutral-400 transition-colors hover:bg-white/[0.06] hover:text-neutral-200", onClick: () => showPanel("assets"), children: ["Assets ", assets.length] }), _jsx("button", { type: "button", className: "ml-auto rounded-md px-2 py-1 text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-200", onClick: () => setLeftOpen(false), "aria-label": "Close hierarchy panel", children: "\u00D7" })] }), activePanel === "collections" ? (_jsx(CollectionsPanel, { session: session })) : activePanel === "prefabs" ? (_jsx(PrefabsPanel, { session: session, api: api })) : activePanel === "catalogs" ? (_jsx(CatalogsPanel, { session: session, definitions: api.getCatalogDefinitions() })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "border-b border-white/[0.08] p-2", children: [_jsx("div", { className: `mb-1.5 ${MICRO}`, children: "Layers" }), _jsxs("div", { className: "flex max-h-24 flex-wrap gap-1 overflow-auto", children: [allKinds.map((kind) => (_jsxs("label", { className: `cursor-pointer rounded-full px-2.5 py-0.5 ring-1 ring-inset transition-colors ${visibility[kind] !== false ? "bg-cyan-500/15 text-cyan-200 ring-cyan-400/25" : "bg-black/30 text-neutral-600 ring-white/[0.06] hover:text-neutral-400"}`, children: [_jsx("input", { type: "checkbox", className: "sr-only", checked: visibility[kind] !== false, onChange: (event) => { api.setVisibility({ ...api.getVisibility(), [kind]: event.target.checked }); setTick((value) => value + 1); } }), kind] }, kind))), allKinds.length === 0 ? _jsx("span", { className: "text-neutral-600", children: "No authored layers" }) : null] })] }), _jsx(OutlinerPanel, { session: session, api: api })] }))] })) : null, _jsxs("main", { className: "pointer-events-none relative min-w-0 flex-1", children: [uiState.tool === "terrain" ? _jsx(TerrainPanel, { session: session, ui: ui }) : null, placementHint !== null ? (_jsx("div", { className: "absolute left-1/2 top-2 -translate-x-1/2 whitespace-nowrap rounded-full border border-cyan-400/30 bg-cyan-950/85 px-4 py-1.5 text-[11px] text-cyan-100 shadow-lg shadow-cyan-950/40 backdrop-blur-md", children: placementHint })) : null, toast !== null ? (_jsx("div", { className: `absolute left-1/2 top-10 -translate-x-1/2 whitespace-nowrap rounded-full border px-4 py-1.5 text-[11px] shadow-lg backdrop-blur-md ${toast.tone === "error" ? "border-rose-400/40 bg-rose-950/90 text-rose-100 shadow-rose-950/40" : "border-emerald-400/30 bg-emerald-950/90 text-emerald-100 shadow-emerald-950/40"}`, children: toast.text })) : null, helpOpen ? (_jsx("div", { className: "pointer-events-auto absolute inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-[2px]", onClick: () => setHelpOpen(false), children: _jsxs("div", { className: "w-[28rem] max-w-[90vw] rounded-2xl border border-white/10 bg-[#101216]/95 p-5 shadow-2xl shadow-black/60", onClick: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "mb-3 flex items-center", children: [_jsx("div", { className: "text-sm font-semibold tracking-wide text-neutral-100", children: "Keyboard shortcuts" }), _jsx("button", { type: "button", className: "ml-auto rounded-md px-2 py-1 text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-200", onClick: () => setHelpOpen(false), "aria-label": "Close shortcuts", children: "\u00D7" })] }), _jsx("div", { className: "grid max-h-[60vh] grid-cols-[auto_1fr] items-center gap-x-4 gap-y-1.5 overflow-auto", children: SHORTCUTS.map((entry) => (_jsxs("div", { className: "contents", children: [_jsx("kbd", { className: "justify-self-start whitespace-nowrap rounded-md bg-white/[0.06] px-1.5 py-0.5 font-mono text-[10px] text-cyan-200 ring-1 ring-inset ring-white/10", children: entry.keys }), _jsx("span", { className: "text-neutral-300", children: entry.action })] }, entry.keys))) })] }) })) : null, uiState.contextMenu !== null ? (_jsx(EditorContextMenu, { x: uiState.contextMenu.clientX, y: uiState.contextMenu.clientY, actions: buildEditorContextMenu({
|
|
555
|
+
hitId: uiState.contextMenu.hitId,
|
|
556
|
+
selection: state.selection,
|
|
557
|
+
canPaste: clipboardFragment !== null,
|
|
558
|
+
}), onPick: runContextAction, onClose: closeContextMenu })) : null, _jsxs("div", { className: "absolute bottom-2 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full border border-white/[0.08] bg-black/70 px-4 py-1.5 text-[11px] text-neutral-400 shadow-lg shadow-black/40 backdrop-blur-md", children: [_jsx("span", { children: "RMB orbit \u00B7 MMB pan \u00B7 click select \u00B7 RMB menu \u00B7 W/E/R \u00B7 Ctrl+C/V \u00B7 F frame \u00B7 ? help" }), _jsxs("span", { className: "ml-3 text-neutral-500", children: [sceneStats.objects, " objs", sceneStats.foliage > 0 ? ` · ≈${formatTriangles(sceneStats.foliage)} foliage` : ""] }), perf !== null ? _jsxs("span", { className: `ml-3 ${perf.fps < 30 ? "text-rose-400" : "text-emerald-400"}`, children: [perf.fps.toFixed(0), " fps \u00B7 ", perf.drawCalls, " draws \u00B7 ", formatTriangles(perf.triangles), " tris"] }) : null] })] }), rightOpen ? _jsx(InspectorPanel, { session: session, ui: ui, onClose: () => setRightOpen(false) }) : null, agentOpen ? _jsx(AgentPanel, { api: api, onClose: () => setAgentOpen(false) }) : null] }), bottomOpen ? (_jsxs("section", { className: "pointer-events-auto flex h-64 min-h-40 max-h-[55vh] resize-y flex-col overflow-hidden border-t border-white/[0.08] bg-[#0d0f13]/95 backdrop-blur-md", children: [_jsxs("div", { className: "flex items-center border-b border-white/[0.08] px-3 py-2", children: [_jsx("div", { className: MICRO, children: "Asset browser" }), _jsxs("span", { className: "ml-2 text-neutral-600", children: [assets.length, " assets \u00B7 Ctrl+B"] }), _jsx("button", { type: "button", className: "ml-auto rounded-md px-2 py-1 text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-200", onClick: () => setBottomOpen(false), "aria-label": "Close asset browser", children: "\u00D7" })] }), _jsx("div", { className: "min-h-0 flex-1 overflow-hidden p-2", children: _jsx(AssetBrowser, { assets: assets, session: session, onPlace: placeAsset }) })] })) : null] }));
|
|
559
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { EditorContextAction } from "./viewportContextMenu.js";
|
|
2
|
+
/**
|
|
3
|
+
* @internal Headless chrome-skinned viewport context menu (#866). Parent owns placement and action dispatch.
|
|
4
|
+
*/
|
|
5
|
+
export declare function EditorContextMenu({ x, y, actions, onPick, onClose, }: {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
actions: readonly EditorContextAction[];
|
|
9
|
+
onPick: (action: EditorContextAction) => void;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
}): import("react").JSX.Element;
|