626879ed0c
- Add enemy freshness tracking (novelty bonus for repeated deploys) - Add freshness bar to sidepanel enemy cards with penalty indicator - Major overhaul of renderer-overlays.js (790+ lines for UI polish) - Enhanced combat log, shop overlays, and inventory UI - Improved weapon/upgrade display with partial ownership colors - Added element icons and weakness/resistance indicators to cards - Enhanced radial menu and tooltip system - Add "stale/%" penalty text when freshness depleted - Update play link to ffazeshift.net in index.html
276 lines
8.5 KiB
JavaScript
276 lines
8.5 KiB
JavaScript
// ═══ 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;
|
|
}
|
|
}
|
|
}
|
|
}
|