Phase 4: 10 boss-exclusive legendaries + ammo/HP balance fixes

Legendaries (cap 1, boss-only): Butcher's Oath, Second Skin, Twin Sidearm,
Crimson Metronome, Abattoir Engine, Gore Crown, Thousand Teeth, Hollow Father,
The Last Cut, Meat Grinder. Once-per-floor flags reset in nextFloor.

Balance fixes: Ghoul Heart now +4 HP (2 full hearts); Brass Magazine ammo
efficiency uses the same diminishing additive formula as Scrap Feed.
This commit is contained in:
MeatSlicer Developer committed 2026-07-30 16:06:20 +10:00
1 parent a93802ebd9
commit f9552326c2
19 files changed
+150 -11

No files matched your search

+23
View File
@@ -148,6 +148,23 @@ function procOnHit(e, dmg, ang, opts) {
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++) {
@@ -241,6 +258,12 @@ function killEnemy(e, ang) {
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 });
+14 -2
View File
@@ -21,7 +21,7 @@ const ITEMS = {
magnetmaw: { name: 'Magnet Maw', desc: '+60% pickup radius', rarity: 'common', cap: 3, apply: s => { s.magnet *= 1.6; } },
gorgingleech: { name: 'Gorging Leech', desc: '+20% XP gained', rarity: 'common', cap: 4, apply: s => { s.xpMul *= 1.20; } },
hollowbones: { name: 'Hollow Bones', desc: '+14% move speed', rarity: 'common', cap: 6, apply: s => { s.speedMul *= 1.14; } },
brassmagazine: { name: 'Brass Magazine', desc: '15% less ammo use, +20% ammo found', rarity: 'common', cap: 5, apply: s => { s.ammoEff *= 1.15; s.ammoPickupMul *= 1.20; } },
brassmagazine: { name: 'Brass Magazine', desc: '+10% ammo efficiency, +20% ammo found', rarity: 'common', cap: 5, apply: s => { s.ammoEff += 0.10; s.ammoPickupMul *= 1.20; } },
ironstomach: { name: 'Iron Stomach', desc: '+½ heart container, heal ½ heart', rarity: 'common', cap: 8, apply: (s, p) => { s.maxHp += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } },
tannedhide: { name: 'Tanned Hide', desc: '+8% chance to ignore damage', rarity: 'common', cap: 6, apply: s => { s.armor += 0.08 / 0.92; } },
bloodlust: { name: 'Bloodlust', desc: 'Kills may drop bonus XP', rarity: 'common', cap: 5, apply: s => { s.bloodlust = (s.bloodlust || 0) + 0.12; } },
@@ -43,7 +43,7 @@ const ITEMS = {
spinecage: { name: 'Spine Cage', desc: 'Contact attackers take damage', rarity: 'uncommon', cap: 5, apply: s => { s.thorns += 8; } },
spitewell: { name: 'Spite Well', desc: 'Taking damage releases a blood nova', rarity: 'uncommon', cap: 5, apply: s => { s.retaliate += 10; } },
orbitcrown: { name: 'Orbit Crown', desc: '+25% orbital speed and damage', rarity: 'uncommon', cap: 5, apply: s => { s.orbSpeedMul *= 1.25; s.orbDmgMul *= 1.25; } },
ghoulheart: { name: 'Ghoul Heart', desc: '+2 max HP, heal 2', rarity: 'uncommon', cap: 5, apply: (s, p) => { s.maxHp += 2; p.hp = Math.min(s.maxHp, p.hp + 2); } },
ghoulheart: { name: 'Ghoul Heart', desc: '+4 max HP, heal 4', rarity: 'uncommon', cap: 5, apply: (s, p) => { s.maxHp += 4; p.hp = Math.min(s.maxHp, p.hp + 4); } },
// ---- rare ----
splittongue: { name: 'Split Tongue', desc: 'Twin parallel shot', rarity: 'rare', cap: 3, apply: s => { s.split += 1; } },
@@ -89,6 +89,18 @@ const ITEMS = {
ironlung: { name: 'Iron Lung', desc: 'The first hit each room is blocked', rarity: 'rare', cap: 3, apply: s => { s.ironLung += 1; } },
meathook: { name: 'Meat Hook', desc: 'Kills yank nearby enemies to the corpse', rarity: 'uncommon', cap: 4, apply: s => { s.meatHook += 1; } },
blooddebt: { name: 'Blood Debt', desc: '+35% damage, −½ heart container', rarity: 'rare', cap: 3, apply: (s, p) => { s.dmgMul *= 1.35; s.maxHp = Math.max(1, s.maxHp - 1); p.hp = Math.min(p.hp, s.maxHp); } },
// ---- phase 4: boss-exclusive legendaries (cap 1, curses allowed) ----
butchersoath: { name: "Butcher's Oath", desc: '+80% damage, but max HP set to 2', rarity: 'legendary', cap: 1, apply: (s, p) => { s.dmgMul *= 1.8; s.maxHp = Math.min(s.maxHp, 2); p.hp = Math.min(p.hp, s.maxHp); } },
secondskin: { name: 'Second Skin', desc: 'Revive once per floor at ½ heart', rarity: 'legendary', cap: 1, apply: s => { s.secondSkin += 1; } },
twinsidearm: { name: 'Twin Sidearm', desc: 'Bone Popper double-taps; specials burn 2× ammo', rarity: 'legendary', cap: 1, apply: s => { s.twinSidearm += 1; } },
crimsonmetronome:{ name: 'Crimson Metronome', desc: '+60% fire rate; every 8th shot costs ½ heart', rarity: 'legendary', cap: 1, apply: s => { s.rateMul *= 1.6; s.crimsonMetronome += 1; } },
abattoirengine:{ name: 'Abattoir Engine', desc: 'Pressure rises 2× faster; score and luck ×1.5', rarity: 'legendary', cap: 1, apply: s => { s.abattoirEngine += 1; s.luck += 0.5; } },
gorecrown: { name: 'Gore Crown', desc: 'Free nova on every kill, −25% damage', rarity: 'legendary', cap: 1, apply: s => { s.goreCrown += 1; s.dmgMul *= 0.75; } },
thousandteeth: { name: 'Thousand Teeth', desc: '+6 shards that can crit, −40% base bullet damage', rarity: 'legendary', cap: 1, apply: s => { s.thousandTeeth += 1; s.dmgMul *= 0.6; } },
hollowfather: { name: 'Hollow Father', desc: '3 damage-scaling orbitals, −25% fire rate', rarity: 'legendary', cap: 1, apply: s => { s.orbitals += 3; s.hollowFather += 1; s.rateMul *= 0.75; } },
thelastcut: { name: 'The Last Cut', desc: 'At ½ heart: ×3 damage, +1s immunity', rarity: 'legendary', cap: 1, apply: s => { s.theLastCut += 1; } },
meatgrinder: { name: 'Meat Grinder', desc: '12 dps aura within 90px, +1 damage taken per hit', rarity: 'legendary', cap: 1, apply: s => { s.meatGrinder += 1; } },
};
const ITEM_LEVEL_CAP = 9;
+50 -1
View File
@@ -26,6 +26,9 @@ function initPlayer() {
dmgLiveMul: 1, executeBonus: 0, burnDamageBonus: 0, choirEvery: 0, sawboneCoil: 0,
gluttonGut: 0, slaughterRhythm: 0, painEngine: 0, thresherPlate: 0, bloodMoat: 0,
ironLung: 0, meatHook: 0, bloodDebt: 0,
// phase-4 legendaries
secondSkin: 0, twinSidearm: 0, crimsonMetronome: 0, abattoirEngine: 0,
goreCrown: 0, thousandTeeth: 0, hollowFather: 0, theLastCut: 0, meatGrinder: 0,
maxHp: 6,
},
};
@@ -127,16 +130,23 @@ function updatePlayer(dt) {
} else {
if (Input.mdown && p.fireT <= 0 && p.weapon.ammo > 0) {
fireWeapon(p, w);
// Twin Sidearm: the Bone Popper double-taps
if (st.twinSidearm > 0 && p.weapon.id === 'bonepopper') fireWeapon(p, w);
// Hollow Choir: every 4th shot fires a free extra volley
if (st.choirEvery > 0) {
p.choirCount = (p.choirCount || 0) + 1;
if (p.choirCount >= 4) { p.choirCount = 0; fireWeapon(p, w); }
}
// Crimson Metronome: every 8th shot costs ½ heart
if (st.crimsonMetronome > 0) {
p.metronomeCount = (p.metronomeCount || 0) + 1;
if (p.metronomeCount >= 8) { p.metronomeCount = 0; p.hp = Math.max(0.5, p.hp - 1); spawnText(p.x, p.y - 14, '-½', '#e2472f'); }
}
p.recoil = Math.min(1, p.recoil + (w.behavior === 'slam' ? 1 : 0.45));
p.muzzleT = 0.07;
p.attackT = 0.4; p.actionT = 0;
p.fireT = rate;
if (p.weapon.ammo !== Infinity) p.weapon.ammo -= 1 / st.ammoEff;
if (p.weapon.ammo !== Infinity) p.weapon.ammo -= (st.twinSidearm > 0 ? 2 : 1) / st.ammoEff;
Sfx.shoot(w);
if (w.behavior === 'slam') addShake(5);
}
@@ -182,6 +192,23 @@ function updatePlayer(dt) {
st.rhythmRateBonus = rhythm;
if (p.painEngineT > 0) p.painEngineT -= dt;
st.dmgLiveMul = p.painEngineT > 0 ? 1 + st.painEngine : 1;
// The Last Cut: while at ½ heart, ×3 damage
if (st.theLastCut > 0 && p.lastCutActive && p.hp <= 1) st.dmgLiveMul *= 3;
// Meat Grinder: passive 12 dps aura within 90px
if (st.meatGrinder > 0) {
p.meatGrinderTick = (p.meatGrinderTick || 0) - dt;
if (p.meatGrinderTick <= 0) {
p.meatGrinderTick = 0.5;
for (const e of G.enemies) {
if (e.hp <= 0) continue;
if (dist2(e.x, e.y, p.x, p.y) < (90 + e.r) * (90 + e.r)) {
tickEnemyDamage(e, 6 * st.dmgMul * st.dmgLiveMul);
if (chance(0.3)) spawnBlood(e.x, e.y, angleTo(p.x, p.y, e.x, e.y), 2);
}
}
}
}
// Thresher Plate: passive contact-damage aura (no need to be hit)
if (st.thresherPlate > 0) {
@@ -232,6 +259,8 @@ function hurtPlayer(dmg, ang, attacker) {
return;
}
let reduced = Math.max(1, Math.round(dmg));
// Meat Grinder: aura damages you for +1 per hit taken
if (p.stats.meatGrinder > 0) reduced += p.stats.meatGrinder;
// shield hearts absorb first (from the Shield Heart perk)
if (p.shieldHp > 0) {
const absorbed = Math.min(p.shieldHp, reduced);
@@ -262,7 +291,27 @@ function hurtPlayer(dmg, ang, attacker) {
areaDamage(p.x, p.y, 85 * Math.sqrt(p.stats.sizeMul), p.stats.retaliate * p.stats.dmgMul, true, { noProc: true });
spawnExplosionFx(p.x, p.y, 70);
}
// The Last Cut: at the brink, gain ×3 damage and +1s immunity
if (p.stats.theLastCut > 0 && p.hp > 0 && p.hp <= 1 && !p.lastCutActive) {
p.lastCutActive = true;
p.invT = Math.max(p.invT, 1.0 + p.stats.invBonus);
spawnText(p.x, p.y - 20, 'THE LAST CUT', '#e2472f');
addToast('THE LAST CUT', '×3 damage while at ½ heart');
Sfx.shieldUp();
}
if (p.hp <= 0) {
// Second Skin: revive once per floor at ½ heart
if (p.stats.secondSkin > 0 && !p.secondSkinUsed) {
p.secondSkinUsed = true;
p.hp = 1;
p.invT = 1.5;
G.flash = 0.6;
addShake(10);
spawnText(p.x, p.y - 20, 'SECOND SKIN', '#e2472f');
addToast('SECOND SKIN', 'you refuse to die — once per floor');
Sfx.shieldUp();
return;
}
p.hp = 0;
p.deathT = 0;
gameOver();
+3
View File
@@ -200,6 +200,9 @@ function nextFloor() {
// Shield Heart perk: fresh shield hearts each floor
G.player.shieldHp = G.player.stats.shieldPerk || 0;
if (G.player.shieldHp > 0) { spawnText(G.player.x, G.player.y - 20, 'SHIELD UP', '#3bc9e0'); Sfx.shieldUp(); }
// legendary once-per-floor flags reset
G.player.secondSkinUsed = false;
G.player.lastCutActive = false;
G.player.x = W / 2; G.player.y = H / 2;
addToast('FLOOR ' + G.floor, 'the meat runs deeper here');
Music.requestFloorMusic();
+2
View File
@@ -42,6 +42,8 @@ const SPRITE_MANIFEST = [
'i_deadweight', 'i_cauterizedveins', 'i_hollowchoir', 'i_sawbonecoil', 'i_gluttonsgut',
'i_slaughterrhythm', 'i_painengine', 'i_thresherplate', 'i_bloodmoat', 'i_ironlung',
'i_meathook', 'i_blooddebt',
'i_butchersoath', 'i_secondskin', 'i_twinsidearm', 'i_crimsonmetronome', 'i_abattoirengine',
'i_gorecrown', 'i_thousandteeth', 'i_hollowfather', 'i_thelastcut', 'i_meatgrinder',
// full 64px/128px eight-direction action atlases
'player_sheet', 'player_legs_sheet',
'enemy_shambler_sheet', 'enemy_runner_sheet', 'enemy_spitter_sheet',
+1 -1
View File
@@ -55,7 +55,7 @@ const PRESSURE_DIAL_MIN = -5, PRESSURE_DIAL_MAX = 5;
function pressureGainUnits(dial) { const d = clamp(dial, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX); return d <= 0 ? 1 + d * 0.2 : 1 + d * 0.8; }
// -5 -> 5 units, 0 -> 3 units, +5 -> 0 units (piecewise linear)
function pressureDropUnits(dial) { const d = clamp(dial, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX); return d <= 0 ? 3 - d * 0.4 : 3 - d * 0.6; }
function pressureGain() { return pressureGainUnits(G.pressureDial) * PRESSURE_UNIT; }
function pressureGain() { const base = pressureGainUnits(G.pressureDial) * PRESSURE_UNIT; return base * (G.player && G.player.stats.abattoirEngine > 0 ? 2 : 1); }
function pressureDropScale() { return pressureDropUnits(G.pressureDial) / PRESSURE_DROP_BASE; }
// total player power: perks drafted (level - 1) + item tiers owned, weighted by rarity.