Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fde2747561 | ||
|
|
c0d909eebd | ||
|
|
717a106119 | ||
|
|
b41dcd8ebb | ||
|
|
1131ed1e0b | ||
|
|
d9a921a1ea | ||
|
|
16efccd912 | ||
|
|
96815f6685 | ||
|
|
9c6fc194ca | ||
|
|
063ace998f | ||
|
|
7269ff53f0 | ||
|
|
f77b4fd402 | ||
|
|
39d89fbc53 | ||
|
|
e801c02ef6 | ||
|
|
7aee2f5f64 |
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
Reference in new issue
Block a user