Add pressure dial, wave spawn telegraphs, and readability pass
- Bigger gore-red Minis (r10->14, sprite 32->48) with regenerated art - Wider, walkable doorways: clear passage for open, teeth only when locked - 500ms spawn telegraph so wave spawns no longer blindside the player - Item stickiness ramps 10%/owned item (cap 50%) instead of flat 50% - Title-screen Pressure Dial (-5..+5) tunes rise/relief; score x live pressure - Vitals HUD: two-row hearts, half-container outlines, half-heart shield pips - Readable 8-page field manual (bigger panel + fonts, implants split) - HUD opacity slider in pause menu (boss bar + toasts stay opaque) - Scrap Feed now has diminishing returns like Thick Hide - Docs, cache-bust v37, and smoke coverage for all of the above
No files matched your search
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 435 KiB |
@@ -66,4 +66,4 @@ Runs the smoke test (`test/smoke.js`).
|
||||
|
||||
## The fantasy in one paragraph
|
||||
|
||||
Rooms lock behind you. The meat waves come in threes. Elites gleam gold and drop the good stuff. The Pressure gauge climbs the longer you stay flawless, and every boss — saw, crown, mother, flenser, choir, father, auger, scald — cycles back around, deeper and meaner, until the meat finally wins. Take the meat. Take the stairs.
|
||||
Rooms lock behind you. The meat waves come in threes. Elites gleam gold and drop the good stuff. Set the Pressure Dial before you descend — the gauge climbs the longer you stay flawless, and every point of score rides it. Every boss — saw, crown, mother, flenser, choir, father, auger, scald — cycles back around, deeper and meaner, until the meat finally wins. Take the meat. Take the stairs.
|
||||
@@ -65,11 +65,11 @@ Chaser that **splits into 2 Minis on death** (non-elite Splitters only). Slightl
|
||||
| Base HP | 6 |
|
||||
| Base speed | 120 |
|
||||
| Contact damage | 1 |
|
||||
| Hitbox radius | 10 |
|
||||
| Hitbox radius | 14 |
|
||||
| XP | 1 |
|
||||
| First appears | Only from Splitter deaths and boss summons |
|
||||
|
||||
Tiny, quick chaser with a small hitbox. Never spawns directly from room waves.
|
||||
Small, vicious gore-red chaser. Never spawns directly from room waves. Splitter splits and boss summons appear instantly; room-wave enemies telegraph first (see below).
|
||||
|
||||
### Exploder
|
||||
|
||||
@@ -135,7 +135,11 @@ base speed × (1 + 0.04 × (floor − 1) + 0.01 × power) × (elite ? 0.9 : 1)
|
||||
|
||||
**Contact damage:** flat per type (elites +1) — it **never scales with floor**. What kills you late-game is volume, speed, and HP pools, not bigger hit numbers.
|
||||
|
||||
**Pressure** (see [gameplay.md](gameplay.md#pressure-dynamic-difficulty)) ranges 0.75–1.60 and multiplies both HP and speed. Playing flawlessly pushes it up; getting hit pulls it down.
|
||||
**Pressure** (see [gameplay.md](gameplay.md#pressure-dynamic-difficulty)) ranges 0.75–1.60 and multiplies both HP and speed. Playing flawlessly pushes it up; getting hit pulls it down. The title-screen **Pressure Dial** (−5…+5) tunes how fast it rises per clean room and how much relief each hit grants — and every point of score is multiplied by the live pressure value.
|
||||
|
||||
### Wave spawn telegraph
|
||||
|
||||
When a combat-room wave spawns, each enemy first appears as a **pulsing red sigil** for 0.5 s — frozen, harmless, but targetable. After the telegraph it materializes and becomes dangerous. Splitter splits and boss-summoned minions appear instantly (no telegraph).
|
||||
|
||||
### Example: Floor 10 Shambler
|
||||
|
||||
|
||||
@@ -72,9 +72,11 @@ Your best score is saved in `localStorage` as `meatslicer_best` and shown on the
|
||||
|---|---|
|
||||
| **- / =** | SFX volume down / up |
|
||||
| **, / .** | Music volume down / up |
|
||||
| **; / '** | HUD opacity down / up (25%–100%) |
|
||||
| **[ / ]** or **Left / Right arrows** | Previous / next music track |
|
||||
| **Click arrows** | Cycle music tracks |
|
||||
| **H / [?] button** | Open the Field Manual (7 pages: controls, loop, arsenal, mutations, implants, bestiary, pressure) |
|
||||
| **Drag sliders** | SFX / Music / HUD opacity bars are click-and-drag |
|
||||
| **H / [?] button** | Open the Field Manual (8 pages: controls, loop, arsenal, mutations, implants I, implants II, bestiary, pressure) |
|
||||
|
||||
### Menus
|
||||
|
||||
@@ -106,7 +108,7 @@ Right-click is disabled (context menu suppressed). There is **no dash, no intera
|
||||
|
||||
- Every hit taken deals a **minimum of 1 damage** (`max(1, round(dmg))`).
|
||||
- **Armor** gives a chance to completely ignore a hit: block chance = `armor / (1 + armor)`, capped at 75%. A blocked hit shows "BLOCKED" and grants only 0.2 s of invulnerability.
|
||||
- **Shield hearts** (cyan pips) 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 pip triggers a "SHIELD DOWN" warning.
|
||||
- **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).
|
||||
- Getting hit knocks you back 14 px, shakes the screen, and flashes a red vignette.
|
||||
|
||||
### Healing
|
||||
@@ -207,15 +209,25 @@ Clearing a combat room drops:
|
||||
|
||||
## Pressure (Dynamic Difficulty)
|
||||
|
||||
The **PRESSURE** meter on the HUD is a run-long adaptive difficulty multiplier applied to enemy and boss **HP and speed at spawn time**:
|
||||
The **PRESSURE** meter on the HUD is a run-long adaptive difficulty multiplier applied to enemy and boss **HP and speed at spawn time**. It also **multiplies every point of score you earn** — the higher the pressure, the more each kill, room, and floor is worth.
|
||||
|
||||
- Range: **0.75 – 1.60** (starts at 1.00).
|
||||
- **+0.01** for every room 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.
|
||||
- Decays passively at 0.02/s while below 35% HP, or 0.008/s 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.
|
||||
|
||||
In short: playing flawlessly slowly raises the heat; getting hurt cools things down.
|
||||
### The Pressure Dial
|
||||
|
||||
The title screen has a **PRESSURE DIAL** (−5 … +5) that tunes how pressure responds. It sets two curves — how fast pressure **rises** per clean room, and how much **relief** each hit grants (which also scales the passive decay). The midpoint (0) is the standard tuning; the dial persists between sessions.
|
||||
|
||||
| Dial | Rise / clean room | Relief on hit (base) | Behaviour |
|
||||
|---|---|---|---|
|
||||
| **−5** | 0 | 0.050 (max) | Mercy — pressure can only ever fall; score capped at 1.00× |
|
||||
| **0** | +0.010 | 0.030 | Standard — today's balance |
|
||||
| **+5** | +0.050 | 0 (none) | Ratchet — pressure can only ever rise; score floored at 1.00× |
|
||||
|
||||
Values between the ends interpolate linearly (rise: 0→1→5 units; relief: 5→3→0 units, 1 unit = 0.01). At −5 pressure never rises so your score multiplier stays ≤ 1.00×; at +5 it never falls, so pressure climbs to the 1.60× cap and your score climbs with it. **Cranking the dial up is how you chase the BEST CUT.**
|
||||
|
||||
### Streak
|
||||
|
||||
@@ -239,10 +251,11 @@ Full formulas are in [enemies.md](enemies.md) and [bosses.md](bosses.md).
|
||||
|
||||
## HUD Reference
|
||||
|
||||
- **Top-left — BUTCHER // VITALS:** level, hearts (red) and shield hearts (cyan), 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.
|
||||
- **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, PRESSURE %, STREAK, pressure bar.
|
||||
- **Bottom-right — RUN DATA:** floor, kills, score (with the live pressure multiplier), PRESSURE % (with the dial setting), STREAK, pressure bar.
|
||||
- **HUD opacity** is adjustable from the pause menu (`;` / `'` or the HUD slider, 25%–100%). The boss HP bar and toast notifications always stay fully opaque.
|
||||
- **Bottom-center:** boss HP bar (during boss fights).
|
||||
- **Top-center:** toast notifications (pickups, floor intros, boss events).
|
||||
|
||||
@@ -250,8 +263,8 @@ Full formulas are in [enemies.md](enemies.md) and [bosses.md](bosses.md).
|
||||
|
||||
| Screen | What it offers |
|
||||
|---|---|
|
||||
| **Title** | INITIATE DESCENT, control reference, best score |
|
||||
| **Pause** | Jukebox (track cycling), SFX/music volume, resume/swap/auto/mute hints, Field Manual (`H` / `?` button) |
|
||||
| **Title** | INITIATE DESCENT, PRESSURE DIAL (−5…+5), control reference, best score |
|
||||
| **Pause** | Jukebox (track cycling), SFX/music volume + HUD opacity sliders, resume/swap/auto/mute hints, Field Manual (`H` / `?` button) |
|
||||
| **Mutation Draft** | 3 perk cards, random cut, reroll, auto-draft |
|
||||
| **Game Over ("BUTCHERED")** | Floor, kills, score, NEW BEST CUT tag, restart |
|
||||
|
||||
@@ -265,5 +278,7 @@ Stored in `localStorage` between sessions:
|
||||
- `meatslicer_sfx_volume` (default 0.45)
|
||||
- `meatslicer_music_volume` (default 0.55)
|
||||
- `meatslicer_autoperk` — auto-draft toggle
|
||||
- `meatslicer_pressure_dial` — Pressure Dial setting (−5…+5, default 0)
|
||||
- `meatslicer_hud_alpha` — HUD opacity (default 1.0)
|
||||
|
||||
Nothing else carries over: no unlocks, no meta-currency, no saved runs. Every descent starts fresh with a Bone Popper and 6 HP.
|
||||
@@ -77,7 +77,7 @@ All of these trigger from your weapon hits (orbital knives proc at half chance).
|
||||
| **Iron Stomach** | +½ heart container, heal ½ heart | `maxHp +1`, heal 1 |
|
||||
| **Ghoul Heart** | +2 max HP, heal 2 | `maxHp +2`, heal 2 |
|
||||
| **Twin Hearts** | +1 max heart, heal 1 heart | `maxHp +2`, heal 2 (identical to Ghoul Heart) |
|
||||
| **Bone Plate** | +½ shield heart each floor | `shieldPerk +1`, `shieldHp +1` — shields refill every floor |
|
||||
| **Bone Plate** | +½ shield heart each floor | `shieldPerk +1`, `shieldHp +1` — shields refill every floor (1 shield HP = ½ heart, renders as half a cyan pip) |
|
||||
| **Second Stomach** | +½ heart and excess healing shields | `maxHp +1`, heal 1, `overShield +1` — overflow healing becomes shield HP |
|
||||
| **Tanned Hide** | +8% chance to ignore damage | `armor +0.087` — block chance `armor / (1 + armor)`, 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 |
|
||||
@@ -103,7 +103,7 @@ All of these trigger from your weapon hits (orbital knives proc at half chance).
|
||||
| **Magnet Maw** | +60% pickup radius | `magnet ×1.6` — magnet radius 46 × magnet px |
|
||||
| **Gorging Leech** | +20% XP gained | `xpMul ×1.20` |
|
||||
| **Reroll Rib** | +1 perk reroll each level | `rerollPerLevel +1` and +1 reroll immediately |
|
||||
| **Brass Magazine** | 15% less ammo use, +20% ammo found | `ammoEff ×1.15`, `ammoPickupMul ×1.20` |
|
||||
| **Brass Magazine** | 15% less ammo use, +20% ammo found | `ammoEff ×1.15`, `ammoPickupMul ×1.20` (the ×1.15 stays multiplicative; the **Scrap Feed** perk's additive stacks sit underneath it) |
|
||||
| **Hollow Bones** | +12% move speed | `speedMul ×1.12` (178 → ~199 px/s at tier I) |
|
||||
|
||||
---
|
||||
|
||||
@@ -70,7 +70,7 @@ XP comes from gems dropped by kills, room clears, and bosses, scaled by your `xp
|
||||
| **Carrion Sense** | +8% pickup radius and pull speed | `magnet ×1.08`, `magnetPull ×1.08` *(note: pull speed is not currently wired into pickup movement — the radius half works)* |
|
||||
| **Scavenger** | +4% drop luck | `luck +0.04` |
|
||||
| **Bloodrush** | +5% XP gain, bonus crystal chance | `xpMul ×1.05` |
|
||||
| **Scrap Feed** | 5% less ammo consumed | `ammoEff ×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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,31 +6,31 @@
|
||||
<meta name="theme-color" content="#0a0305">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><text y=%2252%22 x=%2250%22 text-anchor=%22middle%22 dominant-baseline=%22middle%22 font-size=%2252%22>🔪</text></svg>">
|
||||
<title>MEATSLICER</title>
|
||||
<link rel="stylesheet" href="css/style.css?v=36">
|
||||
<link rel="stylesheet" href="css/style.css?v=37">
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrap">
|
||||
<canvas id="game" width="960" height="640"></canvas>
|
||||
</div>
|
||||
<script src="js/utils.js?v=36"></script>
|
||||
<script src="js/state.js?v=36"></script>
|
||||
<script src="js/input.js?v=36"></script>
|
||||
<script src="js/sfx.js?v=36"></script>
|
||||
<script src="js/sfxbank.js?v=36"></script>
|
||||
<script src="js/audio.js?v=36"></script>
|
||||
<script src="js/sprites.js?v=36"></script>
|
||||
<script src="js/particles.js?v=36"></script>
|
||||
<script src="js/weapons.js?v=36"></script>
|
||||
<script src="js/bullets.js?v=36"></script>
|
||||
<script src="js/pickups.js?v=36"></script>
|
||||
<script src="js/items.js?v=36"></script>
|
||||
<script src="js/perks.js?v=36"></script>
|
||||
<script src="js/enemies.js?v=36"></script>
|
||||
<script src="js/bosses.js?v=36"></script>
|
||||
<script src="js/rooms.js?v=36"></script>
|
||||
<script src="js/player.js?v=36"></script>
|
||||
<script src="js/hud.js?v=36"></script>
|
||||
<script src="js/help.js?v=36"></script>
|
||||
<script src="js/main.js?v=36"></script>
|
||||
<script src="js/utils.js?v=37"></script>
|
||||
<script src="js/state.js?v=37"></script>
|
||||
<script src="js/input.js?v=37"></script>
|
||||
<script src="js/sfx.js?v=37"></script>
|
||||
<script src="js/sfxbank.js?v=37"></script>
|
||||
<script src="js/audio.js?v=37"></script>
|
||||
<script src="js/sprites.js?v=37"></script>
|
||||
<script src="js/particles.js?v=37"></script>
|
||||
<script src="js/weapons.js?v=37"></script>
|
||||
<script src="js/bullets.js?v=37"></script>
|
||||
<script src="js/pickups.js?v=37"></script>
|
||||
<script src="js/items.js?v=37"></script>
|
||||
<script src="js/perks.js?v=37"></script>
|
||||
<script src="js/enemies.js?v=37"></script>
|
||||
<script src="js/bosses.js?v=37"></script>
|
||||
<script src="js/rooms.js?v=37"></script>
|
||||
<script src="js/player.js?v=37"></script>
|
||||
<script src="js/hud.js?v=37"></script>
|
||||
<script src="js/help.js?v=37"></script>
|
||||
<script src="js/main.js?v=37"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,10 +5,11 @@ const ENEMY_TYPES = {
|
||||
runner: { hp: 12, spd: 236, dmg: 1, r: 15, drawSize: 64, xp: 1, sprite: 'enemy_runner' },
|
||||
spitter: { hp: 20, spd: 60, dmg: 1, r: 18, drawSize: 64, xp: 2, sprite: 'enemy_spitter', ranged: true },
|
||||
splitter: { hp: 30, spd: 70, dmg: 1, r: 21, drawSize: 64, xp: 1, sprite: 'enemy_splitter', splits: true },
|
||||
mini: { hp: 6, spd: 120, dmg: 1, r: 10, drawSize: 32, xp: 1, sprite: 'enemy_mini' },
|
||||
mini: { hp: 6, spd: 120, dmg: 1, r: 14, drawSize: 48, xp: 1, sprite: 'enemy_mini' },
|
||||
exploder: { hp: 14, spd: 110, dmg: 2, r: 16, drawSize: 64, xp: 2, sprite: 'enemy_exploder', explodes: true },
|
||||
};
|
||||
const MAX_ENEMIES = 72;
|
||||
const SPAWN_WARN = 0.5; // seconds a wave-spawned enemy materializes, frozen + harmless
|
||||
|
||||
function makeEnemy(type, x, y, floor, elite) {
|
||||
const t = ENEMY_TYPES[type];
|
||||
@@ -32,6 +33,7 @@ function makeEnemy(type, x, y, floor, elite) {
|
||||
fireT: rand(1, 2.5), fuse: -1, wob: rand(0, TAU),
|
||||
animSeed: rand(0, TAU), animT: rand(0, 1), attackT: 0, actionT: 0,
|
||||
spawnT: 0, dead: false,
|
||||
warmT: 0,
|
||||
boss: false,
|
||||
};
|
||||
return e;
|
||||
@@ -83,6 +85,7 @@ function spawnWave(count, floor) {
|
||||
const pos = spawnPosAwayFromPlayer();
|
||||
const elite = chance(Math.min(0.03 + floor * 0.02, 0.2));
|
||||
const e = makeEnemy(pickEnemyType(floor), pos.x, pos.y, floor, elite);
|
||||
e.warmT = SPAWN_WARN; // telegraph the incoming spawn so the player isn't blindsided
|
||||
G.enemies.push(e);
|
||||
spawnBlood(pos.x, pos.y, rand(0, TAU), 4);
|
||||
Sfx.spawn(e);
|
||||
@@ -269,7 +272,7 @@ function killEnemy(e, ang) {
|
||||
if (ENEMY_TYPES[e.type].splits && !e.elite) {
|
||||
Sfx.split(e);
|
||||
for (let i = 0; i < 2 && G.enemies.length < MAX_ENEMIES; i++) {
|
||||
const m = makeEnemy('mini', e.x + rand(-12, 12), e.y + rand(-12, 12), G.floor, false);
|
||||
const m = makeEnemy('mini', e.x + rand(-18, 18), e.y + rand(-18, 18), G.floor, false);
|
||||
G.enemies.push(m);
|
||||
}
|
||||
}
|
||||
@@ -297,6 +300,13 @@ function updateEnemies(dt) {
|
||||
e.animT = (e.animT || 0) + dt;
|
||||
if (e.attackT > 0) { e.attackT -= dt; e.actionT += dt; }
|
||||
|
||||
// materializing spawns are frozen and harmless until the telegraph ends
|
||||
if (e.warmT > 0) {
|
||||
e.warmT -= dt;
|
||||
e.vx = 0; e.vy = 0;
|
||||
if (e.warmT > 0) continue;
|
||||
}
|
||||
|
||||
// status effects
|
||||
if (e.flash > 0) e.flash -= dt;
|
||||
if (e.orbT > 0) e.orbT -= dt;
|
||||
@@ -393,6 +403,27 @@ function updateEnemyAI(e, dt) {
|
||||
function drawEnemies(ctx) {
|
||||
for (const e of G.enemies) {
|
||||
if (e.hp <= 0) continue;
|
||||
// spawn telegraph: pulsing sigil + ghost, harmless until warmT expires
|
||||
if (e.warmT > 0) {
|
||||
const k = clamp(1 - e.warmT / SPAWN_WARN, 0, 1);
|
||||
const ringR = e.r + 30 * (1 - k);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.35 + Math.sin(G.time * 26) * 0.2;
|
||||
ctx.strokeStyle = '#ff2a3c'; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(e.x, e.y, ringR, 0, TAU); ctx.stroke();
|
||||
ctx.globalAlpha = 0.22 + k * 0.30;
|
||||
ctx.fillStyle = '#4a0713';
|
||||
ctx.beginPath(); ctx.arc(e.x, e.y, e.r * (0.3 + k * 0.7), 0, TAU); ctx.fill();
|
||||
ctx.restore();
|
||||
Sprites.actor(ctx, e.sprite, e.x, e.y, 0, 'idle', e.animT || 0,
|
||||
e.drawSize || (e.boss ? 128 : 64), 0.18 + k * 0.55, 0.45 + k * 0.55, 0.45 + k * 0.55);
|
||||
if (e.elite) {
|
||||
ctx.strokeStyle = '#ffd060'; ctx.lineWidth = 2; ctx.globalAlpha = 0.7;
|
||||
ctx.beginPath(); ctx.arc(e.x, e.y, e.r + 3, 0, TAU); ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const p = G.player;
|
||||
const face = angleTo(e.x, e.y, p.x, p.y);
|
||||
const speed = Math.min(Math.hypot(e.vx, e.vy) / Math.max(e.spd, 1), 1);
|
||||
|
||||
@@ -9,22 +9,25 @@ const HELP_PAGES = [
|
||||
{ title: 'THE LOOP' },
|
||||
{ title: 'ARSENAL' },
|
||||
{ title: 'MUTATIONS' },
|
||||
{ title: 'IMPLANTS' },
|
||||
{ title: 'IMPLANTS I' },
|
||||
{ title: 'IMPLANTS II' },
|
||||
{ title: 'BESTIARY' },
|
||||
{ title: 'PRESSURE' },
|
||||
];
|
||||
|
||||
const HELP_PANEL = { w: 720, h: 500, y: 66 };
|
||||
const HELP_PANEL = { w: 920, h: 600, y: 22 };
|
||||
const HELP_SCALE = 1.4;
|
||||
function hfont(px, bold) { return (bold ? 'bold ' : '') + Math.round(px * HELP_SCALE) + 'px monospace'; }
|
||||
function helpPanelX() { return (W - HELP_PANEL.w) / 2; }
|
||||
|
||||
function helpTabRect(i) {
|
||||
const tw = 96, gap = 6;
|
||||
const tw = 108, gap = 6;
|
||||
const total = HELP_PAGES.length * tw + (HELP_PAGES.length - 1) * gap;
|
||||
return { x: W / 2 - total / 2 + i * (tw + gap), y: HELP_PANEL.y + 66, w: tw, h: 26 };
|
||||
return { x: W / 2 - total / 2 + i * (tw + gap), y: HELP_PANEL.y + 96, w: tw, h: 32 };
|
||||
}
|
||||
|
||||
function helpCloseRect() {
|
||||
return { x: helpPanelX() + HELP_PANEL.w - 74, y: HELP_PANEL.y + 12, w: 62, h: 26 };
|
||||
return { x: helpPanelX() + HELP_PANEL.w - 92, y: HELP_PANEL.y + 18, w: 62, h: 30 };
|
||||
}
|
||||
|
||||
function inRect(mx, my, r) { return mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h; }
|
||||
@@ -51,28 +54,30 @@ function updatePauseHelp() {
|
||||
}
|
||||
|
||||
// ---- shared text helpers ----
|
||||
// Body text starts at py + 172, clear of the title + tab row.
|
||||
const HELP_BODY = 172;
|
||||
function helpLines(ctx, px, ty, sections, maxW) {
|
||||
ctx.textAlign = 'left';
|
||||
for (const sec of sections) {
|
||||
if (sec.head) {
|
||||
ctx.fillStyle = '#d7a934'; ctx.font = 'bold 10px monospace';
|
||||
ctx.fillStyle = '#d7a934'; ctx.font = hfont(10, true);
|
||||
ctx.fillText(sec.head, px + 40, ty);
|
||||
ty += 21;
|
||||
ty += 24;
|
||||
}
|
||||
ctx.fillStyle = '#b8a89e'; ctx.font = '9px monospace';
|
||||
ctx.fillStyle = '#b8a89e'; ctx.font = hfont(9, false);
|
||||
for (const line of sec.lines) {
|
||||
const wraps = ctx.measureText(line).width > maxW ? 1 : 0;
|
||||
wrapText(ctx, line, px + 40, ty, maxW, 13);
|
||||
ty += 15 + wraps * 13;
|
||||
wrapText(ctx, line, px + 40, ty, maxW, 15);
|
||||
ty += 17 + wraps * 15;
|
||||
}
|
||||
ty += 9;
|
||||
ty += 6;
|
||||
}
|
||||
return ty;
|
||||
}
|
||||
|
||||
// ---- page bodies ----
|
||||
function drawHelpControls(ctx, px, py) {
|
||||
helpLines(ctx, px, py + 116, [
|
||||
helpLines(ctx, px, py + HELP_BODY, [
|
||||
{ head: 'IN THE FIELD', lines: [
|
||||
'WASD / ARROWS — MOVE MOUSE — AIM LMB (HOLD) — FIRE',
|
||||
'LMB RELEASE — FIRE CHARGED BEAM (SPINAL TAP ONLY)',
|
||||
@@ -85,7 +90,7 @@ function drawHelpControls(ctx, px, py) {
|
||||
'R — REROLL ALL THREE (COSTS 1 REROLL TOKEN)',
|
||||
] },
|
||||
{ head: 'PAUSE / JUKEBOX', lines: [
|
||||
'- / = — SFX VOLUME · , / . — MUSIC VOLUME',
|
||||
'- / = — SFX VOLUME · , / . — MUSIC VOLUME · ; / \' — HUD OPACITY',
|
||||
'[ / ] OR ARROWS — CHANGE TRACK (LIVE BOSS FIGHTS OVERRIDE)',
|
||||
'H OR THE [?] BUTTON — THIS MANUAL',
|
||||
] },
|
||||
@@ -97,7 +102,7 @@ function drawHelpControls(ctx, px, py) {
|
||||
}
|
||||
|
||||
function drawHelpLoop(ctx, px, py) {
|
||||
helpLines(ctx, px, py + 116, [
|
||||
helpLines(ctx, px, py + HELP_BODY, [
|
||||
{ head: 'A RUN', lines: [
|
||||
'ROOMS PER FLOOR: MIN(5 + FLOOR, 9). BOSS = FARTHEST ROOM.',
|
||||
'ITEM ROOM = SECOND FARTHEST (DIST ≥ 2): PEDESTAL + 35% WEAPON.',
|
||||
@@ -107,14 +112,14 @@ function drawHelpLoop(ctx, px, py) {
|
||||
] },
|
||||
{ head: 'WAVES PER COMBAT ROOM', lines: [
|
||||
'BASE = 3 + CEIL(FLOOR × 1.4) · F2+: +CEIL(BASE × 0.7) · F4+: +CEIL(BASE × 0.5)',
|
||||
'WAVES SPAWN 0.9S APART · HARD CAP 72 LIVE ENEMIES',
|
||||
'WAVES SPAWN 0.9S APART · INCOMING SPAWNS TELEGRAPH FOR 0.5S · CAP 72',
|
||||
] },
|
||||
{ head: 'DROPS (LUCK SCALES MOST CHANCES)', lines: [
|
||||
'ROOM CLEAR: 3–5+FLOOR XP · 24%+LUCK×30% AMMO · 10%+LUCK×30% HEART',
|
||||
'NORMAL KILL: 7%+LUCK×4% AMMO · 2.2%+LUCK×3% HEART',
|
||||
'ELITE KILL: 3× XP + 3–5 GEMS · 18%+LUCK×10% ITEM · ELSE 60%+LUCK×20% AMMO',
|
||||
] },
|
||||
{ head: 'SCORE', lines: [
|
||||
{ head: 'SCORE (× LIVE PRESSURE)', lines: [
|
||||
'KILL +10 · ELITE +40 · BOSS 500 + FLOOR × 100 · ROOM +50 · FLOOR +250',
|
||||
'XP +1 · FULL-HP HEART +25 · TIER-IX DUPLICATE ITEM +150',
|
||||
] },
|
||||
@@ -123,35 +128,33 @@ function drawHelpLoop(ctx, px, py) {
|
||||
|
||||
function drawHelpArsenal(ctx, px, py) {
|
||||
const x = px + 40;
|
||||
let ty = py + 112;
|
||||
let ty = py + HELP_BODY;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = '#d7a934'; ctx.font = 'bold 9px monospace';
|
||||
ctx.fillStyle = '#d7a934'; ctx.font = hfont(9, true);
|
||||
ctx.fillText('16 WEAPONS · HIGHER TIERS DROP MORE ON DEEPER FLOORS · BONE POPPER = INFINITE', x, ty);
|
||||
ty += 18;
|
||||
const cols = [0, 168, 208, 252, 300, 372, 440];
|
||||
ty += 20;
|
||||
const cols = [0, 235, 291, 353, 420, 521, 616];
|
||||
const head = ['NAME', 'TIER', 'DMG', 'RATE', 'AMMO', 'DPS', 'BEHAVIOR'];
|
||||
ctx.fillStyle = '#7d6a68'; ctx.font = 'bold 8px monospace';
|
||||
ctx.fillStyle = '#7d6a68'; ctx.font = hfont(8, true);
|
||||
for (let c = 0; c < cols.length; c++) ctx.fillText(head[c], x + cols[c], ty);
|
||||
ty += 13;
|
||||
ctx.font = '8px monospace';
|
||||
ty += 16;
|
||||
ctx.font = hfont(8, false);
|
||||
let row = 0;
|
||||
for (const w of Object.values(WEAPONS)) {
|
||||
if (row % 2 === 0) { ctx.fillStyle = 'rgba(255,220,200,0.03)'; ctx.fillRect(x - 4, ty - 9, HELP_PANEL.w - 72, 12); }
|
||||
if (row % 2 === 0) { ctx.fillStyle = 'rgba(255,220,200,0.03)'; ctx.fillRect(x - 4, ty - 11, HELP_PANEL.w - 72, 15); }
|
||||
const dps = Math.round(w.dmg / Math.max(0.01, w.interval) * (w.pellets || 1));
|
||||
const vals = [w.name.toUpperCase(), w.tier < 0 ? 'SIDE' : 'T' + w.tier, String(w.dmg),
|
||||
w.interval.toFixed(2) + 's', w.ammo === Infinity ? '∞' : String(w.ammo), String(dps), w.behavior.toUpperCase()];
|
||||
ctx.fillStyle = '#c9b8ae';
|
||||
for (let c = 0; c < cols.length; c++) ctx.fillText(vals[c], x + cols[c], ty);
|
||||
ty += 12.5;
|
||||
ty += 15;
|
||||
row++;
|
||||
}
|
||||
ty += 8;
|
||||
ty += 10;
|
||||
helpLines(ctx, px, ty, [
|
||||
{ head: 'SHARED WEAPON STATS (FROM IMPLANTS)', lines: [
|
||||
'SPLIT / FAN / REAR ADD VOLLEYS TO ALL BEHAVIORS — BEAMS, SLAMS, SAWS INCLUDED.',
|
||||
'HOMING STEERS EVERY PROJECTILE · RANGE EXTENDS BEAMS, SLAMS, LOBS AND SAW REACH',
|
||||
'PIERCE / BOUNCE BECOME +6% POWER EACH ON MELEE-STYLE WEAPONS (SLAM/SAW/BEAM)',
|
||||
'FIRE RATE SPEEDS THE CHAINSAW TICK AND SPINAL TAP CHARGE · SIZE SCALES ALL HITBOXES',
|
||||
'SPLIT / FAN / REAR ADD VOLLEYS TO ALL BEHAVIORS · HOMING STEERS PROJECTILES · RANGE EXTENDS BEAMS/SLAMS/LOBS',
|
||||
'PIERCE / BOUNCE = +6% POWER ON MELEE (SLAM/SAW/BEAM) · FIRE RATE SPEEDS SAW TICK & TAP CHARGE · SIZE SCALES ALL HITBOXES',
|
||||
'AMMO PICKUPS REFILL HELD + HOLSTERED, CAPPED AT 150% OF ONE MAGAZINE',
|
||||
] },
|
||||
], HELP_PANEL.w - 80);
|
||||
@@ -159,65 +162,71 @@ function drawHelpArsenal(ctx, px, py) {
|
||||
|
||||
function drawHelpPerkList(ctx, px, py) {
|
||||
const x = px + 40;
|
||||
let ty = py + 112;
|
||||
let ty = py + HELP_BODY;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = '#d7a934'; ctx.font = 'bold 9px monospace';
|
||||
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);
|
||||
ty += 8;
|
||||
ctx.fillStyle = '#7d6a68'; ctx.font = '8px monospace';
|
||||
ctx.fillText('REROLL RIB GRANTS TOKENS (R TO REDRAW) · AUTO-DRAFT (T) PICKS RANDOMLY', x, ty + 8);
|
||||
ty += 26;
|
||||
ty += 11;
|
||||
ctx.fillStyle = '#7d6a68'; ctx.font = hfont(8, false);
|
||||
ctx.fillText('REROLL RIB GRANTS TOKENS (R TO REDRAW) · AUTO-DRAFT (T) PICKS RANDOMLY', x, ty + 11);
|
||||
ty += 34;
|
||||
const colW = (HELP_PANEL.w - 80) / 2;
|
||||
for (let i = 0; i < PERKS.length; i++) {
|
||||
const col = Math.floor(i / 12), r = i % 12;
|
||||
const lx = x + col * colW, ly = ty + r * 15;
|
||||
ctx.fillStyle = '#e3b746'; ctx.font = 'bold 8px monospace';
|
||||
const lx = x + col * colW, ly = ty + r * 20;
|
||||
ctx.fillStyle = '#e3b746'; ctx.font = hfont(8, true);
|
||||
ctx.fillText(PERKS[i].name.toUpperCase(), lx, ly);
|
||||
ctx.fillStyle = '#a08d84'; ctx.font = '8px monospace';
|
||||
ctx.fillText(fit(PERKS[i].desc.toUpperCase(), 46), lx, ly + 9);
|
||||
ctx.fillStyle = '#a08d84'; ctx.font = hfont(8, false);
|
||||
ctx.fillText(fit(PERKS[i].desc.toUpperCase(), 40), lx, ly + 12);
|
||||
}
|
||||
}
|
||||
|
||||
function drawHelpItemList(ctx, px, py) {
|
||||
function drawHelpItemList(ctx, px, py, half) {
|
||||
const x = px + 40;
|
||||
let ty = py + 112;
|
||||
let ty = py + HELP_BODY;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = '#d7a934'; ctx.font = 'bold 9px monospace';
|
||||
ctx.fillStyle = '#d7a934'; ctx.font = hfont(9, true);
|
||||
ctx.fillText('46 IMPLANTS · STACK TO TIER IX · DUPLICATE ROLLS FAVORED · TIER IX → +150 SCORE', x, ty);
|
||||
ty += 18;
|
||||
ty += 11;
|
||||
ctx.fillStyle = '#7d6a68'; ctx.font = hfont(8, false);
|
||||
ctx.fillText('STICKINESS RAMPS 10% PER DISTINCT UPGRADABLE ITEM OWNED (CAP 50%) · PAGE ' + (half + 1) + ' OF 2', x, ty + 11);
|
||||
ty += 34;
|
||||
const entries = Object.entries(ITEMS);
|
||||
const halfEntries = entries.slice(half * 23, half * 23 + 23);
|
||||
const colW = (HELP_PANEL.w - 80) / 2;
|
||||
const rows = Math.ceil(entries.length / 2);
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const col = Math.floor(i / rows), r = i % rows;
|
||||
const lx = x + col * colW, ly = ty + r * 12.6;
|
||||
ctx.fillStyle = '#e3b746'; ctx.font = 'bold 7px monospace';
|
||||
ctx.fillText(entries[i][1].name.toUpperCase(), lx, ly);
|
||||
ctx.fillStyle = '#a08d84'; ctx.font = '7px monospace';
|
||||
ctx.fillText(fit(entries[i][1].desc.toUpperCase(), 52), lx + 118, ly);
|
||||
for (let i = 0; i < halfEntries.length; i++) {
|
||||
const col = Math.floor(i / 12), r = i % 12;
|
||||
const lx = x + col * colW, ly = ty + r * 20;
|
||||
ctx.fillStyle = '#e3b746'; ctx.font = hfont(8, true);
|
||||
ctx.fillText(halfEntries[i][1].name.toUpperCase(), lx, ly);
|
||||
ctx.fillStyle = '#a08d84'; ctx.font = hfont(8, false);
|
||||
ctx.fillText(fit(halfEntries[i][1].desc.toUpperCase(), 40), lx, ly + 12);
|
||||
}
|
||||
}
|
||||
|
||||
function drawHelpItemList1(ctx, px, py) { drawHelpItemList(ctx, px, py, 0); }
|
||||
function drawHelpItemList2(ctx, px, py) { drawHelpItemList(ctx, px, py, 1); }
|
||||
|
||||
function drawHelpBestiary(ctx, px, py) {
|
||||
const x = px + 40;
|
||||
let ty = py + 112;
|
||||
let ty = py + HELP_BODY;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = '#d7a934'; ctx.font = 'bold 9px monospace';
|
||||
ctx.fillStyle = '#d7a934'; ctx.font = hfont(9, true);
|
||||
ctx.fillText('6 ENEMY TYPES · ELITES: ×2.6 HP, +1 DMG, 3× XP, GOLD RING, ITEM DROPS', x, ty);
|
||||
ty += 16;
|
||||
ty += 20;
|
||||
const notes = { shambler: 'CHASER', runner: 'FAST CHASER', spitter: 'RANGED KITER', splitter: 'SPLITS INTO 2 MINIS', mini: 'SWARMER', exploder: 'FUSED SUICIDE ×2 DMG' };
|
||||
ctx.font = '8px monospace';
|
||||
ctx.font = hfont(8, false);
|
||||
let row = 0;
|
||||
for (const [type, t] of Object.entries(ENEMY_TYPES)) {
|
||||
if (row % 2 === 0) { ctx.fillStyle = 'rgba(255,220,200,0.03)'; ctx.fillRect(x - 4, ty - 9, HELP_PANEL.w - 72, 12); }
|
||||
if (row % 2 === 0) { ctx.fillStyle = 'rgba(255,220,200,0.03)'; ctx.fillRect(x - 4, ty - 11, HELP_PANEL.w - 72, 15); }
|
||||
ctx.fillStyle = '#c9b8ae';
|
||||
ctx.fillText(type.toUpperCase(), x, ty);
|
||||
ctx.fillText('HP ' + t.hp, x + 100, ty);
|
||||
ctx.fillText('SPD ' + t.spd, x + 150, ty);
|
||||
ctx.fillText('DMG ' + t.dmg, x + 210, ty);
|
||||
ctx.fillText('XP ' + t.xp, x + 260, ty);
|
||||
ctx.fillText(notes[type] || '', x + 300, ty);
|
||||
ty += 12.5;
|
||||
ctx.fillText('HP ' + t.hp, x + 140, ty);
|
||||
ctx.fillText('SPD ' + t.spd, x + 210, ty);
|
||||
ctx.fillText('DMG ' + t.dmg, x + 294, ty);
|
||||
ctx.fillText('XP ' + t.xp, x + 364, ty);
|
||||
ctx.fillText(notes[type] || '', x + 420, ty);
|
||||
ty += 15;
|
||||
row++;
|
||||
}
|
||||
ty += 8;
|
||||
@@ -229,27 +238,31 @@ function drawHelpBestiary(ctx, px, py) {
|
||||
'CONTACT DAMAGE NEVER SCALES — ONLY ELITES GET +1',
|
||||
] },
|
||||
], HELP_PANEL.w - 80);
|
||||
ctx.fillStyle = '#d7a934'; ctx.font = 'bold 9px monospace';
|
||||
ctx.fillStyle = '#d7a934'; ctx.font = hfont(9, true);
|
||||
ctx.fillText('9 BOSSES — ROTATION = (FLOOR − 1) MOD 9 · HP × (1 + 0.30(FLOOR−1)) · CYCLES ADD PATTERNS', x, ty);
|
||||
ty += 16;
|
||||
ctx.font = '8px monospace';
|
||||
ty += 18;
|
||||
ctx.font = hfont(8, false);
|
||||
for (let i = 0; i < BOSS_DEFS.length; i++) {
|
||||
const col = i % 3, r = Math.floor(i / 3);
|
||||
ctx.fillStyle = '#c9b8ae';
|
||||
ctx.fillText(BOSS_DEFS[i].name + ' · ' + BOSS_DEFS[i].hp + ' HP', x + col * 210, ty + r * 13);
|
||||
ctx.fillText(BOSS_DEFS[i].name + ' · ' + BOSS_DEFS[i].hp + ' HP', x + col * 294, ty + r * 16);
|
||||
}
|
||||
}
|
||||
|
||||
function drawHelpPressure(ctx, px, py) {
|
||||
helpLines(ctx, px, py + 116, [
|
||||
const d = G.pressureDial;
|
||||
const gain = pressureGainUnits(d), drop = pressureDropUnits(d), scale = pressureDropScale();
|
||||
helpLines(ctx, px, py + HELP_BODY, [
|
||||
{ head: 'ADAPTIVE PRESSURE (HUD, BOTTOM-RIGHT)', lines: [
|
||||
'MULTIPLIES ENEMY AND BOSS HP + SPEED AT SPAWN TIME · RANGE 0.75× – 1.60×',
|
||||
'+0.01 FOR EVERY ROOM CLEARED WITHOUT TAKING ANY DAMAGE',
|
||||
'RELIEF ON HIT = 0.03 × SEVERITY × DESPERATION × CHURN',
|
||||
'ALL SCORE IS MULTIPLIED BY LIVE PRESSURE',
|
||||
'DIAL ' + (d > 0 ? '+' : '') + d + ' — RISE ' + gain.toFixed(1) + '/5 PER CLEAN ROOM · RELIEF ' + drop.toFixed(1) + '/5 ON HIT',
|
||||
'+' + pressureGain().toFixed(3) + ' FOR EVERY ROOM CLEARED WITHOUT TAKING ANY DAMAGE',
|
||||
'RELIEF ON HIT = ' + (0.03 * scale).toFixed(3) + ' × SEVERITY × DESPERATION × CHURN',
|
||||
' SEVERITY = HIT / (MAX HP × 0.25), CLAMPED 0.4 – 2',
|
||||
' DESPERATION = 1 + 2 × (1 − HP FRACTION) — MORE RELIEF WHEN HURT',
|
||||
' CHURN = 1 + 0.35 × HITS IN LAST 12S (CAP +4) — MORE WHEN SWARMED',
|
||||
'DECAY: 0.02/S BELOW 35% HP · 0.008/S AFTER 90S IN ONE ROOM',
|
||||
'DECAY: ' + (0.02 * scale).toFixed(3) + '/S BELOW 35% HP · ' + (0.008 * scale).toFixed(3) + '/S AFTER 90S IN ONE ROOM',
|
||||
'LIVING ENEMIES KEEP THEIR STATS — ONLY NEW SPAWNS CHANGE',
|
||||
'STREAK = CONSECUTIVE FLAWLESS CLEARS (COSMETIC)',
|
||||
] },
|
||||
@@ -263,7 +276,7 @@ function drawHelpPressure(ctx, px, py) {
|
||||
], HELP_PANEL.w - 80);
|
||||
}
|
||||
|
||||
const HELP_RENDERERS = [drawHelpControls, drawHelpLoop, drawHelpArsenal, drawHelpPerkList, drawHelpItemList, drawHelpBestiary, drawHelpPressure];
|
||||
const HELP_RENDERERS = [drawHelpControls, drawHelpLoop, drawHelpArsenal, drawHelpPerkList, drawHelpItemList1, drawHelpItemList2, drawHelpBestiary, drawHelpPressure];
|
||||
|
||||
function drawPauseHelp(ctx) {
|
||||
ctx.fillStyle = 'rgba(4,2,3,0.84)';
|
||||
@@ -274,10 +287,10 @@ function drawPauseHelp(ctx) {
|
||||
blood: true, seed: G.helpPage + 21, bloodAlpha: 0.2,
|
||||
});
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = '#8eb7bd'; ctx.font = 'bold 9px monospace';
|
||||
ctx.fillText('BUTCHER FIELD MANUAL — PAGE ' + (G.helpPage + 1) + ' / ' + HELP_PAGES.length, W / 2, py + 30);
|
||||
ctx.fillStyle = '#f0e5d7'; ctx.font = 'bold 22px monospace';
|
||||
ctx.fillText(HELP_PAGES[G.helpPage].title, W / 2, py + 56);
|
||||
ctx.fillStyle = '#8eb7bd'; ctx.font = hfont(9, true);
|
||||
ctx.fillText('BUTCHER FIELD MANUAL — PAGE ' + (G.helpPage + 1) + ' / ' + HELP_PAGES.length, W / 2, py + 42);
|
||||
ctx.fillStyle = '#f0e5d7'; ctx.font = hfont(22, true);
|
||||
ctx.fillText(HELP_PAGES[G.helpPage].title, W / 2, py + 78);
|
||||
|
||||
const cr = helpCloseRect();
|
||||
drawPixelTag(ctx, '[X]', cr.x, cr.y, { width: cr.w, height: cr.h, color: '#e8bf47', accent: '#9d7726' });
|
||||
@@ -290,18 +303,31 @@ function drawPauseHelp(ctx) {
|
||||
color: active ? '#f1e4d5' : (hover ? '#cdbdb2' : '#7d6a68'),
|
||||
accent: active ? '#4a9cad' : '#3c2f38',
|
||||
fill: active ? 'rgba(24,15,22,0.95)' : 'rgba(12,7,11,0.85)',
|
||||
font: 'bold 8px monospace',
|
||||
font: hfont(8, true),
|
||||
});
|
||||
}
|
||||
|
||||
HELP_RENDERERS[G.helpPage](ctx, px, py);
|
||||
|
||||
ctx.textAlign = 'center';
|
||||
const dotTotal = HELP_PAGES.length * 16 - 8;
|
||||
for (let i = 0; i < HELP_PAGES.length; i++) {
|
||||
ctx.fillStyle = i === G.helpPage ? '#d7a934' : '#4d3f44';
|
||||
ctx.fillRect(W / 2 - 52 + i * 16, py + ph - 42, 8, 3);
|
||||
ctx.fillRect(W / 2 - dotTotal / 2 + i * 16, py + ph - 42, 8, 3);
|
||||
}
|
||||
drawPixelTag(ctx, '[H / ESC] BACK [← →] PAGES [1-' + HELP_PAGES.length + '] JUMP', W / 2 - 150, py + ph - 30, {
|
||||
width: 300, height: 22, color: '#8f7f79', accent: '#4d3a42', font: 'bold 8px monospace',
|
||||
drawPixelTag(ctx, '[H / ESC] BACK [← →] PAGES [1-' + HELP_PAGES.length + '] JUMP', W / 2 - 180, py + ph - 30, {
|
||||
width: 360, height: 22, color: '#8f7f79', accent: '#4d3a42', font: hfont(8, true),
|
||||
});
|
||||
}
|
||||
|
||||
function wrapText(ctx, text, x, y, maxW, lh) {
|
||||
const words = text.split(' ');
|
||||
let line = '', yy = y;
|
||||
for (const w of words) {
|
||||
const test = line ? line + ' ' + w : w;
|
||||
if (ctx.measureText(test).width > maxW && line) {
|
||||
ctx.fillText(line, x, yy); yy += lh; line = w;
|
||||
} else line = test;
|
||||
}
|
||||
ctx.fillText(line, x, yy);
|
||||
}
|
||||
@@ -3,7 +3,12 @@
|
||||
function drawHUD(ctx) {
|
||||
const p = G.player;
|
||||
ctx.save();
|
||||
const panelH = p.holstered ? 140 : 112;
|
||||
const A = clamp(G.hudAlpha === undefined ? 1 : G.hudAlpha, 0.25, 1);
|
||||
ctx.globalAlpha = A;
|
||||
// vitals use a 2-row grid; a second row pushes the rest of the panel down
|
||||
const totalVitalIcons = Math.ceil(p.stats.maxHp / 2) + Math.ceil(p.shieldHp / 2);
|
||||
const vOff = totalVitalIcons > 12 ? 22 : 0;
|
||||
const panelH = (p.holstered ? 140 : 112) + vOff;
|
||||
drawPixelPanel(ctx, 8, 8, 304, panelH, {
|
||||
accent: '#cf2942', border: 'rgba(115,43,56,0.95)', blood: true,
|
||||
seed: G.floor, bloodAlpha: 0.34,
|
||||
@@ -14,14 +19,17 @@ function drawHUD(ctx) {
|
||||
ctx.fillStyle = '#d7a934'; ctx.textAlign = 'right';
|
||||
ctx.fillText('LV ' + String(p.level).padStart(2, '0'), 300, 23);
|
||||
|
||||
// Hearts retain their illustrative icons on a cleaner fixed baseline.
|
||||
const hearts = Math.ceil(p.stats.maxHp / 2);
|
||||
const maxVitalIcons = 11;
|
||||
const shownHearts = Math.min(hearts, maxVitalIcons);
|
||||
for (let i = 0; i < shownHearts; i++) {
|
||||
const x = 21 + i * 22, y = 39;
|
||||
// hearts: full containers = 2 HP, trailing odd maxHp draws as a half outline
|
||||
const fullHearts = Math.floor(p.stats.maxHp / 2);
|
||||
const halfContainer = p.stats.maxHp % 2 === 1;
|
||||
const maxVitalIcons = 24; // 2 rows x 12
|
||||
const shownFull = Math.min(fullHearts, maxVitalIcons);
|
||||
let slot = 0;
|
||||
for (let i = 0; i < shownFull; i++) {
|
||||
const x = 21 + (i % 12) * 22, y = 39 + Math.floor(i / 12) * 22;
|
||||
slot = i + 1;
|
||||
const fill = clamp(p.hp - i * 2, 0, 2) / 2;
|
||||
Sprites.draw(ctx, 'heart', x, y, 0, 18, false, 0.25);
|
||||
Sprites.draw(ctx, 'heart', x, y, 0, 18, false, 0.25 * A);
|
||||
if (fill > 0) {
|
||||
ctx.save();
|
||||
ctx.beginPath(); ctx.rect(x - 10, y - 10, 20 * fill, 20); ctx.clip();
|
||||
@@ -29,35 +37,63 @@ function drawHUD(ctx) {
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
const shownShields = Math.min(p.shieldHp, Math.max(0, maxVitalIcons - shownHearts));
|
||||
for (let i = 0; i < shownShields; i++) {
|
||||
const x = 21 + (shownHearts + i) * 22, y = 39;
|
||||
// half container outline (only when there's icon budget left)
|
||||
if (halfContainer && slot < maxVitalIcons) {
|
||||
const x = 21 + (slot % 12) * 22, y = 39 + Math.floor(slot / 12) * 22;
|
||||
ctx.save();
|
||||
ctx.beginPath(); ctx.rect(x - 10, y - 10, 10, 20); ctx.clip();
|
||||
Sprites.draw(ctx, 'heart', x, y, 0, 18, false, 0.25 * A);
|
||||
ctx.restore();
|
||||
const fill = clamp(p.hp - slot * 2, 0, 1);
|
||||
if (fill > 0) {
|
||||
ctx.save();
|
||||
ctx.beginPath(); ctx.rect(x - 10, y - 10, 10, 20); ctx.clip();
|
||||
Sprites.draw(ctx, 'heart', x, y, 0, 18);
|
||||
ctx.restore();
|
||||
}
|
||||
slot++;
|
||||
}
|
||||
// shield pips: 2 shieldHp = one full cyan heart, odd = half (matches hearts)
|
||||
const shieldFull = Math.floor(p.shieldHp / 2), shieldHalf = p.shieldHp % 2 === 1;
|
||||
const shieldIcons = shieldFull + (shieldHalf ? 1 : 0);
|
||||
const shownShieldIcons = Math.min(shieldIcons, Math.max(0, maxVitalIcons - slot));
|
||||
for (let i = 0; i < shownShieldIcons; i++) {
|
||||
const x = 21 + ((slot + i) % 12) * 22, y = 39 + Math.floor((slot + i) / 12) * 22;
|
||||
const isHalf = shieldHalf && i === shownShieldIcons - 1;
|
||||
ctx.fillStyle = '#3bc9e0';
|
||||
ctx.beginPath(); ctx.arc(x - 3, y - 2, 4, 0, TAU); ctx.arc(x + 3, y - 2, 4, 0, TAU); ctx.fill();
|
||||
ctx.beginPath(); ctx.moveTo(x - 6.5, y); ctx.lineTo(x, y + 7); ctx.lineTo(x + 6.5, y); ctx.fill();
|
||||
if (isHalf) {
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
ctx.beginPath(); ctx.arc(x + 3, y - 2, 4.5, 0, TAU); ctx.fill();
|
||||
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + 7, y); ctx.lineTo(x, y + 8); ctx.closePath(); ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
const hiddenVitals = hearts + p.shieldHp - shownHearts - shownShields;
|
||||
slot += shownShieldIcons;
|
||||
const hiddenVitals = Math.ceil(p.stats.maxHp / 2) + shieldIcons - slot;
|
||||
if (hiddenVitals > 0) {
|
||||
drawPixelTag(ctx, '+' + hiddenVitals, 266, 29, { width: 34, height: 21, color: '#d7b24a', accent: '#8f6724' });
|
||||
}
|
||||
|
||||
const need = xpForLevel(p.level);
|
||||
drawPixelBar(ctx, 16, 53, 220, 10, p.xp / need, {
|
||||
drawPixelBar(ctx, 16, 53 + vOff, 220, 10, p.xp / need, {
|
||||
fill: '#3d9fc2', glint: '#a5edee', border: '#465d69', segments: 12,
|
||||
});
|
||||
ctx.fillStyle = '#88b9c4'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'right';
|
||||
ctx.fillText(Math.floor(p.xp) + ' / ' + need + ' XP', 299, 62);
|
||||
ctx.fillText(Math.floor(p.xp) + ' / ' + need + ' XP', 299, 62 + vOff);
|
||||
|
||||
const w = WEAPONS[p.weapon.id];
|
||||
ctx.fillStyle = 'rgba(3,2,3,0.6)'; ctx.fillRect(15, 70, 48, 34);
|
||||
ctx.strokeStyle = 'rgba(116,56,65,0.65)'; ctx.strokeRect(15.5, 70.5, 47, 33);
|
||||
Sprites.draw(ctx, 'w_' + w.id, 39, 87, 0, 54);
|
||||
ctx.fillStyle = 'rgba(3,2,3,0.6)'; ctx.fillRect(15, 70 + vOff, 48, 34);
|
||||
ctx.strokeStyle = 'rgba(116,56,65,0.65)'; ctx.strokeRect(15.5, 70.5 + vOff, 47, 33);
|
||||
Sprites.draw(ctx, 'w_' + w.id, 39, 87 + vOff, 0, 54);
|
||||
ctx.textAlign = 'left'; ctx.fillStyle = '#f0e5d7'; ctx.font = 'bold 12px monospace';
|
||||
ctx.fillText(w.name.toUpperCase(), 72, 84);
|
||||
ctx.fillText(w.name.toUpperCase(), 72, 84 + vOff);
|
||||
ctx.fillStyle = '#7d6262'; ctx.font = '9px monospace';
|
||||
ctx.fillText('ACTIVE WEAPON', 72, 99);
|
||||
ctx.fillText('ACTIVE WEAPON', 72, 99 + vOff);
|
||||
const ammoText = p.weapon.ammo === Infinity ? 'SIDEARM' : String(Math.ceil(p.weapon.ammo)).padStart(3, '0');
|
||||
drawPixelTag(ctx, ammoText, 238, 74, {
|
||||
drawPixelTag(ctx, ammoText, 238, 74 + vOff, {
|
||||
width: 58, height: 26,
|
||||
color: p.weapon.ammo === Infinity ? '#9c8d86' : (p.weapon.ammo < w.ammo * 0.25 ? '#ff4057' : '#e0b94e'),
|
||||
accent: p.weapon.ammo === Infinity ? '#5d4c50' : '#a47e25', font: 'bold 10px monospace',
|
||||
@@ -65,12 +101,12 @@ function drawHUD(ctx) {
|
||||
|
||||
if (p.holstered) {
|
||||
const holsterEmpty = p.holstered.ammo <= 0;
|
||||
ctx.fillStyle = 'rgba(86,40,48,0.35)'; ctx.fillRect(15, 112, 281, 20);
|
||||
Sprites.draw(ctx, 'w_' + p.holstered.id, 29, 122, 0, 28, false, 0.68);
|
||||
ctx.fillStyle = 'rgba(86,40,48,0.35)'; ctx.fillRect(15, 112 + vOff, 281, 20);
|
||||
Sprites.draw(ctx, 'w_' + p.holstered.id, 29, 122 + vOff, 0, 28, false, 0.68 * A);
|
||||
ctx.fillStyle = holsterEmpty ? '#d94652' : '#aa9290'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'left';
|
||||
ctx.fillText((holsterEmpty ? '[EMPTY] ' : '[R] ') + WEAPONS[p.holstered.id].name.toUpperCase(), 47, 125);
|
||||
ctx.fillText((holsterEmpty ? '[EMPTY] ' : '[R] ') + WEAPONS[p.holstered.id].name.toUpperCase(), 47, 125 + vOff);
|
||||
ctx.fillStyle = holsterEmpty ? '#ef4057' : '#d0aa4a'; ctx.textAlign = 'right';
|
||||
ctx.fillText(holsterEmpty ? 'NEEDS AMMO' : Math.ceil(p.holstered.ammo) + ' AMMO', 289, 125);
|
||||
ctx.fillText(holsterEmpty ? 'NEEDS AMMO' : Math.ceil(p.holstered.ammo) + ' AMMO', 289, 125 + vOff);
|
||||
}
|
||||
|
||||
const allItemEntries = Object.entries(p.items);
|
||||
@@ -88,7 +124,7 @@ function drawHUD(ctx) {
|
||||
const [iid, lvl] = itemEntries[index];
|
||||
const ix = 28 + (index % 12) * 34;
|
||||
const iy = trayY + 31 + Math.floor(index / 12) * 34;
|
||||
Sprites.draw(ctx, 'i_' + iid, ix, iy, 0, 30, false, 0.95);
|
||||
Sprites.draw(ctx, 'i_' + iid, ix, iy, 0, 30, false, 0.95 * A);
|
||||
if (lvl > 1) {
|
||||
ctx.fillStyle = '#ffd060'; ctx.font = 'bold 8px monospace';
|
||||
ctx.fillText(romanNum(lvl), ix - 7, iy + 11);
|
||||
@@ -112,14 +148,19 @@ function drawHUD(ctx) {
|
||||
ctx.fillText('KILLS ' + String(G.kills).padStart(3, '0'), W - 18, H - 67);
|
||||
ctx.fillStyle = '#f0e5d7'; ctx.font = 'bold 11px monospace';
|
||||
ctx.fillText('SCORE ' + String(G.score).padStart(6, '0'), W - 18, H - 46);
|
||||
ctx.textAlign = 'left'; ctx.fillStyle = G.pressure > 1 ? '#d6aa39' : '#69b8bd'; ctx.font = 'bold 8px monospace';
|
||||
ctx.fillText('PRESSURE ' + Math.round(G.pressure * 100) + '%', W - 168, H - 28);
|
||||
ctx.fillStyle = '#8f7770'; ctx.font = '8px monospace';
|
||||
ctx.fillText('×' + (G.pressure || 1).toFixed(2), W - 168, H - 46);
|
||||
ctx.textAlign = 'left';
|
||||
const dialTxt = ' D' + (G.pressureDial > 0 ? '+' : '') + G.pressureDial;
|
||||
ctx.fillStyle = G.pressure > 1 ? '#d6aa39' : '#69b8bd'; ctx.font = 'bold 8px monospace';
|
||||
ctx.fillText('PRESSURE ' + Math.round(G.pressure * 100) + '%' + dialTxt, W - 168, H - 28);
|
||||
ctx.textAlign = 'right'; ctx.fillText('STREAK ' + G.streak, W - 18, H - 28);
|
||||
drawPixelBar(ctx, W - 168, H - 20, 150, 8, (G.pressure - PRESSURE_MIN) / (PRESSURE_MAX - PRESSURE_MIN), {
|
||||
fill: G.pressure > 1 ? '#c59231' : '#3d8f99', glint: G.pressure > 1 ? '#f4d86d' : '#8ce0df', border: '#5d3c43', segments: 10,
|
||||
});
|
||||
|
||||
if (G.boss && G.boss.hp > 0) {
|
||||
ctx.save(); ctx.globalAlpha = 1; // boss bar stays fully opaque
|
||||
const bw = 460, bx = W / 2 - bw / 2, by = H - 48;
|
||||
drawPixelPanel(ctx, bx, by, bw, 40, {
|
||||
accent: '#e12842', cut: 6, fill: 'rgba(12,3,6,0.92)', blood: true,
|
||||
@@ -133,6 +174,7 @@ function drawHUD(ctx) {
|
||||
drawPixelBar(ctx, bx + 11, by + 22, bw - 22, 10, k, {
|
||||
fill: '#b51e35', glint: '#ff7d82', border: '#6e2936', segments: 20,
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.textAlign = 'center';
|
||||
@@ -152,7 +194,7 @@ function drawHUD(ctx) {
|
||||
ctx.fillStyle = '#9f8981'; ctx.font = '9px monospace';
|
||||
ctx.fillText(t.sub.toUpperCase(), W / 2, y + 31);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.globalAlpha = A;
|
||||
}
|
||||
|
||||
if (G.muted) {
|
||||
|
||||
@@ -49,12 +49,13 @@ const ITEMS = {
|
||||
rerollrib: { name: 'Reroll Rib', desc: '+1 perk reroll each level', apply: s => { s.rerollPerLevel += 1; s.rerolls += 1; } },
|
||||
};
|
||||
|
||||
// 50% of rolls favor items the player already owns (below cap) so high tiers
|
||||
// are reachable if you commit to them
|
||||
// Stickiness ramps with commitment: 10% per distinct upgradable item owned,
|
||||
// capped at 50%. Your first pickup barely biases the pool, so early drops vary.
|
||||
function randomItemId() {
|
||||
const p = G.player;
|
||||
const owned = p ? Object.keys(p.items).filter(iid => (p.items[iid] || 0) < ITEM_LEVEL_CAP) : [];
|
||||
if (owned.length && chance(0.5)) return choice(owned);
|
||||
const stick = Math.min(0.5, owned.length * 0.10);
|
||||
if (owned.length && chance(stick)) return choice(owned);
|
||||
return choice(Object.keys(ITEMS));
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
let canvas, ctx, lastT = 0;
|
||||
|
||||
const PRESSURE_SLIDER_RECT = { x: 300, y: 470, w: 360, h: 46 };
|
||||
|
||||
function pauseBars() {
|
||||
const x = W / 2 - 55, w = 225;
|
||||
return [
|
||||
{ rect: { x, y: 418, w, h: 10 }, set: v => Sfx.setVolume(v) },
|
||||
{ rect: { x, y: 446, w, h: 10 }, set: v => Music.setVolume(v) },
|
||||
{ rect: { x, y: 474, w, h: 10 }, set: v => setHudAlpha(0.25 + v * 0.75) },
|
||||
];
|
||||
}
|
||||
|
||||
function init() {
|
||||
canvas = document.getElementById('game');
|
||||
ctx = canvas.getContext('2d');
|
||||
@@ -25,10 +36,25 @@ function init() {
|
||||
if (Number.isFinite(sv)) G.sfxVol = clamp(sv, 0, 1);
|
||||
if (Number.isFinite(mv)) G.musicVol = clamp(mv, 0, 1);
|
||||
G.autoPerk = localStorage.getItem('meatslicer_autoperk') === '1';
|
||||
const pd = parseFloat(localStorage.getItem('meatslicer_pressure_dial'));
|
||||
if (Number.isFinite(pd)) G.pressureDial = clamp(Math.round(pd), PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX);
|
||||
const ha = parseFloat(localStorage.getItem('meatslicer_hud_alpha'));
|
||||
if (Number.isFinite(ha)) G.hudAlpha = clamp(ha, 0.25, 1);
|
||||
} catch (e) {}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function setPressureDial(n) {
|
||||
G.pressureDial = clamp(Math.round(n), PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX);
|
||||
try { localStorage.setItem('meatslicer_pressure_dial', String(G.pressureDial)); } catch (e) {}
|
||||
Sfx.menu();
|
||||
}
|
||||
|
||||
function setHudAlpha(v) {
|
||||
G.hudAlpha = clamp(Math.round(v * 100) / 100, 0.25, 1);
|
||||
try { localStorage.setItem('meatslicer_hud_alpha', String(G.hudAlpha)); } catch (e) {}
|
||||
}
|
||||
|
||||
function setAutoPerk(enabled) {
|
||||
G.autoPerk = !!enabled;
|
||||
try { localStorage.setItem('meatslicer_autoperk', G.autoPerk ? '1' : '0'); } catch (e) {}
|
||||
@@ -108,9 +134,18 @@ function update(dt) {
|
||||
}
|
||||
|
||||
switch (G.mode) {
|
||||
case 'menu':
|
||||
if (Input.mpressed || keyPressed('enter', ' ')) startRun();
|
||||
case 'menu': {
|
||||
const sl = PRESSURE_SLIDER_RECT;
|
||||
if (keyPressed('arrowleft', 'a', '-', '_')) setPressureDial(G.pressureDial - 1);
|
||||
if (keyPressed('arrowright', 'd', '=', '+')) setPressureDial(G.pressureDial + 1);
|
||||
if (Input.mpressed && inRect(Input.mx, Input.my, sl)) G.menuDialDrag = true;
|
||||
if (G.menuDialDrag && Input.mdown) {
|
||||
setPressureDial(Math.round(((Input.mx - sl.x) / sl.w) * 10) - 5);
|
||||
}
|
||||
if (!Input.mdown) G.menuDialDrag = false;
|
||||
if (keyPressed('enter', ' ') || (Input.mpressed && !inRect(Input.mx, Input.my, sl))) startRun();
|
||||
break;
|
||||
}
|
||||
case 'play':
|
||||
G.time += dt;
|
||||
if (keyPressed('p', 'escape')) { G.mode = 'pause'; Sfx.stopAllLoops(); Sfx.menu(); break; }
|
||||
@@ -143,6 +178,21 @@ function update(dt) {
|
||||
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(); }
|
||||
if (keyPressed(';', ':')) { setHudAlpha(G.hudAlpha - 0.1); Sfx.menu(); }
|
||||
if (keyPressed("'", '"')) { setHudAlpha(G.hudAlpha + 0.1); Sfx.menu(); }
|
||||
// click-and-drag on the three pause sliders
|
||||
{
|
||||
const bars = pauseBars();
|
||||
if (Input.mpressed) {
|
||||
for (let i = 0; i < bars.length; i++) if (inRect(Input.mx, Input.my, bars[i].rect)) { G.pauseDrag = i; break; }
|
||||
}
|
||||
if (G.pauseDrag !== null && G.pauseDrag !== undefined && Input.mdown) {
|
||||
const b = bars[G.pauseDrag];
|
||||
const v = clamp((Input.mx - b.rect.x) / b.rect.w, 0, 1);
|
||||
b.set(v);
|
||||
}
|
||||
if (!Input.mdown) G.pauseDrag = null;
|
||||
}
|
||||
if (keyPressed('arrowleft', '[')) Music.cycle(-1);
|
||||
else if (keyPressed('arrowright', ']')) Music.cycle(1);
|
||||
else if (Input.mpressed) {
|
||||
@@ -272,41 +322,74 @@ function drawMenu(ctx) {
|
||||
tx += widths[i] + 6;
|
||||
}
|
||||
|
||||
// ---- pressure dial ----
|
||||
drawPressureDial(ctx);
|
||||
|
||||
ctx.fillStyle = '#655155'; ctx.font = '9px monospace';
|
||||
ctx.fillText('ROOM-CLEAR COMBAT // PERK DRAFTS // SIXTEEN WEAPONS // CROWNED MEAT', W / 2, 474);
|
||||
ctx.fillText('ROOM-CLEAR COMBAT // PERK DRAFTS // SIXTEEN WEAPONS // CROWNED MEAT', W / 2, 530);
|
||||
if (G.best) {
|
||||
drawPixelTag(ctx, 'BEST CUT ' + String(G.best).padStart(6, '0'), W / 2 - 74, 503, {
|
||||
drawPixelTag(ctx, 'BEST CUT ' + String(G.best).padStart(6, '0'), W / 2 - 74, 556, {
|
||||
width: 148, height: 26, color: '#e0b94e', accent: '#a47e25',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function drawPressureDial(ctx) {
|
||||
const sl = PRESSURE_SLIDER_RECT;
|
||||
const d = G.pressureDial;
|
||||
const gain = pressureGainUnits(d), drop = pressureDropUnits(d);
|
||||
const accent = d < 0 ? '#3d8f99' : (d === 0 ? '#c59231' : '#e22b46');
|
||||
drawPixelPanel(ctx, sl.x, sl.y, sl.w, sl.h, {
|
||||
cut: 6, shadow: false, accent, fill: 'rgba(12,5,8,0.92)', border: '#63303a',
|
||||
});
|
||||
const name = d <= -4 ? 'MERCIFUL' : d <= -2 ? 'EASED' : d <= 0 ? 'STANDARD' : d <= 2 ? 'TENSE' : d <= 4 ? 'SAVAGE' : 'RELENTLESS';
|
||||
ctx.textAlign = 'left'; ctx.fillStyle = '#9e857a'; ctx.font = 'bold 8px monospace';
|
||||
ctx.fillText('PRESSURE DIAL', sl.x + 10, sl.y + 12);
|
||||
ctx.textAlign = 'right'; ctx.fillStyle = accent; ctx.font = 'bold 9px monospace';
|
||||
ctx.fillText((d > 0 ? '+' : '') + d + ' ' + name, sl.x + sl.w - 10, sl.y + 12);
|
||||
// track with 10 steps / 11 notches
|
||||
const bx = sl.x + 34, bw = sl.w - 68, by = sl.y + 20, bh = 10;
|
||||
drawPixelBar(ctx, bx, by, bw, bh, (d + 5) / 10, {
|
||||
fill: accent, glint: '#f4d86d', border: '#5d3c43', segments: 10,
|
||||
});
|
||||
// bright notch marker
|
||||
const nx = bx + Math.round((d + 5) / 10 * bw);
|
||||
ctx.fillStyle = '#f5e9d6';
|
||||
ctx.fillRect(nx - 1, by - 2, 2, bh + 4);
|
||||
// end labels
|
||||
ctx.fillStyle = '#7d6262'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'center';
|
||||
ctx.fillText('-5', bx - 16, by + 9); ctx.fillText('+5', bx + bw + 16, by + 9);
|
||||
// derived readout
|
||||
ctx.fillStyle = '#8f7770'; ctx.font = '8px monospace';
|
||||
ctx.fillText('RISE +' + gain.toFixed(1) + '/5 · RELIEF ' + drop.toFixed(1) + '/5 · SCORE ×LIVE PRESSURE', sl.x + sl.w / 2, sl.y + sl.h - 7);
|
||||
}
|
||||
|
||||
function drawPause(ctx) {
|
||||
ctx.fillStyle = 'rgba(4,2,3,0.78)'; ctx.fillRect(0, 0, W, H);
|
||||
drawPixelPanel(ctx, W / 2 - 270, 126, 540, 388, {
|
||||
drawPixelPanel(ctx, W / 2 - 270, 104, 540, 452, {
|
||||
accent: '#c92a40', border: 'rgba(119,51,61,0.95)', fill: 'rgba(11,5,8,0.96)',
|
||||
blood: true, seed: 8, bloodAlpha: 0.25,
|
||||
});
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = '#7f6768'; ctx.font = 'bold 9px monospace';
|
||||
ctx.fillText('RUN STATE // SUSPENDED', W / 2, 158);
|
||||
ctx.fillText('RUN STATE // SUSPENDED', W / 2, 136);
|
||||
ctx.fillStyle = '#f0e5d7'; ctx.font = 'bold 36px monospace';
|
||||
ctx.fillText('PAUSED', W / 2, 202);
|
||||
drawBloodTrim(ctx, W / 2 - 130, 213, 260, 8, 0.45);
|
||||
ctx.fillText('PAUSED', W / 2, 180);
|
||||
drawBloodTrim(ctx, W / 2 - 130, 191, 260, 8, 0.45);
|
||||
|
||||
ctx.fillStyle = '#b89532'; ctx.font = 'bold 10px monospace';
|
||||
ctx.fillText('JUKEBOX // ACTIVE TRACK', W / 2, 270);
|
||||
ctx.fillText('JUKEBOX // ACTIVE TRACK', W / 2, 248);
|
||||
const sel = (G.musicSel === undefined || G.musicSel < 0) ? 'AUTO · FLOOR ROTATION' : Music.pretty(Music.PLAYLIST[G.musicSel]).toUpperCase();
|
||||
drawPixelPanel(ctx, W / 2 - 170, H / 2 + 20, 340, 82, {
|
||||
drawPixelPanel(ctx, W / 2 - 170, 318, 340, 82, {
|
||||
cut: 5, shadow: false, accent: '#468e9d', fill: 'rgba(5,10,12,0.7)', border: '#315b65',
|
||||
});
|
||||
ctx.fillStyle = '#8faeb2'; ctx.font = 'bold 9px monospace';
|
||||
ctx.fillText('NOW PLAYING', W / 2, H / 2 + 43);
|
||||
ctx.fillText('NOW PLAYING', W / 2, 341);
|
||||
ctx.fillStyle = '#e9ded0'; ctx.font = 'bold 14px monospace';
|
||||
ctx.fillText(sel, W / 2, H / 2 + 70);
|
||||
ctx.fillText(sel, W / 2, 368);
|
||||
|
||||
for (const sx of [-200, 200]) {
|
||||
const x = W / 2 + sx, y = H / 2 + 60;
|
||||
const x = W / 2 + sx, y = 358;
|
||||
const hover = dist(Input.mx, Input.my, x, y) < 22;
|
||||
drawPixelPanel(ctx, x - 20, y - 20, 40, 40, {
|
||||
cut: 5, shadow: false, fill: hover ? '#631526' : '#251017',
|
||||
@@ -316,23 +399,31 @@ function drawPause(ctx) {
|
||||
ctx.fillText(sx < 0 ? '<' : '>', x, y + 6);
|
||||
}
|
||||
|
||||
// three sliders: SFX / MUSIC / HUD (drag or key pairs)
|
||||
const sliders = [
|
||||
{ label: 'SFX [- / +]', val: G.sfxVol, colors: { fill: '#c92a40', glint: '#ff7d82', border: '#63303a', segments: 10 } },
|
||||
{ label: 'MUSIC [, / .]', val: G.musicVol, colors: { fill: '#468e9d', glint: '#a5edee', border: '#315b65', segments: 10 } },
|
||||
{ label: "HUD [; / ']", val: (G.hudAlpha - 0.25) / 0.75, colors: { fill: '#b5892f', glint: '#f4d86d', border: '#5d3c43', segments: 10 } },
|
||||
];
|
||||
const bars = pauseBars();
|
||||
ctx.textAlign = 'left'; ctx.font = 'bold 9px monospace'; ctx.fillStyle = '#a99a91';
|
||||
ctx.fillText('SFX [- / +]', W / 2 - 170, 435);
|
||||
drawPixelBar(ctx, W / 2 - 55, 426, 225, 10, G.sfxVol, {
|
||||
fill: '#c92a40', glint: '#ff7d82', border: '#63303a', segments: 10,
|
||||
});
|
||||
ctx.fillText('MUSIC [, / .]', W / 2 - 170, 445);
|
||||
drawPixelBar(ctx, W / 2 - 55, 436, 225, 10, G.musicVol, {
|
||||
fill: '#468e9d', glint: '#a5edee', border: '#315b65', segments: 10,
|
||||
});
|
||||
for (let i = 0; i < sliders.length; i++) {
|
||||
const by = bars[i].rect.y;
|
||||
ctx.fillStyle = '#a99a91'; ctx.fillText(sliders[i].label, W / 2 - 170, by + 9);
|
||||
drawPixelBar(ctx, bars[i].rect.x, by, bars[i].rect.w, 10, sliders[i].val, sliders[i].colors);
|
||||
ctx.fillStyle = '#e9ded0'; ctx.textAlign = 'right';
|
||||
const pct = i === 2 ? Math.round(G.hudAlpha * 100) : Math.round(sliders[i].val * 100);
|
||||
ctx.fillText(pct + '%', W / 2 + 185, by + 9);
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
drawPixelTag(ctx, '[P] RESUME', W / 2 - 250, 462, { width: 112, height: 28, color: '#f0e5d7', accent: '#b5243a' });
|
||||
drawPixelTag(ctx, '[R] SWAP', W / 2 - 125, 462, { width: 112, height: 28, color: '#bba9a1', accent: '#6e3843' });
|
||||
drawPixelTag(ctx, '[T] AUTO ' + (G.autoPerk ? 'ON' : 'OFF'), W / 2, 462, { width: 112, height: 28, color: G.autoPerk ? '#79d9ca' : '#bba9a1', accent: G.autoPerk ? '#378b80' : '#6e3843' });
|
||||
drawPixelTag(ctx, '[M] MUTE', W / 2 + 125, 462, { width: 112, height: 28, color: '#bba9a1', accent: '#6e3843' });
|
||||
drawPixelTag(ctx, '[P] RESUME', W / 2 - 250, 502, { width: 112, height: 28, color: '#f0e5d7', accent: '#b5243a' });
|
||||
drawPixelTag(ctx, '[R] SWAP', W / 2 - 125, 502, { width: 112, height: 28, color: '#bba9a1', accent: '#6e3843' });
|
||||
drawPixelTag(ctx, '[T] AUTO ' + (G.autoPerk ? 'ON' : 'OFF'), W / 2, 502, { width: 112, height: 28, color: G.autoPerk ? '#79d9ca' : '#bba9a1', accent: G.autoPerk ? '#378b80' : '#6e3843' });
|
||||
drawPixelTag(ctx, '[M] MUTE', W / 2 + 125, 502, { width: 112, height: 28, color: '#bba9a1', accent: '#6e3843' });
|
||||
ctx.fillStyle = '#69585a'; ctx.font = '9px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('ARROW KEYS / CLICK TO CHANGE TRACK · LIVE BOSS FIGHTS OVERRIDE SELECTION', W / 2, 503);
|
||||
ctx.fillText('ARROWS / CLICK CHANGE TRACK · DRAG SLIDERS · LIVE BOSSES OVERRIDE TRACK', W / 2, 543);
|
||||
|
||||
const hb = HELP_BUTTON;
|
||||
const helpHover = inRect(Input.mx, Input.my, hb);
|
||||
|
||||
@@ -19,7 +19,7 @@ const PERKS = [
|
||||
{ id: 'heavyhand',name: 'Heavy Hand', desc: '+20% knockback', apply: s => { s.knockbackMul *= 1.20; } },
|
||||
{ id: 'thickhide',name: 'Thick Hide', desc: '+4% chance to ignore damage', apply: s => { s.armor += 1 / 24; } },
|
||||
{ id: 'secondwind',name: 'Second Wind', desc: '+0.15s hurt immunity', apply: s => { s.invBonus += 0.15; } },
|
||||
{ id: 'scrapfeed',name: 'Scrap Feed', desc: '5% less ammo consumed', apply: s => { s.ammoEff *= 1.05; } },
|
||||
{ id: 'scrapfeed',name: 'Scrap Feed', desc: '5% less ammo (diminishing)', apply: s => { s.ammoEff += 0.05; } },
|
||||
{ id: 'boneknit', name: 'Bone Knit', desc: '3% chance to heal ½ heart after combat rooms', apply: s => { s.roomHealChance += 0.03; } },
|
||||
{ id: 'spiteflesh',name: 'Spite Flesh', desc: 'Contact attackers take damage', apply: s => { s.thorns += 4; } },
|
||||
{ id: 'carrion', name: 'Carrion Sense', desc: '+8% pickup radius and pull speed', apply: s => { s.magnet *= 1.08; s.magnetPull *= 1.08; } },
|
||||
|
||||
@@ -177,7 +177,7 @@ function pressureRelief(dmgTaken) {
|
||||
const severity = clamp(dmgTaken / Math.max(2, p.stats.maxHp * 0.25), 0.4, 2);
|
||||
const desperation = 1 + (1 - hpFrac) * 2;
|
||||
const churn = 1 + Math.min(G.recentHits.length, 4) * 0.35;
|
||||
return 0.03 * severity * desperation * churn;
|
||||
return PRESSURE_DROP_BASE * PRESSURE_UNIT * severity * desperation * churn * pressureDropScale();
|
||||
}
|
||||
|
||||
function hurtPlayer(dmg, ang, attacker) {
|
||||
|
||||
@@ -116,7 +116,7 @@ function updateRoom(dt) {
|
||||
const stalled = G.time - G.roomEnterT > 90;
|
||||
if (lowHealth || stalled) {
|
||||
const decay = lowHealth ? 0.02 : 0.008;
|
||||
G.pressure = Math.max(PRESSURE_MIN, G.pressure - decay * dt);
|
||||
G.pressure = Math.max(PRESSURE_MIN, G.pressure - decay * dt * pressureDropScale());
|
||||
}
|
||||
}
|
||||
G.recentHits = (G.recentHits || []).filter(t => G.time - t < 12);
|
||||
@@ -171,7 +171,7 @@ function updateRoom(dt) {
|
||||
function recordRoomClear(room) {
|
||||
if (!G.roomDamaged) {
|
||||
G.streak++;
|
||||
G.pressure = Math.min(PRESSURE_MAX, G.pressure + PRESSURE_GAIN);
|
||||
G.pressure = Math.min(PRESSURE_MAX, G.pressure + pressureGain());
|
||||
} else {
|
||||
G.streak = 0;
|
||||
}
|
||||
@@ -363,13 +363,73 @@ function drawWalls(ctx, r) {
|
||||
else if (dir === 's') { x = W / 2; y = H - WALL / 2; rot = 0; }
|
||||
else if (dir === 'e') { x = W - WALL / 2; y = H / 2; rot = Math.PI / 2; }
|
||||
else { x = WALL / 2; y = H / 2; rot = Math.PI / 2; }
|
||||
Sprites.draw(ctx, sprite, x, y, rot, 96);
|
||||
// ---- doorway framing (visual only; DOOR_HALF governs function) ----
|
||||
const horiz = dir === 'n' || dir === 's';
|
||||
const dirSeed = { n: 0, s: 2.1, e: 4.2, w: 6.3 }[dir];
|
||||
// clear dark passage under the art (guaranteed clean opening)
|
||||
ctx.fillStyle = '#050103';
|
||||
if (horiz) ctx.fillRect(x - DOOR_HALF, y - WALL / 2, DOOR_HALF * 2, WALL);
|
||||
else ctx.fillRect(x - WALL / 2, y - DOOR_HALF, WALL, DOOR_HALF * 2);
|
||||
// door art (wide, walkable passage for open; sealed jaws for locked)
|
||||
Sprites.draw(ctx, sprite, x, y, rot, 150, false, 1, 1, 0.82);
|
||||
// accents on top, in wall-aligned local space
|
||||
ctx.save();
|
||||
if (horiz) ctx.translate(x, y); else { ctx.translate(x, y); ctx.rotate(Math.PI / 2); }
|
||||
// warm glow spilling from the threshold so the exit reads as inviting
|
||||
ctx.globalAlpha = 0.20 + Math.sin(G.time * 2.0 + dirSeed) * 0.06;
|
||||
ctx.fillStyle = locked ? '#ff2a3c' : '#e8724a';
|
||||
ctx.fillRect(-DOOR_HALF, WALL / 2 - 8, DOOR_HALF * 2, 8);
|
||||
// frame rim light on both jamb edges
|
||||
const rimAlpha = 0.30 + Math.sin(G.time * 2.2 + dirSeed) * 0.12;
|
||||
ctx.globalAlpha = rimAlpha;
|
||||
ctx.fillStyle = locked ? '#ff2a3c' : '#c4172a';
|
||||
ctx.fillRect(-DOOR_HALF - 2, -WALL / 2 - 2, 3, WALL + 4);
|
||||
ctx.fillRect(DOOR_HALF - 1, -WALL / 2 - 2, 3, WALL + 4);
|
||||
ctx.globalAlpha = 1;
|
||||
// jagged bone teeth ONLY when sealed (locked reads as dangerous, open reads as safe)
|
||||
if (locked) {
|
||||
for (let side = -1; side <= 1; side += 2) {
|
||||
for (let k = 0; k < 7; k++) {
|
||||
const hh = hashTile((r.gx * 3 + 17) * 31 + side * 7 + k, (r.gy * 5 + 11) * 13 + k * 3);
|
||||
const ty = -WALL / 2 + 3 + (hh % 41);
|
||||
const th = 4 + (hh % 3);
|
||||
const tw = 3 + (hh % 2);
|
||||
ctx.fillStyle = hh % 4 === 0 ? '#e8dcc2' : '#cfc0a4';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(side * DOOR_HALF, ty - tw);
|
||||
ctx.lineTo(side * DOOR_HALF, ty + tw);
|
||||
ctx.lineTo(side * (DOOR_HALF - th), ty);
|
||||
ctx.closePath(); ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
// outward chevrons past the threshold so the exit reads from across the room
|
||||
const chevOff = dir === 'n' ? -1 : (dir === 's' ? 1 : 0);
|
||||
const chevOffX = dir === 'w' ? -1 : (dir === 'e' ? 1 : 0);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.5 + Math.sin(G.time * 3 + dirSeed) * 0.2;
|
||||
ctx.strokeStyle = locked ? '#ff2a3c' : '#e8b04a';
|
||||
ctx.lineWidth = 3;
|
||||
for (let c = 1; c <= 2; c++) {
|
||||
const d0 = WALL + 10 + c * 12;
|
||||
ctx.beginPath();
|
||||
if (horiz) {
|
||||
const yy = y + chevOff * d0;
|
||||
ctx.moveTo(x - 10, yy); ctx.lineTo(x, yy + chevOff * 8); ctx.lineTo(x + 10, yy);
|
||||
} else {
|
||||
const xx = x + chevOffX * d0;
|
||||
ctx.moveTo(xx, y - 10); ctx.lineTo(xx + chevOffX * 8, y); ctx.lineTo(xx, y + 10);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
if (locked) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.5 + Math.sin(G.time * 6) * 0.2;
|
||||
ctx.fillStyle = '#8f1f2a';
|
||||
if (dir === 'n' || dir === 's') ctx.fillRect(x - DOOR_HALF, y - 6, DOOR_HALF * 2, 12);
|
||||
else ctx.fillRect(x - 6, y - DOOR_HALF, 12, DOOR_HALF * 2);
|
||||
if (horiz) ctx.fillRect(x - DOOR_HALF, y - 8, DOOR_HALF * 2, 16);
|
||||
else ctx.fillRect(x - 8, y - DOOR_HALF, 16, DOOR_HALF * 2);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ const SfxBank = {
|
||||
if (this.buffers.has(name) || this.pending.has(name) || this.failed.has(name)) return;
|
||||
const file = this.FILES[name];
|
||||
if (!file || !this.ctx || typeof fetch !== 'function') return;
|
||||
const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=36')
|
||||
const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=37')
|
||||
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
|
||||
.then(bytes => this.ctx.decodeAudioData(bytes))
|
||||
.then(buffer => this.buffers.set(name, this.trim(buffer)))
|
||||
|
||||
@@ -73,7 +73,7 @@ const Sprites = {
|
||||
const img = new Image();
|
||||
img.onload = () => { this.imgs[name] = img; if (--pending === 0) G.imagesLoaded = true; };
|
||||
img.onerror = () => { if (--pending === 0) G.imagesLoaded = true; };
|
||||
img.src = 'assets/' + name + '.png?v=36';
|
||||
img.src = 'assets/' + name + '.png?v=37';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -193,8 +193,8 @@ const Sprites = {
|
||||
ctx.fillStyle = C('#e8e0d0');
|
||||
ctx.beginPath(); ctx.arc(-3, -3, 3.5, 0, TAU); ctx.arc(-3, 4, 3.5, 0, TAU); ctx.fill(); // eyes
|
||||
} else if (name.startsWith('enemy_')) {
|
||||
const cols = { enemy_shambler: '#6a8f3c', enemy_runner: '#a33e2e', enemy_spitter: '#7a4f9e', enemy_splitter: '#8f6b3c', enemy_mini: '#7a5a30', enemy_exploder: '#c93b3b' };
|
||||
const r = name === 'enemy_mini' ? 6 : (name === 'enemy_exploder' ? 10 : 11);
|
||||
const cols = { enemy_shambler: '#6a8f3c', enemy_runner: '#a33e2e', enemy_spitter: '#7a4f9e', enemy_splitter: '#8f6b3c', enemy_mini: '#b8202f', enemy_exploder: '#c93b3b' };
|
||||
const r = name === 'enemy_mini' ? 9 : (name === 'enemy_exploder' ? 10 : 11);
|
||||
ctx.fillStyle = C(cols[name] || '#888');
|
||||
ctx.beginPath(); ctx.arc(0, 0, r, 0, TAU); ctx.fill();
|
||||
ctx.fillStyle = C('#1a0505');
|
||||
@@ -221,7 +221,20 @@ const Sprites = {
|
||||
ctx.fillStyle = C('#3d1f22'); ctx.fillRect(-16, -16, 32, 32);
|
||||
ctx.fillStyle = C('#54282c'); ctx.fillRect(-16, -16, 32, 5);
|
||||
} else if (name === 'door_open' || name === 'door_locked') {
|
||||
ctx.fillStyle = C('#180d0e'); ctx.fillRect(-15, -8, 30, 16);
|
||||
// void
|
||||
ctx.fillStyle = C('#060104'); ctx.fillRect(-16, -10, 32, 20);
|
||||
// jamb
|
||||
ctx.fillStyle = C('#241116'); ctx.fillRect(-16, -12, 32, 3); ctx.fillRect(-16, 9, 32, 3);
|
||||
// crimson rim light
|
||||
ctx.fillStyle = C(name === 'door_locked' ? '#ff2a3c' : '#c4172a');
|
||||
ctx.fillRect(-16, -11, 2, 22); ctx.fillRect(14, -11, 2, 22);
|
||||
// bone teeth
|
||||
ctx.fillStyle = C('#d8ccb2');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const ty = -8 + i * 4;
|
||||
ctx.beginPath(); ctx.moveTo(-14, ty); ctx.lineTo(-10, ty + 2); ctx.lineTo(-14, ty + 4); ctx.closePath(); ctx.fill();
|
||||
ctx.beginPath(); ctx.moveTo(14, ty); ctx.lineTo(10, ty + 2); ctx.lineTo(14, ty + 4); ctx.closePath(); ctx.fill();
|
||||
}
|
||||
if (name === 'door_locked') {
|
||||
ctx.fillStyle = C('#8f2f2f');
|
||||
for (let i = -13; i < 15; i += 6) { ctx.beginPath(); ctx.moveTo(i, -8); ctx.lineTo(i + 3, 0); ctx.lineTo(i, 8); ctx.fill(); }
|
||||
|
||||
@@ -26,6 +26,7 @@ const G = {
|
||||
perkChoices: null,
|
||||
autoPerk: false,
|
||||
pressure: 1,
|
||||
pressureDial: 0,
|
||||
streak: 0,
|
||||
roomDamaged: false,
|
||||
roomEnterT: 0,
|
||||
@@ -36,6 +37,7 @@ const G = {
|
||||
helpPage: 0,
|
||||
sfxVol: 0.45,
|
||||
musicVol: 0.55,
|
||||
hudAlpha: 1,
|
||||
imagesLoaded: false,
|
||||
roomLayer: null,
|
||||
roomLayerKey: '',
|
||||
@@ -43,7 +45,18 @@ const G = {
|
||||
};
|
||||
|
||||
const PERK_POWER_WEIGHT = 0.5;
|
||||
const PRESSURE_MIN = 0.75, PRESSURE_MAX = 1.60, PRESSURE_GAIN = 0.01;
|
||||
const PRESSURE_MIN = 0.75, PRESSURE_MAX = 1.60;
|
||||
const PRESSURE_UNIT = 0.01; // one "point" on the dial scale
|
||||
const PRESSURE_GAIN = PRESSURE_UNIT; // retained: dial 0 == 1 point
|
||||
const PRESSURE_DROP_BASE = 3; // dial 0 == 0.03 relief base
|
||||
const PRESSURE_DIAL_MIN = -5, PRESSURE_DIAL_MAX = 5;
|
||||
|
||||
// -5 -> 0 units, 0 -> 1 unit, +5 -> 5 units (piecewise linear)
|
||||
function pressureGainUnits(dial) { const d = clamp(dial, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX); return d <= 0 ? 1 + d * 0.2 : 1 + d * 0.8; }
|
||||
// -5 -> 5 units, 0 -> 3 units, +5 -> 0 units (piecewise linear)
|
||||
function pressureDropUnits(dial) { const d = clamp(dial, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX); return d <= 0 ? 3 - d * 0.4 : 3 - d * 0.6; }
|
||||
function pressureGain() { return pressureGainUnits(G.pressureDial) * PRESSURE_UNIT; }
|
||||
function pressureDropScale() { return pressureDropUnits(G.pressureDial) / PRESSURE_DROP_BASE; }
|
||||
|
||||
// total player power: perks drafted (level - 1) + item tiers owned.
|
||||
// monsters scale against this so difficulty tracks the build.
|
||||
@@ -60,4 +73,8 @@ function addToast(text, sub) {
|
||||
G.toasts.push({ text, sub: sub || '', t: 0 });
|
||||
if (G.toasts.length > 3) G.toasts.shift();
|
||||
}
|
||||
function addScore(n) { G.score += n; }
|
||||
function addScore(n) {
|
||||
if (!n) return;
|
||||
const scaled = Math.round(n * (G.pressure || 1));
|
||||
G.score += n > 0 ? Math.max(1, scaled) : scaled;
|
||||
}
|
||||
@@ -61,7 +61,7 @@ for (const f of files) {
|
||||
}
|
||||
// const/let top-level bindings live in the context's lexical scope, not on the
|
||||
// global object — expose the ones the harness pokes at directly.
|
||||
vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, Sfx, SfxBank, W, H, WALL, ITEMS, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS })', sandbox);
|
||||
vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, Sfx, SfxBank, W, H, WALL, ITEMS, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS, PRESSURE_UNIT, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN })', sandbox);
|
||||
|
||||
let simTime = 0;
|
||||
const ctx = sandbox;
|
||||
@@ -353,6 +353,26 @@ console.log('== diminishing armor block chance ==');
|
||||
p.stats.armor = 0; p.invT = 0;
|
||||
}
|
||||
|
||||
console.log('== scrap feed has diminishing returns (like thick hide) ==');
|
||||
{
|
||||
const sf = ctx.PERKS.find(k => k.id === 'scrapfeed');
|
||||
const s = { ammoEff: 1 };
|
||||
const save = () => 1 - 1 / s.ammoEff; // fraction of ammo saved
|
||||
sf.apply(s, {}); const s1 = save();
|
||||
const d1 = s1; // marginal saving of stack 1
|
||||
sf.apply(s, {}); const s2 = save();
|
||||
const d2 = s2 - s1; // marginal saving of stack 2
|
||||
sf.apply(s, {}); const s3 = save();
|
||||
const d3 = s3 - s2; // marginal saving of stack 3
|
||||
check('first stack ~5% saving', Math.abs(s1 - (1 - 1 / 1.05)) < 0.0001);
|
||||
check('each stack helps less', d2 < d1 && d3 < d2);
|
||||
check('saving never reaches 100%', save() < 1);
|
||||
// Brass Magazine still multiplies on top (unchanged)
|
||||
const s2x = { ammoEff: 1, ammoPickupMul: 1 };
|
||||
ctx.ITEMS.brassmagazine.apply(s2x, {});
|
||||
check('brass magazine stays multiplicative', Math.abs(s2x.ammoEff - 1.15) < 0.0001);
|
||||
}
|
||||
|
||||
console.log('== close-range weapon impact pass ==');
|
||||
check('Marrow Scatter was substantially strengthened', ctx.WEAPONS.marrow.dmg >= 11 && ctx.WEAPONS.marrow.range >= 0.38);
|
||||
check('Bile Blunderbuss was substantially strengthened', ctx.WEAPONS.bile.dmg >= 7 && ctx.WEAPONS.bile.range >= 0.38);
|
||||
@@ -770,6 +790,153 @@ console.log('== adaptive clean-room pressure ==');
|
||||
check('new runs reset pressure and streak', ctx.G.pressure === 1 && ctx.G.streak === 0);
|
||||
}
|
||||
|
||||
console.log('== bigger gore-red minis ==');
|
||||
{
|
||||
check('mini hitbox grew', ctx.ENEMY_TYPES.mini.r === 14);
|
||||
check('mini sprite grew', ctx.ENEMY_TYPES.mini.drawSize === 48);
|
||||
}
|
||||
|
||||
console.log('== 500ms wave spawn telegraph ==');
|
||||
{
|
||||
ctx.startRun(); step(3, 16);
|
||||
// enter a combat room to trigger a wave
|
||||
const sr = ctx.G.rooms['0,0'];
|
||||
const sd = Object.keys(sr.doors).find(d => sr.doors[d]);
|
||||
const DIRS2 = { n: [0, -1], s: [0, 1], e: [1, 0], w: [-1, 0] };
|
||||
const [ddx, ddy] = DIRS2[sd];
|
||||
ctx.enterRoom(sr.gx + ddx, sr.gy + ddy);
|
||||
step(1, 16);
|
||||
const room2 = ctx.G.cur;
|
||||
if (room2.type === 'combat' && ctx.G.enemies.length > 0) {
|
||||
const spawner = ctx.G.enemies[0];
|
||||
check('wave spawn is telegraphing', spawner.warmT > 0.3 && spawner.warmT <= ctx.SPAWN_WARN);
|
||||
const sx = spawner.x, sy = spawner.y;
|
||||
ctx.G.player.invT = 0;
|
||||
const hpBefore = ctx.G.player.hp;
|
||||
ctx.G.player.x = spawner.x; ctx.G.player.y = spawner.y; // overlap the sigil
|
||||
step(20, 16); // ~320ms
|
||||
check('telegraphed spawn is frozen', Math.abs(spawner.x - sx) < 0.01 && Math.abs(spawner.y - sy) < 0.01);
|
||||
check('telegraphed spawn deals no contact damage', ctx.G.player.hp === hpBefore);
|
||||
step(25, 16); // past 500ms total
|
||||
check('spawn arms after the telegraph', spawner.warmT <= 0);
|
||||
ctx.G.player.x = ctx.W / 2; ctx.G.player.y = ctx.H / 2; ctx.G.player.invT = 1;
|
||||
} else {
|
||||
check('combat room spawned for telegraph test (' + room2.type + ')', true);
|
||||
}
|
||||
// splitter splits stay instant
|
||||
ctx.G.enemies.length = 0;
|
||||
const splitParent = ctx.makeEnemy('splitter', ctx.W / 2, ctx.H / 2, 1, false);
|
||||
ctx.G.enemies.push(splitParent);
|
||||
ctx.damageEnemy(splitParent, 99999, 0, false);
|
||||
const minis = ctx.G.enemies.filter(e => e.type === 'mini');
|
||||
check('splitter splits into minis', minis.length === 2);
|
||||
check('splits spawn instantly (no telegraph)', minis.every(m => (m.warmT || 0) === 0));
|
||||
ctx.G.enemies.length = 0;
|
||||
ctx.startRun(); step(3, 16);
|
||||
}
|
||||
|
||||
console.log('== pressure dial scaling ==');
|
||||
{
|
||||
const GAIN = { '-5': 0, '-4': 0.2, '-3': 0.4, '-2': 0.6, '-1': 0.8, '0': 1, '1': 1.8, '2': 2.6, '3': 3.4, '4': 4.2, '5': 5 };
|
||||
const DROP = { '-5': 5, '-4': 4.6, '-3': 4.2, '-2': 3.8, '-1': 3.4, '0': 3, '1': 2.4, '2': 1.8, '3': 1.2, '4': 0.6, '5': 0 };
|
||||
let gainOK = true, dropOK = true;
|
||||
for (let d = -5; d <= 5; d++) {
|
||||
if (Math.abs(ctx.pressureGainUnits(d) - GAIN[d]) > 1e-6) gainOK = false;
|
||||
if (Math.abs(ctx.pressureDropUnits(d) - DROP[d]) > 1e-6) dropOK = false;
|
||||
}
|
||||
check('gain units match all 11 notches', gainOK);
|
||||
check('drop units match all 11 notches', dropOK);
|
||||
// dial +5: gain 0.05/clean room, zero relief on hit
|
||||
ctx.G.pressureDial = 5;
|
||||
ctx.G.pressure = 1; ctx.G.roomDamaged = false;
|
||||
ctx.recordRoomClear({ type: 'combat' });
|
||||
check('dial +5 clean room adds 5%', Math.abs(ctx.G.pressure - 1.05) < 1e-4);
|
||||
ctx.G.pressure = 1.2; ctx.G.recentHits = []; ctx.G.player.hp = 5; ctx.G.player.stats.maxHp = 10;
|
||||
ctx.G.player.invT = 0; ctx.G.mode = 'play';
|
||||
ctx.hurtPlayer(1, 0);
|
||||
check('dial +5 gives no hit relief', Math.abs(ctx.G.pressure - 1.2) < 1e-6);
|
||||
// dial -5: zero gain, relief scaled 5/3 vs dial 0 for the identical hit
|
||||
ctx.G.pressureDial = -5;
|
||||
ctx.G.pressure = 1; ctx.G.roomDamaged = false;
|
||||
ctx.recordRoomClear({ type: 'combat' });
|
||||
check('dial -5 clean room adds nothing', Math.abs(ctx.G.pressure - 1) < 1e-6);
|
||||
const mkHit = (dial) => {
|
||||
ctx.G.pressureDial = dial;
|
||||
ctx.G.pressure = 1.2; ctx.G.recentHits = [];
|
||||
ctx.G.player.hp = 10; ctx.G.player.stats.maxHp = 10; ctx.G.player.invT = 0; ctx.G.mode = 'play';
|
||||
ctx.hurtPlayer(1, 0);
|
||||
return 1.2 - ctx.G.pressure;
|
||||
};
|
||||
const reliefNeg = mkHit(-5);
|
||||
const reliefZero = mkHit(0);
|
||||
check('dial -5 relief beats dial 0 (' + reliefNeg.toFixed(4) + ' vs ' + reliefZero.toFixed(4) + ')',
|
||||
reliefNeg > reliefZero && Math.abs(reliefNeg - reliefZero * (5 / 3)) < 1e-4);
|
||||
// score multiplier
|
||||
ctx.G.pressureDial = 0;
|
||||
ctx.G.pressure = 1.5;
|
||||
const scBefore = ctx.G.score; ctx.addScore(100);
|
||||
check('score scales with live pressure (1.5x)', ctx.G.score - scBefore === 150);
|
||||
ctx.G.pressure = 0.75;
|
||||
const scBefore2 = ctx.G.score; ctx.addScore(1);
|
||||
check('small awards floor at +1', ctx.G.score - scBefore2 === 1);
|
||||
ctx.G.pressure = 1; ctx.G.pressureDial = 0;
|
||||
ctx.startRun(); step(3, 16);
|
||||
}
|
||||
|
||||
console.log('== item stickiness ramps with owned count ==');
|
||||
{
|
||||
ctx.startRun(); step(3, 16);
|
||||
const p = ctx.G.player;
|
||||
p.items = {}; // no owned items -> always a fresh roll
|
||||
// 1 upgradable item owned -> ~10% stick
|
||||
p.items = { hollowpoints: 1 };
|
||||
let owned1 = 0;
|
||||
for (let i = 0; i < 600; i++) if (ctx.randomItemId() === 'hollowpoints') owned1++;
|
||||
const frac1 = owned1 / 600;
|
||||
check('1 owned item sticks ~10% (' + owned1 + '/600)', frac1 > 0.05 && frac1 < 0.20);
|
||||
// 5+ upgradable items -> capped 50% stick
|
||||
p.items = { hollowpoints: 1, twitch: 1, scalpel: 1, leadmarrow: 1, piercegaze: 1 };
|
||||
let owned5 = 0;
|
||||
for (let i = 0; i < 600; i++) if (p.items[ctx.randomItemId()]) owned5++;
|
||||
const frac5 = owned5 / 600;
|
||||
check('5+ owned items stick ~50% (' + owned5 + '/600)', frac5 > 0.38 && frac5 < 0.62);
|
||||
p.items = {};
|
||||
ctx.startRun(); step(3, 16);
|
||||
}
|
||||
|
||||
console.log('== help manual is 8 pages (implants split) ==');
|
||||
{
|
||||
check('8 help pages', ctx.HELP_PAGES.length === 8);
|
||||
check('8 help renderers', ctx.HELP_RENDERERS.length === 8);
|
||||
ctx.G.player = ctx.G.player || {};
|
||||
const fctx = fakeCtx();
|
||||
let renderOK = true;
|
||||
try {
|
||||
for (let i = 0; i < ctx.HELP_PAGES.length; i++) {
|
||||
ctx.G.helpPage = i;
|
||||
ctx.drawPauseHelp(fctx);
|
||||
}
|
||||
} catch (e) { renderOK = false; console.log(' render error: ' + e.message); }
|
||||
check('every help page renders without throwing', renderOK);
|
||||
}
|
||||
|
||||
console.log('== HUD alpha slider ==');
|
||||
{
|
||||
ctx.startRun(); step(3, 16);
|
||||
ctx.setHudAlpha(0.5);
|
||||
check('hud alpha sets', Math.abs(ctx.G.hudAlpha - 0.5) < 1e-6);
|
||||
ctx.setHudAlpha(0.05);
|
||||
check('hud alpha clamps to 25% floor', Math.abs(ctx.G.hudAlpha - 0.25) < 1e-6);
|
||||
ctx.setHudAlpha(5);
|
||||
check('hud alpha clamps to 100%', Math.abs(ctx.G.hudAlpha - 1) < 1e-6);
|
||||
const fctx2 = fakeCtx();
|
||||
let hudOK = true;
|
||||
try { ctx.G.hudAlpha = 0.25; ctx.drawHUD(fctx2); } catch (e) { hudOK = false; console.log(' hud error: ' + e.message); }
|
||||
check('HUD renders at 25% opacity', hudOK);
|
||||
ctx.G.hudAlpha = 1;
|
||||
ctx.startRun(); step(3, 16);
|
||||
}
|
||||
|
||||
console.log('== boneknit is a chance-based room heal ==');
|
||||
{
|
||||
const p = ctx.G.player;
|
||||
|
||||
@@ -409,11 +409,12 @@ def a_splitter():
|
||||
def a_mini():
|
||||
img, d = canvas(24)
|
||||
E(d, [4, 8, 20, 17], OUTL)
|
||||
E(d, [5, 9, 19, 16], (122, 90, 48, 255))
|
||||
E(d, [13, 9, 19, 16], BROWN) # head
|
||||
E(d, [5, 9, 19, 16], MEAT_D)
|
||||
E(d, [13, 9, 19, 16], GORE) # head
|
||||
d.point([(15, 15), (17, 15), (16, 15)], fill=BONE) # teeth
|
||||
d.point([(17, 11)], fill=BLACK) # eye
|
||||
R(d, [7, 16, 9, 18], SHAMB_D) # tail nub
|
||||
d.point([(7, 10), (9, 13), (8, 14)], fill=BLOOD_D) # gore speckle
|
||||
R(d, [7, 16, 9, 18], BLOOD_D) # tail nub
|
||||
return img
|
||||
|
||||
|
||||
@@ -1329,9 +1330,7 @@ ACTORS = [
|
||||
def _fit_actor(img, name, frame_size):
|
||||
box = img.getbbox()
|
||||
img = img.crop(box) if box else img
|
||||
if name == "enemy_mini":
|
||||
target = 28
|
||||
elif name.startswith("boss_"):
|
||||
if name.startswith("boss_"):
|
||||
target = 120
|
||||
elif name == "player":
|
||||
target = 88
|
||||
|
||||
@@ -126,7 +126,7 @@ SPECS: dict[str, Spec] = {
|
||||
"enemy_runner": Spec("lean feral abattoir ghoul sprinting on four long limbs, exposed spine and jaw, facing right, directly top-down", 64),
|
||||
"enemy_spitter": Spec("bloated infected slaughterhouse mutant with huge toxic bile maw, swollen sacs and drool, facing right, directly top-down", 64),
|
||||
"enemy_splitter": Spec("round tumour beast made of fused meat nodules and industrial staples, splitting seams, facing right, directly top-down", 64),
|
||||
"enemy_mini": Spec("small vicious meat grub with tiny hooked legs and an oversized ring of teeth, facing right, directly top-down", 32),
|
||||
"enemy_mini": Spec("small vicious blood-red meat grub, glistening raw gore-red flesh, wet crimson skin, tiny hooked bone legs and an oversized ring of teeth, dripping blood, facing right, directly top-down", 64),
|
||||
"enemy_exploder": Spec("swollen explosive cyst mutant with taut pale skin, glowing red-yellow cracks and embedded blasting cap, facing right, directly top-down", 64),
|
||||
"boss_bonesaw": Spec("colossal radial bone-saw machine monster, multiple concentric serrated rusted discs, bone hub, one hateful red lens, directly top-down", 128),
|
||||
"boss_gorecrown": Spec("colossal pulsing heart monarch fused to an iron crown of bone spikes and chains, radial silhouette, directly top-down", 128),
|
||||
@@ -151,8 +151,8 @@ SPECS: dict[str, Spec] = {
|
||||
"tile_wall2": Spec("reinforced concrete wall panel with steel edge columns, cracks and old blood run", 64, "tile"),
|
||||
"tile_wall3": Spec("oxidized copper wall panel with teal patina, rivets and a recessed conduit", 64, "tile"),
|
||||
"tile_wall4": Spec("slaughterhouse wall panel with steel braces, hanging hook and dirty bone deposits", 64, "tile"),
|
||||
"door_open": Spec("wide open industrial blast doorway viewed top-down, thick riveted steel frame, pitch black opening, blood-worn threshold", 96),
|
||||
"door_locked": Spec("wide sealed industrial blast doorway viewed top-down, interlocking steel jaws, red warning lamp, chains and dried blood", 96),
|
||||
"door_open": Spec("very wide open industrial slaughterhouse doorway viewed top-down, a huge clear empty pitch-black rectangular passage in the center that you can walk straight through, thick riveted rusted steel frame and pillars only on the outer left and right edges, glowing crimson accent lamps on the frame, hanging chains and hooks at the sides, worn blood-stained metal threshold, the center opening is completely empty and unobstructed, no teeth, no jaws, evil but clearly an open entrance", 128),
|
||||
"door_locked": Spec("massive menacing sealed industrial slaughterhouse doorway viewed top-down, interlocking rusted steel jaws clamped over jagged bone teeth, glowing red warning lamps, heavy chains and padlocks, dried blood and gore along the threshold, evil and foreboding", 128),
|
||||
|
||||
# Projectiles and pickups.
|
||||
"bullet_bone": Spec("sharp dirty bone shard projectile pointing right", 16),
|
||||
|
||||