feat(control): таймер сессии поверх сцены
Отдельное окно countdown с «Показать игрокам»; на превью — move/resize/rotate, на презентации тот же layout. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -23,6 +23,7 @@ export type AppWindowKind =
|
||||
| 'sceneDescription'
|
||||
| 'materials'
|
||||
| 'playlists'
|
||||
| 'timer'
|
||||
| 'npcsEditor'
|
||||
| 'sceneEditor'
|
||||
| 'tokenPathEditor'
|
||||
@@ -36,6 +37,7 @@ const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
|
||||
sceneDescription: { ru: 'Описание сцены', en: 'Scene description' },
|
||||
materials: { ru: 'Материалы', en: 'Materials' },
|
||||
playlists: { ru: 'Плейлисты', en: 'Playlists' },
|
||||
timer: { ru: 'Таймер', en: 'Timer' },
|
||||
npcsEditor: { ru: 'НПС', en: 'NPCs' },
|
||||
sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' },
|
||||
tokenPathEditor: { ru: 'Движение токена', en: 'Token path' },
|
||||
|
||||
@@ -55,6 +55,8 @@ import type {
|
||||
SceneTrapsState,
|
||||
SceneViewEvent,
|
||||
SceneViewState,
|
||||
SessionTimerEvent,
|
||||
SessionTimerState,
|
||||
TokenId,
|
||||
TokenPathSessionEvent,
|
||||
TokenPathSessionState,
|
||||
@@ -163,6 +165,8 @@ export const ipcChannels = {
|
||||
closeMaterials: 'windows.closeMaterials',
|
||||
openPlaylists: 'windows.openPlaylists',
|
||||
closePlaylists: 'windows.closePlaylists',
|
||||
openTimer: 'windows.openTimer',
|
||||
closeTimer: 'windows.closeTimer',
|
||||
openNpcsEditor: 'windows.openNpcsEditor',
|
||||
closeNpcsEditor: 'windows.closeNpcsEditor',
|
||||
openNpcs: 'windows.openNpcs',
|
||||
@@ -200,6 +204,11 @@ export const ipcChannels = {
|
||||
dispatch: 'npcsOverlay.dispatch',
|
||||
stateChanged: 'npcsOverlay.stateChanged',
|
||||
},
|
||||
sessionTimer: {
|
||||
getState: 'sessionTimer.getState',
|
||||
dispatch: 'sessionTimer.dispatch',
|
||||
stateChanged: 'sessionTimer.stateChanged',
|
||||
},
|
||||
sceneDarkness: {
|
||||
getState: 'sceneDarkness.getState',
|
||||
dispatch: 'sceneDarkness.dispatch',
|
||||
@@ -357,6 +366,7 @@ export type IpcEventMap = {
|
||||
[ipcChannels.effects.stateChanged]: { state: EffectsState };
|
||||
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
|
||||
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
|
||||
[ipcChannels.sessionTimer.stateChanged]: { state: SessionTimerState };
|
||||
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
||||
[ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState };
|
||||
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
|
||||
@@ -786,6 +796,14 @@ export type IpcInvokeMap = {
|
||||
req: Record<string, never>;
|
||||
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]: {
|
||||
req: Record<string, never>;
|
||||
res: { ok: true };
|
||||
@@ -842,6 +860,14 @@ export type IpcInvokeMap = {
|
||||
req: { event: NpcsOverlayEvent };
|
||||
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]: {
|
||||
req: Record<string, never>;
|
||||
res: { state: EffectsState };
|
||||
@@ -1094,6 +1120,7 @@ export type LegacyIpcEventMap = {
|
||||
[ipcChannels.effects.stateChanged]: { state: EffectsState };
|
||||
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
|
||||
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
|
||||
[ipcChannels.sessionTimer.stateChanged]: { state: SessionTimerState };
|
||||
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
||||
[ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState };
|
||||
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
|
||||
|
||||
@@ -20,4 +20,5 @@ export * from './scenePreviewRotation';
|
||||
export * from './presentationSettings';
|
||||
export * from './sceneTraps';
|
||||
export * from './sceneView';
|
||||
export * from './sessionTimer';
|
||||
export * from './videoPlayback';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user