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,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>
);
}