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