From f9552326c2128f7cc1f0fcfe4ef862287b064de5 Mon Sep 17 00:00:00 2001 From: MeatSlicer Developer Date: Thu, 30 Jul 2026 16:06:20 +1000 Subject: [PATCH] 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. --- assets/i_abattoirengine.png | Bin 0 -> 1629 bytes assets/i_butchersoath.png | Bin 0 -> 1694 bytes assets/i_crimsonmetronome.png | Bin 0 -> 1017 bytes assets/i_gorecrown.png | Bin 0 -> 1652 bytes assets/i_hollowfather.png | Bin 0 -> 1314 bytes assets/i_meatgrinder.png | Bin 0 -> 1459 bytes assets/i_secondskin.png | Bin 0 -> 1551 bytes assets/i_thelastcut.png | Bin 0 -> 1285 bytes assets/i_thousandteeth.png | Bin 0 -> 1987 bytes assets/i_twinsidearm.png | Bin 0 -> 1580 bytes docs/items.md | 14 ++++++++-- js/enemies.js | 23 +++++++++++++++ js/items.js | 16 +++++++++-- js/player.js | 51 +++++++++++++++++++++++++++++++++- js/rooms.js | 3 ++ js/sprites.js | 2 ++ js/state.js | 2 +- test/smoke.js | 40 ++++++++++++++++++++++---- tools/gen_assets.py | 10 +++++++ 19 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 assets/i_abattoirengine.png create mode 100644 assets/i_butchersoath.png create mode 100644 assets/i_crimsonmetronome.png create mode 100644 assets/i_gorecrown.png create mode 100644 assets/i_hollowfather.png create mode 100644 assets/i_meatgrinder.png create mode 100644 assets/i_secondskin.png create mode 100644 assets/i_thelastcut.png create mode 100644 assets/i_thousandteeth.png create mode 100644 assets/i_twinsidearm.png diff --git a/assets/i_abattoirengine.png b/assets/i_abattoirengine.png new file mode 100644 index 0000000000000000000000000000000000000000..90ebe37bf9449c80ea70f9cf40d1ba5617dfd0fc GIT binary patch literal 1629 zcmV-j2BP_iP)T-Xv+0v8?@N%GV6C>} z<@19pUl0KIdIRast@oZAz=^{zH`F8mmTeGMWiknsXjmRvkV&XyNAtY9R^zSqE}4W= ziBikp^?VdE6W+g3(`nbz!nqf#z_0Wz0NVIyt*+AoinSW~bQ-|%WhSVvJ|v}v$o0Au z{(6tPZh3o@Yfb7Mlav|);Jd$A8HAbCeF^WrUVZ8jeDo@22Hb=~5oEXeh`L%vcVNG73r*}68F%?h{d zOII)R%FnL9SOA~D^f@Q1GkF1cl>GGG7aeb#`PQJcr%6s2lI0Mb7`+<0~cuDx-W*?82;62M$3dYQWZ znAV}V*`TG{Y*d;a_{dDKyf3j<+hIL1Ln#nsJu$;OZIeGgSd#$Gzv_Fo5ZzFd{QTQ5 z@y)yI!bP@a8vq0{8T=iSeng>HtrC*M^dpW)`8y_-Z4i>f-d@4%B;Cq3mTh1KWn!_2 zv|9M!ad*N znK%FR7wJd~mKM%2zxZj$OdvPfLqWNRdabE8D76ggx<#>8^C)`wa7}B{*zJ4o?RE## z+9j^aL&D?*5TBnBN@!ffRhd#CNK3c93=bc!88&s@qOMzB*)N=(W^pdf#o08P)`}i{$!g!TSqTY~|tK4fb!;6b-b zAQ&WiQsw;}o#X*6$EbwU4G4ab`Qfi14 zP#zEB@0f(-FexD@M-=jhHgRV_XVmXqR8AOm2M2)&R@+g_q#UMDDn0*3q$x6~%)}5^ zr_u>XCp<<>r4t~8_dPX#8mbNYk_19`MUmeL^T|m{8ZIjZf+v0^onmULIGUdlUY| b{;%v`3B%Ah)Exsp00000NkvXXu0mjfsFWp7 literal 0 HcmV?d00001 diff --git a/assets/i_butchersoath.png b/assets/i_butchersoath.png new file mode 100644 index 0000000000000000000000000000000000000000..37188916f07748b956558d5b072feb9c637fb644 GIT binary patch literal 1694 zcmV;P24VS$P)Fht zw5!xAnzG#|Qnsm8rcy24q^;6~GHBP5bU-r4B*xf)ea!LK@hAA~!tog{rZnBnF8bWq ze$T7-|9_tU`~06{_#zI7cV2!`|9*Amk~V#QO7ji$12BDl>T{iW&hFi}X87d7ZQl@q zo4?}hFMh)-(`UIlb4h!&9!E~84EFbEHm3{c`0)cle(pX3z=er%MtnZ4PZG5Y6XV+C zbI)pTsfm5$g}py6|KxdQu3Xo%26(g{C%LprUuO$UMEWy>8OIqgRfOhu}H(7jlUIzzsaDc)7 z9!>O5?JMW>`6-Tl?_Yeaw?_vLgk#XvhV2FUsO_u3E7NDSO6&ol?IK$% z8QKS(R9qdXZC<)s-8vaLr6RfQ1i~?4er|#3^HW^AG0Wr$Kez5K(I<&~vaV1prb?l8~z6Ww&t`%(k2oYT29C3w4|JN+B;l)T=Od z=9G5r#w?T1J7bMB*uww2GQ5P{NG1k+@>Ap^qOREIt7RY3C0CnJxvlrga zrS@0gotIx^=E`-!d3=EOK_}~(GUaY7@%ex29Z-`)UVf+_aI~{k0}#dPXGl&1U`l34 zDM@`_saOIKg0j+Bg7;p#$!*^df4u$|q2lVmv|gawY0^pT?Sx))wYO_A;`3<$hV%xe zVvT00KKclIEI~A~My7uly4tEFA~CGp z?ErkZm8+A@%dQTd%cL7RXPpmx^ZP&J;u|k<@$&0JLiFHkv9Pt0sg`uTKs2(Zlc~i< zD$A(FSS196wHQ~3T7K8=8}(PCjW&AZ`8_+t5A?l_D%S}mw@soD)z1D#z*DXeT! zZgJp~Tx^84P?R{ioeEO33BlF`J39nrMJShwpE@B8jTYZ4=k@O&EGzUr?WcXvsY|gK z2oh;AA`SHtvRF}+IC8#B0wW(Mt9L+ZHgREM{Lm5IkigeX&2j=)+emuojJz zG@Hn()n{rZ%TZ^QOki6<&X@l;0*^ymeRCMaX%`j)LA?Fl)C{L)wGO+Bjp8mQvl)lo zh16`~xW}rG^^-RDlM#3|wF$u6_anT0KO(FweUGAzuV>1XEglpl&U`o~JU=tS^~-lYJAo0OkHEH~4YY_=ogWXGZJU7v zMoPsJxlFq59GlanOHsCXFcp(*hO=zj3k*pB^&MeN5Qs)L^>@K1@qyYU6KaG@#S#gOINh#>2?Te_`UH%$ o7zsq91nxZ$J_-Is{9nX>0N>&X(cN_!*Z=?k07*qoM6N<$f|Tqvi~s-t literal 0 HcmV?d00001 diff --git a/assets/i_crimsonmetronome.png b/assets/i_crimsonmetronome.png new file mode 100644 index 0000000000000000000000000000000000000000..ee9487209211b8d25f739601cc472824cf4f408c GIT binary patch literal 1017 zcmV~C0n+Gbg&B#L4poNhc1DGW-HJJsQ-Y6jvewBnki%Vc5Kj0 z&6IixiZp1^22G2gbqq&xY18_!NQx43WwL`zN_2u)wwb9ti>`Do&wcNEUY>IV|8ml( zEWdpQR#%p(*Z18@OOQ**R9eFKA{)ccG@DEv9;PQlmY9I2fJ6Z(b?sw0_1CZ3J z-qI}_UE0&ji_}Mx0OS>!)%h%yMw9zbp7Q+Lce&fFh6mt-sSW^+_BWa(h{faV>^A~? z3ZpU$`&+DEnP>6i-^10w+Bc7xz4i%PoeY(hU~*{4_f5~t@qTdxfU+Sd8^Q(nVr()T z0A8Ta^GzgOeKmltQn-+!n28~m+w7K105(J`902O|D*$Z%Q=@DMsth60qrU%_J&T9W z_W9{yg_7H$rbrQh$5ta80Q~~gEJ2pTfWIvz*!$C<|S{K)#9TS&SZ=zIW`MiaBC2ZLo}0Aws@ zU>%&ufOBsnkbkqOBhsU&fApG-+0^lRL`{*XDH89^<_FTji6WGA)dg5}WR|iD*XGB# z*sc?ITI3QkbMX$jgd7xScUEu*DaX;1Zto%NSGkvOx!gu5GM6V5%7!4X$T(FS$Fj)) zx2@gtF|b|fpMshqG02$qMgR-LO}GEXNAn-dM=4C_iyhFB>iSKbyR?cNhknh`+)6A_fsss z6FA&s;J6)sMb8AFKezSzK2Fs}vYz_4e%-;RwdkN5^NNfHjE`SHwhsb_A5Xz)#{TF@ zOd!gf(({X08reSZ1!y*PUKp*2u-oTjpe-dL051%k%}PDs8$PP@zaHAm nbpg5nX3Ju;QjhSz4QT%YR-vEuCAOAW00000NkvXXu0mjf#bxZ9 literal 0 HcmV?d00001 diff --git a/assets/i_gorecrown.png b/assets/i_gorecrown.png new file mode 100644 index 0000000000000000000000000000000000000000..6af38442690999c09c9a57e5cfaebc8a8186bdf7 GIT binary patch literal 1652 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 N002ovPDHLkV1i5)&Dj6| literal 0 HcmV?d00001 diff --git a/assets/i_secondskin.png b/assets/i_secondskin.png new file mode 100644 index 0000000000000000000000000000000000000000..a19929344ffaad7d7181728de24434eda8042954 GIT binary patch literal 1551 zcmV+q2JrcbP)@_jHyoD=Xu>`Z?N#Mh~_u-k4 zNQr}_R#sFwFh&1Ip8-(oZo*-+((JMmT7hMsR6@HJ(|!>H@T(V|VJ4Y-EY1@PSo-T5 z96NQ6FZw@JgL0TV{~5tqk;G;-v*V=$Ec=Se4_8*9ND%<;Pbh#$!Q6l$JvY9Goe7oXhoW%+Y$jt7&}7sUk0?zi^N%PV>P`}XRhE&%2-dF1)j#tdlcZ+6*P_DK}xf?T{l#@Ls4 z);;WMX#&uVXJ)=AIIV~m5AMfVktC|*u~@9-X#k+Lqgi!S6cD8dU(YNtmZI}T<8+=lf~%zoIWVRBL9M%qoymvn*lXgKUHkNw?AU9fU~VAwjVP2=KuR2B z*(Y)F`WV-|Ax1Nc^mQLDnR*&{P!8)_aM-MLo$R7uZXk9y%&nU~T#a=U%njTL$Yc#q zm&br-LQ+L3!jRWbAQlr=Pp4`?eQ*2@IWVRB0>H1WChWPaK6u*hJ>)mWM}Q-dIibtd zSe_MY)(Z&oe2VvbUxCyD2}>;}_t^BDX}^fG&CRr5#NE0JQHl^Bk#JQQ0Z1Qkl8z<0 zDaVAf&pY_|+HiR*Szq^Ibyd;ATkoD$wbknAO9yaN6d0O}qKpTSV@V9@S>5wvcf;6B zMz#jBEVWcoFgHAQ0okwxGxKRd8yvR>t`dsI^`$}sUmMOgw+;@JfNPu7cg4CET=9w= ztgaD8GmA9tw6lLz{Xsd*;?;jMlgv>tH!w4w#+b|F1R8hRxxcz}M3p?3Jj23Ye!E9s zTsOtQ`I>r0GsStZ|M(#tTysGUT)XoChV(3XTk$#?qgmm6O+A13?_^oSXdMGf7hdPp zKmCKQlU=Ij^=l1>qCktzmMbosw|ps~e%IC#3;*8J@XtqI;PVFw-Rb&2`V3_}fHEG? zEw{J5T40rWLwc6c%p#X2@ACWip8IiLOacQRAbEr8-}}F!yTc&>s-lf>)@>#pyOK%XpnCE87+1Wa(B0uE?)09JaIm_DI}dZ@Jt1D*vP}TE z^dLdo8r}RT(bs)g?dv|QE}!05#pi44`8j?_{2#?y;rdBFCu0Br002ovPDHLkV1keZ B|C|5- literal 0 HcmV?d00001 diff --git a/assets/i_thelastcut.png b/assets/i_thelastcut.png new file mode 100644 index 0000000000000000000000000000000000000000..91e47e9546068618c11feec44d0db018c353c6a1 GIT binary patch literal 1285 zcmV+g1^W7lP)+3O?&S- zx8KkCp5HmA@Kpb^ghw?TK7AagB=W{*XKAjj=J&{y3=oP)c)UL0@af~!A3gbG0Fr;a zzV0Icx#fBv(ZC>U9C*ZDfvg7$8d8(tAnArqCer^^3E!X>qN^vYkJpdlBPpCQKqV7!N+5z(R zzRNcsz9`=b^w8CFbCLPbNQ6*C0!W4sJYFB}DkmPVZ&d&(%9pb*XUIFxUtmLGNNrTi z73E|Z0n`KldEfqBocXkxW5!Ku11K&pBGx%h|JQ#}TAa7YTv-7MND_=}E_E_p`~KPh zdfNMh>Ej0g=<2z-pi^@Eig73+5tZUZr8w>?r(A#ZBsE7|Yb(O$+G=@EaD;Ea@8IsS zx0&kb1yJkZVTn9g58HRVKx@m@l_wx}`S|UNb3*XVS1Hr$893pkv^Y=gm?=&yFjf)) zhz16UN^yDL{#~@TT*Y1GTsc6rx0_Sa7(MNMg5~rF@S6#)bra;+xKKbq%ncIyF-p#{LxvhY>H4}X}hN-lWiLLg%oSFL|S_Q zR1iYp`3o>l#qYmgQV4(}&&v4u%W~&0Ba~E>Egt~D-PlHRZ8e8$zTziaE>3$6_VpSf ztvv*zlj`Vo{bOimC(z8^Um>>Hnfd)8Fi6_H+t@8;IpFW4QfFM*{{etIhn%D~`0%(Y zW&5Q#O%vmEZ(7Hk)&x0LH35KOL11WL7)OSgY~w~=E6)euLi-TCqoedkMWIq>q%9#W zoq(d$1|N40IdM0(37zpc#TqS|k(s2!!3sH6Zb{Pw{GCE#YElS{{f#I_=pP*?W;S6d zDZsKpgWY15+Y-{EBvO{D($oeYS8OGG5Df|d_VpS>F`|zCR$u^sFetdIoK)(J%+1aS ze}*R+4GrVJc!OwQkau=gE`T_o&s;tLz)Qiv0*F^8Q`ACK%spNo$_VFXXM{j3K|yL< zor>oib_If_cx-t!kSyi6Jtd8C%(zMJzdJ`$JSKQtmAD(*7WKDV%mDJUANYJe-Y5}GE)IVFuP z*;k`YW2-e?Hk-2`$BNiFzm2!v3=3;cU@0lfPG?A$cW*YR&%=p16(EKGKwC^{Lasq` v|MWS+g^%)|*L%-e}00000NkvXXu0mjf=do1e literal 0 HcmV?d00001 diff --git a/assets/i_thousandteeth.png b/assets/i_thousandteeth.png new file mode 100644 index 0000000000000000000000000000000000000000..3a054d15c939e0a44ba2f0b4586397817d732721 GIT binary patch literal 1987 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 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 }); diff --git a/js/items.js b/js/items.js index 2716373..b7d8377 100644 --- a/js/items.js +++ b/js/items.js @@ -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; diff --git a/js/player.js b/js/player.js index 9f81ece..ce57c85 100644 --- a/js/player.js +++ b/js/player.js @@ -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(); diff --git a/js/rooms.js b/js/rooms.js index 65b0d51..c0d9d3c 100644 --- a/js/rooms.js +++ b/js/rooms.js @@ -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(); diff --git a/js/sprites.js b/js/sprites.js index fe8577e..78c71f2 100644 --- a/js/sprites.js +++ b/js/sprites.js @@ -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', diff --git a/js/state.js b/js/state.js index 7455c34..7506a26 100644 --- a/js/state.js +++ b/js/state.js @@ -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. diff --git a/test/smoke.js b/test/smoke.js index 2850556..e3cd6b6 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 + 12 phase-3 passives)', Object.keys(ctx.ITEMS).length === 72); + check('41 new items added (14 phase-2 + 12 phase-3 + 10 legendaries)', Object.keys(ctx.ITEMS).length === 82); 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', 'deadweight', 'cauterizedveins', 'hollowchoir', 'sawbonecoil', 'gluttonsgut', 'slaughterrhythm', 'painengine', 'thresherplate', 'bloodmoat', 'ironlung', 'meathook', 'blooddebt']; + 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', 'butchersoath', 'secondskin', 'twinsidearm', 'crimsonmetronome', 'abattoirengine', 'gorecrown', 'thousandteeth', 'hollowfather', 'thelastcut', 'meatgrinder']; 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', @@ -367,10 +367,13 @@ console.log('== scrap feed has diminishing returns (like thick hide) =='); check('first stack ~5% saving', Math.abs(s1 - (1 - 1 / 1.05)) < 0.0001); check('each stack helps less', d2 < d1 && d3 < d2); check('saving never reaches 100%', save() < 1); - // Brass Magazine still multiplies on top (unchanged) + // Brass Magazine uses the same diminishing additive formula as Scrap Feed const s2x = { ammoEff: 1, ammoPickupMul: 1 }; ctx.ITEMS.brassmagazine.apply(s2x, {}); - check('brass magazine stays multiplicative', Math.abs(s2x.ammoEff - 1.15) < 0.0001); + check('brass magazine is additive-diminishing (not multiplicative)', Math.abs(s2x.ammoEff - 1.10) < 0.0001); + const s2y = { ammoEff: 1, ammoPickupMul: 1 }; + for (let i = 0; i < 5; i++) ctx.ITEMS.brassmagazine.apply(s2y, {}); + check('brass magazine never reaches 50% saving', 1 - 1 / s2y.ammoEff < 0.5); } console.log('== close-range weapon impact pass =='); @@ -498,6 +501,33 @@ console.log('== tier-scaled items + duplicate favoring =='); check('item rolls favor owned items (' + ownedHits + '/200)', ownedHits >= 40); } +console.log('== phase-4 boss-exclusive legendaries =='); +{ + const p = ctx.G.player; + p.items = {}; + // boss pool is the only source that can roll legendaries + let bossLegendaries = 0; + for (let i = 0; i < 400; i++) if (ctx.ITEMS[ctx.rollItemId('boss', 5)].rarity === 'legendary') bossLegendaries++; + check('boss pool can roll legendaries (' + bossLegendaries + '/400)', bossLegendaries > 10); + // Butcher's Oath: big damage, HP clamped to 2 + const savedMax = p.stats.maxHp, savedHp = p.hp; + p.stats.maxHp = 10; p.hp = 10; + ctx.giveItem('butchersoath'); + check('Butcher\'s Oath clamps max HP to 2', p.stats.maxHp === 2 && p.hp === 2); + p.stats.maxHp = savedMax; p.hp = savedHp; delete p.items.butchersoath; + // Second Skin: revive once per floor at ½ heart + p.stats.secondSkin = 0; p.secondSkinUsed = false; + ctx.giveItem('secondskin'); + p.hp = 1; p.invT = 0; ctx.G.mode = 'play'; + ctx.hurtPlayer(9999, 0); + check('Second Skin revives at ½ heart', p.hp === 1 && p.secondSkinUsed === true && ctx.G.mode === 'play'); + // second death same floor is real + p.invT = 0; + ctx.hurtPlayer(9999, 0); + check('Second Skin only works once per floor', ctx.G.mode === 'gameover'); + ctx.startRun(); step(3, 16); +} + console.log('== phase-3 hook passives =='); { const p = ctx.G.player; @@ -984,7 +1014,7 @@ console.log('== item stickiness ramps with owned count =='); console.log('== help manual grows with the implant roster =='); { - check('10 help pages (4 implant pages for 72 items)', ctx.HELP_PAGES.length === 10); + check('10 help pages (4 implant pages for 82 items)', ctx.HELP_PAGES.length === 10); check('10 help renderers', ctx.HELP_RENDERERS.length === 10); ctx.G.player = ctx.G.player || {}; const fctx = fakeCtx(); diff --git a/tools/gen_assets.py b/tools/gen_assets.py index b774e34..e20c698 100644 --- a/tools/gen_assets.py +++ b/tools/gen_assets.py @@ -291,6 +291,16 @@ ITEMS = { "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", + "butchersoath": "a blood-soaked butcher cleaver crossed with a black oath scroll", + "secondskin": "a pale translucent second skin peeling away to reveal muscle", + "twinsidearm": "two crossed bone pistols fused at the grip with sinew", + "crimsonmetronome": "a crimson metronome with a heart-shaped pendulum and blood drips", + "abattoirengine": "a roaring industrial abattoir engine with pressure gauges and meat grinders", + "gorecrown": "a jagged crown of congealed blood and bone spikes radiating a red aura", + "thousandteeth": "a gaping maw with hundreds of tiny sharp teeth in concentric rings", + "hollowfather": "a gaunt hollow patriarch figure with three orbiting bone halos", + "thelastcut": "a single gleaming cleaver edge with a desperate red gleam", + "meatgrinder": "an industrial meat grinder with spinning blades and gore pouring through", } for iid, subject in ITEMS.items(): SPECS["i_" + iid] = Spec(subject + ", isolated inventory object, strong top-left light, no medallion, no icon background", 32)