feat(control): spawn session NPCs and resize tokens in preview
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,6 +17,10 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.token > .resizeHandle {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.editable {
|
||||
pointer-events: auto;
|
||||
cursor: grab;
|
||||
@@ -25,3 +29,18 @@
|
||||
.editable:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.resizeHandle {
|
||||
position: absolute;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
background: #f5c542;
|
||||
border: 1px solid #000;
|
||||
cursor: nwse-resize;
|
||||
touch-action: none;
|
||||
pointer-events: auto;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
SceneNpcToken,
|
||||
SceneNpcTokensSessionState,
|
||||
} from '../../../shared/types';
|
||||
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers';
|
||||
import { clampSceneNpcTokenSizeN, DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers';
|
||||
import {
|
||||
normalizeNpcDisposition,
|
||||
npcDispositionRingColor,
|
||||
@@ -16,7 +16,7 @@ import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid';
|
||||
import { useAssetUrl } from '../useAssetImageUrl';
|
||||
|
||||
import { PlayerTokenView } from './PlayerTokenView';
|
||||
import { useLiveDragBroadcast } from './useLiveDragBroadcast';
|
||||
import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
|
||||
import styles from './SceneNpcTokensOverlay.module.css';
|
||||
|
||||
type Viewport = { x: number; y: number; w: number; h: number };
|
||||
@@ -35,6 +35,7 @@ function NpcSprite({
|
||||
npc,
|
||||
nx,
|
||||
ny,
|
||||
sizeN,
|
||||
viewport,
|
||||
editable,
|
||||
dragEnabled,
|
||||
@@ -43,6 +44,8 @@ function NpcSprite({
|
||||
disposition,
|
||||
inactive,
|
||||
onMove,
|
||||
onResize,
|
||||
resizeTitle,
|
||||
onContextMenu,
|
||||
snapNorm,
|
||||
}: {
|
||||
@@ -50,6 +53,7 @@ function NpcSprite({
|
||||
npc: ProjectNpc;
|
||||
nx: number;
|
||||
ny: number;
|
||||
sizeN: number;
|
||||
viewport: Viewport;
|
||||
editable: boolean;
|
||||
dragEnabled: boolean;
|
||||
@@ -58,11 +62,14 @@ function NpcSprite({
|
||||
disposition: NpcDisposition;
|
||||
inactive: boolean;
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
onResize?: (placementId: string, sizeN: number) => void;
|
||||
resizeTitle?: string;
|
||||
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
}) {
|
||||
const imageUrl = useAssetUrl(npc.avatarAssetId);
|
||||
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
||||
const [localSize, setLocalSize] = useState<number | null>(null);
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startNx: number;
|
||||
@@ -72,15 +79,26 @@ function NpcSprite({
|
||||
lastNx: number;
|
||||
lastNy: number;
|
||||
} | null>(null);
|
||||
const resizeRef = useRef<{
|
||||
pointerId: number;
|
||||
startSize: number;
|
||||
startDist: number;
|
||||
lastSize: number;
|
||||
} | null>(null);
|
||||
const { schedule, flush } = useLiveDragBroadcast(onMove, String(placement.id));
|
||||
const { schedule: scheduleResize, flush: flushResize } = useLiveResizeBroadcast(
|
||||
onResize,
|
||||
String(placement.id),
|
||||
);
|
||||
const pos = localPos ?? { nx, ny };
|
||||
const shownSizeN = localSize ?? sizeN;
|
||||
const minDim = Math.min(viewport.w, viewport.h);
|
||||
const sizePx = Math.max(16, placement.sizeN * gridFit * displayScale * minDim);
|
||||
const sizePx = Math.max(16, shownSizeN * gridFit * displayScale * minDim);
|
||||
const canDrag = editable && dragEnabled && onMove !== undefined;
|
||||
const canResize = editable && dragEnabled && onResize !== undefined;
|
||||
|
||||
const point = (e: React.PointerEvent) => {
|
||||
const host = e.currentTarget.parentElement;
|
||||
const rect = host?.getBoundingClientRect();
|
||||
const point = (e: React.PointerEvent, host?: HTMLElement | null) => {
|
||||
const rect = (host ?? e.currentTarget.parentElement)?.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))),
|
||||
@@ -95,6 +113,14 @@ function NpcSprite({
|
||||
setLocalPos(null);
|
||||
};
|
||||
|
||||
const endResize = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const d = resizeRef.current;
|
||||
if (d?.pointerId !== e.pointerId) return;
|
||||
resizeRef.current = null;
|
||||
flushResize(d.lastSize);
|
||||
setLocalSize(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.token, canDrag ? styles.editable : ''].filter(Boolean).join(' ')}
|
||||
@@ -104,7 +130,7 @@ function NpcSprite({
|
||||
top: viewport.y + pos.ny * viewport.h,
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
pointerEvents: canDrag || onContextMenu ? 'auto' : undefined,
|
||||
pointerEvents: canDrag || canResize || onContextMenu ? 'auto' : undefined,
|
||||
}}
|
||||
onContextMenu={
|
||||
onContextMenu
|
||||
@@ -163,6 +189,41 @@ function NpcSprite({
|
||||
sizePx={sizePx}
|
||||
inactive={inactive}
|
||||
/>
|
||||
{canResize ? (
|
||||
<div
|
||||
className={styles.resizeHandle}
|
||||
title={resizeTitle}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const host = e.currentTarget.closest('[data-token-layer="1"]') as HTMLElement | null;
|
||||
const p = point(e, host);
|
||||
const dist = Math.max(1e-4, Math.hypot(p.x - pos.nx, p.y - pos.ny));
|
||||
resizeRef.current = {
|
||||
pointerId: e.pointerId,
|
||||
startSize: shownSizeN,
|
||||
startDist: dist,
|
||||
lastSize: shownSizeN,
|
||||
};
|
||||
setLocalSize(shownSizeN);
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const d = resizeRef.current;
|
||||
if (d?.pointerId !== e.pointerId) return;
|
||||
const host = e.currentTarget.closest('[data-token-layer="1"]') as HTMLElement | null;
|
||||
const p = point(e, host);
|
||||
const dist = Math.max(1e-4, Math.hypot(p.x - pos.nx, p.y - pos.ny));
|
||||
const next = clampSceneNpcTokenSizeN(d.startSize * (dist / d.startDist));
|
||||
d.lastSize = next;
|
||||
setLocalSize(next);
|
||||
scheduleResize(next);
|
||||
}}
|
||||
onPointerUp={endResize}
|
||||
onPointerCancel={endResize}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -175,6 +236,8 @@ export function SceneNpcTokensOverlay({
|
||||
grid = null,
|
||||
editable = false,
|
||||
onMove,
|
||||
onResize,
|
||||
resizeTitle,
|
||||
onContextMenu,
|
||||
snapNorm,
|
||||
poseOverrides = null,
|
||||
@@ -188,6 +251,8 @@ export function SceneNpcTokensOverlay({
|
||||
grid?: SceneGrid | null;
|
||||
editable?: boolean;
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
onResize?: (placementId: string, sizeN: number) => void;
|
||||
resizeTitle?: string;
|
||||
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
poseOverrides?: Record<string, { nx: number; ny: number }> | null;
|
||||
@@ -198,7 +263,7 @@ export function SceneNpcTokensOverlay({
|
||||
const displayScale = session?.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE;
|
||||
const gridFit = sceneGridTokenFitFactor(grid);
|
||||
return (
|
||||
<div className={styles.layer}>
|
||||
<div className={styles.layer} data-token-layer="1">
|
||||
{placements.map((placement) => {
|
||||
const npc = byId.get(placement.npcId);
|
||||
if (!npc) return null;
|
||||
@@ -215,6 +280,7 @@ export function SceneNpcTokensOverlay({
|
||||
npc={npc}
|
||||
nx={pose?.nx ?? override?.nx ?? placement.nx}
|
||||
ny={pose?.ny ?? override?.ny ?? placement.ny}
|
||||
sizeN={override?.sizeN ?? placement.sizeN}
|
||||
viewport={viewport}
|
||||
editable={editable}
|
||||
dragEnabled={dragEnabled}
|
||||
@@ -223,6 +289,8 @@ export function SceneNpcTokensOverlay({
|
||||
disposition={disposition}
|
||||
inactive={inactive}
|
||||
{...(onMove ? { onMove } : {})}
|
||||
{...(onResize ? { onResize } : {})}
|
||||
{...(resizeTitle ? { resizeTitle } : {})}
|
||||
{...(onContextMenu ? { onContextMenu } : {})}
|
||||
{...(snapNorm ? { snapNorm } : {})}
|
||||
/>
|
||||
|
||||
@@ -5,12 +5,15 @@ import type {
|
||||
SceneGrid,
|
||||
ScenePlayerTokensSessionState,
|
||||
} from '../../../shared/types';
|
||||
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers';
|
||||
import {
|
||||
clampSceneNpcTokenSizeN,
|
||||
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 { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast';
|
||||
import styles from './SceneNpcTokensOverlay.module.css';
|
||||
|
||||
type Viewport = { x: number; y: number; w: number; h: number };
|
||||
@@ -25,6 +28,8 @@ function PlayerSprite({
|
||||
displayScale,
|
||||
gridFit,
|
||||
onMove,
|
||||
onResize,
|
||||
resizeTitle,
|
||||
snapNorm,
|
||||
}: {
|
||||
player: AppPlayer;
|
||||
@@ -36,10 +41,13 @@ function PlayerSprite({
|
||||
displayScale: number;
|
||||
gridFit: number;
|
||||
onMove?: (playerId: string, nx: number, ny: number) => void;
|
||||
onResize?: (playerId: string, sizeN: number) => void;
|
||||
resizeTitle?: string;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
}) {
|
||||
const imageUrl = usePlayerImageUrl(player.id);
|
||||
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
||||
const [localSize, setLocalSize] = useState<number | null>(null);
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startNx: number;
|
||||
@@ -49,14 +57,25 @@ function PlayerSprite({
|
||||
lastNx: number;
|
||||
lastNy: number;
|
||||
} | null>(null);
|
||||
const resizeRef = useRef<{
|
||||
pointerId: number;
|
||||
startSize: number;
|
||||
startDist: number;
|
||||
lastSize: number;
|
||||
} | null>(null);
|
||||
const { schedule, flush } = useLiveDragBroadcast(onMove, String(player.id));
|
||||
const { schedule: scheduleResize, flush: flushResize } = useLiveResizeBroadcast(
|
||||
onResize,
|
||||
String(player.id),
|
||||
);
|
||||
const pos = localPos ?? { nx, ny };
|
||||
const shownSizeN = localSize ?? sizeN;
|
||||
const minDim = Math.min(viewport.w, viewport.h);
|
||||
const sizePx = Math.max(16, sizeN * gridFit * displayScale * minDim);
|
||||
const sizePx = Math.max(16, shownSizeN * gridFit * displayScale * minDim);
|
||||
const canResize = editable && onResize !== undefined;
|
||||
|
||||
const point = (e: React.PointerEvent) => {
|
||||
const host = e.currentTarget.parentElement;
|
||||
const rect = host?.getBoundingClientRect();
|
||||
const point = (e: React.PointerEvent, host?: HTMLElement | null) => {
|
||||
const rect = (host ?? e.currentTarget.parentElement)?.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))),
|
||||
@@ -71,6 +90,14 @@ function PlayerSprite({
|
||||
setLocalPos(null);
|
||||
};
|
||||
|
||||
const endResize = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const d = resizeRef.current;
|
||||
if (d?.pointerId !== e.pointerId) return;
|
||||
resizeRef.current = null;
|
||||
flushResize(d.lastSize);
|
||||
setLocalSize(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.token, editable ? styles.editable : ''].filter(Boolean).join(' ')}
|
||||
@@ -128,6 +155,41 @@ function PlayerSprite({
|
||||
imageScale={player.imageScale}
|
||||
sizePx={sizePx}
|
||||
/>
|
||||
{canResize ? (
|
||||
<div
|
||||
className={styles.resizeHandle}
|
||||
title={resizeTitle}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const host = e.currentTarget.closest('[data-token-layer="1"]') as HTMLElement | null;
|
||||
const p = point(e, host);
|
||||
const dist = Math.max(1e-4, Math.hypot(p.x - pos.nx, p.y - pos.ny));
|
||||
resizeRef.current = {
|
||||
pointerId: e.pointerId,
|
||||
startSize: shownSizeN,
|
||||
startDist: dist,
|
||||
lastSize: shownSizeN,
|
||||
};
|
||||
setLocalSize(shownSizeN);
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const d = resizeRef.current;
|
||||
if (d?.pointerId !== e.pointerId) return;
|
||||
const host = e.currentTarget.closest('[data-token-layer="1"]') as HTMLElement | null;
|
||||
const p = point(e, host);
|
||||
const dist = Math.max(1e-4, Math.hypot(p.x - pos.nx, p.y - pos.ny));
|
||||
const next = clampSceneNpcTokenSizeN(d.startSize * (dist / d.startDist));
|
||||
d.lastSize = next;
|
||||
setLocalSize(next);
|
||||
scheduleResize(next);
|
||||
}}
|
||||
onPointerUp={endResize}
|
||||
onPointerCancel={endResize}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -140,6 +202,8 @@ export function ScenePlayerTokensOverlay({
|
||||
grid = null,
|
||||
editable = false,
|
||||
onMove,
|
||||
onResize,
|
||||
resizeTitle,
|
||||
snapNorm,
|
||||
}: {
|
||||
library: readonly AppPlayer[];
|
||||
@@ -150,13 +214,15 @@ export function ScenePlayerTokensOverlay({
|
||||
grid?: SceneGrid | null;
|
||||
editable?: boolean;
|
||||
onMove?: (playerId: string, nx: number, ny: number) => void;
|
||||
onResize?: (playerId: string, sizeN: number) => void;
|
||||
resizeTitle?: string;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
}) {
|
||||
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}>
|
||||
<div className={styles.layer} data-token-layer="1">
|
||||
{session.selectedPlayerIds.map((playerId) => {
|
||||
const player = byId.get(playerId);
|
||||
const placement = session.byPlayerId[playerId];
|
||||
@@ -173,6 +239,8 @@ export function ScenePlayerTokensOverlay({
|
||||
displayScale={displayScale}
|
||||
gridFit={gridFit}
|
||||
{...(onMove ? { onMove } : {})}
|
||||
{...(onResize ? { onResize } : {})}
|
||||
{...(resizeTitle ? { resizeTitle } : {})}
|
||||
{...(snapNorm ? { snapNorm } : {})}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -46,3 +46,50 @@ export function useLiveDragBroadcast(
|
||||
|
||||
return { schedule, flush };
|
||||
}
|
||||
|
||||
/** Во время resize шлёт onResize не чаще одного раза за кадр. */
|
||||
export function useLiveResizeBroadcast(
|
||||
onResize: ((id: string, sizeN: number) => void) | undefined,
|
||||
id: string,
|
||||
): {
|
||||
schedule: (sizeN: number) => void;
|
||||
flush: (sizeN: number) => void;
|
||||
} {
|
||||
const rafRef = useRef(0);
|
||||
const pendingRef = useRef<number | null>(null);
|
||||
const onResizeRef = useRef(onResize);
|
||||
onResizeRef.current = onResize;
|
||||
const idRef = useRef(id);
|
||||
idRef.current = id;
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const schedule = useCallback((sizeN: number) => {
|
||||
if (!onResizeRef.current) return;
|
||||
pendingRef.current = sizeN;
|
||||
if (rafRef.current) return;
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = 0;
|
||||
const pending = pendingRef.current;
|
||||
const cb = onResizeRef.current;
|
||||
if (pending === null || !cb) return;
|
||||
cb(idRef.current, pending);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const flush = useCallback((sizeN: number) => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = 0;
|
||||
}
|
||||
pendingRef.current = null;
|
||||
onResizeRef.current?.(idRef.current, sizeN);
|
||||
}, []);
|
||||
|
||||
return { schedule, flush };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { NpcMapSpawnDragState } from '../../../shared/types';
|
||||
import { asNpcId } from '../../../shared/types/ids';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
const EMPTY: NpcMapSpawnDragState = {
|
||||
dragging: false,
|
||||
npcId: null,
|
||||
hoverNx: null,
|
||||
hoverNy: null,
|
||||
};
|
||||
|
||||
export function useNpcMapSpawnDrag(): [
|
||||
NpcMapSpawnDragState,
|
||||
{
|
||||
beginDrag: (npcId: string) => Promise<void>;
|
||||
setHover: (nx: number | null, ny: number | null) => Promise<void>;
|
||||
commit: () => Promise<boolean>;
|
||||
cancel: () => Promise<void>;
|
||||
},
|
||||
] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<NpcMapSpawnDragState>(EMPTY);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.npcMapSpawn.getState, {}).then(({ state: s }) => {
|
||||
setState(s);
|
||||
});
|
||||
return api.on(ipcChannels.npcMapSpawn.stateChanged, ({ state: s }) => {
|
||||
setState(s);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const apiWrap = useMemo(
|
||||
() => ({
|
||||
beginDrag: async (npcId: string) => {
|
||||
await api.invoke(ipcChannels.npcMapSpawn.beginDrag, { npcId: asNpcId(npcId) });
|
||||
},
|
||||
setHover: async (nx: number | null, ny: number | null) => {
|
||||
await api.invoke(ipcChannels.npcMapSpawn.setHover, { nx, ny });
|
||||
},
|
||||
commit: async () => {
|
||||
const res = await api.invoke(ipcChannels.npcMapSpawn.commit, {});
|
||||
return Boolean(res.spawned);
|
||||
},
|
||||
cancel: async () => {
|
||||
await api.invoke(ipcChannels.npcMapSpawn.cancel, {});
|
||||
},
|
||||
}),
|
||||
[api],
|
||||
);
|
||||
|
||||
return [state, apiWrap];
|
||||
}
|
||||
@@ -2,14 +2,21 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type {
|
||||
SceneNpcSessionSpawn,
|
||||
SceneNpcTokensSessionEvent,
|
||||
SceneNpcTokensSessionPlacement,
|
||||
SceneNpcTokensSessionState,
|
||||
} from '../../../shared/types';
|
||||
import {
|
||||
asSceneNpcTokenId,
|
||||
clampNpcTokenSessionScale,
|
||||
clampSceneNpcTokenSizeN,
|
||||
DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
|
||||
emptySceneNpcTokensSessionState,
|
||||
newSceneNpcSessionSpawnId,
|
||||
} from '../../../shared/types/appPlayers';
|
||||
import { asNpcId } from '../../../shared/types/ids';
|
||||
import { normalizeNpcDisposition } from '../../../shared/types/npcDisposition';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
@@ -17,35 +24,89 @@ function withScale(state: SceneNpcTokensSessionState): SceneNpcTokensSessionStat
|
||||
return {
|
||||
...state,
|
||||
scale: clampNpcTokenSessionScale(state.scale),
|
||||
spawned: state.spawned ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
|
||||
return p.nx === undefined && p.ny === undefined && !p.disposition && !p.inactive;
|
||||
return (
|
||||
p.nx === undefined &&
|
||||
p.ny === undefined &&
|
||||
p.sizeN === undefined &&
|
||||
!p.disposition &&
|
||||
!p.inactive
|
||||
);
|
||||
}
|
||||
|
||||
function restOf(prev: SceneNpcTokensSessionPlacement | undefined): SceneNpcTokensSessionPlacement {
|
||||
return {
|
||||
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
|
||||
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
|
||||
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
|
||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||
...(prev?.inactive ? { inactive: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function applyEvent(
|
||||
prev: SceneNpcTokensSessionState,
|
||||
event: SceneNpcTokensSessionEvent,
|
||||
): SceneNpcTokensSessionState {
|
||||
const spawned = prev.spawned ?? {};
|
||||
if (event.kind === 'clear') {
|
||||
return {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: {},
|
||||
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
};
|
||||
return emptySceneNpcTokensSessionState(prev.revision + 1);
|
||||
}
|
||||
if (event.kind === 'setScale') {
|
||||
const scale = clampNpcTokenSessionScale(event.scale);
|
||||
if (prev.scale === scale) return prev;
|
||||
return { ...prev, revision: prev.revision + 1, scale };
|
||||
return { ...prev, revision: prev.revision + 1, scale, spawned };
|
||||
}
|
||||
if (event.kind === 'spawn') {
|
||||
const npcId = String(event.npcId ?? '');
|
||||
const sceneId = String(event.sceneId ?? '');
|
||||
if (!npcId || !sceneId) return prev;
|
||||
const id = event.placementId
|
||||
? asSceneNpcTokenId(String(event.placementId))
|
||||
: newSceneNpcSessionSpawnId();
|
||||
const spawn: SceneNpcSessionSpawn = {
|
||||
id,
|
||||
sceneId,
|
||||
npcId: asNpcId(npcId),
|
||||
nx: Math.max(0, Math.min(1, event.nx)),
|
||||
ny: Math.max(0, Math.min(1, event.ny)),
|
||||
sizeN: clampSceneNpcTokenSizeN(
|
||||
typeof event.sizeN === 'number' ? event.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
|
||||
),
|
||||
disposition: normalizeNpcDisposition(event.disposition),
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
revision: prev.revision + 1,
|
||||
spawned: { ...spawned, [String(id)]: spawn },
|
||||
};
|
||||
}
|
||||
if (event.kind === 'despawn') {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return prev;
|
||||
if (!(placementId in spawned) && !(placementId in prev.byPlacementId)) return prev;
|
||||
const { [placementId]: _s, ...nextSpawned } = spawned;
|
||||
const { [placementId]: _p, ...byPlacementId } = prev.byPlacementId;
|
||||
return {
|
||||
...prev,
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId,
|
||||
spawned: nextSpawned,
|
||||
};
|
||||
}
|
||||
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;
|
||||
const spawn = spawned[placementId];
|
||||
if (cur?.disposition === disposition && (!spawn || spawn.disposition === disposition)) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: {
|
||||
@@ -53,11 +114,13 @@ function applyEvent(
|
||||
[placementId]: {
|
||||
...(cur?.nx !== undefined ? { nx: cur.nx } : {}),
|
||||
...(cur?.ny !== undefined ? { ny: cur.ny } : {}),
|
||||
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
|
||||
disposition,
|
||||
...(cur?.inactive ? { inactive: true } : {}),
|
||||
},
|
||||
},
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
spawned: spawn ? { ...spawned, [placementId]: { ...spawn, disposition } } : spawned,
|
||||
};
|
||||
}
|
||||
if (event.kind === 'setInactive') {
|
||||
@@ -69,6 +132,7 @@ function applyEvent(
|
||||
const next: SceneNpcTokensSessionPlacement = {
|
||||
...(cur?.nx !== undefined ? { nx: cur.nx } : {}),
|
||||
...(cur?.ny !== undefined ? { ny: cur.ny } : {}),
|
||||
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
|
||||
...(cur?.disposition ? { disposition: cur.disposition } : {}),
|
||||
...(inactive ? { inactive: true } : {}),
|
||||
};
|
||||
@@ -78,6 +142,7 @@ function applyEvent(
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: rest,
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
spawned,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -87,12 +152,31 @@ function applyEvent(
|
||||
[placementId]: next,
|
||||
},
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
spawned,
|
||||
};
|
||||
}
|
||||
if (event.kind === 'setSize') {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return prev;
|
||||
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
|
||||
const cur = prev.byPlacementId[placementId];
|
||||
const spawn = spawned[placementId];
|
||||
if (cur?.sizeN === sizeN && (!spawn || spawn.sizeN === sizeN)) return prev;
|
||||
return {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: {
|
||||
...prev.byPlacementId,
|
||||
[placementId]: { ...restOf(cur), sizeN },
|
||||
},
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
spawned: spawn ? { ...spawned, [placementId]: { ...spawn, sizeN } } : spawned,
|
||||
};
|
||||
}
|
||||
// move
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return prev;
|
||||
const cur = prev.byPlacementId[placementId];
|
||||
const spawn = spawned[placementId];
|
||||
return {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: {
|
||||
@@ -100,11 +184,13 @@ function applyEvent(
|
||||
[placementId]: {
|
||||
nx: event.nx,
|
||||
ny: event.ny,
|
||||
...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
|
||||
...(cur?.disposition ? { disposition: cur.disposition } : {}),
|
||||
...(cur?.inactive ? { inactive: true } : {}),
|
||||
},
|
||||
},
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
spawned: spawn ? { ...spawned, [placementId]: { ...spawn, nx: event.nx, ny: event.ny } } : spawned,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,17 +199,14 @@ export function useSceneNpcTokensSession(): [
|
||||
{ dispatch: (event: SceneNpcTokensSessionEvent) => void },
|
||||
] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<SceneNpcTokensSessionState>({
|
||||
revision: 0,
|
||||
byPlacementId: {},
|
||||
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
});
|
||||
const [state, setState] = useState<SceneNpcTokensSessionState>(() =>
|
||||
emptySceneNpcTokensSessionState(0),
|
||||
);
|
||||
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);
|
||||
|
||||
@@ -55,6 +55,18 @@ function applyEvent(
|
||||
},
|
||||
};
|
||||
}
|
||||
if (event.kind === 'setSize') {
|
||||
const prevPlacement = prev.byPlayerId[event.playerId];
|
||||
if (!prevPlacement) return prev;
|
||||
return {
|
||||
...prev,
|
||||
revision: prev.revision + 1,
|
||||
byPlayerId: {
|
||||
...prev.byPlayerId,
|
||||
[event.playerId]: { ...prevPlacement, sizeN: event.sizeN },
|
||||
},
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user