mirror of
https://github.com/mehah/otclient
synced 2026-08-15 16:29:06 -04:00
New Features: - Native macOS app with Cocoa windowing, OpenGL view, improved input, clipboard and cursor handling. - Platform-aware keyboard and shortcut behavior with clearer primary/meta/control semantics and normalized key combos. Bug Fixes: - Expanded detection for "connection refused" network errors. Chores: - macOS build/bundle and packaging improvements; new dependency overlay added; CI now runs macOS build when relevant. Tests: - AddressSanitizer support added for test builds.
554 lines
15 KiB
Lua
554 lines
15 KiB
Lua
-- Add capitalize function to string library if it doesn't exist
|
|
if not string.capitalize then
|
|
function string.capitalize(str)
|
|
if not str or str == "" or str == nil then
|
|
return "Unknown"
|
|
end
|
|
return str:gsub("(%l)(%w*)", function(first, rest)
|
|
return first:upper() .. rest
|
|
end)
|
|
end
|
|
end
|
|
|
|
-- Function to truncate text to a maximum length
|
|
local function short_text(text, maxLength)
|
|
if not text or text == "" or text == nil then
|
|
return "Unknown"
|
|
end
|
|
if string.len(text) > maxLength then
|
|
return text:sub(1, maxLength - 3) .. "..."
|
|
end
|
|
return text
|
|
end
|
|
|
|
if not BossCooldown then
|
|
BossCooldown = {
|
|
launchTime = 0,
|
|
lastTick = 0,
|
|
sort = 0,
|
|
search = "",
|
|
cooldown = {},
|
|
widgets = {},
|
|
window = nil,
|
|
}
|
|
BossCooldown.__index = BossCooldown
|
|
end
|
|
|
|
local widgets = {}
|
|
|
|
function BossCooldown.create()
|
|
BossCooldown.launchTime = 0
|
|
|
|
BossCooldown.sort = 0
|
|
BossCooldown.lastTick = 0
|
|
BossCooldown.search = ""
|
|
|
|
BossCooldown.cooldown = {}
|
|
BossCooldown.widgets = {}
|
|
|
|
BossCooldown.window = openedWindows["bossButton"]
|
|
|
|
if not BossCooldown.window then
|
|
return
|
|
end
|
|
|
|
local toggleFilterButton = BossCooldown.window:recursiveGetChildById("toggleFilterButton")
|
|
if toggleFilterButton then
|
|
toggleFilterButton:setVisible(false)
|
|
end
|
|
|
|
local newWindowButton = BossCooldown.window:recursiveGetChildById("newWindowButton")
|
|
if newWindowButton then
|
|
newWindowButton:setVisible(false)
|
|
end
|
|
|
|
local contextMenuButton = BossCooldown.window:recursiveGetChildById("contextMenuButton")
|
|
local minimizeButton = BossCooldown.window:recursiveGetChildById("minimizeButton")
|
|
|
|
if contextMenuButton and minimizeButton then
|
|
contextMenuButton:setVisible(true)
|
|
contextMenuButton:breakAnchors()
|
|
contextMenuButton:addAnchor(AnchorTop, minimizeButton:getId(), AnchorTop)
|
|
contextMenuButton:addAnchor(AnchorRight, minimizeButton:getId(), AnchorLeft)
|
|
contextMenuButton:setMarginRight(7)
|
|
contextMenuButton:setMarginTop(0)
|
|
|
|
contextMenuButton.onClick = function(widget, mousePos)
|
|
local pos = mousePos or g_window.getMousePosition()
|
|
return onBossExtra(pos)
|
|
end
|
|
end
|
|
|
|
local lockButton = BossCooldown.window:recursiveGetChildById("lockButton")
|
|
|
|
if lockButton and contextMenuButton then
|
|
lockButton:setVisible(true)
|
|
lockButton:breakAnchors()
|
|
lockButton:addAnchor(AnchorTop, contextMenuButton:getId(), AnchorTop)
|
|
lockButton:addAnchor(AnchorRight, contextMenuButton:getId(), AnchorLeft)
|
|
lockButton:setMarginRight(2)
|
|
lockButton:setMarginTop(0)
|
|
end
|
|
|
|
local cl = openedWindows["bossButton"]:recursiveGetChildById("clickablePanel")
|
|
if cl then
|
|
cl.onMouseWheel = scrollUIPanel
|
|
end
|
|
|
|
-- Set up the event handlers for the search text
|
|
local searchText = BossCooldown.window.contentsPanel.searchText
|
|
if searchText then
|
|
-- Store original handlers
|
|
BossCooldown.originalOnKeyPress = searchText.onKeyPress
|
|
BossCooldown.originalOnTextChange = searchText.onTextChange
|
|
|
|
-- Override onKeyPress to unbind movement keys immediately when typing
|
|
searchText.onKeyPress = function(widget, keyCode, keyboardModifiers)
|
|
-- Unbind movement keys when user starts typing
|
|
local gameWalk = modules.game_walk
|
|
if gameWalk then
|
|
gameWalk.unbindWalkKey("W")
|
|
gameWalk.unbindWalkKey("D")
|
|
gameWalk.unbindWalkKey("S")
|
|
gameWalk.unbindWalkKey("A")
|
|
gameWalk.unbindWalkKey("E")
|
|
gameWalk.unbindWalkKey("Q")
|
|
gameWalk.unbindWalkKey("C")
|
|
gameWalk.unbindWalkKey("Z")
|
|
gameWalk.unbindTurnKey("Control+W")
|
|
gameWalk.unbindTurnKey("Control+D")
|
|
gameWalk.unbindTurnKey("Control+S")
|
|
gameWalk.unbindTurnKey("Control+A")
|
|
end
|
|
|
|
-- Handle Escape key to clear focus and restore movement
|
|
if keyCode == KeyEscape then
|
|
-- Re-bind movement keys
|
|
local gameWalk = modules.game_walk
|
|
if gameWalk then
|
|
gameWalk.bindWalkKey("W", North)
|
|
gameWalk.bindWalkKey("D", East)
|
|
gameWalk.bindWalkKey("S", South)
|
|
gameWalk.bindWalkKey("A", West)
|
|
gameWalk.bindWalkKey("E", NorthEast)
|
|
gameWalk.bindWalkKey("Q", NorthWest)
|
|
gameWalk.bindWalkKey("C", SouthEast)
|
|
gameWalk.bindWalkKey("Z", SouthWest)
|
|
gameWalk.bindTurnKey("Control+W", North)
|
|
gameWalk.bindTurnKey("Control+D", East)
|
|
gameWalk.bindTurnKey("Control+S", South)
|
|
gameWalk.bindTurnKey("Control+A", West)
|
|
end
|
|
widget:clearFocus()
|
|
return false
|
|
end
|
|
|
|
-- Call original handler if it exists
|
|
if BossCooldown.originalOnKeyPress then
|
|
return BossCooldown.originalOnKeyPress(widget, keyCode, keyboardModifiers)
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Set up focus change handler
|
|
searchText.onFocusChange = onBossSearchFocusChange
|
|
end
|
|
end
|
|
|
|
function BossCooldown:reset()
|
|
BossCooldown.launchTime = g_clock.millis()
|
|
|
|
BossCooldown.sort = 0
|
|
BossCooldown.lastTick = 0
|
|
BossCooldown.search = ""
|
|
BossCooldown.cooldown = {}
|
|
|
|
BossCooldown.widgets = {}
|
|
|
|
BossCooldown:updateWindow()
|
|
end
|
|
|
|
function BossCooldown:checkTicks()
|
|
if self.lastTick - os.time() > 0 then
|
|
return
|
|
end
|
|
|
|
self.lastTick = os.time() + 1
|
|
|
|
local layout = self.window.contentsPanel.bosses:getLayout()
|
|
local needUpdate = false
|
|
for _, widget in ipairs(self.widgets) do
|
|
layout:enableUpdates()
|
|
if self.search == "" or string.find(widget.name:lower(), self.search:lower(), 1, true) then
|
|
widget:setVisible(true)
|
|
else
|
|
widget:setVisible(false)
|
|
end
|
|
layout:disableUpdates()
|
|
|
|
widget.tick = widget.tick - 1
|
|
widget.cooldown = os.time() + widget.tick
|
|
if widget.cooldown < os.time() then
|
|
widget.cooldown = os.time() + 60 * 365 * 60 * 24
|
|
end
|
|
|
|
local bossCooldownLabel = widget:recursiveGetChildById("bossNCooldown")
|
|
if bossCooldownLabel then
|
|
if widget.tick <= 0 then
|
|
bossCooldownLabel:setText("No Cooldown")
|
|
bossCooldownLabel:setColor("#c0c0c0")
|
|
if widget.type ~= "nocd" then
|
|
needUpdate = true
|
|
widget.cooldown = os.time() + 60 * 365 * 60 * 24
|
|
end
|
|
widget.type = "nocd"
|
|
elseif widget.tick <= 60 then
|
|
bossCooldownLabel:setText(widget.tick .. "s")
|
|
bossCooldownLabel:setColor("#ff9854")
|
|
if widget.type ~= "second" then
|
|
needUpdate = true
|
|
end
|
|
widget.type = "second"
|
|
else
|
|
local duration = math.max(1, widget.tick)
|
|
local days = math.floor(duration / 86400)
|
|
local hours = math.floor((duration % 86400) / 3600)
|
|
local minutes = math.floor((duration % 3600) / 60)
|
|
if days > 0 then
|
|
bossCooldownLabel:setText(string.format("%dd %02dh %02dmin", days, hours, minutes))
|
|
else
|
|
bossCooldownLabel:setText(string.format("%02dh %02dmin", hours, minutes))
|
|
end
|
|
bossCooldownLabel:setColor("#ff9854")
|
|
if widget.type ~= "timed" then
|
|
needUpdate = true
|
|
end
|
|
widget.type = "timed"
|
|
end
|
|
end
|
|
end
|
|
layout:enableUpdates()
|
|
if needUpdate then
|
|
if self.sort == 0 then
|
|
table.sort(self.widgets, function(a, b)
|
|
return a.cooldown < b.cooldown
|
|
end)
|
|
else
|
|
table.sort(self.widgets, function(a, b)
|
|
return a.name < b.name
|
|
end)
|
|
end
|
|
|
|
self.window.contentsPanel.bosses:reorderChildren(self.widgets)
|
|
end
|
|
end
|
|
|
|
function BossCooldown:updateWindow()
|
|
local contentsPanel = BossCooldown.window.contentsPanel
|
|
|
|
BossCooldown.widgets = {}
|
|
contentsPanel.bosses:destroyChildren()
|
|
if BossCooldown.sort == 0 then
|
|
table.sort(BossCooldown.cooldown, function(a, b)
|
|
local acd = a.cooldown
|
|
if acd < os.time() then
|
|
acd = os.time() + 60 * 365 * 60 * 24
|
|
end
|
|
local bcd = b.cooldown
|
|
if bcd < os.time() then
|
|
bcd = os.time() + 60 * 365 * 60 * 24
|
|
end
|
|
return acd < bcd
|
|
end)
|
|
else
|
|
table.sort(BossCooldown.cooldown, function(a, b)
|
|
return a.name < b.name
|
|
end)
|
|
end
|
|
|
|
contentsPanel.searchText:setText("", false)
|
|
|
|
local c = 1
|
|
for _, info in ipairs(BossCooldown.cooldown) do
|
|
local widget = g_ui.createWidget("BossInfo", contentsPanel.bosses)
|
|
|
|
local creatureWidget = widget:recursiveGetChildById("creature")
|
|
local bossNameLabel = widget:recursiveGetChildById("bossName")
|
|
local bossCooldownLabel = widget:recursiveGetChildById("bossNCooldown")
|
|
|
|
if creatureWidget then
|
|
if info.outfit then
|
|
creatureWidget:setOutfit(info.outfit)
|
|
else
|
|
local fallbackOutfit = {
|
|
type = 130,
|
|
head = 0,
|
|
body = 0,
|
|
legs = 0,
|
|
feet = 0,
|
|
addons = 0,
|
|
}
|
|
creatureWidget:setOutfit(fallbackOutfit)
|
|
end
|
|
end
|
|
|
|
if bossNameLabel then
|
|
local bossName = info.name
|
|
|
|
if not bossName or bossName == "" or bossName:trim() == "" then
|
|
bossName = "Boss " .. (info.bossId or "Unknown")
|
|
end
|
|
|
|
local displayName = short_text(string.capitalize(bossName), 13)
|
|
bossNameLabel:setText(displayName)
|
|
widget:setTooltip(string.capitalize(bossName))
|
|
else
|
|
widget:setTooltip("Unknown Boss")
|
|
end
|
|
widget.onClick = function()
|
|
if modules.game_cyclopedia then
|
|
modules.game_cyclopedia.show("bosstiary")
|
|
end
|
|
end
|
|
|
|
local resttime = math.max(0, info.cooldown - os.time())
|
|
if bossCooldownLabel then
|
|
if resttime <= 0 then
|
|
bossCooldownLabel:setText("No Cooldown")
|
|
bossCooldownLabel:setColor("#c0c0c0")
|
|
widget.type = "nocd"
|
|
elseif resttime <= 60 then
|
|
bossCooldownLabel:setText(resttime .. "s")
|
|
bossCooldownLabel:setColor("#ff9854")
|
|
widget.type = "second"
|
|
else
|
|
local duration = math.max(1, resttime)
|
|
local days = math.floor(duration / 86400)
|
|
local hours = math.floor((duration % 86400) / 3600)
|
|
local minutes = math.floor((duration % 3600) / 60)
|
|
if days > 0 then
|
|
bossCooldownLabel:setText(string.format("%dd %02dh %02dmin", days, hours, minutes))
|
|
else
|
|
bossCooldownLabel:setText(string.format("%02dh %02dmin", hours, minutes))
|
|
end
|
|
bossCooldownLabel:setColor("#ff9854")
|
|
widget.type = "timed"
|
|
end
|
|
end
|
|
|
|
-- TODO: Implement tracker cooldown functionality when game_trackers module is available
|
|
|
|
widget.tick = resttime
|
|
widget.name = info.name
|
|
widget.bossId = info.bossId
|
|
c = c + 1
|
|
BossCooldown.widgets[#BossCooldown.widgets + 1] = widget
|
|
end
|
|
|
|
contentsPanel.bosses:setHeight(40 + (35 * c))
|
|
end
|
|
|
|
function BossCooldown:setupCooldown(cooldown)
|
|
BossCooldown.cooldown = {}
|
|
|
|
for i, cooldownData in pairs(cooldown) do
|
|
local raceData = g_things.getRaceData(cooldownData.bossRaceId)
|
|
|
|
local bossEntry = {
|
|
bossId = cooldownData.bossRaceId,
|
|
cooldown = cooldownData.cooldownTime,
|
|
name = raceData and raceData.name or "",
|
|
outfit = raceData and raceData.outfit or nil,
|
|
}
|
|
|
|
BossCooldown.cooldown[#BossCooldown.cooldown + 1] = bossEntry
|
|
end
|
|
|
|
BossCooldown.widgets = {}
|
|
BossCooldown:updateWindow()
|
|
end
|
|
|
|
function checkBossSearch(text)
|
|
if not text or #text <= 1 then
|
|
BossCooldown.search = ""
|
|
else
|
|
BossCooldown.search = text
|
|
end
|
|
|
|
-- Immediately apply the search filter
|
|
if BossCooldown.window and BossCooldown.window.contentsPanel and BossCooldown.window.contentsPanel.bosses then
|
|
local layout = BossCooldown.window.contentsPanel.bosses:getLayout()
|
|
if layout then
|
|
layout:enableUpdates()
|
|
for _, widget in ipairs(BossCooldown.widgets) do
|
|
if BossCooldown.search == "" or string.find(widget.name:lower(), BossCooldown.search:lower(), 1, true) then
|
|
widget:setVisible(true)
|
|
else
|
|
widget:setVisible(false)
|
|
end
|
|
end
|
|
layout:disableUpdates()
|
|
layout:update()
|
|
end
|
|
end
|
|
end
|
|
|
|
function onBossSearchFocusChange(widget, focused)
|
|
if focused then
|
|
-- When gaining focus, unbind movement keys
|
|
local gameWalk = modules.game_walk
|
|
if gameWalk then
|
|
gameWalk.unbindWalkKey("W")
|
|
gameWalk.unbindWalkKey("D")
|
|
gameWalk.unbindWalkKey("S")
|
|
gameWalk.unbindWalkKey("A")
|
|
gameWalk.unbindWalkKey("E")
|
|
gameWalk.unbindWalkKey("Q")
|
|
gameWalk.unbindWalkKey("C")
|
|
gameWalk.unbindWalkKey("Z")
|
|
gameWalk.unbindTurnKey("Control+W")
|
|
gameWalk.unbindTurnKey("Control+D")
|
|
gameWalk.unbindTurnKey("Control+S")
|
|
gameWalk.unbindTurnKey("Control+A")
|
|
end
|
|
else
|
|
-- When losing focus, bind movement keys back
|
|
local gameWalk = modules.game_walk
|
|
if gameWalk then
|
|
gameWalk.bindWalkKey("W", North)
|
|
gameWalk.bindWalkKey("D", East)
|
|
gameWalk.bindWalkKey("S", South)
|
|
gameWalk.bindWalkKey("A", West)
|
|
gameWalk.bindWalkKey("E", NorthEast)
|
|
gameWalk.bindWalkKey("Q", NorthWest)
|
|
gameWalk.bindWalkKey("C", SouthEast)
|
|
gameWalk.bindWalkKey("Z", SouthWest)
|
|
gameWalk.bindTurnKey("Control+W", North)
|
|
gameWalk.bindTurnKey("Control+D", East)
|
|
gameWalk.bindTurnKey("Control+S", South)
|
|
gameWalk.bindTurnKey("Control+A", West)
|
|
end
|
|
end
|
|
end
|
|
function clearSearch()
|
|
BossCooldown.search = ""
|
|
BossCooldown.window.contentsPanel.searchText:setText("", false)
|
|
end
|
|
|
|
function onBossExtra(mousePosition)
|
|
if cancelNextRelease then
|
|
cancelNextRelease = false
|
|
return false
|
|
end
|
|
|
|
local player = g_game.getLocalPlayer()
|
|
if not player then
|
|
return false
|
|
end
|
|
|
|
local sortByCooldown = BossCooldown.sort == 0
|
|
local sortByName = BossCooldown.sort == 1
|
|
|
|
local menu = g_ui.createWidget("PopupMenu")
|
|
menu:setGameMenu(true)
|
|
menu:addCheckBox(tr("sort by cooldown"), sortByCooldown, function()
|
|
BossCooldown.sort = 0
|
|
BossCooldown:updateWindow()
|
|
end)
|
|
menu:addCheckBox(tr("sort by name"), sortByName, function()
|
|
BossCooldown.sort = 1
|
|
BossCooldown:updateWindow()
|
|
end)
|
|
|
|
menu:display(mousePosition)
|
|
return true
|
|
end
|
|
|
|
function toggleBossCDFocus(visible)
|
|
local widget = BossCooldown.window:recursiveGetChildById("clickablePanel")
|
|
if widget and visible then
|
|
widget:setPhantom(true)
|
|
elseif widget then
|
|
widget:setPhantom(false)
|
|
widget.onClick = function()
|
|
modules.game_interface.toggleInternalFocus()
|
|
toggleBossCDFocus(not visible)
|
|
end
|
|
end
|
|
|
|
modules.game_interface.toggleFocus(visible, "bosscooldown")
|
|
|
|
if visible then
|
|
local text = BossCooldown.window.contentsPanel.searchText
|
|
text:focus()
|
|
-- Immediately unbind movement keys when search becomes active
|
|
local gameWalk = modules.game_walk
|
|
if gameWalk then
|
|
gameWalk.unbindWalkKey("W")
|
|
gameWalk.unbindWalkKey("D")
|
|
gameWalk.unbindWalkKey("S")
|
|
gameWalk.unbindWalkKey("A")
|
|
gameWalk.unbindWalkKey("E")
|
|
gameWalk.unbindWalkKey("Q")
|
|
gameWalk.unbindWalkKey("C")
|
|
gameWalk.unbindWalkKey("Z")
|
|
gameWalk.unbindTurnKey("Control+W")
|
|
gameWalk.unbindTurnKey("Control+D")
|
|
gameWalk.unbindTurnKey("Control+S")
|
|
gameWalk.unbindTurnKey("Control+A")
|
|
end
|
|
else
|
|
-- Re-bind movement keys when search becomes inactive
|
|
local gameWalk = modules.game_walk
|
|
if gameWalk then
|
|
gameWalk.bindWalkKey("W", North)
|
|
gameWalk.bindWalkKey("D", East)
|
|
gameWalk.bindWalkKey("S", South)
|
|
gameWalk.bindWalkKey("A", West)
|
|
gameWalk.bindWalkKey("E", NorthEast)
|
|
gameWalk.bindWalkKey("Q", NorthWest)
|
|
gameWalk.bindWalkKey("C", SouthEast)
|
|
gameWalk.bindWalkKey("Z", SouthWest)
|
|
gameWalk.bindTurnKey("Control+W", North)
|
|
gameWalk.bindTurnKey("Control+D", East)
|
|
gameWalk.bindTurnKey("Control+S", South)
|
|
gameWalk.bindTurnKey("Control+A", West)
|
|
end
|
|
BossCooldown.window:setBorderWidth(0)
|
|
end
|
|
end
|
|
|
|
function updateBossFocus()
|
|
scheduleEvent(function()
|
|
if not BossCooldown.window then
|
|
return
|
|
end
|
|
local scrollBar = BossCooldown.window:recursiveGetChildById("miniwindowScrollBar")
|
|
if not scrollBar then
|
|
return
|
|
end
|
|
scrollBar:setValue(1)
|
|
end, 1)
|
|
end
|
|
|
|
function BossCooldown:hasCooldown(raceId)
|
|
for _, info in ipairs(BossCooldown.cooldown) do
|
|
if info.bossId == raceId then
|
|
return info.name, info.cooldown
|
|
end
|
|
end
|
|
return "", -1
|
|
end
|
|
|
|
function BossCooldown:getCooldown(raceId)
|
|
for _, widget in pairs(BossCooldown.widgets) do
|
|
if widget.bossId and widget.bossId == raceId and widget.cooldown and widget.cooldown > os.time() then
|
|
local bossCooldownLabel = widget:recursiveGetChildById("bossNCooldown")
|
|
if bossCooldownLabel then
|
|
return bossCooldownLabel:getText()
|
|
end
|
|
end
|
|
end
|
|
|
|
return ""
|
|
end
|