Skip to content

Commit 8b9cdcc

Browse files
committed
feat: add sounds and confetti for more snazziness
add: new levels chore: clean-up the ai-generated code a bit
1 parent af0c503 commit 8b9cdcc

9 files changed

+776
-444
lines changed

bun.lockb

796 Bytes
Binary file not shown.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"clsx": "^2.1.1",
2424
"lucide-react": "^0.400.0",
2525
"react": "^18.3.1",
26+
"react-confetti": "^6.1.0",
2627
"react-dom": "^18.3.1",
2728
"tailwind-merge": "^2.3.0",
2829
"tailwindcss-animate": "^1.0.7"

src/App.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import OpenEHRQuest from './openEHRQuest';
1+
import OpenEHRQuest from '@/components/openEHRQuest';
22

33
function App() {
44
return (
+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import React, { useState, useEffect } from 'react';
2+
import Confetti from 'react-confetti';
3+
4+
const ConfettiCelebration: React.FC = () => {
5+
const [windowDimension, setWindowDimension] = useState({
6+
width: window.innerWidth,
7+
height: window.innerHeight,
8+
});
9+
10+
useEffect(() => {
11+
const handleResize = () => {
12+
setWindowDimension({
13+
width: window.innerWidth,
14+
height: window.innerHeight,
15+
});
16+
};
17+
18+
window.addEventListener('resize', handleResize);
19+
20+
return () => {
21+
window.removeEventListener('resize', handleResize);
22+
};
23+
}, []);
24+
25+
return (
26+
<Confetti
27+
width={windowDimension.width}
28+
height={windowDimension.height}
29+
recycle={false}
30+
numberOfPieces={500}
31+
/>
32+
);
33+
};
34+
35+
export default ConfettiCelebration;

src/components/HealthBar.tsx

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import React from 'react';
2+
3+
interface HealthBarProps {
4+
health: number;
5+
}
6+
7+
const HealthBar: React.FC<HealthBarProps> = ({ health }) => {
8+
return (
9+
<div className="w-full h-4 bg-gray-200 rounded-full overflow-hidden">
10+
<div
11+
className="h-full bg-green-500 transition-all duration-1000 ease-in-out"
12+
style={{ width: `${health}%` }}
13+
>
14+
<div className="w-full h-full animate-pulse bg-green-400 opacity-75"></div>
15+
</div>
16+
</div>
17+
);
18+
};
19+
20+
export default HealthBar;

src/components/openEHRQuest.tsx

+256
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import React, { useState, useEffect } from 'react';
2+
import { Terminal, Brain, Shield, Zap, VolumeX, Volume2 } from 'lucide-react';
3+
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
4+
import { Button } from '@/components/ui/button';
5+
import {
6+
Card,
7+
CardHeader,
8+
CardContent,
9+
CardFooter,
10+
} from '@/components/ui/card';
11+
import { Progress } from '@/components/ui/progress';
12+
import { Badge } from '@/components/ui/badge';
13+
import {
14+
Tooltip,
15+
TooltipContent,
16+
TooltipProvider,
17+
TooltipTrigger,
18+
} from '@/components/ui/tooltip';
19+
import HealthBar from '@/components/HealthBar';
20+
import useSoundEffects from '@/hooks/useSoundEffects.ts';
21+
import ConfettiCelebration from '@/components/ConfettiCelebration';
22+
import { levels as levelsData } from '@/lib/levels.ts';
23+
24+
interface GameState {
25+
currentLevel: number;
26+
score: number;
27+
playerHealth: number;
28+
badges: string[];
29+
hintUsed: boolean;
30+
}
31+
32+
const OpenEHRQuest: React.FC = () => {
33+
const [gameState, setGameState] = useState<GameState>({
34+
currentLevel: 0,
35+
score: 0,
36+
playerHealth: 100,
37+
badges: [],
38+
hintUsed: false,
39+
});
40+
const [soundEnabled, setSoundEnabled] = useState(true);
41+
const { playCorrectSound, playWrongSound, playCompletionSound } =
42+
useSoundEffects();
43+
44+
const levels = levelsData;
45+
46+
useEffect(() => {
47+
if (
48+
gameState.currentLevel >= levels.length &&
49+
levels.length > 0 &&
50+
soundEnabled
51+
) {
52+
playCompletionSound();
53+
}
54+
}, [
55+
gameState.currentLevel,
56+
levels.length,
57+
soundEnabled,
58+
playCompletionSound,
59+
]);
60+
61+
const handleAnswer = (selectedIndex: number) => {
62+
const currentLevel = levels[gameState.currentLevel];
63+
if (selectedIndex === currentLevel.correctAnswer) {
64+
if (soundEnabled) playCorrectSound();
65+
setGameState((prevState) => ({
66+
...prevState,
67+
score: prevState.score + (prevState.hintUsed ? 50 : 100),
68+
currentLevel: prevState.currentLevel + 1,
69+
hintUsed: false,
70+
badges: [
71+
...prevState.badges,
72+
`Level ${prevState.currentLevel + 1} Master`,
73+
],
74+
}));
75+
} else {
76+
if (soundEnabled) playWrongSound();
77+
setGameState((prevState) => ({
78+
...prevState,
79+
playerHealth: Math.max(0, prevState.playerHealth - 20),
80+
hintUsed: false,
81+
}));
82+
}
83+
};
84+
85+
const useHint = () => {
86+
setGameState((prevState) => ({ ...prevState, hintUsed: true }));
87+
};
88+
89+
const resetGame = () => {
90+
setGameState({
91+
currentLevel: 0,
92+
score: 0,
93+
playerHealth: 100,
94+
badges: [],
95+
hintUsed: false,
96+
});
97+
};
98+
99+
const toggleSound = () => {
100+
setSoundEnabled(!soundEnabled);
101+
};
102+
103+
if (gameState.playerHealth <= 0) {
104+
return (
105+
<div className="flex flex-col items-center justify-center h-screen bg-gray-100">
106+
<Card className="w-full max-w-md">
107+
<CardHeader>
108+
<h1 className="text-2xl font-bold text-center">Game Over</h1>
109+
</CardHeader>
110+
<CardContent>
111+
<p className="text-center mb-4">
112+
Your OpenEHR journey has come to an end.
113+
</p>
114+
<p className="text-center mb-4">Final Score: {gameState.score}</p>
115+
</CardContent>
116+
<CardFooter>
117+
<Button onClick={resetGame} className="w-full">
118+
Try Again
119+
</Button>
120+
</CardFooter>
121+
</Card>
122+
</div>
123+
);
124+
}
125+
126+
if (gameState.currentLevel >= levels.length && levels.length > 0) {
127+
return (
128+
<div className="flex flex-col items-center justify-center h-screen bg-gray-100">
129+
<ConfettiCelebration />
130+
<Card className="w-full max-w-md">
131+
<CardHeader>
132+
<h1 className="text-2xl font-bold text-center">
133+
🎉 Congratulations! 🎉
134+
</h1>
135+
</CardHeader>
136+
<CardContent>
137+
<p className="text-center mb-4">
138+
You've become an OpenEHR Integration Master!
139+
</p>
140+
<p className="text-center mb-4">Final Score: {gameState.score}</p>
141+
<div className="flex flex-wrap justify-center gap-2 mt-4">
142+
{gameState.badges.map((badge, index) => (
143+
<Badge key={index} variant="secondary">
144+
{badge}
145+
</Badge>
146+
))}
147+
</div>
148+
</CardContent>
149+
<CardFooter>
150+
<Button onClick={resetGame} className="w-full">
151+
Play Again
152+
</Button>
153+
</CardFooter>
154+
</Card>
155+
</div>
156+
);
157+
}
158+
const currentLevel = levels[gameState.currentLevel];
159+
160+
return (
161+
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-100 p-4">
162+
<Card className="w-full max-w-4xl">
163+
<CardHeader className="flex flex-row justify-between items-center">
164+
<h1 className="text-2xl font-bold">{currentLevel.title}</h1>
165+
<Button onClick={toggleSound} variant="ghost" size="icon">
166+
{soundEnabled ? (
167+
<Volume2 className="h-4 w-4" />
168+
) : (
169+
<VolumeX className="h-4 w-4" />
170+
)}
171+
</Button>
172+
</CardHeader>
173+
<CardContent>
174+
<p className="text-center text-gray-600 mb-4">
175+
Level {gameState.currentLevel + 1} of {levels.length}
176+
</p>
177+
<Progress
178+
value={((gameState.currentLevel + 1) / levels.length) * 100}
179+
className="w-full mb-4"
180+
/>
181+
<Alert className="my-4">
182+
<Terminal className="h-4 w-4" />
183+
<AlertTitle>Mission Briefing</AlertTitle>
184+
<AlertDescription>{currentLevel.description}</AlertDescription>
185+
</Alert>
186+
<div className="my-4">
187+
<h2 className="text-xl font-semibold mb-2">Challenge:</h2>
188+
<pre className="bg-gray-800 text-white p-4 rounded-md overflow-x-auto whitespace-pre-wrap break-words">
189+
<code>{currentLevel.challenge}</code>
190+
</pre>
191+
</div>
192+
<div className="space-y-2">
193+
{currentLevel.options.map((option, index) => (
194+
<Button
195+
key={index}
196+
onClick={() => handleAnswer(index)}
197+
className="w-full justify-start text-left whitespace-normal h-auto"
198+
variant="outline"
199+
>
200+
{option}
201+
</Button>
202+
))}
203+
</div>
204+
<div className="mt-4 space-y-4">
205+
<div className="flex justify-between items-center">
206+
<TooltipProvider>
207+
<Tooltip>
208+
<TooltipTrigger asChild>
209+
<Button onClick={useHint} disabled={gameState.hintUsed}>
210+
<Zap className="mr-2 h-4 w-4" /> Use Hint
211+
</Button>
212+
</TooltipTrigger>
213+
<TooltipContent>
214+
<p>
215+
{gameState.hintUsed
216+
? 'Hint already used'
217+
: 'Click to reveal a hint (reduces points for this level)'}
218+
</p>
219+
</TooltipContent>
220+
</Tooltip>
221+
</TooltipProvider>
222+
<div className="flex gap-2">
223+
{gameState.badges.slice(-3).map((badge, index) => (
224+
<Badge key={index} variant="secondary">
225+
{badge}
226+
</Badge>
227+
))}
228+
</div>
229+
</div>
230+
{gameState.hintUsed && (
231+
<Alert>
232+
<AlertTitle>Hint</AlertTitle>
233+
<AlertDescription>{currentLevel.hint}</AlertDescription>
234+
</Alert>
235+
)}
236+
</div>
237+
</CardContent>
238+
<CardFooter className="flex-col items-start">
239+
<div className="flex justify-between w-full mb-2">
240+
<div className="flex items-center">
241+
<Brain className="mr-2" />
242+
<span>Score: {gameState.score}</span>
243+
</div>
244+
<div className="flex items-center">
245+
<Shield className="mr-2" />
246+
<span>Health: {gameState.playerHealth}%</span>
247+
</div>
248+
</div>
249+
<HealthBar health={gameState.playerHealth} />
250+
</CardFooter>
251+
</Card>
252+
</div>
253+
);
254+
};
255+
256+
export default OpenEHRQuest;

src/hooks/useSoundEffects.ts

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useCallback } from 'react';
2+
3+
const useSoundEffects = () => {
4+
const playSound = useCallback((frequency: number, duration: number) => {
5+
const audioContext = new (window.AudioContext ||
6+
(window as any).webkitAudioContext)();
7+
const oscillator = audioContext.createOscillator();
8+
const gainNode = audioContext.createGain();
9+
10+
oscillator.connect(gainNode);
11+
gainNode.connect(audioContext.destination);
12+
13+
oscillator.frequency.value = frequency;
14+
oscillator.type = 'sine';
15+
16+
gainNode.gain.setValueAtTime(0, audioContext.currentTime);
17+
gainNode.gain.linearRampToValueAtTime(1, audioContext.currentTime + 0.01);
18+
gainNode.gain.linearRampToValueAtTime(
19+
0,
20+
audioContext.currentTime + duration
21+
);
22+
23+
oscillator.start(audioContext.currentTime);
24+
oscillator.stop(audioContext.currentTime + duration);
25+
}, []);
26+
27+
const playCorrectSound = useCallback(() => playSound(800, 0.1), [playSound]);
28+
const playWrongSound = useCallback(() => playSound(300, 0.2), [playSound]);
29+
const playCompletionSound = useCallback(() => {
30+
playSound(523.25, 0.1); // C5
31+
setTimeout(() => playSound(659.25, 0.1), 100); // E5
32+
setTimeout(() => playSound(783.99, 0.2), 200); // G5
33+
}, [playSound]);
34+
35+
return { playCorrectSound, playWrongSound, playCompletionSound };
36+
};
37+
38+
export default useSoundEffects;

0 commit comments

Comments
 (0)