f6c301a12b
A token can be hidden per session from the control menu. The GM still sees it, and its path keeps moving. Scene editor grid labels no longer call a removed helper. Co-authored-by: Cursor <cursoragent@cursor.com>
349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
import React, { useLayoutEffect, 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 { tokenPathKey } from '../../../shared/types/tokenPathSession';
|
|
import { registerPathTokenNode, writeTokenPathTransform } from '../tokens/tokenPathLiveDom';
|
|
import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
|
|
import {
|
|
TokenInitiativeDecor,
|
|
initiativeConditionsForNpc,
|
|
initiativeHighlightForNpc,
|
|
} from '../initiative/TokenInitiativeDecor';
|
|
import { useInitiativeState } from '../initiative/useInitiativeState';
|
|
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,
|
|
hiddenFromPlayers,
|
|
concealed,
|
|
onMove,
|
|
onResize,
|
|
resizeTitle,
|
|
onContextMenu,
|
|
snapNorm,
|
|
initiativeActive = false,
|
|
initiativeConditions = [],
|
|
}: {
|
|
placement: SceneNpcToken;
|
|
npc: ProjectNpc;
|
|
nx: number;
|
|
ny: number;
|
|
sizeN: number;
|
|
viewport: Viewport;
|
|
editable: boolean;
|
|
dragEnabled: boolean;
|
|
displayScale: number;
|
|
gridFit: number;
|
|
disposition: NpcDisposition;
|
|
inactive: boolean;
|
|
hiddenFromPlayers: boolean;
|
|
concealed: 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 };
|
|
initiativeActive?: boolean;
|
|
initiativeConditions?: readonly import('../../../shared/types/initiative').InitiativeConditionId[];
|
|
}) {
|
|
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 elRef = useRef<HTMLDivElement>(null);
|
|
const pathKey = tokenPathKey('npcToken', 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;
|
|
|
|
useLayoutEffect(() => {
|
|
registerPathTokenNode(pathKey, elRef.current, viewport, false);
|
|
return () => registerPathTokenNode(pathKey, null, viewport, false);
|
|
}, [pathKey, viewport]);
|
|
|
|
useLayoutEffect(() => {
|
|
const el = elRef.current;
|
|
if (!el || el.dataset.pathLive === '1') return;
|
|
writeTokenPathTransform(el, pos.nx, pos.ny, 0, viewport, false);
|
|
}, [pos.nx, pos.ny, viewport]);
|
|
|
|
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
|
|
ref={elRef}
|
|
className={[
|
|
styles.token,
|
|
canDrag ? styles.editable : '',
|
|
initiativeActive ? styles.initiativeActive : '',
|
|
hiddenFromPlayers && !concealed ? styles.hiddenFromPlayers : '',
|
|
concealed ? styles.concealedFromPlayers : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
data-testid={`session-npc-token-${placement.id}`}
|
|
data-initiative-active={initiativeActive ? '1' : '0'}
|
|
style={{
|
|
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}
|
|
/>
|
|
<TokenInitiativeDecor conditions={initiativeConditions} />
|
|
{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,
|
|
concealFromPlayers = false,
|
|
}: {
|
|
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;
|
|
/** На презентации скрытый от игроков токен остаётся в DOM, чтобы путь продолжал идти. */
|
|
concealFromPlayers?: boolean;
|
|
}) {
|
|
const [initiative] = useInitiativeState();
|
|
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 hiddenFromPlayers = Boolean(override?.hiddenFromPlayers);
|
|
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}
|
|
hiddenFromPlayers={hiddenFromPlayers}
|
|
concealed={concealFromPlayers && hiddenFromPlayers}
|
|
initiativeActive={initiativeHighlightForNpc(initiative, key)}
|
|
initiativeConditions={initiativeConditionsForNpc(initiative, key)}
|
|
{...(onMove ? { onMove } : {})}
|
|
{...(onResize ? { onResize } : {})}
|
|
{...(resizeTitle ? { resizeTitle } : {})}
|
|
{...(onContextMenu ? { onContextMenu } : {})}
|
|
{...(snapNorm ? { snapNorm } : {})}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|