feat: add fullscreen mode and extract shared game engine
- Add new fullscreen game mode playable in a dedicated browser tab - Extract shared game logic into `engine.js` to be used by both popup and fullscreen modes - Implement self-protection to prevent closing the game tab or popup tab - Update documentation and bump version to 1.1.0
This commit is contained in:
parent
0f566b02ac
commit
25fc6fb345
8 changed files with 1064 additions and 798 deletions
38
README.md
38
README.md
|
|
@ -22,11 +22,29 @@ Pilot a spaceship, blast the tabs you no longer need, and they really get closed
|
||||||
|
|
||||||
## 🎮 Spielen
|
## 🎮 Spielen
|
||||||
|
|
||||||
1. Auf das Toolbar-Icon klicken.
|
### Zwei Spielmodi
|
||||||
2. Wählen: **Aktuelles Fenster** oder **Alle Fenster**.
|
|
||||||
3. **SPIEL STARTEN** drücken.
|
| Modus | Wie |
|
||||||
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**.
|
| **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
|
### Steuerung
|
||||||
|
|
||||||
|
|
@ -53,9 +71,13 @@ Pilot a spaceship, blast the tabs you no longer need, and they really get closed
|
||||||
```
|
```
|
||||||
tab-shooter/
|
tab-shooter/
|
||||||
├── manifest.json # MV3-Manifest, Berechtigungen
|
├── manifest.json # MV3-Manifest, Berechtigungen
|
||||||
├── popup.html # Overlay-UI: Start-, Spiel- & Game-Over-Screen
|
├── engine.js # Geteilte Game-Engine (Klasse TabShooterGame)
|
||||||
├── popup.css # Space-Theme Styling
|
├── popup.html # Popup-UI (kompakt)
|
||||||
├── popup.js # Game-Engine + Chrome-API-Anbindung
|
├── 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)
|
├── background.js # Service Worker (MV3)
|
||||||
├── generate_icons.py # Icon-Generator (pure stdlib, keine Abhängigkeiten)
|
├── generate_icons.py # Icon-Generator (pure stdlib, keine Abhängigkeiten)
|
||||||
├── README.md
|
├── README.md
|
||||||
|
|
|
||||||
686
engine.js
Normal file
686
engine.js
Normal file
|
|
@ -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<Image | null>.
|
||||||
|
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 <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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
61
game.css
Normal file
61
game.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
97
game.html
Normal file
97
game.html
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Tab Shooter — Vollbild</title>
|
||||||
|
<link rel="stylesheet" href="popup.css" />
|
||||||
|
<link rel="stylesheet" href="game.css" />
|
||||||
|
</head>
|
||||||
|
<body class="fullscreen">
|
||||||
|
<div id="app" class="fullscreen-app">
|
||||||
|
<!-- START SCREEN -->
|
||||||
|
<div id="start-screen" class="screen visible">
|
||||||
|
<h1 class="title">🚀 TAB <span>SHOOTER</span></h1>
|
||||||
|
<p class="subtitle">Vollbild-Modus<br />Dieser Tab ist sicher – du kannst ihn <strong>nicht</strong> abschießen.</p>
|
||||||
|
|
||||||
|
<div class="scope-select">
|
||||||
|
<label class="scope-label">Welche Tabs ins Visier nehmen?</label>
|
||||||
|
<div class="scope-buttons">
|
||||||
|
<button class="scope-btn active" data-scope="current">
|
||||||
|
<span class="scope-icon">🪟</span>
|
||||||
|
<span class="scope-text">Aktuelles<br>Fenster</span>
|
||||||
|
</button>
|
||||||
|
<button 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="900" height="680"></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>
|
||||||
|
|
||||||
|
<!-- 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 type="module" src="game.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
127
game.js
Normal file
127
game.js
Normal file
|
|
@ -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();
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Tab Shooter",
|
"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.",
|
"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"],
|
"permissions": ["tabs", "storage", "favicon"],
|
||||||
"optional_permissions": ["windows"],
|
|
||||||
"action": {
|
"action": {
|
||||||
"default_popup": "popup.html",
|
"default_popup": "popup.html",
|
||||||
"default_title": "Tab Shooter – Tabs abschießen",
|
"default_title": "Tab Shooter – Tabs abschießen",
|
||||||
|
|
@ -19,6 +18,12 @@
|
||||||
"48": "icons/icon48.png",
|
"48": "icons/icon48.png",
|
||||||
"128": "icons/icon128.png"
|
"128": "icons/icon128.png"
|
||||||
},
|
},
|
||||||
|
"web_accessible_resources": [
|
||||||
|
{
|
||||||
|
"resources": ["game.html", "game.css", "game.js", "engine.js"],
|
||||||
|
"matches": ["<all_urls>"]
|
||||||
|
}
|
||||||
|
],
|
||||||
"background": {
|
"background": {
|
||||||
"service_worker": "background.js"
|
"service_worker": "background.js"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="start-btn" class="primary-btn">SPIEL STARTEN</button>
|
<button id="start-btn" class="primary-btn">SPIEL STARTEN</button>
|
||||||
|
<button id="fullscreen-btn" class="secondary-btn">🖥️ Im Vollbild spielen</button>
|
||||||
|
|
||||||
<div class="highscore-display">
|
<div class="highscore-display">
|
||||||
<span class="hs-label">🏆 Highscore</span>
|
<span class="hs-label">🏆 Highscore</span>
|
||||||
|
|
@ -91,6 +92,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="popup.js"></script>
|
<script type="module" src="popup.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
841
popup.js
841
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_W = 420;
|
||||||
const CANVAS_H = 600;
|
const CANVAS_H = 600;
|
||||||
|
|
||||||
// ---------- DOM ----------
|
// ---------- DOM ----------
|
||||||
const canvas = document.getElementById("game-canvas");
|
const canvas = document.getElementById("game-canvas");
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
|
|
||||||
const screens = {
|
const screens = {
|
||||||
start: document.getElementById("start-screen"),
|
start: document.getElementById("start-screen"),
|
||||||
game: document.getElementById("game-screen"),
|
game: document.getElementById("game-screen"),
|
||||||
gameover: document.getElementById("gameover-screen"),
|
gameover: document.getElementById("gameover-screen"),
|
||||||
empty: document.getElementById("empty-screen"),
|
empty: document.getElementById("empty-screen"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const els = {
|
const els = {
|
||||||
hsValue: document.getElementById("hs-value"),
|
hsValue: document.getElementById("hs-value"),
|
||||||
hudScore: document.getElementById("hud-score"),
|
hudScore: document.getElementById("hud-score"),
|
||||||
|
|
@ -28,817 +28,81 @@ const els = {
|
||||||
newRecord: document.getElementById("new-record"),
|
newRecord: document.getElementById("new-record"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- GAME STATE ----------
|
// ---------- STATE ----------
|
||||||
let game = null;
|
let game = null;
|
||||||
let highscore = 0;
|
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) {
|
function showScreen(name) {
|
||||||
Object.values(screens).forEach((s) => s.classList.remove("visible"));
|
Object.values(screens).forEach((s) => s.classList.remove("visible"));
|
||||||
screens[name].classList.add("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 ----------
|
// ---------- HIGHSCORE ----------
|
||||||
async function loadHighscore() {
|
async function loadHighscore() {
|
||||||
const data = await chrome.storage.local.get("highscore");
|
const data = await chrome.storage.local.get("highscore");
|
||||||
highscore = data.highscore || 0;
|
highscore = data.highscore || 0;
|
||||||
els.hsValue.textContent = highscore;
|
els.hsValue.textContent = highscore;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveHighscore(value) {
|
async function saveHighscore(value) {
|
||||||
await chrome.storage.local.set({ highscore: value });
|
await chrome.storage.local.set({ highscore: value });
|
||||||
highscore = value;
|
highscore = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- TAB LOADING ----------
|
// ---------- HUD CALLBACKS ----------
|
||||||
async function loadTabs(scope) {
|
const hudCallbacks = {
|
||||||
let tabs = [];
|
onScore: (s) => (els.hudScore.textContent = s),
|
||||||
if (scope === "all") {
|
onLives: (l) => (els.hudLives.textContent = "❤️".repeat(Math.max(0, l))),
|
||||||
tabs = await chrome.tabs.query({});
|
onTabs: (t) => (els.hudTabs.textContent = t),
|
||||||
} else {
|
onPowerups: (g) => {
|
||||||
const win = await chrome.windows.getLastFocused();
|
let html = "";
|
||||||
tabs = await chrome.tabs.query({ windowId: win.id });
|
if (g.shield > 0) html += "🛡️";
|
||||||
}
|
if (g.tripleShot > 0) html += "🔫";
|
||||||
// Filter out the popup itself (this extension page), chrome:// pages, and
|
if (g.rapidFire > 0) html += "⚡";
|
||||||
// devtools. We keep everything else as a valid target.
|
els.hudPowerups.innerHTML = html;
|
||||||
const playable = tabs.filter((t) => {
|
},
|
||||||
if (!t.id) return false;
|
onGameOver: ({ score }) => {
|
||||||
if (t.url && t.url.startsWith("chrome-extension://")) return false;
|
const isNewRecord = score > highscore;
|
||||||
if (t.url && t.url.startsWith("chrome://")) return false;
|
if (isNewRecord) saveHighscore(score);
|
||||||
if (t.url && t.url.startsWith("devtools://")) return false;
|
els.resultTabs.textContent = game.tabsDestroyed;
|
||||||
return true;
|
els.resultScore.textContent = score;
|
||||||
});
|
els.resultHighscore.textContent = Math.max(highscore, score);
|
||||||
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);
|
els.newRecord.classList.toggle("hidden", !isNewRecord);
|
||||||
showScreen("gameover");
|
showScreen("gameover");
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
// ---------- HELPERS ----------
|
// ---------- GAME START ----------
|
||||||
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() {
|
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) {
|
if (tabs.length === 0) {
|
||||||
showScreen("empty");
|
showScreen("empty");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showScreen("game");
|
showScreen("game");
|
||||||
// Reset HUD
|
els.hudScore.textContent = "0";
|
||||||
updateScoreHUD(0);
|
els.hudLives.textContent = "❤️❤️❤️";
|
||||||
updateLivesHUD(3);
|
els.hudTabs.textContent = tabs.length;
|
||||||
updateTabsHUD(tabs.length);
|
|
||||||
els.hudPowerups.innerHTML = "";
|
els.hudPowerups.innerHTML = "";
|
||||||
|
|
||||||
game = new TabShooterGame(tabs);
|
game = new TabShooterGame(canvas, {}, hudCallbacks);
|
||||||
await game.init();
|
await game.init(tabs);
|
||||||
game.start();
|
game.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scope buttons
|
// ---------- EVENT WIRING ----------
|
||||||
document.querySelectorAll(".scope-btn").forEach((btn) => {
|
document.querySelectorAll(".scope-btn").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
document.querySelectorAll(".scope-btn").forEach((b) => b.classList.remove("active"));
|
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);
|
document.getElementById("start-btn").addEventListener("click", startNewGame);
|
||||||
|
|
||||||
// Replay
|
|
||||||
document.getElementById("replay-btn").addEventListener("click", startNewGame);
|
document.getElementById("replay-btn").addEventListener("click", startNewGame);
|
||||||
|
|
||||||
// Back to menu
|
|
||||||
document.getElementById("menu-btn").addEventListener("click", () => {
|
document.getElementById("menu-btn").addEventListener("click", () => {
|
||||||
if (game) game.stop();
|
if (game) game.stop();
|
||||||
loadHighscore();
|
loadHighscore();
|
||||||
|
|
@ -864,5 +124,12 @@ document.getElementById("empty-menu-btn").addEventListener("click", () => {
|
||||||
showScreen("start");
|
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();
|
loadHighscore();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue