From 1ab6ffd5933543cb254d9257809d6d8a0ea08aad Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Fri, 14 Aug 2026 08:48:41 +0800 Subject: [PATCH] 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 --- app/main/index.ts | 76 ++ app/main/tokens/tokenPathSessionStore.ts | 171 +++++ app/main/windows/createWindows.ts | 80 +++ app/renderer/control/ControlApp.tsx | 352 ++++++++- app/renderer/editor/i18n/editorMessages.ts | 4 +- app/renderer/sceneEditor/SceneEditorApp.tsx | 132 +++- app/renderer/shared/PresentationView.tsx | 24 + .../playerToken/SceneNpcTokensOverlay.tsx | 24 +- .../tokens/SceneTokensOverlay.module.css | 5 + .../shared/tokens/SceneTokensOverlay.tsx | 56 +- .../tokens/TokenPathsOverlay.module.css | 42 ++ .../shared/tokens/TokenPathsOverlay.tsx | 92 +++ .../shared/tokens/useTokenPathLivePoses.ts | 155 ++++ .../shared/tokens/useTokenPathSession.ts | 34 + app/renderer/tokenPathEditor.html | 13 + .../TokenPathEditorApp.module.css | 183 +++++ .../tokenPathEditor/TokenPathEditorApp.tsx | 679 ++++++++++++++++++ app/renderer/tokenPathEditor/main.tsx | 23 + app/shared/appBranding.ts | 2 + app/shared/ipc/contracts.ts | 38 + app/shared/types/appPlayers.ts | 7 + app/shared/types/appTokens.ts | 5 + app/shared/types/index.ts | 3 + app/shared/types/scenePreviewRotation.ts | 37 +- app/shared/types/tokenPath.test.ts | 101 +++ app/shared/types/tokenPath.ts | 306 ++++++++ app/shared/types/tokenPathPlayback.test.ts | 102 +++ app/shared/types/tokenPathPlayback.ts | 113 +++ app/shared/types/tokenPathSession.ts | 77 ++ docs/token-path-spec-v1.md | 22 + package.json | 2 +- vite.config.ts | 1 + 32 files changed, 2933 insertions(+), 28 deletions(-) create mode 100644 app/main/tokens/tokenPathSessionStore.ts create mode 100644 app/renderer/shared/tokens/TokenPathsOverlay.module.css create mode 100644 app/renderer/shared/tokens/TokenPathsOverlay.tsx create mode 100644 app/renderer/shared/tokens/useTokenPathLivePoses.ts create mode 100644 app/renderer/shared/tokens/useTokenPathSession.ts create mode 100644 app/renderer/tokenPathEditor.html create mode 100644 app/renderer/tokenPathEditor/TokenPathEditorApp.module.css create mode 100644 app/renderer/tokenPathEditor/TokenPathEditorApp.tsx create mode 100644 app/renderer/tokenPathEditor/main.tsx create mode 100644 app/shared/types/tokenPath.test.ts create mode 100644 app/shared/types/tokenPath.ts create mode 100644 app/shared/types/tokenPathPlayback.test.ts create mode 100644 app/shared/types/tokenPathPlayback.ts create mode 100644 app/shared/types/tokenPathSession.ts create mode 100644 docs/token-path-spec-v1.md diff --git a/app/main/index.ts b/app/main/index.ts index e8b42b6..056e7c6 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -59,17 +59,20 @@ import { focusEditorWindow, getPresentationContentSize, getSceneDescriptionContent, + getTokenPathEditorTarget, isMultiWindowOpen, markAppQuitting, openMaterialsWindow, openMultiWindow, openNpcsEditorWindow, openSceneEditorWindow, + openTokenPathEditorWindow, openNpcsWindow, openSceneDescriptionWindow, closeMaterialsWindow, closeNpcsEditorWindow, closeSceneEditorWindow, + closeTokenPathEditorWindow, closeNpcsWindow, sendToAppWindows, syncAllWindowChromeTitles, @@ -77,6 +80,9 @@ import { waitForEditorWindowReady, warmNpcsEditorWindow, } from './windows/createWindows'; +import { TokenPathSessionStore } from './tokens/tokenPathSessionStore'; +import { tokenPathTotalLength } from '../shared/types/tokenPath'; +import type { TokenPathTargetKind } from '../shared/types/tokenPathSession'; function emitZipProgress(evt: { kind: 'import' | 'export'; @@ -160,12 +166,59 @@ const videoStore = new VideoPlaybackStore(); const materialsOverlayStore = new MaterialsOverlayStore(); const npcsOverlayStore = new NpcsOverlayStore(); const sceneTokensSessionStore = new SceneTokensSessionStore(); +const tokenPathSessionStore = new TokenPathSessionStore(); const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore(); const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore(); const tokenGridSnapSessionStore = new TokenGridSnapSessionStore(); let tokensStore: TokensStore | null = null; let playersStore: PlayersStore | null = null; +function emitTokenPathSessionState(): void { + const state = tokenPathSessionStore.getState(); + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(ipcChannels.tokenPathSession.stateChanged, { state }); + } +} + +function seedTokenPathPlaybackForProject(project: Project | null): void { + tokenPathSessionStore.reset(); + if (!project?.currentSceneId) { + emitTokenPathSessionState(); + return; + } + const scene = project.scenes[project.currentSceneId]; + if (!scene) { + emitTokenPathSessionState(); + return; + } + const now = Date.now(); + const seedOne = (kind: TokenPathTargetKind, placementId: string, path: NonNullable<(typeof scene.tokens)[number]['path']>) => { + const pathLength = tokenPathTotalLength(path); + if (pathLength <= 1e-9) return; + tokenPathSessionStore.dispatch({ + kind: 'seedPlayback', + entry: { + kind, + placementId, + phase: path.startMode === 'delayed' ? 'delay' : 'moving', + baseDist: 0, + direction: 1, + rejoinDist: null, + durationSec: path.durationSec, + pathLength, + segmentStartedAtMs: now, + }, + }); + }; + for (const t of scene.tokens ?? []) { + if (t.path && t.path.points.length >= 2) seedOne('token', String(t.id), t.path); + } + for (const t of scene.npcTokens ?? []) { + if (t.path && t.path.points.length >= 2) seedOne('npcToken', String(t.id), t.path); + } + emitTokenPathSessionState(); +} + function emitEffectsState(): void { const state = effectsStore.getState(); for (const win of BrowserWindow.getAllWindows()) { @@ -446,6 +499,7 @@ async function main() { sceneDarknessStore.resetSession(); sceneTrapsStore.resetSession(); sceneTokensSessionStore.reset(); + tokenPathSessionStore.reset(); sceneNpcTokensSessionStore.reset(); scenePlayerTokensSessionStore.reset(); tokenGridSnapSessionStore.reset(); @@ -461,6 +515,9 @@ async function main() { if (project) { syncSceneDarknessForProject(project); syncSceneTrapsForProject(project); + seedTokenPathPlaybackForProject(project); + } else { + emitTokenPathSessionState(); } emitSceneDarknessState(); emitSceneTrapsState(); @@ -527,6 +584,15 @@ async function main() { closeSceneEditorWindow(); return { ok: true }; }); + registerHandler(ipcChannels.windows.openTokenPathEditor, ({ kind, placementId }) => { + openTokenPathEditorWindow(kind, placementId); + return { ok: true }; + }); + registerHandler(ipcChannels.windows.closeTokenPathEditor, () => { + closeTokenPathEditorWindow(); + return { ok: true }; + }); + registerHandler(ipcChannels.windows.getTokenPathEditorTarget, () => getTokenPathEditorTarget()); registerHandler(ipcChannels.windows.openNpcs, (req) => { openNpcsWindow(); const npcId = req?.npcId ? asNpcId(String(req.npcId)) : null; @@ -643,6 +709,7 @@ async function main() { emitSceneViewState(); emitSceneTokensSessionState(); emitScenePlayerTokensSessionState(); + seedTokenPathPlaybackForProject(project); emitSessionState(); return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null }; }); @@ -674,6 +741,7 @@ async function main() { emitSceneViewState(); emitSceneTokensSessionState(); emitScenePlayerTokensSessionState(); + seedTokenPathPlaybackForProject(project); emitSessionState(); const p = projectStore.getOpenProject(); return { @@ -1411,6 +1479,14 @@ async function main() { emitSceneTokensSessionState(); return { ok: true }; }); + registerHandler(ipcChannels.tokenPathSession.getState, () => { + return { state: tokenPathSessionStore.getState() }; + }); + registerHandler(ipcChannels.tokenPathSession.dispatch, ({ event }) => { + tokenPathSessionStore.dispatch(event); + emitTokenPathSessionState(); + return { ok: true }; + }); registerHandler(ipcChannels.tokenGridSnap.getState, () => tokenGridSnapSessionStore.getState()); registerHandler(ipcChannels.tokenGridSnap.setEnabled, ({ enabled }) => { diff --git a/app/main/tokens/tokenPathSessionStore.ts b/app/main/tokens/tokenPathSessionStore.ts new file mode 100644 index 0000000..28d9714 --- /dev/null +++ b/app/main/tokens/tokenPathSessionStore.ts @@ -0,0 +1,171 @@ +import { + emptyTokenPathSessionState, + tokenPathKey, + type TokenPathPlaybackEntry, + type TokenPathSessionEvent, + type TokenPathSessionState, + type TokenPathTargetRef, +} from '../../shared/types/tokenPathSession'; + +function bump( + state: TokenPathSessionState, + patch: Partial>, +): TokenPathSessionState { + return { + ...state, + ...patch, + revision: state.revision + 1, + serverNowMs: Date.now(), + }; +} + +export class TokenPathSessionStore { + private state: TokenPathSessionState = emptyTokenPathSessionState(); + + getState(): TokenPathSessionState { + return this.state; + } + + /** Refresh clock without logical change (optional heartbeat). */ + touchClock(): TokenPathSessionState { + this.state = { ...this.state, serverNowMs: Date.now() }; + return this.state; + } + + reset(): TokenPathSessionState { + if ( + Object.keys(this.state.playback).length === 0 && + Object.keys(this.state.presentationVisible).length === 0 + ) { + return this.state; + } + this.state = emptyTokenPathSessionState(this.state.revision + 1); + return this.state; + } + + dispatch(event: TokenPathSessionEvent): TokenPathSessionState { + switch (event.kind) { + case 'clear': + return this.reset(); + case 'showPresentation': { + const key = tokenPathKey(event.target.kind, event.target.placementId); + if (this.state.presentationVisible[key]) return this.state; + this.state = bump(this.state, { + presentationVisible: { ...this.state.presentationVisible, [key]: true }, + }); + return this.state; + } + case 'hidePresentation': { + const key = tokenPathKey(event.target.kind, event.target.placementId); + if (!this.state.presentationVisible[key]) return this.state; + const { [key]: _removed, ...rest } = this.state.presentationVisible; + this.state = bump(this.state, { presentationVisible: rest }); + return this.state; + } + case 'stop': { + const key = tokenPathKey(event.target.kind, event.target.placementId); + const prev = this.state.playback[key]; + if (!prev || prev.phase === 'stopped') return this.state; + const atDist = + typeof event.atDist === 'number' && Number.isFinite(event.atDist) + ? Math.max(0, event.atDist) + : prev.baseDist; + this.state = bump(this.state, { + playback: { + ...this.state.playback, + [key]: { + ...prev, + phase: 'stopped', + baseDist: atDist, + rejoinDist: null, + segmentStartedAtMs: Date.now(), + }, + }, + }); + return this.state; + } + case 'resume': { + const key = tokenPathKey(event.target.kind, event.target.placementId); + const prev = this.state.playback[key]; + if (!prev) return this.state; + if (prev.phase !== 'stopped' && prev.phase !== 'done') return this.state; + const entry: TokenPathPlaybackEntry = { + ...prev, + phase: 'moving', + segmentStartedAtMs: Date.now(), + // Jump to nearest-ahead on path, then continue (v1: no off-path lerp). + baseDist: Math.max(0, event.fromDist), + rejoinDist: null, + direction: 1, + }; + this.state = bump(this.state, { + playback: { ...this.state.playback, [key]: entry }, + }); + return this.state; + } + case 'resetToStart': { + const key = tokenPathKey(event.target.kind, event.target.placementId); + const prev = this.state.playback[key]; + const entry: TokenPathPlaybackEntry = { + kind: event.target.kind, + placementId: event.target.placementId, + phase: 'moving', + segmentStartedAtMs: Date.now(), + baseDist: 0, + direction: 1, + rejoinDist: null, + durationSec: prev?.durationSec ?? 8, + pathLength: prev?.pathLength ?? 1, + }; + this.state = bump(this.state, { + playback: { ...this.state.playback, [key]: entry }, + }); + return this.state; + } + case 'seedPlayback': { + const e = event.entry; + const key = tokenPathKey(e.kind, e.placementId); + const entry: TokenPathPlaybackEntry = { + kind: e.kind, + placementId: e.placementId, + phase: e.phase, + segmentStartedAtMs: e.segmentStartedAtMs ?? Date.now(), + baseDist: e.baseDist, + direction: e.direction, + rejoinDist: e.rejoinDist, + durationSec: e.durationSec, + pathLength: e.pathLength, + }; + this.state = bump(this.state, { + playback: { ...this.state.playback, [key]: entry }, + }); + return this.state; + } + case 'markDone': { + const key = tokenPathKey(event.target.kind, event.target.placementId); + const prev = this.state.playback[key]; + if (!prev || prev.phase === 'done') return this.state; + this.state = bump(this.state, { + playback: { + ...this.state.playback, + [key]: { + ...prev, + phase: 'done', + baseDist: prev.pathLength, + rejoinDist: null, + segmentStartedAtMs: Date.now(), + }, + }, + }); + return this.state; + } + default: { + const _exhaustive: never = event; + void _exhaustive; + return this.state; + } + } + } +} + +export type { TokenPathTargetRef }; diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts index 371fb24..7020aa7 100644 --- a/app/main/windows/createWindows.ts +++ b/app/main/windows/createWindows.ts @@ -18,6 +18,7 @@ export type WindowKind = | 'materials' | 'npcsEditor' | 'sceneEditor' + | 'tokenPathEditor' | 'npcs'; /** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */ @@ -28,6 +29,7 @@ export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [ 'npcs', 'npcsEditor', 'sceneEditor', + 'tokenPathEditor', ] as const; const windows = new Map(); @@ -209,6 +211,8 @@ function pageNameForKind(kind: WindowKind): string { return 'npcsEditor.html'; case 'sceneEditor': return 'sceneEditor.html'; + case 'tokenPathEditor': + return 'tokenPathEditor.html'; case 'npcs': return 'npcs.html'; } @@ -280,6 +284,7 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number } if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT }; if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT }; if (kind === 'sceneEditor') return { width: 1280, height: 800 }; + if (kind === 'tokenPathEditor') return { width: 1100, height: 760 }; if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT }; return { width: 1280, height: 800 }; } @@ -323,6 +328,14 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow minHeight: 600, } : {}), + ...(kind === 'tokenPathEditor' + ? { + width: 1100, + height: 760, + minWidth: 900, + minHeight: 560, + } + : {}), ...(kind === 'npcs' ? { width: NPCS_WINDOW_WIDTH, @@ -361,6 +374,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow kind === 'materials' || kind === 'npcsEditor' || kind === 'sceneEditor' || + kind === 'tokenPathEditor' || kind === 'npcs' ) { win.setMenuBarVisibility(false); @@ -408,6 +422,11 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow }); } win.on('closed', () => windows.delete(kind)); + if (kind === 'sceneEditor') { + win.on('closed', () => { + closeTokenPathEditorWindow(); + }); + } win.on('closed', () => { if (kind !== 'presentation' && kind !== 'control') return; const open = windows.has('presentation') || windows.has('control'); @@ -535,12 +554,73 @@ export function closeNpcsEditorWindow(): void { } export function closeSceneEditorWindow(): void { + closeTokenPathEditorWindow(); const win = windows.get('sceneEditor'); if (win && !win.isDestroyed()) { win.close(); } } +export function closeTokenPathEditorWindow(): void { + const win = windows.get('tokenPathEditor'); + if (win && !win.isDestroyed()) { + win.close(); + } +} + +let pendingTokenPathEditorTarget: { kind: 'token' | 'npcToken'; placementId: string } | null = null; + +export function getTokenPathEditorTarget(): { kind: 'token' | 'npcToken'; placementId: string } | null { + return pendingTokenPathEditorTarget; +} + +function broadcastTokenPathEditorTarget(): void { + const win = windows.get('tokenPathEditor'); + if (!win || win.isDestroyed() || win.webContents.isDestroyed()) return; + try { + win.webContents.send(ipcChannels.windows.tokenPathEditorTargetChanged, pendingTokenPathEditorTarget); + } catch { + /* ignore */ + } +} + +/** Одно окно пути: смена токена = фокус + targetChanged, без второго окна. */ +export function openTokenPathEditorWindow(kind: 'token' | 'npcToken', placementId: string): void { + pendingTokenPathEditorTarget = { kind, placementId }; + const existing = windows.get('tokenPathEditor'); + if (existing && !existing.isDestroyed()) { + if (existing.isMinimized()) existing.restore(); + existing.show(); + existing.focus(); + existing.moveTop(); + broadcastTokenPathEditorTarget(); + return; + } + + const parent = windows.get('sceneEditor') ?? windows.get('editor'); + const win = createWindow('tokenPathEditor', parent ? { parent } : undefined); + const { width, height } = win.getBounds(); + const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds()); + const { x, y, width: dw, height: dh } = display.workArea; + win.setBounds({ + x: Math.round(x + Math.max(0, (dw - width) / 2)), + y: Math.round(y + Math.max(0, (dh - height) / 2)), + width, + height, + }); + win.webContents.once('did-finish-load', () => { + if (!win.isDestroyed()) { + win.show(); + win.focus(); + win.moveTop(); + broadcastTokenPathEditorTarget(); + } + }); + win.on('closed', () => { + pendingTokenPathEditorTarget = null; + }); +} + export function closeNpcsWindow(): void { const win = windows.get('npcs'); if (win && !win.isDestroyed()) { diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 860eb3d..59d59b9 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -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(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 = {}; + 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 = {}; + 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 ? ( + + ) : null} {previewContentRect ? ( { 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} + {tokenSessionCtxMenu + ? createPortal( + <> + + {entry && entry.phase !== 'stopped' ? ( + + ) : null} + {entry && entry.phase === 'stopped' ? ( + + ) : null} + + + ); + })()} + + , + 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 ( + {pathEntry && pathEntry.phase !== 'stopped' ? ( + + ) : null} + {pathEntry && pathEntry.phase === 'stopped' ? ( + + ) : null} + + + ) : null} + {(() => { + const tok = localTokens.find((item) => item.id === tokenCtxMenu.placementId); + if (!tok?.path || tok.path.points.length < 2) return null; + return ( + + ); + })()} + + + , + 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" > + + {(() => { + const tok = localNpcTokens.find((item) => item.id === npcCtxMenu.placementId); + if (!tok?.path || tok.path.points.length < 2) return null; + return ( + + ); + })()} + + + + + +
+ Точек: {draft.points.length} + {draft.closed ? ' · замкнут' : ''} + {dirty ? ' · есть изменения' : ''} + {status ? ` · ${status}` : ''} +
+ + +
+ {!scene || (!isImage && !isVideo) || !url ? ( +
Нет карты сцены для редактирования пути.
+ ) : !target || !placement ? ( +
Выберите токен в редакторе сцены: ПКМ → «Указать движение».
+ ) : ( +
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 ? ( + + ) : ( + + )} + {contentRect ? ( + + {draft.points.length >= 2 ? ( + { + const x = contentRect.x + p.nx * contentRect.w; + const y = contentRect.y + p.ny * contentRect.h; + return `${x},${y}`; + }) + .join(' ')} + /> + ) : null} + + ) : 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 ( + + ); + }) + : null} + {contentRect && tokenLeftTop && target ? ( + + ) : null} +
+ )} +
+ + {pointMenu + ? createPortal( + <> + + + + + , + document.body, + ) + : null} + + ); +} diff --git a/app/renderer/tokenPathEditor/main.tsx b/app/renderer/tokenPathEditor/main.tsx new file mode 100644 index 0000000..6b937fe --- /dev/null +++ b/app/renderer/tokenPathEditor/main.tsx @@ -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( + + + + + + + , +); diff --git a/app/shared/appBranding.ts b/app/shared/appBranding.ts index 2c61ad6..51ace2e 100644 --- a/app/shared/appBranding.ts +++ b/app/shared/appBranding.ts @@ -24,6 +24,7 @@ export type AppWindowKind = | 'materials' | 'npcsEditor' | 'sceneEditor' + | 'tokenPathEditor' | 'npcs'; const WINDOW_SUFFIX: Record = { @@ -35,6 +36,7 @@ const WINDOW_SUFFIX: Record = { materials: { ru: 'Материалы', en: 'Materials' }, npcsEditor: { ru: 'НПС', en: 'NPCs' }, sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' }, + tokenPathEditor: { ru: 'Движение токена', en: 'Token path' }, npcs: { ru: 'НПС', en: 'NPCs' }, }; diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index 60f61f1..032a35c 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -43,6 +43,9 @@ import type { SceneViewEvent, SceneViewState, TokenId, + TokenPathSessionEvent, + TokenPathSessionState, + TokenPathTargetKind, VideoPlaybackEvent, VideoPlaybackState, } from '../types'; @@ -147,6 +150,10 @@ export const ipcChannels = { closeNpcs: 'windows.closeNpcs', openSceneEditor: 'windows.openSceneEditor', closeSceneEditor: 'windows.closeSceneEditor', + openTokenPathEditor: 'windows.openTokenPathEditor', + closeTokenPathEditor: 'windows.closeTokenPathEditor', + getTokenPathEditorTarget: 'windows.getTokenPathEditorTarget', + tokenPathEditorTargetChanged: 'windows.tokenPathEditorTargetChanged', syncChromeTitles: 'windows.syncChromeTitles', getPresentationContentSize: 'windows.getPresentationContentSize', presentationContentSizeChanged: 'windows.presentationContentSizeChanged', @@ -197,6 +204,11 @@ export const ipcChannels = { dispatch: 'sceneTokensSession.dispatch', stateChanged: 'sceneTokensSession.stateChanged', }, + tokenPathSession: { + getState: 'tokenPathSession.getState', + dispatch: 'tokenPathSession.dispatch', + stateChanged: 'tokenPathSession.stateChanged', + }, tokenGridSnap: { getState: 'tokenGridSnap.getState', setEnabled: 'tokenGridSnap.setEnabled', @@ -298,6 +310,11 @@ export type IpcEventMap = { [ipcChannels.sceneView.stateChanged]: { state: SceneViewState }; [ipcChannels.tokens.stateChanged]: { tokens: AppToken[] }; [ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState }; + [ipcChannels.tokenPathSession.stateChanged]: { state: TokenPathSessionState }; + [ipcChannels.windows.tokenPathEditorTargetChanged]: { + kind: TokenPathTargetKind; + placementId: string; + } | null; [ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean }; [ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] }; [ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent; @@ -694,6 +711,18 @@ export type IpcInvokeMap = { req: Record; res: { ok: true }; }; + [ipcChannels.windows.openTokenPathEditor]: { + req: { kind: TokenPathTargetKind; placementId: string }; + res: { ok: true }; + }; + [ipcChannels.windows.closeTokenPathEditor]: { + req: Record; + res: { ok: true }; + }; + [ipcChannels.windows.getTokenPathEditorTarget]: { + req: Record; + res: { kind: TokenPathTargetKind; placementId: string } | null; + }; [ipcChannels.windows.syncChromeTitles]: { req: { localeTag: string }; res: { ok: true }; @@ -774,6 +803,14 @@ export type IpcInvokeMap = { req: { event: SceneTokensSessionEvent }; res: { ok: true }; }; + [ipcChannels.tokenPathSession.getState]: { + req: Record; + res: { state: TokenPathSessionState }; + }; + [ipcChannels.tokenPathSession.dispatch]: { + req: { event: TokenPathSessionEvent }; + res: { ok: true }; + }; [ipcChannels.tokenGridSnap.getState]: { req: Record; res: { enabled: boolean }; @@ -887,6 +924,7 @@ export type LegacyIpcEventMap = { [ipcChannels.sceneView.stateChanged]: { state: SceneViewState }; [ipcChannels.tokens.stateChanged]: { tokens: AppToken[] }; [ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState }; + [ipcChannels.tokenPathSession.stateChanged]: { state: TokenPathSessionState }; [ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean }; [ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] }; [ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent; diff --git a/app/shared/types/appPlayers.ts b/app/shared/types/appPlayers.ts index fda595f..9e82c31 100644 --- a/app/shared/types/appPlayers.ts +++ b/app/shared/types/appPlayers.ts @@ -5,6 +5,7 @@ import { normalizeNpcDisposition } from './npcDisposition'; import type { NpcId, PlayerId, PlayerTeamId, SceneNpcTokenId } from './ids'; import { asNpcId, asPlayerId, asPlayerTeamId, asSceneNpcTokenId } from './ids'; import { normalizeHexColor } from '../npcs/npcGroups'; +import { normalizeTokenPath, type TokenPath } from './tokenPath'; export type { PlayerId, PlayerTeamId, SceneNpcTokenId }; export { asPlayerId, asPlayerTeamId, asSceneNpcTokenId }; @@ -41,6 +42,8 @@ export type SceneNpcToken = { sizeN: number; /** Состояние экземпляра на карте (копируется из НПС при постановке). */ disposition: NpcDisposition; + /** Optional movement path (editor → session playback). */ + path?: TokenPath | null; }; export const DEFAULT_PLAYER_RING_COLOR = '#c9a227'; @@ -207,5 +210,9 @@ export function normalizeSceneNpcToken(raw: unknown): SceneNpcToken | null { ny: Math.max(0, Math.min(1, ny)), sizeN: clampSceneNpcTokenSizeN(typeof obj.sizeN === 'number' ? obj.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N), disposition: normalizeNpcDisposition((obj as { disposition?: unknown }).disposition), + ...(() => { + const path = normalizeTokenPath((obj as { path?: unknown }).path); + return path ? { path } : {}; + })(), }; } diff --git a/app/shared/types/appTokens.ts b/app/shared/types/appTokens.ts index 3cc577c..79cc2e0 100644 --- a/app/shared/types/appTokens.ts +++ b/app/shared/types/appTokens.ts @@ -2,6 +2,7 @@ import type { SceneTokenId, TokenId } from './ids'; import { asSceneTokenId, asTokenId } from './ids'; +import { normalizeTokenPath, type TokenPath } from './tokenPath'; export type { SceneTokenId, TokenId }; export { asSceneTokenId, asTokenId }; @@ -24,6 +25,8 @@ export type SceneToken = { sizeN: number; /** Непрерывный угол поворота в градусах. */ rotationDeg: number; + /** Optional movement path (editor → session playback). */ + path?: TokenPath | null; }; export const DEFAULT_SCENE_TOKEN_SIZE_N = 0.08; @@ -56,6 +59,7 @@ export function normalizeSceneToken(raw: unknown): SceneToken | null { const sizeN = clampSceneTokenSizeN(typeof obj.sizeN === 'number' ? obj.sizeN : DEFAULT_SCENE_TOKEN_SIZE_N); const rotationDeg = typeof obj.rotationDeg === 'number' && Number.isFinite(obj.rotationDeg) ? obj.rotationDeg : 0; + const path = normalizeTokenPath((obj as { path?: unknown }).path); return { id: asSceneTokenId(obj.id), tokenId: asTokenId(obj.tokenId), @@ -63,6 +67,7 @@ export function normalizeSceneToken(raw: unknown): SceneToken | null { ny: Math.max(0, Math.min(1, ny)), sizeN, rotationDeg, + ...(path ? { path } : {}), }; } diff --git a/app/shared/types/index.ts b/app/shared/types/index.ts index 9d6c7e8..7447d7d 100644 --- a/app/shared/types/index.ts +++ b/app/shared/types/index.ts @@ -10,6 +10,9 @@ export * from './npcs'; export * from './sceneDarkness'; export * from './sceneGrid'; export * from './sceneGridSnap'; +export * from './tokenPath'; +export * from './tokenPathSession'; +export * from './tokenPathPlayback'; export * from './scenePreviewRotation'; export * from './sceneTraps'; export * from './sceneView'; diff --git a/app/shared/types/scenePreviewRotation.ts b/app/shared/types/scenePreviewRotation.ts index de00e0c..3175b3b 100644 --- a/app/shared/types/scenePreviewRotation.ts +++ b/app/shared/types/scenePreviewRotation.ts @@ -1,3 +1,5 @@ +import { rotateTokenPathByCwSteps, type TokenPath } from './tokenPath'; + /** Rotate map-normalized markers with scene previewRotationDeg (CSS rotate, y-down). */ export type PreviewRotationDeg = 0 | 90 | 180 | 270; @@ -43,29 +45,46 @@ function clamp01(v: number): number { return Math.max(0, Math.min(1, v)); } -export function rotateMapMarkersByCwSteps( +export function rotateMapMarkersByCwSteps( items: readonly T[], steps: number, ): T[] { const n = ((steps % 4) + 4) % 4; - if (n === 0) return items.map((item) => ({ ...item })); + if (n === 0) { + return items.map((item) => ({ + ...item, + ...(item.path ? { path: rotateTokenPathByCwSteps(item.path, 0) } : null), + })); + } return items.map((item) => { const p = rotateMapNormPointByCwSteps(item.nx, item.ny, n); - return { ...item, nx: p.nx, ny: p.ny }; + const next: T = { ...item, nx: p.nx, ny: p.ny }; + if (item.path) { + (next as { path?: TokenPath | null }).path = rotateTokenPathByCwSteps(item.path, n); + } + return next; }); } /** Non-player tokens: move with the map and keep facing relative to the art. */ -export function rotateSceneTokensByCwSteps( - items: readonly T[], - steps: number, -): T[] { +export function rotateSceneTokensByCwSteps< + T extends { nx: number; ny: number; rotationDeg: number; path?: TokenPath | null }, +>(items: readonly T[], steps: number): T[] { const n = ((steps % 4) + 4) % 4; - if (n === 0) return items.map((item) => ({ ...item })); + if (n === 0) { + return items.map((item) => ({ + ...item, + ...(item.path ? { path: rotateTokenPathByCwSteps(item.path, 0) } : null), + })); + } const delta = n * 90; return items.map((item) => { const p = rotateMapNormPointByCwSteps(item.nx, item.ny, n); - return { ...item, nx: p.nx, ny: p.ny, rotationDeg: item.rotationDeg + delta }; + const next: T = { ...item, nx: p.nx, ny: p.ny, rotationDeg: item.rotationDeg + delta }; + if (item.path) { + (next as { path?: TokenPath | null }).path = rotateTokenPathByCwSteps(item.path, n); + } + return next; }); } diff --git a/app/shared/types/tokenPath.test.ts b/app/shared/types/tokenPath.test.ts new file mode 100644 index 0000000..1ee5a7c --- /dev/null +++ b/app/shared/types/tokenPath.test.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + findNearestAheadDistance, + normalizeTokenPath, + reverseTokenPathPoints, + rotateTokenPathByCwSteps, + sampleTokenPathAtProgress, + tokenPathTotalLength, + tryAppendPathPoint, + TOKEN_PATH_MIN_POINT_DIST, +} from './tokenPath'; + +void test('tryAppendPathPoint rejects double-click distance', () => { + const a = tryAppendPathPoint([], { nx: 0.1, ny: 0.1 }); + assert.ok(a); + const b = tryAppendPathPoint(a!, { nx: 0.1 + TOKEN_PATH_MIN_POINT_DIST / 2, ny: 0.1 }); + assert.equal(b, null); + const c = tryAppendPathPoint(a!, { nx: 0.5, ny: 0.5 }); + assert.equal(c?.length, 2); +}); + +void test('normalizeTokenPath requires ≥2 points', () => { + assert.equal(normalizeTokenPath({ points: [{ nx: 0.1, ny: 0.1 }] }), null); + const p = normalizeTokenPath({ + points: [ + { nx: 0.1, ny: 0.1 }, + { nx: 0.5, ny: 0.1 }, + ], + durationSec: 10, + closed: true, + loopMode: 'loop', + }); + assert.ok(p); + assert.equal(p!.loopMode, 'loop'); + assert.equal(p!.closed, true); +}); + +void test('loop forced to once when not closed', () => { + const p = normalizeTokenPath({ + points: [ + { nx: 0, ny: 0 }, + { nx: 1, ny: 0 }, + ], + closed: false, + loopMode: 'loop', + }); + assert.ok(p); + assert.equal(p!.loopMode, 'once'); +}); + +void test('sampleTokenPathAtProgress endpoints', () => { + const p = normalizeTokenPath({ + points: [ + { nx: 0, ny: 0.5 }, + { nx: 1, ny: 0.5 }, + ], + durationSec: 5, + facingMode: 'fixed', + fixedRotationDeg: 45, + }); + assert.ok(p); + const a = sampleTokenPathAtProgress(p!, 0); + const b = sampleTokenPathAtProgress(p!, 1); + assert.ok(a && b); + assert.ok(Math.abs(a!.nx - 0) < 1e-6); + assert.ok(Math.abs(b!.nx - 1) < 1e-6); + assert.equal(a!.rotationDeg, 45); +}); + +void test('reverse and rotate path', () => { + const pts = reverseTokenPathPoints([ + { nx: 0.1, ny: 0.2 }, + { nx: 0.8, ny: 0.2 }, + ]); + assert.deepEqual(pts[0], { nx: 0.8, ny: 0.2 }); + const path = normalizeTokenPath({ + points: [ + { nx: 0.2, ny: 0.1 }, + { nx: 0.8, ny: 0.1 }, + ], + fixedRotationDeg: 10, + })!; + const rotated = rotateTokenPathByCwSteps(path, 1); + assert.ok(Math.abs(rotated.points[0]!.nx - 0.9) < 1e-6); + assert.ok(Math.abs(rotated.points[0]!.ny - 0.2) < 1e-6); + assert.equal(rotated.fixedRotationDeg, 100); + assert.ok(tokenPathTotalLength(path) > 0); +}); + +void test('findNearestAheadDistance prefers ahead of fromDist', () => { + const path = normalizeTokenPath({ + points: [ + { nx: 0, ny: 0.5 }, + { nx: 1, ny: 0.5 }, + ], + })!; + const mid = findNearestAheadDistance(path, 0.7, 0.5, 0.2); + assert.ok(mid > 0.2); +}); diff --git a/app/shared/types/tokenPath.ts b/app/shared/types/tokenPath.ts new file mode 100644 index 0000000..afd0664 --- /dev/null +++ b/app/shared/types/tokenPath.ts @@ -0,0 +1,306 @@ +/** Path animation for scene tokens / NPC tokens (project-persisted). */ + +export type TokenPathPoint = { nx: number; ny: number }; + +/** How the token faces while moving. */ +export type TokenPathFacingMode = 'tangentSmooth' | 'fixed'; + +/** + * once — play to end and stay. + * pingpong — reverse at ends forever (until stopped). + * loop — only when path is closed (explicit «Замкнуть»). + */ +export type TokenPathLoopMode = 'once' | 'pingpong' | 'loop'; + +/** When playback starts after the scene becomes current. */ +export type TokenPathStartMode = 'onEnter' | 'delayed'; + +export type TokenPath = { + points: TokenPathPoint[]; + /** Explicit close (RMB «Замкнуть» on a point). Enables loop mode. */ + closed: boolean; + loopMode: TokenPathLoopMode; + /** Seconds to traverse the full path once (or one direction for pingpong). */ + durationSec: number; + startMode: TokenPathStartMode; + /** Delay after scene enter when startMode === 'delayed'. */ + delaySec: number; + facingMode: TokenPathFacingMode; + /** Used when facingMode === 'fixed'. */ + fixedRotationDeg: number; +}; + +export const TOKEN_PATH_MIN_POINT_DIST = 0.02; +export const TOKEN_PATH_DURATION_MIN_SEC = 0.5; +export const TOKEN_PATH_DURATION_MAX_SEC = 600; +export const TOKEN_PATH_DELAY_MAX_SEC = 600; +export const DEFAULT_TOKEN_PATH_DURATION_SEC = 8; +export const DEFAULT_TOKEN_PATH_DELAY_SEC = 3; + +export function clamp01(v: number): number { + if (!Number.isFinite(v)) return 0; + return Math.max(0, Math.min(1, v)); +} + +export function distNorm(a: TokenPathPoint, b: TokenPathPoint): number { + const dx = a.nx - b.nx; + const dy = a.ny - b.ny; + return Math.hypot(dx, dy); +} + +export function createEmptyTokenPath(): TokenPath { + return { + points: [], + closed: false, + loopMode: 'once', + durationSec: DEFAULT_TOKEN_PATH_DURATION_SEC, + startMode: 'onEnter', + delaySec: DEFAULT_TOKEN_PATH_DELAY_SEC, + facingMode: 'tangentSmooth', + fixedRotationDeg: 0, + }; +} + +export function normalizeTokenPathFacingMode(raw: unknown): TokenPathFacingMode { + return raw === 'fixed' ? 'fixed' : 'tangentSmooth'; +} + +export function normalizeTokenPathLoopMode(raw: unknown, closed: boolean): TokenPathLoopMode { + if (raw === 'pingpong') return 'pingpong'; + if (raw === 'loop' && closed) return 'loop'; + return 'once'; +} + +export function normalizeTokenPathStartMode(raw: unknown): TokenPathStartMode { + return raw === 'delayed' ? 'delayed' : 'onEnter'; +} + +export function clampTokenPathDurationSec(raw: unknown): number { + const n = typeof raw === 'number' && Number.isFinite(raw) ? raw : DEFAULT_TOKEN_PATH_DURATION_SEC; + return Math.max(TOKEN_PATH_DURATION_MIN_SEC, Math.min(TOKEN_PATH_DURATION_MAX_SEC, n)); +} + +export function clampTokenPathDelaySec(raw: unknown): number { + const n = typeof raw === 'number' && Number.isFinite(raw) ? raw : DEFAULT_TOKEN_PATH_DELAY_SEC; + return Math.max(0, Math.min(TOKEN_PATH_DELAY_MAX_SEC, n)); +} + +export function normalizeTokenPathPoint(raw: unknown): TokenPathPoint | null { + if (!raw || typeof raw !== 'object') return null; + const obj = raw as Partial; + if (typeof obj.nx !== 'number' || !Number.isFinite(obj.nx)) return null; + if (typeof obj.ny !== 'number' || !Number.isFinite(obj.ny)) return null; + return { nx: clamp01(obj.nx), ny: clamp01(obj.ny) }; +} + +/** Drop points closer than TOKEN_PATH_MIN_POINT_DIST to the previous kept point. */ +export function filterMinPointDistance(points: readonly TokenPathPoint[]): TokenPathPoint[] { + if (points.length === 0) return []; + const out: TokenPathPoint[] = [{ ...points[0]! }]; + for (let i = 1; i < points.length; i += 1) { + const p = points[i]!; + const prev = out[out.length - 1]!; + if (distNorm(prev, p) + 1e-9 >= TOKEN_PATH_MIN_POINT_DIST) { + out.push({ ...p }); + } + } + return out; +} + +export function normalizeTokenPath(raw: unknown): TokenPath | null { + if (raw == null) return null; + if (!raw || typeof raw !== 'object') return null; + const obj = raw as Partial; + const points0 = Array.isArray(obj.points) + ? obj.points.map(normalizeTokenPathPoint).filter((p): p is TokenPathPoint => Boolean(p)) + : []; + const points = filterMinPointDistance(points0); + if (points.length < 2) return null; + const closed = Boolean(obj.closed); + const loopMode = normalizeTokenPathLoopMode(obj.loopMode, closed); + return { + points, + closed, + loopMode: closed ? loopMode : loopMode === 'loop' ? 'once' : loopMode, + durationSec: clampTokenPathDurationSec(obj.durationSec), + startMode: normalizeTokenPathStartMode(obj.startMode), + delaySec: clampTokenPathDelaySec(obj.delaySec), + facingMode: normalizeTokenPathFacingMode(obj.facingMode), + fixedRotationDeg: + typeof obj.fixedRotationDeg === 'number' && Number.isFinite(obj.fixedRotationDeg) + ? obj.fixedRotationDeg + : 0, + }; +} + +/** Points used for drawing/length: if closed, append first at end when not already equal. */ +export function tokenPathPolyline(path: TokenPath): TokenPathPoint[] { + if (path.points.length === 0) return []; + if (!path.closed) return path.points.map((p) => ({ ...p })); + const first = path.points[0]!; + const last = path.points[path.points.length - 1]!; + if (distNorm(first, last) < 1e-6) return path.points.map((p) => ({ ...p })); + return [...path.points.map((p) => ({ ...p })), { ...first }]; +} + +export function tokenPathTotalLength(path: TokenPath): number { + const pts = tokenPathPolyline(path); + let len = 0; + for (let i = 1; i < pts.length; i += 1) { + len += distNorm(pts[i - 1]!, pts[i]!); + } + return len; +} + +export type TokenPathSample = { + nx: number; + ny: number; + /** Degrees, CSS-style (0 = right? we use same as token rotationDeg — image up + rotate). */ + rotationDeg: number; + /** Distance along polyline [0, length]. */ + dist: number; +}; + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +function segmentAngleDeg(a: TokenPathPoint, b: TokenPathPoint): number { + // Screen y-down: atan2(dy, dx) with dy positive downward. + const deg = (Math.atan2(b.ny - a.ny, b.nx - a.nx) * 180) / Math.PI; + // Token art faces "up" (−Y); rotate so forward matches travel. + return deg + 90; +} + +function shortestAngleLerp(from: number, to: number, t: number): number { + let delta = ((to - from + 540) % 360) - 180; + return from + delta * t; +} + +/** + * Sample position/facing at distance `dist` along the polyline (clamped). + */ +export function sampleTokenPathAtDistance(path: TokenPath, dist: number): TokenPathSample | null { + const pts = tokenPathPolyline(path); + if (pts.length < 2) return null; + const total = tokenPathTotalLength(path); + if (total <= 1e-9) { + const p = pts[0]!; + return { + nx: p.nx, + ny: p.ny, + rotationDeg: path.facingMode === 'fixed' ? path.fixedRotationDeg : 0, + dist: 0, + }; + } + const d = Math.max(0, Math.min(total, dist)); + let walked = 0; + for (let i = 1; i < pts.length; i += 1) { + const a = pts[i - 1]!; + const b = pts[i]!; + const seg = distNorm(a, b); + if (seg <= 1e-9) continue; + if (walked + seg >= d - 1e-9) { + const t = (d - walked) / seg; + const nx = lerp(a.nx, b.nx, t); + const ny = lerp(a.ny, b.ny, t); + let rotationDeg = path.fixedRotationDeg; + if (path.facingMode === 'tangentSmooth') { + const ang = segmentAngleDeg(a, b); + // Blend with previous segment for smoother corners. + if (i >= 2) { + const prevA = pts[i - 2]!; + const prevAng = segmentAngleDeg(prevA, a); + rotationDeg = shortestAngleLerp(prevAng, ang, Math.min(1, t + 0.35)); + } else { + rotationDeg = ang; + } + } + return { nx, ny, rotationDeg, dist: d }; + } + walked += seg; + } + const last = pts[pts.length - 1]!; + const prev = pts[pts.length - 2]!; + return { + nx: last.nx, + ny: last.ny, + rotationDeg: + path.facingMode === 'fixed' ? path.fixedRotationDeg : segmentAngleDeg(prev, last), + dist: total, + }; +} + +/** + * Progress u in [0,1] over one durationSec pass (pingpong handled by caller via triangle wave). + */ +export function sampleTokenPathAtProgress(path: TokenPath, u: number): TokenPathSample | null { + const total = tokenPathTotalLength(path); + const t = Math.max(0, Math.min(1, u)); + return sampleTokenPathAtDistance(path, t * total); +} + +/** + * Nearest point on the polyline that lies at or ahead of `fromDist` along the route direction. + * Returns distance along path to that sample (for resume-after-drag). + */ +export function findNearestAheadDistance( + path: TokenPath, + nx: number, + ny: number, + fromDist: number, +): number { + const pts = tokenPathPolyline(path); + const total = tokenPathTotalLength(path); + if (pts.length < 2 || total <= 1e-9) return 0; + const start = Math.max(0, Math.min(total, fromDist)); + const samples = 64; + let bestDist = start; + let bestErr = Number.POSITIVE_INFINITY; + for (let i = 0; i <= samples; i += 1) { + const d = start + ((total - start) * i) / samples; + const s = sampleTokenPathAtDistance(path, d); + if (!s) continue; + const err = Math.hypot(s.nx - nx, s.ny - ny); + if (err < bestErr) { + bestErr = err; + bestDist = d; + } + } + // Also check remaining if pingpong will reverse — for once/loop, ahead is enough. + return bestDist; +} + +export function reverseTokenPathPoints(points: readonly TokenPathPoint[]): TokenPathPoint[] { + return [...points].reverse().map((p) => ({ ...p })); +} + +export function rotateTokenPathByCwSteps(path: TokenPath, steps: number): TokenPath { + const n = ((steps % 4) + 4) % 4; + if (n === 0) { + return { + ...path, + points: path.points.map((p) => ({ ...p })), + }; + } + // Import inline to avoid circular deps — callers use scenePreviewRotation. + // We duplicate the CW formula here for a self-contained module OR accept a rotator. + let points = path.points.map((p) => ({ ...p })); + for (let s = 0; s < n; s += 1) { + points = points.map((p) => ({ nx: clamp01(1 - p.ny), ny: clamp01(p.nx) })); + } + const fixedRotationDeg = path.fixedRotationDeg + n * 90; + return { ...path, points, fixedRotationDeg }; +} + +/** Try add a point; returns null if too close to the last point. */ +export function tryAppendPathPoint( + points: readonly TokenPathPoint[], + next: TokenPathPoint, +): TokenPathPoint[] | null { + const p = { nx: clamp01(next.nx), ny: clamp01(next.ny) }; + if (points.length === 0) return [p]; + const last = points[points.length - 1]!; + if (distNorm(last, p) < TOKEN_PATH_MIN_POINT_DIST) return null; + return [...points, p]; +} diff --git a/app/shared/types/tokenPathPlayback.test.ts b/app/shared/types/tokenPathPlayback.test.ts new file mode 100644 index 0000000..0f1eb78 --- /dev/null +++ b/app/shared/types/tokenPathPlayback.test.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { computePathPlaybackSample, resumeFromPose } from './tokenPathPlayback'; +import type { TokenPath } from './tokenPath'; +import type { TokenPathPlaybackEntry } from './tokenPathSession'; + +function path(): TokenPath { + return { + points: [ + { nx: 0, ny: 0.5 }, + { nx: 1, ny: 0.5 }, + ], + closed: false, + loopMode: 'once', + durationSec: 2, + startMode: 'onEnter', + delaySec: 0, + facingMode: 'tangentSmooth', + fixedRotationDeg: 0, + }; +} + +function entry(partial: Partial): TokenPathPlaybackEntry { + return { + kind: 'token', + placementId: 't1', + phase: 'moving', + segmentStartedAtMs: 0, + baseDist: 0, + direction: 1, + rejoinDist: null, + durationSec: 2, + pathLength: 1, + ...partial, + }; +} + +void test('once reaches end and marks done', () => { + const p = path(); + const r = computePathPlaybackSample({ + path: p, + entry: entry({ phase: 'moving', segmentStartedAtMs: 0 }), + nowMs: 2500, + }); + assert.ok(r); + assert.equal(r!.markDone, true); + assert.equal(r!.phase, 'done'); + assert.ok(Math.abs(r!.sample.nx - 1) < 1e-6); +}); + +void test('delay holds at start until delay elapses', () => { + const p = { ...path(), startMode: 'delayed' as const, delaySec: 1 }; + const early = computePathPlaybackSample({ + path: p, + entry: entry({ phase: 'delay', segmentStartedAtMs: 0 }), + nowMs: 400, + }); + assert.ok(early); + assert.equal(early!.phase, 'delay'); + assert.ok(Math.abs(early!.sample.nx) < 1e-6); + + const late = computePathPlaybackSample({ + path: p, + entry: entry({ phase: 'delay', segmentStartedAtMs: 0 }), + nowMs: 1500, + }); + assert.ok(late); + assert.equal(late!.phase, 'moving'); + assert.ok(late!.sample.nx > 0.2); +}); + +void test('stopped freezes at baseDist', () => { + const p = path(); + const r = computePathPlaybackSample({ + path: p, + entry: entry({ phase: 'stopped', baseDist: 0.25 }), + nowMs: 99999, + }); + assert.ok(r); + assert.equal(r!.phase, 'stopped'); + assert.ok(Math.abs(r!.sample.nx - 0.25) < 1e-6); +}); + +void test('pingpong reverses after end', () => { + const p = { ...path(), loopMode: 'pingpong' as const }; + const r = computePathPlaybackSample({ + path: p, + entry: entry({ phase: 'moving', durationSec: 2, pathLength: 1 }), + nowMs: 3000, // 1.5 path lengths → back toward start + }); + assert.ok(r); + assert.equal(r!.markDone, false); + assert.ok(r!.sample.nx < 0.6); +}); + +void test('resumeFromPose picks ahead distance', () => { + const p = path(); + const d = resumeFromPose(p, 0.4, 0.5, 0.1); + assert.ok(d >= 0.1); + assert.ok(d <= 1); +}); diff --git a/app/shared/types/tokenPathPlayback.ts b/app/shared/types/tokenPathPlayback.ts new file mode 100644 index 0000000..4e51fd5 --- /dev/null +++ b/app/shared/types/tokenPathPlayback.ts @@ -0,0 +1,113 @@ +/** + * Pure playback sampling for token paths (shared by control / presentation / editor preview). + */ + +import { + findNearestAheadDistance, + sampleTokenPathAtDistance, + tokenPathTotalLength, + type TokenPath, + type TokenPathSample, +} from './tokenPath'; +import type { TokenPathPlaybackEntry } from './tokenPathSession'; + +export function computePathPlaybackSample(args: { + path: TokenPath; + entry: TokenPathPlaybackEntry; + nowMs: number; +}): { sample: TokenPathSample; phase: TokenPathPlaybackEntry['phase']; markDone: boolean } | null { + const { path, entry, nowMs } = args; + const length = entry.pathLength > 1e-9 ? entry.pathLength : tokenPathTotalLength(path); + if (length <= 1e-9) return null; + const durationMs = Math.max(0.5, entry.durationSec) * 1000; + const speed = length / durationMs; + + if (entry.phase === 'stopped' || entry.phase === 'done') { + const sample = sampleTokenPathAtDistance(path, clampDist(entry.baseDist, length)); + if (!sample) return null; + return { sample, phase: entry.phase, markDone: false }; + } + + let elapsed = Math.max(0, nowMs - entry.segmentStartedAtMs); + + if (entry.phase === 'delay') { + const delayMs = Math.max(0, path.delaySec) * 1000; + if (elapsed < delayMs) { + const start = sampleTokenPathAtDistance(path, 0); + if (!start) return null; + return { sample: start, phase: 'delay', markDone: false }; + } + elapsed -= delayMs; + } + + // Moving (including post-delay). + let base = entry.baseDist; + let dir: 1 | -1 = entry.direction || 1; + let travel = speed * elapsed; + + if (entry.rejoinDist != null && Number.isFinite(entry.rejoinDist)) { + const target = clampDist(entry.rejoinDist, length); + const need = Math.abs(target - base); + if (travel < need) { + const dist = base + Math.sign(target - base || 1) * travel; + const sample = sampleTokenPathAtDistance(path, clampDist(dist, length)); + if (!sample) return null; + return { sample, phase: 'moving', markDone: false }; + } + travel -= need; + base = target; + } + + return advanceAlongPath({ + path, + length, + baseDist: base, + direction: dir, + travelDist: travel, + }); +} + +function clampDist(d: number, length: number): number { + return Math.max(0, Math.min(length, d)); +} + +function advanceAlongPath(args: { + path: TokenPath; + length: number; + baseDist: number; + direction: 1 | -1; + travelDist: number; +}): { sample: TokenPathSample; phase: TokenPathPlaybackEntry['phase']; markDone: boolean } | null { + const { path, length, baseDist, direction, travelDist } = args; + const loopMode = path.loopMode; + const closed = path.closed; + + let dist = baseDist + direction * travelDist; + let markDone = false; + + if (loopMode === 'pingpong') { + const period = Math.max(length * 2, 1e-9); + let d = ((dist % period) + period) % period; + if (d > length) d = period - d; + dist = d; + } else if (loopMode === 'loop' && closed) { + dist = ((dist % length) + length) % length; + } else { + // once (or loop without closed) + if (dist >= length) { + dist = length; + markDone = true; + } else if (dist <= 0) { + dist = 0; + markDone = true; + } + } + + const sample = sampleTokenPathAtDistance(path, dist); + if (!sample) return null; + return { sample, phase: markDone ? 'done' : 'moving', markDone }; +} + +export function resumeFromPose(path: TokenPath, nx: number, ny: number, fromDist: number): number { + return findNearestAheadDistance(path, nx, ny, fromDist); +} diff --git a/app/shared/types/tokenPathSession.ts b/app/shared/types/tokenPathSession.ts new file mode 100644 index 0000000..1c14631 --- /dev/null +++ b/app/shared/types/tokenPathSession.ts @@ -0,0 +1,77 @@ +/** Session runtime for token/NPC path playback and presentation visibility. */ + +export type TokenPathTargetKind = 'token' | 'npcToken'; + +export type TokenPathTargetRef = { + kind: TokenPathTargetKind; + placementId: string; +}; + +export function tokenPathKey(kind: TokenPathTargetKind, placementId: string): string { + return `${kind}:${placementId}`; +} + +export function parseTokenPathKey(key: string): TokenPathTargetRef | null { + const i = key.indexOf(':'); + if (i <= 0) return null; + const kind = key.slice(0, i); + const placementId = key.slice(i + 1); + if ((kind !== 'token' && kind !== 'npcToken') || !placementId) return null; + return { kind, placementId }; +} + +/** + * delay — waiting startMode delayed. + * moving — animating along path (or rejoining after drag). + * done — once finished at end. + * stopped — user stopped; drag allowed; can resume. + */ +export type TokenPathPlaybackPhase = 'delay' | 'moving' | 'done' | 'stopped'; + +export type TokenPathPlaybackEntry = { + kind: TokenPathTargetKind; + placementId: string; + phase: TokenPathPlaybackPhase; + /** Wall-clock ms when current moving/delay segment started (main Date.now). */ + segmentStartedAtMs: number; + /** Path distance at segment start (for moving). */ + baseDist: number; + direction: 1 | -1; + /** If set while moving: first travel to this dist (rejoin), then clear. */ + rejoinDist: number | null; + /** Snapshot of durationSec from path at start. */ + durationSec: number; + /** Snapshot of total polyline length at start. */ + pathLength: number; +}; + +export type TokenPathSessionState = { + revision: number; + /** Keys visible on presentation («Показать путь»). */ + presentationVisible: Record; + playback: Record; + /** Monotonic clock for renderers (updated on dispatch / heartbeats). */ + serverNowMs: number; +}; + +export type TokenPathSessionEvent = + | { kind: 'clear' } + | { kind: 'showPresentation'; target: TokenPathTargetRef } + | { kind: 'hidePresentation'; target: TokenPathTargetRef } + | { kind: 'stop'; target: TokenPathTargetRef; atDist?: number } + | { kind: 'resume'; target: TokenPathTargetRef; nx: number; ny: number; fromDist: number } + | { kind: 'resetToStart'; target: TokenPathTargetRef } + | { + kind: 'seedPlayback'; + entry: Omit & { segmentStartedAtMs?: number }; + } + | { kind: 'markDone'; target: TokenPathTargetRef }; + +export function emptyTokenPathSessionState(revision = 1): TokenPathSessionState { + return { + revision, + presentationVisible: {}, + playback: {}, + serverNowMs: Date.now(), + }; +} diff --git a/docs/token-path-spec-v1.md b/docs/token-path-spec-v1.md new file mode 100644 index 0000000..07a2254 --- /dev/null +++ b/docs/token-path-spec-v1.md @@ -0,0 +1,22 @@ +# ТЗ v1: анимация движения токенов (закрыто) + +## Scope +- Неигровые токены + НПС. Игроки — вне scope. +- Редактор сцены: ПКМ меню → «Указать движение» / «Удалить»; «Сбросить на старт пути». +- Одно Electron-окно редактора пути (фокус/смена токена, закрытие с scene editor). +- Путь: точки, min dist, правка, обратить, замкнуть (ПКМ на точке), once/pingpong/loop(closed), durationSec, onEnter|delayed+delaySec, facing tangentSmooth|fixed. +- Путь виден: редактор + пульт всегда; презентация — только после «Показать путь» (линия). +- Playback: stop → drag; resume → nearest ahead; once → stay at end. +- Поворот сцены ремапит points (+ fixedRotationDeg). + +## Модули +- `app/shared/types/tokenPath.ts` (+ tests) +- path на `SceneToken` / `SceneNpcToken` +- window `tokenPathEditor` +- overlays + session store playback + +## Статус +- [x] Модель + normalize + geometry + rotation helpers +- [x] Persist / remap wiring complete +- [x] Window + UI +- [x] Overlays + session playback + control menus diff --git a/package.json b/package.json index c1bae32..09ea452 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build:obfuscate": "node scripts/build.mjs --production --obfuscate", "lint": "eslint . --max-warnings 0", "typecheck": "tsc -p tsconfig.eslint.json --noEmit", - "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs", + "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs", "format": "prettier . --check", "format:write": "prettier . --write", "postinstall": "patch-package", diff --git a/vite.config.ts b/vite.config.ts index 626746c..82f27b6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -56,6 +56,7 @@ export default defineConfig(({ mode }) => { sceneDescription: path.resolve(__dirname, 'app/renderer/sceneDescription.html'), materials: path.resolve(__dirname, 'app/renderer/materials.html'), sceneEditor: path.resolve(__dirname, 'app/renderer/sceneEditor.html'), + tokenPathEditor: path.resolve(__dirname, 'app/renderer/tokenPathEditor.html'), npcsEditor: path.resolve(__dirname, 'app/renderer/npcsEditor.html'), npcs: path.resolve(__dirname, 'app/renderer/npcs.html'), },