Files
DndGamePlayer/app/main/tokens/tokenPathSessionStore.ts
T
Ivan Fontosh 1ab6ffd593 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>
2026-08-14 08:48:41 +08:00

172 lines
5.4 KiB
TypeScript

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 };