432 lines
13 KiB
HTML
432 lines
13 KiB
HTML
<!doctype html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
<title>Robot Dual Stick (Ramping)</title>
|
|
<style>
|
|
html, body { height: 100%; margin: 0; background: #0b0f14; color: #e8eef6; font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; }
|
|
.wrap { height: 100%; display: grid; grid-template-rows: auto 1fr auto auto; padding: 14px; gap: 12px; }
|
|
.top { display:flex; align-items:center; justify-content:space-between; gap: 10px; }
|
|
.badge { padding: 8px 12px; border-radius: 999px; background: #152033; font-weight: 800; }
|
|
.status { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
|
|
|
.panel { background:#101826; border-radius: 18px; padding: 12px; }
|
|
.sticks {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 12px;
|
|
align-items: center;
|
|
justify-items: center;
|
|
touch-action: none;
|
|
user-select: none;
|
|
}
|
|
.stickBox { width: min(46vw, 340px); aspect-ratio: 1/1; }
|
|
canvas { width: 100%; height: 100%; background:#0f1726; border-radius: 22px; touch-action: none; }
|
|
|
|
.row { display:flex; gap: 12px; align-items:center; justify-content:space-between; flex-wrap: wrap; }
|
|
.btn { padding: 14px 16px; border-radius: 14px; border: 0; background:#1e2b44; color:#fff; font-weight: 900; font-size: 16px; }
|
|
.btn:active { transform: scale(0.98); }
|
|
.btnStop { background:#7a1f2a; }
|
|
.btnOn { outline: 3px solid rgba(59,130,246,0.7); }
|
|
|
|
input[type="range"]{ width: 240px; }
|
|
.small { opacity: 0.86; font-size: 14px; }
|
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
|
.hint { margin-top: 8px; opacity: 0.85; font-size: 14px; text-align: center; }
|
|
|
|
.grid2 { display:grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
|
.kv { display:flex; gap:10px; align-items:center; justify-content:space-between; }
|
|
.kv label { font-weight:800; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<div class="top">
|
|
<div class="badge">🎮 Dual Stick Drive</div>
|
|
<div class="badge status" id="status">offline</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div class="sticks">
|
|
<div class="stickBox">
|
|
<canvas id="leftStick" width="600" height="600"></canvas>
|
|
<div class="hint">Links: Gas (↑/↓)</div>
|
|
</div>
|
|
<div class="stickBox">
|
|
<canvas id="rightStick" width="600" height="600"></canvas>
|
|
<div class="hint">Rechts: Lenken (←/→)</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel row">
|
|
<div style="min-width: 320px;">
|
|
<div class="kv">
|
|
<label>Max Speed:</label>
|
|
<span class="mono"><span id="maxv">180</span></span>
|
|
</div>
|
|
<input id="max" type="range" min="60" max="255" value="180">
|
|
|
|
<div class="grid2" style="margin-top:12px">
|
|
<div class="kv">
|
|
<label>Accel:</label>
|
|
<span class="mono"><span id="accv">2.2</span>/s</span>
|
|
</div>
|
|
<input id="acc" type="range" min="0.8" max="6.0" step="0.1" value="2.2">
|
|
|
|
<div class="kv">
|
|
<label>Decel:</label>
|
|
<span class="mono"><span id="decv">3.6</span>/s</span>
|
|
</div>
|
|
<input id="dec" type="range" min="0.8" max="8.0" step="0.1" value="3.6">
|
|
|
|
<div class="kv">
|
|
<label>Turn:</label>
|
|
<span class="mono"><span id="turnv">6.0</span>/s</span>
|
|
</div>
|
|
<input id="turn" type="range" min="1.0" max="12.0" step="0.1" value="6.0">
|
|
|
|
<div class="kv">
|
|
<label>Expo:</label>
|
|
<span class="mono"><span id="expov">0.35</span></span>
|
|
</div>
|
|
<input id="expo" type="range" min="0.0" max="0.8" step="0.05" value="0.35">
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
|
|
<button class="btn" id="btnConnect">Connect</button>
|
|
<button class="btn" id="btnSlow">SLOW</button>
|
|
<button class="btn btnStop" id="btnStop">STOP</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel small mono" id="readout">
|
|
target: T 0.00 | Turn 0.00 || smooth: T 0.00 | Turn 0.00 || L 0 | R 0
|
|
</div>
|
|
|
|
<div class="small" style="text-align:center; opacity:0.8">
|
|
Loslassen = STOP. Tab/Verbindung weg = STOP. (Web sendet laufend, damit Arduino-Watchdog nicht stoppt.)
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(() => {
|
|
// ===== UI refs =====
|
|
const statusEl = document.getElementById('status');
|
|
const readoutEl = document.getElementById('readout');
|
|
|
|
const maxEl = document.getElementById('max');
|
|
const maxvEl = document.getElementById('maxv');
|
|
|
|
const accEl = document.getElementById('acc');
|
|
const accvEl = document.getElementById('accv');
|
|
|
|
const decEl = document.getElementById('dec');
|
|
const decvEl = document.getElementById('decv');
|
|
|
|
const turnRateEl = document.getElementById('turn');
|
|
const turnvEl = document.getElementById('turnv');
|
|
|
|
const expoEl = document.getElementById('expo');
|
|
const expovEl = document.getElementById('expov');
|
|
|
|
const btnConnect = document.getElementById('btnConnect');
|
|
const btnStop = document.getElementById('btnStop');
|
|
const btnSlow = document.getElementById('btnSlow');
|
|
|
|
const cL = document.getElementById('leftStick');
|
|
const cR = document.getElementById('rightStick');
|
|
|
|
// ===== WebSocket =====
|
|
let ws = null;
|
|
let connected = false;
|
|
|
|
function setStatus(txt, ok=false){
|
|
statusEl.textContent = txt;
|
|
statusEl.style.background = ok ? "#16331d" : "#3a1a1a";
|
|
}
|
|
|
|
function connect(){
|
|
if (connected) return;
|
|
const proto = (location.protocol === "https:") ? "wss" : "ws";
|
|
const url = `${proto}://${location.host}/drive-ws`; // <— NGINX path
|
|
ws = new WebSocket(url);
|
|
|
|
ws.onopen = () => { connected = true; setStatus("online", true); };
|
|
ws.onclose = () => { connected = false; setStatus("offline", false); hardStop(); };
|
|
ws.onerror = () => { connected = false; setStatus("error", false); hardStop(); };
|
|
ws.onmessage = () => {};
|
|
}
|
|
|
|
btnConnect.addEventListener('click', connect);
|
|
|
|
// ===== Control state =====
|
|
// Targets come directly from sticks
|
|
let throttleTarget = 0; // -1..+1 (up = +)
|
|
let turnTarget = 0; // -1..+1 (right = +)
|
|
|
|
// Smoothed (ramped) values
|
|
let throttle = 0;
|
|
let turn = 0;
|
|
|
|
// last motor outputs
|
|
let lastL = 0, lastR = 0;
|
|
|
|
// slow mode
|
|
let slowMode = false;
|
|
btnSlow.addEventListener('click', () => {
|
|
slowMode = !slowMode;
|
|
btnSlow.classList.toggle('btnOn', slowMode);
|
|
});
|
|
|
|
// ===== Helpers =====
|
|
function clamp(v, a, b){ return Math.max(a, Math.min(b, v)); }
|
|
|
|
// Expo curve: 0 = linear, higher = finer around center
|
|
// Returns in [-1..1]
|
|
function expo(v, e){
|
|
v = clamp(v, -1, 1);
|
|
e = clamp(e, 0, 0.95);
|
|
// cubic blend
|
|
return (1 - e) * v + e * v * v * v;
|
|
}
|
|
|
|
// Differential mix: throttle + turn -> left/right in [-1..1]
|
|
function mix(th, tr){
|
|
let l = th + tr;
|
|
let r = th - tr;
|
|
const m = Math.max(1, Math.abs(l), Math.abs(r));
|
|
return { l: l / m, r: r / m };
|
|
}
|
|
|
|
function stepTowards(cur, target, maxDelta){
|
|
const d = target - cur;
|
|
if (Math.abs(d) <= maxDelta) return target;
|
|
return cur + Math.sign(d) * maxDelta;
|
|
}
|
|
|
|
// Send helper with simple rate limit (force bypasses)
|
|
let lastSendMs = 0;
|
|
function sendLR(l, r, force=false){
|
|
if (!connected) return;
|
|
const now = performance.now();
|
|
if (!force && (now - lastSendMs < 35)) return; // ~28Hz max
|
|
lastSendMs = now;
|
|
|
|
try { ws.send(JSON.stringify({ l, r })); } catch(e) {}
|
|
}
|
|
|
|
function hardStop(){
|
|
throttleTarget = 0; turnTarget = 0;
|
|
throttle = 0; turn = 0;
|
|
lastL = 0; lastR = 0;
|
|
sendLR(0, 0, true);
|
|
leftStick.reset();
|
|
rightStick.reset();
|
|
renderReadout();
|
|
}
|
|
|
|
btnStop.addEventListener('click', hardStop);
|
|
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.hidden) hardStop();
|
|
});
|
|
|
|
// ===== Stick widgets =====
|
|
function makeStick(canvas, mode /* 'throttle' or 'turn' */) {
|
|
const ctx = canvas.getContext('2d');
|
|
const cx = canvas.width / 2;
|
|
const cy = canvas.height / 2;
|
|
const radius = canvas.width * 0.32;
|
|
|
|
let active = false;
|
|
let px = cx, py = cy;
|
|
|
|
function pointerPos(evt){
|
|
const r = canvas.getBoundingClientRect();
|
|
return {
|
|
x: (evt.clientX - r.left) * (canvas.width / r.width),
|
|
y: (evt.clientY - r.top) * (canvas.height / r.height),
|
|
};
|
|
}
|
|
|
|
function draw(){
|
|
ctx.clearRect(0,0,canvas.width,canvas.height);
|
|
|
|
// base circle
|
|
ctx.lineWidth = 10;
|
|
ctx.strokeStyle = "#24324d";
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, radius, 0, Math.PI*2);
|
|
ctx.stroke();
|
|
|
|
// crosshair
|
|
ctx.lineWidth = 4;
|
|
ctx.strokeStyle = "#1c263b";
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx, cy - radius); ctx.lineTo(cx, cy + radius);
|
|
ctx.moveTo(cx - radius, cy); ctx.lineTo(cx + radius, cy);
|
|
ctx.stroke();
|
|
|
|
// mode line
|
|
ctx.lineWidth = 6;
|
|
ctx.strokeStyle = "#152033";
|
|
ctx.beginPath();
|
|
if (mode === 'throttle') {
|
|
ctx.moveTo(cx, cy - radius); ctx.lineTo(cx, cy + radius);
|
|
} else {
|
|
ctx.moveTo(cx - radius, cy); ctx.lineTo(cx + radius, cy);
|
|
}
|
|
ctx.stroke();
|
|
|
|
// knob
|
|
ctx.fillStyle = "#3b82f6";
|
|
ctx.beginPath();
|
|
ctx.arc(px, py, canvas.width*0.065, 0, Math.PI*2);
|
|
ctx.fill();
|
|
|
|
// inner dot
|
|
ctx.fillStyle = "#0b0f14";
|
|
ctx.beginPath();
|
|
ctx.arc(px, py, canvas.width*0.03, 0, Math.PI*2);
|
|
ctx.fill();
|
|
}
|
|
|
|
function apply(nx, ny){
|
|
// nx,ny in [-1..1], ny is +down
|
|
if (mode === 'throttle') {
|
|
throttleTarget = clamp(-ny, -1, 1); // up = +
|
|
} else {
|
|
turnTarget = clamp(nx, -1, 1); // right = +
|
|
}
|
|
}
|
|
|
|
function reset(){
|
|
active = false;
|
|
px = cx; py = cy;
|
|
draw();
|
|
}
|
|
|
|
function onDown(evt){
|
|
evt.preventDefault();
|
|
active = true;
|
|
canvas.setPointerCapture(evt.pointerId);
|
|
onMove(evt);
|
|
}
|
|
|
|
function onMove(evt){
|
|
if (!active) return;
|
|
evt.preventDefault();
|
|
|
|
const p = pointerPos(evt);
|
|
const dx = p.x - cx;
|
|
const dy = p.y - cy;
|
|
|
|
const dist = Math.hypot(dx, dy);
|
|
const k = dist > radius ? (radius / dist) : 1;
|
|
|
|
px = cx + dx * k;
|
|
py = cy + dy * k;
|
|
|
|
const nx = clamp((px - cx) / radius, -1, 1);
|
|
const ny = clamp((py - cy) / radius, -1, 1);
|
|
|
|
apply(nx, ny);
|
|
draw();
|
|
}
|
|
|
|
function onUp(evt){
|
|
evt.preventDefault();
|
|
active = false;
|
|
px = cx; py = cy;
|
|
// Release = target back to 0 for that axis
|
|
if (mode === 'throttle') throttleTarget = 0;
|
|
if (mode === 'turn') turnTarget = 0;
|
|
draw();
|
|
}
|
|
|
|
canvas.addEventListener('pointerdown', onDown);
|
|
canvas.addEventListener('pointermove', onMove);
|
|
canvas.addEventListener('pointerup', onUp);
|
|
canvas.addEventListener('pointercancel', onUp);
|
|
canvas.addEventListener('contextmenu', e => e.preventDefault());
|
|
|
|
draw();
|
|
return { reset };
|
|
}
|
|
|
|
const leftStick = makeStick(cL, 'throttle');
|
|
const rightStick = makeStick(cR, 'turn');
|
|
|
|
// ===== UI sliders text =====
|
|
function refreshLabels(){
|
|
maxvEl.textContent = maxEl.value;
|
|
accvEl.textContent = accEl.value;
|
|
decvEl.textContent = decEl.value;
|
|
turnvEl.textContent = turnRateEl.value;
|
|
expovEl.textContent = expoEl.value;
|
|
}
|
|
[maxEl, accEl, decEl, turnRateEl, expoEl].forEach(el => el.addEventListener('input', refreshLabels));
|
|
refreshLabels();
|
|
|
|
// ===== Main control loop (RAMPING happens HERE) =====
|
|
function renderReadout(){
|
|
readoutEl.textContent =
|
|
`target: T ${throttleTarget.toFixed(2)} | Turn ${turnTarget.toFixed(2)} || ` +
|
|
`smooth: T ${throttle.toFixed(2)} | Turn ${turn.toFixed(2)} || ` +
|
|
`L ${lastL} | R ${lastR}`;
|
|
}
|
|
|
|
let lastTick = performance.now();
|
|
|
|
setInterval(() => {
|
|
const now = performance.now();
|
|
const dt = Math.max(0.001, (now - lastTick) / 1000);
|
|
lastTick = now;
|
|
|
|
// rates from UI
|
|
const ACCEL_PER_SEC = parseFloat(accEl.value);
|
|
const DECEL_PER_SEC = parseFloat(decEl.value);
|
|
const TURN_PER_SEC = parseFloat(turnRateEl.value);
|
|
const EXPO = parseFloat(expoEl.value);
|
|
|
|
// ramp throttle
|
|
const rateT = (Math.abs(throttleTarget) > Math.abs(throttle)) ? ACCEL_PER_SEC : DECEL_PER_SEC;
|
|
throttle = stepTowards(throttle, throttleTarget, rateT * dt);
|
|
|
|
// ramp turn
|
|
turn = stepTowards(turn, turnTarget, TURN_PER_SEC * dt);
|
|
|
|
// apply expo AFTER ramp (feels nicer)
|
|
const th = expo(throttle, EXPO);
|
|
const tr = expo(turn, EXPO);
|
|
|
|
// mix
|
|
let { l, r } = mix(th, tr);
|
|
|
|
// scale
|
|
let maxSpeed = parseInt(maxEl.value, 10);
|
|
if (slowMode) maxSpeed = Math.round(maxSpeed * 0.45);
|
|
|
|
const L = Math.round(clamp(l, -1, 1) * maxSpeed);
|
|
const R = Math.round(clamp(r, -1, 1) * maxSpeed);
|
|
|
|
lastL = L; lastR = R;
|
|
renderReadout();
|
|
|
|
// always send with a gentle cap (force keeps Arduino alive)
|
|
// This is both "control output" and heartbeat.
|
|
sendLR(L, R, true);
|
|
}, 20); // 50 Hz smoothing + sending
|
|
|
|
// ===== Start =====
|
|
connect();
|
|
setStatus("connecting...", false);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|