feat(control): трекер инициативы в отдельном окне
Очередь NPC/игроков с DnD, курсором на сцене, состояниями и запасом; сброс при смене сцены. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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'));
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user