Sudoku / script.js
nayohan's picture
Update space
596f7cb
Raw History Blame Contribute Delete
26.3 kB
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}열`;
}
});