Cleaver whirl, HD boss first-draw, elite Splitter minis.
This commit is contained in:
Quintine committed 2026-08-15 00:46:35 +10:00
1 parent 8991bde943
commit faa58b5678
14 files changed
+111 -45

No files matched your search

+10
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 |
+5 -5
View File
@@ -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
+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=60">
<link rel="stylesheet" href="css/style.css?v=61">
</head>
<body>
<div id="wrap">
<canvas id="game" width="960" height="640"></canvas>
</div>
<script src="js/utils.js?v=60"></script>
<script src="js/state.js?v=60"></script>
<script src="js/input.js?v=60"></script>
<script src="js/sfx.js?v=60"></script>
<script src="js/sfxbank.js?v=60"></script>
<script src="js/audio.js?v=60"></script>
<script src="js/sprites.js?v=60"></script>
<script src="js/particles.js?v=60"></script>
<script src="js/weapons.js?v=60"></script>
<script src="js/bullets.js?v=60"></script>
<script src="js/pickups.js?v=60"></script>
<script src="js/items.js?v=60"></script>
<script src="js/perks.js?v=60"></script>
<script src="js/enemies.js?v=60"></script>
<script src="js/bosses.js?v=60"></script>
<script src="js/rooms.js?v=60"></script>
<script src="js/player.js?v=60"></script>
<script src="js/hud.js?v=60"></script>
<script src="js/help.js?v=60"></script>
<script src="js/debug.js?v=60"></script>
<script src="js/main.js?v=60"></script>
<script src="js/utils.js?v=61"></script>
<script src="js/state.js?v=61"></script>
<script src="js/input.js?v=61"></script>
<script src="js/sfx.js?v=61"></script>
<script src="js/sfxbank.js?v=61"></script>
<script src="js/audio.js?v=61"></script>
<script src="js/sprites.js?v=61"></script>
<script src="js/particles.js?v=61"></script>
<script src="js/weapons.js?v=61"></script>
<script src="js/bullets.js?v=61"></script>
<script src="js/pickups.js?v=61"></script>
<script src="js/items.js?v=61"></script>
<script src="js/perks.js?v=61"></script>
<script src="js/enemies.js?v=61"></script>
<script src="js/bosses.js?v=61"></script>
<script src="js/rooms.js?v=61"></script>
<script src="js/player.js?v=61"></script>
<script src="js/hud.js?v=61"></script>
<script src="js/help.js?v=61"></script>
<script src="js/debug.js?v=61"></script>
<script src="js/main.js?v=61"></script>
</body>
</html>
+1 -1
View File
@@ -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);
+11
View File
@@ -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';
+11 -2
View File
@@ -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++) {
+9 -5
View File
@@ -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; }
+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=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)))
+2 -2
View File
@@ -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';
}
},
+5 -2
View File
@@ -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
+1 -1
View File
@@ -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",
+31 -2
View File
@@ -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);
}