otclient-redemption/modules/game_taskboard/controller/modal_preferred.lua
kokekanon 4a87cebb11
feat: protocol 15.24 packets and features (#1604)
New Features:
- Task Board (Bounty, Weekly, Shop) with Preferred & Soulseal modals, Kill Tracker, Offline Training dialog, Vocation tutorial, infobanner notifications, redesigned NPC chat/trade, and multi-slot action bar.

Gameplay Enhancements:
- Expanded Cyclopedia stats, talisman upgrades, weekly rewards/progress, soulseal fights, taskboard shop/offers and offline training actions.

UI / Graphics:
- Per-source spell-effect opacity sliders, status icon bar, HUD/scroll improvements, many new templates/styles, screenshot UI updates, and small label/icon fixes.

Bug Fixes:
- Miscellaneous UI behavior, layout and robustness improvements.
2026-05-20 11:26:11 -03:00

672 lines
23 KiB
Lua

-- Preferred monsters modal - methods added to TaskBoardController
-- Uses openModal/closeModal to open a secondary HTML window without
-- affecting self.ui which points to taskboard.html
local cachedRemoveCost = 0
local cachedAllRaceIds = {}
local cachedRawSlots = {}
local MONSTER_ROW_HEIGHT_PX = 44
local MONSTER_OVERSCAN_ROWS = 2
local MONSTER_BIND_RETRY_DELAY_MS = 25
local MONSTER_BIND_MAX_RETRIES = 20
local MONSTER_SPACER_SEGMENT_PX = 20000
local MONSTER_ROW_HEIGHT_MIN_PX = 36
local MONSTER_ROW_HEIGHT_MAX_PX = 72
-- Split a large pixel value across three spacer segments (max 20000px each).
-- Needed because OTClient widget heights are capped; three divs simulate tall spacers.
local function splitSpacerPx(totalPx)
local px = math.max(0, tonumber(totalPx) or 0)
local a = math.min(px, MONSTER_SPACER_SEGMENT_PX)
px = px - a
local b = math.min(px, MONSTER_SPACER_SEGMENT_PX)
local c = px - b
return a, b, c
end
local function clampRowHeightPx(value)
local px = math.floor((tonumber(value) or MONSTER_ROW_HEIGHT_PX) + 0.5)
return math.max(MONSTER_ROW_HEIGHT_MIN_PX, math.min(MONSTER_ROW_HEIGHT_MAX_PX, px))
end
local function getPreferredUnlockPrice(slotNum, serverPrice)
local slot = tonumber(slotNum) or 0
local price = tonumber(serverPrice) or 0
if price > 0 then
return price
end
return tonumber(PREFERRED_UNLOCK_COST and PREFERRED_UNLOCK_COST[slot]) or 0
end
local function getPreferredActionCost()
local assignCost = tonumber(PREFERRED_SLOT_ASSIGN_COST) or 0
if assignCost > 0 then
return assignCost
end
return tonumber(cachedRemoveCost) or 0
end
local function updateCachedSlot(slotNum, key, value)
local slot = tonumber(slotNum) or 0
if slot <= 0 then
return
end
for index, slotData in ipairs(cachedRawSlots) do
if (tonumber(slotData.slot) or index) == slot then
slotData[key] = value
break
end
end
end
-- Modal open / close
function TaskBoardController:showPreferred()
if self.preferredModal then
return
end
self.preferredModal = self:openModal('template/html/modal_preferred.html', {
mode = 'html'
})
addEvent(function()
if not self.preferredModal or not self.preferredModal.ui then
return
end
self:ensurePreferredMonsterScrollBound()
self:resetPreferredMonsterScroll()
end)
g_game.bountyPreferredAction(BOUNTY_PREF_ACTION_REQUEST, 0, 0)
end
function TaskBoardController:hidePreferred()
if self._preferredBindRetryEvent then
removeEvent(self._preferredBindRetryEvent)
self._preferredBindRetryEvent = nil
end
local scrollWidget = self:getPreferredMonsterScrollWidget()
if scrollWidget and scrollWidget.verticalScrollBar then
local sb = scrollWidget.verticalScrollBar
if sb._preferredValueHook then
disconnect(sb, 'onValueChange', sb._preferredValueHook)
sb._preferredValueHook = nil
end
end
if self.preferredModal then
self:closeModal(self.preferredModal)
self.preferredModal = nil
end
self.allAvailableMonsters = {}
self.visibleMonsters = {}
self.availableMonsters = {}
self.monsterTopSpacerPxA = 0
self.monsterTopSpacerPxB = 0
self.monsterTopSpacerPxC = 0
self.monsterBottomSpacerPxA = 0
self.monsterBottomSpacerPxB = 0
self.monsterBottomSpacerPxC = 0
self.preferredSlots = {}
self.selectedRaceId = 0
self.searchText = ""
self._preferredViewportStart = 0
self._preferredViewportEnd = 0
self._preferredViewportRowHeight = 0
self._preferredApplyingScrollSnap = false
self._preferredViewportRefreshing = false
self._preferredRowHeightPx = MONSTER_ROW_HEIGHT_PX
self._preferredRowHeightMeasured = false
cachedRemoveCost = 0
cachedAllRaceIds = {}
cachedRawSlots = {}
end
-- Server data
function TaskBoardController:onPreferredServerData(slots, removeCost, availableRaceIds)
cachedRawSlots = slots or {}
cachedRemoveCost = tonumber(removeCost) or 0
cachedAllRaceIds = availableRaceIds or {}
self:rebuildPreferredSlots()
self:rebuildAvailableMonsters()
end
-- Slots
function TaskBoardController:rebuildPreferredSlots()
local result = self.preferredSlots or {}
table.clear(result)
local actionCost = getPreferredActionCost()
local selectedRaceId = tonumber(self.selectedRaceId) or 0
local player = g_game.getLocalPlayer()
local balance = player and player:getResourceBalance(ResourceTypes.BOUNTY_POINTS) or 0
for index, slotData in ipairs(cachedRawSlots) do
local slotNum = tonumber(slotData.slot) or index
local unlocked = index == 1 or tonumber(slotData.locked) ~= 1
local preferredId = tonumber(slotData.preferred) or 0
local unwantedId = tonumber(slotData.unwanted) or 0
local lockPrice = getPreferredUnlockPrice(slotNum, slotData.price)
local prefName, prefOutfit, unwName, unwOutfit
if preferredId > 0 then
local rd = g_things.getRaceData(preferredId)
prefName = rd and rd.name and rd.name:capitalize() or 'Unknown'
prefOutfit = rd and rd.outfit or nil
else
prefName = 'Empty'
end
if unwantedId > 0 then
local rd = g_things.getRaceData(unwantedId)
unwName = rd and rd.name and rd.name:capitalize() or 'Unknown'
unwOutfit = rd and rd.outfit or nil
else
unwName = 'Empty'
end
local hasPreferred = preferredId > 0
local hasUnwanted = unwantedId > 0
local canAssign = selectedRaceId ~= 0
table.insert(result, {
slotNum = slotNum,
slotTitle = slotNum == 1 and 'Main Slot' or 'Additional Slots',
unlocked = unlocked and 1 or 0,
locked = unlocked and 0 or 1,
lockPrice = lockPrice,
canUnlock = lockPrice > 0 and balance >= lockPrice,
preferredId = preferredId,
preferredName = prefName,
preferredOutfit = prefOutfit,
showPreferredCreature = hasPreferred and 1 or 0,
showPreferredPlaceholder = hasPreferred and 0 or 1,
showPreferredAssign = hasPreferred and 0 or 1,
showPreferredClear = hasPreferred and 1 or 0,
unwantedId = unwantedId,
unwantedName = unwName,
unwantedOutfit = unwOutfit,
showUnwantedCreature = hasUnwanted and 1 or 0,
showUnwantedPlaceholder = hasUnwanted and 0 or 1,
showUnwantedAssign = hasUnwanted and 0 or 1,
showUnwantedClear = hasUnwanted and 1 or 0,
actionCost = actionCost,
preferredButtonText = hasPreferred and 'Clear' or 'Assign',
unwantedButtonText = hasUnwanted and 'Clear' or 'Assign',
preferredAction = hasPreferred and 'clearPreferred' or 'assignPreferred',
unwantedAction = hasUnwanted and 'clearUnwanted' or 'assignUnwanted',
canPreferredAct = hasPreferred or canAssign,
canUnwantedAct = hasUnwanted or canAssign,
clearCost = actionCost,
canClear = balance >= actionCost
})
end
self.preferredSlots = result
end
-- Monster list
function TaskBoardController:getPreferredMonsterScrollWidget()
if not self.preferredModal or not self.preferredModal.ui then
return nil
end
return self.preferredModal.ui:querySelector("#monsterListScroll")
end
function TaskBoardController:onPreferredMonsterScrollChange(widget, virtualOffset)
if self._preferredApplyingScrollSnap then
return
end
self:refreshMonsterViewport((virtualOffset and virtualOffset.y) or 0)
end
function TaskBoardController:bindPreferredMonsterScroll()
local scrollWidget = self:getPreferredMonsterScrollWidget()
if not scrollWidget then
return
end
if scrollWidget._preferredVirtualized then
return
end
scrollWidget._preferredVirtualized = true
scrollWidget._skipScrollLayoutRecalc = true
scrollWidget._preferredOriginalUpdateScrollBars = scrollWidget.updateScrollBars
scrollWidget.updateScrollBars = function()
end
-- Without AutoFocusNone, OTClient calls focusPreviousChild() automatically when a
-- focused row widget is destroyed during virtual-scroll re-renders (uiwidget.cpp
-- removeChild), shifting focus to an adjacent row without any user interaction.
scrollWidget:setAutoFocusPolicy(AutoFocusNone)
scrollWidget.onScrollChange = function(widget, virtualOffset)
self:onPreferredMonsterScrollChange(widget, virtualOffset)
end
if scrollWidget.verticalScrollBar then
local sb = scrollWidget.verticalScrollBar
sb:setStep(self:getPreferredMonsterRowHeight(scrollWidget))
end
end
function TaskBoardController:ensurePreferredMonsterScrollBound(retryCount)
local scrollWidget = self:getPreferredMonsterScrollWidget()
if scrollWidget and scrollWidget.verticalScrollBar then
self:bindPreferredMonsterScroll()
self:refreshMonsterViewport(tonumber(scrollWidget.verticalScrollBar:getValue()) or 0)
return true
end
retryCount = tonumber(retryCount) or 0
if retryCount >= MONSTER_BIND_MAX_RETRIES then
g_logger.warning('[taskboard/preferred] failed to bind monster scroll: vertical scrollbar not ready')
return false
end
if self._preferredBindRetryEvent then
removeEvent(self._preferredBindRetryEvent)
self._preferredBindRetryEvent = nil
end
self._preferredBindRetryEvent = scheduleEvent(function()
self._preferredBindRetryEvent = nil
if self.preferredModal then
self:ensurePreferredMonsterScrollBound(retryCount + 1)
end
end, MONSTER_BIND_RETRY_DELAY_MS)
return false
end
function TaskBoardController:updatePreferredMonsterScrollRange(totalRows, viewportHeight, rowHeight)
local scrollWidget = self:getPreferredMonsterScrollWidget()
rowHeight = math.max(1, math.floor((tonumber(rowHeight) or self:getPreferredMonsterRowHeight(scrollWidget)) + 0.5))
local visibleRows = math.max(1, math.floor(viewportHeight / rowHeight))
local maxScroll = math.max((math.max(1, totalRows - visibleRows + 1) - 1) * rowHeight, 0)
if scrollWidget and scrollWidget.verticalScrollBar then
local sb = scrollWidget.verticalScrollBar
sb:setMinimum(0)
sb:setMaximum(maxScroll)
sb:setStep(rowHeight)
if (tonumber(sb:getValue()) or 0) > maxScroll then
self._preferredApplyingScrollSnap = true
sb:setValue(maxScroll)
self._preferredApplyingScrollSnap = false
end
end
return maxScroll, visibleRows
end
function TaskBoardController:getPreferredMonsterRowHeight(scrollWidget)
local measured = clampRowHeightPx(self._preferredRowHeightPx)
if self._preferredRowHeightMeasured then
return measured
end
scrollWidget = scrollWidget or self:getPreferredMonsterScrollWidget()
if not scrollWidget or not scrollWidget.querySelectorAll then
return measured
end
local rows = scrollWidget:querySelectorAll(".monsterRow")
local h = 0
if rows and #rows >= 2 then
local delta = math.abs((tonumber(rows[#rows]:getY()) or 0) - (tonumber(rows[1]:getY()) or 0))
local pitch = delta / (#rows - 1)
if pitch > 0 then
h = pitch
end
elseif rows and #rows == 1 then
h = tonumber(rows[1]:getHeight()) or 0
if rows[1].getMarginTop and rows[1].getMarginBottom then
h = h + (tonumber(rows[1]:getMarginTop()) or 0) + (tonumber(rows[1]:getMarginBottom()) or 0)
end
end
if h > 0 then
local rounded = math.floor(h + 0.5)
if rounded >= MONSTER_ROW_HEIGHT_MIN_PX and rounded <= MONSTER_ROW_HEIGHT_MAX_PX then
self._preferredRowHeightPx = rounded
self._preferredRowHeightMeasured = true
return rounded
end
end
return measured
end
function TaskBoardController:resetPreferredMonsterScroll()
local scrollWidget = self:getPreferredMonsterScrollWidget()
if not scrollWidget or not scrollWidget.verticalScrollBar then
self:ensurePreferredMonsterScrollBound()
scrollWidget = self:getPreferredMonsterScrollWidget()
end
if scrollWidget and scrollWidget.verticalScrollBar then
self._preferredApplyingScrollSnap = true
scrollWidget.verticalScrollBar:setValue(0)
self._preferredApplyingScrollSnap = false
elseif scrollWidget then
scrollWidget:setVirtualOffset({
x = 0,
y = 0
})
end
self:refreshMonsterViewport(0)
end
function TaskBoardController:refreshMonsterViewport(scrollValue)
if self._preferredViewportRefreshing then
return
end
self._preferredViewportRefreshing = true
local list = self.allAvailableMonsters or {}
local total = #list
local viewportHeight = 400
local scrollWidget = self:getPreferredMonsterScrollWidget()
local rowHeight = self:getPreferredMonsterRowHeight(scrollWidget)
if scrollWidget and scrollWidget.getPaddingRect then
local paddingRect = scrollWidget:getPaddingRect()
if paddingRect and paddingRect.height and paddingRect.height > 0 then
viewportHeight = paddingRect.height
end
end
if total == 0 then
local visible = self.visibleMonsters or {}
table.clear(visible)
self.visibleMonsters = visible
self.availableMonsters = self.visibleMonsters
self.monsterTopSpacerPxA, self.monsterTopSpacerPxB, self.monsterTopSpacerPxC = 0, 0, 0
self.monsterBottomSpacerPxA, self.monsterBottomSpacerPxB, self.monsterBottomSpacerPxC = 0, 0, 0
self._preferredViewportStart = 0
self._preferredViewportEnd = 0
self._preferredViewportRowHeight = rowHeight
self:updatePreferredMonsterScrollRange(0, viewportHeight, rowHeight)
self._preferredViewportRefreshing = false
return
end
local y = tonumber(scrollValue) or 0
local maxScroll, visibleRows = self:updatePreferredMonsterScrollRange(total, viewportHeight, rowHeight)
y = math.max(0, math.min(y, maxScroll))
local snappedY = math.min(math.floor(y / rowHeight) * rowHeight, maxScroll)
if scrollWidget and scrollWidget.verticalScrollBar and not self._preferredApplyingScrollSnap then
local currentY = scrollWidget.verticalScrollBar:getValue()
if currentY ~= snappedY then
self._preferredApplyingScrollSnap = true
scrollWidget.verticalScrollBar:setValue(snappedY)
self._preferredApplyingScrollSnap = false
y = snappedY
else
y = currentY
end
end
local firstVisibleIndex = math.min(math.floor(y / rowHeight) + 1, math.max(1, total - visibleRows + 1))
local startIndex = math.max(1, firstVisibleIndex - MONSTER_OVERSCAN_ROWS)
local endIndex = math.min(total, firstVisibleIndex + visibleRows + MONSTER_OVERSCAN_ROWS - 1)
if self._preferredViewportStart == startIndex and self._preferredViewportEnd == endIndex and
self._preferredViewportRowHeight == rowHeight then
self._preferredViewportRefreshing = false
return
end
local visible = self.visibleMonsters or {}
table.clear(visible)
for i = startIndex, endIndex do
visible[#visible + 1] = list[i]
end
self.visibleMonsters = visible
self.availableMonsters = visible
self.monsterTopSpacerPxA, self.monsterTopSpacerPxB, self.monsterTopSpacerPxC =
splitSpacerPx((startIndex - 1) * rowHeight)
self.monsterBottomSpacerPxA, self.monsterBottomSpacerPxB, self.monsterBottomSpacerPxC = splitSpacerPx((total -
endIndex) *
rowHeight)
self._preferredViewportStart = startIndex
self._preferredViewportEnd = endIndex
self._preferredViewportRowHeight = rowHeight
self._preferredViewportRefreshing = false
end
function TaskBoardController:rebuildAvailableMonsters()
local usedIds = {}
for _, slotData in ipairs(cachedRawSlots) do
local prefId = tonumber(slotData.preferred) or 0
local unwId = tonumber(slotData.unwanted) or 0
if prefId > 0 then
usedIds[prefId] = true
end
if unwId > 0 then
usedIds[unwId] = true
end
end
local filter = (self.searchText or ""):lower()
local sorted = {}
for _, raceId in ipairs(cachedAllRaceIds) do
if not usedIds[raceId] then
local rd = g_things.getRaceData(raceId)
local name = rd and rd.name or 'Unknown'
name = name:capitalize()
if filter == '' or name:lower():find(filter, 1, true) then
table.insert(sorted, {
raceId = raceId,
name = name,
outfit = rd and rd.outfit or nil
})
end
end
end
table.sort(sorted, function(a, b)
return a.name < b.name
end)
self.allAvailableMonsters = sorted
self._preferredViewportStart = 0
self._preferredViewportEnd = 0
self._preferredViewportRowHeight = 0
self._preferredRowHeightMeasured = false
self._preferredRowHeightPx = MONSTER_ROW_HEIGHT_PX
self:ensurePreferredMonsterScrollBound()
self:resetPreferredMonsterScroll()
end
-- HTML event handlers
function TaskBoardController:filterMonsters(text)
self.searchText = text or ""
self:rebuildAvailableMonsters()
end
function TaskBoardController:selectMonster(raceId)
self.selectedRaceId = tonumber(raceId) or 0
self:rebuildPreferredSlots()
end
function TaskBoardController:isRaceAssigned(raceId, targetSlotNum, targetField)
local targetRaceId = tonumber(raceId) or 0
local targetSlot = tonumber(targetSlotNum) or 0
if targetRaceId == 0 then
return false
end
for index, slotData in ipairs(cachedRawSlots) do
local slotNum = tonumber(slotData.slot) or index
local preferredId = tonumber(slotData.preferred) or 0
local unwantedId = tonumber(slotData.unwanted) or 0
if preferredId == targetRaceId and not (slotNum == targetSlot and targetField == 'preferred') then
return true
end
if unwantedId == targetRaceId and not (slotNum == targetSlot and targetField == 'unwanted') then
return true
end
end
return false
end
function TaskBoardController:onPreferredSlotAction(slotNum, action, cost)
if action == 'assignPreferred' then
self:assignPreferred(slotNum)
elseif action == 'assignUnwanted' then
self:assignUnwanted(slotNum)
elseif action == 'clearPreferred' then
self:clearPreferred(slotNum, cost)
elseif action == 'clearUnwanted' then
self:clearUnwanted(slotNum, cost)
end
end
function TaskBoardController:assignPreferred(slotNum)
if self.selectedRaceId == 0 then
return
end
local parsedSlot = tonumber(slotNum) or 0
if parsedSlot == 0 then
return
end
if self:isRaceAssigned(self.selectedRaceId, parsedSlot, 'preferred') then
return
end
g_game.bountyPreferredAction(BOUNTY_PREF_ACTION_SET_PREFERRED, parsedSlot, self.selectedRaceId)
updateCachedSlot(parsedSlot, 'preferred', self.selectedRaceId)
self.selectedRaceId = 0
self:rebuildPreferredSlots()
self:rebuildAvailableMonsters()
end
function TaskBoardController:assignUnwanted(slotNum)
if self.selectedRaceId == 0 then
return
end
local parsedSlot = tonumber(slotNum) or 0
if parsedSlot == 0 then
return
end
if self:isRaceAssigned(self.selectedRaceId, parsedSlot, 'unwanted') then
return
end
g_game.bountyPreferredAction(BOUNTY_PREF_ACTION_SET_UNWANTED, parsedSlot, self.selectedRaceId)
updateCachedSlot(parsedSlot, 'unwanted', self.selectedRaceId)
self.selectedRaceId = 0
self:rebuildPreferredSlots()
self:rebuildAvailableMonsters()
end
function TaskBoardController:clearPreferred(slotNum, cost)
local parsedSlot = tonumber(slotNum) or 0
if parsedSlot == 0 then
return
end
local parsedCost = tonumber(cost) or getPreferredActionCost()
local msgBox
local function yes()
if msgBox then
msgBox:destroy();
msgBox = nil
end
g_game.bountyPreferredAction(BOUNTY_PREF_ACTION_REMOVE_PREFERRED, parsedSlot, 0)
updateCachedSlot(parsedSlot, 'preferred', 0)
self:rebuildPreferredSlots()
self:rebuildAvailableMonsters()
end
local function cancel()
if msgBox then
msgBox:destroy();
msgBox = nil
end
end
msgBox = displayGeneralBox(tr('Clear Preferred'),
tr('Remove preferred monster? This costs %d Bounty Task Points.', parsedCost), {{
text = tr('Yes'),
callback = yes
}, {
text = tr('Cancel'),
callback = cancel
}}, yes, cancel)
end
function TaskBoardController:clearUnwanted(slotNum, cost)
local parsedSlot = tonumber(slotNum) or 0
if parsedSlot == 0 then
return
end
local parsedCost = tonumber(cost) or getPreferredActionCost()
local msgBox
local function yes()
if msgBox then
msgBox:destroy();
msgBox = nil
end
g_game.bountyPreferredAction(BOUNTY_PREF_ACTION_REMOVE_UNWANTED, parsedSlot, 0)
updateCachedSlot(parsedSlot, 'unwanted', 0)
self:rebuildPreferredSlots()
self:rebuildAvailableMonsters()
end
local function cancel()
if msgBox then
msgBox:destroy();
msgBox = nil
end
end
msgBox = displayGeneralBox(tr('Clear Unwanted'),
tr('Remove unwanted monster? This costs %d Bounty Task Points.', parsedCost), {{
text = tr('Yes'),
callback = yes
}, {
text = tr('Cancel'),
callback = cancel
}}, yes, cancel)
end
function TaskBoardController:buyPreferredSlot(slotNum, price)
local parsedSlot = tonumber(slotNum) or 0
if parsedSlot == 0 then
return
end
local parsedPrice = tonumber(price) or getPreferredUnlockPrice(parsedSlot, 0)
local msgBox
local function yes()
if msgBox then
msgBox:destroy();
msgBox = nil
end
g_game.bountyPreferredAction(BOUNTY_PREF_ACTION_BUY_SLOT, parsedSlot, 0)
end
local function cancel()
if msgBox then
msgBox:destroy();
msgBox = nil
end
end
msgBox = displayGeneralBox(tr('Unlock Slot'),
tr('Unlock this preferred slot for %d Bounty Task Points?', parsedPrice), {{
text = tr('Yes'),
callback = yes
}, {
text = tr('Cancel'),
callback = cancel
}}, yes, cancel)
end