Initial commit: Sudoku game with accounts, history, leaderboard, themes and i18n

Node/Express + SQLite backend, vanilla JS frontend, deployed via Docker Compose.
This commit is contained in:
2026-08-27 21:24:38 +02:00
commit 00e8dcb7d2
16 changed files with 2405 additions and 0 deletions

535
app/game.js Normal file
View File

@@ -0,0 +1,535 @@
(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 };
})();