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:
@@ -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 }) => {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user