diff --git a/package.json b/package.json index f2a3aee..809dc61 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "0.1.0", "private": true, "type": "module", + "engines": { + "node": ">=24.x" + }, "scripts": { "dev": "concurrently \"next dev\" \"npm run websocket:dev\"", "websocket:dev": "node src/app/server/server.js", @@ -21,7 +24,7 @@ "sonner": "^2.0.7" }, "devDependencies": { - "@types/node": "^20", + "@types/node": "^24", "@types/react": "^18", "@types/react-dom": "^18", "concurrently": "^8.2.2", diff --git a/public/SlidingPuzzle.svg b/public/SlidingPuzzle.svg new file mode 100644 index 0000000..0eebf10 --- /dev/null +++ b/public/SlidingPuzzle.svg @@ -0,0 +1,39 @@ + + + + + 1 + + 2 + + 3 + + 4 + + + 5 + + 6 + + 7 + + + + + 9 + + 10 + + 11 + + 8 + + + 13 + + 14 + + 15 + + 12 + diff --git a/src/app/game/page.tsx b/src/app/game/page.tsx index 93328fd..4fe1d57 100644 --- a/src/app/game/page.tsx +++ b/src/app/game/page.tsx @@ -7,6 +7,7 @@ import SchulteImage from '/public/SchulteImage.webp' import MemoryFlip from '/public/MemoryFlip.png' import BulletMath from '/public/BulletMath.png' import SimonSaysImage from '/public/SimonSays.png' +import SlidingPuzzleImage from '/public/SlidingPuzzle.svg' const SelectGame = () => { return ( @@ -60,6 +61,13 @@ const SelectGame = () => { link={'simon-says'} backgroundColor={'#9333EA'} textColor={'white'} + /> + > diff --git a/src/app/game/sliding-puzzle/SlidingPuzzle.module.css b/src/app/game/sliding-puzzle/SlidingPuzzle.module.css new file mode 100644 index 0000000..b306637 --- /dev/null +++ b/src/app/game/sliding-puzzle/SlidingPuzzle.module.css @@ -0,0 +1,110 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + padding: 2rem; + min-height: 100vh; + font-family: system-ui, sans-serif; +} + +.container h1 { + margin-bottom: 0.5rem; +} + +.sizeSelector { + display: flex; + gap: 8px; + margin: 1rem 0; +} + +.sizeButton { + padding: 10px 20px; + min-height: 44px; + font-size: 1rem; + font-weight: bold; + border: 2px solid #2c3e50; + border-radius: 6px; + background-color: white; + color: #2c3e50; + cursor: pointer; + transition: all 0.2s; +} + +.sizeButton:hover { + background-color: #ecf0f1; +} + +.activeSize { + background-color: #2c3e50; + color: white; +} + +.activeSize:hover { + background-color: #34495e; +} + +.moves { + font-size: 1.1rem; + color: #555; +} + +.grid { + display: grid; + gap: 6px; + margin: 1.5rem 0; + background-color: #2c3e50; + padding: 10px; + border-radius: 12px; + width: min(440px, calc(100vw - 40px)); + box-sizing: border-box; +} + +.tile { + aspect-ratio: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: clamp(0.9rem, 3.5vw, 1.5rem); + font-weight: bold; + background-color: #3498db; + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + user-select: none; + transition: transform 0.15s ease, background-color 0.2s; +} + +.tile:hover:not(.empty) { + background-color: #2980b9; + transform: scale(1.05); +} + +.empty { + background-color: transparent; + cursor: default; + pointer-events: none; +} + +.resetButton { + padding: 12px 24px; + font-size: 1.1rem; + background-color: #2c3e50; + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + transition: background-color 0.2s; +} + +.resetButton:hover { + background-color: #34495e; +} + +.winMessage { + color: #27ae60; + font-weight: bold; + font-size: 1.3rem; + margin: 0.5rem 0; +} diff --git a/src/app/game/sliding-puzzle/page.tsx b/src/app/game/sliding-puzzle/page.tsx new file mode 100644 index 0000000..b6f2243 --- /dev/null +++ b/src/app/game/sliding-puzzle/page.tsx @@ -0,0 +1,132 @@ +"use client"; +import { useState, useEffect, useCallback } from "react"; +import styles from "./SlidingPuzzle.module.css"; + +const SIZES = [3, 4, 5]; + +function generateSolvablePuzzle(size: number): number[] { + const total = size * size; + let tiles: number[]; + do { + tiles = [...Array(total - 1)].map((_, i) => i + 1); + for (let i = tiles.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [tiles[i], tiles[j]] = [tiles[j], tiles[i]]; + } + tiles.push(0); + } while (!isSolvable(tiles, size) || isSolved(tiles)); + return tiles; +} + +function isSolvable(tiles: number[], size: number): boolean { + let inversions = 0; + const filtered = tiles.filter((t) => t !== 0); + for (let i = 0; i < filtered.length; i++) { + for (let j = i + 1; j < filtered.length; j++) { + if (filtered[i] > filtered[j]) inversions++; + } + } + + if (size % 2 === 1) { + // Odd grid: solvable if inversions is even + return inversions % 2 === 0; + } else { + // Even grid: find row of blank from bottom (1-based) + const emptyIndex = tiles.indexOf(0); + const rowFromBottom = size - Math.floor(emptyIndex / size); + return (rowFromBottom % 2 === 1) === (inversions % 2 === 0); + } +} + +function isSolved(tiles: number[]): boolean { + for (let i = 0; i < tiles.length - 1; i++) { + if (tiles[i] !== i + 1) return false; + } + return tiles[tiles.length - 1] === 0; +} + +export default function SlidingPuzzle() { + const [size, setSize] = useState(4); + const [tiles, setTiles] = useState([]); + const [moves, setMoves] = useState(0); + const [won, setWon] = useState(false); + + const startGame = useCallback(() => { + setTiles(generateSolvablePuzzle(size)); + setMoves(0); + setWon(false); + }, [size]); + + useEffect(() => { + startGame(); + }, [startGame]); + + const handleTileClick = (index: number) => { + if (won) return; + const emptyIndex = tiles.indexOf(0); + const row = Math.floor(index / size); + const col = index % size; + const emptyRow = Math.floor(emptyIndex / size); + const emptyCol = emptyIndex % size; + + const isAdjacent = + (Math.abs(row - emptyRow) === 1 && col === emptyCol) || + (Math.abs(col - emptyCol) === 1 && row === emptyRow); + + if (isAdjacent) { + const newTiles = [...tiles]; + [newTiles[index], newTiles[emptyIndex]] = [newTiles[emptyIndex], newTiles[index]]; + setTiles(newTiles); + setMoves(moves + 1); + + if (isSolved(newTiles)) { + setWon(true); + } + } + }; + + return ( + + Sliding Puzzle + + + {SIZES.map((s) => ( + setSize(s)} + > + {s}x{s} + + ))} + + + Moves: {moves} + + {won && ( + 🎉 Congratulations! Solved in {moves} moves! + )} + + + {tiles.map((tile, index) => ( + handleTileClick(index)} + aria-label={tile !== 0 ? `Tile ${tile}` : "Empty space"} + disabled={tile === 0} + > + {tile !== 0 ? tile : ""} + + ))} + + + + New Game + + + ); +}
Moves: {moves}
🎉 Congratulations! Solved in {moves} moves!