diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 436fa3f..d14cd41 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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* diff --git a/docs/gameplay.md b/docs/gameplay.md index 744dfac..5715b3d 100644 --- a/docs/gameplay.md +++ b/docs/gameplay.md @@ -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. diff --git a/index.html b/index.html index 31d4673..0dbab0e 100644 --- a/index.html +++ b/index.html @@ -6,32 +6,32 @@ MEATSLICER - +
- - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/js/sfxbank.js b/js/sfxbank.js index 3be6df8..bd42d55 100644 --- a/js/sfxbank.js +++ b/js/sfxbank.js @@ -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))) diff --git a/package.json b/package.json index 060de99..ef553ac 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/smoke.js b/test/smoke.js index 5bd9df4..ff2e8de 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -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));