Files
siege-protocol/js/enemies.js
T
44r0n7 622a9fd170 Initial commit: Siege Protocol
Inverted tower-defense browser game — deploy enemies yourself, tower auto-kills them, pocket credits, upgrade weapons. HTML + Canvas + vanilla JS, no build step.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 11:36:53 -04:00

432 lines
14 KiB
JavaScript
Raw 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) {
// 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;
}
G.enemies.push({
id: uid(),
defId: def.id,
name: def.name,
x: spawnX, y: spawnY,
hp: def.hp,
maxHp: def.hp,
speed: def.speed,
baseSpeed: def.speed,
radius: def.radius,
armor: def.armor ?? 0,
baseArmor: def.armor ?? 0,
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,
});
}
// ── DEPLOY (player action) ────────────────────────────────────
function deployEnemy(defId, quantity = 1) {
const def = ENEMY_DEFS.find(e => e.id === defId);
if (!def) 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 rewardPerUnit = Math.round(def.reward * bonusMult);
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;
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)}]` : '';
addLog(`Deployed ${def.name}${plural}${totalCost}¢${bonusStr}${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;
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);
}
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 towerRange = G.tower.range ?? 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;
}