From acaf91cfa9335cbcced006a8615e4aec3c7389c0 Mon Sep 17 00:00:00 2001 From: "MR.NOBODY" <126796695+TacticalReader@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:41:29 +0530 Subject: [PATCH] Update app.js --- 6-space-game/solution/app.js | 1111 ++++++++++++++++++++-------------- 1 file changed, 657 insertions(+), 454 deletions(-) diff --git a/6-space-game/solution/app.js b/6-space-game/solution/app.js index 05fe7d47..c2658eec 100644 --- a/6-space-game/solution/app.js +++ b/6-space-game/solution/app.js @@ -1,454 +1,657 @@ -// @ts-check -class EventEmitter { - constructor() { - this.listeners = {}; - } - - on(message, listener) { - if (!this.listeners[message]) { - this.listeners[message] = []; - } - this.listeners[message].push(listener); - } - - emit(message, payload = null) { - if (this.listeners[message]) { - this.listeners[message].forEach((l) => l(message, payload)); - } - } -} - -class GameObject { - constructor(x, y) { - this.x = x; - this.y = y; - this.dead = false; - this.type = ''; - this.width = 0; - this.height = 0; - this.img = undefined; - } - - draw(ctx) { - ctx.drawImage(this.img, this.x, this.y, this.width, this.height); - } - - rectFromGameObject() { - return { - top: this.y, - left: this.x, - bottom: this.y + this.height, - right: this.x + this.width, - }; - } -} - -class Hero extends GameObject { - constructor(x, y) { - super(x, y); - (this.width = 99), (this.height = 75); - this.type = 'Hero'; - this.speed = { x: 0, y: 0 }; - } -} - -class Laser extends GameObject { - constructor(x, y) { - super(x, y); - this.width = 9; - this.height = 33; - this.type = 'Laser'; - let id = setInterval(() => { - if (!this.dead) { - this.y = this.y > 0 ? this.y - 20 : this.y; - if (this.y <= 0) { - this.dead = true; - } - } else { - clearInterval(id); - } - }, 100); - } -} - -class Explosion extends GameObject { - constructor(x, y, img) { - super(x, y); - this.img = img; - this.type = 'Explosion'; - (this.width = 56 * 2), (this.height = 54 * 2); - setTimeout(() => { - this.dead = true; - }, 300); - } -} - -class Monster extends GameObject { - constructor(x, y) { - super(x, y); - this.type = 'Monster'; - (this.width = 98), (this.height = 50); - let id = setInterval(() => { - if (!this.dead) { - this.y = this.y < HEIGHT ? this.y + 30 : this.y; - if (this.y >= HEIGHT - this.height) { - this.dead = true; - eventEmitter.emit('MONSTER_OUT_OF_BOUNDS'); - } - } else { - clearInterval(id); - } - }, 1500); - } -} - -const Messages = { - MONSTER_OUT_OF_BOUNDS: 'MONSTER_OUT_OF_BOUNDS', - HERO_SPEED_LEFT: 'HERO_MOVING_LEFT', - HERO_SPEED_RIGHT: 'HERO_MOVING_RIGHT', - HERO_SPEED_ZERO: 'HERO_SPEED_ZERO', - HERO_FIRE: 'HERO_FIRE', - GAME_END_LOSS: 'GAME_END_LOSS', - GAME_END_WIN: 'GAME_END_WIN', - COLLISION_MONSTER_LASER: 'COLLISION_MONSTER_LASER', - COLLISION_MONSTER_HERO: 'COLLISION_MONSTER_HERO', - KEY_EVENT_UP: 'KEY_EVENT_UP', - KEY_EVENT_DOWN: 'KEY_EVENT_DOWN', - KEY_EVENT_LEFT: 'KEY_EVENT_LEFT', - KEY_EVENT_RIGHT: 'KEY_EVENT_RIGHT', - GAME_START: 'GAME_START', -}; - -class Game { - constructor() { - this.points = 0; - this.life = 3; - this.end = false; - this.ready = false; - - eventEmitter.on(Messages.MONSTER_OUT_OF_BOUNDS, () => { - hero.dead = true; - }); - eventEmitter.on(Messages.HERO_SPEED_LEFT, () => { - hero.speed.x = -10; - hero.img = heroImgLeft; - }); - eventEmitter.on(Messages.HERO_SPEED_RIGHT, () => { - hero.speed.x = 10; - hero.img = heroImgRight; - }); - eventEmitter.on(Messages.HERO_SPEED_ZERO, () => { - hero.speed = { x: 0, y: 0 }; - if (game.life === 3) { - hero.img = heroImg; - } else { - hero.img = heroImgDamaged; - } - }); - eventEmitter.on(Messages.HERO_FIRE, () => { - if (coolDown === 0) { - let l = new Laser(hero.x + 45, hero.y - 30); - l.img = laserRedImg; - gameObjects.push(l); - cooling(); - } - }); - eventEmitter.on(Messages.GAME_END_LOSS, (_, gameLoopId) => { - game.end = true; - displayMessage('You died... - Press [Enter] to start the game Captain Pew Pew'); - clearInterval(gameLoopId); - }); - - eventEmitter.on(Messages.GAME_END_WIN, (_, gameLoopId) => { - game.end = true; - displayMessage('Victory!!! Pew Pew... - Press [Enter] to start a new game Captain Pew Pew', 'green'); - clearInterval(gameLoopId); - }); - eventEmitter.on(Messages.COLLISION_MONSTER_LASER, (_, { first: laser, second: monster }) => { - laser.dead = true; - monster.dead = true; - this.points += 100; - - gameObjects.push(new Explosion(monster.x, monster.y, laserRedShot)); - }); - eventEmitter.on(Messages.COLLISION_MONSTER_HERO, (_, { monster: m, id }) => { - game.life--; - if (game.life === 0) { - hero.dead = true; - eventEmitter.emit(Messages.GAME_END_LOSS, id); - gameObjects.push(new Explosion(hero.x, hero.y, laserGreenShot)); - } - hero.img = heroImgDamaged; - m.dead = true; - gameObjects.push(new Explosion(m.x, m.y, laserRedShot)); - }); - eventEmitter.on(Messages.KEY_EVENT_UP, () => { - hero.y = hero.y > 0 ? hero.y - 5 : hero.y; - }); - eventEmitter.on(Messages.KEY_EVENT_DOWN, () => { - hero.y = hero.y < HEIGHT ? hero.y + 5 : hero.y; - }); - eventEmitter.on(Messages.KEY_EVENT_LEFT, () => { - hero.x = hero.x > 0 ? hero.x - 10 : hero.x; - }); - eventEmitter.on(Messages.KEY_EVENT_RIGHT, () => { - hero.x = hero.x < WIDTH ? hero.x + 10 : hero.x; - }); - eventEmitter.on(Messages.GAME_START, () => { - if (game.ready && game.end) { - // assets loaded - runGame(); - } - }); - } -} - -const eventEmitter = new EventEmitter(); -const hero = new Hero(0, 0); -const WIDTH = 1024; -const HEIGHT = 768; -let gameObjects = []; -let laserRedImg; -let laserRedShot; -let laserGreenShot; -let canvas; -let ctx; -let heroImg; -let heroImgLeft; -let heroImgRight; -let heroImgDamaged; -let lifeImg; -let monsterImg; - -let coolDown = 0; - -const game = new Game(); - -function loadTexture(path) { - return new Promise((resolve) => { - const img = new Image(); - img.src = path; - img.onload = () => { - resolve(img); - }; - }); -} - -function rectFromGameObject(go) { - return { - top: go.y, - left: go.x, - bottom: go.y + go.height, - right: go.x + go.width, - }; -} - -function intersectRect(r1, r2) { - return !(r2.left > r1.right || r2.right < r1.left || r2.top > r1.bottom || r2.bottom < r1.top); -} - -function draw(ctx, objects) { - objects.forEach((obj) => { - obj.draw(ctx); - }); -} - -let onKeyDown = function (e) { - console.log(e.keyCode); - switch (e.keyCode) { - case 37: - case 39: - case 38: - case 40: // Arrow keys - case 32: - e.preventDefault(); - break; // Space - default: - break; // do not block other keys - } -}; - -window.addEventListener('keydown', onKeyDown); -window.addEventListener('keydown', (e) => { - switch (e.keyCode) { - case 37: - // if left - eventEmitter.emit(Messages.HERO_SPEED_LEFT); - break; - case 39: - eventEmitter.emit(Messages.HERO_SPEED_RIGHT); - break; - } -}); - -// TODO make message driven -window.addEventListener('keyup', (evt) => { - eventEmitter.emit(Messages.HERO_SPEED_ZERO); - if (evt.key === 'ArrowUp') { - eventEmitter.emit(Messages.KEY_EVENT_UP); - } else if (evt.key === 'ArrowDown') { - eventEmitter.emit(Messages.KEY_EVENT_DOWN); - } else if (evt.key === 'ArrowLeft') { - eventEmitter.emit(Messages.KEY_EVENT_LEFT); - } else if (evt.key === 'ArrowRight') { - eventEmitter.emit(Messages.KEY_EVENT_RIGHT); - } else if (evt.keyCode === 32) { - // space - eventEmitter.emit(Messages.HERO_FIRE); - } else if (evt.key === 'Enter') { - eventEmitter.emit(Messages.GAME_START); - } -}); - -function cooling() { - coolDown = 500; - let id = setInterval(() => { - coolDown -= 100; - if (coolDown === 0) { - clearInterval(id); - } - }, 100); -} - -function displayGameScore(message) { - ctx.font = '30px Arial'; - ctx.fillStyle = 'red'; - ctx.textAlign = 'right'; - ctx.fillText(message, canvas.width - 90, canvas.height - 30); -} - -function displayLife() { - // should show tree ships.. 94 * 3 - const START_X = canvas.width - 150 - 30; - for (let i = 0; i < game.life; i++) { - ctx.drawImage(lifeImg, START_X + (i + 1) * 35, canvas.height - 90); - } -} - -function displayMessage(message, color = 'red') { - ctx.font = '30px Arial'; - ctx.fillStyle = color; - ctx.textAlign = 'center'; - ctx.fillText(message, canvas.width / 2, canvas.height / 2); -} - -function createMonsters(monsterImg) { - // 98 * 5 canvas.width - (98*5 /2) - const MONSTER_TOTAL = 5; - const MONSTER_WIDTH = MONSTER_TOTAL * 98; - const START_X = (canvas.width - MONSTER_WIDTH) / 2; - const STOP_X = START_X + MONSTER_WIDTH; - - for (let x = START_X; x < STOP_X; x += 98) { - for (let y = 0; y < 50 * 5; y += 50) { - gameObjects.push(new Monster(x, y)); - } - } - - gameObjects.forEach((go) => { - go.img = monsterImg; - }); -} - -function createHero(heroImg) { - hero.dead = false; - hero.img = heroImg; - hero.y = (canvas.height / 4) * 3; - hero.x = canvas.width / 2; - gameObjects.push(hero); -} - -function checkGameState(gameLoopId) { - const monsters = gameObjects.filter((go) => go.type === 'Monster'); - if (hero.dead) { - eventEmitter.emit(Messages.GAME_END_LOSS, gameLoopId); - } else if (monsters.length === 0) { - eventEmitter.emit(Messages.GAME_END_WIN); - } - - // update hero position - if (hero.speed.x !== 0) { - hero.x += hero.speed.x; - } - - const lasers = gameObjects.filter((go) => go.type === 'Laser'); - // laser hit something - lasers.forEach((l) => { - monsters.forEach((m) => { - if (intersectRect(l.rectFromGameObject(), m.rectFromGameObject())) { - eventEmitter.emit(Messages.COLLISION_MONSTER_LASER, { - first: l, - second: m, - }); - } - }); - }); - - // hero hit monster - monsters.forEach((m) => { - if (intersectRect(m.rectFromGameObject(), hero.rectFromGameObject())) { - eventEmitter.emit(Messages.COLLISION_MONSTER_HERO, { monster: m, id: gameLoopId }); - } - }); - - gameObjects = gameObjects.filter((go) => !go.dead); -} - -function runGame() { - gameObjects = []; - game.life = 3; - game.points = 0; - game.end = false; - - createMonsters(monsterImg); - createHero(heroImg); - - let gameLoopId = setInterval(() => { - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = 'black'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - displayGameScore('Score: ' + game.points); - displayLife(); - checkGameState(gameLoopId); - draw(ctx, gameObjects); - }, 100); -} - -window.onload = async () => { - canvas = document.getElementById('myCanvas'); - ctx = canvas.getContext('2d'); - - heroImg = await loadTexture('spaceArt/png/player.png'); - heroImgLeft = await loadTexture('spaceArt/png/playerLeft.png'); - heroImgRight = await loadTexture('spaceArt/png/playerRight.png'); - heroImgDamaged = await loadTexture('spaceArt/png/playerDamaged.png'); - monsterImg = await loadTexture('spaceArt/png/enemyShip.png'); - laserRedImg = await loadTexture('spaceArt/png/laserRed.png'); - laserRedShot = await loadTexture('spaceArt/png/laserRedShot.png'); - laserGreenShot = await loadTexture('spaceArt/png/laserGreenShot.png'); - lifeImg = await loadTexture('spaceArt/png/life.png'); - - game.ready = true; - game.end = true; - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = 'black'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - displayMessage('Press [Enter] to start the game Captain Pew Pew', 'blue'); - - // CHECK draw 5 * 5 monsters - // CHECK move monsters down 1 step per 0.5 second - // CHECK if monster collide with hero, destroy both, display loose text - // CHECK if monster reach MAX, destroy hero, loose text - // TODO add explosion when laser hits monster, should render for <=300ms - // TODO add specific texture when moving left or right - // TODO take damage when a meteor moves into you - // TODO add meteor, meteors can damage ships - // TODO add UFO after all monsters are down, UFO can fire back - // TODO start random green laser from an enemy and have it go to HEIGHT, if collide with hero then deduct point - - // CHECK draw bullet - // CHECK , bullet should be destroyed at top - // CHECK space should produce bullet, bullet should move 2 step per second - // CHECK if bullet collide with monster, destroy both - // CHECK if bullet rect intersect with monster rect then it is colliding.. -}; +// Space Game — app.js + +(() => { + // Element references + const $ = (sel, ctx = document) => ctx.querySelector(sel); + const $$ = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel)); + + const canvas = $('#myCanvas'); + const ctx = canvas.getContext('2d'); + + const btnStart = $('[data-action="start"]'); + const btnPause = $('[data-action="pause"]'); + const btnReset = $('[data-action="reset"]'); + const btnMute = $('[data-action="mute"]'); + const btnFullscreen = $('[data-action="fullscreen"]'); + const btnOpenSettings = $('[data-action="open-settings"]'); + + const outScore = $('#score'); + const outLevel = $('#level'); + const outLives = $('#lives'); + const outShield = $('#shield'); + const outFps = $('#fps'); + const outTime = $('#time'); + + const settingsDialog = $('#settingsDialog'); + const pauseDialog = $('#pauseDialog'); + + const inputBgUrl = $('#bgImageUrl'); + const inputSpriteUpload = $('#spriteUpload'); + + const sfxShoot = $('#sfxShoot'); + const sfxExplosion = $('#sfxExplosion'); + const bgMusic = $('#bgMusic'); + + // Game config and state + const BASE_WIDTH = 1024; + const BASE_HEIGHT = 768; + + const settings = { + difficulty: 'normal', // easy | normal | hard | insane + graphics: 'medium', // low | medium | high | ultra + musicVolume: 0.6, // 0..1 + sfxVolume: 0.8, // 0..1 + controlScheme: 'wasd', // wasd | arrows | custom + muted: false + }; + + const difficultyTable = { + easy: { enemyRate: 1.6, enemySpeed: 60, maxEnemies: 12 }, + normal: { enemyRate: 1.2, enemySpeed: 90, maxEnemies: 16 }, + hard: { enemyRate: 0.9, enemySpeed: 120, maxEnemies: 22 }, + insane: { enemyRate: 0.7, enemySpeed: 160, maxEnemies: 28 } + }; + + const graphicsTable = { + low: { glow: false, blur: 0, starLayers: 1 }, + medium:{ glow: false, blur: 0, starLayers: 2 }, + high: { glow: true, blur: 0, starLayers: 3 }, + ultra: { glow: true, blur: 2, starLayers: 4 } + }; + + let state = { + running: false, + paused: false, + lastTs: 0, + accTime: 0, + fps: 0, + frameCount: 0, + frameTimer: 0, + score: 0, + level: 1, + lives: 3, + shield: 100, + timeSec: 0, + enemySpawnTimer: 0, + fireCooldown: 0, + specialCooldown: 0, + }; + + // Entities + const player = { + x: BASE_WIDTH / 2, + y: BASE_HEIGHT - 140, + r: 18, + angle: -Math.PI / 2, + speed: 240, + vx: 0, + vy: 0 + }; + + const bullets = []; + const enemies = []; + const particles = []; + + // Background image + let bgImage = null; + inputBgUrl?.addEventListener('change', () => { + const url = inputBgUrl.value.trim(); + if (!url) { bgImage = null; return; } + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { bgImage = img; }; + img.onerror = () => { bgImage = null; }; + img.src = url; + }); + + // High-DPI canvas scaling while drawing in CSS pixels + function resizeCanvas() { + const dpr = Math.max(1, window.devicePixelRatio || 1); + canvas.width = Math.floor(BASE_WIDTH * dpr); + canvas.height = Math.floor(BASE_HEIGHT * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + resizeCanvas(); + window.addEventListener('resize', resizeCanvas); + + // Input handling + const keys = new Set(); + const keyMap = { + up: ['w', 'W', 'ArrowUp'], + down: ['s', 'S', 'ArrowDown'], + left: ['a', 'A', 'ArrowLeft'], + right: ['d', 'D', 'ArrowRight'], + fire: [' ', 'Spacebar'], + special: ['Shift'], + pause: ['p', 'P'], + fullscreen: ['f', 'F'], + }; + + window.addEventListener('keydown', (e) => { + keys.add(e.key); + if (keyMap.pause.includes(e.key)) { + e.preventDefault(); + togglePause(); + } + if (keyMap.fullscreen.includes(e.key)) { + e.preventDefault(); + toggleFullscreen(); + } + if (keyMap.fire.includes(e.key)) e.preventDefault(); + }, { passive: false }); + + window.addEventListener('keyup', (e) => { + keys.delete(e.key); + }); + + // Buttons + btnStart?.addEventListener('click', startGame); + btnPause?.addEventListener('click', togglePause); + btnReset?.addEventListener('click', resetGame); + btnMute?.addEventListener('click', toggleMute); + btnFullscreen?.addEventListener('click', toggleFullscreen); + btnOpenSettings?.addEventListener('click', () => { + settingsDialog?.showModal(); + }); + + // Settings dialog apply/close + settingsDialog?.addEventListener('close', () => { + if (settingsDialog.returnValue === 'apply') { + const diff = $('#difficulty').value; + const gfx = $('#graphics').value; + const musicV = Number($('#musicVolume').value) / 100; + const sfxV = Number($('#sfxVolume').value) / 100; + const scheme = $('#controlScheme').value; + settings.difficulty = diff; + settings.graphics = gfx; + settings.musicVolume = clamp01(musicV); + settings.sfxVolume = clamp01(sfxV); + settings.controlScheme = scheme; + applyVolumes(); + } + }); + + // Pause dialog buttons (form method="dialog" maps button value) + pauseDialog?.addEventListener('close', () => { + const val = pauseDialog.returnValue; + if (val === 'resume') { + togglePause(false); + } else if (val === 'restart') { + resetGame(); + startGame(); + } + }); + + // Fullscreen events for UI sync + document.addEventListener('fullscreenchange', () => { + // Could update icon or button state here if desired + }); + + // Audio helpers + function applyVolumes() { + const vMusic = settings.muted ? 0 : settings.musicVolume; + const vSfx = settings.muted ? 0 : settings.sfxVolume; + if (bgMusic) bgMusic.volume = clamp01(vMusic); + if (sfxShoot) sfxShoot.volume = clamp01(vSfx); + if (sfxExplosion) sfxExplosion.volume = clamp01(vSfx); + } + applyVolumes(); + + function toggleMute() { + settings.muted = !settings.muted; + btnMute?.setAttribute('aria-pressed', String(settings.muted)); + applyVolumes(); + } + + // Game lifecycle + function startGame() { + if (!state.running) { + state.running = true; + state.paused = false; + state.lastTs = 0; + // Try starting BG music on user gesture; ignore if blocked + bgMusic?.play?.().catch(() => {/* Autoplay may be blocked until further interaction */}); + requestAnimationFrame(loop); + } + } + + function togglePause(force = null) { + if (!state.running) return; + const next = (force === null) ? !state.paused : !!force; + state.paused = next; + if (state.paused) { + pauseDialog?.showModal(); + } else { + if (pauseDialog?.open) pauseDialog.close('resume'); + // Reset lastTs so dt spike doesn’t occur + state.lastTs = 0; + requestAnimationFrame(loop); + } + } + + function resetGame() { + state = { + running: false, + paused: false, + lastTs: 0, accTime: 0, fps: 0, frameCount: 0, frameTimer: 0, + score: 0, level: 1, lives: 3, shield: 100, timeSec: 0, + enemySpawnTimer: 0, fireCooldown: 0, specialCooldown: 0, + }; + bullets.length = 0; + enemies.length = 0; + particles.length = 0; + player.x = BASE_WIDTH / 2; + player.y = BASE_HEIGHT - 140; + player.vx = 0; player.vy = 0; player.angle = -Math.PI / 2; + updateHUD(); + } + + // Fullscreen + async function toggleFullscreen() { + try { + if (!document.fullscreenElement) { + await (canvas.requestFullscreen?.() || $('#play')?.requestFullscreen?.()); + } else { + await document.exitFullscreen?.(); + } + } catch { + // ignore + } + } + + // Loop + function loop(ts) { + if (!state.running || state.paused) return; + if (!state.lastTs) state.lastTs = ts; + const dt = Math.min(0.033, (ts - state.lastTs) / 1000); // clamp 33ms + state.lastTs = ts; + + // FPS calc + state.frameTimer += dt; + state.frameCount++; + if (state.frameTimer >= 0.5) { + state.fps = Math.round(state.frameCount / state.frameTimer); + state.frameCount = 0; + state.frameTimer = 0; + } + + // Update world + update(dt); + // Render + render(); + + requestAnimationFrame(loop); + } + + // Update + function update(dt) { + state.timeSec += dt; + + // Controls + const scheme = settings.controlScheme; + const up = (scheme === 'arrows') ? keys.has('ArrowUp') : (keys.has('w') || keys.has('W') || keys.has('ArrowUp')); + const down = (scheme === 'arrows') ? keys.has('ArrowDown') : (keys.has('s') || keys.has('S') || keys.has('ArrowDown')); + const left = (scheme === 'arrows') ? keys.has('ArrowLeft') : (keys.has('a') || keys.has('A') || keys.has('ArrowLeft')); + const right = (scheme === 'arrows') ? keys.has('ArrowRight') : (keys.has('d') || keys.has('D') || keys.has('ArrowRight')); + const firing = keys.has(' ') || keys.has('Spacebar'); + const special = keys.has('Shift'); + + const accel = player.speed; + player.vx = (right ? 1 : 0) - (left ? 1 : 0); + player.vy = (down ? 1 : 0) - (up ? 1 : 0); + + const mag = Math.hypot(player.vx, player.vy) || 1; + player.vx = (player.vx / mag) * accel; + player.vy = (player.vy / mag) * accel; + + player.x += player.vx * dt; + player.y += player.vy * dt; + + // Boundaries + player.x = clamp(player.x, player.r + 4, BASE_WIDTH - player.r - 4); + player.y = clamp(player.y, player.r + 4, BASE_HEIGHT - player.r - 4); + + // Aim angle toward movement if moving + if (mag > 1) { + player.angle = Math.atan2(player.vy, player.vx); + } + + // Firing + state.fireCooldown -= dt; + if (firing && state.fireCooldown <= 0) { + fireBullet(); + state.fireCooldown = 0.15; + } + + // Special (smart-bomb) + state.specialCooldown -= dt; + if (special && state.specialCooldown <= 0 && state.shield >= 25) { + state.specialCooldown = 6; + state.shield = Math.max(0, state.shield - 25); + // Clear enemies in a wave + enemies.splice(0, enemies.length); + explode(player.x, player.y, 28, 'special'); + sfxExplosion?.cloneNode(true)?.play?.().catch(()=>{}); + } + + // Enemies spawn + const diff = difficultyTable[settings.difficulty] || difficultyTable.normal; + state.enemySpawnTimer -= dt; + if (state.enemySpawnTimer <= 0 && enemies.length < diff.maxEnemies) { + spawnEnemy(diff.enemySpeed); + state.enemySpawnTimer = diff.enemyRate; + } + + // Update bullets + for (let i = bullets.length - 1; i >= 0; i--) { + const b = bullets[i]; + b.x += Math.cos(b.a) * b.speed * dt; + b.y += Math.sin(b.a) * b.speed * dt; + b.life -= dt; + if (b.life <= 0 || b.x < -20 || b.x > BASE_WIDTH + 20 || b.y < -20 || b.y > BASE_HEIGHT + 20) { + bullets.splice(i, 1); + } + } + + // Update enemies + for (let i = enemies.length - 1; i >= 0; i--) { + const e = enemies[i]; + const dx = player.x - e.x; + const dy = player.y - e.y; + const ang = Math.atan2(dy, dx); + e.x += Math.cos(ang) * e.speed * dt; + e.y += Math.sin(ang) * e.speed * dt; + e.a = ang; + + // Bullet collision + let hit = false; + for (let j = bullets.length - 1; j >= 0; j--) { + const b = bullets[j]; + if (dist2(e.x, e.y, b.x, b.y) < (e.r + b.r) * (e.r + b.r)) { + bullets.splice(j, 1); + hit = true; + break; + } + } + if (hit) { + state.score += Math.round(25 + Math.random() * 25); + explode(e.x, e.y, 10, 'enemy'); + sfxExplosion?.cloneNode(true)?.play?.().catch(()=>{}); + enemies.splice(i, 1); + continue; + } + + // Player collision + if (dist2(e.x, e.y, player.x, player.y) < (e.r + player.r) * (e.r + player.r)) { + state.shield -= 25; + explode(player.x, player.y, 16, 'player'); + sfxExplosion?.cloneNode(true)?.play?.().catch(()=>{}); + enemies.splice(i, 1); + if (state.shield <= 0) { + state.lives -= 1; + state.shield = 100; + if (state.lives <= 0) { + gameOver(); + return; + } + } + } + } + + // Particles + for (let i = particles.length - 1; i >= 0; i--) { + const p = particles[i]; + p.x += p.vx * dt; + p.y += p.vy * dt; + p.life -= dt; + if (p.life <= 0) particles.splice(i, 1); + } + + // Level scaling + const newLevel = 1 + Math.floor(state.timeSec / 30); + if (newLevel !== state.level) state.level = newLevel; + + updateHUD(); + } + + function updateHUD() { + outScore.textContent = String(state.score); + outLevel.textContent = String(state.level); + outLives.textContent = String(state.lives); + outShield.textContent = `${Math.max(0, Math.min(100, Math.round(state.shield)))}%`; + outFps.textContent = String(state.fps); + outTime.textContent = toMMSS(state.timeSec); + } + + // Render + function render() { + // Clear + ctx.clearRect(0, 0, BASE_WIDTH, BASE_HEIGHT); + + // Background + if (bgImage) { + drawCoverImage(bgImage, 0, 0, BASE_WIDTH, BASE_HEIGHT); + } else { + // Starfield layers + const starLayers = graphicsTable[settings.graphics]?.starLayers ?? 2; + for (let i = 0; i < starLayers; i++) { + const density = 40 + i * 30; + drawStars(density, i * 1000); + } + } + + // Effects + const gfx = graphicsTable[settings.graphics] || graphicsTable.medium; + if (gfx.blur) { + ctx.filter = `blur(${gfx.blur}px)`; + } + + // Enemies + ctx.save(); + for (const e of enemies) { + ctx.save(); + ctx.translate(e.x, e.y); + ctx.rotate(e.a); + ctx.fillStyle = '#ff6b6b'; + if (gfx.glow) { + ctx.shadowColor = '#ff6b6b'; + ctx.shadowBlur = 12; + } + ctx.beginPath(); + ctx.arc(0, 0, e.r, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + ctx.restore(); + + // Bullets + ctx.save(); + for (const b of bullets) { + ctx.save(); + ctx.translate(b.x, b.y); + ctx.rotate(b.a); + ctx.fillStyle = '#a78bfa'; + if (gfx.glow) { + ctx.shadowColor = '#a78bfa'; + ctx.shadowBlur = 10; + } + ctx.fillRect(-2, -8, 4, 16); + ctx.restore(); + } + ctx.restore(); + + // Player + ctx.save(); + ctx.translate(player.x, player.y); + ctx.rotate(player.angle); + ctx.fillStyle = '#5ac8fa'; + if (gfx.glow) { + ctx.shadowColor = '#5ac8fa'; + ctx.shadowBlur = 14; + } + drawTriangle(0, 0, player.r); + ctx.restore(); + + // Particles + ctx.save(); + for (const p of particles) { + ctx.globalAlpha = Math.max(0, p.life / p.maxLife); + ctx.fillStyle = p.color; + ctx.fillRect(p.x, p.y, p.s, p.s); + } + ctx.globalAlpha = 1; + ctx.restore(); + + // Reset filter + ctx.filter = 'none'; + } + + // Helpers: drawing + function drawTriangle(x, y, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x - r * 0.7, y - r * 0.9); + ctx.lineTo(x - r * 0.7, y + r * 0.9); + ctx.closePath(); + ctx.fill(); + } + + function drawStars(count, seed) { + // Simple deterministic star positions based on seed + const rand = mulberry32(Math.floor(state.timeSec * 60) + seed); + ctx.save(); + ctx.fillStyle = 'rgba(255,255,255,0.9)'; + for (let i = 0; i < count; i++) { + const x = Math.floor(rand() * BASE_WIDTH); + const y = Math.floor(rand() * BASE_HEIGHT); + const s = rand() * 2; + ctx.fillRect(x, y, s, s); + } + ctx.restore(); + } + + function drawCoverImage(img, x, y, w, h) { + const iw = img.naturalWidth || img.width; + const ih = img.naturalHeight || img.height; + const scale = Math.max(w / iw, h / ih); + const dw = iw * scale; + const dh = ih * scale; + const dx = x + (w - dw) / 2; + const dy = y + (h - dh) / 2; + ctx.drawImage(img, dx, dy, dw, dh); + } + + // Spawn and effects + function spawnEnemy(speed) { + // Spawn at a random edge + const edge = Math.floor(Math.random() * 4); + let x = 0, y = 0; + if (edge === 0) { x = Math.random() * BASE_WIDTH; y = -20; } + if (edge === 1) { x = BASE_WIDTH + 20; y = Math.random() * BASE_HEIGHT; } + if (edge === 2) { x = Math.random() * BASE_WIDTH; y = BASE_HEIGHT + 20; } + if (edge === 3) { x = -20; y = Math.random() * BASE_HEIGHT; } + enemies.push({ + x, y, r: 16 + Math.random() * 8, a: 0, + speed: speed * (0.8 + Math.random() * 0.4) + }); + } + + function fireBullet() { + bullets.push({ + x: player.x + Math.cos(player.angle) * (player.r + 6), + y: player.y + Math.sin(player.angle) * (player.r + 6), + a: player.angle, + speed: 540, + life: 1.2, + r: 6 + }); + // Low-latency clone for overlapping sounds + sfxShoot?.cloneNode(true)?.play?.().catch(()=>{}); + } + + function explode(x, y, count, type) { + for (let i = 0; i < count; i++) { + const a = Math.random() * Math.PI * 2; + const sp = 60 + Math.random() * 180; + particles.push({ + x, y, + vx: Math.cos(a) * sp, + vy: Math.sin(a) * sp, + s: 2 + Math.random() * 3, + life: 0.6 + Math.random() * 0.6, + maxLife: 1.2, + color: type === 'player' ? '#ff6b6b' : (type === 'special' ? '#5ac8fa' : '#a78bfa') + }); + } + } + + // Game over + function gameOver() { + state.running = false; + // Persist score + try { + const key = 'spaceGameScores'; + const list = JSON.parse(localStorage.getItem(key) || '[]'); + list.push({ initials: 'YOU', score: state.score, t: Date.now() }); + list.sort((a, b) => b.score - a.score); + localStorage.setItem(key, JSON.stringify(list.slice(0, 10))); + renderScores(list.slice(0, 10)); + } catch { + // ignore + } + // Show pause dialog as "Restart" + if (!pauseDialog?.open) pauseDialog?.showModal(); + } + + function renderScores(list) { + const ol = $('#scores'); + if (!ol) return; + ol.innerHTML = ''; + list.forEach(item => { + const li = document.createElement('li'); + const pretty = item.score.toLocaleString(); + li.dataset.initials = item.initials || 'YOU'; + li.dataset.score = String(item.score); + li.textContent = `${li.dataset.initials} — ${pretty}`; + ol.appendChild(li); + }); + } + + // Utilities + function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); } + function clamp01(v) { return clamp(v, 0, 1); } + function dist2(x1, y1, x2, y2) { const dx = x1 - x2, dy = y1 - y2; return dx*dx + dy*dy; } + function toMMSS(sec) { + const s = Math.floor(sec); + const m = Math.floor(s / 60); + const r = s % 60; + return `${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}`; + } + function mulberry32(a) { + return function() { + let t = a += 0x6D2B79F5; + t = Math.imul(t ^ t >>> 15, t | 1); + t ^= t + Math.imul(t ^ t >>> 7, t | 61); + return ((t ^ t >>> 14) >>> 0) / 4294967296; + } + } + + // Initial scores render + try { + const key = 'spaceGameScores'; + const list = JSON.parse(localStorage.getItem(key) || '[]'); + renderScores(list.slice(0, 10)); + } catch { + // ignore + } + + // Expose minimal API for external hooking if needed + window.SpaceGame = { + start: startGame, + pause: () => togglePause(true), + resume: () => togglePause(false), + reset: resetGame, + fullscreen: toggleFullscreen, + mute: toggleMute + }; +})();