mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
barcode games!
This commit is contained in:
@@ -41,6 +41,7 @@ import VipPanel from './components/VipPanel/index.jsx';
|
||||
import { useSessionSelector } from './context/SessionContext.jsx';
|
||||
import { useTelemetryVisualPolicy } from './context/TelemetryContext.jsx';
|
||||
import ButtonBoxPanel from './components/ButtonBoxPanel/index.jsx';
|
||||
import BarcodeGamesPanel from './components/BarcodeGamesPanel/index.jsx';
|
||||
import RewardRunOverlay from './components/RewardRunOverlay/index.jsx';
|
||||
import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
|
||||
import { pageBackgroundClass, themeGapClass, themeStackClass } from './themeFlags.js';
|
||||
@@ -159,6 +160,7 @@ function MobileFeatureTabs({
|
||||
{/* activities tab */}
|
||||
<TabPanel id="activities">
|
||||
<div className={`flex flex-col ${themeGapClass}`}>
|
||||
<BarcodeGamesPanel />
|
||||
<ButtonBoxPanel />
|
||||
<KinectPanel />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Barcode Game State Hook
|
||||
// Purpose: Subscribes only interested pages/components to barcode game state.
|
||||
// Scope: Keeps scanner-game traffic out of the global session tree so unrelated
|
||||
// UI pages do not receive or retain barcode game data.
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
|
||||
const EMPTY_BARCODE_GAME_STATE = {
|
||||
activeGameId: null,
|
||||
games: [],
|
||||
activeGame: null,
|
||||
counters: {
|
||||
objects: [],
|
||||
rovers: [],
|
||||
codes: [],
|
||||
},
|
||||
recentEvents: [],
|
||||
};
|
||||
|
||||
function normalizeState(payload = {}) {
|
||||
return {
|
||||
...EMPTY_BARCODE_GAME_STATE,
|
||||
...(payload && typeof payload === 'object' ? payload : {}),
|
||||
games: Array.isArray(payload?.games) ? payload.games : [],
|
||||
counters: {
|
||||
...EMPTY_BARCODE_GAME_STATE.counters,
|
||||
...(payload?.counters && typeof payload.counters === 'object' ? payload.counters : {}),
|
||||
},
|
||||
recentEvents: Array.isArray(payload?.recentEvents) ? payload.recentEvents : [],
|
||||
};
|
||||
}
|
||||
|
||||
export default function useBarcodeGameState() {
|
||||
const socket = useSocket();
|
||||
const [state, setState] = useState(EMPTY_BARCODE_GAME_STATE);
|
||||
|
||||
useEffect(() => {
|
||||
function handleState(payload = {}) {
|
||||
setState(normalizeState(payload));
|
||||
}
|
||||
|
||||
socket.emit('barcodeGame:subscribe', {}, (response = {}) => {
|
||||
if (response.state) {
|
||||
handleState(response.state);
|
||||
}
|
||||
});
|
||||
socket.on('barcodeGame:state', handleState);
|
||||
return () => {
|
||||
socket.off('barcodeGame:state', handleState);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
const voteForGame = useCallback(
|
||||
(gameId) =>
|
||||
new Promise((resolve, reject) => {
|
||||
socket.emit('barcodeGame:vote', { gameId }, (response = {}) => {
|
||||
if (response.error) {
|
||||
reject(new Error(response.error));
|
||||
return;
|
||||
}
|
||||
if (response.state) {
|
||||
setState(normalizeState(response.state));
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
}),
|
||||
[socket],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
state,
|
||||
voteForGame,
|
||||
}),
|
||||
[state, voteForGame],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Barcode Games Panel
|
||||
// Purpose: Shows global barcode game state and voting controls inside the
|
||||
// driver's Activities tab.
|
||||
// Scope: This panel is intentionally compact because the scanner page remains
|
||||
// the main room-facing game interface.
|
||||
import { useMemo, useState } from 'react';
|
||||
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
function formatCounter(entry) {
|
||||
const label = typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : 'unknown';
|
||||
const count = Number.isFinite(entry?.count) ? entry.count : 0;
|
||||
return `${label} ${count}`;
|
||||
}
|
||||
|
||||
function formatRecord(activeGame) {
|
||||
const record = activeGame?.worldRecord;
|
||||
if (!record) return null;
|
||||
const rate = Number.isFinite(record.scansPerSecond) ? record.scansPerSecond.toFixed(2) : '0.00';
|
||||
return `${rate} scans per second`;
|
||||
}
|
||||
|
||||
function GameVoteButton({ game, disabled, onVote }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onVote(game.id)}
|
||||
className={[
|
||||
'button-dark min-w-0 flex-1 px-1 py-0.5 text-left text-xs disabled:opacity-50',
|
||||
game.active ? 'border-emerald-300 bg-emerald-950/70 text-emerald-50' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<span className="block truncate font-semibold">{game.title}</span>
|
||||
<span className="block text-[0.68rem] text-slate-300">{game.voteCount || 0} votes</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CounterList({ title, entries }) {
|
||||
if (!Array.isArray(entries) || !entries.length) return null;
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="mb-0.5 text-[0.68rem] font-semibold text-slate-300">{title}</p>
|
||||
<div className="space-y-0.5">
|
||||
{entries.slice(0, 3).map((entry) => (
|
||||
<p key={`${entry.entityId || entry.code}-${entry.type || 'counter'}`} className="truncate text-xs text-slate-100">
|
||||
{formatCounter(entry)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BarcodeGamesPanel() {
|
||||
const { state, voteForGame } = useBarcodeGameState();
|
||||
const [pendingGameId, setPendingGameId] = useState(null);
|
||||
const activeGame = state.activeGame;
|
||||
const recordText = formatRecord(activeGame);
|
||||
const counters = state.counters || {};
|
||||
const voteButtons = useMemo(() => (Array.isArray(state.games) ? state.games : []), [state.games]);
|
||||
|
||||
const handleVote = async (gameId) => {
|
||||
setPendingGameId(gameId);
|
||||
try {
|
||||
await voteForGame(gameId);
|
||||
} finally {
|
||||
setPendingGameId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CardFrame title="Barcode games" bodyClassName="space-y-1 p-1 text-sm">
|
||||
<div className="grid gap-0.5 sm:grid-cols-2">
|
||||
{voteButtons.map((game) => (
|
||||
<GameVoteButton
|
||||
key={game.id}
|
||||
game={game}
|
||||
disabled={Boolean(pendingGameId)}
|
||||
onVote={handleVote}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-neutral-700 bg-neutral-950/70 p-1">
|
||||
<p className="truncate text-sm font-semibold text-white">
|
||||
{activeGame?.title || 'No barcode game'}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-slate-200">
|
||||
{activeGame?.headline || 'vote for a game to start'}
|
||||
</p>
|
||||
{activeGame?.detail ? (
|
||||
<p className="mt-0.5 truncate text-[0.72rem] text-slate-400">{activeGame.detail}</p>
|
||||
) : null}
|
||||
{recordText ? (
|
||||
<p className="mt-0.5 text-[0.72rem] text-emerald-200">world record {recordText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1 sm:grid-cols-2">
|
||||
<CounterList title="Most scanned objects" entries={counters.objects} />
|
||||
<CounterList title="Most scanned rovers" entries={counters.rovers} />
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import VipPanel from '../VipPanel/index.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
|
||||
import BarcodeGamesPanel from '../BarcodeGamesPanel/index.jsx';
|
||||
import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
@@ -317,6 +318,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
{/* activities tab */}
|
||||
<TabPanel id="activities">
|
||||
<div className={`flex flex-col ${themeGapClass}`}>
|
||||
<BarcodeGamesPanel />
|
||||
<ButtonBoxPanel />
|
||||
<KinectPanel />
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
|
||||
import useDefaultNickname from '../../hooks/useDefaultNickname.js';
|
||||
import useUserIdentitySync from '../../hooks/useUserIdentitySync.js';
|
||||
import SocketConnectionPill from '../../components/SocketConnectionPill/index.jsx';
|
||||
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
||||
import useScannerSpeech from './useScannerSpeech.js';
|
||||
|
||||
const EMPTY_SCANNER_STATE = {
|
||||
@@ -48,6 +49,7 @@ export default function ScannerContent() {
|
||||
const [scannerState, setScannerState] = useState(EMPTY_SCANNER_STATE);
|
||||
const [scanAudioEvent, setScanAudioEvent] = useState(null);
|
||||
const [flashActive, setFlashActive] = useState(false);
|
||||
const { state: barcodeGameState } = useBarcodeGameState();
|
||||
|
||||
useDefaultNickname();
|
||||
useUserIdentitySync();
|
||||
@@ -117,7 +119,9 @@ export default function ScannerContent() {
|
||||
}, [focusInput, scannerState.beepAllowed, socket]);
|
||||
|
||||
const lastScan = scannerState.lastScan;
|
||||
const label = lastScan?.label || 'waiting';
|
||||
const activeGame = barcodeGameState.activeGame;
|
||||
const label = activeGame?.headline || lastScan?.label || 'waiting';
|
||||
const detail = activeGame?.detail || '';
|
||||
|
||||
return (
|
||||
<main
|
||||
@@ -141,9 +145,16 @@ export default function ScannerContent() {
|
||||
}}
|
||||
/>
|
||||
<section className="flex min-h-screen w-full items-center justify-center">
|
||||
<h1 className="max-w-full break-words text-[18vw] font-black leading-none tracking-normal">
|
||||
{label}
|
||||
</h1>
|
||||
<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}
|
||||
</h1>
|
||||
{detail ? (
|
||||
<p className="max-w-full break-words text-[5vw] font-bold leading-tight tracking-normal">
|
||||
{detail}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<SocketConnectionPill />
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user