Skip to content

Instantly share code, notes, and snippets.

@Elmuti
Created May 25, 2021 17:38
Show Gist options
  • Save Elmuti/63bc5e80758873d3758d5f8c6797bc6c to your computer and use it in GitHub Desktop.
Save Elmuti/63bc5e80758873d3758d5f8c6797bc6c to your computer and use it in GitHub Desktop.
local RunService = game:GetService("RunService")
local Signal = require(game.ReplicatedStorage.Core.NevermoreSignal)
local UnitMixin = require(game.ReplicatedStorage.Mixins.UnitMixin)
local class = require(game.ReplicatedStorage.Middleclass)
local SpellHandlers = require(game.ServerScriptService.SpellHandlers)
local CastResult = require(game.ReplicatedStorage.Game.SpellCastResult)
local Combat = require(game.ServerScriptService.Combat)
local AuraDatabase = require(game.ReplicatedStorage.AuraDatabase)
local ItemDatabase = require(game.ReplicatedStorage.Databases.ItemDatabase)
local Aura = require(game.ServerScriptService.Aura)
local Assertions = require(game.ReplicatedStorage.Util.Assertions)
local EquipmentSlot = require(game.ReplicatedStorage.Enums.EquipmentSlot)
local DamageInfo = require(game.ReplicatedStorage.Game.DamageInfo)
local LoggerService = require(game.ReplicatedStorage.Util.LoggerService)
local SpellResult = require(game.ReplicatedStorage.Enums.SpellResult)
local DamageSchool = require(game.ReplicatedStorage.Enums.DamageSchool)
local PowerType = require(game.ReplicatedStorage.Enums.PowerType)
local Table = require(game.ReplicatedStorage.Sun.Table)
local Array = require(game.ReplicatedStorage.Sun.Array)
local DRType = require(game.ReplicatedStorage.Enums.DRType)
local UnitClass = require(game.ReplicatedStorage.Enums.UnitClass)
local UnitStat = require(game.ReplicatedStorage.Enums.UnitStat)
local UnitType= require(game.ReplicatedStorage.Enums.UnitType)
local ServerCommands = require(game.ServerScriptService.ServerCommands)
local DamageSourceType = require(game.ReplicatedStorage.Enums.DamageSourceType)
local UnitTeam = require(game.ReplicatedStorage.Enums.UnitTeam)
local ProjectileManager = require(game.ServerScriptService.ProjectileManager)
-- local Timeline = require(game.ReplicatedStorage.Core.Timeline)
local Orakel = require(game.ReplicatedStorage.Orakel.Main)
local mathLib = Orakel.LoadModule("MathLib")
local Logger = LoggerService.GetLogger("Replication")
local Assert = Assertions.Assert
local Unit_S = class("Unit_S")
Unit_S:include(UnitMixin)
-- TODO: change this if needed.
-- also TODO: implement combat leeway. should be simple with the IsFacing functions
local COMBAT_REACH = 8
local COMBAT_REACH_RANGED = 80
--- The maximum movement allowed between Heartbeat ticks when casting.
local CAST_POSITION_DELTA_THRESHOLD = 0.1
-- Grace period in seconds that allows you to keep moving while casting,
-- needed due to roblox movement replication works
local CAST_POSITION_LEEWAY_DURATION = 0.2
local ZERO_VECTOR3 = Vector3.new(0, 0, 0)
local ZERO_VECTOR2 = Vector2.new(0, 0)
--- Constructor (Do not yield in here, do all the yielding before you pass an instance)
-- That's more of a design choice. Nothing will happen if you yield in here.
-- But if you construct units in a for loop you'll slow things down if the ctor yields
-- @constructor Unit_S.new(instance, defaultUnitData)
-- @param {Instance} instance
-- @param {table} defaultUnitData
-- @returns {Unit} Unit
function Unit_S:initialize(guid, template)
template = template or {}
self.GUID = guid
self.Name = template.Name or "Unknown"
-- The unit's position in the world over time
self.Position = template.SpawnPosition or ZERO_VECTOR3
-- The unit's orientation over time
self.Rotation = 0
self.CombatTime = 0
-- Setting this to true will cancel a channel loop
self.StopCastRequested = false
-- If the unit is on GCD (should be player only maybe?)
self.GlobalCooldown = false
self.Health = template.Health or 100
self.MaxHealth = template.MaxHealth or 100
self.PowerType = template.PowerType or PowerType.Rage
self.Power = {
Current = template.Power and template.Power.Current or 0,
Max = template.Power and template.Power.Max or 100,
}
self.Alive = true
self.Class = template.UnitClass or UnitClass.Warrior
-- Who this unit is targeting
self.Target = nil
-- Spell ID currently being casted
self.CurrentCastSpellId = nil
-- shapeshift (no druids plz IDK)
self.Form = nil
-- Always 60
self.Level = 60
-- Team used in arena
self.Team = template.Team or UnitTeam.Neutral
-- Is unit in combat
self.InCombat = false
-- Array of auras
self.Auras = {}
-- Map of SlotID => Item
self.Items = {}
self.Stats = template.Stats or {}
--Contains a list of diminishing returns
self.DRTable = {}
for name, val in pairs(DRType) do
self.DRTable[name] = {Level = 0, Time = 0}
end
-- Internal counter for tracking how many roots are on the unit
-- Rooting the unit adds +1
-- Unrooting the unit subtracts -1
self._RootCounter = 0
-- Array of functions that take in a DamageInfo and can modify it before the damage is applied
self.DamageHandlers = {}
-- Map of spellId => time remaining
self.SpellCooldowns = {}
--# Events #--
-- Fired when a property changes
self.PropertyChanged = Signal.new()
-- Create events. These are usable only on the server
-- Fired when damage has been taken
self.DamageTaken = Signal.new()
-- Fired when damage has been dealt
self.DamageDealt = Signal.new()
-- Fired when an item is equipped. Passes item template
self.ItemEquipped = Signal.new()
-- Fire when an item is unequipped. Passes item template
self.ItemUnequipped = Signal.new()
-- Fired when Power.Current changed
self.PowerChanged = Signal.new()
-- Fired when Power.Max changed
self.MaxPowerChanged = Signal.new()
-- Fires when the unit is rooted or unrooted
self.RootedChanged = Signal.new()
-- Spell cooldown state changed
-- Arguments: (spellId, cooldownTime)
self.SpellCooldownChanged = Signal.new()
self.PropertyChangedSignalsMap = {}
-- Connect signals
self:ConnectSignals()
end
-- If you change this, remember to change the Unit_C:ApplyFullUpdate code too to match this
function Unit_S:BuildFullUpdate(packet)
packet:WriteGUID(self:GetGUID())
packet:WriteVector3(self:GetPosition())
packet:WriteAngleCompressed(self:GetRotation())
packet:WriteInt32(self:GetHealth())
packet:WriteInt32(self:GetMaxHealth())
packet:WriteUInt8(self:GetClass())
packet:WriteUInt8(self:GetTeam())
packet:WriteUInt8(self:GetPowerType())
packet:WriteInt32(self:GetPower())
packet:WriteInt32(self:GetMaxPower())
-- auras
packet:WriteUInt16(#self.Auras)
for _, aura in ipairs(self.Auras) do
local caster = aura:GetCaster()
packet:WriteString(aura:GetAuraId())
packet:WriteFloat(aura:GetRemainingDuration())
packet:WriteBool(aura:IsPermanent())
packet:WriteGUID(caster and caster:GetGUID() or 0)
end
-- items
packet:WriteUInt8(Table.Length(self.Items))
for key, value in pairs(self.Items) do
packet:WriteUInt8(key) -- EquipmentSlot
packet:WriteString(value) -- Item ID
end
-- stats
packet:WriteUInt8(Table.Length(self.Stats))
for key, value in pairs(self.Stats) do
packet:WriteUInt8(key) -- UnitStat
packet:WriteInt32(self:GetStat(key))
end
return packet
end
--- Disconnect signals, set instance to nil
function Unit_S:Destroy()
print("Destroying unit %s", tostring(self))
self:DisconnectSignals()
self.DamageTaken:Destroy()
self.ItemEquipped:Destroy()
self.ItemUnequipped:Destroy()
end
function Unit_S:StopCasting()
self.StopCastRequested = true
end
-- TODO: Cleanup logic here, especially CanCast
--- Cast a spell
-- @param {Spell} spell - The spell to be caster
-- @returns {CastResult}
function Unit_S:Cast(spell, target)
Assertions.AssertTypeof(spell, "table")
print("Unit_S:Cast", spell.Id)
if self:IsCasting() then
return CastResult.NotReady
end
-- Cant cast spell that is on cooldown
if self.SpellCooldowns[spell.Id] then
return CastResult.NotReady
end
if spell.ResourceCost then
if spell.ResourceType == PowerType.Rage then
if self:GetPower() < spell.ResourceCost then
return CastResult.NoRage
end
end
if self:GetPower() < spell.ResourceCost then
return CastResult.NoMana
end
end
-- Spells that don't trigger GCD can be cast during GCD
if spell.TriggerGCD and self:GetGlobalCooldown() then
return CastResult.NotReady
end
if not target or (target == self and not spell.AllowCastOnSelf) then
return CastResult.InvalidTarget
end
if not spell.AllowCastOnDead and not target:IsAlive() then
return CastResult.InvalidTarget
end
local distanceToTarget = self:GetDistanceTo(target)
if distanceToTarget > spell.MaxDistance then
return CastResult.OutOfRange
end
if spell.MinDistance ~= 0 and distanceToTarget < spell.MinDistance then
return CastResult.OutOfRange
end
local handler = SpellHandlers.GetSpellHandler(spell)
-- Call BeforeChannel, it can return false to cancel the spell
local result, detailedResult = handler.Invoke("BeforeChannel", self, target)
if result == false then
return detailedResult and detailedResult or CastResult.InvalidTarget
end
-- At this point we've decided we are going to cast
self.StopCastRequested = false
if self:IsHostileTo(target) then
self:SetInCombat(true)
target:SetInCombat(true)
end
if spell.TriggerGCD then
self:SetGlobalCooldown(true)
spawn(function()
-- TODO: dont use wait() here
wait(1.5)
self:SetGlobalCooldown(false)
end)
end
-- Set spell cooldown
if spell.Cooldown > 0 then
self:AddSpellCooldown(spell.Id, spell.Cooldown)
end
-- If it's not an instant spell then set up channeling
if spell.CastTime > 0 then
-- calculate final cast time after haste is applied
local castTime = spell.CastTime / self:GetSpellHasteModifier() * self:GetSpellHasteMultiplier()
ServerCommands.UnitCastSpell(self, spell.Id, castTime)
self:SetCurrentCastSpellId(spell.Id)
handler.Invoke("ChannelStart", self, target)
--temporary
-- TODO: Move to client!
--local rth = Instance.new("Attachment", self.Instance.Rig:FindFirstChild("RightHand"))
--local lth = Instance.new("Attachment", self.Instance.Rig:FindFirstChild("LeftHand"))
--local ref = game.ReplicatedStorage.Effects.Casting["Fireball"].FlameEffect:Clone()
--local lef = game.ReplicatedStorage.Effects.Casting["Fireball"].FlameEffect:Clone()
--local rpl = game.ReplicatedStorage.Effects.Casting["Fireball"].PointLight:Clone()
--local lpl = game.ReplicatedStorage.Effects.Casting["Fireball"].PointLight:Clone()
--rpl.Parent = self.Instance.Rig:FindFirstChild("RightHand")
--lpl.Parent = self.Instance.Rig:FindFirstChild("LeftHand")
--ref.Parent = rth
--lef.Parent = lth
local progress = 0
local promise = Signal.new()
-- Record cast start position to prevent hacking by moving tiny amounts per tick
-- Will be set after CAST_POSITION_LEEWAY_DURATION has elapsed
local startCastPos = nil
-- Record cast position every tick to detect movement during casting
local lastTickPos = self:GetPosition()
if self.ChannelSpellConn ~= nil then
warn("Cleaning up existing channel spell connection, this should not happen")
self.ChannelSpellConn:Disconnect()
self.ChannelSpellConn = nil
end
local ticksPerSecond = spell.TicksPerSec * self:GetSpellHasteModifier() / self:GetSpellHasteMultiplier()
local tickTimer = 1 / ticksPerSecond
self.ChannelSpellConn = RunService.Heartbeat:Connect(function(dt)
if self.StopCastRequested then
promise:Fire(CastResult.Interrupted)
self.ChannelSpellConn:Disconnect()
self.ChannelSpellConn = nil
return
end
-- NOTE: Disconnect the signal in this callback because the code below yields
-- and may not be resumed before another Heartbeat call
local currentPos = self:GetPosition()
if progress >= CAST_POSITION_LEEWAY_DURATION and not startCastPos then
startCastPos = self:GetPosition()
end
local lastTickDiff = (lastTickPos - currentPos).magnitude
local startCastDiff = startCastPos and (startCastPos - currentPos).magnitude or 0
if progress >= CAST_POSITION_LEEWAY_DURATION and (lastTickDiff > CAST_POSITION_DELTA_THRESHOLD or startCastDiff > 2) then
-- interrupt cast because caster moved
print("interrupt cast because caster moved too far, lastTickDiff " .. lastTickDiff .. " startCastDiff " .. startCastDiff)
promise:Fire(CastResult.Interrupted)
self.ChannelSpellConn:Disconnect()
self.ChannelSpellConn = nil
return
end
lastTickPos = currentPos
-- Handle ticks
tickTimer -= dt
if tickTimer <= 0 then
tickTimer += 1 / ticksPerSecond
handler.Invoke("ChannelTick", self, target)
end
progress += dt
if progress >= castTime then
promise:Fire(CastResult.OK)
self.ChannelSpellConn:Disconnect()
self.ChannelSpellConn = nil
return
end
end)
-- Wait for channel to finish
local result = promise:Wait()
promise:Destroy()
self:SetCurrentCastSpellId(nil)
-- TODO: Move to client (VFX)
--if rth then
-- rth:FindFirstChildOfClass("ParticleEmitter").Enabled = false
-- game.Debris:AddItem(rth, 1.5)
--end
--if lth then
-- lth:FindFirstChildOfClass("ParticleEmitter").Enabled = false
-- game.Debris:AddItem(lth, 1.5)
--end
--if rpl then
-- rpl:Destroy()
--end
--if lpl then
-- lpl:Destroy()
--end
-- ChannelEnd should always be called regardless of the spell outcome
handler.Invoke("ChannelEnd", self, target)
-- If channel was ended early then return and dont execute the spell effect
if result ~= CastResult.OK then
return result
end
end
if spell.ResourceCost then
if spell.ResourceType == PowerType.Rage then
if self:GetPower() < spell.ResourceCost then
return CastResult.NoRage
end
self:SetPower(self:GetPower() - spell.ResourceCost)
end
if self:GetPower() < spell.ResourceCost then
return CastResult.NoMana
end
self:SetPower(self:GetPower() - spell.ResourceCost)
end
-- CastSuccess is only called if CastResult is OK
handler.Invoke("CastSuccess", self, target)
return CastResult.OK
end
--- Creates a new aura from the given auraId and aplies it to the target.
-- The Auras array is serverside only and contains Aura objects.
-- self.Data.Auras is a map of Aura GUID to a table that contains basic aura info for replication
-- @param {string} auraId - The Id of the aura to apply
-- @param {Unit_S=} target - The unit to apply the aura to, defaults to self
-- @returns {void}
function Unit_S:ApplyAura(auraId, target)
target = target or self
local template = AuraDatabase.FindById(auraId)
if not template then
return false
end
local aura = Aura:new(template)
table.insert(self.Auras, aura)
aura:Apply(target, self)
local index = Array.IndexOf(self.Auras, aura)
ServerCommands.NotifyAuraChanged(self, aura, true, index)
aura.Removed:Connect(function()
self:RemoveAura(aura)
end)
end
--- Find out if unit has a specific aura. Parameters can either be aura object or aura Id
-- @param {Aura} aura
-- @returns {bool}
function Unit_S:HasAura(aura)
Assertions.Assert(typeof(aura) == "string" or typeof(aura) == "table")
if typeof(aura) == "string" then
for _, value in ipairs(self.Auras) do
if value:GetAuraId() == aura then
return true
end
end
return false
elseif typeof(aura) == "table" then
return table.find(self.Auras, aura) ~= nil
end
end
--- Find out if unit has a specific aura. Parameters can either be aura object or aura Id
-- @param {Aura} aura
-- @returns {bool}
function Unit_S:GetAura(aura)
Assertions.Assert(typeof(aura) == "string" or typeof(aura) == "table")
if typeof(aura) == "string" then
for _, value in ipairs(self.Auras) do
if value:GetAuraId() == aura then
return value
end
end
return nil
elseif typeof(aura) == "table" then
-- why would you call get aura if you have the aura object already
error("Unit_S:GetAura doesnt work with aura object as parameter yet")
end
end
--- Remove an aura from unit
-- @param {Aura} aura
-- @returns {void}
function Unit_S:RemoveAura(aura)
Assertions.AssertTypeof(aura, "table")
local index = Array.IndexOf(self.Auras, aura)
if index then
table.remove(self.Auras, index)
ServerCommands.NotifyAuraChanged(self, aura, false, index)
end
end
function Unit_S:Root()
self._RootCounter += 1
if self.RootCounter == 1 then
self.RootedChanged:Fire(true)
end
end
-- If forceUnroot is specified then
function Unit_S:Unroot(forceUnroot: boolean)
if forceUnroot then
self._RootCounter = 0
self.RootedChanged:Fire(false)
else
self._RootCounter = math.clamp(self._RootCounter - 1, 0, math.huge)
if self._RootCounter == 0 then
self.RootedChanged:Fire(false)
end
end
end
function Unit_S:IsRooted()
return self._RootCounter > 0
end
--- Is the unit immune to mechanic
-- @param {String} mechanic
-- @returns {bool}
function Unit_S:IsImmune(mechanic)
assert(self.DRTable[mechanic], string.format("The mechanic %s is not implemented in Unit_S.DRTable", mechanic))
if self.DRTable[mechanic].Level >= 4 then
return true
end
if self.Immunities[mechanic] then
return true
end
return false
end
function Unit_S:ApplyDR(mechanic)
assert(self.DRTable[mechanic], string.format("The mechanic %s is not implemented in Unit_S.DRTable", mechanic))
self.DRTable[mechanic].Level += 1
self.DRTable[mechanic].Time = 0
end
function Unit_S:GetDR(mechanic)
assert(self.DRTable[mechanic], string.format("The mechanic %s is not implemented in Unit_S.DRTable", mechanic))
local level = self.DRTable[mechanic].Level
return math.clamp(level, 1, 4)
end
function Unit_S:IsPlayerOrPet()
return self:GetUnitType() == UnitType.Player or self:GetUnitType() == UnitType.Pet
end
function Unit_S:IsCaster()
return self:GetClass() == "Mage" or self:GetClass() == "Warlock" or self:GetClass() == "Priest"
end
function Unit_S:CanAct()
return true
end
function Unit_S:CanDodge()
return true
end
function Unit_S:CanParry()
return true
end
function Unit_S:CanBlock()
return true
end
function Unit_S:CanGlance(target)
return false
end
function Unit_S:CanCrush(target)
return true
end
function Unit_S:DoCombatRoll(target)
Combat.CalculateMeleeOutcome(self, target)
end
--attType = mainhand, offhand
function Unit_S:GetAPMultiplier(attType, normalised)
if not normalised or self:GetUnitType() ~= "Player" then
--return GetAttackTime(attType) / 1000
end
if attType == "RangedAttack" then
return 3.0
end
local weapon --= self:GetWeaponForAttack(attType)
if not weapon then
return 2.4
end
if weapon.Type == "TwoHand" then
return 3.3
--elseif weapon.Type == "Ranged" then
--elseif weapon.Type == "OneHand" then
--elseif weapon.Type == "MainHand" then
--elseif weapon.Type == "OffHand" then
else
return weapon.SubType == "Dagger" and 1.7 or 2.4
end
end
function Unit_S:StartAutoAttack()
if self.IsAutoAttacking then
return
end
-- Should be replaced with self:IsFriendlyTo(self:GetTarget())
if self:GetTarget() == self then
print("targeting self, dont attack")
return
end
print("Starting auto attack")
self.IsAutoAttacking = true
--self.NextAutoAttack = self:GetMainHandSpeed()
--self.NextAutoAttackOH = self:GetOffHandSpeed() + 0.2
self.NextAutoAttack = 0 -- first swing should hit immediately
self.NextAutoAttackOH = self:GetMainHandSpeed() + 0.2 --0.2 second delay for offhand
self.AutoAttackConn = RunService.Heartbeat:Connect(function(dt)
local target = self:GetTarget()
local hasOH = self:GetItem(EquipmentSlot.OffHand)
if not target or not target:IsAlive() then
self:StopAutoAttack()
return
end
self.NextAutoAttack = math.clamp(self.NextAutoAttack - dt, 0, self:GetMainHandSpeed())
if hasOH then
self.NextAutoAttackOH = math.clamp(self.NextAutoAttackOH - dt, 0, self:GetOffHandSpeed())
end
if self:GetDistanceTo(target) > COMBAT_REACH then
print("not in range, stopping autoattack")
return
end
if not self:IsFacing(target) then
print("not facing, stopping autoattack")
return
end
if hasOH then
if self.NextAutoAttackOH <= 0 then
-- NOTE: Right now even if self.NextAuttoAttack reaches a value like -5
-- then this function wont cause multiple auto attacks to be executed
self.NextAutoAttackOH = self:GetOffHandSpeed()
print("Executing auto attack offhand")
local damage, result = Combat.CalculateMeleeDamage(self, target, "MeleeAttackOffhand", 0, true)
local info = DamageInfo.new({
Result = result,
Victim = target,
IsCritical = result == SpellResult.Crit and true or false,
Name = "MeleeOffhand",
SourceType = DamageSourceType.MeleeNormal,
DamageSchool = DamageSchool.Physical,
Amount = damage,
Source = self,
})
Combat.GainRageFromSwing(self, info)
self:DealDamage(info)
end
end
if self.NextAutoAttack <= 0 then
-- NOTE: Right now even if self.NextAuttoAttack reaches a value like -5
-- then this function wont cause multiple auto attacks to be executed
self.NextAutoAttack = self:GetMainHandSpeed()
print("Executing auto attack")
local damage, result = Combat.CalculateMeleeDamage(self, target, "MeleeAttack", 0, true)
local info = DamageInfo.new({
Result = result,
Victim = target,
IsCritical = result == SpellResult.Crit and true or false,
Name = "Melee",
SourceType = DamageSourceType.MeleeNormal,
DamageSchool = DamageSchool.Physical,
Amount = damage,
Source = self,
})
Combat.GainRageFromSwing(self, info)
self:DealDamage(info)
end
end)
end
function Unit_S:StopAutoAttack()
print("Stopping auto attack")
self.IsAutoAttacking = false
if self.AutoAttackConn then
self.AutoAttackConn:Disconnect()
self.AutoAttackConn = nil
end
end
function Unit_S:StartAutoShoot()
if self.IsAutoShooting then
return
end
-- Should be replaced with self:IsFriendlyTo(self:GetTarget())
if self:GetTarget() == self then
print("targeting self, dont attack")
return
end
print("Starting auto shoot")
self.IsAutoShooting = true
--self.NextAutoAttack = self:GetMainHandSpeed()
--self.NextAutoAttackOH = self:GetOffHandSpeed() + 0.2
self.NextAutoShot = 0
self.AutoShotConn = RunService.Heartbeat:Connect(function(dt)
local target = self:GetTarget()
local hasOH = self:GetItem(EquipmentSlot.Ranged)
if not target or not target:IsAlive() then
self:StopAutoShoot()
return
end
self.NextAutoShot = math.clamp(self.NextAutoShot - dt, 0, self:GetRangedSpeed())
warn(string.format("ranged speed: %s", tostring(self:GetRangedSpeed())))
if self:GetDistanceTo(target) > COMBAT_REACH_RANGED then
print("not in range, stopping autoshoot")
return
end
if not self:IsFacing(target) then
print("not facing, stopping autoshoot")
return
end
if self.NextAutoShot <= 0 then
self.NextAutoShot = self:GetRangedSpeed()
print("Executing auto shot")
local onHit = ProjectileManager.CreateProjectile(self, target, game.ReplicatedStorage.Effects.Particles.arrow, {Spin = false})
onHit:Connect(function()
local damage, result = Combat.CalculateMeleeDamage(self, target, "Ranged", 0, true)
local info = DamageInfo.new({
Result = result,
Victim = target,
IsCritical = result == SpellResult.Crit and true or false,
Name = "Ranged",
SourceType = DamageSourceType.MeleeNormal,
DamageSchool = DamageSchool.Physical,
Amount = damage,
Source = self,
})
self:DealDamage(info)
end)
end
end)
end
function Unit_S:StopAutoShoot()
print("Stopping auto shoot")
self.IsAutoShooting = false
if self.AutoShotConn then
self.AutoShotConn:Disconnect()
self.AutoShotConn = nil
end
end
--- Incrase stat by amount, optionally use percentage instead of flat value
-- @param {enum} stat
-- @param {number} amount
-- @param {bool} percent
-- @returns {void}
function Unit_S:ModifyStat(stat, amount, percent)
if typeof(stat) == "string" then
warn("Called ModifyStat with stat argument as a string, you probably meant to use the UnitStat enum instead which are numbers not strings")
end
percent = percent or false
local statTable = self.Stats[stat]
if not statTable then
warn(string.format("Tried to modify stat %s that does not exist %s", stat, tostring(Table.KeyFromValue(UnitStat, stat))))
return
end
if percent then
statTable.PercentModifier += amount
else
statTable.FlatModifier += amount
end
self.Stats[stat] = statTable
if stat == UnitStat.Speed then
warn("TODO: ModifyStat WAlksepedd")
-- self.Instance.Humanoid.WalkSpeed = self:GetStat(UnitStat.Speed)
end
-- TODO: Fix this (Networking V2)
-- TODO: Remove this when we can watch deeply nested tables in the unit data
--[[
if stat == "Speed" then
self.Instance.Humanoid.WalkSpeed = self:GetStat("Speed")
elseif stat == "Vitality" then
self.Data.MaxHealth += (amount / 10)
self.Data.Health += (amount / 10)
elseif stat == "Intellect" then
self.Data.MaxMana += (amount / 15)
self.Data.Mana += (amount / 15)
elseif stat == "Dexterity" then
self.Data.Stats["Armor"].FlatModifier += (amount * 2)
end
]]
end
function Unit_S:DealDamage(damageInfo)
self:SetInCombat(true)
damageInfo.Victim:TakeDamage(damageInfo)
self.DamageDealt:Fire(damageInfo)
end
--- Make unit take damage
-- @param {DamageInfo} damageInfo
-- @returns {void}
function Unit_S:TakeDamage(damageInfo)
Assertions.AssertTypeof(damageInfo, "table")
-- Cant take damage if dead
if not self:IsAlive() then
return
end
-- The Unit taking damage
-- why did i add this?? can it be removed?? i forgot why i added it
damageInfo.Receiver = damageInfo.Receiver or self
-- Apply damage handlers, for example for absorb shields
for _, damageHandler in ipairs(self.DamageHandlers) do
local result = damageHandler(damageInfo)
if result == true then
break
end
end
local currentHealth = self:GetHealth()
local health = math.clamp(currentHealth - damageInfo.Amount, 0, self:GetMaxHealth())
--ENTER COMBAT
self:SetInCombat(true)
-- Move to Warrior aura, react to DamageTaken fired
if self:GetClass() == "Warrior" then
Combat.GainRageFromDamage(self, damageInfo)
end
-- Check if unit died
if health <= 0 then
self:SetAlive(false)
end
self:SetHealth(health)
self.DamageTaken:Fire(damageInfo)
ServerCommands.DamageInfo(damageInfo)
end
function Unit_S:Heal(healInfo)
if not self:IsAlive() then
return
end
healInfo.Amount = mathLib.Round(healInfo.Amount)
local overheal = (self:GetHealth() + healInfo.Amount) - self:GetMaxHealth()
healInfo.Overheal = math.clamp(overheal, 0, math.huge)
local health = math.clamp(self:GetHealth() + healInfo.Amount, 0, self:GetMaxHealth())
self:SetHealth(health)
ServerCommands.HealInfo(healInfo)
end
-- A damage handler accepts a DamageInfo and returns an optional boolean
-- The DamageInfo object can be mutated.
-- If the handler returns true then no other handlers are called
function Unit_S:AddDamageHandler(handler)
Assertions.AssertTypeof(handler, "function")
table.insert(self.DamageHandlers, handler)
end
function Unit_S:RemoveDamageHandler(handler)
Assertions.AssertTypeof(handler, "function")
local index = table.find(self.DamageHandlers)
if not index then
warn("Called RemoveDamageHandler but handler was not found")
return
end
table.remove(self.DamageHandlers, index)
end
--- Equip item to unit
-- @param {number} itemID
-- @returns {void}
function Unit_S:EquipItem(itemID)
warn("Unit_S:EquipItem is temporarily disabled because Unit_S.Instance is being removed")
return
--[[
Implementation is as follows:
- Look up itemID in the item database
- Remove the old item's stats, apply the new item's stats and auras
- Add the new item to the Unit.Data. We will probably use a map of [equipmentIndex: string] => itemID. This step is for for replication purposes, so the clients know their equipment
- If the item has a MeshPart:
- Destroy the old part
- Parent the new part
- Call Humanoid:BuildRigFromAttachments()
]]
--local item = ItemDatabase.FindById(itemID)
--if not item then
-- print(string.format("%s ITEM NOT FOUND", itemID))
-- return false
--end
---- Item already equipped
--if self.Items[item.Slot] then
-- return false
--end
--if item.Slot == EquipmentSlot.Shoulder
-- or item.Slot == EquipmentSlot.Chest
-- or item.Slot == EquipmentSlot.Legs
-- or item.Slot == EquipmentSlot.Feet
-- or item.Slot == EquipmentSlot.Hands
--then
-- -- Character rig rebuild required
-- if item.Model then
-- if typeof(item.Model) == "table" then
-- -- Can provide table of BodyPart = Instance
-- for bodyPartName, modelInstance in pairs(item.Model) do
-- local currentBodyPart = self.Instance.Rig:FindFirstChild(bodyPartName)
-- if not currentBodyPart then
-- warn("Unit has no body part to equip item on: " .. bodyPartName)
-- continue
-- end
-- local newBodyPart = modelInstance:Clone()
-- -- TODO: I think this is causing duplicate attachments and shit. what to do?
-- -- Reparent everything from current body part to new one
-- for _, child in ipairs(currentBodyPart:GetChildren()) do
-- child.Parent = newBodyPart
-- end
-- -- Destroy old body part and parent new one
-- currentBodyPart:Destroy() -- ouch!
-- newBodyPart.Parent = self.Instance.Rig
-- end
-- else
-- -- Otherwise model is just an Instance
-- local model = item.Model:Clone()
-- local currentModel = self.Instance.Rig:FindFirstChild(model.Name)
-- for _, child in ipairs(currentModel:GetChildren()) do
-- child.Parent = model
-- end
-- currentModel:Destroy()
-- model.Parent = self.Instance.Rig
-- end
-- self.Instance.Rig.Humanoid:BuildRigFromAttachments()
-- end
--else
-- if item.Model then
-- local model = item.Model:Clone()
-- model.Name = "Item_" .. item.Slot
-- model.Parent = self.Instance.Rig
-- end
--end
---- Add stats
--for key, value in pairs(item.Stats) do
-- self:ModifyStat(key, value, false)
--end
--self.Items[item.Slot] = item.Id
--self.ItemEquipped:Fire(item)
--return true
end
function Unit_S:UnequipItem(slot)
warn("Unit_S:UnequipItem is temporarily disabled because Unit_S.Instance is being removed")
return
--local item = self:GetItem(slot)
--if not item then
-- return
--end
--self.Items[slot] = nil
---- Remove stats
--for key, value in pairs(item.Stats) do
-- self:ModifyStat(key, -value, false)
--end
---- Remove model
--if item.Slot == EquipmentSlot.Shoulder
-- or item.Slot == EquipmentSlot.Chest
-- or item.Slot == EquipmentSlot.Legs
-- or item.Slot == EquipmentSlot.Feet
--then
-- -- Character rig rebuild required
-- warn("UNEQUIPITEM NOT YET IMPLEMENTED")
--else
-- if item.Model then
-- local model = self.Instance.Rig:FindFirstChild("Item_" .. item.Slot)
-- if model then
-- model:Destroy()
-- end
-- end
--end
--self.ItemUnequipped:Fire(item)
end
-- Function to equip multiple items.
-- Designed because changing a meshpart on a body part requires rebuilding the entire character
-- Using this function avoids rebuilding the character for every single item
function Unit_S:BulkEquipItem(items)
--[[
Implementation: See Unit_S:EquipItem(itemID) but perfform he step of destroying and parenting new MeshParts
in a for loop, and call Humanoid:BuildRigFromAttachments() at the end of the for loop.
]]
error("TODO")
end
function Unit_S:AddSpellCooldown(spellId, cooldown: number)
self.SpellCooldowns[spellId] = cooldown
self.SpellCooldownChanged:Fire(spellId, cooldown)
-- PLAYER ONLY UWU MOVE TO PLAYER_S
ServerCommands.SetSpellCooldown(self, spellId, cooldown)
end
function Unit_S:ConnectSignals()
self.CombatListenerConn = self:GetPropertyChangedSignal("InCombat"):Connect(function(value, oldValue, key)
if value then
self.CombatTime = 0
self.CombatTimerConn = RunService.Heartbeat:Connect(function(delta)
self.CombatTime += delta
if self.CombatTime >= 6 then
self:SetInCombat(false)
if self.CombatTimerConn then
self.CombatTimerConn:Disconnect()
self.CombatTimerConn = nil
end
end
end)
else
if self.CombatTimerConn then
self.CombatTimerConn:Disconnect()
self.CombatTimerConn = nil
end
end
end)
self.PropertyChangedConn = self.PropertyChanged:Connect(function(property, value, oldValue)
if self.PropertyChangedSignalsMap[property] then
self.PropertyChangedSignalsMap[property]:Fire(value, oldValue)
end
end)
self.ServerHeartbeatConn = RunService.Heartbeat:Connect(function(dt)
self:OnHeartbeat(dt)
end)
-- Signal used when we are casting a spell
self.ChannelSpellConn = nil
end
-- Perform generic update loop tasks here.
-- Consider if what you're doing here could be achieved in a better way using events.
function Unit_S:OnHeartbeat(dt: number)
-- Update diminishing returns
for name, data in pairs(self.DRTable) do
if data.Level > 0 then
data.Time += dt
end
if data.Time >= 15 then
data.Level = 0
data.Time = 0
end
end
if self.PowerType == PowerType.Energy then
self.Power.Current = math.floor(math.clamp(self.Power.Current + 10 / dt, 0, self.Power.Max))
end
-- Update spell cooldowns
for key, value in pairs(self.SpellCooldowns) do
self.SpellCooldowns[key] -= dt
if self.SpellCooldowns[key] <= 0 then
self.SpellCooldowns[key] = nil
self.SpellCooldownChanged:Fire(key, 0)
print("Removing spell cooldown for ", key)
end
end
end
function Unit_S:DisconnectSignals()
if self.ChannelSpellConn then
self.ChannelSpellConn:Disconnect()
self.ChannelSpellConn = nil
end
if self.AutoAttackConn then
self.AutoAttackConn:Disconnect()
self.AutoAttackConn = nil
end
if self.CombatListenerConn then
self.CombatListenerConn:Disconnect()
self.CombatListenerConn = nil
end
if self.PropertyChangedConn then
self.PropertyChangedConn:Disconnect()
self.PropertyChangedConn = nil
end
if self.ServerHeartbeatConn then
self.ServerHeartbeatConn:Disconnect()
self.ServerHeartbeatConn = nil
end
end
-- # Getters # --
function Unit_S:GetGUID()
return self.GUID
end
function Unit_S:GetHealth()
return self.Health
end
function Unit_S:GetMaxHealth()
return self.MaxHealth
end
-- Returns the final stat value after all modifiers are applied
function Unit_S:GetStat(stat)
stat = self.Stats[stat]
if not stat then
-- error("break here on unhandled exception to debug")
warn("Stat " .. tostring(stat) .. " not found. GetStat was recently changed to return 0 for stats not found, rather than error. Make sure this is what you want")
return 0
end
local base = stat.Base
local flat = stat.FlatModifier
local percent = stat.PercentModifier
return (base + flat) * (percent + 1)
end
function Unit_S:GetCurrentCastSpellId()
return self.CurrentCastSpellId
end
function Unit_S:GetTarget()
return self.Target
end
function Unit_S:GetPosition(): Vector3
return self.Position
end
function Unit_S:GetRotation(): number
return self.Rotation
end
-- # Setters # --
function Unit_S:SetGUID(value)
self.GUID = value
end
function Unit_S:SetInCombat(value)
self.CombatTime = 0
-- Cant enter combat when dead
if value and not self:IsAlive() then
return
end
local oldValue = self.InCombat
self.InCombat = value
if value ~= oldValue then
self.PropertyChanged:Fire("InCombat", value, oldValue)
ServerCommands.ChangeCombatState(self, value)
end
end
function Unit_S:SetTarget(value)
local oldValue = self.Target
self.Target = value
if value ~= oldValue then
self.PropertyChanged:Fire("Target", value, oldValue)
ServerCommands.SetTarget(self, value)
end
end
function Unit_S:SetGlobalCooldown(value)
local oldValue = self.GlobalCooldown
self.GlobalCooldown = value
if value ~= oldValue then
self.PropertyChanged:Fire("GlobalCooldown", value, oldValue)
end
end
function Unit_S:SetCurrentCastSpellId(value)
local oldValue = self.CurrentCastSpellId
self.CurrentCastSpellId = value
if value ~= oldValue then
self.PropertyChanged:Fire("CurrentCastSpellId", value, oldValue)
if value == nil then
ServerCommands.UnitStopCastting(self)
end
end
end
function Unit_S:SetPower(value: number)
local oldValue = self.Power.Current
self.Power.Current = math.clamp(value, 0, self.Power.Max)
if value ~= oldValue then
self.PowerChanged:Fire(value, oldValue)
ServerCommands.SetPower(self, value)
end
end
function Unit_S:SetMaxPower(value: number)
local oldValue = self.Power.Max
self.Power.Max = value
if value ~= oldValue then
self.MaxPowerChanged:Fire(value, oldValue)
ServerCommands.SetMaxPower(self, value)
end
end
function Unit_S:SetPowerType(value)
local oldValue = self.PowerType
self.PowerType = value
if value ~= oldValue then
self.PowerTypeChanged:Fire(value, oldValue)
ServerCommands.SetPowerType(self, value)
end
end
function Unit_S:SetHealth(value: number)
local oldValue = self.Health
self.Health = value
if value ~= oldValue then
self.PropertyChanged:Fire("Health", value, oldValue)
ServerCommands.SetHealth(self, value)
end
end
function Unit_S:SetMaxHealth(value: number)
local oldValue = self.MaxHealth
self.MaxHealth = value
if value ~= oldValue then
self.PropertyChanged:Fire("MaxHealth", value, oldValue)
ServerCommands.SetMaxHealth(self, value)
end
end
function Unit_S:SetTeam(value)
local oldValue = self.Team
self.Team = value
if value ~= oldValue then
self.PropertyChanged:Fire("Team", value, oldValue)
ServerCommands.SetTeam(self, value)
end
end
function Unit_S:SetPosition(value: Vector3)
self.Position = value
end
function Unit_S:SetRotation(value: number)
self.Rotation = value
end
return Unit_S
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment