import React, { useRef, useState } from 'react'; import type { AppPlayer, SceneGrid, ScenePlayerTokensSessionState, } from '../../../shared/types'; import { 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 styles from './SceneNpcTokensOverlay.module.css'; type Viewport = { x: number; y: number; w: number; h: number }; function PlayerSprite({ player, nx, ny, sizeN, viewport, editable, displayScale, gridFit, onMove, snapNorm, }: { player: AppPlayer; nx: number; ny: number; sizeN: number; viewport: Viewport; editable: boolean; displayScale: number; gridFit: number; onMove?: (playerId: string, nx: number, ny: number) => void; 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 dragRef = useRef<{ pointerId: number; startNx: number; startNy: number; pointerNx: number; pointerNy: number; lastNx: number; lastNy: number; } | null>(null); const { schedule, flush } = useLiveDragBroadcast(onMove, String(player.id)); const pos = localPos ?? { nx, ny }; const minDim = Math.min(viewport.w, viewport.h); const sizePx = Math.max(16, sizeN * gridFit * displayScale * minDim); const point = (e: React.PointerEvent) => { const host = e.currentTarget.parentElement; const rect = host?.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))), }; }; const end = (e: React.PointerEvent) => { const drag = dragRef.current; if (drag?.pointerId !== e.pointerId) return; dragRef.current = null; flush(drag.lastNx, drag.lastNy); setLocalPos(null); }; return (
{ if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); const p = point(e); dragRef.current = { pointerId: e.pointerId, startNx: pos.nx, startNy: pos.ny, pointerNx: p.x, pointerNy: p.y, lastNx: pos.nx, lastNy: pos.ny, }; e.currentTarget.setPointerCapture(e.pointerId); } : undefined } onPointerMove={ editable && onMove ? (e) => { const drag = dragRef.current; if (drag?.pointerId !== e.pointerId) return; const p = point(e); const rawNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx)); const rawNy = Math.max(0, Math.min(1, drag.startNy + p.y - drag.pointerNy)); const snapped = snapNorm ? snapNorm(rawNx, rawNy) : { nx: rawNx, ny: rawNy }; drag.lastNx = snapped.nx; drag.lastNy = snapped.ny; setLocalPos({ nx: drag.lastNx, ny: drag.lastNy }); schedule(drag.lastNx, drag.lastNy); } : undefined } onPointerUp={end} onPointerCancel={end} >
); } export function ScenePlayerTokensOverlay({ library, session, displayScale = DEFAULT_NPC_TOKEN_SESSION_SCALE, viewport, grid = null, editable = false, onMove, snapNorm, }: { library: readonly AppPlayer[]; session: ScenePlayerTokensSessionState | null; /** Общий масштаб с НПС-токенами. */ displayScale?: number; viewport: Viewport | null; grid?: SceneGrid | null; editable?: boolean; onMove?: (playerId: string, nx: number, ny: number) => void; 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 (
{session.selectedPlayerIds.map((playerId) => { const player = byId.get(playerId); const placement = session.byPlayerId[playerId]; if (!player || !placement) return null; return ( ); })}
); }