// ═══ upgrades.js ═══ // ============================================================ // UPGRADES.JS — Purchase, refund, apply upgrade logic // ============================================================ function collectUpgradeDependents(tree, upgradeId) { const result = []; function collect(id) { for (const u of tree) { if (u.requires.includes(id) && !result.includes(u.id)) { result.push(u.id); collect(u.id); } } } collect(upgradeId); return result; } function getTowerDependents(upgradeId) { return collectUpgradeDependents(TOWER_UPGRADE_TREE, upgradeId); } function getWeaponDependents(defId, upgradeId) { return collectUpgradeDependents(WEAPON_UPGRADE_TREES[defId] || [], upgradeId); } function getShieldDependents(upgradeId) { const type = G.tower.shield; return type ? collectUpgradeDependents(SHIELD_UPGRADE_TREES[type] || [], upgradeId) : []; } function buyTowerUpgrade(upgradeId) { const upgrade = TOWER_UPGRADE_TREE.find(u => u.id === upgradeId); if (!upgrade) return; if (!upgrade.repeatable && G.towerUpgradesBought.includes(upgradeId)) return; if (upgrade.effect?.repair && G.tower.hp >= G.tower.maxHp) { addLog('Tower already at full HP.', 'info'); return; } // Tier requirement if ((upgrade.minTier ?? 0) > (G.difficultyTier || 0)) return; for (const req of upgrade.requires) { if (!G.towerUpgradesBought.includes(req)) return; } if (spendableCredits() < upgrade.cost) return; G.credits -= upgrade.cost; if (!upgrade.repeatable) G.towerUpgradesBought.push(upgradeId); applyTowerUpgrade(upgrade.effect); sfx_buy(); addLog('Upgraded: ' + upgrade.label, 'info'); updateHUD(); renderShop(); } function refundTowerUpgrade(upgradeId) { const upgrade = TOWER_UPGRADE_TREE.find(u => u.id === upgradeId); if (!upgrade || !G.towerUpgradesBought.includes(upgradeId)) return; const toRefund = [upgradeId, ...getTowerDependents(upgradeId)] .filter(id => G.towerUpgradesBought.includes(id)); let total = 0; for (const id of toRefund) { const u = TOWER_UPGRADE_TREE.find(x => x.id === id); if (!u) continue; total += u.cost; reverseTowerUpgrade(u.effect, id); G.towerUpgradesBought = G.towerUpgradesBought.filter(x => x !== id); } G.credits += total; sfx_refund(); addLog('Refunded ' + toRefund.length + ' upgrade(s) — +' + total + 'c', 'info'); updateHUD(); renderShop(); } function applyTowerUpgrade(effect) { if (effect.maxHp) { G.tower.maxHp += effect.maxHp; G.tower.hp += effect.maxHp; } if (effect.armor) G.tower.armor += effect.armor; if (effect.aimSpeed) G.tower.aimSpeed += effect.aimSpeed; if (effect.repair) G.tower.hp = Math.min(G.tower.maxHp, G.tower.hp + effect.repair); if (effect.weaponSlot) G.tower.weaponSlots = Math.max(G.tower.weaponSlots, effect.weaponSlot); if (effect.range) G.tower.range += effect.range; if (effect.shield) { G.tower.shield = effect.shield; initShield(effect.shield); G.shieldUpgradesBought = []; } } function reverseTowerUpgrade(effect, id) { if (effect.maxHp) { G.tower.maxHp -= effect.maxHp; G.tower.hp = Math.min(G.tower.hp, G.tower.maxHp); } if (effect.armor) G.tower.armor = Math.max(0, G.tower.armor - effect.armor); if (effect.aimSpeed) G.tower.aimSpeed = Math.max(0.01, G.tower.aimSpeed - effect.aimSpeed); if (effect.range) G.tower.range = Math.max(150, G.tower.range - effect.range); if (effect.weaponSlot) { const remaining = G.towerUpgradesBought .filter(x => x !== id) .map(x => TOWER_UPGRADE_TREE.find(u => u.id === x)) .filter(u => u && u.effect && u.effect.weaponSlot) .map(u => u.effect.weaponSlot); G.tower.weaponSlots = remaining.length > 0 ? Math.max(...remaining) : 1; while (G.weapons.filter(w=>w).length > G.tower.weaponSlots) G.weapons.pop(); } if (effect.shield) { G.tower.shield = null; G.tower.shieldHp = 0; G.tower.shieldMaxHp = 0; G.shieldUpgradesBought = []; } } function initShield(type) { if (type === 'dome') { G.tower.shieldMaxHp = 10; G.tower.shieldHp = 10; G.tower.shieldRechargeDelay = 300; G.tower.shieldRechargeTimer = 0; G.tower.shieldRechargeRate = 1; G.tower.shieldReflect = 0; } else if (type === 'directional') { G.tower.shieldMaxHp = 35; G.tower.shieldHp = 35; G.tower.shieldArcWidth = Math.PI * 0.6; G.tower.shieldTrackSpeed = 0.06; G.tower.shieldAngle = 0; G.tower.shieldAbsorption = 1.0; G.tower.shieldRechargeDelay = 240; G.tower.shieldRechargeTimer = 0; } } function buyWeapon(defId) { const def = WEAPON_DEFS.find(w => w.id === defId); if (!def) return; if (!canBuyWeaponType(defId)) { addLog(`${def.name} limit reached (${MAX_WEAPONS_PER_TYPE}/${MAX_WEAPONS_PER_TYPE}).`, 'lose'); return; } if (spendableCredits() < def.cost) return; G.credits -= def.cost; const instance = makeWeaponInstance(defId); G.weaponUpgradesBought[instance.instanceId] = []; sfx_buy(); const equippedCount = getEquippedWeapons().length; if (equippedCount < G.tower.weaponSlots) { let placedAt = -1; for (let i = 0; i < G.tower.weaponSlots; i++) { if (!G.weapons[i]) { G.weapons[i] = instance; placedAt = i; break; } } if (placedAt >= 0) addLog('Purchased ' + def.name + ' — equipped in slot ' + (placedAt + 1) + '!', 'win'); else addLog('Purchased ' + def.name + ' — equipped!', 'win'); } else { // All slots full — add to inventory G.weaponInventory = G.weaponInventory || []; G.weaponInventory.push(instance); addLog('Purchased ' + def.name + ' — stored in inventory. Open inventory.', 'win'); } updateHUD(); renderShop(); } function buyWeaponUpgrade(instanceId, upgradeId) { const weapon = findWeaponInstance(instanceId); if (!weapon) return; const tree = WEAPON_UPGRADE_TREES[weapon.defId]; if (!tree) return; const upgrade = tree.find(u => u.id === upgradeId); if (!upgrade) return; const bought = G.weaponUpgradesBought[instanceId] || []; if (bought.includes(upgradeId)) return; if ((upgrade.minTier ?? 0) > (G.difficultyTier || 0)) return; for (const req of upgrade.requires) { if (!bought.includes(req)) return; } if (spendableCredits() < upgrade.cost) return; G.credits -= upgrade.cost; bought.push(upgradeId); G.weaponUpgradesBought[instanceId] = bought; applyWeaponUpgrade(weapon, upgrade.effect); sfx_buy(); addLog(getWeaponDef(weapon).name + ': ' + upgrade.label, 'info'); updateHUD(); renderShop(); } function refundWeaponUpgrade(instanceId, upgradeId) { const weapon = findWeaponInstance(instanceId); if (!weapon) return; const tree = WEAPON_UPGRADE_TREES[weapon.defId] || []; const bought = G.weaponUpgradesBought[instanceId] || []; if (!bought.includes(upgradeId)) return; const toRefund = [upgradeId, ...getWeaponDependents(weapon.defId, upgradeId)] .filter(id => bought.includes(id)); let total = 0; for (const id of toRefund) { const u = tree.find(x => x.id === id); if (!u) continue; total += u.cost; reverseWeaponUpgrade(weapon, u.effect); G.weaponUpgradesBought[instanceId] = G.weaponUpgradesBought[instanceId].filter(x => x !== id); } G.credits += total; sfx_refund(); addLog('Refunded ' + toRefund.length + ' upgrade(s) — +' + total + 'c', 'info'); updateHUD(); renderShop(); } function applyWeaponUpgrade(weapon, effect) { if (effect.damage) weapon.damage += effect.damage; if (effect.fireRate) weapon.fireRate = Math.max(4, weapon.fireRate + effect.fireRate); if (effect.projectileSpeed) weapon.projectileSpeed += effect.projectileSpeed; if (effect.pierce) weapon.pierce = (weapon.pierce||0) + effect.pierce; if (effect.critChance) weapon.critChance = (weapon.critChance||0) + effect.critChance; if (effect.range) weapon.range += effect.range; if (effect.coneAngle) weapon.coneAngle += effect.coneAngle; if (effect.dotDuration) weapon.dotDuration = (weapon.dotDuration||0) + effect.dotDuration; if (effect.chains) weapon.chains = (weapon.chains||0) + effect.chains; if (effect.chainRange) weapon.chainRange = (weapon.chainRange||0) + effect.chainRange; if (effect.aoeRadius) weapon.aoeRadius = (weapon.aoeRadius||0) + effect.aoeRadius; if (effect.freezeDuration) weapon.freezeDuration = (weapon.freezeDuration||0) + effect.freezeDuration; if (effect.armorShred) weapon.armorShred = (weapon.armorShred||0) + effect.armorShred; if (effect.targets) weapon.targets = (weapon.targets||1) + effect.targets; if (effect.amplify) weapon.amplify = (weapon.amplify||0) + effect.amplify; if (effect.dotDamage) weapon.dotDamage = (weapon.dotDamage||0) + effect.dotDamage; if (effect.projectileRadius) weapon.projectileRadius = (weapon.projectileRadius||4) + effect.projectileRadius; if (effect.canInfuse) weapon.canInfuse = true; if (effect.canInfuse2) weapon.canInfuse2 = true; if (effect.canInfuse3) weapon.canInfuse3 = true; } function reverseWeaponUpgrade(weapon, effect) { if (effect.damage) weapon.damage = Math.max(1, weapon.damage - effect.damage); if (effect.fireRate) weapon.fireRate = Math.min(600, weapon.fireRate - effect.fireRate); if (effect.projectileSpeed) weapon.projectileSpeed = Math.max(1, weapon.projectileSpeed - effect.projectileSpeed); if (effect.pierce) weapon.pierce = Math.max(0, (weapon.pierce||0) - effect.pierce); if (effect.critChance) weapon.critChance = Math.max(0, (weapon.critChance||0) - effect.critChance); if (effect.range) weapon.range = Math.max(80, weapon.range - effect.range); if (effect.coneAngle) weapon.coneAngle = Math.max(0.1, weapon.coneAngle - effect.coneAngle); if (effect.dotDuration) weapon.dotDuration = Math.max(0, (weapon.dotDuration||0) - effect.dotDuration); if (effect.chains) weapon.chains = Math.max(0, (weapon.chains||0) - effect.chains); if (effect.chainRange) weapon.chainRange = Math.max(40, (weapon.chainRange||0) - effect.chainRange); if (effect.aoeRadius) weapon.aoeRadius = Math.max(10, (weapon.aoeRadius||0) - effect.aoeRadius); if (effect.freezeDuration) weapon.freezeDuration = Math.max(0, (weapon.freezeDuration||0) - effect.freezeDuration); if (effect.armorShred) weapon.armorShred = Math.max(0, (weapon.armorShred||0) - effect.armorShred); if (effect.targets) weapon.targets = Math.max(1, (weapon.targets||1) - effect.targets); if (effect.amplify) weapon.amplify = Math.max(0, (weapon.amplify||0) - effect.amplify); if (effect.dotDamage) weapon.dotDamage = Math.max(0, (weapon.dotDamage||0) - effect.dotDamage); if (effect.projectileRadius) weapon.projectileRadius = Math.max(2, (weapon.projectileRadius||4) - effect.projectileRadius); if (effect.canInfuse) { weapon.canInfuse = false; weapon.elements[0] = null; weapon.elements = weapon.elements.filter(Boolean); } if (effect.canInfuse2) { weapon.canInfuse2 = false; weapon.elements[1] = null; weapon.elements = weapon.elements.filter(Boolean); } if (effect.canInfuse3) { weapon.canInfuse3 = false; weapon.elements[2] = null; weapon.elements = weapon.elements.filter(Boolean); } } function buyShieldUpgrade(upgradeId) { const type = G.tower.shield; if (!type) return; const tree = SHIELD_UPGRADE_TREES[type]; if (!tree) return; const upgrade = tree.find(u => u.id === upgradeId); if (!upgrade) return; const bought = G.shieldUpgradesBought || []; if (bought.includes(upgradeId)) return; for (const req of upgrade.requires) { if (!bought.includes(req)) return; } if (spendableCredits() < upgrade.cost) return; G.credits -= upgrade.cost; bought.push(upgradeId); G.shieldUpgradesBought = bought; applyShieldUpgrade(upgrade.effect); sfx_buy(); addLog('Shield: ' + upgrade.label, 'info'); updateHUD(); renderShop(); } function refundShieldUpgrade(upgradeId) { const type = G.tower.shield; if (!type) return; const tree = SHIELD_UPGRADE_TREES[type] || []; const bought = G.shieldUpgradesBought || []; if (!bought.includes(upgradeId)) return; const toRefund = [upgradeId, ...getShieldDependents(upgradeId)] .filter(id => bought.includes(id)); let total = 0; for (const id of toRefund) { const u = tree.find(x => x.id === id); if (!u) continue; total += u.cost; reverseShieldUpgrade(u.effect); G.shieldUpgradesBought = G.shieldUpgradesBought.filter(x => x !== id); } G.credits += total; sfx_refund(); addLog('Shield refund: +' + total + 'c', 'info'); updateHUD(); renderShop(); } function applyShieldUpgrade(effect) { if (effect.capacity) { G.tower.shieldMaxHp += effect.capacity; G.tower.shieldHp += effect.capacity; } if (effect.rechargeDelay) G.tower.shieldRechargeDelay = Math.max(60, (G.tower.shieldRechargeDelay||300) + effect.rechargeDelay); if (effect.reflect) G.tower.shieldReflect = (G.tower.shieldReflect||0) + effect.reflect; if (effect.arcWidth) G.tower.shieldArcWidth += effect.arcWidth; if (effect.trackSpeed) G.tower.shieldTrackSpeed += effect.trackSpeed; if (effect.absorption) G.tower.shieldAbsorption = (G.tower.shieldAbsorption||1.0) + effect.absorption; } function reverseShieldUpgrade(effect) { if (effect.capacity) { G.tower.shieldMaxHp = Math.max(1, G.tower.shieldMaxHp - effect.capacity); G.tower.shieldHp = Math.min(G.tower.shieldHp, G.tower.shieldMaxHp); } if (effect.rechargeDelay) G.tower.shieldRechargeDelay = Math.min(600, (G.tower.shieldRechargeDelay||300) - effect.rechargeDelay); if (effect.reflect) G.tower.shieldReflect = Math.max(0, (G.tower.shieldReflect||0) - effect.reflect); if (effect.arcWidth) G.tower.shieldArcWidth = Math.max(0.3, G.tower.shieldArcWidth - effect.arcWidth); if (effect.trackSpeed) G.tower.shieldTrackSpeed = Math.max(0.01, G.tower.shieldTrackSpeed - effect.trackSpeed); if (effect.absorption) G.tower.shieldAbsorption = Math.max(1.0, (G.tower.shieldAbsorption||1.0) - effect.absorption); } function setWeaponInfusion(instanceId, slotIndex, element) { const weapon = findWeaponInstance(instanceId); if (!weapon) return; if (slotIndex === 0 && !weapon.canInfuse) return; if (slotIndex === 1 && !weapon.canInfuse2) return; if (slotIndex === 2 && !weapon.canInfuse3) return; weapon.elements[slotIndex] = element; renderShop(); } function setWeaponTargeting(instanceId, targeting) { const weapon = findWeaponInstance(instanceId); if (!weapon) return; weapon.targeting = targeting; } function updateShield() { if (!G.tower.shield) return; if (G.tower.shield === 'directional') { const nearest = []; const cx = canvas.width/2, cy = canvas.height/2; for (const e of G.enemies) { if (!e.alive || e.spawnImmunity > 0) continue; const d = distSq(e.x, e.y, cx, cy); let insertAt = nearest.length; while (insertAt > 0 && nearest[insertAt - 1].d > d) insertAt--; if (insertAt >= 3) continue; nearest.splice(insertAt, 0, { e, d }); if (nearest.length > 3) nearest.length = 3; } if (nearest.length > 0) { let avgX = 0; let avgY = 0; for (const item of nearest) { avgX += item.e.x; avgY += item.e.y; } avgX /= nearest.length; avgY /= nearest.length; const desired = Math.atan2(avgY-canvas.height/2, avgX-canvas.width/2); const delta = normalizeAngle(desired - G.tower.shieldAngle); G.tower.shieldAngle += Math.sign(delta)*Math.min(Math.abs(delta), G.tower.shieldTrackSpeed||0.06); } } if (G.tower.shieldHp < G.tower.shieldMaxHp) { G.tower.shieldRechargeTimer = (G.tower.shieldRechargeTimer||0) + 1; if (G.tower.shieldRechargeTimer >= (G.tower.shieldRechargeDelay||300)) { G.tower.shieldHp = Math.min(G.tower.shieldMaxHp, G.tower.shieldHp + 1); } } else { G.tower.shieldRechargeTimer = 0; } }