feat(control): трекер инициативы в отдельном окне

Очередь NPC/игроков с DnD, курсором на сцене, состояниями и запасом; сброс при смене сцены.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-04 10:09:51 +08:00
parent db1168a46c
commit 86d6d157f0
28 changed files with 1783 additions and 4 deletions
+114
View File
@@ -20,6 +20,7 @@ import {
} from '../shared/project/projectZipExtension';
import type { Project } from '../shared/types';
import { asNpcId } from '../shared/types/ids';
import { listVisibleSceneNpcTokens } from '../shared/types/appPlayers';
import {
asPreviewRotationDeg,
previewRotationStepsCw,
@@ -35,6 +36,7 @@ import { readPresentationSettings, writePresentationSettings } from './preferenc
import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
import { TimerSessionStore } from './sessionTimer/timerSessionStore';
import { InitiativeSessionStore } from './initiative/initiativeSessionStore';
import { ZipProjectStore } from './project/zipStore';
import { SceneViewStore } from './sceneView/sceneViewStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
@@ -69,6 +71,7 @@ import {
openMaterialsWindow,
openPlaylistsWindow,
openTimerWindow,
openInitiativeWindow,
openMultiWindow,
openNpcsEditorWindow,
openSceneEditorWindow,
@@ -78,6 +81,7 @@ import {
closeMaterialsWindow,
closePlaylistsWindow,
closeTimerWindow,
closeInitiativeWindow,
closeNpcsEditorWindow,
closeSceneEditorWindow,
closeTokenPathEditorWindow,
@@ -182,6 +186,7 @@ const videoStore = new VideoPlaybackStore();
const materialsOverlayStore = new MaterialsOverlayStore();
const npcsOverlayStore = new NpcsOverlayStore();
const timerSessionStore = new TimerSessionStore();
const initiativeSessionStore = new InitiativeSessionStore();
const sceneTokensSessionStore = new SceneTokensSessionStore();
const tokenPathSessionStore = new TokenPathSessionStore();
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
@@ -283,6 +288,64 @@ function emitSessionTimerState(): void {
}
}
function emitInitiativeState(): void {
const state = initiativeSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
if (win.isDestroyed() || win.webContents.isDestroyed()) continue;
try {
win.webContents.send(ipcChannels.initiative.stateChanged, { state });
} catch {
// Окно может умирать при закрытии пульта/сессии (дочернее initiative).
}
}
}
function syncInitiativePresenceFromSession(): void {
const project = projectStore.getOpenProject();
const sceneId = project?.currentSceneId ?? null;
const scene = sceneId && project ? project.scenes[sceneId] : undefined;
const npcSession = sceneNpcTokensSessionStore.getState();
const placements = listVisibleSceneNpcTokens(scene?.npcTokens, npcSession.spawned, sceneId);
const npcById = new Map((project?.npcs ?? []).map((n) => [String(n.id), n]));
const npcs = placements.map((p) => {
const npc = npcById.get(String(p.npcId));
return {
placementId: String(p.id),
npcId: String(p.npcId),
name: npc?.name?.trim() || 'NPC',
avatarAssetId: npc?.avatarAssetId ? String(npc.avatarAssetId) : null,
};
});
const playerSession = scenePlayerTokensSessionStore.getState();
const playersLib = playersStore?.listPlayers() ?? [];
const playerById = new Map(playersLib.map((p) => [String(p.id), p]));
const players = playerSession.selectedPlayerIds.map((id) => {
const p = playerById.get(String(id));
return {
playerId: String(id),
name: p?.name?.trim() || 'Player',
};
});
const before = initiativeSessionStore.getState().revision;
initiativeSessionStore.dispatch({
kind: 'syncPresence',
npcs,
players,
playersVisible: playerSession.visible,
});
if (initiativeSessionStore.getState().revision !== before) {
emitInitiativeState();
}
}
function clearAndResyncInitiative(): void {
initiativeSessionStore.clear();
syncInitiativePresenceFromSession();
emitInitiativeState();
}
function emitSceneDarknessState(): void {
const state = sceneDarknessStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
@@ -573,6 +636,7 @@ async function main() {
scenePlayerTokensSessionStore.reset();
tokenGridSnapSessionStore.reset();
timerSessionStore.clear();
initiativeSessionStore.clear();
if (USERS_BRANCH_FEATURES_ENABLED) {
const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
if (playerIds.length > 0) {
@@ -599,13 +663,17 @@ async function main() {
emitTokenGridSnapState();
emitEffectsState();
emitSessionTimerState();
syncInitiativePresenceFromSession();
emitInitiativeState();
return { ok: true };
});
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
closeMultiWindow();
timerSessionStore.clear();
initiativeSessionStore.clear();
tokenGridSnapSessionStore.reset();
emitSessionTimerState();
emitInitiativeState();
emitTokenGridSnapState();
return { ok: true };
});
@@ -669,6 +737,24 @@ async function main() {
closeTimerWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.openInitiative, () => {
openInitiativeWindow({
onClosed: () => {
initiativeSessionStore.dispatch({ kind: 'setEnabled', enabled: false });
emitInitiativeState();
},
});
initiativeSessionStore.dispatch({ kind: 'setEnabled', enabled: true });
syncInitiativePresenceFromSession();
emitInitiativeState();
return { ok: true };
});
registerHandler(ipcChannels.windows.closeInitiative, () => {
closeInitiativeWindow();
initiativeSessionStore.dispatch({ kind: 'setEnabled', enabled: false });
emitInitiativeState();
return { ok: true };
});
registerHandler(ipcChannels.windows.openNpcsEditor, () => {
openNpcsEditorWindow();
return { ok: true };
@@ -733,6 +819,14 @@ async function main() {
emitSessionTimerState();
return { state: timerSessionStore.getState() };
});
registerHandler(ipcChannels.initiative.getState, () => {
return { state: initiativeSessionStore.getState() };
});
registerHandler(ipcChannels.initiative.dispatch, ({ event }) => {
initiativeSessionStore.dispatch(event);
emitInitiativeState();
return { state: initiativeSessionStore.getState() };
});
registerHandler(ipcChannels.project.list, async () => {
const projects = await projectStore.listProjects();
@@ -775,6 +869,7 @@ async function main() {
materialsOverlayStore.clear();
npcsOverlayStore.clear();
timerSessionStore.clear();
initiativeSessionStore.clear();
sceneDarknessStore.resetSession();
sceneMarkerStore.resetSession();
sceneTrapsStore.resetSession();
@@ -788,6 +883,7 @@ async function main() {
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitSessionTimerState();
emitInitiativeState();
emitSceneDarknessState();
emitSceneMarkerState();
emitSceneTrapsState();
@@ -813,6 +909,7 @@ async function main() {
npcsOverlayStore.clear();
sceneViewStore.reset();
scenePlayerTokensSessionStore.clearPlacements();
clearAndResyncInitiative();
// Token moves persist for the whole play session (reset only on project open/close).
const project = projectStore.getOpenProject();
if (project) {
@@ -843,6 +940,7 @@ async function main() {
npcsOverlayStore.clear();
sceneViewStore.reset();
scenePlayerTokensSessionStore.clearPlacements();
clearAndResyncInitiative();
// Token moves persist for the whole play session (reset only on project open/close).
const project = projectStore.getOpenProject();
if (project) {
@@ -902,6 +1000,9 @@ async function main() {
syncSceneTrapsForProject(project);
emitSceneTrapsState();
}
if (project?.currentSceneId === sceneId && patch.npcTokens !== undefined) {
syncInitiativePresenceFromSession();
}
emitSessionState();
return { scene: next };
});
@@ -1879,6 +1980,9 @@ async function main() {
registerHandler(ipcChannels.sceneNpcTokensSession.dispatch, ({ event }) => {
sceneNpcTokensSessionStore.dispatch(event);
emitSceneNpcTokensSessionState();
if (event.kind === 'spawn' || event.kind === 'despawn' || event.kind === 'clear') {
syncInitiativePresenceFromSession();
}
return { ok: true };
});
registerHandler(ipcChannels.npcMapSpawn.getState, () => {
@@ -1935,6 +2039,7 @@ async function main() {
disposition: npc.disposition,
});
emitSceneNpcTokensSessionState();
syncInitiativePresenceFromSession();
return { spawned: true };
});
registerHandler(ipcChannels.scenePlayerTokensSession.getState, () => {
@@ -1943,6 +2048,15 @@ async function main() {
registerHandler(ipcChannels.scenePlayerTokensSession.dispatch, ({ event }) => {
scenePlayerTokensSessionStore.dispatch(event);
emitScenePlayerTokensSessionState();
if (
event.kind === 'setSelection' ||
event.kind === 'setVisible' ||
event.kind === 'show' ||
event.kind === 'clear' ||
event.kind === 'clearPlacements'
) {
syncInitiativePresenceFromSession();
}
return { ok: true };
});