@jgengine/editor 0.18.0 → 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 +265 -2
- package/dist/ClipPreviewLayer.js +18 -2
- package/dist/EditorChrome.js +7 -0
- package/dist/LightingPanel.js +13 -1
- package/dist/handlers/document.js +2 -0
- package/dist/handlers/index.js +2 -0
- package/dist/handlers/navmesh.d.ts +5 -0
- package/dist/handlers/navmesh.js +21 -0
- package/dist/handlers/streets.js +15 -2
- package/dist/mcp/fieldSpec.js +3 -2
- package/dist/mcp/tools.js +3 -2
- package/dist/modelAnimationAuthoring.d.ts +23 -0
- package/dist/modelAnimationAuthoring.js +58 -1
- package/dist/session.d.ts +11 -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 -4
- package/skills/ce-handoff/SKILL.md +2 -2
- package/skills/jgengine/SKILL.md +9 -5
- package/skills/jgengine/api.md +205 -101
- package/skills/jgengine/capabilities.md +56 -0
- package/skills/jgengine/recipes/minimal-game.md +3 -0
- package/skills/jgengine-editor/SKILL.md +3 -1
- package/skills/jgengine-editor/api.md +28 -0
- package/skills/jgengine-editor/reference.md +13 -3
- package/skills/level-design/SKILL.md +1 -1
package/dist/mcp/fieldSpec.js
CHANGED
|
@@ -45,8 +45,8 @@ export const RPC_FIELD_SCHEMAS = {
|
|
|
45
45
|
set_volume: [req(s("id")), n("radius"), n("height"), n("x"), n("y"), n("z")],
|
|
46
46
|
set_path: [req(s("id")), s("kind"), n("width"), s("color"), s("label"), o("meta")],
|
|
47
47
|
add_path: [req(s("id")), req(oa("points", [req(n("x")), n("y"), req(n("z"))])), s("kind"), n("width"), s("color"), s("label"), o("meta")],
|
|
48
|
-
add_marker: [req(s("id")), req(s("kind")), req(n("x")), n("y"), req(n("z")), s("color"), s("label"), n("rotationY"), o("meta")],
|
|
49
|
-
set_marker: [req(s("id")), s("kind"), s("color"), s("label"), n("rotationY"), o("meta")],
|
|
48
|
+
add_marker: [req(s("id")), req(s("kind")), req(n("x")), n("y"), req(n("z")), s("color"), s("label"), n("rotationY"), s("catalogId"), o("meta")],
|
|
49
|
+
set_marker: [req(s("id")), s("kind"), s("color"), s("label"), n("rotationY"), s("catalogId"), o("meta")],
|
|
50
50
|
set_note: [req(s("id")), s("text"), o("meta")],
|
|
51
51
|
set_meta: [req(s("id")), req(o("patch"))],
|
|
52
52
|
apply_preset: [req(s("id")), req(s("preset"))],
|
|
@@ -75,6 +75,7 @@ export const RPC_FIELD_SCHEMAS = {
|
|
|
75
75
|
convert_scatter: [req(s("pathId"))],
|
|
76
76
|
generate_streets: [s("volumeId"), v3("center"), n("halfX"), n("halfZ"), s("seed"), s("mode", ["net", "circuit"]), s("kind"), o("params")],
|
|
77
77
|
bake_minimap: [n("padding"), n("resolution"), n("waterLevel")],
|
|
78
|
+
bakeNavMesh: [req(s("id")), req(val("positions")), req(val("indices")), req(n("agentRadius")), req(n("agentHeight")), req(n("maxSlope")), req(n("maxClimb"))],
|
|
78
79
|
add_foliage: [req(oa("points", [req(n("x")), req(n("z"))])), n("density"), s("item"), s("seed"), n("minSpacing")],
|
|
79
80
|
scatter_summary: [],
|
|
80
81
|
set_parent: [req(sa("ids")), req(sn("parentId"))],
|
package/dist/mcp/tools.js
CHANGED
|
@@ -23,8 +23,8 @@ const TOOL_DESCRIPTIONS = [
|
|
|
23
23
|
{ name: "set_volume", description: "Patch a volume radius/height/center." },
|
|
24
24
|
{ name: "set_path", description: "Patch a path's kind/width/color/label and merge-patch its meta (studio sliders: scatter density, pole spacing, …). Validated against the kind schema." },
|
|
25
25
|
{ name: "add_path", description: "Author a new path/route into the scene document from an ordered list of ≥2 {x,z} (optional y) points — the headless one-shot for placing a route without an export/import roundtrip. kind defaults to `route`; width/color/label/meta are optional and meta is validated against the kind schema. Re-ids on a document-global id collision." },
|
|
26
|
-
{ name: "add_marker", description: "Author a new point marker into the scene document at x/z (optional y) — the headless one-shot for placing a logical marker (spawn, checkpoint, pickup, bounty, or a game's own custom kind) without an export/import roundtrip. kind is required; color/label/rotationY/meta are optional and meta is validated against the kind schema when registered. Re-ids on a document-global id collision." },
|
|
27
|
-
{ name: "set_marker", description: "Patch a marker's kind/color/label/rotationY and merge-patch its meta. Validated against the kind schema when registered." },
|
|
26
|
+
{ name: "add_marker", description: "Author a new point marker into the scene document at x/z (optional y) — the headless one-shot for placing a logical marker (spawn, checkpoint, pickup, bounty, or a game's own custom kind) without an export/import roundtrip. kind is required; color/label/rotationY/catalogId/meta are optional (catalogId names the entity kind a mob/boss marker spawns) and meta is validated against the kind schema when registered. Re-ids on a document-global id collision." },
|
|
27
|
+
{ name: "set_marker", description: "Patch a marker's kind/color/label/rotationY/catalogId and merge-patch its meta. Validated against the kind schema when registered." },
|
|
28
28
|
{ name: "set_note", description: "Patch a note's text and merge-patch its meta." },
|
|
29
29
|
{ name: "set_meta", description: "Merge-patch the meta bag of any document object (marker/volume/path/note) by id — the generic studio-slider primitive. Rejected if it violates the kind's param schema." },
|
|
30
30
|
{ name: "apply_preset", description: "Apply a named preset bundle from the object's kind schema (e.g. a city archetype like manhattan/ruralohio) as one meta patch, ready to tweak afterwards." },
|
|
@@ -68,6 +68,7 @@ const TOOL_DESCRIPTIONS = [
|
|
|
68
68
|
{ name: "convert_scatter", description: "Detach a foliage/scatter region into individually-editable placed prop markers, removing the region." },
|
|
69
69
|
{ name: "generate_streets", description: "Bake the seed-driven street generator into the scene as authorable road/route paths inside a volume (or explicit center+halfX/halfZ). mode net→roads, circuit→closed route; params overlays sliders (gridness/loopiness/connectivity/winding/...). Idempotent: re-running the same seed replaces its own prior output. One undoable edit; round-trips --save." },
|
|
70
70
|
{ name: "bake_minimap", description: "Rasterize the authored terrain into a stored minimap PNG on the document (runtime reads it as the Minimap background). Live-editor only — needs the mounted viewport's terrain sampler, not a headless CLI." },
|
|
71
|
+
{ name: "bakeNavMesh", description: "Bake indexed scene geometry (walkable faces counter-clockwise seen from above) into a named, agent-sized navigation mesh stored in the document." },
|
|
71
72
|
{ name: "add_foliage", description: "Add a foliage/scatter region from a closed polygon (≥3 x/z points) with density and item." },
|
|
72
73
|
{ name: "scatter_summary", description: "Count foliage/scatter regions and their total deterministic instance placements." },
|
|
73
74
|
{ name: "set_parent", description: "Parent objects under another (or null to unparent); cycles are refused. Moving a parent moves its subtree." },
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type AnimGraph } from "@jgengine/core/anim/animGraph";
|
|
1
2
|
/**
|
|
2
3
|
* Authoring model for a placement's `ModelConfig.animation`, persisted on a marker's `meta.animation`
|
|
3
4
|
* and consumed by the shell exactly like a game-authored config. Pure data + reducers, no JSX — the
|
|
@@ -43,6 +44,8 @@ export interface AuthoredAnimationConfig {
|
|
|
43
44
|
timeScale?: number;
|
|
44
45
|
states?: AuthoredAnimationStates;
|
|
45
46
|
oneShots?: Record<string, string>;
|
|
47
|
+
/** A stored animation graph; takes over from `states`/`oneShots` at play time. */
|
|
48
|
+
graph?: AnimGraph;
|
|
46
49
|
}
|
|
47
50
|
/** The value stored at `meta.animation`. */
|
|
48
51
|
export type AnimationSetting = AuthoredAnimationConfig | "auto" | "none";
|
|
@@ -73,3 +76,23 @@ export declare function setOneShotClip(setting: AnimationSetting | undefined, ev
|
|
|
73
76
|
export declare function animationMetaPatch(setting: AnimationSetting | undefined): {
|
|
74
77
|
animation: AnimationSetting | undefined;
|
|
75
78
|
};
|
|
79
|
+
/** Where the graph a placement plays comes from. */
|
|
80
|
+
export type AnimGraphSource = "authored" | "locomotion" | "auto";
|
|
81
|
+
/**
|
|
82
|
+
* The graph a placement plays at run time: its stored `graph`, else the locomotion graph of its
|
|
83
|
+
* custom `states`/`oneShots`, else the one clip roles derive for `"auto"` and no-override placements.
|
|
84
|
+
* `null` for `"none"`, a single-clip config, or a rig whose clips have no idle.
|
|
85
|
+
*/
|
|
86
|
+
export declare function effectiveAnimGraph(setting: AnimationSetting | undefined, clips: readonly string[]): {
|
|
87
|
+
graph: AnimGraph;
|
|
88
|
+
source: AnimGraphSource;
|
|
89
|
+
} | null;
|
|
90
|
+
/** Stores `graph` on the placement, keeping its other fields; the stored graph wins at play time. */
|
|
91
|
+
export declare function storeAnimGraph(setting: AnimationSetting | undefined, graph: AnimGraph): AuthoredAnimationConfig;
|
|
92
|
+
/** Removes a stored graph so `states`/`oneShots` (or clip roles) drive the placement again. */
|
|
93
|
+
export declare function clearAnimGraph(setting: AnimationSetting | undefined): AnimationSetting | undefined;
|
|
94
|
+
/**
|
|
95
|
+
* Sets one transition's crossfade seconds in `graph` and stores the result, so editing a derived
|
|
96
|
+
* graph turns it into an authored one.
|
|
97
|
+
*/
|
|
98
|
+
export declare function setTransitionDuration(setting: AnimationSetting | undefined, graph: AnimGraph, layerId: string, index: number, duration: number): AuthoredAnimationConfig;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseAnimGraph } from "@jgengine/core/anim/animGraph";
|
|
2
|
+
import { animGraphFromConfig } from "@jgengine/core/anim/locomotionGraph";
|
|
3
|
+
import { resolveAnimationConfig, rolesFromClips } from "@jgengine/core/game/clipRoles";
|
|
2
4
|
/**
|
|
3
5
|
* Authoring model for a placement's `ModelConfig.animation`, persisted on a marker's `meta.animation`
|
|
4
6
|
* and consumed by the shell exactly like a game-authored config. Pure data + reducers, no JSX — the
|
|
@@ -63,6 +65,9 @@ export function readAnimationSetting(meta) {
|
|
|
63
65
|
if (Object.keys(oneShots).length > 0)
|
|
64
66
|
config.oneShots = oneShots;
|
|
65
67
|
}
|
|
68
|
+
const graph = parseAnimGraph(value["graph"]);
|
|
69
|
+
if (graph !== undefined)
|
|
70
|
+
config.graph = graph;
|
|
66
71
|
return config;
|
|
67
72
|
}
|
|
68
73
|
/** The authoring mode a stored setting maps to. */
|
|
@@ -162,3 +167,55 @@ export function setOneShotClip(setting, event, clipName) {
|
|
|
162
167
|
export function animationMetaPatch(setting) {
|
|
163
168
|
return { animation: setting };
|
|
164
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* The graph a placement plays at run time: its stored `graph`, else the locomotion graph of its
|
|
172
|
+
* custom `states`/`oneShots`, else the one clip roles derive for `"auto"` and no-override placements.
|
|
173
|
+
* `null` for `"none"`, a single-clip config, or a rig whose clips have no idle.
|
|
174
|
+
*/
|
|
175
|
+
export function effectiveAnimGraph(setting, clips) {
|
|
176
|
+
if (setting === "none")
|
|
177
|
+
return null;
|
|
178
|
+
if (setting !== undefined && setting !== "auto") {
|
|
179
|
+
if (setting.graph !== undefined)
|
|
180
|
+
return { graph: setting.graph, source: "authored" };
|
|
181
|
+
const states = setting.states;
|
|
182
|
+
if (states?.idle === undefined)
|
|
183
|
+
return null;
|
|
184
|
+
const graph = animGraphFromConfig({
|
|
185
|
+
states: { ...states, idle: states.idle, walk: states.walk ?? states.idle },
|
|
186
|
+
...(setting.oneShots === undefined ? {} : { oneShots: setting.oneShots }),
|
|
187
|
+
});
|
|
188
|
+
return graph === undefined ? null : { graph, source: "locomotion" };
|
|
189
|
+
}
|
|
190
|
+
const resolved = resolveAnimationConfig("auto", clips);
|
|
191
|
+
const graph = resolved === undefined ? undefined : animGraphFromConfig(resolved);
|
|
192
|
+
return graph === undefined ? null : { graph, source: "auto" };
|
|
193
|
+
}
|
|
194
|
+
/** Stores `graph` on the placement, keeping its other fields; the stored graph wins at play time. */
|
|
195
|
+
export function storeAnimGraph(setting, graph) {
|
|
196
|
+
return normalizeConfig({ ...asConfig(setting), graph });
|
|
197
|
+
}
|
|
198
|
+
/** Removes a stored graph so `states`/`oneShots` (or clip roles) drive the placement again. */
|
|
199
|
+
export function clearAnimGraph(setting) {
|
|
200
|
+
if (setting === undefined || setting === "auto" || setting === "none")
|
|
201
|
+
return setting;
|
|
202
|
+
const { graph: _graph, ...rest } = setting;
|
|
203
|
+
const next = normalizeConfig(rest);
|
|
204
|
+
return Object.keys(next).length === 0 ? undefined : next;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Sets one transition's crossfade seconds in `graph` and stores the result, so editing a derived
|
|
208
|
+
* graph turns it into an authored one.
|
|
209
|
+
*/
|
|
210
|
+
export function setTransitionDuration(setting, graph, layerId, index, duration) {
|
|
211
|
+
const next = {
|
|
212
|
+
...graph,
|
|
213
|
+
layers: graph.layers.map((layer) => layer.id !== layerId
|
|
214
|
+
? layer
|
|
215
|
+
: {
|
|
216
|
+
...layer,
|
|
217
|
+
transitions: layer.transitions.map((transition, i) => i === index ? { ...transition, duration: Math.max(0, Number.isFinite(duration) ? duration : 0) } : transition),
|
|
218
|
+
}),
|
|
219
|
+
};
|
|
220
|
+
return storeAnimGraph(setting, next);
|
|
221
|
+
}
|
package/dist/session.d.ts
CHANGED
|
@@ -105,6 +105,7 @@ export type EditorBridgeRequest = {
|
|
|
105
105
|
color?: string;
|
|
106
106
|
label?: string;
|
|
107
107
|
rotationY?: number;
|
|
108
|
+
catalogId?: string;
|
|
108
109
|
meta?: Record<string, unknown>;
|
|
109
110
|
} | {
|
|
110
111
|
method: "set_marker";
|
|
@@ -113,6 +114,7 @@ export type EditorBridgeRequest = {
|
|
|
113
114
|
color?: string;
|
|
114
115
|
label?: string;
|
|
115
116
|
rotationY?: number;
|
|
117
|
+
catalogId?: string;
|
|
116
118
|
meta?: Record<string, unknown>;
|
|
117
119
|
} | {
|
|
118
120
|
method: "set_note";
|
|
@@ -246,6 +248,15 @@ export type EditorBridgeRequest = {
|
|
|
246
248
|
padding?: number;
|
|
247
249
|
resolution?: number;
|
|
248
250
|
waterLevel?: number;
|
|
251
|
+
} | {
|
|
252
|
+
method: "bakeNavMesh";
|
|
253
|
+
id: string;
|
|
254
|
+
positions: number[];
|
|
255
|
+
indices: number[];
|
|
256
|
+
agentRadius: number;
|
|
257
|
+
agentHeight: number;
|
|
258
|
+
maxSlope: number;
|
|
259
|
+
maxClimb: number;
|
|
249
260
|
} | {
|
|
250
261
|
method: "add_foliage";
|
|
251
262
|
points: {
|
package/dist/session.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createDocumentLiveSync, createEditorSession, createRuntimePlayControl, installDocumentLiveSync, listEditorKinds, normalizeEditorLayers, resolveCatalogDefinitions, seedEditorCatalogs, } from "@jgengine/core/editor/index";
|
|
2
2
|
import { registerBuiltinSceneKinds } from "@jgengine/core/scene/builtinSceneKinds";
|
|
3
3
|
import { EDITOR_RPC_HANDLERS } from "./handlers/index.js";
|
|
4
|
+
import { warmNavBake } from "./handlers/navmesh.js";
|
|
4
5
|
import { emitEditorConsole } from "./shell/consoleSink.js";
|
|
5
6
|
registerBuiltinSceneKinds();
|
|
6
7
|
/** The accepted run modes, in canonical order — the runtime guard behind {@link EditorRunMode}. @internal */
|
|
@@ -22,6 +23,7 @@ export function getEditorHost() {
|
|
|
22
23
|
}
|
|
23
24
|
/** Builds and installs an editor host for a game: session, visibility, assets, and RPC handling. @internal */
|
|
24
25
|
export function createEditorHost(options) {
|
|
26
|
+
warmNavBake();
|
|
25
27
|
const catalogDefinitions = options.catalogs ?? [];
|
|
26
28
|
const document = seedEditorCatalogs(normalizeEditorLayers(options.layers), catalogDefinitions);
|
|
27
29
|
const session = createEditorSession(document);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { EditorSession } from "@jgengine/core/editor/index";
|
|
2
|
+
import type { EditorAssetEntry } from "../AssetBrowser.js";
|
|
3
|
+
import type { EditorUiStore } from "../uiStore.js";
|
|
4
|
+
/**
|
|
5
|
+
* Graph mode of the Animation dock: the selected placement's animation graph (layers, states,
|
|
6
|
+
* transitions), a scrubbed preview driven by the headless graph runtime and shown on the rig in the
|
|
7
|
+
* viewport, trigger buttons that record presses on the timeline, and crossfade editing that stores
|
|
8
|
+
* the graph on the placement as an undoable edit.
|
|
9
|
+
*
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
export declare function AnimationGraphPanel({ session, ui, rigged, }: {
|
|
13
|
+
session: EditorSession;
|
|
14
|
+
ui: EditorUiStore;
|
|
15
|
+
rigged: readonly EditorAssetEntry[];
|
|
16
|
+
}): import("react").JSX.Element;
|
|
@@ -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
|
+
}
|