// ═══ enemies.js ═══ // ============================================================ // ENEMIES.JS — Portal system, enemy spawning, movement // ============================================================ // ── ENEMY SPAWNING ──────────────────────────────────────────── function spawnEnemy(def, x, y, rewardOverride = null, offsetAngle = null, costOverride = null, breachRiskMult = 1, extraProps = null) { // For swarm units, offset position slightly around the portal let spawnX = x, spawnY = y; if (offsetAngle !== null) { const spread = def.radius * 3.5; spawnX = x + Math.cos(offsetAngle) * spread; spawnY = y + Math.sin(offsetAngle) * spread; } // Apply tier multipliers — skip for echo copies which carry pre-scaled HP const tierDef = DIFFICULTY_TIERS[G.difficultyTier || 0]; const skipScale = extraProps?._echoSplit; const scaledHp = skipScale ? def.hp : Math.round(def.hp * (tierDef?.hpMult ?? 1)); const scaledSpeed = skipScale ? def.speed : def.speed * (tierDef?.speedMult ?? 1); const scaledArmor = skipScale ? (def.armor ?? 0) : Math.round((def.armor ?? 0) * (tierDef?.armorMult ?? 1)); const instance = { id: uid(), defId: def.id, name: def.name, x: spawnX, y: spawnY, hp: scaledHp, maxHp: scaledHp, speed: scaledSpeed, baseSpeed: scaledSpeed, radius: def.radius, armor: scaledArmor, baseArmor: scaledArmor, evasion: def.evasion ?? 0, armorPen: def.armorPen ?? 0, color: def.color, glowColor: def.glowColor, resistances: { ...(def.resistances || {}) }, weaknesses: { ...(def.weaknesses || {}) }, element: def.element, reward: rewardOverride ?? def.reward, cost: costOverride ?? def.cost, breachRiskMult: breachRiskMult ?? 1, alive: true, reachedTower: false, dots: [], slow: null, frozen: 0, hitFlash: 0, amplified: 0, trail: [], spawnImmunity: 60, // opening grace window: tower can acquire target before advance angle: 0, vx: 0, vy: 0, }; if (extraProps) Object.assign(instance, extraProps); G.enemies.push(instance); } // ── DEPLOY (player action) ──────────────────────────────────── function deployEnemy(defId, quantity = 1) { const def = ENEMY_DEFS.find(e => e.id === defId); if (!def) return; // Tier and prestige gate if ((def.minTier ?? 0) > (G.difficultyTier || 0)) return; if ((def.minPrestige ?? 0) > (G.prestigeLevel || 0)) return; const totalCost = def.cost * quantity; if (G.credits < totalCost) return; if (G.gameOver) return; G.credits -= totalCost; // Bonus reward multiplier and breach risk for sending multiples const bonusMult = quantity >= 50 ? 1.3 : quantity >= 25 ? 1.2 : quantity >= 10 ? 1.12 : quantity >= 5 ? 1.05 : 1.0; const tierDef = DIFFICULTY_TIERS[G.difficultyTier || 0]; const tierRewardMult = tierDef?.rewardMult ?? 1; const rewardPerUnit = Math.round(def.reward * bonusMult * tierRewardMult); const breachRiskMult = quantity >= 50 ? 2.2 : quantity >= 25 ? 1.65 : quantity >= 10 ? 1.4 : quantity >= 5 ? 1.2 : 1.0; // Freshness tracking — increment before deploy so bar reflects cost immediately G.enemyFreshness[defId] = (G.enemyFreshness[defId] || 0) + quantity; // First-deploy tip for Void Herald if (def.id === 'voidherald' && !G._voidHeraldTipShown) { G._voidHeraldTipShown = true; addLog('VOID HERALD: Deploy in a crowd — shield activates only when 3+ weapons target it at once.', 'info'); } if (def.id === 'swarm') { // Each swarm card = one burst portal. Split rewards exactly across minis. const swarmUnitCost = def.cost / def.count; for (let i = 0; i < quantity; i++) { const rewardByMini = splitInteger(rewardPerUnit, def.count); openPortal(def, def.count, null, true, swarmUnitCost, breachRiskMult, rewardByMini); } } else { // one portal per enemy — each gets a unique random angle with collision avoidance for (let i = 0; i < quantity; i++) { openPortal(def, 1, rewardPerUnit, false, def.cost, breachRiskMult); } } const plural = quantity > 1 ? ` ×${quantity}` : ''; const bonusStr = bonusMult > 1 ? ` (+${Math.round((bonusMult-1)*100)}% reward!)` : ''; const riskStr = breachRiskMult > 1 ? ` [risk x${breachRiskMult.toFixed(2)}]` : ''; const tierStr = tierRewardMult > 1 ? ` [${tierDef.name}: ×${tierRewardMult.toFixed(1)}]` : ''; addLog(`Deployed ${def.name}${plural} — ${totalCost}¢${bonusStr}${tierStr}${riskStr}`, 'info'); updateHUD(); } function getEnemyCurrentSpeed(enemy) { if (enemy.frozen > 0) return 0; return enemy.slow ? enemy.speed * enemy.slow.factor : enemy.speed; } function getEnemyPathRemaining(enemy, cx, cy) { let rem = 0; let px = enemy.x; let py = enemy.y; const path = Array.isArray(enemy.path) ? enemy.path : null; let idx = Math.max(0, enemy.pathIndex || 0); if (path) { while (idx < path.length) { const node = path[idx]; rem += Math.hypot(node.x - px, node.y - py); px = node.x; py = node.y; idx++; } } rem += Math.hypot(cx - px, cy - py); return rem; } function moveEnemyAlongPath(enemy, step, cx, cy) { let remaining = step; let safety = 0; const path = Array.isArray(enemy.path) ? enemy.path : null; while (remaining > 0 && safety++ < 8) { const idx = Math.max(0, enemy.pathIndex || 0); const node = (path && idx < path.length) ? path[idx] : { x: cx, y: cy }; const dx = node.x - enemy.x; const dy = node.y - enemy.y; const dist = Math.hypot(dx, dy); if (dist < 0.0001) { if (path && idx < path.length) { enemy.pathIndex = idx + 1; continue; } break; } const travel = Math.min(remaining, dist); enemy.x += (dx / dist) * travel; enemy.y += (dy / dist) * travel; enemy.angle = Math.atan2(dy, dx); remaining -= travel; if (travel >= dist - 0.0001 && path && idx < path.length) { enemy.pathIndex = idx + 1; } else { break; } } } function predictEnemyPositionAlongPath(enemy, framesAhead, cx = ARENA_CX, cy = ARENA_CY) { let x = enemy.x; let y = enemy.y; let remainingFrames = Math.max(0, framesAhead); const path = Array.isArray(enemy.path) ? enemy.path : null; let idx = Math.max(0, enemy.pathIndex || 0); if (enemy.spawnImmunity > 0) { const wait = Math.min(remainingFrames, enemy.spawnImmunity); remainingFrames -= wait; if (remainingFrames <= 0) return { x, y }; } const speed = getEnemyCurrentSpeed(enemy); if (speed <= 0 || remainingFrames <= 0) return { x, y }; let safety = 0; while (remainingFrames > 0 && safety++ < 64) { const node = (path && idx < path.length) ? path[idx] : { x: cx, y: cy }; const dx = node.x - x; const dy = node.y - y; const dist = Math.hypot(dx, dy); if (dist < 0.0001) { if (path && idx < path.length) { idx++; continue; } break; } const travel = speed * remainingFrames; if (travel >= dist) { const dt = dist / speed; x = node.x; y = node.y; remainingFrames -= dt; if (path && idx < path.length) idx++; } else { const ratio = travel / dist; x += dx * ratio; y += dy * ratio; remainingFrames = 0; } } return { x, y }; } // ── ENEMY UPDATE ────────────────────────────────────────────── function updateEnemies() { const cx = ARENA_CX; const cy = ARENA_CY; // Reset per-frame flags for special enemies for (const e of G.enemies) { if (!e.alive) continue; e.commanderAuraDR = 0; if (e.defId === 'voidherald') e._voidHitsThisFrame = 0; } // Commander aura: all alive enemies within 80px gain +30% DR (including self) const auraRadSq = 80 * 80; for (const cmd of G.enemies) { if (!cmd.alive || cmd.defId !== 'commander') continue; for (const t of G.enemies) { if (t.alive && distSq(cmd.x, cmd.y, t.x, t.y) <= auraRadSq) { t.commanderAuraDR = 0.3; } } } for (const e of G.enemies) { if (!e.alive) continue; const prevX = e.x; const prevY = e.y; // Spawn immunity countdown if (e.spawnImmunity > 0) { e.spawnImmunity--; e.vx = 0; e.vy = 0; continue; } // Tick status effects tickEnemyStatus(e); if (e.hp <= 0) { killEnemy(e, true); continue; } // Movement const spd = getEnemyCurrentSpeed(e); if (spd > 0) { moveEnemyAlongPath(e, spd, cx, cy); e.vx = e.x - prevX; e.vy = e.y - prevY; const breachRadius = 28 + e.radius; if (distSq(cx, cy, e.x, e.y) < breachRadius * breachRadius) { e.vx = 0; e.vy = 0; breachTower(e); } } else { e.vx = 0; e.vy = 0; } e.pathRemaining = getEnemyPathRemaining(e, cx, cy); // Trail e.trail.push({ x: e.x, y: e.y }); if (e.trail.length > 10) e.trail.shift(); } compactLiveArray(G.enemies, e => e.alive); // Process Echo split queue — spawn 2 copies per pending split if (G._pendingEchoSpawns?.length) { const echoDef = ENEMY_DEFS.find(d => d.id === 'echo'); const tierDef = DIFFICULTY_TIERS[G.difficultyTier || 0]; const tierRewardMult = tierDef?.rewardMult ?? 1; for (const spawn of G._pendingEchoSpawns) { for (let i = 0; i < 2; i++) { const copyReward = Math.round((echoDef.echoReward ?? 120) * tierRewardMult); spawnEnemy( { ...echoDef, hp: spawn.copyHp }, spawn.x, spawn.y, copyReward, Math.random() * Math.PI * 2, echoDef.cost, 1, { _echoSplit: true } ); } } G._pendingEchoSpawns = []; } } function killEnemy(enemy, giveReward) { enemy.alive = false; if (giveReward) { // Freshness bonus — reward multiplier based on how recently this type was sent const freshness = G.enemyFreshness[enemy.defId] || 0; const freshMult = Math.max(1.0, 1.35 - freshness * 0.02); const baseReward = Math.round(enemy.reward); const bonusReward = freshMult > 1.01 ? Math.round(baseReward * freshMult) - baseReward : 0; const totalReward = baseReward + bonusReward; // Kill streak bonus const framesSinceLast = G.frame - G.streak.lastKillFrame; if (framesSinceLast <= 180) { G.streak.count++; } else { G.streak.count = 1; } G.streak.lastKillFrame = G.frame; const streakBonus = Math.min(10, Math.floor(G.streak.count / 3) * 2); G.credits += totalReward + streakBonus; G.score += totalReward + streakBonus; G.totalKills++; sfx_enemy_die(); spawnParticleBurst(enemy.x, enemy.y, enemy.color, 14); // Show reward floater — include freshness bonus if active if (bonusReward > 0) { spawnFloater(enemy.x, enemy.y - enemy.radius - 10, `+${totalReward}¢`, '#00d4ff', 1.1); } else { spawnFloater(enemy.x, enemy.y - enemy.radius - 10, `+${totalReward}¢`, '#ffd700', 1.1); } if (streakBonus > 0) { spawnFloater(enemy.x, enemy.y - enemy.radius - 24, `+${streakBonus}¢ streak`, '#ffd700', 0.9); } addLog(`${enemy.name} destroyed! +${totalReward}¢${streakBonus > 0 ? ` +${streakBonus}¢ streak` : ''}`, 'win'); updateHUD(); } } function breachTower(enemy) { enemy.alive = false; enemy.reachedTower = true; const baseDamage = Math.max(1, Math.ceil(enemy.maxHp * 0.17 + 2) - G.tower.armor); const threatMult = 1 + Math.min(0.9, (enemy.cost || 0) / 450) + (enemy.speed >= 2 ? 0.1 : 0); const riskMult = 1 + Math.max(0, (enemy.breachRiskMult || 1) - 1) * 0.7; const dmg = Math.max(1, Math.round(baseDamage * threatMult * riskMult)); const creditLoss = Math.max(1, Math.round((enemy.cost || 0) * ( 1 + (enemy.speed * 0.12) + (Math.max(0, (enemy.breachRiskMult || 1) - 1) * 0.8) ))); // Shield absorbs first let remaining = dmg; const cx = ARENA_CX; const cy = ARENA_CY; const directionalCanBlock = (() => { if (G.tower.shield !== 'directional') return true; const arc = G.tower.shieldArcWidth ?? (Math.PI * 0.6); const shieldAngle = G.tower.shieldAngle ?? 0; const enemyAngle = Math.atan2(enemy.y - cy, enemy.x - cx); let delta = enemyAngle - shieldAngle; while (delta > Math.PI) delta -= Math.PI * 2; while (delta < -Math.PI) delta += Math.PI * 2; return Math.abs(delta) <= arc / 2; })(); const shieldBlocked = G.tower.shieldHp > 0 && directionalCanBlock; if (shieldBlocked) { const absorbRate = 1.0 + (G.tower.shieldAbsorption ? G.tower.shieldAbsorption - 1.0 : 0); const shieldBlock = Math.min(G.tower.shieldHp, remaining); const absorbed = Math.ceil(shieldBlock * absorbRate); G.tower.shieldHp -= shieldBlock; remaining -= absorbed; remaining = Math.max(0, remaining); // Dome shield reflect upgrade: blast nearby enemies when blocking. if ((G.tower.shieldReflect || 0) > 0) { const reflectDamage = Math.max(1, Math.round(dmg * G.tower.shieldReflect)); const radiusSq = 140 * 140; for (const target of G.enemies) { if (!target.alive || target.id === enemy.id || target.spawnImmunity > 0) continue; if (distSq(target.x, target.y, cx, cy) >= radiusSq) continue; dealDamage(target, reflectDamage, ['arcane'], false); if (target.hp <= 0) killEnemy(target, true); } } } if (remaining > 0) { sfx_tower_damage(); G.tower.hp = Math.max(0, G.tower.hp - remaining); spawnParticleBurst(cx, cy, '#ff3355', 18); spawnFloater(cx, cy - 40, `-${remaining} HP`, '#ff3355', 1.2); } G.credits = Math.max(0, G.credits - creditLoss); addLog(`${enemy.name} breached! Tower -${remaining} HP. Lost ${creditLoss}¢`, 'lose'); updateHUD(); if (G.tower.hp <= 0) endGame(); } // Pick targeting for a weapon — only considers enemies within tower vision range function pickTarget(weapon) { const cx = ARENA_CX, cy = ARENA_CY; const _activeWeapons = (G.weapons || []).filter(w => w); const towerRange = _activeWeapons.length > 0 ? Math.max(..._activeWeapons.map(w => w.range ?? 0)) : 9999; const towerRangeSq = towerRange * towerRange; let targeting = weapon.targeting || 'nearest'; switch (targeting) { case 'nearest': case 'strongest': case 'weakest': case 'fastest': case 'furthest': case 'group': break; default: targeting = 'nearest'; } let best = null; let bestValue = targeting === 'strongest' || targeting === 'fastest' || targeting === 'furthest' || targeting === 'group' ? -Infinity : Infinity; let bestRemaining = Infinity; const remaining = (e) => (typeof e.pathRemaining === 'number' ? e.pathRemaining : getEnemyPathRemaining(e, cx, cy)); const currentSpeed = (e) => getEnemyCurrentSpeed(e); // ponytail: O(n²) group scan, swap to a spatial grid only if enemy counts prove it matters. const groupScore = (e) => { const r = weapon.aoeRadius || (weapon.chainRange ? weapon.chainRange * 0.5 : 80); const rSq = r * r; let count = 0; for (const other of G.enemies) { if (!other.alive || other.spawnImmunity > 0) continue; if (distSq(other.x, other.y, cx, cy) > towerRangeSq) continue; if (distSq(other.x, other.y, e.x, e.y) <= rSq) count++; } return count; }; for (const e of G.enemies) { if (!e.alive || e.spawnImmunity > 0) continue; if (distSq(e.x, e.y, cx, cy) > towerRangeSq) continue; let value; if (targeting === 'strongest') value = e.hp; else if (targeting === 'weakest') value = e.hp; else if (targeting === 'fastest') value = currentSpeed(e); else if (targeting === 'furthest') value = remaining(e); else if (targeting === 'group') value = groupScore(e); else value = remaining(e); if (targeting === 'weakest' || targeting === 'nearest') { if (value < bestValue) { best = e; bestValue = value; } } else if (targeting === 'group') { const rem = remaining(e); if (value > bestValue || (value === bestValue && rem < bestRemaining)) { best = e; bestValue = value; bestRemaining = rem; } } else if (value > bestValue) { best = e; bestValue = value; } } return best; }