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