Add Word Wrangler demos

This commit is contained in:
Mark Backman
2025-04-24 14:29:19 -04:00
parent 09ff836ef6
commit c80d09f66c
54 changed files with 12209 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
import { GAME_STATES, GAME_TEXT, GameState } from "@/constants/gameConstants";
import { IconArrowForwardUp, IconClockPause } from "@tabler/icons-react";
import React from "react";
import { GameWord } from "./GameWord";
import { Timer } from "./Timer";
import styles from "./WordWrangler.module.css";
interface GameContentProps {
gameState: GameState;
currentWord: string;
showAutoDetected: boolean;
timeLeft: number;
showIncorrect: boolean;
score: number;
skipsRemaining: number;
// onCorrect: () => void;
onSkip: () => void;
}
export const GameContent: React.FC<GameContentProps> = ({
gameState,
currentWord,
showAutoDetected,
showIncorrect,
timeLeft,
score,
skipsRemaining,
//onCorrect,
onSkip,
}) => {
// Idle or Connecting State
if (gameState === GAME_STATES.IDLE || gameState === GAME_STATES.CONNECTING) {
return (
<div className={styles.simpleLoadingPlaceholder}>
{GAME_TEXT.startingGame}
</div>
);
}
// Waiting for Intro State
if (gameState === GAME_STATES.WAITING_FOR_INTRO) {
return (
<div className="animate-pulse flex flex-1 flex-col gap-3 items-center justify-center">
<span className="size-18 flex items-center justify-center rounded-full bg-slate-900/50 text-white">
<IconClockPause size={42} className="opacity-50" />
</span>
<span className="text-white text-2xl font-bold">
{GAME_TEXT.waitingForIntro}
</span>
</div>
);
}
// Finished State
if (gameState === GAME_STATES.FINISHED) {
return (
<div className={styles.gameReadyArea}>
<div className={styles.gameResults}>
<h2>{GAME_TEXT.gameOver}</h2>
<p>
{GAME_TEXT.finalScore}: <strong>{score}</strong>
</p>
</div>
<div className={styles.statusNote}>{GAME_TEXT.clickToStart}</div>
</div>
);
}
// Active Game State
return (
<div className={styles.gameArea}>
<GameWord
word={currentWord}
showAutoDetected={showAutoDetected}
showIncorrect={showIncorrect}
/>
<div className="flex flex-col lg:flex-row gap-2 w-full">
<Timer timeLeft={timeLeft} gameState={gameState} />
<button
className="button"
onClick={onSkip}
disabled={skipsRemaining <= 0}
>
<IconArrowForwardUp size={24} className="opacity-50" />
{skipsRemaining > 0
? GAME_TEXT.skipsRemaining(skipsRemaining)
: GAME_TEXT.noSkips}
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,79 @@
import { GAME_TEXT } from "@/constants/gameConstants";
import React from "react";
import styles from "./WordWrangler.module.css";
interface GameWordProps {
word: string;
showAutoDetected: boolean;
showIncorrect: boolean;
}
export const GameWord: React.FC<GameWordProps> = ({
word,
showAutoDetected,
showIncorrect,
}) => {
return (
<div
className={`${styles.currentWord} ${
showAutoDetected ? styles.correctWordDetected : ""
} ${showIncorrect ? styles.incorrectWordDetected : ""}`}
>
<span className={styles.helpText}>{GAME_TEXT.describeWord}</span>
<span className={styles.word}>{word}</span>
{showAutoDetected && <CorrectOverlay />}
{showIncorrect && <IncorrectOverlay />}
</div>
);
};
const CorrectOverlay: React.FC = () => (
<div className={styles.autoDetectedOverlay}>
<div className={styles.checkmarkContainer}>
<svg
className={styles.checkmarkSvg}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 52 52"
>
<circle
className={styles.checkmarkCircle}
cx="26"
cy="26"
r="25"
fill="none"
/>
<path
className={styles.checkmarkCheck}
fill="none"
d="M14.1 27.2l7.1 7.2 16.7-16.8"
/>
</svg>
</div>
</div>
);
const IncorrectOverlay: React.FC = () => (
<div className={styles.incorrectOverlay}>
<div className={styles.xmarkContainer}>
<svg
className={styles.xmarkSvg}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 52 52"
>
<circle
className={styles.xmarkCircle}
cx="26"
cy="26"
r="25"
fill="none"
/>
<path
className={styles.xmarkX}
fill="none"
d="M16 16 L36 36 M36 16 L16 36"
/>
</svg>
</div>
</div>
);

View File

@@ -0,0 +1,11 @@
.divider {
width: 100%;
height: 2px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.15) 30%,
rgba(255, 255, 255, 0.15) 70%,
transparent 100%
);
}

View File

@@ -0,0 +1,44 @@
import { IconLaurelWreathFilled, IconStarFilled } from "@tabler/icons-react";
import styles from "./ScoreRow.module.css";
interface ScoreRowProps {
score: number;
bestScore: number;
}
export function ScoreRow({ score, bestScore = 0 }: ScoreRowProps) {
return (
<div className="flex flex-col w-full lg:w-auto justify-between gap-3 lg:gap-5">
<div className="flex flex-1 w-full lg:w-auto flex-row items-center gap-3 lg:gap-5 text-white bg-black/20 rounded-2xl lg:rounded-3xl px-4 py-3 lg:px-6 lg:py-4">
<IconStarFilled
size={42}
className="text-amber-300 size-8 lg:size-10"
/>
<div className="flex flex-col gap-1">
<span className="text-xs lg:text-sm uppercase font-extrabold tracking-wider">
Current score
</span>
<span className="text-xl lg:text-2xl font-extrabold leading-none">
{score}
</span>
</div>
</div>
<div className={styles.divider} />
<div className="flex flex-row items-center gap-5 text-white rounded-3xl px-6">
<IconLaurelWreathFilled
size={42}
className="text-amber-300 size-8 lg:size-10"
/>
<div className="flex flex-col gap-1">
<span className="text-xs lg:text-sm uppercase font-extrabold tracking-wider">
Best score
</span>
<span className="text-xl lg:text-2xl font-extrabold leading-none">
{bestScore}
</span>
</div>
</div>
</div>
);
}
export default ScoreRow;

View File

@@ -0,0 +1,30 @@
import { GAME_CONFIG, GAME_STATES } from "@/constants/gameConstants";
import { formatTime } from "@/utils/formatTime";
import { IconStopwatch } from "@tabler/icons-react";
import styles from "./WordWrangler.module.css";
interface TimerProps {
timeLeft: number;
gameState: string;
}
export function Timer({ timeLeft, gameState }: TimerProps) {
const lowTimer =
gameState === GAME_STATES.ACTIVE &&
timeLeft <= GAME_CONFIG.LOW_TIME_WARNING;
return (
<div className={`${styles.timer} ${lowTimer ? styles.lowTime : ""}`}>
<div className={styles.timerBadge}>
<IconStopwatch size={24} />
<span>{formatTime(timeLeft)}</span>
</div>
<div className={styles.timerBar}>
<div
className={styles.timerBarFill}
style={{ width: `${(timeLeft / GAME_CONFIG.GAME_DURATION) * 100}%` }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,589 @@
.gameContainer {
position: relative;
z-index: 1;
padding: 4px;
width: 100%;
border-radius: 28px;
margin-top: 50px;
min-height: 300px;
box-shadow: 0px 66px 26px rgba(0, 0, 0, 0.01),
0px 37px 22px rgba(0, 0, 0, 0.05), 0px 16px 16px rgba(0, 0, 0, 0.09),
0px 4px 9px rgba(0, 0, 0, 0.1);
}
@media (min-width: 1024px) {
.gameContainer {
width: auto;
flex: none;
min-width: 626px;
height: 260px;
margin-top: 0;
}
}
.gameContainer:before {
content: "";
position: absolute;
inset: -4px -4px -8px -4px;
border-radius: 28px;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 0%,
rgba(0, 0, 0, 0.15) 100%
);
z-index: -1;
}
.gameContainer:after {
content: "";
box-sizing: border-box;
position: absolute;
inset: 0;
border-radius: var(--border-radius-card);
border: var(--border-width-card) solid transparent;
background-image: linear-gradient(#001146, #0655cc),
linear-gradient(
180deg,
var(--theme-gradient-start) 0%,
var(--theme-gradient-end) 100%
);
background-origin: border-box;
background-clip: padding-box, border-box;
}
.gameContent {
position: relative;
z-index: 1;
background: transparent;
border-radius: 20px;
width: 100%;
height: 100%;
min-height: 292px;
display: flex;
overflow: hidden;
border: 6px solid rgba(0, 0, 0, 0.25);
}
.gameContent:after {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(
70% 40% at 50% 40%,
#2da6ee 0%,
rgba(45, 166, 238, 0) 100%
);
opacity: 0.76;
z-index: -1;
}
.gameArea {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
padding: 12px;
position: relative;
z-index: 2;
}
.timer {
height: var(--button-height);
border-radius: 9999px;
width: 100%;
flex-direction: row;
gap: 12px;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.2);
padding: 12px;
@media (min-width: 1024px) {
flex: 1;
}
.timerBadge {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
background-color: black;
border-radius: 9999px;
color: white;
height: 100%;
padding: 0 12px;
font-weight: 800;
}
.timerBar {
height: 100%;
width: 100%;
border-radius: 9999px;
overflow: hidden;
background-color: var(--color-emerald-100);
}
.timerBarFill {
height: 100%;
width: 100%;
background-color: var(--color-emerald-400);
transition: width 0.3s ease;
}
&.lowTime {
color: #e74c3c;
animation: pulse 1s infinite;
.timerBar {
background-color: var(--color-orange-100);
}
.timerBarFill {
background-color: var(--color-orange-400);
}
}
}
.scoreDisplay {
font-size: 1.25rem;
font-weight: 500;
color: #0071e3;
}
.currentWord {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
width: 100%;
margin-top: 50px;
.helpText {
font-size: 1rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.5);
}
.word {
font-size: 2rem;
font-weight: 800;
letter-spacing: 0.05em;
line-height: 2;
color: #ffffff;
text-shadow: 0px 4px 0px rgba(0, 0, 0, 0.45);
}
@media (min-width: 1024px) {
margin-top: 0;
.word {
font-size: 3rem;
text-shadow: 0px 6px 0px rgba(0, 0, 0, 0.45);
}
}
}
.gameButton {
padding: 0.85rem 0;
font-size: 1.1rem;
font-weight: 500;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
/* Primary button (Skip) */
.skipButton {
flex: 2; /* Takes more space */
background-color: #e74c3c;
color: white;
}
.skipButton:hover {
background-color: #c0392b;
transform: translateY(-2px);
}
/* Secondary button (Correct) - more subdued */
.correctButton {
flex: 1; /* Takes less space */
background-color: #f5f5f7; /* Light gray background */
color: #333; /* Dark text */
border: 1px solid #ddd; /* Subtle border */
}
.correctButton:hover {
background-color: #e8e8ed;
transform: translateY(-1px);
}
.gameReadyArea {
display: flex;
flex-direction: column;
align-items: center;
}
.gameResults {
margin-bottom: 1rem;
padding: 0.75rem;
background-color: #f8f9fa;
border-radius: 8px;
width: 100%;
text-align: center;
}
.gameResults h2 {
margin: 0 0 0.5rem 0;
color: #333;
font-size: 1.3rem;
}
.statusNote {
margin: 0.5rem 0;
padding: 0.6rem 1rem;
background-color: #f8f9fa;
border-left: 3px solid #0071e3;
font-size: 0.95rem;
color: #333;
width: 100%;
text-align: center;
border-radius: 4px;
}
.compactInstructions {
margin: 0.75rem 0;
width: 100%;
max-width: 400px;
background-color: #f8f9fa;
border-radius: 8px;
padding: 0.75rem 1rem;
}
.compactInstructions h3 {
margin: 0 0 0.5rem 0;
color: #333;
font-size: 1.1rem;
text-align: center;
}
.compactInstructions ul {
margin: 0;
padding-left: 1.5rem;
line-height: 1.4;
}
.compactInstructions li {
margin-bottom: 0.4rem;
font-size: 0.9rem;
}
.loadingDots {
display: inline-block;
animation: dotPulse 1.5s infinite linear;
}
@keyframes dotPulse {
0% {
opacity: 0.2;
}
20% {
opacity: 1;
}
100% {
opacity: 0.2;
}
}
@keyframes pulse {
0% {
opacity: 0.8;
}
50% {
opacity: 1;
}
100% {
opacity: 0.8;
}
}
/* Animation styles */
.correctWordDetected {
animation: correctPulse 1.5s ease-in-out;
position: relative;
}
.autoDetectedOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(46, 204, 113, 0.6);
border-radius: 8px;
animation: fadeIn 0.3s ease-in-out;
z-index: 10;
}
.checkmarkContainer {
width: 80px;
height: 80px;
animation: scaleUp 0.4s ease-out;
}
.checkmarkSvg {
width: 100%;
height: 100%;
border-radius: 50%;
display: block;
stroke-width: 4;
stroke: #fff;
stroke-miterlimit: 10;
box-shadow: 0 0 0 rgba(46, 204, 113, 0.7);
animation: fillCheck 0.3s ease-in-out 0.3s forwards,
scale 0.2s ease-in-out 0.7s both;
}
.checkmarkCircle {
stroke-dasharray: 166;
stroke-dashoffset: 166;
stroke-width: 4;
stroke-miterlimit: 10;
stroke: #fff;
fill: transparent;
animation: strokeCheck 0.5s cubic-bezier(0.65, 0, 0.45, 1) forwards;
}
.checkmarkCheck {
transform-origin: 50% 50%;
stroke-dasharray: 48;
stroke-dashoffset: 48;
animation: strokeCheck 0.25s cubic-bezier(0.65, 0, 0.45, 1) 0.6s forwards;
}
@keyframes strokeCheck {
100% {
stroke-dashoffset: 0;
}
}
@keyframes fillCheck {
100% {
box-shadow: inset 0 0 0 50px transparent;
}
}
@keyframes correctPulse {
0% {
box-shadow: 0 0 0 0 rgba(46, 204, 113, 0.7);
}
50% {
box-shadow: 0 0 0 15px rgba(46, 204, 113, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(46, 204, 113, 0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scaleUp {
from {
transform: scale(0.5);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.incorrectWordDetected {
animation: incorrectPulse 1.5s ease-in-out,
shake 0.5s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
position: relative;
}
.incorrectOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(255, 59, 48, 0.6); /* Red with transparency */
border-radius: 8px;
animation: fadeIn 0.3s ease-in-out;
z-index: 10;
}
.xmarkContainer {
width: 80px;
height: 80px;
animation: scaleUp 0.4s ease-out;
}
.xmarkSvg {
width: 100%;
height: 100%;
border-radius: 50%;
display: block;
stroke-width: 4;
stroke: #fff;
stroke-miterlimit: 10;
box-shadow: 0 0 0 rgba(255, 59, 48, 0.7);
animation: fillX 0.3s ease-in-out 0.3s forwards,
scale 0.2s ease-in-out 0.7s both;
}
.xmarkCircle {
stroke-dasharray: 166;
stroke-dashoffset: 166;
stroke-width: 4;
stroke-miterlimit: 10;
stroke: #fff;
fill: transparent;
animation: strokeX 0.5s cubic-bezier(0.65, 0, 0.45, 1) forwards;
}
.xmarkX {
transform-origin: 50% 50%;
stroke-dasharray: 48;
stroke-dashoffset: 48;
animation: strokeX 0.25s cubic-bezier(0.65, 0, 0.45, 1) 0.6s forwards;
}
@keyframes strokeX {
100% {
stroke-dashoffset: 0;
}
}
@keyframes fillX {
100% {
box-shadow: inset 0 0 0 50px transparent;
}
}
@keyframes incorrectPulse {
0% {
box-shadow: 0 0 0 0 rgba(255, 59, 48, 0.7);
}
50% {
box-shadow: 0 0 0 15px rgba(255, 59, 48, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(255, 59, 48, 0);
}
}
@keyframes scale {
0%,
100% {
transform: none;
}
50% {
transform: scale3d(1.1, 1.1, 1);
}
}
@keyframes shake {
10%,
90% {
transform: translate3d(-1px, 0, 0);
}
20%,
80% {
transform: translate3d(2px, 0, 0);
}
30%,
50%,
70% {
transform: translate3d(-3px, 0, 0);
}
40%,
60% {
transform: translate3d(3px, 0, 0);
}
}
/* Game loading UI styles */
.gameLoadingContainer {
display: flex;
justify-content: center;
align-items: center;
height: 250px; /* Fixed height to prevent layout shifts */
width: 100%;
}
.gameLoadingContent {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.5rem;
text-align: center;
}
.gameLoadingIcon {
position: relative;
width: 60px;
height: 60px;
display: flex;
justify-content: center;
align-items: center;
}
.pulseDot {
width: 16px;
height: 16px;
background-color: #0071e3;
border-radius: 50%;
position: relative;
}
.pulseDot:before {
content: "";
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
background-color: #0071e3;
opacity: 0.7;
animation: pulse-wave 1.5s linear infinite;
}
.gameLoadingTitle {
font-size: 1.5rem;
font-weight: 500;
color: #0071e3;
margin: 0;
}
@keyframes pulse-wave {
0% {
transform: scale(1);
opacity: 0.7;
}
50% {
transform: scale(2.5);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 0;
}
}

View File

@@ -0,0 +1,227 @@
import { GAME_STATES, GAME_TEXT } from "@/constants/gameConstants";
import { useConnectionState } from "@/hooks/useConnectionState";
import { useGameState } from "@/hooks/useGameState";
import { useGameTimer } from "@/hooks/useGameTimer";
import { useVisualFeedback } from "@/hooks/useVisualFeedback";
import { useWordDetection } from "@/hooks/useWordDetection";
import { RTVIEvent } from "@pipecat-ai/client-js";
import { useRTVIClientEvent } from "@pipecat-ai/client-react";
import { IconCircleDashedCheck, IconDoorExit } from "@tabler/icons-react";
import { useCallback, useEffect, useRef } from "react";
import Logo from "../../assets/logo.png";
import { GameContent } from "./GameContent";
import { ScoreRow } from "./ScoreRow";
import JSConfetti from "js-confetti";
import Image from "next/image";
import styles from "./WordWrangler.module.css";
export const WordWrangler: React.FC<{
onGameEnded: (score: number, bestScore: number) => void;
}> = ({ onGameEnded }) => {
const botIntroCompletedRef = useRef(false);
const currentScoreRef = useRef(0);
const gameState = useGameState();
const visualFeedback = useVisualFeedback();
const { isConnected, client } = useConnectionState();
// Update the ref whenever score changes
useEffect(() => {
currentScoreRef.current = gameState.score;
}, [gameState.score]);
// End the game
const endGame = useCallback(async () => {
const scoreAtCallTime = currentScoreRef.current;
// Prevent multiple calls to endGame
if (gameState.gameState === GAME_STATES.FINISHED) {
console.log("endGame prevented - game already finished");
return;
}
// Capture the current score before any state changes
const finalScore = scoreAtCallTime;
const currentBestScore = gameState.bestScore;
// Update game state
gameState.finishGame();
visualFeedback.resetVisuals();
// Update best score if needed
if (currentBestScore < finalScore) {
gameState.setBestScore(finalScore);
}
// Disconnect the bot
if (client && isConnected) {
try {
await client.disconnectBot();
await client.disconnect();
} catch (error) {
console.error("Error disconnecting bot:", error);
}
}
// Call the callback with the captured scores
onGameEnded(finalScore, Math.max(finalScore, currentBestScore));
}, [gameState, visualFeedback, client, isConnected, onGameEnded]);
const gameTimer = useGameTimer(endGame);
const wordDetection = useWordDetection({
gameState: gameState.gameState,
currentWord: gameState.currentWord,
onCorrectGuess: handleCorrectGuess,
onIncorrectGuess: handleIncorrectGuess,
});
// Initialize on component mount
useEffect(() => {
gameState.initializeGame();
}, []);
// Handle connection state changes
useEffect(() => {
if (isConnected) {
if (!botIntroCompletedRef.current) {
// Connection is active, but bot hasn't completed intro
gameState.setGameState(GAME_STATES.WAITING_FOR_INTRO);
}
} else {
// Connection lost or never established
if (gameState.gameState === GAME_STATES.ACTIVE) {
// If game was active, it's now finished
endGame();
} else if (gameState.gameState !== GAME_STATES.FINISHED) {
// Reset to idle state if not already finished
gameState.setGameState(GAME_STATES.IDLE);
}
// Reset intro state when connection is lost
botIntroCompletedRef.current = false;
}
}, [isConnected, gameState.gameState, endGame]);
// Listen for the bot to stop speaking to detect intro completion
useRTVIClientEvent(RTVIEvent.BotStoppedSpeaking, () => {
if (
gameState.gameState === GAME_STATES.WAITING_FOR_INTRO &&
!botIntroCompletedRef.current
) {
// First time the bot stops speaking, consider intro done and start the game
botIntroCompletedRef.current = true;
startGame();
}
});
// Handle correct guess with animation
function handleCorrectGuess() {
visualFeedback.showCorrect(() => {
gameState.incrementScore();
gameState.moveToNextWord();
wordDetection.resetLastProcessedMessage();
});
const jsConfetti = new JSConfetti();
jsConfetti.addConfetti();
}
// Handle incorrect guess with animation
function handleIncorrectGuess() {
visualFeedback.showIncorrectAnimation();
}
// Start the game
function startGame() {
// Initialize game state
gameState.initializeGame();
wordDetection.resetLastProcessedMessage();
// Start the timer - now it internally manages countdown and calls endGame when done
gameTimer.startTimer();
}
// Handle manual marking as correct
function handleManualCorrect() {
if (gameState.gameState !== GAME_STATES.ACTIVE) return;
gameState.incrementScore();
const jsConfetti = new JSConfetti();
jsConfetti.addConfetti();
gameState.moveToNextWord();
wordDetection.resetLastProcessedMessage();
}
// Handle skipping a word
function handleSkip() {
if (gameState.gameState !== GAME_STATES.ACTIVE) return;
// Try to use a skip and proceed if successful
if (gameState.useSkip()) {
gameState.moveToNextWord();
wordDetection.resetLastProcessedMessage();
}
}
// Clean up on unmount
useEffect(() => {
return () => {
gameTimer.stopTimer();
visualFeedback.cleanup();
};
}, []);
return (
<div className="min-h-[100dvh] flex flex-col">
<div className="flex-1 flex flex-col items-center justify-center h-screen">
<div className="flex flex-1 flex-col lg:flex-row gap-6 lg:gap-12 items-center justify-center w-full lg:w-auto">
<div className={styles.gameContainer}>
<Image
src={Logo}
alt="Word Wrangler"
className="logo size-[140px] absolute top-[-50px] lg:top-[-60px] left-[50%] -translate-x-1/2 lg:left-auto lg:-translate-x-0 lg:right-[-50px] z-10"
priority
/>
<div className={styles.gameContent}>
<GameContent
gameState={gameState.gameState}
timeLeft={gameTimer.timeLeft}
currentWord={gameState.currentWord}
showAutoDetected={visualFeedback.showAutoDetected}
showIncorrect={visualFeedback.showIncorrect}
score={gameState.score}
skipsRemaining={gameState.skipsRemaining}
onSkip={handleSkip}
/>
</div>
</div>
<ScoreRow score={gameState.score} bestScore={gameState.bestScore} />
</div>
<footer className="flex gap-2 py-4 lg:flex-row lg:gap-4 lg:py-6 w-full items-center justify-center">
<button
className="button outline w-full lg:w-auto"
onClick={handleManualCorrect}
disabled={gameState.gameState !== GAME_STATES.ACTIVE}
>
<IconCircleDashedCheck size={24} />
{GAME_TEXT.correct}
</button>
<button
className="button outline w-full lg:w-auto"
onClick={endGame}
disabled={
gameState.gameState == GAME_STATES.CONNECTING ||
gameState.gameState == GAME_STATES.WAITING_FOR_INTRO
}
>
<IconDoorExit size={24} />
End Game
</button>
</footer>
</div>
</div>
);
};