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:
1 parent
928232ce9d
commit
6b4e7091a9
6 files changed
+145
-27
No files matched your search
+6
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user