Add Word Wrangler demos
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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%
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user