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
@@ -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;
}