Phase 5: active items — room-clear charged, dedicated pedestals, Space to use
- ACTIVES table with 10 actives (Bone Nova, Offal Bomb, Blood Transfusion, Cleaver Storm, Butcher's Bell, Marrow Draught, Slaughter Time, Panic Room, Skinner's Coin, Gut Reroll); charges accrue +1/combat room, +2/boss - Dedicated 'active' pickup with weapon-style swap-drop; 30% boss / 15% item-room bonus pedestal spawns; flat +2 powerScore while held - HUD active slot with charge pips + READY state; ACTIVES help page (12 pages); teal medallion frame variant in draw_sprites.py; 10 generated icons - Procedural Sfx.active()/activeEmpty() (real clips deferred to audio batch) - Fix: toast panel width now fits the wider of title/subtitle and clamps to screen - Bump asset cache to v=40
No files matched your search
|
After Width: | Height: | Size: 750 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -235,6 +235,25 @@ All of these trigger from your weapon hits (orbital knives proc at half chance).
|
||||
| 81 | Thousand Teeth | Legendary | I | Shot modifier |
|
||||
| 82 | Twin Sidearm | Legendary | I | Shot modifier |
|
||||
|
||||
## Active items
|
||||
|
||||
Actives live in their own slot (separate from the passive tray) and are **charged by clearing rooms**: **+1 charge per combat room, +2 per boss**. Using an active spends all charges. Picking up a new active drops the old one with its remaining charges. Sources: 30% chance of a bonus pedestal on boss kills, 15% in item rooms. Use with **SPACE**.
|
||||
|
||||
| Active | Cost | Effect |
|
||||
|---|---|---|
|
||||
| Bone Nova | 2 | Damage ring + hard knockback around you |
|
||||
| Offal Bomb | 1 | Lobs a gore bomb at the cursor |
|
||||
| Blood Transfusion | 2 | Heal 2 hearts, lose 25% of current XP |
|
||||
| Cleaver Storm | 2 | 12 orbiting cleavers shred on contact for 6 s |
|
||||
| Butcher's Bell | 2 | Pull every enemy in and stun for 1 s |
|
||||
| Marrow Draught | 3 | +100% fire rate and free ammo for 5 s |
|
||||
| Slaughter Time | 3 | All enemies slowed to 25% speed for 5 s |
|
||||
| Panic Room | 3 | 2.5 s invulnerable — but you can't fire |
|
||||
| Skinner's Coin | 3 | Clear every enemy bullet into XP gems |
|
||||
| Gut Reroll | 1 | Reroll the item pedestal you're standing on |
|
||||
|
||||
Holding an active contributes a flat **+2** to power score.
|
||||
|
||||
## Synergy notes
|
||||
|
||||
- **Second Stomach** turns *every* healing source into shield generation: Worm Gut, Bone Knit, Vampire Dentures, Hemophage, and hearts at full HP.
|
||||
|
||||
@@ -6,31 +6,31 @@
|
||||
<meta name="theme-color" content="#0a0305">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><text y=%2252%22 x=%2250%22 text-anchor=%22middle%22 dominant-baseline=%22middle%22 font-size=%2252%22>🔪</text></svg>">
|
||||
<title>MEATSLICER</title>
|
||||
<link rel="stylesheet" href="css/style.css?v=39">
|
||||
<link rel="stylesheet" href="css/style.css?v=40">
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrap">
|
||||
<canvas id="game" width="960" height="640"></canvas>
|
||||
</div>
|
||||
<script src="js/utils.js?v=39"></script>
|
||||
<script src="js/state.js?v=39"></script>
|
||||
<script src="js/input.js?v=39"></script>
|
||||
<script src="js/sfx.js?v=39"></script>
|
||||
<script src="js/sfxbank.js?v=39"></script>
|
||||
<script src="js/audio.js?v=39"></script>
|
||||
<script src="js/sprites.js?v=39"></script>
|
||||
<script src="js/particles.js?v=39"></script>
|
||||
<script src="js/weapons.js?v=39"></script>
|
||||
<script src="js/bullets.js?v=39"></script>
|
||||
<script src="js/pickups.js?v=39"></script>
|
||||
<script src="js/items.js?v=39"></script>
|
||||
<script src="js/perks.js?v=39"></script>
|
||||
<script src="js/enemies.js?v=39"></script>
|
||||
<script src="js/bosses.js?v=39"></script>
|
||||
<script src="js/rooms.js?v=39"></script>
|
||||
<script src="js/player.js?v=39"></script>
|
||||
<script src="js/hud.js?v=39"></script>
|
||||
<script src="js/help.js?v=39"></script>
|
||||
<script src="js/main.js?v=39"></script>
|
||||
<script src="js/utils.js?v=40"></script>
|
||||
<script src="js/state.js?v=40"></script>
|
||||
<script src="js/input.js?v=40"></script>
|
||||
<script src="js/sfx.js?v=40"></script>
|
||||
<script src="js/sfxbank.js?v=40"></script>
|
||||
<script src="js/audio.js?v=40"></script>
|
||||
<script src="js/sprites.js?v=40"></script>
|
||||
<script src="js/particles.js?v=40"></script>
|
||||
<script src="js/weapons.js?v=40"></script>
|
||||
<script src="js/bullets.js?v=40"></script>
|
||||
<script src="js/pickups.js?v=40"></script>
|
||||
<script src="js/items.js?v=40"></script>
|
||||
<script src="js/perks.js?v=40"></script>
|
||||
<script src="js/enemies.js?v=40"></script>
|
||||
<script src="js/bosses.js?v=40"></script>
|
||||
<script src="js/rooms.js?v=40"></script>
|
||||
<script src="js/player.js?v=40"></script>
|
||||
<script src="js/hud.js?v=40"></script>
|
||||
<script src="js/help.js?v=40"></script>
|
||||
<script src="js/main.js?v=40"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -594,6 +594,8 @@ function onBossDeath(e) {
|
||||
addShake(12);
|
||||
// boss reward: item pedestal + a real weapon drop + stairs down
|
||||
spawnItemPedestal(W / 2 - 90, H / 2, null, 'boss');
|
||||
// 30% chance of a bonus active pedestal (additive — never replaces the passive)
|
||||
if (chance(0.30)) spawnActivePedestal(W / 2 - 160, H / 2 + 60);
|
||||
const bw = rollWeaponDrop(G.floor + 2); // bosses drop the good stuff
|
||||
spawnPickup('weapon', W / 2 + 90, H / 2, { wid: bw.id });
|
||||
spawnPickup('stairs', W / 2, H / 2 + 80);
|
||||
|
||||
@@ -14,6 +14,7 @@ const HELP_PAGES = [
|
||||
{ title: 'ARSENAL' },
|
||||
{ title: 'MUTATIONS' },
|
||||
...Array.from({ length: HELP_ITEM_PAGE_COUNT }, (_, i) => ({ title: 'IMPLANTS ' + ['I', 'II', 'III', 'IV', 'V', 'VI'][i] || ('P' + (i + 1)) })),
|
||||
{ title: 'ACTIVES' },
|
||||
{ title: 'BESTIARY' },
|
||||
{ title: 'PRESSURE' },
|
||||
];
|
||||
@@ -86,6 +87,7 @@ function drawHelpControls(ctx, px, py) {
|
||||
{ head: 'IN THE FIELD', lines: [
|
||||
'WASD / ARROWS — MOVE MOUSE — AIM LMB (HOLD) — FIRE',
|
||||
'LMB RELEASE — FIRE CHARGED BEAM (SPINAL TAP ONLY)',
|
||||
'SPACE — USE ACTIVE ITEM (CHARGED BY CLEARING ROOMS)',
|
||||
'R — SWAP BONE POPPER ↔ HOLSTERED SPECIAL (EMPTY SPECIALS LOCKED)',
|
||||
'P / ESC — PAUSE · M — MUTE · N — NEXT TRACK · T — AUTO-DRAFT',
|
||||
'DESKTOP APP: F11 — FULLSCREEN · F12 — DEVTOOLS · CTRL+R — RELOAD',
|
||||
@@ -216,6 +218,31 @@ function drawHelpItemList(ctx, px, py, page) {
|
||||
|
||||
const HELP_ITEM_RENDERERS = Array.from({ length: HELP_ITEM_PAGE_COUNT }, (_, i) => (c, x, y) => drawHelpItemList(c, x, y, i));
|
||||
|
||||
function drawHelpActives(ctx, px, py) {
|
||||
const x = px + 40;
|
||||
let ty = py + HELP_BODY;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = '#d7a934'; ctx.font = hfont(9, true);
|
||||
ctx.fillText(Object.keys(ACTIVES).length + ' ACTIVE ITEMS · SPACE TO USE · CHARGED BY CLEARING ROOMS', x, ty);
|
||||
ty += 11;
|
||||
ctx.fillStyle = '#7d6a68'; ctx.font = hfont(8, false);
|
||||
ctx.fillText('+1 CHARGE PER COMBAT ROOM · +2 PER BOSS · PICKING UP A NEW ACTIVE DROPS THE OLD ONE', x, ty + 11);
|
||||
ty += 34;
|
||||
const colW = (HELP_PANEL.w - 80) / 2;
|
||||
const entries = Object.entries(ACTIVES);
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const col = Math.floor(i / 5), r = i % 5;
|
||||
const lx = x + col * colW, ly = ty + r * 62;
|
||||
const [, a] = entries[i];
|
||||
ctx.fillStyle = '#55f5dc'; ctx.font = hfont(8, true);
|
||||
ctx.fillText(a.name.toUpperCase(), lx, ly);
|
||||
ctx.fillStyle = '#5f4d4d'; ctx.font = hfont(7, true);
|
||||
ctx.fillText('COST ' + a.cost + ' CHARGE' + (a.cost > 1 ? 'S' : ''), lx, ly + 13);
|
||||
ctx.fillStyle = '#a08d84'; ctx.font = hfont(8, false);
|
||||
ctx.fillText(fit(a.desc.toUpperCase(), 30), lx + 100, ly + 13);
|
||||
}
|
||||
}
|
||||
|
||||
function drawHelpBestiary(ctx, px, py) {
|
||||
const x = px + 40;
|
||||
let ty = py + HELP_BODY;
|
||||
@@ -285,7 +312,7 @@ function drawHelpPressure(ctx, px, py) {
|
||||
], HELP_PANEL.w - 80);
|
||||
}
|
||||
|
||||
const HELP_RENDERERS = [drawHelpControls, drawHelpLoop, drawHelpArsenal, drawHelpPerkList, ...HELP_ITEM_RENDERERS, drawHelpBestiary, drawHelpPressure];
|
||||
const HELP_RENDERERS = [drawHelpControls, drawHelpLoop, drawHelpArsenal, drawHelpPerkList, ...HELP_ITEM_RENDERERS, drawHelpActives, drawHelpBestiary, drawHelpPressure];
|
||||
|
||||
function drawPauseHelp(ctx) {
|
||||
ctx.fillStyle = 'rgba(4,2,3,0.84)';
|
||||
|
||||
@@ -8,7 +8,7 @@ function drawHUD(ctx) {
|
||||
// vitals use a 2-row grid; a second row pushes the rest of the panel down
|
||||
const totalVitalIcons = Math.ceil(p.stats.maxHp / 2) + Math.ceil(p.shieldHp / 2);
|
||||
const vOff = totalVitalIcons > 12 ? 22 : 0;
|
||||
const panelH = (p.holstered ? 140 : 112) + vOff;
|
||||
const panelH = (p.holstered ? 140 : 112) + (p.active ? 24 : 0) + vOff;
|
||||
drawPixelPanel(ctx, 8, 8, 304, panelH, {
|
||||
accent: '#cf2942', border: 'rgba(115,43,56,0.95)', blood: true,
|
||||
seed: G.floor, bloodAlpha: 0.34,
|
||||
@@ -109,6 +109,22 @@ function drawHUD(ctx) {
|
||||
ctx.fillText(holsterEmpty ? 'NEEDS AMMO' : Math.ceil(p.holstered.ammo) + ' AMMO', 289, 125 + vOff);
|
||||
}
|
||||
|
||||
// active item slot: icon, name, charge pips, READY state
|
||||
if (p.active) {
|
||||
const ad = ACTIVES[p.active.iid];
|
||||
const ay = 112 + vOff + (p.holstered ? 20 : 0);
|
||||
const ready = p.active.charges >= ad.cost;
|
||||
ctx.fillStyle = ready ? 'rgba(34,90,84,0.5)' : 'rgba(30,60,66,0.35)';
|
||||
ctx.fillRect(15, ay, 281, 20);
|
||||
Sprites.draw(ctx, 'a_' + p.active.iid, 29, ay + 10, 0, 28, false, ready ? A : 0.6 * A);
|
||||
ctx.fillStyle = ready ? '#8df0e0' : '#7d9a9c'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'left';
|
||||
ctx.fillText((ready ? '[SPACE] ' : '') + ad.name.toUpperCase(), 47, ay + 13);
|
||||
for (let i = 0; i < ad.cost; i++) {
|
||||
ctx.fillStyle = i < p.active.charges ? '#55f5dc' : 'rgba(85,245,220,0.18)';
|
||||
ctx.fillRect(289 - (ad.cost - 1 - i) * 12 - 8, ay + 6, 8, 8);
|
||||
}
|
||||
}
|
||||
|
||||
const rarityRank = { legendary: 0, rare: 1, uncommon: 2, common: 3 };
|
||||
const allItemEntries = Object.entries(p.items).sort((a, b) =>
|
||||
(rarityRank[ITEMS[a[0]].rarity] - rarityRank[ITEMS[b[0]].rarity]) || (b[1] - a[1]));
|
||||
@@ -186,7 +202,14 @@ function drawHUD(ctx) {
|
||||
const y = 22 + i * 48;
|
||||
ctx.globalAlpha = a;
|
||||
ctx.font = 'bold 13px monospace';
|
||||
const tw = Math.max(ctx.measureText(t.text).width + 48, 190);
|
||||
const titleW = ctx.measureText(t.text.toUpperCase()).width;
|
||||
let subW = 0;
|
||||
if (t.sub) {
|
||||
ctx.font = '9px monospace';
|
||||
subW = ctx.measureText(t.sub.toUpperCase()).width;
|
||||
}
|
||||
// panel must fit the wider of the two lines (and never leave the screen)
|
||||
const tw = Math.min(Math.max(Math.max(titleW, subW) + 48, 190), W - 24);
|
||||
drawPixelPanel(ctx, W / 2 - tw / 2, y, tw, t.sub ? 40 : 28, {
|
||||
cut: 5, shadow: false, accent: '#c59231', blood: true,
|
||||
seed: i + G.floor, bloodAlpha: 0.2,
|
||||
|
||||
@@ -187,3 +187,80 @@ function spawnItemPedestal(x, y, iid, source) {
|
||||
spawnPickup('itemspot', x, y);
|
||||
spawnPickup('item', x, y, { iid: iid || rollItemId(source || 'room', G.floor) });
|
||||
}
|
||||
|
||||
// ---- active items: room-clear charged, dedicated pedestal pickups ----
|
||||
// Actives live in their own table so p.items (tier math + powerScore) stays clean.
|
||||
// charges are granted by recordRoomClear: +1 per combat room, +2 per boss.
|
||||
const ACTIVES = {
|
||||
bonenova: { name: 'Bone Nova', desc: 'Damage ring + hard knockback', cost: 2, use(p) {
|
||||
areaDamage(p.x, p.y, 150, 30 * p.stats.dmgMul, true, { source: 'player' });
|
||||
for (const e of G.enemies) { const a = angleTo(p.x, p.y, e.x, e.y); e.vx += Math.cos(a) * 500; e.vy += Math.sin(a) * 500; }
|
||||
spawnShockwave(p.x, p.y, 150, '#e8dcc2', 0.8); addShake(8);
|
||||
} },
|
||||
offalbomb: { name: 'Offal Bomb', desc: 'Gore bomb at the cursor', cost: 1, use(p) {
|
||||
explodeAt(Input.mx, Input.my, 95, 25 * p.stats.dmgMul, true);
|
||||
} },
|
||||
bloodtransfusion:{ name: 'Blood Transfusion', desc: 'Heal 2 hearts, lose 25% current XP', cost: 2, use(p) {
|
||||
p.xp = Math.floor(p.xp * 0.75);
|
||||
healPlayer(4); spawnText(p.x, p.y - 16, '+2 HEARTS', '#d92038');
|
||||
} },
|
||||
cleaverstorm: { name: 'Cleaver Storm', desc: '12 orbiting cleavers for 6s', cost: 2, use(p) {
|
||||
p.cleaverStormT = 6;
|
||||
} },
|
||||
butchersbell: { name: "Butcher's Bell", desc: 'Pull every enemy in and stun 1s', cost: 2, use(p) {
|
||||
for (const e of G.enemies) {
|
||||
if (e.boss) continue;
|
||||
const a = angleTo(e.x, e.y, p.x, p.y);
|
||||
e.vx += Math.cos(a) * 700; e.vy += Math.sin(a) * 700;
|
||||
e.stunT = Math.max(e.stunT || 0, 1);
|
||||
}
|
||||
spawnShockwave(p.x, p.y, 320, '#c9a227', 0.7); addShake(6);
|
||||
} },
|
||||
marrowdraught: { name: 'Marrow Draught', desc: '+100% fire rate and free ammo for 5s', cost: 3, use(p) {
|
||||
p.marrowDraughtT = 5;
|
||||
} },
|
||||
slaughtertime: { name: 'Slaughter Time', desc: 'Enemies at 25% speed for 5s', cost: 3, use(p) {
|
||||
for (const e of G.enemies) e.slowT = Math.max(e.slowT || 0, 5);
|
||||
} },
|
||||
panicroom: { name: 'Panic Room', desc: '2.5s invulnerable, can\'t fire', cost: 3, use(p) {
|
||||
p.panicRoomT = 2.5;
|
||||
p.invT = Math.max(p.invT, 2.5);
|
||||
} },
|
||||
skinnerscoin: { name: "Skinner's Coin", desc: 'Clear all enemy bullets into gems', cost: 3, use(p) {
|
||||
for (const b of G.ebullets) spawnPickup('gem', b.x, b.y, { v: 1 });
|
||||
G.ebullets = [];
|
||||
spawnShockwave(p.x, p.y, 480, '#55f5dc', 0.5);
|
||||
} },
|
||||
gutreroll: { name: 'Gut Reroll', desc: 'Reroll the pedestal you\'re standing on', cost: 1, use(p) {
|
||||
for (const k of G.pickups) {
|
||||
if (k.type === 'item' && dist2(k.x, k.y, p.x, p.y) < 120 * 120) {
|
||||
k.iid = rollItemId('room', G.floor);
|
||||
spawnText(k.x, k.y - 40, 'REROLLED', '#e2472f');
|
||||
return;
|
||||
}
|
||||
}
|
||||
addToast('NO PEDESTAL', 'stand on an item pedestal to reroll it');
|
||||
} },
|
||||
};
|
||||
|
||||
function useActive() {
|
||||
const p = G.player;
|
||||
if (!p.active || G.mode !== 'play') return;
|
||||
const a = ACTIVES[p.active.iid];
|
||||
if (!a) return;
|
||||
if (p.active.charges < a.cost) {
|
||||
addToast(a.name, 'not ready — clear ' + (a.cost - p.active.charges) + ' more room' + (a.cost - p.active.charges > 1 ? 's' : ''));
|
||||
Sfx.activeEmpty();
|
||||
return;
|
||||
}
|
||||
p.active.charges = 0;
|
||||
a.use(p);
|
||||
spawnText(p.x, p.y - 22, a.name.toUpperCase(), '#e2472f');
|
||||
Sfx.active();
|
||||
}
|
||||
|
||||
// spawn an active pedestal (boss / item-room bonus drops)
|
||||
function spawnActivePedestal(x, y) {
|
||||
spawnPickup('itemspot', x, y);
|
||||
spawnPickup('active', x, y, { aid: choice(Object.keys(ACTIVES)) });
|
||||
}
|
||||
@@ -151,6 +151,7 @@ function update(dt) {
|
||||
if (keyPressed('p', 'escape')) { G.mode = 'pause'; Sfx.stopAllLoops(); Sfx.menu(); break; }
|
||||
if (keyPressed('n')) Music.cycle(1);
|
||||
if (keyPressed('r')) swapWeapon();
|
||||
if (keyPressed(' ')) useActive();
|
||||
if (keyPressed('t')) setAutoPerk(!G.autoPerk);
|
||||
updatePlayer(dt);
|
||||
updateRoom(dt);
|
||||
|
||||
@@ -121,6 +121,21 @@ function collectPickup(k) {
|
||||
for (const q of G.pickups) if (q.type === 'itemspot') q.dead = true; // remove pedestal base
|
||||
break;
|
||||
}
|
||||
case 'active': {
|
||||
// picking up a new active drops the old one with its remaining charges
|
||||
if (p.active) {
|
||||
const a = rand(0, TAU);
|
||||
spawnPickup('active', p.x + Math.cos(a) * 34, p.y + Math.sin(a) * 34,
|
||||
{ aid: p.active.iid, charges: p.active.charges, delay: 0.6 });
|
||||
}
|
||||
const def = ACTIVES[k.aid];
|
||||
p.active = { iid: k.aid, charges: k.charges !== undefined ? k.charges : def.cost };
|
||||
addToast(def.name, def.desc + ' — [SPACE] when charged');
|
||||
spawnText(p.x, p.y - 14, def.name.toUpperCase(), '#55f5dc');
|
||||
Sfx.item();
|
||||
for (const q of G.pickups) if (q.type === 'itemspot') q.dead = true; // remove pedestal base
|
||||
break;
|
||||
}
|
||||
case 'stairs':
|
||||
nextFloor();
|
||||
break;
|
||||
@@ -177,6 +192,17 @@ function drawPickups(ctx) {
|
||||
ctx.beginPath(); ctx.arc(k.x, k.y - 28, 25 + Math.sin(k.t * 4) * 3, 0, TAU); ctx.stroke();
|
||||
ctx.restore();
|
||||
Sprites.draw(ctx, 'i_' + k.iid, k.x, k.y - 28 + bob, 0, 64);
|
||||
} else if (k.type === 'active') {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.22 + Math.sin(k.t * 3) * 0.08;
|
||||
ctx.fillStyle = '#55f5dc';
|
||||
ctx.beginPath(); ctx.arc(k.x, k.y - 28, 30, 0, TAU); ctx.fill();
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.strokeStyle = '#a5f5e8'; ctx.lineWidth = 2;
|
||||
const ringR = 25 + Math.sin(k.t * 4) * 3;
|
||||
ctx.beginPath(); ctx.arc(k.x, k.y - 28, ringR, -Math.PI / 2, -Math.PI / 2 + TAU * ((k.charges || 0) / Math.max(1, ACTIVES[k.aid].cost))); ctx.stroke();
|
||||
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();
|
||||
@@ -191,6 +217,7 @@ function drawPickups(ctx) {
|
||||
if (dist2(k.x, k.y, p.x, p.y) < 90 * 90) {
|
||||
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 === 'ammo') drawPickupLabel(ctx, 'AMMO', k.x, k.y - 26, '#d0b060');
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ function initPlayer() {
|
||||
holstered: null, // a special weapon set aside while the pistol is out
|
||||
level: 1, xp: 0,
|
||||
items: {}, // iid -> quality tier (1..3)
|
||||
active: null, // { iid, charges } — active item, room-clear charged
|
||||
orbAng: 0,
|
||||
stats: {
|
||||
dmgMul: 1, rateMul: 1, speedMul: 1, shotSpeedMul: 1, rangeMul: 1,
|
||||
@@ -107,7 +108,8 @@ function updatePlayer(dt) {
|
||||
Sfx.syncWeaponLoop(w, sustained && Input.mdown && p.weapon.ammo > 0);
|
||||
if (p.fireT > 0) p.fireT -= dt;
|
||||
const frenzyMul = p.frenzyT > 0 ? 1 + st.frenzy : 1;
|
||||
const effectiveRate = st.rateMul * (1 + (st.rhythmRateBonus || 0)) * frenzyMul;
|
||||
const draughtMul = p.marrowDraughtT > 0 ? 2 : 1;
|
||||
const effectiveRate = st.rateMul * (1 + (st.rhythmRateBonus || 0)) * frenzyMul * draughtMul;
|
||||
const rate = w.interval / effectiveRate;
|
||||
|
||||
if (w.behavior === 'saw') {
|
||||
@@ -128,7 +130,7 @@ function updatePlayer(dt) {
|
||||
p.charge = Math.max(0, p.charge - dt * 2);
|
||||
}
|
||||
} else {
|
||||
if (Input.mdown && p.fireT <= 0 && p.weapon.ammo > 0) {
|
||||
if (Input.mdown && p.fireT <= 0 && p.weapon.ammo > 0 && !(p.panicRoomT > 0)) {
|
||||
fireWeapon(p, w);
|
||||
// Twin Sidearm: the Bone Popper double-taps
|
||||
if (st.twinSidearm > 0 && p.weapon.id === 'bonepopper') fireWeapon(p, w);
|
||||
@@ -146,7 +148,7 @@ function updatePlayer(dt) {
|
||||
p.muzzleT = 0.07;
|
||||
p.attackT = 0.4; p.actionT = 0;
|
||||
p.fireT = rate;
|
||||
if (p.weapon.ammo !== Infinity) p.weapon.ammo -= (st.twinSidearm > 0 ? 2 : 1) / st.ammoEff;
|
||||
if (p.weapon.ammo !== Infinity && !(p.marrowDraughtT > 0)) p.weapon.ammo -= (st.twinSidearm > 0 ? 2 : 1) / st.ammoEff;
|
||||
Sfx.shoot(w);
|
||||
if (w.behavior === 'slam') addShake(5);
|
||||
}
|
||||
@@ -210,6 +212,34 @@ function updatePlayer(dt) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- active-item timed effects ----
|
||||
// Marrow Draught: +100% fire rate and free ammo while active
|
||||
if (p.marrowDraughtT > 0) p.marrowDraughtT -= dt;
|
||||
// Panic Room: invulnerable but cannot fire
|
||||
if (p.panicRoomT > 0) p.panicRoomT -= dt;
|
||||
// Cleaver Storm: 12 orbiting cleavers shred on contact
|
||||
if (p.cleaverStormT > 0) {
|
||||
p.cleaverStormT -= dt;
|
||||
p.cleaverStormAng = (p.cleaverStormAng || 0) + dt * 6;
|
||||
p.cleaverStormTick = (p.cleaverStormTick || 0) - dt;
|
||||
const doTick = p.cleaverStormTick <= 0;
|
||||
if (doTick) p.cleaverStormTick = 0.25;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const a = p.cleaverStormAng + (i / 12) * TAU;
|
||||
const cx = p.x + Math.cos(a) * 90, cy = p.y + Math.sin(a) * 90;
|
||||
if (!doTick) continue;
|
||||
for (const e of G.enemies) {
|
||||
if (e.hp <= 0 || e.stormHitT > 0) continue;
|
||||
if (dist2(cx, cy, e.x, e.y) < (14 + e.r) * (14 + e.r)) {
|
||||
e.stormHitT = 0.3;
|
||||
damageEnemy(e, 10 * st.dmgMul * st.dmgLiveMul, a + Math.PI / 2, true, { source: 'player' });
|
||||
spawnBlood(e.x, e.y, a, 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const e of G.enemies) if (e.stormHitT > 0) e.stormHitT -= dt;
|
||||
}
|
||||
|
||||
// Thresher Plate: passive contact-damage aura (no need to be hit)
|
||||
if (st.thresherPlate > 0) {
|
||||
p.thresherTick = (p.thresherTick || 0) - dt;
|
||||
@@ -358,6 +388,13 @@ function drawPlayer(ctx) {
|
||||
Sprites.draw(ctx, 'bullet_cleaver', p.x + Math.cos(a) * 58, p.y + Math.sin(a) * 58, a + Math.PI / 2, 24);
|
||||
}
|
||||
}
|
||||
// Cleaver Storm active: 12 orbiting cleavers
|
||||
if (p.cleaverStormT > 0) {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const a = (p.cleaverStormAng || 0) + (i / 12) * TAU;
|
||||
Sprites.draw(ctx, 'bullet_cleaver', p.x + Math.cos(a) * 90, p.y + Math.sin(a) * 90, a + Math.PI / 2, 22);
|
||||
}
|
||||
}
|
||||
|
||||
const w = WEAPONS[p.weapon.id];
|
||||
const kick = p.recoil * 3;
|
||||
|
||||
@@ -90,6 +90,8 @@ function enterRoom(gx, gy) {
|
||||
const w = rollWeaponDrop(G.floor);
|
||||
spawnPickup('weapon', W / 2 + 55, H / 2, { wid: w.id });
|
||||
}
|
||||
// and occasionally a bonus active pedestal
|
||||
if (chance(0.15)) spawnActivePedestal(W / 2 + 130, H / 2 + 55);
|
||||
room.cleared = true;
|
||||
}
|
||||
} else if (room.type === 'combat' && !room.cleared && room.wavesLeft > 0) {
|
||||
@@ -177,6 +179,18 @@ function recordRoomClear(room) {
|
||||
} else {
|
||||
G.streak = 0;
|
||||
}
|
||||
// active items charge on room clears: +1 per combat room, +2 per boss
|
||||
const p = G.player;
|
||||
if (p && p.active) {
|
||||
const a = ACTIVES[p.active.iid];
|
||||
if (a) {
|
||||
const gain = room.type === 'boss' ? 2 : (room.type === 'combat' ? 1 : 0);
|
||||
if (gain > 0 && p.active.charges < a.cost) {
|
||||
p.active.charges = Math.min(a.cost, p.active.charges + gain);
|
||||
if (p.active.charges >= a.cost) spawnText(p.x, p.y - 30, a.name.toUpperCase() + ' READY', '#e2472f');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (room.type === 'combat' && G.player.stats.roomHeal > 0 && G.player.hp < G.player.stats.maxHp) {
|
||||
const before = G.player.hp;
|
||||
healPlayer(G.player.stats.roomHeal);
|
||||
|
||||
@@ -376,6 +376,8 @@ const Sfx = {
|
||||
ammo() { this.ui('ammo', () => { this.click(.14, 1600, 'ui'); this.blip(520, .08, 'square', .12, -80, 'ui'); }); },
|
||||
weapon() { this.ui('weapon', () => { this.thump(120, .14, .4, 50, 'ui'); this.blip(300, .1, 'square', .25, 200, 'ui'); this.blip(450, .12, 'square', .2, 250, 'ui'); }); },
|
||||
item() { this.ui('item', () => { this.thump(100, .2, .4, 45, 'ui'); this.blip(350, .15, 'triangle', .3, 150, 'ui'); this.blip(525, .2, 'triangle', .25, 200, 'ui'); }); },
|
||||
active() { this.thump(140, .22, .5, 60, 'ui'); this.blip(700, .12, 'sawtooth', .28, 500, 'ui'); this.noise(.1, .14, 2400, 'ui', null, true); },
|
||||
activeEmpty() { this.blip(220, .12, 'square', .18, -90, 'ui'); },
|
||||
levelup() { this.ui('levelup', () => { this.blip(400, .12, 'square', .25, 200, 'ui'); setTimeout(() => this.blip(600, .12, 'square', .25, 200, 'ui'), 100); setTimeout(() => this.blip(800, .2, 'square', .25, 200, 'ui'), 200); }); },
|
||||
perk() { this.ui('perk', () => this.blip(500, .1, 'square', .25, 300, 'ui')); },
|
||||
menu() { this.ui('menu', () => this.blip(620, .055, 'square', .11, 120, 'ui')); },
|
||||
|
||||
@@ -115,7 +115,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=39')
|
||||
const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=40')
|
||||
.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)))
|
||||
|
||||
@@ -44,6 +44,9 @@ const SPRITE_MANIFEST = [
|
||||
'i_meathook', 'i_blooddebt',
|
||||
'i_butchersoath', 'i_secondskin', 'i_twinsidearm', 'i_crimsonmetronome', 'i_abattoirengine',
|
||||
'i_gorecrown', 'i_thousandteeth', 'i_hollowfather', 'i_thelastcut', 'i_meatgrinder',
|
||||
// active items
|
||||
'a_bonenova', 'a_offalbomb', 'a_bloodtransfusion', 'a_cleaverstorm', 'a_butchersbell',
|
||||
'a_marrowdraught', 'a_slaughtertime', 'a_panicroom', 'a_skinnerscoin', 'a_gutreroll',
|
||||
// full 64px/128px eight-direction action atlases
|
||||
'player_sheet', 'player_legs_sheet',
|
||||
'enemy_shambler_sheet', 'enemy_runner_sheet', 'enemy_spitter_sheet',
|
||||
@@ -81,7 +84,7 @@ const Sprites = {
|
||||
const img = new Image();
|
||||
img.onload = () => { this.imgs[name] = img; if (--pending === 0) G.imagesLoaded = true; };
|
||||
img.onerror = () => { if (--pending === 0) G.imagesLoaded = true; };
|
||||
img.src = 'assets/' + name + '.png?v=39';
|
||||
img.src = 'assets/' + name + '.png?v=40';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -286,6 +289,12 @@ const Sprites = {
|
||||
} else if (name.startsWith('w_')) {
|
||||
ctx.fillStyle = C('#b8a888'); ctx.fillRect(-8, -3, 16, 6);
|
||||
ctx.fillStyle = C('#6b5333'); ctx.fillRect(-4, 3, 5, 6);
|
||||
} else if (name.startsWith('a_')) {
|
||||
ctx.fillStyle = C('#2fa898');
|
||||
ctx.beginPath(); ctx.arc(0, 0, 9, 0, TAU); ctx.fill();
|
||||
ctx.fillStyle = C('#0d2a26');
|
||||
ctx.font = 'bold 10px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText('!', 0, 0);
|
||||
} else if (name.startsWith('i_') || name.startsWith('perk_')) {
|
||||
ctx.fillStyle = C('#c9a227');
|
||||
ctx.beginPath(); ctx.arc(0, 0, 9, 0, TAU); ctx.fill();
|
||||
|
||||
@@ -65,6 +65,7 @@ function powerScore() {
|
||||
if (!p) return 0;
|
||||
let n = (p.level - 1) * PERK_POWER_WEIGHT;
|
||||
for (const iid in p.items) n += p.items[iid] * ITEM_RARITY[ITEMS[iid].rarity].power;
|
||||
if (p.active) n += 2; // holding an active item is a flat power contribution
|
||||
return Math.max(0, n);
|
||||
}
|
||||
|
||||
|
||||
@@ -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, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS, PRESSURE_UNIT, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN })', 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_DIAL_MIN, PRESSURE_DIAL_MAX, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN })', sandbox);
|
||||
|
||||
let simTime = 0;
|
||||
const ctx = sandbox;
|
||||
@@ -530,6 +530,46 @@ console.log('== phase-4 boss-exclusive legendaries ==');
|
||||
ctx.startRun(); step(3, 16);
|
||||
}
|
||||
|
||||
console.log('== active items (room-clear charged) ==');
|
||||
{
|
||||
const p = ctx.G.player;
|
||||
p.active = null;
|
||||
// pickup grants a full-charge active
|
||||
ctx.spawnPickup('active', p.x, p.y, { aid: 'bonenova' });
|
||||
step(2, 16);
|
||||
check('active pickup equips fully charged', p.active && p.active.iid === 'bonenova' && p.active.charges === ctx.ACTIVES.bonenova.cost);
|
||||
// swap: picking up a second active drops the first
|
||||
ctx.spawnPickup('active', p.x, p.y, { aid: 'offalbomb' });
|
||||
step(2, 16);
|
||||
check('second active equips and drops the old one', p.active.iid === 'offalbomb' && ctx.G.pickups.some(k => k.type === 'active' && k.aid === 'bonenova'));
|
||||
// use: charges spend, effect fires
|
||||
const powerBefore = ctx.powerScore();
|
||||
check('holding an active adds flat power', powerBefore > 0);
|
||||
ctx.useActive();
|
||||
check('using an active spends all charges', p.active.charges === 0);
|
||||
// charge accrues from room clears
|
||||
ctx.recordRoomClear({ type: 'combat' });
|
||||
check('combat clear grants +1 charge', p.active.charges === 1);
|
||||
ctx.recordRoomClear({ type: 'boss' });
|
||||
check('boss clear grants +2 charges', p.active.charges === ctx.ACTIVES.offalbomb.cost);
|
||||
// under-charged use is blocked
|
||||
p.active.charges = 0;
|
||||
const enemiesBefore = ctx.G.enemies.length;
|
||||
ctx.useActive();
|
||||
check('under-charged active does not fire', p.active.charges === 0 && ctx.G.enemies.length === enemiesBefore);
|
||||
// panic room blocks firing
|
||||
p.active = { iid: 'panicroom', charges: ctx.ACTIVES.panicroom.cost };
|
||||
ctx.useActive();
|
||||
check('panic room grants invulnerability', p.invT >= 2.4 && p.panicRoomT > 0);
|
||||
p.weapon = { id: 'bonepopper', ammo: Infinity };
|
||||
p.fireT = 0;
|
||||
ctx.Input.mdown = true; step(3, 16); ctx.Input.mdown = false;
|
||||
check('panic room prevents firing', ctx.G.bullets.length === 0);
|
||||
p.panicRoomT = 0; p.invT = 1;
|
||||
p.active = null;
|
||||
ctx.G.pickups.length = 0;
|
||||
}
|
||||
|
||||
console.log('== phase-3 hook passives ==');
|
||||
{
|
||||
const p = ctx.G.player;
|
||||
@@ -1016,8 +1056,8 @@ console.log('== item stickiness ramps with owned count ==');
|
||||
|
||||
console.log('== help manual grows with the implant roster ==');
|
||||
{
|
||||
check('11 help pages (5 implant pages for 82 items)', ctx.HELP_PAGES.length === 11);
|
||||
check('11 help renderers', ctx.HELP_RENDERERS.length === 11);
|
||||
check('12 help pages (5 implants + actives)', ctx.HELP_PAGES.length === 12);
|
||||
check('12 help renderers', ctx.HELP_RENDERERS.length === 12);
|
||||
check('help tabs never overflow the panel', (() => {
|
||||
const last = ctx.helpTabRect(ctx.HELP_PAGES.length - 1);
|
||||
const first = ctx.helpTabRect(0);
|
||||
|
||||
@@ -99,7 +99,8 @@ def finish_sprite(img, name):
|
||||
# Passive items are presented as filthy relic medallions rather than
|
||||
# ungrounded clip-art silhouettes. The object remains unique; the shared
|
||||
# iron/bone mount makes the complete inventory read as one designed set.
|
||||
if name.startswith("i_"):
|
||||
if name.startswith(("i_", "a_")):
|
||||
is_active = name.startswith("a_")
|
||||
plate = Image.new("RGBA", graded.size, (0, 0, 0, 0))
|
||||
pd = ImageDraw.Draw(plate)
|
||||
w, h = graded.size
|
||||
@@ -107,9 +108,14 @@ def finish_sprite(img, name):
|
||||
(w * 0.91, h * 0.76), (w * 0.62, h - 2), (w * 0.25, h * 0.91),
|
||||
(2, h * 0.63), (w * 0.08, h * 0.24)]
|
||||
rim = [(round(x), round(y)) for x, y in rim]
|
||||
pd.polygon(rim, fill=(14, 5, 9, 248), outline=(104, 55, 45, 255), width=3)
|
||||
pd.ellipse([7, 7, w - 8, h - 8], fill=(31, 11, 19, 235), outline=(150, 92, 57, 255), width=2)
|
||||
pd.arc([11, 11, w - 12, h - 12], 205, 330, fill=(225, 186, 113, 210), width=2)
|
||||
# actives get a teal-copper mount so they read as usable, not passive
|
||||
rim_outline = (45, 104, 98, 255) if is_active else (104, 55, 45, 255)
|
||||
plate_fill = (9, 24, 26, 235) if is_active else (31, 11, 19, 235)
|
||||
ring = (92, 202, 190, 255) if is_active else (150, 92, 57, 255)
|
||||
arc = (120, 240, 224, 210) if is_active else (225, 186, 113, 210)
|
||||
pd.polygon(rim, fill=(14, 5, 9, 248), outline=rim_outline, width=3)
|
||||
pd.ellipse([7, 7, w - 8, h - 8], fill=plate_fill, outline=ring, width=2)
|
||||
pd.arc([11, 11, w - 12, h - 12], 205, 330, fill=arc, width=2)
|
||||
for rx, ry in ((9, 16), (w - 11, 17), (13, h - 13), (w - 14, h - 12)):
|
||||
pd.ellipse([rx - 2, ry - 2, rx + 2, ry + 2], fill=(83, 91, 91, 255), outline=OUTL, width=1)
|
||||
plate.alpha_composite(graded)
|
||||
@@ -118,7 +124,7 @@ def finish_sprite(img, name):
|
||||
px = graded.load()
|
||||
rng = random.Random(sum(ord(c) for c in name) * 97)
|
||||
area = graded.width * graded.height
|
||||
is_icon = name.startswith(("w_", "wt_", "i_"))
|
||||
is_icon = name.startswith(("w_", "wt_", "i_", "a_"))
|
||||
is_tile = name.startswith(("tile_", "door_", "pedestal", "stairs"))
|
||||
grit = area // (82 if is_tile else (62 if is_icon else 150))
|
||||
for _ in range(max(10, grit)):
|
||||
|
||||
@@ -305,6 +305,21 @@ ITEMS = {
|
||||
for iid, subject in ITEMS.items():
|
||||
SPECS["i_" + iid] = Spec(subject + ", isolated inventory object, strong top-left light, no medallion, no icon background", 32)
|
||||
|
||||
ACTIVES = {
|
||||
"bonenova": "a radial burst of sharp bone shards erupting from a central skull",
|
||||
"offalbomb": "a fused bomb made of packed offal with a lit intestine fuse",
|
||||
"bloodtransfusion": "a blood bag on a steel hook with a transfusion tube and needle",
|
||||
"cleaverstorm": "a whirlwind spiral of twelve small orbiting meat cleavers",
|
||||
"butchersbell": "a huge rusted slaughterhouse dinner bell with a bone clapper",
|
||||
"marrowdraught": "a frothing tankard of dark marrow with a bone straw",
|
||||
"slaughtertime": "a cracked hourglass with blood instead of sand, frozen mid-drop",
|
||||
"panicroom": "a small riveted iron bunker door with a spinning lock wheel",
|
||||
"skinnerscoin": "a gleaming gold coin stamped with a skinning knife",
|
||||
"gutreroll": "a loop of intestine twisted into a circular arrow with dice pips",
|
||||
}
|
||||
for aid, subject in ACTIVES.items():
|
||||
SPECS["a_" + aid] = Spec(subject + ", isolated inventory object, strong top-left light, no medallion, no icon background", 32)
|
||||
|
||||
|
||||
# Reference-derived industrial ramps. All generated images are mapped onto this
|
||||
# palette so independent API calls remain visually coherent.
|
||||
|
||||