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>
This commit is contained in:
+431
@@ -0,0 +1,431 @@
|
||||
// ═══ 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;
|
||||
}
|
||||
Reference in New Issue
Block a user