a29c72b307
Инструкции вынесены в отдельное окно Electron; токены игроков не пропадают при смене сцены; предпросмотр и плейлисты адаптированы; история свёрнута по умолчанию. Co-authored-by: Cursor <cursoragent@cursor.com>
113 lines
3.6 KiB
TypeScript
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 }];
|
|
}
|