Files
siege-protocol/js/renderer-overlays.js
T
44r0n7 626879ed0c 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
2026-06-17 11:58:17 -04:00

999 lines
41 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.
// ═══ renderer-overlays.js ═══
// ============================================================
// 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;
// Game over takes over hit detection — clear HUD regions
clearHitRegions();
// Full-screen dark tint
ctx.fillStyle = 'rgba(0,0,0,0.88)';
ctx.fillRect(0, 0, W, H);
const BW = 460, BH = 270;
const BX = (W - BW) / 2, BY = (H - BH) / 2;
ctx.fillStyle = '#060e16';
ctx.strokeStyle = '#1a3048';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.rect(BX, BY, BW, BH); ctx.fill(); ctx.stroke();
const isBankrupt = G.isBankrupt;
const titleColor = isBankrupt ? '#ffd700' : '#ff3355';
const title = isBankrupt ? 'BANKRUPT' : 'TOWER FALLEN';
const subText = isBankrupt
? 'You ran out of credits with no way to recover.'
: 'The defense has been breached.';
ctx.save();
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.font = '900 30px Orbitron, "Share Tech Mono", monospace';
ctx.fillStyle = titleColor;
ctx.shadowColor = titleColor;
ctx.shadowBlur = 24;
ctx.fillText(title, W / 2, BY + 28);
ctx.shadowBlur = 0;
ctx.font = '14px Orbitron, "Share Tech Mono", monospace';
ctx.fillStyle = '#ffd700';
ctx.fillText(`Score: ${G.score} — Kills: ${G.totalKills}`, W / 2, BY + 78);
ctx.font = '12px "Share Tech Mono", monospace';
if (G._isNewBest) {
ctx.fillStyle = '#ffd700';
ctx.fillText('★ NEW BEST!', W / 2, BY + 108);
} else if (_best) {
ctx.fillStyle = '#3a6080';
ctx.fillText(`Best: ${_best.score}${_best.kills} kills`, W / 2, BY + 108);
}
ctx.font = '11px "Share Tech Mono", monospace';
ctx.fillStyle = '#3a6080';
ctx.fillText(subText, W / 2, BY + 134);
// RESTART button
const RBW = 220, RBH = 44;
const RBX = W / 2 - RBW / 2;
const RBY = BY + 192;
const restHov = isHovered(RBX, RBY, RBW, RBH);
ctx.fillStyle = restHov ? '#00d4ff' : 'transparent';
ctx.strokeStyle = '#00d4ff';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.rect(RBX, RBY, RBW, RBH); ctx.fill(); ctx.stroke();
if (restHov) { ctx.shadowColor = 'rgba(0,212,255,0.35)'; ctx.shadowBlur = 20; }
ctx.font = '12px Orbitron, "Share Tech Mono", monospace';
ctx.textBaseline = 'middle';
ctx.fillStyle = restHov ? '#000000' : '#00d4ff';
ctx.fillText('RESTART MISSION', W / 2, RBY + RBH / 2);
ctx.shadowBlur = 0;
ctx.restore();
addHitRegion(RBX, RBY, RBW, RBH, restartGame);
}
// ── PAUSE OVERLAY ─────────────────────────────────────────────
function drawPauseOverlay() {
if (!document.body.classList.contains('paused')) return;
const W = canvas.width, H = canvas.height;
ctx.fillStyle = 'rgba(0,0,0,0.58)';
ctx.fillRect(0, 0, W, H);
const BW = 420, BH = 118;
const BX = (W - BW) / 2, BY = (H - BH) / 2;
ctx.save();
ctx.fillStyle = 'rgba(6,14,22,0.94)';
ctx.strokeStyle = '#1a3048';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.rect(BX, BY, BW, BH); ctx.fill(); ctx.stroke();
ctx.shadowColor = 'rgba(0,212,255,0.18)';
ctx.shadowBlur = 34;
ctx.strokeRect(BX, BY, BW, BH);
ctx.shadowBlur = 0;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.font = '900 34px Orbitron, "Share Tech Mono", monospace';
ctx.fillStyle = '#ffd700';
ctx.shadowColor = '#ffd700';
ctx.shadowBlur = 22;
ctx.fillText('PAUSED', W / 2, BY + 18);
ctx.shadowBlur = 0;
ctx.font = '11px Orbitron, "Share Tech Mono", monospace';
ctx.fillStyle = '#3a6080';
ctx.fillText('Esc resumes · Space opens armory · I opens inventory', W / 2, BY + 78);
ctx.restore();
}
// ── MOUNT POINT TOOLTIP ───────────────────────────────────────
function _drawMountTooltip(mx, my, weapon, socketR) {
const def = getWeaponDef(weapon);
if (!def) return;
const TW = 168, TH = 58;
let tx = mx + socketR + 10;
let ty = my - TH / 2;
if (tx + TW > 1330) tx = mx - socketR - 10 - TW;
ty = Math.max(68, Math.min(900 - TH - 4, ty));
ctx.save();
ctx.fillStyle = '#050d18';
ctx.strokeStyle = '#00d4ff';
ctx.lineWidth = 1;
ctx.fillRect(tx, ty, TW, TH);
ctx.strokeRect(tx, ty, TW, TH);
ctx.font = '10px Orbitron, monospace';
ctx.letterSpacing = '1px';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillStyle = '#00d4ff';
ctx.fillText((def.icon || '') + ' ' + def.name, tx + 8, ty + 8);
ctx.letterSpacing = '0px';
const parts = [];
if (typeof def.damage === 'number') parts.push(`DMG ${def.damage}`);
if (typeof def.fireRate === 'number') parts.push(`RPM ${Math.round(3600 / def.fireRate)}`);
const el = weapon.elements?.[0] ? ELEMENTS[weapon.elements[0]] : null;
if (el) parts.push(el.icon + ' ' + el.name);
ctx.font = '9px "Share Tech Mono", monospace';
ctx.fillStyle = '#7aaabb';
ctx.fillText(parts.join(' '), tx + 8, ty + 26);
ctx.fillStyle = '#3a6080';
ctx.fillText('Shift+click to interact', tx + 8, ty + 41);
ctx.restore();
}
// ── MOUNT POINT INTERACTION ───────────────────────────────────
function drawMountInteraction(cx, cy) {
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 mountAngle = HARDPOINT_BASE_ANGLE + (slotIndex / totalSlots) * Math.PI * 2;
if (spreadMode) {
const actual = getSlotHardpoint(slotIndex, cx, cy, totalSlots);
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(smx - dropR, smy - dropR, dropR * 2, dropR * 2);
const dragging = _dragWeapon !== null;
// Dashed line from actual hardpoint to spread circle
ctx.save();
ctx.strokeStyle = '#00aaff77';
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 6]);
ctx.beginPath();
ctx.moveTo(toSX(actual.x), toSY(actual.y));
ctx.lineTo(smx, smy);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
// 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(smx, smy, dropR + 5, 0, Math.PI * 2); ctx.stroke();
ctx.shadowBlur = 0;
ctx.restore();
}
ctx.fillStyle = hov ? '#0c1820' : '#050d16';
ctx.strokeStyle = installed
? (hov ? '#ffd700' : hpColor + 'aa')
: (hov ? '#00aaff' : '#1a3240');
ctx.lineWidth = hov ? 2.5 : 1.5;
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}`, smx, smy - (installed ? 8 : 0));
ctx.letterSpacing = '0px';
if (installed) {
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 || '?', 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('+', smx, smy);
}
// _mountDropZones stay in world space — tested against pt.worldX/worldY
_mountDropZones.push({ x: mx, y: my, r: dropR, 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 ────────────────────────────────────────────────
function drawDragGhost() {
if (!_dragWeapon || !_hoverPt) return;
const def = getWeaponDef(_dragWeapon);
if (!def) return;
const GW = 90, GH = 68;
const gx = _hoverPt.x - GW / 2;
const gy = _hoverPt.y - GH / 2;
ctx.save();
ctx.globalAlpha = 0.85;
ctx.fillStyle = '#060e16';
ctx.strokeStyle = '#ffd700';
ctx.lineWidth = 2;
ctx.fillRect(gx, gy, GW, GH);
ctx.strokeRect(gx, gy, GW, GH);
ctx.globalAlpha = 1;
ctx.font = '22px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#ffffff';
ctx.fillText(def.icon || '?', gx + GW / 2, gy + GH / 2 - 8);
ctx.font = '8px Orbitron, monospace';
ctx.fillStyle = '#b8d8e8';
ctx.fillText(def.name, gx + GW / 2, gy + GH / 2 + 14);
ctx.restore();
}