// 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 }; })();