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
@@ -37,6 +37,7 @@ function NpcSprite({
ny,
viewport,
editable,
dragEnabled,
displayScale,
gridFit,
disposition,
@@ -51,6 +52,7 @@ function NpcSprite({
ny: number;
viewport: Viewport;
editable: boolean;
dragEnabled: boolean;
displayScale: number;
gridFit: number;
disposition: NpcDisposition;
@@ -74,6 +76,7 @@ function NpcSprite({
const pos = localPos ?? { nx, ny };
const minDim = Math.min(viewport.w, viewport.h);
const sizePx = Math.max(16, placement.sizeN * gridFit * displayScale * minDim);
const canDrag = editable && dragEnabled && onMove !== undefined;
const point = (e: React.PointerEvent) => {
const host = e.currentTarget.parentElement;
@@ -94,13 +97,14 @@ function NpcSprite({
return (
<div
className={[styles.token, editable ? styles.editable : ''].filter(Boolean).join(' ')}
className={[styles.token, canDrag ? styles.editable : ''].filter(Boolean).join(' ')}
data-testid={`session-npc-token-${placement.id}`}
style={{
left: viewport.x + pos.nx * viewport.w,
top: viewport.y + pos.ny * viewport.h,
width: sizePx,
height: sizePx,
pointerEvents: canDrag || onContextMenu ? 'auto' : undefined,
}}
onContextMenu={
onContextMenu
@@ -112,7 +116,7 @@ function NpcSprite({
: undefined
}
onPointerDown={
editable && onMove !== undefined
canDrag
? (e) => {
if (e.button !== 0) return;
e.preventDefault();
@@ -132,7 +136,7 @@ function NpcSprite({
: undefined
}
onPointerMove={
editable && onMove
canDrag
? (e) => {
const drag = dragRef.current;
if (drag?.pointerId !== e.pointerId) return;
@@ -173,6 +177,8 @@ export function SceneNpcTokensOverlay({
onMove,
onContextMenu,
snapNorm,
poseOverrides = null,
dragEnabledById = null,
}: {
placements: readonly SceneNpcToken[];
library: readonly ProjectNpc[];
@@ -184,6 +190,8 @@ export function SceneNpcTokensOverlay({
onMove?: (placementId: string, nx: number, ny: number) => void;
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
poseOverrides?: Record<string, { nx: number; ny: number }> | null;
dragEnabledById?: Record<string, boolean> | null;
}) {
if (!viewport) return null;
const byId = new Map(library.map((npc) => [npc.id, npc]));
@@ -194,18 +202,22 @@ export function SceneNpcTokensOverlay({
{placements.map((placement) => {
const npc = byId.get(placement.npcId);
if (!npc) return null;
const override = session?.byPlacementId[String(placement.id)];
const key = String(placement.id);
const pose = poseOverrides?.[key];
const override = session?.byPlacementId[key];
const disposition = resolveNpcTokenDisposition(placement, npc, override?.disposition);
const inactive = Boolean(override?.inactive);
const dragEnabled = dragEnabledById ? Boolean(dragEnabledById[key]) : true;
return (
<NpcSprite
key={placement.id}
placement={placement}
npc={npc}
nx={override?.nx ?? placement.nx}
ny={override?.ny ?? placement.ny}
nx={pose?.nx ?? override?.nx ?? placement.nx}
ny={pose?.ny ?? override?.ny ?? placement.ny}
viewport={viewport}
editable={editable}
dragEnabled={dragEnabled}
displayScale={displayScale}
gridFit={gridFit}
disposition={disposition}