From 4e4ba05d5e2967009689a7c8092ca5c6ec1ae0af Mon Sep 17 00:00:00 2001 From: AnaRoGon Date: Tue, 18 Aug 2026 08:50:42 +0200 Subject: [PATCH 01/14] Initial commit with project setup and basic structure established --- AGENTS.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3180985 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,50 @@ +# AGENTS.md + +## Proyecto + +Clone de Asteroids en HTML5 Canvas puro. Sin bundler, sin dependencias externas. + +**Archivos:** +- `index.html` — shell HTML, solo carga el canvas y `game.js` +- `game.js` — toda la lógica del juego (423 líneas) +- `favicon.svg` — ícono + +## Ejecutar + +```bash +npx serve . +``` +Visitar `localhost:3000`. También funciona abriendo `index.html` directamente en el navegador. + +## Arquitectura de `game.js` + +El archivo está organizado en secciones marcadas con comentarios `// ── Sección ──`. Todas las clases y el estado global viven en el mismo archivo, sin módulos. + +**Secciones (en orden):** + +1. **Input** (L8-24) — `keys` y `justPressed` para teclado. `pressed(code)` retorna true solo en el frame en que se presionó. +2. **Utils** (L27-31) — `wrap()`, `dist()`, `rand()`, `randInt()`. El wrap es toroidal, se usa en todas las entidades. +3. **Bullet** (L33-58) — `update(dt)` y `draw()`. Tiene `ttl` y `dead`. +4. **Asteroid** (L61-119) — Tamaños 1-3 con arreglos `RADII`, `SPEEDS`, `POINTS`. `split()` retorna 2 asteroides más pequeños. Vértices irregulares generados al azar. +5. **Ship** (L122-204) — `reset()` para reaparecer. Invencibilidad temporal con parpadeo. `tryShoot()` con cooldown. +6. **Particle** (L207-236) — Explosiones, se auto-destruyen con `ttl`. +7. **Estado del juego** (L238-290) — Variables globales: `ship`, `bullets`, `asteroids`, `particles`, `score`, `lives`, `level`, `state`. +8. **Update** (L293-351) — Loop de actualización con máquina de estados (`playing`, `dead`, `gameover`). Colisiones bala-asteroide y nave-asteroide. +9. **Draw** (L353-409) — Renderizado de HUD, overlays y entidades. +10. **Loop principal** (L412-423) — `requestAnimationFrame` con `dt` limitado a 50ms. + +**Máquina de estados:** +- `playing` → juego activo +- `dead` → esperando `deadTimer` (2s) antes de reaparecer +- `gameover` → esperando `Space` para reiniciar con `initGame()` + +## Convenciones + +- Todo el código en `game.js`. No hay otros archivos JS ni módulos. +- Canvas rendering directo (`ctx.fillRect`, `ctx.beginPath`, etc.). No hay abstracción de rendering. +- Entidades tienen `update(dt)` y `draw()`. El `dt` viene de `requestAnimationFrame`. +- Entidades eliminadas se marcan `dead = true` y se filtran al final del frame. +- El espacio es toroidal: `wrap(valor, max)` asegura que nada sale del canvas. +- `state` controla el flujo del juego — revisar antes de agregar lógica nueva. +- Valores mágicos (velocidades, tamaños, puntos) están como constantes al inicio de cada clase, no hardcodeados en funciones. +- El HUD y overlays están en funciones separadas (`drawHUD`, `drawOverlay`). From 540e0681f6dbda4c8f77df6008b379acd13abf90 Mon Sep 17 00:00:00 2001 From: AnaRoGon Date: Wed, 19 Aug 2026 10:14:59 +0200 Subject: [PATCH 02/14] Implemented power up functionality and update AGENTS file --- AGENTS.md | 23 ++++++++------- game.js | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 98 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3180985..1af712a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Clone de Asteroids en HTML5 Canvas puro. Sin bundler, sin dependencias externas. **Archivos:** - `index.html` — shell HTML, solo carga el canvas y `game.js` -- `game.js` — toda la lógica del juego (423 líneas) +- `game.js` — toda la lógica del juego - `favicon.svg` — ícono ## Ejecutar @@ -22,16 +22,17 @@ El archivo está organizado en secciones marcadas con comentarios `// ── Sec **Secciones (en orden):** -1. **Input** (L8-24) — `keys` y `justPressed` para teclado. `pressed(code)` retorna true solo en el frame en que se presionó. -2. **Utils** (L27-31) — `wrap()`, `dist()`, `rand()`, `randInt()`. El wrap es toroidal, se usa en todas las entidades. -3. **Bullet** (L33-58) — `update(dt)` y `draw()`. Tiene `ttl` y `dead`. -4. **Asteroid** (L61-119) — Tamaños 1-3 con arreglos `RADII`, `SPEEDS`, `POINTS`. `split()` retorna 2 asteroides más pequeños. Vértices irregulares generados al azar. -5. **Ship** (L122-204) — `reset()` para reaparecer. Invencibilidad temporal con parpadeo. `tryShoot()` con cooldown. -6. **Particle** (L207-236) — Explosiones, se auto-destruyen con `ttl`. -7. **Estado del juego** (L238-290) — Variables globales: `ship`, `bullets`, `asteroids`, `particles`, `score`, `lives`, `level`, `state`. -8. **Update** (L293-351) — Loop de actualización con máquina de estados (`playing`, `dead`, `gameover`). Colisiones bala-asteroide y nave-asteroide. -9. **Draw** (L353-409) — Renderizado de HUD, overlays y entidades. -10. **Loop principal** (L412-423) — `requestAnimationFrame` con `dt` limitado a 50ms. +1. **Input** — `keys` y `justPressed` para teclado. `pressed(code)` retorna true solo en el frame en que se presionó. +2. **Utils** — `wrap()`, `dist()`, `rand()`, `randInt()`. El wrap es toroidal, se usa en todas las entidades. +3. **Bullet** — `update(dt)` y `draw()`. Tiene `ttl` y `dead`. +4. **Asteroid** — Tamaños 1-3 con arreglos `RADII`, `SPEEDS`, `POINTS`. `split()` retorna 2 asteroides más pequeños. Vértices irregulares generados al azar. +5. **Ship** — `reset()` para reaparecer. Invencibilidad temporal con parpadeo. `tryShoot()` con cooldown. `applySpeed()` activa boost x2 por 5s. +6. **Particle** — Explosiones, se auto-destruyen con `ttl`. +7. **PowerUp** — Rayo amarillo que otorga velocidad x2 por 5s. Spawning periódico (cada 10-18s). TTL de 8s. Si se recoge otro estando activo, se reinicia el timer. +8. **Estado del juego** — Variables globales: `ship`, `bullets`, `asteroids`, `particles`, `powerUps`, `score`, `lives`, `level`, `state`, `powerUpTimer`. +9. **Update** — Loop de actualización con máquina de estados (`playing`, `dead`, `gameover`). Colisiones bala-asteroide, nave-asteroide y nave-power-up. +10. **Draw** — Renderizado de HUD, overlays y entidades. +11. **Loop principal** — `requestAnimationFrame` con `dt` limitado a 50ms. **Máquina de estados:** - `playing` → juego activo diff --git a/game.js b/game.js index 332a4a6..25fe5c7 100644 --- a/game.js +++ b/game.js @@ -132,16 +132,23 @@ class Ship { this.thrusting = false; this.invincible = 3; this.shootCooldown = 0; + this.speedTimer = 0; this.dead = false; } + applySpeed() { + this.speedTimer = 5; + } + update(dt) { if (this.dead) return; if (this.invincible > 0) this.invincible -= dt; if (this.shootCooldown > 0) this.shootCooldown -= dt; + if (this.speedTimer > 0) this.speedTimer -= dt; const ROT = 3.5; // rad/s - const THRUST = 260; // px/s² + let THRUST = 260; // px/s² + if (this.speedTimer > 0) THRUST *= 2; const DRAG = 0.987; if (keys['ArrowLeft']) this.angle -= ROT * dt; @@ -235,11 +242,58 @@ class Particle { } } +// ── Power-Up ───────────────────────────────────────────────────────────────── +class PowerUp { + constructor(x, y) { + this.x = x; + this.y = y; + this.radius = 10; + this.ttl = 8; + this.dead = false; + + const angle = rand(0, Math.PI * 2); + const speed = rand(20, 50); + this.vx = Math.cos(angle) * speed; + this.vy = Math.sin(angle) * speed; + } + + update(dt) { + this.x = wrap(this.x + this.vx * dt, W); + this.y = wrap(this.y + this.vy * dt, H); + this.ttl -= dt; + if (this.ttl <= 0) this.dead = true; + } + + draw() { + const alpha = Math.min(1, this.ttl / 2); + ctx.save(); + ctx.translate(this.x, this.y); + + // Rayo amarillo + ctx.strokeStyle = `rgba(255,220,0,${alpha.toFixed(2)})`; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(-3, -7); + ctx.lineTo(1, -1); + ctx.lineTo(-1, -1); + ctx.lineTo(3, 7); + ctx.lineTo(-1, 1); + ctx.lineTo(1, 1); + ctx.closePath(); + ctx.stroke(); + + ctx.fillStyle = `rgba(255,220,0,${(alpha * 0.3).toFixed(2)})`; + ctx.fill(); + 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; +let powerUpTimer; function spawnAsteroids(count) { const SAFE_DIST = 130; @@ -258,10 +312,12 @@ function initGame() { bullets = []; asteroids = []; particles = []; + powerUps = []; score = 0; lives = 3; level = 1; state = 'playing'; + powerUpTimer = rand(8, 15); spawnAsteroids(4); } @@ -269,7 +325,9 @@ function nextLevel() { level++; bullets = []; particles = []; + powerUps = []; ship.reset(); + powerUpTimer = rand(8, 15); spawnAsteroids(3 + level); } @@ -317,8 +375,17 @@ function update(dt) { asteroids.forEach(a => a.update(dt)); particles.forEach(p => p.update(dt)); + // Spawning periódico de power-ups + powerUpTimer -= dt; + if (powerUpTimer <= 0) { + powerUps.push(new PowerUp(rand(0, W), rand(0, H))); + powerUpTimer = rand(10, 18); + } + 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 = []; @@ -346,6 +413,16 @@ function update(dt) { } } + // Nave vs power-up + for (const p of powerUps) { + if (!p.dead && dist(ship, p) < ship.radius + p.radius) { + ship.applySpeed(); + explode(p.x, p.y, 6); + p.dead = true; + } + } + powerUps = powerUps.filter(p => !p.dead); + // Nivel completado if (asteroids.length === 0) nextLevel(); } @@ -375,6 +452,12 @@ function drawHUD() { ctx.textAlign = 'left'; ctx.fillText(`SCORE ${score}`, 14, 26); + if (ship.speedTimer > 0) { + ctx.fillStyle = '#ffdc00'; + ctx.fillText(`SPEED ${ship.speedTimer.toFixed(1)}s`, 14, 46); + ctx.fillStyle = '#fff'; + } + ctx.textAlign = 'center'; ctx.fillText(`NIVEL ${level}`, W / 2, 26); @@ -399,6 +482,7 @@ function draw() { particles.forEach(p => p.draw()); asteroids.forEach(a => a.draw()); + powerUps.forEach(p => p.draw()); bullets.forEach(b => b.draw()); ship.draw(); From 3b53d48de6b6baccd25714e965dfe9ec41da0543 Mon Sep 17 00:00:00 2001 From: AnaRoGon Date: Wed, 19 Aug 2026 10:36:14 +0200 Subject: [PATCH 03/14] Add pink start to game and instructions file --- game.js | 148 +++++++++++++++++++++++++++++++++++++++++- tasks-instructions.md | 3 + 2 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 tasks-instructions.md diff --git a/game.js b/game.js index 25fe5c7..ca3570b 100644 --- a/game.js +++ b/game.js @@ -118,6 +118,83 @@ class Asteroid { } } +// ── PinkStar (asteroide especial) ───────────────────────────────────────────── +class PinkStar extends Asteroid { + constructor(x, y) { + super(x, y, 2); + this.radius = 20; + this.ttl = rand(4, 6); + this.life = this.ttl; + this.dead = false; + this.isPinkStar = true; + + const angle = rand(0, Math.PI * 2); + const speed = 110 + rand(-20, 20); + this.vx = Math.cos(angle) * speed; + this.vy = Math.sin(angle) * speed; + this.rotSpeed = rand(-0.8, 0.8); + this.rot = rand(0, Math.PI * 2); + + this.numSpikes = 8; + this.spikeInner = this.radius * 0.55; + this.spikeOuter = this.radius; + } + + update(dt) { + this.x = wrap(this.x + this.vx * dt, W); + this.y = wrap(this.y + this.vy * dt, H); + this.rot += this.rotSpeed * dt; + this.ttl -= dt; + if (this.ttl <= 0) this.dead = true; + } + + split() { return []; } + + draw() { + const alpha = Math.min(1, this.ttl / 2); + ctx.save(); + ctx.translate(this.x, this.y); + ctx.rotate(this.rot); + + // Estela naranja + ctx.strokeStyle = `rgba(255,120,0,${(alpha * 0.5).toFixed(2)})`; + ctx.lineWidth = 1.5; + for (let i = 0; i < 5; i++) { + const a = rand(0, Math.PI * 2); + const len = rand(15, 30); + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(Math.cos(a) * len, Math.sin(a) * len); + ctx.stroke(); + } + + // Sol magenta con puntas suavizadas + ctx.fillStyle = `rgba(255,0,255,${(alpha * 0.8).toFixed(2)})`; + ctx.strokeStyle = `rgba(255,0,255,${alpha.toFixed(2)})`; + ctx.lineWidth = 2; + ctx.beginPath(); + const n = this.numSpikes; + for (let i = 0; i < n; i++) { + const aOuter = (i / n) * Math.PI * 2; + const aInner = ((i + 0.5) / n) * Math.PI * 2; + const ox = Math.cos(aOuter) * this.spikeOuter; + const oy = Math.sin(aOuter) * this.spikeOuter; + const ix = Math.cos(aInner) * this.spikeInner; + const iy = Math.sin(aInner) * this.spikeInner; + if (i === 0) ctx.moveTo(ox, oy); + else ctx.lineTo(ox, oy); + ctx.quadraticCurveTo(ix * 1.15, iy * 1.15, + Math.cos(((i + 1) / n) * Math.PI * 2) * this.spikeOuter, + Math.sin(((i + 1) / n) * Math.PI * 2) * this.spikeOuter); + } + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + ctx.restore(); + } +} + // ── Ship ────────────────────────────────────────────────────────────────────── class Ship { constructor() { this.reset(); } @@ -242,6 +319,48 @@ class Particle { } } +// ── FireworkParticle (explosión fuego artificial) ──────────────────────────── +class FireworkParticle { + constructor(x, y) { + this.x = x; + this.y = y; + const angle = rand(0, Math.PI * 2); + const speed = rand(40, 180); + this.vx = Math.cos(angle) * speed; + this.vy = Math.sin(angle) * speed; + this.life = rand(0.6, 1.4); + this.ttl = this.life; + this.radius = rand(1.5, 3.5); + this.dead = false; + + const colors = [ + [255, 0, 255], + [255, 100, 200], + [255, 150, 50], + [255, 255, 255], + ]; + this.color = colors[randInt(0, colors.length - 1)]; + } + + update(dt) { + this.x += this.vx * dt; + this.y += this.vy * dt; + this.vx *= 0.97; + this.vy *= 0.97; + this.ttl -= dt; + if (this.ttl <= 0) this.dead = true; + } + + draw() { + const alpha = this.ttl / this.life; + const [r, g, b] = this.color; + ctx.fillStyle = `rgba(${r},${g},${b},${alpha.toFixed(2)})`; + ctx.beginPath(); + ctx.arc(this.x, this.y, this.radius * alpha, 0, Math.PI * 2); + ctx.fill(); + } +} + // ── Power-Up ───────────────────────────────────────────────────────────────── class PowerUp { constructor(x, y) { @@ -294,6 +413,8 @@ let score, lives, level; let state; // 'playing' | 'dead' | 'gameover' let deadTimer; let powerUpTimer; +let pinkStarTimer; +let pinkStarsSpawned; function spawnAsteroids(count) { const SAFE_DIST = 130; @@ -318,6 +439,8 @@ function initGame() { level = 1; state = 'playing'; powerUpTimer = rand(8, 15); + pinkStarTimer = rand(3, 7); + pinkStarsSpawned = 0; spawnAsteroids(4); } @@ -328,6 +451,8 @@ function nextLevel() { powerUps = []; ship.reset(); powerUpTimer = rand(8, 15); + pinkStarTimer = rand(3, 7); + pinkStarsSpawned = 0; spawnAsteroids(3 + level); } @@ -335,6 +460,10 @@ function explode(x, y, count = 8) { for (let i = 0; i < count; i++) particles.push(new Particle(x, y)); } +function fireworkExplode(x, y, count = 25) { + for (let i = 0; i < count; i++) particles.push(new FireworkParticle(x, y)); +} + function killShip() { explode(ship.x, ship.y, 14); ship.dead = true; @@ -383,6 +512,20 @@ function update(dt) { } powerUps.forEach(p => p.update(dt)); + // Spawning periódico de pinkStars + pinkStarTimer -= dt; + if (pinkStarTimer <= 0 && pinkStarsSpawned < 2) { + const side = randInt(0, 3); + let x, y; + if (side === 0) { x = 0; y = rand(0, H); } + else if (side === 1) { x = W; y = rand(0, H); } + else if (side === 2) { x = rand(0, W); y = 0; } + else { x = rand(0, W); y = H; } + asteroids.push(new PinkStar(x, y)); + pinkStarsSpawned++; + pinkStarTimer = rand(12, 20); + } + bullets = bullets.filter(b => !b.dead); particles = particles.filter(p => !p.dead); powerUps = powerUps.filter(p => !p.dead); @@ -394,8 +537,9 @@ function update(dt) { if (!a.dead && !b.dead && dist(b, a) < a.radius) { b.dead = true; a.dead = true; - score += POINTS[a.size]; - explode(a.x, a.y, a.size * 5); + score += a.isPinkStar ? 200 : POINTS[a.size]; + if (a.isPinkStar) fireworkExplode(a.x, a.y, 25); + else explode(a.x, a.y, a.size * 5); newAsteroids.push(...a.split()); } } diff --git a/tasks-instructions.md b/tasks-instructions.md new file mode 100644 index 0000000..36f46d0 --- /dev/null +++ b/tasks-instructions.md @@ -0,0 +1,3 @@ +# Tarea + +Implementar un asteroide especial "estrella fugaz", que se mueve más rápido de lo normal pero desaparece con el tiempo. From a78e6c270417392fe78d1f3a140422c9f05faa3b Mon Sep 17 00:00:00 2001 From: AnaRoGon Date: Wed, 19 Aug 2026 11:21:49 +0200 Subject: [PATCH 04/14] feat: triple shot power-up - 3 red bullets for 5s, faster spawn rate, bullet-shaped pickup --- game.js | 96 +++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 77 insertions(+), 19 deletions(-) diff --git a/game.js b/game.js index ca3570b..d5f220f 100644 --- a/game.js +++ b/game.js @@ -31,14 +31,17 @@ const randInt = (min, max) => Math.floor(rand(min, max + 1)); // ── Bullet ──────────────────────────────────────────────────────────────────── class Bullet { - constructor(x, y, angle) { + constructor(x, y, angle, opts = {}) { this.x = x; this.y = y; const SPEED = 520; this.vx = Math.cos(angle) * SPEED; this.vy = Math.sin(angle) * SPEED; this.ttl = 1.1; - this.radius = 2; + this.radius = opts.radius || 2; + this.color = opts.color || '#fff'; + this.length = opts.length || 0; + this.angle = angle; this.dead = false; } @@ -50,10 +53,22 @@ class Bullet { } draw() { - ctx.fillStyle = '#fff'; - ctx.beginPath(); - ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); - ctx.fill(); + ctx.strokeStyle = this.color; + ctx.fillStyle = this.color; + ctx.lineWidth = this.radius * 2; + ctx.lineCap = 'round'; + if (this.length > 0) { + const dx = Math.cos(this.angle) * this.length; + const dy = Math.sin(this.angle) * this.length; + ctx.beginPath(); + ctx.moveTo(this.x - dx, this.y - dy); + ctx.lineTo(this.x + dx, this.y + dy); + ctx.stroke(); + } else { + ctx.beginPath(); + ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); + ctx.fill(); + } } } @@ -210,6 +225,7 @@ class Ship { this.invincible = 3; this.shootCooldown = 0; this.speedTimer = 0; + this.tripleShotTimer = 0; this.dead = false; } @@ -217,11 +233,16 @@ class Ship { this.speedTimer = 5; } + applyTripleShot() { + this.tripleShotTimer = 5; + } + update(dt) { if (this.dead) return; if (this.invincible > 0) this.invincible -= dt; if (this.shootCooldown > 0) this.shootCooldown -= dt; if (this.speedTimer > 0) this.speedTimer -= dt; + if (this.tripleShotTimer > 0) this.tripleShotTimer -= dt; const ROT = 3.5; // rad/s let THRUST = 260; // px/s² @@ -249,6 +270,17 @@ class Ship { const NOSE = 21; const ox = this.x + Math.cos(this.angle) * NOSE; const oy = this.y + Math.sin(this.angle) * NOSE; + if (this.tripleShotTimer > 0) { + const SPREAD = 10; + const px = -Math.sin(this.angle) * SPREAD; + const py = Math.cos(this.angle) * SPREAD; + const opts = { color: '#ff3333', radius: 2.5, length: 8 }; + return [ + new Bullet(ox + px, oy + py, this.angle, opts), + new Bullet(ox, oy, this.angle, opts), + new Bullet(ox - px, oy - py, this.angle, opts), + ]; + } return [new Bullet(ox, oy, this.angle)]; } @@ -369,6 +401,7 @@ class PowerUp { this.radius = 10; this.ttl = 8; this.dead = false; + this.type = Math.random() < 0.65 ? 'speed' : 'tripleShot'; const angle = rand(0, Math.PI * 2); const speed = rand(20, 50); @@ -385,24 +418,41 @@ class PowerUp { draw() { const alpha = Math.min(1, this.ttl / 2); + const color = this.type === 'tripleShot' ? '255,50,50' : '255,220,0'; ctx.save(); ctx.translate(this.x, this.y); - // Rayo amarillo - ctx.strokeStyle = `rgba(255,220,0,${alpha.toFixed(2)})`; + ctx.strokeStyle = `rgba(${color},${alpha.toFixed(2)})`; ctx.lineWidth = 2; ctx.beginPath(); - ctx.moveTo(-3, -7); - ctx.lineTo(1, -1); - ctx.lineTo(-1, -1); - ctx.lineTo(3, 7); - ctx.lineTo(-1, 1); - ctx.lineTo(1, 1); - ctx.closePath(); + if (this.type === 'tripleShot') { + ctx.fillStyle = `rgba(${color},${(alpha * 0.8).toFixed(2)})`; + ctx.beginPath(); + ctx.ellipse(0, 0, 3, 6, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(-2, -6); + ctx.lineTo(0, -9); + ctx.lineTo(2, -6); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } else { + ctx.moveTo(-3, -7); + ctx.lineTo(1, -1); + ctx.lineTo(-1, -1); + ctx.lineTo(3, 7); + ctx.lineTo(-1, 1); + ctx.lineTo(1, 1); + ctx.closePath(); + } ctx.stroke(); - ctx.fillStyle = `rgba(255,220,0,${(alpha * 0.3).toFixed(2)})`; - ctx.fill(); + if (this.type !== 'tripleShot') { + ctx.fillStyle = `rgba(${color},${(alpha * 0.3).toFixed(2)})`; + ctx.fill(); + } ctx.restore(); } } @@ -508,7 +558,7 @@ function update(dt) { powerUpTimer -= dt; if (powerUpTimer <= 0) { powerUps.push(new PowerUp(rand(0, W), rand(0, H))); - powerUpTimer = rand(10, 18); + powerUpTimer = rand(7, 14); } powerUps.forEach(p => p.update(dt)); @@ -560,7 +610,8 @@ function update(dt) { // Nave vs power-up for (const p of powerUps) { if (!p.dead && dist(ship, p) < ship.radius + p.radius) { - ship.applySpeed(); + if (p.type === 'tripleShot') ship.applyTripleShot(); + else ship.applySpeed(); explode(p.x, p.y, 6); p.dead = true; } @@ -602,6 +653,13 @@ function drawHUD() { ctx.fillStyle = '#fff'; } + if (ship.tripleShotTimer > 0) { + ctx.fillStyle = '#ff3333'; + const yOff = ship.speedTimer > 0 ? 62 : 46; + ctx.fillText(`TRIPLE SHOT ${ship.tripleShotTimer.toFixed(1)}s`, 14, yOff); + ctx.fillStyle = '#fff'; + } + ctx.textAlign = 'center'; ctx.fillText(`NIVEL ${level}`, W / 2, 26); From 90b7e331dd237320ebee4416a393b226d0fdff11 Mon Sep 17 00:00:00 2001 From: AnaRoGon Date: Wed, 19 Aug 2026 11:21:51 +0200 Subject: [PATCH 05/14] feat: add shield power-up that protects ship from asteroid collisions --- AGENTS.md | 6 ++-- game.js | 105 ++++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 89 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1af712a..80d6983 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,10 +26,10 @@ El archivo está organizado en secciones marcadas con comentarios `// ── Sec 2. **Utils** — `wrap()`, `dist()`, `rand()`, `randInt()`. El wrap es toroidal, se usa en todas las entidades. 3. **Bullet** — `update(dt)` y `draw()`. Tiene `ttl` y `dead`. 4. **Asteroid** — Tamaños 1-3 con arreglos `RADII`, `SPEEDS`, `POINTS`. `split()` retorna 2 asteroides más pequeños. Vértices irregulares generados al azar. -5. **Ship** — `reset()` para reaparecer. Invencibilidad temporal con parpadeo. `tryShoot()` con cooldown. `applySpeed()` activa boost x2 por 5s. +5. **Ship** — `reset()` para reaparecer. Invencibilidad temporal con parpadeo. `tryShoot()` con cooldown. `applySpeed()` activa boost x2 por 5s. `applyShield()` activa escudo protector por 6s. 6. **Particle** — Explosiones, se auto-destruyen con `ttl`. -7. **PowerUp** — Rayo amarillo que otorga velocidad x2 por 5s. Spawning periódico (cada 10-18s). TTL de 8s. Si se recoge otro estando activo, se reinicia el timer. -8. **Estado del juego** — Variables globales: `ship`, `bullets`, `asteroids`, `particles`, `powerUps`, `score`, `lives`, `level`, `state`, `powerUpTimer`. +7. **PowerUp** — Dos tipos: `'speed'` (rayo amarillo, velocidad x2 por 5s) y `'shield'` (hexágono azul, escudo por 6s). Spawning periódico: speed cada 10-18s, shield cada 15-25s. TTL de 8s. Si se recoge otro del mismo tipo estando activo, se reinicia el timer. +8. **Estado del juego** — Variables globales: `ship`, `bullets`, `asteroids`, `particles`, `powerUps`, `score`, `lives`, `level`, `state`, `powerUpTimer`, `shieldPowerUpTimer`. 9. **Update** — Loop de actualización con máquina de estados (`playing`, `dead`, `gameover`). Colisiones bala-asteroide, nave-asteroide y nave-power-up. 10. **Draw** — Renderizado de HUD, overlays y entidades. 11. **Loop principal** — `requestAnimationFrame` con `dt` limitado a 50ms. diff --git a/game.js b/game.js index ca3570b..28257eb 100644 --- a/game.js +++ b/game.js @@ -210,6 +210,7 @@ class Ship { this.invincible = 3; this.shootCooldown = 0; this.speedTimer = 0; + this.shieldTimer = 0; this.dead = false; } @@ -217,11 +218,16 @@ class Ship { this.speedTimer = 5; } + applyShield() { + this.shieldTimer = 6; + } + update(dt) { if (this.dead) return; if (this.invincible > 0) this.invincible -= dt; if (this.shootCooldown > 0) this.shootCooldown -= dt; if (this.speedTimer > 0) this.speedTimer -= dt; + if (this.shieldTimer > 0) this.shieldTimer -= dt; const ROT = 3.5; // rad/s let THRUST = 260; // px/s² @@ -283,6 +289,18 @@ class Ship { ctx.stroke(); } + // Escudo protector + if (this.shieldTimer > 0) { + const pulse = 0.3 + 0.15 * Math.sin(this.shieldTimer * 6); + ctx.strokeStyle = `rgba(0,180,255,${pulse.toFixed(2)})`; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(0, 0, 22, 0, Math.PI * 2); + ctx.stroke(); + ctx.fillStyle = `rgba(0,180,255,${(pulse * 0.35).toFixed(2)})`; + ctx.fill(); + } + ctx.restore(); } } @@ -363,9 +381,10 @@ class FireworkParticle { // ── Power-Up ───────────────────────────────────────────────────────────────── class PowerUp { - constructor(x, y) { + constructor(x, y, type = 'speed') { this.x = x; this.y = y; + this.type = type; this.radius = 10; this.ttl = 8; this.dead = false; @@ -388,21 +407,38 @@ class PowerUp { ctx.save(); ctx.translate(this.x, this.y); - // Rayo amarillo - ctx.strokeStyle = `rgba(255,220,0,${alpha.toFixed(2)})`; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.moveTo(-3, -7); - ctx.lineTo(1, -1); - ctx.lineTo(-1, -1); - ctx.lineTo(3, 7); - ctx.lineTo(-1, 1); - ctx.lineTo(1, 1); - ctx.closePath(); - ctx.stroke(); + if (this.type === 'shield') { + ctx.strokeStyle = `rgba(0,180,255,${alpha.toFixed(2)})`; + ctx.lineWidth = 2; + ctx.beginPath(); + const n = 6; + for (let i = 0; i < n; i++) { + const a = (i / n) * Math.PI * 2 - Math.PI / 2; + const px = Math.cos(a) * 7; + const py = Math.sin(a) * 7; + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + } + ctx.closePath(); + ctx.stroke(); + ctx.fillStyle = `rgba(0,180,255,${(alpha * 0.3).toFixed(2)})`; + ctx.fill(); + } else { + ctx.strokeStyle = `rgba(255,220,0,${alpha.toFixed(2)})`; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(-3, -7); + ctx.lineTo(1, -1); + ctx.lineTo(-1, -1); + ctx.lineTo(3, 7); + ctx.lineTo(-1, 1); + ctx.lineTo(1, 1); + ctx.closePath(); + ctx.stroke(); + ctx.fillStyle = `rgba(255,220,0,${(alpha * 0.3).toFixed(2)})`; + ctx.fill(); + } - ctx.fillStyle = `rgba(255,220,0,${(alpha * 0.3).toFixed(2)})`; - ctx.fill(); ctx.restore(); } } @@ -413,6 +449,7 @@ let score, lives, level; let state; // 'playing' | 'dead' | 'gameover' let deadTimer; let powerUpTimer; +let shieldPowerUpTimer; let pinkStarTimer; let pinkStarsSpawned; @@ -439,6 +476,7 @@ function initGame() { level = 1; state = 'playing'; powerUpTimer = rand(8, 15); + shieldPowerUpTimer = rand(15, 25); pinkStarTimer = rand(3, 7); pinkStarsSpawned = 0; spawnAsteroids(4); @@ -451,6 +489,7 @@ function nextLevel() { powerUps = []; ship.reset(); powerUpTimer = rand(8, 15); + shieldPowerUpTimer = rand(15, 25); pinkStarTimer = rand(3, 7); pinkStarsSpawned = 0; spawnAsteroids(3 + level); @@ -510,6 +549,14 @@ function update(dt) { powerUps.push(new PowerUp(rand(0, W), rand(0, H))); powerUpTimer = rand(10, 18); } + + // Spawning periódico de shield power-ups + shieldPowerUpTimer -= dt; + if (shieldPowerUpTimer <= 0) { + powerUps.push(new PowerUp(rand(0, W), rand(0, H), 'shield')); + shieldPowerUpTimer = rand(15, 25); + } + powerUps.forEach(p => p.update(dt)); // Spawning periódico de pinkStars @@ -551,17 +598,31 @@ function update(dt) { if (ship.invincible <= 0) { for (const a of asteroids) { if (dist(ship, a) < ship.radius + a.radius * 0.82) { - killShip(); - break; + if (ship.shieldTimer > 0) { + a.dead = true; + score += a.isPinkStar ? 200 : POINTS[a.size]; + if (a.isPinkStar) fireworkExplode(a.x, a.y, 25); + else explode(a.x, a.y, a.size * 5); + asteroids.push(...a.split()); + } else { + killShip(); + break; + } } } + asteroids = asteroids.filter(a => !a.dead); } // Nave vs power-up for (const p of powerUps) { if (!p.dead && dist(ship, p) < ship.radius + p.radius) { - ship.applySpeed(); - explode(p.x, p.y, 6); + if (p.type === 'shield') { + ship.applyShield(); + explode(p.x, p.y, 6); + } else { + ship.applySpeed(); + explode(p.x, p.y, 6); + } p.dead = true; } } @@ -602,6 +663,12 @@ function drawHUD() { ctx.fillStyle = '#fff'; } + if (ship.shieldTimer > 0) { + ctx.fillStyle = '#00b4ff'; + ctx.fillText(`SHIELD ${ship.shieldTimer.toFixed(1)}s`, 14, ship.speedTimer > 0 ? 66 : 46); + ctx.fillStyle = '#fff'; + } + ctx.textAlign = 'center'; ctx.fillText(`NIVEL ${level}`, W / 2, 26); From 84c4251dc428e9031e9f38abf0b5cb1edac49804 Mon Sep 17 00:00:00 2001 From: AnaRoGon Date: Wed, 19 Aug 2026 11:29:49 +0200 Subject: [PATCH 06/14] Add ship skin system with menu selection and 6 unique skins --- game.js | 314 +++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 288 insertions(+), 26 deletions(-) diff --git a/game.js b/game.js index ca3570b..f86b718 100644 --- a/game.js +++ b/game.js @@ -12,7 +12,7 @@ const justPressed = {}; window.addEventListener('keydown', e => { justPressed[e.code] = !keys[e.code]; keys[e.code] = true; - if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) + if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW'].includes(e.code)) e.preventDefault(); }); window.addEventListener('keyup', e => { keys[e.code] = false; }); @@ -29,6 +29,95 @@ const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); const rand = (min, max) => min + Math.random() * (max - min); const randInt = (min, max) => Math.floor(rand(min, max + 1)); +function complementColor(hex) { + const r = parseInt(hex.slice(1, 3), 16) / 255; + const g = parseInt(hex.slice(3, 5), 16) / 255; + const b = parseInt(hex.slice(5, 7), 16) / 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + let h, s, l = (max + min) / 2; + if (max === min) { h = s = 0; } + else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; + case g: h = ((b - r) / d + 2) / 6; break; + case b: h = ((r - g) / d + 4) / 6; break; + } + } + h = (h + 0.5) % 1; + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1; if (t > 1) t -= 1; + if (t < 1/6) return p + (q - p) * 6 * t; + if (t < 1/2) return q; + if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; + return p; + }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + const cr = Math.round(hue2rgb(p, q, h + 1/3) * 255); + const cg = Math.round(hue2rgb(p, q, h) * 255); + const cb = Math.round(hue2rgb(p, q, h - 1/3) * 255); + return `rgba(${cr},${cg},${cb},0.85)`; +} + +// ── Skins ──────────────────────────────────────────────────────────────────── +const SKINS = [ + { + name: 'CLASICA', + color: '#ffffff', + verts: [[20,0],[-12,-9],[-7,0],[-12,9]], + nose: 21, + flameType: 'classic', + flameX: -8, + }, + { + name: 'MANTA', + color: '#00ffcc', + verts: [[18,0],[8,-14],[-6,-10],[-14,-3],[-14,3],[-6,10],[8,14]], + nose: 18, + flameType: 'dual', + flameX: -14, + }, + { + name: 'CRESCENT', + color: '#ff00ff', + verts: [[16,0],[4,-14],[-10,-10],[-14,0],[-10,10],[4,14]], + nose: 16, + flameType: 'trail', + flameX: -14, + }, + { + name: 'DRAGONFLY', + color: '#8888ff', + verts: [[22,0],[6,-5],[2,-3],[2,-12],[-8,-4],[-4,0],[-8,4],[2,12],[2,3],[6,5]], + nose: 22, + flameType: 'trail', + flameX: -8, + }, + { + name: 'ORIGAMI', + color: '#00ff41', + verts: [[22,0],[8,-8],[0,-4],[0,4],[8,8]], + nose: 22, + flameType: 'classic', + flameX: -2, + }, + { + name: 'HAMMER', + color: '#ff4444', + verts: [[16,0],[14,-10],[4,-8],[-4,-4],[-8,0],[-4,4],[4,8],[14,10]], + nose: 16, + flameType: 'dual', + flameX: -8, + }, +]; + +const SKIN_FLAMES = {}; +for (const s of SKINS) SKIN_FLAMES[s.name] = complementColor(s.color); + +let currentSkinIndex = 0; + // ── Bullet ──────────────────────────────────────────────────────────────────── class Bullet { constructor(x, y, angle) { @@ -206,6 +295,7 @@ class Ship { this.vx = 0; this.vy = 0; this.radius = 12; + this.nose = SKINS[currentSkinIndex].nose; this.thrusting = false; this.invincible = 3; this.shootCooldown = 0; @@ -246,41 +336,83 @@ class Ship { tryShoot() { if (this.shootCooldown > 0 || this.dead) return []; this.shootCooldown = 0.2; - const NOSE = 21; - const ox = this.x + Math.cos(this.angle) * NOSE; - const oy = this.y + Math.sin(this.angle) * NOSE; + const ox = this.x + Math.cos(this.angle) * this.nose; + const oy = this.y + Math.sin(this.angle) * this.nose; return [new Bullet(ox, oy, this.angle)]; } draw() { if (this.dead) return; - // Parpadeo durante invencibilidad de reaparición if (this.invincible > 0 && Math.floor(this.invincible * 8) % 2 === 0) return; + const skin = SKINS[currentSkinIndex]; + const flame = SKIN_FLAMES[skin.name]; ctx.save(); ctx.translate(this.x, this.y); ctx.rotate(this.angle); - ctx.strokeStyle = '#fff'; + ctx.strokeStyle = skin.color; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; - // Silueta clásica: triángulo con muesca trasera ctx.beginPath(); - ctx.moveTo( 20, 0); // nariz - ctx.lineTo(-12, -9); // ala izquierda - ctx.lineTo( -7, 0); // muesca trasera - ctx.lineTo(-12, 9); // ala derecha + ctx.moveTo(skin.verts[0][0], skin.verts[0][1]); + for (let i = 1; i < skin.verts.length; i++) + ctx.lineTo(skin.verts[i][0], skin.verts[i][1]); ctx.closePath(); ctx.stroke(); - // Llama del propulsor - if (this.thrusting && Math.random() > 0.35) { - ctx.beginPath(); - ctx.moveTo(-8, -4); - ctx.lineTo(-8 - rand(6, 14), 0); - ctx.lineTo(-8, 4); - ctx.strokeStyle = 'rgba(255, 130, 0, 0.85)'; - ctx.stroke(); + if (this.thrusting && Math.random() > 0.3) { + ctx.strokeStyle = flame; + ctx.lineWidth = 1.5; + const fx = skin.flameX; + switch (skin.flameType) { + case 'classic': { + const len = rand(6, 14); + ctx.beginPath(); + ctx.moveTo(fx, -4); + ctx.lineTo(fx - len, 0); + ctx.lineTo(fx, 4); + ctx.stroke(); + break; + } + case 'dual': { + const len = rand(5, 10); + ctx.beginPath(); + ctx.moveTo(fx, -5); + ctx.lineTo(fx - len, -5); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(fx, 5); + ctx.lineTo(fx - len, 5); + ctx.stroke(); + break; + } + case 'cone': { + const len = rand(8, 16); + ctx.globalAlpha = 0.5; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(fx, -6); + ctx.lineTo(fx - len, 0); + ctx.lineTo(fx, 6); + ctx.closePath(); + ctx.stroke(); + ctx.globalAlpha = 1; + break; + } + case 'trail': { + ctx.lineWidth = 1; + for (let i = 0; i < 3; i++) { + const a = rand(-0.3, 0.3); + const len = rand(8, 18); + ctx.beginPath(); + ctx.moveTo(fx, 0); + ctx.lineTo(fx - Math.cos(a) * len, Math.sin(a) * len); + ctx.stroke(); + } + break; + } + } } ctx.restore(); @@ -410,7 +542,7 @@ class PowerUp { // ── Estado del juego ────────────────────────────────────────────────────────── let ship, bullets, asteroids, particles, powerUps; let score, lives, level; -let state; // 'playing' | 'dead' | 'gameover' +let state; // 'menu' | 'playing' | 'dead' | 'gameover' let deadTimer; let powerUpTimer; let pinkStarTimer; @@ -478,6 +610,15 @@ function killShip() { // ── Update ──────────────────────────────────────────────────────────────────── function update(dt) { + if (state === 'menu') { + if (pressed('ArrowLeft')) currentSkinIndex = (currentSkinIndex - 1 + SKINS.length) % SKINS.length; + if (pressed('ArrowRight')) currentSkinIndex = (currentSkinIndex + 1) % SKINS.length; + if (pressed('Enter') || pressed('Space')) { + initGame(); + } + return; + } + if (state === 'gameover') { if (pressed('Space')) initGame(); particles.forEach(p => p.update(dt)); @@ -494,6 +635,11 @@ function update(dt) { return; } + if (pressed('KeyW')) { + currentSkinIndex = (currentSkinIndex + 1) % SKINS.length; + ship.nose = SKINS[currentSkinIndex].nose; + } + // Disparar if (pressed('Space')) { bullets.push(...ship.tryShoot()); @@ -573,17 +719,18 @@ function update(dt) { // ── Draw ────────────────────────────────────────────────────────────────────── function drawLifeIcon(x, y) { + const skin = SKINS[currentSkinIndex]; + const S = 0.45; ctx.save(); ctx.translate(x, y); ctx.rotate(-Math.PI / 2); - ctx.strokeStyle = '#fff'; + ctx.strokeStyle = skin.color; ctx.lineWidth = 1.2; ctx.lineJoin = 'round'; ctx.beginPath(); - ctx.moveTo( 9, 0); - ctx.lineTo(-6, -5); - ctx.lineTo(-3, 0); - ctx.lineTo(-6, 5); + ctx.moveTo(skin.verts[0][0] * S, skin.verts[0][1] * S); + for (let i = 1; i < skin.verts.length; i++) + ctx.lineTo(skin.verts[i][0] * S, skin.verts[i][1] * S); ctx.closePath(); ctx.stroke(); ctx.restore(); @@ -620,10 +767,125 @@ function drawOverlay(title, sub) { ctx.fillText(sub, W / 2, H / 2 + 22); } +function drawMenuSkinPreview(x, y, skinIndex, scale, highlighted) { + const skin = SKINS[skinIndex]; + const verts = skin.verts; + const flame = SKIN_FLAMES[skin.name]; + ctx.save(); + ctx.translate(x, y); + ctx.strokeStyle = skin.color; + ctx.lineWidth = highlighted ? 2.5 : 1.5; + ctx.lineJoin = 'round'; + if (!highlighted) ctx.globalAlpha = 0.4; + ctx.beginPath(); + ctx.moveTo(verts[0][0] * scale, verts[0][1] * scale); + for (let i = 1; i < verts.length; i++) + ctx.lineTo(verts[i][0] * scale, verts[i][1] * scale); + ctx.closePath(); + ctx.stroke(); + + if (highlighted) { + ctx.strokeStyle = flame; + ctx.lineWidth = 1.5; + const s = scale; + const fx = skin.flameX * s; + switch (skin.flameType) { + case 'classic': + ctx.beginPath(); + ctx.moveTo(fx, -4 * s); + ctx.lineTo(fx - 6 * s, 0); + ctx.lineTo(fx, 4 * s); + ctx.stroke(); + break; + case 'dual': + ctx.beginPath(); + ctx.moveTo(fx, -5 * s); + ctx.lineTo(fx - 6 * s, -5 * s); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(fx, 5 * s); + ctx.lineTo(fx - 6 * s, 5 * s); + ctx.stroke(); + break; + case 'cone': + ctx.globalAlpha = 0.5; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(fx, -6 * s); + ctx.lineTo(fx - 8 * s, 0); + ctx.lineTo(fx, 6 * s); + ctx.closePath(); + ctx.stroke(); + ctx.globalAlpha = 1; + break; + case 'trail': + ctx.lineWidth = 1; + for (let i = 0; i < 3; i++) { + const a = (i - 1) * 0.25; + ctx.beginPath(); + ctx.moveTo(fx, 0); + ctx.lineTo(fx - Math.cos(a) * 10 * s, Math.sin(a) * 10 * s); + ctx.stroke(); + } + break; + } + } + + ctx.restore(); +} + +function drawMenu() { + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, W, H); + + ctx.textAlign = 'center'; + ctx.fillStyle = '#fff'; + ctx.font = 'bold 46px monospace'; + ctx.fillText('ASTEROIDS', W / 2, 80); + + ctx.font = '16px monospace'; + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.fillText('SELECCIONA TU NAVE', W / 2, 120); + + const spacing = 100; + const startX = W / 2 - ((SKINS.length - 1) / 2) * spacing; + const y = H / 2 - 30; + + for (let i = 0; i < SKINS.length; i++) { + const x = startX + i * spacing; + const highlighted = i === currentSkinIndex; + + if (highlighted) { + ctx.fillStyle = 'rgba(255,255,255,0.08)'; + ctx.fillRect(x - 45, y - 45, 90, 90); + } + + drawMenuSkinPreview(x, y, i, highlighted ? 1.8 : 1.2, highlighted); + } + + const skin = SKINS[currentSkinIndex]; + ctx.font = 'bold 22px monospace'; + ctx.fillStyle = skin.color; + ctx.fillText(skin.name, W / 2, H / 2 + 55); + + ctx.font = '15px monospace'; + ctx.fillStyle = 'rgba(255,255,255,0.6)'; + ctx.fillText('\u2190 \u2192 SELECCIONAR ENTER JUGAR', W / 2, H - 60); + + ctx.font = '13px monospace'; + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + ctx.fillText('EN JUEGO: W PARA CAMBIAR SKIN', W / 2, H - 35); +} + function draw() { ctx.fillStyle = '#000'; ctx.fillRect(0, 0, W, H); + if (state === 'menu') { + drawMenu(); + return; + } + particles.forEach(p => p.draw()); asteroids.forEach(a => a.draw()); powerUps.forEach(p => p.draw()); @@ -647,5 +909,5 @@ function loop(ts) { requestAnimationFrame(loop); } -initGame(); +state = 'menu'; requestAnimationFrame(loop); From 033231deb4c9f2dfd4c0feeb561d07e5d3964759 Mon Sep 17 00:00:00 2001 From: AnaRoGon Date: Wed, 19 Aug 2026 12:30:09 +0200 Subject: [PATCH 07/14] add personalized command to create and delete worktrees and update task instructions file --- .gitignore | 0 .opencode/command/remove-worktree.md | 19 +++++++++++++++++++ .opencode/command/worktree.md | 13 +++++++++++++ tasks-instructions.md | 10 +++++++++- 4 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .opencode/command/remove-worktree.md create mode 100644 .opencode/command/worktree.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/.opencode/command/remove-worktree.md b/.opencode/command/remove-worktree.md new file mode 100644 index 0000000..fef2939 --- /dev/null +++ b/.opencode/command/remove-worktree.md @@ -0,0 +1,19 @@ +--- +description: Eliminar un git worktree y sus ramas asociadas +--- + +1. Ejecutar `git worktree list` para encontrar el worktree que coincida con el contexto proporcionado. + +2. Eliminar el worktree encontrado: + +``` +git worktree remove .worktrees/ +``` + +3. Para cada rama local única de ese worktree, eliminarla: + +``` +git branch -d +``` + +Contexto: $ARGUMENTS \ No newline at end of file diff --git a/.opencode/command/worktree.md b/.opencode/command/worktree.md new file mode 100644 index 0000000..8617184 --- /dev/null +++ b/.opencode/command/worktree.md @@ -0,0 +1,13 @@ +--- +description: Crear un git worktree con un nombre derivado del contexto +--- + +1. Tomar el siguiente contexto y derivar un nombre corto y descriptivo en kebab-case (solo minúsculas, guiones, sin caracteres especiales). Si el contexto es muy largo, simplificarlo. + +2. Ejecutar el siguiente comando con el nombre derivado: + +``` +git worktree add .worktrees/ +``` + +Contexto: $ARGUMENTS \ No newline at end of file diff --git a/tasks-instructions.md b/tasks-instructions.md index 36f46d0..b53fdf6 100644 --- a/tasks-instructions.md +++ b/tasks-instructions.md @@ -1,3 +1,11 @@ # Tarea -Implementar un asteroide especial "estrella fugaz", que se mueve más rápido de lo normal pero desaparece con el tiempo. +[x] Implementar un asteroide especial "estrella fugaz", que se mueve más rápido de lo normal pero desaparece con el tiempo. + +# Tarea 2 + +Ahora necesitamos 3 features: + +[x] implementemos un triple shot: Por 5 segundos, el personaje dispara 3 veces en línea recta. +[x] implementemos un sistema de skins: Poder cambiar la apariencia de la nave. +[x] implementemos un escudo: Un escudo que protege a la nave de los proyectiles enemigos. From 80e98e0197df42a3935b356e48f38b98b0dd10b5 Mon Sep 17 00:00:00 2001 From: AnaRoGon Date: Wed, 19 Aug 2026 12:34:11 +0200 Subject: [PATCH 08/14] AGENT file updated --- AGENTS.md | 43 +++++++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 80d6983..05043bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,11 @@ Clone de Asteroids en HTML5 Canvas puro. Sin bundler, sin dependencias externas. - `index.html` — shell HTML, solo carga el canvas y `game.js` - `game.js` — toda la lógica del juego - `favicon.svg` — ícono +- `tasks-instructions.md` — registro de tareas completadas + +**Comandos personalizados (`.opencode/command/`):** +- `worktree.md` — comando `/worktree` para crear worktrees +- `remove-worktree.md` — comando `/remove-worktree` para eliminar worktrees y ramas ## Ejecutar @@ -23,22 +28,38 @@ El archivo está organizado en secciones marcadas con comentarios `// ── Sec **Secciones (en orden):** 1. **Input** — `keys` y `justPressed` para teclado. `pressed(code)` retorna true solo en el frame en que se presionó. -2. **Utils** — `wrap()`, `dist()`, `rand()`, `randInt()`. El wrap es toroidal, se usa en todas las entidades. -3. **Bullet** — `update(dt)` y `draw()`. Tiene `ttl` y `dead`. -4. **Asteroid** — Tamaños 1-3 con arreglos `RADII`, `SPEEDS`, `POINTS`. `split()` retorna 2 asteroides más pequeños. Vértices irregulares generados al azar. -5. **Ship** — `reset()` para reaparecer. Invencibilidad temporal con parpadeo. `tryShoot()` con cooldown. `applySpeed()` activa boost x2 por 5s. `applyShield()` activa escudo protector por 6s. -6. **Particle** — Explosiones, se auto-destruyen con `ttl`. -7. **PowerUp** — Dos tipos: `'speed'` (rayo amarillo, velocidad x2 por 5s) y `'shield'` (hexágono azul, escudo por 6s). Spawning periódico: speed cada 10-18s, shield cada 15-25s. TTL de 8s. Si se recoge otro del mismo tipo estando activo, se reinicia el timer. -8. **Estado del juego** — Variables globales: `ship`, `bullets`, `asteroids`, `particles`, `powerUps`, `score`, `lives`, `level`, `state`, `powerUpTimer`, `shieldPowerUpTimer`. -9. **Update** — Loop de actualización con máquina de estados (`playing`, `dead`, `gameover`). Colisiones bala-asteroide, nave-asteroide y nave-power-up. -10. **Draw** — Renderizado de HUD, overlays y entidades. -11. **Loop principal** — `requestAnimationFrame` con `dt` limitado a 50ms. +2. **Utils** — `wrap()`, `dist()`, `rand()`, `randInt()`, `complementColor()`. El wrap es toroidal, se usa en todas las entidades. +3. **Skins** — Arreglo `SKINS` con 6 skins: CLASICA, MANTA, CRESCENT, DRAGONFLY, ORIGAMI, HAMMER. Cada una define `name`, `color`, `verts` (vértices), `nose`, `flameType`, `flameX`. `currentSkinIndex` controla la selección. +4. **Bullet** — `update(dt)` y `draw()`. Tiene `ttl` y `dead`. +5. **Asteroid** — Tamaños 1-3 con arreglos `RADII`, `SPEEDS`, `POINTS`. `split()` retorna 2 asteroides más pequeños. Vértices irregulares generados al azar. +6. **PinkStar** — Asteroide especial "estrella fugaz". Hereda de `Asteroid`. Velocidad 110px/s, TTL 4-6s, forma de estrella magenta con 8 puntas y estela naranja. `split()` retorna vacío (no genera más asteroides). +7. **Ship** — `reset()` para reaparecer. Invencibilidad temporal con parpadeo. `tryShoot()` con cooldown. `applySpeed()` activa boost x2 por 5s. `applyShield()` activa escudo protector por 6s. `draw()` renderiza según la skin activa (`SKINS[currentSkinIndex]`). +8. **Particle** — Explosiones básicas, se auto-destruyen con `ttl`. +9. **FireworkParticle** — Explosiones de fuego artificial. Colores aleatorios (magenta, rosa, naranja, blanco). Mayor cantidad de partículas (25 por defecto). +10. **PowerUp** — Tres tipos: + - `'speed'` — rayo amarillo, velocidad x2 por 5s + - `'shield'` — hexágono azul, escudo por 6s + - `'tripleShot'` — forma de bala roja, 3 disparos en línea por 5s + Spawning: speed cada 8-15s, shield cada 15-25s. TTL de 8s. +11. **Estado del juego** — Variables globales: `ship`, `bullets`, `asteroids`, `particles`, `powerUps`, `score`, `lives`, `level`, `state`, `powerUpTimer`, `shieldPowerUpTimer`, `pinkStarTimer`, `pinkStarsSpawned`, `currentSkinIndex`. +12. **Update** — Loop de actualización con máquina de estados. Colisiones bala-asteroide, nave-asteroide y nave-power-up. Spawning de PinkStars controlado por `pinkStarTimer`. +13. **Draw** — Renderizado de HUD, overlays y entidades. +14. **Loop principal** — `requestAnimationFrame` con `dt` limitado a 50ms. **Máquina de estados:** +- `menu` → selección de skin con flechas izq/der, Enter/Space para iniciar - `playing` → juego activo - `dead` → esperando `deadTimer` (2s) antes de reaparecer - `gameover` → esperando `Space` para reiniciar con `initGame()` +**Funciones auxiliares:** +- `spawnAsteroids(count)` — genera asteroides evitando la zona central segura (130px) +- `initGame()` — reinicia estado completo del juego +- `nextLevel()` — avanza de nivel, limpia balas/partículas/powerups +- `explode()` — explosión básica de partículas +- `fireworkExplode()` — explosión de fuego artificial (25 partículas) +- `killShip()` — destruye la nave, aplica penalty de vida + ## Convenciones - Todo el código en `game.js`. No hay otros archivos JS ni módulos. @@ -49,3 +70,5 @@ El archivo está organizado en secciones marcadas con comentarios `// ── Sec - `state` controla el flujo del juego — revisar antes de agregar lógica nueva. - Valores mágicos (velocidades, tamaños, puntos) están como constantes al inicio de cada clase, no hardcodeados en funciones. - El HUD y overlays están en funciones separadas (`drawHUD`, `drawOverlay`). +- Las skins se definen en el arreglo `SKINS` con estructura uniforme. +- Los PowerUp se distribuyen: 65% speed, 35% tripleShot (shield se genera por separado). From 5d54ce333222e468d6e8090dfa8af8154cd28762 Mon Sep 17 00:00:00 2001 From: AnaRoGon Date: Wed, 19 Aug 2026 12:42:12 +0200 Subject: [PATCH 09/14] github opencode integration --- .github/workflows/opencode.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/opencode.yml diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml new file mode 100644 index 0000000..53535cb --- /dev/null +++ b/.github/workflows/opencode.yml @@ -0,0 +1,33 @@ +name: opencode + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +jobs: + opencode: + if: | + contains(github.event.comment.body, ' /oc') || + startsWith(github.event.comment.body, '/oc') || + contains(github.event.comment.body, ' /opencode') || + startsWith(github.event.comment.body, '/opencode') + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: read + issues: read + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Run opencode + uses: anomalyco/opencode/github@latest + env: + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + with: + model: opencode/deepseek-v4-flash-free \ No newline at end of file From 8d52f77b060aa0d1137afa7d4880326878622845 Mon Sep 17 00:00:00 2001 From: AnaRoGon Date: Wed, 19 Aug 2026 18:12:45 +0200 Subject: [PATCH 10/14] refactor opencode + github configuration --- .github/workflows/opencode.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 53535cb..69251be 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -16,9 +16,9 @@ jobs: runs-on: ubuntu-latest permissions: id-token: write - contents: read - pull-requests: read - issues: read + contents: write + pull-requests: write + issues: write steps: - name: Checkout repository uses: actions/checkout@v6 @@ -30,4 +30,5 @@ jobs: env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: - model: opencode/deepseek-v4-flash-free \ No newline at end of file + model: opencode/deepseek-v4-flash-free + use_github_token: true \ No newline at end of file From 4849b7686e6485970fba1dc97167940c6d067e76 Mon Sep 17 00:00:00 2001 From: AnaRoGon Date: Wed, 19 Aug 2026 18:16:26 +0200 Subject: [PATCH 11/14] add github token --- .github/workflows/opencode.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 69251be..81722af 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -29,6 +29,7 @@ jobs: uses: anomalyco/opencode/github@latest env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: model: opencode/deepseek-v4-flash-free use_github_token: true \ No newline at end of file From 6243096d519cdfc7e6eaef1b3d93bcb46ae7108f Mon Sep 17 00:00:00 2001 From: AnaRoGon Date: Wed, 19 Aug 2026 18:50:13 +0200 Subject: [PATCH 12/14] configure git --- .github/workflows/opencode.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 81722af..3c5978a 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -25,6 +25,11 @@ jobs: with: persist-credentials: false + - name: Configure git + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + - name: Run opencode uses: anomalyco/opencode/github@latest env: From 465b38df0a907223b4615f7bd4c200f3f92d3f15 Mon Sep 17 00:00:00 2001 From: AnaRoGon Date: Wed, 19 Aug 2026 19:00:05 +0200 Subject: [PATCH 13/14] delete persist-credentials --- .github/workflows/opencode.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 3c5978a..78defa4 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -22,8 +22,6 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 - with: - persist-credentials: false - name: Configure git run: | From 28db4cadd1c2694e0c98388e8bd25cb505cd42df Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 20 Aug 2026 08:03:12 +0000 Subject: [PATCH 14/14] =?UTF-8?q?feat:=20nueva=20skin=20ORO=20amarilla=20d?= =?UTF-8?q?e=20doble=20tama=C3=B1o=20y=20doble=20puntos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- game.js | 35 ++++++++++++++++++++++++++++------- tasks-instructions.md | 7 +++++++ 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 05043bd..47d75f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ El archivo está organizado en secciones marcadas con comentarios `// ── Sec 1. **Input** — `keys` y `justPressed` para teclado. `pressed(code)` retorna true solo en el frame en que se presionó. 2. **Utils** — `wrap()`, `dist()`, `rand()`, `randInt()`, `complementColor()`. El wrap es toroidal, se usa en todas las entidades. -3. **Skins** — Arreglo `SKINS` con 6 skins: CLASICA, MANTA, CRESCENT, DRAGONFLY, ORIGAMI, HAMMER. Cada una define `name`, `color`, `verts` (vértices), `nose`, `flameType`, `flameX`. `currentSkinIndex` controla la selección. +3. **Skins** — Arreglo `SKINS` con 7 skins: CLASICA, MANTA, CRESCENT, DRAGONFLY, ORIGAMI, HAMMER, ORO. Cada una define `name`, `color`, `verts` (vértices), `nose`, `flameType`, `flameX`. Propiedades opcionales: `radius` (radio de colisión, default 12), `points` (multiplicador de puntos, default 1), `previewScale` (escala en menú/vidas, default 1). ORO es el doble de grande y da puntos x2. `currentSkinIndex` controla la selección. 4. **Bullet** — `update(dt)` y `draw()`. Tiene `ttl` y `dead`. 5. **Asteroid** — Tamaños 1-3 con arreglos `RADII`, `SPEEDS`, `POINTS`. `split()` retorna 2 asteroides más pequeños. Vértices irregulares generados al azar. 6. **PinkStar** — Asteroide especial "estrella fugaz". Hereda de `Asteroid`. Velocidad 110px/s, TTL 4-6s, forma de estrella magenta con 8 puntas y estela naranja. `split()` retorna vacío (no genera más asteroides). diff --git a/game.js b/game.js index 6143e0b..fa4e1f2 100644 --- a/game.js +++ b/game.js @@ -111,6 +111,17 @@ const SKINS = [ flameType: 'dual', flameX: -8, }, + { + name: 'ORO', + color: '#ffd700', + verts: [[40,0],[-24,-18],[-14,0],[-24,18]], + nose: 42, + radius: 24, + points: 2, + previewScale: 0.75, + flameType: 'classic', + flameX: -16, + }, ]; const SKIN_FLAMES = {}; @@ -309,8 +320,9 @@ class Ship { this.angle = -Math.PI / 2; this.vx = 0; this.vy = 0; - this.radius = 12; - this.nose = SKINS[currentSkinIndex].nose; + const skin = SKINS[currentSkinIndex]; + this.radius = skin.radius || 12; + this.nose = skin.nose; this.thrusting = false; this.invincible = 3; this.shootCooldown = 0; @@ -723,7 +735,9 @@ function update(dt) { if (pressed('KeyW')) { currentSkinIndex = (currentSkinIndex + 1) % SKINS.length; - ship.nose = SKINS[currentSkinIndex].nose; + const skin = SKINS[currentSkinIndex]; + ship.nose = skin.nose; + ship.radius = skin.radius || 12; } // Disparar @@ -772,12 +786,13 @@ function update(dt) { // Bala vs asteroide const newAsteroids = []; + const mult = SKINS[currentSkinIndex].points || 1; for (const b of bullets) { for (const a of asteroids) { if (!a.dead && !b.dead && dist(b, a) < a.radius) { b.dead = true; a.dead = true; - score += a.isPinkStar ? 200 : POINTS[a.size]; + score += (a.isPinkStar ? 200 : POINTS[a.size]) * mult; if (a.isPinkStar) fireworkExplode(a.x, a.y, 25); else explode(a.x, a.y, a.size * 5); newAsteroids.push(...a.split()); @@ -793,7 +808,7 @@ function update(dt) { if (dist(ship, a) < ship.radius + a.radius * 0.82) { if (ship.shieldTimer > 0) { a.dead = true; - score += a.isPinkStar ? 200 : POINTS[a.size]; + score += (a.isPinkStar ? 200 : POINTS[a.size]) * mult; if (a.isPinkStar) fireworkExplode(a.x, a.y, 25); else explode(a.x, a.y, a.size * 5); asteroids.push(...a.split()); @@ -825,7 +840,7 @@ function update(dt) { // ── Draw ────────────────────────────────────────────────────────────────────── function drawLifeIcon(x, y) { const skin = SKINS[currentSkinIndex]; - const S = 0.45; + const S = 0.45 * (skin.previewScale || 1); ctx.save(); ctx.translate(x, y); ctx.rotate(-Math.PI / 2); @@ -981,7 +996,7 @@ function drawMenu() { ctx.fillRect(x - 45, y - 45, 90, 90); } - drawMenuSkinPreview(x, y, i, highlighted ? 1.8 : 1.2, highlighted); + drawMenuSkinPreview(x, y, i, (highlighted ? 1.8 : 1.2) * (SKINS[i].previewScale || 1), highlighted); } const skin = SKINS[currentSkinIndex]; @@ -989,6 +1004,12 @@ function drawMenu() { ctx.fillStyle = skin.color; ctx.fillText(skin.name, W / 2, H / 2 + 55); + if (skin.points > 1) { + ctx.font = '13px monospace'; + ctx.fillStyle = 'rgba(255,215,0,0.9)'; + ctx.fillText('PUNTOS X2', W / 2, H / 2 + 78); + } + ctx.font = '15px monospace'; ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.fillText('\u2190 \u2192 SELECCIONAR ENTER JUGAR', W / 2, H - 60); diff --git a/tasks-instructions.md b/tasks-instructions.md index b53fdf6..e90247c 100644 --- a/tasks-instructions.md +++ b/tasks-instructions.md @@ -9,3 +9,10 @@ Ahora necesitamos 3 features: [x] implementemos un triple shot: Por 5 segundos, el personaje dispara 3 veces en línea recta. [x] implementemos un sistema de skins: Poder cambiar la apariencia de la nave. [x] implementemos un escudo: Un escudo que protege a la nave de los proyectiles enemigos. + +# Tarea 3 + +Nueva nave amarilla de doble tamaño y doble puntos: + +[x] skin nueva `ORO` (amarilla `#ffd700`), el doble de grande que la nave original. +[x] al usarla, el jugador recibe el doble de puntos por asteroide destruido.