feat(control): spawn session NPCs and resize tokens in preview
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,14 +2,21 @@ 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';
|
||||
|
||||
@@ -17,35 +24,89 @@ function withScale(state: SceneNpcTokensSessionState): SceneNpcTokensSessionStat
|
||||
return {
|
||||
...state,
|
||||
scale: clampNpcTokenSessionScale(state.scale),
|
||||
spawned: state.spawned ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
|
||||
return p.nx === undefined && p.ny === undefined && !p.disposition && !p.inactive;
|
||||
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 {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: {},
|
||||
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
};
|
||||
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 };
|
||||
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];
|
||||
if (cur?.disposition === disposition) return prev;
|
||||
const spawn = spawned[placementId];
|
||||
if (cur?.disposition === disposition && (!spawn || spawn.disposition === disposition)) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: {
|
||||
@@ -53,11 +114,13 @@ function applyEvent(
|
||||
[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') {
|
||||
@@ -69,6 +132,7 @@ function applyEvent(
|
||||
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 } : {}),
|
||||
};
|
||||
@@ -78,6 +142,7 @@ function applyEvent(
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: rest,
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
spawned,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -87,12 +152,31 @@ function applyEvent(
|
||||
[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: {
|
||||
@@ -100,11 +184,13 @@ function applyEvent(
|
||||
[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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,17 +199,14 @@ export function useSceneNpcTokensSession(): [
|
||||
{ dispatch: (event: SceneNpcTokensSessionEvent) => void },
|
||||
] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<SceneNpcTokensSessionState>({
|
||||
revision: 0,
|
||||
byPlacementId: {},
|
||||
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
});
|
||||
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);
|
||||
// Не затираем более свежий optimistic/broadcast стейт устаревшим getState.
|
||||
if (next.revision < localRevRef.current) return;
|
||||
localRevRef.current = next.revision;
|
||||
setState(next);
|
||||
|
||||
Reference in New Issue
Block a user