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,63 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.circle {
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
border: 4px solid #c9a227;
|
||||
overflow: hidden;
|
||||
background: #18181b;
|
||||
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.45);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.circlePan {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.circlePan:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 140%;
|
||||
height: 140%;
|
||||
object-fit: cover;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.avatarEmpty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
#27272a,
|
||||
#27272a 6px,
|
||||
#3f3f46 6px,
|
||||
#3f3f46 12px
|
||||
);
|
||||
}
|
||||
|
||||
.namePlate {
|
||||
max-width: 100%;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(9, 9, 11, 0.88);
|
||||
color: #fafafa;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import type { PlayerImageOffset } from '../../../shared/types/appPlayers';
|
||||
import {
|
||||
clampPlayerImageOffset,
|
||||
clampPlayerImageScale,
|
||||
DEFAULT_PLAYER_IMAGE_SCALE,
|
||||
DEFAULT_PLAYER_RING_COLOR,
|
||||
PLAYER_IMAGE_SCALE_STEP,
|
||||
} from '../../../shared/types/appPlayers';
|
||||
|
||||
import styles from './PlayerTokenView.module.css';
|
||||
|
||||
export type PlayerTokenViewProps = {
|
||||
name: string;
|
||||
imageUrl: string | null;
|
||||
ringColor?: string;
|
||||
imageOffset?: PlayerImageOffset;
|
||||
imageScale?: number;
|
||||
/** Размер круга в px (для превью в модалке). На сцене задавайте через CSS width/height родителя. */
|
||||
sizePx?: number;
|
||||
/** Разрешить drag-pan аватара внутри круга и zoom колесом. */
|
||||
panEnabled?: boolean;
|
||||
onImageOffsetChange?: (offset: PlayerImageOffset) => void;
|
||||
onImageScaleChange?: (scale: number) => void;
|
||||
className?: string;
|
||||
/** Без подписи имени (компактный маркер). */
|
||||
hideName?: boolean;
|
||||
'data-testid'?: string;
|
||||
};
|
||||
|
||||
export function PlayerTokenView({
|
||||
name,
|
||||
imageUrl,
|
||||
ringColor = DEFAULT_PLAYER_RING_COLOR,
|
||||
imageOffset,
|
||||
imageScale,
|
||||
sizePx = 160,
|
||||
panEnabled = false,
|
||||
onImageOffsetChange,
|
||||
onImageScaleChange,
|
||||
className,
|
||||
hideName = false,
|
||||
'data-testid': testId,
|
||||
}: PlayerTokenViewProps) {
|
||||
const offset = clampPlayerImageOffset(imageOffset);
|
||||
const scale = clampPlayerImageScale(imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE);
|
||||
const circleRef = useRef<HTMLDivElement | null>(null);
|
||||
const scaleRef = useRef(scale);
|
||||
scaleRef.current = scale;
|
||||
const onImageScaleChangeRef = useRef(onImageScaleChange);
|
||||
onImageScaleChangeRef.current = onImageScaleChange;
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
origX: number;
|
||||
origY: number;
|
||||
} | null>(null);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!panEnabled || !onImageOffsetChange) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
pointerId: e.pointerId,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origX: offset.x,
|
||||
origY: offset.y,
|
||||
};
|
||||
},
|
||||
[offset.x, offset.y, onImageOffsetChange, panEnabled],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.pointerId !== e.pointerId || !onImageOffsetChange) return;
|
||||
const dx = (e.clientX - d.startX) / Math.max(1, sizePx);
|
||||
const dy = (e.clientY - d.startY) / Math.max(1, sizePx);
|
||||
onImageOffsetChange(clampPlayerImageOffset({ x: d.origX + dx, y: d.origY + dy }));
|
||||
},
|
||||
[onImageOffsetChange, sizePx],
|
||||
);
|
||||
|
||||
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.pointerId !== e.pointerId) return;
|
||||
dragRef.current = null;
|
||||
try {
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = circleRef.current;
|
||||
if (!el || !panEnabled) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
const cb = onImageScaleChangeRef.current;
|
||||
if (!cb) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const direction = e.deltaY < 0 ? 1 : -1;
|
||||
cb(clampPlayerImageScale(scaleRef.current + direction * PLAYER_IMAGE_SCALE_STEP));
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, [panEnabled]);
|
||||
|
||||
const rootClass = [styles.root, className ?? ''].filter(Boolean).join(' ');
|
||||
const nameFontPx = Math.max(9, Math.min(13, Math.round(sizePx * 0.16)));
|
||||
const nameGapPx = Math.max(2, Math.min(8, Math.round(sizePx * 0.06)));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={rootClass}
|
||||
style={{ width: sizePx, gap: hideName ? undefined : nameGapPx }}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div
|
||||
ref={circleRef}
|
||||
className={[styles.circle, panEnabled ? styles.circlePan : ''].filter(Boolean).join(' ')}
|
||||
style={{
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
borderColor: ringColor,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
className={styles.avatar}
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${String(offset.x * 100)}%, ${String(offset.y * 100)}%) scale(${String(scale)})`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.avatarEmpty} />
|
||||
)}
|
||||
</div>
|
||||
{!hideName ? (
|
||||
<div className={styles.namePlate} title={name} style={{ fontSize: nameFontPx }}>
|
||||
{name || '—'}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 9;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.token {
|
||||
position: absolute;
|
||||
overflow: visible;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.token > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editable {
|
||||
pointer-events: auto;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.editable:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { AppPlayer, AppPlayerTeam } from '../../../shared/types';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
export function useAppPlayers(): { players: AppPlayer[]; teams: AppPlayerTeam[] } {
|
||||
const api = getDndApi();
|
||||
const [players, setPlayers] = useState<AppPlayer[]>([]);
|
||||
const [teams, setTeams] = useState<AppPlayerTeam[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.players.list, {}).then((res) => {
|
||||
setPlayers(res.players);
|
||||
setTeams(res.teams);
|
||||
});
|
||||
return api.on(ipcChannels.players.stateChanged, ({ players: nextPlayers, teams: nextTeams }) => {
|
||||
setPlayers(nextPlayers);
|
||||
setTeams(nextTeams);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
return { players, teams };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { PlayerId } from '../../../shared/types';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
export function usePlayerImageUrl(playerId: PlayerId | null | undefined): string | null {
|
||||
const api = getDndApi();
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playerId) {
|
||||
setUrl(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void api.invoke(ipcChannels.players.imageUrl, { id: playerId }).then((res) => {
|
||||
if (!cancelled) setUrl(res.url);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [api, playerId]);
|
||||
|
||||
return url;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type {
|
||||
SceneNpcTokensSessionEvent,
|
||||
SceneNpcTokensSessionState,
|
||||
} from '../../../shared/types';
|
||||
import {
|
||||
clampNpcTokenSessionScale,
|
||||
DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
} from '../../../shared/types/appPlayers';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
function withScale(state: SceneNpcTokensSessionState): SceneNpcTokensSessionState {
|
||||
return {
|
||||
...state,
|
||||
scale: clampNpcTokenSessionScale(state.scale),
|
||||
};
|
||||
}
|
||||
|
||||
function applyEvent(
|
||||
prev: SceneNpcTokensSessionState,
|
||||
event: SceneNpcTokensSessionEvent,
|
||||
): SceneNpcTokensSessionState {
|
||||
if (event.kind === 'clear') {
|
||||
return {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: {},
|
||||
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
};
|
||||
}
|
||||
if (event.kind === 'setScale') {
|
||||
const scale = clampNpcTokenSessionScale(event.scale);
|
||||
if (prev.scale === scale) return prev;
|
||||
return { ...prev, revision: prev.revision + 1, scale };
|
||||
}
|
||||
return {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: {
|
||||
...prev.byPlacementId,
|
||||
[event.placementId]: { nx: event.nx, ny: event.ny },
|
||||
},
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSceneNpcTokensSession(): [
|
||||
SceneNpcTokensSessionState,
|
||||
{ dispatch: (event: SceneNpcTokensSessionEvent) => void },
|
||||
] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<SceneNpcTokensSessionState>({
|
||||
revision: 0,
|
||||
byPlacementId: {},
|
||||
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
});
|
||||
const localRevRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.sceneNpcTokensSession.getState, {}).then((res) => {
|
||||
const next = withScale(res.state);
|
||||
// Не затираем более свежий optimistic/broadcast стейт устаревшим getState.
|
||||
if (next.revision < localRevRef.current) return;
|
||||
localRevRef.current = next.revision;
|
||||
setState(next);
|
||||
});
|
||||
return api.on(ipcChannels.sceneNpcTokensSession.stateChanged, ({ state: incoming }) => {
|
||||
const next = withScale(incoming);
|
||||
if (next.revision < localRevRef.current) return;
|
||||
localRevRef.current = next.revision;
|
||||
setState(next);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const dispatch = useCallback(
|
||||
(event: SceneNpcTokensSessionEvent) => {
|
||||
setState((prev) => {
|
||||
const next = applyEvent(prev, event);
|
||||
localRevRef.current = Math.max(localRevRef.current, next.revision);
|
||||
return next;
|
||||
});
|
||||
void api.invoke(ipcChannels.sceneNpcTokensSession.dispatch, { event });
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
return [state, { dispatch }];
|
||||
}
|
||||
Reference in New Issue
Block a user