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
+1
View File
@@ -20,4 +20,5 @@ export * from './scenePreviewRotation';
export * from './presentationSettings';
export * from './sceneTraps';
export * from './sceneView';
export * from './sessionTimer';
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,
};
}