feat(tokens): animated paths for scene and NPC tokens

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>
This commit is contained in:
Ivan Fontosh
2026-08-14 08:48:41 +08:00
parent 46bec1a86a
commit 1ab6ffd593
32 changed files with 2933 additions and 28 deletions
@@ -26,6 +26,11 @@
cursor: grabbing;
}
.tokenInteractive {
pointer-events: auto;
cursor: context-menu;
}
.tokenImg {
width: 100%;
height: 100%;
@@ -7,6 +7,12 @@ import styles from './SceneTokensOverlay.module.css';
type Viewport = { x: number; y: number; w: number; h: number };
export type TokenPoseOverride = {
nx: number;
ny: number;
rotationDeg?: number;
};
type Props = {
placements: readonly SceneToken[];
library: readonly AppToken[];
@@ -16,24 +22,35 @@ type Props = {
onMove?: (placementId: string, nx: number, ny: number) => void;
/** Snap во время drag (пульт, привязка к сетке). */
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
onContextMenu?: (e: React.MouseEvent, placement: SceneToken) => void;
/** Live path playback / other pose overrides by placement id. */
poseOverrides?: Record<string, TokenPoseOverride> | null;
/** Per-placement drag lock (e.g. while path is animating). Default: all editable. */
dragEnabledById?: Record<string, boolean> | null;
};
function TokenSprite({
placement,
nx,
ny,
rotationDeg,
viewport,
editable,
dragEnabled,
onMove,
snapNorm,
onContextMenu,
}: {
placement: SceneToken;
nx: number;
ny: number;
rotationDeg: number;
viewport: Viewport;
editable: boolean;
dragEnabled: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
onContextMenu?: (e: React.MouseEvent, placement: SceneToken) => void;
}) {
const url = useTokenImageUrl(placement.tokenId);
const dragRef = useRef<{
@@ -66,6 +83,8 @@ function TokenSprite({
const sizePx = Math.max(16, placement.sizeN * minDim);
const left = viewport.x + posNx * viewport.w;
const top = viewport.y + posNy * viewport.h;
const canDrag = editable && dragEnabled && Boolean(onMove);
const interactive = canDrag || Boolean(onContextMenu);
const hostToNorm = (clientX: number, clientY: number, host: HTMLElement) => {
const r = host.getBoundingClientRect();
@@ -85,7 +104,6 @@ function TokenSprite({
cancelAnimationFrame(frameRef.current);
frameRef.current = 0;
}
// Финальный commit в session store — один раз на отпускание.
onMove?.(String(placement.id), d.lastNx, d.lastNy);
setLocalPos({ nx: d.lastNx, ny: d.lastNy });
try {
@@ -97,16 +115,31 @@ function TokenSprite({
return (
<div
className={[styles.token, editable ? styles.tokenEditable : ''].filter(Boolean).join(' ')}
className={[
styles.token,
canDrag ? styles.tokenEditable : '',
interactive && !canDrag ? styles.tokenInteractive : '',
]
.filter(Boolean)
.join(' ')}
style={{
left,
top,
width: sizePx,
height: sizePx,
transform: `translate(-50%, -50%) rotate(${String(placement.rotationDeg)}deg)`,
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
}}
onContextMenu={
onContextMenu
? (e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, placement);
}
: undefined
}
onPointerDown={
editable && onMove
canDrag
? (e) => {
if (e.button !== 0) return;
e.stopPropagation();
@@ -128,7 +161,7 @@ function TokenSprite({
: undefined
}
onPointerMove={
editable && onMove
canDrag
? (e) => {
const d = dragRef.current;
if (!d || d.pointerId !== e.pointerId) return;
@@ -169,6 +202,9 @@ export function SceneTokensOverlay({
editable = false,
onMove,
snapNorm,
onContextMenu,
poseOverrides = null,
dragEnabledById = null,
}: Props) {
if (!viewport || placements.length === 0) return null;
const known = new Set(library.map((t) => t.id));
@@ -179,19 +215,25 @@ export function SceneTokensOverlay({
.filter((p) => known.has(p.tokenId))
.map((placement) => {
const key = String(placement.id);
const pose = poseOverrides?.[key];
const override = session?.byPlacementId[key] ?? session?.byPlacementId[placement.id];
const nx = override?.nx ?? placement.nx;
const ny = override?.ny ?? placement.ny;
const nx = pose?.nx ?? override?.nx ?? placement.nx;
const ny = pose?.ny ?? override?.ny ?? placement.ny;
const rotationDeg = pose?.rotationDeg ?? placement.rotationDeg;
const dragEnabled = dragEnabledById ? Boolean(dragEnabledById[key]) : true;
return (
<TokenSprite
key={key}
placement={placement}
nx={nx}
ny={ny}
rotationDeg={rotationDeg}
viewport={viewport}
editable={editable}
dragEnabled={dragEnabled}
{...(onMove ? { onMove } : {})}
{...(snapNorm ? { snapNorm } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
/>
);
})}
@@ -0,0 +1,42 @@
.layer {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 7;
overflow: visible;
}
.lineGlow {
stroke: rgba(40, 180, 255, 0.28);
stroke-width: 6;
stroke-linecap: round;
stroke-linejoin: round;
}
.line {
stroke: rgba(90, 210, 255, 0.92);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 7 5;
}
.start {
fill: rgba(120, 230, 255, 0.95);
stroke: rgba(0, 0, 0, 0.45);
stroke-width: 1;
}
.closed {
stroke: rgba(255, 200, 80, 0.9);
stroke-width: 1.5;
}
.emphasized .line {
stroke: rgba(255, 220, 120, 0.95);
stroke-dasharray: none;
}
.emphasized .lineGlow {
stroke: rgba(255, 200, 80, 0.35);
}
@@ -0,0 +1,92 @@
import React from 'react';
import type { TokenPath } from '../../../shared/types/tokenPath';
import { tokenPathPolyline } from '../../../shared/types/tokenPath';
import type { SceneNpcToken, SceneToken } from '../../../shared/types';
import { tokenPathKey, type TokenPathSessionState } from '../../../shared/types/tokenPathSession';
import styles from './TokenPathsOverlay.module.css';
type Viewport = { x: number; y: number; w: number; h: number };
type PathItem = {
key: string;
path: TokenPath;
emphasized?: boolean;
};
type Props = {
tokens: readonly SceneToken[];
npcTokens?: readonly SceneNpcToken[];
viewport: Viewport | null;
/** control/editor: always; presentation: only presentationVisible */
mode: 'always' | 'presentation';
pathSession?: TokenPathSessionState | null;
/** Highlight path currently edited */
emphasizeKey?: string | null;
};
function toSvgPoints(path: TokenPath, viewport: Viewport): string {
const pts = tokenPathPolyline(path);
return pts
.map((p) => {
const x = viewport.x + p.nx * viewport.w;
const y = viewport.y + p.ny * viewport.h;
return `${x},${y}`;
})
.join(' ');
}
export function TokenPathsOverlay({
tokens,
npcTokens = [],
viewport,
mode,
pathSession = null,
emphasizeKey = null,
}: Props) {
if (!viewport) return null;
const items: PathItem[] = [];
for (const t of tokens) {
if (!t.path || t.path.points.length < 2) continue;
const key = tokenPathKey('token', String(t.id));
if (mode === 'presentation' && !pathSession?.presentationVisible[key]) continue;
items.push({ key, path: t.path, emphasized: emphasizeKey === key });
}
for (const t of npcTokens) {
if (!t.path || t.path.points.length < 2) continue;
const key = tokenPathKey('npcToken', String(t.id));
if (mode === 'presentation' && !pathSession?.presentationVisible[key]) continue;
items.push({ key, path: t.path, emphasized: emphasizeKey === key });
}
if (items.length === 0) return null;
return (
<svg className={styles.layer} width="100%" height="100%" aria-hidden>
{items.map((item) => {
const pts = toSvgPoints(item.path, viewport);
if (!pts) return null;
const first = item.path.points[0]!;
const fx = viewport.x + first.nx * viewport.w;
const fy = viewport.y + first.ny * viewport.h;
return (
<g key={item.key} className={item.emphasized ? styles.emphasized : undefined}>
<polyline className={styles.lineGlow} points={pts} fill="none" />
<polyline className={styles.line} points={pts} fill="none" />
<circle className={styles.start} cx={fx} cy={fy} r={4} />
{item.path.closed ? (
<circle
className={styles.closed}
cx={fx}
cy={fy}
r={7}
fill="none"
/>
) : null}
</g>
);
})}
</svg>
);
}
@@ -0,0 +1,155 @@
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;
}
@@ -0,0 +1,34 @@
import { useEffect, useMemo, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { TokenPathSessionEvent, TokenPathSessionState } from '../../../shared/types';
import { getDndApi } from '../dndApi';
export function useTokenPathSession(): [
TokenPathSessionState | null,
{ dispatch: (event: TokenPathSessionEvent) => Promise<void> },
] {
const api = getDndApi();
const [state, setState] = useState<TokenPathSessionState | null>(null);
useEffect(() => {
void api.invoke(ipcChannels.tokenPathSession.getState, {}).then(({ state: s }) => {
setState(s);
});
return api.on(ipcChannels.tokenPathSession.stateChanged, ({ state: s }) => {
setState(s);
});
}, [api]);
const apiWrap = useMemo(
() => ({
dispatch: async (event: TokenPathSessionEvent) => {
const res = await api.invoke(ipcChannels.tokenPathSession.dispatch, { event });
void res;
},
}),
[api],
);
return [state, apiWrap];
}