barcode gamings

This commit is contained in:
legop3
2026-06-18 22:01:10 -04:00
parent d3765b7d8f
commit c598e25019
9 changed files with 417 additions and 54 deletions
+84 -6
View File
@@ -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>
+112 -9
View File
@@ -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>
);