document.addEventListener('DOMContentLoaded', () => { const BASE_PUZZLE = '530070000600195000098000060800060003400803001700020006060000280000419005000080079'; const TECHNIQUE_LABELS = { 'naked-single': '단일 후보', 'hidden-single-row': '행 숨은 싱글', 'hidden-single-col': '열 숨은 싱글', 'hidden-single-box': '박스 숨은 싱글' }; const dom = { board: document.getElementById('sudoku-board'), newGameBtn: document.getElementById('new-game-btn'), hintBtn: document.getElementById('hint-btn'), applyHintBtn: document.getElementById('apply-hint-btn'), checkBtn: document.getElementById('check-btn'), resetBtn: document.getElementById('reset-btn'), hintMessage: document.getElementById('hint-message'), gameStatus: document.getElementById('game-status'), puzzleMeta: document.getElementById('puzzle-meta'), difficultyBadge: document.getElementById('difficulty-badge'), filledCount: document.getElementById('filled-count'), remainingCount: document.getElementById('remaining-count'), nextTechnique: document.getElementById('next-technique'), analysisSummary: document.getElementById('analysis-summary'), logList: document.getElementById('log-list') }; const state = { board: [], initial: [], solution: [], analysis: null, pendingHint: null, selected: null, showMistakes: false, hintMessage: '힌트 보기를 누르면 다음 논리 단계를 설명합니다.', gameCount: 0, learningLog: [], cells: [] }; createBoard(); bindEvents(); startNewGame(); function bindEvents() { dom.newGameBtn.addEventListener('click', startNewGame); dom.hintBtn.addEventListener('click', requestHint); dom.applyHintBtn.addEventListener('click', applyHint); dom.checkBtn.addEventListener('click', toggleMistakes); dom.resetBtn.addEventListener('click', resetBoard); } function createBoard() { const fragment = document.createDocumentFragment(); for (let row = 0; row < 9; row += 1) { const rowCells = []; for (let col = 0; col < 9; col += 1) { const input = document.createElement('input'); input.type = 'text'; input.className = 'cell'; input.maxLength = 1; input.inputMode = 'numeric'; input.autocomplete = 'off'; input.pattern = '[1-9]'; input.spellcheck = false; input.setAttribute('aria-describedby', 'sudoku-help'); input.dataset.row = String(row); input.dataset.col = String(col); input.setAttribute('role', 'gridcell'); input.setAttribute('aria-rowindex', String(row + 1)); input.setAttribute('aria-colindex', String(col + 1)); if (col === 2 || col === 5) { input.classList.add('edge-right'); } if (row === 2 || row === 5) { input.classList.add('edge-bottom'); } input.addEventListener('focus', onCellFocus); input.addEventListener('click', onCellFocus); input.addEventListener('input', onCellInput); input.addEventListener('keydown', onCellKeyDown); rowCells.push(input); fragment.appendChild(input); } state.cells.push(rowCells); } dom.board.appendChild(fragment); } function startNewGame() { const initial = generatePuzzle(); const solution = solveBoard(initial); if (!solution) { setStatus('퍼즐 생성에 실패했습니다. 새로고침 후 다시 시도하세요.'); return; } state.initial = cloneBoard(initial); state.board = cloneBoard(initial); state.solution = solution; state.analysis = analyzePuzzle(initial); state.pendingHint = null; state.selected = findFirstEmpty(initial); state.showMistakes = false; state.hintMessage = '힌트 보기를 누르면 다음 논리 단계를 설명합니다.'; state.gameCount += 1; state.learningLog = [ { kind: '분석', message: describeAnalysis(state.analysis) } ]; dom.checkBtn.textContent = '오답 확인'; setStatus('새 퍼즐을 불러왔습니다.'); render(); focusSelectedCell(); } function resetBoard() { state.board = cloneBoard(state.initial); state.pendingHint = null; state.showMistakes = false; state.selected = findFirstEmpty(state.initial); state.hintMessage = '퍼즐을 초기 상태로 되돌렸습니다. 다시 논리로 풀어보세요.'; state.learningLog.unshift({ kind: '초기화', message: '입력한 값을 지우고 시작 상태로 돌아갔습니다.' }); state.learningLog = state.learningLog.slice(0, 8); dom.checkBtn.textContent = '오답 확인'; setStatus('퍼즐을 초기화했습니다.'); render(); focusSelectedCell(); } function requestHint() { const conflicts = collectConflicts(state.board); const wrongCells = collectWrongCells(state.board, state.solution); if (conflicts.size > 0) { state.pendingHint = null; state.hintMessage = '같은 행, 열 또는 박스에 중복된 숫자가 있습니다. 충돌을 먼저 정리하세요.'; setStatus('규칙 충돌이 있어 힌트를 중단했습니다.'); render(); return; } if (wrongCells.length > 0) { state.pendingHint = null; state.showMistakes = true; dom.checkBtn.textContent = '오답 숨기기'; state.hintMessage = '정답과 다른 칸이 있어 힌트를 멈췄습니다. 붉은 표시를 먼저 고쳐보세요.'; setStatus('오답이 있어 힌트를 중단했습니다.'); render(); return; } if (isSolved(state.board)) { state.pendingHint = null; state.hintMessage = '이미 퍼즐을 모두 해결했습니다. 새 퍼즐을 시작할 수 있습니다.'; setStatus('퍼즐이 이미 완성되어 있습니다.'); render(); return; } const step = findNextLogicalStep(state.board); if (!step) { state.pendingHint = null; state.hintMessage = '현재 MVP 규칙(단일 후보, 숨은 싱글)로는 다음 단계를 찾지 못했습니다.'; setStatus('추가 규칙이 필요한 구간입니다.'); render(); return; } state.pendingHint = step; state.selected = { row: step.row, col: step.col }; state.hintMessage = `${TECHNIQUE_LABELS[step.type]}: ${step.detail}`; recordLog('힌트', `${cellRef(step.row, step.col)} = ${step.value} · ${TECHNIQUE_LABELS[step.type]}`); setStatus('다음 논리 단계를 찾았습니다.'); render(); focusSelectedCell(); } function applyHint() { if (!state.pendingHint) { requestHint(); return; } const { row, col, value, type } = state.pendingHint; state.board[row][col] = value; state.selected = { row, col }; state.hintMessage = `${cellRef(row, col)}에 ${value}를 넣었습니다. 다음 단계가 필요하면 다시 힌트를 요청하세요.`; recordLog('적용', `${cellRef(row, col)} = ${value} · ${TECHNIQUE_LABELS[type]}`); state.pendingHint = null; if (boardsEqual(state.board, state.solution)) { state.hintMessage = '퍼즐을 해결했습니다. 새 퍼즐로 다시 시작할 수 있습니다.'; setStatus('완성했습니다. 모든 칸이 맞습니다.'); recordLog('완료', '모든 칸을 올바르게 채웠습니다.'); } else { setStatus('힌트를 적용했습니다.'); } render(); focusSelectedCell(); } function toggleMistakes() { state.showMistakes = !state.showMistakes; dom.checkBtn.textContent = state.showMistakes ? '오답 숨기기' : '오답 확인'; setStatus(state.showMistakes ? '오답 표시를 켰습니다.' : '오답 표시를 껐습니다.'); render(); } function onCellFocus(event) { const row = Number(event.target.dataset.row); const col = Number(event.target.dataset.col); state.selected = { row, col }; renderBoard(); } function onCellInput(event) { const input = event.target; const row = Number(input.dataset.row); const col = Number(input.dataset.col); const previousValue = state.board[row][col]; if (state.initial[row][col] !== 0) { input.value = String(state.initial[row][col]); return; } const raw = input.value.replace(/[^1-9]/g, '').slice(-1); input.value = raw; state.board[row][col] = raw ? Number(raw) : 0; state.pendingHint = null; state.selected = { row, col }; const conflicts = collectConflicts(state.board); const wrongCells = collectWrongCells(state.board, state.solution); if (boardsEqual(state.board, state.solution)) { state.hintMessage = '퍼즐을 해결했습니다. 새 퍼즐로 다시 시작할 수 있습니다.'; setStatus('완성했습니다. 모든 칸이 맞습니다.'); recordLog('완료', '모든 칸을 올바르게 채웠습니다.'); } else if (!raw && previousValue !== 0) { setStatus('칸을 비웠습니다.'); } else if (!raw) { setStatus('1부터 9까지의 숫자만 입력할 수 있습니다.'); } else if (conflicts.size > 0) { setStatus('규칙 충돌이 있습니다.'); } else if (wrongCells.length > 0 && countFilled(state.board) === 81) { setStatus('오답이 있습니다. 오답 확인으로 점검하세요.'); } else { const filled = countFilled(state.board); setStatus(`${filled}칸을 채웠습니다.`); } render(); } function onCellKeyDown(event) { const row = Number(event.target.dataset.row); const col = Number(event.target.dataset.col); let nextRow = row; let nextCol = col; if (event.key === 'ArrowUp') { nextRow = Math.max(0, row - 1); } else if (event.key === 'ArrowDown') { nextRow = Math.min(8, row + 1); } else if (event.key === 'ArrowLeft') { nextCol = Math.max(0, col - 1); } else if (event.key === 'ArrowRight') { nextCol = Math.min(8, col + 1); } else if (event.key === 'Backspace' || event.key === 'Delete' || event.key === 'Escape' || event.key === '0' || event.key === ' ') { if (state.initial[row][col] === 0) { state.board[row][col] = 0; state.pendingHint = null; state.selected = { row, col }; setStatus('칸을 비웠습니다.'); render(); } event.preventDefault(); return; } else { return; } event.preventDefault(); state.selected = { row: nextRow, col: nextCol }; focusSelectedCell(); renderBoard(); } function render() { renderBoard(); renderSidebar(); updateActionButtons(); } function updateActionButtons() { const isComplete = boardsEqual(state.board, state.solution); dom.hintBtn.disabled = isComplete; dom.applyHintBtn.disabled = !state.pendingHint || isComplete; dom.resetBtn.disabled = boardsEqual(state.board, state.initial); dom.checkBtn.textContent = state.showMistakes ? '오답 숨기기' : '오답 확인'; dom.checkBtn.setAttribute('aria-pressed', String(state.showMistakes)); } function renderBoard() { const conflicts = collectConflicts(state.board); const wrongCells = state.showMistakes ? new Set(collectWrongCells(state.board, state.solution)) : new Set(); for (let row = 0; row < 9; row += 1) { for (let col = 0; col < 9; col += 1) { const input = state.cells[row][col]; const value = state.board[row][col]; const key = toKey(row, col); const isGiven = state.initial[row][col] !== 0; const isSelected = Boolean(state.selected && state.selected.row === row && state.selected.col === col); const isHint = Boolean(state.pendingHint && state.pendingHint.row === row && state.pendingHint.col === col); const hasConflict = conflicts.has(key); const hasMistake = wrongCells.has(key); input.value = value === 0 ? '' : String(value); input.readOnly = isGiven; input.classList.toggle('is-given', isGiven); input.classList.toggle('is-selected', isSelected); input.classList.toggle('is-hint', isHint); input.classList.toggle('is-conflict', hasConflict); input.classList.toggle('is-mistake', hasMistake); input.setAttribute('aria-label', buildCellLabel(row, col, value, isGiven)); input.setAttribute('aria-selected', String(isSelected)); input.setAttribute('aria-invalid', String(hasConflict || hasMistake)); input.setAttribute('aria-readonly', String(isGiven)); } } } function renderSidebar() { const filled = countFilled(state.board); const conflicts = collectConflicts(state.board); const wrongCells = collectWrongCells(state.board, state.solution); const isComplete = boardsEqual(state.board, state.solution); let nextTechniqueLabel = '대기 중'; if (isComplete) { nextTechniqueLabel = '완료'; } else if (conflicts.size > 0) { nextTechniqueLabel = '충돌 정리 필요'; } else if (wrongCells.length > 0) { nextTechniqueLabel = '오답 수정 필요'; } else { const nextStep = findNextLogicalStep(state.board); nextTechniqueLabel = nextStep ? TECHNIQUE_LABELS[nextStep.type] : '현재 규칙으로 찾지 못함'; } dom.puzzleMeta.textContent = `${state.gameCount}번째 퍼즐 · 랜덤 변형 학습 퍼즐`; dom.difficultyBadge.textContent = state.analysis.difficulty; dom.hintMessage.textContent = state.hintMessage; dom.filledCount.textContent = String(filled); dom.remainingCount.textContent = String(81 - filled); dom.nextTechnique.textContent = nextTechniqueLabel; dom.analysisSummary.textContent = describeAnalysis(state.analysis); dom.logList.innerHTML = ''; const items = state.learningLog.length > 0 ? state.learningLog : [{ kind: '안내', message: '힌트 로그가 여기에 표시됩니다.' }]; items.slice(0, 8).forEach((item) => { const li = document.createElement('li'); li.textContent = `${item.kind} · ${item.message}`; dom.logList.appendChild(li); }); } function setStatus(message) { dom.gameStatus.textContent = message; } function recordLog(kind, message) { const latest = `${kind}:${message}`; const exists = state.learningLog.some((entry) => `${entry.kind}:${entry.message}` === latest); if (!exists) { state.learningLog.unshift({ kind, message }); state.learningLog = state.learningLog.slice(0, 8); } } function buildCellLabel(row, col, value, isGiven) { if (isGiven) { return `${row + 1}행 ${col + 1}열, 고정 숫자 ${value}`; } if (value === 0) { return `${row + 1}행 ${col + 1}열, 빈칸`; } return `${row + 1}행 ${col + 1}열, 입력값 ${value}`; } function focusSelectedCell() { if (!state.selected) { return; } const cell = state.cells[state.selected.row][state.selected.col]; if (cell) { cell.focus(); } } function analyzePuzzle(board) { const solverResult = buildSolverLog(board); const counts = solverResult.steps.reduce((acc, step) => { acc[step.type] = (acc[step.type] || 0) + 1; return acc; }, {}); return { steps: solverResult.steps, solved: solverResult.solved, counts, difficulty: classifyDifficulty(solverResult.solved, counts) }; } function describeAnalysis(analysis) { const countSummary = formatTechniqueCounts(analysis.counts); if (analysis.solved) { return `${analysis.steps.length}단계 논리 풀이 · ${countSummary}`; } return `일부만 논리 풀이 가능 · ${countSummary}`; } function formatTechniqueCounts(counts) { const parts = []; if (counts['naked-single']) { parts.push(`단일 후보 ${counts['naked-single']}회`); } if (counts['hidden-single-row']) { parts.push(`행 숨은 싱글 ${counts['hidden-single-row']}회`); } if (counts['hidden-single-col']) { parts.push(`열 숨은 싱글 ${counts['hidden-single-col']}회`); } if (counts['hidden-single-box']) { parts.push(`박스 숨은 싱글 ${counts['hidden-single-box']}회`); } return parts.length > 0 ? parts.join(', ') : '기본 규칙 분석 없음'; } function classifyDifficulty(solved, counts) { const hiddenCount = (counts['hidden-single-row'] || 0) + (counts['hidden-single-col'] || 0) + (counts['hidden-single-box'] || 0); if (!solved) { return '연습'; } if (hiddenCount === 0) { return '입문'; } if (hiddenCount <= 8) { return '초급'; } return '중급'; } function buildSolverLog(board) { const working = cloneBoard(board); const steps = []; let guard = 0; while (guard < 100) { if (isSolved(working)) { return { solved: true, steps }; } const next = findNextLogicalStep(working); if (!next) { break; } working[next.row][next.col] = next.value; steps.push(next); guard += 1; } return { solved: isSolved(working), steps }; } function findNextLogicalStep(board) { const candidates = []; for (let row = 0; row < 9; row += 1) { candidates[row] = []; for (let col = 0; col < 9; col += 1) { candidates[row][col] = board[row][col] === 0 ? getCandidates(board, row, col) : []; } } for (let row = 0; row < 9; row += 1) { for (let col = 0; col < 9; col += 1) { if (board[row][col] !== 0) { continue; } if (candidates[row][col].length === 1) { const value = candidates[row][col][0]; return { type: 'naked-single', row, col, value, detail: `${cellRef(row, col)}의 후보는 ${value} 하나뿐입니다. 행, 열, 박스에서 다른 숫자가 모두 제외됩니다.` }; } } } for (let row = 0; row < 9; row += 1) { for (let value = 1; value <= 9; value += 1) { const spots = []; for (let col = 0; col < 9; col += 1) { if (candidates[row][col].includes(value)) { spots.push({ row, col }); } } if (spots.length === 1) { return { type: 'hidden-single-row', row: spots[0].row, col: spots[0].col, value, detail: `${row + 1}행에서 숫자 ${value}가 들어갈 수 있는 칸은 ${cellRef(spots[0].row, spots[0].col)} 하나뿐입니다.` }; } } } for (let col = 0; col < 9; col += 1) { for (let value = 1; value <= 9; value += 1) { const spots = []; for (let row = 0; row < 9; row += 1) { if (candidates[row][col].includes(value)) { spots.push({ row, col }); } } if (spots.length === 1) { return { type: 'hidden-single-col', row: spots[0].row, col: spots[0].col, value, detail: `${col + 1}열에서 숫자 ${value}가 들어갈 수 있는 칸은 ${cellRef(spots[0].row, spots[0].col)} 하나뿐입니다.` }; } } } for (let box = 0; box < 9; box += 1) { const startRow = Math.floor(box / 3) * 3; const startCol = (box % 3) * 3; for (let value = 1; value <= 9; value += 1) { const spots = []; for (let row = startRow; row < startRow + 3; row += 1) { for (let col = startCol; col < startCol + 3; col += 1) { if (candidates[row][col].includes(value)) { spots.push({ row, col }); } } } if (spots.length === 1) { return { type: 'hidden-single-box', row: spots[0].row, col: spots[0].col, value, detail: `${box + 1}번 박스에서 숫자 ${value}가 들어갈 수 있는 칸은 ${cellRef(spots[0].row, spots[0].col)} 하나뿐입니다.` }; } } } return null; } function generatePuzzle() { let board = parseBoard(BASE_PUZZLE); board = remapDigits(board); board = shuffleRows(board); board = shuffleCols(board); if (Math.random() > 0.5) { board = transpose(board); } return board; } function remapDigits(board) { const digits = shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9]); const map = new Map(); for (let i = 1; i <= 9; i += 1) { map.set(i, digits[i - 1]); } return board.map((row) => row.map((value) => (value === 0 ? 0 : map.get(value)))); } function shuffleRows(board) { const bands = shuffle([0, 1, 2]); const order = []; bands.forEach((band) => { shuffle([0, 1, 2]).forEach((offset) => order.push(band * 3 + offset)); }); return order.map((rowIndex) => board[rowIndex].slice()); } function shuffleCols(board) { const stacks = shuffle([0, 1, 2]); const order = []; stacks.forEach((stack) => { shuffle([0, 1, 2]).forEach((offset) => order.push(stack * 3 + offset)); }); return board.map((row) => order.map((colIndex) => row[colIndex])); } function transpose(board) { return board[0].map((_, col) => board.map((row) => row[col])); } function shuffle(values) { const copy = values.slice(); for (let i = copy.length - 1; i > 0; i -= 1) { const j = Math.floor(Math.random() * (i + 1)); [copy[i], copy[j]] = [copy[j], copy[i]]; } return copy; } function solveBoard(board) { const working = cloneBoard(board); return solveRecursive(working) ? working : null; } function solveRecursive(board) { let bestCell = null; let bestCandidates = null; for (let row = 0; row < 9; row += 1) { for (let col = 0; col < 9; col += 1) { if (board[row][col] !== 0) { continue; } const candidates = getCandidates(board, row, col); if (candidates.length === 0) { return false; } if (!bestCandidates || candidates.length < bestCandidates.length) { bestCell = { row, col }; bestCandidates = candidates; } } } if (!bestCell) { return true; } for (const value of bestCandidates) { board[bestCell.row][bestCell.col] = value; if (solveRecursive(board)) { return true; } } board[bestCell.row][bestCell.col] = 0; return false; } function getCandidates(board, row, col) { if (board[row][col] !== 0) { return []; } const candidates = []; for (let value = 1; value <= 9; value += 1) { if (isValidPlacement(board, row, col, value)) { candidates.push(value); } } return candidates; } function isValidPlacement(board, row, col, value) { for (let index = 0; index < 9; index += 1) { if (board[row][index] === value) { return false; } if (board[index][col] === value) { return false; } } const startRow = Math.floor(row / 3) * 3; const startCol = Math.floor(col / 3) * 3; for (let r = startRow; r < startRow + 3; r += 1) { for (let c = startCol; c < startCol + 3; c += 1) { if (board[r][c] === value) { return false; } } } return true; } function collectConflicts(board) { const conflicts = new Set(); for (let row = 0; row < 9; row += 1) { markHouseConflicts(conflicts, Array.from({ length: 9 }, (_, col) => ({ row, col, value: board[row][col] }))); } for (let col = 0; col < 9; col += 1) { markHouseConflicts(conflicts, Array.from({ length: 9 }, (_, row) => ({ row, col, value: board[row][col] }))); } for (let box = 0; box < 9; box += 1) { const startRow = Math.floor(box / 3) * 3; const startCol = (box % 3) * 3; const cells = []; for (let row = startRow; row < startRow + 3; row += 1) { for (let col = startCol; col < startCol + 3; col += 1) { cells.push({ row, col, value: board[row][col] }); } } markHouseConflicts(conflicts, cells); } return conflicts; } function markHouseConflicts(conflicts, cells) { const buckets = new Map(); cells.forEach((cell) => { if (cell.value === 0) { return; } if (!buckets.has(cell.value)) { buckets.set(cell.value, []); } buckets.get(cell.value).push(cell); }); buckets.forEach((items) => { if (items.length > 1) { items.forEach((cell) => conflicts.add(toKey(cell.row, cell.col))); } }); } function collectWrongCells(board, solution) { const wrong = []; for (let row = 0; row < 9; row += 1) { for (let col = 0; col < 9; col += 1) { if (board[row][col] !== 0 && board[row][col] !== solution[row][col]) { wrong.push(toKey(row, col)); } } } return wrong; } function findFirstEmpty(board) { for (let row = 0; row < 9; row += 1) { for (let col = 0; col < 9; col += 1) { if (board[row][col] === 0) { return { row, col }; } } } return { row: 0, col: 0 }; } function isSolved(board) { return board.every((row) => row.every((value) => value !== 0)); } function countFilled(board) { return board.flat().filter((value) => value !== 0).length; } function boardsEqual(a, b) { for (let row = 0; row < 9; row += 1) { for (let col = 0; col < 9; col += 1) { if (a[row][col] !== b[row][col]) { return false; } } } return true; } function cloneBoard(board) { return board.map((row) => row.slice()); } function parseBoard(serialized) { const board = []; for (let row = 0; row < 9; row += 1) { const currentRow = []; for (let col = 0; col < 9; col += 1) { currentRow.push(Number(serialized[row * 9 + col])); } board.push(currentRow); } return board; } function toKey(row, col) { return `${row}-${col}`; } function cellRef(row, col) { return `${row + 1}행 ${col + 1}열`; } });