-- ---------------------------------------------------------------------------- -- Localized Lua globals. -- ---------------------------------------------------------------------------- -- Functions local pairs = _G.pairs local time = _G.time -- Libraries local table = _G.table -- ---------------------------------------------------------------------------- -- AddOn namespace. -- ---------------------------------------------------------------------------- local AddOnFolderName, private = ... local Data = private.Data local Enum = private.Enum local EventMessage = private.EventMessage local LibStub = _G.LibStub local HereBeDragons = LibStub("HereBeDragons-2.0") local LibSharedMedia = LibStub("LibSharedMedia-3.0") local NPCScan = LibStub("AceAddon-3.0"):GetAddon(AddOnFolderName) -- ---------------------------------------------------------------------------- -- Helpers. -- ---------------------------------------------------------------------------- local ProcessDetection do local throttledNPCs = {} function ProcessDetection(detectionData) local npcID = detectionData.npcID local profile = private.db.profile local detection = profile.detection local throttleTime = throttledNPCs[npcID] local now = time() if not Data.Scanner.NPCs[npcID] or (throttleTime and now < throttleTime + detection.intervalSeconds) or (not detection.whileOnTaxi and _G.UnitOnTaxi("player")) then return end throttledNPCs[npcID] = now detectionData.npcName = detectionData.npcName or NPCScan:GetNPCNameFromID(npcID) detectionData.unitClassification = detectionData.unitClassification or "rare" NPCScan:Pour(_G.ERR_ZONE_EXPLORED:format(("%s %s"):format(detectionData.npcName, _G.PARENS_TEMPLATE:format(detectionData.sourceText))), 0, 1, 0) NPCScan:DispatchSensoryCues() NPCScan:SendMessage(EventMessage.DetectedNPC, detectionData) -- TODO: Make the Overlays object listen for the DetectedNPC message and run its own methods private.Overlays.Found(npcID) private.Overlays.Remove(npcID) end end local function ProcessUnit(unitToken, sourceText) if _G.UnitIsUnit("player", unitToken) then return end local npcID = private.UnitTokenToCreatureID(unitToken) if npcID then local unitIsDead = _G.UnitIsDead(unitToken) if private.db.profile.detection.ignoreDeadNPCs and unitIsDead then return end local detectionData = { isDead = unitIsDead, npcID = npcID, npcName = _G.UnitName(unitToken), sourceText = sourceText, unitClassification = _G.UnitClassification(unitToken), unitCreatureType = _G.UnitCreatureType(unitToken), unitLevel = _G.UnitLevel(unitToken), unitToken = unitToken, } ProcessDetection(detectionData) NPCScan:SendMessage(EventMessage.UnitInformationAvailable, detectionData) end end local function CanAddToScanList(npcID) local profile = private.db.profile if profile.blacklist.npcIDs[npcID] then private.Debug("Skipping %s (%d) - blacklisted.", NPCScan:GetNPCNameFromID(npcID), npcID) return false end local npc = Data.NPCs[npcID] -- This is a custom NPC addition; no further processing is possible. if not npc then return true end if npc.factionGroup == _G.UnitFactionGroup("player") then return false end local isTameable = npc.isTameable local detection = profile.detection if isTameable and not detection.tameables then return false end if not isTameable and not detection.rares then return false end if npc:HasActiveWorldQuest() then return true end local hasQuest = npc:HasQuest() if hasQuest then if not npc:IsQuestComplete() then return true elseif detection.ignoreCompletedQuestObjectives then return false end end local achievementID = npc.achievementID if achievementID then if detection.achievementIDs[achievementID] == Enum.DetectionGroupStatus.Disabled then return false end if detection.ignoreCompletedAchievementCriteria and npc:IsAchievementCriteriaComplete() then return false end elseif npc.worldQuestID and not hasQuest then -- Ignore NPCs with an inactive World Quest but no tracking quest and no achievement. return false end return true end local function MergeUserDefinedWithScanList(npcList) if npcList and private.db.profile.detection.userDefined then for npcID in pairs(npcList) do Data.Scanner.NPCs[npcID] = _G.setmetatable({}, private.NPCMetatable) end end end function NPCScan:UpdateScanList(_, mapID) local scannerData = Data.Scanner mapID = mapID or HereBeDragons:GetPlayerZone() if not mapID or mapID < 0 then return end scannerData.mapID = mapID scannerData.continentID = Data.Maps[mapID].continentID for npcID in pairs(scannerData.NPCs) do private.Overlays.Remove(npcID) end table.wipe(scannerData.NPCs) scannerData.NPCCount = 0 local profile = private.db.profile local userDefined = profile.userDefined -- No zone or continent specified, so always look for these. MergeUserDefinedWithScanList(userDefined.npcIDs) if profile.blacklist.mapIDs[scannerData.mapID] or profile.detection.continentIDs[scannerData.continentID] == Enum.DetectionGroupStatus.Disabled then private.Debug("continentID or mapID is blacklisted; terminating update.") self:SendMessage(EventMessage.ScannerDataUpdated, scannerData) return end local zoneNPCCount = 0 local npcList = Data.Maps[scannerData.mapID].NPCs if npcList then for npcID in pairs(npcList) do if CanAddToScanList(npcID) then zoneNPCCount = zoneNPCCount + 1; scannerData.NPCs[npcID] = Data.NPCs[npcID] private.Overlays.Add(npcID) end end scannerData.NPCCount = zoneNPCCount end MergeUserDefinedWithScanList(userDefined.continentNPCs[scannerData.continentID]) MergeUserDefinedWithScanList(userDefined.mapNPCs[scannerData.mapID]) self:SendMessage(EventMessage.ScannerDataUpdated, scannerData) end -- ---------------------------------------------------------------------------- -- Events. -- ---------------------------------------------------------------------------- local function UpdateScanListAchievementCriteria() local needsUpdate = false for _, npc in pairs(Data.Scanner.NPCs) do if npc.achievementID and npc.achievementCriteriaID and not npc:IsAchievementCriteriaComplete() then local _, _, isCompleted = _G.GetAchievementCriteriaInfoByID(npc.achievementID, npc.achievementCriteriaID) if isCompleted then npc.isCriteriaCompleted = isCompleted private.GetOrUpdateNPCOptions() if private.db.profile.detection.ignoreCompletedAchievementCriteria then needsUpdate = true end end end end if needsUpdate then NPCScan:UpdateScanList() end end private.UpdateScanListAchievementCriteria = UpdateScanListAchievementCriteria local function UpdateScanListQuestObjectives() local needsUpdate = false if private.db.profile.detection.ignoreCompletedQuestObjectives then local NPCs = Data.Scanner.NPCs for npcID in pairs(NPCs) do if NPCs[npcID]:IsQuestComplete() then needsUpdate = true end end if needsUpdate then NPCScan:UpdateScanList() end end end private.UpdateScanListQuestObjectives = UpdateScanListQuestObjectives function NPCScan:ACHIEVEMENT_EARNED(_, achievementID) if Data.Achievements[achievementID] then Data.Achievements[achievementID].isCompleted = true if private.db.profile.detection.ignoreCompletedAchievementCriteria then -- Disable tracking for the achievement, since the above setting implies it. private.db.profile.detection.achievementIDs[achievementID] = Enum.DetectionGroupStatus.Disabled end UpdateScanListAchievementCriteria() end end function NPCScan:CRITERIA_UPDATE() UpdateScanListAchievementCriteria() end -- Apparently some vignette NPC daily quests are only flagged as complete after looting... function NPCScan:LOOT_CLOSED() UpdateScanListQuestObjectives() end function NPCScan:NAME_PLATE_UNIT_ADDED(_, unitToken) ProcessUnit(unitToken, _G.UNIT_NAMEPLATES) end function NPCScan:PLAYER_ENTERING_WORLD() self:UpdateScanList("PLAYER_ENTERING_WORLD", _G.C_Map.GetBestMapForUnit("player")) end function NPCScan:PLAYER_TARGET_CHANGED() ProcessUnit("target", _G.TARGET) end function NPCScan:UPDATE_MOUSEOVER_UNIT() local mouseoverID = private.UnitTokenToCreatureID("mouseover") if mouseoverID ~= private.UnitTokenToCreatureID("target") then ProcessUnit("mouseover", _G.MOUSE_LABEL) end end do local VignetteSourceToPreference = { [_G.MINIMAP_LABEL] = "ignoreMiniMap", [_G.WORLD_MAP] = "ignoreWorldMap", } local IgnoredVignetteAtlasName = { VignetteLoot = true, VignetteLootElite = true, } local function IsIgnoringSource(sourceText) return private.db.profile.detection[VignetteSourceToPreference[sourceText]] end local function ProcessVignetteGUID(vignetteGUID) if not vignetteGUID then return end local vignetteInfo = _G.C_VignetteInfo.GetVignetteInfo(vignetteGUID); if not vignetteInfo or IgnoredVignetteAtlasName[vignetteInfo.atlasName] then return end local sourceText = vignetteInfo.onWorldMap and _G.WORLD_MAP or _G.MINIMAP_LABEL if IsIgnoringSource(sourceText) then return end local vignetteName = vignetteInfo.name local vignetteNPCs = private.VignetteIDToNPCMapping[vignetteInfo.vignetteID] local npcID = private.GUIDToCreatureID(vignetteInfo.objectGUID) if vignetteNPCs then for index = 1, #vignetteNPCs do local vignetteNPC = vignetteNPCs[index] local npc = Data.Scanner.NPCs[vignetteNPC.npcID] if npc then ProcessDetection({ npcID = vignetteNPC.npcID, sourceText = sourceText, unitClassification = npc.classification, vignetteName = vignetteName, }) end end return else private.Debug("Unknown vignette: %s - vignetteID %d (NPC ID %d) in mapID %d", vignetteInfo.name, vignetteInfo.vignetteID, npcID or -1, _G.C_Map.GetBestMapForUnit("player")) end local npc = npcID and Data.Scanner.NPCs[npcID] or nil -- The objectGUID can be but isn't always an NPC ID, since some NPCs must be summoned from the vignette object. if npc then ProcessDetection({ npcID = npcID, sourceText = sourceText, unitClassification = npc.classification, vignetteName = vignetteName, }) return end local questID = private.QuestIDFromName[vignetteName] if questID then for questNPCID, questNPC in pairs(private.QuestNPCs[questID]) do ProcessDetection({ npcID = questNPCID, sourceText = sourceText, unitClassification = questNPC.classification, vignetteName = vignetteName, }) end return elseif sourceText == _G.WORLD_MAP then return end npcID = private.NPCIDFromName[vignetteName] npc = npcID and Data.Scanner.NPCs[npcID] or nil if npc then ProcessDetection({ npcID = npcID, sourceText = sourceText, unitClassification = npc.classification, vignetteName = vignetteName, }) return end end function NPCScan:VIGNETTE_MINIMAP_UPDATED(_, vignetteGUID) ProcessVignetteGUID(vignetteGUID) end function NPCScan:VIGNETTES_UPDATED() local vignetteGUIDs = _G.C_VignetteInfo.GetVignettes() for index = 1, #vignetteGUIDs do ProcessVignetteGUID(vignetteGUIDs[index]) end end end -- do-block -- ---------------------------------------------------------------------------- -- Sensory cues. -- ---------------------------------------------------------------------------- do local MAX_FLASH_LOOPS = 3 local flashFrame = _G.CreateFrame("Frame") flashFrame:Hide() flashFrame:SetAllPoints() flashFrame:SetAlpha(0) flashFrame:SetFrameStrata("FULLSCREEN_DIALOG") local flashTexture = flashFrame:CreateTexture() flashTexture:SetBlendMode("ADD") flashTexture:SetAllPoints() local fadeAnimationGroup = flashFrame:CreateAnimationGroup() fadeAnimationGroup:SetLooping("BOUNCE") fadeAnimationGroup:SetScript("OnLoop", function(self, loopState) if loopState == "FORWARD" then self.LoopCount = self.LoopCount + 1 if self.LoopCount >= MAX_FLASH_LOOPS then self:Stop() flashFrame:Hide() end end end) fadeAnimationGroup:SetScript("OnPlay", function(self) self.LoopCount = 0 end) local fadeAnimIn = private.CreateAlphaAnimation(fadeAnimationGroup, 0, 1, 0.5) fadeAnimIn:SetEndDelay(0.25) local ALERT_SOUND_THROTTLE_INTERVAL_SECONDS = 2 local lastSoundTime = time() function NPCScan:PlayFlashAnimation(texturePath, color) flashTexture:SetTexture(LibSharedMedia:Fetch("background", texturePath)) flashTexture:SetVertexColor(color.r, color.g, color.b, color.a) flashFrame:Show() fadeAnimationGroup:Pause() -- Forces OnPlay to fire again if it was already playing fadeAnimationGroup:Play() end local PlayAlertSounds do local SOUND_RESTORE_INTERVAL_SECONDS = 5 local soundsAreOverridden local StoredSoundCVars = {} local SoundChannelCVars = { Ambience = "Sound_EnableAmbience", Master = "Sound_EnableAllSound", Music = "Sound_EnableMusic", SFX = "Sound_EnableSFX", } local function ResetStoredSoundCVars() for cvar, value in pairs(StoredSoundCVars) do _G.SetCVar(cvar, value) end soundsAreOverridden = nil end function PlayAlertSounds(overrideSoundCVars) local soundPreferences = private.db.profile.alert.sound if overrideSoundCVars and not soundsAreOverridden then local channelCVar = SoundChannelCVars[soundPreferences.channel] StoredSoundCVars[channelCVar] = _G.GetCVar(channelCVar) _G.SetCVar(channelCVar, 1) StoredSoundCVars.Sound_EnableSoundWhenGameIsInBG = _G.GetCVar("Sound_EnableSoundWhenGameIsInBG") _G.SetCVar("Sound_EnableSoundWhenGameIsInBG", 1) soundsAreOverridden = true NPCScan:ScheduleTimer(ResetStoredSoundCVars, SOUND_RESTORE_INTERVAL_SECONDS) end for soundName in pairs(soundPreferences.sharedMediaNames) do if soundPreferences.sharedMediaNames[soundName] ~= false then _G.PlaySoundFile(LibSharedMedia:Fetch("sound", soundName), soundPreferences.channel) end end end private.PlayAlertSounds = PlayAlertSounds end function NPCScan:DispatchSensoryCues() local alert = private.db.profile.alert local now = time() if alert.screenFlash.isEnabled then self:PlayFlashAnimation(alert.screenFlash.texture, alert.screenFlash.color) end if alert.sound.isEnabled and now > lastSoundTime + ALERT_SOUND_THROTTLE_INTERVAL_SECONDS then PlayAlertSounds(alert.sound.ignoreMute) lastSoundTime = now end end end