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