diff --git a/public/js/geometry/instruments.js b/public/js/geometry/instruments.js index 5fceb5c..91122df 100644 --- a/public/js/geometry/instruments.js +++ b/public/js/geometry/instruments.js @@ -17,1251 +17,1272 @@ export const SPECIAL_INSTRUMENTS = [ 'plasma_sphere' ]; - // --- Helper: Dual Vintage Analog VU Meters --- function drawAnalogVuMeters(targetCtx, width, height, leftLevel, rightLevel, currentTime, cfg) { - targetCtx.save(); - const meterW = Math.min(370, width * 0.24); - const meterH = meterW * 0.62; - const gap = 34; - const totalW = meterW * 2 + gap; - const startX = -totalW / 2; - const startY = -meterH / 2; - - const meters = [ - { x: startX, label: 'CH 1 • LEFT', level: leftLevel, needleKey: 'leftNeedle', velKey: 'leftVel' }, - { x: startX + meterW + gap, label: 'CH 2 • RIGHT', level: rightLevel, needleKey: 'rightNeedle', velKey: 'rightVel' } - ]; - - for (const m of meters) { - targetCtx.save(); - targetCtx.translate(m.x, startY); - - // 1. Meter Outer Housing - targetCtx.fillStyle = '#0f131a'; - targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.2)'; - targetCtx.lineWidth = 3; - targetCtx.shadowColor = 'rgba(0, 0, 0, 0.75)'; - targetCtx.shadowBlur = 16; - targetCtx.beginPath(); - if (targetCtx.roundRect) targetCtx.roundRect(0, 0, meterW, meterH, 12); - else targetCtx.rect(0, 0, meterW, meterH); - targetCtx.fill(); - targetCtx.stroke(); - targetCtx.shadowBlur = 0; - - // 2. Vintage Dial Face (Illuminated warm parchment) - const dialPad = 10; - const dialW = meterW - dialPad * 2; - const dialH = meterH - dialPad * 2; - const dialGrad = targetCtx.createLinearGradient(0, dialPad, 0, dialPad + dialH); - dialGrad.addColorStop(0, '#fff4dc'); - dialGrad.addColorStop(0.85, '#fae5b8'); - dialGrad.addColorStop(1, '#e5ca95'); - targetCtx.fillStyle = dialGrad; - targetCtx.beginPath(); - if (targetCtx.roundRect) targetCtx.roundRect(dialPad, dialPad, dialW, dialH, 8); - else targetCtx.rect(dialPad, dialPad, dialW, dialH); - targetCtx.fill(); - - // Dial Typography - targetCtx.fillStyle = '#1a1a1a'; - targetCtx.font = `800 ${Math.round(meterW * 0.05)}px 'Orbitron', sans-serif`; - targetCtx.textAlign = 'center'; - targetCtx.fillText('VU', dialPad + dialW / 2, dialPad + dialH * 0.32); - - targetCtx.font = `700 ${Math.round(meterW * 0.034)}px 'Inter', sans-serif`; - targetCtx.fillStyle = '#444444'; - targetCtx.fillText(m.label, dialPad + dialW / 2, dialPad + dialH * 0.46); - - // Arc Scale Geometry - const pivotX = dialPad + dialW / 2; - const pivotY = dialPad + dialH * 1.15; - const arcR = dialH * 0.98; - const startAng = -Math.PI * 0.72; - const zeroDbAng = -Math.PI * 0.53; - const endAng = -Math.PI * 0.38; - - // Black Scale Line (-20 to 0 dB) - targetCtx.strokeStyle = '#1b1b1b'; - targetCtx.lineWidth = 2; - targetCtx.beginPath(); - targetCtx.arc(pivotX, pivotY, arcR, startAng, zeroDbAng); - targetCtx.stroke(); - - // Red Scale Line (0 to +3 dB Overload) - targetCtx.strokeStyle = '#d4142e'; - targetCtx.lineWidth = 3.5; - targetCtx.beginPath(); - targetCtx.arc(pivotX, pivotY, arcR, zeroDbAng, endAng); - targetCtx.stroke(); - - // Scale Ticks - const scaleTicks = [ - { val: -20, t: 0.0 }, - { val: -10, t: 0.25 }, - { val: -7, t: 0.42 }, - { val: -5, t: 0.55 }, - { val: -3, t: 0.70 }, - { val: 0, t: 0.82 }, - { val: +1, t: 0.88 }, - { val: +2, t: 0.94 }, - { val: +3, t: 1.0 } - ]; - - for (const tick of scaleTicks) { - const ang = startAng + tick.t * (endAng - startAng); - const tickLen = (tick.val === 0 || tick.val === -20 || tick.val === 3) ? 8 : 4; - const x1 = pivotX + Math.cos(ang) * arcR; - const y1 = pivotY + Math.sin(ang) * arcR; - const x2 = pivotX + Math.cos(ang) * (arcR - tickLen); - const y2 = pivotY + Math.sin(ang) * (arcR - tickLen); - - targetCtx.strokeStyle = tick.val >= 0 ? '#d4142e' : '#1b1b1b'; - targetCtx.lineWidth = tick.val === 0 ? 2.5 : 1.5; - targetCtx.beginPath(); - targetCtx.moveTo(x1, y1); - targetCtx.lineTo(x2, y2); - targetCtx.stroke(); - - if (tick.val === -20 || tick.val === -7 || tick.val === 0 || tick.val === 3) { - const tx = pivotX + Math.cos(ang) * (arcR - 16); - const ty = pivotY + Math.sin(ang) * (arcR - 16); - targetCtx.fillStyle = tick.val >= 0 ? '#d4142e' : '#222222'; - targetCtx.font = `700 ${Math.round(meterW * 0.03)}px 'Inter', sans-serif`; - targetCtx.fillText(tick.val > 0 ? `+${tick.val}` : `${tick.val}`, tx, ty); - } - } - - // Overload Peak LED (0 VU reference is at 0.82 on the scale) - const ledX = dialPad + dialW - 22; - const ledY = dialPad + 18; - const isPeak = (m.level >= 0.82); - targetCtx.beginPath(); - targetCtx.arc(ledX, ledY, 5, 0, Math.PI * 2); - if (isPeak) { - targetCtx.fillStyle = '#ff1122'; - targetCtx.shadowColor = '#ff2244'; - targetCtx.shadowBlur = 10; - } else { - targetCtx.fillStyle = '#441014'; - targetCtx.shadowBlur = 0; - } - targetCtx.fill(); - targetCtx.shadowBlur = 0; - - // Ballistic Needle Physics Simulation (IEC 60268-17 VU response) - const targetAngle = startAng + Math.max(0, Math.min(1.0, m.level)) * (endAng - startAng); - const spring = 0.26; - const damping = 0.68; - state.vuMeter[m.velKey] = (state.vuMeter[m.velKey] + (targetAngle - state.vuMeter[m.needleKey]) * spring) * damping; - state.vuMeter[m.needleKey] += state.vuMeter[m.velKey]; - state.vuMeter[m.needleKey] = Math.max(startAng - 0.02, Math.min(endAng + 0.02, state.vuMeter[m.needleKey])); - - // Draw Needle - const curAng = state.vuMeter[m.needleKey]; - const needleLen = arcR + 4; - const nx = pivotX + Math.cos(curAng) * needleLen; - const ny = pivotY + Math.sin(curAng) * needleLen; - - targetCtx.strokeStyle = '#1b1b1b'; - targetCtx.lineWidth = 2.0; - targetCtx.lineCap = 'round'; - targetCtx.shadowColor = 'rgba(0, 0, 0, 0.3)'; - targetCtx.shadowBlur = 3; - targetCtx.shadowOffsetX = 2; - targetCtx.shadowOffsetY = 2; - - targetCtx.beginPath(); - targetCtx.moveTo(pivotX, pivotY); - targetCtx.lineTo(nx, ny); - targetCtx.stroke(); - targetCtx.shadowColor = 'transparent'; - - // Pivot Cover Cap - targetCtx.fillStyle = '#222630'; - targetCtx.beginPath(); - targetCtx.arc(pivotX, pivotY, 13, 0, Math.PI * 2); - targetCtx.fill(); - targetCtx.strokeStyle = '#444c60'; - targetCtx.lineWidth = 1.5; - targetCtx.stroke(); - - targetCtx.restore(); - } - targetCtx.restore(); - } - - // --- SPECIAL INSTRUMENT 1: Stereo Goniometer / Lissajous Scope --- function drawGoniometerScope(targetCtx, width, height, timePayload, currentTime, cfg) { - targetCtx.save(); - const radius = Math.min(width, height) * 0.28 * (state.currentPulse || 1.0); - const color1 = cfg.colorGrad1 || '#00ff88'; - const color2 = cfg.colorGrad2 || '#00f0ff'; - - // 1. Polar Graticule & HUD Circles - targetCtx.strokeStyle = 'rgba(0, 240, 255, 0.22)'; - targetCtx.lineWidth = 1.2; - for (const rMul of [0.33, 0.66, 1.0]) { - targetCtx.beginPath(); - targetCtx.arc(0, 0, radius * rMul, 0, Math.PI * 2); - targetCtx.stroke(); - } - - // 2. Diagonal 45° L/R Axes and Mid/Side Axes - targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.18)'; - targetCtx.setLineDash([4, 4]); - targetCtx.beginPath(); - const dLen = radius * 1.05; - targetCtx.moveTo(-dLen * Math.SQRT1_2, -dLen * Math.SQRT1_2); - targetCtx.lineTo(dLen * Math.SQRT1_2, dLen * Math.SQRT1_2); - targetCtx.moveTo(-dLen * Math.SQRT1_2, dLen * Math.SQRT1_2); - targetCtx.lineTo(dLen * Math.SQRT1_2, -dLen * Math.SQRT1_2); - targetCtx.moveTo(-radius, 0); targetCtx.lineTo(radius, 0); - targetCtx.moveTo(0, -radius); targetCtx.lineTo(0, radius); - targetCtx.stroke(); - targetCtx.setLineDash([]); - - // 3. Labels - targetCtx.fillStyle = 'rgba(255, 255, 255, 0.6)'; - targetCtx.font = `700 ${Math.max(10, radius * 0.065)}px 'Orbitron', sans-serif`; - targetCtx.textAlign = 'center'; - targetCtx.textBaseline = 'middle'; - targetCtx.fillText('+L', -dLen * Math.SQRT1_2 - 14, -dLen * Math.SQRT1_2 - 14); - targetCtx.fillText('+R', dLen * Math.SQRT1_2 + 14, -dLen * Math.SQRT1_2 - 14); - targetCtx.fillText('MID', 0, -radius - 16); - targetCtx.fillText('SIDE', radius + 22, 0); - - // 4. Lissajous Trace - const timeL = timePayload && timePayload.left ? timePayload.left : null; - const timeR = timePayload && timePayload.right ? timePayload.right : null; - const pts = 256; - - targetCtx.beginPath(); - targetCtx.strokeStyle = color1; - targetCtx.lineWidth = Math.max(1.5, (cfg.barWidth || 4) * 0.65); - targetCtx.shadowColor = color2; - targetCtx.shadowBlur = cfg.enableGlow ? (cfg.glowBlur || 14) : 8; - - let corrSum = 0; - for (let i = 0; i < pts; i++) { - let lVal = 0; - let rVal = 0; - if (timeL && timeR && timeL.length > 0) { - const idx = Math.floor((i / pts) * timeL.length); - const rawL = timeL[idx]; - const rawR = timeR[idx]; - if (timeL instanceof Float32Array || Math.abs(rawL) <= 1.5) { - lVal = rawL; - rVal = rawR; - } else { - lVal = (rawL - 128) / 128; - rVal = (rawR - 128) / 128; - } - } else { - const t = currentTime * 2.5 + (i / pts) * Math.PI * 4; - lVal = Math.sin(t * 1.5) * (0.3 + (state._kickPunch || 0) * 0.5); - rVal = Math.cos(t * 2.0) * (0.3 + (state._kickPunch || 0) * 0.5); - } - corrSum += lVal * rVal; - const x = (lVal - rVal) * Math.SQRT1_2 * (radius * 0.9); - const y = -(lVal + rVal) * Math.SQRT1_2 * (radius * 0.9); - if (i === 0) targetCtx.moveTo(x, y); - else targetCtx.lineTo(x, y); - } - targetCtx.stroke(); - targetCtx.shadowBlur = 0; - - // 5. Correlation Meter Bar at Bottom - const barW = radius * 1.4; - const barY = radius + 34; - targetCtx.fillStyle = '#0e131a'; - targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.2)'; - targetCtx.lineWidth = 1.5; - if (targetCtx.roundRect) targetCtx.roundRect(-barW / 2, barY, barW, 14, 4); - else targetCtx.rect(-barW / 2, barY, barW, 14); - targetCtx.fill(); - targetCtx.stroke(); - - // Center divider - targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; - targetCtx.beginPath(); - targetCtx.moveTo(0, barY); targetCtx.lineTo(0, barY + 14); - targetCtx.stroke(); - - // Correlation indicator diamond - const normCorr = Math.max(-1, Math.min(1, (corrSum / pts) * 4.0)); - const ptrX = normCorr * (barW / 2 - 6); - targetCtx.fillStyle = normCorr >= 0 ? '#00ff88' : '#ff2244'; - targetCtx.beginPath(); - targetCtx.moveTo(ptrX, barY - 3); - targetCtx.lineTo(ptrX + 4, barY + 7); - targetCtx.lineTo(ptrX, barY + 17); - targetCtx.lineTo(ptrX - 4, barY + 7); - targetCtx.closePath(); - targetCtx.fill(); - - // Labels -1, 0, +1 - targetCtx.fillStyle = 'rgba(255, 255, 255, 0.45)'; - targetCtx.font = `600 ${Math.max(9, radius * 0.045)}px 'Inter', sans-serif`; - targetCtx.fillText('-1', -barW / 2, barY + 26); - targetCtx.fillText('PHASE CORRELATION', 0, barY + 26); - targetCtx.fillText('+1', barW / 2, barY + 26); - - targetCtx.restore(); - } - - // --- SPECIAL INSTRUMENT 2: Dual LED-Segment Peak Meter Towers --- function drawLedPeakMeters(targetCtx, width, height, leftEnergy, rightEnergy, currentTime, cfg) { - targetCtx.save(); - const towerW = Math.min(75, width * 0.055); - const towerH = Math.min(360, height * 0.38); - const gap = 38; - const totalW = towerW * 2 + gap; - const segCount = 28; - const segGap = 3.5; - const segH = (towerH - (segCount - 1) * segGap) / segCount; - const startX = -totalW / 2; - const startY = -towerH / 2; - - const meter = state.ledMeter; - meter.leftLevel = Math.max(leftEnergy, meter.leftLevel * 0.88); - meter.rightLevel = Math.max(rightEnergy, meter.rightLevel * 0.88); - - const targetPeakL = Math.floor(meter.leftLevel * segCount); - if (targetPeakL >= meter.leftPeak) { - meter.leftPeak = targetPeakL; - meter.leftPeakTimer = 45; - } else { - if (meter.leftPeakTimer > 0) meter.leftPeakTimer--; - else meter.leftPeak = Math.max(0, meter.leftPeak - 0.35); - } - - const targetPeakR = Math.floor(meter.rightLevel * segCount); - if (targetPeakR >= meter.rightPeak) { - meter.rightPeak = targetPeakR; - meter.rightPeakTimer = 45; - } else { - if (meter.rightPeakTimer > 0) meter.rightPeakTimer--; - else meter.rightPeak = Math.max(0, meter.rightPeak - 0.35); - } - - const channels = [ - { x: startX, label: 'CH L', level: meter.leftLevel, peak: Math.round(meter.leftPeak) }, - { x: startX + towerW + gap, label: 'CH R', level: meter.rightLevel, peak: Math.round(meter.rightPeak) } - ]; - - // Chassis housing - const pad = 14; - targetCtx.fillStyle = '#0e1219'; - targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.22)'; - targetCtx.lineWidth = 2.5; - targetCtx.shadowColor = 'rgba(0, 0, 0, 0.7)'; - targetCtx.shadowBlur = 14; - if (targetCtx.roundRect) targetCtx.roundRect(startX - pad, startY - pad - 24, totalW + pad * 2, towerH + pad * 2 + 50, 10); - else targetCtx.rect(startX - pad, startY - pad - 24, totalW + pad * 2, towerH + pad * 2 + 50); - targetCtx.fill(); - targetCtx.stroke(); - targetCtx.shadowBlur = 0; - - // Header Title - targetCtx.fillStyle = 'rgba(255, 255, 255, 0.55)'; - targetCtx.font = "700 11px 'Orbitron', monospace"; - targetCtx.textAlign = 'center'; - targetCtx.fillText('PEAK LEVEL INDICATOR (dBu)', 0, startY - 14); - - for (const ch of channels) { - targetCtx.save(); - targetCtx.translate(ch.x, startY); - - const litCount = Math.min(segCount, Math.floor(ch.level * segCount)); - for (let s = 0; s < segCount; s++) { - const segY = towerH - (s + 1) * (segH + segGap); - const isLit = s < litCount; - const isPeak = (s === ch.peak && ch.peak > 0); - - let color = '#00e676'; - if (s >= segCount - 4) color = '#ff1744'; - else if (s >= segCount - 8) color = '#ff9100'; - else if (s >= segCount - 14) color = '#ffd600'; - - if (isLit || isPeak) { - targetCtx.fillStyle = color; - targetCtx.shadowColor = color; - targetCtx.shadowBlur = isPeak ? 12 : 6; - } else { - targetCtx.fillStyle = 'rgba(255, 255, 255, 0.05)'; - targetCtx.shadowBlur = 0; - } - - if (targetCtx.roundRect) targetCtx.roundRect(0, segY, towerW, segH, 2); - else targetCtx.rect(0, segY, towerW, segH); - targetCtx.fill(); - } - targetCtx.shadowBlur = 0; - - targetCtx.fillStyle = cfg.colorGrad1 || '#00f0ff'; - targetCtx.font = "800 12px 'Orbitron', sans-serif"; - targetCtx.textAlign = 'center'; - targetCtx.fillText(ch.label, towerW / 2, towerH + 20); - - targetCtx.restore(); - } - - // Center dB scale markings between towers - const dbMarks = [ - { seg: segCount - 1, text: '+6' }, - { seg: segCount - 3, text: '+3' }, - { seg: segCount - 5, text: '0' }, - { seg: segCount - 9, text: '-6' }, - { seg: segCount - 14, text: '-12' }, - { seg: segCount - 19, text: '-20' }, - { seg: segCount - 24, text: '-30' }, - { seg: 0, text: '-40' } - ]; - targetCtx.fillStyle = 'rgba(255, 255, 255, 0.45)'; - targetCtx.font = "600 9px 'Inter', sans-serif"; - targetCtx.textAlign = 'center'; - targetCtx.textBaseline = 'middle'; - for (const m of dbMarks) { - const y = startY + towerH - (m.seg + 0.5) * (segH + segGap); - targetCtx.fillText(m.text, 0, y); - } - - targetCtx.restore(); - } - - // --- SPECIAL INSTRUMENT 3: Vintage EM84 Magic Eye Tube --- function drawMagicEyeTube(targetCtx, width, height, peakEnergy, currentTime, cfg) { - targetCtx.save(); - const tubeW = Math.min(440, width * 0.32); - const tubeH = tubeW * 0.35; - const startX = -tubeW / 2; - const startY = -tubeH / 2; - - state.magicEyeLevel = (state.magicEyeLevel || 0) * 0.82 + peakEnergy * 0.18; - const level = Math.min(1.0, state.magicEyeLevel * 1.25); - - // 1. Chassis Mount Bracket - targetCtx.fillStyle = '#12161f'; - targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.25)'; - targetCtx.lineWidth = 2.5; - targetCtx.shadowColor = 'rgba(0, 0, 0, 0.8)'; - targetCtx.shadowBlur = 14; - if (targetCtx.roundRect) targetCtx.roundRect(startX - 18, startY - 18, tubeW + 36, tubeH + 54, 12); - else targetCtx.rect(startX - 18, startY - 18, tubeW + 36, tubeH + 54); - targetCtx.fill(); - targetCtx.stroke(); - targetCtx.shadowBlur = 0; - - // 2. Glass Tube Envelope - const glassGrad = targetCtx.createLinearGradient(0, startY, 0, startY + tubeH); - glassGrad.addColorStop(0, 'rgba(255, 255, 255, 0.28)'); - glassGrad.addColorStop(0.15, 'rgba(30, 40, 55, 0.85)'); - glassGrad.addColorStop(0.85, 'rgba(10, 15, 25, 0.92)'); - glassGrad.addColorStop(1, 'rgba(255, 255, 255, 0.18)'); - targetCtx.fillStyle = glassGrad; - targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.4)'; - targetCtx.lineWidth = 2; - if (targetCtx.roundRect) targetCtx.roundRect(startX, startY, tubeW, tubeH, tubeH / 2); - else targetCtx.rect(startX, startY, tubeW, tubeH); - targetCtx.fill(); - targetCtx.stroke(); - - // 3. Orange Heater Filaments at Ends - targetCtx.fillStyle = '#ff6600'; - targetCtx.shadowColor = '#ff5500'; - targetCtx.shadowBlur = 12; - targetCtx.beginPath(); - targetCtx.arc(startX + 28, 0, 5, 0, Math.PI * 2); - targetCtx.arc(startX + tubeW - 28, 0, 5, 0, Math.PI * 2); - targetCtx.fill(); - targetCtx.shadowBlur = 0; - - // 4. Phosphor Screen Window - const screenPadX = 54; - const screenW = tubeW - screenPadX * 2; - const screenH = tubeH * 0.42; - const screenY = -screenH / 2; - targetCtx.fillStyle = '#001a08'; - targetCtx.strokeStyle = 'rgba(0, 255, 100, 0.3)'; - targetCtx.lineWidth = 1.5; - if (targetCtx.roundRect) targetCtx.roundRect(-screenW / 2, screenY, screenW, screenH, 4); - else targetCtx.rect(-screenW / 2, screenY, screenW, screenH); - targetCtx.fill(); - targetCtx.stroke(); - - // 5. Dual Emerald Fluorescent Phosphor Wedges - const halfW = screenW * 0.5; - const gapRatio = Math.max(0, 1.0 - level); - const fanLen = halfW * (1.0 - gapRatio * 0.85); - - const wedgeGrad = targetCtx.createLinearGradient(-screenW / 2, 0, 0, 0); - wedgeGrad.addColorStop(0, '#00cc44'); - wedgeGrad.addColorStop(0.8, '#39ff14'); - wedgeGrad.addColorStop(1, '#b3ffcc'); - - targetCtx.fillStyle = wedgeGrad; - targetCtx.shadowColor = '#00ff66'; - targetCtx.shadowBlur = 14; - - targetCtx.beginPath(); - targetCtx.rect(-screenW / 2, screenY + 2, fanLen, screenH - 4); - targetCtx.rect(screenW / 2 - fanLen, screenY + 2, fanLen, screenH - 4); - targetCtx.fill(); - - if (level >= 0.85) { - targetCtx.fillStyle = '#ffffff'; - targetCtx.shadowColor = '#ffffff'; - targetCtx.shadowBlur = 20; - targetCtx.beginPath(); - targetCtx.arc(0, 0, (level - 0.85) * 45 + 4, 0, Math.PI * 2); - targetCtx.fill(); - } - targetCtx.shadowBlur = 0; - - targetCtx.fillStyle = 'rgba(255, 255, 255, 0.65)'; - targetCtx.font = "800 13px 'Orbitron', monospace"; - targetCtx.textAlign = 'center'; - targetCtx.fillText('EM84 MAGIC EYE • VACUUM TUBE PEAK INDICATOR', 0, startY + tubeH + 20); - - targetCtx.restore(); - } - - // --- SPECIAL INSTRUMENT 4: Reel-to-Reel Master Tape Deck --- function drawReelToReelDeck(targetCtx, width, height, progress, isPlaying, rawBassEnergy, currentTime, cfg) { - targetCtx.save(); - const deckW = Math.min(560, width * 0.42); - const deckH = deckW * 0.66; - const startX = -deckW / 2; - const startY = -deckH / 2; - - if (isPlaying) { - state.reelAngle = (state.reelAngle || 0) + 0.045 + (rawBassEnergy * 0.04); - } - - // 1. Brushed Aluminum Chassis Plate - const chassisGrad = targetCtx.createLinearGradient(0, startY, 0, startY + deckH); - chassisGrad.addColorStop(0, '#222834'); - chassisGrad.addColorStop(0.5, '#161b24'); - chassisGrad.addColorStop(1, '#0e1218'); - targetCtx.fillStyle = chassisGrad; - targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.22)'; - targetCtx.lineWidth = 3; - targetCtx.shadowColor = 'rgba(0, 0, 0, 0.8)'; - targetCtx.shadowBlur = 18; - if (targetCtx.roundRect) targetCtx.roundRect(startX, startY, deckW, deckH, 12); - else targetCtx.rect(startX, startY, deckW, deckH); - targetCtx.fill(); - targetCtx.stroke(); - targetCtx.shadowBlur = 0; - - // Corner Screws - targetCtx.fillStyle = '#444d60'; - for (const [sx, sy] of [[startX + 14, startY + 14], [startX + deckW - 14, startY + 14], [startX + 14, startY + deckH - 14], [startX + deckW - 14, startY + deckH - 14]]) { - targetCtx.beginPath(); - targetCtx.arc(sx, sy, 5, 0, Math.PI * 2); - targetCtx.fill(); - } - - targetCtx.fillStyle = cfg.colorGrad1 || '#00f0ff'; - targetCtx.font = "800 12px 'Orbitron', monospace"; - targetCtx.textAlign = 'center'; - targetCtx.fillText('STUDER A800 • MASTER RECORDER', 0, startY + 24); - - // 2. Twin Tape Reels - const reelSpacing = deckW * 0.27; - const reelY = startY + deckH * 0.44; - const rHub = deckW * 0.07; - const rMax = deckW * 0.21; - const rLeft = Math.sqrt(Math.max(0.01, 1 - progress) * (rMax * rMax - rHub * rHub) + rHub * rHub); - const rRight = Math.sqrt(Math.max(0.01, progress) * (rMax * rMax - rHub * rHub) + rHub * rHub); - - function drawReel(centerX, tapeRadius, angle) { - targetCtx.save(); - targetCtx.translate(centerX, reelY); - - // Tape Pack - targetCtx.fillStyle = '#1c1512'; - targetCtx.beginPath(); - targetCtx.arc(0, 0, tapeRadius, 0, Math.PI * 2); - targetCtx.fill(); - targetCtx.strokeStyle = 'rgba(255, 140, 0, 0.25)'; - targetCtx.lineWidth = 1.2; - targetCtx.stroke(); - - // Flange Rim - targetCtx.strokeStyle = '#8a94a6'; - targetCtx.lineWidth = 2.5; - targetCtx.beginPath(); - targetCtx.arc(0, 0, rMax, 0, Math.PI * 2); - targetCtx.stroke(); - - // 3 Cutouts - targetCtx.rotate(angle); - targetCtx.fillStyle = 'rgba(220, 230, 245, 0.85)'; - targetCtx.strokeStyle = '#4a5568'; - targetCtx.lineWidth = 2; - - for (let sp = 0; sp < 3; sp++) { - targetCtx.save(); - targetCtx.rotate(sp * (Math.PI * 2 / 3)); - targetCtx.beginPath(); - targetCtx.arc(0, -rMax * 0.58, rMax * 0.24, 0, Math.PI * 2); - targetCtx.stroke(); - targetCtx.restore(); - } - - // NAB Hub - targetCtx.rotate(-angle); - const hubGrad = targetCtx.createRadialGradient(0, 0, 4, 0, 0, rHub); - hubGrad.addColorStop(0, '#cbd5e1'); - hubGrad.addColorStop(0.7, '#64748b'); - hubGrad.addColorStop(1, '#334155'); - targetCtx.fillStyle = hubGrad; - targetCtx.beginPath(); - targetCtx.arc(0, 0, rHub, 0, Math.PI * 2); - targetCtx.fill(); - targetCtx.strokeStyle = '#1e293b'; - targetCtx.lineWidth = 2; - targetCtx.stroke(); - - targetCtx.fillStyle = '#0f172a'; - targetCtx.beginPath(); - targetCtx.arc(0, 0, rHub * 0.35, 0, Math.PI * 2); - targetCtx.fill(); - - targetCtx.restore(); - } - - drawReel(-reelSpacing, rLeft, state.reelAngle || 0); - drawReel(reelSpacing, rRight, -(state.reelAngle || 0) * 1.05); - - // 3. Tape Heads Block - const blockY = startY + deckH * 0.82; - targetCtx.fillStyle = '#1e2532'; - targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.2)'; - targetCtx.lineWidth = 2; - if (targetCtx.roundRect) targetCtx.roundRect(-70, blockY - 15, 140, 30, 6); - else targetCtx.rect(-70, blockY - 15, 140, 30); - targetCtx.fill(); - targetCtx.stroke(); - - targetCtx.fillStyle = '#94a3b8'; - for (const hx of [-40, 0, 40]) { - targetCtx.beginPath(); - if (targetCtx.roundRect) targetCtx.roundRect(hx - 8, blockY - 10, 16, 20, 3); - else targetCtx.rect(hx - 8, blockY - 10, 16, 20); - targetCtx.fill(); - } - - // Tape Path Line - targetCtx.strokeStyle = '#ff9933'; - targetCtx.lineWidth = 2.5; - targetCtx.beginPath(); - targetCtx.moveTo(-reelSpacing + rLeft * 0.7, reelY + rLeft * 0.7); - targetCtx.lineTo(-65, blockY); - targetCtx.lineTo(65, blockY); - targetCtx.lineTo(reelSpacing - rRight * 0.7, reelY + rRight * 0.7); - targetCtx.stroke(); - - targetCtx.restore(); - } - - // --- SPECIAL INSTRUMENT 5: Transparent Retro Cassette Deck --- function drawCassetteDeck(targetCtx, width, height, progress, isPlaying, leftEnergy, rightEnergy, currentTime, cfg) { - targetCtx.save(); - const tapeW = Math.min(500, width * 0.38); - const tapeH = tapeW * 0.62; - const startX = -tapeW / 2; - const startY = -tapeH / 2; - - if (isPlaying) { - state.cassetteAngle = (state.cassetteAngle || 0) + 0.05; - } - - // 1. Transparent Polycarbonate Shell - const shellGrad = targetCtx.createLinearGradient(0, startY, 0, startY + tapeH); - shellGrad.addColorStop(0, 'rgba(255, 255, 255, 0.2)'); - shellGrad.addColorStop(0.2, 'rgba(20, 25, 35, 0.82)'); - shellGrad.addColorStop(0.8, 'rgba(10, 15, 25, 0.90)'); - shellGrad.addColorStop(1, 'rgba(255, 255, 255, 0.15)'); - targetCtx.fillStyle = shellGrad; - targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; - targetCtx.lineWidth = 2.5; - targetCtx.shadowColor = 'rgba(0, 0, 0, 0.7)'; - targetCtx.shadowBlur = 14; - if (targetCtx.roundRect) targetCtx.roundRect(startX, startY, tapeW, tapeH, 14); - else targetCtx.rect(startX, startY, tapeW, tapeH); - targetCtx.fill(); - targetCtx.stroke(); - targetCtx.shadowBlur = 0; - - // Screws - targetCtx.fillStyle = '#64748b'; - for (const [sx, sy] of [[startX + 14, startY + 14], [startX + tapeW - 14, startY + 14], [startX + 14, startY + tapeH - 14], [startX + tapeW - 14, startY + tapeH - 14], [0, startY + 14]]) { - targetCtx.beginPath(); - targetCtx.arc(sx, sy, 4, 0, Math.PI * 2); - targetCtx.fill(); - } - - // 2. Label Area - const labelW = tapeW * 0.88; - const labelH = tapeH * 0.62; - const labelX = -labelW / 2; - const labelY = startY + tapeH * 0.12; - targetCtx.fillStyle = '#1e2430'; - targetCtx.strokeStyle = cfg.colorGrad1 || '#00f0ff'; - targetCtx.lineWidth = 1.5; - if (targetCtx.roundRect) targetCtx.roundRect(labelX, labelY, labelW, labelH, 8); - else targetCtx.rect(labelX, labelY, labelW, labelH); - targetCtx.fill(); - targetCtx.stroke(); - - let title = (document.getElementById('trackTitle')?.value || 'TRACK 01').trim(); - let artist = (document.getElementById('trackArtist')?.value || 'STEREO CASSETTE').trim(); - targetCtx.fillStyle = '#ffffff'; - targetCtx.font = "700 14px 'Outfit', sans-serif"; - targetCtx.textAlign = 'left'; - targetCtx.fillText(title, labelX + 16, labelY + 24); - targetCtx.fillStyle = cfg.colorGrad2 || '#ff007f'; - targetCtx.font = "600 11px 'Inter', sans-serif"; - targetCtx.fillText(artist, labelX + 16, labelY + 40); - - targetCtx.fillStyle = '#ffcc00'; - targetCtx.font = "800 11px 'Orbitron', monospace"; - targetCtx.textAlign = 'right'; - targetCtx.fillText('TYPE II • CHROME 60', labelX + labelW - 16, labelY + 24); - - if (cfg.showCover && state.coverImage) { - try { - const coverSize = labelH * 0.52; - targetCtx.drawImage(state.coverImage, labelX + 14, labelY + 48, coverSize, coverSize); - } catch (e) {} - } - - // 3. Clear Center Inspection Window - const winW = tapeW * 0.52; - const winH = tapeH * 0.34; - const winX = -winW / 2; - const winY = startY + tapeH * 0.38; - targetCtx.fillStyle = 'rgba(5, 10, 18, 0.9)'; - targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.25)'; - targetCtx.lineWidth = 1.5; - if (targetCtx.roundRect) targetCtx.roundRect(winX, winY, winW, winH, 6); - else targetCtx.rect(winX, winY, winW, winH); - targetCtx.fill(); - targetCtx.stroke(); - - // 4. Two Toothed Tape Spools - const spoolSpacing = winW * 0.32; - const spoolY = winY + winH * 0.5; - const rHub = 18; - const rMax = 44; - const rLeft = Math.sqrt(Math.max(0.05, 1 - progress) * (rMax * rMax - rHub * rHub) + rHub * rHub); - const rRight = Math.sqrt(Math.max(0.05, progress) * (rMax * rMax - rHub * rHub) + rHub * rHub); - - function drawSpool(cx, tapeR, angle) { - targetCtx.save(); - targetCtx.translate(cx, spoolY); - - targetCtx.fillStyle = '#261b17'; - targetCtx.beginPath(); - targetCtx.arc(0, 0, tapeR, 0, Math.PI * 2); - targetCtx.fill(); - - targetCtx.rotate(angle); - targetCtx.fillStyle = '#e2e8f0'; - targetCtx.beginPath(); - targetCtx.arc(0, 0, rHub, 0, Math.PI * 2); - targetCtx.fill(); - - targetCtx.fillStyle = '#475569'; - for (let t = 0; t < 6; t++) { - targetCtx.save(); - targetCtx.rotate((t * Math.PI) / 3); - targetCtx.fillRect(-2, -rHub, 4, 6); - targetCtx.restore(); - } - targetCtx.restore(); - } - - drawSpool(-spoolSpacing, rLeft, state.cassetteAngle || 0); - drawSpool(spoolSpacing, rRight, state.cassetteAngle || 0); - - // 5. Mini LED Stereo Meter below window - const meterY = winY + winH + 14; - const numLeds = 12; - const ledW = 6; - const ledGap = 3; - const totalMeterW = numLeds * (ledW + ledGap); - const leftLit = Math.floor(leftEnergy * numLeds); - const rightLit = Math.floor(rightEnergy * numLeds); - - for (let l = 0; l < numLeds; l++) { - const lx = -totalMeterW / 2 + l * (ledW + ledGap); - const isLitL = l <= leftLit; - const col = l > numLeds - 3 ? '#ff2244' : (l > numLeds - 6 ? '#ffaa00' : '#00ff88'); - - targetCtx.fillStyle = isLitL ? col : 'rgba(255, 255, 255, 0.08)'; - targetCtx.fillRect(lx, meterY, ledW, 4); - - const isLitR = l <= rightLit; - targetCtx.fillStyle = isLitR ? col : 'rgba(255, 255, 255, 0.08)'; - targetCtx.fillRect(lx, meterY + 6, ledW, 4); - } - - targetCtx.restore(); - } - - // --- SPECIAL INSTRUMENT 6: DJ Vinyl Turntable with Strobe & Tonearm --- function drawVinylTurntable(targetCtx, width, height, progress, isPlaying, rawBassEnergy, currentTime, cfg) { - targetCtx.save(); - const size = Math.min(width, height) * 0.42 * (state.currentPulse || 1.0); - const platterR = size * 0.95; - const recordR = platterR * 0.92; - const labelR = recordR * 0.35; - - if (isPlaying) { - state.turntableAngle = (state.turntableAngle || 0) + 0.035; - } - - // 1. Heavy Platter Rim & Strobe Dots - targetCtx.fillStyle = '#1e2430'; - targetCtx.beginPath(); - targetCtx.arc(0, 0, platterR, 0, Math.PI * 2); - targetCtx.fill(); - targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; - targetCtx.lineWidth = 2.5; - targetCtx.stroke(); - - const strobeCount = 60; - targetCtx.fillStyle = cfg.colorGrad1 || '#ff007f'; - for (let s = 0; s < strobeCount; s++) { - const ang = (state.turntableAngle || 0) + s * ((Math.PI * 2) / strobeCount); - const dotX = Math.cos(ang) * (platterR - 6); - const dotY = Math.sin(ang) * (platterR - 6); - targetCtx.beginPath(); - targetCtx.arc(dotX, dotY, 2.2, 0, Math.PI * 2); - targetCtx.fill(); - } - - // 2. Vinyl Record Body with Micro-Grooves - targetCtx.fillStyle = '#0a0d12'; - targetCtx.beginPath(); - targetCtx.arc(0, 0, recordR, 0, Math.PI * 2); - targetCtx.fill(); - - targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.04)'; - targetCtx.lineWidth = 1; - for (let r = labelR + 10; r < recordR - 6; r += 4) { - targetCtx.beginPath(); - targetCtx.arc(0, 0, r, 0, Math.PI * 2); - targetCtx.stroke(); - } - - // 4-Blade Anisotropic Specular Light Sheen - targetCtx.save(); - targetCtx.rotate(state.turntableAngle || 0); - const sheenGrad = targetCtx.createConicGradient(0, 0, 0); - sheenGrad.addColorStop(0, 'rgba(255, 255, 255, 0.02)'); - sheenGrad.addColorStop(0.12, 'rgba(255, 255, 255, 0.16)'); - sheenGrad.addColorStop(0.25, 'rgba(255, 255, 255, 0.02)'); - sheenGrad.addColorStop(0.37, 'rgba(255, 255, 255, 0.16)'); - sheenGrad.addColorStop(0.5, 'rgba(255, 255, 255, 0.02)'); - sheenGrad.addColorStop(0.62, 'rgba(255, 255, 255, 0.16)'); - sheenGrad.addColorStop(0.75, 'rgba(255, 255, 255, 0.02)'); - sheenGrad.addColorStop(0.87, 'rgba(255, 255, 255, 0.16)'); - sheenGrad.addColorStop(1, 'rgba(255, 255, 255, 0.02)'); - targetCtx.fillStyle = sheenGrad; - targetCtx.beginPath(); - targetCtx.arc(0, 0, recordR, 0, Math.PI * 2); - targetCtx.fill(); - targetCtx.restore(); - - // 3. Center Label Sticker - targetCtx.save(); - targetCtx.beginPath(); - targetCtx.arc(0, 0, labelR, 0, Math.PI * 2); - targetCtx.clip(); - - if (state.coverImage) { - try { - targetCtx.drawImage(state.coverImage, -labelR, -labelR, labelR * 2, labelR * 2); - } catch (e) { - targetCtx.fillStyle = '#ff0055'; - targetCtx.fill(); - } - } else { - const lblGrad = targetCtx.createRadialGradient(0, 0, 4, 0, 0, labelR); - lblGrad.addColorStop(0, '#ffd700'); - lblGrad.addColorStop(0.8, '#ff4500'); - lblGrad.addColorStop(1, '#990022'); - targetCtx.fillStyle = lblGrad; - targetCtx.fill(); - targetCtx.fillStyle = '#ffffff'; - targetCtx.font = "800 10px 'Orbitron', monospace"; - targetCtx.textAlign = 'center'; - targetCtx.fillText('33⅓ RPM', 0, -8); - targetCtx.fillText('STEREO', 0, 8); - } - targetCtx.restore(); - - // Center Spindle - targetCtx.fillStyle = '#e2e8f0'; - targetCtx.strokeStyle = '#000000'; - targetCtx.lineWidth = 1.5; - targetCtx.beginPath(); - targetCtx.arc(0, 0, 8, 0, Math.PI * 2); - targetCtx.fill(); - targetCtx.stroke(); - - // 4. Chrome Tonearm Moving Across Record with Song Progress - const pivotX = platterR * 0.95; - const pivotY = -platterR * 0.85; - const armAngle = 0.82 - progress * 0.46; - const armLen = platterR * 1.25; - - targetCtx.save(); - targetCtx.translate(pivotX, pivotY); - - targetCtx.fillStyle = '#334155'; - targetCtx.strokeStyle = '#94a3b8'; - targetCtx.lineWidth = 2; - targetCtx.beginPath(); - targetCtx.arc(0, 0, 16, 0, Math.PI * 2); - targetCtx.fill(); - targetCtx.stroke(); - - targetCtx.rotate(armAngle); - targetCtx.fillStyle = '#1e293b'; - targetCtx.fillRect(-12, -18, 24, 14); - - targetCtx.strokeStyle = '#cbd5e1'; - targetCtx.lineWidth = 3.5; - targetCtx.shadowColor = 'rgba(0, 0, 0, 0.6)'; - targetCtx.shadowBlur = 6; - targetCtx.beginPath(); - targetCtx.moveTo(0, 0); - targetCtx.lineTo(0, armLen); - targetCtx.stroke(); - targetCtx.shadowBlur = 0; - - targetCtx.fillStyle = '#0f172a'; - targetCtx.fillRect(-6, armLen - 6, 12, 18); - targetCtx.fillStyle = cfg.colorGrad1 || '#00ffaa'; - targetCtx.shadowColor = targetCtx.fillStyle; - targetCtx.shadowBlur = 8; - targetCtx.beginPath(); - targetCtx.arc(0, armLen + 12, 3, 0, Math.PI * 2); - targetCtx.fill(); - - targetCtx.restore(); - targetCtx.restore(); - } - - // --- SPECIAL INSTRUMENT 7: CRT Waterfall Spectrogram --- function drawWaterfallSpectrogram(targetCtx, width, height, fMono, isPlaying, currentTime, cfg) { - targetCtx.save(); - const frameW = Math.min(480, width * 0.36); - const frameH = frameW * 0.68; - const startX = -frameW / 2; - const startY = -frameH / 2; - - const bufW = 240; - const bufH = 150; - - if (!state.waterfallCanvas || state.waterfallCanvas.width !== bufW || state.waterfallCanvas.height !== bufH) { - state.waterfallCanvas = document.createElement('canvas'); - state.waterfallCanvas.width = bufW; - state.waterfallCanvas.height = bufH; - state.waterfallCtx = state.waterfallCanvas.getContext('2d'); - state.waterfallCtx.fillStyle = '#05070e'; - state.waterfallCtx.fillRect(0, 0, bufW, bufH); - } - - const wCtx = state.waterfallCtx; - - if (isPlaying) { - wCtx.drawImage(state.waterfallCanvas, 0, 0, bufW, bufH - 1, 0, 1, bufW, bufH - 1); - - const imgData = wCtx.createImageData(bufW, 1); - const data = imgData.data; - const binCount = fMono.length || 1024; - - for (let x = 0; x < bufW; x++) { - const binIdx = Math.floor(Math.pow(x / bufW, 1.4) * (binCount * 0.45)); - const val = (fMono[binIdx] || 0) / 255; - const pIdx = x * 4; - - let r = 0, g = 0, b = 0; - if (val < 0.25) { - b = Math.floor(val * 4 * 255); - } else if (val < 0.5) { - const t = (val - 0.25) * 4; - g = Math.floor(t * 240); - b = 255; - } else if (val < 0.75) { - const t = (val - 0.5) * 4; - r = Math.floor(t * 255); - g = Math.floor((1 - t * 0.5) * 240); - b = Math.floor((1 - t) * 255); - } else { - const t = (val - 0.75) * 4; - r = 255; - g = Math.floor(120 + t * 135); - b = Math.floor(t * 255); - } - - data[pIdx] = r; - data[pIdx + 1] = g; - data[pIdx + 2] = b; - data[pIdx + 3] = Math.floor(Math.min(255, val * 300)); - } - wCtx.putImageData(imgData, 0, 0); - } - - // 1. CRT Monitor Bezel - targetCtx.fillStyle = '#0f141f'; - targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.25)'; - targetCtx.lineWidth = 3; - targetCtx.shadowColor = 'rgba(0, 0, 0, 0.8)'; - targetCtx.shadowBlur = 18; - if (targetCtx.roundRect) targetCtx.roundRect(startX, startY, frameW, frameH, 12); - else targetCtx.rect(startX, startY, frameW, frameH); - targetCtx.fill(); - targetCtx.stroke(); - targetCtx.shadowBlur = 0; - - targetCtx.fillStyle = cfg.colorGrad1 || '#00f0ff'; - targetCtx.font = "800 11px 'Orbitron', monospace"; - targetCtx.textAlign = 'left'; - targetCtx.fillText('CRT WATERFALL // SPECTROGRAM SONOGRAM', startX + 16, startY + 22); - - // 2. Draw Waterfall Screen - const pad = 14; - const scrX = startX + pad; - const scrY = startY + 34; - const scrW = frameW - pad * 2; - const scrH = frameH - 56; - - targetCtx.drawImage(state.waterfallCanvas, scrX, scrY, scrW, scrH); - - // CRT Scanlines - targetCtx.fillStyle = 'rgba(0, 0, 0, 0.25)'; - for (let y = scrY; y < scrY + scrH; y += 3) { - targetCtx.fillRect(scrX, y, scrW, 1); - } - - // Real-Time Spectrum Profile - targetCtx.strokeStyle = '#ffffff'; - targetCtx.lineWidth = 1.5; - targetCtx.shadowColor = '#00f0ff'; - targetCtx.shadowBlur = 8; - targetCtx.beginPath(); - const pts = 64; - for (let p = 0; p < pts; p++) { - const px = scrX + (p / (pts - 1)) * scrW; - const bin = Math.floor(Math.pow(p / pts, 1.4) * 200); - const py = scrY + 30 - ((fMono[bin] || 0) / 255) * 26; - if (p === 0) targetCtx.moveTo(px, py); - else targetCtx.lineTo(px, py); - } - targetCtx.stroke(); - targetCtx.shadowBlur = 0; - - targetCtx.fillStyle = 'rgba(255, 255, 255, 0.45)'; - targetCtx.font = "600 9px 'Inter', sans-serif"; - targetCtx.fillText('20Hz', scrX, scrY + scrH + 14); - targetCtx.fillText('1kHz', scrX + scrW * 0.45, scrY + scrH + 14); - targetCtx.fillText('20kHz', scrX + scrW - 24, scrY + scrH + 14); - - targetCtx.restore(); - } - - // --- SPECIAL INSTRUMENT 8: Sci-Fi Hologram Sonar / Radar Sweep --- function drawRadarSweep(targetCtx, width, height, fMono, rawBassEnergy, currentTime, cfg) { - targetCtx.save(); - const radius = Math.min(width, height) * 0.28 * (state.currentPulse || 1.0); - const color = cfg.colorGrad1 || '#00ffaa'; - - const sweepSpeed = 2.4; - state.radarSweepAngle = ((currentTime * sweepSpeed) % (Math.PI * 2)); - const curAng = state.radarSweepAngle; - - const binCount = fMono.length || 1024; - const angNorm = (curAng / (Math.PI * 2)); - const binIdx = Math.floor(angNorm * (binCount * 0.35)); - const energy = (fMono[binIdx] || 0) / 255; - - if (state.radarPings === undefined) state.radarPings = []; - - if (energy > 0.45 && Math.random() < 0.4) { - const pingR = radius * (0.25 + (binIdx / (binCount * 0.35)) * 0.7); - state.radarPings.push({ - x: Math.cos(curAng) * pingR, - y: Math.sin(curAng) * pingR, - radius: pingR, - alpha: 1.0, - energy: energy - }); - } - - // 1. Concentric Distance Rings & Crosshairs - targetCtx.strokeStyle = 'rgba(0, 255, 170, 0.22)'; - targetCtx.lineWidth = 1.2; - for (const rMul of [0.25, 0.5, 0.75, 1.0]) { - targetCtx.beginPath(); - targetCtx.arc(0, 0, radius * rMul, 0, Math.PI * 2); - targetCtx.stroke(); - } - - targetCtx.setLineDash([4, 4]); - targetCtx.beginPath(); - targetCtx.moveTo(-radius * 1.08, 0); targetCtx.lineTo(radius * 1.08, 0); - targetCtx.moveTo(0, -radius * 1.08); targetCtx.lineTo(0, radius * 1.08); - targetCtx.stroke(); - targetCtx.setLineDash([]); - - // 2. Rotating Radar Sweep Beam & Fade Trail - const trailAng = 0.55; - const sweepGrad = targetCtx.createConicGradient(curAng - trailAng, 0, 0); - sweepGrad.addColorStop(0, 'transparent'); - sweepGrad.addColorStop(trailAng / (Math.PI * 2), 'rgba(0, 255, 170, 0.35)'); - sweepGrad.addColorStop((trailAng + 0.02) / (Math.PI * 2), 'transparent'); - - targetCtx.fillStyle = sweepGrad; - targetCtx.beginPath(); - targetCtx.arc(0, 0, radius, 0, Math.PI * 2); - targetCtx.fill(); - - targetCtx.strokeStyle = color; - targetCtx.lineWidth = 2.0; - targetCtx.shadowColor = color; - targetCtx.shadowBlur = 10; - targetCtx.beginPath(); - targetCtx.moveTo(0, 0); - targetCtx.lineTo(Math.cos(curAng) * radius, Math.sin(curAng) * radius); - targetCtx.stroke(); - targetCtx.shadowBlur = 0; - - // 3. Radar Pings / Blips - for (let p = state.radarPings.length - 1; p >= 0; p--) { - const ping = state.radarPings[p]; - ping.alpha -= 0.016; - if (ping.alpha <= 0) { - state.radarPings.splice(p, 1); - continue; - } - - targetCtx.fillStyle = color; - targetCtx.shadowColor = color; - targetCtx.shadowBlur = 12; - targetCtx.globalAlpha = Math.min(1.0, ping.alpha * 1.2); - targetCtx.beginPath(); - targetCtx.arc(ping.x, ping.y, Math.max(2, 6 * ping.energy), 0, Math.PI * 2); - targetCtx.fill(); - - targetCtx.strokeStyle = color; - targetCtx.lineWidth = 1; - targetCtx.beginPath(); - targetCtx.arc(ping.x, ping.y, (1 - ping.alpha) * 24 + 4, 0, Math.PI * 2); - targetCtx.stroke(); - } - targetCtx.globalAlpha = 1.0; - targetCtx.shadowBlur = 0; - - // 4. Tactical HUD Readouts - targetCtx.fillStyle = 'rgba(255, 255, 255, 0.65)'; - targetCtx.font = `700 ${Math.max(10, radius * 0.055)}px 'Orbitron', monospace`; - targetCtx.textAlign = 'left'; - targetCtx.fillText('SECTOR SCAN: ACTIVE', -radius, -radius - 14); - targetCtx.textAlign = 'right'; - targetCtx.fillText(`BEARING: ${Math.round((curAng * 180) / Math.PI)}°`, radius, -radius - 14); - - targetCtx.restore(); - } - - // --- SPECIAL INSTRUMENT 9: Tesla Plasma-Kugel (Electric Arc Sphere) --- function drawPlasmaSphere(targetCtx, width, height, rawBassEnergy, trebleEnergy, currentTime, cfg) { - targetCtx.save(); - const radius = Math.min(width, height) * 0.28 * (state.currentPulse || 1.0); - const electrodeR = radius * (0.20 + (state._kickPunch || 0) * 0.08); - const primaryColor = cfg.colorGrad1 || '#c850ff'; - const arcColor = cfg.colorGrad2 || '#00f0ff'; - - // 1. Glass Sphere Outer Contour - targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.4)'; - targetCtx.lineWidth = 2.5; - targetCtx.shadowColor = primaryColor; - targetCtx.shadowBlur = cfg.enableGlow ? 16 : 8; - targetCtx.beginPath(); - targetCtx.arc(0, 0, radius, 0, Math.PI * 2); - targetCtx.stroke(); - targetCtx.shadowBlur = 0; - - // Glass Highlight Arc - targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.35)'; - targetCtx.lineWidth = 3.5; - targetCtx.beginPath(); - targetCtx.arc(0, 0, radius - 4, -Math.PI * 0.85, -Math.PI * 0.45); - targetCtx.stroke(); - - // 2. Central Electrode Core - const coreGrad = targetCtx.createRadialGradient(0, 0, 2, 0, 0, electrodeR * 1.4); - coreGrad.addColorStop(0, '#ffffff'); - coreGrad.addColorStop(0.3, primaryColor); - coreGrad.addColorStop(0.8, '#2a0845'); - coreGrad.addColorStop(1, 'transparent'); - targetCtx.fillStyle = coreGrad; - targetCtx.beginPath(); - targetCtx.arc(0, 0, electrodeR * 1.5, 0, Math.PI * 2); - targetCtx.fill(); - - targetCtx.fillStyle = '#1e1b2e'; - targetCtx.fillRect(-electrodeR * 0.35, electrodeR * 0.8, electrodeR * 0.7, radius * 0.6); - - // 3. Branching Procedural Lightning Arcs - const arcCount = 14 + Math.floor((trebleEnergy || 0) * 8); - targetCtx.shadowColor = arcColor; - targetCtx.shadowBlur = 10; - targetCtx.lineCap = 'round'; - - for (let a = 0; a < arcCount; a++) { - const baseAng = (a / arcCount) * Math.PI * 2 + Math.sin(currentTime * 3 + a) * 0.2; - const targetX = Math.cos(baseAng) * radius; - const targetY = Math.sin(baseAng) * radius; - - const segments = 6; - let curX = Math.cos(baseAng) * electrodeR; - let curY = Math.sin(baseAng) * electrodeR; - - targetCtx.strokeStyle = (a % 2 === 0) ? primaryColor : arcColor; - targetCtx.lineWidth = Math.max(1.2, 2.8 * (0.6 + (state._kickPunch || 0) * 0.5)); - targetCtx.beginPath(); - targetCtx.moveTo(curX, curY); - - for (let s = 1; s <= segments; s++) { - const t = s / segments; - const straightX = Math.cos(baseAng) * (electrodeR + (radius - electrodeR) * t); - const straightY = Math.sin(baseAng) * (electrodeR + (radius - electrodeR) * t); - const jitter = (s === segments) ? 0 : (Math.sin(currentTime * 12 + a * 5 + s) * 14); - const perpX = -Math.sin(baseAng) * jitter; - const perpY = Math.cos(baseAng) * jitter; - - curX = straightX + perpX; - curY = straightY + perpY; - targetCtx.lineTo(curX, curY); - - if (s === 3 && trebleEnergy > 0.35 && a % 3 === 0) { - targetCtx.save(); - targetCtx.lineWidth = 1.2; - targetCtx.strokeStyle = '#ffffff'; - targetCtx.beginPath(); - targetCtx.moveTo(curX, curY); - targetCtx.lineTo(curX + perpX * 1.5 + 8, curY + perpY * 1.5 + 8); - targetCtx.stroke(); - targetCtx.restore(); - } - } - targetCtx.stroke(); - - targetCtx.fillStyle = '#ffffff'; - targetCtx.beginPath(); - targetCtx.arc(targetX, targetY, Math.max(2, 4.5 * (0.6 + trebleEnergy)), 0, Math.PI * 2); - targetCtx.fill(); - } - - targetCtx.shadowBlur = 0; - targetCtx.restore(); - } - \ No newline at end of file + function drawAnalogVuMeters(targetCtx, width, height, leftLevel, rightLevel, currentTime, cfg) { + targetCtx.save(); + const meterW = Math.min(370, width * 0.24); + const meterH = meterW * 0.62; + const gap = 34; + const totalW = meterW * 2 + gap; + const startX = -totalW / 2; + const startY = -meterH / 2; + + const meters = [ + { x: startX, label: 'CH 1 • LEFT', level: leftLevel, needleKey: 'leftNeedle', velKey: 'leftVel' }, + { x: startX + meterW + gap, label: 'CH 2 • RIGHT', level: rightLevel, needleKey: 'rightNeedle', velKey: 'rightVel' } + ]; + + for (const m of meters) { + targetCtx.save(); + targetCtx.translate(m.x, startY); + + // 1. Meter Outer Housing + targetCtx.fillStyle = '#0f131a'; + targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.2)'; + targetCtx.lineWidth = 3; + targetCtx.shadowColor = 'rgba(0, 0, 0, 0.75)'; + targetCtx.shadowBlur = 16; + targetCtx.beginPath(); + if (targetCtx.roundRect) targetCtx.roundRect(0, 0, meterW, meterH, 12); + else targetCtx.rect(0, 0, meterW, meterH); + targetCtx.fill(); + targetCtx.stroke(); + targetCtx.shadowBlur = 0; + + // 2. Vintage Dial Face (Illuminated warm parchment) + const dialPad = 10; + const dialW = meterW - dialPad * 2; + const dialH = meterH - dialPad * 2; + const dialGrad = targetCtx.createLinearGradient(0, dialPad, 0, dialPad + dialH); + dialGrad.addColorStop(0, '#fff4dc'); + dialGrad.addColorStop(0.85, '#fae5b8'); + dialGrad.addColorStop(1, '#e5ca95'); + targetCtx.fillStyle = dialGrad; + targetCtx.beginPath(); + if (targetCtx.roundRect) targetCtx.roundRect(dialPad, dialPad, dialW, dialH, 8); + else targetCtx.rect(dialPad, dialPad, dialW, dialH); + targetCtx.fill(); + + // Dial Typography + targetCtx.fillStyle = '#1a1a1a'; + targetCtx.font = `800 ${Math.round(meterW * 0.05)}px 'Orbitron', sans-serif`; + targetCtx.textAlign = 'center'; + targetCtx.fillText('VU', dialPad + dialW / 2, dialPad + dialH * 0.32); + + targetCtx.font = `700 ${Math.round(meterW * 0.034)}px 'Inter', sans-serif`; + targetCtx.fillStyle = '#444444'; + targetCtx.fillText(m.label, dialPad + dialW / 2, dialPad + dialH * 0.46); + + // Arc Scale Geometry + const pivotX = dialPad + dialW / 2; + const pivotY = dialPad + dialH * 1.15; + const arcR = dialH * 0.98; + const startAng = -Math.PI * 0.72; + const zeroDbAng = -Math.PI * 0.53; + const endAng = -Math.PI * 0.38; + + // Black Scale Line (-20 to 0 dB) + targetCtx.strokeStyle = '#1b1b1b'; + targetCtx.lineWidth = 2; + targetCtx.beginPath(); + targetCtx.arc(pivotX, pivotY, arcR, startAng, zeroDbAng); + targetCtx.stroke(); + + // Red Scale Line (0 to +3 dB Overload) + targetCtx.strokeStyle = '#d4142e'; + targetCtx.lineWidth = 3.5; + targetCtx.beginPath(); + targetCtx.arc(pivotX, pivotY, arcR, zeroDbAng, endAng); + targetCtx.stroke(); + + // Scale Ticks + const scaleTicks = [ + { val: -20, t: 0.0 }, + { val: -10, t: 0.25 }, + { val: -7, t: 0.42 }, + { val: -5, t: 0.55 }, + { val: -3, t: 0.70 }, + { val: 0, t: 0.82 }, + { val: +1, t: 0.88 }, + { val: +2, t: 0.94 }, + { val: +3, t: 1.0 } + ]; + + for (const tick of scaleTicks) { + const ang = startAng + tick.t * (endAng - startAng); + const tickLen = (tick.val === 0 || tick.val === -20 || tick.val === 3) ? 8 : 4; + const x1 = pivotX + Math.cos(ang) * arcR; + const y1 = pivotY + Math.sin(ang) * arcR; + const x2 = pivotX + Math.cos(ang) * (arcR - tickLen); + const y2 = pivotY + Math.sin(ang) * (arcR - tickLen); + + targetCtx.strokeStyle = tick.val >= 0 ? '#d4142e' : '#1b1b1b'; + targetCtx.lineWidth = tick.val === 0 ? 2.5 : 1.5; + targetCtx.beginPath(); + targetCtx.moveTo(x1, y1); + targetCtx.lineTo(x2, y2); + targetCtx.stroke(); + + if (tick.val === -20 || tick.val === -7 || tick.val === 0 || tick.val === 3) { + const tx = pivotX + Math.cos(ang) * (arcR - 16); + const ty = pivotY + Math.sin(ang) * (arcR - 16); + targetCtx.fillStyle = tick.val >= 0 ? '#d4142e' : '#222222'; + targetCtx.font = `700 ${Math.round(meterW * 0.03)}px 'Inter', sans-serif`; + targetCtx.fillText(tick.val > 0 ? `+${tick.val}` : `${tick.val}`, tx, ty); + } + } + + // Overload Peak LED (0 VU reference is at 0.82 on the scale) + const ledX = dialPad + dialW - 22; + const ledY = dialPad + 18; + const isPeak = (m.level >= 0.82); + targetCtx.beginPath(); + targetCtx.arc(ledX, ledY, 5, 0, Math.PI * 2); + if (isPeak) { + targetCtx.fillStyle = '#ff1122'; + targetCtx.shadowColor = '#ff2244'; + targetCtx.shadowBlur = 10; + } else { + targetCtx.fillStyle = '#441014'; + targetCtx.shadowBlur = 0; + } + targetCtx.fill(); + targetCtx.shadowBlur = 0; + + // Ballistic Needle Physics Simulation (IEC 60268-17 VU response) + const targetAngle = startAng + Math.max(0, Math.min(1.0, m.level)) * (endAng - startAng); + const spring = 0.26; + const damping = 0.68; + state.vuMeter[m.velKey] = (state.vuMeter[m.velKey] + (targetAngle - state.vuMeter[m.needleKey]) * spring) * damping; + state.vuMeter[m.needleKey] += state.vuMeter[m.velKey]; + state.vuMeter[m.needleKey] = Math.max(startAng - 0.02, Math.min(endAng + 0.02, state.vuMeter[m.needleKey])); + + // Draw Needle + const curAng = state.vuMeter[m.needleKey]; + const needleLen = arcR + 4; + const nx = pivotX + Math.cos(curAng) * needleLen; + const ny = pivotY + Math.sin(curAng) * needleLen; + + targetCtx.strokeStyle = '#1b1b1b'; + targetCtx.lineWidth = 2.0; + targetCtx.lineCap = 'round'; + targetCtx.shadowColor = 'rgba(0, 0, 0, 0.3)'; + targetCtx.shadowBlur = 3; + targetCtx.shadowOffsetX = 2; + targetCtx.shadowOffsetY = 2; + + targetCtx.beginPath(); + targetCtx.moveTo(pivotX, pivotY); + targetCtx.lineTo(nx, ny); + targetCtx.stroke(); + targetCtx.shadowColor = 'transparent'; + + // Pivot Cover Cap + targetCtx.fillStyle = '#222630'; + targetCtx.beginPath(); + targetCtx.arc(pivotX, pivotY, 13, 0, Math.PI * 2); + targetCtx.fill(); + targetCtx.strokeStyle = '#444c60'; + targetCtx.lineWidth = 1.5; + targetCtx.stroke(); + + targetCtx.restore(); + } + targetCtx.restore(); + } + + // --- SPECIAL INSTRUMENT 1: Stereo Goniometer / Lissajous Scope --- + function drawGoniometerScope(targetCtx, width, height, timePayload, currentTime, cfg) { + targetCtx.save(); + const radius = Math.min(width, height) * 0.28 * (state.currentPulse || 1.0); + const color1 = cfg.colorGrad1 || '#00ff88'; + const color2 = cfg.colorGrad2 || '#00f0ff'; + + // 1. Polar Graticule & HUD Circles + targetCtx.strokeStyle = 'rgba(0, 240, 255, 0.22)'; + targetCtx.lineWidth = 1.2; + for (const rMul of [0.33, 0.66, 1.0]) { + targetCtx.beginPath(); + targetCtx.arc(0, 0, radius * rMul, 0, Math.PI * 2); + targetCtx.stroke(); + } + + // 2. Diagonal 45° L/R Axes and Mid/Side Axes + targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.18)'; + targetCtx.setLineDash([4, 4]); + targetCtx.beginPath(); + const dLen = radius * 1.05; + targetCtx.moveTo(-dLen * Math.SQRT1_2, -dLen * Math.SQRT1_2); + targetCtx.lineTo(dLen * Math.SQRT1_2, dLen * Math.SQRT1_2); + targetCtx.moveTo(-dLen * Math.SQRT1_2, dLen * Math.SQRT1_2); + targetCtx.lineTo(dLen * Math.SQRT1_2, -dLen * Math.SQRT1_2); + targetCtx.moveTo(-radius, 0); targetCtx.lineTo(radius, 0); + targetCtx.moveTo(0, -radius); targetCtx.lineTo(0, radius); + targetCtx.stroke(); + targetCtx.setLineDash([]); + + // 3. Labels + targetCtx.fillStyle = 'rgba(255, 255, 255, 0.6)'; + targetCtx.font = `700 ${Math.max(10, radius * 0.065)}px 'Orbitron', sans-serif`; + targetCtx.textAlign = 'center'; + targetCtx.textBaseline = 'middle'; + targetCtx.fillText('+L', -dLen * Math.SQRT1_2 - 14, -dLen * Math.SQRT1_2 - 14); + targetCtx.fillText('+R', dLen * Math.SQRT1_2 + 14, -dLen * Math.SQRT1_2 - 14); + targetCtx.fillText('MID', 0, -radius - 16); + targetCtx.fillText('SIDE', radius + 22, 0); + + // 4. Lissajous Trace + const timeL = timePayload && timePayload.left ? timePayload.left : null; + const timeR = timePayload && timePayload.right ? timePayload.right : null; + const pts = 256; + + targetCtx.beginPath(); + targetCtx.strokeStyle = color1; + targetCtx.lineWidth = Math.max(1.5, (cfg.barWidth || 4) * 0.65); + targetCtx.shadowColor = color2; + targetCtx.shadowBlur = cfg.enableGlow ? (cfg.glowBlur || 14) : 8; + + let corrSum = 0; + for (let i = 0; i < pts; i++) { + let lVal = 0; + let rVal = 0; + if (timeL && timeR && timeL.length > 0) { + const idx = Math.floor((i / pts) * timeL.length); + const rawL = timeL[idx]; + const rawR = timeR[idx]; + if (timeL instanceof Float32Array || Math.abs(rawL) <= 1.5) { + lVal = rawL; + rVal = rawR; + } else { + lVal = (rawL - 128) / 128; + rVal = (rawR - 128) / 128; + } + } else { + const t = currentTime * 2.5 + (i / pts) * Math.PI * 4; + lVal = Math.sin(t * 1.5) * (0.3 + (state._kickPunch || 0) * 0.5); + rVal = Math.cos(t * 2.0) * (0.3 + (state._kickPunch || 0) * 0.5); + } + corrSum += lVal * rVal; + const x = (lVal - rVal) * Math.SQRT1_2 * (radius * 0.9); + const y = -(lVal + rVal) * Math.SQRT1_2 * (radius * 0.9); + if (i === 0) targetCtx.moveTo(x, y); + else targetCtx.lineTo(x, y); + } + targetCtx.stroke(); + targetCtx.shadowBlur = 0; + + // 5. Correlation Meter Bar at Bottom + const barW = radius * 1.4; + const barY = radius + 34; + targetCtx.fillStyle = '#0e131a'; + targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.2)'; + targetCtx.lineWidth = 1.5; + if (targetCtx.roundRect) targetCtx.roundRect(-barW / 2, barY, barW, 14, 4); + else targetCtx.rect(-barW / 2, barY, barW, 14); + targetCtx.fill(); + targetCtx.stroke(); + + // Center divider + targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + targetCtx.beginPath(); + targetCtx.moveTo(0, barY); targetCtx.lineTo(0, barY + 14); + targetCtx.stroke(); + + // Correlation indicator diamond + const normCorr = Math.max(-1, Math.min(1, (corrSum / pts) * 4.0)); + const ptrX = normCorr * (barW / 2 - 6); + targetCtx.fillStyle = normCorr >= 0 ? '#00ff88' : '#ff2244'; + targetCtx.beginPath(); + targetCtx.moveTo(ptrX, barY - 3); + targetCtx.lineTo(ptrX + 4, barY + 7); + targetCtx.lineTo(ptrX, barY + 17); + targetCtx.lineTo(ptrX - 4, barY + 7); + targetCtx.closePath(); + targetCtx.fill(); + + // Labels -1, 0, +1 + targetCtx.fillStyle = 'rgba(255, 255, 255, 0.45)'; + targetCtx.font = `600 ${Math.max(9, radius * 0.045)}px 'Inter', sans-serif`; + targetCtx.fillText('-1', -barW / 2, barY + 26); + targetCtx.fillText('PHASE CORRELATION', 0, barY + 26); + targetCtx.fillText('+1', barW / 2, barY + 26); + + targetCtx.restore(); + } + + // --- SPECIAL INSTRUMENT 2: Dual LED-Segment Peak Meter Towers --- + function drawLedPeakMeters(targetCtx, width, height, leftEnergy, rightEnergy, currentTime, cfg) { + targetCtx.save(); + const towerW = Math.min(75, width * 0.055); + const towerH = Math.min(360, height * 0.38); + const gap = 38; + const totalW = towerW * 2 + gap; + const segCount = 28; + const segGap = 3.5; + const segH = (towerH - (segCount - 1) * segGap) / segCount; + const startX = -totalW / 2; + const startY = -towerH / 2; + + const meter = state.ledMeter; + meter.leftLevel = Math.max(leftEnergy, meter.leftLevel * 0.88); + meter.rightLevel = Math.max(rightEnergy, meter.rightLevel * 0.88); + + const targetPeakL = Math.floor(meter.leftLevel * segCount); + if (targetPeakL >= meter.leftPeak) { + meter.leftPeak = targetPeakL; + meter.leftPeakTimer = 45; + } else { + if (meter.leftPeakTimer > 0) meter.leftPeakTimer--; + else meter.leftPeak = Math.max(0, meter.leftPeak - 0.35); + } + + const targetPeakR = Math.floor(meter.rightLevel * segCount); + if (targetPeakR >= meter.rightPeak) { + meter.rightPeak = targetPeakR; + meter.rightPeakTimer = 45; + } else { + if (meter.rightPeakTimer > 0) meter.rightPeakTimer--; + else meter.rightPeak = Math.max(0, meter.rightPeak - 0.35); + } + + const channels = [ + { x: startX, label: 'CH L', level: meter.leftLevel, peak: Math.round(meter.leftPeak) }, + { x: startX + towerW + gap, label: 'CH R', level: meter.rightLevel, peak: Math.round(meter.rightPeak) } + ]; + + // Chassis housing + const pad = 14; + targetCtx.fillStyle = '#0e1219'; + targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.22)'; + targetCtx.lineWidth = 2.5; + targetCtx.shadowColor = 'rgba(0, 0, 0, 0.7)'; + targetCtx.shadowBlur = 14; + if (targetCtx.roundRect) targetCtx.roundRect(startX - pad, startY - pad - 24, totalW + pad * 2, towerH + pad * 2 + 50, 10); + else targetCtx.rect(startX - pad, startY - pad - 24, totalW + pad * 2, towerH + pad * 2 + 50); + targetCtx.fill(); + targetCtx.stroke(); + targetCtx.shadowBlur = 0; + + // Header Title + targetCtx.fillStyle = 'rgba(255, 255, 255, 0.55)'; + targetCtx.font = "700 11px 'Orbitron', monospace"; + targetCtx.textAlign = 'center'; + targetCtx.fillText('PEAK LEVEL INDICATOR (dBu)', 0, startY - 14); + + for (const ch of channels) { + targetCtx.save(); + targetCtx.translate(ch.x, startY); + + const litCount = Math.min(segCount, Math.floor(ch.level * segCount)); + for (let s = 0; s < segCount; s++) { + const segY = towerH - (s + 1) * (segH + segGap); + const isLit = s < litCount; + const isPeak = (s === ch.peak && ch.peak > 0); + + let color = '#00e676'; + if (s >= segCount - 4) color = '#ff1744'; + else if (s >= segCount - 8) color = '#ff9100'; + else if (s >= segCount - 14) color = '#ffd600'; + + if (isLit || isPeak) { + targetCtx.fillStyle = color; + targetCtx.shadowColor = color; + targetCtx.shadowBlur = isPeak ? 12 : 6; + } else { + targetCtx.fillStyle = 'rgba(255, 255, 255, 0.05)'; + targetCtx.shadowBlur = 0; + } + + if (targetCtx.roundRect) targetCtx.roundRect(0, segY, towerW, segH, 2); + else targetCtx.rect(0, segY, towerW, segH); + targetCtx.fill(); + } + targetCtx.shadowBlur = 0; + + targetCtx.fillStyle = cfg.colorGrad1 || '#00f0ff'; + targetCtx.font = "800 12px 'Orbitron', sans-serif"; + targetCtx.textAlign = 'center'; + targetCtx.fillText(ch.label, towerW / 2, towerH + 20); + + targetCtx.restore(); + } + + // Center dB scale markings between towers + const dbMarks = [ + { seg: segCount - 1, text: '+6' }, + { seg: segCount - 3, text: '+3' }, + { seg: segCount - 5, text: '0' }, + { seg: segCount - 9, text: '-6' }, + { seg: segCount - 14, text: '-12' }, + { seg: segCount - 19, text: '-20' }, + { seg: segCount - 24, text: '-30' }, + { seg: 0, text: '-40' } + ]; + targetCtx.fillStyle = 'rgba(255, 255, 255, 0.45)'; + targetCtx.font = "600 9px 'Inter', sans-serif"; + targetCtx.textAlign = 'center'; + targetCtx.textBaseline = 'middle'; + for (const m of dbMarks) { + const y = startY + towerH - (m.seg + 0.5) * (segH + segGap); + targetCtx.fillText(m.text, 0, y); + } + + targetCtx.restore(); + } + + // --- SPECIAL INSTRUMENT 3: Vintage EM84 Magic Eye Tube --- + function drawMagicEyeTube(targetCtx, width, height, peakEnergy, currentTime, cfg) { + targetCtx.save(); + const tubeW = Math.min(440, width * 0.32); + const tubeH = tubeW * 0.35; + const startX = -tubeW / 2; + const startY = -tubeH / 2; + + state.magicEyeLevel = (state.magicEyeLevel || 0) * 0.82 + peakEnergy * 0.18; + const level = Math.min(1.0, state.magicEyeLevel * 1.25); + + // 1. Chassis Mount Bracket + targetCtx.fillStyle = '#12161f'; + targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.25)'; + targetCtx.lineWidth = 2.5; + targetCtx.shadowColor = 'rgba(0, 0, 0, 0.8)'; + targetCtx.shadowBlur = 14; + if (targetCtx.roundRect) targetCtx.roundRect(startX - 18, startY - 18, tubeW + 36, tubeH + 54, 12); + else targetCtx.rect(startX - 18, startY - 18, tubeW + 36, tubeH + 54); + targetCtx.fill(); + targetCtx.stroke(); + targetCtx.shadowBlur = 0; + + // 2. Glass Tube Envelope + const glassGrad = targetCtx.createLinearGradient(0, startY, 0, startY + tubeH); + glassGrad.addColorStop(0, 'rgba(255, 255, 255, 0.28)'); + glassGrad.addColorStop(0.15, 'rgba(30, 40, 55, 0.85)'); + glassGrad.addColorStop(0.85, 'rgba(10, 15, 25, 0.92)'); + glassGrad.addColorStop(1, 'rgba(255, 255, 255, 0.18)'); + targetCtx.fillStyle = glassGrad; + targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.4)'; + targetCtx.lineWidth = 2; + if (targetCtx.roundRect) targetCtx.roundRect(startX, startY, tubeW, tubeH, tubeH / 2); + else targetCtx.rect(startX, startY, tubeW, tubeH); + targetCtx.fill(); + targetCtx.stroke(); + + // 3. Orange Heater Filaments at Ends + targetCtx.fillStyle = '#ff6600'; + targetCtx.shadowColor = '#ff5500'; + targetCtx.shadowBlur = 12; + targetCtx.beginPath(); + targetCtx.arc(startX + 28, 0, 5, 0, Math.PI * 2); + targetCtx.arc(startX + tubeW - 28, 0, 5, 0, Math.PI * 2); + targetCtx.fill(); + targetCtx.shadowBlur = 0; + + // 4. Phosphor Screen Window + const screenPadX = 54; + const screenW = tubeW - screenPadX * 2; + const screenH = tubeH * 0.42; + const screenY = -screenH / 2; + targetCtx.fillStyle = '#001a08'; + targetCtx.strokeStyle = 'rgba(0, 255, 100, 0.3)'; + targetCtx.lineWidth = 1.5; + if (targetCtx.roundRect) targetCtx.roundRect(-screenW / 2, screenY, screenW, screenH, 4); + else targetCtx.rect(-screenW / 2, screenY, screenW, screenH); + targetCtx.fill(); + targetCtx.stroke(); + + // 5. Dual Emerald Fluorescent Phosphor Wedges + const halfW = screenW * 0.5; + const gapRatio = Math.max(0, 1.0 - level); + const fanLen = halfW * (1.0 - gapRatio * 0.85); + + const wedgeGrad = targetCtx.createLinearGradient(-screenW / 2, 0, 0, 0); + wedgeGrad.addColorStop(0, '#00cc44'); + wedgeGrad.addColorStop(0.8, '#39ff14'); + wedgeGrad.addColorStop(1, '#b3ffcc'); + + targetCtx.fillStyle = wedgeGrad; + targetCtx.shadowColor = '#00ff66'; + targetCtx.shadowBlur = 14; + + targetCtx.beginPath(); + targetCtx.rect(-screenW / 2, screenY + 2, fanLen, screenH - 4); + targetCtx.rect(screenW / 2 - fanLen, screenY + 2, fanLen, screenH - 4); + targetCtx.fill(); + + if (level >= 0.85) { + targetCtx.fillStyle = '#ffffff'; + targetCtx.shadowColor = '#ffffff'; + targetCtx.shadowBlur = 20; + targetCtx.beginPath(); + targetCtx.arc(0, 0, (level - 0.85) * 45 + 4, 0, Math.PI * 2); + targetCtx.fill(); + } + targetCtx.shadowBlur = 0; + + targetCtx.fillStyle = 'rgba(255, 255, 255, 0.65)'; + targetCtx.font = "800 13px 'Orbitron', monospace"; + targetCtx.textAlign = 'center'; + targetCtx.fillText('EM84 MAGIC EYE • VACUUM TUBE PEAK INDICATOR', 0, startY + tubeH + 20); + + targetCtx.restore(); + } + + // --- SPECIAL INSTRUMENT 4: Reel-to-Reel Master Tape Deck --- + function drawReelToReelDeck(targetCtx, width, height, progress, isPlaying, rawBassEnergy, currentTime, cfg) { + targetCtx.save(); + const deckW = Math.min(560, width * 0.42); + const deckH = deckW * 0.66; + const startX = -deckW / 2; + const startY = -deckH / 2; + + if (isPlaying) { + state.reelAngle = (state.reelAngle || 0) + 0.045 + (rawBassEnergy * 0.04); + } + + // 1. Brushed Aluminum Chassis Plate + const chassisGrad = targetCtx.createLinearGradient(0, startY, 0, startY + deckH); + chassisGrad.addColorStop(0, '#222834'); + chassisGrad.addColorStop(0.5, '#161b24'); + chassisGrad.addColorStop(1, '#0e1218'); + targetCtx.fillStyle = chassisGrad; + targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.22)'; + targetCtx.lineWidth = 3; + targetCtx.shadowColor = 'rgba(0, 0, 0, 0.8)'; + targetCtx.shadowBlur = 18; + if (targetCtx.roundRect) targetCtx.roundRect(startX, startY, deckW, deckH, 12); + else targetCtx.rect(startX, startY, deckW, deckH); + targetCtx.fill(); + targetCtx.stroke(); + targetCtx.shadowBlur = 0; + + // Corner Screws + targetCtx.fillStyle = '#444d60'; + for (const [sx, sy] of [[startX + 14, startY + 14], [startX + deckW - 14, startY + 14], [startX + 14, startY + deckH - 14], [startX + deckW - 14, startY + deckH - 14]]) { + targetCtx.beginPath(); + targetCtx.arc(sx, sy, 5, 0, Math.PI * 2); + targetCtx.fill(); + } + + targetCtx.fillStyle = cfg.colorGrad1 || '#00f0ff'; + targetCtx.font = "800 12px 'Orbitron', monospace"; + targetCtx.textAlign = 'center'; + targetCtx.fillText('STUDER A800 • MASTER RECORDER', 0, startY + 24); + + // 2. Twin Tape Reels + const reelSpacing = deckW * 0.27; + const reelY = startY + deckH * 0.44; + const rHub = deckW * 0.07; + const rMax = deckW * 0.21; + const rLeft = Math.sqrt(Math.max(0.01, 1 - progress) * (rMax * rMax - rHub * rHub) + rHub * rHub); + const rRight = Math.sqrt(Math.max(0.01, progress) * (rMax * rMax - rHub * rHub) + rHub * rHub); + + function drawReel(centerX, tapeRadius, angle) { + targetCtx.save(); + targetCtx.translate(centerX, reelY); + + // Tape Pack + targetCtx.fillStyle = '#1c1512'; + targetCtx.beginPath(); + targetCtx.arc(0, 0, tapeRadius, 0, Math.PI * 2); + targetCtx.fill(); + targetCtx.strokeStyle = 'rgba(255, 140, 0, 0.25)'; + targetCtx.lineWidth = 1.2; + targetCtx.stroke(); + + // Flange Rim + targetCtx.strokeStyle = '#8a94a6'; + targetCtx.lineWidth = 2.5; + targetCtx.beginPath(); + targetCtx.arc(0, 0, rMax, 0, Math.PI * 2); + targetCtx.stroke(); + + // 3 Cutouts + targetCtx.rotate(angle); + targetCtx.fillStyle = 'rgba(220, 230, 245, 0.85)'; + targetCtx.strokeStyle = '#4a5568'; + targetCtx.lineWidth = 2; + + for (let sp = 0; sp < 3; sp++) { + targetCtx.save(); + targetCtx.rotate(sp * (Math.PI * 2 / 3)); + targetCtx.beginPath(); + targetCtx.arc(0, -rMax * 0.58, rMax * 0.24, 0, Math.PI * 2); + targetCtx.stroke(); + targetCtx.restore(); + } + + // NAB Hub + targetCtx.rotate(-angle); + const hubGrad = targetCtx.createRadialGradient(0, 0, 4, 0, 0, rHub); + hubGrad.addColorStop(0, '#cbd5e1'); + hubGrad.addColorStop(0.7, '#64748b'); + hubGrad.addColorStop(1, '#334155'); + targetCtx.fillStyle = hubGrad; + targetCtx.beginPath(); + targetCtx.arc(0, 0, rHub, 0, Math.PI * 2); + targetCtx.fill(); + targetCtx.strokeStyle = '#1e293b'; + targetCtx.lineWidth = 2; + targetCtx.stroke(); + + targetCtx.fillStyle = '#0f172a'; + targetCtx.beginPath(); + targetCtx.arc(0, 0, rHub * 0.35, 0, Math.PI * 2); + targetCtx.fill(); + + targetCtx.restore(); + } + + drawReel(-reelSpacing, rLeft, state.reelAngle || 0); + drawReel(reelSpacing, rRight, -(state.reelAngle || 0) * 1.05); + + // 3. Tape Heads Block + const blockY = startY + deckH * 0.82; + targetCtx.fillStyle = '#1e2532'; + targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.2)'; + targetCtx.lineWidth = 2; + if (targetCtx.roundRect) targetCtx.roundRect(-70, blockY - 15, 140, 30, 6); + else targetCtx.rect(-70, blockY - 15, 140, 30); + targetCtx.fill(); + targetCtx.stroke(); + + targetCtx.fillStyle = '#94a3b8'; + for (const hx of [-40, 0, 40]) { + targetCtx.beginPath(); + if (targetCtx.roundRect) targetCtx.roundRect(hx - 8, blockY - 10, 16, 20, 3); + else targetCtx.rect(hx - 8, blockY - 10, 16, 20); + targetCtx.fill(); + } + + // Tape Path Line + targetCtx.strokeStyle = '#ff9933'; + targetCtx.lineWidth = 2.5; + targetCtx.beginPath(); + targetCtx.moveTo(-reelSpacing + rLeft * 0.7, reelY + rLeft * 0.7); + targetCtx.lineTo(-65, blockY); + targetCtx.lineTo(65, blockY); + targetCtx.lineTo(reelSpacing - rRight * 0.7, reelY + rRight * 0.7); + targetCtx.stroke(); + + targetCtx.restore(); + } + + // --- SPECIAL INSTRUMENT 5: Transparent Retro Cassette Deck --- + function drawCassetteDeck(targetCtx, width, height, progress, isPlaying, leftEnergy, rightEnergy, currentTime, cfg) { + targetCtx.save(); + const tapeW = Math.min(500, width * 0.38); + const tapeH = tapeW * 0.62; + const startX = -tapeW / 2; + const startY = -tapeH / 2; + + if (isPlaying) { + state.cassetteAngle = (state.cassetteAngle || 0) + 0.05; + } + + // 1. Transparent Polycarbonate Shell + const shellGrad = targetCtx.createLinearGradient(0, startY, 0, startY + tapeH); + shellGrad.addColorStop(0, 'rgba(255, 255, 255, 0.2)'); + shellGrad.addColorStop(0.2, 'rgba(20, 25, 35, 0.82)'); + shellGrad.addColorStop(0.8, 'rgba(10, 15, 25, 0.90)'); + shellGrad.addColorStop(1, 'rgba(255, 255, 255, 0.15)'); + targetCtx.fillStyle = shellGrad; + targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + targetCtx.lineWidth = 2.5; + targetCtx.shadowColor = 'rgba(0, 0, 0, 0.7)'; + targetCtx.shadowBlur = 14; + if (targetCtx.roundRect) targetCtx.roundRect(startX, startY, tapeW, tapeH, 14); + else targetCtx.rect(startX, startY, tapeW, tapeH); + targetCtx.fill(); + targetCtx.stroke(); + targetCtx.shadowBlur = 0; + + // Screws + targetCtx.fillStyle = '#64748b'; + for (const [sx, sy] of [[startX + 14, startY + 14], [startX + tapeW - 14, startY + 14], [startX + 14, startY + tapeH - 14], [startX + tapeW - 14, startY + tapeH - 14], [0, startY + 14]]) { + targetCtx.beginPath(); + targetCtx.arc(sx, sy, 4, 0, Math.PI * 2); + targetCtx.fill(); + } + + // 2. Label Area + const labelW = tapeW * 0.88; + const labelH = tapeH * 0.62; + const labelX = -labelW / 2; + const labelY = startY + tapeH * 0.12; + targetCtx.fillStyle = '#1e2430'; + targetCtx.strokeStyle = cfg.colorGrad1 || '#00f0ff'; + targetCtx.lineWidth = 1.5; + if (targetCtx.roundRect) targetCtx.roundRect(labelX, labelY, labelW, labelH, 8); + else targetCtx.rect(labelX, labelY, labelW, labelH); + targetCtx.fill(); + targetCtx.stroke(); + + let title = (document.getElementById('trackTitle')?.value || 'TRACK 01').trim(); + let artist = (document.getElementById('trackArtist')?.value || 'STEREO CASSETTE').trim(); + targetCtx.fillStyle = '#ffffff'; + targetCtx.font = "700 14px 'Outfit', sans-serif"; + targetCtx.textAlign = 'left'; + targetCtx.fillText(title, labelX + 16, labelY + 24); + targetCtx.fillStyle = cfg.colorGrad2 || '#ff007f'; + targetCtx.font = "600 11px 'Inter', sans-serif"; + targetCtx.fillText(artist, labelX + 16, labelY + 40); + + targetCtx.fillStyle = '#ffcc00'; + targetCtx.font = "800 11px 'Orbitron', monospace"; + targetCtx.textAlign = 'right'; + targetCtx.fillText('TYPE II • CHROME 60', labelX + labelW - 16, labelY + 24); + + if (cfg.showCover && state.coverImage) { + try { + const coverSize = labelH * 0.52; + targetCtx.drawImage(state.coverImage, labelX + 14, labelY + 48, coverSize, coverSize); + } catch (e) {} + } + + // 3. Clear Center Inspection Window + const winW = tapeW * 0.52; + const winH = tapeH * 0.34; + const winX = -winW / 2; + const winY = startY + tapeH * 0.38; + targetCtx.fillStyle = 'rgba(5, 10, 18, 0.9)'; + targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.25)'; + targetCtx.lineWidth = 1.5; + if (targetCtx.roundRect) targetCtx.roundRect(winX, winY, winW, winH, 6); + else targetCtx.rect(winX, winY, winW, winH); + targetCtx.fill(); + targetCtx.stroke(); + + // 4. Two Toothed Tape Spools + const spoolSpacing = winW * 0.32; + const spoolY = winY + winH * 0.5; + const rHub = 18; + const rMax = 44; + const rLeft = Math.sqrt(Math.max(0.05, 1 - progress) * (rMax * rMax - rHub * rHub) + rHub * rHub); + const rRight = Math.sqrt(Math.max(0.05, progress) * (rMax * rMax - rHub * rHub) + rHub * rHub); + + function drawSpool(cx, tapeR, angle) { + targetCtx.save(); + targetCtx.translate(cx, spoolY); + + targetCtx.fillStyle = '#261b17'; + targetCtx.beginPath(); + targetCtx.arc(0, 0, tapeR, 0, Math.PI * 2); + targetCtx.fill(); + + targetCtx.rotate(angle); + targetCtx.fillStyle = '#e2e8f0'; + targetCtx.beginPath(); + targetCtx.arc(0, 0, rHub, 0, Math.PI * 2); + targetCtx.fill(); + + targetCtx.fillStyle = '#475569'; + for (let t = 0; t < 6; t++) { + targetCtx.save(); + targetCtx.rotate((t * Math.PI) / 3); + targetCtx.fillRect(-2, -rHub, 4, 6); + targetCtx.restore(); + } + targetCtx.restore(); + } + + drawSpool(-spoolSpacing, rLeft, state.cassetteAngle || 0); + drawSpool(spoolSpacing, rRight, state.cassetteAngle || 0); + + // 5. Mini LED Stereo Meter below window + const meterY = winY + winH + 14; + const numLeds = 12; + const ledW = 6; + const ledGap = 3; + const totalMeterW = numLeds * (ledW + ledGap); + const leftLit = Math.floor(leftEnergy * numLeds); + const rightLit = Math.floor(rightEnergy * numLeds); + + for (let l = 0; l < numLeds; l++) { + const lx = -totalMeterW / 2 + l * (ledW + ledGap); + const isLitL = l <= leftLit; + const col = l > numLeds - 3 ? '#ff2244' : (l > numLeds - 6 ? '#ffaa00' : '#00ff88'); + + targetCtx.fillStyle = isLitL ? col : 'rgba(255, 255, 255, 0.08)'; + targetCtx.fillRect(lx, meterY, ledW, 4); + + const isLitR = l <= rightLit; + targetCtx.fillStyle = isLitR ? col : 'rgba(255, 255, 255, 0.08)'; + targetCtx.fillRect(lx, meterY + 6, ledW, 4); + } + + targetCtx.restore(); + } + + // --- SPECIAL INSTRUMENT 6: DJ Vinyl Turntable with Strobe & Tonearm --- + function drawVinylTurntable(targetCtx, width, height, progress, isPlaying, rawBassEnergy, currentTime, cfg) { + targetCtx.save(); + const size = Math.min(width, height) * 0.42 * (state.currentPulse || 1.0); + const platterR = size * 0.95; + const recordR = platterR * 0.92; + const labelR = recordR * 0.35; + + if (isPlaying) { + state.turntableAngle = (state.turntableAngle || 0) + 0.035; + } + + // 1. Heavy Platter Rim & Strobe Dots + targetCtx.fillStyle = '#1e2430'; + targetCtx.beginPath(); + targetCtx.arc(0, 0, platterR, 0, Math.PI * 2); + targetCtx.fill(); + targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + targetCtx.lineWidth = 2.5; + targetCtx.stroke(); + + const strobeCount = 60; + targetCtx.fillStyle = cfg.colorGrad1 || '#ff007f'; + for (let s = 0; s < strobeCount; s++) { + const ang = (state.turntableAngle || 0) + s * ((Math.PI * 2) / strobeCount); + const dotX = Math.cos(ang) * (platterR - 6); + const dotY = Math.sin(ang) * (platterR - 6); + targetCtx.beginPath(); + targetCtx.arc(dotX, dotY, 2.2, 0, Math.PI * 2); + targetCtx.fill(); + } + + // 2. Vinyl Record Body with Micro-Grooves + targetCtx.fillStyle = '#0a0d12'; + targetCtx.beginPath(); + targetCtx.arc(0, 0, recordR, 0, Math.PI * 2); + targetCtx.fill(); + + targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.04)'; + targetCtx.lineWidth = 1; + for (let r = labelR + 10; r < recordR - 6; r += 4) { + targetCtx.beginPath(); + targetCtx.arc(0, 0, r, 0, Math.PI * 2); + targetCtx.stroke(); + } + + // 4-Blade Anisotropic Specular Light Sheen + targetCtx.save(); + targetCtx.rotate(state.turntableAngle || 0); + const sheenGrad = targetCtx.createConicGradient(0, 0, 0); + sheenGrad.addColorStop(0, 'rgba(255, 255, 255, 0.02)'); + sheenGrad.addColorStop(0.12, 'rgba(255, 255, 255, 0.16)'); + sheenGrad.addColorStop(0.25, 'rgba(255, 255, 255, 0.02)'); + sheenGrad.addColorStop(0.37, 'rgba(255, 255, 255, 0.16)'); + sheenGrad.addColorStop(0.5, 'rgba(255, 255, 255, 0.02)'); + sheenGrad.addColorStop(0.62, 'rgba(255, 255, 255, 0.16)'); + sheenGrad.addColorStop(0.75, 'rgba(255, 255, 255, 0.02)'); + sheenGrad.addColorStop(0.87, 'rgba(255, 255, 255, 0.16)'); + sheenGrad.addColorStop(1, 'rgba(255, 255, 255, 0.02)'); + targetCtx.fillStyle = sheenGrad; + targetCtx.beginPath(); + targetCtx.arc(0, 0, recordR, 0, Math.PI * 2); + targetCtx.fill(); + targetCtx.restore(); + + // 3. Center Label Sticker + targetCtx.save(); + targetCtx.beginPath(); + targetCtx.arc(0, 0, labelR, 0, Math.PI * 2); + targetCtx.clip(); + + if (state.coverImage) { + try { + targetCtx.drawImage(state.coverImage, -labelR, -labelR, labelR * 2, labelR * 2); + } catch (e) { + targetCtx.fillStyle = '#ff0055'; + targetCtx.fill(); + } + } else { + const lblGrad = targetCtx.createRadialGradient(0, 0, 4, 0, 0, labelR); + lblGrad.addColorStop(0, '#ffd700'); + lblGrad.addColorStop(0.8, '#ff4500'); + lblGrad.addColorStop(1, '#990022'); + targetCtx.fillStyle = lblGrad; + targetCtx.fill(); + targetCtx.fillStyle = '#ffffff'; + targetCtx.font = "800 10px 'Orbitron', monospace"; + targetCtx.textAlign = 'center'; + targetCtx.fillText('33⅓ RPM', 0, -8); + targetCtx.fillText('STEREO', 0, 8); + } + targetCtx.restore(); + + // Center Spindle + targetCtx.fillStyle = '#e2e8f0'; + targetCtx.strokeStyle = '#000000'; + targetCtx.lineWidth = 1.5; + targetCtx.beginPath(); + targetCtx.arc(0, 0, 8, 0, Math.PI * 2); + targetCtx.fill(); + targetCtx.stroke(); + + // 4. Chrome Tonearm Moving Across Record with Song Progress + const pivotX = platterR * 0.95; + const pivotY = -platterR * 0.85; + const armAngle = 0.82 - progress * 0.46; + const armLen = platterR * 1.25; + + targetCtx.save(); + targetCtx.translate(pivotX, pivotY); + + targetCtx.fillStyle = '#334155'; + targetCtx.strokeStyle = '#94a3b8'; + targetCtx.lineWidth = 2; + targetCtx.beginPath(); + targetCtx.arc(0, 0, 16, 0, Math.PI * 2); + targetCtx.fill(); + targetCtx.stroke(); + + targetCtx.rotate(armAngle); + targetCtx.fillStyle = '#1e293b'; + targetCtx.fillRect(-12, -18, 24, 14); + + targetCtx.strokeStyle = '#cbd5e1'; + targetCtx.lineWidth = 3.5; + targetCtx.shadowColor = 'rgba(0, 0, 0, 0.6)'; + targetCtx.shadowBlur = 6; + targetCtx.beginPath(); + targetCtx.moveTo(0, 0); + targetCtx.lineTo(0, armLen); + targetCtx.stroke(); + targetCtx.shadowBlur = 0; + + targetCtx.fillStyle = '#0f172a'; + targetCtx.fillRect(-6, armLen - 6, 12, 18); + targetCtx.fillStyle = cfg.colorGrad1 || '#00ffaa'; + targetCtx.shadowColor = targetCtx.fillStyle; + targetCtx.shadowBlur = 8; + targetCtx.beginPath(); + targetCtx.arc(0, armLen + 12, 3, 0, Math.PI * 2); + targetCtx.fill(); + + targetCtx.restore(); + targetCtx.restore(); + } + + // --- SPECIAL INSTRUMENT 7: CRT Waterfall Spectrogram --- + function drawWaterfallSpectrogram(targetCtx, width, height, fMono, isPlaying, currentTime, cfg) { + targetCtx.save(); + const frameW = Math.min(480, width * 0.36); + const frameH = frameW * 0.68; + const startX = -frameW / 2; + const startY = -frameH / 2; + + const bufW = 240; + const bufH = 150; + + if (!state.waterfallCanvas || state.waterfallCanvas.width !== bufW || state.waterfallCanvas.height !== bufH) { + state.waterfallCanvas = document.createElement('canvas'); + state.waterfallCanvas.width = bufW; + state.waterfallCanvas.height = bufH; + state.waterfallCtx = state.waterfallCanvas.getContext('2d'); + state.waterfallCtx.fillStyle = '#05070e'; + state.waterfallCtx.fillRect(0, 0, bufW, bufH); + } + + const wCtx = state.waterfallCtx; + + if (isPlaying) { + wCtx.drawImage(state.waterfallCanvas, 0, 0, bufW, bufH - 1, 0, 1, bufW, bufH - 1); + + const imgData = wCtx.createImageData(bufW, 1); + const data = imgData.data; + const binCount = fMono.length || 1024; + + for (let x = 0; x < bufW; x++) { + const binIdx = Math.floor(Math.pow(x / bufW, 1.4) * (binCount * 0.45)); + const val = (fMono[binIdx] || 0) / 255; + const pIdx = x * 4; + + let r = 0, g = 0, b = 0; + if (val < 0.25) { + b = Math.floor(val * 4 * 255); + } else if (val < 0.5) { + const t = (val - 0.25) * 4; + g = Math.floor(t * 240); + b = 255; + } else if (val < 0.75) { + const t = (val - 0.5) * 4; + r = Math.floor(t * 255); + g = Math.floor((1 - t * 0.5) * 240); + b = Math.floor((1 - t) * 255); + } else { + const t = (val - 0.75) * 4; + r = 255; + g = Math.floor(120 + t * 135); + b = Math.floor(t * 255); + } + + data[pIdx] = r; + data[pIdx + 1] = g; + data[pIdx + 2] = b; + data[pIdx + 3] = Math.floor(Math.min(255, val * 300)); + } + wCtx.putImageData(imgData, 0, 0); + } + + // 1. CRT Monitor Bezel + targetCtx.fillStyle = '#0f141f'; + targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.25)'; + targetCtx.lineWidth = 3; + targetCtx.shadowColor = 'rgba(0, 0, 0, 0.8)'; + targetCtx.shadowBlur = 18; + if (targetCtx.roundRect) targetCtx.roundRect(startX, startY, frameW, frameH, 12); + else targetCtx.rect(startX, startY, frameW, frameH); + targetCtx.fill(); + targetCtx.stroke(); + targetCtx.shadowBlur = 0; + + targetCtx.fillStyle = cfg.colorGrad1 || '#00f0ff'; + targetCtx.font = "800 11px 'Orbitron', monospace"; + targetCtx.textAlign = 'left'; + targetCtx.fillText('CRT WATERFALL // SPECTROGRAM SONOGRAM', startX + 16, startY + 22); + + // 2. Draw Waterfall Screen + const pad = 14; + const scrX = startX + pad; + const scrY = startY + 34; + const scrW = frameW - pad * 2; + const scrH = frameH - 56; + + targetCtx.drawImage(state.waterfallCanvas, scrX, scrY, scrW, scrH); + + // CRT Scanlines + targetCtx.fillStyle = 'rgba(0, 0, 0, 0.25)'; + for (let y = scrY; y < scrY + scrH; y += 3) { + targetCtx.fillRect(scrX, y, scrW, 1); + } + + // Real-Time Spectrum Profile + targetCtx.strokeStyle = '#ffffff'; + targetCtx.lineWidth = 1.5; + targetCtx.shadowColor = '#00f0ff'; + targetCtx.shadowBlur = 8; + targetCtx.beginPath(); + const pts = 64; + for (let p = 0; p < pts; p++) { + const px = scrX + (p / (pts - 1)) * scrW; + const bin = Math.floor(Math.pow(p / pts, 1.4) * 200); + const py = scrY + 30 - ((fMono[bin] || 0) / 255) * 26; + if (p === 0) targetCtx.moveTo(px, py); + else targetCtx.lineTo(px, py); + } + targetCtx.stroke(); + targetCtx.shadowBlur = 0; + + targetCtx.fillStyle = 'rgba(255, 255, 255, 0.45)'; + targetCtx.font = "600 9px 'Inter', sans-serif"; + targetCtx.fillText('20Hz', scrX, scrY + scrH + 14); + targetCtx.fillText('1kHz', scrX + scrW * 0.45, scrY + scrH + 14); + targetCtx.fillText('20kHz', scrX + scrW - 24, scrY + scrH + 14); + + targetCtx.restore(); + } + + // --- SPECIAL INSTRUMENT 8: Sci-Fi Hologram Sonar / Radar Sweep --- + function drawRadarSweep(targetCtx, width, height, fMono, rawBassEnergy, currentTime, cfg) { + targetCtx.save(); + const radius = Math.min(width, height) * 0.28 * (state.currentPulse || 1.0); + const color = cfg.colorGrad1 || '#00ffaa'; + + const sweepSpeed = 2.4; + state.radarSweepAngle = ((currentTime * sweepSpeed) % (Math.PI * 2)); + const curAng = state.radarSweepAngle; + + const binCount = fMono.length || 1024; + const angNorm = (curAng / (Math.PI * 2)); + const binIdx = Math.floor(angNorm * (binCount * 0.35)); + const energy = (fMono[binIdx] || 0) / 255; + + if (state.radarPings === undefined) state.radarPings = []; + + if (energy > 0.45 && Math.random() < 0.4) { + const pingR = radius * (0.25 + (binIdx / (binCount * 0.35)) * 0.7); + state.radarPings.push({ + x: Math.cos(curAng) * pingR, + y: Math.sin(curAng) * pingR, + radius: pingR, + alpha: 1.0, + energy: energy + }); + } + + // 1. Concentric Distance Rings & Crosshairs + targetCtx.strokeStyle = 'rgba(0, 255, 170, 0.22)'; + targetCtx.lineWidth = 1.2; + for (const rMul of [0.25, 0.5, 0.75, 1.0]) { + targetCtx.beginPath(); + targetCtx.arc(0, 0, radius * rMul, 0, Math.PI * 2); + targetCtx.stroke(); + } + + targetCtx.setLineDash([4, 4]); + targetCtx.beginPath(); + targetCtx.moveTo(-radius * 1.08, 0); targetCtx.lineTo(radius * 1.08, 0); + targetCtx.moveTo(0, -radius * 1.08); targetCtx.lineTo(0, radius * 1.08); + targetCtx.stroke(); + targetCtx.setLineDash([]); + + // 2. Rotating Radar Sweep Beam & Fade Trail + const trailAng = 0.55; + const sweepGrad = targetCtx.createConicGradient(curAng - trailAng, 0, 0); + sweepGrad.addColorStop(0, 'transparent'); + sweepGrad.addColorStop(trailAng / (Math.PI * 2), 'rgba(0, 255, 170, 0.35)'); + sweepGrad.addColorStop((trailAng + 0.02) / (Math.PI * 2), 'transparent'); + + targetCtx.fillStyle = sweepGrad; + targetCtx.beginPath(); + targetCtx.arc(0, 0, radius, 0, Math.PI * 2); + targetCtx.fill(); + + targetCtx.strokeStyle = color; + targetCtx.lineWidth = 2.0; + targetCtx.shadowColor = color; + targetCtx.shadowBlur = 10; + targetCtx.beginPath(); + targetCtx.moveTo(0, 0); + targetCtx.lineTo(Math.cos(curAng) * radius, Math.sin(curAng) * radius); + targetCtx.stroke(); + targetCtx.shadowBlur = 0; + + // 3. Radar Pings / Blips + for (let p = state.radarPings.length - 1; p >= 0; p--) { + const ping = state.radarPings[p]; + ping.alpha -= 0.016; + if (ping.alpha <= 0) { + state.radarPings.splice(p, 1); + continue; + } + + targetCtx.fillStyle = color; + targetCtx.shadowColor = color; + targetCtx.shadowBlur = 12; + targetCtx.globalAlpha = Math.min(1.0, ping.alpha * 1.2); + targetCtx.beginPath(); + targetCtx.arc(ping.x, ping.y, Math.max(2, 6 * ping.energy), 0, Math.PI * 2); + targetCtx.fill(); + + targetCtx.strokeStyle = color; + targetCtx.lineWidth = 1; + targetCtx.beginPath(); + targetCtx.arc(ping.x, ping.y, (1 - ping.alpha) * 24 + 4, 0, Math.PI * 2); + targetCtx.stroke(); + } + targetCtx.globalAlpha = 1.0; + targetCtx.shadowBlur = 0; + + // 4. Tactical HUD Readouts + targetCtx.fillStyle = 'rgba(255, 255, 255, 0.65)'; + targetCtx.font = `700 ${Math.max(10, radius * 0.055)}px 'Orbitron', monospace`; + targetCtx.textAlign = 'left'; + targetCtx.fillText('SECTOR SCAN: ACTIVE', -radius, -radius - 14); + targetCtx.textAlign = 'right'; + targetCtx.fillText(`BEARING: ${Math.round((curAng * 180) / Math.PI)}°`, radius, -radius - 14); + + targetCtx.restore(); + } + + // --- SPECIAL INSTRUMENT 9: Tesla Plasma-Kugel (Electric Arc Sphere) --- + function drawPlasmaSphere(targetCtx, width, height, rawBassEnergy, trebleEnergy, currentTime, cfg) { + targetCtx.save(); + const radius = Math.min(width, height) * 0.28 * (state.currentPulse || 1.0); + const electrodeR = radius * (0.20 + (state._kickPunch || 0) * 0.08); + const primaryColor = cfg.colorGrad1 || '#c850ff'; + const arcColor = cfg.colorGrad2 || '#00f0ff'; + + // 1. Glass Sphere Outer Contour + targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.4)'; + targetCtx.lineWidth = 2.5; + targetCtx.shadowColor = primaryColor; + targetCtx.shadowBlur = cfg.enableGlow ? 16 : 8; + targetCtx.beginPath(); + targetCtx.arc(0, 0, radius, 0, Math.PI * 2); + targetCtx.stroke(); + targetCtx.shadowBlur = 0; + + // Glass Highlight Arc + targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.35)'; + targetCtx.lineWidth = 3.5; + targetCtx.beginPath(); + targetCtx.arc(0, 0, radius - 4, -Math.PI * 0.85, -Math.PI * 0.45); + targetCtx.stroke(); + + // 2. Central Electrode Core + const coreGrad = targetCtx.createRadialGradient(0, 0, 2, 0, 0, electrodeR * 1.4); + coreGrad.addColorStop(0, '#ffffff'); + coreGrad.addColorStop(0.3, primaryColor); + coreGrad.addColorStop(0.8, '#2a0845'); + coreGrad.addColorStop(1, 'transparent'); + targetCtx.fillStyle = coreGrad; + targetCtx.beginPath(); + targetCtx.arc(0, 0, electrodeR * 1.5, 0, Math.PI * 2); + targetCtx.fill(); + + targetCtx.fillStyle = '#1e1b2e'; + targetCtx.fillRect(-electrodeR * 0.35, electrodeR * 0.8, electrodeR * 0.7, radius * 0.6); + + // 3. Branching Procedural Lightning Arcs + const arcCount = 14 + Math.floor((trebleEnergy || 0) * 8); + targetCtx.shadowColor = arcColor; + targetCtx.shadowBlur = 10; + targetCtx.lineCap = 'round'; + + for (let a = 0; a < arcCount; a++) { + const baseAng = (a / arcCount) * Math.PI * 2 + Math.sin(currentTime * 3 + a) * 0.2; + const targetX = Math.cos(baseAng) * radius; + const targetY = Math.sin(baseAng) * radius; + + const segments = 6; + let curX = Math.cos(baseAng) * electrodeR; + let curY = Math.sin(baseAng) * electrodeR; + + targetCtx.strokeStyle = (a % 2 === 0) ? primaryColor : arcColor; + targetCtx.lineWidth = Math.max(1.2, 2.8 * (0.6 + (state._kickPunch || 0) * 0.5)); + targetCtx.beginPath(); + targetCtx.moveTo(curX, curY); + + for (let s = 1; s <= segments; s++) { + const t = s / segments; + const straightX = Math.cos(baseAng) * (electrodeR + (radius - electrodeR) * t); + const straightY = Math.sin(baseAng) * (electrodeR + (radius - electrodeR) * t); + const jitter = (s === segments) ? 0 : (Math.sin(currentTime * 12 + a * 5 + s) * 14); + const perpX = -Math.sin(baseAng) * jitter; + const perpY = Math.cos(baseAng) * jitter; + + curX = straightX + perpX; + curY = straightY + perpY; + targetCtx.lineTo(curX, curY); + + if (s === 3 && trebleEnergy > 0.35 && a % 3 === 0) { + targetCtx.save(); + targetCtx.lineWidth = 1.2; + targetCtx.strokeStyle = '#ffffff'; + targetCtx.beginPath(); + targetCtx.moveTo(curX, curY); + targetCtx.lineTo(curX + perpX * 1.5 + 8, curY + perpY * 1.5 + 8); + targetCtx.stroke(); + targetCtx.restore(); + } + } + targetCtx.stroke(); + + targetCtx.fillStyle = '#ffffff'; + targetCtx.beginPath(); + targetCtx.arc(targetX, targetY, Math.max(2, 4.5 * (0.6 + trebleEnergy)), 0, Math.PI * 2); + targetCtx.fill(); + } + + targetCtx.shadowBlur = 0; + targetCtx.restore(); + } + +export { + drawAnalogVuMeters, + drawGoniometerScope, + drawLedPeakMeters, + drawMagicEyeTube, + drawReelToReelDeck, + drawCassetteDeck, + drawVinylTurntable, + drawWaterfallSpectrogram, + drawRadarSweep, + drawPlasmaSphere +}; diff --git a/public/js/geometry/shapes.js b/public/js/geometry/shapes.js index 879e701..450ea3a 100644 --- a/public/js/geometry/shapes.js +++ b/public/js/geometry/shapes.js @@ -5,765 +5,773 @@ import { state } from '../state.js'; import { SPECIAL_INSTRUMENTS } from './instruments.js'; - // --- Shape Geometry Cache & Generator System --- - export const SHAPE_VERTICES_CACHE = {}; - - function getRegularPolygonVertices(sides, startAngle = -Math.PI / 2) { - const verts = []; - const step = (Math.PI * 2) / sides; - for (let i = 0; i < sides; i++) { - const a = startAngle + i * step; - verts.push({ x: Math.cos(a), y: Math.sin(a) }); - } - return verts; - } - - function generateShapeVertices(shape) { - if (SHAPE_VERTICES_CACHE[shape]) return SHAPE_VERTICES_CACHE[shape]; - - let verts = []; - if (shape === 'triangle') { - verts = getRegularPolygonVertices(3, -Math.PI / 2); - } else if (shape === 'diamond') { - verts = getRegularPolygonVertices(4, -Math.PI / 2); - } else if (shape === 'square') { - verts = getRegularPolygonVertices(4, -Math.PI * 0.75); - } else if (shape === 'pentagon') { - verts = getRegularPolygonVertices(5, -Math.PI / 2); - } else if (shape === 'hexagon') { - verts = getRegularPolygonVertices(6, -Math.PI / 2); - } else if (shape === 'heptagon') { - verts = getRegularPolygonVertices(7, -Math.PI / 2); - } else if (shape === 'octagon') { - verts = getRegularPolygonVertices(8, -Math.PI / 2); - } else if (shape === 'star') { - const n = 5; - const step = Math.PI / n; - const start = -Math.PI / 2; - for (let i = 0; i < n * 2; i++) { - const r = (i % 2 === 0) ? 1.0 : 0.48; - const a = start + i * step; - verts.push({ x: Math.cos(a) * r, y: Math.sin(a) * r }); - } - } else if (shape === 'star8') { - const n = 8; - const step = Math.PI / n; - const start = -Math.PI / 2; - for (let i = 0; i < n * 2; i++) { - const r = (i % 2 === 0) ? 1.0 : 0.54; - const a = start + i * step; - verts.push({ x: Math.cos(a) * r, y: Math.sin(a) * r }); - } - } else if (shape === 'cross') { - const w = 0.38; - verts = [ - { x: -w, y: -1.0 }, { x: w, y: -1.0 }, { x: w, y: -w }, - { x: 1.0, y: -w }, { x: 1.0, y: w }, { x: w, y: w }, - { x: w, y: 1.0 }, { x: -w, y: 1.0 }, { x: -w, y: w }, - { x: -1.0, y: w }, { x: -1.0, y: -w }, { x: -w, y: -w } - ]; - } else if (shape === 'heart') { - const count = 72; - const s = 1.0 / 17.2; - for (let i = 0; i < count; i++) { - const theta = (i / count) * Math.PI * 2; - const px = 16 * Math.pow(Math.sin(theta), 3) * s; - const py = -(13 * Math.cos(theta) - 5 * Math.cos(2 * theta) - 2 * Math.cos(3 * theta) - Math.cos(4 * theta)) * s + 0.14; - verts.push({ x: px, y: py }); - } - } else if (shape === 'flower') { - const count = 72; - for (let i = 0; i < count; i++) { - const theta = (i / count) * Math.PI * 2 - Math.PI / 2; - const r = 0.76 + 0.24 * Math.cos(6 * (i / count) * Math.PI * 2); - verts.push({ x: Math.cos(theta) * r, y: Math.sin(theta) * r }); - } - } else if (shape === 'lightning') { - verts = [ - { x: 0.18, y: -1.0 }, - { x: -0.42, y: -0.08 }, - { x: -0.06, y: -0.08 }, - { x: -0.32, y: 1.0 }, - { x: 0.42, y: 0.08 }, - { x: 0.06, y: 0.08 } - ]; - } else if (shape === 'flame') { - verts = [ - { x: 0, y: -0.96 }, - { x: 0.10, y: -0.78 }, - { x: 0.18, y: -0.55 }, - { x: 0.22, y: -0.36 }, - { x: 0.32, y: -0.48 }, - { x: 0.50, y: -0.60 }, - { x: 0.46, y: -0.35 }, - { x: 0.44, y: -0.16 }, - { x: 0.62, y: -0.24 }, - { x: 0.76, y: -0.06 }, - { x: 0.70, y: 0.18 }, - { x: 0.80, y: 0.42 }, - { x: 0.68, y: 0.72 }, - { x: 0.38, y: 0.90 }, - { x: 0, y: 0.95 }, - { x: -0.38, y: 0.90 }, - { x: -0.68, y: 0.72 }, - { x: -0.80, y: 0.42 }, - { x: -0.70, y: 0.18 }, - { x: -0.76, y: -0.06 }, - { x: -0.62, y: -0.24 }, - { x: -0.44, y: -0.16 }, - { x: -0.46, y: -0.35 }, - { x: -0.50, y: -0.60 }, - { x: -0.32, y: -0.48 }, - { x: -0.22, y: -0.36 }, - { x: -0.18, y: -0.55 }, - { x: -0.10, y: -0.78 } - ]; - } else if (shape === 'skull') { - verts = [ - { x: 0, y: -0.95 }, - { x: 0.38, y: -0.90 }, - { x: 0.70, y: -0.68 }, - { x: 0.84, y: -0.32 }, - { x: 0.82, y: 0.0 }, - { x: 0.62, y: 0.18 }, - { x: 0.72, y: 0.38 }, - { x: 0.48, y: 0.52 }, - { x: 0.42, y: 0.88 }, - { x: 0.20, y: 0.94 }, - { x: 0, y: 0.95 }, - { x: -0.20, y: 0.94 }, - { x: -0.42, y: 0.88 }, - { x: -0.48, y: 0.52 }, - { x: -0.72, y: 0.38 }, - { x: -0.62, y: 0.18 }, - { x: -0.82, y: 0.0 }, - { x: -0.84, y: -0.32 }, - { x: -0.70, y: -0.68 }, - { x: -0.38, y: -0.90 } - ]; - } else if (shape === 'crown') { - verts = [ - { x: 0, y: -0.95 }, - { x: 0.24, y: -0.32 }, - { x: 0.50, y: -0.80 }, - { x: 0.66, y: -0.25 }, - { x: 0.88, y: -0.65 }, - { x: 0.82, y: 0.70 }, - { x: 0.40, y: 0.75 }, - { x: 0, y: 0.76 }, - { x: -0.40, y: 0.75 }, - { x: -0.82, y: 0.70 }, - { x: -0.88, y: -0.65 }, - { x: -0.66, y: -0.25 }, - { x: -0.50, y: -0.80 }, - { x: -0.24, y: -0.32 } - ]; - } else if (shape === 'infinity') { - const count = 64; - for (let i = 0; i < count; i++) { - const t = (i / count) * Math.PI * 2; - verts.push({ x: 0.94 * Math.sin(t), y: 0.52 * Math.sin(2 * t) }); - } - } else if (shape === 'eye') { - const count = 32; - for (let i = 0; i <= count; i++) { - const t = i / count; - verts.push({ x: -0.95 + 1.90 * t, y: -Math.sin(t * Math.PI) * 0.62 }); - } - for (let i = count - 1; i > 0; i--) { - const t = i / count; - verts.push({ x: -0.95 + 1.90 * t, y: Math.sin(t * Math.PI) * 0.62 }); - } - } else if (shape === 'gem') { - verts = [ - { x: -0.52, y: -0.85 }, - { x: 0.52, y: -0.85 }, - { x: 0.92, y: -0.32 }, - { x: 0, y: 0.98 }, - { x: -0.92, y: -0.32 } - ]; - } else if (shape === 'pick') { - const count = 48; - for (let i = 0; i < count; i++) { - const a = (i / count) * Math.PI * 2; - const px = 0.80 * Math.cos(a) * (1.0 - 0.45 * Math.sin(a)); - const py = 0.92 * Math.sin(a); - verts.push({ x: px, y: py }); - } - } else if (shape === 'sun') { - const n = 16; - const step = Math.PI / n; - const start = -Math.PI / 2; - for (let i = 0; i < n * 2; i++) { - const r = (i % 2 === 0) ? 1.0 : 0.72; - const a = start + i * step; - verts.push({ x: Math.cos(a) * r, y: Math.sin(a) * r }); - } - } else if (shape === 'droplet') { - const count = 48; - for (let i = 0; i < count; i++) { - const u = (i / count) * Math.PI * 2; - const px = 0.76 * Math.sin(u) * Math.sin(u / 2); - const py = -Math.cos(u) * 0.92 - 0.08 * Math.cos(2 * u) + 0.08; - verts.push({ x: px, y: py }); - } - } else if (shape === 'hourglass') { - verts = [ - { x: -0.80, y: -0.92 }, - { x: 0.80, y: -0.92 }, - { x: 0.18, y: 0.0 }, - { x: 0.80, y: 0.92 }, - { x: -0.80, y: 0.92 }, - { x: -0.18, y: 0.0 } - ]; - } else if (shape === 'butterfly') { - verts = [ - { x: 0, y: -0.75 }, - { x: 0.22, y: -0.68 }, - { x: 0.62, y: -0.88 }, - { x: 0.95, y: -0.48 }, - { x: 0.72, y: -0.10 }, - { x: 0.38, y: -0.02 }, - { x: 0.78, y: 0.35 }, - { x: 0.55, y: 0.72 }, - { x: 0.20, y: 0.62 }, - { x: 0, y: 0.82 }, - { x: -0.20, y: 0.62 }, - { x: -0.55, y: 0.72 }, - { x: -0.78, y: 0.35 }, - { x: -0.38, y: 0.02 }, - { x: -0.72, y: -0.10 }, - { x: -0.95, y: -0.48 }, - { x: -0.62, y: -0.88 }, - { x: -0.22, y: -0.68 } - ]; - } else if (shape === 'hexagram') { - const n = 6; - const step = Math.PI / n; - const start = -Math.PI / 2; - for (let i = 0; i < n * 2; i++) { - const r = (i % 2 === 0) ? 1.0 : 0.577; - const a = start + i * step; - verts.push({ x: Math.cos(a) * r, y: Math.sin(a) * r }); - } - } else if (shape === 'speaker') { - verts = [ - { x: -0.75, y: -0.30 }, - { x: 0.68, y: -0.75 }, - { x: 0.85, y: -0.45 }, - { x: 0.92, y: 0 }, - { x: 0.85, y: 0.45 }, - { x: 0.68, y: 0.75 }, - { x: -0.42, y: 0.30 }, - { x: -0.42, y: 0.85 }, - { x: -0.65, y: 0.85 }, - { x: -0.65, y: 0.30 }, - { x: -0.75, y: 0.30 } - ]; - } else if (shape === 'invader') { - verts = [ - { x: -0.32, y: -0.90 }, - { x: -0.16, y: -0.90 }, - { x: -0.16, y: -0.68 }, - { x: 0.16, y: -0.68 }, - { x: 0.16, y: -0.90 }, - { x: 0.32, y: -0.90 }, - { x: 0.32, y: -0.68 }, - { x: 0.64, y: -0.68 }, - { x: 0.64, y: -0.45 }, - { x: 0.85, y: -0.45 }, - { x: 0.85, y: 0.0 }, - { x: 0.64, y: 0.0 }, - { x: 0.64, y: 0.25 }, - { x: 0.85, y: 0.25 }, - { x: 0.85, y: 0.70 }, - { x: 0.58, y: 0.70 }, - { x: 0.58, y: 0.48 }, - { x: 0.28, y: 0.48 }, - { x: 0.28, y: 0.82 }, - { x: 0.08, y: 0.82 }, - { x: 0.08, y: 0.55 }, - { x: -0.08, y: 0.55 }, - { x: -0.08, y: 0.82 }, - { x: -0.28, y: 0.82 }, - { x: -0.28, y: 0.48 }, - { x: -0.58, y: 0.48 }, - { x: -0.58, y: 0.70 }, - { x: -0.85, y: 0.70 }, - { x: -0.85, y: 0.25 }, - { x: -0.64, y: 0.25 }, - { x: -0.64, y: 0.0 }, - { x: -0.85, y: 0.0 }, - { x: -0.85, y: -0.45 }, - { x: -0.64, y: -0.45 }, - { x: -0.64, y: -0.68 }, - { x: -0.32, y: -0.68 } - ]; - } else if (shape === 'gamepad') { - verts = [ - { x: 0, y: -0.32 }, - { x: 0.35, y: -0.48 }, - { x: 0.70, y: -0.48 }, - { x: 0.88, y: -0.15 }, - { x: 0.92, y: 0.25 }, - { x: 0.72, y: 0.85 }, - { x: 0.45, y: 0.75 }, - { x: 0.30, y: 0.28 }, - { x: 0, y: 0.18 }, - { x: -0.30, y: 0.28 }, - { x: -0.45, y: 0.75 }, - { x: -0.72, y: 0.85 }, - { x: -0.92, y: 0.25 }, - { x: -0.88, y: -0.15 }, - { x: -0.70, y: -0.48 }, - { x: -0.35, y: -0.48 } - ]; - } else if (shape === 'saturn') { - const count = 48; - for (let i = 0; i < count; i++) { - const a = (i / count) * Math.PI * 2; - const sphereR = 0.58; - const ringFactor = Math.pow(Math.cos(a), 4); - const r = sphereR + (0.95 - sphereR) * ringFactor; - const rot = -0.22; - const rawX = Math.cos(a) * r; - const rawY = Math.sin(a) * (r * (0.55 + 0.45 * (1 - ringFactor))); - const px = rawX * Math.cos(rot) - rawY * Math.sin(rot); - const py = rawX * Math.sin(rot) + rawY * Math.cos(rot); - verts.push({ x: px, y: py }); - } - } else if (shape === 'rocket') { - verts = [ - { x: 0, y: -0.96 }, - { x: 0.22, y: -0.55 }, - { x: 0.36, y: -0.05 }, - { x: 0.40, y: 0.35 }, - { x: 0.82, y: 0.72 }, - { x: 0.52, y: 0.72 }, - { x: 0.24, y: 0.72 }, - { x: 0.16, y: 0.92 }, - { x: -0.16, y: 0.92 }, - { x: -0.24, y: 0.72 }, - { x: -0.52, y: 0.72 }, - { x: -0.82, y: 0.72 }, - { x: -0.40, y: 0.35 }, - { x: -0.36, y: -0.05 }, - { x: -0.22, y: -0.55 } - ]; - } else if (shape === 'snowflake') { - const arms = 6; - for (let i = 0; i < arms; i++) { - const baseAngle = (i / arms) * Math.PI * 2 - Math.PI / 2; - verts.push({ x: Math.cos(baseAngle) * 0.98, y: Math.sin(baseAngle) * 0.98 }); - verts.push({ x: Math.cos(baseAngle + 0.15) * 0.70, y: Math.sin(baseAngle + 0.15) * 0.70 }); - verts.push({ x: Math.cos(baseAngle + 0.08) * 0.52, y: Math.sin(baseAngle + 0.08) * 0.52 }); - verts.push({ x: Math.cos(baseAngle + Math.PI / arms) * 0.30, y: Math.sin(baseAngle + Math.PI / arms) * 0.30 }); - verts.push({ x: Math.cos(baseAngle + (Math.PI * 2 / arms) - 0.08) * 0.52, y: Math.sin(baseAngle + (Math.PI * 2 / arms) - 0.08) * 0.52 }); - verts.push({ x: Math.cos(baseAngle + (Math.PI * 2 / arms) - 0.15) * 0.70, y: Math.sin(baseAngle + (Math.PI * 2 / arms) - 0.15) * 0.70 }); - } - } else if (shape === 'leaf') { - verts = [ - { x: -0.04, y: 0.95 }, { x: -0.032, y: 0.317 }, { x: -0.065, y: 0.264 }, { x: -0.189, y: 0.301 }, - { x: -0.274, y: 0.253 }, { x: -0.389, y: 0.245 }, { x: -0.507, y: 0.151 }, { x: -0.361, y: 0.114 }, - { x: -0.253, y: 0.154 }, { x: -0.156, y: 0.143 }, { x: -0.057, y: 0.228 }, { x: -0.121, y: 0.193 }, - { x: -0.063, y: 0.222 }, { x: -0.204, y: 0.179 }, { x: -0.263, y: 0.087 }, { x: -0.399, y: 0.038 }, - { x: -0.429, y: -0.083 }, { x: -0.527, y: -0.169 }, { x: -0.586, y: -0.327 }, { x: -0.428, y: -0.268 }, - { x: -0.342, y: -0.17 }, { x: -0.221, y: -0.14 }, { x: -0.172, y: -0.004 }, { x: -0.08, y: 0.055 }, - { x: -0.037, y: 0.196 }, { x: -0.074, y: 0.143 }, { x: -0.041, y: 0.197 }, { x: -0.149, y: 0.096 }, - { x: -0.168, y: -0.013 }, { x: -0.287, y: -0.11 }, { x: -0.267, y: -0.233 }, { x: -0.363, y: -0.338 }, - { x: -0.329, y: -0.466 }, { x: -0.378, y: -0.589 }, { x: -0.37, y: -0.758 }, { x: -0.255, y: -0.634 }, - { x: -0.214, y: -0.508 }, { x: -0.106, y: -0.432 }, { x: -0.112, y: -0.289 }, { x: -0.017, y: -0.208 }, - { x: -0.046, y: -0.057 }, { x: 0.009, y: 0.038 }, { x: -0.009, y: 0.185 }, { x: -0.024, y: 0.123 }, - { x: -0.02, y: 0.178 }, { x: -0.097, y: 0.031 }, { x: -0.075, y: -0.09 }, { x: -0.166, y: -0.238 }, - { x: -0.095, y: -0.359 }, { x: -0.158, y: -0.507 }, { x: -0.071, y: -0.628 }, { x: -0.076, y: -0.775 }, - { x: 0, y: -0.95 }, { x: 0.076, y: -0.775 }, { x: 0.071, y: -0.628 }, { x: 0.158, y: -0.507 }, - { x: 0.095, y: -0.359 }, { x: 0.166, y: -0.238 }, { x: 0.075, y: -0.09 }, { x: 0.097, y: 0.031 }, - { x: 0.02, y: 0.178 }, { x: 0.024, y: 0.123 }, { x: 0.009, y: 0.185 }, { x: -0.009, y: 0.038 }, - { x: 0.046, y: -0.057 }, { x: 0.017, y: -0.208 }, { x: 0.112, y: -0.289 }, { x: 0.106, y: -0.432 }, - { x: 0.214, y: -0.508 }, { x: 0.255, y: -0.634 }, { x: 0.37, y: -0.758 }, { x: 0.378, y: -0.589 }, - { x: 0.329, y: -0.466 }, { x: 0.363, y: -0.338 }, { x: 0.267, y: -0.233 }, { x: 0.287, y: -0.11 }, - { x: 0.168, y: -0.013 }, { x: 0.149, y: 0.096 }, { x: 0.041, y: 0.197 }, { x: 0.074, y: 0.143 }, - { x: 0.037, y: 0.196 }, { x: 0.08, y: 0.055 }, { x: 0.172, y: -0.004 }, { x: 0.221, y: -0.14 }, - { x: 0.342, y: -0.17 }, { x: 0.428, y: -0.268 }, { x: 0.586, y: -0.327 }, { x: 0.527, y: -0.169 }, - { x: 0.429, y: -0.083 }, { x: 0.399, y: 0.038 }, { x: 0.263, y: 0.087 }, { x: 0.204, y: 0.179 }, - { x: 0.063, y: 0.222 }, { x: 0.121, y: 0.193 }, { x: 0.057, y: 0.228 }, { x: 0.156, y: 0.143 }, - { x: 0.253, y: 0.154 }, { x: 0.361, y: 0.114 }, { x: 0.507, y: 0.151 }, { x: 0.389, y: 0.245 }, - { x: 0.274, y: 0.253 }, { x: 0.189, y: 0.301 }, { x: 0.065, y: 0.264 }, { x: 0.032, y: 0.317 }, - { x: 0.04, y: 0.95 } - ]; - } else if (shape === 'bat') { - verts = [ - { x: 0, y: -0.45 }, - { x: 0.12, y: -0.68 }, - { x: 0.22, y: -0.45 }, - { x: 0.55, y: -0.78 }, - { x: 0.96, y: -0.42 }, - { x: 0.76, y: -0.05 }, - { x: 0.74, y: 0.28 }, - { x: 0.48, y: 0.32 }, - { x: 0.38, y: 0.58 }, - { x: 0.18, y: 0.48 }, - { x: 0, y: 0.78 }, - { x: -0.18, y: 0.48 }, - { x: -0.38, y: 0.58 }, - { x: -0.48, y: 0.32 }, - { x: -0.74, y: 0.28 }, - { x: -0.76, y: -0.05 }, - { x: -0.96, y: -0.42 }, - { x: -0.55, y: -0.78 }, - { x: -0.22, y: -0.45 }, - { x: -0.12, y: -0.68 } - ]; - } else if (shape === 'wolf') { - verts = [ - { x: 0, y: -0.52 }, - { x: 0.35, y: -0.50 }, - { x: 0.62, y: -0.92 }, - { x: 0.68, y: -0.45 }, - { x: 0.92, y: -0.05 }, - { x: 0.68, y: 0.18 }, - { x: 0.78, y: 0.42 }, - { x: 0.42, y: 0.50 }, - { x: 0.28, y: 0.88 }, - { x: 0, y: 0.95 }, - { x: -0.28, y: 0.88 }, - { x: -0.42, y: 0.50 }, - { x: -0.78, y: 0.42 }, - { x: -0.68, y: 0.18 }, - { x: -0.92, y: -0.05 }, - { x: -0.68, y: -0.45 }, - { x: -0.62, y: -0.92 }, - { x: -0.35, y: -0.50 } - ]; - } else if (shape === 'anchor') { - verts = [ - { x: 0, y: -0.94 }, - { x: 0.18, y: -0.82 }, - { x: 0.08, y: -0.70 }, - { x: 0.65, y: -0.65 }, - { x: 0.65, y: -0.52 }, - { x: 0.12, y: -0.52 }, - { x: 0.12, y: 0.35 }, - { x: 0.88, y: 0.22 }, - { x: 0.78, y: 0.58 }, - { x: 0.38, y: 0.82 }, - { x: 0, y: 0.92 }, - { x: -0.38, y: 0.82 }, - { x: -0.78, y: 0.58 }, - { x: -0.88, y: 0.22 }, - { x: -0.12, y: 0.35 }, - { x: -0.12, y: -0.52 }, - { x: -0.65, y: -0.52 }, - { x: -0.65, y: -0.65 }, - { x: -0.08, y: -0.70 }, - { x: -0.18, y: -0.82 } - ]; - } else if (shape === 'cat') { - verts = [ - { x: 0, y: -0.48 }, - { x: 0.30, y: -0.48 }, - { x: 0.68, y: -0.92 }, - { x: 0.72, y: -0.35 }, - { x: 0.94, y: 0.05 }, - { x: 0.88, y: 0.48 }, - { x: 0.45, y: 0.82 }, - { x: 0, y: 0.86 }, - { x: -0.45, y: 0.82 }, - { x: -0.88, y: 0.48 }, - { x: -0.94, y: 0.05 }, - { x: -0.72, y: -0.35 }, - { x: -0.68, y: -0.92 }, - { x: -0.30, y: -0.48 } - ]; - } - - SHAPE_VERTICES_CACHE[shape] = verts; - return verts; - } - - function drawShapeGeometryPath(targetCtx, shape, radius) { - targetCtx.beginPath(); - if (!shape || shape === 'circle' || shape === 'tunnel' || shape === 'blob' || shape === 'linear' || shape === 'oscilloscope' || SPECIAL_INSTRUMENTS.includes(shape)) { - targetCtx.arc(0, 0, radius, 0, Math.PI * 2); - targetCtx.closePath(); - return; - } - - const verts = generateShapeVertices(shape); - if (!verts || verts.length === 0) { - targetCtx.arc(0, 0, radius, 0, Math.PI * 2); - targetCtx.closePath(); - return; - } - - if (shape === 'skull') { - // Outer skull outline - targetCtx.moveTo(verts[0].x * radius, verts[0].y * radius); - for (let i = 1; i < verts.length; i++) { - targetCtx.lineTo(verts[i].x * radius, verts[i].y * radius); - } - targetCtx.closePath(); - - // Left eye socket - const eyeR = 0.13 * radius; - const eyeY = -0.10 * radius; - const leftEyeX = -0.28 * radius; - targetCtx.moveTo(leftEyeX + eyeR, eyeY); - targetCtx.arc(leftEyeX, eyeY, eyeR, 0, Math.PI * 2); - targetCtx.closePath(); - - // Right eye socket - const rightEyeX = 0.28 * radius; - targetCtx.moveTo(rightEyeX + eyeR, eyeY); - targetCtx.arc(rightEyeX, eyeY, eyeR, 0, Math.PI * 2); - targetCtx.closePath(); - - // Inverted triangle nasal cavity - targetCtx.moveTo(0, 0.08 * radius); - targetCtx.lineTo(0.08 * radius, 0.22 * radius); - targetCtx.lineTo(-0.08 * radius, 0.22 * radius); - targetCtx.closePath(); - - // Vertical tooth slits along jaw - const teethTop = 0.58 * radius; - const teethBot = 0.78 * radius; - [-0.24, -0.08, 0.08, 0.24].forEach(tx => { - targetCtx.moveTo(tx * radius, teethTop); - targetCtx.lineTo(tx * radius, teethBot); - }); - return; - } - - if (shape === 'flame') { - // Outer fire silhouette (licking 3-tongue flame) - targetCtx.moveTo(verts[0].x * radius, verts[0].y * radius); - for (let i = 1; i < verts.length; i++) { - targetCtx.lineTo(verts[i].x * radius, verts[i].y * radius); - } - targetCtx.closePath(); - - // Inner dancing core flame - targetCtx.moveTo(0, -0.35 * radius); - targetCtx.quadraticCurveTo(0.25 * radius, 0.05 * radius, 0.20 * radius, 0.40 * radius); - targetCtx.quadraticCurveTo(0.14 * radius, 0.70 * radius, 0, 0.76 * radius); - targetCtx.quadraticCurveTo(-0.14 * radius, 0.70 * radius, -0.20 * radius, 0.40 * radius); - targetCtx.quadraticCurveTo(-0.25 * radius, 0.05 * radius, 0, -0.35 * radius); - targetCtx.closePath(); - return; - } - - if (shape === 'leaf') { - // Outer cannabis leaf silhouette (7 serrated leaflets) - targetCtx.moveTo(verts[0].x * radius, verts[0].y * radius); - for (let i = 1; i < verts.length; i++) { - targetCtx.lineTo(verts[i].x * radius, verts[i].y * radius); - } - targetCtx.closePath(); - - // Central stem petiole - const baseNodeY = 0.259 * radius; - targetCtx.moveTo(0, baseNodeY); - targetCtx.lineTo(0, 0.95 * radius); - - // Primary veins radiating to each of the 7 leaflet tips - const tips = [ - { x: -0.507, y: 0.151 }, - { x: -0.586, y: -0.327 }, - { x: -0.370, y: -0.758 }, - { x: 0, y: -0.950 }, - { x: 0.370, y: -0.758 }, - { x: 0.586, y: -0.327 }, - { x: 0.507, y: 0.151 } - ]; - for (const tip of tips) { - targetCtx.moveTo(0, baseNodeY); - targetCtx.lineTo(tip.x * radius, tip.y * radius); - } - return; - } - - if (shape === 'infinity') { - targetCtx.moveTo(0, 0); - for (let i = 1; i < verts.length; i++) { - targetCtx.lineTo(verts[i].x * radius, verts[i].y * radius); - } - targetCtx.closePath(); - return; - } - - targetCtx.moveTo(verts[0].x * radius, verts[0].y * radius); - for (let i = 1; i < verts.length; i++) { - targetCtx.lineTo(verts[i].x * radius, verts[i].y * radius); - } - targetCtx.closePath(); - } - - // --- Helper: Polygon Path Generator (Backwards Compatible) --- - function drawPolygonPath(targetCtx, sides, radius, cornerRadius = 0) { - if (typeof sides === 'string') { - drawShapeGeometryPath(targetCtx, sides, radius); - return; - } - targetCtx.beginPath(); - if (!sides || sides === 0) { - targetCtx.arc(0, 0, radius, 0, Math.PI * 2); - targetCtx.closePath(); - return; - } - - const angleStep = (Math.PI * 2) / sides; - const startAngle = -Math.PI / 2; - - for (let i = 0; i < sides; i++) { - const a = startAngle + i * angleStep; - const x = Math.cos(a) * radius; - const y = Math.sin(a) * radius; - if (i === 0) targetCtx.moveTo(x, y); - else targetCtx.lineTo(x, y); - } - targetCtx.closePath(); - } - + // --- Shape Geometry Cache & Generator System --- + export const SHAPE_VERTICES_CACHE = {}; - // --- Helper: Fluid / Organic Liquid Blob Radius Evaluation --- - function getBlobRadiusAtAngle(angle, baseRadius, processedBars, rawBassEnergy, midEnergy, currentTime) { - const twoPi = Math.PI * 2; - const normAngle = ((angle % twoPi) + twoPi) % twoPi; - const barCount = (processedBars && processedBars.length > 0) ? processedBars.length : 96; - const barIdx = Math.floor((normAngle / twoPi) * barCount) % barCount; - const freqBoost = (processedBars && processedBars[barIdx] ? processedBars[barIdx] : 0) * 0.42; - - const wave1 = Math.sin(angle * 3 + currentTime * 3.2) * (rawBassEnergy * 28); - const wave2 = Math.cos(angle * 2 - currentTime * 2.6) * (midEnergy * 20); - return Math.max(30, baseRadius + freqBoost + wave1 + wave2); - } - - // --- Helper: Precise Shape Geometry Perimeter Point & Smooth Radial Normal --- - function getShapeGeometryPoint(shape, radius, t) { - if (shape === 'blob') { - const rot = state.currentRotation || 0; - const angle = t * Math.PI * 2 - Math.PI / 2 + rot; - const bd = state._blobRenderData; - const r = getBlobRadiusAtAngle( - angle, - radius, - bd ? bd.activeBars : null, - bd ? bd.rawBassEnergy : 0, - bd ? bd.midEnergy : 0, - bd ? bd.currentTime : 0 - ); - const px = Math.cos(angle) * r; - const py = Math.sin(angle) * r; - const dist = Math.hypot(px, py) || 1; - return { - x: px, - y: py, - nx: px / dist, - ny: py / dist, - angle: angle - }; - } - - if (!shape || shape === 'circle' || shape === 'tunnel' || shape === 'linear' || shape === 'oscilloscope' || SPECIAL_INSTRUMENTS.includes(shape)) { - const angle = t * Math.PI * 2 - Math.PI / 2; - return { - x: Math.cos(angle) * radius, - y: Math.sin(angle) * radius, - nx: Math.cos(angle), - ny: Math.sin(angle), - angle: angle - }; - } - - const verts = generateShapeVertices(shape); - if (!verts || verts.length === 0) { - const angle = t * Math.PI * 2 - Math.PI / 2; - return { - x: Math.cos(angle) * radius, - y: Math.sin(angle) * radius, - nx: Math.cos(angle), - ny: Math.sin(angle), - angle: angle - }; - } - - const N = verts.length; - const totalProgress = ((t % 1) + 1) % 1; - const sideProgress = totalProgress * N; - const sideIdx = Math.floor(sideProgress) % N; - const u = sideProgress - Math.floor(sideProgress); - - const p1 = verts[sideIdx]; - const p2 = verts[(sideIdx + 1) % N]; - - const px = (p1.x + u * (p2.x - p1.x)) * radius; - const py = (p1.y + u * (p2.y - p1.y)) * radius; - const dist = Math.hypot(px, py) || 1; - - return { - x: px, - y: py, - nx: px / dist, - ny: py / dist, - angle: Math.atan2(py, px) - }; - } - - // --- Helper: Precise Polygon Perimeter Point & Smooth Radial Normal (Backwards Compatible) --- - function getPolygonPerimeterPoint(sides, radius, t) { - if (typeof sides === 'string') { - return getShapeGeometryPoint(sides, radius, t); - } - if (!sides || sides < 3) { - const angle = t * Math.PI * 2 - Math.PI / 2; - return { - x: Math.cos(angle) * radius, - y: Math.sin(angle) * radius, - nx: Math.cos(angle), - ny: Math.sin(angle), - angle: angle - }; - } - const totalProgress = ((t % 1) + 1) % 1; - const sideProgress = totalProgress * sides; - const sideIdx = Math.floor(sideProgress) % sides; - const u = sideProgress - Math.floor(sideProgress); - const angleStep = (Math.PI * 2) / sides; - const a1 = -Math.PI / 2 + sideIdx * angleStep; - const a2 = -Math.PI / 2 + ((sideIdx + 1) % sides) * angleStep; - const x1 = Math.cos(a1) * radius; - const y1 = Math.sin(a1) * radius; - const x2 = Math.cos(a2) * radius; - const y2 = Math.sin(a2) * radius; - const px = x1 + u * (x2 - x1); - const py = y1 + u * (y2 - y1); - const dist = Math.hypot(px, py) || 1; - // Radial outward ray from center through perimeter point (eliminates vertex gaps & comb artifacts) - const nx = px / dist; - const ny = py / dist; - return { - x: px, - y: py, - nx: nx, - ny: ny, - angle: Math.atan2(py, px) - }; - } - - // --- Helper: Fluid / Organic Liquid Blob Path --- - function drawFluidBlob(targetCtx, baseRadius, processedBars, rawBassEnergy, midEnergy, currentTime) { - const steps = 140; - const rot = state.currentRotation || 0; - targetCtx.beginPath(); - for (let i = 0; i <= steps; i++) { - const t = i / steps; - const angle = t * Math.PI * 2 - Math.PI / 2 + rot; - const r = getBlobRadiusAtAngle(angle, baseRadius, processedBars, rawBassEnergy, midEnergy, currentTime); - const x = Math.cos(angle) * r; - const y = Math.sin(angle) * r; - if (i === 0) targetCtx.moveTo(x, y); - else targetCtx.lineTo(x, y); - } - targetCtx.closePath(); - } - \ No newline at end of file + function getRegularPolygonVertices(sides, startAngle = -Math.PI / 2) { + const verts = []; + const step = (Math.PI * 2) / sides; + for (let i = 0; i < sides; i++) { + const a = startAngle + i * step; + verts.push({ x: Math.cos(a), y: Math.sin(a) }); + } + return verts; + } + + function generateShapeVertices(shape) { + if (SHAPE_VERTICES_CACHE[shape]) return SHAPE_VERTICES_CACHE[shape]; + + let verts = []; + if (shape === 'triangle') { + verts = getRegularPolygonVertices(3, -Math.PI / 2); + } else if (shape === 'diamond') { + verts = getRegularPolygonVertices(4, -Math.PI / 2); + } else if (shape === 'square') { + verts = getRegularPolygonVertices(4, -Math.PI * 0.75); + } else if (shape === 'pentagon') { + verts = getRegularPolygonVertices(5, -Math.PI / 2); + } else if (shape === 'hexagon') { + verts = getRegularPolygonVertices(6, -Math.PI / 2); + } else if (shape === 'heptagon') { + verts = getRegularPolygonVertices(7, -Math.PI / 2); + } else if (shape === 'octagon') { + verts = getRegularPolygonVertices(8, -Math.PI / 2); + } else if (shape === 'star') { + const n = 5; + const step = Math.PI / n; + const start = -Math.PI / 2; + for (let i = 0; i < n * 2; i++) { + const r = (i % 2 === 0) ? 1.0 : 0.48; + const a = start + i * step; + verts.push({ x: Math.cos(a) * r, y: Math.sin(a) * r }); + } + } else if (shape === 'star8') { + const n = 8; + const step = Math.PI / n; + const start = -Math.PI / 2; + for (let i = 0; i < n * 2; i++) { + const r = (i % 2 === 0) ? 1.0 : 0.54; + const a = start + i * step; + verts.push({ x: Math.cos(a) * r, y: Math.sin(a) * r }); + } + } else if (shape === 'cross') { + const w = 0.38; + verts = [ + { x: -w, y: -1.0 }, { x: w, y: -1.0 }, { x: w, y: -w }, + { x: 1.0, y: -w }, { x: 1.0, y: w }, { x: w, y: w }, + { x: w, y: 1.0 }, { x: -w, y: 1.0 }, { x: -w, y: w }, + { x: -1.0, y: w }, { x: -1.0, y: -w }, { x: -w, y: -w } + ]; + } else if (shape === 'heart') { + const count = 72; + const s = 1.0 / 17.2; + for (let i = 0; i < count; i++) { + const theta = (i / count) * Math.PI * 2; + const px = 16 * Math.pow(Math.sin(theta), 3) * s; + const py = -(13 * Math.cos(theta) - 5 * Math.cos(2 * theta) - 2 * Math.cos(3 * theta) - Math.cos(4 * theta)) * s + 0.14; + verts.push({ x: px, y: py }); + } + } else if (shape === 'flower') { + const count = 72; + for (let i = 0; i < count; i++) { + const theta = (i / count) * Math.PI * 2 - Math.PI / 2; + const r = 0.76 + 0.24 * Math.cos(6 * (i / count) * Math.PI * 2); + verts.push({ x: Math.cos(theta) * r, y: Math.sin(theta) * r }); + } + } else if (shape === 'lightning') { + verts = [ + { x: 0.18, y: -1.0 }, + { x: -0.42, y: -0.08 }, + { x: -0.06, y: -0.08 }, + { x: -0.32, y: 1.0 }, + { x: 0.42, y: 0.08 }, + { x: 0.06, y: 0.08 } + ]; + } else if (shape === 'flame') { + verts = [ + { x: 0, y: -0.96 }, + { x: 0.10, y: -0.78 }, + { x: 0.18, y: -0.55 }, + { x: 0.22, y: -0.36 }, + { x: 0.32, y: -0.48 }, + { x: 0.50, y: -0.60 }, + { x: 0.46, y: -0.35 }, + { x: 0.44, y: -0.16 }, + { x: 0.62, y: -0.24 }, + { x: 0.76, y: -0.06 }, + { x: 0.70, y: 0.18 }, + { x: 0.80, y: 0.42 }, + { x: 0.68, y: 0.72 }, + { x: 0.38, y: 0.90 }, + { x: 0, y: 0.95 }, + { x: -0.38, y: 0.90 }, + { x: -0.68, y: 0.72 }, + { x: -0.80, y: 0.42 }, + { x: -0.70, y: 0.18 }, + { x: -0.76, y: -0.06 }, + { x: -0.62, y: -0.24 }, + { x: -0.44, y: -0.16 }, + { x: -0.46, y: -0.35 }, + { x: -0.50, y: -0.60 }, + { x: -0.32, y: -0.48 }, + { x: -0.22, y: -0.36 }, + { x: -0.18, y: -0.55 }, + { x: -0.10, y: -0.78 } + ]; + } else if (shape === 'skull') { + verts = [ + { x: 0, y: -0.95 }, + { x: 0.38, y: -0.90 }, + { x: 0.70, y: -0.68 }, + { x: 0.84, y: -0.32 }, + { x: 0.82, y: 0.0 }, + { x: 0.62, y: 0.18 }, + { x: 0.72, y: 0.38 }, + { x: 0.48, y: 0.52 }, + { x: 0.42, y: 0.88 }, + { x: 0.20, y: 0.94 }, + { x: 0, y: 0.95 }, + { x: -0.20, y: 0.94 }, + { x: -0.42, y: 0.88 }, + { x: -0.48, y: 0.52 }, + { x: -0.72, y: 0.38 }, + { x: -0.62, y: 0.18 }, + { x: -0.82, y: 0.0 }, + { x: -0.84, y: -0.32 }, + { x: -0.70, y: -0.68 }, + { x: -0.38, y: -0.90 } + ]; + } else if (shape === 'crown') { + verts = [ + { x: 0, y: -0.95 }, + { x: 0.24, y: -0.32 }, + { x: 0.50, y: -0.80 }, + { x: 0.66, y: -0.25 }, + { x: 0.88, y: -0.65 }, + { x: 0.82, y: 0.70 }, + { x: 0.40, y: 0.75 }, + { x: 0, y: 0.76 }, + { x: -0.40, y: 0.75 }, + { x: -0.82, y: 0.70 }, + { x: -0.88, y: -0.65 }, + { x: -0.66, y: -0.25 }, + { x: -0.50, y: -0.80 }, + { x: -0.24, y: -0.32 } + ]; + } else if (shape === 'infinity') { + const count = 64; + for (let i = 0; i < count; i++) { + const t = (i / count) * Math.PI * 2; + verts.push({ x: 0.94 * Math.sin(t), y: 0.52 * Math.sin(2 * t) }); + } + } else if (shape === 'eye') { + const count = 32; + for (let i = 0; i <= count; i++) { + const t = i / count; + verts.push({ x: -0.95 + 1.90 * t, y: -Math.sin(t * Math.PI) * 0.62 }); + } + for (let i = count - 1; i > 0; i--) { + const t = i / count; + verts.push({ x: -0.95 + 1.90 * t, y: Math.sin(t * Math.PI) * 0.62 }); + } + } else if (shape === 'gem') { + verts = [ + { x: -0.52, y: -0.85 }, + { x: 0.52, y: -0.85 }, + { x: 0.92, y: -0.32 }, + { x: 0, y: 0.98 }, + { x: -0.92, y: -0.32 } + ]; + } else if (shape === 'pick') { + const count = 48; + for (let i = 0; i < count; i++) { + const a = (i / count) * Math.PI * 2; + const px = 0.80 * Math.cos(a) * (1.0 - 0.45 * Math.sin(a)); + const py = 0.92 * Math.sin(a); + verts.push({ x: px, y: py }); + } + } else if (shape === 'sun') { + const n = 16; + const step = Math.PI / n; + const start = -Math.PI / 2; + for (let i = 0; i < n * 2; i++) { + const r = (i % 2 === 0) ? 1.0 : 0.72; + const a = start + i * step; + verts.push({ x: Math.cos(a) * r, y: Math.sin(a) * r }); + } + } else if (shape === 'droplet') { + const count = 48; + for (let i = 0; i < count; i++) { + const u = (i / count) * Math.PI * 2; + const px = 0.76 * Math.sin(u) * Math.sin(u / 2); + const py = -Math.cos(u) * 0.92 - 0.08 * Math.cos(2 * u) + 0.08; + verts.push({ x: px, y: py }); + } + } else if (shape === 'hourglass') { + verts = [ + { x: -0.80, y: -0.92 }, + { x: 0.80, y: -0.92 }, + { x: 0.18, y: 0.0 }, + { x: 0.80, y: 0.92 }, + { x: -0.80, y: 0.92 }, + { x: -0.18, y: 0.0 } + ]; + } else if (shape === 'butterfly') { + verts = [ + { x: 0, y: -0.75 }, + { x: 0.22, y: -0.68 }, + { x: 0.62, y: -0.88 }, + { x: 0.95, y: -0.48 }, + { x: 0.72, y: -0.10 }, + { x: 0.38, y: -0.02 }, + { x: 0.78, y: 0.35 }, + { x: 0.55, y: 0.72 }, + { x: 0.20, y: 0.62 }, + { x: 0, y: 0.82 }, + { x: -0.20, y: 0.62 }, + { x: -0.55, y: 0.72 }, + { x: -0.78, y: 0.35 }, + { x: -0.38, y: 0.02 }, + { x: -0.72, y: -0.10 }, + { x: -0.95, y: -0.48 }, + { x: -0.62, y: -0.88 }, + { x: -0.22, y: -0.68 } + ]; + } else if (shape === 'hexagram') { + const n = 6; + const step = Math.PI / n; + const start = -Math.PI / 2; + for (let i = 0; i < n * 2; i++) { + const r = (i % 2 === 0) ? 1.0 : 0.577; + const a = start + i * step; + verts.push({ x: Math.cos(a) * r, y: Math.sin(a) * r }); + } + } else if (shape === 'speaker') { + verts = [ + { x: -0.75, y: -0.30 }, + { x: 0.68, y: -0.75 }, + { x: 0.85, y: -0.45 }, + { x: 0.92, y: 0 }, + { x: 0.85, y: 0.45 }, + { x: 0.68, y: 0.75 }, + { x: -0.42, y: 0.30 }, + { x: -0.42, y: 0.85 }, + { x: -0.65, y: 0.85 }, + { x: -0.65, y: 0.30 }, + { x: -0.75, y: 0.30 } + ]; + } else if (shape === 'invader') { + verts = [ + { x: -0.32, y: -0.90 }, + { x: -0.16, y: -0.90 }, + { x: -0.16, y: -0.68 }, + { x: 0.16, y: -0.68 }, + { x: 0.16, y: -0.90 }, + { x: 0.32, y: -0.90 }, + { x: 0.32, y: -0.68 }, + { x: 0.64, y: -0.68 }, + { x: 0.64, y: -0.45 }, + { x: 0.85, y: -0.45 }, + { x: 0.85, y: 0.0 }, + { x: 0.64, y: 0.0 }, + { x: 0.64, y: 0.25 }, + { x: 0.85, y: 0.25 }, + { x: 0.85, y: 0.70 }, + { x: 0.58, y: 0.70 }, + { x: 0.58, y: 0.48 }, + { x: 0.28, y: 0.48 }, + { x: 0.28, y: 0.82 }, + { x: 0.08, y: 0.82 }, + { x: 0.08, y: 0.55 }, + { x: -0.08, y: 0.55 }, + { x: -0.08, y: 0.82 }, + { x: -0.28, y: 0.82 }, + { x: -0.28, y: 0.48 }, + { x: -0.58, y: 0.48 }, + { x: -0.58, y: 0.70 }, + { x: -0.85, y: 0.70 }, + { x: -0.85, y: 0.25 }, + { x: -0.64, y: 0.25 }, + { x: -0.64, y: 0.0 }, + { x: -0.85, y: 0.0 }, + { x: -0.85, y: -0.45 }, + { x: -0.64, y: -0.45 }, + { x: -0.64, y: -0.68 }, + { x: -0.32, y: -0.68 } + ]; + } else if (shape === 'gamepad') { + verts = [ + { x: 0, y: -0.32 }, + { x: 0.35, y: -0.48 }, + { x: 0.70, y: -0.48 }, + { x: 0.88, y: -0.15 }, + { x: 0.92, y: 0.25 }, + { x: 0.72, y: 0.85 }, + { x: 0.45, y: 0.75 }, + { x: 0.30, y: 0.28 }, + { x: 0, y: 0.18 }, + { x: -0.30, y: 0.28 }, + { x: -0.45, y: 0.75 }, + { x: -0.72, y: 0.85 }, + { x: -0.92, y: 0.25 }, + { x: -0.88, y: -0.15 }, + { x: -0.70, y: -0.48 }, + { x: -0.35, y: -0.48 } + ]; + } else if (shape === 'saturn') { + const count = 48; + for (let i = 0; i < count; i++) { + const a = (i / count) * Math.PI * 2; + const sphereR = 0.58; + const ringFactor = Math.pow(Math.cos(a), 4); + const r = sphereR + (0.95 - sphereR) * ringFactor; + const rot = -0.22; + const rawX = Math.cos(a) * r; + const rawY = Math.sin(a) * (r * (0.55 + 0.45 * (1 - ringFactor))); + const px = rawX * Math.cos(rot) - rawY * Math.sin(rot); + const py = rawX * Math.sin(rot) + rawY * Math.cos(rot); + verts.push({ x: px, y: py }); + } + } else if (shape === 'rocket') { + verts = [ + { x: 0, y: -0.96 }, + { x: 0.22, y: -0.55 }, + { x: 0.36, y: -0.05 }, + { x: 0.40, y: 0.35 }, + { x: 0.82, y: 0.72 }, + { x: 0.52, y: 0.72 }, + { x: 0.24, y: 0.72 }, + { x: 0.16, y: 0.92 }, + { x: -0.16, y: 0.92 }, + { x: -0.24, y: 0.72 }, + { x: -0.52, y: 0.72 }, + { x: -0.82, y: 0.72 }, + { x: -0.40, y: 0.35 }, + { x: -0.36, y: -0.05 }, + { x: -0.22, y: -0.55 } + ]; + } else if (shape === 'snowflake') { + const arms = 6; + for (let i = 0; i < arms; i++) { + const baseAngle = (i / arms) * Math.PI * 2 - Math.PI / 2; + verts.push({ x: Math.cos(baseAngle) * 0.98, y: Math.sin(baseAngle) * 0.98 }); + verts.push({ x: Math.cos(baseAngle + 0.15) * 0.70, y: Math.sin(baseAngle + 0.15) * 0.70 }); + verts.push({ x: Math.cos(baseAngle + 0.08) * 0.52, y: Math.sin(baseAngle + 0.08) * 0.52 }); + verts.push({ x: Math.cos(baseAngle + Math.PI / arms) * 0.30, y: Math.sin(baseAngle + Math.PI / arms) * 0.30 }); + verts.push({ x: Math.cos(baseAngle + (Math.PI * 2 / arms) - 0.08) * 0.52, y: Math.sin(baseAngle + (Math.PI * 2 / arms) - 0.08) * 0.52 }); + verts.push({ x: Math.cos(baseAngle + (Math.PI * 2 / arms) - 0.15) * 0.70, y: Math.sin(baseAngle + (Math.PI * 2 / arms) - 0.15) * 0.70 }); + } + } else if (shape === 'leaf') { + verts = [ + { x: -0.04, y: 0.95 }, { x: -0.032, y: 0.317 }, { x: -0.065, y: 0.264 }, { x: -0.189, y: 0.301 }, + { x: -0.274, y: 0.253 }, { x: -0.389, y: 0.245 }, { x: -0.507, y: 0.151 }, { x: -0.361, y: 0.114 }, + { x: -0.253, y: 0.154 }, { x: -0.156, y: 0.143 }, { x: -0.057, y: 0.228 }, { x: -0.121, y: 0.193 }, + { x: -0.063, y: 0.222 }, { x: -0.204, y: 0.179 }, { x: -0.263, y: 0.087 }, { x: -0.399, y: 0.038 }, + { x: -0.429, y: -0.083 }, { x: -0.527, y: -0.169 }, { x: -0.586, y: -0.327 }, { x: -0.428, y: -0.268 }, + { x: -0.342, y: -0.17 }, { x: -0.221, y: -0.14 }, { x: -0.172, y: -0.004 }, { x: -0.08, y: 0.055 }, + { x: -0.037, y: 0.196 }, { x: -0.074, y: 0.143 }, { x: -0.041, y: 0.197 }, { x: -0.149, y: 0.096 }, + { x: -0.168, y: -0.013 }, { x: -0.287, y: -0.11 }, { x: -0.267, y: -0.233 }, { x: -0.363, y: -0.338 }, + { x: -0.329, y: -0.466 }, { x: -0.378, y: -0.589 }, { x: -0.37, y: -0.758 }, { x: -0.255, y: -0.634 }, + { x: -0.214, y: -0.508 }, { x: -0.106, y: -0.432 }, { x: -0.112, y: -0.289 }, { x: -0.017, y: -0.208 }, + { x: -0.046, y: -0.057 }, { x: 0.009, y: 0.038 }, { x: -0.009, y: 0.185 }, { x: -0.024, y: 0.123 }, + { x: -0.02, y: 0.178 }, { x: -0.097, y: 0.031 }, { x: -0.075, y: -0.09 }, { x: -0.166, y: -0.238 }, + { x: -0.095, y: -0.359 }, { x: -0.158, y: -0.507 }, { x: -0.071, y: -0.628 }, { x: -0.076, y: -0.775 }, + { x: 0, y: -0.95 }, { x: 0.076, y: -0.775 }, { x: 0.071, y: -0.628 }, { x: 0.158, y: -0.507 }, + { x: 0.095, y: -0.359 }, { x: 0.166, y: -0.238 }, { x: 0.075, y: -0.09 }, { x: 0.097, y: 0.031 }, + { x: 0.02, y: 0.178 }, { x: 0.024, y: 0.123 }, { x: 0.009, y: 0.185 }, { x: -0.009, y: 0.038 }, + { x: 0.046, y: -0.057 }, { x: 0.017, y: -0.208 }, { x: 0.112, y: -0.289 }, { x: 0.106, y: -0.432 }, + { x: 0.214, y: -0.508 }, { x: 0.255, y: -0.634 }, { x: 0.37, y: -0.758 }, { x: 0.378, y: -0.589 }, + { x: 0.329, y: -0.466 }, { x: 0.363, y: -0.338 }, { x: 0.267, y: -0.233 }, { x: 0.287, y: -0.11 }, + { x: 0.168, y: -0.013 }, { x: 0.149, y: 0.096 }, { x: 0.041, y: 0.197 }, { x: 0.074, y: 0.143 }, + { x: 0.037, y: 0.196 }, { x: 0.08, y: 0.055 }, { x: 0.172, y: -0.004 }, { x: 0.221, y: -0.14 }, + { x: 0.342, y: -0.17 }, { x: 0.428, y: -0.268 }, { x: 0.586, y: -0.327 }, { x: 0.527, y: -0.169 }, + { x: 0.429, y: -0.083 }, { x: 0.399, y: 0.038 }, { x: 0.263, y: 0.087 }, { x: 0.204, y: 0.179 }, + { x: 0.063, y: 0.222 }, { x: 0.121, y: 0.193 }, { x: 0.057, y: 0.228 }, { x: 0.156, y: 0.143 }, + { x: 0.253, y: 0.154 }, { x: 0.361, y: 0.114 }, { x: 0.507, y: 0.151 }, { x: 0.389, y: 0.245 }, + { x: 0.274, y: 0.253 }, { x: 0.189, y: 0.301 }, { x: 0.065, y: 0.264 }, { x: 0.032, y: 0.317 }, + { x: 0.04, y: 0.95 } + ]; + } else if (shape === 'bat') { + verts = [ + { x: 0, y: -0.45 }, + { x: 0.12, y: -0.68 }, + { x: 0.22, y: -0.45 }, + { x: 0.55, y: -0.78 }, + { x: 0.96, y: -0.42 }, + { x: 0.76, y: -0.05 }, + { x: 0.74, y: 0.28 }, + { x: 0.48, y: 0.32 }, + { x: 0.38, y: 0.58 }, + { x: 0.18, y: 0.48 }, + { x: 0, y: 0.78 }, + { x: -0.18, y: 0.48 }, + { x: -0.38, y: 0.58 }, + { x: -0.48, y: 0.32 }, + { x: -0.74, y: 0.28 }, + { x: -0.76, y: -0.05 }, + { x: -0.96, y: -0.42 }, + { x: -0.55, y: -0.78 }, + { x: -0.22, y: -0.45 }, + { x: -0.12, y: -0.68 } + ]; + } else if (shape === 'wolf') { + verts = [ + { x: 0, y: -0.52 }, + { x: 0.35, y: -0.50 }, + { x: 0.62, y: -0.92 }, + { x: 0.68, y: -0.45 }, + { x: 0.92, y: -0.05 }, + { x: 0.68, y: 0.18 }, + { x: 0.78, y: 0.42 }, + { x: 0.42, y: 0.50 }, + { x: 0.28, y: 0.88 }, + { x: 0, y: 0.95 }, + { x: -0.28, y: 0.88 }, + { x: -0.42, y: 0.50 }, + { x: -0.78, y: 0.42 }, + { x: -0.68, y: 0.18 }, + { x: -0.92, y: -0.05 }, + { x: -0.68, y: -0.45 }, + { x: -0.62, y: -0.92 }, + { x: -0.35, y: -0.50 } + ]; + } else if (shape === 'anchor') { + verts = [ + { x: 0, y: -0.94 }, + { x: 0.18, y: -0.82 }, + { x: 0.08, y: -0.70 }, + { x: 0.65, y: -0.65 }, + { x: 0.65, y: -0.52 }, + { x: 0.12, y: -0.52 }, + { x: 0.12, y: 0.35 }, + { x: 0.88, y: 0.22 }, + { x: 0.78, y: 0.58 }, + { x: 0.38, y: 0.82 }, + { x: 0, y: 0.92 }, + { x: -0.38, y: 0.82 }, + { x: -0.78, y: 0.58 }, + { x: -0.88, y: 0.22 }, + { x: -0.12, y: 0.35 }, + { x: -0.12, y: -0.52 }, + { x: -0.65, y: -0.52 }, + { x: -0.65, y: -0.65 }, + { x: -0.08, y: -0.70 }, + { x: -0.18, y: -0.82 } + ]; + } else if (shape === 'cat') { + verts = [ + { x: 0, y: -0.48 }, + { x: 0.30, y: -0.48 }, + { x: 0.68, y: -0.92 }, + { x: 0.72, y: -0.35 }, + { x: 0.94, y: 0.05 }, + { x: 0.88, y: 0.48 }, + { x: 0.45, y: 0.82 }, + { x: 0, y: 0.86 }, + { x: -0.45, y: 0.82 }, + { x: -0.88, y: 0.48 }, + { x: -0.94, y: 0.05 }, + { x: -0.72, y: -0.35 }, + { x: -0.68, y: -0.92 }, + { x: -0.30, y: -0.48 } + ]; + } + + SHAPE_VERTICES_CACHE[shape] = verts; + return verts; + } + + function drawShapeGeometryPath(targetCtx, shape, radius) { + targetCtx.beginPath(); + if (!shape || shape === 'circle' || shape === 'tunnel' || shape === 'blob' || shape === 'linear' || shape === 'oscilloscope' || SPECIAL_INSTRUMENTS.includes(shape)) { + targetCtx.arc(0, 0, radius, 0, Math.PI * 2); + targetCtx.closePath(); + return; + } + + const verts = generateShapeVertices(shape); + if (!verts || verts.length === 0) { + targetCtx.arc(0, 0, radius, 0, Math.PI * 2); + targetCtx.closePath(); + return; + } + + if (shape === 'skull') { + // Outer skull outline + targetCtx.moveTo(verts[0].x * radius, verts[0].y * radius); + for (let i = 1; i < verts.length; i++) { + targetCtx.lineTo(verts[i].x * radius, verts[i].y * radius); + } + targetCtx.closePath(); + + // Left eye socket + const eyeR = 0.13 * radius; + const eyeY = -0.10 * radius; + const leftEyeX = -0.28 * radius; + targetCtx.moveTo(leftEyeX + eyeR, eyeY); + targetCtx.arc(leftEyeX, eyeY, eyeR, 0, Math.PI * 2); + targetCtx.closePath(); + + // Right eye socket + const rightEyeX = 0.28 * radius; + targetCtx.moveTo(rightEyeX + eyeR, eyeY); + targetCtx.arc(rightEyeX, eyeY, eyeR, 0, Math.PI * 2); + targetCtx.closePath(); + + // Inverted triangle nasal cavity + targetCtx.moveTo(0, 0.08 * radius); + targetCtx.lineTo(0.08 * radius, 0.22 * radius); + targetCtx.lineTo(-0.08 * radius, 0.22 * radius); + targetCtx.closePath(); + + // Vertical tooth slits along jaw + const teethTop = 0.58 * radius; + const teethBot = 0.78 * radius; + [-0.24, -0.08, 0.08, 0.24].forEach(tx => { + targetCtx.moveTo(tx * radius, teethTop); + targetCtx.lineTo(tx * radius, teethBot); + }); + return; + } + + if (shape === 'flame') { + // Outer fire silhouette (licking 3-tongue flame) + targetCtx.moveTo(verts[0].x * radius, verts[0].y * radius); + for (let i = 1; i < verts.length; i++) { + targetCtx.lineTo(verts[i].x * radius, verts[i].y * radius); + } + targetCtx.closePath(); + + // Inner dancing core flame + targetCtx.moveTo(0, -0.35 * radius); + targetCtx.quadraticCurveTo(0.25 * radius, 0.05 * radius, 0.20 * radius, 0.40 * radius); + targetCtx.quadraticCurveTo(0.14 * radius, 0.70 * radius, 0, 0.76 * radius); + targetCtx.quadraticCurveTo(-0.14 * radius, 0.70 * radius, -0.20 * radius, 0.40 * radius); + targetCtx.quadraticCurveTo(-0.25 * radius, 0.05 * radius, 0, -0.35 * radius); + targetCtx.closePath(); + return; + } + + if (shape === 'leaf') { + // Outer cannabis leaf silhouette (7 serrated leaflets) + targetCtx.moveTo(verts[0].x * radius, verts[0].y * radius); + for (let i = 1; i < verts.length; i++) { + targetCtx.lineTo(verts[i].x * radius, verts[i].y * radius); + } + targetCtx.closePath(); + + // Central stem petiole + const baseNodeY = 0.259 * radius; + targetCtx.moveTo(0, baseNodeY); + targetCtx.lineTo(0, 0.95 * radius); + + // Primary veins radiating to each of the 7 leaflet tips + const tips = [ + { x: -0.507, y: 0.151 }, + { x: -0.586, y: -0.327 }, + { x: -0.370, y: -0.758 }, + { x: 0, y: -0.950 }, + { x: 0.370, y: -0.758 }, + { x: 0.586, y: -0.327 }, + { x: 0.507, y: 0.151 } + ]; + for (const tip of tips) { + targetCtx.moveTo(0, baseNodeY); + targetCtx.lineTo(tip.x * radius, tip.y * radius); + } + return; + } + + if (shape === 'infinity') { + targetCtx.moveTo(0, 0); + for (let i = 1; i < verts.length; i++) { + targetCtx.lineTo(verts[i].x * radius, verts[i].y * radius); + } + targetCtx.closePath(); + return; + } + + targetCtx.moveTo(verts[0].x * radius, verts[0].y * radius); + for (let i = 1; i < verts.length; i++) { + targetCtx.lineTo(verts[i].x * radius, verts[i].y * radius); + } + targetCtx.closePath(); + } + + // --- Helper: Polygon Path Generator (Backwards Compatible) --- + function drawPolygonPath(targetCtx, sides, radius, cornerRadius = 0) { + if (typeof sides === 'string') { + drawShapeGeometryPath(targetCtx, sides, radius); + return; + } + targetCtx.beginPath(); + if (!sides || sides === 0) { + targetCtx.arc(0, 0, radius, 0, Math.PI * 2); + targetCtx.closePath(); + return; + } + + const angleStep = (Math.PI * 2) / sides; + const startAngle = -Math.PI / 2; + + for (let i = 0; i < sides; i++) { + const a = startAngle + i * angleStep; + const x = Math.cos(a) * radius; + const y = Math.sin(a) * radius; + if (i === 0) targetCtx.moveTo(x, y); + else targetCtx.lineTo(x, y); + } + targetCtx.closePath(); + } + + + // --- Helper: Fluid / Organic Liquid Blob Radius Evaluation --- + function getBlobRadiusAtAngle(angle, baseRadius, processedBars, rawBassEnergy, midEnergy, currentTime) { + const twoPi = Math.PI * 2; + const normAngle = ((angle % twoPi) + twoPi) % twoPi; + const barCount = (processedBars && processedBars.length > 0) ? processedBars.length : 96; + const barIdx = Math.floor((normAngle / twoPi) * barCount) % barCount; + const freqBoost = (processedBars && processedBars[barIdx] ? processedBars[barIdx] : 0) * 0.42; + + const wave1 = Math.sin(angle * 3 + currentTime * 3.2) * (rawBassEnergy * 28); + const wave2 = Math.cos(angle * 2 - currentTime * 2.6) * (midEnergy * 20); + return Math.max(30, baseRadius + freqBoost + wave1 + wave2); + } + + // --- Helper: Precise Shape Geometry Perimeter Point & Smooth Radial Normal --- + function getShapeGeometryPoint(shape, radius, t) { + if (shape === 'blob') { + const rot = state.currentRotation || 0; + const angle = t * Math.PI * 2 - Math.PI / 2 + rot; + const bd = state._blobRenderData; + const r = getBlobRadiusAtAngle( + angle, + radius, + bd ? bd.activeBars : null, + bd ? bd.rawBassEnergy : 0, + bd ? bd.midEnergy : 0, + bd ? bd.currentTime : 0 + ); + const px = Math.cos(angle) * r; + const py = Math.sin(angle) * r; + const dist = Math.hypot(px, py) || 1; + return { + x: px, + y: py, + nx: px / dist, + ny: py / dist, + angle: angle + }; + } + + if (!shape || shape === 'circle' || shape === 'tunnel' || shape === 'linear' || shape === 'oscilloscope' || SPECIAL_INSTRUMENTS.includes(shape)) { + const angle = t * Math.PI * 2 - Math.PI / 2; + return { + x: Math.cos(angle) * radius, + y: Math.sin(angle) * radius, + nx: Math.cos(angle), + ny: Math.sin(angle), + angle: angle + }; + } + + const verts = generateShapeVertices(shape); + if (!verts || verts.length === 0) { + const angle = t * Math.PI * 2 - Math.PI / 2; + return { + x: Math.cos(angle) * radius, + y: Math.sin(angle) * radius, + nx: Math.cos(angle), + ny: Math.sin(angle), + angle: angle + }; + } + + const N = verts.length; + const totalProgress = ((t % 1) + 1) % 1; + const sideProgress = totalProgress * N; + const sideIdx = Math.floor(sideProgress) % N; + const u = sideProgress - Math.floor(sideProgress); + + const p1 = verts[sideIdx]; + const p2 = verts[(sideIdx + 1) % N]; + + const px = (p1.x + u * (p2.x - p1.x)) * radius; + const py = (p1.y + u * (p2.y - p1.y)) * radius; + const dist = Math.hypot(px, py) || 1; + + return { + x: px, + y: py, + nx: px / dist, + ny: py / dist, + angle: Math.atan2(py, px) + }; + } + + // --- Helper: Precise Polygon Perimeter Point & Smooth Radial Normal (Backwards Compatible) --- + function getPolygonPerimeterPoint(sides, radius, t) { + if (typeof sides === 'string') { + return getShapeGeometryPoint(sides, radius, t); + } + if (!sides || sides < 3) { + const angle = t * Math.PI * 2 - Math.PI / 2; + return { + x: Math.cos(angle) * radius, + y: Math.sin(angle) * radius, + nx: Math.cos(angle), + ny: Math.sin(angle), + angle: angle + }; + } + const totalProgress = ((t % 1) + 1) % 1; + const sideProgress = totalProgress * sides; + const sideIdx = Math.floor(sideProgress) % sides; + const u = sideProgress - Math.floor(sideProgress); + const angleStep = (Math.PI * 2) / sides; + const a1 = -Math.PI / 2 + sideIdx * angleStep; + const a2 = -Math.PI / 2 + ((sideIdx + 1) % sides) * angleStep; + const x1 = Math.cos(a1) * radius; + const y1 = Math.sin(a1) * radius; + const x2 = Math.cos(a2) * radius; + const y2 = Math.sin(a2) * radius; + const px = x1 + u * (x2 - x1); + const py = y1 + u * (y2 - y1); + const dist = Math.hypot(px, py) || 1; + // Radial outward ray from center through perimeter point (eliminates vertex gaps & comb artifacts) + const nx = px / dist; + const ny = py / dist; + return { + x: px, + y: py, + nx: nx, + ny: ny, + angle: Math.atan2(py, px) + }; + } + + // --- Helper: Fluid / Organic Liquid Blob Path --- + function drawFluidBlob(targetCtx, baseRadius, processedBars, rawBassEnergy, midEnergy, currentTime) { + const steps = 140; + const rot = state.currentRotation || 0; + targetCtx.beginPath(); + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const angle = t * Math.PI * 2 - Math.PI / 2 + rot; + const r = getBlobRadiusAtAngle(angle, baseRadius, processedBars, rawBassEnergy, midEnergy, currentTime); + const x = Math.cos(angle) * r; + const y = Math.sin(angle) * r; + if (i === 0) targetCtx.moveTo(x, y); + else targetCtx.lineTo(x, y); + } + targetCtx.closePath(); + } + +export { + drawShapeGeometryPath, + drawFluidBlob, + getShapeGeometryPoint, + generateShapeVertices, + drawPolygonPath, + getBlobRadiusAtAngle +};