diff --git a/app/main/index.ts b/app/main/index.ts
index 858ab65..56e2acb 100644
--- a/app/main/index.ts
+++ b/app/main/index.ts
@@ -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();
diff --git a/app/main/sessionTimer/timerSessionStore.test.ts b/app/main/sessionTimer/timerSessionStore.test.ts
new file mode 100644
index 0000000..ead977c
--- /dev/null
+++ b/app/main/sessionTimer/timerSessionStore.test.ts
@@ -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);
+});
diff --git a/app/main/sessionTimer/timerSessionStore.ts b/app/main/sessionTimer/timerSessionStore.ts
new file mode 100644
index 0000000..9d3fae6
--- /dev/null
+++ b/app/main/sessionTimer/timerSessionStore.ts
@@ -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;
+ }
+}
diff --git a/app/main/windows/createWindows.editorClose.test.ts b/app/main/windows/createWindows.editorClose.test.ts
index 0f2b7ea..8078213 100644
--- a/app/main/windows/createWindows.editorClose.test.ts
+++ b/app/main/windows/createWindows.editorClose.test.ts
@@ -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'));
diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts
index 820ca95..544974a 100644
--- a/app/main/windows/createWindows.ts
+++ b/app/main/windows/createWindows.ts
@@ -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();
diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx
index 81b7531..025384e 100644
--- a/app/renderer/control/ControlApp.tsx
+++ b/app/renderer/control/ControlApp.tsx
@@ -61,6 +61,7 @@ import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverla
import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay';
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost';
+import { SessionTimerLayer } from '../shared/sessionTimer/SessionTimerLayer';
import { useSceneViewState } from '../shared/sceneView/useSceneViewState';
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
import { SceneTokensOverlay } from '../shared/tokens/SceneTokensOverlay';
@@ -1842,6 +1843,22 @@ export function ControlApp() {
🎵
+
) : null}
+
diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts
index 2f83579..71c1ed1 100644
--- a/app/renderer/editor/i18n/editorMessages.ts
+++ b/app/renderer/editor/i18n/editorMessages.ts
@@ -231,7 +231,7 @@ export const EDITOR_MESSAGES: Record> = {
'help.section.controlPanel.title': 'Пульт управления',
'help.section.controlPanel.body':
- 'Пульт — ваш стол во время игры. Игроки смотрят на презентацию, вы управляете всем отсюда.\n\nСлева сверху — «Инструменты», затем эффекты и ход сюжета; справа — мини-копия экрана игроков, варианты переходов и музыка.\n\nВ «Инструменты» кнопка с книгой открывает отдельное окно с оформленным описанием текущей сцены. Если описания нет, кнопка неактивна — при наведении подсказка «Описание отсутствует». Кнопка материалов (иконка карты) открывает окно со списком материалов кампании — подробнее в разделе «Материалы». Кнопка НПС (цветная иконка человека) открывает окно персонажей: карточки описания, чекбокс «Показать» для аватара поверх сцены и постановка токена на карту на время сессии — см. раздел «НПС».\n\n«Предпросмотр экрана» показывает то же, что видят игроки. Здесь же рисуют эффекты — они сразу появляются на большом экране (и на картинке, и на видео). Если на сцене расставлены ловушки, они видны на предпросмотре: правый клик по маркеру — проявить, активировать или обезвредить (см. «Ловушки»). Неигровые токены, токены НПС и игроков тоже видны на предпросмотре: их можно двигать и менять размер за жёлтый уголок до конца сессии. Линии путей движения видны вам всегда; игрокам — только после «Показать путь» в меню токена. На превью можно перетащить плитку из окна НПС — появится сессионный токен. ПКМ по неигровому токену — показать / скрыть путь, остановить / продолжить движение, сброс на старт. ПКМ по токену НПС — активировать / открыть информацию / сменить тип / сделать неактивным / убрать с карты / путь (см. «НПС» и «Неигровые токены»). Аватары с чекбокса «Показать» лежат поверх сцены отдельно от токенов на карте: их двигают, масштабируют лупами из окна НПС и закрывают «Закрыть всех». На видео-сценах внизу превью остаются кнопки воспроизведения и полоса перемотки.\n\nНад превью:\n• «Привязка токенов к сетке» — если на сцене включена сетка, перетаскиваемые токены (неигровые, НПС, игроки) встают по клеткам;\n• «Размеры игр. токенов» — общий масштаб круглых токенов игроков и НПС на предпросмотре и презентации;\n• «Показать игроков» / «Скрыть игроков» — после запуска с игроками.\n\n«Сюжетная линия» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу переносит партию к тому месту на карте и добавляет новый шаг в историю.\n\n«Выключить демонстрацию» — закончить показ и вернуться в редактор.',
+ 'Пульт — ваш стол во время игры. Игроки смотрят на презентацию, вы управляете всем отсюда.\n\nСлева сверху — «Инструменты», затем эффекты и ход сюжета; справа — мини-копия экрана игроков, варианты переходов и музыка.\n\nВ «Инструменты» кнопка с книгой открывает отдельное окно с оформленным описанием текущей сцены. Если описания нет, кнопка неактивна — при наведении подсказка «Описание отсутствует». Кнопка материалов (иконка карты) открывает окно со списком материалов кампании — подробнее в разделе «Материалы». Кнопка ⏱ открывает таймер сессии (обратный отсчёт): «Показать игрокам» выводит виджет на предпросмотр и презентацию — его можно двигать, масштабировать и вращать на превью пульта. Кнопка НПС (цветная иконка человека) открывает окно персонажей: карточки описания, чекбокс «Показать» для аватара поверх сцены и постановка токена на карту на время сессии — см. раздел «НПС».\n\n«Предпросмотр экрана» показывает то же, что видят игроки. Здесь же рисуют эффекты — они сразу появляются на большом экране (и на картинке, и на видео). Если на сцене расставлены ловушки, они видны на предпросмотре: правый клик по маркеру — проявить, активировать или обезвредить (см. «Ловушки»). Неигровые токены, токены НПС и игроков тоже видны на предпросмотре: их можно двигать и менять размер за жёлтый уголок до конца сессии. Линии путей движения видны вам всегда; игрокам — только после «Показать путь» в меню токена. На превью можно перетащить плитку из окна НПС — появится сессионный токен. ПКМ по неигровому токену — показать / скрыть путь, остановить / продолжить движение, сброс на старт. ПКМ по токену НПС — активировать / открыть информацию / сменить тип / сделать неактивным / убрать с карты / путь (см. «НПС» и «Неигровые токены»). Аватары с чекбокса «Показать» лежат поверх сцены отдельно от токенов на карте: их двигают, масштабируют лупами из окна НПС и закрывают «Закрыть всех». На видео-сценах внизу превью остаются кнопки воспроизведения и полоса перемотки.\n\nНад превью:\n• «Привязка токенов к сетке» — если на сцене включена сетка, перетаскиваемые токены (неигровые, НПС, игроки) встают по клеткам;\n• «Размеры игр. токенов» — общий масштаб круглых токенов игроков и НПС на предпросмотре и презентации;\n• «Показать игроков» / «Скрыть игроков» — после запуска с игроками.\n\n«Сюжетная линия» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу переносит партию к тому месту на карте и добавляет новый шаг в историю.\n\n«Выключить демонстрацию» — закончить показ и вернуться в редактор.',
'help.section.transitions.title': 'Переходы между сценами',
'help.section.transitions.body':
@@ -658,7 +658,16 @@ export const EDITOR_MESSAGES: Record> = {
'control.markerColor': 'Цвет маркера',
'control.materialsTool': 'Материалы',
'control.playlistsTool': 'Плейлисты',
+ 'control.timerTool': 'Таймер',
'control.npcsTool': 'НПС',
+
+ 'sessionTimer.duration': 'Длительность (MM:SS)',
+ 'sessionTimer.start': 'Старт',
+ 'sessionTimer.pause': 'Пауза',
+ 'sessionTimer.reset': 'Сброс',
+ 'sessionTimer.showToPlayers': 'Показать игрокам',
+ 'sessionTimer.positionHint':
+ 'Когда показ включён, перетащите таймер на предпросмотре пульта; углы — размер, ручка сверху — поворот.',
'control.descriptionMissing': 'Описание отсутствует',
'control.effects': 'ЭФФЕКТЫ',
'control.tools': 'Очистка',
@@ -920,7 +929,7 @@ export const EDITOR_MESSAGES: Record> = {
'help.section.controlPanel.title': 'Control panel',
'help.section.controlPanel.body':
- 'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nTop-left: Tools, then effects and storyline. On the right: a mini copy of the player screen, branch options, and music.\n\nUnder Tools, the book button opens a separate window with the current scene’s formatted description. If there is no description, the button is disabled — the tooltip reads “No description”. The materials button (map icon) opens a window with the campaign materials list — see the Materials section for details. The NPCs button (colored person icon) opens the characters window: description cards, the Show checkbox for an avatar over the scene, and placing a token on the map for this session — see NPCs.\n\nScreen preview shows what players see. Paint effects here — they appear on the big screen right away for both image and video scenes. If the scene has traps, they appear on the preview: right-click a marker to reveal, activate, or disarm (see Traps). Non-player, NPC, and player tokens are also visible on the preview: you can move them and resize them from the yellow corner until the session ends. Path lines are always visible to you; players see a line only after Show path in the token menu. You can drag a tile from the NPCs window onto the preview to spawn a session token. Right-click a non-player token to show / hide path, stop / resume movement, or reset to start. Right-click an NPC token to activate / open information / change type / make inactive / remove from map / set a path (see NPCs and Non-player tokens). Avatars from the Show checkbox sit over the scene separately from map tokens: drag them, zoom with the NPCs window magnifiers, and clear them with Close all. On video scenes, transport controls and the scrub bar stay at the bottom of the preview.\n\nAbove the preview:\n• Snap tokens to grid — when the scene grid is on, dragged tokens (non-player, NPC, players) snap to cells;\n• Play token size — shared scale for circular player and NPC tokens on the preview and presentation;\n• Show players / Hide players — after Run with players.\n\nStoryline lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to move the party back to that spot and add a new history entry.\n\nStop presentation ends the show and unlocks the editor.',
+ 'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nTop-left: Tools, then effects and storyline. On the right: a mini copy of the player screen, branch options, and music.\n\nUnder Tools, the book button opens a separate window with the current scene’s formatted description. If there is no description, the button is disabled — the tooltip reads “No description”. The materials button (map icon) opens a window with the campaign materials list — see the Materials section for details. The timer button (⏱) opens the session countdown: Show to players places the widget on the control preview and presentation — drag, resize, and rotate it on the control preview. The NPCs button (colored person icon) opens the characters window: description cards, the Show checkbox for an avatar over the scene, and placing a token on the map for this session — see NPCs.\n\nScreen preview shows what players see. Paint effects here — they appear on the big screen right away for both image and video scenes. If the scene has traps, they appear on the preview: right-click a marker to reveal, activate, or disarm (see Traps). Non-player, NPC, and player tokens are also visible on the preview: you can move them and resize them from the yellow corner until the session ends. Path lines are always visible to you; players see a line only after Show path in the token menu. You can drag a tile from the NPCs window onto the preview to spawn a session token. Right-click a non-player token to show / hide path, stop / resume movement, or reset to start. Right-click an NPC token to activate / open information / change type / make inactive / remove from map / set a path (see NPCs and Non-player tokens). Avatars from the Show checkbox sit over the scene separately from map tokens: drag them, zoom with the NPCs window magnifiers, and clear them with Close all. On video scenes, transport controls and the scrub bar stay at the bottom of the preview.\n\nAbove the preview:\n• Snap tokens to grid — when the scene grid is on, dragged tokens (non-player, NPC, players) snap to cells;\n• Play token size — shared scale for circular player and NPC tokens on the preview and presentation;\n• Show players / Hide players — after Run with players.\n\nStoryline lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to move the party back to that spot and add a new history entry.\n\nStop presentation ends the show and unlocks the editor.',
'help.section.transitions.title': 'Scene transitions',
'help.section.transitions.body':
@@ -1349,7 +1358,16 @@ export const EDITOR_MESSAGES: Record> = {
'control.markerColor': 'Marker color',
'control.materialsTool': 'Materials',
'control.playlistsTool': 'Playlists',
+ 'control.timerTool': 'Timer',
'control.npcsTool': 'NPCs',
+
+ 'sessionTimer.duration': 'Duration (MM:SS)',
+ 'sessionTimer.start': 'Start',
+ 'sessionTimer.pause': 'Pause',
+ 'sessionTimer.reset': 'Reset',
+ 'sessionTimer.showToPlayers': 'Show to players',
+ 'sessionTimer.positionHint':
+ 'When shown, drag the timer on the control preview; corners resize, the top handle rotates.',
'control.descriptionMissing': 'No description',
'control.effects': 'EFFECTS',
'control.tools': 'Cleanup',
diff --git a/app/renderer/shared/PresentationView.tsx b/app/renderer/shared/PresentationView.tsx
index 32fef70..d455f19 100644
--- a/app/renderer/shared/PresentationView.tsx
+++ b/app/renderer/shared/PresentationView.tsx
@@ -24,6 +24,7 @@ import { useAppPlayers } from './playerToken/useAppPlayers';
import { useSceneNpcTokensSession } from './playerToken/useSceneNpcTokensSession';
import { useScenePlayerTokensSession } from './playerToken/useScenePlayerTokensSession';
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
+import { SessionTimerLayer } from './sessionTimer/SessionTimerLayer';
import { useSceneViewState } from './sceneView/useSceneViewState';
import { SceneTokensOverlay } from './tokens/SceneTokensOverlay';
import { TokenPathsOverlay } from './tokens/TokenPathsOverlay';
@@ -338,6 +339,7 @@ export function PresentationView({
))}
{activeNpcItems.length > 0 ? : null}
+
{showTitle ? (