From 4f46d54e99206bb95477c090ba7a71a612c32534 Mon Sep 17 00:00:00 2001 From: MeatSlicer Developer Date: Thu, 30 Jul 2026 15:21:02 +1000 Subject: [PATCH] Item system overhaul: rarity bands, per-item caps, source pools + 14 new passives - Add ITEM_RARITY (common/uncommon/rare/legendary) with per-item tier caps - Weighted pools per source: elites favour common, item rooms uncommon, bosses rare/legendary; legendaries are boss-exclusive; capped items excluded - Rarity-weighted powerScore so deep common stacks inflate difficulty less - Differentiate duplicates: Grafted Trigger, Crow Bait, Hollow Bones, Twin Hearts - 14 new clean passives incl. Chill Gland (activates slowOnHit) and Hooked Sinew (activates magnetPull); revive magnetPull in pickups - Rarity-tinted pedestal glow + nameplates, rarity-sorted HUD implant tray, dynamic help implant pages (8 -> 9) - Generate 14 item icons via gen_assets.py; bump asset cache to v=38 - Update smoke tests for 60 items, per-item caps, pools, weighted power --- assets/i_bonemealpowder.png | Bin 0 -> 1492 bytes assets/i_butcherstwine.png | Bin 0 -> 1393 bytes assets/i_chillgland.png | Bin 0 -> 1700 bytes assets/i_cindersump.png | Bin 0 -> 1454 bytes assets/i_gorgedtick.png | Bin 0 -> 1688 bytes assets/i_gristlecord.png | Bin 0 -> 1374 bytes assets/i_gyroscopicribs.png | Bin 0 -> 2046 bytes assets/i_hookedsinew.png | Bin 0 -> 972 bytes assets/i_marrowpiston.png | Bin 0 -> 1021 bytes assets/i_renderedfat.png | Bin 0 -> 1532 bytes assets/i_rimedfang.png | Bin 0 -> 1127 bytes assets/i_rusteddiadem.png | Bin 0 -> 1319 bytes assets/i_splitcortex.png | Bin 0 -> 2072 bytes assets/i_whipcordtendon.png | Bin 0 -> 1681 bytes docs/items.md | 164 +++++++++++++++++++------------ index.html | 42 ++++---- js/bosses.js | 2 +- js/enemies.js | 2 +- js/help.js | 33 ++++--- js/hud.js | 4 +- js/items.js | 189 ++++++++++++++++++++++++------------ js/pickups.js | 9 +- js/rooms.js | 2 +- js/sfxbank.js | 2 +- js/sprites.js | 5 +- js/state.js | 4 +- test/smoke.js | 71 ++++++++++---- tools/gen_assets.py | 14 +++ 28 files changed, 356 insertions(+), 187 deletions(-) create mode 100644 assets/i_bonemealpowder.png create mode 100644 assets/i_butcherstwine.png create mode 100644 assets/i_chillgland.png create mode 100644 assets/i_cindersump.png create mode 100644 assets/i_gorgedtick.png create mode 100644 assets/i_gristlecord.png create mode 100644 assets/i_gyroscopicribs.png create mode 100644 assets/i_hookedsinew.png create mode 100644 assets/i_marrowpiston.png create mode 100644 assets/i_renderedfat.png create mode 100644 assets/i_rimedfang.png create mode 100644 assets/i_rusteddiadem.png create mode 100644 assets/i_splitcortex.png create mode 100644 assets/i_whipcordtendon.png diff --git a/assets/i_bonemealpowder.png b/assets/i_bonemealpowder.png new file mode 100644 index 0000000000000000000000000000000000000000..64324105c3e95579a41d8fe7331e94b6ec74521d GIT binary patch literal 1492 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 IM6N<$f;w2tU;qFB literal 0 HcmV?d00001 diff --git a/assets/i_gorgedtick.png b/assets/i_gorgedtick.png new file mode 100644 index 0000000000000000000000000000000000000000..ee7654d1e67a75b108725528afad9fb40d0f67f8 GIT binary patch literal 1688 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 literal 0 HcmV?d00001 diff --git a/assets/i_hookedsinew.png b/assets/i_hookedsinew.png new file mode 100644 index 0000000000000000000000000000000000000000..031379ad945c364bb206c8e5e6927fa3c8981705 GIT binary patch literal 972 zcmV;-12g=IP)Y}z6osGhN1QkW*LIvHPD7iTh!9r|sv00fWr4CmayMa9sTD#+-5?>lsgU>s73vR& z5J(7B2q9?1BJ8Rvd{mGIr6j~|)p+u0$B8}T@r<1bjvc=&j0dr+qs$8K@|`;~=bm%! zy)*D%59w(WwfHn2-M!C5Z-`T_ns~C97MLw4v*16L0C;7}u5nh=c?5XOle09{_OC)y~4Mai}@aln1ub z8ytVJgIYF;I;#V)DXJ{A)d-k1`vEk}ww^Cj8fXQePjV43YvjES`vGWL4uJl_sCAYO zH~|jLfUBA0*9`}yU}El|4=dJe=rovni@-%$YhPUZ3cbT$TYF)ys+|O|%D@+Ewjg;M}W20KD;TjF-nciMZUvouW~>1TeL$ za>Lhcj|bk7b!I2tB&Zdr0W+07dc00loXCoT7N543mMsj7t|z!uS`n*{<9s@wrMvGL zhW8xom-nrl-yS^?jg4tb8{YYOn^dvF==c~3)ohqh(&E$jqcM{521;U8*djrs)6coH zgG9ms?y0+V?Y&N-W=;5`u|w^FrWhDMJ18RI0P z1gifS>xw~X*PzAIf>|`A+0874cpXqWr$cX-eI5vE1+My~Tqh;FM1J1-17rUPRWl)%fm)M}WkB&hg3-nDsjTl70F+X3 z?^i{o7&CH|MyE%wZ_==?J)HgORmirk2%NyEmJ0=EX0gb(pD(lhtjzN#&pE%oW_n_R zJypH&_l5le*CI0O8y_(vN5_0%D^oo>0W`ZQwM5S10PgR3g;KwgMVSO&+ zj0~91roFw?q(BnaguTA$lu~hLeJ(?w#}IHHZ>~|PU!ZCxDOn;Ip+A((Cs<=U2P#Ykq1HYR%wtKx($%e86mQ{O3NUY=U?^ z?Wu5n_BtVRncxYi;@z=zwQKXs(_Px-DjS6;vr_`$q)1{ggj(}o_lH1RQ0>}eb9rz5 z(+i#74|G-*N?g1)W4zlBmO$aH`MyzbCC}#aOwUEpE^QusyojoqoOUk}ECXF{-A)U_ zNs(=}N7!tjY9{ArJs!@>l(Grh*|F2>n; zIiT9zeRF@*TL|IFTZm%ra5pJpM+7o&`C&wARBkHT!M0#@3mOfcY%cE^F`N`RbN(K~ zN2OS>3Y>;QJo@HS0L&-b42hvE6;U;l3V@8j&MX%@qciI$BLdFU9ZGRUp@l*l+R*$Xr)`>IlQy&+(`}pNc#-Bap+RxRJ9)3p zm+$1f&-1*``+YAwj6Zrv_uFWH|DT-r>znE4db3igEe2qCYM#*SBF54pzK(|3L!0GW7eZ;fKqt1*~#ghwX{MP|g_@YFm2q1i?0tY-{>BgZ#lc;kl$ z1|sES{=Vxb@69W;9%@zqMz5SFJUW?97Ma1QSLXo90wbNcCJP{+9P9t>0AhpZ&^Vm< zPQR;^RolhyQQr{VpMAPaiQ%bv@{~t9aZTpP@r_6~Onm&Ga}NgKtJnWRQ=60TPo1Nw z&8cMXMSVl!dGyM8daeg^*r(KMQO%4~sVx>j&U(hg_y4};@w{bGWiOiAoM;?QY;{#i zKD612G;XobxW$4|uclI4jA~{ai`^l7VW?O~d%am{uQw|trE1=pzQgXL)yuwFn9W|} zaPskyhMYv%>|==p0H$p%VsEa*5A-rT6(Kd@C#7DSX11h8Frnq;8kEp%sdVhKH8*cVu;&=jU$jV`T|LBH4!OXXB*)8;gi;5$#`XJe|#ctmNhHxrs& zl(NlvuLqQ-Hs>$8Hls#?rHWf$c5w4jmng|xiJ4V#S@J1q+_nc%!*nYj{ysAFO6}TPkW~6^97azu+>$G zUjNP$VMmP~P0dzpbyWh4rm#?`penBt|C1Bx1m2}@t%}RSsPd9%BgHGB*p6!EG|{=m zxx5fJ2Gmug_WI6ThTMk@qc}TN=ear zsF`P<*^Wv{A&-s|9_Xhu8Dso*l$in*I;)jr(2tx*pY8>E9bImj-fyo^ZMN`ZxsIqi zNT(+tEzbWx|3P&7)Owz18^BtoQCjNk9C&&o03UvGmWlWxZGUQ@^-#0$M{sD6N^LQX zTP!5&_hK$g2>=UjFJ~_HGaQ|vtk%NniAnl=0lG&gr1g7@40ML_U&p(TR&(K`4{Mo* zT3v~ukn~9s3y2Qz5u{+p&aF0kd=p6+h+r$|I8!FZTjX;`8jj3pYyIE0@0YXzovrB%=nuD+zY00M;ZV4 zuSZP6;js~ABEE=5qo-*8E`Mv?z_m~Tr@py*U)g_`Sii@Zv)*4*!7IDBD{sHFPuX8n ip=4V7VLXgqi=P12ysKpP45$$R0000@yv-Ta4$iHmh#Fn6ssMF%Y%7f1o=oV;evd;Y+oG)j$DL!m~UsJ=*Z7yw@|Or}4?LNrQ=5a}Po zgT7^Ivjy|xc5-bo)8*Qnm~C@dXP4M;EfP`ZP%3LNj#|>^fq4JBJ1!*38Z_Ae@CL%n zkJ|x=sB_qKyIECbON%a7`W1*rMR^JBZD)D6xsgo!6sr5{0C8|iW$oTdrUL4-^m2D% zYR9+u>{nNo1JFMjWX0p%v--zYz-Q@wXsyO)KXlK^$(O59>&lqPQpLz*WYN;0PoDzu zs(o8q5B*!ti%o*t6Tmqa+kQi90l4T$p90%1orD1us~wx-q_kKAK=#rynf?&Y(7Kc$ zkuCxcwqHW}9k=6++xRVZMyyI~3N|;WYp8wXnMo7qGjsyb-gb6J|F@4}xFDa<(*;D- zIZ{i9p4)?W(nla3H;jMmkpEl5Zv;#x)VeaffiRnHw=`}ZW4+y#+(Jtx>2LWs_01ol zmu0qt_YVvcFqzO6mBcpS&nHZjA#%hyN1>e&HtzB(= zcB+9Zz5M_pbkEB4q=lnZI!3o7{v4i^W~?^$1D=!#KRWZjQRcC*>ZHqH0f_A8-{mVVJgwN|JadMF+WX~B9C51~PjARa6T)?B30 zL%b9$TIit)Vw8#{m`DvO&1chSH%T{}Y*Kb7*`3|YX1CjPJWO`FlceC$LmpUW-r4tk zpXdL~f1Ve%u!Sw;;Fp`9Z#wSo>d?-fY1IG@9XGJ$_`< zIa%c84qkifeb$e=yE+KWMHm|x1EBF#8i0YCJ^!hw&#ruA8h2l~WEvM_Z#vuC0JN?v z*M)4Q?X?=e{rm?rRvV92R{?O^-k`I+nIl)O)7{mfegEqSbtexCi8X;AxXK7c6rynz zfc8(mG!b^Tx6ymGhrg1FS-^>7`?=kB7l7z^4*>3_Ryy0;w3*3y%JTE^>}%G#FI*CG z1e>e6(W5_)=$ki4Zta^JJ;K>DtpI%YV?TiAX{;0&)#nJg%0~KzwOga(0>A->i+4Ug zuiY7%WN;#2kQEg=0Pt0L0gx3HLSd-zBDq8W{Ig-)3DsDW-E(`>Zcc40)@J0maBFnj z>_8}@XaE+gji0W6%H&i~yE8P&*uWT;To2a-800y~it(mngry4IMW;yTu@(*BhyD>^ z#%d!Po!Zy~06{^Zyt-1mcC(kki2&(Ph(gh6{9kQ*LO2+}-P~j%UQm|F2m(cg4%}Xfg%oGCfF(SiBUBX;hrNB+mJH|E}5ygj8* zTUHE!r>+Em%V~{o)2q0xYt<%HEZ`%lz!>g9dR zJ@!*wUaEgJ>zh!%{}5?U9g<6=cl06W>@E{wE(YJ+3-{+t&6~w$P5_KQnB}Ql%tCCM z8CBgpinA3(i$pGpnyGr@e5k3UKa$wcSm#%i4Rf=&oYt6{U&0bgu&lcl3=877->%(vZCsd8w}ueSjg1x;JC*@(~c^m z>d_|x%FqzOQ~{4xS1C;C!l&e@U=q+ExsmJ^&3E)5$;K*tN}Q3tVQM5Ns@FqBsf$oV zK~_}L`;W#|BlV(1qN#GP93q%{_Mv43lq8=D*|R{%U0|`=cy(ukw(nRAewP!U5+@W< zNH$iHy!*G&eE!+65i|?;rvbkVLkD41)LT=T>bMJ?xt39`vcGF6kDw du!a8>{sqRyH!LWz9{>OV002ovPDHLkV1m2Gfwlku literal 0 HcmV?d00001 diff --git a/assets/i_splitcortex.png b/assets/i_splitcortex.png new file mode 100644 index 0000000000000000000000000000000000000000..888ac9ceec8bab0c91db3c5f95d64e975071bf25 GIT binary patch literal 2072 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 b<#YKL>>tY?i#&rO00000NkvXXu0mjf927mS literal 0 HcmV?d00001 diff --git a/docs/items.md b/docs/items.md index 6576ac1..093a01c 100644 --- a/docs/items.md +++ b/docs/items.md @@ -1,23 +1,37 @@ # Items -All **46 passive items** in MeatSlicer, with exact effects pulled from `js/items.js` and the proc logic in `js/enemies.js` / `js/player.js`. +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`. --- ## How items work - Items are **passive and permanent** for the run. Pick one up and its effect applies immediately. -- **Duplicates upgrade the tier** (shown as Roman numerals **I–IX** on the pedestal and in your item tray). Each duplicate re-applies the item's effect — multiplicative stats compound, additive stats stack linearly. -- **Tier cap is 9.** Picking up a duplicate at tier IX instead grants +150 score and 5 XP gems with a "MAX" toast. -- There is **no rarity system**. Item rolls use a "smart roll": 50% chance to upgrade an item you already own (below cap), 50% chance of any item from the full pool — so builds naturally specialize over a run. +- **Duplicates upgrade the tier** (shown as Roman numerals on the pedestal and in your item tray). Each duplicate re-applies the item's effect — multiplicative stats compound, additive stats stack linearly. +- **Tier caps are per-item.** Simple stat items stack deep (up to VIII); build-defining and legendary items cap at III or I. Picking up a duplicate at cap instead grants +150 score and 5 XP gems with a "MAX" toast. +- **Every item has a rarity band** — Common, Uncommon, Rare, or Legendary — which drives both its drop weight and how much it inflates enemy scaling (see below). +- Item rolls use a "smart roll": a small chance to upgrade an item you already own (below cap), otherwise a weighted roll from the source's pool. Builds naturally specialize, but rares stay rare. + +### Rarity bands + +| Band | Base weight | Power/tier | Typical cap | Colour | +|---|---|---|---|---| +| Common | 100 | 0.5 | V–VIII | bone | +| Uncommon | 55 | 1.0 | IV–VI | teal | +| Rare | 25 | 1.5 | II–III | gold | +| Legendary | 8 | 3.0 | I | red | + +**Power score** (drives enemy HP/speed scaling) = perks × 0.5 + Σ(item tier × band power). A deep stack of commons inflates difficulty far less than a few rares. ### Where items come from -| Source | Chance | -|---|---| -| Item room pedestal | Guaranteed (1 per floor) | -| Boss kill | Guaranteed (1 pedestal) | -| Elite enemy kill | 18% + luck × 10% | +| Source | Chance | Pool tilt | +|---|---|---| +| Item room pedestal | Guaranteed (1 per floor) | Favours Uncommon / Rare | +| Boss kill | Guaranteed (1 pedestal) | Favours Rare / **Legendary** (boss-only) | +| Elite enemy kill | 18% + luck × 10% | Favours Common | + +Legendary items are **boss-exclusive** — they never roll from item rooms or elites. Items already at their tier cap are excluded from fresh rolls, so pedestals don't waste themselves. --- @@ -29,7 +43,11 @@ All **46 passive items** in MeatSlicer, with exact effects pulled from `js/items | **Marrow Glut** | +20% damage, −10% fire rate | `dmgMul ×1.20`, `rateMul ×0.90` | | **Bloat Rounds** | +25% attack size, +10% damage | `sizeMul ×1.25`, `dmgMul ×1.10` | | **Twitch Fibers** | +20% fire rate | `rateMul ×1.20` | -| **Grafted Trigger** | +18% fire rate | `rateMul ×1.18` | +| **Grafted Trigger** | +18% fire rate, +6% shot speed | `rateMul ×1.18`, `shotSpeedMul ×1.06` | +| **Bonemeal Powder** | +15% damage, +15% shot speed | `dmgMul ×1.15`, `shotSpeedMul ×1.15` | +| **Marrow Piston** | +30% knockback, +12% attack size | `knockbackMul ×1.30`, `sizeMul ×1.12` | +| **Whipcord Tendon** | +20% range, +8% fire rate | `rangeMul ×1.20`, `rateMul ×1.08` | +| **Butcher's Twine** | +12% bleed, +8% fire rate | `bleed +0.12`, `rateMul ×1.08` | Note: attack size (`sizeMul`) also enlarges melee arcs, blast radii, acid pools, and projectile hitboxes — Bloat Rounds is secretly an AoE item. @@ -41,6 +59,8 @@ Note: attack size (`sizeMul`) also enlarges melee arcs, blast radii, acid pools, | **Lead Marrow** | +25% range | `rangeMul ×1.25` | | **Piercing Gaze** | Shots pierce +1 enemy | `pierce +1` | | **Ricochet Ribs** | Shots bounce +1 time | `bounce +1` (wall bounces) | +| **Gyroscopic Ribs** | +2 bounce, +10% range | `bounce +2`, `rangeMul ×1.10` | +| **Split Cortex** | +1 pierce, +1 chain | `pierce +1`, `chain +1` — Rare hybrid projectile modifier | | **Split Tongue** | Twin parallel shot | `split +1` — extra parallel shots per volley | | **Hydra Maw** | Fires 2 extra angled shots | `fan +1` — extra angled shots (±0.16 rad per level) | | **Homing Tumor** | Shots seek flesh (stronger per tier) | `homing +1` — homing strength `1.6 + homing × 0.7` | @@ -55,6 +75,8 @@ All of these trigger from your weapon hits (orbital knives proc at half chance). |---|---|---| | **Flayer Kiss** | Hits inflict heavy bleeding | `bleed +0.20` — every hit applies 2.2 s bleed at `hit damage × bleed × 0.45` DPS | | **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 | | **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 | @@ -76,7 +98,9 @@ All of these trigger from your weapon hits (orbital knives proc at half chance). |---|---|---| | **Iron Stomach** | +½ heart container, heal ½ heart | `maxHp +1`, heal 1 | | **Ghoul Heart** | +2 max HP, heal 2 | `maxHp +2`, heal 2 | -| **Twin Hearts** | +1 max heart, heal 1 heart | `maxHp +2`, heal 2 (identical to Ghoul Heart) | +| **Twin Hearts** | +1 max heart, heal to full | `maxHp +2`, `hp = maxHp` — the premium Rare HP item | +| **Rendered Fat** | +½ heart, +4% armor | `maxHp +1`, `armor +0.04`, heal 1 — clean hybrid HP/armor | +| **Gristle Cord** | +0.12s hurt immunity, +4% armor | `invBonus +0.12`, `armor +0.04` | | **Bone Plate** | +½ shield heart each floor | `shieldPerk +1`, `shieldHp +1` — shields refill every floor (1 shield HP = ½ heart, renders as half a cyan pip) | | **Second Stomach** | +½ heart and excess healing shields | `maxHp +1`, heal 1, `overShield +1` — overflow healing becomes shield HP | | **Tanned Hide** | +8% chance to ignore damage | `armor +0.087` — block chance `armor / (1 + armor)`, cap 75% | @@ -99,65 +123,83 @@ All of these trigger from your weapon hits (orbital knives proc at half chance). | **Orbital Knives** | +1 circling knife | `orbitals +1` — each knife orbits you, dealing `12 × dmgMul × orbDmgMul` on contact (0.35 s cooldown per enemy, half proc chance) | | **Orbit Crown** | +25% orbital speed and damage | `orbSpeedMul ×1.25`, `orbDmgMul ×1.25` | | **Lucky Coin** | Better drops | `luck +0.2` | -| **Crow Bait** | +25% drop luck | `luck +0.25` | +| **Crow Bait** | +15% drop luck, +25% pickup radius | `luck +0.15`, `magnet ×1.25` | | **Magnet Maw** | +60% pickup radius | `magnet ×1.6` — magnet radius 46 × magnet px | | **Gorging Leech** | +20% XP gained | `xpMul ×1.20` | | **Reroll Rib** | +1 perk reroll each level | `rerollPerLevel +1` and +1 reroll immediately | | **Brass Magazine** | 15% less ammo use, +20% ammo found | `ammoEff ×1.15`, `ammoPickupMul ×1.20` (the ×1.15 stays multiplicative; the **Scrap Feed** perk's additive stacks sit underneath it) | -| **Hollow Bones** | +12% move speed | `speedMul ×1.12` (178 → ~199 px/s at tier I) | +| **Hollow Bones** | +14% move speed | `speedMul ×1.14` (178 → ~203 px/s at tier I) | +| **Gorged Tick** | +15% XP, +10% luck | `xpMul ×1.15`, `luck +0.10` | +| **Hooked Sinew** | +30% pickup pull speed, +10% radius | `magnetPull ×1.30`, `magnet ×1.10` (activates the `magnetPull` stat) | +| **Rusted Diadem** | +40% orbital speed | `orbSpeedMul ×1.40` | +| **Rimed Fang** | +4% crit chance, +25% crit damage | `crit +0.04`, `critMul +0.25` | --- ## Complete item index (A–Z) -| # | Item | Category | -|---|---|---| -| 1 | Acid Gland | On-hit proc | -| 2 | Backstabber | Shot modifier | -| 3 | Bloodlust | On-kill | -| 4 | Bloodshot Eye | Criticals | -| 5 | Bloat Rounds | Damage | -| 6 | Bone Plate | Survivability | -| 7 | Brass Magazine | Utility | -| 8 | Chain Sinew | On-hit proc | -| 9 | Crow Bait | Utility | -| 10 | Dead Man's Clock | Survivability | -| 11 | Dead Man's Switch | On-kill | -| 12 | Ember Jar | On-hit proc | -| 13 | Flayer Kiss | On-hit proc | -| 14 | Ghoul Heart | Survivability | -| 15 | Gorging Leech | Utility | -| 16 | Grafted Trigger | Fire rate | -| 17 | Hollow Bones | Utility | -| 18 | Hollow Needle | Criticals | -| 19 | Hollow Points | Damage | -| 20 | Homing Tumor | Shot modifier | -| 21 | Hook Rounds | On-hit proc | -| 22 | Hydra Maw | Shot modifier | -| 23 | Iron Stomach | Survivability | -| 24 | Lead Marrow | Shot modifier | -| 25 | Lucky Coin | Utility | -| 26 | Magnet Maw | Utility | -| 27 | Marrow Glut | Damage | -| 28 | Mortar Bone | On-hit proc | -| 29 | Orbit Crown | Utility | -| 30 | Orbital Knives | Utility | -| 31 | Piercing Gaze | Shot modifier | -| 32 | Reroll Rib | Utility | -| 33 | Ricochet Ribs | Shot modifier | -| 34 | Scalpel | Shot modifier | -| 35 | Second Stomach | Survivability | -| 36 | Sledge Rounds | On-hit proc | -| 37 | Spine Cage | Survivability | -| 38 | Spite Well | Survivability | -| 39 | Splinter Bone | Shot modifier | -| 40 | Split Tongue | Shot modifier | -| 41 | Tanned Hide | Survivability | -| 42 | Twin Hearts | Survivability | -| 43 | Twitch Fibers | Fire rate | -| 44 | Vampire Dentures | On-kill | -| 45 | Volatile Bile | On-kill | -| 46 | Worm Gut | Survivability | +| # | Item | Rarity | Cap | Category | +|---|---|---|---|---| +| 1 | Acid Gland | Uncommon | VI | On-hit proc | +| 2 | Backstabber | Rare | III | Shot modifier | +| 3 | Bloodlust | Common | V | On-kill | +| 4 | Bloodshot Eye | Common | VIII | Criticals | +| 5 | Bloat Rounds | Uncommon | V | Damage | +| 6 | Bone Plate | Rare | III | Survivability | +| 7 | Bonemeal Powder | Common | VI | Damage | +| 8 | Brass Magazine | Common | V | Utility | +| 9 | Butcher's Twine | Uncommon | V | Fire rate | +| 10 | Chain Sinew | Uncommon | VI | On-hit proc | +| 11 | Chill Gland | Uncommon | V | On-hit proc | +| 12 | Cinder Sump | Uncommon | V | On-hit proc | +| 13 | Crow Bait | Common | V | Utility | +| 14 | Dead Man's Clock | Rare | III | Survivability | +| 15 | Dead Man's Switch | Rare | III | On-kill | +| 16 | Ember Jar | Uncommon | V | On-hit proc | +| 17 | Flayer Kiss | Uncommon | V | On-hit proc | +| 18 | Ghoul Heart | Uncommon | V | Survivability | +| 19 | Gorged Tick | Common | V | Utility | +| 20 | Gorging Leech | Common | IV | Utility | +| 21 | Grafted Trigger | Common | VI | Fire rate | +| 22 | Gristle Cord | Uncommon | IV | Survivability | +| 23 | Gyroscopic Ribs | Uncommon | IV | Shot modifier | +| 24 | Hollow Bones | Common | VI | Utility | +| 25 | Hollow Needle | Common | VI | Criticals | +| 26 | Hollow Points | Common | VI | Damage | +| 27 | Homing Tumor | Uncommon | IV | Shot modifier | +| 28 | Hook Rounds | Uncommon | V | On-hit proc | +| 29 | Hooked Sinew | Common | V | Utility | +| 30 | Hydra Maw | Rare | III | Shot modifier | +| 31 | Iron Stomach | Common | VIII | Survivability | +| 32 | Lead Marrow | Common | VIII | Shot modifier | +| 33 | Lucky Coin | Common | V | Utility | +| 34 | Magnet Maw | Common | III | Utility | +| 35 | Marrow Glut | Uncommon | VI | Damage | +| 36 | Marrow Piston | Common | V | Damage | +| 37 | Mortar Bone | Uncommon | V | On-hit proc | +| 38 | Orbit Crown | Uncommon | V | Utility | +| 39 | Orbital Knives | Rare | III | Utility | +| 40 | Piercing Gaze | Uncommon | V | Shot modifier | +| 41 | Rendered Fat | Common | VI | Survivability | +| 42 | Reroll Rib | Rare | II | Utility | +| 43 | Ricochet Ribs | Uncommon | V | Shot modifier | +| 44 | Rimed Fang | Common | VI | Criticals | +| 45 | Rusted Diadem | Uncommon | V | Utility | +| 46 | Scalpel | Common | VIII | Shot modifier | +| 47 | Second Stomach | Rare | III | Survivability | +| 48 | Sledge Rounds | Uncommon | V | On-hit proc | +| 49 | Spine Cage | Uncommon | V | Survivability | +| 50 | Spite Well | Uncommon | V | Survivability | +| 51 | Split Cortex | Rare | III | Shot modifier | +| 52 | Splinter Bone | Rare | III | Shot modifier | +| 53 | Split Tongue | Rare | III | Shot modifier | +| 54 | Tanned Hide | Common | VI | Survivability | +| 55 | Twin Hearts | Rare | III | Survivability | +| 56 | Twitch Fibers | Common | VI | Fire rate | +| 57 | Vampire Dentures | Uncommon | V | On-kill | +| 58 | Volatile Bile | Rare | III | On-kill | +| 59 | Whipcord Tendon | Uncommon | V | Damage | +| 60 | Worm Gut | Rare | III | Survivability | ## Synergy notes diff --git a/index.html b/index.html index 62caace..a4dc2d2 100644 --- a/index.html +++ b/index.html @@ -6,31 +6,31 @@ MEATSLICER - +
- - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/js/bosses.js b/js/bosses.js index 46dd8a2..a0ecb87 100644 --- a/js/bosses.js +++ b/js/bosses.js @@ -593,7 +593,7 @@ function onBossDeath(e) { addScore(500 + G.floor * 100); addShake(12); // boss reward: item pedestal + a real weapon drop + stairs down - spawnItemPedestal(W / 2 - 90, H / 2); + spawnItemPedestal(W / 2 - 90, H / 2, null, 'boss'); const bw = rollWeaponDrop(G.floor + 2); // bosses drop the good stuff spawnPickup('weapon', W / 2 + 90, H / 2, { wid: bw.id }); spawnPickup('stairs', W / 2, H / 2 + 80); diff --git a/js/enemies.js b/js/enemies.js index f570f5d..e4f70d0 100644 --- a/js/enemies.js +++ b/js/enemies.js @@ -255,7 +255,7 @@ function killEnemy(e, ang) { gemVal += irand(3, 5); spawnGems(e.x, e.y, gemVal); if (chance(0.18 + luck * 0.1)) { - spawnPickup('item', e.x, e.y, { iid: randomItemId() }); + spawnPickup('item', e.x, e.y, { iid: rollItemId('elite', G.floor) }); } else if (chance(0.6 + luck * 0.2)) { spawnPickup('ammo', e.x, e.y); } diff --git a/js/help.js b/js/help.js index 9eb9086..939ba9b 100644 --- a/js/help.js +++ b/js/help.js @@ -4,13 +4,16 @@ const HELP_BUTTON = { x: W - 104, y: 24, w: 80, h: 26 }; +// Implant pages are generated from the live ITEMS roster so the manual never +// drifts from the game data; the count and page count are dynamic. +const HELP_ITEM_PAGE_SIZE = 23; +const HELP_ITEM_PAGE_COUNT = Math.ceil(Object.keys(ITEMS).length / HELP_ITEM_PAGE_SIZE); const HELP_PAGES = [ { title: 'CONTROLS' }, { title: 'THE LOOP' }, { title: 'ARSENAL' }, { title: 'MUTATIONS' }, - { title: 'IMPLANTS I' }, - { title: 'IMPLANTS II' }, + ...Array.from({ length: HELP_ITEM_PAGE_COUNT }, (_, i) => ({ title: 'IMPLANTS ' + ['I', 'II', 'III', 'IV', 'V', 'VI'][i] || ('P' + (i + 1)) })), { title: 'BESTIARY' }, { title: 'PRESSURE' }, ]; @@ -181,31 +184,35 @@ function drawHelpPerkList(ctx, px, py) { } } -function drawHelpItemList(ctx, px, py, half) { +function drawHelpItemList(ctx, px, py, page) { const x = px + 40; let ty = py + HELP_BODY; ctx.textAlign = 'left'; + const total = Object.keys(ITEMS).length; ctx.fillStyle = '#d7a934'; ctx.font = hfont(9, true); - ctx.fillText('46 IMPLANTS · STACK TO TIER IX · DUPLICATE ROLLS FAVORED · TIER IX → +150 SCORE', x, ty); + ctx.fillText(total + ' IMPLANTS · PER-ITEM TIER CAPS · RARITY-WEIGHTED POOLS · DUPLICATE ROLLS FAVORED', x, ty); ty += 11; ctx.fillStyle = '#7d6a68'; ctx.font = hfont(8, false); - ctx.fillText('STICKINESS RAMPS 10% PER DISTINCT UPGRADABLE ITEM OWNED (CAP 50%) · PAGE ' + (half + 1) + ' OF 2', x, ty + 11); + ctx.fillText('ELITES FAVOUR COMMON · ITEM ROOMS FAVOUR UNCOMMON · BOSSES FAVOUR RARE/LEGENDARY · PAGE ' + (page + 1) + ' OF ' + HELP_ITEM_PAGE_COUNT, x, ty + 11); ty += 34; const entries = Object.entries(ITEMS); - const halfEntries = entries.slice(half * 23, half * 23 + 23); + const pageEntries = entries.slice(page * HELP_ITEM_PAGE_SIZE, page * HELP_ITEM_PAGE_SIZE + HELP_ITEM_PAGE_SIZE); const colW = (HELP_PANEL.w - 80) / 2; - for (let i = 0; i < halfEntries.length; i++) { + for (let i = 0; i < pageEntries.length; i++) { const col = Math.floor(i / 12), r = i % 12; const lx = x + col * colW, ly = ty + r * 20; - ctx.fillStyle = '#e3b746'; ctx.font = hfont(8, true); - ctx.fillText(halfEntries[i][1].name.toUpperCase(), lx, ly); + const [iid, item] = pageEntries[i]; + const rar = ITEM_RARITY[item.rarity]; + ctx.fillStyle = rar.color; ctx.font = hfont(8, true); + ctx.fillText(item.name.toUpperCase(), lx, ly); + ctx.fillStyle = '#5f4d4d'; ctx.font = hfont(7, true); + ctx.fillText(item.rarity.toUpperCase().slice(0, 3) + '·' + item.cap, lx, ly + 12); ctx.fillStyle = '#a08d84'; ctx.font = hfont(8, false); - ctx.fillText(fit(halfEntries[i][1].desc.toUpperCase(), 40), lx, ly + 12); + ctx.fillText(fit(item.desc.toUpperCase(), 34), lx + 52, ly + 12); } } -function drawHelpItemList1(ctx, px, py) { drawHelpItemList(ctx, px, py, 0); } -function drawHelpItemList2(ctx, px, py) { drawHelpItemList(ctx, px, py, 1); } +const HELP_ITEM_RENDERERS = Array.from({ length: HELP_ITEM_PAGE_COUNT }, (_, i) => (c, x, y) => drawHelpItemList(c, x, y, i)); function drawHelpBestiary(ctx, px, py) { const x = px + 40; @@ -276,7 +283,7 @@ function drawHelpPressure(ctx, px, py) { ], HELP_PANEL.w - 80); } -const HELP_RENDERERS = [drawHelpControls, drawHelpLoop, drawHelpArsenal, drawHelpPerkList, drawHelpItemList1, drawHelpItemList2, drawHelpBestiary, drawHelpPressure]; +const HELP_RENDERERS = [drawHelpControls, drawHelpLoop, drawHelpArsenal, drawHelpPerkList, ...HELP_ITEM_RENDERERS, drawHelpBestiary, drawHelpPressure]; function drawPauseHelp(ctx) { ctx.fillStyle = 'rgba(4,2,3,0.84)'; diff --git a/js/hud.js b/js/hud.js index b943fd3..8e8b76d 100644 --- a/js/hud.js +++ b/js/hud.js @@ -109,7 +109,9 @@ function drawHUD(ctx) { ctx.fillText(holsterEmpty ? 'NEEDS AMMO' : Math.ceil(p.holstered.ammo) + ' AMMO', 289, 125 + vOff); } - const allItemEntries = Object.entries(p.items); + const rarityRank = { legendary: 0, rare: 1, uncommon: 2, common: 3 }; + const allItemEntries = Object.entries(p.items).sort((a, b) => + (rarityRank[ITEMS[a[0]].rarity] - rarityRank[ITEMS[b[0]].rarity]) || (b[1] - a[1])); const maxVisibleItems = allItemEntries.length > 24 ? 23 : 24; const itemEntries = allItemEntries.slice(0, maxVisibleItems); if (itemEntries.length) { diff --git a/js/items.js b/js/items.js index 27fa459..8381795 100644 --- a/js/items.js +++ b/js/items.js @@ -1,73 +1,142 @@ // ---- Isaac-style stacking passive items ---- -const ITEMS = { - hollowpoints: { name: 'Hollow Points', desc: '+25% damage', apply: s => { s.dmgMul *= 1.25; } }, - twitch: { name: 'Twitch Fibers', desc: '+20% fire rate', apply: s => { s.rateMul *= 1.20; } }, - scalpel: { name: 'Scalpel', desc: '+25% shot speed', apply: s => { s.shotSpeedMul *= 1.25; } }, - leadmarrow: { name: 'Lead Marrow', desc: '+25% range', apply: s => { s.rangeMul *= 1.25; } }, - piercegaze: { name: 'Piercing Gaze', desc: 'Shots pierce +1 enemy', apply: s => { s.pierce += 1; } }, - ricochet: { name: 'Ricochet Ribs', desc: 'Shots bounce +1 time', apply: s => { s.bounce += 1; } }, - splittongue: { name: 'Split Tongue', desc: 'Twin parallel shot', apply: s => { s.split += 1; } }, - hydramaw: { name: 'Hydra Maw', desc: 'Fires 2 extra angled shots', apply: s => { s.fan += 1; } }, - homingtumor: { name: 'Homing Tumor', desc: 'Shots seek flesh (stronger per tier)', apply: s => { s.homing = (s.homing || 0) + 1; } }, - orbitalknives: { name: 'Orbital Knives', desc: '+1 circling knife', apply: s => { s.orbitals += 1; } }, - dentures: { name: 'Vampire Dentures', desc: 'Kills may heal you', apply: s => { s.lifestealChance += 0.04; } }, - volatilebile: { name: 'Volatile Bile', desc: 'Kills explode (bigger per tier)', apply: s => { s.explodeOnKill = (s.explodeOnKill || 0) + 1; } }, - backstabber: { name: 'Backstabber', desc: '+1 rear shot', apply: s => { s.rear = (s.rear || 0) + 1; } }, - splinterbone: { name: 'Splinter Bone', desc: 'Shots shatter into +2 shards on hit', apply: s => { s.splinter = (s.splinter || 0) + 2; } }, - ironstomach: { name: 'Iron Stomach', desc: '+½ heart container, heal ½ heart', apply: (s, p) => { s.maxHp += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } }, - luckycoin: { name: 'Lucky Coin', desc: 'Better drops', apply: s => { s.luck += 0.2; } }, - magnetmaw: { name: 'Magnet Maw', desc: '+60% pickup radius', apply: s => { s.magnet *= 1.6; } }, - bloodlust: { name: 'Bloodlust', desc: 'Kills may drop bonus XP', apply: s => { s.bloodlust = (s.bloodlust || 0) + 0.12; } }, - ghoulheart: { name: 'Ghoul Heart', desc: '+2 max HP, heal 2', apply: (s, p) => { s.maxHp += 2; p.hp = Math.min(s.maxHp, p.hp + 2); } }, - chainsinew: { name: 'Chain Sinew', desc: 'Hits arc to +1 nearby enemy', apply: s => { s.chain += 1; } }, - mortarbone: { name: 'Mortar Bone', desc: 'Every sixth hit erupts', apply: s => { s.mortar += 1; } }, - bloatrounds: { name: 'Bloat Rounds', desc: '+25% attack size, +10% damage', apply: s => { s.sizeMul *= 1.25; s.dmgMul *= 1.10; } }, - marrowglut: { name: 'Marrow Glut', desc: '+20% damage, -10% fire rate', apply: s => { s.dmgMul *= 1.20; s.rateMul *= 0.90; } }, - hollowneedle: { name: 'Hollow Needle', desc: '+50% critical damage', apply: s => { s.critMul += 0.50; } }, - bloodshoteye: { name: 'Bloodshot Eye', desc: '+6% critical chance', apply: s => { s.crit += 0.06; } }, - flayerkiss: { name: 'Flayer Kiss', desc: 'Hits inflict heavy bleeding', apply: s => { s.bleed += 0.20; } }, - emberjar: { name: 'Ember Jar', desc: '20% chance to ignite', apply: s => { s.igniteChance += 0.20; } }, - acidgland: { name: 'Acid Gland', desc: 'Hits may leave acid pools', apply: s => { s.acidOnHit += 0.12; } }, - hookrounds: { name: 'Hook Rounds', desc: 'Hits pull enemies toward you', apply: s => { s.pullOnHit += 0.18; } }, - sledgerounds: { name: 'Sledge Rounds', desc: 'More knockback, hits may stun', apply: s => { s.knockbackMul *= 1.35; s.stunOnHit += 0.08; } }, - graftedtrigger:{ name: 'Grafted Trigger', desc: '+18% fire rate', apply: s => { s.rateMul *= 1.18; } }, - deadmanswitch: { name: "Dead Man's Switch", desc: 'Kills trigger a fire-rate frenzy', apply: s => { s.frenzy += 0.12; } }, - orbitcrown: { name: 'Orbit Crown', desc: '+25% orbital speed and damage', apply: s => { s.orbSpeedMul *= 1.25; s.orbDmgMul *= 1.25; } }, - tannedhide: { name: 'Tanned Hide', desc: '+8% chance to ignore damage', apply: s => { s.armor += 0.08 / 0.92; } }, - deadmansclock: { name: "Dead Man's Clock", desc: '+0.25s hurt immunity', apply: s => { s.invBonus += 0.25; } }, - hollowbones: { name: 'Hollow Bones', desc: '+12% move speed', apply: s => { s.speedMul *= 1.12; } }, - boneplate: { name: 'Bone Plate', desc: '+½ shield heart each floor', apply: (s, p) => { s.shieldPerk = (s.shieldPerk || 0) + 1; p.shieldHp += 1; } }, - wormgut: { name: 'Worm Gut', desc: 'Heal ½ heart after combat rooms', apply: s => { s.roomHeal += 1; } }, - spinecage: { name: 'Spine Cage', desc: 'Contact attackers take damage', apply: s => { s.thorns += 8; } }, - secondstomach: { name: 'Second Stomach', desc: '+½ heart and excess healing shields', apply: (s, p) => { s.maxHp += 1; s.overShield += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } }, - spitewell: { name: 'Spite Well', desc: 'Taking damage releases a blood nova', apply: s => { s.retaliate += 10; } }, - twinhearts: { name: 'Twin Hearts', desc: '+1 max heart, heal 1 heart', apply: (s, p) => { s.maxHp += 2; p.hp = Math.min(s.maxHp, p.hp + 2); } }, - brassmagazine: { name: 'Brass Magazine', desc: '15% less ammo use, +20% ammo found', apply: s => { s.ammoEff *= 1.15; s.ammoPickupMul *= 1.20; } }, - crowbait: { name: 'Crow Bait', desc: '+25% drop luck', apply: s => { s.luck += 0.25; } }, - gorgingleech: { name: 'Gorging Leech', desc: '+20% XP gained', apply: s => { s.xpMul *= 1.20; } }, - rerollrib: { name: 'Reroll Rib', desc: '+1 perk reroll each level', apply: s => { s.rerollPerLevel += 1; s.rerolls += 1; } }, +const ITEM_RARITY = { + common: { weight: 100, power: 0.5, color: '#c9b8ae' }, + uncommon: { weight: 55, power: 1.0, color: '#6fb1c0' }, + rare: { weight: 25, power: 1.5, color: '#c9a227' }, + legendary: { weight: 8, power: 3.0, color: '#e2472f' }, }; -// Stickiness ramps with commitment: 10% per distinct upgradable item owned, -// capped at 50%. Your first pickup barely biases the pool, so early drops vary. -function randomItemId() { - const p = G.player; - const owned = p ? Object.keys(p.items).filter(iid => (p.items[iid] || 0) < ITEM_LEVEL_CAP) : []; - const stick = Math.min(0.5, owned.length * 0.10); - if (owned.length && chance(stick)) return choice(owned); - return choice(Object.keys(ITEMS)); -} +const ITEMS = { + // ---- common ---- + hollowpoints: { name: 'Hollow Points', desc: '+25% damage', rarity: 'common', cap: 6, apply: s => { s.dmgMul *= 1.25; } }, + twitch: { name: 'Twitch Fibers', desc: '+20% fire rate', rarity: 'common', cap: 6, apply: s => { s.rateMul *= 1.20; } }, + graftedtrigger:{ name: 'Grafted Trigger', desc: '+18% fire rate, +6% shot speed', rarity: 'common', cap: 6, apply: s => { s.rateMul *= 1.18; s.shotSpeedMul *= 1.06; } }, + scalpel: { name: 'Scalpel', desc: '+25% shot speed', rarity: 'common', cap: 8, apply: s => { s.shotSpeedMul *= 1.25; } }, + leadmarrow: { name: 'Lead Marrow', desc: '+25% range', rarity: 'common', cap: 8, apply: s => { s.rangeMul *= 1.25; } }, + bloodshoteye: { name: 'Bloodshot Eye', desc: '+6% critical chance', rarity: 'common', cap: 8, apply: s => { s.crit += 0.06; } }, + hollowneedle: { name: 'Hollow Needle', desc: '+50% critical damage', rarity: 'common', cap: 6, apply: s => { s.critMul += 0.50; } }, + luckycoin: { name: 'Lucky Coin', desc: 'Better drops', rarity: 'common', cap: 5, apply: s => { s.luck += 0.2; } }, + crowbait: { name: 'Crow Bait', desc: '+15% luck, +25% pickup radius', rarity: 'common', cap: 5, apply: s => { s.luck += 0.15; s.magnet *= 1.25; } }, + 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; } }, + 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; } }, + + // ---- uncommon ---- + bloatrounds: { name: 'Bloat Rounds', desc: '+25% attack size, +10% damage', rarity: 'uncommon', cap: 5, apply: s => { s.sizeMul *= 1.25; s.dmgMul *= 1.10; } }, + marrowglut: { name: 'Marrow Glut', desc: '+20% damage, -10% fire rate', rarity: 'uncommon', cap: 6, apply: s => { s.dmgMul *= 1.20; s.rateMul *= 0.90; } }, + piercegaze: { name: 'Piercing Gaze', desc: 'Shots pierce +1 enemy', rarity: 'uncommon', cap: 5, apply: s => { s.pierce += 1; } }, + ricochet: { name: 'Ricochet Ribs', desc: 'Shots bounce +1 time', rarity: 'uncommon', cap: 5, apply: s => { s.bounce += 1; } }, + flayerkiss: { name: 'Flayer Kiss', desc: 'Hits inflict heavy bleeding', rarity: 'uncommon', cap: 5, apply: s => { s.bleed += 0.20; } }, + emberjar: { name: 'Ember Jar', desc: '20% chance to ignite', rarity: 'uncommon', cap: 5, apply: s => { s.igniteChance += 0.20; } }, + acidgland: { name: 'Acid Gland', desc: 'Hits may leave acid pools', rarity: 'uncommon', cap: 6, apply: s => { s.acidOnHit += 0.12; } }, + hookrounds: { name: 'Hook Rounds', desc: 'Hits pull enemies toward you', rarity: 'uncommon', cap: 5, apply: s => { s.pullOnHit += 0.18; } }, + sledgerounds: { name: 'Sledge Rounds', desc: 'More knockback, hits may stun', rarity: 'uncommon', cap: 5, apply: s => { s.knockbackMul *= 1.35; s.stunOnHit += 0.08; } }, + chainsinew: { name: 'Chain Sinew', desc: 'Hits arc to +1 nearby enemy', rarity: 'uncommon', cap: 6, apply: s => { s.chain += 1; } }, + mortarbone: { name: 'Mortar Bone', desc: 'Every sixth hit erupts', rarity: 'uncommon', cap: 5, apply: s => { s.mortar += 1; } }, + homingtumor: { name: 'Homing Tumor', desc: 'Shots seek flesh (stronger per tier)', rarity: 'uncommon', cap: 4, apply: s => { s.homing = (s.homing || 0) + 1; } }, + dentures: { name: 'Vampire Dentures', desc: 'Kills may heal you', rarity: 'uncommon', cap: 5, apply: s => { s.lifestealChance += 0.04; } }, + 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); } }, + + // ---- rare ---- + splittongue: { name: 'Split Tongue', desc: 'Twin parallel shot', rarity: 'rare', cap: 3, apply: s => { s.split += 1; } }, + hydramaw: { name: 'Hydra Maw', desc: 'Fires 2 extra angled shots', rarity: 'rare', cap: 3, apply: s => { s.fan += 1; } }, + backstabber: { name: 'Backstabber', desc: '+1 rear shot', rarity: 'rare', cap: 3, apply: s => { s.rear = (s.rear || 0) + 1; } }, + splinterbone: { name: 'Splinter Bone', desc: 'Shots shatter into +2 shards on hit', rarity: 'rare', cap: 3, apply: s => { s.splinter = (s.splinter || 0) + 2; } }, + orbitalknives: { name: 'Orbital Knives', desc: '+1 circling knife', rarity: 'rare', cap: 3, apply: s => { s.orbitals += 1; } }, + volatilebile: { name: 'Volatile Bile', desc: 'Kills explode (bigger per tier)', rarity: 'rare', cap: 3, apply: s => { s.explodeOnKill = (s.explodeOnKill || 0) + 1; } }, + deadmanswitch: { name: "Dead Man's Switch", desc: 'Kills trigger a fire-rate frenzy', rarity: 'rare', cap: 3, apply: s => { s.frenzy += 0.12; } }, + boneplate: { name: 'Bone Plate', desc: '+½ shield heart each floor', rarity: 'rare', cap: 3, apply: (s, p) => { s.shieldPerk = (s.shieldPerk || 0) + 1; p.shieldHp += 1; } }, + wormgut: { name: 'Worm Gut', desc: 'Heal ½ heart after combat rooms', rarity: 'rare', cap: 3, apply: s => { s.roomHeal += 1; } }, + secondstomach: { name: 'Second Stomach', desc: '+½ heart and excess healing shields', rarity: 'rare', cap: 3, apply: (s, p) => { s.maxHp += 1; s.overShield += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } }, + twinhearts: { name: 'Twin Hearts', desc: '+1 max heart, heal to full', rarity: 'rare', cap: 3, apply: (s, p) => { s.maxHp += 2; p.hp = s.maxHp; } }, + deadmansclock: { name: "Dead Man's Clock", desc: '+0.25s hurt immunity', rarity: 'rare', cap: 3, apply: s => { s.invBonus += 0.25; } }, + rerollrib: { name: 'Reroll Rib', desc: '+1 perk reroll each level', rarity: 'rare', cap: 2, apply: s => { s.rerollPerLevel += 1; s.rerolls += 1; } }, + + // ---- phase 2 additions (clean, no new engine hooks) ---- + chillgland: { name: 'Chill Gland', desc: '18% chance to chill', rarity: 'uncommon', cap: 5, apply: s => { s.slowOnHit += 0.18; } }, + hookedsinew: { name: 'Hooked Sinew', desc: '+30% pickup pull speed, +10% radius', rarity: 'common', cap: 5, apply: s => { s.magnetPull *= 1.30; s.magnet *= 1.10; } }, + gyroscopicribs:{ name: 'Gyroscopic Ribs', desc: '+2 bounce, +10% range', rarity: 'uncommon', cap: 4, apply: s => { s.bounce += 2; s.rangeMul *= 1.10; } }, + marrowpiston: { name: 'Marrow Piston', desc: '+30% knockback, +12% attack size', rarity: 'common', cap: 5, apply: s => { s.knockbackMul *= 1.30; s.sizeMul *= 1.12; } }, + splitcortex: { name: 'Split Cortex', desc: '+1 pierce, +1 chain', rarity: 'rare', cap: 3, apply: s => { s.pierce += 1; s.chain += 1; } }, + gristlecord: { name: 'Gristle Cord', desc: '+0.12s hurt immunity, +4% armor', rarity: 'uncommon', cap: 4, apply: s => { s.invBonus += 0.12; s.armor += 0.04; } }, + renderedfat: { name: 'Rendered Fat', desc: '+½ heart, +4% armor', rarity: 'common', cap: 6, apply: (s, p) => { s.maxHp += 1; s.armor += 0.04; p.hp = Math.min(s.maxHp, p.hp + 1); } }, + whipcordtendon:{ name: 'Whipcord Tendon', desc: '+20% range, +8% fire rate', rarity: 'uncommon', cap: 5, apply: s => { s.rangeMul *= 1.20; s.rateMul *= 1.08; } }, + rusteddiadem: { name: 'Rusted Diadem', desc: '+40% orbital speed', rarity: 'uncommon', cap: 5, apply: s => { s.orbSpeedMul *= 1.40; } }, + gorgedtick: { name: 'Gorged Tick', desc: '+15% XP, +10% luck', rarity: 'common', cap: 5, apply: s => { s.xpMul *= 1.15; s.luck += 0.10; } }, + bonemealpowder:{ name: 'Bonemeal Powder', desc: '+15% damage, +15% shot speed', rarity: 'common', cap: 6, apply: s => { s.dmgMul *= 1.15; s.shotSpeedMul *= 1.15; } }, + 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; } }, +}; const ITEM_LEVEL_CAP = 9; +function itemCap(iid) { return ITEMS[iid].cap ?? ITEM_LEVEL_CAP; } + function romanNum(n) { return ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'][n - 1] || ('Lv' + n); } +// ---- pools: hybrid weighted table, legendaries are boss-only ---- +const ITEM_POOL_WEIGHTS = { + elite: { common: 100, uncommon: 45, rare: 8, legendary: 0 }, + room: { common: 45, uncommon: 85, rare: 40, legendary: 0 }, + boss: { common: 12, uncommon: 45, rare: 70, legendary: 45 }, +}; +const ITEM_POOL_STICK = { elite: 1.0, room: 0.7, boss: 0.35 }; + +function rollItemId(source, floor) { + const p = G.player; + const w = ITEM_POOL_WEIGHTS[source] || ITEM_POOL_WEIGHTS.room; + const f = floor || G.floor || 1; + + // distinct upgradable items owned, not yet at their own cap + const owned = p ? Object.keys(p.items).filter(iid => (p.items[iid] || 0) < itemCap(iid)) : []; + const stick = Math.min(0.5, owned.length * 0.10) * (ITEM_POOL_STICK[source] || 1); + if (owned.length && chance(stick)) return choice(owned); + + // floor-scaled rarity table, boss-excluding legendaries from non-boss sources + const floorScale = { + common: Math.max(0.4, 1 - f * 0.05), + uncommon: 1, + rare: 1 + f * 0.08, + legendary: 1 + f * 0.10, + }; + let total = 0; + const weights = {}; + for (const r in w) { + const rw = w[r] * floorScale[r]; + weights[r] = rw; + total += rw; + } + let roll = Math.random() * total; + let band = 'common'; + for (const r in weights) { roll -= weights[r]; if (roll <= 0) { band = r; break; } } + + // candidates in the band that are not already capped + let pool = Object.keys(ITEMS).filter(iid => ITEMS[iid].rarity === band && (p ? (p.items[iid] || 0) < itemCap(iid) : true)); + // if the band is exhausted, fall back to any unmaxed item + if (!pool.length) pool = Object.keys(ITEMS).filter(iid => p ? (p.items[iid] || 0) < itemCap(iid) : true); + // absolute fallback: full pool (everything capped -> giveItem converts to score) + if (!pool.length) pool = Object.keys(ITEMS); + return choice(pool); +} + +// legacy alias used by tests / older call sites +function randomItemId() { return rollItemId('room', G.floor); } + // duplicate pickups upgrade the item's quality (effect re-applies per tier) function giveItem(iid) { const p = G.player; const item = ITEMS[iid]; const lvl = p.items[iid] || 0; - if (lvl >= ITEM_LEVEL_CAP) { + const cap = itemCap(iid); + if (lvl >= cap) { addScore(150); spawnGems(p.x, p.y, 5); addToast(item.name + ' MAX', 'already mastered — +150 score'); @@ -87,8 +156,8 @@ function giveItem(iid) { refreshOrbitals(p); } -// spawn an item pedestal (item room / boss reward) -function spawnItemPedestal(x, y, iid) { +// spawn an item pedestal (item room / boss reward); source drives the pool roll +function spawnItemPedestal(x, y, iid, source) { spawnPickup('itemspot', x, y); - spawnPickup('item', x, y, { iid: iid || randomItemId() }); + spawnPickup('item', x, y, { iid: iid || rollItemId(source || 'room', G.floor) }); } diff --git a/js/pickups.js b/js/pickups.js index 877b6d6..ea7f4ba 100644 --- a/js/pickups.js +++ b/js/pickups.js @@ -51,7 +51,7 @@ function updatePickups(dt) { // gems & small pickups fly to the player inside magnet radius if ((k.type === 'gem' || k.type === 'heart' || k.type === 'ammo') && d < magR) { const a = angleTo(k.x, k.y, p.x, p.y); - const sp = lerp(320, 60, d / magR); + const sp = lerp(320, 60, d / magR) * p.stats.magnetPull; k.x += Math.cos(a) * sp * dt; k.y += Math.sin(a) * sp * dt; } @@ -164,12 +164,13 @@ function drawPickups(ctx) { } else if (k.type === 'itemspot') { Sprites.draw(ctx, 'pedestal', k.x, k.y, 0, 64); } else if (k.type === 'item') { + const rarColor = ITEM_RARITY[ITEMS[k.iid].rarity].color; ctx.save(); ctx.globalAlpha = 0.2 + Math.sin(k.t * 3) * 0.07; - ctx.fillStyle = '#ffe075'; + ctx.fillStyle = rarColor; ctx.beginPath(); ctx.arc(k.x, k.y - 28, 30, 0, TAU); ctx.fill(); ctx.globalAlpha = 0.35; - ctx.strokeStyle = '#ffe9a6'; ctx.lineWidth = 1; + ctx.strokeStyle = rarColor; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(k.x, k.y - 28, 25 + Math.sin(k.t * 4) * 3, 0, TAU); ctx.stroke(); ctx.restore(); Sprites.draw(ctx, 'i_' + k.iid, k.x, k.y - 28 + bob, 0, 64); @@ -186,7 +187,7 @@ function drawPickups(ctx) { // hover nameplates when close if (dist2(k.x, k.y, p.x, p.y) < 90 * 90) { if (k.type === 'weapon') drawPickupLabel(ctx, WEAPONS[k.wid].name, k.x, k.y - 44, '#ffd060'); - else if (k.type === 'item') drawPickupLabel(ctx, ITEMS[k.iid].name, k.x, k.y - 66, '#c9a227'); + else if (k.type === 'item') drawPickupLabel(ctx, ITEMS[k.iid].name, k.x, k.y - 66, ITEM_RARITY[ITEMS[k.iid].rarity].color); else if (k.type === 'stairs') drawPickupLabel(ctx, 'STAIRS DOWN', k.x, k.y - 52, '#e8e0d0'); else if (k.type === 'ammo') drawPickupLabel(ctx, 'AMMO', k.x, k.y - 26, '#d0b060'); } diff --git a/js/rooms.js b/js/rooms.js index 4142a99..f24e330 100644 --- a/js/rooms.js +++ b/js/rooms.js @@ -82,7 +82,7 @@ function enterRoom(gx, gy) { room.bossSpawned = true; Music.playBoss(boss.bossKind); } else if (room.type === 'item') { - spawnItemPedestal(W / 2 - 45, H / 2); + spawnItemPedestal(W / 2 - 45, H / 2, null, 'room'); // item rooms sometimes also stock a weapon if (chance(0.35)) { const w = rollWeaponDrop(G.floor); diff --git a/js/sfxbank.js b/js/sfxbank.js index ca7d9c2..472d718 100644 --- a/js/sfxbank.js +++ b/js/sfxbank.js @@ -115,7 +115,7 @@ const SfxBank = { if (this.buffers.has(name) || this.pending.has(name) || this.failed.has(name)) return; const file = this.FILES[name]; if (!file || !this.ctx || typeof fetch !== 'function') return; - const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=37') + const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=38') .then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); }) .then(bytes => this.ctx.decodeAudioData(bytes)) .then(buffer => this.buffers.set(name, this.trim(buffer))) diff --git a/js/sprites.js b/js/sprites.js index ea24bca..872c26c 100644 --- a/js/sprites.js +++ b/js/sprites.js @@ -36,6 +36,9 @@ const SPRITE_MANIFEST = [ 'i_deadmanswitch', 'i_orbitcrown', 'i_tannedhide', 'i_deadmansclock', 'i_hollowbones', 'i_boneplate', 'i_wormgut', 'i_spinecage', 'i_secondstomach', 'i_spitewell', 'i_twinhearts', 'i_brassmagazine', 'i_crowbait', 'i_gorgingleech', 'i_rerollrib', + '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', // full 64px/128px eight-direction action atlases 'player_sheet', 'player_legs_sheet', 'enemy_shambler_sheet', 'enemy_runner_sheet', 'enemy_spitter_sheet', @@ -73,7 +76,7 @@ const Sprites = { const img = new Image(); img.onload = () => { this.imgs[name] = img; if (--pending === 0) G.imagesLoaded = true; }; img.onerror = () => { if (--pending === 0) G.imagesLoaded = true; }; - img.src = 'assets/' + name + '.png?v=37'; + img.src = 'assets/' + name + '.png?v=38'; } }, diff --git a/js/state.js b/js/state.js index 3fe3e96..7455c34 100644 --- a/js/state.js +++ b/js/state.js @@ -58,13 +58,13 @@ function pressureDropUnits(dial) { const d = clamp(dial, PRESSURE_DIAL_MIN, PRES function pressureGain() { return pressureGainUnits(G.pressureDial) * PRESSURE_UNIT; } function pressureDropScale() { return pressureDropUnits(G.pressureDial) / PRESSURE_DROP_BASE; } -// total player power: perks drafted (level - 1) + item tiers owned. +// total player power: perks drafted (level - 1) + item tiers owned, weighted by rarity. // monsters scale against this so difficulty tracks the build. function powerScore() { const p = G.player; if (!p) return 0; let n = (p.level - 1) * PERK_POWER_WEIGHT; - for (const iid in p.items) n += p.items[iid]; + for (const iid in p.items) n += p.items[iid] * ITEM_RARITY[ITEMS[iid].rarity].power; return Math.max(0, n); } diff --git a/test/smoke.js b/test/smoke.js index 020c731..02cb719 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -318,9 +318,9 @@ step(30, 16); console.log('== expanded perk and item rosters =='); { - check('27 new items added', Object.keys(ctx.ITEMS).length === 46); + check('41 new items added (14 phase-2 passives)', Object.keys(ctx.ITEMS).length === 60); 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']; + 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']; 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', @@ -329,7 +329,7 @@ console.log('== expanded perk and item rosters =='); const livePlayer = ctx.G.player; ctx.initPlayer(); const probe = ctx.G.player; - for (const id of newItems) for (let tier = 0; tier < 9; tier++) ctx.ITEMS[id].apply(probe.stats, probe); + for (const id of newItems) for (let tier = 0; tier < (ctx.ITEMS[id].cap ?? 9); tier++) ctx.ITEMS[id].apply(probe.stats, probe); for (const id of newPerks) for (let tier = 0; tier < 3; tier++) ctx.PERKS.find(k => k.id === id).apply(probe.stats, probe); check('all new effects execute repeatedly without non-finite stats', Object.values(probe.stats).every(v => typeof v !== 'number' || Number.isFinite(v)) && Number.isFinite(probe.hp) && Number.isFinite(probe.shieldHp)); @@ -412,7 +412,7 @@ console.log('== continuous saw frame-rate independence =='); p.stats = savedStats; ctx.G.enemies.length = 0; } -console.log('== item upgrades (9 quality tiers) =='); +console.log('== item upgrades (per-item tier caps) =='); { const p = ctx.G.player; const dmgBefore = p.stats.dmgMul; @@ -420,16 +420,47 @@ console.log('== item upgrades (9 quality tiers) =='); check('duplicate upgrades to tier II', p.items.hollowpoints === 2); check('upgrade re-applies the effect', Math.abs(p.stats.dmgMul - dmgBefore * 1.25) < 0.001); for (let i = 0; i < 7; i++) ctx.giveItem('hollowpoints'); - check('nine copies reach tier IX', p.items.hollowpoints === 9); - check('full linear scaling to IX', Math.abs(p.stats.dmgMul - dmgBefore * Math.pow(1.25, 8)) < 0.01); + check('six copies reach the Hollow Points cap (VI)', p.items.hollowpoints === 6); + check('scaling stops at the cap', Math.abs(p.stats.dmgMul - dmgBefore * Math.pow(1.25, 5)) < 0.01); const scoreBefore = ctx.G.score; const dmgAtCap = p.stats.dmgMul; ctx.giveItem('hollowpoints'); - check('tenth copy capped, converts to score', p.items.hollowpoints === 9 && ctx.G.score > scoreBefore); + check('seventh copy capped, converts to score', p.items.hollowpoints === 6 && ctx.G.score > scoreBefore); check('capped copy gives no extra effect', p.stats.dmgMul === dmgAtCap); check('still one icon in the item list', Object.keys(p.items).filter(k => k === 'hollowpoints').length === 1); } +console.log('== rarity pools and per-item caps =='); +{ + const p = ctx.G.player; + p.items = {}; + // legendaries never come from elite or item-room pools + let sawLegendary = false; + for (let i = 0; i < 400; i++) { + const eid = ctx.rollItemId('elite', 1), rid = ctx.rollItemId('room', 1); + if (ctx.ITEMS[eid].rarity === 'legendary' || ctx.ITEMS[rid].rarity === 'legendary') sawLegendary = true; + } + check('elite and room pools never roll legendaries', !sawLegendary); + // boss pool can roll rare (and would roll legendary when any exist) + let bossRares = 0; + for (let i = 0; i < 200; i++) if (ctx.ITEMS[ctx.rollItemId('boss', 3)].rarity === 'rare') bossRares++; + check('boss pool favours rare items (' + bossRares + '/200)', bossRares > 40); + // capped items are excluded from fresh rolls + p.items = { splittongue: 3 }; // rare cap 3 + let reoffered = false; + for (let i = 0; i < 300; i++) if (ctx.rollItemId('room', 1) === 'splittongue') reoffered = true; + check('capped items are not re-offered by pools', !reoffered); + // stickiness still favours owned upgradable items + p.items = { hollowpoints: 1 }; + let owned = 0; + for (let i = 0; i < 600; i++) if (ctx.rollItemId('room', 1) === 'hollowpoints') owned++; + const frac = owned / 600; + check('stickiness ramps toward owned items (' + owned + '/600)', frac > 0.04 && frac < 0.20); + // legacy alias still resolves + check('randomItemId legacy alias works', typeof ctx.randomItemId() === 'string'); + p.items = {}; +} + console.log('== tier-scaled items + duplicate favoring =='); { const p = ctx.G.player; @@ -464,7 +495,7 @@ console.log('== tier-scaled items + duplicate favoring =='); for (let i = 0; i < 200; i++) { if (p.items[ctx.randomItemId()]) ownedHits++; } - check('item rolls favor owned items (' + ownedHits + '/200)', ownedHits >= 60); + check('item rolls favor owned items (' + ownedHits + '/200)', ownedHits >= 40); } console.log('== all weapons fire without crashing =='); @@ -742,16 +773,16 @@ console.log('== monsters scale with player upgrades =='); p.level = 1; p.items = {}; const base = ctx.makeEnemy('shambler', 500, 300, 1, false); check('no upgrades = base hp', Math.abs(base.hp - 26) < 0.01); - // Weighted power: 4 perks × 0.5 + 2 item tiers = 4 power. + // Rarity-weighted power: 4 perks × 0.5 + 2 common tiers × 0.5 = 3 power. p.level = 5; p.items = { hollowpoints: 2 }; const e6 = ctx.makeEnemy('shambler', 500, 300, 1, false); - check('weaker perks count half as much as item tiers', Math.abs(ctx.powerScore() - 4) < 0.001); - check('weighted power gives +20% hp', Math.abs(e6.hp - 26 * 1.2) < 0.01); - check('weighted power gives ~+4% speed', e6.spd >= 55 * 1.04 * 0.9 && e6.spd <= 55 * 1.04 * 1.1); + check('common item tiers weigh 0.5 power each', Math.abs(ctx.powerScore() - 3) < 0.001); + check('weighted power gives +15% hp', Math.abs(e6.hp - 26 * 1.15) < 0.01); + check('weighted power gives ~+3% speed', e6.spd >= 55 * 1.03 * 0.9 && e6.spd <= 55 * 1.03 * 1.1); const e64 = ctx.makeEnemy('shambler', 500, 300, 4, false); - check('floor + weighted scaling stack', Math.abs(e64.hp - 26 * 1.66 * 1.2) < 0.02); + check('floor + weighted scaling stack', Math.abs(e64.hp - 26 * 1.66 * 1.15) < 0.02); const b = ctx.spawnBoss(1); - check('boss hp scales with weighted upgrades', Math.abs(b.hp - 520 * 1.2) < 0.01); + check('boss hp scales with weighted upgrades', Math.abs(b.hp - 520 * 1.15) < 0.01); ctx.G.enemies.length = 0; ctx.G.boss = null; ctx.G.cur.cleared = true; ctx.startRun(); step(5, 16); } @@ -893,21 +924,21 @@ console.log('== item stickiness ramps with owned count =='); let owned1 = 0; for (let i = 0; i < 600; i++) if (ctx.randomItemId() === 'hollowpoints') owned1++; const frac1 = owned1 / 600; - check('1 owned item sticks ~10% (' + owned1 + '/600)', frac1 > 0.05 && frac1 < 0.20); - // 5+ upgradable items -> capped 50% stick + check('1 owned item sticks ~7% (' + owned1 + '/600)', frac1 > 0.03 && frac1 < 0.16); + // 5+ upgradable items -> 50% stick × 0.7 room multiplier ≈ 35% p.items = { hollowpoints: 1, twitch: 1, scalpel: 1, leadmarrow: 1, piercegaze: 1 }; let owned5 = 0; for (let i = 0; i < 600; i++) if (p.items[ctx.randomItemId()]) owned5++; const frac5 = owned5 / 600; - check('5+ owned items stick ~50% (' + owned5 + '/600)', frac5 > 0.38 && frac5 < 0.62); + check('5+ owned items stick ~35% (' + owned5 + '/600)', frac5 > 0.25 && frac5 < 0.48); p.items = {}; ctx.startRun(); step(3, 16); } -console.log('== help manual is 8 pages (implants split) =='); +console.log('== help manual grows with the implant roster =='); { - check('8 help pages', ctx.HELP_PAGES.length === 8); - check('8 help renderers', ctx.HELP_RENDERERS.length === 8); + check('9 help pages (3 implant pages for 60 items)', ctx.HELP_PAGES.length === 9); + check('9 help renderers', ctx.HELP_RENDERERS.length === 9); 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 597e744..8162dc5 100644 --- a/tools/gen_assets.py +++ b/tools/gen_assets.py @@ -265,6 +265,20 @@ ITEMS = { "crowbait": "a black feather bundle tied around a shining skull coin", "gorgingleech": "a fat crimson leech engorged around a glowing blue crystal", "rerollrib": "a curved rib carved with dice pips and a circular arrow", + "chillgland": "a pale frost-rimed gland with blue ice crystals and a steel staple", + "hookedsinew": "a taut red sinew cord pulled around a rusted butcher hook", + "gyroscopicribs": "a rib cage spun into a gyroscope ring around a spinning bone core", + "marrowpiston": "a hydraulic steel piston packed with dark marrow and bone seals", + "splitcortex": "a bisected brain with a clean mirrored split and two wired halves", + "gristlecord": "a thick braided gristle cord with steel clasps and dried blood", + "renderedfat": "a folded slab of rendered tallow fat with a crackled browned edge", + "whipcordtendon": "a long whip-like tendon cord coiled with a barbed tip", + "rusteddiadem": "a corroded iron diadem crown with orbital notches and rust flaking", + "gorgedtick": "a swollen blood-engorged tick clutching a tiny blue crystal", + "bonemealpowder": "a small burlap pouch spilling white bone-meal powder and bone chips", + "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", } for iid, subject in ITEMS.items(): SPECS["i_" + iid] = Spec(subject + ", isolated inventory object, strong top-left light, no medallion, no icon background", 32)