diff --git a/public/js/games/spaceinvaders.js b/public/js/games/spaceinvaders.js new file mode 100644 index 0000000..4bb10ac --- /dev/null +++ b/public/js/games/spaceinvaders.js @@ -0,0 +1,219 @@ +/* ============================================================ + INVADER DOCK — Space Invaders clone + ============================================================ */ +(function () { + 'use strict'; + const { Game, STATE } = window.DSP; + const S = window.DSP_SPRITES; + + const W = 800, H = 600; + const COLS = 10, ROWS = 5, IW = 40, IH = 34; + const TOP = 70; + + class InvaderDock extends Game { + constructor(canvas) { + super({ name: 'invader', title: 'INVADER DOCK', width: W, height: H, canvas, + loadSteps: ['DETECTING FORMATION', 'CHARGING CANNON', 'RAISING BUNKERS', 'SYNCING GRID'] }); + this.invaders = []; + this.dir = 1; + this.drop = false; + this.bullets = []; + this.enemyBullets = []; + this.cannon = { x: W / 2, w: 36 }; + this.bunkers = []; + this.fireCd = 0; + this.stepTimer = 0; + this.stars = Array.from({ length: 60 }, () => ({ x: Math.random() * W, y: Math.random() * H, s: Math.random() < 0.2 ? 2 : 1 })); + } + reset() { + this.invaders = []; + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + this.invaders.push({ + c, r, + x: (W - COLS * IW) / 2 + c * IW, + y: TOP + r * IH, + type: r === 0 ? 3 : r < 3 ? 1 : 2, + alive: true, + }); + } + } + this.dir = 1; + this.drop = false; + this.bullets = []; + this.enemyBullets = []; + this.fireCd = 0; + this.buildBunkers(); + } + buildBunkers() { + this.bunkers = []; + const bw = 60, bh = 30; + const positions = [W * 0.2, W * 0.4, W * 0.6, W * 0.8]; + for (const px of positions) { + const cells = []; + for (let r = 0; r < 4; r++) { + for (let c = 0; c < 6; c++) { + if (r === 3 && (c === 0 || c === 5)) continue; + if (r === 2 && (c === 0 || c === 5)) continue; + cells.push({ x: px - bw / 2 + c * 10, y: H - 90 + r * 8, hp: 2 }); + } + } + this.bunkers.push(cells); + } + } + aliveInvaders() { return this.invaders.filter(i => i.alive); } + update() { + const c = this.cannon; + const left = this.input.down('ArrowLeft', 'KeyA'); + const right = this.input.down('ArrowRight', 'KeyD'); + if (left) c.x -= 5; + if (right) c.x += 5; + c.x = Math.max(c.w / 2, Math.min(W - c.w / 2, c.x)); + + if (this.fireCd > 0) this.fireCd--; + if (this.input.wasPressed('Space') && this.fireCd === 0) { + this.bullets.push({ x: c.x, y: H - 60, vy: -8 }); + this.fireCd = 18; + window.DSP_AUDIO.sfx('laser'); + } + + for (let i = this.bullets.length - 1; i >= 0; i--) { + const b = this.bullets[i]; + b.y += b.vy; + if (b.y < 0) { this.bullets.splice(i, 1); continue; } + let hit = false; + for (const inv of this.invaders) { + if (!inv.alive) continue; + if (b.x > inv.x - IW / 2 + 4 && b.x < inv.x + IW / 2 - 4 && b.y > inv.y - IH / 2 + 4 && b.y < inv.y + IH / 2 - 4) { + inv.alive = false; + const pts = inv.type === 3 ? 30 : inv.type === 1 ? 20 : 10; + this.addScore(pts); + window.DSP_AUDIO.sfx('explode'); + this.particles.burst(inv.x, inv.y, inv.type === 3 ? '#ffe14d' : inv.type === 1 ? '#7dff5e' : '#ff7de9', 12, 2.5, 25); + hit = true; + break; + } + } + if (!hit) { + outer: + for (const bunker of this.bunkers) { + for (const cell of bunker) { + if (cell.hp > 0 && b.x > cell.x && b.x < cell.x + 10 && b.y > cell.y && b.y < cell.y + 8) { + cell.hp--; + this.bullets.splice(i, 1); + hit = true; + break outer; + } + } + } + } + if (hit) this.bullets.splice(i, 1); + } + + const alive = this.aliveInvaders(); + const speed = Math.max(0.5, 1 + (this.invaders.length - alive.length) * 0.08) * (1 + this.level * 0.15); + this.stepTimer++; + const stepEvery = Math.max(4, 14 - this.level * 2); + if (this.stepTimer >= stepEvery) { + this.stepTimer = 0; + let edge = false; + for (const inv of alive) { + inv.x += this.dir * 8; + if (inv.x < IW / 2 || inv.x > W - IW / 2) edge = true; + } + if (edge) { + this.dir *= -1; + for (const inv of alive) { + inv.x += this.dir * 8; + inv.y += 14; + } + } + for (const inv of alive) { + if (inv.y > H - 100) { + this.gameOver(); + return; + } + } + } + + if (alive.length && Math.random() < 0.02 + this.level * 0.008) { + const cols = {}; + for (const inv of alive) { + if (!cols[inv.c] || inv.y > cols[inv.c].y) cols[inv.c] = inv; + } + const colList = Object.values(cols); + const shooter = colList[Math.floor(Math.random() * colList.length)]; + if (shooter) { + this.enemyBullets.push({ x: shooter.x, y: shooter.y + IH / 2, vy: 3 + this.level * 0.3 }); + window.DSP_AUDIO.sfx('hit'); + } + } + for (let i = this.enemyBullets.length - 1; i >= 0; i--) { + const b = this.enemyBullets[i]; + b.y += b.vy; + if (b.y > H) { this.enemyBullets.splice(i, 1); continue; } + let hit = false; + for (const bunker of this.bunkers) { + for (const cell of bunker) { + if (cell.hp > 0 && b.x > cell.x && b.x < cell.x + 10 && b.y > cell.y && b.y < cell.y + 8) { + cell.hp--; + hit = true; + break; + } + } + if (hit) break; + } + if (hit) { this.enemyBullets.splice(i, 1); continue; } + if (Math.abs(b.x - c.x) < c.w / 2 && b.y > H - 60 && b.y < H - 30) { + this.enemyBullets.splice(i, 1); + window.DSP_AUDIO.sfx('bigboom'); + this.particles.burst(c.x, H - 45, '#ff4d6d', 24, 3.5, 35); + this.shake.trigger(7, 20); + this.lives--; + if (this.lives <= 0) this.gameOver(); + } + } + + if (alive.length === 0) { + this.level++; + window.DSP_AUDIO.sfx('levelup'); + this.reset(); + } + } + render() { + const ctx = this.ctx; + ctx.fillStyle = '#05010d'; + ctx.fillRect(0, 0, W, H); + for (const s of this.stars) { + ctx.fillStyle = s.s > 1 ? '#7df9ff' : '#2a2145'; + ctx.fillRect(s.x, s.y, s.s, s.s); + } + const frame = Math.floor(this.time / 20) % 2; + for (const inv of this.invaders) { + if (!inv.alive) continue; + const spriteName = inv.type === 3 ? 'invader3' : inv.type === 1 ? 'invader1' : 'invader2'; + S.draw(ctx, S.get(spriteName, 3), inv.x, inv.y); + } + for (const bunker of this.bunkers) { + for (const cell of bunker) { + if (cell.hp <= 0) continue; + ctx.fillStyle = cell.hp === 2 ? '#7dff5e' : '#3d8a3d'; + ctx.fillRect(cell.x, cell.y, 10, 8); + } + } + const c = this.cannon; + ctx.fillStyle = '#7df9ff'; + ctx.fillRect(c.x - c.w / 2, H - 40, c.w, 14); + ctx.fillRect(c.x - 4, H - 56, 8, 18); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(c.x - c.w / 2, H - 40, c.w, 4); + ctx.fillStyle = '#ffe14d'; + for (const b of this.bullets) ctx.fillRect(b.x - 1.5, b.y - 8, 3, 10); + ctx.fillStyle = '#ff4d6d'; + for (const b of this.enemyBullets) ctx.fillRect(b.x - 1.5, b.y - 6, 3, 8); + } + } + + window.DSP_ACTIVE_GAME = new InvaderDock(document.getElementById('game')); + window.DSP_AUDIO.playMusic('invader'); +})(); \ No newline at end of file