// ═══ weapon-projectiles.js ═══ // ============================================================ // WEAPON PROJECTILES — projectile, beam, AoE, and chain updates // ============================================================ // ── UPDATE PROJECTILES ──────────────────────────────────────── function updateProjectiles() { const W = canvas.width, H = canvas.height; const cx = W / 2, cy = H / 2; for (const p of G.projectiles) { if (p.life <= 0) continue; if (p.type === 'mortar') { // Arc toward target p.progress += (p.speed * 0.6) / Math.hypot(p.tx - p.sx, p.ty - p.sy); p.progress = Math.min(p.progress, 1); p.x = p.sx + (p.tx - p.sx) * p.progress; p.y = p.sy + (p.ty - p.sy) * p.progress; // Arc height const arc = Math.sin(p.progress * Math.PI) * 60; p.y -= arc; if (p.progress >= 1) { // Explode explodeMortar(p); p.life = 0; } continue; } // Homing if (p.homing && p.targetId) { let target = p.targetRef && p.targetRef.alive ? p.targetRef : null; if (!target) { for (const e of G.enemies) { if (e.id === p.targetId && e.alive) { target = e; p.targetRef = e; break; } } } if (target) { const angle = Math.atan2(target.y - p.y, target.x - p.x); const currentAngle = Math.atan2(p.vy, p.vx); const delta = normalizeAngle(angle - currentAngle); const newAngle = currentAngle + Math.sign(delta) * Math.min(Math.abs(delta), p.homingStrength); const spd = Math.hypot(p.vx, p.vy); p.vx = Math.cos(newAngle) * spd; p.vy = Math.sin(newAngle) * spd; } } p.x += p.vx; p.y += p.vy; // Out of bounds if (p.x < -50 || p.x > W+50 || p.y < -50 || p.y > H+50) { p.life = 0; continue; } // Hit detection for (const e of G.enemies) { if (!e.alive || e.spawnImmunity > 0) continue; const hitRadius = e.radius + p.radius; if (distSq(p.x, p.y, e.x, e.y) < hitRadius * hitRadius) { // Evasion check if (e.evasion && Math.random() < e.evasion) { spawnFloater(e.x, e.y - 14, 'DODGE', '#aaaaaa', 0.9); p.life = 0; break; } if (p.aoeRadius > 0) { explodeProjectile(p); } else { dealDamage(e, p.damage, p.elements, true, p.critChance, p.armorShred); if (e.hp <= 0) killEnemy(e, true); } if (p.pierceLeft > 0) { p.pierceLeft--; } else { p.life = 0; break; } } } } compactLiveArray(G.projectiles, p => p.life > 0); } function explodeMortar(p) { sfx_mortar_explode(); spawnParticleBurst(p.x, p.y, p.color, 20); // Screen shake feel: big particle burst spawnParticleBurst(p.x, p.y, '#ffffff', 6); if (p.dotDamage > 0) { // Lingering zone (poison cloud) G.aoeZones.push({ id: uid(), x: p.x, y: p.y, radius: p.aoeRadius, color: p.color, life: 1, duration: p.dotDuration || 180, remaining: p.dotDuration || 180, dotDamage: p.dotDamage, dotInterval: p.dotInterval || 30, dotTick: 0, elements: p.elements, freezeDuration: p.freezeDuration || 0, }); return; } for (const e of G.enemies) { if (!e.alive || e.spawnImmunity > 0) continue; const hitRadius = p.aoeRadius + e.radius; if (distSq(e.x, e.y, p.x, p.y) < hitRadius * hitRadius) { dealDamage(e, p.damage, p.elements, false); if (p.freezeDuration > 0) applyFreeze(e, p.freezeDuration); if (e.hp <= 0) killEnemy(e, true); } } } function explodeProjectile(p) { spawnParticleBurst(p.x, p.y, p.color, 24); spawnParticleBurst(p.x, p.y, '#ffffff', 5); for (const e of G.enemies) { if (!e.alive || e.spawnImmunity > 0) continue; const hitRadius = p.aoeRadius + e.radius; if (distSq(e.x, e.y, p.x, p.y) < hitRadius * hitRadius) { dealDamage(e, p.damage, p.elements, true, p.critChance, p.armorShred); if (e.hp <= 0) killEnemy(e, true); } } p.life = 0; } function updateBeams() { compactLiveArray(G.beams, b => { b.life -= b.decay; return b.life > 0; }); } function updateAoeZones() { for (const z of G.aoeZones) { z.remaining--; z.life = z.remaining / z.duration; z.dotTick++; if (z.dotTick >= z.dotInterval) { z.dotTick = 0; for (const e of G.enemies) { if (!e.alive || e.spawnImmunity > 0) continue; const hitRadius = z.radius + e.radius; if (distSq(e.x, e.y, z.x, z.y) < hitRadius * hitRadius) { dealDamage(e, z.dotDamage, z.elements, false); if (e.hp <= 0) killEnemy(e, true); } } } } compactLiveArray(G.aoeZones, z => z.remaining > 0); } // Chain arc visual helper — pushes a glowing zigzag bolt to G.chainArcs function spawnChainArc(x1, y1, x2, y2, color) { const dx = x2 - x1, dy = y2 - y1; const len = Math.hypot(dx, dy) || 1; const perpX = -dy / len, perpY = dx / len; const steps = 10; const pts = [{ x: x1, y: y1 }]; for (let i = 1; i < steps; i++) { const t = i / steps; const maxOff = 26 * Math.sin(t * Math.PI); // taper to 0 at both ends const off = (Math.random() - 0.5) * 2 * maxOff; pts.push({ x: x1 + dx * t + perpX * off, y: y1 + dy * t + perpY * off }); } pts.push({ x: x2, y: y2 }); G.chainArcs.push({ pts, color, life: 1.0, decay: 0.16 }); // Short branch off the midpoint for realism const mid = pts[Math.floor(steps / 2)]; const branchAngle = Math.atan2(dy, dx) + (Math.random() < 0.5 ? 1 : -1) * (Math.PI * 0.3 + Math.random() * 0.4); const branchLen = len * 0.3; const bpts = [{ x: mid.x, y: mid.y }]; const bSteps = 4; for (let i = 1; i <= bSteps; i++) { const t = i / bSteps; const off = (Math.random() - 0.5) * 12 * Math.sin(t * Math.PI); bpts.push({ x: mid.x + Math.cos(branchAngle) * branchLen * t + perpX * off, y: mid.y + Math.sin(branchAngle) * branchLen * t + perpY * off, }); } G.chainArcs.push({ pts: bpts, color, life: 0.65, decay: 0.20 }); } function updateChainArcs() { compactLiveArray(G.chainArcs, a => { a.life -= a.decay; return a.life > 0; }); }