feat(control): spawn session NPCs and resize tokens in preview
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,12 +5,15 @@ import type {
|
||||
SceneGrid,
|
||||
ScenePlayerTokensSessionState,
|
||||
} from '../../../shared/types';
|
||||
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers';
|
||||
import {
|
||||
clampSceneNpcTokenSizeN,
|
||||
DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
} from '../../../shared/types/appPlayers';
|
||||
import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid';
|
||||
import { usePlayerImageUrl } from './usePlayerImageUrl';
|
||||
|
||||
import { PlayerTokenView } from './PlayerTokenView';
|
||||
import { useLiveDragBroadcast } from './useLiveDragBroadcast';
|
||||
import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
|
||||
import styles from './SceneNpcTokensOverlay.module.css';
|
||||
|
||||
type Viewport = { x: number; y: number; w: number; h: number };
|
||||
@@ -25,6 +28,8 @@ function PlayerSprite({
|
||||
displayScale,
|
||||
gridFit,
|
||||
onMove,
|
||||
onResize,
|
||||
resizeTitle,
|
||||
snapNorm,
|
||||
}: {
|
||||
player: AppPlayer;
|
||||
@@ -36,10 +41,13 @@ function PlayerSprite({
|
||||
displayScale: number;
|
||||
gridFit: number;
|
||||
onMove?: (playerId: string, nx: number, ny: number) => void;
|
||||
onResize?: (playerId: string, sizeN: number) => void;
|
||||
resizeTitle?: string;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
}) {
|
||||
const imageUrl = usePlayerImageUrl(player.id);
|
||||
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
||||
const [localSize, setLocalSize] = useState<number | null>(null);
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startNx: number;
|
||||
@@ -49,14 +57,25 @@ function PlayerSprite({
|
||||
lastNx: number;
|
||||
lastNy: number;
|
||||
} | null>(null);
|
||||
const resizeRef = useRef<{
|
||||
pointerId: number;
|
||||
startSize: number;
|
||||
startDist: number;
|
||||
lastSize: number;
|
||||
} | null>(null);
|
||||
const { schedule, flush } = useLiveDragBroadcast(onMove, String(player.id));
|
||||
const { schedule: scheduleResize, flush: flushResize } = useLiveResizeBroadcast(
|
||||
onResize,
|
||||
String(player.id),
|
||||
);
|
||||
const pos = localPos ?? { nx, ny };
|
||||
const shownSizeN = localSize ?? sizeN;
|
||||
const minDim = Math.min(viewport.w, viewport.h);
|
||||
const sizePx = Math.max(16, sizeN * gridFit * displayScale * minDim);
|
||||
const sizePx = Math.max(16, shownSizeN * gridFit * displayScale * minDim);
|
||||
const canResize = editable && onResize !== undefined;
|
||||
|
||||
const point = (e: React.PointerEvent) => {
|
||||
const host = e.currentTarget.parentElement;
|
||||
const rect = host?.getBoundingClientRect();
|
||||
const point = (e: React.PointerEvent, host?: HTMLElement | null) => {
|
||||
const rect = (host ?? e.currentTarget.parentElement)?.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.max(0, Math.min(1, (e.clientX - ((rect?.left ?? 0) + viewport.x)) / Math.max(1, viewport.w))),
|
||||
y: Math.max(0, Math.min(1, (e.clientY - ((rect?.top ?? 0) + viewport.y)) / Math.max(1, viewport.h))),
|
||||
@@ -71,6 +90,14 @@ function PlayerSprite({
|
||||
setLocalPos(null);
|
||||
};
|
||||
|
||||
const endResize = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const d = resizeRef.current;
|
||||
if (d?.pointerId !== e.pointerId) return;
|
||||
resizeRef.current = null;
|
||||
flushResize(d.lastSize);
|
||||
setLocalSize(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.token, editable ? styles.editable : ''].filter(Boolean).join(' ')}
|
||||
@@ -128,6 +155,41 @@ function PlayerSprite({
|
||||
imageScale={player.imageScale}
|
||||
sizePx={sizePx}
|
||||
/>
|
||||
{canResize ? (
|
||||
<div
|
||||
className={styles.resizeHandle}
|
||||
title={resizeTitle}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const host = e.currentTarget.closest('[data-token-layer="1"]') as HTMLElement | null;
|
||||
const p = point(e, host);
|
||||
const dist = Math.max(1e-4, Math.hypot(p.x - pos.nx, p.y - pos.ny));
|
||||
resizeRef.current = {
|
||||
pointerId: e.pointerId,
|
||||
startSize: shownSizeN,
|
||||
startDist: dist,
|
||||
lastSize: shownSizeN,
|
||||
};
|
||||
setLocalSize(shownSizeN);
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const d = resizeRef.current;
|
||||
if (d?.pointerId !== e.pointerId) return;
|
||||
const host = e.currentTarget.closest('[data-token-layer="1"]') as HTMLElement | null;
|
||||
const p = point(e, host);
|
||||
const dist = Math.max(1e-4, Math.hypot(p.x - pos.nx, p.y - pos.ny));
|
||||
const next = clampSceneNpcTokenSizeN(d.startSize * (dist / d.startDist));
|
||||
d.lastSize = next;
|
||||
setLocalSize(next);
|
||||
scheduleResize(next);
|
||||
}}
|
||||
onPointerUp={endResize}
|
||||
onPointerCancel={endResize}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -140,6 +202,8 @@ export function ScenePlayerTokensOverlay({
|
||||
grid = null,
|
||||
editable = false,
|
||||
onMove,
|
||||
onResize,
|
||||
resizeTitle,
|
||||
snapNorm,
|
||||
}: {
|
||||
library: readonly AppPlayer[];
|
||||
@@ -150,13 +214,15 @@ export function ScenePlayerTokensOverlay({
|
||||
grid?: SceneGrid | null;
|
||||
editable?: boolean;
|
||||
onMove?: (playerId: string, nx: number, ny: number) => void;
|
||||
onResize?: (playerId: string, sizeN: number) => void;
|
||||
resizeTitle?: string;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
}) {
|
||||
if (!viewport || !session?.visible) return null;
|
||||
const byId = new Map(library.map((p) => [String(p.id), p]));
|
||||
const gridFit = sceneGridTokenFitFactor(grid);
|
||||
return (
|
||||
<div className={styles.layer}>
|
||||
<div className={styles.layer} data-token-layer="1">
|
||||
{session.selectedPlayerIds.map((playerId) => {
|
||||
const player = byId.get(playerId);
|
||||
const placement = session.byPlayerId[playerId];
|
||||
@@ -173,6 +239,8 @@ export function ScenePlayerTokensOverlay({
|
||||
displayScale={displayScale}
|
||||
gridFit={gridFit}
|
||||
{...(onMove ? { onMove } : {})}
|
||||
{...(onResize ? { onResize } : {})}
|
||||
{...(resizeTitle ? { resizeTitle } : {})}
|
||||
{...(snapNorm ? { snapNorm } : {})}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user