Triviaverse / src /components /games /MemoryGame.tsx
Simeon Garratt
feat: remove Supabase, bundle questions locally, use JSON DB + API
ad111f4
Raw
History Blame Contribute Delete
7.34 kB
"use client";
import { useState, useEffect, useCallback } from "react";
import { motion } from "framer-motion";
import {
Moon,
Sun,
Star,
Rocket,
Sparkles,
Orbit,
Satellite,
Eclipse,
Brain,
} from "lucide-react";
import { MiniGameResultsScreen } from "@/components/game/MiniGameResultsScreen";
import { MiniGameHeader } from "@/components/game/MiniGameHeader";
import { soundManager } from "@/lib/sound/manager";
import { fireCelebration } from "@/lib/confetti";
const CARD_ICONS = [Moon, Sun, Star, Rocket, Sparkles, Orbit, Satellite, Eclipse];
function nowMs(): number {
return Date.now();
}
type CardItem = {
id: number;
iconId: number;
Icon: React.ComponentType<{ size?: number; className?: string }>;
isFlipped: boolean;
isMatched: boolean;
};
function shuffleArray<T>(arr: T[]): T[] {
const shuffled = [...arr];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
function createCards(): CardItem[] {
const pairs: CardItem[] = [];
CARD_ICONS.forEach((Icon, index) => {
pairs.push({ id: index * 2, iconId: index, Icon, isFlipped: false, isMatched: false });
pairs.push({ id: index * 2 + 1, iconId: index, Icon, isFlipped: false, isMatched: false });
});
return shuffleArray(pairs);
}
export default function MemoryGame() {
const [cards, setCards] = useState<CardItem[]>([]);
const [moves, setMoves] = useState(0);
const [flippedIndices, setFlippedIndices] = useState<number[]>([]);
const [isLocked, setIsLocked] = useState(false);
const [isWon, setIsWon] = useState(false);
const [timeStart, setTimeStart] = useState<number | null>(null);
const [timeElapsed, setTimeElapsed] = useState(0);
const [isStarted, setIsStarted] = useState(false);
const initGame = useCallback(() => {
setCards(createCards());
setMoves(0);
setFlippedIndices([]);
setIsLocked(false);
setIsWon(false);
setTimeStart(null);
setTimeElapsed(0);
setIsStarted(false);
}, []);
// Initialize on mount
useEffect(() => {
const timer = setTimeout(() => {
initGame();
}, 0);
return () => clearTimeout(timer);
}, [initGame]);
// Timer
useEffect(() => {
if (!timeStart || isWon) return;
const interval = setInterval(() => {
setTimeElapsed(Math.floor((Date.now() - timeStart) / 1000));
}, 1000);
return () => clearInterval(interval);
}, [timeStart, isWon]);
const handleCardClick = (index: number) => {
if (isLocked || isWon) return;
const card = cards[index];
if (card.isFlipped || card.isMatched) return;
// Start timer on first card flip
if (!timeStart) {
setTimeStart(nowMs());
setIsStarted(true);
}
soundManager.play("click");
const newCards = [...cards];
newCards[index] = { ...newCards[index], isFlipped: true };
setCards(newCards);
const newFlipped = [...flippedIndices, index];
setFlippedIndices(newFlipped);
if (newFlipped.length === 2) {
setMoves((prev) => prev + 1);
const [first, second] = newFlipped;
if (newCards[first].iconId === newCards[second].iconId) {
// Match found
soundManager.play("correct");
const matched = newCards.map((c) =>
c.iconId === newCards[first].iconId ? { ...c, isMatched: true } : c,
);
setCards(matched);
setFlippedIndices([]);
// Check win condition
if (matched.every((c) => c.isMatched)) {
setIsWon(true);
fireCelebration();
soundManager.play("victory");
}
} else {
// No match -- flip back after a short delay
soundManager.play("wrong");
setIsLocked(true);
setTimeout(() => {
const flippedBack = newCards.map((c, i) =>
i === first || i === second ? { ...c, isFlipped: false } : c,
);
setCards(flippedBack);
setFlippedIndices([]);
setIsLocked(false);
}, 800);
}
}
};
const matchedCount = cards.filter((c) => c.isMatched).length / 2;
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
};
if (isWon) {
return (
<MiniGameResultsScreen
gameId="memory"
score={moves}
stats={[
{ label: "Moves", value: `${moves}` },
{ label: "Time", value: formatTime(timeElapsed) },
{ label: "Pairs Found", value: `${CARD_ICONS.length}` },
]}
onPlayAgain={initGame}
/>
);
}
return (
<div className="flex-1 flex flex-col w-full max-w-2xl mx-auto items-center gap-4 px-4">
<MiniGameHeader
title="Memory Match"
icon={Brain}
accentColor="cyan"
stats={[
{ label: "Moves", value: `${moves}` },
{ label: "Pairs", value: `${matchedCount} / ${CARD_ICONS.length}` },
{ label: "Time", value: formatTime(timeElapsed) },
]}
/>
{!isStarted && cards.length > 0 && (
<p className="text-slate-400 text-sm text-center">
Tap any card to begin. Match all pairs in as few moves as possible.
</p>
)}
<div className="grid grid-cols-4 gap-3 w-full max-w-md mt-4">
{cards.map((card, index) => {
const isRevealed = card.isFlipped || card.isMatched;
return (
<div
key={card.id}
onClick={() => handleCardClick(index)}
className="relative cursor-pointer aspect-square"
style={{ perspective: "1000px" }}
>
<motion.div
className="absolute inset-0"
style={{ transformStyle: "preserve-3d" }}
animate={{ rotateY: isRevealed ? 180 : 0 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
{/* Card Back */}
<div
className="absolute inset-0 rounded-xl bg-gradient-to-br from-indigo-600/80 to-purple-700/80 border border-white/20 flex items-center justify-center shadow-lg hover:border-cyan-400/50 transition-colors"
style={{ backfaceVisibility: "hidden" }}
>
<Star size={24} className="text-white/30" />
</div>
{/* Card Front */}
<div
className={`absolute inset-0 rounded-xl flex items-center justify-center border shadow-lg transition-colors ${
card.isMatched
? "bg-gradient-to-br from-green-600/80 to-emerald-700/80 border-green-400/50"
: "bg-gradient-to-br from-cyan-600/80 to-blue-700/80 border-cyan-400/50"
}`}
style={{
backfaceVisibility: "hidden",
transform: "rotateY(180deg)",
}}
>
<card.Icon
size={32}
className={card.isMatched ? "text-green-200" : "text-white"}
/>
</div>
</motion.div>
</div>
);
})}
</div>
</div>
);
}