Redesign touch controls and engagement interaction
This commit is contained in:
+32
-307
@@ -1,317 +1,42 @@
|
||||
/* joystick-3d.js – Clean Canvas joystick with tactile press animation,
|
||||
expanded soft red boundary glow, clean lowercase curved labels, and 20% larger knob. */
|
||||
|
||||
/* Visual joystick. Control transport remains in app.js through setJoyControl. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const host=document.getElementById('joystick'),canvas=document.getElementById('joystick-canvas');
|
||||
if(!host||!canvas)return;
|
||||
const ctx=canvas.getContext('2d');
|
||||
let width=0,height=0,dpr=1,x=0,y=0,activePointer=null,returnFrame=0;
|
||||
|
||||
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);
|
||||
function geometry(){
|
||||
const radius=Math.min(width,height)*.43;
|
||||
return {cx:width/2,cy:height/2,radius,travel:radius*.67,knob:Math.max(25,Math.min(34,radius*.22))};
|
||||
}
|
||||
function resize(){const rect=host.getBoundingClientRect();width=rect.width;height=rect.height;dpr=Math.min(devicePixelRatio||1,2);canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);canvas.style.width=`${width}px`;canvas.style.height=`${height}px`;draw()}
|
||||
function draw(){
|
||||
if(!width||!height)return;
|
||||
const {cx,cy,radius,travel,knob}=geometry(),kx=cx+x*travel,ky=cy+y*travel,amount=Math.min(1,Math.hypot(x,y));
|
||||
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,width,height);
|
||||
|
||||
canvas.style.cssText =
|
||||
'position:absolute;inset:0;width:100%;height:100%;z-index:5;touch-action:none;cursor:grab;';
|
||||
ctx.beginPath();ctx.arc(cx,cy,radius,0,Math.PI*2);ctx.fillStyle='#eef1ee';ctx.fill();ctx.strokeStyle='#cfd5d1';ctx.lineWidth=1.5;ctx.stroke();
|
||||
ctx.strokeStyle='rgba(80,92,87,.14)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(cx-radius*.76,cy);ctx.lineTo(cx+radius*.76,cy);ctx.moveTo(cx,cy-radius*.76);ctx.lineTo(cx,cy+radius*.76);ctx.stroke();
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.beginPath();ctx.arc(cx,cy,8,0,Math.PI*2);ctx.fillStyle=amount<.03?'#77817d':'#c8ceca';ctx.fill();
|
||||
if(amount>.025){ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(kx,ky);ctx.strokeStyle='rgba(78,91,85,.24)';ctx.lineWidth=3;ctx.lineCap='round';ctx.stroke()}
|
||||
|
||||
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();
|
||||
ctx.save();ctx.shadowColor='rgba(17,25,21,.22)';ctx.shadowBlur=activePointer===null?8:12;ctx.shadowOffsetY=3;
|
||||
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=activePointer===null?'#4d5652':'#303936';ctx.fill();ctx.restore();
|
||||
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.strokeStyle='rgba(255,255,255,.42)';ctx.lineWidth=1;ctx.stroke();
|
||||
ctx.beginPath();ctx.arc(kx,ky,4,0,Math.PI*2);ctx.fillStyle='#dfe5e1';ctx.fill();
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
function publish(){window.setJoyControl?.(x,y)}
|
||||
function setPosition(nextX,nextY,notify=true){const length=Math.hypot(nextX,nextY);if(length>1){nextX/=length;nextY/=length}x=nextX;y=nextY;draw();if(notify)publish()}
|
||||
function positionFromEvent(event){const rect=canvas.getBoundingClientRect(),g=geometry();setPosition((event.clientX-rect.left-g.cx)/g.travel,(event.clientY-rect.top-g.cy)/g.travel)}
|
||||
function returnToCenter(){cancelAnimationFrame(returnFrame);const startX=x,startY=y,start=performance.now();const tick=now=>{const p=Math.min(1,(now-start)/360),smooth=p*p*(3-2*p);setPosition(startX*(1-smooth),startY*(1-smooth));if(p<1)returnFrame=requestAnimationFrame(tick)};returnFrame=requestAnimationFrame(tick)}
|
||||
function release(event){if(activePointer===null||(event?.pointerId!=null&&event.pointerId!==activePointer))return;const pointer=activePointer;activePointer=null;try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');draw();returnToCenter()}
|
||||
|
||||
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(); }
|
||||
};
|
||||
canvas.addEventListener('pointerdown',event=>{if(activePointer!==null)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()});
|
||||
canvas.addEventListener('pointermove',event=>{if(event.pointerId===activePointer)positionFromEvent(event)});
|
||||
canvas.addEventListener('pointerup',release);canvas.addEventListener('pointercancel',release);canvas.addEventListener('lostpointercapture',event=>{if(event.pointerId===activePointer)release(event)});
|
||||
canvas.addEventListener('pointerleave',event=>{if(event.pointerType==='mouse'&&event.pointerId===activePointer)release(event)});
|
||||
addEventListener('resize',resize);resize();
|
||||
window.joystick3D={setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},getState(){return {x,y,active:activePointer!==null}}};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user