// ═══ weapons.js ═══ // ============================================================ // WEAPONS.JS — Firing logic for all weapon types // ============================================================ const AIM_THRESHOLD = 0.18; // radians - tighter aim requirement const HARDPOINT_BASE_ANGLE = -Math.PI / 2; function getHardpointOrbit(totalSlots = Math.max(1, G.tower.weaponSlots)) { // Wider orbit as slots increase so each hardpoint stays readable. return 21 + Math.min(12, totalSlots * 1.6); } function getSlotHardpoint(slotIndex, cx, cy, totalSlots = Math.max(1, G.tower.weaponSlots)) { const orbit = getHardpointOrbit(totalSlots); const mountAngle = HARDPOINT_BASE_ANGLE + (slotIndex / totalSlots) * Math.PI * 2; return { slotIndex, totalSlots, mountAngle, x: cx + Math.cos(mountAngle) * orbit, y: cy + Math.sin(mountAngle) * orbit, orbit, }; } function getWeaponHardpoints(cx, cy) { const totalSlots = Math.max(1, G.tower.weaponSlots); const result = []; for (let slotIndex = 0; slotIndex < totalSlots; slotIndex++) { const weapon = G.weapons[slotIndex]; if (!weapon) continue; const mount = getSlotHardpoint(slotIndex, cx, cy, totalSlots); result.push({ ...mount, weapon }); } return result; } function getWeaponBarrelLength(weapon) { const type = getWeaponDef(weapon)?.type; if (type === 'beam') return 24; if (type === 'mortar') return 14; if (type === 'cone') return 15; if (type === 'multi') return 19; if (type === 'chain') return 18; return 17; } function getWeaponMuzzle(hardpoint) { const w = hardpoint.weapon; const aimAngle = typeof w.aimAngle === 'number' ? w.aimAngle : hardpoint.mountAngle; const barrelLen = getWeaponBarrelLength(w); const recoilOffset = (w.recoil || 0) * 3.2; const reach = Math.max(7, barrelLen - recoilOffset); return { x: hardpoint.x + Math.cos(aimAngle) * reach, y: hardpoint.y + Math.sin(aimAngle) * reach, angle: aimAngle, }; } function clampAimPoint(x, y) { const pad = 18; return { x: Math.max(pad, Math.min(canvas.width - pad, x)), y: Math.max(pad, Math.min(canvas.height - pad, y)), }; } function predictEnemyFuturePoint(target, framesAhead) { if (typeof predictEnemyPositionAlongPath === 'function') { return predictEnemyPositionAlongPath(target, framesAhead); } return { x: target.x + (target.vx || 0) * framesAhead, y: target.y + (target.vy || 0) * framesAhead, }; } function predictInterceptPoint(ox, oy, target, projectileSpeed, maxLeadFrames = 180) { const speed = Math.max(0.01, projectileSpeed); let t = Math.hypot(target.x - ox, target.y - oy) / speed; t = Math.max(0, Math.min(maxLeadFrames, t)); for (let i = 0; i < 5; i++) { const p = predictEnemyFuturePoint(target, t); const dist = Math.hypot(p.x - ox, p.y - oy); const nextT = Math.max(0, Math.min(maxLeadFrames, dist / speed)); if (Math.abs(nextT - t) < 0.2) { t = nextT; break; } t = nextT; } const predicted = predictEnemyFuturePoint(target, t); return clampAimPoint(predicted.x, predicted.y); } function getLeadAimPoint(w, target, ox, oy) { if (!w || !target) return null; const def = getWeaponDef(w); if (!def) return { x: target.x, y: target.y }; if (def.type === 'projectile') { return predictInterceptPoint(ox, oy, target, Math.max(0.1, w.projectileSpeed || 0.1), 220); } if (def.type === 'mortar') { // Mortar progress uses speed^2 * 0.6 in updateProjectiles. const ps = Math.max(0.1, w.projectileSpeed || 0.1); const planarSpeed = ps * ps * 0.6; return predictInterceptPoint(ox, oy, target, planarSpeed, 260); } return { x: target.x, y: target.y }; } function getWeaponColor(w) { const el = getWeaponElements(w)[0]; return ELEMENTS[el]?.color || getWeaponDef(w)?.color || '#fff'; } function getIdleScanAngle(hardpoint) { const sweep = Math.PI / 4; // 90 degree cone, centered away from tower. const phase = hardpoint.slotIndex * 1.7; return normalizeAngle(hardpoint.mountAngle + Math.sin(G.frame * 0.0125 + phase) * sweep); } function updateWeapons() { const cx = ARENA_CX, cy = ARENA_CY; const hardpoints = getWeaponHardpoints(cx, cy); let hasPrimaryAim = false; for (const hardpoint of hardpoints) { const w = hardpoint.weapon; const mountAngle = hardpoint.mountAngle; if (typeof w.aimAngle !== 'number') w.aimAngle = mountAngle; if (typeof w.cooldown !== 'number') w.cooldown = 0; if (typeof w.recoil !== 'number') w.recoil = 0; if (typeof w.muzzleFlash !== 'number') w.muzzleFlash = 0; w.recoil *= 0.68; if (w.recoil < 0.01) w.recoil = 0; if (w.muzzleFlash > 0) w.muzzleFlash--; const target = pickTarget(w); let aimPoint = null; let desiredAngle = null; if (target) { aimPoint = getLeadAimPoint(w, target, hardpoint.x, hardpoint.y); desiredAngle = Math.atan2(aimPoint.y - hardpoint.y, aimPoint.x - hardpoint.x); w.aimAngle = stepTowardAngle(w.aimAngle, desiredAngle, G.tower.aimSpeed); } else { const scanDelta = shortestAngleDelta(w.aimAngle, mountAngle); if (Math.abs(scanDelta) > Math.PI / 4) w.aimAngle = mountAngle; desiredAngle = getIdleScanAngle(hardpoint); w.aimAngle = stepTowardAngle(w.aimAngle, desiredAngle, G.tower.aimSpeed * 0.45); } if (!hasPrimaryAim) { G.tower.cannonAngle = w.aimAngle; hasPrimaryAim = true; } if (w.cooldown > 0) { w.cooldown--; continue; } if (!target) continue; const dx = target.x - hardpoint.x; const dy = target.y - hardpoint.y; const dist = Math.hypot(dx, dy); if (dist > w.range) continue; let fireAngle = w.aimAngle; // Aim check for directional weapons. if (w.defId !== 'flamethrower' && w.defId !== 'laser') { const aimDelta = shortestAngleDelta(w.aimAngle, desiredAngle); if (Math.abs(aimDelta) > AIM_THRESHOLD) continue; // Snap to exact target angle on shot so first-round accuracy is reliable. fireAngle = desiredAngle; w.aimAngle = desiredAngle; } else if (desiredAngle != null) { fireAngle = desiredAngle; w.aimAngle = desiredAngle; } const muzzle = getWeaponMuzzle(hardpoint); fireWeapon(w, target, muzzle.x, muzzle.y, fireAngle, aimPoint); w.cooldown = Math.max(4, w.fireRate); w.recoil = 1; w.muzzleFlash = 4; w.lastFireFrame = G.frame; } if (!hasPrimaryAim) G.tower.cannonAngle = normalizeAngle((G.tower.cannonAngle || 0) + 0.006); // Update projectiles updateProjectiles(); updateBeams(); updateChainArcs(); updateAoeZones(); }