Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faa58b5678 | ||
|
|
8991bde943 | ||
|
|
2cf8dc89fe | ||
|
|
988e04b2d4 | ||
|
|
c41ce5fdb0 | ||
|
|
683e7a47ec | ||
|
|
85238be844 | ||
|
|
59869945d0 | ||
|
|
a2294197e4 | ||
|
|
d60baa6b29 |
No files matched your search
@@ -7,3 +7,6 @@ dist/
|
|||||||
served-state.txt
|
served-state.txt
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
assets/raw/models/
|
||||||
|
assets/raw/meshy/
|
||||||
|
assets/raw/meshy_manifest.jsonl
|
||||||
+30
-1
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [0.9.6] — 2026-08-15
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Cleaver Cadence** — wider, harder whirl (`sweepRange` 90→110, `sweepArc` 2.4→2.85, `dmg` 34→42). Magazine stays 26 / refill 10. The butcher spins ~2.5 turns during the swing. Dual-arc blood/spark VFX and hit gibs; yellow gun muzzle is suppressed.
|
||||||
|
- **Elite Splitters** — now leave 2 Minis on death, same as a normal Splitter (instant, no telegraph).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **HD Remaster first boss-room hitch** — `drawBossLoad` now first-draws the 4× floor/wall caches and blits the boss sheet onto the game canvas under the overlay, so combat starts without a first-frame GPU stall. SD and re-visits are unchanged.
|
||||||
|
|
||||||
|
|
||||||
|
## [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
|
## [0.9.0] — 2026-08-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -221,4 +250,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.
|
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
|
## Documentation index
|
||||||
|
|
||||||
| Doc | Contents |
|
| Doc | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [Gameplay Guide](gameplay.md) | Core loop, controls, player stats, rooms & floors, pickups, leveling, scoring, pressure/difficulty systems, HUD |
|
| [Gameplay Guide](gameplay.md) | Core loop, controls, player stats, rooms & floors, pickups, leveling, scoring, pressure/difficulty systems, HUD |
|
||||||
| [Weapons](weapons.md) | All 16 weapons with exact stats, drop tiers, and damage math |
|
| [Weapons](weapons.md) | All 16 weapons with exact stats, drop tiers, and damage math |
|
||||||
| [Items](items.md) | All 46 passive items, stacking/tier rules, proc numbers, synergies |
|
| [Items](items.md) | All 82 passive items, stacking/tier rules, proc numbers, synergies |
|
||||||
| [Perks](perks.md) | All 23 Mutation Draft perks, XP curve, draft mechanics |
|
| [Perks](perks.md) | All 22 Mutation Draft perks, XP curve, draft mechanics |
|
||||||
| [Enemies](enemies.md) | All 11 enemy types, elites, drops, wave and scaling formulas |
|
| [Enemies](enemies.md) | All 11 enemy types, elites, drops, wave and scaling formulas |
|
||||||
| [Bosses](bosses.md) | All 9 bosses: rotation, scaling, full attack patterns, counters |
|
| [Bosses](bosses.md) | All 9 bosses: rotation, scaling, full attack patterns, counters |
|
||||||
| [Debug Console](dev-debug.md) | In-game cheat/inspection toolset (backtick `` ` `` key, `--dev` flag): all 8 tabs, run tainting, stat-rebuild internals |
|
| [Debug Console](dev-debug.md) | In-game cheat/inspection toolset (backtick `` ` `` key, `--dev` flag): all 8 tabs, run tainting, stat-rebuild internals |
|
||||||
@@ -20,8 +22,8 @@ MeatSlicer is an endless top-down arena roguelite. You are the Butcher, descendi
|
|||||||
### Quick numbers
|
### Quick numbers
|
||||||
|
|
||||||
- **16 weapons** (1 infinite-ammo sidearm + 15 special weapons across 4 tiers)
|
- **16 weapons** (1 infinite-ammo sidearm + 15 special weapons across 4 tiers)
|
||||||
- **46 passive items** (stacking, tier cap IX)
|
- **82 passive items** (stacking, tier cap IX)
|
||||||
- **23 perks** (draft 1 of 3 per level-up)
|
- **22 perks** (draft 1 of 3 per level-up)
|
||||||
- **11 enemy types** (+ elite variants)
|
- **11 enemy types** (+ elite variants)
|
||||||
- **9 bosses** (fixed rotation, one per floor)
|
- **9 bosses** (fixed rotation, one per floor)
|
||||||
- **∞ floors** (permadeath, endless descent, high-score chase)
|
- **∞ floors** (permadeath, endless descent, high-score chase)
|
||||||
|
|||||||
+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
|
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.
|
game is still in development — testers and players are welcome to use it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Enabling it
|
## Enabling it
|
||||||
|
|
||||||
The console is gated behind a dev flag and is completely inert otherwise (the
|
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
|
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*,
|
tier, or set an exact tier. Presets: *one of everything*, *max everything*,
|
||||||
*clear build*.
|
*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`.
|
clear all perks. Set / fire an active item from `ACTIVES`.
|
||||||
5. **WORLD** — warp to floor N (`genFloor` + `enterRoom`), next floor, teleport
|
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
|
to the boss/item room, clear room, kill all, kill boss, regen floor. Spawn
|
||||||
|
|||||||
+67
-14
@@ -56,7 +56,7 @@ Ranged kiter. Holds 190–250 px away from you — approaches if you're farther
|
|||||||
| XP | 1 |
|
| XP | 1 |
|
||||||
| First appears | Floor 2 (spawn weight 16) |
|
| First appears | Floor 2 (spawn weight 16) |
|
||||||
|
|
||||||
Chaser that **splits into 2 Minis on death** (non-elite Splitters only). Slightly tankier than a Shambler. Explosions and AoE that kill the Minis with the parent are valuable here.
|
Chaser that **splits into 2 Minis on death** (elite and normal). Slightly tankier than a Shambler. Explosions and AoE that kill the Minis with the parent are valuable here.
|
||||||
|
|
||||||
### Mini
|
### Mini
|
||||||
|
|
||||||
@@ -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.
|
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
|
## Spawn weights
|
||||||
|
|
||||||
| Enemy | First floor | Weight |
|
| Enemy | First floor | Weight |
|
||||||
|---|---:|---:|
|
|---|---|---|
|
||||||
| Shambler | 1 | 30 |
|
| Shambler | 1 | 30 |
|
||||||
| Runner | 1 | 22 |
|
| Runner | 1 | 22 |
|
||||||
| Spitter | 2 | 14 |
|
| 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.
|
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
|
## Elites
|
||||||
|
|
||||||
Any wave-spawned enemy can roll as an **elite** (gold ring, HP bar pip):
|
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))`).
|
- Every hit taken deals a **minimum of 1 damage** (`max(1, round(dmg))`).
|
||||||
- **Armor** gives a chance to completely ignore a hit. Each source of armor adds its listed chance directly (e.g. Tanned Hide +8%, Thick Hide +4%) — the block chance is simply `armor`, capped at 75%. A blocked hit shows "BLOCKED" and grants only 0.2 s of invulnerability.
|
- **Armor** gives a chance to completely ignore a hit. Each source of armor adds its listed chance directly (e.g. Tanned Hide +8%, Thick Hide +4%) — the block chance is simply `armor`, capped at 75%. A blocked hit shows "BLOCKED" and grants only 0.2 s of invulnerability.
|
||||||
- **Shield hearts** (cyan) absorb damage before real HP. They come from the Bone Plate item and Shield Heart perk, and are refilled to full at the start of each floor. Losing your last shield triggers a "SHIELD DOWN" warning. On the HUD they render at half-heart granularity — **2 shield HP = one full cyan heart**, matching red hearts (2 HP = 1 heart).
|
- **Shield hearts** (cyan) absorb damage before real HP. They come from the Bone Plate item and Shield Heart perk, and regenerate each floor: 25% of your max shields, rounded up to the next whole shield (1 shield = one full cyan heart = 2 shield HP); a new run starts at full. Losing your last shield triggers a "SHIELD DOWN" warning. On the HUD they render at half-heart granularity — **2 shield HP = one full cyan heart**, matching red hearts (2 HP = 1 heart).
|
||||||
- Getting hit knocks you back 14 px, shakes the screen, and flashes a red vignette.
|
- Getting hit knocks you back 14 px, shakes the screen, and flashes a red vignette.
|
||||||
|
|
||||||
### Healing
|
### Healing
|
||||||
|
|
||||||
- Hearts restore 2 HP (1 heart) each.
|
- 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).
|
- 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 |
|
| Elite enemy | 3× XP + 3–5 bonus gems · 18% + luck×10% **item** · else 45% + luck×15% ammo |
|
||||||
| Boss | 1 item pedestal + 1 weapon (rolled at floor + 2) + 1 guaranteed ammo + stairs · the boss room also stocks 1 ammo when you enter · the stair hatch stays sealed for 3 seconds after the kill |
|
| Boss | 1 item pedestal + 1 weapon (rolled at floor + 2) + 1 guaranteed ammo + stairs · the boss room also stocks 1 ammo when you enter · the stair hatch stays sealed for 3 seconds after the kill |
|
||||||
|
|
||||||
**Luck** is a player stat (Lucky Coin, Crow Bait, Scavenger perk) that scales most drop chances as shown above.
|
**Luck** is a player stat (Lucky Coin, Crow Bait) that scales most drop chances as shown above.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ The **PRESSURE** meter on the HUD is a run-long adaptive difficulty multiplier a
|
|||||||
|
|
||||||
- Range: **0.60 – 2.00** (starts at 1.00).
|
- Range: **0.60 – 2.00** (starts at 1.00).
|
||||||
- Gains for rooms cleared **without taking damage** (flawless streak).
|
- Gains for rooms cleared **without taking damage** (flawless streak).
|
||||||
- Taking damage **relieves** pressure, scaled by how big the hit was, how low your HP is, and how many recent hits you've taken.
|
- Taking damage **relieves** pressure, scaled by how big the hit was, how low your HP is, and how many recent hits you've taken. Hits that damage a shield grant no relief.
|
||||||
- Decays passively while below 35% HP, or if you stall in a room for 90+ seconds.
|
- Decays passively while below 35% HP, or if you stall in a room for 90+ seconds.
|
||||||
- Only affects enemies spawned *after* the change — living enemies keep their stats.
|
- Only affects enemies spawned *after* the change — living enemies keep their stats.
|
||||||
|
|
||||||
@@ -234,9 +234,9 @@ The title screen has a **PRESSURE DIAL** (−10 … +10) that tunes how pressure
|
|||||||
|
|
||||||
| Dial | Rise / clean room | Relief on hit (base) | Behaviour |
|
| Dial | Rise / clean room | Relief on hit (base) | Behaviour |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **−10** | −0.020 | 0.080 | Strong mercy; negative rise respects the 0.60 floor |
|
| **−10** | −0.020 | 0.040 | Strong mercy; negative rise respects the 0.60 floor |
|
||||||
| **−5** | 0 | 0.050 | Mercy — pressure does not rise from clean rooms |
|
| **−5** | 0 | 0.025 | Mercy — pressure does not rise from clean rooms |
|
||||||
| **0** | +0.010 | 0.030 | Standard — today's balance |
|
| **0** | +0.010 | 0.015 | Standard tuning |
|
||||||
| **+5** | +0.050 | 0 (none) | Ratchet — pressure can only ever rise; score floored at 1.00× |
|
| **+5** | +0.050 | 0 (none) | Ratchet — pressure can only ever rise; score floored at 1.00× |
|
||||||
| **+10** | +0.100 | 0 | Maximum ratchet; pressure respects the 2.00 ceiling |
|
| **+10** | +0.100 | 0 | Maximum ratchet; pressure respects the 2.00 ceiling |
|
||||||
|
|
||||||
@@ -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.
|
- **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.
|
- **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.
|
- **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.
|
- **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).
|
- **Bottom-center:** boss HP bar (during boss fights).
|
||||||
- **Top-center:** toast notifications (pickups, floor intros, boss events).
|
- **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 |
|
| Screen | What it offers |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Title** | INITIATE DESCENT, PRESSURE DIAL (−10…+10), HD REMASTER ON/OFF tag, control reference, best score, confirmed desktop exit |
|
| **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 (HD first-draw paid under the overlay), then combat |
|
||||||
| **Pause** | Jukebox (track cycling), SFX/music volume + HUD opacity sliders, resume/swap/auto/mute hints, Field Manual (`H` / `?` button) |
|
| **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 |
|
| **Mutation Draft** | 3 perk cards, random cut, reroll, auto-draft |
|
||||||
| **Game Over ("BUTCHERED")** | Floor, kills, score, NEW BEST CUT tag, restart |
|
| **Game Over ("BUTCHERED")** | Floor, kills, score, NEW BEST CUT tag, restart |
|
||||||
|
|||||||
+6
-6
@@ -1,6 +1,6 @@
|
|||||||
# Items
|
# 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
|
## Damage & Fire Rate
|
||||||
|
|
||||||
| Item | Description | Effect per tier |
|
| Item | Description | Exact effect per tier |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Hollow Points** | +25% damage | `dmgMul ×1.25` |
|
| **Hollow Points** | +25% damage | `dmgMul ×1.25` |
|
||||||
| **Marrow Glut** | +20% damage, −10% fire rate | `dmgMul ×1.20`, `rateMul ×0.90` |
|
| **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
|
## Shot Modifiers
|
||||||
|
|
||||||
| Item | Description | Effect per tier |
|
| Item | Description | Exact effect per tier |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Scalpel** | +25% shot speed | `shotSpeedMul ×1.25` |
|
| **Scalpel** | +25% shot speed | `shotSpeedMul ×1.25` |
|
||||||
| **Lead Marrow** | +25% range | `rangeMul ×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 |
|
| **Twin Hearts** | +1 max heart, heal to full | `maxHp +2`, `hp = maxHp` — the premium Rare HP item |
|
||||||
| **Rendered Fat** | +½ heart, +4% armor | `maxHp +1`, `armor +0.04`, heal 1 — clean hybrid HP/armor |
|
| **Rendered Fat** | +½ heart, +4% armor | `maxHp +1`, `armor +0.04`, heal 1 — clean hybrid HP/armor |
|
||||||
| **Gristle Cord** | +0.12s hurt immunity, +4% armor | `invBonus +0.12`, `armor +0.04` |
|
| **Gristle Cord** | +0.12s hurt immunity, +4% armor | `invBonus +0.12`, `armor +0.04` |
|
||||||
| **Bone Plate** | +½ shield heart each floor | `shieldPerk +1`, `shieldHp +1` — shields refill every floor (1 shield HP = ½ heart, renders as half a cyan pip) |
|
| **Bone Plate** | +½ shield heart each floor | `shieldPerk +1`, `shieldHp +1` — shields regen 25% per floor (rounded up) (1 shield HP = ½ heart, renders as half a cyan pip) |
|
||||||
| **Second Stomach** | +½ heart and excess healing shields | `maxHp +1`, heal 1, `overShield +1` — overflow healing becomes shield HP |
|
| **Second Stomach** | +½ heart and excess healing shields | `maxHp +1`, heal 1, `overShield +1` — overflow healing becomes shield HP |
|
||||||
| **Tanned Hide** | +8% chance to ignore damage | `armor +0.08` — literal dodge chance, cap 75% |
|
| **Tanned Hide** | +8% chance to ignore damage | `armor +0.08` — literal dodge chance, cap 75% |
|
||||||
| **Dead Man's Clock** | +0.25s hurt immunity | `invBonus +0.25` — post-hit invulnerability 0.9 s → 1.15 s at tier I |
|
| **Dead Man's Clock** | +0.25s hurt immunity | `invBonus +0.25` — post-hit invulnerability 0.9 s → 1.15 s at tier I |
|
||||||
@@ -123,7 +123,7 @@ All of these trigger from your weapon hits (orbital knives proc at half chance).
|
|||||||
|
|
||||||
## Criticals
|
## Criticals
|
||||||
|
|
||||||
| Item | Description | Effect per tier |
|
| Item | Description | Exact effect per tier |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Bloodshot Eye** | +6% critical chance | `crit +0.06` (base 5%) |
|
| **Bloodshot Eye** | +6% critical chance | `crit +0.06` (base 5%) |
|
||||||
| **Hollow Needle** | +50% critical damage | `critMul +0.50` (base ×2.0) |
|
| **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.
|
- **Split Tongue / Hydra Maw / Backstabber** multiply projectile count, which multiplies proc rolls per second — the core of most broken builds.
|
||||||
- **Piercing Gaze + Ricochet Ribs** feed the slam/saw/beam "inert" bonus (+6% each) on The Tenderizer, Red Right Hand, and Spinal Tap, while also powering Swarm Jar maggots.
|
- **Piercing Gaze + Ricochet Ribs** feed the slam/saw/beam "inert" bonus (+6% each) on The Tenderizer, Red Right Hand, and Spinal Tap, while also powering Swarm Jar maggots.
|
||||||
- **Volatile Bile** chains: explosions can kill, which triggers more explosions (and frenzy, and lifesteal procs).
|
- **Volatile Bile** chains: explosions can kill, which triggers more explosions (and frenzy, and lifesteal procs).
|
||||||
- **Bone Plate + Shield Heart** (perk) stack into a large shield that refills free every floor.
|
- **Bone Plate + Shield Heart** (perk) stack into a large shield pool that regens 25% per floor.
|
||||||
+13
-14
@@ -1,12 +1,12 @@
|
|||||||
# Perks
|
# Perks
|
||||||
|
|
||||||
All **23 perks** in MeatSlicer, with exact effects pulled from `js/perks.js`. Perks are chosen through the **Mutation Draft** each time you level up, and last for the rest of the run.
|
All **22 perks** in MeatSlicer, with exact effects pulled from `js/perks.js`. Perks are chosen through the **Mutation Draft** each time you level up, and last for the rest of the run.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The Mutation Draft
|
## The Mutation Draft
|
||||||
|
|
||||||
- Gaining a level queues a draft. When it opens, you're offered **3 random perks** from the full pool — all 23 perks are equally weighted; there is no rarity system.
|
- Gaining a level queues a draft. When it opens, you're offered **3 random perks** from the full pool — all 22 perks are equally weighted; there is no rarity system.
|
||||||
- Choose with **1 / 2 / 3**, by **clicking a card**, or press **4 / Space** for a "Random Cut".
|
- Choose with **1 / 2 / 3**, by **clicking a card**, or press **4 / Space** for a "Random Cut".
|
||||||
- Press **R** to **reroll** all three choices (costs 1 reroll token). Rerolls come from the **Reroll Rib** item, which also grants +1 reroll every level-up.
|
- Press **R** to **reroll** all three choices (costs 1 reroll token). Rerolls come from the **Reroll Rib** item, which also grants +1 reroll every level-up.
|
||||||
- Press **T** to toggle **Auto-Draft**: future level-ups instantly pick a random perk without pausing the game. The setting persists between sessions.
|
- Press **T** to toggle **Auto-Draft**: future level-ups instantly pick a random perk without pausing the game. The setting persists between sessions.
|
||||||
@@ -54,7 +54,7 @@ XP comes from gems dropped by kills, room clears, and bosses, scaled by your `xp
|
|||||||
| Perk | Description | Exact effect |
|
| Perk | Description | Exact effect |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Big Heart** | +½ max heart, heal ½ heart | `maxHp +1`, heal 1 |
|
| **Big Heart** | +½ max heart, heal ½ heart | `maxHp +1`, heal 1 |
|
||||||
| **Shield Heart** | +½ shield heart at the start of each floor | `shieldPerk +1`, `shieldHp +1` — refills every floor transition |
|
| **Shield Heart** | +½ shield heart at the start of each floor | `shieldPerk +1`, `shieldHp +1` — regen: 25% of max shields (rounded up to a whole shield) each floor transition |
|
||||||
| **Thick Hide** | +4% chance to ignore damage | `armor +0.04` — literal dodge chance, cap 75% |
|
| **Thick Hide** | +4% chance to ignore damage | `armor +0.04` — literal dodge chance, cap 75% |
|
||||||
| **Second Wind** | +0.15s hurt immunity | `invBonus +0.15` — post-hit invulnerability 0.9 s → 1.05 s |
|
| **Second Wind** | +0.15s hurt immunity | `invBonus +0.15` — post-hit invulnerability 0.9 s → 1.05 s |
|
||||||
| **Spite Flesh** | Contact attackers take damage | `thorns +4` — melee attackers take `4 × dmgMul` |
|
| **Spite Flesh** | Contact attackers take damage | `thorns +4` — melee attackers take `4 × dmgMul` |
|
||||||
@@ -68,7 +68,7 @@ XP comes from gems dropped by kills, room clears, and bosses, scaled by your `xp
|
|||||||
| **Sinew Weave** | +3% move speed and range | `speedMul ×1.03`, `rangeMul ×1.03` |
|
| **Sinew Weave** | +3% move speed and range | `speedMul ×1.03`, `rangeMul ×1.03` |
|
||||||
| **Magnet Bile** | +10% pickup radius | `magnet ×1.10` |
|
| **Magnet Bile** | +10% pickup radius | `magnet ×1.10` |
|
||||||
| **Carrion Sense** | +8% pickup radius and pull speed | `magnet ×1.08`, `magnetPull ×1.08` *(note: pull speed is not currently wired into pickup movement — the radius half works)* |
|
| **Carrion Sense** | +8% pickup radius and pull speed | `magnet ×1.08`, `magnetPull ×1.08` *(note: pull speed is not currently wired into pickup movement — the radius half works)* |
|
||||||
| **Scavenger** | +4% drop luck | `luck +0.04` |
|
|
||||||
| **Bloodrush** | +5% XP gain, bonus crystal chance | `xpMul ×1.05` |
|
| **Bloodrush** | +5% XP gain, bonus crystal chance | `xpMul ×1.05` |
|
||||||
| **Scrap Feed** | 5% less ammo (diminishing) | `ammoEff +0.05` per stack — saving fraction = rating/(1+rating), so each extra stack helps less (like Thick Hide) |
|
| **Scrap Feed** | 5% less ammo (diminishing) | `ammoEff +0.05` per stack — saving fraction = rating/(1+rating), so each extra stack helps less (like Thick Hide) |
|
||||||
|
|
||||||
@@ -93,18 +93,17 @@ XP comes from gems dropped by kills, room clears, and bosses, scaled by your `xp
|
|||||||
| 13 | Long Bone | Offensive |
|
| 13 | Long Bone | Offensive |
|
||||||
| 14 | Magnet Bile | Utility |
|
| 14 | Magnet Bile | Utility |
|
||||||
| 15 | Quickening | Mobility |
|
| 15 | Quickening | Mobility |
|
||||||
| 16 | Scavenger | Utility |
|
| 16 | Scrap Feed | Utility |
|
||||||
| 17 | Scrap Feed | Utility |
|
| 17 | Second Wind | Defensive |
|
||||||
| 18 | Second Wind | Defensive |
|
| 18 | Sharpened | Offensive |
|
||||||
| 19 | Sharpened | Offensive |
|
| 19 | Shield Heart | Defensive |
|
||||||
| 20 | Shield Heart | Defensive |
|
| 20 | Sinew Weave | Mobility |
|
||||||
| 21 | Sinew Weave | Mobility |
|
| 21 | Spite Flesh | Defensive |
|
||||||
| 22 | Spite Flesh | Defensive |
|
| 22 | Thick Hide | Defensive |
|
||||||
| 23 | Thick Hide | Defensive |
|
|
||||||
|
|
||||||
## Drafting advice
|
## Drafting advice
|
||||||
|
|
||||||
- **Perks are small, items are big.** A perk is worth ~0.5 power score; an item tier is worth 1.0. Perks smooth out a build; items define it.
|
- **Perks are small, items are big.** A perk is worth ~0.5 power score; an item tier is worth 1.0. Perks smooth out a build; items define it.
|
||||||
- **Shield Heart** is one of the strongest defensive picks — it's a free refilling shield pip every floor, and it stacks with Bone Plate.
|
- **Shield Heart** is one of the strongest defensive picks — it's free shield capacity that regens 25% each floor, and it stacks with Bone Plate.
|
||||||
- **Scavenger / Bloodrush / Magnet Bile** early compound over a long run; **Sharpened / Adrenal Surge** scale whatever your weapon already does well.
|
- **Bloodrush / Magnet Bile** early compound over a long run; **Sharpened / Adrenal Surge** scale whatever your weapon already does well.
|
||||||
- With **Auto-Draft** on, perks become a no-attention background bonus — good for speedrunning floors, bad if you're fishing for a specific build.
|
- With **Auto-Draft** on, perks become a no-attention background bonus — good for speedrunning floors, bad if you're fishing for a specific build.
|
||||||
+8
-8
@@ -106,18 +106,18 @@ Shotgun. Up to 66 damage per trigger pull at point blank before multipliers, but
|
|||||||
|
|
||||||
### Cleaver Cadence
|
### Cleaver Cadence
|
||||||
|
|
||||||
> *It always comes back*
|
> *A wider, gory whirl of steel*
|
||||||
|
|
||||||
| Stat | Value |
|
| Stat | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Damage | 15 |
|
| Damage | 42 |
|
||||||
| Fire interval | 0.38 s |
|
| Fire interval | 0.55 s |
|
||||||
| Projectile speed | 400 (returns at up to 520) |
|
| Reach | 110 px |
|
||||||
| Range | 1.10 s |
|
| Arc | 2.85 rad (~163°) |
|
||||||
| Ammo | 26 (refill 10 per pickup) |
|
| 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 whirl across the aim direction hits every enemy within 110 px inside a ~163° arc for 42 damage with heavy knockback. The butcher spins ~2.5 turns during the swing. Blood and sparks ride the arc; there is no yellow gun muzzle.
|
||||||
|
|
||||||
### Sawblade Launcher
|
### Sawblade Launcher
|
||||||
|
|
||||||
@@ -295,7 +295,7 @@ Point-blank slam — no projectile, an instant circular blast in front of you. B
|
|||||||
|
|
||||||
| Stat | Value |
|
| 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) |
|
| Charge time | 0.85 s (minimum 40% charge = 0.34 s to fire) |
|
||||||
| Beam length | 900 × rangeMul |
|
| Beam length | 900 × rangeMul |
|
||||||
| Beam width | 14 × √sizeMul |
|
| Beam width | 14 × √sizeMul |
|
||||||
|
|||||||
+22
-22
@@ -6,32 +6,32 @@
|
|||||||
<meta name="theme-color" content="#0a0305">
|
<meta name="theme-color" content="#0a0305">
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><text y=%2252%22 x=%2250%22 text-anchor=%22middle%22 dominant-baseline=%22middle%22 font-size=%2252%22>🔪</text></svg>">
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><text y=%2252%22 x=%2250%22 text-anchor=%22middle%22 dominant-baseline=%22middle%22 font-size=%2252%22>🔪</text></svg>">
|
||||||
<title>MEATSLICER</title>
|
<title>MEATSLICER</title>
|
||||||
<link rel="stylesheet" href="css/style.css?v=56">
|
<link rel="stylesheet" href="css/style.css?v=61">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="wrap">
|
<div id="wrap">
|
||||||
<canvas id="game" width="960" height="640"></canvas>
|
<canvas id="game" width="960" height="640"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<script src="js/utils.js?v=56"></script>
|
<script src="js/utils.js?v=61"></script>
|
||||||
<script src="js/state.js?v=56"></script>
|
<script src="js/state.js?v=61"></script>
|
||||||
<script src="js/input.js?v=56"></script>
|
<script src="js/input.js?v=61"></script>
|
||||||
<script src="js/sfx.js?v=56"></script>
|
<script src="js/sfx.js?v=61"></script>
|
||||||
<script src="js/sfxbank.js?v=56"></script>
|
<script src="js/sfxbank.js?v=61"></script>
|
||||||
<script src="js/audio.js?v=56"></script>
|
<script src="js/audio.js?v=61"></script>
|
||||||
<script src="js/sprites.js?v=56"></script>
|
<script src="js/sprites.js?v=61"></script>
|
||||||
<script src="js/particles.js?v=56"></script>
|
<script src="js/particles.js?v=61"></script>
|
||||||
<script src="js/weapons.js?v=56"></script>
|
<script src="js/weapons.js?v=61"></script>
|
||||||
<script src="js/bullets.js?v=56"></script>
|
<script src="js/bullets.js?v=61"></script>
|
||||||
<script src="js/pickups.js?v=56"></script>
|
<script src="js/pickups.js?v=61"></script>
|
||||||
<script src="js/items.js?v=56"></script>
|
<script src="js/items.js?v=61"></script>
|
||||||
<script src="js/perks.js?v=56"></script>
|
<script src="js/perks.js?v=61"></script>
|
||||||
<script src="js/enemies.js?v=56"></script>
|
<script src="js/enemies.js?v=61"></script>
|
||||||
<script src="js/bosses.js?v=56"></script>
|
<script src="js/bosses.js?v=61"></script>
|
||||||
<script src="js/rooms.js?v=56"></script>
|
<script src="js/rooms.js?v=61"></script>
|
||||||
<script src="js/player.js?v=56"></script>
|
<script src="js/player.js?v=61"></script>
|
||||||
<script src="js/hud.js?v=56"></script>
|
<script src="js/hud.js?v=61"></script>
|
||||||
<script src="js/help.js?v=56"></script>
|
<script src="js/help.js?v=61"></script>
|
||||||
<script src="js/debug.js?v=56"></script>
|
<script src="js/debug.js?v=61"></script>
|
||||||
<script src="js/main.js?v=56"></script>
|
<script src="js/main.js?v=61"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+8
-4
@@ -100,6 +100,8 @@ function spawnPosAwayFromPlayer() {
|
|||||||
const y = rand(a.y0 + 40, a.y1 - 40);
|
const y = rand(a.y0 + 40, a.y1 - 40);
|
||||||
if (dist(x, y, p.x, p.y) <= safeDist) continue;
|
if (dist(x, y, p.x, p.y) <= safeDist) continue;
|
||||||
if (nearAnyDoor(x, y)) 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 };
|
return { x, y };
|
||||||
}
|
}
|
||||||
// fallback: a bounded central region, valid even in the thin hall variants
|
// fallback: a bounded central region, valid even in the thin hall variants
|
||||||
@@ -376,7 +378,7 @@ function killEnemy(e, ang) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// splitter splits
|
// splitter splits
|
||||||
if (ENEMY_TYPES[e.type].splits && !e.elite) {
|
if (ENEMY_TYPES[e.type].splits) {
|
||||||
Sfx.split(e);
|
Sfx.split(e);
|
||||||
for (let i = 0; i < 2 && G.enemies.length < MAX_ENEMIES; i++) {
|
for (let i = 0; i < 2 && G.enemies.length < MAX_ENEMIES; i++) {
|
||||||
const m = makeEnemy('mini', e.x + rand(-18, 18), e.y + rand(-18, 18), G.floor, false);
|
const m = makeEnemy('mini', e.x + rand(-18, 18), e.y + rand(-18, 18), G.floor, false);
|
||||||
@@ -458,8 +460,8 @@ function updateEnemies(dt) {
|
|||||||
e.x += px; e.y += py; o.x -= px; o.y -= py;
|
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);
|
const pos = resolveSolids(e.x, e.y, e.r);
|
||||||
e.y = clamp(e.y, G.arena.y0 + e.r, G.arena.y1 - e.r);
|
e.x = pos.x; e.y = pos.y;
|
||||||
|
|
||||||
// contact damage
|
// contact damage
|
||||||
if (e.hitT > 0) e.hitT -= dt;
|
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);
|
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.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);
|
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.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;
|
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;
|
e.summonT -= dt;
|
||||||
if (e.summonT <= 0) {
|
if (e.summonT <= 0) {
|
||||||
e.summonT = e.elite ? 3 : 4;
|
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;
|
e.attackT = 0.5; e.actionT = 0;
|
||||||
}
|
}
|
||||||
} else if (t.ranged) {
|
} else if (t.ranged) {
|
||||||
|
|||||||
+4
-4
@@ -178,7 +178,7 @@ function drawHelpPerkList(ctx, px, py) {
|
|||||||
let ty = py + HELP_BODY;
|
let ty = py + HELP_BODY;
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.fillStyle = '#d7a934'; ctx.font = hfont(9, true);
|
ctx.fillStyle = '#d7a934'; ctx.font = hfont(9, true);
|
||||||
ctx.fillText('23 PERKS · DRAFT 1 OF 3 PER LEVEL · XP FOR NEXT LEVEL: 8 + LEVEL × 4', x, ty);
|
ctx.fillText(PERKS.length + ' PERKS · DRAFT 1 OF 3 PER LEVEL · XP FOR NEXT LEVEL: 8 + LEVEL × 4', x, ty);
|
||||||
ty += 11;
|
ty += 11;
|
||||||
ctx.fillStyle = '#7d6a68'; ctx.font = hfont(8, false);
|
ctx.fillStyle = '#7d6a68'; ctx.font = hfont(8, false);
|
||||||
ctx.fillText('REROLL RIB GRANTS TOKENS (R TO REDRAW) · AUTO-DRAFT (T) PICKS RANDOMLY', x, ty + 11);
|
ctx.fillText('REROLL RIB GRANTS TOKENS (R TO REDRAW) · AUTO-DRAFT (T) PICKS RANDOMLY', x, ty + 11);
|
||||||
@@ -304,11 +304,11 @@ function drawHelpPressure(ctx, px, py) {
|
|||||||
'ALL SCORE IS MULTIPLIED BY LIVE PRESSURE',
|
'ALL SCORE IS MULTIPLIED BY LIVE PRESSURE',
|
||||||
'DIAL ' + (d > 0 ? '+' : '') + d + ' — RISE ' + gain.toFixed(1) + '/10 PER CLEAN ROOM · RELIEF ' + drop.toFixed(1) + '/10 ON HIT',
|
'DIAL ' + (d > 0 ? '+' : '') + d + ' — RISE ' + gain.toFixed(1) + '/10 PER CLEAN ROOM · RELIEF ' + drop.toFixed(1) + '/10 ON HIT',
|
||||||
'+' + pressureGain().toFixed(3) + ' FOR EVERY ROOM CLEARED WITHOUT TAKING ANY DAMAGE',
|
'+' + pressureGain().toFixed(3) + ' FOR EVERY ROOM CLEARED WITHOUT TAKING ANY DAMAGE',
|
||||||
'RELIEF ON HIT = ' + (0.03 * scale).toFixed(3) + ' × SEVERITY × DESPERATION × CHURN',
|
'RELIEF ON HIT = ' + (PRESSURE_DROP_BASE * PRESSURE_UNIT * PRESSURE_RELIEF_MUL * scale).toFixed(3) + ' × SEVERITY × DESPERATION × CHURN',
|
||||||
' SEVERITY = HIT / (MAX HP × 0.25), CLAMPED 0.4 – 2',
|
' SEVERITY = HIT / (MAX HP × 0.25), CLAMPED 0.4 – 2',
|
||||||
' DESPERATION = 1 + 2 × (1 − HP FRACTION) — MORE RELIEF WHEN HURT',
|
' DESPERATION = 1 + 2 × (1 − HP FRACTION) — MORE RELIEF WHEN HURT',
|
||||||
' CHURN = 1 + 0.35 × HITS IN LAST 12S (CAP +4) — MORE WHEN SWARMED',
|
' CHURN = 1 + 0.35 × HITS IN LAST 12S (CAP +4) — MORE WHEN SWARMED',
|
||||||
'DECAY: ' + (0.02 * scale).toFixed(3) + '/S BELOW 35% HP · ' + (0.008 * scale).toFixed(3) + '/S AFTER 90S IN ONE ROOM',
|
'DECAY: ' + (0.02 * scale * PRESSURE_RELIEF_MUL).toFixed(3) + '/S BELOW 35% HP · ' + (0.008 * scale * PRESSURE_RELIEF_MUL).toFixed(3) + '/S AFTER 90S IN ONE ROOM',
|
||||||
'LIVING ENEMIES KEEP THEIR STATS — ONLY NEW SPAWNS CHANGE',
|
'LIVING ENEMIES KEEP THEIR STATS — ONLY NEW SPAWNS CHANGE',
|
||||||
'STREAK = CONSECUTIVE FLAWLESS CLEARS (COSMETIC)',
|
'STREAK = CONSECUTIVE FLAWLESS CLEARS (COSMETIC)',
|
||||||
] },
|
] },
|
||||||
@@ -316,7 +316,7 @@ function drawHelpPressure(ctx, px, py) {
|
|||||||
'ARMOR = BLOCK CHANCE: RATING / (1 + RATING), HARD CAP 75%',
|
'ARMOR = BLOCK CHANCE: RATING / (1 + RATING), HARD CAP 75%',
|
||||||
' THICK HIDE +1/24 RATING · TANNED HIDE +0.08/0.92 (DIMINISHING)',
|
' THICK HIDE +1/24 RATING · TANNED HIDE +0.08/0.92 (DIMINISHING)',
|
||||||
'BLOCKED HIT: 0 DAMAGE, 0.2S I-FRAMES, STREAK PRESERVED',
|
'BLOCKED HIT: 0 DAMAGE, 0.2S I-FRAMES, STREAK PRESERVED',
|
||||||
'SHIELD HEARTS ABSORB FIRST, REFILL EACH FLOOR · 0.9S I-FRAMES ON HIT',
|
'SHIELD HEARTS ABSORB FIRST, REGEN 25% EACH FLOOR · SHIELDED HITS NO RELIEF · 0.9S I-FRAMES ON HIT',
|
||||||
'CRIT: 5% BASE CHANCE, ×2 DAMAGE (CRITMUL STACKS)',
|
'CRIT: 5% BASE CHANCE, ×2 DAMAGE (CRITMUL STACKS)',
|
||||||
] },
|
] },
|
||||||
], HELP_PANEL.w - 80);
|
], HELP_PANEL.w - 80);
|
||||||
|
|||||||
@@ -167,8 +167,6 @@ function drawHUD(ctx) {
|
|||||||
ctx.fillText('KILLS ' + String(G.kills).padStart(3, '0'), W - 18, H - 67);
|
ctx.fillText('KILLS ' + String(G.kills).padStart(3, '0'), W - 18, H - 67);
|
||||||
ctx.fillStyle = '#f0e5d7'; ctx.font = 'bold 11px monospace';
|
ctx.fillStyle = '#f0e5d7'; ctx.font = 'bold 11px monospace';
|
||||||
ctx.fillText('SCORE ' + String(G.score).padStart(6, '0'), W - 18, H - 46);
|
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';
|
ctx.textAlign = 'left';
|
||||||
const dialTxt = ' D' + (G.pressureDial > 0 ? '+' : '') + G.pressureDial;
|
const dialTxt = ' D' + (G.pressureDial > 0 ? '+' : '') + G.pressureDial;
|
||||||
ctx.fillStyle = G.pressure > 1 ? '#d6aa39' : '#69b8bd'; ctx.font = 'bold 8px monospace';
|
ctx.fillStyle = G.pressure > 1 ? '#d6aa39' : '#69b8bd'; ctx.font = 'bold 8px monospace';
|
||||||
|
|||||||
+5
-1
@@ -9,6 +9,10 @@ const Input = {
|
|||||||
anyKey: false,
|
anyKey: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let _rect = null;
|
||||||
|
|
||||||
|
Input.updateRect = function () { _rect = canvas ? canvas.getBoundingClientRect() : null; };
|
||||||
|
|
||||||
function initInput(canvas) {
|
function initInput(canvas) {
|
||||||
window.addEventListener('keydown', (e) => {
|
window.addEventListener('keydown', (e) => {
|
||||||
const k = e.key.toLowerCase();
|
const k = e.key.toLowerCase();
|
||||||
@@ -21,7 +25,7 @@ function initInput(canvas) {
|
|||||||
window.addEventListener('blur', () => { Input.keys = {}; Input.mdown = false; });
|
window.addEventListener('blur', () => { Input.keys = {}; Input.mdown = false; });
|
||||||
|
|
||||||
function setMouse(e) {
|
function setMouse(e) {
|
||||||
const r = canvas.getBoundingClientRect();
|
const r = _rect || (_rect = canvas.getBoundingClientRect());
|
||||||
Input.mx = (e.clientX - r.left) * (W / r.width);
|
Input.mx = (e.clientX - r.left) * (W / r.width);
|
||||||
Input.my = (e.clientY - r.top) * (H / r.height);
|
Input.my = (e.clientY - r.top) * (H / r.height);
|
||||||
}
|
}
|
||||||
|
|||||||
+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; } },
|
orbitalknives: { name: 'Orbital Knives', desc: '+1 circling knife', rarity: 'rare', cap: 3, apply: s => { s.orbitals += 1; } },
|
||||||
volatilebile: { name: 'Volatile Bile', desc: 'Kills explode (bigger per tier)', rarity: 'rare', cap: 3, apply: s => { s.explodeOnKill = (s.explodeOnKill || 0) + 1; } },
|
volatilebile: { name: 'Volatile Bile', desc: 'Kills explode (bigger per tier)', rarity: 'rare', cap: 3, apply: s => { s.explodeOnKill = (s.explodeOnKill || 0) + 1; } },
|
||||||
deadmanswitch: { name: "Dead Man's Switch", desc: 'Kills trigger a fire-rate frenzy', rarity: 'rare', cap: 3, apply: s => { s.frenzy += 0.12; } },
|
deadmanswitch: { name: "Dead Man's Switch", desc: 'Kills trigger a fire-rate frenzy', rarity: 'rare', cap: 3, apply: s => { s.frenzy += 0.12; } },
|
||||||
boneplate: { name: 'Bone Plate', desc: '+½ shield heart each floor', rarity: 'rare', cap: 3, apply: (s, p) => { s.shieldPerk = (s.shieldPerk || 0) + 1; p.shieldHp += 1; } },
|
boneplate: { name: 'Bone Plate', desc: '+½ max shield heart (regens 25% per floor)', rarity: 'rare', cap: 3, apply: (s, p) => { s.shieldPerk = (s.shieldPerk || 0) + 1; p.shieldHp += 1; } },
|
||||||
wormgut: { name: 'Worm Gut', desc: 'Heal ½ heart after combat rooms', rarity: 'rare', cap: 3, apply: s => { s.roomHeal += 1; } },
|
wormgut: { name: 'Worm Gut', desc: 'Heal ½ heart after combat rooms', rarity: 'rare', cap: 3, apply: s => { s.roomHeal += 1; } },
|
||||||
secondstomach: { name: 'Second Stomach', desc: '+½ heart and excess healing shields', rarity: 'rare', cap: 3, apply: (s, p) => { s.maxHp += 1; s.overShield += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } },
|
secondstomach: { name: 'Second Stomach', desc: '+½ heart and excess healing shields', rarity: 'rare', cap: 3, apply: (s, p) => { s.maxHp += 1; s.overShield += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } },
|
||||||
twinhearts: { name: 'Twin Hearts', desc: '+1 max heart, heal to full', rarity: 'rare', cap: 3, apply: (s, p) => { s.maxHp += 2; p.hp = s.maxHp; } },
|
twinhearts: { name: 'Twin Hearts', desc: '+1 max heart, heal to full', rarity: 'rare', cap: 3, apply: (s, p) => { s.maxHp += 2; p.hp = s.maxHp; } },
|
||||||
|
|||||||
+62
-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_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 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 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;
|
const CONFIRM_WINDOW = 3;
|
||||||
|
|
||||||
function pauseBars() {
|
function pauseBars() {
|
||||||
@@ -46,6 +50,7 @@ function init() {
|
|||||||
if (Number.isFinite(sv)) G.sfxVol = clamp(sv, 0, 1);
|
if (Number.isFinite(sv)) G.sfxVol = clamp(sv, 0, 1);
|
||||||
if (Number.isFinite(mv)) G.musicVol = clamp(mv, 0, 1);
|
if (Number.isFinite(mv)) G.musicVol = clamp(mv, 0, 1);
|
||||||
G.autoPerk = localStorage.getItem('meatslicer_autoperk') === '1';
|
G.autoPerk = localStorage.getItem('meatslicer_autoperk') === '1';
|
||||||
|
G.muted = localStorage.getItem('meatslicer_muted') === '1';
|
||||||
const pd = parseFloat(localStorage.getItem('meatslicer_pressure_dial'));
|
const pd = parseFloat(localStorage.getItem('meatslicer_pressure_dial'));
|
||||||
if (Number.isFinite(pd)) G.pressureDial = clamp(Math.round(pd), PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX);
|
if (Number.isFinite(pd)) G.pressureDial = clamp(Math.round(pd), PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX);
|
||||||
const ha = parseFloat(localStorage.getItem('meatslicer_hud_alpha'));
|
const ha = parseFloat(localStorage.getItem('meatslicer_hud_alpha'));
|
||||||
@@ -59,6 +64,14 @@ function setPressureDial(n) {
|
|||||||
try { localStorage.setItem('meatslicer_pressure_dial', String(G.pressureDial)); } catch (e) {}
|
try { localStorage.setItem('meatslicer_pressure_dial', String(G.pressureDial)); } catch (e) {}
|
||||||
Sfx.menu();
|
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) {
|
function setHudAlpha(v) {
|
||||||
G.hudAlpha = clamp(Math.round(v * 100) / 100, 0.25, 1);
|
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;
|
const k = Math.min(window.innerWidth / W, window.innerHeight / H) * 0.97;
|
||||||
canvas.style.width = Math.floor(W * k) + 'px';
|
canvas.style.width = Math.floor(W * k) + 'px';
|
||||||
canvas.style.height = Math.floor(H * k) + 'px';
|
canvas.style.height = Math.floor(H * k) + 'px';
|
||||||
|
if (Input.updateRect) Input.updateRect();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetRun() {
|
function resetRun() {
|
||||||
@@ -193,11 +207,7 @@ function update(dt) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// global keys
|
// global keys
|
||||||
if (keyPressed('m')) {
|
if (keyPressed('m')) toggleMute();
|
||||||
G.muted = !G.muted;
|
|
||||||
Music.setMuted(G.muted);
|
|
||||||
Sfx.setMuted(G.muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (G.mode) {
|
switch (G.mode) {
|
||||||
case 'menu': {
|
case 'menu': {
|
||||||
@@ -236,6 +246,11 @@ function update(dt) {
|
|||||||
updateParticles(dt);
|
updateParticles(dt);
|
||||||
drawToastsUpdate(dt);
|
drawToastsUpdate(dt);
|
||||||
break;
|
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':
|
case 'levelup':
|
||||||
updateLevelup(dt);
|
updateLevelup(dt);
|
||||||
updateParticles(dt);
|
updateParticles(dt);
|
||||||
@@ -264,12 +279,13 @@ function update(dt) {
|
|||||||
const exitClick = Input.mpressed && inRect(Input.mx, Input.my, PAUSE_EXIT_BUTTON);
|
const exitClick = Input.mpressed && inRect(Input.mx, Input.my, PAUSE_EXIT_BUTTON);
|
||||||
if (keyPressed('q') || menuClick) { confirmAction('menu', returnToMenu); break; }
|
if (keyPressed('q') || menuClick) { confirmAction('menu', returnToMenu); break; }
|
||||||
if (canQuitDesktop() && (keyPressed('x') || exitClick)) { confirmAction('desktop', quitToDesktop); 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))) {
|
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;
|
G.pauseHelp = true; G.helpPage = 0; G.confirmAction = null; G.confirmT = 0; Sfx.menu(); break;
|
||||||
}
|
}
|
||||||
if (keyPressed('r')) swapWeapon(true);
|
if (keyPressed('r') || (Input.mpressed && inRect(Input.mx, Input.my, PAUSE_SWAP_BUTTON))) swapWeapon(true);
|
||||||
if (keyPressed('t')) setAutoPerk(!G.autoPerk);
|
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('=', '+')) { 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(); }
|
if (keyPressed(',', '<')) { Music.setVolume(G.musicVol - 0.1); Sfx.menu(); }
|
||||||
@@ -317,6 +333,7 @@ function draw() {
|
|||||||
ctx.fillRect(0, 0, W, H);
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
if (G.mode === 'menu') { drawMenu(ctx); return; }
|
if (G.mode === 'menu') { drawMenu(ctx); return; }
|
||||||
|
if (G.mode === 'bossload') { drawBossLoad(ctx); return; }
|
||||||
|
|
||||||
updateCamera();
|
updateCamera();
|
||||||
|
|
||||||
@@ -367,6 +384,35 @@ function draw() {
|
|||||||
if (G.devMode) { if (G.debugPin && G.mode !== 'debug') drawDebugPin(ctx); }
|
if (G.devMode) { if (G.debugPin && G.mode !== 'debug') drawDebugPin(ctx); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawBossLoad(ctx) {
|
||||||
|
if (G.cur) {
|
||||||
|
drawFloor(ctx, G.cur);
|
||||||
|
drawWalls(ctx, G.cur);
|
||||||
|
}
|
||||||
|
const sheet = G.bossLoadSheet && Sprites.get(G.bossLoadSheet);
|
||||||
|
if (sheet) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
ctx.drawImage(sheet, 0, 0);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
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) {
|
function drawMenu(ctx) {
|
||||||
const t = performance.now() / 1000;
|
const t = performance.now() / 1000;
|
||||||
const bg = ctx.createRadialGradient && ctx.createRadialGradient(W / 2, H * 0.4, 30, W / 2, H * 0.45, 590);
|
const bg = ctx.createRadialGradient && ctx.createRadialGradient(W / 2, H * 0.4, 30, W / 2, H * 0.45, 590);
|
||||||
@@ -540,15 +586,19 @@ function drawPause(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gridX = [W / 2 - 237, W / 2 - 75, W / 2 + 87];
|
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' });
|
const resumeHover = inRect(Input.mx, Input.my, PAUSE_RESUME_BUTTON);
|
||||||
drawPixelTag(ctx, '[R] SWAP WEAPON', gridX[1], 492, { width: 150, height: 28, color: '#bba9a1', accent: '#6e3843' });
|
const swapHover = inRect(Input.mx, Input.my, PAUSE_SWAP_BUTTON);
|
||||||
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 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 menuHover = inRect(Input.mx, Input.my, PAUSE_MENU_BUTTON);
|
||||||
const exitHover = inRect(Input.mx, Input.my, PAUSE_EXIT_BUTTON);
|
const exitHover = inRect(Input.mx, Input.my, PAUSE_EXIT_BUTTON);
|
||||||
const menuConfirm = G.confirmAction === 'menu';
|
const menuConfirm = G.confirmAction === 'menu';
|
||||||
const desktopExit = canQuitDesktop();
|
const desktopExit = canQuitDesktop();
|
||||||
const exitConfirm = desktopExit && G.confirmAction === 'desktop';
|
const exitConfirm = desktopExit && G.confirmAction === 'desktop';
|
||||||
drawPixelTag(ctx, '[M] MUTE AUDIO', gridX[0], 526, { width: 150, height: 28, color: '#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, {
|
drawPixelTag(ctx, menuConfirm ? '[Q] CONFIRM MENU' : '[Q] MAIN MENU', PAUSE_MENU_BUTTON.x, PAUSE_MENU_BUTTON.y, {
|
||||||
width: PAUSE_MENU_BUTTON.w, height: PAUSE_MENU_BUTTON.h,
|
width: PAUSE_MENU_BUTTON.w, height: PAUSE_MENU_BUTTON.h,
|
||||||
color: menuConfirm ? '#ffd36a' : (menuHover ? '#f5e9d6' : '#bba9a1'),
|
color: menuConfirm ? '#ffd36a' : (menuHover ? '#f5e9d6' : '#bba9a1'),
|
||||||
|
|||||||
+37
-1
@@ -104,8 +104,29 @@ function spawnShockwave(x, y, radius, color, alpha, important) {
|
|||||||
}, 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.22, t: 0 }, true);
|
||||||
|
addParticle({ type: 'sweep', x, y, ang, arc: arc * 0.72, r: 12, maxR: reach * 0.82, life: 0.14, t: 0 }, true);
|
||||||
|
spawnShockwave(x, y, reach * 0.55, '#8b1020', 0.45);
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const a = ang - arc / 2 + (i / 9) * arc;
|
||||||
|
const d = reach * rand(0.35, 1);
|
||||||
|
const px = x + Math.cos(a) * d, py = y + Math.sin(a) * d;
|
||||||
|
spawnBlood(px, py, a, 2, i % 3 === 0);
|
||||||
|
if (i % 2 === 0) spawnSpark(px, py, a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function spawnMuzzleFx(x, y, ang, behavior) {
|
function spawnMuzzleFx(x, y, ang, behavior) {
|
||||||
if (behavior === 'slam') return;
|
if (behavior === 'slam' || behavior === 'sweep') return;
|
||||||
const back = ang + Math.PI;
|
const back = ang + Math.PI;
|
||||||
if (behavior === 'flame') {
|
if (behavior === 'flame') {
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
@@ -245,6 +266,15 @@ function drawParticles(ctx) {
|
|||||||
ctx.beginPath(); ctx.ellipse(p.x, p.y, radius, radius * 0.72, 0, 0, TAU); ctx.stroke();
|
ctx.beginPath(); ctx.ellipse(p.x, p.y, radius, radius * 0.72, 0, 0, TAU); ctx.stroke();
|
||||||
ctx.globalAlpha = k * 0.18;
|
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();
|
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') {
|
} else if (p.type === 'beam') {
|
||||||
ctx.globalAlpha = k;
|
ctx.globalAlpha = k;
|
||||||
ctx.strokeStyle = '#d9f6ff'; ctx.lineWidth = 13 * k + 3;
|
ctx.strokeStyle = '#d9f6ff'; ctx.lineWidth = 13 * k + 3;
|
||||||
@@ -254,6 +284,12 @@ function drawParticles(ctx) {
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1.5;
|
ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1.5;
|
||||||
ctx.stroke();
|
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') {
|
} else if (p.type === 'text') {
|
||||||
ctx.globalAlpha = Math.min(1, k * 2);
|
ctx.globalAlpha = Math.min(1, k * 2);
|
||||||
ctx.fillStyle = p.color;
|
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: 'longbone', name: 'Long Bone', desc: '+5% range', apply: s => { s.rangeMul *= 1.05; } },
|
||||||
{ id: 'bigheart', name: 'Big Heart', desc: '+½ max heart, heal ½ heart', apply: (s, p) => { s.maxHp += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } },
|
{ id: 'bigheart', name: 'Big Heart', desc: '+½ max heart, heal ½ heart', apply: (s, p) => { s.maxHp += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } },
|
||||||
{ id: 'magnetb', name: 'Magnet Bile', desc: '+10% pickup radius', apply: s => { s.magnet *= 1.10; } },
|
{ id: 'magnetb', name: 'Magnet Bile', desc: '+10% pickup radius', apply: s => { s.magnet *= 1.10; } },
|
||||||
{ id: 'shieldheart', name: 'Shield Heart', desc: '+½ shield heart at the start of each floor', apply: (s, p) => { s.shieldPerk = (s.shieldPerk || 0) + 1; p.shieldHp = (p.shieldHp || 0) + 1; } },
|
{ id: 'shieldheart', name: 'Shield Heart', desc: '+½ max shield heart (regens 25% per floor)', apply: (s, p) => { s.shieldPerk = (s.shieldPerk || 0) + 1; p.shieldHp = (p.shieldHp || 0) + 1; } },
|
||||||
{ id: 'scavenge', name: 'Scavenger', desc: '+4% drop luck', apply: s => { s.luck += 0.04; } },
|
|
||||||
{ id: 'bloodrush',name: 'Bloodrush', desc: '+5% XP gain, bonus crystal chance', apply: s => { s.xpMul *= 1.05; } },
|
{ id: 'bloodrush',name: 'Bloodrush', desc: '+5% XP gain, bonus crystal chance', apply: s => { s.xpMul *= 1.05; } },
|
||||||
{ id: 'deadeye', name: 'Deadeye', desc: '+5% shot speed', apply: s => { s.shotSpeedMul *= 1.05; } },
|
{ id: 'deadeye', name: 'Deadeye', desc: '+5% shot speed', apply: s => { s.shotSpeedMul *= 1.05; } },
|
||||||
{ id: 'critbone', name: 'Bone Splitter', desc: '+2% critical chance', apply: s => { s.crit += 0.02; } },
|
{ id: 'critbone', name: 'Bone Splitter', desc: '+2% critical chance', apply: s => { s.crit += 0.02; } },
|
||||||
|
|||||||
+3
-4
@@ -53,7 +53,7 @@ function updatePickups(dt) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// gems & small pickups fly to the player inside magnet radius
|
// 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 a = angleTo(k.x, k.y, p.x, p.y);
|
||||||
const sp = lerp(320, 60, d / magR) * p.stats.magnetPull;
|
const sp = lerp(320, 60, d / magR) * p.stats.magnetPull;
|
||||||
k.x += Math.cos(a) * sp * dt; k.y += Math.sin(a) * sp * dt;
|
k.x += Math.cos(a) * sp * dt; k.y += Math.sin(a) * sp * dt;
|
||||||
@@ -73,9 +73,8 @@ function collectPickup(k) {
|
|||||||
Sfx.gem();
|
Sfx.gem();
|
||||||
break;
|
break;
|
||||||
case 'heart': {
|
case 'heart': {
|
||||||
// At full HP hearts stay on the floor for later; Glutton's Gut heals +1
|
// At full HP hearts stay unless Second Stomach has unused overflow-shield room.
|
||||||
// extra per tier below that.
|
if (p.hp >= p.stats.maxHp && !(p.stats.overShield > 0 && p.shieldHp < (p.stats.shieldPerk || 0) + p.stats.overShield)) return false;
|
||||||
if (p.hp >= p.stats.maxHp) return false;
|
|
||||||
const healAmt = 2 + (p.stats.gluttonGut || 0);
|
const healAmt = 2 + (p.stats.gluttonGut || 0);
|
||||||
healPlayer(healAmt); spawnText(p.x, p.y, '+' + healAmt, '#d92038');
|
healPlayer(healAmt); spawnText(p.x, p.y, '+' + healAmt, '#d92038');
|
||||||
Sfx.heart();
|
Sfx.heart();
|
||||||
|
|||||||
+24
-14
@@ -87,11 +87,16 @@ function updatePlayer(dt) {
|
|||||||
const firing = held ? (w.id === 'bonepopper' ? 0.95 : 0.85) : 1;
|
const firing = held ? (w.id === 'bonepopper' ? 0.95 : 0.85) : 1;
|
||||||
if (keyPressed('shift') && p.dashSlowT <= 0) {
|
if (keyPressed('shift') && p.dashSlowT <= 0) {
|
||||||
const dashD = 0.45 * (178 * st.speedMul * firing) * DASH_SLOW_T;
|
const dashD = 0.45 * (178 * st.speedMul * firing) * DASH_SLOW_T;
|
||||||
p.x += (mx / len) * dashD;
|
const sx = p.x, sy = p.y;
|
||||||
p.y += (my / len) * dashD;
|
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;
|
p.dashSlowT = DASH_SLOW_T;
|
||||||
spawnSmoke(p.x, p.y, Math.atan2(-my, -mx), 6, '#9b8d84');
|
spawnDashBurst(sx, sy, Math.atan2(my, mx));
|
||||||
spawnShockwave(p.x, p.y, 26, '#d8c9a8', 0.35);
|
|
||||||
Sfx.duck();
|
Sfx.duck();
|
||||||
}
|
}
|
||||||
const spd = 178 * st.speedMul * firing * (p.dashSlowT > 0 ? 0.5 : 1);
|
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.moveBlend = lerp(p.moveBlend, mx || my ? 1 : 0, clamp(dt * 12, 0, 1));
|
||||||
p.recoil = Math.max(0, p.recoil - dt * 7);
|
p.recoil = Math.max(0, p.recoil - dt * 7);
|
||||||
p.muzzleT = Math.max(0, p.muzzleT - dt);
|
p.muzzleT = Math.max(0, p.muzzleT - dt);
|
||||||
p.x = clamp(p.x, G.arena.x0 + p.r, G.arena.x1 - p.r);
|
const pos = resolveSolids(p.x, p.y, p.r);
|
||||||
p.y = clamp(p.y, G.arena.y0 + p.r, G.arena.y1 - p.r);
|
p.x = pos.x; p.y = pos.y;
|
||||||
|
|
||||||
// aim
|
// aim
|
||||||
p.aim = angleTo(p.x, p.y, mxW(), myW());
|
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 severity = clamp(dmgTaken / Math.max(2, p.stats.maxHp * 0.25), 0.4, 2);
|
||||||
const desperation = 1 + (1 - hpFrac) * 2;
|
const desperation = 1 + (1 - hpFrac) * 2;
|
||||||
const churn = 1 + Math.min(G.recentHits.length, 4) * 0.35;
|
const churn = 1 + Math.min(G.recentHits.length, 4) * 0.35;
|
||||||
return PRESSURE_DROP_BASE * PRESSURE_UNIT * severity * desperation * churn * pressureDropScale();
|
return PRESSURE_DROP_BASE * PRESSURE_UNIT * severity * desperation * churn * pressureDropScale() * PRESSURE_RELIEF_MUL;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hurtPlayer(dmg, ang, attacker) {
|
function hurtPlayer(dmg, ang, attacker) {
|
||||||
@@ -333,8 +338,9 @@ function hurtPlayer(dmg, ang, attacker) {
|
|||||||
// Meat Grinder: aura damages you for +1 per hit taken
|
// Meat Grinder: aura damages you for +1 per hit taken
|
||||||
if (p.stats.meatGrinder > 0) reduced += p.stats.meatGrinder;
|
if (p.stats.meatGrinder > 0) reduced += p.stats.meatGrinder;
|
||||||
// shield hearts absorb first (from the Shield Heart perk)
|
// shield hearts absorb first (from the Shield Heart perk)
|
||||||
|
let absorbed = 0;
|
||||||
if (p.shieldHp > 0) {
|
if (p.shieldHp > 0) {
|
||||||
const absorbed = Math.min(p.shieldHp, reduced);
|
absorbed = Math.min(p.shieldHp, reduced);
|
||||||
p.shieldHp -= absorbed;
|
p.shieldHp -= absorbed;
|
||||||
reduced -= absorbed;
|
reduced -= absorbed;
|
||||||
if (p.shieldHp <= 0) { spawnText(p.x, p.y - 20, 'SHIELD DOWN', '#3bc9e0'); Sfx.shieldBreak(); }
|
if (p.shieldHp <= 0) { spawnText(p.x, p.y - 20, 'SHIELD DOWN', '#3bc9e0'); Sfx.shieldBreak(); }
|
||||||
@@ -352,7 +358,7 @@ function hurtPlayer(dmg, ang, attacker) {
|
|||||||
G.roomDamaged = true;
|
G.roomDamaged = true;
|
||||||
G.recentHits = (G.recentHits || []).filter(t => G.time - t < 12);
|
G.recentHits = (G.recentHits || []).filter(t => G.time - t < 12);
|
||||||
G.recentHits.push(G.time);
|
G.recentHits.push(G.time);
|
||||||
applyPressureDelta(-pressureRelief(reduced));
|
if (absorbed === 0) applyPressureDelta(-pressureRelief(reduced));
|
||||||
G.streak = 0;
|
G.streak = 0;
|
||||||
}
|
}
|
||||||
if (attacker && attacker.hp > 0 && p.stats.thorns > 0) {
|
if (attacker && attacker.hp > 0 && p.stats.thorns > 0) {
|
||||||
@@ -438,18 +444,22 @@ function drawPlayer(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const w = WEAPONS[p.weapon.id];
|
const w = WEAPONS[p.weapon.id];
|
||||||
|
let swingFacing = p.aim;
|
||||||
|
if (w.id === 'cleaver' && p.attackT > 0) {
|
||||||
|
swingFacing = p.aim + (1 - clamp(p.attackT / 0.4, 0, 1)) * TAU * 2.5;
|
||||||
|
}
|
||||||
const kick = p.recoil * 3;
|
const kick = p.recoil * 3;
|
||||||
Sprites.shadow(ctx, p.x, p.y + 17, 27 + p.moveBlend * 2, 9, 0.46);
|
Sprites.shadow(ctx, p.x, p.y + 17, 27 + p.moveBlend * 2, 9, 0.46);
|
||||||
if (p.hp <= 0) {
|
if (p.hp <= 0) {
|
||||||
Sprites.strip(ctx, 'player_death_sheet', p.x, p.y, p.deathT || 0, 12, 14, 128, 144, true);
|
Sprites.strip(ctx, 'player_death_sheet', p.x, p.y, p.deathT || 0, 12, 14, 128, 144, true);
|
||||||
} else {
|
} else {
|
||||||
Sprites.legs(ctx, p.x, p.y + 4, p.bodyFacing, p.step, 124);
|
Sprites.legs(ctx, p.x, p.y + 4, w.id === 'cleaver' && p.attackT > 0 ? swingFacing : p.bodyFacing, p.step, 124);
|
||||||
const torsoOffset = (w.torsoFwd || 18) - kick;
|
const torsoOffset = (w.torsoFwd || 18) - kick;
|
||||||
const torsoX = p.x + Math.cos(p.aim) * torsoOffset;
|
const torsoX = p.x + Math.cos(swingFacing) * torsoOffset;
|
||||||
const torsoY = p.y + Math.sin(p.aim) * torsoOffset;
|
const torsoY = p.y + Math.sin(swingFacing) * torsoOffset;
|
||||||
Sprites.draw(ctx, 'pt_' + p.weapon.id, torsoX, torsoY, p.aim, w.torsoW || 116, p.hitT > 0);
|
Sprites.draw(ctx, 'pt_' + p.weapon.id, torsoX, torsoY, swingFacing, w.torsoW || 116, p.hitT > 0);
|
||||||
}
|
}
|
||||||
if (p.hp > 0 && p.muzzleT > 0 && w.behavior !== 'slam' && w.behavior !== 'saw') {
|
if (p.hp > 0 && p.muzzleT > 0 && w.behavior !== 'slam' && w.behavior !== 'saw' && w.behavior !== 'sweep') {
|
||||||
const muzzleDist = (w.muzzle || 40) - kick;
|
const muzzleDist = (w.muzzle || 40) - kick;
|
||||||
let mx = p.x + Math.cos(p.aim) * muzzleDist, my = p.y + Math.sin(p.aim) * muzzleDist;
|
let mx = p.x + Math.cos(p.aim) * muzzleDist, my = p.y + Math.sin(p.aim) * muzzleDist;
|
||||||
if (w.behavior === 'flame') { const rh = p.aim + Math.PI / 2; mx += Math.cos(rh) * 26; my += Math.sin(rh) * 26; }
|
if (w.behavior === 'flame') { const rh = p.aim + Math.PI / 2; mx += Math.cos(rh) * 26; my += Math.sin(rh) * 26; }
|
||||||
|
|||||||
+118
-25
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
const DIRS = { n: [0, -1], s: [0, 1], e: [1, 0], w: [-1, 0] };
|
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 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 = {
|
const ROOM_SHAPES = {
|
||||||
hall: { x0: 48, y0: 48, x1: 912, y1: 592 }, // unchanged — start & boss rooms
|
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)
|
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)) {
|
for (const [dir, [dx, dy]] of Object.entries(DIRS)) {
|
||||||
r.doors[dir] = !!G.rooms[(r.gx + dx) + ',' + (r.gy + dy)];
|
r.doors[dir] = !!G.rooms[(r.gx + dx) + ',' + (r.gy + dy)];
|
||||||
}
|
}
|
||||||
|
placeRoomObstacles(r);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeRoom(gx, gy, type) {
|
function makeRoom(gx, gy, type) {
|
||||||
return { gx, gy, type, shape: 'hall', theme: 'abattoir', doors: {}, cleared: type === 'start',
|
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 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) {
|
function wavesFor(floor) {
|
||||||
const base = 3 + Math.ceil(floor * 1.4);
|
const base = 3 + Math.ceil(floor * 1.4);
|
||||||
const waves = [base];
|
const waves = [base];
|
||||||
@@ -94,6 +148,8 @@ function enterRoom(gx, gy) {
|
|||||||
G.boss = null;
|
G.boss = null;
|
||||||
G.roomLayer = null;
|
G.roomLayer = null;
|
||||||
G.roomLayerKey = '';
|
G.roomLayerKey = '';
|
||||||
|
G.wallLayer = null;
|
||||||
|
G.wallLayerKey = '';
|
||||||
G.roomDamaged = false;
|
G.roomDamaged = false;
|
||||||
G.roomEnterT = G.time;
|
G.roomEnterT = G.time;
|
||||||
G.entryFresh = false;
|
G.entryFresh = false;
|
||||||
@@ -108,8 +164,9 @@ function enterRoom(gx, gy) {
|
|||||||
// spawn protection: brief invincibility walking into a fresh room
|
// spawn protection: brief invincibility walking into a fresh room
|
||||||
if (G.player) {
|
if (G.player) {
|
||||||
G.player.invT = Math.max(G.player.invT, 1.0);
|
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);
|
const pos = resolveSolids(G.player.x, G.player.y, G.player.r);
|
||||||
G.player.y = clamp(G.player.y, a.y0 + G.player.r, a.y1 - G.player.r);
|
G.player.x = pos.x;
|
||||||
|
G.player.y = pos.y;
|
||||||
}
|
}
|
||||||
// Iron Lung: re-arm the once-per-room hit block
|
// Iron Lung: re-arm the once-per-room hit block
|
||||||
if (G.player && G.player.stats.ironLung > 0) G.player.ironLungReady = true;
|
if (G.player && G.player.stats.ironLung > 0) G.player.ironLungReady = true;
|
||||||
@@ -130,6 +187,10 @@ function enterRoom(gx, gy) {
|
|||||||
// boss fights are a pure ammo drain, so stock the room on the way in
|
// boss fights are a pure ammo drain, so stock the room on the way in
|
||||||
spawnPickup('ammo', a.cx, a.cy + 140);
|
spawnPickup('ammo', a.cx, a.cy + 140);
|
||||||
Music.playBoss(boss.bossKind);
|
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') {
|
} else if (room.type === 'item') {
|
||||||
spawnItemPedestal(a.cx - 45, a.cy, null, 'room');
|
spawnItemPedestal(a.cx - 45, a.cy, null, 'room');
|
||||||
// item rooms sometimes also stock a weapon
|
// item rooms sometimes also stock a weapon
|
||||||
@@ -200,7 +261,7 @@ function updateRoom(dt) {
|
|||||||
const stalled = G.time - G.roomEnterT > 90;
|
const stalled = G.time - G.roomEnterT > 90;
|
||||||
if (lowHealth || stalled) {
|
if (lowHealth || stalled) {
|
||||||
const decay = lowHealth ? 0.02 : 0.008;
|
const decay = lowHealth ? 0.02 : 0.008;
|
||||||
applyPressureDelta(-decay * dt * pressureDropScale());
|
applyPressureDelta(-decay * dt * pressureDropScale() * PRESSURE_RELIEF_MUL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
G.recentHits = (G.recentHits || []).filter(t => G.time - t < 12);
|
G.recentHits = (G.recentHits || []).filter(t => G.time - t < 12);
|
||||||
@@ -302,9 +363,12 @@ function nextFloor() {
|
|||||||
Sfx.stairs();
|
Sfx.stairs();
|
||||||
genFloor(G.floor);
|
genFloor(G.floor);
|
||||||
enterRoom(0, 0);
|
enterRoom(0, 0);
|
||||||
// Shield Heart perk: fresh shield hearts each floor
|
// Shield Heart: regen 25% of max shields per floor, rounded up to the next
|
||||||
G.player.shieldHp = G.player.stats.shieldPerk || 0;
|
// whole shield (1 shield = 2 shieldHp). Full shields only at run start.
|
||||||
if (G.player.shieldHp > 0) { spawnText(G.player.x, G.player.y - 20, 'SHIELD UP', '#3bc9e0'); Sfx.shieldUp(); }
|
const maxShield = G.player.stats.shieldPerk || 0;
|
||||||
|
const shieldBefore = G.player.shieldHp;
|
||||||
|
G.player.shieldHp = Math.min(maxShield, G.player.shieldHp + 2 * Math.ceil(maxShield / 8));
|
||||||
|
if (G.player.shieldHp > shieldBefore) { spawnText(G.player.x, G.player.y - 20, 'SHIELD UP', '#3bc9e0'); Sfx.shieldUp(); }
|
||||||
// legendary once-per-floor flags reset
|
// legendary once-per-floor flags reset
|
||||||
G.player.secondSkinUsed = false;
|
G.player.secondSkinUsed = false;
|
||||||
G.player.lastCutActive = false;
|
G.player.lastCutActive = false;
|
||||||
@@ -415,6 +479,10 @@ function drawRoom(ctx) {
|
|||||||
for (const d of r.decals) {
|
for (const d of r.decals) {
|
||||||
Sprites.draw(ctx, d.img, d.x, d.y, d.rot, 48 * d.s, false, 0.55);
|
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
|
// hazards
|
||||||
for (const h of G.hazards) {
|
for (const h of G.hazards) {
|
||||||
const k = 1 - h.t / h.life;
|
const k = 1 - h.t / h.life;
|
||||||
@@ -435,8 +503,7 @@ function drawRoom(ctx) {
|
|||||||
drawWalls(ctx, r);
|
drawWalls(ctx, r);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawWalls(ctx, r) {
|
function drawWallBands(target, r) {
|
||||||
const locked = roomLocked();
|
|
||||||
const a = roomBounds(r);
|
const a = roomBounds(r);
|
||||||
const theme = ROOM_THEMES[r.theme] || ROOM_THEMES.abattoir;
|
const theme = ROOM_THEMES[r.theme] || ROOM_THEMES.abattoir;
|
||||||
const wallPool = theme.wall;
|
const wallPool = theme.wall;
|
||||||
@@ -444,43 +511,69 @@ function drawWalls(ctx, r) {
|
|||||||
const top = a.y0 - WALL, bottom = a.y1 + WALL;
|
const top = a.y0 - WALL, bottom = a.y1 + WALL;
|
||||||
|
|
||||||
function clipBand(dir) {
|
function clipBand(dir) {
|
||||||
ctx.beginPath();
|
target.beginPath();
|
||||||
if (dir === 'n' || dir === 's') {
|
if (dir === 'n' || dir === 's') {
|
||||||
const y = dir === 'n' ? top : a.y1;
|
const y = dir === 'n' ? top : a.y1;
|
||||||
if (r.doors[dir]) {
|
if (r.doors[dir]) {
|
||||||
ctx.rect(left, y, a.cx - DOOR_HALF - left, WALL);
|
target.rect(left, y, a.cx - DOOR_HALF - left, WALL);
|
||||||
ctx.rect(a.cx + DOOR_HALF, y, right - a.cx - DOOR_HALF, WALL);
|
target.rect(a.cx + DOOR_HALF, y, right - a.cx - DOOR_HALF, WALL);
|
||||||
} else ctx.rect(left, y, right - left, WALL);
|
} else target.rect(left, y, right - left, WALL);
|
||||||
} else {
|
} else {
|
||||||
const x = dir === 'w' ? left : a.x1;
|
const x = dir === 'w' ? left : a.x1;
|
||||||
if (r.doors[dir]) {
|
if (r.doors[dir]) {
|
||||||
ctx.rect(x, top, WALL, a.cy - DOOR_HALF - top);
|
target.rect(x, top, WALL, a.cy - DOOR_HALF - top);
|
||||||
ctx.rect(x, a.cy + DOOR_HALF, WALL, bottom - a.cy - DOOR_HALF);
|
target.rect(x, a.cy + DOOR_HALF, WALL, bottom - a.cy - DOOR_HALF);
|
||||||
} else ctx.rect(x, top, WALL, bottom - top);
|
} else target.rect(x, top, WALL, bottom - top);
|
||||||
}
|
}
|
||||||
ctx.clip();
|
target.clip();
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const dir of ['n', 's']) {
|
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;
|
const y = dir === 'n' ? top + TILE / 2 : a.y1 + TILE / 2;
|
||||||
for (let x = left; x < right; x += TILE) {
|
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);
|
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']) {
|
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;
|
const x = dir === 'w' ? left + TILE / 2 : a.x1 + TILE / 2;
|
||||||
for (let y = top; y < bottom; y += TILE) {
|
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);
|
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)) {
|
for (const [dir, has] of Object.entries(r.doors)) {
|
||||||
if (!has) continue;
|
if (!has) continue;
|
||||||
|
|||||||
+1
-1
@@ -111,7 +111,7 @@ const SfxBank = {
|
|||||||
if (this.buffers.has(name) || this.pending.has(name) || this.failed.has(name)) return;
|
if (this.buffers.has(name) || this.pending.has(name) || this.failed.has(name)) return;
|
||||||
const file = this.FILES[name];
|
const file = this.FILES[name];
|
||||||
if (!file || !this.ctx || typeof fetch !== 'function') return;
|
if (!file || !this.ctx || typeof fetch !== 'function') return;
|
||||||
const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=56')
|
const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=61')
|
||||||
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
|
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
|
||||||
.then(bytes => this.ctx.decodeAudioData(bytes))
|
.then(bytes => this.ctx.decodeAudioData(bytes))
|
||||||
.then(buffer => this.buffers.set(name, this.trim(buffer)))
|
.then(buffer => this.buffers.set(name, this.trim(buffer)))
|
||||||
|
|||||||
+25
-3
@@ -18,7 +18,7 @@ const SPRITE_MANIFEST = [
|
|||||||
'decal_blood1', 'decal_blood2', 'decal_blood3', 'decal_blood4',
|
'decal_blood1', 'decal_blood2', 'decal_blood3', 'decal_blood4',
|
||||||
// level-up perk emblems
|
// level-up perk emblems
|
||||||
'perk_adrenal', 'perk_sharpen', 'perk_quick', 'perk_longbone', 'perk_bigheart',
|
'perk_adrenal', 'perk_sharpen', 'perk_quick', 'perk_longbone', 'perk_bigheart',
|
||||||
'perk_magnetb', 'perk_shieldheart', 'perk_scavenge', 'perk_bloodrush', 'perk_deadeye',
|
'perk_magnetb', 'perk_shieldheart', 'perk_bloodrush', 'perk_deadeye',
|
||||||
'perk_critbone', 'perk_critmeat', 'perk_flensing', 'perk_ember', 'perk_frostbile',
|
'perk_critbone', 'perk_critmeat', 'perk_flensing', 'perk_ember', 'perk_frostbile',
|
||||||
'perk_heavyhand', 'perk_thickhide', 'perk_secondwind', 'perk_scrapfeed', 'perk_boneknit',
|
'perk_heavyhand', 'perk_thickhide', 'perk_secondwind', 'perk_scrapfeed', 'perk_boneknit',
|
||||||
'perk_spiteflesh', 'perk_carrion', 'perk_sinew',
|
'perk_spiteflesh', 'perk_carrion', 'perk_sinew',
|
||||||
@@ -88,15 +88,32 @@ const Sprites = {
|
|||||||
const finish = () => { if (--pending === 0) G.imagesLoaded = true; };
|
const finish = () => { if (--pending === 0) G.imagesLoaded = true; };
|
||||||
img.onload = () => { this.imgs[name] = img; if (typeof img.decode === 'function') img.decode().then(finish, finish); else finish(); };
|
img.onload = () => { this.imgs[name] = img; if (typeof img.decode === 'function') img.decode().then(finish, finish); else finish(); };
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
if (hd && !img._sdRetry) { img._sdRetry = true; img.src = 'assets/' + name + '.png?v=56'; return; }
|
if (hd && !img._sdRetry) { img._sdRetry = true; img.src = 'assets/' + name + '.png?v=61'; return; }
|
||||||
|
this.imgs[name] = null;
|
||||||
finish();
|
finish();
|
||||||
};
|
};
|
||||||
img.src = hd ? 'assets/hd/' + name + '.webp?v=56' : 'assets/' + name + '.png?v=56';
|
img.src = hd ? 'assets/hd/' + name + '.webp?v=61' : 'assets/' + name + '.png?v=61';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
get(name) { return this.imgs[name] || null; },
|
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.
|
// Compact player-only strip: eight forward-facing stride frames.
|
||||||
// Runtime rotation is smooth; distance-driven phase keeps contacts from skating.
|
// Runtime rotation is smooth; distance-driven phase keeps contacts from skating.
|
||||||
legs(ctx, x, y, facing, phase, targetW) {
|
legs(ctx, x, y, facing, phase, targetW) {
|
||||||
@@ -161,6 +178,11 @@ const Sprites = {
|
|||||||
draw(ctx, name, x, y, rot, targetW, flash, alpha, scaleX, scaleY) {
|
draw(ctx, name, x, y, rot, targetW, flash, alpha, scaleX, scaleY) {
|
||||||
const img = this.imgs[name];
|
const img = this.imgs[name];
|
||||||
const tw = targetW || 32;
|
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.save();
|
||||||
ctx.translate(x, y);
|
ctx.translate(x, y);
|
||||||
if (rot) ctx.rotate(rot);
|
if (rot) ctx.rotate(rot);
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ const G = {
|
|||||||
hudAlpha: 1,
|
hudAlpha: 1,
|
||||||
hdRemaster: false,
|
hdRemaster: false,
|
||||||
imagesLoaded: false,
|
imagesLoaded: false,
|
||||||
|
bossLoadT: 0,
|
||||||
|
bossLoadSheet: null,
|
||||||
roomLayer: null,
|
roomLayer: null,
|
||||||
roomLayerKey: '',
|
roomLayerKey: '',
|
||||||
atmosphereLayer: null,
|
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_UNIT = 0.01; // one "point" on the dial scale
|
||||||
const PRESSURE_GAIN = PRESSURE_UNIT; // retained: dial 0 == 1 point
|
const PRESSURE_GAIN = PRESSURE_UNIT; // retained: dial 0 == 1 point
|
||||||
const PRESSURE_DROP_BASE = 3; // dial 0 == 0.03 relief base
|
const PRESSURE_DROP_BASE = 3; // dial 0 == 0.03 relief base
|
||||||
|
const PRESSURE_RELIEF_MUL = 0.5; // global brake: all pressure drops (hit relief + passive decay) are halved
|
||||||
const DEATH_LOCK = 3;
|
const DEATH_LOCK = 3;
|
||||||
|
const BOSS_LOAD_MIN = 1.0;
|
||||||
const PRESSURE_DIAL_MIN = -10, PRESSURE_DIAL_MAX = 10;
|
const PRESSURE_DIAL_MIN = -10, PRESSURE_DIAL_MAX = 10;
|
||||||
|
|
||||||
function hdScale() { return G.hdRemaster ? 4 : 1; }
|
function hdScale() { return G.hdRemaster ? 4 : 1; }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function angleLerp(a, b, t) {
|
|||||||
if (d < -Math.PI) d += TAU;
|
if (d < -Math.PI) d += TAU;
|
||||||
return a + d * t;
|
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)
|
// distance from point to segment (for beam weapons)
|
||||||
function distToSegment(px, py, x1, y1, x2, y2) {
|
function distToSegment(px, py, x1, y1, x2, y2) {
|
||||||
const dx = x2 - x1, dy = y2 - y1;
|
const dx = x2 - x1, dy = y2 - y1;
|
||||||
|
|||||||
+22
-4
@@ -1,6 +1,6 @@
|
|||||||
// ---- The Butcher's Arsenal: 16 horrorcore weapons ----
|
// ---- The Butcher's Arsenal: 16 horrorcore weapons ----
|
||||||
// behavior: bullet | spread | boomerang | bounce | cone | flame | homing |
|
// 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`
|
// Ammo economy: `ammo` is the magazine (trigger-time / burst feel), `refill`
|
||||||
// is what one ammo pickup hands back (~10 kills' worth). Sustained-stream
|
// 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' },
|
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' },
|
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' },
|
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: 42, behavior: 'sweep', sprite: 'bullet_cleaver', sfx: 'heavy', punch: 0.95, sweepRange: 110, sweepArc: 2.85, desc: 'A wider, gory whirl of steel' },
|
||||||
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' },
|
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' },
|
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' },
|
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' },
|
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' },
|
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' },
|
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' },
|
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,24 @@ function fireWeapon(p, w) {
|
|||||||
Sfx.explode({ x: p.x + Math.cos(ang) * 46, y: p.y + Math.sin(ang) * 46 });
|
Sfx.explode({ x: p.x + Math.cos(ang) * 46, y: p.y + Math.sin(ang) * 46 });
|
||||||
break;
|
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' });
|
||||||
|
spawnBlood(e.x, e.y, a, 8, true);
|
||||||
|
spawnGibs(e.x, e.y, 3);
|
||||||
|
addShake(2);
|
||||||
|
}
|
||||||
|
spawnSweepFx(p.x, p.y, ang, reach, w.sweepArc || 2.4);
|
||||||
|
addShake(6);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: { // bullet, bounce and homing projectiles
|
default: { // bullet, bounce and homing projectiles
|
||||||
for (const a of volley) mk(a + rand(-(w.spread || 0), w.spread || 0));
|
for (const a of volley) mk(a + rand(-(w.spread || 0), w.spread || 0));
|
||||||
}
|
}
|
||||||
@@ -200,7 +218,7 @@ function fireBeam(p, def, charge) {
|
|||||||
p.actionT = 0;
|
p.actionT = 0;
|
||||||
addShake(8);
|
addShake(8);
|
||||||
Sfx.shoot(def);
|
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)
|
// damage every enemy in a radius (slams, explosions)
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meatslicer",
|
"name": "meatslicer",
|
||||||
"productName": "MeatSlicer",
|
"productName": "MeatSlicer",
|
||||||
"version": "0.9.0",
|
"version": "0.9.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "A Butcher's Descent - clear, harvest, descend.",
|
"description": "A Butcher's Descent - clear, harvest, descend.",
|
||||||
"main": "desktop/main.js",
|
"main": "desktop/main.js",
|
||||||
|
|||||||
+142
-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
|
// const/let top-level bindings live in the context's lexical scope, not on the
|
||||||
// global object — expose the ones the harness pokes at directly.
|
// global object — expose the ones the harness pokes at directly.
|
||||||
vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, Sfx, SfxBank, W, H, WALL, ITEMS, ACTIVES, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS, PRESSURE_UNIT, PRESSURE_MIN, PRESSURE_MAX, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX, ROOM_SHAPES, ROOM_THEMES, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN, ENTRY_WARN, ENTRY_BUFFER, STUN_UNIT, stunChance, separateEntryWave, defaultPlayerStats, applyPressureDelta, debugRebuildStats, debugRefillAmmo, debugEnabled, DEBUG_PAGES, updateCamera, mxW, myW, angleTo, genFloor, roomBounds, WEAPON_DROP_LOCKOUT, CAM_EDGE_PEEK })', sandbox);
|
vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, Sfx, SfxBank, W, H, WALL, ITEMS, ACTIVES, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS, PRESSURE_UNIT, PRESSURE_MIN, PRESSURE_MAX, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX, PRESSURE_DROP_BASE, PRESSURE_RELIEF_MUL, ROOM_SHAPES, ROOM_THEMES, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN, ENTRY_WARN, ENTRY_BUFFER, STUN_UNIT, stunChance, separateEntryWave, defaultPlayerStats, applyPressureDelta, debugRebuildStats, debugRefillAmmo, debugEnabled, DEBUG_PAGES, updateCamera, mxW, myW, angleTo, genFloor, roomBounds, WEAPON_DROP_LOCKOUT, CAM_EDGE_PEEK, placeRoomObstacles, resolveSolids, spawnDashBurst })', sandbox);
|
||||||
|
|
||||||
let simTime = 0;
|
let simTime = 0;
|
||||||
const ctx = sandbox;
|
const ctx = sandbox;
|
||||||
@@ -84,6 +84,8 @@ function fire(ev, key) {
|
|||||||
function press(key) { fire('keydown', key); }
|
function press(key) { fire('keydown', key); }
|
||||||
function release(key) { fire('keyup', key); }
|
function release(key) { fire('keyup', key); }
|
||||||
function tap(key) { press(key); step(1, 16); release(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;
|
let failures = 0;
|
||||||
function check(name, cond) {
|
function check(name, cond) {
|
||||||
@@ -216,7 +218,7 @@ check('HD loader loads all images (fallbacks)', ctx.G.imagesLoaded === true);
|
|||||||
let hdFallbackOk = 0;
|
let hdFallbackOk = 0;
|
||||||
for (const name of ctx.SPRITE_MANIFEST) {
|
for (const name of ctx.SPRITE_MANIFEST) {
|
||||||
const urls = attemptedUrls[name];
|
const urls = attemptedUrls[name];
|
||||||
const ok = urls && urls.length === 2 && urls[0] === 'assets/hd/' + name + '.webp?v=56' && urls[1] === 'assets/' + name + '.png?v=56';
|
const ok = urls && urls.length === 2 && urls[0] === 'assets/hd/' + name + '.webp?v=61' && urls[1] === 'assets/' + name + '.png?v=61';
|
||||||
if (ok) hdFallbackOk++;
|
if (ok) hdFallbackOk++;
|
||||||
}
|
}
|
||||||
check('HD loader tries WebP then PNG per sprite', hdFallbackOk === ctx.SPRITE_MANIFEST.length);
|
check('HD loader tries WebP then PNG per sprite', hdFallbackOk === ctx.SPRITE_MANIFEST.length);
|
||||||
@@ -241,7 +243,7 @@ check('SD loader loads all images', ctx.G.imagesLoaded === true);
|
|||||||
let sdUrlOk = 0;
|
let sdUrlOk = 0;
|
||||||
for (const name of ctx.SPRITE_MANIFEST) {
|
for (const name of ctx.SPRITE_MANIFEST) {
|
||||||
const urls = attemptedUrls[name];
|
const urls = attemptedUrls[name];
|
||||||
const ok = urls && urls.length === 1 && urls[0] === 'assets/' + name + '.png?v=56';
|
const ok = urls && urls.length === 1 && urls[0] === 'assets/' + name + '.png?v=61';
|
||||||
if (ok) sdUrlOk++;
|
if (ok) sdUrlOk++;
|
||||||
}
|
}
|
||||||
check('SD loader requests one PNG per sprite', sdUrlOk === ctx.SPRITE_MANIFEST.length);
|
check('SD loader requests one PNG per sprite', sdUrlOk === ctx.SPRITE_MANIFEST.length);
|
||||||
@@ -475,7 +477,7 @@ check('player leveled', ctx.G.player.level > 1);
|
|||||||
|
|
||||||
console.log('== rebalanced perks + automatic draft ==');
|
console.log('== rebalanced perks + automatic draft ==');
|
||||||
{
|
{
|
||||||
check('13 new perks added', ctx.PERKS.length === 23);
|
check('perk pool counts 22 perks', ctx.PERKS.length === 22);
|
||||||
const perkStats = { dmgMul: 1 };
|
const perkStats = { dmgMul: 1 };
|
||||||
ctx.PERKS.find(k => k.id === 'sharpen').apply(perkStats, { hp: 1 });
|
ctx.PERKS.find(k => k.id === 'sharpen').apply(perkStats, { hp: 1 });
|
||||||
check('core level-up damage bonus was halved', Math.abs(perkStats.dmgMul - 1.04) < 0.001);
|
check('core level-up damage bonus was halved', Math.abs(perkStats.dmgMul - 1.04) < 0.001);
|
||||||
@@ -661,10 +663,25 @@ console.log('== shield heart perk ==');
|
|||||||
check('unshielded hit hurts', p.hp === hpBefore - 1);
|
check('unshielded hit hurts', p.hp === hpBefore - 1);
|
||||||
p.hp = hpBefore;
|
p.hp = hpBefore;
|
||||||
ctx.nextFloor(); step(3, 16);
|
ctx.nextFloor(); step(3, 16);
|
||||||
check('shield refreshes each floor', p.shieldHp === 2);
|
check('floor regen restores a 1-shield pool fully (25% rounds up)', p.shieldHp === 2);
|
||||||
|
p.stats.shieldPerk = 8; p.shieldHp = 0;
|
||||||
|
ctx.nextFloor(); step(3, 16);
|
||||||
|
check('floor regen grants 25% rounded up (4 shields -> 1 shield)', p.shieldHp === 2);
|
||||||
|
p.stats.shieldPerk = 10; p.shieldHp = 0;
|
||||||
|
ctx.nextFloor(); step(3, 16);
|
||||||
|
check('floor regen at 5 shields grants 2 shields', p.shieldHp === 4);
|
||||||
ctx.startRun(); step(5, 16); // reset floor/state for later sections
|
ctx.startRun(); step(5, 16); // reset floor/state for later sections
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('== mute toggle persists like auto-draft ==');
|
||||||
|
{
|
||||||
|
const before = ctx.G.muted;
|
||||||
|
ctx.toggleMute();
|
||||||
|
check('toggleMute flips and persists mute', ctx.G.muted === !before && stored['meatslicer_muted'] === (ctx.G.muted ? '1' : '0'));
|
||||||
|
ctx.toggleMute();
|
||||||
|
check('toggleMute flips back and persists', ctx.G.muted === before && stored['meatslicer_muted'] === (before ? '1' : '0'));
|
||||||
|
}
|
||||||
|
|
||||||
console.log('== items ==');
|
console.log('== items ==');
|
||||||
ctx.giveItem('hollowpoints');
|
ctx.giveItem('hollowpoints');
|
||||||
ctx.giveItem('orbitalknives');
|
ctx.giveItem('orbitalknives');
|
||||||
@@ -750,6 +767,28 @@ check('Bile Blunderbuss was substantially strengthened', ctx.WEAPONS.bile.dmg >=
|
|||||||
check('Cauterizer was substantially strengthened', ctx.WEAPONS.cauterizer.dmg >= 4.5 && ctx.WEAPONS.cauterizer.range >= 0.36);
|
check('Cauterizer was substantially strengthened', ctx.WEAPONS.cauterizer.dmg >= 4.5 && ctx.WEAPONS.cauterizer.range >= 0.36);
|
||||||
check('melee weapons were substantially strengthened', ctx.WEAPONS.tenderizer.dmg >= 60 && ctx.WEAPONS.redhand.dmg >= 7);
|
check('melee weapons were substantially strengthened', ctx.WEAPONS.tenderizer.dmg >= 60 && ctx.WEAPONS.redhand.dmg >= 7);
|
||||||
|
|
||||||
|
console.log('== cleaver cadence ==');
|
||||||
|
{
|
||||||
|
const c = ctx.WEAPONS.cleaver;
|
||||||
|
check('cleaver magazine unchanged', c.ammo === 26 && c.refill === 10);
|
||||||
|
check('cleaver sweep is larger and hits harder', c.dmg === 42 && c.sweepRange === 110 && c.sweepArc === 2.85);
|
||||||
|
const p = ctx.G.player;
|
||||||
|
p.weapon = { id: 'cleaver', ammo: 26 };
|
||||||
|
p.holstered = null;
|
||||||
|
p.fireT = 0; p.attackT = 0; p.aim = 0;
|
||||||
|
ctx.G.enemies.length = 0; ctx.G.parts.length = 0;
|
||||||
|
const e = ctx.makeEnemy('shambler', p.x + 50, p.y, 1, false);
|
||||||
|
e.spd = 0; e.hp = e.maxHp = 200;
|
||||||
|
ctx.G.enemies.push(e);
|
||||||
|
ctx.Input.mx = e.x; ctx.Input.my = e.y;
|
||||||
|
ctx.Input.mdown = true; step(3, 16); ctx.Input.mdown = false;
|
||||||
|
check('cleaver swing damages in the new reach', e.hp < e.maxHp);
|
||||||
|
check('cleaver swing starts the spin window', p.attackT > 0);
|
||||||
|
check('cleaver swing emits sweep VFX', ctx.G.parts.some(pt => pt.type === 'sweep'));
|
||||||
|
ctx.G.enemies.length = 0; ctx.G.parts.length = 0;
|
||||||
|
p.weapon = { id: 'bonepopper', ammo: Infinity };
|
||||||
|
}
|
||||||
|
|
||||||
console.log('== continuous saw frame-rate independence ==');
|
console.log('== continuous saw frame-rate independence ==');
|
||||||
{
|
{
|
||||||
const p = ctx.G.player;
|
const p = ctx.G.player;
|
||||||
@@ -1069,7 +1108,7 @@ console.log('== per-weapon muzzle spawn distances ==');
|
|||||||
p.aim = 0;
|
p.aim = 0;
|
||||||
const badMuzzles = [];
|
const badMuzzles = [];
|
||||||
for (const w of Object.values(ctx.WEAPONS)) {
|
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.G.bullets.length = 0;
|
||||||
ctx.fireWeapon(p, w);
|
ctx.fireWeapon(p, w);
|
||||||
const b = ctx.G.bullets[0];
|
const b = ctx.G.bullets[0];
|
||||||
@@ -1112,6 +1151,7 @@ console.log('== generic ammo drops ==');
|
|||||||
ctx.WEAPONS.repeater.refill > ctx.WEAPONS.spinaltap.refill * 10);
|
ctx.WEAPONS.repeater.refill > ctx.WEAPONS.spinaltap.refill * 10);
|
||||||
check('stream weapons are time-denominated (drain, not per-shot)',
|
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);
|
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;
|
const repeaterCap = ctx.WEAPONS.repeater.ammo * 1.5;
|
||||||
p.weapon = { id: 'repeater', ammo: repeaterCap - 1 };
|
p.weapon = { id: 'repeater', ammo: repeaterCap - 1 };
|
||||||
@@ -1182,6 +1222,28 @@ console.log('== hearts stay on the floor at full HP ==');
|
|||||||
ctx.startRun(); step(3, 16);
|
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 ==');
|
console.log('== dash: burst, slow, cooldown ==');
|
||||||
{
|
{
|
||||||
ctx.startRun(); step(3, 16);
|
ctx.startRun(); step(3, 16);
|
||||||
@@ -1239,6 +1301,47 @@ console.log('== dash: burst, slow, cooldown ==');
|
|||||||
ctx.startRun(); step(3, 16);
|
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 ==');
|
console.log('== debug refill tops up holstered specials ==');
|
||||||
{
|
{
|
||||||
ctx.startRun(); step(3, 16);
|
ctx.startRun(); step(3, 16);
|
||||||
@@ -1282,6 +1385,13 @@ console.log('== muzzle origins match projectile origins ==');
|
|||||||
const beamParts = ctx.G.parts.filter(pt => pt.type === 'beam');
|
const beamParts = ctx.G.parts.filter(pt => pt.type === 'beam');
|
||||||
const newest = beamParts[beamParts.length - 1];
|
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);
|
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.G.bullets.length = 0;
|
||||||
ctx.startRun(); step(3, 16);
|
ctx.startRun(); step(3, 16);
|
||||||
}
|
}
|
||||||
@@ -1449,12 +1559,24 @@ console.log('== adaptive clean-room pressure ==');
|
|||||||
ctx.hurtPlayer(1, 0); const lowHealthRelief = 1.2 - ctx.G.pressure;
|
ctx.hurtPlayer(1, 0); const lowHealthRelief = 1.2 - ctx.G.pressure;
|
||||||
check('low health dynamically grants more pressure relief', lowHealthRelief > fullHealthRelief);
|
check('low health dynamically grants more pressure relief', lowHealthRelief > fullHealthRelief);
|
||||||
|
|
||||||
|
ctx.G.pressureDial = 0;
|
||||||
|
ctx.G.pressure = 1.2; ctx.G.recentHits = []; p.hp = 10; p.stats.maxHp = 10; p.shieldHp = 0; p.stats.armor = 0; p.invT = 0; ctx.G.mode = 'play';
|
||||||
|
ctx.hurtPlayer(1, 0);
|
||||||
|
// 3 × 0.01 base × 0.4 severity × 1.2 desperation (hp 9/10 after the hit) × 1.35 churn × 1 dial scale × 0.5 mul
|
||||||
|
check('hit relief is halved by the slowdown', Math.abs((1.2 - ctx.G.pressure) - 0.00972) < 1e-9);
|
||||||
|
|
||||||
ctx.G.pressure = 1.2; ctx.G.streak = 4; ctx.G.roomDamaged = false;
|
ctx.G.pressure = 1.2; ctx.G.streak = 4; ctx.G.roomDamaged = false;
|
||||||
p.hp = 10; p.shieldHp = 1; p.invT = 0; ctx.G.mode = 'play';
|
p.hp = 10; p.shieldHp = 1; p.invT = 0; ctx.G.mode = 'play';
|
||||||
ctx.hurtPlayer(1, 0);
|
ctx.hurtPlayer(1, 0);
|
||||||
check('fully shielded hits preserve a clean-room streak', ctx.G.pressure === 1.2 && ctx.G.streak === 4 && !ctx.G.roomDamaged);
|
check('fully shielded hits preserve a clean-room streak', ctx.G.pressure === 1.2 && ctx.G.streak === 4 && !ctx.G.roomDamaged);
|
||||||
p.shieldHp = 0;
|
p.shieldHp = 0;
|
||||||
|
|
||||||
|
ctx.G.pressure = 1.2; ctx.G.streak = 4; ctx.G.roomDamaged = false;
|
||||||
|
p.hp = 10; p.shieldHp = 1; p.invT = 0; ctx.G.mode = 'play';
|
||||||
|
ctx.hurtPlayer(2, 0); // shield absorbs 1, HP takes 1
|
||||||
|
check('partially shielded hits grant no pressure relief', ctx.G.pressure === 1.2 && ctx.G.streak === 0 && ctx.G.roomDamaged);
|
||||||
|
p.shieldHp = 0;
|
||||||
|
|
||||||
ctx.G.pressure = 0.601; ctx.G.recentHits = []; p.hp = 3; p.invT = 0; ctx.G.mode = 'play';
|
ctx.G.pressure = 0.601; ctx.G.recentHits = []; p.hp = 3; p.invT = 0; ctx.G.mode = 'play';
|
||||||
ctx.hurtPlayer(1, 0);
|
ctx.hurtPlayer(1, 0);
|
||||||
check('pressure relief is floored at 60%', ctx.G.pressure === 0.60);
|
check('pressure relief is floored at 60%', ctx.G.pressure === 0.60);
|
||||||
@@ -1520,6 +1642,13 @@ console.log('== 750ms entry-wave spawn telegraph ==');
|
|||||||
check('splitter splits into minis', minis.length === 2);
|
check('splitter splits into minis', minis.length === 2);
|
||||||
check('splits spawn instantly (no telegraph)', minis.every(m => (m.warmT || 0) === 0));
|
check('splits spawn instantly (no telegraph)', minis.every(m => (m.warmT || 0) === 0));
|
||||||
ctx.G.enemies.length = 0;
|
ctx.G.enemies.length = 0;
|
||||||
|
const eliteSplit = ctx.makeEnemy('splitter', ctx.W / 2, ctx.H / 2, 1, true);
|
||||||
|
ctx.G.enemies.push(eliteSplit);
|
||||||
|
ctx.damageEnemy(eliteSplit, 99999, 0, false);
|
||||||
|
const eliteMinis = ctx.G.enemies.filter(en => en.type === 'mini');
|
||||||
|
check('elite splitter splits into minis', eliteSplit.elite === true && eliteMinis.length === 2);
|
||||||
|
check('elite splits spawn instantly', eliteMinis.every(m => (m.warmT || 0) === 0));
|
||||||
|
ctx.G.enemies.length = 0;
|
||||||
ctx.startRun(); step(3, 16);
|
ctx.startRun(); step(3, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1848,7 +1977,12 @@ check('floor 2', ctx.G.floor === 2);
|
|||||||
const bossRoom = Object.values(ctx.G.rooms).find(r => r.type === 'boss');
|
const bossRoom = Object.values(ctx.G.rooms).find(r => r.type === 'boss');
|
||||||
check('boss room exists', !!bossRoom);
|
check('boss room exists', !!bossRoom);
|
||||||
ctx.enterRoom(bossRoom.gx, bossRoom.gy);
|
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 spawned', !!ctx.G.boss);
|
||||||
check('boss hp is finite', ctx.G.boss && isFinite(ctx.G.boss.hp));
|
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'));
|
check('boss music playing', ctx.Music.current && ctx.Music.current.name.includes('Boss'));
|
||||||
@@ -2157,6 +2291,7 @@ ctx.nextFloor(); step(5, 16);
|
|||||||
check('override survives floor change', ctx.Music.current.name === ctx.Music.PLAYLIST[1]);
|
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');
|
const jbr = Object.values(ctx.G.rooms).find(r => r.type === 'boss');
|
||||||
ctx.enterRoom(jbr.gx, jbr.gy); step(5, 16);
|
ctx.enterRoom(jbr.gx, jbr.gy); step(5, 16);
|
||||||
|
drainBossLoad();
|
||||||
check('boss track interrupts override', ctx.Music.current.name.includes('Boss'));
|
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
|
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'));
|
check('boss track not interrupted', ctx.Music.current.name.includes('Boss'));
|
||||||
|
|||||||
+1
-1
@@ -83,7 +83,7 @@ function boot(seed) {
|
|||||||
const AIM_JITTER = {
|
const AIM_JITTER = {
|
||||||
bullet: 0.07, spread: 0.05, bounce: 0.07, boomerang: 0.05, pierce_drag: 0.04,
|
bullet: 0.07, spread: 0.05, bounce: 0.07, boomerang: 0.05, pierce_drag: 0.04,
|
||||||
cone: 0.03, flame: 0.03,
|
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
|
const FLOOR = 3; // mid-game enemy mix
|
||||||
|
|||||||
@@ -390,6 +390,12 @@ def reference_payload(name: str) -> list[dict]:
|
|||||||
if not REFERENCE.exists():
|
if not REFERENCE.exists():
|
||||||
raise SystemExit(f"Missing style reference: {REFERENCE}")
|
raise SystemExit(f"Missing style reference: {REFERENCE}")
|
||||||
references = [{"type": "image_url", "image_url": {"url": data_url(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)
|
guide = shape_guide(name)
|
||||||
if guide:
|
if guide:
|
||||||
references.append({"type": "image_url", "image_url": {"url": data_url(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_trap': return base * 1.3; // direct + trap zone control
|
||||||
case 'lob_swarm': return (w.dmg * 6) / w.interval * 0.85; // 6 homing maggots
|
case 'lob_swarm': return (w.dmg * 6) / w.interval * 0.85; // 6 homing maggots
|
||||||
case 'slam': return base * 1.3; // AoE multi-hit
|
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 '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
|
case 'beam': return (w.dmg * 1.0) / ((w.chargeTime || 0.8) + w.interval) * 1.2; // full-charge cycle, line pierce
|
||||||
default: return base;
|
default: return base;
|
||||||
|
|||||||
Reference in new issue
Block a user