@jgengine/editor 0.12.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,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 });
@@ -8,8 +8,10 @@ export type TopBarSaveState = "idle" | "saving" | "saved" | "error";
8
8
  * edit mode; Play mode mounts {@link PlayModeBar}, which wires the same controls to the
9
9
  * runtime pause/step RPCs so mode switches keep shell chrome.
10
10
  */
11
- export declare function TopAppBar({ gameId, dirty, saveState, lastSavedAt, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }: {
11
+ export declare function TopAppBar({ gameId, onExitToGame, dirty, saveState, lastSavedAt, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }: {
12
12
  gameId: string;
13
+ /** When set (editor summoned over a game), renders an "Exit to game" control that closes the editor. */
14
+ onExitToGame?: () => void;
13
15
  dirty: boolean;
14
16
  saveState: TopBarSaveState;
15
17
  /** Epoch ms of the last successful save this session; omit/null when never saved. */
@@ -34,7 +34,7 @@ function SaveStatus({ dirty, saveState, lastSavedAt, }) {
34
34
  * edit mode; Play mode mounts {@link PlayModeBar}, which wires the same controls to the
35
35
  * runtime pause/step RPCs so mode switches keep shell chrome.
36
36
  */
37
- export function TopAppBar({ gameId, dirty, saveState, lastSavedAt = null, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }) {
37
+ export function TopAppBar({ gameId, onExitToGame, dirty, saveState, lastSavedAt = null, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }) {
38
38
  const [menuOpen, setMenuOpen] = useState(false);
39
39
  const menuRef = useRef(null);
40
40
  useEffect(() => {
@@ -56,7 +56,7 @@ export function TopAppBar({ gameId, dirty, saveState, lastSavedAt = null, saveAv
56
56
  };
57
57
  }, [menuOpen]);
58
58
  const menuItem = `flex w-full items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-[12px] text-neutral-300 transition-colors hover:bg-cyan-500/15 hover:text-cyan-100 ${FOCUS_RING}`;
59
- return (_jsxs("header", { className: `pointer-events-auto relative z-[60] flex h-12 shrink-0 items-center gap-2 border-b ${BORDER} bg-[#111318] px-2.5`, children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2.5", children: [_jsx("div", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px] bg-gradient-to-br from-cyan-500/30 to-indigo-500/25 text-[11px] font-bold tracking-wide text-cyan-200 ring-1 ring-inset ring-cyan-400/30", "aria-hidden": "true", children: "JG" }), _jsxs("div", { className: "min-w-0 leading-tight", children: [_jsx("div", { className: "truncate text-[13px] font-semibold text-neutral-100", children: gameId }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Scene Editor" })] }), _jsx("div", { className: "ml-1 hidden md:block", children: _jsx(SaveStatus, { dirty: dirty, saveState: saveState, lastSavedAt: lastSavedAt }) })] }), _jsx("div", { className: "flex flex-1 justify-center px-2", children: _jsxs("button", { type: "button", onClick: onOpenPalette, "aria-label": "Open command palette", className: `flex h-8 w-full max-w-[420px] items-center gap-2 rounded-[6px] border border-white/[0.08] bg-black/30 px-3 text-[12px] text-neutral-500 transition-colors hover:border-white/[0.14] hover:text-neutral-400 ${FOCUS_RING}`, children: [_jsx(Icon, { name: "search", size: 13 }), _jsx("span", { className: "min-w-0 flex-1 truncate text-left", children: "Search or run a command\u2026" }), _jsxs("span", { className: "hidden items-center gap-0.5 sm:flex", children: [_jsx(Kbd, { children: "Ctrl" }), _jsx(Kbd, { children: "K" })] })] }) }), _jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [_jsx(IconButton, { icon: "undo", label: "Undo (Ctrl+Z)", onClick: onUndo, disabled: !canUndo, tone: "ghost" }), _jsx(IconButton, { icon: "redo", label: "Redo (Ctrl+Y)", onClick: onRedo, disabled: !canRedo, tone: "ghost" }), _jsx(ToolbarDivider, {}), _jsxs("button", { type: "button", onClick: onPlay, className: `flex h-8 items-center gap-1.5 rounded-[6px] bg-emerald-600 px-3.5 text-[12px] font-semibold text-white shadow-sm shadow-emerald-950/40 transition-colors hover:bg-emerald-500 ${FOCUS_RING}`, title: "Enter Play mode (F2+E toggles back)", children: [_jsx(Icon, { name: "play", size: 12 }), "Play"] }), _jsx(IconButton, { icon: "pause", label: "Pause \u2014 available in Play mode", disabled: true }), _jsx(IconButton, { icon: "step", label: "Step one frame \u2014 available in Play mode", disabled: true }), _jsx(ToolbarDivider, {}), _jsx(IconButton, { icon: "walk", label: "Walk the world", onClick: onWalk, tone: "ghost" }), _jsx(IconButton, { icon: "panel", label: "Lay out HUD panels", onClick: onHud, tone: "ghost" }), _jsx(ToolbarDivider, {}), saveAvailable ? (_jsxs("button", { type: "button", onClick: onSave, disabled: saveState === "saving", title: saveError ?? "Write the scene to the game's editor.scene.json (Ctrl+S)", className: `flex h-8 items-center gap-1.5 rounded-[6px] px-3 text-[12px] font-medium transition-colors ${FOCUS_RING} ${saveState === "error"
59
+ return (_jsxs("header", { className: `pointer-events-auto relative z-[60] flex h-12 shrink-0 items-center gap-2 border-b ${BORDER} bg-[#111318] px-2.5`, children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2.5", children: [onExitToGame !== undefined ? (_jsxs("button", { type: "button", onClick: onExitToGame, title: "Close the editor and return to the game (F2+Q)", className: `flex h-8 shrink-0 items-center gap-1.5 rounded-[6px] border border-white/[0.08] bg-black/30 pl-2 pr-2.5 text-[12px] text-neutral-300 transition-colors hover:border-white/[0.16] hover:bg-white/[0.06] hover:text-neutral-100 ${FOCUS_RING}`, children: [_jsx(Icon, { name: "chevronRight", size: 14, className: "rotate-180" }), "Exit to game"] })) : null, _jsx("div", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px] bg-gradient-to-br from-cyan-500/30 to-indigo-500/25 text-[11px] font-bold tracking-wide text-cyan-200 ring-1 ring-inset ring-cyan-400/30", "aria-hidden": "true", children: "JG" }), _jsxs("div", { className: "min-w-0 leading-tight", children: [_jsx("div", { className: "truncate text-[13px] font-semibold text-neutral-100", children: gameId }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Scene Editor" })] }), _jsx("div", { className: "ml-1 hidden md:block", children: _jsx(SaveStatus, { dirty: dirty, saveState: saveState, lastSavedAt: lastSavedAt }) })] }), _jsx("div", { className: "flex flex-1 justify-center px-2", children: _jsxs("button", { type: "button", onClick: onOpenPalette, "aria-label": "Open command palette", className: `flex h-8 w-full max-w-[420px] items-center gap-2 rounded-[6px] border border-white/[0.08] bg-black/30 px-3 text-[12px] text-neutral-500 transition-colors hover:border-white/[0.14] hover:text-neutral-400 ${FOCUS_RING}`, children: [_jsx(Icon, { name: "search", size: 13 }), _jsx("span", { className: "min-w-0 flex-1 truncate text-left", children: "Search or run a command\u2026" }), _jsxs("span", { className: "hidden items-center gap-0.5 sm:flex", children: [_jsx(Kbd, { children: "Ctrl" }), _jsx(Kbd, { children: "K" })] })] }) }), _jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [_jsx(IconButton, { icon: "undo", label: "Undo (Ctrl+Z)", onClick: onUndo, disabled: !canUndo, tone: "ghost" }), _jsx(IconButton, { icon: "redo", label: "Redo (Ctrl+Y)", onClick: onRedo, disabled: !canRedo, tone: "ghost" }), _jsx(ToolbarDivider, {}), _jsxs("button", { type: "button", onClick: onPlay, className: `flex h-8 items-center gap-1.5 rounded-[6px] bg-emerald-600 px-3.5 text-[12px] font-semibold text-white shadow-sm shadow-emerald-950/40 transition-colors hover:bg-emerald-500 ${FOCUS_RING}`, title: "Enter Play mode (F2+E toggles back)", children: [_jsx(Icon, { name: "play", size: 12 }), "Play"] }), _jsx(IconButton, { icon: "pause", label: "Pause \u2014 available in Play mode", disabled: true }), _jsx(IconButton, { icon: "step", label: "Step one frame \u2014 available in Play mode", disabled: true }), _jsx(ToolbarDivider, {}), _jsx(IconButton, { icon: "walk", label: "Walk the world", onClick: onWalk, tone: "ghost" }), _jsx(IconButton, { icon: "panel", label: "Lay out HUD panels", onClick: onHud, tone: "ghost" }), _jsx(ToolbarDivider, {}), saveAvailable ? (_jsxs("button", { type: "button", onClick: onSave, disabled: saveState === "saving", title: saveError ?? "Write the scene to the game's editor.scene.json (Ctrl+S)", className: `flex h-8 items-center gap-1.5 rounded-[6px] px-3 text-[12px] font-medium transition-colors ${FOCUS_RING} ${saveState === "error"
60
60
  ? "bg-rose-600/90 text-white hover:bg-rose-500"
61
61
  : dirty
62
62
  ? "bg-cyan-600 text-white shadow-sm shadow-cyan-950/40 hover:bg-cyan-500"
@@ -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.12.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.12.0",
35
- "@jgengine/react": "^0.12.0",
36
- "@jgengine/shell": "^0.12.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.
@@ -42,7 +44,8 @@ World content routes editor-first: any request that adds, moves, restyles, or re
42
44
 
43
45
  Within each selected domain:
44
46
 
45
- 1. Search `capabilities.md` by intent; it maps needs to imports.
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.
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.
46
49
  2. Open `api.md` only when exact signatures or export inventory are needed.
47
50
  3. Open the linked reference only for a deeper recipe or trap.
48
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.12.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
 
@@ -38,7 +38,7 @@
38
38
  - `WorldFeature` (type): type WorldFeature = | PlaceWorldFeature | ({ kind: "biomes" } & BiomesWorldConfig) | ({ kind: "voxel" } & VoxelWorldConfig) | ({ kind: "plots" } & PlotsWorldConfig) | ({ kind: "tilemap" } & TilemapWorldConfig) | EnvironmentWorldFeature | { kind: "flat" } — A declared world shape passed to `defineGame`. The preferred model is the place feature from `world()` (`@jgengine/core/world/place`): substrate + laws, with all dressing authored in the editor. The remaining members — biomes, voxel grid, plots, tilemap, environment, flat — are the legacy code-declared shapes kept for existing games.
39
39
  - `auditManifest` (function): function auditManifest(manifest: SceneOwnershipManifest): OwnershipDiagnostic[] — Audit every declaration in a manifest, keying diagnostics by each object's provenance id. An empty result means the manifest declares all of its content cleanly — every object is authored, generated, bakeable, or a reasoned runtime/transient object.
40
40
  - `biomes` (function): function biomes(config: BiomesWorldConfig): WorldFeature — Declares a biome-painted world — the whole-world alternative to a single `environment()` terrain.
41
- - `building` (function): function building(config: BuildingEnvironmentConfig = {}): BuildingEnvironmentDescriptor — Declares a cluster of procedurally-massed buildings for `environment()` — count, footprint, stories, style.
41
+ - `building` (function): function building(config: BuildingEnvironmentConfig = {}): BuildingEnvironmentDescriptor — Declares a cluster of procedurally-massed buildings for `environment()` — count, footprint, stories, style. Pass `along` to line road frontage instead of gridding around `position`.
42
42
  - `classifyOwnership` (function): function classifyOwnership(declaration: SceneOwnershipDeclaration): OwnershipVerdict — Resolve one declaration into a single boundary verdict.
43
43
  - `collectOwnershipDiagnostics` (function): function collectOwnershipDiagnostics(entries: Iterable<readonly [string, SceneOwnershipDeclaration]>): OwnershipDiagnostic[] — Audit a keyed set of declarations and return one diagnostic per object that breaks the boundary contract (a `reject` verdict carrying a `violation`). Deterministic: diagnostics come back in the iteration order of `entries`.
44
44
  - `contextVerb` (function): function contextVerb(label: string, command: string, args?: Record<string, unknown>): ContextVerb — Builds a {@link ContextVerb} for a right-click menu entry.
@@ -209,11 +209,18 @@
209
209
  - `Tunable` (interface): interface Tunable<T> — Typed handle to a registered control, exposing its live value and subscribe/set/reset operations.
210
210
  - `TunableAccessor` (interface): interface TunableAccessor — Get/set accessor pair plus initial value used to bind a discovered field to a control.
211
211
 
212
+ ## @jgengine/core/devtools/urlFlags
213
+
214
+ - `readUrlFlag` (function): function readUrlFlag(param: string): boolean — True when `param` is present and not an explicit off value (`0`/`false`/`off`/`no`).
215
+ - `readUrlParam` (function): function readUrlParam(param: string): string | null — The current query value of `param`, or null when absent (or no DOM).
216
+ - `subscribeUrlChange` (function): function subscribeUrlChange(listener: () => void): () => void — Fires `listener` whenever the query string may have changed out from under a flag — browser back/forward (`popstate`). Manual address-bar edits reload the page, so initial reads cover those; this keeps in-app state honest across history navigation. Returns an unsubscribe.
217
+ - `writeUrlParam` (function): function writeUrlParam(param: string, value: string | null): void — Sets `param` to `value`, or removes it when `value` is null, rewriting the URL in place with `history.replaceState` so the rest of the query and the hash survive and no history entry is pushed. No-ops without a DOM, and skips the write when the URL already matches.
218
+
212
219
  ## @jgengine/core/meta/changelog
213
220
 
214
221
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
215
222
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
216
- - `VERSION` (const): const VERSION: "0.12.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.
217
224
 
218
225
  ## @jgengine/core/runtime/adapter
219
226
 
@@ -276,7 +283,7 @@
276
283
  - `SceneObjectContext` (interface): interface SceneObjectContext extends ObjectStore — ⚠ undocumented
277
284
  - `SceneWorldItemContext` (interface): interface SceneWorldItemContext — ⚠ undocumented
278
285
  - `TelegraphInput` (interface): interface TelegraphInput — ⚠ undocumented
279
- - `VfxInput` (interface): interface VfxInput — Request a transient spell/ability VFX burst; `from`/`to` accept an instance id or a world point, `color` is a `0xRRGGBB` tint, and `durationMs` defaults per `kind`.
286
+ - `VfxInput` (interface): interface VfxInput — Request a transient spell/ability VFX burst. The easy path is a named `preset` — `vfx({ preset: "arrow", from: caster, to: enemy })` renders a visible bolt with no color or archetype tuning; `"lightning"`, `"web"`, `"slash"`, `"shield"`, `"heal"`, `"explosion"` and the rest of {@link vfxPresets} likewise just work. `from`/`to` accept an instance id (the shell follows its live pose) or a fixed world point. Anything you also pass — `kind`, `color` (`0xRRGGBB`), `radius`, `durationMs` — overrides the preset; supply `kind` + `color` yourself for a fully custom burst with no preset. `durationMs` defaults per `kind`.
280
287
  - `WorldItemPickupResult` (type): type WorldItemPickupResult = | { status: "ok"; record: WorldItemRecord } | { status: "rejected"; reason: string } — ⚠ undocumented
281
288
  - `createGameContext` (function): function createGameContext<TAssetRef extends ModelAssetRef, TMultiplayer>(options: GameContextOptions<TAssetRef, TMultiplayer>): GameContext — ⚠ undocumented
282
289
 
@@ -307,7 +314,7 @@
307
314
  - `SceneObjectContext` (interface): interface SceneObjectContext extends ObjectStore — ⚠ undocumented
308
315
  - `SceneWorldItemContext` (interface): interface SceneWorldItemContext — ⚠ undocumented
309
316
  - `TelegraphInput` (interface): interface TelegraphInput — ⚠ undocumented
310
- - `VfxInput` (interface): interface VfxInput — Request a transient spell/ability VFX burst; `from`/`to` accept an instance id or a world point, `color` is a `0xRRGGBB` tint, and `durationMs` defaults per `kind`.
317
+ - `VfxInput` (interface): interface VfxInput — Request a transient spell/ability VFX burst. The easy path is a named `preset` — `vfx({ preset: "arrow", from: caster, to: enemy })` renders a visible bolt with no color or archetype tuning; `"lightning"`, `"web"`, `"slash"`, `"shield"`, `"heal"`, `"explosion"` and the rest of {@link vfxPresets} likewise just work. `from`/`to` accept an instance id (the shell follows its live pose) or a fixed world point. Anything you also pass — `kind`, `color` (`0xRRGGBB`), `radius`, `durationMs` — overrides the preset; supply `kind` + `color` yourself for a fully custom burst with no preset. `durationMs` defaults per `kind`.
311
318
  - `WorldItemPickupResult` (type): type WorldItemPickupResult = | { status: "ok"; record: WorldItemRecord } | { status: "rejected"; reason: string } — ⚠ undocumented
312
319
 
313
320
  ## @jgengine/core/runtime/gameRuntime
@@ -322,6 +329,7 @@
322
329
 
323
330
  ## @jgengine/core/runtime/headlessRunner
324
331
 
332
+ - `HeadlessCommandInterface` (interface): interface HeadlessCommandInterface — Canvas-free command/intent surface for {@link HeadlessRunner} — invoke a game's registered UI commands and read the resulting reactive state off `ctx`.
325
333
  - `HeadlessInput` (interface): interface HeadlessInput — One step's worth of player intent handed to {@link HeadlessRunner.step} — the held-action set and pointer state the shell would otherwise publish from the browser each frame.
326
334
  - `HeadlessRunner` (interface): interface HeadlessRunner — A renderer-free driver for a game loop: builds a {@link GameContext} from a {@link GameDefinition}, runs the init hooks, then advances the simulation one step at a time from injected input. No React, R3F, or three.js — the whole play path (time, input, `onTick`, behaviour nav, optional player movement) runs from `core` primitives alone, so a non-React host (a server tick, a test, a CLI replay) can play a real game and read its world snapshot. The shell's FrameDriver is one such driver bolted to `useFrame`; this is the same step distilled out of the render tree.
327
335
  - `HeadlessRunnerOptions` (interface): interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultiplayer> — ⚠ undocumented