Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Asteroids</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet"> | |
| <style> | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| background-color: #000; | |
| color: #fff; | |
| font-family: 'Press Start 2P', cursive; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| height: 100vh; | |
| overflow: hidden; | |
| } | |
| #game-container { | |
| position: relative; | |
| width: 100%; | |
| max-width: 800px; | |
| aspect-ratio: 4 / 3; | |
| border: 2px solid #fff; | |
| box-shadow: 0 0 20px #fff; | |
| } | |
| canvas { | |
| display: block; | |
| background-color: #000; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| #score-container { | |
| position: absolute; | |
| top: 10px; | |
| left: 0; | |
| width: 100%; | |
| display: flex; | |
| justify-content: space-between; | |
| padding: 0 20px; | |
| box-sizing: border-box; | |
| font-size: 16px; | |
| text-shadow: 0 0 5px #fff; | |
| } | |
| #message-overlay { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| text-align: center; | |
| font-size: 24px; | |
| display: none; /* Hidden by default */ | |
| } | |
| #message-overlay p { | |
| margin: 0; | |
| padding: 10px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="game-container"> | |
| <div id="score-container"> | |
| <span id="score">SCORE: 0</span> | |
| <span id="high-score">HIGH: 0</span> | |
| </div> | |
| <canvas id="gameCanvas"></canvas> | |
| <div id="message-overlay"> | |
| <p id="message-title">ASTEROIDS</p> | |
| <p id="message-subtitle" style="font-size: 14px;">PRESS ENTER TO START</p> | |
| </div> | |
| </div> | |
| <script> | |
| // --- DOM ELEMENTS --- | |
| const canvas = document.getElementById('gameCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const scoreEl = document.getElementById('score'); | |
| const highScoreEl = document.getElementById('high-score'); | |
| const messageOverlay = document.getElementById('message-overlay'); | |
| const messageTitle = document.getElementById('message-title'); | |
| const messageSubtitle = document.getElementById('message-subtitle'); | |
| // --- GAME CONSTANTS --- | |
| const SHIP_SIZE = 15; | |
| const SHIP_THRUST = 0.1; | |
| const SHIP_TURN_SPEED = 0.1; // radians | |
| const FRICTION = 0.99; | |
| const BULLET_SPEED = 5; | |
| const BULLET_MAX = 10; | |
| const ASTEROID_NUM = 3; | |
| const ASTEROID_SPEED = 1; | |
| const ASTEROID_SIZE_LARGE = 50; | |
| const ASTEROID_SIZE_MEDIUM = 25; | |
| const ASTEROID_SIZE_SMALL = 12; | |
| const ASTEROID_VERTICES = 10; | |
| const ASTEROID_JAG = 0.4; // Jaggedness of the asteroids | |
| const SAUCER_SPEED = 2; | |
| const SAUCER_SIZE = 15; | |
| const SAUCER_FIRE_RATE = 0.03; // ~ every second at 30fps | |
| const SAUCER_SPAWN_TIME = 15000; // 15 seconds | |
| // --- GAME STATE --- | |
| let ship; | |
| let asteroids = []; | |
| let bullets = []; | |
| let saucer = null; | |
| let score = 0; | |
| let highScore = localStorage.getItem('asteroidsHighScore') || 0; | |
| let lives = 3; | |
| let isPlaying = false; | |
| let keys = {}; | |
| let saucerTimer; | |
| // --- UTILITY FUNCTIONS --- | |
| const degToRad = (deg) => deg * Math.PI / 180; | |
| const radToDeg = (rad) => rad * 180 / Math.PI; | |
| function resizeCanvas() { | |
| const container = document.getElementById('game-container'); | |
| const { width, height } = container.getBoundingClientRect(); | |
| canvas.width = width; | |
| canvas.height = height; | |
| } | |
| // --- CLASSES --- | |
| class Ship { | |
| constructor() { | |
| this.x = canvas.width / 2; | |
| this.y = canvas.height / 2; | |
| this.radius = SHIP_SIZE / 2; | |
| this.angle = degToRad(270); // Pointing up | |
| this.vel = { x: 0, y: 0 }; | |
| this.isThrusting = false; | |
| this.canShoot = true; | |
| this.isInvincible = true; | |
| this.invincibilityTime = 3000; // 3 seconds | |
| setTimeout(() => this.isInvincible = false, this.invincibilityTime); | |
| } | |
| draw() { | |
| ctx.strokeStyle = this.isInvincible ? 'grey' : 'white'; | |
| ctx.lineWidth = SHIP_SIZE / 10; | |
| ctx.beginPath(); | |
| // Nose of the ship | |
| ctx.moveTo( | |
| this.x + this.radius * Math.cos(this.angle), | |
| this.y + this.radius * Math.sin(this.angle) | |
| ); | |
| // Left wing | |
| ctx.lineTo( | |
| this.x - this.radius * (Math.cos(this.angle) + Math.sin(this.angle)), | |
| this.y - this.radius * (Math.sin(this.angle) - Math.cos(this.angle)) | |
| ); | |
| // Right wing | |
| ctx.lineTo( | |
| this.x - this.radius * (Math.cos(this.angle) - Math.sin(this.angle)), | |
| this.y - this.radius * (Math.sin(this.angle) + Math.cos(this.angle)) | |
| ); | |
| ctx.closePath(); | |
| ctx.stroke(); | |
| // Draw thrust flame | |
| if (this.isThrusting) { | |
| ctx.fillStyle = "red"; | |
| ctx.strokeStyle = "yellow"; | |
| ctx.lineWidth = SHIP_SIZE / 15; | |
| ctx.beginPath(); | |
| // Flame point | |
| ctx.moveTo( | |
| this.x - this.radius * (1.5 * Math.cos(this.angle) - 0.5 * Math.sin(this.angle)), | |
| this.y - this.radius * (1.5 * Math.sin(this.angle) + 0.5 * Math.cos(this.angle)) | |
| ); | |
| // Flame base center | |
| ctx.lineTo( | |
| this.x - this.radius * 2.5 * Math.cos(this.angle), | |
| this.y - this.radius * 2.5 * Math.sin(this.angle) | |
| ); | |
| // Flame point | |
| ctx.lineTo( | |
| this.x - this.radius * (1.5 * Math.cos(this.angle) + 0.5 * Math.sin(this.angle)), | |
| this.y - this.radius * (1.5 * Math.sin(this.angle) - 0.5 * Math.cos(this.angle)) | |
| ); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.stroke(); | |
| } | |
| } | |
| update() { | |
| // Rotate ship | |
| if (keys['a'] || keys['A']) { | |
| this.angle -= SHIP_TURN_SPEED; | |
| } | |
| if (keys['d'] || keys['D']) { | |
| this.angle += SHIP_TURN_SPEED; | |
| } | |
| // Thrust | |
| this.isThrusting = (keys['w'] || keys['W'] || keys['e'] || keys['E']); | |
| if (this.isThrusting) { | |
| this.vel.x += SHIP_THRUST * Math.cos(this.angle); | |
| this.vel.y += SHIP_THRUST * Math.sin(this.angle); | |
| } | |
| // Apply friction | |
| this.vel.x *= FRICTION; | |
| this.vel.y *= FRICTION; | |
| // Move ship | |
| this.x += this.vel.x; | |
| this.y += this.vel.y; | |
| // Handle screen wrapping | |
| this.handleScreenWrap(); | |
| this.draw(); | |
| } | |
| shoot() { | |
| if (this.canShoot && bullets.length < BULLET_MAX) { | |
| const bullet = new Bullet( | |
| this.x + this.radius * Math.cos(this.angle), | |
| this.y + this.radius * Math.sin(this.angle), | |
| this.angle | |
| ); | |
| bullets.push(bullet); | |
| this.canShoot = false; | |
| setTimeout(() => this.canShoot = true, 250); // Cooldown | |
| } | |
| } | |
| handleScreenWrap() { | |
| if (this.x < 0 - this.radius) this.x = canvas.width + this.radius; | |
| if (this.x > canvas.width + this.radius) this.x = 0 - this.radius; | |
| if (this.y < 0 - this.radius) this.y = canvas.height + this.radius; | |
| if (this.y > canvas.height + this.radius) this.y = 0 - this.radius; | |
| } | |
| destroy() { | |
| if (this.isInvincible) return; | |
| lives--; | |
| if (lives > 0) { | |
| ship = new Ship(); | |
| } else { | |
| gameOver(); | |
| } | |
| } | |
| } | |
| class Bullet { | |
| constructor(x, y, angle) { | |
| this.x = x; | |
| this.y = y; | |
| this.vel = { | |
| x: BULLET_SPEED * Math.cos(angle), | |
| y: BULLET_SPEED * Math.sin(angle) | |
| }; | |
| this.radius = 2; | |
| this.lifespan = 80; // frames | |
| } | |
| draw() { | |
| ctx.fillStyle = 'white'; | |
| ctx.beginPath(); | |
| ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| update() { | |
| this.x += this.vel.x; | |
| this.y += this.vel.y; | |
| this.lifespan--; | |
| this.draw(); | |
| } | |
| } | |
| class Asteroid { | |
| constructor(x, y, radius) { | |
| this.x = x || Math.random() * canvas.width; | |
| this.y = y || Math.random() * canvas.height; | |
| this.radius = radius || ASTEROID_SIZE_LARGE; | |
| this.vel = { | |
| x: (Math.random() * ASTEROID_SPEED * 2 - ASTEROID_SPEED), | |
| y: (Math.random() * ASTEROID_SPEED * 2 - ASTEROID_SPEED) | |
| }; | |
| this.angle = 0; | |
| this.angleVel = (Math.random() - 0.5) * 0.02; | |
| // Create a jagged shape | |
| this.vertices = []; | |
| for (let i = 0; i < ASTEROID_VERTICES; i++) { | |
| this.vertices.push(Math.random() * ASTEROID_JAG * 2 + 1 - ASTEROID_JAG); | |
| } | |
| } | |
| draw() { | |
| ctx.strokeStyle = 'white'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| let vertAngle = ((Math.PI * 2) / ASTEROID_VERTICES); | |
| ctx.moveTo( | |
| this.x + this.radius * this.vertices[0] * Math.cos(this.angle), | |
| this.y + this.radius * this.vertices[0] * Math.sin(this.angle) | |
| ); | |
| for (let i = 1; i < ASTEROID_VERTICES; i++) { | |
| ctx.lineTo( | |
| this.x + this.radius * this.vertices[i] * Math.cos(this.angle + i * vertAngle), | |
| this.y + this.radius * this.vertices[i] * Math.sin(this.angle + i * vertAngle) | |
| ); | |
| } | |
| ctx.closePath(); | |
| ctx.stroke(); | |
| } | |
| update() { | |
| this.x += this.vel.x; | |
| this.y += this.vel.y; | |
| this.angle += this.angleVel; | |
| // Handle screen wrapping | |
| if (this.x < 0 - this.radius) this.x = canvas.width + this.radius; | |
| if (this.x > canvas.width + this.radius) this.x = 0 - this.radius; | |
| if (this.y < 0 - this.radius) this.y = canvas.height + this.radius; | |
| if (this.y > canvas.height + this.radius) this.y = 0 - this.radius; | |
| this.draw(); | |
| } | |
| breakup() { | |
| if (this.radius === ASTEROID_SIZE_LARGE) { | |
| asteroids.push(new Asteroid(this.x, this.y, ASTEROID_SIZE_MEDIUM)); | |
| asteroids.push(new Asteroid(this.x, this.y, ASTEROID_SIZE_MEDIUM)); | |
| updateScore(20); | |
| } else if (this.radius === ASTEROID_SIZE_MEDIUM) { | |
| asteroids.push(new Asteroid(this.x, this.y, ASTEROID_SIZE_SMALL)); | |
| asteroids.push(new Asteroid(this.x, this.y, ASTEROID_SIZE_SMALL)); | |
| updateScore(50); | |
| } else { | |
| updateScore(100); | |
| } | |
| } | |
| } | |
| class Saucer { | |
| constructor() { | |
| this.x = Math.random() > 0.5 ? 0 - SAUCER_SIZE : canvas.width + SAUCER_SIZE; | |
| this.y = Math.random() * canvas.height; | |
| this.radius = SAUCER_SIZE; | |
| this.vel = { | |
| x: this.x < 0 ? SAUCER_SPEED : -SAUCER_SPEED, | |
| y: 0 | |
| }; | |
| this.bullets = []; | |
| } | |
| draw() { | |
| ctx.strokeStyle = 'white'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(this.x - this.radius, this.y); | |
| ctx.lineTo(this.x + this.radius, this.y); | |
| ctx.moveTo(this.x - this.radius / 2, this.y - this.radius / 2); | |
| ctx.lineTo(this.x + this.radius / 2, this.y - this.radius / 2); | |
| ctx.moveTo(this.x - this.radius, this.y); | |
| ctx.quadraticCurveTo(this.x, this.y - this.radius, this.x + this.radius, this.y); | |
| ctx.closePath(); | |
| ctx.stroke(); | |
| } | |
| update() { | |
| this.x += this.vel.x; | |
| this.y += this.vel.y; | |
| // Saucer shoots at player | |
| if (Math.random() < SAUCER_FIRE_RATE && ship) { | |
| const angleToShip = Math.atan2(ship.y - this.y, ship.x - this.x); | |
| const bullet = new Bullet(this.x, this.y, angleToShip); | |
| this.bullets.push(bullet); | |
| } | |
| // Update saucer bullets | |
| for (let i = this.bullets.length - 1; i >= 0; i--) { | |
| this.bullets[i].update(); | |
| if (this.bullets[i].lifespan <= 0) { | |
| this.bullets.splice(i, 1); | |
| } | |
| } | |
| this.draw(); | |
| } | |
| } | |
| // --- GAME LOGIC --- | |
| function init() { | |
| resizeCanvas(); | |
| highScoreEl.textContent = `HIGH: ${highScore}`; | |
| showMessage("ASTEROIDS", "PRESS ENTER TO START"); | |
| } | |
| function startGame() { | |
| isPlaying = true; | |
| score = 0; | |
| lives = 3; | |
| updateScore(0); | |
| messageOverlay.style.display = 'none'; | |
| ship = new Ship(); | |
| // Create initial asteroids | |
| asteroids = []; | |
| for (let i = 0; i < ASTEROID_NUM; i++) { | |
| asteroids.push(new Asteroid()); | |
| } | |
| // Start saucer timer | |
| clearTimeout(saucerTimer); | |
| saucerTimer = setTimeout(spawnSaucer, SAUCER_SPAWN_TIME); | |
| gameLoop(); | |
| } | |
| function gameOver() { | |
| isPlaying = false; | |
| ship = null; | |
| if (score > highScore) { | |
| highScore = score; | |
| localStorage.setItem('asteroidsHighScore', highScore); | |
| highScoreEl.textContent = `HIGH: ${highScore}`; | |
| } | |
| clearTimeout(saucerTimer); | |
| saucer = null; | |
| showMessage("GAME OVER", "PRESS ENTER TO RESTART"); | |
| } | |
| function showMessage(title, subtitle) { | |
| messageTitle.textContent = title; | |
| messageSubtitle.textContent = subtitle; | |
| messageOverlay.style.display = 'block'; | |
| } | |
| function spawnSaucer() { | |
| if (isPlaying) { | |
| saucer = new Saucer(); | |
| clearTimeout(saucerTimer); | |
| saucerTimer = setTimeout(spawnSaucer, SAUCER_SPAWN_TIME); | |
| } | |
| } | |
| function updateScore(points) { | |
| score += points; | |
| scoreEl.textContent = `SCORE: ${score}`; | |
| } | |
| function checkCollisions() { | |
| // Ship with asteroids | |
| if (ship) { | |
| for (let i = asteroids.length - 1; i >= 0; i--) { | |
| const ast = asteroids[i]; | |
| if (isColliding(ship, ast)) { | |
| ship.destroy(); | |
| ast.breakup(); | |
| asteroids.splice(i, 1); | |
| break; // Prevent multiple collisions in one frame | |
| } | |
| } | |
| } | |
| // Bullets with asteroids | |
| for (let i = bullets.length - 1; i >= 0; i--) { | |
| const bullet = bullets[i]; | |
| for (let j = asteroids.length - 1; j >= 0; j--) { | |
| const ast = asteroids[j]; | |
| if (isColliding(bullet, ast)) { | |
| ast.breakup(); | |
| asteroids.splice(j, 1); | |
| bullets.splice(i, 1); | |
| break; // Bullet can only hit one asteroid | |
| } | |
| } | |
| } | |
| // Saucer logic | |
| if (saucer) { | |
| // Ship bullets with saucer | |
| for (let i = bullets.length - 1; i >= 0; i--) { | |
| if (isColliding(bullets[i], saucer)) { | |
| updateScore(200); | |
| saucer = null; | |
| bullets.splice(i, 1); | |
| break; | |
| } | |
| } | |
| if (saucer) { | |
| // Ship with saucer | |
| if (ship && isColliding(ship, saucer)) { | |
| ship.destroy(); | |
| saucer = null; | |
| } | |
| // Saucer bullets with ship | |
| else if (ship) { | |
| for (let i = saucer.bullets.length - 1; i >= 0; i--) { | |
| if(isColliding(ship, saucer.bullets[i])) { | |
| ship.destroy(); | |
| saucer.bullets.splice(i, 1); | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| function isColliding(obj1, obj2) { | |
| const dist = Math.sqrt(Math.pow(obj1.x - obj2.x, 2) + Math.pow(obj1.y - obj2.y, 2)); | |
| return dist < obj1.radius + obj2.radius; | |
| } | |
| function drawLives() { | |
| let startX = canvas.width - 60; | |
| for (let i = 0; i < lives; i++) { | |
| ctx.save(); | |
| ctx.translate(startX - i * (SHIP_SIZE + 5), 30); | |
| ctx.rotate(degToRad(-90)); | |
| ctx.strokeStyle = 'white'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, -SHIP_SIZE / 2); | |
| ctx.lineTo(SHIP_SIZE / 2, SHIP_SIZE / 2); | |
| ctx.lineTo(-SHIP_SIZE / 2, SHIP_SIZE / 2); | |
| ctx.closePath(); | |
| ctx.stroke(); | |
| ctx.restore(); | |
| } | |
| } | |
| function gameLoop() { | |
| if (!isPlaying) return; | |
| // Clear canvas | |
| ctx.fillStyle = 'black'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| // Update and draw ship | |
| if (ship) { | |
| ship.update(); | |
| } | |
| // Update and draw asteroids | |
| for (let i = 0; i < asteroids.length; i++) { | |
| asteroids[i].update(); | |
| } | |
| // Update and draw bullets | |
| for (let i = bullets.length - 1; i >= 0; i--) { | |
| bullets[i].update(); | |
| if (bullets[i].lifespan <= 0) { | |
| bullets.splice(i, 1); | |
| } | |
| } | |
| // Update and draw saucer | |
| if (saucer) { | |
| saucer.update(); | |
| // Remove saucer if it goes off-screen | |
| if (saucer.x < 0 - saucer.radius || saucer.x > canvas.width + saucer.radius) { | |
| saucer = null; | |
| } | |
| } | |
| // Check for collisions | |
| checkCollisions(); | |
| // Draw lives | |
| drawLives(); | |
| // Check for level clear | |
| if (asteroids.length === 0) { | |
| lives++; | |
| startGame(); | |
| } | |
| requestAnimationFrame(gameLoop); | |
| } | |
| // --- EVENT LISTENERS --- | |
| window.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && !isPlaying) { | |
| startGame(); | |
| } | |
| keys[e.key] = true; | |
| if (e.key === ' ' && ship) { // Spacebar for shooting | |
| e.preventDefault(); | |
| ship.shoot(); | |
| } | |
| }); | |
| window.addEventListener('keyup', (e) => { | |
| keys[e.key] = false; | |
| }); | |
| window.addEventListener('resize', resizeCanvas); | |
| // --- INITIALIZE --- | |
| init(); | |
| </script> | |
| </body> | |
| </html> | |