feat: initialize Tab Shooter Chrome extension game

This commit is contained in:
Janik Dietz 2026-08-04 07:13:45 +02:00
commit 0f566b02ac
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
11 changed files with 1614 additions and 0 deletions

42
.gitignore vendored Normal file
View file

@ -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/

96
README.md Normal file
View file

@ -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! 🛸💥

13
background.js Normal file
View file

@ -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 });
}
});

124
generate_icons.py Normal file
View file

@ -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()

BIN
icons/icon128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
icons/icon16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 B

BIN
icons/icon48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

25
manifest.json Normal file
View file

@ -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"
}
}

350
popup.css Normal file
View file

@ -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;
}

96
popup.html Normal file
View file

@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Tab Shooter</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<div id="app">
<!-- START SCREEN -->
<div id="start-screen" class="screen visible">
<h1 class="title">🚀 TAB <span>SHOOTER</span></h1>
<p class="subtitle">Mach Schluss mit dem Tab-Chaos.<br />Schieß sie alle ab.</p>
<div class="scope-select">
<label class="scope-label">Welche Tabs ins Visier nehmen?</label>
<div class="scope-buttons">
<button id="scope-current" class="scope-btn active" data-scope="current">
<span class="scope-icon">🪟</span>
<span class="scope-text">Aktuelles<br>Fenster</span>
</button>
<button id="scope-all" class="scope-btn" data-scope="all">
<span class="scope-icon">🌐</span>
<span class="scope-text">Alle<br>Fenster</span>
</button>
</div>
</div>
<button id="start-btn" class="primary-btn">SPIEL STARTEN</button>
<div class="highscore-display">
<span class="hs-label">🏆 Highscore</span>
<span id="hs-value" class="hs-value">0</span>
</div>
<div class="controls-hint">
<div><kbd></kbd> <kbd></kbd> / <kbd>A</kbd> <kbd>D</kbd> — Bewegen</div>
<div><kbd>Leertaste</kbd> / <kbd>Klick</kbd> — Schießen</div>
<div><kbd>Maus</kbd> — Ebenfalls steuerbar</div>
</div>
</div>
<!-- GAME SCREEN -->
<div id="game-screen" class="screen">
<canvas id="game-canvas" width="420" height="600"></canvas>
<div id="hud">
<div class="hud-item">
<span class="hud-label">SCORE</span>
<span id="hud-score" class="hud-value">0</span>
</div>
<div class="hud-item">
<span class="hud-label">LIVES</span>
<span id="hud-lives" class="hud-value">❤️❤️❤️</span>
</div>
<div class="hud-item">
<span class="hud-label">TABS</span>
<span id="hud-tabs" class="hud-value">0</span>
</div>
<div class="hud-item powerup-indicator" id="hud-powerups"></div>
</div>
</div>
<!-- GAME OVER SCREEN -->
<div id="gameover-screen" class="screen">
<h1 class="title small">💥 GAME OVER</h1>
<div class="result-stats">
<div class="stat-row">
<span>Tabs vernichtet:</span>
<span id="result-tabs" class="stat-value">0</span>
</div>
<div class="stat-row">
<span>Score:</span>
<span id="result-score" class="stat-value">0</span>
</div>
<div class="stat-row highlight">
<span>🏆 Highscore:</span>
<span id="result-highscore" class="stat-value">0</span>
</div>
</div>
<div id="new-record" class="new-record hidden">⚡ NEUER REKORD! ⚡</div>
<button id="replay-btn" class="primary-btn">NOCHMAL</button>
<button id="menu-btn" class="secondary-btn">ZUM MENÜ</button>
</div>
<!-- LOADING / EMPTY STATE -->
<div id="empty-screen" class="screen">
<div class="empty-icon">🎉</div>
<h2 class="title small">Keine Tabs offen!</h2>
<p class="subtitle">Du hast aktuell keine Tabs zum Abschießen.<br />Chaosfrei!</p>
<button id="empty-menu-btn" class="secondary-btn">ZUM MENÜ</button>
</div>
</div>
<script src="popup.js"></script>
</body>
</html>

868
popup.js Normal file
View file

@ -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();