Files
DndGamePlayer/app/renderer/shared/playerToken/SceneNpcTokensOverlay.tsx
T
2026-08-18 14:42:06 +08:00

302 lines
9.9 KiB
TypeScript

import React, { useRef, useState } from 'react';
import type {
NpcDisposition,
ProjectNpc,
SceneGrid,
SceneNpcToken,
SceneNpcTokensSessionState,
} from '../../../shared/types';
import { clampSceneNpcTokenSizeN, DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers';
import {
normalizeNpcDisposition,
npcDispositionRingColor,
} from '../../../shared/types/npcDisposition';
import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid';
import { useAssetUrl } from '../useAssetImageUrl';
import { PlayerTokenView } from './PlayerTokenView';
import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
import styles from './SceneNpcTokensOverlay.module.css';
type Viewport = { x: number; y: number; w: number; h: number };
export function resolveNpcTokenDisposition(
placement: SceneNpcToken,
npc: ProjectNpc,
sessionDisposition?: NpcDisposition,
): NpcDisposition {
if (sessionDisposition) return normalizeNpcDisposition(sessionDisposition);
return normalizeNpcDisposition(placement.disposition ?? npc.disposition);
}
function NpcSprite({
placement,
npc,
nx,
ny,
sizeN,
viewport,
editable,
dragEnabled,
displayScale,
gridFit,
disposition,
inactive,
onMove,
onResize,
resizeTitle,
onContextMenu,
snapNorm,
}: {
placement: SceneNpcToken;
npc: ProjectNpc;
nx: number;
ny: number;
sizeN: number;
viewport: Viewport;
editable: boolean;
dragEnabled: boolean;
displayScale: number;
gridFit: number;
disposition: NpcDisposition;
inactive: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
onResize?: (placementId: string, sizeN: number) => void;
resizeTitle?: string;
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
}) {
const imageUrl = useAssetUrl(npc.avatarAssetId);
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
const [localSize, setLocalSize] = useState<number | null>(null);
const dragRef = useRef<{
pointerId: number;
startNx: number;
startNy: number;
pointerNx: number;
pointerNy: number;
lastNx: number;
lastNy: number;
} | null>(null);
const resizeRef = useRef<{
pointerId: number;
startSize: number;
startDist: number;
lastSize: number;
} | null>(null);
const { schedule, flush } = useLiveDragBroadcast(onMove, String(placement.id));
const { schedule: scheduleResize, flush: flushResize } = useLiveResizeBroadcast(
onResize,
String(placement.id),
);
const pos = localPos ?? { nx, ny };
const shownSizeN = localSize ?? sizeN;
const minDim = Math.min(viewport.w, viewport.h);
const sizePx = Math.max(16, shownSizeN * gridFit * displayScale * minDim);
const canDrag = editable && dragEnabled && onMove !== undefined;
const canResize = editable && dragEnabled && onResize !== undefined;
const point = (e: React.PointerEvent, host?: HTMLElement | null) => {
const rect = (host ?? e.currentTarget.parentElement)?.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);
};
const endResize = (e: React.PointerEvent<HTMLDivElement>) => {
const d = resizeRef.current;
if (d?.pointerId !== e.pointerId) return;
resizeRef.current = null;
flushResize(d.lastSize);
setLocalSize(null);
};
return (
<div
className={[styles.token, canDrag ? 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,
pointerEvents: canDrag || canResize || onContextMenu ? 'auto' : undefined,
}}
onContextMenu={
onContextMenu
? (e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, placement);
}
: undefined
}
onPointerDown={
canDrag
? (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={
canDrag
? (e) => {
const drag = dragRef.current;
if (drag?.pointerId !== e.pointerId) return;
const p = point(e);
const rawNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx));
const rawNy = Math.max(0, Math.min(1, drag.startNy + p.y - drag.pointerNy));
const snapped = snapNorm ? snapNorm(rawNx, rawNy) : { nx: rawNx, ny: rawNy };
drag.lastNx = snapped.nx;
drag.lastNy = snapped.ny;
setLocalPos({ nx: drag.lastNx, ny: drag.lastNy });
schedule(drag.lastNx, drag.lastNy);
}
: undefined
}
onPointerUp={end}
onPointerCancel={end}
>
<PlayerTokenView
name={npc.name}
imageUrl={imageUrl}
ringColor={npcDispositionRingColor(disposition, inactive)}
imageOffset={npc.imageOffset}
imageScale={npc.imageScale}
sizePx={sizePx}
inactive={inactive}
/>
{canResize ? (
<div
className={styles.resizeHandle}
title={resizeTitle}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const host = e.currentTarget.closest('[data-token-layer="1"]') as HTMLElement | null;
const p = point(e, host);
const dist = Math.max(1e-4, Math.hypot(p.x - pos.nx, p.y - pos.ny));
resizeRef.current = {
pointerId: e.pointerId,
startSize: shownSizeN,
startDist: dist,
lastSize: shownSizeN,
};
setLocalSize(shownSizeN);
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
const d = resizeRef.current;
if (d?.pointerId !== e.pointerId) return;
const host = e.currentTarget.closest('[data-token-layer="1"]') as HTMLElement | null;
const p = point(e, host);
const dist = Math.max(1e-4, Math.hypot(p.x - pos.nx, p.y - pos.ny));
const next = clampSceneNpcTokenSizeN(d.startSize * (dist / d.startDist));
d.lastSize = next;
setLocalSize(next);
scheduleResize(next);
}}
onPointerUp={endResize}
onPointerCancel={endResize}
/>
) : null}
</div>
);
}
export function SceneNpcTokensOverlay({
placements,
library,
session,
viewport,
grid = null,
editable = false,
onMove,
onResize,
resizeTitle,
onContextMenu,
snapNorm,
poseOverrides = null,
dragEnabledById = null,
}: {
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;
onResize?: (placementId: string, sizeN: number) => void;
resizeTitle?: string;
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
poseOverrides?: Record<string, { nx: number; ny: number }> | null;
dragEnabledById?: Record<string, boolean> | null;
}) {
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} data-token-layer="1">
{placements.map((placement) => {
const npc = byId.get(placement.npcId);
if (!npc) return null;
const key = String(placement.id);
const pose = poseOverrides?.[key];
const override = session?.byPlacementId[key];
const disposition = resolveNpcTokenDisposition(placement, npc, override?.disposition);
const inactive = Boolean(override?.inactive);
const dragEnabled = dragEnabledById ? Boolean(dragEnabledById[key]) : true;
return (
<NpcSprite
key={placement.id}
placement={placement}
npc={npc}
nx={pose?.nx ?? override?.nx ?? placement.nx}
ny={pose?.ny ?? override?.ny ?? placement.ny}
sizeN={override?.sizeN ?? placement.sizeN}
viewport={viewport}
editable={editable}
dragEnabled={dragEnabled}
displayScale={displayScale}
gridFit={gridFit}
disposition={disposition}
inactive={inactive}
{...(onMove ? { onMove } : {})}
{...(onResize ? { onResize } : {})}
{...(resizeTitle ? { resizeTitle } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
{...(snapNorm ? { snapNorm } : {})}
/>
);
})}
</div>
);
}