import React, { useState, useEffect } from 'react'; import { RefreshCw, Trophy, Clock, Move, RotateCcw } from 'lucide-react'; // --- Constants & Helpers --- const SUITS = ['hearts', 'diamonds', 'clubs', 'spades']; const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']; const SUIT_SYMBOLS = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }; const SUIT_COLORS = { hearts: 'text-rose-500', diamonds: 'text-rose-500', clubs: 'text-slate-700', spades: 'text-slate-700' }; const CARD_VALUES = { 'A': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9, '10': 10, 'J': 11, 'Q': 12, 'K': 13 }; // --- Game Logic Helpers --- const createDeck = () => { const deck = []; SUITS.forEach(suit => { RANKS.forEach(rank => { deck.push({ id: `${rank}-${suit}`, rank, suit, value: CARD_VALUES[rank], color: (suit === 'hearts' || suit === 'diamonds') ? 'red' : 'black', faceUp: false, }); }); }); return deck; }; const shuffleDeck = (deck) => { const newDeck = [...deck]; for (let i = newDeck.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [newDeck[i], newDeck[j]] = [newDeck[j], newDeck[i]]; } return newDeck; }; export default function CozySolitaire() { // --- State --- const [gameState, setGameState] = useState('loading'); // loading, playing, won const [stock, setStock] = useState([]); const [waste, setWaste] = useState([]); const [foundations, setFoundations] = useState({ hearts: [], diamonds: [], clubs: [], spades: [] }); const [tableau, setTableau] = useState([[], [], [], [], [], [], []]); const [score, setScore] = useState(0); const [moves, setMoves] = useState(0); const [timer, setTimer] = useState(0); const [selectedCard, setSelectedCard] = useState(null); // { card, source: { type: 'tableau'|'waste', index: number } } // --- Initialization --- const startNewGame = () => { const deck = shuffleDeck(createDeck()); const newTableau = [[], [], [], [], [], [], []]; // Deal to tableau let cardIndex = 0; for (let i = 0; i < 7; i++) { for (let j = 0; j <= i; j++) { const card = deck[cardIndex++]; if (j === i) card.faceUp = true; // Top card face up newTableau[i].push(card); } } setStock(deck.slice(cardIndex)); setWaste([]); setFoundations({ hearts: [], diamonds: [], clubs: [], spades: [] }); setTableau(newTableau); setScore(0); setMoves(0); setTimer(0); setSelectedCard(null); setGameState('playing'); }; useEffect(() => { startNewGame(); }, []); useEffect(() => { let interval; if (gameState === 'playing') { interval = setInterval(() => setTimer(t => t + 1), 1000); } return () => clearInterval(interval); }, [gameState]); // --- Interaction Logic --- const handleStockClick = () => { setSelectedCard(null); if (stock.length === 0) { // Recycle waste to stock if (waste.length === 0) return; const newStock = [...waste].reverse().map(c => ({ ...c, faceUp: false })); setStock(newStock); setWaste([]); setScore(s => Math.max(0, s - 100)); // Penalty for recycling } else { // Draw card const newStock = [...stock]; const card = newStock.pop(); card.faceUp = true; setStock(newStock); setWaste([...waste, card]); } }; const attemptMove = (targetLocation, targetIndex = null) => { if (!selectedCard) return; const { card: movingCard, source } = selectedCard; let moveSuccessful = false; let points = 0; // Logic for moving TO Foundation if (targetLocation === 'foundation') { const targetSuit = targetIndex; // In this case index is the suit name const targetPile = foundations[targetSuit]; const topCard = targetPile[targetPile.length - 1]; // Rule: Must be same suit. If empty, must be Ace. Else, must be +1 value. if (movingCard.suit === targetSuit) { if ((!topCard && movingCard.value === 1) || (topCard && movingCard.value === topCard.value + 1)) { // Execute Move const newFoundations = { ...foundations }; newFoundations[targetSuit] = [...targetPile, movingCard]; setFoundations(newFoundations); moveSuccessful = true; points = 10; } } } // Logic for moving TO Tableau if (targetLocation === 'tableau') { const targetPile = tableau[targetIndex]; const topCard = targetPile[targetPile.length - 1]; // If moving a stack from tableau, we need the whole stack let cardsToMove = [movingCard]; if (source.type === 'tableau') { const sourcePile = tableau[source.index]; const splitIndex = sourcePile.findIndex(c => c.id === movingCard.id); cardsToMove = sourcePile.slice(splitIndex); } else { // Can only move single card from waste/foundation cardsToMove = [movingCard]; } // Rule: Alternating colors, -1 value. King on empty. const headOfMovingStack = cardsToMove[0]; const canPlace = (!topCard && headOfMovingStack.value === 13) || // King on empty (topCard && topCard.color !== headOfMovingStack.color && topCard.value === headOfMovingStack.value + 1); if (canPlace) { const newTableau = [...tableau]; newTableau[targetIndex] = [...targetPile, ...cardsToMove]; setTableau(newTableau); moveSuccessful = true; points = 5; } } if (moveSuccessful) { // Remove from source if (source.type === 'waste') { setWaste(waste.slice(0, -1)); } else if (source.type === 'tableau') { const newTableau = [...tableau]; // We might have already modified this in the target logic if target was also tableau, but let's re-copy to be safe or use functional updates carefully. // Actually, let's just re-derive the source removal to be clean. // NOTE: If source and target are both tableau, we need to be careful not to overwrite the target update. // The safest way is to copy the current state (which might include the target update). setTableau(prevTableau => { const tempTableau = [...prevTableau]; const sourcePile = tempTableau[source.index]; const cutIndex = sourcePile.findIndex(c => c.id === movingCard.id); const remaining = sourcePile.slice(0, cutIndex); // Flip new top card if it exists and was face down if (remaining.length > 0 && !remaining[remaining.length-1].faceUp) { remaining[remaining.length-1].faceUp = true; setScore(s => s + 5); } tempTableau[source.index] = remaining; return tempTableau; }); } else if (source.type === 'foundation') { // Rare case: moving back from foundation setFoundations(prev => ({ ...prev, [source.index]: prev[source.index].slice(0, -1) })); points = -10; // Penalty for moving back } setScore(s => s + points); setMoves(m => m + 1); setSelectedCard(null); checkWinCondition(); } else { // Invalid move sound or feedback could go here setSelectedCard(null); } }; const handleCardClick = (card, source) => { // If clicking a face down card, do nothing if (!card.faceUp) return; // If clicking the already selected card, deselect if (selectedCard && selectedCard.card.id === card.id) { setSelectedCard(null); return; } // If we have a selection, try to move to this card's pile if (selectedCard) { // We are trying to move selectedCard ONTO the clicked card (destination) if (source.type === 'tableau') { attemptMove('tableau', source.index); } else if (source.type === 'foundation') { attemptMove('foundation', source.index); // source.index is suit name here } } else { // Select this card setSelectedCard({ card, source }); } }; const handleEmptySpotClick = (type, index) => { if (selectedCard) { attemptMove(type, index); } }; const autoStack = (card, source) => { // Double click shortcut to move to foundation if (!card.faceUp) return; const suit = card.suit; const foundationPile = foundations[suit]; const topFoundation = foundationPile[foundationPile.length - 1]; let canMove = false; if (!topFoundation && card.value === 1) canMove = true; if (topFoundation && card.value === topFoundation.value + 1) canMove = true; if (canMove) { // Manually trigger the move logic // We construct a fake selection then call attemptMove // But since attemptMove relies on state selected card, we can't easily sync reusing that function // So we duplicate the simple move logic here for the shortcut // 1. Add to foundation setFoundations(prev => ({ ...prev, [suit]: [...prev[suit], card] })); // 2. Remove from source if (source.type === 'waste') { setWaste(prev => prev.slice(0, -1)); } else if (source.type === 'tableau') { setTableau(prev => { const newTab = [...prev]; const pile = newTab[source.index]; pile.pop(); if (pile.length > 0 && !pile[pile.length-1].faceUp) { pile[pile.length-1].faceUp = true; setScore(s => s + 5); } return newTab; }); } setScore(s => s + 10); setMoves(m => m + 1); checkWinCondition(); } }; const checkWinCondition = () => { // A bit simplistic, but usually correct: if all cards in foundations, win. // Or strictly: sum of foundation lengths = 52 // Since state updates are async, this might lag by one move, but it catches up. setTimeout(() => { let count = 0; Object.values(foundations).forEach(pile => count += pile.length); // We actually check the *next* state implicitly, but 52 is hard max if (count >= 51) { // 51 because the 52nd card was just added setGameState('won'); } }, 100); }; // --- Render Helpers --- const formatTime = (seconds) => { const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}:${s.toString().padStart(2, '0')}`; }; const Card = ({ card, source, stackIndex, isStacked }) => { const isSelected = selectedCard?.card.id === card.id; // Style for cards in a tableau stack const stackStyle = isStacked ? { marginTop: `${stackIndex * -140}px`, // Overlap amount position: 'relative', top: `${stackIndex * 30}px`, // Visual offset zIndex: stackIndex } : {}; return (
Excellent card sorting skills.