diff --git a/README.md b/README.md index 8fc63a6..6a63ab9 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,29 @@ Pilot a spaceship, blast the tabs you no longer need, and they really get closed ## 🎮 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**. +### Zwei Spielmodi + +| Modus | Wie | +|---|---| +| **Popup** (kompakt, 420×600) | Toolbar-Icon klicken → **SPIEL STARTEN** | +| **Vollbild** (eigener Tab, bis 900×680) | Toolbar-Icon klicken → **🖥️ Im Vollbild spielen** | + +Im Vollbild-Modus öffnet sich das Spiel in einem eigenen Tab. Dieser Tab +ist **geschützt** – er taucht nie als Ziel auf und lässt sich nicht abschießen. +Perfekt, wenn du mehr Platz und eine bessere Übersicht willst. + +### Ablauf + +1. Modus wählen & Scope wählen: **Aktuelles Fenster** oder **Alle Fenster**. +2. **SPIEL STARTEN** drücken. +3. Deine echten Tabs erscheinen als gegnerische Raumschiffe (mit echtem Favicon + Titel). +4. Schießen — jeder Tab, den du triffst, wird in Chrome **wirklich geschlossen**. + +> 🛡️ **Selbstschutz:** Folgende Tabs sind nie Ziele: +> - der Tab, der das Popup trägt (sonst würde sich das Spiel selbst beenden) +> - der Game-Tab im Vollbild-Modus +> - alle internen Chrome-Seiten (`chrome://`, `devtools://`, …) +> - alle Seiten dieser Extension selbst ### Steuerung @@ -53,9 +71,13 @@ Pilot a spaceship, blast the tabs you no longer need, and they really get closed ``` 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 +├── engine.js # Geteilte Game-Engine (Klasse TabShooterGame) +├── popup.html # Popup-UI (kompakt) +├── popup.css # Shared Space-Theme Styling +├── popup.js # Popup-Controller (nutzt engine.js) +├── game.html # Vollbild-UI (eigener Tab) +├── game.css # Vollbild-Overrides +├── game.js # Vollbild-Controller (nutzt engine.js) ├── background.js # Service Worker (MV3) ├── generate_icons.py # Icon-Generator (pure stdlib, keine Abhängigkeiten) ├── README.md diff --git a/engine.js b/engine.js new file mode 100644 index 0000000..5938eee --- /dev/null +++ b/engine.js @@ -0,0 +1,686 @@ +/* ============================================================ + TAB SHOOTER — Shared Game Engine + Wird von popup.js (Overlay) und game.js (Vollbild) genutzt. + ============================================================ */ + +// ---------- UTILITIES ---------- +export function clamp(v, min, max) { + return Math.max(min, Math.min(max, v)); +} + +export function rand(min, max) { + return Math.random() * (max - min) + min; +} + +export function pick(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +export 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(); +} + +export 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); +} + +// Load a favicon via Chrome's _favicon API (requires "favicon" permission). +// Returns a Promise. +export function loadFavicon(pageUrl, size = 32) { + return new Promise((resolve) => { + if (!pageUrl) return resolve(null); + const favUrl = `/_favicon/?pageUrl=${encodeURIComponent(pageUrl)}&size=${size}`; + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => resolve(null); + img.src = favUrl; + }); +} + +/** + * Load all real Chrome tabs that are valid targets. + * ROBUST SELF-PROTECTION: + * - never the game tab itself (its id passed via `excludeTabId`) + * - never any tab belonging to this extension (chrome.runtime.id) + * - never chrome://, devtools://, etc. + */ +export async function loadTabs(scope, excludeTabId = null) { + 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 }); + } + const ownId = chrome.runtime.id; + const playable = tabs.filter((t) => { + if (!t.id) return false; + // 1) never the current game tab itself + if (excludeTabId !== null && t.id === excludeTabId) return false; + // 2) never any tab of THIS extension (popup, game.html, options, …) + if (ownId && t.url && t.url.startsWith(`chrome-extension://${ownId}`)) return false; + // 3) generic chrome-extension:// filter (covers edge cases) + if (t.url && t.url.startsWith("chrome-extension://")) return false; + // 4) internal browser pages + if (t.url && t.url.startsWith("chrome://")) return false; + if (t.url && t.url.startsWith("devtools://")) return false; + if (t.url && t.url.startsWith("edge://")) return false; + if (t.url && t.url.startsWith("about:")) return false; + return true; + }); + return playable; +} + +// ============================================================ +// GAME CLASS +// ============================================================ +export class TabShooterGame { + /** + * @param canvas element + * @param hud object of DOM elements for HUD updates + * @param callbacks { onGameOver, onScoreChange, onLivesChange, onTabsChange } + */ + constructor(canvas, hud, callbacks = {}) { + this.canvas = canvas; + this.ctx = canvas.getContext("2d"); + this.hud = hud; + this.callbacks = callbacks; + + this.W = canvas.width; + this.H = canvas.height; + + this.enemies = []; + this.bullets = []; + this.enemyBullets = []; + this.particles = []; + this.powerups = []; + this.floatingTexts = []; + + this.player = { + x: this.W / 2, + y: this.H - 70, + w: 44, + h: 40, + speed: 6, + cooldown: 0, + lives: 3, + invincible: 0, + }; + + this.score = 0; + this.tabsDestroyed = 0; + this.totalTabs = 0; + this.keys = {}; + this.mouseX = null; + this.firing = false; + + this.tripleShot = 0; + this.rapidFire = 0; + this.shield = 0; + + this.running = false; + this.lastTime = 0; + this.spawnTimer = 0; + this.spawnIndex = 0; + this.spawnQueue = []; + + // Input handlers (bound so we can remove them later) + 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); + this._loop = this.loop.bind(this); + } + + async init(tabs) { + this.totalTabs = tabs.length; + // Preload favicons, build spawn queue + this.spawnQueue = await Promise.all( + tabs.map(async (t) => ({ + tabId: t.id, + title: (t.title || "Tab").slice(0, 22), + url: t.url, + favicon: await loadFavicon(t.url || "", 32), + })) + ); + // Shuffle for variety + this.spawnQueue.sort(() => Math.random() - 0.5); + } + + start() { + this.running = true; + this.attachInput(); + this.lastTime = performance.now(); + requestAnimationFrame(this._loop); + } + + stop() { + this.running = false; + this.detachInput(); + } + + // ---------- INPUT ---------- + attachInput() { + window.addEventListener("keydown", this._onKeyDown); + window.addEventListener("keyup", this._onKeyUp); + this.canvas.addEventListener("mousemove", this._onMouseMove); + this.canvas.addEventListener("mousedown", this._onMouseDown); + this.canvas.addEventListener("mouseup", this._onMouseUp); + this.canvas.addEventListener("mouseleave", this._onMouseLeave); + } + detachInput() { + window.removeEventListener("keydown", this._onKeyDown); + window.removeEventListener("keyup", this._onKeyUp); + this.canvas.removeEventListener("mousemove", this._onMouseMove); + this.canvas.removeEventListener("mousedown", this._onMouseDown); + this.canvas.removeEventListener("mouseup", this._onMouseUp); + this.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 = this.canvas.getBoundingClientRect(); + // Scale from CSS pixels to canvas pixels if needed + const scaleX = this.canvas.width / rect.width; + this.mouseX = (e.clientX - rect.left) * scaleX; + } + 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 = 64; + const h = 54; + const x = rand(30, this.W - 30 - w); + 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, + }); + } + + // ---------- 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 - 6, y: py + 4, vx: -2.5, vy: -8.5 }); + this.bullets.push({ x: px + 6, 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) { + this.powerups.push({ + x, + y, + vy: 1.6, + type: pick(["triple", "rapid", "shield"]), + rot: 0, + }); + } + } + + applyPowerup(type) { + if (type === "triple") { + this.tripleShot = 360; + this.pushFloatingText("TRIPLE SHOT!", "#00f0ff"); + } else if (type === "rapid") { + this.rapidFire = 360; + this.pushFloatingText("RAPID FIRE!", "#ffaa00"); + } else if (type === "shield") { + this.shield = 1; + this.pushFloatingText("SHIELD UP!", "#00ff88"); + } + this.callbacks.onPowerups?.(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"); + this.callbacks.onPowerups?.(this); + this.player.invincible = 40; + return; + } + this.player.lives--; + this.player.invincible = 90; + this.callbacks.onLives?.(this.player.lives); + this.spawnExplosion(this.player.x, this.player.y, "#ff4444", 20); + if (this.player.lives <= 0) this.gameOver(); + } + + 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) { + if (chrome.tabs && typeof chrome.tabs.remove === "function") { + chrome.tabs.remove(tabId).catch(() => { /* already gone */ }); + } + } + + // ---------- 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); + } + + 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 (gently follows) + if (this.mouseX !== null) { + p.x += (this.mouseX - p.x) * 0.25; + } + p.x = clamp(p.x, p.w / 2, this.W - p.w / 2); + + // Firing + if (this.keys[" "] || this.firing) this.fireBullet(); + if (p.cooldown > 0) p.cooldown--; + if (p.invincible > 0) p.invincible--; + let powerupChanged = false; + if (this.tripleShot > 0) { this.tripleShot--; if (this.tripleShot === 0) powerupChanged = true; } + if (this.rapidFire > 0) { this.rapidFire--; if (this.rapidFire === 0) powerupChanged = true; } + if (powerupChanged) this.callbacks.onPowerups?.(this); + + // Spawn enemies + this.spawnTimer--; + if (this.spawnTimer <= 0 && this.spawnIndex < this.spawnQueue.length) { + this.spawnEnemy(); + this.spawnTimer = rand(35, 80); + } + + // Player 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 > this.W + 10) this.bullets.splice(i, 1); + } + + // 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; + 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 > this.H + 10) this.enemyBullets.splice(i, 1); + } + + // 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--; + + if (e.x < 5 || e.x + e.w > this.W - 5) { + e.vx *= -1; + e.x = clamp(e.x, 5, this.W - 5 - e.w); + } + + 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); + } + } + + if (e.y > this.H) { this.enemies.splice(i, 1); continue; } + + // Bullet hits + 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) { + 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++; + this.callbacks.onScore?.(this.score); + this.callbacks.onTabs?.(Math.max(0, this.totalTabs - this.tabsDestroyed)); + this.enemies.splice(i, 1); + } + break; + } + } + } + + // Enemy collides 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 + 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 > this.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 + if (this.spawnIndex >= this.spawnQueue.length && this.enemies.length === 0) { + this.gameOver(true); + } + } + + // ---------- RENDER ---------- + render() { + const ctx = this.ctx; + ctx.clearRect(0, 0, this.W, this.H); + this.renderBackground(); + + // Player bullets + 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; + + for (const e of this.enemies) this.renderEnemy(e); + for (const pu of this.powerups) this.renderPowerup(pu); + this.renderPlayer(); + + // Particles + for (const pt of this.particles) { + ctx.globalAlpha = pt.life / pt.maxLife; + 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() { + const ctx = this.ctx; + if (!this._stars) { + this._stars = []; + for (let i = 0; i < 60; i++) { + this._stars.push({ + x: Math.random() * this.W, + y: Math.random() * this.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 > this.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) { + const ctx = this.ctx; + ctx.save(); + ctx.translate(e.x, e.y); + + if (e.hit > 0) { ctx.shadowColor = "#ffffff"; ctx.shadowBlur = 15; } + + ctx.fillStyle = e.shooter ? "#3a1530" : "#1a1a3a"; + ctx.strokeStyle = e.shooter ? "#ff3366" : "#444466"; + ctx.lineWidth = 1.5; + roundRect(ctx, 0, 0, e.w, e.h, 8); + ctx.fill(); + ctx.stroke(); + ctx.shadowBlur = 0; + + 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); + } + + 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; + ctx.fillText(title, 26, 6, e.w - 30); + + if (e.shooter) { + ctx.fillStyle = "#ff3366"; + ctx.font = "8px Segoe UI, Arial"; + ctx.fillText("● feindlich", 6, 26); + } + ctx.restore(); + } + + renderPlayer() { + const ctx = this.ctx; + const p = this.player; + ctx.save(); + ctx.translate(p.x, p.y); + + 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(); + } + if (p.invincible > 0 && Math.floor(p.invincible / 4) % 2 === 0) ctx.globalAlpha = 0.4; + + ctx.fillStyle = "#00f0ff"; + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(0, -p.h / 2); + ctx.lineTo(-p.w / 2, p.h / 2); + ctx.lineTo(-p.w / 4, p.h / 3); + ctx.lineTo(p.w / 4, p.h / 3); + ctx.lineTo(p.w / 2, p.h / 2); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = "#ff00e5"; + ctx.beginPath(); + ctx.arc(0, -2, 5, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = "#ffaa00"; + ctx.globalAlpha = 0.7 * ctx.globalAlpha; + ctx.fillRect(-4, p.h / 3, 8, 6); + + ctx.restore(); + ctx.globalAlpha = 1; + } + + renderPowerup(pu) { + const ctx = this.ctx; + 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); + ctx.fillText(icons[pu.type], 0, 1); + ctx.restore(); + } + + gameOver(victory = false) { + this.stop(); + this.callbacks.onGameOver?.({ + score: this.score, + tabsDestroyed: this.tabsDestroyed, + totalTabs: this.totalTabs, + victory, + }); + } +} diff --git a/game.css b/game.css new file mode 100644 index 0000000..95dc0df --- /dev/null +++ b/game.css @@ -0,0 +1,61 @@ +/* ============================================================ + TAB SHOOTER — Vollbild-Modus (game.html) + Baut auf popup.css auf und überschreibt nur, was nötig ist. + ============================================================ */ + +html, body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; +} + +body.fullscreen { + width: 100vw; + height: 100vh; +} + +#app.fullscreen-app { + width: 100vw; + height: 100vh; +} + +/* Spiel-Screen zentriert das Canvas im Vollbild */ +#game-screen { + justify-content: center; + align-items: center; +} + +#game-canvas { + width: min(900px, 96vw); + height: min(680px, 88vh); + border-radius: 16px; + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.05), + 0 0 60px rgba(0, 240, 255, 0.15); + cursor: crosshair; +} + +/* HUD im Vollbild: schwebt oben über dem Canvas, zentriert */ +#game-screen #hud { + top: max(12px, 6vh); + left: 50%; + transform: translateX(-50%); + width: min(900px, 96vw); + border-radius: 12px; +} + +/* Start-Screen im Vollbild etwas luftiger */ +#start-screen .scope-select { + max-width: 520px; +} + +#start-screen .primary-btn { + padding: 16px 64px; + font-size: 18px; +} + +/* Gtooltip-hinweis im Vollbild-Menü: das ist dieser Tab */ +#start-screen .subtitle strong { + color: #00ff88; +} diff --git a/game.html b/game.html new file mode 100644 index 0000000..9acdd88 --- /dev/null +++ b/game.html @@ -0,0 +1,97 @@ + + + + + Tab Shooter — Vollbild + + + + +
+ +
+

🚀 TAB SHOOTER

+

Vollbild-Modus
Dieser Tab ist sicher – du kannst ihn nicht abschießen.

+ +
+ +
+ + +
+
+ + + +
+ 🏆 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/game.js b/game.js new file mode 100644 index 0000000..884bbaa --- /dev/null +++ b/game.js @@ -0,0 +1,127 @@ +/* ============================================================ + TAB SHOOTER — Vollbild-Controller (game.html) + Läuft als eigener Tab. Dieser Tab wird über seine ID + von der Zielliste ausgeschlossen -> er lässt sich NICHT abschießen. + ============================================================ */ + +import { TabShooterGame, loadTabs } from "./engine.js"; + +// ---------- DOM ---------- +const canvas = document.getElementById("game-canvas"); +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"), +}; + +// ---------- STATE ---------- +let game = null; +let highscore = 0; +let selectedScope = "current"; +let myTabId = null; + +// Eigene Tab-ID herausfinden – damit wir sie aus der Zielliste streichen können. +(async () => { + try { + const [tab] = await chrome.tabs.getCurrent(); + if (tab) myTabId = tab.id; + } catch (_) { + // getCurrent() schlägt fehlt, wenn wir gar kein Tab sind (sollte nicht passieren) + } +})(); + +// ---------- SCREEN HELPERS ---------- +function showScreen(name) { + Object.values(screens).forEach((s) => s.classList.remove("visible")); + screens[name].classList.add("visible"); +} + +// ---------- 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; +} + +// ---------- HUD CALLBACKS ---------- +const hudCallbacks = { + onScore: (s) => (els.hudScore.textContent = s), + onLives: (l) => (els.hudLives.textContent = "❤️".repeat(Math.max(0, l))), + onTabs: (t) => (els.hudTabs.textContent = t), + onPowerups: (g) => { + let html = ""; + if (g.shield > 0) html += "🛡️"; + if (g.tripleShot > 0) html += "🔫"; + if (g.rapidFire > 0) html += "⚡"; + els.hudPowerups.innerHTML = html; + }, + onGameOver: ({ score }) => { + const isNewRecord = score > highscore; + if (isNewRecord) saveHighscore(score); + els.resultTabs.textContent = game.tabsDestroyed; + els.resultScore.textContent = score; + els.resultHighscore.textContent = Math.max(highscore, score); + els.newRecord.classList.toggle("hidden", !isNewRecord); + showScreen("gameover"); + }, +}; + +// ---------- GAME START ---------- +async function startNewGame() { + // WICHTIG: myTabId wird ausgeschlossen, damit dieser Tab kein Ziel ist. + const tabs = await loadTabs(selectedScope, myTabId); + if (tabs.length === 0) { + showScreen("empty"); + return; + } + showScreen("game"); + els.hudScore.textContent = "0"; + els.hudLives.textContent = "❤️❤️❤️"; + els.hudTabs.textContent = tabs.length; + els.hudPowerups.innerHTML = ""; + + game = new TabShooterGame(canvas, {}, hudCallbacks); + await game.init(tabs); + game.start(); +} + +// ---------- EVENT WIRING ---------- +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; + }); +}); + +document.getElementById("start-btn").addEventListener("click", startNewGame); +document.getElementById("replay-btn").addEventListener("click", startNewGame); + +document.getElementById("menu-btn").addEventListener("click", () => { + if (game) game.stop(); + loadHighscore(); + showScreen("start"); +}); +document.getElementById("empty-menu-btn").addEventListener("click", () => { + loadHighscore(); + showScreen("start"); +}); + +// ---------- INIT ---------- +loadHighscore(); diff --git a/manifest.json b/manifest.json index e25d87b..fff6038 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,9 @@ { "manifest_version": 3, "name": "Tab Shooter", - "version": "1.0.0", + "version": "1.1.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", @@ -19,6 +18,12 @@ "48": "icons/icon48.png", "128": "icons/icon128.png" }, + "web_accessible_resources": [ + { + "resources": ["game.html", "game.css", "game.js", "engine.js"], + "matches": [""] + } + ], "background": { "service_worker": "background.js" } diff --git a/popup.html b/popup.html index 32f5f83..330b2bb 100644 --- a/popup.html +++ b/popup.html @@ -27,6 +27,7 @@ +
🏆 Highscore @@ -91,6 +92,6 @@
- + diff --git a/popup.js b/popup.js index 24b6603..7f1aef5 100644 --- a/popup.js +++ b/popup.js @@ -1,21 +1,21 @@ /* ============================================================ - TAB SHOOTER — Game Engine + Chrome API Integration + TAB SHOOTER — Popup (Overlay) Controller + Nutzt die geteilte engine.js. ============================================================ */ +import { TabShooterGame, loadTabs, clamp } from "./engine.js"; + 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"), @@ -28,817 +28,81 @@ const els = { newRecord: document.getElementById("new-record"), }; -// ---------- GAME STATE ---------- +// ---------- STATE ---------- let game = null; let highscore = 0; -let selectedScope = "current"; // "current" | "all" +let selectedScope = "current"; -// ---------- UTILITIES ---------- +// Merke dir den Tab, von dem aus das Popup geöffnet wurde (den aktiven Tab +// im zuletzt fokussierten Fenster). Wird dieser Tab geschlossen, reißt Chrome +// auch das Popup mit sich -> Spiel beendet sich ohne Vorwarnung. Wir schließen +// ihn daher per excludeTabId von der Zielliste aus. +let openerTabId = null; +chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + if (tabs && tabs[0]) openerTabId = tabs[0].id; +}); + +// ---------- SCREEN HELPERS ---------- 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); +// ---------- HUD CALLBACKS ---------- +const hudCallbacks = { + onScore: (s) => (els.hudScore.textContent = s), + onLives: (l) => (els.hudLives.textContent = "❤️".repeat(Math.max(0, l))), + onTabs: (t) => (els.hudTabs.textContent = t), + onPowerups: (g) => { + let html = ""; + if (g.shield > 0) html += "🛡️"; + if (g.tripleShot > 0) html += "🔫"; + if (g.rapidFire > 0) html += "⚡"; + els.hudPowerups.innerHTML = html; + }, + onGameOver: ({ score }) => { + const isNewRecord = score > highscore; + if (isNewRecord) saveHighscore(score); + els.resultTabs.textContent = game.tabsDestroyed; + els.resultScore.textContent = score; + els.resultHighscore.textContent = Math.max(highscore, 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 -// ============================================================ +// ---------- GAME START ---------- async function startNewGame() { - const tabs = await loadTabs(selectedScope); + // Den Opener-Tab (aktuellen Tab, der das Popup trägt) nie als Ziel zulassen, + // da sein Schließen das Popup und damit das Spiel sofort beenden würde. + const tabs = await loadTabs(selectedScope, openerTabId); if (tabs.length === 0) { showScreen("empty"); return; } showScreen("game"); - // Reset HUD - updateScoreHUD(0); - updateLivesHUD(3); - updateTabsHUD(tabs.length); + els.hudScore.textContent = "0"; + els.hudLives.textContent = "❤️❤️❤️"; + els.hudTabs.textContent = tabs.length; els.hudPowerups.innerHTML = ""; - game = new TabShooterGame(tabs); - await game.init(); + game = new TabShooterGame(canvas, {}, hudCallbacks); + await game.init(tabs); game.start(); } -// Scope buttons +// ---------- EVENT WIRING ---------- document.querySelectorAll(".scope-btn").forEach((btn) => { btn.addEventListener("click", () => { document.querySelectorAll(".scope-btn").forEach((b) => b.classList.remove("active")); @@ -847,13 +111,9 @@ document.querySelectorAll(".scope-btn").forEach((btn) => { }); }); -// 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(); @@ -864,5 +124,12 @@ document.getElementById("empty-menu-btn").addEventListener("click", () => { showScreen("start"); }); -// Init on load +// ---------- FULLSCREEN BUTTON ---------- +document.getElementById("fullscreen-btn").addEventListener("click", async () => { + // Open the fullscreen game in a new tab. The popup will close automatically. + await chrome.tabs.create({ url: chrome.runtime.getURL("game.html") }); + window.close(); +}); + +// ---------- INIT ---------- loadHighscore();