Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
80 changes: 77 additions & 3 deletions game.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,25 +132,28 @@ class Ship {
this.thrusting = false;
this.invincible = 3;
this.shootCooldown = 0;
this.boostTimer = 0;
this.dead = false;
}

update(dt) {
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;
Expand Down Expand Up @@ -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;
Expand All @@ -258,6 +308,7 @@ function initGame() {
bullets = [];
asteroids = [];
particles = [];
powerups = [];
score = 0;
lives = 3;
level = 1;
Expand All @@ -269,6 +320,7 @@ function nextLevel() {
level++;
bullets = [];
particles = [];
powerups = [];
ship.reset();
spawnAsteroids(3 + level);
}
Expand Down Expand Up @@ -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 = [];
Expand All @@ -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());
}
}
Expand All @@ -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();
}
Expand Down Expand Up @@ -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);

Expand All @@ -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();
Expand Down