Initial commit: Sudoku SPA

React + Vite sudoku app with 5 difficulty levels, dark mode,
celebration effects, localStorage save, and deploy script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Duran 2026-02-10 23:59:52 +01:00
commit 5c07425d1d
20 changed files with 3881 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
dist

71
deploy.sh Executable file
View File

@ -0,0 +1,71 @@
#!/usr/bin/env bash
set -euo pipefail
# === Configuració ===
REMOTE_USER="root"
REMOTE_HOST="ontanem.net"
REMOTE_DIR="/var/www/simple-sudoku"
SSH_TARGET="${REMOTE_USER}@${REMOTE_HOST}"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[✓]${NC} $1"; }
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
error() { echo -e "${RED}[✗]${NC} $1"; exit 1; }
# === Comprovacions prèvies ===
echo ""
echo "🚀 Deploy Sudoku → ${SSH_TARGET}:${REMOTE_DIR}"
echo "─────────────────────────────────────────────"
# Verificar que estem al directori del projecte
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
if [ ! -f "package.json" ]; then
error "No s'ha trobat package.json. Executa l'script des del directori del projecte."
fi
# Verificar connexió SSH
info "Comprovant connexió SSH..."
if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "$SSH_TARGET" "echo ok" &>/dev/null; then
error "No s'ha pogut connectar a ${SSH_TARGET}. Verifica la configuració SSH."
fi
# === Build ===
info "Instal·lant dependències..."
npm ci --silent
info "Generant build de producció..."
npx vite build --emptyOutDir
if [ ! -d "dist" ]; then
error "El build ha fallat: no s'ha creat el directori dist/"
fi
# === Deploy ===
info "Creant directori remot si no existeix..."
ssh "$SSH_TARGET" "mkdir -p ${REMOTE_DIR}"
info "Pujant fitxers amb rsync..."
rsync -azP --delete \
dist/ \
"${SSH_TARGET}:${REMOTE_DIR}/"
# === Verificació ===
info "Verificant fitxers al servidor..."
REMOTE_INDEX=$(ssh "$SSH_TARGET" "test -f ${REMOTE_DIR}/index.html && echo 'ok' || echo 'fail'")
if [ "$REMOTE_INDEX" = "ok" ]; then
echo ""
info "Deploy completat correctament! ✨"
echo ""
echo " Fitxers desplegats a: ${SSH_TARGET}:${REMOTE_DIR}/"
echo ""
else
error "Alguna cosa ha anat malament: index.html no trobat al servidor."
fi

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ca">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sudoku</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1796
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "simple-sudoku",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.0"
}
}

843
src/App.css Normal file
View File

@ -0,0 +1,843 @@
/* === Reset & Base === */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
background: #f8f9fa;
color: #212529;
min-height: 100vh;
-webkit-tap-highlight-color: transparent;
}
/* === App Layout === */
.app {
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
padding: 16px;
}
.app-header {
margin-bottom: 12px;
}
.app-header h1 {
font-size: 28px;
font-weight: 700;
color: #343a40;
letter-spacing: -0.5px;
}
.app-main {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: 100%;
max-width: 460px;
}
/* === ScoreBoard === */
.score-board {
display: flex;
justify-content: space-between;
width: 100%;
padding: 10px 16px;
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.score-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.score-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #868e96;
font-weight: 600;
}
.score-value {
font-size: 16px;
font-weight: 600;
color: #343a40;
}
.errors-count {
color: #e53935;
}
.timer {
font-variant-numeric: tabular-nums;
}
/* === Board === */
.board {
display: grid;
grid-template-columns: repeat(9, 1fr);
grid-template-rows: repeat(9, 1fr);
width: 100%;
aspect-ratio: 1;
max-width: 460px;
background: #343a40;
gap: 1px;
padding: 2px;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
user-select: none;
}
/* Thick borders for 3x3 boxes */
.board .cell:nth-child(9n + 1) { margin-left: 1px; }
.board .cell:nth-child(9n + 4) { margin-left: 1px; }
.board .cell:nth-child(9n + 7) { margin-left: 1px; }
.board .cell:nth-child(9n) { margin-right: 1px; }
.board .cell:nth-child(-n + 9) { margin-top: 1px; }
.board .cell:nth-child(n + 28):nth-child(-n + 36) { margin-top: 1px; }
.board .cell:nth-child(n + 55):nth-child(-n + 63) { margin-top: 1px; }
.board .cell:nth-child(n + 73) { margin-bottom: 1px; }
/* === Cell === */
.cell {
display: flex;
align-items: center;
justify-content: center;
background: #fff;
cursor: pointer;
position: relative;
transition: background-color 0.1s ease;
}
.cell-value {
font-size: clamp(20px, 5.5vw, 32px);
font-weight: 500;
line-height: 1;
color: #1976d2;
}
.cell.given .cell-value {
color: #343a40;
font-weight: 700;
}
.cell.error .cell-value {
color: #e53935;
}
/* Cell highlighting */
.cell.selected {
background: #bbdefb;
}
.cell.highlighted-row,
.cell.highlighted-col,
.cell.highlighted-box {
background: #e8eef4;
}
.cell.same-number {
background: #c8e6f9;
}
.cell.same-number.highlighted-row,
.cell.same-number.highlighted-col,
.cell.same-number.highlighted-box {
background: #b3d9f5;
}
.cell.error {
background: #ffebee;
}
/* Celebration animation */
.cell.celebrating::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
animation: celebrateGlow 1.4s ease-in-out forwards;
pointer-events: none;
}
.cell.celebrating .cell-value {
animation: celebrateText 1.4s ease-in-out;
}
@keyframes celebrateGlow {
0% {
background: rgba(255, 213, 79, 0);
}
12% {
background: rgba(255, 213, 79, 0.45);
}
24% {
background: rgba(255, 193, 7, 0.35);
}
36% {
background: rgba(255, 213, 79, 0.25);
}
55% {
background: rgba(255, 224, 130, 0.15);
}
80% {
background: rgba(255, 236, 179, 0.05);
}
100% {
background: rgba(255, 236, 179, 0);
}
}
@keyframes celebrateText {
0% {
transform: scale(1);
}
12% {
color: #f57f17;
transform: scale(1.25);
}
24% {
color: #ff8f00;
transform: scale(0.9);
}
36% {
color: #ffa000;
transform: scale(1.1);
}
55% {
color: #ffb300;
transform: scale(1);
}
80% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
/* Notes grid */
.cell-notes {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
width: 100%;
height: 100%;
padding: 1px;
}
.note {
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(9px, 2vw, 11px);
color: #868e96;
line-height: 1;
}
/* === NumberPad === */
.number-pad {
width: 100%;
display: flex;
flex-direction: column;
gap: 10px;
}
.number-buttons {
display: grid;
grid-template-columns: repeat(9, 1fr);
gap: 6px;
}
.num-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
padding: 8px 0;
border: none;
border-radius: 8px;
background: #fff;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
transition: all 0.15s ease;
}
.num-btn:hover:not(:disabled) {
background: #e3f2fd;
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}
.num-btn:active:not(:disabled) {
transform: translateY(0);
}
.num-btn:disabled {
opacity: 0.35;
cursor: default;
}
.num-btn-value {
font-size: clamp(16px, 4vw, 22px);
font-weight: 600;
color: #1976d2;
line-height: 1;
}
.num-btn-count {
font-size: 9px;
color: #adb5bd;
line-height: 1;
}
.pad-actions {
display: flex;
gap: 8px;
}
.action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px;
border: none;
border-radius: 8px;
background: #fff;
color: #495057;
font-size: 14px;
font-weight: 500;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
transition: all 0.15s ease;
}
.action-btn:hover {
background: #f1f3f5;
}
.action-btn.active {
background: #1976d2;
color: #fff;
box-shadow: 0 2px 8px rgba(25, 118, 210, 0.3);
}
/* === GameControls === */
.game-controls {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.controls-row {
display: flex;
gap: 8px;
}
.controls-row + .controls-row {
margin-top: 4px;
}
.difficulty-select {
flex: 1;
padding: 10px 12px;
border: 1px solid #dee2e6;
border-radius: 8px;
background: #fff;
font-size: 14px;
font-family: inherit;
color: #495057;
cursor: pointer;
appearance: auto;
}
.difficulty-select:focus {
outline: none;
border-color: #1976d2;
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.15);
}
/* === Toggle Switch === */
.checkbox-label {
display: flex;
align-items: center;
gap: 10px;
margin: 4px 0;
padding: 8px 14px;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
font-size: 14px;
font-weight: 500;
color: #495057;
cursor: pointer;
user-select: none;
transition: all 0.15s ease;
}
.checkbox-label:hover {
background: #f1f3f5;
}
.checkbox-label input[type="checkbox"] {
display: none;
}
.toggle-track {
position: relative;
width: 36px;
height: 20px;
background: #ced4da;
border-radius: 10px;
transition: background 0.2s ease;
flex-shrink: 0;
}
.toggle-track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
transition: transform 0.2s ease;
}
.checkbox-label input:checked + .toggle-track {
background: #1976d2;
}
.checkbox-label input:checked + .toggle-track::after {
transform: translateX(16px);
}
/* === Buttons === */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
}
.btn:disabled {
opacity: 0.4;
cursor: default;
}
.btn-primary {
flex: 1;
background: #1976d2;
color: #fff;
box-shadow: 0 2px 6px rgba(25, 118, 210, 0.25);
}
.btn-primary:hover:not(:disabled) {
background: #1565c0;
box-shadow: 0 3px 8px rgba(25, 118, 210, 0.35);
}
.btn-secondary {
flex: 1;
background: #fff;
color: #495057;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.btn-secondary:hover:not(:disabled) {
background: #f1f3f5;
}
.btn-icon {
flex: 0;
padding: 10px;
}
.btn-large {
padding: 14px 28px;
font-size: 16px;
}
.btn-block {
width: 100%;
}
/* === Confetti === */
.confetti-canvas {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
z-index: 200;
pointer-events: none;
}
/* === WinModal === */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
animation: fadeIn 0.3s ease;
padding: 16px;
}
.modal {
background: #fff;
border-radius: 16px;
padding: 32px;
text-align: center;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
max-width: 340px;
width: 100%;
animation: slideUp 0.3s ease;
}
.modal h2 {
font-size: 24px;
color: #2e7d32;
margin-bottom: 4px;
}
.modal p {
color: #868e96;
margin-bottom: 20px;
}
.modal-stats {
display: flex;
justify-content: space-around;
margin-bottom: 24px;
padding: 16px 0;
border-top: 1px solid #f1f3f5;
border-bottom: 1px solid #f1f3f5;
}
.modal-stat {
display: flex;
flex-direction: column;
gap: 4px;
}
.modal-stat-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #868e96;
font-weight: 600;
}
.modal-stat-value {
font-size: 18px;
font-weight: 700;
color: #343a40;
}
.modal-actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
.modal-actions .btn {
flex: 1;
}
.settings-list {
display: flex;
flex-direction: column;
gap: 10px;
margin: 20px 0;
}
.settings-list .checkbox-label {
margin: 0;
}
/* === Animations === */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* === Responsive === */
@media (max-width: 480px) {
.app {
padding: 10px 8px;
}
.app-header h1 {
font-size: 22px;
}
.app-main {
gap: 10px;
}
.score-board {
padding: 8px 12px;
}
.number-buttons {
gap: 4px;
}
.num-btn {
padding: 6px 0;
border-radius: 6px;
}
.pad-actions {
gap: 6px;
}
.action-btn {
padding: 8px;
font-size: 13px;
}
.controls-row {
gap: 6px;
}
.btn {
padding: 8px 12px;
font-size: 13px;
}
}
@media (min-width: 768px) {
.app {
padding: 32px;
}
.app-header {
margin-bottom: 20px;
}
.app-header h1 {
font-size: 32px;
}
}
/* === Dark Mode === */
.app.dark {
background: #1a1a2e;
color: #e0e0e0;
}
.app.dark .app-header h1 {
color: #e0e0e0;
}
.app.dark .score-board {
background: #16213e;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.app.dark .score-label {
color: #8a8a9a;
}
.app.dark .score-value {
color: #e0e0e0;
}
.app.dark .board {
background: #4a4a6a;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
}
.app.dark .cell {
background: #1a1a2e;
}
.app.dark .cell-value {
color: #64b5f6;
}
.app.dark .cell.given .cell-value {
color: #e0e0e0;
}
.app.dark .cell.error .cell-value {
color: #ef5350;
}
.app.dark .cell.selected {
background: #1a3a5c;
}
.app.dark .cell.highlighted-row,
.app.dark .cell.highlighted-col,
.app.dark .cell.highlighted-box {
background: #16213e;
}
.app.dark .cell.same-number {
background: #1a3050;
}
.app.dark .cell.same-number.highlighted-row,
.app.dark .cell.same-number.highlighted-col,
.app.dark .cell.same-number.highlighted-box {
background: #1a2a45;
}
.app.dark .cell.error {
background: #3e1a1a;
}
.app.dark .note {
color: #6a6a7a;
}
.app.dark .num-btn {
background: #16213e;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
.app.dark .num-btn:hover:not(:disabled) {
background: #1a3a5c;
}
.app.dark .num-btn-value {
color: #64b5f6;
}
.app.dark .num-btn-count {
color: #5a5a6a;
}
.app.dark .btn-secondary {
background: #16213e;
color: #c0c0d0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
.app.dark .btn-secondary:hover:not(:disabled) {
background: #1a2a45;
}
.app.dark .btn-primary {
background: #1565c0;
box-shadow: 0 2px 6px rgba(21, 101, 192, 0.4);
}
.app.dark .btn-primary:hover:not(:disabled) {
background: #1976d2;
}
.app.dark .difficulty-select {
background: #16213e;
border-color: #2a2a4a;
color: #c0c0d0;
}
.app.dark .difficulty-select:focus {
border-color: #64b5f6;
box-shadow: 0 0 0 3px rgba(100, 181, 246, 0.15);
}
.app.dark .action-btn {
background: #16213e;
color: #c0c0d0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
.app.dark .action-btn:hover {
background: #1a2a45;
}
.app.dark .action-btn.active {
background: #1565c0;
color: #fff;
}
.app.dark .modal-overlay {
background: rgba(0, 0, 0, 0.6);
}
.app.dark .modal {
background: #16213e;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.app.dark .modal h2 {
color: #e0e0e0;
}
.app.dark .modal p {
color: #8a8a9a;
}
.app.dark .modal-stats {
border-color: #2a2a4a;
}
.app.dark .modal-stat-label {
color: #8a8a9a;
}
.app.dark .modal-stat-value {
color: #e0e0e0;
}
.app.dark .checkbox-label {
background: #1a1a2e;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
color: #c0c0d0;
}
.app.dark .checkbox-label:hover {
background: #1a2a45;
}
.app.dark .toggle-track {
background: #3a3a5a;
}
.app.dark .settings-list .checkbox-label {
background: #1a1a2e;
}

100
src/App.jsx Normal file
View File

@ -0,0 +1,100 @@
import React, { useState, useEffect } from 'react'
import Board from './components/Board'
import NumberPad from './components/NumberPad'
import GameControls from './components/GameControls'
import ScoreBoard from './components/ScoreBoard'
import WinModal from './components/WinModal'
import Confetti from './components/Confetti'
import SettingsModal from './components/SettingsModal'
import { useGame } from './hooks/useGame'
function App() {
const {
state,
numberCounts,
newGame,
selectCell,
setNumber,
clearCell,
toggleNotes,
toggleShowErrors,
toggleShowEffects,
toggleDarkMode,
undo,
redo,
} = useGame()
const [showSettings, setShowSettings] = useState(false)
useEffect(() => {
document.body.style.background = state.darkMode ? '#1a1a2e' : '#f8f9fa'
}, [state.darkMode])
return (
<div className={`app${state.darkMode ? ' dark' : ''}`}>
<header className="app-header">
<h1>Sudoku</h1>
</header>
<main className="app-main">
<ScoreBoard
difficulty={state.difficulty}
errors={state.errors}
timer={state.timer}
showErrors={state.showErrors}
/>
<Board
board={state.board}
puzzle={state.puzzle}
solution={state.solution}
notes={state.notes}
selected={state.selected}
showErrors={state.showErrors}
celebrating={state.showEffects ? state.celebrating : new Set()}
selectCell={selectCell}
/>
<NumberPad
onNumber={setNumber}
numberCounts={numberCounts}
/>
<GameControls
currentDifficulty={state.difficulty}
onNewGame={newGame}
onClear={clearCell}
onUndo={undo}
onRedo={redo}
canUndo={state.history.length > 0}
canRedo={state.future.length > 0}
onOpenSettings={() => setShowSettings(true)}
/>
</main>
<Confetti active={state.isComplete && state.showEffects} />
{state.isComplete && (
<WinModal
timer={state.timer}
errors={state.errors}
difficulty={state.difficulty}
onNewGame={() => newGame(state.difficulty)}
/>
)}
{showSettings && (
<SettingsModal
showErrors={state.showErrors}
showEffects={state.showEffects}
darkMode={state.darkMode}
onToggleShowErrors={toggleShowErrors}
onToggleShowEffects={toggleShowEffects}
onToggleDarkMode={toggleDarkMode}
onClose={() => setShowSettings(false)}
/>
)}
</div>
)
}
export default App

55
src/components/Board.jsx Normal file
View File

@ -0,0 +1,55 @@
import React from 'react'
import Cell from './Cell'
import { checkValue } from '../utils/sudoku'
function Board({ board, puzzle, solution, notes, selected, showErrors, celebrating, selectCell }) {
const selectedValue = selected && board[selected.row][selected.col]
return (
<div className="board">
{board.map((row, r) =>
row.map((value, c) => {
const isGiven = puzzle[r][c] !== 0
const isSelected = selected && selected.row === r && selected.col === c
const isHighlightedRow = selected && selected.row === r && !isSelected
const isHighlightedCol = selected && selected.col === c && !isSelected
const isHighlightedBox =
selected &&
!isSelected &&
Math.floor(selected.row / 3) === Math.floor(r / 3) &&
Math.floor(selected.col / 3) === Math.floor(c / 3)
const isSameNumber =
!isSelected &&
selectedValue !== 0 &&
value !== 0 &&
value === selectedValue
const isError =
showErrors &&
!isGiven &&
value !== 0 &&
!checkValue(solution, r, c, value)
const isCelebrating = celebrating.has(`${r},${c}`)
return (
<Cell
key={`${r}-${c}`}
value={value}
notes={notes[r][c]}
isGiven={isGiven}
isSelected={isSelected}
isHighlightedRow={isHighlightedRow}
isHighlightedCol={isHighlightedCol}
isHighlightedBox={isHighlightedBox}
isSameNumber={isSameNumber}
isError={isError}
isCelebrating={isCelebrating}
onClick={() => selectCell(r, c)}
/>
)
})
)}
</div>
)
}
export default React.memo(Board)

29
src/components/Cell.jsx Normal file
View File

@ -0,0 +1,29 @@
import React from 'react'
function Cell({ value, notes, isGiven, isSelected, isHighlightedRow, isHighlightedCol, isHighlightedBox, isSameNumber, isError, isCelebrating, onClick }) {
const classes = ['cell']
if (isGiven) classes.push('given')
if (isSelected) classes.push('selected')
if (isHighlightedRow) classes.push('highlighted-row')
if (isHighlightedCol) classes.push('highlighted-col')
if (isHighlightedBox) classes.push('highlighted-box')
if (isSameNumber) classes.push('same-number')
if (isError) classes.push('error')
if (isCelebrating) classes.push('celebrating')
return (
<div className={classes.join(' ')} onClick={onClick}>
{value !== 0 ? (
<span className="cell-value">{value}</span>
) : notes && notes.size > 0 ? (
<div className="cell-notes">
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => (
<span key={n} className="note">{notes.has(n) ? n : ''}</span>
))}
</div>
) : null}
</div>
)
}
export default React.memo(Cell)

View File

@ -0,0 +1,87 @@
import React, { useEffect, useRef } from 'react'
const COLORS = ['#ffd700', '#ff6b6b', '#48dbfb', '#ff9ff3', '#54a0ff', '#5f27cd', '#01a3a4', '#f368e0', '#ff9f43', '#2ed573']
const PARTICLE_COUNT = 150
const DURATION = 4000
function Confetti({ active }) {
const canvasRef = useRef(null)
useEffect(() => {
if (!active) return
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
let animationId
let startTime
function resize() {
canvas.width = window.innerWidth
canvas.height = window.innerHeight
}
resize()
window.addEventListener('resize', resize)
const particles = Array.from({ length: PARTICLE_COUNT }, () => ({
x: Math.random() * canvas.width,
y: -20 - Math.random() * canvas.height * 0.5,
w: 4 + Math.random() * 6,
h: 6 + Math.random() * 10,
color: COLORS[Math.floor(Math.random() * COLORS.length)],
vx: (Math.random() - 0.5) * 4,
vy: 1.5 + Math.random() * 3,
rotation: Math.random() * Math.PI * 2,
rotationSpeed: (Math.random() - 0.5) * 0.15,
wobble: Math.random() * Math.PI * 2,
wobbleSpeed: 0.03 + Math.random() * 0.05,
}))
function animate(time) {
if (!startTime) startTime = time
const elapsed = time - startTime
ctx.clearRect(0, 0, canvas.width, canvas.height)
// Fade out in the last second
const fadeStart = DURATION - 1000
const globalAlpha = elapsed > fadeStart ? 1 - (elapsed - fadeStart) / 1000 : 1
for (const p of particles) {
p.x += p.vx + Math.sin(p.wobble) * 0.5
p.y += p.vy
p.rotation += p.rotationSpeed
p.wobble += p.wobbleSpeed
p.vy += 0.02 // gravity
ctx.save()
ctx.globalAlpha = globalAlpha
ctx.translate(p.x, p.y)
ctx.rotate(p.rotation)
ctx.fillStyle = p.color
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h)
ctx.restore()
}
if (elapsed < DURATION) {
animationId = requestAnimationFrame(animate)
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height)
}
}
animationId = requestAnimationFrame(animate)
return () => {
cancelAnimationFrame(animationId)
window.removeEventListener('resize', resize)
}
}, [active])
if (!active) return null
return <canvas ref={canvasRef} className="confetti-canvas" />
}
export default Confetti

View File

@ -0,0 +1,87 @@
import React, { useState } from 'react'
const DIFFICULTIES = [
{ value: 'molt-facil', label: 'Molt fàcil' },
{ value: 'facil', label: 'Fàcil' },
{ value: 'mitja', label: 'Mitjà' },
{ value: 'dificil', label: 'Difícil' },
{ value: 'expert', label: 'Expert' },
]
function GameControls({ currentDifficulty, onNewGame, onClear, onUndo, onRedo, canUndo, canRedo, onOpenSettings }) {
const [difficulty, setDifficulty] = useState(currentDifficulty)
const [showConfirm, setShowConfirm] = useState(false)
function handleNewGame() {
setShowConfirm(false)
onNewGame(difficulty)
}
return (
<div className="game-controls">
<div className="controls-row">
<select
className="difficulty-select"
value={difficulty}
onChange={e => setDifficulty(e.target.value)}
>
{DIFFICULTIES.map(d => (
<option key={d.value} value={d.value}>{d.label}</option>
))}
</select>
<button className="btn btn-primary" onClick={() => setShowConfirm(true)}>
Nova Partida
</button>
</div>
<div className="controls-row">
<button className="btn btn-secondary" onClick={onClear} title="Esborra">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"/>
<line x1="18" y1="9" x2="12" y2="15"/>
<line x1="12" y1="9" x2="18" y2="15"/>
</svg>
Esborra
</button>
<button className="btn btn-secondary" onClick={onUndo} disabled={!canUndo} title="Desfés (Ctrl+Z)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
</svg>
Desfés
</button>
<button className="btn btn-secondary" onClick={onRedo} disabled={!canRedo} title="Refés (Ctrl+Shift+Z)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.13-9.36L23 10"/>
</svg>
Refés
</button>
<button className="btn btn-secondary btn-icon" onClick={onOpenSettings} title="Configuració">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
</div>
{showConfirm && (
<div className="modal-overlay" onClick={() => setShowConfirm(false)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<h2>Nova partida?</h2>
<p>Es perdrà el progrés de la partida actual.</p>
<div className="modal-actions">
<button className="btn btn-secondary" onClick={() => setShowConfirm(false)}>
Cancel·la
</button>
<button className="btn btn-primary" onClick={handleNewGame}>
Comença
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default React.memo(GameControls)

View File

@ -0,0 +1,27 @@
import React from 'react'
function NumberPad({ onNumber, numberCounts }) {
return (
<div className="number-pad">
<div className="number-buttons">
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => {
const remaining = 9 - numberCounts[n]
const disabled = remaining <= 0
return (
<button
key={n}
className={`num-btn${disabled ? ' disabled' : ''}`}
onClick={() => !disabled && onNumber(n)}
disabled={disabled}
>
<span className="num-btn-value">{n}</span>
{remaining > 0 && <span className="num-btn-count">{remaining}</span>}
</button>
)
})}
</div>
</div>
)
}
export default React.memo(NumberPad)

View File

@ -0,0 +1,33 @@
import React from 'react'
import Timer from './Timer'
const DIFFICULTY_LABELS = {
'molt-facil': 'Molt fàcil',
'facil': 'Fàcil',
'mitja': 'Mitjà',
'dificil': 'Difícil',
'expert': 'Expert',
}
function ScoreBoard({ difficulty, errors, timer, showErrors }) {
return (
<div className="score-board">
<div className="score-item">
<span className="score-label">Dificultat</span>
<span className="score-value">{DIFFICULTY_LABELS[difficulty]}</span>
</div>
{showErrors && (
<div className="score-item">
<span className="score-label">Errors</span>
<span className="score-value errors-count">{errors}</span>
</div>
)}
<div className="score-item">
<span className="score-label">Temps</span>
<span className="score-value"><Timer seconds={timer} /></span>
</div>
</div>
)
}
export default React.memo(ScoreBoard)

View File

@ -0,0 +1,45 @@
import React from 'react'
function SettingsModal({ showErrors, showEffects, darkMode, onToggleShowErrors, onToggleShowEffects, onToggleDarkMode, onClose }) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<h2>Configuraci&oacute;</h2>
<div className="settings-list">
<label className="checkbox-label">
<input
type="checkbox"
checked={showErrors}
onChange={onToggleShowErrors}
/>
<span className="toggle-track" />
Mostra errors
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={showEffects}
onChange={onToggleShowEffects}
/>
<span className="toggle-track" />
Utilitza efectes especials
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={darkMode}
onChange={onToggleDarkMode}
/>
<span className="toggle-track" />
Mode fosc
</label>
</div>
<button className="btn btn-primary btn-block" onClick={onClose}>
Tanca
</button>
</div>
</div>
)
}
export default SettingsModal

13
src/components/Timer.jsx Normal file
View File

@ -0,0 +1,13 @@
import React from 'react'
function Timer({ seconds }) {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return (
<span className="timer">
{String(mins).padStart(2, '0')}:{String(secs).padStart(2, '0')}
</span>
)
}
export default React.memo(Timer)

View File

@ -0,0 +1,40 @@
import React from 'react'
import Timer from './Timer'
const DIFFICULTY_LABELS = {
'molt-facil': 'Molt fàcil',
'facil': 'Fàcil',
'mitja': 'Mitjà',
'dificil': 'Difícil',
'expert': 'Expert',
}
function WinModal({ timer, errors, difficulty, onNewGame }) {
return (
<div className="modal-overlay">
<div className="modal">
<h2>Felicitats!</h2>
<p>Has completat el Sudoku!</p>
<div className="modal-stats">
<div className="modal-stat">
<span className="modal-stat-label">Dificultat</span>
<span className="modal-stat-value">{DIFFICULTY_LABELS[difficulty]}</span>
</div>
<div className="modal-stat">
<span className="modal-stat-label">Temps</span>
<span className="modal-stat-value"><Timer seconds={timer} /></span>
</div>
<div className="modal-stat">
<span className="modal-stat-label">Errors</span>
<span className="modal-stat-value">{errors}</span>
</div>
</div>
<button className="btn btn-primary btn-large" onClick={onNewGame}>
Nova Partida
</button>
</div>
</div>
)
}
export default WinModal

476
src/hooks/useGame.js Normal file
View File

@ -0,0 +1,476 @@
import { useReducer, useEffect, useCallback, useRef } from 'react'
import { generatePuzzle, checkValue, isBoardComplete } from '../utils/sudoku'
const STORAGE_KEY = 'sudoku-save'
function cloneBoard(board) {
return board.map(row => [...row])
}
function cloneNotes(notes) {
return notes.map(row => row.map(cell => new Set(cell)))
}
function serializeNotes(notes) {
return notes.map(row => row.map(cell => [...cell]))
}
function deserializeNotes(notes) {
return notes.map(row => row.map(cell => new Set(cell)))
}
function createInitialNotes() {
return Array.from({ length: 9 }, () =>
Array.from({ length: 9 }, () => new Set())
)
}
function saveState(state) {
try {
const toSave = {
puzzle: state.puzzle,
solution: state.solution,
board: state.board,
notes: serializeNotes(state.notes),
difficulty: state.difficulty,
errors: state.errors,
timer: state.timer,
isComplete: state.isComplete,
notesMode: state.notesMode,
showErrors: state.showErrors,
showEffects: state.showEffects,
darkMode: state.darkMode,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave))
} catch {
// ignore storage errors
}
}
function loadSavedState() {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (!saved) return null
const parsed = JSON.parse(saved)
return {
...parsed,
notes: deserializeNotes(parsed.notes),
selected: null,
showErrors: parsed.showErrors || false,
showEffects: parsed.showEffects !== undefined ? parsed.showEffects : true,
darkMode: parsed.darkMode || false,
isRunning: !parsed.isComplete,
celebrating: new Set(),
history: [],
future: [],
}
} catch {
return null
}
}
function findCompletedGroupKeys(board, solution) {
const keys = new Set()
for (let r = 0; r < 9; r++) {
let complete = true
for (let c = 0; c < 9; c++) {
if (board[r][c] !== solution[r][c]) { complete = false; break }
}
if (complete) keys.add(`r${r}`)
}
for (let c = 0; c < 9; c++) {
let complete = true
for (let r = 0; r < 9; r++) {
if (board[r][c] !== solution[r][c]) { complete = false; break }
}
if (complete) keys.add(`c${c}`)
}
for (let br = 0; br < 3; br++) {
for (let bc = 0; bc < 3; bc++) {
let complete = true
for (let r = br * 3; r < br * 3 + 3; r++) {
for (let c = bc * 3; c < bc * 3 + 3; c++) {
if (board[r][c] !== solution[r][c]) { complete = false; break }
}
if (!complete) break
}
if (complete) keys.add(`b${br}${bc}`)
}
}
return keys
}
function cellsFromGroupKeys(keys) {
const cells = new Set()
for (const key of keys) {
if (key[0] === 'r') {
const r = +key[1]
for (let c = 0; c < 9; c++) cells.add(`${r},${c}`)
} else if (key[0] === 'c') {
const c = +key[1]
for (let r = 0; r < 9; r++) cells.add(`${r},${c}`)
} else {
const br = +key[1], bc = +key[2]
for (let r = br * 3; r < br * 3 + 3; r++)
for (let c = bc * 3; c < bc * 3 + 3; c++) cells.add(`${r},${c}`)
}
}
return cells
}
function createNewGameState(difficulty) {
const { puzzle, solution } = generatePuzzle(difficulty)
return {
puzzle,
solution,
board: cloneBoard(puzzle),
notes: createInitialNotes(),
selected: null,
difficulty,
errors: 0,
timer: 0,
isRunning: true,
isComplete: false,
notesMode: false,
showErrors: false,
showEffects: true,
darkMode: false,
celebrating: new Set(),
history: [],
future: [],
}
}
function pushHistory(state) {
return {
history: [...state.history, { board: cloneBoard(state.board), notes: cloneNotes(state.notes) }],
future: [],
}
}
function reducer(state, action) {
switch (action.type) {
case 'NEW_GAME': {
return createNewGameState(action.difficulty)
}
case 'LOAD_GAME': {
const saved = loadSavedState()
if (saved) return saved
return state
}
case 'SELECT_CELL': {
return { ...state, selected: { row: action.row, col: action.col } }
}
case 'SET_NUMBER': {
if (!state.selected || state.isComplete) return state
const { row, col } = state.selected
if (state.puzzle[row][col] !== 0) return state
const num = action.number
const histUpdate = pushHistory(state)
const newBoard = cloneBoard(state.board)
const newNotes = cloneNotes(state.notes)
const currentValue = state.board[row][col]
const hasNotes = state.notes[row][col].size > 0
// Cell already has notes → toggle number in notes
if (hasNotes || state.notesMode) {
if (newNotes[row][col].has(num)) {
newNotes[row][col].delete(num)
} else {
newNotes[row][col].add(num)
}
newBoard[row][col] = 0
return { ...state, ...histUpdate, board: newBoard, notes: newNotes }
}
// Cell has a big number
if (currentValue !== 0) {
if (currentValue === num) {
// Same number again → convert to note
newBoard[row][col] = 0
newNotes[row][col] = new Set([num])
} else {
// Different number → both become notes
newBoard[row][col] = 0
newNotes[row][col] = new Set([currentValue, num])
}
return { ...state, ...histUpdate, board: newBoard, notes: newNotes }
}
// Empty cell → place as big number
newBoard[row][col] = num
const isCorrect = checkValue(state.solution, row, col, num)
const newErrors = (!isCorrect && state.showErrors) ? state.errors + 1 : state.errors
const complete = isCorrect && isBoardComplete(newBoard, state.solution)
// Detect newly completed groups
const prevKeys = findCompletedGroupKeys(state.board, state.solution)
const newKeys = findCompletedGroupKeys(newBoard, state.solution)
const freshKeys = new Set()
for (const key of newKeys) {
if (!prevKeys.has(key)) freshKeys.add(key)
}
const celebrating = cellsFromGroupKeys(freshKeys)
return {
...state,
...histUpdate,
board: newBoard,
notes: newNotes,
errors: newErrors,
isComplete: complete,
isRunning: !complete,
celebrating,
}
}
case 'CLEAR_CELL': {
if (!state.selected || state.isComplete) return state
const { row, col } = state.selected
if (state.puzzle[row][col] !== 0) return state
if (state.board[row][col] === 0 && state.notes[row][col].size === 0) return state
const histUpdate = pushHistory(state)
const newBoard = cloneBoard(state.board)
const newNotes = cloneNotes(state.notes)
newBoard[row][col] = 0
newNotes[row][col] = new Set()
return { ...state, ...histUpdate, board: newBoard, notes: newNotes }
}
case 'TOGGLE_NOTES': {
return { ...state, notesMode: !state.notesMode }
}
case 'TOGGLE_SHOW_ERRORS': {
return { ...state, showErrors: !state.showErrors }
}
case 'TOGGLE_SHOW_EFFECTS': {
return { ...state, showEffects: !state.showEffects }
}
case 'TOGGLE_DARK_MODE': {
return { ...state, darkMode: !state.darkMode }
}
case 'CLEAR_CELEBRATING': {
return { ...state, celebrating: new Set() }
}
case 'UNDO': {
if (state.history.length === 0) return state
const prev = state.history[state.history.length - 1]
return {
...state,
board: prev.board,
notes: prev.notes,
history: state.history.slice(0, -1),
future: [...state.future, { board: cloneBoard(state.board), notes: cloneNotes(state.notes) }],
}
}
case 'REDO': {
if (state.future.length === 0) return state
const next = state.future[state.future.length - 1]
return {
...state,
board: next.board,
notes: next.notes,
future: state.future.slice(0, -1),
history: [...state.history, { board: cloneBoard(state.board), notes: cloneNotes(state.notes) }],
}
}
case 'TICK': {
return { ...state, timer: state.timer + 1 }
}
case 'MOVE_SELECTION': {
if (!state.selected) return { ...state, selected: { row: 0, col: 0 } }
const { row, col } = state.selected
let newRow = row + (action.dr || 0)
let newCol = col + (action.dc || 0)
newRow = Math.max(0, Math.min(8, newRow))
newCol = Math.max(0, Math.min(8, newCol))
return { ...state, selected: { row: newRow, col: newCol } }
}
default:
return state
}
}
export function useGame() {
const [state, dispatch] = useReducer(reducer, null, () => {
const saved = loadSavedState()
if (saved) return saved
return createNewGameState('mitja')
})
// Auto-save to localStorage on every state change
useEffect(() => {
saveState(state)
}, [state])
// Clear celebration after animation
useEffect(() => {
if (state.celebrating.size > 0) {
const timeout = setTimeout(() => dispatch({ type: 'CLEAR_CELEBRATING' }), 1500)
return () => clearTimeout(timeout)
}
}, [state.celebrating])
const timerRef = useRef(null)
useEffect(() => {
if (state.isRunning) {
timerRef.current = setInterval(() => dispatch({ type: 'TICK' }), 1000)
}
return () => clearInterval(timerRef.current)
}, [state.isRunning])
const newGame = useCallback((difficulty) => {
dispatch({ type: 'NEW_GAME', difficulty })
}, [])
const selectCell = useCallback((row, col) => {
dispatch({ type: 'SELECT_CELL', row, col })
}, [])
const setNumber = useCallback((number) => {
dispatch({ type: 'SET_NUMBER', number })
}, [])
const clearCell = useCallback(() => {
dispatch({ type: 'CLEAR_CELL' })
}, [])
const toggleNotes = useCallback(() => {
dispatch({ type: 'TOGGLE_NOTES' })
}, [])
const toggleShowErrors = useCallback(() => {
dispatch({ type: 'TOGGLE_SHOW_ERRORS' })
}, [])
const toggleShowEffects = useCallback(() => {
dispatch({ type: 'TOGGLE_SHOW_EFFECTS' })
}, [])
const toggleDarkMode = useCallback(() => {
dispatch({ type: 'TOGGLE_DARK_MODE' })
}, [])
const undo = useCallback(() => {
dispatch({ type: 'UNDO' })
}, [])
const redo = useCallback(() => {
dispatch({ type: 'REDO' })
}, [])
const moveSelection = useCallback((dr, dc) => {
dispatch({ type: 'MOVE_SELECTION', dr, dc })
}, [])
// Keyboard support
useEffect(() => {
function handleKeyDown(e) {
const num = parseInt(e.key)
if (num >= 1 && num <= 9) {
e.preventDefault()
setNumber(num)
return
}
switch (e.key) {
case 'Backspace':
case 'Delete':
e.preventDefault()
clearCell()
break
case 'ArrowUp':
e.preventDefault()
moveSelection(-1, 0)
break
case 'ArrowDown':
e.preventDefault()
moveSelection(1, 0)
break
case 'ArrowLeft':
e.preventDefault()
moveSelection(0, -1)
break
case 'ArrowRight':
e.preventDefault()
moveSelection(0, 1)
break
case 'n':
case 'N':
e.preventDefault()
toggleNotes()
break
case 'z':
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
if (e.shiftKey) redo()
else undo()
}
break
case 'y':
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
redo()
}
break
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [setNumber, clearCell, moveSelection, toggleNotes, undo, redo])
// Count how many of each number are placed
const numberCounts = Array(10).fill(0)
for (let r = 0; r < 9; r++) {
for (let c = 0; c < 9; c++) {
if (state.board[r][c] !== 0) {
numberCounts[state.board[r][c]]++
}
}
}
return {
state,
numberCounts,
newGame,
selectCell,
setNumber,
clearCell,
toggleNotes,
toggleShowErrors,
toggleShowEffects,
toggleDarkMode,
undo,
redo,
}
}
export function hasSavedGame() {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (!saved) return false
const parsed = JSON.parse(saved)
return !parsed.isComplete
} catch {
return false
}
}

10
src/main.jsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './App.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

129
src/utils/sudoku.js Normal file
View File

@ -0,0 +1,129 @@
const EMPTY = 0
function shuffle(arr) {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
function createEmptyGrid() {
return Array.from({ length: 9 }, () => Array(9).fill(EMPTY))
}
function isValid(grid, row, col, num) {
for (let i = 0; i < 9; i++) {
if (grid[row][i] === num) return false
if (grid[i][col] === num) return false
}
const boxRow = Math.floor(row / 3) * 3
const boxCol = Math.floor(col / 3) * 3
for (let r = boxRow; r < boxRow + 3; r++) {
for (let c = boxCol; c < boxCol + 3; c++) {
if (grid[r][c] === num) return false
}
}
return true
}
function fillGrid(grid) {
for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) {
if (grid[row][col] === EMPTY) {
const candidates = shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9])
for (const num of candidates) {
if (isValid(grid, row, col, num)) {
grid[row][col] = num
if (fillGrid(grid)) return true
grid[row][col] = EMPTY
}
}
return false
}
}
}
return true
}
function countSolutions(grid, limit = 2) {
let count = 0
function solve(g) {
if (count >= limit) return
for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) {
if (g[row][col] === EMPTY) {
for (let num = 1; num <= 9; num++) {
if (isValid(g, row, col, num)) {
g[row][col] = num
solve(g)
g[row][col] = EMPTY
}
if (count >= limit) return
}
return
}
}
}
count++
}
solve(grid)
return count
}
function cloneGrid(grid) {
return grid.map(row => [...row])
}
const BLANKS_BY_DIFFICULTY = {
'molt-facil': 30,
'facil': 40,
'mitja': 46,
'dificil': 52,
'expert': 58,
}
export function generatePuzzle(difficulty = 'mitja') {
const solution = createEmptyGrid()
fillGrid(solution)
const puzzle = cloneGrid(solution)
const target = BLANKS_BY_DIFFICULTY[difficulty] || 46
const positions = shuffle(
Array.from({ length: 81 }, (_, i) => [Math.floor(i / 9), i % 9])
)
let removed = 0
for (const [row, col] of positions) {
if (removed >= target) break
const backup = puzzle[row][col]
puzzle[row][col] = EMPTY
const testGrid = cloneGrid(puzzle)
if (countSolutions(testGrid, 2) !== 1) {
puzzle[row][col] = backup
} else {
removed++
}
}
return { puzzle, solution }
}
export function checkValue(solution, row, col, value) {
return solution[row][col] === value
}
export function isBoardComplete(board, solution) {
for (let r = 0; r < 9; r++) {
for (let c = 0; c < 9; c++) {
if (board[r][c] !== solution[r][c]) return false
}
}
return true
}

7
vite.config.js Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/sudoku/',
})