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}
+2 -2
View File
@@ -196,7 +196,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.tokens.title': 'Неигровые токены',
'help.section.tokens.body':
'Неигровые токены — картинки существ, предметов и маркеров, которые вы ставите на карту сцены. Библиотека токенов хранится в приложении на этом компьютере (не внутри файла проекта). На сцене сохраняется только расстановка: какой токен, где стоит, размер и поворот.\n\nВ редакторе сцены:\n\n1) Откройте «Редактор сцены» для сцены с картинкой или видео (см. «Редактор сцены»).\n\n2) Раскройте «Неигровые токены».\n\n3) «Добавить» — задайте уникальное название и изображение (кнопка выбора или перетаскивание файла).\n\n4) В поиске можно быстро найти токен по имени.\n\n5) Меню «⋮» у плитки — «Изменить» или «Удалить» (с подтверждением). Удаление из пула также убирает этот токен с текущей сцены.\n\n6) Перетащите плитку на карту, чтобы поставить токен. Выделите маркер: перетаскивание — сдвиг, уголок — размер, ручка поворота — угол. Delete / Backspace или ПКМ по маркеру — убрать с карты. «Очистить сцену» снимает все токены и ловушки со сцены.\n\nВо время сессии:\n\n1) На пульте в «Предпросмотр экрана» токены видны сразу (в отличие от ловушек их не нужно проявлять).\n\n2) Перетаскивайте токены левой кнопкой — новая позиция запоминается до конца текущей сессии, в том числе если вы возвращаетесь к сцене через «Сюжетную линию». При новом «Запустить» позиции снова берутся из редактора.\n\n3) Если на сцене включена сетка, на пульте можно отметить «Привязка токенов к сетке» — при перетаскивании токены встают по клеткам (см. «Генератор сетки»).\n\n4) На экране презентации токены только отображаются: клики и перетаскивание для игроков недоступны.\n\nПри экспорте и импорте сюжетных линий нужные файлы токенов упаковываются вместе с линией, чтобы на другом компьютере расстановка не «теряла» картинки.',
'Неигровые токены — картинки существ, предметов и маркеров, которые вы ставите на карту сцены. Библиотека токенов хранится в приложении на этом компьютере (не внутри файла проекта). На сцене сохраняется только расстановка: какой токен, где стоит, размер и поворот.\n\nВ редакторе сцены:\n\n1) Откройте «Редактор сцены» для сцены с картинкой или видео (см. «Редактор сцены»).\n\n2) Раскройте «Неигровые токены».\n\n3) «Добавить» — задайте уникальное название и изображение (кнопка выбора или перетаскивание файла).\n\n4) В поиске можно быстро найти токен по имени.\n\n5) Меню «⋮» у плитки — «Изменить» или «Удалить» (с подтверждением). Удаление из пула также убирает этот токен с текущей сцены.\n\n6) Перетащите плитку на карту, чтобы поставить токен. Выделите маркер: перетаскивание — сдвиг, уголок — размер, ручка поворота — угол. Delete / Backspace — убрать с карты. ПКМ по маркеру — меню: «Указать движение» / «Удалить» / «Сбросить на старт пути». «Очистить сцену» снимает все токены и ловушки со сцены.\n\nВо время сессии:\n\n1) На пульте в «Предпросмотр экрана» токены видны сразу (в отличие от ловушек их не нужно проявлять).\n\n2) Перетаскивайте токены левой кнопкой — новая позиция запоминается до конца текущей сессии, в том числе если вы возвращаетесь к сцене через «Сюжетную линию». При новом «Запустить» позиции снова берутся из редактора.\n\n3) Если на сцене включена сетка, на пульте можно отметить «Привязка токенов к сетке» — при перетаскивании токены встают по клеткам (см. «Генератор сетки»).\n\n4) На экране презентации токены только отображаются: клики и перетаскивание для игроков недоступны.\n\nПри экспорте и импорте сюжетных линий нужные файлы токенов упаковываются вместе с линией, чтобы на другом компьютере расстановка не «теряла» картинки.',
'help.section.campaignAudio.title': 'Аудио игры',
'help.section.campaignAudio.body':
@@ -816,7 +816,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.tokens.title': 'Non-player tokens',
'help.section.tokens.body':
'Non-player tokens are images of creatures, props, and markers you place on the scene map. The token library lives in the app on this computer (not inside the project file). The scene only stores placements: which token, where it stands, size, and rotation.\n\nIn the scene editor:\n\n1) Open Scene editor for an image or video scene (see Scene editor).\n\n2) Expand Non-player tokens.\n\n3) Add — enter a unique name and an image (choose file or drop one).\n\n4) Use search to find a token by name.\n\n5) The ⋮ menu on a tile opens Edit or Delete (with confirmation). Deleting from the library also removes that token from the current scene.\n\n6) Drag a tile onto the map to place it. Select a marker: drag to move, corner handle to resize, rotate handle to turn. Delete / Backspace or right-click the marker removes it from the map. Clear scene removes all tokens and traps from the scene.\n\nDuring a session:\n\n1) On the control panel Screen preview, tokens are visible right away (unlike traps, they do not need revealing).\n\n2) Drag tokens with the left button — the new position is kept until the current session ends, including when you return to the scene via Storyline. A new Run resets positions to what you set in the editor.\n\n3) If the scene has a grid, enable Snap tokens to grid on the control panel so dragged tokens snap to cells (see Grid generator).\n\n4) On the presentation screen tokens are display-only: players cannot click or drag them.\n\nWhen you export or import storylines, the needed token files are packed with the line so placements keep their images on another computer.',
'Non-player tokens are images of creatures, props, and markers you place on the scene map. The token library lives in the app on this computer (not inside the project file). The scene only stores placements: which token, where it stands, size, and rotation.\n\nIn the scene editor:\n\n1) Open Scene editor for an image or video scene (see Scene editor).\n\n2) Expand Non-player tokens.\n\n3) Add — enter a unique name and an image (choose file or drop one).\n\n4) Use search to find a token by name.\n\n5) The ⋮ menu on a tile opens Edit or Delete (with confirmation). Deleting from the library also removes that token from the current scene.\n\n6) Drag a tile onto the map to place it. Select a marker: drag to move, corner handle to resize, rotate handle to turn. Delete / Backspace removes it from the map. Right-click opens a menu: Set movement / Delete / Reset to path start. Clear scene removes all tokens and traps from the scene.\n\nDuring a session:\n\n1) On the control panel Screen preview, tokens are visible right away (unlike traps, they do not need revealing).\n\n2) Drag tokens with the left button — the new position is kept until the current session ends, including when you return to the scene via Storyline. A new Run resets positions to what you set in the editor.\n\n3) If the scene has a grid, enable Snap tokens to grid on the control panel so dragged tokens snap to cells (see Grid generator).\n\n4) On the presentation screen tokens are display-only: players cannot click or drag them.\n\nWhen you export or import storylines, the needed token files are packed with the line so placements keep their images on another computer.',
'help.section.campaignAudio.title': 'Game audio',
'help.section.campaignAudio.body':
+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}
+24
View File
@@ -24,8 +24,11 @@ import { useScenePlayerTokensSession } from './playerToken/useScenePlayerTokensS
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
import { useSceneViewState } from './sceneView/useSceneViewState';
import { SceneTokensOverlay } from './tokens/SceneTokensOverlay';
import { TokenPathsOverlay } from './tokens/TokenPathsOverlay';
import { useAppTokens } from './tokens/useAppTokens';
import { useSceneTokensSession } from './tokens/useSceneTokensSession';
import { useTokenPathLivePoses } from './tokens/useTokenPathLivePoses';
import { useTokenPathSession } from './tokens/useTokenPathSession';
import { SceneTrapsOverlay } from './traps/SceneTrapsOverlay';
import { useSceneTrapsState } from './traps/useSceneTrapsState';
import styles from './PresentationView.module.css';
@@ -61,6 +64,7 @@ export function PresentationView({
const [sceneTokensSession] = useSceneTokensSession();
const [sceneNpcTokensSession] = useSceneNpcTokensSession();
const [scenePlayerTokensSession] = useScenePlayerTokensSession();
const [tokenPathSession, tokenPathApi] = useTokenPathSession();
const [vp] = useVideoPlaybackState();
const videoElRef = useRef<HTMLVideoElement | null>(null);
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
@@ -69,6 +73,15 @@ export function PresentationView({
const scene =
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
const project = session?.project;
const pathLivePoses = useTokenPathLivePoses({
tokens: scene?.tokens ?? [],
npcTokens: scene?.npcTokens ?? [],
pathSession: tokenPathSession,
enabled: Boolean(scene),
onMarkDone: (kind, placementId) => {
void tokenPathApi.dispatch({ kind: 'markDone', target: { kind, placementId } });
},
});
const activeMaterialItems =
project && (materialsOverlay?.activeMaterialIds?.length ?? 0) > 0
? (materialsOverlay?.activeMaterialIds ?? [])
@@ -195,12 +208,22 @@ export function PresentationView({
<SceneGridOverlay grid={scene.grid} viewport={contentRect} />
) : null}
<div className={styles.vignette} />
{(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? (
<TokenPathsOverlay
tokens={scene.tokens ?? []}
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? (scene.npcTokens ?? []) : []}
viewport={contentRect}
mode="presentation"
pathSession={tokenPathSession}
/>
) : null}
{(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? (
<SceneTokensOverlay
placements={scene.tokens ?? []}
library={appTokens}
session={sceneTokensSession}
viewport={contentRect}
poseOverrides={pathLivePoses.tokenPoses}
/>
) : null}
{USERS_BRANCH_FEATURES_ENABLED &&
@@ -212,6 +235,7 @@ export function PresentationView({
session={sceneNpcTokensSession}
viewport={contentRect}
grid={scene.grid}
poseOverrides={pathLivePoses.npcPoses}
/>
) : null}
{USERS_BRANCH_FEATURES_ENABLED &&
@@ -37,6 +37,7 @@ function NpcSprite({
ny,
viewport,
editable,
dragEnabled,
displayScale,
gridFit,
disposition,
@@ -51,6 +52,7 @@ function NpcSprite({
ny: number;
viewport: Viewport;
editable: boolean;
dragEnabled: boolean;
displayScale: number;
gridFit: number;
disposition: NpcDisposition;
@@ -74,6 +76,7 @@ function NpcSprite({
const pos = localPos ?? { nx, ny };
const minDim = Math.min(viewport.w, viewport.h);
const sizePx = Math.max(16, placement.sizeN * gridFit * displayScale * minDim);
const canDrag = editable && dragEnabled && onMove !== undefined;
const point = (e: React.PointerEvent) => {
const host = e.currentTarget.parentElement;
@@ -94,13 +97,14 @@ function NpcSprite({
return (
<div
className={[styles.token, editable ? styles.editable : ''].filter(Boolean).join(' ')}
className={[styles.token, canDrag ? styles.editable : ''].filter(Boolean).join(' ')}
data-testid={`session-npc-token-${placement.id}`}
style={{
left: viewport.x + pos.nx * viewport.w,
top: viewport.y + pos.ny * viewport.h,
width: sizePx,
height: sizePx,
pointerEvents: canDrag || onContextMenu ? 'auto' : undefined,
}}
onContextMenu={
onContextMenu
@@ -112,7 +116,7 @@ function NpcSprite({
: undefined
}
onPointerDown={
editable && onMove !== undefined
canDrag
? (e) => {
if (e.button !== 0) return;
e.preventDefault();
@@ -132,7 +136,7 @@ function NpcSprite({
: undefined
}
onPointerMove={
editable && onMove
canDrag
? (e) => {
const drag = dragRef.current;
if (drag?.pointerId !== e.pointerId) return;
@@ -173,6 +177,8 @@ export function SceneNpcTokensOverlay({
onMove,
onContextMenu,
snapNorm,
poseOverrides = null,
dragEnabledById = null,
}: {
placements: readonly SceneNpcToken[];
library: readonly ProjectNpc[];
@@ -184,6 +190,8 @@ export function SceneNpcTokensOverlay({
onMove?: (placementId: string, nx: number, ny: number) => void;
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
poseOverrides?: Record<string, { nx: number; ny: number }> | null;
dragEnabledById?: Record<string, boolean> | null;
}) {
if (!viewport) return null;
const byId = new Map(library.map((npc) => [npc.id, npc]));
@@ -194,18 +202,22 @@ export function SceneNpcTokensOverlay({
{placements.map((placement) => {
const npc = byId.get(placement.npcId);
if (!npc) return null;
const override = session?.byPlacementId[String(placement.id)];
const key = String(placement.id);
const pose = poseOverrides?.[key];
const override = session?.byPlacementId[key];
const disposition = resolveNpcTokenDisposition(placement, npc, override?.disposition);
const inactive = Boolean(override?.inactive);
const dragEnabled = dragEnabledById ? Boolean(dragEnabledById[key]) : true;
return (
<NpcSprite
key={placement.id}
placement={placement}
npc={npc}
nx={override?.nx ?? placement.nx}
ny={override?.ny ?? placement.ny}
nx={pose?.nx ?? override?.nx ?? placement.nx}
ny={pose?.ny ?? override?.ny ?? placement.ny}
viewport={viewport}
editable={editable}
dragEnabled={dragEnabled}
displayScale={displayScale}
gridFit={gridFit}
disposition={disposition}
@@ -26,6 +26,11 @@
cursor: grabbing;
}
.tokenInteractive {
pointer-events: auto;
cursor: context-menu;
}
.tokenImg {
width: 100%;
height: 100%;
@@ -7,6 +7,12 @@ import styles from './SceneTokensOverlay.module.css';
type Viewport = { x: number; y: number; w: number; h: number };
export type TokenPoseOverride = {
nx: number;
ny: number;
rotationDeg?: number;
};
type Props = {
placements: readonly SceneToken[];
library: readonly AppToken[];
@@ -16,24 +22,35 @@ type Props = {
onMove?: (placementId: string, nx: number, ny: number) => void;
/** Snap во время drag (пульт, привязка к сетке). */
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
onContextMenu?: (e: React.MouseEvent, placement: SceneToken) => void;
/** Live path playback / other pose overrides by placement id. */
poseOverrides?: Record<string, TokenPoseOverride> | null;
/** Per-placement drag lock (e.g. while path is animating). Default: all editable. */
dragEnabledById?: Record<string, boolean> | null;
};
function TokenSprite({
placement,
nx,
ny,
rotationDeg,
viewport,
editable,
dragEnabled,
onMove,
snapNorm,
onContextMenu,
}: {
placement: SceneToken;
nx: number;
ny: number;
rotationDeg: number;
viewport: Viewport;
editable: boolean;
dragEnabled: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
onContextMenu?: (e: React.MouseEvent, placement: SceneToken) => void;
}) {
const url = useTokenImageUrl(placement.tokenId);
const dragRef = useRef<{
@@ -66,6 +83,8 @@ function TokenSprite({
const sizePx = Math.max(16, placement.sizeN * minDim);
const left = viewport.x + posNx * viewport.w;
const top = viewport.y + posNy * viewport.h;
const canDrag = editable && dragEnabled && Boolean(onMove);
const interactive = canDrag || Boolean(onContextMenu);
const hostToNorm = (clientX: number, clientY: number, host: HTMLElement) => {
const r = host.getBoundingClientRect();
@@ -85,7 +104,6 @@ function TokenSprite({
cancelAnimationFrame(frameRef.current);
frameRef.current = 0;
}
// Финальный commit в session store — один раз на отпускание.
onMove?.(String(placement.id), d.lastNx, d.lastNy);
setLocalPos({ nx: d.lastNx, ny: d.lastNy });
try {
@@ -97,16 +115,31 @@ function TokenSprite({
return (
<div
className={[styles.token, editable ? styles.tokenEditable : ''].filter(Boolean).join(' ')}
className={[
styles.token,
canDrag ? styles.tokenEditable : '',
interactive && !canDrag ? styles.tokenInteractive : '',
]
.filter(Boolean)
.join(' ')}
style={{
left,
top,
width: sizePx,
height: sizePx,
transform: `translate(-50%, -50%) rotate(${String(placement.rotationDeg)}deg)`,
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
}}
onContextMenu={
onContextMenu
? (e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, placement);
}
: undefined
}
onPointerDown={
editable && onMove
canDrag
? (e) => {
if (e.button !== 0) return;
e.stopPropagation();
@@ -128,7 +161,7 @@ function TokenSprite({
: undefined
}
onPointerMove={
editable && onMove
canDrag
? (e) => {
const d = dragRef.current;
if (!d || d.pointerId !== e.pointerId) return;
@@ -169,6 +202,9 @@ export function SceneTokensOverlay({
editable = false,
onMove,
snapNorm,
onContextMenu,
poseOverrides = null,
dragEnabledById = null,
}: Props) {
if (!viewport || placements.length === 0) return null;
const known = new Set(library.map((t) => t.id));
@@ -179,19 +215,25 @@ export function SceneTokensOverlay({
.filter((p) => known.has(p.tokenId))
.map((placement) => {
const key = String(placement.id);
const pose = poseOverrides?.[key];
const override = session?.byPlacementId[key] ?? session?.byPlacementId[placement.id];
const nx = override?.nx ?? placement.nx;
const ny = override?.ny ?? placement.ny;
const nx = pose?.nx ?? override?.nx ?? placement.nx;
const ny = pose?.ny ?? override?.ny ?? placement.ny;
const rotationDeg = pose?.rotationDeg ?? placement.rotationDeg;
const dragEnabled = dragEnabledById ? Boolean(dragEnabledById[key]) : true;
return (
<TokenSprite
key={key}
placement={placement}
nx={nx}
ny={ny}
rotationDeg={rotationDeg}
viewport={viewport}
editable={editable}
dragEnabled={dragEnabled}
{...(onMove ? { onMove } : {})}
{...(snapNorm ? { snapNorm } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
/>
);
})}
@@ -0,0 +1,42 @@
.layer {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 7;
overflow: visible;
}
.lineGlow {
stroke: rgba(40, 180, 255, 0.28);
stroke-width: 6;
stroke-linecap: round;
stroke-linejoin: round;
}
.line {
stroke: rgba(90, 210, 255, 0.92);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 7 5;
}
.start {
fill: rgba(120, 230, 255, 0.95);
stroke: rgba(0, 0, 0, 0.45);
stroke-width: 1;
}
.closed {
stroke: rgba(255, 200, 80, 0.9);
stroke-width: 1.5;
}
.emphasized .line {
stroke: rgba(255, 220, 120, 0.95);
stroke-dasharray: none;
}
.emphasized .lineGlow {
stroke: rgba(255, 200, 80, 0.35);
}
@@ -0,0 +1,92 @@
import React from 'react';
import type { TokenPath } from '../../../shared/types/tokenPath';
import { tokenPathPolyline } from '../../../shared/types/tokenPath';
import type { SceneNpcToken, SceneToken } from '../../../shared/types';
import { tokenPathKey, type TokenPathSessionState } from '../../../shared/types/tokenPathSession';
import styles from './TokenPathsOverlay.module.css';
type Viewport = { x: number; y: number; w: number; h: number };
type PathItem = {
key: string;
path: TokenPath;
emphasized?: boolean;
};
type Props = {
tokens: readonly SceneToken[];
npcTokens?: readonly SceneNpcToken[];
viewport: Viewport | null;
/** control/editor: always; presentation: only presentationVisible */
mode: 'always' | 'presentation';
pathSession?: TokenPathSessionState | null;
/** Highlight path currently edited */
emphasizeKey?: string | null;
};
function toSvgPoints(path: TokenPath, viewport: Viewport): string {
const pts = tokenPathPolyline(path);
return pts
.map((p) => {
const x = viewport.x + p.nx * viewport.w;
const y = viewport.y + p.ny * viewport.h;
return `${x},${y}`;
})
.join(' ');
}
export function TokenPathsOverlay({
tokens,
npcTokens = [],
viewport,
mode,
pathSession = null,
emphasizeKey = null,
}: Props) {
if (!viewport) return null;
const items: PathItem[] = [];
for (const t of tokens) {
if (!t.path || t.path.points.length < 2) continue;
const key = tokenPathKey('token', String(t.id));
if (mode === 'presentation' && !pathSession?.presentationVisible[key]) continue;
items.push({ key, path: t.path, emphasized: emphasizeKey === key });
}
for (const t of npcTokens) {
if (!t.path || t.path.points.length < 2) continue;
const key = tokenPathKey('npcToken', String(t.id));
if (mode === 'presentation' && !pathSession?.presentationVisible[key]) continue;
items.push({ key, path: t.path, emphasized: emphasizeKey === key });
}
if (items.length === 0) return null;
return (
<svg className={styles.layer} width="100%" height="100%" aria-hidden>
{items.map((item) => {
const pts = toSvgPoints(item.path, viewport);
if (!pts) return null;
const first = item.path.points[0]!;
const fx = viewport.x + first.nx * viewport.w;
const fy = viewport.y + first.ny * viewport.h;
return (
<g key={item.key} className={item.emphasized ? styles.emphasized : undefined}>
<polyline className={styles.lineGlow} points={pts} fill="none" />
<polyline className={styles.line} points={pts} fill="none" />
<circle className={styles.start} cx={fx} cy={fy} r={4} />
{item.path.closed ? (
<circle
className={styles.closed}
cx={fx}
cy={fy}
r={7}
fill="none"
/>
) : null}
</g>
);
})}
</svg>
);
}
@@ -0,0 +1,155 @@
import { useEffect, useRef, useState } from 'react';
import { computePathPlaybackSample } from '../../../shared/types/tokenPathPlayback';
import {
tokenPathKey,
type TokenPathPlaybackPhase,
type TokenPathSessionState,
} from '../../../shared/types/tokenPathSession';
import type { SceneNpcToken, SceneToken } from '../../../shared/types';
export type TokenPathLivePose = {
nx: number;
ny: number;
rotationDeg: number;
phase: TokenPathPlaybackPhase;
dist: number;
};
type PoseMaps = {
tokenPoses: Record<string, TokenPathLivePose>;
npcPoses: Record<string, TokenPathLivePose>;
};
const EMPTY: PoseMaps = { tokenPoses: {}, npcPoses: {} };
function posesEqual(a: PoseMaps, b: PoseMaps): boolean {
const aT = a.tokenPoses;
const bT = b.tokenPoses;
const aN = a.npcPoses;
const bN = b.npcPoses;
const aTk = Object.keys(aT);
const bTk = Object.keys(bT);
const aNk = Object.keys(aN);
const bNk = Object.keys(bN);
if (aTk.length !== bTk.length || aNk.length !== bNk.length) return false;
for (const k of aTk) {
const x = aT[k];
const y = bT[k];
if (!y || x!.nx !== y.nx || x!.ny !== y.ny || x!.rotationDeg !== y.rotationDeg || x!.phase !== y.phase) {
return false;
}
}
for (const k of aNk) {
const x = aN[k];
const y = bN[k];
if (!y || x!.nx !== y.nx || x!.ny !== y.ny || x!.rotationDeg !== y.rotationDeg || x!.phase !== y.phase) {
return false;
}
}
return true;
}
/**
* RAF poses for control/presentation while path playback is active.
* When phase === 'stopped', pose is omitted so session/placement (drag) wins.
*
* Uses local Date.now() — segmentStartedAtMs is also wall-clock from main (same machine).
* Do NOT clamp to stale serverNowMs: that froze motion after ~250ms until the next IPC bump.
*/
export function useTokenPathLivePoses(args: {
tokens: readonly SceneToken[];
npcTokens: readonly SceneNpcToken[];
pathSession: TokenPathSessionState | null;
enabled?: boolean;
onMarkDone?: (kind: 'token' | 'npcToken', placementId: string, atDist: number) => void;
}): PoseMaps {
const { tokens, npcTokens, pathSession, enabled = true, onMarkDone } = args;
const [poses, setPoses] = useState<PoseMaps>(EMPTY);
const markedDoneRef = useRef<Set<string>>(new Set());
const onMarkDoneRef = useRef(onMarkDone);
onMarkDoneRef.current = onMarkDone;
const pathSessionRef = useRef(pathSession);
pathSessionRef.current = pathSession;
const tokensRef = useRef(tokens);
tokensRef.current = tokens;
const npcTokensRef = useRef(npcTokens);
npcTokensRef.current = npcTokens;
useEffect(() => {
markedDoneRef.current.clear();
}, [pathSession?.revision]);
useEffect(() => {
if (!enabled) {
setPoses(EMPTY);
return;
}
let raf = 0;
let alive = true;
const tick = () => {
if (!alive) return;
const session = pathSessionRef.current;
if (!session) {
setPoses((prev) => (prev === EMPTY || Object.keys(prev.tokenPoses).length + Object.keys(prev.npcPoses).length === 0 ? prev : EMPTY));
raf = requestAnimationFrame(tick);
return;
}
// Wall clock: matches main's Date.now() for segmentStartedAtMs (Electron, one host).
const sampleNow = Date.now();
const tokenPoses: Record<string, TokenPathLivePose> = {};
const npcPoses: Record<string, TokenPathLivePose> = {};
const sampleOne = (
kind: 'token' | 'npcToken',
placementId: string,
path: NonNullable<SceneToken['path']>,
out: Record<string, TokenPathLivePose>,
) => {
const key = tokenPathKey(kind, placementId);
const entry = session.playback[key];
if (!entry) return;
if (entry.phase === 'stopped') return;
const result = computePathPlaybackSample({ path, entry, nowMs: sampleNow });
if (!result) return;
out[placementId] = {
nx: result.sample.nx,
ny: result.sample.ny,
rotationDeg: result.sample.rotationDeg,
phase: result.phase,
dist: result.sample.dist,
};
if (result.markDone && !markedDoneRef.current.has(key)) {
markedDoneRef.current.add(key);
onMarkDoneRef.current?.(kind, placementId, result.sample.dist);
}
};
for (const t of tokensRef.current) {
if (t.path && t.path.points.length >= 2) {
sampleOne('token', String(t.id), t.path, tokenPoses);
}
}
for (const t of npcTokensRef.current) {
if (t.path && t.path.points.length >= 2) {
sampleOne('npcToken', String(t.id), t.path, npcPoses);
}
}
const next: PoseMaps = { tokenPoses, npcPoses };
setPoses((prev) => (posesEqual(prev, next) ? prev : next));
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
alive = false;
cancelAnimationFrame(raf);
};
}, [enabled]);
return poses;
}
@@ -0,0 +1,34 @@
import { useEffect, useMemo, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { TokenPathSessionEvent, TokenPathSessionState } from '../../../shared/types';
import { getDndApi } from '../dndApi';
export function useTokenPathSession(): [
TokenPathSessionState | null,
{ dispatch: (event: TokenPathSessionEvent) => Promise<void> },
] {
const api = getDndApi();
const [state, setState] = useState<TokenPathSessionState | null>(null);
useEffect(() => {
void api.invoke(ipcChannels.tokenPathSession.getState, {}).then(({ state: s }) => {
setState(s);
});
return api.on(ipcChannels.tokenPathSession.stateChanged, ({ state: s }) => {
setState(s);
});
}, [api]);
const apiWrap = useMemo(
() => ({
dispatch: async (event: TokenPathSessionEvent) => {
const res = await api.invoke(ipcChannels.tokenPathSession.dispatch, { event });
void res;
},
}),
[api],
);
return [state, apiWrap];
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-window-icon.png" type="image/png" />
<title>TTRPG</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/tokenPathEditor/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,183 @@
.page {
display: grid;
grid-template-columns: 280px 1fr;
height: 100vh;
width: 100vw;
overflow: hidden;
background: var(--bg, #12141a);
color: var(--text, #e8eaef);
}
.sidebar {
border-right: 1px solid var(--stroke, #2a2f3a);
padding: 12px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.title {
font-weight: 800;
font-size: 14px;
letter-spacing: 0.02em;
}
.hint {
font-size: 12px;
opacity: 0.7;
line-height: 1.4;
margin: 0;
}
.field {
display: grid;
gap: 4px;
font-size: 12px;
}
.actions {
display: grid;
gap: 8px;
margin-top: 4px;
width: 100%;
}
.actions > * {
width: 100%;
max-width: 100%;
display: flex;
box-sizing: border-box;
}
.actions button {
width: 100%;
box-sizing: border-box;
}
.meta {
font-size: 11px;
opacity: 0.65;
line-height: 1.35;
}
.main {
min-width: 0;
min-height: 0;
position: relative;
background: #0b0d12;
}
.host {
position: absolute;
inset: 0;
overflow: hidden;
cursor: crosshair;
touch-action: none;
user-select: none;
}
.empty {
display: grid;
place-items: center;
height: 100%;
padding: 24px;
text-align: center;
opacity: 0.75;
}
.pathSvg {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
}
.pathLine {
stroke: rgba(90, 210, 255, 0.95);
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 8 5;
}
.point {
position: absolute;
z-index: 4;
width: 22px;
height: 22px;
margin: 0;
padding: 0;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.55);
background: rgba(20, 90, 140, 0.92);
color: #fff;
font-size: 10px;
font-weight: 700;
transform: translate(-50%, -50%);
cursor: grab;
touch-action: none;
}
.point:active {
cursor: grabbing;
}
.tokenPreview {
position: absolute;
z-index: 3;
pointer-events: none;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.28);
background: rgba(0, 0, 0, 0.25);
}
.tokenPreview img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.ctxMenuBackdrop {
position: fixed;
inset: 0;
z-index: 40;
border: 0;
background: transparent;
cursor: default;
}
.ctxMenu {
position: fixed;
z-index: 41;
min-width: 160px;
padding: 4px;
border-radius: 8px;
border: 1px solid var(--stroke, #2a2f3a);
background: #1a1e27;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
display: grid;
}
.ctxItem,
.ctxItemDanger {
text-align: left;
border: 0;
background: transparent;
color: inherit;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.ctxItemDanger {
color: #ff8f8f;
}
.ctxItem:hover,
.ctxItemDanger:hover {
background: rgba(255, 255, 255, 0.06);
}
@@ -0,0 +1,679 @@
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>
);
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { TokenPathEditorApp } from './TokenPathEditorApp';
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Missing #root element');
}
createRoot(rootEl).render(
<React.StrictMode>
<WindowErrorBoundary title="Движение токена">
<EditorI18nProvider>
<TokenPathEditorApp />
</EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>,
);