Add hidden dev debug console (v0.5.0)
This commit is contained in:
1 parent
b8b5c360f2
commit
3f76998fab
22 files changed
+1161
-57
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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*
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
+22
-21
@@ -6,31 +6,32 @@
|
||||
<meta name="theme-color" content="#0a0305">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><text y=%2252%22 x=%2250%22 text-anchor=%22middle%22 dominant-baseline=%22middle%22 font-size=%2252%22>🔪</text></svg>">
|
||||
<title>MEATSLICER</title>
|
||||
<link rel="stylesheet" href="css/style.css?v=45">
|
||||
<link rel="stylesheet" href="css/style.css?v=49">
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrap">
|
||||
<canvas id="game" width="960" height="640"></canvas>
|
||||
</div>
|
||||
<script src="js/utils.js?v=45"></script>
|
||||
<script src="js/state.js?v=45"></script>
|
||||
<script src="js/input.js?v=45"></script>
|
||||
<script src="js/sfx.js?v=45"></script>
|
||||
<script src="js/sfxbank.js?v=45"></script>
|
||||
<script src="js/audio.js?v=45"></script>
|
||||
<script src="js/sprites.js?v=45"></script>
|
||||
<script src="js/particles.js?v=45"></script>
|
||||
<script src="js/weapons.js?v=45"></script>
|
||||
<script src="js/bullets.js?v=45"></script>
|
||||
<script src="js/pickups.js?v=45"></script>
|
||||
<script src="js/items.js?v=45"></script>
|
||||
<script src="js/perks.js?v=45"></script>
|
||||
<script src="js/enemies.js?v=45"></script>
|
||||
<script src="js/bosses.js?v=45"></script>
|
||||
<script src="js/rooms.js?v=45"></script>
|
||||
<script src="js/player.js?v=45"></script>
|
||||
<script src="js/hud.js?v=45"></script>
|
||||
<script src="js/help.js?v=45"></script>
|
||||
<script src="js/main.js?v=45"></script>
|
||||
<script src="js/utils.js?v=49"></script>
|
||||
<script src="js/state.js?v=49"></script>
|
||||
<script src="js/input.js?v=49"></script>
|
||||
<script src="js/sfx.js?v=49"></script>
|
||||
<script src="js/sfxbank.js?v=49"></script>
|
||||
<script src="js/audio.js?v=49"></script>
|
||||
<script src="js/sprites.js?v=49"></script>
|
||||
<script src="js/particles.js?v=49"></script>
|
||||
<script src="js/weapons.js?v=49"></script>
|
||||
<script src="js/bullets.js?v=49"></script>
|
||||
<script src="js/pickups.js?v=49"></script>
|
||||
<script src="js/items.js?v=49"></script>
|
||||
<script src="js/perks.js?v=49"></script>
|
||||
<script src="js/enemies.js?v=49"></script>
|
||||
<script src="js/bosses.js?v=49"></script>
|
||||
<script src="js/rooms.js?v=49"></script>
|
||||
<script src="js/player.js?v=49"></script>
|
||||
<script src="js/hud.js?v=49"></script>
|
||||
<script src="js/help.js?v=49"></script>
|
||||
<script src="js/debug.js?v=49"></script>
|
||||
<script src="js/main.js?v=49"></script>
|
||||
</body>
|
||||
</html>
|
||||
+2
-2
@@ -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();
|
||||
|
||||
+633
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+33
-3
@@ -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, {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+29
-21
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@ const SfxBank = {
|
||||
if (this.buffers.has(name) || this.pending.has(name) || this.failed.has(name)) return;
|
||||
const file = this.FILES[name];
|
||||
if (!file || !this.ctx || typeof fetch !== 'function') return;
|
||||
const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=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)))
|
||||
|
||||
+1
-1
@@ -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';
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
+15
@@ -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);
|
||||
}
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
+116
-2
@@ -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);
|
||||
Reference in new issue
Block a user