@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.
- package/CHANGELOG.md +346 -0
- package/dist/ClipPreviewLayer.d.ts +2 -3
- package/dist/ClipPreviewLayer.js +10 -28
- package/dist/CreatorApp.d.ts +12 -0
- package/dist/CreatorApp.js +107 -0
- package/dist/DebugDraw.js +3 -1
- package/dist/EditorApp.d.ts +14 -2
- package/dist/EditorApp.js +29 -14
- package/dist/EditorChrome.d.ts +9 -1
- package/dist/EditorChrome.js +54 -35
- package/dist/InspectorPanel.d.ts +3 -1
- package/dist/InspectorPanel.js +12 -8
- package/dist/MaterialAssetEditor.d.ts +13 -0
- package/dist/MaterialAssetEditor.js +109 -0
- package/dist/MaterialPreview.d.ts +16 -0
- package/dist/MaterialPreview.js +41 -0
- package/dist/MaterialsWorkspacePanel.d.ts +14 -2
- package/dist/MaterialsWorkspacePanel.js +78 -4
- package/dist/RuntimePlayBridge.js +1 -6
- package/dist/SelectionGizmo.js +23 -14
- package/dist/SimulationInspector.d.ts +9 -0
- package/dist/SimulationInspector.js +47 -0
- package/dist/TerrainSculpt.js +3 -1
- package/dist/agent/toolBridge.js +2 -0
- package/dist/authoringPlacement.d.ts +21 -0
- package/dist/authoringPlacement.js +34 -0
- package/dist/handlers/context.d.ts +1 -0
- package/dist/handlers/document.d.ts +1 -1
- package/dist/handlers/document.js +29 -1
- package/dist/handlers/hierarchy.d.ts +1 -1
- package/dist/handlers/hierarchy.js +4 -0
- package/dist/handlers/runtime.js +20 -17
- package/dist/handlers/terrain.js +52 -12
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/materialControls.d.ts +32 -0
- package/dist/materialControls.js +79 -0
- package/dist/mcp/fieldSpec.d.ts +1 -1
- package/dist/mcp/fieldSpec.js +11 -2
- package/dist/mcp/importOptionalGameModule.d.ts +2 -0
- package/dist/mcp/importOptionalGameModule.js +13 -0
- package/dist/mcp/loadGameCatalogs.js +5 -12
- package/dist/mcp/rpcRequest.d.ts +3 -3
- package/dist/mcp/rpcRequest.js +10 -4
- package/dist/mcp/tools.js +10 -1
- package/dist/modelAnimationAuthoring.d.ts +27 -7
- package/dist/modelAnimationAuthoring.js +69 -72
- package/dist/session.d.ts +62 -0
- package/dist/session.js +56 -7
- package/dist/shell/AnimationGraphPanel.js +18 -10
- package/dist/shell/PlayModeBar.d.ts +2 -1
- package/dist/shell/PlayModeBar.js +2 -2
- package/dist/shell/SceneToolbar.d.ts +2 -1
- package/dist/shell/SceneToolbar.js +5 -4
- package/dist/shell/TopAppBar.d.ts +2 -1
- package/dist/shell/TopAppBar.js +2 -2
- package/dist/shell/animGraphPreview.d.ts +2 -0
- package/dist/shell/animGraphPreview.js +5 -2
- package/dist/shell/clipPreview.d.ts +1 -0
- package/dist/useDocumentSave.d.ts +20 -0
- package/dist/useDocumentSave.js +33 -0
- package/package.json +6 -5
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/jgengine/SKILL.md +22 -2
- package/skills/jgengine/api.md +28 -17
- package/skills/jgengine/recipes/minimal-game.md +7 -2
- package/skills/jgengine-editor/SKILL.md +9 -1
- package/skills/jgengine-editor/api.md +102 -16
- package/skills/jgengine-editor/capabilities.md +40 -0
- package/skills/jgengine-editor/reference.md +149 -1
- 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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
55
|
-
const
|
|
56
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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 }),
|
|
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
|
|
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. */
|
package/dist/shell/TopAppBar.js
CHANGED
|
@@ -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:
|
|
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.
|
|
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.
|
|
35
|
-
"@jgengine/navbake": "^0.
|
|
36
|
-
"@jgengine/react": "^0.
|
|
37
|
-
"@jgengine/shell": "^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
|
|
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.
|