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 }) => {
+171
View File
@@ -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<Omit<TokenPathSessionState, 'revision' | 'serverNowMs'>>,
): 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 };
+80
View File
@@ -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<WindowKind, BrowserWindow>();
@@ -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()) {