flip-grid/App.tsx

179 lines
5.6 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react';
import confetti from 'canvas-confetti';
import { GameStatus, GridState } from './types';
import {
createEmptyBoard,
generatePuzzle,
toggleCell,
checkWin
} from './utils/gameLogic';
import {
COLOR_ON,
COLOR_OFF,
COLOR_ON_HOVER,
COLOR_OFF_HOVER,
DEFAULT_GRID_SIZE,
SCRAMBLE_MOVES
} from './constants';
import GameControls from './components/GameControls';
import WinMessage from './components/WinMessage';
const App: React.FC = () => {
const [gridSize, setGridSize] = useState<number>(DEFAULT_GRID_SIZE);
const [grid, setGrid] = useState<GridState>([]);
const [initialGrid, setInitialGrid] = useState<GridState>([]); // To support reset
const [status, setStatus] = useState<GameStatus>(GameStatus.PLAYING);
const [moves, setMoves] = useState<number>(0);
const [isInitializing, setIsInitializing] = useState<boolean>(true);
// Initialize game
const startNewGame = useCallback((size: number = gridSize) => {
setIsInitializing(true);
// Use a timeout to allow UI to show loading state if needed, but mostly to ensure smooth transition
setTimeout(() => {
// Scramble more for larger grids
const scrambleCount = size * 3;
const newBoard = generatePuzzle(size, scrambleCount);
setGrid(newBoard);
setInitialGrid(newBoard); // Save for reset
setStatus(GameStatus.PLAYING);
setMoves(0);
setIsInitializing(false);
}, 100);
}, [gridSize]);
// Handle size change
const handleSizeChange = (size: number) => {
setGridSize(size);
startNewGame(size);
};
// Reset current board to start
const resetGame = () => {
if (initialGrid.length > 0) {
setGrid(initialGrid);
setMoves(0);
setStatus(GameStatus.PLAYING);
}
};
// Initial load
useEffect(() => {
startNewGame();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Handle cell click
const handleCellClick = (r: number, c: number) => {
if (status === GameStatus.WON) return;
const newGrid = toggleCell(grid, r, c);
setGrid(newGrid);
setMoves(prev => prev + 1);
if (checkWin(newGrid)) {
setStatus(GameStatus.WON);
triggerWinConfetti();
}
};
const triggerWinConfetti = () => {
const duration = 3000;
const end = Date.now() + duration;
const frame = () => {
confetti({
particleCount: 3,
angle: 60,
spread: 55,
origin: { x: 0 },
colors: ['#6366f1', '#ec4899', '#8b5cf6']
});
confetti({
particleCount: 3,
angle: 120,
spread: 55,
origin: { x: 1 },
colors: ['#6366f1', '#ec4899', '#8b5cf6']
});
if (Date.now() < end) {
requestAnimationFrame(frame);
}
};
frame();
};
return (
<div className="min-h-screen bg-slate-50 text-slate-900 font-sans selection:bg-indigo-100">
<header className="py-8 text-center bg-white shadow-sm border-b border-slate-100">
<h1 className="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-indigo-600 to-purple-600 tracking-tight">
Flip Grid
</h1>
<p className="mt-2 text-slate-500 font-medium">Turn all lights to the same color.</p>
</header>
<main className="container mx-auto px-4 py-8 flex flex-col items-center">
<GameControls
onNewGame={() => startNewGame()}
onReset={resetGame}
moves={moves}
gridSize={gridSize}
setGridSize={handleSizeChange}
/>
<div className="relative p-6 bg-white rounded-2xl shadow-xl border border-slate-200">
{/* Grid Container */}
<div
className="grid gap-1.5 sm:gap-2 mx-auto transition-all duration-300 ease-in-out"
style={{
gridTemplateColumns: `repeat(${gridSize}, minmax(0, 1fr))`,
width: 'min(90vw, 500px)', // Responsive width constrained by max size
aspectRatio: '1/1'
}}
>
{grid.map((row, r) => (
row.map((active, c) => (
<button
key={`${r}-${c}`}
onClick={() => handleCellClick(r, c)}
disabled={status === GameStatus.WON}
className={`
relative w-full h-full rounded-md sm:rounded-lg shadow-sm transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:ring-offset-1 focus:z-10
${active ? COLOR_ON : COLOR_OFF}
${active ? COLOR_ON_HOVER : COLOR_OFF_HOVER}
active:scale-90
${isInitializing ? 'opacity-0 scale-50' : 'opacity-100 scale-100'}
`}
aria-label={`Toggle cell at row ${r + 1}, column ${c + 1}`}
style={{
transitionDelay: `${(r * gridSize + c) * 3}ms` // Cascading entrance
}}
>
{/* Inner shine effect for depth */}
<div className={`absolute inset-0 rounded-md sm:rounded-lg opacity-20 bg-gradient-to-br from-white to-transparent pointer-events-none`} />
</button>
))
))}
</div>
</div>
</main>
<footer className="py-6 text-center text-slate-400 text-sm">
<p>&copy; {new Date().getFullYear()} Flip Grid Game</p>
</footer>
{status === GameStatus.WON && (
<WinMessage
onPlayAgain={() => startNewGame()}
moves={moves}
/>
)}
</div>
);
};
export default App;