536 lines
16 KiB
JavaScript
536 lines
16 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
const SIZE = 9;
|
|
const BOX = 3;
|
|
const DIFFICULTY_CLUES = { easy: 42, medium: 34, hard: 28, expert: 24 };
|
|
const DECORATIVE = [
|
|
[5,3,4,6,7,8,9,1,2],
|
|
[6,7,2,1,9,5,3,4,8],
|
|
[1,9,8,3,4,2,5,6,7],
|
|
[8,5,9,7,6,1,4,2,3],
|
|
[4,2,6,8,5,3,7,9,1],
|
|
[7,1,3,9,2,4,8,5,6],
|
|
[9,6,1,5,3,7,2,8,4],
|
|
[2,8,7,4,1,9,6,3,5],
|
|
[3,4,5,2,8,6,1,7,9],
|
|
];
|
|
|
|
const STATE = { IDLE: 'idle', RUNNING: 'running', PAUSED: 'paused', FINISHED: 'finished' };
|
|
|
|
let dom = {};
|
|
let callbacks = { onWin: null, onAbandon: null };
|
|
|
|
let state = STATE.IDLE;
|
|
let solution = [], puzzle = [], given = [], board = [], notes = [];
|
|
let selected = null;
|
|
let notesMode = false;
|
|
let history = [];
|
|
let mistakes = 0;
|
|
let timerSeconds = 0;
|
|
let timerHandle = null;
|
|
let currentDifficulty = 'medium';
|
|
|
|
// ---------- Sudoku generation ----------
|
|
|
|
function emptyGrid(){ return Array.from({length:SIZE}, () => Array(SIZE).fill(0)); }
|
|
|
|
function shuffled(arr){
|
|
const a = arr.slice();
|
|
for(let i=a.length-1;i>0;i--){
|
|
const j = Math.floor(Math.random()*(i+1));
|
|
[a[i],a[j]]=[a[j],a[i]];
|
|
}
|
|
return a;
|
|
}
|
|
|
|
function isValidPlacement(grid, row, col, val){
|
|
for(let i=0;i<SIZE;i++){
|
|
if(grid[row][i]===val) return false;
|
|
if(grid[i][col]===val) return false;
|
|
}
|
|
const br = row - row%BOX, bc = col - col%BOX;
|
|
for(let r=0;r<BOX;r++){
|
|
for(let c=0;c<BOX;c++){
|
|
if(grid[br+r][bc+c]===val) return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function fillGrid(grid){
|
|
for(let idx=0; idx<SIZE*SIZE; idx++){
|
|
const row = Math.floor(idx/SIZE), col = idx%SIZE;
|
|
if(grid[row][col]!==0) continue;
|
|
for(const val of shuffled([1,2,3,4,5,6,7,8,9])){
|
|
if(isValidPlacement(grid,row,col,val)){
|
|
grid[row][col]=val;
|
|
if(fillGrid(grid)) return true;
|
|
grid[row][col]=0;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function countSolutions(grid, limit){
|
|
let count = 0;
|
|
function backtrack(){
|
|
if(count>=limit) return;
|
|
let row=-1, col=-1;
|
|
outer:
|
|
for(let r=0;r<SIZE;r++){
|
|
for(let c=0;c<SIZE;c++){
|
|
if(grid[r][c]===0){ row=r; col=c; break outer; }
|
|
}
|
|
}
|
|
if(row===-1){ count++; return; }
|
|
for(let val=1; val<=9; val++){
|
|
if(isValidPlacement(grid,row,col,val)){
|
|
grid[row][col]=val;
|
|
backtrack();
|
|
grid[row][col]=0;
|
|
if(count>=limit) return;
|
|
}
|
|
}
|
|
}
|
|
backtrack();
|
|
return count;
|
|
}
|
|
|
|
function generatePuzzle(clueCount){
|
|
const full = emptyGrid();
|
|
fillGrid(full);
|
|
const puz = full.map(r=>r.slice());
|
|
|
|
const cells = shuffled(Array.from({length:81}, (_,i)=>i));
|
|
let removed = 0;
|
|
const toRemove = 81 - clueCount;
|
|
|
|
for(const idx of cells){
|
|
if(removed>=toRemove) break;
|
|
const row = Math.floor(idx/SIZE), col = idx%SIZE;
|
|
if(puz[row][col]===0) continue;
|
|
const backup = puz[row][col];
|
|
puz[row][col] = 0;
|
|
|
|
const testGrid = puz.map(r=>r.slice());
|
|
const solutions = countSolutions(testGrid, 2);
|
|
if(solutions !== 1){
|
|
puz[row][col] = backup;
|
|
} else {
|
|
removed++;
|
|
}
|
|
}
|
|
return { full, puzzle: puz };
|
|
}
|
|
|
|
// ---------- Rendering ----------
|
|
|
|
function renderDecorative(){
|
|
dom.board.innerHTML = '';
|
|
dom.board.classList.remove('blurred');
|
|
for(let r=0;r<SIZE;r++){
|
|
for(let c=0;c<SIZE;c++){
|
|
const cell = document.createElement('div');
|
|
cell.className = 'cell given';
|
|
const boxIndex = Math.floor(r/BOX) + Math.floor(c/BOX);
|
|
if(boxIndex % 2 === 1) cell.classList.add('alt-box');
|
|
cell.textContent = DECORATIVE[r][c];
|
|
dom.board.appendChild(cell);
|
|
}
|
|
}
|
|
dom.board.classList.add('blurred');
|
|
}
|
|
|
|
function renderBoard(){
|
|
dom.board.innerHTML = '';
|
|
for(let r=0;r<SIZE;r++){
|
|
for(let c=0;c<SIZE;c++){
|
|
const cell = document.createElement('div');
|
|
cell.className = 'cell';
|
|
const boxIndex = Math.floor(r/BOX) + Math.floor(c/BOX);
|
|
if(boxIndex % 2 === 1) cell.classList.add('alt-box');
|
|
cell.dataset.row = r;
|
|
cell.dataset.col = c;
|
|
|
|
const val = board[r][c];
|
|
if(val !== 0){
|
|
cell.textContent = val;
|
|
if(given[r][c]) cell.classList.add('given');
|
|
if(!given[r][c] && solution[r][c] !== val) cell.classList.add('error');
|
|
} else if(notes[r][c].size > 0){
|
|
const notesGrid = document.createElement('div');
|
|
notesGrid.className = 'notes';
|
|
for(let n=1;n<=9;n++){
|
|
const span = document.createElement('span');
|
|
span.textContent = notes[r][c].has(n) ? n : '';
|
|
notesGrid.appendChild(span);
|
|
}
|
|
cell.appendChild(notesGrid);
|
|
}
|
|
|
|
cell.addEventListener('click', () => selectCell(r,c));
|
|
dom.board.appendChild(cell);
|
|
}
|
|
}
|
|
applySelectionStyles();
|
|
}
|
|
|
|
function applySelectionStyles(){
|
|
const cellEls = dom.board.querySelectorAll('.cell');
|
|
cellEls.forEach(el => el.classList.remove('peer','selected','same-value'));
|
|
if(!selected) return;
|
|
const [sr, sc] = selected;
|
|
const selVal = board[sr][sc];
|
|
cellEls.forEach(el => {
|
|
const r = +el.dataset.row, c = +el.dataset.col;
|
|
const sameRow = r===sr, sameCol = c===sc;
|
|
const sameBox = Math.floor(r/BOX)===Math.floor(sr/BOX) && Math.floor(c/BOX)===Math.floor(sc/BOX);
|
|
if(sameRow || sameCol || sameBox) el.classList.add('peer');
|
|
if(r===sr && c===sc) el.classList.add('selected');
|
|
if(selVal!==0 && board[r][c]===selVal) el.classList.add('same-value');
|
|
});
|
|
}
|
|
|
|
function renderKeypad(){
|
|
dom.keypad.innerHTML = '';
|
|
const counts = Array(10).fill(0);
|
|
for(let r=0;r<SIZE;r++) for(let c=0;c<SIZE;c++){
|
|
if(board[r][c]!==0) counts[board[r][c]]++;
|
|
}
|
|
for(let n=1;n<=9;n++){
|
|
const btn = document.createElement('button');
|
|
btn.textContent = n;
|
|
if(counts[n]>=9) btn.classList.add('count-done');
|
|
btn.addEventListener('click', () => inputNumber(n));
|
|
dom.keypad.appendChild(btn);
|
|
}
|
|
}
|
|
|
|
function updateNotesBtn(){
|
|
dom.notesBtn.classList.toggle('active', notesMode);
|
|
}
|
|
|
|
function formatTime(s){
|
|
const m = Math.floor(s/60).toString().padStart(2,'0');
|
|
const sec = (s%60).toString().padStart(2,'0');
|
|
return `${m}:${sec}`;
|
|
}
|
|
|
|
function updateUiForState(){
|
|
dom.difficulty.disabled = (state === STATE.RUNNING || state === STATE.PAUSED);
|
|
dom.startOverlay.hidden = state !== STATE.IDLE;
|
|
dom.pauseOverlay.hidden = state !== STATE.PAUSED;
|
|
dom.gameActions.hidden = !(state === STATE.RUNNING || state === STATE.PAUSED);
|
|
dom.keypad.style.visibility = (state === STATE.RUNNING) ? 'visible' : 'hidden';
|
|
dom.pauseBtn.hidden = state !== STATE.RUNNING;
|
|
dom.board.classList.toggle('blurred', state === STATE.PAUSED);
|
|
}
|
|
|
|
// ---------- Timer ----------
|
|
|
|
function startTimerTick(){
|
|
clearInterval(timerHandle);
|
|
timerHandle = setInterval(() => {
|
|
timerSeconds++;
|
|
dom.timer.textContent = formatTime(timerSeconds);
|
|
}, 1000);
|
|
}
|
|
|
|
function stopTimerTick(){
|
|
clearInterval(timerHandle);
|
|
timerHandle = null;
|
|
}
|
|
|
|
// ---------- History (undo) ----------
|
|
|
|
function pushHistory(){
|
|
history.push({
|
|
board: board.map(r=>r.slice()),
|
|
notes: notes.map(row=>row.map(s=>new Set(s))),
|
|
mistakes
|
|
});
|
|
if(history.length>200) history.shift();
|
|
}
|
|
|
|
function undo(){
|
|
if(state !== STATE.RUNNING || history.length===0) return;
|
|
const prev = history.pop();
|
|
board = prev.board;
|
|
notes = prev.notes;
|
|
mistakes = prev.mistakes;
|
|
dom.mistakes.textContent = mistakes;
|
|
renderBoard();
|
|
}
|
|
|
|
// ---------- Interaction ----------
|
|
|
|
function selectCell(r,c){
|
|
if(state !== STATE.RUNNING) return;
|
|
selected = [r,c];
|
|
applySelectionStyles();
|
|
}
|
|
|
|
function inputNumber(n){
|
|
if(state !== STATE.RUNNING || !selected) return;
|
|
const [r,c] = selected;
|
|
if(given[r][c]) return;
|
|
|
|
pushHistory();
|
|
|
|
if(notesMode){
|
|
if(board[r][c]!==0) { history.pop(); return; }
|
|
if(notes[r][c].has(n)) notes[r][c].delete(n);
|
|
else notes[r][c].add(n);
|
|
renderBoard();
|
|
return;
|
|
}
|
|
|
|
board[r][c] = n;
|
|
notes[r][c].clear();
|
|
clearPeerNotes(r,c,n);
|
|
|
|
if(solution[r][c] !== n){
|
|
mistakes++;
|
|
dom.mistakes.textContent = mistakes;
|
|
}
|
|
|
|
renderBoard();
|
|
renderKeypad();
|
|
checkWin();
|
|
}
|
|
|
|
function clearPeerNotes(row,col,n){
|
|
for(let i=0;i<SIZE;i++){
|
|
notes[row][i].delete(n);
|
|
notes[i][col].delete(n);
|
|
}
|
|
const br = row - row%BOX, bc = col - col%BOX;
|
|
for(let r=0;r<BOX;r++) for(let c=0;c<BOX;c++) notes[br+r][bc+c].delete(n);
|
|
}
|
|
|
|
function eraseCell(){
|
|
if(state !== STATE.RUNNING || !selected) return;
|
|
const [r,c] = selected;
|
|
if(given[r][c]) return;
|
|
pushHistory();
|
|
board[r][c] = 0;
|
|
notes[r][c].clear();
|
|
renderBoard();
|
|
renderKeypad();
|
|
}
|
|
|
|
function giveHint(){
|
|
if(state !== STATE.RUNNING) return;
|
|
if(!selected || board[selected[0]][selected[1]] === solution[selected[0]][selected[1]]){
|
|
const empties = [];
|
|
for(let r=0;r<SIZE;r++) for(let c=0;c<SIZE;c++)
|
|
if(!given[r][c] && board[r][c] !== solution[r][c]) empties.push([r,c]);
|
|
if(empties.length===0) return;
|
|
selected = empties[Math.floor(Math.random()*empties.length)];
|
|
}
|
|
const [hr,hc] = selected;
|
|
pushHistory();
|
|
board[hr][hc] = solution[hr][hc];
|
|
notes[hr][hc].clear();
|
|
clearPeerNotes(hr,hc,solution[hr][hc]);
|
|
renderBoard();
|
|
renderKeypad();
|
|
const el = dom.board.querySelector(`.cell[data-row="${hr}"][data-col="${hc}"]`);
|
|
if(el){ el.classList.add('hint'); setTimeout(()=>el.classList.remove('hint'), 900); }
|
|
checkWin();
|
|
}
|
|
|
|
function checkWin(){
|
|
for(let r=0;r<SIZE;r++) for(let c=0;c<SIZE;c++){
|
|
if(board[r][c] !== solution[r][c]) return;
|
|
}
|
|
state = STATE.FINISHED;
|
|
stopTimerTick();
|
|
clearSavedState();
|
|
updateUiForState();
|
|
if(typeof callbacks.onWin === 'function'){
|
|
callbacks.onWin({ difficulty: currentDifficulty, timeSeconds: timerSeconds, mistakes });
|
|
}
|
|
}
|
|
|
|
// ---------- Persistence (in-progress game only) ----------
|
|
|
|
function saveInProgress(){
|
|
if(state !== STATE.RUNNING && state !== STATE.PAUSED) return;
|
|
try{
|
|
localStorage.setItem('sudoku-inprogress', JSON.stringify({
|
|
difficulty: currentDifficulty,
|
|
solution, puzzle, given, board,
|
|
notes: notes.map(row => row.map(s => Array.from(s))),
|
|
mistakes, timerSeconds
|
|
}));
|
|
}catch(e){}
|
|
}
|
|
|
|
function loadInProgress(){
|
|
try{
|
|
const raw = localStorage.getItem('sudoku-inprogress');
|
|
if(!raw) return false;
|
|
const data = JSON.parse(raw);
|
|
currentDifficulty = data.difficulty || 'medium';
|
|
solution = data.solution;
|
|
puzzle = data.puzzle;
|
|
given = data.given;
|
|
board = data.board;
|
|
notes = data.notes.map(row => row.map(arr => new Set(arr)));
|
|
mistakes = data.mistakes || 0;
|
|
timerSeconds = data.timerSeconds || 0;
|
|
history = [];
|
|
selected = null;
|
|
dom.difficulty.value = currentDifficulty;
|
|
dom.mistakes.textContent = mistakes;
|
|
dom.timer.textContent = formatTime(timerSeconds);
|
|
renderBoard();
|
|
renderKeypad();
|
|
state = STATE.PAUSED;
|
|
updateUiForState();
|
|
return true;
|
|
}catch(e){
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function clearSavedState(){
|
|
try{ localStorage.removeItem('sudoku-inprogress'); }catch(e){}
|
|
}
|
|
|
|
// ---------- Public lifecycle ----------
|
|
|
|
function start(difficulty){
|
|
currentDifficulty = difficulty || dom.difficulty.value;
|
|
timerSeconds = 0;
|
|
mistakes = 0;
|
|
history = [];
|
|
selected = null;
|
|
notesMode = false;
|
|
updateNotesBtn();
|
|
dom.mistakes.textContent = '0';
|
|
dom.timer.textContent = '00:00';
|
|
dom.msg.textContent = '';
|
|
|
|
const clueCount = DIFFICULTY_CLUES[currentDifficulty] || 34;
|
|
const { full, puzzle: puz } = generatePuzzle(clueCount);
|
|
solution = full;
|
|
puzzle = puz;
|
|
given = puz.map(r => r.map(v => v!==0));
|
|
board = puz.map(r => r.slice());
|
|
notes = Array.from({length:SIZE}, ()=>Array.from({length:SIZE}, ()=>new Set()));
|
|
|
|
state = STATE.RUNNING;
|
|
renderBoard();
|
|
renderKeypad();
|
|
updateUiForState();
|
|
startTimerTick();
|
|
saveInProgress();
|
|
}
|
|
|
|
function pause(){
|
|
if(state !== STATE.RUNNING) return;
|
|
state = STATE.PAUSED;
|
|
stopTimerTick();
|
|
updateUiForState();
|
|
saveInProgress();
|
|
}
|
|
|
|
function resume(){
|
|
if(state !== STATE.PAUSED) return;
|
|
state = STATE.RUNNING;
|
|
startTimerTick();
|
|
updateUiForState();
|
|
selected = null;
|
|
applySelectionStyles();
|
|
}
|
|
|
|
function stop(){
|
|
if(state !== STATE.RUNNING && state !== STATE.PAUSED) return;
|
|
const ok = window.confirm(window.I18N ? window.I18N.t('confirm.stop') : 'Abandonner la partie en cours ?');
|
|
if(!ok) return;
|
|
stopTimerTick();
|
|
clearSavedState();
|
|
const snapshot = { difficulty: currentDifficulty, timeSeconds: timerSeconds, mistakes };
|
|
state = STATE.IDLE;
|
|
renderDecorative();
|
|
updateUiForState();
|
|
if(snapshot.timeSeconds >= 1 && typeof callbacks.onAbandon === 'function'){
|
|
callbacks.onAbandon(snapshot);
|
|
}
|
|
}
|
|
|
|
function reset(){
|
|
stopTimerTick();
|
|
clearSavedState();
|
|
state = STATE.IDLE;
|
|
selected = null;
|
|
renderDecorative();
|
|
updateUiForState();
|
|
}
|
|
|
|
function getState(){ return state; }
|
|
|
|
function init(cb){
|
|
callbacks = cb || {};
|
|
dom = {
|
|
board: document.getElementById('board'),
|
|
keypad: document.getElementById('keypad'),
|
|
timer: document.getElementById('timer'),
|
|
mistakes: document.getElementById('mistakes'),
|
|
difficulty: document.getElementById('difficulty'),
|
|
msg: document.getElementById('msg'),
|
|
boardWrap: document.getElementById('boardWrap'),
|
|
startOverlay: document.getElementById('startOverlay'),
|
|
startBtn: document.getElementById('startBtn'),
|
|
pauseOverlay: document.getElementById('pauseOverlay'),
|
|
resumeBtn: document.getElementById('resumeBtn'),
|
|
gameActions: document.getElementById('gameActions'),
|
|
pauseBtn: document.getElementById('pauseBtn'),
|
|
stopBtn: document.getElementById('stopBtn'),
|
|
undoBtn: document.getElementById('undoBtn'),
|
|
eraseBtn: document.getElementById('eraseBtn'),
|
|
notesBtn: document.getElementById('notesBtn'),
|
|
hintBtn: document.getElementById('hintBtn'),
|
|
};
|
|
|
|
dom.startBtn.addEventListener('click', () => start(dom.difficulty.value));
|
|
dom.resumeBtn.addEventListener('click', resume);
|
|
dom.pauseBtn.addEventListener('click', pause);
|
|
dom.stopBtn.addEventListener('click', stop);
|
|
dom.undoBtn.addEventListener('click', undo);
|
|
dom.eraseBtn.addEventListener('click', eraseCell);
|
|
dom.hintBtn.addEventListener('click', giveHint);
|
|
dom.notesBtn.addEventListener('click', () => { notesMode = !notesMode; updateNotesBtn(); });
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if(state !== STATE.RUNNING || !selected) return;
|
|
if(e.key>='1' && e.key<='9'){ inputNumber(parseInt(e.key,10)); return; }
|
|
if(e.key==='Backspace' || e.key==='Delete' || e.key==='0'){ eraseCell(); return; }
|
|
let [r,c] = selected;
|
|
if(e.key==='ArrowUp') r = Math.max(0, r-1);
|
|
else if(e.key==='ArrowDown') r = Math.min(8, r+1);
|
|
else if(e.key==='ArrowLeft') c = Math.max(0, c-1);
|
|
else if(e.key==='ArrowRight') c = Math.min(8, c+1);
|
|
else return;
|
|
e.preventDefault();
|
|
selectCell(r,c);
|
|
});
|
|
|
|
window.addEventListener('beforeunload', saveInProgress);
|
|
setInterval(saveInProgress, 5000);
|
|
|
|
if(!loadInProgress()){
|
|
state = STATE.IDLE;
|
|
renderDecorative();
|
|
}
|
|
updateUiForState();
|
|
}
|
|
|
|
window.SudokuGame = { init, start, pause, resume, stop, reset, getState, STATE };
|
|
})();
|