refactor: abstracted Joystick component with custom zones

This commit is contained in:
Matthieu Bessat 2023-03-26 15:35:25 +02:00
parent 17882b277a
commit acefcb0cd8
5 changed files with 441 additions and 237 deletions

View file

@ -2,28 +2,17 @@
@tailwind components;
@tailwind utilities;
body {
background: black;
color: white;
}
input {
background-color: white;
color:black
color: black;
}
.controller-back {
width: 10em;
height: 10em;
background: red;
border-radius: 50%;
position: relative;
}
.controller-joystick {
position: absolute;
width: 1em;
height: 1em;
}
.controller-joystick-ins {
position: relative;
/* left: -50%; */
@ -41,3 +30,9 @@ input {
/* heigth: 10em; */
background: gray;
}
.joystick-bank {
display: grid;
grid-template-columns: 50% 50%;
height: 20em;
}

View file

@ -1,21 +1,44 @@
<script>
function clamp(v, min, max) {
return Math.min(Math.max(v, min), max);
}
function sign(x) {
if (x == 0) { return 0; }
if (x > 0) { return 1; }
return -1;
}
import Joystick from './Joystick.svelte';
import WebSocketService from './WebSocketService';
import { clamp, areSameObjects } from './utils'
import { onMount } from 'svelte';
import Two from 'two.js';
import WebSocketService from '$lib/WebSocketService.js'
const realSendMode = true;
const maxSpeed = 50;
const buttonBindings = ['x','o','triangle','square','l1','r1','l2','r2','share','options','home','leftstickpress','rightstickpress']
const axisBindings = ['left_joystick_x','left_joystick_y','l2','right_joystick_x','right_joystick_y','r2','cross_x','cross_y']
const WEBSOCKET_URL = "http://localhost:1567";
const REAL_SEND_MODE = true;
const MAX_SPEED = 50;
const MOTORS = ["frontLeft", "frontRight", "backLeft", "backRight"];
const BUTTON_BINDINGS = ['x','o','triangle','square','l1','r1','l2','r2','share','options','home','leftstickpress','rightstickpress']
const AXIS_BINDINGS = ['left_joystick_x','left_joystick_y','l2','right_joystick_x','right_joystick_y','r2','cross_x','cross_y']
const ZONE_DIRECTIONS_MAPPING = ["E", "NE", "N", "NW", "W", "SW", "S", "SE"];
let joystickBankEnabled = false;
let rotationZones = [];
let translationZones = [];
let l2initialized;
let gpDictState;
let prettyGpState;
let direction;
let normalizedVec = {x: 0, y: 0};
let angleFullRangeDeg = 0;
let finalSpeed = 0;
let inputRangesMap = Object.fromEntries(MOTORS.map(m => [m, [307, 410]]));
let leftJoystick;
let rightJoystick;
let lastGpState = null;
let currentWs = null;
let currentGamepadIndex = -1;
function getGamepadState(gpIndex) {
let tmpState = navigator.getGamepads()[currentGamepadIndex];
@ -28,111 +51,44 @@
};
}
let l2initialized;
export let gpDictState;
export let prettyGpState;
export let direction;
export let normalizedVec = {x: 0, y: 0};
export let angleFullRangeDeg = 0;
export let finalSpeed = 0;
export let mouseControlled;
const zone_directions = ["E", "NE", "N", "NW", "W", "SW", "S", "SE"];
let twoScene = null;
let zonesGroup = null;
let leftJoystick = {pointer: null, bg: null};
let rightJoystick = {pointer: null, bg: null};
let lastGpState = null;
let currentWs = null;
let currentGamepadIndex = -1;
function updateRangesMap(inputRangesMap) {
if (!currentWs) return;
// console.log(Object.entries(inputRangesMap))
Object.entries(inputRangesMap).map(([name, range]) => {
console.log(name, range)
currentWs.send(
"set_range",
{name, min: range[0], max: range[1]}
)
})
}
function initGamepads() {
console.log("init gamepads");
console.log("> Initializing gamepads...");
window.addEventListener("gamepadconnected", (e) => {
const gp = navigator.getGamepads()[e.gamepad.index];
currentGamepadIndex = gp.index;
l2initialized = false;
console.log(`Gamepad connected at index ${gp.index}: ${gp.id} with ${gp.buttons.length} buttons, ${gp.axes.length} axes.`);
console.log(`> Gamepad connected at index ${gp.index}: ${gp.id} with ${gp.buttons.length} buttons, ${gp.axes.length} axes.`);
});
window.addEventListener("gamepaddisconnected", (e) => {
console.log(e.gamepad);
console.log("> Gamepad disconnected!", e.gamepad);
});
setTimeout(() => {
if (currentGamepadIndex === -1) {
console.warn("> No gamepad found!");
}
}, 1000)
}
function getGamepadStateAsDict(arrayState) {
return {
axes: Object.fromEntries(axisBindings.map((n, i) => [n, arrayState.axes[i]])),
buttons: Object.fromEntries(buttonBindings.map((n, i) => [n, arrayState.buttons[i]]))
axes: Object.fromEntries(AXIS_BINDINGS.map((n, i) => [n, arrayState.axes[i]])),
buttons: Object.fromEntries(BUTTON_BINDINGS.map((n, i) => [n, arrayState.buttons[i]]))
}
}
function renderJoystick() {
const scaleFactor = 100;
rightJoystick.pointer.translation.x = 200 + normalizedVec.x*scaleFactor;
rightJoystick.pointer.translation.y = 200 + normalizedVec.y*scaleFactor;
}
function updateNavigation(angleFullRange) {
const intensity = Math.hypot(normalizedVec.x, normalizedVec.y)
if (intensity == 0) {
currentWs.send("stop_robot", {})
zonesGroup.children.forEach(zone => {
zone.opacity = 1
})
return
}
function getZoneDirection(angle) {
const nbOfZones = zone_directions.length;
const delta = 2*Math.PI/nbOfZones;
const offset = delta/2;
for (let i = 0; i < nbOfZones; i++) {
let lb = i*delta+offset;
let ub = (i+1)*delta+offset;
// console.log(lb, angle, ub)
if (lb >= angle && angle <= ub) {
return zone_directions[i];
}
}
return "E";
}
// let angleFullRange = -Math.abs(Math.acos(normalizedVec.x))*sign(Math.asin(normalizedVec.y));
angleFullRangeDeg = angleFullRange * 180/Math.PI;
direction = getZoneDirection(angleFullRange)
zonesGroup.children.forEach((zone, i) => {
if (zone_directions.indexOf(direction) == i) {
zone.opacity = 0.5
} else {
zone.opacity = 1
}
})
// console.log("new angle", angleFullRange * 180/Math.PI, direction)
if (realSendMode) {
currentWs.send(
"set_direction",
{"direction": direction}
)
}
}
function updateSpeed(speed) {
// console.log("speed", speed)
finalSpeed = clamp(speed, 0, maxSpeed)
// set speed
if (realSendMode) {
currentWs.send("set_speed", {"speed": finalSpeed})
}
}
function onGamepadChange(receivedState) {
function processNewGamepadState(receivedState) {
gpDictState = getGamepadStateAsDict(lastGpState);
if (!l2initialized && gpDictState.axes.l2 != 0) {
@ -141,44 +97,10 @@
prettyGpState = JSON.stringify(gpDictState, " ", 4);
// pre process the right joystick
let vec = {x: gpDictState.axes.right_joystick_x, y: gpDictState.axes.right_joystick_y};
let angle = Math.atan2(vec.y, vec.x);
let reach = clamp(Math.hypot(vec.x, vec.y), 0, 1);
// create the normalized vector
normalizedVec = {x: reach*Math.cos(angle), y: reach*Math.sin(angle)};
handleJoystickChange();
leftJoystick.onGamepadJoystickChange(gpDictState.axes.left_joystick_x, gpDictState.axes.left_joystick_y);
rightJoystick.onGamepadJoystickChange(gpDictState.axes.right_joystick_x, gpDictState.axes.right_joystick_y);
}
function handleJoystickChange() {
let reach = Math.hypot(normalizedVec.x, normalizedVec.y);
// convert angle that is sometimes from 0 to -180 and sometimes from 0 to 180 deg
// to something from 0 to 360 full circle
let angle = Math.atan2(normalizedVec.y, normalizedVec.x);
let angleFullRange = (reach == 0) ? 0 : (angle < 0) ? -angle : Math.PI + (Math.PI - angle);
console.log({
angle: angle*180/Math.PI,
angleFullRange: angleFullRange*180/Math.PI,
reach, normalizedVec
});
// console.log(vec.x, vec.y, angle, angle *180/Math.PI)
renderJoystick();
updateNavigation(angleFullRange);
const maxSpeed = 50;
let speedForced = l2initialized ? Math.round(((gpDictState.axes.l2 + 1)/2)*maxSpeed) : 0;
let speedNormal = Math.round(0.50*reach*maxSpeed);
updateSpeed(Math.max(speedNormal, speedForced));
}
function areSameObjects(a, b) {
return JSON.stringify(a) == JSON.stringify(b);
}
function loop() {
if (currentGamepadIndex >= 0) {
@ -188,123 +110,152 @@
if (lastGpState == null || !areSameObjects(lastGpState, receivedState)) {
// console.log("changed")
lastGpState = receivedState;
onGamepadChange(receivedState);
twoScene.render();
processNewGamepadState(receivedState);
}
}
window.requestAnimationFrame(loop);
}
onMount(() => {
const sceneContainer = document.getElementById("joy2");
twoScene = new Two({}).appendTo(sceneContainer);
rightJoystick.bg = new Two.Circle(
200, 200, 100
);
twoScene.add(rightJoystick.bg);
rightJoystick.pointer = new Two.Circle(
200, 200, 10
);
rightJoystick.pointer.fill = "red";
rightJoystick.pointer.stroke = "rgba(0, 0, 0, 0.25)";
let nbOfZones = 8;
let delta = 2*Math.PI/nbOfZones;
let offset = delta/2;
zonesGroup = new Two.Group();
for (var i = 0; i < nbOfZones; i++) {
let arc = new Two.ArcSegment(
200, 200, 0, 100,
-i*delta+offset, -(i+1)*delta+offset
);
arc.fill = "green";
zonesGroup.add(arc);
function updateNavigation(angle, distance, zoneIndex) {
if (distance == 0) {
currentWs.send("stop_robot", {})
return
}
let mousePointer = new Two.Circle(0, 0, 4);
angleFullRangeDeg = angle * 180/Math.PI;
normalizedVec = {x: distance*Math.cos(angle), y: distance*Math.sin(angle)}
twoScene.add(zonesGroup);
twoScene.add(rightJoystick.pointer);
twoScene.add(mousePointer);
// update the translation direction
direction = ZONE_DIRECTIONS_MAPPING[zoneIndex];
currentWs.send(
"set_direction",
{"direction": direction}
)
twoScene.render();
// update the speed
// TODO: Have the speed overwrite by non-gamepad users
let speedForced = l2initialized ? Math.round(((gpDictState.axes.l2 + 1)/2)*MAX_SPEED) : 0;
let speedNormal = Math.round(0.50*distance*MAX_SPEED);
let speed = Math.max(speedNormal, speedForced);
finalSpeed = clamp(speed, 0, MAX_SPEED);
let controller_index = 0;
currentWs.send("set_speed", {"speed": finalSpeed});
}
function generateTranslationZones() {
let nbOfZones = 8;
let delta = 2*Math.PI/nbOfZones;
let offset = -delta/2;
let zones = [];
for (var i = 0; i < nbOfZones; i++) {
zones.push({
from: i*delta+offset,
to: (i+1)*delta+offset
});
}
return zones;
}
onMount(() => {
translationZones = generateTranslationZones();
rotationZones = [
{ from: -(6/8)*Math.PI, to: -(2/8)*Math.PI },
{ to: (6/8)*Math.PI, from: (2/8)*Math.PI },
]
initGamepads();
currentWs = new WebSocketService("http://192.168.1.128:1567");
// setup websocket
currentWs = new WebSocketService(WEBSOCKET_URL);
currentWs.isEnabled = REAL_SEND_MODE;
currentWs.start()
currentWs.on('connectionUpdated', 'connection', e => {
console.log(e.detail);
});
// handle mouse events
sceneContainer.onmousemove = (e) => {
if (!mouseControlled) {
return;
}
console.log(e);
let rect = sceneContainer.getBoundingClientRect();
let x = e.clientX - rect.left;
let y = e.clientY - rect.top;
mousePointer.translation.x = x;
mousePointer.translation.y = y;
// compute the position inside the circle
let circle = {x: 200, y: 200, radius: 100};
let relVec = {x: x-circle.x, y: y-circle.y};
// console.log("rel circle", relVec);
if (Math.hypot(relVec.x, relVec.y) < 2*circle.radius) {
// console.log("inside circle")
let vec = {x: relVec.x/circle.radius, y: relVec.y/circle.radius};
let angle = Math.atan2(vec.y, vec.x);
let reach = clamp(Math.hypot(vec.x, vec.y), 0, 1);
normalizedVec = {x: reach*Math.cos(angle), y: reach*Math.sin(angle)};
handleJoystickChange();
}
twoScene.render();
}
sceneContainer.onmousedown = () => {
mouseControlled = true;
mousePointer.opacity = 1;
}
sceneContainer.onmouseup = () => {
mouseControlled = false;
normalizedVec = {x: 0, y: 0};
handleJoystickChange();
mousePointer.opacity = 0;
twoScene.render();
}
joystickBankEnabled = true;
loop();
})
function onLeftJoystickChange({ angle, distance, zone }) {
console.log("onLeftJoystickChange", angle, distance, zone);
}
function onRightJoystickChange({ angle, distance, zone }) {
updateNavigation(angle, distance, zone);
console.log("onRightJoystickChange", angle, distance, zone);
// updateSpeed(distance);
// updateNavigation(angle);
}
$: updateRangesMap(inputRangesMap);
</script>
<h2>Controller</h2>
<div class="grid grid-cols-2">
<div>
<h2>Robot Controller</h2>
<pre>
angle: {angleFullRangeDeg.toFixed(2)} deg
dir: {direction}
speed: {finalSpeed}
vec: {normalizedVec.x.toFixed(4)} {normalizedVec.y.toFixed(4)}
</pre>
<pre style="opacity: 0.6">joystick: {prettyGpState}</pre>
<pre style="opacity: 0.6; font-size: 0.5em">joystick: {prettyGpState}</pre>
</div>
<div>
<!-- <div class="controller-back"> -->
<!-- <div id="joy" class="controller-joystick"> -->
<!-- <div class="controller-joystick-ins"></div> -->
<!-- </div> -->
<!-- </div> -->
<div id="joy2">
{#if joystickBankEnabled}
<div class="joystick-bank">
<!-- <div id="joy1"> -->
<!-- </div> -->
<!-- <div style="opacity: 0.5" id="joy2"> -->
<!-- </div> -->
<Joystick
zonesDescriptions={rotationZones}
onPositionChangeCallback={onLeftJoystickChange}
bind:this={leftJoystick}
/>
<Joystick
zonesDescriptions={translationZones}
onPositionChangeCallback={onRightJoystickChange}
bind:this={rightJoystick}
/>
</div>
{/if}
</div>
</div>
<div>
<h3>Range settings</h3>
<div class="grid grid-cols-2">
<div>frontLeft:</div>
<div class="grid grid-cols-2">
<input type="number" bind:value={inputRangesMap['frontLeft'][0]} />
<input type="number" bind:value={inputRangesMap['frontLeft'][1]} />
</div>
</div>
<div class="grid grid-cols-2">
<div>frontRight:</div>
<div class="grid grid-cols-2">
<input type="number" bind:value={inputRangesMap['frontRight'][0]} />
<input type="number" bind:value={inputRangesMap['frontRight'][1]} />
</div>
</div>
<div class="grid grid-cols-2">
<div>backLeft:</div>
<div class="grid grid-cols-2">
<input type="number" bind:value={inputRangesMap['backLeft'][0]} />
<input type="number" bind:value={inputRangesMap['backLeft'][1]} />
</div>
</div>
<div class="grid grid-cols-2">
<div>backRight:</div>
<div class="grid grid-cols-2">
<input type="number" bind:value={inputRangesMap['backRight'][0]} />
<input type="number" bind:value={inputRangesMap['backRight'][1]} />
</div>
</div>
</div>

230
src/lib/Joystick.svelte Normal file
View file

@ -0,0 +1,230 @@
<script>
import { onMount } from 'svelte';
import Two from 'two.js';
import { sign, clamp } from './utils'
export let onPositionChangeCallback = (angle, distance) => {};
// list of the zones, defined by their start angle, their end angle and an optional identifier
export let zonesDescriptions = [];
const BUTTON_BINDINGS = ['x','o','triangle','square','l1','r1','l2','r2','share','options','home','leftstickpress','rightstickpress']
const AXIS_BINDINGS = ['left_joystick_x','left_joystick_y','l2','right_joystick_x','right_joystick_y','r2','cross_x','cross_y']
let joystickCircleX = 130;
let joystickCircleY = 130;
let joystickCircleRad = 100;
let joystickContainer;
let twoScene = null;
// two.js elements
let zonesGroup = null;
let joystickGroup = null;
let joystickBack = null;
let joystickPointer = null;
let mousePointer = null;
let currentGamepadIndex = -1;
let normalizedVec = {x: 0, y: 0};
let mouseControlled = false;
export function onGamepadJoystickChange(x, y) {
// pre process the right joystick
let angle = Math.atan2(y, x);
let reach = clamp(Math.hypot(x, y), 0, 1);
// create the normalized vector
normalizedVec = {x: reach*Math.cos(angle), y: reach*Math.sin(angle)};
handleJoystickChange()
}
function renderJoystick() {
joystickPointer.translation.x = normalizedVec.x*joystickCircleRad;
joystickPointer.translation.y = normalizedVec.y*joystickCircleRad;
twoScene.render();
}
// get the index of the zone currently pointed by the pointer
function getZoneFromAngle(angle) {
for (var i = 0; i < zonesDescriptions.length; i++) {
let zone = zonesDescriptions[i];
if (angle >= zone.from && angle <= zone.to) {
return i;
}
}
return 0;
}
function handleJoystickChange() {
// distance between 0 and 1
let reach = Math.hypot(normalizedVec.x, normalizedVec.y);
// convert angle that is sometimes from 0 to -180 and sometimes from 0 to 180 deg
// to something from 0 to 360 full circle
let angle = Math.atan2(normalizedVec.y, normalizedVec.x);
let angleFullRange = (reach == 0) ? 0 : (angle < 0) ? -angle : Math.PI + (Math.PI - angle);
console.log('infos in degs', {
angle: angle*180/Math.PI,
angleFullRange: angleFullRange*180/Math.PI,
reach, normalizedVec
});
let selectedZoneIndex = -1;
if (reach == 0) {
zonesGroup.children.forEach(zone => {
zone.opacity = 1
})
}
if (reach > 0) {
selectedZoneIndex = getZoneFromAngle(angleFullRange)
zonesGroup.children.forEach((zone, i) => {
zone.opacity = (i === selectedZoneIndex) ? 0.5 : 1;
})
}
renderJoystick()
onPositionChangeCallback({
angle: angleFullRange,
distance: reach,
zone: selectedZoneIndex
})
}
function resizeScene() {
if (!joystickContainer) return null
constructScene()
}
function constructScene() {
joystickContainer.innerHTML = '';
console.log("construct scene");
let rect = joystickContainer.getBoundingClientRect()
let [width, height] = [rect.width, rect.height];
twoScene = new Two({
width, height
}).appendTo(joystickContainer);
twoScene.renderer.domElement.style.background = 'rgba(0, 0, 0, 0.1)';
joystickGroup = new Two.Group();
joystickGroup.translation.x = width/2;
joystickGroup.translation.y = height/2;
joystickBack = new Two.Circle(
0, 0, joystickCircleRad
);
joystickBack.fill = "rgba(236, 240, 241,1.0)";
joystickBack.stroke = "rgba(52, 73, 94,1.0)";
joystickPointer = new Two.Circle(
0, 0, 10
);
joystickPointer.fill = "rgba(231, 76, 60, 0.8)";
joystickPointer.stroke = "rgba(44, 62, 80, 0.5)";
joystickPointer.linewidth = 2;
let nbOfZones = 8;
let delta = 2*Math.PI/nbOfZones;
let offset = delta/2;
zonesGroup = new Two.Group();
let zonesLabelGroups = new Two.Group();
zonesDescriptions.forEach((zone, index) => {
let arc = new Two.ArcSegment(
0, 0, 0, joystickCircleRad,
-zone.from, -zone.to
);
let a = (-zone.from + -zone.to)/2;
const b = 1.2;
zonesLabelGroups.add(new Two.Text(
`${index}`,
joystickCircleRad*b*Math.cos(a), joystickCircleRad*b*Math.sin(a)
))
arc.fill = "rgba(41, 128, 185,1.0)";
arc.stroke = "rgba(44, 62, 80,1.0)"
zonesGroup.add(arc);
})
mousePointer = new Two.Circle(0, 0, 4);
mousePointer.opacity = 0;
joystickGroup.add(joystickBack);
joystickGroup.add(zonesGroup);
joystickGroup.add(zonesLabelGroups);
joystickGroup.add(joystickPointer);
// marker to test for position
// joystickGroup.add(new Two.Circle(0, 0, 5));
twoScene.add(joystickGroup);
twoScene.add(mousePointer);
twoScene.render();
}
onMount(() => {
console.log('zonesDescriptions', zonesDescriptions);
let resizeObserver = (new ResizeObserver(() => resizeScene()));
resizeObserver.observe(joystickContainer);
constructScene();
function disableMouse() {
mouseControlled = false;
normalizedVec = {x: 0, y: 0};
handleJoystickChange();
mousePointer.opacity = 0;
twoScene.render();
}
// handle mouse events
joystickContainer.onmousemove = (e) => {
if (!mouseControlled) {
return;
}
// console.log(e);
let rect = joystickContainer.getBoundingClientRect();
let x = e.clientX - rect.left;
let y = e.clientY - rect.top;
mousePointer.translation.x = x;
mousePointer.translation.y = y;
// compute the position inside the circle
let circle = {
x: joystickGroup.translation.x,
y: joystickGroup.translation.y,
radius: joystickCircleRad
};
let relVec = {x: x-circle.x, y: y-circle.y};
// console.log("rel circle", relVec);
// if (Math.hypot(relVec.x, relVec.y) < 2*circle.radius) {
// console.log("inside circle")
let vec = {x: relVec.x/circle.radius, y: relVec.y/circle.radius};
let angle = Math.atan2(vec.y, vec.x);
let reach = clamp(Math.hypot(vec.x, vec.y), 0, 1);
normalizedVec = {x: reach*Math.cos(angle), y: reach*Math.sin(angle)};
handleJoystickChange();
// }
twoScene.render();
}
joystickContainer.onmousedown = () => {
mouseControlled = true;
mousePointer.opacity = 1;
}
joystickContainer.onmouseup = () => {
disableMouse();
}
joystickContainer.onmouseleave = () => {
disableMouse();
}
})
</script>
<div bind:this={joystickContainer} class="joystick-container">
</div>

View file

@ -16,6 +16,7 @@ export default class WebSocketService extends EventTarget {
}
/* Various other things */
this.isEnabled = true
this.recovery = false
this.isConnected = false
this.handlersList = []
@ -119,7 +120,7 @@ export default class WebSocketService extends EventTarget {
send(cmd, args = {}) {
const payload = {cmd, args};
if (!this.ws.readyState) {
if (!this.ws.readyState || !this.ws.isEnabled) {
console.log("Would have sent", payload)
return
}

27
src/lib/utils.ts Normal file
View file

@ -0,0 +1,27 @@
/**
* Return the clamped value of a number in a bounded interval
*/
export function clamp(v: number, min: number, max: number): number {
return Math.min(Math.max(v, min), max);
}
/**
* Return the sign as integer of a number
*/
export function sign(x: number): number {
if (x == 0) {
return 0;
}
if (x > 0) {
return 1;
}
return -1;
}
/**
* Crude way to tell if two objects are the same
*/
export function areSameObjects(a: any, b: any): boolean {
return JSON.stringify(a) == JSON.stringify(b);
}