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
+76
View File
@@ -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 }) => {