Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8991bde943 | ||
|
|
2cf8dc89fe | ||
|
|
988e04b2d4 | ||
|
|
c41ce5fdb0 | ||
|
|
683e7a47ec | ||
|
|
85238be844 | ||
|
|
59869945d0 | ||
|
|
a2294197e4 |
No files matched your search
@@ -7,3 +7,6 @@ dist/
|
||||
served-state.txt
|
||||
__pycache__/
|
||||
*.pyc
|
||||
assets/raw/models/
|
||||
assets/raw/meshy/
|
||||
assets/raw/meshy_manifest.jsonl
|
||||
+20
-1
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [0.9.5] — 2026-08-15
|
||||
|
||||
### Added
|
||||
- **Boss-room loading overlay** — first visit of a boss room holds combat in `bossload` for at least 1s while the boss action sheet is warmed (decode + GPU upload) so the roar is not cut by the hitch. Re-entering a visited boss room skips the overlay. A failed sheet cannot deadlock the screen.
|
||||
|
||||
### Changed
|
||||
- **Elite Brood Sac summons** — an elite sac now seeds 4–6 minis per cycle (normal remains 2). Wave elite rolls still exclude Brood Sac; debug/other elite paths are the intended case. Death burst is unchanged (4).
|
||||
|
||||
### Removed
|
||||
- **Redundant RUN DATA pressure multiplier** — the `×N.NN` number next to score is gone; `PRESSURE NN%` and the bar remain.
|
||||
|
||||
|
||||
## [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 +240,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
@@ -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
@@ -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
@@ -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 (elite: 4–6 every 3s) and 4 on death. At most two are wave-selected; the brood population cap is 40. Excluded from normal wave elite rolls and thin halls; an elite sac (debug or other spawn paths) still uses the elite summon count.
|
||||
|
||||
---
|
||||
|
||||
## 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):
|
||||
|
||||
+9
-8
@@ -113,13 +113,13 @@ 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
|
||||
|
||||
- Hearts restore 2 HP (1 heart) each.
|
||||
- Healing past max HP normally goes to waste — unless you have the **Second Stomach** item, which converts overflow healing into shield HP. Hearts are not consumed at full HP (they stay on the floor for later, and the magnet won't drag them).
|
||||
- Healing past max HP normally goes to waste — unless you have the **Second Stomach** item, which converts overflow healing into shield HP. Hearts stay on the floor at full HP unless Second Stomach has unused overflow-shield room (then they magnetize, collect, and fill shields).
|
||||
- Other healing sources: Vampire Dentures (kill procs), Hemophage (hit procs), Worm Gut / Bone Knit (post-combat heals).
|
||||
|
||||
---
|
||||
@@ -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 |
|
||||
|
||||
@@ -271,7 +271,7 @@ Full formulas are in [enemies.md](enemies.md) and [bosses.md](bosses.md).
|
||||
- **Top-left — BUTCHER // VITALS:** level, hearts (red, 2 HP per heart; half containers render as half outlines) and shield hearts (cyan, 2 shield HP per heart), shown on up to two rows, XP bar, active weapon + ammo (red when below 25%), holstered weapon with `[R]` swap hint.
|
||||
- **Item tray** (below vitals): up to 24 item icons with tier numerals I–IX.
|
||||
- **Top-right — SECTOR MAP:** minimap. White = current room, red = boss, gold = item, gray = cleared, maroon = uncleared. Includes floor number.
|
||||
- **Bottom-right — RUN DATA:** floor, kills, score (with the live pressure multiplier), PRESSURE % (with the dial setting), STREAK, pressure bar.
|
||||
- **Bottom-right — RUN DATA:** floor, kills, score, PRESSURE % (with the dial setting), STREAK, pressure bar.
|
||||
- **HUD opacity** is adjustable from the pause menu (`;` / `'` or the HUD slider, 25%–100%). The boss HP bar and toast notifications always stay fully opaque.
|
||||
- **Bottom-center:** boss HP bar (during boss fights).
|
||||
- **Top-center:** toast notifications (pickups, floor intros, boss events).
|
||||
@@ -281,6 +281,7 @@ Full formulas are in [enemies.md](enemies.md) and [bosses.md](bosses.md).
|
||||
| Screen | What it offers |
|
||||
|---|---|
|
||||
| **Title** | INITIATE DESCENT, PRESSURE DIAL (−10…+10), HD REMASTER ON/OFF tag, control reference, best score, confirmed desktop exit |
|
||||
| **Boss approach** | Full-screen hold on first boss-room entry: boss name + FRESH MEAT APPROACHES, ≥1s, then combat |
|
||||
| **Pause** | Jukebox (track cycling), SFX/music volume + HUD opacity sliders, resume/swap/auto/mute hints, Field Manual (`H` / `?` button) |
|
||||
| **Mutation Draft** | 3 perk cards, random cut, reroll, auto-draft |
|
||||
| **Game Over ("BUTCHERED")** | Floor, kills, score, NEW BEST CUT tag, restart |
|
||||
|
||||
+6
-6
@@ -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
@@ -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.
|
||||
+8
-8
@@ -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
|
||||
|
||||
@@ -295,7 +295,7 @@ Point-blank slam — no projectile, an instant circular blast in front of you. B
|
||||
|
||||
| Stat | Value |
|
||||
|---|---|
|
||||
| Damage | 95 (× 0.4–1.0 by charge) |
|
||||
| Damage | 190 (× 0.4–1.0 by charge) |
|
||||
| Charge time | 0.85 s (minimum 40% charge = 0.34 s to fire) |
|
||||
| Beam length | 900 × rangeMul |
|
||||
| Beam width | 14 × √sizeMul |
|
||||
|
||||
+22
-22
@@ -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=57">
|
||||
<link rel="stylesheet" href="css/style.css?v=60">
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrap">
|
||||
<canvas id="game" width="960" height="640"></canvas>
|
||||
</div>
|
||||
<script src="js/utils.js?v=57"></script>
|
||||
<script src="js/state.js?v=57"></script>
|
||||
<script src="js/input.js?v=57"></script>
|
||||
<script src="js/sfx.js?v=57"></script>
|
||||
<script src="js/sfxbank.js?v=57"></script>
|
||||
<script src="js/audio.js?v=57"></script>
|
||||
<script src="js/sprites.js?v=57"></script>
|
||||
<script src="js/particles.js?v=57"></script>
|
||||
<script src="js/weapons.js?v=57"></script>
|
||||
<script src="js/bullets.js?v=57"></script>
|
||||
<script src="js/pickups.js?v=57"></script>
|
||||
<script src="js/items.js?v=57"></script>
|
||||
<script src="js/perks.js?v=57"></script>
|
||||
<script src="js/enemies.js?v=57"></script>
|
||||
<script src="js/bosses.js?v=57"></script>
|
||||
<script src="js/rooms.js?v=57"></script>
|
||||
<script src="js/player.js?v=57"></script>
|
||||
<script src="js/hud.js?v=57"></script>
|
||||
<script src="js/help.js?v=57"></script>
|
||||
<script src="js/debug.js?v=57"></script>
|
||||
<script src="js/main.js?v=57"></script>
|
||||
<script src="js/utils.js?v=60"></script>
|
||||
<script src="js/state.js?v=60"></script>
|
||||
<script src="js/input.js?v=60"></script>
|
||||
<script src="js/sfx.js?v=60"></script>
|
||||
<script src="js/sfxbank.js?v=60"></script>
|
||||
<script src="js/audio.js?v=60"></script>
|
||||
<script src="js/sprites.js?v=60"></script>
|
||||
<script src="js/particles.js?v=60"></script>
|
||||
<script src="js/weapons.js?v=60"></script>
|
||||
<script src="js/bullets.js?v=60"></script>
|
||||
<script src="js/pickups.js?v=60"></script>
|
||||
<script src="js/items.js?v=60"></script>
|
||||
<script src="js/perks.js?v=60"></script>
|
||||
<script src="js/enemies.js?v=60"></script>
|
||||
<script src="js/bosses.js?v=60"></script>
|
||||
<script src="js/rooms.js?v=60"></script>
|
||||
<script src="js/player.js?v=60"></script>
|
||||
<script src="js/hud.js?v=60"></script>
|
||||
<script src="js/help.js?v=60"></script>
|
||||
<script src="js/debug.js?v=60"></script>
|
||||
<script src="js/main.js?v=60"></script>
|
||||
</body>
|
||||
</html>
|
||||
+7
-3
@@ -100,6 +100,8 @@ function spawnPosAwayFromPlayer() {
|
||||
const y = rand(a.y0 + 40, a.y1 - 40);
|
||||
if (dist(x, y, p.x, p.y) <= safeDist) continue;
|
||||
if (nearAnyDoor(x, y)) continue;
|
||||
const obstacles = G.cur && G.cur.obstacles;
|
||||
if (obstacles && obstacles.some(o => dist(x, y, o.x, o.y) < 16 + o.r)) continue;
|
||||
return { x, y };
|
||||
}
|
||||
// fallback: a bounded central region, valid even in the thin hall variants
|
||||
@@ -458,8 +460,8 @@ function updateEnemies(dt) {
|
||||
e.x += px; e.y += py; o.x -= px; o.y -= py;
|
||||
}
|
||||
}
|
||||
e.x = clamp(e.x, G.arena.x0 + e.r, G.arena.x1 - e.r);
|
||||
e.y = clamp(e.y, G.arena.y0 + e.r, G.arena.y1 - e.r);
|
||||
const pos = resolveSolids(e.x, e.y, e.r);
|
||||
e.x = pos.x; e.y = pos.y;
|
||||
|
||||
// contact damage
|
||||
if (e.hitT > 0) e.hitT -= dt;
|
||||
@@ -522,6 +524,8 @@ function updateEnemyAI(e, dt) {
|
||||
const behind = p.aim + Math.PI, range = rand(90, 125);
|
||||
e.x = clamp(p.x + Math.cos(behind) * range, G.arena.x0 + e.r, G.arena.x1 - e.r);
|
||||
e.y = clamp(p.y + Math.sin(behind) * range, G.arena.y0 + e.r, G.arena.y1 - e.r);
|
||||
const landed = resolveSolids(e.x, e.y, e.r);
|
||||
e.x = landed.x; e.y = landed.y;
|
||||
e.phased = false; e.alpha = 1; e.ambushState = 'lunge'; e.lungeT = 0.48;
|
||||
e.faceDir = angleTo(e.x, e.y, p.x, p.y); e.attackT = 0.48; e.actionT = 0;
|
||||
}
|
||||
@@ -547,7 +551,7 @@ function updateEnemyAI(e, dt) {
|
||||
e.summonT -= dt;
|
||||
if (e.summonT <= 0) {
|
||||
e.summonT = e.elite ? 3 : 4;
|
||||
spawnBroodMinis(e, 2, 0.35);
|
||||
spawnBroodMinis(e, e.elite ? irand(4, 6) : 2, 0.35);
|
||||
e.attackT = 0.5; e.actionT = 0;
|
||||
}
|
||||
} else if (t.ranged) {
|
||||
|
||||
+4
-4
@@ -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);
|
||||
|
||||
@@ -167,8 +167,6 @@ function drawHUD(ctx) {
|
||||
ctx.fillText('KILLS ' + String(G.kills).padStart(3, '0'), W - 18, H - 67);
|
||||
ctx.fillStyle = '#f0e5d7'; ctx.font = 'bold 11px monospace';
|
||||
ctx.fillText('SCORE ' + String(G.score).padStart(6, '0'), W - 18, H - 46);
|
||||
ctx.fillStyle = '#8f7770'; ctx.font = '8px monospace';
|
||||
ctx.fillText('×' + (G.pressure || 1).toFixed(2), W - 168, H - 46);
|
||||
ctx.textAlign = 'left';
|
||||
const dialTxt = ' D' + (G.pressureDial > 0 ? '+' : '') + G.pressureDial;
|
||||
ctx.fillStyle = G.pressure > 1 ? '#d6aa39' : '#69b8bd'; ctx.font = 'bold 8px monospace';
|
||||
|
||||
+1
-1
@@ -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; } },
|
||||
|
||||
+50
-12
@@ -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);
|
||||
@@ -194,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': {
|
||||
@@ -237,6 +246,11 @@ function update(dt) {
|
||||
updateParticles(dt);
|
||||
drawToastsUpdate(dt);
|
||||
break;
|
||||
case 'bossload':
|
||||
updateParticles(dt);
|
||||
if (G.bossLoadT > 0) G.bossLoadT -= dt;
|
||||
if (G.bossLoadT <= 0 && Sprites.warm(G.bossLoadSheet)) G.mode = 'play';
|
||||
break;
|
||||
case 'levelup':
|
||||
updateLevelup(dt);
|
||||
updateParticles(dt);
|
||||
@@ -265,12 +279,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(); }
|
||||
@@ -318,6 +333,7 @@ function draw() {
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
if (G.mode === 'menu') { drawMenu(ctx); return; }
|
||||
if (G.mode === 'bossload') { drawBossLoad(ctx); return; }
|
||||
|
||||
updateCamera();
|
||||
|
||||
@@ -368,6 +384,24 @@ function draw() {
|
||||
if (G.devMode) { if (G.debugPin && G.mode !== 'debug') drawDebugPin(ctx); }
|
||||
}
|
||||
|
||||
function drawBossLoad(ctx) {
|
||||
ctx.fillStyle = '#0a0506';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
ctx.textAlign = 'center';
|
||||
const name = G.boss && G.boss.name ? G.boss.name : 'FRESH MEAT';
|
||||
ctx.fillStyle = '#d9c9bc'; ctx.font = 'bold 28px monospace';
|
||||
ctx.fillText(name, W / 2, H / 2 - 6);
|
||||
ctx.fillStyle = '#8f1f2e'; ctx.font = 'bold 11px monospace';
|
||||
ctx.fillText('FRESH MEAT APPROACHES', W / 2, H / 2 + 16);
|
||||
const pr = Math.min(1, Math.max(0, G.bossLoadT / BOSS_LOAD_MIN));
|
||||
const tier = Math.floor((1 - pr) * 5) % 3;
|
||||
ctx.fillStyle = '#3a1a1e';
|
||||
for (let i = 0; i < 3; i++) ctx.fillRect(W / 2 - 22 + i * 14, H / 2 + 30, 8, 8);
|
||||
ctx.fillStyle = '#d7a934';
|
||||
ctx.fillRect(W / 2 - 22 + tier * 14, H / 2 + 30, 8, 8);
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
function drawMenu(ctx) {
|
||||
const t = performance.now() / 1000;
|
||||
const bg = ctx.createRadialGradient && ctx.createRadialGradient(W / 2, H * 0.4, 30, W / 2, H * 0.45, 590);
|
||||
@@ -541,15 +575,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'),
|
||||
|
||||
@@ -104,6 +104,18 @@ function spawnShockwave(x, y, radius, color, alpha, important) {
|
||||
}, important);
|
||||
}
|
||||
|
||||
function spawnDashBurst(x, y, ang) {
|
||||
addParticle({ type: 'bloodball', x, y, vx: 0, vy: 0, life: 0.18, t: 0, r: 10, maxR: 56 }, true);
|
||||
spawnShockwave(x, y, 52, '#8b1020', 0.75);
|
||||
spawnBlood(x, y, ang, 16, true);
|
||||
spawnGibs(x, y, 7, false);
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
@@ -245,6 +257,15 @@ function drawParticles(ctx) {
|
||||
ctx.beginPath(); ctx.ellipse(p.x, p.y, radius, radius * 0.72, 0, 0, TAU); ctx.stroke();
|
||||
ctx.globalAlpha = k * 0.18;
|
||||
ctx.fillStyle = p.color; ctx.beginPath(); ctx.ellipse(p.x, p.y, radius * 0.86, radius * 0.58, 0, 0, TAU); ctx.fill();
|
||||
} else if (p.type === 'bloodball') {
|
||||
const progress = 1 - k;
|
||||
const radius = lerp(p.r, p.maxR, progress);
|
||||
ctx.globalAlpha = k * 0.82;
|
||||
ctx.fillStyle = '#6a0b14';
|
||||
ctx.beginPath(); ctx.ellipse(p.x, p.y, radius, radius * 0.72, 0, 0, TAU); ctx.fill();
|
||||
ctx.globalAlpha = k * 0.55;
|
||||
ctx.fillStyle = '#b41828';
|
||||
ctx.beginPath(); ctx.ellipse(p.x, p.y, radius * 0.55, radius * 0.55 * 0.72, 0, 0, TAU); ctx.fill();
|
||||
} else if (p.type === 'beam') {
|
||||
ctx.globalAlpha = k;
|
||||
ctx.strokeStyle = '#d9f6ff'; ctx.lineWidth = 13 * k + 3;
|
||||
@@ -254,6 +275,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
@@ -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; } },
|
||||
|
||||
+3
-4
@@ -53,7 +53,7 @@ function updatePickups(dt) {
|
||||
}
|
||||
|
||||
// gems & small pickups fly to the player inside magnet radius
|
||||
if ((k.type === 'gem' || k.type === 'ammo' || (k.type === 'heart' && p.hp < p.stats.maxHp)) && d < magR) {
|
||||
if ((k.type === 'gem' || k.type === 'ammo' || (k.type === 'heart' && (p.hp < p.stats.maxHp || (p.stats.overShield > 0 && p.shieldHp < (p.stats.shieldPerk || 0) + p.stats.overShield)))) && d < magR) {
|
||||
const a = angleTo(k.x, k.y, p.x, p.y);
|
||||
const sp = lerp(320, 60, d / magR) * p.stats.magnetPull;
|
||||
k.x += Math.cos(a) * sp * dt; k.y += Math.sin(a) * sp * dt;
|
||||
@@ -73,9 +73,8 @@ function collectPickup(k) {
|
||||
Sfx.gem();
|
||||
break;
|
||||
case 'heart': {
|
||||
// At full HP hearts stay on the floor for later; Glutton's Gut heals +1
|
||||
// extra per tier below that.
|
||||
if (p.hp >= p.stats.maxHp) return false;
|
||||
// At full HP hearts stay unless Second Stomach has unused overflow-shield room.
|
||||
if (p.hp >= p.stats.maxHp && !(p.stats.overShield > 0 && p.shieldHp < (p.stats.shieldPerk || 0) + p.stats.overShield)) return false;
|
||||
const healAmt = 2 + (p.stats.gluttonGut || 0);
|
||||
healPlayer(healAmt); spawnText(p.x, p.y, '+' + healAmt, '#d92038');
|
||||
Sfx.heart();
|
||||
|
||||
+15
-9
@@ -87,11 +87,16 @@ function updatePlayer(dt) {
|
||||
const firing = held ? (w.id === 'bonepopper' ? 0.95 : 0.85) : 1;
|
||||
if (keyPressed('shift') && p.dashSlowT <= 0) {
|
||||
const dashD = 0.45 * (178 * st.speedMul * firing) * DASH_SLOW_T;
|
||||
p.x += (mx / len) * dashD;
|
||||
p.y += (my / len) * dashD;
|
||||
const sx = p.x, sy = p.y;
|
||||
const stepD = dashD / 8;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
p.x += (mx / len) * stepD;
|
||||
p.y += (my / len) * stepD;
|
||||
const pos = resolveSolids(p.x, p.y, p.r);
|
||||
p.x = pos.x; p.y = pos.y;
|
||||
}
|
||||
p.dashSlowT = DASH_SLOW_T;
|
||||
spawnSmoke(p.x, p.y, Math.atan2(-my, -mx), 6, '#9b8d84');
|
||||
spawnShockwave(p.x, p.y, 26, '#d8c9a8', 0.35);
|
||||
spawnDashBurst(sx, sy, Math.atan2(my, mx));
|
||||
Sfx.duck();
|
||||
}
|
||||
const spd = 178 * st.speedMul * firing * (p.dashSlowT > 0 ? 0.5 : 1);
|
||||
@@ -107,8 +112,8 @@ function updatePlayer(dt) {
|
||||
p.moveBlend = lerp(p.moveBlend, mx || my ? 1 : 0, clamp(dt * 12, 0, 1));
|
||||
p.recoil = Math.max(0, p.recoil - dt * 7);
|
||||
p.muzzleT = Math.max(0, p.muzzleT - dt);
|
||||
p.x = clamp(p.x, G.arena.x0 + p.r, G.arena.x1 - p.r);
|
||||
p.y = clamp(p.y, G.arena.y0 + p.r, G.arena.y1 - p.r);
|
||||
const pos = resolveSolids(p.x, p.y, p.r);
|
||||
p.x = pos.x; p.y = pos.y;
|
||||
|
||||
// aim
|
||||
p.aim = angleTo(p.x, p.y, mxW(), myW());
|
||||
@@ -305,7 +310,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 +338,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 +358,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) {
|
||||
|
||||
+73
-7
@@ -2,6 +2,9 @@
|
||||
|
||||
const DIRS = { n: [0, -1], s: [0, 1], e: [1, 0], w: [-1, 0] };
|
||||
const OPP = { n: 's', s: 'n', e: 'w', w: 'e' };
|
||||
const OBSTACLE_COUNTS = { grand_hall: 3, deep_hall: 3, meat_hall: 5 };
|
||||
const OBSTACLE_R = 42;
|
||||
|
||||
const ROOM_SHAPES = {
|
||||
hall: { x0: 48, y0: 48, x1: 912, y1: 592 }, // unchanged — start & boss rooms
|
||||
wide_hall: { x0: 48, y0: 176, x1: 912, y1: 464 }, // h 224 -> 288 ("a little wider"; cy stays 320, min dim 288 < 300 keeps censer/broodsac excluded)
|
||||
@@ -64,16 +67,67 @@ function genFloor(floor) {
|
||||
for (const [dir, [dx, dy]] of Object.entries(DIRS)) {
|
||||
r.doors[dir] = !!G.rooms[(r.gx + dx) + ',' + (r.gy + dy)];
|
||||
}
|
||||
placeRoomObstacles(r);
|
||||
}
|
||||
}
|
||||
|
||||
function makeRoom(gx, gy, type) {
|
||||
return { gx, gy, type, shape: 'hall', theme: 'abattoir', doors: {}, cleared: type === 'start',
|
||||
visited: false, decals: [], wavesLeft: 0, spawnT: 0, bossSpawned: false, pickups: [] };
|
||||
visited: false, decals: [], obstacles: [], wavesLeft: 0, spawnT: 0, bossSpawned: false, pickups: [] };
|
||||
}
|
||||
|
||||
function roomKey(x, y) { return x + ',' + y; }
|
||||
|
||||
function nearRoomDoor(room, x, y) {
|
||||
const a = roomBounds(room);
|
||||
const M = Math.min(130, Math.min(a.w, a.h) * 0.32);
|
||||
if (room.doors.n && Math.abs(x - a.cx) < DOOR_HALF + 30 && y < a.y0 + M) return true;
|
||||
if (room.doors.s && Math.abs(x - a.cx) < DOOR_HALF + 30 && y > a.y1 - M) return true;
|
||||
if (room.doors.e && Math.abs(y - a.cy) < DOOR_HALF + 30 && x > a.x1 - M) return true;
|
||||
if (room.doors.w && Math.abs(y - a.cy) < DOOR_HALF + 30 && x < a.x0 + M) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function placeRoomObstacles(room) {
|
||||
room.obstacles = [];
|
||||
const count = OBSTACLE_COUNTS[room.shape];
|
||||
if (!count) return;
|
||||
const a = roomBounds(room);
|
||||
for (let tries = 0; tries < 80 && room.obstacles.length < count; tries++) {
|
||||
const x = rand(a.x0 + 80, a.x1 - 80);
|
||||
const y = rand(a.y0 + 80, a.y1 - 80);
|
||||
if (nearRoomDoor(room, x, y)) continue;
|
||||
if (dist(x, y, a.cx, a.cy) < 110) continue;
|
||||
if (room.obstacles.some(o => dist(x, y, o.x, o.y) < OBSTACLE_R * 2 + 24)) continue;
|
||||
room.obstacles.push({ x, y, r: OBSTACLE_R, kind: 'bloodpool' });
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSolids(x, y, r) {
|
||||
const obstacles = G.cur && G.cur.obstacles;
|
||||
if (obstacles && obstacles.length) {
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
for (const o of obstacles) {
|
||||
const d = dist(x, y, o.x, o.y);
|
||||
if (d < r + o.r) {
|
||||
if (d > 0.01) {
|
||||
const n = (r + o.r) / d;
|
||||
x = o.x + (x - o.x) * n;
|
||||
y = o.y + (y - o.y) * n;
|
||||
} else {
|
||||
x = o.x + r + o.r;
|
||||
y = o.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
x = clamp(x, G.arena.x0 + r, G.arena.x1 - r);
|
||||
y = clamp(y, G.arena.y0 + r, G.arena.y1 - r);
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
|
||||
function wavesFor(floor) {
|
||||
const base = 3 + Math.ceil(floor * 1.4);
|
||||
const waves = [base];
|
||||
@@ -110,8 +164,9 @@ function enterRoom(gx, gy) {
|
||||
// spawn protection: brief invincibility walking into a fresh room
|
||||
if (G.player) {
|
||||
G.player.invT = Math.max(G.player.invT, 1.0);
|
||||
G.player.x = clamp(G.player.x, a.x0 + G.player.r, a.x1 - G.player.r);
|
||||
G.player.y = clamp(G.player.y, a.y0 + G.player.r, a.y1 - G.player.r);
|
||||
const pos = resolveSolids(G.player.x, G.player.y, G.player.r);
|
||||
G.player.x = pos.x;
|
||||
G.player.y = pos.y;
|
||||
}
|
||||
// Iron Lung: re-arm the once-per-room hit block
|
||||
if (G.player && G.player.stats.ironLung > 0) G.player.ironLungReady = true;
|
||||
@@ -132,6 +187,10 @@ function enterRoom(gx, gy) {
|
||||
// boss fights are a pure ammo drain, so stock the room on the way in
|
||||
spawnPickup('ammo', a.cx, a.cy + 140);
|
||||
Music.playBoss(boss.bossKind);
|
||||
G.mode = 'bossload';
|
||||
G.bossLoadSheet = boss.sprite + '_sheet';
|
||||
G.bossLoadT = BOSS_LOAD_MIN;
|
||||
Sprites.warm(G.bossLoadSheet);
|
||||
} else if (room.type === 'item') {
|
||||
spawnItemPedestal(a.cx - 45, a.cy, null, 'room');
|
||||
// item rooms sometimes also stock a weapon
|
||||
@@ -202,7 +261,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);
|
||||
@@ -304,9 +363,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;
|
||||
@@ -417,6 +479,10 @@ function drawRoom(ctx) {
|
||||
for (const d of r.decals) {
|
||||
Sprites.draw(ctx, d.img, d.x, d.y, d.rot, 48 * d.s, false, 0.55);
|
||||
}
|
||||
for (const o of r.obstacles) {
|
||||
Sprites.draw(ctx, 'decal_blood1', o.x, o.y, 0, o.r * 2.4, false, 0.92);
|
||||
Sprites.draw(ctx, 'decal_blood3', o.x, o.y, 0.7, o.r * 1.7, false, 0.75);
|
||||
}
|
||||
// hazards
|
||||
for (const h of G.hazards) {
|
||||
const k = 1 - h.t / h.life;
|
||||
|
||||
+1
-1
@@ -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=57')
|
||||
const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=60')
|
||||
.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)))
|
||||
|
||||
+20
-3
@@ -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,15 +88,32 @@ 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=57'; return; }
|
||||
if (hd && !img._sdRetry) { img._sdRetry = true; img.src = 'assets/' + name + '.png?v=60'; return; }
|
||||
this.imgs[name] = null;
|
||||
finish();
|
||||
};
|
||||
img.src = hd ? 'assets/hd/' + name + '.webp?v=57' : 'assets/' + name + '.png?v=57';
|
||||
img.src = hd ? 'assets/hd/' + name + '.webp?v=60' : 'assets/' + name + '.png?v=60';
|
||||
}
|
||||
},
|
||||
|
||||
get(name) { return this.imgs[name] || null; },
|
||||
|
||||
forceUpload(img) {
|
||||
if (typeof document === 'undefined' || !document.createElement) return;
|
||||
if (!document.createElement('canvas').getContext) return;
|
||||
if (!this._warmCtx) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 1; c.height = 1;
|
||||
this._warmCtx = c.getContext('2d');
|
||||
}
|
||||
if (this._warmCtx) this._warmCtx.drawImage(img, 0, 0, 1, 1);
|
||||
},
|
||||
|
||||
warm(name) {
|
||||
if (this.imgs[name]) { this.forceUpload(this.imgs[name]); return true; }
|
||||
return this.imgs[name] !== undefined;
|
||||
},
|
||||
|
||||
// Compact player-only strip: eight forward-facing stride frames.
|
||||
// Runtime rotation is smooth; distance-driven phase keeps contacts from skating.
|
||||
legs(ctx, x, y, facing, phase, targetW) {
|
||||
|
||||
@@ -51,6 +51,8 @@ const G = {
|
||||
hudAlpha: 1,
|
||||
hdRemaster: false,
|
||||
imagesLoaded: false,
|
||||
bossLoadT: 0,
|
||||
bossLoadSheet: null,
|
||||
roomLayer: null,
|
||||
roomLayerKey: '',
|
||||
atmosphereLayer: null,
|
||||
@@ -70,7 +72,9 @@ 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 BOSS_LOAD_MIN = 1.0;
|
||||
const PRESSURE_DIAL_MIN = -10, PRESSURE_DIAL_MAX = 10;
|
||||
|
||||
function hdScale() { return G.hdRemaster ? 4 : 1; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
+19
-4
@@ -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' },
|
||||
@@ -22,7 +22,7 @@ const WEAPONS = {
|
||||
trapqueen: { id: 'trapqueen', name: 'Trap Queen', held: 58, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 2, ammo: 26, refill: 13, interval: 0.55, dmg: 10, spd: 999, behavior: 'lob_trap', sprite: 'bullet_saw', sfx: 'lob', punch: 0.65, desc: 'Bear traps. For bears. And worse' },
|
||||
tenderizer: { id: 'tenderizer', name: 'The Tenderizer', held: 66, torsoW: 132, torsoFwd: 23, muzzle: 78, tier: 3, ammo: 10, refill: 5, interval: 0.58, dmg: 60, spd: 0, behavior: 'slam', sprite: 'bullet_bone', sfx: 'heavy', punch: 1.00, desc: 'A room-shaking close-range meat paste' },
|
||||
redhand: { id: 'redhand', name: 'Red Right Hand', held: 68, torsoW: 134, torsoFwd: 24, muzzle: 80, tier: 2, ammo: 150, refill: 72, drain: 18, interval: 0.10, dmg: 7, spd: 0, behavior: 'saw', sprite: 'bullet_saw', sfx: 'saw', punch: 0.38, desc: 'A chainsaw that liquefies anything in reach' },
|
||||
spinaltap: { id: 'spinaltap', name: 'Spinal Tap', held: 68, torsoW: 136, torsoFwd: 24, muzzle: 81, tier: 3, ammo: 7, refill: 4, interval: 0.30, dmg: 95, spd: 0, chargeTime: 0.85, behavior: 'beam', sprite: 'bullet_bone', sfx: 'beam', punch: 1.00, desc: 'Charge it. Delete a line of meat' },
|
||||
spinaltap: { id: 'spinaltap', name: 'Spinal Tap', held: 68, torsoW: 136, torsoFwd: 24, muzzle: 81, tier: 3, ammo: 7, refill: 4, interval: 0.30, dmg: 190, spd: 0, chargeTime: 0.85, behavior: 'beam', sprite: 'bullet_bone', sfx: 'beam', punch: 1.00, desc: 'Charge it. Delete a line of meat' },
|
||||
swarmjar: { id: 'swarmjar', name: 'Swarm Jar', held: 62, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 3, ammo: 12, refill: 6, interval: 0.80, dmg: 9, spd: 999, behavior: 'lob_swarm', sprite: 'bullet_gore', sfx: 'lob', punch: 0.70, desc: 'A jar of friends. They are hungry' },
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -200,7 +215,7 @@ function fireBeam(p, def, charge) {
|
||||
p.actionT = 0;
|
||||
addShake(8);
|
||||
Sfx.shoot(def);
|
||||
p.weapon.ammo -= 1 / st.ammoEff;
|
||||
if (!(p.marrowDraughtT > 0)) p.weapon.ammo -= 1 / st.ammoEff;
|
||||
}
|
||||
|
||||
// damage every enemy in a radius (slams, explosions)
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meatslicer",
|
||||
"productName": "MeatSlicer",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.5",
|
||||
"private": true,
|
||||
"description": "A Butcher's Descent - clear, harvest, descend.",
|
||||
"main": "desktop/main.js",
|
||||
|
||||
+113
-7
@@ -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, placeRoomObstacles, resolveSolids, spawnDashBurst })', sandbox);
|
||||
|
||||
let simTime = 0;
|
||||
const ctx = sandbox;
|
||||
@@ -84,6 +84,8 @@ function fire(ev, key) {
|
||||
function press(key) { fire('keydown', key); }
|
||||
function release(key) { fire('keyup', key); }
|
||||
function tap(key) { press(key); step(1, 16); release(key); }
|
||||
function drainBossLoad() { let g = 0; while (ctx.G.mode === 'bossload' && g++ < 150) step(1, 16); }
|
||||
|
||||
|
||||
let failures = 0;
|
||||
function check(name, cond) {
|
||||
@@ -216,7 +218,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=57' && urls[1] === 'assets/' + name + '.png?v=57';
|
||||
const ok = urls && urls.length === 2 && urls[0] === 'assets/hd/' + name + '.webp?v=60' && urls[1] === 'assets/' + name + '.png?v=60';
|
||||
if (ok) hdFallbackOk++;
|
||||
}
|
||||
check('HD loader tries WebP then PNG per sprite', hdFallbackOk === ctx.SPRITE_MANIFEST.length);
|
||||
@@ -241,7 +243,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=57';
|
||||
const ok = urls && urls.length === 1 && urls[0] === 'assets/' + name + '.png?v=60';
|
||||
if (ok) sdUrlOk++;
|
||||
}
|
||||
check('SD loader requests one PNG per sprite', sdUrlOk === ctx.SPRITE_MANIFEST.length);
|
||||
@@ -475,7 +477,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 +663,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 +1086,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];
|
||||
@@ -1112,6 +1129,7 @@ console.log('== generic ammo drops ==');
|
||||
ctx.WEAPONS.repeater.refill > ctx.WEAPONS.spinaltap.refill * 10);
|
||||
check('stream weapons are time-denominated (drain, not per-shot)',
|
||||
ctx.WEAPONS.bile.drain > 0 && ctx.WEAPONS.cauterizer.drain > 0 && ctx.WEAPONS.redhand.drain > 0);
|
||||
check('Spinal Tap base damage is 190', ctx.WEAPONS.spinaltap.dmg === 190);
|
||||
|
||||
const repeaterCap = ctx.WEAPONS.repeater.ammo * 1.5;
|
||||
p.weapon = { id: 'repeater', ammo: repeaterCap - 1 };
|
||||
@@ -1182,6 +1200,28 @@ console.log('== hearts stay on the floor at full HP ==');
|
||||
ctx.startRun(); step(3, 16);
|
||||
}
|
||||
|
||||
console.log('== Second Stomach hearts fill overflow shields ==');
|
||||
{
|
||||
ctx.startRun(); step(3, 16);
|
||||
const p = ctx.G.player;
|
||||
ctx.giveItem('secondstomach');
|
||||
p.hp = p.stats.maxHp;
|
||||
p.shieldHp = 0;
|
||||
const cap = (p.stats.shieldPerk || 0) + p.stats.overShield;
|
||||
ctx.G.pickups.length = 0;
|
||||
ctx.G.enemies.length = 0;
|
||||
ctx.spawnPickup('heart', p.x, p.y);
|
||||
step(2, 16);
|
||||
check('Second Stomach collects heart at full HP', ctx.G.pickups.length === 0);
|
||||
check('heart overflow fills shields', p.hp === p.stats.maxHp && p.shieldHp === Math.min(cap, 2));
|
||||
p.hp = p.stats.maxHp;
|
||||
p.shieldHp = cap;
|
||||
ctx.spawnPickup('heart', p.x, p.y);
|
||||
step(2, 16);
|
||||
check('heart stays when Second Stomach shields are full', ctx.G.pickups.length === 1);
|
||||
ctx.startRun(); step(3, 16);
|
||||
}
|
||||
|
||||
console.log('== dash: burst, slow, cooldown ==');
|
||||
{
|
||||
ctx.startRun(); step(3, 16);
|
||||
@@ -1239,6 +1279,47 @@ console.log('== dash: burst, slow, cooldown ==');
|
||||
ctx.startRun(); step(3, 16);
|
||||
}
|
||||
|
||||
console.log('== large-room blood pools and dash gore ==');
|
||||
{
|
||||
ctx.startRun(); step(3, 16);
|
||||
const hall = ctx.makeRoom(0, 0, 'combat'); hall.shape = 'hall'; hall.doors = {};
|
||||
ctx.placeRoomObstacles(hall);
|
||||
check('small rooms get no obstacles', hall.obstacles.length === 0);
|
||||
|
||||
const meat = ctx.makeRoom(1, 0, 'combat'); meat.shape = 'meat_hall';
|
||||
meat.doors = { e: true, w: true, n: false, s: false };
|
||||
ctx.placeRoomObstacles(meat);
|
||||
check('meat_hall places five pools', meat.obstacles.length === 5);
|
||||
const mb = ctx.roomBounds(meat);
|
||||
check('pools stay off center and doors', meat.obstacles.every(o =>
|
||||
Math.hypot(o.x - mb.cx, o.y - mb.cy) >= 110));
|
||||
|
||||
ctx.G.cur = meat; ctx.setArenaForRoom(meat);
|
||||
const o = meat.obstacles[0];
|
||||
const pushed = ctx.resolveSolids(o.x, o.y, 20);
|
||||
check('resolveSolids pushes out of a pool', Math.hypot(pushed.x - o.x, pushed.y - o.y) >= o.r + 20 - 0.05);
|
||||
|
||||
const p = ctx.G.player;
|
||||
p.x = o.x - (o.r + p.r + 8); p.y = o.y; p.dashSlowT = 0;
|
||||
ctx.G.parts.length = 0;
|
||||
ctx.Input.keys.d = true;
|
||||
press('shift'); step(1, 16); release('shift');
|
||||
check('dash does not enter the pool', Math.hypot(p.x - o.x, p.y - o.y) >= o.r + p.r - 0.05);
|
||||
check('dash emits a bloodball', ctx.G.parts.some(pt => pt.type === 'bloodball'));
|
||||
ctx.Input.keys.d = false;
|
||||
|
||||
ctx.G.bullets.length = 0;
|
||||
ctx.G.bullets.push({
|
||||
x: o.x - 80, y: o.y, vx: 400, vy: 0, r: 5, dmg: 1,
|
||||
pierce: 0, bounce: 0, bounces: 0, life: 1, t: 0, behavior: 'bullet',
|
||||
sprite: 'bullet_bone', homing: 0, hit: null, dragTarget: null,
|
||||
});
|
||||
ctx.updateBullets(0.3);
|
||||
check('projectiles pass through pools', ctx.G.bullets.length === 1 && ctx.G.bullets[0].x > o.x);
|
||||
|
||||
ctx.startRun(); step(3, 16);
|
||||
}
|
||||
|
||||
console.log('== debug refill tops up holstered specials ==');
|
||||
{
|
||||
ctx.startRun(); step(3, 16);
|
||||
@@ -1282,6 +1363,13 @@ console.log('== muzzle origins match projectile origins ==');
|
||||
const beamParts = ctx.G.parts.filter(pt => pt.type === 'beam');
|
||||
const newest = beamParts[beamParts.length - 1];
|
||||
check('beam particle starts at spinaltap muzzle (81px)', newest && Math.abs(newest.x - (p.x + 81)) < 0.01 && Math.abs(newest.y - (p.y)) < 0.01);
|
||||
p.weapon = { id: 'spinaltap', ammo: 7 };
|
||||
p.marrowDraughtT = 5;
|
||||
ctx.fireBeam(p, ctx.WEAPONS.spinaltap, 1);
|
||||
check('Marrow Draught fireBeam spends no ammo', p.weapon.ammo === 7);
|
||||
p.marrowDraughtT = 0;
|
||||
ctx.fireBeam(p, ctx.WEAPONS.spinaltap, 1);
|
||||
check('fireBeam spends ammo without Marrow Draught', p.weapon.ammo === 6);
|
||||
ctx.G.bullets.length = 0;
|
||||
ctx.startRun(); step(3, 16);
|
||||
}
|
||||
@@ -1449,12 +1537,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);
|
||||
@@ -1848,7 +1948,12 @@ check('floor 2', ctx.G.floor === 2);
|
||||
const bossRoom = Object.values(ctx.G.rooms).find(r => r.type === 'boss');
|
||||
check('boss room exists', !!bossRoom);
|
||||
ctx.enterRoom(bossRoom.gx, bossRoom.gy);
|
||||
step(10, 16);
|
||||
step(1, 16);
|
||||
check('boss room opens a loading screen', ctx.G.mode === 'bossload');
|
||||
drainBossLoad();
|
||||
check('boss loading screen resolves to play', ctx.G.mode === 'play');
|
||||
check('boss sheet warm helper exists', typeof ctx.Sprites.warm === 'function');
|
||||
check('elite summoner count uses 4-6', (() => { const e = ctx.makeEnemy('broodsac', 400, 300, 3, true); return e.elite === true; })());
|
||||
check('boss spawned', !!ctx.G.boss);
|
||||
check('boss hp is finite', ctx.G.boss && isFinite(ctx.G.boss.hp));
|
||||
check('boss music playing', ctx.Music.current && ctx.Music.current.name.includes('Boss'));
|
||||
@@ -2157,6 +2262,7 @@ ctx.nextFloor(); step(5, 16);
|
||||
check('override survives floor change', ctx.Music.current.name === ctx.Music.PLAYLIST[1]);
|
||||
const jbr = Object.values(ctx.G.rooms).find(r => r.type === 'boss');
|
||||
ctx.enterRoom(jbr.gx, jbr.gy); step(5, 16);
|
||||
drainBossLoad();
|
||||
check('boss track interrupts override', ctx.Music.current.name.includes('Boss'));
|
||||
ctx.Music.cycle(1); // cycling during a boss must not cut the boss track
|
||||
check('boss track not interrupted', ctx.Music.current.name.includes('Boss'));
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -390,6 +390,12 @@ def reference_payload(name: str) -> list[dict]:
|
||||
if not REFERENCE.exists():
|
||||
raise SystemExit(f"Missing style reference: {REFERENCE}")
|
||||
references = [{"type": "image_url", "image_url": {"url": data_url(REFERENCE)}}]
|
||||
if name.startswith(("w_", "wt_", "pt_")):
|
||||
mid = name[3:] if name.startswith(("wt_", "pt_")) else name[2:]
|
||||
prefix = "wt_" if name.startswith(("wt_", "pt_")) else "w_"
|
||||
model = RAW / "guides" / "models" / f"{prefix}{mid}.png"
|
||||
if model.exists():
|
||||
references.append({"type": "image_url", "image_url": {"url": data_url(model)}})
|
||||
guide = shape_guide(name)
|
||||
if guide:
|
||||
references.append({"type": "image_url", "image_url": {"url": data_url(guide)}})
|
||||
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render Meshy weapon GLBs to 2D magenta-backed stills via Blender."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
BLENDER_SCRIPT = Path(__file__).resolve().parent / "render_model_refs_blender.py"
|
||||
IN_DIR = ROOT / "assets" / "raw" / "models"
|
||||
OUT_DIR = ROOT / "assets" / "raw" / "guides" / "models"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
blender = shutil.which("blender")
|
||||
if not blender:
|
||||
print("blender: missing binary; skipping model reference renders")
|
||||
return 0
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cmd = [
|
||||
blender,
|
||||
"--background",
|
||||
"--python",
|
||||
str(BLENDER_SCRIPT),
|
||||
"--",
|
||||
"--in",
|
||||
str(IN_DIR),
|
||||
"--out",
|
||||
str(OUT_DIR),
|
||||
]
|
||||
print(" ".join(cmd))
|
||||
return subprocess.call(cmd)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Blender 5.2: orthographic stills of each weapon_<id>.glb."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
|
||||
MAGENTA = (1.0, 0.0, 1.0, 1.0)
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--in", dest="in_dir", required=True)
|
||||
parser.add_argument("--out", dest="out_dir", required=True)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def reset_scene():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = "BLENDER_EEVEE"
|
||||
scene.render.resolution_x = 1024
|
||||
scene.render.resolution_y = 1024
|
||||
scene.render.film_transparent = False
|
||||
scene.render.image_settings.file_format = "PNG"
|
||||
scene.render.image_settings.color_mode = "RGBA"
|
||||
world = bpy.data.worlds.new("Magenta")
|
||||
scene.world = world
|
||||
world.use_nodes = True
|
||||
bg = world.node_tree.nodes.get("Background")
|
||||
if bg:
|
||||
bg.inputs[0].default_value = MAGENTA
|
||||
bg.inputs[1].default_value = 1.0
|
||||
light_data = bpy.data.lights.new("Sun", "SUN")
|
||||
light_data.energy = 5.0
|
||||
light = bpy.data.objects.new("Sun", light_data)
|
||||
scene.collection.objects.link(light)
|
||||
light.location = (-1.0, -1.0, 2.0)
|
||||
light.rotation_euler = (math.atan2(math.hypot(1, 1), 2), 0.0, math.atan2(-1, -1))
|
||||
cam_data = bpy.data.cameras.new("Cam")
|
||||
cam_data.type = "ORTHO"
|
||||
cam_data.ortho_scale = 2.4
|
||||
cam = bpy.data.objects.new("Cam", cam_data)
|
||||
scene.collection.objects.link(cam)
|
||||
scene.camera = cam
|
||||
return scene, cam
|
||||
|
||||
|
||||
def import_weapon(path: Path):
|
||||
before = set(bpy.data.objects)
|
||||
bpy.ops.import_scene.gltf(filepath=str(path))
|
||||
imported = [obj for obj in bpy.data.objects if obj not in before]
|
||||
meshes = [obj for obj in imported if obj.type == "MESH"]
|
||||
if not meshes:
|
||||
return None
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
for obj in meshes:
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = meshes[0]
|
||||
if len(meshes) > 1:
|
||||
bpy.ops.object.join()
|
||||
joined = bpy.context.view_layer.objects.active
|
||||
return joined
|
||||
|
||||
|
||||
def normalize_weapon(obj):
|
||||
bpy.context.view_layer.update()
|
||||
bbox = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
|
||||
min_c = Vector((min(v.x for v in bbox), min(v.y for v in bbox), min(v.z for v in bbox)))
|
||||
max_c = Vector((max(v.x for v in bbox), max(v.y for v in bbox), max(v.z for v in bbox)))
|
||||
size = max_c - min_c
|
||||
longest = max(size.x, size.y, size.z, 1e-6)
|
||||
scale = 2.0 / longest
|
||||
obj.scale *= scale
|
||||
bpy.context.view_layer.update()
|
||||
bbox = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
|
||||
center = sum(bbox, Vector()) / 8.0
|
||||
obj.location -= center
|
||||
bpy.context.view_layer.update()
|
||||
bbox = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
|
||||
min_c = Vector((min(v.x for v in bbox), min(v.y for v in bbox), min(v.z for v in bbox)))
|
||||
max_c = Vector((max(v.x for v in bbox), max(v.y for v in bbox), max(v.z for v in bbox)))
|
||||
size = max_c - min_c
|
||||
axis = max(((size.x, "x"), (size.y, "y"), (size.z, "z")))[1]
|
||||
if axis == "y":
|
||||
obj.rotation_euler[2] += -math.pi / 2
|
||||
elif axis == "z":
|
||||
obj.rotation_euler[1] += math.pi / 2
|
||||
bpy.context.view_layer.update()
|
||||
bbox = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
|
||||
center = sum(bbox, Vector()) / 8.0
|
||||
obj.location -= center
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
|
||||
def aim_camera(cam, location):
|
||||
cam.location = location
|
||||
direction = Vector((0.0, 0.0, 0.0)) - Vector(location)
|
||||
cam.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
|
||||
|
||||
|
||||
def render_to(scene, path: Path):
|
||||
scene.render.filepath = str(path)
|
||||
bpy.ops.render.render(write_still=True)
|
||||
|
||||
|
||||
def main(argv):
|
||||
args = parse_args(argv)
|
||||
in_dir = Path(args.in_dir)
|
||||
out_dir = Path(args.out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
glbs = sorted(in_dir.glob("weapon_*.glb"))
|
||||
for glb in glbs:
|
||||
wid = glb.stem[len("weapon_") :]
|
||||
scene, cam = reset_scene()
|
||||
obj = import_weapon(glb)
|
||||
if obj is None:
|
||||
print(f"skip {glb.name}: no mesh")
|
||||
continue
|
||||
normalize_weapon(obj)
|
||||
aim_camera(cam, (0.0, 0.0, 4.0))
|
||||
render_to(scene, out_dir / f"wt_{wid}.png")
|
||||
aim_camera(cam, (0.0, -3.2, 1.4))
|
||||
render_to(scene, out_dir / f"w_{wid}.png")
|
||||
print(f"rendered {wid}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
if "--" in argv:
|
||||
argv = argv[argv.index("--") + 1 :]
|
||||
else:
|
||||
argv = []
|
||||
main(argv)
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user