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:
@@ -0,0 +1,173 @@
|
||||
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<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (drag?.pointerId !== e.pointerId) return;
|
||||
dragRef.current = null;
|
||||
onMove?.(String(placement.id), drag.lastNx, drag.lastNy);
|
||||
setLocalPos(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.token, editable ? styles.editable : ''].filter(Boolean).join(' ')}
|
||||
data-testid={`session-npc-token-${placement.id}`}
|
||||
style={{
|
||||
left: viewport.x + pos.nx * viewport.w,
|
||||
top: viewport.y + pos.ny * viewport.h,
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
}}
|
||||
onPointerDown={
|
||||
editable && onMove !== undefined
|
||||
? (e) => {
|
||||
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}
|
||||
>
|
||||
<PlayerTokenView
|
||||
name={npc.name}
|
||||
imageUrl={imageUrl}
|
||||
ringColor={npc.ringColor}
|
||||
imageOffset={npc.imageOffset}
|
||||
imageScale={npc.imageScale}
|
||||
sizePx={sizePx}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.layer}>
|
||||
{placements.map((placement) => {
|
||||
const npc = byId.get(placement.npcId);
|
||||
if (!npc) return null;
|
||||
const override = session?.byPlacementId[String(placement.id)];
|
||||
return (
|
||||
<NpcSprite
|
||||
key={placement.id}
|
||||
placement={placement}
|
||||
npc={npc}
|
||||
nx={override?.nx ?? placement.nx}
|
||||
ny={override?.ny ?? placement.ny}
|
||||
viewport={viewport}
|
||||
editable={editable}
|
||||
displayScale={displayScale}
|
||||
gridFit={gridFit}
|
||||
{...(onMove ? { onMove } : {})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user