From 7aee2f5f641bda2df0d9e2b1c03d18969bc35293 Mon Sep 17 00:00:00 2001 From: Quintine Date: Sun, 9 Aug 2026 13:56:38 +1000 Subject: [PATCH 1/4] fix: don't fire weapon when choosing upgrade from level-up menu --- index.html | 44 ++++++++++++++++++++++---------------------- js/main.js | 4 ++++ test/smoke.js | 18 +++++++++++++++++- 3 files changed, 43 insertions(+), 23 deletions(-) diff --git a/index.html b/index.html index 2e8a8c0..546184a 100644 --- a/index.html +++ b/index.html @@ -6,32 +6,32 @@ MEATSLICER - +
- - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/js/main.js b/js/main.js index 0e4fff6..c1bc435 100644 --- a/js/main.js +++ b/js/main.js @@ -221,6 +221,10 @@ function update(dt) { break; case 'levelup': updateLevelup(); + // A click that installed a perk must not carry the held button into play; + // updatePlayer fires on Input.mdown, so the very next frame would shoot. + // Same mask pattern as the pinned debug console below. + if (G.mode !== 'levelup') Input.mdown = false; updateParticles(dt); break; case 'debug': diff --git a/test/smoke.js b/test/smoke.js index e9052f3..a0408d0 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -61,7 +61,7 @@ for (const f of files) { } // const/let top-level bindings live in the context's lexical scope, not on the // global object — expose the ones the harness pokes at directly. -vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, Sfx, SfxBank, W, H, WALL, ITEMS, ACTIVES, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS, PRESSURE_UNIT, PRESSURE_MIN, PRESSURE_MAX, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX, ROOM_SHAPES, ROOM_THEMES, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN, defaultPlayerStats, applyPressureDelta, debugRebuildStats, debugEnabled, DEBUG_PAGES })', sandbox); +vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, Sfx, SfxBank, W, H, WALL, ITEMS, ACTIVES, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS, PRESSURE_UNIT, PRESSURE_MIN, PRESSURE_MAX, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX, WEAPON_DROP_LOCKOUT, ROOM_SHAPES, ROOM_THEMES, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN, defaultPlayerStats, applyPressureDelta, debugRebuildStats, debugEnabled, DEBUG_PAGES })', sandbox); let simTime = 0; const ctx = sandbox; @@ -308,6 +308,22 @@ console.log('== rebalanced perks + automatic draft =='); ctx.G.autoPerk = false; ctx.startRun(); step(3, 16); } + +console.log('== upgrade card click does not fire =='); +{ + ctx.startRun(); step(3, 16); // fresh run in play mode + ctx.G.enemies.length = 0; // determinism: nothing can interfere + const perksBefore = ctx.G.player.perks.length; + ctx.G.pendingLevelups = 1; ctx.G.perkChoices = ctx.PERKS.slice(0, 3); ctx.G.mode = 'levelup'; + // click inside card 0 (cards start at x=207, y=220, 170x230) + ctx.Input.mx = 300; ctx.Input.my = 300; + ctx.Input.mpressed = true; ctx.Input.mdown = true; + step(6, 16); // first frame consumes the click, the rest run with mdown held + check('card click exits the draft into play', ctx.G.mode === 'play'); + check('card click grants exactly one perk', ctx.G.player.perks.length === perksBefore + 1); + check('card click fires no bullet (mdown not carried into play)', ctx.G.bullets.length === 0); +} + autoDraft = true; // later sections must not get stuck in the perk draft console.log('== shield heart perk =='); From e801c02ef6e2eb5d52716882558150daa82a3e16 Mon Sep 17 00:00:00 2001 From: Quintine Date: Sun, 9 Aug 2026 13:56:41 +1000 Subject: [PATCH 2/4] feat: 2.5s lockout before dropped weapons can be re-picked --- js/pickups.js | 2 +- js/state.js | 2 ++ package-lock.json | 4 ++-- test/smoke.js | 24 ++++++++++++++++++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/js/pickups.js b/js/pickups.js index 17abca1..95a977f 100644 --- a/js/pickups.js +++ b/js/pickups.js @@ -117,7 +117,7 @@ function collectPickup(k) { if (old) { const a = rand(0, TAU); spawnPickup('weapon', p.x + Math.cos(a) * 34, p.y + Math.sin(a) * 34, - { wid: old.id, ammo: old.ammo, delay: 0.6 }); + { wid: old.id, ammo: old.ammo, delay: WEAPON_DROP_LOCKOUT }); } p.weapon = { id: w.id, ammo: k.ammo !== undefined ? k.ammo : w.ammo }; p.holstered = null; diff --git a/js/state.js b/js/state.js index 4d59a2b..27b3580 100644 --- a/js/state.js +++ b/js/state.js @@ -59,6 +59,8 @@ const PERK_POWER_WEIGHT = 0.5; // ammo economy: global dial for drop frequency (refill size is per-weapon) const AMMO_DROP_SCALE = 0.75; const PRESSURE_MIN = 0.60, PRESSURE_MAX = 2.00; +// seconds a player-dropped weapon stays un-pickable (prevents instant swap loops) +const WEAPON_DROP_LOCKOUT = 2.5; const PRESSURE_UNIT = 0.01; // one "point" on the dial scale const PRESSURE_GAIN = PRESSURE_UNIT; // retained: dial 0 == 1 point const PRESSURE_DROP_BASE = 3; // dial 0 == 0.03 relief base diff --git a/package-lock.json b/package-lock.json index e0a3cd7..a6de9dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meatslicer", - "version": "0.1.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meatslicer", - "version": "0.1.0", + "version": "0.5.0", "devDependencies": { "electron": "^40.10.2", "electron-builder": "^25.1.8" diff --git a/test/smoke.js b/test/smoke.js index a0408d0..2b25494 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -324,6 +324,30 @@ console.log('== upgrade card click does not fire =='); check('card click fires no bullet (mdown not carried into play)', ctx.G.bullets.length === 0); } +console.log('== dropped weapon lockout =='); +{ + ctx.startRun(); step(3, 16); // clean, armed player in play mode + const p = ctx.G.player; + p.weapon = { id: 'cleaver', ammo: 60 }; p.holstered = null; + ctx.G.pickups.length = 0; + ctx.spawnPickup('weapon', p.x, p.y, { wid: 'repeater', ammo: 30 }); + ctx.updatePickups(0.016); ctx.updatePickups(0.016); // pick up the repeater + check('weapon swap happens instantly on fresh loot', p.weapon.id === 'repeater'); + const drop = ctx.G.pickups.find(k => k.type === 'weapon'); + check('old weapon dropped with the lockout delay', !!drop && Math.abs(drop.delay - ctx.WEAPON_DROP_LOCKOUT) < 0.1); + if (drop) { drop.vx = 0; drop.vy = 0; } // hold it still for the timing checks + // standing on the drop, it must stay uncollectible for ~70% of the lockout + const blockedFor = Math.floor(ctx.WEAPON_DROP_LOCKOUT * 0.7 / 0.016); + for (let i = 0; i < blockedFor; i++) { p.x = drop.x; p.y = drop.y; ctx.updatePickups(0.016); } + const stillBlocked = ctx.G.pickups.some(k => k.type === 'weapon') && p.weapon.id === 'repeater'; + check('dropped weapon stays un-pickable inside the lockout window', stillBlocked); + // the remaining ~30% (plus margin) lets it be re-picked + const collectFor = Math.ceil(ctx.WEAPON_DROP_LOCKOUT / 0.016) + 10; + for (let i = 0; i < collectFor; i++) { p.x = drop.x; p.y = drop.y; ctx.updatePickups(0.016); } + check('dropped weapon re-pickable after the lockout', p.weapon.id === 'cleaver'); + check('lockout constant is within the 2-3s request', ctx.WEAPON_DROP_LOCKOUT >= 2 && ctx.WEAPON_DROP_LOCKOUT <= 3); +} + autoDraft = true; // later sections must not get stuck in the perk draft console.log('== shield heart perk =='); From 39d89fbc53c920f3c37b70905b309d61e7630a81 Mon Sep 17 00:00:00 2001 From: Quintine Date: Sun, 9 Aug 2026 13:56:43 +1000 Subject: [PATCH 3/4] build: add Linux AppImage target and dist:linux script --- electron-builder.yml | 5 +++++ package.json | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/electron-builder.yml b/electron-builder.yml index 8d56e6b..6abcacc 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -15,3 +15,8 @@ win: target: - zip icon: build/icon.png +linux: + target: + - AppImage + icon: build/icon.png + category: Game diff --git a/package.json b/package.json index 835ccca..58ce7d3 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,14 @@ "scripts": { "start": "electron .", "dist": "electron-builder --win zip", + "dist:linux": "electron-builder --linux --publish never", "test": "node test/smoke.js" }, "devDependencies": { "electron": "^40.10.2", "electron-builder": "^25.1.8" + }, + "allowScripts": { + "electron@40.10.2": true } } From f77b4fd402d1cc7999e0ed3daa8e5db2790cc7dc Mon Sep 17 00:00:00 2001 From: Quintine Date: Sun, 9 Aug 2026 14:22:26 +1000 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20crimson=20metronome=20lends=20?= =?UTF-8?q?=C2=BD=20heart,=20repaid=20on=20a=20clean=20room=20clear?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 44 ++++++++++++++++++++++---------------------- js/items.js | 2 +- js/player.js | 11 +++++++++-- js/rooms.js | 8 ++++++++ test/smoke.js | 28 ++++++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 25 deletions(-) diff --git a/index.html b/index.html index 546184a..d78079a 100644 --- a/index.html +++ b/index.html @@ -6,32 +6,32 @@ MEATSLICER - +
- - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/js/items.js b/js/items.js index 4c6f3e2..3ce01d6 100644 --- a/js/items.js +++ b/js/items.js @@ -94,7 +94,7 @@ const ITEMS = { butchersoath: { name: "Butcher's Oath", desc: '+80% damage, but max HP set to 2', rarity: 'legendary', cap: 1, apply: (s, p) => { s.dmgMul *= 1.8; s.maxHp = Math.min(s.maxHp, 2); p.hp = Math.min(p.hp, s.maxHp); } }, secondskin: { name: 'Second Skin', desc: 'Revive once per floor at ½ heart', rarity: 'legendary', cap: 1, apply: s => { s.secondSkin += 1; } }, twinsidearm: { name: 'Twin Sidearm', desc: 'Bone Popper double-taps; specials burn 2× ammo', rarity: 'legendary', cap: 1, apply: s => { s.twinSidearm += 1; } }, - crimsonmetronome:{ name: 'Crimson Metronome', desc: '+60% fire rate; every 8th shot costs ½ heart', rarity: 'legendary', cap: 1, apply: s => { s.rateMul *= 1.6; s.crimsonMetronome += 1; } }, + crimsonmetronome:{ name: 'Crimson Metronome', desc: '+60% fire rate; every 8th shot lends ½ heart, repaid if you clear the room clean', rarity: 'legendary', cap: 1, apply: s => { s.rateMul *= 1.6; s.crimsonMetronome += 1; } }, abattoirengine:{ name: 'Abattoir Engine', desc: 'Pressure rises 2× faster; score and luck ×1.5', rarity: 'legendary', cap: 1, apply: s => { s.abattoirEngine += 1; s.luck += 0.5; } }, gorecrown: { name: 'Gore Crown', desc: 'Free nova on every kill, −25% damage', rarity: 'legendary', cap: 1, apply: s => { s.goreCrown += 1; s.dmgMul *= 0.75; } }, thousandteeth: { name: 'Thousand Teeth', desc: '+6 shards that can crit, −40% base bullet damage', rarity: 'legendary', cap: 1, apply: s => { s.thousandTeeth += 1; s.dmgMul *= 0.6; } }, diff --git a/js/player.js b/js/player.js index 39f89f7..1ca708a 100644 --- a/js/player.js +++ b/js/player.js @@ -31,6 +31,7 @@ function initPlayer() { animT: 0, attackT: 0, actionT: 0, hitT: 0, deathT: 0, weapon: { id: 'bonepopper', ammo: Infinity }, holstered: null, // a special weapon set aside while the pistol is out + metronomeTmp: 0, // ½-hearts lent by Crimson Metronome, repaid on a clean clear level: 1, xp: 0, items: {}, // iid -> quality tier (1..3) perks: [], // perk ids drafted this run, in grant order @@ -155,10 +156,16 @@ function updatePlayer(dt) { p.choirCount = (p.choirCount || 0) + 1; if (p.choirCount >= 4) { p.choirCount = 0; fireWeapon(p, w); } } - // Crimson Metronome: every 8th shot costs ½ heart + // Crimson Metronome: every 8th shot lends ½ heart, repaid when the room clears clean if (st.crimsonMetronome > 0) { p.metronomeCount = (p.metronomeCount || 0) + 1; - if (p.metronomeCount >= 8) { p.metronomeCount = 0; p.hp = Math.max(0.5, p.hp - 1); spawnText(p.x, p.y - 14, '-½', '#e2472f'); } + if (p.metronomeCount >= 8) { + p.metronomeCount = 0; + const loss = Math.min(1, Math.max(0, p.hp - 0.5)); + p.hp = Math.max(0.5, p.hp - 1); + p.metronomeTmp += loss; // on loan: restored by a clean room clear + spawnText(p.x, p.y - 14, '-½', '#e2472f'); + } } p.recoil = Math.min(1, p.recoil + (w.behavior === 'slam' ? 1 : 0.45)); p.muzzleT = 0.07; diff --git a/js/rooms.js b/js/rooms.js index cd2b544..6771206 100644 --- a/js/rooms.js +++ b/js/rooms.js @@ -88,6 +88,7 @@ function enterRoom(gx, gy) { G.roomLayerKey = ''; G.roomDamaged = false; G.roomEnterT = G.time; + if (G.player) G.player.metronomeTmp = 0; // fresh per-room Crimson-Metronome loan ledger const room = G.rooms[roomKey(gx, gy)]; G.cur = room; @@ -221,6 +222,13 @@ function recordRoomClear(room) { } // active items charge on room clears: +1 per combat room, +2 per boss const p = G.player; + // Crimson Metronome: a ½-heart loan is repaid when the room clears clean + if (!G.roomDamaged && p.metronomeTmp > 0) { + const back = Math.min(p.metronomeTmp, p.stats.maxHp - p.hp); + p.hp += back; + p.metronomeTmp = 0; + if (back > 0) spawnText(p.x, p.y - 26, 'LOAN REPAID +' + back, '#d98991'); + } if (p && p.active) { const a = ACTIVES[p.active.iid]; if (a) { diff --git a/test/smoke.js b/test/smoke.js index 2b25494..22566e2 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -1810,5 +1810,33 @@ check('debug console closes back to play', ctx.G.mode === 'play'); ctx.G.mode = 'play'; })(); +console.log('== crimson metronome heart loan =='); +{ + ctx.startRun(); step(3, 16); // self-contained: fresh player, empty arena + ctx.G.enemies.length = 0; + const p = ctx.G.player; + ctx.giveItem('crimsonmetronome'); + check('metronome grants the loan flag', p.stats.crimsonMetronome === 1); + p.weapon = { id: 'repeater', ammo: 500 }; p.holstered = null; // per-shot trigger + p.hp = 6; p.metronomeCount = 0; p.metronomeTmp = 0; + const hpBefore = p.hp; + ctx.Input.mdown = true; + let guard = 0; + while (p.metronomeTmp === 0 && guard++ < 4000) step(1, 16); + ctx.Input.mdown = false; + check('8th shot lends ½ heart (not lost)', p.hp === hpBefore - 1 && p.metronomeTmp === 1); + ctx.G.roomDamaged = false; + ctx.recordRoomClear({ type: 'combat' }); + check('clean room clear repays the loan', p.hp === hpBefore && p.metronomeTmp === 0); + ctx.Input.mdown = true; + guard = 0; + while (p.metronomeTmp === 0 && guard++ < 4000) step(1, 16); + ctx.Input.mdown = false; + check('loan accrues again', p.hp === hpBefore - 1 && p.metronomeTmp === 1); + ctx.G.roomDamaged = true; + ctx.recordRoomClear({ type: 'combat' }); + check('hit room forfeits the loan', p.hp === hpBefore - 1 && p.metronomeTmp === 1); +} + console.log(failures === 0 ? '\nALL CHECKS PASSED' : '\n' + failures + ' CHECKS FAILED'); process.exit(failures === 0 ? 0 : 1);