@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.
- package/CHANGELOG.md +529 -1
- package/dist/ClipPreviewLayer.d.ts +15 -0
- package/dist/ClipPreviewLayer.js +60 -0
- package/dist/EditorApp.js +4 -3
- package/dist/EditorCameraDriver.d.ts +6 -1
- package/dist/EditorCameraDriver.js +25 -1
- package/dist/EditorChrome.js +4 -6
- package/dist/InspectorPanel.d.ts +4 -1
- package/dist/InspectorPanel.js +47 -5
- package/dist/ParentPickerMenu.js +2 -6
- package/dist/RuntimePlayBridge.js +10 -15
- package/dist/SchemaInspector.d.ts +3 -1
- package/dist/SchemaInspector.js +63 -32
- package/dist/TerrainPanel.js +3 -4
- package/dist/TerrainReadout.js +1 -3
- package/dist/agent/AgentPanel.js +7 -11
- package/dist/camera/orbitFraming.d.ts +62 -0
- package/dist/camera/orbitFraming.js +69 -0
- package/dist/chromeFields.js +9 -4
- package/dist/handlers/document.d.ts +1 -1
- package/dist/handlers/document.js +78 -9
- package/dist/mcp/fieldSpec.js +4 -2
- package/dist/mcp/tools.js +4 -2
- package/dist/modelAnimationAuthoring.d.ts +75 -0
- package/dist/modelAnimationAuthoring.js +164 -0
- package/dist/session.d.ts +54 -15
- package/dist/shell/AnimationPanel.d.ts +10 -8
- package/dist/shell/AnimationPanel.js +120 -26
- package/dist/shell/BottomDock.d.ts +3 -1
- package/dist/shell/BottomDock.js +2 -2
- package/dist/shell/CommandPalette.js +1 -5
- package/dist/shell/HierarchyPanel.js +17 -26
- package/dist/shell/PlayModeBar.d.ts +7 -0
- package/dist/shell/PlayModeBar.js +12 -3
- package/dist/shell/SceneToolbar.js +9 -5
- package/dist/shell/clipPreview.d.ts +85 -0
- package/dist/shell/clipPreview.js +82 -0
- package/dist/shell/fields.js +8 -8
- package/dist/shell/glbThumbnailCache.js +39 -8
- package/dist/shell/scatterCoverage.d.ts +22 -0
- package/dist/shell/scatterCoverage.js +40 -0
- package/dist/uiStore.d.ts +6 -0
- package/dist/uiStore.js +1 -0
- package/dist/useStoreSelector.d.ts +12 -0
- package/dist/useStoreSelector.js +23 -0
- package/package.json +5 -5
- package/skills/ce-handoff/SKILL.md +149 -0
- package/skills/jgengine/SKILL.md +7 -3
- package/skills/jgengine/api.md +17 -7
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine/recipes/minimal-game.md +7 -4
- package/skills/jgengine-editor/api.md +56 -4
- 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
|
-
|
|
513
|
-
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
*
|
|
9
|
-
* - Out of scope: clip authoring, keyframe curves, scene-kind param animation, director cinematics
|
|
10
|
-
* stored outside the scene document.
|
|
6
|
+
* Animation dock with two modes:
|
|
7
|
+
* - **Path** — scrub/play the editor orbit camera along authored scene polylines (real path points,
|
|
8
|
+
* no fabricated keyframes).
|
|
9
|
+
* - **Clips** — preview a rigged asset's animation clips in the viewport (scrub, loop, speed) via the
|
|
10
|
+
* shell mixer, either a placed instance's asset or any rigged catalog asset.
|
|
11
11
|
*
|
|
12
12
|
* @internal
|
|
13
13
|
*/
|
|
14
|
-
export declare function AnimationPanel({ session, api }: {
|
|
14
|
+
export declare function AnimationPanel({ session, api, ui, assets, }: {
|
|
15
15
|
session: EditorSession;
|
|
16
16
|
api: EditorHostApi;
|
|
17
|
+
ui: EditorUiStore;
|
|
18
|
+
assets: readonly EditorAssetEntry[];
|
|
17
19
|
}): import("react").JSX.Element;
|
|
@@ -1,23 +1,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 {
|
|
3
|
+
import { markerCatalogId } from "@jgengine/core/world/authoredObjects";
|
|
4
|
+
import { shallowArrayEqual, useStoreSelector } from "../useStoreSelector.js";
|
|
5
|
+
import { advancePreview, clipEntriesFromRef, initialClipPreviewState, MAX_PREVIEW_SPEED, MIN_PREVIEW_SPEED, scrubPreview, selectPreviewClip, setPreviewLoop, setPreviewSpeed, togglePreviewPlaying, } from "./clipPreview.js";
|
|
4
6
|
import { listScrubbablePaths, samplePathAt } from "./pathFlythrough.js";
|
|
5
7
|
import { FOCUS_RING, INPUT_CLS, NUMERIC } from "./theme.js";
|
|
6
8
|
import { EmptyState, IconButton } from "./ui.js";
|
|
7
9
|
/** Default world-units/sec when playing a path flythrough preview. */
|
|
8
10
|
const DEFAULT_PLAY_SPEED = 8;
|
|
11
|
+
/** Rigged asset entries (carry animation clips) for the clip-preview picker. */
|
|
12
|
+
function riggedAssets(assets) {
|
|
13
|
+
return assets.filter((entry) => entry.url !== undefined && entry.clips !== undefined && entry.clips.length > 0);
|
|
14
|
+
}
|
|
9
15
|
/**
|
|
10
|
-
* Animation dock
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
15
|
-
* - Out of scope: clip authoring, keyframe curves, scene-kind param animation, director cinematics
|
|
16
|
-
* stored outside the scene document.
|
|
16
|
+
* Animation dock with two modes:
|
|
17
|
+
* - **Path** — scrub/play the editor orbit camera along authored scene polylines (real path points,
|
|
18
|
+
* no fabricated keyframes).
|
|
19
|
+
* - **Clips** — preview a rigged asset's animation clips in the viewport (scrub, loop, speed) via the
|
|
20
|
+
* shell mixer, either a placed instance's asset or any rigged catalog asset.
|
|
17
21
|
*
|
|
18
22
|
* @internal
|
|
19
23
|
*/
|
|
20
|
-
export function AnimationPanel({ session, api }) {
|
|
24
|
+
export function AnimationPanel({ session, api, ui, assets, }) {
|
|
25
|
+
const rigged = useMemo(() => riggedAssets(assets), [assets]);
|
|
26
|
+
// Default to clips only when the scene has rigged assets but no scrubbable path yet.
|
|
27
|
+
const paths = useStoreSelector(session, (state) => state.document.paths);
|
|
28
|
+
const [mode, setMode] = useState(() => rigged.length > 0 && listScrubbablePaths(paths).length === 0 ? "clips" : "path");
|
|
29
|
+
// Tear down the viewport preview 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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
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.
|
|
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-
|
|
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;
|
package/dist/shell/BottomDock.js
CHANGED
|
@@ -11,7 +11,7 @@ import { IconButton, PanelTabs } from "./ui.js";
|
|
|
11
11
|
* Tabbed bottom dock: Content Browser, Console, Profiler, Animation (path flythrough scrub),
|
|
12
12
|
* and the AI Assistant. Only the active tab's panel mounts, so hidden tools cost nothing per frame.
|
|
13
13
|
*/
|
|
14
|
-
export function BottomDock({ tab, onSelectTab, onClose, assets, session, api, consoleStore, perfHistory, browserView, onSetBrowserView, onPlaceAsset, onImportModels, importBusy, }) {
|
|
14
|
+
export function BottomDock({ tab, onSelectTab, onClose, assets, session, api, ui, consoleStore, perfHistory, browserView, onSetBrowserView, onPlaceAsset, onImportModels, importBusy, }) {
|
|
15
15
|
const consoleEntries = useSyncExternalStore(consoleStore.subscribe, consoleStore.getEntries, consoleStore.getEntries);
|
|
16
16
|
const consoleErrorCount = consoleEntries.reduce((sum, entry) => (entry.severity === "error" ? sum + 1 : sum), 0);
|
|
17
17
|
let content;
|
|
@@ -25,7 +25,7 @@ export function BottomDock({ tab, onSelectTab, onClose, assets, session, api, co
|
|
|
25
25
|
content = _jsx(ProfilerPanel, { history: perfHistory });
|
|
26
26
|
}
|
|
27
27
|
else if (tab === "animation") {
|
|
28
|
-
content = _jsx(AnimationPanel, { session: session, api: api });
|
|
28
|
+
content = _jsx(AnimationPanel, { session: session, api: api, ui: ui, assets: assets });
|
|
29
29
|
}
|
|
30
30
|
else {
|
|
31
31
|
content = _jsx(AgentPanel, { api: api, embedded: true });
|
|
@@ -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", {
|
|
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) => {
|