fix: persistent mute toggle, 25%/floor shield regen, halved pressure relief, shielded hits no relief, drop Scavenger perk (0.9.2)

This commit is contained in:
Quintine committed 2026-08-13 17:24:27 +10:00
1 parent 683e7a47ec
commit c41ce5fdb0
16 files changed
+105 -71

No files matched your search

+2 -2
View File
@@ -13,7 +13,7 @@ MeatSlicer is an endless top-down arena roguelite. You are the Butcher, descendi
| [Gameplay Guide](gameplay.md) | Core loop, controls, player stats, rooms & floors, pickups, leveling, scoring, pressure/difficulty systems, HUD | | [Gameplay Guide](gameplay.md) | Core loop, controls, player stats, rooms & floors, pickups, leveling, scoring, pressure/difficulty systems, HUD |
| [Weapons](weapons.md) | All 16 weapons with exact stats, drop tiers, and damage math | | [Weapons](weapons.md) | All 16 weapons with exact stats, drop tiers, and damage math |
| [Items](items.md) | All 82 passive items, stacking/tier rules, proc numbers, synergies | | [Items](items.md) | All 82 passive items, stacking/tier rules, proc numbers, synergies |
| [Perks](perks.md) | All 23 Mutation Draft perks, XP curve, draft mechanics | | [Perks](perks.md) | All 22 Mutation Draft perks, XP curve, draft mechanics |
| [Enemies](enemies.md) | All 11 enemy types, elites, drops, wave and scaling formulas | | [Enemies](enemies.md) | All 11 enemy types, elites, drops, wave and scaling formulas |
| [Bosses](bosses.md) | All 9 bosses: rotation, scaling, full attack patterns, counters | | [Bosses](bosses.md) | All 9 bosses: rotation, scaling, full attack patterns, counters |
| [Debug Console](dev-debug.md) | In-game cheat/inspection toolset (backtick `` ` `` key, `--dev` flag): all 8 tabs, run tainting, stat-rebuild internals | | [Debug Console](dev-debug.md) | In-game cheat/inspection toolset (backtick `` ` `` key, `--dev` flag): all 8 tabs, run tainting, stat-rebuild internals |
@@ -23,7 +23,7 @@ MeatSlicer is an endless top-down arena roguelite. You are the Butcher, descendi
- **16 weapons** (1 infinite-ammo sidearm + 15 special weapons across 4 tiers) - **16 weapons** (1 infinite-ammo sidearm + 15 special weapons across 4 tiers)
- **82 passive items** (stacking, tier cap IX) - **82 passive items** (stacking, tier cap IX)
- **23 perks** (draft 1 of 3 per level-up) - **22 perks** (draft 1 of 3 per level-up)
- **11 enemy types** (+ elite variants) - **11 enemy types** (+ elite variants)
- **9 bosses** (fixed rotation, one per floor) - **9 bosses** (fixed rotation, one per floor)
- **∞ floors** (permadeath, endless descent, high-score chase) - **∞ floors** (permadeath, endless descent, high-score chase)
+6 -6
View File
@@ -113,7 +113,7 @@ Right-click is disabled (context menu suppressed). There is **no interact key**
- Every hit taken deals a **minimum of 1 damage** (`max(1, round(dmg))`). - Every hit taken deals a **minimum of 1 damage** (`max(1, round(dmg))`).
- **Armor** gives a chance to completely ignore a hit. Each source of armor adds its listed chance directly (e.g. Tanned Hide +8%, Thick Hide +4%) — the block chance is simply `armor`, capped at 75%. A blocked hit shows "BLOCKED" and grants only 0.2 s of invulnerability. - **Armor** gives a chance to completely ignore a hit. Each source of armor adds its listed chance directly (e.g. Tanned Hide +8%, Thick Hide +4%) — the block chance is simply `armor`, capped at 75%. A blocked hit shows "BLOCKED" and grants only 0.2 s of invulnerability.
- **Shield hearts** (cyan) absorb damage before real HP. They come from the Bone Plate item and Shield Heart perk, and are refilled to full at the start of each floor. Losing your last shield triggers a "SHIELD DOWN" warning. On the HUD they render at half-heart granularity — **2 shield HP = one full cyan heart**, matching red hearts (2 HP = 1 heart). - **Shield hearts** (cyan) absorb damage before real HP. They come from the Bone Plate item and Shield Heart perk, and regenerate each floor: 25% of your max shields, rounded up to the next whole shield (1 shield = one full cyan heart = 2 shield HP); a new run starts at full. Losing your last shield triggers a "SHIELD DOWN" warning. On the HUD they render at half-heart granularity — **2 shield HP = one full cyan heart**, matching red hearts (2 HP = 1 heart).
- Getting hit knocks you back 14 px, shakes the screen, and flashes a red vignette. - Getting hit knocks you back 14 px, shakes the screen, and flashes a red vignette.
### Healing ### Healing
@@ -205,7 +205,7 @@ Clearing a combat room drops:
| Elite enemy | 3× XP + 3–5 bonus gems · 18% + luck×10% **item** · else 45% + luck×15% ammo | | Elite enemy | 3× XP + 3–5 bonus gems · 18% + luck×10% **item** · else 45% + luck×15% ammo |
| Boss | 1 item pedestal + 1 weapon (rolled at floor + 2) + 1 guaranteed ammo + stairs · the boss room also stocks 1 ammo when you enter · the stair hatch stays sealed for 3 seconds after the kill | | Boss | 1 item pedestal + 1 weapon (rolled at floor + 2) + 1 guaranteed ammo + stairs · the boss room also stocks 1 ammo when you enter · the stair hatch stays sealed for 3 seconds after the kill |
**Luck** is a player stat (Lucky Coin, Crow Bait, Scavenger perk) that scales most drop chances as shown above. **Luck** is a player stat (Lucky Coin, Crow Bait) that scales most drop chances as shown above.
--- ---
@@ -224,7 +224,7 @@ The **PRESSURE** meter on the HUD is a run-long adaptive difficulty multiplier a
- Range: **0.60 – 2.00** (starts at 1.00). - Range: **0.60 – 2.00** (starts at 1.00).
- Gains for rooms cleared **without taking damage** (flawless streak). - Gains for rooms cleared **without taking damage** (flawless streak).
- Taking damage **relieves** pressure, scaled by how big the hit was, how low your HP is, and how many recent hits you've taken. - Taking damage **relieves** pressure, scaled by how big the hit was, how low your HP is, and how many recent hits you've taken. Hits that damage a shield grant no relief.
- Decays passively while below 35% HP, or if you stall in a room for 90+ seconds. - Decays passively while below 35% HP, or if you stall in a room for 90+ seconds.
- Only affects enemies spawned *after* the change — living enemies keep their stats. - Only affects enemies spawned *after* the change — living enemies keep their stats.
@@ -234,9 +234,9 @@ The title screen has a **PRESSURE DIAL** (−10 … +10) that tunes how pressure
| Dial | Rise / clean room | Relief on hit (base) | Behaviour | | Dial | Rise / clean room | Relief on hit (base) | Behaviour |
|---|---|---|---| |---|---|---|---|
| **−10** | −0.020 | 0.080 | Strong mercy; negative rise respects the 0.60 floor | | **−10** | −0.020 | 0.040 | Strong mercy; negative rise respects the 0.60 floor |
| **−5** | 0 | 0.050 | Mercy — pressure does not rise from clean rooms | | **−5** | 0 | 0.025 | Mercy — pressure does not rise from clean rooms |
| **0** | +0.010 | 0.030 | Standard — today's balance | | **0** | +0.010 | 0.015 | Standard tuning |
| **+5** | +0.050 | 0 (none) | Ratchet — pressure can only ever rise; score floored at 1.00× | | **+5** | +0.050 | 0 (none) | Ratchet — pressure can only ever rise; score floored at 1.00× |
| **+10** | +0.100 | 0 | Maximum ratchet; pressure respects the 2.00 ceiling | | **+10** | +0.100 | 0 | Maximum ratchet; pressure respects the 2.00 ceiling |
+2 -2
View File
@@ -113,7 +113,7 @@ All of these trigger from your weapon hits (orbital knives proc at half chance).
| **Twin Hearts** | +1 max heart, heal to full | `maxHp +2`, `hp = maxHp` — the premium Rare HP item | | **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 | | **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` | | **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) | | **Bone Plate** | +½ shield heart each floor | `shieldPerk +1`, `shieldHp +1` — shields regen 25% per floor (rounded up) (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 | | **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.08` — literal dodge chance, cap 75% | | **Tanned Hide** | +8% chance to ignore damage | `armor +0.08` — literal dodge chance, cap 75% |
| **Dead Man's Clock** | +0.25s hurt immunity | `invBonus +0.25` — post-hit invulnerability 0.9 s → 1.15 s at tier I | | **Dead Man's Clock** | +0.25s hurt immunity | `invBonus +0.25` — post-hit invulnerability 0.9 s → 1.15 s at tier I |
@@ -261,4 +261,4 @@ Holding an active contributes a flat **+2** to power score.
- **Split Tongue / Hydra Maw / Backstabber** multiply projectile count, which multiplies proc rolls per second — the core of most broken builds. - **Split Tongue / Hydra Maw / Backstabber** multiply projectile count, which multiplies proc rolls per second — the core of most broken builds.
- **Piercing Gaze + Ricochet Ribs** feed the slam/saw/beam "inert" bonus (+6% each) on The Tenderizer, Red Right Hand, and Spinal Tap, while also powering Swarm Jar maggots. - **Piercing Gaze + Ricochet Ribs** feed the slam/saw/beam "inert" bonus (+6% each) on The Tenderizer, Red Right Hand, and Spinal Tap, while also powering Swarm Jar maggots.
- **Volatile Bile** chains: explosions can kill, which triggers more explosions (and frenzy, and lifesteal procs). - **Volatile Bile** chains: explosions can kill, which triggers more explosions (and frenzy, and lifesteal procs).
- **Bone Plate + Shield Heart** (perk) stack into a large shield that refills free every floor. - **Bone Plate + Shield Heart** (perk) stack into a large shield pool that regens 25% per floor.
+13 -14
View File
@@ -1,12 +1,12 @@
# Perks # Perks
All **23 perks** in MeatSlicer, with exact effects pulled from `js/perks.js`. Perks are chosen through the **Mutation Draft** each time you level up, and last for the rest of the run. All **22 perks** in MeatSlicer, with exact effects pulled from `js/perks.js`. Perks are chosen through the **Mutation Draft** each time you level up, and last for the rest of the run.
--- ---
## The Mutation Draft ## The Mutation Draft
- Gaining a level queues a draft. When it opens, you're offered **3 random perks** from the full pool — all 23 perks are equally weighted; there is no rarity system. - Gaining a level queues a draft. When it opens, you're offered **3 random perks** from the full pool — all 22 perks are equally weighted; there is no rarity system.
- Choose with **1 / 2 / 3**, by **clicking a card**, or press **4 / Space** for a "Random Cut". - Choose with **1 / 2 / 3**, by **clicking a card**, or press **4 / Space** for a "Random Cut".
- Press **R** to **reroll** all three choices (costs 1 reroll token). Rerolls come from the **Reroll Rib** item, which also grants +1 reroll every level-up. - Press **R** to **reroll** all three choices (costs 1 reroll token). Rerolls come from the **Reroll Rib** item, which also grants +1 reroll every level-up.
- Press **T** to toggle **Auto-Draft**: future level-ups instantly pick a random perk without pausing the game. The setting persists between sessions. - Press **T** to toggle **Auto-Draft**: future level-ups instantly pick a random perk without pausing the game. The setting persists between sessions.
@@ -54,7 +54,7 @@ XP comes from gems dropped by kills, room clears, and bosses, scaled by your `xp
| Perk | Description | Exact effect | | Perk | Description | Exact effect |
|---|---|---| |---|---|---|
| **Big Heart** | +½ max heart, heal ½ heart | `maxHp +1`, heal 1 | | **Big Heart** | +½ max heart, heal ½ heart | `maxHp +1`, heal 1 |
| **Shield Heart** | +½ shield heart at the start of each floor | `shieldPerk +1`, `shieldHp +1` — refills every floor transition | | **Shield Heart** | +½ shield heart at the start of each floor | `shieldPerk +1`, `shieldHp +1` — regen: 25% of max shields (rounded up to a whole shield) each floor transition |
| **Thick Hide** | +4% chance to ignore damage | `armor +0.04` — literal dodge chance, cap 75% | | **Thick Hide** | +4% chance to ignore damage | `armor +0.04` — literal dodge chance, cap 75% |
| **Second Wind** | +0.15s hurt immunity | `invBonus +0.15` — post-hit invulnerability 0.9 s → 1.05 s | | **Second Wind** | +0.15s hurt immunity | `invBonus +0.15` — post-hit invulnerability 0.9 s → 1.05 s |
| **Spite Flesh** | Contact attackers take damage | `thorns +4` — melee attackers take `4 × dmgMul` | | **Spite Flesh** | Contact attackers take damage | `thorns +4` — melee attackers take `4 × dmgMul` |
@@ -68,7 +68,7 @@ XP comes from gems dropped by kills, room clears, and bosses, scaled by your `xp
| **Sinew Weave** | +3% move speed and range | `speedMul ×1.03`, `rangeMul ×1.03` | | **Sinew Weave** | +3% move speed and range | `speedMul ×1.03`, `rangeMul ×1.03` |
| **Magnet Bile** | +10% pickup radius | `magnet ×1.10` | | **Magnet Bile** | +10% pickup radius | `magnet ×1.10` |
| **Carrion Sense** | +8% pickup radius and pull speed | `magnet ×1.08`, `magnetPull ×1.08` *(note: pull speed is not currently wired into pickup movement — the radius half works)* | | **Carrion Sense** | +8% pickup radius and pull speed | `magnet ×1.08`, `magnetPull ×1.08` *(note: pull speed is not currently wired into pickup movement — the radius half works)* |
| **Scavenger** | +4% drop luck | `luck +0.04` |
| **Bloodrush** | +5% XP gain, bonus crystal chance | `xpMul ×1.05` | | **Bloodrush** | +5% XP gain, bonus crystal chance | `xpMul ×1.05` |
| **Scrap Feed** | 5% less ammo (diminishing) | `ammoEff +0.05` per stack — saving fraction = rating/(1+rating), so each extra stack helps less (like Thick Hide) | | **Scrap Feed** | 5% less ammo (diminishing) | `ammoEff +0.05` per stack — saving fraction = rating/(1+rating), so each extra stack helps less (like Thick Hide) |
@@ -93,18 +93,17 @@ XP comes from gems dropped by kills, room clears, and bosses, scaled by your `xp
| 13 | Long Bone | Offensive | | 13 | Long Bone | Offensive |
| 14 | Magnet Bile | Utility | | 14 | Magnet Bile | Utility |
| 15 | Quickening | Mobility | | 15 | Quickening | Mobility |
| 16 | Scavenger | Utility | | 16 | Scrap Feed | Utility |
| 17 | Scrap Feed | Utility | | 17 | Second Wind | Defensive |
| 18 | Second Wind | Defensive | | 18 | Sharpened | Offensive |
| 19 | Sharpened | Offensive | | 19 | Shield Heart | Defensive |
| 20 | Shield Heart | Defensive | | 20 | Sinew Weave | Mobility |
| 21 | Sinew Weave | Mobility | | 21 | Spite Flesh | Defensive |
| 22 | Spite Flesh | Defensive | | 22 | Thick Hide | Defensive |
| 23 | Thick Hide | Defensive |
## Drafting advice ## Drafting advice
- **Perks are small, items are big.** A perk is worth ~0.5 power score; an item tier is worth 1.0. Perks smooth out a build; items define it. - **Perks are small, items are big.** A perk is worth ~0.5 power score; an item tier is worth 1.0. Perks smooth out a build; items define it.
- **Shield Heart** is one of the strongest defensive picks — it's a free refilling shield pip every floor, and it stacks with Bone Plate. - **Shield Heart** is one of the strongest defensive picks — it's free shield capacity that regens 25% each floor, and it stacks with Bone Plate.
- **Scavenger / Bloodrush / Magnet Bile** early compound over a long run; **Sharpened / Adrenal Surge** scale whatever your weapon already does well. - **Bloodrush / Magnet Bile** early compound over a long run; **Sharpened / Adrenal Surge** scale whatever your weapon already does well.
- With **Auto-Draft** on, perks become a no-attention background bonus — good for speedrunning floors, bad if you're fishing for a specific build. - With **Auto-Draft** on, perks become a no-attention background bonus — good for speedrunning floors, bad if you're fishing for a specific build.
+22 -22
View File
@@ -6,32 +6,32 @@
<meta name="theme-color" content="#0a0305"> <meta name="theme-color" content="#0a0305">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><text y=%2252%22 x=%2250%22 text-anchor=%22middle%22 dominant-baseline=%22middle%22 font-size=%2252%22>🔪</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><text y=%2252%22 x=%2250%22 text-anchor=%22middle%22 dominant-baseline=%22middle%22 font-size=%2252%22>🔪</text></svg>">
<title>MEATSLICER</title> <title>MEATSLICER</title>
<link rel="stylesheet" href="css/style.css?v=57"> <link rel="stylesheet" href="css/style.css?v=58">
</head> </head>
<body> <body>
<div id="wrap"> <div id="wrap">
<canvas id="game" width="960" height="640"></canvas> <canvas id="game" width="960" height="640"></canvas>
</div> </div>
<script src="js/utils.js?v=57"></script> <script src="js/utils.js?v=58"></script>
<script src="js/state.js?v=57"></script> <script src="js/state.js?v=58"></script>
<script src="js/input.js?v=57"></script> <script src="js/input.js?v=58"></script>
<script src="js/sfx.js?v=57"></script> <script src="js/sfx.js?v=58"></script>
<script src="js/sfxbank.js?v=57"></script> <script src="js/sfxbank.js?v=58"></script>
<script src="js/audio.js?v=57"></script> <script src="js/audio.js?v=58"></script>
<script src="js/sprites.js?v=57"></script> <script src="js/sprites.js?v=58"></script>
<script src="js/particles.js?v=57"></script> <script src="js/particles.js?v=58"></script>
<script src="js/weapons.js?v=57"></script> <script src="js/weapons.js?v=58"></script>
<script src="js/bullets.js?v=57"></script> <script src="js/bullets.js?v=58"></script>
<script src="js/pickups.js?v=57"></script> <script src="js/pickups.js?v=58"></script>
<script src="js/items.js?v=57"></script> <script src="js/items.js?v=58"></script>
<script src="js/perks.js?v=57"></script> <script src="js/perks.js?v=58"></script>
<script src="js/enemies.js?v=57"></script> <script src="js/enemies.js?v=58"></script>
<script src="js/bosses.js?v=57"></script> <script src="js/bosses.js?v=58"></script>
<script src="js/rooms.js?v=57"></script> <script src="js/rooms.js?v=58"></script>
<script src="js/player.js?v=57"></script> <script src="js/player.js?v=58"></script>
<script src="js/hud.js?v=57"></script> <script src="js/hud.js?v=58"></script>
<script src="js/help.js?v=57"></script> <script src="js/help.js?v=58"></script>
<script src="js/debug.js?v=57"></script> <script src="js/debug.js?v=58"></script>
<script src="js/main.js?v=57"></script> <script src="js/main.js?v=58"></script>
</body> </body>
</html> </html>
+4 -4
View File
@@ -178,7 +178,7 @@ function drawHelpPerkList(ctx, px, py) {
let ty = py + HELP_BODY; let ty = py + HELP_BODY;
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.fillStyle = '#d7a934'; ctx.font = hfont(9, true); ctx.fillStyle = '#d7a934'; ctx.font = hfont(9, true);
ctx.fillText('23 PERKS · DRAFT 1 OF 3 PER LEVEL · XP FOR NEXT LEVEL: 8 + LEVEL × 4', x, ty); ctx.fillText(PERKS.length + ' PERKS · DRAFT 1 OF 3 PER LEVEL · XP FOR NEXT LEVEL: 8 + LEVEL × 4', x, ty);
ty += 11; ty += 11;
ctx.fillStyle = '#7d6a68'; ctx.font = hfont(8, false); ctx.fillStyle = '#7d6a68'; ctx.font = hfont(8, false);
ctx.fillText('REROLL RIB GRANTS TOKENS (R TO REDRAW) · AUTO-DRAFT (T) PICKS RANDOMLY', x, ty + 11); ctx.fillText('REROLL RIB GRANTS TOKENS (R TO REDRAW) · AUTO-DRAFT (T) PICKS RANDOMLY', x, ty + 11);
@@ -304,11 +304,11 @@ function drawHelpPressure(ctx, px, py) {
'ALL SCORE IS MULTIPLIED BY LIVE PRESSURE', 'ALL SCORE IS MULTIPLIED BY LIVE PRESSURE',
'DIAL ' + (d > 0 ? '+' : '') + d + ' — RISE ' + gain.toFixed(1) + '/10 PER CLEAN ROOM · RELIEF ' + drop.toFixed(1) + '/10 ON HIT', 'DIAL ' + (d > 0 ? '+' : '') + d + ' — RISE ' + gain.toFixed(1) + '/10 PER CLEAN ROOM · RELIEF ' + drop.toFixed(1) + '/10 ON HIT',
'+' + pressureGain().toFixed(3) + ' FOR EVERY ROOM CLEARED WITHOUT TAKING ANY DAMAGE', '+' + pressureGain().toFixed(3) + ' FOR EVERY ROOM CLEARED WITHOUT TAKING ANY DAMAGE',
'RELIEF ON HIT = ' + (0.03 * scale).toFixed(3) + ' × SEVERITY × DESPERATION × CHURN', 'RELIEF ON HIT = ' + (PRESSURE_DROP_BASE * PRESSURE_UNIT * PRESSURE_RELIEF_MUL * scale).toFixed(3) + ' × SEVERITY × DESPERATION × CHURN',
' SEVERITY = HIT / (MAX HP × 0.25), CLAMPED 0.4 – 2', ' SEVERITY = HIT / (MAX HP × 0.25), CLAMPED 0.4 – 2',
' DESPERATION = 1 + 2 × (1 − HP FRACTION) — MORE RELIEF WHEN HURT', ' DESPERATION = 1 + 2 × (1 − HP FRACTION) — MORE RELIEF WHEN HURT',
' CHURN = 1 + 0.35 × HITS IN LAST 12S (CAP +4) — MORE WHEN SWARMED', ' CHURN = 1 + 0.35 × HITS IN LAST 12S (CAP +4) — MORE WHEN SWARMED',
'DECAY: ' + (0.02 * scale).toFixed(3) + '/S BELOW 35% HP · ' + (0.008 * scale).toFixed(3) + '/S AFTER 90S IN ONE ROOM', 'DECAY: ' + (0.02 * scale * PRESSURE_RELIEF_MUL).toFixed(3) + '/S BELOW 35% HP · ' + (0.008 * scale * PRESSURE_RELIEF_MUL).toFixed(3) + '/S AFTER 90S IN ONE ROOM',
'LIVING ENEMIES KEEP THEIR STATS — ONLY NEW SPAWNS CHANGE', 'LIVING ENEMIES KEEP THEIR STATS — ONLY NEW SPAWNS CHANGE',
'STREAK = CONSECUTIVE FLAWLESS CLEARS (COSMETIC)', 'STREAK = CONSECUTIVE FLAWLESS CLEARS (COSMETIC)',
] }, ] },
@@ -316,7 +316,7 @@ function drawHelpPressure(ctx, px, py) {
'ARMOR = BLOCK CHANCE: RATING / (1 + RATING), HARD CAP 75%', 'ARMOR = BLOCK CHANCE: RATING / (1 + RATING), HARD CAP 75%',
' THICK HIDE +1/24 RATING · TANNED HIDE +0.08/0.92 (DIMINISHING)', ' THICK HIDE +1/24 RATING · TANNED HIDE +0.08/0.92 (DIMINISHING)',
'BLOCKED HIT: 0 DAMAGE, 0.2S I-FRAMES, STREAK PRESERVED', 'BLOCKED HIT: 0 DAMAGE, 0.2S I-FRAMES, STREAK PRESERVED',
'SHIELD HEARTS ABSORB FIRST, REFILL EACH FLOOR · 0.9S I-FRAMES ON HIT', 'SHIELD HEARTS ABSORB FIRST, REGEN 25% EACH FLOOR · SHIELDED HITS NO RELIEF · 0.9S I-FRAMES ON HIT',
'CRIT: 5% BASE CHANCE, ×2 DAMAGE (CRITMUL STACKS)', 'CRIT: 5% BASE CHANCE, ×2 DAMAGE (CRITMUL STACKS)',
] }, ] },
], HELP_PANEL.w - 80); ], HELP_PANEL.w - 80);
+1 -1
View File
@@ -53,7 +53,7 @@ const ITEMS = {
orbitalknives: { name: 'Orbital Knives', desc: '+1 circling knife', rarity: 'rare', cap: 3, apply: s => { s.orbitals += 1; } }, 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; } }, 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; } }, 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; } }, boneplate: { name: 'Bone Plate', desc: '+½ max shield heart (regens 25% per 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; } }, 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); } }, 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; } }, 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; } },
+5 -1
View File
@@ -50,6 +50,7 @@ function init() {
if (Number.isFinite(sv)) G.sfxVol = clamp(sv, 0, 1); if (Number.isFinite(sv)) G.sfxVol = clamp(sv, 0, 1);
if (Number.isFinite(mv)) G.musicVol = clamp(mv, 0, 1); if (Number.isFinite(mv)) G.musicVol = clamp(mv, 0, 1);
G.autoPerk = localStorage.getItem('meatslicer_autoperk') === '1'; G.autoPerk = localStorage.getItem('meatslicer_autoperk') === '1';
G.muted = localStorage.getItem('meatslicer_muted') === '1';
const pd = parseFloat(localStorage.getItem('meatslicer_pressure_dial')); const pd = parseFloat(localStorage.getItem('meatslicer_pressure_dial'));
if (Number.isFinite(pd)) G.pressureDial = clamp(Math.round(pd), PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX); if (Number.isFinite(pd)) G.pressureDial = clamp(Math.round(pd), PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX);
const ha = parseFloat(localStorage.getItem('meatslicer_hud_alpha')); const ha = parseFloat(localStorage.getItem('meatslicer_hud_alpha'));
@@ -67,6 +68,9 @@ function toggleMute() {
G.muted = !G.muted; G.muted = !G.muted;
Music.setMuted(G.muted); Music.setMuted(G.muted);
Sfx.setMuted(G.muted); Sfx.setMuted(G.muted);
try { localStorage.setItem('meatslicer_muted', G.muted ? '1' : '0'); } catch (e) {}
addToast(G.muted ? 'AUDIO MUTED' : 'AUDIO UNMUTED', G.muted ? 'all sound is silenced' : 'sound restored');
if (!G.muted) Sfx.menu();
} }
function setHudAlpha(v) { function setHudAlpha(v) {
@@ -559,7 +563,7 @@ function drawPause(ctx) {
const menuConfirm = G.confirmAction === 'menu'; const menuConfirm = G.confirmAction === 'menu';
const desktopExit = canQuitDesktop(); const desktopExit = canQuitDesktop();
const exitConfirm = desktopExit && G.confirmAction === 'desktop'; const exitConfirm = desktopExit && G.confirmAction === 'desktop';
drawPixelTag(ctx, '[M] MUTE AUDIO', gridX[0], 526, { width: 150, height: 28, color: muteHover ? '#f5e9d6' : '#bba9a1', accent: muteHover ? '#b5243a' : '#6e3843' }); drawPixelTag(ctx, '[M] MUTE ' + (G.muted ? 'ON' : 'OFF'), gridX[0], 526, { width: 150, height: 28, color: G.muted ? '#79d9ca' : (muteHover ? '#f5e9d6' : '#bba9a1'), accent: G.muted ? '#378b80' : (muteHover ? '#b5243a' : '#6e3843') });
drawPixelTag(ctx, menuConfirm ? '[Q] CONFIRM MENU' : '[Q] MAIN MENU', PAUSE_MENU_BUTTON.x, PAUSE_MENU_BUTTON.y, { drawPixelTag(ctx, menuConfirm ? '[Q] CONFIRM MENU' : '[Q] MAIN MENU', PAUSE_MENU_BUTTON.x, PAUSE_MENU_BUTTON.y, {
width: PAUSE_MENU_BUTTON.w, height: PAUSE_MENU_BUTTON.h, width: PAUSE_MENU_BUTTON.w, height: PAUSE_MENU_BUTTON.h,
color: menuConfirm ? '#ffd36a' : (menuHover ? '#f5e9d6' : '#bba9a1'), color: menuConfirm ? '#ffd36a' : (menuHover ? '#f5e9d6' : '#bba9a1'),
+1 -2
View File
@@ -7,8 +7,7 @@ const PERKS = [
{ id: 'longbone', name: 'Long Bone', desc: '+5% range', apply: s => { s.rangeMul *= 1.05; } }, { id: 'longbone', name: 'Long Bone', desc: '+5% range', apply: s => { s.rangeMul *= 1.05; } },
{ id: 'bigheart', name: 'Big Heart', desc: '+½ max heart, heal ½ heart', apply: (s, p) => { s.maxHp += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } }, { id: 'bigheart', name: 'Big Heart', desc: '+½ max heart, heal ½ heart', apply: (s, p) => { s.maxHp += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } },
{ id: 'magnetb', name: 'Magnet Bile', desc: '+10% pickup radius', apply: s => { s.magnet *= 1.10; } }, { id: 'magnetb', name: 'Magnet Bile', desc: '+10% pickup radius', apply: s => { s.magnet *= 1.10; } },
{ id: 'shieldheart', name: 'Shield Heart', desc: '+½ shield heart at the start of each floor', apply: (s, p) => { s.shieldPerk = (s.shieldPerk || 0) + 1; p.shieldHp = (p.shieldHp || 0) + 1; } }, { id: 'shieldheart', name: 'Shield Heart', desc: '+½ max shield heart (regens 25% per floor)', apply: (s, p) => { s.shieldPerk = (s.shieldPerk || 0) + 1; p.shieldHp = (p.shieldHp || 0) + 1; } },
{ id: 'scavenge', name: 'Scavenger', desc: '+4% drop luck', apply: s => { s.luck += 0.04; } },
{ id: 'bloodrush',name: 'Bloodrush', desc: '+5% XP gain, bonus crystal chance', apply: s => { s.xpMul *= 1.05; } }, { id: 'bloodrush',name: 'Bloodrush', desc: '+5% XP gain, bonus crystal chance', apply: s => { s.xpMul *= 1.05; } },
{ id: 'deadeye', name: 'Deadeye', desc: '+5% shot speed', apply: s => { s.shotSpeedMul *= 1.05; } }, { id: 'deadeye', name: 'Deadeye', desc: '+5% shot speed', apply: s => { s.shotSpeedMul *= 1.05; } },
{ id: 'critbone', name: 'Bone Splitter', desc: '+2% critical chance', apply: s => { s.crit += 0.02; } }, { id: 'critbone', name: 'Bone Splitter', desc: '+2% critical chance', apply: s => { s.crit += 0.02; } },
+4 -3
View File
@@ -305,7 +305,7 @@ function pressureRelief(dmgTaken) {
const severity = clamp(dmgTaken / Math.max(2, p.stats.maxHp * 0.25), 0.4, 2); const severity = clamp(dmgTaken / Math.max(2, p.stats.maxHp * 0.25), 0.4, 2);
const desperation = 1 + (1 - hpFrac) * 2; const desperation = 1 + (1 - hpFrac) * 2;
const churn = 1 + Math.min(G.recentHits.length, 4) * 0.35; const churn = 1 + Math.min(G.recentHits.length, 4) * 0.35;
return PRESSURE_DROP_BASE * PRESSURE_UNIT * severity * desperation * churn * pressureDropScale(); return PRESSURE_DROP_BASE * PRESSURE_UNIT * severity * desperation * churn * pressureDropScale() * PRESSURE_RELIEF_MUL;
} }
function hurtPlayer(dmg, ang, attacker) { function hurtPlayer(dmg, ang, attacker) {
@@ -333,8 +333,9 @@ function hurtPlayer(dmg, ang, attacker) {
// Meat Grinder: aura damages you for +1 per hit taken // Meat Grinder: aura damages you for +1 per hit taken
if (p.stats.meatGrinder > 0) reduced += p.stats.meatGrinder; if (p.stats.meatGrinder > 0) reduced += p.stats.meatGrinder;
// shield hearts absorb first (from the Shield Heart perk) // shield hearts absorb first (from the Shield Heart perk)
let absorbed = 0;
if (p.shieldHp > 0) { if (p.shieldHp > 0) {
const absorbed = Math.min(p.shieldHp, reduced); absorbed = Math.min(p.shieldHp, reduced);
p.shieldHp -= absorbed; p.shieldHp -= absorbed;
reduced -= absorbed; reduced -= absorbed;
if (p.shieldHp <= 0) { spawnText(p.x, p.y - 20, 'SHIELD DOWN', '#3bc9e0'); Sfx.shieldBreak(); } if (p.shieldHp <= 0) { spawnText(p.x, p.y - 20, 'SHIELD DOWN', '#3bc9e0'); Sfx.shieldBreak(); }
@@ -352,7 +353,7 @@ function hurtPlayer(dmg, ang, attacker) {
G.roomDamaged = true; G.roomDamaged = true;
G.recentHits = (G.recentHits || []).filter(t => G.time - t < 12); G.recentHits = (G.recentHits || []).filter(t => G.time - t < 12);
G.recentHits.push(G.time); G.recentHits.push(G.time);
applyPressureDelta(-pressureRelief(reduced)); if (absorbed === 0) applyPressureDelta(-pressureRelief(reduced));
G.streak = 0; G.streak = 0;
} }
if (attacker && attacker.hp > 0 && p.stats.thorns > 0) { if (attacker && attacker.hp > 0 && p.stats.thorns > 0) {
+7 -4
View File
@@ -202,7 +202,7 @@ function updateRoom(dt) {
const stalled = G.time - G.roomEnterT > 90; const stalled = G.time - G.roomEnterT > 90;
if (lowHealth || stalled) { if (lowHealth || stalled) {
const decay = lowHealth ? 0.02 : 0.008; const decay = lowHealth ? 0.02 : 0.008;
applyPressureDelta(-decay * dt * pressureDropScale()); applyPressureDelta(-decay * dt * pressureDropScale() * PRESSURE_RELIEF_MUL);
} }
} }
G.recentHits = (G.recentHits || []).filter(t => G.time - t < 12); G.recentHits = (G.recentHits || []).filter(t => G.time - t < 12);
@@ -304,9 +304,12 @@ function nextFloor() {
Sfx.stairs(); Sfx.stairs();
genFloor(G.floor); genFloor(G.floor);
enterRoom(0, 0); enterRoom(0, 0);
// Shield Heart perk: fresh shield hearts each floor // Shield Heart: regen 25% of max shields per floor, rounded up to the next
G.player.shieldHp = G.player.stats.shieldPerk || 0; // whole shield (1 shield = 2 shieldHp). Full shields only at run start.
if (G.player.shieldHp > 0) { spawnText(G.player.x, G.player.y - 20, 'SHIELD UP', '#3bc9e0'); Sfx.shieldUp(); } const maxShield = G.player.stats.shieldPerk || 0;
const shieldBefore = G.player.shieldHp;
G.player.shieldHp = Math.min(maxShield, G.player.shieldHp + 2 * Math.ceil(maxShield / 8));
if (G.player.shieldHp > shieldBefore) { spawnText(G.player.x, G.player.y - 20, 'SHIELD UP', '#3bc9e0'); Sfx.shieldUp(); }
// legendary once-per-floor flags reset // legendary once-per-floor flags reset
G.player.secondSkinUsed = false; G.player.secondSkinUsed = false;
G.player.lastCutActive = false; G.player.lastCutActive = false;
+1 -1
View File
@@ -111,7 +111,7 @@ const SfxBank = {
if (this.buffers.has(name) || this.pending.has(name) || this.failed.has(name)) return; if (this.buffers.has(name) || this.pending.has(name) || this.failed.has(name)) return;
const file = this.FILES[name]; const file = this.FILES[name];
if (!file || !this.ctx || typeof fetch !== 'function') return; if (!file || !this.ctx || typeof fetch !== 'function') return;
const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=57') const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=58')
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); }) .then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
.then(bytes => this.ctx.decodeAudioData(bytes)) .then(bytes => this.ctx.decodeAudioData(bytes))
.then(buffer => this.buffers.set(name, this.trim(buffer))) .then(buffer => this.buffers.set(name, this.trim(buffer)))
+3 -3
View File
@@ -18,7 +18,7 @@ const SPRITE_MANIFEST = [
'decal_blood1', 'decal_blood2', 'decal_blood3', 'decal_blood4', 'decal_blood1', 'decal_blood2', 'decal_blood3', 'decal_blood4',
// level-up perk emblems // level-up perk emblems
'perk_adrenal', 'perk_sharpen', 'perk_quick', 'perk_longbone', 'perk_bigheart', 'perk_adrenal', 'perk_sharpen', 'perk_quick', 'perk_longbone', 'perk_bigheart',
'perk_magnetb', 'perk_shieldheart', 'perk_scavenge', 'perk_bloodrush', 'perk_deadeye', 'perk_magnetb', 'perk_shieldheart', 'perk_bloodrush', 'perk_deadeye',
'perk_critbone', 'perk_critmeat', 'perk_flensing', 'perk_ember', 'perk_frostbile', 'perk_critbone', 'perk_critmeat', 'perk_flensing', 'perk_ember', 'perk_frostbile',
'perk_heavyhand', 'perk_thickhide', 'perk_secondwind', 'perk_scrapfeed', 'perk_boneknit', 'perk_heavyhand', 'perk_thickhide', 'perk_secondwind', 'perk_scrapfeed', 'perk_boneknit',
'perk_spiteflesh', 'perk_carrion', 'perk_sinew', 'perk_spiteflesh', 'perk_carrion', 'perk_sinew',
@@ -88,10 +88,10 @@ const Sprites = {
const finish = () => { if (--pending === 0) G.imagesLoaded = true; }; const finish = () => { if (--pending === 0) G.imagesLoaded = true; };
img.onload = () => { this.imgs[name] = img; if (typeof img.decode === 'function') img.decode().then(finish, finish); else finish(); }; img.onload = () => { this.imgs[name] = img; if (typeof img.decode === 'function') img.decode().then(finish, finish); else finish(); };
img.onerror = () => { img.onerror = () => {
if (hd && !img._sdRetry) { img._sdRetry = true; img.src = 'assets/' + name + '.png?v=57'; return; } if (hd && !img._sdRetry) { img._sdRetry = true; img.src = 'assets/' + name + '.png?v=58'; return; }
finish(); finish();
}; };
img.src = hd ? 'assets/hd/' + name + '.webp?v=57' : 'assets/' + name + '.png?v=57'; img.src = hd ? 'assets/hd/' + name + '.webp?v=58' : 'assets/' + name + '.png?v=58';
} }
}, },
+1
View File
@@ -70,6 +70,7 @@ const WEAPON_DROP_LOCKOUT = 2.5;
const PRESSURE_UNIT = 0.01; // one "point" on the dial scale const PRESSURE_UNIT = 0.01; // one "point" on the dial scale
const PRESSURE_GAIN = PRESSURE_UNIT; // retained: dial 0 == 1 point const PRESSURE_GAIN = PRESSURE_UNIT; // retained: dial 0 == 1 point
const PRESSURE_DROP_BASE = 3; // dial 0 == 0.03 relief base const PRESSURE_DROP_BASE = 3; // dial 0 == 0.03 relief base
const PRESSURE_RELIEF_MUL = 0.5; // global brake: all pressure drops (hit relief + passive decay) are halved
const DEATH_LOCK = 3; const DEATH_LOCK = 3;
const PRESSURE_DIAL_MIN = -10, PRESSURE_DIAL_MAX = 10; const PRESSURE_DIAL_MIN = -10, PRESSURE_DIAL_MAX = 10;
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "meatslicer", "name": "meatslicer",
"productName": "MeatSlicer", "productName": "MeatSlicer",
"version": "0.9.1", "version": "0.9.2",
"private": true, "private": true,
"description": "A Butcher's Descent - clear, harvest, descend.", "description": "A Butcher's Descent - clear, harvest, descend.",
"main": "desktop/main.js", "main": "desktop/main.js",
+32 -5
View File
@@ -64,7 +64,7 @@ for (const f of files) {
} }
// const/let top-level bindings live in the context's lexical scope, not on the // 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. // 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, ACTIVES, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS, PRESSURE_UNIT, PRESSURE_MIN, PRESSURE_MAX, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX, ROOM_SHAPES, ROOM_THEMES, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN, ENTRY_WARN, ENTRY_BUFFER, STUN_UNIT, stunChance, separateEntryWave, defaultPlayerStats, applyPressureDelta, debugRebuildStats, debugRefillAmmo, debugEnabled, DEBUG_PAGES, updateCamera, mxW, myW, angleTo, genFloor, roomBounds, WEAPON_DROP_LOCKOUT, CAM_EDGE_PEEK })', 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_MIN, PRESSURE_MAX, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX, PRESSURE_DROP_BASE, PRESSURE_RELIEF_MUL, ROOM_SHAPES, ROOM_THEMES, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN, ENTRY_WARN, ENTRY_BUFFER, STUN_UNIT, stunChance, separateEntryWave, defaultPlayerStats, applyPressureDelta, debugRebuildStats, debugRefillAmmo, debugEnabled, DEBUG_PAGES, updateCamera, mxW, myW, angleTo, genFloor, roomBounds, WEAPON_DROP_LOCKOUT, CAM_EDGE_PEEK })', sandbox);
let simTime = 0; let simTime = 0;
const ctx = sandbox; const ctx = sandbox;
@@ -216,7 +216,7 @@ check('HD loader loads all images (fallbacks)', ctx.G.imagesLoaded === true);
let hdFallbackOk = 0; let hdFallbackOk = 0;
for (const name of ctx.SPRITE_MANIFEST) { for (const name of ctx.SPRITE_MANIFEST) {
const urls = attemptedUrls[name]; const urls = attemptedUrls[name];
const ok = urls && urls.length === 2 && urls[0] === 'assets/hd/' + name + '.webp?v=57' && urls[1] === 'assets/' + name + '.png?v=57'; const ok = urls && urls.length === 2 && urls[0] === 'assets/hd/' + name + '.webp?v=58' && urls[1] === 'assets/' + name + '.png?v=58';
if (ok) hdFallbackOk++; if (ok) hdFallbackOk++;
} }
check('HD loader tries WebP then PNG per sprite', hdFallbackOk === ctx.SPRITE_MANIFEST.length); check('HD loader tries WebP then PNG per sprite', hdFallbackOk === ctx.SPRITE_MANIFEST.length);
@@ -241,7 +241,7 @@ check('SD loader loads all images', ctx.G.imagesLoaded === true);
let sdUrlOk = 0; let sdUrlOk = 0;
for (const name of ctx.SPRITE_MANIFEST) { for (const name of ctx.SPRITE_MANIFEST) {
const urls = attemptedUrls[name]; const urls = attemptedUrls[name];
const ok = urls && urls.length === 1 && urls[0] === 'assets/' + name + '.png?v=57'; const ok = urls && urls.length === 1 && urls[0] === 'assets/' + name + '.png?v=58';
if (ok) sdUrlOk++; if (ok) sdUrlOk++;
} }
check('SD loader requests one PNG per sprite', sdUrlOk === ctx.SPRITE_MANIFEST.length); check('SD loader requests one PNG per sprite', sdUrlOk === ctx.SPRITE_MANIFEST.length);
@@ -475,7 +475,7 @@ check('player leveled', ctx.G.player.level > 1);
console.log('== rebalanced perks + automatic draft =='); console.log('== rebalanced perks + automatic draft ==');
{ {
check('13 new perks added', ctx.PERKS.length === 23); check('perk pool counts 22 perks', ctx.PERKS.length === 22);
const perkStats = { dmgMul: 1 }; const perkStats = { dmgMul: 1 };
ctx.PERKS.find(k => k.id === 'sharpen').apply(perkStats, { hp: 1 }); ctx.PERKS.find(k => k.id === 'sharpen').apply(perkStats, { hp: 1 });
check('core level-up damage bonus was halved', Math.abs(perkStats.dmgMul - 1.04) < 0.001); check('core level-up damage bonus was halved', Math.abs(perkStats.dmgMul - 1.04) < 0.001);
@@ -661,10 +661,25 @@ console.log('== shield heart perk ==');
check('unshielded hit hurts', p.hp === hpBefore - 1); check('unshielded hit hurts', p.hp === hpBefore - 1);
p.hp = hpBefore; p.hp = hpBefore;
ctx.nextFloor(); step(3, 16); ctx.nextFloor(); step(3, 16);
check('shield refreshes each floor', p.shieldHp === 2); check('floor regen restores a 1-shield pool fully (25% rounds up)', p.shieldHp === 2);
p.stats.shieldPerk = 8; p.shieldHp = 0;
ctx.nextFloor(); step(3, 16);
check('floor regen grants 25% rounded up (4 shields -> 1 shield)', p.shieldHp === 2);
p.stats.shieldPerk = 10; p.shieldHp = 0;
ctx.nextFloor(); step(3, 16);
check('floor regen at 5 shields grants 2 shields', p.shieldHp === 4);
ctx.startRun(); step(5, 16); // reset floor/state for later sections ctx.startRun(); step(5, 16); // reset floor/state for later sections
} }
console.log('== mute toggle persists like auto-draft ==');
{
const before = ctx.G.muted;
ctx.toggleMute();
check('toggleMute flips and persists mute', ctx.G.muted === !before && stored['meatslicer_muted'] === (ctx.G.muted ? '1' : '0'));
ctx.toggleMute();
check('toggleMute flips back and persists', ctx.G.muted === before && stored['meatslicer_muted'] === (before ? '1' : '0'));
}
console.log('== items =='); console.log('== items ==');
ctx.giveItem('hollowpoints'); ctx.giveItem('hollowpoints');
ctx.giveItem('orbitalknives'); ctx.giveItem('orbitalknives');
@@ -1449,12 +1464,24 @@ console.log('== adaptive clean-room pressure ==');
ctx.hurtPlayer(1, 0); const lowHealthRelief = 1.2 - ctx.G.pressure; ctx.hurtPlayer(1, 0); const lowHealthRelief = 1.2 - ctx.G.pressure;
check('low health dynamically grants more pressure relief', lowHealthRelief > fullHealthRelief); check('low health dynamically grants more pressure relief', lowHealthRelief > fullHealthRelief);
ctx.G.pressureDial = 0;
ctx.G.pressure = 1.2; ctx.G.recentHits = []; p.hp = 10; p.stats.maxHp = 10; p.shieldHp = 0; p.stats.armor = 0; p.invT = 0; ctx.G.mode = 'play';
ctx.hurtPlayer(1, 0);
// 3 × 0.01 base × 0.4 severity × 1.2 desperation (hp 9/10 after the hit) × 1.35 churn × 1 dial scale × 0.5 mul
check('hit relief is halved by the slowdown', Math.abs((1.2 - ctx.G.pressure) - 0.00972) < 1e-9);
ctx.G.pressure = 1.2; ctx.G.streak = 4; ctx.G.roomDamaged = false; ctx.G.pressure = 1.2; ctx.G.streak = 4; ctx.G.roomDamaged = false;
p.hp = 10; p.shieldHp = 1; p.invT = 0; ctx.G.mode = 'play'; p.hp = 10; p.shieldHp = 1; p.invT = 0; ctx.G.mode = 'play';
ctx.hurtPlayer(1, 0); ctx.hurtPlayer(1, 0);
check('fully shielded hits preserve a clean-room streak', ctx.G.pressure === 1.2 && ctx.G.streak === 4 && !ctx.G.roomDamaged); check('fully shielded hits preserve a clean-room streak', ctx.G.pressure === 1.2 && ctx.G.streak === 4 && !ctx.G.roomDamaged);
p.shieldHp = 0; p.shieldHp = 0;
ctx.G.pressure = 1.2; ctx.G.streak = 4; ctx.G.roomDamaged = false;
p.hp = 10; p.shieldHp = 1; p.invT = 0; ctx.G.mode = 'play';
ctx.hurtPlayer(2, 0); // shield absorbs 1, HP takes 1
check('partially shielded hits grant no pressure relief', ctx.G.pressure === 1.2 && ctx.G.streak === 0 && ctx.G.roomDamaged);
p.shieldHp = 0;
ctx.G.pressure = 0.601; ctx.G.recentHits = []; p.hp = 3; p.invT = 0; ctx.G.mode = 'play'; ctx.G.pressure = 0.601; ctx.G.recentHits = []; p.hp = 3; p.invT = 0; ctx.G.mode = 'play';
ctx.hurtPlayer(1, 0); ctx.hurtPlayer(1, 0);
check('pressure relief is floored at 60%', ctx.G.pressure === 0.60); check('pressure relief is floored at 60%', ctx.G.pressure === 0.60);