626879ed0c
- 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
999 lines
41 KiB
JavaScript
999 lines
41 KiB
JavaScript
// ═══ 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();
|
||
}
|