import { useEffect, useRef } from 'react'; import { computePathPlaybackSample } from '../../../shared/types/tokenPathPlayback'; import { tokenPathKey, type TokenPathSessionState, } from '../../../shared/types/tokenPathSession'; import type { SceneNpcToken, SceneToken } from '../../../shared/types'; import { applyLivePathPose, endAllLivePathPoses, endLivePathPose } from './tokenPathLiveDom'; /** * RAF poses for control/presentation while path playback is active. * Writes transforms on registered token DOM nodes — no React setState per frame. * * 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; }): void { const { tokens, npcTokens, pathSession, enabled = true, onMarkDone } = args; const markedDoneRef = useRef>(new Set()); const liveKeysRef = 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) { endAllLivePathPoses(); liveKeysRef.current.clear(); return; } let raf = 0; let alive = true; const tick = () => { if (!alive) return; const session = pathSessionRef.current; const nextKeys = new Set(); if (session) { const sampleNow = Date.now(); const sampleOne = ( kind: 'token' | 'npcToken', placementId: string, path: NonNullable, ) => { 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; nextKeys.add(key); applyLivePathPose( key, result.sample.nx, result.sample.ny, result.sample.rotationDeg, entry.phase === 'moving' || entry.phase === 'delay', ); 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); } } for (const t of npcTokensRef.current) { if (t.path && t.path.points.length >= 2) { sampleOne('npcToken', String(t.id), t.path); } } } for (const key of liveKeysRef.current) { if (!nextKeys.has(key)) endLivePathPose(key); } liveKeysRef.current = nextKeys; raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => { alive = false; cancelAnimationFrame(raf); for (const key of liveKeysRef.current) { endLivePathPose(key); } liveKeysRef.current.clear(); }; }, [enabled]); }