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
|