6 Commits
26 changed files with 305 additions and 133 deletions

No files matched your search

+8 -1
View File
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [0.9.1] — 2026-08-13
### Changed
- **Static wall bands cached** — wall tiles render once into an offscreen canvas layer (`G.wallLayer`) and are composited each frame; the layer is invalidated on room entry (`G.wallLayerKey`), so door sprites stay dynamic.
- **Sprite draw fast path** — `Sprites.draw` issues a single `drawImage` when a sprite has no rotation, scale, alpha, or flash.
- **Canvas rect cached** — the input canvas `getBoundingClientRect` is cached (`Input.updateRect`) and refreshed on canvas fit instead of every frame.
## [0.9.0] — 2026-08-12
### Added
@@ -221,4 +228,4 @@ The initial series of commits that built the game from scratch. Grouped by featu
---
*Generated from the git log. Last commit: v0.9.0*
*Generated from the git log. Last commit: v0.9.1*
+6 -4
View File
@@ -4,14 +4,16 @@
MeatSlicer is an endless top-down arena roguelite. You are the Butcher, descending floor by floor through an infinite meat-locker hell: clear rooms of flesh-hungry horrors, draft mutations, stack grotesque passive items into a broken build, kill the boss, take the stairs. The descent never ends — the only question is how deep you get before you're butchered, and whether your score becomes the new best cut.
---
## Documentation index
| Doc | Contents |
|---|---|
| [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 |
| [Items](items.md) | All 46 passive items, stacking/tier rules, proc numbers, synergies |
| [Perks](perks.md) | All 23 Mutation Draft perks, XP curve, draft mechanics |
| [Items](items.md) | All 82 passive items, stacking/tier rules, proc numbers, synergies |
| [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 |
| [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 |
@@ -20,8 +22,8 @@ MeatSlicer is an endless top-down arena roguelite. You are the Butcher, descendi
### Quick numbers
- **16 weapons** (1 infinite-ammo sidearm + 15 special weapons across 4 tiers)
- **46 passive items** (stacking, tier cap IX)
- **23 perks** (draft 1 of 3 per level-up)
- **82 passive items** (stacking, tier cap IX)
- **22 perks** (draft 1 of 3 per level-up)
- **11 enemy types** (+ elite variants)
- **9 bosses** (fixed rotation, one per floor)
- **∞ floors** (permadeath, endless descent, high-score chase)
+3 -1
View File
@@ -4,6 +4,8 @@ An in-game cheat / inspection toolset. It is gated behind a dev launch flag so
it doesn't interfere with normal play, and it's documented here because the
game is still in development — testers and players are welcome to use it.
---
## Enabling it
The console is gated behind a dev flag and is completely inert otherwise (the
@@ -57,7 +59,7 @@ pinning) do **not** taint. Taint resets on the next run (`resetRun()`).
Give +1 (routes through `giveItem`), remove 1 tier, remove all, set to max
tier, or set an exact tier. Presets: *one of everything*, *max everything*,
*clear build*.
4. **PERKS+ACTIVE** — grant any of the 22 `PERKS` by id, remove a drafted perk,
4. **PERKS+ACTIVE** — grant any of the 23 `PERKS` by id, remove a drafted perk,
clear all perks. Set / fire an active item from `ACTIVES`.
5. **WORLD** — warp to floor N (`genFloor` + `enterRoom`), next floor, teleport
to the boss/item room, clear room, kill all, kill boss, regen floor. Spawn
+66 -13
View File
@@ -84,12 +84,77 @@ Small, vicious gore-red chaser. Never spawns directly from room waves. Splitter
Suicide bomber. Charges at you; within 56 px its **0.5 s fuse** lights (it flashes). When the fuse ends — or when it's killed by anything — it detonates: radius 70 (fuse) / 60 (killed), **2 damage**. The blast only hurts *you*, not other enemies. Kill them at range or bait the fuse and back off.
### Censer
| Stat | Value |
|---|---|
| Base HP | 34 |
| Base speed | 45 |
| Contact damage | 1 |
| Hitbox radius | 19 |
| XP | 3 |
| First appears | Floor 4 (spawn weight 8) |
A zone caster that every ~3.3s predicts player movement and telegraphs a hostile acid pool (radius 38, 0.75s warning, ~4.2s life). Excluded from wave picks when the arena's minimum dimension is below 300px.
### Bulwark
| Stat | Value |
|---|---|
| Base HP | 60 |
| Base speed | 38 |
| Contact damage | 2 |
| Hitbox radius | 23 |
| XP | 3 |
| First appears | Floor 5 (spawn weight 8) |
Slowly faces the player; hits within ±70° of its front deal 20%, flank/rear hits full. Resists on-hit stun and cannot be elite.
### Flenserling
| Stat | Value |
|---|---|
| Base HP | 22 |
| Base speed | 150 |
| Contact damage | 2 |
| Hitbox radius | 16 |
| XP | 3 |
| First appears | Floor 5 (spawn weight 7) |
Periodically phases untargetable, appears behind the aim vector, and lunges.
### Choirmaster
| Stat | Value |
|---|---|
| Base HP | 40 |
| Base speed | 50 |
| Contact damage | 1 |
| Hitbox radius | 19 |
| XP | 4 |
| First appears | Floor 6 (spawn weight 6) |
Its 180px aura grants +35% speed and heals nearby non-boss monsters ~1.8% max HP per second (minimum 0.5). At most one is selected by a normal wave.
### Brood Sac
| Stat | Value |
|---|---|
| Base HP | 45 |
| Base speed | 20 |
| Contact damage | 1 |
| Hitbox radius | 22 |
| XP | 4 |
| First appears | Floor 7 (spawn weight 6) |
Spawns 2 Minis every 4s and 4 on death. At most two are wave-selected; the brood population cap is 40. Cannot be elite and is excluded from thin halls.
---
## Spawn weights
| Enemy | First floor | Weight |
|---|---:|---:|
|---|---|---|
| Shambler | 1 | 30 |
| Runner | 1 | 22 |
| Spitter | 2 | 14 |
@@ -103,18 +168,6 @@ Suicide bomber. Charges at you; within 56 px its **0.5 s fuse** lights (it flash
Weights stay flat once unlocked; floors get harder through stat scaling and wave size, not new mixes.
## New enemy types
**Censer** — 34 HP, speed 45, damage 1, radius 19, XP 3; floor 4, weight 8. A zone caster that every ~3.3s predicts player movement and telegraphs a hostile acid pool (radius 38, 0.75s warning, ~4.2s life). Excluded from wave picks when the arena's minimum dimension is below 300px.
**Bulwark** — 60 HP, speed 38, damage 2, radius 23, XP 3; floor 5, weight 8. Slowly faces the player; hits within ±70° of its front deal 20%, flank/rear hits full. Resists on-hit stun and cannot be elite.
**Flenserling** — 22 HP, speed 150, damage 2, radius 16, XP 3; floor 5, weight 7. Periodically phases untargetable, appears behind the aim vector, and lunges.
**Choirmaster** — 40 HP, speed 50, damage 1, radius 19, XP 4; floor 6, weight 6. Its 180px aura grants +35% speed and heals nearby non-boss monsters ~1.8% max HP per second (minimum 0.5). At most one is selected by a normal wave.
**Brood Sac** — 45 HP, speed 20, damage 1, radius 22, XP 4; floor 7, weight 6. Spawns 2 Minis every 4s and 4 on death. At most two are wave-selected; the brood population cap is 40. Cannot be elite and is excluded from thin halls.
## Elites
Any wave-spawned enemy can roll as an **elite** (gold ring, HP bar pip):
+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))`).
- **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.
### 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 |
| 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).
- 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.
- 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 |
|---|---|---|---|
| **−10** | −0.020 | 0.080 | Strong mercy; negative rise respects the 0.60 floor |
| **−5** | 0 | 0.050 | Mercy — pressure does not rise from clean rooms |
| **0** | +0.010 | 0.030 | Standard — today's balance |
| **−10** | −0.020 | 0.040 | Strong mercy; negative rise respects the 0.60 floor |
| **−5** | 0 | 0.025 | Mercy — pressure does not rise from clean rooms |
| **0** | +0.010 | 0.015 | Standard tuning |
| **+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 |
+6 -6
View File
@@ -1,6 +1,6 @@
# Items
All **72 passive items** in MeatSlicer, with exact effects pulled from `js/items.js` and the proc logic in `js/enemies.js` / `js/player.js`.
All **82 passive items** in MeatSlicer, with exact effects pulled from `js/items.js` and the proc logic in `js/enemies.js` / `js/player.js`.
---
@@ -37,7 +37,7 @@ Legendary items are **boss-exclusive** — they never roll from item rooms or el
## Damage & Fire Rate
| Item | Description | Effect per tier |
| Item | Description | Exact effect per tier |
|---|---|---|
| **Hollow Points** | +25% damage | `dmgMul ×1.25` |
| **Marrow Glut** | +20% damage, −10% fire rate | `dmgMul ×1.20`, `rateMul ×0.90` |
@@ -53,7 +53,7 @@ Note: attack size (`sizeMul`) also enlarges melee arcs, blast radii, acid pools,
## Shot Modifiers
| Item | Description | Effect per tier |
| Item | Description | Exact effect per tier |
|---|---|---|
| **Scalpel** | +25% shot speed | `shotSpeedMul ×1.25` |
| **Lead Marrow** | +25% range | `rangeMul ×1.25` |
@@ -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 |
| **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) |
| **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 |
| **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 |
@@ -123,7 +123,7 @@ All of these trigger from your weapon hits (orbital knives proc at half chance).
## Criticals
| Item | Description | Effect per tier |
| Item | Description | Exact effect per tier |
|---|---|---|
| **Bloodshot Eye** | +6% critical chance | `crit +0.06` (base 5%) |
| **Hollow Needle** | +50% critical damage | `critMul +0.50` (base ×2.0) |
@@ -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.
- **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).
- **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
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
- 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".
- 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.
@@ -54,7 +54,7 @@ XP comes from gems dropped by kills, room clears, and bosses, scaled by your `xp
| Perk | Description | Exact effect |
|---|---|---|
| **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% |
| **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` |
@@ -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` |
| **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)* |
| **Scavenger** | +4% drop luck | `luck +0.04` |
| **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) |
@@ -93,18 +93,17 @@ XP comes from gems dropped by kills, room clears, and bosses, scaled by your `xp
| 13 | Long Bone | Offensive |
| 14 | Magnet Bile | Utility |
| 15 | Quickening | Mobility |
| 16 | Scavenger | Utility |
| 17 | Scrap Feed | Utility |
| 18 | Second Wind | Defensive |
| 19 | Sharpened | Offensive |
| 20 | Shield Heart | Defensive |
| 21 | Sinew Weave | Mobility |
| 22 | Spite Flesh | Defensive |
| 23 | Thick Hide | Defensive |
| 16 | Scrap Feed | Utility |
| 17 | Second Wind | Defensive |
| 18 | Sharpened | Offensive |
| 19 | Shield Heart | Defensive |
| 20 | Sinew Weave | Mobility |
| 21 | Spite Flesh | Defensive |
| 22 | Thick Hide | Defensive |
## 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.
- **Shield Heart** is one of the strongest defensive picks — it's a free refilling shield pip every 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.
- **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.
- **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.
+7 -7
View File
@@ -106,18 +106,18 @@ Shotgun. Up to 66 damage per trigger pull at point blank before multipliers, but
### Cleaver Cadence
> *It always comes back*
> *A wide, dramatic melee sweep*
| Stat | Value |
|---|---|
| Damage | 15 |
| Fire interval | 0.38 s |
| Projectile speed | 400 (returns at up to 520) |
| Range | 1.10 s |
| Damage | 34 |
| Fire interval | 0.55 s |
| Reach | 90 px |
| Arc | 2.4 rad (~137°) |
| Ammo | 26 (refill 10 per pickup) |
| Recoil | 0.42 |
| Recoil | 0.95 (heavy) |
Boomerang: flies out while decelerating for 0.45 s, then accelerates back to you (caught within 20 px). **Always pierces** — it can hit enemies on both the outbound and return trip.
Melee sweep: one wide swing across the aim direction hits every enemy within 90 px inside a ~137° arc for 34 damage with heavy knockback.
### Sawblade Launcher
+22 -22
View File
@@ -6,32 +6,32 @@
<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>">
<title>MEATSLICER</title>
<link rel="stylesheet" href="css/style.css?v=56">
<link rel="stylesheet" href="css/style.css?v=58">
</head>
<body>
<div id="wrap">
<canvas id="game" width="960" height="640"></canvas>
</div>
<script src="js/utils.js?v=56"></script>
<script src="js/state.js?v=56"></script>
<script src="js/input.js?v=56"></script>
<script src="js/sfx.js?v=56"></script>
<script src="js/sfxbank.js?v=56"></script>
<script src="js/audio.js?v=56"></script>
<script src="js/sprites.js?v=56"></script>
<script src="js/particles.js?v=56"></script>
<script src="js/weapons.js?v=56"></script>
<script src="js/bullets.js?v=56"></script>
<script src="js/pickups.js?v=56"></script>
<script src="js/items.js?v=56"></script>
<script src="js/perks.js?v=56"></script>
<script src="js/enemies.js?v=56"></script>
<script src="js/bosses.js?v=56"></script>
<script src="js/rooms.js?v=56"></script>
<script src="js/player.js?v=56"></script>
<script src="js/hud.js?v=56"></script>
<script src="js/help.js?v=56"></script>
<script src="js/debug.js?v=56"></script>
<script src="js/main.js?v=56"></script>
<script src="js/utils.js?v=58"></script>
<script src="js/state.js?v=58"></script>
<script src="js/input.js?v=58"></script>
<script src="js/sfx.js?v=58"></script>
<script src="js/sfxbank.js?v=58"></script>
<script src="js/audio.js?v=58"></script>
<script src="js/sprites.js?v=58"></script>
<script src="js/particles.js?v=58"></script>
<script src="js/weapons.js?v=58"></script>
<script src="js/bullets.js?v=58"></script>
<script src="js/pickups.js?v=58"></script>
<script src="js/items.js?v=58"></script>
<script src="js/perks.js?v=58"></script>
<script src="js/enemies.js?v=58"></script>
<script src="js/bosses.js?v=58"></script>
<script src="js/rooms.js?v=58"></script>
<script src="js/player.js?v=58"></script>
<script src="js/hud.js?v=58"></script>
<script src="js/help.js?v=58"></script>
<script src="js/debug.js?v=58"></script>
<script src="js/main.js?v=58"></script>
</body>
</html>
+4 -4
View File
@@ -178,7 +178,7 @@ function drawHelpPerkList(ctx, px, py) {
let ty = py + HELP_BODY;
ctx.textAlign = 'left';
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;
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);
@@ -304,11 +304,11 @@ function drawHelpPressure(ctx, px, py) {
'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',
'+' + 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',
' DESPERATION = 1 + 2 × (1 − HP FRACTION) — MORE RELIEF WHEN HURT',
' 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',
'STREAK = CONSECUTIVE FLAWLESS CLEARS (COSMETIC)',
] },
@@ -316,7 +316,7 @@ function drawHelpPressure(ctx, px, py) {
'ARMOR = BLOCK CHANCE: RATING / (1 + RATING), HARD CAP 75%',
' THICK HIDE +1/24 RATING · TANNED HIDE +0.08/0.92 (DIMINISHING)',
'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)',
] },
], HELP_PANEL.w - 80);
+5 -1
View File
@@ -9,6 +9,10 @@ const Input = {
anyKey: false,
};
let _rect = null;
Input.updateRect = function () { _rect = canvas ? canvas.getBoundingClientRect() : null; };
function initInput(canvas) {
window.addEventListener('keydown', (e) => {
const k = e.key.toLowerCase();
@@ -21,7 +25,7 @@ function initInput(canvas) {
window.addEventListener('blur', () => { Input.keys = {}; Input.mdown = false; });
function setMouse(e) {
const r = canvas.getBoundingClientRect();
const r = _rect || (_rect = canvas.getBoundingClientRect());
Input.mx = (e.clientX - r.left) * (W / r.width);
Input.my = (e.clientY - r.top) * (H / r.height);
}
+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; } },
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; } },
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; } },
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; } },
+27 -12
View File
@@ -7,6 +7,10 @@ const MENU_EXIT_BUTTON = { x: W - 208, y: H - 48, w: 180, h: 28 };
const PAUSE_MENU_BUTTON = { x: W / 2 - 75, y: 526, w: 150, h: 28 };
const PAUSE_EXIT_BUTTON = { x: W / 2 + 87, y: 526, w: 150, h: 28 };
const HD_TOGGLE_BUTTON = { x: 28, y: H - 48, w: 220, h: 28 };
const PAUSE_RESUME_BUTTON = { x: W / 2 - 237, y: 492, w: 150, h: 28 };
const PAUSE_SWAP_BUTTON = { x: W / 2 - 75, y: 492, w: 150, h: 28 };
const PAUSE_AUTO_BUTTON = { x: W / 2 + 87, y: 492, w: 150, h: 28 };
const PAUSE_MUTE_BUTTON = { x: W / 2 - 237, y: 526, w: 150, h: 28 };
const CONFIRM_WINDOW = 3;
function pauseBars() {
@@ -46,6 +50,7 @@ function init() {
if (Number.isFinite(sv)) G.sfxVol = clamp(sv, 0, 1);
if (Number.isFinite(mv)) G.musicVol = clamp(mv, 0, 1);
G.autoPerk = localStorage.getItem('meatslicer_autoperk') === '1';
G.muted = localStorage.getItem('meatslicer_muted') === '1';
const pd = parseFloat(localStorage.getItem('meatslicer_pressure_dial'));
if (Number.isFinite(pd)) G.pressureDial = clamp(Math.round(pd), PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX);
const ha = parseFloat(localStorage.getItem('meatslicer_hud_alpha'));
@@ -59,6 +64,14 @@ function setPressureDial(n) {
try { localStorage.setItem('meatslicer_pressure_dial', String(G.pressureDial)); } catch (e) {}
Sfx.menu();
}
function toggleMute() {
G.muted = !G.muted;
Music.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) {
G.hudAlpha = clamp(Math.round(v * 100) / 100, 0.25, 1);
@@ -105,6 +118,7 @@ function fitCanvas() {
const k = Math.min(window.innerWidth / W, window.innerHeight / H) * 0.97;
canvas.style.width = Math.floor(W * k) + 'px';
canvas.style.height = Math.floor(H * k) + 'px';
if (Input.updateRect) Input.updateRect();
}
function resetRun() {
@@ -193,11 +207,7 @@ function update(dt) {
}
// global keys
if (keyPressed('m')) {
G.muted = !G.muted;
Music.setMuted(G.muted);
Sfx.setMuted(G.muted);
}
if (keyPressed('m')) toggleMute();
switch (G.mode) {
case 'menu': {
@@ -264,12 +274,13 @@ function update(dt) {
const exitClick = Input.mpressed && inRect(Input.mx, Input.my, PAUSE_EXIT_BUTTON);
if (keyPressed('q') || menuClick) { confirmAction('menu', returnToMenu); break; }
if (canQuitDesktop() && (keyPressed('x') || exitClick)) { confirmAction('desktop', quitToDesktop); break; }
if (keyPressed('p', 'escape')) { G.mode = 'play'; G.confirmAction = null; G.confirmT = 0; Sfx.menu(); break; }
if (keyPressed('p', 'escape') || (Input.mpressed && inRect(Input.mx, Input.my, PAUSE_RESUME_BUTTON))) { G.mode = 'play'; G.confirmAction = null; G.confirmT = 0; Sfx.menu(); break; }
if (keyPressed('h') || (Input.mpressed && inRect(Input.mx, Input.my, HELP_BUTTON))) {
G.pauseHelp = true; G.helpPage = 0; G.confirmAction = null; G.confirmT = 0; Sfx.menu(); break;
}
if (keyPressed('r')) swapWeapon(true);
if (keyPressed('t')) setAutoPerk(!G.autoPerk);
if (keyPressed('r') || (Input.mpressed && inRect(Input.mx, Input.my, PAUSE_SWAP_BUTTON))) swapWeapon(true);
if (keyPressed('t') || (Input.mpressed && inRect(Input.mx, Input.my, PAUSE_AUTO_BUTTON))) setAutoPerk(!G.autoPerk);
if (Input.mpressed && inRect(Input.mx, Input.my, PAUSE_MUTE_BUTTON)) { toggleMute(); break; }
if (keyPressed('-', '_')) { Sfx.setVolume(G.sfxVol - 0.1); Sfx.menu(); }
if (keyPressed('=', '+')) { Sfx.setVolume(G.sfxVol + 0.1); Sfx.menu(); }
if (keyPressed(',', '<')) { Music.setVolume(G.musicVol - 0.1); Sfx.menu(); }
@@ -540,15 +551,19 @@ function drawPause(ctx) {
}
const gridX = [W / 2 - 237, W / 2 - 75, W / 2 + 87];
drawPixelTag(ctx, '[P] RESUME', gridX[0], 492, { width: 150, height: 28, color: '#f0e5d7', accent: '#b5243a' });
drawPixelTag(ctx, '[R] SWAP WEAPON', gridX[1], 492, { width: 150, height: 28, color: '#bba9a1', accent: '#6e3843' });
drawPixelTag(ctx, '[T] AUTO ' + (G.autoPerk ? 'ON' : 'OFF'), gridX[2], 492, { width: 150, height: 28, color: G.autoPerk ? '#79d9ca' : '#bba9a1', accent: G.autoPerk ? '#378b80' : '#6e3843' });
const resumeHover = inRect(Input.mx, Input.my, PAUSE_RESUME_BUTTON);
const swapHover = inRect(Input.mx, Input.my, PAUSE_SWAP_BUTTON);
const autoHover = inRect(Input.mx, Input.my, PAUSE_AUTO_BUTTON);
const muteHover = inRect(Input.mx, Input.my, PAUSE_MUTE_BUTTON);
drawPixelTag(ctx, '[P] RESUME', gridX[0], 492, { width: 150, height: 28, color: resumeHover ? '#f5e9d6' : '#bba9a1', accent: resumeHover ? '#b5243a' : '#6e3843' });
drawPixelTag(ctx, '[R] SWAP WEAPON', gridX[1], 492, { width: 150, height: 28, color: swapHover ? '#f5e9d6' : '#bba9a1', accent: swapHover ? '#b5243a' : '#6e3843' });
drawPixelTag(ctx, '[T] AUTO ' + (G.autoPerk ? 'ON' : 'OFF'), gridX[2], 492, { width: 150, height: 28, color: G.autoPerk ? '#79d9ca' : (autoHover ? '#f5e9d6' : '#bba9a1'), accent: G.autoPerk ? '#378b80' : (autoHover ? '#b5243a' : '#6e3843') });
const menuHover = inRect(Input.mx, Input.my, PAUSE_MENU_BUTTON);
const exitHover = inRect(Input.mx, Input.my, PAUSE_EXIT_BUTTON);
const menuConfirm = G.confirmAction === 'menu';
const desktopExit = canQuitDesktop();
const exitConfirm = desktopExit && G.confirmAction === 'desktop';
drawPixelTag(ctx, '[M] MUTE AUDIO', gridX[0], 526, { width: 150, height: 28, color: '#bba9a1', accent: '#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, {
width: PAUSE_MENU_BUTTON.w, height: PAUSE_MENU_BUTTON.h,
color: menuConfirm ? '#ffd36a' : (menuHover ? '#f5e9d6' : '#bba9a1'),
+10
View File
@@ -104,6 +104,10 @@ function spawnShockwave(x, y, radius, color, alpha, important) {
}, important);
}
function spawnSweepFx(x, y, ang, reach, arc) {
addParticle({ type: 'sweep', x, y, ang, arc, r: 20, maxR: reach, life: 0.18, t: 0 }, true);
}
function spawnMuzzleFx(x, y, ang, behavior) {
if (behavior === 'slam') return;
const back = ang + Math.PI;
@@ -254,6 +258,12 @@ function drawParticles(ctx) {
ctx.stroke();
ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1.5;
ctx.stroke();
} else if (p.type === 'sweep') {
const progress = 1 - k;
const radius = lerp(p.r, p.maxR, progress);
ctx.globalAlpha = k;
ctx.strokeStyle = '#f0c8b2'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.arc(p.x, p.y, radius, p.ang - p.arc / 2, p.ang + p.arc / 2); ctx.stroke();
} else if (p.type === 'text') {
ctx.globalAlpha = Math.min(1, k * 2);
ctx.fillStyle = p.color;
+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: '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: '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: 'scavenge', name: 'Scavenger', desc: '+4% drop luck', apply: s => { s.luck += 0.04; } },
{ 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: '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: '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 desperation = 1 + (1 - hpFrac) * 2;
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) {
@@ -333,8 +333,9 @@ function hurtPlayer(dmg, ang, attacker) {
// Meat Grinder: aura damages you for +1 per hit taken
if (p.stats.meatGrinder > 0) reduced += p.stats.meatGrinder;
// shield hearts absorb first (from the Shield Heart perk)
let absorbed = 0;
if (p.shieldHp > 0) {
const absorbed = Math.min(p.shieldHp, reduced);
absorbed = Math.min(p.shieldHp, reduced);
p.shieldHp -= absorbed;
reduced -= absorbed;
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.recentHits = (G.recentHits || []).filter(t => G.time - t < 12);
G.recentHits.push(G.time);
applyPressureDelta(-pressureRelief(reduced));
if (absorbed === 0) applyPressureDelta(-pressureRelief(reduced));
G.streak = 0;
}
if (attacker && attacker.hp > 0 && p.stats.thorns > 0) {
+52 -22
View File
@@ -94,6 +94,8 @@ function enterRoom(gx, gy) {
G.boss = null;
G.roomLayer = null;
G.roomLayerKey = '';
G.wallLayer = null;
G.wallLayerKey = '';
G.roomDamaged = false;
G.roomEnterT = G.time;
G.entryFresh = false;
@@ -200,7 +202,7 @@ function updateRoom(dt) {
const stalled = G.time - G.roomEnterT > 90;
if (lowHealth || stalled) {
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);
@@ -302,9 +304,12 @@ function nextFloor() {
Sfx.stairs();
genFloor(G.floor);
enterRoom(0, 0);
// Shield Heart perk: fresh shield hearts each floor
G.player.shieldHp = G.player.stats.shieldPerk || 0;
if (G.player.shieldHp > 0) { spawnText(G.player.x, G.player.y - 20, 'SHIELD UP', '#3bc9e0'); Sfx.shieldUp(); }
// Shield Heart: regen 25% of max shields per floor, rounded up to the next
// whole shield (1 shield = 2 shieldHp). Full shields only at run start.
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
G.player.secondSkinUsed = false;
G.player.lastCutActive = false;
@@ -435,8 +440,7 @@ function drawRoom(ctx) {
drawWalls(ctx, r);
}
function drawWalls(ctx, r) {
const locked = roomLocked();
function drawWallBands(target, r) {
const a = roomBounds(r);
const theme = ROOM_THEMES[r.theme] || ROOM_THEMES.abattoir;
const wallPool = theme.wall;
@@ -444,43 +448,69 @@ function drawWalls(ctx, r) {
const top = a.y0 - WALL, bottom = a.y1 + WALL;
function clipBand(dir) {
ctx.beginPath();
target.beginPath();
if (dir === 'n' || dir === 's') {
const y = dir === 'n' ? top : a.y1;
if (r.doors[dir]) {
ctx.rect(left, y, a.cx - DOOR_HALF - left, WALL);
ctx.rect(a.cx + DOOR_HALF, y, right - a.cx - DOOR_HALF, WALL);
} else ctx.rect(left, y, right - left, WALL);
target.rect(left, y, a.cx - DOOR_HALF - left, WALL);
target.rect(a.cx + DOOR_HALF, y, right - a.cx - DOOR_HALF, WALL);
} else target.rect(left, y, right - left, WALL);
} else {
const x = dir === 'w' ? left : a.x1;
if (r.doors[dir]) {
ctx.rect(x, top, WALL, a.cy - DOOR_HALF - top);
ctx.rect(x, a.cy + DOOR_HALF, WALL, bottom - a.cy - DOOR_HALF);
} else ctx.rect(x, top, WALL, bottom - top);
target.rect(x, top, WALL, a.cy - DOOR_HALF - top);
target.rect(x, a.cy + DOOR_HALF, WALL, bottom - a.cy - DOOR_HALF);
} else target.rect(x, top, WALL, bottom - top);
}
ctx.clip();
target.clip();
}
for (const dir of ['n', 's']) {
ctx.save(); clipBand(dir);
target.save(); clipBand(dir);
const y = dir === 'n' ? top + TILE / 2 : a.y1 + TILE / 2;
for (let x = left; x < right; x += TILE) {
const h = hashTile(Math.floor(x / TILE) + r.gx * 17, (dir === 'n' ? -1 : 11) + r.gy * 19);
Sprites.draw(ctx, wallPool[h % wallPool.length], x + TILE / 2, y, 0, TILE);
Sprites.draw(target, wallPool[h % wallPool.length], x + TILE / 2, y, 0, TILE);
}
ctx.restore();
target.restore();
}
for (const dir of ['w', 'e']) {
ctx.save(); clipBand(dir);
target.save(); clipBand(dir);
const x = dir === 'w' ? left + TILE / 2 : a.x1 + TILE / 2;
for (let y = top; y < bottom; y += TILE) {
const h = hashTile((dir === 'w' ? -1 : 16) + r.gx * 17, Math.floor(y / TILE) + r.gy * 19);
Sprites.draw(ctx, wallPool[h % wallPool.length], x, y + TILE / 2, Math.PI / 2, TILE);
Sprites.draw(target, wallPool[h % wallPool.length], x, y + TILE / 2, Math.PI / 2, TILE);
}
ctx.restore();
target.restore();
}
target.strokeStyle = 'rgba(0,0,0,0.5)'; target.lineWidth = 3;
target.strokeRect(a.x0, a.y0, a.w, a.h);
}
function drawWalls(ctx, r) {
const locked = roomLocked();
const a = roomBounds(r);
// static wall tiles are cached; door sprites are dynamic per frame
const canCache = typeof document !== 'undefined' && document.createElement && G.imagesLoaded;
if (canCache) {
const key = r.gx + ',' + r.gy + ':' + G.floor + ':' + r.shape + ':' + r.theme + ':' + hdScale();
if (!G.wallLayer || G.wallLayerKey !== key) {
const rs = hdScale();
const layer = document.createElement('canvas');
layer.width = (a.x1 + WALL) * rs;
layer.height = (a.y1 + WALL) * rs;
const lctx = layer.getContext('2d');
lctx.setTransform(rs, 0, 0, rs, 0, 0);
lctx.imageSmoothingEnabled = !G.hdRemaster;
drawWallBands(lctx, r);
G.wallLayer = layer;
G.wallLayerKey = key;
}
ctx.drawImage(G.wallLayer, 0, 0, G.wallLayer.width / hdScale(), G.wallLayer.height / hdScale());
} else {
drawWallBands(ctx, r);
}
ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 3;
ctx.strokeRect(a.x0, a.y0, a.w, a.h);
for (const [dir, has] of Object.entries(r.doors)) {
if (!has) continue;
+1 -1
View File
@@ -111,7 +111,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=56')
const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=58')
.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)))
+8 -3
View File
@@ -18,7 +18,7 @@ const SPRITE_MANIFEST = [
'decal_blood1', 'decal_blood2', 'decal_blood3', 'decal_blood4',
// level-up perk emblems
'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_heavyhand', 'perk_thickhide', 'perk_secondwind', 'perk_scrapfeed', 'perk_boneknit',
'perk_spiteflesh', 'perk_carrion', 'perk_sinew',
@@ -88,10 +88,10 @@ const Sprites = {
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.onerror = () => {
if (hd && !img._sdRetry) { img._sdRetry = true; img.src = 'assets/' + name + '.png?v=56'; return; }
if (hd && !img._sdRetry) { img._sdRetry = true; img.src = 'assets/' + name + '.png?v=58'; return; }
finish();
};
img.src = hd ? 'assets/hd/' + name + '.webp?v=56' : 'assets/' + name + '.png?v=56';
img.src = hd ? 'assets/hd/' + name + '.webp?v=58' : 'assets/' + name + '.png?v=58';
}
},
@@ -161,6 +161,11 @@ const Sprites = {
draw(ctx, name, x, y, rot, targetW, flash, alpha, scaleX, scaleY) {
const img = this.imgs[name];
const tw = targetW || 32;
if (img && !rot && scaleX === undefined && scaleY === undefined && alpha === undefined && !flash) {
const dh = img.height * (tw / img.width);
ctx.drawImage(img, x - tw / 2, y - dh / 2, tw, dh);
return;
}
ctx.save();
ctx.translate(x, y);
if (rot) ctx.rotate(rot);
+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_GAIN = PRESSURE_UNIT; // retained: dial 0 == 1 point
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 PRESSURE_DIAL_MIN = -10, PRESSURE_DIAL_MAX = 10;
+1
View File
@@ -16,6 +16,7 @@ function angleLerp(a, b, t) {
if (d < -Math.PI) d += TAU;
return a + d * t;
}
function angleDelta(a, b) { let d = (b - a) % TAU; if (d > Math.PI) d -= TAU; if (d < -Math.PI) d += TAU; return d; }
// distance from point to segment (for beam weapons)
function distToSegment(px, py, x1, y1, x2, y2) {
const dx = x2 - x1, dy = y2 - y1;
+17 -2
View File
@@ -1,6 +1,6 @@
// ---- The Butcher's Arsenal: 16 horrorcore weapons ----
// behavior: bullet | spread | boomerang | bounce | cone | flame | homing |
// pierce_drag | lob | lob_trap | lob_swarm | slam | saw | beam
// pierce_drag | lob | lob_trap | lob_swarm | slam | saw | beam | sweep
// Ammo economy: `ammo` is the magazine (trigger-time / burst feel), `refill`
// is what one ammo pickup hands back (~10 kills' worth). Sustained-stream
@@ -11,7 +11,7 @@ const WEAPONS = {
bonepopper: { id: 'bonepopper', name: 'Bone Popper', held: 38, torsoW: 106, torsoFwd: 18, muzzle: 62, tier: -1, ammo: Infinity, interval: 0.60, dmg: 8, spd: 540, range: 0.45, behavior: 'bullet', sprite: 'bullet_bone', sfx: 'default', punch: 0.42, desc: 'Trusty bone-shard sidearm' },
repeater: { id: 'repeater', name: 'Ribcage Repeater', held: 52, torsoW: 120, torsoFwd: 20, muzzle: 70, tier: 0, ammo: 84, refill: 42, interval: 0.09, dmg: 6, spd: 560, spread: 0.10, range: 0.42, behavior: 'bullet', sprite: 'bullet_bone', sfx: 'rapid', punch: 0.18, desc: 'Rattles off rib slivers' },
marrow: { id: 'marrow', name: 'Marrow Scatter', held: 54, torsoW: 120, torsoFwd: 20, muzzle: 69, tier: 0, ammo: 20, refill: 6, interval: 0.58, dmg: 11, spd: 500, pellets: 6, spread: 0.55, range: 0.38, behavior: 'spread', sprite: 'bullet_bone', sfx: 'spread', punch: 0.92, desc: 'A devastating close blast of jagged bone' },
cleaver: { id: 'cleaver', name: 'Cleaver Cadence', held: 60, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 1, ammo: 26, refill: 10, interval: 0.38, dmg: 15, spd: 400, range: 1.10, behavior: 'boomerang', sprite: 'bullet_cleaver', sfx: 'boomerang', punch: 0.42, desc: 'It always comes back' },
cleaver: { id: 'cleaver', name: 'Cleaver Cadence', held: 60, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 1, ammo: 26, refill: 10, interval: 0.55, dmg: 34, behavior: 'sweep', sprite: 'bullet_cleaver', sfx: 'heavy', punch: 0.95, sweepRange: 90, sweepArc: 2.4, desc: 'A wide, dramatic melee sweep' },
saw: { id: 'saw', name: 'Sawblade Launcher',held: 52, torsoW: 118, torsoFwd: 20, muzzle: 70, tier: 1, ammo: 24, refill: 9, interval: 0.34, dmg: 13, spd: 360, bounces: 5, range: 0.60, behavior: 'bounce', sprite: 'bullet_saw', sfx: 'saw', punch: 0.45, desc: 'Bouncing, hungry steel' },
bile: { id: 'bile', name: 'Bile Blunderbuss', held: 54, torsoW: 118, torsoFwd: 20, muzzle: 68, tier: 2, ammo: 80, refill: 21, drain: 8, interval: 0.12, dmg: 7, spd: 340, spread: 0.32, range: 0.38, behavior: 'cone', sprite: 'bullet_gore', sfx: 'flame', punch: 0.34, desc: 'A brutal corrosive torrent; pools linger' },
hemophage: { id: 'hemophage', name: 'Hemophage', held: 50, torsoW: 116, torsoFwd: 19, muzzle: 67, tier: 1, ammo: 70, refill: 42, interval: 0.19, dmg: 8, spd: 480, lifesteal: 0.5, range: 0.45, behavior: 'bullet', sprite: 'bullet_syringe', sfx: 'default', punch: 0.35, desc: 'Drinks what it touches' },
@@ -131,6 +131,21 @@ function fireWeapon(p, w) {
Sfx.explode({ x: p.x + Math.cos(ang) * 46, y: p.y + Math.sin(ang) * 46 });
break;
}
case 'sweep': {
const reach = (w.sweepRange || 90) * st.rangeMul;
const half = (w.sweepArc || 2.4) / 2;
for (const e of G.enemies) {
if (e.phased) continue;
const d = dist(p.x, p.y, e.x, e.y);
if (d > reach + e.r) continue;
const a = angleTo(p.x, p.y, e.x, e.y);
if (Math.abs(angleDelta(a, ang)) > half) continue;
damageEnemy(e, w.dmg * dmgMul, a, true, { source: 'player' });
}
spawnSweepFx(p.x, p.y, ang, reach, w.sweepArc || 2.4);
addShake(4);
break;
}
default: { // bullet, bounce and homing projectiles
for (const a of volley) mk(a + rand(-(w.spread || 0), w.spread || 0));
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meatslicer",
"productName": "MeatSlicer",
"version": "0.9.0",
"version": "0.9.2",
"private": true,
"description": "A Butcher's Descent - clear, harvest, descend.",
"main": "desktop/main.js",
+33 -6
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
// 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;
const ctx = sandbox;
@@ -216,7 +216,7 @@ check('HD loader loads all images (fallbacks)', ctx.G.imagesLoaded === true);
let hdFallbackOk = 0;
for (const name of ctx.SPRITE_MANIFEST) {
const urls = attemptedUrls[name];
const ok = urls && urls.length === 2 && urls[0] === 'assets/hd/' + name + '.webp?v=56' && urls[1] === 'assets/' + name + '.png?v=56';
const ok = urls && urls.length === 2 && urls[0] === 'assets/hd/' + name + '.webp?v=58' && urls[1] === 'assets/' + name + '.png?v=58';
if (ok) hdFallbackOk++;
}
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;
for (const name of ctx.SPRITE_MANIFEST) {
const urls = attemptedUrls[name];
const ok = urls && urls.length === 1 && urls[0] === 'assets/' + name + '.png?v=56';
const ok = urls && urls.length === 1 && urls[0] === 'assets/' + name + '.png?v=58';
if (ok) sdUrlOk++;
}
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 ==');
{
check('13 new perks added', ctx.PERKS.length === 23);
check('perk pool counts 22 perks', ctx.PERKS.length === 22);
const perkStats = { dmgMul: 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);
@@ -661,10 +661,25 @@ console.log('== shield heart perk ==');
check('unshielded hit hurts', p.hp === hpBefore - 1);
p.hp = hpBefore;
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
}
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 ==');
ctx.giveItem('hollowpoints');
ctx.giveItem('orbitalknives');
@@ -1069,7 +1084,7 @@ console.log('== per-weapon muzzle spawn distances ==');
p.aim = 0;
const badMuzzles = [];
for (const w of Object.values(ctx.WEAPONS)) {
if (['slam', 'saw', 'beam', 'flame'].includes(w.behavior)) continue;
if (['slam', 'saw', 'beam', 'flame', 'sweep'].includes(w.behavior)) continue;
ctx.G.bullets.length = 0;
ctx.fireWeapon(p, w);
const b = ctx.G.bullets[0];
@@ -1449,12 +1464,24 @@ console.log('== adaptive clean-room pressure ==');
ctx.hurtPlayer(1, 0); const lowHealthRelief = 1.2 - ctx.G.pressure;
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;
p.hp = 10; p.shieldHp = 1; p.invT = 0; ctx.G.mode = 'play';
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);
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.hurtPlayer(1, 0);
check('pressure relief is floored at 60%', ctx.G.pressure === 0.60);
+1 -1
View File
@@ -83,7 +83,7 @@ function boot(seed) {
const AIM_JITTER = {
bullet: 0.07, spread: 0.05, bounce: 0.07, boomerang: 0.05, pierce_drag: 0.04,
cone: 0.03, flame: 0.03,
homing: 0, lob: 0, lob_trap: 0, lob_swarm: 0, slam: 0, saw: 0, beam: 0,
homing: 0, lob: 0, lob_trap: 0, lob_swarm: 0, slam: 0, saw: 0, beam: 0, sweep: 0,
};
const FLOOR = 3; // mid-game enemy mix
+1
View File
@@ -28,6 +28,7 @@ function effectiveDps(w) {
case 'lob_trap': return base * 1.3; // direct + trap zone control
case 'lob_swarm': return (w.dmg * 6) / w.interval * 0.85; // 6 homing maggots
case 'slam': return base * 1.3; // AoE multi-hit
case 'sweep': return base * 1.5; // arc can hit multiple enemies
case 'saw': return base * 1.1; // continuous contact
case 'beam': return (w.dmg * 1.0) / ((w.chargeTime || 0.8) + w.interval) * 1.2; // full-charge cycle, line pierce
default: return base;