diff --git a/assets/i_blooddebt.png b/assets/i_blooddebt.png new file mode 100644 index 0000000..a287384 Binary files /dev/null and b/assets/i_blooddebt.png differ diff --git a/assets/i_bloodmoat.png b/assets/i_bloodmoat.png new file mode 100644 index 0000000..765045f Binary files /dev/null and b/assets/i_bloodmoat.png differ diff --git a/assets/i_cauterizedveins.png b/assets/i_cauterizedveins.png new file mode 100644 index 0000000..27642f4 Binary files /dev/null and b/assets/i_cauterizedveins.png differ diff --git a/assets/i_deadweight.png b/assets/i_deadweight.png new file mode 100644 index 0000000..d7cea7a Binary files /dev/null and b/assets/i_deadweight.png differ diff --git a/assets/i_gluttonsgut.png b/assets/i_gluttonsgut.png new file mode 100644 index 0000000..ec3558b Binary files /dev/null and b/assets/i_gluttonsgut.png differ diff --git a/assets/i_hollowchoir.png b/assets/i_hollowchoir.png new file mode 100644 index 0000000..4cc7035 Binary files /dev/null and b/assets/i_hollowchoir.png differ diff --git a/assets/i_ironlung.png b/assets/i_ironlung.png new file mode 100644 index 0000000..533a9a2 Binary files /dev/null and b/assets/i_ironlung.png differ diff --git a/assets/i_meathook.png b/assets/i_meathook.png new file mode 100644 index 0000000..6870ba5 Binary files /dev/null and b/assets/i_meathook.png differ diff --git a/assets/i_painengine.png b/assets/i_painengine.png new file mode 100644 index 0000000..b3202e0 Binary files /dev/null and b/assets/i_painengine.png differ diff --git a/assets/i_sawbonecoil.png b/assets/i_sawbonecoil.png new file mode 100644 index 0000000..1dbb534 Binary files /dev/null and b/assets/i_sawbonecoil.png differ diff --git a/assets/i_slaughterrhythm.png b/assets/i_slaughterrhythm.png new file mode 100644 index 0000000..3f7ab27 Binary files /dev/null and b/assets/i_slaughterrhythm.png differ diff --git a/assets/i_thresherplate.png b/assets/i_thresherplate.png new file mode 100644 index 0000000..886668c Binary files /dev/null and b/assets/i_thresherplate.png differ diff --git a/docs/items.md b/docs/items.md index 093a01c..400ec3c 100644 --- a/docs/items.md +++ b/docs/items.md @@ -1,6 +1,6 @@ # Items -All **60 passive items** in MeatSlicer, with exact effects pulled from `js/items.js` and the proc logic in `js/enemies.js` / `js/player.js`. +All **72 passive items** in MeatSlicer, with exact effects pulled from `js/items.js` and the proc logic in `js/enemies.js` / `js/player.js`. --- @@ -77,6 +77,18 @@ All of these trigger from your weapon hits (orbital knives proc at half chance). | **Ember Jar** | 20% chance to ignite | `+0.20` chance — burn for 1.8 s at `5 × dmgMul` DPS | | **Chill Gland** | 18% chance to chill | `+0.18` chance — slows enemies (activates the previously unused `slowOnHit` stat) | | **Cinder Sump** | +12% ignite, +12% acid | `igniteChance +0.12`, `acidOnHit +0.12` — dual-element hybrid | +| **Dead Weight** | +40% damage to enemies under 30% HP | `executeBonus +0.40` — checked in `damageEnemy` | +| **Cauterized Veins** | +15% ignite, +25% damage to burning enemies | `igniteChance +0.15`, `burnDamageBonus +0.25` | +| **Hollow Choir** | Every 4th shot fires a free extra volley | `choirEvery +1` — fires a second `fireWeapon` every 4th trigger pull | +| **Sawbone Coil** | Expiring bullets split into 2 shards | `sawboneCoil +1` — on bullet expiry, spawns 2 shards at 40% damage | +| **Glutton's Gut** | Hearts heal +1 extra; overheal becomes score | `gluttonGut +1` — heart heal `2 + tier`, full-HP score `25 + tier × 10` | +| **Slaughter Rhythm** | +4% fire rate per recent kill (cap +40%) | `slaughterRhythm +0.04` — kills stamp `killStamps`; rate bonus decays after 3 s | +| **Pain Engine** | +30% damage for 4s after being hit | `painEngine +0.30` — `dmgLiveMul` rises while `painEngineT` is active | +| **Thresher Plate** | Passive contact-damage aura | `thresherPlate +1` — ticks `6 × tier × dmgMul` on nearby enemies every 0.4 s | +| **Blood Moat** | Kills leave an acid pool | `bloodMoat +1` — on kill, spawns an acid hazard at the corpse | +| **Iron Lung** | The first hit each room is blocked | `ironLung +1` — re-armed by `enterRoom`; blocks one hit with a 0.5 s i-frame | +| **Meat Hook** | Kills yank nearby enemies to the corpse | `meatHook +1` — on kill, nearby enemies are pulled toward the body | +| **Blood Debt** | +35% damage, −½ heart container | `dmgMul ×1.35`, `maxHp −1` — the Rare trade-off item | | **Acid Gland** | Hits may leave acid pools | `+0.12` chance (cap 65%) — pool radius 18 × sizeMul, 2.5 s, `5 × dmgMul` DPS | | **Hook Rounds** | Hits pull enemies toward you | `+0.18` chance (cap 80%) — yanks the enemy toward you at speed 170 | | **Sledge Rounds** | More knockback, hits may stun | `knockbackMul ×1.35`, `+0.08` stun chance (cap 75%) — 0.35 s stun, non-bosses only | @@ -200,6 +212,18 @@ All of these trigger from your weapon hits (orbital knives proc at half chance). | 58 | Volatile Bile | Rare | III | On-kill | | 59 | Whipcord Tendon | Uncommon | V | Damage | | 60 | Worm Gut | Rare | III | Survivability | +| 61 | Blood Debt | Rare | III | Damage | +| 62 | Blood Moat | Uncommon | IV | On-kill | +| 63 | Cauterized Veins | Uncommon | V | On-hit proc | +| 64 | Dead Weight | Uncommon | V | Damage | +| 65 | Glutton's Gut | Common | V | Survivability | +| 66 | Hollow Choir | Rare | III | Shot modifier | +| 67 | Iron Lung | Rare | III | Survivability | +| 68 | Meat Hook | Uncommon | IV | On-kill | +| 69 | Pain Engine | Rare | III | Damage | +| 70 | Sawbone Coil | Uncommon | V | Shot modifier | +| 71 | Slaughter Rhythm | Rare | III | Fire rate | +| 72 | Thresher Plate | Uncommon | V | Survivability | ## Synergy notes diff --git a/js/bullets.js b/js/bullets.js index 20ec078..dd8cf08 100644 --- a/js/bullets.js +++ b/js/bullets.js @@ -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) diff --git a/js/enemies.js b/js/enemies.js index e4f70d0..0b61771 100644 --- a/js/enemies.js +++ b/js/enemies.js @@ -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; diff --git a/js/items.js b/js/items.js index 8381795..2716373 100644 --- a/js/items.js +++ b/js/items.js @@ -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; diff --git a/js/pickups.js b/js/pickups.js index ea7f4ba..c01cfd1 100644 --- a/js/pickups.js +++ b/js/pickups.js @@ -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. diff --git a/js/player.js b/js/player.js index 145c5cc..9f81ece 100644 --- a/js/player.js +++ b/js/player.js @@ -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(); diff --git a/js/rooms.js b/js/rooms.js index f24e330..65b0d51 100644 --- a/js/rooms.js +++ b/js/rooms.js @@ -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; diff --git a/js/sprites.js b/js/sprites.js index 872c26c..fe8577e 100644 --- a/js/sprites.js +++ b/js/sprites.js @@ -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', diff --git a/js/weapons.js b/js/weapons.js index 366e9f0..4b09f78 100644 --- a/js/weapons.js +++ b/js/weapons.js @@ -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; diff --git a/test/smoke.js b/test/smoke.js index 02cb719..2850556 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -318,9 +318,9 @@ step(30, 16); console.log('== expanded perk and item rosters =='); { - check('41 new items added (14 phase-2 passives)', Object.keys(ctx.ITEMS).length === 60); + check('41 new items added (14 phase-2 + 12 phase-3 passives)', Object.keys(ctx.ITEMS).length === 72); const newPerks = ['critbone', 'critmeat', 'flensing', 'ember', 'frostbile', 'heavyhand', 'thickhide', 'secondwind', 'scrapfeed', 'boneknit', 'spiteflesh', 'carrion', 'sinew']; - const newItems = ['chainsinew', 'mortarbone', 'bloatrounds', 'marrowglut', 'hollowneedle', 'bloodshoteye', 'flayerkiss', 'emberjar', 'acidgland', 'hookrounds', 'sledgerounds', 'graftedtrigger', 'deadmanswitch', 'orbitcrown', 'tannedhide', 'deadmansclock', 'hollowbones', 'boneplate', 'wormgut', 'spinecage', 'secondstomach', 'spitewell', 'twinhearts', 'brassmagazine', 'crowbait', 'gorgingleech', 'rerollrib', 'chillgland', 'hookedsinew', 'gyroscopicribs', 'marrowpiston', 'splitcortex', 'gristlecord', 'renderedfat', 'whipcordtendon', 'rusteddiadem', 'gorgedtick', 'bonemealpowder', 'rimedfang', 'butcherstwine', 'cindersump']; + const newItems = ['chainsinew', 'mortarbone', 'bloatrounds', 'marrowglut', 'hollowneedle', 'bloodshoteye', 'flayerkiss', 'emberjar', 'acidgland', 'hookrounds', 'sledgerounds', 'graftedtrigger', 'deadmanswitch', 'orbitcrown', 'tannedhide', 'deadmansclock', 'hollowbones', 'boneplate', 'wormgut', 'spinecage', 'secondstomach', 'spitewell', 'twinhearts', 'brassmagazine', 'crowbait', 'gorgingleech', 'rerollrib', 'chillgland', 'hookedsinew', 'gyroscopicribs', 'marrowpiston', 'splitcortex', 'gristlecord', 'renderedfat', 'whipcordtendon', 'rusteddiadem', 'gorgedtick', 'bonemealpowder', 'rimedfang', 'butcherstwine', 'cindersump', 'deadweight', 'cauterizedveins', 'hollowchoir', 'sawbonecoil', 'gluttonsgut', 'slaughterrhythm', 'painengine', 'thresherplate', 'bloodmoat', 'ironlung', 'meathook', 'blooddebt']; check('all new perks have manifest icons or fallbacks', newPerks.every(id => ctx.SPRITE_MANIFEST.includes('perk_' + id))); check('all new items have manifest icons or fallbacks', newItems.every(id => ctx.SPRITE_MANIFEST.includes('i_' + id))); check('new roster entries all have names, descriptions and effects', @@ -498,6 +498,53 @@ console.log('== tier-scaled items + duplicate favoring =='); check('item rolls favor owned items (' + ownedHits + '/200)', ownedHits >= 40); } +console.log('== phase-3 hook passives =='); +{ + const p = ctx.G.player; + const s = p.stats; + // Dead Weight: execute bonus on wounded enemies (isolate stats so no leftover items leak) + const savedStats = s; + p.stats = Object.assign({}, s, { executeBonus: 0.40, burnDamageBonus: 0, crit: 0, critMul: 2 }); + const wounded = ctx.makeEnemy('shambler', 500, 300, 1, false); wounded.hp = wounded.maxHp = 100; wounded.hp = 20; // 20% of maxHp + const healthy = ctx.makeEnemy('shambler', 500, 300, 1, false); healthy.hp = healthy.maxHp = 100; + const wBefore = wounded.hp, hBefore = healthy.hp; + ctx.damageEnemy(wounded, 10, 0, false, { noCrit: true }); + ctx.damageEnemy(healthy, 10, 0, false, { noCrit: true }); + check('Dead Weight executes wounded enemies', Math.abs((wBefore - wounded.hp) - 14) < 0.01 && Math.abs((hBefore - healthy.hp) - 10) < 0.01); + p.stats = savedStats; + + // Cauterized Veins: bonus damage to burning enemies + const savedBurn = s.burnDamageBonus; + s.burnDamageBonus = 0.25; + const burner = ctx.makeEnemy('shambler', 500, 300, 1, false); burner.burnT = 1; + const bBefore = burner.hp; + ctx.damageEnemy(burner, 10, 0, false, { noCrit: true }); + check('Cauterized Veins boosts damage on burning enemies', Math.abs((bBefore - burner.hp) - 12.5) < 0.01); + s.burnDamageBonus = savedBurn; + ctx.G.enemies.length = 0; + + // Pain Engine: dmgLiveMul rises after being hit + const savedPain = s.painEngine, savedArmor = s.armor, savedMax = s.maxHp; + s.painEngine = 0.30; s.armor = 0; s.maxHp = 999; p.hp = 999; p.invT = 0; p.painEngineT = 0; ctx.G.mode = 'play'; + ctx.hurtPlayer(1, 0); + check('Pain Engine arms after being hit', p.painEngineT > 0); + s.painEngine = savedPain; s.armor = savedArmor; s.maxHp = savedMax; p.invT = 1; p.painEngineT = 0; + + // Iron Lung: first hit in a room is blocked + const savedLung = s.ironLung; + s.ironLung = 1; p.ironLungReady = true; p.invT = 0; const hpBeforeLung = p.hp; + ctx.hurtPlayer(1, 0); + check('Iron Lung blocks the first room hit', p.hp === hpBeforeLung && p.ironLungReady === false); + s.ironLung = savedLung; p.invT = 1; + + // Slaughter Rhythm: recent kills grant fire rate + const savedRhythm = s.slaughterRhythm; + s.slaughterRhythm = 0.04; p.killStamps = [ctx.G.time, ctx.G.time, ctx.G.time]; + p.fireT = 0; ctx.updatePlayer(0.016); + check('Slaughter Rhythm grants live fire rate', (s.rhythmRateBonus || 0) > 0); + s.slaughterRhythm = savedRhythm; p.killStamps = []; +} + console.log('== all weapons fire without crashing =='); for (const wid of Object.keys(ctx.WEAPONS)) { ctx.G.player.weapon = { id: wid, ammo: 50 }; @@ -937,8 +984,8 @@ console.log('== item stickiness ramps with owned count =='); console.log('== help manual grows with the implant roster =='); { - check('9 help pages (3 implant pages for 60 items)', ctx.HELP_PAGES.length === 9); - check('9 help renderers', ctx.HELP_RENDERERS.length === 9); + check('10 help pages (4 implant pages for 72 items)', ctx.HELP_PAGES.length === 10); + check('10 help renderers', ctx.HELP_RENDERERS.length === 10); ctx.G.player = ctx.G.player || {}; const fctx = fakeCtx(); let renderOK = true; diff --git a/tools/gen_assets.py b/tools/gen_assets.py index 8162dc5..b774e34 100644 --- a/tools/gen_assets.py +++ b/tools/gen_assets.py @@ -279,6 +279,18 @@ ITEMS = { "rimedfang": "a long predatory fang crusted with white frost and a steel collar", "butcherstwine": "a spool of blood-stained butcher twine with a frayed loose end", "cindersump": "a smouldering iron sump grate with orange cinders and green acid drips", + "deadweight": "a heavy iron butcher weight crushing a pale bone", + "cauterizedveins": "a forearm cross-section with glowing cauterized orange veins", + "hollowchoir": "a hollow bone pipe organ with three small singing mouths", + "sawbonecoil": "a coiled spring made of interlocking bone saw teeth", + "gluttonsgut": "a distended stomach with a second mouth and spilled half-digested hearts", + "slaughterrhythm": "a metronome with a pendulum made of a bloody cleaver", + "painengine": "a small iron engine with a pressure gauge and a screaming flesh piston", + "thresherplate": "a spiked iron threshing plate bristling with rotating barbs", + "bloodmoat": "a ring-shaped moat of dark blood with a bone drawbridge", + "ironlung": "a riveted iron lung with bellows and a steel windpipe", + "meathook": "a huge rusted slaughterhouse meat hook on a taut chain", + "blooddebt": "a parchment contract signed in blood with a heart seal", } for iid, subject in ITEMS.items(): SPECS["i_" + iid] = Spec(subject + ", isolated inventory object, strong top-left light, no medallion, no icon background", 32)