feat(players): app Players library and circular NPC tokens on scenes
Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type {
|
||||
SceneNpcTokensSessionEvent,
|
||||
SceneNpcTokensSessionState,
|
||||
} from '../../../shared/types';
|
||||
import {
|
||||
clampNpcTokenSessionScale,
|
||||
DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
} from '../../../shared/types/appPlayers';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
function withScale(state: SceneNpcTokensSessionState): SceneNpcTokensSessionState {
|
||||
return {
|
||||
...state,
|
||||
scale: clampNpcTokenSessionScale(state.scale),
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
return {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: {
|
||||
...prev.byPlacementId,
|
||||
[event.placementId]: { nx: event.nx, ny: event.ny },
|
||||
},
|
||||
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 }];
|
||||
}
|
||||
Reference in New Issue
Block a user