Skip to content

Commit fad9394

Browse files
RickDnampsclaude
andcommitted
Feat: Gamepad API BT controller + Lock Mode
BTController implémente la Gamepad API HTML5 : - Auto-détection manette BT via navigator.getGamepads() - Polling rAF 30 Hz propulsion, 20 Hz dôme - Kids mode : vitesse limitée par _speedLimit (même slider que joystick) - Child Lock : propulsion bloquée, arrêt immédiat si actif en cours - Boutons : panneaux dôme/body ouverts tant qu'appuyés, son front montant - Débranchement → arrêt moteurs auto - Le lock ne peut être retiré que depuis l'interface web/Android (pas depuis la manette) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent cb76369 commit fad9394

3 files changed

Lines changed: 324 additions & 56 deletions

File tree

android/app/src/main/assets/js/app.js

Lines changed: 162 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1310,52 +1310,186 @@ async function loadScripts() { await scriptEngine.load(); }
13101310

13111311
class BTController {
13121312
constructor() {
1313-
this._connected = false;
1313+
this._connected = false;
1314+
this._gamepadIdx = null;
1315+
this._prevBtns = {};
1316+
this._driveActive = false;
1317+
this._domeActive = false;
1318+
this._lastDriveMs = 0;
1319+
this._lastDomeMs = 0;
1320+
this._DRIVE_HZ = 1000 / 30; // 30 req/s
1321+
this._DOME_HZ = 1000 / 20; // 20 req/s
13141322
this._loadMappings();
1323+
this._bind();
13151324
}
13161325

1317-
updateStatus(data) {
1318-
if (!data) return;
1319-
const connected = data.bt_connected || false;
1320-
const name = data.bt_name || '—';
1321-
const pct = data.bt_battery || 0;
1326+
_bind() {
1327+
window.addEventListener('gamepadconnected', e => this._onConnect(e.gamepad));
1328+
window.addEventListener('gamepaddisconnected', e => this._onDisconnect(e.gamepad));
1329+
const poll = () => { this._tick(); requestAnimationFrame(poll); };
1330+
requestAnimationFrame(poll);
1331+
}
1332+
1333+
_onConnect(gp) {
1334+
this._gamepadIdx = gp.index;
1335+
this._connected = true;
1336+
this._prevBtns = {};
1337+
this._setUI(true, gp.id.split('(')[0].trim().slice(0, 24));
1338+
toast('Manette BT connectée', 'ok');
1339+
}
1340+
1341+
_onDisconnect(gp) {
1342+
if (gp.index !== this._gamepadIdx) return;
1343+
this._gamepadIdx = null;
1344+
this._connected = false;
1345+
this._driveActive = false;
1346+
this._domeActive = false;
1347+
this._setUI(false, '—');
1348+
api('/motion/stop', 'POST');
1349+
api('/motion/dome/stop', 'POST');
1350+
toast('Manette BT déconnectée', 'error');
1351+
}
1352+
1353+
_tick() {
1354+
const pads = navigator.getGamepads ? navigator.getGamepads() : [];
1355+
1356+
// Auto-détection si pas encore associée
1357+
if (this._gamepadIdx === null) {
1358+
for (let i = 0; i < pads.length; i++) {
1359+
if (pads[i]) { this._onConnect(pads[i]); break; }
1360+
}
1361+
}
1362+
if (this._gamepadIdx === null) return;
1363+
1364+
const gp = pads[this._gamepadIdx];
1365+
if (!gp) {
1366+
if (this._connected) this._onDisconnect({ index: this._gamepadIdx });
1367+
return;
1368+
}
1369+
1370+
const m = this._getMappings();
1371+
const dz = (parseInt(m.deadzone) || 8) / 100;
1372+
const now = performance.now();
13221373

1323-
this._connected = connected;
1374+
// ── PROPULSION — bloquée en Child Lock (mode 2) ────────────────
1375+
if (!lockMgr.isDriveLocked()) {
1376+
const tRaw = -this._axis(gp, m.throttle || 'L_STICK_Y');
1377+
const sRaw = this._axis(gp, m.steer || 'L_STICK_X');
1378+
const t = Math.abs(tRaw) > dz ? tRaw * _speedLimit : 0;
1379+
const s = Math.abs(sRaw) > dz ? sRaw * _speedLimit * 0.55 : 0;
1380+
1381+
if (now - this._lastDriveMs >= this._DRIVE_HZ) {
1382+
this._lastDriveMs = now;
1383+
if (Math.abs(t) > 0.01 || Math.abs(s) > 0.01) {
1384+
api('/motion/arcade', 'POST', { throttle: t, steering: s });
1385+
this._driveActive = true;
1386+
} else if (this._driveActive) {
1387+
api('/motion/stop', 'POST');
1388+
this._driveActive = false;
1389+
}
1390+
}
1391+
} else if (this._driveActive) {
1392+
// Child Lock activé en cours de conduite → arrêt immédiat
1393+
api('/motion/stop', 'POST');
1394+
this._driveActive = false;
1395+
}
13241396

1325-
const icon = document.querySelector('.gamepad-icon');
1397+
// ── DÔME ──────────────────────────────────────────────────────
1398+
const dRaw = this._axis(gp, m.dome || 'R_STICK_X');
1399+
if (now - this._lastDomeMs >= this._DOME_HZ) {
1400+
this._lastDomeMs = now;
1401+
if (Math.abs(dRaw) > dz) {
1402+
api('/motion/dome/turn', 'POST', { speed: dRaw * 0.85 });
1403+
this._domeActive = true;
1404+
} else if (this._domeActive) {
1405+
api('/motion/dome/stop', 'POST');
1406+
this._domeActive = false;
1407+
}
1408+
}
1409+
1410+
// ── BOUTONS — détection de front montant/descendant ───────────
1411+
const prev = this._prevBtns;
1412+
1413+
// Panneau dôme : ouvert tant qu'appuyé
1414+
const p1 = m.panel1 || 'SQUARE';
1415+
const p1v = this._btn(gp, p1);
1416+
if (p1v && !prev[p1]) api('/servo/dome/open_all', 'POST');
1417+
if (!p1v && prev[p1]) api('/servo/dome/close_all', 'POST');
1418+
prev[p1] = p1v;
1419+
1420+
// Panneau body : ouvert tant qu'appuyé
1421+
const p2 = m.panel2 || 'TRIANGLE';
1422+
const p2v = this._btn(gp, p2);
1423+
if (p2v && !prev[p2]) api('/servo/body/open_all', 'POST');
1424+
if (!p2v && prev[p2]) api('/servo/body/close_all', 'POST');
1425+
prev[p2] = p2v;
1426+
1427+
// Son aléatoire — front montant seulement
1428+
const au = m.audio || 'CIRCLE';
1429+
const auv = this._btn(gp, au);
1430+
if (auv && !prev[au]) api('/audio/random', 'POST', { category: 'happy' });
1431+
prev[au] = auv;
1432+
}
1433+
1434+
// Lecture d'axe — retourne -1..1
1435+
_axis(gp, name) {
1436+
const axisMap = { L_STICK_X: 0, L_STICK_Y: 1, R_STICK_X: 2, R_STICK_Y: 3 };
1437+
if (name in axisMap) return gp.axes[axisMap[name]] || 0;
1438+
const btnMap = { L2: 6, R2: 7 };
1439+
if (name in btnMap) { const b = gp.buttons[btnMap[name]]; return b ? b.value : 0; }
1440+
return 0;
1441+
}
1442+
1443+
// Lecture bouton — retourne bool
1444+
_btn(gp, name) {
1445+
const map = {
1446+
CROSS: 0, CIRCLE: 1, SQUARE: 2, TRIANGLE: 3,
1447+
L1: 4, R1: 5, L2: 6, R2: 7, SELECT: 8, START: 9,
1448+
L3: 10, R3: 11, DPAD_UP: 12, DPAD_DOWN: 13, DPAD_LEFT: 14, DPAD_RIGHT: 15,
1449+
};
1450+
const idx = map[name];
1451+
return (idx !== undefined && gp.buttons[idx]) ? gp.buttons[idx].pressed : false;
1452+
}
1453+
1454+
_setUI(connected, name) {
1455+
const icon = document.querySelector('.gamepad-icon');
13261456
const statusText = el('bt-status-text');
13271457
const deviceName = el('bt-device-name');
13281458
const pillBt = el('pill-bt');
1329-
const fillEl = el('bt-battery-fill');
1330-
const pctEl = document.querySelector('#bt-battery-pct') || el('bt-battery-pct');
1331-
13321459
if (icon) icon.classList.toggle('connected', connected);
1333-
if (statusText) {
1334-
statusText.textContent = connected ? 'CONNECTED' : 'NOT CONNECTED';
1335-
statusText.classList.toggle('connected', connected);
1336-
}
1337-
if (deviceName) deviceName.textContent = name;
1460+
if (statusText) { statusText.textContent = connected ? 'CONNECTED' : 'NOT CONNECTED'; statusText.classList.toggle('connected', connected); }
1461+
if (deviceName) deviceName.textContent = name || '—';
13381462
if (pillBt) pillBt.className = 'status-pill ' + (connected ? 'ok' : '');
1463+
}
13391464

1340-
if (fillEl) {
1465+
// Appelé par le poller status — le statut BT réel vient de la Gamepad API (local)
1466+
updateStatus(data) {
1467+
if (!data) return;
1468+
// Batterie si le serveur la connaît (futur)
1469+
const pct = data.bt_battery || 0;
1470+
if (pct > 0) {
1471+
const fillEl = el('bt-battery-fill');
1472+
const pctEl = el('bt-battery-pct');
13411473
const bcolor = pct > 50 ? '#00cc66' : pct > 25 ? '#ff8800' : '#ff2244';
1342-
fillEl.style.width = pct + '%';
1343-
fillEl.style.background = bcolor;
1474+
if (fillEl) { fillEl.style.width = pct + '%'; fillEl.style.background = bcolor; }
1475+
if (pctEl) pctEl.textContent = pct + '%';
13441476
}
1345-
if (pctEl) pctEl.textContent = pct + '%';
1477+
}
1478+
1479+
_getMappings() {
1480+
try { const s = localStorage.getItem('r2d2-bt-mappings'); return s ? JSON.parse(s) : {}; }
1481+
catch { return {}; }
13461482
}
13471483

13481484
_loadMappings() {
13491485
try {
1350-
const saved = localStorage.getItem('r2d2-bt-mappings');
1351-
if (!saved) return;
1352-
const m = JSON.parse(saved);
1353-
if (m.throttle) { const e = el('bt-map-throttle'); if (e) { for (let o of e.options) if (o.value === m.throttle) { o.selected = true; break; } } }
1354-
if (m.steer) { const e = el('bt-map-steer'); if (e) { for (let o of e.options) if (o.value === m.steer) { o.selected = true; break; } } }
1355-
if (m.dome) { const e = el('bt-map-dome'); if (e) { for (let o of e.options) if (o.value === m.dome) { o.selected = true; break; } } }
1486+
const m = this._getMappings();
1487+
if (m.throttle) { const e = el('bt-map-throttle'); if (e) { for (const o of e.options) if (o.value === m.throttle) { o.selected = true; break; } } }
1488+
if (m.steer) { const e = el('bt-map-steer'); if (e) { for (const o of e.options) if (o.value === m.steer) { o.selected = true; break; } } }
1489+
if (m.dome) { const e = el('bt-map-dome'); if (e) { for (const o of e.options) if (o.value === m.dome) { o.selected = true; break; } } }
13561490
const dz = el('bt-deadzone');
1357-
if (dz && m.deadzone) { dz.value = m.deadzone; el('bt-deadzone-val').textContent = m.deadzone + '%'; }
1358-
} catch (e) { /* ignore */ }
1491+
if (dz && m.deadzone) { dz.value = m.deadzone; const dzv = el('bt-deadzone-val'); if (dzv) dzv.textContent = m.deadzone + '%'; }
1492+
} catch { /* ignore */ }
13591493
}
13601494

13611495
saveMappings() {

android/compiled/R2-D2_Control.apk

0 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)