// ---- enemy types, AI, damage & death ---- const ENEMY_TYPES = { shambler: { hp: 26, spd: 55, dmg: 1, r: 18, drawSize: 64, xp: 1, sprite: 'enemy_shambler' }, runner: { hp: 12, spd: 236, dmg: 1, r: 15, drawSize: 64, xp: 1, sprite: 'enemy_runner' }, spitter: { hp: 20, spd: 60, dmg: 1, r: 18, drawSize: 64, xp: 2, sprite: 'enemy_spitter', ranged: true }, splitter: { hp: 30, spd: 70, dmg: 1, r: 21, drawSize: 64, xp: 1, sprite: 'enemy_splitter', splits: true }, mini: { hp: 6, spd: 120, dmg: 1, r: 14, drawSize: 48, xp: 1, sprite: 'enemy_mini' }, exploder: { hp: 14, spd: 110, dmg: 2, r: 16, drawSize: 64, xp: 2, sprite: 'enemy_exploder', explodes: true }, censer: { hp: 34, spd: 45, dmg: 1, r: 19, drawSize: 64, xp: 3, sprite: 'enemy_censer', zoneCaster: true }, bulwark: { hp: 60, spd: 38, dmg: 2, r: 23, drawSize: 72, xp: 3, sprite: 'enemy_bulwark', frontalArmor: true }, choirmaster: { hp: 40, spd: 50, dmg: 1, r: 19, drawSize: 64, xp: 4, sprite: 'enemy_choirmaster', support: true }, flenserling: { hp: 22, spd: 150, dmg: 2, r: 16, drawSize: 64, xp: 3, sprite: 'enemy_flenserling', ambusher: true }, broodsac: { hp: 45, spd: 20, dmg: 1, r: 22, drawSize: 72, xp: 4, sprite: 'enemy_broodsac', summoner: true, broodBurst: 4 }, }; const MAX_ENEMIES = 72; const SPAWN_WARN = 0.5; // seconds a wave-spawned enemy materializes, frozen + harmless const HP_BAR_SHOW = 2.5; function makeEnemy(type, x, y, floor, elite) { const t = ENEMY_TYPES[type]; const up = powerScore(); // floor scaling × player-power scaling (+5% hp / +1% speed per upgrade) const fscale = (1 + (floor - 1) * 0.22) * (1 + up * 0.05); const sscale = 1 + (floor - 1) * 0.04 + up * 0.01; const e = { type, x, y, vx: 0, vy: 0, r: t.r * (elite ? 1.45 : 1), drawSize: t.drawSize * (elite ? 1.5 : 1), hp: t.hp * fscale * (elite ? 2.6 : 1) * G.pressure, maxHp: t.hp * fscale * (elite ? 2.6 : 1) * G.pressure, spd: t.spd * sscale * (elite ? 0.9 : 1) * rand(0.9, 1.1) * G.pressure, dmg: t.dmg + (elite ? 1 : 0), xp: t.xp * (elite ? 3 : 1), sprite: t.sprite, elite: !!elite, hitT: 0, flash: 0, hpBarT: 0, burnT: 0, burnDps: 0, bleedT: 0, bleedDps: 0, slowT: 0, stunT: 0, rootT: 0, fireT: rand(1, 2.5), castT: rand(1.5, 3), summonT: rand(2.5, 4), supportT: 1, fuse: -1, wob: rand(0, TAU), faceDir: rand(0, TAU), choirT: 0, ambushState: 'stalk', ambushT: rand(1.8, 3.2), phaseT: 0, lungeT: 0, alpha: 1, animSeed: rand(0, TAU), animT: rand(0, 1), attackT: 0, actionT: 0, spawnT: 0, dead: false, warmT: 0, boss: false, }; return e; } // pick an enemy type for a floor-scaled wave function pickEnemyType(floor) { const table = [ ['shambler', 30], ['runner', 22], ['spitter', floor >= 2 ? 14 : 0], ['splitter', floor >= 2 ? 12 : 0], ['exploder', floor >= 3 ? 10 : 0], ['censer', floor >= 4 ? 8 : 0], ['bulwark', floor >= 5 ? 8 : 0], ['flenserling', floor >= 5 ? 7 : 0], ['choirmaster', floor >= 6 ? 6 : 0], ['broodsac', floor >= 7 ? 6 : 0], ]; const total = table.reduce((s, t) => s + t[1], 0); let r = Math.random() * total; for (const [type, w] of table) { r -= w; if (r <= 0) return type; } return 'shambler'; } function pickWaveEnemyType(floor) { for (let tries = 0; tries < 12; tries++) { const type = pickEnemyType(floor); if (type === 'choirmaster' && G.enemies.some(e => e.hp > 0 && e.type === 'choirmaster')) continue; if (type === 'broodsac' && G.enemies.filter(e => e.hp > 0 && e.type === 'broodsac').length >= 2) continue; if ((type === 'censer' || type === 'broodsac') && Math.min(G.arena.w, G.arena.h) < 300) continue; return type; } return 'shambler'; } // true if (x,y) is within keep-out range of any of the current room's doorways function nearAnyDoor(x, y) { const r = G.cur; if (!r) return false; const a = G.arena; const M = Math.min(130, Math.min(a.w, a.h) * 0.32); if (r.doors.n && Math.abs(x - a.cx) < DOOR_HALF + 30 && y < a.y0 + M) return true; if (r.doors.s && Math.abs(x - a.cx) < DOOR_HALF + 30 && y > a.y1 - M) return true; if (r.doors.e && Math.abs(y - a.cy) < DOOR_HALF + 30 && x > a.x1 - M) return true; if (r.doors.w && Math.abs(y - a.cy) < DOOR_HALF + 30 && x < a.x0 + M) return true; return false; } function spawnPosAwayFromPlayer() { const p = G.player; const a = G.arena; const safeDist = Math.min(200, Math.min(a.w, a.h) * 0.42); for (let tries = 0; tries < 30; tries++) { const x = rand(a.x0 + 40, a.x1 - 40); const y = rand(a.y0 + 40, a.y1 - 40); if (dist(x, y, p.x, p.y) <= safeDist) continue; if (nearAnyDoor(x, y)) continue; return { x, y }; } // fallback: a bounded central region, valid even in the thin hall variants const rx = Math.max(12, Math.min(150, a.w / 2 - 40)); const ry = Math.max(12, Math.min(120, a.h / 2 - 40)); return { x: rand(a.cx - rx, a.cx + rx), y: rand(a.cy - ry, a.cy + ry) }; } function spawnWave(count, floor, warn) { for (let i = 0; i < count; i++) { if (G.enemies.length >= MAX_ENEMIES) break; const type = pickWaveEnemyType(floor); const pos = spawnPosAwayFromPlayer(); const canElite = type !== 'bulwark' && type !== 'broodsac'; const elite = canElite && chance(Math.min(0.03 + floor * 0.02, 0.2)); const e = makeEnemy(type, pos.x, pos.y, floor, elite); e.warmT = warn || SPAWN_WARN; // telegraph the incoming spawn so the player isn't blindsided G.enemies.push(e); spawnBlood(pos.x, pos.y, rand(0, TAU), 4); Sfx.spawn(e); } } function spawnBroodMinis(source, count, warm) { const cap = Math.min(MAX_ENEMIES, 40); let spawned = 0; for (let i = 0; i < count && G.enemies.length < cap; i++) { const ang = i / Math.max(1, count) * TAU + rand(-0.2, 0.2); const x = clamp(source.x + Math.cos(ang) * rand(18, 32), G.arena.x0 + 16, G.arena.x1 - 16); const y = clamp(source.y + Math.sin(ang) * rand(18, 32), G.arena.y0 + 16, G.arena.y1 - 16); const m = makeEnemy('mini', x, y, G.floor, false); m.warmT = warm || 0; G.enemies.push(m); spawned++; } if (spawned) Sfx.split(source); return spawned; } // returns true if the enemy died function damageEnemy(e, dmg, ang, knockback, opts) { if (e.hp <= 0) return true; if (e.phased) return false; if (!(dmg > 0)) return false; // NaN / zero / negative damage does nothing if (G.debugFlags && G.debugFlags.ohko) dmg = 1e9; // debug: one-shot-kill, still respects plates // crit roll const p = G.player; const o = opts || {}; const crit = !!(p && !o.noCrit && chance(p.stats.crit || 0)); if (crit) dmg *= p.stats.critMul || 2; // Dead Weight: execute wounded enemies if (p && p.stats.executeBonus > 0 && e.maxHp > 0 && e.hp / e.maxHp < 0.30) dmg *= 1 + p.stats.executeBonus; // Cauterized Veins: bonus damage to burning enemies if (p && p.stats.burnDamageBonus > 0 && e.burnT > 0) dmg *= 1 + p.stats.burnDamageBonus; let mitigated = false; if (e.type === 'bulwark' && !o.ignoreArmor) { const hitFrom = Number.isFinite(ang) ? ang + Math.PI : (p ? angleTo(e.x, e.y, p.x, p.y) : e.faceDir); const delta = Math.abs(Math.atan2(Math.sin(hitFrom - e.faceDir), Math.cos(hitFrom - e.faceDir))); if (delta <= 70 * Math.PI / 180) { dmg *= 0.2; mitigated = true; } } if (e.dmgTakenMul !== undefined && e.dmgTakenMul < 1) mitigated = true; dmg *= e.dmgTakenMul === undefined ? 1 : e.dmgTakenMul; e.hp -= dmg; e.flash = 0.12; e.hpBarT = HP_BAR_SHOW; spawnText(e.x, e.y, Math.max(1, Math.round(dmg)), mitigated ? '#d0b887' : '#ffb0a0'); if (e.plates > 0) { e.plateDmg = (e.plateDmg || 0) + dmg; const threshold = e.maxHp * 0.06; if (e.plateDmg >= threshold) { e.plateDmg -= threshold; e.plates--; e.rootT = Math.max(e.rootT || 0, 0.45); e.flash = 0.2; addShake(6); spawnGibs(e.x, e.y, 4, true); spawnText(e.x, e.y - e.r, 'PLATE BROKEN', '#ffd080'); Sfx.boss(e, 'plate'); if (e.plates <= 0) { e.dmgTakenMul = 1; addToast('ARMOR BREACHED', 'Plate Father is exposed'); Sfx.boss(e, 'enrage'); } } } if (knockback && !e.boss && !e.dragged) { const kb = p ? p.stats.knockbackMul || 1 : 1; e.vx += Math.cos(ang) * 90 * kb; e.vy += Math.sin(ang) * 90 * kb; } if (p && !o.noProc) procOnHit(e, dmg, ang, o); Sfx.hit(e, crit); if (e.hp <= 0) { killEnemy(e, ang); return true; } return false; } // Diminishing (armor-style) conversion of a raw stun rating into a stagger chance. function stunChance(raw) { return Math.min(0.50, Math.max(0, raw) / (1 + Math.max(0, raw))); } // Shared player-hit payload. Every weapon primitive routes through damageEnemy, // so items remain useful for bullets, beams, slams, saws, traps and orbitals. function procOnHit(e, dmg, ang, opts) { const p = G.player, s = p.stats; const chanceScale = opts.procScale === undefined ? 1 : opts.procScale; const magnitudeScale = opts.procMagnitudeScale === undefined ? 1 : opts.procMagnitudeScale; const intervalScale = opts.procIntervalScale === undefined ? 1 : opts.procIntervalScale; const procDamage = dmg / Math.max(0.05, intervalScale) * magnitudeScale; // Thousand Teeth: bullets shatter into 6 extra shards that can crit. // Shards inherit the shard flag so they don't recurse. The −40% base bullet // damage is applied as a global dmgMul penalty in the item's apply(). if (s.thousandTeeth > 0 && !(opts.bullet && opts.bullet.shard) && chance(clamp(chanceScale, 0, 1))) { for (let i = 0; i < 6; i++) { const sa = rand(0, TAU); const shardCrit = chance(s.crit || 0); G.bullets.push({ x: e.x, y: e.y, ang: sa, vx: Math.cos(sa) * 300 * s.shotSpeedMul, vy: Math.sin(sa) * 300 * s.shotSpeedMul, r: 3 * s.sizeMul, dmg: procDamage * 0.4 * (shardCrit ? (s.critMul || 2) : 1), pierce: 0, bounce: s.bounce, life: 0.35 * s.rangeMul, t: 0, behavior: 'bullet', sprite: 'bullet_bone', homing: s.homing ? 1.6 + s.homing * 0.7 : 0, shard: true, sizeMul: s.sizeMul, }); } spawnSpark(e.x, e.y, ang); } if (s.splinter > 0 && !(opts.bullet && opts.bullet.shard) && chance(clamp(chanceScale, 0, 1))) { const count = Math.min(18, Math.floor(s.splinter)); for (let i = 0; i < count; i++) { const sa = rand(0, TAU); G.bullets.push({ x: e.x, y: e.y, ang: sa, vx: Math.cos(sa) * 300 * s.shotSpeedMul, vy: Math.sin(sa) * 300 * s.shotSpeedMul, r: 3 * s.sizeMul, dmg: procDamage * 0.4, pierce: 0, bounce: s.bounce, life: 0.35 * s.rangeMul, t: 0, behavior: 'bullet', sprite: 'bullet_bone', homing: s.homing ? 1.6 + s.homing * 0.7 : 0, shard: true, sizeMul: s.sizeMul, }); } spawnSpark(e.x, e.y, ang); } if (s.bleed > 0) { e.bleedT = Math.max(e.bleedT || 0, 2.2); e.bleedDps = Math.max(e.bleedDps || 0, procDamage * s.bleed * 0.45); } if (s.igniteChance > 0 && chance(clamp(s.igniteChance * chanceScale, 0, 0.9))) { e.burnT = Math.max(e.burnT || 0, 1.8); e.burnDps = Math.max(e.burnDps || 0, 5 * s.dmgMul); } if (s.slowOnHit > 0 && chance(clamp(s.slowOnHit * chanceScale, 0, 0.9))) e.slowT = Math.max(e.slowT || 0, 1.5); if (!e.boss && e.type !== 'bulwark') { const stunCh = clamp((s.stunOnHit || 0) * chanceScale, 0, 0.75) + stunChance(s.stunRaw || 0); if (stunCh > 0 && chance(clamp(stunCh, 0, 0.75))) e.stunT = Math.max(e.stunT || 0, 0.35); } if (s.pullOnHit > 0 && !e.boss && chance(clamp(s.pullOnHit * chanceScale, 0, 0.8))) { const toward = angleTo(e.x, e.y, p.x, p.y); e.vx += Math.cos(toward) * 170; e.vy += Math.sin(toward) * 170; } if (s.acidOnHit > 0 && G.hazards.length < 80 && chance(clamp(s.acidOnHit * chanceScale, 0, 0.65))) { G.hazards.push({ kind: 'acid', x: e.x, y: e.y, r: 18 * s.sizeMul, life: 2.5, t: 0, dps: 5 * s.dmgMul }); } if (s.chain > 0 && G.enemies.length > 1 && chance(clamp(chanceScale, 0, 1))) { const hit = new Set([e]); let from = e; for (let i = 0; i < Math.min(6, Math.floor(s.chain)); i++) { let next = null, best = 180 * 180; for (const target of G.enemies) { if (target.hp <= 0 || target.phased || hit.has(target)) continue; const d2 = dist2(from.x, from.y, target.x, target.y); if (d2 < best) { best = d2; next = target; } } if (!next) break; hit.add(next); damageEnemy(next, procDamage * 0.35, angleTo(from.x, from.y, next.x, next.y), false, { noProc: true, noCrit: true }); spawnSpark(next.x, next.y, angleTo(from.x, from.y, next.x, next.y)); from = next; } } if (s.mortar > 0) { p.mortarHits = (p.mortarHits || 0) + chanceScale; const every = Math.max(2, 7 - Math.min(5, Math.floor(s.mortar))); if (p.mortarHits >= every) { p.mortarHits -= every; const radius = 42 + s.mortar * 5; for (const target of G.enemies) { if (target.hp > 0 && target !== e && dist2(e.x, e.y, target.x, target.y) < radius * radius) { damageEnemy(target, procDamage * 0.55, angleTo(e.x, e.y, target.x, target.y), true, { noProc: true, noCrit: true }); } } spawnExplosionFx(e.x, e.y, radius); } } } function tickEnemyDamage(e, amount) { if (!(amount > 0) || e.hp <= 0) return; e.hp -= amount * (e.dmgTakenMul === undefined ? 1 : e.dmgTakenMul); if (e.hp <= 0) killEnemy(e, rand(0, TAU)); } function killEnemy(e, ang) { if (e.dead) return; spawnCorpse(e); e.dead = true; e.hp = 0; const p = G.player; if (p.stats.frenzy > 0) p.frenzyT = Math.max(p.frenzyT || 0, 3); G.kills++; if (p.stats.slaughterRhythm > 0) { p.killStamps = p.killStamps || []; p.killStamps.push(G.time); } addScore(e.boss ? 500 : (e.elite ? 40 : 10)); spawnBlood(e.x, e.y, ang || rand(0, TAU), e.boss ? 40 : (e.elite ? 20 : 10), e.boss || e.elite); spawnGibs(e.x, e.y, e.boss ? 16 : (e.elite ? 8 : 4), e.boss || e.elite); if (e.boss || e.elite) spawnShockwave(e.x, e.y, e.boss ? e.r * 2.4 : e.r * 1.7, '#b82a38', e.boss ? 0.9 : 0.55); if (!e.boss) Sfx.enemyDie(e); // Vampire Dentures heal on kills, naturally normalizing slow and rapid weapons. const stealChance = clamp((p.stats.lifestealChance || 0) * 3, 0, 0.9); if (p.hp < p.stats.maxHp && stealChance && chance(stealChance)) { lifestealPlayer(e.boss ? 4 : (e.elite ? 2 : 1)); } if (e.boss) { onBossDeath(e); return; } // Gore Crown: free nova on every kill if (p.stats.goreCrown > 0) { areaDamage(e.x, e.y, 70 * Math.sqrt(p.stats.sizeMul), 8 * p.stats.dmgMul * p.stats.dmgLiveMul, true, { noProc: true, noCrit: true }); spawnExplosionFx(e.x, e.y, 60); } // Blood Moat: kills leave an acid pool if (p.stats.bloodMoat > 0 && G.hazards.length < 80) { G.hazards.push({ kind: 'acid', x: e.x, y: e.y, r: 16 * p.stats.sizeMul, life: 2.5, t: 0, dps: 5 * p.stats.dmgMul * p.stats.dmgLiveMul }); } // Meat Hook: kills yank nearby enemies to the corpse if (p.stats.meatHook > 0) { const yankR = 120 + p.stats.meatHook * 15; for (const o of G.enemies) { if (o === e || o.hp <= 0 || o.boss) continue; if (dist2(o.x, o.y, e.x, e.y) < yankR * yankR) { const a = angleTo(o.x, o.y, e.x, e.y); o.vx += Math.cos(a) * 240; o.vy += Math.sin(a) * 240; } } } // Volatile Bile: kills explode — radius and damage scale with tier if (p.stats.explodeOnKill > 0) { const rad = 60 + p.stats.explodeOnKill * 15; areaDamage(e.x, e.y, rad, (10 + p.stats.explodeOnKill * 8) * p.stats.dmgMul, true); spawnExplosionFx(e.x, e.y, rad); Sfx.explode({ x: e.x, y: e.y }); } // drops const luck = p.stats.luck; let gemVal = e.xp + (p.stats.bloodlust && chance(p.stats.bloodlust) ? e.xp : 0); // Bloodrush keeps its XP multiplier and makes the bonus visible as extra crystals. const xpBonusChance = clamp((p.stats.xpMul || 1) - 1, 0, 0.9); if (xpBonusChance && chance(xpBonusChance)) gemVal += Math.max(1, Math.round(e.xp * 0.5)); if (e.elite) { // special monsters: always bonus XP, mostly ammo, sometimes an item gemVal += irand(3, 5); spawnGems(e.x, e.y, gemVal); if (chance(0.18 + luck * 0.1)) { spawnPickup('item', e.x, e.y, { iid: rollItemId('elite', G.floor) }); } else if (chance((0.6 + luck * 0.2) * AMMO_DROP_SCALE)) { spawnPickup('ammo', e.x, e.y); } } else { spawnGems(e.x, e.y, gemVal); if (chance((0.07 + luck * 0.04) * AMMO_DROP_SCALE)) { spawnPickup('ammo', e.x, e.y); } else if (chance(0.022 + luck * 0.03)) { spawnPickup('heart', e.x, e.y); } } // splitter splits if (ENEMY_TYPES[e.type].splits && !e.elite) { Sfx.split(e); for (let i = 0; i < 2 && G.enemies.length < MAX_ENEMIES; i++) { const m = makeEnemy('mini', e.x + rand(-18, 18), e.y + rand(-18, 18), G.floor, false); G.enemies.push(m); } } if (ENEMY_TYPES[e.type].broodBurst) spawnBroodMinis(e, ENEMY_TYPES[e.type].broodBurst, 0.2); // exploder goes boom if (ENEMY_TYPES[e.type].explodes) explodeAt(e.x, e.y, 60, 2, false); } function explodeAt(x, y, r, dmg, fromPlayer) { spawnExplosionFx(x, y, r); Sfx.explode({ x, y }); if (fromPlayer) { areaDamage(x, y, r, dmg, true); } else { const p = G.player; if (dist(x, y, p.x, p.y) < r + p.r) hurtPlayer(dmg, angleTo(x, y, p.x, p.y)); } } function updateEnemies(dt) { const p = G.player; for (let i = G.enemies.length - 1; i >= 0; i--) { const e = G.enemies[i]; if (e.hp <= 0) { G.enemies.splice(i, 1); continue; } e.spawnT = Math.min((e.spawnT || 0) + dt, 1); e.animT = (e.animT || 0) + dt; if (e.hpBarT > 0) e.hpBarT = Math.max(0, e.hpBarT - dt); if (e.choirT > 0) e.choirT = Math.max(0, e.choirT - dt); if (e.attackT > 0) { e.attackT -= dt; e.actionT += dt; } // materializing spawns are frozen and harmless until the telegraph ends if (e.warmT > 0) { e.warmT -= dt; e.vx = 0; e.vy = 0; if (e.warmT > 0) continue; } // status effects if (e.flash > 0) e.flash -= dt; if (e.orbT > 0) e.orbT -= dt; if (e.burnT > 0) { e.burnT -= dt; e.hpBarT = HP_BAR_SHOW; tickEnemyDamage(e, e.burnDps * dt); if (chance(0.2)) addParticle({ type: 'flame', x: e.x + rand(-6, 6), y: e.y + rand(-6, 6), vx: 0, vy: -40, life: 0.3, t: 0, r: 3 }); if (e.hp <= 0) { G.enemies.splice(i, 1); continue; } } if (e.bleedT > 0) { e.bleedT -= dt; e.hpBarT = HP_BAR_SHOW; tickEnemyDamage(e, e.bleedDps * dt); if (e.hp <= 0) { G.enemies.splice(i, 1); continue; } } if (e.slowT > 0) e.slowT -= dt; if (e.stunT > 0) { e.stunT -= dt; e.vx = 0; e.vy = 0; continue; } if (e.rootT > 0) { e.rootT -= dt; e.vx = 0; e.vy = 0; continue; } if (e.dragged) { e.vx = 0; e.vy = 0; continue; } // harpooned if (e.boss) { updateBoss(e, dt); } else updateEnemyAI(e, dt); // integrate + separation e.x += e.vx * dt; e.y += e.vy * dt; const drag = Math.pow(0.82, dt * 60); e.vx *= drag; e.vy *= drag; // Resolve each pair once instead of twice. This halves the hottest // enemy-crowd loop while preserving the same mutual separation. for (let j = i - 1; j >= 0; j--) { const o = G.enemies[j]; if (o.hp <= 0) continue; const d2 = dist2(e.x, e.y, o.x, o.y); const min = (e.r + o.r) * 0.9; if (d2 < min * min && d2 > 0.01) { const d = Math.sqrt(d2), push = (min - d) / d * 30 * dt; const px = (e.x - o.x) * push, py = (e.y - o.y) * push; e.x += px; e.y += py; o.x -= px; o.y -= py; } } e.x = clamp(e.x, G.arena.x0 + e.r, G.arena.x1 - e.r); e.y = clamp(e.y, G.arena.y0 + e.r, G.arena.y1 - e.r); // contact damage if (e.hitT > 0) e.hitT -= dt; if (!e.phased && e.hitT <= 0 && p.hp > 0 && dist2(e.x, e.y, p.x, p.y) < (e.r + p.r) * (e.r + p.r)) { e.attackT = 0.3; e.actionT = 0; hurtPlayer(e.dmg, angleTo(e.x, e.y, p.x, p.y), e); e.hitT = 0.7; } } } function updateEnemyAI(e, dt) { const p = G.player; const a = angleTo(e.x, e.y, p.x, p.y); const d = dist(e.x, e.y, p.x, p.y); const buff = e.choirT > 0 ? 1.35 : 1; const spd = (e.slowT > 0 ? e.spd * 0.45 : e.spd) * buff; const t = ENEMY_TYPES[e.type]; if (t.zoneCaster) { // Censer: drift toward room centre while predicting the player's movement. const ca = angleTo(e.x, e.y, G.arena.cx, G.arena.cy); if (dist(e.x, e.y, G.arena.cx, G.arena.cy) > 80) { e.vx += Math.cos(ca) * spd * 2.2 * dt; e.vy += Math.sin(ca) * spd * 2.2 * dt; } if (d < 150) { e.vx -= Math.cos(a) * spd * 2.5 * dt; e.vy -= Math.sin(a) * spd * 2.5 * dt; } e.castT -= dt; if (e.castT <= 0) { e.castT = e.elite ? 2.3 : 3.3; const lead = 75 * (p.moveBlend || 0); const tx = clamp(p.x + Math.cos(p.bodyFacing) * lead, G.arena.x0 + 40, G.arena.x1 - 40); const ty = clamp(p.y + Math.sin(p.bodyFacing) * lead, G.arena.y0 + 40, G.arena.y1 - 40); G.telegraphs.push({ kind: 'pool', owner: e, x: tx, y: ty, r: e.elite ? 48 : 38, t: 0, dur: 0.75, life: e.elite ? 5.5 : 4.2 }); e.attackT = 0.5; e.actionT = 0; Sfx.spit(e); } } else if (t.frontalArmor) { // Bulwark: slowly tracks the player; its facing also drives frontal mitigation. e.faceDir = angleLerp(e.faceDir, a, clamp(dt * 1.15, 0, 1)); e.vx += Math.cos(e.faceDir) * spd * 3 * dt; e.vy += Math.sin(e.faceDir) * spd * 3 * dt; } else if (t.support) { // Choirmaster: hover behind the pack, accelerating and mending nearby monsters. if (d < 175) { e.vx -= Math.cos(a) * spd * 2.8 * dt; e.vy -= Math.sin(a) * spd * 2.8 * dt; } else if (d > 250) { e.vx += Math.cos(a) * spd * 2.2 * dt; e.vy += Math.sin(a) * spd * 2.2 * dt; } e.supportT -= dt; for (const o of G.enemies) { if (o === e || o.hp <= 0 || o.boss) continue; if (dist2(e.x, e.y, o.x, o.y) <= 180 * 180) { o.choirT = Math.max(o.choirT || 0, 0.18); if (e.supportT <= 0) o.hp = Math.min(o.maxHp, o.hp + Math.max(0.5, o.maxHp * 0.018)); } } if (e.supportT <= 0) { e.supportT = 1; e.attackT = 0.28; e.actionT = 0; } } else if (t.ambusher) { // Flenserling: disappear, reposition behind the player's aim, then lunge. if (e.ambushState === 'gone') { e.vx = 0; e.vy = 0; e.phaseT -= dt; if (e.phaseT <= 0) { const behind = p.aim + Math.PI, range = rand(90, 125); e.x = clamp(p.x + Math.cos(behind) * range, G.arena.x0 + e.r, G.arena.x1 - e.r); e.y = clamp(p.y + Math.sin(behind) * range, G.arena.y0 + e.r, G.arena.y1 - e.r); e.phased = false; e.alpha = 1; e.ambushState = 'lunge'; e.lungeT = 0.48; e.faceDir = angleTo(e.x, e.y, p.x, p.y); e.attackT = 0.48; e.actionT = 0; } } else if (e.ambushState === 'lunge') { e.lungeT -= dt; e.vx += Math.cos(e.faceDir) * spd * 6 * dt; e.vy += Math.sin(e.faceDir) * spd * 6 * dt; if (e.lungeT <= 0) { e.ambushState = 'stalk'; e.ambushT = rand(2.2, 3.4); } } else { e.faceDir = angleLerp(e.faceDir, a, clamp(dt * 8, 0, 1)); e.vx += Math.cos(a) * spd * 2.7 * dt; e.vy += Math.sin(a) * spd * 2.7 * dt; e.ambushT -= dt; if (e.ambushT <= 0) { e.ambushState = 'gone'; e.phaseT = 0.55; e.phased = true; e.alpha = 0; e.vx = 0; e.vy = 0; spawnSmoke(e.x, e.y, -Math.PI / 2, 5, '#8d2038'); } } } else if (t.summoner) { // Brood Sac: roots near the room centre and continuously seeds minis. const ca = angleTo(e.x, e.y, G.arena.cx, G.arena.cy); if (dist(e.x, e.y, G.arena.cx, G.arena.cy) > 110) { e.vx += Math.cos(ca) * spd * 1.6 * dt; e.vy += Math.sin(ca) * spd * 1.6 * dt; } e.summonT -= dt; if (e.summonT <= 0) { e.summonT = e.elite ? 3 : 4; spawnBroodMinis(e, 2, 0.35); e.attackT = 0.5; e.actionT = 0; } } else if (t.ranged) { const desiredFar = Math.min(250, Math.min(G.arena.w, G.arena.h) * 0.44); const desiredNear = Math.max(90, desiredFar - 60); if (d > desiredFar) { e.vx += Math.cos(a) * spd * 3 * dt; e.vy += Math.sin(a) * spd * 3 * dt; } else if (d < desiredNear) { e.vx -= Math.cos(a) * spd * 3 * dt; e.vy -= Math.sin(a) * spd * 3 * dt; } e.fireT -= dt; if (e.fireT <= 0 && d < 420) { e.fireT = e.elite ? 1.1 : 1.9; e.attackT = 0.34; e.actionT = 0; const n = e.elite ? 3 : 1; for (let k = 0; k < n; k++) { const fa = a + (n > 1 ? (k - 1) * 0.25 : 0); G.ebullets.push({ x: e.x, y: e.y, vx: Math.cos(fa) * 220, vy: Math.sin(fa) * 220, r: 5, dmg: 1, life: 3, t: 0, sprite: 'bullet_gore' }); } Sfx.spit(e); } } else if (t.explodes) { e.vx += Math.cos(a) * spd * 4 * dt; e.vy += Math.sin(a) * spd * 4 * dt; if (e.fuse < 0 && d < 56) { e.fuse = 0.5; e.attackT = 0.5; e.actionT = 0; Sfx.fuse(e); } if (e.fuse >= 0) { e.fuse -= dt; e.flash = 0.05; if (e.fuse <= 0) { e.hp = 0; explodeAt(e.x, e.y, 70, 2, false); } } } else { e.wob += dt * 3; const wa = a + Math.sin(e.wob) * 0.3; e.vx += Math.cos(wa) * spd * 4 * dt; e.vy += Math.sin(wa) * spd * 4 * dt; } } function drawEnemies(ctx) { for (const e of G.enemies) { if (e.hp <= 0) continue; // spawn telegraph: pulsing sigil + ghost, harmless until warmT expires if (e.warmT > 0) { const k = clamp(1 - e.warmT / SPAWN_WARN, 0, 1); const ringR = e.r + 30 * (1 - k); ctx.save(); ctx.globalAlpha = 0.35 + Math.sin(G.time * 26) * 0.2; ctx.strokeStyle = '#ff2a3c'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(e.x, e.y, ringR, 0, TAU); ctx.stroke(); ctx.globalAlpha = 0.22 + k * 0.30; ctx.fillStyle = '#4a0713'; ctx.beginPath(); ctx.arc(e.x, e.y, e.r * (0.3 + k * 0.7), 0, TAU); ctx.fill(); ctx.restore(); Sprites.actor(ctx, e.sprite, e.x, e.y, 0, 'idle', e.animT || 0, e.drawSize || (e.boss ? 128 : 64), 0.18 + k * 0.55, 0.45 + k * 0.55, 0.45 + k * 0.55); if (e.elite) { ctx.strokeStyle = '#ffd060'; ctx.lineWidth = 2; ctx.globalAlpha = 0.7; ctx.beginPath(); ctx.arc(e.x, e.y, e.r + 3, 0, TAU); ctx.stroke(); ctx.globalAlpha = 1; } continue; } const p = G.player; const face = (e.type === 'bulwark' || e.type === 'flenserling') ? e.faceDir : angleTo(e.x, e.y, p.x, p.y); const speed = Math.min(Math.hypot(e.vx, e.vy) / Math.max(e.spd, 1), 1); let action = speed > 0.12 ? 'move' : 'idle'; let animTime = e.animT || 0; if (e.attackT > 0 || (e.boss && e.state !== 'idle')) { action = 'attack'; animTime = e.attackT > 0 ? e.actionT : (e.animT || 0); } if (e.flash > 0) { action = 'hit'; animTime = 0.12 - e.flash; } let pulse = 1; if (e.type === 'exploder' && e.fuse >= 0) pulse = 1 + (0.5 - e.fuse) * 0.13 + Math.sin(G.time * 28) * 0.04; const appear = clamp((e.spawnT || 0) / 0.24, 0.12, 1); if (e.type === 'choirmaster' && e.alpha > 0.05) { ctx.save(); ctx.globalAlpha = 0.28 + Math.sin(G.time * 8) * 0.08; ctx.strokeStyle = '#e22b46'; ctx.lineWidth = 2; for (const o of G.enemies) { if (o === e || o.hp <= 0 || o.boss || o.choirT <= 0) continue; if (dist2(e.x, e.y, o.x, o.y) > 180 * 180) continue; ctx.beginPath(); ctx.moveTo(e.x, e.y); ctx.lineTo(o.x, o.y); ctx.stroke(); } ctx.restore(); } if (e.alpha === undefined || e.alpha > 0.05) Sprites.shadow(ctx, e.x, e.y + e.r * 0.55, e.r * 0.9, e.r * 0.34, (e.boss ? 0.5 : 0.34) * (e.alpha === undefined ? 1 : e.alpha)); Sprites.actor(ctx, e.sprite, e.x, e.y, face, action, animTime, e.drawSize || (e.boss ? 128 : 64), e.alpha === undefined ? 1 : e.alpha, appear * pulse, appear * pulse); if (e.type === 'bulwark') { ctx.save(); ctx.translate(e.x, e.y); ctx.rotate(e.faceDir); ctx.strokeStyle = '#c7a56d'; ctx.globalAlpha = 0.72; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(0, 0, e.r + 5, -70 * Math.PI / 180, 70 * Math.PI / 180); ctx.stroke(); ctx.restore(); } if (e.plates > 0) { ctx.save(); ctx.translate(e.x, e.y); ctx.lineWidth = 4; for (let k = 0; k < e.plates; k++) { ctx.strokeStyle = k % 2 ? '#8d755d' : '#b79b71'; ctx.beginPath(); ctx.arc(0, 0, e.r + 7, k * Math.PI / 2 + 0.08, k * Math.PI / 2 + 1.25); ctx.stroke(); } ctx.restore(); } if (e.elite) { ctx.strokeStyle = '#ffd060'; ctx.lineWidth = 2; ctx.globalAlpha = 0.7; ctx.beginPath(); ctx.arc(e.x, e.y, e.r + 3, 0, TAU); ctx.stroke(); ctx.globalAlpha = 1; } // Damaged normal monsters show a compact timed pip; elites/bosses stay visible. if (!e.phased && (e.boss || e.elite || (e.hpBarT > 0 && e.hp < e.maxHp))) { const w = e.boss ? 60 : (e.elite ? 26 : (e.type === 'mini' ? 14 : 18)); const h = e.boss || e.elite ? 4 : 3; const alpha = e.boss || e.elite ? 1 : clamp(e.hpBarT / 0.5, 0, 1); ctx.save(); ctx.globalAlpha = alpha; ctx.fillStyle = '#24070b'; ctx.fillRect(e.x - w / 2 - 1, e.y - e.r - 11, w + 2, h + 2); ctx.fillStyle = '#5a0d18'; ctx.fillRect(e.x - w / 2, e.y - e.r - 10, w, h); ctx.fillStyle = '#e22b46'; ctx.fillRect(e.x - w / 2, e.y - e.r - 10, w * clamp(e.hp / e.maxHp, 0, 1), h); ctx.restore(); } } }