Controller Map

@itzchillv1.0.1

A live controller tester that shows buttons, axes, and Rebind input names.

Files

Older release. Installs get 1.0.1.

main.luau

--[[
  rebind: min_sdk=3.12.0
  rebind: permissions=none
  rebind: tick_rate=30
  rebind: memory_limit_mb=16
]]

local COLORS = {
  panel = "#11100FF5",
  key = "#1A1917",
  key_active = "#8FB7FF20",
  border = "#2E2A26",
  border_active = "#8FB7FF90",
  text = "#F4F1E8",
  soft = "#D8DDD3",
  muted = "#A39C94",
  accent = "#8FB7FF",
  line = "#332D28",
}

local BUTTONS = {
  { id = "PadA", label = "A" },
  { id = "PadB", label = "B" },
  { id = "PadX", label = "X" },
  { id = "PadY", label = "Y" },
  { id = "PadLB", label = "LB" },
  { id = "PadRB", label = "RB" },
  { id = "PadLT", label = "LT" },
  { id = "PadRT", label = "RT" },
  { id = "PadView", label = "VIEW" },
  { id = "PadMenu", label = "MENU" },
  { id = "PadLS", label = "LS" },
  { id = "PadRS", label = "RS" },
  { id = "PadUp", label = "UP" },
  { id = "PadDown", label = "DOWN" },
  { id = "PadLeft", label = "LEFT" },
  { id = "PadRight", label = "RIGHT" },
}

local AXES = {
  { id = "LeftX", label = "LX", signed = true },
  { id = "LeftY", label = "LY", signed = true },
  { id = "RightX", label = "RX", signed = true },
  { id = "RightY", label = "RY", signed = true },
  { id = "LT", label = "LT", signed = false },
  { id = "RT", label = "RT", signed = false },
}

local cfg = rb.ui.schema({
  toggle_key = rb.ui.keybind("Ctrl+Alt+G", {
    label = "Show / hide controller",
    group = "Hotkeys",
  }),
  show_on_start = rb.ui.toggle(false, {
    label = "Show on start",
    group = "Behavior",
  }),
  show_buttons = rb.ui.toggle(true, {
    label = "Show buttons",
    group = "Display",
  }),
  show_axes = rb.ui.toggle(true, {
    label = "Show axes",
    group = "Display",
  }),
  anchor = rb.ui.select("bottom-right", {
    "top-left",
    "top-right",
    "bottom-left",
    "bottom-right",
  }, {
    label = "Position",
    group = "Display",
  }),
})

local state = {
  visible = cfg.show_on_start ~= false,
  surface = nil,
  signature = nil,
}

local function add_rect(ops, x, y, width, height, fill, radius, border, border_width)
  local op = {
    kind = "rect",
    x = x,
    y = y,
    w = width,
    h = height,
    fill = fill,
    radius = radius or 0,
  }
  if border then
    op.border = border
    op.border_width = border_width or 1
  end
  ops[#ops + 1] = op
end

local function add_text(ops, x, y, size, value, color, bold)
  ops[#ops + 1] = {
    kind = "text",
    x = x,
    y = y,
    size = size,
    text = value,
    color = color,
    bold = bold == true,
  }
end

local function text_width(value, size, bold)
  local ok, width = pcall(rb.overlay.measure, value, size, bold)
  if ok and width then
    return width
  end
  return #value * size * 0.62
end

local function controller_device()
  local ok, devices = pcall(rb.input.devices)
  if not ok or type(devices) ~= "table" then
    return nil
  end
  for _, device in ipairs(devices) do
    if device.kind == "gamepad" or device.kind == "joystick" then
      return device
    end
  end
  return nil
end

local function is_down(id)
  local ok, value = pcall(rb.input.is_down, id)
  return ok and value == true
end

local function axis_value(id)
  local ok, value = pcall(rb.input.axis, id)
  if ok and type(value) == "number" then
    return value
  end
  return 0
end

local function remove_surface()
  if state.surface then
    pcall(function()
      state.surface:remove()
    end)
    state.surface = nil
  end
end

local function device_label(device)
  if not device then
    return "NO CONTROLLER DETECTED"
  end
  local name = device.nickname or device.name or "Controller"
  return string.upper(tostring(name))
end

local function draw()
  if not state.visible then
    remove_surface()
    return
  end

  local device = controller_device()
  local button_states = {}
  local axis_states = {}
  local signature_parts = {
    tostring(cfg.anchor),
    tostring(cfg.show_buttons),
    tostring(cfg.show_axes),
    device_label(device),
  }

  for _, button in ipairs(BUTTONS) do
    button_states[button.id] = is_down(button.id)
    signature_parts[#signature_parts + 1] = button.id .. "=" .. tostring(button_states[button.id])
  end
  for _, axis in ipairs(AXES) do
    local value = axis_value(axis.id)
    axis_states[axis.id] = value
    signature_parts[#signature_parts + 1] = axis.id .. "=" .. string.format("%.2f", value)
  end

  local signature = table.concat(signature_parts, "|")
  if signature == state.signature then
    return
  end
  state.signature = signature

  local width = 560
  local show_buttons = cfg.show_buttons ~= false
  local show_axes = cfg.show_axes ~= false
  local height = 78
  if show_buttons then
    height = height + 30 + 4 * 36
  end
  if show_axes then
    height = height + 30 + #AXES * 28
  end
  height = height + 16

  local ops = {}
  add_rect(ops, 0, 0, width, height, COLORS.panel, 16, COLORS.line, 1)
  add_rect(ops, 22, 22, 3, 30, COLORS.accent, 1)
  add_text(ops, 36, 18, 18, "CONTROLLER", COLORS.text, true)
  add_text(ops, 36, 43, 10, device_label(device), COLORS.muted)
  add_text(ops, width - 24 - text_width("Ctrl+Alt+G", 9, false), 22, 9, "Ctrl+Alt+G", COLORS.muted)

  local y = 78
  if show_buttons then
    add_text(ops, 22, y, 9, "BUTTONS", COLORS.muted, true)
    y = y + 30
    for index, button in ipairs(BUTTONS) do
      local column = (index - 1) % 4
      local row = (index - 1) // 4
      local x = 16 + column * 132
      local row_y = y + row * 36
      local pressed = button_states[button.id] == true
      add_rect(
        ops,
        x,
        row_y,
        124,
        28,
        pressed and COLORS.key_active or COLORS.key,
        7,
        pressed and COLORS.border_active or COLORS.border,
        1
      )
      add_text(ops, x + 10, row_y + 8, 10, button.label, pressed and COLORS.text or COLORS.soft, true)
      add_text(
        ops,
        x + 124 - 10 - text_width(button.id, 8, false),
        row_y + 9,
        8,
        button.id,
        COLORS.muted
      )
    end
    y = y + 4 * 36
  end

  if show_axes then
    add_text(ops, 22, y, 9, "AXES", COLORS.muted, true)
    y = y + 30
    for _, axis in ipairs(AXES) do
      local value = axis_states[axis.id] or 0
      local bar_x = 70
      local bar_width = 360
      local bar_y = y + 7
      add_text(ops, 22, y + 6, 9, axis.label, COLORS.soft, true)
      add_rect(ops, bar_x, bar_y, bar_width, 7, "#252A2E", 3)
      if axis.signed then
        local center = bar_x + bar_width / 2
        local amount = math.min(1, math.abs(value)) * (bar_width / 2)
        if value >= 0 then
          add_rect(ops, center, bar_y, math.max(1, amount), 7, COLORS.accent, 3)
        else
          add_rect(ops, center - amount, bar_y, math.max(1, amount), 7, COLORS.accent, 3)
        end
      elseif value > 0 then
        add_rect(ops, bar_x, bar_y, math.max(1, bar_width * math.min(1, value)), 7, COLORS.accent, 3)
      end
      add_text(
        ops,
        bar_x + bar_width + 18,
        y + 5,
        9,
        string.format("%.2f", value),
        COLORS.muted
      )
      y = y + 28
    end
  end

  if not device then
    add_text(ops, 22, height - 22, 9, "Connect a controller in hardware mode to see live input.", COLORS.muted)
  end

  if state.surface then
    local ok = pcall(function()
      state.surface:set({
        width = width,
        height = height,
        ops = ops,
      })
    end)
    if not ok then
      remove_surface()
    end
  end

  if not state.surface then
    local ok, surface = pcall(rb.overlay.surface, {
      anchor = cfg.anchor,
      width = width,
      height = height,
      ops = ops,
    })
    if ok then
      state.surface = surface
    else
      rb.log.warn("Controller Map: could not create surface: " .. tostring(surface))
    end
  end
end

local function toggle()
  state.visible = not state.visible
  state.signature = nil
  if state.visible then
    draw()
  else
    remove_surface()
  end
end

local function tick()
  if state.visible then
    draw()
  end
end

rb.bind(cfg.toggle_key, toggle)

function OnStart()
  state.visible = cfg.show_on_start ~= false
  state.signature = nil
  draw()
end

function OnTick()
  tick()
end

function OnStop()
  remove_surface()
end