@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 +47 -1
- package/dist/ClipPreviewLayer.d.ts +15 -0
- package/dist/ClipPreviewLayer.js +60 -0
- package/dist/EditorApp.js +2 -1
- package/dist/EditorChrome.js +2 -2
- package/dist/InspectorPanel.d.ts +4 -1
- package/dist/InspectorPanel.js +35 -4
- package/dist/SchemaInspector.d.ts +3 -1
- package/dist/SchemaInspector.js +8 -3
- package/dist/modelAnimationAuthoring.d.ts +75 -0
- package/dist/modelAnimationAuthoring.js +164 -0
- package/dist/shell/AnimationPanel.d.ts +10 -8
- package/dist/shell/AnimationPanel.js +112 -12
- package/dist/shell/BottomDock.d.ts +3 -1
- package/dist/shell/BottomDock.js +2 -2
- package/dist/shell/clipPreview.d.ts +85 -0
- package/dist/shell/clipPreview.js +82 -0
- package/dist/shell/scatterCoverage.d.ts +22 -0
- package/dist/shell/scatterCoverage.js +31 -0
- package/dist/uiStore.d.ts +6 -0
- package/dist/uiStore.js +1 -0
- package/package.json +4 -4
- package/skills/jgengine/SKILL.md +4 -2
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-editor/api.md +40 -1
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
*
|
|
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 {
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
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.
|
|
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-
|
|
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;
|
package/dist/shell/BottomDock.js
CHANGED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/editor",
|
|
3
|
-
"version": "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.
|
|
35
|
-
"@jgengine/react": "^0.
|
|
36
|
-
"@jgengine/shell": "^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",
|
package/skills/jgengine/SKILL.md
CHANGED
|
@@ -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
|
|
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.
|
package/skills/jgengine/api.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|