feat(players): NPC disposition types and launch-with-players session tokens
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>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
|
||||
export function useScenePlayerTokensSession(): [
|
||||
ScenePlayerTokensSessionState,
|
||||
{ dispatch: (event: ScenePlayerTokensSessionEvent) => void },
|
||||
] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<ScenePlayerTokensSessionState>({
|
||||
revision: 0,
|
||||
selectedPlayerIds: [],
|
||||
visible: false,
|
||||
byPlayerId: {},
|
||||
});
|
||||
const localRevRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.scenePlayerTokensSession.getState, {}).then((res) => {
|
||||
if (res.state.revision < localRevRef.current) return;
|
||||
localRevRef.current = res.state.revision;
|
||||
setState(res.state);
|
||||
});
|
||||
return api.on(ipcChannels.scenePlayerTokensSession.stateChanged, ({ state: next }) => {
|
||||
if (next.revision < localRevRef.current) return;
|
||||
localRevRef.current = next.revision;
|
||||
setState(next);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const dispatch = useCallback(
|
||||
(event: ScenePlayerTokensSessionEvent) => {
|
||||
if (event.kind !== 'seedBottom' && event.kind !== 'show') {
|
||||
setState((prev) => {
|
||||
const next = applyEvent(prev, event);
|
||||
localRevRef.current = Math.max(localRevRef.current, next.revision);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
void api.invoke(ipcChannels.scenePlayerTokensSession.dispatch, { event });
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
return [state, { dispatch }];
|
||||
}
|
||||
Reference in New Issue
Block a user