feat(control): таймер сессии поверх сцены
Отдельное окно countdown с «Показать игрокам»; на превью — move/resize/rotate, на презентации тот же layout. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -34,6 +34,7 @@ import { LicenseService } from './license/licenseService';
|
||||
import { readPresentationSettings, writePresentationSettings } from './preferences/appPreferences';
|
||||
import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
|
||||
import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
|
||||
import { TimerSessionStore } from './sessionTimer/timerSessionStore';
|
||||
import { ZipProjectStore } from './project/zipStore';
|
||||
import { SceneViewStore } from './sceneView/sceneViewStore';
|
||||
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
||||
@@ -67,6 +68,7 @@ import {
|
||||
markAppQuitting,
|
||||
openMaterialsWindow,
|
||||
openPlaylistsWindow,
|
||||
openTimerWindow,
|
||||
openMultiWindow,
|
||||
openNpcsEditorWindow,
|
||||
openSceneEditorWindow,
|
||||
@@ -75,6 +77,7 @@ import {
|
||||
openSceneDescriptionWindow,
|
||||
closeMaterialsWindow,
|
||||
closePlaylistsWindow,
|
||||
closeTimerWindow,
|
||||
closeNpcsEditorWindow,
|
||||
closeSceneEditorWindow,
|
||||
closeTokenPathEditorWindow,
|
||||
@@ -178,6 +181,7 @@ const sceneViewStore = new SceneViewStore();
|
||||
const videoStore = new VideoPlaybackStore();
|
||||
const materialsOverlayStore = new MaterialsOverlayStore();
|
||||
const npcsOverlayStore = new NpcsOverlayStore();
|
||||
const timerSessionStore = new TimerSessionStore();
|
||||
const sceneTokensSessionStore = new SceneTokensSessionStore();
|
||||
const tokenPathSessionStore = new TokenPathSessionStore();
|
||||
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
|
||||
@@ -272,6 +276,13 @@ function syncNpcsOverlayWithProject(project: Project | null): void {
|
||||
npcsOverlayStore.ensureNpcStillExists(ids);
|
||||
}
|
||||
|
||||
function emitSessionTimerState(): void {
|
||||
const state = timerSessionStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.sessionTimer.stateChanged, { state });
|
||||
}
|
||||
}
|
||||
|
||||
function emitSceneDarknessState(): void {
|
||||
const state = sceneDarknessStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
@@ -561,6 +572,7 @@ async function main() {
|
||||
clearNpcMapSpawnDrag();
|
||||
scenePlayerTokensSessionStore.reset();
|
||||
tokenGridSnapSessionStore.reset();
|
||||
timerSessionStore.clear();
|
||||
if (USERS_BRANCH_FEATURES_ENABLED) {
|
||||
const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
|
||||
if (playerIds.length > 0) {
|
||||
@@ -586,11 +598,14 @@ async function main() {
|
||||
emitScenePlayerTokensSessionState();
|
||||
emitTokenGridSnapState();
|
||||
emitEffectsState();
|
||||
emitSessionTimerState();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
|
||||
closeMultiWindow();
|
||||
timerSessionStore.clear();
|
||||
tokenGridSnapSessionStore.reset();
|
||||
emitSessionTimerState();
|
||||
emitTokenGridSnapState();
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -646,6 +661,14 @@ async function main() {
|
||||
closePlaylistsWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.openTimer, () => {
|
||||
openTimerWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.closeTimer, () => {
|
||||
closeTimerWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.openNpcsEditor, () => {
|
||||
openNpcsEditorWindow();
|
||||
return { ok: true };
|
||||
@@ -701,6 +724,15 @@ async function main() {
|
||||
emitNpcsOverlayState();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.sessionTimer.getState, () => {
|
||||
return { state: timerSessionStore.getState() };
|
||||
});
|
||||
registerHandler(ipcChannels.sessionTimer.dispatch, ({ event }) => {
|
||||
timerSessionStore.dispatch(event);
|
||||
timerSessionStore.settleIfExpired();
|
||||
emitSessionTimerState();
|
||||
return { state: timerSessionStore.getState() };
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.project.list, async () => {
|
||||
const projects = await projectStore.listProjects();
|
||||
@@ -742,6 +774,7 @@ async function main() {
|
||||
effectsStore.clear();
|
||||
materialsOverlayStore.clear();
|
||||
npcsOverlayStore.clear();
|
||||
timerSessionStore.clear();
|
||||
sceneDarknessStore.resetSession();
|
||||
sceneMarkerStore.resetSession();
|
||||
sceneTrapsStore.resetSession();
|
||||
@@ -754,6 +787,7 @@ async function main() {
|
||||
emitEffectsState();
|
||||
emitMaterialsOverlayState();
|
||||
emitNpcsOverlayState();
|
||||
emitSessionTimerState();
|
||||
emitSceneDarknessState();
|
||||
emitSceneMarkerState();
|
||||
emitSceneTrapsState();
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { TimerSessionStore } from './timerSessionStore';
|
||||
import {
|
||||
clampSessionTimerLayout,
|
||||
formatSessionTimerMs,
|
||||
getSessionTimerRemainingMs,
|
||||
parseSessionTimerDurationInput,
|
||||
} from '../../shared/types/sessionTimer';
|
||||
|
||||
void test('getSessionTimerRemainingMs: paused uses remainingMs', () => {
|
||||
const store = new TimerSessionStore();
|
||||
const s = store.getState();
|
||||
assert.equal(getSessionTimerRemainingMs(s, 1_000_000), s.remainingMs);
|
||||
});
|
||||
|
||||
void test('start pause start preserves remaining', () => {
|
||||
const store = new TimerSessionStore();
|
||||
const t0 = 1_000_000;
|
||||
store.dispatch({ kind: 'setDuration', durationMs: 60_000 }, t0);
|
||||
store.dispatch({ kind: 'start' }, t0);
|
||||
store.dispatch({ kind: 'pause' }, t0 + 10_000);
|
||||
const afterPause = store.getState();
|
||||
assert.equal(afterPause.running, false);
|
||||
assert.equal(afterPause.remainingMs, 50_000);
|
||||
store.dispatch({ kind: 'start' }, t0 + 20_000);
|
||||
assert.equal(getSessionTimerRemainingMs(store.getState(), t0 + 25_000), 45_000);
|
||||
});
|
||||
|
||||
void test('reset restores duration', () => {
|
||||
const store = new TimerSessionStore();
|
||||
store.dispatch({ kind: 'setDuration', durationMs: 90_000 });
|
||||
store.dispatch({ kind: 'start' }, 0);
|
||||
store.dispatch({ kind: 'pause' }, 30_000);
|
||||
store.dispatch({ kind: 'reset' });
|
||||
assert.equal(store.getState().remainingMs, 90_000);
|
||||
assert.equal(store.getState().running, false);
|
||||
});
|
||||
|
||||
void test('setVisibleToPlayers and layout.set', () => {
|
||||
const store = new TimerSessionStore();
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: true });
|
||||
assert.equal(store.getState().visibleToPlayers, true);
|
||||
store.dispatch({
|
||||
kind: 'layout.set',
|
||||
layout: { cx: 0.2, cy: 0.3, scale: 2, rotationDeg: 45 },
|
||||
});
|
||||
assert.equal(store.getState().layout.cx, 0.2);
|
||||
assert.equal(store.getState().layout.scale, 2);
|
||||
assert.equal(store.getState().layout.rotationDeg, 45);
|
||||
});
|
||||
|
||||
void test('settleIfExpired stops at zero', () => {
|
||||
const store = new TimerSessionStore();
|
||||
store.dispatch({ kind: 'setDuration', durationMs: 5_000 }, 0);
|
||||
store.dispatch({ kind: 'start' }, 0);
|
||||
store.settleIfExpired(10_000);
|
||||
assert.equal(store.getState().running, false);
|
||||
assert.equal(store.getState().remainingMs, 0);
|
||||
});
|
||||
|
||||
void test('clampSessionTimerLayout / format / parse', () => {
|
||||
const clamped = clampSessionTimerLayout({ cx: 9, cy: -9, scale: 100, rotationDeg: 370 });
|
||||
assert.ok(clamped.cx <= 1.2);
|
||||
assert.ok(clamped.cy >= -0.2);
|
||||
assert.ok(clamped.scale <= 8);
|
||||
assert.equal(clamped.rotationDeg, 10);
|
||||
assert.equal(formatSessionTimerMs(65_000), '01:05');
|
||||
assert.equal(formatSessionTimerMs(3_661_000), '1:01:01');
|
||||
assert.equal(parseSessionTimerDurationInput('5:00'), 300_000);
|
||||
assert.equal(parseSessionTimerDurationInput('1:02:03'), 3723_000);
|
||||
assert.equal(parseSessionTimerDurationInput('bad'), null);
|
||||
});
|
||||
|
||||
void test('clear restores defaults', () => {
|
||||
const store = new TimerSessionStore();
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: true });
|
||||
store.dispatch({ kind: 'start' }, 0);
|
||||
store.clear();
|
||||
assert.equal(store.getState().visibleToPlayers, false);
|
||||
assert.equal(store.getState().running, false);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
clampSessionTimerDurationMs,
|
||||
clampSessionTimerLayout,
|
||||
createInitialSessionTimerState,
|
||||
getSessionTimerRemainingMs,
|
||||
type SessionTimerEvent,
|
||||
type SessionTimerState,
|
||||
} from '../../shared/types/sessionTimer';
|
||||
|
||||
export class TimerSessionStore {
|
||||
private state: SessionTimerState = createInitialSessionTimerState();
|
||||
|
||||
getState(): SessionTimerState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
clear(): SessionTimerState {
|
||||
this.state = createInitialSessionTimerState();
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: SessionTimerEvent, now = Date.now()): SessionTimerState {
|
||||
switch (event.kind) {
|
||||
case 'setDuration': {
|
||||
const durationMs = clampSessionTimerDurationMs(event.durationMs);
|
||||
if (this.state.running) {
|
||||
const remaining = getSessionTimerRemainingMs(this.state, now);
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
durationMs,
|
||||
remainingMs: Math.min(remaining, durationMs),
|
||||
endsAt: now + Math.min(remaining, durationMs),
|
||||
};
|
||||
} else {
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
durationMs,
|
||||
remainingMs: durationMs,
|
||||
endsAt: null,
|
||||
running: false,
|
||||
};
|
||||
}
|
||||
return this.state;
|
||||
}
|
||||
case 'start': {
|
||||
const remaining = getSessionTimerRemainingMs(this.state, now);
|
||||
if (remaining <= 0) {
|
||||
// Restart from full duration if already at zero.
|
||||
const durationMs = this.state.durationMs;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
remainingMs: durationMs,
|
||||
running: true,
|
||||
endsAt: now + durationMs,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
running: true,
|
||||
endsAt: now + remaining,
|
||||
remainingMs: remaining,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'pause': {
|
||||
if (!this.state.running) return this.state;
|
||||
const remaining = getSessionTimerRemainingMs(this.state, now);
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
running: false,
|
||||
endsAt: null,
|
||||
remainingMs: remaining,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'reset': {
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
running: false,
|
||||
endsAt: null,
|
||||
remainingMs: this.state.durationMs,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setVisibleToPlayers': {
|
||||
if (this.state.visibleToPlayers === event.visible) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
visibleToPlayers: event.visible,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'layout.set': {
|
||||
const layout = clampSessionTimerLayout(event.layout);
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
layout,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Когда running и время вышло — зафиксировать 00:00 (вызывается из UI/main по желанию). */
|
||||
settleIfExpired(now = Date.now()): SessionTimerState {
|
||||
if (!this.state.running) return this.state;
|
||||
const remaining = getSessionTimerRemainingMs(this.state, now);
|
||||
if (remaining > 0) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
running: false,
|
||||
endsAt: null,
|
||||
remainingMs: 0,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,14 @@ void test('createWindows: окно материалов закрывается
|
||||
assert.match(src, /export function closeMultiWindow[\s\S]*closeMaterialsWindow/);
|
||||
});
|
||||
|
||||
void test('createWindows: окно таймера закрывается с multi-window', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('openTimerWindow'));
|
||||
assert.ok(src.includes('closeTimerWindow'));
|
||||
assert.ok(src.includes("createWindow('timer'"));
|
||||
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeTimerWindow/);
|
||||
});
|
||||
|
||||
void test('createWindows: окно НПС закрывается с multi-window', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('openNpcsWindow'));
|
||||
|
||||
@@ -21,6 +21,7 @@ export type WindowKind =
|
||||
| 'sceneDescription'
|
||||
| 'materials'
|
||||
| 'playlists'
|
||||
| 'timer'
|
||||
| 'npcsEditor'
|
||||
| 'sceneEditor'
|
||||
| 'tokenPathEditor'
|
||||
@@ -59,6 +60,10 @@ const MATERIALS_WINDOW_HEIGHT = 720;
|
||||
const PLAYLISTS_WINDOW_WIDTH = 720;
|
||||
const PLAYLISTS_WINDOW_HEIGHT = 800;
|
||||
|
||||
/** Компактное окно таймера сессии. */
|
||||
const TIMER_WINDOW_WIDTH = 360;
|
||||
const TIMER_WINDOW_HEIGHT = 420;
|
||||
|
||||
/** Редактор НПС — как основной редактор, шире инспектор. */
|
||||
const NPCS_EDITOR_WINDOW_WIDTH = 1400;
|
||||
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
|
||||
@@ -169,6 +174,7 @@ function closePlaySessionAuxiliaryWindows(): void {
|
||||
closeSceneDescriptionWindow();
|
||||
closeMaterialsWindow();
|
||||
closePlaylistsWindow();
|
||||
closeTimerWindow();
|
||||
closeNpcsWindow();
|
||||
}
|
||||
|
||||
@@ -219,6 +225,8 @@ function pageNameForKind(kind: WindowKind): string {
|
||||
return 'materials.html';
|
||||
case 'playlists':
|
||||
return 'playlists.html';
|
||||
case 'timer':
|
||||
return 'timer.html';
|
||||
case 'npcsEditor':
|
||||
return 'npcsEditor.html';
|
||||
case 'sceneEditor':
|
||||
@@ -295,6 +303,7 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
|
||||
if (kind === 'sceneDescription') return { width: 720, height: 640 };
|
||||
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 === '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 };
|
||||
@@ -406,6 +415,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
kind === 'sceneDescription' ||
|
||||
kind === 'materials' ||
|
||||
kind === 'playlists' ||
|
||||
kind === 'timer' ||
|
||||
kind === 'npcsEditor' ||
|
||||
kind === 'sceneEditor' ||
|
||||
kind === 'tokenPathEditor' ||
|
||||
@@ -603,6 +613,13 @@ export function closePlaylistsWindow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function closeTimerWindow(): void {
|
||||
const win = windows.get('timer');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeNpcsEditorWindow(): void {
|
||||
const win = windows.get('npcsEditor');
|
||||
if (win && !win.isDestroyed()) {
|
||||
@@ -792,6 +809,36 @@ export function openPlaylistsWindow(): void {
|
||||
});
|
||||
}
|
||||
|
||||
export function openTimerWindow(): void {
|
||||
const existing = windows.get('timer');
|
||||
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('timer', parent ? { parent } : undefined);
|
||||
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