"use strict"; const CONFIG = { title: "Times Table Invaders", virtualWidth: 90, virtualHeight: 120, groundHeight: 16, maxDt: 0.05, leaderboardKey: "tti.leaderboard.v1", leaderboardSize: 3, starCount: 90, fadedTitleAlpha: 0.07, laserDuration: 0.18, explosion: { min: 18, max: 26, ringMs: 450 }, phases: [ { at: 0, tables: [2, 10], spawnEvery: 4.0, fallSpeed: 10 }, { at: 60, tables: [2, 4, 5, 10], spawnEvery: 3.5, fallSpeed: 12 }, { at: 150, tables: [2, 3, 4, 5, 6, 7, 8, 9, 10], spawnEvery: 3.0, fallSpeed: 14 }, { at: 300, tables: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], spawnEvery: 2.6, fallSpeed: 16 } ], ramp: { minSpawnEvery: 1.4, maxFallSpeed: 40, step: 20, speedPerStep: 0.8 } }; const CANNON = { baseW: 16, baseH: 7, turretR: 4.5, barrelW: 3.6, barrelH: 9, rimW: 4.8, rimH: 1.2, recoilOffset: 2.2 }; const UFO = { radius: 4.5, spawnY: -6, minGap: 10.5, wobbleAmpMin: 1.2, wobbleAmpMax: 2.8, wobbleSpeedMin: 1.2, wobbleSpeedMax: 2.6 }; const State = Object.freeze({ WELCOME: "welcome", PLAYING: "playing", GAME_OVER: "gameover" }); const game = { state: State.WELCOME, score: 0, elapsed: 0, animTime: 0, spawnTimer: 0, ufos: [], stars: [], houses: [], particles: [], rings: [], shotQueue: [], laser: null, cannon: { recoil: 0, angle: -Math.PI / 2 }, leaderboard: [], pendingSave: false }; const els = { canvas: document.getElementById("scene"), welcome: document.getElementById("welcome-overlay"), gameover: document.getElementById("gameover-overlay"), startBtn: document.getElementById("start-btn"), restartBtn: document.getElementById("restart-btn"), answer: document.getElementById("answer"), fireBtn: document.getElementById("fire-btn"), score: document.getElementById("score"), finalScore: document.getElementById("final-score-value"), top3Block: document.getElementById("top3-block"), muteBtn: document.getElementById("mute-btn"), nameInput: document.getElementById("player-name"), saveScoreBtn: document.getElementById("save-score-btn"), leaderboardBody: document.querySelector("#welcome-leaderboard tbody"), leaderboardEmpty: document.getElementById("leaderboard-empty"), gameoverLeaderboardBody: document.querySelector("#gameover-leaderboard tbody"), gameoverLeaderboardEmpty: document.getElementById("gameover-leaderboard-empty") }; const ctx = els.canvas.getContext("2d"); let viewScale = 1; const AudioFX = { muted: true, audioCtx: null, ensure() { if (!this.audioCtx) { const Ctor = window.AudioContext || window.webkitAudioContext; if (!Ctor) return null; this.audioCtx = new Ctor(); } if (this.audioCtx.state === "suspended") this.audioCtx.resume(); return this.audioCtx; }, laser() { if (this.muted) return; const ac = this.ensure(); if (!ac) return; const t = ac.currentTime; const osc = ac.createOscillator(); const gain = ac.createGain(); osc.type = "square"; osc.frequency.setValueAtTime(880, t); osc.frequency.exponentialRampToValueAtTime(140, t + 0.16); gain.gain.setValueAtTime(0.2, t); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.2); osc.connect(gain); gain.connect(ac.destination); osc.start(t); osc.stop(t + 0.22); }, explosion() { if (this.muted) return; const ac = this.ensure(); if (!ac) return; const t = ac.currentTime; const dur = 0.45; const buffer = ac.createBuffer(1, Math.floor(ac.sampleRate * dur), ac.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < data.length; i++) { data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / data.length, 2); } const src = ac.createBufferSource(); src.buffer = buffer; const filter = ac.createBiquadFilter(); filter.type = "lowpass"; filter.frequency.setValueAtTime(1600, t); filter.frequency.exponentialRampToValueAtTime(80, t + dur); const gain = ac.createGain(); gain.gain.setValueAtTime(0.5, t); gain.gain.exponentialRampToValueAtTime(0.001, t + dur); src.connect(filter); filter.connect(gain); gain.connect(ac.destination); src.start(t); }, gameOver() { if (this.muted) return; const ac = this.ensure(); if (!ac) return; const notes = [329.63, 261.63, 196.0, 130.81]; const step = 0.3; const t0 = ac.currentTime; notes.forEach((freq, i) => { const t = t0 + i * step; const osc = ac.createOscillator(); const gain = ac.createGain(); osc.type = "triangle"; osc.frequency.setValueAtTime(freq, t); gain.gain.setValueAtTime(0.0001, t); gain.gain.linearRampToValueAtTime(0.3, t + 0.02); gain.gain.exponentialRampToValueAtTime(0.001, t + step * 1.8); osc.connect(gain); gain.connect(ac.destination); osc.start(t); osc.stop(t + step * 2); }); } }; function resizeCanvas() { const rect = els.canvas.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; const dpr = window.devicePixelRatio || 1; const w = Math.max(1, Math.round(rect.width * dpr)); const h = Math.max(1, Math.round(rect.height * dpr)); if (w === els.canvas.width && h === els.canvas.height) return; els.canvas.width = w; els.canvas.height = h; viewScale = w / CONFIG.virtualWidth; } function show(el) { el.classList.remove("hidden"); } function hide(el) { el.classList.add("hidden"); } function loadLeaderboard() { try { const raw = localStorage.getItem(CONFIG.leaderboardKey); if (!raw) return []; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed .filter((e) => e && typeof e.name === "string" && Number.isFinite(e.score)) .map((e) => ({ name: e.name.slice(0, 16), score: Math.floor(e.score) })) .sort((a, b) => b.score - a.score) .slice(0, CONFIG.leaderboardSize); } catch (err) { return []; } } function saveLeaderboard(entries) { try { localStorage.setItem(CONFIG.leaderboardKey, JSON.stringify(entries)); } catch (err) { /* storage unavailable: game keeps working without persistence */ } } function renderLeaderboard(body, emptyEl) { body.textContent = ""; if (game.leaderboard.length === 0) { show(emptyEl); return; } hide(emptyEl); game.leaderboard.forEach((entry, index) => { const tr = document.createElement("tr"); const rankTd = document.createElement("td"); rankTd.textContent = String(index + 1); const nameTd = document.createElement("td"); nameTd.textContent = entry.name; const scoreTd = document.createElement("td"); scoreTd.textContent = String(entry.score); tr.append(rankTd, nameTd, scoreTd); body.appendChild(tr); }); } function renderAllLeaderboards() { renderLeaderboard(els.leaderboardBody, els.leaderboardEmpty); renderLeaderboard(els.gameoverLeaderboardBody, els.gameoverLeaderboardEmpty); } function updateScoreDisplay() { els.score.textContent = String(game.score); } // A score makes the top 3 if it is positive and either the board is not full // or it beats the current lowest entry (strictly). function qualifiesForLeaderboard(score) { if (score <= 0) return false; if (game.leaderboard.length < CONFIG.leaderboardSize) return true; const lowest = game.leaderboard[game.leaderboard.length - 1].score; return score > lowest; } // Insert the finished score at its correct rank, evicting the lowest entry so // the board never exceeds leaderboardSize, then persist and dismiss the popup. // Guarded by state + pendingSave so a rapid double Enter/click saves once only. function saveScore() { if (game.state !== State.GAME_OVER || !game.pendingSave) return; const name = els.nameInput.value.trim().slice(0, 16); if (!name) { els.nameInput.focus(); return; } const list = game.leaderboard.concat({ name, score: game.score }); list.sort((a, b) => b.score - a.score); const top = list.slice(0, CONFIG.leaderboardSize); game.leaderboard = top; saveLeaderboard(top); renderAllLeaderboards(); game.pendingSave = false; hide(els.top3Block); } function setControlsEnabled(enabled) { els.answer.disabled = !enabled; els.fireBtn.disabled = !enabled; } function enterWelcome() { show(els.welcome); hide(els.gameover); setControlsEnabled(false); renderAllLeaderboards(); } function enterPlaying() { hide(els.welcome); hide(els.gameover); setControlsEnabled(true); els.answer.focus(); } function enterGameOver() { setControlsEnabled(false); els.finalScore.textContent = String(game.score); if (qualifiesForLeaderboard(game.score)) { game.pendingSave = true; els.nameInput.value = ""; show(els.top3Block); els.nameInput.focus(); } else { game.pendingSave = false; hide(els.top3Block); } renderAllLeaderboards(); show(els.gameover); } const stateEnter = { [State.WELCOME]: enterWelcome, [State.PLAYING]: enterPlaying, [State.GAME_OVER]: enterGameOver }; function setState(next) { if (next === game.state) return; game.state = next; stateEnter[next](); } function startGame() { generateScenery(); game.score = 0; game.elapsed = 0; game.spawnTimer = 0; game.ufos.length = 0; game.particles.length = 0; game.rings.length = 0; game.shotQueue.length = 0; game.laser = null; game.cannon.recoil = 0; game.cannon.angle = -Math.PI / 2; game.pendingSave = false; updateScoreDisplay(); setState(State.PLAYING); } function endGame() { AudioFX.gameOver(); setState(State.GAME_OVER); } function submitAnswer() { if (game.state !== State.PLAYING) return; const raw = els.answer.value; els.answer.value = ""; els.answer.focus(); game.cannon.recoil = 1; const value = parseInt(raw, 10); const matches = Number.isFinite(value) ? game.ufos.filter((u) => u.result === value).sort((a, b) => b.y - a.y) : []; if (matches.length > 0) { for (const ufo of matches) game.shotQueue.push(ufo); } else if (!game.laser) { game.cannon.angle = -Math.PI / 2; const tip = getCannonTip(); game.laser = { origin: { x: tip.x, y: tip.y }, target: null, t: 0 }; AudioFX.laser(); } } let firePulseTimer = null; function pulseFireButton() { els.fireBtn.classList.add("btn-pressed"); if (firePulseTimer !== null) clearTimeout(firePulseTimer); firePulseTimer = setTimeout(() => { els.fireBtn.classList.remove("btn-pressed"); firePulseTimer = null; }, 130); } function wireInput() { els.answer.addEventListener("input", () => { els.answer.value = els.answer.value.replace(/\D/g, "").slice(0, 3); }); els.answer.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); submitAnswer(); pulseFireButton(); } }); els.fireBtn.addEventListener("click", submitAnswer); } function wireOverlays() { els.startBtn.addEventListener("click", startGame); els.restartBtn.addEventListener("click", startGame); els.saveScoreBtn.addEventListener("click", saveScore); els.nameInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); saveScore(); } }); els.muteBtn.addEventListener("click", () => { AudioFX.muted = !AudioFX.muted; els.muteBtn.classList.toggle("muted", AudioFX.muted); els.muteBtn.setAttribute("aria-pressed", String(AudioFX.muted)); }); } let lastTime = null; function frame(timeMs) { if (lastTime === null) lastTime = timeMs; const dt = Math.min(CONFIG.maxDt, Math.max(0, (timeMs - lastTime) / 1000)); lastTime = timeMs; update(dt); draw(); requestAnimationFrame(frame); } function update(dt) { game.animTime += dt; updateEffects(dt); if (game.state !== State.PLAYING) return; game.elapsed += dt; processShotQueue(); game.spawnTimer -= dt; if (game.spawnTimer <= 0) { const phase = currentPhase(); const diff = currentDifficulty(phase); const spawned = spawnUfo(); game.spawnTimer = spawned ? diff.spawnEvery * (0.8 + Math.random() * 0.4) : 0.6; } const groundTop = CONFIG.virtualHeight - CONFIG.groundHeight; for (const ufo of game.ufos) { ufo.y += ufo.speed * dt; ufo.x = ufo.baseX + Math.sin(game.elapsed * ufo.wobbleSpeed + ufo.wobblePhase) * ufo.wobbleAmp; } for (const ufo of game.ufos) { if (ufo.y + UFO.radius >= groundTop) { endGame(); return; } } } function updateEffects(dt) { if (game.cannon.recoil > 0) { game.cannon.recoil = Math.max(0, game.cannon.recoil - dt * 4); } if (game.laser) { game.laser.t += dt; if (game.laser.t >= CONFIG.laserDuration) { const target = game.laser.target; game.laser = null; if (target && game.state === State.PLAYING && game.ufos.includes(target)) { explodeUfo(target); } } } else { const rest = -Math.PI / 2; const diff = rest - game.cannon.angle; if (Math.abs(diff) > 0.001) { game.cannon.angle += diff * Math.min(1, dt * 10); } else { game.cannon.angle = rest; } } for (let i = game.particles.length - 1; i >= 0; i--) { const pt = game.particles[i]; pt.life -= dt; if (pt.life <= 0) { game.particles.splice(i, 1); continue; } pt.x += pt.vx * dt; pt.y += pt.vy * dt; } for (let i = game.rings.length - 1; i >= 0; i--) { const ring = game.rings[i]; ring.t += dt; if (ring.t >= ring.duration) game.rings.splice(i, 1); } } function processShotQueue() { if (game.laser) return; let target = null; while (game.shotQueue.length > 0) { const candidate = game.shotQueue.shift(); if (game.ufos.includes(candidate)) { target = candidate; break; } } if (!target) return; const pivot = getCannonPivot(); game.cannon.angle = Math.atan2(target.y - pivot.y, target.x - pivot.x); const tip = getCannonTip(); game.laser = { origin: { x: tip.x, y: tip.y }, target, t: 0 }; game.cannon.recoil = 1; AudioFX.laser(); } const EXPLOSION_COLORS = ["#ffd76a", "#ff9d5c", "#8ee7ff", "#ffffff", "#b18cff"]; function explodeUfo(ufo) { const idx = game.ufos.indexOf(ufo); if (idx !== -1) game.ufos.splice(idx, 1); AudioFX.explosion(); game.score += 1; updateScoreDisplay(); const count = CONFIG.explosion.min + Math.floor(Math.random() * (CONFIG.explosion.max - CONFIG.explosion.min + 1)); for (let i = 0; i < count; i++) { const angle = Math.random() * Math.PI * 2; const speed = 6 + Math.random() * 16; const life = 0.35 + Math.random() * 0.35; game.particles.push({ x: ufo.x, y: ufo.y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, r: 0.3 + Math.random() * 0.6, color: pick(EXPLOSION_COLORS), life, maxLife: life }); } game.rings.push({ x: ufo.x, y: ufo.y, t: 0, duration: CONFIG.explosion.ringMs / 1000 }); } function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } function cartoonFont(size) { return 'bold ' + size + 'px "Comic Sans MS", "Chalkboard SE", "Marker Felt", "Segoe UI", sans-serif'; } function traceRoundRect(x, y, w, h, r) { const rr = Math.min(r, w / 2, h / 2); ctx.beginPath(); ctx.moveTo(x + rr, y); ctx.lineTo(x + w - rr, y); ctx.arcTo(x + w, y, x + w, y + rr, rr); ctx.lineTo(x + w, y + h - rr); ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr); ctx.lineTo(x + rr, y + h); ctx.arcTo(x, y + h, x, y + h - rr, rr); ctx.lineTo(x, y + rr); ctx.arcTo(x, y, x + rr, y, rr); ctx.closePath(); } function generateStars() { const skyBottom = CONFIG.virtualHeight - CONFIG.groundHeight; game.stars = []; for (let i = 0; i < CONFIG.starCount; i++) { const r = 0.25 + Math.random() * 0.45; game.stars.push({ x: Math.random() * CONFIG.virtualWidth, y: 1 + Math.random() * (skyBottom - 3), r, baseAlpha: 0.35 + Math.random() * 0.5, phase: Math.random() * Math.PI * 2, speed: 0.6 + Math.random() * 2.2, bright: r > 0.58 }); } } const HOUSE_BODIES = ["#e8d5b5", "#d9b38c", "#c98f75", "#b56576"]; const HOUSE_ROOFS = ["#8d5b4f", "#7f4a3c", "#9c6644", "#6d597a"]; function generateHouses() { const count = 3 + Math.floor(Math.random() * 3); const houses = []; let attempts = 0; while (houses.length < count && attempts < 80) { attempts++; const w = 8 + Math.random() * 5; const x = 2 + Math.random() * (CONFIG.virtualWidth - 4 - w); if (houses.some((o) => Math.abs(o.x - x) < o.w / 2 + w / 2 + 3)) continue; houses.push({ x, w, h: 6 + Math.random() * 4, roofH: 3 + Math.random() * 2, body: pick(HOUSE_BODIES), roof: pick(HOUSE_ROOFS) }); } game.houses = houses; } function generateScenery() { generateStars(); generateHouses(); } function currentPhase() { let phase = CONFIG.phases[0]; for (const p of CONFIG.phases) { if (game.elapsed >= p.at) phase = p; } return phase; } // Post-phase-4 gradual ramp: once the final phase begins, fall speed eases up // toward CONFIG.ramp.maxFallSpeed and the spawn interval eases down toward // CONFIG.ramp.minSpawnEvery. The pace is set by speedPerStep per `step` seconds, // so one full ramp window = (cap - base) / speedPerStep * step. function currentDifficulty(phase) { const last = CONFIG.phases[CONFIG.phases.length - 1]; if (phase !== last) return { fallSpeed: phase.fallSpeed, spawnEvery: phase.spawnEvery }; const into = Math.max(0, game.elapsed - last.at); const total = ((CONFIG.ramp.maxFallSpeed - last.fallSpeed) / CONFIG.ramp.speedPerStep) * CONFIG.ramp.step; const p = total > 0 ? Math.min(1, into / total) : 1; return { fallSpeed: Math.min(last.fallSpeed + (CONFIG.ramp.maxFallSpeed - last.fallSpeed) * p, CONFIG.ramp.maxFallSpeed), spawnEvery: Math.max(CONFIG.ramp.minSpawnEvery, last.spawnEvery - (last.spawnEvery - CONFIG.ramp.minSpawnEvery) * p) }; } function spawnUfo() { const phase = currentPhase(); const diff = currentDifficulty(phase); const table = pick(phase.tables); const other = 2 + Math.floor(Math.random() * 11); const pair = Math.random() < 0.5 ? [table, other] : [other, table]; const a = pair[0]; const b = pair[1]; const margin = UFO.radius + 1; for (let attempt = 0; attempt < 8; attempt++) { const amp = UFO.wobbleAmpMin + Math.random() * (UFO.wobbleAmpMax - UFO.wobbleAmpMin); const lo = margin + amp; const hi = CONFIG.virtualWidth - margin - amp; const baseX = lo + Math.random() * (hi - lo); if (game.ufos.some((u) => u.y < 20 && Math.abs(u.baseX - baseX) < UFO.minGap)) continue; game.ufos.push({ a, b, result: a * b, baseX, x: baseX, y: UFO.spawnY, speed: diff.fallSpeed, wobbleAmp: amp, wobblePhase: Math.random() * Math.PI * 2, wobbleSpeed: UFO.wobbleSpeedMin + Math.random() * (UFO.wobbleSpeedMax - UFO.wobbleSpeedMin) }); return true; } return false; } function drawSky() { const w = CONFIG.virtualWidth; const h = CONFIG.virtualHeight; const sky = ctx.createLinearGradient(0, 0, 0, h); sky.addColorStop(0, "#070b21"); sky.addColorStop(1, "#1c2452"); ctx.fillStyle = sky; ctx.fillRect(0, 0, w, h); } function drawFadedTitle() { const maxW = CONFIG.virtualWidth - 8; const lines = ["TIMES TABLE", "INVADERS"]; ctx.save(); ctx.globalAlpha = CONFIG.fadedTitleAlpha; ctx.fillStyle = "#ffffff"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; let y = 24; for (const line of lines) { let size = 9; ctx.font = cartoonFont(size); const tw = ctx.measureText(line).width; if (tw > maxW) size *= maxW / tw; ctx.font = cartoonFont(size); ctx.fillText(line, CONFIG.virtualWidth / 2, y); y += size * 1.4; } ctx.restore(); } function drawStars() { const t = game.animTime; ctx.fillStyle = "#dfe6ff"; for (const s of game.stars) { const twinkle = 0.5 + 0.5 * Math.sin(t * s.speed + s.phase); ctx.globalAlpha = s.baseAlpha * (0.3 + 0.7 * twinkle); ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill(); if (s.bright) { const len = s.r * (1.6 + 1.4 * twinkle); ctx.strokeStyle = "#dfe6ff"; ctx.lineWidth = 0.12; ctx.beginPath(); ctx.moveTo(s.x - len, s.y); ctx.lineTo(s.x + len, s.y); ctx.moveTo(s.x, s.y - len); ctx.lineTo(s.x, s.y + len); ctx.stroke(); } } ctx.globalAlpha = 1; } function drawGround() { const w = CONFIG.virtualWidth; const h = CONFIG.virtualHeight; const top = h - CONFIG.groundHeight; const grass = ctx.createLinearGradient(0, top, 0, h); grass.addColorStop(0, "#3fae5a"); grass.addColorStop(1, "#237a38"); ctx.fillStyle = grass; ctx.fillRect(0, top, w, CONFIG.groundHeight); ctx.fillStyle = "rgba(255, 255, 255, 0.14)"; ctx.fillRect(0, top, w, 0.5); } function drawHouse(house) { const groundTop = CONFIG.virtualHeight - CONFIG.groundHeight; const bodyTop = groundTop - house.h; ctx.fillStyle = house.body; ctx.fillRect(house.x, bodyTop, house.w, house.h); ctx.fillStyle = house.roof; ctx.beginPath(); ctx.moveTo(house.x - 1, bodyTop); ctx.lineTo(house.x + house.w / 2, bodyTop - house.roofH); ctx.lineTo(house.x + house.w + 1, bodyTop); ctx.closePath(); ctx.fill(); const dw = Math.min(2.4, house.w * 0.3); const dh = Math.min(3.6, house.h * 0.55); ctx.fillStyle = "#5d4127"; ctx.fillRect(house.x + house.w / 2 - dw / 2, groundTop - dh, dw, dh); const ww = Math.min(2, house.w * 0.26); ctx.fillStyle = "#ffe9a8"; ctx.fillRect(house.x + house.w * 0.14, bodyTop + house.h * 0.28, ww, ww); } function getCannonPivot() { const baseTop = CONFIG.virtualHeight - CANNON.baseH; return { x: CONFIG.virtualWidth / 2, y: baseTop - CANNON.turretR * 0.55 }; } function getCannonTip() { const pivot = getCannonPivot(); const len = CANNON.barrelH - game.cannon.recoil * CANNON.recoilOffset; return { x: pivot.x + Math.cos(game.cannon.angle) * len, y: pivot.y + Math.sin(game.cannon.angle) * len }; } function drawCannon() { const pivot = getCannonPivot(); const h = CONFIG.virtualHeight; const baseTop = h - CANNON.baseH; const recoil = game.cannon.recoil * CANNON.recoilOffset; ctx.fillStyle = "#3d4668"; ctx.beginPath(); ctx.moveTo(pivot.x - CANNON.baseW / 2, h); ctx.lineTo(pivot.x - CANNON.baseW / 2 + 2, baseTop); ctx.lineTo(pivot.x + CANNON.baseW / 2 - 2, baseTop); ctx.lineTo(pivot.x + CANNON.baseW / 2, h); ctx.closePath(); ctx.fill(); ctx.save(); ctx.translate(pivot.x, pivot.y); ctx.rotate(game.cannon.angle + Math.PI / 2); const barrel = ctx.createLinearGradient(-CANNON.barrelW / 2, 0, CANNON.barrelW / 2, 0); barrel.addColorStop(0, "#7d8ac0"); barrel.addColorStop(0.5, "#c3cdf2"); barrel.addColorStop(1, "#66739f"); ctx.fillStyle = barrel; traceRoundRect(-CANNON.barrelW / 2, -CANNON.barrelH + recoil + CANNON.rimH, CANNON.barrelW, CANNON.barrelH - CANNON.rimH, 1.2); ctx.fill(); ctx.fillStyle = "#aab6e0"; traceRoundRect(-CANNON.rimW / 2, -CANNON.barrelH + recoil, CANNON.rimW, CANNON.rimH, 0.5); ctx.fill(); ctx.restore(); ctx.fillStyle = "#57648f"; ctx.beginPath(); ctx.arc(pivot.x, pivot.y, CANNON.turretR, 0, Math.PI * 2); ctx.fill(); } function drawUfo(ufo) { const x = ufo.x; const y = ufo.y; const r = UFO.radius; const domeR = r * 0.58; const dome = ctx.createLinearGradient(0, y - domeR, 0, y); dome.addColorStop(0, "rgba(170, 235, 255, 0.95)"); dome.addColorStop(1, "rgba(70, 130, 210, 0.5)"); ctx.fillStyle = dome; ctx.beginPath(); ctx.arc(x, y - r * 0.08, domeR, Math.PI, 0); ctx.closePath(); ctx.fill(); ctx.strokeStyle = "rgba(255, 255, 255, 0.75)"; ctx.lineWidth = 0.3; ctx.beginPath(); ctx.arc(x - domeR * 0.1, y - r * 0.08, domeR * 0.6, Math.PI * 1.15, Math.PI * 1.75); ctx.stroke(); const body = ctx.createLinearGradient(0, y - r * 0.3, 0, y + r * 0.42); body.addColorStop(0, "#e6ecff"); body.addColorStop(0.55, "#9fb0e8"); body.addColorStop(1, "#5a699e"); ctx.fillStyle = body; ctx.beginPath(); ctx.ellipse(x, y + r * 0.06, r, r * 0.36, 0, 0, Math.PI * 2); ctx.fill(); const t = game.animTime; for (let i = -1; i <= 1; i++) { const on = Math.sin(t * 4 + ufo.wobblePhase + i * 2.1) > 0; ctx.fillStyle = on ? "#ffd76a" : "#6d6236"; ctx.beginPath(); ctx.arc(x + i * r * 0.52, y + r * 0.14, 0.45, 0, Math.PI * 2); ctx.fill(); } const label = ufo.a + " \u00d7 " + ufo.b; ctx.font = cartoonFont(2.3); const tw = ctx.measureText(label).width; const pw = Math.max(tw + 2.4, r * 1.1); const ph = 3.1; const py = y + r * 0.42 + 0.9; traceRoundRect(x - pw / 2, py, pw, ph, 1.4); ctx.fillStyle = "rgba(8, 12, 30, 0.85)"; ctx.fill(); ctx.strokeStyle = "rgba(150, 180, 255, 0.65)"; ctx.lineWidth = 0.18; ctx.stroke(); ctx.fillStyle = "#ffffff"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(label, x, py + ph / 2 + 0.08); } function drawLaser() { const laser = game.laser; if (!laser) return; let end; if (laser.target && game.ufos.includes(laser.target)) { end = { x: laser.target.x, y: laser.target.y }; } else { end = { x: laser.origin.x, y: -2 }; } const fade = Math.max(0, 1 - laser.t / CONFIG.laserDuration); ctx.save(); ctx.lineCap = "round"; ctx.strokeStyle = "rgba(120, 220, 255, " + 0.3 * fade + ")"; ctx.lineWidth = 1.8; ctx.beginPath(); ctx.moveTo(laser.origin.x, laser.origin.y); ctx.lineTo(end.x, end.y); ctx.stroke(); ctx.strokeStyle = "rgba(255, 255, 255, " + 0.95 * fade + ")"; ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(laser.origin.x, laser.origin.y); ctx.lineTo(end.x, end.y); ctx.stroke(); const fr = 0.6 + 1.4 * fade; const flash = ctx.createRadialGradient(laser.origin.x, laser.origin.y, 0, laser.origin.x, laser.origin.y, fr); flash.addColorStop(0, "rgba(255, 255, 255, " + 0.9 * fade + ")"); flash.addColorStop(1, "rgba(140, 220, 255, 0)"); ctx.fillStyle = flash; ctx.beginPath(); ctx.arc(laser.origin.x, laser.origin.y, fr, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } function drawEffects() { for (const ring of game.rings) { const p = ring.t / ring.duration; ctx.strokeStyle = "rgba(180, 230, 255, " + 0.7 * (1 - p) + ")"; ctx.lineWidth = 0.5 * (1 - p) + 0.1; ctx.beginPath(); ctx.arc(ring.x, ring.y, 1 + p * 7, 0, Math.PI * 2); ctx.stroke(); } for (const pt of game.particles) { const p = Math.max(0, pt.life / pt.maxLife); ctx.globalAlpha = p; ctx.fillStyle = pt.color; ctx.beginPath(); ctx.arc(pt.x, pt.y, pt.r * (0.5 + 0.5 * p), 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; } function draw() { const w = CONFIG.virtualWidth; const h = CONFIG.virtualHeight; ctx.setTransform(viewScale, 0, 0, viewScale, 0, 0); drawSky(); drawFadedTitle(); drawStars(); drawGround(); for (const house of game.houses) drawHouse(house); for (const ufo of game.ufos) drawUfo(ufo); drawLaser(); drawCannon(); drawEffects(); } function wireResize() { if (typeof ResizeObserver !== "undefined") { new ResizeObserver(resizeCanvas).observe(els.canvas); } window.addEventListener("resize", resizeCanvas); } function init() { resizeCanvas(); generateScenery(); wireResize(); wireInput(); wireOverlays(); game.leaderboard = loadLeaderboard(); updateScoreDisplay(); enterWelcome(); requestAnimationFrame(frame); } init();