diff --git a/docs/bosses.md b/docs/bosses.md index 0772fbf..2d9a4fe 100644 --- a/docs/bosses.md +++ b/docs/bosses.md @@ -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"* diff --git a/docs/gameplay.md b/docs/gameplay.md index ab06449..5abfa44 100644 --- a/docs/gameplay.md +++ b/docs/gameplay.md @@ -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. diff --git a/js/bosses.js b/js/bosses.js index ca8ed25..7905669 100644 --- a/js/bosses.js +++ b/js/bosses.js @@ -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(); } diff --git a/js/enemies.js b/js/enemies.js index 5b3f661..eae2c12 100644 --- a/js/enemies.js +++ b/js/enemies.js @@ -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) { diff --git a/js/help.js b/js/help.js index 1d05398..7b9c699 100644 --- a/js/help.js +++ b/js/help.js @@ -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)) { diff --git a/js/main.js b/js/main.js index 61c2b62..b44d671 100644 --- a/js/main.js +++ b/js/main.js @@ -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(); diff --git a/js/particles.js b/js/particles.js index 51c88c6..b7cf972 100644 --- a/js/particles.js +++ b/js/particles.js @@ -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); diff --git a/js/pickups.js b/js/pickups.js index d409cd7..17abca1 100644 --- a/js/pickups.js +++ b/js/pickups.js @@ -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'); } } diff --git a/test/smoke.js b/test/smoke.js index 217982e..7f3e72d 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -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');