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