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:
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user