Author SHA1 Message Date
Quintine fde2747561 docs: add 0.7.0/0.6.0 changelog entries, fix docs index, note Linux build in README 2026-08-11 19:20:32 +10:00
Quintine c0d909eebd release: bump game and memory versions to 0.7.0 2026-08-11 19:06:36 +10:00
Quintine 717a106119 feat: peek big-room camera toward walls, shorten perk-selection guard to 0.4s 2026-08-11 19:03:36 +10:00
Quintine b41dcd8ebb feat: QoL weapon/UI batch — door fix, Red Right Hand + flamethrower right-offset, fire slowdown, literal armor dodge, persistent pedestals, menu shot-lock 2026-08-11 18:25:34 +10:00
Quintine 1131ed1e0b Merge branch 'bigrooms' into main 2026-08-11 17:35:42 +10:00
Quintine d9a921a1ea Merge commit integrating big-rooms + linux-and-qol (keep both features) 2026-08-11 17:35:02 +10:00
Quintine 16efccd912 release: bump game and memory versions to 0.6.0 (big rooms) 2026-08-11 08:56:33 +10:00
Quintine 96815f6685 feat: big rooms with Isaac-style camera, extra-large and odd rooms 2026-08-11 08:56:33 +10:00
Quintine 9c6fc194ca release: bump game and memory versions to 0.6.0 (big rooms) 2026-08-11 08:51:48 +10:00
Quintine 063ace998f feat: big rooms with Isaac-style camera, extra-large and odd rooms 2026-08-11 08:51:43 +10:00
admin 7269ff53f0 Merge pull request #1 from Quintine/feature/memory-versioning
Feature/memory versioning
2026-08-10 21:42:56 +10:00
Quintine f77b4fd402 feat: crimson metronome lends ½ heart, repaid on a clean room clear 2026-08-09 14:22:26 +10:00
Quintine 39d89fbc53 build: add Linux AppImage target and dist:linux script 2026-08-09 13:56:43 +10:00
Quintine e801c02ef6 feat: 2.5s lockout before dropped weapons can be re-picked 2026-08-09 13:56:41 +10:00
Quintine 7aee2f5f64 fix: don't fire weapon when choosing upgrade from level-up menu 2026-08-09 13:56:38 +10:00
19 changed files with 429 additions and 470 deletions

No files matched your search

+1 -1
View File
@@ -40,7 +40,7 @@ npm test
npm run dist npm run dist
``` ```
`npm test` runs the smoke test. `npm run dist` packages the Windows Electron build as a zip in `dist/`. `npm test` runs the smoke test. `npm run dist` packages the Windows Electron build as a zip in `dist/`; `npm run dist:linux` produces a Linux AppImage in `dist/`. Every release ships both platforms.
## Documentation ## Documentation
+35 -1
View File
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
--- ---
## [0.7.0] — 2026-08-11
### Added
- **Dual-platform release** — every push now ships BOTH a Windows zip and a Linux AppImage attached to a matching GitHub release (`dist:linux` script + AppImage target added to `electron-builder.yml`). Standing policy recorded in the release-process knowledge.
- **Crimson Metronome heart loan** — the legendary lends a half heart on its 8th shot and repays it when the room is cleared clean; a hit room forfeits the loan.
- **Weapon-drop lockout** — dropped weapons stay un-pickable for a 2.5s window to stop instant swap loops.
- **Big-room wall-peek camera** — in rooms larger than the screen, the camera now glides slightly past the arena edge (into the drawn wall band) as the player nears a wall, so walls and their exit doors come into view.
- **20 pre-existing statistical checks seeded deterministically** — large RNG sampling (item favor, elite drops, toast re-arm) now runs under a fixed seed so the smoke suite passes identically every run.
### Changed
- **Big-room door entries land at the door** — the door-transition placement now sets BOTH coordinates (destination arena's `cx`/`cy` on the opposite face), fixing the landing-into-a-wall bug in the new large room shapes.
- **Red Right Hand hitbox matches the saw** — the chainsaw's hit area now sits on the character's right hand (`aim + π/2`, offset `rho`) at full blade reach/radius, instead of a small forward circle.
- **Flamethrower fires from the right hand** — The Cauterizer's projectiles originate 26px right of the body; the Bile Blunderbuss cone is unchanged.
- **Firing slows movement** — holding fire while moving slows the player 5% with the Bone Popper and 15% with every other weapon.
- **Armour is now a literal dodge chance** — the `armor` stat is a direct 0–75% damage-dodge chance (the diminishing curve is gone); Tanned Hide +8%, Thick Hide +4%.
- **Pedestal bases persist** — an item pedestal stays after its item is taken and is never removed by walking over it.
- **Weapon fire locked out in menus + 50ms after close** — firing is impossible in any menu and for 50ms after it closes; supersedes the level-up-only click mask.
- **Perk-selection guard shortened** — the accidental-pick lock on a fresh draft dropped from 1.5s to ~0.4s.
### Fixed
- **No accidental shot when choosing an upgrade card** — the reward-click no longer carries the held button into play (now generalized to every menu→play transition).
### Removed
- **`docs/development.md`** — retired from the repo; durable developer knowledge now lives in Serena memories (`.serena/memories/meatslicer-development.md`, memory version 0.7.0, and `meatslicer-release-process.md`).
---
## [0.6.0] — 2026-08-10
### Added
- **Big rooms with Isaac-style camera** — floor rooms can spawn extra-large and odd-shaped arenas (grand hall, deep hall, meat hall, odd hall); the view follows the player and clamps to the room instead of being a fixed screen, and the floor mixes them in at higher floors with reciprocal doors.
---
## [0.5.0] — 2026-07-30 ## [0.5.0] — 2026-07-30
### Added ### Added
@@ -155,4 +189,4 @@ The initial series of commits that built the game from scratch. Grouped by featu
--- ---
*Generated from the git log. Last commit: v0.5.0* *Generated from the git log. Last commit: v0.7.0*
+1 -1
View File
@@ -15,7 +15,7 @@ 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 | | [Enemies](enemies.md) | All 11 enemy types, elites, drops, wave and scaling formulas |
| [Bosses](bosses.md) | All 9 bosses: rotation, scaling, full attack patterns, counters | | [Bosses](bosses.md) | All 9 bosses: rotation, scaling, full attack patterns, counters |
| [Debug Console](dev-debug.md) | In-game cheat/inspection toolset (backtick `` ` `` key, `--dev` flag): all 8 tabs, run tainting, stat-rebuild internals | | [Debug Console](dev-debug.md) | In-game cheat/inspection toolset (backtick `` ` `` key, `--dev` flag): all 8 tabs, run tainting, stat-rebuild internals |
| [Development Guide](development.md) | Architecture, build/test workflow, Electron packaging, asset and audio pipelines, versioning, releases, and Git practices | | Development Guide | Architecture, build/test workflow, Electron packaging, asset and audio pipelines, versioning, releases, and Git practices — maintained in Serena memory (`.serena/memories/meatslicer-development.md`), not this repo |
| [Changelog](CHANGELOG.md) | Release history by version | | [Changelog](CHANGELOG.md) | Release history by version |
### Quick numbers ### Quick numbers
-373
View File
@@ -1,373 +0,0 @@
# Development Guide
This guide consolidates the durable project knowledge used to develop, test,
package, and maintain MeatSlicer. When this guide and the implementation
disagree, the implementation and configuration files are the source of truth.
**Memory version: 0.5.2**
## Architecture
MeatSlicer is an endless top-down arena roguelite built with plain JavaScript
and HTML5 canvas. It has no browser framework, module system, or bundler.
`index.html` loads the scripts in `js/` as globals in a fixed order:
1. `utils.js`
2. `state.js`
3. `input.js`
4. `sfx.js`
5. `sfxbank.js`
6. `audio.js`
7. `sprites.js`
8. `particles.js`
9. `weapons.js`
10. `bullets.js`
11. `pickups.js`
12. `items.js`
13. `perks.js`
14. `enemies.js`
15. `bosses.js`
16. `rooms.js`
17. `player.js`
18. `hud.js`
19. `help.js`
20. `debug.js`
21. `main.js`
Because the scripts share globals, changing their order can break initialization
or runtime dependencies.
### Runtime details
- The canvas uses a fixed 960×640 logical resolution and is CSS-scaled with
pixelated image rendering.
- There is no camera. Rooms use variable `G.arena` inset bounds for full halls,
horizontal or vertical halls, chambers, and pits.
- Persistence uses `localStorage`. Current keys include `meatslicer_best`, the
music and SFX volumes, `meatslicer_autoperk`, `meatslicer_pressure_dial`, and
`meatslicer_hud_alpha`.
- `powerScore()` weights perks at 0.5 and item tiers at 1.0 when scaling enemies.
- `G.pressure` is an adaptive difficulty multiplier, clamped from 0.60 to 2.00.
The title-screen pressure dial ranges from −10 to +10.
- `procOnHit` in `js/enemies.js` is the shared on-hit payload router used by all
weapons.
### Cache busting
Asset and script URLs use a `?v=N` cache suffix. When shipping changed assets or
scripts, bump the value together in:
- `index.html`
- `js/sprites.js`
- `js/sfxbank.js`
## Repository layout
| Path | Purpose |
|---|---|
| `index.html` | Browser entry point and script load order |
| `js/` | Game systems and content definitions |
| `css/` | Browser and canvas presentation |
| `assets/` | Shipped sprites, animation sheets, UI art, and sound effects |
| `assets/raw/` | Generation intermediates; ignored and excluded from packages |
| `mp3-music/` | Music and boss tracks |
| `desktop/` | Electron main process, preload, and verification harness |
| `tools/` | Static server and visual asset-generation tools |
| `test/smoke.js` | Headless integration and regression suite |
| `docs/` | Player, content, release, and developer documentation |
| `build/icon.png` | Windows application icon |
## Build, run, and test
### Browser
On Windows:
```bat
play.bat
```
This runs the allowlisted static server in `tools/serve_game.py` and opens
<http://localhost:8123>. The equivalent manual command is:
```sh
python tools/serve_game.py --port 8123
```
Use `play-dev.bat` or append `?dev=1` to enable the debug console.
### Electron
```sh
npm install
npm start
```
Use `npm start -- --dev` to enable the debug console in Electron.
The lockfile currently resolves Electron 40.10.2 and electron-builder 25.1.8.
These versions support the development machine's Node 20 environment; review
Node compatibility before upgrading either package.
### Tests
Run the complete smoke suite before every commit:
```sh
npm test
```
This is equivalent to `node test/smoke.js` and must finish with
`ALL CHECKS PASSED`. The suite asserts exact balance values and scaling
formulas, so update expectations deliberately when changing game balance.
For a quick JavaScript syntax check:
```sh
node --check js/<file>.js
```
### Windows distributable
```sh
npm run dist
```
electron-builder writes a versioned zip and an unpacked application under
`dist/`. The version comes from `package.json`. `dist/` and `node_modules/` are
ignored; commit package manifests, not generated dependencies or builds.
## Electron packaging
The Electron shell serves the unchanged browser game through a privileged
`app://` protocol.
### Package contents
`electron-builder.yml` uses a strict allowlist for the desktop shell, HTML,
CSS, JavaScript, shipped assets, music, documentation, and `package.json`.
`assets/raw/**` must remain explicitly excluded because it contains large
generation intermediates that are not part of the game.
The application deliberately uses `asar: false`.
### Custom protocol requirements
The `app://` protocol is registered as standard, secure, fetch-capable, and
stream-capable. Streaming is required for looping MP3 audio. The handler must:
- call `decodeURIComponent`, because music filenames contain spaces;
- use `path.join` rather than `path.resolve`, because URL pathnames begin with
`/`; and
- preserve traversal protection before serving a requested path.
### Window behavior
- Borderless fullscreen starts at 1280×832 restore geometry.
- F11 toggles fullscreen.
- F12 and Ctrl+Shift+I toggle developer tools.
- Ctrl+R reloads.
- The application menu is removed.
- A single-instance lock prevents duplicate app instances.
### winCodeSign extraction issue
electron-builder's winCodeSign archive can fail to extract on Windows when the
current account cannot create symlinks. The local workaround is a pre-seeded
cache under:
```text
%LOCALAPPDATA%\electron-builder\Cache\winCodeSign\winCodeSign-2.6.0
```
If the cache must be rebuilt, extract the archive while excluding its `darwin`
entries, or enable an environment that permits symlink creation.
### Packaged-app verification
The optional harness in `desktop/main.js` drives a run, loads the sound bank,
captures diagnostics, and exits. In PowerShell:
```powershell
$env:MS_VERIFY_OUT = "$env:TEMP\meatslicer-verify.json"
Start-Process "dist\win-unpacked\MeatSlicer.exe" -Wait
Get-Content $env:MS_VERIFY_OUT
```
Set `MS_VERIFY_SHOT` to a PNG path to capture a screenshot as well. The same
variables work with `npx electron .`. A valid result has an empty `log`, no SFX
failures, and an SFX buffer count matching `SfxBank.FILES` and the files on
disk; the exact count can change as audio is added.
The Windows build is not code-signed, so downloaded copies may trigger a
SmartScreen warning. Code signing, installers, automatic updates, and
macOS/Linux packaging remain future work.
## Visual asset pipeline
The project has two visual asset tiers. AI-generated stills are the production
tier; procedural drawings are fallbacks and composition guides.
### Production sprites
`tools/gen_assets.py` generates production stills through OpenRouter's image
endpoint and derives animation sheets from them.
```sh
python tools/gen_assets.py --list
python tools/gen_assets.py <name-substrings> --quality medium
python tools/gen_assets.py <name-substrings> --force --quality medium
```
Authentication is read from `OPENROUTER_API_KEY` in the process environment or
the user-only `~/.config/MeatSlicer/.env`, outside the served game directory.
Never commit credentials.
The generator:
- attaches `assets/raw/guides/style_reference.png` as a style reference;
- requests isolated sprites on `#FF00FF` backgrounds;
- flood-keys the border to alpha;
- crops, centers, resizes, and quantizes output to the shared palette; and
- records generation metadata in `assets/raw/openrouter_manifest.json`.
`render_sheets()` calls `render_actor_sheet(name, use_existing=True)`, ensuring
animation sheets are derived from the production still.
### Procedural fallback sprites
`tools/draw_sprites.py` contains procedural fallback drawing functions. It is
useful for guides, runtime fallbacks, and derived effects.
> **Destructive-tool warning:** Never run `draw_sprites.py` without an explicit,
> narrow name filter. Its main routine writes every matching procedural still
> and rebuilds sheets with `use_existing=False`, which can overwrite production
> AI stills and their derived atlases.
Prefer `gen_assets.py` for shipped characters.
### Actor animation atlases
- Boss frames are 128×128.
- Player frames are 96×96.
- Normal enemy frames are 64×64.
- Atlases use eight columns and eight compass directions.
- Cell lookup is `offset + direction * frames + frame`.
- Source coordinates are `(cell % 8) * frameSize` and
`floor(cell / 8) * frameSize`.
- Actions match `ACTOR_ANIMS` in `js/sprites.js`: idle, move, attack, hit, and
death.
- Player legs use a separate eight-frame strip.
To add a production animated character:
1. Add its `Spec` to `SPECS` in `tools/gen_assets.py`.
2. Add its name to `ACTORS` in `tools/draw_sprites.py`.
3. Run `python tools/gen_assets.py <name> --quality medium`.
4. Register the still and sheet in `SPRITE_MANIFEST` as required.
5. Bump the shared cache version before shipping.
## Audio pipeline
Sound effects are generated with the ElevenLabs audio tooling. There is no
repository-local generation script, so requests should use a per-file manifest
with the exact target filename, sound description, and style brief.
Generated files may initially land in the project root, and generated filenames
may replace underscores with hyphens. Move and rename each result into
`assets/sfx/` using the underscore filename expected by the game.
To wire a new sound:
1. Generate the clip using its exact manifest and style description.
2. Move and rename it into `assets/sfx/`.
3. Register it in `SfxBank.FILES` in `js/sfxbank.js`.
4. Run `npm test` to verify every registered file exists and is non-empty.
5. Bump the shared cache version before shipping.
`Sfx.sample()` and `Sfx.ui()` can fall back to procedural audio when a sample is
missing, but registering a missing file in `SfxBank.FILES` fails the smoke test.
## Versioning and releases
Versions use `X.Y.Z` with project-specific rules:
- `X` is the main version and is changed manually by the project owner only.
- `Y` marks a major feature or content expansion.
- `Z` marks a fix, minor addition, balance tweak, or adjustment.
- Components do not roll over automatically at 9; values such as `0.42.677`
are valid.
The version lives in `package.json` and determines the packaged artifact name.
When changing the version:
1. Update `package.json`.
2. Bump the shared `?v=N` cache value in `index.html`, `js/sprites.js`, and
`js/sfxbank.js`.
3. Run `npm test` and commit the source changes.
4. Run `npm run dist`.
5. Verify the packaged executable with `MS_VERIFY_OUT`.
## Memory versioning
The project knowledge ("memories") — this guide and the player-facing documents
under `docs/` — carries its own `X.Y.Z` version, tracked independently of the
game release version in `package.json`:
- `X` — a major milestone. Changed by the project owner only; a routine update
or bump rule never raises it.
- `Y` — a major feature push: a large content expansion, a major mechanic, or a
significant restructure of the knowledge base.
- `Z` — a small addition, a bug fix, or a single new small feature (a new
gotcha, a corrected formula, a changed workflow, or the documentation sync
that follows a gameplay change).
Components are independent counters and never roll over: `0.999.999` and
`0.42.677` are perfectly valid, and bumping `Z` on `0.999.999` gives
`0.999.1000`. Only `X` is exempt from automatic increases — it changes by
explicit human edit.
The current version is the `**Memory version:**` stamp at the top of this
guide; that stamp is the single source of truth.
When a change to durable knowledge lands (anywhere under `docs/`):
1. Edit the stamp: bump `Z` for a small addition or fix, `Y` for a major push,
or — for a milestone — the project owner raises `X` by hand.
2. Run `npm test` (docs are not loaded by the game, so the suite is unaffected)
and commit with an imperative message naming the bump, e.g.
`docs: record Iron Lung synergy (memory 0.5.0 -> 0.5.1)`.
3. No `?v=` cache bump is needed — `docs/` is not served through `index.html`
script tags; it only ships inside packages.
## Git workflow
Every completed change should be committed:
1. Run `git status` and review `git diff`.
2. Stage only intended files.
3. Never commit credentials, local environments, raw generation data,
dependencies, or build output.
4. Run `npm test`.
5. Commit with a concise imperative summary matching the repository history.
The primary branch is `main`, and `origin` points to the public GitHub
repository. The local repository uses the GitHub account's noreply identity for
new commits.
## Keeping project knowledge current
Architecture, commands, workflows, naming conventions, and costly gotchas
should be documented when they become durable project knowledge. Keep one topic
per section, record why a constraint exists, and update stale guidance instead
of adding contradictory instructions.
Player-facing mechanics belong in the relevant files under `docs/`. Keep those
documents synchronized with gameplay changes. This development guide should be
updated whenever the build, packaging, asset, audio, release, or contribution
workflow changes.
Keep the `**Memory version:**` stamp at the top of this guide current: any
durable-knowledge change bumps it per `## Memory versioning`.
+5
View File
@@ -15,3 +15,8 @@ win:
target: target:
- zip - zip
icon: build/icon.png icon: build/icon.png
linux:
target:
- AppImage
icon: build/icon.png
category: Game
+22 -22
View File
@@ -6,32 +6,32 @@
<meta name="theme-color" content="#0a0305"> <meta name="theme-color" content="#0a0305">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><text y=%2252%22 x=%2250%22 text-anchor=%22middle%22 dominant-baseline=%22middle%22 font-size=%2252%22>🔪</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><text y=%2252%22 x=%2250%22 text-anchor=%22middle%22 dominant-baseline=%22middle%22 font-size=%2252%22>🔪</text></svg>">
<title>MEATSLICER</title> <title>MEATSLICER</title>
<link rel="stylesheet" href="css/style.css?v=51"> <link rel="stylesheet" href="css/style.css?v=53">
</head> </head>
<body> <body>
<div id="wrap"> <div id="wrap">
<canvas id="game" width="960" height="640"></canvas> <canvas id="game" width="960" height="640"></canvas>
</div> </div>
<script src="js/utils.js?v=51"></script> <script src="js/utils.js?v=53"></script>
<script src="js/state.js?v=51"></script> <script src="js/state.js?v=53"></script>
<script src="js/input.js?v=51"></script> <script src="js/input.js?v=53"></script>
<script src="js/sfx.js?v=51"></script> <script src="js/sfx.js?v=53"></script>
<script src="js/sfxbank.js?v=51"></script> <script src="js/sfxbank.js?v=53"></script>
<script src="js/audio.js?v=51"></script> <script src="js/audio.js?v=53"></script>
<script src="js/sprites.js?v=51"></script> <script src="js/sprites.js?v=53"></script>
<script src="js/particles.js?v=51"></script> <script src="js/particles.js?v=53"></script>
<script src="js/weapons.js?v=51"></script> <script src="js/weapons.js?v=53"></script>
<script src="js/bullets.js?v=51"></script> <script src="js/bullets.js?v=53"></script>
<script src="js/pickups.js?v=51"></script> <script src="js/pickups.js?v=53"></script>
<script src="js/items.js?v=51"></script> <script src="js/items.js?v=53"></script>
<script src="js/perks.js?v=51"></script> <script src="js/perks.js?v=53"></script>
<script src="js/enemies.js?v=51"></script> <script src="js/enemies.js?v=53"></script>
<script src="js/bosses.js?v=51"></script> <script src="js/bosses.js?v=53"></script>
<script src="js/rooms.js?v=51"></script> <script src="js/rooms.js?v=53"></script>
<script src="js/player.js?v=51"></script> <script src="js/player.js?v=53"></script>
<script src="js/hud.js?v=51"></script> <script src="js/hud.js?v=53"></script>
<script src="js/help.js?v=51"></script> <script src="js/help.js?v=53"></script>
<script src="js/debug.js?v=51"></script> <script src="js/debug.js?v=53"></script>
<script src="js/main.js?v=51"></script> <script src="js/main.js?v=53"></script>
</body> </body>
</html> </html>
+2 -2
View File
@@ -72,7 +72,7 @@ function dbgClicked(rect) { return Input.mpressed && inRect(Input.mx, Input.my,
// clamp the cursor to the arena so spawned pickups/pedestals never land in walls // clamp the cursor to the arena so spawned pickups/pedestals never land in walls
function dbgCursor() { function dbgCursor() {
const a = G.arena; 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) }; return { x: clamp(mxW(), a.x0 + 12, a.x1 - 12), y: clamp(myW(), a.y0 + 12, a.y1 - 12) };
} }
// number row: label + [-] value [+] // number row: label + [-] value [+]
@@ -185,7 +185,7 @@ function dbgRenderPlayer(ctx, b) {
{ t: 'KILL PLAYER', fn: () => { p.hp = 0; gameOver(); } }, { t: 'KILL PLAYER', fn: () => { p.hp = 0; gameOver(); } },
{ t: '+1 LEVELUP', fn: () => { G.pendingLevelups++; addToast('DEBUG', '+1 pending levelup'); } }, { t: '+1 LEVELUP', fn: () => { G.pendingLevelups++; addToast('DEBUG', '+1 pending levelup'); } },
{ t: 'OPEN DRAFT', fn: () => { openPerkDraft(); } }, { 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: 'TP TO CURSOR', fn: () => { p.x = clamp(mxW(), G.arena.x0 + p.r, G.arena.x1 - p.r); p.y = clamp(myW(), G.arena.y0 + p.r, G.arena.y1 - p.r); } },
{ t: '+500 SCORE', fn: () => addScore(500) }, { t: '+500 SCORE', fn: () => addScore(500) },
]; ];
for (let i = 0; i < acts.length; i++) { for (let i = 0; i < acts.length; i++) {
+3 -3
View File
@@ -23,7 +23,7 @@ const ITEMS = {
hollowbones: { name: 'Hollow Bones', desc: '+14% move speed', rarity: 'common', cap: 6, apply: s => { s.speedMul *= 1.14; } }, hollowbones: { name: 'Hollow Bones', desc: '+14% move speed', rarity: 'common', cap: 6, apply: s => { s.speedMul *= 1.14; } },
brassmagazine: { name: 'Brass Magazine', desc: '+10% ammo efficiency, +20% ammo found', rarity: 'common', cap: 5, apply: s => { s.ammoEff += 0.10; s.ammoPickupMul *= 1.20; } }, brassmagazine: { name: 'Brass Magazine', desc: '+10% ammo efficiency, +20% ammo found', rarity: 'common', cap: 5, apply: s => { s.ammoEff += 0.10; s.ammoPickupMul *= 1.20; } },
ironstomach: { name: 'Iron Stomach', desc: '+½ heart container, heal ½ heart', rarity: 'common', cap: 8, apply: (s, p) => { s.maxHp += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } }, ironstomach: { name: 'Iron Stomach', desc: '+½ heart container, heal ½ heart', rarity: 'common', cap: 8, apply: (s, p) => { s.maxHp += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } },
tannedhide: { name: 'Tanned Hide', desc: '+8% chance to ignore damage', rarity: 'common', cap: 6, apply: s => { s.armor += 0.08 / 0.92; } }, tannedhide: { name: 'Tanned Hide', desc: '+8% chance to ignore damage', rarity: 'common', cap: 6, apply: s => { s.armor += 0.08; } },
bloodlust: { name: 'Bloodlust', desc: 'Kills may drop bonus XP', rarity: 'common', cap: 5, apply: s => { s.bloodlust = (s.bloodlust || 0) + 0.12; } }, bloodlust: { name: 'Bloodlust', desc: 'Kills may drop bonus XP', rarity: 'common', cap: 5, apply: s => { s.bloodlust = (s.bloodlust || 0) + 0.12; } },
// ---- uncommon ---- // ---- uncommon ----
@@ -94,7 +94,7 @@ const ITEMS = {
butchersoath: { name: "Butcher's Oath", desc: '+80% damage, but max HP set to 2', rarity: 'legendary', cap: 1, apply: (s, p) => { s.dmgMul *= 1.8; s.maxHp = Math.min(s.maxHp, 2); p.hp = Math.min(p.hp, s.maxHp); } }, butchersoath: { name: "Butcher's Oath", desc: '+80% damage, but max HP set to 2', rarity: 'legendary', cap: 1, apply: (s, p) => { s.dmgMul *= 1.8; s.maxHp = Math.min(s.maxHp, 2); p.hp = Math.min(p.hp, s.maxHp); } },
secondskin: { name: 'Second Skin', desc: 'Revive once per floor at ½ heart', rarity: 'legendary', cap: 1, apply: s => { s.secondSkin += 1; } }, secondskin: { name: 'Second Skin', desc: 'Revive once per floor at ½ heart', rarity: 'legendary', cap: 1, apply: s => { s.secondSkin += 1; } },
twinsidearm: { name: 'Twin Sidearm', desc: 'Bone Popper double-taps; specials burn 2× ammo', rarity: 'legendary', cap: 1, apply: s => { s.twinSidearm += 1; } }, twinsidearm: { name: 'Twin Sidearm', desc: 'Bone Popper double-taps; specials burn 2× ammo', rarity: 'legendary', cap: 1, apply: s => { s.twinSidearm += 1; } },
crimsonmetronome:{ name: 'Crimson Metronome', desc: '+60% fire rate; every 8th shot costs ½ heart', rarity: 'legendary', cap: 1, apply: s => { s.rateMul *= 1.6; s.crimsonMetronome += 1; } }, crimsonmetronome:{ name: 'Crimson Metronome', desc: '+60% fire rate; every 8th shot lends ½ heart, repaid if you clear the room clean', rarity: 'legendary', cap: 1, apply: s => { s.rateMul *= 1.6; s.crimsonMetronome += 1; } },
abattoirengine:{ name: 'Abattoir Engine', desc: 'Pressure rises 2× faster; score and luck ×1.5', rarity: 'legendary', cap: 1, apply: s => { s.abattoirEngine += 1; s.luck += 0.5; } }, abattoirengine:{ name: 'Abattoir Engine', desc: 'Pressure rises 2× faster; score and luck ×1.5', rarity: 'legendary', cap: 1, apply: s => { s.abattoirEngine += 1; s.luck += 0.5; } },
gorecrown: { name: 'Gore Crown', desc: 'Free nova on every kill, −25% damage', rarity: 'legendary', cap: 1, apply: s => { s.goreCrown += 1; s.dmgMul *= 0.75; } }, gorecrown: { name: 'Gore Crown', desc: 'Free nova on every kill, −25% damage', rarity: 'legendary', cap: 1, apply: s => { s.goreCrown += 1; s.dmgMul *= 0.75; } },
thousandteeth: { name: 'Thousand Teeth', desc: '+6 shards that can crit, −40% base bullet damage', rarity: 'legendary', cap: 1, apply: s => { s.thousandTeeth += 1; s.dmgMul *= 0.6; } }, thousandteeth: { name: 'Thousand Teeth', desc: '+6 shards that can crit, −40% base bullet damage', rarity: 'legendary', cap: 1, apply: s => { s.thousandTeeth += 1; s.dmgMul *= 0.6; } },
@@ -198,7 +198,7 @@ const ACTIVES = {
spawnShockwave(p.x, p.y, 150, '#e8dcc2', 0.8); addShake(8); spawnShockwave(p.x, p.y, 150, '#e8dcc2', 0.8); addShake(8);
} }, } },
offalbomb: { name: 'Offal Bomb', desc: 'Gore bomb at the cursor', cost: 1, use(p) { offalbomb: { name: 'Offal Bomb', desc: 'Gore bomb at the cursor', cost: 1, use(p) {
explodeAt(Input.mx, Input.my, 95, 25 * p.stats.dmgMul, true); explodeAt(mxW(), myW(), 95, 25 * p.stats.dmgMul, true);
} }, } },
bloodtransfusion:{ name: 'Blood Transfusion', desc: 'Heal 2 hearts, lose 25% current XP', cost: 2, use(p) { bloodtransfusion:{ name: 'Blood Transfusion', desc: 'Heal 2 hearts, lose 25% current XP', cost: 2, use(p) {
p.xp = Math.floor(p.xp * 0.75); p.xp = Math.floor(p.xp * 0.75);
+10 -3
View File
@@ -101,7 +101,7 @@ function resetRun() {
G.confirmAction = null; G.confirmT = 0; G.confirmAction = null; G.confirmT = 0;
G.deathLockT = 0; G.deathLockT = 0;
G.pressure = 1; G.streak = 0; G.roomDamaged = false; G.roomEnterT = 0; G.recentHits = []; G.pressure = 1; G.streak = 0; G.roomDamaged = false; G.roomEnterT = 0; G.recentHits = [];
G.shake = 0; G.flash = 0; G.shake = 0; G.flash = 0; G.shotLockT = 0;
G.debugUsed = false; G.debugFlags = {}; G.debugTimescale = 1; G.debugFrameStep = false; G.debugUsed = false; G.debugFlags = {}; G.debugTimescale = 1; G.debugFrameStep = false;
initPlayer(); initPlayer();
genFloor(1); genFloor(1);
@@ -169,6 +169,7 @@ function loop(now) {
} }
function update(dt) { function update(dt) {
const menuWas = G.mode !== 'play';
Music.update(dt); Music.update(dt);
if (G.deathLockT > 0) G.deathLockT = Math.max(0, G.deathLockT - dt); if (G.deathLockT > 0) G.deathLockT = Math.max(0, G.deathLockT - dt);
if (G.confirmT > 0) { if (G.confirmT > 0) {
@@ -287,6 +288,8 @@ function update(dt) {
break; break;
} }
if (menuWas && G.mode === 'play') { Input.mdown = false; G.shotLockT = 0.05; }
G.shotLockT = Math.max(0, (G.shotLockT || 0) - dt);
if (G.shake > 0) G.shake = Math.max(0, G.shake - dt * 30); if (G.shake > 0) G.shake = Math.max(0, G.shake - dt * 30);
if (G.flash > 0) G.flash = Math.max(0, G.flash - dt); if (G.flash > 0) G.flash = Math.max(0, G.flash - dt);
if (G.transition > 0) G.transition = Math.max(0, G.transition - dt); if (G.transition > 0) G.transition = Math.max(0, G.transition - dt);
@@ -298,8 +301,11 @@ function draw() {
if (G.mode === 'menu') { drawMenu(ctx); return; } if (G.mode === 'menu') { drawMenu(ctx); return; }
// world (with screen shake) updateCamera();
// world (with camera + screen shake)
ctx.save(); ctx.save();
ctx.translate(-G.cam.x, -G.cam.y);
if (G.shake > 0) ctx.translate(rand(-G.shake, G.shake) * 0.5, rand(-G.shake, G.shake) * 0.5); if (G.shake > 0) ctx.translate(rand(-G.shake, G.shake) * 0.5, rand(-G.shake, G.shake) * 0.5);
drawRoom(ctx); drawRoom(ctx);
drawCorpses(ctx); drawCorpses(ctx);
@@ -309,6 +315,7 @@ function draw() {
drawBullets(ctx); drawBullets(ctx);
drawParticles(ctx); drawParticles(ctx);
drawTelegraphs(ctx); drawTelegraphs(ctx);
if (G.devMode) drawDebugOverlays(ctx);
ctx.restore(); ctx.restore();
if (G.atmosphereLayer) ctx.drawImage(G.atmosphereLayer, 0, 0); if (G.atmosphereLayer) ctx.drawImage(G.atmosphereLayer, 0, 0);
@@ -340,7 +347,7 @@ function draw() {
else if (G.mode === 'pause') { drawPause(ctx); if (G.pauseHelp) drawPauseHelp(ctx); } else if (G.mode === 'pause') { drawPause(ctx); if (G.pauseHelp) drawPauseHelp(ctx); }
else if (G.mode === 'gameover') drawGameOver(ctx); else if (G.mode === 'gameover') drawGameOver(ctx);
else if (G.mode === 'debug') drawDebug(ctx); else if (G.mode === 'debug') drawDebug(ctx);
if (G.devMode) { drawDebugOverlays(ctx); if (G.debugPin && G.mode !== 'debug') drawDebugPin(ctx); } if (G.devMode) { if (G.debugPin && G.mode !== 'debug') drawDebugPin(ctx); }
} }
function drawMenu(ctx) { function drawMenu(ctx) {
+2 -2
View File
@@ -17,7 +17,7 @@ const PERKS = [
{ id: 'ember', name: 'Ember Hands', desc: '8% chance to ignite', apply: s => { s.igniteChance += 0.08; } }, { id: 'ember', name: 'Ember Hands', desc: '8% chance to ignite', apply: s => { s.igniteChance += 0.08; } },
{ id: 'frostbile',name: 'Chill Bile', desc: '10% chance to slow', apply: s => { s.slowOnHit += 0.10; } }, { id: 'frostbile',name: 'Chill Bile', desc: '10% chance to slow', apply: s => { s.slowOnHit += 0.10; } },
{ id: 'heavyhand',name: 'Heavy Hand', desc: '+20% knockback', apply: s => { s.knockbackMul *= 1.20; } }, { id: 'heavyhand',name: 'Heavy Hand', desc: '+20% knockback', apply: s => { s.knockbackMul *= 1.20; } },
{ id: 'thickhide',name: 'Thick Hide', desc: '+4% chance to ignore damage', apply: s => { s.armor += 1 / 24; } }, { id: 'thickhide',name: 'Thick Hide', desc: '+4% chance to ignore damage', apply: s => { s.armor += 0.04; } },
{ id: 'secondwind',name: 'Second Wind', desc: '+0.15s hurt immunity', apply: s => { s.invBonus += 0.15; } }, { id: 'secondwind',name: 'Second Wind', desc: '+0.15s hurt immunity', apply: s => { s.invBonus += 0.15; } },
{ id: 'scrapfeed',name: 'Scrap Feed', desc: '5% less ammo (diminishing)', apply: s => { s.ammoEff += 0.05; } }, { id: 'scrapfeed',name: 'Scrap Feed', desc: '5% less ammo (diminishing)', apply: s => { s.ammoEff += 0.05; } },
{ id: 'boneknit', name: 'Bone Knit', desc: '3% chance to heal ½ heart after combat rooms', apply: s => { s.roomHealChance += 0.03; } }, { id: 'boneknit', name: 'Bone Knit', desc: '3% chance to heal ½ heart after combat rooms', apply: s => { s.roomHealChance += 0.03; } },
@@ -61,7 +61,7 @@ function openPerkDraft() {
} }
const opts = shuffle([...PERKS]).slice(0, 3); const opts = shuffle([...PERKS]).slice(0, 3);
G.perkChoices = opts; G.perkChoices = opts;
if (G.mode !== 'levelup') G.selectionLock = 1.5; if (G.mode !== 'levelup') G.selectionLock = 0.4; // brief guard against accidental instant picks
G.mode = 'levelup'; G.mode = 'levelup';
Sfx.levelup(); Sfx.levelup();
} }
+2 -4
View File
@@ -59,7 +59,7 @@ function updatePickups(dt) {
k.x += Math.cos(a) * sp * dt; k.y += Math.sin(a) * sp * dt; k.x += Math.cos(a) * sp * dt; k.y += Math.sin(a) * sp * dt;
} }
if (d < p.r + 12 && !(k.delay > 0)) { if (d < p.r + 12 && !(k.delay > 0) && k.type !== 'itemspot') {
collectPickup(k); collectPickup(k);
G.pickups.splice(i, 1); G.pickups.splice(i, 1);
} }
@@ -129,7 +129,7 @@ function collectPickup(k) {
if (old) { if (old) {
const a = rand(0, TAU); const a = rand(0, TAU);
spawnPickup('weapon', p.x + Math.cos(a) * 34, p.y + Math.sin(a) * 34, spawnPickup('weapon', p.x + Math.cos(a) * 34, p.y + Math.sin(a) * 34,
{ wid: old.id, ammo: old.ammo, delay: 0.6 }); { wid: old.id, ammo: old.ammo, delay: WEAPON_DROP_LOCKOUT });
} }
p.weapon = { id: w.id, ammo: k.ammo !== undefined ? k.ammo : w.ammo }; p.weapon = { id: w.id, ammo: k.ammo !== undefined ? k.ammo : w.ammo };
p.holstered = null; p.holstered = null;
@@ -141,7 +141,6 @@ function collectPickup(k) {
} }
case 'item': { case 'item': {
giveItem(k.iid); giveItem(k.iid);
for (const q of G.pickups) if (q.type === 'itemspot') q.dead = true; // remove pedestal base
break; break;
} }
case 'active': { case 'active': {
@@ -156,7 +155,6 @@ function collectPickup(k) {
addToast(def.name, def.desc + ' — [SPACE] when charged', 5.5); addToast(def.name, def.desc + ' — [SPACE] when charged', 5.5);
spawnText(p.x, p.y - 14, def.name.toUpperCase(), '#55f5dc'); spawnText(p.x, p.y - 14, def.name.toUpperCase(), '#55f5dc');
Sfx.item(); Sfx.item();
for (const q of G.pickups) if (q.type === 'itemspot') q.dead = true; // remove pedestal base
break; break;
} }
case 'stairs': case 'stairs':
+19 -11
View File
@@ -32,6 +32,7 @@ function initPlayer() {
animT: 0, attackT: 0, actionT: 0, hitT: 0, deathT: 0, animT: 0, attackT: 0, actionT: 0, hitT: 0, deathT: 0,
weapon: { id: 'bonepopper', ammo: Infinity }, weapon: { id: 'bonepopper', ammo: Infinity },
holstered: null, // a special weapon set aside while the pistol is out holstered: null, // a special weapon set aside while the pistol is out
metronomeTmp: 0, // ½-hearts lent by Crimson Metronome, repaid on a clean clear
level: 1, xp: 0, level: 1, xp: 0,
items: {}, // iid -> quality tier (1..3) items: {}, // iid -> quality tier (1..3)
perks: [], // perk ids drafted this run, in grant order perks: [], // perk ids drafted this run, in grant order
@@ -64,6 +65,8 @@ function swapWeapon(silent) {
function updatePlayer(dt) { function updatePlayer(dt) {
const p = G.player; const p = G.player;
const st = p.stats; const st = p.stats;
const w = WEAPONS[p.weapon.id];
const held = Input.mdown && !(G.shotLockT > 0); // fire allowed; locked for 50ms after a menu closes
if (p.invT > 0) p.invT -= dt; if (p.invT > 0) p.invT -= dt;
if (p.hitT > 0) p.hitT -= dt; if (p.hitT > 0) p.hitT -= dt;
if (p.frenzyT > 0) p.frenzyT -= dt; if (p.frenzyT > 0) p.frenzyT -= dt;
@@ -78,7 +81,7 @@ function updatePlayer(dt) {
if (keyDown('d', 'arrowright')) mx += 1; if (keyDown('d', 'arrowright')) mx += 1;
if (mx || my) { if (mx || my) {
const len = Math.hypot(mx, my); const len = Math.hypot(mx, my);
const spd = 178 * st.speedMul; const spd = 178 * st.speedMul * (held ? (w.id === 'bonepopper' ? 0.95 : 0.85) : 1);
p.x += (mx / len) * spd * dt; p.x += (mx / len) * spd * dt;
p.y += (my / len) * spd * dt; p.y += (my / len) * spd * dt;
p.step += dt * 9 * Math.min(st.speedMul, 1.8); p.step += dt * 9 * Math.min(st.speedMul, 1.8);
@@ -95,7 +98,7 @@ function updatePlayer(dt) {
p.y = clamp(p.y, G.arena.y0 + p.r, G.arena.y1 - p.r); p.y = clamp(p.y, G.arena.y0 + p.r, G.arena.y1 - p.r);
// aim // aim
p.aim = angleTo(p.x, p.y, Input.mx, Input.my); p.aim = angleTo(p.x, p.y, mxW(), myW());
// The waist and legs turn smoothly with travel while the weapon torso aims freely. // The waist and legs turn smoothly with travel while the weapon torso aims freely.
// Once planted, the lower body turns back under the independently aimed torso. // Once planted, the lower body turns back under the independently aimed torso.
if (mx || my) { if (mx || my) {
@@ -105,9 +108,8 @@ function updatePlayer(dt) {
} }
// firing // firing
const w = WEAPONS[p.weapon.id];
const sustained = ['cone', 'flame', 'saw', 'beam'].includes(w.behavior); const sustained = ['cone', 'flame', 'saw', 'beam'].includes(w.behavior);
Sfx.syncWeaponLoop(w, sustained && Input.mdown && p.weapon.ammo > 0); Sfx.syncWeaponLoop(w, sustained && held && p.weapon.ammo > 0);
if (p.fireT > 0) p.fireT -= dt; if (p.fireT > 0) p.fireT -= dt;
const frenzyMul = p.frenzyT > 0 ? 1 + st.frenzy : 1; const frenzyMul = p.frenzyT > 0 ? 1 + st.frenzy : 1;
const draughtMul = p.marrowDraughtT > 0 ? 2 : 1; const draughtMul = p.marrowDraughtT > 0 ? 2 : 1;
@@ -115,7 +117,7 @@ function updatePlayer(dt) {
const rate = w.interval / effectiveRate; const rate = w.interval / effectiveRate;
if (w.behavior === 'saw') { if (w.behavior === 'saw') {
if (Input.mdown && p.weapon.ammo > 0) { if (held && p.weapon.ammo > 0) {
sawTick(p, w, p.weapon, dt); sawTick(p, w, p.weapon, dt);
if (!(p.marrowDraughtT > 0)) p.weapon.ammo -= dt * (w.drain || 18) / st.ammoEff; if (!(p.marrowDraughtT > 0)) p.weapon.ammo -= dt * (w.drain || 18) / st.ammoEff;
p.muzzleT = 0.04; p.recoil = 0.18 + Math.sin(G.time * 45) * 0.05; p.muzzleT = 0.04; p.recoil = 0.18 + Math.sin(G.time * 45) * 0.05;
@@ -125,7 +127,7 @@ function updatePlayer(dt) {
else p.fireT = 0; else p.fireT = 0;
} else if (w.drain) { } else if (w.drain) {
// cone / flame streams: projectiles are free, ammo is time-denominated // cone / flame streams: projectiles are free, ammo is time-denominated
if (Input.mdown && p.weapon.ammo > 0 && !(p.panicRoomT > 0)) { if (held && p.weapon.ammo > 0 && !(p.panicRoomT > 0)) {
if (p.fireT <= 0) { if (p.fireT <= 0) {
fireWeapon(p, w); fireWeapon(p, w);
p.recoil = Math.min(1, p.recoil + 0.45); p.recoil = Math.min(1, p.recoil + 0.45);
@@ -138,7 +140,7 @@ function updatePlayer(dt) {
} }
else p.fireT = 0; else p.fireT = 0;
} else if (w.behavior === 'beam') { } else if (w.behavior === 'beam') {
if (Input.mdown && p.weapon.ammo > 0) { if (held && p.weapon.ammo > 0) {
p.charge = Math.min(p.charge + dt * effectiveRate * Math.sqrt(st.shotSpeedMul), w.chargeTime); p.charge = Math.min(p.charge + dt * effectiveRate * Math.sqrt(st.shotSpeedMul), w.chargeTime);
} else if (Input.mreleased && p.charge > 0) { } else if (Input.mreleased && p.charge > 0) {
if (p.charge >= w.chargeTime * 0.4) fireBeam(p, w, p.charge / w.chargeTime); if (p.charge >= w.chargeTime * 0.4) fireBeam(p, w, p.charge / w.chargeTime);
@@ -147,7 +149,7 @@ function updatePlayer(dt) {
p.charge = Math.max(0, p.charge - dt * 2); p.charge = Math.max(0, p.charge - dt * 2);
} }
} else { } else {
if (Input.mdown && p.fireT <= 0 && p.weapon.ammo > 0 && !(p.panicRoomT > 0)) { if (held && p.fireT <= 0 && p.weapon.ammo > 0 && !(p.panicRoomT > 0)) {
fireWeapon(p, w); fireWeapon(p, w);
// Twin Sidearm: the Bone Popper double-taps // Twin Sidearm: the Bone Popper double-taps
if (st.twinSidearm > 0 && p.weapon.id === 'bonepopper') fireWeapon(p, w); if (st.twinSidearm > 0 && p.weapon.id === 'bonepopper') fireWeapon(p, w);
@@ -156,10 +158,16 @@ function updatePlayer(dt) {
p.choirCount = (p.choirCount || 0) + 1; p.choirCount = (p.choirCount || 0) + 1;
if (p.choirCount >= 4) { p.choirCount = 0; fireWeapon(p, w); } if (p.choirCount >= 4) { p.choirCount = 0; fireWeapon(p, w); }
} }
// Crimson Metronome: every 8th shot costs ½ heart // Crimson Metronome: every 8th shot lends ½ heart, repaid when the room clears clean
if (st.crimsonMetronome > 0) { if (st.crimsonMetronome > 0) {
p.metronomeCount = (p.metronomeCount || 0) + 1; p.metronomeCount = (p.metronomeCount || 0) + 1;
if (p.metronomeCount >= 8) { p.metronomeCount = 0; p.hp = Math.max(0.5, p.hp - 1); spawnText(p.x, p.y - 14, '-½', '#e2472f'); } if (p.metronomeCount >= 8) {
p.metronomeCount = 0;
const loss = Math.min(1, Math.max(0, p.hp - 0.5));
p.hp = Math.max(0.5, p.hp - 1);
p.metronomeTmp += loss; // on loan: restored by a clean room clear
spawnText(p.x, p.y - 14, '-½', '#e2472f');
}
} }
p.recoil = Math.min(1, p.recoil + (w.behavior === 'slam' ? 1 : 0.45)); p.recoil = Math.min(1, p.recoil + (w.behavior === 'slam' ? 1 : 0.45));
p.muzzleT = 0.07; p.muzzleT = 0.07;
@@ -275,7 +283,7 @@ function updatePlayer(dt) {
} }
function armorBlockChance(rating) { function armorBlockChance(rating) {
return Math.min(0.75, Math.max(0, rating) / (1 + Math.max(0, rating))); return Math.min(0.75, Math.max(0, rating));
} }
function pressureRelief(dmgTaken) { function pressureRelief(dmgTaken) {
+47 -15
View File
@@ -3,11 +3,15 @@
const DIRS = { n: [0, -1], s: [0, 1], e: [1, 0], w: [-1, 0] }; const DIRS = { n: [0, -1], s: [0, 1], e: [1, 0], w: [-1, 0] };
const OPP = { n: 's', s: 'n', e: 'w', w: 'e' }; const OPP = { n: 's', s: 'n', e: 'w', w: 'e' };
const ROOM_SHAPES = { const ROOM_SHAPES = {
hall: { x0: 48, y0: 48, x1: 912, y1: 592 }, hall: { x0: 48, y0: 48, x1: 912, y1: 592 }, // unchanged — start & boss rooms
wide_hall: { x0: 48, y0: 208, x1: 912, y1: 432 }, wide_hall: { x0: 48, y0: 176, x1: 912, y1: 464 }, // h 224 -> 288 ("a little wider"; cy stays 320, min dim 288 < 300 keeps censer/broodsac excluded)
tall_hall: { x0: 368, y0: 48, x1: 592, y1: 592 }, tall_hall: { x0: 336, y0: 48, x1: 624, y1: 592 }, // w 224 -> 288 (cx stays 480)
chamber: { x0: 208, y0: 128, x1: 752, y1: 512 }, chamber: { x0: 208, y0: 128, x1: 752, y1: 512 }, // unchanged — item rooms
pit: { x0: 128, y0: 88, x1: 832, y1: 552 }, pit: { x0: 128, y0: 88, x1: 832, y1: 552 }, // unchanged
grand_hall:{ x0: 48, y0: 48, x1: 1680, y1: 592 }, // new, ~1.7× wide — camera scrolls horizontally (w 1632 > 960)
deep_hall: { x0: 480, y0: 48, x1: 912, y1: 1136 }, // new, ~1.85× tall — camera scrolls vertically (h 1088 > 640)
meat_hall: { x0: 48, y0: 48, x1: 1680, y1: 1136 }, // new, 1.7× × 1.85× — camera scrolls both axes
odd_hall: { x0: 160, y0: 128, x1: 800, y1: 512 }, // new, fits screen but off-center — visually "odd" with zero camera
}; };
const ROOM_THEMES = { const ROOM_THEMES = {
abattoir: { floor: ['tile_floor1', 'tile_floor1', 'tile_floor4', 'tile_floor6'], wall: ['tile_wall', 'tile_wall2'] }, abattoir: { floor: ['tile_floor1', 'tile_floor1', 'tile_floor4', 'tile_floor6'], wall: ['tile_wall', 'tile_wall2'] },
@@ -49,13 +53,14 @@ function genFloor(floor) {
const d = Math.abs(r.gx) + Math.abs(r.gy); const d = Math.abs(r.gx) + Math.abs(r.gy);
if (r.type === 'combat' && d >= 2) { r.type = 'item'; break; } if (r.type === 'combat' && d >= 2) { r.type = 'item'; break; }
} }
const combatShapes = ['hall', 'hall', 'hall', 'pit', 'chamber', 'wide_hall', 'tall_hall']; const smallShapes = ['hall', 'hall', 'pit', 'chamber', 'wide_hall', 'tall_hall', 'odd_hall'];
const bigShapes = ['grand_hall', 'deep_hall', 'meat_hall'];
const themes = Object.keys(ROOM_THEMES); const themes = Object.keys(ROOM_THEMES);
for (const r of Object.values(G.rooms)) { for (const r of Object.values(G.rooms)) {
r.theme = choice(themes); r.theme = choice(themes);
if (r.type === 'start' || r.type === 'boss') r.shape = 'hall'; if (r.type === 'start' || r.type === 'boss') r.shape = 'hall';
else if (r.type === 'item') r.shape = 'chamber'; else if (r.type === 'item') r.shape = 'chamber';
else r.shape = choice(combatShapes); else r.shape = (G.floor >= 2 && chance(0.30)) ? choice(bigShapes) : choice(smallShapes);
for (const [dir, [dx, dy]] of Object.entries(DIRS)) { for (const [dir, [dx, dy]] of Object.entries(DIRS)) {
r.doors[dir] = !!G.rooms[(r.gx + dx) + ',' + (r.gy + dy)]; r.doors[dir] = !!G.rooms[(r.gx + dx) + ',' + (r.gy + dy)];
} }
@@ -92,6 +97,7 @@ function enterRoom(gx, gy) {
G.roomDamaged = false; G.roomDamaged = false;
G.roomEnterT = G.time; G.roomEnterT = G.time;
G.entryFresh = false; G.entryFresh = false;
if (G.player) G.player.metronomeTmp = 0; // fresh per-room Crimson-Metronome loan ledger
const room = G.rooms[roomKey(gx, gy)]; const room = G.rooms[roomKey(gx, gy)];
G.cur = room; G.cur = room;
@@ -143,6 +149,24 @@ function enterRoom(gx, gy) {
} }
} }
// Isaac-style: the view centers on the player and is clamped so it never leaves
// the room. Rooms that fit the screen keep cam exactly at the room center —
// which is (0,0) for every current shape, so existing rendering is byte-identical.
// In larger rooms the camera may peek a little past the arena edge (into the
// drawn wall band, which is WALL thick) so walls and their exit doors come into
// view as the player reaches them.
const CAM_EDGE_PEEK = WALL * 0.9; // how far past the arena edge the camera may glide
function updateCamera() {
const a = G.arena, p = G.player;
if (!a || !p) return;
G.cam = G.cam || { x: 0, y: 0 };
G.cam.x = a.w <= W ? a.cx - W / 2 : clamp(p.x - W / 2, a.x0 - CAM_EDGE_PEEK, a.x1 - W + CAM_EDGE_PEEK);
G.cam.y = a.h <= H ? a.cy - H / 2 : clamp(p.y - H / 2, a.y0 - CAM_EDGE_PEEK, a.y1 - H + CAM_EDGE_PEEK);
}
// screen-space mouse -> world coords (safe pre-player: cam exists from state.js)
function mxW() { return Input.mx + G.cam.x; }
function myW() { return Input.my + G.cam.y; }
function separateEntryWave() { function separateEntryWave() {
const p = G.player; const p = G.player;
const a = G.arena; const a = G.arena;
@@ -219,10 +243,10 @@ function updateRoom(dt) {
enterRoom(r.gx + dx, r.gy + dy); enterRoom(r.gx + dx, r.gy + dy);
const next = G.arena; const next = G.arena;
// place player at the opposite door in the destination's own shape // place player at the opposite door in the destination's own shape
if (dir === 'n') p.y = next.y1 - p.r - 8; if (dir === 'n') { p.y = next.y1 - p.r - 8; p.x = next.cx; }
if (dir === 's') p.y = next.y0 + p.r + 8; if (dir === 's') { p.y = next.y0 + p.r + 8; p.x = next.cx; }
if (dir === 'e') p.x = next.x0 + p.r + 8; if (dir === 'e') { p.x = next.x0 + p.r + 8; p.y = next.cy; }
if (dir === 'w') p.x = next.x1 - p.r - 8; if (dir === 'w') { p.x = next.x1 - p.r - 8; p.y = next.cy; }
p.x = clamp(p.x, next.x0 + p.r, next.x1 - p.r); p.x = clamp(p.x, next.x0 + p.r, next.x1 - p.r);
p.y = clamp(p.y, next.y0 + p.r, next.y1 - p.r); p.y = clamp(p.y, next.y0 + p.r, next.y1 - p.r);
if (G.entryFresh) { separateEntryWave(); G.entryFresh = false; } if (G.entryFresh) { separateEntryWave(); G.entryFresh = false; }
@@ -241,6 +265,13 @@ function recordRoomClear(room) {
} }
// active items charge on room clears: +1 per combat room, +2 per boss // active items charge on room clears: +1 per combat room, +2 per boss
const p = G.player; const p = G.player;
// Crimson Metronome: a ½-heart loan is repaid when the room clears clean
if (!G.roomDamaged && p.metronomeTmp > 0) {
const back = Math.min(p.metronomeTmp, p.stats.maxHp - p.hp);
p.hp += back;
p.metronomeTmp = 0;
if (back > 0) spawnText(p.x, p.y - 26, 'LOAN REPAID +' + back, '#d98991');
}
if (p && p.active) { if (p && p.active) {
const a = ACTIVES[p.active.iid]; const a = ACTIVES[p.active.iid];
if (a) { if (a) {
@@ -338,11 +369,11 @@ const FLOOR_TILE_POOL = [
]; ];
const WALL_TILE_POOL = ['tile_wall', 'tile_wall', 'tile_wall2', 'tile_wall3', 'tile_wall4']; const WALL_TILE_POOL = ['tile_wall', 'tile_wall', 'tile_wall2', 'tile_wall3', 'tile_wall4'];
function drawFloorTiles(target, r) { function drawFloorTiles(target, r, extW, extH) {
const a = roomBounds(r); const a = roomBounds(r);
const theme = ROOM_THEMES[r.theme] || ROOM_THEMES.abattoir; const theme = ROOM_THEMES[r.theme] || ROOM_THEMES.abattoir;
target.fillStyle = '#050204'; target.fillStyle = '#050204';
target.fillRect(0, 0, W, H); target.fillRect(0, 0, extW === undefined ? W : extW, extH === undefined ? H : extH);
target.save(); target.save();
target.beginPath(); target.rect(a.x0, a.y0, a.w, a.h); target.clip(); target.beginPath(); target.rect(a.x0, a.y0, a.w, a.h); target.clip();
const tx0 = Math.floor(a.x0 / TILE), tx1 = Math.ceil(a.x1 / TILE); const tx0 = Math.floor(a.x0 / TILE), tx1 = Math.ceil(a.x1 / TILE);
@@ -364,11 +395,12 @@ function drawFloor(ctx, r) {
if (!canCache) { drawFloorTiles(ctx, r); return; } if (!canCache) { drawFloorTiles(ctx, r); return; }
const key = r.gx + ',' + r.gy + ':' + G.floor + ':' + r.shape + ':' + r.theme; const key = r.gx + ',' + r.gy + ':' + G.floor + ':' + r.shape + ':' + r.theme;
if (!G.roomLayer || G.roomLayerKey !== key) { if (!G.roomLayer || G.roomLayerKey !== key) {
const a = roomBounds(r);
const layer = document.createElement('canvas'); const layer = document.createElement('canvas');
layer.width = W; layer.height = H; layer.width = Math.max(W, a.x1); layer.height = Math.max(H, a.y1);
const lctx = layer.getContext('2d'); const lctx = layer.getContext('2d');
lctx.imageSmoothingEnabled = false; lctx.imageSmoothingEnabled = false;
drawFloorTiles(lctx, r); drawFloorTiles(lctx, r, layer.width, layer.height);
G.roomLayer = layer; G.roomLayerKey = key; G.roomLayer = layer; G.roomLayerKey = key;
} }
ctx.drawImage(G.roomLayer, 0, 0); ctx.drawImage(G.roomLayer, 0, 0);
+1 -1
View File
@@ -111,7 +111,7 @@ const SfxBank = {
if (this.buffers.has(name) || this.pending.has(name) || this.failed.has(name)) return; if (this.buffers.has(name) || this.pending.has(name) || this.failed.has(name)) return;
const file = this.FILES[name]; const file = this.FILES[name];
if (!file || !this.ctx || typeof fetch !== 'function') return; if (!file || !this.ctx || typeof fetch !== 'function') return;
const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=51') const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=53')
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); }) .then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
.then(bytes => this.ctx.decodeAudioData(bytes)) .then(bytes => this.ctx.decodeAudioData(bytes))
.then(buffer => this.buffers.set(name, this.trim(buffer))) .then(buffer => this.buffers.set(name, this.trim(buffer)))
+1 -1
View File
@@ -86,7 +86,7 @@ const Sprites = {
const img = new Image(); const img = new Image();
img.onload = () => { this.imgs[name] = img; if (--pending === 0) G.imagesLoaded = true; }; img.onload = () => { this.imgs[name] = img; if (--pending === 0) G.imagesLoaded = true; };
img.onerror = () => { if (--pending === 0) G.imagesLoaded = true; }; img.onerror = () => { if (--pending === 0) G.imagesLoaded = true; };
img.src = 'assets/' + name + '.png?v=51'; img.src = 'assets/' + name + '.png?v=53';
} }
}, },
+4
View File
@@ -28,6 +28,7 @@ const G = {
pressure: 1, pressure: 1,
pressureDial: 0, pressureDial: 0,
streak: 0, streak: 0,
shotLockT: 0,
roomDamaged: false, roomDamaged: false,
roomEnterT: 0, roomEnterT: 0,
recentHits: [], recentHits: [],
@@ -53,6 +54,7 @@ const G = {
roomLayerKey: '', roomLayerKey: '',
atmosphereLayer: null, atmosphereLayer: null,
arena: { x0: WALL, y0: WALL, x1: W - WALL, y1: H - WALL, cx: W / 2, cy: H / 2, w: W - WALL * 2, h: H - WALL * 2 }, arena: { x0: WALL, y0: WALL, x1: W - WALL, y1: H - WALL, cx: W / 2, cy: H / 2, w: W - WALL * 2, h: H - WALL * 2 },
cam: { x: 0, y: 0 }, // world coords of the viewport's top-left; 0,0 for every fit-to-screen room
}; };
const PERK_POWER_WEIGHT = 0.5; const PERK_POWER_WEIGHT = 0.5;
@@ -60,6 +62,8 @@ const PERK_POWER_WEIGHT = 0.5;
const AMMO_DROP_SCALE = 0.75; const AMMO_DROP_SCALE = 0.75;
const STUN_UNIT = 0.03 / 0.97; // Sine Weave raw rating per stack, so the first stack = 3% stagger chance const STUN_UNIT = 0.03 / 0.97; // Sine Weave raw rating per stack, so the first stack = 3% stagger chance
const PRESSURE_MIN = 0.60, PRESSURE_MAX = 2.00; const PRESSURE_MIN = 0.60, PRESSURE_MAX = 2.00;
// seconds a player-dropped weapon stays un-pickable (prevents instant swap loops)
const WEAPON_DROP_LOCKOUT = 2.5;
const PRESSURE_UNIT = 0.01; // one "point" on the dial scale const PRESSURE_UNIT = 0.01; // one "point" on the dial scale
const PRESSURE_GAIN = PRESSURE_UNIT; // retained: dial 0 == 1 point const PRESSURE_GAIN = PRESSURE_UNIT; // retained: dial 0 == 1 point
const PRESSURE_DROP_BASE = 3; // dial 0 == 0.03 relief base const PRESSURE_DROP_BASE = 3; // dial 0 == 0.03 relief base
+14 -7
View File
@@ -47,7 +47,7 @@ function fireWeapon(p, w) {
const st = p.stats; const st = p.stats;
const ang = p.aim; const ang = p.aim;
const muzzle = w.muzzle || 35; const muzzle = w.muzzle || 35;
const bx = p.x + Math.cos(ang) * muzzle, by = p.y + Math.sin(ang) * muzzle; let bx = p.x + Math.cos(ang) * muzzle, by = p.y + Math.sin(ang) * muzzle;
const dmgMul = st.dmgMul * st.dmgLiveMul; const dmgMul = st.dmgMul * st.dmgLiveMul;
const spdMul = st.shotSpeedMul; const spdMul = st.shotSpeedMul;
spawnMuzzleFx(bx, by, ang, w.behavior); spawnMuzzleFx(bx, by, ang, w.behavior);
@@ -86,6 +86,10 @@ function fireWeapon(p, w) {
break; break;
} }
case 'cone': case 'flame': { case 'cone': case 'flame': {
if (w.behavior === 'flame') {
const rh = ang + Math.PI / 2;
bx += Math.cos(rh) * 26; by += Math.sin(rh) * 26; // right-hand hold: origin right of the body
}
for (const center of volley) { for (const center of volley) {
for (let i = 0; i < 2; i++) mk(center + rand(-w.spread, w.spread), { pierce: 2 + st.pierce, r: 6 * st.sizeMul }); for (let i = 0; i < 2; i++) mk(center + rand(-w.spread, w.spread), { pierce: 2 + st.pierce, r: 6 * st.sizeMul });
} }
@@ -100,11 +104,11 @@ function fireWeapon(p, w) {
break; break;
} }
case 'lob': case 'lob_trap': case 'lob_swarm': { case 'lob': case 'lob_trap': case 'lob_swarm': {
const primaryD = Math.max(dist(bx, by, Input.mx, Input.my), 1); const primaryD = Math.max(dist(bx, by, mxW(), myW()), 1);
for (let i = 0; i < volley.length; i++) { for (let i = 0; i < volley.length; i++) {
const a = volley[i]; const a = volley[i];
const tx = i === 0 ? Input.mx : p.x + Math.cos(a) * primaryD; const tx = i === 0 ? mxW() : p.x + Math.cos(a) * primaryD;
const ty = i === 0 ? Input.my : p.y + Math.sin(a) * primaryD; const ty = i === 0 ? myW() : p.y + Math.sin(a) * primaryD;
const d = Math.max(dist(bx, by, tx, ty), 1); const d = Math.max(dist(bx, by, tx, ty), 1);
const flight = clamp(d / (520 * spdMul), 0.12, 0.8); const flight = clamp(d / (520 * spdMul), 0.12, 0.8);
mk(a, { mk(a, {
@@ -144,9 +148,12 @@ function sawTick(p, def, inst, dt) {
const inert = 1 + (st.pierce + st.bounce + st.homing) * 0.06; const inert = 1 + (st.pierce + st.bounce + st.homing) * 0.06;
let hitAny = false; let hitAny = false;
for (const a of angles) { for (const a of angles) {
const reach = 30 * st.rangeMul * Math.sqrt(st.shotSpeedMul); const reach = 58 * st.rangeMul * Math.sqrt(st.shotSpeedMul); // out to the blade (was 30)
const cx = p.x + Math.cos(a) * reach, cy = p.y + Math.sin(a) * reach; const rh = p.aim + Math.PI / 2; // right-hand side, perpendicular to aim
const radius = 34 * Math.sqrt(st.sizeMul); const rho = 36 * Math.sqrt(st.sizeMul); // right-hand offset distance
const cx = p.x + Math.cos(a) * reach + Math.cos(rh) * rho;
const cy = p.y + Math.sin(a) * reach + Math.sin(rh) * rho;
const radius = 38 * Math.sqrt(st.sizeMul); // full blade (was 34)
for (const e of G.enemies) { for (const e of G.enemies) {
if (dist2(cx, cy, e.x, e.y) < (radius + e.r) * (radius + e.r)) { if (dist2(cx, cy, e.x, e.y) < (radius + e.r) * (radius + e.r)) {
const tickScale = dt / Math.max(0.01, def.interval || 0.1); const tickScale = dt / Math.max(0.01, def.interval || 0.1);
+5 -1
View File
@@ -1,17 +1,21 @@
{ {
"name": "meatslicer", "name": "meatslicer",
"productName": "MeatSlicer", "productName": "MeatSlicer",
"version": "0.5.2", "version": "0.7.0",
"private": true, "private": true,
"description": "A Butcher's Descent - clear, harvest, descend.", "description": "A Butcher's Descent - clear, harvest, descend.",
"main": "desktop/main.js", "main": "desktop/main.js",
"scripts": { "scripts": {
"start": "electron .", "start": "electron .",
"dist": "electron-builder --win zip", "dist": "electron-builder --win zip",
"dist:linux": "electron-builder --linux --publish never",
"test": "node test/smoke.js" "test": "node test/smoke.js"
}, },
"devDependencies": { "devDependencies": {
"electron": "^40.10.2", "electron": "^40.10.2",
"electron-builder": "^25.1.8" "electron-builder": "^25.1.8"
},
"allowScripts": {
"electron@40.10.2": true
} }
} }
+255 -22
View File
@@ -61,7 +61,7 @@ for (const f of files) {
} }
// const/let top-level bindings live in the context's lexical scope, not on the // const/let top-level bindings live in the context's lexical scope, not on the
// global object — expose the ones the harness pokes at directly. // global object — expose the ones the harness pokes at directly.
vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, Sfx, SfxBank, W, H, WALL, ITEMS, ACTIVES, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS, PRESSURE_UNIT, PRESSURE_MIN, PRESSURE_MAX, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX, ROOM_SHAPES, ROOM_THEMES, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN, ENTRY_WARN, ENTRY_BUFFER, STUN_UNIT, stunChance, separateEntryWave, defaultPlayerStats, applyPressureDelta, debugRebuildStats, debugEnabled, DEBUG_PAGES })', 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, ENTRY_WARN, ENTRY_BUFFER, STUN_UNIT, stunChance, separateEntryWave, defaultPlayerStats, applyPressureDelta, debugRebuildStats, debugEnabled, DEBUG_PAGES, updateCamera, mxW, myW, angleTo, genFloor, roomBounds, WEAPON_DROP_LOCKOUT, CAM_EDGE_PEEK })', sandbox);
let simTime = 0; let simTime = 0;
const ctx = sandbox; const ctx = sandbox;
@@ -88,6 +88,16 @@ function check(name, cond) {
else { console.log(' FAIL ' + name); failures++; } else { console.log(' FAIL ' + name); failures++; }
} }
// Deterministic PRNG override so statistical sampling checks pass every run.
// Restores Math.random afterwards; nested calls work via an internal stack.
function withSeededRandom(seed, fn) {
let s = seed >>> 0;
const orig = Math.random;
const inner = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
Math.random = inner;
try { return fn(); } finally { Math.random = orig; }
}
console.log('== boot =='); console.log('== boot ==');
for (const fn of (listeners['win:load'] || [])) fn(); for (const fn of (listeners['win:load'] || [])) fn();
step(5, 16); step(5, 16);
@@ -279,15 +289,78 @@ console.log('== variable room shapes and themes ==');
ctx.G.cur = savedRoom; ctx.G.arena = savedArena; ctx.G.player.x = px; ctx.G.player.y = py; ctx.G.cur = savedRoom; ctx.G.arena = savedArena; ctx.G.player.x = px; ctx.G.player.y = py;
} }
console.log('== big rooms + camera ==');
{
// fit rooms keep camera pinned at (0,0) — current look is unchanged
const fit = ctx.makeRoom(0, 0, 'combat'); fit.shape = 'hall';
ctx.setArenaForRoom(fit);
ctx.G.player.x = 100; ctx.G.player.y = 320;
ctx.updateCamera();
check('fit rooms keep camera at origin', ctx.G.cam.x === 0 && ctx.G.cam.y === 0);
// wide room: camera follows the player, clamped to the room
const wide = ctx.makeRoom(0, 0, 'combat'); wide.shape = 'grand_hall';
ctx.setArenaForRoom(wide);
const wa = ctx.G.arena;
ctx.G.player.x = 100; ctx.G.player.y = 320; ctx.updateCamera();
check('big-room camera peeks past the left arena edge toward the wall',
Math.abs(ctx.G.cam.x - (wa.x0 - ctx.CAM_EDGE_PEEK)) < 1);
ctx.G.player.x = wa.x1 - 200; ctx.G.player.y = 320; ctx.updateCamera();
check('big-room camera peeks past the right arena edge toward the wall',
Math.abs(ctx.G.cam.x - (wa.x1 - ctx.W + ctx.CAM_EDGE_PEEK)) < 1);
ctx.G.player.x = wa.cx; ctx.G.player.y = 320; ctx.updateCamera();
check('camera centers on the player mid-room', ctx.G.cam.x === wa.cx - ctx.W / 2 && ctx.G.cam.y === 0);
// the wall peek stays inside the drawn wall band (never black void)
check('wall peek stays within the wall band', ctx.CAM_EDGE_PEEK <= ctx.WALL);
// world-mouse conversion
ctx.G.cam.x = wa.cx - ctx.W / 2; ctx.Input.mx = 120; ctx.Input.my = 240;
check('screen mouse maps to world coords', ctx.mxW() === 120 + ctx.G.cam.x && ctx.myW() === 240);
// end-to-end: live in a big room — update+draw path, floor direct path, aim
ctx.startRun();
const big = ctx.G.rooms['0,0'];
big.shape = 'meat_hall';
ctx.G.cur = big; ctx.setArenaForRoom(big);
ctx.G.player.x = ctx.G.arena.x0 + 80; ctx.G.player.y = ctx.G.arena.y0 + 80;
ctx.G.enemies.length = 0;
ctx.Input.mx = 300; ctx.Input.my = 200; ctx.Input.mdown = true;
step(3, 16); // runs update() (firing, weapon lob path) + draw() (direct floor, camera)
check('big room runs without error and aims at world mouse',
Math.abs(ctx.G.player.aim - ctx.angleTo(ctx.G.player.x, ctx.G.player.y, ctx.Input.mx + ctx.G.cam.x, ctx.Input.my + ctx.G.cam.y)) < 1e-9);
ctx.Input.mdown = false;
ctx.startRun(); step(3, 16);
}
// floor >= 2 mixes big/odd rooms into combat shapes; doors stay reciprocal
{
ctx.G.floor = 6;
let sawBig = false, reciprocal = true;
for (let trial = 0; trial < 40 && !sawBig; trial++) {
ctx.genFloor(6);
for (const r of Object.values(ctx.G.rooms)) {
if (r.type === 'combat' && ['grand_hall', 'deep_hall', 'meat_hall', 'odd_hall'].includes(r.shape)) sawBig = true;
if (r.doors.n && !ctx.G.rooms[(r.gx) + ',' + (r.gy - 1)]) reciprocal = false;
if (r.doors.s && !ctx.G.rooms[(r.gx) + ',' + (r.gy + 1)]) reciprocal = false;
if (r.doors.e && !ctx.G.rooms[(r.gx + 1) + ',' + (r.gy)]) reciprocal = false;
if (r.doors.w && !ctx.G.rooms[(r.gx - 1) + ',' + (r.gy)]) reciprocal = false;
}
}
check('floor 6 mixes in big and odd rooms', sawBig);
check('big-room maps keep reciprocal doors', reciprocal);
ctx.startRun(); step(3, 16);
}
console.log('== XP / perks =='); console.log('== XP / perks ==');
ctx.gainXP(100); ctx.gainXP(100);
step(5, 16); step(5, 16);
check('levelup triggered', ctx.G.mode === 'levelup' || ctx.G.pendingLevelups > 0); check('levelup triggered', ctx.G.mode === 'levelup' || ctx.G.pendingLevelups > 0);
check('perk selection is locked on first presentation', (ctx.G.selectionLock || 0) > 0); check('perk selection is locked on first presentation', (ctx.G.selectionLock || 0) > 0);
check('perk selection lock is a brief ~0.4s guard', (ctx.G.selectionLock || 0) > 0 && (ctx.G.selectionLock || 0) <= 0.4);
const lockedChoices = ctx.G.perkChoices; const lockedChoices = ctx.G.perkChoices;
tap('1'); // ignored while the selection lock is active tap('1'); // ignored while the selection lock is active
check('locked draft ignores selection input', ctx.G.perkChoices === lockedChoices); check('locked draft ignores selection input', ctx.G.perkChoices === lockedChoices);
step(100, 16); // advance 1.6s in levelup mode, clearing the 1.5s selection lock step(100, 16); // advance 1.6s in levelup mode, clearing the 0.4s selection lock
let guard = 0; let guard = 0;
while (ctx.G.mode === 'levelup' && guard++ < 30) { tap('1'); step(2, 16); } while (ctx.G.mode === 'levelup' && guard++ < 30) { tap('1'); step(2, 16); }
check('perks drafted', ctx.G.mode === 'play'); check('perks drafted', ctx.G.mode === 'play');
@@ -303,7 +376,7 @@ console.log('== rebalanced perks + automatic draft ==');
ctx.G.pendingLevelups = 1; ctx.G.autoPerk = false; ctx.openPerkDraft(); ctx.G.pendingLevelups = 1; ctx.G.autoPerk = false; ctx.openPerkDraft();
p.stats.rerolls = 1; tap('r'); p.stats.rerolls = 1; tap('r');
check('Reroll Rib currency refreshes a manual draft', p.stats.rerolls === 0 && ctx.G.perkChoices.length === 3); check('Reroll Rib currency refreshes a manual draft', p.stats.rerolls === 0 && ctx.G.perkChoices.length === 3);
step(100, 16); // clear the 1.5s selection lock armed by openPerkDraft above step(100, 16); // clear the 0.4s selection lock armed by openPerkDraft above
tap('4'); tap('4');
check('one-off random draft choice resolves normally', ctx.G.mode === 'play' && ctx.G.pendingLevelups === 0); check('one-off random draft choice resolves normally', ctx.G.mode === 'play' && ctx.G.pendingLevelups === 0);
const levelBefore = p.level; const levelBefore = p.level;
@@ -314,21 +387,149 @@ console.log('== rebalanced perks + automatic draft ==');
ctx.G.autoPerk = false; ctx.G.autoPerk = false;
ctx.startRun(); step(3, 16); ctx.startRun(); step(3, 16);
} }
console.log('== upgrade card click does not fire ==');
{
ctx.startRun(); step(3, 16); // fresh run in play mode
ctx.G.enemies.length = 0; // determinism: nothing can interfere
const perksBefore = ctx.G.player.perks.length;
ctx.G.pendingLevelups = 1; ctx.G.perkChoices = ctx.PERKS.slice(0, 3); ctx.G.mode = 'levelup';
// click inside card 0 (cards start at x=207, y=220, 170x230)
ctx.Input.mx = 300; ctx.Input.my = 300;
ctx.Input.mpressed = true; ctx.Input.mdown = true;
step(6, 16); // first frame consumes the click, the rest run with mdown held
check('card click exits the draft into play', ctx.G.mode === 'play');
check('card click grants exactly one perk', ctx.G.player.perks.length === perksBefore + 1);
check('card click fires no bullet (mdown not carried into play)', ctx.G.bullets.length === 0);
}
console.log('== big-room door entry lands at the door ==');
{
ctx.startRun(); step(3, 16);
// current room is the start room; force a west neighbor as a deep_hall
const cur = ctx.G.rooms['0,0'];
cur.type = 'start'; cur.shape = 'hall';
const west = ctx.makeRoom(-1, 0, 'combat'); west.shape = 'deep_hall';
ctx.G.rooms['-1,0'] = west;
cur.doors.w = true; west.doors.e = true;
cur.pickups = ctx.G.pickups;
ctx.G.cur = cur; ctx.setArenaForRoom(cur);
const a = ctx.G.arena;
ctx.G.enemies.length = 0;
ctx.G.player.y = a.cy; ctx.G.player.x = a.x0 + ctx.G.player.r + 2;
ctx.Input.mx = ctx.G.player.x; ctx.Input.my = ctx.G.player.y; ctx.Input.mdown = false;
press('a'); step(1, 16); release('a'); step(2, 16);
const na = ctx.roomBounds(west);
check('entering a big room places the player at its door, not in a wall',
ctx.G.cur === west &&
Math.abs(ctx.G.player.x - (na.x1 - ctx.G.player.r - 8)) < 1 &&
Math.abs(ctx.G.player.y - na.cy) < 1);
ctx.startRun(); step(3, 16);
}
console.log('== red right hand hitbox sits on the right ==');
{
ctx.startRun(); step(3, 16);
const p = ctx.G.player; p.aim = 0; p.weapon = { id: 'redhand', ammo: 999 }; p.holstered = null;
ctx.G.enemies.length = 0;
// NEW hitbox center: (p.x + 58, p.y + 36), radius 38. Old center: (p.x + 30, p.y), radius 34.
// Point (p.x+75, p.y+40): dist to old center ~60 > 34+18 (misses), dist to new ~17 < 38+18 (hits).
const e = ctx.makeEnemy('shambler', p.x + 75, p.y + 40, 1, false); e.spd = 0; e.hp = 5;
ctx.G.enemies.push(e); const hp = e.hp;
ctx.sawTick(p, ctx.WEAPONS.redhand, p.weapon, 0.05);
ctx.G.enemies.length = 0;
check('chainsaw hits the right-plus-forward blade zone', e.hp < hp);
ctx.startRun(); step(3, 16);
}
console.log('== flamethrower spawns right of the body ==');
{
ctx.startRun(); step(3, 16);
const p = ctx.G.player; p.aim = 0; p.weapon = { id: 'cauterizer', ammo: 999 }; p.holstered = null;
ctx.G.bullets.length = 0; ctx.fireWeapon(p, ctx.WEAPONS.cauterizer);
const flame = ctx.G.bullets.filter(b => b.behavior === 'flame');
const minY = Math.min(...flame.map(b => b.y));
check('flame pellets start right of the body center', minY > (p.y + 20));
ctx.G.bullets.length = 0; ctx.startRun(); step(3, 16);
}
console.log('== firing slows the player ==');
{
ctx.startRun(); step(3, 16);
const p = ctx.G.player, dt = 1 / 60, fr = 30;
ctx.G.enemies.length = 0;
const measure = () => {
const x0 = p.x; ctx.Input.mdown = true; ctx.Input.mx = 900; ctx.Input.my = p.y; press('d');
for (let i = 0; i < fr; i++) ctx.updatePlayer(dt);
release('d'); ctx.Input.mdown = false;
return p.x - x0;
};
p.x = 200; p.y = 320; p.weapon = { id: 'bonepopper', ammo: 999 }; p.holstered = null;
const pistolDx = measure();
p.x = 200; p.weapon = { id: 'repeater', ammo: 999 };
const heavyDx = measure();
const expected = (factor) => 178 * factor * (fr * dt);
check('pistol firing slows the player ~5%, heavy ~15%',
Math.abs(pistolDx - expected(0.95)) < 0.5 && Math.abs(heavyDx - expected(0.85)) < 0.5);
ctx.G.bullets.length = 0; ctx.startRun(); step(3, 16);
}
console.log('== pedestal base persists after the item is taken ==');
{
ctx.startRun(); step(3, 16);
ctx.G.pickups.length = 0;
const p = ctx.G.player;
const x = p.x + 40, y = p.y;
ctx.spawnItemPedestal(x, y, 'hollowpoints', 'room');
p.x = x; p.y = y; step(8, 16); // walk onto it: the item is taken
const baseBefore = ctx.G.pickups.some(q => q.type === 'itemspot');
for (let i = 0; i < 40; i++) { p.x += 3; step(1, 16); } // walk back and forth over the pedestal
const baseAfter = ctx.G.pickups.some(q => q.type === 'itemspot');
check('pedestal survives item pickup and being walked over', baseBefore && baseAfter);
ctx.startRun(); step(3, 16);
}
console.log('== dropped weapon lockout ==');
{
ctx.startRun(); step(3, 16); // clean, armed player in play mode
const p = ctx.G.player;
p.weapon = { id: 'cleaver', ammo: 60 }; p.holstered = null;
ctx.G.pickups.length = 0;
ctx.spawnPickup('weapon', p.x, p.y, { wid: 'repeater', ammo: 30 });
ctx.updatePickups(0.016); ctx.updatePickups(0.016); // pick up the repeater
check('weapon swap happens instantly on fresh loot', p.weapon.id === 'repeater');
const drop = ctx.G.pickups.find(k => k.type === 'weapon');
check('old weapon dropped with the lockout delay', !!drop && Math.abs(drop.delay - ctx.WEAPON_DROP_LOCKOUT) < 0.1);
if (drop) { drop.vx = 0; drop.vy = 0; } // hold it still for the timing checks
// standing on the drop, it must stay uncollectible for ~70% of the lockout
const blockedFor = Math.floor(ctx.WEAPON_DROP_LOCKOUT * 0.7 / 0.016);
for (let i = 0; i < blockedFor; i++) { p.x = drop.x; p.y = drop.y; ctx.updatePickups(0.016); }
const stillBlocked = ctx.G.pickups.some(k => k.type === 'weapon') && p.weapon.id === 'repeater';
check('dropped weapon stays un-pickable inside the lockout window', stillBlocked);
// the remaining ~30% (plus margin) lets it be re-picked
const collectFor = Math.ceil(ctx.WEAPON_DROP_LOCKOUT / 0.016) + 10;
for (let i = 0; i < collectFor; i++) { p.x = drop.x; p.y = drop.y; ctx.updatePickups(0.016); }
check('dropped weapon re-pickable after the lockout', p.weapon.id === 'cleaver');
check('lockout constant is within the 2-3s request', ctx.WEAPON_DROP_LOCKOUT >= 2 && ctx.WEAPON_DROP_LOCKOUT <= 3);
}
autoDraft = true; // later sections must not get stuck in the perk draft autoDraft = true; // later sections must not get stuck in the perk draft
console.log('== nearby-item description toast =='); console.log('== nearby-item description toast ==');
{ {
const p = ctx.G.player; const p = ctx.G.player;
p.x = 300; p.y = 320; // deterministic anchor away from walls so teleports aren't clamped
ctx.G.pickups.length = 0; ctx.G.pickups.length = 0;
ctx.G.toasts.length = 0; ctx.G.toasts.length = 0;
ctx.spawnPickup('item', p.x + 50, p.y, { iid: 'hollowpoints' }); const it = ctx.spawnPickup('item', p.x + 50, p.y, { iid: 'hollowpoints' });
it.vx = 0; it.vy = 0; // deterministic: no drift while stepping
step(1, 16); // within the 90px radius -> toast once step(1, 16); // within the 90px radius -> toast once
check('nearby item toasts its description once', check('nearby item toasts its description once',
ctx.G.toasts.length === 1 && ctx.G.toasts[0].text === ctx.ITEMS.hollowpoints.name && ctx.G.toasts[0].sub === ctx.ITEMS.hollowpoints.desc); ctx.G.toasts.length === 1 && ctx.G.toasts[0].text === ctx.ITEMS.hollowpoints.name && ctx.G.toasts[0].sub === ctx.ITEMS.hollowpoints.desc);
step(4, 16); // stay adjacent, must not re-fire step(4, 16); // stay adjacent, must not re-fire
check('proximity toast does not repeat while adjacent', ctx.G.toasts.length === 1); check('proximity toast does not repeat while adjacent', ctx.G.toasts.length === 1);
p.x += 500; step(1, 16); // leave the 90px radius -> re-arms p.x += 500; step(1, 16); // leave the 90px radius -> re-arms
p.x -= 420; step(1, 16); // back within 90 (outside pickup reach) -> fires again p.x -= 400; step(1, 16); // back to 50px (within 90 toast radius, outside the 32px pickup reach) -> fires again
check('proximity toast re-arms after leaving', ctx.G.toasts.length === 2); check('proximity toast re-arms after leaving', ctx.G.toasts.length === 2);
ctx.G.pickups.length = 0; ctx.G.pickups.length = 0;
ctx.startRun(); step(3, 16); ctx.startRun(); step(3, 16);
@@ -396,10 +597,10 @@ console.log('== expanded perk and item rosters ==');
ctx.G.player = livePlayer; ctx.G.player = livePlayer;
} }
console.log('== diminishing armor block chance =='); console.log('== armor is a literal damage-dodge chance ==');
{ {
const c1 = ctx.armorBlockChance(0.5), c2 = ctx.armorBlockChance(1), cHuge = ctx.armorBlockChance(999); const c1 = ctx.armorBlockChance(0.05), c2 = ctx.armorBlockChance(0.10), cHuge = ctx.armorBlockChance(999);
check('armor chance has diminishing absolute returns', c1 > 0 && c2 > c1 && (c2 - c1) < c1); check('armor chance tracks the rating directly', Math.abs(c1 - 0.05) < 1e-9 && Math.abs(c2 - 0.10) < 1e-9);
check('armor chance is capped at 75%', Math.abs(cHuge - 0.75) < 0.0001); check('armor chance is capped at 75%', Math.abs(cHuge - 0.75) < 0.0001);
const p = ctx.G.player, hp = p.hp; const p = ctx.G.player, hp = p.hp;
const realChance = ctx.chance; const realChance = ctx.chance;
@@ -555,9 +756,11 @@ console.log('== tier-scaled items + duplicate favoring ==');
check('kill-explosion hurts nearby enemies', v2.hp < v2hpBefore); check('kill-explosion hurts nearby enemies', v2.hp < v2hpBefore);
ctx.G.enemies.length = 0; ctx.G.enemies.length = 0;
let ownedHits = 0; let ownedHits = 0;
for (let i = 0; i < 200; i++) { withSeededRandom(0xC0FFEE, () => {
if (p.items[ctx.randomItemId()]) ownedHits++; for (let i = 0; i < 200; i++) {
} if (p.items[ctx.randomItemId()]) ownedHits++;
}
});
check('item rolls favor owned items (' + ownedHits + '/200)', ownedHits >= 40); check('item rolls favor owned items (' + ownedHits + '/200)', ownedHits >= 40);
} }
@@ -759,7 +962,7 @@ console.log('== per-weapon muzzle spawn distances ==');
p.aim = 0; p.aim = 0;
const badMuzzles = []; const badMuzzles = [];
for (const w of Object.values(ctx.WEAPONS)) { for (const w of Object.values(ctx.WEAPONS)) {
if (['slam', 'saw', 'beam'].includes(w.behavior)) continue; if (['slam', 'saw', 'beam', 'flame'].includes(w.behavior)) continue;
ctx.G.bullets.length = 0; ctx.G.bullets.length = 0;
ctx.fireWeapon(p, w); ctx.fireWeapon(p, w);
const b = ctx.G.bullets[0]; const b = ctx.G.bullets[0];
@@ -851,16 +1054,18 @@ console.log('== drop economy: weapons are rare, elites feed you ==');
// elites: always bonus gems, mostly ammo, sometimes an item // elites: always bonus gems, mostly ammo, sometimes an item
p.x = 100; p.y = 100; p.x = 100; p.y = 100;
let eliteGemValue = 0, eliteAmmo = 0, eliteItems = 0, eliteWeapons = 0; let eliteGemValue = 0, eliteAmmo = 0, eliteItems = 0, eliteWeapons = 0;
for (let i = 0; i < 30; i++) { withSeededRandom(0xBEEF, () => {
const e = ctx.makeEnemy('shambler', 700, 500, 1, true); for (let i = 0; i < 30; i++) {
ctx.G.enemies.push(e); const e = ctx.makeEnemy('shambler', 700, 500, 1, true);
ctx.G.pickups.length = 0; // isolate this kill's drops ctx.G.enemies.push(e);
ctx.damageEnemy(e, 9999, 0, false); ctx.G.pickups.length = 0; // isolate this kill's drops
eliteGemValue += ctx.G.pickups.filter(k => k.type === 'gem').reduce((s, k) => s + k.v, 0); ctx.damageEnemy(e, 9999, 0, false);
eliteAmmo += ctx.G.pickups.filter(k => k.type === 'ammo').length; eliteGemValue += ctx.G.pickups.filter(k => k.type === 'gem').reduce((s, k) => s + k.v, 0);
eliteItems += ctx.G.pickups.filter(k => k.type === 'item').length; eliteAmmo += ctx.G.pickups.filter(k => k.type === 'ammo').length;
eliteWeapons += ctx.G.pickups.filter(k => k.type === 'weapon').length; eliteItems += ctx.G.pickups.filter(k => k.type === 'item').length;
} eliteWeapons += ctx.G.pickups.filter(k => k.type === 'weapon').length;
}
});
step(3, 16); step(3, 16);
check('elites always drop bonus gems (avg value ' + (eliteGemValue / 30).toFixed(1) + ')', eliteGemValue / 30 >= 5.5); check('elites always drop bonus gems (avg value ' + (eliteGemValue / 30).toFixed(1) + ')', eliteGemValue / 30 >= 5.5);
check('elites mostly drop ammo (' + eliteAmmo + '/30)', eliteAmmo >= 6); check('elites mostly drop ammo (' + eliteAmmo + '/30)', eliteAmmo >= 6);
@@ -1831,5 +2036,33 @@ check('debug console closes back to play', ctx.G.mode === 'play');
ctx.G.mode = 'play'; ctx.G.mode = 'play';
})(); })();
console.log('== crimson metronome heart loan ==');
{
ctx.startRun(); step(3, 16); // self-contained: fresh player, empty arena
ctx.G.enemies.length = 0;
const p = ctx.G.player;
ctx.giveItem('crimsonmetronome');
check('metronome grants the loan flag', p.stats.crimsonMetronome === 1);
p.weapon = { id: 'repeater', ammo: 500 }; p.holstered = null; // per-shot trigger
p.hp = 6; p.metronomeCount = 0; p.metronomeTmp = 0;
const hpBefore = p.hp;
ctx.Input.mdown = true;
let guard = 0;
while (p.metronomeTmp === 0 && guard++ < 4000) step(1, 16);
ctx.Input.mdown = false;
check('8th shot lends ½ heart (not lost)', p.hp === hpBefore - 1 && p.metronomeTmp === 1);
ctx.G.roomDamaged = false;
ctx.recordRoomClear({ type: 'combat' });
check('clean room clear repays the loan', p.hp === hpBefore && p.metronomeTmp === 0);
ctx.Input.mdown = true;
guard = 0;
while (p.metronomeTmp === 0 && guard++ < 4000) step(1, 16);
ctx.Input.mdown = false;
check('loan accrues again', p.hp === hpBefore - 1 && p.metronomeTmp === 1);
ctx.G.roomDamaged = true;
ctx.recordRoomClear({ type: 'combat' });
check('hit room forfeits the loan', p.hp === hpBefore - 1 && p.metronomeTmp === 1);
}
console.log(failures === 0 ? '\nALL CHECKS PASSED' : '\n' + failures + ' CHECKS FAILED'); console.log(failures === 0 ? '\nALL CHECKS PASSED' : '\n' + failures + ' CHECKS FAILED');
process.exit(failures === 0 ? 0 : 1); process.exit(failures === 0 ? 0 : 1);