import React, { useRef, useState } from 'react'; import type { ProjectNpc, SceneGrid, SceneNpcToken, SceneNpcTokensSessionState, } from '../../../shared/types'; import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers'; import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid'; import { useAssetUrl } from '../useAssetImageUrl'; import { PlayerTokenView } from './PlayerTokenView'; import styles from './SceneNpcTokensOverlay.module.css'; type Viewport = { x: number; y: number; w: number; h: number }; function NpcSprite({ placement, npc, nx, ny, viewport, editable, displayScale, gridFit, onMove, }: { placement: SceneNpcToken; npc: ProjectNpc; nx: number; ny: number; viewport: Viewport; editable: boolean; displayScale: number; gridFit: number; onMove?: (placementId: string, nx: number, ny: number) => void; }) { const imageUrl = useAssetUrl(npc.avatarAssetId); 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 pos = localPos ?? { nx, ny }; const minDim = Math.min(viewport.w, viewport.h); const sizePx = Math.max(16, placement.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; onMove?.(String(placement.id), 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); drag.lastNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx)); drag.lastNy = Math.max(0, Math.min(1, drag.startNy + p.y - drag.pointerNy)); setLocalPos({ nx: drag.lastNx, ny: drag.lastNy }); } : undefined } onPointerUp={end} onPointerCancel={end} >
); } export function SceneNpcTokensOverlay({ placements, library, session, viewport, grid = null, editable = false, onMove, }: { placements: readonly SceneNpcToken[]; library: readonly ProjectNpc[]; session: SceneNpcTokensSessionState | null; viewport: Viewport | null; /** Для гекса — вписанный диаметр ячейки, не описанный. */ grid?: SceneGrid | null; editable?: boolean; onMove?: (placementId: string, nx: number, ny: number) => void; }) { if (!viewport) return null; const byId = new Map(library.map((npc) => [npc.id, npc])); const displayScale = session?.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE; const gridFit = sceneGridTokenFitFactor(grid); return (
{placements.map((placement) => { const npc = byId.get(placement.npcId); if (!npc) return null; const override = session?.byPlacementId[String(placement.id)]; return ( ); })}
); }