feat(players): app Players library and circular NPC tokens on scenes

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>
This commit is contained in:
Ivan Fontosh
2026-07-30 13:38:35 +08:00
parent a3a03eb9e3
commit 101f595bac
57 changed files with 4176 additions and 317 deletions
@@ -0,0 +1,159 @@
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>
);
}