diff --git a/.gitignore b/.gitignore index 0b03b9c..9f96d16 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ dist/ served-state.txt __pycache__/ *.pyc +assets/raw/models/ +assets/raw/meshy/ +assets/raw/meshy_manifest.jsonl diff --git a/docs/gameplay.md b/docs/gameplay.md index 7e9dabe..ee9931e 100644 --- a/docs/gameplay.md +++ b/docs/gameplay.md @@ -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). --- diff --git a/docs/weapons.md b/docs/weapons.md index 5697282..0dd0a95 100644 --- a/docs/weapons.md +++ b/docs/weapons.md @@ -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 | diff --git a/index.html b/index.html index 4e66ffa..3b72dc1 100644 --- a/index.html +++ b/index.html @@ -6,32 +6,32 @@ MEATSLICER - +
- - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/js/pickups.js b/js/pickups.js index 967ab78..2cbab2a 100644 --- a/js/pickups.js +++ b/js/pickups.js @@ -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(); diff --git a/js/sfxbank.js b/js/sfxbank.js index d8246ff..3cd8fd3 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=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))) diff --git a/js/sprites.js b/js/sprites.js index 0f7b6f5..480ca2c 100644 --- a/js/sprites.js +++ b/js/sprites.js @@ -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'; } }, diff --git a/js/weapons.js b/js/weapons.js index 0bbf317..ce98cbf 100644 --- a/js/weapons.js +++ b/js/weapons.js @@ -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) diff --git a/package.json b/package.json index 6ed3f15..d7ac8f9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/smoke.js b/test/smoke.js index 6050ca0..de2a3ba 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -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); }