Files
DndGamePlayer/app/renderer/shared/playerToken/ScenePlayerTokensOverlay.tsx
T
Ivan Fontosh cc50e64e21 feat(players): NPC disposition types and launch-with-players session tokens
Add Hostile/Neutral/Friendly ring types with session-only inactive overrides on control, plus launch-with-players flow and live player tokens on the map.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:11:01 +08:00

175 lines
5.2 KiB
TypeScript

import React, { useRef, useState } from 'react';
import type {
AppPlayer,
SceneGrid,
ScenePlayerTokensSessionState,
} from '../../../shared/types';
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers';
import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid';
import { usePlayerImageUrl } from './usePlayerImageUrl';
import { PlayerTokenView } from './PlayerTokenView';
import { useLiveDragBroadcast } from './useLiveDragBroadcast';
import styles from './SceneNpcTokensOverlay.module.css';
type Viewport = { x: number; y: number; w: number; h: number };
function PlayerSprite({
player,
nx,
ny,
sizeN,
viewport,
editable,
displayScale,
gridFit,
onMove,
}: {
player: AppPlayer;
nx: number;
ny: number;
sizeN: number;
viewport: Viewport;
editable: boolean;
displayScale: number;
gridFit: number;
onMove?: (playerId: string, nx: number, ny: number) => void;
}) {
const imageUrl = usePlayerImageUrl(player.id);
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 { schedule, flush } = useLiveDragBroadcast(onMove, String(player.id));
const pos = localPos ?? { nx, ny };
const minDim = Math.min(viewport.w, viewport.h);
const sizePx = Math.max(16, 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;
flush(drag.lastNx, drag.lastNy);
setLocalPos(null);
};
return (
<div
className={[styles.token, editable ? styles.editable : ''].filter(Boolean).join(' ')}
data-testid={`session-player-token-${player.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 });
schedule(drag.lastNx, drag.lastNy);
}
: undefined
}
onPointerUp={end}
onPointerCancel={end}
>
<PlayerTokenView
name={player.name}
imageUrl={imageUrl}
ringColor={player.ringColor}
imageOffset={player.imageOffset}
imageScale={player.imageScale}
sizePx={sizePx}
/>
</div>
);
}
export function ScenePlayerTokensOverlay({
library,
session,
displayScale = DEFAULT_NPC_TOKEN_SESSION_SCALE,
viewport,
grid = null,
editable = false,
onMove,
}: {
library: readonly AppPlayer[];
session: ScenePlayerTokensSessionState | null;
/** Общий масштаб с НПС-токенами. */
displayScale?: number;
viewport: Viewport | null;
grid?: SceneGrid | null;
editable?: boolean;
onMove?: (playerId: string, nx: number, ny: number) => void;
}) {
if (!viewport || !session?.visible) return null;
const byId = new Map(library.map((p) => [String(p.id), p]));
const gridFit = sceneGridTokenFitFactor(grid);
return (
<div className={styles.layer}>
{session.selectedPlayerIds.map((playerId) => {
const player = byId.get(playerId);
const placement = session.byPlayerId[playerId];
if (!player || !placement) return null;
return (
<PlayerSprite
key={playerId}
player={player}
nx={placement.nx}
ny={placement.ny}
sizeN={placement.sizeN}
viewport={viewport}
editable={editable}
displayScale={displayScale}
gridFit={gridFit}
{...(onMove ? { onMove } : {})}
/>
);
})}
</div>
);
}