8ccd7689f7
Add configurable Xbox motion and action bindings, frame-aware six-axis MAVLink controls, sensor logging, camera viewing and recording, and a faster startup path. Synchronize both app copies and document companion hardware setup and verification limits.
126 lines
6.5 KiB
JavaScript
126 lines
6.5 KiB
JavaScript
/* Controller conditioning is independent of drawing and MAVLink transport. */
|
||
((root)=>{
|
||
const clamp=value=>Math.max(-1,Math.min(1,Number.isFinite(value)?value:0));
|
||
function deadZone(value,zone=.08){
|
||
value=clamp(value);zone=Math.max(0,Math.min(.4,zone));
|
||
return Math.abs(value)<=zone?0:Math.sign(value)*(Math.abs(value)-zone)/(1-zone);
|
||
}
|
||
function defaults(pad){
|
||
return {turn:0,forward:1,vertical:pad?.axes?.length>=4?3:pad?.axes?.length>=3?2:-1,
|
||
invertTurn:false,invertForward:true,invertVertical:true,deadZone:.08};
|
||
}
|
||
function read(pad,config){
|
||
if(config?.version===2)return readProfile(pad,config);
|
||
if(!pad?.connected||!pad.axes)return null;
|
||
const axis=(index,invert)=>{
|
||
if(index===-1)return 0;
|
||
if(!Number.isInteger(index)||index<0||index>=pad.axes.length||!Number.isFinite(pad.axes[index]))return null;
|
||
return deadZone(pad.axes[index],config.deadZone)*(invert?-1:1);
|
||
};
|
||
let turn=axis(config.turn,config.invertTurn),forward=axis(config.forward,config.invertForward),vertical=axis(config.vertical,config.invertVertical);
|
||
if(turn===null||forward===null||vertical===null)return null;
|
||
const length=Math.hypot(turn,forward);if(length>1){turn/=length;forward/=length}
|
||
return {turn,forward,vertical};
|
||
}
|
||
class Smoother{
|
||
constructor(){this.reset()}
|
||
reset(){this.value=0;this.last=null;this.release=null}
|
||
step(target,now){
|
||
target=clamp(target);
|
||
const dt=this.last===null?0:Math.max(0,Math.min(.05,(now-this.last)/1000));this.last=now;
|
||
if(target===0){
|
||
if(!this.release)this.release={at:now,start:this.value};
|
||
const t=Math.min(1,Math.max(0,(now-this.release.at)/360)),ease=t*t*(3-2*t);
|
||
this.value=this.release.start*(1-ease);
|
||
}else{
|
||
this.release=null;
|
||
this.value+=(target-this.value)*(1-Math.exp(-dt/.1));
|
||
if(Math.abs(target-this.value)<.001)this.value=target;
|
||
}
|
||
return this.value;
|
||
}
|
||
}
|
||
class Motion{
|
||
constructor(){this.axes={turn:new Smoother(),forward:new Smoother(),vertical:new Smoother()}}
|
||
reset(){Object.values(this.axes).forEach(axis=>axis.reset())}
|
||
step(target,now){
|
||
const demand=Object.fromEntries(Object.entries(this.axes).map(([key,axis])=>[key,axis.step(target[key],now)]));
|
||
const length=Math.hypot(demand.turn,demand.forward);if(length>1){demand.turn/=length;demand.forward/=length}
|
||
return demand;
|
||
}
|
||
}
|
||
const names=['forward','lateral','vertical','turn','pitch','roll'];
|
||
const pair=(negative='off',positive='off')=>({negative,positive,scale:1});
|
||
function preset(name='triggers3'){
|
||
const bindings=Object.fromEntries(names.map(axis=>[axis,pair()]));
|
||
bindings.forward=pair('button:6','button:7');
|
||
bindings.vertical=pair('button:0','button:1');
|
||
bindings.turn=pair('axis:0:-','axis:0:+');
|
||
if(name==='sixaxis'){
|
||
bindings.lateral=pair('axis:2:-','axis:2:+');
|
||
bindings.pitch=pair('axis:1:-','axis:1:+');
|
||
bindings.roll=pair('button:4','button:5');
|
||
}else if(name==='classic'){
|
||
bindings.forward=pair('axis:1:+','axis:1:-');
|
||
bindings.vertical=pair('axis:3:+','axis:3:-');
|
||
}
|
||
return {version:2,preset:name,deadZone:.08,expo:.15,bindings,
|
||
actions:{lights:'button:2',stream:'button:3',record:'button:8'}};
|
||
}
|
||
function migrate(config,pad){
|
||
if(config?.version===2)return structuredClone(config);
|
||
if(!config)return preset(pad&&pad.mapping!=='standard'?'classic':'triggers3');
|
||
const result=preset('classic');
|
||
for(const axis of ['forward','vertical','turn']){
|
||
const index=config[axis],invert=config['invert'+axis[0].toUpperCase()+axis.slice(1)];
|
||
result.bindings[axis]=Number.isInteger(index)&&index>=0?
|
||
pair(`axis:${index}:${invert?'+':'-'}`,`axis:${index}:${invert?'-':'+'}`):pair();
|
||
}
|
||
result.deadZone=config.deadZone??.08;
|
||
result.actions={lights:'off',stream:'off',record:'off'};
|
||
return result;
|
||
}
|
||
function validProfile(config){
|
||
const source=value=>typeof value==='string'&&/^(off|button:(?:[0-9]|[12][0-9]|3[01])|axis:(?:[0-9]|1[0-5]):[+-])$/.test(value);
|
||
return config?.version===2&&Number.isFinite(config.deadZone)&&config.deadZone>=0&&config.deadZone<=.3&&
|
||
Number.isFinite(config.expo)&&config.expo>=0&&config.expo<=1&&names.every(axis=>{
|
||
const item=config.bindings?.[axis];return item&&source(item.negative)&&source(item.positive)&&
|
||
(item.negative==='off'||item.negative!==item.positive)&&Number.isFinite(item.scale)&&item.scale>=0&&item.scale<=1;
|
||
})&&['lights','stream','record'].every(action=>source(config.actions?.[action])&&!config.actions[action].startsWith('axis:'));
|
||
}
|
||
function inputValue(pad,source,zone){
|
||
if(source==='off')return 0;
|
||
const parts=source.split(':'),index=Number(parts[1]);
|
||
if(parts[0]==='button'){
|
||
const button=pad.buttons?.[index];if(!button||!Number.isFinite(button.value))return null;
|
||
return index===6||index===7?Math.max(0,deadZone(button.value,zone)):(button.pressed?1:0);
|
||
}
|
||
const value=pad.axes?.[index];
|
||
return Number.isFinite(value)?Math.max(0,deadZone(value,zone)*(parts[2]==='-'?-1:1)):null;
|
||
}
|
||
function readProfile(pad,config){
|
||
if(!pad?.connected||!validProfile(config))return null;
|
||
const result={};
|
||
for(const name of names){
|
||
const item=config.bindings[name],negative=inputValue(pad,item.negative,config.deadZone),positive=inputValue(pad,item.positive,config.deadZone);
|
||
if(negative===null||positive===null)return null;
|
||
const value=clamp(positive-negative);
|
||
result[name]=((1-config.expo)*value+config.expo*value**3)*item.scale;
|
||
}
|
||
return result;
|
||
}
|
||
function actions(pad,config){
|
||
return Object.fromEntries(Object.entries(config?.actions||{}).map(([name,source])=>[name,!!pad&&inputValue(pad,source,0)>.5]));
|
||
}
|
||
function sources(pad){
|
||
const choices=[['off','Off']],axes=['Left X','Left Y','Right X','Right Y'];
|
||
for(let index=0;index<Math.max(4,pad?.axes?.length||0);index++)for(const sign of ['-','+'])
|
||
choices.push([`axis:${index}:${sign}`,`${axes[index]||'Axis '+index} ${sign==='-'?'−':'+'}`]);
|
||
const buttons=['A','B','X','Y','LB','RB','LT','RT','View','Menu','Left click','Right click','D-pad up','D-pad down','D-pad left','D-pad right','Guide'];
|
||
for(let index=0;index<Math.max(17,pad?.buttons?.length||0);index++)choices.push([`button:${index}`,buttons[index]||'Button '+index]);
|
||
return choices;
|
||
}
|
||
root.ControllerInput={deadZone,defaults,read,Motion,names,preset,migrate,validProfile,actions,sources};
|
||
if(typeof module!=='undefined')module.exports=root.ControllerInput;
|
||
})(typeof window!=='undefined'?window:globalThis);
|