From 3f76998fab691b3c12f8a7873a91105990363428 Mon Sep 17 00:00:00 2001 From: MeatSlicer Developer Date: Fri, 31 Jul 2026 08:49:39 +1000 Subject: [PATCH] Add hidden dev debug console (v0.5.0) --- desktop/main.js | 5 + desktop/preload.js | 3 + docs/CHANGELOG.md | 158 +++++++++++ docs/README.md | 7 + docs/dev-debug.md | 118 +++++++++ index.html | 43 +-- js/bosses.js | 4 +- js/debug.js | 633 +++++++++++++++++++++++++++++++++++++++++++++ js/enemies.js | 1 + js/hud.js | 1 + js/items.js | 4 +- js/main.js | 36 ++- js/perks.js | 1 + js/player.js | 50 ++-- js/rooms.js | 4 +- js/sfx.js | 3 +- js/sfxbank.js | 2 +- js/sprites.js | 2 +- js/state.js | 15 ++ package.json | 2 +- play-dev.bat | 8 + test/smoke.js | 118 ++++++++- 22 files changed, 1161 insertions(+), 57 deletions(-) create mode 100644 docs/CHANGELOG.md create mode 100644 docs/dev-debug.md create mode 100644 js/debug.js create mode 100644 play-dev.bat diff --git a/desktop/main.js b/desktop/main.js index 2cdad76..8d1fddd 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -39,6 +39,8 @@ function installProtocol() { }); } +const DEV = process.argv.includes('--dev'); + function createWindow() { const win = new BrowserWindow({ fullscreen: true, // borderless windowed fullscreen on Windows @@ -50,6 +52,9 @@ function createWindow() { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, + // forward --dev into the preload's process.argv (custom app flags aren't + // guaranteed to appear there otherwise); enables the hidden debug console + additionalArguments: DEV ? ['--ms-dev'] : [], }, }); Menu.setApplicationMenu(null); diff --git a/desktop/preload.js b/desktop/preload.js index 68e9817..c5d49f0 100644 --- a/desktop/preload.js +++ b/desktop/preload.js @@ -2,4 +2,7 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('MSDesktop', { quit: () => ipcRenderer.send('ms-quit'), + // true only when launched with --dev (forwarded as --ms-dev via additionalArguments); + // enables the hidden debug console + dev: process.argv.includes('--ms-dev') || process.argv.includes('--dev'), }); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..4b9ee7b --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,158 @@ +# Changelog + +All notable changes to **MeatSlicer** are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [0.5.0] — 2026-07-30 + +### Added +- **Hidden in-game debug console** — a dev-only cheat/inspection toolset opened with the backtick `` ` `` key from play or pause, gated behind a launch flag so it stays undiscovered in normal play (`MeatSlicer.exe --dev`, `npm start -- --dev`, `play-dev.bat`, or `localStorage.meatslicer_dev='1'`). Eight tabs reusing the field-manual canvas UI: + - **Player** — hp/maxHp/shield/level, full-heal, kill, grant levelups, force the perk draft, teleport to cursor, and god-mode / infinite-ammo / one-shot-kill toggles + - **Weapons** — equip any of the 16 weapons (old weapon goes to the holster), refill/set ammo, drop at cursor, spawn item pedestal + - **Items** — give, remove, or set exact tier for any item via a stat-rebuild; presets for one-of-everything, max-everything, clear-build + - **Perks+Active** — grant any of the 22 perks by id, remove drafted perks, equip/fire any active item + - **World** — warp to any floor, teleport to the boss/item room, clear/kill room, spawn any of the 11 enemy types (count + elite) or any of the 9 bosses by id, spawn pickups + - **Pressure** — set the pressure value or dial numerically, freeze pressure, live gain/relief readout + - **Stats** *(read-only)* — the full `p.stats` table in two scrollable columns, diff-highlighted against defaults, plus powerScore, entity counts, and a wall-clock FPS meter + - **Misc** — timescale (0.25×/1×/4×), freeze + single-frame step, render visualizers (collision circles, arena bounds, enemy HP numbers, hazard shapes, magnet radius), set score/kills/streak, trigger game over, SFX/FX test buttons, and a pin-live mode that keeps the world running behind the panel +- **Run tainting** — any mutating debug action marks the run so best-score persistence is skipped; the HUD shows a `[DEBUG]` tag and the game-over screen shows "score not recorded". Read-only tools do not taint +- **Item/perk removal via stat rebuild** — `defaultPlayerStats()` factory extracted from `initPlayer()`, plus `p.perks` tracking in `grantPerk()`, enabling `debugRebuildStats()` to reset and re-apply the whole build +- **`applyPressureDelta()`** — single mutation point for `G.pressure` (hurt relief, room decay, room-clear gain) so the debug pressure-lock can freeze difficulty in one place +- **Dev documentation** — `docs/dev-debug.md` (kept out of the player-facing manual) + +### Changed +- **`spawnBoss()` accepts an optional forced boss index** so the console can spawn any boss by id +- **`useActive()` accepts an optional force flag** to fire from the debug console outside play mode + +### Fixed +- **Positional audio NaN guard** — `Sfx.output()` no longer produces a silent/broken node when a caller passes a positionless object (this is why the debug BOSS ROAR test was inaudible); `pan`/`distanceGain` are only computed for finite coordinates + +--- + +## [0.4.0] — 2026-07-30 + +### Added +- **Confirmed menu and desktop exits** — explicit confirm dialog before quitting +- **Locked gore death and enemy health pips** — visual health indicators on enemies, gore-styled death animation locked behind kill state +- **Variable room shapes and themes** — rooms are no longer uniform rectangles; different shapes and visual themes appear across the descent +- **Pressure dial expanded to 21 notches** — finer granularity on the pressure mechanic (previously 11 notches) +- **Five pressure-monster archetypes** — new enemy types that spawn based on pressure level +- **Boss stairs sealed after victory** — stairs to the next floor are blocked until the boss is defeated; the seal breaks on victory + +### Changed +- **Pause actions and duplicate weapons polished** — pause menu interactions refined; duplicate weapon handling improved + +### Fixed +- **Desktop exit disabled in browsers** — the desktop-specific quit action no longer appears when running in a browser + +--- + +## [0.3.0] — 2026-07-30 + +### Added +- **Per-weapon ammo refill system** — every special weapon gets an explicit `refill` value (units per ammo pickup, tuned so one pickup funds ~10 kills), replacing the `ammoRefillFraction` log2 heuristic and `ammoWeight` overrides +- **Stream weapons** — Bile Blunderbuss and The Cauterizer join Red Right Hand as time-denominated stream weapons (`drain` per second) instead of per-shot cost +- **Boss room ammo** — boss rooms now stock one ammo pickup on entry and drop one guaranteed ammo on death +- **Calibration tool** — `tools/ammo_sim.js` calibrates every weapon into the 8–13 kills/refill band (all 15 pass); smoke test guards the ammo structure + +### Changed +- **Sawblades ricochet through enemies** — bounce fix makes them chain effectively +- **Traps catch 3 victims** — increased from lower cap +- **Lobbed projectiles arc overhead** — Flesh Masher, Trap Queen, and Swarm Jar payloads now reliably deploy at the target point +- **Marrow Draught** now covers stream drain +- **Balance trimming**: Cauterizer burn (2.2s×2 → 1.5s×0.8), bile acid pools (3.5s×1.5 → 2.5s×0.8) + +### Removed +- **Unused `wpn-hemophage-2.mp3`** — stale audio asset deleted + +--- + +## [0.2.0] — 2026-07-30 + +### Changed +- **Toast durations are now per-toast** — `addToast` accepts a third argument for display duration; passive item and active item pickups display for **5.5s** instead of the default 2.5s + +--- + +## [0.1.0] — Pre-release (2026-07-29 – 2026-07-30) + +The initial series of commits that built the game from scratch. Grouped by feature area. + +### Added — Core Game +- **Adaptive progression and item expansion** *(`8ab0857`)* — initial commit with 298 files establishing the entire game codebase: player, enemies, bosses, weapons, items, perks, HUD, rooms, bullets, particles, audio, music, sprites, and the smoke-test harness +- **Doom-style weapon audio batch** *(`64ac534`)* — all 13 weapon fire one-shots and 4 weapon loops regenerated in a chunky, bass-heavy 1993 Doom style via ElevenLabs +- **New SfxBank clips**: `ui_active`, `ui_active_empty` (active items), `ui_curse` (legendary pickups), `plr_revive` (Second Skin revive) + +### Added — Items & Progression +- **Item system overhaul** *(`4f46d54`)*: + - Item rarity bands (`common`/`uncommon`/`rare`/`legendary`) with per-item tier caps + - Weighted source pools (elites → common, item rooms → uncommon, bosses → rare/legendary) + - Rarity-weighted powerScore to cap difficulty inflation from common stacks + - 14 new passives including Chill Gland (activates `slowOnHit`) and Hooked Sinew (activates `magnetPull`) + - Rarity-tinted pedestal glow + nameplates, rarity-sorted HUD implant tray + - Generated 14 item icons; asset cache v=38 +- **Phase 3: 12 hook passives** *(`a93802e`)* — Dead Weight (execute), Cauterized Veins (burn bonus), Pain Engine, Iron Lung, Slaughter Rhythm, Hollow Choir, Sawbone Coil, Glutton's Gut, Thresher Plate, Blood Moat, Meat Hook, Blood Debt +- **Phase 4: 10 boss-exclusive legendaries** *(`f955232`)* — Butcher's Oath, Second Skin, Twin Sidearm, Crimson Metronome, Abattoir Engine, Gore Crown, Thousand Teeth, Hollow Father, The Last Cut, Meat Grinder (cap 1, boss-only, reset per floor) +- **Phase 5: Active items** *(`0ec12cd`)*: + - 10 actives: Bone Nova, Offal Bomb, Blood Transfusion, Cleaver Storm, Butcher's Bell, Marrow Draught, Slaughter Time, Panic Room, Skinner's Coin, Gut Reroll + - Charges accrue +1/combat room, +2/boss + - Dedicated active pickup with weapon-style swap-drop; 30% boss / 15% item-room bonus pedestal spawns + - HUD active slot with charge pips + READY state; ACTIVES help page +- **Differentiate duplicates** *(`4f46d54`)* — Grafted Trigger, Crow Bait, Hollow Bones, Twin Hearts + +### Added — Pressure & Difficulty +- **Pressure dial** *(`a02cff5`)* — title-screen dial (-5..+5) tunes pressure rise/relief; score multiplied by live pressure +- **500ms spawn telegraph** *(`a02cff5`)* — wave spawns telegraph before appearing + +### Added — Weapons +- **Rebalanced weapon tiers** *(`f71d202`)* via quantitative DPS/range/accuracy/pool scoring: + - T0: repeater, marrow + - T1: cleaver, saw, hemophage, eye + - T2: bile, guthook, fleshmasher, trapqueen, redhand + - T3: cauterizer, tenderizer, spinaltap, swarmjar +- **Ammo economy tightened** *(`4dad22d`)* — 25% less ammo per box, 25% fewer ammo drops + +### Added — HUD & UI +- **In-game field manual** *(`3f856e4`)* — 7-page technical manual (controls, run loop, arsenal, mutations, implants, bestiary, pressure/defense math); renders live from game tables +- **Vitals HUD** *(`a02cff5`)* — two-row hearts, half-container outlines, half-heart shield pips +- **HUD opacity slider** *(`a02cff5`)* — in pause menu; boss bar + toasts stay opaque +- **Readable 8-page field manual** *(`a02cff5`)* — bigger panel + fonts, implants split across pages +- **Help manual layout fix** *(`d5b1828`)* — tabs shrink to fit 920px panel; implant rows spaced at 26px + +### Added — Visual & Environment +- **Bigger gore-red Minis** *(`a02cff5`)* — radius 10→14, sprite 32→48 with regenerated art +- **Wider walkable doorways** *(`a02cff5`)* — clear passage for open doors, teeth only when locked +- **Item stickiness ramps** *(`a02cff5`)* — 10%/owned item (cap 50%) instead of flat 50% +- **Generated perk and item icons** *(`9ba0e22`)* — via `gen_assets.py` +- **Player documentation** *(`c76d126`)* — docs for items, perks, bosses, and gameplay +- **Desktop build** *(`956f116`)* — Electron app with custom `app://` protocol, borderless fullscreen, single-instance lock, and `MS_VERIFY` diagnostic harness + +### Changed +- **Bone Knit** *(`8cf9932`)* — now a chance-based room heal: 3% roll per stack to mend half a heart on combat room clear (was guaranteed) +- **Scrap Feed** *(`a02cff5`)* — diminishing returns like Thick Hide +- **Ghoul Heart** *(`f955232`)* — now +4 HP (2 full hearts) +- **Brass Magazine** *(`f955232`)* — ammo efficiency uses diminishing additive formula (same as Scrap Feed) +- **Audio rebatch** *(`64ac534`)* — weapon fire sounds regenerated in Doom-style +- **Pinned Electron** `@40.10.2` + `electron-builder@25.1.8` for Node 20 compatibility + +### Removed +- **Footstep system** *(`64ac534`)* — removed entirely (8 `plr_step*` samples, `Sfx.footstep`, player.js call site, smoke test block) + +--- + +## Legend + +| Symbol | Meaning | +|--------|---------| +| `Added` | New features | +| `Changed` | Changes in existing functionality | +| `Fixed` | Bug fixes | +| `Removed` | Removed features | + +--- + +*Generated from the git log. Last commit: v0.5.0* diff --git a/docs/README.md b/docs/README.md index 13de947..8b6b211 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,13 @@ MeatSlicer is an endless top-down arena roguelite. You are the Butcher, descendi | [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 | +For developers: + +| Doc | Contents | +|---|---| +| [Debug Console](dev-debug.md) | Hidden dev-only cheat/inspection toolset (backtick `` ` `` key, `--dev` flag): all 8 tabs, run tainting, stat-rebuild internals | +| [Changelog](CHANGELOG.md) | Release history by version | + ### Quick numbers - **16 weapons** (1 infinite-ammo sidearm + 15 special weapons across 4 tiers) diff --git a/docs/dev-debug.md b/docs/dev-debug.md new file mode 100644 index 0000000..f7da314 --- /dev/null +++ b/docs/dev-debug.md @@ -0,0 +1,118 @@ +# Debug Console (dev only) + +A hidden in-game cheat / inspection toolset. Not linked from the player-facing +help manual — it is meant to stay undiscovered in normal play. + +## Enabling it + +The console is gated behind a dev flag and is completely inert otherwise (the +open key does nothing and `G.devMode` is `false`). + +| Launch | Flag | +|---|---| +| Packaged Electron app | `MeatSlicer.exe --dev` | +| `npm start` | `npm start -- --dev` | +| Browser (local server) | `http://localhost:8123/?dev=1` → use **`play-dev.bat`** | +| Any browser (persistent) | `localStorage.setItem('meatslicer_dev', '1')` in devtools | + +Detection lives in `debugEnabled()` (`js/debug.js`). For the packaged app, the +`--dev` flag is forwarded into the preload's `process.argv` as `--ms-dev` via +`webPreferences.additionalArguments` (`desktop/main.js`), and +`desktop/preload.js` exposes it as `window.MSDesktop.dev` (custom app flags +aren't guaranteed to reach a renderer's `process.argv` on their own, hence the +explicit forward). + +## Opening it + +Press the **backtick `` ` ``** (or `~`) key from **play** or **pause**. The +game pauses (its own `G.mode = 'debug'`). `` ` `` / `Esc` / the `[X]` button +closes it back to where you came from. + +Navigate tabs with `←`/`→`, number keys `1-8`, or by clicking the tab tags. + +## Run tainting + +Any **mutating** action sets `G.debugUsed = true`. When tainted: + +- `gameOver()` skips the `meatslicer_best` best-score write. +- The HUD shows a red `[DEBUG]` tag and the game-over screen shows + `[ DEBUG RUN — SCORE NOT RECORDED ]`. +- The console footer shows `TAINTED`. + +Read-only tools (the STATS tab, the render visualizers, the FPS counter, +pinning) do **not** taint. Taint resets on the next run (`resetRun()`). + +## Tabs + +1. **PLAYER** — hp/maxHp/shield/level number rows, full-heal, kill, +1 pending + levelup, force-open the perk draft, teleport to cursor, +500 score, and + three toggles: **God mode** (`hurtPlayer` no-ops), **Infinite ammo** (ammo + topped up each frame), **One-shot kill** (`damageEnemy` forces `dmg=1e9`, + which still respects Plate Father's plate mitigation). +2. **WEAPONS** — picker over all 16 `WEAPONS`. Equip (old weapon goes to the + holster unless it was the Bone Popper), refill ammo, holster current, drop + the weapon as a pickup at the cursor, spawn an item pedestal, set ammo. +3. **ITEMS** — picker over all `ITEMS` grouped by rarity, showing current tier. + Give +1 (routes through `giveItem`), remove 1 tier, remove all, set to max + tier, or set an exact tier. Presets: *one of everything*, *max everything*, + *clear build*. +4. **PERKS+ACTIVE** — grant any of the 22 `PERKS` by id, remove a drafted perk, + clear all perks. Set / fire an active item from `ACTIVES`. +5. **WORLD** — warp to floor N (`genFloor` + `enterRoom`), next floor, teleport + to the boss/item room, clear room, kill all, kill boss, regen floor. Spawn + any of the 11 `ENEMY_TYPES` (count + elite flag) or any of the 9 `BOSS_DEFS` + by id (uses `spawnBoss(floor, forcedIndex)`), and spawn pickups at cursor. +6. **PRESSURE** — set `G.pressure` (clamped to `PRESSURE_MIN..MAX`), set the + dial, and **freeze pressure** (the `applyPressureDelta` helper in + `js/state.js` no-ops). Shows live `pressureGainUnits`/`pressureDropUnits`, + streak, recent hits, and the Abattoir Engine multiplier. +7. **STATS** *(read-only)* — the full `p.stats` table in two scrollable columns, + diff-highlighted against `defaultPlayerStats()`. Plus `powerScore()`, entity + counts, FPS, floor/time. +8. **MISC** — timescale (0.25×/1×/4×), freeze + single-frame step, render + visualizers (collision circles, arena bounds, enemy HP numbers, + hazard/telegraph shapes, magnet radius), set score/kills/streak, trigger + game over, and SFX/FX test buttons. The **PIN** toggle keeps the panel open + while the world runs live behind it (and shows a compact always-on-top + readout when the panel is closed but a visualizer is active). + +## How item/perk removal works (stat rebuild) + +`ITEMS[iid].apply(stats, p)` and `perk.apply(stats, p)` are one-way mutations, +so removal isn't a subtraction — it's a rebuild. `debugRebuildStats()` resets +`p.stats` to `defaultPlayerStats()`, zeroes `shieldHp`, then re-applies every +owned item tier (`p.items[iid]` times, mirroring `giveItem`) and every drafted +perk (`p.perks`, tracked by `grantPerk`). + +Two caveats: + +- **maxHp-lowering items** (Blood Debt, Butcher's Oath) replay in ownership + insertion order, so a rebuild can produce a valid but slightly different + maxHp than the original build order. Current hp is snapshotted before and + clamped to the new maxHp afterward. +- One-time player side effects baked into some `apply` functions (e.g. the + immediate heal from Iron Stomach, the immediate shield from Bone Plate / + Shield Heart) are re-granted on rebuild. This is intentional and keeps the + model simple. + +## Architecture + +Everything lives in `js/debug.js`, loaded after `js/help.js` and before +`js/main.js` in `index.html`. It reuses the help manual's canvas UI primitives +(`drawPixelPanel`, `drawPixelTag`, `hfont`, `inRect`, `wrapText`) and mirrors +its `HELP_PAGES` / `updatePauseHelp` structure with a `DEBUG_PAGES` array. + +The hooks into core code are intentionally minimal: + +- `js/state.js` — `debugUsed`/`debugFlags`/debug panel state on `G`, plus the + `applyPressureDelta()` helper. +- `js/player.js` — `defaultPlayerStats()` factory (extracted from `initPlayer`), + `perks: []` field, and the god-mode early-return in `hurtPlayer()`. +- `js/perks.js` — one line in `grantPerk()` to record the perk id. +- `js/enemies.js` — one line in `damageEnemy()` for OHKO. +- `js/rooms.js` — two `applyPressureDelta()` call sites. +- `js/bosses.js` — optional `forcedIndex` param on `spawnBoss()`. +- `js/main.js` — `G.devMode` in `init()`, the open gesture in `play`/`pause`, + the `debug` case in `update()`, timescale in `loop()`, and the + `drawDebug`/`drawDebugOverlays`/`drawDebugPin` calls in `draw()`. +- `js/hud.js` + `drawGameOver` — the taint markers. diff --git a/index.html b/index.html index ef72062..3cef805 100644 --- a/index.html +++ b/index.html @@ -6,31 +6,32 @@ MEATSLICER - +
- - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/js/bosses.js b/js/bosses.js index 7905669..d85e6f4 100644 --- a/js/bosses.js +++ b/js/bosses.js @@ -14,8 +14,8 @@ const BOSS_DEFS = [ function bossIndexForFloor(floor) { return (floor - 1) % BOSS_DEFS.length; } -function spawnBoss(floor) { - const def = BOSS_DEFS[bossIndexForFloor(floor)]; +function spawnBoss(floor, forcedIndex) { + const def = BOSS_DEFS[forcedIndex === undefined ? bossIndexForFloor(floor) : forcedIndex]; const cycle = Math.floor((floor - 1) / 3); const tier = Math.max(0, cycle - def.debutCycle); const up = powerScore(); diff --git a/js/debug.js b/js/debug.js new file mode 100644 index 0000000..8bf6533 --- /dev/null +++ b/js/debug.js @@ -0,0 +1,633 @@ +// ---- hidden debug console ---- +// Enabled only in dev builds / launches: packaged app needs `--dev`, browser +// needs `?dev=1` or localStorage meatslicer_dev=1. Open with the ` key from +// play or pause. Mutating actions taint the run (G.debugUsed) so best-score +// persistence is skipped. See docs/dev-debug.md. + +function debugEnabled() { + try { if (window.MSDesktop && window.MSDesktop.dev) return true; } catch (e) {} + try { if (typeof location !== 'undefined' && location.search && location.search.indexOf('dev=1') !== -1) return true; } catch (e) {} + try { if (typeof localStorage !== 'undefined' && localStorage.getItem('meatslicer_dev') === '1') return true; } catch (e) {} + return false; +} + +// Reset stats to defaults and re-apply every owned item tier and drafted perk. +// Removal / tier-setting routes through here because item.apply() is one-way. +// Note: maxHp-lowering items (Blood Debt, Butcher's Oath) replay in ownership +// insertion order, so the result is valid but not necessarily bit-identical to +// the original build order; hp is snapshotted and clamped afterward. +function debugRebuildStats() { + const p = G.player; + if (!p) return; + const hp = p.hp; + p.stats = defaultPlayerStats(); + p.shieldHp = 0; + for (const iid in p.items) { + const item = ITEMS[iid]; + const n = p.items[iid] || 0; + for (let i = 0; i < n; i++) item.apply(p.stats, p); + } + if (p.perks) for (const pid of p.perks) { + const perk = PERKS.find(x => x.id === pid); + if (perk) perk.apply(p.stats, p); + } + p.hp = clamp(hp, 1, p.stats.maxHp); + refreshOrbitals(p); +} + +function debugTaint() { G.debugUsed = true; } + +// ---- layout (mirrors HELP_PANEL in js/help.js) ---- +const DEBUG_PANEL = { w: 920, h: 600, y: 22 }; +function debugPanelX() { return (W - DEBUG_PANEL.w) / 2; } +function debugTabRect(i) { + const gap = 5; + const tw = Math.min(112, Math.floor((DEBUG_PANEL.w - 40 - (DEBUG_PAGES.length - 1) * gap) / DEBUG_PAGES.length)); + const total = DEBUG_PAGES.length * tw + (DEBUG_PAGES.length - 1) * gap; + return { x: W / 2 - total / 2 + i * (tw + gap), y: DEBUG_PANEL.y + 78, w: tw, h: 30 }; +} +function debugCloseRect() { return { x: debugPanelX() + DEBUG_PANEL.w - 92, y: DEBUG_PANEL.y + 18, w: 62, h: 30 }; } +function debugBodyRect() { return { x: debugPanelX() + 16, y: DEBUG_PANEL.y + 116, w: DEBUG_PANEL.w - 32, h: DEBUG_PANEL.h - 168 }; } + +// ---- tiny widget helpers ---- +function dbgText(ctx, text, x, y, color, font, align) { + ctx.fillStyle = color; ctx.font = font || hfont(9); ctx.textAlign = align || 'left'; + ctx.fillText(text, x, y); +} +function dbgButton(ctx, label, x, y, w, h, opts) { + const o = opts || {}; + const hover = inRect(Input.mx, Input.my, { x, y, w, h }); + drawPixelTag(ctx, label, x, y, { + width: w, height: h, + color: o.active ? '#ffd060' : (hover ? '#f0e2d4' : (o.color || '#b7a49c')), + accent: o.active ? '#c0392b' : (hover ? '#8a3546' : '#4d3038'), + fill: o.active ? 'rgba(48,18,22,0.95)' : 'rgba(14,8,12,0.9)', + font: o.font || hfont(8, true), + }); + o._hover = hover; + o._rect = { x, y, w, h }; + return o; +} +function dbgClicked(rect) { return Input.mpressed && inRect(Input.mx, Input.my, rect); } +// clamp the cursor to the arena so spawned pickups/pedestals never land in walls +function dbgCursor() { + const a = G.arena; + return { x: clamp(Input.mx, a.x0 + 12, a.x1 - 12), y: clamp(Input.my, a.y0 + 12, a.y1 - 12) }; +} + +// number row: label + [-] value [+] +function dbgNumRow(ctx, label, x, y, w, get, set, step, min, max, fmt) { + dbgText(ctx, label, x, y + 14, '#cdbdb2', hfont(9, true)); + const v = get(); + const minus = dbgButton(ctx, '−', x + w - 118, y, 26, 20); + const plus = dbgButton(ctx, '+', x + w - 30, y, 26, 20); + dbgText(ctx, (fmt ? fmt(v) : String(v)), x + w - 60, y + 14, '#e8d8c8', hfont(9, true), 'center'); + if (dbgClicked(minus._rect)) { set(clamp(v - step, min, max)); return true; } + if (dbgClicked(plus._rect)) { set(clamp(v + step, min, max)); return true; } + return false; +} +function dbgToggle(ctx, label, x, y, w, on, hint) { + const b = dbgButton(ctx, label + ': ' + (on ? 'ON' : 'off'), x, y, w, 24, { active: on }); + return { rect: b._rect, on }; +} + +// ---- shared scrollable picker ---- +// items: [{ key, label, sub?, color? }]. state: { scroll, sel }. rowH fixed. +// kbd: when true this picker owns arrow/page-key navigation (see dual-picker tabs). +function debugPicker(ctx, rect, items, state, rowH, kbd) { + rowH = rowH || 24; + const vis = Math.floor(rect.h / rowH); + const maxScroll = Math.max(0, items.length - vis); + state.scroll = clamp(state.scroll || 0, 0, maxScroll); + if (state.sel === undefined || state.sel >= items.length) state.sel = Math.min(items.length - 1, Math.max(0, state.sel || 0)); + + // keyboard nav — only when this picker has focus (kbd=true). Tabs that show + // two pickers pass kbd based on which rect the mouse is over, so a keypress + // doesn't drive both at once. + if (kbd) { + if (keyPressed('arrowdown')) state.sel = Math.min(items.length - 1, state.sel + 1); + if (keyPressed('arrowup')) state.sel = Math.max(0, state.sel - 1); + if (keyPressed('pagedown')) state.sel = Math.min(items.length - 1, state.sel + vis); + if (keyPressed('pageup')) state.sel = Math.max(0, state.sel - vis); + } + if (state.sel < state.scroll) state.scroll = state.sel; + if (state.sel >= state.scroll + vis) state.scroll = state.sel - vis + 1; + + drawPixelPanel(ctx, rect.x, rect.y, rect.w, rect.h, { cut: 4, shadow: false, fill: 'rgba(6,3,6,0.6)', border: 'rgba(70,36,44,0.7)', accent: '#5a2030' }); + ctx.save(); + ctx.beginPath(); ctx.rect(rect.x + 2, rect.y + 2, rect.w - 4, rect.h - 4); ctx.clip(); + let clicked = -1; + for (let i = 0; i < vis; i++) { + const idx = state.scroll + i; + if (idx >= items.length) break; + const it = items[idx]; + const ry = rect.y + i * rowH; + const rrow = { x: rect.x + 4, y: ry, w: rect.w - 8, h: rowH }; + const sel = idx === state.sel; + const hover = inRect(Input.mx, Input.my, rrow); + if (sel || hover) { + ctx.fillStyle = sel ? 'rgba(120,30,45,0.5)' : 'rgba(90,50,60,0.3)'; + ctx.fillRect(rrow.x, rrow.y, rrow.w, rrow.h); + } + if (hover && Input.mpressed) clicked = idx; + dbgText(ctx, it.label, rect.x + 10, ry + rowH / 2 + 3, it.color || (sel ? '#ffd7a0' : '#c8b6ac'), hfont(8, sel)); + if (it.sub) dbgText(ctx, it.sub, rect.x + rect.w - 10, ry + rowH / 2 + 3, '#7d6a66', hfont(8), 'right'); + } + ctx.restore(); + if (clicked >= 0) state.sel = clicked; + // scrollbar + if (items.length > vis) { + const sh = Math.max(20, rect.h * vis / items.length); + const sy = rect.y + (rect.h - sh) * (state.scroll / Math.max(1, maxScroll)); + ctx.fillStyle = 'rgba(140,60,80,0.6)'; ctx.fillRect(rect.x + rect.w - 6, sy, 3, sh); + } + dbgText(ctx, items.length + ' · [↑↓] nav · [click] select', rect.x + 4, rect.y + rect.h + 13, '#6f5c58', hfont(8)); + return state.sel; +} + +// convenience getters +function dbgWeaponItems() { return Object.keys(WEAPONS).map(id => ({ key: id, label: WEAPONS[id].name, sub: 't' + WEAPONS[id].tier + ' · ' + WEAPONS[id].behavior })); } +function dbgItemItems() { + const order = { common: 0, uncommon: 1, rare: 2, legendary: 3 }; + return Object.keys(ITEMS).sort((a, b) => order[ITEMS[a].rarity] - order[ITEMS[b].rarity]).map(iid => { + const it = ITEMS[iid]; + return { key: iid, label: it.name + (G.player.items[iid] ? ' ' + romanNum(G.player.items[iid]) : ''), sub: it.rarity, color: ITEM_RARITY[it.rarity].color }; + }); +} +function dbgPerkItems() { return PERKS.map(p => ({ key: p.id, label: p.name + (G.player.perks && G.player.perks.indexOf(p.id) !== -1 ? ' ✓' : ''), sub: p.desc })); } +function dbgActiveItems() { return Object.keys(ACTIVES).map(aid => ({ key: aid, label: ACTIVES[aid].name, sub: 'cost ' + ACTIVES[aid].cost })); } +function dbgEnemyItems() { return Object.keys(ENEMY_TYPES).map(t => ({ key: t, label: ENEMY_TYPES[t].name || t, sub: 'hp ' + ENEMY_TYPES[t].hp })); } +function dbgBossItems() { return BOSS_DEFS.map(b => ({ key: b.kind, label: b.name, sub: b.kind })); } + +// persistent per-tab ui state +const DBG = { + weapon: { scroll: 0, sel: 0 }, item: { scroll: 0, sel: 0 }, perk: { scroll: 0, sel: 0 }, + active: { scroll: 0, sel: 0 }, enemy: { scroll: 0, sel: 0 }, boss: { scroll: 0, sel: 0 }, + statScroll: 0, spawnCount: 4, spawnElite: false, floorJump: 1, pickupSel: 0, + fpsFrames: 0, fpsT: 0, fps: 60, // wall-clock fps meter for the stats tab +}; + +// ---- per-tab renderers ---- +function dbgRenderPlayer(ctx, b) { + const p = G.player; + let y = b.y + 4; + dbgText(ctx, 'HEALTH ' + p.hp + ' / ' + p.stats.maxHp + ' SHIELD ' + p.shieldHp + ' LEVEL ' + p.level + ' XP ' + p.xp + '/' + xpForLevel(p.level), b.x, y, '#e8d0bd', hfont(10, true)); + y += 22; + const halfW = (b.w - 20) / 2; + dbgNumRow(ctx, 'HP', b.x, y, halfW, () => p.hp, v => { debugTaint(); p.hp = clamp(v, 1, p.stats.maxHp); }, 1, 1, p.stats.maxHp); + dbgNumRow(ctx, 'maxHp', b.x + halfW + 20, y, halfW, () => p.stats.maxHp, v => { debugTaint(); p.stats.maxHp = Math.max(1, v); p.hp = clamp(p.hp, 1, p.stats.maxHp); }, 2, 1, 99); + y += 26; + dbgNumRow(ctx, 'shield', b.x, y, halfW, () => p.shieldHp, v => { debugTaint(); p.shieldHp = Math.max(0, v); }, 1, 0, 20); + dbgNumRow(ctx, 'level', b.x + halfW + 20, y, halfW, () => p.level, v => { debugTaint(); p.level = Math.max(1, v); }, 1, 1, 99); + y += 30; + const acts = [ + { t: 'FULL HEAL', fn: () => { p.hp = p.stats.maxHp; spawnText(p.x, p.y - 16, 'FULL', '#7dd87d'); } }, + { t: 'KILL PLAYER', fn: () => { p.hp = 0; gameOver(); } }, + { t: '+1 LEVELUP', fn: () => { G.pendingLevelups++; addToast('DEBUG', '+1 pending levelup'); } }, + { t: 'OPEN DRAFT', fn: () => { openPerkDraft(); } }, + { t: 'TP TO CURSOR', fn: () => { p.x = clamp(Input.mx, G.arena.x0 + p.r, G.arena.x1 - p.r); p.y = clamp(Input.my, G.arena.y0 + p.r, G.arena.y1 - p.r); } }, + { t: '+500 SCORE', fn: () => addScore(500) }, + ]; + for (let i = 0; i < acts.length; i++) { + const ax = b.x + (i % 3) * (b.w / 3), ay = y + Math.floor(i / 3) * 32; + const btn = dbgButton(ctx, acts[i].t, ax, ay, b.w / 3 - 12, 26); + if (dbgClicked(btn._rect)) { debugTaint(); acts[i].fn(); Sfx.menu(); } + } + y += 2 * 32 + 6; + dbgText(ctx, 'TOGGLES', b.x, y, '#8eb7bd', hfont(9, true)); + y += 8; + const togs = [ + { k: 'god', label: 'GOD MODE', hint: 'hurtPlayer no-ops' }, + { k: 'infAmmo', label: 'INF AMMO', hint: 'ammo topped each frame' }, + { k: 'ohko', label: 'ONE-SHOT KILL', hint: 'dmg ×1e9 in damageEnemy' }, + ]; + for (let i = 0; i < togs.length; i++) { + const tx = b.x + i * (b.w / 3); + const t = dbgToggle(ctx, togs[i].label, tx, y + 6, b.w / 3 - 16, !!G.debugFlags[togs[i].k]); + dbgText(ctx, togs[i].hint, tx, y + 36, '#6f5c58', hfont(8)); + if (dbgClicked(t.rect)) { G.debugFlags[togs[i].k] = !t.on; if (!t.on) debugTaint(); Sfx.menu(); } + } + y += 52; + dbgText(ctx, 'Mutating this tab taints the run — best score will not be saved.', b.x, y, '#7d5a58', hfont(8)); +} + +function dbgRenderWeapons(ctx, b) { + const p = G.player; + const lw = 320; + dbgText(ctx, 'CURRENT: ' + WEAPONS[p.weapon.id].name + ' ammo ' + (p.weapon.ammo === Infinity ? '∞' : Math.ceil(p.weapon.ammo)), b.x, b.y + 4, '#e8d0bd', hfont(9, true)); + dbgText(ctx, 'HOLSTER: ' + (p.holstered ? WEAPONS[p.holstered.id].name : '—'), b.x, b.y + 20, '#b7a49c', hfont(8)); + const list = dbgWeaponItems(); + const rect = { x: b.x, y: b.y + 30, w: lw, h: b.h - 30 }; + const sel = debugPicker(ctx, rect, list, DBG.weapon, 24, true); + const wid = list[sel] && list[sel].key; + const ax = b.x + lw + 20; + let ay = b.y + 6; + const act = (label, fn, active) => { + const btn = dbgButton(ctx, label, ax, ay, b.w - lw - 20, 26, { active }); ay += 32; + if (dbgClicked(btn._rect)) { debugTaint(); fn(); Sfx.menu(); } + }; + act('EQUIP ' + (wid ? WEAPONS[wid].name.toUpperCase() : '—'), () => { + if (p.weapon.id !== 'bonepopper') p.holstered = p.weapon; + p.weapon = { id: wid, ammo: WEAPONS[wid].ammo }; + p.charge = 0; p.fireT = 0; + addToast('DEBUG', WEAPONS[wid].name); + }); + act('REFILL AMMO', () => { if (p.weapon.ammo !== Infinity) p.weapon.ammo = WEAPONS[p.weapon.id].ammo; }); + act('HOLSTER CURRENT', () => { if (p.weapon.id !== 'bonepopper') { p.holstered = p.weapon; p.weapon = { id: 'bonepopper', ammo: Infinity }; } }); + act('DROP AT CURSOR', () => { const c = dbgCursor(); spawnPickup('weapon', c.x, c.y, { wid }); }); + act('SPAWN ITEM PEDESTAL', () => { const c = dbgCursor(); spawnItemPedestal(c.x, c.y, null, 'room'); }); + ay += 4; + dbgNumRow(ctx, 'ammo', ax, ay, b.w - lw - 20, () => (p.weapon.ammo === Infinity ? 999 : p.weapon.ammo), v => { debugTaint(); if (p.weapon.ammo !== Infinity) p.weapon.ammo = Math.max(0, v); }, 10, 0, 999); + dbgText(ctx, 'Equip swaps the live weapon; the old one goes to the holster unless it was the Bone Popper.', ax, ay + 32, '#6f5c58', hfont(8)); +} + +function dbgRenderItems(ctx, b) { + const lw = 340; + const list = dbgItemItems(); + const rect = { x: b.x, y: b.y, w: lw, h: b.h - 16 }; + const sel = debugPicker(ctx, rect, list, DBG.item, 24, true); + const iid = list[sel] && list[sel].key; + const item = iid && ITEMS[iid]; + const ax = b.x + lw + 20; + let ay = b.y + 6; + if (item) { + dbgText(ctx, item.name, ax, ay, ITEM_RARITY[item.rarity].color, hfont(10, true)); ay += 16; + dbgText(ctx, 'tier ' + (G.player.items[iid] || 0) + ' / ' + itemCap(iid) + ' · ' + item.rarity, ax, ay, '#b7a49c', hfont(8)); ay += 14; + wrapText(ctx, item.desc.toUpperCase(), ax, ay + 8, b.w - lw - 24, 12); ay += 40; + const act = (label, fn) => { + const btn = dbgButton(ctx, label, ax, ay, b.w - lw - 20, 24); ay += 30; + if (dbgClicked(btn._rect)) { debugTaint(); fn(); Sfx.menu(); } + }; + act('GIVE +1 (' + item.name + ')', () => giveItem(iid)); + act('REMOVE 1 TIER', () => { const n = G.player.items[iid] || 0; if (n > 1) { G.player.items[iid] = n - 1; debugRebuildStats(); } else if (n === 1) { delete G.player.items[iid]; debugRebuildStats(); } }); + act('REMOVE ALL', () => { if (G.player.items[iid]) { delete G.player.items[iid]; debugRebuildStats(); } }); + act('SET TO MAX TIER', () => { G.player.items[iid] = itemCap(iid); debugRebuildStats(); }); + ay += 4; + dbgNumRow(ctx, 'tier', ax, ay, b.w - lw - 20, () => (G.player.items[iid] || 0), v => { debugTaint(); const t = Math.round(clamp(v, 0, itemCap(iid))); if (t === 0) delete G.player.items[iid]; else G.player.items[iid] = t; debugRebuildStats(); }, 1, 0, itemCap(iid)); + ay += 34; + } + dbgText(ctx, 'PRESETS', ax, ay, '#8eb7bd', hfont(9, true)); ay += 8; + const pre = [ + { t: 'ONE OF EVERYTHING', fn: () => { for (const k in ITEMS) if (!G.player.items[k]) giveItem(k); } }, + { t: 'MAX EVERYTHING', fn: () => { for (const k in ITEMS) G.player.items[k] = itemCap(k); debugRebuildStats(); } }, + { t: 'CLEAR BUILD', fn: () => { G.player.items = {}; G.player.perks = []; debugRebuildStats(); } }, + ]; + for (const pr of pre) { + const btn = dbgButton(ctx, pr.t, ax, ay, b.w - lw - 20, 24); ay += 30; + if (dbgClicked(btn._rect)) { debugTaint(); pr.fn(); Sfx.menu(); } + } + dbgText(ctx, 'Removal / tier-set rebuilds stats from scratch. maxHp-lowering items replay in ownership order.', b.x, b.y + b.h + 14, '#6f5c58', hfont(8)); +} + +function dbgRenderPerks(ctx, b) { + const half = (b.w - 16) / 2; + const focusRight = Input.mx >= b.x + half + 16; // active-item column owns keys when hovered + const plist = dbgPerkItems(); + const rect = { x: b.x, y: b.y, w: half, h: b.h - 60 }; + const sel = debugPicker(ctx, rect, plist, DBG.perk, 24, !focusRight); + const pid = plist[sel] && plist[sel].key; + let ay = b.y + b.h - 52; + let btn = dbgButton(ctx, 'GRANT ' + (pid ? pid.toUpperCase() : '—'), b.x, ay, half, 26); + if (dbgClicked(btn._rect) && pid) { debugTaint(); const perk = PERKS.find(p => p.id === pid); if (perk) grantPerk(perk); } + ay += 32; + btn = dbgButton(ctx, 'REMOVE SELECTED', b.x, ay, half / 2 - 4, 24); + if (dbgClicked(btn._rect) && pid) { const i = G.player.perks.indexOf(pid); if (i !== -1) { G.player.perks.splice(i, 1); debugTaint(); debugRebuildStats(); } } + btn = dbgButton(ctx, 'CLEAR ALL PERKS', b.x + half / 2 + 4, ay, half / 2 - 4, 24); + if (dbgClicked(btn._rect)) { G.player.perks = []; debugTaint(); debugRebuildStats(); } + + const ax = b.x + half + 16; + dbgText(ctx, 'DRAFTED (' + (G.player.perks ? G.player.perks.length : 0) + ')', ax, b.y + 4, '#e8d0bd', hfont(9, true)); + let py = b.y + 22; + for (const pid2 of (G.player.perks || [])) { + const pk = PERKS.find(p => p.id === pid2); + dbgText(ctx, pk ? pk.name : pid2, ax, py, '#c8b6ac', hfont(8)); + py += 14; + } + py += 10; + dbgText(ctx, 'ACTIVE ITEM', ax, py, '#8eb7bd', hfont(9, true)); py += 6; + const alist = dbgActiveItems(); + const arect = { x: ax, y: py, w: half, h: b.h - (py - b.y) - 60 }; + const asel = debugPicker(ctx, arect, alist, DBG.active, 20, focusRight); + const aid = alist[asel] && alist[asel].key; + py = arect.y + arect.h + 20; + btn = dbgButton(ctx, 'EQUIP ' + (aid ? aid.toUpperCase() : '—'), ax, py, half / 2 - 4, 24); + if (dbgClicked(btn._rect) && aid) { debugTaint(); G.player.active = { iid: aid, charges: ACTIVES[aid].cost }; addToast('DEBUG', ACTIVES[aid].name); } + btn = dbgButton(ctx, 'FIRE', ax + half / 2 + 4, py, half / 2 - 4, 24); + if (dbgClicked(btn._rect)) { debugTaint(); useActive(true); } +} + +function dbgRenderWorld(ctx, b) { + const half = (b.w - 16) / 2; + let y = b.y + 4; + dbgText(ctx, 'FLOOR ' + G.floor + ' ROOM ' + (G.cur ? G.cur.gx + ',' + G.cur.gy + ' (' + G.cur.type + ')' : '—'), b.x, y, '#e8d0bd', hfont(10, true)); + y += 20; + dbgNumRow(ctx, 'jump to floor', b.x, y, half, () => DBG.floorJump, v => { DBG.floorJump = Math.max(1, Math.round(v)); }, 1, 1, 99); + let btn = dbgButton(ctx, 'WARP TO FLOOR ' + DBG.floorJump, b.x + half + 20, y, half, 22); + if (dbgClicked(btn._rect)) { debugTaint(); G.floor = DBG.floorJump; genFloor(G.floor); enterRoom(0, 0); Sfx.menu(); } + y += 30; + const navs = [ + { t: 'NEXT FLOOR', fn: () => nextFloor() }, + { t: 'TO BOSS ROOM', fn: () => { const r = Object.values(G.rooms).find(r => r.type === 'boss'); if (r) enterRoom(r.gx, r.gy); } }, + { t: 'TO ITEM ROOM', fn: () => { const r = Object.values(G.rooms).find(r => r.type === 'item'); if (r) enterRoom(r.gx, r.gy); } }, + { t: 'CLEAR ROOM', fn: () => { for (const e of G.enemies) if (!e.boss) e.hp = 0; if (G.cur) { G.cur.cleared = true; G.cur.wavesLeft = 0; } } }, + { t: 'KILL ALL', fn: () => { for (const e of G.enemies) e.hp = 0; } }, + { t: 'KILL BOSS', fn: () => { if (G.boss) G.boss.hp = 0; } }, + { t: 'REGEN FLOOR', fn: () => { genFloor(G.floor); enterRoom(0, 0); } }, + ]; + for (let i = 0; i < navs.length; i++) { + const nx = b.x + (i % 2) * (half + 20), ny = y + Math.floor(i / 2) * 28; + btn = dbgButton(ctx, navs[i].t, nx, ny, half, 24); + if (dbgClicked(btn._rect)) { debugTaint(); navs[i].fn(); Sfx.menu(); } + } + y += Math.ceil(navs.length / 2) * 28 + 8; + + dbgText(ctx, 'SPAWN ENEMY', b.x, y, '#8eb7bd', hfont(9, true)); + y += 4; + const focusBoss = Input.mx >= b.x + half + 20; // boss column owns keys when hovered + const elist = dbgEnemyItems(); + const erect = { x: b.x, y, w: half, h: b.y + b.h - y - 56 }; + const esel = debugPicker(ctx, erect, elist, DBG.enemy, 20, !focusBoss); + const etype = elist[esel] && elist[esel].key; + let ey = erect.y + erect.h + 20; + dbgNumRow(ctx, 'count', b.x, ey, half, () => DBG.spawnCount, v => { DBG.spawnCount = Math.round(clamp(v, 1, 40)); }, 1, 1, 40); + const et = dbgToggle(ctx, 'ELITE', b.x, ey + 24, 60, DBG.spawnElite); + if (dbgClicked(et.rect)) DBG.spawnElite = !DBG.spawnElite; + btn = dbgButton(ctx, 'SPAWN ' + DBG.spawnCount + '× ' + (etype ? etype.toUpperCase() : '—'), b.x + 70, ey + 20, half - 70, 24); + if (dbgClicked(btn._rect) && etype) { + debugTaint(); + for (let i = 0; i < DBG.spawnCount; i++) { + if (G.enemies.length >= MAX_ENEMIES) break; + const pos = spawnPosAwayFromPlayer(); + const e = makeEnemy(etype, pos.x, pos.y, G.floor, DBG.spawnElite); + G.enemies.push(e); + } + Sfx.menu(); + } + + const bx = b.x + half + 20; + dbgText(ctx, 'SPAWN BOSS', bx, y - 4, '#8eb7bd', hfont(9, true)); + const blist = dbgBossItems(); + const brect = { x: bx, y, w: half, h: b.y + b.h - y - 88 }; + const bsel = debugPicker(ctx, brect, blist, DBG.boss, 20, focusBoss); + const bkind = blist[bsel] && blist[bsel].key; + btn = dbgButton(ctx, 'SPAWN ' + (bkind ? bkind.toUpperCase() : '—'), bx, brect.y + brect.h + 18, half, 24); + if (dbgClicked(btn._rect) && bkind) { + debugTaint(); + const idx = BOSS_DEFS.findIndex(d => d.kind === bkind); + spawnBoss(G.floor, idx >= 0 ? idx : undefined); + Music.playBoss(bkind); + Sfx.menu(); + } + const kinds = ['heart', 'ammo', 'gem']; + btn = dbgButton(ctx, 'SPAWN PICKUP AT CURSOR', bx, brect.y + brect.h + 48, half / 2 - 4, 24); + if (dbgClicked(btn._rect)) { + debugTaint(); + spawnPickup(kinds[DBG.pickupSel % kinds.length], dbgCursor().x, dbgCursor().y, kinds[DBG.pickupSel % kinds.length] === 'gem' ? { v: 5 } : undefined); + Sfx.menu(); + } + const pk = dbgButton(ctx, 'type: ' + kinds[DBG.pickupSel % 3], bx + half / 2 + 4, brect.y + brect.h + 48, half / 2 - 4, 24); + if (dbgClicked(pk._rect)) { DBG.pickupSel++; Sfx.menu(); } +} + +function dbgRenderPressure(ctx, b) { + let y = b.y + 4; + dbgText(ctx, 'PRESSURE ' + G.pressure.toFixed(3) + ' DIAL ' + G.pressureDial + ' LOCKED ' + (G.debugFlags.pressureLock ? 'YES' : 'no'), b.x, y, '#e8d0bd', hfont(11, true)); + y += 24; + const half = (b.w - 16) / 2; + dbgNumRow(ctx, 'set pressure', b.x, y, half, () => G.pressure, v => { debugTaint(); G.pressure = clamp(v, PRESSURE_MIN, PRESSURE_MAX); }, 0.05, PRESSURE_MIN, PRESSURE_MAX, v => v.toFixed(3)); + dbgNumRow(ctx, 'set dial', b.x + half + 20, y, half, () => G.pressureDial, v => { debugTaint(); setPressureDial(Math.round(v)); }, 1, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX); + y += 30; + const lk = dbgToggle(ctx, 'FREEZE PRESSURE', b.x, y, 180, !!G.debugFlags.pressureLock, 'applyPressureDelta no-ops'); + if (dbgClicked(lk.rect)) { G.debugFlags.pressureLock = !lk.on; if (!lk.on) debugTaint(); Sfx.menu(); } + y += 40; + dbgText(ctx, 'LIVE VALUES (current dial)', b.x, y, '#8eb7bd', hfont(9, true)); y += 16; + const rows = [ + ['pressureGainUnits', pressureGainUnits(G.pressureDial).toFixed(3) + ' → gain ' + pressureGain().toFixed(4) + '/room'], + ['pressureDropUnits', pressureDropUnits(G.pressureDial).toFixed(3) + ' → relief scale ' + pressureDropScale().toFixed(3)], + ['G.streak', String(G.streak)], + ['recentHits (12s)', String((G.recentHits || []).length)], + ['abattoirEngine', String(G.player.stats.abattoirEngine)], + ]; + for (const r of rows) { dbgText(ctx, r[0], b.x, y, '#b7a49c', hfont(8)); dbgText(ctx, r[1], b.x + 220, y, '#e8d0bd', hfont(8)); y += 15; } + y += 8; + dbgText(ctx, 'Pressure is applied at spawn time; existing enemies are not re-scaled.', b.x, y, '#6f5c58', hfont(8)); +} + +function dbgRenderStats(ctx, b) { + const p = G.player; + const def = defaultPlayerStats(); + const keys = Object.keys(def); + const colW = (b.w - 40) / 2; + const lineH = 13; + const perCol = Math.floor((b.h - 90) / lineH); + const vis = perCol * 2; + const maxScroll = Math.max(0, keys.length - vis); + DBG.statScroll = clamp(DBG.statScroll || 0, 0, maxScroll); + if (keyPressed('arrowdown')) DBG.statScroll = Math.min(maxScroll, DBG.statScroll + 1); + if (keyPressed('arrowup')) DBG.statScroll = Math.max(0, DBG.statScroll - 1); + if (keyPressed('pagedown')) DBG.statScroll = Math.min(maxScroll, DBG.statScroll + perCol); + if (keyPressed('pageup')) DBG.statScroll = Math.max(0, DBG.statScroll - perCol); + + let y = b.y + 2; + dbgText(ctx, 'powerScore ' + powerScore().toFixed(2) + ' floor ' + G.floor + ' time ' + G.time.toFixed(1) + 's fps ' + DBG.fps, b.x, y, '#e8d0bd', hfont(9, true)); + y += 16; + dbgText(ctx, 'enemies ' + G.enemies.length + ' bullets ' + G.bullets.length + ' ebullets ' + G.ebullets.length + ' pickups ' + G.pickups.length + ' parts ' + G.parts.length + ' hazards ' + G.hazards.length, b.x, y, '#b7a49c', hfont(8)); + y += 18; + const colHeader = ['KEY', 'VALUE', 'Δ']; + for (let c = 0; c < 2; c++) { + const cx = b.x + c * (colW + 20); + dbgText(ctx, colHeader[0], cx, y, '#8eb7bd', hfont(8, true)); + dbgText(ctx, colHeader[1], cx + colW - 130, y, '#8eb7bd', hfont(8, true)); + dbgText(ctx, colHeader[2], cx + colW - 40, y, '#8eb7bd', hfont(8, true)); + } + y += 14; + const startY = y; + for (let i = 0; i < vis; i++) { + const idx = DBG.statScroll + i; + if (idx >= keys.length) break; + const k = keys[idx]; + const col = Math.floor(i / perCol); + const row = i % perCol; + const cx = b.x + col * (colW + 20); + const ry = startY + row * lineH; + const cur = p.stats[k]; + const dv = def[k]; + const changed = cur !== dv; + dbgText(ctx, k, cx, ry + 10, changed ? '#ffd7a0' : '#a8968e', hfont(8, changed)); + dbgText(ctx, typeof cur === 'number' ? (Math.abs(cur) < 10 ? cur.toFixed(3) : Math.round(cur)) : String(cur), cx + colW - 130, ry + 10, changed ? '#ffd060' : '#c8b6ac', hfont(8, changed)); + if (changed) dbgText(ctx, typeof cur === 'number' ? '+' + (cur - dv).toFixed(2) : '*', cx + colW - 40, ry + 10, '#7dd87d', hfont(8)); + } + dbgText(ctx, 'highlighted = differs from defaultPlayerStats() · [↑↓/pg] scroll (' + (DBG.statScroll + 1) + '-' + Math.min(keys.length, DBG.statScroll + vis) + '/' + keys.length + ')', b.x, b.y + b.h + 14, '#6f5c58', hfont(8)); +} + +function dbgRenderMisc(ctx, b) { + let y = b.y + 4; + const half = (b.w - 16) / 2; + dbgText(ctx, 'TIME / RENDER', b.x, y, '#8eb7bd', hfont(9, true)); y += 6; + const ts = [0.25, 1, 4]; + for (let i = 0; i < ts.length; i++) { + const btn = dbgButton(ctx, '×' + ts[i], b.x + i * 70, y, 60, 24, { active: (G.debugTimescale || 1) === ts[i] }); + if (dbgClicked(btn._rect)) { G.debugTimescale = ts[i]; if (ts[i] !== 1) debugTaint(); Sfx.menu(); } + } + const fr = dbgToggle(ctx, 'FREEZE', b.x + 230, y, 90, !!G.debugFlags.freeze); + if (dbgClicked(fr.rect)) { G.debugFlags.freeze = !fr.on; if (!fr.on) debugTaint(); Sfx.menu(); } + const st = dbgButton(ctx, 'STEP 1F', b.x + 330, y, 90, 24, { active: false }); + if (dbgClicked(st._rect) && G.debugFlags.freeze) { debugTaint(); G.debugFrameStep = true; Sfx.menu(); } + y += 34; + const viz = [ + { k: 'showCollide', label: 'COLLISION CIRCLES' }, + { k: 'showArena', label: 'ARENA BOUNDS' }, + { k: 'showHpNums', label: 'ENEMY HP NUMBERS' }, + { k: 'showHazards', label: 'HAZARD/TELEGRAPH' }, + { k: 'showMagnet', label: 'MAGNET RADIUS' }, + ]; + for (let i = 0; i < viz.length; i++) { + const vx = b.x + (i % 3) * 200, vy = y + Math.floor(i / 3) * 28; + const t = dbgToggle(ctx, viz[i].label, vx, vy, 160, !!G.debugFlags[viz[i].k]); + if (dbgClicked(t.rect)) { G.debugFlags[viz[i].k] = !t.on; Sfx.menu(); } // read-only: no taint + } + y += 2 * 28 + 8; + dbgText(ctx, 'RUN VALUES', b.x, y, '#8eb7bd', hfont(9, true)); y += 4; + dbgNumRow(ctx, 'score', b.x, y, half, () => G.score, v => { debugTaint(); G.score = Math.max(0, Math.round(v)); }, 100, 0, 9999999); + dbgNumRow(ctx, 'kills', b.x + half + 20, y, half, () => G.kills, v => { debugTaint(); G.kills = Math.max(0, Math.round(v)); }, 10, 0, 99999); + y += 30; + dbgNumRow(ctx, 'streak', b.x, y, half, () => G.streak, v => { debugTaint(); G.streak = Math.max(0, Math.round(v)); }, 1, 0, 99); + const go = dbgButton(ctx, 'TRIGGER GAME OVER', b.x + half + 20, y, half, 22); + if (dbgClicked(go._rect)) { debugTaint(); G.player.hp = 0; gameOver(); } + y += 34; + dbgText(ctx, 'FX / AUDIO TEST', b.x, y, '#8eb7bd', hfont(9, true)); y += 4; + const fx = [ + { t: 'SHAKE', fn: () => addShake(12) }, { t: 'FLASH', fn: () => { G.flash = 0.6; } }, + { t: 'TOAST', fn: () => addToast('DEBUG TOAST', 'subtitle', 3) }, { t: 'SHOOT SFX', fn: () => Sfx.shoot(WEAPONS.repeater) }, + { t: 'BOSS ROAR', fn: () => Sfx.boss({ bossKind: 'bonesaw', x: G.player.x, y: G.player.y }, 'roar') }, { t: 'PERK SFX', fn: () => Sfx.perk() }, + ]; + for (let i = 0; i < fx.length; i++) { + const fx2 = fx[i], bx = b.x + (i % 3) * 170, by = y + Math.floor(i / 3) * 26; + const btn = dbgButton(ctx, fx2.t, bx, by, 160, 22); + if (dbgClicked(btn._rect)) { fx2.fn(); } + } + y += 2 * 26 + 10; + dbgText(ctx, 'PIN OVERLAY (live)', b.x, y, '#8eb7bd', hfont(9, true)); y += 4; + const pin = dbgToggle(ctx, 'PIN PANEL + LIVE WORLD', b.x, y, 260, G.debugPin, 'game keeps running behind the panel'); + if (dbgClicked(pin.rect)) { G.debugPin = !pin.on; Sfx.menu(); } + y += 34; + dbgText(ctx, G.debugUsed ? 'RUN TAINTED — best score will NOT be saved' : 'run clean so far (mutating actions taint)', b.x, y, G.debugUsed ? '#e02945' : '#7dd87d', hfont(8, true)); +} + +const DEBUG_PAGES = [ + { title: 'PLAYER', render: dbgRenderPlayer }, + { title: 'WEAPONS', render: dbgRenderWeapons }, + { title: 'ITEMS', render: dbgRenderItems }, + { title: 'PERKS+ACTIVE', render: dbgRenderPerks }, + { title: 'WORLD', render: dbgRenderWorld }, + { title: 'PRESSURE', render: dbgRenderPressure }, + { title: 'STATS', render: dbgRenderStats }, + { title: 'MISC', render: dbgRenderMisc }, +]; + +// ---- top-level update / draw ---- +function updateDebug() { + // infinite-ammo upkeep (also runs while pinned so live play benefits) + if (G.debugFlags.infAmmo && G.player && G.player.weapon.ammo !== Infinity) G.player.weapon.ammo = WEAPONS[G.player.weapon.id].ammo; + + // wall-clock fps counter for the stats tab (independent of timescale/freeze) + DBG.fpsFrames++; + const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); + if (!DBG.fpsT) DBG.fpsT = nowMs; + if (nowMs - DBG.fpsT >= 500) { DBG.fps = Math.round(DBG.fpsFrames * 1000 / (nowMs - DBG.fpsT)); DBG.fpsFrames = 0; DBG.fpsT = nowMs; } + + if (keyPressed('`', '~', 'escape')) { G.mode = G.debugReturn || 'play'; Sfx.menu(); return; } + if (keyPressed('arrowright')) { G.debugPage = (G.debugPage + 1) % DEBUG_PAGES.length; Sfx.menu(); } + else if (keyPressed('arrowleft')) { G.debugPage = (G.debugPage + DEBUG_PAGES.length - 1) % DEBUG_PAGES.length; Sfx.menu(); } + else for (let i = 0; i < DEBUG_PAGES.length; i++) if (keyPressed(String(i + 1))) { G.debugPage = i; Sfx.menu(); break; } + if (Input.mpressed) { + if (inRect(Input.mx, Input.my, debugCloseRect())) { G.mode = G.debugReturn || 'play'; Sfx.menu(); return; } + for (let i = 0; i < DEBUG_PAGES.length; i++) if (inRect(Input.mx, Input.my, debugTabRect(i))) { G.debugPage = i; Sfx.menu(); break; } + } +} + +function drawDebug(ctx) { + ctx.fillStyle = G.debugPin ? 'rgba(4,2,3,0.55)' : 'rgba(4,2,3,0.9)'; + ctx.fillRect(0, 0, W, H); + const px = debugPanelX(), py = DEBUG_PANEL.y, pw = DEBUG_PANEL.w, ph = DEBUG_PANEL.h; + drawPixelPanel(ctx, px, py, pw, ph, { + accent: '#c0392b', border: 'rgba(120,50,60,0.95)', fill: 'rgba(10,5,8,0.97)', + blood: true, seed: G.debugPage + 31, bloodAlpha: 0.22, + }); + ctx.textAlign = 'center'; + ctx.fillStyle = '#e2908a'; ctx.font = hfont(9, true); + ctx.fillText('BUTCHER DEBUG CONSOLE' + (G.debugPin ? ' · PINNED / LIVE' : ' · PAUSED'), W / 2, py + 40); + ctx.fillStyle = '#f0e5d7'; ctx.font = hfont(18, true); + ctx.fillText('[' + (G.debugPage + 1) + '] ' + DEBUG_PAGES[G.debugPage].title, W / 2, py + 66); + const cr = debugCloseRect(); + drawPixelTag(ctx, '[X]', cr.x, cr.y, { width: cr.w, height: cr.h, color: '#e8bf47', accent: '#9d7726' }); + for (let i = 0; i < DEBUG_PAGES.length; i++) { + const r = debugTabRect(i); + const active = i === G.debugPage; + drawPixelTag(ctx, (i + 1) + ' ' + DEBUG_PAGES[i].title, r.x, r.y, { + width: r.w, height: r.h, + color: active ? '#ffd7a0' : (inRect(Input.mx, Input.my, r) ? '#cdbdb2' : '#7d6a68'), + accent: active ? '#c0392b' : '#3c2f38', + fill: active ? 'rgba(40,14,18,0.95)' : 'rgba(12,7,11,0.85)', + font: hfont(7, true), + }); + } + DEBUG_PAGES[G.debugPage].render(ctx, debugBodyRect()); + ctx.textAlign = 'center'; + drawPixelTag(ctx, '[` / ESC] CLOSE [← →] TABS [1-' + DEBUG_PAGES.length + '] JUMP ' + (G.debugUsed ? 'TAINTED' : 'CLEAN'), W / 2 - 260, py + ph - 34, { + width: 520, height: 22, color: G.debugUsed ? '#e02945' : '#8f7f79', accent: '#4d3a42', font: hfont(8, true), + }); +} + +// live world overlays (drawn during play when devMode is on and toggles are set) +function drawDebugOverlays(ctx) { + if (!G.devMode) return; + const f = G.debugFlags; + if (!(f.showCollide || f.showArena || f.showHpNums || f.showHazards || f.showMagnet)) return; + const p = G.player; + ctx.save(); + ctx.lineWidth = 1; + if (f.showArena) { + ctx.strokeStyle = 'rgba(90,200,220,0.8)'; + ctx.strokeRect(G.arena.x0, G.arena.y0, G.arena.w, G.arena.h); + } + if (f.showCollide) { + ctx.strokeStyle = 'rgba(120,220,120,0.7)'; + ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, TAU); ctx.stroke(); + ctx.strokeStyle = 'rgba(240,120,120,0.6)'; + for (const e of G.enemies) { ctx.beginPath(); ctx.arc(e.x, e.y, e.r, 0, TAU); ctx.stroke(); } + } + if (f.showHpNums) { + ctx.font = '9px monospace'; ctx.textAlign = 'center'; ctx.fillStyle = '#ffd0a0'; + for (const e of G.enemies) if (e.hp > 0) ctx.fillText(Math.ceil(e.hp) + '/' + Math.ceil(e.maxHp), e.x, e.y - e.r - 14); + } + if (f.showHazards) { + ctx.strokeStyle = 'rgba(230,180,60,0.7)'; + for (const hz of G.hazards) { ctx.beginPath(); ctx.arc(hz.x, hz.y, hz.r, 0, TAU); ctx.stroke(); } + for (const tg of G.telegraphs) { ctx.beginPath(); ctx.arc(tg.x, tg.y, tg.r, 0, TAU); ctx.stroke(); } + } + if (f.showMagnet) { + ctx.strokeStyle = 'rgba(140,160,255,0.5)'; + ctx.beginPath(); ctx.arc(p.x, p.y, 90 * (p.stats.magnet || 1), 0, TAU); ctx.stroke(); + } + ctx.restore(); +} + +// compact always-on-top readout when pinned but panel is closed +function drawDebugPin(ctx) { + const p = G.player; + const lines = [ + 'DEBUG ' + (G.debugUsed ? 'TAINTED' : 'clean'), + 'hp ' + p.hp + '/' + p.stats.maxHp + ' pwr ' + powerScore().toFixed(1) + ' press ' + G.pressure.toFixed(2), + 'wpn ' + WEAPONS[p.weapon.id].name + ' en ' + G.enemies.length + ' fl ' + G.floor, + ]; + const w = 300, h = 18 + lines.length * 14; + const x = W - w - 8, y = 8; + ctx.fillStyle = 'rgba(8,4,6,0.78)'; ctx.fillRect(x, y, w, h); + ctx.strokeStyle = 'rgba(192,57,43,0.7)'; ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); + ctx.textAlign = 'left'; ctx.font = hfont(8, true); + for (let i = 0; i < lines.length; i++) { + ctx.fillStyle = i === 0 ? (G.debugUsed ? '#e02945' : '#7dd87d') : '#cdbdb2'; + ctx.fillText(lines[i], x + 8, y + 15 + i * 14); + } +} diff --git a/js/enemies.js b/js/enemies.js index eae2c12..f11d02b 100644 --- a/js/enemies.js +++ b/js/enemies.js @@ -144,6 +144,7 @@ function damageEnemy(e, dmg, ang, knockback, opts) { if (e.hp <= 0) return true; if (e.phased) return false; if (!(dmg > 0)) return false; // NaN / zero / negative damage does nothing + if (G.debugFlags && G.debugFlags.ohko) dmg = 1e9; // debug: one-shot-kill, still respects plates // crit roll const p = G.player; const o = opts || {}; diff --git a/js/hud.js b/js/hud.js index 444f84c..92db1fc 100644 --- a/js/hud.js +++ b/js/hud.js @@ -16,6 +16,7 @@ function drawHUD(ctx) { ctx.textAlign = 'left'; ctx.fillStyle = '#d9c9bc'; ctx.font = 'bold 10px monospace'; ctx.fillText('BUTCHER // VITALS', 18, 23); + if (G.debugUsed) { ctx.fillStyle = '#e02945'; ctx.textAlign = 'left'; ctx.fillText('[DEBUG]', 128, 23); } ctx.fillStyle = '#d7a934'; ctx.textAlign = 'right'; ctx.fillText('LV ' + String(p.level).padStart(2, '0'), 300, 23); diff --git a/js/items.js b/js/items.js index ba9a758..4c6f3e2 100644 --- a/js/items.js +++ b/js/items.js @@ -243,9 +243,9 @@ const ACTIVES = { } }, }; -function useActive() { +function useActive(force) { const p = G.player; - if (!p.active || G.mode !== 'play') return; + if (!p.active || (G.mode !== 'play' && !force)) return; const a = ACTIVES[p.active.iid]; if (!a) return; if (p.active.charges < a.cost) { diff --git a/js/main.js b/js/main.js index 20f82e3..0e4fff6 100644 --- a/js/main.js +++ b/js/main.js @@ -21,6 +21,7 @@ function init() { canvas = document.getElementById('game'); ctx = canvas.getContext('2d'); ctx.imageSmoothingEnabled = false; + G.devMode = debugEnabled(); initInput(canvas); Sprites.load(); fitCanvas(); @@ -101,6 +102,7 @@ function resetRun() { G.deathLockT = 0; G.pressure = 1; G.streak = 0; G.roomDamaged = false; G.roomEnterT = 0; G.recentHits = []; G.shake = 0; G.flash = 0; + G.debugUsed = false; G.debugFlags = {}; G.debugTimescale = 1; G.debugFrameStep = false; initPlayer(); genFloor(1); enterRoom(0, 0); @@ -145,7 +147,7 @@ function gameOver() { G.deathLockT = DEATH_LOCK; spawnPlayerGore(G.player.x, G.player.y); Sfx.playerDeath(); - if (G.score > (G.best || 0)) { + if (!G.debugUsed && G.score > (G.best || 0)) { G.best = G.score; try { localStorage.setItem('meatslicer_best', String(G.best)); } catch (e) {} } @@ -153,8 +155,13 @@ function gameOver() { } function loop(now) { - const dt = Math.min((now - lastT) / 1000 || 0.016, 0.05); + let dt = Math.min((now - lastT) / 1000 || 0.016, 0.05); lastT = now; + dt *= (G.debugTimescale || 1); + if (G.debugFlags.freeze) { + if (G.debugFrameStep) { G.debugFrameStep = false; dt = 1 / 60; } + else dt = 0; + } update(dt); draw(); clearInputEdges(); @@ -196,6 +203,7 @@ function update(dt) { } case 'play': G.time += dt; + if (G.devMode && keyPressed('`', '~')) { G.debugReturn = 'play'; G.mode = 'debug'; Sfx.menu(); break; } if (keyPressed('p', 'escape')) { G.mode = 'pause'; G.confirmAction = null; G.confirmT = 0; Sfx.stopAllLoops(); Sfx.menu(); break; } if (keyPressed('n')) Music.cycle(1); if (keyPressed('r')) swapWeapon(); @@ -215,8 +223,26 @@ function update(dt) { updateLevelup(); updateParticles(dt); break; + case 'debug': + updateDebug(); + // pinned mode lets the world keep running behind the panel + if (G.debugPin) { + G.time += dt; + // mask trigger input so clicking panel buttons doesn't fire the weapon + const md = Input.mdown, mp = Input.mpressed, mr = Input.mreleased; + Input.mdown = Input.mpressed = Input.mreleased = false; + updatePlayer(dt); + Input.mdown = md; Input.mpressed = mp; Input.mreleased = mr; + updateRoom(dt); updateEnemies(dt); updateTelegraphs(dt); + updateBullets(dt); updateHazards(dt); updatePickups(dt); updateParticles(dt); + drawToastsUpdate(dt); + } else { + updateParticles(dt); + } + break; case 'pause': { if (G.pauseHelp) { updatePauseHelp(); break; } + if (G.devMode && keyPressed('`', '~')) { G.debugReturn = 'pause'; G.mode = 'debug'; Sfx.menu(); break; } const menuClick = Input.mpressed && inRect(Input.mx, Input.my, PAUSE_MENU_BUTTON); const exitClick = Input.mpressed && inRect(Input.mx, Input.my, PAUSE_EXIT_BUTTON); if (keyPressed('q') || menuClick) { confirmAction('menu', returnToMenu); break; } @@ -313,6 +339,8 @@ function draw() { if (G.mode === 'levelup') drawLevelup(ctx); else if (G.mode === 'pause') { drawPause(ctx); if (G.pauseHelp) drawPauseHelp(ctx); } else if (G.mode === 'gameover') drawGameOver(ctx); + else if (G.mode === 'debug') drawDebug(ctx); + if (G.devMode) { drawDebugOverlays(ctx); if (G.debugPin && G.mode !== 'debug') drawDebugPin(ctx); } } function drawMenu(ctx) { @@ -536,7 +564,9 @@ function drawGameOver(ctx) { ctx.fillStyle = i === 2 ? '#e2b742' : '#eee0d3'; ctx.font = 'bold 18px monospace'; ctx.fillText(String(stats[i][1]).padStart(i === 2 ? 6 : 2, '0'), x, 329); } - if (G.score >= (G.best || 0) && G.score > 0) { + if (G.debugUsed) { + drawPixelTag(ctx, '[ DEBUG RUN — SCORE NOT RECORDED ]', W / 2 - 128, 367, { width: 256, height: 26, color: '#e02945', accent: '#77202c' }); + } else if (G.score >= (G.best || 0) && G.score > 0) { drawPixelTag(ctx, 'NEW BEST CUT', W / 2 - 68, 367, { width: 136, height: 26, color: '#f1cb53', accent: '#b38b2e' }); } drawPixelPanel(ctx, W / 2 - 140, 414, 280, 50, { diff --git a/js/perks.js b/js/perks.js index a17f1c4..a9facc2 100644 --- a/js/perks.js +++ b/js/perks.js @@ -67,6 +67,7 @@ function openPerkDraft() { function grantPerk(perk) { perk.apply(G.player.stats, G.player); + if (G.player.perks) G.player.perks.push(perk.id); addToast(perk.name, perk.desc); Sfx.perk(); } diff --git a/js/player.js b/js/player.js index cb0493e..39f89f7 100644 --- a/js/player.js +++ b/js/player.js @@ -1,5 +1,27 @@ // ---- the butcher (player) ---- +function defaultPlayerStats() { + return { + dmgMul: 1, rateMul: 1, speedMul: 1, shotSpeedMul: 1, rangeMul: 1, + pierce: 0, bounce: 0, homing: 0, split: 0, fan: 0, rear: 0, splinter: 0, + orbitals: 0, lifestealChance: 0, explodeOnKill: 0, + armor: 0, luck: 0, magnet: 1, magnetPull: 1, xpMul: 1, crit: 0.05, critMul: 2, bloodlust: 0, + bleed: 0, igniteChance: 0, slowOnHit: 0, stunOnHit: 0, acidOnHit: 0, pullOnHit: 0, + chain: 0, knockbackMul: 1, invBonus: 0, ammoEff: 1, ammoPickupMul: 1, + roomHeal: 0, roomHealChance: 0, thorns: 0, retaliate: 0, sizeMul: 1, mortar: 0, frenzy: 0, + orbSpeedMul: 1, orbDmgMul: 1, overShield: 0, + rerolls: 0, rerollPerLevel: 0, + // phase-3 conditional / hook passives + dmgLiveMul: 1, executeBonus: 0, burnDamageBonus: 0, choirEvery: 0, sawboneCoil: 0, + gluttonGut: 0, slaughterRhythm: 0, painEngine: 0, thresherPlate: 0, bloodMoat: 0, + ironLung: 0, meatHook: 0, bloodDebt: 0, + // phase-4 legendaries + secondSkin: 0, twinSidearm: 0, crimsonMetronome: 0, abattoirEngine: 0, + goreCrown: 0, thousandTeeth: 0, hollowFather: 0, theLastCut: 0, meatGrinder: 0, + maxHp: 6, + }; +} + function initPlayer() { G.player = { x: W / 2, y: H / 2, r: 20, @@ -11,27 +33,10 @@ function initPlayer() { holstered: null, // a special weapon set aside while the pistol is out level: 1, xp: 0, items: {}, // iid -> quality tier (1..3) + perks: [], // perk ids drafted this run, in grant order active: null, // { iid, charges } — active item, room-clear charged orbAng: 0, - stats: { - dmgMul: 1, rateMul: 1, speedMul: 1, shotSpeedMul: 1, rangeMul: 1, - pierce: 0, bounce: 0, homing: 0, split: 0, fan: 0, rear: 0, splinter: 0, - orbitals: 0, lifestealChance: 0, explodeOnKill: 0, - armor: 0, luck: 0, magnet: 1, magnetPull: 1, xpMul: 1, crit: 0.05, critMul: 2, bloodlust: 0, - bleed: 0, igniteChance: 0, slowOnHit: 0, stunOnHit: 0, acidOnHit: 0, pullOnHit: 0, - chain: 0, knockbackMul: 1, invBonus: 0, ammoEff: 1, ammoPickupMul: 1, - roomHeal: 0, roomHealChance: 0, thorns: 0, retaliate: 0, sizeMul: 1, mortar: 0, frenzy: 0, - orbSpeedMul: 1, orbDmgMul: 1, overShield: 0, - rerolls: 0, rerollPerLevel: 0, - // phase-3 conditional / hook passives - dmgLiveMul: 1, executeBonus: 0, burnDamageBonus: 0, choirEvery: 0, sawboneCoil: 0, - gluttonGut: 0, slaughterRhythm: 0, painEngine: 0, thresherPlate: 0, bloodMoat: 0, - ironLung: 0, meatHook: 0, bloodDebt: 0, - // phase-4 legendaries - secondSkin: 0, twinSidearm: 0, crimsonMetronome: 0, abattoirEngine: 0, - goreCrown: 0, thousandTeeth: 0, hollowFather: 0, theLastCut: 0, meatGrinder: 0, - maxHp: 6, - }, + stats: defaultPlayerStats(), }; } @@ -282,8 +287,11 @@ function pressureRelief(dmgTaken) { } function hurtPlayer(dmg, ang, attacker) { + if (G.debugFlags && G.debugFlags.god) return; const p = G.player; - if (p.invT > 0 || p.hp <= 0 || G.mode !== 'play') return; + // live sim = play mode, or the pinned debug console running the world + const live = G.mode === 'play' || (G.mode === 'debug' && G.debugPin); + if (p.invT > 0 || p.hp <= 0 || !live) return; // Iron Lung: the first hit each room is blocked if (p.ironLungReady) { p.ironLungReady = false; @@ -322,7 +330,7 @@ function hurtPlayer(dmg, ang, attacker) { G.roomDamaged = true; G.recentHits = (G.recentHits || []).filter(t => G.time - t < 12); G.recentHits.push(G.time); - G.pressure = clamp(G.pressure - pressureRelief(reduced), PRESSURE_MIN, PRESSURE_MAX); + applyPressureDelta(-pressureRelief(reduced)); G.streak = 0; } if (attacker && attacker.hp > 0 && p.stats.thorns > 0) { diff --git a/js/rooms.js b/js/rooms.js index cbee65f..cd2b544 100644 --- a/js/rooms.js +++ b/js/rooms.js @@ -157,7 +157,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 * pressureDropScale()); + applyPressureDelta(-decay * dt * pressureDropScale()); } } G.recentHits = (G.recentHits || []).filter(t => G.time - t < 12); @@ -215,7 +215,7 @@ function updateRoom(dt) { function recordRoomClear(room) { if (!G.roomDamaged) { G.streak++; - G.pressure = clamp(G.pressure + pressureGain(), PRESSURE_MIN, PRESSURE_MAX); + applyPressureDelta(pressureGain()); } else { G.streak = 0; } diff --git a/js/sfx.js b/js/sfx.js index 4c68c69..efca4ea 100644 --- a/js/sfx.js +++ b/js/sfx.js @@ -137,7 +137,8 @@ const Sfx = { const gain = this.ctx.createGain(); const p = G.player; let pan = 0, distanceGain = 1; - if (pos && p) { + // guard against non-finite coords (e.g. a caller passing { bossKind } with no x/y) + if (pos && p && Number.isFinite(pos.x) && Number.isFinite(pos.y)) { pan = clamp((pos.x - p.x) / (W / 2), -1, 1) * 0.75; const maxD = Math.hypot(W, H); distanceGain = 1 - 0.35 * clamp(dist(pos.x, pos.y, p.x, p.y) / maxD, 0, 1); diff --git a/js/sfxbank.js b/js/sfxbank.js index 055de7b..8ef54b2 100644 --- a/js/sfxbank.js +++ b/js/sfxbank.js @@ -111,7 +111,7 @@ const SfxBank = { if (this.buffers.has(name) || this.pending.has(name) || this.failed.has(name)) return; const file = this.FILES[name]; if (!file || !this.ctx || typeof fetch !== 'function') return; - const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=45') + const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=49') .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))) diff --git a/js/sprites.js b/js/sprites.js index ac84138..8b3eef0 100644 --- a/js/sprites.js +++ b/js/sprites.js @@ -86,7 +86,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=45'; + img.src = 'assets/' + name + '.png?v=49'; } }, diff --git a/js/state.js b/js/state.js index 800f7f5..4d59a2b 100644 --- a/js/state.js +++ b/js/state.js @@ -38,6 +38,13 @@ const G = { muted: false, pauseHelp: false, helpPage: 0, + // debug console state (see js/debug.js); devMode is set in init() + debugUsed: false, + debugFlags: {}, + debugPage: 0, + debugPin: false, + debugTimescale: 1, + debugFrameStep: false, sfxVol: 0.45, musicVol: 0.55, hudAlpha: 1, @@ -97,3 +104,11 @@ function addScore(n) { const scaled = Math.round(n * (G.pressure || 1)); G.score += n > 0 ? Math.max(1, scaled) : scaled; } + +// Single mutation point for G.pressure. Three sites (hurt relief, room decay, +// room-clear gain) route here so the debug pressure-lock can freeze difficulty +// in one place. Each site already clamped to the same bounds. +function applyPressureDelta(d) { + if (G.debugFlags && G.debugFlags.pressureLock) return; + G.pressure = clamp(G.pressure + d, PRESSURE_MIN, PRESSURE_MAX); +} diff --git a/package.json b/package.json index 990687f..835ccca 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "meatslicer", "productName": "MeatSlicer", - "version": "0.4.0", + "version": "0.5.0", "private": true, "description": "A Butcher's Descent - clear, harvest, descend.", "main": "desktop/main.js", diff --git a/play-dev.bat b/play-dev.bat new file mode 100644 index 0000000..2239313 --- /dev/null +++ b/play-dev.bat @@ -0,0 +1,8 @@ +@echo off +rem MeatSlicer dev launcher - enables the hidden debug console (?dev=1). +cd /d %~dp0 +start "MeatSlicer server" /min python tools\serve_game.py --port 8123 +timeout /t 1 /nobreak >nul +start "" "http://localhost:8123/?dev=1" +echo MeatSlicer running at http://localhost:8123/?dev=1 [debug console: ` key] +echo Close the minimized "MeatSlicer server" window to stop it. diff --git a/test/smoke.js b/test/smoke.js index 28263ec..e9052f3 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -54,14 +54,14 @@ vm.createContext(sandbox); // load game scripts in index.html order const files = ['utils', 'state', 'input', 'sfx', 'sfxbank', 'audio', 'sprites', 'particles', 'weapons', - 'bullets', 'pickups', 'items', 'perks', 'enemies', 'bosses', 'rooms', 'player', 'hud', 'help', 'main']; + 'bullets', 'pickups', 'items', 'perks', 'enemies', 'bosses', 'rooms', 'player', 'hud', 'help', 'debug', 'main']; for (const f of files) { const code = fs.readFileSync(path.join(__dirname, '..', 'js', f + '.js'), 'utf8'); vm.runInContext(code, sandbox, { filename: f + '.js' }); } // const/let top-level bindings live in the context's lexical scope, not on the // global object — expose the ones the harness pokes at directly. -vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, Sfx, SfxBank, W, H, WALL, ITEMS, ACTIVES, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS, PRESSURE_UNIT, PRESSURE_MIN, PRESSURE_MAX, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX, ROOM_SHAPES, ROOM_THEMES, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN })', 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, ROOM_SHAPES, ROOM_THEMES, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN, defaultPlayerStats, applyPressureDelta, debugRebuildStats, debugEnabled, DEBUG_PAGES })', sandbox); let simTime = 0; const ctx = sandbox; @@ -1656,5 +1656,119 @@ check('auto plays a playlist track', ctx.Music.PLAYLIST.includes(ctx.Music.curre step(120, 16); // idle soak +console.log('== debug console =='); +// gating: backtick is inert in the web harness (no dev flag) +const modeBeforeDebug = ctx.G.mode; +ctx.G.devMode = false; +tap('`'); +check('debug console stays closed without dev flag', ctx.G.mode === modeBeforeDebug); +// enable dev, open, navigate tabs, close +ctx.G.devMode = true; +ctx.G.mode = 'play'; +tap('`'); +check('debug console opens on backtick when dev', ctx.G.mode === 'debug'); +const pageBefore = ctx.G.debugPage; +tap('arrowright'); +check('tab advances', ctx.G.debugPage === (pageBefore + 1) % ctx.DEBUG_PAGES.length); +tap('`'); +check('debug console closes back to play', ctx.G.mode === 'play'); + +// defaultPlayerStats is a clean, self-contained snapshot of the starting stats +(function () { + const def = ctx.defaultPlayerStats(); + const def2 = ctx.defaultPlayerStats(); + check('defaultPlayerStats returns independent copies', def !== def2 && def.maxHp === 6 && def.dmgMul === 1 && def.critMul === 2); + // a freshly-initialised player always matches the factory output + const saved = ctx.G.player; + ctx.initPlayer(); + const fresh = ctx.G.player.stats; + check('defaultPlayerStats matches initPlayer stats', Object.keys(def).every(k => def[k] === fresh[k]) && Object.keys(fresh).length === Object.keys(def).length); + check('initPlayer seeds empty perk tracking', Array.isArray(ctx.G.player.perks) && ctx.G.player.perks.length === 0); + ctx.G.player = saved; +})(); + +// stat rebuild reproduces a build, then removal strips its contribution +(function () { + ctx.G.player.items = {}; + ctx.G.player.perks = []; + ctx.G.player.stats = ctx.defaultPlayerStats(); + const baseDmg = ctx.G.player.stats.dmgMul; + ctx.giveItem('marrowglut'); // dmgMul *= 1.20 + ctx.grantPerk(ctx.PERKS.find(p => p.id === 'critbone')); // crit += 0.02 + const afterDmg = ctx.G.player.stats.dmgMul; + const afterCrit = ctx.G.player.stats.crit; + check('item + perk apply', Math.abs(afterDmg - baseDmg * 1.20) < 1e-9 && Math.abs(afterCrit - 0.07) < 1e-9); + ctx.debugRebuildStats(); + check('rebuild reproduces stats', Math.abs(ctx.G.player.stats.dmgMul - afterDmg) < 1e-9 && Math.abs(ctx.G.player.stats.crit - afterCrit) < 1e-9); + delete ctx.G.player.items['marrowglut']; + ctx.debugRebuildStats(); + check('item removal strips its stat', Math.abs(ctx.G.player.stats.dmgMul - baseDmg) < 1e-9); + check('perk survives item removal', Math.abs(ctx.G.player.stats.crit - afterCrit) < 1e-9); +})(); + +// pressure delta helper: respects bounds and the freeze lock +(function () { + ctx.G.pressure = 1; ctx.G.debugFlags = {}; + ctx.applyPressureDelta(5); check('pressure clamps to max', ctx.G.pressure === ctx.PRESSURE_MAX); + ctx.applyPressureDelta(-99); check('pressure clamps to min', ctx.G.pressure === ctx.PRESSURE_MIN); + ctx.G.pressure = 1; ctx.G.debugFlags.pressureLock = true; + ctx.applyPressureDelta(0.5); check('freeze locks pressure', ctx.G.pressure === 1); + ctx.G.debugFlags = {}; +})(); + +// taint blocks best-score persistence +(function () { + ctx.G.debugUsed = true; + ctx.G.best = 100; + ctx.G.score = 500; + const old = ctx.G.player.hp; + ctx.G.player.hp = 1; + ctx.gameOver(); + check('debug-tainted run does not save best', ctx.G.best === 100); + ctx.G.debugUsed = false; ctx.G.mode = 'play'; + ctx.G.player.hp = old; +})(); + +// god mode and OHKO guards +(function () { + ctx.G.debugFlags.god = true; + const hp = ctx.G.player.hp; + ctx.hurtPlayer(5, 0, null); + check('god mode blocks damage', ctx.G.player.hp === hp); + ctx.G.debugFlags.god = false; + ctx.G.enemies = []; + const victim = ctx.makeEnemy('shambler', 100, 100, 1, false); + ctx.G.enemies.push(victim); + ctx.G.debugFlags.ohko = true; + ctx.damageEnemy(victim, 1, 0, false); + check('ohko kills a full-health enemy', victim.hp <= 0); + ctx.G.debugFlags.ohko = false; + ctx.G.enemies = []; +})(); + +// pinned debug console = live sim (player can be hurt); un-pinned = protected +(function () { + ctx.G.player.invT = 0; + const hp = ctx.G.player.hp; + ctx.G.mode = 'debug'; ctx.G.debugPin = true; + ctx.hurtPlayer(1, 0, null); + check('pinned debug keeps the player hittable', ctx.G.player.hp === hp - 1); + ctx.G.debugPin = false; ctx.G.player.invT = 0; const hp2 = ctx.G.player.hp; + ctx.hurtPlayer(1, 0, null); + check('un-pinned debug protects the player', ctx.G.player.hp === hp2); + ctx.G.mode = 'play'; +})(); + +// useActive(force) fires from the debug console; without force it stays gated +(function () { + ctx.G.mode = 'debug'; + ctx.G.player.active = { iid: 'bonenova', charges: 2 }; + ctx.useActive(); // not forced: no-op in debug mode + check('useActive is mode-gated without force', ctx.G.player.active.charges === 2); + ctx.useActive(true); // debug FIRE button path + check('useActive(force) fires in debug mode', ctx.G.player.active.charges === 0); + ctx.G.mode = 'play'; +})(); + console.log(failures === 0 ? '\nALL CHECKS PASSED' : '\n' + failures + ' CHECKS FAILED'); process.exit(failures === 0 ? 0 : 1);