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
+130 -2
View File
@@ -54,10 +54,12 @@ import { ContainedVideo } from '../shared/ContainedVideo';
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
import { RotatedImage } from '../shared/RotatedImage';
import { TokenPathsOverlay } from '../shared/tokens/TokenPathsOverlay';
import { useAppTokens } from '../shared/tokens/useAppTokens';
import { TrapGlyph } from '../shared/traps/TrapGlyph';
import { Button, Input, Select } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { sampleTokenPathAtDistance } from '../../shared/types/tokenPath';
import styles from './SceneEditorApp.module.css';
import { SceneTokenMarker } from './SceneTokenMarker';
@@ -235,6 +237,11 @@ export function SceneEditorApp() {
y: number;
placementId: string;
} | null>(null);
const [tokenCtxMenu, setTokenCtxMenu] = useState<{
x: number;
y: number;
placementId: string;
} | null>(null);
const [selected, setSelected] = useState<Selection>(null);
const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 });
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
@@ -832,6 +839,12 @@ export function SceneEditorApp() {
/>
)}
<SceneGridOverlay grid={localGrid} viewport={contentRect} />
<TokenPathsOverlay
tokens={localTokens}
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? localNpcTokens : []}
viewport={contentRect}
mode="always"
/>
{contentRect
? localTokens.map((tok) => {
const minDim = Math.min(contentRect.w, contentRect.h);
@@ -850,8 +863,8 @@ export function SceneEditorApp() {
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
persistTokens(tokensRef.current.filter((t) => t.id !== tok.id));
setSelected((cur) => (cur?.kind === 'token' && cur.id === tok.id ? null : cur));
setSelected({ kind: 'token', id: tok.id });
setTokenCtxMenu({ x: e.clientX, y: e.clientY, placementId: tok.id });
}}
onMovePointerDown={(e) => {
if (spaceDownRef.current) return;
@@ -1069,6 +1082,84 @@ export function SceneEditorApp() {
)
: null}
{tokenCtxMenu
? createPortal(
<>
<button
type="button"
className={styles.ctxMenuBackdrop}
aria-label={t('common.close')}
onClick={() => setTokenCtxMenu(null)}
/>
<div
className={styles.ctxMenu}
style={{ left: tokenCtxMenu.x, top: tokenCtxMenu.y }}
role="menu"
>
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
const id = tokenCtxMenu.placementId;
setTokenCtxMenu(null);
void api
.invoke(ipcChannels.windows.openTokenPathEditor, {
kind: 'token',
placementId: id,
})
.catch((err) => console.error('[sceneEditor] openTokenPathEditor', err));
}}
>
Указать движение
</button>
{(() => {
const tok = localTokens.find((item) => item.id === tokenCtxMenu.placementId);
if (!tok?.path || tok.path.points.length < 2) return null;
return (
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
const sample = sampleTokenPathAtDistance(tok.path!, 0);
if (sample) {
updateToken(tok.id, {
nx: sample.nx,
ny: sample.ny,
...(tok.path?.facingMode === 'fixed'
? { rotationDeg: tok.path.fixedRotationDeg }
: { rotationDeg: sample.rotationDeg }),
});
}
setTokenCtxMenu(null);
}}
>
Сбросить на старт пути
</button>
);
})()}
<button
type="button"
className={styles.ctxItemDanger}
role="menuitem"
onClick={() => {
const id = tokenCtxMenu.placementId;
setTokenCtxMenu(null);
persistTokens(tokensRef.current.filter((item) => item.id !== id));
setSelected((current) =>
current?.kind === 'token' && current.id === id ? null : current,
);
}}
>
{t('common.delete')}
</button>
</div>
</>,
document.body,
)
: null}
{USERS_BRANCH_FEATURES_ENABLED && npcCtxMenu
? createPortal(
<>
@@ -1083,6 +1174,43 @@ export function SceneEditorApp() {
style={{ left: npcCtxMenu.x, top: npcCtxMenu.y }}
role="menu"
>
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
const id = npcCtxMenu.placementId;
setNpcCtxMenu(null);
void api
.invoke(ipcChannels.windows.openTokenPathEditor, {
kind: 'npcToken',
placementId: id,
})
.catch((err) => console.error('[sceneEditor] openTokenPathEditor', err));
}}
>
Указать движение
</button>
{(() => {
const tok = localNpcTokens.find((item) => item.id === npcCtxMenu.placementId);
if (!tok?.path || tok.path.points.length < 2) return null;
return (
<button
type="button"
className={styles.ctxItem}
role="menuitem"
onClick={() => {
const sample = sampleTokenPathAtDistance(tok.path!, 0);
if (sample) {
updateNpcToken(tok.id, { nx: sample.nx, ny: sample.ny });
}
setNpcCtxMenu(null);
}}
>
Сбросить на старт пути
</button>
);
})()}
<button
type="button"
className={styles.ctxItemDanger}