From 0f566b02ac36bd9c661db03ef39f566b5df404c8 Mon Sep 17 00:00:00 2001 From: Janik Dietz Date: Tue, 4 Aug 2026 07:13:45 +0200 Subject: [PATCH] feat: initialize Tab Shooter Chrome extension game --- .gitignore | 42 +++ README.md | 96 +++++ background.js | 13 + generate_icons.py | 124 +++++++ icons/icon128.png | Bin 0 -> 9628 bytes icons/icon16.png | Bin 0 -> 341 bytes icons/icon48.png | Bin 0 -> 1692 bytes manifest.json | 25 ++ popup.css | 350 +++++++++++++++++++ popup.html | 96 +++++ popup.js | 868 ++++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 1614 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 background.js create mode 100644 generate_icons.py create mode 100644 icons/icon128.png create mode 100644 icons/icon16.png create mode 100644 icons/icon48.png create mode 100644 manifest.json create mode 100644 popup.css create mode 100644 popup.html create mode 100644 popup.js 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 0000000000000000000000000000000000000000..ca9b5049a596ec6bb619dec76a3cd2f95df937e1 GIT binary patch literal 9628 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 ZW9(M)j_T{CuTxh{+8PGx<+tvJ{11AvF2eu- literal 0 HcmV?d00001 diff --git a/icons/icon16.png b/icons/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..282aaa7bb50333bee29bdef987dac4e4ebdcbdde GIT binary patch literal 341 zcmV-b0jmCqP)=VemVM7=}Sv zF^ahPZ#?oI7+fyruAK0Lhv$7>bSaN6(5)uu0zGPyF3_i@8KtB4WwhHMrV@-2fBH7s zZP2e~Xp=g6VD`FUNRHZJKqYD8@4b;|8j_=S7*w+iTi>oSxBJfg{+}UPI@=aQI>oT9 z%{vzIA1v-P4aw5kwiwp)3@Z2~7T2D6xbex;T*Htoo!r8EjOYah9b_v!S$X04YMmAT z*N`lo+`@arb($^5-UZL5Zdgvd@-bOwZSKdAES=nB6a2xb&amaGx5WF{EqMtmxydH@ ngE75G7Z}$|bOEn(nJ)eTG&_*=HKcjK00000NkvXXu0mjfQ%asj literal 0 HcmV?d00001 diff --git a/icons/icon48.png b/icons/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..1910318d4d427a442b7fd0f7933596af2c2b3072 GIT binary patch literal 1692 zcmZ9Ndpr|r7{{mCT*8{jeGE&fHd-q87&9p}J0Wwa7!Boq+;VT5rc&m1jDtwzRzpIu zr6?mgq6=d#pD1ava)~-q|5Sgx&->5&Jn!%GeSgpMraL>@$x5qA0{{S7dz=kkv>R+J zNw6roZd#!N0N~U1Hdch_*S+rVT^NxH3I3LIrRBa_(%LCnDYTR&54qE2QwYOg=Z8{T zv$`4T%>Dp|yh}EG`mtyfFpk1wt5VMswSy^b$gSc$Sv$+N4 z+pxZmk&hwLg@qjf_j+h4a8s!v$=kYVSJzsu$weQw0Hv}sOIDraTcYHvp{ML6oq9YM z%GeJty^t0io8ohD37+=eJ{coNW%wU)DT9UL4r$#CQ09vOc-|8&fS+n*{8 z4g~GpWl+@uyx#uRMLh7!>RYoBqPq#iz(m(jsVP5Fxnef0@CyoB{c4iS@T0!Aug`9M z$gH=lqhPS;?K!*)F5&-54$Xy<=FM1|3M0d@IJh zim9!BZ)@ZfZ4F0(M|5M2ku!-!w8Bn*t8ab8SP)RMnP_!*yGv z>R@f<;W2&P@LW~$rIm^A#vP6p8k8jXdZeH!BItDywf7uDd*2=K$vBTKu=5^2q#=z4 z2OjoofUON|+hQEHS>ILE4vI78{5=Gp=zafkJxZ;VH;Wa1qtc88ts7{uBGt|gkSw`+ z*XR<>_|fD|BwTI&Z1qm(LwSPZ+aS zs904Nod;Z&l2|I;#GoZuNEo6NwD;eX3B7eIG-A|<={9yT3w3u=cmo-~Z6LLC&lQ_` z(iL=AcRA%mY(KpSp9^cwI_5n->Zd~UtmW{NZXLW+x~U5o2yL&Dl0xx1Um0oOh{Ko4 zKAYos0^F~vXU+^Ng*bDQ;L+#4DXM$rUC^N~hQ_C>F7)ifxp`4pV!b$Gi7nno0$DSM zB|Ad~TrK5)Q$A2o7uan9)(q)H$#$)W3l>H;>2gu*SDU zHWq_qwomx~G;CO9Ne)a{s&`*@)Gn`BkW5ftbY$CEs4V-EkbTSBCg`uqo5x+#r;I=2 z^50}M#GLR&1=#U8NCi~Ol>vlhx=%06e=5^KwozEDWIo#~gNa%l6I)Ab>8>!V9p^S2 ziJ!0Jfa7$AH=%~65;SqRK3 zn@6l9p)_0s*{4mArhXXXotpW5t|eheJN^`z(Y&viQw}n6X$Xi2#?aqr1dJvedNb(t z2b+BMZWb)lH#fr}suI`u>=5O=T-al!VxKluGF6&kO1K_t4Sc89P#iOHw85l_-!!Qa z|1m;o)X`kqjn$L|t|}wK9+TjS)zYrP zbmy}kDf%9=ncMTkHykA-fmwMw{SGAO%Glvth&D~ypq2e+b~HV)&OZvP3cBd*?{e|R zu^XC)AhFN0cwC=t==_=`fZn6sG7{OlRc!AN7~KOUSN*OZ&$)tiH>~YByimgx0%t@{ zA1F}ap4!g({GnnA5T5Ei!c5@kJ_?cnV<_(|?Po+o6H7zDGp2^KBa&Dg2)<)WB{9%y z&B-D#&27LtuUeplSvI|<6{*Koh3BM=pF&FvfguNShsTa-)r5TJPEbU@4#3{l(Wchg GC*^-v1ttps literal 0 HcmV?d00001 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();