jevstrudel.git / packages / gamepad / gamepad.mjs
1// @strudel/gamepad/index.mjs
2
3import { signal } from '@strudel/core';
4
5// Button mapping for Logitech Dual Action (STANDARD GAMEPAD Vendor: 046d Product: c216)
6export const buttonMap = {
7  a: 0,
8  b: 1,
9  x: 2,
10  y: 3,
11  lb: 4,
12  rb: 5,
13  lt: 6,
14  rt: 7,
15  back: 8,
16  start: 9,
17  l3: 10,
18  ls: 10,
19  r3: 11,
20  rs: 11,
21  u: 12,
22  up: 12,
23  d: 13,
24  down: 13,
25  l: 14,
26  left: 14,
27  r: 15,
28  right: 15,
29};
30
31class ButtonSequenceDetector {
32  constructor(timeWindow = 1000) {
33    this.sequence = [];
34    this.timeWindow = timeWindow;
35    this.lastInputTime = 0;
36    this.buttonStates = Array(16).fill(0); // Track previous state of each button
37    // Button mapping for character inputs
38  }
39
40  addInput(buttonIndex, buttonValue) {
41    const currentTime = Date.now();
42
43    // Only add input on button press (rising edge)
44    if (buttonValue === 1 && this.buttonStates[buttonIndex] === 0) {
45      // Clear sequence if too much time has passed
46      if (currentTime - this.lastInputTime > this.timeWindow) {
47        this.sequence = [];
48      }
49
50      // Store the button name instead of index
51      const buttonName = Object.keys(buttonMap).find((key) => buttonMap[key] === buttonIndex) || buttonIndex.toString();
52
53      this.sequence.push({
54        input: buttonName,
55        timestamp: currentTime,
56      });
57
58      this.lastInputTime = currentTime;
59
60      //console.log(this.sequence);
61      // Keep only inputs within the time window
62      this.sequence = this.sequence.filter((entry) => currentTime - entry.timestamp <= this.timeWindow);
63    }
64
65    // Update button state
66    this.buttonStates[buttonIndex] = buttonValue;
67  }
68
69  checkSequence(targetSequence) {
70    if (!Array.isArray(targetSequence) && typeof targetSequence !== 'string') {
71      console.error('ButtonSequenceDetector: targetSequence must be an array or string');
72      return 0;
73    }
74
75    if (this.sequence.length < targetSequence.length) return 0;
76
77    // Convert string input to array if needed
78    const sequence =
79      typeof targetSequence === 'string'
80        ? targetSequence.toLowerCase().split('')
81        : targetSequence.map((s) => s.toString().toLowerCase());
82
83    //console.log(this.sequence);
84
85    // Get the last n inputs where n is the target sequence length
86    const lastInputs = this.sequence.slice(-targetSequence.length).map((entry) => entry.input);
87
88    // Compare sequences
89    return lastInputs.every((input, index) => {
90      const target = sequence[index];
91      // Check if either the input matches directly or they refer to the same button in the map
92      return (
93        input === target ||
94        buttonMap[input] === buttonMap[target] ||
95        // Also check if the numerical index matches
96        buttonMap[input] === parseInt(target)
97      );
98    })
99      ? 1
100      : 0;
101  }
102}
103
104class GamepadHandler {
105  constructor(index = 0) {
106    // Add index parameter
107    this._gamepads = {};
108    this._activeGamepad = index; // Use provided index
109    this._axes = [0, 0, 0, 0];
110    this._buttons = Array(16).fill(0);
111    this.setupEventListeners();
112  }
113
114  setupEventListeners() {
115    window.addEventListener('gamepadconnected', (e) => {
116      this._gamepads[e.gamepad.index] = e.gamepad;
117      if (!this._activeGamepad) {
118        this._activeGamepad = e.gamepad.index;
119      }
120    });
121
122    window.addEventListener('gamepaddisconnected', (e) => {
123      delete this._gamepads[e.gamepad.index];
124      if (this._activeGamepad === e.gamepad.index) {
125        this._activeGamepad = Object.keys(this._gamepads)[0] || null;
126      }
127    });
128  }
129
130  poll() {
131    if (this._activeGamepad !== null) {
132      const gamepad = navigator.getGamepads()[this._activeGamepad];
133      if (gamepad) {
134        // Update axes (normalized to 0-1 range)
135        this._axes = gamepad.axes.map((axis) => (axis + 1) / 2);
136        // Update buttons
137        this._buttons = gamepad.buttons.map((button) => button.value);
138      }
139    }
140  }
141
142  getAxes() {
143    return this._axes;
144  }
145  getButtons() {
146    return this._buttons;
147  }
148}
149
150// Module-level state store for toggle states
151const gamepadStates = new Map();
152
153export const gamepad = (index = 0) => {
154  const handler = new GamepadHandler(index);
155  const sequenceDetector = new ButtonSequenceDetector(2000);
156
157  // Base signal that polls gamepad state and handles sequence detection
158  const baseSignal = signal((t) => {
159    handler.poll();
160    const axes = handler.getAxes();
161    const buttons = handler.getButtons();
162
163    // Add all button inputs to sequence detector
164    buttons.forEach((value, i) => {
165      sequenceDetector.addInput(i, value);
166    });
167
168    return { axes, buttons, t };
169  });
170
171  // Create axes patterns
172  const axes = {
173    x1: baseSignal.fmap((state) => state.axes[0]),
174    y1: baseSignal.fmap((state) => state.axes[1]),
175    x2: baseSignal.fmap((state) => state.axes[2]),
176    y2: baseSignal.fmap((state) => state.axes[3]),
177  };
178
179  // Add bipolar versions
180  axes.x1_2 = axes.x1.toBipolar();
181  axes.y1_2 = axes.y1.toBipolar();
182  axes.x2_2 = axes.x2.toBipolar();
183  axes.y2_2 = axes.y2.toBipolar();
184
185  // Create button patterns
186  const buttons = Array(16)
187    .fill(null)
188    .map((_, i) => {
189      // Create unique key for this gamepad+button combination
190      const stateKey = `gamepad${index}_btn${i}`;
191
192      // Initialize toggle state if it doesn't exist
193      if (!gamepadStates.has(stateKey)) {
194        gamepadStates.set(stateKey, {
195          lastButtonState: 0,
196          toggleState: 0,
197        });
198      }
199
200      // Direct button value pattern (no longer needs to call addInput)
201      const btn = baseSignal.fmap((state) => state.buttons[i]);
202
203      // Button toggle pattern with persistent state
204      const toggle = baseSignal.fmap((state) => {
205        const currentState = state.buttons[i];
206        const buttonState = gamepadStates.get(stateKey);
207
208        if (currentState === 1 && buttonState.lastButtonState === 0) {
209          // Toggle the state on rising edge
210          buttonState.toggleState = buttonState.toggleState === 0 ? 1 : 0;
211        }
212
213        buttonState.lastButtonState = currentState;
214        return buttonState.toggleState;
215      });
216
217      return { value: btn, toggle };
218    });
219
220  // Create sequence checker pattern
221  const btnSequence = (sequence) => {
222    return baseSignal.fmap(() => sequenceDetector.checkSequence(sequence));
223  };
224  const checkSequence = btnSequence;
225  const btnSeq = btnSequence;
226  const btnseq = btnSeq;
227
228  // Return an object with all controls
229  return {
230    ...axes,
231    buttons,
232    ...Object.fromEntries(
233      Object.entries(buttonMap).flatMap(([key, index]) => [
234        [key.toLowerCase(), buttons[index].value],
235        [key.toUpperCase(), buttons[index].value],
236        [`tgl${key.toLowerCase()}`, buttons[index].toggle],
237        [`tgl${key.toUpperCase()}`, buttons[index].toggle],
238      ]),
239    ),
240    checkSequence,
241    btnSequence,
242    btnSeq,
243    btnseq,
244    raw: baseSignal,
245  };
246};
247
248// Optional: Export for debugging or state management
249export const getGamepadStates = () => Object.fromEntries(gamepadStates);
250export const clearGamepadStates = () => gamepadStates.clear();