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:
@@ -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 }];
|
||||
}
|
||||
Reference in New Issue
Block a user