Snapmove

@rebind / snapmovev0.1.4Public

Last-key-wins movement resolution for WASD.

Files

main.luau8.8 KB

-- snapmove — SOCD "snap tap" for instantaneous directional changes in FPS games.
-- When both opposing keys are held (A+D or W+S), the most recently pressed
-- direction wins. The losing key is briefly overlapped so the game registers
-- the transition before it's released.

local GAMES = require("lib/games")

local cfg = UI.Schema({
  enabled = UI.Toggle(true, { label = "Enable Snapmove" }),
  toggle_key = UI.Keybind("F11", { label = "Toggle Key" }),

  -- Game Detection
  auto_detect = UI.Toggle(true, {
    label = "Auto-detect games",
    group = "Game Detection",
    tooltip = "Active in known FPS games (CS2, VALORANT, Apex, CoD, etc.)",
  }),
  targets = UI.Text("", {
    label = "Window/Process Targets",
    group = "Game Detection",
    placeholder = "comma-separated, e.g. cs2, valorant, r5apex",
    tooltip = "Optional extra targets. Leave empty if auto-detect covers your games. Matches against window title and process name (case-insensitive).",
  }),

  -- Behavior
  humanize = UI.Toggle(true, {
    label = "Humanize overlap",
    group = "Behavior",
    tooltip = "Randomize each overlap duration between the min and max for natural-feeling timing",
  }),
  overlap_min = UI.Slider(15, {
    min = 0,
    max = 55,
    suffix = "ms",
    group = "Behavior",
    label = "Overlap Min",
    showIf = "humanize",
    tooltip = "Minimum overlap when humanize is on",
  }),
  overlap_max = UI.Slider(25, {
    min = 0,
    max = 60,
    suffix = "ms",
    group = "Behavior",
    label = "Overlap Max",
    tooltip = "Maximum time both opposing keys stay held together before the loser releases",
  }),

  -- Diagnostics
  debug_global = UI.Toggle(false, {
    label = "Debug: run globally",
    group = "Diagnostics",
    tooltip = "Ignore game detection and run snapmove in every application",
  }),
  debug_log = UI.Toggle(false, {
    label = "Debug logging",
    group = "Diagnostics",
    tooltip = "Log every key state change to the Rebind console",
  }),
})

-- ── Targeting ────────────────────────────────────────────────────────────────────

local manualList = {}
local manualStr = ""

local function matchTarget()
  if cfg.debug_global then
    return true
  end

  -- auto-detect: check hardcoded game list
  if cfg.auto_detect then
    local win = System.Window()
    local title = win.title:lower()
    local proc = win.process:lower()
    for _, t in ipairs(GAMES) do
      if title:find(t, 1, true) or proc:find(t, 1, true) then
        return true
      end
    end
  end

  -- manual targets
  local raw = cfg.targets
  if raw == "" then
    -- no manual targets: if auto-detect is off, run globally;
    -- if auto-detect is on, only the game list matters (already checked above)
    return not cfg.auto_detect
  end

  if raw ~= manualStr then
    manualList = {}
    for part in raw:gmatch("[^,]+") do
      local t = part:match("^%s*(.-)%s*$")
      if #t > 0 then
        manualList[#manualList + 1] = t:lower()
      end
    end
    manualStr = raw
  end

  if #manualList == 0 then
    return not cfg.auto_detect
  end

  local win = System.Window()
  local title = win.title:lower()
  local proc = win.process:lower()
  for _, t in ipairs(manualList) do
    if title:find(t, 1, true) or proc:find(t, 1, true) then
      return true
    end
  end
  return false
end

local KEYS = { "W", "A", "S", "D" }
local OPPOSITE = { W = "S", S = "W", A = "D", D = "A" }
local AXIS_NAME =
  { A = "horizontal", D = "horizontal", W = "vertical", S = "vertical" }

-- ── State ─────────────────────────────────────────────────────────────────────────

local held = {} -- physically held (key → bool)
local virt = {} -- what the PC sees (key → bool)
local lastH = nil -- last pressed on horizontal axis ("A" or "D")
local lastV = nil -- last pressed on vertical axis ("W" or "S")
local timers = {} -- pending overlap-release timers (key → handle)

-- ── Debug logging ─────────────────────────────────────────────────────────────────

local function dbg(msg)
  if cfg.debug_log then
    Log.Info("[snapmove] " .. msg)
  end
end

-- ── Helpers ───────────────────────────────────────────────────────────────────────

local function setVirt(key, down)
  if virt[key] == down then
    return
  end
  if down then
    HID.Down(key)
    dbg("HID.Down  " .. key)
  else
    HID.Up(key)
    dbg("HID.Up    " .. key)
  end
  virt[key] = down
end

local function resolveAxis(a, b, lastPressed)
  -- Cancel any pending overlap timer for this axis
  for _, k in ipairs({ a, b }) do
    if timers[k] then
      timers[k]:Cancel()
      timers[k] = nil
    end
  end

  local aHeld = held[a]
  local bHeld = held[b]

  if aHeld and bHeld then
    -- Both held: last-pressed wins, loser releases after overlap
    local winner = lastPressed or a
    local loser = (winner == a) and b or a
    setVirt(winner, true)
    setVirt(loser, true)

    local overlapMs
    if cfg.humanize then
      overlapMs = Math.Random(cfg.overlap_min, cfg.overlap_max)
    else
      overlapMs = cfg.overlap_max
    end

    dbg(
      "SOCD "
        .. AXIS_NAME[winner]
        .. "  winner="
        .. winner
        .. " loser="
        .. loser
        .. " overlap="
        .. overlapMs
        .. "ms"
    )

    if overlapMs > 0 then
      timers[a] = Timer.After(overlapMs, function()
        if held[winner] and held[loser] then
          setVirt(loser, false)
          dbg("SOCD " .. AXIS_NAME[winner] .. " overlap end released " .. loser)
        end
      end)
    else
      setVirt(loser, false)
    end
  else
    setVirt(a, aHeld or false)
    setVirt(b, bHeld or false)
  end
end

-- Only the key's own axis: re-resolving a settled axis would press its loser
-- again for another overlap.
local function resolve(key)
  if AXIS_NAME[key] == "horizontal" then
    resolveAxis("A", "D", lastH)
  else
    resolveAxis("W", "S", lastV)
  end
end

local function releaseAll()
  for _, t in pairs(timers) do
    if t then
      t:Cancel()
    end
  end
  timers = {}
  for _, key in ipairs(KEYS) do
    setVirt(key, false)
  end
  held = {}
  lastH, lastV = nil, nil
  dbg("all keys released")
end

-- ── Lifecycle ─────────────────────────────────────────────────────────────────────

function OnStop()
  releaseAll()
end

function OnBlur()
  releaseAll()
end

-- ── OnTick: catch releases the engine might miss ──────────────────────────────────
-- When OnDown swallows a WASD key, OnUp is not guaranteed. Poll instead.

function OnTick()
  if not cfg.enabled then
    return
  end
  if not matchTarget() then
    return
  end

  -- collect every caught release first, so an axis that lost both keys is
  -- resolved once with both gone
  local caught = {}
  for _, key in ipairs(KEYS) do
    if held[key] and not Input.IsDown(key) then
      held[key] = nil
      dbg("OnTick caught release of " .. key)
      caught[AXIS_NAME[key]] = key
    end
  end
  for _, key in pairs(caught) do
    resolve(key)
  end
end

-- ── Input hooks ───────────────────────────────────────────────────────────────────

function OnDown(key)
  if key == cfg.toggle_key then
    cfg.enabled = not cfg.enabled
    if not cfg.enabled then
      releaseAll()
    end
    UI.Notify(cfg.enabled and "Snapmove ON" or "Snapmove OFF", "info")
    dbg("toggled  " .. (cfg.enabled and "ON" or "OFF"))
    return false
  end

  if not cfg.enabled then
    return true
  end
  if not matchTarget() then
    return true
  end
  if not OPPOSITE[key] then
    return true
  end

  held[key] = true

  if key == "A" or key == "D" then
    lastH = key
  else
    lastV = key
  end

  dbg("press    " .. key .. "  held=" .. #Input.GetActiveKeys())
  resolve(key)
  return false
end

function OnUp(key)
  if not cfg.enabled then
    return true
  end
  if not matchTarget() then
    return true
  end
  if not OPPOSITE[key] then
    return true
  end

  held[key] = nil
  dbg("release  " .. key)
  resolve(key)
  return false
end

4 files, 12.4 KB.