Add secret auto-solver unlocked via Konami code
Logical-only solver (naked + hidden singles, no backtracking) fills one cell every ~2-3s with a blink animation, so progress is only made when the deduction is certain. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2243abbb22
commit
b2cc21a354
16
src/App.css
16
src/App.css
@ -201,6 +201,22 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Auto-solve blink animation */
|
||||||
|
.cell.auto-blinking .cell-value {
|
||||||
|
animation: autoFillBlink 0.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes autoFillBlink {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
text-shadow: 0 0 10px rgba(255, 152, 0, 0.7);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.25;
|
||||||
|
text-shadow: 0 0 0 transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes celebrateText {
|
@keyframes celebrateText {
|
||||||
0% {
|
0% {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
|
|||||||
75
src/App.jsx
75
src/App.jsx
@ -7,6 +7,7 @@ import WinModal from './components/WinModal'
|
|||||||
import Confetti from './components/Confetti'
|
import Confetti from './components/Confetti'
|
||||||
import SettingsModal from './components/SettingsModal'
|
import SettingsModal from './components/SettingsModal'
|
||||||
import { useGame } from './hooks/useGame'
|
import { useGame } from './hooks/useGame'
|
||||||
|
import { findCertainMove } from './utils/sudoku'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const {
|
const {
|
||||||
@ -23,6 +24,7 @@ function App() {
|
|||||||
toggleDarkMode,
|
toggleDarkMode,
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
|
autoFill,
|
||||||
} = useGame()
|
} = useGame()
|
||||||
|
|
||||||
const [showSettings, setShowSettings] = useState(false)
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
@ -30,6 +32,10 @@ function App() {
|
|||||||
const saved = parseInt(localStorage.getItem('sudoku-hue'), 10)
|
const saved = parseInt(localStorage.getItem('sudoku-hue'), 10)
|
||||||
return isNaN(saved) ? 211 : saved
|
return isNaN(saved) ? 211 : saved
|
||||||
})
|
})
|
||||||
|
const [solveUnlocked, setSolveUnlocked] = useState(false)
|
||||||
|
const [autoSolving, setAutoSolving] = useState(false)
|
||||||
|
const [autoBlinkCell, setAutoBlinkCell] = useState(null)
|
||||||
|
const [autoCooldown, setAutoCooldown] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.background = state.darkMode ? '#1a1a2e' : '#f8f9fa'
|
document.body.style.background = state.darkMode ? '#1a1a2e' : '#f8f9fa'
|
||||||
@ -50,6 +56,71 @@ function App() {
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (solveUnlocked) return
|
||||||
|
const sequence = [
|
||||||
|
'ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown',
|
||||||
|
'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight',
|
||||||
|
'b', 'a',
|
||||||
|
]
|
||||||
|
let progress = 0
|
||||||
|
function onKeyDown(e) {
|
||||||
|
const expected = sequence[progress]
|
||||||
|
const key = expected.length === 1 ? e.key.toLowerCase() : e.key
|
||||||
|
if (key === expected) {
|
||||||
|
progress++
|
||||||
|
if (progress === sequence.length) {
|
||||||
|
setSolveUnlocked(true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
progress = key === sequence[0] ? 1 : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
|
}, [solveUnlocked])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoSolving) {
|
||||||
|
setAutoBlinkCell(null)
|
||||||
|
setAutoCooldown(false)
|
||||||
|
}
|
||||||
|
}, [autoSolving])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoSolving) return
|
||||||
|
if (state.isComplete) {
|
||||||
|
setAutoSolving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (autoBlinkCell || autoCooldown) return
|
||||||
|
|
||||||
|
const move = findCertainMove(state.board)
|
||||||
|
if (!move) {
|
||||||
|
setAutoSolving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
autoFill(move.row, move.col, move.value)
|
||||||
|
setAutoBlinkCell({ row: move.row, col: move.col })
|
||||||
|
}, [autoSolving, state.board, state.isComplete, autoBlinkCell, autoCooldown, autoFill])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoBlinkCell) return
|
||||||
|
const blinkMs = 1000 + Math.random() * 1000
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setAutoBlinkCell(null)
|
||||||
|
setAutoCooldown(true)
|
||||||
|
}, blinkMs)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [autoBlinkCell])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoCooldown) return
|
||||||
|
const cooldownMs = 1000 + Math.random() * 1000
|
||||||
|
const t = setTimeout(() => setAutoCooldown(false), cooldownMs)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [autoCooldown])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`app${state.darkMode ? ' dark' : ''}`} style={{ '--hue': hue }}>
|
<div className={`app${state.darkMode ? ' dark' : ''}`} style={{ '--hue': hue }}>
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
@ -73,6 +144,7 @@ function App() {
|
|||||||
highlightedNumber={state.highlightedNumber}
|
highlightedNumber={state.highlightedNumber}
|
||||||
showErrors={state.showErrors}
|
showErrors={state.showErrors}
|
||||||
celebrating={state.showEffects ? state.celebrating : new Set()}
|
celebrating={state.showEffects ? state.celebrating : new Set()}
|
||||||
|
autoBlinkCell={autoBlinkCell}
|
||||||
selectCell={selectCell}
|
selectCell={selectCell}
|
||||||
deselect={deselect}
|
deselect={deselect}
|
||||||
/>
|
/>
|
||||||
@ -92,6 +164,9 @@ function App() {
|
|||||||
canUndo={state.history.length > 0}
|
canUndo={state.history.length > 0}
|
||||||
canRedo={state.future.length > 0}
|
canRedo={state.future.length > 0}
|
||||||
onOpenSettings={() => setShowSettings(true)}
|
onOpenSettings={() => setShowSettings(true)}
|
||||||
|
solveUnlocked={solveUnlocked}
|
||||||
|
autoSolving={autoSolving}
|
||||||
|
onToggleAutoSolve={() => setAutoSolving(s => !s)}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React from 'react'
|
|||||||
import Cell from './Cell'
|
import Cell from './Cell'
|
||||||
import { checkValue } from '../utils/sudoku'
|
import { checkValue } from '../utils/sudoku'
|
||||||
|
|
||||||
function Board({ board, puzzle, solution, notes, selected, highlightedNumber, showErrors, celebrating, selectCell, deselect }) {
|
function Board({ board, puzzle, solution, notes, selected, highlightedNumber, showErrors, celebrating, autoBlinkCell, selectCell, deselect }) {
|
||||||
const selectedValue = selected && board[selected.row][selected.col]
|
const selectedValue = selected && board[selected.row][selected.col]
|
||||||
const activeNumber = selected ? selectedValue : highlightedNumber
|
const activeNumber = selected ? selectedValue : highlightedNumber
|
||||||
|
|
||||||
@ -31,6 +31,8 @@ function Board({ board, puzzle, solution, notes, selected, highlightedNumber, sh
|
|||||||
value !== 0 &&
|
value !== 0 &&
|
||||||
!checkValue(solution, r, c, value)
|
!checkValue(solution, r, c, value)
|
||||||
const isCelebrating = celebrating.has(`${r},${c}`)
|
const isCelebrating = celebrating.has(`${r},${c}`)
|
||||||
|
const isAutoBlinking =
|
||||||
|
autoBlinkCell && autoBlinkCell.row === r && autoBlinkCell.col === c
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Cell
|
<Cell
|
||||||
@ -45,6 +47,7 @@ function Board({ board, puzzle, solution, notes, selected, highlightedNumber, sh
|
|||||||
isSameNumber={isSameNumber}
|
isSameNumber={isSameNumber}
|
||||||
isError={isError}
|
isError={isError}
|
||||||
isCelebrating={isCelebrating}
|
isCelebrating={isCelebrating}
|
||||||
|
isAutoBlinking={isAutoBlinking}
|
||||||
onClick={(e) => e.ctrlKey || e.metaKey ? deselect() : selectCell(r, c)}
|
onClick={(e) => e.ctrlKey || e.metaKey ? deselect() : selectCell(r, c)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
function Cell({ value, notes, isGiven, isSelected, isHighlightedRow, isHighlightedCol, isHighlightedBox, isSameNumber, isError, isCelebrating, onClick }) {
|
function Cell({ value, notes, isGiven, isSelected, isHighlightedRow, isHighlightedCol, isHighlightedBox, isSameNumber, isError, isCelebrating, isAutoBlinking, onClick }) {
|
||||||
const classes = ['cell']
|
const classes = ['cell']
|
||||||
if (isGiven) classes.push('given')
|
if (isGiven) classes.push('given')
|
||||||
if (isSelected) classes.push('selected')
|
if (isSelected) classes.push('selected')
|
||||||
@ -10,6 +10,7 @@ function Cell({ value, notes, isGiven, isSelected, isHighlightedRow, isHighlight
|
|||||||
if (isSameNumber) classes.push('same-number')
|
if (isSameNumber) classes.push('same-number')
|
||||||
if (isError) classes.push('error')
|
if (isError) classes.push('error')
|
||||||
if (isCelebrating) classes.push('celebrating')
|
if (isCelebrating) classes.push('celebrating')
|
||||||
|
if (isAutoBlinking) classes.push('auto-blinking')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.join(' ')} onClick={onClick}>
|
<div className={classes.join(' ')} onClick={onClick}>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ const DIFFICULTIES = [
|
|||||||
{ value: 'expert', label: 'Expert' },
|
{ value: 'expert', label: 'Expert' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function GameControls({ currentDifficulty, onNewGame, onClear, onUndo, onRedo, canUndo, canRedo, onOpenSettings }) {
|
function GameControls({ currentDifficulty, onNewGame, onClear, onUndo, onRedo, canUndo, canRedo, onOpenSettings, solveUnlocked, autoSolving, onToggleAutoSolve }) {
|
||||||
const [difficulty, setDifficulty] = useState(currentDifficulty)
|
const [difficulty, setDifficulty] = useState(currentDifficulty)
|
||||||
const [showConfirm, setShowConfirm] = useState(false)
|
const [showConfirm, setShowConfirm] = useState(false)
|
||||||
|
|
||||||
@ -32,6 +32,11 @@ function GameControls({ currentDifficulty, onNewGame, onClear, onUndo, onRedo, c
|
|||||||
<button className="btn btn-primary" onClick={() => setShowConfirm(true)}>
|
<button className="btn btn-primary" onClick={() => setShowConfirm(true)}>
|
||||||
Nova Partida
|
Nova Partida
|
||||||
</button>
|
</button>
|
||||||
|
{solveUnlocked && (
|
||||||
|
<button className="btn btn-secondary" onClick={onToggleAutoSolve}>
|
||||||
|
{autoSolving ? 'Atura' : 'Resol'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="controls-row">
|
<div className="controls-row">
|
||||||
<button className="btn btn-secondary" onClick={onClear} title="Esborra">
|
<button className="btn btn-secondary" onClick={onClear} title="Esborra">
|
||||||
|
|||||||
@ -243,6 +243,40 @@ function reducer(state, action) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'AUTO_FILL': {
|
||||||
|
if (state.isComplete) return state
|
||||||
|
const { row, col, value } = action
|
||||||
|
if (state.puzzle[row][col] !== 0) return state
|
||||||
|
if (state.board[row][col] !== 0) return state
|
||||||
|
|
||||||
|
const histUpdate = pushHistory(state)
|
||||||
|
const newBoard = cloneBoard(state.board)
|
||||||
|
const newNotes = cloneNotes(state.notes)
|
||||||
|
newBoard[row][col] = value
|
||||||
|
newNotes[row][col] = new Set()
|
||||||
|
|
||||||
|
const complete = isBoardComplete(newBoard, state.solution)
|
||||||
|
|
||||||
|
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,
|
||||||
|
selected: { row, col },
|
||||||
|
board: newBoard,
|
||||||
|
notes: newNotes,
|
||||||
|
isComplete: complete,
|
||||||
|
isRunning: !complete,
|
||||||
|
celebrating,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case 'CLEAR_CELL': {
|
case 'CLEAR_CELL': {
|
||||||
if (!state.selected || state.isComplete) return state
|
if (!state.selected || state.isComplete) return state
|
||||||
const { row, col } = state.selected
|
const { row, col } = state.selected
|
||||||
@ -390,6 +424,10 @@ export function useGame() {
|
|||||||
dispatch({ type: 'REDO' })
|
dispatch({ type: 'REDO' })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const autoFill = useCallback((row, col, value) => {
|
||||||
|
dispatch({ type: 'AUTO_FILL', row, col, value })
|
||||||
|
}, [])
|
||||||
|
|
||||||
const moveSelection = useCallback((dr, dc) => {
|
const moveSelection = useCallback((dr, dc) => {
|
||||||
dispatch({ type: 'MOVE_SELECTION', dr, dc })
|
dispatch({ type: 'MOVE_SELECTION', dr, dc })
|
||||||
}, [])
|
}, [])
|
||||||
@ -481,6 +519,7 @@ export function useGame() {
|
|||||||
toggleDarkMode,
|
toggleDarkMode,
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
|
autoFill,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -127,3 +127,83 @@ export function isBoardComplete(board, solution) {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findCertainMove(board) {
|
||||||
|
const candidates = Array.from({ length: 9 }, () =>
|
||||||
|
Array.from({ length: 9 }, () => null)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (let r = 0; r < 9; r++) {
|
||||||
|
for (let c = 0; c < 9; c++) {
|
||||||
|
if (board[r][c] !== 0) continue
|
||||||
|
const cands = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9])
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
cands.delete(board[r][i])
|
||||||
|
cands.delete(board[i][c])
|
||||||
|
}
|
||||||
|
const br = Math.floor(r / 3) * 3
|
||||||
|
const bc = Math.floor(c / 3) * 3
|
||||||
|
for (let rr = br; rr < br + 3; rr++) {
|
||||||
|
for (let cc = bc; cc < bc + 3; cc++) {
|
||||||
|
cands.delete(board[rr][cc])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates[r][c] = cands
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let r = 0; r < 9; r++) {
|
||||||
|
for (let c = 0; c < 9; c++) {
|
||||||
|
const cands = candidates[r][c]
|
||||||
|
if (cands && cands.size === 1) {
|
||||||
|
return { row: r, col: c, value: [...cands][0] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let r = 0; r < 9; r++) {
|
||||||
|
for (let n = 1; n <= 9; n++) {
|
||||||
|
let count = 0, lastCol = -1
|
||||||
|
for (let c = 0; c < 9; c++) {
|
||||||
|
if (candidates[r][c] && candidates[r][c].has(n)) {
|
||||||
|
count++
|
||||||
|
lastCol = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count === 1) return { row: r, col: lastCol, value: n }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let c = 0; c < 9; c++) {
|
||||||
|
for (let n = 1; n <= 9; n++) {
|
||||||
|
let count = 0, lastRow = -1
|
||||||
|
for (let r = 0; r < 9; r++) {
|
||||||
|
if (candidates[r][c] && candidates[r][c].has(n)) {
|
||||||
|
count++
|
||||||
|
lastRow = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count === 1) return { row: lastRow, col: c, value: n }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let br = 0; br < 3; br++) {
|
||||||
|
for (let bc = 0; bc < 3; bc++) {
|
||||||
|
for (let n = 1; n <= 9; n++) {
|
||||||
|
let count = 0, lastR = -1, lastC = -1
|
||||||
|
for (let r = br * 3; r < br * 3 + 3; r++) {
|
||||||
|
for (let c = bc * 3; c < bc * 3 + 3; c++) {
|
||||||
|
if (candidates[r][c] && candidates[r][c].has(n)) {
|
||||||
|
count++
|
||||||
|
lastR = r
|
||||||
|
lastC = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count === 1) return { row: lastR, col: lastC, value: n }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user