release: bump to 0.8.0 (HD Remaster) with docs, cache bust, and smoke coverage

Cache-bust v=54 -> v=55 across index.html and sfxbank.js, bump the game
version to 0.8.0, document the HD Remaster setting in gameplay.md and the
changelog, and extend the smoke suite with 4x sheet-geometry bounds checks,
HD loader fallback URL assertions, and toggle persistence/reload checks.
This commit is contained in:
Quintine committed 2026-08-12 10:11:23 +10:00
1 parent 928232ce9d
commit 6b4e7091a9
6 files changed
+145 -27

No files matched your search

+6 -1
View File
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [0.8.0] — 2026-08-12
### Added
- **HD Remaster setting** — opt-in visual mode toggled from the title screen (press **H** or click the HD REMASTER tag). When on: the canvas renders at 4× resolution (3840×2560) with smooth scaling, the sprite loader serves a 4× WebP tier (`assets/hd/`) generated from the raw source art (`assets/raw/`) by `tools/hd_assets.py`, and the room-layer offscreen canvas renders at 4×. The setting persists to `localStorage` (`meatslicer_hd_remaster`) and reloads the page on toggle (no live hot-swap). If an HD file is missing, the loader falls back per-sprite to the standard PNG. **Default is off** (classic pixelated 960×640 look). Toggle is title-screen only (mid-run reload would lose the run).
## [0.7.2] — 2026-08-11
### Fixed
@@ -196,4 +201,4 @@ The initial series of commits that built the game from scratch. Grouped by featu
---
*Generated from the git log. Last commit: v0.7.2*
*Generated from the git log. Last commit: v0.8.0*
+7 -1
View File
@@ -83,6 +83,7 @@ Your best score is saved in `localStorage` as `meatslicer_best` and shown on the
| Input | Action |
|---|---|
| **Enter / Space / Click** | Start run (title), restart after death |
| **H / click HD tag** | Toggle HD Remaster (title screen; reloads) |
The desktop app title screen has **[X] Exit to Desktop**. Pause has **[Q] Main Menu** and **[X] Exit**.
The first matching press arms a 3-second confirmation; press it again to confirm. `P` or
@@ -245,6 +246,10 @@ Values between the anchors interpolate linearly. Negative rise respects the 0.60
The HUD also tracks your **streak** of consecutive rooms cleared without damage. It is purely cosmetic bragging rights — its only mechanical effect is feeding the pressure gain above.
### HD Remaster
An opt-in visual mode toggled from the title screen (press **H** or click the **HD REMASTER** tag). When on: the canvas renders at **4× resolution** (3840×2560) with smooth scaling, and sprites load from a **4× WebP tier** (`assets/hd/`) generated from the raw source art (`assets/raw/`) by `tools/hd_assets.py` — full-color, no palette quantization. The setting persists to `localStorage` (`meatslicer_hd_remaster`). Toggling reloads the page (no live hot-swap). If an HD file is missing for a sprite, the loader falls back per-sprite to the standard PNG. **Default is off** (classic pixelated 960×640 look). The toggle is title-screen only — not in the pause menu (a mid-run reload would lose the run).
---
## Difficulty Scaling (summary)
@@ -275,7 +280,7 @@ Full formulas are in [enemies.md](enemies.md) and [bosses.md](bosses.md).
| Screen | What it offers |
|---|---|
| **Title** | INITIATE DESCENT, PRESSURE DIAL (−10…+10), control reference, best score, confirmed desktop exit |
| **Title** | INITIATE DESCENT, PRESSURE DIAL (−10…+10), HD REMASTER ON/OFF tag, control reference, best score, confirmed desktop exit |
| **Pause** | Jukebox (track cycling), SFX/music volume + HUD opacity sliders, resume/swap/auto/mute hints, Field Manual (`H` / `?` button) |
| **Mutation Draft** | 3 perk cards, random cut, reroll, auto-draft |
| **Game Over ("BUTCHERED")** | Floor, kills, score, NEW BEST CUT tag, restart |
@@ -292,6 +297,7 @@ Stored in `localStorage` between sessions:
- `meatslicer_autoperk` — auto-draft toggle
- `meatslicer_pressure_dial` — Pressure Dial setting (−10…+10, default 0)
- `meatslicer_hud_alpha` — HUD opacity (default 1.0)
- `meatslicer_hd_remaster` — HD Remaster toggle (default off)
Nothing else carries over: no unlocks, no meta-currency, no saved runs. Every descent starts fresh with a Bone Popper and 6 HP.
+22 -22
View File
@@ -6,32 +6,32 @@
<meta name="theme-color" content="#0a0305">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><text y=%2252%22 x=%2250%22 text-anchor=%22middle%22 dominant-baseline=%22middle%22 font-size=%2252%22>🔪</text></svg>">
<title>MEATSLICER</title>
<link rel="stylesheet" href="css/style.css?v=54">
<link rel="stylesheet" href="css/style.css?v=55">
</head>
<body>
<div id="wrap">
<canvas id="game" width="960" height="640"></canvas>
</div>
<script src="js/utils.js?v=54"></script>
<script src="js/state.js?v=54"></script>
<script src="js/input.js?v=54"></script>
<script src="js/sfx.js?v=54"></script>
<script src="js/sfxbank.js?v=54"></script>
<script src="js/audio.js?v=54"></script>
<script src="js/sprites.js?v=54"></script>
<script src="js/particles.js?v=54"></script>
<script src="js/weapons.js?v=54"></script>
<script src="js/bullets.js?v=54"></script>
<script src="js/pickups.js?v=54"></script>
<script src="js/items.js?v=54"></script>
<script src="js/perks.js?v=54"></script>
<script src="js/enemies.js?v=54"></script>
<script src="js/bosses.js?v=54"></script>
<script src="js/rooms.js?v=54"></script>
<script src="js/player.js?v=54"></script>
<script src="js/hud.js?v=54"></script>
<script src="js/help.js?v=54"></script>
<script src="js/debug.js?v=54"></script>
<script src="js/main.js?v=54"></script>
<script src="js/utils.js?v=55"></script>
<script src="js/state.js?v=55"></script>
<script src="js/input.js?v=55"></script>
<script src="js/sfx.js?v=55"></script>
<script src="js/sfxbank.js?v=55"></script>
<script src="js/audio.js?v=55"></script>
<script src="js/sprites.js?v=55"></script>
<script src="js/particles.js?v=55"></script>
<script src="js/weapons.js?v=55"></script>
<script src="js/bullets.js?v=55"></script>
<script src="js/pickups.js?v=55"></script>
<script src="js/items.js?v=55"></script>
<script src="js/perks.js?v=55"></script>
<script src="js/enemies.js?v=55"></script>
<script src="js/bosses.js?v=55"></script>
<script src="js/rooms.js?v=55"></script>
<script src="js/player.js?v=55"></script>
<script src="js/hud.js?v=55"></script>
<script src="js/help.js?v=55"></script>
<script src="js/debug.js?v=55"></script>
<script src="js/main.js?v=55"></script>
</body>
</html>
+1 -1
View File
@@ -111,7 +111,7 @@ const SfxBank = {
if (this.buffers.has(name) || this.pending.has(name) || this.failed.has(name)) return;
const file = this.FILES[name];
if (!file || !this.ctx || typeof fetch !== 'function') return;
const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=54')
const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=55')
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
.then(bytes => this.ctx.decodeAudioData(bytes))
.then(buffer => this.buffers.set(name, this.trim(buffer)))
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meatslicer",
"productName": "MeatSlicer",
"version": "0.7.2",
"version": "0.8.0",
"private": true,
"description": "A Butcher's Descent - clear, harvest, descend.",
"main": "desktop/main.js",
+108 -1
View File
@@ -31,11 +31,14 @@ const canvasStub = {
getBoundingClientRect: () => ({ left: 0, top: 0, width: 960, height: 640 }),
};
const stored = {};
let reloadCalls = 0;
const sandbox = {
console,
performance: { now: () => simTime },
requestAnimationFrame: () => {}, // we drive update() manually
localStorage: { getItem: () => null, setItem: () => {} },
localStorage: { getItem: k => stored[k] ?? null, setItem: (k, v) => { stored[k] = String(v); } },
location: { reload() { reloadCalls++; } },
document: { getElementById: () => canvasStub },
Image: class { constructor() { this.width = 32; this.height = 32; } set src(v) { this.onerror && this.onerror(); } },
Audio: class {
@@ -153,6 +156,110 @@ delete ctx.Sprites.imgs.player_legs_sheet;
check('legs strip addresses all 8 stride frames', legDraws === 8);
check('legs strip reads stay in bounds', legOutOfBounds === 0);
check('legs strip rotates smoothly at runtime', legRotations.length === 8 && legRotations.every(angle => Math.abs(angle - Math.PI / 3) < 0.001));
console.log('== HD sheets address 4x atlases in bounds ==');
let hdSheetDraws = 0, hdOutOfBounds = 0;
const hdActorCtx = fakeCtx();
hdActorCtx.drawImage = (img, sx, sy, sw, sh) => {
hdSheetDraws++;
if (sx < 0 || sy < 0 || sx + sw > img.width || sy + sh > img.height) hdOutOfBounds++;
};
const hdSheetCases = [
['player', 3072, 11136, 96],
['enemy_shambler', 2048, 7424, 64],
['boss_bonesaw', 4096, 14848, 128],
];
for (const [name, width, height, targetW] of hdSheetCases) {
ctx.Sprites.imgs[name + '_sheet'] = { width, height };
for (let dir = 0; dir < 8; dir++) {
for (const action of Object.keys(ctx.ACTOR_ANIMS)) {
ctx.Sprites.actor(hdActorCtx, name, 0, 0, dir * Math.PI / 4, action, 0.2, targetW);
}
}
delete ctx.Sprites.imgs[name + '_sheet'];
}
check('HD sheets address 4x atlases in bounds', hdSheetDraws === 120 && hdOutOfBounds === 0);
let hdLegDraws = 0, hdLegOutOfBounds = 0;
const hdLegRotations = [];
const hdLegCtx = fakeCtx();
hdLegCtx.rotate = angle => hdLegRotations.push(angle);
hdLegCtx.drawImage = (img, sx, sy, sw, sh) => {
hdLegDraws++;
if (sx < 0 || sy < 0 || sx + sw > img.width || sy + sh > img.height) hdLegOutOfBounds++;
};
ctx.Sprites.imgs.player_legs_sheet = { width: 3072, height: 384 };
for (let frame = 0; frame < 8; frame++) {
ctx.Sprites.legs(hdLegCtx, 0, 0, Math.PI / 3, frame * Math.PI / 4 + 0.01, 384);
}
delete ctx.Sprites.imgs.player_legs_sheet;
check('HD legs strip addresses 8 stride frames at 4x resolution', hdLegDraws === 8 && hdLegOutOfBounds === 0);
check('HD legs strip rotates smoothly', hdLegRotations.length === 8 && hdLegRotations.every(a => Math.abs(a - Math.PI / 3) < 0.001));
console.log('== HD loader falls back per sprite to SD ==');
const origImage = sandbox.Image;
let attemptedUrls = {};
ctx.G.hdRemaster = true;
sandbox.Image = class extends origImage {
constructor() { super(); }
set src(v) {
const base = v.split('?')[0];
const name = ctx.SPRITE_MANIFEST.find(n => base === 'assets/hd/' + n + '.webp' || base === 'assets/' + n + '.png');
if (name) {
if (!attemptedUrls[name]) attemptedUrls[name] = [];
attemptedUrls[name].push(v);
}
this.onerror && this.onerror();
}
};
ctx.Sprites.load();
check('HD loader loads all images (fallbacks)', ctx.G.imagesLoaded === true);
let hdFallbackOk = 0;
for (const name of ctx.SPRITE_MANIFEST) {
const urls = attemptedUrls[name];
const ok = urls && urls.length === 2 && urls[0] === 'assets/hd/' + name + '.webp?v=55' && urls[1] === 'assets/' + name + '.png?v=55';
if (ok) hdFallbackOk++;
}
check('HD loader tries WebP then PNG per sprite', hdFallbackOk === ctx.SPRITE_MANIFEST.length);
console.log('== SD loader path (pristine restore) ==');
attemptedUrls = {};
sandbox.Image = class extends origImage {
constructor() { super(); }
set src(v) {
const base = v.split('?')[0];
const name = ctx.SPRITE_MANIFEST.find(n => base === 'assets/hd/' + n + '.webp' || base === 'assets/' + n + '.png');
if (name) {
if (!attemptedUrls[name]) attemptedUrls[name] = [];
attemptedUrls[name].push(v);
}
this.onerror && this.onerror();
}
};
ctx.G.hdRemaster = false;
ctx.Sprites.load();
check('SD loader loads all images', ctx.G.imagesLoaded === true);
let sdUrlOk = 0;
for (const name of ctx.SPRITE_MANIFEST) {
const urls = attemptedUrls[name];
const ok = urls && urls.length === 1 && urls[0] === 'assets/' + name + '.png?v=55';
if (ok) sdUrlOk++;
}
check('SD loader requests one PNG per sprite', sdUrlOk === ctx.SPRITE_MANIFEST.length);
sandbox.Image = origImage;
console.log('== HD toggle persists and reloads ==');
ctx.G.mode = 'menu';
const reloadBefore = reloadCalls;
ctx.toggleHdRemaster();
check('HD toggle enables the flag', ctx.G.hdRemaster === true);
check('HD toggle persists to localStorage', stored['meatslicer_hd_remaster'] === '1');
check('HD toggle reloads the page', reloadCalls === reloadBefore + 1);
ctx.toggleHdRemaster();
check('HD toggle disables the flag', ctx.G.hdRemaster === false);
check('HD toggle persists off value', stored['meatslicer_hd_remaster'] === '0');
check('HD toggle reloads again', reloadCalls === reloadBefore + 2);
reloadCalls = 0;
check('every weapon has layered render and muzzle geometry', Object.values(ctx.WEAPONS).every(w =>
Number.isFinite(w.torsoW) && Number.isFinite(w.torsoFwd) && Number.isFinite(w.muzzle) && w.muzzle > 0));
check('every weapon has finite impact punch metadata', Object.values(ctx.WEAPONS).every(w => Number.isFinite(w.punch) && w.punch > 0));