1153 lines
No EOL
41 KiB
JavaScript
1153 lines
No EOL
41 KiB
JavaScript
// Game settings - tweak these to adjust difficulty and feel
|
|
const gameConfig = {
|
|
difficulties: {
|
|
easy: {
|
|
lives: Infinity,
|
|
timer: 180, // 3 min timer
|
|
baseSpeed: 2,
|
|
speedVariation: 0.8, // Makes squares move at slightly different speeds
|
|
initialSquares: 10,
|
|
maxSquares: 15,
|
|
squaresPerLevel: 2,
|
|
speedIncrease: 0.2,
|
|
redSquareRatio: 0.2, // 1 in 5 squares are red
|
|
timeBonus: 30 // 30s bonus for completing level
|
|
},
|
|
medium: {
|
|
lives: 5,
|
|
timer: 120, // 2 min timer
|
|
baseSpeed: 3,
|
|
speedVariation: 0.8,
|
|
initialSquares: 15,
|
|
maxSquares: 20,
|
|
squaresPerLevel: 3,
|
|
speedIncrease: 0.3,
|
|
redSquareRatio: 0.3, // 1 in 3 squares are red
|
|
timeBonus: 20
|
|
},
|
|
hard: {
|
|
lives: 3,
|
|
timer: 90, // 1.5 min timer
|
|
baseSpeed: 4,
|
|
speedVariation: 1,
|
|
initialSquares: 20,
|
|
maxSquares: 25,
|
|
squaresPerLevel: 4,
|
|
speedIncrease: 0.4,
|
|
redSquareRatio: 0.4, // 2 in 5 squares are red
|
|
timeBonus: 15
|
|
}
|
|
},
|
|
colors: {
|
|
good: '#4ecca3', // Green
|
|
bad: '#ff6b6b', // Red
|
|
bonus: '#ffd93d' // Yellow
|
|
},
|
|
trailLength: 20, // How long the square trails are
|
|
particleCount: 20, // Number of particles in explosions
|
|
trailParticleSize: 3, // Size of trail particles
|
|
sparkleCount: 8, // Number of sparkles around bonus squares
|
|
backgroundStars: 100 // Number of stars in the background
|
|
};
|
|
|
|
// Cookie handling - saves player preferences
|
|
function setCookie(name, value, days) {
|
|
const date = new Date();
|
|
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
|
|
const expires = "expires=" + date.toUTCString();
|
|
document.cookie = name + "=" + value + ";" + expires + ";path=/";
|
|
}
|
|
|
|
function getCookie(name) {
|
|
const nameEQ = name + "=";
|
|
const ca = document.cookie.split(';');
|
|
for(let i = 0; i < ca.length; i++) {
|
|
let c = ca[i];
|
|
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
|
|
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Game state - tracks everything that changes during gameplay
|
|
let gameState = {
|
|
selectedDifficulty: getCookie('difficulty') || 'medium',
|
|
selectedMode: getCookie('mode') || 'levels',
|
|
isPlaying: false,
|
|
score: 0,
|
|
level: 1,
|
|
lives: 5,
|
|
startTime: null,
|
|
gameTime: 0,
|
|
currentSpeed: 0,
|
|
levelStartTime: null,
|
|
levelTime: 0,
|
|
remainingTime: 0,
|
|
currentCombo: 0,
|
|
maxCombo: 0,
|
|
lastUpdateTime: null // Used for accurate timer updates
|
|
};
|
|
|
|
// DOM Elements
|
|
const difficultyButtons = document.querySelectorAll('.difficulty-btn');
|
|
const modeButtons = document.querySelectorAll('.mode-btn');
|
|
const restartButton = document.getElementById('restart-game');
|
|
|
|
// Add popup elements
|
|
const lifeLossPopup = document.createElement('div');
|
|
lifeLossPopup.className = 'life-loss-popup';
|
|
document.body.appendChild(lifeLossPopup);
|
|
|
|
const pointGainPopup = document.createElement('div');
|
|
pointGainPopup.className = 'point-gain-popup';
|
|
document.body.appendChild(pointGainPopup);
|
|
|
|
function showLifeLossPopup(livesLost) {
|
|
lifeLossPopup.textContent = `-${livesLost} ${livesLost === 1 ? 'Life' : 'Lives'}`;
|
|
lifeLossPopup.classList.add('show');
|
|
setTimeout(() => {
|
|
lifeLossPopup.classList.remove('show');
|
|
}, 1000);
|
|
}
|
|
|
|
function showPointGainPopup(points) {
|
|
pointGainPopup.textContent = `+${points} ${points === 1 ? 'Point' : 'Points'}`;
|
|
pointGainPopup.classList.add('show');
|
|
setTimeout(() => {
|
|
pointGainPopup.classList.remove('show');
|
|
}, 1000);
|
|
}
|
|
|
|
// Add death screen handling
|
|
const deathScreen = document.querySelector('.death-screen');
|
|
const finalScore = document.querySelector('.final-score');
|
|
const finalLevel = document.querySelector('.final-level');
|
|
const finalTime = document.querySelector('.final-time');
|
|
|
|
function showDeathScreen() {
|
|
// Update final stats
|
|
finalScore.textContent = gameState.score;
|
|
finalLevel.textContent = gameState.level;
|
|
|
|
// Format time
|
|
const minutes = Math.floor(gameState.gameTime / 60);
|
|
const seconds = Math.floor(gameState.gameTime % 60);
|
|
finalTime.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
|
|
// Hide level display in endless mode
|
|
const levelRow = document.querySelector('.death-screen .stat-row:nth-child(2)');
|
|
if (gameState.selectedMode === 'endless') {
|
|
levelRow.style.display = 'none';
|
|
} else {
|
|
levelRow.style.display = 'flex';
|
|
}
|
|
|
|
// Update mode and difficulty badges with proper capitalization
|
|
const modeText = gameState.selectedMode.charAt(0).toUpperCase() + gameState.selectedMode.slice(1);
|
|
const difficultyText = gameState.selectedDifficulty.charAt(0).toUpperCase() + gameState.selectedDifficulty.slice(1);
|
|
|
|
document.querySelector('.mode-text').textContent = modeText;
|
|
document.querySelector('.difficulty-text').textContent = difficultyText;
|
|
|
|
// Show death screen
|
|
deathScreen.classList.add('show');
|
|
}
|
|
|
|
// Set initial active states
|
|
function setInitialStates() {
|
|
// Set difficulty
|
|
difficultyButtons.forEach(button => {
|
|
if (button.dataset.difficulty === gameState.selectedDifficulty) {
|
|
button.classList.add('active');
|
|
}
|
|
});
|
|
|
|
// Set game mode
|
|
modeButtons.forEach(button => {
|
|
if (button.dataset.mode === gameState.selectedMode) {
|
|
button.classList.add('active');
|
|
}
|
|
});
|
|
|
|
// Show initial explanations
|
|
document.querySelector(`.${gameState.selectedDifficulty}-info`).style.display = 'block';
|
|
document.querySelector(`.${gameState.selectedMode}-info`).style.display = 'block';
|
|
}
|
|
|
|
// Event Listeners
|
|
difficultyButtons.forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
difficultyButtons.forEach(btn => btn.classList.remove('active'));
|
|
button.classList.add('active');
|
|
gameState.selectedDifficulty = button.dataset.difficulty;
|
|
setCookie('difficulty', gameState.selectedDifficulty, 365);
|
|
|
|
// Update info text visibility
|
|
document.querySelectorAll('.difficulty-info .info-text').forEach(info => info.style.display = 'none');
|
|
document.querySelector(`.${gameState.selectedDifficulty}-info`).style.display = 'block';
|
|
|
|
restartGame();
|
|
});
|
|
});
|
|
|
|
modeButtons.forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
modeButtons.forEach(btn => btn.classList.remove('active'));
|
|
button.classList.add('active');
|
|
gameState.selectedMode = button.dataset.mode;
|
|
setCookie('mode', gameState.selectedMode, 365);
|
|
|
|
// Update info text visibility
|
|
document.querySelectorAll('.mode-info .info-text').forEach(info => info.style.display = 'none');
|
|
document.querySelector(`.${gameState.selectedMode}-info`).style.display = 'block';
|
|
|
|
restartGame();
|
|
});
|
|
});
|
|
|
|
restartButton.addEventListener('click', restartGame);
|
|
|
|
// Set initial states when the page loads
|
|
setInitialStates();
|
|
|
|
// Start game automatically
|
|
window.addEventListener('load', () => {
|
|
startGame();
|
|
});
|
|
|
|
function restartGame() {
|
|
// Clear timer interval
|
|
if (gameState.timerInterval) {
|
|
clearInterval(gameState.timerInterval);
|
|
}
|
|
|
|
// Remove existing canvas if it exists
|
|
const existingCanvas = document.getElementById('gameCanvas');
|
|
if (existingCanvas) {
|
|
existingCanvas.remove();
|
|
}
|
|
|
|
// Reset game state
|
|
gameState.score = 0;
|
|
gameState.level = 1;
|
|
gameState.lives = 5;
|
|
gameState.currentSpeed = 0;
|
|
gameState.currentCombo = 0;
|
|
gameState.maxCombo = 0;
|
|
updateStats();
|
|
|
|
// Reset timers
|
|
gameState.startTime = null;
|
|
gameState.levelStartTime = null;
|
|
gameState.lastUpdateTime = null;
|
|
gameState.gameTime = 0;
|
|
gameState.levelTime = 0;
|
|
gameState.remainingTime = 0;
|
|
|
|
// Start new game
|
|
startGame();
|
|
}
|
|
|
|
function startGame() {
|
|
gameState.isPlaying = true;
|
|
gameState.startTime = Date.now();
|
|
gameState.levelStartTime = Date.now();
|
|
gameState.lastUpdateTime = Date.now();
|
|
gameState.gameTime = 0;
|
|
gameState.levelTime = 0;
|
|
|
|
// Initialize remaining time based on difficulty
|
|
const config = gameConfig.difficulties[gameState.selectedDifficulty];
|
|
gameState.remainingTime = config.timer;
|
|
|
|
// Hide death screen
|
|
deathScreen.classList.remove('show');
|
|
|
|
// Create game canvas
|
|
const canvas = document.createElement('canvas');
|
|
canvas.id = 'gameCanvas';
|
|
document.querySelector('.container').appendChild(canvas);
|
|
|
|
// Initialize game
|
|
initializeGame();
|
|
updateStats();
|
|
|
|
// Start timer update interval
|
|
startTimerUpdate();
|
|
}
|
|
|
|
// Creates a particle effect when squares are collected
|
|
function createParticle(x, y, color) {
|
|
const particle = document.createElement('div');
|
|
particle.className = 'particle';
|
|
particle.style.left = `${x}px`;
|
|
particle.style.top = `${y}px`;
|
|
particle.style.backgroundColor = color;
|
|
particle.style.width = '10px';
|
|
particle.style.height = '10px';
|
|
particle.style.borderRadius = '50%';
|
|
document.body.appendChild(particle);
|
|
|
|
// Remove particle after animation
|
|
setTimeout(() => particle.remove(), 500);
|
|
}
|
|
|
|
// Creates sparkle effects around bonus squares
|
|
function createSparkle(x, y) {
|
|
const sparkle = document.createElement('div');
|
|
sparkle.className = 'sparkle';
|
|
sparkle.style.left = `${x}px`;
|
|
sparkle.style.top = `${y}px`;
|
|
sparkle.style.width = '20px';
|
|
sparkle.style.height = '20px';
|
|
sparkle.style.background = 'radial-gradient(circle, #fff 0%, transparent 70%)';
|
|
document.body.appendChild(sparkle);
|
|
|
|
setTimeout(() => sparkle.remove(), 1000);
|
|
}
|
|
|
|
// Checks if level is complete - either no squares left or only red squares
|
|
function isLevelComplete(squares) {
|
|
if (squares.length === 0) return true;
|
|
return squares.every(square => square.color === gameConfig.colors.bad);
|
|
}
|
|
|
|
// Calculates how many squares should be in the current level
|
|
function getSquaresForLevel() {
|
|
const config = gameConfig.difficulties[gameState.selectedDifficulty];
|
|
return Math.min(
|
|
config.initialSquares + (gameState.level - 1) * config.squaresPerLevel,
|
|
config.maxSquares
|
|
);
|
|
}
|
|
|
|
// Gets the speed for the current level
|
|
function getSpeedForLevel() {
|
|
const config = gameConfig.difficulties[gameState.selectedDifficulty];
|
|
return config.baseSpeed + (gameState.level - 1) * config.speedIncrease;
|
|
}
|
|
|
|
// Calculates how many red squares should be in the current level
|
|
function getRedSquaresForLevel() {
|
|
const config = gameConfig.difficulties[gameState.selectedDifficulty];
|
|
const totalSquares = getSquaresForLevel();
|
|
const redSquares = Math.floor(totalSquares * config.redSquareRatio);
|
|
return Math.min(redSquares, totalSquares - 2); // Always leave at least 2 non-red squares
|
|
}
|
|
|
|
// Spawns a new square with random properties
|
|
function spawnSquare(canvas, squares, isRed = false) {
|
|
const size = 30;
|
|
const config = gameConfig.difficulties[gameState.selectedDifficulty];
|
|
const baseSpeed = getSpeedForLevel();
|
|
|
|
// Add some randomness to square speeds
|
|
const speedVariation = (Math.random() - 0.5) * config.speedVariation;
|
|
const speed = baseSpeed + speedVariation;
|
|
|
|
// Random direction
|
|
const angle = Math.random() * Math.PI * 2;
|
|
const vx = Math.cos(angle) * speed;
|
|
const vy = Math.sin(angle) * speed;
|
|
|
|
const square = {
|
|
x: Math.random() * (canvas.width - size),
|
|
y: Math.random() * (canvas.height - size),
|
|
size: size,
|
|
vx: vx,
|
|
vy: vy,
|
|
color: isRed ? gameConfig.colors.bad :
|
|
Math.random() < 0.7 ? gameConfig.colors.good : gameConfig.colors.bonus,
|
|
trail: [],
|
|
isHighlighted: false
|
|
};
|
|
squares.push(square);
|
|
}
|
|
|
|
// Handles level completion - calculates bonuses and spawns next level
|
|
function handleLevelCompletion(squares) {
|
|
const config = gameConfig.difficulties[gameState.selectedDifficulty];
|
|
|
|
// Calculate time bonuses
|
|
const baseTimeBonus = config.timeBonus;
|
|
const levelTime = (Date.now() - gameState.levelStartTime) / 1000;
|
|
const speedBonus = Math.max(0, Math.floor((config.timer - levelTime) * 0.5));
|
|
const comboBonus = Math.min(30, gameState.maxCombo * 2); // 2s per combo, max 30s
|
|
|
|
const finalBonus = baseTimeBonus + speedBonus + comboBonus;
|
|
|
|
// Show bonus notification
|
|
let bonusMessage = `<span class="bonus-change">Level Complete! +${finalBonus}s</span>`;
|
|
if (speedBonus > 0) {
|
|
bonusMessage += `<br><span class="score-change">Speed Bonus: +${speedBonus}s</span>`;
|
|
}
|
|
if (comboBonus > 0) {
|
|
bonusMessage += `<br><span class="score-change">Combo Bonus: +${comboBonus}s</span>`;
|
|
}
|
|
showNotification(bonusMessage, 2000);
|
|
|
|
// Clear squares with effects
|
|
squares.forEach(square => {
|
|
for (let i = 0; i < gameConfig.particleCount * 2; i++) {
|
|
const angle = (Math.PI * 2 * i) / (gameConfig.particleCount * 2);
|
|
const x = square.x + square.size / 2;
|
|
const y = square.y + square.size / 2;
|
|
createParticle(x, y, square.color);
|
|
}
|
|
});
|
|
|
|
squares.length = 0;
|
|
gameState.level++;
|
|
updateStats();
|
|
|
|
// Reset combo tracking
|
|
gameState.currentCombo = 0;
|
|
gameState.maxCombo = 0;
|
|
|
|
// Spawn next level
|
|
const canvas = document.getElementById('gameCanvas');
|
|
const numSquares = getSquaresForLevel();
|
|
const numRedSquares = getRedSquaresForLevel();
|
|
|
|
// Spawn red squares first
|
|
for (let i = 0; i < numRedSquares; i++) {
|
|
spawnSquare(canvas, squares, true);
|
|
}
|
|
|
|
// Spawn remaining squares
|
|
for (let i = numRedSquares; i < numSquares; i++) {
|
|
spawnSquare(canvas, squares, false);
|
|
}
|
|
|
|
// Reset level timer with bonus
|
|
gameState.levelStartTime = Date.now() - (finalBonus * 1000);
|
|
gameState.levelTime = 0;
|
|
}
|
|
|
|
// Handles square collection - scoring, effects, and game state updates
|
|
function handleSquareClick(square, squares) {
|
|
const index = squares.indexOf(square);
|
|
if (index > -1) {
|
|
let scoreChange = 0;
|
|
let livesChange = 0;
|
|
let bonusChange = 0;
|
|
let bonusLife = false;
|
|
let notificationParts = [];
|
|
|
|
// Handle scoring
|
|
if (square.color === gameConfig.colors.good) {
|
|
scoreChange = 1;
|
|
gameState.currentCombo++;
|
|
notificationParts.push(`<span class="score-change">+${scoreChange} Point${scoreChange !== 1 ? 's' : ''}</span>`);
|
|
} else if (square.color === gameConfig.colors.bonus) {
|
|
scoreChange = 2;
|
|
bonusChange = 2;
|
|
gameState.currentCombo += 2;
|
|
notificationParts.push(`<span class="bonus-change">+${scoreChange} Bonus Point${scoreChange !== 1 ? 's' : ''}</span>`);
|
|
// Bonus life logic (medium only, if lost a life)
|
|
const config = gameConfig.difficulties[gameState.selectedDifficulty];
|
|
if (
|
|
gameState.selectedDifficulty === 'medium' &&
|
|
gameState.lives < config.lives &&
|
|
Math.random() < 0.1 // 10% chance
|
|
) {
|
|
gameState.lives += 1;
|
|
bonusLife = true;
|
|
notificationParts.push('<span class="lives-change">+1 Bonus Life</span>');
|
|
}
|
|
}
|
|
|
|
// Handle red squares
|
|
if (square.color === gameConfig.colors.bad) {
|
|
const config = gameConfig.difficulties[gameState.selectedDifficulty];
|
|
if (config.lives !== Infinity) {
|
|
livesChange = -1;
|
|
gameState.currentCombo = 0;
|
|
notificationParts.push('<span class="lives-change">-1 Life</span>');
|
|
if (gameState.lives <= 0) {
|
|
gameState.isPlaying = false;
|
|
showDeathScreen();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update game state
|
|
gameState.score += scoreChange;
|
|
gameState.lives += livesChange;
|
|
|
|
// Create notification message
|
|
if (notificationParts.length > 0) {
|
|
showNotification(notificationParts.join(', '));
|
|
}
|
|
|
|
// Create effects
|
|
for (let i = 0; i < gameConfig.particleCount; i++) {
|
|
const angle = (Math.PI * 2 * i) / gameConfig.particleCount;
|
|
const x = square.x + square.size / 2;
|
|
const y = square.y + square.size / 2;
|
|
createParticle(x, y, square.color);
|
|
}
|
|
|
|
// Extra sparkles for bonus squares
|
|
if (square.color === gameConfig.colors.bonus) {
|
|
for (let i = 0; i < gameConfig.sparkleCount; i++) {
|
|
const angle = (Math.PI * 2 * i) / gameConfig.sparkleCount;
|
|
const x = square.x + square.size / 2 + Math.cos(angle) * 20;
|
|
const y = square.y + square.size / 2 + Math.sin(angle) * 20;
|
|
createSparkle(x, y);
|
|
}
|
|
}
|
|
|
|
squares.splice(index, 1);
|
|
|
|
// Update max combo
|
|
if (gameState.currentCombo > gameState.maxCombo) {
|
|
gameState.maxCombo = gameState.currentCombo;
|
|
}
|
|
|
|
// Check for level completion
|
|
if (isLevelComplete(squares)) {
|
|
handleLevelCompletion(squares);
|
|
}
|
|
|
|
updateStats();
|
|
}
|
|
}
|
|
|
|
function initializeGame() {
|
|
const canvas = document.getElementById('gameCanvas');
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Set canvas size to fullscreen
|
|
function resizeCanvas() {
|
|
canvas.width = window.innerWidth;
|
|
canvas.height = window.innerHeight;
|
|
}
|
|
|
|
resizeCanvas();
|
|
window.addEventListener('resize', resizeCanvas);
|
|
|
|
// Create background stars
|
|
const stars = Array.from({ length: gameConfig.backgroundStars }, () => ({
|
|
x: Math.random() * canvas.width,
|
|
y: Math.random() * canvas.height,
|
|
size: Math.random() * 2 + 1,
|
|
speed: Math.random() * 0.5 + 0.1,
|
|
opacity: Math.random() * 0.5 + 0.3
|
|
}));
|
|
|
|
// Game objects
|
|
const squares = [];
|
|
let selectionBox = null;
|
|
let isSelecting = false;
|
|
let startX, startY;
|
|
let isDragging = false;
|
|
|
|
// Spawn initial squares with correct red square ratio
|
|
const numSquares = getSquaresForLevel();
|
|
const numRedSquares = getRedSquaresForLevel();
|
|
|
|
// Spawn red squares first
|
|
for (let i = 0; i < numRedSquares; i++) {
|
|
spawnSquare(canvas, squares, true);
|
|
}
|
|
|
|
// Spawn remaining squares
|
|
for (let i = numRedSquares; i < numSquares; i++) {
|
|
spawnSquare(canvas, squares, false);
|
|
}
|
|
|
|
// Function to handle selected squares
|
|
function handleSelectedSquares(selectionBox) {
|
|
if (!selectionBox) return;
|
|
|
|
// Calculate selection bounds
|
|
const left = Math.min(selectionBox.x, selectionBox.x + selectionBox.width);
|
|
const right = Math.max(selectionBox.x, selectionBox.x + selectionBox.width);
|
|
const top = Math.min(selectionBox.y, selectionBox.y + selectionBox.height);
|
|
const bottom = Math.max(selectionBox.y, selectionBox.y + selectionBox.height);
|
|
|
|
// Check for squares in selection
|
|
const selectedSquares = squares.filter(square => {
|
|
const squareCenterX = square.x + square.size / 2;
|
|
const squareCenterY = square.y + square.size / 2;
|
|
|
|
return squareCenterX >= left &&
|
|
squareCenterX <= right &&
|
|
squareCenterY >= top &&
|
|
squareCenterY <= bottom;
|
|
});
|
|
|
|
// If only one square is selected and the selection box is very small,
|
|
// treat it as a click on that square
|
|
if (selectedSquares.length === 1 &&
|
|
Math.abs(selectionBox.width) < 5 &&
|
|
Math.abs(selectionBox.height) < 5) {
|
|
handleSquareClick(selectedSquares[0], squares);
|
|
return;
|
|
}
|
|
|
|
// Calculate total changes
|
|
let totalScoreChange = 0;
|
|
let totalBonusChange = 0;
|
|
let totalLivesChange = 0;
|
|
let bonusLife = false;
|
|
let notificationParts = [];
|
|
|
|
// Handle each selected square
|
|
selectedSquares.forEach(square => {
|
|
if (square.color === gameConfig.colors.good) {
|
|
totalScoreChange += 1;
|
|
gameState.currentCombo++;
|
|
} else if (square.color === gameConfig.colors.bonus) {
|
|
totalScoreChange += 2;
|
|
totalBonusChange += 2;
|
|
gameState.currentCombo += 2;
|
|
|
|
// Bonus life logic (medium only, if lost a life)
|
|
const config = gameConfig.difficulties[gameState.selectedDifficulty];
|
|
if (
|
|
gameState.selectedDifficulty === 'medium' &&
|
|
gameState.lives < config.lives &&
|
|
Math.random() < 0.1 // 10% chance
|
|
) {
|
|
totalLivesChange += 1;
|
|
bonusLife = true;
|
|
}
|
|
} else if (square.color === gameConfig.colors.bad) {
|
|
const config = gameConfig.difficulties[gameState.selectedDifficulty];
|
|
if (config.lives !== Infinity) {
|
|
totalLivesChange -= 1;
|
|
gameState.currentCombo = 0;
|
|
}
|
|
}
|
|
|
|
// Create effects
|
|
for (let i = 0; i < gameConfig.particleCount; i++) {
|
|
const angle = (Math.PI * 2 * i) / gameConfig.particleCount;
|
|
const x = square.x + square.size / 2;
|
|
const y = square.y + square.size / 2;
|
|
createParticle(x, y, square.color);
|
|
}
|
|
|
|
// Extra sparkles for bonus squares
|
|
if (square.color === gameConfig.colors.bonus) {
|
|
for (let i = 0; i < gameConfig.sparkleCount; i++) {
|
|
const angle = (Math.PI * 2 * i) / gameConfig.sparkleCount;
|
|
const x = square.x + square.size / 2 + Math.cos(angle) * 20;
|
|
const y = square.y + square.size / 2 + Math.sin(angle) * 20;
|
|
createSparkle(x, y);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Build notification message
|
|
if (totalScoreChange > 0) {
|
|
notificationParts.push(`<span class="score-change">+${totalScoreChange} Point${totalScoreChange !== 1 ? 's' : ''}</span>`);
|
|
}
|
|
if (totalBonusChange > 0) {
|
|
notificationParts.push(`<span class="bonus-change">+${totalBonusChange} Bonus Point${totalBonusChange !== 1 ? 's' : ''}</span>`);
|
|
}
|
|
if (totalLivesChange > 0) {
|
|
notificationParts.push(`<span class="lives-change">+${totalLivesChange} Bonus Life${totalLivesChange !== 1 ? 's' : ''}</span>`);
|
|
}
|
|
if (totalLivesChange < 0) {
|
|
notificationParts.push(`<span class="lives-change">${totalLivesChange} Life${totalLivesChange !== -1 ? 's' : ''}</span>`);
|
|
}
|
|
|
|
// Show notification if there are any changes
|
|
if (notificationParts.length > 0) {
|
|
showNotification(notificationParts.join(', '));
|
|
}
|
|
|
|
// Update game state
|
|
gameState.score += totalScoreChange;
|
|
gameState.lives += totalLivesChange;
|
|
|
|
// Check for game over
|
|
if (gameState.lives <= 0) {
|
|
gameState.isPlaying = false;
|
|
showDeathScreen();
|
|
}
|
|
|
|
// Remove selected squares
|
|
selectedSquares.forEach(square => {
|
|
const index = squares.indexOf(square);
|
|
if (index > -1) {
|
|
squares.splice(index, 1);
|
|
}
|
|
});
|
|
|
|
// Update max combo
|
|
if (gameState.currentCombo > gameState.maxCombo) {
|
|
gameState.maxCombo = gameState.currentCombo;
|
|
}
|
|
|
|
// Check for level completion
|
|
if (isLevelComplete(squares)) {
|
|
handleLevelCompletion(squares);
|
|
}
|
|
|
|
updateStats();
|
|
}
|
|
|
|
// Function to clear selection
|
|
function clearSelection() {
|
|
if (isSelecting && selectionBox) {
|
|
// Handle any squares in the selection before clearing
|
|
handleSelectedSquares(selectionBox);
|
|
}
|
|
isSelecting = false;
|
|
isDragging = false;
|
|
selectionBox = null;
|
|
}
|
|
|
|
// Mouse event listeners
|
|
canvas.addEventListener('mousedown', (e) => {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const scaleX = canvas.width / rect.width;
|
|
const scaleY = canvas.height / rect.height;
|
|
startX = (e.clientX - rect.left) * scaleX;
|
|
startY = (e.clientY - rect.top) * scaleY;
|
|
|
|
// Only allow direct clicking in easy and medium difficulties
|
|
if (gameState.selectedDifficulty !== 'hard') {
|
|
// Check if we clicked directly on a square
|
|
const clickedSquare = squares.find(square => {
|
|
return startX >= square.x &&
|
|
startX <= square.x + square.size &&
|
|
startY >= square.y &&
|
|
startY <= square.y + square.size;
|
|
});
|
|
|
|
if (clickedSquare) {
|
|
handleSquareClick(clickedSquare, squares);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Start selection box (for hard mode or when clicking empty space)
|
|
isSelecting = true;
|
|
isDragging = true;
|
|
selectionBox = {
|
|
x: startX,
|
|
y: startY,
|
|
width: 0,
|
|
height: 0
|
|
};
|
|
// Disable pointer events on UI
|
|
document.querySelector('.settings-icon').classList.add('no-pointer-events');
|
|
document.querySelector('.settings-menu').classList.add('no-pointer-events');
|
|
document.querySelector('h1').classList.add('no-pointer-events');
|
|
document.querySelector('.game-stats').classList.add('no-pointer-events');
|
|
});
|
|
|
|
canvas.addEventListener('mousemove', (e) => {
|
|
if (!isSelecting) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const scaleX = canvas.width / rect.width;
|
|
const scaleY = canvas.height / rect.height;
|
|
const currentX = (e.clientX - rect.left) * scaleX;
|
|
const currentY = (e.clientY - rect.top) * scaleY;
|
|
|
|
// Update selection box
|
|
selectionBox.width = currentX - startX;
|
|
selectionBox.height = currentY - startY;
|
|
|
|
// Highlight squares that would be selected
|
|
squares.forEach(square => {
|
|
const squareCenterX = square.x + square.size / 2;
|
|
const squareCenterY = square.y + square.size / 2;
|
|
const left = Math.min(selectionBox.x, selectionBox.x + selectionBox.width);
|
|
const right = Math.max(selectionBox.x, selectionBox.x + selectionBox.width);
|
|
const top = Math.min(selectionBox.y, selectionBox.y + selectionBox.height);
|
|
const bottom = Math.max(selectionBox.y, selectionBox.y + selectionBox.height);
|
|
|
|
square.isHighlighted = squareCenterX >= left &&
|
|
squareCenterX <= right &&
|
|
squareCenterY >= top &&
|
|
squareCenterY <= bottom;
|
|
});
|
|
});
|
|
|
|
canvas.addEventListener('mouseup', (e) => {
|
|
if (!isSelecting) return;
|
|
|
|
if (isDragging && selectionBox) {
|
|
handleSelectedSquares(selectionBox);
|
|
}
|
|
isSelecting = false;
|
|
isDragging = false;
|
|
selectionBox = null;
|
|
// Re-enable pointer events on UI
|
|
document.querySelector('.settings-icon').classList.remove('no-pointer-events');
|
|
document.querySelector('.settings-menu').classList.remove('no-pointer-events');
|
|
document.querySelector('h1').classList.remove('no-pointer-events');
|
|
document.querySelector('.game-stats').classList.remove('no-pointer-events');
|
|
// Clear highlights
|
|
squares.forEach(square => {
|
|
square.isHighlighted = false;
|
|
});
|
|
});
|
|
|
|
// Handle window blur
|
|
window.addEventListener('blur', () => {
|
|
isSelecting = false;
|
|
isDragging = false;
|
|
selectionBox = null;
|
|
// Re-enable pointer events on UI
|
|
document.querySelector('.settings-icon').classList.remove('no-pointer-events');
|
|
document.querySelector('.settings-menu').classList.remove('no-pointer-events');
|
|
document.querySelector('h1').classList.remove('no-pointer-events');
|
|
document.querySelector('.game-stats').classList.remove('no-pointer-events');
|
|
squares.forEach(square => {
|
|
square.isHighlighted = false;
|
|
});
|
|
});
|
|
|
|
// Handle mouse leave
|
|
canvas.addEventListener('mouseleave', () => {
|
|
isSelecting = false;
|
|
isDragging = false;
|
|
selectionBox = null;
|
|
// Re-enable pointer events on UI
|
|
document.querySelector('.settings-icon').classList.remove('no-pointer-events');
|
|
document.querySelector('.settings-menu').classList.remove('no-pointer-events');
|
|
document.querySelector('h1').classList.remove('no-pointer-events');
|
|
document.querySelector('.game-stats').classList.remove('no-pointer-events');
|
|
squares.forEach(square => {
|
|
square.isHighlighted = false;
|
|
});
|
|
});
|
|
|
|
// Handle mouse up outside canvas
|
|
document.addEventListener('mouseup', () => {
|
|
if (isSelecting) {
|
|
if (isDragging && selectionBox) {
|
|
handleSelectedSquares(selectionBox);
|
|
}
|
|
isSelecting = false;
|
|
isDragging = false;
|
|
selectionBox = null;
|
|
// Re-enable pointer events on UI
|
|
document.querySelector('.settings-icon').classList.remove('no-pointer-events');
|
|
document.querySelector('.settings-menu').classList.remove('no-pointer-events');
|
|
document.querySelector('h1').classList.remove('no-pointer-events');
|
|
document.querySelector('.game-stats').classList.remove('no-pointer-events');
|
|
squares.forEach(square => {
|
|
square.isHighlighted = false;
|
|
});
|
|
}
|
|
});
|
|
|
|
// Game loop
|
|
function gameLoop() {
|
|
if (!gameState.isPlaying) return;
|
|
|
|
// Update game time
|
|
if (gameState.startTime) {
|
|
gameState.gameTime = (Date.now() - gameState.startTime) / 1000;
|
|
}
|
|
|
|
// Clear canvas
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Draw animated background
|
|
const time = Date.now() / 1000;
|
|
|
|
// Draw stars
|
|
stars.forEach(star => {
|
|
star.y += star.speed;
|
|
if (star.y > canvas.height) {
|
|
star.y = 0;
|
|
star.x = Math.random() * canvas.width;
|
|
}
|
|
|
|
const pulse = Math.sin(time * 2 + star.x) * 0.2 + 0.8;
|
|
ctx.beginPath();
|
|
ctx.arc(star.x, star.y, star.size, 0, Math.PI * 2);
|
|
ctx.fillStyle = `rgba(255, 255, 255, ${star.opacity * pulse})`;
|
|
ctx.fill();
|
|
});
|
|
|
|
// Draw selection box
|
|
if (selectionBox) {
|
|
// Draw selection box with gradient
|
|
const gradient = ctx.createLinearGradient(
|
|
selectionBox.x, selectionBox.y,
|
|
selectionBox.x + selectionBox.width,
|
|
selectionBox.y + selectionBox.height
|
|
);
|
|
gradient.addColorStop(0, 'rgba(96, 165, 250, 0.2)');
|
|
gradient.addColorStop(1, 'rgba(96, 165, 250, 0.1)');
|
|
|
|
ctx.fillStyle = gradient;
|
|
ctx.fillRect(selectionBox.x, selectionBox.y, selectionBox.width, selectionBox.height);
|
|
|
|
ctx.strokeStyle = '#60a5fa';
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeRect(selectionBox.x, selectionBox.y, selectionBox.width, selectionBox.height);
|
|
}
|
|
|
|
// Update and draw squares
|
|
squares.forEach(square => {
|
|
// Update trail
|
|
const trailPoint = {
|
|
x: square.x + square.size / 2,
|
|
y: square.y + square.size / 2,
|
|
size: gameConfig.trailParticleSize,
|
|
alpha: 1,
|
|
angle: Math.atan2(square.vy, square.vx)
|
|
};
|
|
|
|
square.trail.unshift(trailPoint);
|
|
if (square.trail.length > gameConfig.trailLength) {
|
|
square.trail.pop();
|
|
}
|
|
|
|
// Update position
|
|
square.x += square.vx;
|
|
square.y += square.vy;
|
|
|
|
// Bounce off walls
|
|
if (square.x <= 0 || square.x + square.size >= canvas.width) {
|
|
square.vx *= -1;
|
|
}
|
|
if (square.y <= 0 || square.y + square.size >= canvas.height) {
|
|
square.vy *= -1;
|
|
}
|
|
|
|
// Draw trail
|
|
square.trail.forEach((particle, index) => {
|
|
const progress = index / gameConfig.trailLength;
|
|
const alpha = 1 - progress;
|
|
const size = particle.size * (1 - progress * 0.5);
|
|
|
|
// Draw trail particle
|
|
ctx.save();
|
|
ctx.translate(particle.x, particle.y);
|
|
ctx.rotate(particle.angle);
|
|
|
|
// Draw elongated particle
|
|
ctx.beginPath();
|
|
ctx.moveTo(-size * 2, -size);
|
|
ctx.lineTo(size * 2, -size);
|
|
ctx.lineTo(size * 2, size);
|
|
ctx.lineTo(-size * 2, size);
|
|
ctx.closePath();
|
|
|
|
// Add glow effect
|
|
const gradient = ctx.createLinearGradient(-size * 2, 0, size * 2, 0);
|
|
gradient.addColorStop(0, `${square.color}00`);
|
|
gradient.addColorStop(0.5, `${square.color}${Math.floor(alpha * 255).toString(16).padStart(2, '0')}`);
|
|
gradient.addColorStop(1, `${square.color}00`);
|
|
|
|
ctx.fillStyle = gradient;
|
|
ctx.fill();
|
|
ctx.restore();
|
|
});
|
|
|
|
// Draw square with highlight effect if selected
|
|
if (square.isHighlighted) {
|
|
ctx.shadowColor = '#60a5fa';
|
|
ctx.shadowBlur = 15;
|
|
ctx.shadowOffsetX = 0;
|
|
ctx.shadowOffsetY = 0;
|
|
}
|
|
|
|
ctx.fillStyle = square.color;
|
|
ctx.fillRect(square.x, square.y, square.size, square.size);
|
|
|
|
// Reset shadow
|
|
ctx.shadowColor = 'transparent';
|
|
ctx.shadowBlur = 0;
|
|
|
|
// Draw sparkles for bonus squares
|
|
if (square.color === gameConfig.colors.bonus) {
|
|
const time = Date.now() / 1000;
|
|
for (let i = 0; i < gameConfig.sparkleCount; i++) {
|
|
const angle = time + (Math.PI * 2 * i) / gameConfig.sparkleCount;
|
|
const distance = 25 + Math.sin(time * 3 + i) * 5;
|
|
const x = square.x + square.size / 2 + Math.cos(angle) * distance;
|
|
const y = square.y + square.size / 2 + Math.sin(angle) * distance;
|
|
|
|
// Draw sparkle
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.rotate(angle);
|
|
|
|
// Sparkle shape
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -3);
|
|
ctx.lineTo(0, 3);
|
|
ctx.moveTo(-3, 0);
|
|
ctx.lineTo(3, 0);
|
|
ctx.strokeStyle = '#fff';
|
|
ctx.lineWidth = 1;
|
|
ctx.stroke();
|
|
|
|
// Sparkle glow
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, 2, 0, Math.PI * 2);
|
|
ctx.fillStyle = '#fff';
|
|
ctx.fill();
|
|
|
|
ctx.restore();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Spawn new squares if needed (for endless mode)
|
|
if (gameState.selectedMode === 'endless' &&
|
|
squares.length < gameConfig.difficulties[gameState.selectedDifficulty].maxSquares) {
|
|
const config = gameConfig.difficulties[gameState.selectedDifficulty];
|
|
const totalSquares = config.maxSquares;
|
|
const currentRedSquares = squares.filter(square => square.color === gameConfig.colors.bad).length;
|
|
const targetRedSquares = Math.floor(totalSquares * config.redSquareRatio);
|
|
|
|
// Spawn a red square if we're below the target ratio
|
|
if (currentRedSquares < targetRedSquares) {
|
|
spawnSquare(canvas, squares, true);
|
|
} else {
|
|
spawnSquare(canvas, squares, false);
|
|
}
|
|
}
|
|
|
|
requestAnimationFrame(gameLoop);
|
|
}
|
|
|
|
// Start game loop
|
|
gameLoop();
|
|
}
|
|
|
|
// Add timer update function
|
|
function startTimerUpdate() {
|
|
// Clear any existing interval
|
|
if (gameState.timerInterval) {
|
|
clearInterval(gameState.timerInterval);
|
|
}
|
|
|
|
// Update timer every 100ms for smoother display
|
|
gameState.timerInterval = setInterval(() => {
|
|
if (!gameState.isPlaying) {
|
|
clearInterval(gameState.timerInterval);
|
|
return;
|
|
}
|
|
|
|
const currentTime = Date.now();
|
|
const deltaTime = (currentTime - gameState.lastUpdateTime) / 1000;
|
|
gameState.lastUpdateTime = currentTime;
|
|
|
|
// Update remaining time
|
|
if (gameState.selectedMode === 'levels') {
|
|
gameState.remainingTime = Math.max(0, gameState.remainingTime - deltaTime);
|
|
|
|
// Check for time up
|
|
if (gameState.remainingTime <= 0) {
|
|
gameState.isPlaying = false;
|
|
showDeathScreen();
|
|
clearInterval(gameState.timerInterval);
|
|
}
|
|
}
|
|
|
|
updateStats();
|
|
}, 100);
|
|
}
|
|
|
|
// Add visibility change handler
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.hidden) {
|
|
// Store the time when the tab becomes hidden
|
|
gameState.lastUpdateTime = Date.now();
|
|
} else {
|
|
// Update the last update time when the tab becomes visible again
|
|
gameState.lastUpdateTime = Date.now();
|
|
}
|
|
});
|
|
|
|
// Update game stats display
|
|
function updateStats() {
|
|
// Update score
|
|
document.querySelector('.stat-item.score .stat-value').textContent = gameState.score;
|
|
|
|
// Update lives (only in levels mode or non-easy difficulties)
|
|
const livesElement = document.querySelector('.stat-item.lives');
|
|
const config = gameConfig.difficulties[gameState.selectedDifficulty];
|
|
|
|
if (config.lives !== Infinity) {
|
|
livesElement.classList.add('show');
|
|
document.querySelector('.stat-item.lives .stat-value').textContent = gameState.lives;
|
|
} else {
|
|
livesElement.classList.remove('show');
|
|
}
|
|
|
|
// Update level (only in levels mode)
|
|
const levelElement = document.querySelector('.stat-item.level');
|
|
if (gameState.selectedMode === 'levels') {
|
|
levelElement.style.display = 'flex';
|
|
document.querySelector('.stat-item.level .stat-value').textContent = gameState.level;
|
|
} else {
|
|
levelElement.style.display = 'none';
|
|
}
|
|
|
|
// Update timer
|
|
const timerElement = document.querySelector('.stat-item.timer');
|
|
const timerValue = document.querySelector('.stat-item.timer .stat-value');
|
|
|
|
if (gameState.selectedMode === 'levels') {
|
|
timerElement.style.display = 'flex';
|
|
|
|
// Format time
|
|
const minutes = Math.floor(gameState.remainingTime / 60);
|
|
const seconds = Math.floor(gameState.remainingTime % 60);
|
|
timerValue.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
|
|
// Add warning class when time is running low
|
|
if (gameState.remainingTime <= 30) {
|
|
timerElement.classList.add('warning');
|
|
} else {
|
|
timerElement.classList.remove('warning');
|
|
}
|
|
} else {
|
|
timerElement.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// Add event listener for the death screen restart button
|
|
document.querySelector('.death-screen .restart-btn').addEventListener('click', restartGame);
|
|
|
|
// Function to show time bonus popup
|
|
function showTimeBonusPopup(seconds, breakdown) {
|
|
const timeBonusPopup = document.createElement('div');
|
|
timeBonusPopup.className = 'time-bonus-popup';
|
|
|
|
// Create detailed breakdown
|
|
timeBonusPopup.innerHTML = `
|
|
<div class="total-bonus">+${seconds}s</div>
|
|
<div class="bonus-breakdown">
|
|
<div class="bonus-item">Base: +${breakdown.base}s</div>
|
|
<div class="bonus-item">Speed: +${breakdown.speed}s</div>
|
|
<div class="bonus-item">Combo: +${breakdown.combo}s</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(timeBonusPopup);
|
|
|
|
timeBonusPopup.classList.add('show');
|
|
setTimeout(() => {
|
|
timeBonusPopup.classList.remove('show');
|
|
timeBonusPopup.remove();
|
|
}, 2000);
|
|
}
|
|
|
|
// New notification system
|
|
function showNotification(message, duration = 1500) {
|
|
const notificationContent = document.querySelector('.notification-content');
|
|
notificationContent.innerHTML = message;
|
|
notificationContent.classList.add('show');
|
|
|
|
setTimeout(() => {
|
|
notificationContent.classList.remove('show');
|
|
}, duration);
|
|
}
|