diff --git a/assets/enemy_broodsac.png b/assets/enemy_broodsac.png new file mode 100644 index 0000000..69d5048 Binary files /dev/null and b/assets/enemy_broodsac.png differ diff --git a/assets/enemy_broodsac_sheet.png b/assets/enemy_broodsac_sheet.png new file mode 100644 index 0000000..a02daca Binary files /dev/null and b/assets/enemy_broodsac_sheet.png differ diff --git a/assets/enemy_bulwark.png b/assets/enemy_bulwark.png new file mode 100644 index 0000000..48cb777 Binary files /dev/null and b/assets/enemy_bulwark.png differ diff --git a/assets/enemy_bulwark_sheet.png b/assets/enemy_bulwark_sheet.png new file mode 100644 index 0000000..1846956 Binary files /dev/null and b/assets/enemy_bulwark_sheet.png differ diff --git a/assets/enemy_censer.png b/assets/enemy_censer.png new file mode 100644 index 0000000..4cd23f3 Binary files /dev/null and b/assets/enemy_censer.png differ diff --git a/assets/enemy_censer_sheet.png b/assets/enemy_censer_sheet.png new file mode 100644 index 0000000..f58dbba Binary files /dev/null and b/assets/enemy_censer_sheet.png differ diff --git a/assets/enemy_choirmaster.png b/assets/enemy_choirmaster.png new file mode 100644 index 0000000..868e525 Binary files /dev/null and b/assets/enemy_choirmaster.png differ diff --git a/assets/enemy_choirmaster_sheet.png b/assets/enemy_choirmaster_sheet.png new file mode 100644 index 0000000..583d016 Binary files /dev/null and b/assets/enemy_choirmaster_sheet.png differ diff --git a/assets/enemy_flenserling.png b/assets/enemy_flenserling.png new file mode 100644 index 0000000..0b3643d Binary files /dev/null and b/assets/enemy_flenserling.png differ diff --git a/assets/enemy_flenserling_sheet.png b/assets/enemy_flenserling_sheet.png new file mode 100644 index 0000000..2301268 Binary files /dev/null and b/assets/enemy_flenserling_sheet.png differ diff --git a/js/enemies.js b/js/enemies.js index 1a12162..5b3f661 100644 --- a/js/enemies.js +++ b/js/enemies.js @@ -7,6 +7,11 @@ const ENEMY_TYPES = { 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 @@ -31,7 +36,9 @@ function makeEnemy(type, x, y, floor, elite) { 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), fuse: -1, wob: rand(0, TAU), + 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, @@ -43,11 +50,16 @@ function makeEnemy(type, x, y, floor, elite) { // pick an enemy type for a floor-scaled wave function pickEnemyType(floor) { const table = [ - ['shambler', 40], - ['runner', floor >= 1 ? 25 : 0], - ['spitter', floor >= 2 ? 18 : 0], - ['splitter', floor >= 2 ? 16 : 0], - ['exploder', floor >= 3 ? 14 : 0], + ['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; @@ -55,6 +67,17 @@ function pickEnemyType(floor) { 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; @@ -88,9 +111,11 @@ function spawnPosAwayFromPlayer() { function spawnWave(count, floor) { for (let i = 0; i < count; i++) { if (G.enemies.length >= MAX_ENEMIES) break; + const type = pickWaveEnemyType(floor); const pos = spawnPosAwayFromPlayer(); - const elite = chance(Math.min(0.03 + floor * 0.02, 0.2)); - const e = makeEnemy(pickEnemyType(floor), pos.x, pos.y, floor, elite); + 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 = 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); @@ -98,6 +123,22 @@ function spawnWave(count, floor) { } } +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; @@ -112,7 +153,13 @@ function damageEnemy(e, dmg, ang, knockback, opts) { 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; - const mitigated = e.dmgTakenMul !== undefined && e.dmgTakenMul < 1; + 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; @@ -195,7 +242,7 @@ function procOnHit(e, dmg, ang, opts) { 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 && s.stunOnHit > 0 && chance(clamp(s.stunOnHit * chanceScale, 0, 0.75))) e.stunT = Math.max(e.stunT || 0, 0.35); + if (!e.boss && e.type !== 'bulwark' && s.stunOnHit > 0 && chance(clamp(s.stunOnHit * chanceScale, 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; @@ -327,6 +374,7 @@ function killEnemy(e, ang) { 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); } @@ -350,6 +398,7 @@ function updateEnemies(dt) { 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 @@ -417,13 +466,85 @@ 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 spd = e.slowT > 0 ? e.spd * 0.45 : e.spd; + 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.ranged) { - // spitter: hold ~230px range and fire - if (d > 250) { e.vx += Math.cos(a) * spd * 3 * dt; e.vy += Math.sin(a) * spd * 3 * dt; } - else if (d < 190) { e.vx -= Math.cos(a) * spd * 3 * dt; e.vy -= Math.sin(a) * spd * 3 * dt; } + 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.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; @@ -431,10 +552,8 @@ function updateEnemyAI(e, dt) { 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', - }); + 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); } @@ -447,7 +566,6 @@ function updateEnemyAI(e, dt) { if (e.fuse <= 0) { e.hp = 0; explodeAt(e.x, e.y, 70, 2, false); } } } else { - // chaser with a little wobble 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; @@ -479,7 +597,7 @@ function drawEnemies(ctx) { continue; } const p = G.player; - const face = angleTo(e.x, e.y, p.x, p.y); + 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; @@ -491,9 +609,25 @@ function drawEnemies(ctx) { 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++) { diff --git a/js/sprites.js b/js/sprites.js index 16891b5..fc36582 100644 --- a/js/sprites.js +++ b/js/sprites.js @@ -5,6 +5,7 @@ const SPRITE_MANIFEST = [ 'player', 'player_legs', 'enemy_shambler', 'enemy_runner', 'enemy_spitter', 'enemy_splitter', 'enemy_mini', 'enemy_exploder', + 'enemy_censer', 'enemy_bulwark', 'enemy_choirmaster', 'enemy_flenserling', 'enemy_broodsac', 'boss_bonesaw', 'boss_gorecrown', 'boss_knifecrawl', 'boss_vealmother', 'boss_flenser', 'boss_hookchoir', 'boss_platefather', 'boss_augerprime', 'boss_scald', @@ -51,6 +52,7 @@ const SPRITE_MANIFEST = [ 'player_sheet', 'player_legs_sheet', 'player_death_sheet', 'enemy_shambler_sheet', 'enemy_runner_sheet', 'enemy_spitter_sheet', 'enemy_splitter_sheet', 'enemy_mini_sheet', 'enemy_exploder_sheet', + 'enemy_censer_sheet', 'enemy_bulwark_sheet', 'enemy_choirmaster_sheet', 'enemy_flenserling_sheet', 'enemy_broodsac_sheet', 'boss_bonesaw_sheet', 'boss_gorecrown_sheet', 'boss_knifecrawl_sheet', 'boss_vealmother_sheet', 'boss_flenser_sheet', 'boss_hookchoir_sheet', 'boss_platefather_sheet', 'boss_augerprime_sheet', 'boss_scald_sheet', diff --git a/test/smoke.js b/test/smoke.js index c4505a6..fc99b3d 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -1036,6 +1036,78 @@ console.log('== 500ms wave spawn telegraph =='); ctx.startRun(); step(3, 16); } +console.log('== five pressure-monster archetypes =='); +{ + const newTypes = ['censer', 'bulwark', 'choirmaster', 'flenserling', 'broodsac']; + check('enemy roster expanded from six to eleven types', Object.keys(ctx.ENEMY_TYPES).length === 11); + check('all five new monster stills and sheets are registered', newTypes.every(type => + ctx.SPRITE_MANIFEST.includes('enemy_' + type) && ctx.SPRITE_MANIFEST.includes('enemy_' + type + '_sheet'))); + let lockedSafe = true; + for (let i = 0; i < 500; i++) if (newTypes.includes(ctx.pickEnemyType(3))) lockedSafe = false; + check('new pressure monsters stay locked through floor 3', lockedSafe); + + ctx.startRun(); step(3, 16); + const p = ctx.G.player; + p.x = ctx.G.arena.cx; p.y = ctx.G.arena.cy; + + const censer = ctx.makeEnemy('censer', p.x - 180, p.y, 7, false); + censer.castT = 0; ctx.G.enemies = [censer]; ctx.G.telegraphs = []; + ctx.updateEnemyAI(censer, 0.016); + check('Censer predicts and telegraphs a hostile pool', ctx.G.telegraphs.some(t => t.kind === 'pool' && t.owner === censer)); + + const front = ctx.makeEnemy('bulwark', 300, 300, 7, false); front.faceDir = 0; + const rear = ctx.makeEnemy('bulwark', 300, 300, 7, false); rear.faceDir = 0; + const frontBefore = front.hp, rearBefore = rear.hp; + ctx.damageEnemy(front, 10, Math.PI, false, { noProc: true, noCrit: true }); + ctx.damageEnemy(rear, 10, 0, false, { noProc: true, noCrit: true }); + check('Bulwark frontal armor rewards flanking', frontBefore - front.hp < (rearBefore - rear.hp) * 0.25); + + const choir = ctx.makeEnemy('choirmaster', 300, 300, 7, false); + const ally = ctx.makeEnemy('shambler', 330, 300, 7, false); ally.hp = ally.maxHp / 2; + const allyHp = ally.hp; choir.supportT = 0; ctx.G.enemies = [choir, ally]; + ctx.updateEnemyAI(choir, 0.016); + check('Choirmaster buffs and repairs nearby monsters', ally.choirT > 0 && ally.hp > allyHp); + + const flenserling = ctx.makeEnemy('flenserling', 260, 260, 7, false); + flenserling.ambushT = 0; ctx.G.enemies = [flenserling]; + ctx.updateEnemyAI(flenserling, 0.016); + const phasedHp = flenserling.hp; + const phasedHit = ctx.damageEnemy(flenserling, 10, 0, false, { noProc: true, noCrit: true }); + check('Flenserling phases out and becomes untargetable', flenserling.phased && !phasedHit && flenserling.hp === phasedHp); + flenserling.phaseT = 0; ctx.updateEnemyAI(flenserling, 0.016); + check('Flenserling reappears behind the player for a lunge', !flenserling.phased && flenserling.ambushState === 'lunge'); + + const sac = ctx.makeEnemy('broodsac', 300, 300, 7, false); + sac.summonT = 0; ctx.G.enemies = [sac]; + ctx.updateEnemyAI(sac, 0.016); + check('Brood Sac periodically spawns two minis', ctx.G.enemies.filter(e => e.type === 'mini').length === 2); + const burstSac = ctx.makeEnemy('broodsac', 400, 300, 7, false); + ctx.G.enemies = [burstSac]; + ctx.damageEnemy(burstSac, 99999, 0, false, { noProc: true, noCrit: true }); + check('Brood Sac death bursts into four minis', ctx.G.enemies.filter(e => e.type === 'mini').length === 4); + + const cappedChoir = ctx.makeEnemy('choirmaster', 200, 200, 10, false); + const cappedSacA = ctx.makeEnemy('broodsac', 240, 200, 10, false); + const cappedSacB = ctx.makeEnemy('broodsac', 280, 200, 10, false); + ctx.G.enemies = [cappedChoir, cappedSacA, cappedSacB]; + let capSafe = true; + for (let i = 0; i < 300; i++) { + const type = ctx.pickWaveEnemyType(10); + if (type === 'choirmaster' || type === 'broodsac') capSafe = false; + } + check('support and summoner per-room caps are enforced', capSafe); + + const thin = ctx.makeRoom(0, 0, 'combat'); thin.shape = 'wide_hall'; thin.doors = {}; + ctx.G.cur = thin; ctx.setArenaForRoom(thin); ctx.G.enemies = []; + let hallSafe = true; + for (let i = 0; i < 300; i++) { + const type = ctx.pickWaveEnemyType(10); + if (type === 'censer' || type === 'broodsac') hallSafe = false; + } + check('zone denial and summoners stay out of thin halls', hallSafe); + ctx.startRun(); step(3, 16); +} + console.log('== pressure dial scaling =='); { const GAIN = [-2, -1.6, -1.2, -0.8, -0.4, 0, 0.2, 0.4, 0.6, 0.8, 1, 1.8, 2.6, 3.4, 4.2, 5, 6, 7, 8, 9, 10]; diff --git a/tools/draw_sprites.py b/tools/draw_sprites.py index c807539..06e00be 100644 --- a/tools/draw_sprites.py +++ b/tools/draw_sprites.py @@ -1327,6 +1327,7 @@ ANIM_ACTIONS = { ACTORS = [ "player", "enemy_shambler", "enemy_runner", "enemy_spitter", "enemy_splitter", "enemy_mini", "enemy_exploder", + "enemy_censer", "enemy_bulwark", "enemy_choirmaster", "enemy_flenserling", "enemy_broodsac", "boss_bonesaw", "boss_gorecrown", "boss_knifecrawl", "boss_vealmother", "boss_flenser", "boss_hookchoir", "boss_platefather", "boss_augerprime", "boss_scald", diff --git a/tools/gen_assets.py b/tools/gen_assets.py index 8f00c96..f085298 100644 --- a/tools/gen_assets.py +++ b/tools/gen_assets.py @@ -128,6 +128,11 @@ SPECS: dict[str, Spec] = { "enemy_splitter": Spec("round tumour beast made of fused meat nodules and industrial staples, splitting seams, facing right, directly top-down", 64), "enemy_mini": Spec("small vicious blood-red meat grub, glistening raw gore-red flesh, wet crimson skin, tiny hooked bone legs and an oversized ring of teeth, dripping blood, facing right, directly top-down", 64), "enemy_exploder": Spec("swollen explosive cyst mutant with taut pale skin, glowing red-yellow cracks and embedded blasting cap, facing right, directly top-down", 64), + "enemy_censer": Spec("floating plague censer creature, rusted brass incense cage fused to a wet lung and dangling butcher chains, leaking toxic green vapor, clear radial silhouette, facing right, directly top-down", 64), + "enemy_bulwark": Spec("hulking slaughterhouse shield mutant, enormous riveted iron carapace covering its front and exposed red muscle at the rear, broad asymmetric silhouette, facing right, directly top-down", 64), + "enemy_choirmaster": Spec("gaunt support horror with a circular throat organ, several horn-like bone tubes and glowing crimson vocal sacs, commanding pose, facing right, directly top-down", 64), + "enemy_flenserling": Spec("lean flayed ambusher with hooked scalpel forelimbs, stretched sinew and a long low stalking silhouette, no skin, facing right, directly top-down", 64), + "enemy_broodsac": Spec("rooted translucent meat womb swollen with small red larvae, stapled birth seams and short umbilical tendrils, heavy stationary silhouette, facing right, directly top-down", 64), "boss_bonesaw": Spec("colossal radial bone-saw machine monster, multiple concentric serrated rusted discs, bone hub, one hateful red lens, directly top-down", 128), "boss_gorecrown": Spec("colossal pulsing heart monarch fused to an iron crown of bone spikes and chains, radial silhouette, directly top-down", 128), "boss_knifecrawl": Spec("colossal spider horror made from fused butcher knives, scalpels and pale flesh, radial blade legs, directly top-down", 128),