1 Commits
Author SHA1 Message Date
Quintine 988e04b2d4 0.9.3
Restore 2D 0.9.2, ignore Meshy refs, double Spinal Tap, Marrow Draught beam ammo, Second Stomach hearts fill overflow shields.
2026-08-14 23:31:39 +10:00
10 changed files with 68 additions and 36 deletions

No files matched your search

+3
View File
@@ -7,3 +7,6 @@ dist/
served-state.txt
__pycache__/
*.pyc
assets/raw/models/
assets/raw/meshy/
assets/raw/meshy_manifest.jsonl
+1 -1
View File
@@ -119,7 +119,7 @@ Right-click is disabled (context menu suppressed). There is **no interact key**
### Healing
- Hearts restore 2 HP (1 heart) each.
- Healing past max HP normally goes to waste — unless you have the **Second Stomach** item, which converts overflow healing into shield HP. Hearts are not consumed at full HP (they stay on the floor for later, and the magnet won't drag them).
- Healing past max HP normally goes to waste — unless you have the **Second Stomach** item, which converts overflow healing into shield HP. Hearts stay on the floor at full HP unless Second Stomach has unused overflow-shield room (then they magnetize, collect, and fill shields).
- Other healing sources: Vampire Dentures (kill procs), Hemophage (hit procs), Worm Gut / Bone Knit (post-combat heals).
---
+1 -1
View File
@@ -295,7 +295,7 @@ Point-blank slam — no projectile, an instant circular blast in front of you. B
| Stat | Value |
|---|---|
| Damage | 95 (× 0.4–1.0 by charge) |
| Damage | 190 (× 0.4–1.0 by charge) |
| Charge time | 0.85 s (minimum 40% charge = 0.34 s to fire) |
| Beam length | 900 × rangeMul |
| Beam width | 14 × √sizeMul |
+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=58">
<link rel="stylesheet" href="css/style.css?v=59">
</head>
<body>
<div id="wrap">
<canvas id="game" width="960" height="640"></canvas>
</div>
<script src="js/utils.js?v=58"></script>
<script src="js/state.js?v=58"></script>
<script src="js/input.js?v=58"></script>
<script src="js/sfx.js?v=58"></script>
<script src="js/sfxbank.js?v=58"></script>
<script src="js/audio.js?v=58"></script>
<script src="js/sprites.js?v=58"></script>
<script src="js/particles.js?v=58"></script>
<script src="js/weapons.js?v=58"></script>
<script src="js/bullets.js?v=58"></script>
<script src="js/pickups.js?v=58"></script>
<script src="js/items.js?v=58"></script>
<script src="js/perks.js?v=58"></script>
<script src="js/enemies.js?v=58"></script>
<script src="js/bosses.js?v=58"></script>
<script src="js/rooms.js?v=58"></script>
<script src="js/player.js?v=58"></script>
<script src="js/hud.js?v=58"></script>
<script src="js/help.js?v=58"></script>
<script src="js/debug.js?v=58"></script>
<script src="js/main.js?v=58"></script>
<script src="js/utils.js?v=59"></script>
<script src="js/state.js?v=59"></script>
<script src="js/input.js?v=59"></script>
<script src="js/sfx.js?v=59"></script>
<script src="js/sfxbank.js?v=59"></script>
<script src="js/audio.js?v=59"></script>
<script src="js/sprites.js?v=59"></script>
<script src="js/particles.js?v=59"></script>
<script src="js/weapons.js?v=59"></script>
<script src="js/bullets.js?v=59"></script>
<script src="js/pickups.js?v=59"></script>
<script src="js/items.js?v=59"></script>
<script src="js/perks.js?v=59"></script>
<script src="js/enemies.js?v=59"></script>
<script src="js/bosses.js?v=59"></script>
<script src="js/rooms.js?v=59"></script>
<script src="js/player.js?v=59"></script>
<script src="js/hud.js?v=59"></script>
<script src="js/help.js?v=59"></script>
<script src="js/debug.js?v=59"></script>
<script src="js/main.js?v=59"></script>
</body>
</html>
+3 -4
View File
@@ -53,7 +53,7 @@ function updatePickups(dt) {
}
// gems & small pickups fly to the player inside magnet radius
if ((k.type === 'gem' || k.type === 'ammo' || (k.type === 'heart' && p.hp < p.stats.maxHp)) && d < magR) {
if ((k.type === 'gem' || k.type === 'ammo' || (k.type === 'heart' && (p.hp < p.stats.maxHp || (p.stats.overShield > 0 && p.shieldHp < (p.stats.shieldPerk || 0) + p.stats.overShield)))) && d < magR) {
const a = angleTo(k.x, k.y, p.x, p.y);
const sp = lerp(320, 60, d / magR) * p.stats.magnetPull;
k.x += Math.cos(a) * sp * dt; k.y += Math.sin(a) * sp * dt;
@@ -73,9 +73,8 @@ function collectPickup(k) {
Sfx.gem();
break;
case 'heart': {
// At full HP hearts stay on the floor for later; Glutton's Gut heals +1
// extra per tier below that.
if (p.hp >= p.stats.maxHp) return false;
// At full HP hearts stay unless Second Stomach has unused overflow-shield room.
if (p.hp >= p.stats.maxHp && !(p.stats.overShield > 0 && p.shieldHp < (p.stats.shieldPerk || 0) + p.stats.overShield)) return false;
const healAmt = 2 + (p.stats.gluttonGut || 0);
healPlayer(healAmt); spawnText(p.x, p.y, '+' + healAmt, '#d92038');
Sfx.heart();
+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=58')
const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=59')
.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,10 +88,10 @@ 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=58'; return; }
if (hd && !img._sdRetry) { img._sdRetry = true; img.src = 'assets/' + name + '.png?v=59'; return; }
finish();
};
img.src = hd ? 'assets/hd/' + name + '.webp?v=58' : 'assets/' + name + '.png?v=58';
img.src = hd ? 'assets/hd/' + name + '.webp?v=59' : 'assets/' + name + '.png?v=59';
}
},
+2 -2
View File
@@ -22,7 +22,7 @@ const WEAPONS = {
trapqueen: { id: 'trapqueen', name: 'Trap Queen', held: 58, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 2, ammo: 26, refill: 13, interval: 0.55, dmg: 10, spd: 999, behavior: 'lob_trap', sprite: 'bullet_saw', sfx: 'lob', punch: 0.65, desc: 'Bear traps. For bears. And worse' },
tenderizer: { id: 'tenderizer', name: 'The Tenderizer', held: 66, torsoW: 132, torsoFwd: 23, muzzle: 78, tier: 3, ammo: 10, refill: 5, interval: 0.58, dmg: 60, spd: 0, behavior: 'slam', sprite: 'bullet_bone', sfx: 'heavy', punch: 1.00, desc: 'A room-shaking close-range meat paste' },
redhand: { id: 'redhand', name: 'Red Right Hand', held: 68, torsoW: 134, torsoFwd: 24, muzzle: 80, tier: 2, ammo: 150, refill: 72, drain: 18, interval: 0.10, dmg: 7, spd: 0, behavior: 'saw', sprite: 'bullet_saw', sfx: 'saw', punch: 0.38, desc: 'A chainsaw that liquefies anything in reach' },
spinaltap: { id: 'spinaltap', name: 'Spinal Tap', held: 68, torsoW: 136, torsoFwd: 24, muzzle: 81, tier: 3, ammo: 7, refill: 4, interval: 0.30, dmg: 95, spd: 0, chargeTime: 0.85, behavior: 'beam', sprite: 'bullet_bone', sfx: 'beam', punch: 1.00, desc: 'Charge it. Delete a line of meat' },
spinaltap: { id: 'spinaltap', name: 'Spinal Tap', held: 68, torsoW: 136, torsoFwd: 24, muzzle: 81, tier: 3, ammo: 7, refill: 4, interval: 0.30, dmg: 190, spd: 0, chargeTime: 0.85, behavior: 'beam', sprite: 'bullet_bone', sfx: 'beam', punch: 1.00, desc: 'Charge it. Delete a line of meat' },
swarmjar: { id: 'swarmjar', name: 'Swarm Jar', held: 62, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 3, ammo: 12, refill: 6, interval: 0.80, dmg: 9, spd: 999, behavior: 'lob_swarm', sprite: 'bullet_gore', sfx: 'lob', punch: 0.70, desc: 'A jar of friends. They are hungry' },
};
@@ -215,7 +215,7 @@ function fireBeam(p, def, charge) {
p.actionT = 0;
addShake(8);
Sfx.shoot(def);
p.weapon.ammo -= 1 / st.ammoEff;
if (!(p.marrowDraughtT > 0)) p.weapon.ammo -= 1 / st.ammoEff;
}
// damage every enemy in a radius (slams, explosions)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meatslicer",
"productName": "MeatSlicer",
"version": "0.9.2",
"version": "0.9.3",
"private": true,
"description": "A Butcher's Descent - clear, harvest, descend.",
"main": "desktop/main.js",
+32 -2
View File
@@ -216,7 +216,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=58' && urls[1] === 'assets/' + name + '.png?v=58';
const ok = urls && urls.length === 2 && urls[0] === 'assets/hd/' + name + '.webp?v=59' && urls[1] === 'assets/' + name + '.png?v=59';
if (ok) hdFallbackOk++;
}
check('HD loader tries WebP then PNG per sprite', hdFallbackOk === ctx.SPRITE_MANIFEST.length);
@@ -241,7 +241,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=58';
const ok = urls && urls.length === 1 && urls[0] === 'assets/' + name + '.png?v=59';
if (ok) sdUrlOk++;
}
check('SD loader requests one PNG per sprite', sdUrlOk === ctx.SPRITE_MANIFEST.length);
@@ -1127,6 +1127,7 @@ console.log('== generic ammo drops ==');
ctx.WEAPONS.repeater.refill > ctx.WEAPONS.spinaltap.refill * 10);
check('stream weapons are time-denominated (drain, not per-shot)',
ctx.WEAPONS.bile.drain > 0 && ctx.WEAPONS.cauterizer.drain > 0 && ctx.WEAPONS.redhand.drain > 0);
check('Spinal Tap base damage is 190', ctx.WEAPONS.spinaltap.dmg === 190);
const repeaterCap = ctx.WEAPONS.repeater.ammo * 1.5;
p.weapon = { id: 'repeater', ammo: repeaterCap - 1 };
@@ -1197,6 +1198,28 @@ console.log('== hearts stay on the floor at full HP ==');
ctx.startRun(); step(3, 16);
}
console.log('== Second Stomach hearts fill overflow shields ==');
{
ctx.startRun(); step(3, 16);
const p = ctx.G.player;
ctx.giveItem('secondstomach');
p.hp = p.stats.maxHp;
p.shieldHp = 0;
const cap = (p.stats.shieldPerk || 0) + p.stats.overShield;
ctx.G.pickups.length = 0;
ctx.G.enemies.length = 0;
ctx.spawnPickup('heart', p.x, p.y);
step(2, 16);
check('Second Stomach collects heart at full HP', ctx.G.pickups.length === 0);
check('heart overflow fills shields', p.hp === p.stats.maxHp && p.shieldHp === Math.min(cap, 2));
p.hp = p.stats.maxHp;
p.shieldHp = cap;
ctx.spawnPickup('heart', p.x, p.y);
step(2, 16);
check('heart stays when Second Stomach shields are full', ctx.G.pickups.length === 1);
ctx.startRun(); step(3, 16);
}
console.log('== dash: burst, slow, cooldown ==');
{
ctx.startRun(); step(3, 16);
@@ -1297,6 +1320,13 @@ console.log('== muzzle origins match projectile origins ==');
const beamParts = ctx.G.parts.filter(pt => pt.type === 'beam');
const newest = beamParts[beamParts.length - 1];
check('beam particle starts at spinaltap muzzle (81px)', newest && Math.abs(newest.x - (p.x + 81)) < 0.01 && Math.abs(newest.y - (p.y)) < 0.01);
p.weapon = { id: 'spinaltap', ammo: 7 };
p.marrowDraughtT = 5;
ctx.fireBeam(p, ctx.WEAPONS.spinaltap, 1);
check('Marrow Draught fireBeam spends no ammo', p.weapon.ammo === 7);
p.marrowDraughtT = 0;
ctx.fireBeam(p, ctx.WEAPONS.spinaltap, 1);
check('fireBeam spends ammo without Marrow Draught', p.weapon.ammo === 6);
ctx.G.bullets.length = 0;
ctx.startRun(); step(3, 16);
}