@jgengine/editor 0.13.0 → 0.14.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 CHANGED
@@ -10,7 +10,53 @@ let consumers pick up the better stuff, not just to list what moved.
10
10
  Agents building on the published SDK can also read this programmatically:
11
11
  `import { VERSION, CHANGELOG } from "@jgengine/core/meta/changelog"` gives the
12
12
  same data as typed values, so an updater can diff its installed version against
13
- the latest and surface the migration steps.
13
+ the latest and surface the migration steps. Easier still: `npx jgengine upgrade`
14
+ run inside a game project diffs the installed `@jgengine/*` versions against the
15
+ latest release and prints every Migrate step and Adopt-worthy addition in
16
+ between (`--json` for structured output).
17
+
18
+ ## [Unreleased]
19
+
20
+ <!--
21
+ Every PR that changes `packages/*/src` records its consumer-facing change here, so
22
+ the next release's notes are complete by construction. Add a bullet under the right
23
+ subhead below. `bun run check-changelog` fails a source PR whose `[Unreleased]` block
24
+ is untouched; pure refactors/tests bypass with `[skip changelog]` in a commit message.
25
+ At publish, rename this heading to the new version and mirror the entries into
26
+ `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
+ -->
28
+
29
+ ### Migrate
30
+
31
+ - _Nothing yet._
32
+
33
+ ### Added
34
+
35
+ ### Changed
36
+
37
+ ### Removed
38
+
39
+ ## 0.14.0
40
+
41
+ ### Migrate
42
+
43
+ - **Nothing required** — this release is additive drop-in primitives, editor authoring, CLI discovery, and fixes. Bump `@jgengine/*` pins to `^0.14.0` (CLI `jgengine` is 0.11.0; `@jgengine/github` unchanged at 0.1.0) and rebuild.
44
+ - One behavior fix to know about: in a `rig: "chase"` game, on-foot movement and aim are now camera-relative (the chase rig reports its yaw to the shell like every other rig). If a game somehow relied on world-yaw-0 movement under a chase camera, that was the bug this fixes.
45
+
46
+ ### Added
47
+
48
+ - One authoring gesture for scatterable coverage: `@jgengine/core/world/scatterCoverage` owns shared density/budget semantics (per-kind unit, requested→count→capped, one clamp-and-warn phrasing) for `grass_field`/`scatter`/`city`; the editor inspector leads each kind with the same Area → Assets → Density coverage section, scatter truncates to the shared 250k instance budget, and city single-sources its 2,600-lot cap.
49
+ - **Created games ship a drive/playtest script** (#1248) — `npx jgengine create` scaffolds now include `scripts/drive.mjs` (`bun run drive`): dependency-free headless play/testing of the running game — ordered `--click`/`--key`/`--wait`/`--shot`/`--rpc` steps against the agent bridge, plus `--playtest --strict` progress/softlock verdicts off `capture.probe`. Shared Chrome/CDP machinery lives in `scripts/browser.mjs`; `scripts/shoot.mjs` is unchanged in behavior, now a thin CLI over it.
50
+ - Editor: viewport clip preview for rigged assets (Animation dock "Clips" mode — pick a rig or placed instance, play/scrub/loop/speed any catalog clip) and an Inspector "Animation" section that authors a placement's `ModelConfig.animation` (role→clip dropdowns, auto/none, walk/run/fade, one-shot event bindings) as undoable `marker.meta.animation` edits. New subpaths: `@jgengine/editor/shell/clipPreview`, `@jgengine/editor/modelAnimationAuthoring`, `@jgengine/editor/ClipPreviewLayer`.
51
+ - **`npx jgengine find <intent>`** — active capability discovery: searches every shipped domain's `capabilities.md` (staged inside the CLI tarball, so it works regardless of which `@jgengine/*` packages a project installed) and prints the drop-in primitive + its import for an intent like `"toggleable window"`, `inventory`, or `minimap`. Scaffolded games are now briefed (in `AGENTS.md` and the `jgengine` intake skill) to reach for it before hand-rolling a HUD/inventory/window/rig.
52
+ - **`EntityPreview`** (`@jgengine/shell/render/EntityPreview`) — drop-in live 3D entity portrait for character screens, unit inspectors, and loadout viewers. Owns the nested `<Canvas>`, the `GameContextBridge`, and a `StudioStage` lighting rig with optional turntable / face-camera; the game passes its own `renderEntity` as children (or a native `model` + `instanceId`) and, bound to a live entity, the portrait walks/flinches/topples in sync with the world.
53
+ - **`GameContextBridge`** (`@jgengine/react`) — re-provide the running `GameContext` across a nested React reconciler boundary (the R3F `<Canvas>`), so a game building its own preview canvas no longer re-derives the bridge by hand.
54
+
55
+ ### Changed
56
+
57
+ - **Chase rig reports its yaw to the shell** — `ChaseRig` now writes the camera yaw back to the shared yaw ref like every other player-facing rig, so on-foot movement and aim in a `rig: "chase"` game are camera-relative instead of frozen to world yaw 0 (WASD no longer fights the camera the moment it swings behind the player). Interior views (cockpit/hood/rear) report the vehicle heading.
58
+ - **Combat VFX no longer render as black squares under AO/DOF post-processing** (#1247) — GTAO/Bokeh scene prepasses skip overlay effects. Games with custom additive overlay effects opt out the same way: spread `POSTFX_OVERLAY_USERDATA` (`@jgengine/shell/postfx/postfxOverlay`) onto the overlay group's `userData`.
59
+ - **`PanelHost` windows stack above the HUD by default** (`@jgengine/react`) — the host establishes its own stacking context at `zIndexBase` (default 40), so open windows always paint over stat bars / nameplates / frames instead of bleeding through. Overridable per instance.
14
60
 
15
61
  ## 0.13.0
16
62
 
@@ -0,0 +1,15 @@
1
+ import type { EditorHostApi } from "./session.js";
2
+ import type { EditorUiStore } from "./uiStore.js";
3
+ /**
4
+ * Viewport layer that renders the active clip-preview asset playing the selected clip, mounted by
5
+ * `EditorApp` inside the editor's R3F scene + GameContext. Reuses the shell's shared GLTF loader,
6
+ * scene clone, and `useModelAnimation` mixer — the same driver `EntityModel` runs at play time — so
7
+ * the preview IS the runtime playback. Reads the live session from the editor UI store and publishes
8
+ * the selected clip's measured duration back so the dock scrubber can normalize.
9
+ *
10
+ * @internal — not a game-author entry point.
11
+ */
12
+ export declare function ClipPreviewLayer({ api, ui }: {
13
+ api: EditorHostApi;
14
+ ui: EditorUiStore;
15
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Suspense, useEffect, useMemo } from "react";
3
+ import { useLoader } from "@react-three/fiber";
4
+ import * as THREE from "three";
5
+ import { sharedGltfLoader } from "@jgengine/shell/render/modelLoad";
6
+ import { cloneModelScene, disposeClonedMaterials } from "@jgengine/shell/render/modelRender";
7
+ import { useModelAnimation } from "@jgengine/shell/render/useModelAnimation";
8
+ import { previewAnimationConfig } from "./shell/clipPreview.js";
9
+ import { useStoreSelector } from "./useStoreSelector.js";
10
+ /** World-unit height the previewed model is normalized to, so tiny/huge rigs are both framed. */
11
+ const PREVIEW_TARGET_HEIGHT = 2;
12
+ /**
13
+ * Viewport layer that renders the active clip-preview asset playing the selected clip, mounted by
14
+ * `EditorApp` inside the editor's R3F scene + GameContext. Reuses the shell's shared GLTF loader,
15
+ * scene clone, and `useModelAnimation` mixer — the same driver `EntityModel` runs at play time — so
16
+ * the preview IS the runtime playback. Reads the live session from the editor UI store and publishes
17
+ * the selected clip's measured duration back so the dock scrubber can normalize.
18
+ *
19
+ * @internal — not a game-author entry point.
20
+ */
21
+ export function ClipPreviewLayer({ api, ui }) {
22
+ const session = useStoreSelector(ui, (state) => state.clipPreview);
23
+ if (session === null)
24
+ return null;
25
+ return (_jsx(Suspense, { fallback: null, children: _jsx(ClipPreviewModel, { session: session, api: api, ui: ui }, session.source.assetId) }));
26
+ }
27
+ function ClipPreviewModel({ session, api, ui, }) {
28
+ const { source, driver } = session;
29
+ const gltf = useLoader(sharedGltfLoader, source.url);
30
+ const scene = useMemo(() => cloneModelScene(gltf.scene), [gltf]);
31
+ useEffect(() => () => disposeClonedMaterials(scene), [scene]);
32
+ // Normalize height and center/ground so any rig frames nicely at the camera focus point.
33
+ const placement = useMemo(() => {
34
+ const box = new THREE.Box3().setFromObject(scene);
35
+ const height = box.max.y - box.min.y;
36
+ if (!Number.isFinite(height) || height <= 0)
37
+ return { scale: 1, minY: 0, centerX: 0, centerZ: 0 };
38
+ return {
39
+ scale: PREVIEW_TARGET_HEIGHT / height,
40
+ minY: box.min.y,
41
+ centerX: (box.min.x + box.max.x) / 2,
42
+ centerZ: (box.min.z + box.max.z) / 2,
43
+ };
44
+ }, [scene]);
45
+ const config = useMemo(() => previewAnimationConfig(driver), [driver]);
46
+ useModelAnimation(scene, gltf.animations, config, undefined);
47
+ // Publish the selected clip's duration once loaded so the dock scrubber can span it (0 = unknown).
48
+ const clipName = driver.clipName;
49
+ useEffect(() => {
50
+ const clip = clipName === null ? undefined : gltf.animations.find((entry) => entry.name === clipName);
51
+ const duration = clip?.duration ?? 0;
52
+ const current = ui.getState().clipPreview;
53
+ if (current !== null && current.source.assetId === source.assetId && current.duration !== duration) {
54
+ ui.patch({ clipPreview: { ...current, duration } });
55
+ }
56
+ }, [gltf, clipName, ui, source.assetId]);
57
+ const focus = api.getFocusTarget() ?? { x: 0, y: 0, z: 0 };
58
+ const s = placement.scale;
59
+ return (_jsx("group", { position: [focus.x, focus.y, focus.z], children: _jsx("primitive", { object: scene, scale: s, position: [-s * placement.centerX, -s * placement.minY, -s * placement.centerZ] }) }));
60
+ }
package/dist/EditorApp.js CHANGED
@@ -15,6 +15,7 @@ import { MaterialDropZone } from "./MaterialDropZone.js";
15
15
  import { PerfProbe } from "./PerfProbe.js";
16
16
  import { ScatterPreview } from "./ScatterPreview.js";
17
17
  import { CityPreview } from "./CityPreview.js";
18
+ import { ClipPreviewLayer } from "./ClipPreviewLayer.js";
18
19
  import { SelectionGizmo, ViewportSelect } from "./SelectionGizmo.js";
19
20
  import { TerrainReadout } from "./TerrainReadout.js";
20
21
  import { TerrainReadoutHud } from "./TerrainReadoutHud.js";
@@ -153,7 +154,7 @@ function EditorWorldOverlay({ api, ui, world, readout, }) {
153
154
  unsubscribe();
154
155
  };
155
156
  }, [session, readoutActive]);
156
- return (_jsxs(_Fragment, { children: [_jsx(PerfProbe, { api: api }), _jsx(EditorCameraDriver, { api: api }), _jsx(ViewportSelect, { api: api, ui: ui }), _jsx(MaterialDropZone, { api: api }), _jsx(AssetDropZone, { api: api }), _jsx(TerrainSculpt, { api: api, ui: ui, world: world }), _jsx(ScatterPreview, { api: api }), _jsx(CityPreview, { api: api }), showGrid ? _jsx("gridHelper", { args: [400, 80, "#3b4252", "#20242e"], position: [0, 0.05, 0] }) : null, readoutActive ? (_jsx(TerrainReadout, { groundHeightAt: groundHeightAt, region: readoutRegion, showContours: showContours, showSurfaceGrid: showSurfaceGrid, version: terrainVersion, readout: readout })) : null, _jsx(EditorLayerOverlays, { document: document, visibility: visibility, selection: selection, hoverId: hoverId, onSelect: (id) => session.dispatch({ type: "select", ids: [id] }), activePathPoint: pathPoint, groundHeightAt: groundHeightAt }), pathDraft.length > 0 ? _jsx(PathDraftPreview, { points: pathDraft }) : null, _jsx(SelectionGizmo, { session: session, ui: ui, groundSnap: groundHeightAt })] }));
157
+ return (_jsxs(_Fragment, { children: [_jsx(PerfProbe, { api: api }), _jsx(EditorCameraDriver, { api: api }), _jsx(ViewportSelect, { api: api, ui: ui }), _jsx(MaterialDropZone, { api: api }), _jsx(AssetDropZone, { api: api }), _jsx(TerrainSculpt, { api: api, ui: ui, world: world }), _jsx(ScatterPreview, { api: api }), _jsx(CityPreview, { api: api }), _jsx(ClipPreviewLayer, { api: api, ui: ui }), showGrid ? _jsx("gridHelper", { args: [400, 80, "#3b4252", "#20242e"], position: [0, 0.05, 0] }) : null, readoutActive ? (_jsx(TerrainReadout, { groundHeightAt: groundHeightAt, region: readoutRegion, showContours: showContours, showSurfaceGrid: showSurfaceGrid, version: terrainVersion, readout: readout })) : null, _jsx(EditorLayerOverlays, { document: document, visibility: visibility, selection: selection, hoverId: hoverId, onSelect: (id) => session.dispatch({ type: "select", ids: [id] }), activePathPoint: pathPoint, groundHeightAt: groundHeightAt }), pathDraft.length > 0 ? _jsx(PathDraftPreview, { points: pathDraft }) : null, _jsx(SelectionGizmo, { session: session, ui: ui, groundSnap: groundHeightAt })] }));
157
158
  }
158
159
  /** Default skin for the play/walk escape chip; swap via `EditorAppProps.modeChip`, or pass `null` to hide it (F2+E still exits). */
159
160
  function EditorModeChip({ mode, onExit }) {
@@ -745,11 +745,11 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocumen
745
745
  ? `Parented to ${parentId}`
746
746
  : `Parented ${ids.length} objects to ${parentId}`);
747
747
  }
748
- }, onClose: () => setParentPicker(null) })) : null] }), layoutState.bottomOpen ? (_jsxs(_Fragment, { children: [_jsx(PanelResizer, { orientation: "horizontal", label: "Resize bottom dock", sign: -1, onResize: (delta) => layout.resize("bottomHeight", delta) }), _jsx("div", { className: "flex shrink-0 flex-col", style: { height: layoutState.bottomHeight }, children: _jsx(BottomDock, { tab: layoutState.bottomTab, onSelectTab: (tab) => layout.patch({ bottomTab: tab }), onClose: () => layout.patch({ bottomOpen: false }), assets: liveAssets, session: session, api: api, consoleStore: consoleStore, perfHistory: perfHistory, browserView: layoutState.browserView, onSetBrowserView: (view) => layout.patch({ browserView: view }), onPlaceAsset: placeAsset, onImportModels: importModels, importBusy: importBusy }) })] })) : (_jsxs("div", { className: `pointer-events-auto flex h-7 shrink-0 items-center gap-1 border-t ${BORDER} bg-[#0e1014] px-1.5`, children: [[
748
+ }, onClose: () => setParentPicker(null) })) : null] }), layoutState.bottomOpen ? (_jsxs(_Fragment, { children: [_jsx(PanelResizer, { orientation: "horizontal", label: "Resize bottom dock", sign: -1, onResize: (delta) => layout.resize("bottomHeight", delta) }), _jsx("div", { className: "flex shrink-0 flex-col", style: { height: layoutState.bottomHeight }, children: _jsx(BottomDock, { tab: layoutState.bottomTab, onSelectTab: (tab) => layout.patch({ bottomTab: tab }), onClose: () => layout.patch({ bottomOpen: false }), assets: liveAssets, session: session, api: api, ui: ui, consoleStore: consoleStore, perfHistory: perfHistory, browserView: layoutState.browserView, onSetBrowserView: (view) => layout.patch({ browserView: view }), onPlaceAsset: placeAsset, onImportModels: importModels, importBusy: importBusy }) })] })) : (_jsxs("div", { className: `pointer-events-auto flex h-7 shrink-0 items-center gap-1 border-t ${BORDER} bg-[#0e1014] px-1.5`, children: [[
749
749
  ["content", "Content Browser"],
750
750
  ["console", "Console"],
751
751
  ["profiler", "Profiler"],
752
752
  ["animation", "Animation"],
753
753
  ["assistant", "AI Assistant"],
754
- ].map(([id, label]) => (_jsx("button", { type: "button", onClick: () => openBottomTab(id), className: `rounded-[4px] px-2 py-0.5 text-[10px] text-neutral-500 transition-colors hover:bg-white/[0.05] hover:text-neutral-300 ${FOCUS_RING}`, children: label }, id))), _jsx("span", { className: "ml-auto text-[9px] text-neutral-600", children: "Ctrl+B opens the content browser" })] }))] }), layoutState.rightOpen ? (_jsxs(_Fragment, { children: [_jsx(PanelResizer, { orientation: "vertical", label: "Resize inspector panel", sign: -1, onResize: (delta) => layout.resize("rightWidth", delta) }), _jsx("aside", { className: `pointer-events-auto hidden min-h-0 flex-col overflow-hidden border-l ${BORDER} bg-[#111318] md:flex`, style: { width: layoutState.rightWidth }, "aria-label": "Inspector dock", children: _jsx(InspectorPanel, { session: session, ui: ui, api: api, tab: layoutState.inspectorTab, onSelectTab: (tab) => layout.patch({ inspectorTab: tab }), collapsed: layoutState.collapsed, onToggleSection: (id) => layout.toggleSection(id), onClose: () => layout.patch({ rightOpen: false }) }) })] })) : null] }), _jsx(StatusBar, { api: api, history: perfHistory, objects: sceneStats.objects, foliage: sceneStats.foliage, selectionCount: state.selection.length, autosave: true }), paletteQuery !== null ? (_jsx(CommandPalette, { commands: paletteCommands, initialQuery: paletteQuery, onClose: () => setPaletteQuery(null) })) : null, helpOpen ? (_jsx("div", { className: "pointer-events-auto absolute inset-0 z-[70] flex items-center justify-center bg-black/50 backdrop-blur-[2px]", onClick: () => setHelpOpen(false), children: _jsxs("div", { className: "w-[28rem] max-w-[90vw] rounded-[10px] border border-white/10 bg-[#14171d] p-5 shadow-2xl shadow-black/60", onClick: (event) => event.stopPropagation(), role: "dialog", "aria-label": "Keyboard shortcuts", children: [_jsxs("div", { className: "mb-3 flex items-center", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-semibold tracking-wide text-neutral-100", children: [_jsx(Icon, { name: "command", size: 15, className: "text-cyan-300" }), "Keyboard shortcuts"] }), _jsx(IconButton, { icon: "close", label: "Close shortcuts", tone: "ghost", className: "ml-auto", onClick: () => setHelpOpen(false) })] }), _jsx("div", { className: "grid max-h-[60vh] grid-cols-[auto_1fr] items-center gap-x-4 gap-y-1.5 overflow-auto", children: shortcuts.map((entry) => (_jsxs("div", { className: "contents", children: [_jsx("span", { className: "justify-self-start", children: _jsx(Kbd, { children: entry.keys }) }), _jsx("span", { className: "text-neutral-300", children: entry.action })] }, entry.keys))) })] }) })) : null] }));
754
+ ].map(([id, label]) => (_jsx("button", { type: "button", onClick: () => openBottomTab(id), className: `rounded-[4px] px-2 py-0.5 text-[10px] text-neutral-500 transition-colors hover:bg-white/[0.05] hover:text-neutral-300 ${FOCUS_RING}`, children: label }, id))), _jsx("span", { className: "ml-auto text-[9px] text-neutral-600", children: "Ctrl+B opens the content browser" })] }))] }), layoutState.rightOpen ? (_jsxs(_Fragment, { children: [_jsx(PanelResizer, { orientation: "vertical", label: "Resize inspector panel", sign: -1, onResize: (delta) => layout.resize("rightWidth", delta) }), _jsx("aside", { className: `pointer-events-auto hidden min-h-0 flex-col overflow-hidden border-l ${BORDER} bg-[#111318] md:flex`, style: { width: layoutState.rightWidth }, "aria-label": "Inspector dock", children: _jsx(InspectorPanel, { session: session, ui: ui, api: api, assets: liveAssets, tab: layoutState.inspectorTab, onSelectTab: (tab) => layout.patch({ inspectorTab: tab }), collapsed: layoutState.collapsed, onToggleSection: (id) => layout.toggleSection(id), onClose: () => layout.patch({ rightOpen: false }) }) })] })) : null] }), _jsx(StatusBar, { api: api, history: perfHistory, objects: sceneStats.objects, foliage: sceneStats.foliage, selectionCount: state.selection.length, autosave: true }), paletteQuery !== null ? (_jsx(CommandPalette, { commands: paletteCommands, initialQuery: paletteQuery, onClose: () => setPaletteQuery(null) })) : null, helpOpen ? (_jsx("div", { className: "pointer-events-auto absolute inset-0 z-[70] flex items-center justify-center bg-black/50 backdrop-blur-[2px]", onClick: () => setHelpOpen(false), children: _jsxs("div", { className: "w-[28rem] max-w-[90vw] rounded-[10px] border border-white/10 bg-[#14171d] p-5 shadow-2xl shadow-black/60", onClick: (event) => event.stopPropagation(), role: "dialog", "aria-label": "Keyboard shortcuts", children: [_jsxs("div", { className: "mb-3 flex items-center", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-semibold tracking-wide text-neutral-100", children: [_jsx(Icon, { name: "command", size: 15, className: "text-cyan-300" }), "Keyboard shortcuts"] }), _jsx(IconButton, { icon: "close", label: "Close shortcuts", tone: "ghost", className: "ml-auto", onClick: () => setHelpOpen(false) })] }), _jsx("div", { className: "grid max-h-[60vh] grid-cols-[auto_1fr] items-center gap-x-4 gap-y-1.5 overflow-auto", children: shortcuts.map((entry) => (_jsxs("div", { className: "contents", children: [_jsx("span", { className: "justify-self-start", children: _jsx(Kbd, { children: entry.keys }) }), _jsx("span", { className: "text-neutral-300", children: entry.action })] }, entry.keys))) })] }) })) : null] }));
755
755
  }
@@ -1,4 +1,5 @@
1
1
  import { type EditorSession } from "@jgengine/core/editor/index";
2
+ import { type EditorAssetEntry } from "./AssetBrowser.js";
2
3
  import type { EditorHostApi } from "./session.js";
3
4
  import type { EditorUiStore } from "./uiStore.js";
4
5
  import type { InspectorTab } from "./shell/layoutStore.js";
@@ -9,12 +10,14 @@ import type { InspectorTab } from "./shell/layoutStore.js";
9
10
  * `pathPoint` slice, so UI-only churn never rerenders it on `EditorChrome`'s render tick.
10
11
  * @internal — mounted by `EditorChrome` inside the right dock.
11
12
  */
12
- export declare function InspectorPanel({ session, ui, onClose, api, tab, onSelectTab, collapsed, onToggleSection, }: {
13
+ export declare function InspectorPanel({ session, ui, onClose, api, assets, tab, onSelectTab, collapsed, onToggleSection, }: {
13
14
  session: EditorSession;
14
15
  ui: EditorUiStore;
15
16
  onClose?: () => void;
16
17
  /** Host API for RPC-backed actions (material assignment, camera). */
17
18
  api?: EditorHostApi;
19
+ /** Live browser asset list, used to resolve a placed marker's rig clips for animation authoring. */
20
+ assets?: readonly EditorAssetEntry[];
18
21
  /** Controlled active tab; omit for local state. */
19
22
  tab?: InspectorTab;
20
23
  onSelectTab?: (tab: InspectorTab) => void;
@@ -4,10 +4,13 @@ import { editorParentOf, findEditorNote, findEditorPath, isEditorObjectLocked, }
4
4
  import { readVegetationSettings, vegetationFootprint } from "@jgengine/core/world/vegetation";
5
5
  import { getSceneKind, isSceneKind, parseParams, } from "@jgengine/core/scene/sceneKinds";
6
6
  import { getAssetGenerator } from "@jgengine/core/scene/assetGenerator";
7
+ import { markerCatalogId } from "@jgengine/core/world/authoredObjects";
7
8
  import { useGameContext } from "@jgengine/react/provider";
8
9
  import { canAuthorTrigger, clearMaterialAssignmentPatch, clearTriggerInstallPatch, defaultTriggerInstallPatch, hasAuthoredTrigger, hasMaterialAssignment, } from "./authoredComponentMeta.js";
9
10
  import { SchemaInspector } from "./SchemaInspector.js";
10
11
  import { TriggerInspector } from "./TriggerInspector.js";
12
+ import { describeClipRoles } from "./AssetBrowser.js";
13
+ import { animationMode, animationMetaPatch, LOCOMOTION_ROLES, ONE_SHOT_EVENTS, readAnimationSetting, setAnimationMode, setLocomotionClip, setLocomotionNumber, setOneShotClip, } from "./modelAnimationAuthoring.js";
11
14
  import { listParentCandidates } from "./parentCandidates.js";
12
15
  import { TERRAIN_MATERIALS } from "./uiStore.js";
13
16
  import { shallowArrayEqual, useStoreSelector } from "./useStoreSelector.js";
@@ -15,6 +18,7 @@ import { INPUT } from "./chromeStyles.js";
15
18
  import { NumberField } from "./chromeFields.js";
16
19
  import { AxisNumberField, FieldRow, SectionAction } from "./shell/fields.js";
17
20
  import { Icon, kindIcon } from "./shell/icons.js";
21
+ import { CoverageSection } from "./shell/scatterCoverage.js";
18
22
  import { FOCUS_RING, INPUT_CLS, NUMERIC } from "./shell/theme.js";
19
23
  import { CollapsibleSection, EmptyState, IconButton, PanelTabs } from "./shell/ui.js";
20
24
  function VegetationFields({ volume, onMeta, }) {
@@ -49,8 +53,11 @@ function KindInspector({ object, meta, onMeta }) {
49
53
  const definition = getSceneKind(object.kind);
50
54
  if (definition === undefined)
51
55
  return null;
52
- const note = definition.note?.(object, parseParams(definition.schema, object.meta));
53
- return (_jsx(SchemaInspector, { schema: definition.schema, label: definition.label, meta: meta, onMeta: onMeta, ...(definition.accent === undefined ? {} : { accent: definition.accent }), ...(note === undefined ? {} : { note }) }));
56
+ const params = parseParams(definition.schema, object.meta);
57
+ const note = definition.note?.(object, params);
58
+ const coverage = definition.coverage;
59
+ const accent = definition.accent ?? "#34d399";
60
+ return (_jsxs("div", { className: "space-y-2", children: [coverage !== undefined ? (_jsx(CoverageSection, { schema: definition.schema, coverage: coverage, object: object, params: params, accent: accent, onMeta: onMeta, ...(note === undefined ? {} : { note }) })) : null, _jsx(SchemaInspector, { schema: definition.schema, label: definition.label, meta: meta, onMeta: onMeta, ...(definition.accent === undefined ? {} : { accent: definition.accent }), ...(coverage === undefined && note !== undefined ? { note } : {}), ...(coverage === undefined ? {} : { hideKeys: [coverage.densityKey] }) })] }));
54
61
  }
55
62
  /** Auto-generated inspector for a placed generator asset's params (building/bookcase/…). */
56
63
  function GeneratorInspector({ meta, onMeta }) {
@@ -107,6 +114,26 @@ function ComponentAddBar({ target, meta, onMeta, api, selectionIds, }) {
107
114
  event.currentTarget.value = "";
108
115
  }, children: [_jsx("option", { value: "", children: "+ Material\u2026" }), TERRAIN_MATERIALS.map((material) => (_jsx("option", { value: material.id, children: material.label }, material.id)))] })] })) : null] })] }));
109
116
  }
117
+ /** A labeled clip dropdown (never free-text) for a role/event; "— none —" clears the binding. */
118
+ function ClipSelectRow({ label, clips, value, onChange, }) {
119
+ return (_jsx(FieldRow, { label: label, children: _jsxs("select", { className: `h-6.5 w-full min-w-0 px-1.5 ${INPUT_CLS}`, value: value ?? "", "aria-label": `${label} clip`, onChange: (event) => onChange(event.target.value === "" ? null : event.target.value), children: [_jsx("option", { value: "", children: "\u2014 none \u2014" }), clips.map((clip) => (_jsx("option", { value: clip, children: clip }, clip)))] }) }));
120
+ }
121
+ const ANIMATION_MODE_HINT = {
122
+ default: "Inherits the catalog default: a rigged asset auto-animates from its clip roles.",
123
+ auto: "Forces derivation of idle/walk/run + hit/death/attack from this rig's clip names.",
124
+ none: "Renders the bind pose — no clip playback.",
125
+ custom: "Author explicit role→clip bindings below; unset roles fall back to the rig's defaults.",
126
+ };
127
+ /**
128
+ * Inspector section authoring a placement's `ModelConfig.animation` for a rigged catalog asset.
129
+ * Persists to `marker.meta.animation` through the passed `onChange` (an undoable `setMarker` dispatch).
130
+ * All clip picks are dropdowns over the asset's real clip list — never free-text.
131
+ */
132
+ function ModelAnimationSection({ clips, setting, onChange, }) {
133
+ const mode = animationMode(setting);
134
+ const config = mode === "custom" && typeof setting === "object" ? setting : null;
135
+ return (_jsxs("div", { className: "space-y-2.5", children: [_jsxs("div", { className: "text-[10px] text-neutral-500", children: [clips.length, " clips \u00B7 ", describeClipRoles(clips)] }), _jsx(FieldRow, { label: "Mode", children: _jsxs("select", { className: `h-6.5 w-full min-w-0 px-1.5 ${INPUT_CLS}`, value: mode, "aria-label": "Animation mode", onChange: (event) => onChange(setAnimationMode(setting, event.target.value, clips)), children: [_jsx("option", { value: "default", children: "Default (catalog)" }), _jsx("option", { value: "auto", children: "Auto (derive)" }), _jsx("option", { value: "none", children: "None (bind pose)" }), _jsx("option", { value: "custom", children: "Custom" })] }) }), config === null ? (_jsx("div", { className: "text-[10px] leading-relaxed text-neutral-600", children: ANIMATION_MODE_HINT[mode] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "space-y-1.5 rounded-[6px] border border-white/[0.06] bg-white/[0.02] p-2", children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "Locomotion states" }), LOCOMOTION_ROLES.map((role) => (_jsx(ClipSelectRow, { label: role, clips: clips, value: config.states?.[role] ?? null, onChange: (clip) => onChange(setLocomotionClip(setting, role, clip)) }, role))), _jsx(FieldRow, { label: "walkSpeed", title: "Speed (u/s) above which the entity counts as moving (walk).", children: _jsx(AxisNumberField, { label: "u/s", step: 0.1, value: config.states?.walkSpeed ?? 0.5, onCommit: (value) => onChange(setLocomotionNumber(setting, "walkSpeed", Math.max(0, value))) }) }), _jsx(FieldRow, { label: "runSpeed", title: "Speed above which the run clip plays (when a run clip is set).", children: _jsx(AxisNumberField, { label: "u/s", step: 0.5, value: config.states?.runSpeed ?? 6, onCommit: (value) => onChange(setLocomotionNumber(setting, "runSpeed", Math.max(0, value))) }) }), _jsx(FieldRow, { label: "fadeSec", title: "Crossfade duration (s) when the locomotion state changes.", children: _jsx(AxisNumberField, { label: "s", step: 0.05, value: config.states?.fadeSec ?? 0.2, onCommit: (value) => onChange(setLocomotionNumber(setting, "fadeSec", Math.max(0, value))) }) })] }), _jsxs("div", { className: "space-y-1.5 rounded-[6px] border border-white/[0.06] bg-white/[0.02] p-2", children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "One-shot events" }), _jsx("div", { className: "text-[10px] text-neutral-600", children: "hit / death auto-fire on damage/death; others via playEntityAnimation." }), ONE_SHOT_EVENTS.map((event) => (_jsx(ClipSelectRow, { label: event, clips: clips, value: config.oneShots?.[event] ?? null, onChange: (clip) => onChange(setOneShotClip(setting, event, clip)) }, event)))] })] }))] }));
136
+ }
110
137
  function Section({ id, title, icon, sections, children, trailing, }) {
111
138
  return (_jsx(CollapsibleSection, { title: title, ...(icon === undefined ? {} : { icon }), open: sections.collapsed[id] !== true, onToggle: () => sections.toggle(id), ...(trailing === undefined ? {} : { trailing }), children: children }));
112
139
  }
@@ -127,7 +154,7 @@ function MaterialsTab({ api, selection, meta, }) {
127
154
  * `pathPoint` slice, so UI-only churn never rerenders it on `EditorChrome`'s render tick.
128
155
  * @internal — mounted by `EditorChrome` inside the right dock.
129
156
  */
130
- export function InspectorPanel({ session, ui, onClose, api, tab, onSelectTab, collapsed, onToggleSection, }) {
157
+ export function InspectorPanel({ session, ui, onClose, api, assets, tab, onSelectTab, collapsed, onToggleSection, }) {
131
158
  const document = useStoreSelector(session, (state) => state.document);
132
159
  const selection = useStoreSelector(session, (state) => state.selection, shallowArrayEqual);
133
160
  const pathPoint = useStoreSelector(ui, (state) => state.pathPoint);
@@ -198,12 +225,16 @@ export function InspectorPanel({ session, ui, onClose, api, tab, onSelectTab, co
198
225
  else if (selectedMarker !== undefined) {
199
226
  const marker = selectedMarker;
200
227
  const onMeta = (patch, coalesce) => session.dispatch({ type: "setMarker", id: marker.id, patch: { meta: { ...marker.meta, ...patch } } }, { coalesce: `${coalesce}:${marker.id}` });
228
+ // Rig clips for animation authoring: this placement's catalog asset must carry animation clips.
229
+ const markerCatalog = markerCatalogId(marker);
230
+ const markerRigAsset = markerCatalog === null ? undefined : assets?.find((entry) => entry.id === markerCatalog);
231
+ const markerRigClips = markerRigAsset?.clips !== undefined && markerRigAsset.clips.length > 0 ? markerRigAsset.clips : null;
201
232
  const hasTrigger = hasAuthoredTrigger(marker.meta);
202
233
  const hasMaterial = hasMaterialAssignment(marker.meta);
203
234
  const hasGenerator = typeof marker.meta?.["assetId"] === "string" && getAssetGenerator(marker.meta["assetId"]) !== undefined;
204
235
  const hasKindParams = isSceneKind(marker.kind);
205
236
  const componentCards = (_jsxs(_Fragment, { children: [_jsx(ComponentAddBar, { target: "marker", meta: marker.meta, onMeta: onMeta, api: api, selectionIds: [marker.id] }), hasKindParams ? (_jsx(Section, { id: "kindParams", title: "Kind parameters", icon: "settings", sections: sections, children: _jsx(KindInspector, { object: markerObject(marker), meta: marker.meta, onMeta: onMeta }) })) : null, hasTrigger ? (_jsx(Section, { id: "trigger", title: "Trigger", icon: "target", sections: sections, trailing: _jsx(SectionAction, { label: "Remove trigger component", onClick: () => onMeta(clearTriggerInstallPatch(), "component:remove-trigger"), children: "remove" }), children: _jsx(TriggerInspector, { target: "marker", meta: marker.meta, onMeta: onMeta }) })) : null, hasMaterial ? (_jsx(Section, { id: "material", title: "Material", icon: "sphere", sections: sections, trailing: _jsx(SectionAction, { label: "Remove material assignment", onClick: () => onMeta(clearMaterialAssignmentPatch(), "component:remove-material"), children: "remove" }), children: _jsxs("div", { className: "text-[11px] text-neutral-300", children: ["materialId: ", _jsx("span", { className: NUMERIC, children: String(marker.meta?.["materialId"]) })] }) })) : null, hasGenerator ? (_jsx(Section, { id: "generator", title: "Generator", icon: "cube", sections: sections, children: _jsx(GeneratorInspector, { meta: marker.meta, onMeta: onMeta }) })) : null, !hasKindParams && !hasTrigger && !hasMaterial && !hasGenerator && !canAuthorTrigger("marker") ? (_jsx(EmptyState, { icon: "settings", title: "No components", description: "This marker has no optional components. Games that register trigger actions enable Add trigger here." })) : null] }));
206
- body = (_jsxs("div", { className: "space-y-2 p-2.5", children: [_jsx(ObjectHeader, { kind: marker.kind, id: marker.id, label: marker.label ?? "", placeholder: marker.id, locked: isEditorObjectLocked(document, marker.id), onLabel: (value) => session.dispatch({ type: "setMarker", id: marker.id, patch: { label: value } }, { coalesce: `label:${marker.id}` }) }), activeTab === "inspector" ? (_jsxs(_Fragment, { children: [_jsx(Section, { id: "transform", title: "Transform", icon: "move", sections: sections, trailing: _jsx(SectionAction, { label: "Reset position to origin", onClick: () => session.dispatch({ type: "setTransform", id: marker.id, position: { x: 0, y: 0, z: 0 } }), children: "reset" }), children: _jsxs("div", { className: "space-y-1.5", children: [_jsx(FieldRow, { label: "Position", children: ["x", "y", "z"].map((axis) => (_jsx(AxisNumberField, { axis: axis, label: axis, step: 0.5, value: marker.position[axis], onCommit: (value) => session.dispatch({ type: "setTransform", id: marker.id, position: { ...marker.position, [axis]: value } }, { coalesce: `pos:${axis}:${marker.id}` }) }, axis))) }), _jsx(FieldRow, { label: "Rotation", children: _jsx(AxisNumberField, { axis: "y", label: "y\u00B0", step: 5, precision: 1, value: Math.round((((marker.rotationY ?? 0) * 180) / Math.PI) * 10) / 10, onCommit: (value) => session.dispatch({ type: "setTransform", id: marker.id, rotationY: (value * Math.PI) / 180 }, { coalesce: `rot:${marker.id}` }) }) })] }) }), _jsx(Section, { id: "appearance", title: "Appearance", icon: "sphere", sections: sections, children: _jsx(KindColorFields, { kind: marker.kind, color: marker.color, onKind: (kind) => session.dispatch({ type: "setMarker", id: marker.id, patch: { kind } }, { coalesce: `kind:${marker.id}` }), onColor: (color) => session.dispatch({ type: "setMarker", id: marker.id, patch: { color } }, { coalesce: `color:${marker.id}` }) }) }), _jsx(Section, { id: "placement", title: "Placement", icon: "pin", sections: sections, children: _jsxs("div", { className: "space-y-2", children: [_jsx(ClearanceField, { meta: marker.meta, onMeta: onMeta }), _jsx(ParentField, { session: session, id: marker.id })] }) }), marker.meta !== undefined ? (_jsx(Section, { id: "meta", title: "Raw metadata", icon: "script", sections: sections, children: _jsx("pre", { className: "max-h-48 overflow-auto rounded-[5px] border border-white/[0.06] bg-black/40 p-2 text-[10px] text-neutral-400", children: JSON.stringify(marker.meta, null, 2) }) })) : null] })) : (componentCards)] }));
237
+ body = (_jsxs("div", { className: "space-y-2 p-2.5", children: [_jsx(ObjectHeader, { kind: marker.kind, id: marker.id, label: marker.label ?? "", placeholder: marker.id, locked: isEditorObjectLocked(document, marker.id), onLabel: (value) => session.dispatch({ type: "setMarker", id: marker.id, patch: { label: value } }, { coalesce: `label:${marker.id}` }) }), activeTab === "inspector" ? (_jsxs(_Fragment, { children: [_jsx(Section, { id: "transform", title: "Transform", icon: "move", sections: sections, trailing: _jsx(SectionAction, { label: "Reset position to origin", onClick: () => session.dispatch({ type: "setTransform", id: marker.id, position: { x: 0, y: 0, z: 0 } }), children: "reset" }), children: _jsxs("div", { className: "space-y-1.5", children: [_jsx(FieldRow, { label: "Position", children: ["x", "y", "z"].map((axis) => (_jsx(AxisNumberField, { axis: axis, label: axis, step: 0.5, value: marker.position[axis], onCommit: (value) => session.dispatch({ type: "setTransform", id: marker.id, position: { ...marker.position, [axis]: value } }, { coalesce: `pos:${axis}:${marker.id}` }) }, axis))) }), _jsx(FieldRow, { label: "Rotation", children: _jsx(AxisNumberField, { axis: "y", label: "y\u00B0", step: 5, precision: 1, value: Math.round((((marker.rotationY ?? 0) * 180) / Math.PI) * 10) / 10, onCommit: (value) => session.dispatch({ type: "setTransform", id: marker.id, rotationY: (value * Math.PI) / 180 }, { coalesce: `rot:${marker.id}` }) }) })] }) }), _jsx(Section, { id: "appearance", title: "Appearance", icon: "sphere", sections: sections, children: _jsx(KindColorFields, { kind: marker.kind, color: marker.color, onKind: (kind) => session.dispatch({ type: "setMarker", id: marker.id, patch: { kind } }, { coalesce: `kind:${marker.id}` }), onColor: (color) => session.dispatch({ type: "setMarker", id: marker.id, patch: { color } }, { coalesce: `color:${marker.id}` }) }) }), _jsx(Section, { id: "placement", title: "Placement", icon: "pin", sections: sections, children: _jsxs("div", { className: "space-y-2", children: [_jsx(ClearanceField, { meta: marker.meta, onMeta: onMeta }), _jsx(ParentField, { session: session, id: marker.id })] }) }), markerRigClips !== null ? (_jsx(Section, { id: "modelAnimation", title: "Animation", icon: "film", sections: sections, children: _jsx(ModelAnimationSection, { clips: markerRigClips, setting: readAnimationSetting(marker.meta), onChange: (next) => onMeta(animationMetaPatch(next), "animation") }) })) : null, marker.meta !== undefined ? (_jsx(Section, { id: "meta", title: "Raw metadata", icon: "script", sections: sections, children: _jsx("pre", { className: "max-h-48 overflow-auto rounded-[5px] border border-white/[0.06] bg-black/40 p-2 text-[10px] text-neutral-400", children: JSON.stringify(marker.meta, null, 2) }) })) : null] })) : (componentCards)] }));
207
238
  }
208
239
  else if (selectedVolume !== undefined) {
209
240
  const volume = selectedVolume;
@@ -9,11 +9,13 @@ export type MetaPatch = (patch: Record<string, unknown>, coalesce: string) => vo
9
9
  * seam owns the inspector. Replaces the hand-written per-kind field blocks (`ScatterFields`).
10
10
  * @internal — mounted by `EditorChrome`'s inspector; not a game-author entry point.
11
11
  */
12
- export declare function SchemaInspector({ schema, label, accent, note, meta, onMeta, }: {
12
+ export declare function SchemaInspector({ schema, label, accent, note, meta, onMeta, hideKeys, }: {
13
13
  schema: ParamSchema;
14
14
  label: string;
15
15
  accent?: string;
16
16
  note?: string;
17
17
  meta: Record<string, unknown> | undefined;
18
18
  onMeta: MetaPatch;
19
+ /** Field keys to omit from this body — hoisted elsewhere (e.g. the density slider in `CoverageSection`). */
20
+ hideKeys?: readonly string[];
19
21
  }): import("react").JSX.Element;
@@ -82,10 +82,15 @@ function GroupSection({ title, accent, fields, schema, meta, onMeta, defaultColl
82
82
  * seam owns the inspector. Replaces the hand-written per-kind field blocks (`ScatterFields`).
83
83
  * @internal — mounted by `EditorChrome`'s inspector; not a game-author entry point.
84
84
  */
85
- export function SchemaInspector({ schema, label, accent = "#34d399", note, meta, onMeta, }) {
85
+ export function SchemaInspector({ schema, label, accent = "#34d399", note, meta, onMeta, hideKeys, }) {
86
86
  const groups = schema.groups ?? [];
87
87
  const grouped = new Set(groups.map((group) => group.id));
88
+ const hidden = hideKeys === undefined || hideKeys.length === 0 ? null : new Set(hideKeys);
89
+ const visible = hidden === null ? schema.fields : schema.fields.filter((field) => !hidden.has(field.key));
88
90
  // Fields with no group (or a group not declared in schema.groups) render first, headerless.
89
- const ungrouped = schema.fields.filter((field) => field.group === undefined || !grouped.has(field.group));
90
- return (_jsxs("div", { className: "space-y-2 rounded-lg border p-2.5", style: { borderColor: `${accent}26`, backgroundColor: `${accent}10` }, children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-[0.14em]", style: { color: accent }, children: label }), _jsx(PresetRow, { schema: schema, onMeta: onMeta }), ungrouped.map((field) => field.type === "action" ? (_jsx(ActionButton, { field: field, schema: schema, onMeta: onMeta }, field.key)) : (_jsx(FieldRow, { field: field, meta: meta, onMeta: onMeta }, field.key))), groups.map((group) => (_jsx(GroupSection, { title: group.label, accent: accent, fields: schema.fields.filter((field) => field.group === group.id), schema: schema, meta: meta, onMeta: onMeta, defaultCollapsed: group.collapsed ?? false }, group.id))), note !== undefined && note.length > 0 ? _jsx("div", { className: "text-[10px] text-neutral-500", children: note }) : null] }));
91
+ const ungrouped = visible.filter((field) => field.group === undefined || !grouped.has(field.group));
92
+ return (_jsxs("div", { className: "space-y-2 rounded-lg border p-2.5", style: { borderColor: `${accent}26`, backgroundColor: `${accent}10` }, children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-[0.14em]", style: { color: accent }, children: label }), _jsx(PresetRow, { schema: schema, onMeta: onMeta }), ungrouped.map((field) => field.type === "action" ? (_jsx(ActionButton, { field: field, schema: schema, onMeta: onMeta }, field.key)) : (_jsx(FieldRow, { field: field, meta: meta, onMeta: onMeta }, field.key))), groups
93
+ .map((group) => ({ group, fields: visible.filter((field) => field.group === group.id) }))
94
+ .filter((entry) => entry.fields.length > 0)
95
+ .map(({ group, fields }) => (_jsx(GroupSection, { title: group.label, accent: accent, fields: fields, schema: schema, meta: meta, onMeta: onMeta, defaultCollapsed: group.collapsed ?? false }, group.id))), note !== undefined && note.length > 0 ? _jsx("div", { className: "text-[10px] text-neutral-500", children: note }) : null] }));
91
96
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Authoring model for a placement's `ModelConfig.animation`, persisted on a marker's `meta.animation`
3
+ * and consumed by the shell exactly like a game-authored config. Pure data + reducers, no JSX — the
4
+ * inspector section wires these to `session.dispatch({ type: "setMarker", ... })` so every edit is
5
+ * undoable like any other inspector change. Splitting this out mirrors how `pathFlythrough.ts` /
6
+ * `renameObject.ts` keep inspector logic testable apart from the panel.
7
+ *
8
+ * The stored value is either the string modes `"auto"` / `"none"` (see `resolveAnimationConfig`) or a
9
+ * concrete config object. `undefined` means "no override" — a catalog-resolved rigged asset still
10
+ * auto-animates from its clip roles. Authored types here are permissive supersets of core's
11
+ * `ModelAnimationConfig` so an in-progress edit (idle picked, walk not yet) is still storable.
12
+ *
13
+ * @internal
14
+ */
15
+ /** Locomotion state roles the speed-driven idle/walk/run crossfade reads. */
16
+ export declare const LOCOMOTION_ROLES: readonly ["idle", "walk", "run"];
17
+ /** Locomotion clip role a placement can override (idle/walk/run). */
18
+ export type LocomotionRole = (typeof LOCOMOTION_ROLES)[number];
19
+ /** Numeric locomotion tunings (`ModelAnimationStates`). */
20
+ export declare const LOCOMOTION_NUMBERS: readonly ["walkSpeed", "runSpeed", "fadeSec"];
21
+ /** Numeric locomotion tuning field on an authored animation config (walkSpeed/runSpeed/fadeSec). */
22
+ export type LocomotionNumber = (typeof LOCOMOTION_NUMBERS)[number];
23
+ /**
24
+ * One-shot event slots offered in the inspector. `hit` / `death` auto-fire on this entity's
25
+ * `combat.hitReaction` / `entity.died`; the rest fire when the game calls `playEntityAnimation`.
26
+ */
27
+ export declare const ONE_SHOT_EVENTS: readonly ["attack", "hit", "death", "jump", "interact", "cheer"];
28
+ /** One-shot animation event a placement can bind a clip to (attack/hit/death/...). */
29
+ export type OneShotEvent = (typeof ONE_SHOT_EVENTS)[number];
30
+ /** Role->clip override map persisted inside an authored animation config. */
31
+ export interface AuthoredAnimationStates {
32
+ idle?: string;
33
+ walk?: string;
34
+ run?: string;
35
+ walkSpeed?: number;
36
+ runSpeed?: number;
37
+ fadeSec?: number;
38
+ }
39
+ /** Structurally compatible with `ModelAnimationConfig`, but every field optional for authoring. */
40
+ export interface AuthoredAnimationConfig {
41
+ clip?: string;
42
+ loop?: boolean;
43
+ timeScale?: number;
44
+ states?: AuthoredAnimationStates;
45
+ oneShots?: Record<string, string>;
46
+ }
47
+ /** The value stored at `meta.animation`. */
48
+ export type AnimationSetting = AuthoredAnimationConfig | "auto" | "none";
49
+ /** Which authoring mode a stored setting represents. `default` = no override key. */
50
+ export type AnimationMode = "default" | "auto" | "none" | "custom";
51
+ /** Reads and shape-validates the animation override from a marker's meta; undefined when absent/invalid. */
52
+ export declare function readAnimationSetting(meta: Record<string, unknown> | undefined): AnimationSetting | undefined;
53
+ /** The authoring mode a stored setting maps to. */
54
+ export declare function animationMode(setting: AnimationSetting | undefined): AnimationMode;
55
+ /**
56
+ * Switches authoring mode. `custom` seeds from the asset's clip-role defaults (so the user starts from
57
+ * a working config, then overrides) when there is no existing object config; the other modes are the
58
+ * bare string/undefined values.
59
+ */
60
+ export declare function setAnimationMode(setting: AnimationSetting | undefined, mode: AnimationMode, clips?: readonly string[]): AnimationSetting | undefined;
61
+ /** A concrete custom config derived from a rigged asset's clip roles — the `custom` mode seed. */
62
+ export declare function defaultCustomConfig(clips: readonly string[]): AuthoredAnimationConfig;
63
+ /** Sets or clears (null) a locomotion state clip; forces the setting into `custom`. */
64
+ export declare function setLocomotionClip(setting: AnimationSetting | undefined, role: LocomotionRole, clipName: string | null): AuthoredAnimationConfig;
65
+ /** Sets or clears (null) a numeric locomotion tuning. */
66
+ export declare function setLocomotionNumber(setting: AnimationSetting | undefined, key: LocomotionNumber, value: number | null): AuthoredAnimationConfig;
67
+ /** Binds or clears (null) a one-shot event to a clip. */
68
+ export declare function setOneShotClip(setting: AnimationSetting | undefined, event: string, clipName: string | null): AuthoredAnimationConfig;
69
+ /**
70
+ * The meta patch that persists a setting (or clears the override when undefined). Shallow-merges onto
71
+ * `marker.meta` via `setMarker`; an undefined value drops out of the saved JSON document.
72
+ */
73
+ export declare function animationMetaPatch(setting: AnimationSetting | undefined): {
74
+ animation: AnimationSetting | undefined;
75
+ };
@@ -0,0 +1,164 @@
1
+ import { rolesFromClips } from "@jgengine/core/game/clipRoles";
2
+ /**
3
+ * Authoring model for a placement's `ModelConfig.animation`, persisted on a marker's `meta.animation`
4
+ * and consumed by the shell exactly like a game-authored config. Pure data + reducers, no JSX — the
5
+ * inspector section wires these to `session.dispatch({ type: "setMarker", ... })` so every edit is
6
+ * undoable like any other inspector change. Splitting this out mirrors how `pathFlythrough.ts` /
7
+ * `renameObject.ts` keep inspector logic testable apart from the panel.
8
+ *
9
+ * The stored value is either the string modes `"auto"` / `"none"` (see `resolveAnimationConfig`) or a
10
+ * concrete config object. `undefined` means "no override" — a catalog-resolved rigged asset still
11
+ * auto-animates from its clip roles. Authored types here are permissive supersets of core's
12
+ * `ModelAnimationConfig` so an in-progress edit (idle picked, walk not yet) is still storable.
13
+ *
14
+ * @internal
15
+ */
16
+ /** Locomotion state roles the speed-driven idle/walk/run crossfade reads. */
17
+ export const LOCOMOTION_ROLES = ["idle", "walk", "run"];
18
+ /** Numeric locomotion tunings (`ModelAnimationStates`). */
19
+ export const LOCOMOTION_NUMBERS = ["walkSpeed", "runSpeed", "fadeSec"];
20
+ /**
21
+ * One-shot event slots offered in the inspector. `hit` / `death` auto-fire on this entity's
22
+ * `combat.hitReaction` / `entity.died`; the rest fire when the game calls `playEntityAnimation`.
23
+ */
24
+ export const ONE_SHOT_EVENTS = ["attack", "hit", "death", "jump", "interact", "cheer"];
25
+ function isRecord(value) {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
+ }
28
+ /** Reads and shape-validates the animation override from a marker's meta; undefined when absent/invalid. */
29
+ export function readAnimationSetting(meta) {
30
+ const value = meta?.["animation"];
31
+ if (value === "auto" || value === "none")
32
+ return value;
33
+ if (!isRecord(value))
34
+ return undefined;
35
+ const config = {};
36
+ if (typeof value["clip"] === "string")
37
+ config.clip = value["clip"];
38
+ if (typeof value["loop"] === "boolean")
39
+ config.loop = value["loop"];
40
+ if (typeof value["timeScale"] === "number")
41
+ config.timeScale = value["timeScale"];
42
+ if (isRecord(value["states"])) {
43
+ const raw = value["states"];
44
+ const states = {};
45
+ for (const role of LOCOMOTION_ROLES)
46
+ if (typeof raw[role] === "string")
47
+ states[role] = raw[role];
48
+ for (const key of LOCOMOTION_NUMBERS)
49
+ if (typeof raw[key] === "number")
50
+ states[key] = raw[key];
51
+ if (Object.keys(states).length > 0)
52
+ config.states = states;
53
+ }
54
+ if (isRecord(value["oneShots"])) {
55
+ const raw = value["oneShots"];
56
+ const oneShots = {};
57
+ for (const [event, clip] of Object.entries(raw)) {
58
+ if (typeof clip === "string")
59
+ oneShots[event] = clip;
60
+ else if (Array.isArray(clip) && typeof clip[0] === "string")
61
+ oneShots[event] = clip[0];
62
+ }
63
+ if (Object.keys(oneShots).length > 0)
64
+ config.oneShots = oneShots;
65
+ }
66
+ return config;
67
+ }
68
+ /** The authoring mode a stored setting maps to. */
69
+ export function animationMode(setting) {
70
+ if (setting === undefined)
71
+ return "default";
72
+ if (setting === "auto")
73
+ return "auto";
74
+ if (setting === "none")
75
+ return "none";
76
+ return "custom";
77
+ }
78
+ function asConfig(setting) {
79
+ return setting !== undefined && setting !== "auto" && setting !== "none" ? setting : {};
80
+ }
81
+ function normalizeConfig(config) {
82
+ const next = { ...config };
83
+ if (next.states !== undefined && Object.keys(next.states).length === 0)
84
+ delete next.states;
85
+ if (next.oneShots !== undefined && Object.keys(next.oneShots).length === 0)
86
+ delete next.oneShots;
87
+ return next;
88
+ }
89
+ /**
90
+ * Switches authoring mode. `custom` seeds from the asset's clip-role defaults (so the user starts from
91
+ * a working config, then overrides) when there is no existing object config; the other modes are the
92
+ * bare string/undefined values.
93
+ */
94
+ export function setAnimationMode(setting, mode, clips = []) {
95
+ if (mode === "default")
96
+ return undefined;
97
+ if (mode === "auto")
98
+ return "auto";
99
+ if (mode === "none")
100
+ return "none";
101
+ if (setting !== undefined && setting !== "auto" && setting !== "none")
102
+ return setting;
103
+ return normalizeConfig(defaultCustomConfig(clips));
104
+ }
105
+ /** A concrete custom config derived from a rigged asset's clip roles — the `custom` mode seed. */
106
+ export function defaultCustomConfig(clips) {
107
+ const roles = rolesFromClips(clips);
108
+ const states = {};
109
+ if (roles.idle?.[0] !== undefined)
110
+ states.idle = roles.idle[0];
111
+ if (roles.walk?.[0] !== undefined)
112
+ states.walk = roles.walk[0];
113
+ if (roles.run?.[0] !== undefined)
114
+ states.run = roles.run[0];
115
+ const oneShots = {};
116
+ for (const event of ONE_SHOT_EVENTS) {
117
+ const variant = roles[event]?.[0];
118
+ if (variant !== undefined)
119
+ oneShots[event] = variant;
120
+ }
121
+ const config = {};
122
+ if (Object.keys(states).length > 0)
123
+ config.states = states;
124
+ if (Object.keys(oneShots).length > 0)
125
+ config.oneShots = oneShots;
126
+ return config;
127
+ }
128
+ /** Sets or clears (null) a locomotion state clip; forces the setting into `custom`. */
129
+ export function setLocomotionClip(setting, role, clipName) {
130
+ const config = asConfig(setting);
131
+ const states = { ...config.states };
132
+ if (clipName === null)
133
+ delete states[role];
134
+ else
135
+ states[role] = clipName;
136
+ return normalizeConfig({ ...config, states });
137
+ }
138
+ /** Sets or clears (null) a numeric locomotion tuning. */
139
+ export function setLocomotionNumber(setting, key, value) {
140
+ const config = asConfig(setting);
141
+ const states = { ...config.states };
142
+ if (value === null || !Number.isFinite(value))
143
+ delete states[key];
144
+ else
145
+ states[key] = value;
146
+ return normalizeConfig({ ...config, states });
147
+ }
148
+ /** Binds or clears (null) a one-shot event to a clip. */
149
+ export function setOneShotClip(setting, event, clipName) {
150
+ const config = asConfig(setting);
151
+ const oneShots = { ...config.oneShots };
152
+ if (clipName === null)
153
+ delete oneShots[event];
154
+ else
155
+ oneShots[event] = clipName;
156
+ return normalizeConfig({ ...config, oneShots });
157
+ }
158
+ /**
159
+ * The meta patch that persists a setting (or clears the override when undefined). Shallow-merges onto
160
+ * `marker.meta` via `setMarker`; an undefined value drops out of the saved JSON document.
161
+ */
162
+ export function animationMetaPatch(setting) {
163
+ return { animation: setting };
164
+ }