Merge branch 'users' into main

Players library, circular NPC tokens, disposition types; keep main overlay/release fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-04 08:03:22 +08:00
69 changed files with 5943 additions and 327 deletions
+27
View File
@@ -15,6 +15,11 @@ import { MaterialOverlay } from './materials/MaterialOverlay';
import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
import { SceneNpcTokensOverlay } from './playerToken/SceneNpcTokensOverlay';
import { ScenePlayerTokensOverlay } from './playerToken/ScenePlayerTokensOverlay';
import { useAppPlayers } from './playerToken/useAppPlayers';
import { useSceneNpcTokensSession } from './playerToken/useSceneNpcTokensSession';
import { useScenePlayerTokensSession } from './playerToken/useScenePlayerTokensSession';
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
import { useSceneViewState } from './sceneView/useSceneViewState';
import { SceneTokensOverlay } from './tokens/SceneTokensOverlay';
@@ -26,6 +31,7 @@ import styles from './PresentationView.module.css';
import { RotatedImage } from './RotatedImage';
import { useAssetUrl } from './useAssetImageUrl';
import { useVideoPlaybackState } from './video/useVideoPlaybackState';
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../shared/types/appPlayers';
export type PresentationViewProps = {
session: SessionState | null;
@@ -48,7 +54,10 @@ export function PresentationView({
const [materialsOverlay] = useMaterialsOverlayState();
const [npcsOverlay] = useNpcsOverlayState();
const appTokens = useAppTokens();
const { players: appPlayers } = useAppPlayers();
const [sceneTokensSession] = useSceneTokensSession();
const [sceneNpcTokensSession] = useSceneNpcTokensSession();
const [scenePlayerTokensSession] = useScenePlayerTokensSession();
const [vp] = useVideoPlaybackState();
const videoElRef = useRef<HTMLVideoElement | null>(null);
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
@@ -187,6 +196,24 @@ export function PresentationView({
viewport={contentRect}
/>
) : null}
{scene?.previewAssetType === 'image' && contentRect ? (
<SceneNpcTokensOverlay
placements={scene.npcTokens ?? []}
library={project?.npcs ?? []}
session={sceneNpcTokensSession}
viewport={contentRect}
grid={scene.grid}
/>
) : null}
{scene?.previewAssetType === 'image' && contentRect ? (
<ScenePlayerTokensOverlay
library={appPlayers}
session={scenePlayerTokensSession}
displayScale={sceneNpcTokensSession.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE}
viewport={contentRect}
grid={scene.grid}
/>
) : null}
{scene?.previewAssetType === 'image' && contentRect ? (
<SceneTrapsOverlay
traps={scene.traps ?? []}
@@ -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,163 @@
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;
/** Session «Неактивен»: серый фильтр аватара. */
inactive?: 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,
inactive = 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: inactive ? '#9ca3af' : 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)})`,
...(inactive ? { filter: 'grayscale(1)', opacity: 0.85 } : {}),
}}
/>
) : (
<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,213 @@
import React, { useRef, useState } from 'react';
import type {
NpcDisposition,
ProjectNpc,
SceneGrid,
SceneNpcToken,
SceneNpcTokensSessionState,
} from '../../../shared/types';
import { 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 } 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,
viewport,
editable,
displayScale,
gridFit,
disposition,
inactive,
onMove,
onContextMenu,
}: {
placement: SceneNpcToken;
npc: ProjectNpc;
nx: number;
ny: number;
viewport: Viewport;
editable: boolean;
displayScale: number;
gridFit: number;
disposition: NpcDisposition;
inactive: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => 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 { schedule, flush } = useLiveDragBroadcast(onMove, String(placement.id));
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;
flush(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,
}}
onContextMenu={
onContextMenu
? (e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, placement);
}
: undefined
}
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={npc.name}
imageUrl={imageUrl}
ringColor={npcDispositionRingColor(disposition, inactive)}
imageOffset={npc.imageOffset}
imageScale={npc.imageScale}
sizePx={sizePx}
inactive={inactive}
/>
</div>
);
}
export function SceneNpcTokensOverlay({
placements,
library,
session,
viewport,
grid = null,
editable = false,
onMove,
onContextMenu,
}: {
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;
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => 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)];
const disposition = resolveNpcTokenDisposition(placement, npc, override?.disposition);
const inactive = Boolean(override?.inactive);
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}
disposition={disposition}
inactive={inactive}
{...(onMove ? { onMove } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
/>
);
})}
</div>
);
}
@@ -0,0 +1,174 @@
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>
);
}
@@ -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,48 @@
import { useCallback, useEffect, useRef } from 'react';
/** Во время drag шлёт onMove не чаще одного раза за кадр (для live-синхронизации презентации). */
export function useLiveDragBroadcast(
onMove: ((id: string, nx: number, ny: number) => void) | undefined,
id: string,
): {
schedule: (nx: number, ny: number) => void;
flush: (nx: number, ny: number) => void;
} {
const rafRef = useRef(0);
const pendingRef = useRef<{ nx: number; ny: number } | null>(null);
const onMoveRef = useRef(onMove);
onMoveRef.current = onMove;
const idRef = useRef(id);
idRef.current = id;
useEffect(
() => () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
},
[],
);
const schedule = useCallback((nx: number, ny: number) => {
if (!onMoveRef.current) return;
pendingRef.current = { nx, ny };
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = 0;
const pending = pendingRef.current;
const cb = onMoveRef.current;
if (!pending || !cb) return;
cb(idRef.current, pending.nx, pending.ny);
});
}, []);
const flush = useCallback((nx: number, ny: number) => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = 0;
}
pendingRef.current = null;
onMoveRef.current?.(idRef.current, nx, ny);
}, []);
return { schedule, flush };
}
@@ -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,152 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type {
SceneNpcTokensSessionEvent,
SceneNpcTokensSessionPlacement,
SceneNpcTokensSessionState,
} from '../../../shared/types';
import {
clampNpcTokenSessionScale,
DEFAULT_NPC_TOKEN_SESSION_SCALE,
} from '../../../shared/types/appPlayers';
import { normalizeNpcDisposition } from '../../../shared/types/npcDisposition';
import { getDndApi } from '../dndApi';
function withScale(state: SceneNpcTokensSessionState): SceneNpcTokensSessionState {
return {
...state,
scale: clampNpcTokenSessionScale(state.scale),
};
}
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
return p.nx === undefined && p.ny === undefined && !p.disposition && !p.inactive;
}
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 };
}
if (event.kind === 'setDisposition') {
const placementId = String(event.placementId ?? '');
if (!placementId) return prev;
const disposition = normalizeNpcDisposition(event.disposition);
const cur = prev.byPlacementId[placementId];
if (cur?.disposition === disposition) return prev;
return {
revision: prev.revision + 1,
byPlacementId: {
...prev.byPlacementId,
[placementId]: {
...(cur?.nx !== undefined ? { nx: cur.nx } : {}),
...(cur?.ny !== undefined ? { ny: cur.ny } : {}),
disposition,
...(cur?.inactive ? { inactive: true } : {}),
},
},
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
};
}
if (event.kind === 'setInactive') {
const placementId = String(event.placementId ?? '');
if (!placementId) return prev;
const inactive = Boolean(event.inactive);
const cur = prev.byPlacementId[placementId];
if (Boolean(cur?.inactive) === inactive) return prev;
const next: SceneNpcTokensSessionPlacement = {
...(cur?.nx !== undefined ? { nx: cur.nx } : {}),
...(cur?.ny !== undefined ? { ny: cur.ny } : {}),
...(cur?.disposition ? { disposition: cur.disposition } : {}),
...(inactive ? { inactive: true } : {}),
};
if (isEmptyPlacement(next)) {
const { [placementId]: _removed, ...rest } = prev.byPlacementId;
return {
revision: prev.revision + 1,
byPlacementId: rest,
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
};
}
return {
revision: prev.revision + 1,
byPlacementId: {
...prev.byPlacementId,
[placementId]: next,
},
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
};
}
// move
const placementId = String(event.placementId ?? '');
if (!placementId) return prev;
const cur = prev.byPlacementId[placementId];
return {
revision: prev.revision + 1,
byPlacementId: {
...prev.byPlacementId,
[placementId]: {
nx: event.nx,
ny: event.ny,
...(cur?.disposition ? { disposition: cur.disposition } : {}),
...(cur?.inactive ? { inactive: true } : {}),
},
},
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 }];
}
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type {
ScenePlayerTokensSessionEvent,
ScenePlayerTokensSessionState,
} from '../../../shared/types';
import { getDndApi } from '../dndApi';
function applyEvent(
prev: ScenePlayerTokensSessionState,
event: ScenePlayerTokensSessionEvent,
): ScenePlayerTokensSessionState {
if (event.kind === 'clear') {
return { revision: prev.revision + 1, selectedPlayerIds: [], visible: false, byPlayerId: {} };
}
if (event.kind === 'clearPlacements') {
return { ...prev, revision: prev.revision + 1, visible: false, byPlayerId: {} };
}
if (event.kind === 'setSelection') {
const seen = new Set<string>();
const selectedPlayerIds: string[] = [];
for (const id of event.playerIds) {
const s = String(id ?? '').trim();
if (!s || seen.has(s)) continue;
seen.add(s);
selectedPlayerIds.push(s);
}
return { revision: prev.revision + 1, selectedPlayerIds, visible: false, byPlayerId: {} };
}
if (event.kind === 'setVisible') {
if (event.visible && prev.selectedPlayerIds.length === 0) return prev;
return { ...prev, revision: prev.revision + 1, visible: Boolean(event.visible) };
}
if (event.kind === 'show') {
if (prev.selectedPlayerIds.length === 0) return prev;
// placements приходят с main; локально только включаем visible после seed через broadcast
return { ...prev, revision: prev.revision + 1, visible: true };
}
if (event.kind === 'seedBottom') {
return prev;
}
if (event.kind === 'move') {
const prevPlacement = prev.byPlayerId[event.playerId];
return {
...prev,
revision: prev.revision + 1,
byPlayerId: {
...prev.byPlayerId,
[event.playerId]: {
nx: event.nx,
ny: event.ny,
sizeN: prevPlacement?.sizeN ?? 0.1,
},
},
};
}
return prev;
}
export function useScenePlayerTokensSession(): [
ScenePlayerTokensSessionState,
{ dispatch: (event: ScenePlayerTokensSessionEvent) => void },
] {
const api = getDndApi();
const [state, setState] = useState<ScenePlayerTokensSessionState>({
revision: 0,
selectedPlayerIds: [],
visible: false,
byPlayerId: {},
});
const localRevRef = useRef(0);
useEffect(() => {
void api.invoke(ipcChannels.scenePlayerTokensSession.getState, {}).then((res) => {
if (res.state.revision < localRevRef.current) return;
localRevRef.current = res.state.revision;
setState(res.state);
});
return api.on(ipcChannels.scenePlayerTokensSession.stateChanged, ({ state: next }) => {
if (next.revision < localRevRef.current) return;
localRevRef.current = next.revision;
setState(next);
});
}, [api]);
const dispatch = useCallback(
(event: ScenePlayerTokensSessionEvent) => {
if (event.kind !== 'seedBottom' && event.kind !== 'show') {
setState((prev) => {
const next = applyEvent(prev, event);
localRevRef.current = Math.max(localRevRef.current, next.revision);
return next;
});
}
void api.invoke(ipcChannels.scenePlayerTokensSession.dispatch, { event });
},
[api],
);
return [state, { dispatch }];
}
@@ -184,7 +184,7 @@ export function SceneTokensOverlay({
ny={ny}
viewport={viewport}
editable={editable}
onMove={onMove}
{...(onMove ? { onMove } : {})}
/>
);
})}
+3
View File
@@ -17,6 +17,7 @@ type ButtonProps = {
iconOnly?: boolean;
/** Позиция тултипа относительно кнопки. */
tooltipPlacement?: 'top' | 'bottom' | 'bottom-left';
'data-testid'?: string;
};
export function Button({
@@ -28,6 +29,7 @@ export function Button({
ariaLabel,
iconOnly = false,
tooltipPlacement = 'top',
'data-testid': testId,
}: ButtonProps) {
const btnRef = useRef<HTMLButtonElement | null>(null);
const hostRef = useRef<HTMLSpanElement | null>(null);
@@ -85,6 +87,7 @@ export function Button({
className={btnClass}
disabled={disabled}
aria-label={ariaLabel}
data-testid={testId}
onClick={disabled ? undefined : onClick}
onMouseEnter={disabled ? undefined : showTip}
onMouseLeave={disabled ? undefined : hideTip}
@@ -70,3 +70,63 @@ void test('WindowErrorBoundary component exists and catches errors', () => {
assert.ok(src.includes('componentDidCatch'));
assert.ok(src.includes('role="alert"'));
});
void test('EditorApp: Players header after File, opens modal', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'editor/EditorApp.tsx'), 'utf8');
const fileIdx = src.indexOf("t('top.file')");
const playersIdx = src.indexOf('data-testid="players-header-btn"');
assert.ok(fileIdx > 0, 'File menu present');
assert.ok(playersIdx > fileIdx, 'Players button must follow File in source order');
assert.ok(src.includes('PlayersManagerModal'));
assert.ok(src.includes('setPlayersManagerOpen(true)'));
});
void test('Players modal: flat teams only, progress overlay, PlayerTokenView', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'editor/PlayersModals.tsx'), 'utf8');
assert.ok(src.includes('PlayerTokenView'));
assert.ok(src.includes('data-testid="players-modal"'));
assert.ok(src.includes('data-testid="players-save-progress"'));
assert.doesNotMatch(src, /parentId|подкоманд|subteam|sub-team/i);
assert.ok(src.includes("application/x-dnd-player-id"));
});
void test('Scene NPC accordion: separate mime and PlayerTokenView markers', () => {
const scene = fs.readFileSync(path.join(rendererRoot, 'sceneEditor/SceneEditorApp.tsx'), 'utf8');
const tokenTile = fs.readFileSync(path.join(rendererRoot, 'sceneEditor/TokenTile.tsx'), 'utf8');
assert.ok(scene.includes('data-testid="scene-npc-accordion"'));
assert.ok(scene.includes("application/x-dnd-scene-npc-id"));
assert.ok(tokenTile.includes("application/x-dnd-app-token-id"));
assert.notEqual(
'application/x-dnd-scene-npc-id',
'application/x-dnd-app-token-id',
'NPC scene mime must differ from app token mime',
);
assert.ok(scene.includes('PlayerTokenView'));
assert.ok(scene.includes('persistNpcTokens([])'));
assert.doesNotMatch(scene, /SceneTokenMarker[\s\S]{0,80}npcTokens/);
});
void test('Control and Presentation use SceneNpcTokensOverlay', () => {
const control = fs.readFileSync(path.join(rendererRoot, 'control/ControlApp.tsx'), 'utf8');
const presentation = fs.readFileSync(path.join(rendererRoot, 'shared/PresentationView.tsx'), 'utf8');
assert.ok(control.includes('SceneNpcTokensOverlay'));
assert.ok(presentation.includes('SceneNpcTokensOverlay'));
assert.ok(control.includes('sceneNpcTokensSession'));
assert.ok(control.includes('ScenePlayerTokensOverlay'));
assert.ok(presentation.includes('ScenePlayerTokensOverlay'));
assert.ok(control.includes('toggle-session-players'));
});
void test('EditorApp: split Run button and launch-with-players modal', () => {
const editor = fs.readFileSync(path.join(rendererRoot, 'editor/EditorApp.tsx'), 'utf8');
assert.ok(editor.includes('splitRun'));
assert.ok(editor.includes('run-menu-btn'));
assert.ok(editor.includes('LaunchPlayersModal'));
assert.ok(editor.includes('top.runWithPlayers'));
});
void test('NpcsEditorApp: token appearance via PlayerTokenView', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8');
assert.ok(src.includes('PlayerTokenView'));
assert.ok(src.includes('disposition') || src.includes('updateNpcFields'));
});