Phase 3: 12 hook passives with conditional damage, auras, and kill/room effects

- dmgLiveMul live damage channel wired into fireWeapon, sawTick, fireBeam, orbitals
- Dead Weight (execute), Cauterized Veins (burn bonus), Pain Engine, Iron Lung,
  Slaughter Rhythm, Hollow Choir, Sawbone Coil, Glutton's Gut, Thresher Plate,
  Blood Moat, Meat Hook, Blood Debt
- Iron Lung re-armed per room in enterRoom; Slaughter Rhythm stamps kills
- Targeted smoke tests for the five stat-conditional effects; icons + docs
This commit is contained in:
MeatSlicer Developer committed 2026-07-30 15:44:31 +10:00
1 parent 4f46d54e99
commit a93802ebd9
23 files changed
+195 -13

No files matched your search

+14
View File
@@ -89,6 +89,20 @@ function updateBullets(dt) {
if (b.behavior === 'cone' && chance(0.35)) {
G.hazards.push({ kind: 'acid', x: b.x, y: b.y, r: 24 * (b.sizeMul || 1), life: 3.5, t: 0, dps: b.dmg * 1.5 });
}
// Sawbone Coil: bullets split into 2 shards on expiry
if (p.stats.sawboneCoil > 0 && !b.shard && b.behavior !== 'lob' && b.behavior !== 'lob_trap' && b.behavior !== 'lob_swarm') {
for (let k = 0; k < 2; k++) {
const sa = rand(0, TAU);
G.bullets.push({
x: b.x, y: b.y, ang: sa,
vx: Math.cos(sa) * 260 * p.stats.shotSpeedMul, vy: Math.sin(sa) * 260 * p.stats.shotSpeedMul,
r: 3 * p.stats.sizeMul, dmg: b.dmg * 0.4, pierce: 0, bounce: p.stats.bounce,
life: 0.4 * p.stats.rangeMul, t: 0, behavior: 'bullet', sprite: 'bullet_bone',
homing: p.stats.homing ? 1.6 + p.stats.homing * 0.7 : 0, shard: true, sizeMul: p.stats.sizeMul,
});
}
spawnSpark(b.x, b.y, rand(0, TAU));
}
}
// lobbed projectiles trigger on expiry (reached target)
+21
View File
@@ -102,6 +102,10 @@ function damageEnemy(e, dmg, ang, knockback, opts) {
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;
const mitigated = e.dmgTakenMul !== undefined && e.dmgTakenMul < 1;
dmg *= e.dmgTakenMul === undefined ? 1 : e.dmgTakenMul;
e.hp -= dmg;
@@ -222,6 +226,7 @@ function killEnemy(e, ang) {
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);
@@ -236,6 +241,22 @@ function killEnemy(e, ang) {
if (e.boss) { onBossDeath(e); return; }
// 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;
+14
View File
@@ -75,6 +75,20 @@ const ITEMS = {
rimedfang: { name: 'Rimed Fang', desc: '+4% crit chance, +25% crit damage', rarity: 'common', cap: 6, apply: s => { s.crit += 0.04; s.critMul += 0.25; } },
butcherstwine: { name: 'Butcher\'s Twine', desc: '+12% bleed, +8% fire rate', rarity: 'uncommon', cap: 5, apply: s => { s.bleed += 0.12; s.rateMul *= 1.08; } },
cindersump: { name: 'Cinder Sump', desc: '+12% ignite, +12% acid', rarity: 'uncommon', cap: 5, apply: s => { s.igniteChance += 0.12; s.acidOnHit += 0.12; } },
// ---- phase 3 additions (small engine hooks) ----
deadweight: { name: 'Dead Weight', desc: '+40% damage to enemies under 30% HP', rarity: 'uncommon', cap: 5, apply: s => { s.executeBonus += 0.40; } },
cauterizedveins:{ name: 'Cauterized Veins', desc: '+15% ignite, +25% damage to burning enemies', rarity: 'uncommon', cap: 5, apply: s => { s.igniteChance += 0.15; s.burnDamageBonus += 0.25; } },
hollowchoir: { name: 'Hollow Choir', desc: 'Every 4th shot fires a free extra volley', rarity: 'rare', cap: 3, apply: s => { s.choirEvery += 1; } },
sawbonecoil: { name: 'Sawbone Coil', desc: 'Expiring bullets split into 2 shards', rarity: 'uncommon', cap: 5, apply: s => { s.sawboneCoil += 1; } },
gluttonsgut: { name: "Glutton's Gut", desc: 'Hearts heal +1 extra; overheal becomes score', rarity: 'common', cap: 5, apply: s => { s.gluttonGut += 1; } },
slaughterrhythm:{ name: 'Slaughter Rhythm', desc: '+4% fire rate per recent kill (cap +40%)', rarity: 'rare', cap: 3, apply: s => { s.slaughterRhythm += 0.04; } },
painengine: { name: 'Pain Engine', desc: '+30% damage for 4s after being hit', rarity: 'rare', cap: 3, apply: s => { s.painEngine += 0.30; } },
thresherplate: { name: 'Thresher Plate', desc: 'Passive contact-damage aura', rarity: 'uncommon', cap: 5, apply: s => { s.thresherPlate += 1; } },
bloodmoat: { name: 'Blood Moat', desc: 'Kills leave an acid pool', rarity: 'uncommon', cap: 4, apply: s => { s.bloodMoat += 1; } },
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); } },
};
const ITEM_LEVEL_CAP = 9;
+6 -3
View File
@@ -69,11 +69,14 @@ function collectPickup(k) {
gainXP(k.v * p.stats.xpMul);
Sfx.gem();
break;
case 'heart':
if (p.hp < p.stats.maxHp) { healPlayer(2); spawnText(p.x, p.y, '+2', '#d92038'); }
else addScore(25);
case 'heart': {
// Glutton's Gut: hearts heal +1 extra per tier; overheal becomes score
const healAmt = 2 + (p.stats.gluttonGut || 0);
if (p.hp < p.stats.maxHp) { healPlayer(healAmt); spawnText(p.x, p.y, '+' + healAmt, '#d92038'); }
else addScore(25 + (p.stats.gluttonGut || 0) * 10);
Sfx.heart();
break;
}
case 'ammo': {
// Generic rounds refill every finite-ammo weapon the butcher is carrying,
// including a special weapon currently holstered behind the Bone Popper.
+44 -2
View File
@@ -22,6 +22,10 @@ function initPlayer() {
roomHeal: 0, roomHealChance: 0, thorns: 0, retaliate: 0, sizeMul: 1, mortar: 0, frenzy: 0,
orbSpeedMul: 1, orbDmgMul: 1, overShield: 0,
rerolls: 0, rerollPerLevel: 0,
// phase-3 conditional / hook passives
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,
maxHp: 6,
},
};
@@ -100,7 +104,7 @@ function updatePlayer(dt) {
Sfx.syncWeaponLoop(w, sustained && Input.mdown && p.weapon.ammo > 0);
if (p.fireT > 0) p.fireT -= dt;
const frenzyMul = p.frenzyT > 0 ? 1 + st.frenzy : 1;
const effectiveRate = st.rateMul * frenzyMul;
const effectiveRate = st.rateMul * (1 + (st.rhythmRateBonus || 0)) * frenzyMul;
const rate = w.interval / effectiveRate;
if (w.behavior === 'saw') {
@@ -123,6 +127,11 @@ function updatePlayer(dt) {
} else {
if (Input.mdown && p.fireT <= 0 && p.weapon.ammo > 0) {
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); }
}
p.recoil = Math.min(1, p.recoil + (w.behavior === 'slam' ? 1 : 0.45));
p.muzzleT = 0.07;
p.attackT = 0.4; p.actionT = 0;
@@ -154,7 +163,7 @@ function updatePlayer(dt) {
if (e.orbT > 0) continue;
if (dist2(ox, oy, e.x, e.y) < (12 + e.r) * (12 + e.r)) {
e.orbT = 0.35;
damageEnemy(e, 12 * st.dmgMul * st.orbDmgMul, a + Math.PI / 2, true, {
damageEnemy(e, 12 * st.dmgMul * st.dmgLiveMul * st.orbDmgMul, a + Math.PI / 2, true, {
source: 'player', procScale: 0.5, procMagnitudeScale: 0.5,
});
spawnBlood(e.x, e.y, a, 3);
@@ -165,6 +174,30 @@ function updatePlayer(dt) {
// open queued level-ups
if (G.pendingLevelups > 0 && G.mode === 'play') openPerkDraft();
// ---- phase-3 live multipliers & auras ----
// Slaughter Rhythm: +rate per recent kill, Pain Engine: +dmg after being hit
p.killStamps = (p.killStamps || []).filter(t => G.time - t < 3);
const rhythm = st.slaughterRhythm > 0 ? Math.min(p.killStamps.length * st.slaughterRhythm, 0.40) : 0;
st.rhythmRateBonus = rhythm;
if (p.painEngineT > 0) p.painEngineT -= dt;
st.dmgLiveMul = p.painEngineT > 0 ? 1 + st.painEngine : 1;
// Thresher Plate: passive contact-damage aura (no need to be hit)
if (st.thresherPlate > 0) {
p.thresherTick = (p.thresherTick || 0) - dt;
if (p.thresherTick <= 0) {
p.thresherTick = 0.4;
const range = 46 * Math.sqrt(st.sizeMul);
for (const e of G.enemies) {
if (e.hp <= 0 || e.boss) continue;
if (dist2(e.x, e.y, p.x, p.y) < (range + e.r) * (range + e.r)) {
damageEnemy(e, 6 * st.thresherPlate * st.dmgMul * st.dmgLiveMul, angleTo(p.x, p.y, e.x, e.y), true, { noProc: true });
}
}
}
}
// Iron Lung room-block cooldown flag is set in enterRoom
}
function armorBlockChance(rating) {
@@ -183,6 +216,14 @@ function pressureRelief(dmgTaken) {
function hurtPlayer(dmg, ang, attacker) {
const p = G.player;
if (p.invT > 0 || p.hp <= 0 || G.mode !== 'play') return;
// Iron Lung: the first hit each room is blocked
if (p.ironLungReady) {
p.ironLungReady = false;
p.invT = 0.5; p.hitT = 0.1;
spawnText(p.x, p.y - 20, 'IRON LUNG', '#9fb4bd');
addShake(2); Sfx.shieldUp();
return;
}
if (p.stats.armor > 0 && chance(armorBlockChance(p.stats.armor))) {
p.invT = 0.2;
p.hitT = 0.1;
@@ -201,6 +242,7 @@ function hurtPlayer(dmg, ang, attacker) {
p.hp -= reduced;
p.hitT = 0.18;
p.invT = 0.9 + p.stats.invBonus;
if (reduced > 0 && p.stats.painEngine > 0) p.painEngineT = 4;
G.flash = 0.35;
addShake(7);
Sfx.hurt();
+2
View File
@@ -67,6 +67,8 @@ function enterRoom(gx, gy) {
// spawn protection: brief invincibility walking into a fresh room
if (G.player) G.player.invT = Math.max(G.player.invT, 1.0);
// Iron Lung: re-arm the once-per-room hit block
if (G.player && G.player.stats.ironLung > 0) G.player.ironLungReady = true;
if (!room.visited) {
room.visited = true;
+3
View File
@@ -39,6 +39,9 @@ const SPRITE_MANIFEST = [
'i_chillgland', 'i_hookedsinew', 'i_gyroscopicribs', 'i_marrowpiston', 'i_splitcortex',
'i_gristlecord', 'i_renderedfat', 'i_whipcordtendon', 'i_rusteddiadem', 'i_gorgedtick',
'i_bonemealpowder', 'i_rimedfang', 'i_butcherstwine', 'i_cindersump',
'i_deadweight', 'i_cauterizedveins', 'i_hollowchoir', 'i_sawbonecoil', 'i_gluttonsgut',
'i_slaughterrhythm', 'i_painengine', 'i_thresherplate', 'i_bloodmoat', 'i_ironlung',
'i_meathook', 'i_blooddebt',
// full 64px/128px eight-direction action atlases
'player_sheet', 'player_legs_sheet',
'enemy_shambler_sheet', 'enemy_runner_sheet', 'enemy_spitter_sheet',
+3 -3
View File
@@ -43,7 +43,7 @@ function fireWeapon(p, w) {
const ang = p.aim;
const muzzle = w.muzzle || 35;
const bx = p.x + Math.cos(ang) * muzzle, by = p.y + Math.sin(ang) * muzzle;
const dmgMul = st.dmgMul;
const dmgMul = st.dmgMul * st.dmgLiveMul;
const spdMul = st.shotSpeedMul;
spawnMuzzleFx(bx, by, ang, w.behavior);
@@ -144,7 +144,7 @@ function sawTick(p, def, inst, dt) {
for (const e of G.enemies) {
if (dist2(cx, cy, e.x, e.y) < (radius + e.r) * (radius + e.r)) {
const tickScale = dt / Math.max(0.01, def.interval || 0.1);
damageEnemy(e, def.dmg * st.dmgMul * st.rateMul * inert * tickScale, a, false, {
damageEnemy(e, def.dmg * st.dmgMul * st.dmgLiveMul * st.rateMul * inert * tickScale, a, false, {
source: 'player', procScale: tickScale, procIntervalScale: tickScale,
});
hitAny = true;
@@ -166,7 +166,7 @@ function fireBeam(p, def, charge) {
for (let i = 1; i <= st.rear; i++) angles.push(p.aim + Math.PI + (i - (st.rear + 1) / 2) * 0.18);
const len = 900 * st.rangeMul;
const inert = 1 + (st.pierce + st.bounce + st.homing) * 0.06;
const dmg = def.dmg * st.dmgMul * (0.4 + 0.6 * charge) * inert;
const dmg = def.dmg * st.dmgMul * st.dmgLiveMul * (0.4 + 0.6 * charge) * inert;
const width = 14 * Math.sqrt(st.sizeMul);
for (const ang of angles) {
const x2 = p.x + Math.cos(ang) * len, y2 = p.y + Math.sin(ang) * len;