commit 0f566b02ac36bd9c661db03ef39f566b5df404c8 Author: Janik Dietz Date: Tue Aug 4 07:13:45 2026 +0200 feat: initialize Tab Shooter Chrome extension game diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5dc35ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# ---- Betriebssystem / Editor ---- +.DS_Store +Thumbs.db +desktop.ini +*.swp +*.swo +*~ + +# ---- Editor-/IDE-Verzeichnisse ---- +.vscode/ +.idea/ +*.sublime-project +*.sublime-workspace + +# ---- ZCode-spezifisch ---- +.zcode/ + +# ---- Logs & Temp ---- +*.log +*.tmp +tmp/ + +# ---- Node (falls später Tooling dazukommt) ---- +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# ---- Build-Output / Packete ---- +dist/ +build/ +*.zip +*.crx # fertiges gepacktes Extension-Bundle + +# ---- Python (für generate_icons.py) ---- +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +env/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..8fc63a6 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# 🚀 Tab Shooter + +A small Chrome browser game that turns your open tabs into targets. +Pilot a spaceship, blast the tabs you no longer need, and they really get closed. + +> Für alle, die zu viele Tabs offen haben. Schieß sie ab — und sie verschwinden wirklich. + +--- + +## 📦 Installation (in Chrome laden) + +1. Chrome öffnen → `chrome://extensions` aufrufen. +2. Oben rechts den **Entwicklermodus** (Developer mode) einschalten. +3. Auf **„Entpackt laden“** (Load unpacked) klicken. +4. Den Ordner `tab-shooter` auswählen. +5. Das Tab-Shooter-Icon 🚀 erscheint in der Toolbar. Anklicken → Spiel startet. + +> Nach Code-Änderungen in `popup.js`/`manifest.json` die Extension auf +> `chrome://extensions` einfach neu laden (🔄-Button). + +--- + +## 🎮 Spielen + +1. Auf das Toolbar-Icon klicken. +2. Wählen: **Aktuelles Fenster** oder **Alle Fenster**. +3. **SPIEL STARTEN** drücken. +4. Deine echten Tabs erscheinen als gegnerische Raumschiffe (mit echtem Favicon + Titel). +5. Schießen — jeder Tab, den du triffst, wird in Chrome **wirklich geschlossen**. + +### Steuerung + +| Aktion | Tastatur | Maus | +|---|---|---| +| Bewegen | `←` `→` oder `A` `D` | Maus bewegen | +| Schießen | `Leertaste` | Klick (halten) | + +### Features + +- **Echte Tabs als Gegner** – Favicon & Titel, verknüpft via Tab-ID. +- **Bewegte Gegner** – manche sind „feindlich" und schießen zurück. +- **Power-Ups** (zufälliger Drop): + - 🔫 **Triple Shot** – drei Schüße gleichzeitig (6 s) + - ⚡ **Rapid Fire** – schnellere Schussfolge (6 s) + - 🛡️ **Shield** – absorbiert einen Treffer +- **Score & Highscore** – gespeichert via `chrome.storage.local`. +- **3 Leben**, danach Game Over mit Statistik. + +--- + +## 🗂 Projektstruktur + +``` +tab-shooter/ +├── manifest.json # MV3-Manifest, Berechtigungen +├── popup.html # Overlay-UI: Start-, Spiel- & Game-Over-Screen +├── popup.css # Space-Theme Styling +├── popup.js # Game-Engine + Chrome-API-Anbindung +├── background.js # Service Worker (MV3) +├── generate_icons.py # Icon-Generator (pure stdlib, keine Abhängigkeiten) +├── README.md +└── icons/ + ├── icon16.png + ├── icon48.png + └── icon128.png +``` + +--- + +## 🔐 Berechtigungen & Privatsphäre + +| Permission | Zweck | +|---|---| +| `tabs` | Offene Tabs lesen (Titel, Favicon, ID) und getroffene Tabs schließen. | +| `favicon` | Favicons über die `_favicon`-API laden. | +| `storage` | Highscore lokal speichern. | + +**Es werden keine Inhaltsdaten (Page content) von Tabs gelesen** — nur Titel und Favicon-URL. +Tabs werden **ausschließlich dann** geschlossen, wenn sie im Spiel getroffen wurden. +Der aktive Popup-Tab und interne `chrome://`-Seiten sind nie Ziele. + +--- + +## 🛠 Icons neu generieren + +Falls du die Icons anpassen willst (Farben, Form in `generate_icons.py`): + +```bash +python generate_icons.py +``` + +Das Skript nutzt nur die Python-Standardbibliothek (`zlib` + `struct`) — keine PIL nötig. + +--- + +Viel Spaß beim Aufräumen! 🛸💥 diff --git a/background.js b/background.js new file mode 100644 index 0000000..41d0a51 --- /dev/null +++ b/background.js @@ -0,0 +1,13 @@ +/* Tab Shooter — Background Service Worker (MV3) + * Minimal: das Popup übernimmt die gesamte Spiellogik. + * Der Service Worker ist erforderlich für die Manifest-Deklaration + * und kann künftig z.B. ein Tastenkürzel (commands) anbieten. + */ + +// Bei Installation: Default-Highscore setzen, falls nicht vorhanden. +chrome.runtime.onInstalled.addListener(async () => { + const data = await chrome.storage.local.get("highscore"); + if (data.highscore === undefined) { + await chrome.storage.local.set({ highscore: 0 }); + } +}); diff --git a/generate_icons.py b/generate_icons.py new file mode 100644 index 0000000..fdafe2b --- /dev/null +++ b/generate_icons.py @@ -0,0 +1,124 @@ +"""Tab Shooter — Icon Generator (pure stdlib). + +Erzeugt icons/icon16.png, icon48.png, icon128.png direkt über zlib+struct. +Keine externen Abhängigkeiten (PIL nicht nötig). +""" +import os +import struct +import zlib + +OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "icons") + + +def write_png(path, width, height, rgba_pixels): + """rgba_pixels: bytearray mit width*height*4 Bytes (RGBA).""" + def chunk(tag, data): + c = tag + data + crc = struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF) + return struct.pack(">I", len(data)) + c + crc + + header = b"\x89PNG\r\n\x1a\n" + ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) # 8-bit, RGBA + # Filter byte 0 am Anfang jeder Zeile + raw = bytearray() + stride = width * 4 + for y in range(height): + raw.append(0) + raw.extend(rgba_pixels[y * stride:(y + 1) * stride]) + idat = zlib.compress(bytes(raw), 9) + png = header + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"" ) + with open(path, "wb") as f: + f.write(png) + + +def lerp(a, b, t): + return int(a + (b - a) * t) + + +def lerp_color(c1, c2, t): + return tuple(lerp(c1[i], c2[i], t) for i in range(3)) + + +def render_icon(size): + """Zeichnet ein Raumschiff mit Cyan->Magenta Verlauf auf dunklem Grund.""" + bg_top = (10, 6, 30) + bg_bot = (30, 8, 50) + cyan = (0, 240, 255) + magenta = (255, 0, 229) + yellow = (255, 170, 0) + + px = bytearray(size * size * 4) + cx = (size - 1) / 2.0 + nose_y = size * 0.18 + base_y = size * 0.80 + half_w = size * 0.34 + + def set_pixel(x, y, r, g, b, a=255): + if 0 <= x < size and 0 <= y < size: + i = (y * size + x) * 4 + # alpha-blend over existing + if px[i + 3] == 0: + px[i:i + 4] = bytes([r, g, b, a]) + else: + ia = a / 255.0 + px[i] = int(r * ia + px[i] * (1 - ia)) + px[i + 1] = int(g * ia + px[i + 1] * (1 - ia)) + px[i + 2] = int(b * ia + px[i + 2] * (1 - ia)) + px[i + 3] = min(255, px[i + 3] + a) + + for y in range(size): + for x in range(size): + # Hintergrund-Verlauf (radial-ish) + t = y / max(1, size - 1) + r, g, b = lerp_color(bg_top, bg_bot, t) + # Sterne + if (x * 7 + y * 13) % 37 == 0 and size >= 48: + r, g, b = 220, 220, 255 + set_pixel(x, y, r, g, b, 255) + + # Raumschiff: für jede Zeile y berechne ship-x-Bereich + for y in range(size): + # relative position im Schiff (0 = nose, 1 = base) + if y < nose_y or y > base_y: + continue + t = (y - nose_y) / max(1, (base_y - nose_y)) + # Schiffsbreite wächst zur Basis + w = half_w * (0.15 + 0.85 * t) + # Schiff-Verlauf cyan->magenta + cr, cg, cb = lerp_color(cyan, magenta, t) + for x in range(size): + if abs(x - cx) <= w: + # leichte Randabdunklung + edge = abs(x - cx) / max(1, w) + shade = 1.0 - edge * 0.3 + set_pixel(x, y, int(cr * shade), int(cg * shade), int(cb * shade), 255) + # Triebwerk-Glühen an der Basis + if t > 0.9: + for x in range(size): + if abs(x - cx) < w * 0.5: + set_pixel(x, y, *yellow, 255) + + # Cockpit (kleiner Magenta-Kreis nahe der Spitze) + cockpit_cy = int(nose_y + (base_y - nose_y) * 0.35) + cockpit_r = max(1, size // 12) + for y in range(size): + for x in range(size): + dx = x - cx + dy = y - cockpit_cy + if dx * dx + dy * dy <= cockpit_r * cockpit_r: + set_pixel(x, y, *magenta, 255) + + return px + + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + for s in (16, 48, 128): + pixels = render_icon(s) + path = os.path.join(OUT_DIR, f"icon{s}.png") + write_png(path, s, s, pixels) + print(f"Wrote {path}") + + +if __name__ == "__main__": + main() diff --git a/icons/icon128.png b/icons/icon128.png new file mode 100644 index 0000000..ca9b504 Binary files /dev/null and b/icons/icon128.png differ diff --git a/icons/icon16.png b/icons/icon16.png new file mode 100644 index 0000000..282aaa7 Binary files /dev/null and b/icons/icon16.png differ diff --git a/icons/icon48.png b/icons/icon48.png new file mode 100644 index 0000000..1910318 Binary files /dev/null and b/icons/icon48.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..e25d87b --- /dev/null +++ b/manifest.json @@ -0,0 +1,25 @@ +{ + "manifest_version": 3, + "name": "Tab Shooter", + "version": "1.0.0", + "description": "Schließe deine offenen Tabs, indem du sie wie ein Raumschiff-Pilot abschießt. Ein kleines Minigame für Tab-Hoarder.", + "permissions": ["tabs", "storage", "favicon"], + "optional_permissions": ["windows"], + "action": { + "default_popup": "popup.html", + "default_title": "Tab Shooter – Tabs abschießen", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "background": { + "service_worker": "background.js" + } +} diff --git a/popup.css b/popup.css new file mode 100644 index 0000000..4c94402 --- /dev/null +++ b/popup.css @@ -0,0 +1,350 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; + user-select: none; +} + +body { + width: 420px; + height: 600px; + overflow: hidden; + background: #05060f; + color: #e0e0ff; + font-family: "Segoe UI", "Arial", sans-serif; +} + +#app { + position: relative; + width: 100%; + height: 100%; + background: + radial-gradient(ellipse at top, #1a1040 0%, transparent 60%), + radial-gradient(ellipse at bottom right, #2a0a40 0%, transparent 50%), + #05060f; + overflow: hidden; +} + +/* Starfield background */ +#app::before { + content: ""; + position: absolute; + inset: 0; + background-image: + radial-gradient(1px 1px at 20% 30%, #ffffff, transparent), + radial-gradient(1px 1px at 60% 70%, #ffffff, transparent), + radial-gradient(2px 2px at 80% 20%, #ffffff, transparent), + radial-gradient(1px 1px at 40% 80%, #aaaaff, transparent), + radial-gradient(1px 1px at 90% 50%, #ffffff, transparent), + radial-gradient(1px 1px at 10% 60%, #ffaaaa, transparent), + radial-gradient(2px 2px at 70% 40%, #ffffff, transparent), + radial-gradient(1px 1px at 30% 10%, #aaffff, transparent); + background-size: 200px 200px; + opacity: 0.6; + pointer-events: none; +} + +/* SCREENS */ +.screen { + position: absolute; + inset: 0; + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px; + z-index: 10; +} + +.screen.visible { + display: flex; +} + +#game-screen { + padding: 0; + justify-content: flex-start; +} + +/* TITLE */ +.title { + font-size: 38px; + font-weight: 800; + letter-spacing: 2px; + text-align: center; + background: linear-gradient(135deg, #00f0ff 0%, #ff00e5 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-shadow: 0 0 30px rgba(0, 240, 255, 0.3); + margin-bottom: 8px; +} + +.title.small { + font-size: 28px; + margin-bottom: 16px; +} + +.title span { + color: #ff00e5; + -webkit-text-fill-color: #ff00e5; +} + +.subtitle { + font-size: 14px; + text-align: center; + color: #8888aa; + line-height: 1.5; + margin-bottom: 24px; +} + +/* SCOPE SELECT */ +.scope-select { + width: 100%; + margin-bottom: 20px; +} + +.scope-label { + display: block; + text-align: center; + font-size: 12px; + color: #8888aa; + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 10px; +} + +.scope-buttons { + display: flex; + gap: 12px; + justify-content: center; +} + +.scope-btn { + flex: 1; + background: rgba(255, 255, 255, 0.04); + border: 2px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 14px 8px; + color: #e0e0ff; + cursor: pointer; + transition: all 0.2s; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + font-family: inherit; +} + +.scope-btn:hover { + border-color: rgba(0, 240, 255, 0.4); + background: rgba(0, 240, 255, 0.05); +} + +.scope-btn.active { + border-color: #00f0ff; + background: rgba(0, 240, 255, 0.12); + box-shadow: 0 0 20px rgba(0, 240, 255, 0.2); +} + +.scope-icon { + font-size: 26px; +} + +.scope-text { + font-size: 12px; + line-height: 1.2; + text-align: center; +} + +/* BUTTONS */ +.primary-btn { + background: linear-gradient(135deg, #00f0ff 0%, #00a0ff 100%); + color: #05060f; + border: none; + padding: 14px 48px; + font-size: 16px; + font-weight: 700; + letter-spacing: 2px; + border-radius: 30px; + cursor: pointer; + transition: all 0.2s; + font-family: inherit; + box-shadow: 0 0 30px rgba(0, 240, 255, 0.4); + margin-bottom: 20px; +} + +.primary-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 40px rgba(0, 240, 255, 0.6); +} + +.primary-btn:active { + transform: translateY(0); +} + +.secondary-btn { + background: transparent; + color: #8888aa; + border: 1px solid rgba(255, 255, 255, 0.15); + padding: 10px 32px; + font-size: 13px; + border-radius: 20px; + cursor: pointer; + transition: all 0.2s; + font-family: inherit; + margin-top: 8px; +} + +.secondary-btn:hover { + color: #e0e0ff; + border-color: rgba(255, 255, 255, 0.4); +} + +/* HIGHSCORE */ +.highscore-display { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 20px; + padding: 8px 20px; + background: rgba(255, 215, 0, 0.06); + border: 1px solid rgba(255, 215, 0, 0.2); + border-radius: 20px; +} + +.hs-label { + font-size: 13px; +} + +.hs-value { + font-size: 16px; + font-weight: 700; + color: #ffd700; +} + +/* CONTROLS HINT */ +.controls-hint { + text-align: center; + font-size: 11px; + color: #666688; + line-height: 2; +} + +.controls-hint kbd { + display: inline-block; + min-width: 22px; + padding: 2px 6px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 4px; + font-size: 10px; + font-family: monospace; + color: #ccccee; + margin: 0 1px; +} + +/* HUD */ +#hud { + position: absolute; + top: 0; + left: 0; + right: 0; + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: linear-gradient(180deg, rgba(0, 0, 0, 0.7) 0%, transparent 100%); + z-index: 20; + pointer-events: none; +} + +.hud-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.hud-label { + font-size: 9px; + color: #666688; + letter-spacing: 1px; +} + +.hud-value { + font-size: 14px; + font-weight: 700; + color: #00f0ff; +} + +.powerup-indicator { + flex-direction: row; + gap: 4px; + font-size: 16px; +} + +#game-canvas { + display: block; + width: 420px; + height: 600px; +} + +/* GAME OVER */ +.result-stats { + width: 100%; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + padding: 16px; + margin-bottom: 16px; +} + +.stat-row { + display: flex; + justify-content: space-between; + padding: 8px 4px; + font-size: 14px; + color: #aaaacc; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.stat-row:last-child { + border-bottom: none; +} + +.stat-row.highlight { + color: #ffd700; +} + +.stat-value { + font-weight: 700; + color: #e0e0ff; +} + +.stat-row.highlight .stat-value { + color: #ffd700; +} + +.new-record { + color: #ffd700; + font-weight: 700; + font-size: 16px; + letter-spacing: 2px; + margin-bottom: 16px; + animation: pulse 1s ease-in-out infinite alternate; +} + +.new-record.hidden { + display: none; +} + +@keyframes pulse { + from { transform: scale(1); } + to { transform: scale(1.08); } +} + +/* EMPTY STATE */ +.empty-icon { + font-size: 56px; + margin-bottom: 8px; +} diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..32f5f83 --- /dev/null +++ b/popup.html @@ -0,0 +1,96 @@ + + + + + Tab Shooter + + + +
+ +
+

🚀 TAB SHOOTER

+

Mach Schluss mit dem Tab-Chaos.
Schieß sie alle ab.

+ +
+ +
+ + +
+
+ + + +
+ 🏆 Highscore + 0 +
+ +
+
/ A D — Bewegen
+
Leertaste / Klick — Schießen
+
Maus — Ebenfalls steuerbar
+
+
+ + +
+ +
+
+ SCORE + 0 +
+
+ LIVES + ❤️❤️❤️ +
+
+ TABS + 0 +
+
+
+
+ + +
+

💥 GAME OVER

+
+
+ Tabs vernichtet: + 0 +
+
+ Score: + 0 +
+
+ 🏆 Highscore: + 0 +
+
+ + + +
+ + +
+
🎉
+

Keine Tabs offen!

+

Du hast aktuell keine Tabs zum Abschießen.
Chaosfrei!

+ +
+
+ + + + diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..24b6603 --- /dev/null +++ b/popup.js @@ -0,0 +1,868 @@ +/* ============================================================ + 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();