Seal boss stairs after victory

This commit is contained in:
MeatSlicer Developer committed 2026-07-30 23:37:26 +10:00
1 parent d96d492a43
commit 92491dd06e
9 files changed
+55 -15

No files matched your search

+1
View File
@@ -41,6 +41,7 @@ Killing a boss grants **500 + floor × 100** score, clears all hostile bullets/t
- **1 item pedestal** (left of room center)
- **1 weapon drop** rolled at **floor + 2** (right of center)
- **1 ammo pickup** (above center) — the boss room also stocks one when you walk in, so you can afford to spend the magazine on the fight
- **1 sealed stair hatch** (below center) — it remains closed and uncollectable for 3 seconds after the kill so the player cannot fall through it accidentally
- **Stairs Down** (below center)
> *"[NAME] DESTROYED — take the meat, take the stairs"*
+1 -1
View File
@@ -202,7 +202,7 @@ Clearing a combat room drops:
|---|---|
| Normal enemy | XP gems (enemy's XP value) · 5.25% + luck×3% ammo · 2.2% + luck×3% heart |
| Elite enemy | 3× XP + 3–5 bonus gems · 18% + luck×10% **item** · else 45% + luck×15% ammo |
| Boss | 1 item pedestal + 1 weapon (rolled at floor + 2) + 1 guaranteed ammo + stairs · the boss room also stocks 1 ammo when you enter |
| Boss | 1 item pedestal + 1 weapon (rolled at floor + 2) + 1 guaranteed ammo + stairs · the boss room also stocks 1 ammo when you enter · the stair hatch stays sealed for 3 seconds after the kill |
**Luck** is a player stat (Lucky Coin, Crow Bait, Scavenger perk) that scales most drop chances as shown above.
+1 -1
View File
@@ -599,7 +599,7 @@ function onBossDeath(e) {
if (chance(0.30)) spawnActivePedestal(G.arena.cx - 160, G.arena.cy + 60);
const bw = rollWeaponDrop(G.floor + 2); // bosses drop the good stuff
spawnPickup('weapon', G.arena.cx + 90, G.arena.cy, { wid: bw.id });
spawnPickup('stairs', G.arena.cx, G.arena.cy + 80);
spawnPickup('stairs', G.arena.cx, G.arena.cy + 80, { delay: 3 });
addToast(e.name + ' DESTROYED', 'take the meat, take the stairs');
Music.requestFloorMusic();
}
+1
View File
@@ -521,6 +521,7 @@ function updateEnemyAI(e, dt) {
e.vx += Math.cos(e.faceDir) * spd * 6 * dt; e.vy += Math.sin(e.faceDir) * spd * 6 * dt;
if (e.lungeT <= 0) { e.ambushState = 'stalk'; e.ambushT = rand(2.2, 3.4); }
} else {
e.faceDir = angleLerp(e.faceDir, a, clamp(dt * 8, 0, 1));
e.vx += Math.cos(a) * spd * 2.7 * dt; e.vy += Math.sin(a) * spd * 2.7 * dt;
e.ambushT -= dt;
if (e.ambushT <= 0) {
+6 -2
View File
@@ -250,9 +250,13 @@ function drawHelpBestiary(ctx, px, py) {
let ty = py + HELP_BODY;
ctx.textAlign = 'left';
ctx.fillStyle = '#d7a934'; ctx.font = hfont(9, true);
ctx.fillText('6 ENEMY TYPES · ELITES: ×2.6 HP, +1 DMG, 3× XP, GOLD RING, ITEM DROPS', x, ty);
ctx.fillText(Object.keys(ENEMY_TYPES).length + ' ENEMY TYPES · ELITES: ×2.6 HP, +1 DMG, 3× XP, GOLD RING, ITEM DROPS', x, ty);
ty += 20;
const notes = { shambler: 'CHASER', runner: 'FAST CHASER', spitter: 'RANGED KITER', splitter: 'SPLITS INTO 2 MINIS', mini: 'SWARMER', exploder: 'FUSED SUICIDE ×2 DMG' };
const notes = {
shambler: 'CHASER', runner: 'FAST CHASER', spitter: 'RANGED KITER', splitter: 'SPLITS INTO 2 MINIS',
mini: 'SWARMER', exploder: 'FUSED SUICIDE ×2 DMG', censer: 'HOSTILE ACID ZONES', bulwark: 'FLANK ITS FRONT ARMOR',
choirmaster: 'SPEED + REPAIR AURA', flenserling: 'PHASE AMBUSHER', broodsac: 'SPAWNS MINIS',
};
ctx.font = hfont(8, false);
let row = 0;
for (const [type, t] of Object.entries(ENEMY_TYPES)) {
+1
View File
@@ -98,6 +98,7 @@ function resetRun() {
G.parts = []; G.corpses = []; G.toasts = []; G.pendingLevelups = 0; G.perkChoices = null;
G.pauseHelp = false; G.helpPage = 0;
G.confirmAction = null; G.confirmT = 0;
G.deathLockT = 0;
G.pressure = 1; G.streak = 0; G.roomDamaged = false; G.roomEnterT = 0; G.recentHits = [];
G.shake = 0; G.flash = 0;
initPlayer();
+3 -3
View File
@@ -13,7 +13,7 @@ function addParticle(p, important) {
return true;
}
function spawnBlood(x, y, ang, n, big) {
function spawnBlood(x, y, ang, n, big, important) {
for (let i = 0; i < n; i++) {
const a = ang + rand(-0.9, 0.9);
const sp = rand(40, big ? 260 : 160);
@@ -23,7 +23,7 @@ function spawnBlood(x, y, ang, n, big) {
life: rand(0.3, 0.7), t: 0,
r: rand(1.5, big ? 4.5 : 3),
mist: i % 4 === 0, shade: i % 3,
});
}, important);
}
// persistent floor decal
if (G.cur && chance(0.55)) {
@@ -147,7 +147,7 @@ function spawnExplosionFx(x, y, r) {
function spawnPlayerGore(x, y) {
spawnExplosionFx(x, y, 110);
spawnShockwave(x, y, 138, '#ff2947', 0.95, true);
for (let i = 0; i < 4; i++) spawnBlood(x, y, i * TAU / 4, 10, true);
for (let i = 0; i < 4; i++) spawnBlood(x, y, i * TAU / 4, 10, true, true);
spawnGibs(x, y, 26, true, true);
for (let i = 0; i < 18; i++) {
const a = rand(0, TAU), sp = rand(90, 350);
+19 -8
View File
@@ -215,13 +215,24 @@ function drawPickups(ctx) {
ctx.restore();
Sprites.draw(ctx, 'a_' + k.aid, k.x, k.y - 28 + bob, 0, 64);
} else if (k.type === 'stairs') {
Sprites.draw(ctx, 'stairs', k.x, k.y, 0, 64);
ctx.save();
ctx.globalAlpha = 0.5 + Math.sin(k.t * 3) * 0.25;
ctx.strokeStyle = '#d8b84e'; ctx.lineWidth = 2;
const inset = 3 + Math.sin(k.t * 3) * 2;
ctx.strokeRect(k.x - 31 + inset, k.y - 31 + inset, 62 - inset * 2, 62 - inset * 2);
ctx.restore();
if (k.delay > 0) {
ctx.save(); ctx.translate(k.x, k.y);
ctx.fillStyle = '#171116'; ctx.fillRect(-31, -31, 62, 62);
ctx.strokeStyle = '#75525a'; ctx.lineWidth = 3; ctx.strokeRect(-29, -29, 58, 58);
ctx.fillStyle = '#38242a';
for (let sy = -20; sy <= 20; sy += 10) ctx.fillRect(-25, sy, 50, 5);
ctx.fillStyle = '#d8b84e'; ctx.font = 'bold 8px monospace'; ctx.textAlign = 'center';
ctx.fillText('SEALED ' + k.delay.toFixed(1), 0, 3);
ctx.restore();
} else {
Sprites.draw(ctx, 'stairs', k.x, k.y, 0, 64);
ctx.save();
ctx.globalAlpha = 0.5 + Math.sin(k.t * 3) * 0.25;
ctx.strokeStyle = '#d8b84e'; ctx.lineWidth = 2;
const inset = 3 + Math.sin(k.t * 3) * 2;
ctx.strokeRect(k.x - 31 + inset, k.y - 31 + inset, 62 - inset * 2, 62 - inset * 2);
ctx.restore();
}
}
// hover nameplates when close
@@ -229,7 +240,7 @@ function drawPickups(ctx) {
if (k.type === 'weapon') drawPickupLabel(ctx, WEAPONS[k.wid].name, k.x, k.y - 44, '#ffd060');
else if (k.type === 'item') drawPickupLabel(ctx, ITEMS[k.iid].name, k.x, k.y - 66, ITEM_RARITY[ITEMS[k.iid].rarity].color);
else if (k.type === 'active') drawPickupLabel(ctx, ACTIVES[k.aid].name, k.x, k.y - 66, '#55f5dc');
else if (k.type === 'stairs') drawPickupLabel(ctx, 'STAIRS DOWN', k.x, k.y - 52, '#e8e0d0');
else if (k.type === 'stairs') drawPickupLabel(ctx, k.delay > 0 ? 'HATCH SEALED' : 'STAIRS DOWN', k.x, k.y - 52, k.delay > 0 ? '#b89c46' : '#e8e0d0');
else if (k.type === 'ammo') drawPickupLabel(ctx, 'AMMO', k.x, k.y - 26, '#d0b060');
}
}
+22
View File
@@ -1346,6 +1346,7 @@ for (let g2 = 0; g2 < 400 && ctx.G.boss; g2++) {
check('boss dead', !ctx.G.boss);
check('boss death clears remaining minions', ctx.G.enemies.length === 0);
check('stairs spawned', ctx.G.pickups.some(p => p.type === 'stairs') || ctx.G.mode !== 'play');
check('boss stairs begin with a three-second safety seal', ctx.G.pickups.some(p => p.type === 'stairs' && p.delay > 0 && p.delay <= 3));
check('boss drops a weapon', ctx.G.pickups.some(p => p.type === 'weapon'));
check('boss reward pedestal', ctx.G.pickups.some(p => p.type === 'item'));
@@ -1370,6 +1371,8 @@ const stairs = ctx.G.pickups.find(p => p.type === 'stairs');
if (stairs) {
ctx.G.player.x = stairs.x; ctx.G.player.y = stairs.y;
step(5, 16);
check('sealed boss stairs cannot advance the floor', ctx.G.floor === 2);
step(190, 16);
check('floor advanced', ctx.G.floor === 3);
}
@@ -1577,6 +1580,25 @@ tap('r');
step(5, 16);
check('restart works', ctx.G.mode === 'play' && ctx.G.floor === 1);
console.log('== confirmed destructive actions ==');
{
let fired = 0;
ctx.confirmAction('probe', () => fired++);
check('first destructive action arms confirmation without firing', ctx.G.confirmAction === 'probe' && ctx.G.confirmT > 2.9 && fired === 0);
ctx.confirmAction('probe', () => fired++);
check('second matching action confirms and clears state', fired === 1 && ctx.G.confirmAction === null && ctx.G.confirmT === 0);
ctx.confirmAction('expire', () => fired++);
ctx.G.mode = 'inspect';
ctx.update(3.1);
check('confirmation expires without firing', fired === 1 && ctx.G.confirmAction === null && ctx.G.confirmT === 0);
ctx.startRun(); step(3, 16);
ctx.G.score = 999; ctx.G.mode = 'pause'; ctx.G.deathLockT = 2;
ctx.confirmAction('menu', ctx.returnToMenu);
ctx.confirmAction('menu', ctx.returnToMenu);
check('confirmed return to menu fully resets the abandoned run', ctx.G.mode === 'menu' && ctx.G.score === 0 && ctx.G.deathLockT === 0);
ctx.startRun(); step(3, 16);
}
console.log('== pause / mute ==');
tap('p');
check('paused', ctx.G.mode === 'pause');