feat(control): таймер сессии поверх сцены

Отдельное окно countdown с «Показать игрокам»; на превью — move/resize/rotate, на презентации тот же layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-03 16:51:56 +08:00
parent a4d7ab77e7
commit db1168a46c
23 changed files with 1313 additions and 3 deletions
+34
View File
@@ -34,6 +34,7 @@ import { LicenseService } from './license/licenseService';
import { readPresentationSettings, writePresentationSettings } from './preferences/appPreferences'; import { readPresentationSettings, writePresentationSettings } from './preferences/appPreferences';
import { MaterialsOverlayStore } from './materials/materialsOverlayStore'; import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
import { NpcsOverlayStore } from './npcs/npcsOverlayStore'; import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
import { TimerSessionStore } from './sessionTimer/timerSessionStore';
import { ZipProjectStore } from './project/zipStore'; import { ZipProjectStore } from './project/zipStore';
import { SceneViewStore } from './sceneView/sceneViewStore'; import { SceneViewStore } from './sceneView/sceneViewStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol'; import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
@@ -67,6 +68,7 @@ import {
markAppQuitting, markAppQuitting,
openMaterialsWindow, openMaterialsWindow,
openPlaylistsWindow, openPlaylistsWindow,
openTimerWindow,
openMultiWindow, openMultiWindow,
openNpcsEditorWindow, openNpcsEditorWindow,
openSceneEditorWindow, openSceneEditorWindow,
@@ -75,6 +77,7 @@ import {
openSceneDescriptionWindow, openSceneDescriptionWindow,
closeMaterialsWindow, closeMaterialsWindow,
closePlaylistsWindow, closePlaylistsWindow,
closeTimerWindow,
closeNpcsEditorWindow, closeNpcsEditorWindow,
closeSceneEditorWindow, closeSceneEditorWindow,
closeTokenPathEditorWindow, closeTokenPathEditorWindow,
@@ -178,6 +181,7 @@ const sceneViewStore = new SceneViewStore();
const videoStore = new VideoPlaybackStore(); const videoStore = new VideoPlaybackStore();
const materialsOverlayStore = new MaterialsOverlayStore(); const materialsOverlayStore = new MaterialsOverlayStore();
const npcsOverlayStore = new NpcsOverlayStore(); const npcsOverlayStore = new NpcsOverlayStore();
const timerSessionStore = new TimerSessionStore();
const sceneTokensSessionStore = new SceneTokensSessionStore(); const sceneTokensSessionStore = new SceneTokensSessionStore();
const tokenPathSessionStore = new TokenPathSessionStore(); const tokenPathSessionStore = new TokenPathSessionStore();
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore(); const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
@@ -272,6 +276,13 @@ function syncNpcsOverlayWithProject(project: Project | null): void {
npcsOverlayStore.ensureNpcStillExists(ids); 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 { function emitSceneDarknessState(): void {
const state = sceneDarknessStore.getState(); const state = sceneDarknessStore.getState();
for (const win of BrowserWindow.getAllWindows()) { for (const win of BrowserWindow.getAllWindows()) {
@@ -561,6 +572,7 @@ async function main() {
clearNpcMapSpawnDrag(); clearNpcMapSpawnDrag();
scenePlayerTokensSessionStore.reset(); scenePlayerTokensSessionStore.reset();
tokenGridSnapSessionStore.reset(); tokenGridSnapSessionStore.reset();
timerSessionStore.clear();
if (USERS_BRANCH_FEATURES_ENABLED) { if (USERS_BRANCH_FEATURES_ENABLED) {
const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : []; const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
if (playerIds.length > 0) { if (playerIds.length > 0) {
@@ -586,11 +598,14 @@ async function main() {
emitScenePlayerTokensSessionState(); emitScenePlayerTokensSessionState();
emitTokenGridSnapState(); emitTokenGridSnapState();
emitEffectsState(); emitEffectsState();
emitSessionTimerState();
return { ok: true }; return { ok: true };
}); });
registerHandler(ipcChannels.windows.closeMultiWindow, () => { registerHandler(ipcChannels.windows.closeMultiWindow, () => {
closeMultiWindow(); closeMultiWindow();
timerSessionStore.clear();
tokenGridSnapSessionStore.reset(); tokenGridSnapSessionStore.reset();
emitSessionTimerState();
emitTokenGridSnapState(); emitTokenGridSnapState();
return { ok: true }; return { ok: true };
}); });
@@ -646,6 +661,14 @@ async function main() {
closePlaylistsWindow(); closePlaylistsWindow();
return { ok: true }; return { ok: true };
}); });
registerHandler(ipcChannels.windows.openTimer, () => {
openTimerWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.closeTimer, () => {
closeTimerWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.openNpcsEditor, () => { registerHandler(ipcChannels.windows.openNpcsEditor, () => {
openNpcsEditorWindow(); openNpcsEditorWindow();
return { ok: true }; return { ok: true };
@@ -701,6 +724,15 @@ async function main() {
emitNpcsOverlayState(); emitNpcsOverlayState();
return { ok: true }; 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 () => { registerHandler(ipcChannels.project.list, async () => {
const projects = await projectStore.listProjects(); const projects = await projectStore.listProjects();
@@ -742,6 +774,7 @@ async function main() {
effectsStore.clear(); effectsStore.clear();
materialsOverlayStore.clear(); materialsOverlayStore.clear();
npcsOverlayStore.clear(); npcsOverlayStore.clear();
timerSessionStore.clear();
sceneDarknessStore.resetSession(); sceneDarknessStore.resetSession();
sceneMarkerStore.resetSession(); sceneMarkerStore.resetSession();
sceneTrapsStore.resetSession(); sceneTrapsStore.resetSession();
@@ -754,6 +787,7 @@ async function main() {
emitEffectsState(); emitEffectsState();
emitMaterialsOverlayState(); emitMaterialsOverlayState();
emitNpcsOverlayState(); emitNpcsOverlayState();
emitSessionTimerState();
emitSceneDarknessState(); emitSceneDarknessState();
emitSceneMarkerState(); emitSceneMarkerState();
emitSceneTrapsState(); 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);
});
+131
View File
@@ -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/); 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', () => { void test('createWindows: окно НПС закрывается с multi-window', () => {
const src = readCreateWindows(); const src = readCreateWindows();
assert.ok(src.includes('openNpcsWindow')); assert.ok(src.includes('openNpcsWindow'));
+47
View File
@@ -21,6 +21,7 @@ export type WindowKind =
| 'sceneDescription' | 'sceneDescription'
| 'materials' | 'materials'
| 'playlists' | 'playlists'
| 'timer'
| 'npcsEditor' | 'npcsEditor'
| 'sceneEditor' | 'sceneEditor'
| 'tokenPathEditor' | 'tokenPathEditor'
@@ -59,6 +60,10 @@ const MATERIALS_WINDOW_HEIGHT = 720;
const PLAYLISTS_WINDOW_WIDTH = 720; const PLAYLISTS_WINDOW_WIDTH = 720;
const PLAYLISTS_WINDOW_HEIGHT = 800; 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_WIDTH = 1400;
const NPCS_EDITOR_WINDOW_HEIGHT = 860; const NPCS_EDITOR_WINDOW_HEIGHT = 860;
@@ -169,6 +174,7 @@ function closePlaySessionAuxiliaryWindows(): void {
closeSceneDescriptionWindow(); closeSceneDescriptionWindow();
closeMaterialsWindow(); closeMaterialsWindow();
closePlaylistsWindow(); closePlaylistsWindow();
closeTimerWindow();
closeNpcsWindow(); closeNpcsWindow();
} }
@@ -219,6 +225,8 @@ function pageNameForKind(kind: WindowKind): string {
return 'materials.html'; return 'materials.html';
case 'playlists': case 'playlists':
return 'playlists.html'; return 'playlists.html';
case 'timer':
return 'timer.html';
case 'npcsEditor': case 'npcsEditor':
return 'npcsEditor.html'; return 'npcsEditor.html';
case 'sceneEditor': case 'sceneEditor':
@@ -295,6 +303,7 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
if (kind === 'sceneDescription') return { width: 720, height: 640 }; if (kind === 'sceneDescription') return { width: 720, height: 640 };
if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT }; 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 === '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 === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT };
if (kind === 'sceneEditor') return { width: 1280, height: 800 }; if (kind === 'sceneEditor') return { width: 1280, height: 800 };
if (kind === 'tokenPathEditor') return { width: 1100, height: 760 }; if (kind === 'tokenPathEditor') return { width: 1100, height: 760 };
@@ -406,6 +415,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
kind === 'sceneDescription' || kind === 'sceneDescription' ||
kind === 'materials' || kind === 'materials' ||
kind === 'playlists' || kind === 'playlists' ||
kind === 'timer' ||
kind === 'npcsEditor' || kind === 'npcsEditor' ||
kind === 'sceneEditor' || kind === 'sceneEditor' ||
kind === 'tokenPathEditor' || 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 { export function closeNpcsEditorWindow(): void {
const win = windows.get('npcsEditor'); const win = windows.get('npcsEditor');
if (win && !win.isDestroyed()) { 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 { function positionNpcsEditorWindow(win: BrowserWindow): void {
const parent = windows.get('editor'); const parent = windows.get('editor');
const { width, height } = win.getBounds(); const { width, height } = win.getBounds();
+18
View File
@@ -61,6 +61,7 @@ import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverla
import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay'; import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay';
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState'; import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost'; import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost';
import { SessionTimerLayer } from '../shared/sessionTimer/SessionTimerLayer';
import { useSceneViewState } from '../shared/sceneView/useSceneViewState'; import { useSceneViewState } from '../shared/sceneView/useSceneViewState';
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay'; import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
import { SceneTokensOverlay } from '../shared/tokens/SceneTokensOverlay'; import { SceneTokensOverlay } from '../shared/tokens/SceneTokensOverlay';
@@ -1842,6 +1843,22 @@ export function ControlApp() {
🎵 🎵
</span> </span>
</Button> </Button>
<Button
variant="ghost"
iconOnly
title={t('control.timerTool')}
ariaLabel={t('control.timerTool')}
data-testid="control-timer-tool"
onClick={() => {
void api.invoke(ipcChannels.windows.openTimer, {}).catch((err) => {
console.error('[control] openTimer failed', err);
});
}}
>
<span className={styles.iconGlyph} aria-hidden>
⏱
</span>
</Button>
<Button <Button
variant="ghost" variant="ghost"
iconOnly iconOnly
@@ -2980,6 +2997,7 @@ export function ControlApp() {
style={{ zIndex: 48 }} style={{ zIndex: 48 }}
/> />
) : null} ) : null}
<SessionTimerLayer viewport={previewContentRect} editable />
</div> </div>
</Surface> </Surface>
+20 -2
View File
@@ -231,7 +231,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.controlPanel.title': 'Пульт управления', 'help.section.controlPanel.title': 'Пульт управления',
'help.section.controlPanel.body': '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.title': 'Переходы между сценами',
'help.section.transitions.body': 'help.section.transitions.body':
@@ -658,7 +658,16 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.markerColor': 'Цвет маркера', 'control.markerColor': 'Цвет маркера',
'control.materialsTool': 'Материалы', 'control.materialsTool': 'Материалы',
'control.playlistsTool': 'Плейлисты', 'control.playlistsTool': 'Плейлисты',
'control.timerTool': 'Таймер',
'control.npcsTool': 'НПС', 'control.npcsTool': 'НПС',
'sessionTimer.duration': 'Длительность (MM:SS)',
'sessionTimer.start': 'Старт',
'sessionTimer.pause': 'Пауза',
'sessionTimer.reset': 'Сброс',
'sessionTimer.showToPlayers': 'Показать игрокам',
'sessionTimer.positionHint':
'Когда показ включён, перетащите таймер на предпросмотре пульта; углы — размер, ручка сверху — поворот.',
'control.descriptionMissing': 'Описание отсутствует', 'control.descriptionMissing': 'Описание отсутствует',
'control.effects': 'ЭФФЕКТЫ', 'control.effects': 'ЭФФЕКТЫ',
'control.tools': 'Очистка', 'control.tools': 'Очистка',
@@ -920,7 +929,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.controlPanel.title': 'Control panel', 'help.section.controlPanel.title': 'Control panel',
'help.section.controlPanel.body': '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.title': 'Scene transitions',
'help.section.transitions.body': 'help.section.transitions.body':
@@ -1349,7 +1358,16 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.markerColor': 'Marker color', 'control.markerColor': 'Marker color',
'control.materialsTool': 'Materials', 'control.materialsTool': 'Materials',
'control.playlistsTool': 'Playlists', 'control.playlistsTool': 'Playlists',
'control.timerTool': 'Timer',
'control.npcsTool': 'NPCs', '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.descriptionMissing': 'No description',
'control.effects': 'EFFECTS', 'control.effects': 'EFFECTS',
'control.tools': 'Cleanup', 'control.tools': 'Cleanup',
+2
View File
@@ -24,6 +24,7 @@ import { useAppPlayers } from './playerToken/useAppPlayers';
import { useSceneNpcTokensSession } from './playerToken/useSceneNpcTokensSession'; import { useSceneNpcTokensSession } from './playerToken/useSceneNpcTokensSession';
import { useScenePlayerTokensSession } from './playerToken/useScenePlayerTokensSession'; import { useScenePlayerTokensSession } from './playerToken/useScenePlayerTokensSession';
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost'; import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
import { SessionTimerLayer } from './sessionTimer/SessionTimerLayer';
import { useSceneViewState } from './sceneView/useSceneViewState'; import { useSceneViewState } from './sceneView/useSceneViewState';
import { SceneTokensOverlay } from './tokens/SceneTokensOverlay'; import { SceneTokensOverlay } from './tokens/SceneTokensOverlay';
import { TokenPathsOverlay } from './tokens/TokenPathsOverlay'; import { TokenPathsOverlay } from './tokens/TokenPathsOverlay';
@@ -338,6 +339,7 @@ export function PresentationView({
))} ))}
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null} {activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
</SceneOverlayHost> </SceneOverlayHost>
<SessionTimerLayer viewport={contentRect} editable={false} />
{showTitle ? ( {showTitle ? (
<div className={styles.titleWrap}> <div className={styles.titleWrap}>
<div className={compact ? styles.titleCompact : styles.titleFull}> <div className={compact ? styles.titleCompact : styles.titleFull}>
@@ -0,0 +1,55 @@
import React, { useEffect, useMemo, useState } from 'react';
import { getSessionTimerRemainingMs } from '../../../shared/types/sessionTimer';
import { SessionTimerOverlay } from './SessionTimerOverlay';
import { useSessionTimerState } from './useSessionTimerState';
/** Оверлей таймера с тиком remaining; editable только на пульте. */
export function SessionTimerLayer({
viewport,
editable = false,
}: {
viewport: { x: number; y: number; w: number; h: number } | null;
editable?: boolean;
}) {
const [state, timerApi] = useSessionTimerState();
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!state?.visibleToPlayers) return;
if (!state.running) return;
const id = window.setInterval(() => setNow(Date.now()), 200);
return () => window.clearInterval(id);
}, [state?.running, state?.visibleToPlayers]);
useEffect(() => {
if (!state?.running || !state.visibleToPlayers) return;
const remaining = getSessionTimerRemainingMs(state, now);
if (remaining > 0) return;
void timerApi.dispatch({ kind: 'pause' });
}, [now, state, timerApi]);
const remainingMs = useMemo(() => {
if (!state) return 0;
return getSessionTimerRemainingMs(state, now);
}, [now, state]);
if (!viewport || !state?.visibleToPlayers) return null;
return (
<SessionTimerOverlay
remainingMs={remainingMs}
layout={state.layout}
viewport={viewport}
editable={editable}
onLayoutChange={
editable
? (layout) => {
void timerApi.dispatch({ kind: 'layout.set', layout });
}
: undefined
}
/>
);
}
@@ -0,0 +1,106 @@
.root {
position: absolute;
z-index: 52;
pointer-events: none;
overflow: visible;
}
.frame {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.28);
background: rgba(12, 14, 20, 0.82);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
color: rgba(255, 255, 255, 0.96);
font-weight: 800;
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
pointer-events: none;
user-select: none;
}
.frameEditable {
pointer-events: auto;
cursor: move;
outline: 1px solid rgba(245, 197, 66, 0.55);
}
.expired {
border-color: rgba(229, 72, 77, 0.85);
background: rgba(60, 12, 16, 0.88);
color: #ffb4b4;
}
.display {
padding: 0 10%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.handle {
position: absolute;
width: 12px;
height: 12px;
border-radius: 2px;
border: 1px solid rgba(245, 197, 66, 0.9);
background: rgba(20, 22, 28, 0.95);
padding: 0;
pointer-events: auto;
z-index: 2;
}
.handle_nw {
left: -6px;
top: -6px;
cursor: nwse-resize;
}
.handle_ne {
right: -6px;
top: -6px;
cursor: nesw-resize;
}
.handle_sw {
left: -6px;
bottom: -6px;
cursor: nesw-resize;
}
.handle_se {
right: -6px;
bottom: -6px;
cursor: nwse-resize;
}
.rotateHandle {
position: absolute;
left: 50%;
top: -28px;
width: 12px;
height: 12px;
margin-left: -6px;
border-radius: 50%;
border: 1px solid rgba(245, 197, 66, 0.9);
background: rgba(20, 22, 28, 0.95);
padding: 0;
cursor: grab;
pointer-events: auto;
z-index: 2;
}
.rotateHandle::after {
content: '';
position: absolute;
left: 50%;
top: 12px;
width: 1px;
height: 14px;
margin-left: -0.5px;
background: rgba(245, 197, 66, 0.7);
}
@@ -0,0 +1,342 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { SessionTimerLayout } from '../../../shared/types';
import {
clampSessionTimerLayout,
DEFAULT_SESSION_TIMER_LAYOUT,
formatSessionTimerMs,
SESSION_TIMER_BASE_WIDTH_FRAC,
sessionTimerVisualLayout,
} from '../../../shared/types/sessionTimer';
import styles from './SessionTimerOverlay.module.css';
type Corner = 'nw' | 'ne' | 'sw' | 'se';
function pointerAngleDeg(cx: number, cy: number, x: number, y: number): number {
return (Math.atan2(y - cy, x - cx) * 180) / Math.PI;
}
function shortestAngleDelta(from: number, to: number): number {
let d = to - from;
while (d > 180) d -= 360;
while (d < -180) d += 360;
return d;
}
function screenToLocal(
clientX: number,
clientY: number,
centerX: number,
centerY: number,
rotationDeg: number,
): { x: number; y: number } {
const dx = clientX - centerX;
const dy = clientY - centerY;
const rad = (-rotationDeg * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
return { x: dx * cos - dy * sin, y: dx * sin + dy * cos };
}
function localToScreenOffset(lx: number, ly: number, rotationDeg: number): { x: number; y: number } {
const rad = (rotationDeg * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
return { x: lx * cos - ly * sin, y: lx * sin + ly * cos };
}
export function SessionTimerOverlay({
remainingMs,
layout,
viewport,
editable = false,
onLayoutChange,
}: {
remainingMs: number;
layout: SessionTimerLayout;
viewport: { x: number; y: number; w: number; h: number };
editable?: boolean;
onLayoutChange?: (layout: SessionTimerLayout) => void;
}) {
const rootRef = useRef<HTMLDivElement | null>(null);
const [draftLayout, setDraftLayout] = useState<SessionTimerLayout | null>(null);
const pendingLayoutRef = useRef<SessionTimerLayout | null>(null);
const draftRafRef = useRef(0);
const onLayoutChangeRef = useRef(onLayoutChange);
onLayoutChangeRef.current = onLayoutChange;
const dragRef = useRef<
| { mode: 'move'; startX: number; startY: number; origin: SessionTimerLayout }
| {
mode: 'resize';
corner: Corner;
origin: SessionTimerLayout;
baseW: number;
baseH: number;
viewW: number;
viewH: number;
centerClientX: number;
centerClientY: number;
}
| {
mode: 'rotate';
origin: SessionTimerLayout;
startPointerAngle: number;
centerX: number;
centerY: number;
}
| null
>(null);
useEffect(() => {
if (dragRef.current) return;
setDraftLayout(null);
}, [layout]);
useEffect(
() => () => {
if (draftRafRef.current !== 0) window.cancelAnimationFrame(draftRafRef.current);
},
[],
);
const effectiveLayout = draftLayout ?? layout ?? DEFAULT_SESSION_TIMER_LAYOUT;
const visual = useMemo(
() => sessionTimerVisualLayout(effectiveLayout, viewport),
[effectiveLayout, viewport],
);
const baseW = Math.max(48, viewport.w * SESSION_TIMER_BASE_WIDTH_FRAC);
const baseH = baseW * 0.42;
const expired = remainingMs <= 0;
const label = formatSessionTimerMs(remainingMs);
const layoutCenterClient = useCallback(() => {
const root = rootRef.current;
if (!root) return { x: 0, y: 0 };
const r = root.getBoundingClientRect();
return {
x: r.left + effectiveLayout.cx * r.width,
y: r.top + effectiveLayout.cy * r.height,
};
}, [effectiveLayout.cx, effectiveLayout.cy]);
const publishDraft = (next: SessionTimerLayout): void => {
pendingLayoutRef.current = next;
if (draftRafRef.current !== 0) return;
draftRafRef.current = window.requestAnimationFrame(() => {
draftRafRef.current = 0;
const pending = pendingLayoutRef.current;
if (!pending) return;
setDraftLayout(pending);
onLayoutChangeRef.current?.(pending);
});
};
const onPointerMove = (e: PointerEvent) => {
const drag = dragRef.current;
if (!drag || !onLayoutChange) return;
if (drag.mode === 'rotate') {
const angle = pointerAngleDeg(drag.centerX, drag.centerY, e.clientX, e.clientY);
const delta = shortestAngleDelta(drag.startPointerAngle, angle);
publishDraft(
clampSessionTimerLayout({
...drag.origin,
rotationDeg: drag.origin.rotationDeg + delta,
}),
);
return;
}
const root = rootRef.current;
if (!root) return;
const r = root.getBoundingClientRect();
if (drag.mode === 'move') {
const dx = (e.clientX - drag.startX) / Math.max(1, r.width);
const dy = (e.clientY - drag.startY) / Math.max(1, r.height);
publishDraft(
clampSessionTimerLayout({
...drag.origin,
cx: drag.origin.cx + dx,
cy: drag.origin.cy + dy,
}),
);
return;
}
const { baseW: bw, baseH: bh, viewW, viewH, origin, corner, centerClientX, centerClientY } = drag;
const originW = bw * origin.scale;
const originH = bh * origin.scale;
const originLeft = -originW / 2;
const originTop = -originH / 2;
const originRight = originW / 2;
const originBottom = originH / 2;
let anchorX = originLeft;
let anchorY = originTop;
if (corner === 'nw') {
anchorX = originRight;
anchorY = originBottom;
} else if (corner === 'ne') {
anchorX = originLeft;
anchorY = originBottom;
} else if (corner === 'sw') {
anchorX = originRight;
anchorY = originTop;
}
const local = screenToLocal(
e.clientX,
e.clientY,
centerClientX,
centerClientY,
origin.rotationDeg ?? 0,
);
const newW = Math.max(8, Math.abs(local.x - anchorX));
const newH = Math.max(8, Math.abs(local.y - anchorY));
const nextScale = Math.max(newW / Math.max(1, bw), newH / Math.max(1, bh));
const ww = bw * nextScale;
const hh = bh * nextScale;
let nextLeft = anchorX;
let nextTop = anchorY;
if (corner === 'nw') {
nextLeft = anchorX - ww;
nextTop = anchorY - hh;
} else if (corner === 'ne') {
nextLeft = anchorX;
nextTop = anchorY - hh;
} else if (corner === 'sw') {
nextLeft = anchorX - ww;
nextTop = anchorY;
}
const localCenterX = nextLeft + ww / 2;
const localCenterY = nextTop + hh / 2;
const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0);
publishDraft(
clampSessionTimerLayout({
...origin,
cx: origin.cx + screenOffset.x / Math.max(1, viewW),
cy: origin.cy + screenOffset.y / Math.max(1, viewH),
scale: nextScale,
}),
);
};
const endDrag = () => {
dragRef.current = null;
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', endDrag);
if (draftRafRef.current !== 0) {
window.cancelAnimationFrame(draftRafRef.current);
draftRafRef.current = 0;
}
const finalLayout = pendingLayoutRef.current;
if (finalLayout) {
setDraftLayout(finalLayout);
onLayoutChangeRef.current?.(finalLayout);
}
};
const startDrag = (e: React.PointerEvent, mode: 'move' | 'resize', corner?: Corner) => {
if (!editable || !onLayoutChange) return;
e.preventDefault();
e.stopPropagation();
if (mode === 'move') {
dragRef.current = {
mode: 'move',
startX: e.clientX,
startY: e.clientY,
origin: { ...effectiveLayout },
};
} else {
const center = layoutCenterClient();
dragRef.current = {
mode: 'resize',
corner: corner ?? 'se',
origin: { ...effectiveLayout },
baseW,
baseH,
viewW: viewport.w,
viewH: viewport.h,
centerClientX: center.x,
centerClientY: center.y,
};
}
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', endDrag);
};
const startRotate = (e: React.PointerEvent) => {
if (!editable || !onLayoutChange) return;
e.preventDefault();
e.stopPropagation();
const center = layoutCenterClient();
dragRef.current = {
mode: 'rotate',
origin: { ...effectiveLayout },
startPointerAngle: pointerAngleDeg(center.x, center.y, e.clientX, e.clientY),
centerX: center.x,
centerY: center.y,
};
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', endDrag);
};
return (
<div
ref={rootRef}
className={styles.root}
style={{
left: viewport.x,
top: viewport.y,
width: viewport.w,
height: viewport.h,
}}
data-testid="session-timer-overlay-root"
>
<div
className={[styles.frame, editable ? styles.frameEditable : '', expired ? styles.expired : '']
.filter(Boolean)
.join(' ')}
data-testid="session-timer-overlay"
style={{
left: visual.left - viewport.x - visual.width / 2,
top: visual.top - viewport.y - visual.height / 2,
width: visual.width,
height: visual.height,
transform: `rotate(${String(visual.rotationDeg)}deg)`,
transformOrigin: 'center center',
fontSize: Math.max(14, visual.height * 0.42),
}}
onPointerDown={(e) => startDrag(e, 'move')}
>
<div className={styles.display}>{label}</div>
{editable ? (
<>
{(['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
<button
key={corner}
type="button"
className={[styles.handle, styles[`handle_${corner}`]].join(' ')}
aria-label="Resize"
onPointerDown={(e) => startDrag(e, 'resize', corner)}
/>
))}
<button
type="button"
className={styles.rotateHandle}
aria-label="Rotate"
onPointerDown={startRotate}
/>
</>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { SessionTimerEvent, SessionTimerState } from '../../../shared/types';
import { getDndApi } from '../dndApi';
export function useSessionTimerState(): readonly [
SessionTimerState | null,
{ dispatch: (event: SessionTimerEvent) => Promise<SessionTimerState> },
] {
const api = getDndApi();
const [state, setState] = useState<SessionTimerState | null>(null);
useEffect(() => {
void api.invoke(ipcChannels.sessionTimer.getState, {}).then((r) => {
setState(r.state);
});
return api.on(ipcChannels.sessionTimer.stateChanged, ({ state: next }) => {
setState(next);
});
}, [api]);
return [
state,
{
dispatch: async (event) => {
const r = await api.invoke(ipcChannels.sessionTimer.dispatch, { event });
setState(r.state);
return r.state;
},
},
] as const;
}
@@ -13,6 +13,7 @@ const SECONDARY_WINDOW_MAINS = [
'npcs/npcsMain.tsx', 'npcs/npcsMain.tsx',
'materials/main.tsx', 'materials/main.tsx',
'playlists/main.tsx', 'playlists/main.tsx',
'timer/main.tsx',
'sceneEditor/main.tsx', 'sceneEditor/main.tsx',
'sceneDescription/main.tsx', 'sceneDescription/main.tsx',
'control/main.tsx', 'control/main.tsx',
@@ -140,6 +141,13 @@ void test('ControlApp: Playlists tool opens window', () => {
assert.ok(src.includes('windows.openPlaylists')); assert.ok(src.includes('windows.openPlaylists'));
}); });
void test('ControlApp: Timer tool opens window', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'control/ControlApp.tsx'), 'utf8');
assert.ok(src.includes('data-testid="control-timer-tool"'));
assert.ok(src.includes('windows.openTimer'));
assert.ok(src.includes('SessionTimerLayer'));
});
void test('PlayerTokenView: name plate shows full text without ellipsis', () => { void test('PlayerTokenView: name plate shows full text without ellipsis', () => {
const css = fs.readFileSync( const css = fs.readFileSync(
path.join(rendererRoot, 'shared/playerToken/PlayerTokenView.module.css'), path.join(rendererRoot, 'shared/playerToken/PlayerTokenView.module.css'),
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-window-icon.png" type="image/png" />
<title>TTRPG</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/timer/main.tsx"></script>
</body>
</html>
+89
View File
@@ -0,0 +1,89 @@
.page {
height: 100vh;
box-sizing: border-box;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
background: var(--bg);
color: var(--text);
}
.clock {
font-size: 48px;
font-weight: 800;
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
text-align: center;
padding: 16px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3);
}
.clock[data-expired='1'] {
border-color: rgba(229, 72, 77, 0.85);
color: #ffb4b4;
}
.presets {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.presets > * {
width: 100%;
max-width: none;
display: flex;
}
.presets button {
width: 100%;
justify-content: center;
}
.field {
display: grid;
gap: 6px;
}
.fieldLabel {
font-size: var(--text-xs);
font-weight: 700;
color: var(--text2);
}
.actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.actions > * {
width: 100%;
max-width: none;
display: flex;
}
.actions button {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
}
.checkRow {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--text-sm);
font-weight: 600;
}
.hint {
margin-top: auto;
font-size: var(--text-xs);
color: var(--text2);
line-height: 1.35;
}
+141
View File
@@ -0,0 +1,141 @@
import React, { useEffect, useMemo, useState } from 'react';
import { ipcChannels } from '../../shared/ipc/contracts';
import {
DEFAULT_SESSION_TIMER_DURATION_MS,
formatSessionTimerMs,
getSessionTimerRemainingMs,
parseSessionTimerDurationInput,
} from '../../shared/types/sessionTimer';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { useSessionTimerState } from '../shared/sessionTimer/useSessionTimerState';
import { Button, Input } from '../shared/ui/controls';
import styles from './TimerApp.module.css';
const PRESETS_MS = [60_000, 5 * 60_000, 10 * 60_000] as const;
function durationInputFromMs(ms: number): string {
return formatSessionTimerMs(ms);
}
export function TimerApp() {
const { t } = useEditorI18n();
const api = getDndApi();
const [state, timerApi] = useSessionTimerState();
const [now, setNow] = useState(() => Date.now());
const [durationDraft, setDurationDraft] = useState(durationInputFromMs(DEFAULT_SESSION_TIMER_DURATION_MS));
useEffect(() => {
if (!state || state.running) return;
setDurationDraft(durationInputFromMs(state.durationMs));
}, [state]);
useEffect(() => {
if (!state?.running) return;
const id = window.setInterval(() => setNow(Date.now()), 200);
return () => window.clearInterval(id);
}, [state?.running]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
void api.invoke(ipcChannels.windows.closeTimer, {});
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [api]);
const remainingMs = useMemo(() => {
if (!state) return DEFAULT_SESSION_TIMER_DURATION_MS;
return getSessionTimerRemainingMs(state, now);
}, [now, state]);
useEffect(() => {
if (!state?.running) return;
if (remainingMs > 0) return;
void timerApi.dispatch({ kind: 'pause' });
}, [remainingMs, state?.running, timerApi]);
if (!state) {
return <div className={styles.page} data-testid="timer-app" />;
}
const applyDurationDraft = async () => {
const parsed = parseSessionTimerDurationInput(durationDraft);
if (parsed == null) {
setDurationDraft(durationInputFromMs(state.durationMs));
return;
}
await timerApi.dispatch({ kind: 'setDuration', durationMs: parsed });
};
return (
<div className={styles.page} data-testid="timer-app">
<div className={styles.clock} data-expired={remainingMs <= 0 ? '1' : '0'}>
{formatSessionTimerMs(remainingMs)}
</div>
<div className={styles.presets}>
{PRESETS_MS.map((ms) => (
<Button
key={ms}
variant="ghost"
disabled={state.running}
onClick={() => {
setDurationDraft(durationInputFromMs(ms));
void timerApi.dispatch({ kind: 'setDuration', durationMs: ms });
}}
>
{formatSessionTimerMs(ms)}
</Button>
))}
</div>
<label className={styles.field}>
<span className={styles.fieldLabel}>{t('sessionTimer.duration')}</span>
<Input
value={durationDraft}
onChange={setDurationDraft}
onBlur={() => void applyDurationDraft()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void applyDurationDraft();
}
}}
/>
</label>
<div className={styles.actions}>
{state.running ? (
<Button variant="primary" onClick={() => void timerApi.dispatch({ kind: 'pause' })}>
{t('sessionTimer.pause')}
</Button>
) : (
<Button variant="primary" onClick={() => void timerApi.dispatch({ kind: 'start' })}>
{t('sessionTimer.start')}
</Button>
)}
<Button variant="ghost" onClick={() => void timerApi.dispatch({ kind: 'reset' })}>
{t('sessionTimer.reset')}
</Button>
</div>
<label className={styles.checkRow}>
<input
type="checkbox"
checked={state.visibleToPlayers}
onChange={(e) =>
void timerApi.dispatch({ kind: 'setVisibleToPlayers', visible: e.target.checked })
}
/>
<span>{t('sessionTimer.showToPlayers')}</span>
</label>
<div className={styles.hint}>{t('sessionTimer.positionHint')}</div>
</div>
);
}
+23
View File
@@ -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 { TimerApp } from './TimerApp';
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Missing #root element');
}
createRoot(rootEl).render(
<React.StrictMode>
<WindowErrorBoundary title="Timer">
<EditorI18nProvider>
<TimerApp />
</EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>,
);
+2
View File
@@ -23,6 +23,7 @@ export type AppWindowKind =
| 'sceneDescription' | 'sceneDescription'
| 'materials' | 'materials'
| 'playlists' | 'playlists'
| 'timer'
| 'npcsEditor' | 'npcsEditor'
| 'sceneEditor' | 'sceneEditor'
| 'tokenPathEditor' | 'tokenPathEditor'
@@ -36,6 +37,7 @@ const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
sceneDescription: { ru: 'Описание сцены', en: 'Scene description' }, sceneDescription: { ru: 'Описание сцены', en: 'Scene description' },
materials: { ru: 'Материалы', en: 'Materials' }, materials: { ru: 'Материалы', en: 'Materials' },
playlists: { ru: 'Плейлисты', en: 'Playlists' }, playlists: { ru: 'Плейлисты', en: 'Playlists' },
timer: { ru: 'Таймер', en: 'Timer' },
npcsEditor: { ru: 'НПС', en: 'NPCs' }, npcsEditor: { ru: 'НПС', en: 'NPCs' },
sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' }, sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' },
tokenPathEditor: { ru: 'Движение токена', en: 'Token path' }, tokenPathEditor: { ru: 'Движение токена', en: 'Token path' },
+27
View File
@@ -55,6 +55,8 @@ import type {
SceneTrapsState, SceneTrapsState,
SceneViewEvent, SceneViewEvent,
SceneViewState, SceneViewState,
SessionTimerEvent,
SessionTimerState,
TokenId, TokenId,
TokenPathSessionEvent, TokenPathSessionEvent,
TokenPathSessionState, TokenPathSessionState,
@@ -163,6 +165,8 @@ export const ipcChannels = {
closeMaterials: 'windows.closeMaterials', closeMaterials: 'windows.closeMaterials',
openPlaylists: 'windows.openPlaylists', openPlaylists: 'windows.openPlaylists',
closePlaylists: 'windows.closePlaylists', closePlaylists: 'windows.closePlaylists',
openTimer: 'windows.openTimer',
closeTimer: 'windows.closeTimer',
openNpcsEditor: 'windows.openNpcsEditor', openNpcsEditor: 'windows.openNpcsEditor',
closeNpcsEditor: 'windows.closeNpcsEditor', closeNpcsEditor: 'windows.closeNpcsEditor',
openNpcs: 'windows.openNpcs', openNpcs: 'windows.openNpcs',
@@ -200,6 +204,11 @@ export const ipcChannels = {
dispatch: 'npcsOverlay.dispatch', dispatch: 'npcsOverlay.dispatch',
stateChanged: 'npcsOverlay.stateChanged', stateChanged: 'npcsOverlay.stateChanged',
}, },
sessionTimer: {
getState: 'sessionTimer.getState',
dispatch: 'sessionTimer.dispatch',
stateChanged: 'sessionTimer.stateChanged',
},
sceneDarkness: { sceneDarkness: {
getState: 'sceneDarkness.getState', getState: 'sceneDarkness.getState',
dispatch: 'sceneDarkness.dispatch', dispatch: 'sceneDarkness.dispatch',
@@ -357,6 +366,7 @@ export type IpcEventMap = {
[ipcChannels.effects.stateChanged]: { state: EffectsState }; [ipcChannels.effects.stateChanged]: { state: EffectsState };
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState }; [ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState }; [ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
[ipcChannels.sessionTimer.stateChanged]: { state: SessionTimerState };
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
[ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState }; [ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState };
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState }; [ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
@@ -786,6 +796,14 @@ export type IpcInvokeMap = {
req: Record<string, never>; req: Record<string, never>;
res: { ok: true }; res: { ok: true };
}; };
[ipcChannels.windows.openTimer]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.closeTimer]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.openNpcsEditor]: { [ipcChannels.windows.openNpcsEditor]: {
req: Record<string, never>; req: Record<string, never>;
res: { ok: true }; res: { ok: true };
@@ -842,6 +860,14 @@ export type IpcInvokeMap = {
req: { event: NpcsOverlayEvent }; req: { event: NpcsOverlayEvent };
res: { ok: true }; res: { ok: true };
}; };
[ipcChannels.sessionTimer.getState]: {
req: Record<string, never>;
res: { state: SessionTimerState };
};
[ipcChannels.sessionTimer.dispatch]: {
req: { event: SessionTimerEvent };
res: { state: SessionTimerState };
};
[ipcChannels.effects.getState]: { [ipcChannels.effects.getState]: {
req: Record<string, never>; req: Record<string, never>;
res: { state: EffectsState }; res: { state: EffectsState };
@@ -1094,6 +1120,7 @@ export type LegacyIpcEventMap = {
[ipcChannels.effects.stateChanged]: { state: EffectsState }; [ipcChannels.effects.stateChanged]: { state: EffectsState };
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState }; [ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState }; [ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
[ipcChannels.sessionTimer.stateChanged]: { state: SessionTimerState };
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
[ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState }; [ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState };
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState }; [ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
+1
View File
@@ -20,4 +20,5 @@ export * from './scenePreviewRotation';
export * from './presentationSettings'; export * from './presentationSettings';
export * from './sceneTraps'; export * from './sceneTraps';
export * from './sceneView'; export * from './sceneView';
export * from './sessionTimer';
export * from './videoPlayback'; export * from './videoPlayback';
+128
View File
@@ -0,0 +1,128 @@
import {
clampMaterialsLayout,
type MaterialsOverlayLayout,
} from './materials';
/** Раскладка виджета таймера — тот же контракт, что у материалов. */
export type SessionTimerLayout = MaterialsOverlayLayout;
export const DEFAULT_SESSION_TIMER_DURATION_MS = 5 * 60 * 1000;
export const SESSION_TIMER_DURATION_MIN_MS = 1_000;
export const SESSION_TIMER_DURATION_MAX_MS = 24 * 60 * 60 * 1000;
export const DEFAULT_SESSION_TIMER_LAYOUT: SessionTimerLayout = {
cx: 0.92,
cy: 0.08,
scale: 1,
rotationDeg: 0,
};
/** Базовая ширина плашки при scale=1 относительно viewport (доли ширины). */
export const SESSION_TIMER_BASE_WIDTH_FRAC = 0.18;
export const SESSION_TIMER_REF_VIEW_W = 1920;
export type SessionTimerState = {
revision: number;
durationMs: number;
/** Snapshot оставшегося времени, когда не running (или после стопа на 0). */
remainingMs: number;
running: boolean;
/** Абсолютный момент окончания (Date.now), когда running. */
endsAt: number | null;
visibleToPlayers: boolean;
layout: SessionTimerLayout;
};
export type SessionTimerEvent =
| { kind: 'setDuration'; durationMs: number }
| { kind: 'start' }
| { kind: 'pause' }
| { kind: 'reset' }
| { kind: 'setVisibleToPlayers'; visible: boolean }
| { kind: 'layout.set'; layout: SessionTimerLayout };
export function clampSessionTimerDurationMs(ms: number): number {
if (!Number.isFinite(ms)) return DEFAULT_SESSION_TIMER_DURATION_MS;
return Math.max(SESSION_TIMER_DURATION_MIN_MS, Math.min(SESSION_TIMER_DURATION_MAX_MS, Math.round(ms)));
}
export function clampSessionTimerLayout(layout: SessionTimerLayout): SessionTimerLayout {
return clampMaterialsLayout(layout);
}
export function createInitialSessionTimerState(): SessionTimerState {
return {
revision: 1,
durationMs: DEFAULT_SESSION_TIMER_DURATION_MS,
remainingMs: DEFAULT_SESSION_TIMER_DURATION_MS,
running: false,
endsAt: null,
visibleToPlayers: false,
layout: { ...DEFAULT_SESSION_TIMER_LAYOUT },
};
}
/** Оставшееся время в мс на момент `now`. */
export function getSessionTimerRemainingMs(state: SessionTimerState, now = Date.now()): number {
if (state.running && state.endsAt != null) {
return Math.max(0, state.endsAt - now);
}
return Math.max(0, state.remainingMs);
}
export function formatSessionTimerMs(ms: number): string {
const totalSec = Math.max(0, Math.ceil(ms / 1000));
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
const mm = String(m).padStart(2, '0');
const ss = String(s).padStart(2, '0');
if (h > 0) return `${String(h)}:${mm}:${ss}`;
return `${mm}:${ss}`;
}
/** Парсинг "MM:SS" / "H:MM:SS" / "M:SS" в мс; null если невалидно. */
export function parseSessionTimerDurationInput(raw: string): number | null {
const t = raw.trim();
if (!t) return null;
const parts = t.split(':').map((p) => p.trim());
if (parts.length < 2 || parts.length > 3) return null;
const nums = parts.map((p) => Number(p));
if (nums.some((n) => !Number.isFinite(n) || n < 0 || !Number.isInteger(n))) return null;
let h = 0;
let m = 0;
let s = 0;
if (parts.length === 3) {
h = nums[0]!;
m = nums[1]!;
s = nums[2]!;
} else {
m = nums[0]!;
s = nums[1]!;
}
if (m > 59 || s > 59) return null;
const ms = ((h * 60 + m) * 60 + s) * 1000;
if (ms < SESSION_TIMER_DURATION_MIN_MS) return null;
return clampSessionTimerDurationMs(ms);
}
/**
* Пиксельный layout плашки: центр + размер от ширины viewport и scale.
* Одинаковый алгоритм на пульте и презентации → parity.
*/
export function sessionTimerVisualLayout(
layout: SessionTimerLayout,
viewport: { x: number; y: number; w: number; h: number },
): { left: number; top: number; width: number; height: number; rotationDeg: number } {
const clamped = clampSessionTimerLayout(layout);
const vw = Math.max(1, viewport.w);
const width = Math.max(48, vw * SESSION_TIMER_BASE_WIDTH_FRAC * clamped.scale);
const height = width * 0.42;
return {
left: viewport.x + clamped.cx * vw,
top: viewport.y + clamped.cy * Math.max(1, viewport.h),
width,
height,
rotationDeg: clamped.rotationDeg,
};
}
+1 -1
View File
@@ -10,7 +10,7 @@
"build:obfuscate": "node scripts/build.mjs --production --obfuscate", "build:obfuscate": "node scripts/build.mjs --production --obfuscate",
"lint": "eslint . --max-warnings 0", "lint": "eslint . --max-warnings 0",
"typecheck": "tsc -p tsconfig.eslint.json --noEmit", "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/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/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": "prettier . --check",
"format:write": "prettier . --write", "format:write": "prettier . --write",
"postinstall": "patch-package", "postinstall": "patch-package",
+1
View File
@@ -56,6 +56,7 @@ export default defineConfig(({ mode }) => {
sceneDescription: path.resolve(__dirname, 'app/renderer/sceneDescription.html'), sceneDescription: path.resolve(__dirname, 'app/renderer/sceneDescription.html'),
materials: path.resolve(__dirname, 'app/renderer/materials.html'), materials: path.resolve(__dirname, 'app/renderer/materials.html'),
playlists: path.resolve(__dirname, 'app/renderer/playlists.html'), playlists: path.resolve(__dirname, 'app/renderer/playlists.html'),
timer: path.resolve(__dirname, 'app/renderer/timer.html'),
sceneEditor: path.resolve(__dirname, 'app/renderer/sceneEditor.html'), sceneEditor: path.resolve(__dirname, 'app/renderer/sceneEditor.html'),
tokenPathEditor: path.resolve(__dirname, 'app/renderer/tokenPathEditor.html'), tokenPathEditor: path.resolve(__dirname, 'app/renderer/tokenPathEditor.html'),
npcsEditor: path.resolve(__dirname, 'app/renderer/npcsEditor.html'), npcsEditor: path.resolve(__dirname, 'app/renderer/npcsEditor.html'),