diff --git a/space-invaders/README.md b/space-invaders/README.md new file mode 100644 index 00000000..d323ebc1 --- /dev/null +++ b/space-invaders/README.md @@ -0,0 +1,29 @@ +# Space Invaders — Nouveau + +A mobile-responsive Progressive Web App recreation of the 1978 arcade classic, +built with vanilla HTML5 Canvas, CSS, and JavaScript (ES modules) — no build +step, no dependencies. + +Faithful to the original's mechanics: the hive-mind alien swarm with its +hardware-accurate speed-up curve, deterministic Rolling/Plunger/Squiggly +firing patterns, the hidden shot-count-based mystery ship scoring table, +pixel-level destructible bunkers, and the one-bullet-at-a-time cannon — all +reskinned in a gilded, jewel-toned Art Nouveau visual style with procedurally +synthesized (not sampled) sound effects. + +## Running locally + +Any static file server works, e.g.: + +```bash +cd space-invaders +python3 -m http.server 8080 +``` + +Then open `http://localhost:8080`. Installable as a PWA (offline-capable via +service worker) from a supporting browser. + +## Controls + +- **Move**: Arrow keys / A-D, or the on-screen D-pad on touch devices. +- **Fire**: Space / Up / W, or the on-screen fire button. diff --git a/space-invaders/css/style.css b/space-invaders/css/style.css new file mode 100644 index 00000000..76540ac9 --- /dev/null +++ b/space-invaders/css/style.css @@ -0,0 +1,157 @@ +:root { + --gold: #d4af37; + --gold-bright: #f3d47a; + --gold-dim: #8a6a2a; + --ink: #120a24; + --ink-deep: #0a0616; + --plum: #1d1030; + --parchment: #e8d9a8; + --ruby: #c0384f; +} + +* { + box-sizing: border-box; +} + +html, body { + height: 100%; + margin: 0; + background: radial-gradient(ellipse at center, var(--plum) 0%, var(--ink-deep) 75%); + color: var(--parchment); + font-family: Georgia, 'Palatino Linotype', 'Book Antiqua', serif; + overflow: hidden; + -webkit-tap-highlight-color: transparent; + touch-action: none; +} + +#stage { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.6rem; + padding: 0.6rem env(safe-area-inset-right) 0.6rem env(safe-area-inset-left); +} + +#hud { + width: min(92vw, 460px); + display: flex; + justify-content: space-between; + align-items: baseline; + letter-spacing: 0.12em; + text-transform: uppercase; + padding: 0 0.4rem; +} + +.hud-block { + display: flex; + flex-direction: column; + align-items: center; + min-width: 3.5rem; +} + +.hud-label { + font-size: 0.55rem; + color: var(--gold-dim); + letter-spacing: 0.2em; +} + +.hud-value { + font-size: 1.1rem; + color: var(--gold-bright); + text-shadow: 0 0 6px rgba(212, 175, 55, 0.55); + font-variant-numeric: tabular-nums; +} + +#canvas-frame { + position: relative; + padding: 14px; + border-radius: 18px 18px 8px 8px; + background: + linear-gradient(180deg, rgba(212, 175, 55, 0.08), transparent 40%), + var(--ink); + border: 2px solid var(--gold); + box-shadow: + 0 0 0 1px rgba(212, 175, 55, 0.25), + 0 0 24px rgba(212, 175, 55, 0.18), + inset 0 0 30px rgba(0, 0, 0, 0.6); + max-height: 82vh; + display: flex; +} + +#canvas-frame::before { + content: ''; + position: absolute; + inset: 4px; + pointer-events: none; + border: 1px solid rgba(212, 175, 55, 0.35); + border-radius: 14px 14px 6px 6px; +} + +#game-canvas { + display: block; + width: auto; + height: auto; + max-width: 88vw; + max-height: 78vh; + aspect-ratio: 224 / 256; + background: var(--ink-deep); + border-radius: 10px 10px 4px 4px; +} + +#touch-controls { + display: none; + width: min(92vw, 460px); + justify-content: space-between; + align-items: center; + padding: 0 0.5rem; +} + +@media (pointer: coarse) { + #touch-controls { + display: flex; + } + #stage { + justify-content: space-between; + } + #game-canvas { + max-height: 62vh; + } +} + +.dpad { + display: flex; + gap: 0.9rem; +} + +.ctrl { + font-family: inherit; + width: 3.4rem; + height: 3.4rem; + border-radius: 50%; + border: 2px solid var(--gold); + background: radial-gradient(circle at 35% 30%, rgba(212, 175, 55, 0.35), rgba(18, 10, 36, 0.9)); + color: var(--gold-bright); + font-size: 1.3rem; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 0 12px rgba(212, 175, 55, 0.3); + user-select: none; +} + +.ctrl:active { + background: radial-gradient(circle at 35% 30%, rgba(243, 212, 122, 0.5), rgba(18, 10, 36, 0.9)); +} + +.ctrl--fire { + width: 4rem; + height: 4rem; + font-size: 1.6rem; +} + +@media (max-height: 480px) { + .hud-value { font-size: 0.9rem; } + .hud-label { font-size: 0.5rem; } +} diff --git a/space-invaders/icons/icon-192.png b/space-invaders/icons/icon-192.png new file mode 100644 index 00000000..cfc342f7 Binary files /dev/null and b/space-invaders/icons/icon-192.png differ diff --git a/space-invaders/icons/icon-512.png b/space-invaders/icons/icon-512.png new file mode 100644 index 00000000..c9d440cf Binary files /dev/null and b/space-invaders/icons/icon-512.png differ diff --git a/space-invaders/icons/icon-maskable-512.png b/space-invaders/icons/icon-maskable-512.png new file mode 100644 index 00000000..ae75c75e Binary files /dev/null and b/space-invaders/icons/icon-maskable-512.png differ diff --git a/space-invaders/icons/icon.svg b/space-invaders/icons/icon.svg new file mode 100644 index 00000000..bfcec82c --- /dev/null +++ b/space-invaders/icons/icon.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/space-invaders/index.html b/space-invaders/index.html new file mode 100644 index 00000000..ecb14aca --- /dev/null +++ b/space-invaders/index.html @@ -0,0 +1,46 @@ + + + + + + Space Invaders — Nouveau + + + + + + + + +
+
+
+ Score + 0000 +
+
+ High Score + 0000 +
+
+ Lives + 3 +
+
+ +
+ +
+ + +
+ + + + diff --git a/space-invaders/js/audio.js b/space-invaders/js/audio.js new file mode 100644 index 00000000..60910100 --- /dev/null +++ b/space-invaders/js/audio.js @@ -0,0 +1,139 @@ +// AudioManager — all sound effects are synthesized procedurally at load time +// (no external .wav assets), then played back as low-latency AudioBufferSourceNodes. +export class AudioManager { + constructor() { + this.ctx = null; + this.buffers = new Map(); + this.masterGain = null; + this.ufoSource = null; + this.ufoGain = null; + this.enabled = true; + } + + // AudioContext must be created/resumed from a user gesture on most browsers. + unlock() { + if (this.ctx) { + if (this.ctx.state === 'suspended') this.ctx.resume(); + return; + } + const Ctx = window.AudioContext || window.webkitAudioContext; + this.ctx = new Ctx(); + this.masterGain = this.ctx.createGain(); + this.masterGain.gain.value = 0.55; + this.masterGain.connect(this.ctx.destination); + this._generateAll(); + } + + _makeBuffer(duration, fn) { + const sr = this.ctx.sampleRate; + const length = Math.max(1, Math.floor(sr * duration)); + const buffer = this.ctx.createBuffer(1, length, sr); + const data = buffer.getChannelData(0); + for (let i = 0; i < length; i++) { + const t = i / sr; + data[i] = fn(t, i, sr); + } + return buffer; + } + + _generateAll() { + const noise = (t, decay) => (Math.random() * 2 - 1) * Math.exp(-t * decay); + + // Player shot: quick descending square-ish sweep, bright and thin. + this.buffers.set('shoot', this._makeBuffer(0.16, (t) => { + const freq = 900 - t * 3200; + const env = Math.exp(-t * 14); + return Math.sign(Math.sin(2 * Math.PI * freq * t)) * 0.35 * env; + })); + + // Alien hit: a short "pop" — filtered noise burst with a falling thud. + this.buffers.set('alienHit', this._makeBuffer(0.14, (t) => { + const thud = Math.sin(2 * Math.PI * (140 - t * 400) * t) * 0.5; + return (noise(t, 24) * 0.6 + thud) * Math.exp(-t * 18); + })); + + // Player explosion: bigger, longer, low rumbling noise burst. + this.buffers.set('playerExplosion', this._makeBuffer(0.5, (t) => { + const rumble = Math.sin(2 * Math.PI * (90 - t * 60) * t) * 0.4; + return (noise(t, 5) * 0.8 + rumble) * Math.exp(-t * 4.2); + })); + + // UFO hit: rewarding bright descending chime/arpeggio. + this.buffers.set('ufoHit', this._makeBuffer(0.55, (t) => { + const notes = [1568, 1244, 987, 1568]; + const idx = Math.min(notes.length - 1, Math.floor(t * 9)); + const freq = notes[idx]; + const env = Math.exp(-((t % 0.14)) * 10); + return Math.sin(2 * Math.PI * freq * t) * 0.4 * env; + })); + + // UFO flying loop: two alternating warbling tones (76477-chip style siren). + this.buffers.set('ufoFlying', this._makeBuffer(0.6, (t) => { + const warble = Math.sin(2 * Math.PI * 6 * t); + const freq = 300 + warble * 90; + return Math.sin(2 * Math.PI * freq * t) * 0.25; + })); + + // Four descending "march" notes forming the alien heartbeat. + const marchFreqs = [110, 98, 87, 82]; + marchFreqs.forEach((freq, idx) => { + this.buffers.set(`march${idx + 1}`, this._makeBuffer(0.11, (t) => { + const env = Math.exp(-t * 16); + return (Math.sign(Math.sin(2 * Math.PI * freq * t)) * 0.5 + Math.sin(2 * Math.PI * freq * 2 * t) * 0.2) * env; + })); + }); + + // Extra life awarded: bright ascending arpeggio. + this.buffers.set('extraLife', this._makeBuffer(0.5, (t) => { + const notes = [523, 659, 784, 1046]; + const idx = Math.min(notes.length - 1, Math.floor(t * 9)); + const env = Math.exp(-((t % 0.12)) * 9); + return Math.sin(2 * Math.PI * notes[idx] * t) * 0.35 * env; + })); + + // Wave clear fanfare. + this.buffers.set('waveClear', this._makeBuffer(0.7, (t) => { + const notes = [392, 523, 659, 784, 1046]; + const idx = Math.min(notes.length - 1, Math.floor(t * 7.5)); + const env = Math.exp(-((t % 0.13)) * 7); + return Math.sin(2 * Math.PI * notes[idx] * t) * 0.32 * env; + })); + + // Game over: descending, mournful. + this.buffers.set('gameOver', this._makeBuffer(1.1, (t) => { + const freq = 220 - t * 120; + const env = Math.exp(-t * 1.6); + return Math.sin(2 * Math.PI * Math.max(40, freq) * t) * 0.3 * env; + })); + } + + playSound(name, { loop = false, gain = 1 } = {}) { + if (!this.enabled || !this.ctx) return null; + const buffer = this.buffers.get(name); + if (!buffer) return null; + const source = this.ctx.createBufferSource(); + source.buffer = buffer; + source.loop = loop; + const g = this.ctx.createGain(); + g.gain.value = gain; + source.connect(g).connect(this.masterGain); + source.start(0); + return source; + } + + startUfoLoop() { + if (this.ufoSource || !this.ctx) return; + this.ufoSource = this.playSound('ufoFlying', { loop: true, gain: 0.6 }); + } + + stopUfoLoop() { + if (this.ufoSource) { + try { this.ufoSource.stop(); } catch (e) { /* already stopped */ } + this.ufoSource = null; + } + } + + playMarchStep(step) { + this.playSound(`march${(step % 4) + 1}`); + } +} diff --git a/space-invaders/js/collision.js b/space-invaders/js/collision.js new file mode 100644 index 00000000..8a38d6d1 --- /dev/null +++ b/space-invaders/js/collision.js @@ -0,0 +1,104 @@ +import { Explosion } from './entities/explosion.js'; +import { PALETTE } from './constants.js'; + +// Broad-phase: cheap Axis-Aligned Bounding Box overlap test used for every +// entity-to-entity pair before any expensive pixel work is considered. +export function isColliding(a, b) { + return a.x < b.x + b.width + && a.x + a.width > b.x + && a.y < b.y + b.height + && a.y + a.height > b.y; +} + +function bunkerNarrowPhaseHit(bunker, bullet) { + // Sample a few points along the bullet's leading edge against the bunker's + // live pixel bitmap — the narrow-phase check, only reached after AABB hits. + const points = [ + [bullet.x + bullet.width / 2, bullet.y], + [bullet.x + bullet.width / 2, bullet.y + bullet.height], + [bullet.x, bullet.y + bullet.height / 2], + [bullet.x + bullet.width, bullet.y + bullet.height / 2], + ]; + for (const [px, py] of points) { + if (bunker.isSolidAt(px, py)) return { x: px, y: py }; + } + return null; +} + +export function checkCollisions(game) { + const { player, swarm, mysteryShip, bunkers, playerBullets, alienBullets, explosions, audio } = game; + + // Player bullets vs aliens. + for (const bullet of playerBullets) { + if (!bullet.isAlive) continue; + for (const alien of swarm.aliens) { + if (!alien.isAlive) continue; + const pos = swarm.alienScreenPosition(alien); + const rect = { x: pos.x, y: pos.y, width: alien.width, height: alien.height }; + if (isColliding(bullet, rect)) { + alien.isAlive = false; + bullet.isAlive = false; + game.score += alien.points; + game.onScoreChanged(); + explosions.push(new Explosion(pos.x + alien.width / 2, pos.y + alien.height / 2, PALETTE.goldBright)); + audio.playSound('alienHit'); + break; + } + } + } + + // Player bullets vs mystery ship. + if (mysteryShip.active) { + for (const bullet of playerBullets) { + if (!bullet.isAlive) continue; + if (isColliding(bullet, mysteryShip)) { + bullet.isAlive = false; + const points = mysteryShip.scoreValue(); + game.score += points; + game.onScoreChanged(); + explosions.push(new Explosion( + mysteryShip.x + mysteryShip.width / 2, + mysteryShip.y + mysteryShip.height / 2, + PALETTE.ufo, + 0.5, + )); + audio.playSound('ufoHit'); + mysteryShip.despawn(); + } + } + } + + // Alien bullets vs player. + if (player.isAlive) { + for (const bullet of alienBullets) { + if (!bullet.isAlive) continue; + if (isColliding(bullet, player)) { + bullet.isAlive = false; + game.onPlayerHit(); + } + } + } + + // Bullets vs bunkers (AABB broad-phase, then pixel narrow-phase + damage). + const allBullets = [...playerBullets, ...alienBullets]; + for (const bullet of allBullets) { + if (!bullet.isAlive) continue; + for (const bunker of bunkers) { + if (!isColliding(bullet, bunker.getBounds())) continue; + const hit = bunkerNarrowPhaseHit(bunker, bullet); + if (hit) { + bunker.applyDamage(hit.x, hit.y); + bullet.isAlive = false; + break; + } + } + } + + // Reset the player's single-shot lock whenever its bullet has resolved. + for (const bullet of playerBullets) { + if (!bullet.isAlive) player.onBulletResolved(); + } + + game.playerBullets = playerBullets.filter((b) => b.isAlive); + game.alienBullets = alienBullets.filter((b) => b.isAlive); +} diff --git a/space-invaders/js/constants.js b/space-invaders/js/constants.js new file mode 100644 index 00000000..52c498d2 --- /dev/null +++ b/space-invaders/js/constants.js @@ -0,0 +1,89 @@ +// Core game constants — the "magic numbers" centralized for easy tuning. +export const CANVAS_WIDTH = 224; +export const CANVAS_HEIGHT = 256; + +export const ALIEN_ROWS = 5; +export const ALIEN_COLS = 11; +export const ALIEN_H_SPACING = 16; +export const ALIEN_V_SPACING = 16; +export const ALIEN_START_X = 16; +export const ALIEN_START_Y = 34; +export const ALIEN_DROP_DISTANCE = 8; +export const ALIEN_MAX_DEPTH_Y = 170; + +export const ALIEN_TYPES = { + SQUID: { name: 'squid', row: 0, width: 8, height: 8, points: 30 }, + CRAB: { name: 'crab', rows: [1, 2], width: 11, height: 8, points: 20 }, + OCTOPUS: { name: 'octopus', rows: [3, 4], width: 12, height: 8, points: 10 }, +}; + +export function alienTypeForRow(row) { + if (row === 0) return 'squid'; + if (row === 1 || row === 2) return 'crab'; + return 'octopus'; +} + +export function pointsForType(type) { + if (type === 'squid') return 30; + if (type === 'crab') return 20; + return 10; +} + +export const BASE_MOVE_INTERVAL = 900; // ms, slowest possible step (full swarm) +export const MIN_MOVE_INTERVAL = 45; // ms, fastest possible step (last alien) + +export const PLAYER_WIDTH = 13; +export const PLAYER_HEIGHT = 8; +export const PLAYER_SPEED = 90; // px/sec +export const PLAYER_START_LIVES = 3; +export const EXTRA_LIFE_SCORE = 1500; + +export const PLAYER_BULLET_SPEED = 220; // px/sec upward +export const ALIEN_BULLET_SPEED = 110; // px/sec downward + +export const MYSTERY_SHIP_WIDTH = 16; +export const MYSTERY_SHIP_HEIGHT = 7; +export const MYSTERY_SHIP_SPEED = 60; // px/sec +export const MYSTERY_SHIP_MIN_DELAY = 12000; +export const MYSTERY_SHIP_MAX_DELAY = 22000; + +export const UFO_SCORE_TABLE = [100, 50, 50, 100, 150, 100, 100, 50, 300, 100, 100, 100, 50, 150, 100]; + +export const BUNKER_COUNT = 4; +export const BUNKER_WIDTH = 22; +export const BUNKER_HEIGHT = 16; +export const BUNKER_Y = 200; + +export const ALIEN_FIRE_MIN_INTERVAL = 350; +export const ALIEN_FIRE_MAX_INTERVAL = 1000; + +// Column target sequences for the three canonical shot "personalities". +export const FIRE_PATTERNS = { + ROLLING: [4, 8, 2, 6, 10, 0, 9, 3, 7, 1, 5], + PLUNGER: [1, 7, 1, 1, 1, 4, 8, 2, 6, 10, 0, 9, 3, 7, 1], + SQUIGGLY: [5, 9, 3, 7, 1, 5, 10, 4, 8, 2, 6, 0, 9, 3, 7], +}; + +// Art Nouveau palette — jewel tones on deep indigo, gold linework throughout. +export const PALETTE = { + bgTop: '#120a24', + bgBottom: '#1d1030', + gold: '#d4af37', + goldBright: '#f3d47a', + goldDim: '#8a6a2a', + squid: '#3ea88a', + squidDark: '#215e4c', + crab: '#a56cc1', + crabDark: '#5c3670', + octopus: '#d4763f', + octopusDark: '#7a4022', + player: '#4fb8c9', + playerDark: '#245a63', + ufo: '#c0384f', + ufoDark: '#6e1c2b', + bunker: '#7a6a9a', + bunkerDark: '#3a2f52', + bulletPlayer: '#f3d47a', + bulletAlien: '#e0668a', + text: '#e8d9a8', +}; diff --git a/space-invaders/js/entities/alien.js b/space-invaders/js/entities/alien.js new file mode 100644 index 00000000..4c7dc50f --- /dev/null +++ b/space-invaders/js/entities/alien.js @@ -0,0 +1,16 @@ +import { alienTypeForRow, pointsForType } from '../constants.js'; + +export class Alien { + constructor(row, col, offsetX, offsetY) { + this.row = row; + this.col = col; + this.offsetX = offsetX; + this.offsetY = offsetY; + this.type = alienTypeForRow(row); + this.points = pointsForType(this.type); + this.isAlive = true; + this.frame = 0; + this.width = this.type === 'squid' ? 8 : this.type === 'crab' ? 11 : 12; + this.height = 8; + } +} diff --git a/space-invaders/js/entities/alienFireController.js b/space-invaders/js/entities/alienFireController.js new file mode 100644 index 00000000..10a8e08b --- /dev/null +++ b/space-invaders/js/entities/alienFireController.js @@ -0,0 +1,49 @@ +import { FIRE_PATTERNS, ALIEN_FIRE_MIN_INTERVAL, ALIEN_FIRE_MAX_INTERVAL, ALIEN_COLS } from '../constants.js'; +import { AlienBullet } from './bullet.js'; + +const PATTERN_NAMES = ['ROLLING', 'PLUNGER', 'SQUIGGLY']; + +// Models the original's deterministic, column-based alien firing rather than +// pure randomness: a shot "personality" is picked, then a sequence of target +// columns is walked, always hitting the lowest living alien in that column. +export class AlienFireController { + constructor(swarm) { + this.swarm = swarm; + this.timer = 0; + this.nextInterval = this._randomInterval(); + this.sequenceIndex = { ROLLING: 0, PLUNGER: 0, SQUIGGLY: 0 }; + } + + _randomInterval() { + return ALIEN_FIRE_MIN_INTERVAL + Math.random() * (ALIEN_FIRE_MAX_INTERVAL - ALIEN_FIRE_MIN_INTERVAL); + } + + reset() { + this.timer = 0; + this.nextInterval = this._randomInterval(); + } + + update(dt, alienBullets) { + this.timer += dt * 1000; + if (this.timer < this.nextInterval) return; + this.timer = 0; + this.nextInterval = this._randomInterval(); + + const patternName = PATTERN_NAMES[Math.floor(Math.random() * PATTERN_NAMES.length)]; + const sequence = FIRE_PATTERNS[patternName]; + const idx = this.sequenceIndex[patternName]; + const col = sequence[idx % sequence.length] % ALIEN_COLS; + this.sequenceIndex[patternName] = idx + 1; + + const shooter = this.swarm.lowestAlienInColumn(col); + if (!shooter) return; + + const pos = this.swarm.alienScreenPosition(shooter); + const bullet = new AlienBullet( + pos.x + shooter.width / 2 - 1.5, + pos.y + shooter.height, + patternName.toLowerCase(), + ); + alienBullets.push(bullet); + } +} diff --git a/space-invaders/js/entities/bullet.js b/space-invaders/js/entities/bullet.js new file mode 100644 index 00000000..32bae346 --- /dev/null +++ b/space-invaders/js/entities/bullet.js @@ -0,0 +1,56 @@ +import { PLAYER_BULLET_SPEED, ALIEN_BULLET_SPEED, CANVAS_HEIGHT, PALETTE } from '../constants.js'; + +export class PlayerBullet { + constructor(x, y) { + this.x = x; + this.y = y; + this.width = 1; + this.height = 4; + this.isAlive = true; + } + + update(dt) { + this.y -= PLAYER_BULLET_SPEED * dt; + if (this.y + this.height < 0) this.isAlive = false; + } + + render(ctx) { + ctx.fillStyle = PALETTE.bulletPlayer; + ctx.shadowColor = PALETTE.bulletPlayer; + ctx.shadowBlur = 3; + ctx.fillRect(this.x, this.y, this.width, this.height); + ctx.shadowBlur = 0; + } +} + +const ALIEN_BULLET_WAVE = { amplitude: 1.5, frequency: 10 }; + +export class AlienBullet { + constructor(x, y, style = 'rolling') { + this.x = x; + this.y = y; + this.width = 3; + this.height = 7; + this.isAlive = true; + this.style = style; // 'rolling' | 'plunger' | 'squiggly' + this.age = 0; + } + + update(dt) { + this.age += dt; + this.y += ALIEN_BULLET_SPEED * dt; + if (this.style === 'squiggly') { + this.x += Math.sin(this.age * ALIEN_BULLET_WAVE.frequency) * ALIEN_BULLET_WAVE.amplitude * dt * 10; + } + if (this.y > CANVAS_HEIGHT) this.isAlive = false; + } + + render(ctx) { + ctx.fillStyle = PALETTE.bulletAlien; + ctx.shadowColor = PALETTE.bulletAlien; + ctx.shadowBlur = 3; + const wobble = this.style === 'squiggly' ? Math.sin(this.age * 14) * 1.2 : 0; + ctx.fillRect(this.x + wobble, this.y, this.width, this.height); + ctx.shadowBlur = 0; + } +} diff --git a/space-invaders/js/entities/bunker.js b/space-invaders/js/entities/bunker.js new file mode 100644 index 00000000..78dc9943 --- /dev/null +++ b/space-invaders/js/entities/bunker.js @@ -0,0 +1,94 @@ +import { BUNKER_WIDTH, BUNKER_HEIGHT, PALETTE } from '../constants.js'; + +const DAMAGE_BLOCK = 3; // chunky erase granularity, matches the original's blocky look + +export class Bunker { + constructor(x, y) { + this.x = x; + this.y = y; + this.width = BUNKER_WIDTH; + this.height = BUNKER_HEIGHT; + this.canvas = document.createElement('canvas'); + this.canvas.width = BUNKER_WIDTH; + this.canvas.height = BUNKER_HEIGHT; + this.ctx = this.canvas.getContext('2d', { willReadFrequently: true }); + this.repair(); + } + + // Draws the pristine ornamental bunker — an Art Nouveau arch with carved + // filigree — onto the off-screen bitmap that damage will later erase from. + repair() { + const c = this.ctx; + c.clearRect(0, 0, this.width, this.height); + c.fillStyle = PALETTE.bunker; + c.strokeStyle = PALETTE.bunkerDark; + c.lineWidth = 1; + + c.beginPath(); + c.moveTo(0, this.height); + c.lineTo(0, 6); + c.quadraticCurveTo(0, 0, 6, 0); + c.lineTo(this.width - 6, 0); + c.quadraticCurveTo(this.width, 0, this.width, 6); + c.lineTo(this.width, this.height); + // notch cut from underside like the classic bunker silhouette + c.lineTo(this.width * 0.68, this.height); + c.quadraticCurveTo(this.width * 0.55, this.height - 6, this.width * 0.5, this.height - 6); + c.quadraticCurveTo(this.width * 0.45, this.height - 6, this.width * 0.32, this.height); + c.closePath(); + c.fill(); + c.stroke(); + + // Ornamental filigree veins. + c.strokeStyle = PALETTE.goldDim; + c.lineWidth = 0.5; + c.beginPath(); + c.moveTo(3, this.height - 3); + c.quadraticCurveTo(this.width / 2, 2, this.width - 3, this.height - 3); + c.stroke(); + + this.isDestroyed = false; + } + + getBounds() { + return { x: this.x, y: this.y, width: this.width, height: this.height }; + } + + // Narrow-phase impact: erase a chunky radius of pixels around the impact + // point by zeroing their alpha, then write the modified bitmap back. + applyDamage(worldX, worldY, radius = 4) { + const localX = Math.round(worldX - this.x); + const localY = Math.round(worldY - this.y); + const imageData = this.ctx.getImageData(0, 0, this.width, this.height); + const data = imageData.data; + + for (let by = -radius; by <= radius; by += DAMAGE_BLOCK) { + for (let bx = -radius; bx <= radius; bx += DAMAGE_BLOCK) { + if (bx * bx + by * by > radius * radius) continue; + for (let dy = 0; dy < DAMAGE_BLOCK; dy++) { + for (let dx = 0; dx < DAMAGE_BLOCK; dx++) { + const px = localX + bx + dx; + const py = localY + by + dy; + if (px < 0 || py < 0 || px >= this.width || py >= this.height) continue; + const idx = (py * this.width + px) * 4 + 3; + data[idx] = 0; + } + } + } + } + this.ctx.putImageData(imageData, 0, 0); + } + + // Pixel-perfect hit test used as the narrow-phase check after an AABB hit. + isSolidAt(worldX, worldY) { + const localX = Math.round(worldX - this.x); + const localY = Math.round(worldY - this.y); + if (localX < 0 || localY < 0 || localX >= this.width || localY >= this.height) return false; + const pixel = this.ctx.getImageData(localX, localY, 1, 1).data; + return pixel[3] > 0; + } + + render(ctx) { + ctx.drawImage(this.canvas, this.x, this.y); + } +} diff --git a/space-invaders/js/entities/explosion.js b/space-invaders/js/entities/explosion.js new file mode 100644 index 00000000..f594c2cc --- /dev/null +++ b/space-invaders/js/entities/explosion.js @@ -0,0 +1,21 @@ +import { drawExplosion } from '../render.js'; + +export class Explosion { + constructor(x, y, color, duration = 0.35) { + this.x = x; + this.y = y; + this.color = color; + this.age = 0; + this.duration = duration; + this.isAlive = true; + } + + update(dt) { + this.age += dt; + if (this.age >= this.duration) this.isAlive = false; + } + + render(ctx) { + drawExplosion(ctx, this); + } +} diff --git a/space-invaders/js/entities/mysteryShip.js b/space-invaders/js/entities/mysteryShip.js new file mode 100644 index 00000000..8c62f128 --- /dev/null +++ b/space-invaders/js/entities/mysteryShip.js @@ -0,0 +1,78 @@ +import { + MYSTERY_SHIP_WIDTH, MYSTERY_SHIP_HEIGHT, MYSTERY_SHIP_SPEED, CANVAS_WIDTH, + MYSTERY_SHIP_MIN_DELAY, MYSTERY_SHIP_MAX_DELAY, UFO_SCORE_TABLE, PALETTE, +} from '../constants.js'; + +export class MysteryShip { + constructor(game) { + this.game = game; + this.width = MYSTERY_SHIP_WIDTH; + this.height = MYSTERY_SHIP_HEIGHT; + this.x = 0; + this.y = 12; + this.direction = 1; + this.active = false; + this.spawnTimer = this._randomDelay(); + } + + _randomDelay() { + return MYSTERY_SHIP_MIN_DELAY + Math.random() * (MYSTERY_SHIP_MAX_DELAY - MYSTERY_SHIP_MIN_DELAY); + } + + _spawn() { + this.direction = Math.random() < 0.5 ? 1 : -1; + this.x = this.direction === 1 ? -this.width : CANVAS_WIDTH + this.width; + this.active = true; + this.game.audio.startUfoLoop(); + } + + despawn() { + this.active = false; + this.game.audio.stopUfoLoop(); + this.spawnTimer = this._randomDelay(); + } + + // Score awarded follows the original's hidden deterministic sequence based + // on total shots fired, not randomness. + scoreValue() { + return UFO_SCORE_TABLE[this.game.playerShotCount % UFO_SCORE_TABLE.length]; + } + + update(dt) { + if (!this.active) { + this.spawnTimer -= dt * 1000; + if (this.spawnTimer <= 0) this._spawn(); + return; + } + this.x += this.direction * MYSTERY_SHIP_SPEED * dt; + if (this.direction === 1 && this.x > CANVAS_WIDTH) this.despawn(); + if (this.direction === -1 && this.x + this.width < 0) this.despawn(); + } + + render(ctx) { + if (!this.active) return; + ctx.save(); + ctx.translate(this.x, this.y); + ctx.shadowColor = PALETTE.ufo; + ctx.shadowBlur = 5; + ctx.fillStyle = PALETTE.ufo; + ctx.strokeStyle = PALETTE.gold; + ctx.lineWidth = 0.6; + + ctx.beginPath(); + ctx.moveTo(1, this.height); + ctx.bezierCurveTo(-2, this.height, -2, 2, this.width / 2, 1); + ctx.bezierCurveTo(this.width + 2, 2, this.width + 2, this.height, this.width - 1, this.height); + ctx.bezierCurveTo(this.width - 3, this.height + 2, 3, this.height + 2, 1, this.height); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + // Gem-like canopy jewel, the classic Nouveau "peacock eye" motif. + ctx.fillStyle = PALETTE.goldBright; + ctx.beginPath(); + ctx.ellipse(this.width / 2, this.height - 1, 2.2, 1.6, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } +} diff --git a/space-invaders/js/entities/player.js b/space-invaders/js/entities/player.js new file mode 100644 index 00000000..b00ec452 --- /dev/null +++ b/space-invaders/js/entities/player.js @@ -0,0 +1,90 @@ +import { + PLAYER_WIDTH, PLAYER_HEIGHT, PLAYER_SPEED, CANVAS_WIDTH, CANVAS_HEIGHT, PALETTE, +} from '../constants.js'; +import { PlayerBullet } from './bullet.js'; + +export class Player { + constructor(game) { + this.game = game; + this.width = PLAYER_WIDTH; + this.height = PLAYER_HEIGHT; + this.x = (CANVAS_WIDTH - this.width) / 2; + this.y = CANVAS_HEIGHT - 24; + this.canFire = true; + this.isAlive = true; + this.hitFlashTimer = 0; + } + + update(dt, input) { + if (!this.isAlive) return; + let dx = 0; + if (input.left) dx -= 1; + if (input.right) dx += 1; + this.x += dx * PLAYER_SPEED * dt; + this.x = Math.max(2, Math.min(CANVAS_WIDTH - this.width - 2, this.x)); + + if (input.fire) this.fire(); + if (this.hitFlashTimer > 0) this.hitFlashTimer -= dt; + } + + fire() { + if (!this.canFire || !this.isAlive) return; + const bullet = new PlayerBullet(this.x + this.width / 2 - 0.5, this.y - 4); + this.game.playerBullets.push(bullet); + this.canFire = false; + this.game.playerShotCount += 1; + this.game.audio.playSound('shoot'); + } + + onBulletResolved() { + this.canFire = true; + } + + render(ctx) { + if (!this.isAlive) return; + const cx = this.x + this.width / 2; + const cy = this.y + this.height; + ctx.save(); + ctx.translate(cx, cy); + + // Art Nouveau cannon: a stylised winged/finned dart with jewel core and + // whiplash tendrils sweeping back from the hull, rendered as vector paths. + const glow = this.hitFlashTimer > 0 ? PALETTE.goldBright : PALETTE.player; + ctx.strokeStyle = PALETTE.gold; + ctx.lineWidth = 0.6; + ctx.fillStyle = glow; + ctx.shadowColor = glow; + ctx.shadowBlur = 4; + + ctx.beginPath(); + ctx.moveTo(0, -this.height); + ctx.bezierCurveTo(-2, -this.height + 2, -3, -2, -this.width / 2, 0); + ctx.lineTo(-this.width / 2 + 1, -1); + ctx.bezierCurveTo(-3, -2.5, -1.5, -3.5, 0, -this.height + 1); + ctx.bezierCurveTo(1.5, -3.5, 3, -2.5, this.width / 2 - 1, -1); + ctx.lineTo(this.width / 2, 0); + ctx.bezierCurveTo(3, -2, 2, -this.height + 2, 0, -this.height); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + // Tendril flourishes curling from either side of the hull. + ctx.strokeStyle = PALETTE.goldDim; + ctx.lineWidth = 0.5; + ctx.shadowBlur = 0; + ctx.beginPath(); + ctx.moveTo(-this.width / 2, -1); + ctx.quadraticCurveTo(-this.width / 2 - 3, -3, -this.width / 2 - 1, -6); + ctx.moveTo(this.width / 2, -1); + ctx.quadraticCurveTo(this.width / 2 + 3, -3, this.width / 2 + 1, -6); + ctx.stroke(); + + // Central jewel core. + ctx.fillStyle = PALETTE.goldBright; + ctx.beginPath(); + ctx.ellipse(0, -this.height + 3, 1.4, 1.8, 0, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + } +} diff --git a/space-invaders/js/entities/swarm.js b/space-invaders/js/entities/swarm.js new file mode 100644 index 00000000..a65243df --- /dev/null +++ b/space-invaders/js/entities/swarm.js @@ -0,0 +1,127 @@ +import { + ALIEN_ROWS, ALIEN_COLS, ALIEN_H_SPACING, ALIEN_V_SPACING, ALIEN_START_X, ALIEN_START_Y, + ALIEN_DROP_DISTANCE, CANVAS_WIDTH, BASE_MOVE_INTERVAL, MIN_MOVE_INTERVAL, +} from '../constants.js'; +import { Alien } from './alien.js'; +import { AlienFireController } from './alienFireController.js'; +import { drawAlien } from '../render.js'; + +const EDGE_MARGIN = 10; +const STEP_SIZE = 3; + +// The "Hive Mind": a single controller owning the swarm's collective position, +// direction and pace. Individual Aliens never store world coordinates — their +// screen position is always swarm-origin + fixed grid offset. +export class Swarm { + constructor(game) { + this.game = game; + this.x = 0; + this.y = 0; + this.direction = 1; + this.timer = 0; + this.animFrame = 0; + this.marchStep = 0; + this.aliens = []; + this.fireController = new AlienFireController(this); + this._buildGrid(ALIEN_START_Y); + } + + _buildGrid(startY) { + this.aliens = []; + for (let row = 0; row < ALIEN_ROWS; row++) { + for (let col = 0; col < ALIEN_COLS; col++) { + const offsetX = ALIEN_START_X + col * ALIEN_H_SPACING; + const offsetY = startY + row * ALIEN_V_SPACING; + this.aliens.push(new Alien(row, col, offsetX, offsetY)); + } + } + } + + resetForWave(startY) { + this.x = 0; + this.y = 0; + this.direction = 1; + this.timer = 0; + this.marchStep = 0; + this._buildGrid(startY); + this.fireController.reset(); + } + + get aliveCount() { + return this.aliens.reduce((n, a) => n + (a.isAlive ? 1 : 0), 0); + } + + get isDefeated() { + return this.aliveCount === 0; + } + + alienScreenPosition(alien) { + return { x: this.x + alien.offsetX, y: this.y + alien.offsetY }; + } + + lowestAlienInColumn(col) { + let best = null; + for (const a of this.aliens) { + if (!a.isAlive || a.col !== col) continue; + if (!best || a.row > best.row) best = a; + } + return best; + } + + bottomMostY() { + let maxY = -Infinity; + for (const a of this.aliens) { + if (!a.isAlive) continue; + const y = this.y + a.offsetY + a.height; + if (y > maxY) maxY = y; + } + return maxY === -Infinity ? 0 : maxY; + } + + update(dt, alienBullets) { + const total = ALIEN_ROWS * ALIEN_COLS; + const alive = this.aliveCount; + if (alive === 0) return; + + const moveInterval = Math.max(MIN_MOVE_INTERVAL, (alive / total) * BASE_MOVE_INTERVAL); + this.timer += dt * 1000; + + if (this.timer >= moveInterval) { + this.timer = 0; + this._step(); + this.game.audio.playMarchStep(this.marchStep); + this.marchStep += 1; + this.animFrame = 1 - this.animFrame; + } + + this.fireController.update(dt, alienBullets); + } + + _step() { + this.x += this.direction * STEP_SIZE; + + let leftmost = Infinity; + let rightmost = -Infinity; + for (const a of this.aliens) { + if (!a.isAlive) continue; + const left = this.x + a.offsetX; + const right = left + a.width; + if (left < leftmost) leftmost = left; + if (right > rightmost) rightmost = right; + } + if (leftmost === Infinity) return; + + if (rightmost >= CANVAS_WIDTH - EDGE_MARGIN || leftmost <= EDGE_MARGIN) { + this.y += ALIEN_DROP_DISTANCE; + this.direction *= -1; + } + } + + render(ctx) { + for (const alien of this.aliens) { + if (!alien.isAlive) continue; + const pos = this.alienScreenPosition(alien); + drawAlien(ctx, alien, pos.x, pos.y, this.animFrame); + } + } +} diff --git a/space-invaders/js/game.js b/space-invaders/js/game.js new file mode 100644 index 00000000..ea49cdcd --- /dev/null +++ b/space-invaders/js/game.js @@ -0,0 +1,165 @@ +import { + CANVAS_WIDTH, CANVAS_HEIGHT, ALIEN_START_Y, ALIEN_DROP_DISTANCE, ALIEN_MAX_DEPTH_Y, + BUNKER_COUNT, BUNKER_WIDTH, BUNKER_Y, EXTRA_LIFE_SCORE, PLAYER_START_LIVES, +} from './constants.js'; +import { InputHandler } from './input.js'; +import { AudioManager } from './audio.js'; +import { Player } from './entities/player.js'; +import { Swarm } from './entities/swarm.js'; +import { MysteryShip } from './entities/mysteryShip.js'; +import { Bunker } from './entities/bunker.js'; +import { Explosion } from './entities/explosion.js'; +import { StateMachine } from './state/stateMachine.js'; +import { createAttractState } from './state/attractState.js'; +import { createPlayState } from './state/playState.js'; +import { createGameOverState } from './state/gameOverState.js'; + +const HIGH_SCORE_KEY = 'space-invaders-nouveau-high-score'; + +class Game { + constructor(ctx) { + this.ctx = ctx; + this.input = new InputHandler(); + this.audio = new AudioManager(); + + this.score = 0; + this.highScore = Number(localStorage.getItem(HIGH_SCORE_KEY)) || 0; + this.lives = PLAYER_START_LIVES; + this.wave = 1; + this.playerShotCount = 0; + this.extraLifeAwarded = false; + + this.player = new Player(this); + this.swarm = new Swarm(this); + this.mysteryShip = new MysteryShip(this); + this.bunkers = this._createBunkers(); + this.playerBullets = []; + this.alienBullets = []; + this.explosions = []; + + this.hud = { + score: document.getElementById('score-value'), + lives: document.getElementById('lives-value'), + highScore: document.getElementById('highscore-value'), + }; + + this._updateHud(); + + const unlockOnce = () => { this.audio.unlock(); window.removeEventListener('keydown', unlockOnce); window.removeEventListener('touchstart', unlockOnce); }; + window.addEventListener('keydown', unlockOnce); + window.addEventListener('touchstart', unlockOnce); + } + + _createBunkers() { + const margin = (CANVAS_WIDTH - BUNKER_COUNT * BUNKER_WIDTH) / (BUNKER_COUNT + 1); + const bunkers = []; + for (let i = 0; i < BUNKER_COUNT; i++) { + const x = margin + i * (BUNKER_WIDTH + margin); + bunkers.push(new Bunker(Math.round(x), BUNKER_Y)); + } + return bunkers; + } + + _updateHud() { + this.hud.score.textContent = String(this.score).padStart(4, '0'); + this.hud.lives.textContent = String(Math.max(0, this.lives)); + this.hud.highScore.textContent = String(this.highScore).padStart(4, '0'); + } + + onScoreChanged() { + if (this.score > this.highScore) { + this.highScore = this.score; + localStorage.setItem(HIGH_SCORE_KEY, String(this.highScore)); + } + if (!this.extraLifeAwarded && this.score >= EXTRA_LIFE_SCORE) { + this.extraLifeAwarded = true; + this.lives += 1; + this.audio.playSound('extraLife'); + } + this._updateHud(); + } + + onPlayerHit() { + if (!this.player.isAlive) return; + this.player.isAlive = false; + this.lives -= 1; + this.player.hitFlashTimer = 0.3; + this.audio.playSound('playerExplosion'); + this._updateHud(); + this.explosions.push(new Explosion( + this.player.x + this.player.width / 2, + this.player.y + this.player.height / 2, + '#4fb8c9', + 0.6, + )); + } + + startNewGame() { + this.score = 0; + this.lives = PLAYER_START_LIVES; + this.wave = 1; + this.playerShotCount = 0; + this.extraLifeAwarded = false; + this.playerBullets = []; + this.alienBullets = []; + this.explosions = []; + this.player = new Player(this); + this.mysteryShip = new MysteryShip(this); + this.swarm.resetForWave(ALIEN_START_Y); + for (const b of this.bunkers) b.repair(); + this._updateHud(); + } + + nextWave() { + this.wave += 1; + const depth = Math.min(ALIEN_START_Y + (this.wave - 1) * ALIEN_DROP_DISTANCE, ALIEN_MAX_DEPTH_Y); + this.swarm.resetForWave(depth); + this.playerBullets = []; + this.alienBullets = []; + this.player.isAlive = true; + this.player.canFire = true; + this.player.x = (CANVAS_WIDTH - this.player.width) / 2; + for (const b of this.bunkers) b.repair(); + } +} + +function init() { + const canvas = document.getElementById('game-canvas'); + canvas.width = CANVAS_WIDTH; + canvas.height = CANVAS_HEIGHT; + const ctx = canvas.getContext('2d'); + ctx.imageSmoothingEnabled = false; + + const game = new Game(ctx); + window.__game = game; // debugging hook only + + const machine = new StateMachine({}); + // Build states after machine exists so they can call machine.transition(). + machine.states.attract = createAttractState(game, machine); + machine.states.play = createPlayState(game, machine); + machine.states.gameOver = createGameOverState(game, machine); + machine.start('attract'); + + game.input.bindTouchControls(document.getElementById('touch-controls')); + + let lastTime = performance.now(); + function loop(now) { + let deltaTime = (now - lastTime) / 1000; + lastTime = now; + deltaTime = Math.min(deltaTime, 1 / 20); // clamp to avoid spiral-of-death on tab switch back + + machine.update(deltaTime); + machine.render(ctx); + + requestAnimationFrame(loop); + } + requestAnimationFrame(loop); + + if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('./sw.js').catch(() => { /* offline support is best-effort */ }); + }); + } +} + +window.addEventListener('load', init); diff --git a/space-invaders/js/input.js b/space-invaders/js/input.js new file mode 100644 index 00000000..3cabbe3c --- /dev/null +++ b/space-invaders/js/input.js @@ -0,0 +1,66 @@ +// Polls keyboard + touch input and exposes a single, unified state object. +export class InputHandler { + constructor() { + this.left = false; + this.right = false; + this.fire = false; + // Edge-triggered convenience for menu / restart actions. Latched by the + // raw press event itself (not just polled state) so a tap shorter than + // one animation frame is never dropped. + this.firePressed = false; + this._firePressLatch = false; + + window.addEventListener('keydown', (e) => this._onKey(e, true)); + window.addEventListener('keyup', (e) => this._onKey(e, false)); + } + + _onKey(e, isDown) { + switch (e.code) { + case 'ArrowLeft': + case 'KeyA': + this.left = isDown; + e.preventDefault(); + break; + case 'ArrowRight': + case 'KeyD': + this.right = isDown; + e.preventDefault(); + break; + case 'Space': + case 'ArrowUp': + case 'KeyW': + if (isDown && !this.fire) this._firePressLatch = true; + this.fire = isDown; + e.preventDefault(); + break; + } + } + + bindTouchControls(root) { + const bind = (selector, prop) => { + const el = root.querySelector(selector); + if (!el) return; + const start = (e) => { + e.preventDefault(); + if (prop === 'fire' && !this.fire) this._firePressLatch = true; + this[prop] = true; + }; + const end = (e) => { e.preventDefault(); this[prop] = false; }; + el.addEventListener('touchstart', start, { passive: false }); + el.addEventListener('touchend', end, { passive: false }); + el.addEventListener('touchcancel', end, { passive: false }); + el.addEventListener('mousedown', start); + el.addEventListener('mouseup', end); + el.addEventListener('mouseleave', end); + }; + bind('[data-control="left"]', 'left'); + bind('[data-control="right"]', 'right'); + bind('[data-control="fire"]', 'fire'); + } + + // Call once per frame: drains the latch into a one-frame-wide pulse. + update() { + this.firePressed = this._firePressLatch; + this._firePressLatch = false; + } +} diff --git a/space-invaders/js/render.js b/space-invaders/js/render.js new file mode 100644 index 00000000..939d23a5 --- /dev/null +++ b/space-invaders/js/render.js @@ -0,0 +1,203 @@ +import { CANVAS_WIDTH, CANVAS_HEIGHT, PALETTE } from './constants.js'; + +// --- Alien sprites ----------------------------------------------------- +// Each alien type is a small stained-glass/whiplash-curve creature drawn as +// a mirrored vector path (draw the right half, reflect for the left) so the +// silhouette stays perfectly symmetric like Art Nouveau glasswork. + +function halfPath(type, frame) { + switch (type) { + case 'squid': + return (c) => { + c.moveTo(0, 0); + c.quadraticCurveTo(2, -1, 3, -3); + c.quadraticCurveTo(4, -5, 2, -6); + c.quadraticCurveTo(1, -6.5, 0, -6); + c.lineTo(0, -1); + c.quadraticCurveTo(3, 0, 4, 2); + if (frame === 0) { + c.quadraticCurveTo(4.5, 4, 3, 4); + c.quadraticCurveTo(2.5, 3, 2, 2); + } else { + c.quadraticCurveTo(5, 3, 4, 4.5); + c.quadraticCurveTo(3, 3, 2, 2); + } + c.lineTo(0, 1); + }; + case 'crab': + return (c) => { + c.moveTo(0, -6); + c.quadraticCurveTo(3, -6.5, 4, -4); + c.quadraticCurveTo(4.5, -2, 3, -1); + c.lineTo(5.5, -1); + c.quadraticCurveTo(6, 0, 5, 1); + c.lineTo(3, 1); + if (frame === 0) { + c.lineTo(4, 4); + c.quadraticCurveTo(3, 4.5, 2, 3); + } else { + c.lineTo(2, 4.5); + c.quadraticCurveTo(1.5, 4, 1, 3); + } + c.lineTo(1, 1); + c.lineTo(0, 1); + }; + default: // octopus + return (c) => { + c.moveTo(0, -6); + c.quadraticCurveTo(4, -6.5, 5, -3); + c.quadraticCurveTo(5.5, -1, 4, 0); + c.lineTo(5.5, 0.5); + if (frame === 0) { + c.lineTo(5, 4); + c.lineTo(3.5, 2); + } else { + c.lineTo(3.5, 4.5); + c.lineTo(2.5, 2); + } + c.lineTo(2, 3); + c.lineTo(0, 1); + }; + } +} + +const ALIEN_COLORS = { + squid: [PALETTE.squid, PALETTE.squidDark], + crab: [PALETTE.crab, PALETTE.crabDark], + octopus: [PALETTE.octopus, PALETTE.octopusDark], +}; + +export function drawAlien(ctx, alien, x, y, animFrame) { + const [fill, dark] = ALIEN_COLORS[alien.type]; + const scale = alien.type === 'squid' ? 1 : alien.type === 'crab' ? 1.3 : 1.4; + ctx.save(); + ctx.translate(x + alien.width / 2, y + alien.height / 2 + 1); + ctx.scale(scale, scale); + ctx.fillStyle = fill; + ctx.strokeStyle = dark; + ctx.lineWidth = 0.4; + ctx.shadowColor = fill; + ctx.shadowBlur = 2.5; + + const build = halfPath(alien.type, animFrame); + ctx.beginPath(); + build(ctx); + ctx.scale(-1, 1); + build(ctx); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + // Jewel eyes. + ctx.shadowBlur = 0; + ctx.fillStyle = PALETTE.goldBright; + ctx.beginPath(); + ctx.ellipse(-1, -2.5, 0.6, 0.6, 0, 0, Math.PI * 2); + ctx.ellipse(1, -2.5, 0.6, 0.6, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); +} + +// --- Explosion particles ------------------------------------------------ +// A blooming-flower burst: petals of light radiating and curling outward. +export function drawExplosion(ctx, explosion) { + const { x, y, age, duration, color } = explosion; + const progress = age / duration; + const petals = 8; + const radius = 2 + progress * 7; + const alpha = 1 - progress; + + ctx.save(); + ctx.translate(x, y); + ctx.globalAlpha = alpha; + ctx.fillStyle = color; + ctx.shadowColor = color; + ctx.shadowBlur = 4; + + for (let i = 0; i < petals; i++) { + const angle = (i / petals) * Math.PI * 2 + progress * 1.2; + const px = Math.cos(angle) * radius; + const py = Math.sin(angle) * radius; + ctx.beginPath(); + ctx.ellipse(px, py, 1.6 * (1 - progress * 0.4), 0.8, angle, 0, Math.PI * 2); + ctx.fill(); + } + ctx.beginPath(); + ctx.arc(0, 0, 1.4 * (1 - progress), 0, Math.PI * 2); + ctx.fillStyle = PALETTE.goldBright; + ctx.fill(); + ctx.restore(); +} + +// --- Backdrop ------------------------------------------------------------- +// Deep nebula gradient with slow-drifting whiplash curves (the defining +// Art Nouveau line — an elongated "S" swept across the field). +export function drawBackground(ctx, time) { + const grad = ctx.createLinearGradient(0, 0, 0, CANVAS_HEIGHT); + grad.addColorStop(0, PALETTE.bgTop); + grad.addColorStop(1, PALETTE.bgBottom); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + + ctx.save(); + ctx.globalAlpha = 0.12; + ctx.strokeStyle = PALETTE.gold; + ctx.lineWidth = 1.2; + const drift = (time * 0.005) % (Math.PI * 2); + for (let i = 0; i < 3; i++) { + const yBase = 40 + i * 80 + Math.sin(drift + i) * 10; + ctx.beginPath(); + ctx.moveTo(-20, yBase); + ctx.bezierCurveTo( + CANVAS_WIDTH * 0.25, yBase - 30 + Math.sin(drift + i) * 12, + CANVAS_WIDTH * 0.75, yBase + 30 - Math.sin(drift + i) * 12, + CANVAS_WIDTH + 20, yBase, + ); + ctx.stroke(); + } + ctx.restore(); + + // Faint static starfield (deterministic pseudo-random, no per-frame alloc). + ctx.save(); + ctx.fillStyle = PALETTE.gold; + for (let i = 0; i < 40; i++) { + const sx = (i * 53.7) % CANVAS_WIDTH; + const sy = (i * 97.3) % CANVAS_HEIGHT; + const tw = 0.3 + 0.3 * Math.sin(time * 0.002 + i); + ctx.globalAlpha = Math.max(0.05, tw); + ctx.fillRect(sx, sy, 1, 1); + } + ctx.restore(); +} + +// --- Ornamental frame ----------------------------------------------------- +// A gilded proscenium border wrapping the playfield, evoking a Mucha poster +// frame: corner medallions joined by flowing vine linework. +export function drawFrame(ctx) { + ctx.save(); + ctx.strokeStyle = PALETTE.gold; + ctx.lineWidth = 2; + ctx.strokeRect(1, 1, CANVAS_WIDTH - 2, CANVAS_HEIGHT - 2); + + ctx.lineWidth = 0.7; + ctx.globalAlpha = 0.8; + const corners = [ + [0, 0, 1, 1], + [CANVAS_WIDTH, 0, -1, 1], + [0, CANVAS_HEIGHT, 1, -1], + [CANVAS_WIDTH, CANVAS_HEIGHT, -1, -1], + ]; + for (const [cx, cy, sx, sy] of corners) { + ctx.save(); + ctx.translate(cx, cy); + ctx.scale(sx, sy); + ctx.beginPath(); + ctx.moveTo(2, 10); + ctx.quadraticCurveTo(2, 2, 10, 2); + ctx.moveTo(4, 14); + ctx.quadraticCurveTo(14, 14, 14, 4); + ctx.stroke(); + ctx.restore(); + } + ctx.restore(); +} diff --git a/space-invaders/js/state/attractState.js b/space-invaders/js/state/attractState.js new file mode 100644 index 00000000..b2c6ff41 --- /dev/null +++ b/space-invaders/js/state/attractState.js @@ -0,0 +1,85 @@ +import { CANVAS_WIDTH, CANVAS_HEIGHT, PALETTE } from '../constants.js'; +import { drawBackground, drawFrame, drawAlien } from '../render.js'; +import { Alien } from '../entities/alien.js'; + +const LEGEND = [ + { type: 'squid', label: '= 30 PTS' }, + { type: 'crab', label: '= 20 PTS' }, + { type: 'octopus', label: '= 10 PTS' }, +]; + +export function createAttractState(game, machine) { + let time = 0; + let blink = 0; + const legendAliens = LEGEND.map((entry, i) => { + const a = new Alien(i === 0 ? 0 : i === 1 ? 1 : 3, 0, 0, 0); + return a; + }); + + return { + enter() { + time = 0; + game.audio.stopUfoLoop(); + }, + exit() {}, + update(dt) { + time += dt; + blink += dt; + game.input.update(); + if (game.input.firePressed) { + game.audio.unlock(); + game.startNewGame(); + machine.transition('play'); + } + }, + render(ctx) { + drawBackground(ctx, time * 1000); + drawFrame(ctx); + + ctx.save(); + ctx.textAlign = 'center'; + ctx.fillStyle = PALETTE.goldBright; + ctx.shadowColor = PALETTE.gold; + ctx.shadowBlur = 6; + ctx.font = '16px Georgia, serif'; + ctx.fillText('SPACE', CANVAS_WIDTH / 2, 56); + ctx.fillText('INVADERS', CANVAS_WIDTH / 2, 74); + ctx.font = 'italic 6px Georgia, serif'; + ctx.fillStyle = PALETTE.gold; + ctx.shadowBlur = 0; + ctx.fillText('— nouveau —', CANVAS_WIDTH / 2, 84); + ctx.restore(); + + ctx.save(); + ctx.textAlign = 'left'; + ctx.font = '6px Georgia, serif'; + ctx.fillStyle = PALETTE.text; + let ly = 118; + ctx.fillText('* SCORE ADVANCE TABLE *', CANVAS_WIDTH / 2 - 52, ly - 12); + for (let i = 0; i < legendAliens.length; i++) { + const alien = legendAliens[i]; + const x = CANVAS_WIDTH / 2 - 40; + drawAlien(ctx, alien, x, ly - 3, 0); + ctx.fillText(LEGEND[i].label, x + 14, ly); + ly += 16; + } + ctx.restore(); + + if (Math.floor(blink * 2) % 2 === 0) { + ctx.save(); + ctx.textAlign = 'center'; + ctx.fillStyle = PALETTE.goldBright; + ctx.font = '8px Georgia, serif'; + ctx.fillText('PRESS FIRE TO START', CANVAS_WIDTH / 2, CANVAS_HEIGHT - 40); + ctx.restore(); + } + + ctx.save(); + ctx.textAlign = 'center'; + ctx.fillStyle = PALETTE.text; + ctx.font = '6px Georgia, serif'; + ctx.fillText('ARROWS / A-D MOVE SPACE FIRE', CANVAS_WIDTH / 2, CANVAS_HEIGHT - 20); + ctx.restore(); + }, + }; +} diff --git a/space-invaders/js/state/gameOverState.js b/space-invaders/js/state/gameOverState.js new file mode 100644 index 00000000..8ceb1271 --- /dev/null +++ b/space-invaders/js/state/gameOverState.js @@ -0,0 +1,51 @@ +import { CANVAS_WIDTH, CANVAS_HEIGHT, PALETTE } from '../constants.js'; +import { drawBackground, drawFrame } from '../render.js'; + +const MIN_DISPLAY_TIME = 1500; + +export function createGameOverState(game, machine) { + let time = 0; + let blink = 0; + + return { + enter() { + time = 0; + blink = 0; + game.audio.playSound('gameOver'); + game.audio.stopUfoLoop(); + }, + exit() {}, + update(dt) { + time += dt; + blink += dt; + game.input.update(); + if (time * 1000 > MIN_DISPLAY_TIME && game.input.firePressed) { + machine.transition('attract'); + } + }, + render(ctx) { + drawBackground(ctx, time * 1000); + drawFrame(ctx); + + ctx.save(); + ctx.textAlign = 'center'; + ctx.fillStyle = PALETTE.ufo; + ctx.shadowColor = PALETTE.gold; + ctx.shadowBlur = 6; + ctx.font = '18px Georgia, serif'; + ctx.fillText('GAME OVER', CANVAS_WIDTH / 2, 110); + + ctx.font = '8px Georgia, serif'; + ctx.fillStyle = PALETTE.text; + ctx.shadowBlur = 0; + ctx.fillText(`SCORE ${game.score}`, CANVAS_WIDTH / 2, 135); + ctx.fillText(`HIGH SCORE ${game.highScore}`, CANVAS_WIDTH / 2, 150); + + if (Math.floor(blink * 2) % 2 === 0) { + ctx.fillStyle = PALETTE.goldBright; + ctx.fillText('PRESS FIRE TO CONTINUE', CANVAS_WIDTH / 2, CANVAS_HEIGHT - 50); + } + ctx.restore(); + }, + }; +} diff --git a/space-invaders/js/state/playState.js b/space-invaders/js/state/playState.js new file mode 100644 index 00000000..e442e7e1 --- /dev/null +++ b/space-invaders/js/state/playState.js @@ -0,0 +1,128 @@ +import { CANVAS_WIDTH, PALETTE } from '../constants.js'; +import { drawBackground, drawFrame } from '../render.js'; +import { checkCollisions } from '../collision.js'; +import { Explosion } from '../entities/explosion.js'; + +const RESPAWN_DELAY = 1200; +const WAVE_CLEAR_DELAY = 1600; +const GAME_OVER_DELAY = 900; + +export function createPlayState(game, machine) { + let phase = 'running'; // running | respawning | waveClear | dying + let timer = 0; + let bgTime = 0; + + function beginRespawn() { + phase = 'respawning'; + timer = RESPAWN_DELAY; + } + + function beginGameOverTransition() { + phase = 'dying'; + timer = GAME_OVER_DELAY; + } + + function beginWaveClear() { + phase = 'waveClear'; + timer = WAVE_CLEAR_DELAY; + game.audio.playSound('waveClear'); + } + + return { + enter() { + phase = 'running'; + timer = 0; + }, + exit() { + game.audio.stopUfoLoop(); + }, + update(dt) { + bgTime += dt; + game.input.update(); + + if (phase === 'respawning') { + timer -= dt * 1000; + if (timer <= 0) { + game.player.isAlive = true; + game.player.x = (CANVAS_WIDTH - game.player.width) / 2; + game.player.canFire = true; + phase = 'running'; + } + return; + } + + if (phase === 'waveClear') { + timer -= dt * 1000; + if (timer <= 0) { + game.nextWave(); + phase = 'running'; + } + return; + } + + if (phase === 'dying') { + timer -= dt * 1000; + if (timer <= 0) machine.transition('gameOver'); + return; + } + + // --- normal gameplay update --- + game.player.update(dt, game.input); + game.swarm.update(dt, game.alienBullets); + game.mysteryShip.update(dt); + + for (const b of game.playerBullets) b.update(dt); + for (const b of game.alienBullets) b.update(dt); + for (const e of game.explosions) e.update(dt); + game.explosions = game.explosions.filter((e) => e.isAlive); + + checkCollisions(game); + + // Aliens reaching the player's line is an instant loss condition. + if (game.swarm.aliveCount > 0 && game.swarm.bottomMostY() >= game.player.y) { + game.lives = 0; + game.player.isAlive = false; + game.explosions.push(new Explosion( + game.player.x + game.player.width / 2, + game.player.y + game.player.height / 2, + PALETTE.player, + 0.6, + )); + game.audio.playSound('playerExplosion'); + } + + if (!game.player.isAlive) { + if (game.lives <= 0) beginGameOverTransition(); + else beginRespawn(); + return; + } + + if (game.swarm.isDefeated) { + beginWaveClear(); + } + }, + render(ctx) { + drawBackground(ctx, bgTime * 1000); + drawFrame(ctx); + + for (const bunker of game.bunkers) bunker.render(ctx); + game.swarm.render(ctx); + game.mysteryShip.render(ctx); + game.player.render(ctx); + for (const b of game.playerBullets) b.render(ctx); + for (const b of game.alienBullets) b.render(ctx); + for (const e of game.explosions) e.render(ctx); + + if (phase === 'waveClear') { + ctx.save(); + ctx.textAlign = 'center'; + ctx.fillStyle = PALETTE.goldBright; + ctx.font = '10px Georgia, serif'; + ctx.shadowColor = PALETTE.gold; + ctx.shadowBlur = 5; + ctx.fillText(`WAVE ${game.wave} CLEARED`, CANVAS_WIDTH / 2, 130); + ctx.restore(); + } + }, + }; +} diff --git a/space-invaders/js/state/stateMachine.js b/space-invaders/js/state/stateMachine.js new file mode 100644 index 00000000..74c6405d --- /dev/null +++ b/space-invaders/js/state/stateMachine.js @@ -0,0 +1,24 @@ +export class StateMachine { + constructor(states) { + this.states = states; + this.current = null; + } + + start(initial) { + this.transition(initial); + } + + transition(name) { + if (this.current && this.states[this.current].exit) this.states[this.current].exit(); + this.current = name; + if (this.states[this.current].enter) this.states[this.current].enter(); + } + + update(dt) { + this.states[this.current].update(dt); + } + + render(ctx) { + this.states[this.current].render(ctx); + } +} diff --git a/space-invaders/manifest.webmanifest b/space-invaders/manifest.webmanifest new file mode 100644 index 00000000..434067b2 --- /dev/null +++ b/space-invaders/manifest.webmanifest @@ -0,0 +1,17 @@ +{ + "name": "Space Invaders — Nouveau", + "short_name": "SI Nouveau", + "description": "A modern Art Nouveau reimagining of the 1978 arcade classic, with synthesized sound and a gilded, jewel-toned swarm.", + "start_url": ".", + "scope": ".", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#120a24", + "theme_color": "#120a24", + "icons": [ + { "src": "icons/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }, + { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/space-invaders/sw.js b/space-invaders/sw.js new file mode 100644 index 00000000..7ac0ea4a --- /dev/null +++ b/space-invaders/sw.js @@ -0,0 +1,55 @@ +const CACHE_NAME = 'space-invaders-nouveau-v1'; + +const ASSET_LIST = [ + './', + './index.html', + './manifest.webmanifest', + './css/style.css', + './js/game.js', + './js/constants.js', + './js/input.js', + './js/audio.js', + './js/render.js', + './js/collision.js', + './js/entities/alien.js', + './js/entities/alienFireController.js', + './js/entities/bullet.js', + './js/entities/bunker.js', + './js/entities/explosion.js', + './js/entities/mysteryShip.js', + './js/entities/player.js', + './js/entities/swarm.js', + './js/state/stateMachine.js', + './js/state/attractState.js', + './js/state/playState.js', + './js/state/gameOverState.js', + './icons/icon.svg', + './icons/icon-192.png', + './icons/icon-512.png', + './icons/icon-maskable-512.png', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSET_LIST)), + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((names) => Promise.all( + names.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name)), + )), + ); + self.clients.claim(); +}); + +// Cache-first, falling back to network — ideal for a self-contained game +// where every asset is required for the app to function at all. +self.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET') return; + event.respondWith( + caches.match(event.request).then((cached) => cached || fetch(event.request)), + ); +});