Files
DndGamePlayer/app/renderer/shared/playerToken/useSceneNpcTokensSession.ts
T
Ivan Fontosh 101f595bac feat(players): app Players library and circular NPC tokens on scenes
Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:38:35 +08:00

89 lines
2.6 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type {
SceneNpcTokensSessionEvent,
SceneNpcTokensSessionState,
} from '../../../shared/types';
import {
clampNpcTokenSessionScale,
DEFAULT_NPC_TOKEN_SESSION_SCALE,
} from '../../../shared/types/appPlayers';
import { getDndApi } from '../dndApi';
function withScale(state: SceneNpcTokensSessionState): SceneNpcTokensSessionState {
return {
...state,
scale: clampNpcTokenSessionScale(state.scale),
};
}
function applyEvent(
prev: SceneNpcTokensSessionState,
event: SceneNpcTokensSessionEvent,
): SceneNpcTokensSessionState {
if (event.kind === 'clear') {
return {
revision: prev.revision + 1,
byPlacementId: {},
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
};
}
if (event.kind === 'setScale') {
const scale = clampNpcTokenSessionScale(event.scale);
if (prev.scale === scale) return prev;
return { ...prev, revision: prev.revision + 1, scale };
}
return {
revision: prev.revision + 1,
byPlacementId: {
...prev.byPlacementId,
[event.placementId]: { nx: event.nx, ny: event.ny },
},
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
};
}
export function useSceneNpcTokensSession(): [
SceneNpcTokensSessionState,
{ dispatch: (event: SceneNpcTokensSessionEvent) => void },
] {
const api = getDndApi();
const [state, setState] = useState<SceneNpcTokensSessionState>({
revision: 0,
byPlacementId: {},
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
});
const localRevRef = useRef(0);
useEffect(() => {
void api.invoke(ipcChannels.sceneNpcTokensSession.getState, {}).then((res) => {
const next = withScale(res.state);
// Не затираем более свежий optimistic/broadcast стейт устаревшим getState.
if (next.revision < localRevRef.current) return;
localRevRef.current = next.revision;
setState(next);
});
return api.on(ipcChannels.sceneNpcTokensSession.stateChanged, ({ state: incoming }) => {
const next = withScale(incoming);
if (next.revision < localRevRef.current) return;
localRevRef.current = next.revision;
setState(next);
});
}, [api]);
const dispatch = useCallback(
(event: SceneNpcTokensSessionEvent) => {
setState((prev) => {
const next = applyEvent(prev, event);
localRevRef.current = Math.max(localRevRef.current, next.revision);
return next;
});
void api.invoke(ipcChannels.sceneNpcTokensSession.dispatch, { event });
},
[api],
);
return [state, { dispatch }];
}