Skip to content

Commit

Permalink
feat: add sounds and confetti for more snazziness
Browse files Browse the repository at this point in the history
add: new levels
chore: clean-up the ai-generated code a bit
  • Loading branch information
gmickel committed Jul 3, 2024
1 parent af0c503 commit 8b9cdcc
Show file tree
Hide file tree
Showing 9 changed files with 776 additions and 444 deletions.
Binary file modified bun.lockb
Binary file not shown.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"clsx": "^2.1.1",
"lucide-react": "^0.400.0",
"react": "^18.3.1",
"react-confetti": "^6.1.0",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7"
Expand Down
2 changes: 1 addition & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import OpenEHRQuest from './openEHRQuest';
import OpenEHRQuest from '@/components/openEHRQuest';

function App() {
return (
Expand Down
35 changes: 35 additions & 0 deletions src/components/ConfettiCelebration.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React, { useState, useEffect } from 'react';
import Confetti from 'react-confetti';

const ConfettiCelebration: React.FC = () => {
const [windowDimension, setWindowDimension] = useState({
width: window.innerWidth,
height: window.innerHeight,
});

useEffect(() => {
const handleResize = () => {
setWindowDimension({
width: window.innerWidth,
height: window.innerHeight,
});
};

window.addEventListener('resize', handleResize);

return () => {
window.removeEventListener('resize', handleResize);
};
}, []);

return (
<Confetti
width={windowDimension.width}
height={windowDimension.height}
recycle={false}
numberOfPieces={500}
/>
);
};

export default ConfettiCelebration;
20 changes: 20 additions & 0 deletions src/components/HealthBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react';

interface HealthBarProps {
health: number;
}

const HealthBar: React.FC<HealthBarProps> = ({ health }) => {
return (
<div className="w-full h-4 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 transition-all duration-1000 ease-in-out"
style={{ width: `${health}%` }}
>
<div className="w-full h-full animate-pulse bg-green-400 opacity-75"></div>
</div>
</div>
);
};

export default HealthBar;
256 changes: 256 additions & 0 deletions src/components/openEHRQuest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
import React, { useState, useEffect } from 'react';
import { Terminal, Brain, Shield, Zap, VolumeX, Volume2 } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import {
Card,
CardHeader,
CardContent,
CardFooter,
} from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import HealthBar from '@/components/HealthBar';
import useSoundEffects from '@/hooks/useSoundEffects.ts';
import ConfettiCelebration from '@/components/ConfettiCelebration';
import { levels as levelsData } from '@/lib/levels.ts';

interface GameState {
currentLevel: number;
score: number;
playerHealth: number;
badges: string[];
hintUsed: boolean;
}

const OpenEHRQuest: React.FC = () => {
const [gameState, setGameState] = useState<GameState>({
currentLevel: 0,
score: 0,
playerHealth: 100,
badges: [],
hintUsed: false,
});
const [soundEnabled, setSoundEnabled] = useState(true);
const { playCorrectSound, playWrongSound, playCompletionSound } =
useSoundEffects();

const levels = levelsData;

useEffect(() => {
if (
gameState.currentLevel >= levels.length &&
levels.length > 0 &&
soundEnabled
) {
playCompletionSound();
}
}, [
gameState.currentLevel,
levels.length,
soundEnabled,
playCompletionSound,
]);

const handleAnswer = (selectedIndex: number) => {
const currentLevel = levels[gameState.currentLevel];
if (selectedIndex === currentLevel.correctAnswer) {
if (soundEnabled) playCorrectSound();
setGameState((prevState) => ({
...prevState,
score: prevState.score + (prevState.hintUsed ? 50 : 100),
currentLevel: prevState.currentLevel + 1,
hintUsed: false,
badges: [
...prevState.badges,
`Level ${prevState.currentLevel + 1} Master`,
],
}));
} else {
if (soundEnabled) playWrongSound();
setGameState((prevState) => ({
...prevState,
playerHealth: Math.max(0, prevState.playerHealth - 20),
hintUsed: false,
}));
}
};

const useHint = () => {
setGameState((prevState) => ({ ...prevState, hintUsed: true }));
};

const resetGame = () => {
setGameState({
currentLevel: 0,
score: 0,
playerHealth: 100,
badges: [],
hintUsed: false,
});
};

const toggleSound = () => {
setSoundEnabled(!soundEnabled);
};

if (gameState.playerHealth <= 0) {
return (
<div className="flex flex-col items-center justify-center h-screen bg-gray-100">
<Card className="w-full max-w-md">
<CardHeader>
<h1 className="text-2xl font-bold text-center">Game Over</h1>
</CardHeader>
<CardContent>
<p className="text-center mb-4">
Your OpenEHR journey has come to an end.
</p>
<p className="text-center mb-4">Final Score: {gameState.score}</p>
</CardContent>
<CardFooter>
<Button onClick={resetGame} className="w-full">
Try Again
</Button>
</CardFooter>
</Card>
</div>
);
}

if (gameState.currentLevel >= levels.length && levels.length > 0) {
return (
<div className="flex flex-col items-center justify-center h-screen bg-gray-100">
<ConfettiCelebration />
<Card className="w-full max-w-md">
<CardHeader>
<h1 className="text-2xl font-bold text-center">
🎉 Congratulations! 🎉
</h1>
</CardHeader>
<CardContent>
<p className="text-center mb-4">
You've become an OpenEHR Integration Master!
</p>
<p className="text-center mb-4">Final Score: {gameState.score}</p>
<div className="flex flex-wrap justify-center gap-2 mt-4">
{gameState.badges.map((badge, index) => (
<Badge key={index} variant="secondary">
{badge}
</Badge>
))}
</div>
</CardContent>
<CardFooter>
<Button onClick={resetGame} className="w-full">
Play Again
</Button>
</CardFooter>
</Card>
</div>
);
}
const currentLevel = levels[gameState.currentLevel];

return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-100 p-4">
<Card className="w-full max-w-4xl">
<CardHeader className="flex flex-row justify-between items-center">
<h1 className="text-2xl font-bold">{currentLevel.title}</h1>
<Button onClick={toggleSound} variant="ghost" size="icon">
{soundEnabled ? (
<Volume2 className="h-4 w-4" />
) : (
<VolumeX className="h-4 w-4" />
)}
</Button>
</CardHeader>
<CardContent>
<p className="text-center text-gray-600 mb-4">
Level {gameState.currentLevel + 1} of {levels.length}
</p>
<Progress
value={((gameState.currentLevel + 1) / levels.length) * 100}
className="w-full mb-4"
/>
<Alert className="my-4">
<Terminal className="h-4 w-4" />
<AlertTitle>Mission Briefing</AlertTitle>
<AlertDescription>{currentLevel.description}</AlertDescription>
</Alert>
<div className="my-4">
<h2 className="text-xl font-semibold mb-2">Challenge:</h2>
<pre className="bg-gray-800 text-white p-4 rounded-md overflow-x-auto whitespace-pre-wrap break-words">
<code>{currentLevel.challenge}</code>
</pre>
</div>
<div className="space-y-2">
{currentLevel.options.map((option, index) => (
<Button
key={index}
onClick={() => handleAnswer(index)}
className="w-full justify-start text-left whitespace-normal h-auto"
variant="outline"
>
{option}
</Button>
))}
</div>
<div className="mt-4 space-y-4">
<div className="flex justify-between items-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={useHint} disabled={gameState.hintUsed}>
<Zap className="mr-2 h-4 w-4" /> Use Hint
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{gameState.hintUsed
? 'Hint already used'
: 'Click to reveal a hint (reduces points for this level)'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex gap-2">
{gameState.badges.slice(-3).map((badge, index) => (
<Badge key={index} variant="secondary">
{badge}
</Badge>
))}
</div>
</div>
{gameState.hintUsed && (
<Alert>
<AlertTitle>Hint</AlertTitle>
<AlertDescription>{currentLevel.hint}</AlertDescription>
</Alert>
)}
</div>
</CardContent>
<CardFooter className="flex-col items-start">
<div className="flex justify-between w-full mb-2">
<div className="flex items-center">
<Brain className="mr-2" />
<span>Score: {gameState.score}</span>
</div>
<div className="flex items-center">
<Shield className="mr-2" />
<span>Health: {gameState.playerHealth}%</span>
</div>
</div>
<HealthBar health={gameState.playerHealth} />
</CardFooter>
</Card>
</div>
);
};

export default OpenEHRQuest;
38 changes: 38 additions & 0 deletions src/hooks/useSoundEffects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useCallback } from 'react';

const useSoundEffects = () => {
const playSound = useCallback((frequency: number, duration: number) => {
const audioContext = new (window.AudioContext ||
(window as any).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();

oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);

oscillator.frequency.value = frequency;
oscillator.type = 'sine';

gainNode.gain.setValueAtTime(0, audioContext.currentTime);
gainNode.gain.linearRampToValueAtTime(1, audioContext.currentTime + 0.01);
gainNode.gain.linearRampToValueAtTime(
0,
audioContext.currentTime + duration
);

oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + duration);
}, []);

const playCorrectSound = useCallback(() => playSound(800, 0.1), [playSound]);
const playWrongSound = useCallback(() => playSound(300, 0.2), [playSound]);
const playCompletionSound = useCallback(() => {
playSound(523.25, 0.1); // C5
setTimeout(() => playSound(659.25, 0.1), 100); // E5
setTimeout(() => playSound(783.99, 0.2), 200); // G5
}, [playSound]);

return { playCorrectSound, playWrongSound, playCompletionSound };
};

export default useSoundEffects;
Loading

0 comments on commit 8b9cdcc

Please sign in to comment.