feat(timeline): 支持角色轨迹关键帧与轨道拖拽排序
This commit is contained in:
@@ -12,12 +12,17 @@ export function PlaybackTransformSync() {
|
||||
const scene = useThree((state) => state.scene);
|
||||
const tracks = useDirectorStore((state) => state.project.timeline.tracks);
|
||||
const cameraTracks = useDirectorStore((state) => state.project.timeline.cameraTracks);
|
||||
const objects = useDirectorStore((state) => state.project.objects);
|
||||
const playhead = usePlaybackStore((state) => state.playhead);
|
||||
const duration = usePlaybackStore((state) => state.duration);
|
||||
const editingObjectId = usePlaybackStore((state) => state.editingObjectId);
|
||||
|
||||
useFrame(() => {
|
||||
// 播放、scrub 时都按 playhead 插值覆盖;editingObjectId 的跳过逻辑在共享函数里
|
||||
applyTimelineTransformsAtTime(scene, tracks, cameraTracks, playhead, editingObjectId);
|
||||
applyTimelineTransformsAtTime(scene, tracks, cameraTracks, playhead, editingObjectId, {
|
||||
objects,
|
||||
duration,
|
||||
});
|
||||
});
|
||||
|
||||
return null;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, vi } from "vitest";
|
||||
import { VIEWPORT_CAMERA_VISUAL_SCALE } from "../schema/cameraGeometry";
|
||||
import { degreesRotationToRadians } from "../schema/directorProject";
|
||||
import { createInitialDirectorState, useDirectorStore } from "../store/directorStore";
|
||||
import { usePlaybackStore } from "../runtime/playbackStore";
|
||||
import { getImportedModelNormalization, getTrajectoryGroundPoint, SceneRoot } from "./SceneRoot";
|
||||
import { AXIS_COLORS } from "./sceneAxes";
|
||||
import { GROUND_GRID_ELEVATION, GROUND_PLANE_SIZE } from "./sceneGround";
|
||||
@@ -162,6 +163,7 @@ vi.mock("../runtime/CharacterModel", async () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
mockCharacterModelShouldSuspend.current = false;
|
||||
usePlaybackStore.setState({ isPlaying: false, playhead: 0, duration: 5, editingObjectId: null });
|
||||
const base = createInitialDirectorState();
|
||||
useDirectorStore.setState({
|
||||
...useDirectorStore.getState(),
|
||||
@@ -678,6 +680,17 @@ it("renders solid trajectory spheres and a smooth curve through the waypoints",
|
||||
expect(container.querySelectorAll('mesh[name^="trajectory-point-"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("puts a walking character at its trajectory sample for the current playhead", () => {
|
||||
useDirectorStore.getState().addTrajectoryPoint("char_default_a", [8, 0, 0]);
|
||||
// 单点轨迹:0s 在角色起始位置,时间轴末尾(5s)到达该轨迹点
|
||||
usePlaybackStore.setState({ isPlaying: false, playhead: 5, duration: 5, editingObjectId: null });
|
||||
|
||||
render(<SceneRoot />);
|
||||
|
||||
const characterGroup = screen.getByTestId("mock-character-model").closest("group");
|
||||
expect(characterGroup).toHaveAttribute("position", "8,0,0");
|
||||
});
|
||||
|
||||
it("shows a translate gizmo on the selected trajectory point so it can be dragged", () => {
|
||||
useDirectorStore.getState().addTrajectoryPoint("char_default_a", [1, 0, 2]);
|
||||
const pointId = useDirectorStore
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
VIEWPORT_CAMERA_VISUAL_SCALE,
|
||||
} from "../schema/cameraGeometry";
|
||||
import { VIEWPORT_OBJECT_LABEL_VERTICAL_GAP } from "../schema/viewportLabels";
|
||||
import { evaluateTrajectoryMotionAtTime, getTrajectoryPointTime } from "../schema/trajectoryMotion";
|
||||
import type { TransformMode } from "../store/directorStore";
|
||||
import { useDirectorStore } from "../store/directorStore";
|
||||
import { usePlaybackStore } from "../runtime/playbackStore";
|
||||
@@ -498,14 +499,20 @@ function ObjectSceneNode({
|
||||
const updateObjectTransform = useDirectorStore((state) => state.updateObjectTransform);
|
||||
const isPlaying = usePlaybackStore((state) => state.isPlaying);
|
||||
const motionTime = usePlaybackStore((state) => state.playhead);
|
||||
// 当前选中的轨迹点若属于本角色,则把角色「预览」到该路点的位置与朝向上,
|
||||
// 让用户直接看到角色在某个轨迹点的姿态(水平角/俯仰角/横滚角)。
|
||||
// 播放时角色要沿路径动画,故预览只在非播放时生效(拖拽 gizmo 也已排除播放态)。
|
||||
const playbackDuration = usePlaybackStore((state) => state.duration);
|
||||
// 当前选中的轨迹点若属于本角色,则抑制角色自身的变换 gizmo(拖拽交给轨迹点 gizmo)。
|
||||
const selectedTrajectoryPoint = useDirectorStore((state) => state.selectedTrajectoryPoint);
|
||||
const activeTrajectoryPoint =
|
||||
!isPlaying && selectedTrajectoryPoint?.objectId === item.id
|
||||
? item.trajectoryPoints?.find((point) => point.id === selectedTrajectoryPoint.pointId)
|
||||
: undefined;
|
||||
// 带轨迹的角色:位置 / 朝向由「人物轨迹 + 播放头」决定。
|
||||
// 这里把同一个结果也算进 props,和 PlaybackTransformSync 每帧的写入保持一致,
|
||||
// 否则 React 每次重渲染都会把角色拉回起始姿态,和轨迹走位互相打架(表现为抖动/闪回)。
|
||||
const trajectoryMotion = useMemo(
|
||||
() => evaluateTrajectoryMotionAtTime(item, playbackDuration, motionTime),
|
||||
[item, playbackDuration, motionTime]
|
||||
);
|
||||
const isImportedModel = asset?.sourceType === "model";
|
||||
const characterLabelKey = `${item.id}:${item.bodyType ?? ""}:${item.characterRig?.rigType ?? ""}`;
|
||||
const fallbackCharacterLabelY =
|
||||
@@ -549,10 +556,10 @@ function ObjectSceneNode({
|
||||
const node = (
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={activeTrajectoryPoint ? activeTrajectoryPoint.position : item.transform.position}
|
||||
position={trajectoryMotion ? trajectoryMotion.position : item.transform.position}
|
||||
// 数据模型里旋转是度数,应用到 Three.js 的 rotation 属性(弧度)前要转换,
|
||||
// 否则 360° 会被当成 360 弧度 ≈ 57 圈。
|
||||
rotation={degreesRotationToRadians(activeTrajectoryPoint ? activeTrajectoryPoint.rotation : item.transform.rotation)}
|
||||
rotation={degreesRotationToRadians(trajectoryMotion ? trajectoryMotion.rotation : item.transform.rotation)}
|
||||
scale={item.transform.scale}
|
||||
userData={{ directorObjectId: item.id }}
|
||||
onClick={(event) => {
|
||||
@@ -867,6 +874,9 @@ function TrajectoryPath({
|
||||
const selectTrajectoryPoint = useDirectorStore((state) => state.selectTrajectoryPoint);
|
||||
const updateTrajectoryPoint = useDirectorStore((state) => state.updateTrajectoryPoint);
|
||||
const isPlaying = usePlaybackStore((state) => state.isPlaying);
|
||||
const setPlaying = usePlaybackStore((state) => state.setPlaying);
|
||||
const setPlayhead = usePlaybackStore((state) => state.setPlayhead);
|
||||
const playbackDuration = usePlaybackStore((state) => state.duration);
|
||||
const translationSnap = useDirectorStore((state) => (state.project.scene.snapToGrid ? 1 : null));
|
||||
const selectedGroupRef = useRef<Group>(null!);
|
||||
const points = owner.trajectoryPoints ?? [];
|
||||
@@ -903,6 +913,14 @@ function TrajectoryPath({
|
||||
function selectPoint(pointId: string, event: ThreeEvent<MouseEvent>) {
|
||||
event.stopPropagation();
|
||||
selectTrajectoryPoint(owner.id, pointId);
|
||||
|
||||
// 轨迹点本身就是时间轴上的关键帧:选中即把指针定位到该点时间,
|
||||
// 角色按「沿轨迹行走」的插值正好预览到这一点的位置与朝向(0s = 角色起始位置)。
|
||||
const pointIndex = points.findIndex((point) => point.id === pointId);
|
||||
if (pointIndex < 0) return;
|
||||
|
||||
setPlaying(false);
|
||||
setPlayhead(getTrajectoryPointTime(pointIndex, points.length, playbackDuration));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user