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:
1 parent
4f46d54e99
commit
a93802ebd9
23 files changed
+195
-13
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user