Felix Zieger
commited on
Commit
·
0ce34cb
1
Parent(s):
6a6ea1a
app update
Browse files- README.md +15 -1
- src/components/GameContainer.tsx +15 -22
- src/components/HighScoreBoard.tsx +95 -17
- src/components/game/GameOver.tsx +0 -8
- src/components/game/GuessDisplay.tsx +51 -6
- src/components/game/SentenceBuilder.tsx +48 -7
- src/components/game/WelcomeScreen.tsx +5 -3
- src/components/game/WordDisplay.tsx +20 -2
- src/services/mistralService.ts +12 -3
- supabase/functions/generate-word/index.ts +5 -3
README.md
CHANGED
|
@@ -7,7 +7,11 @@ sdk: docker
|
|
| 7 |
app_port: 8080
|
| 8 |
pinned: false
|
| 9 |
---
|
|
|
|
| 10 |
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
## Develop locally
|
| 13 |
|
|
@@ -16,4 +20,14 @@ Add a .env file with `VITE_MISTRAL_API_KEY=xxx`
|
|
| 16 |
```
|
| 17 |
npm i
|
| 18 |
npm run dev
|
| 19 |
-
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
app_port: 8080
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
+
# Think in Sync
|
| 11 |
|
| 12 |
+
This game is a variation of a classical childrens game.
|
| 13 |
+
You will be given a secret word. Your goal is to describe this secret word so that an AI can guess it.
|
| 14 |
+
However, you are only allowed to say one word at the time, taking turns with another AI.
|
| 15 |
|
| 16 |
## Develop locally
|
| 17 |
|
|
|
|
| 20 |
```
|
| 21 |
npm i
|
| 22 |
npm run dev
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
## What technologies are used for this project?
|
| 26 |
+
|
| 27 |
+
This project is built with .
|
| 28 |
+
|
| 29 |
+
- Vite
|
| 30 |
+
- TypeScript
|
| 31 |
+
- React
|
| 32 |
+
- shadcn-ui
|
| 33 |
+
- Tailwind CSS
|
src/components/GameContainer.tsx
CHANGED
|
@@ -3,14 +3,13 @@ import { getRandomWord } from "@/lib/words";
|
|
| 3 |
import { motion } from "framer-motion";
|
| 4 |
import { generateAIResponse, guessWord } from "@/services/mistralService";
|
| 5 |
import { useToast } from "@/components/ui/use-toast";
|
| 6 |
-
import { HighScoreBoard } from "./HighScoreBoard";
|
| 7 |
import { WelcomeScreen } from "./game/WelcomeScreen";
|
| 8 |
import { WordDisplay } from "./game/WordDisplay";
|
| 9 |
import { SentenceBuilder } from "./game/SentenceBuilder";
|
| 10 |
import { GuessDisplay } from "./game/GuessDisplay";
|
| 11 |
import { GameOver } from "./game/GameOver";
|
| 12 |
|
| 13 |
-
type GameState = "welcome" | "showing-word" | "building-sentence" | "showing-guess" | "game-over"
|
| 14 |
|
| 15 |
export const GameContainer = () => {
|
| 16 |
const [gameState, setGameState] = useState<GameState>("welcome");
|
|
@@ -35,7 +34,7 @@ export const GameContainer = () => {
|
|
| 35 |
if (correct) {
|
| 36 |
handleNextRound();
|
| 37 |
} else {
|
| 38 |
-
setGameState("
|
| 39 |
}
|
| 40 |
}
|
| 41 |
}
|
|
@@ -104,12 +103,16 @@ export const GameContainer = () => {
|
|
| 104 |
};
|
| 105 |
|
| 106 |
const handleNextRound = () => {
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
};
|
| 114 |
|
| 115 |
const handlePlayAgain = () => {
|
|
@@ -135,13 +138,12 @@ export const GameContainer = () => {
|
|
| 135 |
setSuccessfulRounds(prev => prev + 1);
|
| 136 |
return true;
|
| 137 |
}
|
| 138 |
-
setGameState("high-scores");
|
| 139 |
return false;
|
| 140 |
};
|
| 141 |
|
| 142 |
const getAverageWordsPerRound = () => {
|
| 143 |
if (successfulRounds === 0) return 0;
|
| 144 |
-
return totalWords / successfulRounds;
|
| 145 |
};
|
| 146 |
|
| 147 |
return (
|
|
@@ -175,27 +177,18 @@ export const GameContainer = () => {
|
|
| 175 |
sentence={sentence}
|
| 176 |
aiGuess={aiGuess}
|
| 177 |
currentWord={currentWord}
|
| 178 |
-
onNextRound={
|
| 179 |
-
handleGuessComplete();
|
| 180 |
-
handleNextRound();
|
| 181 |
-
}}
|
| 182 |
onPlayAgain={handlePlayAgain}
|
| 183 |
-
/>
|
| 184 |
-
) : gameState === "high-scores" ? (
|
| 185 |
-
<HighScoreBoard
|
| 186 |
currentScore={successfulRounds}
|
| 187 |
avgWordsPerRound={getAverageWordsPerRound()}
|
| 188 |
-
onClose={() => setGameState("game-over")}
|
| 189 |
-
onPlayAgain={handlePlayAgain}
|
| 190 |
/>
|
| 191 |
) : gameState === "game-over" ? (
|
| 192 |
<GameOver
|
| 193 |
successfulRounds={successfulRounds}
|
| 194 |
-
onViewHighScores={() => setGameState("high-scores")}
|
| 195 |
onPlayAgain={handlePlayAgain}
|
| 196 |
/>
|
| 197 |
) : null}
|
| 198 |
</motion.div>
|
| 199 |
</div>
|
| 200 |
);
|
| 201 |
-
};
|
|
|
|
| 3 |
import { motion } from "framer-motion";
|
| 4 |
import { generateAIResponse, guessWord } from "@/services/mistralService";
|
| 5 |
import { useToast } from "@/components/ui/use-toast";
|
|
|
|
| 6 |
import { WelcomeScreen } from "./game/WelcomeScreen";
|
| 7 |
import { WordDisplay } from "./game/WordDisplay";
|
| 8 |
import { SentenceBuilder } from "./game/SentenceBuilder";
|
| 9 |
import { GuessDisplay } from "./game/GuessDisplay";
|
| 10 |
import { GameOver } from "./game/GameOver";
|
| 11 |
|
| 12 |
+
type GameState = "welcome" | "showing-word" | "building-sentence" | "showing-guess" | "game-over";
|
| 13 |
|
| 14 |
export const GameContainer = () => {
|
| 15 |
const [gameState, setGameState] = useState<GameState>("welcome");
|
|
|
|
| 34 |
if (correct) {
|
| 35 |
handleNextRound();
|
| 36 |
} else {
|
| 37 |
+
setGameState("game-over");
|
| 38 |
}
|
| 39 |
}
|
| 40 |
}
|
|
|
|
| 103 |
};
|
| 104 |
|
| 105 |
const handleNextRound = () => {
|
| 106 |
+
if (handleGuessComplete()) {
|
| 107 |
+
const word = getRandomWord();
|
| 108 |
+
setCurrentWord(word);
|
| 109 |
+
setGameState("showing-word");
|
| 110 |
+
setSentence([]);
|
| 111 |
+
setAiGuess("");
|
| 112 |
+
console.log("Next round started with word:", word);
|
| 113 |
+
} else {
|
| 114 |
+
setGameState("game-over");
|
| 115 |
+
}
|
| 116 |
};
|
| 117 |
|
| 118 |
const handlePlayAgain = () => {
|
|
|
|
| 138 |
setSuccessfulRounds(prev => prev + 1);
|
| 139 |
return true;
|
| 140 |
}
|
|
|
|
| 141 |
return false;
|
| 142 |
};
|
| 143 |
|
| 144 |
const getAverageWordsPerRound = () => {
|
| 145 |
if (successfulRounds === 0) return 0;
|
| 146 |
+
return totalWords / successfulRounds + 1; // The total words include the ones in the failed last round, so we also count it in the denominator
|
| 147 |
};
|
| 148 |
|
| 149 |
return (
|
|
|
|
| 177 |
sentence={sentence}
|
| 178 |
aiGuess={aiGuess}
|
| 179 |
currentWord={currentWord}
|
| 180 |
+
onNextRound={handleNextRound}
|
|
|
|
|
|
|
|
|
|
| 181 |
onPlayAgain={handlePlayAgain}
|
|
|
|
|
|
|
|
|
|
| 182 |
currentScore={successfulRounds}
|
| 183 |
avgWordsPerRound={getAverageWordsPerRound()}
|
|
|
|
|
|
|
| 184 |
/>
|
| 185 |
) : gameState === "game-over" ? (
|
| 186 |
<GameOver
|
| 187 |
successfulRounds={successfulRounds}
|
|
|
|
| 188 |
onPlayAgain={handlePlayAgain}
|
| 189 |
/>
|
| 190 |
) : null}
|
| 191 |
</motion.div>
|
| 192 |
</div>
|
| 193 |
);
|
| 194 |
+
};
|
src/components/HighScoreBoard.tsx
CHANGED
|
@@ -12,6 +12,14 @@ import {
|
|
| 12 |
TableRow,
|
| 13 |
} from "@/components/ui/table";
|
| 14 |
import { useToast } from "@/components/ui/use-toast";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
interface HighScore {
|
| 17 |
id: string;
|
|
@@ -28,6 +36,8 @@ interface HighScoreBoardProps {
|
|
| 28 |
onPlayAgain: () => void;
|
| 29 |
}
|
| 30 |
|
|
|
|
|
|
|
| 31 |
export const HighScoreBoard = ({
|
| 32 |
currentScore,
|
| 33 |
avgWordsPerRound,
|
|
@@ -36,6 +46,8 @@ export const HighScoreBoard = ({
|
|
| 36 |
}: HighScoreBoardProps) => {
|
| 37 |
const [playerName, setPlayerName] = useState("");
|
| 38 |
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
|
|
|
| 39 |
const { toast } = useToast();
|
| 40 |
|
| 41 |
const { data: highScores, refetch } = useQuery({
|
|
@@ -62,6 +74,24 @@ export const HighScoreBoard = ({
|
|
| 62 |
return;
|
| 63 |
}
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
setIsSubmitting(true);
|
| 66 |
try {
|
| 67 |
const { error } = await supabase.from("high_scores").insert({
|
|
@@ -77,6 +107,7 @@ export const HighScoreBoard = ({
|
|
| 77 |
description: "Your score has been recorded",
|
| 78 |
});
|
| 79 |
|
|
|
|
| 80 |
await refetch();
|
| 81 |
setPlayerName("");
|
| 82 |
} catch (error) {
|
|
@@ -91,6 +122,22 @@ export const HighScoreBoard = ({
|
|
| 91 |
}
|
| 92 |
};
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
return (
|
| 95 |
<div className="space-y-6">
|
| 96 |
<div className="text-center">
|
|
@@ -101,20 +148,22 @@ export const HighScoreBoard = ({
|
|
| 101 |
</p>
|
| 102 |
</div>
|
| 103 |
|
| 104 |
-
|
| 105 |
-
<
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
| 118 |
|
| 119 |
<div className="rounded-md border">
|
| 120 |
<Table>
|
|
@@ -127,15 +176,15 @@ export const HighScoreBoard = ({
|
|
| 127 |
</TableRow>
|
| 128 |
</TableHeader>
|
| 129 |
<TableBody>
|
| 130 |
-
{
|
| 131 |
<TableRow key={score.id}>
|
| 132 |
-
<TableCell>{index + 1}</TableCell>
|
| 133 |
<TableCell>{score.player_name}</TableCell>
|
| 134 |
<TableCell>{score.score}</TableCell>
|
| 135 |
<TableCell>{score.avg_words_per_round.toFixed(1)}</TableCell>
|
| 136 |
</TableRow>
|
| 137 |
))}
|
| 138 |
-
{!
|
| 139 |
<TableRow>
|
| 140 |
<TableCell colSpan={4} className="text-center">
|
| 141 |
No high scores yet. Be the first!
|
|
@@ -146,6 +195,35 @@ export const HighScoreBoard = ({
|
|
| 146 |
</Table>
|
| 147 |
</div>
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
<div className="flex justify-end gap-4">
|
| 150 |
<Button variant="outline" onClick={onClose}>
|
| 151 |
Close
|
|
|
|
| 12 |
TableRow,
|
| 13 |
} from "@/components/ui/table";
|
| 14 |
import { useToast } from "@/components/ui/use-toast";
|
| 15 |
+
import {
|
| 16 |
+
Pagination,
|
| 17 |
+
PaginationContent,
|
| 18 |
+
PaginationItem,
|
| 19 |
+
PaginationLink,
|
| 20 |
+
PaginationNext,
|
| 21 |
+
PaginationPrevious,
|
| 22 |
+
} from "@/components/ui/pagination";
|
| 23 |
|
| 24 |
interface HighScore {
|
| 25 |
id: string;
|
|
|
|
| 36 |
onPlayAgain: () => void;
|
| 37 |
}
|
| 38 |
|
| 39 |
+
const ITEMS_PER_PAGE = 10;
|
| 40 |
+
|
| 41 |
export const HighScoreBoard = ({
|
| 42 |
currentScore,
|
| 43 |
avgWordsPerRound,
|
|
|
|
| 46 |
}: HighScoreBoardProps) => {
|
| 47 |
const [playerName, setPlayerName] = useState("");
|
| 48 |
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 49 |
+
const [hasSubmitted, setHasSubmitted] = useState(false);
|
| 50 |
+
const [currentPage, setCurrentPage] = useState(1);
|
| 51 |
const { toast } = useToast();
|
| 52 |
|
| 53 |
const { data: highScores, refetch } = useQuery({
|
|
|
|
| 74 |
return;
|
| 75 |
}
|
| 76 |
|
| 77 |
+
if (currentScore < 1) {
|
| 78 |
+
toast({
|
| 79 |
+
title: "Error",
|
| 80 |
+
description: "You need to complete at least one round to submit a score",
|
| 81 |
+
variant: "destructive",
|
| 82 |
+
});
|
| 83 |
+
return;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
if (hasSubmitted) {
|
| 87 |
+
toast({
|
| 88 |
+
title: "Error",
|
| 89 |
+
description: "You have already submitted your score for this game",
|
| 90 |
+
variant: "destructive",
|
| 91 |
+
});
|
| 92 |
+
return;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
setIsSubmitting(true);
|
| 96 |
try {
|
| 97 |
const { error } = await supabase.from("high_scores").insert({
|
|
|
|
| 107 |
description: "Your score has been recorded",
|
| 108 |
});
|
| 109 |
|
| 110 |
+
setHasSubmitted(true);
|
| 111 |
await refetch();
|
| 112 |
setPlayerName("");
|
| 113 |
} catch (error) {
|
|
|
|
| 122 |
}
|
| 123 |
};
|
| 124 |
|
| 125 |
+
const totalPages = highScores ? Math.ceil(highScores.length / ITEMS_PER_PAGE) : 0;
|
| 126 |
+
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
| 127 |
+
const paginatedScores = highScores?.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
| 128 |
+
|
| 129 |
+
const handlePreviousPage = () => {
|
| 130 |
+
if (currentPage > 1) {
|
| 131 |
+
setCurrentPage(p => p - 1);
|
| 132 |
+
}
|
| 133 |
+
};
|
| 134 |
+
|
| 135 |
+
const handleNextPage = () => {
|
| 136 |
+
if (currentPage < totalPages) {
|
| 137 |
+
setCurrentPage(p => p + 1);
|
| 138 |
+
}
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
return (
|
| 142 |
<div className="space-y-6">
|
| 143 |
<div className="text-center">
|
|
|
|
| 148 |
</p>
|
| 149 |
</div>
|
| 150 |
|
| 151 |
+
{!hasSubmitted && currentScore > 0 && (
|
| 152 |
+
<div className="flex gap-4 mb-6">
|
| 153 |
+
<Input
|
| 154 |
+
placeholder="Enter your name"
|
| 155 |
+
value={playerName}
|
| 156 |
+
onChange={(e) => setPlayerName(e.target.value)}
|
| 157 |
+
className="flex-1"
|
| 158 |
+
/>
|
| 159 |
+
<Button
|
| 160 |
+
onClick={handleSubmitScore}
|
| 161 |
+
disabled={isSubmitting || !playerName.trim() || hasSubmitted}
|
| 162 |
+
>
|
| 163 |
+
{isSubmitting ? "Submitting..." : "Submit Score"}
|
| 164 |
+
</Button>
|
| 165 |
+
</div>
|
| 166 |
+
)}
|
| 167 |
|
| 168 |
<div className="rounded-md border">
|
| 169 |
<Table>
|
|
|
|
| 176 |
</TableRow>
|
| 177 |
</TableHeader>
|
| 178 |
<TableBody>
|
| 179 |
+
{paginatedScores?.map((score, index) => (
|
| 180 |
<TableRow key={score.id}>
|
| 181 |
+
<TableCell>{startIndex + index + 1}</TableCell>
|
| 182 |
<TableCell>{score.player_name}</TableCell>
|
| 183 |
<TableCell>{score.score}</TableCell>
|
| 184 |
<TableCell>{score.avg_words_per_round.toFixed(1)}</TableCell>
|
| 185 |
</TableRow>
|
| 186 |
))}
|
| 187 |
+
{!paginatedScores?.length && (
|
| 188 |
<TableRow>
|
| 189 |
<TableCell colSpan={4} className="text-center">
|
| 190 |
No high scores yet. Be the first!
|
|
|
|
| 195 |
</Table>
|
| 196 |
</div>
|
| 197 |
|
| 198 |
+
{totalPages > 1 && (
|
| 199 |
+
<Pagination>
|
| 200 |
+
<PaginationContent>
|
| 201 |
+
<PaginationItem>
|
| 202 |
+
<PaginationPrevious
|
| 203 |
+
onClick={handlePreviousPage}
|
| 204 |
+
className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}
|
| 205 |
+
/>
|
| 206 |
+
</PaginationItem>
|
| 207 |
+
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
| 208 |
+
<PaginationItem key={page}>
|
| 209 |
+
<PaginationLink
|
| 210 |
+
onClick={() => setCurrentPage(page)}
|
| 211 |
+
isActive={currentPage === page}
|
| 212 |
+
>
|
| 213 |
+
{page}
|
| 214 |
+
</PaginationLink>
|
| 215 |
+
</PaginationItem>
|
| 216 |
+
))}
|
| 217 |
+
<PaginationItem>
|
| 218 |
+
<PaginationNext
|
| 219 |
+
onClick={handleNextPage}
|
| 220 |
+
className={currentPage === totalPages ? "pointer-events-none opacity-50" : ""}
|
| 221 |
+
/>
|
| 222 |
+
</PaginationItem>
|
| 223 |
+
</PaginationContent>
|
| 224 |
+
</Pagination>
|
| 225 |
+
)}
|
| 226 |
+
|
| 227 |
<div className="flex justify-end gap-4">
|
| 228 |
<Button variant="outline" onClick={onClose}>
|
| 229 |
Close
|
src/components/game/GameOver.tsx
CHANGED
|
@@ -3,13 +3,11 @@ import { motion } from "framer-motion";
|
|
| 3 |
|
| 4 |
interface GameOverProps {
|
| 5 |
successfulRounds: number;
|
| 6 |
-
onViewHighScores: () => void;
|
| 7 |
onPlayAgain: () => void;
|
| 8 |
}
|
| 9 |
|
| 10 |
export const GameOver = ({
|
| 11 |
successfulRounds,
|
| 12 |
-
onViewHighScores,
|
| 13 |
onPlayAgain,
|
| 14 |
}: GameOverProps) => {
|
| 15 |
return (
|
|
@@ -23,12 +21,6 @@ export const GameOver = ({
|
|
| 23 |
You completed {successfulRounds} rounds successfully!
|
| 24 |
</p>
|
| 25 |
<div className="flex gap-4">
|
| 26 |
-
<Button
|
| 27 |
-
onClick={onViewHighScores}
|
| 28 |
-
className="flex-1 bg-secondary text-lg hover:bg-secondary/90"
|
| 29 |
-
>
|
| 30 |
-
View High Scores
|
| 31 |
-
</Button>
|
| 32 |
<Button
|
| 33 |
onClick={onPlayAgain}
|
| 34 |
className="flex-1 bg-primary text-lg hover:bg-primary/90"
|
|
|
|
| 3 |
|
| 4 |
interface GameOverProps {
|
| 5 |
successfulRounds: number;
|
|
|
|
| 6 |
onPlayAgain: () => void;
|
| 7 |
}
|
| 8 |
|
| 9 |
export const GameOver = ({
|
| 10 |
successfulRounds,
|
|
|
|
| 11 |
onPlayAgain,
|
| 12 |
}: GameOverProps) => {
|
| 13 |
return (
|
|
|
|
| 21 |
You completed {successfulRounds} rounds successfully!
|
| 22 |
</p>
|
| 23 |
<div className="flex gap-4">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
<Button
|
| 25 |
onClick={onPlayAgain}
|
| 26 |
className="flex-1 bg-primary text-lg hover:bg-primary/90"
|
src/components/game/GuessDisplay.tsx
CHANGED
|
@@ -1,5 +1,12 @@
|
|
| 1 |
import { Button } from "@/components/ui/button";
|
| 2 |
import { motion } from "framer-motion";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
interface GuessDisplayProps {
|
| 5 |
sentence: string[];
|
|
@@ -7,6 +14,8 @@ interface GuessDisplayProps {
|
|
| 7 |
currentWord: string;
|
| 8 |
onNextRound: () => void;
|
| 9 |
onPlayAgain: () => void;
|
|
|
|
|
|
|
| 10 |
}
|
| 11 |
|
| 12 |
export const GuessDisplay = ({
|
|
@@ -15,8 +24,11 @@ export const GuessDisplay = ({
|
|
| 15 |
currentWord,
|
| 16 |
onNextRound,
|
| 17 |
onPlayAgain,
|
|
|
|
|
|
|
| 18 |
}: GuessDisplayProps) => {
|
| 19 |
const isGuessCorrect = () => aiGuess.toLowerCase() === currentWord.toLowerCase();
|
|
|
|
| 20 |
|
| 21 |
return (
|
| 22 |
<motion.div
|
|
@@ -42,12 +54,45 @@ export const GuessDisplay = ({
|
|
| 42 |
)}
|
| 43 |
</p>
|
| 44 |
</div>
|
| 45 |
-
<
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
</motion.div>
|
| 52 |
);
|
| 53 |
};
|
|
|
|
| 1 |
import { Button } from "@/components/ui/button";
|
| 2 |
import { motion } from "framer-motion";
|
| 3 |
+
import {
|
| 4 |
+
Dialog,
|
| 5 |
+
DialogContent,
|
| 6 |
+
DialogTrigger,
|
| 7 |
+
} from "@/components/ui/dialog";
|
| 8 |
+
import { HighScoreBoard } from "@/components/HighScoreBoard";
|
| 9 |
+
import { useState } from "react";
|
| 10 |
|
| 11 |
interface GuessDisplayProps {
|
| 12 |
sentence: string[];
|
|
|
|
| 14 |
currentWord: string;
|
| 15 |
onNextRound: () => void;
|
| 16 |
onPlayAgain: () => void;
|
| 17 |
+
currentScore: number;
|
| 18 |
+
avgWordsPerRound: number;
|
| 19 |
}
|
| 20 |
|
| 21 |
export const GuessDisplay = ({
|
|
|
|
| 24 |
currentWord,
|
| 25 |
onNextRound,
|
| 26 |
onPlayAgain,
|
| 27 |
+
currentScore,
|
| 28 |
+
avgWordsPerRound,
|
| 29 |
}: GuessDisplayProps) => {
|
| 30 |
const isGuessCorrect = () => aiGuess.toLowerCase() === currentWord.toLowerCase();
|
| 31 |
+
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
| 32 |
|
| 33 |
return (
|
| 34 |
<motion.div
|
|
|
|
| 54 |
)}
|
| 55 |
</p>
|
| 56 |
</div>
|
| 57 |
+
<div className="flex flex-col gap-4">
|
| 58 |
+
{isGuessCorrect() ? (
|
| 59 |
+
<Button
|
| 60 |
+
onClick={onNextRound}
|
| 61 |
+
className="w-full bg-primary text-lg hover:bg-primary/90"
|
| 62 |
+
>
|
| 63 |
+
Next Round ⏎
|
| 64 |
+
</Button>
|
| 65 |
+
) : (
|
| 66 |
+
<>
|
| 67 |
+
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
| 68 |
+
<DialogTrigger asChild>
|
| 69 |
+
<Button
|
| 70 |
+
className="w-full bg-secondary text-lg hover:bg-secondary/90"
|
| 71 |
+
>
|
| 72 |
+
View High Scores
|
| 73 |
+
</Button>
|
| 74 |
+
</DialogTrigger>
|
| 75 |
+
<DialogContent className="max-w-md bg-white">
|
| 76 |
+
<HighScoreBoard
|
| 77 |
+
currentScore={currentScore}
|
| 78 |
+
avgWordsPerRound={avgWordsPerRound}
|
| 79 |
+
onClose={() => setIsDialogOpen(false)}
|
| 80 |
+
onPlayAgain={() => {
|
| 81 |
+
setIsDialogOpen(false);
|
| 82 |
+
onPlayAgain();
|
| 83 |
+
}}
|
| 84 |
+
/>
|
| 85 |
+
</DialogContent>
|
| 86 |
+
</Dialog>
|
| 87 |
+
<Button
|
| 88 |
+
onClick={onPlayAgain}
|
| 89 |
+
className="w-full bg-primary text-lg hover:bg-primary/90"
|
| 90 |
+
>
|
| 91 |
+
Play Again ⏎
|
| 92 |
+
</Button>
|
| 93 |
+
</>
|
| 94 |
+
)}
|
| 95 |
+
</div>
|
| 96 |
</motion.div>
|
| 97 |
);
|
| 98 |
};
|
src/components/game/SentenceBuilder.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import { Button } from "@/components/ui/button";
|
| 2 |
import { Input } from "@/components/ui/input";
|
| 3 |
import { motion } from "framer-motion";
|
| 4 |
-
import { KeyboardEvent, useRef, useEffect } from "react";
|
| 5 |
|
| 6 |
interface SentenceBuilderProps {
|
| 7 |
currentWord: string;
|
|
@@ -25,19 +25,53 @@ export const SentenceBuilder = ({
|
|
| 25 |
onMakeGuess,
|
| 26 |
}: SentenceBuilderProps) => {
|
| 27 |
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
useEffect(() => {
|
| 30 |
setTimeout(() => {
|
| 31 |
inputRef.current?.focus();
|
| 32 |
}, 100);
|
| 33 |
}, []);
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
| 36 |
if (e.shiftKey && e.key === 'Enter') {
|
| 37 |
e.preventDefault();
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
onMakeGuess();
|
| 40 |
-
}
|
|
|
|
|
|
|
| 41 |
}
|
| 42 |
};
|
| 43 |
|
|
@@ -53,8 +87,15 @@ export const SentenceBuilder = ({
|
|
| 53 |
<p className="mb-6 text-sm text-gray-600">
|
| 54 |
Take turns with AI to describe your word without using the word itself!
|
| 55 |
</p>
|
| 56 |
-
<div className="mb-4 rounded-lg bg-secondary/10
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
{currentWord}
|
| 59 |
</p>
|
| 60 |
</div>
|
|
@@ -89,9 +130,9 @@ export const SentenceBuilder = ({
|
|
| 89 |
</Button>
|
| 90 |
<Button
|
| 91 |
type="button"
|
| 92 |
-
onClick={
|
| 93 |
className="flex-1 bg-secondary text-lg hover:bg-secondary/90"
|
| 94 |
-
disabled={sentence.length
|
| 95 |
>
|
| 96 |
{isAiThinking ? "AI is thinking..." : "Make AI Guess ⇧⏎"}
|
| 97 |
</Button>
|
|
|
|
| 1 |
import { Button } from "@/components/ui/button";
|
| 2 |
import { Input } from "@/components/ui/input";
|
| 3 |
import { motion } from "framer-motion";
|
| 4 |
+
import { KeyboardEvent, useRef, useEffect, useState } from "react";
|
| 5 |
|
| 6 |
interface SentenceBuilderProps {
|
| 7 |
currentWord: string;
|
|
|
|
| 25 |
onMakeGuess,
|
| 26 |
}: SentenceBuilderProps) => {
|
| 27 |
const inputRef = useRef<HTMLInputElement>(null);
|
| 28 |
+
const [imageLoaded, setImageLoaded] = useState(false);
|
| 29 |
+
const imagePath = `/think_in_sync_assets/${currentWord.toLowerCase()}.jpg`;
|
| 30 |
|
| 31 |
+
useEffect(() => {
|
| 32 |
+
const img = new Image();
|
| 33 |
+
img.onload = () => setImageLoaded(true);
|
| 34 |
+
img.src = imagePath;
|
| 35 |
+
console.log("Attempting to load image:", imagePath);
|
| 36 |
+
}, [imagePath]);
|
| 37 |
+
|
| 38 |
+
// Focus input on initial render
|
| 39 |
useEffect(() => {
|
| 40 |
setTimeout(() => {
|
| 41 |
inputRef.current?.focus();
|
| 42 |
}, 100);
|
| 43 |
}, []);
|
| 44 |
|
| 45 |
+
// Focus input after AI finishes thinking
|
| 46 |
+
useEffect(() => {
|
| 47 |
+
if (!isAiThinking && sentence.length > 0 && sentence.length % 2 === 0) {
|
| 48 |
+
setTimeout(() => {
|
| 49 |
+
inputRef.current?.focus();
|
| 50 |
+
}, 100);
|
| 51 |
+
}
|
| 52 |
+
}, [isAiThinking, sentence.length]);
|
| 53 |
+
|
| 54 |
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
| 55 |
if (e.shiftKey && e.key === 'Enter') {
|
| 56 |
e.preventDefault();
|
| 57 |
+
handleMakeGuess();
|
| 58 |
+
}
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
const handleMakeGuess = () => {
|
| 62 |
+
if (playerInput.trim()) {
|
| 63 |
+
// Create a synthetic form event to add the current word
|
| 64 |
+
const syntheticEvent = {
|
| 65 |
+
preventDefault: () => {},
|
| 66 |
+
} as React.FormEvent;
|
| 67 |
+
onSubmitWord(syntheticEvent);
|
| 68 |
+
|
| 69 |
+
// Wait a brief moment for the state to update before making the guess
|
| 70 |
+
setTimeout(() => {
|
| 71 |
onMakeGuess();
|
| 72 |
+
}, 100);
|
| 73 |
+
} else {
|
| 74 |
+
onMakeGuess();
|
| 75 |
}
|
| 76 |
};
|
| 77 |
|
|
|
|
| 87 |
<p className="mb-6 text-sm text-gray-600">
|
| 88 |
Take turns with AI to describe your word without using the word itself!
|
| 89 |
</p>
|
| 90 |
+
<div className="mb-4 overflow-hidden rounded-lg bg-secondary/10">
|
| 91 |
+
{imageLoaded && (
|
| 92 |
+
<img
|
| 93 |
+
src={imagePath}
|
| 94 |
+
alt={currentWord}
|
| 95 |
+
className="mx-auto h-48 w-full object-cover"
|
| 96 |
+
/>
|
| 97 |
+
)}
|
| 98 |
+
<p className="p-4 text-2xl font-bold tracking-wider text-secondary">
|
| 99 |
{currentWord}
|
| 100 |
</p>
|
| 101 |
</div>
|
|
|
|
| 130 |
</Button>
|
| 131 |
<Button
|
| 132 |
type="button"
|
| 133 |
+
onClick={handleMakeGuess}
|
| 134 |
className="flex-1 bg-secondary text-lg hover:bg-secondary/90"
|
| 135 |
+
disabled={(!sentence.length && !playerInput.trim()) || isAiThinking}
|
| 136 |
>
|
| 137 |
{isAiThinking ? "AI is thinking..." : "Make AI Guess ⇧⏎"}
|
| 138 |
</Button>
|
src/components/game/WelcomeScreen.tsx
CHANGED
|
@@ -12,9 +12,11 @@ export const WelcomeScreen = ({ onStart }: WelcomeScreenProps) => {
|
|
| 12 |
animate={{ opacity: 1 }}
|
| 13 |
className="text-center"
|
| 14 |
>
|
| 15 |
-
<h1 className="mb-6 text-4xl font-bold text-gray-900">
|
| 16 |
<p className="mb-8 text-gray-600">
|
| 17 |
-
|
|
|
|
|
|
|
| 18 |
</p>
|
| 19 |
<Button
|
| 20 |
onClick={onStart}
|
|
@@ -24,4 +26,4 @@ export const WelcomeScreen = ({ onStart }: WelcomeScreenProps) => {
|
|
| 24 |
</Button>
|
| 25 |
</motion.div>
|
| 26 |
);
|
| 27 |
-
};
|
|
|
|
| 12 |
animate={{ opacity: 1 }}
|
| 13 |
className="text-center"
|
| 14 |
>
|
| 15 |
+
<h1 className="mb-6 text-4xl font-bold text-gray-900">Think in Sync</h1>
|
| 16 |
<p className="mb-8 text-gray-600">
|
| 17 |
+
This game is a variation of a classical childrens game.
|
| 18 |
+
You will be given a secret word. Your goal is to describe this secret word so that an AI can guess it.
|
| 19 |
+
However, you are only allowed to say one word at the time, taking turns with another AI.
|
| 20 |
</p>
|
| 21 |
<Button
|
| 22 |
onClick={onStart}
|
|
|
|
| 26 |
</Button>
|
| 27 |
</motion.div>
|
| 28 |
);
|
| 29 |
+
};
|
src/components/game/WordDisplay.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import { Button } from "@/components/ui/button";
|
| 2 |
import { motion } from "framer-motion";
|
|
|
|
| 3 |
|
| 4 |
interface WordDisplayProps {
|
| 5 |
currentWord: string;
|
|
@@ -8,6 +9,16 @@ interface WordDisplayProps {
|
|
| 8 |
}
|
| 9 |
|
| 10 |
export const WordDisplay = ({ currentWord, successfulRounds, onContinue }: WordDisplayProps) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
return (
|
| 12 |
<motion.div
|
| 13 |
initial={{ opacity: 0 }}
|
|
@@ -15,8 +26,15 @@ export const WordDisplay = ({ currentWord, successfulRounds, onContinue }: WordD
|
|
| 15 |
className="text-center"
|
| 16 |
>
|
| 17 |
<h2 className="mb-4 text-2xl font-semibold text-gray-900">Your Word</h2>
|
| 18 |
-
<div className="mb-4 rounded-lg bg-secondary/10
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
{currentWord}
|
| 21 |
</p>
|
| 22 |
</div>
|
|
|
|
| 1 |
import { Button } from "@/components/ui/button";
|
| 2 |
import { motion } from "framer-motion";
|
| 3 |
+
import { useEffect, useState } from "react";
|
| 4 |
|
| 5 |
interface WordDisplayProps {
|
| 6 |
currentWord: string;
|
|
|
|
| 9 |
}
|
| 10 |
|
| 11 |
export const WordDisplay = ({ currentWord, successfulRounds, onContinue }: WordDisplayProps) => {
|
| 12 |
+
const [imageLoaded, setImageLoaded] = useState(false);
|
| 13 |
+
const imagePath = `/think_in_sync_assets/${currentWord.toLowerCase()}.jpg`;
|
| 14 |
+
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
const img = new Image();
|
| 17 |
+
img.onload = () => setImageLoaded(true);
|
| 18 |
+
img.src = imagePath;
|
| 19 |
+
console.log("Attempting to load image:", imagePath);
|
| 20 |
+
}, [imagePath]);
|
| 21 |
+
|
| 22 |
return (
|
| 23 |
<motion.div
|
| 24 |
initial={{ opacity: 0 }}
|
|
|
|
| 26 |
className="text-center"
|
| 27 |
>
|
| 28 |
<h2 className="mb-4 text-2xl font-semibold text-gray-900">Your Word</h2>
|
| 29 |
+
<div className="mb-4 overflow-hidden rounded-lg bg-secondary/10">
|
| 30 |
+
{imageLoaded && (
|
| 31 |
+
<img
|
| 32 |
+
src={imagePath}
|
| 33 |
+
alt={currentWord}
|
| 34 |
+
className="mx-auto h-48 w-full object-cover"
|
| 35 |
+
/>
|
| 36 |
+
)}
|
| 37 |
+
<p className="p-6 text-4xl font-bold tracking-wider text-secondary">
|
| 38 |
{currentWord}
|
| 39 |
</p>
|
| 40 |
</div>
|
src/services/mistralService.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
| 1 |
import { supabase } from "@/integrations/supabase/client";
|
| 2 |
|
| 3 |
export const generateAIResponse = async (currentWord: string, currentSentence: string[]): Promise<string> => {
|
|
|
|
|
|
|
| 4 |
const { data, error } = await supabase.functions.invoke('generate-word', {
|
| 5 |
-
body: {
|
|
|
|
|
|
|
|
|
|
| 6 |
});
|
| 7 |
|
| 8 |
if (error) {
|
|
@@ -13,7 +18,8 @@ export const generateAIResponse = async (currentWord: string, currentSentence: s
|
|
| 13 |
throw error;
|
| 14 |
}
|
| 15 |
|
| 16 |
-
if (!data
|
|
|
|
| 17 |
throw new Error('No word generated');
|
| 18 |
}
|
| 19 |
|
|
@@ -22,6 +28,8 @@ export const generateAIResponse = async (currentWord: string, currentSentence: s
|
|
| 22 |
};
|
| 23 |
|
| 24 |
export const guessWord = async (sentence: string): Promise<string> => {
|
|
|
|
|
|
|
| 25 |
const { data, error } = await supabase.functions.invoke('guess-word', {
|
| 26 |
body: { sentence }
|
| 27 |
});
|
|
@@ -34,7 +42,8 @@ export const guessWord = async (sentence: string): Promise<string> => {
|
|
| 34 |
throw error;
|
| 35 |
}
|
| 36 |
|
| 37 |
-
if (!data
|
|
|
|
| 38 |
throw new Error('No guess generated');
|
| 39 |
}
|
| 40 |
|
|
|
|
| 1 |
import { supabase } from "@/integrations/supabase/client";
|
| 2 |
|
| 3 |
export const generateAIResponse = async (currentWord: string, currentSentence: string[]): Promise<string> => {
|
| 4 |
+
console.log('Calling generate-word function with:', { currentWord, currentSentence });
|
| 5 |
+
|
| 6 |
const { data, error } = await supabase.functions.invoke('generate-word', {
|
| 7 |
+
body: {
|
| 8 |
+
currentWord,
|
| 9 |
+
currentSentence: currentSentence.join(' ')
|
| 10 |
+
}
|
| 11 |
});
|
| 12 |
|
| 13 |
if (error) {
|
|
|
|
| 18 |
throw error;
|
| 19 |
}
|
| 20 |
|
| 21 |
+
if (!data?.word) {
|
| 22 |
+
console.error('No word generated in response:', data);
|
| 23 |
throw new Error('No word generated');
|
| 24 |
}
|
| 25 |
|
|
|
|
| 28 |
};
|
| 29 |
|
| 30 |
export const guessWord = async (sentence: string): Promise<string> => {
|
| 31 |
+
console.log('Calling guess-word function with sentence:', sentence);
|
| 32 |
+
|
| 33 |
const { data, error } = await supabase.functions.invoke('guess-word', {
|
| 34 |
body: { sentence }
|
| 35 |
});
|
|
|
|
| 42 |
throw error;
|
| 43 |
}
|
| 44 |
|
| 45 |
+
if (!data?.guess) {
|
| 46 |
+
console.error('No guess generated in response:', data);
|
| 47 |
throw new Error('No guess generated');
|
| 48 |
}
|
| 49 |
|
supabase/functions/generate-word/index.ts
CHANGED
|
@@ -15,6 +15,9 @@ serve(async (req) => {
|
|
| 15 |
const { currentWord, currentSentence } = await req.json();
|
| 16 |
console.log('Generating word for:', { currentWord, currentSentence });
|
| 17 |
|
|
|
|
|
|
|
|
|
|
| 18 |
const client = new Mistral({
|
| 19 |
apiKey: Deno.env.get('MISTRAL_API_KEY'),
|
| 20 |
});
|
|
@@ -33,7 +36,7 @@ serve(async (req) => {
|
|
| 33 |
role: "system",
|
| 34 |
content: `You are helping in a word game. The secret word is "${currentWord}".
|
| 35 |
Your task is to find a sentence to describe this word without using it directly.
|
| 36 |
-
Answer with a complete, grammatically correct sentence that starts with "${
|
| 37 |
Do not add quotes or backticks. Just answer with the sentence.`
|
| 38 |
}
|
| 39 |
],
|
|
@@ -45,9 +48,8 @@ serve(async (req) => {
|
|
| 45 |
console.log('AI full response:', aiResponse);
|
| 46 |
|
| 47 |
// Extract the new word by comparing with the existing sentence
|
| 48 |
-
const existingWords = currentSentence.join(' ');
|
| 49 |
const newWord = aiResponse
|
| 50 |
-
.slice(
|
| 51 |
.trim()
|
| 52 |
.split(' ')[0]
|
| 53 |
.replace(/[.,!?]$/, ''); // Remove any punctuation at the end
|
|
|
|
| 15 |
const { currentWord, currentSentence } = await req.json();
|
| 16 |
console.log('Generating word for:', { currentWord, currentSentence });
|
| 17 |
|
| 18 |
+
// currentSentence is already a string from the client
|
| 19 |
+
const existingSentence = currentSentence || '';
|
| 20 |
+
|
| 21 |
const client = new Mistral({
|
| 22 |
apiKey: Deno.env.get('MISTRAL_API_KEY'),
|
| 23 |
});
|
|
|
|
| 36 |
role: "system",
|
| 37 |
content: `You are helping in a word game. The secret word is "${currentWord}".
|
| 38 |
Your task is to find a sentence to describe this word without using it directly.
|
| 39 |
+
Answer with a complete, grammatically correct sentence that starts with "${existingSentence}".
|
| 40 |
Do not add quotes or backticks. Just answer with the sentence.`
|
| 41 |
}
|
| 42 |
],
|
|
|
|
| 48 |
console.log('AI full response:', aiResponse);
|
| 49 |
|
| 50 |
// Extract the new word by comparing with the existing sentence
|
|
|
|
| 51 |
const newWord = aiResponse
|
| 52 |
+
.slice(existingSentence.length)
|
| 53 |
.trim()
|
| 54 |
.split(' ')[0]
|
| 55 |
.replace(/[.,!?]$/, ''); // Remove any punctuation at the end
|