Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>Space Invaders Arcade</title> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap'); | |
| :root { | |
| --bg-color: #050505; | |
| --screen-bg: #111; | |
| --neon-green: #39ff14; | |
| --neon-red: #ff0033; | |
| --neon-white: #eee; | |
| } | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| background-color: var(--bg-color); | |
| color: var(--neon-white); | |
| font-family: 'Press Start 2P', cursive; | |
| overflow: hidden; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| height: 100vh; | |
| user-select: none; | |
| -webkit-user-select: none; | |
| } | |
| #game-container { | |
| position: relative; | |
| width: 100%; | |
| height: 100%; | |
| max-width: 600px; /* Arcade aspect ratio constraint */ | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| background: #000; | |
| box-shadow: 0 0 20px rgba(0, 255, 0, 0.1); | |
| } | |
| canvas { | |
| background-color: var(--screen-bg); | |
| image-rendering: pixelated; /* Crucial for retro look */ | |
| max-width: 100%; | |
| max-height: 100%; | |
| } | |
| /* Retro CRT Scanline Effect */ | |
| .scanlines { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: linear-gradient( | |
| to bottom, | |
| rgba(255,255,255,0), | |
| rgba(255,255,255,0) 50%, | |
| rgba(0,0,0,0.2) 50%, | |
| rgba(0,0,0,0.2) | |
| ); | |
| background-size: 100% 4px; | |
| pointer-events: none; | |
| z-index: 10; | |
| } | |
| .overlay-ui { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| pointer-events: none; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: space-between; | |
| padding: 20px; | |
| box-sizing: border-box; | |
| z-index: 20; | |
| } | |
| .hud { | |
| display: flex; | |
| justify-content: space-between; | |
| width: 100%; | |
| font-size: 16px; | |
| text-shadow: 2px 2px 0px #000; | |
| } | |
| #start-screen, #game-over-screen { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0, 0, 0, 0.85); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 30; | |
| text-align: center; | |
| } | |
| h1 { | |
| color: var(--neon-green); | |
| font-size: 40px; | |
| margin-bottom: 20px; | |
| line-height: 1.5; | |
| text-shadow: 4px 4px 0px #003300; | |
| } | |
| .btn { | |
| background: transparent; | |
| border: 2px solid var(--neon-green); | |
| color: var(--neon-green); | |
| padding: 15px 30px; | |
| font-family: 'Press Start 2P', cursive; | |
| font-size: 16px; | |
| cursor: pointer; | |
| margin-top: 20px; | |
| text-transform: uppercase; | |
| transition: all 0.1s; | |
| pointer-events: auto; | |
| } | |
| .btn:active { | |
| background: var(--neon-green); | |
| color: #000; | |
| } | |
| .hidden { | |
| display: none ; | |
| } | |
| /* Mobile Controls Hints */ | |
| .mobile-controls-hint { | |
| position: absolute; | |
| bottom: 20px; | |
| width: 100%; | |
| text-align: center; | |
| color: rgba(255, 255, 255, 0.3); | |
| font-size: 10px; | |
| pointer-events: none; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="game-container"> | |
| <canvas id="gameCanvas"></canvas> | |
| <div class="scanlines"></div> | |
| <div class="overlay-ui"> | |
| <div class="hud"> | |
| <div id="scoreDisplay">SCORE: 0000</div> | |
| <div id="highScoreDisplay">HI: 0000</div> | |
| </div> | |
| </div> | |
| <div id="start-screen"> | |
| <h1>SPACE<br>INVADERS</h1> | |
| <p style="color: #ccc; font-size: 12px; margin-bottom: 30px;">ARROWS TO MOVE • SPACE TO SHOOT</p> | |
| <p style="color: #888; font-size: 10px; margin-bottom: 30px;">(TOUCH SIDES TO MOVE • TAP TO SHOOT)</p> | |
| <button class="btn" id="startBtn">INSERT COIN</button> | |
| </div> | |
| <div id="game-over-screen" class="hidden"> | |
| <h1 style="color: var(--neon-red);">GAME OVER</h1> | |
| <p id="finalScore">SCORE: 0</p> | |
| <button class="btn" id="restartBtn">TRY AGAIN</button> | |
| </div> | |
| </div> | |
| <script> | |
| /** | |
| * SPACE INVADERS CLONE | |
| * Single file implementation with synthesized audio and generated pixel art. | |
| */ | |
| // --- Audio System (Web Audio API) --- | |
| const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); | |
| const soundEnabled = true; | |
| const Sounds = { | |
| shoot: () => playTone(880, 'square', 0.1, -10), | |
| alienDeath: () => playTone(150, 'sawtooth', 0.2, -5), | |
| playerDeath: () => noise(0.5), | |
| ufo: () => playTone(300, 'sine', 0.5, -10, true), // Looping tone concept, implemented simply here | |
| ufoHit: () => playTone(1000, 'square', 0.3, -5) | |
| }; | |
| function playTone(freq, type, duration, vol = -10, slide = false) { | |
| if (!soundEnabled || audioCtx.state === 'suspended') audioCtx.resume(); | |
| const osc = audioCtx.createOscillator(); | |
| const gain = audioCtx.createGain(); | |
| osc.type = type; | |
| osc.frequency.setValueAtTime(freq, audioCtx.currentTime); | |
| if (slide) { | |
| osc.frequency.linearRampToValueAtTime(freq - 100, audioCtx.currentTime + duration); | |
| } | |
| gain.gain.setValueAtTime(Math.pow(10, vol/20), audioCtx.currentTime); | |
| gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + duration); | |
| osc.connect(gain); | |
| gain.connect(audioCtx.destination); | |
| osc.start(); | |
| osc.stop(audioCtx.currentTime + duration); | |
| } | |
| function noise(duration) { | |
| if (!soundEnabled || audioCtx.state === 'suspended') audioCtx.resume(); | |
| const bufferSize = audioCtx.sampleRate * duration; | |
| const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate); | |
| const data = buffer.getChannelData(0); | |
| for (let i = 0; i < bufferSize; i++) { | |
| data[i] = Math.random() * 2 - 1; | |
| } | |
| const noiseSrc = audioCtx.createBufferSource(); | |
| noiseSrc.buffer = buffer; | |
| const gain = audioCtx.createGain(); | |
| gain.gain.setValueAtTime(0.1, audioCtx.currentTime); | |
| gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + duration); | |
| noiseSrc.connect(gain); | |
| gain.connect(audioCtx.destination); | |
| noiseSrc.start(); | |
| } | |
| // --- Graphics Constants (Pixel Art Bitmaps) --- | |
| // 1 = pixel, 0 = empty | |
| const SPRITES = { | |
| player: [ | |
| [0,0,0,0,0,1,0,0,0,0,0], | |
| [0,0,0,0,1,1,1,0,0,0,0], | |
| [0,0,0,0,1,1,1,0,0,0,0], | |
| [0,1,1,1,1,1,1,1,1,1,0], | |
| [1,1,1,1,1,1,1,1,1,1,1], | |
| [1,1,1,1,1,1,1,1,1,1,1], | |
| [1,1,1,1,1,1,1,1,1,1,1], | |
| [1,1,1,1,1,1,1,1,1,1,1] | |
| ], | |
| alien1: [ // Squid (Top row) | |
| [0,0,0,1,1,0,0,0], | |
| [0,0,1,1,1,1,0,0], | |
| [0,1,1,1,1,1,1,0], | |
| [1,1,0,1,1,0,1,1], | |
| [1,1,1,1,1,1,1,1], | |
| [0,1,0,1,1,0,1,0], | |
| [1,0,0,0,0,0,0,1], | |
| [0,1,0,0,0,0,1,0] | |
| ], | |
| alien2: [ // Crab (Middle rows) | |
| [0,0,1,0,0,0,0,0,1,0,0], | |
| [0,0,0,1,0,0,0,1,0,0,0], | |
| [0,0,1,1,1,1,1,1,1,0,0], | |
| [0,1,1,0,1,1,1,0,1,1,0], | |
| [1,1,1,1,1,1,1,1,1,1,1], | |
| [1,0,1,1,1,1,1,1,1,0,1], | |
| [1,0,1,0,0,0,0,0,1,0,1], | |
| [0,0,0,1,1,0,1,1,0,0,0] | |
| ], | |
| alien3: [ // Octopus (Bottom rows) | |
| [0,0,0,0,1,1,1,1,0,0,0,0], | |
| [0,1,1,1,1,1,1,1,1,1,1,0], | |
| [1,1,1,1,1,1,1,1,1,1,1,1], | |
| [1,1,1,0,0,1,1,0,0,1,1,1], | |
| [1,1,1,1,1,1,1,1,1,1,1,1], | |
| [0,0,0,1,1,0,0,1,1,0,0,0], | |
| [0,0,1,1,0,1,1,0,1,1,0,0], | |
| [1,1,0,0,0,0,0,0,0,0,1,1] | |
| ], | |
| ufo: [ | |
| [0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0], | |
| [0,0,0,1,1,1,1,1,1,1,1,1,1,0,0,0], | |
| [0,0,1,1,1,1,1,1,1,1,1,1,1,1,0,0], | |
| [0,1,1,0,1,1,0,1,1,0,1,1,0,1,1,0], | |
| [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], | |
| [0,0,1,1,1,0,0,1,1,0,0,1,1,1,0,0], | |
| [0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0] | |
| ] | |
| }; | |
| // --- Game Engine --- | |
| const canvas = document.getElementById('gameCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| // Game Constants | |
| const LOGICAL_WIDTH = 600; | |
| const LOGICAL_HEIGHT = 800; | |
| const PLAYER_SPEED = 4; | |
| const BULLET_SPEED = 7; | |
| const ALIEN_BULLET_SPEED = 4; | |
| const PIXEL_SIZE = 3; // Size of one 'pixel' in the sprite grid | |
| // Game State | |
| let lastTime = 0; | |
| let score = 0; | |
| let highScore = localStorage.getItem('si_highscore') || 0; | |
| let lives = 3; | |
| let level = 1; | |
| let gameState = 'START'; // START, PLAYING, GAMEOVER, LEVEL_TRANSITION | |
| // Entities | |
| let player = {}; | |
| let aliens = []; | |
| let bullets = []; // {x, y, vY, type} type: 'player' | 'enemy' | |
| let particles = []; // {x, y, vX, vY, life, color} | |
| let bunkers = []; // {x, y, width, height, blocks: []} | |
| let ufo = null; | |
| let ufoTimer = 0; | |
| // Input State | |
| const keys = { | |
| ArrowLeft: false, | |
| ArrowRight: false, | |
| Space: false | |
| }; | |
| // Touch State | |
| let touchX = null; | |
| let touchActive = false; | |
| function initCanvas() { | |
| canvas.width = LOGICAL_WIDTH; | |
| canvas.height = LOGICAL_HEIGHT; | |
| ctx.imageSmoothingEnabled = false; | |
| } | |
| function resetGame() { | |
| score = 0; | |
| lives = 3; | |
| level = 1; | |
| updateScoreUI(); | |
| initLevel(); | |
| gameState = 'PLAYING'; | |
| document.getElementById('game-over-screen').classList.add('hidden'); | |
| document.getElementById('start-screen').classList.add('hidden'); | |
| } | |
| function initLevel() { | |
| player = { | |
| x: LOGICAL_WIDTH / 2 - (11 * PIXEL_SIZE) / 2, | |
| y: LOGICAL_HEIGHT - 50, | |
| w: 11 * PIXEL_SIZE, | |
| h: 8 * PIXEL_SIZE, | |
| color: '#39ff14', | |
| cooldown: 0 | |
| }; | |
| bullets = []; | |
| particles = []; | |
| ufo = null; | |
| ufoTimer = Math.random() * 1000 + 500; | |
| // Create Aliens | |
| aliens = []; | |
| const rows = 5; | |
| const cols = 11; | |
| const startX = 50; | |
| const startY = 80; | |
| for(let r=0; r<rows; r++) { | |
| for(let c=0; c<cols; c++) { | |
| let type = 'alien1'; | |
| if(r >= 1 && r <= 2) type = 'alien2'; | |
| if(r >= 3) type = 'alien3'; | |
| aliens.push({ | |
| x: startX + c * 40, | |
| y: startY + r * 35, | |
| w: SPRITES[type][0].length * PIXEL_SIZE, | |
| h: SPRITES[type].length * PIXEL_SIZE, | |
| type: type, | |
| row: r, | |
| col: c, | |
| active: true | |
| }); | |
| } | |
| } | |
| // Alien movement state | |
| alienDir = 1; // 1 = right, -1 = left | |
| alienMoveTimer = 0; | |
| alienMoveInterval = Math.max(5, 60 - (level * 5)); // Get faster per level | |
| alienSoundToggle = false; // For simple march beat if we added one | |
| // Bunkers (only reset on full game restart usually, but here every level for simplicity) | |
| if (level === 1 || bunkers.length === 0) { | |
| bunkers = []; | |
| for(let i=0; i<4; i++) { | |
| createBunker(80 + i * 130, LOGICAL_HEIGHT - 150); | |
| } | |
| } | |
| } | |
| function createBunker(x, y) { | |
| const w = 60; | |
| const h = 40; | |
| const blocks = []; | |
| const blockSize = 4; | |
| for(let by = 0; by < h; by += blockSize) { | |
| for(let bx = 0; bx < w; bx += blockSize) { | |
| // Create an arch shape | |
| if (by > 25 && bx > 15 && bx < 45) continue; | |
| // Corner rounding | |
| if (by < 8 && (bx < 8 || bx > 52)) continue; | |
| blocks.push({ | |
| x: x + bx, | |
| y: y + by, | |
| w: blockSize, | |
| h: blockSize, | |
| active: true | |
| }); | |
| } | |
| } | |
| bunkers.push({ x, y, w, h, blocks }); | |
| } | |
| // --- Input Handling --- | |
| window.addEventListener('keydown', e => { | |
| if(e.code === 'ArrowLeft') keys.ArrowLeft = true; | |
| if(e.code === 'ArrowRight') keys.ArrowRight = true; | |
| if(e.code === 'Space' || e.code === 'ArrowUp') keys.Space = true; | |
| }); | |
| window.addEventListener('keyup', e => { | |
| if(e.code === 'ArrowLeft') keys.ArrowLeft = false; | |
| if(e.code === 'ArrowRight') keys.ArrowRight = false; | |
| if(e.code === 'Space' || e.code === 'ArrowUp') keys.Space = false; | |
| }); | |
| // Touch Controls | |
| canvas.addEventListener('touchstart', e => { | |
| e.preventDefault(); | |
| touchActive = true; | |
| handleTouch(e.touches[0]); | |
| // Tap logic for shooting: if tap is quick or if we want auto fire | |
| // For arcade feel: touching sides moves, tapping center area shoots | |
| // Simplified: Just fire if touching. | |
| keys.Space = true; | |
| }, {passive: false}); | |
| canvas.addEventListener('touchmove', e => { | |
| e.preventDefault(); | |
| handleTouch(e.touches[0]); | |
| }, {passive: false}); | |
| canvas.addEventListener('touchend', e => { | |
| e.preventDefault(); | |
| touchActive = false; | |
| keys.ArrowLeft = false; | |
| keys.ArrowRight = false; | |
| keys.Space = false; | |
| touchX = null; | |
| }); | |
| function handleTouch(touch) { | |
| const rect = canvas.getBoundingClientRect(); | |
| const x = touch.clientX - rect.left; | |
| const scaleX = canvas.width / rect.width; | |
| const gameX = x * scaleX; | |
| // Virtual joystick logic logic | |
| // Screen divided in half. Left half = Left, Right half = Right. | |
| if (gameX < LOGICAL_WIDTH / 2) { | |
| keys.ArrowLeft = true; | |
| keys.ArrowRight = false; | |
| } else { | |
| keys.ArrowLeft = false; | |
| keys.ArrowRight = true; | |
| } | |
| } | |
| // --- Logic --- | |
| function update(dt) { | |
| if (gameState !== 'PLAYING') return; | |
| // Player Movement | |
| if (keys.ArrowLeft) player.x = Math.max(10, player.x - PLAYER_SPEED); | |
| if (keys.ArrowRight) player.x = Math.min(LOGICAL_WIDTH - player.w - 10, player.x + PLAYER_SPEED); | |
| // Player Shoot | |
| if (player.cooldown > 0) player.cooldown--; | |
| if (keys.Space && player.cooldown <= 0) { | |
| bullets.push({ | |
| x: player.x + player.w/2 - 2, | |
| y: player.y, | |
| w: 4, | |
| h: 10, | |
| vY: -BULLET_SPEED, | |
| color: '#39ff14', | |
| type: 'player' | |
| }); | |
| player.cooldown = 25; | |
| Sounds.shoot(); | |
| } | |
| // Update Bullets | |
| for (let i = bullets.length - 1; i >= 0; i--) { | |
| const b = bullets[i]; | |
| b.y += b.vY; | |
| // Out of bounds | |
| if (b.y < 0 || b.y > LOGICAL_HEIGHT) { | |
| bullets.splice(i, 1); | |
| continue; | |
| } | |
| // Bullet collisions with bunkers | |
| let bulletHitBunker = false; | |
| bunkers.forEach(bunker => { | |
| if (b.x < bunker.x + bunker.w && b.x + b.w > bunker.x && | |
| b.y < bunker.y + bunker.h && b.y + b.h > bunker.y) { | |
| // Check individual blocks | |
| for (let k = bunker.blocks.length - 1; k >= 0; k--) { | |
| const block = bunker.blocks[k]; | |
| if (block.active && | |
| b.x < block.x + block.w && b.x + b.w > block.x && | |
| b.y < block.y + block.h && b.y + b.h > block.y) { | |
| block.active = false; // Destroy block | |
| bulletHitBunker = true; | |
| createExplosion(block.x, block.y, '#39ff14', 2); | |
| // Destroy bullet | |
| break; // Only destroy one block per frame per bullet usually, or bullet dies immediately | |
| } | |
| } | |
| } | |
| }); | |
| if (bulletHitBunker) { | |
| bullets.splice(i, 1); | |
| continue; | |
| } | |
| // Bullet Collisions (Player Bullet hitting Aliens) | |
| if (b.type === 'player') { | |
| // Check UFO | |
| if (ufo && checkRectCollide(b, ufo)) { | |
| createExplosion(ufo.x + ufo.w/2, ufo.y + ufo.h/2, '#ff0033', 20); | |
| ufo = null; | |
| score += Math.floor(Math.random()*3 + 1) * 100; | |
| updateScoreUI(); | |
| Sounds.ufoHit(); | |
| bullets.splice(i, 1); | |
| continue; | |
| } | |
| // Check Aliens | |
| let hit = false; | |
| for (let j = 0; j < aliens.length; j++) { | |
| const a = aliens[j]; | |
| if (a.active && checkRectCollide(b, a)) { | |
| a.active = false; | |
| hit = true; | |
| score += (4 - Math.floor(a.row/2)) * 10; // Top row worth more? Actually in SI top is 30, mid 20, bot 10 | |
| updateScoreUI(); | |
| createExplosion(a.x + a.w/2, a.y + a.h/2, '#fff', 8); | |
| Sounds.alienDeath(); | |
| break; | |
| } | |
| } | |
| if (hit) { | |
| bullets.splice(i, 1); | |
| // Increase speed slightly as aliens die | |
| const activeAliens = aliens.filter(a => a.active).length; | |
| if (activeAliens === 0) { | |
| levelComplete(); | |
| } else { | |
| // Speed up based on ratio remaining | |
| const total = 55; | |
| const ratio = activeAliens / total; | |
| alienMoveInterval = Math.max(2, (60 - (level*5)) * ratio); | |
| } | |
| continue; | |
| } | |
| } | |
| // Alien Bullet hitting Player | |
| else if (b.type === 'enemy') { | |
| if (checkRectCollide(b, player)) { | |
| playerHit(); | |
| bullets.splice(i, 1); | |
| continue; | |
| } | |
| } | |
| // Bullet vs Bullet (rare but cool) | |
| /* logic omitted for simplicity */ | |
| } | |
| // Update Aliens | |
| alienMoveTimer++; | |
| if (alienMoveTimer > alienMoveInterval) { | |
| alienMoveTimer = 0; | |
| moveAliens(); | |
| } | |
| // Alien Shooting | |
| if (Math.random() < 0.02 + (level * 0.005)) { | |
| const activeCols = []; | |
| aliens.forEach(a => { | |
| if(a.active) { | |
| if(!activeCols[a.col] || a.y > activeCols[a.col].y) { | |
| activeCols[a.col] = a; | |
| } | |
| } | |
| }); | |
| const shootingAlien = activeCols[Object.keys(activeCols)[Math.floor(Math.random() * Object.keys(activeCols).length)]]; | |
| if(shootingAlien) { | |
| bullets.push({ | |
| x: shootingAlien.x + shootingAlien.w/2, | |
| y: shootingAlien.y + shootingAlien.h, | |
| w: 4, | |
| h: 10, | |
| vY: ALIEN_BULLET_SPEED, | |
| color: '#fff', | |
| type: 'enemy' | |
| }); | |
| } | |
| } | |
| // UFO Logic | |
| if (!ufo) { | |
| ufoTimer--; | |
| if (ufoTimer <= 0) { | |
| ufo = { | |
| x: -50, | |
| y: 40, | |
| w: SPRITES.ufo[0].length * PIXEL_SIZE, | |
| h: SPRITES.ufo.length * PIXEL_SIZE, | |
| vX: 3 | |
| }; | |
| Sounds.ufo(); | |
| } | |
| } else { | |
| ufo.x += ufo.vX; | |
| if (ufo.x > LOGICAL_WIDTH + 50) { | |
| ufo = null; | |
| ufoTimer = Math.random() * 1000 + 500; | |
| } | |
| } | |
| // Particles | |
| for (let i = particles.length - 1; i >= 0; i--) { | |
| const p = particles[i]; | |
| p.x += p.vX; | |
| p.y += p.vY; | |
| p.life--; | |
| if (p.life <= 0) particles.splice(i, 1); | |
| } | |
| } | |
| function moveAliens() { | |
| let hitEdge = false; | |
| let lowermost = 0; | |
| aliens.forEach(a => { | |
| if (!a.active) return; | |
| if (alienDir === 1 && a.x + a.w > LOGICAL_WIDTH - 20) hitEdge = true; | |
| if (alienDir === -1 && a.x < 20) hitEdge = true; | |
| if (a.y > lowermost) lowermost = a.y; | |
| }); | |
| if (hitEdge) { | |
| alienDir *= -1; | |
| aliens.forEach(a => a.y += 20); | |
| // Check invasion | |
| if (lowermost + 20 >= player.y) { | |
| gameOver(); | |
| } | |
| } else { | |
| aliens.forEach(a => a.x += 10 * alienDir); | |
| } | |
| } | |
| function checkRectCollide(r1, r2) { | |
| return (r1.x < r2.x + r2.w && | |
| r1.x + r1.w > r2.x && | |
| r1.y < r2.y + r2.h && | |
| r1.y + r1.h > r2.y); | |
| } | |
| function playerHit() { | |
| lives--; | |
| createExplosion(player.x + player.w/2, player.y + player.h/2, '#39ff14', 50); | |
| Sounds.playerDeath(); | |
| updateScoreUI(); | |
| if (lives <= 0) { | |
| gameOver(); | |
| } else { | |
| // Respawn delay or effect could go here | |
| bullets = []; // Clear bullets for fairness | |
| player.x = LOGICAL_WIDTH / 2; // Reset Pos | |
| // Pause briefly? | |
| } | |
| } | |
| function levelComplete() { | |
| level++; | |
| score += 1000; | |
| initLevel(); | |
| // Brief pause logic could go here | |
| } | |
| function gameOver() { | |
| gameState = 'GAMEOVER'; | |
| if (score > highScore) { | |
| highScore = score; | |
| localStorage.setItem('si_highscore', highScore); | |
| } | |
| document.getElementById('finalScore').innerText = "SCORE: " + score; | |
| document.getElementById('game-over-screen').classList.remove('hidden'); | |
| } | |
| function createExplosion(x, y, color, count) { | |
| for(let i=0; i<count; i++) { | |
| particles.push({ | |
| x: x, | |
| y: y, | |
| vX: (Math.random() - 0.5) * 8, | |
| vY: (Math.random() - 0.5) * 8, | |
| life: 20 + Math.random() * 10, | |
| color: color | |
| }); | |
| } | |
| } | |
| // --- Rendering --- | |
| function drawSprite(spriteMap, x, y, color) { | |
| ctx.fillStyle = color; | |
| for(let r=0; r<spriteMap.length; r++) { | |
| for(let c=0; c<spriteMap[r].length; c++) { | |
| if(spriteMap[r][c] === 1) { | |
| ctx.fillRect(x + c * PIXEL_SIZE, y + r * PIXEL_SIZE, PIXEL_SIZE, PIXEL_SIZE); | |
| } | |
| } | |
| } | |
| } | |
| function draw() { | |
| // Clear Screen | |
| ctx.fillStyle = 'black'; | |
| ctx.fillRect(0, 0, LOGICAL_WIDTH, LOGICAL_HEIGHT); | |
| // Stars/Background | |
| // (Static optimization: could draw once to offscreen canvas, but cheap enough here) | |
| ctx.fillStyle = 'white'; | |
| // Simple starfield | |
| /* Note: In a real complex app, don't generate stars every frame. | |
| Here we just leave black for cleaner arcade look or draw static stars if precalc'd. | |
| Let's skip stars for that stark 1978 look. */ | |
| // Draw Bunkers | |
| ctx.fillStyle = '#39ff14'; | |
| bunkers.forEach(b => { | |
| b.blocks.forEach(block => { | |
| if(block.active) { | |
| ctx.fillRect(block.x, block.y, block.w, block.h); | |
| } | |
| }); | |
| }); | |
| if (gameState === 'PLAYING' || gameState === 'GAMEOVER') { | |
| // Draw Player | |
| if (lives > 0 || Math.floor(Date.now() / 100) % 2 === 0) { // Flicker if hit? (not imp yet) | |
| drawSprite(SPRITES.player, player.x, player.y, player.color); | |
| } | |
| // Draw Aliens | |
| // Animation frame for aliens (arms up/down) | |
| // Using global timer for sync animation | |
| // Actually in SI, animation depends on position, but simple toggle is fine | |
| const animFrame = Math.floor(Date.now() / 500) % 2; | |
| aliens.forEach(a => { | |
| if (a.active) { | |
| // For pure authentic look, sprites change slightly. | |
| // We use same sprite for simplicity, or could modify array reading. | |
| // Let's just draw them. | |
| drawSprite(SPRITES[a.type], a.x, a.y, '#fff'); | |
| } | |
| }); | |
| // Draw UFO | |
| if (ufo) { | |
| drawSprite(SPRITES.ufo, ufo.x, ufo.y, '#ff0033'); | |
| } | |
| // Draw Bullets | |
| bullets.forEach(b => { | |
| ctx.fillStyle = b.color; | |
| // Simple rect bullet | |
| ctx.fillRect(b.x, b.y, b.w, b.h); | |
| // Or zig-zag for alien bullets | |
| if(b.type === 'enemy') { | |
| // Add visual flare to alien bullets | |
| ctx.fillRect(b.x - 2, b.y + 2, 8, 2); | |
| } | |
| }); | |
| // Draw Particles | |
| particles.forEach(p => { | |
| ctx.fillStyle = p.color; | |
| ctx.fillRect(p.x, p.y, PIXEL_SIZE, PIXEL_SIZE); | |
| }); | |
| // Draw Floor Line | |
| ctx.fillStyle = '#39ff14'; | |
| ctx.fillRect(0, LOGICAL_HEIGHT - 1, LOGICAL_WIDTH, 1); | |
| } | |
| // Lives Display (Icons at bottom) | |
| for(let i=0; i<Math.max(0, lives-1); i++) { | |
| drawSprite(SPRITES.player, 20 + i * 40, LOGICAL_HEIGHT - 30, '#39ff14'); | |
| } | |
| } | |
| function updateScoreUI() { | |
| document.getElementById('scoreDisplay').innerText = `SCORE: ${score.toString().padStart(4, '0')}`; | |
| document.getElementById('highScoreDisplay').innerText = `HI: ${highScore.toString().padStart(4, '0')}`; | |
| } | |
| function loop() { | |
| const now = Date.now(); | |
| const dt = (now - lastTime) / 1000; | |
| lastTime = now; | |
| update(dt); | |
| draw(); | |
| requestAnimationFrame(loop); | |
| } | |
| // --- Initialization --- | |
| initCanvas(); | |
| updateScoreUI(); | |
| document.getElementById('startBtn').addEventListener('click', () => { | |
| // Resume Audio Context on user interaction | |
| if(audioCtx.state === 'suspended') audioCtx.resume(); | |
| resetGame(); | |
| }); | |
| document.getElementById('restartBtn').addEventListener('click', () => { | |
| resetGame(); | |
| }); | |
| window.addEventListener('resize', initCanvas); // Ideally handle resize better, but canvas scales via CSS | |
| // Start Loop | |
| requestAnimationFrame(loop); | |
| </script> | |
| </body> | |
| </html> |