import React, { useCallback, useEffect, useRef } from 'react'; import type { PlayerImageOffset } from '../../../shared/types/appPlayers'; import { clampPlayerImageOffset, clampPlayerImageScale, DEFAULT_PLAYER_IMAGE_SCALE, DEFAULT_PLAYER_RING_COLOR, PLAYER_IMAGE_SCALE_STEP, } from '../../../shared/types/appPlayers'; import styles from './PlayerTokenView.module.css'; export type PlayerTokenViewProps = { name: string; imageUrl: string | null; ringColor?: string; imageOffset?: PlayerImageOffset; imageScale?: number; /** Размер круга в px (для превью в модалке). На сцене задавайте через CSS width/height родителя. */ sizePx?: number; /** Разрешить drag-pan аватара внутри круга и zoom колесом. */ panEnabled?: boolean; onImageOffsetChange?: (offset: PlayerImageOffset) => void; onImageScaleChange?: (scale: number) => void; className?: string; /** Без подписи имени (компактный маркер). */ hideName?: boolean; 'data-testid'?: string; }; export function PlayerTokenView({ name, imageUrl, ringColor = DEFAULT_PLAYER_RING_COLOR, imageOffset, imageScale, sizePx = 160, panEnabled = false, onImageOffsetChange, onImageScaleChange, className, hideName = false, 'data-testid': testId, }: PlayerTokenViewProps) { const offset = clampPlayerImageOffset(imageOffset); const scale = clampPlayerImageScale(imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE); const circleRef = useRef(null); const scaleRef = useRef(scale); scaleRef.current = scale; const onImageScaleChangeRef = useRef(onImageScaleChange); onImageScaleChangeRef.current = onImageScaleChange; const dragRef = useRef<{ pointerId: number; startX: number; startY: number; origX: number; origY: number; } | null>(null); const onPointerDown = useCallback( (e: React.PointerEvent) => { if (!panEnabled || !onImageOffsetChange) return; e.preventDefault(); e.stopPropagation(); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); dragRef.current = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, origX: offset.x, origY: offset.y, }; }, [offset.x, offset.y, onImageOffsetChange, panEnabled], ); const onPointerMove = useCallback( (e: React.PointerEvent) => { const d = dragRef.current; if (!d || d.pointerId !== e.pointerId || !onImageOffsetChange) return; const dx = (e.clientX - d.startX) / Math.max(1, sizePx); const dy = (e.clientY - d.startY) / Math.max(1, sizePx); onImageOffsetChange(clampPlayerImageOffset({ x: d.origX + dx, y: d.origY + dy })); }, [onImageOffsetChange, sizePx], ); const onPointerUp = useCallback((e: React.PointerEvent) => { const d = dragRef.current; if (!d || d.pointerId !== e.pointerId) return; dragRef.current = null; try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* ignore */ } }, []); useEffect(() => { const el = circleRef.current; if (!el || !panEnabled) return; const onWheel = (e: WheelEvent) => { const cb = onImageScaleChangeRef.current; if (!cb) return; e.preventDefault(); e.stopPropagation(); const direction = e.deltaY < 0 ? 1 : -1; cb(clampPlayerImageScale(scaleRef.current + direction * PLAYER_IMAGE_SCALE_STEP)); }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, [panEnabled]); const rootClass = [styles.root, className ?? ''].filter(Boolean).join(' '); const nameFontPx = Math.max(9, Math.min(13, Math.round(sizePx * 0.16))); const nameGapPx = Math.max(2, Math.min(8, Math.round(sizePx * 0.06))); return (
{imageUrl ? ( ) : (
)}
{!hideName ? (
{name || '—'}
) : null}
); }