@jgengine/editor 0.18.1 → 0.19.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 +158 -7
- package/dist/ClipPreviewLayer.js +18 -2
- package/dist/EditorChrome.js +7 -0
- package/dist/LightingPanel.js +5 -1
- package/dist/handlers/document.js +2 -0
- package/dist/handlers/navmesh.d.ts +2 -0
- package/dist/handlers/navmesh.js +16 -4
- package/dist/mcp/fieldSpec.js +2 -2
- package/dist/mcp/tools.js +3 -3
- package/dist/modelAnimationAuthoring.d.ts +23 -0
- package/dist/modelAnimationAuthoring.js +58 -1
- package/dist/session.d.ts +2 -0
- package/dist/session.js +2 -0
- package/dist/shell/AnimationGraphPanel.d.ts +16 -0
- package/dist/shell/AnimationGraphPanel.js +172 -0
- package/dist/shell/AnimationPanel.d.ts +3 -1
- package/dist/shell/AnimationPanel.js +9 -7
- package/dist/shell/WorkspaceRail.js +1 -1
- package/dist/shell/animGraphPreview.d.ts +56 -0
- package/dist/shell/animGraphPreview.js +92 -0
- package/dist/shell/clipPreview.d.ts +8 -0
- package/package.json +5 -5
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/jgengine/SKILL.md +7 -5
- package/skills/jgengine/api.md +118 -89
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine/recipes/minimal-game.md +3 -0
- package/skills/jgengine-editor/SKILL.md +3 -1
- package/skills/jgengine-editor/api.md +18 -0
- package/skills/jgengine-editor/reference.md +13 -3
- package/skills/level-design/SKILL.md +1 -1
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { markerCatalogId } from "@jgengine/core/world/authoredObjects";
|
|
4
|
+
import { animationMetaPatch, clearAnimGraph, effectiveAnimGraph, readAnimationSetting, setTransitionDuration, storeAnimGraph, } from "../modelAnimationAuthoring.js";
|
|
5
|
+
import { shallowArrayEqual, useStoreSelector } from "../useStoreSelector.js";
|
|
6
|
+
import { graphParamControls, graphTriggers, MAX_PREVIEW_SECONDS, recordTrigger, simulateGraphPreview, } from "./animGraphPreview.js";
|
|
7
|
+
import { initialClipPreviewState } from "./clipPreview.js";
|
|
8
|
+
import { FOCUS_RING, INPUT_CLS, NUMERIC } from "./theme.js";
|
|
9
|
+
import { EmptyState, IconButton } from "./ui.js";
|
|
10
|
+
const SOURCE_LABEL = {
|
|
11
|
+
authored: "Stored on this placement",
|
|
12
|
+
locomotion: "Built from the placement's states and one-shots",
|
|
13
|
+
auto: "Derived from the rig's clip names",
|
|
14
|
+
};
|
|
15
|
+
function conditionText(condition) {
|
|
16
|
+
return `${condition.param} ${condition.op} ${String(condition.value)}`;
|
|
17
|
+
}
|
|
18
|
+
function stateClips(state) {
|
|
19
|
+
if (state.kind === "clip")
|
|
20
|
+
return state.clip;
|
|
21
|
+
if (state.kind === "blend1D")
|
|
22
|
+
return state.points.map((point) => `${point.clip}@${point.at}`).join(" · ");
|
|
23
|
+
return state.points.map((point) => `${point.clip}@${point.at[0]},${point.at[1]}`).join(" · ");
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Graph mode of the Animation dock: the selected placement's animation graph (layers, states,
|
|
27
|
+
* transitions), a scrubbed preview driven by the headless graph runtime and shown on the rig in the
|
|
28
|
+
* viewport, trigger buttons that record presses on the timeline, and crossfade editing that stores
|
|
29
|
+
* the graph on the placement as an undoable edit.
|
|
30
|
+
*
|
|
31
|
+
* @internal
|
|
32
|
+
*/
|
|
33
|
+
export function AnimationGraphPanel({ session, ui, rigged, }) {
|
|
34
|
+
const selection = useStoreSelector(session, (state) => state.selection, shallowArrayEqual);
|
|
35
|
+
const markers = useStoreSelector(session, (state) => state.document.markers);
|
|
36
|
+
const target = useMemo(() => {
|
|
37
|
+
for (const id of selection) {
|
|
38
|
+
const marker = markers.find((entry) => entry.id === id);
|
|
39
|
+
if (marker === undefined)
|
|
40
|
+
continue;
|
|
41
|
+
const catalogId = markerCatalogId(marker);
|
|
42
|
+
const asset = catalogId === null ? undefined : rigged.find((entry) => entry.id === catalogId);
|
|
43
|
+
if (asset?.url !== undefined && asset.clips !== undefined)
|
|
44
|
+
return { marker, asset };
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}, [selection, markers, rigged]);
|
|
48
|
+
if (target === null) {
|
|
49
|
+
return (_jsx(EmptyState, { icon: "film", title: "Animation graph", badge: "Placed rig", description: "Select a placed instance of a rigged asset to see the graph it plays: layers, states and transitions, with a scrubbed preview on the rig in the viewport." }));
|
|
50
|
+
}
|
|
51
|
+
return _jsx(GraphInspector, { session: session, ui: ui, marker: target.marker, asset: target.asset }, target.marker.id);
|
|
52
|
+
}
|
|
53
|
+
function GraphInspector({ session, ui, marker, asset, }) {
|
|
54
|
+
const clips = asset.clips ?? [];
|
|
55
|
+
const setting = readAnimationSetting(marker.meta);
|
|
56
|
+
const effective = useMemo(() => effectiveAnimGraph(setting, clips), [JSON.stringify(setting), clips]);
|
|
57
|
+
const graph = effective?.graph ?? null;
|
|
58
|
+
const controls = useMemo(() => (graph === null ? [] : graphParamControls(graph)), [graph]);
|
|
59
|
+
const triggerNames = useMemo(() => (graph === null ? [] : graphTriggers(graph)), [graph]);
|
|
60
|
+
const [time, setTime] = useState(0);
|
|
61
|
+
const [playing, setPlaying] = useState(false);
|
|
62
|
+
const [params, setParams] = useState({});
|
|
63
|
+
const [triggers, setTriggers] = useState([]);
|
|
64
|
+
const durations = useStoreSelector(ui, (state) => state.clipPreview?.clipDurations);
|
|
65
|
+
const frame = useMemo(() => {
|
|
66
|
+
if (graph === null)
|
|
67
|
+
return null;
|
|
68
|
+
const merged = {};
|
|
69
|
+
for (const control of controls)
|
|
70
|
+
merged[control.name] = params[control.name] ?? (control.kind === "boolean" ? false : 0);
|
|
71
|
+
return simulateGraphPreview({ graph, durations: durations ?? {}, time, params: merged, triggers });
|
|
72
|
+
}, [graph, controls, params, triggers, time, durations]);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (asset.url === undefined)
|
|
75
|
+
return;
|
|
76
|
+
const current = ui.getState().clipPreview;
|
|
77
|
+
const sameAsset = current?.source.assetId === asset.id;
|
|
78
|
+
ui.patch({
|
|
79
|
+
clipPreview: {
|
|
80
|
+
source: { assetId: asset.id, label: asset.label, url: asset.url, clips },
|
|
81
|
+
driver: initialClipPreviewState(null),
|
|
82
|
+
duration: 0,
|
|
83
|
+
...(sameAsset && current?.clipDurations !== undefined ? { clipDurations: current.clipDurations } : {}),
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
return () => {
|
|
87
|
+
if (ui.getState().clipPreview?.source.assetId === asset.id)
|
|
88
|
+
ui.patch({ clipPreview: null });
|
|
89
|
+
};
|
|
90
|
+
}, [asset.id, asset.url, asset.label, clips, ui]);
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const current = ui.getState().clipPreview;
|
|
93
|
+
if (current === null || current.source.assetId !== asset.id)
|
|
94
|
+
return;
|
|
95
|
+
if (graph === null || frame === null) {
|
|
96
|
+
if (current.graphPose !== undefined)
|
|
97
|
+
ui.patch({ clipPreview: { ...current, graphPose: undefined } });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
ui.patch({ clipPreview: { ...current, graphPose: { graph, clips: frame.clips } } });
|
|
101
|
+
}, [graph, frame, asset.id, ui, durations]);
|
|
102
|
+
const timeRef = useRef(time);
|
|
103
|
+
timeRef.current = time;
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!playing)
|
|
106
|
+
return;
|
|
107
|
+
let handle = 0;
|
|
108
|
+
let last = performance.now();
|
|
109
|
+
const tick = (now) => {
|
|
110
|
+
const next = timeRef.current + Math.min(0.05, Math.max(0, (now - last) / 1000));
|
|
111
|
+
last = now;
|
|
112
|
+
if (next >= MAX_PREVIEW_SECONDS) {
|
|
113
|
+
setTime(MAX_PREVIEW_SECONDS);
|
|
114
|
+
setPlaying(false);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
setTime(next);
|
|
118
|
+
handle = requestAnimationFrame(tick);
|
|
119
|
+
};
|
|
120
|
+
handle = requestAnimationFrame(tick);
|
|
121
|
+
return () => cancelAnimationFrame(handle);
|
|
122
|
+
}, [playing]);
|
|
123
|
+
const commit = (next) => session.dispatch({ type: "setMarker", id: marker.id, patch: { meta: { ...marker.meta, ...animationMetaPatch(next) } } }, { coalesce: `animation:${marker.id}` });
|
|
124
|
+
if (graph === null || effective === null || frame === null) {
|
|
125
|
+
return (_jsx(EmptyState, { icon: "film", title: "No graph", badge: marker.label ?? marker.id, description: "This placement plays no graph: its animation is off, a single clip, or the rig has no idle clip. Pick Custom or Auto in the inspector's Animation section." }));
|
|
126
|
+
}
|
|
127
|
+
const weights = new Map(frame.clips.map((clip) => [`${clip.layer}:${clip.clip}`, clip.weight]));
|
|
128
|
+
const trigger = (name) => setTriggers((current) => recordTrigger(current, name, timeRef.current));
|
|
129
|
+
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: marker.label ?? marker.id }), _jsxs("span", { className: "text-[10px] text-neutral-600", children: [asset.label, " \u00B7 ", SOURCE_LABEL[effective.source]] }), _jsx("div", { className: "ml-auto flex items-center gap-1", children: effective.source === "authored" ? (_jsx("button", { type: "button", className: SMALL_BUTTON, onClick: () => commit(clearAnimGraph(setting)), title: "Drop the stored graph", children: "Use derived graph" })) : (_jsx("button", { type: "button", className: SMALL_BUTTON, onClick: () => commit(storeAnimGraph(setting, graph)), title: "Store this graph on the placement", children: "Store graph" })) })] }), _jsxs("div", { className: "flex min-h-0 flex-1 gap-3 overflow-auto p-3", children: [_jsxs("div", { className: "flex w-64 shrink-0 flex-col gap-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(IconButton, { icon: playing ? "pause" : "play", label: playing ? "Pause preview" : "Play preview", size: 14, active: playing, onClick: () => setPlaying((value) => !value) }), _jsx("button", { type: "button", className: SMALL_BUTTON, onClick: () => {
|
|
130
|
+
setPlaying(false);
|
|
131
|
+
setTime(0);
|
|
132
|
+
setTriggers([]);
|
|
133
|
+
}, children: "Reset" }), _jsxs("span", { className: `ml-auto text-[10px] text-neutral-400 ${NUMERIC}`, children: [time.toFixed(2), " s"] })] }), _jsxs("label", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[9px] uppercase tracking-wider text-neutral-600", children: "Scrub" }), _jsx("input", { type: "range", min: 0, max: MAX_PREVIEW_SECONDS, step: 0.01, value: time, "aria-label": "Graph preview time", className: `h-2 w-full accent-cyan-400 ${FOCUS_RING}`, onChange: (event) => {
|
|
134
|
+
setPlaying(false);
|
|
135
|
+
setTime(Number(event.target.value));
|
|
136
|
+
} }), _jsx(TriggerTicks, { triggers: triggers })] }), controls.map((control) => control.kind === "boolean" ? (_jsxs("label", { className: "flex items-center gap-2 text-[11px] text-neutral-300", children: [_jsx("input", { type: "checkbox", checked: params[control.name] === true, "aria-label": `Parameter ${control.name}`, onChange: (event) => setParams((current) => ({ ...current, [control.name]: event.target.checked })) }), control.name] }, control.name)) : (_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: control.name }), _jsx("span", { className: NUMERIC, children: Number(params[control.name] ?? 0).toFixed(2) })] }), _jsx("input", { type: "range", min: control.min, max: control.max, step: (control.max - control.min) / 200, value: Number(params[control.name] ?? 0), "aria-label": `Parameter ${control.name}`, className: `h-2 w-full accent-cyan-400 ${FOCUS_RING}`, onChange: (event) => setParams((current) => ({ ...current, [control.name]: Number(event.target.value) })) })] }, control.name))), triggerNames.length > 0 ? (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[9px] uppercase tracking-wider text-neutral-600", children: "Triggers at the playhead" }), _jsx("div", { className: "flex flex-wrap gap-1", children: triggerNames.map((name) => (_jsx("button", { type: "button", className: SMALL_BUTTON, onClick: () => trigger(name), children: name }, name))) })] })) : null, frame.events.length > 0 ? (_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: "text-[9px] uppercase tracking-wider text-neutral-600", children: "Clip events" }), frame.events.slice(-6).map((event, index) => (_jsxs("span", { className: `text-[10px] text-amber-200/80 ${NUMERIC}`, children: [event.at.toFixed(2), " s \u00B7 ", event.name, " (", event.clip, ")"] }, index)))] })) : null] }), _jsx("div", { className: "flex min-w-0 flex-1 flex-col gap-3", children: graph.layers.map((layer) => (_jsxs("section", { className: "rounded-[6px] border border-white/[0.07] bg-white/[0.02]", "aria-label": `Layer ${layer.id}`, children: [_jsxs("header", { className: "flex items-center gap-2 border-b border-white/[0.06] px-2.5 py-1.5", children: [_jsx("span", { className: "text-[11px] font-medium text-neutral-100", children: layer.id }), _jsxs("span", { className: "text-[10px] text-neutral-500", children: ["entry ", layer.entry] }), layer.mask !== undefined ? _jsxs(Chip, { children: ["mask ", layer.mask.join(", ")] }) : null, layer.additive === true ? _jsx(Chip, { children: "additive" }) : null, layer.weight !== undefined ? _jsxs(Chip, { children: ["weight ", layer.weight] }) : null, _jsxs("span", { className: "ml-auto text-[10px] text-cyan-200", children: ["\u25B6 ", frame.states[layer.id]] })] }), _jsxs("div", { className: "grid grid-cols-2 gap-3 p-2.5", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[9px] uppercase tracking-wider text-neutral-600", children: "States" }), Object.entries(layer.states).map(([name, state]) => {
|
|
137
|
+
const active = frame.states[layer.id] === name;
|
|
138
|
+
const clipsOfState = state.kind === "clip" ? [state.clip] : state.points.map((point) => point.clip);
|
|
139
|
+
const weight = clipsOfState.reduce((sum, clip) => sum + (weights.get(`${layer.id}:${clip}`) ?? 0), 0);
|
|
140
|
+
return (_jsxs("div", { className: `rounded-[5px] border px-2 py-1 ${active ? "border-cyan-400/40 bg-cyan-500/10" : "border-white/[0.05]"}`, children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-[11px] text-neutral-100", children: name }), _jsx("span", { className: "text-[10px] text-neutral-500", children: state.kind }), _jsx("span", { className: `ml-auto text-[10px] text-neutral-400 ${NUMERIC}`, children: weight > 0 ? weight.toFixed(2) : "" })] }), _jsx("div", { className: "truncate text-[10px] text-neutral-500", title: stateClips(state), children: stateClips(state) }), _jsx("div", { className: "mt-1 h-1 rounded bg-white/[0.05]", children: _jsx("div", { className: "h-1 rounded bg-cyan-400/70", style: { width: `${Math.round(Math.min(1, weight) * 100)}%` } }) })] }, name));
|
|
141
|
+
})] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[9px] uppercase tracking-wider text-neutral-600", children: "Transitions" }), layer.transitions.length === 0 ? _jsx("span", { className: "text-[10px] text-neutral-600", children: "None" }) : null, layer.transitions.map((transition, index) => (_jsx(TransitionRow, { transition: transition, onTrigger: transition.trigger === undefined ? undefined : () => trigger(transition.trigger), onDuration: (duration) => commit(setTransitionDuration(setting, graph, layer.id, index, duration)) }, index)))] })] })] }, layer.id))) })] })] }));
|
|
142
|
+
}
|
|
143
|
+
const SMALL_BUTTON = `rounded-[5px] border border-white/[0.08] bg-white/[0.03] px-2 py-0.5 text-[10px] text-neutral-300 transition-colors hover:bg-white/[0.07] ${FOCUS_RING}`;
|
|
144
|
+
function Chip({ children }) {
|
|
145
|
+
return _jsx("span", { className: "rounded-[4px] bg-white/[0.05] px-1.5 py-0.5 text-[9px] text-neutral-400", children: children });
|
|
146
|
+
}
|
|
147
|
+
function TriggerTicks({ triggers }) {
|
|
148
|
+
if (triggers.length === 0)
|
|
149
|
+
return null;
|
|
150
|
+
return (_jsx("div", { className: "relative h-3", "aria-label": "Recorded triggers", children: triggers.map((entry, index) => (_jsx("span", { title: `${entry.name} at ${entry.at.toFixed(2)} s`, className: "absolute top-0 h-3 w-0.5 bg-amber-300/80", style: { left: `${(entry.at / MAX_PREVIEW_SECONDS) * 100}%` } }, index))) }));
|
|
151
|
+
}
|
|
152
|
+
function TransitionRow({ transition, onTrigger, onDuration, }) {
|
|
153
|
+
const [draft, setDraft] = useState(String(transition.duration ?? 0.2));
|
|
154
|
+
useEffect(() => setDraft(String(transition.duration ?? 0.2)), [transition.duration]);
|
|
155
|
+
const label = `${transition.from} → ${transition.to}`;
|
|
156
|
+
return (_jsxs("div", { className: "flex items-center gap-1.5 rounded-[5px] border border-white/[0.05] px-2 py-1", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "truncate text-[11px] text-neutral-100", children: label }), _jsx("div", { className: "truncate text-[10px] text-neutral-500", children: [
|
|
157
|
+
transition.trigger === undefined ? null : `trigger ${transition.trigger}`,
|
|
158
|
+
...(transition.when ?? []).map(conditionText),
|
|
159
|
+
transition.exitTime === undefined ? null : `exit ${transition.exitTime}`,
|
|
160
|
+
]
|
|
161
|
+
.filter((part) => part !== null)
|
|
162
|
+
.join(" · ") || "always" })] }), _jsx("input", { className: `${INPUT_CLS} h-6 w-14 px-1 text-right text-[11px] ${NUMERIC}`, value: draft, "aria-label": `Crossfade seconds for ${label}`, inputMode: "decimal", onChange: (event) => setDraft(event.target.value), onBlur: () => {
|
|
163
|
+
const value = Number(draft);
|
|
164
|
+
if (Number.isFinite(value) && value !== (transition.duration ?? 0.2))
|
|
165
|
+
onDuration(value);
|
|
166
|
+
else
|
|
167
|
+
setDraft(String(transition.duration ?? 0.2));
|
|
168
|
+
}, onKeyDown: (event) => {
|
|
169
|
+
if (event.key === "Enter")
|
|
170
|
+
event.target.blur();
|
|
171
|
+
} }), _jsx("span", { className: "text-[9px] text-neutral-600", children: "s" }), onTrigger !== undefined ? _jsx(IconButton, { icon: "play", label: `Trigger ${transition.trigger}`, size: 11, onClick: onTrigger }) : null] }));
|
|
172
|
+
}
|
|
@@ -3,7 +3,9 @@ import type { EditorAssetEntry } from "../AssetBrowser.js";
|
|
|
3
3
|
import type { EditorHostApi } from "../session.js";
|
|
4
4
|
import type { EditorUiStore } from "../uiStore.js";
|
|
5
5
|
/**
|
|
6
|
-
* Animation dock with
|
|
6
|
+
* Animation dock with three modes:
|
|
7
|
+
* - **Graph** — the selected placement's animation graph with a scrubbed, triggerable preview posed
|
|
8
|
+
* on the rig in the viewport (see {@link AnimationGraphPanel}).
|
|
7
9
|
* - **Path** — scrub/play the editor orbit camera along authored scene polylines (real path points,
|
|
8
10
|
* no fabricated keyframes).
|
|
9
11
|
* - **Clips** — preview a rigged asset's animation clips in the viewport (scrub, loop, speed) via the
|
|
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
3
3
|
import { markerCatalogId } from "@jgengine/core/world/authoredObjects";
|
|
4
4
|
import { shallowArrayEqual, useStoreSelector } from "../useStoreSelector.js";
|
|
5
5
|
import { advancePreview, clipEntriesFromRef, initialClipPreviewState, MAX_PREVIEW_SPEED, MIN_PREVIEW_SPEED, scrubPreview, selectPreviewClip, setPreviewLoop, setPreviewSpeed, togglePreviewPlaying, } from "./clipPreview.js";
|
|
6
|
+
import { AnimationGraphPanel } from "./AnimationGraphPanel.js";
|
|
6
7
|
import { listScrubbablePaths, samplePathAt } from "./pathFlythrough.js";
|
|
7
8
|
import { FOCUS_RING, INPUT_CLS, NUMERIC } from "./theme.js";
|
|
8
9
|
import { EmptyState, IconButton } from "./ui.js";
|
|
@@ -13,7 +14,9 @@ function riggedAssets(assets) {
|
|
|
13
14
|
return assets.filter((entry) => entry.url !== undefined && entry.clips !== undefined && entry.clips.length > 0);
|
|
14
15
|
}
|
|
15
16
|
/**
|
|
16
|
-
* Animation dock with
|
|
17
|
+
* Animation dock with three modes:
|
|
18
|
+
* - **Graph** — the selected placement's animation graph with a scrubbed, triggerable preview posed
|
|
19
|
+
* on the rig in the viewport (see {@link AnimationGraphPanel}).
|
|
17
20
|
* - **Path** — scrub/play the editor orbit camera along authored scene polylines (real path points,
|
|
18
21
|
* no fabricated keyframes).
|
|
19
22
|
* - **Clips** — preview a rigged asset's animation clips in the viewport (scrub, loop, speed) via the
|
|
@@ -23,9 +26,7 @@ function riggedAssets(assets) {
|
|
|
23
26
|
*/
|
|
24
27
|
export function AnimationPanel({ session, api, ui, assets, }) {
|
|
25
28
|
const rigged = useMemo(() => riggedAssets(assets), [assets]);
|
|
26
|
-
|
|
27
|
-
const paths = useStoreSelector(session, (state) => state.document.paths);
|
|
28
|
-
const [mode, setMode] = useState(() => rigged.length > 0 && listScrubbablePaths(paths).length === 0 ? "clips" : "path");
|
|
29
|
+
const [mode, setMode] = useState(() => (rigged.length > 0 ? "graph" : "path"));
|
|
29
30
|
// Tear down the viewport preview when the panel unmounts; leaving clip mode tears down in the
|
|
30
31
|
// tab click below.
|
|
31
32
|
useEffect(() => () => {
|
|
@@ -33,13 +34,14 @@ export function AnimationPanel({ session, api, ui, assets, }) {
|
|
|
33
34
|
ui.patch({ clipPreview: null });
|
|
34
35
|
}, [ui]);
|
|
35
36
|
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: [
|
|
36
|
-
["
|
|
37
|
+
["graph", "Graph"],
|
|
37
38
|
["clips", "Clip preview"],
|
|
39
|
+
["path", "Path flythrough"],
|
|
38
40
|
].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: () => {
|
|
39
|
-
if (id !==
|
|
41
|
+
if (id !== mode && ui.getState().clipPreview !== null)
|
|
40
42
|
ui.patch({ clipPreview: null });
|
|
41
43
|
setMode(id);
|
|
42
|
-
}, children: label }, id))) }) }), mode === "path" ? (_jsx(PathFlythrough, { session: session, api: api })) : (_jsx(ClipPreview, { session: session, ui: ui, rigged: rigged }))] }));
|
|
44
|
+
}, children: label }, id))) }) }), mode === "path" ? (_jsx(PathFlythrough, { session: session, api: api })) : mode === "graph" ? (_jsx(AnimationGraphPanel, { session: session, ui: ui, rigged: rigged })) : (_jsx(ClipPreview, { session: session, ui: ui, rigged: rigged }))] }));
|
|
43
45
|
}
|
|
44
46
|
/** Clip-preview mode: pick a rigged asset (placed instance or catalog) and play its clips in the viewport. */
|
|
45
47
|
function ClipPreview({ session, ui, rigged, }) {
|
|
@@ -8,7 +8,7 @@ export const WORKSPACES = [
|
|
|
8
8
|
{ id: "assets", label: "Assets", icon: "image", supported: true },
|
|
9
9
|
{ id: "materials", label: "Materials", icon: "sphere", supported: true },
|
|
10
10
|
{ id: "scripting", label: "Scripting", icon: "script", supported: true },
|
|
11
|
-
{ id: "animation", label: "Animation", icon: "film", supported:
|
|
11
|
+
{ id: "animation", label: "Animation", icon: "film", supported: true },
|
|
12
12
|
{ id: "audio", label: "Audio", icon: "audio", supported: false },
|
|
13
13
|
{ id: "lighting", label: "Lighting", icon: "bulb", supported: true },
|
|
14
14
|
{ id: "ai", label: "AI", icon: "sparkle", supported: true },
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type AnimClipOutput, type AnimGraph, type AnimParams } from "@jgengine/core/anim/animGraph";
|
|
2
|
+
/**
|
|
3
|
+
* Editor animation-graph preview: pure logic, no JSX. Replays a graph from its entry states to a
|
|
4
|
+
* scrub time with fixed steps, arming each recorded trigger at its time, so scrubbing back and forth
|
|
5
|
+
* always shows the same pose the runtime would reach.
|
|
6
|
+
*
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
/** A trigger pressed during preview, at a time on the scrub timeline. */
|
|
10
|
+
export interface GraphPreviewTrigger {
|
|
11
|
+
at: number;
|
|
12
|
+
name: string;
|
|
13
|
+
}
|
|
14
|
+
/** Inputs for {@link simulateGraphPreview}. */
|
|
15
|
+
export interface GraphPreviewInput {
|
|
16
|
+
graph: AnimGraph;
|
|
17
|
+
/** Clip durations in seconds from the loaded rig; unknown clips count as one second. */
|
|
18
|
+
durations: Readonly<Record<string, number>>;
|
|
19
|
+
time: number;
|
|
20
|
+
params: AnimParams;
|
|
21
|
+
triggers: readonly GraphPreviewTrigger[];
|
|
22
|
+
/** Fixed step in seconds. Default 1/30. */
|
|
23
|
+
step?: number;
|
|
24
|
+
}
|
|
25
|
+
/** What the rig shows at the scrub time. */
|
|
26
|
+
export interface GraphPreviewFrame {
|
|
27
|
+
time: number;
|
|
28
|
+
/** Current state per layer id. */
|
|
29
|
+
states: Record<string, string>;
|
|
30
|
+
clips: AnimClipOutput[];
|
|
31
|
+
/** Clip events fired from zero to `time`, with when they fired. */
|
|
32
|
+
events: {
|
|
33
|
+
at: number;
|
|
34
|
+
name: string;
|
|
35
|
+
clip: string;
|
|
36
|
+
}[];
|
|
37
|
+
}
|
|
38
|
+
/** Longest preview timeline the scrubber offers, in seconds. */
|
|
39
|
+
export declare const MAX_PREVIEW_SECONDS = 20;
|
|
40
|
+
/** Replays `graph` from its entry states to `time` and reports the pose there. */
|
|
41
|
+
export declare function simulateGraphPreview(input: GraphPreviewInput): GraphPreviewFrame;
|
|
42
|
+
/** Every clip name a graph plays. */
|
|
43
|
+
export declare function graphClipNames(graph: AnimGraph): string[];
|
|
44
|
+
/** Every trigger name a graph's transitions listen for, in first-seen order. */
|
|
45
|
+
export declare function graphTriggers(graph: AnimGraph): string[];
|
|
46
|
+
/** A parameter the preview needs a control for: blend inputs and condition operands. */
|
|
47
|
+
export interface GraphParamControl {
|
|
48
|
+
name: string;
|
|
49
|
+
kind: "number" | "boolean";
|
|
50
|
+
min: number;
|
|
51
|
+
max: number;
|
|
52
|
+
}
|
|
53
|
+
/** Parameters a graph reads, with a range covering its blend points and condition values. */
|
|
54
|
+
export declare function graphParamControls(graph: AnimGraph): GraphParamControl[];
|
|
55
|
+
/** Adds a trigger press at `at`, keeping the log in time order. */
|
|
56
|
+
export declare function recordTrigger(triggers: readonly GraphPreviewTrigger[], name: string, at: number): GraphPreviewTrigger[];
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createAnimGraphRuntime, } from "@jgengine/core/anim/animGraph";
|
|
2
|
+
/** Longest preview timeline the scrubber offers, in seconds. */
|
|
3
|
+
export const MAX_PREVIEW_SECONDS = 20;
|
|
4
|
+
/** Replays `graph` from its entry states to `time` and reports the pose there. */
|
|
5
|
+
export function simulateGraphPreview(input) {
|
|
6
|
+
const step = input.step ?? 1 / 30;
|
|
7
|
+
const time = Math.max(0, Math.min(MAX_PREVIEW_SECONDS, input.time));
|
|
8
|
+
const runtime = createAnimGraphRuntime(input.graph);
|
|
9
|
+
const clips = {};
|
|
10
|
+
for (const name of graphClipNames(input.graph))
|
|
11
|
+
clips[name] = input.durations[name] ?? 1;
|
|
12
|
+
const pending = [...input.triggers].filter((trigger) => trigger.at <= time).sort((a, b) => a.at - b.at);
|
|
13
|
+
const events = [];
|
|
14
|
+
let elapsed = 0;
|
|
15
|
+
let next = 0;
|
|
16
|
+
let out = runtime.advance(0, input.params, clips);
|
|
17
|
+
while (elapsed < time - 1e-9) {
|
|
18
|
+
const dt = Math.min(step, time - elapsed);
|
|
19
|
+
while (next < pending.length && pending[next].at <= elapsed + 1e-9)
|
|
20
|
+
runtime.trigger(pending[next++].name);
|
|
21
|
+
out = runtime.advance(dt, input.params, clips);
|
|
22
|
+
elapsed += dt;
|
|
23
|
+
for (const event of out.events)
|
|
24
|
+
events.push({ at: elapsed, name: event.name, clip: event.clip });
|
|
25
|
+
}
|
|
26
|
+
if (next < pending.length) {
|
|
27
|
+
while (next < pending.length)
|
|
28
|
+
runtime.trigger(pending[next++].name);
|
|
29
|
+
out = runtime.advance(0, input.params, clips);
|
|
30
|
+
}
|
|
31
|
+
const states = {};
|
|
32
|
+
for (const layer of input.graph.layers)
|
|
33
|
+
states[layer.id] = runtime.stateOf(layer.id) ?? layer.entry;
|
|
34
|
+
return { time, states, clips: out.clips, events };
|
|
35
|
+
}
|
|
36
|
+
/** Every clip name a graph plays. */
|
|
37
|
+
export function graphClipNames(graph) {
|
|
38
|
+
const names = new Set();
|
|
39
|
+
for (const layer of graph.layers) {
|
|
40
|
+
for (const state of Object.values(layer.states)) {
|
|
41
|
+
if (state.kind === "clip")
|
|
42
|
+
names.add(state.clip);
|
|
43
|
+
else
|
|
44
|
+
for (const point of state.points)
|
|
45
|
+
names.add(point.clip);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return [...names];
|
|
49
|
+
}
|
|
50
|
+
/** Every trigger name a graph's transitions listen for, in first-seen order. */
|
|
51
|
+
export function graphTriggers(graph) {
|
|
52
|
+
const names = new Set();
|
|
53
|
+
for (const layer of graph.layers)
|
|
54
|
+
for (const transition of layer.transitions)
|
|
55
|
+
if (transition.trigger !== undefined)
|
|
56
|
+
names.add(transition.trigger);
|
|
57
|
+
return [...names];
|
|
58
|
+
}
|
|
59
|
+
/** Parameters a graph reads, with a range covering its blend points and condition values. */
|
|
60
|
+
export function graphParamControls(graph) {
|
|
61
|
+
const controls = new Map();
|
|
62
|
+
const widen = (name, value) => {
|
|
63
|
+
const kind = typeof value === "boolean" ? "boolean" : "number";
|
|
64
|
+
const current = controls.get(name) ?? { name, kind, min: 0, max: 1 };
|
|
65
|
+
if (kind === "number") {
|
|
66
|
+
current.min = Math.min(current.min, value);
|
|
67
|
+
current.max = Math.max(current.max, value * 1.25, value + 0.5);
|
|
68
|
+
}
|
|
69
|
+
controls.set(name, current);
|
|
70
|
+
};
|
|
71
|
+
for (const layer of graph.layers) {
|
|
72
|
+
for (const state of Object.values(layer.states)) {
|
|
73
|
+
if (state.kind === "blend1D")
|
|
74
|
+
for (const point of state.points)
|
|
75
|
+
widen(state.param, point.at);
|
|
76
|
+
if (state.kind === "blend2D") {
|
|
77
|
+
for (const point of state.points) {
|
|
78
|
+
widen(state.params[0], point.at[0]);
|
|
79
|
+
widen(state.params[1], point.at[1]);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const transition of layer.transitions)
|
|
84
|
+
for (const condition of transition.when ?? [])
|
|
85
|
+
widen(condition.param, condition.value);
|
|
86
|
+
}
|
|
87
|
+
return [...controls.values()];
|
|
88
|
+
}
|
|
89
|
+
/** Adds a trigger press at `at`, keeping the log in time order. */
|
|
90
|
+
export function recordTrigger(triggers, name, at) {
|
|
91
|
+
return [...triggers, { at, name }].sort((a, b) => a.at - b.at);
|
|
92
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AnimClipOutput, AnimGraph } from "@jgengine/core/anim/animGraph";
|
|
1
2
|
import { type ClipRole } from "@jgengine/core/game/clipRoles";
|
|
2
3
|
import type { ModelAnimationConfig } from "@jgengine/core/game/playableGame";
|
|
3
4
|
/**
|
|
@@ -39,6 +40,13 @@ export interface ClipPreviewSession {
|
|
|
39
40
|
readonly source: ClipPreviewSource;
|
|
40
41
|
readonly driver: ClipPreviewState;
|
|
41
42
|
readonly duration: number;
|
|
43
|
+
/** Every clip's duration, published by the viewport layer once the GLB loads. */
|
|
44
|
+
readonly clipDurations?: Readonly<Record<string, number>>;
|
|
45
|
+
/** When set, the viewport poses the rig from this graph output instead of playing `driver`'s clip. */
|
|
46
|
+
readonly graphPose?: {
|
|
47
|
+
readonly graph: AnimGraph;
|
|
48
|
+
readonly clips: readonly AnimClipOutput[];
|
|
49
|
+
};
|
|
42
50
|
}
|
|
43
51
|
/** Playback state of the clip preview driver. `time` is seconds into the clip. */
|
|
44
52
|
export interface ClipPreviewState {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Scene/world/asset editor for JGengine — loaded lazily by the runner; never import from game entrypoints.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"test": "bun test src"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@jgengine/core": "^0.
|
|
35
|
-
"@jgengine/navbake": "^0.
|
|
36
|
-
"@jgengine/react": "^0.
|
|
37
|
-
"@jgengine/shell": "^0.
|
|
34
|
+
"@jgengine/core": "^0.19.0",
|
|
35
|
+
"@jgengine/navbake": "^0.19.0",
|
|
36
|
+
"@jgengine/react": "^0.19.0",
|
|
37
|
+
"@jgengine/shell": "^0.19.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"@react-three/drei": "^10.0.0",
|
|
@@ -143,7 +143,7 @@ When sufficient, return a concise orientation: objective, progress, decisions, c
|
|
|
143
143
|
|
|
144
144
|
## JGengine notes
|
|
145
145
|
|
|
146
|
-
- Multi-session engine work
|
|
146
|
+
- Multi-session engine work spans GitHub issues and PRs — point at those; do not paste full plan bodies.
|
|
147
147
|
- Prefer `bun run agent:bootstrap --check` status, branch names, and PR links as state anchors.
|
|
148
148
|
- Local parallelism uses `.claude/worktrees/`; never invent `C:\tmp\…` Codex-shared trees.
|
|
149
149
|
- Durable process changes belong in skills, not only the handoff.
|
package/skills/jgengine/SKILL.md
CHANGED
|
@@ -5,7 +5,9 @@ description: Route game work to the smallest useful JGengine guidance set.
|
|
|
5
5
|
|
|
6
6
|
# JGengine intake and router
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
This skill decides what to load; it is not an engine manual. Package map, commands, layering, and license live in the JGengine README (repo-root [`README.md`](../../../README.md) in the monorepo, [jgengine.com](https://jgengine.com) from a created project); open it when you need one of those facts.
|
|
9
|
+
|
|
10
|
+
A created project installs only `jgengine`, `jgengine-editor`, `jgengine-verify`, `game-design`, and `jgengine-ui`. Run `npx jgengine skills -p --all` before loading any other domain named below. Those projects do not ship `api.md`; read exact signatures from the `.d.ts` files under `node_modules/@jgengine/<pkg>/dist/`.
|
|
9
11
|
|
|
10
12
|
## 1. Establish the target
|
|
11
13
|
|
|
@@ -20,7 +22,7 @@ Treat the pitch as a unique composition of needs, not a genre to fill in — nev
|
|
|
20
22
|
- HUD/menu states and **custom** UI art direction (compose shipped building blocks + HudTheme; no unskinned stock face)
|
|
21
23
|
- one observable completion scenario
|
|
22
24
|
|
|
23
|
-
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/*`.
|
|
25
|
+
Pick the dimension first: `create` defaults to a 3D world (terrain, sky, player model, editor scene); a grid, card, tile, or other board game with no 3D scene takes `create --2d` (a `board` world drawn by GameUI over `@jgengine/core/puzzle/cellGrid`). 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/*`.
|
|
24
26
|
|
|
25
27
|
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.
|
|
26
28
|
|
|
@@ -46,9 +48,9 @@ World content routes editor-first: any request that adds, moves, restyles, or re
|
|
|
46
48
|
|
|
47
49
|
Within each selected domain:
|
|
48
50
|
|
|
49
|
-
0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code)
|
|
50
|
-
1. Search
|
|
51
|
-
2. Open `api.md` only when exact signatures
|
|
51
|
+
0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code) (`combat-loop`, `loot`, `world-drops`, `click-target`, `ability-bar`, `hitscan-weapon`, `rolled-gear`, `enter-vehicle` and more); `npx jgengine recipe` lists them. Copy that instead of reconning per-file.
|
|
52
|
+
1. Search capabilities by intent; do not read `capabilities.md` top to bottom. `npx jgengine find <intent>` searches every domain's index and every recipe at once, installed or not, and prints the primitive and its import (or the recipe to open); in the monorepo, `grep -i "<intent>" .claude/skills/*/capabilities.md` does the same. Search the moment you are tempted to hand-roll a HUD window, inventory grid, paperdoll, hotkey listener, minimap, stat bar, or character motion. A `z-index`, a `keydown` for a panel, a `<div>` inventory, or a limb bob almost certainly has a shipped drop-in (`usePanels`/`PanelHost`, `InventoryGrid`, `CharacterSheet`/`Paperdoll`, `PartMotionRig`, `EntityPreview`); games own the *skin*, not the re-derivation.
|
|
53
|
+
2. Open `api.md` (monorepo) or the package `.d.ts` only when exact signatures are needed; grep it, never read it whole.
|
|
52
54
|
3. Open the linked reference only for a deeper recipe or trap.
|
|
53
55
|
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.
|
|
54
56
|
|