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
+351 -1
View File
@@ -56,9 +56,12 @@ import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost';
import { useSceneViewState } from '../shared/sceneView/useSceneViewState';
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
import { SceneTokensOverlay } from '../shared/tokens/SceneTokensOverlay';
import { TokenPathsOverlay } from '../shared/tokens/TokenPathsOverlay';
import { useAppTokens } from '../shared/tokens/useAppTokens';
import { useSceneTokensSession } from '../shared/tokens/useSceneTokensSession';
import { useTokenGridSnapSession } from '../shared/tokens/useTokenGridSnapSession';
import { useTokenPathLivePoses } from '../shared/tokens/useTokenPathLivePoses';
import { useTokenPathSession } from '../shared/tokens/useTokenPathSession';
import { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay';
import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState';
import { Button } from '../shared/ui/controls';
@@ -66,6 +69,9 @@ import { EllipsisText } from '../shared/ui/EllipsisText';
import ellipsisStyles from '../shared/ui/ellipsisText.module.css';
import { Surface } from '../shared/ui/Surface';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { resumeFromPose, computePathPlaybackSample } from '../../shared/types/tokenPathPlayback';
import { tokenPathKey } from '../../shared/types/tokenPathSession';
import { sampleTokenPathAtDistance, tokenPathTotalLength } from '../../shared/types/tokenPath';
import styles from './ControlApp.module.css';
import { ControlAudioCard } from './ControlAudioCard';
@@ -156,6 +162,7 @@ export function ControlApp() {
const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
const [sceneNpcTokensSession, sceneNpcTokensApi] = useSceneNpcTokensSession();
const [scenePlayerTokensSession, scenePlayerTokensApi] = useScenePlayerTokensSession();
const [tokenPathSession, tokenPathApi] = useTokenPathSession();
const [tokenGridSnap, tokenGridSnapApi] = useTokenGridSnapSession();
const { players: appPlayers } = useAppPlayers();
const [npcSessionCtxMenu, setNpcSessionCtxMenu] = useState<{
@@ -163,6 +170,11 @@ export function ControlApp() {
y: number;
placementId: string;
} | null>(null);
const [tokenSessionCtxMenu, setTokenSessionCtxMenu] = useState<{
x: number;
y: number;
placementId: string;
} | null>(null);
const [sceneView, sceneViewApi] = useSceneViewState();
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
@@ -991,8 +1003,63 @@ export function ControlApp() {
/** Действия с токенами/ловушками только без активной кисти эффектов. */
const markersInteractive = tool.tool === 'none';
const pathLivePoses = useTokenPathLivePoses({
tokens: currentScene?.tokens ?? [],
npcTokens: currentScene?.npcTokens ?? [],
pathSession: tokenPathSession,
enabled: Boolean(currentScene),
onMarkDone: (kind, placementId) => {
void tokenPathApi.dispatch({ kind: 'markDone', target: { kind, placementId } });
const placement =
kind === 'token'
? (currentScene?.tokens ?? []).find((t) => String(t.id) === placementId)
: (currentScene?.npcTokens ?? []).find((t) => String(t.id) === placementId);
if (!placement?.path) return;
const end = sampleTokenPathAtDistance(placement.path, tokenPathTotalLength(placement.path));
if (!end) return;
if (kind === 'token') {
void sceneTokensApi.dispatch({
kind: 'move',
placementId,
nx: end.nx,
ny: end.ny,
});
} else {
sceneNpcTokensApi.dispatch({
kind: 'move',
placementId,
nx: end.nx,
ny: end.ny,
});
}
},
});
const tokenPathDragEnabled = useMemo(() => {
const out: Record<string, boolean> = {};
for (const t of currentScene?.tokens ?? []) {
const key = String(t.id);
const entry = tokenPathSession?.playback[tokenPathKey('token', key)];
out[key] = !entry || entry.phase === 'stopped';
}
return out;
}, [currentScene?.tokens, tokenPathSession?.playback]);
const npcPathDragEnabled = useMemo(() => {
const out: Record<string, boolean> = {};
for (const t of currentScene?.npcTokens ?? []) {
const key = String(t.id);
const entry = tokenPathSession?.playback[tokenPathKey('npcToken', key)];
out[key] = !entry || entry.phase === 'stopped';
}
return out;
}, [currentScene?.npcTokens, tokenPathSession?.playback]);
useEffect(() => {
if (!markersInteractive) setNpcSessionCtxMenu(null);
if (!markersInteractive) {
setNpcSessionCtxMenu(null);
setTokenSessionCtxMenu(null);
}
}, [markersInteractive]);
toolRef.current = tool;
@@ -2139,6 +2206,15 @@ export function ControlApp() {
clearDraftFromPixi();
}}
/>
{previewContentRect ? (
<TokenPathsOverlay
tokens={currentScene?.tokens ?? []}
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? (currentScene?.npcTokens ?? []) : []}
viewport={previewContentRect}
mode="always"
pathSession={tokenPathSession}
/>
) : null}
{previewContentRect ? (
<SceneTokensOverlay
placements={currentScene?.tokens ?? []}
@@ -2147,6 +2223,8 @@ export function ControlApp() {
viewport={previewContentRect}
editable={markersInteractive}
snapNorm={snapNormActive}
poseOverrides={pathLivePoses.tokenPoses}
dragEnabledById={tokenPathDragEnabled}
onMove={(placementId, nx, ny) => {
const snapped = snapNormActive(nx, ny);
void sceneTokensApi.dispatch({
@@ -2156,6 +2234,20 @@ export function ControlApp() {
ny: snapped.ny,
});
}}
{...(markersInteractive
? {
onContextMenu: (
e: React.MouseEvent,
placement: { id: string },
) => {
setTokenSessionCtxMenu({
x: e.clientX,
y: e.clientY,
placementId: String(placement.id),
});
},
}
: {})}
/>
) : null}
{USERS_BRANCH_FEATURES_ENABLED && previewContentRect ? (
@@ -2167,6 +2259,8 @@ 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);
sceneNpcTokensApi.dispatch({
@@ -2660,6 +2754,151 @@ export function ControlApp() {
) : null}
</div>
{tokenSessionCtxMenu
? createPortal(
<>
<button
type="button"
className={styles.ctxMenuBackdrop}
aria-label={t('common.close')}
onClick={() => setTokenSessionCtxMenu(null)}
/>
<div
className={styles.ctxMenu}
style={{ left: tokenSessionCtxMenu.x, top: tokenSessionCtxMenu.y }}
role="menu"
>
{(() => {
const placement = (currentScene?.tokens ?? []).find(
(item) => String(item.id) === tokenSessionCtxMenu.placementId,
);
if (!placement?.path || placement.path.points.length < 2) {
return (
<div className={styles.ctxItem} style={{ opacity: 0.55, cursor: 'default' }}>
Нет пути движения
</div>
);
}
const path = placement.path;
const target = {
kind: 'token' as const,
placementId: tokenSessionCtxMenu.placementId,
};
const key = tokenPathKey(target.kind, target.placementId);
const entry = tokenPathSession?.playback[key];
const visible = Boolean(tokenPathSession?.presentationVisible[key]);
const override =
sceneTokensSession?.byPlacementId[tokenSessionCtxMenu.placementId];
return (
<>
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
void tokenPathApi.dispatch({
kind: visible ? 'hidePresentation' : 'showPresentation',
target,
});
setTokenSessionCtxMenu(null);
}}
>
{visible ? 'Скрыть путь' : 'Показать путь'}
</button>
{entry && entry.phase !== 'stopped' ? (
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
const sample = computePathPlaybackSample({
path,
entry,
nowMs: Date.now(),
});
if (sample) {
void sceneTokensApi.dispatch({
kind: 'move',
placementId: target.placementId,
nx: sample.sample.nx,
ny: sample.sample.ny,
});
}
void tokenPathApi.dispatch({
kind: 'stop',
target,
atDist: sample?.sample.dist ?? entry.baseDist,
});
setTokenSessionCtxMenu(null);
}}
>
Остановить движение
</button>
) : null}
{entry && entry.phase === 'stopped' ? (
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
const nx = override?.nx ?? placement.nx;
const ny = override?.ny ?? placement.ny;
const fromDist = resumeFromPose(path, nx, ny, entry.baseDist);
void tokenPathApi.dispatch({
kind: 'resume',
target,
nx,
ny,
fromDist,
});
setTokenSessionCtxMenu(null);
}}
>
Продолжить движение
</button>
) : null}
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
const len = tokenPathTotalLength(path);
void tokenPathApi.dispatch({
kind: 'seedPlayback',
entry: {
kind: 'token',
placementId: target.placementId,
phase: path.startMode === 'delayed' ? 'delay' : 'moving',
baseDist: 0,
direction: 1,
rejoinDist: null,
durationSec: path.durationSec,
pathLength: len,
},
});
const start = sampleTokenPathAtDistance(path, 0);
if (start) {
void sceneTokensApi.dispatch({
kind: 'move',
placementId: target.placementId,
nx: start.nx,
ny: start.ny,
});
}
setTokenSessionCtxMenu(null);
}}
>
Сбросить на старт пути
</button>
</>
);
})()}
</div>
</>,
document.body,
)
: null}
{USERS_BRANCH_FEATURES_ENABLED && npcSessionCtxMenu
? createPortal(
<>
@@ -2690,6 +2929,14 @@ export function ControlApp() {
override?.disposition,
);
const inactive = Boolean(override?.inactive);
const path = placement.path;
const pathTarget = {
kind: 'npcToken' as const,
placementId: npcSessionCtxMenu.placementId,
};
const pathKey = tokenPathKey(pathTarget.kind, pathTarget.placementId);
const pathEntry = tokenPathSession?.playback[pathKey];
const pathVisible = Boolean(tokenPathSession?.presentationVisible[pathKey]);
if (inactive) {
return (
<button
@@ -2711,6 +2958,109 @@ export function ControlApp() {
}
return (
<>
{path && path.points.length >= 2 ? (
<>
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
void tokenPathApi.dispatch({
kind: pathVisible ? 'hidePresentation' : 'showPresentation',
target: pathTarget,
});
setNpcSessionCtxMenu(null);
}}
>
{pathVisible ? 'Скрыть путь' : 'Показать путь'}
</button>
{pathEntry && pathEntry.phase !== 'stopped' ? (
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
const sample = computePathPlaybackSample({
path,
entry: pathEntry,
nowMs: Date.now(),
});
if (sample) {
sceneNpcTokensApi.dispatch({
kind: 'move',
placementId: pathTarget.placementId,
nx: sample.sample.nx,
ny: sample.sample.ny,
});
}
void tokenPathApi.dispatch({
kind: 'stop',
target: pathTarget,
atDist: sample?.sample.dist ?? pathEntry.baseDist,
});
setNpcSessionCtxMenu(null);
}}
>
Остановить движение
</button>
) : null}
{pathEntry && pathEntry.phase === 'stopped' ? (
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
const nx = override?.nx ?? placement.nx;
const ny = override?.ny ?? placement.ny;
const fromDist = resumeFromPose(path, nx, ny, pathEntry.baseDist);
void tokenPathApi.dispatch({
kind: 'resume',
target: pathTarget,
nx,
ny,
fromDist,
});
setNpcSessionCtxMenu(null);
}}
>
Продолжить движение
</button>
) : null}
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
const len = tokenPathTotalLength(path);
void tokenPathApi.dispatch({
kind: 'seedPlayback',
entry: {
kind: 'npcToken',
placementId: pathTarget.placementId,
phase: path.startMode === 'delayed' ? 'delay' : 'moving',
baseDist: 0,
direction: 1,
rejoinDist: null,
durationSec: path.durationSec,
pathLength: len,
},
});
const start = sampleTokenPathAtDistance(path, 0);
if (start) {
sceneNpcTokensApi.dispatch({
kind: 'move',
placementId: pathTarget.placementId,
nx: start.nx,
ny: start.ny,
});
}
setNpcSessionCtxMenu(null);
}}
>
Сбросить на старт пути
</button>
</>
) : null}
<button
type="button"
className={styles.ctxItem}