1ab6ffd593
Add path editor window, session playback on control/presentation, and RMB controls. Fix live pose clock so motion no longer freezes after ~250ms. Co-authored-by: Cursor <cursoragent@cursor.com>
156 lines
4.9 KiB
TypeScript
156 lines
4.9 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
|
|
import { computePathPlaybackSample } from '../../../shared/types/tokenPathPlayback';
|
|
import {
|
|
tokenPathKey,
|
|
type TokenPathPlaybackPhase,
|
|
type TokenPathSessionState,
|
|
} from '../../../shared/types/tokenPathSession';
|
|
import type { SceneNpcToken, SceneToken } from '../../../shared/types';
|
|
|
|
export type TokenPathLivePose = {
|
|
nx: number;
|
|
ny: number;
|
|
rotationDeg: number;
|
|
phase: TokenPathPlaybackPhase;
|
|
dist: number;
|
|
};
|
|
|
|
type PoseMaps = {
|
|
tokenPoses: Record<string, TokenPathLivePose>;
|
|
npcPoses: Record<string, TokenPathLivePose>;
|
|
};
|
|
|
|
const EMPTY: PoseMaps = { tokenPoses: {}, npcPoses: {} };
|
|
|
|
function posesEqual(a: PoseMaps, b: PoseMaps): boolean {
|
|
const aT = a.tokenPoses;
|
|
const bT = b.tokenPoses;
|
|
const aN = a.npcPoses;
|
|
const bN = b.npcPoses;
|
|
const aTk = Object.keys(aT);
|
|
const bTk = Object.keys(bT);
|
|
const aNk = Object.keys(aN);
|
|
const bNk = Object.keys(bN);
|
|
if (aTk.length !== bTk.length || aNk.length !== bNk.length) return false;
|
|
for (const k of aTk) {
|
|
const x = aT[k];
|
|
const y = bT[k];
|
|
if (!y || x!.nx !== y.nx || x!.ny !== y.ny || x!.rotationDeg !== y.rotationDeg || x!.phase !== y.phase) {
|
|
return false;
|
|
}
|
|
}
|
|
for (const k of aNk) {
|
|
const x = aN[k];
|
|
const y = bN[k];
|
|
if (!y || x!.nx !== y.nx || x!.ny !== y.ny || x!.rotationDeg !== y.rotationDeg || x!.phase !== y.phase) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* RAF poses for control/presentation while path playback is active.
|
|
* When phase === 'stopped', pose is omitted so session/placement (drag) wins.
|
|
*
|
|
* Uses local Date.now() — segmentStartedAtMs is also wall-clock from main (same machine).
|
|
* Do NOT clamp to stale serverNowMs: that froze motion after ~250ms until the next IPC bump.
|
|
*/
|
|
export function useTokenPathLivePoses(args: {
|
|
tokens: readonly SceneToken[];
|
|
npcTokens: readonly SceneNpcToken[];
|
|
pathSession: TokenPathSessionState | null;
|
|
enabled?: boolean;
|
|
onMarkDone?: (kind: 'token' | 'npcToken', placementId: string, atDist: number) => void;
|
|
}): PoseMaps {
|
|
const { tokens, npcTokens, pathSession, enabled = true, onMarkDone } = args;
|
|
const [poses, setPoses] = useState<PoseMaps>(EMPTY);
|
|
const markedDoneRef = useRef<Set<string>>(new Set());
|
|
const onMarkDoneRef = useRef(onMarkDone);
|
|
onMarkDoneRef.current = onMarkDone;
|
|
|
|
const pathSessionRef = useRef(pathSession);
|
|
pathSessionRef.current = pathSession;
|
|
const tokensRef = useRef(tokens);
|
|
tokensRef.current = tokens;
|
|
const npcTokensRef = useRef(npcTokens);
|
|
npcTokensRef.current = npcTokens;
|
|
|
|
useEffect(() => {
|
|
markedDoneRef.current.clear();
|
|
}, [pathSession?.revision]);
|
|
|
|
useEffect(() => {
|
|
if (!enabled) {
|
|
setPoses(EMPTY);
|
|
return;
|
|
}
|
|
let raf = 0;
|
|
let alive = true;
|
|
|
|
const tick = () => {
|
|
if (!alive) return;
|
|
const session = pathSessionRef.current;
|
|
if (!session) {
|
|
setPoses((prev) => (prev === EMPTY || Object.keys(prev.tokenPoses).length + Object.keys(prev.npcPoses).length === 0 ? prev : EMPTY));
|
|
raf = requestAnimationFrame(tick);
|
|
return;
|
|
}
|
|
|
|
// Wall clock: matches main's Date.now() for segmentStartedAtMs (Electron, one host).
|
|
const sampleNow = Date.now();
|
|
const tokenPoses: Record<string, TokenPathLivePose> = {};
|
|
const npcPoses: Record<string, TokenPathLivePose> = {};
|
|
|
|
const sampleOne = (
|
|
kind: 'token' | 'npcToken',
|
|
placementId: string,
|
|
path: NonNullable<SceneToken['path']>,
|
|
out: Record<string, TokenPathLivePose>,
|
|
) => {
|
|
const key = tokenPathKey(kind, placementId);
|
|
const entry = session.playback[key];
|
|
if (!entry) return;
|
|
if (entry.phase === 'stopped') return;
|
|
const result = computePathPlaybackSample({ path, entry, nowMs: sampleNow });
|
|
if (!result) return;
|
|
out[placementId] = {
|
|
nx: result.sample.nx,
|
|
ny: result.sample.ny,
|
|
rotationDeg: result.sample.rotationDeg,
|
|
phase: result.phase,
|
|
dist: result.sample.dist,
|
|
};
|
|
if (result.markDone && !markedDoneRef.current.has(key)) {
|
|
markedDoneRef.current.add(key);
|
|
onMarkDoneRef.current?.(kind, placementId, result.sample.dist);
|
|
}
|
|
};
|
|
|
|
for (const t of tokensRef.current) {
|
|
if (t.path && t.path.points.length >= 2) {
|
|
sampleOne('token', String(t.id), t.path, tokenPoses);
|
|
}
|
|
}
|
|
for (const t of npcTokensRef.current) {
|
|
if (t.path && t.path.points.length >= 2) {
|
|
sampleOne('npcToken', String(t.id), t.path, npcPoses);
|
|
}
|
|
}
|
|
|
|
const next: PoseMaps = { tokenPoses, npcPoses };
|
|
setPoses((prev) => (posesEqual(prev, next) ? prev : next));
|
|
raf = requestAnimationFrame(tick);
|
|
};
|
|
|
|
raf = requestAnimationFrame(tick);
|
|
return () => {
|
|
alive = false;
|
|
cancelAnimationFrame(raf);
|
|
};
|
|
}, [enabled]);
|
|
|
|
return poses;
|
|
}
|