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
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue