Created
May 25, 2021 17:38
-
-
Save Elmuti/63bc5e80758873d3758d5f8c6797bc6c to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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