/* ============================================================ TAB SHOOTER — Game Engine + Chrome API Integration ============================================================ */ const CANVAS_W = 420; const CANVAS_H = 600; // ---------- DOM ---------- const canvas = document.getElementById("game-canvas"); const ctx = canvas.getContext("2d"); const screens = { start: document.getElementById("start-screen"), game: document.getElementById("game-screen"), gameover: document.getElementById("gameover-screen"), empty: document.getElementById("empty-screen"), }; const els = { hsValue: document.getElementById("hs-value"), hudScore: document.getElementById("hud-score"), hudLives: document.getElementById("hud-lives"), hudTabs: document.getElementById("hud-tabs"), hudPowerups: document.getElementById("hud-powerups"), resultTabs: document.getElementById("result-tabs"), resultScore: document.getElementById("result-score"), resultHighscore: document.getElementById("result-highscore"), newRecord: document.getElementById("new-record"), }; // ---------- GAME STATE ---------- let game = null; let highscore = 0; let selectedScope = "current"; // "current" | "all" // ---------- UTILITIES ---------- function showScreen(name) { Object.values(screens).forEach((s) => s.classList.remove("visible")); screens[name].classList.add("visible"); } function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } function rand(min, max) { return Math.random() * (max - min) + min; } function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } // Load a favicon for a given URL as an Image (Chrome MV3 favicon API). // Returns a Promise that resolves to an Image or null on error. function loadFavicon(pageUrl, size = 32) { return new Promise((resolve) => { // Chrome "_favicon" API route. Requires "favicon" permission in manifest. const favUrl = `/_favicon/?pageUrl=${encodeURIComponent(pageUrl)}&size=${size}`; const img = new Image(); img.onload = () => resolve(img); img.onerror = () => resolve(null); // Slight delay safety: if it fails we just render the placeholder letter. img.src = favUrl; }); } // ---------- HIGHSCORE ---------- async function loadHighscore() { const data = await chrome.storage.local.get("highscore"); highscore = data.highscore || 0; els.hsValue.textContent = highscore; } async function saveHighscore(value) { await chrome.storage.local.set({ highscore: value }); highscore = value; } // ---------- TAB LOADING ---------- async function loadTabs(scope) { let tabs = []; if (scope === "all") { tabs = await chrome.tabs.query({}); } else { const win = await chrome.windows.getLastFocused(); tabs = await chrome.tabs.query({ windowId: win.id }); } // Filter out the popup itself (this extension page), chrome:// pages, and // devtools. We keep everything else as a valid target. const playable = tabs.filter((t) => { if (!t.id) return false; if (t.url && t.url.startsWith("chrome-extension://")) return false; if (t.url && t.url.startsWith("chrome://")) return false; if (t.url && t.url.startsWith("devtools://")) return false; return true; }); return playable; } // ============================================================ // GAME CLASS // ============================================================ class TabShooterGame { constructor(tabs) { this.tabs = tabs; this.enemies = []; this.bullets = []; this.enemyBullets = []; this.particles = []; this.powerups = []; this.floatingTexts = []; this.player = { x: CANVAS_W / 2, y: CANVAS_H - 60, w: 40, h: 36, speed: 5, cooldown: 0, lives: 3, invincible: 0, }; this.score = 0; this.tabsDestroyed = 0; this.totalTabs = tabs.length; this.keys = {}; this.mouseX = null; this.firing = false; // Power-up timers this.tripleShot = 0; this.rapidFire = 0; this.shield = 0; this.running = false; this.lastTime = 0; this.spawnTimer = 0; this.spawnIndex = 0; // Bind handlers this._onKeyDown = this.onKeyDown.bind(this); this._onKeyUp = this.onKeyUp.bind(this); this._onMouseMove = this.onMouseMove.bind(this); this._onMouseDown = this.onMouseDown.bind(this); this._onMouseUp = this.onMouseUp.bind(this); this._onMouseLeave = this.onMouseLeave.bind(this); } async init() { // Preload favicons for all tabs, then build enemy spawn queue. const loaded = await Promise.all( this.tabs.map(async (t) => { const fav = await loadFavicon(t.url || "", 32); return { tabId: t.id, title: (t.title || "Tab").slice(0, 22), url: t.url, favicon: fav, }; }) ); // Shuffle the spawn order for variety this.spawnQueue = loaded.sort(() => Math.random() - 0.5); } start() { this.running = true; this.attachInput(); this.lastTime = performance.now(); requestAnimationFrame(this.loop.bind(this)); } stop() { this.running = false; this.detachInput(); } // ---------- INPUT ---------- attachInput() { window.addEventListener("keydown", this._onKeyDown); window.addEventListener("keyup", this._onKeyUp); canvas.addEventListener("mousemove", this._onMouseMove); canvas.addEventListener("mousedown", this._onMouseDown); canvas.addEventListener("mouseup", this._onMouseUp); canvas.addEventListener("mouseleave", this._onMouseLeave); } detachInput() { window.removeEventListener("keydown", this._onKeyDown); window.removeEventListener("keyup", this._onKeyUp); canvas.removeEventListener("mousemove", this._onMouseMove); canvas.removeEventListener("mousedown", this._onMouseDown); canvas.removeEventListener("mouseup", this._onMouseUp); canvas.removeEventListener("mouseleave", this._onMouseLeave); } onKeyDown(e) { this.keys[e.key.toLowerCase()] = true; if ([" ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(e.key)) { e.preventDefault(); } } onKeyUp(e) { this.keys[e.key.toLowerCase()] = false; } onMouseMove(e) { const rect = canvas.getBoundingClientRect(); this.mouseX = e.clientX - rect.left; } onMouseDown() { this.firing = true; } onMouseUp() { this.firing = false; } onMouseLeave() { this.mouseX = null; this.firing = false; } // ---------- SPAWNING ---------- spawnEnemy() { if (this.spawnIndex >= this.spawnQueue.length) return; const data = this.spawnQueue[this.spawnIndex]; this.spawnIndex++; const w = 60; const h = 50; const x = rand(30, CANVAS_W - 30 - w); // ~25% of enemies are "shooters" const isShooter = Math.random() < 0.25; this.enemies.push({ x, y: -h - 10, w, h, vx: rand(-1.2, 1.2), vy: rand(0.5, 1.1), hp: 1, tabId: data.tabId, title: data.title, favicon: data.favicon, shooter: isShooter, shootCooldown: rand(60, 180), swayPhase: rand(0, Math.PI * 2), hit: 0, closing: false, // true once we've issued tabs.remove for it }); } // ---------- SHOOTING ---------- fireBullet() { if (this.player.cooldown > 0) return; const baseCd = this.rapidFire > 0 ? 6 : 14; this.player.cooldown = baseCd; const px = this.player.x; const py = this.player.y - this.player.h / 2; if (this.tripleShot > 0) { this.bullets.push({ x: px, y: py, vx: 0, vy: -9 }); this.bullets.push({ x: px - 4, y: py + 4, vx: -2.5, vy: -8.5 }); this.bullets.push({ x: px + 4, y: py + 4, vx: 2.5, vy: -8.5 }); } else { this.bullets.push({ x: px, y: py, vx: 0, vy: -9 }); } } // ---------- POWER-UPS ---------- maybeDropPowerup(x, y) { if (Math.random() < 0.18) { const types = ["triple", "rapid", "shield"]; this.powerups.push({ x, y, vy: 1.6, type: pick(types), rot: 0, }); } } applyPowerup(type) { if (type === "triple") { this.tripleShot = 360; // ~6s at 60fps this.pushFloatingText("TRIPLE SHOT!", "#00f0ff"); } else if (type === "rapid") { this.rapidFire = 360; this.pushFloatingText("RAPID FIRE!", "#ffaa00"); } else if (type === "shield") { this.shield = 1; // absorbs 1 hit this.pushFloatingText("SHIELD UP!", "#00ff88"); } updatePowerupHUD(this); } pushFloatingText(text, color) { this.floatingTexts.push({ text, color, x: this.player.x, y: this.player.y - 30, life: 60, }); } // ---------- DAMAGE ---------- damagePlayer() { if (this.player.invincible > 0) return; if (this.shield > 0) { this.shield = 0; this.pushFloatingText("SHIELD GONE", "#ff4444"); updatePowerupHUD(this); this.player.invincible = 40; return; } this.player.lives--; this.player.invincible = 90; updateLivesHUD(this.player.lives); // Screen-shake-ish particle burst this.spawnExplosion(this.player.x, this.player.y, "#ff4444", 20); if (this.player.lives <= 0) { this.gameOver(); } } // ---------- PARTICLES ---------- spawnExplosion(x, y, color, count = 15) { for (let i = 0; i < count; i++) { const angle = rand(0, Math.PI * 2); const speed = rand(1, 5); this.particles.push({ x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: rand(20, 45), maxLife: 45, size: rand(2, 4), color, }); } } // ---------- TAB CLOSING ---------- closeTab(tabId) { // Don't re-issue close for the same enemy. if (chrome.tabs && typeof chrome.tabs.remove === "function") { chrome.tabs.remove(tabId).catch(() => { // Tab may already be gone — ignore silently. }); } } // ---------- MAIN LOOP ---------- loop(now) { if (!this.running) return; const dt = Math.min(40, now - this.lastTime); this.lastTime = now; this.update(dt); this.render(); requestAnimationFrame(this.loop.bind(this)); } update(dt) { const p = this.player; // Movement: keyboard let dx = 0; if (this.keys["arrowleft"] || this.keys["a"]) dx -= 1; if (this.keys["arrowright"] || this.keys["d"]) dx += 1; p.x += dx * p.speed; // Movement: mouse (overrides keyboard if present) if (this.mouseX !== null) { p.x += (this.mouseX - p.x) * 0.25; } p.x = clamp(p.x, p.w / 2, CANVAS_W - p.w / 2); // Firing if (this.keys[" "] || this.firing) { this.fireBullet(); } if (p.cooldown > 0) p.cooldown--; if (p.invincible > 0) p.invincible--; if (this.tripleShot > 0) this.tripleShot--; if (this.rapidFire > 0) this.rapidFire--; if (this.tripleShot === 0 || this.rapidFire === 0 || this.shield === 0) { updatePowerupHUD(this); } // Spawn enemies gradually — keep a steady stream. this.spawnTimer--; if (this.spawnTimer <= 0 && this.spawnIndex < this.spawnQueue.length) { this.spawnEnemy(); this.spawnTimer = rand(35, 80); } // Update bullets for (let i = this.bullets.length - 1; i >= 0; i--) { const b = this.bullets[i]; b.x += b.vx; b.y += b.vy; if (b.y < -10 || b.x < -10 || b.x > CANVAS_W + 10) { this.bullets.splice(i, 1); } } // Update enemy bullets for (let i = this.enemyBullets.length - 1; i >= 0; i--) { const b = this.enemyBullets[i]; b.y += b.vy; b.x += b.vx; // Hit player? if ( Math.abs(b.x - p.x) < p.w / 2 && Math.abs(b.y - p.y) < p.h / 2 ) { this.enemyBullets.splice(i, 1); this.damagePlayer(); continue; } if (b.y > CANVAS_H + 10) this.enemyBullets.splice(i, 1); } // Update enemies for (let i = this.enemies.length - 1; i >= 0; i--) { const e = this.enemies[i]; e.swayPhase += 0.04; e.x += e.vx + Math.sin(e.swayPhase) * 0.4; e.y += e.vy; if (e.hit > 0) e.hit--; // Bounce off side walls if (e.x < 5 || e.x + e.w > CANVAS_W - 5) { e.vx *= -1; e.x = clamp(e.x, 5, CANVAS_W - 5 - e.w); } // Shooter fires if (e.shooter && !e.closing) { e.shootCooldown--; if (e.shootCooldown <= 0) { this.enemyBullets.push({ x: e.x + e.w / 2, y: e.y + e.h, vx: 0, vy: 3.2, }); e.shootCooldown = rand(90, 220); } } // Enemy reaches bottom → player takes damage, enemy loops off if (e.y > CANVAS_H) { this.enemies.splice(i, 1); continue; } // Bullet collisions for (let j = this.bullets.length - 1; j >= 0; j--) { const b = this.bullets[j]; if ( b.x > e.x && b.x < e.x + e.w && b.y > e.y && b.y < e.y + e.h ) { this.bullets.splice(j, 1); e.hp--; e.hit = 6; if (e.hp <= 0) { // Destroyed! Close the real tab. this.closeTab(e.tabId); e.closing = true; this.spawnExplosion(e.x + e.w / 2, e.y + e.h / 2, "#ff8844", 18); this.maybeDropPowerup(e.x + e.w / 2, e.y + e.h / 2); this.score += 100; this.tabsDestroyed++; updateScoreHUD(this.score); updateTabsHUD(Math.max(0, this.totalTabs - this.tabsDestroyed)); this.enemies.splice(i, 1); } break; } } } // Collide enemy directly with player for (let i = this.enemies.length - 1; i >= 0; i--) { const e = this.enemies[i]; if ( Math.abs(e.x + e.w / 2 - p.x) < p.w / 2 + e.w / 2 - 8 && Math.abs(e.y + e.h / 2 - p.y) < p.h / 2 + e.h / 2 - 8 ) { if (!e.closing) { this.closeTab(e.tabId); e.closing = true; } this.spawnExplosion(e.x + e.w / 2, e.y + e.h / 2, "#ff4444", 14); this.enemies.splice(i, 1); this.damagePlayer(); } } // Power-ups falling for (let i = this.powerups.length - 1; i >= 0; i--) { const pu = this.powerups[i]; pu.y += pu.vy; pu.rot += 0.08; if ( Math.abs(pu.x - p.x) < p.w / 2 + 12 && Math.abs(pu.y - p.y) < p.h / 2 + 12 ) { this.applyPowerup(pu.type); this.powerups.splice(i, 1); continue; } if (pu.y > CANVAS_H + 20) this.powerups.splice(i, 1); } // Particles for (let i = this.particles.length - 1; i >= 0; i--) { const pt = this.particles[i]; pt.x += pt.vx; pt.y += pt.vy; pt.vx *= 0.96; pt.vy *= 0.96; pt.life--; if (pt.life <= 0) this.particles.splice(i, 1); } // Floating texts for (let i = this.floatingTexts.length - 1; i >= 0; i--) { const ft = this.floatingTexts[i]; ft.y -= 0.6; ft.life--; if (ft.life <= 0) this.floatingTexts.splice(i, 1); } // Win condition: all tabs destroyed & no enemies left & queue empty if ( this.spawnIndex >= this.spawnQueue.length && this.enemies.length === 0 ) { this.gameOver(true); } } // ---------- RENDER ---------- render() { ctx.clearRect(0, 0, CANVAS_W, CANVAS_H); // Subtle background grid / nebula this.renderBackground(); // Bullets (player) for (const b of this.bullets) { ctx.fillStyle = "#00f0ff"; ctx.shadowColor = "#00f0ff"; ctx.shadowBlur = 10; ctx.fillRect(b.x - 2, b.y - 8, 4, 12); } ctx.shadowBlur = 0; // Enemy bullets for (const b of this.enemyBullets) { ctx.fillStyle = "#ff3366"; ctx.shadowColor = "#ff3366"; ctx.shadowBlur = 8; ctx.beginPath(); ctx.arc(b.x, b.y, 4, 0, Math.PI * 2); ctx.fill(); } ctx.shadowBlur = 0; // Enemies (tabs) for (const e of this.enemies) { this.renderEnemy(e); } // Power-ups for (const pu of this.powerups) { this.renderPowerup(pu); } // Player ship this.renderPlayer(); // Particles for (const pt of this.particles) { const alpha = pt.life / pt.maxLife; ctx.globalAlpha = alpha; ctx.fillStyle = pt.color; ctx.fillRect(pt.x - pt.size / 2, pt.y - pt.size / 2, pt.size, pt.size); } ctx.globalAlpha = 1; // Floating texts for (const ft of this.floatingTexts) { ctx.globalAlpha = clamp(ft.life / 60, 0, 1); ctx.fillStyle = ft.color; ctx.font = "bold 13px Segoe UI, Arial"; ctx.textAlign = "center"; ctx.fillText(ft.text, ft.x, ft.y); } ctx.globalAlpha = 1; } renderBackground() { // Moving starfield if (!this._stars) { this._stars = []; for (let i = 0; i < 50; i++) { this._stars.push({ x: Math.random() * CANVAS_W, y: Math.random() * CANVAS_H, s: Math.random() * 1.5 + 0.3, v: Math.random() * 0.8 + 0.2, }); } } ctx.fillStyle = "#ffffff"; for (const st of this._stars) { st.y += st.v; if (st.y > CANVAS_H) st.y = 0; ctx.globalAlpha = 0.3 + st.s * 0.3; ctx.fillRect(st.x, st.y, st.s, st.s); } ctx.globalAlpha = 1; } renderEnemy(e) { // Body ctx.save(); ctx.translate(e.x, e.y); // Glow when recently hit if (e.hit > 0) { ctx.shadowColor = "#ffffff"; ctx.shadowBlur = 15; } // Rounded rect "tab" body const r = 8; ctx.fillStyle = e.shooter ? "#3a1530" : "#1a1a3a"; ctx.strokeStyle = e.shooter ? "#ff3366" : "#444466"; ctx.lineWidth = 1.5; roundRect(ctx, 0, 0, e.w, e.h, r); ctx.fill(); ctx.stroke(); ctx.shadowBlur = 0; // Favicon if (e.favicon) { try { ctx.drawImage(e.favicon, 6, 4, 16, 16); } catch (_) { drawLetterAvatar(ctx, e.title, 6, 4, 16); } } else { drawLetterAvatar(ctx, e.title, 6, 4, 16); } // Title text (clipped) ctx.fillStyle = "#ccccdd"; ctx.font = "9px Segoe UI, Arial"; ctx.textAlign = "left"; ctx.textBaseline = "top"; const title = e.title.length > 14 ? e.title.slice(0, 13) + "…" : e.title; // Two-line layout: first line of title ctx.fillText(title, 26, 6, e.w - 30); // Shooter indicator if (e.shooter) { ctx.fillStyle = "#ff3366"; ctx.font = "8px Segoe UI, Arial"; ctx.fillText("● feindlich", 6, 26); } ctx.restore(); } renderPlayer() { const p = this.player; ctx.save(); ctx.translate(p.x, p.y); // Shield aura if (this.shield > 0) { ctx.strokeStyle = "rgba(0,255,136,0.6)"; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(0, 0, p.w / 2 + 8, 0, Math.PI * 2); ctx.stroke(); } // Invincibility flicker if (p.invincible > 0 && Math.floor(p.invincible / 4) % 2 === 0) { ctx.globalAlpha = 0.4; } // Ship body — triangle with details ctx.fillStyle = "#00f0ff"; ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(0, -p.h / 2); // nose ctx.lineTo(-p.w / 2, p.h / 2); // bottom-left ctx.lineTo(-p.w / 4, p.h / 3); ctx.lineTo(p.w / 4, p.h / 3); ctx.lineTo(p.w / 2, p.h / 2); // bottom-right ctx.closePath(); ctx.fill(); ctx.stroke(); // Cockpit ctx.fillStyle = "#ff00e5"; ctx.beginPath(); ctx.arc(0, -2, 5, 0, Math.PI * 2); ctx.fill(); // Engine glow ctx.fillStyle = "#ffaa00"; ctx.globalAlpha = 0.7 * ctx.globalAlpha; ctx.fillRect(-4, p.h / 3, 8, 6); ctx.restore(); ctx.globalAlpha = 1; } renderPowerup(pu) { ctx.save(); ctx.translate(pu.x, pu.y); ctx.rotate(pu.rot); const colors = { triple: "#00f0ff", rapid: "#ffaa00", shield: "#00ff88", }; const icons = { triple: "T", rapid: "R", shield: "S" }; const c = colors[pu.type]; ctx.fillStyle = c; ctx.shadowColor = c; ctx.shadowBlur = 12; ctx.beginPath(); ctx.arc(0, 0, 10, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; ctx.fillStyle = "#05060f"; ctx.font = "bold 12px Segoe UI, Arial"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.rotate(-pu.rot); // keep icon upright ctx.fillText(icons[pu.type], 0, 1); ctx.restore(); } // ---------- END ---------- gameOver(victory = false) { this.stop(); const isNewRecord = this.score > highscore; if (isNewRecord) { saveHighscore(this.score); } els.resultTabs.textContent = this.tabsDestroyed; els.resultScore.textContent = this.score; els.resultHighscore.textContent = Math.max(highscore, this.score); els.newRecord.classList.toggle("hidden", !isNewRecord); showScreen("gameover"); } } // ---------- HELPERS ---------- function roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } function drawLetterAvatar(ctx, title, x, y, size) { const colors = ["#ff6b6b", "#4ecdc4", "#ffe66d", "#a8e6cf", "#c7b3ff", "#ffb3d9"]; const letter = (title || "?").trim().charAt(0).toUpperCase() || "?"; const color = colors[(title || "").length % colors.length]; ctx.fillStyle = color; ctx.fillRect(x, y, size, size); ctx.fillStyle = "#ffffff"; ctx.font = "bold 10px Segoe UI, Arial"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(letter, x + size / 2, y + size / 2 + 1); } // ---------- HUD UPDATES ---------- function updateScoreHUD(score) { els.hudScore.textContent = score; } function updateLivesHUD(lives) { els.hudLives.textContent = "❤️".repeat(Math.max(0, lives)); } function updateTabsHUD(remaining) { els.hudTabs.textContent = remaining; } function updatePowerupHUD(game) { let html = ""; if (game.shield > 0) html += "🛡️"; if (game.tripleShot > 0) html += "🔫"; if (game.rapidFire > 0) html += "⚡"; els.hudPowerups.innerHTML = html; } // ============================================================ // BOOTSTRAP / SCREEN WIRING // ============================================================ async function startNewGame() { const tabs = await loadTabs(selectedScope); if (tabs.length === 0) { showScreen("empty"); return; } showScreen("game"); // Reset HUD updateScoreHUD(0); updateLivesHUD(3); updateTabsHUD(tabs.length); els.hudPowerups.innerHTML = ""; game = new TabShooterGame(tabs); await game.init(); game.start(); } // Scope buttons document.querySelectorAll(".scope-btn").forEach((btn) => { btn.addEventListener("click", () => { document.querySelectorAll(".scope-btn").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); selectedScope = btn.dataset.scope; }); }); // Start button document.getElementById("start-btn").addEventListener("click", startNewGame); // Replay document.getElementById("replay-btn").addEventListener("click", startNewGame); // Back to menu document.getElementById("menu-btn").addEventListener("click", () => { if (game) game.stop(); loadHighscore(); showScreen("start"); }); document.getElementById("empty-menu-btn").addEventListener("click", () => { loadHighscore(); showScreen("start"); }); // Init on load loadHighscore();