fix(tokens): импорт неигровых токенов из zip и плавные пути на macOS

Полный импорт/открытие проекта подхватывает app-tokens. Движение по путям пишется в DOM без ре-рендера кадра, у пульта и презентации выключен background throttling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-07 16:16:30 +08:00
parent 586166cacd
commit fdccc0deb5
16 changed files with 285 additions and 111 deletions
@@ -1,58 +1,17 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef } 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;
}
import { applyLivePathPose, endAllLivePathPoses, endLivePathPose } from './tokenPathLiveDom';
/**
* RAF poses for control/presentation while path playback is active.
* When phase === 'stopped', pose is omitted so session/placement (drag) wins.
* 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.
@@ -63,10 +22,10 @@ export function useTokenPathLivePoses(args: {
pathSession: TokenPathSessionState | null;
enabled?: boolean;
onMarkDone?: (kind: 'token' | 'npcToken', placementId: string, atDist: number) => void;
}): PoseMaps {
}): void {
const { tokens, npcTokens, pathSession, enabled = true, onMarkDone } = args;
const [poses, setPoses] = useState<PoseMaps>(EMPTY);
const markedDoneRef = useRef<Set<string>>(new Set());
const liveKeysRef = useRef<Set<string>>(new Set());
const onMarkDoneRef = useRef(onMarkDone);
onMarkDoneRef.current = onMarkDone;
@@ -83,7 +42,8 @@ export function useTokenPathLivePoses(args: {
useEffect(() => {
if (!enabled) {
setPoses(EMPTY);
endAllLivePathPoses();
liveKeysRef.current.clear();
return;
}
let raf = 0;
@@ -92,55 +52,51 @@ export function useTokenPathLivePoses(args: {
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;
}
const nextKeys = new Set<string>();
if (session) {
const sampleNow = Date.now();
// 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,
const sampleOne = (
kind: 'token' | 'npcToken',
placementId: string,
path: NonNullable<SceneToken['path']>,
) => {
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);
}
};
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 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, npcPoses);
for (const t of npcTokensRef.current) {
if (t.path && t.path.points.length >= 2) {
sampleOne('npcToken', String(t.id), t.path);
}
}
}
const next: PoseMaps = { tokenPoses, npcPoses };
setPoses((prev) => (posesEqual(prev, next) ? prev : next));
for (const key of liveKeysRef.current) {
if (!nextKeys.has(key)) endLivePathPose(key);
}
liveKeysRef.current = nextKeys;
raf = requestAnimationFrame(tick);
};
@@ -148,8 +104,10 @@ export function useTokenPathLivePoses(args: {
return () => {
alive = false;
cancelAnimationFrame(raf);
for (const key of liveKeysRef.current) {
endLivePathPose(key);
}
liveKeysRef.current.clear();
};
}, [enabled]);
return poses;
}