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
+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()) {