crushgame / game.js
dailyGit
dailygit: space watch redeploy
85facc6
Raw
History Blame
62.6 kB
class Match3Game {
constructor() {
this.boardRows = 7;
this.boardCols = 8;
this.numGemTypes = 6;
this.board = [];
this.selectedCell = null;
this.score = 0;
this.totalScore = 0;
this.moves = 30;
this.level = 1;
this.levelTargetScore = 1000;
this.isProcessing = false;
this.dragStartCell = null;
this.isDragging = false;
this.visualSwapActive = false;
this.visualSwapTarget = null;
this.mouseX = 0;
this.mouseY = 0;
this.dragOffsetX = 0;
this.dragOffsetY = 0;
this.targetOffsetX = 0;
this.targetOffsetY = 0;
this.currentOffsetX = 0;
this.currentOffsetY = 0;
this.animationFrameId = null;
this.buttonClickCooldown = false;
this.renderPending = false;
this.lastBoardState = null;
// Load best stats from localStorage
this.bestLevel = parseInt(localStorage.getItem('bestLevel') || '1');
this.bestScore = parseInt(localStorage.getItem('bestScore') || '0');
// Initialize sound system
this.soundManager = new SoundManager();
this.init();
}
init() {
this.setupEventListeners();
this.setupTheme();
this.showHome();
}
setupTheme() {
// Load saved theme or default to light
const savedTheme = localStorage.getItem('gameTheme') || 'light';
this.applyTheme(savedTheme);
// Setup theme selector
const themeSelect = document.getElementById('theme-select');
if (themeSelect) {
themeSelect.value = savedTheme;
themeSelect.addEventListener('change', (e) => {
this.applyTheme(e.target.value);
});
}
}
applyTheme(theme) {
const body = document.body;
if (theme === 'dark') {
body.classList.add('dark-theme');
body.classList.remove('light-theme');
} else {
body.classList.add('light-theme');
body.classList.remove('dark-theme');
}
localStorage.setItem('gameTheme', theme);
}
showHome() {
document.getElementById('home-page').classList.remove('hidden');
const gameHeader = document.querySelector('.game-header');
const gameBoard = document.getElementById('game-board');
const gameControls = document.querySelector('.game-controls');
if (gameHeader) gameHeader.classList.add('hidden');
if (gameBoard) gameBoard.classList.add('hidden');
if (gameControls) {
gameControls.classList.add('hidden');
gameControls.style.visibility = 'hidden';
}
// Stop background music when going home
if (this.soundManager) {
this.soundManager.stopBackgroundMusic();
}
this.updateHomeStats();
}
showGame() {
document.getElementById('home-page').classList.add('hidden');
const gameHeader = document.querySelector('.game-header');
const gameBoard = document.getElementById('game-board');
const gameControls = document.querySelector('.game-controls');
if (gameHeader) gameHeader.classList.remove('hidden');
if (gameBoard) gameBoard.classList.remove('hidden');
if (gameControls) {
gameControls.classList.remove('hidden');
gameControls.style.visibility = 'visible';
gameControls.style.display = 'flex';
gameControls.style.opacity = '1';
}
this.createBoard();
this.renderBoard();
this.updateUI();
// Start background music
if (this.soundManager) {
this.soundManager.startBackgroundMusic();
this.soundManager.updateMusicButton();
}
}
updateHomeStats() {
document.getElementById('best-level').textContent = this.bestLevel;
document.getElementById('best-score').textContent = this.bestScore.toLocaleString();
}
saveBestStats() {
let updated = false;
if (this.level > this.bestLevel) {
this.bestLevel = this.level;
localStorage.setItem('bestLevel', this.bestLevel.toString());
updated = true;
}
if (this.totalScore > this.bestScore) {
this.bestScore = this.totalScore;
localStorage.setItem('bestScore', this.bestScore.toString());
updated = true;
}
// Update home stats display if we're on home page
if (updated && !document.querySelector('.game-header').classList.contains('hidden')) {
// Will update when going to home
}
}
createBoard() {
// Initialize empty board
this.board = [];
for (let row = 0; row < this.boardRows; row++) {
this.board[row] = [];
for (let col = 0; col < this.boardCols; col++) {
this.board[row][col] = this.getRandomGem();
}
}
// Remove initial matches
while (this.findMatches().length > 0) {
for (let row = 0; row < this.boardRows; row++) {
for (let col = 0; col < this.boardCols; col++) {
if (this.isPartOfMatch(row, col)) {
this.board[row][col] = this.getRandomGem();
}
}
}
}
}
getRandomGem() {
return Math.floor(Math.random() * this.numGemTypes);
}
isPartOfMatch(row, col) {
const gem = this.board[row][col];
// Check horizontal match
let horizontalCount = 1;
for (let c = col - 1; c >= 0 && this.board[row][c] === gem; c--) horizontalCount++;
for (let c = col + 1; c < this.boardCols && this.board[row][c] === gem; c++) horizontalCount++;
if (horizontalCount >= 3) return true;
// Check vertical match
let verticalCount = 1;
for (let r = row - 1; r >= 0 && this.board[r][col] === gem; r--) verticalCount++;
for (let r = row + 1; r < this.boardRows && this.board[r][col] === gem; r++) verticalCount++;
if (verticalCount >= 3) return true;
return false;
}
renderBoard(forceUpdate = false) {
// Allow rendering during processing only if forceUpdate is true (for final render after matches)
if (this.isProcessing && !forceUpdate) {
this.renderPending = true;
return;
}
// Don't render during active drag unless forced
if (this.isDragging && !forceUpdate) {
this.renderPending = true;
return;
}
const boardElement = document.getElementById('game-board');
if (!boardElement) return;
// Check if board already exists - update instead of recreate
const existingCells = boardElement.querySelectorAll('.cell');
const needsFullRender = existingCells.length !== this.boardRows * this.boardCols;
if (needsFullRender) {
boardElement.innerHTML = '';
}
// Create board state string for comparison
const currentState = JSON.stringify(this.board);
const stateChanged = currentState !== this.lastBoardState;
// If state hasn't changed and we don't need full render, skip update completely
if (!needsFullRender && !stateChanged && !forceUpdate) {
return;
}
this.lastBoardState = currentState;
// Update cells directly without requestAnimationFrame to prevent flickering
for (let row = 0; row < this.boardRows; row++) {
for (let col = 0; col < this.boardCols; col++) {
let cell = existingCells[row * this.boardCols + col];
if (!cell || needsFullRender) {
cell = document.createElement('div');
cell.dataset.row = row;
cell.dataset.col = col;
// Click handler (fallback)
cell.addEventListener('click', () => this.handleCellClick(row, col));
// Drag handlers
cell.addEventListener('mousedown', (e) => this.handleDragStart(e, row, col));
cell.addEventListener('mouseenter', (e) => this.handleDragOver(e, row, col));
// Prevent text selection during drag
cell.addEventListener('selectstart', (e) => e.preventDefault());
cell.addEventListener('dragstart', (e) => e.preventDefault());
boardElement.appendChild(cell);
}
// Only update if gem type changed
const gemType = this.board[row][col];
const expectedClass = `cell gem-${gemType}`;
const expectedEmoji = this.getGemEmoji(gemType);
// Handle empty cells first
if (gemType === -1) {
// Hide empty cells
if (!cell.classList.contains('empty-cell')) {
cell.classList.add('empty-cell');
cell.style.display = 'none';
}
continue;
}
// Remove empty-cell class and ensure visibility for non-empty cells
if (cell.classList.contains('empty-cell')) {
cell.classList.remove('empty-cell');
cell.style.display = '';
cell.style.opacity = '';
cell.style.visibility = '';
}
// Check if cell needs update
const currentGemClass = Array.from(cell.classList).find(c => c.startsWith('gem-'));
const needsUpdate = needsFullRender ||
currentGemClass !== `gem-${gemType}` ||
cell.textContent !== expectedEmoji;
if (needsUpdate) {
// NEVER update cells that are in animation states
const isMatched = cell.classList.contains('matched');
const isMatchEnlarge = cell.classList.contains('match-enlarge');
const hasActiveAnimation = isMatched || isMatchEnlarge;
// Only update if not animating
if (!hasActiveAnimation) {
// Always ensure cell is visible for non-empty cells
cell.style.display = '';
cell.style.opacity = '';
cell.style.visibility = '';
cell.className = expectedClass;
cell.textContent = expectedEmoji;
// Only reset transform if cell is idle
if (!cell.style.transform || cell.style.transform === '') {
cell.style.transform = '';
cell.style.transition = '';
}
}
}
}
}
// Add board-level mousemove for better drag tracking
boardElement.addEventListener('mousemove', (e) => {
if (this.isDragging && this.dragStartCell) {
// Update mouse position for smooth tracking
this.mouseX = e.clientX;
this.mouseY = e.clientY;
const startCell = document.querySelector(
`[data-row="${this.dragStartCell.row}"][data-col="${this.dragStartCell.col}"]`
);
if (startCell) {
const rect = startCell.getBoundingClientRect();
// Calculate target offset (where we want to go)
this.targetOffsetX = (e.clientX - (rect.left + rect.width / 2)) * 0.6;
this.targetOffsetY = (e.clientY - (rect.top + rect.height / 2)) * 0.6;
this.dragOffsetX = this.targetOffsetX;
this.dragOffsetY = this.targetOffsetY;
}
const cell = e.target.closest('.cell');
if (cell && cell.dataset.row && cell.dataset.col) {
const row = parseInt(cell.dataset.row);
const col = parseInt(cell.dataset.col);
this.handleDragOver(e, row, col);
}
}
});
// Add global mouse up handler to handle drag end
document.addEventListener('mouseup', () => this.handleDragEnd());
}
getGemEmoji(gemType) {
if (gemType === -1) return ''; // Empty cell
const emojis = ['🔴', '🔵', '🟡', '🟢', '🟠', '🟣'];
return emojis[gemType] || '⚪';
}
handleDragStart(e, row, col) {
if (this.isProcessing) return;
if (this.moves <= 0) return; // No moves left
e.preventDefault();
this.dragStartCell = { row, col };
this.isDragging = true;
// Play swipe sound
if (this.soundManager) {
this.soundManager.playSwipeSound();
}
const cell = document.querySelector(`[data-row="${row}"][data-col="${col}"]`);
if (cell) {
const rect = cell.getBoundingClientRect();
const boardRect = document.getElementById('game-board').getBoundingClientRect();
// Store initial mouse position relative to cell center
this.mouseX = e.clientX;
this.mouseY = e.clientY;
this.dragOffsetX = 0;
this.dragOffsetY = 0;
this.targetOffsetX = 0;
this.targetOffsetY = 0;
this.currentOffsetX = 0;
this.currentOffsetY = 0;
cell.classList.add('selected', 'dragging');
cell.style.cursor = 'grabbing';
cell.style.transition = 'none'; // Disable transition during drag for smooth following
cell.style.willChange = 'transform';
// Start smooth position tracking
this.startDragTracking();
}
// Add dragging class to board for visual feedback
document.getElementById('game-board').classList.add('is-dragging');
}
startDragTracking() {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
}
const updateDragPosition = () => {
if (!this.isDragging || !this.dragStartCell) return;
const cell = document.querySelector(
`[data-row="${this.dragStartCell.row}"][data-col="${this.dragStartCell.col}"]`
);
if (cell) {
// Smooth interpolation using easing (ease-out cubic for fluid motion)
const smoothingFactor = 0.15; // Lower = smoother but slower response
this.currentOffsetX += (this.targetOffsetX - this.currentOffsetX) * smoothingFactor;
this.currentOffsetY += (this.targetOffsetY - this.currentOffsetY) * smoothingFactor;
// Apply smooth transform with fluid motion
const scale = 1.12 + Math.min(Math.abs(this.currentOffsetX) + Math.abs(this.currentOffsetY), 30) / 200;
const rotationX = this.currentOffsetY * 0.1;
const rotationY = this.currentOffsetX * 0.1;
cell.style.transform = `translate(${this.currentOffsetX}px, ${this.currentOffsetY}px) scale(${scale}) translateZ(20px) rotateX(${rotationX}deg) rotateY(${rotationY}deg)`;
}
this.animationFrameId = requestAnimationFrame(updateDragPosition);
};
this.animationFrameId = requestAnimationFrame(updateDragPosition);
}
handleDragOver(e, row, col) {
if (!this.isDragging || !this.dragStartCell) return;
if (this.isProcessing) return;
e.preventDefault();
// Don't update if hovering over the same cell
if (row === this.dragStartCell.row && col === this.dragStartCell.col) return;
// Check if target is adjacent
const isAdjacent =
(Math.abs(this.dragStartCell.row - row) === 1 && this.dragStartCell.col === col) ||
(Math.abs(this.dragStartCell.col - col) === 1 && this.dragStartCell.row === row);
const startCell = document.querySelector(
`[data-row="${this.dragStartCell.row}"][data-col="${this.dragStartCell.col}"]`
);
// If we have a visual swap active but target changed, revert it
if (this.visualSwapActive && this.visualSwapTarget) {
if (this.visualSwapTarget.row !== row || this.visualSwapTarget.col !== col) {
this.revertVisualSwap();
}
}
if (isAdjacent) {
const targetCell = document.querySelector(`[data-row="${row}"][data-col="${col}"]`);
// If this is a new target or we don't have a visual swap, create one
if (!this.visualSwapActive ||
!this.visualSwapTarget ||
this.visualSwapTarget.row !== row ||
this.visualSwapTarget.col !== col) {
// Revert any existing swap first
if (this.visualSwapActive) {
this.revertVisualSwap();
}
// Perform visual swap
this.performVisualSwap(this.dragStartCell.row, this.dragStartCell.col, row, col);
this.visualSwapTarget = { row, col };
}
if (targetCell) {
targetCell.classList.add('drag-target');
if (startCell) {
this.drawConnectionLine(startCell, targetCell);
}
}
} else {
// Not adjacent - revert visual swap if active
if (this.visualSwapActive) {
this.revertVisualSwap();
}
// Remove target from all cells
document.querySelectorAll('.cell').forEach(c => {
c.classList.remove('drag-target');
});
// Remove connection line if not adjacent
this.removeConnectionLine();
}
}
performVisualSwap(row1, col1, row2, col2) {
const cell1 = document.querySelector(`[data-row="${row1}"][data-col="${col1}"]`);
const cell2 = document.querySelector(`[data-row="${row2}"][data-col="${col2}"]`);
if (!cell1 || !cell2) return;
// Get cell positions for smooth swap animation
const rect1 = cell1.getBoundingClientRect();
const rect2 = cell2.getBoundingClientRect();
// Calculate relative positions in pixels for smoother animation
const deltaX = rect2.left - rect1.left;
const deltaY = rect2.top - rect1.top;
// Actually swap in the board array so match checking works correctly
[this.board[row1][col1], this.board[row2][col2]] =
[this.board[row2][col2], this.board[row1][col1]];
// Get the gem types (now swapped)
const gem1 = this.board[row1][col1];
const gem2 = this.board[row2][col2];
// Enable ultra-smooth transitions for swap animation
cell1.style.transition = 'transform 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
cell2.style.transition = 'transform 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
// Animate swap with fluid position interpolation
cell1.style.transform = `translate(${deltaX}px, ${deltaY}px) scale(1.08) translateZ(15px)`;
cell2.style.transform = `translate(${-deltaX}px, ${-deltaY}px) scale(1.08) translateZ(15px)`;
// Update the visual appearance mid-animation for seamless transition
setTimeout(() => {
cell1.className = `cell gem-${gem1}`;
cell1.textContent = this.getGemEmoji(gem1);
cell1.classList.add('selected', 'dragging');
cell2.className = `cell gem-${gem2}`;
cell2.textContent = this.getGemEmoji(gem2);
cell2.classList.add('drag-target');
}, 175); // Halfway through animation for smooth visual swap
// Reset transforms after animation completes
setTimeout(() => {
cell1.style.transform = '';
cell2.style.transform = '';
}, 350);
this.visualSwapActive = true;
}
revertVisualSwap() {
if (!this.visualSwapActive || !this.dragStartCell || !this.visualSwapTarget) return;
const cell1 = document.querySelector(
`[data-row="${this.dragStartCell.row}"][data-col="${this.dragStartCell.col}"]`
);
const cell2 = document.querySelector(
`[data-row="${this.visualSwapTarget.row}"][data-col="${this.visualSwapTarget.col}"]`
);
if (cell1 && cell2) {
// Smooth revert animation
cell1.style.transition = 'transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)';
cell2.style.transition = 'transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)';
// Animate back to original positions
cell1.style.transform = 'translate(0, 0) scale(1.1) translateZ(15px)';
cell2.style.transform = 'translate(0, 0) scale(1.1) translateZ(15px)';
// Revert the board array swap after animation
setTimeout(() => {
[this.board[this.dragStartCell.row][this.dragStartCell.col],
this.board[this.visualSwapTarget.row][this.visualSwapTarget.col]] =
[this.board[this.visualSwapTarget.row][this.visualSwapTarget.col],
this.board[this.dragStartCell.row][this.dragStartCell.col]];
// Restore original appearance
const gem1 = this.board[this.dragStartCell.row][this.dragStartCell.col];
const gem2 = this.board[this.visualSwapTarget.row][this.visualSwapTarget.col];
cell1.className = `cell gem-${gem1}`;
cell1.textContent = this.getGemEmoji(gem1);
cell1.classList.add('selected', 'dragging');
cell1.style.transform = '';
cell2.className = `cell gem-${gem2}`;
cell2.textContent = this.getGemEmoji(gem2);
cell2.style.transform = '';
// Render board after revert animation completes
requestAnimationFrame(() => {
this.renderBoard();
});
}, 300);
}
this.visualSwapActive = false;
this.visualSwapTarget = null;
}
drawConnectionLine(startCell, targetCell) {
// Remove existing line
this.removeConnectionLine();
const board = document.getElementById('game-board');
const startRect = startCell.getBoundingClientRect();
const targetRect = targetCell.getBoundingClientRect();
const boardRect = board.getBoundingClientRect();
// Calculate positions relative to board
const startX = startRect.left + startRect.width / 2 - boardRect.left;
const startY = startRect.top + startRect.height / 2 - boardRect.top;
const targetX = targetRect.left + targetRect.width / 2 - boardRect.left;
const targetY = targetRect.top + targetRect.height / 2 - boardRect.top;
// Create SVG line
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'drag-connection');
svg.style.position = 'absolute';
svg.style.left = '0';
svg.style.top = '0';
svg.style.width = '100%';
svg.style.height = '100%';
svg.style.pointerEvents = 'none';
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', startX);
line.setAttribute('y1', startY);
line.setAttribute('x2', targetX);
line.setAttribute('y2', targetY);
line.setAttribute('stroke', 'rgba(100, 150, 255, 0.9)');
line.setAttribute('stroke-width', '5');
line.setAttribute('stroke-dasharray', '8, 4');
line.setAttribute('marker-end', 'url(#arrowhead)');
// Add arrow marker
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
marker.setAttribute('id', 'arrowhead');
marker.setAttribute('markerWidth', '10');
marker.setAttribute('markerHeight', '10');
marker.setAttribute('refX', '9');
marker.setAttribute('refY', '3');
marker.setAttribute('orient', 'auto');
const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
polygon.setAttribute('points', '0 0, 10 3, 0 6');
polygon.setAttribute('fill', 'rgba(100, 150, 255, 0.9)');
marker.appendChild(polygon);
defs.appendChild(marker);
svg.appendChild(defs);
svg.appendChild(line);
board.style.position = 'relative';
board.appendChild(svg);
}
removeConnectionLine() {
const existingLine = document.querySelector('.drag-connection');
if (existingLine) {
existingLine.remove();
}
}
handleDragEnd() {
if (!this.isDragging || !this.dragStartCell) return;
// If we have a visual swap active, commit it to the game logic
if (this.visualSwapActive && this.visualSwapTarget) {
// The board array is already swapped from performVisualSwap
// Just check for matches and process
const matches = this.findMatches();
if (matches.length > 0) {
// Valid swap
this.selectedCell = null;
this.updateCellSelection();
if (this.moves > 0) this.moves--;
// Animate matched cells by enlarging them
this.animateMatchedCells(matches).then(() => {
this.processMatches();
});
} else {
// Invalid swap - revert
this.revertVisualSwap();
// Don't call renderBoard here - revertVisualSwap handles it
}
// Clear visual swap state
this.visualSwapActive = false;
this.visualSwapTarget = null;
} else {
// No visual swap was active, check if there's a target cell
const targetCell = document.querySelector('.drag-target');
if (targetCell) {
const targetRow = parseInt(targetCell.dataset.row);
const targetCol = parseInt(targetCell.dataset.col);
this.swapCells(
this.dragStartCell.row,
this.dragStartCell.col,
targetRow,
targetCol
);
} else {
// No valid target - just clean up
if (this.visualSwapActive) {
this.revertVisualSwap();
}
}
}
// Remove connection line
this.removeConnectionLine();
// Reset drag state first
this.isDragging = false;
const dragStart = this.dragStartCell;
this.dragStartCell = null;
// Cancel animation frame
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
// Smoothly reset all drag-related styles using requestAnimationFrame for smoothness
requestAnimationFrame(() => {
document.querySelectorAll('.cell').forEach(c => {
// Only reset if not in an animation state
if (!c.classList.contains('matched') && !c.classList.contains('match-enlarge')) {
c.style.transition = 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.2s ease';
c.style.transform = '';
c.style.willChange = '';
c.classList.remove('selected', 'drag-target', 'dragging');
c.style.cursor = '';
// Remove inline styles after transition
setTimeout(() => {
if (!c.classList.contains('matched')) {
c.style.transition = '';
}
}, 300);
}
});
});
// Reset offsets
this.currentOffsetX = 0;
this.currentOffsetY = 0;
this.targetOffsetX = 0;
this.targetOffsetY = 0;
// Remove board dragging class
document.getElementById('game-board').classList.remove('is-dragging');
}
handleCellClick(row, col) {
// Clicking only allows visual selection, no matching allowed
// Matching is only allowed through drag and drop
if (this.isProcessing) return;
if (this.isDragging) return; // Ignore clicks during drag
// Just allow visual selection for feedback, but don't perform swaps
this.selectedCell = { row, col };
this.updateCellSelection();
}
updateCellSelection() {
const cells = document.querySelectorAll('.cell');
cells.forEach(cell => cell.classList.remove('selected'));
if (this.selectedCell) {
const cell = document.querySelector(
`[data-row="${this.selectedCell.row}"][data-col="${this.selectedCell.col}"]`
);
if (cell) cell.classList.add('selected');
}
}
swapCells(row1, col1, row2, col2) {
// If visual swap is already active, the board array is already swapped
// Otherwise, swap it now
if (!this.visualSwapActive) {
// Swap in board
[this.board[row1][col1], this.board[row2][col2]] =
[this.board[row2][col2], this.board[row1][col1]];
}
// Check if swap creates a match
const matches = this.findMatches();
if (matches.length > 0) {
// Valid swap - visual swap already shows the correct state (or we just swapped)
this.selectedCell = null;
this.updateCellSelection();
if (this.moves > 0) this.moves--;
// Play match sound
if (this.soundManager) {
this.soundManager.playMatchSound(matches.length);
}
// Animate matched cells by enlarging them
this.animateMatchedCells(matches).then(() => {
this.processMatches();
});
} else {
// Invalid swap - revert both visual and logical
if (this.visualSwapActive) {
// Visual swap will be reverted by revertVisualSwap, but we need to revert the array
[this.board[row1][col1], this.board[row2][col2]] =
[this.board[row2][col2], this.board[row1][col1]];
} else {
// Revert the swap we just made
[this.board[row1][col1], this.board[row2][col2]] =
[this.board[row2][col2], this.board[row1][col1]];
}
// Don't call renderBoard here - revertVisualSwap handles visual update
// renderBoard will be called after revert completes
}
}
async animateMatchedCells(matches) {
// No animation - skip immediately
// This function is kept for compatibility but does nothing now
await this.sleep(0);
}
updateCellsAfterMatch() {
// This function is no longer needed - we use renderBoard(true) instead
// Keeping it for backwards compatibility but it's essentially a no-op now
}
findMatches() {
const matches = [];
const visited = new Set();
// Check horizontal matches
for (let row = 0; row < this.boardRows; row++) {
let count = 1;
let currentGem = this.board[row][0];
for (let col = 1; col < this.boardCols; col++) {
if (this.board[row][col] === currentGem) {
count++;
} else {
if (count >= 3) {
for (let c = col - count; c < col; c++) {
const key = `${row},${c}`;
if (!visited.has(key)) {
matches.push({ row, col: c });
visited.add(key);
}
}
}
count = 1;
currentGem = this.board[row][col];
}
}
// Check end of row
if (count >= 3) {
for (let c = this.boardCols - count; c < this.boardCols; c++) {
const key = `${row},${c}`;
if (!visited.has(key)) {
matches.push({ row, col: c });
visited.add(key);
}
}
}
}
// Check vertical matches
for (let col = 0; col < this.boardCols; col++) {
let count = 1;
let currentGem = this.board[0][col];
for (let row = 1; row < this.boardRows; row++) {
if (this.board[row][col] === currentGem) {
count++;
} else {
if (count >= 3) {
for (let r = row - count; r < row; r++) {
const key = `${r},${col}`;
if (!visited.has(key)) {
matches.push({ row: r, col });
visited.add(key);
}
}
}
count = 1;
currentGem = this.board[row][col];
}
}
// Check end of column
if (count >= 3) {
for (let r = this.boardRows - count; r < this.boardRows; r++) {
const key = `${r},${col}`;
if (!visited.has(key)) {
matches.push({ row: r, col });
visited.add(key);
}
}
}
}
return matches;
}
async processMatches() {
this.isProcessing = true;
while (true) {
const matches = this.findMatches();
if (matches.length === 0) {
break;
}
// Update score
this.score += matches.length * 10;
this.updateUI();
// Animate matched cells - add matched class to trigger fade-out
matches.forEach(match => {
const cell = document.querySelector(
`[data-row="${match.row}"][data-col="${match.col}"]`
);
if (cell) {
cell.classList.add('matched');
// Create random particles for each matched cell
this.createExplosionParticles(cell);
}
});
// No animation wait - tiles disappear immediately
await this.sleep(50);
// Clean up particles
document.querySelectorAll('.explosion-particle').forEach(particle => {
particle.remove();
});
// Remove matched cells from board and DOM
matches.forEach(match => {
this.board[match.row][match.col] = -1; // Mark as empty
// Mark cell as empty - renderBoard will handle hiding it
// Don't manipulate DOM directly here to prevent flickering
});
// Make pieces fall
await this.applyGravity();
// Fill empty spaces with new random gems
this.fillEmptySpaces();
// Re-render board to show new tiles - force update even during processing
this.renderBoard(true);
// Small delay to let the new tiles appear before checking for new matches
await this.sleep(100);
}
this.isProcessing = false;
// Render any pending updates after processing completes - do it synchronously
if (this.renderPending) {
this.renderPending = false;
this.renderBoard(true);
}
this.checkGameOver();
}
async applyGravity() {
for (let col = 0; col < this.boardCols; col++) {
let writeIndex = this.boardRows - 1;
for (let row = this.boardRows - 1; row >= 0; row--) {
if (this.board[row][col] !== -1) {
if (writeIndex !== row) {
this.board[writeIndex][col] = this.board[row][col];
this.board[row][col] = -1;
}
writeIndex--;
}
}
}
}
fillEmptySpaces() {
for (let row = 0; row < this.boardRows; row++) {
for (let col = 0; col < this.boardCols; col++) {
if (this.board[row][col] === -1) {
this.board[row][col] = this.getRandomGem();
}
}
}
}
checkGameOver() {
// Check if level is complete (reached target score)
if (this.score >= this.levelTargetScore) {
this.completeLevel();
return;
}
// Check if moves are exhausted and target not reached - game over
if (this.moves <= 0 && this.score < this.levelTargetScore) {
this.gameOver();
}
}
gameOver() {
// Pause the game
this.isProcessing = true;
// Save best stats
this.saveBestStats();
// Play game over sound
if (this.soundManager) {
this.soundManager.playGameOverSound();
}
// Show game over modal
document.getElementById('final-level').textContent = this.level;
document.getElementById('final-score').textContent = this.totalScore.toLocaleString();
document.getElementById('game-over').classList.remove('hidden');
}
retryLevel() {
// Hide game over modal
document.getElementById('game-over').classList.add('hidden');
// Reset level score (keep total score and level)
this.score = 0;
// Reset moves for current level
this.moves = this.getLevelMoves(this.level);
// Reset game state
this.selectedCell = null;
this.isProcessing = false;
// Create new board
this.createBoard();
this.renderBoard();
this.updateUI();
}
completeLevel() {
// Pause the game
this.isProcessing = true;
// Update total score
this.totalScore += this.score;
// Play level complete sound
if (this.soundManager) {
this.soundManager.playLevelCompleteSound();
}
// Show level complete modal
document.getElementById('completed-level').textContent = this.level;
document.getElementById('level-score').textContent = this.score;
document.getElementById('total-score').textContent = this.totalScore;
document.getElementById('level-complete').classList.remove('hidden');
}
continueToNextLevel() {
// Hide level complete modal
document.getElementById('level-complete').classList.add('hidden');
// Increment level
this.level++;
// Save best stats
this.saveBestStats();
// Increase difficulty: more score needed, but also more moves
this.levelTargetScore = this.level * 1000;
this.moves = this.getLevelMoves(this.level); // Use correct calculation
// Reset level score (keep total score)
this.score = 0;
// Create new board
this.createBoard();
this.renderBoard();
this.updateUI();
// Resume game
this.isProcessing = false;
}
goToHome() {
// Save best stats before going home
this.saveBestStats();
// Hide all modals
document.getElementById('level-complete').classList.add('hidden');
document.getElementById('game-over').classList.add('hidden');
// Show home page
this.showHome();
}
endGame() {
// Go to home from game over screen
this.goToHome();
}
getLevelMoves(level) {
// Level 1: 30 moves, Level 2+: 25 + (level * 2)
return level === 1 ? 30 : 25 + (level * 2);
}
updateUI() {
document.getElementById('score').textContent = this.score;
document.getElementById('moves').textContent = Math.max(0, this.moves);
document.getElementById('level').textContent = this.level;
document.getElementById('target-score').textContent = this.levelTargetScore.toLocaleString();
// Calculate and display the correct level moves
const levelMoves = this.getLevelMoves(this.level);
document.getElementById('level-moves').textContent = levelMoves;
// Show progress to next level (optional visual indicator)
const progress = Math.min(100, (this.score / this.levelTargetScore) * 100);
// Could add a progress bar here if desired
}
setupEventListeners() {
// Helper function to prevent rapid clicks
const preventRapidClicks = (callback) => {
return (e) => {
if (this.buttonClickCooldown) {
e.preventDefault();
e.stopPropagation();
return;
}
this.buttonClickCooldown = true;
const button = e.currentTarget;
// Play click sound
if (this.soundManager) {
this.soundManager.playClickSound();
}
// Add visual feedback
button.style.transform = 'scale(0.95)';
setTimeout(() => {
button.style.transform = '';
}, 150);
// Execute callback
callback();
// Reset cooldown after a short delay
setTimeout(() => {
this.buttonClickCooldown = false;
}, 300);
};
};
document.getElementById('start-game-btn').addEventListener('click', preventRapidClicks(() => {
this.startNewGame();
}));
document.getElementById('home-btn').addEventListener('click', preventRapidClicks(() => {
this.goToHome();
}));
document.getElementById('reset-btn').addEventListener('click', preventRapidClicks(() => {
this.resetGame();
}));
document.getElementById('retry-btn').addEventListener('click', preventRapidClicks(() => {
this.retryLevel();
}));
document.getElementById('quit-game-btn').addEventListener('click', preventRapidClicks(() => {
this.goToHome();
}));
document.getElementById('hint-btn').addEventListener('click', preventRapidClicks(() => {
this.showHint();
}));
document.getElementById('continue-btn').addEventListener('click', preventRapidClicks(() => {
this.continueToNextLevel();
}));
document.getElementById('quit-btn').addEventListener('click', preventRapidClicks(() => {
this.goToHome();
}));
// Music toggle button
const musicToggleBtn = document.getElementById('music-toggle-btn');
if (musicToggleBtn) {
musicToggleBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (this.soundManager) {
this.soundManager.toggleMusic();
this.soundManager.playClickSound();
}
});
}
}
startNewGame() {
this.score = 0;
this.totalScore = 0;
this.moves = 30;
this.level = 1;
this.levelTargetScore = 1000;
this.selectedCell = null;
this.isProcessing = false;
this.showGame();
}
resetGame() {
// Reset current game but stay on game screen
this.score = 0;
this.totalScore = 0;
this.moves = 30;
this.level = 1;
this.levelTargetScore = 1000;
this.selectedCell = null;
this.isProcessing = false;
document.getElementById('game-over').classList.add('hidden');
document.getElementById('level-complete').classList.add('hidden');
this.createBoard();
this.renderBoard();
this.updateUI();
}
showHint() {
// Find a valid move
for (let row = 0; row < this.boardRows - 1; row++) {
for (let col = 0; col < this.boardCols; col++) {
// Try swapping down
[this.board[row][col], this.board[row + 1][col]] =
[this.board[row + 1][col], this.board[row][col]];
if (this.findMatches().length > 0) {
// Valid move found - highlight it
[this.board[row][col], this.board[row + 1][col]] =
[this.board[row + 1][col], this.board[row][col]];
const cell1 = document.querySelector(`[data-row="${row}"][data-col="${col}"]`);
const cell2 = document.querySelector(`[data-row="${row + 1}"][data-col="${col}"]`);
if (cell1 && cell2) {
cell1.style.boxShadow = '0 0 0 4px #00ff00';
cell2.style.boxShadow = '0 0 0 4px #00ff00';
setTimeout(() => {
cell1.style.boxShadow = '';
cell2.style.boxShadow = '';
}, 2000);
}
return;
}
[this.board[row][col], this.board[row + 1][col]] =
[this.board[row + 1][col], this.board[row][col]];
}
}
for (let row = 0; row < this.boardRows; row++) {
for (let col = 0; col < this.boardCols - 1; col++) {
// Try swapping right
[this.board[row][col], this.board[row][col + 1]] =
[this.board[row][col + 1], this.board[row][col]];
if (this.findMatches().length > 0) {
// Valid move found
[this.board[row][col], this.board[row][col + 1]] =
[this.board[row][col + 1], this.board[row][col]];
const cell1 = document.querySelector(`[data-row="${row}"][data-col="${col}"]`);
const cell2 = document.querySelector(`[data-row="${row}"][data-col="${col + 1}"]`);
if (cell1 && cell2) {
cell1.style.boxShadow = '0 0 0 4px #00ff00';
cell2.style.boxShadow = '0 0 0 4px #00ff00';
setTimeout(() => {
cell1.style.boxShadow = '';
cell2.style.boxShadow = '';
}, 2000);
}
return;
}
[this.board[row][col], this.board[row][col + 1]] =
[this.board[row][col + 1], this.board[row][col]];
}
}
alert('No valid moves found! Shuffling board...');
this.createBoard();
this.renderBoard();
}
createExplosionParticles(cell) {
const particleEmojis = ['✨', '⭐', '💫', '🌟', '⚡', '💥'];
const numParticles = 4 + Math.floor(Math.random() * 3); // 4-6 particles per cell
for (let i = 0; i < numParticles; i++) {
const particle = document.createElement('div');
particle.className = 'explosion-particle';
particle.textContent = particleEmojis[Math.floor(Math.random() * particleEmojis.length)];
// Generate random nonlinear path
const angle = (Math.PI * 2 * i) / numParticles + (Math.random() - 0.5) * 0.8; // Spread around circle with randomness
const distance = 20 + Math.random() * 15; // 20-35px distance (scaled down)
const curveAmount = 7 + Math.random() * 10; // Curve variation (scaled down)
// Create curved path with 3 control points
const x1 = Math.cos(angle) * (distance * 0.3) + (Math.random() - 0.5) * curveAmount;
const y1 = Math.sin(angle) * (distance * 0.3) + (Math.random() - 0.5) * curveAmount;
const x2 = Math.cos(angle) * (distance * 0.6) + (Math.random() - 0.5) * curveAmount * 1.5;
const y2 = Math.sin(angle) * (distance * 0.6) + (Math.random() - 0.5) * curveAmount * 1.5;
const x3 = Math.cos(angle) * distance + (Math.random() - 0.5) * curveAmount * 2;
const y3 = Math.sin(angle) * distance + (Math.random() - 0.5) * curveAmount * 2;
// Random rotation
const rotation = (Math.random() > 0.5 ? 1 : -1) * (720 + Math.random() * 720); // 720-1440 degrees
// Random scale variation
const scale = 0.05 + Math.random() * 0.03; // 0.05-0.08
// Set CSS custom properties for animation
particle.style.setProperty('--particle-x1', `${x1}px`);
particle.style.setProperty('--particle-y1', `${y1}px`);
particle.style.setProperty('--particle-x2', `${x2}px`);
particle.style.setProperty('--particle-y2', `${y2}px`);
particle.style.setProperty('--particle-x3', `${x3}px`);
particle.style.setProperty('--particle-y3', `${y3}px`);
particle.style.setProperty('--particle-rotate', `${rotation}deg`);
particle.style.setProperty('--particle-scale', scale.toString());
// Random delay for staggered effect
particle.style.animationDelay = `${Math.random() * 0.2}s`;
cell.appendChild(particle);
}
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Sound Manager Class
class SoundManager {
constructor() {
this.audioContext = null;
this.musicEnabled = localStorage.getItem('musicEnabled') !== 'false';
this.soundsEnabled = localStorage.getItem('soundsEnabled') !== 'false';
this.musicVolume = parseFloat(localStorage.getItem('musicVolume') || '0.5');
this.sfxVolume = parseFloat(localStorage.getItem('sfxVolume') || '0.7');
this.musicOscillator = null;
this.musicGainNode = null;
this.isMusicPlaying = false;
this.initAudioContext();
this.setupVolumeControls();
}
setupVolumeControls() {
// Setup music volume control
const musicVolumeSlider = document.getElementById('music-volume');
if (musicVolumeSlider) {
musicVolumeSlider.value = this.musicVolume * 100;
musicVolumeSlider.addEventListener('input', (e) => {
this.musicVolume = e.target.value / 100;
localStorage.setItem('musicVolume', this.musicVolume);
});
}
// Setup SFX volume control
const sfxVolumeSlider = document.getElementById('sfx-volume');
if (sfxVolumeSlider) {
sfxVolumeSlider.value = this.sfxVolume * 100;
sfxVolumeSlider.addEventListener('input', (e) => {
this.sfxVolume = e.target.value / 100;
localStorage.setItem('sfxVolume', this.sfxVolume);
});
}
}
initAudioContext() {
try {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
} catch (e) {
console.warn('Web Audio API not supported');
}
}
// Start background music (looping, fun upbeat melody)
startBackgroundMusic() {
if (!this.audioContext || !this.musicEnabled || this.isMusicPlaying) return;
try {
this.isMusicPlaying = true;
this.playMusicLoop();
} catch (e) {
console.warn('Could not start music:', e);
}
}
playMusicLoop() {
if (!this.audioContext || !this.musicEnabled) return;
const notes = [261.63, 293.66, 329.63, 349.23, 392.00, 440.00, 493.88, 523.25]; // C major scale
let noteIndex = 0;
const playNote = () => {
if (!this.musicEnabled || !this.isMusicPlaying) return;
const frequency = notes[noteIndex % notes.length];
const oscillator = this.audioContext.createOscillator();
const gainNode = this.audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(this.audioContext.destination);
oscillator.type = 'sine';
oscillator.frequency.value = frequency;
const baseGain = this.musicVolume * 0.1;
gainNode.gain.setValueAtTime(baseGain, this.audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(baseGain * 0.5, this.audioContext.currentTime + 0.3);
gainNode.gain.exponentialRampToValueAtTime(baseGain * 0.1, this.audioContext.currentTime + 0.6);
oscillator.start(this.audioContext.currentTime);
oscillator.stop(this.audioContext.currentTime + 0.6);
noteIndex++;
setTimeout(() => {
if (this.musicEnabled && this.isMusicPlaying) {
playNote();
}
}, 300);
};
playNote();
}
stopBackgroundMusic() {
this.isMusicPlaying = false;
if (this.musicOscillator) {
this.musicOscillator.stop();
this.musicOscillator = null;
}
}
toggleMusic() {
this.musicEnabled = !this.musicEnabled;
localStorage.setItem('musicEnabled', this.musicEnabled);
if (this.musicEnabled) {
this.startBackgroundMusic();
} else {
this.stopBackgroundMusic();
}
this.updateMusicButton();
}
updateMusicButton() {
const btn = document.getElementById('music-toggle-btn');
if (btn) {
btn.textContent = this.musicEnabled ? '🔊' : '🔇';
}
}
// Play swipe sound (whoosh)
playSwipeSound() {
if (!this.audioContext || !this.soundsEnabled) return;
const oscillator = this.audioContext.createOscillator();
const gainNode = this.audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(this.audioContext.destination);
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(400, this.audioContext.currentTime);
oscillator.frequency.exponentialRampToValueAtTime(200, this.audioContext.currentTime + 0.1);
const volume = this.sfxVolume * 0.2;
gainNode.gain.setValueAtTime(volume, this.audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(volume * 0.05, this.audioContext.currentTime + 0.1);
oscillator.start(this.audioContext.currentTime);
oscillator.stop(this.audioContext.currentTime + 0.1);
}
// Play match sound (successful match - cheerful chime)
playMatchSound(count = 1) {
if (!this.audioContext || !this.soundsEnabled) return;
const frequencies = [523.25, 659.25, 783.99]; // C, E, G chord
const delay = 0.05;
frequencies.forEach((freq, index) => {
setTimeout(() => {
const oscillator = this.audioContext.createOscillator();
const gainNode = this.audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(this.audioContext.destination);
oscillator.type = 'sine';
oscillator.frequency.value = freq * (1 + count * 0.1);
const volume = this.sfxVolume * 0.15;
gainNode.gain.setValueAtTime(volume, this.audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(volume * 0.07, this.audioContext.currentTime + 0.3);
oscillator.start(this.audioContext.currentTime);
oscillator.stop(this.audioContext.currentTime + 0.3);
}, index * delay);
});
}
// Play level complete sound (victory fanfare)
playLevelCompleteSound() {
if (!this.audioContext || !this.soundsEnabled) return;
const melody = [523.25, 659.25, 783.99, 1046.50, 783.99, 1046.50];
melody.forEach((freq, index) => {
setTimeout(() => {
const oscillator = this.audioContext.createOscillator();
const gainNode = this.audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(this.audioContext.destination);
oscillator.type = 'sine';
oscillator.frequency.value = freq;
const volume = this.sfxVolume * 0.2;
gainNode.gain.setValueAtTime(volume, this.audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(volume * 0.05, this.audioContext.currentTime + 0.4);
oscillator.start(this.audioContext.currentTime);
oscillator.stop(this.audioContext.currentTime + 0.4);
}, index * 150);
});
}
// Play game over sound (sad tone)
playGameOverSound() {
if (!this.audioContext || !this.soundsEnabled) return;
const frequencies = [392.00, 349.23, 293.66, 261.63];
frequencies.forEach((freq, index) => {
setTimeout(() => {
const oscillator = this.audioContext.createOscillator();
const gainNode = this.audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(this.audioContext.destination);
oscillator.type = 'sine';
oscillator.frequency.value = freq;
const volume = this.sfxVolume * 0.15;
gainNode.gain.setValueAtTime(volume, this.audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(volume * 0.07, this.audioContext.currentTime + 0.5);
oscillator.start(this.audioContext.currentTime);
oscillator.stop(this.audioContext.currentTime + 0.5);
}, index * 200);
});
}
// Play button click sound
playClickSound() {
if (!this.audioContext || !this.soundsEnabled) return;
const oscillator = this.audioContext.createOscillator();
const gainNode = this.audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(this.audioContext.destination);
oscillator.type = 'sine';
oscillator.frequency.value = 800;
const volume = this.sfxVolume * 0.1;
gainNode.gain.setValueAtTime(volume, this.audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(volume * 0.1, this.audioContext.currentTime + 0.05);
oscillator.start(this.audioContext.currentTime);
oscillator.stop(this.audioContext.currentTime + 0.05);
}
}
// Initialize game when page loads
let game;
window.addEventListener('DOMContentLoaded', () => {
game = new Match3Game();
});