From 8991bde943abea6f18f3efc88d395c4fa3f0b432 Mon Sep 17 00:00:00 2001 From: Quintine Date: Sat, 15 Aug 2026 00:24:05 +1000 Subject: [PATCH] 0.9.5 feat: boss-room loading overlay, drop HUD pressure multiplier, elite Brood Sac 4-6 minis --- docs/CHANGELOG.md | 12 ++++++++++++ docs/enemies.md | 2 +- docs/gameplay.md | 3 ++- index.html | 44 ++++++++++++++++++++++---------------------- js/enemies.js | 2 +- js/hud.js | 2 -- js/main.js | 24 ++++++++++++++++++++++++ js/rooms.js | 4 ++++ js/sfxbank.js | 2 +- js/sprites.js | 21 +++++++++++++++++++-- js/state.js | 3 +++ package.json | 2 +- test/smoke.js | 14 +++++++++++--- 13 files changed, 101 insertions(+), 34 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3c29a8e..d06b876 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.9.5] — 2026-08-15 + +### Added +- **Boss-room loading overlay** — first visit of a boss room holds combat in `bossload` for at least 1s while the boss action sheet is warmed (decode + GPU upload) so the roar is not cut by the hitch. Re-entering a visited boss room skips the overlay. A failed sheet cannot deadlock the screen. + +### Changed +- **Elite Brood Sac summons** — an elite sac now seeds 4–6 minis per cycle (normal remains 2). Wave elite rolls still exclude Brood Sac; debug/other elite paths are the intended case. Death burst is unchanged (4). + +### Removed +- **Redundant RUN DATA pressure multiplier** — the `×N.NN` number next to score is gone; `PRESSURE NN%` and the bar remain. + + ## [0.9.1] — 2026-08-13 ### Changed diff --git a/docs/enemies.md b/docs/enemies.md index 78f572b..909f730 100644 --- a/docs/enemies.md +++ b/docs/enemies.md @@ -147,7 +147,7 @@ Its 180px aura grants +35% speed and heals nearby non-boss monsters ~1.8% max HP | XP | 4 | | First appears | Floor 7 (spawn weight 6) | -Spawns 2 Minis every 4s and 4 on death. At most two are wave-selected; the brood population cap is 40. Cannot be elite and is excluded from thin halls. +Spawns 2 Minis every 4s (elite: 4–6 every 3s) and 4 on death. At most two are wave-selected; the brood population cap is 40. Excluded from normal wave elite rolls and thin halls; an elite sac (debug or other spawn paths) still uses the elite summon count. --- diff --git a/docs/gameplay.md b/docs/gameplay.md index ee9931e..9f28d16 100644 --- a/docs/gameplay.md +++ b/docs/gameplay.md @@ -271,7 +271,7 @@ Full formulas are in [enemies.md](enemies.md) and [bosses.md](bosses.md). - **Top-left — BUTCHER // VITALS:** level, hearts (red, 2 HP per heart; half containers render as half outlines) and shield hearts (cyan, 2 shield HP per heart), shown on up to two rows, XP bar, active weapon + ammo (red when below 25%), holstered weapon with `[R]` swap hint. - **Item tray** (below vitals): up to 24 item icons with tier numerals I–IX. - **Top-right — SECTOR MAP:** minimap. White = current room, red = boss, gold = item, gray = cleared, maroon = uncleared. Includes floor number. -- **Bottom-right — RUN DATA:** floor, kills, score (with the live pressure multiplier), PRESSURE % (with the dial setting), STREAK, pressure bar. +- **Bottom-right — RUN DATA:** floor, kills, score, PRESSURE % (with the dial setting), STREAK, pressure bar. - **HUD opacity** is adjustable from the pause menu (`;` / `'` or the HUD slider, 25%–100%). The boss HP bar and toast notifications always stay fully opaque. - **Bottom-center:** boss HP bar (during boss fights). - **Top-center:** toast notifications (pickups, floor intros, boss events). @@ -281,6 +281,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), HD REMASTER ON/OFF tag, control reference, best score, confirmed desktop exit | +| **Boss approach** | Full-screen hold on first boss-room entry: boss name + FRESH MEAT APPROACHES, ≥1s, then combat | | **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 | diff --git a/index.html b/index.html index 3b72dc1..187233b 100644 --- a/index.html +++ b/index.html @@ -6,32 +6,32 @@ MEATSLICER - +
- - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/js/enemies.js b/js/enemies.js index b8d76ef..8f85eca 100644 --- a/js/enemies.js +++ b/js/enemies.js @@ -551,7 +551,7 @@ function updateEnemyAI(e, dt) { e.summonT -= dt; if (e.summonT <= 0) { e.summonT = e.elite ? 3 : 4; - spawnBroodMinis(e, 2, 0.35); + spawnBroodMinis(e, e.elite ? irand(4, 6) : 2, 0.35); e.attackT = 0.5; e.actionT = 0; } } else if (t.ranged) { diff --git a/js/hud.js b/js/hud.js index 92db1fc..8deb768 100644 --- a/js/hud.js +++ b/js/hud.js @@ -167,8 +167,6 @@ function drawHUD(ctx) { ctx.fillText('KILLS ' + String(G.kills).padStart(3, '0'), W - 18, H - 67); ctx.fillStyle = '#f0e5d7'; ctx.font = 'bold 11px monospace'; ctx.fillText('SCORE ' + String(G.score).padStart(6, '0'), W - 18, H - 46); - ctx.fillStyle = '#8f7770'; ctx.font = '8px monospace'; - ctx.fillText('×' + (G.pressure || 1).toFixed(2), W - 168, H - 46); ctx.textAlign = 'left'; const dialTxt = ' D' + (G.pressureDial > 0 ? '+' : '') + G.pressureDial; ctx.fillStyle = G.pressure > 1 ? '#d6aa39' : '#69b8bd'; ctx.font = 'bold 8px monospace'; diff --git a/js/main.js b/js/main.js index 0840a9a..51a1b96 100644 --- a/js/main.js +++ b/js/main.js @@ -246,6 +246,11 @@ function update(dt) { updateParticles(dt); drawToastsUpdate(dt); break; + case 'bossload': + updateParticles(dt); + if (G.bossLoadT > 0) G.bossLoadT -= dt; + if (G.bossLoadT <= 0 && Sprites.warm(G.bossLoadSheet)) G.mode = 'play'; + break; case 'levelup': updateLevelup(dt); updateParticles(dt); @@ -328,6 +333,7 @@ function draw() { ctx.fillRect(0, 0, W, H); if (G.mode === 'menu') { drawMenu(ctx); return; } + if (G.mode === 'bossload') { drawBossLoad(ctx); return; } updateCamera(); @@ -378,6 +384,24 @@ function draw() { if (G.devMode) { if (G.debugPin && G.mode !== 'debug') drawDebugPin(ctx); } } +function drawBossLoad(ctx) { + ctx.fillStyle = '#0a0506'; + ctx.fillRect(0, 0, W, H); + ctx.textAlign = 'center'; + const name = G.boss && G.boss.name ? G.boss.name : 'FRESH MEAT'; + ctx.fillStyle = '#d9c9bc'; ctx.font = 'bold 28px monospace'; + ctx.fillText(name, W / 2, H / 2 - 6); + ctx.fillStyle = '#8f1f2e'; ctx.font = 'bold 11px monospace'; + ctx.fillText('FRESH MEAT APPROACHES', W / 2, H / 2 + 16); + const pr = Math.min(1, Math.max(0, G.bossLoadT / BOSS_LOAD_MIN)); + const tier = Math.floor((1 - pr) * 5) % 3; + ctx.fillStyle = '#3a1a1e'; + for (let i = 0; i < 3; i++) ctx.fillRect(W / 2 - 22 + i * 14, H / 2 + 30, 8, 8); + ctx.fillStyle = '#d7a934'; + ctx.fillRect(W / 2 - 22 + tier * 14, H / 2 + 30, 8, 8); + ctx.textAlign = 'left'; +} + function drawMenu(ctx) { const t = performance.now() / 1000; const bg = ctx.createRadialGradient && ctx.createRadialGradient(W / 2, H * 0.4, 30, W / 2, H * 0.45, 590); diff --git a/js/rooms.js b/js/rooms.js index 904b202..5958777 100644 --- a/js/rooms.js +++ b/js/rooms.js @@ -187,6 +187,10 @@ function enterRoom(gx, gy) { // boss fights are a pure ammo drain, so stock the room on the way in spawnPickup('ammo', a.cx, a.cy + 140); Music.playBoss(boss.bossKind); + G.mode = 'bossload'; + G.bossLoadSheet = boss.sprite + '_sheet'; + G.bossLoadT = BOSS_LOAD_MIN; + Sprites.warm(G.bossLoadSheet); } else if (room.type === 'item') { spawnItemPedestal(a.cx - 45, a.cy, null, 'room'); // item rooms sometimes also stock a weapon diff --git a/js/sfxbank.js b/js/sfxbank.js index 3cd8fd3..1921c72 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=59') + const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=60') .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/js/sprites.js b/js/sprites.js index 480ca2c..8618fbb 100644 --- a/js/sprites.js +++ b/js/sprites.js @@ -88,15 +88,32 @@ const Sprites = { const finish = () => { if (--pending === 0) G.imagesLoaded = true; }; img.onload = () => { this.imgs[name] = img; if (typeof img.decode === 'function') img.decode().then(finish, finish); else finish(); }; img.onerror = () => { - if (hd && !img._sdRetry) { img._sdRetry = true; img.src = 'assets/' + name + '.png?v=59'; return; } + if (hd && !img._sdRetry) { img._sdRetry = true; img.src = 'assets/' + name + '.png?v=60'; return; } + this.imgs[name] = null; finish(); }; - img.src = hd ? 'assets/hd/' + name + '.webp?v=59' : 'assets/' + name + '.png?v=59'; + img.src = hd ? 'assets/hd/' + name + '.webp?v=60' : 'assets/' + name + '.png?v=60'; } }, get(name) { return this.imgs[name] || null; }, + forceUpload(img) { + if (typeof document === 'undefined' || !document.createElement) return; + if (!document.createElement('canvas').getContext) return; + if (!this._warmCtx) { + const c = document.createElement('canvas'); + c.width = 1; c.height = 1; + this._warmCtx = c.getContext('2d'); + } + if (this._warmCtx) this._warmCtx.drawImage(img, 0, 0, 1, 1); + }, + + warm(name) { + if (this.imgs[name]) { this.forceUpload(this.imgs[name]); return true; } + return this.imgs[name] !== undefined; + }, + // Compact player-only strip: eight forward-facing stride frames. // Runtime rotation is smooth; distance-driven phase keeps contacts from skating. legs(ctx, x, y, facing, phase, targetW) { diff --git a/js/state.js b/js/state.js index cf18cc1..8733f3f 100644 --- a/js/state.js +++ b/js/state.js @@ -51,6 +51,8 @@ const G = { hudAlpha: 1, hdRemaster: false, imagesLoaded: false, + bossLoadT: 0, + bossLoadSheet: null, roomLayer: null, roomLayerKey: '', atmosphereLayer: null, @@ -72,6 +74,7 @@ const PRESSURE_GAIN = PRESSURE_UNIT; // retained: dial 0 == 1 point const PRESSURE_DROP_BASE = 3; // dial 0 == 0.03 relief base const PRESSURE_RELIEF_MUL = 0.5; // global brake: all pressure drops (hit relief + passive decay) are halved const DEATH_LOCK = 3; +const BOSS_LOAD_MIN = 1.0; const PRESSURE_DIAL_MIN = -10, PRESSURE_DIAL_MAX = 10; function hdScale() { return G.hdRemaster ? 4 : 1; } diff --git a/package.json b/package.json index cc019e5..dcb920d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "meatslicer", "productName": "MeatSlicer", - "version": "0.9.4", + "version": "0.9.5", "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 3f20ed5..3548846 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -84,6 +84,8 @@ function fire(ev, key) { function press(key) { fire('keydown', key); } function release(key) { fire('keyup', key); } function tap(key) { press(key); step(1, 16); release(key); } +function drainBossLoad() { let g = 0; while (ctx.G.mode === 'bossload' && g++ < 150) step(1, 16); } + let failures = 0; function check(name, cond) { @@ -216,7 +218,7 @@ 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=59' && urls[1] === 'assets/' + name + '.png?v=59'; + const ok = urls && urls.length === 2 && urls[0] === 'assets/hd/' + name + '.webp?v=60' && urls[1] === 'assets/' + name + '.png?v=60'; if (ok) hdFallbackOk++; } check('HD loader tries WebP then PNG per sprite', hdFallbackOk === ctx.SPRITE_MANIFEST.length); @@ -241,7 +243,7 @@ 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=59'; + const ok = urls && urls.length === 1 && urls[0] === 'assets/' + name + '.png?v=60'; if (ok) sdUrlOk++; } check('SD loader requests one PNG per sprite', sdUrlOk === ctx.SPRITE_MANIFEST.length); @@ -1946,7 +1948,12 @@ check('floor 2', ctx.G.floor === 2); const bossRoom = Object.values(ctx.G.rooms).find(r => r.type === 'boss'); check('boss room exists', !!bossRoom); ctx.enterRoom(bossRoom.gx, bossRoom.gy); -step(10, 16); +step(1, 16); +check('boss room opens a loading screen', ctx.G.mode === 'bossload'); +drainBossLoad(); +check('boss loading screen resolves to play', ctx.G.mode === 'play'); +check('boss sheet warm helper exists', typeof ctx.Sprites.warm === 'function'); +check('elite summoner count uses 4-6', (() => { const e = ctx.makeEnemy('broodsac', 400, 300, 3, true); return e.elite === true; })()); check('boss spawned', !!ctx.G.boss); check('boss hp is finite', ctx.G.boss && isFinite(ctx.G.boss.hp)); check('boss music playing', ctx.Music.current && ctx.Music.current.name.includes('Boss')); @@ -2255,6 +2262,7 @@ ctx.nextFloor(); step(5, 16); check('override survives floor change', ctx.Music.current.name === ctx.Music.PLAYLIST[1]); const jbr = Object.values(ctx.G.rooms).find(r => r.type === 'boss'); ctx.enterRoom(jbr.gx, jbr.gy); step(5, 16); +drainBossLoad(); check('boss track interrupts override', ctx.Music.current.name.includes('Boss')); ctx.Music.cycle(1); // cycling during a boss must not cut the boss track check('boss track not interrupted', ctx.Music.current.name.includes('Boss'));