(() => {
const boardElement = document.getElementById('board');
const remainingElement = document.getElementById('remaining');
const flagsElement = document.getElementById('flags');
const timerElement = document.getElementById('timer');
const difficultySelect = document.getElementById('difficulty');
const restartButton = document.getElementById('restart');
const overlay = document.getElementById('overlay');
const overlayButton = document.getElementById('overlay-btn');
const overlayMessage = overlay.querySelector('p');
const collectionList = document.getElementById('collection-list');
const collectionCount = document.getElementById('collection-count');
const collectionEmpty = document.getElementById('collection-empty');
const difficulties = {
tiny: { size: 6, fairies: 5 },
easy: { size: 8, fairies: 10 },
bright: { size: 9, fairies: 15 },
};
const game = {
boardSize: difficulties.tiny.size,
fairyTotal: difficulties.tiny.fairies,
board: [],
cells: [],
flagsUsed: 0,
revealedSafe: 0,
isActive: false,
timer: 0,
timerInterval: null,
overlayTimeout: null,
};
const fairyCollection = [];
const audio = createAudio();
function stopTimer() {
if (game.timerInterval) {
clearInterval(game.timerInterval);
game.timerInterval = null;
}
}
function startTimer() {
if (game.timerInterval) return;
game.timerInterval = setInterval(() => {
game.timer += 1;
updateTimerDisplay();
}, 1000);
}
function updateTimerDisplay() {
const minutes = String(Math.floor(game.timer / 60)).padStart(2, '0');
const seconds = String(game.timer % 60).padStart(2, '0');
timerElement.textContent = `${minutes}:${seconds}`;
}
function buildBoardCells() {
boardElement.innerHTML = '';
boardElement.style.gridTemplateColumns = `repeat(${game.boardSize}, var(--cell-size))`;
boardElement.style.gridTemplateRows = `repeat(${game.boardSize}, var(--cell-size))`;
game.cells = [];
for (let r = 0; r < game.boardSize; r += 1) {
const rowElements = [];
for (let c = 0; c < game.boardSize; c += 1) {
const cellButton = document.createElement('button');
cellButton.type = 'button';
cellButton.className = 'cell cell--hidden';
cellButton.dataset.skipReveal = 'false';
cellButton.addEventListener('click', () => handleReveal(r, c, cellButton));
cellButton.addEventListener('contextmenu', (event) => {
event.preventDefault();
toggleFlag(r, c);
});
let longPressTimer = null;
const clearLongPress = () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
};
cellButton.addEventListener('pointerdown', (event) => {
if (event.pointerType === 'touch') {
clearLongPress();
longPressTimer = setTimeout(() => {
cellButton.dataset.skipReveal = 'true';
toggleFlag(r, c);
clearLongPress();
}, 550);
}
});
['pointerup', 'pointerleave', 'pointercancel'].forEach((evtName) => {
cellButton.addEventListener(evtName, clearLongPress);
});
rowElements.push(cellButton);
boardElement.appendChild(cellButton);
}
game.cells.push(rowElements);
}
}
function createDataBoard() {
game.board = Array.from({ length: game.boardSize }, () =>
Array.from({ length: game.boardSize }, () => ({
hasFairy: false,
revealed: false,
flagged: false,
neighborCount: 0,
}))
);
}
function placeFairies() {
const totalCells = game.boardSize * game.boardSize;
const spots = new Set();
while (spots.size < game.fairyTotal) {
spots.add(Math.floor(Math.random() * totalCells));
}
spots.forEach((index) => {
const row = Math.floor(index / game.boardSize);
const col = index % game.boardSize;
game.board[row][col].hasFairy = true;
});
}
function calculateNeighborCounts() {
const directions = [
[-1, -1], [-1, 0], [-1, 1],
[0, -1], [0, 1],
[1, -1], [1, 0], [1, 1],
];
for (let r = 0; r < game.boardSize; r += 1) {
for (let c = 0; c < game.boardSize; c += 1) {
const cell = game.board[r][c];
if (cell.hasFairy) continue;
let count = 0;
directions.forEach(([dr, dc]) => {
const nr = r + dr;
const nc = c + dc;
if (nr >= 0 && nr < game.boardSize && nc >= 0 && nc < game.boardSize) {
if (game.board[nr][nc].hasFairy) count += 1;
}
});
cell.neighborCount = count;
}
}
}
function handleReveal(row, col, element) {
if (!game.isActive) return;
if (element.dataset.skipReveal === 'true') {
element.dataset.skipReveal = 'false';
return;
}
const cell = game.board[row][col];
if (cell.flagged || cell.revealed) return;
if (!game.timerInterval) startTimer();
cell.revealed = true;
if (cell.hasFairy) {
revealCellElement(row, col, cell);
revealAllFairies();
playSound('fairy');
finishGame(false);
return;
}
game.revealedSafe += 1;
revealCellElement(row, col, cell);
playSound(cell.neighborCount === 0 ? 'open' : 'chime');
if (cell.neighborCount === 0) {
floodReveal(row, col);
}
if (game.revealedSafe === game.boardSize * game.boardSize - game.fairyTotal) {
revealAllFairies(true);
collectFairies();
finishGame(true);
}
}
function floodReveal(row, col) {
const queue = [[row, col]];
const dirs = [
[-1, -1], [-1, 0], [-1, 1],
[0, -1], [0, 1],
[1, -1], [1, 0], [1, 1],
];
while (queue.length) {
const [cr, cc] = queue.shift();
dirs.forEach(([dr, dc]) => {
const nr = cr + dr;
const nc = cc + dc;
if (nr < 0 || nr >= game.boardSize || nc < 0 || nc >= game.boardSize) return;
const neighbor = game.board[nr][nc];
if (neighbor.revealed || neighbor.flagged || neighbor.hasFairy) return;
neighbor.revealed = true;
game.revealedSafe += 1;
revealCellElement(nr, nc, neighbor);
playSound(neighbor.neighborCount === 0 ? 'open' : 'chime');
if (neighbor.neighborCount === 0) {
queue.push([nr, nc]);
}
});
}
}
function revealCellElement(row, col, cell) {
const button = game.cells[row][col];
button.classList.remove('cell--hidden', 'cell--flagged');
button.classList.add('cell--revealed');
button.textContent = '';
button.dataset.skipReveal = 'false';
for (let i = 1; i <= 8; i += 1) {
button.classList.remove(`cell--num-${i}`);
}
if (cell.hasFairy) {
button.classList.add('cell--fairy');
button.textContent = '🧚';
return;
}
if (cell.neighborCount > 0) {
button.textContent = cell.neighborCount;
button.classList.add(`cell--num-${cell.neighborCount}`);
}
}
function toggleFlag(row, col) {
if (!game.isActive) return;
const cell = game.board[row][col];
if (cell.revealed) return;
cell.flagged = !cell.flagged;
game.flagsUsed += cell.flagged ? 1 : -1;
const button = game.cells[row][col];
button.textContent = '';
if (cell.flagged) {
button.classList.add('cell--flagged');
button.classList.remove('cell--hidden');
} else {
button.classList.remove('cell--flagged');
button.classList.add('cell--hidden');
}
updateStats();
playSound(cell.flagged ? 'flag' : 'unflag');
}
function revealAllFairies(isWin = false) {
for (let r = 0; r < game.boardSize; r += 1) {
for (let c = 0; c < game.boardSize; c += 1) {
const cell = game.board[r][c];
if (cell.hasFairy) {
revealCellElement(r, c, cell);
} else if (!isWin && game.cells[r][c].classList.contains('cell--flagged')) {
game.cells[r][c].classList.remove('cell--flagged');
game.cells[r][c].classList.add('cell--revealed');
game.cells[r][c].textContent = '';
}
}
}
}
function finishGame(hasWon) {
game.isActive = false;
stopTimer();
overlayMessage.textContent = hasWon
? 'You found every fairy friend! They fluttered into your satchel.'
: 'A shy fairy fluttered out! Try again for a perfect sweep?';
overlayButton.textContent = hasWon ? 'Search again' : 'Try again';
if (game.overlayTimeout) {
clearTimeout(game.overlayTimeout);
game.overlayTimeout = null;
}
const delay = hasWon ? 1400 : 0;
const showOverlay = () => {
overlay.classList.remove('hidden');
game.overlayTimeout = null;
};
if (delay === 0) {
showOverlay();
} else {
overlay.classList.add('hidden');
game.overlayTimeout = setTimeout(showOverlay, delay);
}
playSound(hasWon ? 'win' : 'lose');
}
function updateStats() {
const remaining = Math.max(game.fairyTotal - game.flagsUsed, 0);
remainingElement.textContent = remaining;
flagsElement.textContent = game.flagsUsed;
}
function prepareGame() {
const config = difficulties[difficultySelect.value] || difficulties.easy;
game.boardSize = config.size;
game.fairyTotal = config.fairies;
game.flagsUsed = 0;
game.revealedSafe = 0;
game.isActive = true;
stopTimer();
game.timer = 0;
updateTimerDisplay();
buildBoardCells();
createDataBoard();
placeFairies();
calculateNeighborCounts();
updateStats();
}
function startNewRound() {
audio.resume();
if (game.overlayTimeout) {
clearTimeout(game.overlayTimeout);
game.overlayTimeout = null;
}
prepareGame();
overlay.classList.add('hidden');
}
restartButton.addEventListener('click', startNewRound);
overlayButton.addEventListener('click', startNewRound);
prepareGame();
function playSound(type) {
if (audio) audio.play(type);
}
function collectFairies() {
if (!collectionList) return;
const newFairies = [];
for (let r = 0; r < game.boardSize; r += 1) {
for (let c = 0; c < game.boardSize; c += 1) {
if (game.board[r][c].hasFairy) {
newFairies.push({ row: r, col: c });
}
}
}
if (!newFairies.length) return;
newFairies.forEach((fairy, index) => {
const fairyEl = document.createElement('span');
fairyEl.className = 'collection-fairy collection-fairy--new';
fairyEl.setAttribute('role', 'listitem');
fairyEl.textContent = getFairyEmoji();
fairyCollection.push(`${fairy.row}-${fairy.col}-${Date.now()}-${index}`);
collectionList.appendChild(fairyEl);
setTimeout(() => {
fairyEl.classList.remove('collection-fairy--new');
}, 700);
});
updateCollectionDisplay();
}
function updateCollectionDisplay() {
if (!collectionCount) return;
collectionCount.textContent = fairyCollection.length;
if (collectionEmpty) {
collectionEmpty.hidden = fairyCollection.length > 0;
}
}
function getFairyEmoji() {
const emojis = ['🧚', '🧚♀️', '🧚♂️'];
return emojis[Math.floor(Math.random() * emojis.length)];
}
function createAudio() {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (!AudioCtx) return null;
const ctx = new AudioCtx();
const master = ctx.createGain();
master.gain.value = 0.18;
master.connect(ctx.destination);
ctx.suspend();
const tones = {
chime: 880,
open: 640,
fairy: 520,
flag: 720,
unflag: 420,
win: 990,
lose: 300,
};
function ping(freq, duration = 0.2) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
osc.connect(gain);
gain.connect(master);
const start = ctx.currentTime;
gain.gain.setValueAtTime(0.001, start);
gain.gain.linearRampToValueAtTime(0.25, start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.002, start + duration);
osc.start(start);
osc.stop(start + duration + 0.02);
}
return {
resume() {
if (ctx.state === 'suspended') ctx.resume();
},
play(event) {
if (event === 'fairy') {
[520, 640, 760].forEach((freq, i) => ping(freq, 0.15, i * 0.04));
return;
}
const freq = tones[event];
if (freq) ping(freq);
},
};
}
})();