Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faa58b5678 |
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user