/* joystick-3d.js – Clean Canvas joystick with tactile press animation, expanded soft red boundary glow, clean lowercase curved labels, and 20% larger knob. */ (() => { 'use strict'; const host = document.getElementById('joystick'); if (!host) return; let canvas = document.getElementById('joystick-canvas'); if (!canvas) { canvas = document.createElement('canvas'); canvas.id = 'joystick-canvas'; host.appendChild(canvas); } canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;z-index:5;touch-action:none;cursor:grab;'; const ctx = canvas.getContext('2d'); let W = 0, H = 0, dpr = 1; let nx = 0, ny = 0; // normalised knob position: -1 to +1 let dragging = false; let animId = 0; let pressAnimId = 0; let pressScale = 1.0; // smooth press scale: 1.0 down to 0.93 function resize() { const r = host.getBoundingClientRect(); dpr = Math.min(window.devicePixelRatio || 1, 2); W = r.width; H = r.height; canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); draw(); } window.addEventListener('resize', resize); const TAU = Math.PI * 2; function drawCurvedText(text, cx, cy, radius, centerAngle, flip = false) { ctx.save(); ctx.font = '300 11.5px Inter, "SF Pro Display", "Helvetica Neue", Arial, sans-serif'; ctx.fillStyle = '#656f6c'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const charWidths = []; let totalWidth = 0; for (let i = 0; i < text.length; i++) { const w = ctx.measureText(text[i]).width + 1.8; charWidths.push(w); totalWidth += w; } const totalAngle = totalWidth / radius; if (!flip) { let currentAngle = centerAngle - totalAngle / 2; for (let i = 0; i < text.length; i++) { const charAngle = charWidths[i] / radius; const midAngle = currentAngle + charAngle / 2; const x = cx + radius * Math.cos(midAngle); const y = cy + radius * Math.sin(midAngle); ctx.save(); ctx.translate(x, y); ctx.rotate(midAngle + Math.PI / 2); ctx.fillText(text[i], 0, 0); ctx.restore(); currentAngle += charAngle; } } else { let currentAngle = centerAngle + totalAngle / 2; for (let i = 0; i < text.length; i++) { const charAngle = charWidths[i] / radius; const midAngle = currentAngle - charAngle / 2; const x = cx + radius * Math.cos(midAngle); const y = cy + radius * Math.sin(midAngle); ctx.save(); ctx.translate(x, y); ctx.rotate(midAngle - Math.PI / 2); ctx.fillText(text[i], 0, 0); ctx.restore(); currentAngle -= charAngle; } } ctx.restore(); } function draw() { if (!W || !H) return; ctx.save(); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, W, H); const cx = W / 2; const cy = H / 2; const R = Math.min(W, H) * 0.45; const baseKnobR = R * 0.22; // 20% larger knob const knobR = baseKnobR * pressScale; const travel = R * 0.58; const dist = Math.hypot(nx, ny); /* ── 1. SPHERICAL BOWL BASE ── */ ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, R, 0, TAU); ctx.clip(); // Base smooth light background (clean, no blue edge gradient) const bg = ctx.createRadialGradient(cx, cy - R * 0.1, R * 0.1, cx, cy, R); bg.addColorStop(0, '#ffffff'); bg.addColorStop(0.55, '#f7f9f8'); bg.addColorStop(0.85, '#edf1f0'); bg.addColorStop(1, '#e3e8e7'); ctx.fillStyle = bg; ctx.fillRect(cx - R, cy - R, R * 2, R * 2); // 30% larger, 20% lighter transparent red boundary glow when pulled near edge (dist > 0.65) if (dist > 0.65) { const redFactor = Math.min(1, (dist - 0.65) / 0.35); const angle = Math.atan2(ny, nx); const edgeX = cx + R * Math.cos(angle); const edgeY = cy + R * Math.sin(angle); const redGrad = ctx.createRadialGradient(edgeX, edgeY, 0, edgeX, edgeY, R * 0.85); redGrad.addColorStop(0, `rgba(240, 110, 95, ${0.14 * redFactor})`); redGrad.addColorStop(0.5, `rgba(240, 110, 95, ${0.045 * redFactor})`); redGrad.addColorStop(1, 'rgba(240, 110, 95, 0)'); ctx.fillStyle = redGrad; ctx.fillRect(cx - R, cy - R, R * 2, R * 2); } // Straight vertical and horizontal axis lines ctx.strokeStyle = 'rgba(155, 175, 178, 0.32)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(cx, cy - R * 0.85); ctx.lineTo(cx, cy + R * 0.85); ctx.moveTo(cx - R * 0.85, cy); ctx.lineTo(cx + R * 0.85, cy); ctx.stroke(); // ── Curved Lowercase Directional Labels ── const labelR = R * 0.84; drawCurvedText('forward', cx, cy, labelR, -Math.PI / 2, false); drawCurvedText('reverse', cx, cy, labelR, Math.PI / 2, true); drawCurvedText('turn left', cx, cy, labelR, Math.PI, false); drawCurvedText('turn right', cx, cy, labelR, 0, false); ctx.restore(); // un-clip // Outer rim border ctx.beginPath(); ctx.arc(cx, cy, R, 0, TAU); ctx.strokeStyle = '#cdd6d4'; ctx.lineWidth = 1.5; ctx.stroke(); /* ── 2. SOFT KNOB SHADOW ── */ const kx = cx + nx * travel; const ky = cy + ny * travel; const shadowY = ky + 2.5; const sg = ctx.createRadialGradient(kx, shadowY, knobR * 0.3, kx, shadowY, knobR * 1.25); sg.addColorStop(0, 'rgba(18, 28, 24, 0.15)'); sg.addColorStop(0.55, 'rgba(18, 28, 24, 0.05)'); sg.addColorStop(1, 'rgba(0, 0, 0, 0)'); ctx.beginPath(); ctx.arc(kx, shadowY, knobR * 1.25, 0, TAU); ctx.fillStyle = sg; ctx.fill(); /* ── 3. SINGLE 20% LARGER LIGHTER KNOB ── */ ctx.save(); ctx.translate(kx, ky); // Lighter, clean metallic/slate gradient const kg = ctx.createLinearGradient(0, -knobR, 0, knobR); kg.addColorStop(0, '#687271'); kg.addColorStop(0.45, '#515958'); kg.addColorStop(1, '#3c4443'); ctx.beginPath(); ctx.arc(0, 0, knobR, 0, TAU); ctx.fillStyle = kg; ctx.fill(); // Clean 1px top highlight bevel const bevel = ctx.createLinearGradient(0, -knobR, 0, knobR); bevel.addColorStop(0, 'rgba(255, 255, 255, 0.55)'); bevel.addColorStop(0.4, 'rgba(255, 255, 255, 0.05)'); bevel.addColorStop(0.7, 'rgba(0, 0, 0, 0.1)'); bevel.addColorStop(1, 'rgba(0, 0, 0, 0.40)'); ctx.strokeStyle = bevel; ctx.lineWidth = 1; ctx.stroke(); // Outer light edge border (matching vertical knob) ctx.strokeStyle = '#d7dedc'; ctx.lineWidth = 1; ctx.stroke(); // Subtle cyan light glow ONLY when actively holding/dragging if (dragging) { const gg = ctx.createRadialGradient(0, 0, 0, 0, 0, knobR * 1.15); gg.addColorStop(0, 'rgba(255, 255, 255, 0.32)'); gg.addColorStop(0.4, 'rgba(200, 245, 255, 0.18)'); gg.addColorStop(0.75, 'rgba(114, 216, 232, 0.10)'); gg.addColorStop(1, 'rgba(0, 0, 0, 0)'); ctx.beginPath(); ctx.arc(0, 0, knobR, 0, TAU); ctx.fillStyle = gg; ctx.fill(); } ctx.restore(); // un-knob translate ctx.restore(); // top-level save } function setPos(x, y) { const len = Math.hypot(x, y); if (len > 1) { x /= len; y /= len; } nx = x; ny = y; draw(); if (window.setJoyControl) window.setJoyControl(nx, ny); } function pointerToNorm(e) { const r = canvas.getBoundingClientRect(); const cx = r.width / 2; const cy = r.height / 2; const travel = Math.min(r.width, r.height) * 0.45 * 0.58; const px = e.clientX - r.left; const py = e.clientY - r.top; setPos((px - cx) / travel, (py - cy) / travel); } /* ── Tactile Press Animation (matching vertical knob) ── */ function animatePress(toPressed) { cancelAnimationFrame(pressAnimId); const startScale = pressScale; const targetScale = toPressed ? 0.93 : 1.0; const duration = toPressed ? 110 : 450; const startTime = performance.now(); function step(now) { const p = Math.min(1, (now - startTime) / duration); const curve = toPressed ? p * (2 - p) : 1 + Math.sin((p - 1) * Math.PI * 2.5) * Math.pow(1 - p, 2) * 0.35; pressScale = startScale + (targetScale - startScale) * (toPressed ? curve : p); if (!toPressed) { pressScale = 1.0 + (startScale - 1.0) * Math.pow(1 - p, 2) + Math.sin(p * Math.PI) * 0.02 * (1 - p); } draw(); if (p < 1) { pressAnimId = requestAnimationFrame(step); } else { pressScale = targetScale; draw(); } } pressAnimId = requestAnimationFrame(step); } function easeBack() { cancelAnimationFrame(animId); const sx = nx, sy = ny, t0 = performance.now(); (function step(now) { const p = Math.min(1, (now - t0) / 320); const s = p * p * (3 - 2 * p); nx = sx * (1 - s); ny = sy * (1 - s); draw(); if (window.setJoyControl) window.setJoyControl(nx, ny); if (p < 1) animId = requestAnimationFrame(step); })(t0); } canvas.addEventListener('pointerdown', e => { dragging = true; canvas.style.cursor = 'grabbing'; cancelAnimationFrame(animId); animatePress(true); canvas.setPointerCapture(e.pointerId); pointerToNorm(e); }); canvas.addEventListener('pointermove', e => { if (dragging) pointerToNorm(e); }); canvas.addEventListener('pointerup', () => { dragging = false; canvas.style.cursor = 'grab'; animatePress(false); easeBack(); }); canvas.addEventListener('pointercancel', () => { dragging = false; canvas.style.cursor = 'grab'; animatePress(false); easeBack(); }); resize(); window.joystick3D = { setPos(x, y) { cancelAnimationFrame(animId); setPos(x, y); }, easeBack() { if (!dragging) easeBack(); } }; })();