Files
DndGamePlayer/app/renderer/shared/playerToken/useSceneNpcTokensSession.ts
T
2026-08-18 14:42:06 +08:00

236 lines
7.9 KiB
TypeScript

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';
function withScale(state: SceneNpcTokensSessionState): SceneNpcTokensSessionState {
return {
...state,
scale: clampNpcTokenSessionScale(state.scale),
spawned: state.spawned ?? {},
};
}
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
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 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, 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];
const spawn = spawned[placementId];
if (cur?.disposition === disposition && (!spawn || spawn.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 } : {}),
...(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') {
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?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}),
...(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,
spawned,
};
}
return {
revision: prev.revision + 1,
byPlacementId: {
...prev.byPlacementId,
[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: {
...prev.byPlacementId,
[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,
};
}
export function useSceneNpcTokensSession(): [
SceneNpcTokensSessionState,
{ dispatch: (event: SceneNpcTokensSessionEvent) => void },
] {
const api = getDndApi();
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);
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 }];
}