// ═══ 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(); }