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 };
});
@@ -0,0 +1,105 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { InitiativeSessionStore } from './initiativeSessionStore';
import {
initiativeNpcEntryId,
initiativePlayerEntryId,
} from '../../shared/types/initiative';
void test('initiative: addManual + next cycles order', () => {
const store = new InitiativeSessionStore();
store.dispatch({ kind: 'addManual', name: 'A' });
store.dispatch({ kind: 'addManual', name: 'B' });
const a = store.getState().order[0]!;
const b = store.getState().order[1]!;
assert.equal(store.getState().activeId, a);
store.dispatch({ kind: 'next' });
assert.equal(store.getState().activeId, b);
store.dispatch({ kind: 'next' });
assert.equal(store.getState().activeId, a);
});
void test('initiative: syncPresence adds npcs and players', () => {
const store = new InitiativeSessionStore();
store.dispatch({
kind: 'syncPresence',
npcs: [{ placementId: 'p1', npcId: 'n1', name: 'Goblin', avatarAssetId: null }],
players: [{ playerId: 'pl1', name: 'Hero' }],
playersVisible: true,
});
const st = store.getState();
assert.ok(st.entries[initiativeNpcEntryId('p1')]);
assert.ok(st.entries[initiativePlayerEntryId('pl1')]);
assert.deepEqual(st.order, [initiativeNpcEntryId('p1'), initiativePlayerEntryId('pl1')]);
});
void test('initiative: hide players moves them to reserve', () => {
const store = new InitiativeSessionStore();
store.dispatch({
kind: 'syncPresence',
npcs: [],
players: [{ playerId: 'pl1', name: 'Hero' }],
playersVisible: true,
});
const pid = initiativePlayerEntryId('pl1');
assert.ok(store.getState().order.includes(pid));
store.dispatch({
kind: 'syncPresence',
npcs: [],
players: [{ playerId: 'pl1', name: 'Hero' }],
playersVisible: false,
});
assert.ok(!store.getState().order.includes(pid));
assert.ok(store.getState().reserve.includes(pid));
store.dispatch({
kind: 'syncPresence',
npcs: [],
players: [{ playerId: 'pl1', name: 'Hero' }],
playersVisible: true,
});
assert.ok(store.getState().reserve.includes(pid));
assert.ok(!store.getState().order.includes(pid));
});
void test('initiative: conditions unique + reserve/restore', () => {
const store = new InitiativeSessionStore();
store.dispatch({ kind: 'addManual', name: 'X' });
const id = store.getState().order[0]!;
store.dispatch({ kind: 'addCondition', id, conditionId: 'poisoned' });
store.dispatch({ kind: 'addCondition', id, conditionId: 'poisoned' });
assert.deepEqual(store.getState().conditions[id], ['poisoned']);
store.dispatch({ kind: 'moveToReserve', id });
assert.deepEqual(store.getState().order, []);
assert.deepEqual(store.getState().reserve, [id]);
store.dispatch({ kind: 'restoreFromReserve', id });
assert.deepEqual(store.getState().order, [id]);
assert.deepEqual(store.getState().conditions[id], ['poisoned']);
});
void test('initiative: clear keeps enabled', () => {
const store = new InitiativeSessionStore();
store.dispatch({ kind: 'setEnabled', enabled: true });
store.dispatch({ kind: 'addManual', name: 'Z' });
store.clear();
assert.equal(store.getState().enabled, true);
assert.deepEqual(store.getState().order, []);
});
void test('initiative: despawn removes npc entry', () => {
const store = new InitiativeSessionStore();
store.dispatch({
kind: 'syncPresence',
npcs: [{ placementId: 'p1', npcId: 'n1', name: 'Goblin', avatarAssetId: null }],
players: [],
playersVisible: false,
});
store.dispatch({
kind: 'syncPresence',
npcs: [],
players: [],
playersVisible: false,
});
assert.deepEqual(store.getState().order, []);
assert.deepEqual(Object.keys(store.getState().entries), []);
});
@@ -0,0 +1,318 @@
import {
createInitialInitiativeState,
initiativeManualLetter,
initiativeNpcEntryId,
initiativePlayerEntryId,
isInitiativeConditionId,
newInitiativeManualId,
pickInitiativeManualGradient,
type InitiativeEntry,
type InitiativeEvent,
type InitiativePresenceNpc,
type InitiativePresencePlayer,
type InitiativeState,
} from '../../shared/types/initiative';
function withoutId(ids: readonly string[], id: string): string[] {
return ids.filter((x) => x !== id);
}
function ensureActiveValid(state: InitiativeState): InitiativeState {
if (state.activeId != null && state.order.includes(state.activeId)) return state;
const nextActive = state.order[0] ?? null;
if (state.activeId === nextActive) return state;
return { ...state, activeId: nextActive };
}
export class InitiativeSessionStore {
private state: InitiativeState = createInitialInitiativeState();
getState(): InitiativeState {
return this.state;
}
clear(): InitiativeState {
const enabled = this.state.enabled;
this.state = { ...createInitialInitiativeState(), enabled, revision: this.state.revision + 1 };
return this.state;
}
dispatch(event: InitiativeEvent): InitiativeState {
switch (event.kind) {
case 'clear': {
this.state = {
...createInitialInitiativeState(),
enabled: this.state.enabled,
revision: this.state.revision + 1,
};
return this.state;
}
case 'setEnabled': {
if (this.state.enabled === event.enabled) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
enabled: event.enabled,
};
return this.state;
}
case 'syncPresence': {
return this.syncPresence(event.npcs, event.players, event.playersVisible);
}
case 'reorder': {
const allowed = new Set(this.state.order);
const nextOrder = event.order.filter((id) => allowed.has(id));
for (const id of this.state.order) {
if (!nextOrder.includes(id)) nextOrder.push(id);
}
if (nextOrder.length === this.state.order.length && nextOrder.every((id, i) => id === this.state.order[i])) {
return this.state;
}
this.state = ensureActiveValid({
...this.state,
revision: this.state.revision + 1,
order: nextOrder,
});
return this.state;
}
case 'setActive': {
if (event.id != null && !this.state.order.includes(event.id)) return this.state;
if (this.state.activeId === event.id) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
activeId: event.id,
};
return this.state;
}
case 'next': {
if (this.state.order.length === 0) {
if (this.state.activeId == null) return this.state;
this.state = { ...this.state, revision: this.state.revision + 1, activeId: null };
return this.state;
}
const cur = this.state.activeId ? this.state.order.indexOf(this.state.activeId) : -1;
const nextIdx = cur < 0 ? 0 : (cur + 1) % this.state.order.length;
const nextId = this.state.order[nextIdx] ?? null;
if (nextId === this.state.activeId) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
activeId: nextId,
};
return this.state;
}
case 'addManual': {
const name = event.name.trim();
if (!name) return this.state;
const id = newInitiativeManualId();
const entry: InitiativeEntry = {
id,
kind: 'manual',
name,
letter: initiativeManualLetter(name),
gradient: pickInitiativeManualGradient(),
};
this.state = {
...this.state,
revision: this.state.revision + 1,
entries: { ...this.state.entries, [id]: entry },
order: [...this.state.order, id],
activeId: this.state.activeId ?? id,
};
return this.state;
}
case 'moveToReserve': {
if (!this.state.order.includes(event.id)) return this.state;
if (!this.state.entries[event.id]) return this.state;
const order = withoutId(this.state.order, event.id);
const reserve = this.state.reserve.includes(event.id)
? this.state.reserve
: [...this.state.reserve, event.id];
this.state = ensureActiveValid({
...this.state,
revision: this.state.revision + 1,
order,
reserve,
});
return this.state;
}
case 'restoreFromReserve': {
if (!this.state.reserve.includes(event.id)) return this.state;
if (!this.state.entries[event.id]) return this.state;
const reserve = withoutId(this.state.reserve, event.id);
const order = this.state.order.includes(event.id)
? this.state.order
: [...this.state.order, event.id];
this.state = {
...this.state,
revision: this.state.revision + 1,
order,
reserve,
activeId: this.state.activeId ?? event.id,
};
return this.state;
}
case 'deleteEntry': {
if (!this.state.entries[event.id]) return this.state;
const { [event.id]: _removed, ...entries } = this.state.entries;
const { [event.id]: _c, ...conditions } = this.state.conditions;
this.state = ensureActiveValid({
...this.state,
revision: this.state.revision + 1,
entries,
conditions,
order: withoutId(this.state.order, event.id),
reserve: withoutId(this.state.reserve, event.id),
});
return this.state;
}
case 'addCondition': {
if (!this.state.entries[event.id]) return this.state;
if (!isInitiativeConditionId(event.conditionId)) return this.state;
const cur = this.state.conditions[event.id] ?? [];
if (cur.includes(event.conditionId)) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
conditions: {
...this.state.conditions,
[event.id]: [...cur, event.conditionId],
},
};
return this.state;
}
case 'removeCondition': {
const cur = this.state.conditions[event.id];
if (!cur?.includes(event.conditionId)) return this.state;
const next = cur.filter((c) => c !== event.conditionId);
const conditions = { ...this.state.conditions };
if (next.length === 0) delete conditions[event.id];
else conditions[event.id] = next;
this.state = {
...this.state,
revision: this.state.revision + 1,
conditions,
};
return this.state;
}
default: {
const _exhaustive: never = event;
return _exhaustive;
}
}
}
private syncPresence(
npcs: readonly InitiativePresenceNpc[],
players: readonly InitiativePresencePlayer[],
playersVisible: boolean,
): InitiativeState {
const npcByPlacement = new Map(npcs.map((n) => [n.placementId, n]));
const playerById = new Map(players.map((p) => [p.playerId, p]));
let entries: Record<string, InitiativeEntry> = { ...this.state.entries };
let order = [...this.state.order];
let reserve = [...this.state.reserve];
let conditions = { ...this.state.conditions };
let changed = false;
for (const [id, entry] of Object.entries(entries)) {
if (entry.kind === 'manual') continue;
if (entry.kind === 'npc') {
const live = npcByPlacement.get(entry.placementId);
if (!live) {
delete entries[id];
delete conditions[id];
order = withoutId(order, id);
reserve = withoutId(reserve, id);
changed = true;
continue;
}
if (entry.name !== live.name || entry.avatarAssetId !== live.avatarAssetId || entry.npcId !== live.npcId) {
entries[id] = {
...entry,
npcId: live.npcId,
name: live.name,
avatarAssetId: live.avatarAssetId,
};
changed = true;
}
continue;
}
const live = playerById.get(entry.playerId);
if (!live) {
delete entries[id];
delete conditions[id];
order = withoutId(order, id);
reserve = withoutId(reserve, id);
changed = true;
continue;
}
if (entry.name !== live.name) {
entries[id] = { ...entry, name: live.name };
changed = true;
}
}
for (const npc of npcs) {
const id = initiativeNpcEntryId(npc.placementId);
if (entries[id]) continue;
entries[id] = {
id,
kind: 'npc',
placementId: npc.placementId,
npcId: npc.npcId,
name: npc.name,
avatarAssetId: npc.avatarAssetId,
};
order.push(id);
changed = true;
}
for (const player of players) {
const id = initiativePlayerEntryId(player.playerId);
if (entries[id]) continue;
entries[id] = {
id,
kind: 'player',
playerId: player.playerId,
name: player.name,
};
order.push(id);
changed = true;
}
const wasVisible = this.state.playersVisible;
if (wasVisible && !playersVisible) {
const playerIdsInOrder = order.filter((id) => entries[id]?.kind === 'player');
if (playerIdsInOrder.length > 0) {
for (const id of playerIdsInOrder) {
order = withoutId(order, id);
if (!reserve.includes(id)) reserve.push(id);
}
changed = true;
}
}
if (this.state.playersVisible !== playersVisible) changed = true;
if (!changed) {
// Still update playersVisible if somehow same structure
if (this.state.playersVisible === playersVisible) return this.state;
}
let next: InitiativeState = {
...this.state,
revision: this.state.revision + 1,
entries,
order,
reserve,
conditions,
playersVisible,
};
next = ensureActiveValid(next);
this.state = next;
return this.state;
}
}
@@ -60,6 +60,14 @@ void test('createWindows: окно таймера закрывается с mult
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeTimerWindow/);
});
void test('createWindows: окно инициативы закрывается с multi-window', () => {
const src = readCreateWindows();
assert.ok(src.includes('openInitiativeWindow'));
assert.ok(src.includes('closeInitiativeWindow'));
assert.ok(src.includes("createWindow('initiative'"));
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeInitiativeWindow/);
});
void test('createWindows: окно НПС закрывается с multi-window', () => {
const src = readCreateWindows();
assert.ok(src.includes('openNpcsWindow'));
+51
View File
@@ -22,6 +22,7 @@ export type WindowKind =
| 'materials'
| 'playlists'
| 'timer'
| 'initiative'
| 'npcsEditor'
| 'sceneEditor'
| 'tokenPathEditor'
@@ -64,6 +65,10 @@ const PLAYLISTS_WINDOW_HEIGHT = 800;
const TIMER_WINDOW_WIDTH = 360;
const TIMER_WINDOW_HEIGHT = 420;
/** Окно трекера инициативы. */
const INITIATIVE_WINDOW_WIDTH = 920;
const INITIATIVE_WINDOW_HEIGHT = 420;
/** Редактор НПС — как основной редактор, шире инспектор. */
const NPCS_EDITOR_WINDOW_WIDTH = 1400;
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
@@ -175,6 +180,7 @@ function closePlaySessionAuxiliaryWindows(): void {
closeMaterialsWindow();
closePlaylistsWindow();
closeTimerWindow();
closeInitiativeWindow();
closeNpcsWindow();
}
@@ -227,6 +233,8 @@ function pageNameForKind(kind: WindowKind): string {
return 'playlists.html';
case 'timer':
return 'timer.html';
case 'initiative':
return 'initiative.html';
case 'npcsEditor':
return 'npcsEditor.html';
case 'sceneEditor':
@@ -304,6 +312,7 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT };
if (kind === 'playlists') return { width: PLAYLISTS_WINDOW_WIDTH, height: PLAYLISTS_WINDOW_HEIGHT };
if (kind === 'timer') return { width: TIMER_WINDOW_WIDTH, height: TIMER_WINDOW_HEIGHT };
if (kind === 'initiative') return { width: INITIATIVE_WINDOW_WIDTH, height: INITIATIVE_WINDOW_HEIGHT };
if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT };
if (kind === 'sceneEditor') return { width: 1280, height: 800 };
if (kind === 'tokenPathEditor') return { width: 1100, height: 760 };
@@ -620,6 +629,13 @@ export function closeTimerWindow(): void {
}
}
export function closeInitiativeWindow(): void {
const win = windows.get('initiative');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function closeNpcsEditorWindow(): void {
const win = windows.get('npcsEditor');
if (win && !win.isDestroyed()) {
@@ -839,6 +855,41 @@ export function openTimerWindow(): void {
});
}
export function openInitiativeWindow(opts?: { onClosed?: () => void }): void {
const existing = windows.get('initiative');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('control') ?? windows.get('presentation');
const win = createWindow('initiative', parent ? { parent } : undefined);
if (opts?.onClosed) {
win.once('closed', () => {
opts.onClosed?.();
});
}
const { width, height } = win.getBounds();
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
const { x, y, width: dw, height: dh } = display.workArea;
win.setBounds({
x: Math.round(x + Math.max(0, (dw - width) / 2)),
y: Math.round(y + Math.max(0, (dh - height) / 2)),
width,
height,
});
win.webContents.once('did-finish-load', () => {
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
}
});
}
function positionNpcsEditorWindow(win: BrowserWindow): void {
const parent = windows.get('editor');
const { width, height } = win.getBounds();