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:
@@ -7,6 +7,12 @@ import styles from './SceneTokensOverlay.module.css';
|
||||
|
||||
type Viewport = { x: number; y: number; w: number; h: number };
|
||||
|
||||
export type TokenPoseOverride = {
|
||||
nx: number;
|
||||
ny: number;
|
||||
rotationDeg?: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
placements: readonly SceneToken[];
|
||||
library: readonly AppToken[];
|
||||
@@ -16,24 +22,35 @@ type Props = {
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
/** Snap во время drag (пульт, привязка к сетке). */
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
onContextMenu?: (e: React.MouseEvent, placement: SceneToken) => void;
|
||||
/** Live path playback / other pose overrides by placement id. */
|
||||
poseOverrides?: Record<string, TokenPoseOverride> | null;
|
||||
/** Per-placement drag lock (e.g. while path is animating). Default: all editable. */
|
||||
dragEnabledById?: Record<string, boolean> | null;
|
||||
};
|
||||
|
||||
function TokenSprite({
|
||||
placement,
|
||||
nx,
|
||||
ny,
|
||||
rotationDeg,
|
||||
viewport,
|
||||
editable,
|
||||
dragEnabled,
|
||||
onMove,
|
||||
snapNorm,
|
||||
onContextMenu,
|
||||
}: {
|
||||
placement: SceneToken;
|
||||
nx: number;
|
||||
ny: number;
|
||||
rotationDeg: number;
|
||||
viewport: Viewport;
|
||||
editable: boolean;
|
||||
dragEnabled: boolean;
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
onContextMenu?: (e: React.MouseEvent, placement: SceneToken) => void;
|
||||
}) {
|
||||
const url = useTokenImageUrl(placement.tokenId);
|
||||
const dragRef = useRef<{
|
||||
@@ -66,6 +83,8 @@ function TokenSprite({
|
||||
const sizePx = Math.max(16, placement.sizeN * minDim);
|
||||
const left = viewport.x + posNx * viewport.w;
|
||||
const top = viewport.y + posNy * viewport.h;
|
||||
const canDrag = editable && dragEnabled && Boolean(onMove);
|
||||
const interactive = canDrag || Boolean(onContextMenu);
|
||||
|
||||
const hostToNorm = (clientX: number, clientY: number, host: HTMLElement) => {
|
||||
const r = host.getBoundingClientRect();
|
||||
@@ -85,7 +104,6 @@ function TokenSprite({
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = 0;
|
||||
}
|
||||
// Финальный commit в session store — один раз на отпускание.
|
||||
onMove?.(String(placement.id), d.lastNx, d.lastNy);
|
||||
setLocalPos({ nx: d.lastNx, ny: d.lastNy });
|
||||
try {
|
||||
@@ -97,16 +115,31 @@ function TokenSprite({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.token, editable ? styles.tokenEditable : ''].filter(Boolean).join(' ')}
|
||||
className={[
|
||||
styles.token,
|
||||
canDrag ? styles.tokenEditable : '',
|
||||
interactive && !canDrag ? styles.tokenInteractive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
transform: `translate(-50%, -50%) rotate(${String(placement.rotationDeg)}deg)`,
|
||||
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
|
||||
}}
|
||||
onContextMenu={
|
||||
onContextMenu
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, placement);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerDown={
|
||||
editable && onMove
|
||||
canDrag
|
||||
? (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
@@ -128,7 +161,7 @@ function TokenSprite({
|
||||
: undefined
|
||||
}
|
||||
onPointerMove={
|
||||
editable && onMove
|
||||
canDrag
|
||||
? (e) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.pointerId !== e.pointerId) return;
|
||||
@@ -169,6 +202,9 @@ export function SceneTokensOverlay({
|
||||
editable = false,
|
||||
onMove,
|
||||
snapNorm,
|
||||
onContextMenu,
|
||||
poseOverrides = null,
|
||||
dragEnabledById = null,
|
||||
}: Props) {
|
||||
if (!viewport || placements.length === 0) return null;
|
||||
const known = new Set(library.map((t) => t.id));
|
||||
@@ -179,19 +215,25 @@ export function SceneTokensOverlay({
|
||||
.filter((p) => known.has(p.tokenId))
|
||||
.map((placement) => {
|
||||
const key = String(placement.id);
|
||||
const pose = poseOverrides?.[key];
|
||||
const override = session?.byPlacementId[key] ?? session?.byPlacementId[placement.id];
|
||||
const nx = override?.nx ?? placement.nx;
|
||||
const ny = override?.ny ?? placement.ny;
|
||||
const nx = pose?.nx ?? override?.nx ?? placement.nx;
|
||||
const ny = pose?.ny ?? override?.ny ?? placement.ny;
|
||||
const rotationDeg = pose?.rotationDeg ?? placement.rotationDeg;
|
||||
const dragEnabled = dragEnabledById ? Boolean(dragEnabledById[key]) : true;
|
||||
return (
|
||||
<TokenSprite
|
||||
key={key}
|
||||
placement={placement}
|
||||
nx={nx}
|
||||
ny={ny}
|
||||
rotationDeg={rotationDeg}
|
||||
viewport={viewport}
|
||||
editable={editable}
|
||||
dragEnabled={dragEnabled}
|
||||
{...(onMove ? { onMove } : {})}
|
||||
{...(snapNorm ? { snapNorm } : {})}
|
||||
{...(onContextMenu ? { onContextMenu } : {})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user