/* * Simple Flappy Bird clone written in pure JavaScript. * The game uses HTML5 canvas to render a scrolling background, pipes, a pixel art bird, and simple ground. * Tapping (clicking) or pressing the space bar will cause the bird to flap upward. * Collision with a pipe or the ground ends the game. * Scoring is achieved by successfully navigating through gaps between pipes. */ (() => { const canvas = document.getElementById('game'); const ctx = canvas.getContext('2d'); const overlay = document.getElementById('overlay'); const scoreElem = document.getElementById('score'); // Additional UI elements for enhanced UI // Only high score, ability button and customization modal remain after removing coins/power displays const highscoreElem = document.getElementById('highscore'); const coinsElem = document.getElementById('coins'); const abilityBtn = document.getElementById('ability-btn'); const shopBtn = document.getElementById('shop-btn'); const shopModal = document.getElementById('shop-modal'); // Generic content area inside the modal that will display either characters or abilities const shopContentElem = document.getElementById('shop-content'); // Tab buttons for switching between characters and abilities const tabCharBtn = document.getElementById('tab-char'); const tabPowerBtn = document.getElementById('tab-power'); const closeShopBtn = document.getElementById('close-shop'); // Particle effect elements const effectContainer = document.getElementById('effect-container'); const effectImg = document.getElementById('effect-img'); // Dimensions const W = canvas.width; const H = canvas.height; const groundHeight = 100; // Game state let gameState = 'start'; // start, play, over let frameCount = 0; let score = 0; // Persistent data // High score persistence let highScore = parseInt(localStorage.getItem('flappyHighScore')) || 0; // Coins persistence – used to purchase abilities let coins = parseInt(localStorage.getItem('flappyCoins')) || 0; // Load unlocked characters or default to all available let unlocked = {}; try { unlocked = JSON.parse(localStorage.getItem('flappyUnlocked')) || {}; } catch (e) { unlocked = {}; } // Character definitions. Prices removed since characters are always free. const characters = [ { id: 'yellow', displayName: 'Classic', frames: ['bird0', 'bird1'] }, { id: 'blue', displayName: 'Blue', frames: ['blue_bird1', 'blue_bird2'] }, { id: 'red', displayName: 'Red', frames: ['red_bird1', 'red_bird2'] }, { id: 'green', displayName: 'Green', frames: ['green_bird1', 'green_bird2'] }, ]; // Unlock all characters by default characters.forEach(ch => { unlocked[ch.id] = true; }); let currentChar = localStorage.getItem('flappyCharacter') || 'yellow'; // Persistent state for abilities. Only giant and bomb abilities exist. The user must unlock // these with coins before they can be selected. let selectedAbility = localStorage.getItem('flappySelectedAbility') || null; // Unlocked abilities stored as object mapping id->true. Load from localStorage or initialise empty. let unlockedAbilities = {}; try { unlockedAbilities = JSON.parse(localStorage.getItem('flappyUnlockedAbilities')) || {}; } catch (e) { unlockedAbilities = {}; } // Define the available abilities. Only two remain: Giant Mode and Bomb. Each has // a price (coins required to unlock) and a description explaining its effect. const abilities = [ { id: 'giant', displayName: 'Giant Mode', description: 'Grow huge and destroy pipes briefly.', price: 50 }, { id: 'bomb', displayName: 'Bomb', description: 'Destroy the next pipe instantly.', price: 30 } ]; // Iconography for each ability. Use simple emoji to convey the power at a glance. const abilityIcons = { giant: '🦅', bomb: '💣' }; // Initial shop tab (characters or abilities) let currentShopTab = 'characters'; // Bird state const bird = { x: 80, y: H / 2, width: 34, // approximate width for collision box height: 24, // approximate height for collision box vy: 0, frame: 0 }; // Base bird dimensions (for transformations) const baseBirdW = bird.width; const baseBirdH = bird.height; // Ability state variables // Ability state variables // Number of giant frames remaining when Giant Mode is active let giantTimer = 0; // Bomb state: when >0, the next pipe will automatically be destroyed and skipped let bombReady = 0; // Timers for removed abilities (slow motion, freeze, gravity flip) are omitted but kept at zero for compatibility let slowMotionTimer = 0; let freezeTimer = 0; let gravityFlipTimer = 0; // Remove timers and flags for abilities that were dropped (tiny, wind, doubleflap, glide). // These variables are intentionally omitted to keep state management simple. // Cooldown timer for the ability button (frames) let abilityCooldown = 0; // Timer and identifier for active particle effect let effectTimer = 0; let effectAbility = null; // Touch delay to prevent double triggers (0.2 seconds = 12 frames at 60fps) let touchDelay = 0; /** * Reset all ability timers and flags. Called when starting a new round. */ function resetAbilityState() { // Reset timers for current abilities giantTimer = 0; bombReady = 0; slowMotionTimer = 0; freezeTimer = 0; gravityFlipTimer = 0; // Reset cooldowns and effects abilityCooldown = 0; effectTimer = 0; effectAbility = null; // Reset touch delay touchDelay = 0; } /** * Activate the currently selected ability. This function applies the relevant * gameplay modifiers, triggers the particle effect and plays the associated sound. * After activation the ability enters a cooldown before it can be used again. */ function activateAbility() { if (!selectedAbility || abilityCooldown > 0 || gameState !== 'play') return; // Reset any existing ability effects before applying new one resetAbilityState(); // Apply the ability effect switch (selectedAbility) { case 'giant': // Grow huge for 5 seconds (~300 frames). giantTimer = 300; break; case 'bomb': // Destroy the next pipe instantly. bombReady = 1; break; } // Trigger particle effect and sound effectAbility = selectedAbility; effectTimer = 60; // show effect for one second (60 frames) if (effectImg && effectContainer) { effectImg.src = `assets/effects/${selectedAbility}.gif`; // Adjust size based on ability: giant uses a larger effect than bomb if (selectedAbility === 'giant') { effectImg.style.width = '256px'; effectImg.style.height = '256px'; } else { effectImg.style.width = '160px'; effectImg.style.height = '160px'; } // Position the effect at the centre of the bird effectImg.style.left = `${bird.x + bird.width / 2}px`; effectImg.style.top = `${bird.y + bird.height / 2}px`; effectContainer.style.display = 'block'; } // Play ability sound if loaded const snd = abilitySounds[selectedAbility]; if (snd) { snd.currentTime = 0; snd.play(); } // Set cooldown (5 seconds ~ 300 frames) abilityCooldown = 300; updateAbilityButton(); } // Constants const gravity = 0.35; const flapStrength = -7; const pipeGap = 140; // vertical gap between pipes const pipeWidth = 60; const pipeSpeed = 2; const spawnInterval = 90; // frames between pipe generation (~1.5s at ~60fps) // Pipes array const pipes = []; // Assets const images = {}; const assetsToLoad = [ { name: 'bird0', src: 'assets/bird1.png' }, { name: 'bird1', src: 'assets/bird2.png' }, { name: 'pipe', src: 'assets/pipe.png' }, { name: 'background', src: 'assets/background.jpg' } ]; // Add tinted bird variants for character selection ['blue_bird1','blue_bird2','red_bird1','red_bird2','green_bird1','green_bird2'].forEach(name => { assetsToLoad.push({ name: name, src: `assets/${name}.png` }); }); // Audio assets. Wing flap sound and ability sounds. const audio = { wing: new Audio('assets/wing.wav'), }; audio.wing.volume = 0.6; // Load a unique sound for each ability. Only giant and bomb (turbo) remain. These audio // variants were generated from the original wing sound to provide unique cues per power. const abilitySounds = {}; ['giant','bomb'].forEach(id => { // For bomb we reuse the turbo sound file if a dedicated file is not present. const file = id === 'bomb' ? 'turbo' : id; const s = new Audio(`assets/${file}.wav`); s.volume = 0.7; abilitySounds[id] = s; }); // Preload images let assetsLoaded = 0; assetsToLoad.forEach(asset => { const img = new Image(); img.src = asset.src; img.onload = () => { images[asset.name] = img; assetsLoaded++; if (assetsLoaded === assetsToLoad.length) { // Once assets are loaded, restore any saved game state or start a new game if (localStorage.getItem('flappyGameState')) { loadGameState(); } else { resetGame(); } requestAnimationFrame(loop); } }; }); /** * Resets the game state for a new round. Also clears any persisted game save. */ function resetGame() { gameState = 'start'; overlay.style.display = 'flex'; overlay.innerHTML = 'Click or press space to flap
Press again after game over to restart'; score = 0; scoreElem.textContent = ''; bird.y = H / 2; bird.vy = 0; bird.frame = 0; pipes.length = 0; frameCount = 0; // Remove saved game state since we are starting fresh localStorage.removeItem('flappyGameState'); // Update persistent display elements updateScoresDisplay(); updateCoinsDisplay(); updateAbilityButton(); // Reset ability state resetAbilityState(); // Hide any active particle effect if (effectContainer) effectContainer.style.display = 'none'; } /** * Handles the bird flap action. */ function flap() { if (gameState === 'start') { // Start the game on first flap. Do not automatically trigger any ability. gameState = 'play'; overlay.style.display = 'none'; // Enable the ability button now that the game is active updateAbilityButton(); } else if (gameState === 'play') { // If touch delay is active, discard this input (treat as single input) if (touchDelay > 0) return; // Each click sets the bird's vertical velocity. No limit on the number of flaps. bird.vy = flapStrength; audio.wing.currentTime = 0; audio.wing.play(); // Set touch delay (0.2 seconds = 12 frames at 60fps) to discard subsequent inputs touchDelay = 12; } else if (gameState === 'over') { resetGame(); } } // Input handling window.addEventListener('keydown', (e) => { if (e.code === 'Space') { e.preventDefault(); flap(); } }); // Only trigger flap on clicks/touches outside of interactive UI such as the shop window.addEventListener('mousedown', (e) => { // Ignore clicks on shop button, shop modal or their children if (e.target.closest && (e.target.closest('#shop-btn') || e.target.closest('#shop-modal') || e.target.closest('#close-shop'))) { return; } flap(); }); window.addEventListener('touchstart', (e) => { if (e.target.closest && (e.target.closest('#shop-btn') || e.target.closest('#shop-modal') || e.target.closest('#close-shop'))) { return; } flap(); }); /** * Displays the character shop when the game is not in play. */ function openShop() { if (gameState === 'play') return; // Hide overlay while browsing shop to avoid text behind overlay.style.display = 'none'; shopModal.style.display = 'block'; currentShopTab = 'characters'; updateShopNav(); renderShop(); // Update header text based on current tab const header = document.getElementById('shop-header'); if (header) header.textContent = 'Select Bird'; } /** * Rebuilds the character list inside the shop modal. Called when the shop opens or after purchases. */ function renderShop() { // Clear content area shopContentElem.innerHTML = ''; if (currentShopTab === 'characters') { characters.forEach(char => { const container = document.createElement('div'); container.style.width = '120px'; container.style.textAlign = 'center'; container.style.border = '1px solid rgba(255,255,255,0.3)'; container.style.borderRadius = '8px'; container.style.padding = '10px'; container.style.background = 'rgba(0,0,0,0.5)'; // Character preview image const img = new Image(); const frameName = char.frames[0]; if (images[frameName]) { img.src = images[frameName].src; } else { const asset = assetsToLoad.find(a => a.name === frameName); img.src = asset ? asset.src : ''; } img.style.width = '60px'; img.style.height = '60px'; img.style.imageRendering = 'pixelated'; container.appendChild(img); // Name label const name = document.createElement('div'); name.textContent = char.displayName; name.style.marginTop = '4px'; container.appendChild(name); // Action button const btn = document.createElement('button'); btn.style.marginTop = '6px'; btn.style.padding = '4px 8px'; btn.style.border = 'none'; btn.style.borderRadius = '4px'; btn.style.cursor = 'pointer'; if (currentChar === char.id) { btn.textContent = 'Selected'; btn.disabled = true; btn.style.background = '#888'; btn.style.color = '#fff'; } else { btn.textContent = 'Select'; btn.style.background = '#00C853'; btn.style.color = '#fff'; btn.addEventListener('click', () => { currentChar = char.id; localStorage.setItem('flappyCharacter', currentChar); renderShop(); }); } container.appendChild(btn); shopContentElem.appendChild(container); }); } else if (currentShopTab === 'powers') { abilities.forEach(abl => { const container = document.createElement('div'); // Grid layout: each ability occupies half width of the modal minus gap container.style.width = 'calc(50% - 20px)'; container.style.boxSizing = 'border-box'; container.style.textAlign = 'center'; container.style.border = '1px solid rgba(255,255,255,0.3)'; container.style.borderRadius = '8px'; container.style.padding = '10px'; container.style.background = 'rgba(0,0,0,0.5)'; // Icon const iconElem = document.createElement('div'); iconElem.textContent = abilityIcons[abl.id] || ''; iconElem.style.fontSize = '1.8rem'; iconElem.style.marginBottom = '4px'; container.appendChild(iconElem); // Name label const title = document.createElement('div'); title.textContent = abl.displayName; title.style.fontWeight = 'bold'; title.style.fontSize = '0.9rem'; container.appendChild(title); // Description const desc = document.createElement('div'); desc.textContent = abl.description; desc.style.fontSize = '0.7rem'; desc.style.marginTop = '4px'; desc.style.opacity = '0.8'; container.appendChild(desc); // Price label const price = document.createElement('div'); price.style.fontSize = '0.7rem'; price.style.marginTop = '4px'; price.style.fontStyle = 'italic'; price.textContent = `Price: ${abl.price} coins`; container.appendChild(price); // Action button (Buy/Select) const btn = document.createElement('button'); btn.style.marginTop = '6px'; btn.style.padding = '4px 8px'; btn.style.border = 'none'; btn.style.borderRadius = '4px'; btn.style.cursor = 'pointer'; if (!unlockedAbilities[abl.id]) { // Not unlocked: show Buy button btn.textContent = `Buy (${abl.price})`; btn.style.background = coins >= abl.price ? '#00C853' : '#757575'; btn.style.color = '#fff'; btn.disabled = coins < abl.price; btn.addEventListener('click', () => { if (coins >= abl.price) { coins -= abl.price; localStorage.setItem('flappyCoins', coins.toString()); unlockedAbilities[abl.id] = true; localStorage.setItem('flappyUnlockedAbilities', JSON.stringify(unlockedAbilities)); updateCoinsDisplay(); renderShop(); } }); } else { // Already unlocked: allow select if (selectedAbility === abl.id) { btn.textContent = 'Selected'; btn.disabled = true; btn.style.background = '#888'; btn.style.color = '#fff'; } else { btn.textContent = 'Select'; btn.style.background = '#00C853'; btn.style.color = '#fff'; btn.addEventListener('click', () => { selectedAbility = abl.id; localStorage.setItem('flappySelectedAbility', selectedAbility); updateAbilityButton(); renderShop(); }); } } container.appendChild(btn); shopContentElem.appendChild(container); }); } } /** * Hides the character shop. */ function closeShop() { shopModal.style.display = 'none'; // If game isn't in play state, restore overlay visibility for start or game over screens if (gameState !== 'play') { overlay.style.display = 'flex'; } } /** * Updates the high score display in the UI. */ function updateScoresDisplay() { highScore = parseInt(localStorage.getItem('flappyHighScore')) || highScore; // Display high score with trophy icon highscoreElem.innerHTML = `🏆 ${highScore}`; } /** * Updates the coin counter display in the UI. */ function updateCoinsDisplay() { if (typeof coins !== 'number') coins = 0; // Display coin count with coin icon (U+1FA99), fallback to simple circle if unsupported const coinIcon = '🪙'; coinsElem.innerHTML = `${coinIcon} ${coins}`; } /** * Updates the ability activation button. The button shows the icon of the * selected ability and is hidden when no ability is selected. It becomes * disabled during cooldown and enabled otherwise. */ function updateAbilityButton() { if (!abilityBtn) return; if (selectedAbility && unlockedAbilities[selectedAbility]) { abilityBtn.style.display = 'block'; abilityBtn.textContent = abilityIcons[selectedAbility] || '?'; // Disable button if the ability is cooling down or if the game is not active if (abilityCooldown > 0 || gameState !== 'play') { abilityBtn.disabled = true; abilityBtn.style.opacity = '0.5'; } else { abilityBtn.disabled = false; abilityBtn.style.opacity = '1'; } } else { abilityBtn.style.display = 'none'; } } // Event handlers for shop buttons shopBtn.addEventListener('click', openShop); closeShopBtn.addEventListener('click', closeShop); // Ability button triggers the currently selected ability when pressed if (abilityBtn) { abilityBtn.addEventListener('click', (e) => { e.stopPropagation(); activateAbility(); }); } // Update navigation button styles based on current tab function updateShopNav() { if (currentShopTab === 'characters') { tabCharBtn.style.background = '#00C853'; tabPowerBtn.style.background = '#757575'; const header = document.getElementById('shop-header'); if (header) header.textContent = 'Select Bird'; } else { tabCharBtn.style.background = '#757575'; tabPowerBtn.style.background = '#00C853'; const header = document.getElementById('shop-header'); if (header) header.textContent = 'Abilities'; } } // Tab navigation events tabCharBtn.addEventListener('click', () => { currentShopTab = 'characters'; updateShopNav(); renderShop(); }); /** * Save the current game state to local storage so the user can continue later. * We only save minimal necessary data: game state, score, bird position, velocity, * pipe positions and heights, ability timers, cooldown, and selected ability. */ function saveGameState() { const state = { gameState, score, bird: { x: bird.x, y: bird.y, vy: bird.vy }, pipes: pipes.map(p => ({ x: p.x, topHeight: p.topHeight, passed: p.passed })), giantTimer, bombReady, slowMotionTimer, freezeTimer, gravityFlipTimer, abilityCooldown, selectedAbility, touchDelay }; try { localStorage.setItem('flappyGameState', JSON.stringify(state)); } catch (e) { // local storage may be full or disabled; ignore errors } } /** * Load a previously saved game state from local storage. If no valid saved state exists, * this function does nothing. It sets up the game variables and UI to reflect the * saved state so the player can continue where they left off. */ function loadGameState() { const dataStr = localStorage.getItem('flappyGameState'); if (!dataStr) return; try { const data = JSON.parse(dataStr); // Restore basic state gameState = data.gameState || 'start'; score = data.score || 0; if (data.bird) { bird.x = data.bird.x; bird.y = data.bird.y; bird.vy = data.bird.vy; } pipes.length = 0; if (Array.isArray(data.pipes)) { data.pipes.forEach(p => { pipes.push({ x: p.x, topHeight: p.topHeight, passed: p.passed }); }); } giantTimer = data.giantTimer || 0; bombReady = data.bombReady || 0; slowMotionTimer = data.slowMotionTimer || 0; freezeTimer = data.freezeTimer || 0; gravityFlipTimer = data.gravityFlipTimer || 0; abilityCooldown = data.abilityCooldown || 0; selectedAbility = data.selectedAbility || selectedAbility; touchDelay = data.touchDelay || 0; // Restore UI overlay.style.display = gameState === 'play' ? 'none' : 'flex'; if (gameState === 'play') { overlay.style.display = 'none'; } else if (gameState === 'start') { overlay.innerHTML = 'Click or press space to flap
Press again after game over to restart'; } else if (gameState === 'over') { overlay.innerHTML = `Game Over
Score: ${score}
High Score: ${highScore}
Click or press space to restart`; } updateScoresDisplay(); updateCoinsDisplay(); updateAbilityButton(); } catch (e) { // If parsing fails, clear the corrupted saved state and start fresh localStorage.removeItem('flappyGameState'); resetGame(); } } tabPowerBtn.addEventListener('click', () => { currentShopTab = 'powers'; updateShopNav(); renderShop(); }); /** * Generates a new pipe at the right edge of the canvas with a random gap position. */ function spawnPipe() { const minHeight = 40; const maxTopHeight = H - groundHeight - pipeGap - minHeight; const topHeight = Math.floor(Math.random() * (maxTopHeight - minHeight + 1)) + minHeight; pipes.push({ x: W, topHeight: topHeight, passed: false }); } /** * Draws the scrolling background. A simple parallax effect is achieved by repeating the background horizontally. */ function drawBackground() { const bgImg = images.background; const scrollX = (frameCount * pipeSpeed / 2) % bgImg.width; // Draw two images side by side to fill the canvas width ctx.drawImage(bgImg, -scrollX, 0, bgImg.width, H); ctx.drawImage(bgImg, bgImg.width - scrollX, 0, bgImg.width, H); } /** * Draws the ground as a simple green rectangle with a brown border. */ function drawGround() { const groundY = H - groundHeight; // Grass ctx.fillStyle = '#78C850'; ctx.fillRect(0, groundY, W, groundHeight * 0.35); // Dirt ctx.fillStyle = '#8B4513'; ctx.fillRect(0, groundY + groundHeight * 0.35, W, groundHeight * 0.65); // Draw some lighter stripes to simulate texture ctx.strokeStyle = 'rgba(255,255,255,0.1)'; for (let i = 0; i < W; i += 20) { ctx.beginPath(); ctx.moveTo(i, groundY + groundHeight * 0.4); ctx.lineTo(i, H); ctx.stroke(); } } /** * Main update and render loop. */ function loop() { frameCount++; ctx.clearRect(0, 0, W, H); drawBackground(); // Adjust bird size for giant/tiny power‑ups let scale = 1; if (giantTimer > 0) { scale = 1.6; } bird.width = baseBirdW * scale; bird.height = baseBirdH * scale; if (gameState === 'play') { // Determine gravity direction. Gravity can be inverted when gravityFlipTimer > 0. const gdir = gravityFlipTimer > 0 ? -1 : 1; let effectiveGravity = gravity * gdir; bird.vy += effectiveGravity; bird.y += bird.vy; // Bound bird to screen if (gdir === 1) { if (bird.y < 0) { bird.y = 0; bird.vy = 0; } } else { // When gravity is flipped, top is ground; do not bound at bottom // But keep bird within canvas if (bird.y + bird.height > H - groundHeight) { // allow falling off bottom but keep within visible area bird.y = H - groundHeight - bird.height; bird.vy = 0; } } // Spawn pipes periodically if (frameCount % spawnInterval === 0) { spawnPipe(); } // Determine current pipe speed with modifiers let currentSpeed = pipeSpeed; if (slowMotionTimer > 0) currentSpeed = pipeSpeed * 0.5; if (freezeTimer > 0) currentSpeed = 0; // Update pipe positions for (let i = 0; i < pipes.length; i++) { const p = pipes[i]; p.x -= currentSpeed; } // Remove off‑screen pipes while (pipes.length && pipes[0].x + pipeWidth < 0) { pipes.shift(); } // If the bomb ability has been activated (bombReady > 0), automatically // destroy the next upcoming pipe. This simulates an explosion that // clears a single obstacle. if (bombReady > 0 && pipes.length) { const nextPipe = pipes[0]; if (!nextPipe.passed) { nextPipe.passed = true; score++; // Award a coin for each pipe cleared via bomb coins++; localStorage.setItem('flappyCoins', coins.toString()); updateCoinsDisplay(); // Remove the skipped pipe to prevent collision detection pipes.shift(); bombReady--; } } // Award score when bird passes pipes for (const p of pipes) { if (!p.passed && p.x + pipeWidth < bird.x) { p.passed = true; score++; // Award one coin per pipe passed coins++; localStorage.setItem('flappyCoins', coins.toString()); updateCoinsDisplay(); } } // Collision detection with pipes for (let i = 0; i < pipes.length && gameState === 'play'; i++) { const p = pipes[i]; const birdBox = { x: bird.x, y: bird.y, w: bird.width, h: bird.height }; const topBox = { x: p.x, y: 0, w: pipeWidth, h: p.topHeight }; const bottomBox = { x: p.x, y: p.topHeight + pipeGap, w: pipeWidth, h: H - groundHeight - (p.topHeight + pipeGap) }; if (rectIntersect(birdBox, topBox) || rectIntersect(birdBox, bottomBox)) { if (giantTimer > 0) { // Destroy pipe: award pass and remove p.passed = true; score++; coins++; localStorage.setItem('flappyCoins', coins.toString()); updateCoinsDisplay(); pipes.splice(i, 1); i--; continue; } else { // Game over gameState = 'over'; touchDelay = 0; // Reset touch delay to allow immediate restart updateAbilityButton(); if (score > highScore) { highScore = score; localStorage.setItem('flappyHighScore', highScore.toString()); } overlay.style.display = 'flex'; overlay.innerHTML = `Game Over
Score: ${score}
High Score: ${highScore}
Click or press space to restart`; updateScoresDisplay(); break; } } } // Check collision with ground or top depending on gravity if (gameState === 'play') { if (gravityFlipTimer <= 0) { // Normal gravity – check ground at bottom if (bird.y + bird.height >= H - groundHeight) { if (giantTimer > 0) { // If giant mode is active, shrink back and allow one extra bounce off the ground giantTimer = 0; bird.y = H - groundHeight - bird.height - 1; bird.vy = 0; } else { bird.y = H - groundHeight - bird.height; gameState = 'over'; touchDelay = 0; // Reset touch delay to allow immediate restart updateAbilityButton(); if (score > highScore) { highScore = score; localStorage.setItem('flappyHighScore', highScore.toString()); } overlay.style.display = 'flex'; overlay.innerHTML = `Game Over
Score: ${score}
High Score: ${highScore}
Click or press space to restart`; updateScoresDisplay(); } } } else { // Gravity flipped – check top as ground if (bird.y <= 0) { if (giantTimer > 0) { // If giant mode is active when gravity is flipped, shrink back and allow a bounce giantTimer = 0; bird.y = 1; bird.vy = 0; } else { bird.y = 0; gameState = 'over'; touchDelay = 0; // Reset touch delay to allow immediate restart updateAbilityButton(); if (score > highScore) { highScore = score; localStorage.setItem('flappyHighScore', highScore.toString()); } overlay.style.display = 'flex'; overlay.innerHTML = `Game Over
Score: ${score}
High Score: ${highScore}
Click or press space to restart`; updateScoresDisplay(); } } } } // Decrement timers each frame if (slowMotionTimer > 0) slowMotionTimer--; if (freezeTimer > 0) freezeTimer--; if (giantTimer > 0) giantTimer--; if (gravityFlipTimer > 0) gravityFlipTimer--; // Update ability cooldown and effect timers if (abilityCooldown > 0) { abilityCooldown--; if (abilityCooldown === 0) { updateAbilityButton(); } } if (effectTimer > 0) { effectTimer--; if (effectTimer <= 0) { // Hide the effect overlay when finished effectContainer.style.display = 'none'; effectAbility = null; } } // Decrement touch delay timer if (touchDelay > 0) touchDelay--; // Periodically save the game state so the player can resume later if (!window._saveFrameCounter) window._saveFrameCounter = 0; window._saveFrameCounter++; if (window._saveFrameCounter >= 30) { saveGameState(); window._saveFrameCounter = 0; } } // Draw pipes for (const p of pipes) { // Top pipe ctx.drawImage(images.pipe, p.x, p.topHeight - images.pipe.height, pipeWidth, images.pipe.height); // Bottom pipe ctx.save(); // Flip the pipe vertically for bottom ctx.translate(p.x + pipeWidth, p.topHeight + pipeGap); ctx.scale(-1, 1); ctx.drawImage(images.pipe, 0, 0, pipeWidth, images.pipe.height); ctx.restore(); } // Animate bird (toggle between frames every few frames) if (frameCount % 10 === 0) bird.frame = (bird.frame + 1) % 2; // Select the appropriate frame for the current character. Default to classic frames if unavailable. let birdImg; const currentCharData = characters.find(c => c.id === currentChar) || characters[0]; const frameName = currentCharData.frames[bird.frame] || currentCharData.frames[0]; birdImg = images[frameName] || images.bird0; // Slight rotation based on velocity for a dynamic feel const angle = Math.min(Math.max(bird.vy / 10, -0.5), 0.7); ctx.save(); ctx.translate(bird.x + bird.width / 2, bird.y + bird.height / 2); ctx.rotate(angle); ctx.drawImage(birdImg, -bird.width / 2, -bird.height / 2, bird.width, bird.height); ctx.restore(); drawGround(); // Draw score if (gameState !== 'start') { scoreElem.textContent = score; } requestAnimationFrame(loop); } /** * Simple rectangle collision detection. * @param {Object} a Rectangle A with x, y, w, h. * @param {Object} b Rectangle B with x, y, w, h. * @returns {boolean} True if the rectangles intersect. */ function rectIntersect(a, b) { return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; } // Initialize game resetGame(); })();