// ═══ elements.js ═══ // ============================================================ // ELEMENTS.JS — Elemental damage, debuffs, DoT // ============================================================ // Apply elemental hit effects to an enemy function applyElementalEffects(enemy, elements, rawDamage) { let totalDamage = rawDamage; const results = { damage: 0, critted: false, effects: [] }; for (const el of elements) { if (!el) continue; const res = enemy.resistances?.[el] ?? 1.0; const weak = enemy.weaknesses?.[el] ?? 1.0; const mult = res * weak; switch (el) { case 'fire': applyDoT(enemy, 'burn', 'fire', 1, 30, 180, '#ff6b35'); results.effects.push('burn'); break; case 'ice': applySlow(enemy, 0.5, 120); results.effects.push('slow'); break; case 'lightning': chainLightningProc(enemy, totalDamage * 0.5, 100); results.effects.push('chain'); break; case 'poison': applyDoT(enemy, 'poison', 'poison', 1, 20, 240, '#7fff4f'); results.effects.push('poison'); break; case 'void': enemy.armor = Math.max(0, (enemy.armor || 0) - 1); results.effects.push('shred'); break; case 'arcane': enemy.amplified = (enemy.amplified || 0) + 0.25; results.effects.push('amplify'); break; } totalDamage *= mult; } results.damage = totalDamage; return results; } function applyDoT(enemy, type, element, dmg, interval, duration, color) { if (!enemy.dots) enemy.dots = []; // Refresh or add const existing = enemy.dots.find(d => d.type === type); if (existing) { existing.remaining = Math.max(existing.remaining, duration); existing.dmg = Math.max(existing.dmg, dmg); existing.element = element; } else { enemy.dots.push({ type, element, dmg, interval, remaining: duration, tick: 0, color }); } } function applySlow(enemy, factor, duration) { if (!enemy.slow || enemy.slow.factor > factor) { enemy.slow = { factor, remaining: duration }; } else { enemy.slow.remaining = Math.max(enemy.slow.remaining, duration); } } function applyFreeze(enemy, duration) { // Diminishing returns: each successive freeze is shorter // 60-frame immunity window after thawing if (enemy._freezeImmune > 0) return; if (enemy.immuneToFreeze) return; const diminish = enemy._freezeCount ? Math.pow(0.75, enemy._freezeCount) : 1.0; const actual = Math.max(30, Math.round(duration * diminish)); enemy.frozen = actual; enemy._freezeCount = (enemy._freezeCount || 0) + 1; if (!enemy.slow) enemy.slow = { factor: 0, remaining: actual }; enemy.slow.factor = 0; enemy.slow.remaining = actual; } // Chain lightning proc — bounces to nearby enemies function chainLightningProc(sourceEnemy, damage, range) { if (!G || !G.enemies) return; const rangeSq = range * range; let seen = 0; let target = null; for (const e of G.enemies) { if (!e.alive || e.id === sourceEnemy.id) continue; if (distSq(e.x, e.y, sourceEnemy.x, sourceEnemy.y) >= rangeSq) continue; seen++; if (Math.random() < 1 / seen) target = e; } if (!target) return; dealDamage(target, damage, ['lightning'], false); // Spark visual spawnChainArc(sourceEnemy.x, sourceEnemy.y, target.x, target.y, '#ffe033'); } // Core damage dealer — handles armor, resist, amplify, crits function dealDamage(enemy, rawDamage, elements = ['physical'], canCrit = false, critChance = 0, armorShred = 0) { if (!enemy.alive) return 0; // Armor shred if (armorShred > 0) { enemy.armor = Math.max(0, (enemy.armor || 0) - armorShred); } // Base damage after armor let dmg = Math.max(1, rawDamage - (enemy.armor || 0)); // Elemental multipliers let elemMult = 1; for (const el of elements) { if (!el || el === 'physical') continue; const res = enemy.resistances?.[el] ?? 1.0; const weak = enemy.weaknesses?.[el] ?? 1.0; elemMult *= res * weak; } dmg *= elemMult; // Amplify debuff if (enemy.amplified) dmg *= (1 + enemy.amplified); // Crit let critted = false; if (canCrit && Math.random() < critChance) { dmg *= 2; critted = true; } dmg = Math.round(dmg); // ── Special enemy modifiers ─────────────────────────────── // Commander aura: -30% incoming damage if (enemy.commanderAuraDR > 0) { dmg = Math.max(1, Math.round(dmg * (1 - enemy.commanderAuraDR))); } // Juggernaut damage cap (12 per hit; lightning doubles cap) if (enemy.defId === 'juggernaut') { const baseCap = ENEMY_DEFS.find(d => d.id === 'juggernaut')?.damageCapPerHit ?? 12; const cap = elements.includes('lightning') ? baseCap * 2 : baseCap; dmg = Math.min(dmg, cap); } // Void Herald shield: absorbs 60% damage when 3+ weapons hit in same frame if (enemy.defId === 'voidherald') { enemy._voidHitsThisFrame = (enemy._voidHitsThisFrame || 0) + 1; if (enemy._voidHitsThisFrame >= 3) { dmg = Math.max(1, Math.round(dmg * 0.4)); } } // Echo split: on burst hit >30 raw damage, queue a split (once per echo) if (enemy.defId === 'echo' && !enemy._echoSplit && rawDamage > 30) { enemy._echoSplit = true; G._pendingEchoSpawns = G._pendingEchoSpawns || []; G._pendingEchoSpawns.push({ x: enemy.x, y: enemy.y, copyHp: Math.round(enemy.maxHp * 0.4) }); } enemy.hp -= dmg; enemy.hitFlash = 6; // Show damage number for crits / elemental const showNumber = critted || (elements.some(e => e && e !== 'physical')); if (showNumber && dmg > 0) { const color = critted ? '#ffffff' : (ELEMENTS[elements[0]]?.color || '#fff'); const label = critted ? `${dmg}!` : `${dmg}`; spawnFloater(enemy.x, enemy.y - enemy.radius - 4, label, color, critted ? 1.4 : 1.0); } // Apply elemental side effects for (const el of elements) { if (!el || el === 'physical') continue; applyElementalEffect(enemy, el); } return dmg; } function applyElementalEffect(enemy, el) { switch (el) { case 'fire': applyDoT(enemy, 'burn', 'fire', 1, 30, 180, '#ff6b35'); break; case 'ice': applySlow(enemy, 0.5, 120); break; case 'poison': applyDoT(enemy, 'poison', 'poison', 1, 20, 240, '#7fff4f'); break; case 'void': enemy.armor = Math.max(0, (enemy.armor || 0) - 1); if (enemy.defId === 'siegebreaker') { enemy.regenPausedUntil = G.frame + 180; // pause regen 3 seconds } break; case 'arcane': enemy.amplified = Math.min(1.5, (enemy.amplified || 0) + 0.1); break; case 'lightning': // chain handled separately in weapon logic break; } } // Tick DoTs and debuffs on an enemy each frame function tickEnemyStatus(enemy) { // DoTs if (enemy.dots) { for (const dot of enemy.dots) { dot.tick++; if (dot.tick >= dot.interval) { dot.tick = 0; const res = enemy.resistances?.[dot.element] ?? 1.0; const weak = enemy.weaknesses?.[dot.element] ?? 1.0; const dmg = Math.max(0, Math.round(dot.dmg * res * weak)); if (dmg > 0) { enemy.hp -= dmg; enemy.hitFlash = 3; // Small particle tick spawnParticle(enemy.x + (Math.random()-0.5)*8, enemy.y + (Math.random()-0.5)*8, dot.color, 0, 0, 0.6, 3, 0.05); } } dot.remaining--; } let write = 0; for (let i = 0; i < enemy.dots.length; i++) { const dot = enemy.dots[i]; if (dot.remaining > 0) enemy.dots[write++] = dot; } enemy.dots.length = write; } // Slow / freeze decay if (enemy.frozen > 0) { enemy.frozen--; if (enemy.frozen === 0) { if (enemy.slow) enemy.slow = null; enemy._freezeImmune = 60; // 1 second immunity after thaw } } if (enemy._freezeImmune > 0) enemy._freezeImmune--; if (enemy.slow) { enemy.slow.remaining--; if (enemy.slow.remaining <= 0) enemy.slow = null; } // Hit flash decay if (enemy.hitFlash > 0) enemy.hitFlash--; // Amplify decay if (enemy.amplified) { enemy.amplified *= 0.995; if (enemy.amplified < 0.01) enemy.amplified = 0; } // Siege Breaker HP regen: +5 HP/sec; void damage pauses for 3s if (enemy.defId === 'siegebreaker' && enemy.hp > 0) { if (!enemy.regenPausedUntil || G.frame >= enemy.regenPausedUntil) { enemy._regenAcc = (enemy._regenAcc || 0) + 5 / 60; if (enemy._regenAcc >= 1) { const regen = Math.floor(enemy._regenAcc); enemy.hp = Math.min(enemy.maxHp, enemy.hp + regen); enemy._regenAcc -= regen; } } } }