4fa43a4349
Scaling WorldMapDetailFrame and WorldMapButton grew every marker with the map, and the explored areas (WorldMapOverlay<n>) overflowed the window. The two frames now get width and height x zoom: the client places markers at their map fraction x that width, so they follow the map at their own size (markers placed once are moved by the zoom ratio). Tiles and explored areas are laid out x zoom and cropped to the view. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
477 lines
15 KiB
Lua
477 lines
15 KiB
Lua
-- World map zoom: the mouse wheel enlarges the map toward the cursor (x 1.25 per step, 1 to 4)
|
|
-- and a left-button drag pans the zoomed map; a click without drag keeps its meaning.
|
|
-- Camelot zooms through map art layers (mapcanvas_scrollcontainermixin.lua), which 3.3.5
|
|
-- lacks. The zoom returns to 1 when the map changes, changes mode or closes.
|
|
-- The map is ENLARGED, not scaled: WorldMapDetailFrame and WorldMapButton get their width and
|
|
-- height x zoom at their own scale. The client places every marker at its map fraction x
|
|
-- their width, so the markers move with the map and keep their size, as in camelot. The art
|
|
-- (tiles, explored areas) has fixed sizes in the client: it is laid out and cropped here.
|
|
-- Units: "map units" are those of the whole map (1002 x 668); the frames' are map units x zoom.
|
|
|
|
local W = ForeverUI.WorldMap
|
|
local Z = { z = 1, cx = 0.5, cy = 0.5, active = false, STEP = 1.25, MAX = 4, THRESHOLD = 4 }
|
|
W.zoom = Z
|
|
|
|
local CARD_W, CARD_H = 1002, 668
|
|
local TILE, COLUMNS, ROWS = 256, 4, 3
|
|
|
|
local function tile(k)
|
|
return _G["WorldMapDetailTile" .. k]
|
|
end
|
|
|
|
local function ready()
|
|
return W.built and W.canvas and WorldMapDetailFrame and WorldMapButton and WORLDMAP_SETTINGS
|
|
end
|
|
|
|
-- Screen pixels per map unit
|
|
local function pixelsPerUnit()
|
|
return WorldMapDetailFrame:GetEffectiveScale() * Z.z
|
|
end
|
|
|
|
-- The view: the canvas rectangle in map units, origin at the map's top left
|
|
local function viewSize()
|
|
local c, d = W.canvas, WorldMapDetailFrame
|
|
local k = c:GetEffectiveScale() / d:GetEffectiveScale() / Z.z
|
|
return c:GetWidth() * k, c:GetHeight() * k
|
|
end
|
|
|
|
local function view()
|
|
local vw, vh = viewSize()
|
|
local x, y = Z.cx * CARD_W, Z.cy * CARD_H
|
|
return x - vw / 2, y - vh / 2, x + vw / 2, y + vh / 2
|
|
end
|
|
Z.view = view
|
|
|
|
-- Keep the center where the view stays inside the map
|
|
local function clampView()
|
|
local vw, vh = viewSize()
|
|
local halfX, halfY = vw / 2 / CARD_W, vh / 2 / CARD_H
|
|
if halfX >= 0.5 then Z.cx = 0.5 else Z.cx = math.max(halfX, math.min(1 - halfX, Z.cx)) end
|
|
if halfY >= 0.5 then Z.cy = 0.5 else Z.cy = math.max(halfY, math.min(1 - halfY, Z.cy)) end
|
|
end
|
|
|
|
-- ------------------------------------------------------------------ Art
|
|
|
|
-- 3.3.5 clips nothing outside a ScrollFrame and client frames are never reparented, so each
|
|
-- piece of art is cropped to the view: its visible part, at its place x zoom, with matching
|
|
-- tex coords. x, y, w, h: the piece in map units; u0, u1, v0, v1: its tex coords.
|
|
local function cropRegion(t, x, y, w, h, u0, u1, v0, v1, x0, y0, x1, y1)
|
|
local d, z = WorldMapDetailFrame, Z.z
|
|
local ix0, iy0 = math.max(x, x0), math.max(y, y0)
|
|
local ix1 = math.min(x + w, CARD_W, x1)
|
|
local iy1 = math.min(y + h, CARD_H, y1)
|
|
t:ClearAllPoints()
|
|
if ix1 - ix0 < 0.01 or iy1 - iy0 < 0.01 then
|
|
t:SetPoint("TOPLEFT", d, "TOPLEFT", x * z, -y * z)
|
|
t:SetWidth(1)
|
|
t:SetHeight(1)
|
|
t:SetAlpha(0)
|
|
else
|
|
local du, dv = (u1 - u0) / w, (v1 - v0) / h
|
|
t:SetPoint("TOPLEFT", d, "TOPLEFT", ix0 * z, -iy0 * z)
|
|
t:SetWidth((ix1 - ix0) * z)
|
|
t:SetHeight((iy1 - iy0) * z)
|
|
t:SetTexCoord(u0 + (ix0 - x) * du, u0 + (ix1 - x) * du, v0 + (iy0 - y) * dv, v0 + (iy1 - y) * dv)
|
|
t:SetAlpha(1)
|
|
end
|
|
end
|
|
|
|
-- Explored areas (WorldMapOverlay<n>, laid out by WorldMapFrame_Update in map units from the
|
|
-- map's top left): each piece's place, size and tex coords, read after the client sets them
|
|
local function overlay(i)
|
|
return _G["WorldMapOverlay" .. i]
|
|
end
|
|
|
|
local function recordOverlays()
|
|
for i = 1, NUM_WORLDMAP_OVERLAYS or 0 do
|
|
local o = overlay(i)
|
|
if o and o:IsShown() then
|
|
local _, _, _, x, y = o:GetPoint(1)
|
|
local ulx, uly, llx, lly, urx = o:GetTexCoord()
|
|
o.foreverZoomArt = { x or 0, -(y or 0), o:GetWidth(), o:GetHeight(),
|
|
ulx or 0, urx or 1, uly or 0, lly or 1 }
|
|
elseif o then
|
|
o.foreverZoomArt = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
local function restoreOverlays()
|
|
for i = 1, NUM_WORLDMAP_OVERLAYS or 0 do
|
|
local o = overlay(i)
|
|
local a = o and o.foreverZoomArt
|
|
if a then
|
|
o:ClearAllPoints()
|
|
o:SetPoint("TOPLEFT", WorldMapDetailFrame, "TOPLEFT", a[1], -a[2])
|
|
o:SetWidth(a[3])
|
|
o:SetHeight(a[4])
|
|
o:SetTexCoord(a[5], a[6], a[7], a[8])
|
|
o:SetAlpha(1)
|
|
o.foreverZoomArt = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
local function cropTexture()
|
|
local x0, y0, x1, y1 = view()
|
|
for rowLine = 1, ROWS do
|
|
for column = 1, COLUMNS do
|
|
local t = tile((rowLine - 1) * COLUMNS + column)
|
|
if t then
|
|
cropRegion(t, (column - 1) * TILE, (rowLine - 1) * TILE, TILE, TILE, 0, 1, 0, 1,
|
|
x0, y0, x1, y1)
|
|
end
|
|
end
|
|
end
|
|
for i = 1, NUM_WORLDMAP_OVERLAYS or 0 do
|
|
local o = overlay(i)
|
|
local a = o and o.foreverZoomArt
|
|
if a and o:IsShown() then
|
|
cropRegion(o, a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8], x0, y0, x1, y1)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Tile anchors from WorldMapFrame.xml (541-636): the first at the corner, each right of the
|
|
-- previous one, each row under the first tile of the row above; then the small-window crop
|
|
-- (WorldMap.lua)
|
|
local function rechainTiles()
|
|
for k = 1, COLUMNS * ROWS do
|
|
local t = tile(k)
|
|
if t then
|
|
t:ClearAllPoints()
|
|
t:SetAlpha(1)
|
|
if k == 1 then
|
|
t:SetPoint("TOPLEFT", WorldMapDetailFrame, "TOPLEFT", 0, 0)
|
|
elseif (k - 1) % COLUMNS == 0 then
|
|
t:SetPoint("TOPLEFT", tile(k - COLUMNS), "BOTTOMLEFT", 0, 0)
|
|
else
|
|
t:SetPoint("TOPLEFT", tile(k - 1), "TOPRIGHT", 0, 0)
|
|
end
|
|
end
|
|
end
|
|
if W.cropTiles then W.cropTiles(true) end
|
|
end
|
|
|
|
-- --------------------------------------------------------------- Markers
|
|
|
|
-- A marker out of the view fades out and releases the mouse (invisible, it would still
|
|
-- steal hover and clicks from the quest log or the frame); back in view, it gets its alpha
|
|
-- and mouse back
|
|
local function turnOff(f)
|
|
f.foreverZoomAlpha = f:GetAlpha()
|
|
f.foreverZoomMouse = f.IsMouseEnabled and f:IsMouseEnabled() and true or false
|
|
f:SetAlpha(0)
|
|
if f.foreverZoomMouse then f:EnableMouse(false) end
|
|
end
|
|
|
|
local function restore(f)
|
|
f:SetAlpha(f.foreverZoomAlpha)
|
|
if f.foreverZoomMouse then f:EnableMouse(true) end
|
|
f.foreverZoomAlpha, f.foreverZoomMouse = nil, nil
|
|
end
|
|
|
|
-- Fade out the markers outside the view, restore those back inside
|
|
local function sortBy()
|
|
local c = W.canvas
|
|
local e = c:GetEffectiveScale()
|
|
local left, down, right, top = c:GetLeft(), c:GetBottom(), c:GetRight(), c:GetTop()
|
|
if not (left and down and right and top) then return end
|
|
left, down, right, top = left * e, down * e, right * e, top * e
|
|
for _, parent in ipairs({ WorldMapButton, WorldMapPOIFrame }) do
|
|
if parent then
|
|
for _, f in ipairs({ parent:GetChildren() }) do
|
|
if f ~= WorldMapFrameAreaFrame and f:IsShown() then
|
|
local x, y = f:GetCenter()
|
|
local fe = f:GetEffectiveScale()
|
|
local inside = not x or (x * fe >= left and x * fe <= right and y * fe >= down and y * fe <= top)
|
|
if inside then
|
|
if f.foreverZoomAlpha then restore(f) end
|
|
elseif not f.foreverZoomAlpha then
|
|
turnOff(f)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function restoreChildren()
|
|
for _, parent in ipairs({ WorldMapButton, WorldMapPOIFrame }) do
|
|
if parent then
|
|
for _, f in ipairs({ parent:GetChildren() }) do
|
|
if f.foreverZoomAlpha then restore(f) end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Markers placed once (landmarks, quest POIs...) keep the place the client gave them at the
|
|
-- previous zoom: their offsets from the map's top left go x new / old. Markers the client
|
|
-- places on each frame (units) already follow the width. foreverZoomSet remembers the
|
|
-- offsets written here, to tell them from offsets the client wrote since.
|
|
local function replaceMarkers(old, new)
|
|
if old == new then return end
|
|
local d, b, p = WorldMapDetailFrame, WorldMapButton, WorldMapPOIFrame
|
|
for _, parent in ipairs({ b, p, d }) do
|
|
if parent then
|
|
for _, f in ipairs({ parent:GetChildren() }) do
|
|
if f ~= b and f ~= p and f ~= WorldMapBlobFrame and f ~= WorldMapFrameAreaFrame
|
|
and f:GetNumPoints() == 1 then
|
|
local point, rel, relPoint, x, y = f:GetPoint(1)
|
|
if (rel == d or rel == b or rel == p) and relPoint == "TOPLEFT" and x and y then
|
|
local s = f.foreverZoomSet
|
|
local bx, by
|
|
if s and math.abs(s[1] - x) < 1e-3 and math.abs(s[2] - y) < 1e-3 then
|
|
bx, by = s[3], s[4]
|
|
else
|
|
bx, by = x / old, y / old
|
|
end
|
|
f:ClearAllPoints()
|
|
f:SetPoint(point, rel, relPoint, bx * new, by * new)
|
|
f.foreverZoomSet = { bx * new, by * new, bx, by }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ------------------------------------------------------------- Apply
|
|
|
|
-- Current map key: a map change resets the zoom to 1
|
|
function Z.mapKey()
|
|
return tostring(GetCurrentMapAreaID and GetCurrentMapAreaID()) .. ":" ..
|
|
tostring(GetCurrentMapDungeonLevel and GetCurrentMapDungeonLevel())
|
|
end
|
|
|
|
-- The enlarged frames: WorldMapDetailFrame and WorldMapButton (siblings, same scale and corner)
|
|
local function enlarge()
|
|
for f, s in pairs(Z.sizes) do
|
|
f:SetWidth(s[1] * Z.z)
|
|
f:SetHeight(s[2] * Z.z)
|
|
end
|
|
end
|
|
|
|
-- The quest blob (WorldMapBlobFrame) is drawn by the engine in one piece and would overflow,
|
|
-- so it hides while zoomed; WorldMapButton's hit rect is limited to the view.
|
|
-- old: the zoom before this change (markers placed once follow it)
|
|
function Z.apply(old)
|
|
local d = WorldMapDetailFrame
|
|
if not Z.active then
|
|
Z.active = true
|
|
Z.points = {}
|
|
for i = 1, d:GetNumPoints() do
|
|
Z.points[i] = { d:GetPoint(i) }
|
|
end
|
|
Z.sizes = {
|
|
[d] = { d:GetWidth(), d:GetHeight() },
|
|
[WorldMapButton] = { WorldMapButton:GetWidth(), WorldMapButton:GetHeight() },
|
|
}
|
|
Z.key = Z.mapKey()
|
|
recordOverlays()
|
|
old = 1
|
|
end
|
|
old = old or Z.z
|
|
local z = Z.z
|
|
enlarge()
|
|
clampView()
|
|
d:ClearAllPoints()
|
|
d:SetPoint("CENTER", W.canvas, "CENTER", (0.5 - Z.cx) * CARD_W * z, (Z.cy - 0.5) * CARD_H * z)
|
|
if WorldMapBlobFrame then
|
|
WorldMapBlobFrame:SetAlpha(0)
|
|
if WorldMapBlobFrame_CalculateHitTranslations then WorldMapBlobFrame_CalculateHitTranslations() end
|
|
end
|
|
local a = WorldMapFrameAreaFrame
|
|
if a then
|
|
a:ClearAllPoints()
|
|
a:SetPoint("TOP", W.canvas, "TOP", 0, -10)
|
|
end
|
|
local x0, y0, x1, y1 = view()
|
|
WorldMapButton:SetHitRectInsets(math.max(0, x0) * z, math.max(0, CARD_W - x1) * z,
|
|
math.max(0, y0) * z, math.max(0, CARD_H - y1) * z)
|
|
cropTexture()
|
|
replaceMarkers(old, z)
|
|
sortBy()
|
|
end
|
|
|
|
-- Back to the whole map. After a mode change, the client and WorldMap.lua have just set
|
|
-- their own anchors: the map's is kept then.
|
|
function Z.reset(isModeChange)
|
|
if not Z.active then
|
|
Z.z = 1
|
|
return
|
|
end
|
|
local old = Z.z
|
|
Z.z = 1
|
|
Z.active = false
|
|
enlarge()
|
|
local d = WorldMapDetailFrame
|
|
if not isModeChange and Z.points then
|
|
d:ClearAllPoints()
|
|
for _, p in ipairs(Z.points) do
|
|
d:SetPoint(p[1], p[2], p[3], p[4], p[5])
|
|
end
|
|
end
|
|
if WorldMapBlobFrame then
|
|
WorldMapBlobFrame:SetAlpha(1)
|
|
if WorldMapBlobFrame_CalculateHitTranslations then WorldMapBlobFrame_CalculateHitTranslations() end
|
|
end
|
|
local a = WorldMapFrameAreaFrame
|
|
if a then
|
|
a:ClearAllPoints()
|
|
a:SetPoint("TOP", WorldMapButton, "TOP", 0, -10)
|
|
end
|
|
WorldMapButton:SetHitRectInsets(0, 0, 0, 0)
|
|
WorldMapButton:EnableMouse(true)
|
|
Z.pressPoint, Z.dragging = nil, false
|
|
rechainTiles()
|
|
restoreOverlays()
|
|
replaceMarkers(old, 1)
|
|
restoreChildren()
|
|
Z.cx, Z.cy = 0.5, 0.5
|
|
end
|
|
|
|
-- ------------------------------------------------------------- Mouse wheel
|
|
|
|
-- Map point under the cursor, and the cursor offset from the canvas center in screen pixels
|
|
local function pointUnderCursor()
|
|
local x, y = GetCursorPosition()
|
|
local c = W.canvas
|
|
local cx, cy = c:GetCenter()
|
|
if not cx then return nil end
|
|
local ce = c:GetEffectiveScale()
|
|
local dx, dy = x - cx * ce, y - cy * ce
|
|
local de = pixelsPerUnit()
|
|
return Z.cx + dx / (CARD_W * de), Z.cy - dy / (CARD_H * de), dx, dy, de
|
|
end
|
|
|
|
function Z.wheel(direction)
|
|
if not ready() then return end
|
|
local old = Z.z
|
|
local new = direction > 0 and old * Z.STEP or old / Z.STEP
|
|
if new > Z.MAX then new = Z.MAX end
|
|
if new < 1.0001 then new = 1 end
|
|
if new == old then return end
|
|
if new == 1 then
|
|
Z.reset(false)
|
|
return
|
|
end
|
|
if not Z.active then Z.cx, Z.cy = 0.5, 0.5 end
|
|
-- Keep the point under the cursor in place (camelot: SetPanTarget)
|
|
local fx, fy, dx, dy, de = pointUnderCursor()
|
|
Z.z = new
|
|
if fx then
|
|
local de2 = de * new / old
|
|
Z.cx = fx - dx / (CARD_W * de2)
|
|
Z.cy = fy + dy / (CARD_H * de2)
|
|
end
|
|
Z.apply(old)
|
|
end
|
|
|
|
-- --------------------------------------------------------------- Watcher
|
|
|
|
local mapKey = Z.mapKey
|
|
|
|
-- Child of the map frame, so it only runs while the map is open
|
|
local watcher = CreateFrame("Frame", nil, WorldMapFrame)
|
|
watcher:SetScript("OnUpdate", function(_, elapsed)
|
|
if not (Z.active and ready()) then
|
|
Z.key = ready() and mapKey() or nil
|
|
return
|
|
end
|
|
if mapKey() ~= Z.key then
|
|
Z.reset(false)
|
|
Z.key = mapKey()
|
|
return
|
|
end
|
|
-- Drag: past THRESHOLD pixels the map follows the cursor. WorldMapButton_OnClick navigates
|
|
-- on mouse up, so WorldMapButton releases the mouse until the button is up. The client code
|
|
-- is not wrapped: it closes dropdowns and would taint their state if called from the addon.
|
|
local pressPoint = Z.pressPoint
|
|
if pressPoint then
|
|
local x, y = GetCursorPosition()
|
|
if not IsMouseButtonDown("LeftButton") then
|
|
Z.pressPoint = nil
|
|
if Z.dragging then
|
|
Z.dragging = false
|
|
WorldMapButton:EnableMouse(true)
|
|
end
|
|
else
|
|
if not Z.dragging and math.abs(x - pressPoint.x) + math.abs(y - pressPoint.y) > Z.THRESHOLD then
|
|
Z.dragging = true
|
|
WorldMapButton:EnableMouse(false)
|
|
end
|
|
if Z.dragging then
|
|
local de = pixelsPerUnit()
|
|
Z.cx = pressPoint.cx - (x - pressPoint.x) / (CARD_W * de)
|
|
Z.cy = pressPoint.cy + (y - pressPoint.y) / (CARD_H * de)
|
|
Z.apply()
|
|
return
|
|
end
|
|
end
|
|
end
|
|
-- Markers move with their units: ten passes a second are enough (Z.apply sorts at once).
|
|
-- Each pass reads every child of the map buttons (about sixty) and builds three tables.
|
|
Z.sortWait = (Z.sortWait or 0) - elapsed
|
|
if Z.sortWait <= 0 then
|
|
Z.sortWait = 0.1
|
|
sortBy()
|
|
end
|
|
end)
|
|
Z.watcher = watcher
|
|
|
|
-- ------------------------------------------------------------ Hooks
|
|
|
|
if WorldMapButton then
|
|
WorldMapButton:EnableMouseWheel(true)
|
|
WorldMapButton:SetScript("OnMouseWheel", function(_, direction)
|
|
Z.wheel(direction)
|
|
end)
|
|
WorldMapButton:HookScript("OnMouseDown", function(_, button)
|
|
if button == "LeftButton" and Z.active then
|
|
local x, y = GetCursorPosition()
|
|
Z.pressPoint = { x = x, y = y, cx = Z.cx, cy = Z.cy }
|
|
Z.dragging = false
|
|
end
|
|
end)
|
|
-- Player arrow: WorldMapButton_OnUpdate places it (position x the map's width x
|
|
-- WORLDMAP_SETTINGS.size); place it again, or hide it out of the view
|
|
WorldMapButton:HookScript("OnUpdate", function()
|
|
if not Z.active then return end
|
|
local x, y = GetPlayerMapPosition("player")
|
|
if not x or (x == 0 and y == 0) then return end
|
|
local px, py = x * CARD_W, y * CARD_H
|
|
local x0, y0, x1, y1 = view()
|
|
if px < x0 or px > x1 or py < y0 or py > y1 then
|
|
if ShowWorldMapArrowFrame then ShowWorldMapArrowFrame(nil) end
|
|
elseif PositionWorldMapArrowFrame then
|
|
local s = WORLDMAP_SETTINGS.size * Z.z
|
|
PositionWorldMapArrowFrame("CENTER", "WorldMapDetailFrame", "TOPLEFT", px * s, -py * s)
|
|
end
|
|
end)
|
|
end
|
|
|
|
if WorldMapFrame then
|
|
WorldMapFrame:HookScript("OnHide", function() Z.reset(false) end)
|
|
end
|
|
|
|
-- WorldMapFrame_Update reloads the tiles and lays out the explored areas again
|
|
-- (WORLD_MAP_UPDATE): crop them again while zoomed
|
|
if WorldMapFrame_Update then
|
|
hooksecurefunc("WorldMapFrame_Update", function()
|
|
if Z.active then
|
|
recordOverlays()
|
|
cropTexture()
|
|
end
|
|
end)
|
|
end
|
|
|
|
-- Mode or view change: back to 1, without undoing what the client has just set
|
|
for _, name in ipairs({ "WorldMap_ToggleSizeDown", "WorldMap_ToggleSizeUp", "WorldMapFrame_SetMiniMode",
|
|
"WorldMapFrame_SetQuestMapView", "WorldMapFrame_SetFullMapView" }) do
|
|
if _G[name] then
|
|
hooksecurefunc(name, function() Z.reset(true) end)
|
|
end
|
|
end
|