Initial commit: CatDoku puzzle game with leaderboard
This commit is contained in:
commit
642f568ac6
11 files changed
+2988
No files matched your search
@@ -0,0 +1,592 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const COLORS = [
|
||||
'#6abf69', '#4a90d9', '#e8a838', '#c06dbf',
|
||||
'#e06060', '#40b8c8', '#d4c830', '#8b6b4a',
|
||||
'#7088a0', '#d04080', '#5060b0', '#30a080'
|
||||
];
|
||||
|
||||
const CAT_EMOJI = '\uD83D\uDC31';
|
||||
const X_MARK = '\u2715';
|
||||
|
||||
let state = {
|
||||
size: 8,
|
||||
grid: [],
|
||||
solution: [],
|
||||
errors: 3,
|
||||
maxErrors: 3,
|
||||
catsPlaced: 0,
|
||||
totalCats: 0,
|
||||
level: 1,
|
||||
score: 0,
|
||||
hints: 3,
|
||||
gameOver: false,
|
||||
won: false,
|
||||
playerName: '',
|
||||
levelStartTime: null,
|
||||
levelTime: 0,
|
||||
sessionRecords: []
|
||||
};
|
||||
|
||||
let pressTimer = null;
|
||||
let longPressTriggered = false;
|
||||
let gameTimer = null;
|
||||
const LONG_PRESS_MS = 500;
|
||||
|
||||
// DOM refs
|
||||
const gridContainer = document.getElementById('grid-container');
|
||||
const levelDisplay = document.getElementById('level-display');
|
||||
const scoreDisplay = document.getElementById('score-display');
|
||||
const catsDisplay = document.getElementById('cats-display');
|
||||
const errorsDisplay = document.getElementById('errors-display');
|
||||
const hintCountEl = document.getElementById('hint-count');
|
||||
const modalOverlay = document.getElementById('modal-overlay');
|
||||
const modalTitle = document.getElementById('modal-title');
|
||||
const modalMessage = document.getElementById('modal-message');
|
||||
const modalBtn = document.getElementById('modal-btn');
|
||||
const gridSizeSelect = document.getElementById('grid-size');
|
||||
const btnNewGame = document.getElementById('btn-new-game');
|
||||
const btnHint = document.getElementById('btn-hint');
|
||||
const btnEraser = document.getElementById('btn-eraser');
|
||||
const timeDisplay = document.getElementById('time-display');
|
||||
const playerNameDisplay = document.getElementById('player-name-display');
|
||||
const nameInputOverlay = document.getElementById('name-input-overlay');
|
||||
const nameInput = document.getElementById('name-input');
|
||||
const nameSubmitBtn = document.getElementById('name-submit-btn');
|
||||
|
||||
// --- Generator ---
|
||||
|
||||
function shuffle(arr) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function isAdjacent(r1, c1, r2, c2) {
|
||||
return Math.abs(r1 - r2) <= 1 && Math.abs(c1 - c2) <= 1 && !(r1 === r2 && c1 === c2);
|
||||
}
|
||||
|
||||
function canPlaceCat(grid, row, col, size, placed) {
|
||||
for (const p of placed) {
|
||||
if (p.row === row || p.col === col) return false;
|
||||
if (isAdjacent(p.row, p.col, row, col)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function generateSolution(size) {
|
||||
const placed = [];
|
||||
const rows = shuffle([...Array(size).keys()]);
|
||||
|
||||
function backtrack(rowIdx) {
|
||||
if (rowIdx === size) return true;
|
||||
const row = rows[rowIdx];
|
||||
const cols = shuffle([...Array(size).keys()]);
|
||||
for (const col of cols) {
|
||||
if (canPlaceCat(null, row, col, size, placed)) {
|
||||
placed.push({ row, col });
|
||||
if (backtrack(rowIdx + 1)) return true;
|
||||
placed.pop();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try multiple times with different shuffles
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
placed.length = 0;
|
||||
for (let i = 0; i < size; i++) rows[i] = i;
|
||||
shuffle(rows);
|
||||
if (backtrack(0)) return placed;
|
||||
}
|
||||
|
||||
// Fallback: simple placement
|
||||
placed.length = 0;
|
||||
for (let i = 0; i < size; i++) {
|
||||
placed.push({ row: i, col: (i * 2) % size });
|
||||
}
|
||||
return placed;
|
||||
}
|
||||
|
||||
function generateColors(size, solution) {
|
||||
const grid = Array.from({ length: size }, () => Array(size).fill(-1));
|
||||
const colorCount = solution.length;
|
||||
const colorOrder = shuffle([...Array(colorCount).keys()]);
|
||||
|
||||
// Assign each solution cat its color
|
||||
solution.forEach((cat, i) => {
|
||||
grid[cat.row][cat.col] = colorOrder[i];
|
||||
});
|
||||
|
||||
// Flood fill from each cat to expand regions contiguously
|
||||
// Use BFS from all cats simultaneously, expanding one step at a time
|
||||
const queues = colorOrder.map((color, i) => {
|
||||
const cat = solution[i];
|
||||
return [{ r: cat.row, c: cat.col }];
|
||||
});
|
||||
|
||||
let totalFilled = colorCount;
|
||||
const totalCells = size * size;
|
||||
|
||||
while (totalFilled < totalCells) {
|
||||
let expanded = false;
|
||||
// Shuffle queue order each round for randomness
|
||||
const order = shuffle([...Array(colorCount).keys()]);
|
||||
for (const i of order) {
|
||||
if (queues[i].length === 0) continue;
|
||||
const idx = Math.floor(Math.random() * queues[i].length);
|
||||
const { r, c } = queues[i].splice(idx, 1)[0];
|
||||
const dirs = shuffle([[0,1],[0,-1],[1,0],[-1,0]]);
|
||||
for (const [dr, dc] of dirs) {
|
||||
const nr = r + dr, nc = c + dc;
|
||||
if (nr >= 0 && nr < size && nc >= 0 && nc < size && grid[nr][nc] === -1) {
|
||||
grid[nr][nc] = colorOrder[i];
|
||||
queues[i].push({ r: nr, c: nc });
|
||||
totalFilled++;
|
||||
expanded = true;
|
||||
if (totalFilled >= totalCells) break;
|
||||
}
|
||||
}
|
||||
if (totalFilled >= totalCells) break;
|
||||
}
|
||||
if (!expanded) break;
|
||||
}
|
||||
|
||||
return grid;
|
||||
}
|
||||
|
||||
function generatePuzzle(size) {
|
||||
const solution = generateSolution(size);
|
||||
const colorGrid = generateColors(size, solution);
|
||||
|
||||
const grid = [];
|
||||
for (let r = 0; r < size; r++) {
|
||||
grid[r] = [];
|
||||
for (let c = 0; c < size; c++) {
|
||||
grid[r][c] = {
|
||||
color: colorGrid[r][c],
|
||||
hasCat: false,
|
||||
hasX: false,
|
||||
isSolution: solution.some(s => s.row === r && s.col === c)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { grid, solution };
|
||||
}
|
||||
|
||||
// --- Game Logic ---
|
||||
|
||||
function initGame() {
|
||||
const size = parseInt(gridSizeSelect.value);
|
||||
state.size = size;
|
||||
state.errors = state.maxErrors;
|
||||
state.catsPlaced = 0;
|
||||
state.totalCats = size;
|
||||
state.gameOver = false;
|
||||
state.won = false;
|
||||
state.hints = 3;
|
||||
state.levelStartTime = Date.now();
|
||||
state.levelTime = 0;
|
||||
|
||||
const puzzle = generatePuzzle(size);
|
||||
state.grid = puzzle.grid;
|
||||
state.solution = puzzle.solution;
|
||||
|
||||
updateUI();
|
||||
renderGrid();
|
||||
hideModal();
|
||||
startTimer();
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
if (gameTimer) clearInterval(gameTimer);
|
||||
gameTimer = setInterval(() => {
|
||||
if (!state.gameOver && !state.won) {
|
||||
state.levelTime = Math.floor((Date.now() - state.levelStartTime) / 1000);
|
||||
updateTimeDisplay();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (gameTimer) {
|
||||
clearInterval(gameTimer);
|
||||
gameTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function updateTimeDisplay() {
|
||||
if (timeDisplay) {
|
||||
timeDisplay.textContent = formatTime(state.levelTime);
|
||||
}
|
||||
}
|
||||
|
||||
function saveLevelRecord() {
|
||||
const record = {
|
||||
playerName: state.playerName,
|
||||
level: state.level,
|
||||
gridSize: state.size,
|
||||
time: state.levelTime,
|
||||
score: state.score,
|
||||
errors: state.maxErrors - state.errors,
|
||||
hintsUsed: 3 - state.hints,
|
||||
date: new Date().toISOString()
|
||||
};
|
||||
state.sessionRecords.push(record);
|
||||
|
||||
// Salva in localStorage
|
||||
const allRecords = JSON.parse(localStorage.getItem('catdoku_records') || '[]');
|
||||
allRecords.push(record);
|
||||
localStorage.setItem('catdoku_records', JSON.stringify(allRecords));
|
||||
}
|
||||
|
||||
function getCatCountInRow(row) {
|
||||
let count = 0;
|
||||
for (let c = 0; c < state.size; c++) {
|
||||
if (state.grid[row][c].hasCat) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function getCatCountInCol(col) {
|
||||
let count = 0;
|
||||
for (let r = 0; r < state.size; r++) {
|
||||
if (state.grid[r][col].hasCat) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function getCatCountInColor(color) {
|
||||
let count = 0;
|
||||
for (let r = 0; r < state.size; r++) {
|
||||
for (let c = 0; c < state.size; c++) {
|
||||
if (state.grid[r][c].color === color && state.grid[r][c].hasCat) count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function isCatAdjacent(row, col) {
|
||||
for (let dr = -1; dr <= 1; dr++) {
|
||||
for (let dc = -1; dc <= 1; dc++) {
|
||||
if (dr === 0 && dc === 0) continue;
|
||||
const nr = row + dr, nc = col + dc;
|
||||
if (nr >= 0 && nr < state.size && nc >= 0 && nc < state.size) {
|
||||
if (state.grid[nr][nc].hasCat) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isValidPlacement(row, col) {
|
||||
const cell = state.grid[row][col];
|
||||
if (cell.hasX) return false;
|
||||
if (getCatCountInRow(row) > 0) return false;
|
||||
if (getCatCountInCol(col) > 0) return false;
|
||||
if (getCatCountInColor(cell.color) > 0) return false;
|
||||
if (isCatAdjacent(row, col)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function placeCat(row, col) {
|
||||
if (state.gameOver || state.won) return;
|
||||
|
||||
const cell = state.grid[row][col];
|
||||
|
||||
// If cat already there, remove it
|
||||
if (cell.hasCat) {
|
||||
cell.hasCat = false;
|
||||
state.catsPlaced--;
|
||||
renderGrid();
|
||||
updateUI();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if placement is valid
|
||||
if (!isValidPlacement(row, col)) {
|
||||
state.errors--;
|
||||
flashError(row, col);
|
||||
updateUI();
|
||||
|
||||
if (state.errors <= 0) {
|
||||
state.gameOver = true;
|
||||
showModal('Game Over!', 'Hai esaurito tutti i tentativi. Riprova!', 'Ricomincia', () => {
|
||||
initGame();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
cell.hasCat = true;
|
||||
state.catsPlaced++;
|
||||
renderGrid();
|
||||
updateUI();
|
||||
|
||||
// Check win
|
||||
if (state.catsPlaced === state.totalCats) {
|
||||
state.won = true;
|
||||
stopTimer();
|
||||
state.score += state.errors * 10 + state.hints * 5;
|
||||
saveLevelRecord();
|
||||
state.level++;
|
||||
showModal('Vittoria!',
|
||||
`Hai completato il livello ${state.level - 1}!\nTempo: ${formatTime(state.levelTime)}\nPunteggio: ${state.score}`,
|
||||
'Livello Successivo', () => {
|
||||
initGame();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toggleX(row, col) {
|
||||
if (state.gameOver || state.won) return;
|
||||
const cell = state.grid[row][col];
|
||||
if (cell.hasCat) return; // Can't X a cat cell
|
||||
cell.hasX = !cell.hasX;
|
||||
renderGrid();
|
||||
}
|
||||
|
||||
function flashError(row, col) {
|
||||
const cellEl = document.querySelector(`.cell[data-row="${row}"][data-col="${col}"]`);
|
||||
if (cellEl) {
|
||||
cellEl.classList.add('error-flash');
|
||||
setTimeout(() => cellEl.classList.remove('error-flash'), 500);
|
||||
}
|
||||
}
|
||||
|
||||
function useHint() {
|
||||
if (state.gameOver || state.won) return;
|
||||
|
||||
// Se non ci sono suggerimenti, mostra popup premium
|
||||
if (state.hints <= 0) {
|
||||
showPremiumPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find a solution cat that isn't placed yet
|
||||
for (const sol of state.solution) {
|
||||
if (!state.grid[sol.row][sol.col].hasCat) {
|
||||
state.grid[sol.row][sol.col].hasCat = true;
|
||||
state.catsPlaced++;
|
||||
state.hints--;
|
||||
renderGrid();
|
||||
updateUI();
|
||||
|
||||
if (state.catsPlaced === state.totalCats) {
|
||||
state.won = true;
|
||||
state.score += state.errors * 10 + state.hints * 5;
|
||||
state.level++;
|
||||
showModal('Vittoria!', 'Hai completato il livello! Punteggio: ' + state.score, 'Livello Successivo', () => {
|
||||
initGame();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rendering ---
|
||||
|
||||
function renderGrid() {
|
||||
gridContainer.innerHTML = '';
|
||||
gridContainer.style.gridTemplateColumns = `repeat(${state.size}, 1fr)`;
|
||||
|
||||
for (let r = 0; r < state.size; r++) {
|
||||
for (let c = 0; c < state.size; c++) {
|
||||
const cell = state.grid[r][c];
|
||||
const cellEl = document.createElement('div');
|
||||
cellEl.className = 'cell';
|
||||
cellEl.dataset.row = r;
|
||||
cellEl.dataset.col = c;
|
||||
cellEl.style.backgroundColor = COLORS[cell.color % COLORS.length];
|
||||
|
||||
if (cell.hasCat) {
|
||||
cellEl.innerHTML = `<span class="cat">${CAT_EMOJI}</span>`;
|
||||
} else if (cell.hasX) {
|
||||
cellEl.innerHTML = `<span class="x-mark">${X_MARK}</span>`;
|
||||
}
|
||||
|
||||
// Events
|
||||
cellEl.addEventListener('mousedown', (e) => onPointerDown(e, r, c));
|
||||
cellEl.addEventListener('mouseup', (e) => onPointerUp(e, r, c));
|
||||
cellEl.addEventListener('mouseleave', () => onPointerLeave());
|
||||
cellEl.addEventListener('touchstart', (e) => onTouchStart(e, r, c), { passive: false });
|
||||
cellEl.addEventListener('touchend', (e) => onTouchEnd(e, r, c));
|
||||
cellEl.addEventListener('touchcancel', () => onPointerLeave());
|
||||
cellEl.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
|
||||
gridContainer.appendChild(cellEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
levelDisplay.textContent = state.level;
|
||||
scoreDisplay.textContent = state.score;
|
||||
catsDisplay.textContent = `${state.catsPlaced}/${state.totalCats}`;
|
||||
hintCountEl.textContent = state.hints;
|
||||
|
||||
// Aggiungi classe "zero" quando i suggerimenti sono esauriti
|
||||
hintCountEl.classList.toggle('zero', state.hints <= 0);
|
||||
|
||||
// Update error hearts
|
||||
const hearts = errorsDisplay.querySelectorAll('.heart');
|
||||
hearts.forEach((h, i) => {
|
||||
h.classList.toggle('lost', i >= state.errors);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Input Handling ---
|
||||
|
||||
function onPointerDown(e, row, col) {
|
||||
if (e.button !== 0) return;
|
||||
longPressTriggered = false;
|
||||
pressTimer = setTimeout(() => {
|
||||
longPressTriggered = true;
|
||||
placeCat(row, col);
|
||||
}, LONG_PRESS_MS);
|
||||
}
|
||||
|
||||
function onPointerUp(e, row, col) {
|
||||
clearTimeout(pressTimer);
|
||||
if (!longPressTriggered) {
|
||||
toggleX(row, col);
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerLeave() {
|
||||
clearTimeout(pressTimer);
|
||||
longPressTriggered = false;
|
||||
}
|
||||
|
||||
function onTouchStart(e, row, col) {
|
||||
e.preventDefault();
|
||||
longPressTriggered = false;
|
||||
pressTimer = setTimeout(() => {
|
||||
longPressTriggered = true;
|
||||
placeCat(row, col);
|
||||
}, LONG_PRESS_MS);
|
||||
}
|
||||
|
||||
function onTouchEnd(e, row, col) {
|
||||
clearTimeout(pressTimer);
|
||||
if (!longPressTriggered) {
|
||||
toggleX(row, col);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Modal ---
|
||||
|
||||
function showModal(title, message, btnText, callback) {
|
||||
modalTitle.textContent = title;
|
||||
modalMessage.textContent = message;
|
||||
modalBtn.textContent = btnText;
|
||||
modalOverlay.classList.remove('hidden');
|
||||
modalBtn.onclick = () => {
|
||||
hideModal();
|
||||
if (callback) callback();
|
||||
};
|
||||
}
|
||||
|
||||
function hideModal() {
|
||||
modalOverlay.classList.add('hidden');
|
||||
}
|
||||
|
||||
function showPremiumPopup() {
|
||||
const premiumOverlay = document.getElementById('premium-overlay');
|
||||
const premiumBtn = document.getElementById('premium-btn');
|
||||
const premiumTimer = document.getElementById('premium-timer');
|
||||
|
||||
premiumOverlay.classList.remove('hidden');
|
||||
|
||||
let countdown = 5;
|
||||
premiumBtn.disabled = true;
|
||||
premiumBtn.textContent = `Attendi ${countdown}s...`;
|
||||
premiumTimer.textContent = countdown;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
countdown--;
|
||||
premiumTimer.textContent = countdown;
|
||||
|
||||
if (countdown <= 0) {
|
||||
clearInterval(timer);
|
||||
premiumBtn.disabled = false;
|
||||
premiumBtn.textContent = 'OK';
|
||||
} else {
|
||||
premiumBtn.textContent = `Attendi ${countdown}s...`;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
premiumBtn.onclick = () => {
|
||||
clearInterval(timer);
|
||||
premiumOverlay.classList.add('hidden');
|
||||
state.hints++;
|
||||
updateUI();
|
||||
};
|
||||
}
|
||||
|
||||
// --- Event Listeners ---
|
||||
|
||||
btnNewGame.addEventListener('click', () => {
|
||||
state.level = 1;
|
||||
state.score = 0;
|
||||
state.hints = 3;
|
||||
state.sessionRecords = [];
|
||||
showNameInput();
|
||||
});
|
||||
|
||||
btnHint.addEventListener('click', useHint);
|
||||
|
||||
btnEraser.addEventListener('click', () => {
|
||||
// Clear all X marks (not cats)
|
||||
for (let r = 0; r < state.size; r++) {
|
||||
for (let c = 0; c < state.size; c++) {
|
||||
state.grid[r][c].hasX = false;
|
||||
}
|
||||
}
|
||||
renderGrid();
|
||||
});
|
||||
|
||||
// Name input handling
|
||||
function showNameInput() {
|
||||
nameInputOverlay.classList.remove('hidden');
|
||||
nameInput.value = state.playerName || '';
|
||||
nameInput.focus();
|
||||
}
|
||||
|
||||
function hideNameInput() {
|
||||
nameInputOverlay.classList.add('hidden');
|
||||
}
|
||||
|
||||
nameSubmitBtn.addEventListener('click', () => {
|
||||
const name = nameInput.value.trim();
|
||||
if (name) {
|
||||
state.playerName = name;
|
||||
if (playerNameDisplay) {
|
||||
playerNameDisplay.textContent = name;
|
||||
}
|
||||
hideNameInput();
|
||||
initGame();
|
||||
}
|
||||
});
|
||||
|
||||
nameInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
nameSubmitBtn.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Init - mostra input nome all'avvio
|
||||
showNameInput();
|
||||
|
||||
})();
|
||||
Reference in new issue
Block a user