318 lines
9.7 KiB
JavaScript
318 lines
9.7 KiB
JavaScript
/* 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(); }
|
||
};
|
||
})();
|