feat(players): NPC disposition types and launch-with-players session tokens

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>
This commit is contained in:
Ivan Fontosh
2026-07-30 17:11:01 +08:00
parent 101f595bac
commit cc50e64e21
38 changed files with 1803 additions and 59 deletions
@@ -26,6 +26,8 @@ export type PlayerTokenViewProps = {
className?: string;
/** Без подписи имени (компактный маркер). */
hideName?: boolean;
/** Session «Неактивен»: серый фильтр аватара. */
inactive?: boolean;
'data-testid'?: string;
};
@@ -41,6 +43,7 @@ export function PlayerTokenView({
onImageScaleChange,
className,
hideName = false,
inactive = false,
'data-testid': testId,
}: PlayerTokenViewProps) {
const offset = clampPlayerImageOffset(imageOffset);
@@ -128,7 +131,7 @@ export function PlayerTokenView({
style={{
width: sizePx,
height: sizePx,
borderColor: ringColor,
borderColor: inactive ? '#9ca3af' : ringColor,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
@@ -143,6 +146,7 @@ export function PlayerTokenView({
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 } : {}),
}}
/>
) : (
@@ -1,20 +1,35 @@
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,
@@ -24,7 +39,10 @@ function NpcSprite({
editable,
displayScale,
gridFit,
disposition,
inactive,
onMove,
onContextMenu,
}: {
placement: SceneNpcToken;
npc: ProjectNpc;
@@ -34,7 +52,10 @@ function NpcSprite({
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);
@@ -47,6 +68,7 @@ function NpcSprite({
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);
@@ -64,7 +86,7 @@ function NpcSprite({
const drag = dragRef.current;
if (drag?.pointerId !== e.pointerId) return;
dragRef.current = null;
onMove?.(String(placement.id), drag.lastNx, drag.lastNy);
flush(drag.lastNx, drag.lastNy);
setLocalPos(null);
};
@@ -78,6 +100,15 @@ function NpcSprite({
width: sizePx,
height: sizePx,
}}
onContextMenu={
onContextMenu
? (e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, placement);
}
: undefined
}
onPointerDown={
editable && onMove !== undefined
? (e) => {
@@ -107,6 +138,7 @@ function NpcSprite({
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
}
@@ -116,10 +148,11 @@ function NpcSprite({
<PlayerTokenView
name={npc.name}
imageUrl={imageUrl}
ringColor={npc.ringColor}
ringColor={npcDispositionRingColor(disposition, inactive)}
imageOffset={npc.imageOffset}
imageScale={npc.imageScale}
sizePx={sizePx}
inactive={inactive}
/>
</div>
);
@@ -133,6 +166,7 @@ export function SceneNpcTokensOverlay({
grid = null,
editable = false,
onMove,
onContextMenu,
}: {
placements: readonly SceneNpcToken[];
library: readonly ProjectNpc[];
@@ -142,6 +176,7 @@ export function SceneNpcTokensOverlay({
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]));
@@ -153,6 +188,8 @@ export function SceneNpcTokensOverlay({
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}
@@ -164,7 +201,10 @@ export function SceneNpcTokensOverlay({
editable={editable}
displayScale={displayScale}
gridFit={gridFit}
disposition={disposition}
inactive={inactive}
{...(onMove ? { onMove } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
/>
);
})}
@@ -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,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 };
}
@@ -3,12 +3,14 @@ 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 {
@@ -18,6 +20,10 @@ function withScale(state: SceneNpcTokensSessionState): SceneNpcTokensSessionStat
};
}
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
return p.nx === undefined && p.ny === undefined && !p.disposition && !p.inactive;
}
function applyEvent(
prev: SceneNpcTokensSessionState,
event: SceneNpcTokensSessionEvent,
@@ -34,11 +40,69 @@ function applyEvent(
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,
[event.placementId]: { nx: event.nx, ny: event.ny },
[placementId]: {
nx: event.nx,
ny: event.ny,
...(cur?.disposition ? { disposition: cur.disposition } : {}),
...(cur?.inactive ? { inactive: true } : {}),
},
},
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
};
@@ -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 }];
}