feat(control): таймер сессии поверх сцены
Отдельное окно countdown с «Показать игрокам»; на превью — move/resize/rotate, на презентации тот же layout. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import { useAppPlayers } from './playerToken/useAppPlayers';
|
||||
import { useSceneNpcTokensSession } from './playerToken/useSceneNpcTokensSession';
|
||||
import { useScenePlayerTokensSession } from './playerToken/useScenePlayerTokensSession';
|
||||
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
|
||||
import { SessionTimerLayer } from './sessionTimer/SessionTimerLayer';
|
||||
import { useSceneViewState } from './sceneView/useSceneViewState';
|
||||
import { SceneTokensOverlay } from './tokens/SceneTokensOverlay';
|
||||
import { TokenPathsOverlay } from './tokens/TokenPathsOverlay';
|
||||
@@ -338,6 +339,7 @@ export function PresentationView({
|
||||
))}
|
||||
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
|
||||
</SceneOverlayHost>
|
||||
<SessionTimerLayer viewport={contentRect} editable={false} />
|
||||
{showTitle ? (
|
||||
<div className={styles.titleWrap}>
|
||||
<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',
|
||||
'materials/main.tsx',
|
||||
'playlists/main.tsx',
|
||||
'timer/main.tsx',
|
||||
'sceneEditor/main.tsx',
|
||||
'sceneDescription/main.tsx',
|
||||
'control/main.tsx',
|
||||
@@ -140,6 +141,13 @@ void test('ControlApp: Playlists tool opens window', () => {
|
||||
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', () => {
|
||||
const css = fs.readFileSync(
|
||||
path.join(rendererRoot, 'shared/playerToken/PlayerTokenView.module.css'),
|
||||
|
||||
Reference in New Issue
Block a user