feat: initialize Tab Shooter Chrome extension game
This commit is contained in:
commit
0f566b02ac
11 changed files with 1614 additions and 0 deletions
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal 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
96
README.md
Normal 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
13
background.js
Normal 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
124
generate_icons.py
Normal 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
BIN
icons/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
BIN
icons/icon16.png
Normal file
BIN
icons/icon16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 341 B |
BIN
icons/icon48.png
Normal file
BIN
icons/icon48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
25
manifest.json
Normal file
25
manifest.json
Normal 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
350
popup.css
Normal 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
96
popup.html
Normal 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
868
popup.js
Normal 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();
|
||||||
Loading…
Add table
Add a link
Reference in a new issue