cc50e64e21
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>
153 lines
4.9 KiB
TypeScript
153 lines
4.9 KiB
TypeScript
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 {
|
|
return {
|
|
...state,
|
|
scale: clampNpcTokenSessionScale(state.scale),
|
|
};
|
|
}
|
|
|
|
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
|
|
return p.nx === undefined && p.ny === undefined && !p.disposition && !p.inactive;
|
|
}
|
|
|
|
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 };
|
|
}
|
|
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,
|
|
[placementId]: {
|
|
nx: event.nx,
|
|
ny: event.ny,
|
|
...(cur?.disposition ? { disposition: cur.disposition } : {}),
|
|
...(cur?.inactive ? { inactive: true } : {}),
|
|
},
|
|
},
|
|
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 }];
|
|
}
|