diff --git a/app/main/index.ts b/app/main/index.ts index 56e2acb..cd350d2 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -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 }; }); diff --git a/app/main/initiative/initiativeSessionStore.test.ts b/app/main/initiative/initiativeSessionStore.test.ts new file mode 100644 index 0000000..960ff05 --- /dev/null +++ b/app/main/initiative/initiativeSessionStore.test.ts @@ -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), []); +}); diff --git a/app/main/initiative/initiativeSessionStore.ts b/app/main/initiative/initiativeSessionStore.ts new file mode 100644 index 0000000..e6b050f --- /dev/null +++ b/app/main/initiative/initiativeSessionStore.ts @@ -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 = { ...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; + } +} diff --git a/app/main/windows/createWindows.editorClose.test.ts b/app/main/windows/createWindows.editorClose.test.ts index 8078213..fc5b1ed 100644 --- a/app/main/windows/createWindows.editorClose.test.ts +++ b/app/main/windows/createWindows.editorClose.test.ts @@ -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')); diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts index 544974a..1a6a3bc 100644 --- a/app/main/windows/createWindows.ts +++ b/app/main/windows/createWindows.ts @@ -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(); diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 025384e..52290a4 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -1859,6 +1859,22 @@ export function ControlApp() { ⏱ + + + + {conditionsOpen && menuPos + ? createPortal( +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + {available.length === 0 ? ( +
{t('initiative.conditionsEmpty')}
+ ) : ( + available.map((id) => ( + + )) + )} +
, + document.body, + ) + : null} + + ); +} + +export function InitiativeApp() { + const { t } = useEditorI18n(); + const api = getDndApi(); + const [state, initiativeApi] = useInitiativeState(); + const [manualName, setManualName] = useState(''); + const [conditionsFor, setConditionsFor] = useState(null); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + void api.invoke(ipcChannels.windows.closeInitiative, {}); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [api]); + + const orderEntries = useMemo(() => { + if (!state) return []; + return state.order.map((id) => state.entries[id]).filter(Boolean) as InitiativeEntry[]; + }, [state]); + + const reserveEntries = useMemo(() => { + if (!state) return []; + return state.reserve.map((id) => state.entries[id]).filter(Boolean) as InitiativeEntry[]; + }, [state]); + + if (!state) { + return
; + } + + const reorderDrop = (targetId: string, e: React.DragEvent) => { + e.preventDefault(); + const fromId = e.dataTransfer.getData(DND_MIME); + if (!fromId || fromId === targetId) return; + const next = [...state.order]; + const fromIdx = next.indexOf(fromId); + const toIdx = next.indexOf(targetId); + if (fromIdx < 0 || toIdx < 0) return; + next.splice(fromIdx, 1); + next.splice(toIdx, 0, fromId); + void initiativeApi.dispatch({ kind: 'reorder', order: next }); + }; + + const addManual = () => { + const name = manualName.trim(); + if (!name) return; + void initiativeApi.dispatch({ kind: 'addManual', name }); + setManualName(''); + }; + + return ( +
+
+ +
+ { + if (e.key === 'Enter') { + e.preventDefault(); + addManual(); + } + }} + /> + +
+
+ +
{t('initiative.queue')}
+
+ {orderEntries.length === 0 ? ( +
{t('initiative.queueEmpty')}
+ ) : ( + orderEntries.map((entry) => ( + void initiativeApi.dispatch({ kind: 'setActive', id: entry.id })} + onRemove={() => void initiativeApi.dispatch({ kind: 'moveToReserve', id: entry.id })} + onDragStart={(e) => { + e.dataTransfer.setData(DND_MIME, entry.id); + e.dataTransfer.effectAllowed = 'move'; + }} + onDragOver={(e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }} + onDrop={(e) => reorderDrop(entry.id, e)} + conditionsOpen={conditionsFor === entry.id} + onToggleConditions={() => + setConditionsFor((cur) => (cur === entry.id ? null : entry.id)) + } + onAddCondition={(conditionId) => { + void initiativeApi.dispatch({ kind: 'addCondition', id: entry.id, conditionId }); + setConditionsFor(null); + }} + onRemoveCondition={(conditionId) => { + void initiativeApi.dispatch({ kind: 'removeCondition', id: entry.id, conditionId }); + }} + /> + )) + )} +
+ +
{t('initiative.reserve')}
+
+ {reserveEntries.length === 0 ? ( +
{t('initiative.reserveEmpty')}
+ ) : ( + reserveEntries.map((entry) => ( +
+ + {entry.name} + + {entry.kind === 'manual' ? ( + + ) : null} +
+ )) + )} +
+
+ ); +} diff --git a/app/renderer/initiative/main.tsx b/app/renderer/initiative/main.tsx new file mode 100644 index 0000000..8b91f32 --- /dev/null +++ b/app/renderer/initiative/main.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; + +import '../shared/ui/globals.css'; +import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext'; +import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary'; + +import { InitiativeApp } from './InitiativeApp'; + +const rootEl = document.getElementById('root'); +if (!rootEl) { + throw new Error('Missing #root element'); +} + +createRoot(rootEl).render( + + + + + + + , +); diff --git a/app/renderer/presentation/main.tsx b/app/renderer/presentation/main.tsx index f95cbaa..97cb3e6 100644 --- a/app/renderer/presentation/main.tsx +++ b/app/renderer/presentation/main.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import '../shared/ui/globals.css'; +import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext'; import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary'; import { PresentationApp } from './PresentationApp'; @@ -14,7 +15,9 @@ if (!rootEl) { createRoot(rootEl).render( - + + + , ); diff --git a/app/renderer/shared/initiative/InitiativeConditionChips.module.css b/app/renderer/shared/initiative/InitiativeConditionChips.module.css new file mode 100644 index 0000000..53aef41 --- /dev/null +++ b/app/renderer/shared/initiative/InitiativeConditionChips.module.css @@ -0,0 +1,55 @@ +.row { + display: flex; + flex-wrap: wrap; + gap: 4px; + justify-content: center; + max-width: none; + width: 100%; + pointer-events: none; +} + +.compact { + gap: 3px; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid rgba(96, 165, 250, 0.75); + background: rgba(15, 23, 42, 0.85); + color: #93c5fd; + font-size: 11px; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; + pointer-events: auto; +} + +.compact .chip { + font-size: 9px; + padding: 1px 6px; +} + +.chipLabel { + pointer-events: none; +} + +.chipRemove { + appearance: none; + border: none; + background: transparent; + color: inherit; + padding: 0; + margin: 0; + cursor: pointer; + font-size: 12px; + line-height: 1; + opacity: 0.85; +} + +.chipRemove:hover { + opacity: 1; +} diff --git a/app/renderer/shared/initiative/InitiativeConditionChips.tsx b/app/renderer/shared/initiative/InitiativeConditionChips.tsx new file mode 100644 index 0000000..2045349 --- /dev/null +++ b/app/renderer/shared/initiative/InitiativeConditionChips.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import type { InitiativeConditionId } from '../../../shared/types/initiative'; +import { useEditorI18n } from '../../editor/i18n/EditorI18nContext'; + +import styles from './InitiativeConditionChips.module.css'; + +export function InitiativeConditionChips({ + conditions, + removable = false, + onRemove, + compact = false, +}: { + conditions: readonly InitiativeConditionId[]; + removable?: boolean; + onRemove?: (id: InitiativeConditionId) => void; + compact?: boolean; +}) { + const { t } = useEditorI18n(); + if (conditions.length === 0) return null; + return ( +
+ {conditions.map((id) => ( + + {t(`initiative.condition.${id}`)} + {removable ? ( + + ) : null} + + ))} +
+ ); +} diff --git a/app/renderer/shared/initiative/TokenInitiativeDecor.module.css b/app/renderer/shared/initiative/TokenInitiativeDecor.module.css new file mode 100644 index 0000000..7f93a89 --- /dev/null +++ b/app/renderer/shared/initiative/TokenInitiativeDecor.module.css @@ -0,0 +1,13 @@ +.wrap { + position: absolute; + left: 50%; + top: 100%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + margin-top: 4px; + z-index: 3; + pointer-events: none; +} diff --git a/app/renderer/shared/initiative/TokenInitiativeDecor.tsx b/app/renderer/shared/initiative/TokenInitiativeDecor.tsx new file mode 100644 index 0000000..8b52b49 --- /dev/null +++ b/app/renderer/shared/initiative/TokenInitiativeDecor.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import type { InitiativeConditionId } from '../../../shared/types/initiative'; + +import { InitiativeConditionChips } from './InitiativeConditionChips'; +import styles from './TokenInitiativeDecor.module.css'; + +export { + initiativeConditionsForNpc, + initiativeConditionsForPlayer, + initiativeHighlightForNpc, + initiativeHighlightForPlayer, +} from './initiativeSceneHelpers'; + +/** Чипы состояний под токеном (видны и на презентации). */ +export function TokenInitiativeDecor({ + conditions, +}: { + conditions: readonly InitiativeConditionId[]; +}) { + if (conditions.length === 0) return null; + return ( +
+ +
+ ); +} diff --git a/app/renderer/shared/initiative/initiativeSceneHelpers.ts b/app/renderer/shared/initiative/initiativeSceneHelpers.ts new file mode 100644 index 0000000..1addda9 --- /dev/null +++ b/app/renderer/shared/initiative/initiativeSceneHelpers.ts @@ -0,0 +1,37 @@ +import type { InitiativeConditionId, InitiativeState } from '../../../shared/types/initiative'; +import { + initiativeNpcEntryId, + initiativePlayerEntryId, +} from '../../../shared/types/initiative'; + +export function initiativeHighlightForNpc( + state: InitiativeState | null | undefined, + placementId: string, +): boolean { + if (!state?.enabled || !state.activeId) return false; + return state.activeId === initiativeNpcEntryId(placementId); +} + +export function initiativeHighlightForPlayer( + state: InitiativeState | null | undefined, + playerId: string, +): boolean { + if (!state?.enabled || !state.activeId) return false; + return state.activeId === initiativePlayerEntryId(playerId); +} + +export function initiativeConditionsForNpc( + state: InitiativeState | null | undefined, + placementId: string, +): InitiativeConditionId[] { + if (!state) return []; + return state.conditions[initiativeNpcEntryId(placementId)] ?? []; +} + +export function initiativeConditionsForPlayer( + state: InitiativeState | null | undefined, + playerId: string, +): InitiativeConditionId[] { + if (!state) return []; + return state.conditions[initiativePlayerEntryId(playerId)] ?? []; +} diff --git a/app/renderer/shared/initiative/useInitiativeState.ts b/app/renderer/shared/initiative/useInitiativeState.ts new file mode 100644 index 0000000..32f7697 --- /dev/null +++ b/app/renderer/shared/initiative/useInitiativeState.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from 'react'; + +import { ipcChannels } from '../../../shared/ipc/contracts'; +import type { InitiativeEvent, InitiativeState } from '../../../shared/types'; +import { getDndApi } from '../dndApi'; + +export function useInitiativeState(): readonly [ + InitiativeState | null, + { dispatch: (event: InitiativeEvent) => Promise }, +] { + const api = getDndApi(); + const [state, setState] = useState(null); + + useEffect(() => { + void api.invoke(ipcChannels.initiative.getState, {}).then((r) => { + setState(r.state); + }); + return api.on(ipcChannels.initiative.stateChanged, ({ state: next }) => { + setState(next); + }); + }, [api]); + + return [ + state, + { + dispatch: async (event) => { + const r = await api.invoke(ipcChannels.initiative.dispatch, { event }); + setState(r.state); + return r.state; + }, + }, + ] as const; +} diff --git a/app/renderer/shared/playerToken/SceneNpcTokensOverlay.module.css b/app/renderer/shared/playerToken/SceneNpcTokensOverlay.module.css index b2e3fa0..3b1d40d 100644 --- a/app/renderer/shared/playerToken/SceneNpcTokensOverlay.module.css +++ b/app/renderer/shared/playerToken/SceneNpcTokensOverlay.module.css @@ -30,6 +30,27 @@ cursor: grabbing; } +.initiativeActive { + z-index: 10; +} + +/* Кольцо строго по центру круга токена (обёртка = sizePx × sizePx). */ +.initiativeActive::before { + content: ''; + position: absolute; + left: 50%; + top: 50%; + width: 100%; + height: 100%; + transform: translate(-50%, -50%); + border-radius: 50%; + border: 3px solid #f59e0b; + background: transparent; + box-shadow: none; + pointer-events: none; + z-index: 1; +} + .resizeHandle { position: absolute; right: -5px; diff --git a/app/renderer/shared/playerToken/SceneNpcTokensOverlay.tsx b/app/renderer/shared/playerToken/SceneNpcTokensOverlay.tsx index 4bbabe0..912a26a 100644 --- a/app/renderer/shared/playerToken/SceneNpcTokensOverlay.tsx +++ b/app/renderer/shared/playerToken/SceneNpcTokensOverlay.tsx @@ -17,6 +17,12 @@ import { useAssetUrl } from '../useAssetImageUrl'; import { PlayerTokenView } from './PlayerTokenView'; import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast'; +import { + TokenInitiativeDecor, + initiativeConditionsForNpc, + initiativeHighlightForNpc, +} from '../initiative/TokenInitiativeDecor'; +import { useInitiativeState } from '../initiative/useInitiativeState'; import styles from './SceneNpcTokensOverlay.module.css'; type Viewport = { x: number; y: number; w: number; h: number }; @@ -48,6 +54,8 @@ function NpcSprite({ resizeTitle, onContextMenu, snapNorm, + initiativeActive = false, + initiativeConditions = [], }: { placement: SceneNpcToken; npc: ProjectNpc; @@ -66,6 +74,8 @@ function NpcSprite({ resizeTitle?: string; onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void; snapNorm?: (nx: number, ny: number) => { nx: number; ny: number }; + initiativeActive?: boolean; + initiativeConditions?: readonly import('../../../shared/types/initiative').InitiativeConditionId[]; }) { const imageUrl = useAssetUrl(npc.avatarAssetId); const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null); @@ -123,8 +133,15 @@ function NpcSprite({ return (
+ {canResize ? (
| null; dragEnabledById?: Record | null; }) { + const [initiative] = useInitiativeState(); if (!viewport) return null; const byId = new Map(library.map((npc) => [npc.id, npc])); const displayScale = session?.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE; @@ -288,6 +307,8 @@ export function SceneNpcTokensOverlay({ gridFit={gridFit} disposition={disposition} inactive={inactive} + initiativeActive={initiativeHighlightForNpc(initiative, key)} + initiativeConditions={initiativeConditionsForNpc(initiative, key)} {...(onMove ? { onMove } : {})} {...(onResize ? { onResize } : {})} {...(resizeTitle ? { resizeTitle } : {})} diff --git a/app/renderer/shared/playerToken/ScenePlayerTokensOverlay.tsx b/app/renderer/shared/playerToken/ScenePlayerTokensOverlay.tsx index 682a594..361f3ac 100644 --- a/app/renderer/shared/playerToken/ScenePlayerTokensOverlay.tsx +++ b/app/renderer/shared/playerToken/ScenePlayerTokensOverlay.tsx @@ -14,6 +14,12 @@ import { usePlayerImageUrl } from './usePlayerImageUrl'; import { PlayerTokenView } from './PlayerTokenView'; import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast'; +import { + TokenInitiativeDecor, + initiativeConditionsForPlayer, + initiativeHighlightForPlayer, +} from '../initiative/TokenInitiativeDecor'; +import { useInitiativeState } from '../initiative/useInitiativeState'; import styles from './SceneNpcTokensOverlay.module.css'; type Viewport = { x: number; y: number; w: number; h: number }; @@ -31,6 +37,8 @@ function PlayerSprite({ onResize, resizeTitle, snapNorm, + initiativeActive = false, + initiativeConditions = [], }: { player: AppPlayer; nx: number; @@ -44,6 +52,8 @@ function PlayerSprite({ onResize?: (playerId: string, sizeN: number) => void; resizeTitle?: string; snapNorm?: (nx: number, ny: number) => { nx: number; ny: number }; + initiativeActive?: boolean; + initiativeConditions?: readonly import('../../../shared/types/initiative').InitiativeConditionId[]; }) { const imageUrl = usePlayerImageUrl(player.id); const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null); @@ -100,8 +110,15 @@ function PlayerSprite({ return (
+ {canResize ? (
{ nx: number; ny: number }; }) { + const [initiative] = useInitiativeState(); if (!viewport || !session?.visible) return null; const byId = new Map(library.map((p) => [String(p.id), p])); const gridFit = sceneGridTokenFitFactor(grid); @@ -238,6 +257,8 @@ export function ScenePlayerTokensOverlay({ editable={editable} displayScale={displayScale} gridFit={gridFit} + initiativeActive={initiativeHighlightForPlayer(initiative, playerId)} + initiativeConditions={initiativeConditionsForPlayer(initiative, playerId)} {...(onMove ? { onMove } : {})} {...(onResize ? { onResize } : {})} {...(resizeTitle ? { resizeTitle } : {})} diff --git a/app/renderer/shared/ui/secondaryWindows.stability.test.ts b/app/renderer/shared/ui/secondaryWindows.stability.test.ts index e392e26..3bf8690 100644 --- a/app/renderer/shared/ui/secondaryWindows.stability.test.ts +++ b/app/renderer/shared/ui/secondaryWindows.stability.test.ts @@ -14,6 +14,7 @@ const SECONDARY_WINDOW_MAINS = [ 'materials/main.tsx', 'playlists/main.tsx', 'timer/main.tsx', + 'initiative/main.tsx', 'sceneEditor/main.tsx', 'sceneDescription/main.tsx', 'control/main.tsx', @@ -148,6 +149,12 @@ void test('ControlApp: Timer tool opens window', () => { assert.ok(src.includes('SessionTimerLayer')); }); +void test('ControlApp: Initiative tool opens window', () => { + const src = fs.readFileSync(path.join(rendererRoot, 'control/ControlApp.tsx'), 'utf8'); + assert.ok(src.includes('data-testid="control-initiative-tool"')); + assert.ok(src.includes('windows.openInitiative')); +}); + void test('PlayerTokenView: name plate shows full text without ellipsis', () => { const css = fs.readFileSync( path.join(rendererRoot, 'shared/playerToken/PlayerTokenView.module.css'), diff --git a/app/shared/appBranding.ts b/app/shared/appBranding.ts index f63a947..b6556e5 100644 --- a/app/shared/appBranding.ts +++ b/app/shared/appBranding.ts @@ -24,6 +24,7 @@ export type AppWindowKind = | 'materials' | 'playlists' | 'timer' + | 'initiative' | 'npcsEditor' | 'sceneEditor' | 'tokenPathEditor' @@ -38,6 +39,7 @@ const WINDOW_SUFFIX: Record = { materials: { ru: 'Материалы', en: 'Materials' }, playlists: { ru: 'Плейлисты', en: 'Playlists' }, timer: { ru: 'Таймер', en: 'Timer' }, + initiative: { ru: 'Инициатива', en: 'Initiative' }, npcsEditor: { ru: 'НПС', en: 'NPCs' }, sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' }, tokenPathEditor: { ru: 'Движение токена', en: 'Token path' }, diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index 0f383cc..dcb9508 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -57,6 +57,8 @@ import type { SceneViewState, SessionTimerEvent, SessionTimerState, + InitiativeEvent, + InitiativeState, TokenId, TokenPathSessionEvent, TokenPathSessionState, @@ -167,6 +169,8 @@ export const ipcChannels = { closePlaylists: 'windows.closePlaylists', openTimer: 'windows.openTimer', closeTimer: 'windows.closeTimer', + openInitiative: 'windows.openInitiative', + closeInitiative: 'windows.closeInitiative', openNpcsEditor: 'windows.openNpcsEditor', closeNpcsEditor: 'windows.closeNpcsEditor', openNpcs: 'windows.openNpcs', @@ -209,6 +213,11 @@ export const ipcChannels = { dispatch: 'sessionTimer.dispatch', stateChanged: 'sessionTimer.stateChanged', }, + initiative: { + getState: 'initiative.getState', + dispatch: 'initiative.dispatch', + stateChanged: 'initiative.stateChanged', + }, sceneDarkness: { getState: 'sceneDarkness.getState', dispatch: 'sceneDarkness.dispatch', @@ -367,6 +376,7 @@ export type IpcEventMap = { [ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState }; [ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState }; [ipcChannels.sessionTimer.stateChanged]: { state: SessionTimerState }; + [ipcChannels.initiative.stateChanged]: { state: InitiativeState }; [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; [ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState }; [ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState }; @@ -804,6 +814,14 @@ export type IpcInvokeMap = { req: Record; res: { ok: true }; }; + [ipcChannels.windows.openInitiative]: { + req: Record; + res: { ok: true }; + }; + [ipcChannels.windows.closeInitiative]: { + req: Record; + res: { ok: true }; + }; [ipcChannels.windows.openNpcsEditor]: { req: Record; res: { ok: true }; @@ -868,6 +886,14 @@ export type IpcInvokeMap = { req: { event: SessionTimerEvent }; res: { state: SessionTimerState }; }; + [ipcChannels.initiative.getState]: { + req: Record; + res: { state: InitiativeState }; + }; + [ipcChannels.initiative.dispatch]: { + req: { event: InitiativeEvent }; + res: { state: InitiativeState }; + }; [ipcChannels.effects.getState]: { req: Record; res: { state: EffectsState }; @@ -1121,6 +1147,7 @@ export type LegacyIpcEventMap = { [ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState }; [ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState }; [ipcChannels.sessionTimer.stateChanged]: { state: SessionTimerState }; + [ipcChannels.initiative.stateChanged]: { state: InitiativeState }; [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; [ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState }; [ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState }; diff --git a/app/shared/types/index.ts b/app/shared/types/index.ts index 945c0e6..c1c29c8 100644 --- a/app/shared/types/index.ts +++ b/app/shared/types/index.ts @@ -21,4 +21,5 @@ export * from './presentationSettings'; export * from './sceneTraps'; export * from './sceneView'; export * from './sessionTimer'; +export * from './initiative'; export * from './videoPlayback'; diff --git a/app/shared/types/initiative.ts b/app/shared/types/initiative.ts new file mode 100644 index 0000000..6cb114a --- /dev/null +++ b/app/shared/types/initiative.ts @@ -0,0 +1,152 @@ +/** Состояния (conditions) для трекера инициативы. */ +export const INITIATIVE_CONDITION_IDS = [ + 'frightened', + 'exhaustion', + 'concentration', + 'invisible', + 'incapacitated', + 'petrified', + 'restrained', + 'blinded', + 'poisoned', + 'charmed', + 'stunned', + 'paralyzed', + 'prone', + 'grappled', + 'unconscious', + 'deafened', +] as const; + +export type InitiativeConditionId = (typeof INITIATIVE_CONDITION_IDS)[number]; + +export function isInitiativeConditionId(raw: string): raw is InitiativeConditionId { + return (INITIATIVE_CONDITION_IDS as readonly string[]).includes(raw); +} + +/** Палитра градиентов для ручных слотов (фиксируется при создании). */ +export const INITIATIVE_MANUAL_GRADIENTS = [ + 'linear-gradient(135deg, #3b82f6 0%, #1e3a8a 100%)', + 'linear-gradient(135deg, #22c55e 0%, #14532d 100%)', + 'linear-gradient(135deg, #f59e0b 0%, #92400e 100%)', + 'linear-gradient(135deg, #ef4444 0%, #7f1d1d 100%)', + 'linear-gradient(135deg, #a855f7 0%, #4c1d95 100%)', + 'linear-gradient(135deg, #06b6d4 0%, #155e75 100%)', + 'linear-gradient(135deg, #ec4899 0%, #9d174d 100%)', + 'linear-gradient(135deg, #84cc16 0%, #3f6212 100%)', +] as const; + +export type InitiativeEntryKind = 'npc' | 'player' | 'manual'; + +export type InitiativeNpcEntry = { + id: string; + kind: 'npc'; + placementId: string; + npcId: string; + name: string; + avatarAssetId: string | null; +}; + +export type InitiativePlayerEntry = { + id: string; + kind: 'player'; + playerId: string; + name: string; +}; + +export type InitiativeManualEntry = { + id: string; + kind: 'manual'; + name: string; + letter: string; + gradient: string; +}; + +export type InitiativeEntry = InitiativeNpcEntry | InitiativePlayerEntry | InitiativeManualEntry; + +export type InitiativePresenceNpc = { + placementId: string; + npcId: string; + name: string; + avatarAssetId: string | null; +}; + +export type InitiativePresencePlayer = { + playerId: string; + name: string; +}; + +export type InitiativeState = { + revision: number; + /** Окно трекера открыто — курсор на сцене виден. */ + enabled: boolean; + entries: Record; + /** Активная очередь (слева направо). */ + order: string[]; + /** Запас: удалены из очереди / скрытые игроки. */ + reserve: string[]; + activeId: string | null; + conditions: Record; + /** Последняя известная видимость токенов игроков на карте. */ + playersVisible: boolean; +}; + +export type InitiativeEvent = + | { kind: 'setEnabled'; enabled: boolean } + | { + kind: 'syncPresence'; + npcs: readonly InitiativePresenceNpc[]; + players: readonly InitiativePresencePlayer[]; + playersVisible: boolean; + } + | { kind: 'reorder'; order: readonly string[] } + | { kind: 'setActive'; id: string | null } + | { kind: 'next' } + | { kind: 'addManual'; name: string } + | { kind: 'moveToReserve'; id: string } + | { kind: 'restoreFromReserve'; id: string } + | { kind: 'deleteEntry'; id: string } + | { kind: 'addCondition'; id: string; conditionId: InitiativeConditionId } + | { kind: 'removeCondition'; id: string; conditionId: InitiativeConditionId } + | { kind: 'clear' }; + +export function createInitialInitiativeState(): InitiativeState { + return { + revision: 1, + enabled: false, + entries: {}, + order: [], + reserve: [], + activeId: null, + conditions: {}, + playersVisible: false, + }; +} + +export function newInitiativeManualId(): string { + return `init_manual_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +export function initiativeManualLetter(name: string): string { + const t = name.trim(); + if (!t) return '?'; + const ch = [...t][0]; + return (ch ?? '?').toUpperCase(); +} + +export function pickInitiativeManualGradient(seed?: number): string { + const n = INITIATIVE_MANUAL_GRADIENTS.length; + const idx = + typeof seed === 'number' && Number.isFinite(seed) + ? Math.abs(Math.floor(seed)) % n + : Math.floor(Math.random() * n); + return INITIATIVE_MANUAL_GRADIENTS[idx]!; +} + +export function initiativeNpcEntryId(placementId: string): string { + return `init_npc_${placementId}`; +} + +export function initiativePlayerEntryId(playerId: string): string { + return `init_player_${playerId}`; +} diff --git a/package.json b/package.json index 6556d61..b38b906 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build:obfuscate": "node scripts/build.mjs --production --obfuscate", "lint": "eslint . --max-warnings 0", "typecheck": "tsc -p tsconfig.eslint.json --noEmit", - "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs", + "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs", "format": "prettier . --check", "format:write": "prettier . --write", "postinstall": "patch-package", diff --git a/vite.config.ts b/vite.config.ts index a6f98d6..addee29 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -57,6 +57,7 @@ export default defineConfig(({ mode }) => { materials: path.resolve(__dirname, 'app/renderer/materials.html'), playlists: path.resolve(__dirname, 'app/renderer/playlists.html'), timer: path.resolve(__dirname, 'app/renderer/timer.html'), + initiative: path.resolve(__dirname, 'app/renderer/initiative.html'), sceneEditor: path.resolve(__dirname, 'app/renderer/sceneEditor.html'), tokenPathEditor: path.resolve(__dirname, 'app/renderer/tokenPathEditor.html'), npcsEditor: path.resolve(__dirname, 'app/renderer/npcsEditor.html'),