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; npcPoses: Record; }; 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(EMPTY); const markedDoneRef = useRef>(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 = {}; const npcPoses: Record = {}; const sampleOne = ( kind: 'token' | 'npcToken', placementId: string, path: NonNullable, out: Record, ) => { 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; }