@jgengine/editor 0.13.0 → 0.15.0-next.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +529 -1
  2. package/dist/ClipPreviewLayer.d.ts +15 -0
  3. package/dist/ClipPreviewLayer.js +60 -0
  4. package/dist/EditorApp.js +4 -3
  5. package/dist/EditorCameraDriver.d.ts +6 -1
  6. package/dist/EditorCameraDriver.js +25 -1
  7. package/dist/EditorChrome.js +4 -6
  8. package/dist/InspectorPanel.d.ts +4 -1
  9. package/dist/InspectorPanel.js +47 -5
  10. package/dist/ParentPickerMenu.js +2 -6
  11. package/dist/RuntimePlayBridge.js +10 -15
  12. package/dist/SchemaInspector.d.ts +3 -1
  13. package/dist/SchemaInspector.js +63 -32
  14. package/dist/TerrainPanel.js +3 -4
  15. package/dist/TerrainReadout.js +1 -3
  16. package/dist/agent/AgentPanel.js +7 -11
  17. package/dist/camera/orbitFraming.d.ts +62 -0
  18. package/dist/camera/orbitFraming.js +69 -0
  19. package/dist/chromeFields.js +9 -4
  20. package/dist/handlers/document.d.ts +1 -1
  21. package/dist/handlers/document.js +78 -9
  22. package/dist/mcp/fieldSpec.js +4 -2
  23. package/dist/mcp/tools.js +4 -2
  24. package/dist/modelAnimationAuthoring.d.ts +75 -0
  25. package/dist/modelAnimationAuthoring.js +164 -0
  26. package/dist/session.d.ts +54 -15
  27. package/dist/shell/AnimationPanel.d.ts +10 -8
  28. package/dist/shell/AnimationPanel.js +120 -26
  29. package/dist/shell/BottomDock.d.ts +3 -1
  30. package/dist/shell/BottomDock.js +2 -2
  31. package/dist/shell/CommandPalette.js +1 -5
  32. package/dist/shell/HierarchyPanel.js +17 -26
  33. package/dist/shell/PlayModeBar.d.ts +7 -0
  34. package/dist/shell/PlayModeBar.js +12 -3
  35. package/dist/shell/SceneToolbar.js +9 -5
  36. package/dist/shell/clipPreview.d.ts +85 -0
  37. package/dist/shell/clipPreview.js +82 -0
  38. package/dist/shell/fields.js +8 -8
  39. package/dist/shell/glbThumbnailCache.js +39 -8
  40. package/dist/shell/scatterCoverage.d.ts +22 -0
  41. package/dist/shell/scatterCoverage.js +40 -0
  42. package/dist/uiStore.d.ts +6 -0
  43. package/dist/uiStore.js +1 -0
  44. package/dist/useStoreSelector.d.ts +12 -0
  45. package/dist/useStoreSelector.js +23 -0
  46. package/package.json +5 -5
  47. package/skills/ce-handoff/SKILL.md +149 -0
  48. package/skills/jgengine/SKILL.md +7 -3
  49. package/skills/jgengine/api.md +17 -7
  50. package/skills/jgengine/capabilities.md +12 -0
  51. package/skills/jgengine/recipes/minimal-game.md +7 -4
  52. package/skills/jgengine-editor/api.md +56 -4
  53. package/skills/jgengine-editor/reference.md +44 -0
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Authoring model for a placement's `ModelConfig.animation`, persisted on a marker's `meta.animation`
3
+ * and consumed by the shell exactly like a game-authored config. Pure data + reducers, no JSX — the
4
+ * inspector section wires these to `session.dispatch({ type: "setMarker", ... })` so every edit is
5
+ * undoable like any other inspector change. Splitting this out mirrors how `pathFlythrough.ts` /
6
+ * `renameObject.ts` keep inspector logic testable apart from the panel.
7
+ *
8
+ * The stored value is either the string modes `"auto"` / `"none"` (see `resolveAnimationConfig`) or a
9
+ * concrete config object. `undefined` means "no override" — a catalog-resolved rigged asset still
10
+ * auto-animates from its clip roles. Authored types here are permissive supersets of core's
11
+ * `ModelAnimationConfig` so an in-progress edit (idle picked, walk not yet) is still storable.
12
+ *
13
+ * @internal
14
+ */
15
+ /** Locomotion state roles the speed-driven idle/walk/run crossfade reads. */
16
+ export declare const LOCOMOTION_ROLES: readonly ["idle", "walk", "run"];
17
+ /** Locomotion clip role a placement can override (idle/walk/run). */
18
+ export type LocomotionRole = (typeof LOCOMOTION_ROLES)[number];
19
+ /** Numeric locomotion tunings (`ModelAnimationStates`). */
20
+ export declare const LOCOMOTION_NUMBERS: readonly ["walkSpeed", "runSpeed", "fadeSec"];
21
+ /** Numeric locomotion tuning field on an authored animation config (walkSpeed/runSpeed/fadeSec). */
22
+ export type LocomotionNumber = (typeof LOCOMOTION_NUMBERS)[number];
23
+ /**
24
+ * One-shot event slots offered in the inspector. `hit` / `death` auto-fire on this entity's
25
+ * `combat.hitReaction` / `entity.died`; the rest fire when the game calls `playEntityAnimation`.
26
+ */
27
+ export declare const ONE_SHOT_EVENTS: readonly ["attack", "hit", "death", "jump", "interact", "cheer"];
28
+ /** One-shot animation event a placement can bind a clip to (attack/hit/death/...). */
29
+ export type OneShotEvent = (typeof ONE_SHOT_EVENTS)[number];
30
+ /** Role->clip override map persisted inside an authored animation config. */
31
+ export interface AuthoredAnimationStates {
32
+ idle?: string;
33
+ walk?: string;
34
+ run?: string;
35
+ walkSpeed?: number;
36
+ runSpeed?: number;
37
+ fadeSec?: number;
38
+ }
39
+ /** Structurally compatible with `ModelAnimationConfig`, but every field optional for authoring. */
40
+ export interface AuthoredAnimationConfig {
41
+ clip?: string;
42
+ loop?: boolean;
43
+ timeScale?: number;
44
+ states?: AuthoredAnimationStates;
45
+ oneShots?: Record<string, string>;
46
+ }
47
+ /** The value stored at `meta.animation`. */
48
+ export type AnimationSetting = AuthoredAnimationConfig | "auto" | "none";
49
+ /** Which authoring mode a stored setting represents. `default` = no override key. */
50
+ export type AnimationMode = "default" | "auto" | "none" | "custom";
51
+ /** Reads and shape-validates the animation override from a marker's meta; undefined when absent/invalid. */
52
+ export declare function readAnimationSetting(meta: Record<string, unknown> | undefined): AnimationSetting | undefined;
53
+ /** The authoring mode a stored setting maps to. */
54
+ export declare function animationMode(setting: AnimationSetting | undefined): AnimationMode;
55
+ /**
56
+ * Switches authoring mode. `custom` seeds from the asset's clip-role defaults (so the user starts from
57
+ * a working config, then overrides) when there is no existing object config; the other modes are the
58
+ * bare string/undefined values.
59
+ */
60
+ export declare function setAnimationMode(setting: AnimationSetting | undefined, mode: AnimationMode, clips?: readonly string[]): AnimationSetting | undefined;
61
+ /** A concrete custom config derived from a rigged asset's clip roles — the `custom` mode seed. */
62
+ export declare function defaultCustomConfig(clips: readonly string[]): AuthoredAnimationConfig;
63
+ /** Sets or clears (null) a locomotion state clip; forces the setting into `custom`. */
64
+ export declare function setLocomotionClip(setting: AnimationSetting | undefined, role: LocomotionRole, clipName: string | null): AuthoredAnimationConfig;
65
+ /** Sets or clears (null) a numeric locomotion tuning. */
66
+ export declare function setLocomotionNumber(setting: AnimationSetting | undefined, key: LocomotionNumber, value: number | null): AuthoredAnimationConfig;
67
+ /** Binds or clears (null) a one-shot event to a clip. */
68
+ export declare function setOneShotClip(setting: AnimationSetting | undefined, event: string, clipName: string | null): AuthoredAnimationConfig;
69
+ /**
70
+ * The meta patch that persists a setting (or clears the override when undefined). Shallow-merges onto
71
+ * `marker.meta` via `setMarker`; an undefined value drops out of the saved JSON document.
72
+ */
73
+ export declare function animationMetaPatch(setting: AnimationSetting | undefined): {
74
+ animation: AnimationSetting | undefined;
75
+ };
@@ -0,0 +1,164 @@
1
+ import { rolesFromClips } from "@jgengine/core/game/clipRoles";
2
+ /**
3
+ * Authoring model for a placement's `ModelConfig.animation`, persisted on a marker's `meta.animation`
4
+ * and consumed by the shell exactly like a game-authored config. Pure data + reducers, no JSX — the
5
+ * inspector section wires these to `session.dispatch({ type: "setMarker", ... })` so every edit is
6
+ * undoable like any other inspector change. Splitting this out mirrors how `pathFlythrough.ts` /
7
+ * `renameObject.ts` keep inspector logic testable apart from the panel.
8
+ *
9
+ * The stored value is either the string modes `"auto"` / `"none"` (see `resolveAnimationConfig`) or a
10
+ * concrete config object. `undefined` means "no override" — a catalog-resolved rigged asset still
11
+ * auto-animates from its clip roles. Authored types here are permissive supersets of core's
12
+ * `ModelAnimationConfig` so an in-progress edit (idle picked, walk not yet) is still storable.
13
+ *
14
+ * @internal
15
+ */
16
+ /** Locomotion state roles the speed-driven idle/walk/run crossfade reads. */
17
+ export const LOCOMOTION_ROLES = ["idle", "walk", "run"];
18
+ /** Numeric locomotion tunings (`ModelAnimationStates`). */
19
+ export const LOCOMOTION_NUMBERS = ["walkSpeed", "runSpeed", "fadeSec"];
20
+ /**
21
+ * One-shot event slots offered in the inspector. `hit` / `death` auto-fire on this entity's
22
+ * `combat.hitReaction` / `entity.died`; the rest fire when the game calls `playEntityAnimation`.
23
+ */
24
+ export const ONE_SHOT_EVENTS = ["attack", "hit", "death", "jump", "interact", "cheer"];
25
+ function isRecord(value) {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
+ }
28
+ /** Reads and shape-validates the animation override from a marker's meta; undefined when absent/invalid. */
29
+ export function readAnimationSetting(meta) {
30
+ const value = meta?.["animation"];
31
+ if (value === "auto" || value === "none")
32
+ return value;
33
+ if (!isRecord(value))
34
+ return undefined;
35
+ const config = {};
36
+ if (typeof value["clip"] === "string")
37
+ config.clip = value["clip"];
38
+ if (typeof value["loop"] === "boolean")
39
+ config.loop = value["loop"];
40
+ if (typeof value["timeScale"] === "number")
41
+ config.timeScale = value["timeScale"];
42
+ if (isRecord(value["states"])) {
43
+ const raw = value["states"];
44
+ const states = {};
45
+ for (const role of LOCOMOTION_ROLES)
46
+ if (typeof raw[role] === "string")
47
+ states[role] = raw[role];
48
+ for (const key of LOCOMOTION_NUMBERS)
49
+ if (typeof raw[key] === "number")
50
+ states[key] = raw[key];
51
+ if (Object.keys(states).length > 0)
52
+ config.states = states;
53
+ }
54
+ if (isRecord(value["oneShots"])) {
55
+ const raw = value["oneShots"];
56
+ const oneShots = {};
57
+ for (const [event, clip] of Object.entries(raw)) {
58
+ if (typeof clip === "string")
59
+ oneShots[event] = clip;
60
+ else if (Array.isArray(clip) && typeof clip[0] === "string")
61
+ oneShots[event] = clip[0];
62
+ }
63
+ if (Object.keys(oneShots).length > 0)
64
+ config.oneShots = oneShots;
65
+ }
66
+ return config;
67
+ }
68
+ /** The authoring mode a stored setting maps to. */
69
+ export function animationMode(setting) {
70
+ if (setting === undefined)
71
+ return "default";
72
+ if (setting === "auto")
73
+ return "auto";
74
+ if (setting === "none")
75
+ return "none";
76
+ return "custom";
77
+ }
78
+ function asConfig(setting) {
79
+ return setting !== undefined && setting !== "auto" && setting !== "none" ? setting : {};
80
+ }
81
+ function normalizeConfig(config) {
82
+ const next = { ...config };
83
+ if (next.states !== undefined && Object.keys(next.states).length === 0)
84
+ delete next.states;
85
+ if (next.oneShots !== undefined && Object.keys(next.oneShots).length === 0)
86
+ delete next.oneShots;
87
+ return next;
88
+ }
89
+ /**
90
+ * Switches authoring mode. `custom` seeds from the asset's clip-role defaults (so the user starts from
91
+ * a working config, then overrides) when there is no existing object config; the other modes are the
92
+ * bare string/undefined values.
93
+ */
94
+ export function setAnimationMode(setting, mode, clips = []) {
95
+ if (mode === "default")
96
+ return undefined;
97
+ if (mode === "auto")
98
+ return "auto";
99
+ if (mode === "none")
100
+ return "none";
101
+ if (setting !== undefined && setting !== "auto" && setting !== "none")
102
+ return setting;
103
+ return normalizeConfig(defaultCustomConfig(clips));
104
+ }
105
+ /** A concrete custom config derived from a rigged asset's clip roles — the `custom` mode seed. */
106
+ export function defaultCustomConfig(clips) {
107
+ const roles = rolesFromClips(clips);
108
+ const states = {};
109
+ if (roles.idle?.[0] !== undefined)
110
+ states.idle = roles.idle[0];
111
+ if (roles.walk?.[0] !== undefined)
112
+ states.walk = roles.walk[0];
113
+ if (roles.run?.[0] !== undefined)
114
+ states.run = roles.run[0];
115
+ const oneShots = {};
116
+ for (const event of ONE_SHOT_EVENTS) {
117
+ const variant = roles[event]?.[0];
118
+ if (variant !== undefined)
119
+ oneShots[event] = variant;
120
+ }
121
+ const config = {};
122
+ if (Object.keys(states).length > 0)
123
+ config.states = states;
124
+ if (Object.keys(oneShots).length > 0)
125
+ config.oneShots = oneShots;
126
+ return config;
127
+ }
128
+ /** Sets or clears (null) a locomotion state clip; forces the setting into `custom`. */
129
+ export function setLocomotionClip(setting, role, clipName) {
130
+ const config = asConfig(setting);
131
+ const states = { ...config.states };
132
+ if (clipName === null)
133
+ delete states[role];
134
+ else
135
+ states[role] = clipName;
136
+ return normalizeConfig({ ...config, states });
137
+ }
138
+ /** Sets or clears (null) a numeric locomotion tuning. */
139
+ export function setLocomotionNumber(setting, key, value) {
140
+ const config = asConfig(setting);
141
+ const states = { ...config.states };
142
+ if (value === null || !Number.isFinite(value))
143
+ delete states[key];
144
+ else
145
+ states[key] = value;
146
+ return normalizeConfig({ ...config, states });
147
+ }
148
+ /** Binds or clears (null) a one-shot event to a clip. */
149
+ export function setOneShotClip(setting, event, clipName) {
150
+ const config = asConfig(setting);
151
+ const oneShots = { ...config.oneShots };
152
+ if (clipName === null)
153
+ delete oneShots[event];
154
+ else
155
+ oneShots[event] = clipName;
156
+ return normalizeConfig({ ...config, oneShots });
157
+ }
158
+ /**
159
+ * The meta patch that persists a setting (or clears the override when undefined). Shallow-merges onto
160
+ * `marker.meta` via `setMarker`; an undefined value drops out of the saved JSON document.
161
+ */
162
+ export function animationMetaPatch(setting) {
163
+ return { animation: setting };
164
+ }
package/dist/session.d.ts CHANGED
@@ -82,6 +82,30 @@ export type EditorBridgeRequest = {
82
82
  color?: string;
83
83
  label?: string;
84
84
  meta?: Record<string, unknown>;
85
+ } | {
86
+ method: "add_path";
87
+ id: string;
88
+ points: {
89
+ x: number;
90
+ y?: number;
91
+ z: number;
92
+ }[];
93
+ kind?: string;
94
+ width?: number;
95
+ color?: string;
96
+ label?: string;
97
+ meta?: Record<string, unknown>;
98
+ } | {
99
+ method: "add_marker";
100
+ id: string;
101
+ kind: string;
102
+ x: number;
103
+ y?: number;
104
+ z: number;
105
+ color?: string;
106
+ label?: string;
107
+ rotationY?: number;
108
+ meta?: Record<string, unknown>;
85
109
  } | {
86
110
  method: "set_marker";
87
111
  id: string;
@@ -114,8 +138,16 @@ export type EditorBridgeRequest = {
114
138
  x?: number;
115
139
  y?: number;
116
140
  z?: number;
141
+ distance?: number;
142
+ pitch?: number;
143
+ yaw?: number;
144
+ height?: number;
117
145
  } | {
118
146
  method: "camera_frame";
147
+ distance?: number;
148
+ pitch?: number;
149
+ yaw?: number;
150
+ height?: number;
119
151
  } | {
120
152
  method: "scene_summary";
121
153
  } | {
@@ -499,6 +531,25 @@ export interface EditorPerfSample {
499
531
  avgMs: number;
500
532
  }[];
501
533
  }
534
+ /**
535
+ * Where the editor orbit camera looks, plus optional placement so a single `camera_goto`/
536
+ * `camera_frame` can compose an aerial. `x/y/z` is the orbit target (pan-only when that is all that
537
+ * is set — the historical behavior); `distance`, `pitch` (degrees above the horizon), `yaw`
538
+ * (degrees), and `height` reposition the camera around that target when provided.
539
+ */
540
+ export interface EditorFocusTarget {
541
+ x: number;
542
+ y: number;
543
+ z: number;
544
+ /** Radial distance from the target to the camera, world units. */
545
+ distance?: number;
546
+ /** Elevation above the horizon, degrees (90 = straight-down aerial). */
547
+ pitch?: number;
548
+ /** Azimuth around the target, degrees. */
549
+ yaw?: number;
550
+ /** Explicit camera height above the target; overrides the pitch-derived height. */
551
+ height?: number;
552
+ }
502
553
  /** The live editor's global control surface — session, visibility, camera focus, assets, mode, RPC. */
503
554
  export interface EditorHostApi {
504
555
  gameId: string;
@@ -508,21 +559,9 @@ export interface EditorHostApi {
508
559
  getVisibility(): EditorKindVisibility;
509
560
  setVisibility(next: EditorKindVisibility): void;
510
561
  subscribeVisibility(listener: () => void): () => void;
511
- getFocusTarget(): {
512
- x: number;
513
- y: number;
514
- z: number;
515
- } | null;
516
- setFocusTarget(target: {
517
- x: number;
518
- y: number;
519
- z: number;
520
- } | null): void;
521
- subscribeFocus(listener: (target: {
522
- x: number;
523
- y: number;
524
- z: number;
525
- } | null) => void): () => void;
562
+ getFocusTarget(): EditorFocusTarget | null;
563
+ setFocusTarget(target: EditorFocusTarget | null): void;
564
+ subscribeFocus(listener: (target: EditorFocusTarget | null) => void): () => void;
526
565
  getAssets(): readonly EditorAssetInfo[];
527
566
  setAssets(assets: readonly EditorAssetInfo[]): void;
528
567
  getCatalogDefinitions(): readonly EditorCatalogDefinition[];
@@ -1,17 +1,19 @@
1
1
  import type { EditorSession } from "@jgengine/core/editor/index";
2
+ import type { EditorAssetEntry } from "../AssetBrowser.js";
2
3
  import type { EditorHostApi } from "../session.js";
4
+ import type { EditorUiStore } from "../uiStore.js";
3
5
  /**
4
- * Animation dock: path flythrough scrubber over authored scene polylines.
5
- *
6
- * Scope (honest, no fake NLE):
7
- * - Scrub / play the editor orbit camera along document paths via {@link EditorHostApi.setFocusTarget}.
8
- * - Uses real path points + `@jgengine/core` path-follow sampling — not invented keyframes.
9
- * - Out of scope: clip authoring, keyframe curves, scene-kind param animation, director cinematics
10
- * stored outside the scene document.
6
+ * Animation dock with two modes:
7
+ * - **Path** — scrub/play the editor orbit camera along authored scene polylines (real path points,
8
+ * no fabricated keyframes).
9
+ * - **Clips** — preview a rigged asset's animation clips in the viewport (scrub, loop, speed) via the
10
+ * shell mixer, either a placed instance's asset or any rigged catalog asset.
11
11
  *
12
12
  * @internal
13
13
  */
14
- export declare function AnimationPanel({ session, api }: {
14
+ export declare function AnimationPanel({ session, api, ui, assets, }: {
15
15
  session: EditorSession;
16
16
  api: EditorHostApi;
17
+ ui: EditorUiStore;
18
+ assets: readonly EditorAssetEntry[];
17
19
  }): import("react").JSX.Element;
@@ -1,23 +1,124 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { useStoreSelector } from "../useStoreSelector.js";
3
+ import { markerCatalogId } from "@jgengine/core/world/authoredObjects";
4
+ import { shallowArrayEqual, useStoreSelector } from "../useStoreSelector.js";
5
+ import { advancePreview, clipEntriesFromRef, initialClipPreviewState, MAX_PREVIEW_SPEED, MIN_PREVIEW_SPEED, scrubPreview, selectPreviewClip, setPreviewLoop, setPreviewSpeed, togglePreviewPlaying, } from "./clipPreview.js";
4
6
  import { listScrubbablePaths, samplePathAt } from "./pathFlythrough.js";
5
7
  import { FOCUS_RING, INPUT_CLS, NUMERIC } from "./theme.js";
6
8
  import { EmptyState, IconButton } from "./ui.js";
7
9
  /** Default world-units/sec when playing a path flythrough preview. */
8
10
  const DEFAULT_PLAY_SPEED = 8;
11
+ /** Rigged asset entries (carry animation clips) for the clip-preview picker. */
12
+ function riggedAssets(assets) {
13
+ return assets.filter((entry) => entry.url !== undefined && entry.clips !== undefined && entry.clips.length > 0);
14
+ }
9
15
  /**
10
- * Animation dock: path flythrough scrubber over authored scene polylines.
11
- *
12
- * Scope (honest, no fake NLE):
13
- * - Scrub / play the editor orbit camera along document paths via {@link EditorHostApi.setFocusTarget}.
14
- * - Uses real path points + `@jgengine/core` path-follow sampling — not invented keyframes.
15
- * - Out of scope: clip authoring, keyframe curves, scene-kind param animation, director cinematics
16
- * stored outside the scene document.
16
+ * Animation dock with two modes:
17
+ * - **Path** — scrub/play the editor orbit camera along authored scene polylines (real path points,
18
+ * no fabricated keyframes).
19
+ * - **Clips** — preview a rigged asset's animation clips in the viewport (scrub, loop, speed) via the
20
+ * shell mixer, either a placed instance's asset or any rigged catalog asset.
17
21
  *
18
22
  * @internal
19
23
  */
20
- export function AnimationPanel({ session, api }) {
24
+ export function AnimationPanel({ session, api, ui, assets, }) {
25
+ const rigged = useMemo(() => riggedAssets(assets), [assets]);
26
+ // Default to clips only when the scene has rigged assets but no scrubbable path yet.
27
+ const paths = useStoreSelector(session, (state) => state.document.paths);
28
+ const [mode, setMode] = useState(() => rigged.length > 0 && listScrubbablePaths(paths).length === 0 ? "clips" : "path");
29
+ // Tear down the viewport preview when the panel unmounts; leaving clip mode tears down in the
30
+ // tab click below.
31
+ useEffect(() => () => {
32
+ if (ui.getState().clipPreview !== null)
33
+ ui.patch({ clipPreview: null });
34
+ }, [ui]);
35
+ 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
+ ["path", "Path flythrough"],
37
+ ["clips", "Clip preview"],
38
+ ].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 !== "clips" && ui.getState().clipPreview !== null)
40
+ ui.patch({ clipPreview: null });
41
+ setMode(id);
42
+ }, children: label }, id))) }) }), mode === "path" ? (_jsx(PathFlythrough, { session: session, api: api })) : (_jsx(ClipPreview, { session: session, ui: ui, rigged: rigged }))] }));
43
+ }
44
+ /** Clip-preview mode: pick a rigged asset (placed instance or catalog) and play its clips in the viewport. */
45
+ function ClipPreview({ session, ui, rigged, }) {
46
+ const preview = useStoreSelector(ui, (state) => state.clipPreview);
47
+ const selection = useStoreSelector(session, (state) => state.selection, shallowArrayEqual);
48
+ // A placed marker in the selection that resolves to a rigged catalog asset — offer to preview it.
49
+ const selectedRigged = useMemo(() => {
50
+ const document = session.getState().document;
51
+ for (const id of selection) {
52
+ const marker = document.markers.find((entry) => entry.id === id);
53
+ if (marker === undefined)
54
+ continue;
55
+ const catalogId = markerCatalogId(marker);
56
+ if (catalogId === null)
57
+ continue;
58
+ const asset = rigged.find((entry) => entry.id === catalogId);
59
+ if (asset !== undefined)
60
+ return asset;
61
+ }
62
+ return null;
63
+ }, [selection, rigged, session]);
64
+ const startPreview = useCallback((asset) => {
65
+ if (asset.url === undefined || asset.clips === undefined)
66
+ return;
67
+ const firstClip = asset.clips[0] ?? null;
68
+ ui.patch({
69
+ clipPreview: {
70
+ source: { assetId: asset.id, label: asset.label, url: asset.url, clips: asset.clips },
71
+ driver: initialClipPreviewState(firstClip),
72
+ duration: 0,
73
+ },
74
+ });
75
+ }, [ui]);
76
+ const updateDriver = useCallback((next) => {
77
+ const current = ui.getState().clipPreview;
78
+ if (current === null)
79
+ return;
80
+ ui.patch({ clipPreview: { ...current, driver: next } });
81
+ }, [ui]);
82
+ // While playing with a known clip duration, advance the driver each frame (freerunning mixer + slider in sync).
83
+ const previewRef = useRef(preview);
84
+ previewRef.current = preview;
85
+ const playing = preview?.driver.playing ?? false;
86
+ useEffect(() => {
87
+ if (!playing)
88
+ return;
89
+ let frame = 0;
90
+ let last = performance.now();
91
+ const tick = (now) => {
92
+ const dt = Math.min(0.05, Math.max(0, (now - last) / 1000));
93
+ last = now;
94
+ const current = previewRef.current;
95
+ if (current === null || !current.driver.playing)
96
+ return;
97
+ const next = advancePreview(current.driver, dt, current.duration);
98
+ if (next !== current.driver)
99
+ ui.patch({ clipPreview: { ...current, driver: next } });
100
+ frame = requestAnimationFrame(tick);
101
+ };
102
+ frame = requestAnimationFrame(tick);
103
+ return () => cancelAnimationFrame(frame);
104
+ }, [playing, ui, preview?.duration, preview?.source.assetId]);
105
+ if (rigged.length === 0) {
106
+ 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." }));
107
+ }
108
+ const source = preview?.source ?? null;
109
+ const driver = preview?.driver ?? null;
110
+ const duration = preview?.duration ?? 0;
111
+ const clipEntries = clipEntriesFromRef(source);
112
+ 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) => {
113
+ const asset = rigged.find((entry) => entry.id === event.target.value);
114
+ if (asset !== undefined)
115
+ startPreview(asset);
116
+ }, 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
117
+ ? "border-cyan-400/30 bg-cyan-500/15 text-cyan-200"
118
+ : "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."] })] }))] })] }));
119
+ }
120
+ /** Path flythrough mode: scrub the orbit camera along authored scene polylines. */
121
+ function PathFlythrough({ session, api }) {
21
122
  const paths = useStoreSelector(session, (state) => state.document.paths);
22
123
  const scrubbable = useMemo(() => listScrubbablePaths(paths), [paths]);
23
124
  const [selectedId, setSelectedId] = useState(null);
@@ -25,22 +126,15 @@ export function AnimationPanel({ session, api }) {
25
126
  const [playing, setPlaying] = useState(false);
26
127
  const [loop, setLoop] = useState(true);
27
128
  const playRef = useRef({ playing: false, loop: true, progress: 0, pathId: null });
129
+ // selectedId is raw intent; a removed path falls back to the first scrubbable one at read time.
28
130
  const selected = scrubbable.find((entry) => entry.id === selectedId) ?? scrubbable[0] ?? null;
29
131
  const selectedPath = selected === null ? undefined : paths.find((path) => path.id === selected.id);
30
- useEffect(() => {
31
- if (selected !== null && selectedId !== selected.id)
32
- setSelectedId(selected.id);
33
- if (selected === null && selectedId !== null)
34
- setSelectedId(null);
35
- }, [selected, selectedId]);
36
- useEffect(() => {
37
- playRef.current = {
38
- playing,
39
- loop,
40
- progress,
41
- pathId: selected?.id ?? null,
42
- };
43
- }, [playing, loop, progress, selected?.id]);
132
+ playRef.current = {
133
+ playing,
134
+ loop,
135
+ progress,
136
+ pathId: selected?.id ?? null,
137
+ };
44
138
  const applyProgress = useCallback((next, pathId) => {
45
139
  const path = session.getState().document.paths.find((entry) => entry.id === pathId);
46
140
  if (path === undefined)
@@ -114,11 +208,11 @@ export function AnimationPanel({ session, api }) {
114
208
  };
115
209
  }, [playing, selected, applyProgress, session]);
116
210
  if (scrubbable.length === 0) {
117
- return (_jsx(EmptyState, { icon: "film", title: "Path flythrough", badge: "Scoped", description: "Scope: scrub the orbit camera along authored scene paths (routes, roads, patrols). Draw a path with 2+ points via Add \u2192 path/road, then open this tab. Keyframe clip authoring and scene-kind param animation are not in scope." }));
211
+ 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
212
  }
119
213
  const lengthLabel = selected === null ? "—" : selected.length < 10 ? `${selected.length.toFixed(1)} m` : `${Math.round(selected.length)} m`;
120
214
  const distance = selected === null || selected.length <= 0 ? 0 : progress * selected.length;
121
- return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [_jsxs("div", { className: "flex h-8 shrink-0 items-center gap-1.5 border-b border-white/[0.06] px-2", children: [_jsx("span", { className: "text-[10px] uppercase tracking-wider text-neutral-500", children: "Path flythrough" }), _jsxs("span", { className: `text-[10px] text-neutral-600 ${NUMERIC}`, children: [scrubbable.length, " path", scrubbable.length === 1 ? "" : "s", " \u00B7 camera follows scrub"] }), _jsxs("div", { className: "ml-auto flex items-center gap-1", children: [_jsx(IconButton, { icon: playing ? "pause" : "play", label: playing ? "Pause flythrough" : "Play flythrough", size: 12, active: playing, disabled: selected === null || selected.length <= 0, onClick: () => setPlaying((value) => !value) }), _jsx("button", { type: "button", className: `rounded-[5px] border px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${FOCUS_RING} ${loop
215
+ 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
216
  ? "border-cyan-400/30 bg-cyan-500/15 text-cyan-200"
123
217
  : "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
218
  ? "—"
@@ -1,6 +1,7 @@
1
1
  import type { EditorSession } from "@jgengine/core/editor/index";
2
2
  import type { EditorAssetEntry } from "../AssetBrowser.js";
3
3
  import type { EditorHostApi } from "../session.js";
4
+ import type { EditorUiStore } from "../uiStore.js";
4
5
  import type { EditorConsoleStore } from "./consoleStore.js";
5
6
  import type { BottomDockTab, BrowserViewMode } from "./layoutStore.js";
6
7
  import type { PerfHistoryStore } from "./perfHistory.js";
@@ -8,13 +9,14 @@ import type { PerfHistoryStore } from "./perfHistory.js";
8
9
  * Tabbed bottom dock: Content Browser, Console, Profiler, Animation (path flythrough scrub),
9
10
  * and the AI Assistant. Only the active tab's panel mounts, so hidden tools cost nothing per frame.
10
11
  */
11
- export declare function BottomDock({ tab, onSelectTab, onClose, assets, session, api, consoleStore, perfHistory, browserView, onSetBrowserView, onPlaceAsset, onImportModels, importBusy, }: {
12
+ export declare function BottomDock({ tab, onSelectTab, onClose, assets, session, api, ui, consoleStore, perfHistory, browserView, onSetBrowserView, onPlaceAsset, onImportModels, importBusy, }: {
12
13
  tab: BottomDockTab;
13
14
  onSelectTab: (tab: BottomDockTab) => void;
14
15
  onClose: () => void;
15
16
  assets: readonly EditorAssetEntry[];
16
17
  session: EditorSession;
17
18
  api: EditorHostApi;
19
+ ui: EditorUiStore;
18
20
  consoleStore: EditorConsoleStore;
19
21
  perfHistory: PerfHistoryStore;
20
22
  browserView: BrowserViewMode;
@@ -11,7 +11,7 @@ import { IconButton, PanelTabs } from "./ui.js";
11
11
  * Tabbed bottom dock: Content Browser, Console, Profiler, Animation (path flythrough scrub),
12
12
  * and the AI Assistant. Only the active tab's panel mounts, so hidden tools cost nothing per frame.
13
13
  */
14
- export function BottomDock({ tab, onSelectTab, onClose, assets, session, api, consoleStore, perfHistory, browserView, onSetBrowserView, onPlaceAsset, onImportModels, importBusy, }) {
14
+ export function BottomDock({ tab, onSelectTab, onClose, assets, session, api, ui, consoleStore, perfHistory, browserView, onSetBrowserView, onPlaceAsset, onImportModels, importBusy, }) {
15
15
  const consoleEntries = useSyncExternalStore(consoleStore.subscribe, consoleStore.getEntries, consoleStore.getEntries);
16
16
  const consoleErrorCount = consoleEntries.reduce((sum, entry) => (entry.severity === "error" ? sum + 1 : sum), 0);
17
17
  let content;
@@ -25,7 +25,7 @@ export function BottomDock({ tab, onSelectTab, onClose, assets, session, api, co
25
25
  content = _jsx(ProfilerPanel, { history: perfHistory });
26
26
  }
27
27
  else if (tab === "animation") {
28
- content = _jsx(AnimationPanel, { session: session, api: api });
28
+ content = _jsx(AnimationPanel, { session: session, api: api, ui: ui, assets: assets });
29
29
  }
30
30
  else {
31
31
  content = _jsx(AgentPanel, { api: api, embedded: true });
@@ -13,13 +13,9 @@ export function CommandPalette({ commands, onClose, initialQuery = "", }) {
13
13
  const [query, setQuery] = useState(initialQuery);
14
14
  const [cursor, setCursor] = useState(0);
15
15
  const [recentIds, setRecentIds] = useState(() => loadRecentCommandIds());
16
- const inputRef = useRef(null);
17
16
  const listRef = useRef(null);
18
17
  const filtered = useMemo(() => filterPaletteCommands(commands, query, recentIds).slice(0, 40), [commands, query, recentIds]);
19
18
  const clampedCursor = Math.min(cursor, Math.max(0, filtered.length - 1));
20
- useEffect(() => {
21
- inputRef.current?.focus();
22
- }, []);
23
19
  useEffect(() => {
24
20
  const row = listRef.current?.children[clampedCursor];
25
21
  if (row instanceof HTMLElement)
@@ -32,7 +28,7 @@ export function CommandPalette({ commands, onClose, initialQuery = "", }) {
32
28
  onClose();
33
29
  command.run();
34
30
  };
35
- return (_jsx("div", { className: "pointer-events-auto absolute inset-0 z-[70] flex items-start justify-center bg-black/50 pt-[12vh] backdrop-blur-[2px]", onClick: onClose, children: _jsxs("div", { role: "dialog", "aria-label": "Command palette", className: "w-[520px] max-w-[92vw] overflow-hidden rounded-[8px] border border-white/10 bg-[#14171d] shadow-2xl shadow-black/60", onClick: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-white/[0.07] px-3", children: [_jsx(Icon, { name: "search", size: 14, className: "shrink-0 text-neutral-500" }), _jsx("input", { ref: inputRef, value: query, onChange: (event) => {
31
+ return (_jsx("div", { className: "pointer-events-auto absolute inset-0 z-[70] flex items-start justify-center bg-black/50 pt-[12vh] backdrop-blur-[2px]", onClick: onClose, children: _jsxs("div", { role: "dialog", "aria-label": "Command palette", className: "w-[520px] max-w-[92vw] overflow-hidden rounded-[8px] border border-white/10 bg-[#14171d] shadow-2xl shadow-black/60", onClick: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-white/[0.07] px-3", children: [_jsx(Icon, { name: "search", size: 14, className: "shrink-0 text-neutral-500" }), _jsx("input", { autoFocus: true, value: query, onChange: (event) => {
36
32
  setQuery(event.target.value);
37
33
  setCursor(0);
38
34
  }, onKeyDown: (event) => {