mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
barcode gamings
This commit is contained in:
@@ -5,10 +5,14 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
|
||||
const STATE_STALE_MS = 10 * 1000;
|
||||
const RESUBSCRIBE_MS = 5 * 1000;
|
||||
|
||||
const EMPTY_BARCODE_GAME_STATE = {
|
||||
activeGameId: null,
|
||||
games: [],
|
||||
activeGame: null,
|
||||
participants: [],
|
||||
counters: {
|
||||
objects: [],
|
||||
rovers: [],
|
||||
@@ -23,6 +27,7 @@ function normalizeState(payload = {}) {
|
||||
...EMPTY_BARCODE_GAME_STATE,
|
||||
...(payload && typeof payload === 'object' ? payload : {}),
|
||||
games: Array.isArray(payload?.games) ? payload.games : [],
|
||||
participants: Array.isArray(payload?.participants) ? payload.participants : [],
|
||||
counters: {
|
||||
...EMPTY_BARCODE_GAME_STATE.counters,
|
||||
...(payload?.counters && typeof payload.counters === 'object' ? payload.counters : {}),
|
||||
@@ -34,20 +39,92 @@ function normalizeState(payload = {}) {
|
||||
export default function useBarcodeGameState() {
|
||||
const socket = useSocket();
|
||||
const [state, setState] = useState(EMPTY_BARCODE_GAME_STATE);
|
||||
const [connectionState, setConnectionState] = useState({
|
||||
connected: Boolean(socket.connected),
|
||||
stale: true,
|
||||
lastReceivedAt: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let staleTimer = null;
|
||||
let retryTimer = null;
|
||||
let lastReceivedAt = 0;
|
||||
|
||||
function handleState(payload = {}) {
|
||||
if (disposed) return;
|
||||
lastReceivedAt = Date.now();
|
||||
setState(normalizeState(payload));
|
||||
setConnectionState({
|
||||
connected: Boolean(socket.connected),
|
||||
stale: false,
|
||||
lastReceivedAt,
|
||||
});
|
||||
}
|
||||
|
||||
function subscribeToGameState() {
|
||||
// Socket.io rooms are server-side state, so a reconnect needs a fresh
|
||||
// subscribe packet. Retrying the same idempotent subscribe is cheap and
|
||||
// prevents the scanner page from getting stuck with old game text after a
|
||||
// transient Wi-Fi or server restart.
|
||||
socket.emit('barcodeGame:subscribe', {}, (response = {}) => {
|
||||
if (response.state) {
|
||||
handleState(response.state);
|
||||
return;
|
||||
}
|
||||
setConnectionState((previous) => ({
|
||||
...previous,
|
||||
connected: Boolean(socket.connected),
|
||||
stale: !lastReceivedAt,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function handleConnect() {
|
||||
setConnectionState((previous) => ({
|
||||
...previous,
|
||||
connected: true,
|
||||
stale: !lastReceivedAt,
|
||||
}));
|
||||
subscribeToGameState();
|
||||
}
|
||||
|
||||
function handleDisconnect() {
|
||||
setConnectionState((previous) => ({
|
||||
...previous,
|
||||
connected: false,
|
||||
stale: true,
|
||||
}));
|
||||
}
|
||||
|
||||
socket.emit('barcodeGame:subscribe', {}, (response = {}) => {
|
||||
if (response.state) {
|
||||
handleState(response.state);
|
||||
}
|
||||
});
|
||||
socket.on('barcodeGame:state', handleState);
|
||||
socket.on('connect', handleConnect);
|
||||
socket.on('disconnect', handleDisconnect);
|
||||
subscribeToGameState();
|
||||
|
||||
staleTimer = window.setInterval(() => {
|
||||
const isStale = !lastReceivedAt || Date.now() - lastReceivedAt > STATE_STALE_MS;
|
||||
setConnectionState((previous) => ({
|
||||
...previous,
|
||||
connected: Boolean(socket.connected),
|
||||
stale: isStale,
|
||||
}));
|
||||
}, 1000);
|
||||
|
||||
retryTimer = window.setInterval(() => {
|
||||
if (!socket.connected) return;
|
||||
if (!lastReceivedAt || Date.now() - lastReceivedAt > STATE_STALE_MS) {
|
||||
subscribeToGameState();
|
||||
}
|
||||
}, RESUBSCRIBE_MS);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearInterval(staleTimer);
|
||||
window.clearInterval(retryTimer);
|
||||
socket.off('barcodeGame:state', handleState);
|
||||
socket.off('connect', handleConnect);
|
||||
socket.off('disconnect', handleDisconnect);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
@@ -71,8 +148,9 @@ export default function useBarcodeGameState() {
|
||||
return useMemo(
|
||||
() => ({
|
||||
state,
|
||||
connectionState,
|
||||
voteForGame,
|
||||
}),
|
||||
[state, voteForGame],
|
||||
[connectionState, state, voteForGame],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,28 @@ function StatGrid({ stats }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantsBlock({ participants }) {
|
||||
const knownParticipants = Array.isArray(participants) ? participants : [];
|
||||
// Participants are server-owned because rover scans, identity lookup, and
|
||||
// score eligibility all happen outside this panel. The UI only formats the
|
||||
// current round list so users can tell whether their rover was counted.
|
||||
const displayNames = knownParticipants
|
||||
.map((participant) => participant?.nickname || participant?.roverId)
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="min-w-0 border border-neutral-700 px-2 py-1.5">
|
||||
<p className="text-sm font-semibold text-neutral-400">Participants</p>
|
||||
<p className="font-mono text-2xl font-semibold leading-tight text-neutral-50">
|
||||
{knownParticipants.length}
|
||||
</p>
|
||||
<p className="truncate text-sm leading-tight text-neutral-300">
|
||||
{displayNames.length ? displayNames.join(', ') : 'Scan rover to join'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CounterList({ title, entries }) {
|
||||
if (!Array.isArray(entries) || !entries.length) return null;
|
||||
return (
|
||||
@@ -120,11 +142,15 @@ export default function BarcodeGamesPanel() {
|
||||
const [pendingGameId, setPendingGameId] = useState(null);
|
||||
const activeGame = state.activeGame;
|
||||
const display = activeGame?.display || {};
|
||||
const votingDisabled = state.phase === 'starting' || state.phase === 'running';
|
||||
// Voting should stop once the shared game system has moved past selection.
|
||||
// The joining phase is included because it is still part of starting the
|
||||
// selected game, even though game rules are not running until the countdown.
|
||||
const votingDisabled = state.phase === 'joining' || state.phase === 'starting' || state.phase === 'running';
|
||||
const timerEndsAt = display.timer?.endsAt;
|
||||
const now = useClock(Number.isFinite(timerEndsAt));
|
||||
const timerText = formatTimer(timerEndsAt, now);
|
||||
const games = useMemo(() => (Array.isArray(state.games) ? state.games : []), [state.games]);
|
||||
const participants = Array.isArray(state.participants) ? state.participants : [];
|
||||
|
||||
const handleVote = async (gameId) => {
|
||||
setPendingGameId(gameId);
|
||||
@@ -148,23 +174,29 @@ export default function BarcodeGamesPanel() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="space-y-1 border-t border-neutral-700 pt-1.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<section className="space-y-2 border-t border-neutral-700 pt-2">
|
||||
<div className="grid items-start gap-2 lg:grid-cols-[minmax(0,1fr)_15rem_15rem]">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-neutral-400">{display.title || activeGame?.title || 'No game active'}</p>
|
||||
<p className="break-words text-lg font-semibold leading-tight text-neutral-50">
|
||||
<p className="break-words text-2xl font-bold leading-tight text-neutral-50">
|
||||
{display.title || activeGame?.title || 'No game active'}
|
||||
</p>
|
||||
<p className="mt-0.5 break-words text-lg font-semibold leading-tight text-neutral-200">
|
||||
{display.primary || 'Vote for a game to start'}
|
||||
</p>
|
||||
{display.secondary ? (
|
||||
<p className="mt-0.5 break-words text-sm leading-snug text-neutral-300">{display.secondary}</p>
|
||||
<p className="mt-1 break-words text-base leading-snug text-neutral-300">{display.secondary}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{timerText ? (
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="text-[0.68rem] text-neutral-400">{display.timer?.label || 'Time'}</p>
|
||||
<p className="font-mono text-base font-semibold text-neutral-50">{timerText}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="border border-neutral-700 px-2 py-1.5 text-left lg:text-right">
|
||||
<p className="text-sm font-semibold text-neutral-400">{display.timer?.label || 'Time'}</p>
|
||||
<p className="font-mono text-2xl font-semibold leading-tight text-neutral-50">
|
||||
{timerText || '--'}
|
||||
</p>
|
||||
<p className="text-sm leading-tight text-neutral-500">
|
||||
{timerText ? 'active timer' : 'no timer'}
|
||||
</p>
|
||||
</div>
|
||||
<ParticipantsBlock participants={participants} />
|
||||
</div>
|
||||
<StatGrid stats={display.stats} />
|
||||
</section>
|
||||
|
||||
@@ -15,6 +15,8 @@ const EMPTY_SCANNER_STATE = {
|
||||
lastScan: null,
|
||||
registryError: null,
|
||||
};
|
||||
const SCANNER_STATE_STALE_MS = 10 * 1000;
|
||||
const SCANNER_RESUBSCRIBE_MS = 5 * 1000;
|
||||
|
||||
function useClock(enabled) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
@@ -69,9 +71,14 @@ export default function ScannerContent() {
|
||||
const inputRef = useRef(null);
|
||||
const flashTimerRef = useRef(null);
|
||||
const [scannerState, setScannerState] = useState(EMPTY_SCANNER_STATE);
|
||||
const [scannerConnectionState, setScannerConnectionState] = useState({
|
||||
connected: Boolean(socket.connected),
|
||||
stale: true,
|
||||
lastReceivedAt: null,
|
||||
});
|
||||
const [scanAudioEvent, setScanAudioEvent] = useState(null);
|
||||
const [flashActive, setFlashActive] = useState(false);
|
||||
const { state: barcodeGameState } = useBarcodeGameState();
|
||||
const { state: barcodeGameState, connectionState: barcodeGameConnectionState } = useBarcodeGameState();
|
||||
|
||||
useDefaultNickname();
|
||||
useUserIdentitySync();
|
||||
@@ -93,22 +100,94 @@ export default function ScannerContent() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let staleTimer = null;
|
||||
let retryTimer = null;
|
||||
let lastReceivedAt = 0;
|
||||
|
||||
function handleScannerState(nextState = {}) {
|
||||
if (disposed) return;
|
||||
lastReceivedAt = Date.now();
|
||||
setScannerState({
|
||||
...EMPTY_SCANNER_STATE,
|
||||
...(nextState && typeof nextState === 'object' ? nextState : {}),
|
||||
});
|
||||
setScannerConnectionState({
|
||||
connected: Boolean(socket.connected),
|
||||
stale: false,
|
||||
lastReceivedAt,
|
||||
});
|
||||
}
|
||||
|
||||
function handleScanAudio(payload = null) {
|
||||
setScanAudioEvent(payload && typeof payload === 'object' ? payload : null);
|
||||
}
|
||||
|
||||
socket.emit('barcode:subscribe', {}, () => {});
|
||||
function subscribeToScannerState() {
|
||||
// The scan input path is intentionally independent from display state, so
|
||||
// the page can beep/flash even if it missed a previous status broadcast.
|
||||
// This subscribe is idempotent and gives the rover-facing page a way to
|
||||
// repair its display after reconnects or quiet periods.
|
||||
socket.emit('barcode:subscribe', {}, (response = {}) => {
|
||||
if (response.state) {
|
||||
handleScannerState(response.state);
|
||||
return;
|
||||
}
|
||||
setScannerConnectionState((previous) => ({
|
||||
...previous,
|
||||
connected: Boolean(socket.connected),
|
||||
stale: !lastReceivedAt,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function handleConnect() {
|
||||
setScannerConnectionState((previous) => ({
|
||||
...previous,
|
||||
connected: true,
|
||||
stale: !lastReceivedAt,
|
||||
}));
|
||||
subscribeToScannerState();
|
||||
}
|
||||
|
||||
function handleDisconnect() {
|
||||
setScannerConnectionState((previous) => ({
|
||||
...previous,
|
||||
connected: false,
|
||||
stale: true,
|
||||
}));
|
||||
}
|
||||
|
||||
socket.on('barcode:state', handleScannerState);
|
||||
socket.on('barcode:scanAudio', handleScanAudio);
|
||||
socket.on('connect', handleConnect);
|
||||
socket.on('disconnect', handleDisconnect);
|
||||
subscribeToScannerState();
|
||||
|
||||
staleTimer = window.setInterval(() => {
|
||||
const isStale = !lastReceivedAt || Date.now() - lastReceivedAt > SCANNER_STATE_STALE_MS;
|
||||
setScannerConnectionState((previous) => ({
|
||||
...previous,
|
||||
connected: Boolean(socket.connected),
|
||||
stale: isStale,
|
||||
}));
|
||||
}, 1000);
|
||||
|
||||
retryTimer = window.setInterval(() => {
|
||||
if (!socket.connected) return;
|
||||
if (!lastReceivedAt || Date.now() - lastReceivedAt > SCANNER_STATE_STALE_MS) {
|
||||
subscribeToScannerState();
|
||||
}
|
||||
}, SCANNER_RESUBSCRIBE_MS);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearInterval(staleTimer);
|
||||
window.clearInterval(retryTimer);
|
||||
socket.off('barcode:state', handleScannerState);
|
||||
socket.off('barcode:scanAudio', handleScanAudio);
|
||||
socket.off('connect', handleConnect);
|
||||
socket.off('disconnect', handleDisconnect);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
@@ -146,8 +225,19 @@ export default function ScannerContent() {
|
||||
const timerEndsAt = display.timer?.endsAt;
|
||||
const now = useClock(Number.isFinite(timerEndsAt));
|
||||
const timerText = formatTimer(timerEndsAt, now);
|
||||
const label = display.primary || activeGame?.headline || lastScan?.label || 'waiting';
|
||||
const detail = display.secondary || activeGame?.detail || '';
|
||||
const showGameDisplay = activeGame?.status && activeGame.status !== 'idle';
|
||||
// Idle game state is useful for the Activities panel, but the scanner page's
|
||||
// normal job is still showing the resolved barcode text. Only active lifecycle
|
||||
// states take over the large rover-facing display.
|
||||
const title = showGameDisplay ? display.title || activeGame?.title || 'Barcode games' : lastScan?.label || 'Waiting';
|
||||
const label = showGameDisplay ? display.primary || activeGame?.headline || '' : '';
|
||||
const detail = showGameDisplay ? display.secondary || activeGame?.detail || '' : '';
|
||||
const participants = Array.isArray(barcodeGameState.participants) ? barcodeGameState.participants : [];
|
||||
const syncMessage = !scannerConnectionState.connected
|
||||
? 'scanner offline'
|
||||
: scannerConnectionState.stale || barcodeGameConnectionState.stale
|
||||
? 'syncing'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<main
|
||||
@@ -171,22 +261,35 @@ export default function ScannerContent() {
|
||||
}}
|
||||
/>
|
||||
<section className="flex min-h-screen w-full items-center justify-center">
|
||||
<div className="flex max-w-full flex-col items-center gap-[4vh]">
|
||||
<h1 className="max-w-full break-words text-[15vw] font-black leading-none tracking-normal">
|
||||
{label}
|
||||
<div className="flex max-w-full flex-col items-center gap-[3vh]">
|
||||
<h1 className="max-w-full break-words text-[17vw] font-black leading-none tracking-normal">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="max-w-full break-words text-[6vw] font-bold leading-tight tracking-normal">
|
||||
{label}
|
||||
</p>
|
||||
{detail ? (
|
||||
<p className="max-w-full break-words text-[5vw] font-bold leading-tight tracking-normal">
|
||||
<p className="max-w-full break-words text-[4vw] font-bold leading-tight tracking-normal">
|
||||
{detail}
|
||||
</p>
|
||||
) : null}
|
||||
{timerText ? (
|
||||
<p className="font-mono text-[6vw] font-black leading-none tracking-normal">
|
||||
<p className="font-mono text-[7vw] font-black leading-none tracking-normal">
|
||||
{timerText}
|
||||
</p>
|
||||
) : null}
|
||||
{participants.length ? (
|
||||
<p className="max-w-full truncate text-[3vw] font-semibold leading-tight tracking-normal">
|
||||
{participants.map((participant) => participant.nickname || participant.roverId).filter(Boolean).join(' / ')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
{syncMessage ? (
|
||||
<div className="absolute bottom-4 left-4 text-left text-[2.5vw] font-bold leading-none tracking-normal opacity-80 md:text-xl">
|
||||
{syncMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<SocketConnectionPill />
|
||||
</main>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user