@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.
@@ -1,17 +1,19 @@
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";
4
+ import type { EditorUiStore } from "../uiStore.js";
3
5
  /**
4
- * Animation dock: path flythrough scrubber over authored scene polylines.
5
- *
6
- * Scope (honest, no fake NLE):
7
- * - Scrub / play the editor orbit camera along document paths via {@link EditorHostApi.setFocusTarget}.
8
- * - Uses real path points + `@jgengine/core` path-follow sampling — not invented keyframes.
9
- * - Out of scope: clip authoring, keyframe curves, scene-kind param animation, director cinematics
10
- * stored outside the scene document.
6
+ * Animation dock with two modes:
7
+ * - **Path** — scrub/play the editor orbit camera along authored scene polylines (real path points,
8
+ * no fabricated keyframes).
9
+ * - **Clips** — preview a rigged asset's animation clips in the viewport (scrub, loop, speed) via the
10
+ * shell mixer, either a placed instance's asset or any rigged catalog asset.
11
11
  *
12
12
  * @internal
13
13
  */
14
- export declare function AnimationPanel({ session, api }: {
14
+ export declare function AnimationPanel({ session, api, ui, assets, }: {
15
15
  session: EditorSession;
16
16
  api: EditorHostApi;
17
+ ui: EditorUiStore;
18
+ assets: readonly EditorAssetEntry[];
17
19
  }): import("react").JSX.Element;
@@ -1,23 +1,123 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { useStoreSelector } from "../useStoreSelector.js";
3
+ import { markerCatalogId } from "@jgengine/core/world/authoredObjects";
4
+ import { shallowArrayEqual, useStoreSelector } from "../useStoreSelector.js";
5
+ import { advancePreview, clipEntriesFromRef, initialClipPreviewState, MAX_PREVIEW_SPEED, MIN_PREVIEW_SPEED, scrubPreview, selectPreviewClip, setPreviewLoop, setPreviewSpeed, togglePreviewPlaying, } from "./clipPreview.js";
4
6
  import { listScrubbablePaths, samplePathAt } from "./pathFlythrough.js";
5
7
  import { FOCUS_RING, INPUT_CLS, NUMERIC } from "./theme.js";
6
8
  import { EmptyState, IconButton } from "./ui.js";
7
9
  /** Default world-units/sec when playing a path flythrough preview. */
8
10
  const DEFAULT_PLAY_SPEED = 8;
11
+ /** Rigged asset entries (carry animation clips) for the clip-preview picker. */
12
+ function riggedAssets(assets) {
13
+ return assets.filter((entry) => entry.url !== undefined && entry.clips !== undefined && entry.clips.length > 0);
14
+ }
9
15
  /**
10
- * Animation dock: path flythrough scrubber over authored scene polylines.
11
- *
12
- * Scope (honest, no fake NLE):
13
- * - Scrub / play the editor orbit camera along document paths via {@link EditorHostApi.setFocusTarget}.
14
- * - Uses real path points + `@jgengine/core` path-follow sampling — not invented keyframes.
15
- * - Out of scope: clip authoring, keyframe curves, scene-kind param animation, director cinematics
16
- * stored outside the scene document.
16
+ * Animation dock with two modes:
17
+ * - **Path** — scrub/play the editor orbit camera along authored scene polylines (real path points,
18
+ * no fabricated keyframes).
19
+ * - **Clips** — preview a rigged asset's animation clips in the viewport (scrub, loop, speed) via the
20
+ * shell mixer, either a placed instance's asset or any rigged catalog asset.
17
21
  *
18
22
  * @internal
19
23
  */
20
- export function AnimationPanel({ session, api }) {
24
+ export function AnimationPanel({ session, api, ui, assets, }) {
25
+ const rigged = useMemo(() => riggedAssets(assets), [assets]);
26
+ // Default to clips only when the scene has rigged assets but no scrubbable path yet.
27
+ const paths = useStoreSelector(session, (state) => state.document.paths);
28
+ const [mode, setMode] = useState(() => rigged.length > 0 && listScrubbablePaths(paths).length === 0 ? "clips" : "path");
29
+ // Tear down the viewport preview whenever the panel leaves clip mode or unmounts.
30
+ useEffect(() => {
31
+ if (mode !== "clips" && ui.getState().clipPreview !== null)
32
+ ui.patch({ clipPreview: null });
33
+ }, [mode, ui]);
34
+ useEffect(() => () => {
35
+ if (ui.getState().clipPreview !== null)
36
+ ui.patch({ clipPreview: null });
37
+ }, [ui]);
38
+ return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [_jsx("div", { className: "flex h-8 shrink-0 items-center gap-1.5 border-b border-white/[0.06] px-2", children: _jsx("div", { className: "flex items-center gap-0.5", role: "tablist", "aria-label": "Animation mode", children: [
39
+ ["path", "Path flythrough"],
40
+ ["clips", "Clip preview"],
41
+ ].map(([id, label]) => (_jsx("button", { type: "button", role: "tab", "aria-selected": mode === id, className: `rounded-[5px] px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${FOCUS_RING} ${mode === id ? "bg-cyan-500/15 text-cyan-200" : "text-neutral-500 hover:text-neutral-300"}`, onClick: () => setMode(id), children: label }, id))) }) }), mode === "path" ? (_jsx(PathFlythrough, { session: session, api: api })) : (_jsx(ClipPreview, { session: session, ui: ui, rigged: rigged }))] }));
42
+ }
43
+ /** Clip-preview mode: pick a rigged asset (placed instance or catalog) and play its clips in the viewport. */
44
+ function ClipPreview({ session, ui, rigged, }) {
45
+ const preview = useStoreSelector(ui, (state) => state.clipPreview);
46
+ const selection = useStoreSelector(session, (state) => state.selection, shallowArrayEqual);
47
+ // A placed marker in the selection that resolves to a rigged catalog asset — offer to preview it.
48
+ const selectedRigged = useMemo(() => {
49
+ const document = session.getState().document;
50
+ for (const id of selection) {
51
+ const marker = document.markers.find((entry) => entry.id === id);
52
+ if (marker === undefined)
53
+ continue;
54
+ const catalogId = markerCatalogId(marker);
55
+ if (catalogId === null)
56
+ continue;
57
+ const asset = rigged.find((entry) => entry.id === catalogId);
58
+ if (asset !== undefined)
59
+ return asset;
60
+ }
61
+ return null;
62
+ }, [selection, rigged, session]);
63
+ const startPreview = useCallback((asset) => {
64
+ if (asset.url === undefined || asset.clips === undefined)
65
+ return;
66
+ const firstClip = asset.clips[0] ?? null;
67
+ ui.patch({
68
+ clipPreview: {
69
+ source: { assetId: asset.id, label: asset.label, url: asset.url, clips: asset.clips },
70
+ driver: initialClipPreviewState(firstClip),
71
+ duration: 0,
72
+ },
73
+ });
74
+ }, [ui]);
75
+ const updateDriver = useCallback((next) => {
76
+ const current = ui.getState().clipPreview;
77
+ if (current === null)
78
+ return;
79
+ ui.patch({ clipPreview: { ...current, driver: next } });
80
+ }, [ui]);
81
+ // While playing with a known clip duration, advance the driver each frame (freerunning mixer + slider in sync).
82
+ const previewRef = useRef(preview);
83
+ previewRef.current = preview;
84
+ const playing = preview?.driver.playing ?? false;
85
+ useEffect(() => {
86
+ if (!playing)
87
+ return;
88
+ let frame = 0;
89
+ let last = performance.now();
90
+ const tick = (now) => {
91
+ const dt = Math.min(0.05, Math.max(0, (now - last) / 1000));
92
+ last = now;
93
+ const current = previewRef.current;
94
+ if (current === null || !current.driver.playing)
95
+ return;
96
+ const next = advancePreview(current.driver, dt, current.duration);
97
+ if (next !== current.driver)
98
+ ui.patch({ clipPreview: { ...current, driver: next } });
99
+ frame = requestAnimationFrame(tick);
100
+ };
101
+ frame = requestAnimationFrame(tick);
102
+ return () => cancelAnimationFrame(frame);
103
+ }, [playing, ui, preview?.duration, preview?.source.assetId]);
104
+ if (rigged.length === 0) {
105
+ return (_jsx(EmptyState, { icon: "film", title: "Clip preview", badge: "Rigged assets", description: "Preview animation clips in the viewport. No rigged assets in this game's catalog \u2014 imported/catalog models carrying animation clips (\u25B6 badge in the Content Browser) show up here." }));
106
+ }
107
+ const source = preview?.source ?? null;
108
+ const driver = preview?.driver ?? null;
109
+ const duration = preview?.duration ?? 0;
110
+ const clipEntries = clipEntriesFromRef(source);
111
+ return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [_jsxs("div", { className: "flex h-7 shrink-0 items-center gap-1.5 border-b border-white/[0.06] px-2", children: [_jsx("span", { className: "text-[10px] uppercase tracking-wider text-neutral-500", children: "Clip preview" }), _jsxs("span", { className: `text-[10px] text-neutral-600 ${NUMERIC}`, children: [rigged.length, " rigged asset", rigged.length === 1 ? "" : "s", " \u00B7 viewport playback"] }), selectedRigged !== null && selectedRigged.id !== source?.assetId ? (_jsx("button", { type: "button", className: `ml-auto rounded-[5px] border border-cyan-400/30 bg-cyan-500/15 px-2 py-0.5 text-[10px] text-cyan-200 transition-colors hover:bg-cyan-500/25 ${FOCUS_RING}`, onClick: () => startPreview(selectedRigged), title: `Preview the selected placement's rig (${selectedRigged.label})`, children: "Preview selection" })) : null] }), _jsxs("div", { className: "flex min-h-0 flex-1 flex-col gap-2 overflow-auto p-3", children: [_jsxs("label", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[9px] uppercase tracking-wider text-neutral-600", children: "Rigged asset" }), _jsxs("select", { className: `${INPUT_CLS} h-8 px-2 text-[12px]`, value: source?.assetId ?? "", "aria-label": "Preview asset", onChange: (event) => {
112
+ const asset = rigged.find((entry) => entry.id === event.target.value);
113
+ if (asset !== undefined)
114
+ startPreview(asset);
115
+ }, children: [_jsx("option", { value: "", disabled: true, children: "Select a rigged asset\u2026" }), rigged.map((asset) => (_jsxs("option", { value: asset.id, children: [asset.label, " \u00B7 ", asset.clips?.length ?? 0, " clips"] }, asset.id)))] })] }), source === null || driver === null ? (_jsx("p", { className: "text-[10px] leading-relaxed text-neutral-600", children: "Pick a rigged asset above (or select a placed instance and press \u201CPreview selection\u201D) to play its clips at the camera focus point." })) : (_jsxs(_Fragment, { children: [_jsxs("label", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[9px] uppercase tracking-wider text-neutral-600", children: "Clip" }), _jsx("select", { className: `${INPUT_CLS} h-8 px-2 text-[12px]`, value: driver.clipName ?? "", "aria-label": "Preview clip", onChange: (event) => updateDriver(selectPreviewClip(driver, event.target.value)), children: clipEntries.map((entry) => (_jsxs("option", { value: entry.name, children: [entry.name, entry.role === null ? "" : ` · ${entry.role}`] }, entry.name))) })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(IconButton, { icon: driver.playing ? "pause" : "play", label: driver.playing ? "Pause clip" : "Play clip", size: 14, active: driver.playing, disabled: driver.clipName === null, onClick: () => updateDriver(togglePreviewPlaying(driver)) }), _jsx("button", { type: "button", className: `rounded-[5px] border px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${FOCUS_RING} ${driver.loop
116
+ ? "border-cyan-400/30 bg-cyan-500/15 text-cyan-200"
117
+ : "border-white/[0.07] bg-white/[0.02] text-neutral-500 hover:text-neutral-300"}`, "aria-pressed": driver.loop, "aria-label": driver.loop ? "Loop on" : "Loop off", onClick: () => updateDriver(setPreviewLoop(driver, !driver.loop)), children: "Loop" }), _jsxs("div", { className: "ml-auto flex items-center gap-1.5", children: [_jsx("span", { className: "text-[9px] uppercase tracking-wider text-neutral-600", children: "Speed" }), _jsx("input", { type: "range", min: MIN_PREVIEW_SPEED, max: MAX_PREVIEW_SPEED, step: 0.05, value: driver.speed, "aria-label": "Playback speed", className: `h-2 w-24 accent-cyan-400 ${FOCUS_RING}`, onChange: (event) => updateDriver(setPreviewSpeed(driver, Number(event.target.value))) }), _jsxs("span", { className: `w-8 text-right text-[10px] text-neutral-400 ${NUMERIC}`, children: [driver.speed.toFixed(2), "\u00D7"] })] })] }), _jsxs("label", { className: "flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-center justify-between text-[9px] uppercase tracking-wider text-neutral-600", children: [_jsx("span", { children: "Scrub" }), _jsxs("span", { className: NUMERIC, children: [driver.time.toFixed(2), duration > 0 ? ` / ${duration.toFixed(2)}` : "", " s"] })] }), _jsx("input", { type: "range", min: 0, max: duration > 0 ? duration : 1, step: 0.01, value: Math.min(driver.time, duration > 0 ? duration : 1), "aria-label": "Clip time", disabled: driver.clipName === null || duration <= 0, className: `h-2 w-full accent-cyan-400 ${FOCUS_RING}`, onChange: (event) => updateDriver(scrubPreview(driver, Number(event.target.value))) })] }), _jsxs("p", { className: "text-[10px] leading-relaxed text-neutral-600", children: ["Plays the selected clip on ", source.label, " at the camera focus point. Same mixer the game runs at play time. Authoring lives in the inspector\u2019s Animation section on a placed instance."] })] }))] })] }));
118
+ }
119
+ /** Path flythrough mode: scrub the orbit camera along authored scene polylines. */
120
+ function PathFlythrough({ session, api }) {
21
121
  const paths = useStoreSelector(session, (state) => state.document.paths);
22
122
  const scrubbable = useMemo(() => listScrubbablePaths(paths), [paths]);
23
123
  const [selectedId, setSelectedId] = useState(null);
@@ -114,11 +214,11 @@ export function AnimationPanel({ session, api }) {
114
214
  };
115
215
  }, [playing, selected, applyProgress, session]);
116
216
  if (scrubbable.length === 0) {
117
- return (_jsx(EmptyState, { icon: "film", title: "Path flythrough", badge: "Scoped", description: "Scope: scrub the orbit camera along authored scene paths (routes, roads, patrols). Draw a path with 2+ points via Add \u2192 path/road, then open this tab. Keyframe clip authoring and scene-kind param animation are not in scope." }));
217
+ return (_jsx(EmptyState, { icon: "film", title: "Path flythrough", badge: "Scoped", description: "Scope: scrub the orbit camera along authored scene paths (routes, roads, patrols). Draw a path with 2+ points via Add \u2192 path/road, then open this tab. Switch to Clip preview above to play a rigged asset's animations." }));
118
218
  }
119
219
  const lengthLabel = selected === null ? "—" : selected.length < 10 ? `${selected.length.toFixed(1)} m` : `${Math.round(selected.length)} m`;
120
220
  const distance = selected === null || selected.length <= 0 ? 0 : progress * selected.length;
121
- return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [_jsxs("div", { className: "flex h-8 shrink-0 items-center gap-1.5 border-b border-white/[0.06] px-2", children: [_jsx("span", { className: "text-[10px] uppercase tracking-wider text-neutral-500", children: "Path flythrough" }), _jsxs("span", { className: `text-[10px] text-neutral-600 ${NUMERIC}`, children: [scrubbable.length, " path", scrubbable.length === 1 ? "" : "s", " \u00B7 camera follows scrub"] }), _jsxs("div", { className: "ml-auto flex items-center gap-1", children: [_jsx(IconButton, { icon: playing ? "pause" : "play", label: playing ? "Pause flythrough" : "Play flythrough", size: 12, active: playing, disabled: selected === null || selected.length <= 0, onClick: () => setPlaying((value) => !value) }), _jsx("button", { type: "button", className: `rounded-[5px] border px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${FOCUS_RING} ${loop
221
+ return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [_jsxs("div", { className: "flex h-7 shrink-0 items-center gap-1.5 border-b border-white/[0.06] px-2", children: [_jsx("span", { className: "text-[10px] uppercase tracking-wider text-neutral-500", children: "Path flythrough" }), _jsxs("span", { className: `text-[10px] text-neutral-600 ${NUMERIC}`, children: [scrubbable.length, " path", scrubbable.length === 1 ? "" : "s", " \u00B7 camera follows scrub"] }), _jsxs("div", { className: "ml-auto flex items-center gap-1", children: [_jsx(IconButton, { icon: playing ? "pause" : "play", label: playing ? "Pause flythrough" : "Play flythrough", size: 12, active: playing, disabled: selected === null || selected.length <= 0, onClick: () => setPlaying((value) => !value) }), _jsx("button", { type: "button", className: `rounded-[5px] border px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${FOCUS_RING} ${loop
122
222
  ? "border-cyan-400/30 bg-cyan-500/15 text-cyan-200"
123
223
  : "border-white/[0.07] bg-white/[0.02] text-neutral-500 hover:text-neutral-300"}`, "aria-pressed": loop, "aria-label": loop ? "Loop on" : "Loop off", onClick: () => setLoop((value) => !value), children: "Loop" })] })] }), _jsxs("div", { className: "flex min-h-0 flex-1 gap-3 overflow-auto p-3", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: [_jsxs("label", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[9px] uppercase tracking-wider text-neutral-600", children: "Path" }), _jsx("select", { className: `${INPUT_CLS} h-8 px-2 text-[12px]`, value: selected?.id ?? "", "aria-label": "Flythrough path", onChange: (event) => onSelectPath(event.target.value), children: scrubbable.map((entry) => (_jsxs("option", { value: entry.id, children: [entry.label, " \u00B7 ", entry.kind, " \u00B7 ", entry.pointCount, " pts"] }, entry.id))) })] }), _jsxs("label", { className: "flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-center justify-between text-[9px] uppercase tracking-wider text-neutral-600", children: [_jsx("span", { children: "Progress" }), _jsxs("span", { className: NUMERIC, children: [(progress * 100).toFixed(0), "% \u00B7 ", distance.toFixed(1), " / ", lengthLabel.replace(" m", ""), " m"] })] }), _jsx("input", { type: "range", min: 0, max: 1, step: 0.001, value: progress, "aria-label": "Flythrough progress", disabled: selected === null || selected.length <= 0, className: `h-2 w-full accent-cyan-400 ${FOCUS_RING}`, onChange: (event) => onScrub(Number(event.target.value)) })] }), _jsxs("p", { className: "text-[10px] leading-relaxed text-neutral-600", children: ["Scope: real path points only \u2014 no fabricated keyframes. Play moves the orbit focus along the polyline at", " ", DEFAULT_PLAY_SPEED, " u/s. Edit points in the viewport or inspector."] })] }), _jsxs("div", { className: "flex w-40 shrink-0 flex-col gap-1.5", children: [_jsx(Meta, { label: "Kind", value: selected?.kind ?? "—" }), _jsx(Meta, { label: "Points", value: selected === null ? "—" : String(selected.pointCount) }), _jsx(Meta, { label: "Length", value: lengthLabel }), _jsx(Meta, { label: "At", value: selectedPath === undefined
124
224
  ? "—"
@@ -1,6 +1,7 @@
1
1
  import type { EditorSession } from "@jgengine/core/editor/index";
2
2
  import type { EditorAssetEntry } from "../AssetBrowser.js";
3
3
  import type { EditorHostApi } from "../session.js";
4
+ import type { EditorUiStore } from "../uiStore.js";
4
5
  import type { EditorConsoleStore } from "./consoleStore.js";
5
6
  import type { BottomDockTab, BrowserViewMode } from "./layoutStore.js";
6
7
  import type { PerfHistoryStore } from "./perfHistory.js";
@@ -8,13 +9,14 @@ import type { PerfHistoryStore } from "./perfHistory.js";
8
9
  * Tabbed bottom dock: Content Browser, Console, Profiler, Animation (path flythrough scrub),
9
10
  * and the AI Assistant. Only the active tab's panel mounts, so hidden tools cost nothing per frame.
10
11
  */
11
- export declare function BottomDock({ tab, onSelectTab, onClose, assets, session, api, consoleStore, perfHistory, browserView, onSetBrowserView, onPlaceAsset, onImportModels, importBusy, }: {
12
+ export declare function BottomDock({ tab, onSelectTab, onClose, assets, session, api, ui, consoleStore, perfHistory, browserView, onSetBrowserView, onPlaceAsset, onImportModels, importBusy, }: {
12
13
  tab: BottomDockTab;
13
14
  onSelectTab: (tab: BottomDockTab) => void;
14
15
  onClose: () => void;
15
16
  assets: readonly EditorAssetEntry[];
16
17
  session: EditorSession;
17
18
  api: EditorHostApi;
19
+ ui: EditorUiStore;
18
20
  consoleStore: EditorConsoleStore;
19
21
  perfHistory: PerfHistoryStore;
20
22
  browserView: BrowserViewMode;
@@ -11,7 +11,7 @@ import { IconButton, PanelTabs } from "./ui.js";
11
11
  * Tabbed bottom dock: Content Browser, Console, Profiler, Animation (path flythrough scrub),
12
12
  * and the AI Assistant. Only the active tab's panel mounts, so hidden tools cost nothing per frame.
13
13
  */
14
- export function BottomDock({ tab, onSelectTab, onClose, assets, session, api, consoleStore, perfHistory, browserView, onSetBrowserView, onPlaceAsset, onImportModels, importBusy, }) {
14
+ export function BottomDock({ tab, onSelectTab, onClose, assets, session, api, ui, consoleStore, perfHistory, browserView, onSetBrowserView, onPlaceAsset, onImportModels, importBusy, }) {
15
15
  const consoleEntries = useSyncExternalStore(consoleStore.subscribe, consoleStore.getEntries, consoleStore.getEntries);
16
16
  const consoleErrorCount = consoleEntries.reduce((sum, entry) => (entry.severity === "error" ? sum + 1 : sum), 0);
17
17
  let content;
@@ -25,7 +25,7 @@ export function BottomDock({ tab, onSelectTab, onClose, assets, session, api, co
25
25
  content = _jsx(ProfilerPanel, { history: perfHistory });
26
26
  }
27
27
  else if (tab === "animation") {
28
- content = _jsx(AnimationPanel, { session: session, api: api });
28
+ content = _jsx(AnimationPanel, { session: session, api: api, ui: ui, assets: assets });
29
29
  }
30
30
  else {
31
31
  content = _jsx(AgentPanel, { api: api, embedded: true });
@@ -0,0 +1,85 @@
1
+ import { type ClipRole } from "@jgengine/core/game/clipRoles";
2
+ import type { ModelAnimationConfig } from "@jgengine/core/game/playableGame";
3
+ /**
4
+ * Editor viewport clip-preview model — pure logic, no JSX / R3F. Mirrors how `pathFlythrough.ts`
5
+ * splits the flythrough math out of {@link AnimationPanel}: derives a rigged asset's playable clip
6
+ * list and models a scrub/loop/speed playback driver that the viewport layer and the dock panel
7
+ * both consume. The actual mixer is the shell's `useModelAnimation`; this module only shapes the
8
+ * config it plays and the transitions the controls dispatch.
9
+ *
10
+ * @internal
11
+ */
12
+ /** A rigged asset carrying animation clip names — the browser entry / catalog `ModelAssetRef`. */
13
+ export interface ClipSourceRef {
14
+ readonly clips?: readonly string[];
15
+ }
16
+ /** One previewable clip with its detected semantic role (null when unclassified). */
17
+ export interface ClipEntry {
18
+ readonly name: string;
19
+ readonly role: ClipRole | null;
20
+ }
21
+ /**
22
+ * Lists a rigged asset's clips with their classified roles, preserving catalog order. Empty when
23
+ * the ref carries no clips (not rigged, or clip metadata absent).
24
+ */
25
+ export declare function clipEntriesFromRef(ref: ClipSourceRef | null | undefined): ClipEntry[];
26
+ /** A rigged asset resolved for viewport preview: its catalog id, label, GLB url, and clip names. */
27
+ export interface ClipPreviewSource {
28
+ readonly assetId: string;
29
+ readonly label: string;
30
+ readonly url: string;
31
+ readonly clips: readonly string[];
32
+ }
33
+ /**
34
+ * The live clip-preview session shared across panels via the editor UI store: which asset is being
35
+ * previewed, the playback driver, and the selected clip's measured duration (published by the
36
+ * viewport layer once its GLB loads; 0 while unknown).
37
+ */
38
+ export interface ClipPreviewSession {
39
+ readonly source: ClipPreviewSource;
40
+ readonly driver: ClipPreviewState;
41
+ readonly duration: number;
42
+ }
43
+ /** Playback state of the clip preview driver. `time` is seconds into the clip. */
44
+ export interface ClipPreviewState {
45
+ /** Clip currently loaded into the preview, or null when nothing is selected. */
46
+ clipName: string | null;
47
+ playing: boolean;
48
+ loop: boolean;
49
+ /** Playback rate multiplier; clamped to a sane preview range. */
50
+ speed: number;
51
+ /** Playhead position in seconds. Meaningful while paused/scrubbing. */
52
+ time: number;
53
+ }
54
+ /** Lowest / highest preview playback rate the speed control allows. */
55
+ /** Lower bound for the clip-preview playback rate. */
56
+ export declare const MIN_PREVIEW_SPEED = 0.1;
57
+ /** Upper bound for the clip-preview playback rate. */
58
+ export declare const MAX_PREVIEW_SPEED = 4;
59
+ /** Fresh driver state for a clip (auto-plays when a clip is given, like the browser badge implies). */
60
+ export declare function initialClipPreviewState(clipName?: string | null): ClipPreviewState;
61
+ /** Switch to a different clip: resets the playhead and starts playing it. */
62
+ export declare function selectPreviewClip(state: ClipPreviewState, clipName: string): ClipPreviewState;
63
+ /** Sets whether the previewed clip is playing; pausing keeps the current scrub time. */
64
+ export declare function setPreviewPlaying(state: ClipPreviewState, playing: boolean): ClipPreviewState;
65
+ /** Toggles play/pause of the previewed clip without losing the scrub position. */
66
+ export declare function togglePreviewPlaying(state: ClipPreviewState): ClipPreviewState;
67
+ /** Sets whether the previewed clip loops when it reaches its end. */
68
+ export declare function setPreviewLoop(state: ClipPreviewState, loop: boolean): ClipPreviewState;
69
+ /** Sets the preview playback rate, clamped to [MIN_PREVIEW_SPEED, MAX_PREVIEW_SPEED]. */
70
+ export declare function setPreviewSpeed(state: ClipPreviewState, speed: number): ClipPreviewState;
71
+ /** Scrub to an absolute time (seconds). Pauses playback, matching the path-flythrough scrubber. */
72
+ export declare function scrubPreview(state: ClipPreviewState, time: number): ClipPreviewState;
73
+ /**
74
+ * Advances the playhead by `dt` seconds at the current speed, wrapping when looping and clamping
75
+ * (then stopping) at `duration` otherwise. No-op while paused, clip-less, or the duration is
76
+ * unknown (<= 0). Uses the same time += speed·dt formula the shell mixer runs, so a panel that ticks
77
+ * this in lockstep with the freerunning viewport mixer stays in sync.
78
+ */
79
+ export declare function advancePreview(state: ClipPreviewState, dt: number, duration: number): ClipPreviewState;
80
+ /**
81
+ * The `ModelConfig.animation` config the preview layer feeds to `useModelAnimation`. Playing yields
82
+ * a freerunning single-clip config (mixer owns the clock, no per-frame churn); paused yields a held
83
+ * `paused`+`time` pose so a scrub renders the exact frame. Returns undefined with no clip (bind pose).
84
+ */
85
+ export declare function previewAnimationConfig(state: ClipPreviewState): ModelAnimationConfig | undefined;
@@ -0,0 +1,82 @@
1
+ import { classifyClip } from "@jgengine/core/game/clipRoles";
2
+ /**
3
+ * Lists a rigged asset's clips with their classified roles, preserving catalog order. Empty when
4
+ * the ref carries no clips (not rigged, or clip metadata absent).
5
+ */
6
+ export function clipEntriesFromRef(ref) {
7
+ const clips = ref?.clips;
8
+ if (clips === undefined || clips.length === 0)
9
+ return [];
10
+ return clips.map((name) => ({ name, role: classifyClip(name) }));
11
+ }
12
+ /** Lowest / highest preview playback rate the speed control allows. */
13
+ /** Lower bound for the clip-preview playback rate. */
14
+ export const MIN_PREVIEW_SPEED = 0.1;
15
+ /** Upper bound for the clip-preview playback rate. */
16
+ export const MAX_PREVIEW_SPEED = 4;
17
+ function clampSpeed(speed) {
18
+ if (!Number.isFinite(speed))
19
+ return 1;
20
+ return Math.max(MIN_PREVIEW_SPEED, Math.min(MAX_PREVIEW_SPEED, speed));
21
+ }
22
+ /** Fresh driver state for a clip (auto-plays when a clip is given, like the browser badge implies). */
23
+ export function initialClipPreviewState(clipName = null) {
24
+ return { clipName, playing: clipName !== null, loop: true, speed: 1, time: 0 };
25
+ }
26
+ /** Switch to a different clip: resets the playhead and starts playing it. */
27
+ export function selectPreviewClip(state, clipName) {
28
+ return { ...state, clipName, time: 0, playing: true };
29
+ }
30
+ /** Sets whether the previewed clip is playing; pausing keeps the current scrub time. */
31
+ export function setPreviewPlaying(state, playing) {
32
+ if (state.clipName === null)
33
+ return { ...state, playing: false };
34
+ return { ...state, playing };
35
+ }
36
+ /** Toggles play/pause of the previewed clip without losing the scrub position. */
37
+ export function togglePreviewPlaying(state) {
38
+ return setPreviewPlaying(state, !state.playing);
39
+ }
40
+ /** Sets whether the previewed clip loops when it reaches its end. */
41
+ export function setPreviewLoop(state, loop) {
42
+ return { ...state, loop };
43
+ }
44
+ /** Sets the preview playback rate, clamped to [MIN_PREVIEW_SPEED, MAX_PREVIEW_SPEED]. */
45
+ export function setPreviewSpeed(state, speed) {
46
+ return { ...state, speed: clampSpeed(speed) };
47
+ }
48
+ /** Scrub to an absolute time (seconds). Pauses playback, matching the path-flythrough scrubber. */
49
+ export function scrubPreview(state, time) {
50
+ const next = Number.isFinite(time) ? Math.max(0, time) : 0;
51
+ return { ...state, time: next, playing: false };
52
+ }
53
+ /**
54
+ * Advances the playhead by `dt` seconds at the current speed, wrapping when looping and clamping
55
+ * (then stopping) at `duration` otherwise. No-op while paused, clip-less, or the duration is
56
+ * unknown (<= 0). Uses the same time += speed·dt formula the shell mixer runs, so a panel that ticks
57
+ * this in lockstep with the freerunning viewport mixer stays in sync.
58
+ */
59
+ export function advancePreview(state, dt, duration) {
60
+ if (!state.playing || state.clipName === null || duration <= 0 || !Number.isFinite(dt) || dt <= 0) {
61
+ return state;
62
+ }
63
+ let next = state.time + state.speed * dt;
64
+ if (next >= duration) {
65
+ if (state.loop)
66
+ next %= duration;
67
+ else
68
+ return { ...state, time: duration, playing: false };
69
+ }
70
+ return { ...state, time: next };
71
+ }
72
+ /**
73
+ * The `ModelConfig.animation` config the preview layer feeds to `useModelAnimation`. Playing yields
74
+ * a freerunning single-clip config (mixer owns the clock, no per-frame churn); paused yields a held
75
+ * `paused`+`time` pose so a scrub renders the exact frame. Returns undefined with no clip (bind pose).
76
+ */
77
+ export function previewAnimationConfig(state) {
78
+ if (state.clipName === null)
79
+ return undefined;
80
+ const base = { clip: state.clipName, loop: state.loop, timeScale: state.speed };
81
+ return state.playing ? base : { ...base, paused: true, time: state.time };
82
+ }
@@ -0,0 +1,22 @@
1
+ import type { ParamSchema, ParsedParams, SceneKindCoverage, SceneKindObject } from "@jgengine/core/scene/sceneKinds";
2
+ /** A patch to a single meta key, coalesced under a stable undo key (matches `SchemaInspector`). */
3
+ type MetaPatch = (patch: Record<string, unknown>, coalesce: string) => void;
4
+ /**
5
+ * The shared leading block for every scatterable kind (`grass_field` / `scatter` / `city`): one
6
+ * consistent Area → Assets → Density gesture. Renders the footprint Area readout, a compact summary
7
+ * of what fills it (the weighted palette, when the kind declares one), the single Density slider
8
+ * hoisted out of the schema body, and the kind's budget clamp-and-warn `note`. The rest of the kind's
9
+ * parameters render below via `SchemaInspector` (with the density key hidden). One module so grass,
10
+ * scatter, and city never re-derive the "how much stuff grows here" affordance.
11
+ * @internal — mounted by `InspectorPanel`'s `KindInspector` when a kind declares `coverage`.
12
+ */
13
+ export declare function CoverageSection({ schema, coverage, object, params, note, accent, onMeta, }: {
14
+ schema: ParamSchema;
15
+ coverage: SceneKindCoverage;
16
+ object: SceneKindObject;
17
+ params: ParsedParams;
18
+ note?: string;
19
+ accent: string;
20
+ onMeta: MetaPatch;
21
+ }): import("react").JSX.Element;
22
+ export {};
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { scatterFootprintArea } from "@jgengine/core/world/scatterCoverage";
3
+ import { MICRO_LABEL } from "./theme.js";
4
+ const fmt = (n) => Math.round(n).toLocaleString();
5
+ /** One-line summary of a weighted "what fills it" palette: count + the leading item ids. */
6
+ function assetsSummary(entries) {
7
+ if (entries === undefined || entries.length === 0)
8
+ return null;
9
+ const ids = entries.map((entry) => entry.item).filter((item) => item.length > 0);
10
+ if (ids.length === 0)
11
+ return null;
12
+ const head = ids.slice(0, 4).join(", ");
13
+ const rest = ids.length > 4 ? ` +${ids.length - 4}` : "";
14
+ return `${ids.length} ${ids.length === 1 ? "type" : "types"} · ${head}${rest}`;
15
+ }
16
+ /**
17
+ * The shared leading block for every scatterable kind (`grass_field` / `scatter` / `city`): one
18
+ * consistent Area → Assets → Density gesture. Renders the footprint Area readout, a compact summary
19
+ * of what fills it (the weighted palette, when the kind declares one), the single Density slider
20
+ * hoisted out of the schema body, and the kind's budget clamp-and-warn `note`. The rest of the kind's
21
+ * parameters render below via `SchemaInspector` (with the density key hidden). One module so grass,
22
+ * scatter, and city never re-derive the "how much stuff grows here" affordance.
23
+ * @internal — mounted by `InspectorPanel`'s `KindInspector` when a kind declares `coverage`.
24
+ */
25
+ export function CoverageSection({ schema, coverage, object, params, note, accent, onMeta, }) {
26
+ const area = scatterFootprintArea(object);
27
+ const densityField = schema.fields.find((field) => field.key === coverage.densityKey);
28
+ const density = typeof params[coverage.densityKey] === "number" ? params[coverage.densityKey] : 0;
29
+ const assets = coverage.assetsKey === undefined ? null : assetsSummary(params[coverage.assetsKey]);
30
+ 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: "Coverage" }), _jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO_LABEL, children: "area" }), _jsx("span", { className: "text-cyan-200", children: area > 0 ? `${fmt(area)} m²` : "—" })] }), assets !== null ? (_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO_LABEL, children: "fills with" }), _jsx("span", { className: "min-w-0 truncate text-right text-[10px] text-neutral-300", title: assets, children: assets })] })) : null, densityField !== undefined && densityField.type === "range" ? (_jsxs("label", { className: "block space-y-1", children: [_jsxs("span", { className: "flex items-center justify-between", children: [_jsx("span", { className: MICRO_LABEL, children: densityField.label ?? densityField.key }), _jsxs("span", { className: "text-cyan-200", children: [density.toFixed(densityField.step !== undefined && densityField.step >= 1 ? 0 : 2), densityField.unit ?? ""] })] }), _jsx("input", { type: "range", min: densityField.min, max: densityField.max, step: densityField.step ?? (densityField.max - densityField.min) / 100, value: density, className: "w-full accent-emerald-400", "aria-label": `${densityField.label ?? densityField.key} density`, onChange: (event) => onMeta({ [coverage.densityKey]: Number(event.target.value) }, `kind:${coverage.densityKey}`) })] })) : null, note !== undefined && note.length > 0 ? _jsx("div", { className: "text-[10px] text-neutral-500", children: note }) : null] }));
31
+ }
package/dist/uiStore.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { EditorSession, EditorVec3, EditorVolumeShape } from "@jgengine/core/editor/index";
2
2
  import type { TerraformFalloff, TerraformShape } from "@jgengine/core/world/terraform";
3
+ import type { ClipPreviewSession } from "./shell/clipPreview.js";
3
4
  /** Which top-level editor tool is active: object placement/selection, or terrain sculpting. */
4
5
  export type EditorTool = "select" | "terrain";
5
6
  /** A heightfield sculpt brush the terrain tool can apply. */
@@ -120,6 +121,11 @@ export interface EditorUiState {
120
121
  sculpt: SculptSettings;
121
122
  paint: PaintSettings;
122
123
  contextMenu: EditorContextMenuState | null;
124
+ /**
125
+ * Active viewport clip-preview session (rigged asset + playback driver), or null when nothing is
126
+ * previewing. Driven by the Animation dock's clip mode; rendered by `ClipPreviewLayer`.
127
+ */
128
+ clipPreview: ClipPreviewSession | null;
123
129
  }
124
130
  /** Subscribable store for the editor's transient UI state (gizmo, snapping, placement). */
125
131
  export interface EditorUiStore {
package/dist/uiStore.js CHANGED
@@ -61,6 +61,7 @@ export function createEditorUiStore() {
61
61
  sculpt: { ...DEFAULT_SCULPT_SETTINGS },
62
62
  paint: { ...DEFAULT_PAINT_SETTINGS },
63
63
  contextMenu: null,
64
+ clipPreview: null,
64
65
  };
65
66
  const listeners = new Set();
66
67
  const emit = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/editor",
3
- "version": "0.13.0",
3
+ "version": "0.14.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,9 +31,9 @@
31
31
  "test": "bun test src"
32
32
  },
33
33
  "dependencies": {
34
- "@jgengine/core": "^0.13.0",
35
- "@jgengine/react": "^0.13.0",
36
- "@jgengine/shell": "^0.13.0"
34
+ "@jgengine/core": "^0.14.0",
35
+ "@jgengine/react": "^0.14.0",
36
+ "@jgengine/shell": "^0.14.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@react-three/drei": "^10.0.0",
@@ -5,7 +5,7 @@ description: Route game work to the smallest useful JGengine guidance set.
5
5
 
6
6
  # JGengine intake and router
7
7
 
8
- Read the repository [README](../../../README.md) for package truth, commands, layering, and license. This skill decides what to load; it is not an engine manual.
8
+ Read the JGengine README for package truth, commands, layering, and license — the repo-root [`README.md`](../../../README.md) in the monorepo, or [jgengine.com](https://jgengine.com) from a scaffolded consumer project (where the relative link does not resolve). This skill decides what to load; it is not an engine manual.
9
9
 
10
10
  ## 1. Establish the target
11
11
 
@@ -20,6 +20,8 @@ Treat the pitch as a unique composition of needs, not a genre to fill in — nev
20
20
 
21
21
  For a new in-repo game, scaffold with `bun run new:game "<id-or-title>"` (thin script alias for `jgengine create`, auto-detects the in-repo variant). Outside this monorepo, start with `npx jgengine create`; never copy `Games/*`.
22
22
 
23
+ For an existing game asked to pick up a new engine release ("jgengine published a new version, grab it"), run `npx jgengine upgrade` in the project first: it diffs installed `@jgengine/*` versions against the latest release and prints every Migrate step and Adopt-worthy addition in between (`--json` for structured output). Bump, migrate oldest-first, then work the Adopt lists instead of reconstructing the changelog by hand.
24
+
23
25
  ## 2. Select domains
24
26
 
25
27
  Load only rows the target needs. Do not preload every domain.
@@ -43,7 +45,7 @@ World content routes editor-first: any request that adds, moves, restyles, or re
43
45
  Within each selected domain:
44
46
 
45
47
  0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code) for `combat-loop`, `boss-telegraph`, `loot`, `quest`, `coop-presence`, `third-person-camera`; `npx jgengine recipe` lists them. Copy that instead of reconning per-file.
46
- 1. Search `capabilities.md` by intent; it maps needs to imports.
48
+ 1. Search `capabilities.md` by intent; it maps needs to imports. Outside the monorepo (or before a domain skill is even loaded), `npx jgengine find <intent>` greps every shipped capability index at once and prints the primitive + import — reach for it the moment you're tempted to hand-roll a HUD window, inventory grid, paperdoll, hotkey listener, minimap, stat bar, or character motion. If you're writing a `z-index`, a `keydown` for a panel, a `<div>` inventory, or a limb bob, a shipped drop-in almost certainly exists (`usePanels`/`PanelHost`, `InventoryGrid`, `CharacterSheet`/`Paperdoll`, `PartMotionRig`, `EntityPreview`); games own the *skin*, not the re-derivation.
47
49
  2. Open `api.md` only when exact signatures or export inventory are needed.
48
50
  3. Open the linked reference only for a deeper recipe or trap.
49
51
  4. For how several primitives wire into a running loop, read the domain's `recipes/` — connected, genre-free walkthroughs organized by composition seam; this skill's own [recipes/minimal-game.md](recipes/minimal-game.md) is the default whole-game walkthrough. Prefer a recipe over reading a game's source; the games under `Games/*` are not templates or references, and a hybrid game is just a different composition of the same primitives.
@@ -6,7 +6,7 @@
6
6
 
7
7
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
8
8
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
9
- - `VERSION` (const): const VERSION: "0.13.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.14.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -220,7 +220,7 @@
220
220
 
221
221
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
222
222
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
223
- - `VERSION` (const): const VERSION: "0.13.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
223
+ - `VERSION` (const): const VERSION: "0.14.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
224
224
 
225
225
  ## @jgengine/core/runtime/adapter
226
226