Merge commit integrating big-rooms + linux-and-qol (keep both features)

This commit is contained in:
Quintine committed 2026-08-11 17:35:02 +10:00
commit d9a921a1ea
9 files changed
+103 -5

No files matched your search

+5
View File
@@ -15,3 +15,8 @@ win:
target: target:
- zip - zip
icon: build/icon.png icon: build/icon.png
linux:
target:
- AppImage
icon: build/icon.png
category: Game
+1 -1
View File
@@ -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); } }, 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; } }, 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; } }, 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; } }, 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; } }, 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; } }, 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; } },
+4
View File
@@ -221,6 +221,10 @@ function update(dt) {
break; break;
case 'levelup': case 'levelup':
updateLevelup(dt); updateLevelup(dt);
// 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); updateParticles(dt);
break; break;
case 'debug': case 'debug':
+1 -1
View File
@@ -129,7 +129,7 @@ function collectPickup(k) {
if (old) { if (old) {
const a = rand(0, TAU); const a = rand(0, TAU);
spawnPickup('weapon', p.x + Math.cos(a) * 34, p.y + Math.sin(a) * 34, 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.weapon = { id: w.id, ammo: k.ammo !== undefined ? k.ammo : w.ammo };
p.holstered = null; p.holstered = null;
+9 -2
View File
@@ -32,6 +32,7 @@ function initPlayer() {
animT: 0, attackT: 0, actionT: 0, hitT: 0, deathT: 0, animT: 0, attackT: 0, actionT: 0, hitT: 0, deathT: 0,
weapon: { id: 'bonepopper', ammo: Infinity }, weapon: { id: 'bonepopper', ammo: Infinity },
holstered: null, // a special weapon set aside while the pistol is out 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, level: 1, xp: 0,
items: {}, // iid -> quality tier (1..3) items: {}, // iid -> quality tier (1..3)
perks: [], // perk ids drafted this run, in grant order perks: [], // perk ids drafted this run, in grant order
@@ -156,10 +157,16 @@ function updatePlayer(dt) {
p.choirCount = (p.choirCount || 0) + 1; p.choirCount = (p.choirCount || 0) + 1;
if (p.choirCount >= 4) { p.choirCount = 0; fireWeapon(p, w); } 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) { if (st.crimsonMetronome > 0) {
p.metronomeCount = (p.metronomeCount || 0) + 1; 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.recoil = Math.min(1, p.recoil + (w.behavior === 'slam' ? 1 : 0.45));
p.muzzleT = 0.07; p.muzzleT = 0.07;
+8
View File
@@ -97,6 +97,7 @@ function enterRoom(gx, gy) {
G.roomDamaged = false; G.roomDamaged = false;
G.roomEnterT = G.time; G.roomEnterT = G.time;
G.entryFresh = false; G.entryFresh = false;
if (G.player) G.player.metronomeTmp = 0; // fresh per-room Crimson-Metronome loan ledger
const room = G.rooms[roomKey(gx, gy)]; const room = G.rooms[roomKey(gx, gy)];
G.cur = room; G.cur = room;
@@ -260,6 +261,13 @@ function recordRoomClear(room) {
} }
// active items charge on room clears: +1 per combat room, +2 per boss // active items charge on room clears: +1 per combat room, +2 per boss
const p = G.player; 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) { if (p && p.active) {
const a = ACTIVES[p.active.iid]; const a = ACTIVES[p.active.iid];
if (a) { if (a) {
+2
View File
@@ -61,6 +61,8 @@ const PERK_POWER_WEIGHT = 0.5;
const AMMO_DROP_SCALE = 0.75; const AMMO_DROP_SCALE = 0.75;
const STUN_UNIT = 0.03 / 0.97; // Sine Weave raw rating per stack, so the first stack = 3% stagger chance const STUN_UNIT = 0.03 / 0.97; // Sine Weave raw rating per stack, so the first stack = 3% stagger chance
const PRESSURE_MIN = 0.60, PRESSURE_MAX = 2.00; 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_UNIT = 0.01; // one "point" on the dial scale
const PRESSURE_GAIN = PRESSURE_UNIT; // retained: dial 0 == 1 point const PRESSURE_GAIN = PRESSURE_UNIT; // retained: dial 0 == 1 point
const PRESSURE_DROP_BASE = 3; // dial 0 == 0.03 relief base const PRESSURE_DROP_BASE = 3; // dial 0 == 0.03 relief base
+4
View File
@@ -8,10 +8,14 @@
"scripts": { "scripts": {
"start": "electron .", "start": "electron .",
"dist": "electron-builder --win zip", "dist": "electron-builder --win zip",
"dist:linux": "electron-builder --linux --publish never",
"test": "node test/smoke.js" "test": "node test/smoke.js"
}, },
"devDependencies": { "devDependencies": {
"electron": "^40.10.2", "electron": "^40.10.2",
"electron-builder": "^25.1.8" "electron-builder": "^25.1.8"
},
"allowScripts": {
"electron@40.10.2": true
} }
} }
+69 -1
View File
@@ -61,7 +61,7 @@ for (const f of files) {
} }
// const/let top-level bindings live in the context's lexical scope, not on the // 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. // 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, ENTRY_WARN, ENTRY_BUFFER, STUN_UNIT, stunChance, separateEntryWave, defaultPlayerStats, applyPressureDelta, debugRebuildStats, debugEnabled, DEBUG_PAGES, updateCamera, mxW, myW, angleTo, genFloor, roomBounds })', 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, ROOM_SHAPES, ROOM_THEMES, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN, ENTRY_WARN, ENTRY_BUFFER, STUN_UNIT, stunChance, separateEntryWave, defaultPlayerStats, applyPressureDelta, debugRebuildStats, debugEnabled, DEBUG_PAGES, updateCamera, mxW, myW, angleTo, genFloor, roomBounds, WEAPON_DROP_LOCKOUT })', sandbox);
let simTime = 0; let simTime = 0;
const ctx = sandbox; const ctx = sandbox;
@@ -372,6 +372,46 @@ console.log('== rebalanced perks + automatic draft ==');
ctx.G.autoPerk = false; ctx.G.autoPerk = false;
ctx.startRun(); step(3, 16); 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);
}
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 autoDraft = true; // later sections must not get stuck in the perk draft
console.log('== nearby-item description toast =='); console.log('== nearby-item description toast ==');
@@ -1889,5 +1929,33 @@ check('debug console closes back to play', ctx.G.mode === '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'); console.log(failures === 0 ? '\nALL CHECKS PASSED' : '\n' + failures + ' CHECKS FAILED');
process.exit(failures === 0 ? 0 : 1); process.exit(failures === 0 ? 0 : 1);