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
496 lines
16 KiB
JavaScript
496 lines
16 KiB
JavaScript
// ═══ 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;
|
||
}
|