diff --git a/CodexForever.lua b/CodexForever.lua new file mode 100644 index 0000000..abfc2fc --- /dev/null +++ b/CodexForever.lua @@ -0,0 +1,214 @@ +-- Freewood-OctoWoW-Codex: mitgelieferte Startdaten (Addon Freewood-OctoWoW-ForeverData) +-- +-- ForeverData enthaelt, was Spieler in Forever bereits erfasst haben: Quests mit Geber, Abgabe, Zielen, Belohnungen und Ketten, +-- Kreaturen, Objekte, Beute, Wegenetz, Verkehrsverbindungen, Ortspunkte. Die Daten bilden die unterste Schicht: +-- eigene Daten > empfangene Daten > ForeverData > Classic-Vorlage (Freewood-OctoWoW-CodexData) +-- Sie liegen nur im Speicher (nichts davon wird gespeichert oder weitergesendet) und gehen durch dieselbe Pruefung +-- (Share.Sanitize) wie empfangene Datensaetze. Abschalten: /fwforever aus + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex then + return +end + +local Forever = { loaded = false, count = 0 } +Codex.Forever = Forever +NS.Forever = Forever + +local DATA_ADDON = "Freewood-OctoWoW-ForeverData" + +function Forever.Disabled() + local db = Codex.db + return db and db.settings and db.settings.noForever == true or false +end + +-- Zusatztabellen (Item-Qualitaet, Item-Werte, Anzeige-IDs, Namen, Rezepte): nur fehlende Eintraege werden ergaenzt, +-- eigene Werte gehen vor. Einmal je Datenstand (db.foreverRev). +local function FillTable(dst, src, check) + if type(dst) ~= "table" or type(src) ~= "table" then return end + for k, v in pairs(src) do + if dst[k] == nil and check(k, v) then dst[k] = v end + end +end + +local function IsId(k) return type(k) == "number" and k >= 1 and k == math.floor(k) and k < 1e9 end + +local EXTRAS = { + iq = function(k, v) return IsId(k) and type(v) == "number" and v >= 0 and v <= 8 end, + disp = function(k, v) return IsId(k) and type(v) == "number" and v > 0 end, + vfac = function(k, v) return IsId(k) and (v == "A" or v == "H") end, + wrq = function(k, v) return IsId(k) and type(v) == "table" end, + names = function(k, v) return IsId(k) and type(v) == "string" and #v <= 80 end, + istats = function(k, v) + if not IsId(k) or type(v) ~= "table" then return false end + for sk, sv in pairs(v) do + if sk == "_fx" then + if type(sv) ~= "table" then return false end + elseif type(sk) ~= "string" or #sk > 60 or (type(sv) ~= "number" and type(sv) ~= "string" and type(sv) ~= "boolean") then return false end + end + return true + end, +} + +local function MergeExtras(g) + local db = Codex.db + if not db or not g.extra or db.foreverRev == g.rev then return end + for name, check in pairs(EXTRAS) do + if type(g.extra[name]) == "table" then + db[name] = db[name] or {} + FillTable(db[name], g.extra[name], check) + end + end + if type(g.extra.recipes) == "table" then + db.recipes = db.recipes or {} + for prof, list in pairs(g.extra.recipes) do + if type(prof) == "string" and type(list) == "table" then + db.recipes[prof] = db.recipes[prof] or {} + FillTable(db.recipes[prof], list, function(k, v) return type(k) == "string" and #k <= 80 and type(v) == "table" end) + end + end + end + if type(g.extra.qd) == "table" then + db.cat = db.cat or {} + db.cat.qd = db.cat.qd or {} + FillTable(db.cat.qd, g.extra.qd, function(k, v) return IsId(k) and type(v) == "table" end) + if Codex.Catalog then Codex.Catalog.rev = (Codex.Catalog.rev or 0) + 1 end + end + -- Karten-Scan (Karten, Gebiete, Gebiete je Karte, Trigger, Flugpunkte): nur fehlende Eintraege, eigener Scan geht vor + if type(g.extra.scan) == "table" then + db.cat = db.cat or {} + for _, name in ipairs({ "m", "a", "ma", "t", "x" }) do + if type(g.extra.scan[name]) == "table" then + db.cat[name] = db.cat[name] or {} + FillTable(db.cat[name], g.extra.scan[name], function(k, v) + return type(k) == "number" and (type(v) == "string" or type(v) == "table") + end) + end + end + end + db.foreverRev = g.rev +end + +-- Daten des Addons laden (bei Bedarf wird das Addon geladen). Rueckgabe: true, wenn Daten da sind +function Forever.Load() + if Forever.loaded then return true end + if Forever.Disabled() or not Codex.Share or not Codex.Share.SetBuiltin then return false end + local g = _G.FreewoodForeverData + if not (g and g.ready) then + local load = (_G.C_AddOns and _G.C_AddOns.LoadAddOn) or _G.LoadAddOn + if type(load) == "function" then pcall(load, DATA_ADDON) end + g = _G.FreewoodForeverData + end + if not (g and g.ready and type(g.list) == "table") then + Forever.missing = true + return false + end + if Forever.loading then return false end + Forever.loading = true + local list = g.list + local total, slice, from = #list, 600, 1 + Codex.Share.BuiltinBegin() + Forever.count = 0 + local function Finish() + Codex.Share.BuiltinDone() + pcall(MergeExtras, g) + Forever.version, Forever.rev = g.version, g.rev + Forever.loaded = true + Forever.loading = false + Forever.missing = false + if Codex.chainRev then Codex.chainRev = Codex.chainRev + 1 end + FW:Fire("CODEX_CHANGED", "forever") + end + local function Step() + if not Forever.loading then return end -- zwischendurch ausgeschaltet + local to = math.min(total, from + slice - 1) + Forever.count = Forever.count + Codex.Share.BuiltinAdd(list, from, to) + from = to + 1 + if from > total then + Finish() + elseif _G.C_Timer and _G.C_Timer.After then + _G.C_Timer.After(0.05, Step) + else + Step() + end + end + Step() + return true +end + +-- Quellen eines Items aus den Startdaten: { u = { {id, chance} }, o = { ... }, v = { Haendler-IDs } } oder nil +local itemCache = {} +function Forever.Item(id) + if not Forever.loaded or Forever.Disabled() then return nil end + local g = _G.FreewoodForeverData + local s = g and type(g.src) == "table" and g.src[id] + if type(s) ~= "string" then return nil end + local c = itemCache[id] + if c then return c end + c = { u = {}, o = {}, v = {} } + for part in s:gmatch("[^;]+") do + local tag, body = part:sub(1, 1), part:sub(2) + if tag == "u" or tag == "o" then + for k, v in body:gmatch("(%d+)=([%d%.]+)") do + local t = c[tag] + t[#t + 1] = { id = tonumber(k), chance = tonumber(v) } + end + elseif tag == "v" then + for k in body:gmatch("%d+") do c.v[#c.v + 1] = tonumber(k) end + end + end + itemCache[id] = c + return c +end + +-- Alle Item-IDs mit Quellen in den Startdaten (Tabelle id -> Text; nur lesen) +function Forever.ItemIds() + if not Forever.loaded or Forever.Disabled() then return {} end + local g = _G.FreewoodForeverData + return (g and type(g.src) == "table") and g.src or {} +end + +function Forever.ItemHasVendor(id) + local it = Forever.Item(id) + return it ~= nil and #it.v > 0 +end + +-- Schalter: an = Startdaten nutzen (Standard) +function Forever.SetEnabled(on) + local db = Codex.db + if not db then return end + db.settings.noForever = (not on) or nil + if on then + Forever.Load() + elseif Forever.loading then + Forever.loading = false + Codex.Share.SetBuiltin({}) + Forever.count = 0 + elseif Forever.loaded then + Codex.Share.SetBuiltin({}) + Forever.loaded, Forever.count = false, 0 + if Codex.chainRev then Codex.chainRev = Codex.chainRev + 1 end + FW:Fire("CODEX_CHANGED", "forever") + end +end + +function Forever.Status() + local L = FW.L + if Forever.Disabled() then return L.CODEX_FOREVER_OFF end + if Forever.loaded then return string.format(L.CODEX_FOREVER_ON, Forever.count, tostring(Forever.version or "?")) end + return L.CODEX_FOREVER_MISSING +end + +SLASH_FREEWOODFOREVER1 = "/fwforever" +SlashCmdList["FREEWOODFOREVER"] = function(msg) + msg = (msg or ""):lower():gsub("^%s+", ""):gsub("%s+$", "") + if msg == "aus" or msg == "off" then + Forever.SetEnabled(false) + elseif msg == "an" or msg == "on" then + Forever.SetEnabled(true) + end + FW:Print(Forever.Status()) +end diff --git a/CodexHints.lua b/CodexHints.lua new file mode 100644 index 0000000..199d1cc --- /dev/null +++ b/CodexHints.lua @@ -0,0 +1,427 @@ +-- Freewood-OctoWoW-Codex: Lern-Hinweise +-- Nach einem Stufenaufstieg (ausserhalb des Kampfes) oder einer neuen Berufsstufe zeigt ein kurzer Hinweis +-- in der Bildschirmmitte, was man jetzt beim Lehrer lernen kann. Grundlage: Lernplan (Lehrer-Daten). + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS.Codex +if not FW or not Codex or not FW.UI then + return +end + +local UI = FW.UI +local Hints = { queue = {}, showing = false, shown = 0 } +NS.Hints = Hints +Codex.Hints = Hints + +local DURATION = 5 +local FADE = 0.6 +local MAX_ROWS = 5 +local ROW_H = 24 +local WIDTH = 380 + +------------------------------------------------------------------------ +-- Einstellungen +------------------------------------------------------------------------ + +function Hints.ClassOn() + return Codex.db.settings.hintClass ~= false +end +function Hints.ProfOn() + return Codex.db.settings.hintProf ~= false +end + +------------------------------------------------------------------------ +-- Inhalt +------------------------------------------------------------------------ + +local function SpellKnown(id) + if not id then return false end + local SB = _G.C_SpellBook + local fn = (SB and SB.IsSpellKnown) or _G.IsSpellKnown or (_G.C_Spell and _G.C_Spell.IsSpellKnown) + if type(fn) ~= "function" then return false end + local ok, known = pcall(fn, id) + return ok and known == true +end + +local function Entry(r) + local cost = Codex.ModePrice(r.pr) + return { nm = r.nm or "?", sp = r.sp, rk = r.rk or (r.sp and Codex.SpellRank(r.sp)) or nil, cost = cost } +end + +-- Fähigkeiten deiner Klasse, die auf dieser Stufe beim Lehrer dazukommen +function Hints.ClassEntries(level) + local token = Codex.Time and Codex.Time.PlayerToken() + if not token then return {} end + local plan = Codex.LearnPlan(token) + local g = plan.levels[level] + local list = {} + if g then + for _, sp in ipairs(g.spells) do + if not SpellKnown(sp.sp) then + list[#list + 1] = { nm = sp.nm or "?", sp = sp.sp, rk = sp.rk or (sp.sp and Codex.SpellRank(sp.sp)) or nil, cost = sp.cost } + end + end + end + return list +end + +-- Berufslehrer-Fähigkeiten, deren Fertigkeitswert in (von, bis] liegt +function Hints.ProfEntries(skill, from, to) + local list, seen = {}, {} + for _, u in pairs(Codex.V().units) do + if u.tr and u.ro and (u.ro.proftrainer or 0) > 0 then + for key, r in pairs(u.tr) do + if r.sn == skill and r.sr and r.sr > from and r.sr <= to then + local k = r.sp and ("s" .. r.sp) or key + if not seen[k] then + seen[k] = true + list[#list + 1] = Entry(r) + end + end + end + end + end + table.sort(list, function(a, b) + if a.nm ~= b.nm then return a.nm < b.nm end + return (a.rk or "") < (b.rk or "") + end) + return list +end + +------------------------------------------------------------------------ +-- Anzeige +------------------------------------------------------------------------ + +local frame + +-- gespeicherte Position (verschoben in den Einstellungen) oder Standard: Bildschirmmitte, etwas hoeher +local function ApplyPos(f) + f:ClearAllPoints() + local p = Codex.db.settings.hintPos + if type(p) == "table" and p.point and p.x and p.y then + f:SetPoint(p.point, UIParent, p.rel or p.point, p.x, p.y) + else + f:SetPoint("CENTER", UIParent, "CENTER", 0, 160) + end +end + +function Hints.SavePos() + if not frame or not frame.GetPoint then return false end + local point, _, rel, x, y = frame:GetPoint(1) + if not point then return false end + Codex.db.settings.hintPos = { point = point, rel = rel, x = x, y = y } + return true +end + +function Hints.ResetPos() + Codex.db.settings.hintPos = nil + if frame then ApplyPos(frame) end +end + +local function Build() + local f = CreateFrame("Button", nil, UIParent) + f:SetSize(WIDTH, 80) + f:SetFrameStrata("DIALOG") + f:SetClampedToScreen(true) + f:SetMovable(true) + f:RegisterForDrag("LeftButton") + f:SetScript("OnDragStart", function(self) + if Hints.editing then self:StartMoving() end + end) + f:SetScript("OnDragStop", function(self) + self:StopMovingOrSizing() + if Hints.editing then Hints.SavePos() end + end) + ApplyPos(f) + f:Hide() + local bg = UI.CreatePanel(f, "bgDeep", "gold") + bg:SetAllPoints(f) + -- Inhalt auf eigener Ebene ueber dem Hintergrund (ein Kindfenster zeichnet sonst ueber den Texten des Eltern-Fensters) + local c = CreateFrame("Frame", nil, f) + c:SetAllPoints(f) + c:SetFrameLevel((bg:GetFrameLevel() or 1) + 2) + f.title = UI.CreateLabel(c, "", { font = "heading", size = 14, color = "goldLight" }) + f.title:SetPoint("TOPLEFT", f, "TOPLEFT", 12, -10) + f.title:SetPoint("TOPRIGHT", f, "TOPRIGHT", -12, -10) + f.title:SetWordWrap(false) + f.rows = {} + for i = 1, MAX_ROWS do + local row = {} + row.icon = c:CreateTexture(nil, "ARTWORK") + row.icon:SetSize(ROW_H - 4, ROW_H - 4) + row.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + row.icon:SetPoint("TOPLEFT", f, "TOPLEFT", 12, -(34 + (i - 1) * ROW_H)) + row.text = UI.CreateLabel(c, "", { size = 12 }) + row.text:SetPoint("LEFT", row.icon, "RIGHT", 8, 0) + row.text:SetWidth(WIDTH - 150) + row.text:SetWordWrap(false) + row.cost = UI.CreateLabel(c, "", { size = 11, color = "textDim", justify = "RIGHT" }) + row.cost:SetPoint("RIGHT", f, "RIGHT", -12, 0) + row.cost:SetPoint("TOP", row.icon, "TOP", 0, 0) + row.cost:SetPoint("BOTTOM", row.icon, "BOTTOM", 0, 0) + row.cost:SetWidth(120) + f.rows[i] = row + end + f.more = UI.CreateLabel(c, "", { size = 11, color = "textDim" }) + f.foot = UI.CreateLabel(c, "", { size = 10, color = "textDim" }) + f:SetScript("OnClick", function() + if Hints.editing then return end + Hints.Dismiss() + if f.onClick then f.onClick() end + end) + f:SetScript("OnUpdate", function(self, elapsed) + if Hints.editing then + self.age = 0 + self:SetAlpha(1) + return + end + self.age = (self.age or 0) + (elapsed or 0) + if self.age >= DURATION then + Hints.Dismiss() + elseif self.age > DURATION - FADE then + self:SetAlpha(math.max(0, (DURATION - self.age) / FADE)) + end + end) + return f +end + +local function InCombat() + if _G.InCombatLockdown and _G.InCombatLockdown() then return true end + return _G.UnitAffectingCombat and _G.UnitAffectingCombat("player") or false +end + +local function Display(h) + frame = frame or Build() + ApplyPos(frame) + local L = FW.L + frame.title:SetText(h.title) + local n = math.min(#h.entries, MAX_ROWS) + local total = 0 + local money = _G.GetMoney and _G.GetMoney() or nil + for i = 1, MAX_ROWS do + local row, e = frame.rows[i], h.entries[i] + if e and i <= n then + local icon = e.sp and Codex.SpellIcon(e.sp) or nil + row.icon:SetTexture(icon or "Interface\\Icons\\INV_Misc_QuestionMark") + row.icon:Show() + row.text:SetText(e.nm .. (e.rk and (" |cff9a9aa3(" .. e.rk .. ")|r") or "")) + row.cost:SetText((e.cost and e.cost > 0 and Codex.FormatMoney) and Codex.FormatMoney(e.cost) or "") + else + row.icon:Hide() + row.text:SetText("") + row.cost:SetText("") + end + end + for _, e in ipairs(h.entries) do total = total + (e.cost or 0) end + local y = 34 + n * ROW_H + 4 + local more = #h.entries - n + frame.more:ClearAllPoints() + frame.more:SetPoint("TOPLEFT", frame, "TOPLEFT", 12, -y) + frame.more:SetText(more > 0 and string.format(L.CODEX_HINT_MORE, more) or "") + if more > 0 then y = y + 16 end + local foot = Hints.editing and L.CODEX_HINT_DRAG or L.CODEX_HINT_CLICK + if total > 0 and Codex.FormatMoney and not Hints.editing then + foot = string.format(L.CODEX_HINT_COST, Codex.FormatMoney(total)) + if money and money < total then + foot = foot .. " | " .. string.format(L.CODEX_HINT_MISSING, Codex.FormatMoney(total - money)) + end + foot = foot .. " | " .. L.CODEX_HINT_CLICK + end + frame.foot:ClearAllPoints() + frame.foot:SetPoint("TOPLEFT", frame, "TOPLEFT", 12, -y) + frame.foot:SetText(foot) + frame:SetHeight(y + 26) + frame.onClick = h.onClick + frame.age = 0 + frame:SetAlpha(1) + frame:Show() + Hints.showing = true + Hints.shown = Hints.shown + 1 + Hints.last = h +end + +function Hints.Dismiss() + if Hints.editing then return end + if frame then frame:Hide() end + Hints.showing = false + Hints.Flush() +end + +-- naechsten wartenden Hinweis zeigen, wenn kein Kampf ist und keiner laeuft +function Hints.Flush() + if Hints.showing or Hints.editing or InCombat() then return end + local h = table.remove(Hints.queue, 1) + if h then Display(h) end +end + +function Hints.Push(h) + if not h or #h.entries == 0 then return false end + Hints.queue[#Hints.queue + 1] = h + Hints.Flush() + return true +end + +local function OpenPlan(level) + return function() + UI:Show("codex") + if Codex.ui and Codex.ui.Go then Codex.ui.Go("learn", level) end + end +end + +function Hints.OnLevelUp(level) + level = tonumber(level) + if not level or not Hints.ClassOn() or not Codex.db.settings.enabled then return false end + local entries = Hints.ClassEntries(level) + return Hints.Push({ title = string.format(FW.L.CODEX_HINT_CLASS_TITLE, level), entries = entries, onClick = OpenPlan(level) }) +end + +local prevSkill = {} +function Hints.OnSkill(skill, value) + value = tonumber(value) + if not skill or not value then return false end + local from = prevSkill[skill] or (value - 1) + prevSkill[skill] = value + if not Hints.ProfOn() or not Codex.db.settings.enabled then return false end + local entries = Hints.ProfEntries(skill, from, value) + return Hints.Push({ title = string.format(FW.L.CODEX_HINT_PROF_TITLE, skill, value), entries = entries, onClick = function() UI:Show("codex") end }) +end + +-- Test aus den Einstellungen: letzte Stufe mit Daten, sonst eine Beispielzeile +function Hints.Demo() + local pl = _G.UnitLevel and _G.UnitLevel("player") or 1 + local entries + for lv = pl, 1, -1 do + entries = Hints.ClassEntries(lv) + if #entries > 0 then + pl = lv + break + end + end + if not entries or #entries == 0 then + entries = { { nm = FW.L.CODEX_HINT_DEMO, rk = "1", cost = 10000 } } + end + Hints.queue = {} + Hints.showing = false + if frame then frame:Hide() end + Hints.Push({ title = string.format(FW.L.CODEX_HINT_CLASS_TITLE, pl), entries = entries, onClick = OpenPlan(pl) }) +end + +-- Verschieben: der Hinweis bleibt stehen, bis der Modus beendet wird; Position wird beim Loslassen gespeichert +function Hints.SetEditMode(on) + on = on and true or false + if on == (Hints.editing == true) then return end + if on then + Hints.queue, Hints.showing = {}, false + if frame then frame:Hide() end + local pl = _G.UnitLevel and _G.UnitLevel("player") or 1 + local entries = { { nm = FW.L.CODEX_HINT_DEMO, rk = "1", cost = 10000 }, { nm = FW.L.CODEX_HINT_DEMO, rk = "2", cost = 25000 } } + Hints.editing = true + Display({ title = string.format(FW.L.CODEX_HINT_CLASS_TITLE, pl), entries = entries }) + else + Hints.SavePos() + Hints.editing = false + Hints.showing = false + if frame then frame:Hide() end + Hints.Flush() + end + FW:Fire("CODEX_HINT_EDIT") +end + +------------------------------------------------------------------------ +-- Ereignisse +------------------------------------------------------------------------ + +-- Muster aus dem Textbaustein des Spiels ("... %s ... %d" oder mit Stellen "%1$s ... %2$d"). Rueckgabe: Muster, Reihenfolge +-- der Platzhalter (Stelle -> Rang in der Fundstelle), sonst nil +local function SkillPattern(fmt) + fmt = fmt or _G.SKILL_RANK_UP + if type(fmt) ~= "string" then return nil end + local out, order, i = { "^" }, {}, 1 + while i <= #fmt do + local pos, kind, len = fmt:match("^%%(%d+)%$([sd])()", i) + if pos then + i = len + else + kind, len = fmt:match("^%%([sd])()", i) + pos = #order + 1 + if kind then i = len end + end + if kind then + out[#out + 1] = kind == "s" and "(.+)" or "(%d+)" + order[#order + 1] = tonumber(pos) + else + local ch = fmt:sub(i, i) + out[#out + 1] = ch:match("%w") and ch or ("%" .. ch) + i = i + 1 + end + end + out[#out + 1] = "$" + return table.concat(out), order +end +Hints.SkillPattern = SkillPattern + +FW:RegisterEvent("PLAYER_LEVEL_UP", function(_, level) + if C_Timer and C_Timer.After then + C_Timer.After(1, function() Hints.OnLevelUp(level) end) + else + Hints.OnLevelUp(level) + end +end, Hints) +FW:RegisterEvent("PLAYER_REGEN_ENABLED", function() Hints.Flush() end, Hints) +FW:RegisterEvent("PLAYER_REGEN_DISABLED", function() + if Hints.editing then Hints.SetEditMode(false) end +end, Hints) +FW:RegisterEvent("CHAT_MSG_SKILL", function(_, text) + local pat, order = SkillPattern() + if type(text) ~= "string" or not pat then return end + local caps = { text:match(pat) } + if #caps == 0 then return end + local skill, value = caps[1], caps[2] + if order and order[1] == 2 then skill, value = caps[2], caps[1] end -- Fertigkeit steht an Stelle 2 + if skill then Hints.OnSkill(skill, value) end +end, Hints) + +------------------------------------------------------------------------ +-- Einstellungen: Abschnitt +------------------------------------------------------------------------ + +if UI.RegisterSettingsSection then + UI:RegisterSettingsSection("codex-hints", { + order = 106, + height = 300, + build = function(parent) + local L = FW.L + local f, stack = UI.CreateSection(parent, L.CODEX_HINT_HEADER) + local note = UI.CreateLabel(f, L.CODEX_HINT_NOTE, { size = 11, color = "textDim", justifyV = "TOP" }) + stack:Add(note, { fit = true, gap = 8 }) + local classBox = UI.CreateCheckbox(f, L.CODEX_HINT_CLASS_SWITCH, function() return Hints.ClassOn() end, + function(on) Codex.db.settings.hintClass = on and true or false end) + stack:Add(classBox, { fit = true, gap = 10 }) + local profBox = UI.CreateCheckbox(f, L.CODEX_HINT_PROF_SWITCH, function() return Hints.ProfOn() end, + function(on) Codex.db.settings.hintProf = on and true or false end) + stack:Add(profBox, { fit = true, gap = 4 }) + local test = UI.CreateButton(f, L.CODEX_HINT_TEST, 200, 26, function() Hints.Demo() end, "default") + stack:Add(test, { height = 26, gap = 12 }) + local move + move = UI.CreateButton(f, L.CODEX_HINT_MOVE, 200, 26, function() + Hints.SetEditMode(not Hints.editing) + end, "default") + stack:Add(move, { height = 26, gap = 6 }) + local reset = UI.CreateButton(f, L.CODEX_HINT_RESET, 200, 26, function() Hints.ResetPos() end, "default") + stack:Add(reset, { height = 26, gap = 6 }) + stack:Layout() + f.classBox, f.profBox, f.move = classBox, profBox, move + FW:On("CODEX_HINT_EDIT", function() + move:SetLabel(Hints.editing and L.CODEX_HINT_MOVE_DONE or L.CODEX_HINT_MOVE) + move:SetStyle(Hints.editing and "primary" or "default") + end, f) + return f + end, + refresh = function(frame) + frame.classBox:Refresh() + frame.profBox:Refresh() + end, + }) +end diff --git a/CodexLookup.lua b/CodexLookup.lua new file mode 100644 index 0000000..cdffa3e --- /dev/null +++ b/CodexLookup.lua @@ -0,0 +1,328 @@ +-- Freewood-OctoWoW-Codex: Kreaturen ueber ihre ID nachschlagen (OctoWoW) +-- +-- C_CreatureInfo.GetCreatureInfoByID liefert zu einer Kreatur-ID: creatureID, displayID, name (englisch), type (Nummer), +-- family, rank, subName. Ist die Kreatur dem Client noch unbekannt, laedt RequestLoadCreatureByID sie nach +-- (Antwort: Ereignis CREATURE_DATA_LOAD_RESULT). +-- Im Hintergrund werden bei allen Kreaturen, die wir schon kennen, fehlende Angaben ergaenzt: +-- ct Typ (sprachunabhaengig, aus der Typ-Nummer) +-- c Klasse (elite, selten, Boss ...) falls noch keine bekannt +-- ne englischer Name (Ersatz, wenn kein gesehener Name da ist) +-- Es werden nur bestehende Datensaetze ergaenzt, keine neuen angelegt. + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex then + return +end + +local Lookup = { started = false } +NS.Lookup = Lookup +Codex.Lookup = Lookup + +local TYPE_CODE = { + [1] = "beast", [2] = "dragonkin", [3] = "demon", [4] = "elemental", [5] = "giant", [6] = "undead", + [7] = "humanoid", [8] = "critter", [9] = "mechanical", [10] = "other", [11] = "totem", [12] = "pet", [13] = "gascloud", +} +local RANK_CODE = { [1] = "elite", [2] = "rareelite", [3] = "worldboss", [4] = "rare" } + +local TICK = 0.4 -- Abstand zwischen zwei Schritten (Sekunden) +local WAIT = 1.6 -- so lange auf die Antwort einer Anfrage warten +local PER_TICK = 25 -- bereits bekannte Kreaturen je Schritt (nur lesen, kostet fast nichts) +local RETRY_AFTER = 7 * 86400 -- erfolglose IDs nach einer Woche wieder versuchen +local FIRE_EVERY = 40 -- Oberflaeche hoechstens alle so viele Aenderungen auffrischen + +local state = { + queue = {}, first = 1, last = 0, + pending = nil, pendingAt = 0, + changed = 0, lastFire = 0, generation = 0, + stats = { read = 0, requested = 0, filled = 0, failed = 0 }, +} +Lookup.state = state + +local function Now() + return GetTime and GetTime() or 0 +end + +local function Store() + local db = Codex.db + db.lookup = db.lookup or {} + db.lookup.fail = db.lookup.fail or {} + return db.lookup +end + +local function Enabled() + return Codex.db ~= nil and Codex.db.settings ~= nil and Codex.db.settings.lookup ~= false + and _G.C_CreatureInfo ~= nil and _G.C_CreatureInfo.GetCreatureInfoByID ~= nil +end + +local function ReadInfo(id) + local ok, info = pcall(_G.C_CreatureInfo.GetCreatureInfoByID, id) + if ok and type(info) == "table" and info.creatureID == id then + return info + end + return nil +end + +local function NeedsInfo(u) + return type(u) == "table" and (not u.ct or not u.ne) +end + +-- Schreibt die Antwort in den bestehenden Datensatz. Rueckgabe: true, wenn sich etwas geaendert hat +function Lookup.Apply(id, info) + local u = Codex.db.units[id] + if type(u) ~= "table" or type(info) ~= "table" then + return false + end + local changed = false + local code = TYPE_CODE[info.type] + if code and u.ct ~= code then + u.ct = code + changed = true + end + local rc = RANK_CODE[info.rank] + if rc and not u.c then + u.c = rc + changed = true + end + local name = info.name + if type(name) == "string" and name ~= "" and not u.ne then + u.ne = name:sub(1, 60) + changed = true + end + return changed +end + +local function Changed(id) + state.changed = state.changed + 1 + state.stats.filled = state.stats.filled + 1 + if state.changed - state.lastFire >= FIRE_EVERY then + state.lastFire = state.changed + FW:Fire("CODEX_CHANGED", "unit", id) + end +end + +-- Warteschlange (mit Zaehlern, nicht table.remove) +local function Push(id) + state.last = state.last + 1 + state.queue[state.last] = id +end + +local function Pop() + if state.first > state.last then + return nil + end + local id = state.queue[state.first] + state.queue[state.first] = nil + state.first = state.first + 1 + if state.first > state.last then + state.first, state.last = 1, 0 + end + return id +end + +function Lookup.QueueSize() + return state.last - state.first + 1 +end + +-- Alle bekannten Kreaturen, denen etwas fehlt, in die Schlange stellen +function Lookup.Scan() + if not Enabled() then + return 0 + end + local fail = Store().fail + local now = time and time() or 0 + local queued = {} + for i = state.first, state.last do + queued[state.queue[i]] = true + end + local ids = {} + for id, u in pairs(Codex.db.units) do + if type(id) == "number" and NeedsInfo(u) and not queued[id] then + local f = fail[id] + if not f or now - f > RETRY_AFTER then + ids[#ids + 1] = id + end + end + end + table.sort(ids) + for i = 1, #ids do + Push(ids[i]) + end + return #ids +end + +local function Finish(id, info) + state.pending = nil + if info then + if Lookup.Apply(id, info) then + Changed(id) + end + else + state.stats.failed = state.stats.failed + 1 + Store().fail[id] = time and time() or 1 + end +end + +function Lookup.OnLoadResult(_, id, ok) + id = tonumber(id) + if state.pending and id == state.pending then + Finish(id, ReadInfo(id)) + end +end + +function Lookup.Step() + if not Enabled() then + return + end + local now = Now() + if state.pending then + if now - state.pendingAt < WAIT then + return + end + -- keine Antwort: noch einmal lesen, sonst als erfolglos merken + Finish(state.pending, ReadInfo(state.pending)) + end + local n = 0 + while n < PER_TICK do + local id = Pop() + if not id then + return + end + local u = Codex.db.units[id] + if NeedsInfo(u) then + local info = ReadInfo(id) + if info then + state.stats.read = state.stats.read + 1 + if Lookup.Apply(id, info) then + Changed(id) + end + n = n + 1 + else + -- dem Client unbekannt: nachladen lassen und auf die Antwort warten + state.pending, state.pendingAt = id, now + state.stats.requested = state.stats.requested + 1 + local ok = pcall(_G.C_CreatureInfo.RequestLoadCreatureByID, id) + if not ok then + Finish(id, nil) + end + return + end + end + end +end + +function Lookup:Start() + if self.started then + return + end + self.started = true + state.generation = state.generation + 1 + local gen = state.generation + FW:RegisterEvent("CREATURE_DATA_LOAD_RESULT", Lookup.OnLoadResult, Lookup) + local function Loop(delay, fn) + C_Timer.After(delay, function() + if not Lookup.started or gen ~= state.generation then + return + end + fn() + Loop(delay, fn) + end) + end + Loop(TICK, Lookup.Step) + -- erste Runde kurz nach dem Einloggen, danach alle 10 Minuten fuer neu gesehene Kreaturen + C_Timer.After(20, function() + if Lookup.started and gen == state.generation then + Lookup.Scan() + end + end) + Loop(600, Lookup.Scan) +end + +function Lookup:Stop() + self.started = false + state.generation = state.generation + 1 + FW:UnregisterEvent("CREATURE_DATA_LOAD_RESULT", Lookup) +end + +-- /fwlookup Stand anzeigen +-- /fwlookup run sofort neu durchsuchen +-- /fwlookup retry auch fruehere Fehlschlaege noch einmal versuchen +-- /fwlookup id eine einzelne Kreatur nachschlagen und anzeigen +-- /fwlookup clean Reste ohne Daten anzeigen; "clean yes" entfernt sie +function Lookup.Command(msg) + msg = (msg or ""):lower() + if msg == "retry" then + Store().fail = {} + msg = "run" + end + if msg == "run" then + local n = Lookup.Scan() + FW:Print(string.format("Nachschlagen: %d Kreaturen vorgemerkt", n)) + return + end + if msg == "clean" or msg == "clean yes" then + -- Reste ohne Beobachtungen: Kreaturen, die der Client nicht kennt (Nachschlagen erfolglos), ohne Kills, Positionen, + -- Rollen, Beute oder sonstige Daten. Erst anzeigen, nur mit "clean yes" loeschen. + local Share = Codex.Share + local list = {} + for id in pairs(Store().fail) do + local u = Codex.db.units[id] + if type(u) == "table" and not (Share and Share.IsShareable and Share.IsShareable("u", u)) and not u.ed and not u.cde and not u.cen + and not u.ro and not u.d and not u.cells and (u.k or 0) == 0 then + list[#list + 1] = id + end + end + table.sort(list) + if #list == 0 then + FW:Print("Aufraeumen: keine Reste gefunden") + return + end + for _, id in ipairs(list) do + local u = Codex.db.units[id] + FW:Print(string.format(" %d %s", id, tostring(u.n or u.ne or "?"))) + end + if msg == "clean yes" then + for _, id in ipairs(list) do + Codex.db.units[id] = nil + Store().fail[id] = nil + end + FW:Fire("CODEX_CHANGED", "unit", list[1]) + FW:Print(string.format("Aufraeumen: %d Kreaturen entfernt", #list)) + else + FW:Print(string.format("Aufraeumen: %d Kreaturen ohne Daten. Zum Entfernen: /fwlookup clean yes", #list)) + end + return + end + local want = msg:match("^id%s+(%d+)$") + if want then + local id = tonumber(want) + local info = ReadInfo(id) + if info then + FW:Print(string.format("Kreatur %d: %s, Typ %s, Rang %s, Familie %s, Anzeige %s", id, tostring(info.name), + tostring(info.type), tostring(info.rank), tostring(info.family), tostring(info.displayID))) + else + FW:Print("Kreatur " .. id .. " ist dem Client noch unbekannt (/fwnpc " .. id .. " laedt sie nach)") + end + return + end + local total, withType, withName = 0, 0, 0 + for id, u in pairs(Codex.db.units) do + if type(id) == "number" then + total = total + 1 + if u.ct then withType = withType + 1 end + if u.ne or u.n then withName = withName + 1 end + end + end + local failed = 0 + for _ in pairs(Store().fail) do failed = failed + 1 end + local s = state.stats + FW:Print(string.format("Nachschlagen: %s | Kreaturen %d, mit Typ %d, mit Namen %d | gelesen %d, angefragt %d, ergaenzt %d, erfolglos %d (gemerkt %d) | Schlange %d", + Enabled() and "an" or "aus", total, withType, withName, s.read, s.requested, s.filled, s.failed, failed, Lookup.QueueSize())) +end + +if _G.SlashCmdList then + _G.SLASH_FWLOOKUP1 = "/fwlookup" + _G.SlashCmdList["FWLOOKUP"] = function(msg) Lookup.Command(msg) end +end diff --git a/CodexMap.lua b/CodexMap.lua new file mode 100644 index 0000000..4fa12cf --- /dev/null +++ b/CodexMap.lua @@ -0,0 +1,764 @@ +-- Freewood-OctoWoW-Codex: Fundorte auf der Weltkarte +-- Zeichnet Punkte mit weissem Rand ueber Blizzards Kartenflaeche: schwarz fuer Kreaturen, +-- rot fuer Objekte, blau fuer Quest-Geber und -Abgabe. Fundorte werden gebuendelt (BIN Zellen je Punkt). +-- Quelle sind die Zellen, die Codex beim Spielen erfasst hat (Raster 200 x 200 je Karte). + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS.Codex +if not FW or not Codex then + return +end + +local UI = FW.UI +local GRID = Codex.GRID +local MAX_DOTS = 600 +local BIN = 3 -- Zellen je Punkt (3 Zellen = 1,5 % der Kartenbreite) +local DOT_MIN = 9 -- Punktgroesse in Bildschirm-Pixeln (kleinster / groesster Punkt) +local DOT_MAX = 14 +local RIM = 1.5 -- weisser Rand in Pixeln +local LAYER_LEVEL = 1500 -- weit ueber Blizzards Kartensymbolen, damit nichts die Punkte verdeckt + +local COLORS = { + u = { 0, 0, 0 }, + o = { 0.9, 0.1, 0.1 }, + p = { 0.2, 0.55, 1 }, +} +local ORDER = { u = 1, o = 2, p = 3 } +local MASK = "Interface\\CharacterFrame\\TempPortraitAlphaMask" + +local Overlay = { points = nil, label = nil, hooked = false } +Codex.Map = Overlay + +local layer, dots, infoButton, StartHover +local segDash = {} -- [Abschnitt] = { sig, tex = {...}, n }: Striche je Abschnitt, damit nur der veraenderte neu gesetzt wird +local DASH_LEN, DASH_GAP, DASH_THICK = 8, 6, 3 -- Pixel +local MAX_DASHES = 900 +local atan2 = math.atan2 or function(y, x) return math.atan(y, x) end + +-- Runder Punkt: weisse Scheibe mit schwarzer/roter/blauer Scheibe darauf. Rund wird er durch eine +-- Maske aus dem Spiel; fehlt sie, bleiben es Quadrate. +local function MaskAvailable() + if Codex.db and Codex.db.settings.squareDots then + return false + end + if type(GetFileIDFromPath) == "function" then + return GetFileIDFromPath(MASK) ~= nil + end + return true +end + +local function NewDot(f) + local dot = {} + local useMask = MaskAvailable() + for _, name in ipairs({ "rim", "fill" }) do + local tex = f:CreateTexture(nil, name == "rim" and "ARTWORK" or "OVERLAY", nil, name == "rim" and 1 or 2) + tex:SetColorTexture(1, 1, 1, 1) + if useMask and f.CreateMaskTexture and tex.AddMaskTexture then + local ok, mask = pcall(f.CreateMaskTexture, f) + if ok and mask then + mask:SetTexture(MASK, "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE") + mask:SetAllPoints(tex) + tex:AddMaskTexture(mask) + tex.mask = mask + end + end + dot[name] = tex + end + return dot +end + +local function HideDot(dot) + dot.rim:Hide() + dot.fill:Hide() +end + +local API = Codex.MapAPI + +local function Canvas() + return API.Canvas() +end + +local function EnsureLayer() + if layer then + return layer + end + local canvas = Canvas() + if not canvas then + return nil + end + -- Die Ebene haengt am Kartenfenster selbst (nicht an der Kartenflaeche): dort ist sie sicher sichtbar. + -- Die Punkte werden an die Kartenflaeche geheftet und folgen ihr beim Verschieben und Zoomen. + local wm = _G.WorldMapFrame + if API.Classic() then + -- 1.12-Karte: Ebene direkt auf der Kartenflaeche, Anteile 0..1 gelten fuer die ganze Flaeche + layer = CreateFrame("Frame", nil, canvas) + layer:SetAllPoints(canvas) + layer:SetFrameLevel((canvas:GetFrameLevel() or 1) + 20) + if wm.GetFrameStrata then layer:SetFrameStrata(wm:GetFrameStrata()) end + else + layer = CreateFrame("Frame", nil, wm) + layer:SetAllPoints(wm.ScrollContainer or wm) + layer:SetFrameLevel((wm:GetFrameLevel() or 1) + LAYER_LEVEL) + if wm.GetFrameStrata then + layer:SetFrameStrata(wm:GetFrameStrata()) + end + end + if layer.SetClipsChildren then + layer:SetClipsChildren(true) + end + layer:SetScript("OnSizeChanged", function(_, w) + if w and w > 1 and Overlay.points then + Overlay:Refresh() + end + end) + dots = {} + StartHover(layer) + return layer +end + +local function EnsureInfoButton() + if infoButton then + return infoButton + end + local wm = _G.WorldMapFrame + if not wm then + return nil + end + infoButton = UI.CreateButton(wm, "", 300, 22, function() + Overlay:Clear() + end, "default") + if API.Classic() then + local canvas = Canvas() or wm + if wm.GetFrameStrata then infoButton:SetFrameStrata(wm:GetFrameStrata()) end + infoButton:SetFrameLevel((canvas:GetFrameLevel() or 1) + 40) + infoButton:SetPoint("TOPLEFT", canvas, "TOPLEFT", 12, -12) + else + infoButton:SetFrameStrata("HIGH") + infoButton:SetFrameLevel((wm:GetFrameLevel() or 1) + LAYER_LEVEL + 100) + local anchor = wm.ScrollContainer or wm + infoButton:SetPoint("TOPLEFT", anchor, "TOPLEFT", 12, -42) + end + infoButton:Hide() + return infoButton +end + +local function CurrentMapID() + return API.CurrentMapID(function() + local list, seen = {}, {} + for _, p in ipairs(Overlay.points or {}) do + if p.m and not seen[p.m] then seen[p.m] = true; list[#list + 1] = p.m end + end + return list + end) +end + +-- Faktor Kartenflaeche -> Bildschirm (aendert sich beim Zoomen) +function Overlay:CanvasScale() + local canvas = Canvas() + local s = 1 + if canvas and canvas.GetEffectiveScale and _G.UIParent then + local a, b = canvas:GetEffectiveScale(), _G.UIParent:GetEffectiveScale() + if type(a) == "number" and type(b) == "number" and b > 0 and a > 0 then + s = a / b + end + end + return s +end + +-- Skalierung der Punkte-Ebene relativ zur Oberflaeche +local function LayerScale() + local s = 1 + if layer and layer.GetEffectiveScale and _G.UIParent then + local a, b = layer:GetEffectiveScale(), _G.UIParent:GetEffectiveScale() + if type(a) == "number" and type(b) == "number" and b > 0 and a > 0 then + s = a / b + end + end + return s +end + +-- Punkte einer Karte zusammenfassen: je Klasse (u, o, p) und BIN x BIN Zellen ein Punkt +function Overlay:Bins(mapID) + local bins = {} + for _, p in ipairs(self.points or {}) do + if p.m == mapID then + local cls = p.pin and "p" or (p.k == "o" and "o" or "u") + local cx = math.min(GRID - 1, math.max(0, math.floor(p.x * GRID))) + local cy = math.min(GRID - 1, math.max(0, math.floor(p.y * GRID))) + local base = math.floor(cy / BIN) * 1000 + math.floor(cx / BIN) + local key = base .. cls + local b = bins[key] + if not b then + b = { sx = 0, sy = 0, n = 0, names = {}, cls = cls, base = base } + bins[key] = b + end + local w = p.n or 1 + b.sx, b.sy, b.n = b.sx + p.x * w, b.sy + p.y * w, b.n + w + if p.name then + b.names[p.name] = (b.names[p.name] or 0) + w + end + end + end + local list, maxN, perBase = {}, 1, {} + for _, b in pairs(bins) do + b.x, b.y = b.sx / b.n, b.sy / b.n + list[#list + 1] = b + perBase[b.base] = (perBase[b.base] or 0) + 1 + if b.n > maxN then + maxN = b.n + end + end + for _, b in ipairs(list) do + b.shared = perBase[b.base] > 1 + end + -- Reihenfolge: Kreaturen zuerst, Quest-Punkte zuletzt (oben) + table.sort(list, function(a, b) + if ORDER[a.cls] ~= ORDER[b.cls] then return ORDER[a.cls] < ORDER[b.cls] end + if a.y ~= b.y then return a.y < b.y end + return a.x < b.x + end) + return list, maxN +end + +-- Gestrichelte Linie durch die Wegpunkte (Luftlinie von Station zu Station). +-- Jeder Abschnitt merkt sich seine Lage: bleibt er gleich, werden die Striche nur wieder eingeblendet. +local function HideDashes(from) + for i = from or 1, #segDash do + local s = segDash[i] + for k = 1, s.n do s.tex[k]:Hide() end + s.n, s.sig = 0, nil + end +end + +local function DrawPath(f, canvas, path, width, height, ratio, layerScale) + local total = 0 + local len = DASH_LEN / layerScale + local step = (DASH_LEN + DASH_GAP) / layerScale + for i = 2, #path do + local s = segDash[i - 1] + if not s then s = { tex = {}, n = 0 }; segDash[i - 1] = s end + local x1, y1 = path[i - 1].x * width * ratio, -path[i - 1].y * height * ratio + local x2, y2 = path[i].x * width * ratio, -path[i].y * height * ratio + local sig = string.format("%.2f,%.2f,%.2f,%.2f,%.4f", x1, y1, x2, y2, layerScale) + if s.sig == sig then + for k = 1, s.n do s.tex[k]:Show() end + else + local dx, dy = x2 - x1, y2 - y1 + local dist = math.sqrt(dx * dx + dy * dy) + local count = 0 + if dist > 0 then + local ux, uy = dx / dist, dy / dist + local angle = atan2(dy, dx) + local t = len / 2 + while t <= dist and total + count < MAX_DASHES do + count = count + 1 + local tex = s.tex[count] + if not tex then + tex = f:CreateTexture(nil, "OVERLAY", nil, 0) + tex:SetColorTexture(1, 0.85, 0.2, 0.95) + s.tex[count] = tex + end + tex:ClearAllPoints() + tex:SetSize(len, DASH_THICK / layerScale) + tex:SetPoint("CENTER", canvas, "TOPLEFT", x1 + ux * t, y1 + uy * t) + if tex.SetRotation then tex:SetRotation(angle) end + tex:Show() + t = t + step + end + end + for k = count + 1, s.n do s.tex[k]:Hide() end + s.n, s.sig = count, sig + end + total = total + s.n + end + HideDashes(#path) -- Abschnitte, die es nicht mehr gibt + return total +end + +function Overlay:Refresh() + local f = EnsureLayer() + if not f then + return + end + for _, d in ipairs(dots) do + HideDot(d) + end + if not (self.path and #self.path > 1 and self.points) then + HideDashes() + end + f:Show() + local btn = EnsureInfoButton() + if not self.points or #self.points == 0 then + if btn then + btn:Hide() + end + return + end + if btn then + btn:SetLabel(string.format(FW.L.CODEX_MAP_LABEL, self.label or "")) + btn:SetWidth(math.max(200, (btn.label:GetStringWidth() or 200) + 24)) + btn:Show() + end + + local mapID = CurrentMapID() + if not mapID then + return + end + local canvas = Canvas() or f + local width, height = canvas:GetWidth() or 1, canvas:GetHeight() or 1 + if width < 2 or height < 2 then + if _G.C_Timer and not self.retrying then + self.retrying = true + _G.C_Timer.After(0.2, function() + Overlay.retrying = false + if Overlay.points then + Overlay:Refresh() + end + end) + end + return + end + + local bins, maxN = self:Bins(mapID) + self.bins = bins + local scale = self:CanvasScale() + local layerScale = LayerScale() + local ratio = scale / layerScale -- Kartenflaeche-Einheiten -> Einheiten der Punkte-Ebene + self.lastScale = scale + self.drawnMap = mapID + self.hoverR = (DOT_MAX / 2 + 3) / scale / width -- Trefferradius als Anteil der Kartenbreite + self.hoverRY = (DOT_MAX / 2 + 3) / scale / height + self.lastDraw = { mapID = mapID, dots = #bins } + if self.path and #self.path > 1 and self.pathMap == mapID then + self.lastDraw.dashes = DrawPath(f, canvas, self.path, width, height, ratio, layerScale) + else + HideDashes() + end + + for i, b in ipairs(bins) do + if i > MAX_DOTS then + break + end + local dot = dots[i] + if not dot then + dot = NewDot(f) + dots[i] = dot + end + local px = DOT_MIN + (DOT_MAX - DOT_MIN) * math.min(1, b.n / maxN) + local d = px / layerScale + local rim = (px + RIM * 2) / layerScale + local off, offY = 0, 0 + if b.shared then + -- Kreatur links, Objekt rechts, Quest-Punkt darueber: nebeneinander statt uebereinander + local step = (px * 0.7) / layerScale + if b.cls == "u" then off = -step elseif b.cls == "o" then off = step else offY = step end + end + local x, y = b.x * width * ratio + off, -b.y * height * ratio + offY + local c = COLORS[b.cls] + dot.rim:ClearAllPoints() + dot.rim:SetSize(rim, rim) + dot.rim:SetPoint("CENTER", canvas, "TOPLEFT", x, y) + dot.rim:SetVertexColor(1, 1, 1, 1) + dot.rim:Show() + dot.fill:ClearAllPoints() + dot.fill:SetSize(d, d) + dot.fill:SetPoint("CENTER", canvas, "TOPLEFT", x, y) + dot.fill:SetVertexColor(c[1], c[2], c[3], 1) + dot.fill:Show() + end +end + +-- Mouseover: Namen der Fundorte in der Naehe des Mauszeigers +local function MouseOverMap() + if API.Classic() then + return API.CursorNorm() + end + local wm = _G.WorldMapFrame + local sc = wm and wm.ScrollContainer + if type(sc) ~= "table" or type(sc.GetNormalizedCursorPosition) ~= "function" then + return nil + end + local focus + if GetMouseFoci then + focus = GetMouseFoci() + focus = focus and focus[1] + elseif GetMouseFocus then + focus = GetMouseFocus() + end + local f, ok = focus, (focus == nil) + local depth = 0 + while f and depth < 12 do + if f == sc then + ok = true + break + end + f = f.GetParent and f:GetParent() or nil + depth = depth + 1 + end + if not ok then + return nil + end + local x, y = sc:GetNormalizedCursorPosition() + if not x or x < 0 or x > 1 or y < 0 or y > 1 then + return nil + end + return x, y +end + +function Overlay:HideHover() + self.hoverKey = nil + if self.hoverShown then + self.hoverShown = nil + local tip = _G.GameTooltip + if tip:GetOwner() == _G.WorldMapFrame then + tip:Hide() + end + end +end + +function Overlay:UpdateHover() + if not self.points or not self.bins or not self.drawnMap then + return self:HideHover() + end + local x, y = MouseOverMap() + if not x then + return self:HideHover() + end + local key = math.floor(x * GRID) * 1000 + math.floor(y * GRID) + if key == self.hoverKey and self.hoverShown ~= nil then + return + end + self.hoverKey = key + local r, ry = self.hoverR or 0.02, self.hoverRY or 0.03 + local names = {} + local hit = false + for i, b in ipairs(self.bins) do + if i > MAX_DOTS then + break + end + if math.abs(b.x - x) <= r and math.abs(b.y - y) <= ry then + hit = true + for name, n in pairs(b.names) do + names[name] = (names[name] or 0) + n + end + end + end + if not hit then + return self:HideHover() + end + local list = {} + for name, n in pairs(names) do + list[#list + 1] = { name = name, n = n } + end + table.sort(list, function(a, b) + if a.n ~= b.n then return a.n > b.n end + return a.name < b.name + end) + local tip = _G.GameTooltip + tip:SetOwner(_G.WorldMapFrame, "ANCHOR_CURSOR") + tip:AddLine(FW.L.CODEX_MAP_HOVER, 0.94, 0.75, 0.36) + for i, e in ipairs(list) do + if i > 10 then + tip:AddLine(string.format(FW.L.CODEX_MAP_MORE, #list - 10), 0.8, 0.8, 0.8) + break + end + tip:AddLine(e.name, 0.85, 0.85, 0.85) + end + tip:Show() + self.hoverShown = true +end + +local hoverElapsed = 0 +StartHover = function(f) + f:SetScript("OnUpdate", function(_, elapsed) + hoverElapsed = hoverElapsed + elapsed + if hoverElapsed < 0.08 then + return + end + hoverElapsed = 0 + if Overlay.points then + local sig = API.Signature() + if sig ~= Overlay.lastSig then + Overlay.lastSig = sig + Overlay:Refresh() + end + local sc = Overlay:CanvasScale() + if Overlay.lastScale and math.abs(sc - Overlay.lastScale) > 0.01 then + Overlay:Refresh() + end + Overlay:UpdateHover() + end + end) +end + +function Overlay:Hook() + if self.hooked then + return true + end + local wm = _G.WorldMapFrame + if not wm or not Canvas() then + return false + end + self.hooked = true + if wm.OnMapChanged then + pcall(hooksecurefunc, wm, "OnMapChanged", function() + Overlay:Refresh() + end) + end + if wm.HookScript then + wm:HookScript("OnShow", function() + Overlay:Refresh() + end) + end + if API.Classic() and _G.hooksecurefunc and _G.SetMapZoom then + pcall(hooksecurefunc, "SetMapZoom", function() Overlay:Refresh() end) + end + return true +end + +-- Karte mit dem meisten Gewicht, bevorzugt die aktuelle, wenn sie Punkte hat +function Overlay:BestMap() + local weights = {} + for _, p in ipairs(self.points or {}) do + weights[p.m] = (weights[p.m] or 0) + (p.n or 1) + end + local current = CurrentMapID() + if current and weights[current] then + return current + end + local best, bestN + for m, n in pairs(weights) do + if not bestN or n > bestN or (n == bestN and m < best) then + best, bestN = m, n + end + end + return best +end + +-- Weltkarte oeffnen und auf das passende Gebiet stellen +function Overlay:Focus() + local wm = _G.WorldMapFrame + if not wm or not self.points then + return + end + local target = self:BestMap() + if not wm:IsShown() then + if type(ToggleWorldMap) == "function" then + pcall(ToggleWorldMap) + else + pcall(wm.Show, wm) + end + end + if target and target ~= CurrentMapID() then + API.SetMap(target) + end + self:Refresh() + return target +end + +-- points: Liste { m, x, y, n, name, pin }, label: Text fuers Knoepfchen auf der Karte +function Overlay:Show(points, label, path, pathMap) + if not points or #points == 0 then + FW:Print(FW.L.CODEX_MAP_NONE) + return false + end + self.route = nil -- eine andere Anzeige beendet die laufende Quest-Route + self.points = points + self.label = label + self.path = path + self.pathMap = pathMap + self:Hook() + self:Refresh() + self:Focus() + return true +end + +function Overlay:Clear() + self.route = nil + self:HideHover() + self.points = nil + self.label = nil + self.path = nil + self.pathMap = nil + self.bins = nil + self:Refresh() +end + +------------------------------------------------------------------------ +-- Laufende Quest-Route: folgt dir, solange die Karte offen ist +------------------------------------------------------------------------ + +local ROUTE_MOVE = 0.005 -- Bewegung (Anteil der Kartenbreite), ab der neu gerechnet wird +local ROUTE_ARRIVE = 0.015 -- so nah dran gilt die Station als erreicht +local ROUTE_TICK = 1 -- Sekunden zwischen zwei Pruefungen +local routeGen = 0 + +-- Status der Stationen der aktuellen Route; aendert er sich (Quest angenommen), wird neu gerechnet +local function RouteSignature(points) + local t = {} + for i, p in ipairs(points or {}) do + t[i] = Codex.QuestStatus(p.id) and "x" or "o" + end + return table.concat(t) +end + +local function PlayerLoc() + local loc = FW.Util.GetPlayerLocation() + if loc and loc.mapID and loc.x and loc.y then return loc end + return nil +end + +-- Reihenfolge und Linie neu rechnen (billig: die Positionen der Quests stehen schon in r.cands). +-- Rueckgabe: false, wenn keine Station mehr uebrig ist +function Overlay:RouteRecompute(first) + local r = self.route + if not r then return false end + local loc = PlayerLoc() + local ax, ay = Codex.PlayerPosOn(r.mapID, loc) + if ax then + for _, p in ipairs(r.points or {}) do + if p.m == r.mapID and (p.x - ax) ^ 2 + (p.y - ay) ^ 2 < ROUTE_ARRIVE ^ 2 then + r.skip[p.id] = true + end + end + end + -- nur Quests, die noch offen sind (nicht angenommen, nicht abgegeben) und nicht schon erreicht wurden + local live = {} + for _, c in ipairs(r.cands) do + if not r.skip[c.id] and Codex.QuestStatus(c.id) == nil then live[#live + 1] = c end + end + r.cands = live + local points, mapID, path = Codex.QuestRouteOrder(live, loc) + if #points == 0 then + -- nichts mehr uebrig: Ende; sonst nur nichts in der Naehe (anderes Gebiet): alte Anzeige behalten + if #live == 0 then self:Clear() end + r.last = loc and { m = loc.mapID, x = loc.x, y = loc.y } or nil + return false + end + local mapChanged = r.mapID ~= mapID + self.points, self.path, self.pathMap = points, path, mapID + self.label = string.format(FW.L.CODEX_ROUTE_LABEL, #points) + self.bins = nil + r.points, r.mapID, r.sig = points, mapID, RouteSignature(points) + r.last = loc and { m = loc.mapID, x = loc.x, y = loc.y } or nil + local wm = _G.WorldMapFrame + if mapChanged and not first and wm and wm.IsShown and wm:IsShown() then + API.SetMap(mapID) + end + self:Refresh() + return true +end + +-- Eine Pruefung: neu rechnen, wenn du dich bewegt hast oder sich eine Quest geaendert hat +function Overlay:RouteStep() + local r = self.route + if not r then return false end + local wm = _G.WorldMapFrame + if wm and wm.IsShown and not wm:IsShown() then return false end + local loc = PlayerLoc() + local changed = RouteSignature(r.points) ~= r.sig + if not changed and loc then + local last = r.last + changed = not last or last.m ~= loc.mapID or (loc.x - last.x) ^ 2 + (loc.y - last.y) ^ 2 > ROUTE_MOVE ^ 2 + end + if changed then return self:RouteRecompute(false) end + return false +end + +local function RouteTick(gen) + local r = Overlay.route + if not r or r.gen ~= gen then return end + Overlay:RouteStep() + if Overlay.route and Overlay.route.gen == gen and _G.C_Timer then + _G.C_Timer.After(ROUTE_TICK, function() RouteTick(gen) end) + end +end + +-- ids: Quest-IDs der Liste. Zeigt die Route und haelt sie danach aktuell. +function Overlay:StartRoute(ids, opts) + local loc = PlayerLoc() + local cands = Codex.QuestRouteCandidates(ids, opts) + local points, mapID, path = Codex.QuestRouteOrder(cands, loc) + if #points == 0 then + FW:Print(FW.L.CODEX_ROUTE_NONE) + return false + end + self:Show(points, string.format(FW.L.CODEX_ROUTE_LABEL, #points), path, mapID) + routeGen = routeGen + 1 + self.route = { cands = cands, skip = {}, points = points, mapID = mapID, gen = routeGen, + sig = RouteSignature(points), last = loc and { m = loc.mapID, x = loc.x, y = loc.y } or nil } + if _G.C_Timer then + _G.C_Timer.After(ROUTE_TICK, function() RouteTick(routeGen) end) + end + return true +end + +FW:RegisterEvent("ADDON_LOADED", function(_, name) + if name == "Blizzard_WorldMap" then + Overlay:Hook() + Overlay:Refresh() + end +end, Overlay) + +-- Diagnose: /fwcodex map +function Overlay:Debug() + local canvas = Canvas() + local d = self.lastDraw + FW:Print("Karte: " .. tostring(CurrentMapID()) .. ", Flaeche " .. tostring(canvas and canvas:GetWidth()) .. " x " + .. tostring(canvas and canvas:GetHeight()) .. ", Ebene " .. tostring(canvas and canvas:GetFrameLevel()) + .. ", Overlay-Ebene " .. tostring(layer and layer:GetFrameLevel())) + FW:Print("Punkte geladen: " .. tostring(self.points and #self.points or 0) .. ", gezeichnet: " .. tostring(d and d.dots) + .. ", Maske: " .. tostring(MaskAvailable()) .. ", Skalierung: " .. string.format("%.2f", self:CanvasScale())) + local wm = _G.WorldMapFrame + FW:Print("Kartenfenster sichtbar: " .. tostring(wm and wm:IsShown()) .. ", Kartenflaeche sichtbar: " .. tostring(canvas and canvas:IsVisible()) + .. ", Punkte-Ebene sichtbar: " .. tostring(layer and layer:IsVisible()) .. ", Ebenen-Skalierung: " .. string.format("%.2f", LayerScale())) + API.Debug() + local seen = {} + for _, p in ipairs(self.points or {}) do + if p.m and not seen[p.m] then + seen[p.m] = true + local c, z = API.Resolve(p.m) + FW:Print("Punkte-Karte " .. p.m .. " (" .. tostring(_G.C_Map and _G.C_Map.GetMapInfo and (_G.C_Map.GetMapInfo(p.m) or {}).name) .. ") -> Kontinent " + .. tostring(c) .. ", Zone " .. tostring(z)) + end + end + local first = dots and dots[1] + if first then + FW:Print("Erster Punkt: sichtbar " .. tostring(first.fill:IsVisible()) .. ", Grösse " .. tostring(first.fill:GetWidth()) + .. ", Ebene der Ebene sichtbar " .. tostring(layer and layer:IsVisible())) + end +end + +-- Test: grosser roter Kasten in der Kartenmitte, zeigt ob ueberhaupt gezeichnet wird +function Overlay:Test() + local f = EnsureLayer() + if not f then + FW:Print("Weltkarte nicht gefunden.") + return + end + if not f.testTex then + f.testTex = f:CreateTexture(nil, "OVERLAY") + f.testTex:SetColorTexture(1, 0.1, 0.1, 0.6) + f.testTex:SetSize(160, 160) + f.testTex:SetPoint("CENTER", f, "CENTER", 0, 0) + end + if f.testTex:IsShown() then + f.testTex:Hide() + else + f.testTex:Show() + end +end + +-- /fwcodex map square: Punkte als Quadrate (falls die runde Maske Probleme macht) +function Overlay:ToggleSquare() + local st = Codex.db.settings + st.squareDots = not st.squareDots + for _, d in ipairs(dots or {}) do + HideDot(d) + end + dots = {} + FW:Print("Codex-Karte: " .. (st.squareDots and "Quadrate" or "runde Punkte")) + self:Refresh() +end