Files
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

496 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ═══ 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;
}