The server rejects any PBC frame with seq == 0 (AddonControl envelope check), so GEAR_BAGS_REQ/GEAR_EQUIP_REQ were silently dropped and the panel rendered with no data. Send uses an incrementing seq like PlayerbotControl.
399 lines
14 KiB
Lua
399 lines
14 KiB
Lua
--[[
|
|
BotGear.lua — Altbot character & inventory controller.
|
|
Uses PBC addon protocol, no dependency on PlayerbotControl.
|
|
/botgear — opens panel for targeted bot.
|
|
Left-click bag item → pick up for move; left-click another slot → move
|
|
Right-click bag item → equip to correct equipment slot
|
|
Shift+right-click bag item → destroy (permanent)
|
|
Right-click equipped item → unequip to first free bag slot
|
|
Shows the bot's full 3D character (ModelScene), equipment, all bags, gold.
|
|
]]
|
|
|
|
local ADDON = "BotGear"
|
|
local PREFIX = "PBC"
|
|
|
|
local SLOT_SIZE = 36
|
|
local GAP = 4
|
|
local BAG_COLS = 4
|
|
local BACKPACK_SLOTS = 16
|
|
local MAX_BAGS = 4
|
|
|
|
-- State -------------------------------------------------------------
|
|
local frame, botGuid, botName
|
|
local equipButtons = {} -- [equipSlot] = button
|
|
local bagButtons = {} -- [bagNum] = { [slot] = button }
|
|
local equipData = {} -- [equipSlot] = {entry, quality}
|
|
local bagData = {} -- [bagNum] = { [slot] = {entry,count,quality} }
|
|
local bagLayout = {} -- [bagNum] = {entry, size}
|
|
local gold = 0
|
|
local selected = nil -- {bag=, slot=} item picked up for move
|
|
local modelActor = nil
|
|
local bagScroll, bagContent, goldText
|
|
|
|
local EQUIP_SLOT_NAMES = {
|
|
[0]="Head",[1]="Neck",[2]="Shoulder",[3]="Shirt",[4]="Chest",
|
|
[5]="Waist",[6]="Legs",[7]="Feet",[8]="Wrist",[9]="Hands",
|
|
[10]="Finger1",[11]="Finger2",[12]="Trinket1",[13]="Trinket2",
|
|
[14]="Back",[15]="MainHand",[16]="OffHand",[17]="Ranged",[18]="Tabard",
|
|
}
|
|
|
|
-- Comms -------------------------------------------------------------
|
|
local seqCounter = 1 -- server rejects frames with seq == 0
|
|
local function Send(mtype, ...)
|
|
local args = {...}
|
|
local body = table.concat(args, "|")
|
|
local f = string.format("1|%08x|1|1|%s|%s", seqCounter, mtype, body)
|
|
seqCounter = (seqCounter % 0xFFFFFFFF) + 1
|
|
C_ChatInfo.SendAddonMessage(PREFIX, f, "WHISPER", botName or UnitName("player"))
|
|
end
|
|
|
|
local function ResolveTarget()
|
|
if not UnitExists("target") then return nil, nil end
|
|
local guid = UnitGUID("target")
|
|
if not guid then return nil, nil end
|
|
local low = tonumber(string.match(guid, "-(%d+)-%x+$"))
|
|
return low, UnitName("target")
|
|
end
|
|
|
|
local function RequestBags()
|
|
if not botGuid then return end
|
|
Send("GEAR_BAGS_REQ", tostring(botGuid))
|
|
end
|
|
|
|
local function RequestEquip()
|
|
if not botGuid then return end
|
|
Send("GEAR_EQUIP_REQ", tostring(botGuid))
|
|
end
|
|
|
|
local function RefreshAll()
|
|
RequestBags()
|
|
C_Timer.After(0.15, RequestEquip)
|
|
end
|
|
|
|
-- Rendering ---------------------------------------------------------
|
|
local function RefreshSlot(btn, entry, count, quality, isSelected)
|
|
if not btn then return end
|
|
if entry and entry > 0 then
|
|
local _, _, _, _, _, _, _, _, _, tex = GetItemInfo(entry)
|
|
btn.itemEntry = entry
|
|
btn.icon:SetTexture(tex or "Interface\\Icons\\INV_Misc_QuestionMark")
|
|
btn.icon:SetAlpha(1.0)
|
|
btn.countText:SetText(count and count > 1 and count or "")
|
|
btn:SetAlpha(1.0)
|
|
else
|
|
btn.itemEntry = 0
|
|
btn.icon:SetTexture(nil)
|
|
btn.icon:SetAlpha(0)
|
|
btn.countText:SetText("")
|
|
btn:SetAlpha(0.35)
|
|
end
|
|
if isSelected then
|
|
btn:SetBackdropColor(1, 0.82, 0.2, 0.35)
|
|
else
|
|
btn:SetBackdropColor(0, 0, 0, 0)
|
|
end
|
|
end
|
|
|
|
local function CreateSlotButton(parent, x, y, slotType, bag, slot)
|
|
local btn = CreateFrame("Button", nil, parent, "BackdropTemplate")
|
|
btn:SetSize(SLOT_SIZE, SLOT_SIZE)
|
|
btn:SetPoint("TOPLEFT", x, -y)
|
|
btn:SetNormalTexture("Interface\\Buttons\\UI-Quickslot2")
|
|
btn:SetHighlightTexture("Interface\\Buttons\\ButtonHilight-Square")
|
|
btn:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8x8", edgeFile = "Interface\\Buttons\\WHITE8x8", edgeSize = 1 })
|
|
btn.slotType = slotType
|
|
btn.bag = bag
|
|
btn.slotIdx = slot
|
|
btn.itemEntry = 0
|
|
|
|
btn.icon = btn:CreateTexture(nil, "ARTWORK")
|
|
btn.icon:SetAllPoints()
|
|
btn.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)
|
|
btn.icon:SetAlpha(0)
|
|
btn.countText = btn:CreateFontString(nil, "OVERLAY", "NumberFontNormal")
|
|
btn.countText:SetPoint("BOTTOMRIGHT", -1, 2)
|
|
|
|
btn:SetScript("OnEnter", function(self)
|
|
if self.itemEntry and self.itemEntry > 0 then
|
|
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
|
|
GameTooltip:SetItemByID(self.itemEntry)
|
|
GameTooltip:Show()
|
|
end
|
|
end)
|
|
btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
|
|
|
|
btn:SetScript("OnClick", function(self, button)
|
|
if self.itemEntry == 0 then return end
|
|
if slotType == "bag" then
|
|
if button == "RightButton" and IsShiftKeyDown() then
|
|
-- Destroy (permanent)
|
|
Send("GEAR_DESTROY_ITEM", tostring(botGuid), tostring(bag), tostring(slot))
|
|
selected = nil
|
|
C_Timer.After(0.3, RefreshAll)
|
|
elseif button == "RightButton" then
|
|
-- Equip
|
|
Send("GEAR_EQUIP_ITEM", tostring(botGuid), tostring(bag), tostring(slot))
|
|
C_Timer.After(0.3, RefreshAll)
|
|
elseif button == "LeftButton" then
|
|
-- Move: pick up / place
|
|
local key = bag * 1000 + slot
|
|
if not selected then
|
|
selected = { bag = bag, slot = slot }
|
|
RefreshBags()
|
|
elseif selected.bag == bag and selected.slot == slot then
|
|
selected = nil
|
|
RefreshBags()
|
|
else
|
|
Send("GEAR_MOVE_ITEM", tostring(botGuid),
|
|
tostring(selected.bag), tostring(selected.slot),
|
|
tostring(bag), tostring(slot))
|
|
selected = nil
|
|
C_Timer.After(0.3, RefreshAll)
|
|
end
|
|
end
|
|
elseif slotType == "equip" and button == "RightButton" then
|
|
Send("GEAR_UNEQUIP_ITEM", tostring(botGuid), tostring(slot))
|
|
C_Timer.After(0.3, RefreshAll)
|
|
end
|
|
end)
|
|
|
|
return btn
|
|
end
|
|
|
|
local function RefreshEquip()
|
|
for i = 0, 18 do
|
|
local d = equipData[i]
|
|
RefreshSlot(equipButtons[i], d and d.entry, 1, d and d.quality, false)
|
|
end
|
|
end
|
|
|
|
local function RefreshBags()
|
|
for bag = 0, MAX_BAGS do
|
|
local btns = bagButtons[bag]
|
|
if btns then
|
|
for slot, btn in pairs(btns) do
|
|
local d = bagData[bag] and bagData[bag][slot]
|
|
local isSel = selected and selected.bag == bag and selected.slot == slot
|
|
RefreshSlot(btn, d and d.entry, d and d.count, d and d.quality, isSel)
|
|
end
|
|
end
|
|
end
|
|
if goldText then
|
|
local g = math.floor(gold / 10000)
|
|
local s = math.floor((gold % 10000) / 100)
|
|
local c = gold % 100
|
|
goldText:SetText(string.format("Gold: %dg %ds %dc", g, s, c))
|
|
end
|
|
end
|
|
|
|
-- Rebuild the bag section layout inside the scroll content.
|
|
local function BuildBagLayout()
|
|
local content = bagContent
|
|
if not content then return end
|
|
|
|
-- Compute total content height: backpack + each bag, header lines included.
|
|
local y = 0
|
|
local function RowOfSlots(bag, size, title)
|
|
local header = content:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall")
|
|
header:SetPoint("TOPLEFT", content, "TOPLEFT", 4, -y)
|
|
header:SetText(title)
|
|
y = y + 18
|
|
local rows = math.ceil(size / BAG_COLS)
|
|
for slot = 0, size - 1 do
|
|
local col = slot % BAG_COLS
|
|
local row = math.floor(slot / BAG_COLS)
|
|
if not bagButtons[bag] then bagButtons[bag] = {} end
|
|
if not bagButtons[bag][slot] then
|
|
bagButtons[bag][slot] = CreateSlotButton(content,
|
|
col * (SLOT_SIZE + GAP), row * (SLOT_SIZE + GAP), "bag", bag, slot)
|
|
end
|
|
bagButtons[bag][slot]:Show()
|
|
bagButtons[bag][slot]:ClearAllPoints()
|
|
bagButtons[bag][slot]:SetPoint("TOPLEFT", content, "TOPLEFT",
|
|
col * (SLOT_SIZE + GAP), -(y + row * (SLOT_SIZE + GAP)))
|
|
end
|
|
y = y + rows * (SLOT_SIZE + GAP) + 8
|
|
end
|
|
|
|
RowOfSlots(0, BACKPACK_SLOTS, "Backpack")
|
|
for bag = 1, MAX_BAGS do
|
|
local info = bagLayout[bag]
|
|
if info and info.size > 0 then
|
|
RowOfSlots(bag, info.size, "Bag " .. bag)
|
|
else
|
|
-- hide leftover buttons for a removed bag
|
|
if bagButtons[bag] then
|
|
for _, btn in pairs(bagButtons[bag]) do btn:Hide() end
|
|
end
|
|
end
|
|
end
|
|
|
|
content:SetHeight(y + 20)
|
|
bagScroll:UpdateScrollChildRect()
|
|
end
|
|
|
|
-- Wire protocol: <guid>|G|<gold>|B|<bag>|<entry>|<size>...|I|<bag>|<slot>|<entry>|<count>|<quality>...
|
|
local function ParseBagsResp(guidStr, body)
|
|
if not guidStr or tonumber(guidStr) ~= botGuid then return end
|
|
bagData = {}
|
|
bagLayout = {}
|
|
gold = 0
|
|
local parts = { strsplit("|", body) }
|
|
local i = 1
|
|
while parts[i] do
|
|
local kind = parts[i]
|
|
if kind == "G" then
|
|
gold = tonumber(parts[i + 1]) or 0
|
|
i = i + 2
|
|
elseif kind == "B" then
|
|
local num = tonumber(parts[i + 1])
|
|
bagLayout[num] = { entry = tonumber(parts[i + 2]), size = tonumber(parts[i + 3]) or 0 }
|
|
i = i + 4
|
|
elseif kind == "I" then
|
|
local b = tonumber(parts[i + 1])
|
|
local s = tonumber(parts[i + 2])
|
|
if not bagData[b] then bagData[b] = {} end
|
|
bagData[b][s] = {
|
|
entry = tonumber(parts[i + 3]),
|
|
count = tonumber(parts[i + 4]),
|
|
quality = tonumber(parts[i + 5]) or 1,
|
|
}
|
|
i = i + 6
|
|
else
|
|
break
|
|
end
|
|
end
|
|
BuildBagLayout()
|
|
RefreshBags()
|
|
end
|
|
|
|
local function ParseEquipResp(guidStr, body)
|
|
if not guidStr or tonumber(guidStr) ~= botGuid then return end
|
|
equipData = {}
|
|
if body then
|
|
for part in body:gmatch("[^|]+") do
|
|
local slot, entry, qual = part:match("([^,]+),([^,]+),([^,]*)")
|
|
if slot then
|
|
equipData[tonumber(slot)] = {
|
|
entry = tonumber(entry),
|
|
quality = tonumber(qual) or 1,
|
|
}
|
|
end
|
|
end
|
|
end
|
|
RefreshEquip()
|
|
end
|
|
|
|
-- UI ----------------------------------------------------------------
|
|
local function RefreshTarget()
|
|
local g, n = ResolveTarget()
|
|
if g then
|
|
botGuid = g
|
|
botName = n
|
|
if frame and frame.title then
|
|
frame.title:SetText("Bot - " .. (n or "?"))
|
|
end
|
|
if modelActor then
|
|
modelActor:SetUnit("target")
|
|
end
|
|
RefreshAll()
|
|
end
|
|
end
|
|
|
|
local function BuildFrame()
|
|
if frame then return end
|
|
frame = CreateFrame("Frame", "BotGearFrame", UIParent, "BackdropTemplate")
|
|
frame:SetSize(520, 700)
|
|
frame:SetPoint("CENTER")
|
|
frame:SetBackdrop({ bgFile="Interface\\DialogFrame\\Background-DialogFrame",
|
|
edgeFile="Interface\\Tooltips\\UI-Tooltip-Border", tile=1,tileSize=16,edgeSize=16,
|
|
insets={left=3,right=3,top=3,bottom=3}})
|
|
frame:SetMovable(true)
|
|
frame:RegisterForDrag("LeftButton")
|
|
frame:SetScript("OnDragStart", frame.StartMoving)
|
|
frame:SetScript("OnDragStop", frame.StopMovingOrSizing)
|
|
tinsert(UISpecialFrames, "BotGearFrame")
|
|
|
|
local close = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
|
|
close:SetPoint("TOPRIGHT", -5, -5)
|
|
|
|
frame.title = frame:CreateFontString(nil, "ARTWORK", "GameFontNormalLarge")
|
|
frame.title:SetPoint("TOP", 0, -12)
|
|
frame.title:SetText("Bot - /target a bot")
|
|
|
|
-- 3D character model
|
|
local modelScene = CreateFrame("ModelScene", nil, frame)
|
|
modelScene:SetSize(180, 250)
|
|
modelScene:SetPoint("TOPLEFT", frame, "TOPLEFT", 12, -40)
|
|
modelScene:SetPortraitZoom(1.0)
|
|
modelActor = modelScene:CreateActor()
|
|
modelActor:SetPosition(0, 0, 0)
|
|
modelActor:SetFacing(-1.8)
|
|
modelActor:SetUnit("player")
|
|
|
|
-- Gold
|
|
goldText = frame:CreateFontString(nil, "ARTWORK", "GameFontNormal")
|
|
goldText:SetPoint("TOPLEFT", frame, "TOPLEFT", 14, -300)
|
|
goldText:SetText("Gold: 0g 0s 0c")
|
|
|
|
-- Equipment
|
|
local equipLbl = frame:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall")
|
|
equipLbl:SetPoint("TOPLEFT", frame, "TOPLEFT", 14, -320)
|
|
equipLbl:SetText("Equipment")
|
|
|
|
for i = 0, 18 do
|
|
local col = i < 10 and 12 or 102
|
|
local row = (i < 10 and i or (i - 10)) * (SLOT_SIZE + GAP)
|
|
equipButtons[i] = CreateSlotButton(frame, col, row + 335, "equip", nil, i)
|
|
local lbl = frame:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall")
|
|
local lcol = i < 10 and 50 or 140
|
|
local lrow = (i < 10 and i or (i - 10)) * (SLOT_SIZE + GAP) + 350
|
|
lbl:SetPoint("TOPLEFT", lcol, -lrow)
|
|
lbl:SetText(EQUIP_SLOT_NAMES[i] or ("Slot" .. i))
|
|
end
|
|
|
|
-- Bags (scroll)
|
|
bagScroll = CreateFrame("ScrollFrame", nil, frame, "BackdropTemplate")
|
|
bagScroll:SetPoint("TOPLEFT", frame, "TOPLEFT", 205, -40)
|
|
bagScroll:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -12, 34)
|
|
bagScroll:SetBackdrop({ bgFile="Interface\\DialogFrame\\UI-DialogBox-Background" })
|
|
bagContent = CreateFrame("Frame", nil, bagScroll)
|
|
bagContent:SetSize(300, 400)
|
|
bagScroll:SetScrollChild(bagContent)
|
|
|
|
local refresh = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
|
|
refresh:SetSize(80, 22)
|
|
refresh:SetPoint("BOTTOM", 0, 8)
|
|
refresh:SetText("Refresh")
|
|
refresh:SetScript("OnClick", RefreshTarget)
|
|
end
|
|
|
|
-- Inbound PBC messages
|
|
local f = CreateFrame("Frame")
|
|
f:RegisterEvent("CHAT_MSG_ADDON")
|
|
f:SetScript("OnEvent", function(_, _, prefix, msg, channel, sender)
|
|
if prefix ~= PREFIX then return end
|
|
local mtype, body = msg:match("^1|[^|]+|%d+|%d+|([^|]+)|(.+)$")
|
|
if not mtype then return end
|
|
|
|
if mtype == "GEAR_BAGS_RESP" then
|
|
local guidStr, rest = body:match("^([^|]+)|(.*)$")
|
|
ParseBagsResp(guidStr, rest)
|
|
elseif mtype == "GEAR_EQUIP_RESP" then
|
|
local guidStr, rest = body:match("^([^|]+)|(.*)$")
|
|
ParseEquipResp(guidStr, rest)
|
|
end
|
|
end)
|
|
|
|
-- Slash command
|
|
SLASH_BOTGEAR1 = "/botgear"
|
|
SlashCmdList["BOTGEAR"] = function()
|
|
BuildFrame()
|
|
if frame:IsVisible() then
|
|
frame:Hide()
|
|
else
|
|
frame:Show()
|
|
RefreshTarget()
|
|
end
|
|
end
|