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 -3
View File
@@ -1073,7 +1073,7 @@ export function ControlApp() {
/** Действия с токенами/ловушками только без активной кисти эффектов. */
const markersInteractive = tool.tool === 'none';
const pathLivePoses = useTokenPathLivePoses({
useTokenPathLivePoses({
tokens: currentScene?.tokens ?? [],
npcTokens: visibleNpcTokens,
pathSession: tokenPathSession,
@@ -2727,7 +2727,6 @@ export function ControlApp() {
viewport={previewContentRect}
editable={markersInteractive}
snapNorm={snapNormActive}
poseOverrides={pathLivePoses.tokenPoses}
dragEnabledById={tokenPathDragEnabled}
onMove={(placementId, nx, ny) => {
const snapped = snapNormActive(nx, ny);
@@ -2767,7 +2766,6 @@ export function ControlApp() {
grid={currentScene?.grid ?? null}
editable={markersInteractive}
snapNorm={snapNormActive}
poseOverrides={pathLivePoses.npcPoses}
dragEnabledById={npcPathDragEnabled}
onMove={(placementId, nx, ny) => {
const snapped = snapNormActive(nx, ny);
@@ -33,3 +33,15 @@ void test('ControlApp: draft кисти без корневого draftFxTick',
assert.ok(pixi.includes('mergeDraftState'));
assert.ok(pixi.includes('forwardRef'));
});
void test('ControlApp: path playback не гоняет setState корня', () => {
const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
const hook = fs.readFileSync(
path.join(here, '../shared/tokens/useTokenPathLivePoses.ts'),
'utf8',
);
assert.doesNotMatch(app, /\bpathLivePoses\b/);
assert.doesNotMatch(hook, /\bsetPoses\b/);
assert.ok(hook.includes('applyLivePathPose'));
assert.ok(hook.includes('requestAnimationFrame'));
});
+1 -3
View File
@@ -107,7 +107,7 @@ export function PresentationView({
sceneNpcTokensSession.spawned,
session?.currentSceneId,
);
const pathLivePoses = useTokenPathLivePoses({
useTokenPathLivePoses({
tokens: scene?.tokens ?? [],
npcTokens: visibleNpcTokens,
pathSession: tokenPathSession,
@@ -257,7 +257,6 @@ export function PresentationView({
library={appTokens}
session={sceneTokensSession}
viewport={contentRect}
poseOverrides={pathLivePoses.tokenPoses}
/>
) : null}
{USERS_BRANCH_FEATURES_ENABLED &&
@@ -269,7 +268,6 @@ export function PresentationView({
session={sceneNpcTokensSession}
viewport={contentRect}
grid={scene.grid}
poseOverrides={pathLivePoses.npcPoses}
/>
) : null}
{USERS_BRANCH_FEATURES_ENABLED &&
@@ -7,8 +7,9 @@
.token {
position: absolute;
left: 0;
top: 0;
overflow: visible;
transform: translate(-50%, -50%);
pointer-events: none;
touch-action: none;
}
@@ -1,4 +1,4 @@
import React, { useRef, useState } from 'react';
import React, { useLayoutEffect, useRef, useState } from 'react';
import type {
NpcDisposition,
@@ -16,6 +16,8 @@ import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid';
import { useAssetUrl } from '../useAssetImageUrl';
import { PlayerTokenView } from './PlayerTokenView';
import { tokenPathKey } from '../../../shared/types/tokenPathSession';
import { registerPathTokenNode, writeTokenPathTransform } from '../tokens/tokenPathLiveDom';
import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
import {
TokenInitiativeDecor,
@@ -100,6 +102,8 @@ function NpcSprite({
onResize,
String(placement.id),
);
const elRef = useRef<HTMLDivElement>(null);
const pathKey = tokenPathKey('npcToken', String(placement.id));
const pos = localPos ?? { nx, ny };
const shownSizeN = localSize ?? sizeN;
const minDim = Math.min(viewport.w, viewport.h);
@@ -107,6 +111,17 @@ function NpcSprite({
const canDrag = editable && dragEnabled && onMove !== undefined;
const canResize = editable && dragEnabled && onResize !== undefined;
useLayoutEffect(() => {
registerPathTokenNode(pathKey, elRef.current, viewport, false);
return () => registerPathTokenNode(pathKey, null, viewport, false);
}, [pathKey, viewport]);
useLayoutEffect(() => {
const el = elRef.current;
if (!el || el.dataset.pathLive === '1') return;
writeTokenPathTransform(el, pos.nx, pos.ny, 0, viewport, false);
}, [pos.nx, pos.ny, viewport]);
const point = (e: React.PointerEvent, host?: HTMLElement | null) => {
const rect = (host ?? e.currentTarget.parentElement)?.getBoundingClientRect();
return {
@@ -133,6 +148,7 @@ function NpcSprite({
return (
<div
ref={elRef}
className={[
styles.token,
canDrag ? styles.editable : '',
@@ -143,8 +159,6 @@ function NpcSprite({
data-testid={`session-npc-token-${placement.id}`}
data-initiative-active={initiativeActive ? '1' : '0'}
style={{
left: viewport.x + pos.nx * viewport.w,
top: viewport.y + pos.ny * viewport.h,
width: sizePx,
height: sizePx,
pointerEvents: canDrag || canResize || onContextMenu ? 'auto' : undefined,
@@ -8,6 +8,8 @@
.token {
position: absolute;
left: 0;
top: 0;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(0, 0, 0, 0.2);
@@ -1,9 +1,11 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { AppToken, SceneToken, SceneTokensSessionState } from '../../../shared/types';
import { clampSceneTokenSizeN } from '../../../shared/types/appTokens';
import { tokenPathKey } from '../../../shared/types/tokenPathSession';
import { useLiveResizeBroadcast } from '../playerToken/useLiveDragBroadcast';
import { registerPathTokenNode, writeTokenPathTransform } from './tokenPathLiveDom';
import { useTokenImageUrl } from './useTokenImageUrl';
import styles from './SceneTokensOverlay.module.css';
@@ -106,18 +108,29 @@ function TokenSprite({
};
}, []);
const elRef = useRef<HTMLDivElement>(null);
const posNx = localPos?.nx ?? nx;
const posNy = localPos?.ny ?? ny;
const shownSizeN = localSize ?? sizeN;
const pathKey = tokenPathKey('token', String(placement.id));
const minDim = Math.min(viewport.w, viewport.h);
const sizePx = Math.max(16, shownSizeN * minDim);
const left = viewport.x + posNx * viewport.w;
const top = viewport.y + posNy * viewport.h;
const canDrag = editable && dragEnabled && Boolean(onMove);
const canResize = editable && dragEnabled && Boolean(onResize);
const interactive = canDrag || canResize || Boolean(onContextMenu);
useLayoutEffect(() => {
registerPathTokenNode(pathKey, elRef.current, viewport, true);
return () => registerPathTokenNode(pathKey, null, viewport, true);
}, [pathKey, viewport]);
useLayoutEffect(() => {
const el = elRef.current;
if (!el || el.dataset.pathLive === '1') return;
writeTokenPathTransform(el, posNx, posNy, rotationDeg, viewport, true);
}, [posNx, posNy, rotationDeg, viewport]);
const hostToNorm = (clientX: number, clientY: number, host: HTMLElement) => {
const r = host.getBoundingClientRect();
const w = Math.max(1e-6, viewport.w);
@@ -160,6 +173,7 @@ function TokenSprite({
return (
<div
ref={elRef}
className={[
styles.token,
canDrag ? styles.tokenEditable : '',
@@ -168,11 +182,8 @@ function TokenSprite({
.filter(Boolean)
.join(' ')}
style={{
left,
top,
width: sizePx,
height: sizePx,
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
}}
onContextMenu={
onContextMenu
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { writeTokenPathTransform } from './tokenPathLiveDom';
void test('writeTokenPathTransform: GPU-friendly translate3d, not left/top', () => {
const el = { style: { transform: '' } } as unknown as HTMLElement;
writeTokenPathTransform(el, 0.25, 0.5, 45, { x: 10, y: 20, w: 200, h: 100 }, true);
assert.equal(el.style.transform, 'translate3d(60px, 70px, 0) translate(-50%, -50%) rotate(45deg)');
});
void test('writeTokenPathTransform: NPC without rotate keeps centering only', () => {
const el = { style: { transform: '' } } as unknown as HTMLElement;
writeTokenPathTransform(el, 0, 0, 90, { x: 0, y: 0, w: 100, h: 100 }, false);
assert.equal(el.style.transform, 'translate3d(0px, 0px, 0) translate(-50%, -50%)');
});
@@ -0,0 +1,65 @@
/** Direct DOM transforms for path playback — avoids React re-renders on every RAF tick. */
export type TokenPathViewport = { x: number; y: number; w: number; h: number };
type NodeReg = {
el: HTMLElement;
viewport: TokenPathViewport;
rotate: boolean;
};
const registry = new Map<string, NodeReg>();
export function writeTokenPathTransform(
el: HTMLElement,
nx: number,
ny: number,
rotationDeg: number,
viewport: TokenPathViewport,
rotate: boolean,
): void {
const x = viewport.x + nx * viewport.w;
const y = viewport.y + ny * viewport.h;
const rot = rotate ? ` rotate(${String(rotationDeg)}deg)` : '';
el.style.transform = `translate3d(${String(x)}px, ${String(y)}px, 0) translate(-50%, -50%)${rot}`;
}
export function registerPathTokenNode(
key: string,
el: HTMLElement | null,
viewport: TokenPathViewport,
rotate: boolean,
): void {
if (!el) {
registry.delete(key);
return;
}
registry.set(key, { el, viewport, rotate });
}
export function applyLivePathPose(
key: string,
nx: number,
ny: number,
rotationDeg: number,
animating: boolean,
): void {
const n = registry.get(key);
if (!n) return;
n.el.dataset.pathLive = '1';
n.el.style.willChange = animating ? 'transform' : '';
writeTokenPathTransform(n.el, nx, ny, rotationDeg, n.viewport, n.rotate);
}
export function endLivePathPose(key: string): void {
const n = registry.get(key);
if (!n) return;
delete n.el.dataset.pathLive;
n.el.style.willChange = '';
}
export function endAllLivePathPoses(): void {
for (const key of [...registry.keys()]) {
endLivePathPose(key);
}
}
@@ -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;
}