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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user