export type Pad=Pick; export type Buttons=[number,number,number]; export const defaultButtons:Buttons=[0,1,9]; const defaultsFor=(p:Pad|null):Buttons=>p?[0,Math.min(1,Math.max(0,p.buttons.length-1)),Math.min(9,Math.max(0,p.buttons.length-1))]:[...defaultButtons]; const down=(p:Pad,i:number)=>!!p.buttons[i]&&(p.buttons[i].pressed||p.buttons[i].value>.5); const key=(p:Pad)=>`${p.index}:${p.id}`; const validButtons=(v:unknown):v is Buttons=>Array.isArray(v)&&v.length===3&&v.every(n=>Number.isInteger(n)&&n>=0&&n<128); export function gamepadInput(p:Pad|null,mapped:Buttons=defaultButtons){ const axis=(i:number)=>Number.isFinite(p?.axes[i])?p!.axes[i]:0; const up=!!p&&(down(p,12)||axis(1)<-.35),downward=!!p&&(down(p,13)||axis(1)>.35); const left=!!p&&(down(p,14)||axis(0)<-.35),right=!!p&&(down(p,15)||axis(0)>.35); const jump=!!p&&down(p,mapped[0]),sword=!!p&&down(p,mapped[1]),start=!!p&&down(p,mapped[2]); return {up,down:downward,left,right,jump,sword,start,bits:(up?1<<4:0)|(downward?1<<5:0)|(left?1<<6:0)|(right?1<<7:0)|(jump||start?1<<8:0)|(sword?1:0)}; } /** Start resumes a foreground paused game; that same press must not jump. */ export class ControllerGate { private consumedStart=false; frame(input:ReturnType&{startEdge:boolean},running:boolean,paused:boolean,visible:boolean,focused:boolean){ if(!input.start)this.consumedStart=false; const resume=running&&paused&&visible&&focused&&input.startEdge; if(resume)this.consumedStart=true; const active=running&&visible&&(!paused||resume); return {resume,bits:active?input.bits&~(this.consumedStart?(1<<8)|1:0):0,startKey:active&&input.start&&!this.consumedStart}; } } /** Choose activity, never the first connected HID. A manual choice stays pinned. */ export class PadRouter { preferredId='';private preferredIndex:number|null=null;private automaticKey=''; private previous=new Map(); choose(p:Pad|null){this.preferredId=p?.id||'';this.preferredIndex=p?.index??null;this.automaticKey='';} sample(pads:readonly(Pad|null)[]){ const live=pads.filter((p):p is Pad=>!!p?.connected);let candidates:Pad[]=[]; for(const p of live){const prior=this.previous.get(key(p)); const button=p.buttons.some((_,i)=>down(p,i)&&!prior?.buttons[i]); const axis=!this.automaticKey&&!!prior&&p.axes.some((v,i)=>Number.isFinite(v)&&Math.abs(v)<=1&&Math.abs(v)>.55&&Math.abs(v-(prior.axes[i]??v))>.4); const plausible=p.mapping==='standard'||p.buttons.length>=10||(p.buttons.length>=2&&p.axes.length>=2); if(plausible&&(button||axis))candidates.push(p); this.previous.set(key(p),{buttons:p.buttons.map((_,i)=>down(p,i)),axes:[...p.axes]}); } const keys=new Set(live.map(key));for(const k of this.previous.keys())if(!keys.has(k))this.previous.delete(k); if(this.preferredId){return live.find(p=>p.id===this.preferredId&&p.index===this.preferredIndex)||live.find(p=>p.id===this.preferredId)||null;} if(candidates.length){candidates.sort((a,b)=>Number(b.mapping==='standard')-Number(a.mapping==='standard')); const current=live.find(p=>key(p)===this.automaticKey); if(current?.mapping!=='standard'||candidates[0].mapping==='standard')this.automaticKey=key(candidates[0]); } return live.find(p=>key(p)===this.automaticKey)||null; } } function load(k:string):unknown{try{return JSON.parse(localStorage.getItem(k)||'null');}catch{return null;}} function save(k:string,v:unknown){try{localStorage.setItem(k,JSON.stringify(v));}catch{/* Controls still work when browser storage is unavailable. */}} function text(id:string,value:string){const e=document.getElementById(id);if(e&&e.textContent!==value)e.textContent=value;} export class ControllerInput { readonly router=new PadRouter();private mapped:Buttons=[...defaultButtons];private activeKey='';private lastStart=false; private listSignature='';private profileButtonCount=-1;private lastRender=0;private current:Pad|null=null; state={...gamepadInput(null),startEdge:false,changed:false,pad:null as Pad|null}; constructor(private identity:string){ const selected=load(identity+'-controller-choice');if(typeof selected==='string')this.router.preferredId=selected; const select=document.getElementById('pad-device') as HTMLSelectElement; select.onchange=()=>{const p=this.readPads().find(p=>p&&String(p.index)===select.value)||null;this.router.choose(p);save(identity+'-controller-choice',this.router.preferredId);this.activeKey='';this.lastStart=false;}; for(let i=0;i<3;i++)(document.getElementById('map'+i) as HTMLSelectElement).onchange=(e)=>{ const n=Number((e.target as HTMLSelectElement).value);if(!this.current||!Number.isInteger(n)||n<0||n>=this.current.buttons.length)return; this.mapped[i]=n;save(this.profileKey(this.current),this.mapped);this.lastStart=true; }; document.getElementById('pad-reset')!.onclick=()=>{this.mapped=defaultsFor(this.current);if(this.current)save(this.profileKey(this.current),this.mapped);this.profileButtonCount=-1;}; } private readPads():Array{try{return Array.from(navigator.getGamepads?.()||[]);}catch{return [];}} private profileKey(p:Pad){return this.identity+'-buttons-v2:'+p.id;} sample(now:number,running:boolean,paused:boolean){ const pads=this.readPads(),p=this.router.sample(pads),selected=p?key(p):'';const changed=selected!==this.activeKey; if(changed){this.activeKey=selected;this.current=p;this.lastStart=false;this.profileButtonCount=-1; const profile=p?load(this.profileKey(p)):null,legacy=load(this.identity+'-mapping'); const fits=(v:unknown):v is Buttons=>validButtons(v)&&!!p&&v.every(n=>n=80){this.lastRender=now;this.render(pads,p,running,paused);} return this.state; } private render(pads:Array,p:Pad|null,running:boolean,paused:boolean){ const live=pads.filter((x):x is Gamepad=>!!x?.connected),select=document.getElementById('pad-device') as HTMLSelectElement; const signature=live.map(x=>key(x)).join('|')+'|'+this.router.preferredId; if(signature!==this.listSignature){this.listSignature=signature;select.replaceChildren();const auto=new Option('Automatic โ€” press a controller button','auto');select.add(auto); for(const pad of live)select.add(new Option(`${pad.id} (device ${pad.index+1})`,String(pad.index))); if(this.router.preferredId&&!p){const absent=new Option('Selected controller disconnected','missing');absent.disabled=true;select.add(absent);} select.value=this.router.preferredId?(p?String(p.index):'missing'):'auto'; } const state=p?`Using: ${p.id}`:this.router.preferredId?'Selected controller is disconnected. Reconnect it or choose another controller.':live.length?'Choose your controller above, or press one of its buttons.':'Connect a controller, then press a button.'; text('controller',state);text('pad-delivery',!running?'Click Start Lanternbound once to begin.':paused?'Game paused โ€” click Resume or press controller Start while this page is active.':p?'Game running โ€” controller input is being sent to the game.':'Choose your controller to send its input to the game.'); if(p&&this.profileButtonCount!==p.buttons.length){this.profileButtonCount=p.buttons.length; for(let i=0;i<3;i++){const s=document.getElementById('map'+i) as HTMLSelectElement;s.replaceChildren();for(let n=0;ndown(p,i)?i+1:0).filter(Boolean).join(', ')||'none'}. Sticks/axes: ${p.axes.map((v,i)=>`${i+1}: ${Number(v).toFixed(2)}`).join(' ยท ')||'none'}.`:'Choose a controller to test its buttons.'); } }