Add Word Wrangler demos
This commit is contained in:
41
examples/word-wrangler-gemini-live/client/.gitignore
vendored
Normal file
41
examples/word-wrangler-gemini-live/client/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
3
examples/word-wrangler-gemini-live/client/env.example
Normal file
3
examples/word-wrangler-gemini-live/client/env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:7860
|
||||
PIPECAT_CLOUD_API_KEY=""
|
||||
AGENT_NAME=word-wrangler
|
||||
16
examples/word-wrangler-gemini-live/client/eslint.config.mjs
Normal file
16
examples/word-wrangler-gemini-live/client/eslint.config.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
8
examples/word-wrangler-gemini-live/client/next.config.ts
Normal file
8
examples/word-wrangler-gemini-live/client/next.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
6049
examples/word-wrangler-gemini-live/client/package-lock.json
generated
Normal file
6049
examples/word-wrangler-gemini-live/client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
examples/word-wrangler-gemini-live/client/package.json
Normal file
33
examples/word-wrangler-gemini-live/client/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pipecat-ai/client-js": "^0.3.5",
|
||||
"@pipecat-ai/client-react": "^0.3.5",
|
||||
"@pipecat-ai/daily-transport": "^0.3.10",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"@tailwindcss/postcss": "^4.1.3",
|
||||
"js-confetti": "^0.12.0",
|
||||
"next": "15.2.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.2.4",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.1.3",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.3088 5.05615C3.64682 4.92779 4.02833 5.02411 4.26653 5.29797L7.36884 8.86461H16.6312L19.7335 5.29797C19.9717 5.02411 20.3532 4.92779 20.6912 5.05615C21.0292 5.18452 21.253 5.51072 21.253 5.87504V13.75H24V15.5H19.5181V8.19909L17.6762 10.3167C17.5115 10.506 17.2738 10.6146 17.0241 10.6146H6.9759C6.72616 10.6146 6.48854 10.506 6.32383 10.3167L4.48193 8.19909V15.5H0V13.75H2.74699V5.87504C2.74699 5.51072 2.97078 5.18452 3.3088 5.05615Z" fill="black"/>
|
||||
<path d="M19.5181 17.25H24V19H19.5181V17.25Z" fill="black"/>
|
||||
<path d="M0 17.25H4.48193V19H0V17.25Z" fill="black"/>
|
||||
<path d="M9.25301 14.3333C9.25301 14.9777 8.73517 15.5 8.09639 15.5C7.4576 15.5 6.93976 14.9777 6.93976 14.3333C6.93976 13.689 7.4576 13.1667 8.09639 13.1667C8.73517 13.1667 9.25301 13.689 9.25301 14.3333Z" fill="black"/>
|
||||
<path d="M17.0602 14.3333C17.0602 14.9777 16.5424 15.5 15.9036 15.5C15.2648 15.5 14.747 14.9777 14.747 14.3333C14.747 13.689 15.2648 13.1667 15.9036 13.1667C16.5424 13.1667 17.0602 13.689 17.0602 14.3333Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
BIN
examples/word-wrangler-gemini-live/client/public/og-image.png
Normal file
BIN
examples/word-wrangler-gemini-live/client/public/og-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 549 KiB |
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,
|
||||
};
|
||||
}
|
||||
30
examples/word-wrangler-gemini-live/client/tsconfig.json
Normal file
30
examples/word-wrangler-gemini-live/client/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/components/*": ["./src/components/*"],
|
||||
"@/contexts/*": ["./src/contexts/*"],
|
||||
"@/providers/*": ["./src/providers/*"],
|
||||
"@/styles/*": ["./src/styles/*"],
|
||||
"@/data/*": ["./src/data/*"],
|
||||
"@/types/*": ["./src/types/*"],
|
||||
"@/constants/*": ["./src/constants/*"],
|
||||
"@/utils/*": ["./src/utils/*"],
|
||||
"@/hooks/*": ["./src/hooks/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user