179 lines
5.6 KiB
TypeScript
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>© {new Date().getFullYear()} Flip Grid Game</p>
|
|
</footer>
|
|
|
|
{status === GameStatus.WON && (
|
|
<WinMessage
|
|
onPlayAgain={() => startNewGame()}
|
|
moves={moves}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default App; |