Files
DndGamePlayer/app/renderer/shared/playerToken/useScenePlayerTokensSession.ts
T
Ivan Fontosh a29c72b307 feat: окно инструкций, свёрнутая история и обновление спонсоров
Инструкции вынесены в отдельное окно Electron; токены игроков не пропадают при смене сцены; предпросмотр и плейлисты адаптированы; история свёрнута по умолчанию.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 06:21:42 +08:00

113 lines
3.6 KiB
TypeScript

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,
},
},
};
}
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;
}
export function useScenePlayerTokensSession(): [
ScenePlayerTokensSessionState,
{ dispatch: (event: ScenePlayerTokensSessionEvent) => void },
] {
const api = getDndApi();
const [state, setState] = useState<ScenePlayerTokensSessionState>({
revision: 0,
selectedPlayerIds: [],
visible: false,
byPlayerId: {},
});
/** Только revision с main. Оптимистичные move/setSize не должны глушить смену сцены. */
const serverRevRef = useRef(0);
useEffect(() => {
const applyServer = (next: ScenePlayerTokensSessionState) => {
if (next.revision < serverRevRef.current) return;
serverRevRef.current = next.revision;
setState(next);
};
void api.invoke(ipcChannels.scenePlayerTokensSession.getState, {}).then((res) => {
applyServer(res.state);
});
return api.on(ipcChannels.scenePlayerTokensSession.stateChanged, ({ state: next }) => {
applyServer(next);
});
}, [api]);
const dispatch = useCallback(
(event: ScenePlayerTokensSessionEvent) => {
if (event.kind !== 'seedBottom' && event.kind !== 'show') {
setState((prev) => applyEvent(prev, event));
}
void api.invoke(ipcChannels.scenePlayerTokensSession.dispatch, { event });
},
[api],
);
return [state, { dispatch }];
}