Files
Ivan Fontosh 1ab6ffd593 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>
2026-08-14 08:48:41 +08:00

93 lines
2.9 KiB
TypeScript

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>
);
}