101f595bac
Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
160 lines
5.0 KiB
TypeScript
160 lines
5.0 KiB
TypeScript
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<HTMLDivElement | null>(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 (
|
|
<div
|
|
className={rootClass}
|
|
style={{ width: sizePx, gap: hideName ? undefined : nameGapPx }}
|
|
data-testid={testId}
|
|
>
|
|
<div
|
|
ref={circleRef}
|
|
className={[styles.circle, panEnabled ? styles.circlePan : ''].filter(Boolean).join(' ')}
|
|
style={{
|
|
width: sizePx,
|
|
height: sizePx,
|
|
borderColor: ringColor,
|
|
}}
|
|
onPointerDown={onPointerDown}
|
|
onPointerMove={onPointerMove}
|
|
onPointerUp={onPointerUp}
|
|
onPointerCancel={onPointerUp}
|
|
>
|
|
{imageUrl ? (
|
|
<img
|
|
className={styles.avatar}
|
|
src={imageUrl}
|
|
alt=""
|
|
draggable={false}
|
|
style={{
|
|
transform: `translate(-50%, -50%) translate(${String(offset.x * 100)}%, ${String(offset.y * 100)}%) scale(${String(scale)})`,
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className={styles.avatarEmpty} />
|
|
)}
|
|
</div>
|
|
{!hideName ? (
|
|
<div className={styles.namePlate} title={name} style={{ fontSize: nameFontPx }}>
|
|
{name || '—'}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|