Add Word Wrangler demos
This commit is contained in:
BIN
examples/word-wrangler-gemini-live/client/src/assets/logo.png
Normal file
BIN
examples/word-wrangler-gemini-live/client/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 468 KiB |
BIN
examples/word-wrangler-gemini-live/client/src/assets/star.png
Normal file
BIN
examples/word-wrangler-gemini-live/client/src/assets/star.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
@@ -0,0 +1,19 @@
|
||||
export function Card({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-white rounded-3xl relative card-border text-black ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardInner({ children }: { children: React.ReactNode }) {
|
||||
return <div className="p-6 lg:p-10 relative z-2">{children}</div>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { BUTTON_TEXT } from "@/constants/gameConstants";
|
||||
import { useConnectionState } from "@/hooks/useConnectionState";
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
|
||||
interface StartGameButtonProps {
|
||||
onGameStarted?: () => void;
|
||||
onGameEnded?: () => void;
|
||||
isGameEnded?: boolean;
|
||||
}
|
||||
|
||||
export function StartGameButton({
|
||||
onGameStarted,
|
||||
onGameEnded,
|
||||
isGameEnded,
|
||||
}: StartGameButtonProps) {
|
||||
const { isConnecting, isDisconnecting, toggleConnection } =
|
||||
useConnectionState(onGameStarted, onGameEnded);
|
||||
|
||||
// Show spinner during connection process
|
||||
const showSpinner = isConnecting;
|
||||
const btnText = isGameEnded ? BUTTON_TEXT.RESTART : BUTTON_TEXT.START;
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
className="styled-button"
|
||||
onClick={toggleConnection}
|
||||
disabled={isConnecting || isDisconnecting}
|
||||
>
|
||||
<>
|
||||
<span className="styled-button-text">
|
||||
{isConnecting ? BUTTON_TEXT.CONNECTING : btnText}
|
||||
</span>
|
||||
<span className="styled-button-icon">
|
||||
{showSpinner ? (
|
||||
<span className="spinner"></span>
|
||||
) : (
|
||||
<IconArrowRight size={16} strokeWidth={3} />
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Game configuration
|
||||
export const GAME_CONFIG = {
|
||||
MAX_SKIPS: 3,
|
||||
GAME_DURATION: 60, // seconds
|
||||
WORD_POOL_SIZE: 30,
|
||||
ANIMATION_DURATION: 1000, // ms
|
||||
TIMER_INTERVAL: 1000, // ms
|
||||
LOW_TIME_WARNING: 10, // seconds
|
||||
};
|
||||
|
||||
// Game states
|
||||
export const GAME_STATES = {
|
||||
IDLE: "idle",
|
||||
CONNECTING: "connecting",
|
||||
WAITING_FOR_INTRO: "waitingForIntro",
|
||||
ACTIVE: "active",
|
||||
FINISHED: "finished",
|
||||
} as const;
|
||||
|
||||
export type GameState = (typeof GAME_STATES)[keyof typeof GAME_STATES];
|
||||
|
||||
// Text used in the game
|
||||
export const GAME_TEXT = {
|
||||
time: "Time",
|
||||
score: "Score",
|
||||
gameOver: "Game Over!",
|
||||
finalScore: "Final Score",
|
||||
correct: "Mark Correct",
|
||||
skip: "Skip →",
|
||||
noSkips: "No Skips Left",
|
||||
skipsRemaining: (num: number) => `Skip (${num} left)`,
|
||||
startingGame: `How many words can you describe in ${GAME_CONFIG.GAME_DURATION} seconds?`,
|
||||
waitingForIntro: "Getting ready...",
|
||||
clickToStart: "Press Start Game to begin",
|
||||
describeWord: "Describe the following word:",
|
||||
introTitle: "How many words can you describe within 60 seconds?",
|
||||
introGuide1: "Earn points each time the AI correctly guesses the word",
|
||||
introGuide2: "Do not say the word, or you will lose points",
|
||||
introGuide3: "You can skip the word if you don't know it",
|
||||
aiPersonality: "AI Personality",
|
||||
finalScoreMessage: "Your best score:",
|
||||
};
|
||||
|
||||
// Pattern for detecting guesses in transcripts
|
||||
export const TRANSCRIPT_PATTERNS = {
|
||||
// Match both "Is it "word"?" and "Is it a/an word?" patterns
|
||||
GUESS_PATTERN:
|
||||
/is it [""]?([^""?]+)[""]?(?:\?)?|is it (?:a|an) ([^?]+)(?:\?)?/i,
|
||||
};
|
||||
|
||||
// Connection states
|
||||
export const CONNECTION_STATES = {
|
||||
ACTIVE: ["connected", "ready"],
|
||||
CONNECTING: ["connecting", "initializing", "initialized", "authenticating"],
|
||||
DISCONNECTING: ["disconnecting"],
|
||||
};
|
||||
|
||||
// Button text
|
||||
export const BUTTON_TEXT = {
|
||||
START: "Start Game",
|
||||
END: "End Game",
|
||||
CONNECTING: "Connecting...",
|
||||
STARTING: "Starting...",
|
||||
RESTART: "Play Again",
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import { PersonalityType, DEFAULT_PERSONALITY } from '@/types/personality';
|
||||
|
||||
interface ConfigurationContextProps {
|
||||
personality: PersonalityType;
|
||||
setPersonality: (personality: PersonalityType) => void;
|
||||
}
|
||||
|
||||
const ConfigurationContext = createContext<
|
||||
ConfigurationContextProps | undefined
|
||||
>(undefined);
|
||||
|
||||
interface ConfigurationProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ConfigurationProvider({
|
||||
children,
|
||||
}: ConfigurationProviderProps) {
|
||||
const [personality, setPersonality] =
|
||||
useState<PersonalityType>(DEFAULT_PERSONALITY);
|
||||
|
||||
const value = {
|
||||
personality,
|
||||
setPersonality,
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigurationContext.Provider value={value}>
|
||||
{children}
|
||||
</ConfigurationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConfigurationSettings() {
|
||||
const context = useContext(ConfigurationContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'useConfigurationSettings must be used within a ConfigurationProvider'
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
// 100 Easy Words - Common, everyday objects and concepts
|
||||
export const EASY_CATCH_PHRASE_WORDS = [
|
||||
// Common Objects
|
||||
'Chair',
|
||||
'Table',
|
||||
'Door',
|
||||
'Window',
|
||||
'Book',
|
||||
'Pencil',
|
||||
'Phone',
|
||||
'Computer',
|
||||
'Ball',
|
||||
'Car',
|
||||
'Shoe',
|
||||
'Hat',
|
||||
'Cup',
|
||||
'Plate',
|
||||
'Fork',
|
||||
'Spoon',
|
||||
'Knife',
|
||||
'Key',
|
||||
'Clock',
|
||||
'Watch',
|
||||
|
||||
// Food & Drink
|
||||
'Pizza',
|
||||
'Hamburger',
|
||||
'Ice cream',
|
||||
'Chocolate',
|
||||
'Apple',
|
||||
'Banana',
|
||||
'Orange',
|
||||
'Milk',
|
||||
'Water',
|
||||
'Cake',
|
||||
'Cookie',
|
||||
'Bread',
|
||||
'Egg',
|
||||
'Cheese',
|
||||
'Chicken',
|
||||
|
||||
// Animals
|
||||
'Dog',
|
||||
'Cat',
|
||||
'Fish',
|
||||
'Bird',
|
||||
'Horse',
|
||||
'Cow',
|
||||
'Pig',
|
||||
'Duck',
|
||||
'Lion',
|
||||
'Tiger',
|
||||
'Bear',
|
||||
'Elephant',
|
||||
'Monkey',
|
||||
'Rabbit',
|
||||
'Frog',
|
||||
|
||||
// Colors & Simple Concepts
|
||||
'Red',
|
||||
'Blue',
|
||||
'Green',
|
||||
'Yellow',
|
||||
'Black',
|
||||
'White',
|
||||
'Big',
|
||||
'Small',
|
||||
'Hot',
|
||||
'Cold',
|
||||
'Happy',
|
||||
'Sad',
|
||||
'Fast',
|
||||
'Slow',
|
||||
'Old',
|
||||
'Young',
|
||||
'Up',
|
||||
'Down',
|
||||
'Left',
|
||||
'Right',
|
||||
|
||||
// Simple Activities
|
||||
'Run',
|
||||
'Walk',
|
||||
'Jump',
|
||||
'Swim',
|
||||
'Sleep',
|
||||
'Eat',
|
||||
'Drink',
|
||||
'Laugh',
|
||||
'Cry',
|
||||
'Smile',
|
||||
'Play',
|
||||
'Work',
|
||||
'Read',
|
||||
'Write',
|
||||
'Draw',
|
||||
'Sing',
|
||||
'Dance',
|
||||
'Talk',
|
||||
'Listen',
|
||||
'Cook',
|
||||
];
|
||||
|
||||
// 300 Medium Words - More specific items, common concepts, popular culture
|
||||
export const MEDIUM_CATCH_PHRASE_WORDS = [
|
||||
// Household Items & Technology
|
||||
'Refrigerator',
|
||||
'Television',
|
||||
'Microwave',
|
||||
'Bookshelf',
|
||||
'Couch',
|
||||
'Dishwasher',
|
||||
'Ceiling fan',
|
||||
'Toaster',
|
||||
'Vacuum cleaner',
|
||||
'Blender',
|
||||
'Printer',
|
||||
'Headphones',
|
||||
'Smartphone',
|
||||
'Laptop',
|
||||
'Tablet',
|
||||
'Camera',
|
||||
'Remote control',
|
||||
'Charger',
|
||||
'Keyboard',
|
||||
'Mouse',
|
||||
'Lightbulb',
|
||||
'Shower curtain',
|
||||
'Doorknob',
|
||||
'Power outlet',
|
||||
'Coffee maker',
|
||||
|
||||
// Food & Cuisine
|
||||
'Spaghetti',
|
||||
'Burrito',
|
||||
'Sushi',
|
||||
'Pancake',
|
||||
'Waffle',
|
||||
'Cereal',
|
||||
'Sandwich',
|
||||
'Salad',
|
||||
'French fries',
|
||||
'Hot dog',
|
||||
'Cupcake',
|
||||
'Donut',
|
||||
'Milkshake',
|
||||
'Smoothie',
|
||||
'Oatmeal',
|
||||
'Peanut butter',
|
||||
'Jelly',
|
||||
'Bacon',
|
||||
'Scrambled eggs',
|
||||
'Toast',
|
||||
'Steak',
|
||||
'Mashed potatoes',
|
||||
'Broccoli',
|
||||
'Carrot',
|
||||
'Onion',
|
||||
|
||||
// Animals & Nature
|
||||
'Giraffe',
|
||||
'Penguin',
|
||||
'Kangaroo',
|
||||
'Dolphin',
|
||||
'Octopus',
|
||||
'Butterfly',
|
||||
'Spider',
|
||||
'Eagle',
|
||||
'Turtle',
|
||||
'Squirrel',
|
||||
'Rainbow',
|
||||
'Waterfall',
|
||||
'Mountain',
|
||||
'Beach',
|
||||
'Forest',
|
||||
'Hurricane',
|
||||
'Snowflake',
|
||||
'Thunderstorm',
|
||||
'Volcano',
|
||||
'Desert',
|
||||
'Sunrise',
|
||||
'Sunset',
|
||||
'Moon',
|
||||
'Stars',
|
||||
'Planet',
|
||||
|
||||
// Sports & Activities
|
||||
'Basketball',
|
||||
'Football',
|
||||
'Soccer',
|
||||
'Baseball',
|
||||
'Tennis',
|
||||
'Golf',
|
||||
'Swimming',
|
||||
'Skiing',
|
||||
'Snowboarding',
|
||||
'Hiking',
|
||||
'Camping',
|
||||
'Fishing',
|
||||
'Gardening',
|
||||
'Painting',
|
||||
'Photography',
|
||||
'Cycling',
|
||||
'Jogging',
|
||||
'Yoga',
|
||||
'Dancing',
|
||||
'Cooking',
|
||||
'Driving',
|
||||
'Flying',
|
||||
'Sailing',
|
||||
'Surfing',
|
||||
'Rock climbing',
|
||||
|
||||
// Clothing & Accessories
|
||||
'Sunglasses',
|
||||
'Umbrella',
|
||||
'Necklace',
|
||||
'Bracelet',
|
||||
'Ring',
|
||||
'Earrings',
|
||||
'Backpack',
|
||||
'Purse',
|
||||
'Wallet',
|
||||
'Watch',
|
||||
'Sneakers',
|
||||
'Sandals',
|
||||
'Boots',
|
||||
'High heels',
|
||||
'Flip flops',
|
||||
'Scarf',
|
||||
'Gloves',
|
||||
'Belt',
|
||||
'Tie',
|
||||
'Jacket',
|
||||
'Sweater',
|
||||
'Sweatshirt',
|
||||
'Jeans',
|
||||
'Shorts',
|
||||
'Dress',
|
||||
|
||||
// Places
|
||||
'Restaurant',
|
||||
'Grocery store',
|
||||
'Shopping mall',
|
||||
'Movie theater',
|
||||
'Park',
|
||||
'School',
|
||||
'Library',
|
||||
'Museum',
|
||||
'Zoo',
|
||||
'Airport',
|
||||
'Hospital',
|
||||
'Hotel',
|
||||
'Bank',
|
||||
'Post office',
|
||||
'Gym',
|
||||
'Beach',
|
||||
'Swimming pool',
|
||||
'Church',
|
||||
'Stadium',
|
||||
'Concert hall',
|
||||
'Farm',
|
||||
'City',
|
||||
'Village',
|
||||
'Country',
|
||||
'Island',
|
||||
|
||||
// Jobs & Professions
|
||||
'Teacher',
|
||||
'Doctor',
|
||||
'Nurse',
|
||||
'Police officer',
|
||||
'Firefighter',
|
||||
'Chef',
|
||||
'Waiter',
|
||||
'Pilot',
|
||||
'Engineer',
|
||||
'Scientist',
|
||||
'Actor',
|
||||
'Singer',
|
||||
'Artist',
|
||||
'Writer',
|
||||
'Photographer',
|
||||
'Farmer',
|
||||
'Mechanic',
|
||||
'Electrician',
|
||||
'Plumber',
|
||||
'Carpenter',
|
||||
'Lawyer',
|
||||
'Accountant',
|
||||
'Businessman',
|
||||
'Salesperson',
|
||||
'Architect',
|
||||
|
||||
// Transportation
|
||||
'Bicycle',
|
||||
'Motorcycle',
|
||||
'Bus',
|
||||
'Train',
|
||||
'Airplane',
|
||||
'Helicopter',
|
||||
'Boat',
|
||||
'Ship',
|
||||
'Submarine',
|
||||
'Rocket',
|
||||
'Taxi',
|
||||
'Ambulance',
|
||||
'Fire truck',
|
||||
'Police car',
|
||||
'School bus',
|
||||
'Skateboard',
|
||||
'Scooter',
|
||||
'Rollerblades',
|
||||
'Wagon',
|
||||
'Sled',
|
||||
'Escalator',
|
||||
'Elevator',
|
||||
'Tractor',
|
||||
'Bulldozer',
|
||||
'Crane',
|
||||
|
||||
// Entertainment & Hobbies
|
||||
'Movie',
|
||||
'Music',
|
||||
'Book',
|
||||
'Game',
|
||||
'Puzzle',
|
||||
'Toy',
|
||||
'Doll',
|
||||
'Action figure',
|
||||
'Video game',
|
||||
'Board game',
|
||||
'Knitting',
|
||||
'Sewing',
|
||||
'Woodworking',
|
||||
'Baking',
|
||||
'Grilling',
|
||||
'Hunting',
|
||||
'Archery',
|
||||
'Bowling',
|
||||
'Karaoke',
|
||||
'Dancing',
|
||||
'Collecting',
|
||||
'Reading',
|
||||
'Writing',
|
||||
'Drawing',
|
||||
'Painting',
|
||||
|
||||
// Body Parts & Health
|
||||
'Heart',
|
||||
'Brain',
|
||||
'Stomach',
|
||||
'Lungs',
|
||||
'Liver',
|
||||
'Kidneys',
|
||||
'Skin',
|
||||
'Hair',
|
||||
'Nails',
|
||||
'Teeth',
|
||||
'Eyes',
|
||||
'Ears',
|
||||
'Nose',
|
||||
'Mouth',
|
||||
'Throat',
|
||||
'Shoulder',
|
||||
'Elbow',
|
||||
'Wrist',
|
||||
'Hip',
|
||||
'Knee',
|
||||
'Ankle',
|
||||
'Exercise',
|
||||
'Medicine',
|
||||
'Doctor',
|
||||
'Hospital',
|
||||
|
||||
// Common Concepts
|
||||
'Birthday',
|
||||
'Wedding',
|
||||
'Funeral',
|
||||
'Holiday',
|
||||
'Vacation',
|
||||
'Friendship',
|
||||
'Love',
|
||||
'Family',
|
||||
'Education',
|
||||
'Career',
|
||||
'Money',
|
||||
'Time',
|
||||
'Weather',
|
||||
'Season',
|
||||
'History',
|
||||
'Future',
|
||||
'Success',
|
||||
'Failure',
|
||||
'Challenge',
|
||||
'Opportunity',
|
||||
'Competition',
|
||||
'Cooperation',
|
||||
'Leadership',
|
||||
'Creativity',
|
||||
'Innovation',
|
||||
];
|
||||
|
||||
// 100 Hard Words - Abstract concepts, specific terminology, complex ideas
|
||||
export const HARD_CATCH_PHRASE_WORDS = [
|
||||
// Academic & Scientific Terms
|
||||
'Photosynthesis',
|
||||
'Mitochondria',
|
||||
'Quantum physics',
|
||||
'Relativity',
|
||||
'Biodiversity',
|
||||
'Logarithm',
|
||||
'Algorithm',
|
||||
'Neural network',
|
||||
'Nanotechnology',
|
||||
'Thermodynamics',
|
||||
'Paleontology',
|
||||
'Archaeology',
|
||||
'Linguistics',
|
||||
'Metaphysics',
|
||||
'Epistemology',
|
||||
'Cryptocurrency',
|
||||
'Blockchain',
|
||||
'Artificial intelligence',
|
||||
'Machine learning',
|
||||
'Virtual reality',
|
||||
|
||||
// Abstract Concepts
|
||||
'Nostalgia',
|
||||
'Ambivalence',
|
||||
'Empathy',
|
||||
'Serendipity',
|
||||
'Irony',
|
||||
'Existentialism',
|
||||
'Utilitarianism',
|
||||
'Nihilism',
|
||||
'Altruism',
|
||||
'Pragmatism',
|
||||
'Transcendence',
|
||||
'Symbolism',
|
||||
'Paradox',
|
||||
'Dichotomy',
|
||||
'Synchronicity',
|
||||
'Meritocracy',
|
||||
'Bureaucracy',
|
||||
'Democracy',
|
||||
'Capitalism',
|
||||
'Socialism',
|
||||
|
||||
// Specialized Terminology
|
||||
'Amortization',
|
||||
'Cryptocurrency',
|
||||
'Jurisprudence',
|
||||
'Cartography',
|
||||
'Meteorology',
|
||||
'Gentrification',
|
||||
'Immunology',
|
||||
'Horticulture',
|
||||
'Gerontology',
|
||||
'Acoustics',
|
||||
'Encryption',
|
||||
'Astrophysics',
|
||||
'Ornithology',
|
||||
'Entomology',
|
||||
'Viticulture',
|
||||
'Numismatics',
|
||||
'Philately',
|
||||
'Calligraphy',
|
||||
'Lexicography',
|
||||
'Etymology',
|
||||
|
||||
// Cultural & Historical References
|
||||
'Renaissance',
|
||||
'Industrial Revolution',
|
||||
'Cold War',
|
||||
'Enlightenment',
|
||||
'Reformation',
|
||||
'Colonialism',
|
||||
'Globalization',
|
||||
'Diaspora',
|
||||
'Apartheid',
|
||||
'Imperialism',
|
||||
'Monarchy',
|
||||
'Republic',
|
||||
'Federation',
|
||||
'Aristocracy',
|
||||
'Oligarchy',
|
||||
'Surrealism',
|
||||
'Impressionism',
|
||||
'Baroque',
|
||||
'Neoclassicism',
|
||||
'Romanticism',
|
||||
|
||||
// Complex Activities & Processes
|
||||
'Meditation',
|
||||
'Negotiation',
|
||||
'Diplomacy',
|
||||
'Arbitration',
|
||||
'Litigation',
|
||||
'Legislation',
|
||||
'Deliberation',
|
||||
'Investigation',
|
||||
'Implementation',
|
||||
'Extrapolation',
|
||||
'Procurement',
|
||||
'Outsourcing',
|
||||
'Diversification',
|
||||
'Consolidation',
|
||||
'Optimization',
|
||||
'Orchestration',
|
||||
'Choreography',
|
||||
'Composition',
|
||||
'Improvisation',
|
||||
'Interpretation',
|
||||
];
|
||||
|
||||
// Combined lists for random selection
|
||||
export const ALL_CATCH_PHRASE_WORDS = [
|
||||
...EASY_CATCH_PHRASE_WORDS,
|
||||
...MEDIUM_CATCH_PHRASE_WORDS,
|
||||
...HARD_CATCH_PHRASE_WORDS,
|
||||
];
|
||||
|
||||
// Get a batch of random words (useful for starting a game with multiple words)
|
||||
export const getRandomCatchPhraseWords = (count: number = 30): string[] => {
|
||||
const wordList = [...ALL_CATCH_PHRASE_WORDS];
|
||||
|
||||
// Shuffle the array using Fisher-Yates algorithm
|
||||
for (let i = wordList.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[wordList[i], wordList[j]] = [wordList[j], wordList[i]];
|
||||
}
|
||||
|
||||
return wordList.slice(0, count);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import {
|
||||
useRTVIClient,
|
||||
useRTVIClientTransportState,
|
||||
} from '@pipecat-ai/client-react';
|
||||
import { CONNECTION_STATES } from '@/constants/gameConstants';
|
||||
|
||||
export function useConnectionState(
|
||||
onConnected?: () => void,
|
||||
onDisconnected?: () => void
|
||||
) {
|
||||
const client = useRTVIClient();
|
||||
const transportState = useRTVIClientTransportState();
|
||||
|
||||
const isConnected = CONNECTION_STATES.ACTIVE.includes(transportState);
|
||||
const isConnecting = CONNECTION_STATES.CONNECTING.includes(transportState);
|
||||
const isDisconnecting =
|
||||
CONNECTION_STATES.DISCONNECTING.includes(transportState);
|
||||
|
||||
// Handle connection changes
|
||||
useEffect(() => {
|
||||
if (isConnected && onConnected) {
|
||||
onConnected();
|
||||
}
|
||||
if (!isConnected && !isConnecting && onDisconnected) {
|
||||
onDisconnected();
|
||||
}
|
||||
}, [isConnected, isConnecting, onConnected, onDisconnected]);
|
||||
|
||||
// Toggle connection state
|
||||
const toggleConnection = useCallback(async () => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
if (isConnected) {
|
||||
await client.disconnect();
|
||||
} else {
|
||||
await client.connect();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Connection error:', error);
|
||||
}
|
||||
}, [client, isConnected]);
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
isDisconnecting,
|
||||
toggleConnection,
|
||||
transportState,
|
||||
client, // Expose the client for direct access when needed
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { GAME_CONFIG, GAME_STATES, GameState } from "@/constants/gameConstants";
|
||||
import { getRandomCatchPhraseWords } from "@/data/wordWranglerWords";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export function useGameState() {
|
||||
// Game state
|
||||
const [gameState, setGameState] = useState<GameState>(GAME_STATES.IDLE);
|
||||
const [timeLeft, setTimeLeft] = useState(GAME_CONFIG.GAME_DURATION);
|
||||
const [score, setScore] = useState(0);
|
||||
const [words, setWords] = useState<string[]>([]);
|
||||
const [currentWordIndex, setCurrentWordIndex] = useState(0);
|
||||
const [skipsRemaining, setSkipsRemaining] = useState(GAME_CONFIG.MAX_SKIPS);
|
||||
const [bestScore, _setBestScore] = useState(0);
|
||||
|
||||
// Initialize or reset game state
|
||||
const initializeGame = useCallback(() => {
|
||||
const freshWords = getRandomCatchPhraseWords(GAME_CONFIG.WORD_POOL_SIZE);
|
||||
setWords(freshWords);
|
||||
setGameState(GAME_STATES.ACTIVE);
|
||||
setTimeLeft(GAME_CONFIG.GAME_DURATION);
|
||||
setScore(0);
|
||||
setCurrentWordIndex(0);
|
||||
setSkipsRemaining(GAME_CONFIG.MAX_SKIPS);
|
||||
|
||||
// Get best score from local storage
|
||||
const storedScore = localStorage.getItem("bestScore");
|
||||
if (storedScore) {
|
||||
_setBestScore(Number(storedScore) || 0);
|
||||
}
|
||||
return freshWords;
|
||||
}, []);
|
||||
|
||||
// End game
|
||||
const finishGame = useCallback(() => {
|
||||
setGameState(GAME_STATES.FINISHED);
|
||||
}, []);
|
||||
|
||||
// Handle scoring
|
||||
const incrementScore = useCallback(() => {
|
||||
setScore((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
// Handle best score
|
||||
const setBestScore = useCallback((newBestScore: number) => {
|
||||
_setBestScore(newBestScore);
|
||||
localStorage.setItem("bestScore", newBestScore.toString());
|
||||
}, []);
|
||||
|
||||
// Handle word navigation
|
||||
const moveToNextWord = useCallback(() => {
|
||||
setCurrentWordIndex((prev) => {
|
||||
if (prev >= words.length - 1) {
|
||||
// If we're at the end of the word list, get new words
|
||||
setWords(getRandomCatchPhraseWords(GAME_CONFIG.WORD_POOL_SIZE));
|
||||
return 0;
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
}, [words]);
|
||||
|
||||
// Handle skipping
|
||||
const useSkip = useCallback(() => {
|
||||
if (skipsRemaining <= 0) return false;
|
||||
setSkipsRemaining((prev) => prev - 1);
|
||||
return true;
|
||||
}, [skipsRemaining]);
|
||||
|
||||
// Update timer
|
||||
const decrementTimer = useCallback(() => {
|
||||
return setTimeLeft((prev) => {
|
||||
if (prev <= 1) {
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
gameState,
|
||||
setGameState,
|
||||
timeLeft,
|
||||
score,
|
||||
bestScore,
|
||||
words,
|
||||
currentWord: words[currentWordIndex] || "",
|
||||
skipsRemaining,
|
||||
|
||||
// Actions
|
||||
initializeGame,
|
||||
finishGame,
|
||||
incrementScore,
|
||||
setBestScore,
|
||||
moveToNextWord,
|
||||
useSkip,
|
||||
decrementTimer,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { GAME_CONFIG } from "@/constants/gameConstants";
|
||||
import { clearTimer } from "@/utils/timerUtils";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export function useGameTimer(onTimeUp: () => void) {
|
||||
const [timeLeft, setTimeLeft] = useState(GAME_CONFIG.GAME_DURATION);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasCalledTimeUpRef = useRef(false);
|
||||
|
||||
// Start the game timer with initial duration
|
||||
const startTimer = useCallback(() => {
|
||||
// Reset time left and timeUp flag
|
||||
setTimeLeft(GAME_CONFIG.GAME_DURATION);
|
||||
hasCalledTimeUpRef.current = false;
|
||||
|
||||
// Clear any existing timer
|
||||
timerRef.current = clearTimer(timerRef.current);
|
||||
|
||||
// Start a new timer
|
||||
timerRef.current = setInterval(() => {
|
||||
setTimeLeft((prev) => {
|
||||
if (prev <= 1 && !hasCalledTimeUpRef.current) {
|
||||
// Time's up - clear the interval and call the callback
|
||||
timerRef.current = clearTimer(timerRef.current);
|
||||
hasCalledTimeUpRef.current = true;
|
||||
onTimeUp();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, GAME_CONFIG.TIMER_INTERVAL);
|
||||
}, [onTimeUp]);
|
||||
|
||||
// Stop the timer
|
||||
const stopTimer = useCallback(() => {
|
||||
timerRef.current = clearTimer(timerRef.current);
|
||||
hasCalledTimeUpRef.current = false;
|
||||
}, []);
|
||||
|
||||
// Reset the timer to initial value without starting it
|
||||
const resetTimer = useCallback(() => {
|
||||
setTimeLeft(GAME_CONFIG.GAME_DURATION);
|
||||
hasCalledTimeUpRef.current = false;
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
timerRef.current = clearTimer(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
timeLeft,
|
||||
startTimer,
|
||||
stopTimer,
|
||||
resetTimer,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { clearTimer } from '@/utils/timerUtils';
|
||||
import { GAME_CONFIG } from '@/constants/gameConstants';
|
||||
|
||||
export function useVisualFeedback() {
|
||||
// Visual feedback state
|
||||
const [showAutoDetected, setShowAutoDetected] = useState(false);
|
||||
const [showIncorrect, setShowIncorrect] = useState(false);
|
||||
const autoDetectTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Reset all visual states
|
||||
const resetVisuals = useCallback(() => {
|
||||
setShowAutoDetected(false);
|
||||
setShowIncorrect(false);
|
||||
autoDetectTimerRef.current = clearTimer(autoDetectTimerRef.current);
|
||||
}, []);
|
||||
|
||||
// Show correct animation
|
||||
const showCorrect = useCallback((onComplete?: () => void) => {
|
||||
// Clear any existing animation
|
||||
autoDetectTimerRef.current = clearTimer(autoDetectTimerRef.current);
|
||||
|
||||
// Show correct animation
|
||||
setShowAutoDetected(true);
|
||||
setShowIncorrect(false);
|
||||
|
||||
// Set timeout to hide animation
|
||||
autoDetectTimerRef.current = setTimeout(() => {
|
||||
setShowAutoDetected(false);
|
||||
if (onComplete) onComplete();
|
||||
}, GAME_CONFIG.ANIMATION_DURATION);
|
||||
}, []);
|
||||
|
||||
// Show incorrect animation
|
||||
const showIncorrectAnimation = useCallback(() => {
|
||||
// Clear any existing animation
|
||||
autoDetectTimerRef.current = clearTimer(autoDetectTimerRef.current);
|
||||
|
||||
// Show incorrect animation
|
||||
setShowIncorrect(true);
|
||||
setShowAutoDetected(false);
|
||||
|
||||
// Set timeout to hide animation
|
||||
autoDetectTimerRef.current = setTimeout(() => {
|
||||
setShowIncorrect(false);
|
||||
}, GAME_CONFIG.ANIMATION_DURATION);
|
||||
}, []);
|
||||
|
||||
// Clean up function
|
||||
const cleanup = useCallback(() => {
|
||||
autoDetectTimerRef.current = clearTimer(autoDetectTimerRef.current);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
showAutoDetected,
|
||||
showIncorrect,
|
||||
resetVisuals,
|
||||
showCorrect,
|
||||
showIncorrectAnimation,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useRef } from 'react';
|
||||
import { useRTVIClientEvent } from '@pipecat-ai/client-react';
|
||||
import { RTVIEvent } from '@pipecat-ai/client-js';
|
||||
import { detectWordGuess } from '@/utils/wordDetection';
|
||||
import { GAME_STATES, GameState } from '@/constants/gameConstants';
|
||||
|
||||
interface UseWordDetectionProps {
|
||||
gameState: GameState;
|
||||
currentWord: string;
|
||||
onCorrectGuess: () => void;
|
||||
onIncorrectGuess: () => void;
|
||||
}
|
||||
|
||||
export function useWordDetection({
|
||||
gameState,
|
||||
currentWord,
|
||||
onCorrectGuess,
|
||||
onIncorrectGuess,
|
||||
}: UseWordDetectionProps) {
|
||||
const lastProcessedMessageRef = useRef('');
|
||||
|
||||
// Reset the last processed message
|
||||
const resetLastProcessedMessage = () => {
|
||||
lastProcessedMessageRef.current = '';
|
||||
};
|
||||
|
||||
// Listen for bot transcripts to detect correct answers
|
||||
useRTVIClientEvent(RTVIEvent.BotTranscript, (data) => {
|
||||
if (gameState !== GAME_STATES.ACTIVE) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentWord) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.text) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if this is a repeat of the same transcript
|
||||
if (data.text === lastProcessedMessageRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastProcessedMessageRef.current = data.text;
|
||||
|
||||
// Use the utility function to detect word guesses
|
||||
const result = detectWordGuess(data.text, currentWord);
|
||||
|
||||
if (result.isCorrect) {
|
||||
onCorrectGuess();
|
||||
} else if (result.isExplicitGuess) {
|
||||
onIncorrectGuess();
|
||||
} else {
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
resetLastProcessedMessage,
|
||||
};
|
||||
}
|
||||
32
examples/word-wrangler-gemini-live/client/src/pages/_app.tsx
Normal file
32
examples/word-wrangler-gemini-live/client/src/pages/_app.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ConfigurationProvider } from "@/contexts/Configuration";
|
||||
import { RTVIProvider } from "@/providers/RTVIProvider";
|
||||
import { RTVIClientAudio } from "@pipecat-ai/client-react";
|
||||
import type { AppProps } from "next/app";
|
||||
import { Nunito } from "next/font/google";
|
||||
import Head from "next/head";
|
||||
import "../styles/globals.css";
|
||||
|
||||
const nunito = Nunito({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Daily | Word Wrangler</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</Head>
|
||||
<main className={`${nunito.variable}`}>
|
||||
<ConfigurationProvider>
|
||||
<RTVIProvider>
|
||||
<RTVIClientAudio />
|
||||
<Component {...pageProps} />
|
||||
</RTVIProvider>
|
||||
</ConfigurationProvider>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Head, Html, Main, NextScript } from "next/document";
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head>
|
||||
<meta
|
||||
name="description"
|
||||
content="Describe words without saying them and an AI will guess them!"
|
||||
/>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<meta charSet="UTF-8" />
|
||||
|
||||
{/* Open Graph / Social Media Meta Tags */}
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://word-wrangler.vercel.app/" />
|
||||
<meta
|
||||
property="og:title"
|
||||
content="Word Wrangler - AI Word Guessing Game"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Describe words without saying them and an AI will guess them!"
|
||||
/>
|
||||
<meta property="og:image" content="/og-image.png" />
|
||||
|
||||
{/* Twitter Card Meta Tags */}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="https://word-wrangler.vercel.app/" />
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content="Word Wrangler - AI Word Guessing Game"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Describe words without saying them and an AI will guess them!"
|
||||
/>
|
||||
<meta name="twitter:image" content="/og-image.png" />
|
||||
</Head>
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { personality } = req.body;
|
||||
|
||||
// Validate required parameters
|
||||
if (!personality) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Missing required configuration parameters" });
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.pipecat.daily.co/v1/public/${process.env.AGENT_NAME}/start`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.PIPECAT_CLOUD_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
createDailyRoom: true,
|
||||
body: {
|
||||
personality,
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log("Response from API:", JSON.stringify(data, null, 2));
|
||||
|
||||
// Transform the response to match what RTVI client expects
|
||||
return res.status(200).json({
|
||||
room_url: data.dailyRoom,
|
||||
token: data.dailyToken,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error starting agent:", error);
|
||||
return res.status(500).json({ error: "Failed to start agent" });
|
||||
}
|
||||
}
|
||||
168
examples/word-wrangler-gemini-live/client/src/pages/index.tsx
Normal file
168
examples/word-wrangler-gemini-live/client/src/pages/index.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Card, CardInner } from "@/components/Card";
|
||||
import { WordWrangler } from "@/components/Game/WordWrangler";
|
||||
import { StartGameButton } from "@/components/StartButton";
|
||||
import { GAME_TEXT } from "@/constants/gameConstants";
|
||||
import { useConfigurationSettings } from "@/contexts/Configuration";
|
||||
import { PERSONALITY_PRESETS, PersonalityType } from "@/types/personality";
|
||||
import {
|
||||
IconArrowForwardUp,
|
||||
IconCheck,
|
||||
IconCode,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import JSConfetti from "js-confetti";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import Logo from "../assets/logo.png";
|
||||
import Star from "../assets/star.png";
|
||||
|
||||
export default function Home() {
|
||||
const [hasStarted, setHasStarted] = useState(false);
|
||||
const [gameEnded, setGameEnded] = useState(false);
|
||||
const [score, setScore] = useState(0);
|
||||
const [bestScore, setBestScore] = useState(0);
|
||||
const config = useConfigurationSettings();
|
||||
|
||||
useEffect(() => {
|
||||
if (gameEnded) {
|
||||
const confetti = new JSConfetti();
|
||||
confetti.addConfetti({
|
||||
emojis: ["⭐", "⚡️", "👑", "✨", "💫", "🏆", "💯"],
|
||||
});
|
||||
}
|
||||
}, [gameEnded]);
|
||||
|
||||
if (gameEnded) {
|
||||
return (
|
||||
<div className="flex flex-col justify-between lg:justify-center items-center min-h-[100dvh] py-4">
|
||||
<div className="flex flex-1 w-full">
|
||||
<Card className="w-full lg:max-w-2xl mx-auto mt-[50px] lg:mt-[120px] self-center text-center pt-[62px]">
|
||||
<div className="flex items-center justify-center w-[162px] h-[162px] rounded-full absolute z-20 -top-[81px] left-1/2 -translate-x-1/2 animate-bounce-in">
|
||||
<Image src={Star} alt="Star" priority />
|
||||
</div>
|
||||
<CardInner>
|
||||
<h2 className="text-xl font-extrabold">{GAME_TEXT.finalScore}</h2>
|
||||
<p className="text-4xl font-extrabold text-emerald-700 bg-emerald-50 rounded-full px-4 py-4 my-4">
|
||||
{score}
|
||||
</p>
|
||||
<p className="font-medium text-slate-500">
|
||||
{GAME_TEXT.finalScoreMessage}{" "}
|
||||
<span className="text-slate-700 font-extrabold">
|
||||
{bestScore}
|
||||
</span>
|
||||
</p>
|
||||
<div className="h-[1px] bg-slate-200 my-6" />
|
||||
<div className="flex items-center justify-center">
|
||||
<Link
|
||||
href="https://github.com/daily-co/word-wrangler-gemini-live"
|
||||
className="button ghost w-full lg:w-auto"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconCode size={24} />
|
||||
View project source code
|
||||
</Link>
|
||||
</div>
|
||||
</CardInner>
|
||||
</Card>
|
||||
</div>
|
||||
<footer className="flex flex-col justify-center w-full py-4 lg:py-12">
|
||||
<StartGameButton
|
||||
isGameEnded={true}
|
||||
onGameStarted={() => {
|
||||
setGameEnded(false);
|
||||
setScore(0);
|
||||
setHasStarted(true);
|
||||
}}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasStarted) {
|
||||
return (
|
||||
<div className="flex flex-col justify-between items-center min-h-[100dvh] py-4 overflow-hidden">
|
||||
<div className="flex flex-1">
|
||||
<Card className="lg:min-w-2xl mx-auto mt-[50px] lg:mt-[120px] self-center">
|
||||
<Image
|
||||
src={Logo}
|
||||
alt="Word Wrangler"
|
||||
className="logo size-[150px] lg:size-[278px] absolute top-[-75px] lg:top-[-139px] left-[50%] -translate-x-1/2 z-10 animate-bounce-in"
|
||||
priority
|
||||
/>
|
||||
|
||||
<CardInner>
|
||||
<div className="flex flex-col gap-5 lg:gap-8 text-center mt-[50px] lg:mt-[100px]">
|
||||
<h2 className="text-xl font-extrabold">
|
||||
{GAME_TEXT.introTitle}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-3 lg:gap-4">
|
||||
<div className="flex flex-row gap-3 relative">
|
||||
<div className="absolute -top-3 -left-3 border-3 border-white lg:static size-10 lg:size-12 bg-emerald-100 text-emerald-500 rounded-full flex items-center justify-center font-semibold">
|
||||
<IconCheck size={24} />
|
||||
</div>
|
||||
<div className="flex-1 flex h-[53px] lg:h-auto bg-slate-100 rounded-full text-slate-500 leading-5 px-12 items-center justify-center font-semibold text-pretty text-sm lg:text-base">
|
||||
{GAME_TEXT.introGuide1}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 relative">
|
||||
<div className="absolute -top-3 -left-3 border-3 border-white lg:static size-10 lg:size-12 bg-red-100 text-red-500 rounded-full flex items-center justify-center font-semibold">
|
||||
<IconX size={24} />
|
||||
</div>
|
||||
<div className="flex-1 flex h-[53px] lg:h-auto bg-slate-100 rounded-full text-slate-500 leading-5 px-12 items-center justify-center font-semibold text-pretty text-sm lg:text-base">
|
||||
{GAME_TEXT.introGuide2}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 relative">
|
||||
<div className="absolute -top-3 -left-3 border-3 border-white lg:static size-10 lg:size-12 bg-slate-100 text-slate-400 rounded-full flex items-center justify-center font-semibold">
|
||||
<IconArrowForwardUp size={24} />
|
||||
</div>
|
||||
<div className="flex-1 flex h-[53px] lg:h-auto bg-slate-100 rounded-full text-slate-500 leading-5 px-12 items-center justify-center font-semibold text-pretty text-sm lg:text-base">
|
||||
{GAME_TEXT.introGuide3}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-slate-100 h-[1px] my-4 lg:my-6" />
|
||||
<div>
|
||||
<label className="font-bold flex flex-col gap-2 flex-1">
|
||||
{GAME_TEXT.aiPersonality}
|
||||
<select
|
||||
className="rounded-xl h-11 font-normal"
|
||||
value={config.personality}
|
||||
onChange={(e) =>
|
||||
config.setPersonality(e.target.value as PersonalityType)
|
||||
}
|
||||
>
|
||||
{Object.entries(PERSONALITY_PRESETS).map(
|
||||
([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</CardInner>
|
||||
</Card>
|
||||
</div>
|
||||
<footer className="flex flex-col justify-center w-full py-4 lg:py-12">
|
||||
<StartGameButton onGameStarted={() => setHasStarted(true)} />
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WordWrangler
|
||||
onGameEnded={(score, bestScore = 0) => {
|
||||
setScore(score);
|
||||
setBestScore(bestScore);
|
||||
setGameEnded(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { RTVIClient } from "@pipecat-ai/client-js";
|
||||
import { DailyTransport } from "@pipecat-ai/daily-transport";
|
||||
import { RTVIClientProvider } from "@pipecat-ai/client-react";
|
||||
import { PropsWithChildren, useEffect, useState, useRef } from "react";
|
||||
import { useConfigurationSettings } from "@/contexts/Configuration";
|
||||
|
||||
// Get the API base URL from environment variables
|
||||
// Default to "/api" if not specified
|
||||
// "/api" is the default for Next.js API routes and used
|
||||
// for the Pipecat Cloud deployed agent
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "/api";
|
||||
|
||||
console.log("Using API base URL:", API_BASE_URL);
|
||||
|
||||
export function RTVIProvider({ children }: PropsWithChildren) {
|
||||
const [client, setClient] = useState<RTVIClient | null>(null);
|
||||
const config = useConfigurationSettings();
|
||||
const clientCreated = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Only create the client once
|
||||
if (clientCreated.current) return;
|
||||
|
||||
const transport = new DailyTransport();
|
||||
|
||||
const rtviClient = new RTVIClient({
|
||||
transport,
|
||||
params: {
|
||||
baseUrl: API_BASE_URL,
|
||||
endpoints: {
|
||||
connect: "/connect",
|
||||
},
|
||||
requestData: {
|
||||
personality: config.personality,
|
||||
},
|
||||
},
|
||||
enableMic: true,
|
||||
enableCam: false,
|
||||
});
|
||||
|
||||
setClient(rtviClient);
|
||||
clientCreated.current = true;
|
||||
|
||||
// Cleanup when component unmounts
|
||||
return () => {
|
||||
if (rtviClient) {
|
||||
rtviClient.disconnect().catch((err) => {
|
||||
console.error("Error disconnecting client:", err);
|
||||
});
|
||||
}
|
||||
clientCreated.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update the connectParams when config changes
|
||||
useEffect(() => {
|
||||
if (!client) return;
|
||||
|
||||
// Update the connect params without recreating the client
|
||||
client.params.requestData = {
|
||||
personality: config.personality,
|
||||
};
|
||||
}, [client, config.personality]);
|
||||
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <RTVIClientProvider client={client}>{children}</RTVIClientProvider>;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export const styles = {
|
||||
main: {
|
||||
display: "flex",
|
||||
flexDirection: "column" as const,
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
padding: "2rem 0",
|
||||
},
|
||||
container: {
|
||||
width: "100%",
|
||||
maxWidth: "800px",
|
||||
padding: "0 1rem",
|
||||
},
|
||||
title: {
|
||||
fontSize: "2rem",
|
||||
textAlign: "center" as const,
|
||||
marginBottom: "2rem",
|
||||
color: "#333",
|
||||
},
|
||||
gameContainer: {
|
||||
marginBottom: "2rem",
|
||||
},
|
||||
controlsContainer: {
|
||||
display: "flex",
|
||||
flexDirection: "column" as const,
|
||||
gap: "1rem",
|
||||
marginBottom: "2rem",
|
||||
},
|
||||
settings: {
|
||||
backgroundColor: "white",
|
||||
padding: "1rem",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
|
||||
},
|
||||
label: {
|
||||
display: "flex",
|
||||
flexDirection: "column" as const,
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.9rem",
|
||||
color: "#555",
|
||||
},
|
||||
select: {
|
||||
padding: "0.5rem",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
fontSize: "1rem",
|
||||
},
|
||||
instructions: {
|
||||
backgroundColor: "white",
|
||||
padding: "1.5rem",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
|
||||
},
|
||||
instructionsTitle: {
|
||||
fontSize: "1.4rem",
|
||||
marginBottom: "1rem",
|
||||
color: "#333",
|
||||
},
|
||||
instructionsList: {
|
||||
paddingLeft: "1.5rem",
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
};
|
||||
297
examples/word-wrangler-gemini-live/client/src/styles/globals.css
Normal file
297
examples/word-wrangler-gemini-live/client/src/styles/globals.css
Normal file
@@ -0,0 +1,297 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--border-radius-card: 24px;
|
||||
--border-width-card: 4px;
|
||||
--theme-gradient-start: #fdd256;
|
||||
--theme-gradient-end: #a62249;
|
||||
--button-height-sm: 52px;
|
||||
--button-height: 58px;
|
||||
--animate-bounce-in: zoom-bounce 0.75s ease-out forwards;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100dvh;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: linear-gradient(180deg, #0059b7 0%, #7dceff 100%);
|
||||
}
|
||||
|
||||
main {
|
||||
font-family: var(--font-sans);
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button:not(:disabled):hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
button:not(:disabled):active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.card-border {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card-border: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;
|
||||
}
|
||||
|
||||
.card-border:after {
|
||||
content: "";
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #ffffff;
|
||||
border-radius: var(--border-radius-card);
|
||||
border: var(--border-width-card) solid transparent;
|
||||
background-image: linear-gradient(#ffffff, #ffffff),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
var(--theme-gradient-start) 0%,
|
||||
var(--theme-gradient-end) 100%
|
||||
);
|
||||
background-origin: border-box;
|
||||
background-clip: padding-box, border-box;
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
padding: 0 36px 0 12px;
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBjbGlwLXBhdGg9InVybCgjY2xpcDBfNV80MSkiPjxwYXRoIGQ9Ik04IDlMMTIgNUwxNiA5IiBzdHJva2U9IiNCQkQ1RTEiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTE2IDE1TDEyIDE5TDggMTUiIHN0cm9rZT0iI0JCRDU4MSIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L2c+PGRlZnM+PGNsaXBQYXRoIGlkPSJjbGlwMF81XzQxIj48cmVjdCB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIGZpbGw9IndoaXRlIi8+PC9jbGlwUGF0aD48L2RlZnM+PC9zdmc+");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
background-size: 24px;
|
||||
border: 1px solid var(--color-slate-200);
|
||||
color: var(--color-slate-600);
|
||||
}
|
||||
|
||||
select::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.styled-button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
height: 56px;
|
||||
padding: 0 32px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
transparent 88%,
|
||||
rgba(255, 255, 255, 0.2) 88%,
|
||||
rgba(255, 255, 255, 0.2) 100%
|
||||
),
|
||||
linear-gradient(180deg, #10abe3 0%, #0046b5 98.08%);
|
||||
border-width: 2px 2px 5px 2px;
|
||||
border-style: solid;
|
||||
border-color: #000000;
|
||||
overflow: hidden;
|
||||
box-shadow: 0px 4px 0px 4px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.styled-button:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
border-radius: 12px;
|
||||
border: 4px solid transparent;
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
var(--theme-gradient-start) 0%,
|
||||
var(--theme-gradient-end) 100%
|
||||
);
|
||||
background-origin: border-box;
|
||||
background-clip: border-box;
|
||||
-webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.styled-button:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #0d1e4c;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.styled-button-text {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
text-shadow: 2px 2px 0 rgba(0, 0, 0, 0.35);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.styled-button:active {
|
||||
transform: translateY(6px);
|
||||
box-shadow: 0 0 0 #000000, inset 0 -2px 12px rgba(0, 0, 0, 0.2),
|
||||
inset 0 2px 12px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.styled-button-icon {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-grow: 0;
|
||||
padding: 0px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: rgba(42, 88, 173, 0.25);
|
||||
border-radius: 999px;
|
||||
z-index: 1;
|
||||
}
|
||||
.styled-button-icon:after {
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0px;
|
||||
background: rgba(3, 85, 188, 0.5);
|
||||
border-radius: 999px;
|
||||
border: 2px solid #0a82d1;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.styled-button {
|
||||
height: 60px;
|
||||
}
|
||||
.styled-button-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 2px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
appearance: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
color: #ffffff;
|
||||
font-weight: 800;
|
||||
padding: 0 12px;
|
||||
height: var(--button-height-sm);
|
||||
border-radius: 999px;
|
||||
font-size: 16px;
|
||||
|
||||
&.outline {
|
||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||
background-color: transparent;
|
||||
outline: none;
|
||||
transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;
|
||||
}
|
||||
&.outline:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
&.ghost {
|
||||
background-color: var(--color-slate-100);
|
||||
color: var(--color-slate-600);
|
||||
height: var(--button-height-sm);
|
||||
}
|
||||
|
||||
&.ghost:hover {
|
||||
background-color: var(--color-slate-200);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.button {
|
||||
padding: 0 24px;
|
||||
gap: 12px;
|
||||
height: var(--button-height);
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
|
||||
@keyframes zoom-bounce {
|
||||
0% {
|
||||
transform: scale(0.25);
|
||||
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
75% {
|
||||
transform: scale(0.95);
|
||||
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export type PersonalityType =
|
||||
| 'friendly'
|
||||
| 'professional'
|
||||
| 'enthusiastic'
|
||||
| 'thoughtful'
|
||||
| 'witty';
|
||||
|
||||
// This object can be useful for displaying user-friendly labels or descriptions
|
||||
export const PERSONALITY_PRESETS: Record<PersonalityType, string> = {
|
||||
friendly: 'Friendly',
|
||||
professional: 'Professional',
|
||||
enthusiastic: 'Enthusiastic',
|
||||
thoughtful: 'Thoughtful',
|
||||
witty: 'Witty',
|
||||
};
|
||||
|
||||
// Default personality to use
|
||||
export const DEFAULT_PERSONALITY: PersonalityType = 'witty';
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Formats seconds into MM:SS format
|
||||
*/
|
||||
export function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Safely clears any type of timer
|
||||
* @param timer The timer to clear
|
||||
* @returns null to reassign to the timer reference
|
||||
*/
|
||||
export function clearTimer(timer: NodeJS.Timeout | null): null {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a countdown timer that calls the callback every second
|
||||
* @returns A function to stop the timer
|
||||
*/
|
||||
export function createCountdownTimer(
|
||||
durationSeconds: number,
|
||||
onTick: (secondsLeft: number) => void,
|
||||
onComplete: () => void
|
||||
): () => void {
|
||||
let secondsLeft = durationSeconds;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
secondsLeft--;
|
||||
onTick(secondsLeft);
|
||||
|
||||
if (secondsLeft <= 0) {
|
||||
clearInterval(timer);
|
||||
onComplete();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { TRANSCRIPT_PATTERNS } from '@/constants/gameConstants';
|
||||
|
||||
/**
|
||||
* Checks if a transcript contains a correct guess for the target word
|
||||
*/
|
||||
export function detectWordGuess(transcript: string, targetWord: string) {
|
||||
const currentWordLower = targetWord.toLowerCase().trim();
|
||||
|
||||
// Primary detection: Look for explicit guesses
|
||||
const guessPattern = TRANSCRIPT_PATTERNS.GUESS_PATTERN;
|
||||
const guessMatch = transcript.match(guessPattern);
|
||||
|
||||
if (guessMatch) {
|
||||
// Extract the guessed word from whichever group matched (group 1 or 2)
|
||||
let guessedWord = (guessMatch[1] || guessMatch[2] || '')
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
|
||||
// Remove articles ("a", "an", "the") from the beginning of the guessed word
|
||||
guessedWord = guessedWord.replace(/^(a|an|the)\s+/i, '');
|
||||
|
||||
return {
|
||||
isCorrect: guessedWord === currentWordLower,
|
||||
isExplicitGuess: true,
|
||||
guessedWord,
|
||||
};
|
||||
}
|
||||
|
||||
// Secondary detection: Check if word appears in transcript
|
||||
const containsWord = transcript.toLowerCase().includes(currentWordLower);
|
||||
|
||||
return {
|
||||
isCorrect: containsWord,
|
||||
isExplicitGuess: false,
|
||||
guessedWord: containsWord ? targetWord : null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user