From edc52fdd860c6bc4ef3964e83141c45ab5ce176b Mon Sep 17 00:00:00 2001 From: Sarah Kuhn Date: Wed, 30 Sep 2026 09:41:35 +0200 Subject: [PATCH] Addon CritTracker implementiert (WoW Forever 12.x Beta, Interface 16001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Getrennte Erfassung für Schaden und Heilung - Erkennung schwacher Crits mit einstellbarem Schwellenwert (Standard 30%) - Audio-Feedback (crit1-4, heal1-4, low.mp3) für Deutsch und Englisch - Einstellungsmenü mit Cooldown-Slider (1-30s), Sound-Auswahl und Test-Buttons - Automatischer Reset der Rekorde bei Stufenaufstieg (PLAYER_LEVEL_UP) - Lokale Chat-Benachrichtigungen (nur für Spieler sichtbar) - Slash-Befehle /ctr und /crittracker - Entwickler- & Agenten-Leitfaden (AGENT_GUIDE.md) hinzugefügt - sounds/English/low.mp3 repariert --- AGENT_GUIDE.md | 185 ++++++++++++++++ Config.lua | 488 +++++++++++++++++++++++++++++++++++++++++ CritTracker.lua | 398 +++++++++++++++++++++++++++++++++ CritTracker.toc | 13 ++ Localization.lua | 157 +++++++++++++ README.md | 44 ++++ sounds/English/low.mp3 | Bin 1024 -> 27876 bytes 7 files changed, 1285 insertions(+) create mode 100644 AGENT_GUIDE.md create mode 100644 Config.lua create mode 100644 CritTracker.lua create mode 100644 CritTracker.toc create mode 100644 Localization.lua diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md new file mode 100644 index 0000000..544aa8b --- /dev/null +++ b/AGENT_GUIDE.md @@ -0,0 +1,185 @@ +# CritTracker – Entwickler- & Agenten-Leitfaden (Developer & Agent Guide) + +Dieses Dokument dient als technische Referenz für Entwickler und KI-Agenten, die das Addon **CritTracker** nachvollziehen, erweitern oder in einer vergleichbaren Umgebung reproduzieren möchten. + +--- + +## 1. System- & Zielumgebung + +- **Spiel / Client:** World of Warcraft - Forever (Beta) +- **Engine-Basis:** WoW Retail 12.x Engine +- **TOC Interface-Version:** `16001` (`## Interface: 16001`) +- **Addon-Verzeichnis:** `Interface/AddOns/CritTracker/` +- **Slash-Befehle:** `/ctr`, `/crittracker` (Hinweis: `/ct` wurde bewusst vermieden, um Namenskonflikte mit anderen Addons wie CTMod auszuschließen). + +--- + +## 2. Projektstruktur + +``` +CritTracker/ +├── CritTracker.toc # Addon-Manifest für Interface 16001 +├── Localization.lua # Lokalisierungs-Strings (deDE und enUS) +├── CritTracker.lua # Core Engine: Event-Handling, Combat-Log, Cooldown, Reset +├── Config.lua # UI: 2-Spalten Einstellungsmenü (Modern Settings API + Standalone) +├── README.md # Benutzerdokumentation +├── AGENT_GUIDE.md # Diese technische Spezifikation für Agenten +└── sounds/ + ├── German/ # Deutsche Audio-Dateien + │ ├── crit1.mp3 .. crit4.mp3 + │ ├── heal1.mp3 .. heal4.mp3 + │ └── low.mp3 + └── English/ # Englische Audio-Dateien + ├── crit1.mp3 .. crit4.mp3 + ├── heal1.mp3 .. heal4.mp3 + └── low.mp3 +``` + +> [!IMPORTANT] +> **Audio-Asset-Korrektur:** Die ursprüngliche Datei `sounds/English/low.mp3` war eine fehlerhafte 1.024-Byte-Null-Datei (Stille). Sie wurde durch die funktionierende Audiodatei aus `sounds/German/low.mp3` ersetzt, damit englische Clients nicht stumm bleiben. + +--- + +## 3. Datenarchitektur (`SavedVariables: CritTrackerDB`) + +Die Daten werden in `CritTrackerDB` gespeichert. Die Konfiguration ist accountweit geteilt, während die Rekorde strikt pro Charakter und Realm isoliert sind. + +```lua +CritTrackerDB = { + settings = { + enabled = true, -- Addon an/aus + enableChat = true, -- Lokale Chat-Ausgabe an/aus + enableSound = true, -- Soundausgabe an/aus + enableLowCrit = true, -- Erkennung schwacher Crits an/aus + lowThreshold = 30, -- Grenzwert in Prozent (Standard: 30%) + cooldown = 5, -- Cooldown in Sekunden (1-30s, Schieberegler) + damageSound = "crit1", -- "crit1", "crit2", "crit3", "crit4", "none" + healSound = "heal1", -- "heal1", "heal2", "heal3", "heal4", "none" + lowSound = "low", -- "low", "none" + soundChannel = "Master", -- "Master", "Dialog", "SFX" + language = "auto", -- "auto", "German", "English" + announceMode = "all", -- "all" oder "records_only" + }, + chars = { + ["CharakterName - RealmName"] = { + level = 80, + maxDamage = 152000, + maxDamageSpell = "Feuerball", + maxHeal = 89000, + maxHealSpell = "Lichtblitz", + } + } +} +``` + +--- + +## 4. Logik- & Entscheidungsfluss (Core Engine) + +### 4.1. Combat Log Filterung (`COMBAT_LOG_EVENT_UNFILTERED`) + +Die Engine verwendet `CombatLogGetCurrentEventInfo()` mit folgenden Regeln: + +1. **Source-Check:** Nur Events mit `sourceGUID == UnitGUID("player")` werden verarbeitet (Spieler-Treffer). +2. **Sub-Events:** + - **Schaden:** `SWING_DAMAGE`, `SPELL_DAMAGE`, `RANGE_DAMAGE`, `SPELL_PERIODIC_DAMAGE`, `SPELL_BUILDING_DAMAGE`. + - Effektiver Schaden: `amount + absorbed` (Absorbe werden dem Treffer zugerechnet). + - Bedingung: `critical == true`. + - **Heilung:** `SPELL_HEAL`, `SPELL_PERIODIC_HEAL`, `SPELL_BUILDING_HEAL`. + - Effektive Heilung: `amount + overhealing` (Overheal wird berücksichtigt, damit große Heiler-Crits bei voller HP nicht fälschlich als "Low Crit" eingestuft werden). + - Bedingung: `critical == true`. + +### 4.2. Ablaufdiagramm der Crit-Verarbeitung + +```mermaid +flowchart TD + A["Combat Log Crit Event (Player)"] --> B{"Addon aktiviert?"} + B -- Nein --> End[Abbruch] + B -- Ja --> C["Hole bisherigen Rekord (Schaden/Heilung)"] + + C --> D{"amount > currentRecord?"} + D -- Ja --> E["Neuer Rekord! Aktualisiere charData (maxRecord = amount)"] + D -- Nein --> F{"enableLowCrit UND currentRecord > 0 UND amount <= (currentRecord * threshold)?"} + + F -- Ja --> G["Als Low-Crit markieren (Sound: low.mp3)"] + F -- Nein --> H["Als normaler Crit markieren (Sound: critX / healX)"] + + E --> I{"GetTime() - lastOutputTime < cooldown?"} + G --> I + H --> I + + I -- Ja (im Cooldown) --> J["Ausgabe unterdrückt (Anti-Spam)"] + I -- Nein (Cooldown frei) --> K["lastOutputTime = GetTime()"] + + K --> L["PlaySoundFile(soundPath, soundChannel)"] + K --> M["DEFAULT_CHAT_FRAME:AddMessage(formatierte Nachricht)"] +``` + +--- + +## 5. Kernfunktionen im Detail + +### 5.1. Level-Up Reset +- **Event:** `PLAYER_LEVEL_UP` +- **Sicherheits-Check:** Beim Login (`PLAYER_LOGIN`) wird geprüft, ob `UnitLevel("player") > charData.level`. Falls der Spieler offline oder bei deaktiviertem Addon aufgestiegen ist, wird der Reset nachträglich durchgeführt. +- **Wirkung:** `maxDamage = 0`, `maxHeal = 0`, `level = newLevel`. +- **Chat-Meldung:** Gibt lokalen Hinweis aus: `[CritTracker] Stufenaufstieg! Krit-Rekorde wurden auf 0 zurückgesetzt.` + +### 5.2. Cooldown für Audio- & Chat-Ausgabe +- Einstellbar von **1 bis 30 Sekunden** über den Schieberegler in den Optionen. +- **Wichtig für Agenten:** Der Cooldown blockiert **nicht** das Speichern neuer Rekorde! Trifft ein Spieler während des Cooldowns einen neuen Rekordwert, wird `charData.maxDamage` sofort aktualisiert. Lediglich die Soundwiedergabe und der Textchat werden gedrosselt, um Tonaussetzer und Chatüberflutung zu verhindern. + +### 5.3. Lokale Chat-Ausgabe +- Alle Chat-Meldungen verwenden `DEFAULT_CHAT_FRAME:AddMessage(msg)` oder `print(msg)`. +- Es werden **keine** öffentlichen Chatkanäle (`SAY`, `PARTY`, `RAID`) angesprochen. Die Ausgabe ist garantiert nur für den lokalen Client sichtbar. +- Abschaltbar über `CritTrackerDB.settings.enableChat`. + +### 5.4. Zweisprachigkeit (Localization Architecture) +- Lokalisierungs-Proxy via Lua-Metatable in `CritTracker.L`: + ```lua + CritTracker.L = setmetatable({}, { + __index = function(_, key) + local lang = CritTracker:GetActiveLanguage() + local locKey = (lang == "German") and "deDE" or "enUS" + return addonTable.Locales[locKey][key] or addonTable.Locales["enUS"][key] or key + end + }) + ``` +- **Sprachauflösung:** + - `auto`: Prüft `GetLocale()`. Bei `deDE` wird Deutsch gewählt, bei allen anderen Sprachen Englisch. + - `German`: Erzwingt Deutsch. + - `English`: Erzwingt Englisch. +- **Audio-Pfad:** + `Interface\AddOns\CritTracker\sounds\\.mp3` + +--- + +## 6. UI-Architektur (`Config.lua`) + +Das Einstellungsmenü ist für maximale Kompatibilität mit modernem Retail WoW (Dragonflight, War Within, Midnight / 12.x) ausgelegt: + +1. **Modern Settings API:** + - Registrierung über `Settings.RegisterCanvasLayoutCategory(panel, "CritTracker")` und `Settings.RegisterAddOnCategory(category)`. + - Öffnen über `Settings.OpenToCategory(category:GetID())`. +2. **Standalone-Fallback:** + - Sollte das Settings-Menü im Betastatus nicht ansprechbar sein oder während des Kampfes aufgerufen werden, öffnet sich das Panel automatisch als bewegliches Standalone-Fenster (`BackdropTemplateMixin`, Close-Button, Dragging). +3. **2-Spalten-Layout:** + - **Linke Spalte:** Checkboxen (Addon, Chat, Sound, Low-Crit), Cooldown-Slider (1–30s), Schwellenwert-Slider (5–100%), Charakter-Rekordanzeige und Reset-Button. + - **Rechte Spalte:** Sound-Auswahl (Schaden, Heilung, Low, Kanal), Sprachauswahl und Test-Buttons (`►`). + +--- + +## 7. Anleitung zur Nachbildung / Testen + +1. **Dateien anlegen:** Die Dateien `CritTracker.toc`, `Localization.lua`, `CritTracker.lua` und `Config.lua` wie spezifiziert erstellen. +2. **Sounds ablegen:** + - `sounds/German/`: `crit1.mp3` bis `crit4.mp3`, `heal1.mp3` bis `heal4.mp3`, `low.mp3`. + - `sounds/English/`: `crit1.mp3` bis `crit4.mp3`, `heal1.mp3` bis `heal4.mp3`, `low.mp3`. +3. **Im Spiel testen:** + - `/ctr` eingeben -> Einstellungsfenster muss sich öffnen. + - Auf die `►`-Buttons klicken -> Die jeweiligen MP3-Dateien müssen hörbar abgespielt werden. + - `/ctr test crit` -> Testet Schadensmeldung und Audio. + - `/ctr test heal` -> Testet Heilungsmeldung und Audio. + - `/ctr test low` -> Testet Low-Crit-Meldung und `low.mp3`. + - Schieberegler verstellen -> Werte in `CritTrackerDB.settings` müssen persistieren. + - `/ctr reset` -> Rekorde werden auf 0 gesetzt. diff --git a/Config.lua b/Config.lua new file mode 100644 index 0000000..5c9b61b --- /dev/null +++ b/Config.lua @@ -0,0 +1,488 @@ +--[[ + CritTracker - Configuration & Options UI + Supports Modern WoW 12.x / Retail Settings API and Standalone UI +]] + +local addonName, addonTable = ... +local CritTracker = addonTable.CritTracker + +local panel = CreateFrame("Frame", "CritTrackerOptionsPanel", UIParent) +CritTracker.optionsPanel = panel +panel.name = "CritTracker" + +-- Standalone Window Styling (Close Button & Dragging) +panel:SetSize(660, 560) +panel:SetMovable(true) +panel:EnableMouse(true) +panel:RegisterForDrag("LeftButton") +panel:SetScript("OnDragStart", panel.StartMoving) +panel:SetScript("OnDragStop", panel.StopMovingOrSizing) + +-- Close Button for Standalone Mode +local closeBtn = CreateFrame("Button", nil, panel, "UIPanelCloseButton") +closeBtn:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -6, -6) +closeBtn:SetScript("OnClick", function() + panel:Hide() +end) + +-- UI Element References for Language Updating +local uiLabels = {} + +local function RegisterLabel(fontString, locKey) + table.insert(uiLabels, { element = fontString, key = locKey, isText = true }) + fontString:SetText(CritTracker.L[locKey] or locKey) +end + +-- Controls State Update Helper +local function UpdateSegmentedButtons(buttons, selectedKey) + for key, btn in pairs(buttons) do + if key == selectedKey then + btn:LockHighlight() + btn:SetText("|cffffd100" .. (btn.displayLabel or key) .. "|r") + else + btn:UnlockHighlight() + btn:SetText(btn.displayLabel or key) + end + end +end + +-- Refresh Panel Displays +function CritTracker:UpdateOptionsDisplay() + if not self.db then return end + + -- Checkboxes + if self.ui_cbEnable then self.ui_cbEnable:SetChecked(self.db.enabled) end + if self.ui_cbChat then self.ui_cbChat:SetChecked(self.db.enableChat) end + if self.ui_cbSound then self.ui_cbSound:SetChecked(self.db.enableSound) end + if self.ui_cbLow then self.ui_cbLow:SetChecked(self.db.enableLowCrit) end + + -- Sliders + if self.ui_sliderCooldown then + self.ui_sliderCooldown:SetValue(self.db.cooldown or 5) + self.ui_lblCooldown:SetText(string.format(self.L["OPT_COOLDOWN_TITLE"], self.db.cooldown or 5)) + end + if self.ui_sliderThreshold then + self.ui_sliderThreshold:SetValue(self.db.lowThreshold or 30) + self.ui_lblThreshold:SetText(string.format(self.L["OPT_THRESHOLD_TITLE"], self.db.lowThreshold or 30)) + end + + -- Audio Buttons + if self.ui_damageButtons then + UpdateSegmentedButtons(self.ui_damageButtons, self.db.damageSound or "crit1") + end + if self.ui_healButtons then + UpdateSegmentedButtons(self.ui_healButtons, self.db.healSound or "heal1") + end + if self.ui_lowButtons then + UpdateSegmentedButtons(self.ui_lowButtons, self.db.lowSound or "low") + end + if self.ui_channelButtons then + UpdateSegmentedButtons(self.ui_channelButtons, self.db.soundChannel or "Master") + end + if self.ui_langButtons then + UpdateSegmentedButtons(self.ui_langButtons, self.db.language or "auto") + end + + -- Character Stats + local charData = self:GetCharData() + local dmgNum = self:FormatNumber(charData.maxDamage) + local dmgSpell = (charData.maxDamageSpell ~= "") and charData.maxDamageSpell or self.L["OPT_STATS_NONE"] + local healNum = self:FormatNumber(charData.maxHeal) + local healSpell = (charData.maxHealSpell ~= "") and charData.maxHealSpell or self.L["OPT_STATS_NONE"] + + if self.ui_lblDamageRecord then + self.ui_lblDamageRecord:SetText(string.format(self.L["OPT_STATS_DAMAGE"], "|cffff4444" .. dmgNum .. "|r", dmgSpell)) + end + if self.ui_lblHealRecord then + self.ui_lblHealRecord:SetText(string.format(self.L["OPT_STATS_HEAL"], "|cff44ff44" .. healNum .. "|r", healSpell)) + end + + -- Dynamic Language Labels + for _, item in ipairs(uiLabels) do + if item.isText and item.element and item.key then + item.element:SetText(self.L[item.key] or item.key) + end + end +end + +-- Initialize UI Components +function CritTracker:InitConfig() + -- Title & Header + local title = panel:CreateFontString(nil, "ARTWORK", "GameFontNormalLarge") + title:SetPoint("TOPLEFT", panel, "TOPLEFT", 20, -18) + title:SetText("|cff00e5ffCritTracker|r") + + local subtitle = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + subtitle:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -4) + subtitle:SetText(self.L["ADDON_SUBTITLE"] .. " |cff888888v1.0.0 (TOC 16001)|r") + + ------------------------------------------------------------------------- + -- LEFT COLUMN: General Options, Cooldown/Threshold Sliders, Character Stats + ------------------------------------------------------------------------- + local colLeftX = 20 + local curY = -70 + + -- Section: General + local secGeneral = panel:CreateFontString(nil, "ARTWORK", "GameFontNormal") + secGeneral:SetPoint("TOPLEFT", panel, "TOPLEFT", colLeftX, curY) + RegisterLabel(secGeneral, "OPT_SECTION_GENERAL") + curY = curY - 24 + + -- Checkbox: Enable Addon + local cbEnable = CreateFrame("CheckButton", "CritTrackerCB_Enable", panel, "UICheckButtonTemplate") + cbEnable:SetPoint("TOPLEFT", panel, "TOPLEFT", colLeftX, curY) + cbEnable.Text:SetText(self.L["OPT_ENABLE"]) + RegisterLabel(cbEnable.Text, "OPT_ENABLE") + cbEnable:SetScript("OnClick", function(selfBtn) + CritTracker.db.enabled = selfBtn:GetChecked() + end) + self.ui_cbEnable = cbEnable + curY = curY - 28 + + -- Checkbox: Enable Chat + local cbChat = CreateFrame("CheckButton", "CritTrackerCB_Chat", panel, "UICheckButtonTemplate") + cbChat:SetPoint("TOPLEFT", panel, "TOPLEFT", colLeftX, curY) + cbChat.Text:SetText(self.L["OPT_CHAT"]) + RegisterLabel(cbChat.Text, "OPT_CHAT") + cbChat:SetScript("OnClick", function(selfBtn) + CritTracker.db.enableChat = selfBtn:GetChecked() + end) + self.ui_cbChat = cbChat + curY = curY - 28 + + -- Checkbox: Enable Sound + local cbSound = CreateFrame("CheckButton", "CritTrackerCB_Sound", panel, "UICheckButtonTemplate") + cbSound:SetPoint("TOPLEFT", panel, "TOPLEFT", colLeftX, curY) + cbSound.Text:SetText(self.L["OPT_SOUND"]) + RegisterLabel(cbSound.Text, "OPT_SOUND") + cbSound:SetScript("OnClick", function(selfBtn) + CritTracker.db.enableSound = selfBtn:GetChecked() + end) + self.ui_cbSound = cbSound + curY = curY - 28 + + -- Checkbox: Enable Low Crit + local cbLow = CreateFrame("CheckButton", "CritTrackerCB_Low", panel, "UICheckButtonTemplate") + cbLow:SetPoint("TOPLEFT", panel, "TOPLEFT", colLeftX, curY) + cbLow.Text:SetText(self.L["OPT_LOW"]) + RegisterLabel(cbLow.Text, "OPT_LOW") + cbLow:SetScript("OnClick", function(selfBtn) + CritTracker.db.enableLowCrit = selfBtn:GetChecked() + end) + self.ui_cbLow = cbLow + curY = curY - 36 + + -- Section: Cooldown & Threshold + local secCooldown = panel:CreateFontString(nil, "ARTWORK", "GameFontNormal") + secCooldown:SetPoint("TOPLEFT", panel, "TOPLEFT", colLeftX, curY) + RegisterLabel(secCooldown, "OPT_SECTION_COOLDOWN_THRESHOLD") + curY = curY - 22 + + -- Slider: Cooldown (1 - 30 seconds) + local lblCooldown = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + lblCooldown:SetPoint("TOPLEFT", panel, "TOPLEFT", colLeftX + 4, curY) + self.ui_lblCooldown = lblCooldown + curY = curY - 16 + + local sliderCooldown = CreateFrame("Slider", "CritTrackerSlider_Cooldown", panel, "OptionsSliderTemplate") + sliderCooldown:SetPoint("TOPLEFT", panel, "TOPLEFT", colLeftX + 6, curY) + sliderCooldown:SetWidth(270) + sliderCooldown:SetMinMaxValues(1, 30) + sliderCooldown:SetValueStep(1) + sliderCooldown:SetObeyStepOnDrag(true) + _G[sliderCooldown:GetName() .. "Low"]:SetText("1s") + _G[sliderCooldown:GetName() .. "High"]:SetText("30s") + _G[sliderCooldown:GetName() .. "Text"]:SetText("") + sliderCooldown:SetScript("OnValueChanged", function(selfSlider, val) + val = math.floor(val + 0.5) + CritTracker.db.cooldown = val + lblCooldown:SetText(string.format(CritTracker.L["OPT_COOLDOWN_TITLE"], val)) + end) + self.ui_sliderCooldown = sliderCooldown + curY = curY - 42 + + -- Slider: Low Threshold (5% - 100%, default 30%) + local lblThreshold = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + lblThreshold:SetPoint("TOPLEFT", panel, "TOPLEFT", colLeftX + 4, curY) + self.ui_lblThreshold = lblThreshold + curY = curY - 16 + + local sliderThreshold = CreateFrame("Slider", "CritTrackerSlider_Threshold", panel, "OptionsSliderTemplate") + sliderThreshold:SetPoint("TOPLEFT", panel, "TOPLEFT", colLeftX + 6, curY) + sliderThreshold:SetWidth(270) + sliderThreshold:SetMinMaxValues(5, 100) + sliderThreshold:SetValueStep(5) + sliderThreshold:SetObeyStepOnDrag(true) + _G[sliderThreshold:GetName() .. "Low"]:SetText("5%") + _G[sliderThreshold:GetName() .. "High"]:SetText("100%") + _G[sliderThreshold:GetName() .. "Text"]:SetText("") + sliderThreshold:SetScript("OnValueChanged", function(selfSlider, val) + val = math.floor(val + 0.5) + CritTracker.db.lowThreshold = val + lblThreshold:SetText(string.format(CritTracker.L["OPT_THRESHOLD_TITLE"], val)) + end) + self.ui_sliderThreshold = sliderThreshold + curY = curY - 44 + + -- Section: Character Records + local secRecords = panel:CreateFontString(nil, "ARTWORK", "GameFontNormal") + secRecords:SetPoint("TOPLEFT", panel, "TOPLEFT", colLeftX, curY) + RegisterLabel(secRecords, "OPT_SECTION_RECORDS") + curY = curY - 20 + + -- Box for Records + local recordBox = CreateFrame("Frame", nil, panel) + recordBox:SetPoint("TOPLEFT", panel, "TOPLEFT", colLeftX, curY) + recordBox:SetSize(280, 80) + local bgTex = recordBox:CreateTexture(nil, "BACKGROUND") + bgTex:SetAllPoints() + bgTex:SetColorTexture(0, 0, 0, 0.35) + + local lblCharName = recordBox:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + lblCharName:SetPoint("TOPLEFT", recordBox, "TOPLEFT", 8, -8) + lblCharName:SetText("|cff00e5ff" .. CritTracker:GetCharKey() .. "|r") + + local lblDmg = recordBox:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + lblDmg:SetPoint("TOPLEFT", lblCharName, "BOTTOMLEFT", 0, -6) + self.ui_lblDamageRecord = lblDmg + + local lblHeal = recordBox:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + lblHeal:SetPoint("TOPLEFT", lblDmg, "BOTTOMLEFT", 0, -4) + self.ui_lblHealRecord = lblHeal + + -- Reset Button + local resetBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") + resetBtn:SetPoint("TOPLEFT", recordBox, "BOTTOMLEFT", 0, -8) + resetBtn:SetSize(160, 24) + resetBtn:SetText(self.L["OPT_RESET_BUTTON"]) + RegisterLabel(resetBtn:GetFontString(), "OPT_RESET_BUTTON") + resetBtn:SetScript("OnClick", function() + CritTracker:ResetRecords() + end) + + ------------------------------------------------------------------------- + -- RIGHT COLUMN: Audio Selection, Test Buttons, Language + ------------------------------------------------------------------------- + local colRightX = 330 + local curRightY = -70 + + -- Section: Audio + local secAudio = panel:CreateFontString(nil, "ARTWORK", "GameFontNormal") + secAudio:SetPoint("TOPLEFT", panel, "TOPLEFT", colRightX, curRightY) + RegisterLabel(secAudio, "OPT_SECTION_AUDIO") + curRightY = curRightY - 24 + + -- Damage Sound Row + local lblDmgSound = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + lblDmgSound:SetPoint("TOPLEFT", panel, "TOPLEFT", colRightX, curRightY) + RegisterLabel(lblDmgSound, "OPT_DAMAGE_SOUND") + curRightY = curRightY - 18 + + self.ui_damageButtons = {} + local dmgOptions = { "crit1", "crit2", "crit3", "crit4", "none" } + local btnX = colRightX + for _, opt in ipairs(dmgOptions) do + local btn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") + btn:SetPoint("TOPLEFT", panel, "TOPLEFT", btnX, curRightY) + btn:SetSize((opt == "none") and 42 or 46, 22) + btn.displayLabel = (opt == "none") and (self.L["OPT_SOUND_NONE"]) or opt + btn:SetText(btn.displayLabel) + btn:SetScript("OnClick", function() + CritTracker.db.damageSound = opt + UpdateSegmentedButtons(CritTracker.ui_damageButtons, opt) + end) + self.ui_damageButtons[opt] = btn + btnX = btnX + ((opt == "none") and 44 or 48) + end + + local testDmgBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") + testDmgBtn:SetPoint("TOPLEFT", panel, "TOPLEFT", btnX + 4, curRightY) + testDmgBtn:SetSize(52, 22) + testDmgBtn:SetText("►") + testDmgBtn:SetScript("OnClick", function() + CritTracker:PlaySound(CritTracker.db.damageSound or "crit1") + end) + curRightY = curRightY - 34 + + -- Heal Sound Row + local lblHealSound = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + lblHealSound:SetPoint("TOPLEFT", panel, "TOPLEFT", colRightX, curRightY) + RegisterLabel(lblHealSound, "OPT_HEAL_SOUND") + curRightY = curRightY - 18 + + self.ui_healButtons = {} + local healOptions = { "heal1", "heal2", "heal3", "heal4", "none" } + btnX = colRightX + for _, opt in ipairs(healOptions) do + local btn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") + btn:SetPoint("TOPLEFT", panel, "TOPLEFT", btnX, curRightY) + btn:SetSize((opt == "none") and 42 or 46, 22) + btn.displayLabel = (opt == "none") and (self.L["OPT_SOUND_NONE"]) or opt + btn:SetText(btn.displayLabel) + btn:SetScript("OnClick", function() + CritTracker.db.healSound = opt + UpdateSegmentedButtons(CritTracker.ui_healButtons, opt) + end) + self.ui_healButtons[opt] = btn + btnX = btnX + ((opt == "none") and 44 or 48) + end + + local testHealBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") + testHealBtn:SetPoint("TOPLEFT", panel, "TOPLEFT", btnX + 4, curRightY) + testHealBtn:SetSize(52, 22) + testHealBtn:SetText("►") + testHealBtn:SetScript("OnClick", function() + CritTracker:PlaySound(CritTracker.db.healSound or "heal1") + end) + curRightY = curRightY - 34 + + -- Low Sound Row + local lblLowSound = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + lblLowSound:SetPoint("TOPLEFT", panel, "TOPLEFT", colRightX, curRightY) + RegisterLabel(lblLowSound, "OPT_LOW_SOUND") + curRightY = curRightY - 18 + + self.ui_lowButtons = {} + local lowOptions = { "low", "none" } + btnX = colRightX + for _, opt in ipairs(lowOptions) do + local btn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") + btn:SetPoint("TOPLEFT", panel, "TOPLEFT", btnX, curRightY) + btn:SetSize((opt == "none") and 42 or 52, 22) + btn.displayLabel = (opt == "none") and (self.L["OPT_SOUND_NONE"]) or opt + btn:SetText(btn.displayLabel) + btn:SetScript("OnClick", function() + CritTracker.db.lowSound = opt + UpdateSegmentedButtons(CritTracker.ui_lowButtons, opt) + end) + self.ui_lowButtons[opt] = btn + btnX = btnX + ((opt == "none") and 44 or 54) + end + + local testLowBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") + testLowBtn:SetPoint("TOPLEFT", panel, "TOPLEFT", btnX + 4, curRightY) + testLowBtn:SetSize(52, 22) + testLowBtn:SetText("►") + testLowBtn:SetScript("OnClick", function() + CritTracker:PlaySound(CritTracker.db.lowSound or "low") + end) + curRightY = curRightY - 36 + + -- Sound Channel Row + local lblChannel = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + lblChannel:SetPoint("TOPLEFT", panel, "TOPLEFT", colRightX, curRightY) + RegisterLabel(lblChannel, "OPT_SOUND_CHANNEL") + curRightY = curRightY - 18 + + self.ui_channelButtons = {} + local chanOptions = { "Master", "Dialog", "SFX" } + btnX = colRightX + for _, opt in ipairs(chanOptions) do + local btn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") + btn:SetPoint("TOPLEFT", panel, "TOPLEFT", btnX, curRightY) + btn:SetSize(62, 22) + btn.displayLabel = opt + btn:SetText(opt) + btn:SetScript("OnClick", function() + CritTracker.db.soundChannel = opt + UpdateSegmentedButtons(CritTracker.ui_channelButtons, opt) + end) + self.ui_channelButtons[opt] = btn + btnX = btnX + 66 + end + curRightY = curRightY - 38 + + -- Section: Language + local secLang = panel:CreateFontString(nil, "ARTWORK", "GameFontNormal") + secLang:SetPoint("TOPLEFT", panel, "TOPLEFT", colRightX, curRightY) + RegisterLabel(secLang, "OPT_SECTION_LANGUAGE") + curRightY = curRightY - 22 + + local lblLang = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + lblLang:SetPoint("TOPLEFT", panel, "TOPLEFT", colRightX, curRightY) + RegisterLabel(lblLang, "OPT_LANGUAGE") + curRightY = curRightY - 18 + + self.ui_langButtons = {} + local langOptions = { + { id = "auto", label = "OPT_LANG_AUTO", width = 85 }, + { id = "German", label = "OPT_LANG_DE", width = 85 }, + { id = "English", label = "OPT_LANG_EN", width = 85 }, + } + btnX = colRightX + for _, item in ipairs(langOptions) do + local btn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") + btn:SetPoint("TOPLEFT", panel, "TOPLEFT", btnX, curRightY) + btn:SetSize(item.width, 22) + btn.displayLabel = self.L[item.label] or item.id + btn:SetText(btn.displayLabel) + btn:SetScript("OnClick", function() + CritTracker.db.language = item.id + CritTracker:UpdateOptionsDisplay() + end) + self.ui_langButtons[item.id] = btn + btnX = btnX + item.width + 4 + end + + -- Initial display refresh + panel:SetScript("OnShow", function() + CritTracker:UpdateOptionsDisplay() + end) + + -- Register with Modern Settings API if available + if Settings and Settings.RegisterCanvasLayoutCategory then + local category = Settings.RegisterCanvasLayoutCategory(panel, "CritTracker") + Settings.RegisterAddOnCategory(category) + CritTracker.settingsCategory = category + elseif InterfaceOptions_AddCategory then + InterfaceOptions_AddCategory(panel) + end +end + +-- Open Options Method +function CritTracker:OpenOptions() + if InCombatLockdown and InCombatLockdown() then + self:Print("|cffff4444[CritTracker]|r Einstellungen können während des Kampfes nicht geöffnet werden.") + return + end + + local opened = false + if Settings and Settings.OpenToCategory and self.settingsCategory then + local ok = pcall(function() + Settings.OpenToCategory(self.settingsCategory:GetID()) + end) + if ok then + opened = true + end + end + + if not opened and InterfaceOptionsFrame_OpenToCategory and panel then + local ok = pcall(function() + InterfaceOptionsFrame_OpenToCategory(panel) + end) + if ok then + opened = true + end + end + + -- Fallback to Standalone Movable Dialog Window + if not opened and panel then + if panel:GetParent() ~= UIParent then + panel:SetParent(UIParent) + end + if not panel.hasBackdrop and BackdropTemplateMixin then + Mixin(panel, BackdropTemplateMixin) + panel:SetBackdrop({ + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark", + edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", + tile = true, tileSize = 32, edgeSize = 32, + insets = { left = 8, right = 8, top = 8, bottom = 8 }, + }) + panel.hasBackdrop = true + end + panel:ClearAllPoints() + panel:SetPoint("CENTER", UIParent, "CENTER", 0, 0) + panel:Show() + self:UpdateOptionsDisplay() + end +end diff --git a/CritTracker.lua b/CritTracker.lua new file mode 100644 index 0000000..85d4788 --- /dev/null +++ b/CritTracker.lua @@ -0,0 +1,398 @@ +--[[ + CritTracker - Core Engine + World of Warcraft - Forever (12.x Beta / Interface 16001) +]] + +local addonName, addonTable = ... + +local CritTracker = CreateFrame("Frame", "CritTrackerFrame") +addonTable.CritTracker = CritTracker +_G["CritTracker"] = CritTracker + +-- Timing & State +CritTracker.lastOutputTime = 0 + +-- Defaults +local defaultSettings = { + enabled = true, + enableChat = true, + enableSound = true, + enableLowCrit = true, + lowThreshold = 30, -- 30% of highest recorded crit + cooldown = 5, -- 5 seconds output cooldown (1-30s) + damageSound = "crit1", -- crit1, crit2, crit3, crit4, none + healSound = "heal1", -- heal1, heal2, heal3, heal4, none + lowSound = "low", -- low, none + soundChannel = "Master", -- Master, Dialog, SFX + language = "auto", -- "auto", "German", "English" + announceMode = "all", -- "all" (subject to cooldown) or "records_only" +} + +-- Dynamic Localization Proxy +CritTracker.L = setmetatable({}, { + __index = function(_, key) + local lang = CritTracker:GetActiveLanguage() + local locKey = (lang == "German") and "deDE" or "enUS" + local locTable = (addonTable.Locales and addonTable.Locales[locKey]) or (addonTable.Locales and addonTable.Locales["enUS"]) + if locTable and locTable[key] then + return locTable[key] + end + if addonTable.Locales and addonTable.Locales["enUS"] and addonTable.Locales["enUS"][key] then + return addonTable.Locales["enUS"][key] + end + return key + end +}) + +-- Language Resolution +function CritTracker:GetActiveLanguage() + local setting = (self.db and self.db.language) or "auto" + if setting == "German" then + return "German" + elseif setting == "English" then + return "English" + else + -- Auto: German client -> German, all others -> English + local locale = GetLocale() + if locale == "deDE" then + return "German" + else + return "English" + end + end +end + +-- Sound File Path +function CritTracker:GetSoundFilePath(soundKey) + local lang = self:GetActiveLanguage() + return "Interface\\AddOns\\CritTracker\\sounds\\" .. lang .. "\\" .. soundKey .. ".mp3" +end + +-- Play Sound +function CritTracker:PlaySound(soundKey) + if not self.db.enableSound or not soundKey or soundKey == "none" then + return + end + local path = self:GetSoundFilePath(soundKey) + local channel = self.db.soundChannel or "Master" + PlaySoundFile(path, channel) +end + +-- Print Message (Visible strictly to the local player) +function CritTracker:Print(msg) + if not msg then return end + if DEFAULT_CHAT_FRAME then + DEFAULT_CHAT_FRAME:AddMessage(msg) + else + print(msg) + end +end + +-- Format Numbers with commas / dots +function CritTracker:FormatNumber(n) + if not n then return "0" end + if BreakUpLargeNumbers then + return BreakUpLargeNumbers(n) + end + local left, num, right = string.match(tostring(n), '^([^%d]*%d)(%d*)(.-)$') + if not left then return tostring(n) end + return left .. (num:reverse():gsub('(%d%d%d)', '%1.'):reverse()) .. right +end + +-- Character Data Accessor (strictly per character & realm) +function CritTracker:GetCharKey() + local name = UnitName("player") or "Unknown" + local realm = GetRealmName() or "UnknownRealm" + return name .. " - " .. realm +end + +function CritTracker:GetCharData() + if not CritTrackerDB then + CritTrackerDB = {} + end + if not CritTrackerDB.chars then + CritTrackerDB.chars = {} + end + local key = self:GetCharKey() + if not CritTrackerDB.chars[key] then + CritTrackerDB.chars[key] = { + level = UnitLevel("player") or 1, + maxDamage = 0, + maxDamageSpell = "", + maxHeal = 0, + maxHealSpell = "", + } + end + return CritTrackerDB.chars[key] +end + +-- Reset Records for Current Character +function CritTracker:ResetRecords() + local charData = self:GetCharData() + charData.maxDamage = 0 + charData.maxDamageSpell = "" + charData.maxHeal = 0 + charData.maxHealSpell = "" + charData.level = UnitLevel("player") or 1 + + local charName = UnitName("player") or "Player" + self:Print(string.format(self.L["RESET_SUCCESS"], charName)) + + if self.UpdateOptionsDisplay then + self:UpdateOptionsDisplay() + end +end + +-- Level Up Handler +function CritTracker:OnLevelUp(newLevel) + local charData = self:GetCharData() + charData.maxDamage = 0 + charData.maxDamageSpell = "" + charData.maxHeal = 0 + charData.maxHealSpell = "" + charData.level = newLevel or UnitLevel("player") + + self:Print(string.format(self.L["LEVEL_UP_RESET"], charData.level)) + + if self.UpdateOptionsDisplay then + self:UpdateOptionsDisplay() + end +end + +-- Initialization +function CritTracker:OnInitialize() + if not CritTrackerDB then + CritTrackerDB = {} + end + if not CritTrackerDB.settings then + CritTrackerDB.settings = {} + end + for k, v in pairs(defaultSettings) do + if CritTrackerDB.settings[k] == nil then + CritTrackerDB.settings[k] = v + end + end + self.db = CritTrackerDB.settings + + -- Initialize Character + local charData = self:GetCharData() + local curLevel = UnitLevel("player") + if charData.level and charData.level > 0 and curLevel > charData.level then + -- Leveled up while offline / addon disabled + self:OnLevelUp(curLevel) + else + charData.level = curLevel + end + + if self.InitConfig then + self:InitConfig() + end +end + +-- Main Crit Processing +function CritTracker:ProcessCrit(isHeal, spellName, amount, destName) + if not self.db or not self.db.enabled or not amount or amount <= 0 then + return + end + + local charData = self:GetCharData() + local currentRecord = isHeal and charData.maxHeal or charData.maxDamage + local isNewRecord = (amount > currentRecord) + local isLowCrit = false + + local thresholdPercent = self.db.lowThreshold or 30 + local thresholdValue = math.floor(currentRecord * (thresholdPercent / 100)) + + -- Check for Low Crit + if self.db.enableLowCrit and currentRecord > 0 and not isNewRecord and amount <= thresholdValue then + isLowCrit = true + end + + -- Update Record if higher + if isNewRecord then + if isHeal then + charData.maxHeal = amount + charData.maxHealSpell = spellName or "" + else + charData.maxDamage = amount + charData.maxDamageSpell = spellName or "" + end + + if self.UpdateOptionsDisplay then + self:UpdateOptionsDisplay() + end + end + + -- Cooldown Check for Audio and Text Output + local now = GetTime() + local cooldown = self.db.cooldown or 5 + if (now - self.lastOutputTime) < cooldown then + -- Suppressed by Cooldown (Records are already preserved) + return + end + + -- Determine Sound and Text + local soundKey = nil + local chatMsg = nil + local destStr = (destName and destName ~= "") and string.format(self.L["TARGET_ON"], destName) or "" + local spellStr = (spellName and spellName ~= "") and spellName or self.L["UNKNOWN"] + local amountStr = self:FormatNumber(amount) + local recordStr = self:FormatNumber(currentRecord) + + if isLowCrit then + soundKey = self.db.lowSound or "low" + local formatStr = isHeal and self.L["LOW_CRIT_HEAL"] or self.L["LOW_CRIT_DAMAGE"] + chatMsg = string.format(formatStr, destStr, spellStr, amountStr, thresholdPercent, recordStr) + elseif isNewRecord then + soundKey = isHeal and (self.db.healSound or "heal1") or (self.db.damageSound or "crit1") + local formatStr = isHeal and self.L["NEW_RECORD_HEAL"] or self.L["NEW_RECORD_DAMAGE"] + chatMsg = string.format(formatStr, destStr, spellStr, amountStr) + else + -- Normal Crit (between threshold and current record) + if self.db.announceMode == "all" then + soundKey = isHeal and (self.db.healSound or "heal1") or (self.db.damageSound or "crit1") + local formatStr = isHeal and self.L["NORMAL_CRIT_HEAL"] or self.L["NORMAL_CRIT_DAMAGE"] + chatMsg = string.format(formatStr, destStr, spellStr, amountStr) + end + end + + -- If something triggered, apply cooldown and output + if soundKey or chatMsg then + self.lastOutputTime = now + + if self.db.enableSound and soundKey and soundKey ~= "none" then + self:PlaySound(soundKey) + end + + if self.db.enableChat and chatMsg then + self:Print(chatMsg) + end + end +end + +-- Combat Log Parser +function CritTracker:OnCombatLogEvent() + local timestamp, subevent, hideCaster, sourceGUID, sourceName, sourceFlags, sourceRaidFlags, + destGUID, destName, destFlags, destRaidFlags, + arg12, arg13, arg14, arg15, arg16, arg17, arg18, arg19, arg20, + arg21, arg22, arg23, arg24 = CombatLogGetCurrentEventInfo() + + if not sourceGUID or sourceGUID ~= UnitGUID("player") then + return + end + + if subevent == "SWING_DAMAGE" then + local amount = arg12 + local overkill = arg13 + local school = arg14 + local resisted = arg15 + local blocked = arg16 + local absorbed = arg17 + local critical = arg18 + + if critical and critical ~= 0 then + local totalDamage = (amount or 0) + (absorbed or 0) + self:ProcessCrit(false, self.L["MELEE_ATTACK"], totalDamage, destName) + end + + elseif subevent == "SPELL_DAMAGE" or subevent == "RANGE_DAMAGE" + or subevent == "SPELL_PERIODIC_DAMAGE" or subevent == "SPELL_BUILDING_DAMAGE" then + local spellId = arg12 + local spellName = arg13 + local spellSchool = arg14 + local amount = arg15 + local overkill = arg16 + local school = arg17 + local resisted = arg18 + local blocked = arg19 + local absorbed = arg20 + local critical = arg21 + + if critical and critical ~= 0 then + local totalDamage = (amount or 0) + (absorbed or 0) + self:ProcessCrit(false, spellName, totalDamage, destName) + end + + elseif subevent == "SPELL_HEAL" or subevent == "SPELL_PERIODIC_HEAL" or subevent == "SPELL_BUILDING_HEAL" then + local spellId = arg12 + local spellName = arg13 + local spellSchool = arg14 + local amount = arg15 + local overhealing = arg16 + local absorbed = arg17 + local critical = arg18 + + if critical and critical ~= 0 then + local totalHeal = (amount or 0) + (overhealing or 0) + self:ProcessCrit(true, spellName, totalHeal, destName) + end + end +end + +-- Event Registration +CritTracker:RegisterEvent("ADDON_LOADED") +CritTracker:RegisterEvent("PLAYER_LOGIN") +CritTracker:RegisterEvent("PLAYER_LEVEL_UP") +CritTracker:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED") + +CritTracker:SetScript("OnEvent", function(self, event, ...) + if event == "COMBAT_LOG_EVENT_UNFILTERED" then + self:OnCombatLogEvent() + elseif event == "PLAYER_LEVEL_UP" then + local newLevel = ... + self:OnLevelUp(newLevel) + elseif event == "ADDON_LOADED" then + local name = ... + if name == addonName then + self:OnInitialize() + end + elseif event == "PLAYER_LOGIN" then + local charData = self:GetCharData() + local curLevel = UnitLevel("player") + if charData.level and charData.level > 0 and curLevel > charData.level then + self:OnLevelUp(curLevel) + else + charData.level = curLevel + end + end +end) + +-- Slash Commands +SLASH_CRITTRACKER1 = "/crittracker" +SLASH_CRITTRACKER2 = "/ctr" +SlashCmdList["CRITTRACKER"] = function(msg) + local cmd, arg = string.match((msg or ""):lower():trim(), "^(%S*)%s*(.-)$") + + if cmd == "" or cmd == "config" or cmd == "options" or cmd == "menu" then + if CritTracker.OpenOptions then + CritTracker:OpenOptions() + end + elseif cmd == "reset" then + CritTracker:ResetRecords() + elseif cmd == "stats" then + local charData = CritTracker:GetCharData() + local dmgStr = CritTracker:FormatNumber(charData.maxDamage) + local healStr = CritTracker:FormatNumber(charData.maxHeal) + local dmgSpell = (charData.maxDamageSpell ~= "") and charData.maxDamageSpell or CritTracker.L["OPT_STATS_NONE"] + local healSpell = (charData.maxHealSpell ~= "") and charData.maxHealSpell or CritTracker.L["OPT_STATS_NONE"] + local name = UnitName("player") or "Player" + CritTracker:Print(string.format(CritTracker.L["CURRENT_STATS"], name, charData.level or 1, dmgStr, dmgSpell, healStr, healSpell)) + elseif cmd == "test" then + if arg == "heal" then + CritTracker:PlaySound(CritTracker.db.healSound or "heal1") + CritTracker:Print(string.format(CritTracker.L["NORMAL_CRIT_HEAL"], "", "Test Heal", CritTracker:FormatNumber(50000))) + elseif arg == "low" then + CritTracker:PlaySound("low") + CritTracker:Print(string.format(CritTracker.L["LOW_CRIT_DAMAGE"], "", "Test Low Hit", CritTracker:FormatNumber(150), CritTracker.db.lowThreshold, CritTracker:FormatNumber(1000))) + else + CritTracker:PlaySound(CritTracker.db.damageSound or "crit1") + CritTracker:Print(string.format(CritTracker.L["NORMAL_CRIT_DAMAGE"], "", "Test Strike", CritTracker:FormatNumber(125000))) + end + else + CritTracker:Print(CritTracker.L["SLASH_HELP_HEADER"]) + CritTracker:Print(CritTracker.L["SLASH_HELP_CONFIG"]) + CritTracker:Print(CritTracker.L["SLASH_HELP_STATS"]) + CritTracker:Print(CritTracker.L["SLASH_HELP_RESET"]) + CritTracker:Print(CritTracker.L["SLASH_HELP_TEST"]) + end +end diff --git a/CritTracker.toc b/CritTracker.toc new file mode 100644 index 0000000..c58be37 --- /dev/null +++ b/CritTracker.toc @@ -0,0 +1,13 @@ +## Interface: 16001 +## Title: CritTracker +## Title-deDE: CritTracker +## Notes: Tracks critical strikes and heals with sound alerts and chat announcements. +## Notes-deDE: Zeichnet kritische Treffer und Heilungen getrennt auf, mit Sound- und Chat-Benachrichtigungen. +## Author: Sarah +## Version: 1.0.0 +## SavedVariables: CritTrackerDB +## IconTexture: Interface\Icons\Spell_Nature_BloodLust + +Localization.lua +CritTracker.lua +Config.lua diff --git a/Localization.lua b/Localization.lua new file mode 100644 index 0000000..c3ff511 --- /dev/null +++ b/Localization.lua @@ -0,0 +1,157 @@ +--[[ + CritTracker Localization + Supports German (deDE) and English (default / enUS) +]] + +local _, addonTable = ... + +local L_enUS = { + -- General / Branding + ["ADDON_TITLE"] = "CritTracker", + ["ADDON_SUBTITLE"] = "World of Warcraft - Forever (12.x Beta)", + ["MELEE_ATTACK"] = "Melee Attack", + ["UNKNOWN"] = "Unknown", + ["TARGET_ON"] = "on %s", + + -- Chat Announcements (Player only) + ["NEW_RECORD_DAMAGE"] = "|cff00e5ff[CritTracker]|r |cffffd100★ NEW DAMAGE RECORD! ★|r %s with %s: |cffff4444%s|r!", + ["NEW_RECORD_HEAL"] = "|cff00e5ff[CritTracker]|r |cffffd100★ NEW HEAL RECORD! ★|r %s with %s: |cff44ff44%s|r!", + ["NORMAL_CRIT_DAMAGE"] = "|cff00e5ff[CritTracker]|r |cffff8800Critical Strike:|r %s with %s: |cffffffff%s|r", + ["NORMAL_CRIT_HEAL"] = "|cff00e5ff[CritTracker]|r |cff00ff88Critical Heal:|r %s with %s: |cffffffff%s|r", + ["LOW_CRIT_DAMAGE"] = "|cff00e5ff[CritTracker]|r |cff999999Low Crit (Damage):|r %s with %s: |cffff8888%s|r (|cffaaaaaa<= %d%% of record %s|r)", + ["LOW_CRIT_HEAL"] = "|cff00e5ff[CritTracker]|r |cff999999Low Crit (Heal):|r %s with %s: |cff88ff88%s|r (|cffaaaaaa<= %d%% of record %s|r)", + ["LEVEL_UP_RESET"] = "|cff00e5ff[CritTracker]|r Level Up (%d)! Crit records have been reset to 0.", + ["RESET_SUCCESS"] = "|cff00e5ff[CritTracker]|r Crit records for %s have been reset to 0.", + ["CURRENT_STATS"] = "|cff00e5ff[CritTracker]|r Current Records for %s (Level %d):\n - Damage: |cffff4444%s|r (%s)\n - Healing: |cff44ff44%s|r (%s)", + + -- Options UI + ["OPT_SECTION_GENERAL"] = "General Options", + ["OPT_ENABLE"] = "Enable Addon", + ["OPT_ENABLE_DESC"] = "Enable or disable CritTracker completely.", + ["OPT_CHAT"] = "Enable Chat Output", + ["OPT_CHAT_DESC"] = "Show critical strikes and heals in your chat window (visible to you only).", + ["OPT_SOUND"] = "Enable Audio Alerts", + ["OPT_SOUND_DESC"] = "Play audio cues for critical strikes, heals, and low crits.", + ["OPT_LOW"] = "Enable Low Crit Alerts", + ["OPT_LOW_DESC"] = "Trigger special alert and sound when a critical hit/heal falls below your threshold.", + + ["OPT_SECTION_COOLDOWN_THRESHOLD"] = "Cooldown & Threshold", + ["OPT_COOLDOWN_TITLE"] = "Alert Cooldown: %d sec.", + ["OPT_COOLDOWN_DESC"] = "Minimum time between chat and audio announcements (1-30 seconds). Prevents audio overlapping and chat spam.", + ["OPT_THRESHOLD_TITLE"] = "Low Crit Threshold: %d%%", + ["OPT_THRESHOLD_DESC"] = "Percentage of your current record. Crits at or below this value are considered low crits (Default: 30%%).", + + ["OPT_SECTION_AUDIO"] = "Audio Settings", + ["OPT_DAMAGE_SOUND"] = "Damage Crit Sound:", + ["OPT_HEAL_SOUND"] = "Heal Crit Sound:", + ["OPT_LOW_SOUND"] = "Low Crit Sound:", + ["OPT_SOUND_CHANNEL"] = "Sound Channel:", + ["OPT_CHANNEL_MASTER"] = "Master", + ["OPT_CHANNEL_DIALOG"] = "Dialog", + ["OPT_CHANNEL_SFX"] = "SFX", + ["OPT_SOUND_NONE"] = "None", + + ["OPT_SECTION_LANGUAGE"] = "Language / Sprache", + ["OPT_LANGUAGE"] = "Voice & Text Language:", + ["OPT_LANG_AUTO"] = "Auto (Client)", + ["OPT_LANG_DE"] = "German (Deutsch)", + ["OPT_LANG_EN"] = "English", + + ["OPT_TEST_BUTTON"] = "Play Test", + ["OPT_TEST_DAMAGE"] = "Test Damage", + ["OPT_TEST_HEAL"] = "Test Heal", + ["OPT_TEST_LOW"] = "Test Low", + + ["OPT_SECTION_RECORDS"] = "Character Records", + ["OPT_STATS_HEADER"] = "Current character records:", + ["OPT_STATS_DAMAGE"] = "Highest Damage: %s (%s)", + ["OPT_STATS_HEAL"] = "Highest Heal: %s (%s)", + ["OPT_STATS_NONE"] = "None recorded yet", + ["OPT_RESET_BUTTON"] = "Reset Records", + ["OPT_RESET_TOOLTIP"] = "Reset highest damage and healing records for this character to 0.", + + -- Slash Command Help + ["SLASH_HELP_HEADER"] = "|cff00e5ffCritTracker Commands:|r", + ["SLASH_HELP_CONFIG"] = " |cffffd100/ctr|r or |cffffd100/crittracker|r - Open settings panel", + ["SLASH_HELP_STATS"] = " |cffffd100/ctr stats|r - Show current records", + ["SLASH_HELP_RESET"] = " |cffffd100/ctr reset|r - Reset current character records", + ["SLASH_HELP_TEST"] = " |cffffd100/ctr test [crit|heal|low]|r - Test sound and chat alert", +} + +local L_deDE = { + -- General / Branding + ["ADDON_TITLE"] = "CritTracker", + ["ADDON_SUBTITLE"] = "World of Warcraft - Forever (12.x Beta)", + ["MELEE_ATTACK"] = "Nahkampfangriff", + ["UNKNOWN"] = "Unbekannt", + ["TARGET_ON"] = "an %s", + + -- Chat Announcements (Player only) + ["NEW_RECORD_DAMAGE"] = "|cff00e5ff[CritTracker]|r |cffffd100★ NEUER SCHADENS-REKORD! ★|r %s mit %s: |cffff4444%s|r!", + ["NEW_RECORD_HEAL"] = "|cff00e5ff[CritTracker]|r |cffffd100★ NEUER HEILUNGS-REKORD! ★|r %s mit %s: |cff44ff44%s|r!", + ["NORMAL_CRIT_DAMAGE"] = "|cff00e5ff[CritTracker]|r |cffff8800Kritischer Treffer:|r %s mit %s: |cffffffff%s|r", + ["NORMAL_CRIT_HEAL"] = "|cff00e5ff[CritTracker]|r |cff00ff88Kritische Heilung:|r %s mit %s: |cffffffff%s|r", + ["LOW_CRIT_DAMAGE"] = "|cff00e5ff[CritTracker]|r |cff999999Schwacher Krit (Schaden):|r %s mit %s: |cffff8888%s|r (|cffaaaaaa<= %d%% von Rekord %s|r)", + ["LOW_CRIT_HEAL"] = "|cff00e5ff[CritTracker]|r |cff999999Schwache kritische Heilung:|r %s mit %s: |cff88ff88%s|r (|cffaaaaaa<= %d%% von Rekord %s|r)", + ["LEVEL_UP_RESET"] = "|cff00e5ff[CritTracker]|r Stufenaufstieg (%d)! Krit-Rekorde wurden auf 0 zurückgesetzt.", + ["RESET_SUCCESS"] = "|cff00e5ff[CritTracker]|r Krit-Rekorde für %s wurden auf 0 zurückgesetzt.", + ["CURRENT_STATS"] = "|cff00e5ff[CritTracker]|r Aktuelle Rekorde für %s (Stufe %d):\n - Schaden: |cffff4444%s|r (%s)\n - Heilung: |cff44ff44%s|r (%s)", + + -- Options UI + ["OPT_SECTION_GENERAL"] = "Allgemeine Optionen", + ["OPT_ENABLE"] = "Addon aktivieren", + ["OPT_ENABLE_DESC"] = "Aktiviert oder deaktiviert CritTracker vollständig.", + ["OPT_CHAT"] = "Chat-Ausgabe aktivieren", + ["OPT_CHAT_DESC"] = "Zeigt kritische Treffer und Heilungen im Chat an (nur für dich sichtbar).", + ["OPT_SOUND"] = "Sound-Ausgabe aktivieren", + ["OPT_SOUND_DESC"] = "Spielt Sounds bei kritischen Treffern, Heilungen und schwachen Crits ab.", + ["OPT_LOW"] = "Schwache Crits erkennen & melden", + ["OPT_LOW_DESC"] = "Meldet kritische Treffer oder Heilungen, die unter dem Grenzwert deines Rekords liegen.", + + ["OPT_SECTION_COOLDOWN_THRESHOLD"] = "Cooldown & Grenzwert", + ["OPT_COOLDOWN_TITLE"] = "Ausgabe-Cooldown: %d Sek.", + ["OPT_COOLDOWN_DESC"] = "Mindestabstand zwischen Chat- und Sound-Ausgaben (1-30 Sekunden). Verhindert Audio-Überlagerungen und Chat-Spam bei schnellen Crits.", + ["OPT_THRESHOLD_TITLE"] = "Grenzwert für schwache Crits: %d%%", + ["OPT_THRESHOLD_DESC"] = "Prozentsatz deines aktuellen Rekords. Ein Krit bei oder unter diesem Wert gilt als schwacher Krit (Standard: 30%%).", + + ["OPT_SECTION_AUDIO"] = "Audio-Einstellungen", + ["OPT_DAMAGE_SOUND"] = "Sound für Schaden-Crits:", + ["OPT_HEAL_SOUND"] = "Sound für Heilung-Crits:", + ["OPT_LOW_SOUND"] = "Sound für schwache Crits:", + ["OPT_SOUND_CHANNEL"] = "Sound-Kanal:", + ["OPT_CHANNEL_MASTER"] = "Master", + ["OPT_CHANNEL_DIALOG"] = "Dialog", + ["OPT_CHANNEL_SFX"] = "SFX", + ["OPT_SOUND_NONE"] = "Aus", + + ["OPT_SECTION_LANGUAGE"] = "Sprache / Language", + ["OPT_LANGUAGE"] = "Audio- & Textsprache:", + ["OPT_LANG_AUTO"] = "Automatisch (Client)", + ["OPT_LANG_DE"] = "Deutsch (German)", + ["OPT_LANG_EN"] = "English", + + ["OPT_TEST_BUTTON"] = "Test abspielen", + ["OPT_TEST_DAMAGE"] = "Test Schaden", + ["OPT_TEST_HEAL"] = "Test Heilung", + ["OPT_TEST_LOW"] = "Test Low", + + ["OPT_SECTION_RECORDS"] = "Charakter-Rekorde", + ["OPT_STATS_HEADER"] = "Aktuelle Rekorde dieses Charakters:", + ["OPT_STATS_DAMAGE"] = "Höchster Schaden: %s (%s)", + ["OPT_STATS_HEAL"] = "Höchste Heilung: %s (%s)", + ["OPT_STATS_NONE"] = "Noch keine Rekorde erfasst", + ["OPT_RESET_BUTTON"] = "Rekorde zurücksetzen", + ["OPT_RESET_TOOLTIP"] = "Setzt den höchsten Schaden- und Heilungswert dieses Charakters auf 0 zurück.", + + -- Slash Command Help + ["SLASH_HELP_HEADER"] = "|cff00e5ffCritTracker Befehle:|r", + ["SLASH_HELP_CONFIG"] = " |cffffd100/ctr|r oder |cffffd100/crittracker|r - Einstellungsmenü öffnen", + ["SLASH_HELP_STATS"] = " |cffffd100/ctr stats|r - Aktuelle Rekorde anzeigen", + ["SLASH_HELP_RESET"] = " |cffffd100/ctr reset|r - Rekorde dieses Charakters auf 0 zurücksetzen", + ["SLASH_HELP_TEST"] = " |cffffd100/ctr test [crit|heal|low]|r - Test-Sound und Chat-Meldung auslösen", +} + +addonTable.Locales = { + ["enUS"] = L_enUS, + ["deDE"] = L_deDE, +} diff --git a/README.md b/README.md index a1d6630..2a6efec 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,46 @@ # CritTracker +CritTracker ist ein World of Warcraft Addon für **World of Warcraft - Forever (12.x Beta)** (TOC Interface `16001`), das kritische Treffer und Heilungen getrennt erfasst und mit anpassbarem Audio- und lokalem Chat-Feedback versieht. + +--- + +## Features + +- **Getrennte Erfassung für Schaden & Heilung:** + - Protokolliert höchste kritische Treffer (Rekorde) für Schaden und Heilung getrennt nach Zauber/Angriff. + - Nur für den Spieler sichtbare Chat-Ausgabe (`AddMessage`), die in den Einstellungen vollständig deaktiviert werden kann. +- **Erkennung schwacher kritischer Treffer ("Low Crits"):** + - Einstellbarer Schwellenwert (standardmäßig **30%** des aktuellen Rekords). + - Spielt bei schwachen Crits die Sound-Datei `low.mp3` ab und gibt eine dezent gefärbte Nachricht aus. +- **Sicherheits-Mechanismen:** + - **Level-Up Reset:** Bei jedem Stufenaufstieg (`PLAYER_LEVEL_UP`) sowie beim ersten Login nach einem Level-Up werden die Rekorde dieses Charakters automatisch auf 0 zurückgesetzt. + - **Ausgabe-Cooldown:** Ein Schieberegler (1–30 Sekunden, Standard 5s) verhindert Audio-Überlagerungen und Chat-Spam bei schnellen aufeinanderfolgenden Crits. + - **Charakterspezifische Datenspeicherung:** Rekorde werden getrennt nach Charakter & Realm gespeichert, sodass Twinks nicht gegen Endgame-Zahlen antreten. +- **Audio-Feedback:** + - Schadens-Crits: Auswahl zwischen `crit1` bis `crit4` (oder deaktiviert). + - Heilungs-Crits: Auswahl zwischen `heal1` bis `heal4` (oder deaktiviert). + - Schwache Crits: `low.mp3` (oder deaktiviert). + - Wählbarer Sound-Kanal (`Master`, `Dialog`, `SFX`). +- **Zweisprachigkeit:** + - Deutsche Clients (`deDE`): Deutsche Benutzeroberfläche und deutsche Sounds (`sounds/German/`). + - Alle anderen Clients: Englische Benutzeroberfläche und englische Sounds (`sounds/English/`). + - Optionale manuelle Sprachauswahl in den Einstellungen. + +--- + +## Befehle (Slash Commands) + +- `/ctr` oder `/crittracker` – Öffnet das Einstellungsmenü. +- `/ctr stats` – Zeigt die aktuellen Rekorde des Charakters im Chat an. +- `/ctr reset` – Setzt die Rekorde des aktuellen Charakters manuell auf 0 zurück. +- `/ctr test [crit|heal|low]` – Löst einen Test-Sound und eine Chat-Nachricht aus. + - `/ctr test crit` (Schadens-Crit Test) + - `/ctr test heal` (Heilungs-Crit Test) + - `/ctr test low` (Schwacher Treffer Test) + +--- + +## Installation + +Kopiere den Ordner `CritTracker` in dein Addon-Verzeichnis: +`World of Warcraft\_retail_\Interface\AddOns\CritTracker` diff --git a/sounds/English/low.mp3 b/sounds/English/low.mp3 index 06d7405020018ddf3cacee90fd4af10487da3d20..6f0e69f82064eefc58b5162d81972013eed06e71 100644 GIT binary patch literal 27876 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 literal 1024 ScmZQz7zLvtFd70QH3R?z00031