Files
2026-04-25 19:46:01 +03:00

4194 lines
141 KiB
JavaScript

/**
* NodeInvaders - ComfyUI Space Invaders Game
* A fully integrated arcade shooter where enemies are real ComfyUI nodes
*/
import { app } from "../../../scripts/app.js";
import {
rand,
clamp,
pickWeightedType,
getLiteGraph,
pickAvailableNodeType,
createGraphNode,
clearNodeTypesCache,
setGameMode,
getGameMode,
BOSS_TYPE
} from "./node_invaders.js";
const BOSS_IMAGE_URLS = [
new URL("./boss2.png", import.meta.url).href,
new URL("./boss.png", import.meta.url).href
];
let bossImages = null;
function getBossImages() {
if (bossImages || typeof Image === "undefined") return bossImages;
bossImages = BOSS_IMAGE_URLS.map(src => {
const img = new Image();
img.decoding = "async";
img.src = src;
return img;
});
return bossImages;
}
// ============================================================================
// CONFIGURATION
// ============================================================================
const CONFIG = {
// Player settings
PLAYER_SPEED: 340,
PLAYER_RADIUS: 18,
PLAYER_EDGE_MARGIN: 0.04,
// Bullet settings
BULLET_SPEED: 620,
BULLET_LIFETIME: 2.5,
SHOOT_COOLDOWN: 0.12,
// Enemy settings
SPAWN_BASE_INTERVAL: 0.85,
SPAWN_INTERVAL_DECAY: 0.008,
MIN_SPAWN_INTERVAL: 0.22,
MAX_ENEMIES: 40,
ENEMY_SPAWN_MARGIN: 220,
ENEMY_BOUNDARY_MARGIN: 300,
// Enemy bullet settings
ENEMY_BULLET_SPEED: 210,
ENEMY_BULLET_LIFETIME: 2.4,
// Collision settings
HIT_PADDING: 20,
DEBUG_COLLISION: false,
// Visual effects
SHAKE_DURATION: 0.18,
SHAKE_POWER: 8,
FLASH_DURATION: 0.12,
MUZZLE_FLASH_DURATION: 0.08,
// Particle settings
PARTICLE_EXHAUST_COUNT: 2,
PARTICLE_MUZZLE_COUNT: 8,
PARTICLE_EXPLOSION_COUNT: 45,
PARTICLE_SPARK_COUNT: 20,
PARTICLE_HIT_COUNT: 12,
// Skill settings
ROCKET_SKILL_KILLS_REQUIRED: 10,
ROCKET_COUNT: 5,
ROCKET_SPEED: 450,
ROCKET_TURN_RATE: 4.5,
ROCKET_LIFETIME: 3.0,
ROCKET_EXPLOSION_RADIUS: 80,
LASER_SKILL_KILLS_REQUIRED: 20,
LASER_DURATION: 5.0,
LASER_DAMAGE_PER_SECOND: 8,
LASER_WIDTH: 12,
NOVA_SKILL_KILLS_REQUIRED: 40,
NOVA_BOSS_DAMAGE: 18,
SKILL_READY_DELAY: 1.5,
// Combo settings
COMBO_WINDOW: 1.5,
COMBO_MULTIPLIER: 0.5,
// Milestone thresholds
MILESTONES: [5, 10, 25, 50, 100],
// Starfield settings
STAR_COUNT: 120,
STAR_PARALLAX: 0.15, // How much stars move relative to camera
// Delta time cap
MAX_DELTA_TIME: 0.05,
// Lives
PLAYER_LIVES: 3,
INVULN_DURATION: 2.4,
INVULN_BLINK_HZ: 8,
// Boss settings
BOSS_FIRST_SPAWN_KILLS: 12,
BOSS_KILL_INTERVAL: 20,
BOSS_BULLET_SPEED: 260,
BOSS_NODE_WIDTH: 520,
BOSS_NODE_HEIGHT: 390,
BOSS_DASH_MIN_INTERVAL: 1.2,
BOSS_DASH_MAX_INTERVAL: 2.0,
BOSS_DASH_DURATION: 0.2,
BOSS_DASH_MIN_DISTANCE: 150,
BOSS_DASH_MAX_DISTANCE: 330,
BOSS_DASH_PLAYER_SAFE_DISTANCE: 360
};
function getDifficulty() {
return getGameMode() === "all"
? {
spawnRate: 0.68,
spawnDecay: 1.25,
minSpawn: 0.16,
enemyCapBonus: 14,
enemySpeed: 1.22,
enemySeek: 1.16,
enemyFireRate: 0.78,
burstChance: 0.55,
bossFirstKills: 8,
bossInterval: 14
}
: {
spawnRate: 1,
spawnDecay: 1,
minSpawn: CONFIG.MIN_SPAWN_INTERVAL,
enemyCapBonus: 0,
enemySpeed: 1,
enemySeek: 1,
enemyFireRate: 1,
burstChance: 0.35,
bossFirstKills: CONFIG.BOSS_FIRST_SPAWN_KILLS,
bossInterval: CONFIG.BOSS_KILL_INTERVAL
};
}
// ============================================================================
// COLLISION UTILITIES
// ============================================================================
/**
* Check if a point is inside a rectangle
*/
function pointInRect(px, py, rx, ry, rw, rh) {
return px >= rx && px <= rx + rw && py >= ry && py <= ry + rh;
}
/**
* Check if two line segments intersect
*/
function segmentsIntersect(ax, ay, bx, by, cx, cy, dx, dy) {
const rpx = bx - ax;
const rpy = by - ay;
const spx = dx - cx;
const spy = dy - cy;
const det = rpx * spy - rpy * spx;
if (det === 0) return false;
const t = ((cx - ax) * spy - (cy - ay) * spx) / det;
const u = ((cx - ax) * rpy - (cy - ay) * rpx) / det;
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
}
/**
* Check if a line segment intersects a rectangle
* Optimized version with early exit
*/
function lineIntersectsRect(x1, y1, x2, y2, rx, ry, rw, rh) {
// Quick bounding box check first
const lineMinX = Math.min(x1, x2);
const lineMaxX = Math.max(x1, x2);
const lineMinY = Math.min(y1, y2);
const lineMaxY = Math.max(y1, y2);
// Early exit if line bounding box doesn't intersect rectangle
if (lineMaxX < rx || lineMinX > rx + rw || lineMaxY < ry || lineMinY > ry + rh) {
return false;
}
// Check if either endpoint is inside the rectangle
if (pointInRect(x1, y1, rx, ry, rw, rh) || pointInRect(x2, y2, rx, ry, rw, rh)) {
return true;
}
// Check intersection with each edge (only if line crosses rectangle bounds)
const x3 = rx, y3 = ry;
const x4 = rx + rw, y4 = ry;
const x5 = rx + rw, y5 = ry + rh;
const x6 = rx, y6 = ry + rh;
return segmentsIntersect(x1, y1, x2, y2, x3, y3, x4, y4) ||
segmentsIntersect(x1, y1, x2, y2, x4, y4, x5, y5) ||
segmentsIntersect(x1, y1, x2, y2, x5, y5, x6, y6) ||
segmentsIntersect(x1, y1, x2, y2, x6, y6, x3, y3);
}
/**
* Get enemy's actual bounds (from node if available)
* Results are cached per enemy to avoid repeated calculations
*/
function getEnemyBounds(enemy) {
let x, y, w, h;
if (enemy.node && enemy.node.pos) {
// Use node's actual position and size
x = enemy.node.pos[0];
y = enemy.node.pos[1];
// Get actual rendered size from node
if (enemy.node.size && Array.isArray(enemy.node.size)) {
w = enemy.node.size[0];
h = enemy.node.size[1];
} else if (typeof enemy.node.computeSize === 'function') {
const computed = enemy.node.computeSize();
w = computed[0];
h = computed[1];
} else {
w = enemy.w;
h = enemy.h;
}
} else {
// Fallback to enemy's own tracking
w = enemy.w;
h = enemy.h;
x = enemy.cx - w / 2;
y = enemy.cy - h / 2;
}
const bounds = { x, y, w, h, cx: x + w / 2, cy: y + h / 2 };
return bounds;
}
/**
* Check if a bullet hits an enemy (line-rect collision)
* Uses node's actual position if available, falls back to enemy.cx/cy
*/
function bulletHitsEnemy(bullet, enemy) {
const padding = CONFIG.HIT_PADDING;
const bounds = getEnemyBounds(enemy);
const rx = bounds.x - padding;
const ry = bounds.y - padding;
const rw = bounds.w + padding * 2;
const rh = bounds.h + padding * 2;
return lineIntersectsRect(bullet.prevX, bullet.prevY, bullet.x, bullet.y, rx, ry, rw, rh);
}
/**
* Check circle-rectangle collision for player-enemy collision
*/
function circleRectCollision(cx, cy, radius, rx, ry, rw, rh) {
const nearestX = Math.max(rx, Math.min(cx, rx + rw));
const nearestY = Math.max(ry, Math.min(cy, ry + rh));
const dx = cx - nearestX;
const dy = cy - nearestY;
return (dx * dx + dy * dy) <= (radius * radius);
}
// ============================================================================
// PLAYER CLASS
// ============================================================================
class Player {
constructor() {
this.x = 0;
this.y = 0;
this.vx = 0;
this.vy = 0;
this.angle = -Math.PI / 2;
this.radius = CONFIG.PLAYER_RADIUS;
this.speed = CONFIG.PLAYER_SPEED;
}
reset(centerX, centerY) {
this.x = centerX;
this.y = centerY;
this.vx = 0;
this.vy = 0;
this.angle = -Math.PI / 2;
}
update(dt, input, view) {
// Calculate aim direction - direct angle (no smoothing for instant response)
const aim = this.getAimVector(input);
// Direct angle assignment - uçak mouse'a direkt bakar
this.angle = aim.angle;
// Movement is fixed to screen/world axes; mouse only controls aim.
let mx = 0;
let my = 0;
if (input.up) my -= 1;
if (input.down) my += 1;
if (input.left) mx -= 1;
if (input.right) mx += 1;
if (mx !== 0 || my !== 0) {
const mag = Math.hypot(mx, my) || 1;
this.vx = (mx / mag) * this.speed;
this.vy = (my / mag) * this.speed;
} else {
// Deceleration
this.vx *= 0.85;
this.vy *= 0.85;
}
// Apply movement
this.x += this.vx * dt;
this.y += this.vy * dt;
// Constrain to play area (rectangular bounds matching viewport)
const edgeMargin = CONFIG.PLAYER_EDGE_MARGIN;
const minX = view.left + view.width * edgeMargin;
const maxX = view.right - view.width * edgeMargin;
const minY = view.top + view.height * edgeMargin;
const maxY = view.bottom - view.height * edgeMargin;
if (this.x < minX) { this.x = minX; this.vx = Math.max(0, this.vx); }
else if (this.x > maxX) { this.x = maxX; this.vx = Math.min(0, this.vx); }
if (this.y < minY) { this.y = minY; this.vy = Math.max(0, this.vy); }
else if (this.y > maxY) { this.y = maxY; this.vy = Math.min(0, this.vy); }
}
getAimVector(input) {
let dx = input.pointerX - this.x;
let dy = input.pointerY - this.y;
if (!input.pointerActive) {
dx = 0;
dy = -1;
}
const len = Math.hypot(dx, dy) || 1;
return {
x: dx / len,
y: dy / len,
angle: Math.atan2(dy, dx)
};
}
draw(ctx, muzzleFlash, invulnTime = 0) {
const thrust = Math.hypot(this.vx, this.vy);
const thrustRatio = Math.min(thrust / this.speed, 1);
const now = performance.now();
// Invulnerability flicker — skip half the frames at high frequency
if (invulnTime > 0) {
const cycle = Math.floor(now * 0.012) & 1;
if (cycle === 0) return;
}
ctx.save();
ctx.translate(this.x, this.y);
// Energy shield ring while invulnerable
if (invulnTime > 0) {
const pulse = 0.6 + 0.4 * Math.sin(now * 0.012);
const r = 24 + pulse * 4;
ctx.save();
ctx.globalCompositeOperation = "screen";
const grad = ctx.createRadialGradient(0, 0, 12, 0, 0, r + 8);
grad.addColorStop(0, "rgba(96, 165, 250, 0)");
grad.addColorStop(0.7, `rgba(96, 165, 250, ${0.35 * pulse})`);
grad.addColorStop(1, "rgba(96, 165, 250, 0)");
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(0, 0, r + 8, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = `rgba(180, 220, 255, ${0.7 * pulse})`;
ctx.lineWidth = 1.5;
ctx.setLineDash([6, 6]);
ctx.lineDashOffset = -now * 0.05;
ctx.beginPath();
ctx.arc(0, 0, r, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
ctx.rotate(this.angle + Math.PI / 2);
// ── Twin engine exhaust ──
if (thrustRatio > 0.05) {
const flameLength = 16 + thrustRatio * 30;
const flicker = 1 + Math.sin(now * 0.02) * 0.18;
for (const ex of [-5, 5]) {
const flameWidth = 3 + thrustRatio * 2.5;
const outer = ctx.createLinearGradient(ex, 12, ex, 12 + flameLength * flicker);
outer.addColorStop(0, "rgba(160, 220, 255, 1)");
outer.addColorStop(0.35, "rgba(96, 165, 250, 0.7)");
outer.addColorStop(0.75, "rgba(80, 110, 220, 0.35)");
outer.addColorStop(1, "rgba(60, 80, 200, 0)");
ctx.fillStyle = outer;
ctx.beginPath();
ctx.moveTo(ex - flameWidth, 12);
ctx.quadraticCurveTo(ex, 12 + flameLength * 0.55 * flicker, ex + flameWidth, 12);
ctx.quadraticCurveTo(ex, 12 + flameLength * flicker, ex - flameWidth, 12);
ctx.fill();
const inner = ctx.createLinearGradient(ex, 11, ex, 11 + flameLength * 0.55 * flicker);
inner.addColorStop(0, "rgba(255, 255, 255, 1)");
inner.addColorStop(0.6, "rgba(200, 230, 255, 0.85)");
inner.addColorStop(1, "rgba(150, 200, 255, 0)");
ctx.fillStyle = inner;
ctx.beginPath();
ctx.moveTo(ex - flameWidth * 0.45, 11);
ctx.quadraticCurveTo(ex, 11 + flameLength * 0.4 * flicker, ex + flameWidth * 0.45, 11);
ctx.quadraticCurveTo(ex, 11 + flameLength * 0.6 * flicker, ex - flameWidth * 0.45, 11);
ctx.fill();
}
}
// ── Wing thruster glow ──
ctx.save();
ctx.globalCompositeOperation = "screen";
const wingGlow = ctx.createRadialGradient(0, 4, 4, 0, 4, 24);
wingGlow.addColorStop(0, "rgba(96, 165, 250, 0.55)");
wingGlow.addColorStop(1, "rgba(96, 165, 250, 0)");
ctx.fillStyle = wingGlow;
ctx.beginPath();
ctx.arc(0, 4, 24, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// ── Ship hull ──
const bodyGrad = ctx.createLinearGradient(0, -22, 0, 14);
bodyGrad.addColorStop(0, "#f0f6ff");
bodyGrad.addColorStop(0.5, "#a8c2e6");
bodyGrad.addColorStop(1, "#4060a0");
ctx.fillStyle = bodyGrad;
ctx.strokeStyle = "rgba(120, 180, 255, 0.95)";
ctx.lineWidth = 1.5;
// Main fuselage — chevron-style fighter
ctx.beginPath();
ctx.moveTo(0, -22); // Nose tip
ctx.lineTo(3.5, -10); // Upper hull edge
ctx.lineTo(13, 6); // Right wing tip
ctx.lineTo(9, 12); // Right wing trailing
ctx.lineTo(6, 10); // Right engine root
ctx.lineTo(2, 13); // Right engine outer
ctx.lineTo(0, 9); // Center notch
ctx.lineTo(-2, 13); // Left engine outer
ctx.lineTo(-6, 10); // Left engine root
ctx.lineTo(-9, 12); // Left wing trailing
ctx.lineTo(-13, 6); // Left wing tip
ctx.lineTo(-3.5, -10); // Upper hull edge
ctx.closePath();
ctx.fill();
ctx.stroke();
// Hull crease lines (panel detail)
ctx.strokeStyle = "rgba(60, 90, 140, 0.6)";
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(0, -20);
ctx.lineTo(0, 8);
ctx.moveTo(-3.5, -8);
ctx.lineTo(-9, 10);
ctx.moveTo(3.5, -8);
ctx.lineTo(9, 10);
ctx.stroke();
// ── Cockpit canopy ──
const cockpitGrad = ctx.createLinearGradient(0, -10, 0, 4);
cockpitGrad.addColorStop(0, "#a8d4ff");
cockpitGrad.addColorStop(0.5, "#3b82f6");
cockpitGrad.addColorStop(1, "#1e3a8a");
ctx.fillStyle = cockpitGrad;
ctx.strokeStyle = "rgba(180, 220, 255, 0.9)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, -10);
ctx.quadraticCurveTo(4, -7, 3, 2);
ctx.quadraticCurveTo(0, 4, -3, 2);
ctx.quadraticCurveTo(-4, -7, 0, -10);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Cockpit highlight
ctx.fillStyle = "rgba(255, 255, 255, 0.7)";
ctx.beginPath();
ctx.ellipse(-1, -6, 1, 2.5, 0, 0, Math.PI * 2);
ctx.fill();
// ── Wing weapon pods ──
ctx.fillStyle = "#1e293b";
ctx.strokeStyle = "rgba(120, 180, 255, 0.7)";
ctx.lineWidth = 0.8;
for (const wx of [-9, 9]) {
ctx.beginPath();
ctx.roundRect ? ctx.roundRect(wx - 1.5, -2, 3, 8, 1) :
ctx.rect(wx - 1.5, -2, 3, 8);
ctx.fill();
ctx.stroke();
}
// ── Engine ports (twin) ──
for (const ex of [-5, 5]) {
const ringGrad = ctx.createRadialGradient(ex, 11, 0, ex, 11, 4);
ringGrad.addColorStop(0, "#ffffff");
ringGrad.addColorStop(0.4, "rgba(160, 220, 255, 0.9)");
ringGrad.addColorStop(1, "rgba(96, 165, 250, 0)");
ctx.fillStyle = ringGrad;
ctx.beginPath();
ctx.arc(ex, 11, 4, 0, Math.PI * 2);
ctx.fill();
}
// ── Muzzle flash (paired wing-cannon flash) ──
if (muzzleFlash) {
ctx.save();
ctx.globalCompositeOperation = "screen";
for (const wx of [-9, 9, 0]) {
const yOffset = wx === 0 ? -28 : -4;
const sz = wx === 0 ? 8 : 5;
const flashGrad = ctx.createRadialGradient(wx, yOffset, 0, wx, yOffset, sz * 2.5);
flashGrad.addColorStop(0, "rgba(255, 255, 240, 1)");
flashGrad.addColorStop(0.4, "rgba(255, 220, 120, 0.85)");
flashGrad.addColorStop(1, "rgba(255, 150, 60, 0)");
ctx.fillStyle = flashGrad;
ctx.beginPath();
ctx.arc(wx, yOffset, sz * 2.5, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
// Front muzzle spike
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.moveTo(0, -32);
ctx.lineTo(3, -22);
ctx.lineTo(-3, -22);
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
}
// ============================================================================
// BULLET CLASS
// ============================================================================
class Bullet {
constructor(x, y, vx, vy, isEnemy = false) {
this.x = x;
this.y = y;
this.prevX = x;
this.prevY = y;
this.vx = vx;
this.vy = vy;
this.life = isEnemy ? CONFIG.ENEMY_BULLET_LIFETIME : CONFIG.BULLET_LIFETIME;
this.isEnemy = isEnemy;
this.active = true;
}
update(dt) {
this.prevX = this.x;
this.prevY = this.y;
this.x += this.vx * dt;
this.y += this.vy * dt;
this.life -= dt;
if (this.life <= 0) {
this.active = false;
}
}
isOutOfBounds(view, padding = 160) {
return this.x < view.left - padding ||
this.x > view.right + padding ||
this.y < view.top - padding ||
this.y > view.bottom + padding;
}
draw(ctx) {
ctx.save();
const dist = Math.hypot(this.x - this.prevX, this.y - this.prevY);
const trailLength = Math.min(dist, 25);
if (this.isEnemy) {
// Enemy bullet - red/orange theme
// Outer glow
ctx.shadowColor = "rgba(255, 60, 60, 1)";
ctx.shadowBlur = 16;
ctx.fillStyle = "rgba(255, 100, 100, 1)";
ctx.beginPath();
ctx.arc(this.x, this.y, 4.5, 0, Math.PI * 2);
ctx.fill();
// Core
ctx.shadowBlur = 0;
ctx.fillStyle = "rgba(255, 200, 120, 1)";
ctx.beginPath();
ctx.arc(this.x, this.y, 2.8, 0, Math.PI * 2);
ctx.fill();
// Bright center
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.arc(this.x, this.y, 1.2, 0, Math.PI * 2);
ctx.fill();
// Gradient trail
const dx = this.x - this.prevX;
const dy = this.y - this.prevY;
const angle = Math.atan2(dy, dx);
const startX = this.x - Math.cos(angle) * trailLength;
const startY = this.y - Math.sin(angle) * trailLength;
const gradient = ctx.createLinearGradient(startX, startY, this.x, this.y);
gradient.addColorStop(0, "rgba(255, 100, 100, 0)");
gradient.addColorStop(0.3, "rgba(255, 120, 80, 0.4)");
gradient.addColorStop(1, "rgba(255, 200, 120, 0.9)");
ctx.strokeStyle = gradient;
ctx.lineWidth = 3.5;
ctx.lineCap = "round";
ctx.shadowColor = "rgba(255, 60, 60, 0.8)";
ctx.shadowBlur = 12;
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(this.x, this.y);
ctx.stroke();
} else {
// Player bullet - bright yellow/white theme
// Outer glow
ctx.shadowColor = "rgba(255, 255, 150, 1)";
ctx.shadowBlur = 20;
ctx.fillStyle = "rgba(255, 255, 200, 1)";
ctx.beginPath();
ctx.arc(this.x, this.y, 4, 0, Math.PI * 2);
ctx.fill();
// Core
ctx.shadowBlur = 0;
ctx.fillStyle = "#ffffaa";
ctx.beginPath();
ctx.arc(this.x, this.y, 2.8, 0, Math.PI * 2);
ctx.fill();
// Bright center
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.arc(this.x, this.y, 1.4, 0, Math.PI * 2);
ctx.fill();
// Gradient trail
const dx = this.x - this.prevX;
const dy = this.y - this.prevY;
const angle = Math.atan2(dy, dx);
const startX = this.x - Math.cos(angle) * trailLength;
const startY = this.y - Math.sin(angle) * trailLength;
const gradient = ctx.createLinearGradient(startX, startY, this.x, this.y);
gradient.addColorStop(0, "rgba(255, 255, 200, 0)");
gradient.addColorStop(0.3, "rgba(255, 255, 180, 0.5)");
gradient.addColorStop(1, "rgba(255, 255, 220, 1)");
ctx.strokeStyle = gradient;
ctx.lineWidth = 3.2;
ctx.lineCap = "round";
ctx.shadowColor = "rgba(255, 255, 150, 0.9)";
ctx.shadowBlur = 14;
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(this.x, this.y);
ctx.stroke();
}
ctx.restore();
}
}
// ============================================================================
// ROCKET CLASS (Homing Missile)
// ============================================================================
class Rocket {
constructor(x, y, targetEnemy) {
this.x = x;
this.y = y;
this.prevX = x;
this.prevY = y;
this.target = targetEnemy;
this.vx = 0;
this.vy = -CONFIG.ROCKET_SPEED; // Start upward
this.speed = CONFIG.ROCKET_SPEED;
this.life = CONFIG.ROCKET_LIFETIME;
this.active = true;
this.angle = -Math.PI / 2;
}
update(dt, enemies) {
this.prevX = this.x;
this.prevY = this.y;
// Find new target if current is dead or out of range
if (!this.target || !this.target.active || this.target.hp <= 0) {
this.target = this.findNearestEnemy(enemies);
}
// Homing behavior
if (this.target && this.target.active) {
const dx = this.target.cx - this.x;
const dy = this.target.cy - this.y;
const dist = Math.hypot(dx, dy);
if (dist > 0) {
const targetAngle = Math.atan2(dy, dx);
let angleDiff = targetAngle - this.angle;
// Normalize angle difference to [-PI, PI]
while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
while (angleDiff < -Math.PI) angleDiff += Math.PI * 2;
// Turn towards target
const maxTurn = CONFIG.ROCKET_TURN_RATE * dt;
if (Math.abs(angleDiff) > maxTurn) {
this.angle += Math.sign(angleDiff) * maxTurn;
} else {
this.angle = targetAngle;
}
}
}
// Move in current direction
this.vx = Math.cos(this.angle) * this.speed;
this.vy = Math.sin(this.angle) * this.speed;
this.x += this.vx * dt;
this.y += this.vy * dt;
this.life -= dt;
if (this.life <= 0) {
this.active = false;
}
}
findNearestEnemy(enemies) {
let nearest = null;
let nearestDistSq = Infinity;
for (const enemy of enemies) {
if (!enemy.active || enemy.hp <= 0) continue;
const dx = enemy.cx - this.x;
const dy = enemy.cy - this.y;
const distSq = dx * dx + dy * dy; // Use squared distance to avoid sqrt
if (distSq < nearestDistSq) {
nearestDistSq = distSq;
nearest = enemy;
}
}
return nearest;
}
isOutOfBounds(view, padding = 200) {
return this.x < view.left - padding ||
this.x > view.right + padding ||
this.y < view.top - padding ||
this.y > view.bottom + padding;
}
checkHit(enemy) {
if (!enemy.active || enemy.hp <= 0) return false;
const dx = enemy.cx - this.x;
const dy = enemy.cy - this.y;
const distSq = dx * dx + dy * dy;
const radiusSq = CONFIG.ROCKET_EXPLOSION_RADIUS * CONFIG.ROCKET_EXPLOSION_RADIUS;
return distSq < radiusSq;
}
draw(ctx) {
ctx.save();
const now = performance.now();
const trailAngle = this.angle + Math.PI;
const trailLength = 70;
// ── Smoke trail (layered) ──
ctx.save();
ctx.globalCompositeOperation = "screen";
for (let layer = 4; layer >= 0; layer--) {
const width = 4 + layer * 4;
const alpha = 0.28 - layer * 0.05;
const layerLength = trailLength - layer * 10;
if (layerLength <= 0) continue;
const startX = this.x + Math.cos(trailAngle) * layerLength;
const startY = this.y + Math.sin(trailAngle) * layerLength;
const gradient = ctx.createLinearGradient(startX, startY, this.x, this.y);
gradient.addColorStop(0, "rgba(255, 80, 20, 0)");
gradient.addColorStop(0.35, `rgba(255, 110, 40, ${alpha})`);
gradient.addColorStop(0.75, `rgba(255, 190, 90, ${alpha * 1.4})`);
gradient.addColorStop(1, `rgba(255, 245, 200, ${alpha * 2.2})`);
ctx.strokeStyle = gradient;
ctx.lineWidth = width;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(this.x, this.y);
ctx.stroke();
}
ctx.restore();
// Core bright streak
const coreStartX = this.x + Math.cos(trailAngle) * 30;
const coreStartY = this.y + Math.sin(trailAngle) * 30;
const coreGradient = ctx.createLinearGradient(coreStartX, coreStartY, this.x, this.y);
coreGradient.addColorStop(0, "rgba(255, 255, 200, 0)");
coreGradient.addColorStop(0.5, "rgba(255, 255, 220, 0.85)");
coreGradient.addColorStop(1, "rgba(255, 255, 255, 1)");
ctx.strokeStyle = coreGradient;
ctx.lineWidth = 3;
ctx.shadowColor = "rgba(255, 200, 100, 1)";
ctx.shadowBlur = 18;
ctx.beginPath();
ctx.moveTo(coreStartX, coreStartY);
ctx.lineTo(this.x, this.y);
ctx.stroke();
// ── Lock-on reticle around target ──
if (this.target && this.target.active) {
ctx.save();
ctx.globalCompositeOperation = "screen";
const tx = this.target.cx;
const ty = this.target.cy;
const r = Math.max(this.target.w, this.target.h) * 0.65;
const wobble = (Math.sin(now * 0.012) + 1) * 0.5;
ctx.strokeStyle = `rgba(255, 80, 80, ${0.5 + wobble * 0.3})`;
ctx.lineWidth = 1.5;
// 4 corner brackets
const brackets = [
[-1, -1], [1, -1], [1, 1], [-1, 1]
];
const bs = 8;
for (const [sx, sy] of brackets) {
const x = tx + sx * r;
const y = ty + sy * r;
ctx.beginPath();
ctx.moveTo(x, y - sy * bs);
ctx.lineTo(x, y);
ctx.lineTo(x - sx * bs, y);
ctx.stroke();
}
ctx.restore();
}
// Rocket body
ctx.shadowBlur = 0;
ctx.translate(this.x, this.y);
ctx.rotate(this.angle);
// Outer glow halo
ctx.shadowColor = "rgba(255, 100, 50, 1)";
ctx.shadowBlur = 26;
ctx.fillStyle = "rgba(255, 150, 50, 0.85)";
ctx.beginPath();
ctx.arc(0, 0, 9, 0, Math.PI * 2);
ctx.fill();
// Sleek missile body (gradient metal)
ctx.shadowBlur = 10;
const bodyGrad = ctx.createLinearGradient(-7, 0, 14, 0);
bodyGrad.addColorStop(0, "#ff8a3c");
bodyGrad.addColorStop(0.5, "#ffd166");
bodyGrad.addColorStop(1, "#ffffff");
ctx.fillStyle = bodyGrad;
ctx.beginPath();
ctx.moveTo(14, 0); // Nose tip
ctx.lineTo(8, -3);
ctx.lineTo(-3, -4); // Top fin root
ctx.lineTo(-7, -7); // Top fin tip
ctx.lineTo(-7, -1);
ctx.lineTo(-9, 0); // Tail center
ctx.lineTo(-7, 1);
ctx.lineTo(-7, 7); // Bottom fin tip
ctx.lineTo(-3, 4); // Bottom fin root
ctx.lineTo(8, 3);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = "rgba(180, 60, 20, 0.7)";
ctx.lineWidth = 0.8;
ctx.stroke();
// Hot tip core
ctx.shadowBlur = 0;
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.arc(4, 0, 2.5, 0, Math.PI * 2);
ctx.fill();
// Engine exhaust burn
const burn = ctx.createRadialGradient(-9, 0, 0, -9, 0, 5);
burn.addColorStop(0, "rgba(255, 255, 255, 1)");
burn.addColorStop(0.5, "rgba(255, 200, 100, 0.85)");
burn.addColorStop(1, "rgba(255, 100, 30, 0)");
ctx.fillStyle = burn;
ctx.beginPath();
ctx.arc(-9, 0, 5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
// ============================================================================
// ENEMY CLASS
// ============================================================================
class Enemy {
constructor(typeInfo, cx, cy, vx, vy, graph, graphCanvas) {
this.name = typeInfo.name;
this.color = typeInfo.color;
this.header = typeInfo.header;
this.accent = typeInfo.accent;
this.stroke = typeInfo.stroke;
this.hp = typeInfo.hp;
this.maxHp = typeInfo.hp;
this.isBoss = !!typeInfo.isBoss;
this.bossImages = this.isBoss ? getBossImages() : null;
if (this.isBoss) {
this.w = CONFIG.BOSS_NODE_WIDTH;
this.h = CONFIG.BOSS_NODE_HEIGHT;
} else {
this.w = typeInfo.size;
this.h = Math.round(typeInfo.size * 0.62);
}
this.cx = cx;
this.cy = cy;
this.vx = vx;
this.vy = vy;
this.maxSpeed = typeInfo.speed + rand(-12, 16);
this.seek = typeInfo.seek * rand(0.7, 1.3);
this.orbit = typeInfo.orbit + rand(-8, 8);
this.wobble = typeInfo.wobble * rand(0.6, 1.4);
this.wobbleSpeed = typeInfo.wobbleSpeed * rand(0.7, 1.5);
this.canShoot = typeInfo.canShoot;
this.fireInterval = typeInfo.fireInterval;
this.fireTimer = rand(0.6, typeInfo.fireInterval);
this.portsIn = typeInfo.portsIn;
this.portsOut = typeInfo.portsOut;
this.points = typeInfo.points;
this.typeCandidates = typeInfo.typeCandidates;
this.hitFlash = 0;
this.flashColor = typeInfo.accent;
this.flashApplied = false;
this.t = rand(0, 2);
this.jinkTimer = rand(0.4, 1.2);
this.jinkStrength = rand(22, 78);
this.anchorAngle = rand(0, Math.PI * 2);
this.anchorRadius = this.isBoss ? rand(340, 460) : rand(170, 380);
this.anchorSpin = (Math.random() > 0.5 ? 1 : -1) * rand(0.12, 0.38);
this.bossDashTimer = this.isBoss ? rand(CONFIG.BOSS_DASH_MIN_INTERVAL, CONFIG.BOSS_DASH_MAX_INTERVAL) : 0;
this.bossDash = null;
this.dashSoundQueued = false;
this.node = null;
this.baseColor = typeInfo.header;
this.baseBg = typeInfo.color;
this.active = true;
if (!this.isBoss) {
this.createNode(graph, graphCanvas, typeInfo);
}
}
createNode(graph, graphCanvas, typeInfo) {
if (!graph?.add) return;
const LiteGraph = getLiteGraph();
if (!LiteGraph) return;
// Find available node type
const candidates = Array.isArray(typeInfo.typeCandidates) ? typeInfo.typeCandidates : [];
let nodeType = pickAvailableNodeType(candidates);
if (!nodeType) {
nodeType = pickAvailableNodeType(["Reroute", "Note", "PrimitiveNode"]);
}
if (!nodeType) return;
const node = createGraphNode(nodeType);
if (!node) return;
// Configure node appearance
node.title = typeInfo.name;
node.color = typeInfo.header;
node.bgcolor = typeInfo.color;
node._nodeInvader = true;
node._nodeInvaderBase = { color: typeInfo.header, bgcolor: typeInfo.color };
if (typeof node.computeSize === "function") {
node.size = node.computeSize();
}
if (Array.isArray(node.size)) {
this.w = node.size[0];
this.h = node.size[1];
node.pos = [this.cx - this.w / 2, this.cy - this.h / 2];
} else {
node.pos = [this.cx, this.cy];
}
// Ensure visibility
if (node.setFlag) {
node.setFlag(LiteGraph.FLAG_VISIBLE, true);
}
// Add to graph
graph.add(node);
if (node.setDirty) {
node.setDirty(true);
}
this.node = node;
this.baseColor = node.color;
this.baseBg = node.bgcolor;
}
update(dt, view, center, playerX, playerY, syncTimer = 0) {
this.t += dt;
if (this.isBoss && this.updateBossDash(dt, view, playerX, playerY)) {
if (this.canShoot) {
this.fireTimer -= dt;
}
this.hitFlash = Math.max(0, this.hitFlash - dt);
return;
}
// Calculate boundary margins
const margin = Math.max(CONFIG.ENEMY_BOUNDARY_MARGIN, Math.max(this.w, this.h));
const minX = view.left - margin;
const maxX = view.right + margin;
const minY = view.top - margin;
const maxY = view.bottom + margin;
const isNearEdge = this.cx < minX + margin * 0.5 || this.cx > maxX - margin * 0.5 ||
this.cy < minY + margin * 0.5 || this.cy > maxY - margin * 0.5;
const isOutside = this.cx < minX || this.cx > maxX || this.cy < minY || this.cy > maxY;
const orbitPhase = this.anchorAngle + this.t * this.anchorSpin;
const targetRadius = this.anchorRadius + Math.sin(this.t * 0.55 + this.anchorAngle) * 34;
const targetX = playerX + Math.cos(orbitPhase) * targetRadius;
const targetY = playerY + Math.sin(orbitPhase) * targetRadius;
const toTargetX = targetX - this.cx;
const toTargetY = targetY - this.cy;
const dist = Math.hypot(toTargetX, toTargetY) || 1;
const nx = toTargetX / dist;
const ny = toTargetY / dist;
const toPlayerX = this.cx - playerX;
const toPlayerY = this.cy - playerY;
const playerDist = Math.hypot(toPlayerX, toPlayerY) || 1;
const minPlayerDist = this.isBoss ? 240 : 95;
// Adjust forces based on position
const orbitMultiplier = isNearEdge ? 0.2 : 1.0;
const seekMultiplier = isNearEdge ? 2.0 : 1.0;
// Apply seek and orbit forces
this.vx += (nx * this.seek * seekMultiplier + -ny * this.orbit * orbitMultiplier) * dt * 30;
this.vy += (ny * this.seek * seekMultiplier + nx * this.orbit * orbitMultiplier) * dt * 30;
if (playerDist < minPlayerDist) {
const repel = (1 - playerDist / minPlayerDist) * (this.isBoss ? 170 : 115);
this.vx += (toPlayerX / playerDist) * repel * dt;
this.vy += (toPlayerY / playerDist) * repel * dt;
}
// Apply wobble
const wobbleMultiplier = isNearEdge ? 0.3 : 1.0;
const wobble = Math.sin(this.t * this.wobbleSpeed) * this.wobble * wobbleMultiplier;
this.vx += -ny * wobble * 30 * dt;
this.vy += nx * wobble * 30 * dt;
// Apply jink (random acceleration)
this.jinkTimer -= dt;
if (this.jinkTimer <= 0) {
const jink = this.jinkStrength * (isNearEdge ? 0.3 : 1.0);
this.vx += rand(-jink, jink);
this.vy += rand(-jink, jink);
this.jinkTimer = rand(0.5, 1.6);
}
// Strong push toward the current engagement lane if outside
if (isOutside) {
const pushStrength = 150;
this.vx += nx * pushStrength * dt;
this.vy += ny * pushStrength * dt;
}
// Clamp speed
const speed = Math.hypot(this.vx, this.vy);
if (speed > this.maxSpeed) {
this.vx = (this.vx / speed) * this.maxSpeed;
this.vy = (this.vy / speed) * this.maxSpeed;
}
// Apply velocity
this.cx += this.vx * dt;
this.cy += this.vy * dt;
// Hard boundary enforcement
if (this.cx < minX) {
this.cx = minX;
if (this.vx < 0) this.vx = Math.abs(nx) * this.maxSpeed * 0.8;
} else if (this.cx > maxX) {
this.cx = maxX;
if (this.vx > 0) this.vx = -Math.abs(nx) * this.maxSpeed * 0.8;
}
if (this.cy < minY) {
this.cy = minY;
if (this.vy < 0) this.vy = Math.abs(ny) * this.maxSpeed * 0.8;
} else if (this.cy > maxY) {
this.cy = maxY;
if (this.vy > 0) this.vy = -Math.abs(ny) * this.maxSpeed * 0.8;
}
// Additional pull toward the current engagement lane when at boundary
if (isOutside || isNearEdge) {
const pullStrength = isOutside ? 200 : 80;
this.vx += nx * pullStrength * dt;
this.vy += ny * pullStrength * dt;
}
// Update fire timer
if (this.canShoot) {
this.fireTimer -= dt;
}
// Update hit flash
this.hitFlash = Math.max(0, this.hitFlash - dt);
// Sync node position (throttled)
this.syncNodePosition(dt, syncTimer);
}
updateBossDash(dt, view, playerX, playerY) {
if (!this.isBoss) return false;
if (this.bossDash) {
const dash = this.bossDash;
dash.elapsed += dt;
const t = clamp(dash.elapsed / dash.duration, 0, 1);
const eased = 1 - Math.pow(1 - t, 3);
this.cx = dash.fromX + (dash.toX - dash.fromX) * eased;
this.cy = dash.fromY + (dash.toY - dash.fromY) * eased;
this.vx = 0;
this.vy = 0;
if (t >= 1) {
this.bossDash = null;
this.bossDashTimer = rand(CONFIG.BOSS_DASH_MIN_INTERVAL, CONFIG.BOSS_DASH_MAX_INTERVAL);
}
return true;
}
this.bossDashTimer -= dt;
if (this.bossDashTimer > 0) return false;
const padX = Math.min(this.w * 0.5, Math.max(80, view.width * 0.18));
const padY = Math.min(this.h * 0.5, Math.max(70, view.height * 0.16));
const minX = view.left + padX;
const maxX = view.right - padX;
const minY = view.top + padY;
const maxY = view.bottom - padY;
let toX = this.cx;
let toY = this.cy;
for (let i = 0; i < 12; i++) {
const angle = rand(0, Math.PI * 2);
const distance = rand(CONFIG.BOSS_DASH_MIN_DISTANCE, CONFIG.BOSS_DASH_MAX_DISTANCE);
const candidateX = clamp(this.cx + Math.cos(angle) * distance, minX, maxX);
const candidateY = clamp(this.cy + Math.sin(angle) * distance, minY, maxY);
const fromDist = Math.hypot(candidateX - this.cx, candidateY - this.cy);
const playerDist = Math.hypot(candidateX - playerX, candidateY - playerY);
if (fromDist >= CONFIG.BOSS_DASH_MIN_DISTANCE * 0.75 &&
fromDist <= CONFIG.BOSS_DASH_MAX_DISTANCE &&
playerDist >= CONFIG.BOSS_DASH_PLAYER_SAFE_DISTANCE) {
toX = candidateX;
toY = candidateY;
break;
}
}
if (toX === this.cx && toY === this.cy) {
const awayX = this.cx - playerX;
const awayY = this.cy - playerY;
const awayLen = Math.hypot(awayX, awayY) || 1;
const distance = CONFIG.BOSS_DASH_MIN_DISTANCE;
toX = clamp(this.cx + (awayX / awayLen) * distance, minX, maxX);
toY = clamp(this.cy + (awayY / awayLen) * distance, minY, maxY);
}
this.bossDash = {
fromX: this.cx,
fromY: this.cy,
toX,
toY,
elapsed: 0,
duration: CONFIG.BOSS_DASH_DURATION
};
this.flashApplied = false;
this.dashSoundQueued = true;
return true;
}
syncNodePosition(dt, syncTimer) {
if (!this.node) return;
if (!this.node.graph) {
this.node = null;
return;
}
// Throttle node position updates
if (syncTimer > 0) {
// Still update bounds cache frame even if not syncing
this._boundsFrame = (this._boundsFrame || 0) + 1;
return;
}
// Update size from node if available
if (Array.isArray(this.node.size)) {
this.w = this.node.size[0];
this.h = this.node.size[1];
}
// Update flash effect on node
if (this.hitFlash > 0) {
if (!this.flashApplied) {
this.flashApplied = true;
this.node.color = "#ffffff";
this.node.bgcolor = this.flashColor;
}
} else if (this.flashApplied) {
this.flashApplied = false;
this.node.color = this.baseColor;
this.node.bgcolor = this.baseBg;
}
// Update node position from our game position (center to top-left)
const newPosX = this.cx - this.w / 2;
const newPosY = this.cy - this.h / 2;
if (Array.isArray(this.node.pos)) {
this.node.pos[0] = newPosX;
this.node.pos[1] = newPosY;
} else {
this.node.pos = [newPosX, newPosY];
}
// Don't call setDirty on every sync - let graph redraw handle it
// if (this.node.setDirty) {
// this.node.setDirty(true);
// }
if (Array.isArray(this.node.pos)) {
this.cx = this.node.pos[0] + this.w / 2;
this.cy = this.node.pos[1] + this.h / 2;
}
}
shouldFire() {
return this.canShoot && this.fireTimer <= 0;
}
resetFireTimer() {
this.fireTimer = this.fireInterval + rand(-0.4, 0.4);
}
takeDamage(amount = 1) {
this.hp = Math.max(0, this.hp - amount);
this.hitFlash = 0.16;
return this.hp <= 0;
}
checkPlayerCollision(playerX, playerY, playerRadius) {
const bounds = getEnemyBounds(this);
return circleRectCollision(playerX, playerY, playerRadius, bounds.x, bounds.y, bounds.w, bounds.h);
}
removeNode(graph) {
if (this.node && graph) {
graph.remove(this.node);
this.node = null;
}
}
drawBossOverlay(ctx, time) {
if (!this.isBoss) return;
const cx = this.cx;
const cy = this.cy;
const w = this.w;
const h = this.h;
const halfDiag = Math.hypot(w, h) / 2;
ctx.save();
ctx.globalCompositeOperation = "screen";
// Outer pulsing aura
const pulse = 0.55 + 0.45 * Math.sin(time * 3.4);
for (let layer = 3; layer >= 0; layer--) {
const r = halfDiag + 18 + layer * 18 + pulse * 8;
const alpha = (0.18 - layer * 0.04) * (0.6 + pulse * 0.4);
const grad = ctx.createRadialGradient(cx, cy, halfDiag * 0.8, cx, cy, r);
grad.addColorStop(0, `rgba(192, 132, 252, ${alpha})`);
grad.addColorStop(0.6, `rgba(240, 171, 252, ${alpha * 0.5})`);
grad.addColorStop(1, "rgba(192, 132, 252, 0)");
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
// Crackling boss frame
ctx.save();
ctx.strokeStyle = `rgba(240, 171, 252, ${0.7 + pulse * 0.3})`;
ctx.lineWidth = 2.5;
ctx.shadowColor = "rgba(192, 132, 252, 0.9)";
ctx.shadowBlur = 18;
const x0 = cx - w / 2 - 6;
const y0 = cy - h / 2 - 6;
const cornerW = 22;
const cornerH = 22;
// Four animated corner brackets
ctx.beginPath();
ctx.moveTo(x0, y0 + cornerH); ctx.lineTo(x0, y0); ctx.lineTo(x0 + cornerW, y0);
ctx.moveTo(x0 + w + 12 - cornerW, y0); ctx.lineTo(x0 + w + 12, y0); ctx.lineTo(x0 + w + 12, y0 + cornerH);
ctx.moveTo(x0 + w + 12, y0 + h + 12 - cornerH); ctx.lineTo(x0 + w + 12, y0 + h + 12); ctx.lineTo(x0 + w + 12 - cornerW, y0 + h + 12);
ctx.moveTo(x0 + cornerW, y0 + h + 12); ctx.lineTo(x0, y0 + h + 12); ctx.lineTo(x0, y0 + h + 12 - cornerH);
ctx.stroke();
ctx.restore();
// Boss label + HP bar above the node
ctx.save();
ctx.shadowColor = "rgba(0,0,0,0.7)";
ctx.shadowBlur = 6;
ctx.font = "800 13px 'Segoe UI Variable Display', 'Segoe UI', Inter, system-ui, sans-serif";
ctx.letterSpacing = "0.08em";
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
ctx.fillStyle = "#f0abfc";
ctx.fillText("BOSS", cx, cy - h / 2 - 18);
ctx.letterSpacing = "0px";
ctx.shadowBlur = 0;
const barW = w;
const barH = 6;
const barX = cx - barW / 2;
const barY = cy - h / 2 - 12;
const ratio = clamp(this.hp / this.maxHp, 0, 1);
ctx.fillStyle = "rgba(0,0,0,0.55)";
ctx.fillRect(barX, barY, barW, barH);
const hpGrad = ctx.createLinearGradient(barX, barY, barX + barW, barY);
hpGrad.addColorStop(0, "#f0abfc");
hpGrad.addColorStop(0.5, "#c084fc");
hpGrad.addColorStop(1, "#7c3aed");
ctx.fillStyle = hpGrad;
ctx.fillRect(barX, barY, barW * ratio, barH);
ctx.strokeStyle = "rgba(240, 171, 252, 0.8)";
ctx.lineWidth = 1;
ctx.strokeRect(barX, barY, barW, barH);
ctx.restore();
}
draw(ctx) {
if (this.isBoss) {
this.drawBossSprite(ctx);
return;
}
// Only draw fallback if no node exists
if (this.node) return;
const x = this.cx;
const y = this.cy;
const w = this.w;
const h = this.h;
const x0 = x - w / 2;
const y0 = y - h / 2;
const radius = 8;
const headerHeight = 18;
ctx.save();
if (this.hitFlash > 0) {
ctx.globalAlpha = 0.9;
}
// Body
ctx.fillStyle = this.color;
ctx.strokeStyle = this.stroke;
ctx.lineWidth = 2;
roundRect(ctx, x0, y0, w, h, radius);
ctx.fill();
ctx.stroke();
// Header
ctx.fillStyle = this.header;
roundRect(ctx, x0, y0, w, headerHeight, radius, true);
ctx.fill();
// Title
ctx.fillStyle = "#f2f6ff";
ctx.font = "750 11px 'Segoe UI Variable', 'Segoe UI', Inter, system-ui, sans-serif";
ctx.fillText(this.name, x0 + 8, y0 + 13);
// Ports
ctx.fillStyle = this.accent;
for (let i = 0; i < this.portsIn; i++) {
const py = y0 + headerHeight + ((i + 1) * (h - headerHeight)) / (this.portsIn + 1);
ctx.beginPath();
ctx.arc(x0 - 3, py, 3, 0, Math.PI * 2);
ctx.fill();
}
for (let i = 0; i < this.portsOut; i++) {
const py = y0 + headerHeight + ((i + 1) * (h - headerHeight)) / (this.portsOut + 1);
ctx.beginPath();
ctx.arc(x0 + w + 3, py, 3, 0, Math.PI * 2);
ctx.fill();
}
// HP bar
const hpWidth = w - 14;
const hpRatio = this.hp / this.maxHp;
ctx.fillStyle = "rgba(0, 0, 0, 0.45)";
ctx.fillRect(x0 + 7, y0 + h - 10, hpWidth, 4);
ctx.fillStyle = this.accent;
ctx.fillRect(x0 + 7, y0 + h - 10, hpWidth * hpRatio, 4);
ctx.restore();
}
drawBossSprite(ctx) {
const x0 = this.cx - this.w / 2;
const y0 = this.cy - this.h / 2;
const images = this.bossImages || [];
const img = images[Math.floor(performance.now() / 1000) % 2] || images[0];
const pulse = 0.5 + 0.5 * Math.sin(performance.now() * 0.004);
ctx.save();
ctx.globalCompositeOperation = "screen";
const glow = ctx.createRadialGradient(this.cx, this.cy, this.w * 0.18, this.cx, this.cy, this.w * 0.62);
glow.addColorStop(0, `rgba(240, 171, 252, ${0.22 + pulse * 0.12})`);
glow.addColorStop(0.55, `rgba(124, 58, 237, ${0.16 + pulse * 0.08})`);
glow.addColorStop(1, "rgba(124, 58, 237, 0)");
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(this.cx, this.cy, this.w * 0.64, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
ctx.save();
if (this.hitFlash > 0) {
ctx.shadowColor = "#ffffff";
ctx.shadowBlur = 26;
} else {
ctx.shadowColor = "rgba(240, 171, 252, 0.75)";
ctx.shadowBlur = 18;
}
if (img && img.complete && img.naturalWidth > 0) {
ctx.drawImage(img, x0, y0, this.w, this.h);
} else {
const grad = ctx.createLinearGradient(x0, y0, x0, y0 + this.h);
grad.addColorStop(0, "#3b0764");
grad.addColorStop(1, "#12031f");
ctx.fillStyle = grad;
roundRect(ctx, x0, y0, this.w, this.h, 14);
ctx.fill();
ctx.strokeStyle = "#f0abfc";
ctx.lineWidth = 3;
ctx.stroke();
ctx.shadowBlur = 0;
ctx.fillStyle = "#f0abfc";
ctx.font = "900 46px 'Segoe UI Variable Display', 'Segoe UI', Inter, system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("BOSS", this.cx, this.cy);
}
if (this.hitFlash > 0) {
ctx.globalCompositeOperation = "screen";
ctx.globalAlpha = clamp(this.hitFlash * 3, 0, 0.65);
ctx.fillStyle = "#ffffff";
ctx.fillRect(x0, y0, this.w, this.h);
}
ctx.restore();
}
}
// ============================================================================
// PARTICLE SYSTEM
// ============================================================================
class Particle {
constructor(x, y, vx, vy, life, size, color, kind) {
this.x = x;
this.y = y;
this.vx = vx;
this.vy = vy;
this.life = life;
this.maxLife = life;
this.size = size;
this.color = color;
this.kind = kind;
this.active = true;
this.rotation = rand(0, Math.PI * 2);
this.rotationSpeed = rand(-8, 8);
}
update(dt) {
this.x += this.vx * dt;
this.y += this.vy * dt;
this.rotation += this.rotationSpeed * dt;
if (this.kind === "spark") {
this.vx *= 0.98;
this.vy *= 0.98;
} else if (this.kind === "exhaust") {
this.vx *= 0.92;
this.vy *= 0.92;
this.size *= 0.97;
} else if (this.kind === "debris") {
this.vy += 50 * dt;
this.vx *= 0.99;
this.size *= 0.995;
} else {
this.vy += 40 * dt;
}
this.life -= dt;
if (this.life <= 0) {
this.active = false;
}
}
draw(ctx, view) {
// Viewport culling
const padding = 50;
if (this.x < view.left - padding || this.x > view.right + padding ||
this.y < view.top - padding || this.y > view.bottom + padding) {
return;
}
const lifeRatio = this.life / this.maxLife;
const alpha = clamp(lifeRatio * 1.5, 0, 1);
if (alpha < 0.02) return;
ctx.globalAlpha = alpha;
if (this.kind === "spark") {
// No shadowBlur - use bright color + trail for glow illusion
ctx.strokeStyle = this.color;
ctx.lineWidth = this.size * 1.8;
ctx.lineCap = "round";
ctx.beginPath();
const tailX = this.x - this.vx * 0.06;
const tailY = this.y - this.vy * 0.06;
ctx.moveTo(tailX, tailY);
ctx.lineTo(this.x, this.y);
ctx.stroke();
// Bright center dot
if (this.size > 1.2) {
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.arc(this.x, this.y, this.size * 0.35, 0, Math.PI * 2);
ctx.fill();
}
} else if (this.kind === "exhaust") {
// Simple circle, no shadow
ctx.fillStyle = "rgba(255, 180, 80, 0.85)";
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
} else {
// Debris - no shadow, use double-circle for glow illusion
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
if (this.size > 1.8) {
ctx.globalAlpha = alpha * 0.5;
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.arc(this.x, this.y, this.size * 0.3, 0, Math.PI * 2);
ctx.fill();
}
}
}
}
class Shockwave {
constructor(x, y, maxRadius, life, color) {
this.x = x;
this.y = y;
this.radius = 0;
this.maxRadius = maxRadius;
this.life = life;
this.maxLife = life;
this.color = color;
this.active = true;
}
update(dt) {
this.radius += (this.maxRadius / this.maxLife) * dt * 1.2;
this.life -= dt;
if (this.life <= 0) {
this.active = false;
}
}
draw(ctx, view) {
// Viewport culling
const padding = this.maxRadius;
if (this.x + padding < view.left || this.x - padding > view.right ||
this.y + padding < view.top || this.y - padding > view.bottom) {
return;
}
const lifeRatio = this.life / this.maxLife;
if (lifeRatio < 0.02) return;
const alpha = clamp(lifeRatio * 1.5, 0, 0.8);
// Outer glow ring (wider, dimmer — no shadowBlur)
ctx.strokeStyle = this.color;
ctx.globalAlpha = alpha * 0.35;
ctx.lineWidth = 6;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.stroke();
// Main ring
ctx.globalAlpha = alpha * 0.7;
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.stroke();
// Inner bright ring
ctx.strokeStyle = "#ffffff";
ctx.globalAlpha = alpha * 0.8;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius * 0.85, 0, Math.PI * 2);
ctx.stroke();
}
}
class Explosion {
constructor(x, y, maxRadius, life, color, accent) {
this.x = x;
this.y = y;
this.maxRadius = maxRadius;
this.life = life;
this.maxLife = life;
this.color = color;
this.accent = accent || color;
this.active = true;
this.pulsePhase = rand(0, Math.PI * 2);
}
update(dt) {
this.life -= dt;
this.pulsePhase += dt * 12;
if (this.life <= 0) {
this.active = false;
}
}
draw(ctx, view) {
// Viewport culling
const padding = this.maxRadius;
if (this.x + padding < view.left || this.x - padding > view.right ||
this.y + padding < view.top || this.y - padding > view.bottom) {
return;
}
const lifeRatio = clamp(this.life / this.maxLife, 0, 1);
if (lifeRatio < 0.02) return;
const expansion = 1 - lifeRatio;
const pulse = 1 + Math.sin(this.pulsePhase) * 0.15;
const radius = this.maxRadius * expansion * pulse;
const fade = lifeRatio;
// Outer glow layer (no shadowBlur — larger, dimmer circle)
ctx.globalAlpha = fade * 0.3;
ctx.fillStyle = this.accent;
ctx.beginPath();
ctx.arc(this.x, this.y, radius * 0.9, 0, Math.PI * 2);
ctx.fill();
// Main explosion body
ctx.globalAlpha = fade * 0.85;
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, radius * 0.65, 0, Math.PI * 2);
ctx.fill();
// Bright core
ctx.globalAlpha = fade;
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.arc(this.x, this.y, Math.max(3, radius * 0.25), 0, Math.PI * 2);
ctx.fill();
// Sparkle effect (only if bright and large enough)
if (lifeRatio > 0.4 && radius > 15) {
ctx.globalAlpha = (lifeRatio - 0.4) * 2;
ctx.fillStyle = "#ffffff";
for (let i = 0; i < 4; i++) {
const angle = (i / 4) * Math.PI * 2 + this.pulsePhase;
const dist = radius * 0.5;
const sparkX = this.x + Math.cos(angle) * dist;
const sparkY = this.y + Math.sin(angle) * dist;
ctx.beginPath();
ctx.arc(sparkX, sparkY, 1.5, 0, Math.PI * 2);
ctx.fill();
}
}
}
}
// ============================================================================
// FLOATING TEXT CLASS (for combos and milestones)
// ============================================================================
class FloatingText {
constructor(x, y, text, color, size = 24, duration = 1.2) {
this.x = x;
this.y = y;
this.text = text;
this.color = color;
this.size = size;
this.life = duration;
this.maxLife = duration;
this.active = true;
this.vy = -60; // Float upward
this.scale = 0; // Start small, scale up
}
update(dt) {
this.life -= dt;
this.y += this.vy * dt;
this.vy *= 0.95; // Slow down
// Scale animation
const lifeRatio = this.life / this.maxLife;
if (lifeRatio > 0.8) {
// Pop in
this.scale = 1 - ((lifeRatio - 0.8) / 0.2);
this.scale = 1 + (1 - this.scale) * 0.3; // Overshoot
} else if (lifeRatio > 0.2) {
this.scale = 1;
} else {
// Fade out
this.scale = lifeRatio / 0.2;
}
if (this.life <= 0) {
this.active = false;
}
}
draw(ctx) {
if (this.scale <= 0) return;
const alpha = clamp(this.life / this.maxLife * 2, 0, 1);
ctx.save();
ctx.translate(this.x, this.y);
ctx.scale(this.scale, this.scale);
ctx.font = `850 ${this.size}px 'Segoe UI Variable Display', 'Segoe UI', Inter, system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.globalAlpha = alpha;
ctx.shadowColor = "rgba(0, 0, 0, 0.85)";
ctx.shadowBlur = 10;
ctx.shadowOffsetY = 2;
ctx.strokeStyle = "rgba(3, 7, 18, 0.82)";
ctx.lineJoin = "round";
ctx.lineWidth = Math.max(3, this.size * 0.12);
ctx.strokeText(this.text, 0, 0);
const grad = ctx.createLinearGradient(0, -this.size * 0.55, 0, this.size * 0.55);
grad.addColorStop(0, "#ffffff");
grad.addColorStop(0.18, this.color);
grad.addColorStop(1, this.color);
ctx.shadowColor = this.color;
ctx.shadowBlur = 14;
ctx.fillStyle = grad;
ctx.fillText(this.text, 0, 0);
ctx.restore();
}
}
class ParticleSystem {
constructor() {
this.particles = [];
this.shockwaves = [];
this.explosions = [];
this.maxParticles = 300; // Limit total particles for performance
}
clear() {
this.particles = [];
this.shockwaves = [];
this.explosions = [];
}
update(dt) {
// Update particles (limit count for performance)
if (this.particles.length > this.maxParticles) {
// Remove oldest inactive particles first, then oldest active ones
const toRemove = this.particles.length - this.maxParticles;
for (let i = 0; i < toRemove && i < this.particles.length; i++) {
if (!this.particles[i].active) {
this.particles.splice(i, 1);
i--;
}
}
// If still over limit, remove oldest
while (this.particles.length > this.maxParticles) {
this.particles.shift();
}
}
// Batch update particles - more efficient removal
for (let i = this.particles.length - 1; i >= 0; i--) {
const particle = this.particles[i];
particle.update(dt);
if (!particle.active) {
// Swap with last element for O(1) removal
if (i < this.particles.length - 1) {
this.particles[i] = this.particles[this.particles.length - 1];
}
this.particles.pop();
}
}
// Update shockwaves - optimized removal
for (let i = this.shockwaves.length - 1; i >= 0; i--) {
const wave = this.shockwaves[i];
wave.update(dt);
if (!wave.active) {
if (i < this.shockwaves.length - 1) {
this.shockwaves[i] = this.shockwaves[this.shockwaves.length - 1];
}
this.shockwaves.pop();
}
}
// Update explosions - optimized removal
for (let i = this.explosions.length - 1; i >= 0; i--) {
const explosion = this.explosions[i];
explosion.update(dt);
if (!explosion.active) {
if (i < this.explosions.length - 1) {
this.explosions[i] = this.explosions[this.explosions.length - 1];
}
this.explosions.pop();
}
}
}
addExhaust(x, y, angle) {
const exhaustAngle = angle + Math.PI + rand(-0.3, 0.3);
const exhaustSpeed = rand(20, 60);
this.particles.push(new Particle(
x + Math.cos(exhaustAngle) * 8,
y + Math.sin(exhaustAngle) * 8,
Math.cos(exhaustAngle) * exhaustSpeed,
Math.sin(exhaustAngle) * exhaustSpeed,
rand(0.15, 0.3),
rand(1.5, 2.6),
"rgba(255, 180, 90, 0.7)",
"exhaust"
));
}
addMuzzleFlash(x, y, angle) {
for (let i = 0; i < Math.min(4, CONFIG.PARTICLE_MUZZLE_COUNT / 2); i++) {
const sparkAngle = angle + Math.PI + rand(-0.8, 0.8);
const sparkSpeed = rand(80, 160);
this.particles.push(new Particle(
x, y,
Math.cos(sparkAngle) * sparkSpeed,
Math.sin(sparkAngle) * sparkSpeed,
rand(0.1, 0.2),
rand(1.5, 3),
"rgba(255, 240, 180, 1)",
"spark"
));
}
for (let i = 0; i < 2; i++) {
const sparkAngle = angle + Math.PI + rand(-0.4, 0.4);
const sparkSpeed = rand(40, 80);
this.particles.push(new Particle(
x, y,
Math.cos(sparkAngle) * sparkSpeed,
Math.sin(sparkAngle) * sparkSpeed,
rand(0.05, 0.12),
rand(2.5, 4),
"#ffffff",
"spark"
));
}
}
addExplosion(x, y, color, accent) {
const core = accent || color;
const debrisCount = Math.min(20, Math.floor(CONFIG.PARTICLE_EXPLOSION_COUNT * 0.45));
for (let i = 0; i < debrisCount; i++) {
const angle = rand(0, Math.PI * 2);
const speed = rand(100, 300);
this.particles.push(new Particle(
x, y,
Math.cos(angle) * speed,
Math.sin(angle) * speed,
rand(0.4, 1.0),
rand(2.5, 6),
Math.random() > 0.5 ? color : core,
"debris"
));
}
const sparkCount = Math.min(10, Math.floor(CONFIG.PARTICLE_SPARK_COUNT * 0.5));
for (let i = 0; i < sparkCount; i++) {
const angle = rand(0, Math.PI * 2);
const speed = rand(200, 420);
this.particles.push(new Particle(
x, y,
Math.cos(angle) * speed,
Math.sin(angle) * speed,
rand(0.25, 0.6),
rand(1.5, 3.5),
core,
"spark"
));
}
for (let i = 0; i < 4; i++) {
const angle = rand(0, Math.PI * 2);
const speed = rand(250, 450);
this.particles.push(new Particle(
x, y,
Math.cos(angle) * speed,
Math.sin(angle) * speed,
rand(0.15, 0.35),
rand(1, 2),
"#ffffff",
"spark"
));
}
this.explosions.push(new Explosion(x, y, rand(100, 140), 0.5, color, core));
if (Math.random() > 0.7) {
const offsetX = rand(-15, 15);
const offsetY = rand(-15, 15);
this.explosions.push(new Explosion(x + offsetX, y + offsetY, rand(50, 70), 0.3, core, "#ffffff"));
}
this.shockwaves.push(new Shockwave(x, y, 120, 0.4, core));
}
addRocketDetonation(x, y, color, accent) {
const core = accent || "#ffd166";
const outer = color || "#ff5e5b";
this.explosions.push(new Explosion(x, y, 180, 0.55, outer, core));
this.explosions.push(new Explosion(x, y, 92, 0.32, "#ffffff", core));
this.shockwaves.push(new Shockwave(x, y, 170, 0.42, core));
this.shockwaves.push(new Shockwave(x, y, 95, 0.26, "#ffffff"));
for (let i = 0; i < 18; i++) {
const angle = (i / 18) * Math.PI * 2 + rand(-0.08, 0.08);
const speed = rand(280, 560);
this.particles.push(new Particle(
x, y,
Math.cos(angle) * speed,
Math.sin(angle) * speed,
rand(0.22, 0.52),
rand(1.4, 3.2),
i % 3 === 0 ? "#ffffff" : core,
"spark"
));
}
for (let i = 0; i < 12; i++) {
const angle = rand(0, Math.PI * 2);
const speed = rand(90, 240);
this.particles.push(new Particle(
x, y,
Math.cos(angle) * speed,
Math.sin(angle) * speed,
rand(0.45, 0.9),
rand(3.5, 7.5),
Math.random() > 0.45 ? outer : core,
"debris"
));
}
}
addHitSpark(x, y, color) {
const hitColor = color || "#ffffff";
for (let i = 0; i < Math.min(6, CONFIG.PARTICLE_HIT_COUNT / 2); i++) {
const angle = rand(0, Math.PI * 2);
const speed = rand(80, 180);
this.particles.push(new Particle(
x, y,
Math.cos(angle) * speed,
Math.sin(angle) * speed,
rand(0.15, 0.3),
rand(1.5, 3),
hitColor,
"spark"
));
}
for (let i = 0; i < 3; i++) {
const angle = rand(0, Math.PI * 2);
const speed = rand(100, 200);
this.particles.push(new Particle(
x, y,
Math.cos(angle) * speed,
Math.sin(angle) * speed,
rand(0.1, 0.2),
rand(1, 2),
"#ffffff",
"spark"
));
}
for (let i = 0; i < 2; i++) {
const angle = rand(0, Math.PI * 2);
const speed = rand(40, 100);
this.particles.push(new Particle(
x, y,
Math.cos(angle) * speed,
Math.sin(angle) * speed,
rand(0.2, 0.4),
rand(2, 3.5),
hitColor,
"debris"
));
}
// Only add shockwave occasionally (50% chance)
if (Math.random() > 0.5) {
this.shockwaves.push(new Shockwave(x, y, 50, 0.25, hitColor));
}
}
draw(ctx, view) {
// Batch rendering for better performance
// Draw shockwaves with additive blending
ctx.save();
ctx.globalCompositeOperation = "screen";
for (const wave of this.shockwaves) {
if (wave.active) {
wave.draw(ctx, view);
}
}
ctx.restore();
// Draw explosions with additive blending
ctx.save();
ctx.globalCompositeOperation = "screen";
for (const explosion of this.explosions) {
if (explosion.active) {
explosion.draw(ctx, view);
}
}
ctx.restore();
// Draw particles with normal blending - batch by kind for better performance
ctx.save();
// Group particles by kind to reduce state changes
const sparks = [];
const exhausts = [];
const debris = [];
for (const particle of this.particles) {
if (!particle.active) continue;
if (particle.kind === "spark") sparks.push(particle);
else if (particle.kind === "exhaust") exhausts.push(particle);
else debris.push(particle);
}
// Draw each group
for (const particle of sparks) {
particle.draw(ctx, view);
}
for (const particle of exhausts) {
particle.draw(ctx, view);
}
for (const particle of debris) {
particle.draw(ctx, view);
}
ctx.restore();
}
}
// ============================================================================
// INPUT HANDLER
// ============================================================================
class InputHandler {
constructor() {
this.up = false;
this.down = false;
this.left = false;
this.right = false;
this.shoot = false;
this.pointerX = 0;
this.pointerY = 0;
this.pointerDown = false;
this.pointerActive = false;
this.rightClick = false;
this.rightClickDown = false;
}
reset() {
this.up = false;
this.down = false;
this.left = false;
this.right = false;
this.shoot = false;
this.pointerDown = false;
this.rightClick = false;
this.rightClickDown = false;
}
setPointer(x, y) {
this.pointerX = x;
this.pointerY = y;
this.pointerActive = true;
}
isFiring() {
return this.pointerDown || this.shoot;
}
isRightClicking() {
return this.rightClickDown || this.rightClick;
}
}
class SoundFX {
constructor() {
this.ctx = null;
this.master = null;
this.lastShot = 0;
this.lastHit = 0;
this.volume = 0.42;
}
ensure() {
if (this.ctx) {
if (this.ctx.state === "suspended") {
this.ctx.resume().catch(() => {});
}
return this.ctx.state === "running";
}
if (typeof window === "undefined") return false;
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (!AudioCtx) return false;
try {
this.ctx = new AudioCtx();
this.master = this.ctx.createGain();
this.master.gain.value = this.volume;
this.master.connect(this.ctx.destination);
if (this.ctx.state === "suspended") {
this.ctx.resume().catch(() => {});
}
return this.ctx.state === "running";
} catch (e) {
this.ctx = null;
return false;
}
}
tone(freq, duration, type = "sine", gain = 0.08, endFreq = null) {
if (!this.ensure()) {
setTimeout(() => this.tone(freq, duration, type, gain, endFreq), 40);
return;
}
const now = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const amp = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, now);
if (endFreq) {
osc.frequency.exponentialRampToValueAtTime(Math.max(20, endFreq), now + duration);
}
amp.gain.setValueAtTime(0.0001, now);
amp.gain.exponentialRampToValueAtTime(gain, now + 0.01);
amp.gain.exponentialRampToValueAtTime(0.0001, now + duration);
osc.connect(amp);
amp.connect(this.master);
osc.start(now);
osc.stop(now + duration + 0.02);
}
noise(duration, gain = 0.05, filterFreq = 900, type = "lowpass") {
if (!this.ensure()) {
setTimeout(() => this.noise(duration, gain, filterFreq, type), 40);
return;
}
const now = this.ctx.currentTime;
const length = Math.max(1, Math.floor(this.ctx.sampleRate * duration));
const buffer = this.ctx.createBuffer(1, length, this.ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < length; i++) {
data[i] = (Math.random() * 2 - 1) * (1 - i / length);
}
const src = this.ctx.createBufferSource();
const filter = this.ctx.createBiquadFilter();
const amp = this.ctx.createGain();
src.buffer = buffer;
filter.type = type;
filter.frequency.value = filterFreq;
amp.gain.setValueAtTime(gain, now);
amp.gain.exponentialRampToValueAtTime(0.0001, now + duration);
src.connect(filter);
filter.connect(amp);
amp.connect(this.master);
src.start(now);
}
playShoot() {
const nowMs = performance.now();
if (nowMs - this.lastShot < 55) return;
this.lastShot = nowMs;
this.tone(880, 0.055, "triangle", 0.06, 1180);
this.tone(1320, 0.045, "sine", 0.025, 980);
}
playHit() {
const nowMs = performance.now();
if (nowMs - this.lastHit < 80) return;
this.lastHit = nowMs;
this.tone(520, 0.055, "sine", 0.055, 740);
}
playExplosion(isBoss = false) {
this.noise(isBoss ? 0.32 : 0.12, isBoss ? 0.08 : 0.045, isBoss ? 740 : 1200, "lowpass");
this.tone(isBoss ? 160 : 260, isBoss ? 0.22 : 0.12, "triangle", isBoss ? 0.08 : 0.055, isBoss ? 90 : 180);
this.tone(isBoss ? 320 : 520, 0.09, "sine", isBoss ? 0.055 : 0.04, isBoss ? 240 : 680);
}
playDamage() {
this.tone(260, 0.15, "triangle", 0.1, 130);
this.noise(0.08, 0.045, 1500, "bandpass");
}
playRocket() {
this.tone(360, 0.08, "triangle", 0.075, 540);
setTimeout(() => this.tone(540, 0.1, "triangle", 0.07, 860), 55);
this.noise(0.11, 0.035, 2200, "highpass");
}
playLaser() {
this.tone(620, 0.18, "sine", 0.08, 1240);
this.tone(930, 0.14, "triangle", 0.05, 1560);
}
playStart() {
this.tone(420, 0.07, "sine", 0.07, 560);
setTimeout(() => this.tone(640, 0.1, "triangle", 0.075, 840), 65);
}
playBossSpawn() {
this.tone(140, 0.34, "triangle", 0.09, 80);
this.tone(280, 0.18, "sine", 0.055, 180);
this.noise(0.2, 0.06, 620, "lowpass");
}
playBossDash() {
this.tone(360, 0.1, "triangle", 0.065, 920);
this.noise(0.07, 0.03, 2600, "highpass");
}
playGameOver() {
this.tone(320, 0.26, "triangle", 0.09, 120);
}
playNova() {
this.tone(330, 0.12, "sine", 0.09, 660);
setTimeout(() => this.tone(660, 0.14, "triangle", 0.08, 990), 75);
setTimeout(() => this.tone(990, 0.22, "sine", 0.07, 1320), 145);
this.noise(0.18, 0.045, 3200, "highpass");
}
}
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
function roundRect(ctx, x, y, w, h, r, topOnly = false) {
const radius = Math.min(r, w / 2, h / 2);
ctx.beginPath();
if (topOnly) {
ctx.moveTo(x + radius, y);
ctx.lineTo(x + w - radius, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
ctx.lineTo(x + w, y + h);
ctx.lineTo(x, y + h);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
return;
}
ctx.moveTo(x + radius, y);
ctx.lineTo(x + w - radius, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
ctx.lineTo(x + w, y + h - radius);
ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
ctx.lineTo(x + radius, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
// ============================================================================
// MAIN GAME CLASS
// ============================================================================
export class NodeInvadersGame {
constructor(root) {
this.root = root;
this.canvas = root.querySelector("#nodeinvaders-canvas");
this.ctx = this.canvas.getContext("2d");
// UI elements
this.ui = {
score: root.querySelector("#ni-score"),
time: root.querySelector("#ni-time"),
kills: root.querySelector("#ni-kills"),
lives: root.querySelector("#ni-lives"),
modePill: root.querySelector("#ni-mode-pill"),
modeButtons: root.querySelectorAll(".ni-mode"),
startOverlay: root.querySelector("#nodeinvaders-start"),
overOverlay: root.querySelector("#nodeinvaders-over"),
overScore: root.querySelector("#ni-over-score"),
overTime: root.querySelector("#ni-over-time"),
overKills: root.querySelector("#ni-over-kills"),
overReason: root.querySelector("#ni-over-reason"),
startButton: root.querySelector("#ni-start-btn"),
closeButton: root.querySelector("#ni-close-btn"),
restartButton: root.querySelector("#ni-restart-btn"),
exitButton: root.querySelector("#ni-exit-btn"),
rocketIcon: root.querySelector("#ni-rocket-skill"),
rocketProgress: root.querySelector("#ni-rocket-progress"),
laserIcon: root.querySelector("#ni-laser-skill"),
laserProgress: root.querySelector("#ni-laser-progress"),
novaIcon: root.querySelector("#ni-nova-skill"),
novaProgress: root.querySelector("#ni-nova-progress")
};
// ComfyUI integration
this.graph = app?.graph || null;
this.graphCanvas = app?.canvas || null;
this.graphAvailable = Boolean(this.graph && this.graphCanvas && getLiteGraph());
// View state
this.view = { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 };
this.scale = 1;
this.offsetX = 0;
this.offsetY = 0;
this.center = { x: 0, y: 0 };
this.width = 0;
this.height = 0;
this.dpr = 1;
// Game state
this.visible = false;
this.running = false;
this.lastTime = 0;
this.rafId = null;
this.state = { startTime: 0, time: 0, score: 0, kills: 0, lives: CONFIG.PLAYER_LIVES };
this.invulnTime = 0;
// Game objects
this.player = new Player();
this.input = new InputHandler();
this.particles = new ParticleSystem();
this.sfx = new SoundFX();
this.bullets = [];
this.enemyBullets = [];
this.enemies = [];
this.rockets = [];
this.floatingTexts = [];
// Combo system
this.combo = {
count: 0,
timer: 0,
lastKillX: 0,
lastKillY: 0
};
this.achievedMilestones = new Set();
// Skill states
this.skills = {
rocket: {
available: false,
armingUntil: null,
killsRequired: CONFIG.ROCKET_SKILL_KILLS_REQUIRED
},
laser: {
available: false,
active: false,
armingUntil: null,
timeLeft: 0,
killsRequired: CONFIG.LASER_SKILL_KILLS_REQUIRED
},
nova: {
available: false,
armingUntil: null,
killsRequired: CONFIG.NOVA_SKILL_KILLS_REQUIRED
}
};
// Laser hit particle tracking
this._laserHitParticles = null;
// Timers and effects
this.spawnTimer = 0;
this.shootCooldown = 0;
this.muzzleTime = 0;
this.shakeTime = 0;
this.shakePower = 0;
this.flashTime = 0;
this.flashColor = "#ffffff";
// Performance optimization timers
this.graphRedrawTimer = 0;
this.graphRedrawInterval = 1 / 30;
this.uiUpdateTimer = 0;
this.uiUpdateInterval = 1 / 10;
this.nodeSyncTimer = 0;
this.nodeSyncInterval = 1 / 30;
this.viewUpdateNeeded = true;
// Star field (generated once, reused)
this.stars = null;
this._vignetteGradient = null;
this._lastVignetteSize = null;
// Bind methods
this.tick = this.tick.bind(this);
this.handlePointerMove = this.handlePointerMove.bind(this);
this.handlePointerDown = this.handlePointerDown.bind(this);
this.handlePointerUp = this.handlePointerUp.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleKeyUp = this.handleKeyUp.bind(this);
this.handleBlur = this.handleBlur.bind(this);
this.handleContextMenu = this.handleContextMenu.bind(this);
// Boss tracking
this.bossSpawnedCount = 0;
this.nextBossKillThreshold = CONFIG.BOSS_FIRST_SPAWN_KILLS;
this.activeBoss = null;
// External callback
this.toggleGamePanel = null;
// Default mode
setGameMode("api");
this.applyModePill();
}
applyModePill() {
if (!this.ui.modePill) return;
const mode = getGameMode();
this.ui.modePill.textContent = mode === "api" ? "API Nodes Only" : "Total Chaos";
}
setActiveModeButton(mode) {
if (!this.ui.modeButtons) return;
this.ui.modeButtons.forEach(btn => {
if (btn.dataset.mode === mode) btn.classList.add("is-active");
else btn.classList.remove("is-active");
});
}
setToggleGamePanel(fn) {
this.toggleGamePanel = fn;
}
mount() {
this.resize();
this.attachEvents();
this.showStart();
this.startLoop();
}
attachEvents() {
this.root.addEventListener("pointermove", this.handlePointerMove);
this.root.addEventListener("pointerdown", this.handlePointerDown);
this.root.addEventListener("pointerup", this.handlePointerUp);
this.root.addEventListener("pointerleave", this.handlePointerUp);
this.root.addEventListener("contextmenu", this.handleContextMenu);
window.addEventListener("keydown", this.handleKeyDown, { capture: true });
window.addEventListener("keyup", this.handleKeyUp, { capture: true });
window.addEventListener("blur", this.handleBlur);
this.ui.startButton?.addEventListener("click", () => this.startNewGame());
this.ui.restartButton?.addEventListener("click", () => this.startNewGame());
if (this.ui.modeButtons) {
this.ui.modeButtons.forEach(btn => {
btn.addEventListener("click", () => {
const mode = btn.dataset.mode;
if (!mode) return;
setGameMode(mode);
this.setActiveModeButton(mode);
this.applyModePill();
});
});
}
this.ui.closeButton?.addEventListener("click", () => {
if (this.toggleGamePanel) this.toggleGamePanel();
});
this.ui.exitButton?.addEventListener("click", () => {
if (this.toggleGamePanel) this.toggleGamePanel();
});
}
detachEvents() {
this.root.removeEventListener("pointermove", this.handlePointerMove);
this.root.removeEventListener("pointerdown", this.handlePointerDown);
this.root.removeEventListener("pointerup", this.handlePointerUp);
this.root.removeEventListener("pointerleave", this.handlePointerUp);
this.root.removeEventListener("contextmenu", this.handleContextMenu);
window.removeEventListener("keydown", this.handleKeyDown, { capture: true });
window.removeEventListener("keyup", this.handleKeyUp, { capture: true });
window.removeEventListener("blur", this.handleBlur);
}
setVisible(isVisible) {
this.visible = isVisible;
if (isVisible) {
this.lastTime = performance.now();
this.startLoop();
} else {
this.stopLoop();
this.input.reset();
this.resetGameState(true);
this.showStart();
}
}
startLoop() {
if (!this.rafId) {
this.rafId = requestAnimationFrame(this.tick);
}
}
stopLoop() {
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
resetGameState(removeNodes = false) {
if (removeNodes) {
this.removeAllInvaderNodes();
}
this.enemies = [];
this.bullets = [];
this.enemyBullets = [];
this.rockets = [];
this.floatingTexts = [];
this.particles.clear();
this.spawnTimer = 0;
this.shootCooldown = 0;
this.muzzleTime = 0;
this.shakeTime = 0;
this.shakePower = 0;
this.flashTime = 0;
this.input.pointerActive = false;
this.player.vx = 0;
this.player.vy = 0;
// Reset combo
this.combo.count = 0;
this.combo.timer = 0;
this.achievedMilestones.clear();
// Reset boss tracking
this.bossSpawnedCount = 0;
this.nextBossKillThreshold = getDifficulty().bossFirstKills;
this.activeBoss = null;
// Reset lives + invulnerability
this.state.lives = CONFIG.PLAYER_LIVES;
this.invulnTime = 0;
// Reset skills
this.skills.rocket.available = false;
this.skills.rocket.armingUntil = null;
this.skills.rocket.killsRequired = CONFIG.ROCKET_SKILL_KILLS_REQUIRED;
this.skills.laser.available = false;
this.skills.laser.active = false;
this.skills.laser.armingUntil = null;
this.skills.laser.timeLeft = 0;
this.skills.laser.killsRequired = CONFIG.LASER_SKILL_KILLS_REQUIRED;
this.skills.nova.available = false;
this.skills.nova.armingUntil = null;
this.skills.nova.killsRequired = CONFIG.NOVA_SKILL_KILLS_REQUIRED;
// Reset laser hit tracking
this._laserHitParticles = null;
}
removeAllInvaderNodes() {
if (!this.graphAvailable || !this.graph) return;
const nodes = this.graph._nodes || [];
for (const node of nodes.slice()) {
if (node && node._nodeInvader) {
this.graph.remove(node);
}
}
this.requestGraphRedraw();
}
showStart() {
this.running = false;
this.ui.startOverlay?.classList.remove("is-hidden");
this.ui.overOverlay?.classList.add("is-hidden");
this.updateUI();
}
startNewGame() {
this.sfx.ensure();
clearNodeTypesCache();
this.resetGameState(true);
this.input.reset();
this.running = true;
this.state = {
startTime: performance.now(),
time: 0,
score: 0,
kills: 0,
lives: CONFIG.PLAYER_LIVES
};
this.invulnTime = 0;
this.viewUpdateNeeded = true;
this.updateView();
this.player.reset(this.center.x, this.center.y);
this.sfx.playStart();
this.ui.startOverlay?.classList.add("is-hidden");
this.ui.overOverlay?.classList.add("is-hidden");
// Show "GET READY!" message
this.floatingTexts.push(new FloatingText(
this.center.x, this.center.y,
"GET READY!", "#4ade80",
42, 1.5
));
}
damagePlayer(reason) {
if (!this.running) return;
if (this.invulnTime > 0) return;
this.state.lives = Math.max(0, this.state.lives - 1);
if (this.state.lives <= 0) {
this.sfx.playGameOver();
this.endGame(reason);
return;
}
// Survive the hit
this.sfx.playDamage();
this.invulnTime = CONFIG.INVULN_DURATION;
this.flashTime = Math.max(this.flashTime, 0.16);
this.flashColor = "#ff5e5b";
this.shakeTime = Math.max(this.shakeTime, 0.28);
this.shakePower = Math.max(this.shakePower, 12);
// Push the player toward the center to escape danger and clear nearby threats
const cx = this.center.x;
const cy = this.center.y;
this.player.x = cx;
this.player.y = cy;
this.player.vx = 0;
this.player.vy = 0;
// Wipe enemy bullets within a safe radius
const safeR = 220;
const safeRSq = safeR * safeR;
this.enemyBullets = this.enemyBullets.filter(b => {
const dx = b.x - cx;
const dy = b.y - cy;
return (dx * dx + dy * dy) > safeRSq;
});
// Visual + text feedback
this.particles.addExplosion(cx, cy, "#ff5e5b", "#ffd166");
this.floatingTexts.push(new FloatingText(
cx, cy - 50,
`${this.state.lives} LIFE${this.state.lives === 1 ? "" : "S"} LEFT`,
"#ff5e5b", 28, 1.4
));
this.updateUI();
}
endGame(reason) {
if (!this.running) return;
this.running = false;
const timeText = `${this.state.time.toFixed(1)}s`;
if (this.ui.overScore) this.ui.overScore.textContent = `${this.state.score}`;
if (this.ui.overTime) this.ui.overTime.textContent = timeText;
if (this.ui.overKills) this.ui.overKills.textContent = `${this.state.kills}`;
if (this.ui.overReason) this.ui.overReason.textContent = reason;
this.ui.overOverlay?.classList.remove("is-hidden");
}
// ========================================================================
// VIEW & COORDINATE SYSTEM
// ========================================================================
resize() {
this.syncCanvasSize();
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
this.viewUpdateNeeded = true;
this.updateView();
if (!this.running) {
this.player.reset(this.center.x, this.center.y);
}
}
syncCanvasSize() {
let width = 0, height = 0;
if (this.graphCanvas?.canvas) {
const rect = this.graphCanvas.canvas.getBoundingClientRect();
width = rect.width;
height = rect.height;
} else {
const rect = this.root.getBoundingClientRect();
width = rect.width;
height = rect.height;
}
width = Math.max(1, width);
height = Math.max(1, height);
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const targetWidth = Math.floor(width * dpr);
const targetHeight = Math.floor(height * dpr);
if (this.width !== width) this.width = width;
if (this.height !== height) this.height = height;
if (this.dpr !== dpr) this.dpr = dpr;
if (this.canvas.width !== targetWidth) this.canvas.width = targetWidth;
if (this.canvas.height !== targetHeight) this.canvas.height = targetHeight;
if (this.canvas.style.width !== `${width}px`) this.canvas.style.width = `${width}px`;
if (this.canvas.style.height !== `${height}px`) this.canvas.style.height = `${height}px`;
}
updateView() {
// Re-check graph availability
if (!this.graphAvailable && app?.graph && app?.canvas && getLiteGraph()) {
this.graph = app.graph;
this.graphCanvas = app.canvas;
this.graphAvailable = true;
}
this.syncCanvasSize();
// Get LiteGraph canvas transform - this is what positions nodes
const graphCanvas = this.graphCanvas;
if (graphCanvas && graphCanvas.ds) {
this.scale = graphCanvas.ds.scale || 1;
// Store offset in graph coordinates (not pixel coordinates)
this.offsetX = graphCanvas.ds.offset?.[0] || 0;
this.offsetY = graphCanvas.ds.offset?.[1] || 0;
} else {
this.scale = 1;
this.offsetX = 0;
this.offsetY = 0;
}
// Calculate visible graph area (in graph coordinates)
// Use CSS size, not pixel size (DPR already handled in rendering)
const cssWidth = this.width || 800;
const cssHeight = this.height || 600;
const viewLeft = -this.offsetX;
const viewTop = -this.offsetY;
const viewWidth = cssWidth / this.scale;
const viewHeight = cssHeight / this.scale;
this.view.left = viewLeft;
this.view.top = viewTop;
this.view.right = viewLeft + viewWidth;
this.view.bottom = viewTop + viewHeight;
this.view.width = viewWidth;
this.view.height = viewHeight;
this.center.x = viewLeft + viewWidth * 0.5;
this.center.y = viewTop + viewHeight * 0.5;
this.viewUpdateNeeded = false;
}
screenToGraph(event) {
// Always get fresh transform info for accurate mouse tracking
const graphCanvas = this.graphCanvas;
let scale = 1;
let offsetX = 0;
let offsetY = 0;
if (graphCanvas && graphCanvas.ds) {
scale = graphCanvas.ds.scale || 1;
offsetX = graphCanvas.ds.offset?.[0] || 0;
offsetY = graphCanvas.ds.offset?.[1] || 0;
}
const rect = this.canvas.getBoundingClientRect();
const canvasX = event.clientX - rect.left;
const canvasY = event.clientY - rect.top;
const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height;
const pixelX = canvasX * scaleX;
const pixelY = canvasY * scaleY;
// Convert pixel coordinates to graph coordinates
// Draw transform: ctx.setTransform(scale * dpr, 0, 0, scale * dpr, offsetX * scale * dpr, offsetY * scale * dpr)
// To reverse: graphX = pixelX / (scale * dpr) - (offsetX * scale * dpr) / (scale * dpr)
// Simplified: graphX = pixelX / (scale * dpr) - offsetX
const dpr = this.dpr || 1;
const drawScale = scale * dpr;
const graphX = (pixelX / drawScale) - offsetX;
const graphY = (pixelY / drawScale) - offsetY;
return { x: graphX, y: graphY };
}
requestGraphRedraw() {
if (!this.graphCanvas) return;
if (this.graphCanvas.setDirty) {
this.graphCanvas.setDirty(true, true);
} else if (this.graphCanvas.draw) {
this.graphCanvas.draw(true, true);
}
}
// ========================================================================
// INPUT HANDLERS
// ========================================================================
handlePointerMove(event) {
if (!this.visible) return;
// Update only transform info needed for coordinate conversion (lightweight)
this.updateTransformForMouse();
const pos = this.screenToGraph(event);
this.input.setPointer(pos.x, pos.y);
}
updateTransformForMouse() {
// Lightweight update - only get scale and offset for mouse tracking
const graphCanvas = this.graphCanvas;
if (graphCanvas && graphCanvas.ds) {
this.scale = graphCanvas.ds.scale || 1;
this.offsetX = graphCanvas.ds.offset?.[0] || 0;
this.offsetY = graphCanvas.ds.offset?.[1] || 0;
} else {
this.scale = 1;
this.offsetX = 0;
this.offsetY = 0;
}
}
handlePointerDown(event) {
if (!this.visible) return;
this.sfx.ensure();
event.preventDefault();
event.stopPropagation();
if (event.button === 2) {
// Right click for skills
this.input.rightClickDown = true;
this.handleSkillActivation();
} else {
// Left click for shooting
this.handlePointerMove(event);
this.input.pointerDown = true;
}
}
handlePointerUp(event) {
if (!this.visible) return;
if (!event) return;
event.preventDefault();
event.stopPropagation();
if (event.button === 2) {
this.input.rightClickDown = false;
} else {
this.input.pointerDown = false;
}
}
isGameKey(event) {
const code = event.code;
const key = event.key?.toLowerCase();
return code === "ArrowUp" || code === "ArrowDown" ||
code === "ArrowLeft" || code === "ArrowRight" ||
code === "Space" ||
key === "w" || key === "a" || key === "s" || key === "d";
}
handleKeyDown(event) {
if (!this.visible) return;
this.sfx.ensure();
if (this.isGameKey(event) || event.code === "Escape") {
event.preventDefault();
event.stopPropagation();
}
const key = (event.key || "").toLowerCase();
if (event.code === "ArrowUp" || key === "w") this.input.up = true;
if (event.code === "ArrowDown" || key === "s") this.input.down = true;
if (event.code === "ArrowLeft" || key === "a") this.input.left = true;
if (event.code === "ArrowRight" || key === "d") this.input.right = true;
if (event.code === "Space") this.input.shoot = true;
if (event.code === "Escape" && this.toggleGamePanel) {
this.toggleGamePanel();
}
}
handleKeyUp(event) {
if (!this.visible) return;
if (this.isGameKey(event)) {
event.preventDefault();
event.stopPropagation();
}
const key = (event.key || "").toLowerCase();
if (event.code === "ArrowUp" || key === "w") this.input.up = false;
if (event.code === "ArrowDown" || key === "s") this.input.down = false;
if (event.code === "ArrowLeft" || key === "a") this.input.left = false;
if (event.code === "ArrowRight" || key === "d") this.input.right = false;
if (event.code === "Space") this.input.shoot = false;
}
handleBlur() {
this.input.reset();
}
handleContextMenu(event) {
if (this.visible) {
event.preventDefault();
event.stopPropagation();
}
}
handleSkillActivation() {
if (!this.running) return;
if (this.skills.nova.available) {
this.activateNovaSkill();
this.skills.nova.available = false;
this.skills.nova.armingUntil = null;
this.skills.nova.killsRequired = this.state.kills + CONFIG.NOVA_SKILL_KILLS_REQUIRED;
return;
}
if (this.skills.rocket.available) {
this.activateRocketSkill();
this.skills.rocket.available = false;
this.skills.rocket.armingUntil = null;
this.skills.rocket.killsRequired = this.state.kills + CONFIG.ROCKET_SKILL_KILLS_REQUIRED;
return;
}
// Laser skill - one click activates for duration
if (this.skills.laser.available && !this.skills.laser.active) {
this.activateLaserSkill();
return;
}
}
activateNovaSkill() {
this.sfx.playNova();
const cx = this.player.x;
const cy = this.player.y;
this.particles.shockwaves.push(new Shockwave(cx, cy, 620, 0.7, "#a7f3d0"));
this.particles.shockwaves.push(new Shockwave(cx, cy, 420, 0.42, "#ffffff"));
this.particles.addRocketDetonation(cx, cy, "#60a5fa", "#a7f3d0");
for (let i = this.enemies.length - 1; i >= 0; i--) {
const enemy = this.enemies[i];
if (!enemy.active || enemy.hp <= 0) continue;
if (enemy.isBoss) {
const killed = enemy.takeDamage(CONFIG.NOVA_BOSS_DAMAGE);
this.particles.addRocketDetonation(enemy.cx, enemy.cy, enemy.color, enemy.accent);
if (killed) {
this.destroyEnemy(i);
}
continue;
}
this.destroyEnemy(i);
}
this.enemyBullets = [];
this.flashTime = Math.max(this.flashTime, 0.32);
this.flashColor = "#a7f3d0";
this.shakeTime = Math.max(this.shakeTime, 0.55);
this.shakePower = Math.max(this.shakePower, 18);
this.floatingTexts.push(new FloatingText(
cx, cy - 72,
"NOVA!", "#a7f3d0",
42, 1.4
));
}
activateRocketSkill() {
this.sfx.playRocket();
// Fire 5 rockets at nearest enemies
const targets = [...this.enemies]
.filter(e => e.active && e.hp > 0)
.sort((a, b) => {
const dxA = a.cx - this.player.x;
const dyA = a.cy - this.player.y;
const dxB = b.cx - this.player.x;
const dyB = b.cy - this.player.y;
const distSqA = dxA * dxA + dyA * dyA;
const distSqB = dxB * dxB + dyB * dyB;
return distSqA - distSqB; // Compare squared distances
})
.slice(0, CONFIG.ROCKET_COUNT);
const aim = this.player.getAimVector(this.input);
const startX = this.player.x + aim.x * 25;
const startY = this.player.y + aim.y * 25;
for (let i = 0; i < CONFIG.ROCKET_COUNT; i++) {
const target = targets[i] || null;
const rocket = new Rocket(startX, startY, target);
// Spread rockets slightly
rocket.x += rand(-15, 15);
rocket.y += rand(-15, 15);
this.rockets.push(rocket);
}
// Visual effect
this.particles.addMuzzleFlash(startX, startY, aim.angle);
this.shakeTime = Math.max(this.shakeTime, 0.1);
this.shakePower = Math.max(this.shakePower, 6);
}
activateLaserSkill() {
if (this.skills.laser.available && !this.skills.laser.active) {
this.sfx.playLaser();
this.skills.laser.active = true;
this.skills.laser.timeLeft = CONFIG.LASER_DURATION;
this.skills.laser.available = false;
this.skills.laser.armingUntil = null;
this.skills.laser.killsRequired = this.state.kills + CONFIG.LASER_SKILL_KILLS_REQUIRED;
}
}
// ========================================================================
// GAME LOOP
// ========================================================================
tick(now) {
if (!this.visible) return;
const delta = this.lastTime ? (now - this.lastTime) / 1000 : 0;
const dt = clamp(delta, 0, CONFIG.MAX_DELTA_TIME);
this.lastTime = now;
this.update(dt, now);
this.draw();
this.rafId = requestAnimationFrame(this.tick);
}
update(dt, now) {
// Only update view when needed (resize, pointer move, etc.)
if (this.viewUpdateNeeded) {
this.updateView();
}
this.particles.update(dt);
// Update floating texts
for (let i = this.floatingTexts.length - 1; i >= 0; i--) {
this.floatingTexts[i].update(dt);
if (!this.floatingTexts[i].active) {
this.floatingTexts.splice(i, 1);
}
}
if (this.running) {
this.state.time = (now - this.state.startTime) / 1000;
// Update combo timer
if (this.combo.timer > 0) {
this.combo.timer -= dt;
if (this.combo.timer <= 0) {
this.combo.count = 0;
}
}
// Check skill availability
this.updateSkills();
// Update player
this.player.update(dt, this.input, this.view);
// Add exhaust particles
const thrust = Math.hypot(this.player.vx, this.player.vy);
if (thrust > 30) {
this.particles.addExhaust(this.player.x, this.player.y, this.player.angle);
}
// Handle shooting
this.updateShooting(dt);
// Handle laser (runs automatically for duration once activated)
if (this.skills.laser.active) {
this.updateLaser(dt);
}
// Update rockets
this.updateRockets(dt);
// Update enemies
this.updateEnemies(dt);
// Update bullets
this.updateBullets(dt);
this.updateEnemyBullets(dt);
// Spawn enemies
if (this.running) {
this.spawnEnemies(dt);
}
}
// Update effects
if (this.invulnTime > 0) {
this.invulnTime = Math.max(0, this.invulnTime - dt);
}
if (this.shakeTime > 0) {
this.shakeTime = Math.max(0, this.shakeTime - dt);
this.shakePower = Math.max(0, this.shakePower - dt * 8);
}
if (this.muzzleTime > 0) {
this.muzzleTime = Math.max(0, this.muzzleTime - dt);
}
if (this.flashTime > 0) {
this.flashTime = Math.max(0, this.flashTime - dt * 4);
}
// Request graph redraw if enemies exist (throttled)
this.graphRedrawTimer -= dt;
if (this.graphRedrawTimer <= 0 && this.graphAvailable && this.running && this.enemies.length > 0) {
const hasNodes = this.enemies.some(e => e.node && e.node.graph);
if (hasNodes) {
this.requestGraphRedraw();
}
this.graphRedrawTimer = this.graphRedrawInterval;
}
// Update UI (throttled)
this.uiUpdateTimer -= dt;
if (this.uiUpdateTimer <= 0) {
this.updateUI();
this.uiUpdateTimer = this.uiUpdateInterval;
}
}
updateSkills() {
const now = this.state.time;
const rocket = this.skills.rocket;
if (!rocket.available) {
if (this.state.kills >= rocket.killsRequired) {
if (rocket.armingUntil === null) {
rocket.armingUntil = now + CONFIG.SKILL_READY_DELAY;
}
if (now >= rocket.armingUntil) {
rocket.available = true;
rocket.armingUntil = null;
}
} else {
rocket.armingUntil = null;
}
}
const laser = this.skills.laser;
if (laser.active) {
laser.armingUntil = null;
return;
}
if (!laser.available) {
if (this.state.kills >= laser.killsRequired) {
if (laser.armingUntil === null) {
laser.armingUntil = now + CONFIG.SKILL_READY_DELAY;
}
if (now >= laser.armingUntil) {
laser.available = true;
laser.armingUntil = null;
}
} else {
laser.armingUntil = null;
}
}
const nova = this.skills.nova;
if (!nova.available) {
if (this.state.kills >= nova.killsRequired) {
if (nova.armingUntil === null) {
nova.armingUntil = now + CONFIG.SKILL_READY_DELAY;
}
if (now >= nova.armingUntil) {
nova.available = true;
nova.armingUntil = null;
}
} else {
nova.armingUntil = null;
}
}
}
updateRockets(dt) {
for (let i = this.rockets.length - 1; i >= 0; i--) {
const rocket = this.rockets[i];
rocket.update(dt, this.enemies);
if (!rocket.active || rocket.isOutOfBounds(this.view)) {
this.rockets.splice(i, 1);
continue;
}
// Check collision with enemies
for (let j = this.enemies.length - 1; j >= 0; j--) {
const enemy = this.enemies[j];
if (!enemy.active || enemy.hp <= 0) continue;
if (rocket.checkHit(enemy)) {
const killed = enemy.takeDamage(enemy.maxHp); // Instant kill
this.rockets.splice(i, 1);
this.particles.addRocketDetonation(enemy.cx, enemy.cy, enemy.color, enemy.accent);
this.shakeTime = Math.max(this.shakeTime, 0.24);
this.shakePower = Math.max(this.shakePower, 13);
if (killed) {
this.destroyEnemy(j);
}
break;
}
}
}
}
updateLaser(dt) {
// Laser runs automatically for duration once activated
this.skills.laser.timeLeft -= dt;
if (this.skills.laser.timeLeft <= 0) {
this.skills.laser.active = false;
this.skills.laser.timeLeft = 0;
return;
}
// Laser damage to enemies (use same offset as drawLaser for consistency)
const aim = this.player.getAimVector(this.input);
const laserLength = 2000;
const laserStartX = this.player.x + aim.x * 20;
const laserStartY = this.player.y + aim.y * 20;
const laserEndX = laserStartX + aim.x * laserLength;
const laserEndY = laserStartY + aim.y * laserLength;
const damage = CONFIG.LASER_DAMAGE_PER_SECOND * dt;
// Initialize laser hit tracking if not exists
if (!this._laserHitParticles) {
this._laserHitParticles = new Map();
}
// Update particle timers
for (const [enemyId, timer] of this._laserHitParticles.entries()) {
const newTimer = timer - dt;
if (newTimer <= 0) {
this._laserHitParticles.delete(enemyId);
} else {
this._laserHitParticles.set(enemyId, newTimer);
}
}
// Quick bounding box check for laser line
const minX = Math.min(laserStartX, laserEndX);
const maxX = Math.max(laserStartX, laserEndX);
const minY = Math.min(laserStartY, laserEndY);
const maxY = Math.max(laserStartY, laserEndY);
for (let i = this.enemies.length - 1; i >= 0; i--) {
const enemy = this.enemies[i];
if (!enemy.active || enemy.hp <= 0) continue;
// Quick bounding box check first
const enemyMaxX = enemy.cx + enemy.w / 2 + CONFIG.HIT_PADDING;
const enemyMinX = enemy.cx - enemy.w / 2 - CONFIG.HIT_PADDING;
const enemyMaxY = enemy.cy + enemy.h / 2 + CONFIG.HIT_PADDING;
const enemyMinY = enemy.cy - enemy.h / 2 - CONFIG.HIT_PADDING;
// Skip if enemy bounding box doesn't intersect laser bounding box
if (enemyMaxX < minX || enemyMinX > maxX || enemyMaxY < minY || enemyMinY > maxY) {
continue;
}
const bounds = getEnemyBounds(enemy);
if (lineIntersectsRect(
laserStartX, laserStartY,
laserEndX, laserEndY,
bounds.x - CONFIG.HIT_PADDING,
bounds.y - CONFIG.HIT_PADDING,
bounds.w + CONFIG.HIT_PADDING * 2,
bounds.h + CONFIG.HIT_PADDING * 2
)) {
const killed = enemy.takeDamage(damage);
// Throttle particle effects - only add particles every 0.1 seconds per enemy
const enemyId = enemy.cx + enemy.cy * 10000; // Simple ID
if (!this._laserHitParticles.has(enemyId)) {
this.particles.addHitSpark(enemy.cx, enemy.cy, enemy.accent);
this._laserHitParticles.set(enemyId, 0.1); // 0.1 second cooldown
}
if (killed) {
this.destroyEnemy(i);
this._laserHitParticles.delete(enemyId);
}
}
}
}
updateUI() {
if (this.ui.score) this.ui.score.textContent = `${this.state.score}`;
if (this.ui.time) this.ui.time.textContent = `${this.state.time.toFixed(1)}`;
if (this.ui.kills) this.ui.kills.textContent = `${this.state.kills}`;
if (this.ui.lives) {
const max = CONFIG.PLAYER_LIVES;
const current = Math.max(0, this.state.lives);
let html = "";
for (let i = 0; i < max; i++) {
const cls = i < current ? "ni-life on" : "ni-life off";
html += `<span class="${cls}">&hearts;</span>`;
}
this.ui.lives.innerHTML = html;
}
// Update skill UI
this.updateSkillUI();
}
updateSkillUI() {
// Rocket skill
const rocketIcon = this.ui.rocketIcon;
const rocketProgress = this.ui.rocketProgress;
if (rocketIcon && rocketProgress) {
if (this.skills.rocket.available) {
rocketIcon.classList.add("ready");
rocketProgress.textContent = "READY";
} else {
rocketIcon.classList.remove("ready");
const remaining = Math.max(0, this.skills.rocket.killsRequired - this.state.kills);
if (remaining === 0 && this.skills.rocket.armingUntil !== null) {
rocketProgress.textContent = `${Math.max(0, this.skills.rocket.armingUntil - this.state.time).toFixed(1)}s`;
} else {
rocketProgress.textContent = `${remaining}`;
}
}
}
// Laser skill
const laserIcon = this.ui.laserIcon;
const laserProgress = this.ui.laserProgress;
if (laserIcon && laserProgress) {
if (this.skills.laser.active) {
laserIcon.classList.remove("ready");
laserIcon.classList.add("active");
laserProgress.textContent = "ACTIVE";
} else if (this.skills.laser.available) {
laserIcon.classList.remove("active");
laserIcon.classList.add("ready");
laserProgress.textContent = "READY";
} else {
laserIcon.classList.remove("ready", "active");
const remaining = Math.max(0, this.skills.laser.killsRequired - this.state.kills);
if (remaining === 0 && this.skills.laser.armingUntil !== null) {
laserProgress.textContent = `${Math.max(0, this.skills.laser.armingUntil - this.state.time).toFixed(1)}s`;
} else {
laserProgress.textContent = `${remaining}`;
}
}
}
const novaIcon = this.ui.novaIcon;
const novaProgress = this.ui.novaProgress;
if (novaIcon && novaProgress) {
if (this.skills.nova.available) {
novaIcon.classList.add("ready");
novaProgress.textContent = "READY";
} else {
novaIcon.classList.remove("ready");
const remaining = Math.max(0, this.skills.nova.killsRequired - this.state.kills);
if (remaining === 0 && this.skills.nova.armingUntil !== null) {
novaProgress.textContent = `${Math.max(0, this.skills.nova.armingUntil - this.state.time).toFixed(1)}s`;
} else {
novaProgress.textContent = `${remaining}`;
}
}
}
}
updateShooting(dt) {
if (!this.input.isFiring()) return;
this.shootCooldown -= dt;
if (this.shootCooldown <= 0) {
this.fireBullet();
this.shootCooldown = CONFIG.SHOOT_COOLDOWN;
}
}
fireBullet() {
if (!this.running) return;
const aim = this.player.getAimVector(this.input);
const startX = this.player.x + aim.x * 20;
const startY = this.player.y + aim.y * 20;
this.bullets.push(new Bullet(
startX, startY,
aim.x * CONFIG.BULLET_SPEED,
aim.y * CONFIG.BULLET_SPEED
));
this.sfx.playShoot();
this.particles.addMuzzleFlash(startX, startY, aim.angle);
this.muzzleTime = CONFIG.MUZZLE_FLASH_DURATION;
}
updateBullets(dt) {
for (let i = this.bullets.length - 1; i >= 0; i--) {
const bullet = this.bullets[i];
bullet.update(dt);
// Check bounds
if (!bullet.active || bullet.isOutOfBounds(this.view)) {
this.bullets.splice(i, 1);
continue;
}
// Quick bounding box check before expensive collision
const bulletX = bullet.x;
const bulletY = bullet.y;
const searchRadius = 150; // Only check enemies within this radius
const searchRadiusSq = searchRadius * searchRadius;
// Check collision with enemies (optimized with distance check)
for (let j = this.enemies.length - 1; j >= 0; j--) {
const enemy = this.enemies[j];
if (!enemy.active || enemy.hp <= 0) continue;
// Quick distance check first
const dx = enemy.cx - bulletX;
const dy = enemy.cy - bulletY;
const distSq = dx * dx + dy * dy;
// Skip if too far (rough estimate using enemy size)
const maxEnemySize = Math.max(enemy.w, enemy.h) + CONFIG.HIT_PADDING * 2;
if (distSq > (maxEnemySize * maxEnemySize + searchRadiusSq)) continue;
if (bulletHitsEnemy(bullet, enemy)) {
const killed = enemy.takeDamage(1);
this.sfx.playHit();
this.shakeTime = Math.max(this.shakeTime, 0.05);
this.shakePower = Math.max(this.shakePower, 4);
this.bullets.splice(i, 1);
if (killed) {
this.destroyEnemy(j);
} else {
this.flashTime = Math.max(this.flashTime, 0.04);
this.flashColor = enemy.accent;
// Throttle hit sparks - only add every other hit
if (Math.random() > 0.5) {
this.particles.addHitSpark(enemy.cx, enemy.cy, enemy.accent);
}
}
break;
}
}
}
}
updateEnemyBullets(dt) {
for (let i = this.enemyBullets.length - 1; i >= 0; i--) {
const bullet = this.enemyBullets[i];
bullet.update(dt);
if (!bullet.active || bullet.isOutOfBounds(this.view)) {
this.enemyBullets.splice(i, 1);
continue;
}
const dx = bullet.x - this.player.x;
const dy = bullet.y - this.player.y;
const hitRadius = this.player.radius + 6;
const shieldRadius = this.player.radius + 28;
if (this.invulnTime > 0) {
if (dx * dx + dy * dy <= shieldRadius * shieldRadius) {
this.enemyBullets.splice(i, 1);
this.particles.addHitSpark(bullet.x, bullet.y, "#60a5fa");
}
continue;
}
if (dx * dx + dy * dy <= hitRadius * hitRadius) {
this.enemyBullets.splice(i, 1);
this.particles.addHitSpark(this.player.x, this.player.y, "#ff5e5b");
this.damagePlayer("Enemy fire");
break;
}
}
}
updateEnemies(dt) {
// Update node sync timer
this.nodeSyncTimer -= dt;
const shouldSyncNodes = this.nodeSyncTimer <= 0;
if (shouldSyncNodes) {
this.nodeSyncTimer = this.nodeSyncInterval;
}
// Increment frame counter for bounds cache invalidation
const currentFrame = (this._frameCounter || 0) + 1;
this._frameCounter = currentFrame;
for (let i = this.enemies.length - 1; i >= 0; i--) {
const enemy = this.enemies[i];
// Update bounds frame for cache invalidation
enemy._boundsFrame = currentFrame;
enemy.update(dt, this.view, this.center, this.player.x, this.player.y, shouldSyncNodes ? 0 : this.nodeSyncTimer);
if (enemy.dashSoundQueued) {
enemy.dashSoundQueued = false;
this.sfx.playBossDash();
}
// Check if enemy should fire
if (enemy.shouldFire()) {
this.spawnEnemyBullet(enemy);
enemy.resetFireTimer();
}
// Check player collision (skip while invulnerable)
if (this.invulnTime > 0) continue;
if (enemy.checkPlayerCollision(this.player.x, this.player.y, this.player.radius)) {
this.particles.addExplosion(this.player.x, this.player.y, enemy.color, enemy.accent);
this.damagePlayer(enemy.isBoss ? "Boss impact" : "Enemy impact");
break;
}
}
}
spawnEnemies(dt) {
const elapsed = this.state.time;
const difficulty = getDifficulty();
const baseInterval = Math.max(
difficulty.minSpawn,
(CONFIG.SPAWN_BASE_INTERVAL - elapsed * CONFIG.SPAWN_INTERVAL_DECAY * difficulty.spawnDecay) * difficulty.spawnRate
);
if (!this.activeBoss && this.state.kills >= this.nextBossKillThreshold) {
this.spawnBoss();
this.nextBossKillThreshold = this.state.kills + difficulty.bossInterval;
}
this.spawnTimer -= dt;
if (this.spawnTimer <= 0) {
const burst = elapsed > 12 && Math.random() < difficulty.burstChance ? 2 : 1;
for (let i = 0; i < burst; i++) {
this.spawnEnemy();
}
this.spawnTimer = baseInterval;
}
const maxEnemies = Math.min(
CONFIG.MAX_ENEMIES + difficulty.enemyCapBonus,
20 + difficulty.enemyCapBonus + Math.floor(elapsed / 6)
);
if (this.enemies.length > maxEnemies) {
const center = this.center;
const sorted = this.enemies
.map((e, i) => ({ e, i, distSq: (e.cx - center.x) ** 2 + (e.cy - center.y) ** 2 }))
.filter(item => !item.e.isBoss)
.sort((a, b) => b.distSq - a.distSq);
const toRemove = Math.min(sorted.length, this.enemies.length - maxEnemies);
const removeIndices = sorted.slice(0, toRemove).map(s => s.i).sort((a, b) => b - a);
for (const idx of removeIndices) {
const enemy = this.enemies[idx];
// Visual feedback: small poof instead of silent removal
this.particles.addHitSpark(enemy.cx, enemy.cy, enemy.accent);
enemy.removeNode(this.graph);
this.enemies.splice(idx, 1);
}
}
}
spawnEnemy() {
const type = pickWeightedType();
const difficulty = getDifficulty();
const margin = CONFIG.ENEMY_SPAWN_MARGIN;
const view = this.view;
// Random spawn position on edge
const side = Math.floor(Math.random() * 4);
let cx = 0, cy = 0;
if (side === 0) {
cx = view.left - margin;
cy = rand(view.top, view.bottom);
} else if (side === 1) {
cx = view.right + margin;
cy = rand(view.top, view.bottom);
} else if (side === 2) {
cx = rand(view.left, view.right);
cy = view.top - margin;
} else {
cx = rand(view.left, view.right);
cy = view.bottom + margin;
}
const aimX = this.player?.x || this.center.x;
const aimY = this.player?.y || this.center.y;
const dx = aimX - cx;
const dy = aimY - cy;
const dist = Math.hypot(dx, dy) || 1;
const nx = dx / dist;
const ny = dy / dist;
const maxSpeed = (type.speed + rand(-12, 16)) * difficulty.enemySpeed;
const strafe = rand(-0.4, 0.4);
const vx = nx * maxSpeed + -ny * maxSpeed * strafe;
const vy = ny * maxSpeed + nx * maxSpeed * strafe;
const enemy = new Enemy(type, cx, cy, vx, vy, this.graph, this.graphCanvas);
enemy.maxSpeed *= difficulty.enemySpeed;
enemy.seek *= difficulty.enemySeek;
enemy.fireInterval *= difficulty.enemyFireRate;
enemy.fireTimer *= difficulty.enemyFireRate;
this.enemies.push(enemy);
}
spawnBoss() {
const view = this.view;
const margin = CONFIG.ENEMY_SPAWN_MARGIN;
// Drop the boss in from the top so the player has time to react
const cx = (view.left + view.right) / 2 + rand(-80, 80);
const cy = view.top - margin;
const dx = this.center.x - cx;
const dy = this.center.y - cy;
const dist = Math.hypot(dx, dy) || 1;
const speed = BOSS_TYPE.speed;
const vx = (dx / dist) * speed * 0.5;
const vy = (dy / dist) * speed * 0.5;
const boss = new Enemy(BOSS_TYPE, cx, cy, vx, vy, this.graph, this.graphCanvas);
this.enemies.push(boss);
this.activeBoss = boss;
this.bossSpawnedCount += 1;
// Big arrival announcement
this.floatingTexts.push(new FloatingText(
this.center.x, this.center.y - 60,
"BOSS COMING", "#f0abfc",
34, 2.0
));
this.shakeTime = Math.max(this.shakeTime, 0.4);
this.shakePower = Math.max(this.shakePower, 14);
this.flashTime = Math.max(this.flashTime, 0.18);
this.flashColor = "#c084fc";
this.sfx.playBossSpawn();
}
spawnEnemyBullet(enemy) {
const dx = this.player.x - enemy.cx;
const dy = this.player.y - enemy.cy;
const dist = Math.hypot(dx, dy) || 1;
if (enemy.isBoss) {
// Boss fires a tight three-shot spread
const baseAngle = Math.atan2(dy, dx);
const speed = CONFIG.BOSS_BULLET_SPEED;
for (const offset of [-0.18, 0, 0.18]) {
const a = baseAngle + offset;
this.enemyBullets.push(new Bullet(
enemy.cx, enemy.cy,
Math.cos(a) * speed,
Math.sin(a) * speed,
true
));
}
return;
}
this.enemyBullets.push(new Bullet(
enemy.cx, enemy.cy,
(dx / dist) * CONFIG.ENEMY_BULLET_SPEED,
(dy / dist) * CONFIG.ENEMY_BULLET_SPEED,
true
));
}
destroyEnemy(index) {
const enemy = this.enemies[index];
this.enemies.splice(index, 1);
enemy.removeNode(this.graph);
this.sfx.playExplosion(enemy.isBoss);
if (enemy.isBoss && this.activeBoss === enemy) {
this.activeBoss = null;
}
// Update combo
this.combo.count += 1;
this.combo.timer = CONFIG.COMBO_WINDOW;
this.combo.lastKillX = enemy.cx;
this.combo.lastKillY = enemy.cy;
// Calculate score with combo bonus
const comboBonus = Math.floor(this.combo.count * CONFIG.COMBO_MULTIPLIER);
const pointsEarned = (enemy.points || 1) + comboBonus;
this.state.kills += 1;
this.state.score += pointsEarned;
// Show combo text if combo >= 2
if (this.combo.count >= 2) {
const comboText = `${this.combo.count}x COMBO!`;
const comboColor = this.combo.count >= 5 ? "#ff5e5b" :
this.combo.count >= 3 ? "#ffd166" : "#4ade80";
this.floatingTexts.push(new FloatingText(
enemy.cx, enemy.cy - 30,
comboText, comboColor,
18 + Math.min(this.combo.count * 2, 16),
1.0
));
}
// Check milestones
this.checkMilestones();
if (enemy.isBoss) {
// Multi-stage boss explosion
for (let i = 0; i < 3; i++) {
const ox = rand(-60, 60);
const oy = rand(-40, 40);
this.particles.addExplosion(enemy.cx + ox, enemy.cy + oy, enemy.color, enemy.accent);
}
this.floatingTexts.push(new FloatingText(
enemy.cx, enemy.cy,
`BOSS DOWN +${pointsEarned}`, "#f0abfc",
32, 2.4
));
this.shakeTime = Math.max(this.shakeTime, 0.5);
this.shakePower = Math.max(this.shakePower, 16);
this.flashTime = Math.max(this.flashTime, 0.3);
this.flashColor = "#c084fc";
} else {
this.particles.addExplosion(enemy.cx, enemy.cy, enemy.color, enemy.accent);
this.shakeTime = Math.max(this.shakeTime, CONFIG.SHAKE_DURATION);
this.shakePower = Math.max(this.shakePower, CONFIG.SHAKE_POWER);
this.flashTime = Math.max(this.flashTime, CONFIG.FLASH_DURATION);
this.flashColor = enemy.accent;
}
}
checkMilestones() {
for (const milestone of CONFIG.MILESTONES) {
if (this.state.kills >= milestone && !this.achievedMilestones.has(milestone)) {
this.achievedMilestones.add(milestone);
this.showMilestone(milestone);
}
}
}
showMilestone(count) {
const messages = {
5: "WARMING UP!",
10: "ON FIRE!",
25: "UNSTOPPABLE!",
50: "LEGENDARY!",
100: "GOD MODE!"
};
const colors = {
5: "#4ade80",
10: "#ffd166",
25: "#ff9f1c",
50: "#ff5e5b",
100: "#a855f7"
};
const message = messages[count] || `${count} KILLS!`;
const color = colors[count] || "#ffffff";
// Show milestone at center of view
this.floatingTexts.push(new FloatingText(
this.center.x, this.center.y,
message, color,
36, 2.0
));
// Extra shake for milestone
this.shakeTime = Math.max(this.shakeTime, 0.3);
this.shakePower = Math.max(this.shakePower, 12);
}
// ========================================================================
// RENDERING
// ========================================================================
drawCollisionDebug(ctx) {
ctx.save();
const padding = CONFIG.HIT_PADDING;
// Draw enemy hitboxes
for (const enemy of this.enemies) {
if (!enemy.active || enemy.hp <= 0) continue;
// Use the same getEnemyBounds function as collision detection
const bounds = getEnemyBounds(enemy);
const rx = bounds.x - padding;
const ry = bounds.y - padding;
const rw = bounds.w + padding * 2;
const rh = bounds.h + padding * 2;
// Draw hitbox (red) - this is what collision uses
ctx.strokeStyle = "rgba(255, 0, 0, 0.8)";
ctx.lineWidth = 2;
ctx.strokeRect(rx, ry, rw, rh);
// Draw center point (yellow)
ctx.fillStyle = "rgba(255, 255, 0, 1)";
ctx.beginPath();
ctx.arc(bounds.cx, bounds.cy, 5, 0, Math.PI * 2);
ctx.fill();
// Draw actual node bounds (cyan)
ctx.strokeStyle = "rgba(0, 255, 255, 0.6)";
ctx.lineWidth = 1;
ctx.strokeRect(bounds.x, bounds.y, bounds.w, bounds.h);
}
// Draw bullet positions (green)
ctx.fillStyle = "rgba(0, 255, 0, 1)";
for (const bullet of this.bullets) {
ctx.beginPath();
ctx.arc(bullet.x, bullet.y, 4, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
draw() {
const ctx = this.ctx;
const cw = this.canvas.width;
const ch = this.canvas.height;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cw, ch);
// ── Star field background (screen-space, before graph transform) ──
if (this.running) {
this.drawStarfield(ctx, cw, ch);
}
// Apply shake
const shakeX = this.shakeTime > 0 ? rand(-this.shakePower, this.shakePower) : 0;
const shakeY = this.shakeTime > 0 ? rand(-this.shakePower, this.shakePower) : 0;
// Transform: DPR * scale, then offset
const dpr = this.dpr;
const drawScale = this.scale * dpr;
const drawOffsetX = (this.offsetX * this.scale + shakeX * this.scale) * dpr;
const drawOffsetY = (this.offsetY * this.scale + shakeY * this.scale) * dpr;
ctx.setTransform(drawScale, 0, 0, drawScale, drawOffsetX, drawOffsetY);
// Draw particles (includes shockwaves and explosions)
this.particles.draw(ctx, this.view);
// Draw enemies (fallback only if no node)
for (const enemy of this.enemies) {
enemy.draw(ctx);
}
// Draw boss overlays (always, on top of the ComfyUI node render)
const overlayTime = performance.now() * 0.001;
for (const enemy of this.enemies) {
if (enemy.isBoss) {
enemy.drawBossOverlay(ctx, overlayTime);
}
}
// Draw bullets
for (const bullet of this.bullets) {
bullet.draw(ctx);
}
// Draw enemy bullets
for (const bullet of this.enemyBullets) {
bullet.draw(ctx);
}
// Draw rockets
for (const rocket of this.rockets) {
rocket.draw(ctx);
}
// Draw laser
if (this.skills.laser.active) {
this.drawLaser(ctx);
}
// Draw player (with invuln flicker)
this.player.draw(ctx, this.muzzleTime > 0, this.invulnTime);
// Draw floating texts
for (const text of this.floatingTexts) {
text.draw(ctx);
}
// Debug collision
if (CONFIG.DEBUG_COLLISION && this.running) {
this.drawCollisionDebug(ctx);
}
// ── Screen-space overlays (flash + vignette) ──
ctx.setTransform(1, 0, 0, 1, 0, 0);
// Flash overlay
if (this.flashTime > 0) {
const alpha = clamp(this.flashTime * 2, 0, 0.18);
ctx.globalCompositeOperation = "screen";
ctx.globalAlpha = alpha;
ctx.fillStyle = this.flashColor;
ctx.fillRect(0, 0, cw, ch);
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1;
}
// Vignette
if (this.running) {
this.drawVignette(ctx, cw, ch);
}
// Draw skill UI
this.drawSkillUI();
}
drawLaser(ctx) {
const aim = this.player.getAimVector(this.input);
const laserLength = 2000;
const startX = this.player.x + aim.x * 20;
const startY = this.player.y + aim.y * 20;
const endX = startX + aim.x * laserLength;
const endY = startY + aim.y * laserLength;
const now = performance.now();
const pulse = 0.5 + 0.5 * Math.sin(now * 0.026);
const jitter = 1.5 + pulse * 2.5;
const normalX = -aim.y;
const normalY = aim.x;
ctx.save();
ctx.globalCompositeOperation = "screen";
const beam = ctx.createLinearGradient(startX, startY, endX, endY);
beam.addColorStop(0, "rgba(255, 255, 255, 0.98)");
beam.addColorStop(0.12, "rgba(125, 249, 255, 0.95)");
beam.addColorStop(0.55, "rgba(96, 165, 250, 0.88)");
beam.addColorStop(1, "rgba(168, 85, 247, 0.2)");
const layers = [
{ width: CONFIG.LASER_WIDTH + 30 + pulse * 8, alpha: 0.16, blur: 34 },
{ width: CONFIG.LASER_WIDTH + 16 + pulse * 5, alpha: 0.34, blur: 22 },
{ width: CONFIG.LASER_WIDTH + 5, alpha: 0.82, blur: 10 }
];
for (const layer of layers) {
ctx.globalAlpha = layer.alpha;
ctx.strokeStyle = beam;
ctx.lineWidth = layer.width;
ctx.lineCap = "round";
ctx.shadowColor = "rgba(80, 210, 255, 1)";
ctx.shadowBlur = layer.blur;
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
ctx.stroke();
}
ctx.globalAlpha = 0.95;
ctx.shadowBlur = 0;
ctx.strokeStyle = "rgba(255, 255, 255, 0.98)";
ctx.lineWidth = Math.max(3, CONFIG.LASER_WIDTH * 0.34);
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
ctx.stroke();
for (let i = 0; i < 4; i++) {
const phase = now * 0.004 + i * 1.7;
const offset = Math.sin(phase) * jitter + (i - 1.5) * 2.6;
const tail = 60 + i * 28;
ctx.globalAlpha = 0.35;
ctx.strokeStyle = i % 2 ? "rgba(240, 171, 252, 0.9)" : "rgba(125, 249, 255, 0.9)";
ctx.lineWidth = 1.2;
ctx.shadowColor = ctx.strokeStyle;
ctx.shadowBlur = 12;
ctx.beginPath();
ctx.moveTo(startX + normalX * offset + aim.x * tail, startY + normalY * offset + aim.y * tail);
ctx.lineTo(endX + normalX * offset, endY + normalY * offset);
ctx.stroke();
}
ctx.globalAlpha = 1;
const muzzle = ctx.createRadialGradient(startX, startY, 0, startX, startY, 34 + pulse * 8);
muzzle.addColorStop(0, "rgba(255, 255, 255, 1)");
muzzle.addColorStop(0.22, "rgba(125, 249, 255, 0.95)");
muzzle.addColorStop(0.58, "rgba(96, 165, 250, 0.42)");
muzzle.addColorStop(1, "rgba(96, 165, 250, 0)");
ctx.fillStyle = muzzle;
ctx.shadowColor = "rgba(80, 210, 255, 1)";
ctx.shadowBlur = 28;
ctx.beginPath();
ctx.arc(startX, startY, 34 + pulse * 8, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "rgba(255, 255, 255, 0.85)";
ctx.lineWidth = 1.5;
ctx.shadowBlur = 0;
ctx.beginPath();
ctx.arc(startX, startY, 14 + pulse * 4, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
// ====================================================================
// VISUAL EFFECTS
// ====================================================================
/**
* Generate star field (called once, cached)
*/
generateStars(cw, ch) {
const stars = [];
for (let i = 0; i < CONFIG.STAR_COUNT; i++) {
stars.push({
x: Math.random() * cw,
y: Math.random() * ch,
size: 0.4 + Math.random() * 1.6,
brightness: 0.3 + Math.random() * 0.7,
twinkleSpeed: 0.5 + Math.random() * 3,
twinklePhase: Math.random() * Math.PI * 2
});
}
this.stars = stars;
this._starCanvasW = cw;
this._starCanvasH = ch;
}
drawStarfield(ctx, cw, ch) {
// Regenerate if canvas size changed
if (!this.stars || this._starCanvasW !== cw || this._starCanvasH !== ch) {
this.generateStars(cw, ch);
}
const now = performance.now() * 0.001;
// Parallax offset based on camera position (subtle)
const px = (this.offsetX * CONFIG.STAR_PARALLAX * this.dpr) % cw;
const py = (this.offsetY * CONFIG.STAR_PARALLAX * this.dpr) % ch;
ctx.save();
for (const star of this.stars) {
// Apply parallax wrapping
let sx = (star.x + px) % cw;
let sy = (star.y + py) % ch;
if (sx < 0) sx += cw;
if (sy < 0) sy += ch;
// Twinkle
const twinkle = 0.5 + 0.5 * Math.sin(now * star.twinkleSpeed + star.twinklePhase);
const alpha = star.brightness * twinkle;
if (alpha < 0.08) continue; // Skip dim stars
ctx.globalAlpha = alpha;
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.arc(sx, sy, star.size * this.dpr * 0.5, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.restore();
}
drawVignette(ctx, cw, ch) {
// Cache vignette gradient (only regenerate on size change)
const sizeKey = `${cw}x${ch}`;
if (this._lastVignetteSize !== sizeKey) {
const cx = cw / 2;
const cy = ch / 2;
const outerRadius = Math.hypot(cx, cy);
const g = ctx.createRadialGradient(cx, cy, outerRadius * 0.45, cx, cy, outerRadius);
g.addColorStop(0, "rgba(0,0,0,0)");
g.addColorStop(0.7, "rgba(0,0,0,0)");
g.addColorStop(1, "rgba(0,0,0,0.35)");
this._vignetteGradient = g;
this._lastVignetteSize = sizeKey;
}
ctx.fillStyle = this._vignetteGradient;
ctx.fillRect(0, 0, cw, ch);
}
drawSkillUI() {
// Skill UI is drawn in HTML/CSS
// Actual UI handled by updateSkillUI() which updates DOM elements
}
}