cc50e64e21
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>
175 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|