crosshair-studio

@itzchillv1.0.0

Overlay crosshair for games and apps that have none.

Files

Version 1.0.0 (current)

main.luau

--[[
  rebind: min_sdk=3.9.0
  rebind: version=1.0.0
  rebind: permissions=none
  rebind: key_block=false
  rebind: tick_rate=60
  rebind: memory_limit_mb=32
]]

-- Crosshair Studio is a vector overlay: no game memory, no screen reading,
-- and no injection. The bloom is an input-driven approximation, not real
-- weapon spread.

local VERSION = "1.0.0"
local STATE_FILE = "crosshair_state.json"
local SURFACE_SIZE = 600
local CENTER = SURFACE_SIZE / 2
local DEFAULT_COLOR = "#a5e63c"
local DEFAULT_OUTLINE = "#000000"

local PROFILE_KEYS = {
  "shape",
  "color",
  "outline_color",
  "outline",
  "glow",
  "gap",
  "length",
  "thickness",
  "dot",
  "dot_shape",
  "opacity",
  "offset_x",
  "offset_y",
  "activation",
  "ads",
  "bloom",
  "move_bloom",
  "fire_bloom",
  "smooth",
}

local DEFAULTS = {
  shape = "plus",
  color = DEFAULT_COLOR,
  outline_color = DEFAULT_OUTLINE,
  outline = 1,
  glow = 0,
  gap = 8,
  length = 14,
  thickness = 2,
  dot = 2,
  dot_shape = "circle",
  opacity = 0.95,
  offset_x = 0,
  offset_y = 0,
  activation = "toggle",
  ads = "show",
  bloom = true,
  move_bloom = 7,
  fire_bloom = 5,
  smooth = 14,
}

local PRESETS = {
  plus = { shape = "plus", gap = 8, length = 14, thickness = 2, dot = 2, dot_shape = "circle" },
  dot = { shape = "dot", gap = 0, length = 0, thickness = 2, dot = 5, dot_shape = "circle" },
  t = { shape = "t", gap = 8, length = 12, thickness = 2, dot = 2, dot_shape = "circle" },
  circle = { shape = "circle", gap = 8, length = 10, thickness = 2, dot = 0, dot_shape = "circle" },
  x = { shape = "x", gap = 6, length = 16, thickness = 2, dot = 0, dot_shape = "circle" },
}

local PRESET_ORDER = { "plus", "dot", "t", "circle", "x" }

local function normalize_shape(value)
  local name = tostring(value or "plus")
  for _, allowed in ipairs(PRESET_ORDER) do
    if name == allowed then
      return name
    end
  end
  return "plus"
end

local cfg = rb.ui.schema({
  shape = rb.ui.select("plus", PRESET_ORDER, { label = "Shape" }),
  color = rb.ui.color(DEFAULT_COLOR, { label = "Crosshair color" }),
  outline_color = rb.ui.color(DEFAULT_OUTLINE, { label = "Outline color" }),
  outline = rb.ui.slider(1, { min = 0, max = 6, step = 1, label = "Outline width" }),
  glow = rb.ui.slider(0, { min = 0, max = 20, step = 1, label = "Glow" }),
  gap = rb.ui.slider(8, { min = 0, max = 50, step = 1, label = "Center gap" }),
  length = rb.ui.slider(14, { min = 0, max = 40, step = 1, label = "Arm length" }),
  thickness = rb.ui.slider(2, { min = 1, max = 10, step = 1, label = "Thickness" }),
  dot = rb.ui.slider(2, { min = 0, max = 12, step = 1, label = "Dot size" }),
  dot_shape = rb.ui.select("circle", { "circle", "square" }, { label = "Dot shape" }),
  opacity = rb.ui.slider(0.95, { min = 0, max = 1, step = 0.05, label = "Opacity (0 = transparent)" }),
  offset_x = rb.ui.slider(0, { min = -120, max = 120, step = 1, label = "Offset X" }),
  offset_y = rb.ui.slider(0, { min = -120, max = 120, step = 1, label = "Offset Y" }),
  activation = rb.ui.select("toggle", { "toggle", "hold" }, { label = "Activation" }),
  start_visible = rb.ui.toggle(true, { label = "Show on start" }),
  ads = rb.ui.select("show", { "show", "compact", "hide" }, { label = "While ADS (right mouse)" }),
  bloom = rb.ui.toggle(true, { label = "Input bloom (cosmetic)" }),
  move_bloom = rb.ui.slider(7, { min = 0, max = 40, step = 1, label = "Move bloom" }),
  fire_bloom = rb.ui.slider(5, { min = 0, max = 40, step = 1, label = "Fire bloom" }),
  smooth = rb.ui.slider(14, { min = 1, max = 30, step = 1, label = "Bloom speed" }),
  toggle_key = rb.ui.keybind("F12", { label = "Toggle / editor key" }),
})

local state = {
  surface = nil,
  menu = nil,
  visible = false,
  saved_visible = nil,
  gap = 0,
  keys = {},
  profiles = {},
  last_profile = 0,
  dirty = true,
  signature = nil,
  last_render_gap = -1,
  last_ads = false,
  warned_invisible = false,
  logged_surface = false,
}

local function n(value, fallback)
  local number = tonumber(value)
  if number == nil then
    return fallback
  end
  return number
end

local function clamp(value, low, high)
  return math.max(low, math.min(high, value))
end

local function norm_key(key)
  local text = tostring(key or "")
  if #text == 1 then
    return text:upper()
  end
  return text
end

local function with_alpha(hex, alpha)
  local text = tostring(hex or "#ffffff"):gsub("#", "")
  local source_alpha = 255
  if #text == 8 then
    source_alpha = tonumber(text:sub(7, 8), 16) or 255
    text = text:sub(1, 6)
  end
  if #text ~= 6 or text:find("%X") then
    text = "ffffff"
    source_alpha = 255
  end
  local value = math.floor(clamp(alpha or 1, 0, 1) * source_alpha)
  return "#" .. text .. string.format("%02X", value)
end

local function add_rect(ops, x, y, w, h, fill, radius)
  w = math.floor(w)
  h = math.floor(h)
  if w < 1 or h < 1 then
    return
  end
  ops[#ops + 1] = {
    kind = "rect",
    x = math.floor(x),
    y = math.floor(y),
    w = w,
    h = h,
    fill = fill,
    radius = radius or 0,
  }
end

local function add_ring(ops, cx, cy, radius, thickness, color)
  radius = math.max(1, radius)
  ops[#ops + 1] = {
    kind = "rect",
    x = cx - radius,
    y = cy - radius,
    w = radius * 2,
    h = radius * 2,
    fill = "#00000000",
    border = color,
    border_width = math.max(1, thickness),
    radius = radius,
  }
end

local function add_arm(ops, cx, cy, direction, gap, length, thickness, color, expand)
  local extra = expand or 0
  local size = thickness + extra * 2
  local arm = length + extra * 2
  if direction == "up" then
    add_rect(ops, cx - size / 2, cy - gap - arm, size, arm, color, 1)
  elseif direction == "down" then
    add_rect(ops, cx - size / 2, cy + gap, size, arm, color, 1)
  elseif direction == "left" then
    add_rect(ops, cx - gap - arm, cy - size / 2, arm, size, color, 1)
  elseif direction == "right" then
    add_rect(ops, cx + gap, cy - size / 2, arm, size, color, 1)
  end
end

local function add_dot(ops, cx, cy, size, shape, color, expand)
  local extra = expand or 0
  local dot_size = size + extra * 2
  if dot_size <= 0 then
    return
  end
  local radius = 0
  if shape == "circle" then
    radius = math.floor(dot_size / 2)
  end
  add_rect(ops, cx - dot_size / 2, cy - dot_size / 2, dot_size, dot_size, color, radius)
end

local function add_shape(ops, shape, cx, cy, gap, length, thickness, dot, dot_shape, color, expand)
  local extra = expand or 0
  if shape == "plus" then
    add_arm(ops, cx, cy, "up", gap, length, thickness, color, extra)
    add_arm(ops, cx, cy, "down", gap, length, thickness, color, extra)
    add_arm(ops, cx, cy, "left", gap, length, thickness, color, extra)
    add_arm(ops, cx, cy, "right", gap, length, thickness, color, extra)
  elseif shape == "t" then
    add_arm(ops, cx, cy, "up", gap, length, thickness, color, extra)
    add_arm(ops, cx, cy, "left", gap, length, thickness, color, extra)
    add_arm(ops, cx, cy, "right", gap, length, thickness, color, extra)
  elseif shape == "circle" then
    -- A square with radius = half its width is a true circle. Drawing it as a
    -- transparent-filled, bordered rounded rect gives a clean vector ring.
    local radius = math.max(1, gap + length + extra)
    add_ring(ops, cx, cy, radius, thickness + extra * 2, color)
  elseif shape == "x" then
    -- One-pixel diagonal stepping keeps the arms joined; square joins avoid
    -- the beaded look produced by rounded, spaced blocks.
    local size = thickness + extra * 2
    local step = math.max(1, math.ceil((length + 1) / 20))
    local block = size + step - 1
    local amount = gap
    while amount <= gap + length do
      add_rect(ops, cx + amount - block / 2, cy + amount - block / 2, block, block, color, 0)
      add_rect(ops, cx - amount - block / 2, cy + amount - block / 2, block, block, color, 0)
      add_rect(ops, cx + amount - block / 2, cy - amount - block / 2, block, block, color, 0)
      add_rect(ops, cx - amount - block / 2, cy - amount - block / 2, block, block, color, 0)
      amount = amount + step
    end
  end

  if shape == "dot" then
    add_dot(ops, cx, cy, math.max(1, dot), dot_shape, color, extra)
  elseif dot > 0 then
    add_dot(ops, cx, cy, dot, dot_shape, color, extra)
  end
end

local function settings_signature()
  local parts = {}
  for _, key in ipairs(PROFILE_KEYS) do
    parts[#parts + 1] = tostring(cfg[key])
  end
  return table.concat(parts, "|")
end

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

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

  local ads = state.keys.Mouse2 == true
  if tostring(cfg.ads) == "hide" and ads then
    remove_surface()
    return
  end

  local shape = normalize_shape(cfg.shape)
  local color = tostring(cfg.color or DEFAULT_COLOR)
  local outline_color = tostring(cfg.outline_color or DEFAULT_OUTLINE)
  local outline = math.max(0, n(cfg.outline, 1))
  local glow = math.max(0, n(cfg.glow, 0))
  local thickness = math.max(1, n(cfg.thickness, 2))
  local dot = math.max(0, n(cfg.dot, 2))
  local dot_shape = tostring(cfg.dot_shape or "circle")
  local gap = math.max(0, state.gap)
  local length = math.max(0, n(cfg.length, 14))

  if tostring(cfg.ads) == "compact" and ads then
    gap = gap * 0.7
    length = length * 0.8
  end

  local cx = CENTER + n(cfg.offset_x, 0)
  local cy = CENTER + n(cfg.offset_y, 0)
  local ops = {}

  if glow > 0 then
    add_shape(ops, shape, cx, cy, gap, length, thickness, dot, dot_shape, with_alpha(color, 0.16), outline + glow)
  end
  if outline > 0 then
    add_shape(ops, shape, cx, cy, gap, length, thickness, dot, dot_shape, outline_color, outline)
  end
  add_shape(ops, shape, cx, cy, gap, length, thickness, dot, dot_shape, color, 0)

  if #ops == 0 then
    remove_surface()
    return
  end

  local opacity = clamp(n(cfg.opacity, 0.95), 0, 1)
  if state.surface then
    local updated = pcall(function()
      state.surface:set({
        width = SURFACE_SIZE,
        height = SURFACE_SIZE,
        ops = ops,
        opacity = opacity,
      })
    end)
    if not updated then
      remove_surface()
    end
  end

  if not state.surface then
    local created = pcall(function()
      -- No background op is added: the surface stays transparent and only
      -- the reticle geometry is painted.
      state.surface = rb.overlay.surface({
        anchor = "center",
        width = SURFACE_SIZE,
        height = SURFACE_SIZE,
        opacity = opacity,
        ops = ops,
      })
    end)
    if not created then
      state.surface = nil
      rb.log.warn("Crosshair Studio: surface creation failed")
      return
    end
    if not state.logged_surface then
      state.logged_surface = true
      rb.log.info("Crosshair Studio surface ready (" .. #ops .. " ops)")
    end
  end

  state.last_render_gap = gap
  state.dirty = false
end

local function save_state()
  local payload = {
    version = 1,
    visible = state.visible,
    last_profile = state.last_profile,
    profiles = state.profiles,
  }
  local ok, err = pcall(rb.file.write_json, STATE_FILE, payload)
  if not ok then
    rb.log.warn("Crosshair Studio: state write failed: " .. tostring(err))
  end
end

local function load_state()
  if not rb.file.exists(STATE_FILE) then
    return
  end
  local ok, data = pcall(rb.file.read_json, STATE_FILE)
  if not ok or type(data) ~= "table" then
    return
  end
  if type(data.profiles) == "table" then
    state.profiles = data.profiles
  end
  state.last_profile = tonumber(data.last_profile) or 0
  if type(data.visible) == "boolean" then
    state.saved_visible = data.visible
  end
end

local function set_visible(value)
  state.visible = value and true or false
  rb.log.info("Crosshair Studio: " .. (state.visible and "visible" or "hidden"))
  save_state()
  if state.visible then
    render_crosshair()
  else
    remove_surface()
  end
end

local function toggle_visible()
  set_visible(not state.visible)
end

local function apply_settings(settings)
  for _, key in ipairs(PROFILE_KEYS) do
    if settings[key] ~= nil then
      cfg[key] = settings[key]
    end
  end
  cfg.shape = normalize_shape(cfg.shape)
  state.dirty = true
  state.signature = nil
  render_crosshair()
end

local function snapshot()
  local data = {}
  for _, key in ipairs(PROFILE_KEYS) do
    data[key] = cfg[key]
  end
  return data
end

local function profile_hint(slot)
  local profile = state.profiles[slot]
  if type(profile) ~= "table" then
    return "empty"
  end
  return string.format("%s  %s  gap:%s  len:%s",
    normalize_shape(profile.shape),
    tostring(profile.color or DEFAULT_COLOR),
    tostring(profile.gap or "?"),
    tostring(profile.length or "?"))
end

local function save_profile(slot)
  state.profiles[slot] = snapshot()
  state.last_profile = slot
  save_state()
  rb.overlay.toast("Saved to profile " .. slot, {
    title = "Crosshair Studio",
    duration = 2500,
  })
end

local function load_profile(slot)
  local profile = state.profiles[slot]
  if type(profile) ~= "table" then
    rb.overlay.toast("Profile " .. slot .. " is empty", {
      title = "Crosshair Studio",
      duration = 2500,
    })
    return
  end
  apply_settings(profile)
  state.last_profile = slot
  save_state()
  rb.overlay.toast("Loaded profile " .. slot, {
    title = "Crosshair Studio",
    duration = 2500,
  })
end

local function cycle_profile()
  local available = {}
  for slot = 1, 4 do
    if type(state.profiles[slot]) == "table" then
      available[#available + 1] = slot
    end
  end
  if #available == 0 then
    rb.overlay.toast("Save a profile first", {
      title = "Crosshair Studio",
      duration = 3000,
    })
    return
  end
  local next_slot = available[1]
  for _, slot in ipairs(available) do
    if slot > state.last_profile then
      next_slot = slot
      break
    end
  end
  load_profile(next_slot)
end

local function reset_settings()
  for _, key in ipairs(PROFILE_KEYS) do
    if DEFAULTS[key] ~= nil then
      cfg[key] = DEFAULTS[key]
    end
  end
  state.dirty = true
  state.signature = nil
  render_crosshair()
  rb.overlay.toast("Crosshair reset", { title = "Crosshair Studio", duration = 2500 })
end

local function show_help()
  rb.overlay.toast("F12 toggle  |  Ctrl+F12 editor  |  Alt+F12 profile\nAll size, color, ADS and bloom settings are in Rebind Options.", {
    title = "Crosshair Studio",
    duration = 6500,
  })
end

local function open_studio()
  if state.menu then
    pcall(function()
      state.menu:close()
    end)
    state.menu = nil
    return
  end

  if not state.visible and tostring(cfg.activation) ~= "hold" then
    set_visible(true)
  end

  local items = {}
  local actions = {}
  local function add(label, hint, action)
    items[#items + 1] = { label = label, hint = hint }
    actions[#actions + 1] = action
  end

  add("Toggle crosshair", state.visible and "on" or "off", "toggle")
  for _, name in ipairs(PRESET_ORDER) do
    add("Preset: " .. name, "apply shape", "preset:" .. name)
  end
  for slot = 1, 4 do
    add("Save to profile " .. slot, profile_hint(slot), "save:" .. slot)
  end
  for slot = 1, 4 do
    add("Load profile " .. slot, profile_hint(slot), "load:" .. slot)
  end
  add("Cycle profile", "Alt+F12", "cycle")
  add("Reset settings", "shape, size, color defaults", "reset")
  add("Help / keys", "F12, Ctrl+F12, Alt+F12", "help")

  local menu, why = rb.overlay.menu(items, {
    title = "Crosshair Studio",
    subtitle = "F12 toggle  |  Ctrl+F12 editor  |  Alt+F12 profile",
    width = 760,
    theme = "tokyo-night",
    on_select = function(index)
      local action = actions[index]
      if not action then
        return
      end
      if action == "toggle" then
        toggle_visible()
      elseif action == "cycle" then
        cycle_profile()
      elseif action == "reset" then
        reset_settings()
      elseif action == "help" then
        show_help()
      elseif action:sub(1, 7) == "preset:" then
        local name = action:sub(8)
        apply_settings(PRESETS[name] or PRESETS.plus)
        rb.overlay.toast("Preset: " .. name, {
          title = "Crosshair Studio",
          duration = 2200,
        })
      elseif action:sub(1, 5) == "save:" then
        save_profile(tonumber(action:sub(6)) or 1)
      elseif action:sub(1, 5) == "load:" then
        load_profile(tonumber(action:sub(6)) or 1)
      end
    end,
    on_close = function()
      state.menu = nil
    end,
  })
  if menu then
    state.menu = menu
  else
    rb.log.warn("Crosshair Studio menu: " .. tostring(why))
    rb.overlay.toast("Could not open editor: " .. tostring(why), {
      title = "Crosshair Studio",
      duration = 3500,
    })
  end
end

function OnStart()
  load_state()
  cfg.shape = normalize_shape(cfg.shape)
  state.keys = {}
  state.gap = math.max(0, n(cfg.gap, 8))
  if cfg.start_visible ~= false then
    state.visible = true
  elseif state.saved_visible ~= nil then
    state.visible = state.saved_visible
  else
    state.visible = false
  end
  if tostring(cfg.activation) == "hold" then
    state.visible = false
  end
  state.signature = nil
  state.dirty = true
  save_state()
  render_crosshair()

  if not rb.overlay.visible() then
    state.warned_invisible = true
    rb.overlay.toast("Exclusive fullscreen hides overlays. Use borderless.", {
      title = "Crosshair Studio",
      duration = 5000,
    })
  else
    rb.overlay.toast("F12 toggle  |  Ctrl+F12 editor", {
      title = "Crosshair Studio " .. VERSION,
      duration = 4000,
    })
  end
end

function OnStop()
  remove_surface()
  if state.menu then
    pcall(function()
      state.menu:close()
    end)
    state.menu = nil
  end
  save_state()
end

function OnTick()
  local signature = settings_signature()
  if signature ~= state.signature then
    local first_tick = state.signature == nil
    state.signature = signature
    state.dirty = true
    -- Editing an option is a preview action. Bring the reticle back if the
    -- user had toggled it off, but never override Hold mode or the initial
    -- Show-on-start choice.
    if not first_tick
      and not state.visible
      and tostring(cfg.activation) ~= "hold" then
      set_visible(true)
    end
  end

  local keys = state.keys
  local moving = keys.W or keys.A or keys.S or keys.D or keys.Space
  local firing = keys.Mouse1 == true
  local ads = keys.Mouse2 == true

  if ads ~= state.last_ads then
    state.last_ads = ads
    state.dirty = true
  end

  local target = math.max(0, n(cfg.gap, 8))
  if cfg.bloom ~= false then
    if moving then
      target = target + math.max(0, n(cfg.move_bloom, 7))
    end
    if firing then
      target = target + math.max(0, n(cfg.fire_bloom, 5))
    end
  end
  if tostring(cfg.ads) == "compact" and ads then
    target = target * 0.6
  end

  local speed = clamp(n(cfg.smooth, 14) / 60, 0.02, 1)
  state.gap = state.gap + (target - state.gap) * speed
  state.gap = clamp(state.gap, 0, 80)

  if state.visible then
    local gap_delta = math.abs(state.gap - state.last_render_gap)
    if state.dirty or gap_delta > 0.08 then
      render_crosshair()
    end
  else
    remove_surface()
  end
end

-- Observer hooks, not OnDown/OnUp: the crosshair tracks keys without ever
-- intercepting or re-emitting game input.
function OnInputDown(key)
  local normalized = norm_key(key)
  state.keys[normalized] = true

  if normalized == norm_key(cfg.toggle_key) then
    local modifiers = rb.input.modifiers() or {}
    if modifiers.ctrl then
      open_studio()
      return
    end
    if modifiers.alt then
      cycle_profile()
      return
    end
    if tostring(cfg.activation) == "hold" then
      set_visible(true)
    else
      toggle_visible()
    end
  end
end

function OnInputUp(key)
  local normalized = norm_key(key)
  state.keys[normalized] = false

  if normalized == norm_key(cfg.toggle_key)
    and tostring(cfg.activation) == "hold" then
    local modifiers = rb.input.modifiers() or {}
    if not modifiers.ctrl and not modifiers.alt then
      set_visible(false)
    end
  end
end