/ cli / src / net / input_capture.lua
input_capture.lua
  1  -- InputCapture
  2  --
  3  -- Captures local input into a compact input table for NetClient + local prediction.
  4  --
  5  -- No hard dependency on Love2D globals for tests.
  6  -- If love.keyboard.isDown exists, it will be used as a truth source.
  7  -- Otherwise, uses keypressed/keyreleased tracking.
  8  --
  9  -- API:
 10  --   InputCapture.new()
 11  --   :update(dt)
 12  --   :on_keypressed(key)
 13  --   :on_keyreleased(key)
 14  --   :current_input() -> {
 15  --        move_x, move_y, aim_delta, shooting, weapon_slot, sprinting, dash, harvest, place_mine,
 16  --        tower_toggle, shop_toggle, ui_up, ui_down, ui_confirm, weapon_prev, weapon_next,
 17  --        debug_toggle, enemies_toggle, music_prev, music_next, mute_toggle
 18  --      }
 19  --
 20  -- Controls:
 21  -- - WASD move
 22  -- - I/O aim
 23  -- - SPACE shoot (hold)
 24  -- - LSHIFT sprint (hold)
 25  -- - LCTRL dash (tap)
 26  -- - 0..8 weapon slot
 27  -- - E harvest (hold)
 28  -- - F place mine (tap)
 29  -- - G toggle tower (tap)
 30  
 31  local InputCapture = {}
 32  InputCapture.__index = InputCapture
 33  
 34  local function has_love_isdown()
 35    return love and love.keyboard and love.keyboard.isDown
 36  end
 37  
 38  local function norm_key(k)
 39    return tostring(k or ""):lower()
 40  end
 41  
 42  local function get_primary_pad()
 43    if not (love and love.joystick) then
 44      return nil, false
 45    end
 46    if love.joystick.getGamepads then
 47      local pads = love.joystick.getGamepads()
 48      if pads and #pads > 0 then
 49        return pads[1], true
 50      end
 51    end
 52    if love.joystick.getJoysticks then
 53      local joys = love.joystick.getJoysticks()
 54      if joys and #joys > 0 then
 55        local j = joys[1]
 56        local mapped = (j.isGamepad and j:isGamepad()) or false
 57        return j, mapped
 58      end
 59    end
 60    return nil, false
 61  end
 62  
 63  local function axis_value(pad, mapped, name, idx)
 64    if mapped and pad.getGamepadAxis then
 65      return pad:getGamepadAxis(name) or 0
 66    end
 67    if pad.getAxis then
 68      return pad:getAxis(idx) or 0
 69    end
 70    return 0
 71  end
 72  
 73  local function axis_dir(v, dz)
 74    if not v then return 0 end
 75    if v > dz then return 1 end
 76    if v < -dz then return -1 end
 77    return 0
 78  end
 79  
 80  function InputCapture.new()
 81    return setmetatable({
 82      keys = {},
 83      weapon_slot = 1,
 84      deadzone = 0.25,
 85      trigger_threshold = 0.35,
 86      _aim_delta = 0,
 87      _move_x = 0,
 88      _move_y = 0,
 89      _shooting = false,
 90      _sprinting = false,
 91      _dash = false,
 92      _dash_prev = false,
 93      _harvest = false,
 94      _place_mine = false,
 95      _place_mine_prev = false,
 96      _tower_toggle = false,
 97      _tower_prev = false,
 98      _shop_toggle = false,
 99      _shop_prev = false,
100      _ui_up = false,
101      _ui_up_prev = false,
102      _ui_down = false,
103      _ui_down_prev = false,
104      _ui_confirm = false,
105      _ui_confirm_prev = false,
106      _weapon_prev = false,
107      _weapon_prev_prev = false,
108      _weapon_next = false,
109      _weapon_next_prev = false,
110      _debug_toggle = false,
111      _debug_prev = false,
112      _enemies_toggle = false,
113      _enemies_prev = false,
114      _music_prev = false,
115      _music_prev_prev = false,
116      _music_next = false,
117      _music_next_prev = false,
118      _mute_toggle = false,
119      _mute_prev = false,
120    }, InputCapture)
121  end
122  
123  function InputCapture:on_keypressed(key)
124    key = norm_key(key)
125    self.keys[key] = true
126  
127    local n = tonumber(key)
128    if n and n >= 0 and n <= 8 then
129      self.weapon_slot = math.floor(n)
130    end
131  end
132  
133  function InputCapture:on_keyreleased(key)
134    key = norm_key(key)
135    self.keys[key] = false
136  end
137  
138  function InputCapture:_down(key)
139    key = norm_key(key)
140    if has_love_isdown() then
141      return love.keyboard.isDown(key)
142    end
143    return self.keys[key] == true
144  end
145  
146  function InputCapture:update(_dt)
147    local gp, mapped = get_primary_pad()
148    local gmx, gmy, gaim = 0, 0, 0
149    local shoot_gp = false
150    local sprint_gp = false
151    local dash_gp = false
152    local harvest_gp = false
153    local place_gp = false
154    local tower_gp = false
155    local shop_gp = false
156    local ui_up_gp = false
157    local ui_down_gp = false
158    local ui_confirm_gp = false
159    local weapon_prev_gp = false
160    local weapon_next_gp = false
161    local debug_gp = false
162    local enemies_gp = false
163    local music_prev_gp = false
164    local music_next_gp = false
165    local mute_gp = false
166  
167    if gp then
168      local dz = self.deadzone or 0.25
169      local lx = axis_value(gp, mapped, "leftx", 1)
170      local ly = axis_value(gp, mapped, "lefty", 2)
171      local rx = axis_value(gp, mapped, "rightx", 3)
172      gmx = axis_dir(lx, dz)
173      gmy = axis_dir(ly, dz)
174      gaim = -axis_dir(rx, dz)
175  
176      if mapped and gp.isGamepadDown then
177        local dpad_left = gp:isGamepadDown("dpleft")
178        local dpad_right = gp:isGamepadDown("dpright")
179        local dpad_up = gp:isGamepadDown("dpup")
180        local dpad_down = gp:isGamepadDown("dpdown")
181        if dpad_left then gmx = -1 end
182        if dpad_right then gmx = 1 end
183        if dpad_up then gmy = -1 end
184        if dpad_down then gmy = 1 end
185  
186        local a_down = gp:isGamepadDown("a")
187        local back_down = gp:isGamepadDown("back")
188        local guide_down = gp:isGamepadDown("guide")
189        local l3_down = gp:isGamepadDown("leftstick")
190        local r3_down = gp:isGamepadDown("rightstick")
191        local combo = back_down and (dpad_left or dpad_right or dpad_up or dpad_down)
192  
193        ui_up_gp = dpad_up and not back_down
194        ui_down_gp = dpad_down and not back_down
195        ui_confirm_gp = a_down
196        shop_gp = back_down and not combo
197        weapon_prev_gp = l3_down
198        weapon_next_gp = r3_down
199        debug_gp = back_down and dpad_up
200        enemies_gp = back_down and dpad_down
201        music_prev_gp = back_down and dpad_left
202        music_next_gp = back_down and dpad_right
203        mute_gp = guide_down
204  
205        local tr = gp.getGamepadAxis and gp:getGamepadAxis("triggerright") or 0
206        shoot_gp = a_down or gp:isGamepadDown("rightshoulder") or tr > (self.trigger_threshold or 0.35)
207        harvest_gp = gp:isGamepadDown("x")
208        dash_gp = gp:isGamepadDown("b")
209        place_gp = gp:isGamepadDown("y")
210        tower_gp = gp:isGamepadDown("start")
211  
212        local tl = gp.getGamepadAxis and gp:getGamepadAxis("triggerleft") or 0
213        sprint_gp = gp:isGamepadDown("leftshoulder") or tl > (self.trigger_threshold or 0.35)
214      elseif gp.isDown then
215        local tr = 0
216        if gp.getAxis then
217          local a5 = gp:getAxis(5) or 0
218          local a6 = gp:getAxis(6) or 0
219          tr = math.max(a5, a6)
220        end
221        shoot_gp = gp:isDown(1) or gp:isDown(2) or gp:isDown(3) or tr > (self.trigger_threshold or 0.35)
222        harvest_gp = gp:isDown(4)
223  
224        local tl = 0
225        if gp.getAxis then
226          local a3 = gp:getAxis(3) or 0
227          local a4 = gp:getAxis(4) or 0
228          tl = math.max(a3, a4)
229        end
230        sprint_gp = (gp.isDown and gp:isDown(5)) or tl > (self.trigger_threshold or 0.35)
231      end
232    end
233  
234    local ax = 0
235    if self:_down("i") then ax = ax + 1 end
236    if self:_down("o") then ax = ax - 1 end
237    if ax == 0 then ax = gaim end
238    self._aim_delta = ax
239  
240    local mx = 0
241    local my = 0
242    if self:_down("a") then mx = mx - 1 end
243    if self:_down("d") then mx = mx + 1 end
244    if self:_down("w") then my = my - 1 end
245    if self:_down("s") then my = my + 1 end
246    if gmx ~= 0 then mx = gmx end
247    if gmy ~= 0 then my = gmy end
248    self._move_x = mx
249    self._move_y = my
250  
251    self._shooting = self:_down("space") or shoot_gp
252    self._sprinting = self:_down("lshift") or self:_down("rshift") or sprint_gp
253    local dash_down = self:_down("lctrl") or self:_down("rctrl") or dash_gp
254    self._dash = dash_down and not (self._dash_prev == true)
255    self._dash_prev = dash_down
256    self._harvest = self:_down("e") or harvest_gp
257    local place_down = self:_down("f") or place_gp
258    self._place_mine = place_down and not (self._place_mine_prev == true)
259    self._place_mine_prev = place_down
260    local tower_down = self:_down("g") or tower_gp
261    self._tower_toggle = tower_down and not (self._tower_prev == true)
262    self._tower_prev = tower_down
263  
264    local shop_down = shop_gp
265    self._shop_toggle = shop_down and not (self._shop_prev == true)
266    self._shop_prev = shop_down
267  
268    local up_down = ui_up_gp
269    self._ui_up = up_down and not (self._ui_up_prev == true)
270    self._ui_up_prev = up_down
271  
272    local down_down = ui_down_gp
273    self._ui_down = down_down and not (self._ui_down_prev == true)
274    self._ui_down_prev = down_down
275  
276    local confirm_down = ui_confirm_gp
277    self._ui_confirm = confirm_down and not (self._ui_confirm_prev == true)
278    self._ui_confirm_prev = confirm_down
279  
280    local weapon_prev_down = weapon_prev_gp
281    self._weapon_prev = weapon_prev_down and not (self._weapon_prev_prev == true)
282    self._weapon_prev_prev = weapon_prev_down
283  
284    local weapon_next_down = weapon_next_gp
285    self._weapon_next = weapon_next_down and not (self._weapon_next_prev == true)
286    self._weapon_next_prev = weapon_next_down
287  
288    local debug_down = debug_gp
289    self._debug_toggle = debug_down and not (self._debug_prev == true)
290    self._debug_prev = debug_down
291  
292    local enemies_down = enemies_gp
293    self._enemies_toggle = enemies_down and not (self._enemies_prev == true)
294    self._enemies_prev = enemies_down
295  
296    local music_prev_down = music_prev_gp
297    self._music_prev = music_prev_down and not (self._music_prev_prev == true)
298    self._music_prev_prev = music_prev_down
299  
300    local music_next_down = music_next_gp
301    self._music_next = music_next_down and not (self._music_next_prev == true)
302    self._music_next_prev = music_next_down
303  
304    local mute_down = mute_gp
305    self._mute_toggle = mute_down and not (self._mute_prev == true)
306    self._mute_prev = mute_down
307  end
308  
309  function InputCapture:current_input()
310    return {
311      move_x = self._move_x,
312      move_y = self._move_y,
313      aim_delta = self._aim_delta,
314      shooting = self._shooting,
315      weapon_slot = self.weapon_slot,
316      sprinting = self._sprinting,
317      dash = self._dash,
318      harvest = self._harvest,
319      place_mine = self._place_mine,
320      tower_toggle = self._tower_toggle,
321      shop_toggle = self._shop_toggle,
322      ui_up = self._ui_up,
323      ui_down = self._ui_down,
324      ui_confirm = self._ui_confirm,
325      weapon_prev = self._weapon_prev,
326      weapon_next = self._weapon_next,
327      debug_toggle = self._debug_toggle,
328      enemies_toggle = self._enemies_toggle,
329      music_prev = self._music_prev,
330      music_next = self._music_next,
331      mute_toggle = self._mute_toggle,
332    }
333  end
334  
335  return InputCapture