GCD Goblin for WoW Forever

@paysch / gcd-goblinv1.0.1Public

World of Warcraft turbo keypress for Rebind: hold a key to auto repeat it, record a rotation and replay it with one key, plus anti-AFK.

Files

Older release. Installs get 1.0.1.

lib/overlay.luau16.2 KB

-- lib/overlay.luau
-- The overlay for GCD Goblin: art from assets/ (drawn by .tools/make_art.py,
-- which also writes every image's size and the menu positions to
-- assets/layout.json) shown on overlay surfaces. Rebind's own widgets cannot
-- show images, so the menu and the recording panel are drawn here too,
-- driven by binds in main.luau.
--
-- Every surface is placed over the game window, never just whichever
-- monitor is active, and all of them are hidden while the game is not in
-- front (overlay.hide / overlay.show, from main.luau's blur and focus).
--
-- Fixed wording is baked into phrase images; anything that changes is laid
-- out at runtime with line(): key names in [brackets], values as [TAGS].
-- Every hotkey the player needs is shown where it is used.

local overlay = {}

local layout = rb.file.read_json("assets/layout.json")
local art = {}
for name in layout.sizes do
  art[name] = rb.file.read_bytes("assets/" .. name .. ".png")
end

-- set by overlay.init: the settings, the key names to show, and the actions
local cfg, keys, actions

function overlay.init(settings, key_names, menu_actions)
  cfg, keys, actions = settings, key_names, menu_actions
end

local function image(name, x, y, w)
  return { kind = "image", png = art[name], x = x, y = y, w = w, h = if w then layout.sizes[name][2] else nil }
end

-- Lines. A piece is a phrase image's name, { key = "F8" } for a key in
-- brackets, { text = "8", color, size } for text, or { space = 18 }.
local PIECE_GAP = 5
local KEY_TEXT_SIZE = 13
local KEY_MIN_TEXT = 12
local TEXT_SIZE = 13
local KEY_END = layout.sizes.keycap_l[1]
local KEY_HEIGHT = layout.sizes.keycap_l[2]

local function piece_width(piece)
  if type(piece) == "string" then
    return layout.sizes[piece][1]
  end
  if piece.key then
    local width = rb.overlay.measure(piece.key, KEY_TEXT_SIZE, true)
    return 2 * KEY_END + math.max(width, KEY_MIN_TEXT) + 6
  end
  if piece.text then
    return (rb.overlay.measure(piece.text, piece.size or TEXT_SIZE, true))
  end
  return piece.space
end

local function line_width(pieces)
  local width = PIECE_GAP * (#pieces - 1)
  for _, piece in pieces do
    width += piece_width(piece)
  end
  return width
end

-- The ops drawing pieces left to right from x, centred on the line mid_y.
local function line(pieces, x, mid_y)
  local ops = {}
  for _, piece in pieces do
    local width = piece_width(piece)
    if type(piece) == "string" then
      table.insert(ops, image(piece, x, mid_y - layout.sizes[piece][2] // 2))
    elseif piece.key then
      local top = mid_y - KEY_HEIGHT // 2
      table.insert(ops, image("keycap_l", x, top))
      table.insert(ops, image("keycap_m", x + KEY_END, top, width - 2 * KEY_END))
      table.insert(ops, image("keycap_r", x + width - KEY_END, top))
      local text_width, text_height = rb.overlay.measure(piece.key, KEY_TEXT_SIZE, true)
      table.insert(ops, {
        kind = "text", text = piece.key, size = KEY_TEXT_SIZE, bold = true, color = "#6ee8ff",
        x = x + (width - text_width) // 2, y = top + (KEY_HEIGHT - 2 - text_height) // 2,
      })
    elseif piece.text then
      local size = piece.size or TEXT_SIZE
      local _, text_height = rb.overlay.measure(piece.text, size, true)
      table.insert(ops, {
        kind = "text", text = piece.text, size = size, bold = true, color = piece.color or "#f0e2c0",
        x = x, y = mid_y - text_height // 2,
      })
    end
    x += width + PIECE_GAP
  end
  return ops
end

local function append(ops, more)
  for _, op in more do
    table.insert(ops, op)
  end
  return ops
end

-- Placement over the game window. `where` names a horizontal edge or the
-- centre (x = "center", "left" or "right"), a vertical one (y = "top",
-- "center" or "bottom"), and an offset dx, dy from there. Overlays are only
-- drawn while the game is focused, so the focused window is the game.
local function place(name, where)
  local w = rb.system.window()
  local width, height = layout.sizes[name][1], layout.sizes[name][2]
  local x = if where.x == "left" then 0 elseif where.x == "right" then w.width - width else (w.width - width) // 2
  local y = if where.y == "top" then 0 elseif where.y == "bottom" then w.height - height else (w.height - height) // 2
  return w.x + x + (where.dx or 0), w.y + y + (where.dy or 0)
end

local function surface(name, where, ops)
  local x, y = place(name, where)
  return rb.overlay.surface({
    x = x, y = y,
    width = layout.sizes[name][1],
    height = layout.sizes[name][2],
    capturable = true,
    ops = ops,
  })
end

-- A chip's ops: its art, and a detail line laid out from pieces if given.
local function chip_ops(name, pieces)
  local ops = { image(name, 0, 0) }
  if pieces then
    append(ops, line(pieces, layout.chip_detail.x, layout.chip_detail.y))
  end
  return ops
end

-- Where things go: one column down the top centre, the part of the screen
-- the game's default UI leaves free (the minimap, action bars, cast bar and
-- extra ability icons are elsewhere): the status chips (chat, anti-AFK) in a
-- row, notices under them, then the rotation's chip or recording panel. The
-- menu sits in the middle; the top-left corner stays clear for the
-- PixelState strip.
local CHIP_HEIGHT = layout.sizes.chip_welcome[2]
local STATUS = { x = "center", y = "top", dy = 6 }
local NOTICE = { x = "center", y = "top", dy = 6 + CHIP_HEIGHT }
local ROTATION = { x = "center", y = "top", dy = 6 + 2 * CHIP_HEIGHT }
local MIDDLE = { x = "center", y = "center" }
local STATUS_GAP = 4

-- Slots: each shows at most one thing, kept as art name + ops (+ a key to
-- tell when it changed), so hide() can drop the surfaces and show() can put
-- back exactly what was there.
local slots = {
  chat = { where = STATUS },
  afk = { where = STATUS },
  rotation = { where = ROTATION },
  recording = { where = ROTATION },
}
local visible = true

local function open(slot)
  slot.surface = surface(slot.name, slot.where, slot.ops)
end

-- The status chips are centred as a group, side by side.
local function arrange_status()
  local shown = {}
  for _, slot in { slots.chat, slots.afk } do
    if slot.name then
      table.insert(shown, slot)
    end
  end
  local width = layout.sizes.chip_welcome[1]
  local total = #shown * width + (#shown - 1) * STATUS_GAP
  for i, slot in shown do
    slot.where = { x = "center", y = "top", dy = STATUS.dy, dx = -total // 2 + (i - 1) * (width + STATUS_GAP) + width // 2 }
    if slot.surface then
      local x, y = place(slot.name, slot.where)
      slot.surface:set({ x = x, y = y })
    end
  end
end

-- Shows `name` drawn with ops in a slot, or empties it for nil. Redraws only
-- when key (default: the name) changes; a slot keeps one size of art.
local function set_slot(slot, name, ops, key)
  key = key or name
  if slot.key == key then
    return
  end
  slot.key, slot.name, slot.ops = key, name, ops
  if not name then
    if slot.surface then
      slot.surface:remove()
      slot.surface = nil
    end
  elseif slot.surface then
    slot.surface:draw(ops)
  elseif visible then
    open(slot)
  end
end

-- Notices: one at a time under the status chips, gone after ms. They are
-- not brought back after the game loses focus.
local NOTICE_MS = 2500
local notice = nil

local function show_notice(name, ops, ms)
  if notice then
    notice:remove()
    notice = nil
  end
  if not visible then
    return
  end
  local shown = surface(name, NOTICE, ops)
  notice = shown
  rb.timer.after(ms, function()
    if notice == shown then
      notice:remove()
      notice = nil
    end
  end)
end

-- A chip shown briefly, such as "turbo keypress off" with "[F8] turns it back on".
function overlay.notice(name, pieces, ms)
  show_notice(name, chip_ops(name, pieces), ms or NOTICE_MS)
end

-- The install result. A failure shows the first line of the error under its
-- title.
local TOAST_MS = 5000
-- as many as fit inside the box's frame
local TOAST_DETAIL_CHARS = 44

function overlay.toast(installed, detail)
  local name = if installed then "toast_ok" else "toast_fail"
  local ops = { image(name, 0, 0) }
  if detail then
    table.insert(ops, {
      kind = "text", x = layout.toast_detail.x, y = layout.toast_detail.y, size = 12,
      color = "#f0e2c0", text = detail:sub(1, TOAST_DETAIL_CHARS),
    })
  end
  show_notice(name, ops, TOAST_MS)
end

-- The playing chip: moving stops it, the play key restarts it.
function overlay.playing(on)
  local pieces = { "d_move_to_stop", { key = keys.play }, "d_restarts" }
  set_slot(slots.rotation, if on then "chip_playing" else nil, if on then chip_ops("chip_playing", pieces) else nil)
end

-- The recording panel: what to do, the keys that finish it, and a live count
-- of the presses so far. nil hides it.
function overlay.recording(presses)
  if presses == nil then
    set_slot(slots.recording, nil)
    return
  end
  local r = layout.record
  local ops = { image("record", 0, 0) }
  append(ops, line({ { key = "Enter" }, "b_save", { space = 14 }, { key = "Esc" }, "b_cancel" }, r.keys_x, r.line_y))
  local count = { { text = tostring(presses), color = "#f0c060", size = 18 }, "b_presses" }
  append(ops, line(count, r.count_right - line_width(count), r.line_y))
  set_slot(slots.recording, "record", ops, "record " .. presses)
end

-- The menu: only what you change mid-session (set-and-forget settings, such
-- as chat safety, stay in Options). Each row has its label, then two
-- columns with one straight edge each: the hotkey that does the same thing
-- outside the menu, left-aligned, and the value, a [TAG] right-aligned. The
-- status line under the rows says what Enter (or Left / Right) does on the
-- selected row.
local strip_ready = false

local GOOD, BAD, NUMBER, DIM, ARROW = "#78f08c", "#ff6e64", "#f0c060", "#968ca0", "#6ee8ff"

local ROWS = {
  {
    kind = "toggle", field = "enabled", key = "turbo",
    hint = function() return { "h_turbo" } end,
  },
  {
    kind = "speed",
    hint = function() return { "h_speed" } end,
  },
  {
    kind = "toggle", field = "afk_enabled", key = "afk",
    hint = function() return { "h_afk", { key = keys.afk_key }, "h_when_idle" } end,
  },
  {
    kind = "rotation", key = "play",
    hint = function()
      local first = actions.length() == nil
      return { if first then "h_rotation_first" else "h_rotation", { key = keys.play }, "h_to_play" }
    end,
  },
  {
    -- the value and the hint both follow whether the strip is on screen
    kind = "addon",
    hint = function() return { if strip_ready then "h_addon_ready" else "h_addon" } end,
  },
}
-- the bounds of the "Between presses" slider in Options
local GAP_MIN, GAP_MAX, GAP_STEP = 10, 400, 10

-- { surface, banner, frame, animation, selected, drawn } while open
local menu = nil

-- A row's value as a [TAG] and its colour.
local function value(row)
  if row.kind == "toggle" then
    return if cfg[row.field] then "[ON]" else "[OFF]", if cfg[row.field] then GOOD else BAD
  elseif row.kind == "speed" then
    return string.format("[%.1f/s]", 1000 / (cfg.hold + cfg.gap)), NUMBER
  elseif row.kind == "rotation" then
    local length = actions.length()
    return if length then "[" .. length .. "]" else "[NONE]", if length then NUMBER else DIM
  end
  return if strip_ready then "[READY]" else "[MISSING]", if strip_ready then GOOD else BAD
end

local function draw_menu()
  local state = { menu.selected }
  for _, row in ROWS do
    table.insert(state, (value(row)))
  end
  state = table.concat(state, "|")
  if menu.drawn == state then
    return
  end
  menu.drawn = state

  local m = layout.menu
  local menu_width = layout.sizes.menu[1]
  local ops = {
    image("menu", 0, 0),
    image("menu_highlight", m.highlight_x, m.rows[menu.selected] + 2),
    image("menu_labels", 0, 0),
  }
  for i, row in ROWS do
    local mid = m.rows[i] + m.row_height // 2
    if row.key then
      append(ops, line({ { key = keys[row.key] } }, m.key_x, mid))
    end
    local tag, color = value(row)
    local pieces = { { text = tag, color = color } }
    -- Left / Right work only on the selected speed row, so only it shows them
    if row.kind == "speed" and i == menu.selected then
      pieces = { { text = "<", color = ARROW }, pieces[1], { text = ">", color = ARROW } }
    end
    append(ops, line(pieces, m.value_right - line_width(pieces), mid))
  end

  local hint = ROWS[menu.selected].hint()
  append(ops, line(hint, (menu_width - line_width(hint)) // 2, m.hint_y))
  menu.surface:draw(ops)
end

function overlay.menu_open()
  return menu ~= nil
end

function overlay.close_menu()
  if menu then
    menu.animation:stop()
    menu.banner:remove()
    menu.surface:remove()
    menu = nil
  end
end

-- Opens the menu, or closes it when it is already open. The goblin beside
-- the logo lives on a second, clear surface with the menu's size and place,
-- so it lands exactly on top; only that layer is redrawn per frame, not the
-- whole menu.
function overlay.toggle_menu()
  if menu then
    overlay.close_menu()
    return
  end
  menu = { selected = 1, frame = 0, surface = surface("menu", MIDDLE, {}) }
  menu.banner = surface("menu", MIDDLE, { image("banner_0", 0, 0) })
  menu.animation = rb.timer.every(layout.banner.frame_ms, function()
    menu.frame = (menu.frame + 1) % layout.banner.frames
    menu.banner:draw({ image("banner_" .. menu.frame, 0, 0) })
  end)
  draw_menu()
end

-- Up and Down move the selection (wrapping). Left and Right change the speed
-- on its row. Enter toggles a row's setting, or closes the menu and records a
-- rotation or installs the addon. Escape closes.
function overlay.menu_key(key)
  if key == "Escape" then
    overlay.close_menu()
    return
  end
  local row = ROWS[menu.selected]
  if key == "Up" then
    menu.selected = (menu.selected - 2) % #ROWS + 1
  elseif key == "Down" then
    menu.selected = menu.selected % #ROWS + 1
  elseif (key == "Left" or key == "Right") and row.kind == "speed" then
    -- a shorter gap is a faster speed
    local step = if key == "Right" then -GAP_STEP else GAP_STEP
    cfg.gap = math.clamp(cfg.gap + step, GAP_MIN, GAP_MAX)
  elseif key == "Enter" and row.kind == "toggle" then
    cfg[row.field] = not cfg[row.field]
  elseif key == "Enter" and (row.kind == "rotation" or row.kind == "addon") then
    overlay.close_menu()
    if row.kind == "rotation" then
      actions.record()
    else
      actions.install()
    end
    return
  end
  draw_menu()
end

-- chat is true or false, or nil when the PixelState strip cannot be read
function overlay.sync(chat)
  strip_ready = chat ~= nil
  local chat_chip = nil
  if cfg.chat_detect and cfg.chat_overlay then
    chat_chip = if chat == nil then "chip_nostrip" elseif chat then "chip_typing" else "chip_idle"
  end
  local afk_chip = if cfg.afk_enabled and cfg.afk_overlay then "chip_afk" else nil
  set_slot(slots.chat, chat_chip, chat_chip and chip_ops(chat_chip))
  set_slot(slots.afk, afk_chip, afk_chip and chip_ops(afk_chip))
  arrange_status()
  -- keeps the menu in step when a setting changes from a hotkey or Options
  if menu then
    draw_menu()
  end
end

-- The game lost focus: take everything off screen, keeping what each slot
-- shows so show() can put it back. The menu closes.
function overlay.hide()
  visible = false
  overlay.close_menu()
  for _, slot in slots do
    if slot.surface then
      slot.surface:remove()
      slot.surface = nil
    end
  end
  if notice then
    notice:remove()
    notice = nil
  end
end

-- The game is in front again: put the slots back, over the game window
-- wherever it is now.
function overlay.show()
  visible = true
  arrange_status()
  for _, slot in slots do
    if slot.name and not slot.surface then
      open(slot)
    end
  end
end

return overlay

61 files, 595.0 KB. 53 have no text preview.