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:
2026-06-16 11:36:53 -04:00
commit 622a9fd170
31 changed files with 6164 additions and 0 deletions
+431
View File
@@ -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;
}