Bastion aims to serve as a highly performant, simplisitic, and expandable World of Warcraft data visualization framework.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Bastion/src/Unit/Unit.lua

908 lines
22 KiB

local Tinkr, Bastion = ...
-- Create a new Unit class
---@class Unit
local Unit = {
cache = nil,
---@type AuraTable
aura_table = nil,
---@type Unit
unit = nil,
last_shadow_techniques = 0,
swings_since_sht = 0,
last_off_attack = 0,
last_main_attack = 0,
last_combat_time = 0,
}
function Unit:__index(k)
local response = Bastion.ClassMagic:Resolve(Unit, k)
if k == 'unit' then
return rawget(self, k)
end
if response == nil then
response = rawget(self, k)
end
if response == nil then
error("Unit:__index: " .. k .. " does not exist")
end
return response
end
-- Equals
---@param other Unit
---@return boolean
function Unit:__eq(other)
return UnitIsUnit(self:GetOMToken(), other.unit)
end
-- tostring
---@return string
function Unit:__tostring()
return "Bastion.__Unit(" .. tostring(self:GetOMToken()) .. ")" .. " - " .. (self:GetName() or '')
end
-- Constructor
---@param unit string
---@return Unit
function Unit:New(unit)
local self = setmetatable({}, Unit)
self.unit = unit
self.cache = Bastion.Cache:New()
self.aura_table = Bastion.AuraTable:New(self)
self.regression_history = {}
return self
end
-- Check if the unit is valid
---@return boolean
function Unit:IsValid()
return self:GetOMToken() ~= nil and self:Exists()
end
-- Check if the unit exists
---@return boolean
function Unit:Exists()
return Object(self:GetOMToken())
end
-- Get the units token
---@return string
function Unit:Token()
return self:GetOMToken()
end
-- Get the units name
---@return string
function Unit:GetName()
return UnitName(self:GetOMToken())
end
-- Get the units GUID
---@return string
function Unit:GetGUID()
return ObjectGUID(self:GetOMToken())
end
-- Get the units health
---@return number
function Unit:GetHealth()
return UnitHealth(self:GetOMToken())
end
-- Get the units max health
---@return number
function Unit:GetMaxHealth()
return UnitHealthMax(self:GetOMToken())
end
-- Get the units health percentage
---@return number
function Unit:GetHP()
return self:GetHealth() / self:GetMaxHealth() * 100
end
-- Get the units health deficit
---@return number
function Unit:GetHealthPercent()
return self:GetHP()
end
-- Get the units power type
---@return number
function Unit:GetPowerType()
return UnitPowerType(self:GetOMToken())
end
-- Get the units power
---@param powerType number | nil
---@return number
function Unit:GetPower(powerType)
local powerType = powerType or self:GetPowerType()
return UnitPower(self:GetOMToken(), powerType)
end
-- Get the units max power
---@param powerType number | nil
---@return number
function Unit:GetMaxPower(powerType)
local powerType = powerType or self:GetPowerType()
return UnitPowerMax(self:GetOMToken(), powerType)
end
-- Get the units power percentage
---@param powerType number | nil
---@return number
function Unit:GetPP(powerType)
local powerType = powerType or self:GetPowerType()
return self:GetPower(powerType) / self:GetMaxPower(powerType) * 100
end
-- Get the units power deficit
---@param powerType number | nil
---@return number
function Unit:GetPowerDeficit(powerType)
local powerType = powerType or self:GetPowerType()
return self:GetMaxPower(powerType) - self:GetPower(powerType)
end
-- Get the units position
---@return Vector3
function Unit:GetPosition()
local x, y, z = ObjectPosition(self:GetOMToken())
return Bastion.Vector3:New(x, y, z)
end
-- Get the units distance from another unit
---@param unit Unit
---@return number
function Unit:GetDistance(unit)
local pself = self:GetPosition()
local punit = unit:GetPosition()
return pself:Distance(punit)
end
-- Is the unit dead
---@return boolean
function Unit:IsDead()
return UnitIsDeadOrGhost(self:GetOMToken())
end
-- Is the unit alive
---@return boolean
function Unit:IsAlive()
return not UnitIsDeadOrGhost(self:GetOMToken())
end
-- Is the unit a pet
---@return boolean
function Unit:IsPet()
return UnitIsUnit(self:GetOMToken(), "pet")
end
-- Is the unit a friendly unit
---@return boolean
function Unit:IsFriendly()
return UnitIsFriend("player", self:GetOMToken())
end
-- IsEnemy
---@return boolean
function Unit:IsEnemy()
return UnitCanAttack("player", self:GetOMToken())
end
-- Is the unit a hostile unit
---@return boolean
function Unit:IsHostile()
return UnitCanAttack(self:GetOMToken(), 'player')
end
-- Is the unit a boss
---@return boolean
function Unit:IsBoss()
if UnitClassification(self:GetOMToken()) == "worldboss" then
return true
end
for i = 1, 5 do
local bossGUID = UnitGUID("boss" .. i)
if self:GetGUID() == bossGUID then
return true
end
end
return false
end
---@return string
function Unit:GetOMToken()
if not self.unit then
return "none"
end
return self.unit:unit()
end
-- Is the unit a target
---@return boolean
function Unit:IsTarget()
return UnitIsUnit(self:GetOMToken(), "target")
end
-- Is the unit a focus
---@return boolean
function Unit:IsFocus()
return UnitIsUnit(self:GetOMToken(), "focus")
end
-- Is the unit a mouseover
---@return boolean
function Unit:IsMouseover()
return UnitIsUnit(self:GetOMToken(), "mouseover")
end
-- Is the unit a tank
---@return boolean
function Unit:IsTank()
return UnitGroupRolesAssigned(self:GetOMToken()) == "TANK"
end
-- Is the unit a healer
---@return boolean
function Unit:IsHealer()
return UnitGroupRolesAssigned(self:GetOMToken()) == "HEALER"
end
-- Is the unit a damage dealer
---@return boolean
function Unit:IsDamage()
return UnitGroupRolesAssigned(self:GetOMToken()) == "DAMAGER"
end
-- Is the unit a player
---@return boolean
function Unit:IsPlayer()
return UnitIsPlayer(self:GetOMToken())
end
-- Is the unit a player controlled unit
---@return boolean
function Unit:IsPCU()
return UnitPlayerControlled(self:GetOMToken())
end
-- Get if the unit is affecting combat
---@return boolean
function Unit:IsAffectingCombat()
return UnitAffectingCombat(self:GetOMToken())
end
-- Get the units class id
---@return Class
function Unit:GetClass()
local locale, class, classID = UnitClass(self:GetOMToken())
return Bastion.Class:New(locale, class, classID)
end
-- Get the units auras
---@return AuraTable
function Unit:GetAuras()
return self.aura_table
end
-- Get the raw unit
---@return string
function Unit:GetRawUnit()
return self:GetOMToken()
end
local isClassicWow = select(4, GetBuildInfo()) < 40000
-- Check if two units are in melee
-- function Unit:InMelee(unit)
-- return UnitInMelee(self:GetOMToken(), unit:GetOMToken())
-- end
local losFlag = bit.bor(0x1, 0x10, 0x100000)
-- Check if the unit can see another unit
---@param unit Unit
---@return boolean
function Unit:CanSee(unit)
-- mechagon smoke cloud
-- local mechagonID = 2097
-- local smokecloud = 298602
-- local name, instanceType, difficultyID, difficultyName, maxPlayers, dynamicDifficulty, isDynamic, instanceID, instanceGroupSize, LfgDungeonID =
-- GetInstanceInfo()
-- otherUnit = otherUnit and otherUnit or "player"
-- if instanceID == 2097 then
-- if (self:debuff(smokecloud, unit) and not self:debuff(smokecloud, otherUnit))
-- or (self:debuff(smokecloud, otherUnit) and not self:debuff(smokecloud, unit))
-- then
-- return false
-- end
-- end
local ax, ay, az = ObjectPosition(self:GetOMToken())
local ah = ObjectHeight(self:GetOMToken())
local attx, atty, attz = GetUnitAttachmentPosition(unit:GetOMToken(), 34)
if not attx or not ax then
return false
end
if not ah then
return false
end
if (ax == 0 and ay == 0 and az == 0) or (attx == 0 and atty == 0 and attz == 0) then
return true
end
if not attx or not ax then
return false
end
local x, y, z = TraceLine(ax, ay, az + ah, attx, atty, attz, losFlag)
if x ~= 0 or y ~= 0 or z ~= 0 then
return false
else
return true
end
end
-- Check if the unit is casting a spell
---@return boolean
function Unit:IsCasting()
return UnitCastingInfo(self:GetOMToken()) ~= nil
end
-- Get Casting or channeling spell
---@return Spell | nil
function Unit:GetCastingOrChannelingSpell()
local name, text, texture, startTimeMS, endTimeMS, isTradeSkill, castID, notInterruptible, spellId = UnitCastingInfo(self
.unit)
if not name then
name, text, texture, startTimeMS, endTimeMS, isTradeSkill, notInterruptible, spellId = UnitChannelInfo(self.unit
:unit())
end
if name then
return Bastion.SpellBook:GetSpell(spellId)
end
return nil
end
-- Check if the unit is channeling a spell
---@return boolean
function Unit:IsChanneling()
return UnitChannelInfo(self:GetOMToken()) ~= nil
end
-- Check if the unit is casting or channeling a spell
---@return boolean
function Unit:IsCastingOrChanneling()
return self:IsCasting() or self:IsChanneling()
end
-- Check if the unit can attack the target
---@param unit Unit
---@return boolean
function Unit:CanAttack(unit)
return UnitCanAttack(self:GetOMToken(), unit:GetOMToken())
end
---@return number
function Unit:GetChannelOrCastPercentComplete()
local name, text, texture, startTimeMS, endTimeMS, isTradeSkill, castID, notInterruptible, spellId = UnitCastingInfo(self
.unit)
if not name then
name, text, texture, startTimeMS, endTimeMS, isTradeSkill, notInterruptible, spellId = UnitChannelInfo(self.unit
:unit())
end
if name and startTimeMS and endTimeMS then
local start = startTimeMS / 1000
local finish = endTimeMS / 1000
local current = GetTime()
return ((current - start) / (finish - start)) * 100
end
return 0
end
-- Check if unit is interruptible
---@return boolean
function Unit:IsInterruptible()
local name, text, texture, startTimeMS, endTimeMS, isTradeSkill, castID, notInterruptible, spellId = UnitCastingInfo(self
.unit)
if not name then
name, text, texture, startTimeMS, endTimeMS, isTradeSkill, notInterruptible, spellId = UnitChannelInfo(self.unit
:unit())
end
if name then
return not notInterruptible
end
return false
end
-- Check if unit is interruptible
---@param percent number
---@return boolean
function Unit:IsInterruptibleAt(percent)
if not self:IsInterruptible() then
return false
end
local percent = percent or math.random(2, 5)
local castPercent = self:GetChannelOrCastPercentComplete()
if castPercent >= percent then
return true
end
return false
end
-- Get the number of enemies in a given range of the unit and cache the result for .5 seconds
---@param range number
---@return number
function Unit:GetEnemies(range)
local enemies = self.cache:Get("enemies_" .. range)
if enemies then
return enemies
end
local count = 0
Bastion.UnitManager:EnumEnemies(function(unit)
if not self:IsUnit(unit) and unit:GetDistance(self) <= range and unit:IsAlive() and self:CanSee(unit) and
unit:IsEnemy() then
count = count + 1
end
end)
self.cache:Set("enemies_" .. range, count, .5)
return count
end
-- Get the number of melee attackers
---@return number
function Unit:GetMeleeAttackers()
local enemies = self.cache:Get("melee_attackers")
if enemies then
return enemies
end
local count = 0
Bastion.UnitManager:EnumEnemies(function(unit)
if not self:IsUnit(unit) and unit:IsAlive() and self:CanSee(unit) and
self:InMelee(unit) and unit:IsEnemy() and unit:InCombatOdds() > 80 then
count = count + 1
end
end)
self.cache:Set("melee_attackers", count, .5)
return count
end
---@param distance number
---@param percent number
---@return number
function Unit:GetPartyHPAround(distance, percent)
local count = 0
Bastion.UnitManager:EnumFriends(function(unit)
if not self:IsUnit(unit) and unit:GetDistance(self) <= distance and unit:IsAlive() and self:CanSee(unit) and
unit:GetHP() <= percent then
count = count + 1
end
end)
return count
end
-- Is moving
---@return boolean
function Unit:IsMoving()
return GetUnitSpeed(self:GetOMToken()) > 0
end
-- Is moving at all
---@return boolean
function Unit:IsMovingAtAll()
return ObjectMovementFlag(self:GetOMToken()) ~= 0
end
---@param unit Unit | nil
---@return number
function Unit:GetComboPoints(unit)
if Tinkr.classic then
if not unit then
return 0
end
return GetComboPoints(self:GetOMToken(), unit:GetOMToken())
end
return UnitPower(self:GetOMToken(), 4)
end
---@return number
function Unit:GetComboPointsMax()
if Tinkr.classic then
return 5
end
return UnitPowerMax(self:GetOMToken(), 4)
end
-- Get combopoints deficit
---@param unit Unit | nil
---@return number
function Unit:GetComboPointsDeficit(unit)
if Tinkr.classic then
return self:GetComboPointsMax() - self:GetComboPoints(unit)
end
return self:GetComboPointsMax() - self:GetComboPoints()
end
-- IsUnit
---@param unit Unit
---@return boolean
function Unit:IsUnit(unit)
return UnitIsUnit(self:GetOMToken(), unit and unit:GetOMToken() or 'none')
end
-- IsTanking
---@param unit Unit
---@return boolean
function Unit:IsTanking(unit)
local isTanking, status, threatpct, rawthreatpct, threatvalue = UnitDetailedThreatSituation(self:GetOMToken(),
unit:GetOMToken())
return isTanking
end
-- IsFacing
---@param unit Unit
---@return boolean
function Unit:IsFacing(unit)
local rot = ObjectRotation(self:GetOMToken())
local x, y, z = ObjectPosition(self:GetOMToken())
local x2, y2, z2 = ObjectPosition(unit:GetOMToken())
if not x or not x2 or not rot then
return false
end
local angle = math.atan2(y2 - y, x2 - x) - rot
angle = math.deg(angle)
angle = angle % 360
if angle > 180 then
angle = angle - 360
end
return math.abs(angle) < 90
end
-- IsBehind
---@param unit Unit
---@return boolean
function Unit:IsBehind(unit)
local rot = ObjectRotation(unit:GetOMToken())
local x, y, z = ObjectPosition(unit:GetOMToken())
local x2, y2, z2 = ObjectPosition(self:GetOMToken())
if not x or not x2 then
return false
end
local angle = math.atan2(y2 - y, x2 - x) - rot
angle = math.deg(angle)
angle = angle % 360
if angle > 180 then
angle = angle - 360
end
return math.abs(angle) > 90
end
---@return number
function Unit:GetMeleeBoost()
if IsPlayerSpell(196924) then
return 3
end
return 0
end
-- Melee calculation
-- float fMaxDist = fmaxf((float)(*(float*)((uintptr_t)this + 0x1BF8) + 1.3333) + *(float*)((uintptr_t)target + 0x1BF8), 5.0);
-- fMaxDist = fMaxDist + 1.0;
-- Vector3 myPos = ((WoWGameObject*)this)->GetPosition();
-- Vector3 targetPos = ((WoWGameObject*)target)->GetPosition();
-- return ((myPos.x - targetPos.x) * (myPos.x - targetPos.x)) + ((myPos.y - targetPos.y) * (myPos.y - targetPos.y)) + ((myPos.z - targetPos.z) * (myPos.z - targetPos.z)) <= (float)(fMaxDist * fMaxDist);
-- InMelee
---@param unit Unit
---@return boolean
function Unit:InMelee(unit)
local x, y, z = ObjectPosition(self:GetOMToken())
local x2, y2, z2 = ObjectPosition(unit:GetOMToken())
if not x or not x2 then
return false
end
local dist = math.sqrt((x - x2) ^ 2 + (y - y2) ^ 2 + (z - z2) ^ 2)
local maxDist = math.max((ObjectCombatReach(self:GetOMToken()) + 1.3333) + ObjectCombatReach(unit:GetOMToken()), 5.0)
maxDist = maxDist + 1.0 + self:GetMeleeBoost()
return dist <= maxDist
end
-- Get object id
---@return number
function Unit:GetID()
return ObjectID(self:GetOMToken())
end
-- In party
---@return boolean
function Unit:IsInParty()
return UnitInParty(self:GetOMToken())
end
-- Linear regression between time and percent to something
---@param time table
---@param percent table
---@return number, number
function Unit:LinearRegression(time, percent)
local x = time
local y = percent
local n = #x
local sum_x = 0
local sum_y = 0
local sum_xy = 0
local sum_xx = 0
local sum_yy = 0
for i = 1, n do
sum_x = sum_x + x[i]
sum_y = sum_y + y[i]
sum_xy = sum_xy + x[i] * y[i]
sum_xx = sum_xx + x[i] * x[i]
sum_yy = sum_yy + y[i] * y[i]
end
local slope = (n * sum_xy - sum_x * sum_y) / (n * sum_xx - sum_x * sum_x)
local intercept = (sum_y - slope * sum_x) / n
return slope, intercept
end
-- Use linear regression to get the health percent at a given time in the future
---@param time number
---@return number
function Unit:PredictHealth(time)
local x = {}
local y = {}
if #self.regression_history > 20 then
table.remove(self.regression_history, 1)
end
table.insert(self.regression_history, { time = GetTime(), percent = self:GetHP() })
for i = 1, #self.regression_history do
local entry = self.regression_history[i]
table.insert(x, entry.time)
table.insert(y, entry.percent)
end
local slope, intercept = self:LinearRegression(x, y)
return slope * time + intercept
end
-- Use linear regression to guess the time until a given health percent
---@param percent number
---@return number
function Unit:PredictTime(percent)
local x = {}
local y = {}
if #self.regression_history > 20 then
table.remove(self.regression_history, 1)
end
table.insert(self.regression_history, { time = GetTime(), percent = self:GetHP() })
for i = 1, #self.regression_history do
local entry = self.regression_history[i]
table.insert(x, entry.time)
table.insert(y, entry.percent)
end
local slope, intercept = self:LinearRegression(x, y)
return (percent - intercept) / slope
end
-- Time until death
---@return number
function Unit:TimeToDie()
if self:IsDead() then
self.regression_history = {}
return 0
end
local timeto = self:PredictTime(0) - GetTime()
if timeto ~= timeto or timeto < 0 or timeto == math.huge then
return 0
end
return timeto
end
-- Set combat time if affecting combat and return the difference between now and the last time
---@return number
function Unit:GetCombatTime()
return GetTime() - self.last_combat_time
end
-- Set last combat time
---@param time number
---@return nil
function Unit:SetLastCombatTime(time)
self.last_combat_time = time
end
-- Get combat odds (if the last combat time is less than 1 minute ago return 1 / time, else return 0)
-- the closer to 0 the more likely the unit is to be in combat (0 = 100%) 60 = 0%
---@return number
function Unit:InCombatOdds()
local time = self:GetCombatTime()
local percent = 1 - (time / 60)
return percent * 100
end
-- Get units gcd time
---@return number
function Unit:GetGCD()
local start, duration = GetSpellCooldown(61304)
if start == 0 then
return 0
end
return duration - (GetTime() - start)
end
-- Get units max gcd time
--[[
The GCD without Haste is 1.5 seconds
With 50% Haste the GCD is 1 second
With 100% Haste the GCD is 0.5 seconds
The GCD won't drop below 1 second
More than 50% Haste will drop a spell below 1 second
]]
---@return number
function Unit:GetMaxGCD()
local haste = UnitSpellHaste(self:GetOMToken())
if haste > 50 then
haste = 50
end
return 1.5 / (1 + haste / 100)
end
-- IsStealthed
---@return boolean
function Unit:IsStealthed()
local Stealth = Bastion.SpellBook:GetSpell(1784)
local Vanish = Bastion.SpellBook:GetSpell(1856)
local ShadowDance = Bastion.SpellBook:GetSpell(185422)
local Subterfuge = Bastion.SpellBook:GetSpell(115192)
local Shadowmeld = Bastion.SpellBook:GetSpell(58984)
local Sepsis = Bastion.SpellBook:GetSpell(328305)
return self:GetAuras():FindAny(Stealth) or self:GetAuras():FindAny(ShadowDance)
end
-- Get unit swing timers
---@return number, number
function Unit:GetSwingTimers()
local main_speed, off_speed = UnitAttackSpeed(self:GetOMToken())
local main_speed = main_speed or 2
local off_speed = off_speed or 2
local main_speed_remains = main_speed - (GetTime() - self.last_main_attack)
local off_speed_remains = off_speed - (GetTime() - self.last_off_attack)
if main_speed_remains < 0 then
main_speed_remains = 0
end
if off_speed_remains < 0 then
off_speed_remains = 0
end
return main_speed_remains, off_speed_remains
end
---@return nil
function Unit:WatchForSwings()
Bastion.EventManager:RegisterWoWEvent("COMBAT_LOG_EVENT_UNFILTERED", function()
local _, subtype, _, sourceGUID, sourceName, _, _, destGUID, destName, destFlags, _, spellID, spellName, _, amount, interrupt, a, b, c, d, offhand, multistrike = CombatLogGetCurrentEventInfo()
if sourceGUID == self:GetGUID() then
if subtype == "SPELL_ENERGIZE" and spellID == 196911 then
self.last_shadow_techniques = GetTime()
self.swings_since_sht = 0
end
if subtype:sub(1, 5) == "SWING" and not multistrike then
if subtype == "SWING_MISSED" then
offhand = spellName
end
local now = GetTime()
if now > self.last_shadow_techniques + 3 then
self.swings_since_sht = self.swings_since_sht + 1
end
if offhand then
self.last_off_attack = GetTime()
else
self.last_main_attack = GetTime()
end
end
end
end)
end
-- ismounted
---@return boolean
function Unit:IsMounted()
return UnitIsMounted(self.unit)
end
-- isindoors
---@return boolean
function Unit:IsOutdoors()
return ObjectIsOutdoors(self.unit)
end
-- IsIndoors
---@return boolean
function Unit:IsIndoors()
return not ObjectIsOutdoors(self.unit)
end
-- IsSubmerged
---@return boolean
function Unit:IsSubmerged()
return ObjectIsSubmerged(self.unit)
end
-- IsDry
---@return boolean
function Unit:IsDry()
return not ObjectIsSubmerged(self.unit)
end
return Unit