diff --git a/index.html b/index.html index 9c8aa27..2e00d4b 100644 --- a/index.html +++ b/index.html @@ -43,6 +43,10 @@ body{ float:left; width:50%; } +.log{ + clear:both; + display:none; +} @@ -87,10 +91,13 @@ body{ Battery: 0% +
no data
+ + @@ -103,7 +110,7 @@ body{ var activeWaypoints = []; var waypoints = []; var follow = false; - + var phoneIcon = L.icon({ iconUrl: 'images/iphone.png' }); @@ -207,8 +214,9 @@ body{ follow = true }) $('#manual').click(function(){ - // control via xbox follow = false + clearCurrentTarget() + nodecopterGamepad.initGamepad(socket); }) }) diff --git a/public/gamepad-calibration.js b/public/gamepad-calibration.js new file mode 100644 index 0000000..fd182f6 --- /dev/null +++ b/public/gamepad-calibration.js @@ -0,0 +1,57 @@ +/* + * gamepad-calibration for the nodecopter-gamepad module. + * + * calibration is initialized with: + * + * nodecopter.initCalibrators([ + * { min: -1, max: 1, center: 0 }, // left-x + * { min: -1, max: 1, center: 0 }, // left-y + * { min: -1, max: 1, center: 0 }, // right-x + * { min: -1, max: 1, center: 0 } // right-y + * ]); + * + * where min/max/center values are observed values from the gamepad for the maximum values. + * After initialization, calibrated values can be retrieved with: + * + * var leftX = nodecopter.getCalibratedValue(gamepad.axes, AXES.LEFT_ANALOGUE_HOR); + * + * @author Martin Schuhfuss + */ +(function(exports) { + "use strict"; + + var calibrators = []; + + // creates a calibration-function for each of the calibration-datasets + exports.initCalibrators = function(calibrationData) { + return calibrators = calibrationData.map(function(cd) { + // intercept invalid calibration-data, ensure that `min < center < max` + if(cd.min >= cd.center) { throw new Error('invalid calibration-data (min >= center)'); } + if(cd.max <= cd.center) { throw new Error('invalid calibration-data (max <= center)'); } + + var negDelta = Math.abs(cd.center - cd.min), + posDelta = Math.abs(cd.center - cd.max), + negScaling = 1/negDelta, + posScaling = 1/posDelta; + + // the calibration-function calculates calibrated output-values from raw-inputs, clipped to range [-1, 1] + return function(rawValue) { + var centerOffset = rawValue - cd.center; + + if(centerOffset < 0) { + return Math.max(-1, centerOffset * negScaling); + } else if(centerOffset > 0) { + return Math.min(1, centerOffset * posScaling); + } else { return 0.0; } + }; + }); + }; + + exports.getCalibratedValue = function(axesData, axisId) { + var rawValue = axesData[axisId]; + + if(!calibrators[axisId]) { return rawValue; } + + return calibrators[axisId](rawValue); + }; +}((typeof exports === 'undefined')? (this.nodecopterGamepad = this.nodecopterGamepad||{}) : exports)); \ No newline at end of file diff --git a/public/gamepad-client.js b/public/gamepad-client.js new file mode 100644 index 0000000..82c5545 --- /dev/null +++ b/public/gamepad-client.js @@ -0,0 +1,194 @@ +(function(exports) { + "use strict"; + + // lookup-tables + var BTN = { + // Face (main) buttons + FACE_1: 0, FACE_2: 1, FACE_3: 2, FACE_4: 3, + + // Top/bottom shoulder buttons + LEFT_SHOULDER: 4, RIGHT_SHOULDER: 5, + LEFT_SHOULDER_BOTTOM: 6, RIGHT_SHOULDER_BOTTOM: 7, + + SELECT: 8, START: 9, + + // Analogue stick-buttons (if depressible) + LEFT_ANALOGUE_STICK: 10, + RIGHT_ANALOGUE_STICK: 11, + + // Directional (discrete) pad + PAD_TOP: 12, PAD_BOTTOM: 13, PAD_LEFT: 14, PAD_RIGHT: 15 + }; + + var AXES = { + LEFT_ANALOGUE_HOR: 0, LEFT_ANALOGUE_VERT: 1, + RIGHT_ANALOGUE_HOR: 2, RIGHT_ANALOGUE_VERT: 3 + }; + + + var socket = null; + + /** + * initializes the gamepad-controls + * + * @param websocket the websocket control-events are sent to + * @param options TODO... + * some ideas: + * - calibration-data + * - handler-callbacks for 'gamepadState'-events + * - button-configuration + */ + exports.initGamepad = function(websocket, options) { + socket = websocket; + + // kick-off control-loop + (function __controlLoop() { + requestAnimationFrame(__controlLoop); + + // TODO: only works in newer versions of chrome, adapt for mozilla-API… + var gamepad = navigator.webkitGetGamepads()[0]; + + // TODO: emit events gamepadConnect/gamepadDisconnect + if(!gamepad) { return; } + + handleGamepadState(gamepad); + } ()); + }; + + // initialize + var lastGamepadState = { + leftX: 0.0, leftY: 0.0, + btnStart: false, btnStop: false, + btnTurnCW: false, btnTurnCCW: false, + btnUp: false, btnDown: false + }; + + var getCalibratedValue = exports.getCalibratedValue || function(axes,idx) { return axes[idx]; }; + + function handleGamepadState(gamepad) { + var leftX, leftY; + + leftX = getCalibratedValue(gamepad.axes, AXES.LEFT_ANALOGUE_HOR); + leftY = getCalibratedValue(gamepad.axes, AXES.LEFT_ANALOGUE_VERT); + + // this is for better readability only and might be inlined. + // (I suspect the js-engine will likely inline it anyway). + var gamepadState = { + // toFixed(1) to prevent too many useless nav-packets + leftX: leftX.toFixed(1), + leftY: leftY.toFixed(1), + + // buttons are converted to boolean for easier handling + btnStart: (1==gamepad.buttons[BTN.START]), + btnStop: (1==gamepad.buttons[BTN.SELECT]), + btnTurnCW: (1==gamepad.buttons[BTN.RIGHT_SHOULDER]), + btnTurnCCW: (1==gamepad.buttons[BTN.LEFT_SHOULDER]), + btnDown: (1==gamepad.buttons[BTN.FACE_1]), + btnUp: (1==gamepad.buttons[BTN.FACE_4]), + + btnFlipFwd: (1==gamepad.buttons[BTN.PAD_TOP]), + btnFlipBwd: (1==gamepad.buttons[BTN.PAD_BOTTOM]), + btnFlipLeft: (1==gamepad.buttons[BTN.PAD_LEFT]), + btnFlipRight: (1==gamepad.buttons[BTN.PAD_RIGHT]) + }; + + // ---- logging + // TODO: emit a gamepadState-event or something + document.querySelector('.log').innerHTML = JSON.stringify(gamepadState, null, 2); + + // ---- analogue-stick left/right + var horiz=gamepadState.leftX; + if(horiz != lastGamepadState.leftX) { + if(horiz<0) { // negative: left + socket.emit('control', { action: 'left', speed: -horiz }); + } else if(horiz>0) { + socket.emit('control', { action: 'right', speed: horiz }); + } else { // == 0 + socket.emit('control', { action: 'left', speed: 0 }); + socket.emit('control', { action: 'right', speed: 0 }); + } + } + + // ---- analogue-stick up/down + var leftY=gamepadState.leftY; + if(leftY != lastGamepadState.leftY) { + if(leftY<0) { // negative: up + socket.emit('control', { action: 'front', speed: -leftY }); + } else if(leftY>0) { + socket.emit('control', { action: 'back', speed: leftY }); + } else { // == 0 + socket.emit('control', { action: 'front', speed: 0 }); + socket.emit('control', { action: 'back', speed: 0 }); + } + } + + // ---- takeoff/land + if(gamepadState.btnStart && !lastGamepadState.btnStart) { + socket.emit('control', { action: 'takeoffOrLand' }); + } + + // ---- stop-button + if(gamepadState.btnStop && !lastGamepadState.btnStop) { + socket.emit('control', { action: 'stop' }); + } + + // ---- up/down/cw/ccw buttons (TODO: add accelleration for more fine-grained control) + var evMap = { + btnUp: { action: 'up', mode: 'toggleSpeed' }, + btnDown: { action: 'down', mode: 'toggleSpeed' }, + btnTurnCW: { action: 'clockwise', mode: 'toggleSpeed' }, + btnTurnCCW: { action: 'counterClockwise', mode: 'toggleSpeed' }, + + btnFlipFwd: { action: 'animate', animation: 'flipAhead', mode: 'trigger' }, + btnFlipBwd: { action: 'animate', animation: 'flipBehind', mode: 'trigger' }, + btnFlipLeft: { action: 'animate', animation: 'flipLeft', mode: 'trigger' }, + btnFlipRight: { action: 'animate', animation: 'flipRight', mode: 'trigger' } + }; + + Object.keys(evMap).forEach(function(btnId) { + var evData = evMap[btnId], + curr = gamepadState[btnId], + last = lastGamepadState[btnId]; + + if('toggleSpeed' == evData.mode) { + if(curr && !last) { // btnPress + socket.emit('control', { action: evData.action, speed: 0.5 }); + } else if(!curr && last) { // btnRelease + socket.emit('control', { action: evData.action, speed: 0 }); + } + } else if('trigger' == evData.mode) { + if(curr && !last) { + socket.emit('control', { action: evData.action, animation: evData.animation, duration: 15 }); + } + } + }); + + lastGamepadState = gamepadState; + }; +} ( (typeof exports === 'undefined')? (this.nodecopterGamepad = this.nodecopterGamepad||{}) : exports) ); + +// RAF-Polyfill +(function(window) { + var lastTime = 0; + var vendors = ['ms', 'moz', 'webkit', 'o']; + for(var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { + window.requestAnimationFrame = window[vendors[x]+'RequestAnimationFrame']; + window.cancelAnimationFrame = + window[vendors[x]+'CancelAnimationFrame'] || window[vendors[x]+'CancelRequestAnimationFrame']; + } + + if (!window.requestAnimationFrame) + window.requestAnimationFrame = function(callback, element) { + var currTime = new Date().getTime(); + var timeToCall = Math.max(0, 16 - (currTime - lastTime)); + var id = window.setTimeout(function() { callback(currTime + timeToCall); }, + timeToCall); + lastTime = currTime + timeToCall; + return id; + }; + + if (!window.cancelAnimationFrame) + window.cancelAnimationFrame = function(id) { + clearTimeout(id); + }; +}(window||{})); \ No newline at end of file diff --git a/server.js b/server.js index cecb89b..af8e68c 100644 --- a/server.js +++ b/server.js @@ -24,6 +24,15 @@ io.set('destroy upgrade', false) io.sockets.on('connection', function(socket) { console.log('connection') + socket.on('control', function(ev) { + console.log('[control]', JSON.stringify(ev)); + if(ev.action == 'animate'){ + client.animate(ev.animation, ev.duration) + } else { + client[ev.action].call(client, ev.speed); + } + }) + socket.on('takeoff', function(data){ console.log('takeoff', data) client.takeoff()