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 {
|
||||
0% {
|
||||
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 SettingsModal from './components/SettingsModal'
|
||||
import { useGame } from './hooks/useGame'
|
||||
import { findCertainMove } from './utils/sudoku'
|
||||
|
||||
function App() {
|
||||
const {
|
||||
@ -23,6 +24,7 @@ function App() {
|
||||
toggleDarkMode,
|
||||
undo,
|
||||
redo,
|
||||
autoFill,
|
||||
} = useGame()
|
||||
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
@ -30,6 +32,10 @@ function App() {
|
||||
const saved = parseInt(localStorage.getItem('sudoku-hue'), 10)
|
||||
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(() => {
|
||||
document.body.style.background = state.darkMode ? '#1a1a2e' : '#f8f9fa'
|
||||
@ -50,6 +56,71 @@ function App() {
|
||||
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 (
|
||||
<div className={`app${state.darkMode ? ' dark' : ''}`} style={{ '--hue': hue }}>
|
||||
<header className="app-header">
|
||||
@ -73,6 +144,7 @@ function App() {
|
||||
highlightedNumber={state.highlightedNumber}
|
||||
showErrors={state.showErrors}
|
||||
celebrating={state.showEffects ? state.celebrating : new Set()}
|
||||
autoBlinkCell={autoBlinkCell}
|
||||
selectCell={selectCell}
|
||||
deselect={deselect}
|
||||
/>
|
||||
@ -92,6 +164,9 @@ function App() {
|
||||
canUndo={state.history.length > 0}
|
||||
canRedo={state.future.length > 0}
|
||||
onOpenSettings={() => setShowSettings(true)}
|
||||
solveUnlocked={solveUnlocked}
|
||||
autoSolving={autoSolving}
|
||||
onToggleAutoSolve={() => setAutoSolving(s => !s)}
|
||||
/>
|
||||
</main>
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import React from 'react'
|
||||
import Cell from './Cell'
|
||||
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 activeNumber = selected ? selectedValue : highlightedNumber
|
||||
|
||||
@ -31,6 +31,8 @@ function Board({ board, puzzle, solution, notes, selected, highlightedNumber, sh
|
||||
value !== 0 &&
|
||||
!checkValue(solution, r, c, value)
|
||||
const isCelebrating = celebrating.has(`${r},${c}`)
|
||||
const isAutoBlinking =
|
||||
autoBlinkCell && autoBlinkCell.row === r && autoBlinkCell.col === c
|
||||
|
||||
return (
|
||||
<Cell
|
||||
@ -45,6 +47,7 @@ function Board({ board, puzzle, solution, notes, selected, highlightedNumber, sh
|
||||
isSameNumber={isSameNumber}
|
||||
isError={isError}
|
||||
isCelebrating={isCelebrating}
|
||||
isAutoBlinking={isAutoBlinking}
|
||||
onClick={(e) => e.ctrlKey || e.metaKey ? deselect() : selectCell(r, c)}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
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']
|
||||
if (isGiven) classes.push('given')
|
||||
if (isSelected) classes.push('selected')
|
||||
@ -10,6 +10,7 @@ function Cell({ value, notes, isGiven, isSelected, isHighlightedRow, isHighlight
|
||||
if (isSameNumber) classes.push('same-number')
|
||||
if (isError) classes.push('error')
|
||||
if (isCelebrating) classes.push('celebrating')
|
||||
if (isAutoBlinking) classes.push('auto-blinking')
|
||||
|
||||
return (
|
||||
<div className={classes.join(' ')} onClick={onClick}>
|
||||
|
||||
@ -8,7 +8,7 @@ const DIFFICULTIES = [
|
||||
{ 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 [showConfirm, setShowConfirm] = useState(false)
|
||||
|
||||
@ -32,6 +32,11 @@ function GameControls({ currentDifficulty, onNewGame, onClear, onUndo, onRedo, c
|
||||
<button className="btn btn-primary" onClick={() => setShowConfirm(true)}>
|
||||
Nova Partida
|
||||
</button>
|
||||
{solveUnlocked && (
|
||||
<button className="btn btn-secondary" onClick={onToggleAutoSolve}>
|
||||
{autoSolving ? 'Atura' : 'Resol'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="controls-row">
|
||||
<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': {
|
||||
if (!state.selected || state.isComplete) return state
|
||||
const { row, col } = state.selected
|
||||
@ -390,6 +424,10 @@ export function useGame() {
|
||||
dispatch({ type: 'REDO' })
|
||||
}, [])
|
||||
|
||||
const autoFill = useCallback((row, col, value) => {
|
||||
dispatch({ type: 'AUTO_FILL', row, col, value })
|
||||
}, [])
|
||||
|
||||
const moveSelection = useCallback((dr, dc) => {
|
||||
dispatch({ type: 'MOVE_SELECTION', dr, dc })
|
||||
}, [])
|
||||
@ -481,6 +519,7 @@ export function useGame() {
|
||||
toggleDarkMode,
|
||||
undo,
|
||||
redo,
|
||||
autoFill,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -127,3 +127,83 @@ export function isBoardComplete(board, solution) {
|
||||
}
|
||||
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