Files
MeatSlicer/js/hud.js
T
MeatSlicer Developer 0ec12cd72c 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
2026-07-30 17:14:25 +10:00

276 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---- HUD: hearts, XP, weapon, items, boss bar, minimap, toasts ----
function drawHUD(ctx) {
const p = G.player;
ctx.save();
const A = clamp(G.hudAlpha === undefined ? 1 : G.hudAlpha, 0.25, 1);
ctx.globalAlpha = A;
// 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) + (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,
});
ctx.textAlign = 'left';
ctx.fillStyle = '#d9c9bc'; ctx.font = 'bold 10px monospace';
ctx.fillText('BUTCHER // VITALS', 18, 23);
ctx.fillStyle = '#d7a934'; ctx.textAlign = 'right';
ctx.fillText('LV ' + String(p.level).padStart(2, '0'), 300, 23);
// hearts: full containers = 2 HP, trailing odd maxHp draws as a half outline
const fullHearts = Math.floor(p.stats.maxHp / 2);
const halfContainer = p.stats.maxHp % 2 === 1;
const maxVitalIcons = 24; // 2 rows x 12
const shownFull = Math.min(fullHearts, maxVitalIcons);
let slot = 0;
for (let i = 0; i < shownFull; i++) {
const x = 21 + (i % 12) * 22, y = 39 + Math.floor(i / 12) * 22;
slot = i + 1;
const fill = clamp(p.hp - i * 2, 0, 2) / 2;
Sprites.draw(ctx, 'heart', x, y, 0, 18, false, 0.25 * A);
if (fill > 0) {
ctx.save();
ctx.beginPath(); ctx.rect(x - 10, y - 10, 20 * fill, 20); ctx.clip();
Sprites.draw(ctx, 'heart', x, y, 0, 18);
ctx.restore();
}
}
// half container outline (only when there's icon budget left)
if (halfContainer && slot < maxVitalIcons) {
const x = 21 + (slot % 12) * 22, y = 39 + Math.floor(slot / 12) * 22;
ctx.save();
ctx.beginPath(); ctx.rect(x - 10, y - 10, 10, 20); ctx.clip();
Sprites.draw(ctx, 'heart', x, y, 0, 18, false, 0.25 * A);
ctx.restore();
const fill = clamp(p.hp - slot * 2, 0, 1);
if (fill > 0) {
ctx.save();
ctx.beginPath(); ctx.rect(x - 10, y - 10, 10, 20); ctx.clip();
Sprites.draw(ctx, 'heart', x, y, 0, 18);
ctx.restore();
}
slot++;
}
// shield pips: 2 shieldHp = one full cyan heart, odd = half (matches hearts)
const shieldFull = Math.floor(p.shieldHp / 2), shieldHalf = p.shieldHp % 2 === 1;
const shieldIcons = shieldFull + (shieldHalf ? 1 : 0);
const shownShieldIcons = Math.min(shieldIcons, Math.max(0, maxVitalIcons - slot));
for (let i = 0; i < shownShieldIcons; i++) {
const x = 21 + ((slot + i) % 12) * 22, y = 39 + Math.floor((slot + i) / 12) * 22;
const isHalf = shieldHalf && i === shownShieldIcons - 1;
ctx.fillStyle = '#3bc9e0';
ctx.beginPath(); ctx.arc(x - 3, y - 2, 4, 0, TAU); ctx.arc(x + 3, y - 2, 4, 0, TAU); ctx.fill();
ctx.beginPath(); ctx.moveTo(x - 6.5, y); ctx.lineTo(x, y + 7); ctx.lineTo(x + 6.5, y); ctx.fill();
if (isHalf) {
ctx.save();
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath(); ctx.arc(x + 3, y - 2, 4.5, 0, TAU); ctx.fill();
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + 7, y); ctx.lineTo(x, y + 8); ctx.closePath(); ctx.fill();
ctx.restore();
}
}
slot += shownShieldIcons;
const hiddenVitals = Math.ceil(p.stats.maxHp / 2) + shieldIcons - slot;
if (hiddenVitals > 0) {
drawPixelTag(ctx, '+' + hiddenVitals, 266, 29, { width: 34, height: 21, color: '#d7b24a', accent: '#8f6724' });
}
const need = xpForLevel(p.level);
drawPixelBar(ctx, 16, 53 + vOff, 220, 10, p.xp / need, {
fill: '#3d9fc2', glint: '#a5edee', border: '#465d69', segments: 12,
});
ctx.fillStyle = '#88b9c4'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'right';
ctx.fillText(Math.floor(p.xp) + ' / ' + need + ' XP', 299, 62 + vOff);
const w = WEAPONS[p.weapon.id];
ctx.fillStyle = 'rgba(3,2,3,0.6)'; ctx.fillRect(15, 70 + vOff, 48, 34);
ctx.strokeStyle = 'rgba(116,56,65,0.65)'; ctx.strokeRect(15.5, 70.5 + vOff, 47, 33);
Sprites.draw(ctx, 'w_' + w.id, 39, 87 + vOff, 0, 54);
ctx.textAlign = 'left'; ctx.fillStyle = '#f0e5d7'; ctx.font = 'bold 12px monospace';
ctx.fillText(w.name.toUpperCase(), 72, 84 + vOff);
ctx.fillStyle = '#7d6262'; ctx.font = '9px monospace';
ctx.fillText('ACTIVE WEAPON', 72, 99 + vOff);
const ammoText = p.weapon.ammo === Infinity ? 'SIDEARM' : String(Math.ceil(p.weapon.ammo)).padStart(3, '0');
drawPixelTag(ctx, ammoText, 238, 74 + vOff, {
width: 58, height: 26,
color: p.weapon.ammo === Infinity ? '#9c8d86' : (p.weapon.ammo < w.ammo * 0.25 ? '#ff4057' : '#e0b94e'),
accent: p.weapon.ammo === Infinity ? '#5d4c50' : '#a47e25', font: 'bold 10px monospace',
});
if (p.holstered) {
const holsterEmpty = p.holstered.ammo <= 0;
ctx.fillStyle = 'rgba(86,40,48,0.35)'; ctx.fillRect(15, 112 + vOff, 281, 20);
Sprites.draw(ctx, 'w_' + p.holstered.id, 29, 122 + vOff, 0, 28, false, 0.68 * A);
ctx.fillStyle = holsterEmpty ? '#d94652' : '#aa9290'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'left';
ctx.fillText((holsterEmpty ? '[EMPTY] ' : '[R] ') + WEAPONS[p.holstered.id].name.toUpperCase(), 47, 125 + vOff);
ctx.fillStyle = holsterEmpty ? '#ef4057' : '#d0aa4a'; ctx.textAlign = 'right';
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]));
const maxVisibleItems = allItemEntries.length > 24 ? 23 : 24;
const itemEntries = allItemEntries.slice(0, maxVisibleItems);
if (itemEntries.length) {
const cols = Math.min(12, itemEntries.length), rows = Math.ceil(itemEntries.length / 12);
const trayY = panelH + 16, trayW = 28 + cols * 34, trayH = 22 + rows * 34;
drawPixelPanel(ctx, 8, trayY, trayW, trayH, {
accent: '#a77d2b', cut: 5, blood: true, seed: itemEntries.length, bloodAlpha: 0.2,
});
ctx.fillStyle = '#9e857a'; ctx.font = 'bold 8px monospace'; ctx.textAlign = 'left';
ctx.fillText('IMPLANTS', 17, trayY + 12);
for (let index = 0; index < itemEntries.length; index++) {
const [iid, lvl] = itemEntries[index];
const ix = 28 + (index % 12) * 34;
const iy = trayY + 31 + Math.floor(index / 12) * 34;
Sprites.draw(ctx, 'i_' + iid, ix, iy, 0, 30, false, 0.95 * A);
if (lvl > 1) {
ctx.fillStyle = '#ffd060'; ctx.font = 'bold 8px monospace';
ctx.fillText(romanNum(lvl), ix - 7, iy + 11);
}
}
if (allItemEntries.length > itemEntries.length) drawPixelTag(ctx, '+' + (allItemEntries.length - itemEntries.length), trayW - 49, trayY + trayH - 23, {
width: 30, height: 20, color: '#e0b94e', accent: '#8d6724', font: 'bold 8px monospace',
});
}
drawMinimap(ctx);
drawPixelPanel(ctx, W - 180, H - 104, 172, 96, {
accent: '#b5892f', cut: 6, blood: true, seed: G.kills + 4, bloodAlpha: 0.22,
});
ctx.textAlign = 'left'; ctx.fillStyle = '#88746f'; ctx.font = 'bold 8px monospace';
ctx.fillText('RUN DATA', W - 168, H - 87);
ctx.fillStyle = '#d6aa39'; ctx.font = 'bold 12px monospace';
ctx.fillText('FLOOR ' + String(G.floor).padStart(2, '0'), W - 168, H - 65);
ctx.textAlign = 'right'; ctx.fillStyle = '#a18d87'; ctx.font = '9px monospace';
ctx.fillText('KILLS ' + String(G.kills).padStart(3, '0'), W - 18, H - 67);
ctx.fillStyle = '#f0e5d7'; ctx.font = 'bold 11px monospace';
ctx.fillText('SCORE ' + String(G.score).padStart(6, '0'), W - 18, H - 46);
ctx.fillStyle = '#8f7770'; ctx.font = '8px monospace';
ctx.fillText('×' + (G.pressure || 1).toFixed(2), W - 168, H - 46);
ctx.textAlign = 'left';
const dialTxt = ' D' + (G.pressureDial > 0 ? '+' : '') + G.pressureDial;
ctx.fillStyle = G.pressure > 1 ? '#d6aa39' : '#69b8bd'; ctx.font = 'bold 8px monospace';
ctx.fillText('PRESSURE ' + Math.round(G.pressure * 100) + '%' + dialTxt, W - 168, H - 28);
ctx.textAlign = 'right'; ctx.fillText('STREAK ' + G.streak, W - 18, H - 28);
drawPixelBar(ctx, W - 168, H - 20, 150, 8, (G.pressure - PRESSURE_MIN) / (PRESSURE_MAX - PRESSURE_MIN), {
fill: G.pressure > 1 ? '#c59231' : '#3d8f99', glint: G.pressure > 1 ? '#f4d86d' : '#8ce0df', border: '#5d3c43', segments: 10,
});
if (G.boss && G.boss.hp > 0) {
ctx.save(); ctx.globalAlpha = 1; // boss bar stays fully opaque
const bw = 460, bx = W / 2 - bw / 2, by = H - 48;
drawPixelPanel(ctx, bx, by, bw, 40, {
accent: '#e12842', cut: 6, fill: 'rgba(12,3,6,0.92)', blood: true,
seed: G.floor, bloodAlpha: 0.25,
});
const k = clamp(G.boss.hp / G.boss.maxHp, 0, 1);
ctx.fillStyle = '#efc6bd'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'left';
ctx.fillText('TARGET // ' + G.boss.name, bx + 12, by + 15);
ctx.textAlign = 'right'; ctx.fillStyle = '#af7d7c';
ctx.fillText(Math.ceil(G.boss.hp) + ' / ' + Math.ceil(G.boss.maxHp), bx + bw - 12, by + 15);
drawPixelBar(ctx, bx + 11, by + 22, bw - 22, 10, k, {
fill: '#b51e35', glint: '#ff7d82', border: '#6e2936', segments: 20,
});
ctx.restore();
}
ctx.textAlign = 'center';
for (let i = 0; i < G.toasts.length; i++) {
const t = G.toasts[i];
const a = clamp(2.5 - t.t, 0, 1);
const y = 22 + i * 48;
ctx.globalAlpha = a;
ctx.font = 'bold 13px monospace';
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,
});
ctx.fillStyle = '#e3b746'; ctx.fillText(t.text.toUpperCase(), W / 2, y + 17);
if (t.sub) {
ctx.fillStyle = '#9f8981'; ctx.font = '9px monospace';
ctx.fillText(t.sub.toUpperCase(), W / 2, y + 31);
}
ctx.globalAlpha = A;
}
if (G.muted) {
drawPixelTag(ctx, '[M] MUTED', 9, H - 31, {
width: 76, height: 22, color: '#a28f87', accent: '#5f2932',
});
}
ctx.restore();
}
function drawMinimap(ctx) {
const rooms = Object.values(G.rooms);
if (!rooms.length) return;
const s = 12, pad = 3;
let minX = 0, maxX = 0, minY = 0, maxY = 0;
for (const r of rooms) {
minX = Math.min(minX, r.gx); maxX = Math.max(maxX, r.gx);
minY = Math.min(minY, r.gy); maxY = Math.max(maxY, r.gy);
}
const gridW = (maxX - minX + 1) * (s + pad) - pad;
const gridH = (maxY - minY + 1) * (s + pad) - pad;
const panelW = Math.max(148, gridW + 24), panelH = Math.max(78, gridH + 38);
const px = W - panelW - 8, py = 8;
drawPixelPanel(ctx, px, py, panelW, panelH, {
accent: '#4a9cad', cut: 6, blood: true, seed: G.floor + 9, bloodAlpha: 0.16,
});
ctx.fillStyle = '#8eb7bd'; ctx.font = 'bold 8px monospace'; ctx.textAlign = 'left';
ctx.fillText('SECTOR MAP', px + 11, py + 16);
ctx.textAlign = 'right'; ctx.fillStyle = '#d1a839';
ctx.fillText('F-' + String(G.floor).padStart(2, '0'), px + panelW - 11, py + 16);
const ox = px + Math.floor((panelW - gridW) / 2);
const oy = py + 27 + Math.floor((panelH - 32 - gridH) / 2);
for (const r of rooms) {
if (!r.visited && r !== G.cur) continue;
const x = ox + (r.gx - minX) * (s + pad);
const y = oy + (r.gy - minY) * (s + pad);
ctx.fillStyle = '#130b0e'; ctx.fillRect(x - 1, y - 1, s + 2, s + 2);
if (r === G.cur) ctx.fillStyle = '#e9ded0';
else if (r.type === 'boss') ctx.fillStyle = '#b72138';
else if (r.type === 'item') ctx.fillStyle = '#c9a227';
else if (r.cleared) ctx.fillStyle = '#43545a';
else ctx.fillStyle = '#7a4f59';
ctx.fillRect(x, y, s, s);
if (r === G.cur) {
ctx.strokeStyle = '#67c7d2'; ctx.strokeRect(x - 2.5, y - 2.5, s + 5, s + 5);
}
}
}
function drawToastsUpdate(dt) {
for (const t of G.toasts) t.t += dt;
while (G.toasts.length && G.toasts[0].t > 2.5) G.toasts.shift();
}