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

680 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}