mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
even more more barcode game stuff
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -18,8 +18,8 @@
|
||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-Bv_Ho3il.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-esPFU1Dr.css">
|
||||
<script type="module" crossorigin src="/assets/index-I6c2my6z.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CBt8gbRK.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -224,6 +224,9 @@ function getPublicState(rawState, context = {}) {
|
||||
const remainingMs = state.stepStartedAt
|
||||
? Math.max(0, REQUEST_TIMEOUT_MS - (now - state.stepStartedAt))
|
||||
: 0;
|
||||
const currentStep = state.currentQuest?.steps?.[state.progressIndex] || null;
|
||||
const totalSteps = state.currentQuest?.steps?.length || 0;
|
||||
const stepLabel = totalSteps > 1 ? `Step ${state.progressIndex + 1} of ${totalSteps}` : 'Find the object';
|
||||
return {
|
||||
id: GAME_ID,
|
||||
title: 'Scan quest',
|
||||
@@ -238,6 +241,28 @@ function getPublicState(rawState, context = {}) {
|
||||
},
|
||||
remainingMs,
|
||||
actionLabel: 'Start quest',
|
||||
display: {
|
||||
// The display payload is intentionally structured instead of HTML or
|
||||
// React component names. Games describe what should be shown, while each
|
||||
// client surface decides how large or compact that information should be.
|
||||
title: 'Scan quest',
|
||||
primary: currentStep ? `Scan ${currentStep.label}` : 'Add object barcodes',
|
||||
secondary: currentStep ? stepLabel : 'The registry needs at least one object',
|
||||
timer: state.stepStartedAt
|
||||
? {
|
||||
label: 'Time left',
|
||||
endsAt: state.stepStartedAt + REQUEST_TIMEOUT_MS,
|
||||
}
|
||||
: null,
|
||||
stats: [
|
||||
{ label: 'Completed', value: state.completedQuests },
|
||||
{ label: 'Quest points', value: getTopScores(state)[0]?.points || 0 },
|
||||
],
|
||||
results: state.recentEvents.slice(0, 3).map((event) => ({
|
||||
label: event.kind === 'timeout' ? 'Skipped' : event.kind,
|
||||
value: event.label || event.points || '',
|
||||
})),
|
||||
},
|
||||
scores: getTopScores(state),
|
||||
completedQuests: state.completedQuests,
|
||||
recentEvents: state.recentEvents,
|
||||
|
||||
@@ -223,6 +223,14 @@ function getPublicState(rawState, context = {}) {
|
||||
? calculateRate(state.scans.length, state.startedAt, elapsedEnd)
|
||||
: state.finalResult?.scansPerSecond || 0;
|
||||
const remainingMs = state.status === 'running' ? Math.max(0, (state.endsAt || now) - now) : 0;
|
||||
const worldRecordText = state.worldRecord
|
||||
? `${Number(state.worldRecord.scansPerSecond || 0).toFixed(2)} scans per second`
|
||||
: 'none yet';
|
||||
const primary = state.status === 'running'
|
||||
? `${Number(currentRate).toFixed(2)} scans per second`
|
||||
: state.finalResult
|
||||
? `${Number(state.finalResult.scansPerSecond || 0).toFixed(2)} scans per second`
|
||||
: 'Scan anything';
|
||||
|
||||
return {
|
||||
id: GAME_ID,
|
||||
@@ -242,6 +250,33 @@ function getPublicState(rawState, context = {}) {
|
||||
participants: Object.values(state.participantCounts || {}).sort((a, b) => (b.scanCount || 0) - (a.scanCount || 0)),
|
||||
recentRounds: state.recentRounds,
|
||||
actionLabel: state.status === 'running' ? 'Running' : 'Start round',
|
||||
display: {
|
||||
// This display description lets the scanner page and the driver panel
|
||||
// render the same game state at different scales without hardcoding
|
||||
// scans-per-second-specific UI branches in shared React components.
|
||||
title: 'Scans per second',
|
||||
primary,
|
||||
secondary: state.status === 'running'
|
||||
? `${state.scans.length} scans counted`
|
||||
: state.finalResult
|
||||
? `${state.finalResult.scanCount} scans in the last round`
|
||||
: 'Five minute scan challenge',
|
||||
timer: state.status === 'running' && state.endsAt
|
||||
? {
|
||||
label: 'Time left',
|
||||
endsAt: state.endsAt,
|
||||
}
|
||||
: null,
|
||||
stats: [
|
||||
{ label: 'Scans', value: state.scans.length },
|
||||
{ label: 'Current rate', value: Number(currentRate).toFixed(2) },
|
||||
{ label: 'World record', value: worldRecordText },
|
||||
],
|
||||
results: state.recentRounds.slice(0, 3).map((round) => ({
|
||||
label: 'Round',
|
||||
value: `${Number(round.scansPerSecond || 0).toFixed(2)} scans per second`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -258,23 +258,6 @@ function activateGame(draft, gameId, now) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetGame(draft, gameId, now) {
|
||||
const definition = getGameDefinition(gameId);
|
||||
if (!definition) return false;
|
||||
const currentState = ensureGameState(draft, gameId);
|
||||
const nextState = definition.reset
|
||||
? definition.reset(currentState, buildGameContext(draft, { now }))
|
||||
: definition.createInitialState();
|
||||
draft.games[gameId] = nextState;
|
||||
draft.activeGameId = gameId;
|
||||
addRecentEvent(draft, {
|
||||
kind: 'gameReset',
|
||||
gameId,
|
||||
title: definition.title,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeGameResult(result, fallbackState) {
|
||||
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
||||
return { state: fallbackState, awards: [] };
|
||||
@@ -349,7 +332,7 @@ function setVote(socket, gameId) {
|
||||
});
|
||||
|
||||
broadcastState();
|
||||
return { success: true, state: buildStatePayload() };
|
||||
return { success: true, state: buildStatePayload(socket) };
|
||||
}
|
||||
|
||||
function settleActiveGameIfNeeded() {
|
||||
@@ -404,29 +387,40 @@ function handleScan(scan) {
|
||||
broadcastState();
|
||||
}
|
||||
|
||||
function resetActiveGame() {
|
||||
const now = Date.now();
|
||||
let resetGameId = null;
|
||||
|
||||
withGameStore((draft) => {
|
||||
const gameId = draft.activeGameId;
|
||||
if (!getGameDefinition(gameId)) return;
|
||||
resetGameId = gameId;
|
||||
resetGame(draft, gameId, now);
|
||||
});
|
||||
|
||||
if (!resetGameId) return { error: 'no active barcode game' };
|
||||
broadcastState();
|
||||
return { success: true, state: buildStatePayload() };
|
||||
}
|
||||
|
||||
function topCounters(bucket = {}, limit = 5) {
|
||||
return Object.values(bucket || {})
|
||||
.sort((a, b) => (b.count || 0) - (a.count || 0))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function buildStatePayload() {
|
||||
function getPlayerForSocket(store, socket) {
|
||||
if (!socket) return null;
|
||||
const identity = getIdentitySummary(socket);
|
||||
const playerKey = normalizePlayerKey(identity, socket.id, '');
|
||||
const player = playerKey ? store.players?.[playerKey] || null : null;
|
||||
if (!player) {
|
||||
return {
|
||||
playerKey,
|
||||
nickname: identity.nickname || null,
|
||||
totalPoints: 0,
|
||||
rank: null,
|
||||
games: {},
|
||||
};
|
||||
}
|
||||
const rankedPlayers = Object.values(store.players || {})
|
||||
.filter((entry) => Number.isFinite(entry?.totalPoints) && entry.totalPoints > 0)
|
||||
.sort((a, b) => (b.totalPoints || 0) - (a.totalPoints || 0));
|
||||
const rank = rankedPlayers.findIndex((entry) => entry.playerKey === player.playerKey) + 1;
|
||||
return {
|
||||
playerKey: player.playerKey,
|
||||
nickname: player.nickname || identity.nickname || null,
|
||||
totalPoints: player.totalPoints || 0,
|
||||
rank: rank > 0 ? rank : null,
|
||||
games: player.games || {},
|
||||
};
|
||||
}
|
||||
|
||||
function buildStatePayload(socket = null) {
|
||||
settleActiveGameIfNeeded();
|
||||
const store = loadStore();
|
||||
const now = Date.now();
|
||||
@@ -452,6 +446,7 @@ function buildStatePayload() {
|
||||
})),
|
||||
activeGame,
|
||||
leaderboard,
|
||||
ownPlayer: getPlayerForSocket(store, socket),
|
||||
counters: {
|
||||
objects: topCounters(store.globalCounters?.objects),
|
||||
rovers: topCounters(store.globalCounters?.rovers),
|
||||
@@ -481,13 +476,20 @@ function ensureReadonlyGameState(store, gameId) {
|
||||
}
|
||||
|
||||
function broadcastState() {
|
||||
io.to(GAME_SOCKET_ROOM).emit('barcodeGame:state', buildStatePayload());
|
||||
const room = io.sockets.adapter.rooms.get(GAME_SOCKET_ROOM);
|
||||
if (!room) return;
|
||||
room.forEach((socketId) => {
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
if (socket) {
|
||||
socket.emit('barcodeGame:state', buildStatePayload(socket));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('barcodeGame:subscribe', (_payload = {}, cb = () => {}) => {
|
||||
socket.join(GAME_SOCKET_ROOM);
|
||||
const state = buildStatePayload();
|
||||
const state = buildStatePayload(socket);
|
||||
socket.emit('barcodeGame:state', state);
|
||||
cb({ success: true, state });
|
||||
});
|
||||
@@ -501,14 +503,6 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('barcodeGame:resetActive', (_payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
cb(resetActiveGame(socket));
|
||||
} catch (err) {
|
||||
logger.warn('Barcode game reset failed', { error: err.message });
|
||||
cb({ error: err.message || 'barcode game reset failed' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
subscribe('barcode.scanned', (event) => {
|
||||
@@ -524,7 +518,6 @@ subscribe('barcode.scanned', (event) => {
|
||||
module.exports = {
|
||||
buildStatePayload,
|
||||
handleScan,
|
||||
resetActiveGame,
|
||||
setVote,
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ const EMPTY_BARCODE_GAME_STATE = {
|
||||
codes: [],
|
||||
},
|
||||
recentEvents: [],
|
||||
ownPlayer: null,
|
||||
};
|
||||
|
||||
function normalizeState(payload = {}) {
|
||||
@@ -67,29 +68,11 @@ export default function useBarcodeGameState() {
|
||||
[socket],
|
||||
);
|
||||
|
||||
const resetActiveGame = useCallback(
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
socket.emit('barcodeGame:resetActive', {}, (response = {}) => {
|
||||
if (response.error) {
|
||||
reject(new Error(response.error));
|
||||
return;
|
||||
}
|
||||
if (response.state) {
|
||||
setState(normalizeState(response.state));
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
}),
|
||||
[socket],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
state,
|
||||
resetActiveGame,
|
||||
voteForGame,
|
||||
}),
|
||||
[resetActiveGame, state, voteForGame],
|
||||
[state, voteForGame],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,69 +1,79 @@
|
||||
// Barcode Games Panel
|
||||
// Purpose: Shows global barcode game state and voting controls inside the driver's Activities tab.
|
||||
// Scope: Gives drivers a readable control/status surface while the scanner page remains the room-facing display.
|
||||
import { useMemo, useState } from 'react';
|
||||
// Purpose: Shows barcode game selection, current game status, and player points in the Activities tab.
|
||||
// Scope: Keeps the driver-side UI compact but informative; game-specific rules stay in server game modules.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
function formatSeconds(ms) {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return null;
|
||||
const totalSeconds = Math.ceil(ms / 1000);
|
||||
function useClock(enabled) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
const timer = window.setInterval(() => {
|
||||
setNow(Date.now());
|
||||
}, 500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [enabled]);
|
||||
|
||||
return now;
|
||||
}
|
||||
|
||||
function formatTimer(endsAt, now) {
|
||||
if (!Number.isFinite(endsAt)) return null;
|
||||
const totalSeconds = Math.max(0, Math.ceil((endsAt - now) / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return minutes > 0 ? `${minutes}:${String(seconds).padStart(2, '0')}` : `${seconds}s`;
|
||||
}
|
||||
|
||||
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 }) {
|
||||
function GameChoice({ game, disabled, onVote }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
disabled={disabled || game.active}
|
||||
onClick={() => onVote(game.id)}
|
||||
className={[
|
||||
'button-dark min-h-[5.5rem] min-w-0 px-2 py-1.5 text-left disabled:opacity-50',
|
||||
game.active ? 'border-emerald-300 bg-emerald-950/80 text-emerald-50' : 'bg-neutral-950/80',
|
||||
'button-dark flex min-h-[4.25rem] flex-col items-start justify-between px-1.5 py-1 text-left disabled:opacity-70',
|
||||
game.active ? 'border-emerald-500/70' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<span className="flex items-start justify-between gap-1">
|
||||
<span className="text-base font-semibold leading-tight">{game.title}</span>
|
||||
<span className="shrink-0 rounded border border-neutral-600 px-1 py-0.5 text-xs text-slate-200">
|
||||
{game.voteCount || 0} votes
|
||||
<span className="flex w-full items-start justify-between gap-1">
|
||||
<span className="text-sm font-semibold leading-tight text-neutral-50">{game.title}</span>
|
||||
<span className="shrink-0 font-mono text-[0.72rem] text-neutral-300">{game.voteCount || 0}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1 block text-sm leading-snug text-slate-300">{game.description}</span>
|
||||
<span className="mt-1 block text-xs font-semibold text-emerald-200">
|
||||
<span className="mt-0.5 line-clamp-2 text-xs leading-snug text-neutral-300">{game.description}</span>
|
||||
<span className="mt-0.5 text-[0.7rem] font-semibold text-neutral-200">
|
||||
{game.active ? 'Active' : game.actionLabel || 'Start'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatGrid({ stats }) {
|
||||
if (!Array.isArray(stats) || !stats.length) return null;
|
||||
return (
|
||||
<div className="grid gap-1 sm:grid-cols-3">
|
||||
{stats.slice(0, 6).map((stat) => (
|
||||
<div key={stat.label} className="border border-neutral-700 px-1.5 py-1">
|
||||
<p className="truncate text-[0.68rem] text-neutral-400">{stat.label}</p>
|
||||
<p className="truncate text-sm font-semibold text-neutral-100">{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CounterList({ title, entries }) {
|
||||
if (!Array.isArray(entries) || !entries.length) return null;
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 text-sm font-semibold text-slate-200">{title}</p>
|
||||
<div className="space-y-1">
|
||||
{entries.slice(0, 3).map((entry) => (
|
||||
<div
|
||||
key={`${entry.entityId || entry.code}-${entry.type || 'counter'}`}
|
||||
className="flex items-center justify-between gap-2 rounded border border-neutral-700 bg-neutral-950/70 px-2 py-1"
|
||||
>
|
||||
<span className="min-w-0 truncate text-sm text-slate-100">{formatCounter(entry).label}</span>
|
||||
<span className="font-mono text-sm font-semibold text-slate-200">{formatCounter(entry).count}</span>
|
||||
<div>
|
||||
<p className="mb-0.5 text-xs font-semibold text-neutral-300">{title}</p>
|
||||
<div className="space-y-0.5">
|
||||
{entries.slice(0, 4).map((entry) => (
|
||||
<div key={`${entry.entityId || entry.code}-${entry.type || 'counter'}`} className="flex justify-between gap-2 text-xs">
|
||||
<span className="min-w-0 truncate text-neutral-200">{entry.label || entry.entityId || entry.code}</span>
|
||||
<span className="font-mono text-neutral-300">{entry.count || 0}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -71,36 +81,48 @@ function CounterList({ title, entries }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Leaderboard({ players }) {
|
||||
if (!Array.isArray(players) || !players.length) return null;
|
||||
function Leaderboard({ players, ownPlayer }) {
|
||||
const hasOwnPoints = ownPlayer && Number.isFinite(ownPlayer.totalPoints);
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-1 text-sm font-semibold text-slate-200">Player points</p>
|
||||
<div className="space-y-1">
|
||||
{players.slice(0, 5).map((player, idx) => (
|
||||
<div
|
||||
key={player.playerKey}
|
||||
className="grid grid-cols-[2rem_minmax(0,1fr)_auto] items-center gap-2 rounded border border-neutral-700 bg-neutral-950/70 px-2 py-1"
|
||||
>
|
||||
<span className="font-mono text-sm text-slate-400">{idx + 1}</span>
|
||||
<span className="truncate text-sm font-semibold text-white">{player.nickname}</span>
|
||||
<span className="font-mono text-sm text-emerald-200">{player.totalPoints}</span>
|
||||
<div className="grid gap-1 sm:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||
<div className="border border-neutral-700 px-1.5 py-1">
|
||||
<p className="text-xs text-neutral-400">Your points</p>
|
||||
<p className="text-lg font-semibold leading-tight text-neutral-50">
|
||||
{hasOwnPoints ? ownPlayer.totalPoints : 0}
|
||||
</p>
|
||||
{ownPlayer?.rank ? <p className="text-[0.7rem] text-neutral-400">Rank {ownPlayer.rank}</p> : null}
|
||||
</div>
|
||||
<div className="border border-neutral-700 px-1.5 py-1">
|
||||
<p className="mb-0.5 text-xs font-semibold text-neutral-300">Leaderboard</p>
|
||||
{Array.isArray(players) && players.length ? (
|
||||
<div className="space-y-0.5">
|
||||
{players.slice(0, 4).map((player, idx) => (
|
||||
<div key={player.playerKey} className="grid grid-cols-[1.25rem_minmax(0,1fr)_auto] gap-1 text-xs">
|
||||
<span className="font-mono text-neutral-500">{idx + 1}</span>
|
||||
<span className="truncate text-neutral-200">{player.nickname}</span>
|
||||
<span className="font-mono text-neutral-300">{player.totalPoints}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-neutral-500">No points yet</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BarcodeGamesPanel() {
|
||||
const { state, resetActiveGame, voteForGame } = useBarcodeGameState();
|
||||
const { state, voteForGame } = useBarcodeGameState();
|
||||
const [pendingGameId, setPendingGameId] = useState(null);
|
||||
const [resetPending, setResetPending] = useState(false);
|
||||
const activeGame = state.activeGame;
|
||||
const recordText = formatRecord(activeGame);
|
||||
const remainingText = formatSeconds(activeGame?.remainingMs);
|
||||
const counters = state.counters || {};
|
||||
const voteButtons = useMemo(() => (Array.isArray(state.games) ? state.games : []), [state.games]);
|
||||
const display = activeGame?.display || {};
|
||||
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 handleVote = async (gameId) => {
|
||||
setPendingGameId(gameId);
|
||||
@@ -111,35 +133,11 @@ export default function BarcodeGamesPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
setResetPending(true);
|
||||
try {
|
||||
await resetActiveGame();
|
||||
} finally {
|
||||
setResetPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
title="Barcode games"
|
||||
bodyClassName="space-y-3 p-2 text-sm"
|
||||
actions={
|
||||
activeGame ? (
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark px-2 py-1 text-xs disabled:opacity-50"
|
||||
disabled={resetPending}
|
||||
onClick={handleReset}
|
||||
>
|
||||
Reset active game
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{voteButtons.map((game) => (
|
||||
<GameVoteButton
|
||||
<CardFrame title="Barcode games" bodyClassName="space-y-2 p-1.5 text-sm">
|
||||
<div className="grid gap-1 sm:grid-cols-2">
|
||||
{games.map((game) => (
|
||||
<GameChoice
|
||||
key={game.id}
|
||||
game={game}
|
||||
disabled={Boolean(pendingGameId)}
|
||||
@@ -148,38 +146,32 @@ export default function BarcodeGamesPanel() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-neutral-700 bg-neutral-950/80 p-3">
|
||||
<p className="truncate text-xl font-bold leading-tight text-white">
|
||||
{activeGame?.title || 'No barcode game'}
|
||||
<section className="space-y-1 border-t border-neutral-700 pt-1.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<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">
|
||||
{display.primary || 'Vote for a game to start'}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold leading-snug text-slate-100">
|
||||
{activeGame?.headline || 'vote for a game to start'}
|
||||
</p>
|
||||
{activeGame?.detail ? (
|
||||
<p className="mt-1 text-sm leading-snug text-slate-300">{activeGame.detail}</p>
|
||||
{display.secondary ? (
|
||||
<p className="mt-0.5 break-words text-sm leading-snug text-neutral-300">{display.secondary}</p>
|
||||
) : null}
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-3">
|
||||
{remainingText ? (
|
||||
<div className="rounded border border-neutral-700 bg-black/30 px-2 py-1">
|
||||
<p className="text-xs text-slate-400">Time left</p>
|
||||
<p className="font-mono text-lg font-semibold text-white">{remainingText}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{recordText ? (
|
||||
<div className="rounded border border-neutral-700 bg-black/30 px-2 py-1 sm:col-span-2">
|
||||
<p className="text-xs text-slate-400">World record</p>
|
||||
<p className="text-lg font-semibold text-emerald-200">{recordText}</p>
|
||||
{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>
|
||||
</div>
|
||||
<StatGrid stats={display.stats} />
|
||||
</section>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<Leaderboard players={state.leaderboard} />
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<CounterList title="Most scanned objects" entries={counters.objects} />
|
||||
<CounterList title="Most scanned rovers" entries={counters.rovers} />
|
||||
</div>
|
||||
<Leaderboard players={state.leaderboard} ownPlayer={state.ownPlayer} />
|
||||
|
||||
<div className="grid gap-2 border-t border-neutral-700 pt-1.5 sm:grid-cols-2">
|
||||
<CounterList title="Most scanned objects" entries={state.counters?.objects} />
|
||||
<CounterList title="Most scanned rovers" entries={state.counters?.rovers} />
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,28 @@ const EMPTY_SCANNER_STATE = {
|
||||
registryError: null,
|
||||
};
|
||||
|
||||
function useClock(enabled) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
const timer = window.setInterval(() => {
|
||||
setNow(Date.now());
|
||||
}, 500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [enabled]);
|
||||
|
||||
return now;
|
||||
}
|
||||
|
||||
function formatTimer(endsAt, now) {
|
||||
if (!Number.isFinite(endsAt)) return '';
|
||||
const totalSeconds = Math.max(0, Math.ceil((endsAt - now) / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return minutes > 0 ? `${minutes}:${String(seconds).padStart(2, '0')}` : `${seconds}s`;
|
||||
}
|
||||
|
||||
function playSubmitBeep() {
|
||||
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioContextClass) return;
|
||||
@@ -120,8 +142,12 @@ export default function ScannerContent() {
|
||||
|
||||
const lastScan = scannerState.lastScan;
|
||||
const activeGame = barcodeGameState.activeGame;
|
||||
const label = activeGame?.headline || lastScan?.label || 'waiting';
|
||||
const detail = activeGame?.detail || '';
|
||||
const display = activeGame?.display || {};
|
||||
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 || '';
|
||||
|
||||
return (
|
||||
<main
|
||||
@@ -154,6 +180,11 @@ export default function ScannerContent() {
|
||||
{detail}
|
||||
</p>
|
||||
) : null}
|
||||
{timerText ? (
|
||||
<p className="font-mono text-[6vw] font-black leading-none tracking-normal">
|
||||
{timerText}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<SocketConnectionPill />
|
||||
|
||||
Reference in New Issue
Block a user