diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d06b876..b5c7ada 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.9.6] — 2026-08-15 + +### Changed +- **Cleaver Cadence** — wider, harder whirl (`sweepRange` 90→110, `sweepArc` 2.4→2.85, `dmg` 34→42). Magazine stays 26 / refill 10. The butcher spins ~2.5 turns during the swing. Dual-arc blood/spark VFX and hit gibs; yellow gun muzzle is suppressed. +- **Elite Splitters** — now leave 2 Minis on death, same as a normal Splitter (instant, no telegraph). + +### Fixed +- **HD Remaster first boss-room hitch** — `drawBossLoad` now first-draws the 4× floor/wall caches and blits the boss sheet onto the game canvas under the overlay, so combat starts without a first-frame GPU stall. SD and re-visits are unchanged. + + ## [0.9.5] — 2026-08-15 ### Added diff --git a/docs/enemies.md b/docs/enemies.md index 909f730..a6771b1 100644 --- a/docs/enemies.md +++ b/docs/enemies.md @@ -56,7 +56,7 @@ Ranged kiter. Holds 190–250 px away from you — approaches if you're farther | XP | 1 | | First appears | Floor 2 (spawn weight 16) | -Chaser that **splits into 2 Minis on death** (non-elite Splitters only). Slightly tankier than a Shambler. Explosions and AoE that kill the Minis with the parent are valuable here. +Chaser that **splits into 2 Minis on death** (elite and normal). Slightly tankier than a Shambler. Explosions and AoE that kill the Minis with the parent are valuable here. ### Mini diff --git a/docs/gameplay.md b/docs/gameplay.md index 9f28d16..ae6a9a7 100644 --- a/docs/gameplay.md +++ b/docs/gameplay.md @@ -281,7 +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 | +| **Boss approach** | Full-screen hold on first boss-room entry: boss name + FRESH MEAT APPROACHES, ≥1s (HD first-draw paid under the overlay), 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/docs/weapons.md b/docs/weapons.md index 0dd0a95..c5e005d 100644 --- a/docs/weapons.md +++ b/docs/weapons.md @@ -106,18 +106,18 @@ Shotgun. Up to 66 damage per trigger pull at point blank before multipliers, but ### Cleaver Cadence -> *A wide, dramatic melee sweep* +> *A wider, gory whirl of steel* | Stat | Value | |---|---| -| Damage | 34 | +| Damage | 42 | | Fire interval | 0.55 s | -| Reach | 90 px | -| Arc | 2.4 rad (~137°) | +| Reach | 110 px | +| Arc | 2.85 rad (~163°) | | Ammo | 26 (refill 10 per pickup) | | Recoil | 0.95 (heavy) | -Melee sweep: one wide swing across the aim direction hits every enemy within 90 px inside a ~137° arc for 34 damage with heavy knockback. +Melee sweep: one wide whirl across the aim direction hits every enemy within 110 px inside a ~163° arc for 42 damage with heavy knockback. The butcher spins ~2.5 turns during the swing. Blood and sparks ride the arc; there is no yellow gun muzzle. ### Sawblade Launcher diff --git a/index.html b/index.html index 187233b..f5f3b7e 100644 --- a/index.html +++ b/index.html @@ -6,32 +6,32 @@ MEATSLICER - +
- - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/js/enemies.js b/js/enemies.js index 8f85eca..e187b59 100644 --- a/js/enemies.js +++ b/js/enemies.js @@ -378,7 +378,7 @@ function killEnemy(e, ang) { } // splitter splits - if (ENEMY_TYPES[e.type].splits && !e.elite) { + if (ENEMY_TYPES[e.type].splits) { Sfx.split(e); for (let i = 0; i < 2 && G.enemies.length < MAX_ENEMIES; i++) { const m = makeEnemy('mini', e.x + rand(-18, 18), e.y + rand(-18, 18), G.floor, false); diff --git a/js/main.js b/js/main.js index 51a1b96..419e295 100644 --- a/js/main.js +++ b/js/main.js @@ -385,6 +385,17 @@ function draw() { } function drawBossLoad(ctx) { + if (G.cur) { + drawFloor(ctx, G.cur); + drawWalls(ctx, G.cur); + } + const sheet = G.bossLoadSheet && Sprites.get(G.bossLoadSheet); + if (sheet) { + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.drawImage(sheet, 0, 0); + ctx.restore(); + } ctx.fillStyle = '#0a0506'; ctx.fillRect(0, 0, W, H); ctx.textAlign = 'center'; diff --git a/js/particles.js b/js/particles.js index 7236703..8c7047e 100644 --- a/js/particles.js +++ b/js/particles.js @@ -113,11 +113,20 @@ function spawnDashBurst(x, y, ang) { function spawnSweepFx(x, y, ang, reach, arc) { - addParticle({ type: 'sweep', x, y, ang, arc, r: 20, maxR: reach, life: 0.18, t: 0 }, true); + addParticle({ type: 'sweep', x, y, ang, arc, r: 20, maxR: reach, life: 0.22, t: 0 }, true); + addParticle({ type: 'sweep', x, y, ang, arc: arc * 0.72, r: 12, maxR: reach * 0.82, life: 0.14, t: 0 }, true); + spawnShockwave(x, y, reach * 0.55, '#8b1020', 0.45); + for (let i = 0; i < 10; i++) { + const a = ang - arc / 2 + (i / 9) * arc; + const d = reach * rand(0.35, 1); + const px = x + Math.cos(a) * d, py = y + Math.sin(a) * d; + spawnBlood(px, py, a, 2, i % 3 === 0); + if (i % 2 === 0) spawnSpark(px, py, a); + } } function spawnMuzzleFx(x, y, ang, behavior) { - if (behavior === 'slam') return; + if (behavior === 'slam' || behavior === 'sweep') return; const back = ang + Math.PI; if (behavior === 'flame') { for (let i = 0; i < 3; i++) { diff --git a/js/player.js b/js/player.js index deb1517..dd3ca1c 100644 --- a/js/player.js +++ b/js/player.js @@ -444,18 +444,22 @@ function drawPlayer(ctx) { } const w = WEAPONS[p.weapon.id]; + let swingFacing = p.aim; + if (w.id === 'cleaver' && p.attackT > 0) { + swingFacing = p.aim + (1 - clamp(p.attackT / 0.4, 0, 1)) * TAU * 2.5; + } const kick = p.recoil * 3; Sprites.shadow(ctx, p.x, p.y + 17, 27 + p.moveBlend * 2, 9, 0.46); if (p.hp <= 0) { Sprites.strip(ctx, 'player_death_sheet', p.x, p.y, p.deathT || 0, 12, 14, 128, 144, true); } else { - Sprites.legs(ctx, p.x, p.y + 4, p.bodyFacing, p.step, 124); + Sprites.legs(ctx, p.x, p.y + 4, w.id === 'cleaver' && p.attackT > 0 ? swingFacing : p.bodyFacing, p.step, 124); const torsoOffset = (w.torsoFwd || 18) - kick; - const torsoX = p.x + Math.cos(p.aim) * torsoOffset; - const torsoY = p.y + Math.sin(p.aim) * torsoOffset; - Sprites.draw(ctx, 'pt_' + p.weapon.id, torsoX, torsoY, p.aim, w.torsoW || 116, p.hitT > 0); + const torsoX = p.x + Math.cos(swingFacing) * torsoOffset; + const torsoY = p.y + Math.sin(swingFacing) * torsoOffset; + Sprites.draw(ctx, 'pt_' + p.weapon.id, torsoX, torsoY, swingFacing, w.torsoW || 116, p.hitT > 0); } - if (p.hp > 0 && p.muzzleT > 0 && w.behavior !== 'slam' && w.behavior !== 'saw') { + if (p.hp > 0 && p.muzzleT > 0 && w.behavior !== 'slam' && w.behavior !== 'saw' && w.behavior !== 'sweep') { const muzzleDist = (w.muzzle || 40) - kick; let mx = p.x + Math.cos(p.aim) * muzzleDist, my = p.y + Math.sin(p.aim) * muzzleDist; if (w.behavior === 'flame') { const rh = p.aim + Math.PI / 2; mx += Math.cos(rh) * 26; my += Math.sin(rh) * 26; } diff --git a/js/sfxbank.js b/js/sfxbank.js index 1921c72..db985c2 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=60') + const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=61') .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 8618fbb..89c5fbe 100644 --- a/js/sprites.js +++ b/js/sprites.js @@ -88,11 +88,11 @@ 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=60'; return; } + if (hd && !img._sdRetry) { img._sdRetry = true; img.src = 'assets/' + name + '.png?v=61'; return; } this.imgs[name] = null; finish(); }; - img.src = hd ? 'assets/hd/' + name + '.webp?v=60' : 'assets/' + name + '.png?v=60'; + img.src = hd ? 'assets/hd/' + name + '.webp?v=61' : 'assets/' + name + '.png?v=61'; } }, diff --git a/js/weapons.js b/js/weapons.js index ce98cbf..2a257b0 100644 --- a/js/weapons.js +++ b/js/weapons.js @@ -11,7 +11,7 @@ const WEAPONS = { bonepopper: { id: 'bonepopper', name: 'Bone Popper', held: 38, torsoW: 106, torsoFwd: 18, muzzle: 62, tier: -1, ammo: Infinity, interval: 0.60, dmg: 8, spd: 540, range: 0.45, behavior: 'bullet', sprite: 'bullet_bone', sfx: 'default', punch: 0.42, desc: 'Trusty bone-shard sidearm' }, repeater: { id: 'repeater', name: 'Ribcage Repeater', held: 52, torsoW: 120, torsoFwd: 20, muzzle: 70, tier: 0, ammo: 84, refill: 42, interval: 0.09, dmg: 6, spd: 560, spread: 0.10, range: 0.42, behavior: 'bullet', sprite: 'bullet_bone', sfx: 'rapid', punch: 0.18, desc: 'Rattles off rib slivers' }, marrow: { id: 'marrow', name: 'Marrow Scatter', held: 54, torsoW: 120, torsoFwd: 20, muzzle: 69, tier: 0, ammo: 20, refill: 6, interval: 0.58, dmg: 11, spd: 500, pellets: 6, spread: 0.55, range: 0.38, behavior: 'spread', sprite: 'bullet_bone', sfx: 'spread', punch: 0.92, desc: 'A devastating close blast of jagged bone' }, - cleaver: { id: 'cleaver', name: 'Cleaver Cadence', held: 60, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 1, ammo: 26, refill: 10, interval: 0.55, dmg: 34, behavior: 'sweep', sprite: 'bullet_cleaver', sfx: 'heavy', punch: 0.95, sweepRange: 90, sweepArc: 2.4, desc: 'A wide, dramatic melee sweep' }, + cleaver: { id: 'cleaver', name: 'Cleaver Cadence', held: 60, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 1, ammo: 26, refill: 10, interval: 0.55, dmg: 42, behavior: 'sweep', sprite: 'bullet_cleaver', sfx: 'heavy', punch: 0.95, sweepRange: 110, sweepArc: 2.85, desc: 'A wider, gory whirl of steel' }, saw: { id: 'saw', name: 'Sawblade Launcher',held: 52, torsoW: 118, torsoFwd: 20, muzzle: 70, tier: 1, ammo: 24, refill: 9, interval: 0.34, dmg: 13, spd: 360, bounces: 5, range: 0.60, behavior: 'bounce', sprite: 'bullet_saw', sfx: 'saw', punch: 0.45, desc: 'Bouncing, hungry steel' }, bile: { id: 'bile', name: 'Bile Blunderbuss', held: 54, torsoW: 118, torsoFwd: 20, muzzle: 68, tier: 2, ammo: 80, refill: 21, drain: 8, interval: 0.12, dmg: 7, spd: 340, spread: 0.32, range: 0.38, behavior: 'cone', sprite: 'bullet_gore', sfx: 'flame', punch: 0.34, desc: 'A brutal corrosive torrent; pools linger' }, hemophage: { id: 'hemophage', name: 'Hemophage', held: 50, torsoW: 116, torsoFwd: 19, muzzle: 67, tier: 1, ammo: 70, refill: 42, interval: 0.19, dmg: 8, spd: 480, lifesteal: 0.5, range: 0.45, behavior: 'bullet', sprite: 'bullet_syringe', sfx: 'default', punch: 0.35, desc: 'Drinks what it touches' }, @@ -141,9 +141,12 @@ function fireWeapon(p, w) { const a = angleTo(p.x, p.y, e.x, e.y); if (Math.abs(angleDelta(a, ang)) > half) continue; damageEnemy(e, w.dmg * dmgMul, a, true, { source: 'player' }); + spawnBlood(e.x, e.y, a, 8, true); + spawnGibs(e.x, e.y, 3); + addShake(2); } spawnSweepFx(p.x, p.y, ang, reach, w.sweepArc || 2.4); - addShake(4); + addShake(6); break; } default: { // bullet, bounce and homing projectiles diff --git a/package.json b/package.json index dcb920d..1482c9e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "meatslicer", "productName": "MeatSlicer", - "version": "0.9.5", + "version": "0.9.6", "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 3548846..78d22ca 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -218,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=60' && urls[1] === 'assets/' + name + '.png?v=60'; + const ok = urls && urls.length === 2 && urls[0] === 'assets/hd/' + name + '.webp?v=61' && urls[1] === 'assets/' + name + '.png?v=61'; if (ok) hdFallbackOk++; } check('HD loader tries WebP then PNG per sprite', hdFallbackOk === ctx.SPRITE_MANIFEST.length); @@ -243,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=60'; + const ok = urls && urls.length === 1 && urls[0] === 'assets/' + name + '.png?v=61'; if (ok) sdUrlOk++; } check('SD loader requests one PNG per sprite', sdUrlOk === ctx.SPRITE_MANIFEST.length); @@ -767,6 +767,28 @@ check('Bile Blunderbuss was substantially strengthened', ctx.WEAPONS.bile.dmg >= check('Cauterizer was substantially strengthened', ctx.WEAPONS.cauterizer.dmg >= 4.5 && ctx.WEAPONS.cauterizer.range >= 0.36); check('melee weapons were substantially strengthened', ctx.WEAPONS.tenderizer.dmg >= 60 && ctx.WEAPONS.redhand.dmg >= 7); +console.log('== cleaver cadence =='); +{ + const c = ctx.WEAPONS.cleaver; + check('cleaver magazine unchanged', c.ammo === 26 && c.refill === 10); + check('cleaver sweep is larger and hits harder', c.dmg === 42 && c.sweepRange === 110 && c.sweepArc === 2.85); + const p = ctx.G.player; + p.weapon = { id: 'cleaver', ammo: 26 }; + p.holstered = null; + p.fireT = 0; p.attackT = 0; p.aim = 0; + ctx.G.enemies.length = 0; ctx.G.parts.length = 0; + const e = ctx.makeEnemy('shambler', p.x + 50, p.y, 1, false); + e.spd = 0; e.hp = e.maxHp = 200; + ctx.G.enemies.push(e); + ctx.Input.mx = e.x; ctx.Input.my = e.y; + ctx.Input.mdown = true; step(3, 16); ctx.Input.mdown = false; + check('cleaver swing damages in the new reach', e.hp < e.maxHp); + check('cleaver swing starts the spin window', p.attackT > 0); + check('cleaver swing emits sweep VFX', ctx.G.parts.some(pt => pt.type === 'sweep')); + ctx.G.enemies.length = 0; ctx.G.parts.length = 0; + p.weapon = { id: 'bonepopper', ammo: Infinity }; +} + console.log('== continuous saw frame-rate independence =='); { const p = ctx.G.player; @@ -1620,6 +1642,13 @@ console.log('== 750ms entry-wave spawn telegraph =='); check('splitter splits into minis', minis.length === 2); check('splits spawn instantly (no telegraph)', minis.every(m => (m.warmT || 0) === 0)); ctx.G.enemies.length = 0; + const eliteSplit = ctx.makeEnemy('splitter', ctx.W / 2, ctx.H / 2, 1, true); + ctx.G.enemies.push(eliteSplit); + ctx.damageEnemy(eliteSplit, 99999, 0, false); + const eliteMinis = ctx.G.enemies.filter(en => en.type === 'mini'); + check('elite splitter splits into minis', eliteSplit.elite === true && eliteMinis.length === 2); + check('elite splits spawn instantly', eliteMinis.every(m => (m.warmT || 0) === 0)); + ctx.G.enemies.length = 0; ctx.startRun(); step(3, 16); }