commit 81da3e3f7dbe674e80906f2c829e443118e48b2e Author: SKB Date: Sat Apr 25 19:46:01 2026 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c57be2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +*.tmp.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..edf52a9 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Node Invaders + +A small arcade shooter that runs inside the ComfyUI canvas. + +Every kill is a tiny act of rebellion against your monthly bill. Survive the swarm of API nodes and face the ultimate boss in a battle for local inference. + +Node Invaders + +## Game Modes + +- **API Invasion**: Only `api/*` nodes spawn. +- **Total Chaos**: Every built-in node is fair game. Maximum carnage. + +## Controls + +- Mouse: aim +- Left click or Space: fire +- WASD or arrow keys: move +- Right click: use the best ready ultimate +- Esc: close the game panel + +## Ultimates + +- Rocket: ready every 10 kills +- Laser: ready every 20 kills +- Nova: ready every 40 kills + +Nova clears regular enemies, damages the boss heavily, and wipes enemy bullets. + diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..04bbade --- /dev/null +++ b/__init__.py @@ -0,0 +1,32 @@ +""" +Node Invaders - ComfyUI Arcade Shooter +""" + +WEB_DIRECTORY = "." + + +class NodeInvaders_Game: + """Node Invaders Game node - loads the game overlay in ComfyUI.""" + + @classmethod + def INPUT_TYPES(cls): + return {"required": {}} + + RETURN_TYPES = () + FUNCTION = "run" + OUTPUT_NODE = True + CATEGORY = "Game" + + def run(self): + return () + + +NODE_CLASS_MAPPINGS = { + "NodeInvaders_Game": NodeInvaders_Game +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "NodeInvaders_Game": "Node Invaders" +} + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/banner.png b/banner.png new file mode 100644 index 0000000..2540ed1 Binary files /dev/null and b/banner.png differ diff --git a/boss.png b/boss.png new file mode 100644 index 0000000..83e8d7f Binary files /dev/null and b/boss.png differ diff --git a/boss2.png b/boss2.png new file mode 100644 index 0000000..08d0f02 Binary files /dev/null and b/boss2.png differ diff --git a/game.js b/game.js new file mode 100644 index 0000000..62df228 --- /dev/null +++ b/game.js @@ -0,0 +1,4193 @@ +/** + * 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 += `♥`; + } + 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 + } +} diff --git a/node_invaders.js b/node_invaders.js new file mode 100644 index 0000000..238a536 --- /dev/null +++ b/node_invaders.js @@ -0,0 +1,1362 @@ +import { app } from "../../../scripts/app.js"; +import { NodeInvadersGame } from "./game.js"; + +const EXTENSION_NAME = "Comfy.NodeInvaders.Game.Panel"; +const PANEL_ID = "nodeinvaders-panel"; +const BUTTON_ID = "nodeinvaders-toggle-button"; +const STYLE_ID = "nodeinvaders-style"; + +let gamePanel = null; +let gameButton = null; +let gameInstance = null; +let resizeHandler = null; +let currentMode = "api"; + +export function setGameMode(mode) { + if (mode !== "api" && mode !== "all") return; + if (currentMode !== mode) { + currentMode = mode; + clearNodeTypesCache(); + } +} + +export function getGameMode() { + return currentMode; +} +export const BOSS_TYPE = { + name: "BOSS", + typeCandidates: [], + color: "#1a0033", + header: "#3b0764", + accent: "#f0abfc", + stroke: "#c084fc", + hp: 32, + size: 320, + speed: 22, + seek: 6, + orbit: 5, + wobble: 0.4, + wobbleSpeed: 0.8, + canShoot: true, + fireInterval: 0.85, + points: 50, + weight: 0, + portsIn: 0, + portsOut: 0, + isBoss: true +}; + +const NODE_TYPES = [ + { + name: "KSampler", + typeCandidates: ["KSampler"], + color: "#ff5e5b", + header: "#3a1414", + accent: "#ffd166", + stroke: "#ffb3b1", + hp: 6, + size: 86, + speed: 40, + seek: 14, + orbit: 4, + wobble: 0.4, + wobbleSpeed: 1.2, + canShoot: false, + fireInterval: 3.2, + points: 3, + weight: 1.1, + portsIn: 3, + portsOut: 2 + }, + { + name: "CLIP", + typeCandidates: ["CLIPTextEncode", "CLIPTextEncodeSDXL", "CLIPTextEncodeSDXLRefiner", "CLIPSetLastLayer"], + color: "#72d884", + header: "#0f2b1f", + accent: "#b6ff70", + stroke: "#a6f4bf", + hp: 2, + size: 50, + speed: 125, + seek: 8, + orbit: 18, + wobble: 1.8, + wobbleSpeed: 3.2, + canShoot: true, + fireInterval: 1.6, + points: 2, + weight: 2.2, + portsIn: 2, + portsOut: 1 + }, + { + name: "VAE", + typeCandidates: ["VAEDecode", "VAEEncode", "VAELoader"], + color: "#4ea8de", + header: "#0d1b2a", + accent: "#8be9fd", + stroke: "#7fc7ff", + hp: 5, + size: 102, + speed: 32, + seek: 10, + orbit: 0, + wobble: 0.3, + wobbleSpeed: 0.8, + canShoot: false, + fireInterval: 3.4, + points: 3, + weight: 1.0, + portsIn: 4, + portsOut: 2 + }, + { + name: "Load Image", + typeCandidates: ["LoadImage", "LoadImageMask"], + color: "#f6bd60", + header: "#3a2a14", + accent: "#ffd27d", + stroke: "#ffe0a3", + hp: 3, + size: 72, + speed: 70, + seek: 9, + orbit: 8, + wobble: 1.0, + wobbleSpeed: 2.2, + canShoot: false, + fireInterval: 2.8, + points: 2, + weight: 1.6, + portsIn: 2, + portsOut: 2 + }, + { + name: "ControlNet", + typeCandidates: ["ControlNetLoader", "ControlNetApply", "ControlNetApplyAdvanced", "ControlNet"], + color: "#48cae4", + header: "#0b2530", + accent: "#80ffea", + stroke: "#9be5ff", + hp: 4, + size: 78, + speed: 62, + seek: 10, + orbit: -16, + wobble: 0.8, + wobbleSpeed: 2.0, + canShoot: true, + fireInterval: 2.0, + points: 3, + weight: 1.4, + portsIn: 3, + portsOut: 3 + }, + { + name: "LoRA", + typeCandidates: ["LoraLoader", "LoraLoaderModelOnly", "LoraLoaderStack", "LoraLoaderSimple", "LoraLoaderTags"], + color: "#ff9f1c", + header: "#3a2100", + accent: "#ffbf69", + stroke: "#ffd29b", + hp: 3, + size: 64, + speed: 92, + seek: 12, + orbit: 12, + wobble: 1.2, + wobbleSpeed: 2.6, + canShoot: true, + fireInterval: 2.3, + points: 2, + weight: 1.8, + portsIn: 2, + portsOut: 2 + }, + { + name: "Empty Latent", + typeCandidates: ["EmptyLatentImage", "EmptyLatent"], + color: "#9aa0a6", + header: "#202124", + accent: "#e8eaed", + stroke: "#c7c9cc", + hp: 1, + size: 44, + speed: 85, + seek: 14, + orbit: -4, + wobble: 2.4, + wobbleSpeed: 3.4, + canShoot: false, + fireInterval: 2.6, + points: 1, + weight: 3.0, + portsIn: 1, + portsOut: 1 + } +]; + +export function rand(min, max) { + return Math.random() * (max - min) + min; +} + +export function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} +function hslToHex(h, s, l) { + l /= 100; + const a = s * Math.min(l, 1 - l) / 100; + const f = n => { + const k = (n + h / 30) % 12; + const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + return Math.round(255 * color).toString(16).padStart(2, '0'); + }; + return `#${f(0)}${f(8)}${f(4)}`; +} +function darkenColor(hex, amount = 0.3) { + const num = parseInt(hex.replace("#", ""), 16); + const r = Math.round(Math.max(0, Math.min(255, (num >> 16) - amount * 255))); + const g = Math.round(Math.max(0, Math.min(255, ((num >> 8) & 0x00FF) - amount * 255))); + const b = Math.round(Math.max(0, Math.min(255, (num & 0x0000FF) - amount * 255))); + return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; +} +function lightenColor(hex, amount = 0.3) { + const num = parseInt(hex.replace("#", ""), 16); + const r = Math.round(Math.min(255, (num >> 16) + amount * 255)); + const g = Math.round(Math.min(255, ((num >> 8) & 0x00FF) + amount * 255)); + const b = Math.round(Math.min(255, (num & 0x0000FF) + amount * 255)); + return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; +} +function createTypeFromNodeName(nodeName) { + const hue = Math.random() * 360; + const sat = 60 + Math.random() * 40; + const light = 40 + Math.random() * 30; + const colorHex = hslToHex(hue, sat, light); + const headerHex = darkenColor(colorHex, 0.5); + const accentHex = lightenColor(colorHex, 0.4); + const strokeHex = lightenColor(colorHex, 0.2); + const hash = nodeName.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + const baseHp = 1 + (hash % 6); + const baseSize = 44 + (hash % 60); + const baseSpeed = 30 + (hash % 100); + + return { + name: nodeName, + typeCandidates: [nodeName], + color: colorHex, + header: headerHex, + accent: accentHex, + stroke: strokeHex, + hp: baseHp, + size: baseSize, + speed: baseSpeed, + seek: 8 + (hash % 8), + orbit: (hash % 20) - 10, + wobble: 0.3 + (hash % 200) / 100, + wobbleSpeed: 0.8 + (hash % 300) / 100, + canShoot: (hash % 3) === 0, + fireInterval: 1.6 + (hash % 200) / 100, + points: Math.max(1, Math.floor(baseHp / 2)), + weight: 1.0, + portsIn: 1 + (hash % 4), + portsOut: 1 + (hash % 3) + }; +} +let allNodeTypesCache = null; + +export function clearNodeTypesCache() { + allNodeTypesCache = null; +} +function isApiNode(nodeName, nodeDef) { + if (nodeDef?.category) { + const cat = String(nodeDef.category).toLowerCase().trim(); + if (cat.startsWith("api")) return true; + if (cat.includes("/api/") || cat.includes("api node") || cat.includes("api-")) return true; + } + const lower = nodeName.toLowerCase(); + const apiVendors = [ + "openai", "anthropic", "stability", "stabilityai", + "gemini", "veo", "imagen", "luma", "kling", + "runway", "minimax", "ideogram", "recraft", + "pika", "bfl", "fluxapi", "blackforestlabs", + "tripo", "rodin" + ]; + for (const vendor of apiVendors) { + if (lower.includes(vendor)) return true; + } + return false; +} +function hasComplexJsUi(nodeName, nodeDef) { + if (!nodeDef) return false; + const proto = nodeDef.prototype; + if (proto) { + const drawHooks = [ + "onDrawForeground", + "onDrawBackground", + "onDrawTitle", + "onDrawTitleBar" + ]; + for (const hook of drawHooks) { + if (Object.prototype.hasOwnProperty.call(proto, hook)) return true; + } + if (Object.prototype.hasOwnProperty.call(proto, "addDOMWidget")) return true; + } + const lower = nodeName.toLowerCase(); + const heavyUiNames = [ + "preview", "previewimage", + "showimage", "imageshow", + "saveimage", "saveanimatedwebp", "saveanimatedpng", + "loadvideo", "savevideo", + "imagecrop", "imagepad", + "maskeditor", "imageinpaint" + ]; + for (const pat of heavyUiNames) { + if (lower === pat || lower.includes(pat)) return true; + } + return false; +} +function isCustomNode(nodeName, nodeDef) { + if (nodeDef?.category) { + const category = nodeDef.category.toLowerCase(); + if (category.includes('custom') || + category.includes('extension') || + category.includes('plugin')) { + return true; + } + } + const nameLower = nodeName.toLowerCase(); + const customPatterns = [ + 'custom', + 'extension', + 'plugin', + 'addon', + 'thirdparty', + 'third-party' + ]; + + for (const pattern of customPatterns) { + if (nameLower.includes(pattern)) { + return true; + } + } + if (nodeDef?.meta?.author && + !nodeDef.meta.author.toLowerCase().includes('comfyui') && + !nodeDef.meta.author.toLowerCase().includes('comfy')) { + return true; + } + + return false; +} + +function isInternalNode(nodeName) { + return nodeName.startsWith('_') || + nodeName.includes('Reroute') || + nodeName === 'Note' || + nodeName.includes('PrimitiveNode') || + nodeName === 'Primitive'; +} + +function getAllAvailableNodeTypes() { + if (allNodeTypesCache) { + return allNodeTypesCache; + } + + const registered = getRegisteredNodeTypes(); + const allTypes = []; + + if (currentMode === "api") { + for (const nodeName in registered) { + if (isInternalNode(nodeName)) continue; + const nodeDef = registered[nodeName]; + if (!isApiNode(nodeName, nodeDef)) continue; + const typeInfo = createTypeFromNodeName(nodeName); + typeInfo.isApiNode = true; + allTypes.push(typeInfo); + } + if (allTypes.length === 0) { + for (const type of NODE_TYPES) { + const available = pickAvailableNodeType(type.typeCandidates); + if (available) { + allTypes.push({ ...type, typeCandidates: [available] }); + } + } + } + } else { + for (const type of NODE_TYPES) { + const available = pickAvailableNodeType(type.typeCandidates); + if (available) { + allTypes.push({ ...type, typeCandidates: [available] }); + } + } + + for (const nodeName in registered) { + const alreadyIncluded = NODE_TYPES.some(type => + type.typeCandidates.includes(nodeName) + ); + if (alreadyIncluded) continue; + if (isInternalNode(nodeName)) continue; + + const nodeDef = registered[nodeName]; + if (isCustomNode(nodeName, nodeDef)) continue; + if (hasComplexJsUi(nodeName, nodeDef)) continue; + + allTypes.push(createTypeFromNodeName(nodeName)); + } + } + + allNodeTypesCache = allTypes; + return allTypes; +} + +export function pickWeightedType() { + const allTypes = getAllAvailableNodeTypes(); + + if (allTypes.length === 0) { + let total = 0; + for (const type of NODE_TYPES) { + total += type.weight; + } + let roll = Math.random() * total; + for (const type of NODE_TYPES) { + if (roll < type.weight) { + return type; + } + roll -= type.weight; + } + return NODE_TYPES[0]; + } + let total = 0; + for (const type of allTypes) { + total += type.weight || 1.0; + } + let roll = Math.random() * total; + for (const type of allTypes) { + const weight = type.weight || 1.0; + if (roll < weight) { + return type; + } + roll -= weight; + } + return allTypes[0]; +} + +export function getLiteGraph() { + return window.LiteGraph || globalThis.LiteGraph; +} + +function getRegisteredNodeTypes() { + const liteGraph = getLiteGraph(); + return liteGraph?.registered_node_types || {}; +} + +export function pickAvailableNodeType(candidates) { + const registered = getRegisteredNodeTypes(); + for (const candidate of candidates) { + if (registered?.[candidate]) { + return candidate; + } + } + return null; +} + +export function createGraphNode(typeName) { + const liteGraph = getLiteGraph(); + if (!liteGraph?.createNode) { + return null; + } + return liteGraph.createNode(typeName); +} + +function injectStyles() { + if (document.getElementById(STYLE_ID)) { + return; + } + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` +#${PANEL_ID} { + display: none; + z-index: 60; + pointer-events: none; +} + +#${PANEL_ID} * { + box-sizing: border-box; +} + +#nodeinvaders-container { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: auto; + background: transparent; + color: #eaf2ff; + font-family: "Segoe UI Variable", "Segoe UI", Inter, ui-sans-serif, system-ui, sans-serif; + user-select: none; +} + +#nodeinvaders-canvas { + width: 100%; + height: 100%; + display: block; +} + +#nodeinvaders-ui { + position: absolute; + top: 72px; + left: 50%; + transform: translateX(-50%); + width: min(760px, calc(100vw - 180px)); + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + z-index: 4; + pointer-events: none; +} + +.ni-brand { + font-family: "Segoe UI Variable Display", "Segoe UI", Inter, ui-sans-serif, system-ui, sans-serif; + font-size: 13px; + font-weight: 850; + letter-spacing: 0.14em; + text-transform: uppercase; + background: linear-gradient(120deg, #4ade80 0%, #60a5fa 50%, #c084fc 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; + text-shadow: 0 0 14px rgba(96, 165, 250, 0.38); + filter: drop-shadow(0 2px 5px rgba(0, 0, 0, 0.72)); +} + +.ni-mode-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + border-radius: 7px; + background: rgba(7, 10, 16, 0.78); + border: 1px solid rgba(192, 132, 252, 0.45); + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #f0abfc; + width: fit-content; + backdrop-filter: blur(6px); +} + +.ni-mode-pill::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: #f0abfc; + box-shadow: 0 0 8px #f0abfc; +} + +.ni-stats { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + max-width: 100%; + padding: 8px 12px; + border-radius: 8px; + background: linear-gradient(135deg, rgba(6, 9, 15, 0.88), rgba(15, 18, 30, 0.8)); + border: 1px solid rgba(120, 140, 180, 0.22); + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.05); + backdrop-filter: blur(8px); + font-size: 11px; + font-weight: 650; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.ni-stat { + position: relative; +} + +.ni-stat + .ni-stat::before { + content: ""; + position: absolute; + left: -7px; + top: 2px; + bottom: 2px; + width: 1px; + background: linear-gradient(180deg, transparent, rgba(192, 132, 252, 0.35), transparent); +} + +.ni-stat { + display: flex; + gap: 6px; + align-items: center; +} + +.ni-value { + color: #ffd166; + font-weight: 800; + font-variant-numeric: tabular-nums; +} + +.ni-lives { + min-width: 56px; + gap: 4px; +} + +.ni-life { + display: inline-flex; + align-items: center; + justify-content: center; + width: 15px; + height: 15px; + font-size: 14px; + line-height: 1; + transform: translateY(-1px); + text-shadow: 0 0 10px rgba(255, 94, 91, 0.75); +} + +.ni-life.on { + color: #ff5e5b; +} + +.ni-life.off { + color: rgba(255, 255, 255, 0.22); + text-shadow: none; +} + +.ni-hint { + max-width: min(680px, 100%); + padding: 5px 10px; + border-radius: 7px; + background: rgba(5, 8, 13, 0.62); + border: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(234, 242, 255, 0.72); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; + line-height: 1.25; + text-align: center; + text-shadow: 0 1px 6px rgba(0, 0, 0, 0.75); + backdrop-filter: blur(7px); +} + +@media (max-width: 760px) { + #nodeinvaders-ui { + top: 64px; + width: calc(100vw - 96px); + } + + .ni-stats { + flex-wrap: wrap; + row-gap: 5px; + } + + .ni-hint { + display: none; + } +} + +.ni-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: + radial-gradient(ellipse at 20% 0%, rgba(96, 165, 250, 0.18) 0%, transparent 55%), + radial-gradient(ellipse at 80% 100%, rgba(192, 132, 252, 0.18) 0%, transparent 55%), + linear-gradient(140deg, rgba(6, 10, 18, 0.94), rgba(10, 20, 34, 0.92)); + z-index: 5; + overflow: hidden; +} + +.ni-overlay::before { + content: ""; + position: absolute; + inset: 0; + background-image: repeating-linear-gradient( + 0deg, + rgba(255, 255, 255, 0.025) 0, + rgba(255, 255, 255, 0.025) 1px, + transparent 1px, + transparent 3px + ); + pointer-events: none; + z-index: 1; +} + +.ni-overlay.is-hidden { + display: none; +} + +.ni-card { + position: relative; + z-index: 2; + width: 92vw; + max-width: 720px; + padding: 32px 36px; + border-radius: 22px; + border: 1px solid rgba(192, 132, 252, 0.25); + background: + linear-gradient(180deg, rgba(20, 22, 40, 0.95) 0%, rgba(10, 12, 22, 0.95) 100%); + box-shadow: + 0 25px 60px rgba(0, 0, 0, 0.6), + 0 0 80px rgba(96, 165, 250, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.05); + text-align: left; + animation: ni-fade-up 0.6s cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.ni-card::before { + content: ""; + position: absolute; + inset: -1px; + border-radius: 22px; + padding: 1px; + background: linear-gradient(135deg, rgba(74, 222, 128, 0.4), rgba(96, 165, 250, 0.3), rgba(192, 132, 252, 0.4)); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; + opacity: 0.6; +} + +.ni-title { + font-family: "Segoe UI Variable Display", "Segoe UI", Inter, ui-sans-serif, system-ui, sans-serif; + font-size: 42px; + font-weight: 900; + line-height: 1; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 8px; + background: linear-gradient(120deg, #4ade80 0%, #60a5fa 50%, #c084fc 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; + filter: drop-shadow(0 0 18px rgba(96, 165, 250, 0.45)); +} + +.ni-subtitle { + font-size: 12px; + letter-spacing: 0.2em; + text-transform: uppercase; + color: rgba(192, 132, 252, 0.85); + margin-bottom: 22px; +} + +.ni-section-label { + font-size: 11px; + letter-spacing: 2.4px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.55); + margin: 6px 0 10px; +} + +.ni-modes { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 20px; +} + +@media (max-width: 600px) { + .ni-modes { grid-template-columns: 1fr; } +} + +.ni-mode { + position: relative; + padding: 14px 16px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.03); + cursor: pointer; + transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease; + text-align: left; + color: inherit; + font-family: inherit; +} + +.ni-mode:hover { + transform: translateY(-2px); + border-color: rgba(192, 132, 252, 0.5); + background: rgba(192, 132, 252, 0.06); +} + +.ni-mode.is-active { + border-color: rgba(74, 222, 128, 0.7); + background: linear-gradient(135deg, rgba(74, 222, 128, 0.12), rgba(96, 165, 250, 0.08)); + box-shadow: 0 0 30px rgba(74, 222, 128, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.ni-mode-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.ni-mode-name { + font-family: "Segoe UI Variable Display", "Segoe UI", Inter, ui-sans-serif, system-ui, sans-serif; + font-size: 18px; + font-weight: 850; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #eaf2ff; +} + +.ni-mode-badge { + font-size: 10px; + letter-spacing: 1.6px; + text-transform: uppercase; + padding: 2px 8px; + border-radius: 999px; + background: rgba(74, 222, 128, 0.18); + border: 1px solid rgba(74, 222, 128, 0.5); + color: #4ade80; + opacity: 0; + transition: opacity 0.15s ease; +} + +.ni-mode.is-active .ni-mode-badge { + opacity: 1; +} + +.ni-mode-desc { + font-size: 12px; + line-height: 1.45; + color: rgba(234, 242, 255, 0.7); +} + +.ni-mode-tag { + margin-top: 8px; + font-size: 10px; + letter-spacing: 1.4px; + text-transform: uppercase; + color: rgba(255, 209, 102, 0.85); +} + +.ni-mode[data-mode="api"] .ni-mode-name { + background: linear-gradient(120deg, #f0abfc, #c084fc); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.ni-mode[data-mode="all"] .ni-mode-name { + background: linear-gradient(120deg, #4ade80, #60a5fa); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.ni-tip-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 8px; + margin-bottom: 22px; + font-size: 12px; + color: rgba(234, 242, 255, 0.78); +} + +.ni-tip-list span { + display: block; + padding: 8px 12px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.ni-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.ni-button { + padding: 10px 18px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: linear-gradient(135deg, #ff5e5b, #ff9f1c); + color: #0b0f18; + font-weight: bold; + letter-spacing: 1px; + cursor: pointer; + text-transform: uppercase; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.ni-button:hover { + transform: translateY(-1px); + box-shadow: 0 10px 24px rgba(255, 95, 92, 0.35); +} + +.ni-secondary { + background: rgba(255, 255, 255, 0.08); + color: #f0f4ff; +} + +.ni-overview { + display: flex; + gap: 12px; + margin: 16px 0 20px; + font-size: 13px; +} + +.ni-overview div { + padding: 8px 12px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); + min-width: 120px; +} + +.ni-overview strong { + display: block; + font-size: 18px; + color: #ffd166; +} + +.ni-reason { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 1.4px; + opacity: 0.7; + margin-bottom: 16px; +} + +@keyframes ni-fade-up { + 0% { opacity: 0; transform: translateY(12px); } + 100% { opacity: 1; transform: translateY(0); } +} + +#nodeinvaders-skills { + position: absolute; + right: 18px; + top: 50%; + transform: translateY(-34%); + display: flex; + flex-direction: column; + gap: 10px; + z-index: 4; + pointer-events: none; +} + +.ni-skill { + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + width: 74px; + min-height: 74px; + padding: 8px 10px; + border-radius: 10px; + background: rgba(7, 10, 16, 0.9); + border: 1px solid rgba(255, 255, 255, 0.16); + backdrop-filter: blur(8px); + transition: all 0.2s ease; +} + +.ni-skill.ready { + border-color: #4ade80; + box-shadow: 0 0 20px rgba(74, 222, 128, 0.4); + animation: ni-pulse 1.5s ease infinite; +} + +.ni-skill.active { + border-color: #60a5fa; + box-shadow: 0 0 25px rgba(96, 165, 250, 0.6); + animation: ni-pulse-fast 0.8s ease infinite; +} + +.ni-skill-icon { + font-size: 24px; + line-height: 1; +} + +.ni-skill-label { + font-size: 10px; + font-weight: bold; + letter-spacing: 0.5px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.9); +} + +.ni-skill-progress { + font-size: 12px; + font-weight: bold; + color: #ffd166; + min-height: 16px; + text-align: center; +} + +@media (max-height: 720px) { + #nodeinvaders-skills { + top: auto; + right: 72px; + bottom: 22px; + transform: none; + flex-direction: row; + } +} + +.ni-skill.ready .ni-skill-progress { + color: #4ade80; +} + +.ni-skill.active .ni-skill-progress { + color: #60a5fa; +} + +@keyframes ni-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } +} + +@keyframes ni-pulse-fast { + 0%, 100% { transform: scale(1); box-shadow: 0 0 25px rgba(96, 165, 250, 0.6); } + 50% { transform: scale(1.08); box-shadow: 0 0 35px rgba(96, 165, 250, 0.8); } +} + +@keyframes ni-button-pulse { + 0%, 100% { box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5), 0 0 0 0px rgba(96, 165, 250, 0); } + 50% { box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6), 0 0 12px 2px rgba(96, 165, 250, 0.25); } +} + +#nodeinvaders-toggle-button { + position: fixed; + top: 158px; + right: 8px; + z-index: 10000; + width: 44px; + height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: rgba(10, 14, 24, 0.85); + color: #eaf2ff; + cursor: pointer; + backdrop-filter: blur(16px) saturate(180%); + border: 1px solid rgba(255, 255, 255, 0.1); + transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); + box-shadow: + 0 4px 15px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(255, 255, 255, 0.05), + inset 0 1px 1px rgba(255, 255, 255, 0.1); + padding: 0; + outline: none; + animation: ni-button-pulse 3s infinite ease-in-out; +} + +#nodeinvaders-toggle-button::before { + content: ""; + position: absolute; + inset: -1px; + border-radius: 14px; + padding: 1px; + background: linear-gradient(135deg, #4ade80, #60a5fa, #c084fc); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + opacity: 0.35; + transition: opacity 0.3s ease; +} + +#nodeinvaders-toggle-button:hover { + transform: translateY(-2px) scale(1.04); + background: rgba(20, 24, 38, 0.88); + border-color: rgba(255, 255, 255, 0.15); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.55), 0 0 18px rgba(96, 165, 250, 0.25); +} + +#nodeinvaders-toggle-button:hover::before { + opacity: 0.85; +} + +#nodeinvaders-toggle-button:active { + transform: translateY(0) scale(0.96); +} + +.ni-control-label { + font-family: "Segoe UI Variable Display", "Segoe UI", Inter, system-ui, sans-serif; + font-size: 17px; + font-weight: 900; + letter-spacing: 0.02em; + background: linear-gradient(135deg, #4ade80, #60a5fa, #c084fc); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + filter: drop-shadow(0 0 8px rgba(96, 165, 250, 0.35)); + user-select: none; +} + +.ni-control-tooltip { + position: absolute; + top: 58px; + right: 0; + background: rgba(8, 10, 16, 0.96); + color: #eaf2ff; + padding: 7px 12px; + border-radius: 9px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; + opacity: 0; + transform: translateY(-8px); + transition: all 0.25s cubic-bezier(0.2, 0.8, 0.2, 1); + pointer-events: none; + border: 1px solid rgba(192, 132, 252, 0.25); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.6); + backdrop-filter: blur(10px); +} + +#nodeinvaders-toggle-button:hover .ni-control-tooltip { + opacity: 1; + transform: translateY(0); +} + +`; + + document.head.appendChild(style); +} + +function getCanvasBounds() { + const container = document.getElementById("graph-canvas-container") + || document.querySelector(".graph-canvas-container") + || document.querySelector("canvas")?.parentElement; + + if (container) { + const rect = container.getBoundingClientRect(); + return { + width: rect.width, + height: rect.height, + container, + rect + }; + } + + return { + width: window.innerWidth, + height: window.innerHeight, + container: null, + rect: null + }; +} + +function createGamePanel() { + const bounds = getCanvasBounds(); + const panel = document.createElement("div"); + panel.id = PANEL_ID; + + if (bounds.container) { + panel.style.cssText = "position:absolute;left:0;top:0;width:100%;height:100%;display:none;"; + } else { + panel.style.cssText = "position:fixed;left:0;top:0;width:100vw;height:100vh;display:none;"; + } + + panel.innerHTML = ` +
+ +
+
Node Invaders
+
API Nodes Only
+
+
Score 0
+
Time 0.0s
+
Destroyed 0
+
+
+
Mouse to aim. Hold click or Space to fire. WASD or arrows to drift. Right click for skills. Esc closes.
+
+
+
+
🚀
+
Rocket
+
10
+
+
+
⚡
+
Laser
+
20
+
+
+
✦
+
Nova
+
40
+
+
+
+
+
Node Invaders
+
ComfyUI Arcade Shooter
+ + +
+ + +
+ +
+ 🎯 Mouse aims, click / Space fires. + 🛞 WASD or arrows to drift. + 💥 Right-click triggers skills when ready. + 👹 Beware the Boss — it bites back. +
+ +
+ + +
+
+
+ +
+ `; + + if (bounds.container) { + const style = window.getComputedStyle(bounds.container); + if (style.position === "static") { + bounds.container.style.position = "relative"; + } + bounds.container.appendChild(panel); + } else { + document.body.appendChild(panel); + } + + gamePanel = panel; + const root = panel.querySelector("#nodeinvaders-container"); + gameInstance = new NodeInvadersGame(root); + gameInstance.setToggleGamePanel(toggleGamePanel); + gameInstance.mount(); + return panel; +} + +function createControlButton() { + if (document.getElementById(BUTTON_ID)) { + gameButton = document.getElementById(BUTTON_ID); + return gameButton; + } + + const button = document.createElement("button"); + button.id = BUTTON_ID; + button.type = "button"; + + const label = document.createElement("span"); + label.className = "ni-control-label"; + label.textContent = "NI"; + button.appendChild(label); + + const tooltip = document.createElement("div"); + tooltip.className = "ni-control-tooltip"; + tooltip.textContent = "Node Invaders"; + button.appendChild(tooltip); + + button.addEventListener("click", toggleGamePanel); + document.body.appendChild(button); + gameButton = button; + return button; +} + +async function toggleGamePanel() { + if (!gamePanel) { + createGamePanel(); + } + + const isVisible = gamePanel.style.display !== "none"; + if (!isVisible) { + gamePanel.style.display = "block"; + if (gameButton) { + const label = gameButton.querySelector(".ni-control-label"); + if (label) label.textContent = "X"; + const tooltip = gameButton.querySelector(".ni-control-tooltip"); + if (tooltip) { + tooltip.textContent = "Close Node Invaders"; + } + } + gameInstance?.setVisible(true); + gameInstance?.resize(); + } else { + gamePanel.style.display = "none"; + if (gameButton) { + const label = gameButton.querySelector(".ni-control-label"); + if (label) label.textContent = "NI"; + const tooltip = gameButton.querySelector(".ni-control-tooltip"); + if (tooltip) { + tooltip.textContent = "Node Invaders"; + } + } + gameInstance?.setVisible(false); + } +} + +app.registerExtension({ + name: EXTENSION_NAME, + async setup() { + injectStyles(); + createControlButton(); + resizeHandler = () => { + if (gameInstance) { + gameInstance.resize(); + } + }; + window.addEventListener("resize", resizeHandler); + }, + async beforeUnload() { + if (resizeHandler) { + window.removeEventListener("resize", resizeHandler); + resizeHandler = null; + } + if (gameInstance) { + gameInstance.setVisible(false); + gameInstance.detachEvents(); + gameInstance = null; + } + if (gamePanel) { + gamePanel.remove(); + gamePanel = null; + } + if (gameButton) { + gameButton.remove(); + gameButton = null; + } + const style = document.getElementById(STYLE_ID); + if (style) { + style.remove(); + } + } +});