feat: working basic joystick control + with mouse

This commit is contained in:
Matthieu Bessat 2023-03-05 11:16:27 +01:00
parent dc35b13ab4
commit 0657dff26e
14 changed files with 5491 additions and 5 deletions

4917
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -12,13 +12,21 @@
},
"devDependencies": {
"@sveltejs/adapter-auto": "^1.0.0",
"@sveltejs/adapter-static": "^1.0.5",
"@sveltejs/kit": "^1.0.0",
"autoprefixer": "^10.4.13",
"eslint": "^8.28.0",
"eslint-plugin-svelte3": "^4.0.0",
"postcss": "^8.4.21",
"sass": "^1.58.0",
"svelte": "^3.54.0",
"svelte-check": "^3.0.1",
"tailwindcss": "^3.2.4",
"typescript": "^4.9.3",
"vite": "^4.0.0"
},
"type": "module"
"type": "module",
"dependencies": {
"two.js": "^0.8.10"
}
}

6
postcss.config.cjs Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

38
src/app.css Normal file
View file

@ -0,0 +1,38 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
background: black;
color: white;
}
.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%; */
/* top: -45%; */
margin-top: -50%;
margin-left: -50%;
width: 1em;
height: 1em;
background: blue;
border-radius: 50%;
}
#joy2 {
/* width: 10em; */
/* heigth: 10em; */
background: gray;
}

6
src/hook.js Normal file
View file

@ -0,0 +1,6 @@
export const handle = async ({ event, resolve }) => {
const response = await resolve(event, {
ssr: false,
});
return response;
};

3
src/hook.server.js Normal file
View file

@ -0,0 +1,3 @@
export async function handle({ event, resolve }) {
return resolve(event, { ssr: false });
}

327
src/lib/Controller.svelte Normal file
View file

@ -0,0 +1,327 @@
<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 { 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']
function getGamepadState(gpIndex) {
let tmpState = navigator.getGamepads()[currentGamepadIndex];
return {
buttons: tmpState.buttons.map(b => {
return {pressed: b.pressed, touched: b.touched, value: b.value}
}),
axes: tmpState.axes
}
}
let l2initialized;
export let data;
export let gpState;
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 initGamepads() {
console.log("init 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.`);
});
window.addEventListener("gamepaddisconnected", (e) => {
console.log(e.gamepad);
});
}
function convert(s) {
// -1 1
// 0 2
return ((s+1)/2)*100
}
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]]))
}
}
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) {
gpDictState = getGamepadStateAsDict(lastGpState);
if (!l2initialized && gpDictState.axes.l2 != 0) {
l2initialized = true;
}
prettyGpState = JSON.stringify(gpDictState, " ", 4);
// joy.style.left = convert(gpDictState.axes.right_joystick_x) + "%"
// joy.style.top = convert(gpDictState.axes.right_joystick_y) + "%"
// 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();
}
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) {
let receivedState = getGamepadState(currentGamepadIndex);
// console.log(lastGpState == receivedState, lastGpState, JSON.stringify(lastGpState), JSON.stringify(receivedState))
if (lastGpState == null || !areSameObjects(lastGpState, receivedState)) {
// console.log("changed")
lastGpState = receivedState;
onGamepadChange(receivedState);
twoScene.render();
}
}
window.requestAnimationFrame(loop)
}
onMount(() => {
let 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);
}
let mousePointer = new Two.Circle(0, 0, 4);
twoScene.add(zonesGroup);
twoScene.add(rightJoystick.pointer);
twoScene.add(mousePointer);
twoScene.render();
let controller_index = 0;
initGamepads();
currentWs = new WebSocketService("http://192.168.1.128:1567");
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();
}
loop();
})
</script>
<h2>Controller</h2>
<div class="grid grid-cols-2">
<div>
<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>
</div>
<div>
<!-- <div class="controller-back"> -->
<!-- <div id="joy" class="controller-joystick"> -->
<!-- <div class="controller-joystick-ins"></div> -->
<!-- </div> -->
<!-- </div> -->
<div id="joy2">
</div>
</div>
</div>

131
src/lib/WebSocketService.js Normal file
View file

@ -0,0 +1,131 @@
export default class WebSocketService extends EventTarget {
constructor(address) {
super()
/* Set address */
this.addressLocalStorageKey = 'WR_UI_SERVER_ADDRESS'
this.address = address
let lsAddress = window.localStorage.getItem(this.addressLocalStorageKey)
if (lsAddress != null) { this.address = lsAddress}
/* Set identifier */
this.identifierLocalStorageKey = 'WR_UI_CLIENT_IDENTIFIER'
this.identifier = 'default_' + Date.now().toString()
let lsIdentifier = window.localStorage.getItem(this.identifierLocalStorageKey)
if (lsIdentifier != null && lsIdentifier != 'null') { this.identifier = lsIdentifier } else {
window.localStorage.setItem(this.identifierLocalStorageKey, this.identifier)
}
/* Various other things */
this.recovery = false
this.isConnected = false
this.handlersList = []
this.toSend = []
}
start() {
//console.log(this.isConnected)
let addr = 'ws://' + this.address.replace('http://', '')
addr += "?identifier=" + this.identifier
if (this.isConnected) {
this.recovery = false
return
}
try {
this.ws = new WebSocket(addr)
} catch (err) {
console.log(err)
}
this.ws.onopen = () => {
this.isConnected = true
console.log(
'%c > WebSocketService: opened (id: ' + this.identifier +')',
'background: black; color: #00ff00; font-size: 1.1em'
)
this.dispatchEvent(new CustomEvent('connectionUpdated', { detail: true }))
this.toSend.forEach(value => {
this.send(value[0], value[1])
})
this.toSend = []
}
this.ws.onmessage = (event) => {
console.log(event.data)
let parsed = JSON.parse(event.data)
// this.dispatchEvent(new CustomEvent(
// parsed['responseType'], { detail: parsed['data'] })
// )
}
this.ws.onclose = () => {
if (!this.recovery) {
console.log('%c > WebSocketService: closed ', 'background: black; color: #00ff00; font-size: 1.1em')
this.dispatchEvent(new CustomEvent('connectionUpdated', { detail: false }))
}
this.isConnected = false
this.recovery = true
setTimeout(this.start.bind(this), 1000)
}
}
on(event, identifier, handler) {
if (this.isConnected) {
this.send('sub', { topic: event })
} else {
this.toSend.push(['sub', { topic: event }])
}
let filterRes = this.handlersList.filter(h => h.identifier === identifier)
//console.log(filterRes)
let realHandler = e => {
//console.log(e)
handler(e)
}
if (filterRes.length === 0) {
this.handlersList.push({
identifier,
event,
realHandler
})
} else {
this.removeEventListener(event, filterRes[0].realHandler)
this.handlersList = this.handlersList.map((h) => {
if (h.identifier === identifier) {
h.realHandler = realHandler
}
return h
})
}
this.addEventListener(event, realHandler)
}
changeAddress(address) {
this.address = address
window.localStorage.setItem(this.addressLocalStorageKey, this.address)
this.start()
}
getAddress() {
return this.address
}
changeIdentifier(identifier) {
this.identifier = identifier
window.localStorage.setItem(this.identifierLocalStorageKey, this.address)
this.start()
}
getIdentifier() {
return this.identifier
}
send(cmd, args = {}) {
const payload = {cmd, args};
if (!this.ws.readyState) {
console.log("Would have sent", payload)
return
}
console.debug(cmd, args)
this.ws.send(JSON.stringify(payload))
}
}

1
src/routes/+layout.js Normal file
View file

@ -0,0 +1 @@
export const ssr = false;

View file

@ -0,0 +1,5 @@
<script>
import "../app.css";
</script>
<slot />

View file

@ -1,2 +1,10 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>
<script>
import Controller from '$lib/Controller.svelte';
import WebSocketService from '$lib/WebSocketService.js'
/** @type {import('./$types').PageData} */
export let data;
</script>
<Controller />

View file

@ -1,10 +1,14 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/kit/vite';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter()
}
adapter: adapter({
fallback: true
})
},
preprocess: vitePreprocess()
};
export default config;

10
tailwind.config.cjs Normal file
View file

@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/**/*.{html,js,svelte,ts}',
],
theme: {
extend: {},
},
plugins: [],
}

22
ws_client.py Normal file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env python
import asyncio
import json
from websockets import connect
async def cmd(ws, name, **args):
msg = {
'cmd': name,
'args': args
}
await ws.send(json.dumps(msg))
res = await ws.recv()
print(res)
async def hello(uri):
async with connect(uri) as ws:
await cmd(ws, "ping")
await cmd(ws, "shit")
await cmd(ws, "set_vector", vector_type="trans", vector_args=[1, 0])
asyncio.run(hello("ws://192.168.1.252:1567"))