Files
siege-protocol/js/elements.js
T
44r0n7 626879ed0c Add freshness bar, enhance overlays and renderers
- 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
2026-06-17 11:58:17 -04:00

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;
}
}
}
}