Add freshness bar, enhance overlays and renderers

- Add enemy freshness tracking (novelty bonus for repeated deploys)
- Add freshness bar to sidepanel enemy cards with penalty indicator
- Major overhaul of renderer-overlays.js (790+ lines for UI polish)
- Enhanced combat log, shop overlays, and inventory UI
- Improved weapon/upgrade display with partial ownership colors
- Added element icons and weakness/resistance indicators to cards
- Enhanced radial menu and tooltip system
- Add "stale/%" penalty text when freshness depleted
- Update play link to ffazeshift.net in index.html
This commit is contained in:
2026-06-17 11:58:17 -04:00
parent 6a710c3f03
commit 626879ed0c
21 changed files with 1884 additions and 312 deletions
+764 -28
View File
@@ -3,6 +3,29 @@
// RENDERER OVERLAYS — game over, pause, mount drag UI
// ============================================================
// ── WEAPON RADIAL + DETAIL STATE ─────────────────────────────
let _radialSlot = -1; // which slot has radial menu open (-1 = none)
let _weaponDetailScrollY = 0;
let _weaponDetailScrollMax = 0;
let _sellRegion = null; // {x,y,w,h,slot} — written each frame, read by input.js for hold-to-sell
let _prestigeHoldRegion = null; // {x,y,w,h} — written each frame by drawPrestigeConfirm, read by input.js
function checkSellHold() {
if (_sellHoldSlot >= 0 && Date.now() - _sellHoldMs >= SELL_HOLD_DURATION) {
const slot = _sellHoldSlot;
_sellHoldSlot = -1;
sellWeapon(slot);
}
}
function checkPrestigeHold() {
if (_prestigeHoldMs > 0 && Date.now() - _prestigeHoldMs >= PRESTIGE_HOLD_DURATION) {
_prestigeHoldMs = -1;
prestige();
closePrestigeDialog();
}
}
// ── GAME OVER / BANKRUPT OVERLAY ─────────────────────────────
function drawGameOverPanel() {
const W = canvas.width, H = canvas.height;
@@ -145,50 +168,62 @@ function _drawMountTooltip(mx, my, weapon, socketR) {
ctx.fillText(parts.join(' '), tx + 8, ty + 26);
ctx.fillStyle = '#3a6080';
ctx.fillText('drag to move or bag', tx + 8, ty + 41);
ctx.fillText('Shift+click to interact', tx + 8, ty + 41);
ctx.restore();
}
// ── MOUNT POINT INTERACTION (drawn after overlays so it sits on top) ─────
// ── MOUNT POINT INTERACTION ───────────────────────────────────
function drawMountInteraction(cx, cy) {
if (G.shopOpen) return;
if (G.armoryOpen || G.commandOpen) return;
if (G.weaponDetailSlot >= 0) return;
// All drawing uses screen-space coords so UI stays fixed-size at any zoom level
const zoom = G?.camera?.zoom ?? 1.0;
const toSX = wx => (wx - cx) * zoom + cx;
const toSY = wy => (wy - cy) * zoom + cy;
const invOpen = document.body.classList.contains('inventory-open');
const totalSlots = Math.max(1, G.tower.weaponSlots);
const hpRatio = G.tower.hp / G.tower.maxHp;
const hpColor = hpRatio > 0.5 ? '#00d4ff' : hpRatio > 0.25 ? '#ffd700' : '#ff3355';
const spreadMode = invOpen || _shiftHeld || _radialSlot >= 0;
for (let slotIndex = 0; slotIndex < totalSlots; slotIndex++) {
const weapon = G.weapons[slotIndex];
const installed = !!weapon;
const weapon = G.weapons[slotIndex];
const installed = !!weapon;
const mountAngle = HARDPOINT_BASE_ANGLE + (slotIndex / totalSlots) * Math.PI * 2;
if (invOpen) {
if (spreadMode) {
const actual = getSlotHardpoint(slotIndex, cx, cy, totalSlots);
const ORBIT = Math.max(78, 64 + totalSlots * 6);
const mx = cx + Math.cos(mountAngle) * ORBIT;
const my = cy + Math.sin(mountAngle) * ORBIT;
const ORBIT = Math.max(78, 64 + totalSlots * 6);
const mx = cx + Math.cos(mountAngle) * ORBIT; // world
const my = cy + Math.sin(mountAngle) * ORBIT; // world
const smx = toSX(mx);
const smy = toSY(my);
const dropR = 20;
const hov = isHovered(mx - dropR, my - dropR, dropR * 2, dropR * 2);
const hov = isHovered(smx - dropR, smy - dropR, dropR * 2, dropR * 2);
const dragging = _dragWeapon !== null;
// Dashed line from actual hardpoint to spread circle
ctx.save();
ctx.strokeStyle = '#00aaff33';
ctx.lineWidth = 1;
ctx.strokeStyle = '#00aaff77';
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 6]);
ctx.beginPath();
ctx.moveTo(actual.x, actual.y);
ctx.lineTo(mx, my);
ctx.moveTo(toSX(actual.x), toSY(actual.y));
ctx.lineTo(smx, smy);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
if (dragging) {
// Drag drop indicator
if ((invOpen || _shiftHeld) && dragging) {
ctx.save();
ctx.shadowBlur = hov ? 20 : 8;
ctx.shadowColor = '#ffd700';
ctx.strokeStyle = hov ? '#ffd700' : '#00aaff55';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(mx, my, dropR + 5, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.arc(smx, smy, dropR + 5, 0, Math.PI * 2); ctx.stroke();
ctx.shadowBlur = 0;
ctx.restore();
}
@@ -198,40 +233,741 @@ function drawMountInteraction(cx, cy) {
? (hov ? '#ffd700' : hpColor + 'aa')
: (hov ? '#00aaff' : '#1a3240');
ctx.lineWidth = hov ? 2.5 : 1.5;
ctx.beginPath(); ctx.arc(mx, my, dropR, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.arc(smx, smy, dropR, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.font = '8px Orbitron, monospace';
ctx.letterSpacing = '1px';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#3a6080';
ctx.fillText(`S${slotIndex + 1}`, mx, my - (installed ? 8 : 0));
ctx.fillText(`S${slotIndex + 1}`, smx, smy - (installed ? 8 : 0));
ctx.letterSpacing = '0px';
if (installed) {
_dragRegions.push({ x: mx - dropR, y: my - dropR, w: dropR * 2, h: dropR * 2, weapon, source: { type: 'slot', slotIndex } });
if (invOpen || _shiftHeld) {
_dragRegions.push({ x: smx - dropR, y: smy - dropR, w: dropR * 2, h: dropR * 2, weapon, source: { type: 'slot', slotIndex } });
}
const def = getWeaponDef(weapon);
ctx.font = '14px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = '#ffffff';
ctx.fillText(def?.icon || '?', mx, my + 5);
ctx.fillText(def?.icon || '?', smx, smy + 5);
if ((_shiftHeld || _radialSlot >= 0) && !invOpen && hov) {
_drawMountTooltip(smx, smy, weapon, dropR);
}
} else {
ctx.font = '14px "Share Tech Mono", monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = '#1a3240';
ctx.fillText('+', mx, my);
ctx.fillText('+', smx, smy);
}
// _mountDropZones stay in world space — tested against pt.worldX/worldY
_mountDropZones.push({ x: mx, y: my, r: dropR, slotIndex });
addHitRegion(mx - dropR, my - dropR, dropR * 2, dropR * 2, () => openWeaponPicker(slotIndex));
if (hov && installed) _drawMountTooltip(mx, my, weapon, dropR);
} else {
// inventory closed: invisible hit region — click opens picker for this slot
const mount = getSlotHardpoint(slotIndex, cx, cy, totalSlots);
const r = 10;
addHitRegion(mount.x - r, mount.y - r, r * 2, r * 2, () => openWeaponPicker(slotIndex));
if (invOpen) {
addHitRegion(smx - dropR, smy - dropR, dropR * 2, dropR * 2, () => openWeaponPicker(slotIndex));
} else {
const si = slotIndex;
if (installed) {
addHitRegion(smx - dropR, smy - dropR, dropR * 2, dropR * 2, () => {
_radialSlot = (_radialSlot === si) ? -1 : si;
});
} else {
addHitRegion(smx - dropR, smy - dropR, dropR * 2, dropR * 2, () => openWeaponPicker(si));
}
}
}
}
// ── Radial menu ────────────────────────────────────────────────
if (_radialSlot >= 0) {
const totalSlotsR = Math.max(1, G.tower.weaponSlots);
const mountAngleR = HARDPOINT_BASE_ANGLE + (_radialSlot / totalSlotsR) * Math.PI * 2;
const ORBIT_R = Math.max(78, 64 + totalSlotsR * 6);
const rmx = cx + Math.cos(mountAngleR) * ORBIT_R; // world
const rmy = cy + Math.sin(mountAngleR) * ORBIT_R; // world
const srmx = toSX(rmx);
const srmy = toSY(rmy);
const weapon = G.weapons[_radialSlot];
const si = _radialSlot;
const w = weapon;
const outerR = 90;
const innerR = 22;
const iconR = 68;
const iconBtnR = 22;
const curMode = weapon ? (weapon.targeting || 'nearest') : 'nearest';
const opts = [
{ key: 'upgrades', angle: -Math.PI / 2, label: 'UPGRADES', icon: '⚙', color: '#00d4ff' },
{ key: 'target', angle: 0, label: curMode.toUpperCase(), icon: '🎯', color: '#ffd700' },
{ key: 'sell', angle: Math.PI / 2, label: 'SELL', icon: '💰', color: '#ff3355' },
{ key: 'inventory', angle: Math.PI, label: 'INVENTORY', icon: '📦', color: '#00aaff' },
];
let hovKey = null;
if (_hoverPt) {
const dx = _hoverPt.x - srmx, dy = _hoverPt.y - srmy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist >= innerR && dist <= outerR) {
const angle = Math.atan2(dy, dx);
for (const opt of opts) {
let diff = angle - opt.angle;
while (diff > Math.PI) diff -= 2 * Math.PI;
while (diff < -Math.PI) diff += 2 * Math.PI;
if (Math.abs(diff) < Math.PI / 4) { hovKey = opt.key; break; }
}
}
}
ctx.save();
ctx.fillStyle = 'rgba(4,12,22,0.92)';
ctx.beginPath();
ctx.arc(srmx, srmy, outerR, 0, Math.PI * 2);
ctx.fill();
for (const opt of opts) {
const startAngle = opt.angle - Math.PI / 4;
const endAngle = opt.angle + Math.PI / 4;
const hov = hovKey === opt.key;
ctx.beginPath();
ctx.moveTo(srmx, srmy);
ctx.arc(srmx, srmy, outerR - 1, startAngle, endAngle);
ctx.closePath();
if (hov) { ctx.fillStyle = opt.color + '28'; ctx.fill(); }
ctx.strokeStyle = '#1a3048';
ctx.lineWidth = 1;
ctx.stroke();
}
ctx.strokeStyle = '#2a4060';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(srmx, srmy, outerR, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = 'rgba(4,12,22,0.95)';
ctx.beginPath();
ctx.arc(srmx, srmy, innerR, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#1a3048';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(srmx, srmy, innerR, 0, Math.PI * 2);
ctx.stroke();
if (weapon) {
const def = getWeaponDef(weapon);
ctx.font = '22px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#ffffff';
ctx.fillText(def?.icon || '?', srmx, hovKey ? srmy - 7 : srmy);
if (hovKey) {
const hovOpt = opts.find(o => o.key === hovKey);
ctx.font = '8px Orbitron, monospace';
ctx.letterSpacing = '1px';
ctx.fillStyle = hovOpt.color;
ctx.fillText(hovOpt.label, srmx, srmy + 10);
ctx.letterSpacing = '0px';
}
}
_sellRegion = null;
for (const opt of opts) {
const ix = srmx + Math.cos(opt.angle) * iconR;
const iy = srmy + Math.sin(opt.angle) * iconR;
const hov = hovKey === opt.key;
let holdProgress = 0;
if (opt.key === 'sell' && _sellHoldSlot === si) {
holdProgress = Math.min(1, (Date.now() - _sellHoldMs) / SELL_HOLD_DURATION);
}
ctx.font = (hov ? '22px' : '18px') + ' monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = hov ? opt.color : '#b8d8e8';
ctx.globalAlpha = hov ? 1 : 0.82;
ctx.fillText(opt.icon, ix, iy + 1);
ctx.globalAlpha = 1;
if (opt.key === 'sell' && holdProgress > 0) {
ctx.strokeStyle = '#ff3355';
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(ix, iy, 16, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * holdProgress);
ctx.stroke();
ctx.lineCap = 'butt';
}
if (opt.key === 'sell') {
_sellRegion = { x: srmx - outerR, y: srmy, w: outerR * 2, h: outerR, slot: si };
}
}
ctx.restore();
const HR = iconBtnR;
addHitRegion(srmx - HR, srmy - iconR - HR, HR * 2, HR * 2, () => { if (w) openWeaponDetail(si); });
const modes = ['nearest','strongest','weakest','fastest','furthest','group'];
addHitRegion(srmx + iconR - HR, srmy - HR, HR * 2, HR * 2, () => {
if (!w) return;
const idx = modes.indexOf(w.targeting || 'nearest');
setWeaponTargeting(w.instanceId, modes[(idx + 1) % modes.length]);
});
addHitRegion(srmx - HR, srmy + iconR - HR, HR * 2, HR * 2, () => { /* hold to sell */ });
addHitRegion(srmx - iconR - HR, srmy - HR, HR * 2, HR * 2, () => {
removeWeaponFromSlot(si);
_radialSlot = -1;
});
addHitRegion(0, 0, canvas.width, canvas.height, () => { _radialSlot = -1; });
}
}
function drawWeaponDetailOverlay() {
if (G.weaponDetailSlot < 0) { _sellRegion = null; return; }
_shopRightClick.length = 0;
const slot = G.weaponDetailSlot;
const weapon = G.weapons[slot];
if (!weapon) { closeWeaponDetail(); return; }
const def = getWeaponDef(weapon);
const W = canvas.width, H = canvas.height;
const PX = 235, PW = 860;
const PY = HUD_H + 8;
const PH = H - HUD_H - 16;
// Background dim
ctx.fillStyle = 'rgba(0,0,0,0.42)';
ctx.fillRect(0, 0, W, H);
// Panel bg + border
ctx.fillStyle = '#050c16';
ctx.strokeStyle = '#1a3048';
ctx.lineWidth = 1;
ctx.fillRect(PX, PY, PW, PH);
ctx.strokeRect(PX, PY, PW, PH);
const PAD = 16;
let y = PY;
// ── Header row ────────────────────────────────────────────────
const HDR_H = 48;
ctx.fillStyle = '#040c14';
ctx.fillRect(PX, y, PW, HDR_H);
ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(PX, y + HDR_H); ctx.lineTo(PX + PW, y + HDR_H); ctx.stroke();
// ◀ button
const NAV_W = 36, NAV_H = 30;
const prevHov = isHovered(PX + PAD, y + (HDR_H - NAV_H) / 2, NAV_W, NAV_H);
ctx.fillStyle = prevHov ? '#1a2838' : 'transparent';
ctx.strokeStyle = prevHov ? '#00d4ff' : '#1a3048'; ctx.lineWidth = 1;
ctx.fillRect(PX + PAD, y + (HDR_H - NAV_H) / 2, NAV_W, NAV_H);
ctx.strokeRect(PX + PAD, y + (HDR_H - NAV_H) / 2, NAV_W, NAV_H);
ctx.font = '14px Orbitron, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = prevHov ? '#00d4ff' : '#3a6080';
ctx.fillText('◀', PX + PAD + NAV_W / 2, y + HDR_H / 2);
addHitRegion(PX + PAD, y + (HDR_H - NAV_H) / 2, NAV_W, NAV_H, () => {
const filled = G.weapons.slice(0, G.tower.weaponSlots).map((w, i) => w ? i : -1).filter(i => i >= 0);
const idx = filled.indexOf(slot);
if (idx > 0) G.weaponDetailSlot = filled[idx - 1];
else if (filled.length > 1) G.weaponDetailSlot = filled[filled.length - 1];
_weaponDetailScrollY = 0;
});
// Weapon icon + name + element icons
const titleX = PX + PAD + NAV_W + 10;
ctx.font = '20px monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillStyle = '#ffffff'; ctx.fillText(def?.icon || '?', titleX, y + HDR_H / 2);
const iconW = 24;
ctx.font = '900 14px Orbitron, "Share Tech Mono", monospace'; ctx.letterSpacing = '2px';
ctx.fillStyle = '#b8d8e8'; ctx.fillText(def?.name || '', titleX + iconW + 6, y + HDR_H / 2);
ctx.letterSpacing = '0px';
// Element icons (skip physical — it's the default, not meaningful to display)
const elIcons = getWeaponElements(weapon).filter(el => el !== 'physical').map(el => ELEMENTS[el]?.icon || '').join(' ');
if (elIcons.trim()) {
ctx.font = '13px monospace'; ctx.textBaseline = 'middle';
ctx.font = '900 14px Orbitron, "Share Tech Mono", monospace'; ctx.letterSpacing = '2px';
const nameW = ctx.measureText(def?.name || '').width;
ctx.letterSpacing = '0px'; ctx.font = '13px monospace';
ctx.fillStyle = '#b8d8e8'; ctx.fillText(elIcons, titleX + iconW + 6 + nameW + 10, y + HDR_H / 2);
}
// Slot label centered
ctx.font = '10px Orbitron, monospace'; ctx.letterSpacing = '2px';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#3a6080';
ctx.fillText('· SOCKET ' + (slot + 1), PX + PW / 2, y + HDR_H / 2);
ctx.letterSpacing = '0px';
// ✕ close button — calculated first so ▶ can be placed to its left
const CBW = 80, CBH = 28;
const CBX = PX + PW - PAD - CBW, CBY = y + (HDR_H - CBH) / 2;
// ▶ button — 8px left of ✕ close so they don't overlap
const nextX = CBX - 8 - NAV_W;
const nextHov = isHovered(nextX, y + (HDR_H - NAV_H) / 2, NAV_W, NAV_H);
ctx.fillStyle = nextHov ? '#1a2838' : 'transparent';
ctx.strokeStyle = nextHov ? '#00d4ff' : '#1a3048'; ctx.lineWidth = 1;
ctx.fillRect(nextX, y + (HDR_H - NAV_H) / 2, NAV_W, NAV_H);
ctx.strokeRect(nextX, y + (HDR_H - NAV_H) / 2, NAV_W, NAV_H);
ctx.font = '14px Orbitron, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = nextHov ? '#00d4ff' : '#3a6080';
ctx.fillText('▶', nextX + NAV_W / 2, y + HDR_H / 2);
addHitRegion(nextX, y + (HDR_H - NAV_H) / 2, NAV_W, NAV_H, () => {
const filled = G.weapons.slice(0, G.tower.weaponSlots).map((w, i) => w ? i : -1).filter(i => i >= 0);
const idx = filled.indexOf(slot);
if (idx >= 0 && idx < filled.length - 1) G.weaponDetailSlot = filled[idx + 1];
else if (filled.length > 1) G.weaponDetailSlot = filled[0];
_weaponDetailScrollY = 0;
});
const cbHov = isHovered(CBX, CBY, CBW, CBH);
ctx.fillStyle = cbHov ? '#3d0808' : 'transparent';
ctx.strokeStyle = '#ff3355'; ctx.lineWidth = 1;
ctx.fillRect(CBX, CBY, CBW, CBH); ctx.strokeRect(CBX, CBY, CBW, CBH);
ctx.font = '10px Orbitron, monospace'; ctx.letterSpacing = '1px';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#ff3355';
ctx.fillText('✕ CLOSE', CBX + CBW / 2, CBY + CBH / 2);
ctx.letterSpacing = '0px';
addHitRegion(CBX, CBY, CBW, CBH, closeWeaponDetail);
y += HDR_H;
// ── Stats row ────────────────────────────────────────────────
const STAT_H = 40;
ctx.fillStyle = '#040a10';
ctx.fillRect(PX, y, PW, STAT_H);
ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(PX, y + STAT_H); ctx.lineTo(PX + PW, y + STAT_H); ctx.stroke();
const statPairs = [
['DMG', weapon.damage],
['RATE', weapon.fireRate + 'f'],
...(weapon.pierce ? [['PIERCE', weapon.pierce]] : []),
...(weapon.critChance ? [['CRIT', Math.round(weapon.critChance * 100) + '%']] : []),
...(weapon.chains ? [['CHAINS', weapon.chains]] : []),
...(weapon.aoeRadius ? [['AOE', weapon.aoeRadius]] : []),
...(weapon.targets > 1 ? [['TARGETS', weapon.targets]] : []),
];
const SPW = Math.floor(PW / Math.max(1, statPairs.length));
ctx.font = '9px "Share Tech Mono", monospace';
ctx.textBaseline = 'top';
for (let i = 0; i < statPairs.length; i++) {
const sx = PX + i * SPW;
ctx.fillStyle = '#3a6080'; ctx.textAlign = 'left'; ctx.fillText(statPairs[i][0], sx + PAD, y + 8);
ctx.fillStyle = '#b8d8e8'; ctx.fillText(String(statPairs[i][1]), sx + PAD, y + 22);
}
y += STAT_H;
// ── Targeting row ────────────────────────────────────────────
const TGT_H = 40;
ctx.fillStyle = '#030810';
ctx.fillRect(PX, y, PW, TGT_H);
ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(PX, y + TGT_H); ctx.lineTo(PX + PW, y + TGT_H); ctx.stroke();
ctx.font = '9px Orbitron, monospace'; ctx.letterSpacing = '2px';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#3a6080';
ctx.fillText('TARGET:', PX + PAD, y + TGT_H / 2);
ctx.letterSpacing = '0px';
const lblW = ctx.measureText('TARGET: ').width;
const tgtModes = ['nearest','strongest','weakest','fastest','furthest','group'];
const curMode = weapon.targeting || 'nearest';
const TPILL_W = 160, TPILL_H = 26;
const tpillX = PX + PAD + lblW + 10;
const tpillY = y + (TGT_H - TPILL_H) / 2;
const tpHov = isHovered(tpillX, tpillY, TPILL_W, TPILL_H);
ctx.fillStyle = tpHov ? '#0c1e30' : '#060e18';
ctx.strokeStyle = tpHov ? '#00d4ff' : '#1a3048'; ctx.lineWidth = 1;
ctx.fillRect(tpillX, tpillY, TPILL_W, TPILL_H);
ctx.strokeRect(tpillX, tpillY, TPILL_W, TPILL_H);
ctx.font = '10px Orbitron, monospace'; ctx.letterSpacing = '0.5px';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = tpHov ? '#00d4ff' : '#b8d8e8';
ctx.fillText(curMode.toUpperCase() + ' ▸', tpillX + TPILL_W / 2, tpillY + TPILL_H / 2);
ctx.letterSpacing = '0px';
const wid = weapon.instanceId;
addHitRegion(tpillX, tpillY, TPILL_W, TPILL_H, () => {
const idx = tgtModes.indexOf(G.weapons[G.weaponDetailSlot]?.targeting || 'nearest');
setWeaponTargeting(wid, tgtModes[(idx + 1) % tgtModes.length]);
});
y += TGT_H;
// ── Infuse slots (if weapon has infuse capability) ───────────
const infuseSlots = weapon.canInfuse3 ? 3 : weapon.canInfuse2 ? 2 : weapon.canInfuse ? 1 : 0;
if (infuseSlots > 0) {
const INF_H = 54;
ctx.fillStyle = '#040a10'; ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1;
ctx.fillRect(PX, y, PW, INF_H); ctx.strokeRect(PX, y, PW, INF_H);
ctx.font = '9px Orbitron, monospace'; ctx.letterSpacing = '2px';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#3a6080';
ctx.fillText('ELEMENTS:', PX + PAD, y + INF_H / 2);
ctx.letterSpacing = '0px';
const infW2 = ctx.measureText('ELEMENTS: ').width;
const SLT_W = 100, SLT_H = 32, SLT_GAP = 8;
for (let si = 0; si < infuseSlots; si++) {
const sx = PX + PAD + infW2 + 16 + si * (SLT_W + SLT_GAP);
const sy2 = y + (INF_H - SLT_H) / 2;
const el = weapon.elements?.[si];
const elDef = el ? ELEMENTS[el] : null;
const sHov = isHovered(sx, sy2, SLT_W, SLT_H);
ctx.fillStyle = el ? '#0a1828' : '#060e18';
ctx.strokeStyle = el ? (ELEMENTS[el]?.color || '#1a3048') : (sHov ? '#00aaff' : '#1a3048');
ctx.lineWidth = 1;
ctx.fillRect(sx, sy2, SLT_W, SLT_H); ctx.strokeRect(sx, sy2, SLT_W, SLT_H);
ctx.font = '11px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = el ? '#ffffff' : '#1a3240';
ctx.fillText(elDef ? elDef.icon + ' ' + elDef.name : '+ INFUSE', sx + SLT_W / 2, sy2 + SLT_H / 2);
}
ctx.beginPath(); ctx.moveTo(PX, y + INF_H); ctx.lineTo(PX + PW, y + INF_H); ctx.stroke();
y += INF_H;
}
// ── Scrollable upgrades body ─────────────────────────────────
const FOOTER_H = 52;
const bodyY = y;
const bodyH = PH - (y - PY) - FOOTER_H;
ctx.save();
ctx.beginPath(); ctx.rect(PX, bodyY, PW, bodyH); ctx.clip();
const upgTree = WEAPON_UPGRADE_TREES[weapon.defId] || [];
const bought = G.weaponUpgradesBought[weapon.instanceId] || [];
// Group upgrades into chains based on `requires` — find root nodes (no requirements)
let ugY = bodyY + PAD - _weaponDetailScrollY;
const cx2 = PX + PAD, cw2 = PW - PAD * 2;
if (upgTree.length === 0) {
ctx.font = '11px "Share Tech Mono", monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#1a3048';
ctx.fillText('No upgrades available for this weapon.', PX + PW / 2, bodyY + bodyH / 2);
} else {
// Build chains: group nodes into linear sequences
const visited = new Set();
const chains = [];
const buildChain = (startNode) => {
const chain = [];
let cur = startNode;
while (cur && !visited.has(cur.id)) {
visited.add(cur.id);
chain.push(cur);
const next = upgTree.find(u => u.requires && u.requires.includes(cur.id) && !visited.has(u.id));
cur = next || null;
}
return chain;
};
// Iterate and build all chains
for (const root of upgTree) {
if (!visited.has(root.id)) {
chains.push(buildChain(root));
}
}
for (const chain of chains) {
if (chain.length === 0) continue;
// Calculate row width
let rowW = 0;
for (let i = 0; i < chain.length; i++) {
if (i > 0) rowW += _SH_ARR_W;
rowW += _SH_UPG_W;
}
let nx = cx2 + Math.max(0, (cw2 - rowW) / 2);
const screenY = ugY;
if (screenY + _SH_UPG_H >= bodyY && screenY < bodyY + bodyH) {
for (let i = 0; i < chain.length; i++) {
const upg = chain[i];
if (i > 0) {
ctx.font = '13px "Share Tech Mono", monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#3a5060';
ctx.fillText('→', nx + _SH_ARR_W / 2, screenY + _SH_UPG_H / 2);
nx += _SH_ARR_W;
}
const isBought = bought.includes(upg.id);
const reqsMet = !upg.requires || upg.requires.every(r => bought.includes(r));
const cantAfford = !isBought && reqsMet && spendableCredits() < upg.cost;
const locked = !isBought && !reqsMet;
const uid2 = upg.id;
const wIid = weapon.instanceId;
_shopUpgNode(
nx, screenY, upg, isBought, locked, cantAfford,
(!isBought && reqsMet && !cantAfford) ? () => buyWeaponUpgrade(wIid, uid2) : null,
(isBought && !upg.repeatable) ? () => refundWeaponUpgrade(wIid, uid2) : null
);
nx += _SH_UPG_W;
}
}
ugY += _SH_UPG_H + 14;
}
_weaponDetailScrollMax = Math.max(0, ugY + _weaponDetailScrollY - (bodyY + bodyH - PAD));
}
ctx.restore();
// ── Footer ────────────────────────────────────────────────────
const fy = PY + PH - FOOTER_H;
ctx.fillStyle = '#040c14';
ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1;
ctx.fillRect(PX, fy, PW, FOOTER_H);
ctx.beginPath(); ctx.moveTo(PX, fy); ctx.lineTo(PX + PW, fy); ctx.stroke();
// UNEQUIP TO INVENTORY button
const UBW = 200, UBH = 32;
const UBX = PX + PAD, UBY = fy + (FOOTER_H - UBH) / 2;
const ubHov = isHovered(UBX, UBY, UBW, UBH);
ctx.fillStyle = ubHov ? '#0c1e30' : 'transparent';
ctx.strokeStyle = ubHov ? '#00d4ff' : '#1a3048'; ctx.lineWidth = 1;
ctx.fillRect(UBX, UBY, UBW, UBH); ctx.strokeRect(UBX, UBY, UBW, UBH);
ctx.font = '10px Orbitron, monospace'; ctx.letterSpacing = '1px';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = ubHov ? '#00d4ff' : '#3a6080';
ctx.fillText('UNEQUIP TO INVENTORY', UBX + UBW / 2, UBY + UBH / 2);
ctx.letterSpacing = '0px';
addHitRegion(UBX, UBY, UBW, UBH, () => {
removeWeaponFromSlot(slot);
closeWeaponDetail();
});
// SELL button with hold bar
const SBW = 180, SBH = 32;
const SBX = PX + PW - PAD - SBW, SBY = fy + (FOOTER_H - SBH) / 2;
const sellPrice = calcSellPrice(weapon);
let sellHoldP = 0;
if (_sellHoldSlot === slot) {
sellHoldP = Math.min(1, (Date.now() - _sellHoldMs) / SELL_HOLD_DURATION);
}
ctx.fillStyle = '#0a0808';
ctx.strokeStyle = '#ff3355'; ctx.lineWidth = 1;
ctx.fillRect(SBX, SBY, SBW, SBH); ctx.strokeRect(SBX, SBY, SBW, SBH);
if (sellHoldP > 0) {
ctx.fillStyle = '#ff335566';
ctx.fillRect(SBX + 1, SBY + 1, (SBW - 2) * sellHoldP, SBH - 2);
}
ctx.font = '10px Orbitron, monospace'; ctx.letterSpacing = '1px';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#ff3355';
ctx.fillText('SELL — ' + sellPrice + '¢', SBX + SBW / 2, SBY + SBH / 2);
ctx.letterSpacing = '0px';
_sellRegion = { x: SBX, y: SBY, w: SBW, h: SBH, slot };
// Click outside panel = close
addHitRegion(PX, PY, PW, PH, () => {});
addHitRegion(0, 0, canvas.width, canvas.height, closeWeaponDetail);
}
// ── THREAT LEVEL PANEL ────────────────────────────────────────
function drawThreatPanel() {
if (!G.threatOpen) return;
const W = canvas.width, H = canvas.height;
const PW = 800, PX = (W - PW) / 2;
const HDR_H = 54, TL_ROW_H = 50, GAP = 4;
const PH = HDR_H + 14 + DIFFICULTY_TIERS.length * (TL_ROW_H + GAP) + 20;
const PY = HUD_H + 12;
ctx.fillStyle = 'rgba(0,0,0,0.62)';
ctx.fillRect(0, 0, W, H);
ctx.save();
ctx.fillStyle = '#040c14'; ctx.strokeStyle = '#ff6b3544'; ctx.lineWidth = 1;
ctx.fillRect(PX, PY, PW, PH); ctx.strokeRect(PX, PY, PW, PH);
ctx.fillStyle = '#060e18';
ctx.fillRect(PX, PY, PW, HDR_H);
ctx.strokeStyle = '#ff6b3544'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(PX, PY + HDR_H); ctx.lineTo(PX + PW, PY + HDR_H); ctx.stroke();
ctx.font = '900 14px Orbitron, monospace'; ctx.letterSpacing = '5px';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillStyle = '#ff6b35'; ctx.shadowColor = '#ff6b3544'; ctx.shadowBlur = 10;
ctx.fillText('THREAT LEVEL', PX + 24, PY + HDR_H / 2);
ctx.shadowBlur = 0; ctx.letterSpacing = '0px';
ctx.font = '15px Orbitron, monospace';
ctx.textAlign = 'center'; ctx.fillStyle = '#ffd700';
ctx.fillText('💰 ' + G.credits + '¢', PX + PW / 2, PY + HDR_H / 2);
const CBW = 100, CBH = 30, CBX = PX + PW - 18 - CBW, CBY = PY + (HDR_H - CBH) / 2;
const cbHov = isHovered(CBX, CBY, CBW, CBH);
ctx.fillStyle = cbHov ? '#3d0808' : 'transparent'; ctx.strokeStyle = '#ff3355'; ctx.lineWidth = 1;
ctx.fillRect(CBX, CBY, CBW, CBH); ctx.strokeRect(CBX, CBY, CBW, CBH);
ctx.font = '10px Orbitron, monospace'; ctx.letterSpacing = '1.5px';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#ff3355';
ctx.fillText('✕ CLOSE', CBX + CBW / 2, CBY + CBH / 2);
ctx.letterSpacing = '0px';
addHitRegion(CBX, CBY, CBW, CBH, closeThreatPanel);
const cx = PX + 18, cw = PW - 36;
let ry = PY + HDR_H + 14;
for (const tier of DIFFICULTY_TIERS) {
const isActive = G.difficultyTier === tier.id;
const isUnlocked = G.unlockedTiers.includes(tier.id);
const canAfford = tier.id === 0 || spendableCredits() >= tier.unlockCost;
const rowHov = !isActive && isHovered(cx, ry, cw, TL_ROW_H);
ctx.fillStyle = isActive ? '#0c2010' : (rowHov ? '#090f18' : '#060e18');
ctx.strokeStyle = isActive ? '#00ff88' : (rowHov ? '#ff6b35' : '#1a3048');
ctx.lineWidth = 1;
ctx.fillRect(cx, ry, cw, TL_ROW_H); ctx.strokeRect(cx, ry, cw, TL_ROW_H);
ctx.font = '12px Orbitron, monospace'; ctx.letterSpacing = '1px';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillStyle = isActive ? '#00ff88' : (isUnlocked ? '#b8d8e8' : '#3a6080');
ctx.fillText(tier.name, cx + 14, ry + TL_ROW_H / 2 - 9);
ctx.letterSpacing = '0px';
ctx.font = '10px "Share Tech Mono", monospace';
ctx.fillStyle = '#3a6080';
const multStr = tier.id === 0
? 'Base difficulty — standard enemy stats and rewards'
: `HP ×${tier.hpMult} Spd ×${tier.speedMult} Arm ×${tier.armorMult} Rew ×${tier.rewardMult}`;
ctx.fillText(multStr, cx + 14, ry + TL_ROW_H / 2 + 9);
const BW = 140, BH = 34, BX = cx + cw - BW - 12, BY = ry + (TL_ROW_H - BH) / 2;
if (isActive) {
ctx.fillStyle = '#0c2010'; ctx.strokeStyle = '#00ff88'; ctx.lineWidth = 1;
ctx.fillRect(BX, BY, BW, BH); ctx.strokeRect(BX, BY, BW, BH);
ctx.font = '10px Orbitron, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = '#00ff88'; ctx.fillText('ACTIVE', BX + BW / 2, BY + BH / 2);
} else if (isUnlocked) {
const bhov = isHovered(BX, BY, BW, BH);
ctx.fillStyle = bhov ? '#0c1e30' : 'transparent'; ctx.strokeStyle = '#00d4ff'; ctx.lineWidth = 1;
ctx.fillRect(BX, BY, BW, BH); ctx.strokeRect(BX, BY, BW, BH);
ctx.font = '10px Orbitron, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = '#00d4ff'; ctx.fillText('SWITCH', BX + BW / 2, BY + BH / 2);
const tid = tier.id;
addHitRegion(BX, BY, BW, BH, () => setThreatTier(tid));
} else {
const bhov = isHovered(BX, BY, BW, BH);
ctx.fillStyle = (bhov && canAfford) ? '#120800' : 'transparent';
ctx.strokeStyle = canAfford ? '#ff6b35' : '#1a2838'; ctx.lineWidth = 1;
ctx.fillRect(BX, BY, BW, BH); ctx.strokeRect(BX, BY, BW, BH);
ctx.font = '10px Orbitron, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = canAfford ? '#ff6b35' : '#3a2010';
ctx.fillText('UNLOCK ' + tier.unlockCost + '¢', BX + BW / 2, BY + BH / 2);
if (canAfford) { const tid = tier.id; addHitRegion(BX, BY, BW, BH, () => unlockThreatTier(tid)); }
}
ry += TL_ROW_H + GAP;
}
ctx.restore();
}
// ── PRESTIGE CONFIRMATION DIALOG ──────────────────────────────
function drawPrestigeConfirm() {
if (!G.prestigeOpen) return;
const W = canvas.width, H = canvas.height;
const PW = 580, PH = 320;
const PX = (W - PW) / 2, PY = (H - PH) / 2;
const cost = prestigeCost();
const canAfford = G.credits >= cost;
const lvl = G.prestigeLevel || 0;
ctx.fillStyle = 'rgba(0,0,0,0.78)';
ctx.fillRect(0, 0, W, H);
ctx.save();
ctx.fillStyle = '#040c14'; ctx.strokeStyle = '#c77dff44'; ctx.lineWidth = 1;
ctx.fillRect(PX, PY, PW, PH); ctx.strokeRect(PX, PY, PW, PH);
const HDR_H = 52;
ctx.fillStyle = '#060e18'; ctx.fillRect(PX, PY, PW, HDR_H);
ctx.strokeStyle = '#c77dff44'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(PX, PY + HDR_H); ctx.lineTo(PX + PW, PY + HDR_H); ctx.stroke();
ctx.font = '900 13px Orbitron, monospace'; ctx.letterSpacing = '4px';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = '#c77dff'; ctx.shadowColor = '#c77dff44'; ctx.shadowBlur = 12;
ctx.fillText('PRESTIGE CONFIRMATION', W / 2, PY + HDR_H / 2);
ctx.shadowBlur = 0; ctx.letterSpacing = '0px';
let ty = PY + HDR_H + 20;
ctx.font = '13px Orbitron, monospace'; ctx.letterSpacing = '1px';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillStyle = '#c77dff';
ctx.fillText(`LEVEL ${lvl}${lvl + 1}`, W / 2, ty);
ctx.letterSpacing = '0px'; ty += 26;
ctx.font = '11px "Share Tech Mono", monospace';
ctx.fillStyle = '#ffd700';
ctx.fillText('Cost: ' + cost + '¢' + (!canAfford ? ' (insufficient credits)' : ''), W / 2, ty); ty += 22;
ctx.fillStyle = '#ff6b35';
ctx.fillText('RESETS: credits → 150¢ · all tower and weapon upgrades', W / 2, ty); ty += 18;
ctx.fillStyle = '#00d4ff';
ctx.fillText('KEEPS: unlocked threat tiers · permanent stat bonuses', W / 2, ty); ty += 26;
// Bonus preview
const FRACTION = 0.25;
const preview = { ...(G.permanentBonuses || {}) };
for (const upg of TOWER_UPGRADE_TREE) {
if (!G.towerUpgradesBought.includes(upg.id) || upg.repeatable) continue;
const e = upg.effect;
if (e.maxHp) preview.maxHp = (preview.maxHp || 0) + e.maxHp * FRACTION;
if (e.armor) preview.armor = (preview.armor || 0) + e.armor * FRACTION;
if (e.aimSpeed) preview.aimSpeed = (preview.aimSpeed || 0) + e.aimSpeed * FRACTION;
if (e.range) preview.range = (preview.range || 0) + e.range * FRACTION;
}
const parts = [];
if (preview.maxHp) parts.push(`+${Math.floor(preview.maxHp)} HP`);
if (preview.armor) parts.push(`+${Math.floor(preview.armor)} Armor`);
if (preview.range) parts.push(`+${Math.floor(preview.range)} Range`);
if (preview.aimSpeed) parts.push(`+aim`);
ctx.fillStyle = parts.length > 0 ? '#c77dff' : '#3a6080';
ctx.fillText(parts.length > 0
? 'NEW PERMANENT BONUSES: ' + parts.join(' ')
: 'No upgrades owned — no bonuses will bank this prestige',
W / 2, ty);
// Buttons
const BTN_W = 190, BTN_H = 42;
const BTN_Y = PY + PH - 58;
const CANCEL_X = W / 2 - BTN_W - 14;
const CONFIRM_X = W / 2 + 14;
const cancelHov = isHovered(CANCEL_X, BTN_Y, BTN_W, BTN_H);
ctx.fillStyle = cancelHov ? '#1a0808' : 'transparent'; ctx.strokeStyle = '#ff3355'; ctx.lineWidth = 1;
ctx.fillRect(CANCEL_X, BTN_Y, BTN_W, BTN_H); ctx.strokeRect(CANCEL_X, BTN_Y, BTN_W, BTN_H);
ctx.font = '11px Orbitron, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = '#ff3355'; ctx.fillText('CANCEL', CANCEL_X + BTN_W / 2, BTN_Y + BTN_H / 2);
addHitRegion(CANCEL_X, BTN_Y, BTN_W, BTN_H, closePrestigeDialog);
const confirmHov = canAfford && isHovered(CONFIRM_X, BTN_Y, BTN_W, BTN_H);
ctx.fillStyle = confirmHov ? '#1a0830' : 'transparent';
ctx.strokeStyle = canAfford ? '#c77dff' : '#3a2050'; ctx.lineWidth = 1;
ctx.fillRect(CONFIRM_X, BTN_Y, BTN_W, BTN_H); ctx.strokeRect(CONFIRM_X, BTN_Y, BTN_W, BTN_H);
// Hold progress fill
if (canAfford && _prestigeHoldMs > 0) {
const holdProgress = Math.min(1, (Date.now() - _prestigeHoldMs) / PRESTIGE_HOLD_DURATION);
ctx.fillStyle = '#c77dff33';
ctx.fillRect(CONFIRM_X, BTN_Y, BTN_W * holdProgress, BTN_H);
}
ctx.font = '11px Orbitron, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = canAfford ? '#c77dff' : '#3a2050';
ctx.fillText(canAfford ? 'HOLD TO CONFIRM' : 'CONFIRM PRESTIGE', CONFIRM_X + BTN_W / 2, BTN_Y + BTN_H / 2);
if (canAfford) _prestigeHoldRegion = { x: CONFIRM_X, y: BTN_Y, w: BTN_W, h: BTN_H };
else _prestigeHoldRegion = null;
ctx.restore();
}
// ── DRAG GHOST ────────────────────────────────────────────────