@jgengine/editor 0.19.0 → 0.21.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +346 -0
  2. package/dist/ClipPreviewLayer.d.ts +2 -3
  3. package/dist/ClipPreviewLayer.js +10 -28
  4. package/dist/CreatorApp.d.ts +12 -0
  5. package/dist/CreatorApp.js +107 -0
  6. package/dist/DebugDraw.js +3 -1
  7. package/dist/EditorApp.d.ts +14 -2
  8. package/dist/EditorApp.js +29 -14
  9. package/dist/EditorChrome.d.ts +9 -1
  10. package/dist/EditorChrome.js +54 -35
  11. package/dist/InspectorPanel.d.ts +3 -1
  12. package/dist/InspectorPanel.js +12 -8
  13. package/dist/MaterialAssetEditor.d.ts +13 -0
  14. package/dist/MaterialAssetEditor.js +109 -0
  15. package/dist/MaterialPreview.d.ts +16 -0
  16. package/dist/MaterialPreview.js +41 -0
  17. package/dist/MaterialsWorkspacePanel.d.ts +14 -2
  18. package/dist/MaterialsWorkspacePanel.js +78 -4
  19. package/dist/RuntimePlayBridge.js +1 -6
  20. package/dist/SelectionGizmo.js +23 -14
  21. package/dist/SimulationInspector.d.ts +9 -0
  22. package/dist/SimulationInspector.js +47 -0
  23. package/dist/TerrainSculpt.js +3 -1
  24. package/dist/agent/toolBridge.js +2 -0
  25. package/dist/authoringPlacement.d.ts +21 -0
  26. package/dist/authoringPlacement.js +34 -0
  27. package/dist/handlers/context.d.ts +1 -0
  28. package/dist/handlers/document.d.ts +1 -1
  29. package/dist/handlers/document.js +29 -1
  30. package/dist/handlers/hierarchy.d.ts +1 -1
  31. package/dist/handlers/hierarchy.js +4 -0
  32. package/dist/handlers/runtime.js +20 -17
  33. package/dist/handlers/terrain.js +52 -12
  34. package/dist/index.d.ts +2 -1
  35. package/dist/index.js +1 -0
  36. package/dist/materialControls.d.ts +32 -0
  37. package/dist/materialControls.js +79 -0
  38. package/dist/mcp/fieldSpec.d.ts +1 -1
  39. package/dist/mcp/fieldSpec.js +11 -2
  40. package/dist/mcp/importOptionalGameModule.d.ts +2 -0
  41. package/dist/mcp/importOptionalGameModule.js +13 -0
  42. package/dist/mcp/loadGameCatalogs.js +5 -12
  43. package/dist/mcp/rpcRequest.d.ts +3 -3
  44. package/dist/mcp/rpcRequest.js +10 -4
  45. package/dist/mcp/tools.js +10 -1
  46. package/dist/modelAnimationAuthoring.d.ts +27 -7
  47. package/dist/modelAnimationAuthoring.js +69 -72
  48. package/dist/session.d.ts +62 -0
  49. package/dist/session.js +56 -7
  50. package/dist/shell/AnimationGraphPanel.js +18 -10
  51. package/dist/shell/PlayModeBar.d.ts +2 -1
  52. package/dist/shell/PlayModeBar.js +2 -2
  53. package/dist/shell/SceneToolbar.d.ts +2 -1
  54. package/dist/shell/SceneToolbar.js +5 -4
  55. package/dist/shell/TopAppBar.d.ts +2 -1
  56. package/dist/shell/TopAppBar.js +2 -2
  57. package/dist/shell/animGraphPreview.d.ts +2 -0
  58. package/dist/shell/animGraphPreview.js +5 -2
  59. package/dist/shell/clipPreview.d.ts +1 -0
  60. package/dist/useDocumentSave.d.ts +20 -0
  61. package/dist/useDocumentSave.js +33 -0
  62. package/package.json +6 -5
  63. package/skills/ce-handoff/SKILL.md +1 -1
  64. package/skills/jgengine/SKILL.md +22 -2
  65. package/skills/jgengine/api.md +28 -17
  66. package/skills/jgengine/recipes/minimal-game.md +7 -2
  67. package/skills/jgengine-editor/SKILL.md +9 -1
  68. package/skills/jgengine-editor/api.md +102 -16
  69. package/skills/jgengine-editor/capabilities.md +40 -0
  70. package/skills/jgengine-editor/reference.md +149 -1
  71. package/skills/jgengine-editor/references/live-sync.md +15 -0
package/dist/session.d.ts CHANGED
@@ -1,7 +1,11 @@
1
+ import type { EditorSimulation } from "@jgengine/core/editor/simulation";
2
+ import type { MaterialAsset, MaterialSelector } from "@jgengine/core/material/materialAsset";
3
+ import type { StaticPrefabBake } from "@jgengine/core/editor/staticPrefab";
1
4
  import { type DocumentLiveSync, type DocumentPatch, type EditorCatalogDefinition, type EditorCommand, type EditorDocument, type EditorGridPaletteEntry, type EditorKindVisibility, type EditorLayersInput, type EditorSession, type EditorSessionState, type RuntimeEntityState, type RuntimePlayControl } from "@jgengine/core/editor/index";
2
5
  import type { TerraformMode, TerrainMaterialLayer } from "@jgengine/core/world/terraform";
3
6
  import type { ParamSchema } from "@jgengine/core/scene/sceneKinds";
4
7
  import type { TerrainField } from "@jgengine/core/world/terrain";
8
+ import type { ModelMaterialSlotInfo } from "@jgengine/shell/render/materialAsset";
5
9
  /** How the editor hosts the game: frozen placement view, roamable world, the real game, or HUD-layout authoring. */
6
10
  export type EditorRunMode = "edit" | "walk" | "play" | "hud";
7
11
  /** The accepted run modes, in canonical order — the runtime guard behind {@link EditorRunMode}. @internal */
@@ -152,6 +156,11 @@ export type EditorBridgeRequest = {
152
156
  height?: number;
153
157
  } | {
154
158
  method: "scene_summary";
159
+ } | {
160
+ method: "get_simulation";
161
+ } | {
162
+ method: "set_simulation";
163
+ simulation: EditorSimulation | null;
155
164
  } | {
156
165
  method: "export_document";
157
166
  } | {
@@ -294,6 +303,10 @@ export type EditorBridgeRequest = {
294
303
  } | {
295
304
  method: "delete_prefab";
296
305
  prefabId: string;
306
+ } | {
307
+ method: "set_prefab_static_bake";
308
+ prefabId: string;
309
+ bake: StaticPrefabBake | null;
297
310
  } | {
298
311
  method: "list_collections";
299
312
  } | {
@@ -340,6 +353,26 @@ export type EditorBridgeRequest = {
340
353
  color?: string;
341
354
  label?: string;
342
355
  meta?: Record<string, unknown>;
356
+ } | {
357
+ method: "list_material_assets";
358
+ } | {
359
+ method: "list_material_slots";
360
+ id: string;
361
+ } | {
362
+ method: "upsert_material_asset";
363
+ asset: MaterialAsset;
364
+ coalesce?: string;
365
+ } | {
366
+ method: "remove_material_asset";
367
+ id: string;
368
+ } | {
369
+ method: "assign_material_asset";
370
+ ids: string[];
371
+ materialId: string;
372
+ selector: MaterialSelector;
373
+ } | {
374
+ method: "clear_material_assets";
375
+ ids: string[];
343
376
  } | {
344
377
  method: "assign_material";
345
378
  ids: string[];
@@ -498,6 +531,25 @@ export interface EditorAssetInfo {
498
531
  kind: string;
499
532
  url?: string;
500
533
  }
534
+ /** Canonical imported slot names and mesh prerequisites reported by the loaded model. */
535
+ export type EditorMaterialSlotInfo = Readonly<Omit<ModelMaterialSlotInfo, "uvSets">> & {
536
+ readonly uvSets: readonly number[];
537
+ };
538
+ /** A live model-source observation; loading and unavailable reports never contain fabricated slots. */
539
+ export type EditorMaterialSlotReport = {
540
+ status: "ready";
541
+ slots: readonly EditorMaterialSlotInfo[];
542
+ } | {
543
+ status: "loading";
544
+ } | {
545
+ status: "unavailable";
546
+ reason: string;
547
+ };
548
+ /** A marker's current imported-model inventory, including an explicit availability state. */
549
+ export type EditorMaterialSlotInventory = EditorMaterialSlotReport & {
550
+ id: string;
551
+ sourceUrl?: string;
552
+ };
501
553
  /** Rolling frame-rate sample published by the in-canvas PerfProbe. */
502
554
  export interface EditorPerfSample {
503
555
  /**
@@ -575,6 +627,10 @@ export interface EditorHostApi {
575
627
  subscribeFocus(listener: (target: EditorFocusTarget | null) => void): () => void;
576
628
  getAssets(): readonly EditorAssetInfo[];
577
629
  setAssets(assets: readonly EditorAssetInfo[]): void;
630
+ /** Read observed slot data for the marker's current model URL; headless or unloaded sources are unavailable. */
631
+ getMaterialSlots(id: string): EditorMaterialSlotInventory;
632
+ /** Publish a loaded-model observation only while its marker still resolves to this source URL. */
633
+ reportMaterialSlots(id: string, sourceUrl: string, report: EditorMaterialSlotReport): boolean;
578
634
  getCatalogDefinitions(): readonly EditorCatalogDefinition[];
579
635
  getPerf(): EditorPerfSample | null;
580
636
  setPerf(sample: EditorPerfSample): void;
@@ -586,6 +642,8 @@ export interface EditorHostApi {
586
642
  getTerrainSampler(): TerrainField | null;
587
643
  setTerrainSampler(field: TerrainField | null): void;
588
644
  getMode(): EditorRunMode;
645
+ /** Immutable authored snapshot captured on entering an isolated playtest. */
646
+ getPlayDocument(): EditorDocument | null;
589
647
  setMode(mode: EditorRunMode): void;
590
648
  subscribeMode(listener: (mode: EditorRunMode) => void): () => void;
591
649
  /** Play-mode pause/step gate consumed by the runtime publisher. */
@@ -611,6 +669,10 @@ export declare function createEditorHost(options: {
611
669
  y: number;
612
670
  z: number;
613
671
  } | null) => void;
672
+ validateDocument?: (document: EditorDocument) => void;
673
+ isolatedPlaytest?: boolean;
674
+ publishGlobal?: boolean;
675
+ persistAssetUrls?: boolean;
614
676
  }): {
615
677
  session: EditorSession;
616
678
  api: EditorHostApi;
package/dist/session.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createDocumentLiveSync, createEditorSession, createRuntimePlayControl, installDocumentLiveSync, listEditorKinds, normalizeEditorLayers, resolveCatalogDefinitions, seedEditorCatalogs, } from "@jgengine/core/editor/index";
1
+ import { createDocumentLiveSync, cloneEditorDocument, createEditorSession, createRuntimePlayControl, installDocumentLiveSync, listEditorKinds, normalizeEditorLayers, resolveCatalogDefinitions, seedEditorCatalogs, } from "@jgengine/core/editor/index";
2
2
  import { registerBuiltinSceneKinds } from "@jgengine/core/scene/builtinSceneKinds";
3
3
  import { EDITOR_RPC_HANDLERS } from "./handlers/index.js";
4
4
  import { warmNavBake } from "./handlers/navmesh.js";
@@ -26,9 +26,16 @@ export function createEditorHost(options) {
26
26
  warmNavBake();
27
27
  const catalogDefinitions = options.catalogs ?? [];
28
28
  const document = seedEditorCatalogs(normalizeEditorLayers(options.layers), catalogDefinitions);
29
- const session = createEditorSession(document);
29
+ let mode = "edit";
30
+ const session = createEditorSession(document, 100, (candidate) => {
31
+ if (options.isolatedPlaytest && mode === "play")
32
+ throw new Error("Return to editor before changing the authored scene");
33
+ options.validateDocument?.(candidate);
34
+ });
30
35
  const liveSync = createDocumentLiveSync(document);
31
- const uninstallLiveSync = installDocumentLiveSync(liveSync);
36
+ let uninstallLiveSync = installDocumentLiveSync(liveSync);
37
+ let playDocument = null;
38
+ let playSync = null;
32
39
  let lastMirroredDocument = session.getState().document;
33
40
  const unsubscribeSessionMirror = session.subscribe((state) => {
34
41
  if (state.document === lastMirroredDocument)
@@ -39,9 +46,17 @@ export function createEditorHost(options) {
39
46
  let visibility = {};
40
47
  let focusTarget = null;
41
48
  let assets = [...(options.assets ?? [])];
49
+ const materialSlotReports = new Map();
50
+ const materialSource = (id) => {
51
+ const marker = session.getState().document.markers.find((item) => item.id === id);
52
+ if (!marker)
53
+ return { reason: `No authored model marker ${id}.` };
54
+ const catalogId = marker.catalogId ?? marker.meta?.catalogId;
55
+ const sourceUrl = assets.find((asset) => asset.id === catalogId)?.url;
56
+ return sourceUrl ? { sourceUrl } : { reason: `Marker ${id} has no URL-backed catalog model.` };
57
+ };
42
58
  let perf = null;
43
59
  let terrainSampler = null;
44
- let mode = "edit";
45
60
  let playControl = createRuntimePlayControl(false);
46
61
  const visibilityListeners = new Set();
47
62
  const focusListeners = new Set();
@@ -72,7 +87,7 @@ export function createEditorHost(options) {
72
87
  const api = {
73
88
  gameId: options.gameId,
74
89
  getSession: () => session,
75
- getLiveSync: () => liveSync,
90
+ getLiveSync: () => playSync ?? liveSync,
76
91
  getVisibility: () => visibility,
77
92
  setVisibility(next) {
78
93
  visibility = { ...next };
@@ -101,6 +116,31 @@ export function createEditorHost(options) {
101
116
  getAssets: () => assets,
102
117
  setAssets(next) {
103
118
  assets = [...next];
119
+ for (const [id, inventory] of materialSlotReports) {
120
+ if (materialSource(id).sourceUrl !== inventory.sourceUrl)
121
+ materialSlotReports.delete(id);
122
+ }
123
+ },
124
+ getMaterialSlots(id) {
125
+ const source = materialSource(id);
126
+ const inventory = materialSlotReports.get(id);
127
+ if (source.sourceUrl === undefined || inventory?.sourceUrl !== source.sourceUrl) {
128
+ materialSlotReports.delete(id);
129
+ return { id, ...source, status: "unavailable", reason: source.reason ?? "This model has no loaded slot inventory. Select it and open the Materials workspace." };
130
+ }
131
+ return structuredClone(inventory);
132
+ },
133
+ reportMaterialSlots(id, sourceUrl, report) {
134
+ if (materialSource(id).sourceUrl !== sourceUrl)
135
+ return false;
136
+ const previous = materialSlotReports.get(id);
137
+ if (previous?.sourceUrl === sourceUrl && previous.status !== "loading" && report.status === "loading")
138
+ return true;
139
+ materialSlotReports.delete(id);
140
+ materialSlotReports.set(id, structuredClone({ ...report, id, sourceUrl }));
141
+ if (materialSlotReports.size > 64)
142
+ materialSlotReports.delete(materialSlotReports.keys().next().value);
143
+ return true;
104
144
  },
105
145
  getCatalogDefinitions: () => resolveCatalogDefinitions(session.getState().document, catalogDefinitions),
106
146
  getPerf: () => perf,
@@ -112,9 +152,16 @@ export function createEditorHost(options) {
112
152
  terrainSampler = field;
113
153
  },
114
154
  getMode: () => mode,
155
+ getPlayDocument: () => playDocument === null ? null : cloneEditorDocument(playDocument),
115
156
  setMode(next) {
116
157
  if (next === mode)
117
158
  return;
159
+ if (options.isolatedPlaytest) {
160
+ uninstallLiveSync();
161
+ playDocument = next === "play" ? cloneEditorDocument(session.getState().document) : null;
162
+ playSync = playDocument === null ? null : createDocumentLiveSync(playDocument);
163
+ uninstallLiveSync = installDocumentLiveSync(playSync ?? liveSync);
164
+ }
118
165
  mode = next;
119
166
  if (next === "play") {
120
167
  playControl = createRuntimePlayControl(false);
@@ -157,7 +204,8 @@ export function createEditorHost(options) {
157
204
  const ctx = {
158
205
  api,
159
206
  session,
160
- liveSync,
207
+ liveSync: playSync ?? liveSync,
208
+ persistAssetUrls: options.persistAssetUrls,
161
209
  gameId: options.gameId,
162
210
  catalogDefinitions: defs,
163
211
  catalogById: new Map(defs.map((definition) => [definition.id, definition])),
@@ -181,8 +229,9 @@ export function createEditorHost(options) {
181
229
  }
182
230
  },
183
231
  };
184
- const uninstallHost = installEditorHost(api);
232
+ const uninstallHost = options.publishGlobal === false ? () => { } : installEditorHost(api);
185
233
  const dispose = () => {
234
+ materialSlotReports.clear();
186
235
  unsubscribeSessionMirror();
187
236
  uninstallLiveSync();
188
237
  uninstallHost();
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useRef, useState } from "react";
3
3
  import { markerCatalogId } from "@jgengine/core/world/authoredObjects";
4
- import { animationMetaPatch, clearAnimGraph, effectiveAnimGraph, readAnimationSetting, setTransitionDuration, storeAnimGraph, } from "../modelAnimationAuthoring.js";
4
+ import { animationMetaPatch, clearAnimGraph, effectiveAnimGraph, readAnimationSettingResult, setTransitionDuration, storeAnimGraph, } from "../modelAnimationAuthoring.js";
5
5
  import { shallowArrayEqual, useStoreSelector } from "../useStoreSelector.js";
6
6
  import { graphParamControls, graphTriggers, MAX_PREVIEW_SECONDS, recordTrigger, simulateGraphPreview, } from "./animGraphPreview.js";
7
7
  import { initialClipPreviewState } from "./clipPreview.js";
@@ -38,6 +38,8 @@ export function AnimationGraphPanel({ session, ui, rigged, }) {
38
38
  const marker = markers.find((entry) => entry.id === id);
39
39
  if (marker === undefined)
40
40
  continue;
41
+ if (readAnimationSettingResult({ markers }, marker.id).diagnostics.length > 0)
42
+ return { marker, asset: undefined };
41
43
  const catalogId = markerCatalogId(marker);
42
44
  const asset = catalogId === null ? undefined : rigged.find((entry) => entry.id === catalogId);
43
45
  if (asset?.url !== undefined && asset.clips !== undefined)
@@ -51,9 +53,11 @@ export function AnimationGraphPanel({ session, ui, rigged, }) {
51
53
  return _jsx(GraphInspector, { session: session, ui: ui, marker: target.marker, asset: target.asset }, target.marker.id);
52
54
  }
53
55
  function GraphInspector({ session, ui, marker, asset, }) {
54
- const clips = asset.clips ?? [];
55
- const setting = readAnimationSetting(marker.meta);
56
- const effective = useMemo(() => effectiveAnimGraph(setting, clips), [JSON.stringify(setting), clips]);
56
+ const clips = asset?.clips ?? [];
57
+ const document = useStoreSelector(session, (state) => state.document);
58
+ const { setting, diagnostics } = readAnimationSettingResult(document, marker.id);
59
+ const invalid = diagnostics.length > 0;
60
+ const effective = useMemo(() => invalid || asset === undefined ? null : effectiveAnimGraph(setting, clips), [JSON.stringify(setting), invalid, asset, clips]);
57
61
  const graph = effective?.graph ?? null;
58
62
  const controls = useMemo(() => (graph === null ? [] : graphParamControls(graph)), [graph]);
59
63
  const triggerNames = useMemo(() => (graph === null ? [] : graphTriggers(graph)), [graph]);
@@ -61,6 +65,8 @@ function GraphInspector({ session, ui, marker, asset, }) {
61
65
  const [playing, setPlaying] = useState(false);
62
66
  const [params, setParams] = useState({});
63
67
  const [triggers, setTriggers] = useState([]);
68
+ useEffect(() => { if (invalid)
69
+ setPlaying(false); }, [invalid]);
64
70
  const durations = useStoreSelector(ui, (state) => state.clipPreview?.clipDurations);
65
71
  const frame = useMemo(() => {
66
72
  if (graph === null)
@@ -71,7 +77,7 @@ function GraphInspector({ session, ui, marker, asset, }) {
71
77
  return simulateGraphPreview({ graph, durations: durations ?? {}, time, params: merged, triggers });
72
78
  }, [graph, controls, params, triggers, time, durations]);
73
79
  useEffect(() => {
74
- if (asset.url === undefined)
80
+ if (asset?.url === undefined)
75
81
  return;
76
82
  const current = ui.getState().clipPreview;
77
83
  const sameAsset = current?.source.assetId === asset.id;
@@ -87,18 +93,18 @@ function GraphInspector({ session, ui, marker, asset, }) {
87
93
  if (ui.getState().clipPreview?.source.assetId === asset.id)
88
94
  ui.patch({ clipPreview: null });
89
95
  };
90
- }, [asset.id, asset.url, asset.label, clips, ui]);
96
+ }, [asset?.id, asset?.url, asset?.label, clips, ui]);
91
97
  useEffect(() => {
92
98
  const current = ui.getState().clipPreview;
93
- if (current === null || current.source.assetId !== asset.id)
99
+ if (asset === undefined || current === null || current.source.assetId !== asset.id)
94
100
  return;
95
101
  if (graph === null || frame === null) {
96
102
  if (current.graphPose !== undefined)
97
103
  ui.patch({ clipPreview: { ...current, graphPose: undefined } });
98
104
  return;
99
105
  }
100
- ui.patch({ clipPreview: { ...current, graphPose: { graph, clips: frame.clips } } });
101
- }, [graph, frame, asset.id, ui, durations]);
106
+ ui.patch({ clipPreview: { ...current, graphPose: { graph, clips: frame.clips, ...(frame.rootMotion === true ? { rootMotion: true } : {}) } } });
107
+ }, [graph, frame, asset?.id, ui, durations]);
102
108
  const timeRef = useRef(time);
103
109
  timeRef.current = time;
104
110
  useEffect(() => {
@@ -121,7 +127,9 @@ function GraphInspector({ session, ui, marker, asset, }) {
121
127
  return () => cancelAnimationFrame(handle);
122
128
  }, [playing]);
123
129
  const commit = (next) => session.dispatch({ type: "setMarker", id: marker.id, patch: { meta: { ...marker.meta, ...animationMetaPatch(next) } } }, { coalesce: `animation:${marker.id}` });
124
- if (graph === null || effective === null || frame === null) {
130
+ if (invalid)
131
+ return (_jsxs("div", { className: "space-y-2 p-3 text-[11px]", children: [_jsxs("div", { role: "alert", "aria-label": "Animation diagnostics", className: "space-y-1 text-amber-200", children: [_jsx("p", { children: "The saved animation override is invalid. Its graph cannot be previewed or edited until repaired or replaced." }), diagnostics.map((entry) => _jsxs("div", { children: [_jsxs("div", { className: "break-all", children: [entry.path, ": ", entry.message] }), _jsx("div", { children: entry.repair })] }, `${entry.path}:${entry.message}`))] }), _jsxs("div", { className: "flex gap-2", children: [_jsx("button", { type: "button", className: SMALL_BUTTON, onClick: () => commit(undefined), children: "Remove invalid animation override" }), _jsx("button", { type: "button", className: SMALL_BUTTON, onClick: () => commit("auto"), children: "Replace invalid override with Auto" })] }), _jsx("p", { className: "text-neutral-500", children: "Replacement discards the invalid override and can be undone. Repair its saved fields through the existing marker metadata API to retain the intended graph." })] }));
132
+ if (graph === null || effective === null || frame === null || asset === undefined) {
125
133
  return (_jsx(EmptyState, { icon: "film", title: "No graph", badge: marker.label ?? marker.id, description: "This placement plays no graph: its animation is off, a single clip, or the rig has no idle clip. Pick Custom or Auto in the inspector's Animation section." }));
126
134
  }
127
135
  const weights = new Map(frame.clips.map((clip) => [`${clip.layer}:${clip.clip}`, clip.weight]));
@@ -12,8 +12,9 @@ export declare function usePlayControl(api: EditorHostApi): RuntimePlayControl;
12
12
  * controls (`runtime_pause` / `runtime_resume` / `runtime_step`); Exit returns to edit.
13
13
  * @internal
14
14
  */
15
- export declare function PlayModeBar({ gameId, api, onExit, }: {
15
+ export declare function PlayModeBar({ gameId, api, onExit, exitLabel, }: {
16
16
  gameId: string;
17
17
  api: EditorHostApi;
18
18
  onExit: () => void;
19
+ exitLabel?: string;
19
20
  }): import("react").JSX.Element;
@@ -19,7 +19,7 @@ export function usePlayControl(api) {
19
19
  * controls (`runtime_pause` / `runtime_resume` / `runtime_step`); Exit returns to edit.
20
20
  * @internal
21
21
  */
22
- export function PlayModeBar({ gameId, api, onExit, }) {
22
+ export function PlayModeBar({ gameId, api, onExit, exitLabel = "Editor", }) {
23
23
  const play = usePlayControl(api);
24
24
  const pause = () => {
25
25
  api.handle({ method: "runtime_pause" });
@@ -32,5 +32,5 @@ export function PlayModeBar({ gameId, api, onExit, }) {
32
32
  };
33
33
  return (_jsxs("header", { className: `pointer-events-auto relative z-[60] flex h-12 shrink-0 items-center gap-2 border-b ${BORDER} bg-[#111318] px-2.5`, role: "banner", "aria-label": "Play mode", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2.5", children: [_jsx("div", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px] bg-gradient-to-br from-emerald-500/30 to-cyan-500/25 text-[11px] font-bold tracking-wide text-emerald-200 ring-1 ring-inset ring-emerald-400/30", "aria-hidden": "true", children: "JG" }), _jsxs("div", { className: "min-w-0 leading-tight", children: [_jsx("div", { className: "truncate text-[13px] font-semibold text-neutral-100", children: gameId }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Play mode" })] }), _jsxs("span", { className: `ml-1 hidden items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] sm:flex ${play.paused
34
34
  ? "bg-amber-500/15 text-amber-200 ring-1 ring-inset ring-amber-400/25"
35
- : "bg-emerald-500/15 text-emerald-200 ring-1 ring-inset ring-emerald-400/25"}`, children: [_jsx("span", { className: `h-1.5 w-1.5 rounded-full ${play.paused ? "bg-amber-400" : "bg-emerald-400 animate-pulse"}` }), play.paused ? "Paused" : "Playing"] })] }), _jsxs("div", { className: "ml-auto flex shrink-0 items-center gap-1", children: [_jsx(IconButton, { icon: play.paused ? "play" : "pause", label: play.paused ? "Resume simulation" : "Pause simulation", onClick: () => (play.paused ? resume() : pause()), active: play.paused }), _jsx(IconButton, { icon: "step", label: "Step one frame (pause required)", onClick: step, disabled: !play.paused }), _jsx(ToolbarDivider, {}), _jsxs("button", { type: "button", onClick: onExit, title: "Return to the editor (F2+E)", className: `flex h-8 items-center gap-1.5 rounded-[6px] border border-white/[0.07] bg-[#191d24] px-3 text-[12px] font-medium text-neutral-200 transition-colors hover:bg-[#1f242d] ${FOCUS_RING}`, children: [_jsx(Icon, { name: "panel", size: 12 }), "Editor"] })] })] }));
35
+ : "bg-emerald-500/15 text-emerald-200 ring-1 ring-inset ring-emerald-400/25"}`, children: [_jsx("span", { className: `h-1.5 w-1.5 rounded-full ${play.paused ? "bg-amber-400" : "bg-emerald-400 animate-pulse"}` }), play.paused ? "Paused" : "Playing"] })] }), _jsxs("div", { className: "ml-auto flex shrink-0 items-center gap-1", children: [_jsx(IconButton, { icon: play.paused ? "play" : "pause", label: play.paused ? "Resume simulation" : "Pause simulation", onClick: () => (play.paused ? resume() : pause()), active: play.paused }), _jsx(IconButton, { icon: "step", label: "Step one frame (pause required)", onClick: step, disabled: !play.paused }), _jsx(ToolbarDivider, {}), _jsxs("button", { type: "button", onClick: onExit, title: "Return to the editor (F2+E)", className: `flex h-8 items-center gap-1.5 rounded-[6px] border border-white/[0.07] bg-[#191d24] px-3 text-[12px] font-medium text-neutral-200 transition-colors hover:bg-[#1f242d] ${FOCUS_RING}`, children: [_jsx(Icon, { name: "panel", size: 12 }), exitLabel] })] })] }));
36
36
  }
@@ -8,7 +8,7 @@ export declare const ADD_VOLUME_ENTRIES: readonly {
8
8
  * Contextual scene toolbar under the app bar: tools, gizmo modes, gizmo space, pivot,
9
9
  * snapping, viewport overlays, framing, projection, and the Add menu.
10
10
  */
11
- export declare function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode, gridSize, rotationSnapDeg, scaleSnap, cameraProjection, showGrid, showContours, showSurfaceGrid, showElevation, placementActive, onSetTool, onSetGizmoMode, onSetGizmoSpace, onSetGizmoPivot, onSetSnapMode, onSetGridSize, onSetRotationSnapDeg, onSetScaleSnap, onSetCameraProjection, onToggleGrid, onToggleContours, onToggleSurfaceGrid, onToggleElevation, onFrame, onStartPlacement, onOpenAssistant, assistantOpen, }: {
11
+ export declare function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode, gridSize, rotationSnapDeg, scaleSnap, cameraProjection, showGrid, showContours, showSurfaceGrid, showElevation, placementActive, onSetTool, onSetGizmoMode, onSetGizmoSpace, onSetGizmoPivot, onSetSnapMode, onSetGridSize, onSetRotationSnapDeg, onSetScaleSnap, onSetCameraProjection, onToggleGrid, onToggleContours, onToggleSurfaceGrid, onToggleElevation, onFrame, onStartPlacement, onOpenAssistant, assistantOpen, allowedKinds, }: {
12
12
  tool: EditorTool;
13
13
  gizmoMode: GizmoMode;
14
14
  gizmoSpace: GizmoSpace;
@@ -40,4 +40,5 @@ export declare function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot,
40
40
  onStartPlacement: (tool: PlacementTool) => void;
41
41
  onOpenAssistant: () => void;
42
42
  assistantOpen: boolean;
43
+ allowedKinds?: readonly string[];
43
44
  }): import("react").JSX.Element;
@@ -70,7 +70,7 @@ const PIVOT_LABEL = {
70
70
  * Contextual scene toolbar under the app bar: tools, gizmo modes, gizmo space, pivot,
71
71
  * snapping, viewport overlays, framing, projection, and the Add menu.
72
72
  */
73
- export function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode, gridSize, rotationSnapDeg, scaleSnap, cameraProjection, showGrid, showContours, showSurfaceGrid, showElevation, placementActive, onSetTool, onSetGizmoMode, onSetGizmoSpace, onSetGizmoPivot, onSetSnapMode, onSetGridSize, onSetRotationSnapDeg, onSetScaleSnap, onSetCameraProjection, onToggleGrid, onToggleContours, onToggleSurfaceGrid, onToggleElevation, onFrame, onStartPlacement, onOpenAssistant, assistantOpen, }) {
73
+ export function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode, gridSize, rotationSnapDeg, scaleSnap, cameraProjection, showGrid, showContours, showSurfaceGrid, showElevation, placementActive, onSetTool, onSetGizmoMode, onSetGizmoSpace, onSetGizmoPivot, onSetSnapMode, onSetGridSize, onSetRotationSnapDeg, onSetScaleSnap, onSetCameraProjection, onToggleGrid, onToggleContours, onToggleSurfaceGrid, onToggleElevation, onFrame, onStartPlacement, onOpenAssistant, assistantOpen, allowedKinds, }) {
74
74
  const [addOpen, setAddOpen] = useState(false);
75
75
  const [snapOpen, setSnapOpen] = useState(false);
76
76
  const [pivotOpen, setPivotOpen] = useState(false);
@@ -79,7 +79,8 @@ export function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode
79
79
  lastSnapRef.current = snapMode;
80
80
  // Recomputed every render (opening the menu rerenders): studios may register after the chrome
81
81
  // mounts (game modules load lazily), and a once-on-mount snapshot would silently hide them.
82
- const studioKinds = listSceneKinds().filter((definition) => definition.addCategory !== undefined);
82
+ const allowed = (kind) => allowedKinds === undefined || allowedKinds.includes(kind);
83
+ const studioKinds = listSceneKinds().filter((definition) => definition.addCategory !== undefined && allowed(definition.kind));
83
84
  const pick = (placement) => {
84
85
  setAddOpen(false);
85
86
  onStartPlacement(placement);
@@ -112,7 +113,7 @@ export function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode
112
113
  ? "border-cyan-400/30 bg-cyan-500/15 text-cyan-100"
113
114
  : "border-white/[0.07] bg-[#191d24] text-neutral-300 hover:bg-[#1f242d]"}`, children: [_jsx(Icon, { name: "camera", size: 12 }), cameraProjection === "perspective" ? "Persp" : "Ortho"] }), _jsx(ToolbarDivider, {}), _jsxs("div", { className: "relative", children: [_jsxs("button", { type: "button", onClick: () => setAddOpen((value) => !value), "aria-expanded": addOpen, className: `flex h-7 items-center gap-1.5 rounded-[5px] px-2.5 text-[12px] font-medium transition-colors ${FOCUS_RING} ${addOpen || placementActive
114
115
  ? "bg-cyan-600 text-white shadow-sm shadow-cyan-950/40"
115
- : "border border-white/[0.07] bg-[#191d24] text-neutral-200 hover:bg-[#1f242d]"}`, children: [_jsx(Icon, { name: "plus", size: 12 }), "Add"] }), _jsxs(MenuShell, { open: addOpen, onClose: () => setAddOpen(false), children: [_jsx("div", { className: `px-2 pb-1 pt-1.5 ${MICRO_LABEL}`, children: "Markers" }), WELL_KNOWN_MARKER_KINDS.map((kind) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "marker", kind }), children: kind }, kind))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Volumes" }), ADD_VOLUME_ENTRIES.map((entry) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick(entry.tool), children: entry.label }, entry.label))), [...studioKinds.reduce((groups, definition) => {
116
+ : "border border-white/[0.07] bg-[#191d24] text-neutral-200 hover:bg-[#1f242d]"}`, children: [_jsx(Icon, { name: "plus", size: 12 }), "Add"] }), _jsxs(MenuShell, { open: addOpen, onClose: () => setAddOpen(false), children: [_jsx("div", { className: `px-2 pb-1 pt-1.5 ${MICRO_LABEL}`, children: "Markers" }), WELL_KNOWN_MARKER_KINDS.filter(allowed).map((kind) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "marker", kind }), children: kind }, kind))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Volumes" }), ADD_VOLUME_ENTRIES.filter((entry) => ("kind" in entry.tool && allowed(entry.tool.kind))).map((entry) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick(entry.tool), children: entry.label }, entry.label))), [...studioKinds.reduce((groups, definition) => {
116
117
  const category = definition.addCategory ?? "Studios";
117
118
  (groups.get(category) ?? groups.set(category, []).get(category)).push(definition);
118
119
  return groups;
@@ -120,7 +121,7 @@ export function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode
120
121
  ? { tool: "path", kind: definition.kind }
121
122
  : definition.target === "volume"
122
123
  ? { tool: "volume", kind: definition.kind, shape: "box" }
123
- : { tool: "marker", kind: definition.kind }), children: [definition.label, definition.target === "path" ? (definition.pathShape === "line" ? " (draw line)" : " (lasso)") : ""] }, definition.kind)))] }, category))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Paths & notes" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "route" }), children: "Draw path (route)" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "road" }), children: "Draw road" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "note" }), children: "Note" })] })] }), _jsx("div", { className: "ml-auto flex items-center gap-1", children: _jsxs("button", { type: "button", onClick: onOpenAssistant, className: `flex h-7 items-center gap-1.5 rounded-[5px] px-2.5 text-[12px] transition-colors ${FOCUS_RING} ${assistantOpen
124
+ : { tool: "marker", kind: definition.kind }), children: [definition.label, definition.target === "path" ? (definition.pathShape === "line" ? " (draw line)" : " (lasso)") : ""] }, definition.kind)))] }, category))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Paths & notes" }), _jsx("button", { type: "button", disabled: !allowed("route"), className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "route" }), children: "Draw path (route)" }), _jsx("button", { type: "button", disabled: !allowed("road"), className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "road" }), children: "Draw road" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "note" }), children: "Note" })] })] }), _jsx("div", { className: "ml-auto flex items-center gap-1", children: _jsxs("button", { type: "button", onClick: onOpenAssistant, className: `flex h-7 items-center gap-1.5 rounded-[5px] px-2.5 text-[12px] transition-colors ${FOCUS_RING} ${assistantOpen
124
125
  ? "border border-violet-400/40 bg-violet-500/20 text-violet-100"
125
126
  : "border border-white/[0.07] bg-[#191d24] text-neutral-300 hover:bg-[#1f242d] hover:text-neutral-100"}`, title: "AI assistant \u2014 drives the scene through the same RPC and undo as the toolbar", children: [_jsx(Icon, { name: "sparkle", size: 13 }), "AI"] }) })] }));
126
127
  }
@@ -8,10 +8,11 @@ export type TopBarSaveState = "idle" | "saving" | "saved" | "error";
8
8
  * edit mode; Play mode mounts {@link PlayModeBar}, which wires the same controls to the
9
9
  * runtime pause/step RPCs so mode switches keep shell chrome.
10
10
  */
11
- export declare function TopAppBar({ gameId, onExitToGame, dirty, saveState, lastSavedAt, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }: {
11
+ export declare function TopAppBar({ gameId, onExitToGame, exitLabel, dirty, saveState, lastSavedAt, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }: {
12
12
  gameId: string;
13
13
  /** When set (editor summoned over a game), renders an "Exit to game" control that closes the editor. */
14
14
  onExitToGame?: () => void;
15
+ exitLabel?: string;
15
16
  dirty: boolean;
16
17
  saveState: TopBarSaveState;
17
18
  /** Epoch ms of the last successful save this session; omit/null when never saved. */
@@ -34,7 +34,7 @@ function SaveStatus({ dirty, saveState, lastSavedAt, }) {
34
34
  * edit mode; Play mode mounts {@link PlayModeBar}, which wires the same controls to the
35
35
  * runtime pause/step RPCs so mode switches keep shell chrome.
36
36
  */
37
- export function TopAppBar({ gameId, onExitToGame, dirty, saveState, lastSavedAt = null, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }) {
37
+ export function TopAppBar({ gameId, onExitToGame, exitLabel = "Exit to game", dirty, saveState, lastSavedAt = null, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }) {
38
38
  const [menuOpen, setMenuOpen] = useState(false);
39
39
  const menuRef = useRef(null);
40
40
  useEffect(() => {
@@ -56,7 +56,7 @@ export function TopAppBar({ gameId, onExitToGame, dirty, saveState, lastSavedAt
56
56
  };
57
57
  }, [menuOpen]);
58
58
  const menuItem = `flex w-full items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-[12px] text-neutral-300 transition-colors hover:bg-cyan-500/15 hover:text-cyan-100 ${FOCUS_RING}`;
59
- return (_jsxs("header", { className: `pointer-events-auto relative z-[60] flex h-12 shrink-0 items-center gap-2 border-b ${BORDER} bg-[#111318] px-2.5`, children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2.5", children: [onExitToGame !== undefined ? (_jsxs("button", { type: "button", onClick: onExitToGame, title: "Close the editor and return to the game (F2+Q)", className: `flex h-8 shrink-0 items-center gap-1.5 rounded-[6px] border border-white/[0.08] bg-black/30 pl-2 pr-2.5 text-[12px] text-neutral-300 transition-colors hover:border-white/[0.16] hover:bg-white/[0.06] hover:text-neutral-100 ${FOCUS_RING}`, children: [_jsx(Icon, { name: "chevronRight", size: 14, className: "rotate-180" }), "Exit to game"] })) : null, _jsx("div", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px] bg-gradient-to-br from-cyan-500/30 to-indigo-500/25 text-[11px] font-bold tracking-wide text-cyan-200 ring-1 ring-inset ring-cyan-400/30", "aria-hidden": "true", children: "JG" }), _jsxs("div", { className: "min-w-0 leading-tight", children: [_jsx("div", { className: "truncate text-[13px] font-semibold text-neutral-100", children: gameId }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Scene Editor" })] }), _jsx("div", { className: "ml-1 hidden md:block", children: _jsx(SaveStatus, { dirty: dirty, saveState: saveState, lastSavedAt: lastSavedAt }) })] }), _jsx("div", { className: "flex flex-1 justify-center px-2", children: _jsxs("button", { type: "button", onClick: onOpenPalette, "aria-label": "Open command palette", className: `flex h-8 w-full max-w-[420px] items-center gap-2 rounded-[6px] border border-white/[0.08] bg-black/30 px-3 text-[12px] text-neutral-500 transition-colors hover:border-white/[0.14] hover:text-neutral-400 ${FOCUS_RING}`, children: [_jsx(Icon, { name: "search", size: 13 }), _jsx("span", { className: "min-w-0 flex-1 truncate text-left", children: "Search or run a command\u2026" }), _jsxs("span", { className: "hidden items-center gap-0.5 sm:flex", children: [_jsx(Kbd, { children: "Ctrl" }), _jsx(Kbd, { children: "K" })] })] }) }), _jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [_jsx(IconButton, { icon: "undo", label: "Undo (Ctrl+Z)", onClick: onUndo, disabled: !canUndo, tone: "ghost" }), _jsx(IconButton, { icon: "redo", label: "Redo (Ctrl+Y)", onClick: onRedo, disabled: !canRedo, tone: "ghost" }), _jsx(ToolbarDivider, {}), _jsxs("button", { type: "button", onClick: onPlay, className: `flex h-8 items-center gap-1.5 rounded-[6px] bg-emerald-600 px-3.5 text-[12px] font-semibold text-white shadow-sm shadow-emerald-950/40 transition-colors hover:bg-emerald-500 ${FOCUS_RING}`, title: "Enter Play mode (F2+E toggles back)", children: [_jsx(Icon, { name: "play", size: 12 }), "Play"] }), _jsx(IconButton, { icon: "pause", label: "Pause \u2014 available in Play mode", disabled: true }), _jsx(IconButton, { icon: "step", label: "Step one frame \u2014 available in Play mode", disabled: true }), _jsx(ToolbarDivider, {}), _jsx(IconButton, { icon: "walk", label: "Walk the world", onClick: onWalk, tone: "ghost" }), _jsx(IconButton, { icon: "panel", label: "Lay out HUD panels", onClick: onHud, tone: "ghost" }), _jsx(ToolbarDivider, {}), saveAvailable ? (_jsxs("button", { type: "button", onClick: onSave, disabled: saveState === "saving", title: saveError ?? "Write the scene to the game's editor.scene.json (Ctrl+S)", className: `flex h-8 items-center gap-1.5 rounded-[6px] px-3 text-[12px] font-medium transition-colors ${FOCUS_RING} ${saveState === "error"
59
+ return (_jsxs("header", { className: `pointer-events-auto relative z-[60] flex h-12 shrink-0 items-center gap-2 border-b ${BORDER} bg-[#111318] px-2.5`, children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2.5", children: [onExitToGame !== undefined ? (_jsxs("button", { type: "button", onClick: onExitToGame, title: `${exitLabel} (F2+Q)`, className: `flex h-8 shrink-0 items-center gap-1.5 rounded-[6px] border border-white/[0.08] bg-black/30 pl-2 pr-2.5 text-[12px] text-neutral-300 transition-colors hover:border-white/[0.16] hover:bg-white/[0.06] hover:text-neutral-100 ${FOCUS_RING}`, children: [_jsx(Icon, { name: "chevronRight", size: 14, className: "rotate-180" }), exitLabel] })) : null, _jsx("div", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px] bg-gradient-to-br from-cyan-500/30 to-indigo-500/25 text-[11px] font-bold tracking-wide text-cyan-200 ring-1 ring-inset ring-cyan-400/30", "aria-hidden": "true", children: "JG" }), _jsxs("div", { className: "min-w-0 leading-tight", children: [_jsx("div", { className: "truncate text-[13px] font-semibold text-neutral-100", children: gameId }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Scene Editor" })] }), _jsx("div", { className: "ml-1 hidden md:block", children: _jsx(SaveStatus, { dirty: dirty, saveState: saveState, lastSavedAt: lastSavedAt }) })] }), _jsx("div", { className: "flex flex-1 justify-center px-2", children: _jsxs("button", { type: "button", onClick: onOpenPalette, "aria-label": "Open command palette", className: `flex h-8 w-full max-w-[420px] items-center gap-2 rounded-[6px] border border-white/[0.08] bg-black/30 px-3 text-[12px] text-neutral-500 transition-colors hover:border-white/[0.14] hover:text-neutral-400 ${FOCUS_RING}`, children: [_jsx(Icon, { name: "search", size: 13 }), _jsx("span", { className: "min-w-0 flex-1 truncate text-left", children: "Search or run a command\u2026" }), _jsxs("span", { className: "hidden items-center gap-0.5 sm:flex", children: [_jsx(Kbd, { children: "Ctrl" }), _jsx(Kbd, { children: "K" })] })] }) }), _jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [_jsx(IconButton, { icon: "undo", label: "Undo (Ctrl+Z)", onClick: onUndo, disabled: !canUndo, tone: "ghost" }), _jsx(IconButton, { icon: "redo", label: "Redo (Ctrl+Y)", onClick: onRedo, disabled: !canRedo, tone: "ghost" }), _jsx(ToolbarDivider, {}), _jsxs("button", { type: "button", onClick: onPlay, className: `flex h-8 items-center gap-1.5 rounded-[6px] bg-emerald-600 px-3.5 text-[12px] font-semibold text-white shadow-sm shadow-emerald-950/40 transition-colors hover:bg-emerald-500 ${FOCUS_RING}`, title: "Enter Play mode (F2+E toggles back)", children: [_jsx(Icon, { name: "play", size: 12 }), "Play"] }), _jsx(IconButton, { icon: "pause", label: "Pause \u2014 available in Play mode", disabled: true }), _jsx(IconButton, { icon: "step", label: "Step one frame \u2014 available in Play mode", disabled: true }), _jsx(ToolbarDivider, {}), _jsx(IconButton, { icon: "walk", label: "Walk the world", onClick: onWalk, tone: "ghost" }), _jsx(IconButton, { icon: "panel", label: "Lay out HUD panels", onClick: onHud, tone: "ghost" }), _jsx(ToolbarDivider, {}), saveAvailable ? (_jsxs("button", { type: "button", onClick: onSave, disabled: saveState === "saving", title: saveError ?? "Write the scene to the game's editor.scene.json (Ctrl+S)", className: `flex h-8 items-center gap-1.5 rounded-[6px] px-3 text-[12px] font-medium transition-colors ${FOCUS_RING} ${saveState === "error"
60
60
  ? "bg-rose-600/90 text-white hover:bg-rose-500"
61
61
  : dirty
62
62
  ? "bg-cyan-600 text-white shadow-sm shadow-cyan-950/40 hover:bg-cyan-500"
@@ -34,6 +34,8 @@ export interface GraphPreviewFrame {
34
34
  name: string;
35
35
  clip: string;
36
36
  }[];
37
+ /** Active root-motion playback stays in place in the editor, matching the collision-authoritative shell. */
38
+ rootMotion?: true;
37
39
  }
38
40
  /** Longest preview timeline the scrubber offers, in seconds. */
39
41
  export declare const MAX_PREVIEW_SECONDS = 20;
@@ -31,15 +31,18 @@ export function simulateGraphPreview(input) {
31
31
  const states = {};
32
32
  for (const layer of input.graph.layers)
33
33
  states[layer.id] = runtime.stateOf(layer.id) ?? layer.entry;
34
- return { time, states, clips: out.clips, events };
34
+ return { time, states, clips: out.clips, events, ...(out.rootMotion === true ? { rootMotion: true } : {}) };
35
35
  }
36
36
  /** Every clip name a graph plays. */
37
37
  export function graphClipNames(graph) {
38
38
  const names = new Set();
39
39
  for (const layer of graph.layers) {
40
40
  for (const state of Object.values(layer.states)) {
41
- if (state.kind === "clip")
41
+ if (state.kind === "clip") {
42
42
  names.add(state.clip);
43
+ for (const clip of state.variants ?? [])
44
+ names.add(clip);
45
+ }
43
46
  else
44
47
  for (const point of state.points)
45
48
  names.add(point.clip);
@@ -46,6 +46,7 @@ export interface ClipPreviewSession {
46
46
  readonly graphPose?: {
47
47
  readonly graph: AnimGraph;
48
48
  readonly clips: readonly AnimClipOutput[];
49
+ readonly rootMotion?: true;
49
50
  };
50
51
  }
51
52
  /** Playback state of the clip preview driver. `time` is seconds into the clip. */
@@ -0,0 +1,20 @@
1
+ import type { EditorSession } from "@jgengine/core/editor/commands";
2
+ /** Document save lifecycle owned by the mounted authoring session, across viewport modes. */
3
+ export interface EditorDocumentSave {
4
+ available: boolean;
5
+ dirty: boolean;
6
+ saveState: "idle" | "saving" | "saved" | "error";
7
+ saveError: string | null;
8
+ lastSavedAt: number | null;
9
+ doSave(): void;
10
+ }
11
+ /** Save status shared with editor chrome without changing the viewport component identity. @internal */
12
+ export declare const EditorDocumentSaveContext: import("react").Context<EditorDocumentSave | undefined>;
13
+ type SaveFn = (json: string) => Promise<{
14
+ ok: boolean;
15
+ path?: string;
16
+ error?: string;
17
+ }>;
18
+ /** Keep the acknowledged document and pending/error status alive while editor chrome unmounts. @internal */
19
+ export declare function useDocumentSave(session: EditorSession, save: SaveFn | undefined, onResult?: (ok: boolean, detail: string) => void): EditorDocumentSave;
20
+ export {};
@@ -0,0 +1,33 @@
1
+ import { createContext, useCallback, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
+ /** Save status shared with editor chrome without changing the viewport component identity. @internal */
3
+ export const EditorDocumentSaveContext = createContext(undefined);
4
+ /** Keep the acknowledged document and pending/error status alive while editor chrome unmounts. @internal */
5
+ export function useDocumentSave(session, save, onResult) {
6
+ const store = useMemo(() => ({ subscribe: (listener) => session.subscribe(listener), getSnapshot: () => session.getState().document }), [session]);
7
+ const document = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
8
+ const [status, setStatus] = useState(() => ({ document, state: "idle", error: null, at: null }));
9
+ const currentJson = useMemo(() => JSON.stringify(document), [document]);
10
+ const savedJson = useMemo(() => JSON.stringify(status.document), [status.document]);
11
+ const pending = useRef(false);
12
+ const result = useRef(onResult);
13
+ result.current = onResult;
14
+ const doSave = useCallback(() => {
15
+ if (save === undefined || pending.current)
16
+ return;
17
+ const savedDocument = session.getState().document;
18
+ const json = session.exportJson(true);
19
+ pending.current = true;
20
+ setStatus((previous) => ({ ...previous, state: "saving" }));
21
+ void Promise.resolve().then(() => save(json)).then((value) => {
22
+ if (!value.ok)
23
+ throw new Error(value.error ?? "save failed");
24
+ setStatus({ document: savedDocument, state: "saved", error: null, at: Date.now() });
25
+ result.current?.(true, value.path === undefined ? "Scene saved" : `Scene saved to ${value.path}`);
26
+ }).catch((failure) => {
27
+ const message = failure instanceof Error ? failure.message : String(failure);
28
+ setStatus((previous) => ({ ...previous, state: "error", error: message }));
29
+ result.current?.(false, `Save failed: ${message}`);
30
+ }).finally(() => { pending.current = false; });
31
+ }, [session, save]);
32
+ return { available: save !== undefined, dirty: currentJson !== savedJson, saveState: status.state, saveError: status.error, lastSavedAt: status.at, doSave };
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/editor",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Scene/world/asset editor for JGengine — loaded lazily by the runner; never import from game entrypoints.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -31,10 +31,10 @@
31
31
  "test": "bun test src"
32
32
  },
33
33
  "dependencies": {
34
- "@jgengine/core": "^0.19.0",
35
- "@jgengine/navbake": "^0.19.0",
36
- "@jgengine/react": "^0.19.0",
37
- "@jgengine/shell": "^0.19.0"
34
+ "@jgengine/core": "^0.21.0",
35
+ "@jgengine/navbake": "^0.21.0",
36
+ "@jgengine/react": "^0.21.0",
37
+ "@jgengine/shell": "^0.21.0"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@react-three/drei": "^10.0.0",
@@ -46,6 +46,7 @@
46
46
  "devDependencies": {
47
47
  "@react-three/drei": "^10.7.7",
48
48
  "@react-three/fiber": "^9.5.0",
49
+ "@types/node": "^24",
49
50
  "@types/react": "^19",
50
51
  "@types/react-dom": "^19",
51
52
  "@types/three": "^0.182.0",
@@ -25,7 +25,7 @@ Create one immutable handoff at the destination the user requested, or use the m
25
25
  ### Build the handoff
26
26
 
27
27
  1. Distill the current objective and the user's latest intent. If a focus was supplied, make it the `resume_focus`.
28
- 2. Inspect only the workspace state needed to explain what exists now. Use JGengine project instructions already in context (`AGENTS.md` / `CLAUDE.md`, `workflow`, domain skills).
28
+ 2. Inspect only the workspace state needed to explain what exists now. Use JGengine project instructions already in context (`AGENTS.md`, `workflow`, domain skills).
29
29
  3. Point to plans, issues, commits, diffs, documentation, and relevant files instead of reproducing their contents. Prefer repository-relative paths (`docs/plans/…`, `packages/…`, `Games/…`).
30
30
  4. Redact secrets, credentials, tokens, and unrelated personal information. Preserve operational paths only when the next agent needs them.
31
31
  5. Write or publish with existing tools. If the user named another path, folder, format, or publication destination, honor it. Do not also create a managed-store copy unless asked.