From 0ec12cd72c4b9c8329d76a3ac6336bfd0dffb82b Mon Sep 17 00:00:00 2001 From: MeatSlicer Developer Date: Thu, 30 Jul 2026 17:14:25 +1000 Subject: [PATCH] =?UTF-8?q?Phase=205:=20active=20items=20=E2=80=94=20room-?= =?UTF-8?q?clear=20charged,=20dedicated=20pedestals,=20Space=20to=20use?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ACTIVES table with 10 actives (Bone Nova, Offal Bomb, Blood Transfusion, Cleaver Storm, Butcher's Bell, Marrow Draught, Slaughter Time, Panic Room, Skinner's Coin, Gut Reroll); charges accrue +1/combat room, +2/boss - Dedicated 'active' pickup with weapon-style swap-drop; 30% boss / 15% item-room bonus pedestal spawns; flat +2 powerScore while held - HUD active slot with charge pips + READY state; ACTIVES help page (12 pages); teal medallion frame variant in draw_sprites.py; 10 generated icons - Procedural Sfx.active()/activeEmpty() (real clips deferred to audio batch) - Fix: toast panel width now fits the wider of title/subtitle and clamps to screen - Bump asset cache to v=40 --- assets/a_bloodtransfusion.png | Bin 0 -> 750 bytes assets/a_bonenova.png | Bin 0 -> 1589 bytes assets/a_butchersbell.png | Bin 0 -> 1307 bytes assets/a_cleaverstorm.png | Bin 0 -> 1717 bytes assets/a_gutreroll.png | Bin 0 -> 1892 bytes assets/a_marrowdraught.png | Bin 0 -> 1576 bytes assets/a_offalbomb.png | Bin 0 -> 1674 bytes assets/a_panicroom.png | Bin 0 -> 1042 bytes assets/a_skinnerscoin.png | Bin 0 -> 1677 bytes assets/a_slaughtertime.png | Bin 0 -> 1089 bytes docs/items.md | 19 +++++++++ index.html | 42 +++++++++---------- js/bosses.js | 2 + js/help.js | 29 ++++++++++++- js/hud.js | 27 +++++++++++- js/items.js | 77 ++++++++++++++++++++++++++++++++++ js/main.js | 1 + js/pickups.js | 27 ++++++++++++ js/player.js | 43 +++++++++++++++++-- js/rooms.js | 14 +++++++ js/sfx.js | 2 + js/sfxbank.js | 2 +- js/sprites.js | 11 ++++- js/state.js | 1 + test/smoke.js | 46 ++++++++++++++++++-- tools/draw_sprites.py | 16 ++++--- tools/gen_assets.py | 15 +++++++ 27 files changed, 337 insertions(+), 37 deletions(-) create mode 100644 assets/a_bloodtransfusion.png create mode 100644 assets/a_bonenova.png create mode 100644 assets/a_butchersbell.png create mode 100644 assets/a_cleaverstorm.png create mode 100644 assets/a_gutreroll.png create mode 100644 assets/a_marrowdraught.png create mode 100644 assets/a_offalbomb.png create mode 100644 assets/a_panicroom.png create mode 100644 assets/a_skinnerscoin.png create mode 100644 assets/a_slaughtertime.png diff --git a/assets/a_bloodtransfusion.png b/assets/a_bloodtransfusion.png new file mode 100644 index 0000000000000000000000000000000000000000..ae0970af442187c96c3862a633ada73a443f2633 GIT binary patch literal 750 zcmVtjcZ(Z^rbP4g&p_>GOM0kn_3_OSg zEeeXlLkuA)jiu$E&D=EihrQcesPFRWkXu-%cjMj3@8#uvpZ9s*@B96IpYQK|;TS40 zI5q;n>#6$;5A}=g)@ISs)sENIl-GK?e2Ki7nG>N%g5}Lk37~WROK}KPtb7L`6iE)yn;Vdg)X^8 zWcC}>S8Klwcicu|l~&5nyNNem0wC4ULa3@FFB9quO-5aE3sV8}^CDo2%LEQRK}8Mg z(PVLgD2rI76##2<9chQHFa_042&NQlaXFU*N8og5UxIT#e&Jv8;omo5*!x4okR1$# zwIz^t*wDGR*j`K#6Y(Qs)u+rTP6sUbG*v)KHM9sN^%31q1xJ~I@ZnLZJIhInNz%L- z4G;Ar`_{yN&UZSbS1;cc=WD3DwOIi4HabO{%SD^Z1wcnvd-2-w;MfS}JC6k***YvD zHk**{C4Oj6!0T$_VP_q!dVzn*C$iVK7gH#;M(O$2khVFndfCLpqzC`fU$>8c2uNkU zohYSN?Q>xI*%|=m-)iKoIH=DQt(h*ACBLDyZ07*qoM6N<$f~S;Xz5oCK literal 0 HcmV?d00001 diff --git a/assets/a_bonenova.png b/assets/a_bonenova.png new file mode 100644 index 0000000000000000000000000000000000000000..ca9ae2f82c4c3799464a3c68aa33a2f0afec08c1 GIT binary patch literal 1589 zcmV-52Fm$~P){t@RHGZ2(=IfkO&TQ_8q;dnV7s?HzcFUeAy3yV<) zz*eJ?gp$FaE0Kp9Yw`DX%1B(1cf*Fb+tUNGseUJbc>T3MNI*F1t7SsTuzC7l0w7vg zBwHFB0Q~0He-hzTnr`hMB zodF#4?B{3Q@2IGyL06Km1B0%Fk--1}E$(JUPoH5)vyCmba>o9C9)RhEo0L9bW8iQ9 z5dPjy84AaEyT{MKXi#`sU38!7Uz-87STvy+fCT6`*&_g)cAGS7H6#-;ZWeB##obJ+ ztBDW#`iT`5vCrAS8^1rz`7?eRo^NJkFhHZ-LO40Y#c-05Xmriss{r_WJ83@n%F2An zR3?Xc@w%wqw?_ufULu)@p*NT~(BR zo^KXvx0n{M^I+9uyp>S$f>xk9Xg>G~KCc^#QAeBaIDptxtYKj;i?gr@Gf+}fLtyA5 z4lGxCC>*20Y62jfoI!6eQS#^`M8_tSki1Gn#!WBDkC*EiMFZ?fYjLJDyJJ4XQ?wk#Ez$)=6z)ji&2*c8VbjV#1)}} zDp(l^;O!niX03+YqmN)Okjy4$04UpTbH_k zD^%9)CNn%tbY_vs^CJKZDH$Sh1yw1RYfOBt5IM|d=5tvp^?IqkYqVSP>u-J>W#jA| zd#!o^l8G24H8rHhCYj32GoQ=y&}NMoiAGV=dAmKZvJ3pLA0hi)Gdpkmo0ktCq+lj1 z%eQJ@-DuI%{Om3aVu`-N%Ut+0O6jJ}ICedb_RB8`jEDJrCQa_<5~iXeG7F0o7d^<6 zWd@877r%OCGX9I3Hx>a%2m49PWEs8o&pQiI+liz33k8rv!=e1CzNZsR-q;L~uZ8hs zk|z6h0HUevt<;2)I4bm5j5^WgJI_=7 zR;l3olb^Az@Bu3I`h4-}4W_)WXeuil_0@8@v&70)SFK>>!$jW`lx}y?Rale{J}?>- zKChb=qmD>iAut}My3$Itu!!$3@0-fZv)8KUVtAe^Te;fGVtGyMsNDg;TM6av2M?>Z zbmiTUA3VA`+Bwu%E3Mj6dCaq49`o#%Ta8A1Ubj5dSgQ^v`R~8cP-Ldmc&m&Ib#34^W#r`^WDXi%gw zIg!fb=swlYlP^AV|K>t-WyOEZgj=*)l`-jj?&-T4#nxpXnyXelr-S|MsoRaew^Mpr zU2;>ghIH`OP3N@R@^y6kzWWNsX}96?x}~SpB~?)0%6~y^bd0tWM;RCmu6qAL-?s$d nwrN?UGC2VLJQnBwb}#lHzJa`gWE;9%00000NkvXXu0mjfmP8a@ literal 0 HcmV?d00001 diff --git a/assets/a_butchersbell.png b/assets/a_butchersbell.png new file mode 100644 index 0000000000000000000000000000000000000000..98dbc5475ea8496d5c6112afb1c611aeab9154e9 GIT binary patch literal 1307 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 LNkvXXu0mjfu~@UUt%Kk&D#=AP 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 U>rU+n2mk;807*qoM6N<$g7x4fg8%>k literal 0 HcmV?d00001 diff --git a/assets/a_panicroom.png b/assets/a_panicroom.png new file mode 100644 index 0000000000000000000000000000000000000000..908c91fa3e40a7232adf94675e3be3f712d8ed91 GIT binary patch literal 1042 zcmV+t1nv8YP)>%AxU z`E&x>ZNf069|ZJ+AnyzULSdtH3P67$-b_^0KP_TTyWNdwtwo@9_KJQGU<#X#V*+5z zZpoZt;$qkMQT|`V0Q7ZAYzgFs1ZwN*2+S8R7K_FiaXiRj^&_r>0b!!5UzwOGY@{tV zB2ZiRvHw>zfAJ&2L@}+^nbzu9pCe*RV3|i`4GVz7y9seTSQ-F!Qf~;YYH7q)g$#pQOG%sZvJeM0@{5vHmiP#T<+zWaaTxreAZkr#P7Ga_Y z{Ey&AbIS(ac8|exX*r_uiS0_@=SH*WhOs3eQ8f7iepE@j$(JfFOcdGP9!8=#EXMC- zM8@nE>A8fZ`IKdFOmNJl-L4)wGvB8_ns5e$B_Fm;AUkMOBrFf4tKbtSLU4FDsdlV% z3Z6^<-T9Jo1TRoJ&854m3d*zKwA&@N1SfxgLg^F*wCV=3MC0B0yiD;R?_WSJ1Q^C! zCg8b{>Pk+F@P~+xTh?6aqPA-EbaC1+v5tx5>~m%{}&cr zr-F?}q;$&r_qq8fogzn?>tKK}yIqPKsqtKzMe?yFHag+{a}Fc3sH62czZF?`6F%O| z0L3@6Te5=&MxwCl#u5T@q+z7L!iqh&g74-1JyNzS52!T{s5K8N(sdjYDceZd<~kTu zV&~T**pAluj`VrdO7`5?A)GQ^Ei39|xrKN8|!Av;Y7A M07*qoM6N<$g2kioO#lD@ literal 0 HcmV?d00001 diff --git a/assets/a_skinnerscoin.png b/assets/a_skinnerscoin.png new file mode 100644 index 0000000000000000000000000000000000000000..a16eb520233483c17b9bb312fe83283a0f307b56 GIT binary patch literal 1677 zcmV;826Fj{P)Y z!c5#yfvviRjr9miiyDuW8|ge~dfn-C|KjREnkj1)zAt0>3~atUh^^xs{TEJYl?Of$ z;3qHFQrGF_>_A$4lqg#eYh~F%Yr`A>u385!EzEN05kMf8Bp6YsH^#Jv^V1w^wiMUE z@RdRB>_D1d|MVZ6=&tAF~8rRty{ZI7D^byC^vLy6+sy=k zZ+-^ZDe>v5QAEDJmEvq&6}>G^5p_j4N=7;)*w}r3jsyNNSj0+4`Vh3oLQ>8pt12_*1qWrl(xRO1R0YX@-K6`=(^kBPE5 zJl>4>=#O*v^IGj*;XP!P8jFczeUCHx@m~q6Db|AF!VC<&*~Qexwn6U$x*eC(%-%K$ z@l33#0(>v%Zg747vp;$X>m3iCK!T}_ZNuI7j}|UN*Du#l;|e4GQUjp%wm9*4BV7G( z1yeF14()HVQBre6=pz_UiE7ZxO`i+rzt_Xz%|CH{JjuOnBWhgX#~&t(-v>@#YT}c3 z7HO=uX?j~78V_ZaZZ(mS4w2|nsr;Kos9Oi$OZ&;5W3)ERaeXKYJ{brYUaWBNht25z zm(rtWWTrn3@_22H)_u?-28*s}-%bVfWt#~Z|x8*cW68$!1dt1*0o&UY_Uq5 zaapzfx?Dy&Bx?M)oF=pq#p8_-jAuvl^7?(lem^Khe@u4M)mUxQ+8q{g#VTgHChlz; zS&ZE`sBwi?s=mdq9{l^8{hIBy#%ddt6_R!!vz7O5yQG}Nroz4WmLZkg&I{uIR?Pkb X7hOXyO9@>X00000NkvXXu0mjfPFO%H literal 0 HcmV?d00001 diff --git a/assets/a_slaughtertime.png b/assets/a_slaughtertime.png new file mode 100644 index 0000000000000000000000000000000000000000..c0b8dbd25ee71525ef5f965dbc8078abde274252 GIT binary patch literal 1089 zcmV-H1it%;P)v_r7^U9O`FZ)5 zOTX{93{a@3B%=OHS5j4VNQ^?E%we&De$*Oh3oa$_U@J#ejS}&TEL_i$&5ZNi5BJXn zuCH&Pg+>8r7mLW)ak<^r5Ezd~sW)_LViiRtZ0ANDV81s9ndxw!_#UcWQvaO{yjkhv!uHCxHt=sSL z?Kj_(lPZ0=QdM;(+AW^!6)8#5KovH086Yw<$=J*l^ZxIGn^=Fh49EQ5fkhn}6qt#J z%)senlyoxskGP&6SVEvzf{RNBq?1u{QUz^o2W@S~#DweJ{l4ljn4M0&R9X#`g!P6_ zIu_u@_AVdl(AeFew56HPcYvczj1N419!#gu-9_uSAosMp%1;k!U8x-c0C}TBA!O%s zkDu^d2mrAaw~oL?8xGHfc>m6A{)pMh8y%iS1AHCNn!y2dcM-wmwuXRMmW`lK&}SeB&C&Z;x!Zr``ILt|wbWn&+GG;CUS4`-PbS2qvf^l(u&_E}w7rnIH;!Xfb6$uYHIm54_` z9Tvb9fQS#Rd1M`d$jqdHs`Jz4KACEhj6-1F<7NA#P0HpV7Iq^@E%I_3XhLG1@wgjV zP8x&s1eX%n$(PZZN3`_@_v2ymg$jF}6Y@r<3&=Nl_`HqabmDY5sr~wp$g5Mm literal 0 HcmV?d00001 diff --git a/docs/items.md b/docs/items.md index 45a55a6..0dbd7f6 100644 --- a/docs/items.md +++ b/docs/items.md @@ -235,6 +235,25 @@ All of these trigger from your weapon hits (orbital knives proc at half chance). | 81 | Thousand Teeth | Legendary | I | Shot modifier | | 82 | Twin Sidearm | Legendary | I | Shot modifier | +## Active items + +Actives live in their own slot (separate from the passive tray) and are **charged by clearing rooms**: **+1 charge per combat room, +2 per boss**. Using an active spends all charges. Picking up a new active drops the old one with its remaining charges. Sources: 30% chance of a bonus pedestal on boss kills, 15% in item rooms. Use with **SPACE**. + +| Active | Cost | Effect | +|---|---|---| +| Bone Nova | 2 | Damage ring + hard knockback around you | +| Offal Bomb | 1 | Lobs a gore bomb at the cursor | +| Blood Transfusion | 2 | Heal 2 hearts, lose 25% of current XP | +| Cleaver Storm | 2 | 12 orbiting cleavers shred on contact for 6 s | +| Butcher's Bell | 2 | Pull every enemy in and stun for 1 s | +| Marrow Draught | 3 | +100% fire rate and free ammo for 5 s | +| Slaughter Time | 3 | All enemies slowed to 25% speed for 5 s | +| Panic Room | 3 | 2.5 s invulnerable โ€” but you can't fire | +| Skinner's Coin | 3 | Clear every enemy bullet into XP gems | +| Gut Reroll | 1 | Reroll the item pedestal you're standing on | + +Holding an active contributes a flat **+2** to power score. + ## Synergy notes - **Second Stomach** turns *every* healing source into shield generation: Worm Gut, Bone Knit, Vampire Dentures, Hemophage, and hearts at full HP. diff --git a/index.html b/index.html index e0e86f3..2abbd9f 100644 --- a/index.html +++ b/index.html @@ -6,31 +6,31 @@ MEATSLICER - +
- - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/js/bosses.js b/js/bosses.js index a0ecb87..fb5b919 100644 --- a/js/bosses.js +++ b/js/bosses.js @@ -594,6 +594,8 @@ function onBossDeath(e) { addShake(12); // boss reward: item pedestal + a real weapon drop + stairs down spawnItemPedestal(W / 2 - 90, H / 2, null, 'boss'); + // 30% chance of a bonus active pedestal (additive โ€” never replaces the passive) + if (chance(0.30)) spawnActivePedestal(W / 2 - 160, H / 2 + 60); 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/help.js b/js/help.js index cc4326f..bffddf3 100644 --- a/js/help.js +++ b/js/help.js @@ -14,6 +14,7 @@ const HELP_PAGES = [ { title: 'ARSENAL' }, { title: 'MUTATIONS' }, ...Array.from({ length: HELP_ITEM_PAGE_COUNT }, (_, i) => ({ title: 'IMPLANTS ' + ['I', 'II', 'III', 'IV', 'V', 'VI'][i] || ('P' + (i + 1)) })), + { title: 'ACTIVES' }, { title: 'BESTIARY' }, { title: 'PRESSURE' }, ]; @@ -86,6 +87,7 @@ function drawHelpControls(ctx, px, py) { { head: 'IN THE FIELD', lines: [ 'WASD / ARROWS โ€” MOVE MOUSE โ€” AIM LMB (HOLD) โ€” FIRE', 'LMB RELEASE โ€” FIRE CHARGED BEAM (SPINAL TAP ONLY)', + 'SPACE โ€” USE ACTIVE ITEM (CHARGED BY CLEARING ROOMS)', 'R โ€” SWAP BONE POPPER โ†” HOLSTERED SPECIAL (EMPTY SPECIALS LOCKED)', 'P / ESC โ€” PAUSE ยท M โ€” MUTE ยท N โ€” NEXT TRACK ยท T โ€” AUTO-DRAFT', 'DESKTOP APP: F11 โ€” FULLSCREEN ยท F12 โ€” DEVTOOLS ยท CTRL+R โ€” RELOAD', @@ -216,6 +218,31 @@ function drawHelpItemList(ctx, px, py, page) { const HELP_ITEM_RENDERERS = Array.from({ length: HELP_ITEM_PAGE_COUNT }, (_, i) => (c, x, y) => drawHelpItemList(c, x, y, i)); +function drawHelpActives(ctx, px, py) { + const x = px + 40; + let ty = py + HELP_BODY; + ctx.textAlign = 'left'; + ctx.fillStyle = '#d7a934'; ctx.font = hfont(9, true); + ctx.fillText(Object.keys(ACTIVES).length + ' ACTIVE ITEMS ยท SPACE TO USE ยท CHARGED BY CLEARING ROOMS', x, ty); + ty += 11; + ctx.fillStyle = '#7d6a68'; ctx.font = hfont(8, false); + ctx.fillText('+1 CHARGE PER COMBAT ROOM ยท +2 PER BOSS ยท PICKING UP A NEW ACTIVE DROPS THE OLD ONE', x, ty + 11); + ty += 34; + const colW = (HELP_PANEL.w - 80) / 2; + const entries = Object.entries(ACTIVES); + for (let i = 0; i < entries.length; i++) { + const col = Math.floor(i / 5), r = i % 5; + const lx = x + col * colW, ly = ty + r * 62; + const [, a] = entries[i]; + ctx.fillStyle = '#55f5dc'; ctx.font = hfont(8, true); + ctx.fillText(a.name.toUpperCase(), lx, ly); + ctx.fillStyle = '#5f4d4d'; ctx.font = hfont(7, true); + ctx.fillText('COST ' + a.cost + ' CHARGE' + (a.cost > 1 ? 'S' : ''), lx, ly + 13); + ctx.fillStyle = '#a08d84'; ctx.font = hfont(8, false); + ctx.fillText(fit(a.desc.toUpperCase(), 30), lx + 100, ly + 13); + } +} + function drawHelpBestiary(ctx, px, py) { const x = px + 40; let ty = py + HELP_BODY; @@ -285,7 +312,7 @@ function drawHelpPressure(ctx, px, py) { ], HELP_PANEL.w - 80); } -const HELP_RENDERERS = [drawHelpControls, drawHelpLoop, drawHelpArsenal, drawHelpPerkList, ...HELP_ITEM_RENDERERS, drawHelpBestiary, drawHelpPressure]; +const HELP_RENDERERS = [drawHelpControls, drawHelpLoop, drawHelpArsenal, drawHelpPerkList, ...HELP_ITEM_RENDERERS, drawHelpActives, drawHelpBestiary, drawHelpPressure]; function drawPauseHelp(ctx) { ctx.fillStyle = 'rgba(4,2,3,0.84)'; diff --git a/js/hud.js b/js/hud.js index 8e8b76d..4ae0558 100644 --- a/js/hud.js +++ b/js/hud.js @@ -8,7 +8,7 @@ function drawHUD(ctx) { // vitals use a 2-row grid; a second row pushes the rest of the panel down const totalVitalIcons = Math.ceil(p.stats.maxHp / 2) + Math.ceil(p.shieldHp / 2); const vOff = totalVitalIcons > 12 ? 22 : 0; - const panelH = (p.holstered ? 140 : 112) + vOff; + const panelH = (p.holstered ? 140 : 112) + (p.active ? 24 : 0) + vOff; drawPixelPanel(ctx, 8, 8, 304, panelH, { accent: '#cf2942', border: 'rgba(115,43,56,0.95)', blood: true, seed: G.floor, bloodAlpha: 0.34, @@ -109,6 +109,22 @@ function drawHUD(ctx) { ctx.fillText(holsterEmpty ? 'NEEDS AMMO' : Math.ceil(p.holstered.ammo) + ' AMMO', 289, 125 + vOff); } + // active item slot: icon, name, charge pips, READY state + if (p.active) { + const ad = ACTIVES[p.active.iid]; + const ay = 112 + vOff + (p.holstered ? 20 : 0); + const ready = p.active.charges >= ad.cost; + ctx.fillStyle = ready ? 'rgba(34,90,84,0.5)' : 'rgba(30,60,66,0.35)'; + ctx.fillRect(15, ay, 281, 20); + Sprites.draw(ctx, 'a_' + p.active.iid, 29, ay + 10, 0, 28, false, ready ? A : 0.6 * A); + ctx.fillStyle = ready ? '#8df0e0' : '#7d9a9c'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'left'; + ctx.fillText((ready ? '[SPACE] ' : '') + ad.name.toUpperCase(), 47, ay + 13); + for (let i = 0; i < ad.cost; i++) { + ctx.fillStyle = i < p.active.charges ? '#55f5dc' : 'rgba(85,245,220,0.18)'; + ctx.fillRect(289 - (ad.cost - 1 - i) * 12 - 8, ay + 6, 8, 8); + } + } + 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])); @@ -186,7 +202,14 @@ function drawHUD(ctx) { const y = 22 + i * 48; ctx.globalAlpha = a; ctx.font = 'bold 13px monospace'; - const tw = Math.max(ctx.measureText(t.text).width + 48, 190); + const titleW = ctx.measureText(t.text.toUpperCase()).width; + let subW = 0; + if (t.sub) { + ctx.font = '9px monospace'; + subW = ctx.measureText(t.sub.toUpperCase()).width; + } + // panel must fit the wider of the two lines (and never leave the screen) + const tw = Math.min(Math.max(Math.max(titleW, subW) + 48, 190), W - 24); drawPixelPanel(ctx, W / 2 - tw / 2, y, tw, t.sub ? 40 : 28, { cut: 5, shadow: false, accent: '#c59231', blood: true, seed: i + G.floor, bloodAlpha: 0.2, diff --git a/js/items.js b/js/items.js index b7d8377..301f73e 100644 --- a/js/items.js +++ b/js/items.js @@ -187,3 +187,80 @@ function spawnItemPedestal(x, y, iid, source) { spawnPickup('itemspot', x, y); spawnPickup('item', x, y, { iid: iid || rollItemId(source || 'room', G.floor) }); } + +// ---- active items: room-clear charged, dedicated pedestal pickups ---- +// Actives live in their own table so p.items (tier math + powerScore) stays clean. +// charges are granted by recordRoomClear: +1 per combat room, +2 per boss. +const ACTIVES = { + bonenova: { name: 'Bone Nova', desc: 'Damage ring + hard knockback', cost: 2, use(p) { + areaDamage(p.x, p.y, 150, 30 * p.stats.dmgMul, true, { source: 'player' }); + for (const e of G.enemies) { const a = angleTo(p.x, p.y, e.x, e.y); e.vx += Math.cos(a) * 500; e.vy += Math.sin(a) * 500; } + spawnShockwave(p.x, p.y, 150, '#e8dcc2', 0.8); addShake(8); + } }, + offalbomb: { name: 'Offal Bomb', desc: 'Gore bomb at the cursor', cost: 1, use(p) { + explodeAt(Input.mx, Input.my, 95, 25 * p.stats.dmgMul, true); + } }, + bloodtransfusion:{ name: 'Blood Transfusion', desc: 'Heal 2 hearts, lose 25% current XP', cost: 2, use(p) { + p.xp = Math.floor(p.xp * 0.75); + healPlayer(4); spawnText(p.x, p.y - 16, '+2 HEARTS', '#d92038'); + } }, + cleaverstorm: { name: 'Cleaver Storm', desc: '12 orbiting cleavers for 6s', cost: 2, use(p) { + p.cleaverStormT = 6; + } }, + butchersbell: { name: "Butcher's Bell", desc: 'Pull every enemy in and stun 1s', cost: 2, use(p) { + for (const e of G.enemies) { + if (e.boss) continue; + const a = angleTo(e.x, e.y, p.x, p.y); + e.vx += Math.cos(a) * 700; e.vy += Math.sin(a) * 700; + e.stunT = Math.max(e.stunT || 0, 1); + } + spawnShockwave(p.x, p.y, 320, '#c9a227', 0.7); addShake(6); + } }, + marrowdraught: { name: 'Marrow Draught', desc: '+100% fire rate and free ammo for 5s', cost: 3, use(p) { + p.marrowDraughtT = 5; + } }, + slaughtertime: { name: 'Slaughter Time', desc: 'Enemies at 25% speed for 5s', cost: 3, use(p) { + for (const e of G.enemies) e.slowT = Math.max(e.slowT || 0, 5); + } }, + panicroom: { name: 'Panic Room', desc: '2.5s invulnerable, can\'t fire', cost: 3, use(p) { + p.panicRoomT = 2.5; + p.invT = Math.max(p.invT, 2.5); + } }, + skinnerscoin: { name: "Skinner's Coin", desc: 'Clear all enemy bullets into gems', cost: 3, use(p) { + for (const b of G.ebullets) spawnPickup('gem', b.x, b.y, { v: 1 }); + G.ebullets = []; + spawnShockwave(p.x, p.y, 480, '#55f5dc', 0.5); + } }, + gutreroll: { name: 'Gut Reroll', desc: 'Reroll the pedestal you\'re standing on', cost: 1, use(p) { + for (const k of G.pickups) { + if (k.type === 'item' && dist2(k.x, k.y, p.x, p.y) < 120 * 120) { + k.iid = rollItemId('room', G.floor); + spawnText(k.x, k.y - 40, 'REROLLED', '#e2472f'); + return; + } + } + addToast('NO PEDESTAL', 'stand on an item pedestal to reroll it'); + } }, +}; + +function useActive() { + const p = G.player; + if (!p.active || G.mode !== 'play') return; + const a = ACTIVES[p.active.iid]; + if (!a) return; + if (p.active.charges < a.cost) { + addToast(a.name, 'not ready โ€” clear ' + (a.cost - p.active.charges) + ' more room' + (a.cost - p.active.charges > 1 ? 's' : '')); + Sfx.activeEmpty(); + return; + } + p.active.charges = 0; + a.use(p); + spawnText(p.x, p.y - 22, a.name.toUpperCase(), '#e2472f'); + Sfx.active(); +} + +// spawn an active pedestal (boss / item-room bonus drops) +function spawnActivePedestal(x, y) { + spawnPickup('itemspot', x, y); + spawnPickup('active', x, y, { aid: choice(Object.keys(ACTIVES)) }); +} diff --git a/js/main.js b/js/main.js index c531b66..302d42a 100644 --- a/js/main.js +++ b/js/main.js @@ -151,6 +151,7 @@ function update(dt) { if (keyPressed('p', 'escape')) { G.mode = 'pause'; Sfx.stopAllLoops(); Sfx.menu(); break; } if (keyPressed('n')) Music.cycle(1); if (keyPressed('r')) swapWeapon(); + if (keyPressed(' ')) useActive(); if (keyPressed('t')) setAutoPerk(!G.autoPerk); updatePlayer(dt); updateRoom(dt); diff --git a/js/pickups.js b/js/pickups.js index c01cfd1..58a5923 100644 --- a/js/pickups.js +++ b/js/pickups.js @@ -121,6 +121,21 @@ function collectPickup(k) { for (const q of G.pickups) if (q.type === 'itemspot') q.dead = true; // remove pedestal base break; } + case 'active': { + // picking up a new active drops the old one with its remaining charges + if (p.active) { + const a = rand(0, TAU); + spawnPickup('active', p.x + Math.cos(a) * 34, p.y + Math.sin(a) * 34, + { aid: p.active.iid, charges: p.active.charges, delay: 0.6 }); + } + const def = ACTIVES[k.aid]; + p.active = { iid: k.aid, charges: k.charges !== undefined ? k.charges : def.cost }; + addToast(def.name, def.desc + ' โ€” [SPACE] when charged'); + spawnText(p.x, p.y - 14, def.name.toUpperCase(), '#55f5dc'); + Sfx.item(); + for (const q of G.pickups) if (q.type === 'itemspot') q.dead = true; // remove pedestal base + break; + } case 'stairs': nextFloor(); break; @@ -177,6 +192,17 @@ function drawPickups(ctx) { 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); + } else if (k.type === 'active') { + ctx.save(); + ctx.globalAlpha = 0.22 + Math.sin(k.t * 3) * 0.08; + ctx.fillStyle = '#55f5dc'; + ctx.beginPath(); ctx.arc(k.x, k.y - 28, 30, 0, TAU); ctx.fill(); + ctx.globalAlpha = 0.4; + ctx.strokeStyle = '#a5f5e8'; ctx.lineWidth = 2; + const ringR = 25 + Math.sin(k.t * 4) * 3; + ctx.beginPath(); ctx.arc(k.x, k.y - 28, ringR, -Math.PI / 2, -Math.PI / 2 + TAU * ((k.charges || 0) / Math.max(1, ACTIVES[k.aid].cost))); ctx.stroke(); + ctx.restore(); + Sprites.draw(ctx, 'a_' + k.aid, k.x, k.y - 28 + bob, 0, 64); } else if (k.type === 'stairs') { Sprites.draw(ctx, 'stairs', k.x, k.y, 0, 64); ctx.save(); @@ -191,6 +217,7 @@ function drawPickups(ctx) { 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, ITEM_RARITY[ITEMS[k.iid].rarity].color); + else if (k.type === 'active') drawPickupLabel(ctx, ACTIVES[k.aid].name, k.x, k.y - 66, '#55f5dc'); 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/player.js b/js/player.js index ce57c85..b33f122 100644 --- a/js/player.js +++ b/js/player.js @@ -11,6 +11,7 @@ function initPlayer() { holstered: null, // a special weapon set aside while the pistol is out level: 1, xp: 0, items: {}, // iid -> quality tier (1..3) + active: null, // { iid, charges } โ€” active item, room-clear charged orbAng: 0, stats: { dmgMul: 1, rateMul: 1, speedMul: 1, shotSpeedMul: 1, rangeMul: 1, @@ -107,7 +108,8 @@ 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 * (1 + (st.rhythmRateBonus || 0)) * frenzyMul; + const draughtMul = p.marrowDraughtT > 0 ? 2 : 1; + const effectiveRate = st.rateMul * (1 + (st.rhythmRateBonus || 0)) * frenzyMul * draughtMul; const rate = w.interval / effectiveRate; if (w.behavior === 'saw') { @@ -128,7 +130,7 @@ function updatePlayer(dt) { p.charge = Math.max(0, p.charge - dt * 2); } } else { - if (Input.mdown && p.fireT <= 0 && p.weapon.ammo > 0) { + if (Input.mdown && p.fireT <= 0 && p.weapon.ammo > 0 && !(p.panicRoomT > 0)) { fireWeapon(p, w); // Twin Sidearm: the Bone Popper double-taps if (st.twinSidearm > 0 && p.weapon.id === 'bonepopper') fireWeapon(p, w); @@ -146,7 +148,7 @@ function updatePlayer(dt) { p.muzzleT = 0.07; p.attackT = 0.4; p.actionT = 0; p.fireT = rate; - if (p.weapon.ammo !== Infinity) p.weapon.ammo -= (st.twinSidearm > 0 ? 2 : 1) / st.ammoEff; + if (p.weapon.ammo !== Infinity && !(p.marrowDraughtT > 0)) p.weapon.ammo -= (st.twinSidearm > 0 ? 2 : 1) / st.ammoEff; Sfx.shoot(w); if (w.behavior === 'slam') addShake(5); } @@ -210,6 +212,34 @@ function updatePlayer(dt) { } } + // ---- active-item timed effects ---- + // Marrow Draught: +100% fire rate and free ammo while active + if (p.marrowDraughtT > 0) p.marrowDraughtT -= dt; + // Panic Room: invulnerable but cannot fire + if (p.panicRoomT > 0) p.panicRoomT -= dt; + // Cleaver Storm: 12 orbiting cleavers shred on contact + if (p.cleaverStormT > 0) { + p.cleaverStormT -= dt; + p.cleaverStormAng = (p.cleaverStormAng || 0) + dt * 6; + p.cleaverStormTick = (p.cleaverStormTick || 0) - dt; + const doTick = p.cleaverStormTick <= 0; + if (doTick) p.cleaverStormTick = 0.25; + for (let i = 0; i < 12; i++) { + const a = p.cleaverStormAng + (i / 12) * TAU; + const cx = p.x + Math.cos(a) * 90, cy = p.y + Math.sin(a) * 90; + if (!doTick) continue; + for (const e of G.enemies) { + if (e.hp <= 0 || e.stormHitT > 0) continue; + if (dist2(cx, cy, e.x, e.y) < (14 + e.r) * (14 + e.r)) { + e.stormHitT = 0.3; + damageEnemy(e, 10 * st.dmgMul * st.dmgLiveMul, a + Math.PI / 2, true, { source: 'player' }); + spawnBlood(e.x, e.y, a, 3); + } + } + } + for (const e of G.enemies) if (e.stormHitT > 0) e.stormHitT -= dt; + } + // Thresher Plate: passive contact-damage aura (no need to be hit) if (st.thresherPlate > 0) { p.thresherTick = (p.thresherTick || 0) - dt; @@ -358,6 +388,13 @@ function drawPlayer(ctx) { Sprites.draw(ctx, 'bullet_cleaver', p.x + Math.cos(a) * 58, p.y + Math.sin(a) * 58, a + Math.PI / 2, 24); } } + // Cleaver Storm active: 12 orbiting cleavers + if (p.cleaverStormT > 0) { + for (let i = 0; i < 12; i++) { + const a = (p.cleaverStormAng || 0) + (i / 12) * TAU; + Sprites.draw(ctx, 'bullet_cleaver', p.x + Math.cos(a) * 90, p.y + Math.sin(a) * 90, a + Math.PI / 2, 22); + } + } const w = WEAPONS[p.weapon.id]; const kick = p.recoil * 3; diff --git a/js/rooms.js b/js/rooms.js index c0d9d3c..b6cdbf3 100644 --- a/js/rooms.js +++ b/js/rooms.js @@ -90,6 +90,8 @@ function enterRoom(gx, gy) { const w = rollWeaponDrop(G.floor); spawnPickup('weapon', W / 2 + 55, H / 2, { wid: w.id }); } + // and occasionally a bonus active pedestal + if (chance(0.15)) spawnActivePedestal(W / 2 + 130, H / 2 + 55); room.cleared = true; } } else if (room.type === 'combat' && !room.cleared && room.wavesLeft > 0) { @@ -177,6 +179,18 @@ function recordRoomClear(room) { } else { G.streak = 0; } + // active items charge on room clears: +1 per combat room, +2 per boss + const p = G.player; + if (p && p.active) { + const a = ACTIVES[p.active.iid]; + if (a) { + const gain = room.type === 'boss' ? 2 : (room.type === 'combat' ? 1 : 0); + if (gain > 0 && p.active.charges < a.cost) { + p.active.charges = Math.min(a.cost, p.active.charges + gain); + if (p.active.charges >= a.cost) spawnText(p.x, p.y - 30, a.name.toUpperCase() + ' READY', '#e2472f'); + } + } + } if (room.type === 'combat' && G.player.stats.roomHeal > 0 && G.player.hp < G.player.stats.maxHp) { const before = G.player.hp; healPlayer(G.player.stats.roomHeal); diff --git a/js/sfx.js b/js/sfx.js index 3012182..6c1374f 100644 --- a/js/sfx.js +++ b/js/sfx.js @@ -376,6 +376,8 @@ const Sfx = { ammo() { this.ui('ammo', () => { this.click(.14, 1600, 'ui'); this.blip(520, .08, 'square', .12, -80, 'ui'); }); }, weapon() { this.ui('weapon', () => { this.thump(120, .14, .4, 50, 'ui'); this.blip(300, .1, 'square', .25, 200, 'ui'); this.blip(450, .12, 'square', .2, 250, 'ui'); }); }, item() { this.ui('item', () => { this.thump(100, .2, .4, 45, 'ui'); this.blip(350, .15, 'triangle', .3, 150, 'ui'); this.blip(525, .2, 'triangle', .25, 200, 'ui'); }); }, + active() { this.thump(140, .22, .5, 60, 'ui'); this.blip(700, .12, 'sawtooth', .28, 500, 'ui'); this.noise(.1, .14, 2400, 'ui', null, true); }, + activeEmpty() { this.blip(220, .12, 'square', .18, -90, 'ui'); }, levelup() { this.ui('levelup', () => { this.blip(400, .12, 'square', .25, 200, 'ui'); setTimeout(() => this.blip(600, .12, 'square', .25, 200, 'ui'), 100); setTimeout(() => this.blip(800, .2, 'square', .25, 200, 'ui'), 200); }); }, perk() { this.ui('perk', () => this.blip(500, .1, 'square', .25, 300, 'ui')); }, menu() { this.ui('menu', () => this.blip(620, .055, 'square', .11, 120, 'ui')); }, diff --git a/js/sfxbank.js b/js/sfxbank.js index 180280b..0bc2435 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=39') + const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=40') .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 9933395..27416c9 100644 --- a/js/sprites.js +++ b/js/sprites.js @@ -44,6 +44,9 @@ const SPRITE_MANIFEST = [ 'i_meathook', 'i_blooddebt', 'i_butchersoath', 'i_secondskin', 'i_twinsidearm', 'i_crimsonmetronome', 'i_abattoirengine', 'i_gorecrown', 'i_thousandteeth', 'i_hollowfather', 'i_thelastcut', 'i_meatgrinder', + // active items + 'a_bonenova', 'a_offalbomb', 'a_bloodtransfusion', 'a_cleaverstorm', 'a_butchersbell', + 'a_marrowdraught', 'a_slaughtertime', 'a_panicroom', 'a_skinnerscoin', 'a_gutreroll', // full 64px/128px eight-direction action atlases 'player_sheet', 'player_legs_sheet', 'enemy_shambler_sheet', 'enemy_runner_sheet', 'enemy_spitter_sheet', @@ -81,7 +84,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=39'; + img.src = 'assets/' + name + '.png?v=40'; } }, @@ -286,6 +289,12 @@ const Sprites = { } else if (name.startsWith('w_')) { ctx.fillStyle = C('#b8a888'); ctx.fillRect(-8, -3, 16, 6); ctx.fillStyle = C('#6b5333'); ctx.fillRect(-4, 3, 5, 6); + } else if (name.startsWith('a_')) { + ctx.fillStyle = C('#2fa898'); + ctx.beginPath(); ctx.arc(0, 0, 9, 0, TAU); ctx.fill(); + ctx.fillStyle = C('#0d2a26'); + ctx.font = 'bold 10px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('!', 0, 0); } else if (name.startsWith('i_') || name.startsWith('perk_')) { ctx.fillStyle = C('#c9a227'); ctx.beginPath(); ctx.arc(0, 0, 9, 0, TAU); ctx.fill(); diff --git a/js/state.js b/js/state.js index 7506a26..f8160f7 100644 --- a/js/state.js +++ b/js/state.js @@ -65,6 +65,7 @@ function powerScore() { if (!p) return 0; let n = (p.level - 1) * PERK_POWER_WEIGHT; for (const iid in p.items) n += p.items[iid] * ITEM_RARITY[ITEMS[iid].rarity].power; + if (p.active) n += 2; // holding an active item is a flat power contribution return Math.max(0, n); } diff --git a/test/smoke.js b/test/smoke.js index 39e5697..3d5cc8e 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -61,7 +61,7 @@ for (const f of files) { } // const/let top-level bindings live in the context's lexical scope, not on the // global object โ€” expose the ones the harness pokes at directly. -vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, Sfx, SfxBank, W, H, WALL, ITEMS, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS, PRESSURE_UNIT, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN })', sandbox); +vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, Sfx, SfxBank, W, H, WALL, ITEMS, ACTIVES, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS, PRESSURE_UNIT, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN })', sandbox); let simTime = 0; const ctx = sandbox; @@ -530,6 +530,46 @@ console.log('== phase-4 boss-exclusive legendaries =='); ctx.startRun(); step(3, 16); } +console.log('== active items (room-clear charged) =='); +{ + const p = ctx.G.player; + p.active = null; + // pickup grants a full-charge active + ctx.spawnPickup('active', p.x, p.y, { aid: 'bonenova' }); + step(2, 16); + check('active pickup equips fully charged', p.active && p.active.iid === 'bonenova' && p.active.charges === ctx.ACTIVES.bonenova.cost); + // swap: picking up a second active drops the first + ctx.spawnPickup('active', p.x, p.y, { aid: 'offalbomb' }); + step(2, 16); + check('second active equips and drops the old one', p.active.iid === 'offalbomb' && ctx.G.pickups.some(k => k.type === 'active' && k.aid === 'bonenova')); + // use: charges spend, effect fires + const powerBefore = ctx.powerScore(); + check('holding an active adds flat power', powerBefore > 0); + ctx.useActive(); + check('using an active spends all charges', p.active.charges === 0); + // charge accrues from room clears + ctx.recordRoomClear({ type: 'combat' }); + check('combat clear grants +1 charge', p.active.charges === 1); + ctx.recordRoomClear({ type: 'boss' }); + check('boss clear grants +2 charges', p.active.charges === ctx.ACTIVES.offalbomb.cost); + // under-charged use is blocked + p.active.charges = 0; + const enemiesBefore = ctx.G.enemies.length; + ctx.useActive(); + check('under-charged active does not fire', p.active.charges === 0 && ctx.G.enemies.length === enemiesBefore); + // panic room blocks firing + p.active = { iid: 'panicroom', charges: ctx.ACTIVES.panicroom.cost }; + ctx.useActive(); + check('panic room grants invulnerability', p.invT >= 2.4 && p.panicRoomT > 0); + p.weapon = { id: 'bonepopper', ammo: Infinity }; + p.fireT = 0; + ctx.Input.mdown = true; step(3, 16); ctx.Input.mdown = false; + check('panic room prevents firing', ctx.G.bullets.length === 0); + p.panicRoomT = 0; p.invT = 1; + p.active = null; + ctx.G.pickups.length = 0; +} + console.log('== phase-3 hook passives =='); { const p = ctx.G.player; @@ -1016,8 +1056,8 @@ console.log('== item stickiness ramps with owned count =='); console.log('== help manual grows with the implant roster =='); { - check('11 help pages (5 implant pages for 82 items)', ctx.HELP_PAGES.length === 11); - check('11 help renderers', ctx.HELP_RENDERERS.length === 11); + check('12 help pages (5 implants + actives)', ctx.HELP_PAGES.length === 12); + check('12 help renderers', ctx.HELP_RENDERERS.length === 12); check('help tabs never overflow the panel', (() => { const last = ctx.helpTabRect(ctx.HELP_PAGES.length - 1); const first = ctx.helpTabRect(0); diff --git a/tools/draw_sprites.py b/tools/draw_sprites.py index d354a83..2e4d497 100644 --- a/tools/draw_sprites.py +++ b/tools/draw_sprites.py @@ -99,7 +99,8 @@ def finish_sprite(img, name): # Passive items are presented as filthy relic medallions rather than # ungrounded clip-art silhouettes. The object remains unique; the shared # iron/bone mount makes the complete inventory read as one designed set. - if name.startswith("i_"): + if name.startswith(("i_", "a_")): + is_active = name.startswith("a_") plate = Image.new("RGBA", graded.size, (0, 0, 0, 0)) pd = ImageDraw.Draw(plate) w, h = graded.size @@ -107,9 +108,14 @@ def finish_sprite(img, name): (w * 0.91, h * 0.76), (w * 0.62, h - 2), (w * 0.25, h * 0.91), (2, h * 0.63), (w * 0.08, h * 0.24)] rim = [(round(x), round(y)) for x, y in rim] - pd.polygon(rim, fill=(14, 5, 9, 248), outline=(104, 55, 45, 255), width=3) - pd.ellipse([7, 7, w - 8, h - 8], fill=(31, 11, 19, 235), outline=(150, 92, 57, 255), width=2) - pd.arc([11, 11, w - 12, h - 12], 205, 330, fill=(225, 186, 113, 210), width=2) + # actives get a teal-copper mount so they read as usable, not passive + rim_outline = (45, 104, 98, 255) if is_active else (104, 55, 45, 255) + plate_fill = (9, 24, 26, 235) if is_active else (31, 11, 19, 235) + ring = (92, 202, 190, 255) if is_active else (150, 92, 57, 255) + arc = (120, 240, 224, 210) if is_active else (225, 186, 113, 210) + pd.polygon(rim, fill=(14, 5, 9, 248), outline=rim_outline, width=3) + pd.ellipse([7, 7, w - 8, h - 8], fill=plate_fill, outline=ring, width=2) + pd.arc([11, 11, w - 12, h - 12], 205, 330, fill=arc, width=2) for rx, ry in ((9, 16), (w - 11, 17), (13, h - 13), (w - 14, h - 12)): pd.ellipse([rx - 2, ry - 2, rx + 2, ry + 2], fill=(83, 91, 91, 255), outline=OUTL, width=1) plate.alpha_composite(graded) @@ -118,7 +124,7 @@ def finish_sprite(img, name): px = graded.load() rng = random.Random(sum(ord(c) for c in name) * 97) area = graded.width * graded.height - is_icon = name.startswith(("w_", "wt_", "i_")) + is_icon = name.startswith(("w_", "wt_", "i_", "a_")) is_tile = name.startswith(("tile_", "door_", "pedestal", "stairs")) grit = area // (82 if is_tile else (62 if is_icon else 150)) for _ in range(max(10, grit)): diff --git a/tools/gen_assets.py b/tools/gen_assets.py index e20c698..8f00c96 100644 --- a/tools/gen_assets.py +++ b/tools/gen_assets.py @@ -305,6 +305,21 @@ ITEMS = { for iid, subject in ITEMS.items(): SPECS["i_" + iid] = Spec(subject + ", isolated inventory object, strong top-left light, no medallion, no icon background", 32) +ACTIVES = { + "bonenova": "a radial burst of sharp bone shards erupting from a central skull", + "offalbomb": "a fused bomb made of packed offal with a lit intestine fuse", + "bloodtransfusion": "a blood bag on a steel hook with a transfusion tube and needle", + "cleaverstorm": "a whirlwind spiral of twelve small orbiting meat cleavers", + "butchersbell": "a huge rusted slaughterhouse dinner bell with a bone clapper", + "marrowdraught": "a frothing tankard of dark marrow with a bone straw", + "slaughtertime": "a cracked hourglass with blood instead of sand, frozen mid-drop", + "panicroom": "a small riveted iron bunker door with a spinning lock wheel", + "skinnerscoin": "a gleaming gold coin stamped with a skinning knife", + "gutreroll": "a loop of intestine twisted into a circular arrow with dice pips", +} +for aid, subject in ACTIVES.items(): + SPECS["a_" + aid] = Spec(subject + ", isolated inventory object, strong top-left light, no medallion, no icon background", 32) + # Reference-derived industrial ramps. All generated images are mapped onto this # palette so independent API calls remain visually coherent.