Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fde2747561 | ||
|
|
c0d909eebd | ||
|
|
717a106119 | ||
|
|
b41dcd8ebb | ||
|
|
1131ed1e0b | ||
|
|
d9a921a1ea | ||
|
|
9c6fc194ca | ||
|
|
063ace998f | ||
|
|
f77b4fd402 | ||
|
|
39d89fbc53 | ||
|
|
e801c02ef6 | ||
|
|
7aee2f5f64 |
No files matched your search
@@ -40,7 +40,7 @@ npm test
|
||||
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
|
||||
|
||||
|
||||
+35
-1
@@ -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
|
||||
|
||||
### 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
@@ -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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
|
||||
### Quick numbers
|
||||
|
||||
@@ -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.6.0**
|
||||
|
||||
## 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, and automatic updates 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`.
|
||||
@@ -15,3 +15,8 @@ win:
|
||||
target:
|
||||
- zip
|
||||
icon: build/icon.png
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
icon: build/icon.png
|
||||
category: Game
|
||||
+22
-22
@@ -6,32 +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=52">
|
||||
<link rel="stylesheet" href="css/style.css?v=53">
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrap">
|
||||
<canvas id="game" width="960" height="640"></canvas>
|
||||
</div>
|
||||
<script src="js/utils.js?v=52"></script>
|
||||
<script src="js/state.js?v=52"></script>
|
||||
<script src="js/input.js?v=52"></script>
|
||||
<script src="js/sfx.js?v=52"></script>
|
||||
<script src="js/sfxbank.js?v=52"></script>
|
||||
<script src="js/audio.js?v=52"></script>
|
||||
<script src="js/sprites.js?v=52"></script>
|
||||
<script src="js/particles.js?v=52"></script>
|
||||
<script src="js/weapons.js?v=52"></script>
|
||||
<script src="js/bullets.js?v=52"></script>
|
||||
<script src="js/pickups.js?v=52"></script>
|
||||
<script src="js/items.js?v=52"></script>
|
||||
<script src="js/perks.js?v=52"></script>
|
||||
<script src="js/enemies.js?v=52"></script>
|
||||
<script src="js/bosses.js?v=52"></script>
|
||||
<script src="js/rooms.js?v=52"></script>
|
||||
<script src="js/player.js?v=52"></script>
|
||||
<script src="js/hud.js?v=52"></script>
|
||||
<script src="js/help.js?v=52"></script>
|
||||
<script src="js/debug.js?v=52"></script>
|
||||
<script src="js/main.js?v=52"></script>
|
||||
<script src="js/utils.js?v=53"></script>
|
||||
<script src="js/state.js?v=53"></script>
|
||||
<script src="js/input.js?v=53"></script>
|
||||
<script src="js/sfx.js?v=53"></script>
|
||||
<script src="js/sfxbank.js?v=53"></script>
|
||||
<script src="js/audio.js?v=53"></script>
|
||||
<script src="js/sprites.js?v=53"></script>
|
||||
<script src="js/particles.js?v=53"></script>
|
||||
<script src="js/weapons.js?v=53"></script>
|
||||
<script src="js/bullets.js?v=53"></script>
|
||||
<script src="js/pickups.js?v=53"></script>
|
||||
<script src="js/items.js?v=53"></script>
|
||||
<script src="js/perks.js?v=53"></script>
|
||||
<script src="js/enemies.js?v=53"></script>
|
||||
<script src="js/bosses.js?v=53"></script>
|
||||
<script src="js/rooms.js?v=53"></script>
|
||||
<script src="js/player.js?v=53"></script>
|
||||
<script src="js/hud.js?v=53"></script>
|
||||
<script src="js/help.js?v=53"></script>
|
||||
<script src="js/debug.js?v=53"></script>
|
||||
<script src="js/main.js?v=53"></script>
|
||||
</body>
|
||||
</html>
|
||||
+2
-2
@@ -23,7 +23,7 @@ const ITEMS = {
|
||||
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; } },
|
||||
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; } },
|
||||
|
||||
// ---- 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); } },
|
||||
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; } },
|
||||
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; } },
|
||||
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; } },
|
||||
|
||||
+4
-1
@@ -101,7 +101,7 @@ function resetRun() {
|
||||
G.confirmAction = null; G.confirmT = 0;
|
||||
G.deathLockT = 0;
|
||||
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;
|
||||
initPlayer();
|
||||
genFloor(1);
|
||||
@@ -169,6 +169,7 @@ function loop(now) {
|
||||
}
|
||||
|
||||
function update(dt) {
|
||||
const menuWas = G.mode !== 'play';
|
||||
Music.update(dt);
|
||||
if (G.deathLockT > 0) G.deathLockT = Math.max(0, G.deathLockT - dt);
|
||||
if (G.confirmT > 0) {
|
||||
@@ -287,6 +288,8 @@ function update(dt) {
|
||||
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.flash > 0) G.flash = Math.max(0, G.flash - dt);
|
||||
if (G.transition > 0) G.transition = Math.max(0, G.transition - dt);
|
||||
|
||||
+2
-2
@@ -17,7 +17,7 @@ const PERKS = [
|
||||
{ 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: '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: '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; } },
|
||||
@@ -61,7 +61,7 @@ function openPerkDraft() {
|
||||
}
|
||||
const opts = shuffle([...PERKS]).slice(0, 3);
|
||||
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';
|
||||
Sfx.levelup();
|
||||
}
|
||||
|
||||
+2
-4
@@ -59,7 +59,7 @@ function updatePickups(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);
|
||||
G.pickups.splice(i, 1);
|
||||
}
|
||||
@@ -129,7 +129,7 @@ function collectPickup(k) {
|
||||
if (old) {
|
||||
const a = rand(0, TAU);
|
||||
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.holstered = null;
|
||||
@@ -141,7 +141,6 @@ function collectPickup(k) {
|
||||
}
|
||||
case 'item': {
|
||||
giveItem(k.iid);
|
||||
for (const q of G.pickups) if (q.type === 'itemspot') q.dead = true; // remove pedestal base
|
||||
break;
|
||||
}
|
||||
case 'active': {
|
||||
@@ -156,7 +155,6 @@ function collectPickup(k) {
|
||||
addToast(def.name, def.desc + ' — [SPACE] when charged', 5.5);
|
||||
spawnText(p.x, p.y - 14, def.name.toUpperCase(), '#55f5dc');
|
||||
Sfx.item();
|
||||
for (const q of G.pickups) if (q.type === 'itemspot') q.dead = true; // remove pedestal base
|
||||
break;
|
||||
}
|
||||
case 'stairs':
|
||||
|
||||
+18
-10
@@ -32,6 +32,7 @@ function initPlayer() {
|
||||
animT: 0, attackT: 0, actionT: 0, hitT: 0, deathT: 0,
|
||||
weapon: { id: 'bonepopper', ammo: Infinity },
|
||||
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,
|
||||
items: {}, // iid -> quality tier (1..3)
|
||||
perks: [], // perk ids drafted this run, in grant order
|
||||
@@ -64,6 +65,8 @@ function swapWeapon(silent) {
|
||||
function updatePlayer(dt) {
|
||||
const p = G.player;
|
||||
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.hitT > 0) p.hitT -= dt;
|
||||
if (p.frenzyT > 0) p.frenzyT -= dt;
|
||||
@@ -78,7 +81,7 @@ function updatePlayer(dt) {
|
||||
if (keyDown('d', 'arrowright')) mx += 1;
|
||||
if (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.y += (my / len) * spd * dt;
|
||||
p.step += dt * 9 * Math.min(st.speedMul, 1.8);
|
||||
@@ -105,9 +108,8 @@ function updatePlayer(dt) {
|
||||
}
|
||||
|
||||
// firing
|
||||
const w = WEAPONS[p.weapon.id];
|
||||
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;
|
||||
const frenzyMul = p.frenzyT > 0 ? 1 + st.frenzy : 1;
|
||||
const draughtMul = p.marrowDraughtT > 0 ? 2 : 1;
|
||||
@@ -115,7 +117,7 @@ function updatePlayer(dt) {
|
||||
const rate = w.interval / effectiveRate;
|
||||
|
||||
if (w.behavior === 'saw') {
|
||||
if (Input.mdown && p.weapon.ammo > 0) {
|
||||
if (held && p.weapon.ammo > 0) {
|
||||
sawTick(p, w, p.weapon, dt);
|
||||
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;
|
||||
@@ -125,7 +127,7 @@ function updatePlayer(dt) {
|
||||
else p.fireT = 0;
|
||||
} else if (w.drain) {
|
||||
// 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) {
|
||||
fireWeapon(p, w);
|
||||
p.recoil = Math.min(1, p.recoil + 0.45);
|
||||
@@ -138,7 +140,7 @@ function updatePlayer(dt) {
|
||||
}
|
||||
else p.fireT = 0;
|
||||
} 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);
|
||||
} else if (Input.mreleased && p.charge > 0) {
|
||||
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);
|
||||
}
|
||||
} 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);
|
||||
// Twin Sidearm: the Bone Popper double-taps
|
||||
if (st.twinSidearm > 0 && p.weapon.id === 'bonepopper') fireWeapon(p, w);
|
||||
@@ -156,10 +158,16 @@ function updatePlayer(dt) {
|
||||
p.choirCount = (p.choirCount || 0) + 1;
|
||||
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) {
|
||||
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.muzzleT = 0.07;
|
||||
@@ -275,7 +283,7 @@ function updatePlayer(dt) {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
+18
-6
@@ -97,6 +97,7 @@ function enterRoom(gx, gy) {
|
||||
G.roomDamaged = false;
|
||||
G.roomEnterT = G.time;
|
||||
G.entryFresh = false;
|
||||
if (G.player) G.player.metronomeTmp = 0; // fresh per-room Crimson-Metronome loan ledger
|
||||
|
||||
const room = G.rooms[roomKey(gx, gy)];
|
||||
G.cur = room;
|
||||
@@ -151,12 +152,16 @@ 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, a.x1 - W);
|
||||
G.cam.y = a.h <= H ? a.cy - H / 2 : clamp(p.y - H / 2, a.y0, a.y1 - H);
|
||||
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; }
|
||||
@@ -238,10 +243,10 @@ function updateRoom(dt) {
|
||||
enterRoom(r.gx + dx, r.gy + dy);
|
||||
const next = G.arena;
|
||||
// place player at the opposite door in the destination's own shape
|
||||
if (dir === 'n') p.y = next.y1 - p.r - 8;
|
||||
if (dir === 's') p.y = next.y0 + p.r + 8;
|
||||
if (dir === 'e') p.x = next.x0 + p.r + 8;
|
||||
if (dir === 'w') p.x = next.x1 - 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; p.x = next.cx; }
|
||||
if (dir === 'e') { p.x = next.x0 + p.r + 8; p.y = next.cy; }
|
||||
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.y = clamp(p.y, next.y0 + p.r, next.y1 - p.r);
|
||||
if (G.entryFresh) { separateEntryWave(); G.entryFresh = false; }
|
||||
@@ -260,6 +265,13 @@ function recordRoomClear(room) {
|
||||
}
|
||||
// active items charge on room clears: +1 per combat room, +2 per boss
|
||||
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) {
|
||||
const a = ACTIVES[p.active.iid];
|
||||
if (a) {
|
||||
|
||||
+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=52')
|
||||
const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=53')
|
||||
.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=52';
|
||||
img.src = 'assets/' + name + '.png?v=53';
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ const G = {
|
||||
pressure: 1,
|
||||
pressureDial: 0,
|
||||
streak: 0,
|
||||
shotLockT: 0,
|
||||
roomDamaged: false,
|
||||
roomEnterT: 0,
|
||||
recentHits: [],
|
||||
@@ -61,6 +62,8 @@ const PERK_POWER_WEIGHT = 0.5;
|
||||
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 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_GAIN = PRESSURE_UNIT; // retained: dial 0 == 1 point
|
||||
const PRESSURE_DROP_BASE = 3; // dial 0 == 0.03 relief base
|
||||
|
||||
+11
-4
@@ -47,7 +47,7 @@ function fireWeapon(p, w) {
|
||||
const st = p.stats;
|
||||
const ang = p.aim;
|
||||
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 spdMul = st.shotSpeedMul;
|
||||
spawnMuzzleFx(bx, by, ang, w.behavior);
|
||||
@@ -86,6 +86,10 @@ function fireWeapon(p, w) {
|
||||
break;
|
||||
}
|
||||
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 (let i = 0; i < 2; i++) mk(center + rand(-w.spread, w.spread), { pierce: 2 + st.pierce, r: 6 * st.sizeMul });
|
||||
}
|
||||
@@ -144,9 +148,12 @@ function sawTick(p, def, inst, dt) {
|
||||
const inert = 1 + (st.pierce + st.bounce + st.homing) * 0.06;
|
||||
let hitAny = false;
|
||||
for (const a of angles) {
|
||||
const reach = 30 * st.rangeMul * Math.sqrt(st.shotSpeedMul);
|
||||
const cx = p.x + Math.cos(a) * reach, cy = p.y + Math.sin(a) * reach;
|
||||
const radius = 34 * Math.sqrt(st.sizeMul);
|
||||
const reach = 58 * st.rangeMul * Math.sqrt(st.shotSpeedMul); // out to the blade (was 30)
|
||||
const rh = p.aim + Math.PI / 2; // right-hand side, perpendicular to aim
|
||||
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) {
|
||||
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);
|
||||
|
||||
+5
-1
@@ -1,17 +1,21 @@
|
||||
{
|
||||
"name": "meatslicer",
|
||||
"productName": "MeatSlicer",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"description": "A Butcher's Descent - clear, harvest, descend.",
|
||||
"main": "desktop/main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dist": "electron-builder --win zip",
|
||||
"dist:linux": "electron-builder --linux --publish never",
|
||||
"test": "node test/smoke.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^40.10.2",
|
||||
"electron-builder": "^25.1.8"
|
||||
},
|
||||
"allowScripts": {
|
||||
"electron@40.10.2": true
|
||||
}
|
||||
}
|
||||
+199
-24
@@ -61,7 +61,7 @@ for (const f of files) {
|
||||
}
|
||||
// const/let top-level bindings live in the context's lexical scope, not on the
|
||||
// global object — expose the ones the harness pokes at directly.
|
||||
vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, Sfx, SfxBank, W, H, WALL, ITEMS, 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 })', 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;
|
||||
const ctx = sandbox;
|
||||
@@ -88,6 +88,16 @@ function check(name, cond) {
|
||||
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 ==');
|
||||
for (const fn of (listeners['win:load'] || [])) fn();
|
||||
step(5, 16);
|
||||
@@ -293,11 +303,15 @@ console.log('== big rooms + camera ==');
|
||||
ctx.setArenaForRoom(wide);
|
||||
const wa = ctx.G.arena;
|
||||
ctx.G.player.x = 100; ctx.G.player.y = 320; ctx.updateCamera();
|
||||
check('camera clamps at left arena edge', ctx.G.cam.x === wa.x0);
|
||||
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('camera clamps at right arena edge', ctx.G.cam.x === wa.x1 - ctx.W);
|
||||
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;
|
||||
@@ -342,10 +356,11 @@ ctx.gainXP(100);
|
||||
step(5, 16);
|
||||
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 lock is a brief ~0.4s guard', (ctx.G.selectionLock || 0) > 0 && (ctx.G.selectionLock || 0) <= 0.4);
|
||||
const lockedChoices = ctx.G.perkChoices;
|
||||
tap('1'); // ignored while the selection lock is active
|
||||
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;
|
||||
while (ctx.G.mode === 'levelup' && guard++ < 30) { tap('1'); step(2, 16); }
|
||||
check('perks drafted', ctx.G.mode === 'play');
|
||||
@@ -361,7 +376,7 @@ console.log('== rebalanced perks + automatic draft ==');
|
||||
ctx.G.pendingLevelups = 1; ctx.G.autoPerk = false; ctx.openPerkDraft();
|
||||
p.stats.rerolls = 1; tap('r');
|
||||
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');
|
||||
check('one-off random draft choice resolves normally', ctx.G.mode === 'play' && ctx.G.pendingLevelups === 0);
|
||||
const levelBefore = p.level;
|
||||
@@ -372,21 +387,149 @@ console.log('== rebalanced perks + automatic draft ==');
|
||||
ctx.G.autoPerk = false;
|
||||
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
|
||||
|
||||
console.log('== nearby-item description toast ==');
|
||||
{
|
||||
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.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
|
||||
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);
|
||||
step(4, 16); // stay adjacent, must not re-fire
|
||||
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 -= 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);
|
||||
ctx.G.pickups.length = 0;
|
||||
ctx.startRun(); step(3, 16);
|
||||
@@ -454,10 +597,10 @@ console.log('== expanded perk and item rosters ==');
|
||||
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);
|
||||
check('armor chance has diminishing absolute returns', c1 > 0 && c2 > c1 && (c2 - c1) < c1);
|
||||
const c1 = ctx.armorBlockChance(0.05), c2 = ctx.armorBlockChance(0.10), cHuge = ctx.armorBlockChance(999);
|
||||
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);
|
||||
const p = ctx.G.player, hp = p.hp;
|
||||
const realChance = ctx.chance;
|
||||
@@ -613,9 +756,11 @@ console.log('== tier-scaled items + duplicate favoring ==');
|
||||
check('kill-explosion hurts nearby enemies', v2.hp < v2hpBefore);
|
||||
ctx.G.enemies.length = 0;
|
||||
let ownedHits = 0;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
if (p.items[ctx.randomItemId()]) ownedHits++;
|
||||
}
|
||||
withSeededRandom(0xC0FFEE, () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
if (p.items[ctx.randomItemId()]) ownedHits++;
|
||||
}
|
||||
});
|
||||
check('item rolls favor owned items (' + ownedHits + '/200)', ownedHits >= 40);
|
||||
}
|
||||
|
||||
@@ -817,7 +962,7 @@ console.log('== per-weapon muzzle spawn distances ==');
|
||||
p.aim = 0;
|
||||
const badMuzzles = [];
|
||||
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.fireWeapon(p, w);
|
||||
const b = ctx.G.bullets[0];
|
||||
@@ -909,16 +1054,18 @@ console.log('== drop economy: weapons are rare, elites feed you ==');
|
||||
// elites: always bonus gems, mostly ammo, sometimes an item
|
||||
p.x = 100; p.y = 100;
|
||||
let eliteGemValue = 0, eliteAmmo = 0, eliteItems = 0, eliteWeapons = 0;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const e = ctx.makeEnemy('shambler', 700, 500, 1, true);
|
||||
ctx.G.enemies.push(e);
|
||||
ctx.G.pickups.length = 0; // isolate this kill's drops
|
||||
ctx.damageEnemy(e, 9999, 0, false);
|
||||
eliteGemValue += ctx.G.pickups.filter(k => k.type === 'gem').reduce((s, k) => s + k.v, 0);
|
||||
eliteAmmo += ctx.G.pickups.filter(k => k.type === 'ammo').length;
|
||||
eliteItems += ctx.G.pickups.filter(k => k.type === 'item').length;
|
||||
eliteWeapons += ctx.G.pickups.filter(k => k.type === 'weapon').length;
|
||||
}
|
||||
withSeededRandom(0xBEEF, () => {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const e = ctx.makeEnemy('shambler', 700, 500, 1, true);
|
||||
ctx.G.enemies.push(e);
|
||||
ctx.G.pickups.length = 0; // isolate this kill's drops
|
||||
ctx.damageEnemy(e, 9999, 0, false);
|
||||
eliteGemValue += ctx.G.pickups.filter(k => k.type === 'gem').reduce((s, k) => s + k.v, 0);
|
||||
eliteAmmo += ctx.G.pickups.filter(k => k.type === 'ammo').length;
|
||||
eliteItems += ctx.G.pickups.filter(k => k.type === 'item').length;
|
||||
eliteWeapons += ctx.G.pickups.filter(k => k.type === 'weapon').length;
|
||||
}
|
||||
});
|
||||
step(3, 16);
|
||||
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);
|
||||
@@ -1889,5 +2036,33 @@ check('debug console closes back to 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');
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
Reference in new issue
Block a user