diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9cda8f7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,25 @@ +# AGENTS.md + +Clon de Asteroids en canvas HTML5 puro. Sin frameworks, sin bundler, sin dependencias. + +## Correr / verificar + +- No hay build, test ni lint. Abre `index.html` en el navegador, o corre `npx serve .` y visita `http://localhost:3000`. +- Para probar un cambio: recarga la página y juega; no hay forma automatizada de verificar. + +## Layout del código + +- `game.js` — TODO el juego, un solo archivo (clases `Bullet`, `Asteroid`, `Ship`, `Particle` + funciones de init/hud/loop). +- `index.html` — canvas fijo de `800x600` (`W`/`H` en `game.js`). El espacio es toroidal: usa `wrap()` en `game.js:27` para envolvimiento, no dejes que entidades salgan del lienzo. +- `favicon.svg` — icono de la página. + +## Convenciones + +- Identificadores en inglés; comentarios en español y solo cuando aportan valor. +- Loop de juego con `requestAnimationFrame`, `dt` limitado a `0.05s` (`game.js:415`). +- El input se propaga a `preventDefault` para `Space`/flechas; no romper ese manejo o la página scrollea al jugar. + +## Gotchas + +- `game.js` no está enmascarado en un IIFE: define variables globales (`canvas`, `ctx`, `W`, `H`). No colisiones de nombres de nivel global. +- La dificultad escala por nivel (`nextLevel`, `game.js:268`); los asteroides se parten como fragmentos más pequeños al destruirse. \ No newline at end of file diff --git a/game.js b/game.js index 332a4a6..167d6b5 100644 --- a/game.js +++ b/game.js @@ -132,6 +132,7 @@ class Ship { this.thrusting = false; this.invincible = 3; this.shootCooldown = 0; + this.boostTimer = 0; this.dead = false; } @@ -139,18 +140,20 @@ class Ship { if (this.dead) return; if (this.invincible > 0) this.invincible -= dt; if (this.shootCooldown > 0) this.shootCooldown -= dt; + if (this.boostTimer > 0) this.boostTimer -= dt; const ROT = 3.5; // rad/s const THRUST = 260; // px/s² const DRAG = 0.987; + const thrust = this.boostTimer > 0 ? THRUST * SPEED_MULT : THRUST; if (keys['ArrowLeft']) this.angle -= ROT * dt; if (keys['ArrowRight']) this.angle += ROT * dt; this.thrusting = !!keys['ArrowUp']; if (this.thrusting) { - this.vx += Math.cos(this.angle) * THRUST * dt; - this.vy += Math.sin(this.angle) * THRUST * dt; + this.vx += Math.cos(this.angle) * thrust * dt; + this.vy += Math.sin(this.angle) * thrust * dt; } this.vx *= DRAG; @@ -235,8 +238,55 @@ class Particle { } } +// ── Power-Up (velocidad) ────────────────────────────────────────────────────── +const POWERUP_CHANCE = 0.12; // probabilidad de drop al destruir un asteroide +const POWERUP_TTL = 8; // segundos hasta que expira si no se recoge +const SPEED_MULT = 2; // multiplicador de velocidad +const SPEED_DURATION = 5; // duración del boost en segundos + +class PowerUp { + constructor(x, y) { + this.x = x; + this.y = y; + this.radius = 14; + this.ttl = POWERUP_TTL; + this.kind = 'speed'; + this.dead = false; + } + + update(dt) { + this.ttl -= dt; + if (this.ttl <= 0) this.dead = true; + } + + draw() { + // Pulso con parpadeo cuando está por expirar + const pulse = 1 + Math.sin(performance.now() / 120) * 0.08; + const alpha = this.ttl > 1.5 ? 1 : Math.floor(this.ttl * 4) % 2; + ctx.save(); + ctx.translate(this.x, this.y); + ctx.scale(pulse, pulse); + ctx.strokeStyle = `rgba(80, 220, 255, ${alpha})`; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(0, 0, this.radius, 0, Math.PI * 2); + ctx.stroke(); + + // Doble chevron hacia la derecha (velocidad) + ctx.beginPath(); + ctx.moveTo(-5, -6); + ctx.lineTo( 1, 0); + ctx.lineTo(-5, 6); + ctx.moveTo( 2, -6); + ctx.lineTo( 8, 0); + ctx.lineTo( 2, 6); + ctx.stroke(); + ctx.restore(); + } +} + // ── Estado del juego ────────────────────────────────────────────────────────── -let ship, bullets, asteroids, particles; +let ship, bullets, asteroids, particles, powerups; let score, lives, level; let state; // 'playing' | 'dead' | 'gameover' let deadTimer; @@ -258,6 +308,7 @@ function initGame() { bullets = []; asteroids = []; particles = []; + powerups = []; score = 0; lives = 3; level = 1; @@ -269,6 +320,7 @@ function nextLevel() { level++; bullets = []; particles = []; + powerups = []; ship.reset(); spawnAsteroids(3 + level); } @@ -316,9 +368,11 @@ function update(dt) { bullets.forEach(b => b.update(dt)); asteroids.forEach(a => a.update(dt)); particles.forEach(p => p.update(dt)); + powerups.forEach(p => p.update(dt)); bullets = bullets.filter(b => !b.dead); particles = particles.filter(p => !p.dead); + powerups = powerups.filter(p => !p.dead); // Bala vs asteroide const newAsteroids = []; @@ -329,6 +383,9 @@ function update(dt) { a.dead = true; score += POINTS[a.size]; explode(a.x, a.y, a.size * 5); + // Drop del power-up de velocidad + if (Math.random() < POWERUP_CHANCE) + powerups.push(new PowerUp(a.x, a.y)); newAsteroids.push(...a.split()); } } @@ -346,6 +403,16 @@ function update(dt) { } } + // Nave vs power-up + for (const p of powerups) { + if (!p.dead && dist(ship, p) < ship.radius + p.radius) { + p.dead = true; + ship.boostTimer = SPEED_DURATION; + explode(p.x, p.y, 6); + } + } + powerups = powerups.filter(p => !p.dead); + // Nivel completado if (asteroids.length === 0) nextLevel(); } @@ -378,6 +445,12 @@ function drawHUD() { ctx.textAlign = 'center'; ctx.fillText(`NIVEL ${level}`, W / 2, 26); + // Indicador de boost de velocidad activo + if (ship.boostTimer > 0) { + ctx.fillStyle = 'rgba(80, 220, 255, 1)'; + ctx.fillText(`VELOCIDAD ${ship.boostTimer.toFixed(1)}s`, W / 2, 48); + } + for (let i = 0; i < lives; i++) drawLifeIcon(W - 16 - i * 22, 18); @@ -400,6 +473,7 @@ function draw() { particles.forEach(p => p.draw()); asteroids.forEach(a => a.draw()); bullets.forEach(b => b.draw()); + powerups.forEach(p => p.draw()); ship.draw(); drawHUD();