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
@@ -0,0 +1,183 @@
.page {
display: grid;
grid-template-columns: 280px 1fr;
height: 100vh;
width: 100vw;
overflow: hidden;
background: var(--bg, #12141a);
color: var(--text, #e8eaef);
}
.sidebar {
border-right: 1px solid var(--stroke, #2a2f3a);
padding: 12px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.title {
font-weight: 800;
font-size: 14px;
letter-spacing: 0.02em;
}
.hint {
font-size: 12px;
opacity: 0.7;
line-height: 1.4;
margin: 0;
}
.field {
display: grid;
gap: 4px;
font-size: 12px;
}
.actions {
display: grid;
gap: 8px;
margin-top: 4px;
width: 100%;
}
.actions > * {
width: 100%;
max-width: 100%;
display: flex;
box-sizing: border-box;
}
.actions button {
width: 100%;
box-sizing: border-box;
}
.meta {
font-size: 11px;
opacity: 0.65;
line-height: 1.35;
}
.main {
min-width: 0;
min-height: 0;
position: relative;
background: #0b0d12;
}
.host {
position: absolute;
inset: 0;
overflow: hidden;
cursor: crosshair;
touch-action: none;
user-select: none;
}
.empty {
display: grid;
place-items: center;
height: 100%;
padding: 24px;
text-align: center;
opacity: 0.75;
}
.pathSvg {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
}
.pathLine {
stroke: rgba(90, 210, 255, 0.95);
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 8 5;
}
.point {
position: absolute;
z-index: 4;
width: 22px;
height: 22px;
margin: 0;
padding: 0;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.55);
background: rgba(20, 90, 140, 0.92);
color: #fff;
font-size: 10px;
font-weight: 700;
transform: translate(-50%, -50%);
cursor: grab;
touch-action: none;
}
.point:active {
cursor: grabbing;
}
.tokenPreview {
position: absolute;
z-index: 3;
pointer-events: none;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.28);
background: rgba(0, 0, 0, 0.25);
}
.tokenPreview img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.ctxMenuBackdrop {
position: fixed;
inset: 0;
z-index: 40;
border: 0;
background: transparent;
cursor: default;
}
.ctxMenu {
position: fixed;
z-index: 41;
min-width: 160px;
padding: 4px;
border-radius: 8px;
border: 1px solid var(--stroke, #2a2f3a);
background: #1a1e27;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
display: grid;
}
.ctxItem,
.ctxItemDanger {
text-align: left;
border: 0;
background: transparent;
color: inherit;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.ctxItemDanger {
color: #ff8f8f;
}
.ctxItem:hover,
.ctxItemDanger:hover {
background: rgba(255, 255, 255, 0.06);
}
@@ -0,0 +1,679 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import type { SceneNpcToken, SceneToken, TokenPath, TokenPathPoint } from '../../shared/types';
import {
createEmptyTokenPath,
normalizeTokenPath,
reverseTokenPathPoints,
sampleTokenPathAtProgress,
tokenPathPolyline,
tryAppendPathPoint,
} from '../../shared/types/tokenPath';
import type { TokenPathTargetKind } from '../../shared/types/tokenPathSession';
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
import { getDndApi } from '../shared/dndApi';
import { ContainedVideo } from '../shared/ContainedVideo';
import { RotatedImage } from '../shared/RotatedImage';
import { useAppTokens } from '../shared/tokens/useAppTokens';
import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl';
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
import { normalizeNpcDisposition, npcDispositionRingColor } from '../../shared/types/npcDisposition';
import { sceneGridTokenFitFactor } from '../../shared/types/sceneGrid';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { Button, Input, Select } from '../shared/ui/controls';
import styles from './TokenPathEditorApp.module.css';
type Target = { kind: TokenPathTargetKind; placementId: string };
type Draft = TokenPath;
type PointMenu = { x: number; y: number; index: number };
function cloneDraft(path: TokenPath | null | undefined): Draft {
if (!path) return createEmptyTokenPath();
return {
...path,
points: path.points.map((p) => ({ ...p })),
};
}
function TokenPreview({
kind,
token,
npcToken,
npcName,
npcAvatarUrl,
ringColor,
imageOffset,
imageScale,
left,
top,
sizePx,
rotationDeg,
}: {
kind: TokenPathTargetKind;
token?: SceneToken;
npcToken?: SceneNpcToken;
npcName?: string;
npcAvatarUrl?: string | null;
ringColor?: string;
imageOffset?: { x: number; y: number };
imageScale?: number;
left: number;
top: number;
sizePx: number;
rotationDeg: number;
}) {
const url = useTokenImageUrl(token?.tokenId ?? null);
if (kind === 'token' && token) {
return (
<div
className={styles.tokenPreview}
style={{
left,
top,
width: sizePx,
height: sizePx,
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
}}
>
{url ? <img src={url} alt="" draggable={false} /> : null}
</div>
);
}
if (kind === 'npcToken' && npcToken) {
return (
<div className={styles.tokenPreview} style={{ left, top, width: sizePx, height: sizePx }}>
<PlayerTokenView
name={npcName ?? ''}
imageUrl={npcAvatarUrl ?? null}
ringColor={ringColor ?? '#888'}
sizePx={sizePx}
{...(imageOffset ? { imageOffset } : {})}
{...(typeof imageScale === 'number' ? { imageScale } : {})}
/>
</div>
);
}
return null;
}
export function TokenPathEditorApp() {
const api = getDndApi();
const appTokens = useAppTokens();
const [session, setSession] = useState<SessionState | null>(null);
const [target, setTarget] = useState<Target | null>(null);
const [draft, setDraft] = useState<Draft>(createEmptyTokenPath());
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(
null,
);
const [pointMenu, setPointMenu] = useState<PointMenu | null>(null);
const [previewPlaying, setPreviewPlaying] = useState(false);
const [previewU, setPreviewU] = useState(0);
const [dirty, setDirty] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const hostRef = useRef<HTMLDivElement | null>(null);
const dragPointRef = useRef<{ index: number; pointerId: number } | null>(null);
const saveTimerRef = useRef(0);
const draftRef = useRef(draft);
draftRef.current = draft;
const project = session?.project ?? null;
const sceneId = project?.currentSceneId ?? null;
const scene = sceneId && project ? project.scenes[sceneId] : undefined;
const url = useAssetUrl(scene?.previewAssetId ?? null);
const rot = scene?.previewRotationDeg ?? 0;
const isImage = scene?.previewAssetType === 'image';
const isVideo = scene?.previewAssetType === 'video';
const placement = useMemo(() => {
if (!target || !scene) return null;
if (target.kind === 'token') {
return (scene.tokens ?? []).find((t) => String(t.id) === target.placementId) ?? null;
}
return (scene.npcTokens ?? []).find((t) => String(t.id) === target.placementId) ?? null;
}, [scene, target]);
const npcMeta = useMemo(() => {
if (!target || target.kind !== 'npcToken' || !placement || !('npcId' in placement)) return null;
const npc = project?.npcs.find((n) => n.id === placement.npcId);
return npc ?? null;
}, [placement, project?.npcs, target]);
const npcAvatarUrl = useAssetUrl(npcMeta?.avatarAssetId ?? null);
useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => {
setSession({ project: p, currentSceneId: p?.currentSceneId ?? null });
});
return api.on(ipcChannels.session.stateChanged, ({ state }) => setSession(state));
}, [api]);
useEffect(() => {
void api.invoke(ipcChannels.windows.getTokenPathEditorTarget, {}).then((t) => {
setTarget(t);
});
return api.on(ipcChannels.windows.tokenPathEditorTargetChanged, (t) => {
setTarget(t);
});
}, [api]);
useEffect(() => {
if (!placement) {
setDraft(createEmptyTokenPath());
setDirty(false);
return;
}
setDraft(cloneDraft(placement.path ?? null));
setDirty(false);
setPreviewPlaying(false);
setPreviewU(0);
setStatus(null);
}, [placement?.id, target?.kind, target?.placementId]);
const persistDraft = useCallback(
(next: Draft, immediate = false) => {
if (!sceneId || !target) return;
const normalized = normalizeTokenPath(next);
const run = () => {
if (target.kind === 'token') {
const tokens = (scene?.tokens ?? []).map((t) =>
String(t.id) === target.placementId ? { ...t, path: normalized } : t,
);
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { tokens } });
} else {
const npcTokens = (scene?.npcTokens ?? []).map((t) =>
String(t.id) === target.placementId ? { ...t, path: normalized } : t,
);
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { npcTokens } });
}
setDirty(false);
setStatus(normalized ? 'Сохранено' : 'Путь очищен (нужно ≥2 точки)');
};
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
if (immediate) {
run();
return;
}
saveTimerRef.current = window.setTimeout(run, 180);
},
[api, scene?.npcTokens, scene?.tokens, sceneId, target],
);
const updateDraft = useCallback(
(updater: (prev: Draft) => Draft, opts?: { save?: boolean; immediate?: boolean }) => {
setDraft((prev) => {
const next = updater(prev);
draftRef.current = next;
if (opts?.save !== false) {
setDirty(true);
persistDraft(next, opts?.immediate);
}
return next;
});
},
[persistDraft],
);
useEffect(() => {
if (!previewPlaying) return;
const started = performance.now();
const durationMs = Math.max(0.5, draft.durationSec) * 1000;
const loopMode = draft.loopMode;
const closed = draft.closed;
let raf = 0;
let stopped = false;
const tick = (now: number) => {
if (stopped) return;
const elapsed = Math.max(0, now - started);
let u = 0;
if (loopMode === 'pingpong') {
const period = Math.max(durationMs * 2, 1e-9);
let t = elapsed % period;
if (t > durationMs) t = period - t;
u = t / durationMs;
} else if (loopMode === 'loop' && closed) {
u = (elapsed % durationMs) / durationMs;
} else {
// once (и loop без замыкания)
u = Math.min(1, elapsed / durationMs);
setPreviewU(u);
if (u >= 1) {
stopped = true;
setPreviewPlaying(false);
return;
}
raf = requestAnimationFrame(tick);
return;
}
setPreviewU(u);
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
stopped = true;
cancelAnimationFrame(raf);
};
}, [previewPlaying, draft.durationSec, draft.points, draft.closed, draft.loopMode]);
const hostToNorm = useCallback(
(clientX: number, clientY: number) => {
const host = hostRef.current;
if (!host || !contentRect) return null;
const r = host.getBoundingClientRect();
return {
nx: Math.max(0, Math.min(1, (clientX - (r.left + contentRect.x)) / Math.max(1e-6, contentRect.w))),
ny: Math.max(0, Math.min(1, (clientY - (r.top + contentRect.y)) / Math.max(1e-6, contentRect.h))),
};
},
[contentRect],
);
const previewSample = useMemo(() => {
if (!previewPlaying || draft.points.length < 2) return null;
const normalized = normalizeTokenPath(draft);
if (!normalized) return null;
return sampleTokenPathAtProgress(normalized, previewU);
}, [draft, previewPlaying, previewU]);
const tokenLeftTop = useMemo(() => {
if (!contentRect || !placement) return null;
const nx = previewSample?.nx ?? placement.nx;
const ny = previewSample?.ny ?? placement.ny;
const minDim = Math.min(contentRect.w, contentRect.h);
const sizeN =
target?.kind === 'npcToken' && 'sizeN' in placement
? placement.sizeN * sceneGridTokenFitFactor(scene?.grid ?? null)
: placement.sizeN;
const sizePx = Math.max(16, sizeN * minDim);
return {
left: contentRect.x + nx * contentRect.w,
top: contentRect.y + ny * contentRect.h,
sizePx,
rotationDeg:
previewSample?.rotationDeg ??
(target?.kind === 'token' && 'rotationDeg' in placement ? placement.rotationDeg : 0),
};
}, [contentRect, placement, previewSample, scene?.grid, target?.kind]);
const title = useMemo(() => {
if (!target) return 'Движение токена';
if (target.kind === 'token') {
const tok = placement && 'tokenId' in placement ? appTokens.find((a) => a.id === placement.tokenId) : null;
return tok ? `Движение: ${tok.name}` : 'Движение токена';
}
return npcMeta ? `Движение: ${npcMeta.name}` : 'Движение НПС';
}, [appTokens, npcMeta, placement, target]);
if (!USERS_BRANCH_FEATURES_ENABLED && target?.kind === 'npcToken') {
return (
<div className={styles.page}>
<div className={styles.empty}>НПС недоступны в этой сборке.</div>
</div>
);
}
return (
<div className={styles.page}>
<aside className={styles.sidebar}>
<div className={styles.title}>{title}</div>
<p className={styles.hint}>
ЛКМ по карте добавить точку. Перетаскивайте точки для правки. ПКМ по точке замкнуть или удалить.
</p>
<label className={styles.field}>
<span>Длительность, сек</span>
<Input
value={String(draft.durationSec)}
onChange={(v) => {
const n = Number(v);
updateDraft((d) => ({
...d,
durationSec: Number.isFinite(n) ? n : d.durationSec,
}));
}}
/>
</label>
<label className={styles.field}>
<span>Режим цикла</span>
<Select
value={draft.loopMode}
options={[
{ value: 'once', label: 'Один раз' },
{ value: 'pingpong', label: 'Туда-обратно' },
{
value: 'loop',
label: draft.closed ? 'Зациклить' : 'Зациклить (нужно замкнуть)',
disabled: !draft.closed,
},
]}
onChange={(v) => {
updateDraft((d) => ({
...d,
loopMode: v === 'loop' && !d.closed ? 'once' : (v as Draft['loopMode']),
}));
}}
/>
</label>
<label className={styles.field}>
<span>Старт</span>
<Select
value={draft.startMode}
options={[
{ value: 'onEnter', label: 'При входе в сцену' },
{ value: 'delayed', label: 'С задержкой' },
]}
onChange={(v) => {
updateDraft((d) => ({
...d,
startMode: v === 'delayed' ? 'delayed' : 'onEnter',
}));
}}
/>
</label>
{draft.startMode === 'delayed' ? (
<label className={styles.field}>
<span>Задержка, сек</span>
<Input
value={String(draft.delaySec)}
onChange={(v) => {
const n = Number(v);
updateDraft((d) => ({
...d,
delaySec: Number.isFinite(n) ? n : d.delaySec,
}));
}}
/>
</label>
) : null}
<label className={styles.field}>
<span>Ориентация</span>
<Select
value={draft.facingMode}
options={[
{ value: 'tangentSmooth', label: 'По касательной' },
{ value: 'fixed', label: 'Фиксированный угол' },
]}
onChange={(v) => {
updateDraft((d) => ({
...d,
facingMode: v === 'fixed' ? 'fixed' : 'tangentSmooth',
}));
}}
/>
</label>
{draft.facingMode === 'fixed' ? (
<label className={styles.field}>
<span>Угол, °</span>
<Input
value={String(draft.fixedRotationDeg)}
onChange={(v) => {
const n = Number(v);
updateDraft((d) => ({
...d,
fixedRotationDeg: Number.isFinite(n) ? n : d.fixedRotationDeg,
}));
}}
/>
</label>
) : null}
<div className={styles.actions}>
<Button
onClick={() => {
updateDraft((d) => ({
...d,
points: reverseTokenPathPoints(d.points),
}));
}}
disabled={draft.points.length < 2}
>
Обратить путь
</Button>
<Button
onClick={() => {
setPreviewPlaying((p) => !p);
setPreviewU(0);
}}
disabled={draft.points.length < 2}
>
{previewPlaying ? 'Стоп превью' : 'Превью'}
</Button>
<Button
onClick={() => {
updateDraft(() => createEmptyTokenPath(), { immediate: true });
setPreviewPlaying(false);
}}
>
Очистить путь
</Button>
<Button
onClick={() => {
persistDraft(draftRef.current, true);
}}
>
Сохранить
</Button>
</div>
<div className={styles.meta}>
Точек: {draft.points.length}
{draft.closed ? ' · замкнут' : ''}
{dirty ? ' · есть изменения' : ''}
{status ? ` · ${status}` : ''}
</div>
</aside>
<main className={styles.main}>
{!scene || (!isImage && !isVideo) || !url ? (
<div className={styles.empty}>Нет карты сцены для редактирования пути.</div>
) : !target || !placement ? (
<div className={styles.empty}>Выберите токен в редакторе сцены: ПКМ «Указать движение».</div>
) : (
<div
ref={hostRef}
className={styles.host}
onContextMenu={(e) => e.preventDefault()}
onPointerDown={(e) => {
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest('[data-path-point]')) return;
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
updateDraft((d) => {
const next = tryAppendPathPoint(d.points, p);
if (!next) return d;
return { ...d, points: next, closed: false, loopMode: d.loopMode === 'loop' ? 'once' : d.loopMode };
});
}}
onPointerMove={(e) => {
const drag = dragPointRef.current;
if (!drag || drag.pointerId !== e.pointerId) return;
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
updateDraft((d) => {
const points = d.points.map((pt, i) => (i === drag.index ? p : pt));
return { ...d, points };
});
}}
onPointerUp={(e) => {
if (dragPointRef.current?.pointerId === e.pointerId) {
dragPointRef.current = null;
persistDraft(draftRef.current, true);
}
}}
onPointerCancel={(e) => {
if (dragPointRef.current?.pointerId === e.pointerId) {
dragPointRef.current = null;
}
}}
>
{isImage ? (
<RotatedImage url={url} rotationDeg={rot} mode="contain" onContentRectChange={setContentRect} />
) : (
<ContainedVideo
url={url}
rotationDeg={rot}
muted
playsInline
loop
preload="metadata"
onContentRectChange={setContentRect}
/>
)}
{contentRect ? (
<svg className={styles.pathSvg} width="100%" height="100%" aria-hidden>
{draft.points.length >= 2 ? (
<polyline
className={styles.pathLine}
fill="none"
points={tokenPathPolyline(draft)
.map((p) => {
const x = contentRect.x + p.nx * contentRect.w;
const y = contentRect.y + p.ny * contentRect.h;
return `${x},${y}`;
})
.join(' ')}
/>
) : null}
</svg>
) : null}
{contentRect
? draft.points.map((p: TokenPathPoint, index: number) => {
const left = contentRect.x + p.nx * contentRect.w;
const top = contentRect.y + p.ny * contentRect.h;
return (
<button
key={`${index}_${p.nx}_${p.ny}`}
type="button"
data-path-point
className={styles.point}
style={{ left, top }}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
(e.currentTarget as HTMLButtonElement).setPointerCapture(e.pointerId);
dragPointRef.current = { index, pointerId: e.pointerId };
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
setPointMenu({ x: e.clientX, y: e.clientY, index });
}}
>
{index + 1}
</button>
);
})
: null}
{contentRect && tokenLeftTop && target ? (
<TokenPreview
kind={target.kind}
left={tokenLeftTop.left}
top={tokenLeftTop.top}
sizePx={tokenLeftTop.sizePx}
rotationDeg={tokenLeftTop.rotationDeg}
{...(target.kind === 'token' && placement && 'tokenId' in placement
? { token: placement as SceneToken }
: {})}
{...(target.kind === 'npcToken' && placement && 'npcId' in placement
? { npcToken: placement as SceneNpcToken }
: {})}
{...(npcMeta?.name ? { npcName: npcMeta.name } : {})}
{...(npcAvatarUrl ? { npcAvatarUrl } : {})}
{...(npcMeta
? {
ringColor: npcDispositionRingColor(
normalizeNpcDisposition(
(placement && 'disposition' in placement ? placement.disposition : undefined) ??
npcMeta.disposition,
),
),
}
: {})}
{...(npcMeta?.imageOffset ? { imageOffset: npcMeta.imageOffset } : {})}
{...(typeof npcMeta?.imageScale === 'number'
? { imageScale: npcMeta.imageScale }
: {})}
/>
) : null}
</div>
)}
</main>
{pointMenu
? createPortal(
<>
<button
type="button"
className={styles.ctxMenuBackdrop}
aria-label="Закрыть"
onClick={() => setPointMenu(null)}
/>
<div className={styles.ctxMenu} style={{ left: pointMenu.x, top: pointMenu.y }} role="menu">
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
updateDraft((d) => ({
...d,
closed: true,
loopMode: d.loopMode === 'once' ? d.loopMode : d.loopMode,
}));
setPointMenu(null);
}}
>
Замкнуть путь
</button>
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
updateDraft((d) => ({
...d,
closed: false,
loopMode: d.loopMode === 'loop' ? 'once' : d.loopMode,
}));
setPointMenu(null);
}}
>
Разомкнуть
</button>
<button
type="button"
className={styles.ctxItemDanger}
role="menuitem"
onClick={() => {
const idx = pointMenu.index;
updateDraft((d) => {
const points = d.points.filter((_, i) => i !== idx);
return {
...d,
points,
closed: points.length >= 2 ? d.closed : false,
loopMode: points.length >= 2 && d.closed ? d.loopMode : d.loopMode === 'loop' ? 'once' : d.loopMode,
};
});
setPointMenu(null);
}}
>
Удалить точку
</button>
</div>
</>,
document.body,
)
: null}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { TokenPathEditorApp } from './TokenPathEditorApp';
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Missing #root element');
}
createRoot(rootEl).render(
<React.StrictMode>
<WindowErrorBoundary title="Движение токена">
<EditorI18nProvider>
<TokenPathEditorApp />
</EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>,
);