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:
commit
5c07425d1d
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
71
deploy.sh
Executable file
71
deploy.sh
Executable 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
12
index.html
Normal 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
1796
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal 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
843
src/App.css
Normal 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
100
src/App.jsx
Normal 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
55
src/components/Board.jsx
Normal 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
29
src/components/Cell.jsx
Normal 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)
|
||||||
87
src/components/Confetti.jsx
Normal file
87
src/components/Confetti.jsx
Normal 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
|
||||||
87
src/components/GameControls.jsx
Normal file
87
src/components/GameControls.jsx
Normal 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)
|
||||||
27
src/components/NumberPad.jsx
Normal file
27
src/components/NumberPad.jsx
Normal 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)
|
||||||
33
src/components/ScoreBoard.jsx
Normal file
33
src/components/ScoreBoard.jsx
Normal 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)
|
||||||
45
src/components/SettingsModal.jsx
Normal file
45
src/components/SettingsModal.jsx
Normal 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ó</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
13
src/components/Timer.jsx
Normal 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)
|
||||||
40
src/components/WinModal.jsx
Normal file
40
src/components/WinModal.jsx
Normal 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
476
src/hooks/useGame.js
Normal 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
10
src/main.jsx
Normal 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
129
src/utils/sudoku.js
Normal 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
7
vite.config.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: '/sudoku/',
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user