I may have gone overboard… no, I definitely went overboard, you were just asking about how to cancel the delayed tasks if there was another attack. But I hope this will be useful to you in more than just that. I tried to comment a lot, without stating things that are very obvious.
I haven’t tested anything. Some of it are assumptions, like assuming that you want to trigger an attack when parts touch and one of the attack animations is playing, while not having attacked anyone yet in that animation cycle (because that’s probably what I would do), but what I wanted to do, beyond just solving the issue you were having, is to show you some useful principles you can take away and keep. I’m not gonna list them here, you’ll sense them from the comments.
You could also copy-paste some of the logic to the client, to create effects and whatnot faster, and without having to use remotes in either direction, keeping safe what’s supposed to be safe, while not sacrificing the eye candy to ping.
Feel free to ask any questions.
-- server Script
local Players = game:GetService('Players')
-- attacker victim(s)
local combos: {[Model]: {[Model]: { comboCount: number, lastAttackTime: number }}?} = {}
local comboDamageAddition = 0.1 -- will be multiplied by the combo count
local maxComboCount = 5 -- to cap the multiplier
local attackingDuration, underAttackDuration = 0.35, 0.9
-- most probably not ideal, use body movers if you can, like a short-lived VectorForce or LinearVelocity
local function applyKnockback(Victim: Model, direction: Vector3, magnitude: number)
-- correct the direction to point slightly upwards, so the floor friction doesn't stop the impulse
direction = Vector3.new(direction.X, Victim.HumanoidRootPart.Position.Y + 0.5, direction.Z).Unit
Victim.HumanoidRootPart:ApplyImpulse(direction * magnitude)
end
local function afterAttacking(Attacker: Model, Victim: Model?)
if not combos[Attacker] then
-- the attacker is dead
elseif os.clock() - combos[Attacker][Victim].lastAttackTime > attackingDuration then
-- the attacker is not attacking that victim anymore, but might be attacking someone else
combos[Attacker][Victim] = nil
end
if os.clock() - Attacker:GetAttribute('lastAttackTime') > attackingDuration then
-- the attacker is not attacking anyone anymore
end
end
local function afterAttacked(Victim: Model, Attacker: Model?)
if not combos[Victim] then
-- the victim is dead
elseif not combos[Attacker] or not combos[Attacker][Victim] then
--[[ the victim is no longer being attacked by that attacker, but might be under attack from someone else;
as long as attackingDuration < underAttackDuration this will be ture, but because I'm not sure about
how the scheduler works exactly, I can't claim that this will be true if the attackingDuration is
one cycle's worth of time (1/30 of a second on the server I believe) smaller than underAttackDuration;
if you want to be absolutely sure, and maybe even want to use values larger than underAttackDuration,
add this check as well: or os.clock() - combos[Attacker][Victim].lastAttackTime > underAttackDuration ]]--
end
if os.clock() - Victim:GetAttribute('lastAttackedTime') > underAttackDuration then
-- the victim is no longer being attacked by anyone
Victim.Humanoid.WalkSpeed = 16
Victim.Humanoid.JumpHeight = 7.2
end
end
local function onAttack(Attacker: Model, Victim: Model, damage: number)
-- makes sure both are still alive, just to be safe
if not combos[Attacker] or not combos[Victim] then
return
end
local timeOfAttack = os.clock()
Attacker:SetAttribute('lastAttackTime', timeOfAttack)
Victim:SetAttribute('lastAttackedTime', timeOfAttack)
local comboData = combos[Attacker][Victim] or { comboCount = 0, lastAttackTime = timeOfAttack }
-- need this value just for knowing when to knockback
local rawComboCount = comboData.comboCount + 1
local cappedComboCount = math.min(rawComboCount, maxComboCount)
Victim.Humanoid:TakeDamage(damage * (1 + cappedComboCount * comboDamageAddition))
Victim.Humanoid.WalkSpeed = 1
Victim.Humanoid.JumpHeight = 0
combos[Attacker][Victim] = comboData
--[[ assuming that you want to apply the knockback when maxComboCount is reached, this is how you will know
it was just reached - from this point on rawComboCount will be maxComboCount + 1;
you could even separate the two, have the knockback trigger and the maxComboCount be different things ]]--
if rawComboCount == maxComboCount then -- receiver
applyKnockback(Victim,
-- direction of the force magnitude of the force
CFrame.lookAt(Attacker.HumanoidRootPart.Position, Victim.HumanoidRootPart.Position).LookVector, 100)
else
applyKnockback(Victim,
CFrame.lookAt(Attacker.HumanoidRootPart.Position, Victim.HumanoidRootPart.Position).LookVector, 35)
end
task.delay(attackingDuration, afterAttacking, Attacker, Victim)
task.delay(underAttackDuration, afterAttacked, Victim, Attacker)
end
local function onCharacterAdded(Character: Model, playerStats: {[string]: any})
local damageFactors = { Head = 1.5, UpperTorso = 1, LowerTorso = 1.2 }
--[[ a damage multiplier based on the part that's hit, you could use only HumanoidRootPart
if you want to keep the hitbox fair/consistent for all, or you could impose avatar scale ]]--
local partDamage: {[string]: number} = { LeftHand = 5, RightHand = 5, LeftFoot = 7, RightFoot = 7 }
--[[ how much damage each part will deal; for tools this should be set in Studio by adding
a "damage" attribute to the tool's damaging part ]]--
local activeAnimationTracks: {[string]: boolean} = {}
--[[ that is, those animations that can currently deal damage; they are playing, have played again
or looped since the last hit, and have not hit any target since ]]--
local function connectAnimator(Animator: Animator)
--[[ this allows us to monitor animation tracks on the server; it only registers a track when it is
played for the first time, so it's theoretically possible that damage will not happen on that
first playing (if the damaging part was already touching the damage-receiving part when the
animations started), but this would probably be very rare, and is outweighed by the benefits ]]--
Animator.AnimationPlayed:Connect(function(animationTrack)
-- e.g. if your animations were named "Attack_LeftHand", "Attack_Sword", etc.
local damagingPart = string.sub(animationTrack.Animation.Name, 8)
if not partDamage[damagingPart] then
--[[ we're not interested in unregistered animations/parts
but we'll stil won't discard those that are added in later (tools) ]]--
return
elseif activeAnimationTracks[damagingPart] == nil then
-- these will connect only once because we're explicitly checking for nil
animationTrack.DidLoop:Connect(function()
-- the part can damage again after its animation loops
activeAnimationTracks[damagingPart] = true
end)
animationTrack.Stopped:Connect(function()
-- the part can't damage anymore once its animation stops playing
activeAnimationTracks[damagingPart] = false
end)
end
-- the part can damage after its animation starts playing
activeAnimationTracks[damagingPart] = true
end)
end
local function processDescendant(Descendant: Instance)
if Descendant:IsA('Animator') then
return connectAnimator(Descendant)
elseif not Descendant:IsA('BasePart') then
return
end
if damageFactors[Descendant.Name] then
-- an example for if you used % values for damage resistance
Descendant:SetAttribute('damageFactor',
damageFactors[Descendant.Name] * (100 - playerStats.damageResistance) / 100)
--[[ we're not interested in registering taken damage for these parts, as each character
will be processing the damage they deal, because that's much simpler when it comes to tracking ]]--
return
elseif not partDamage[Descendant.Name] and not Descendant:GetAttribute('damage') then
-- return if it's neither one of the damaging parts that load in, nor a tool
return
end
-- an example for if you used % values for damage multiplication; could even be <100% if you want to nerf someone
local damage = (partDamage[Descendant.Name] and partDamage[Descendant.Name] or Descendant:GetAttribute('damage'))
* playerStats.damageMultiplier
-- only damaging parts are left at this point, and when they are touched we check conditions and deal damage
Descendant.Touched:Connect(function(OtherPart)
-- we're only interested if the other part can be damaged, and the attack animation of our part is "active"
if OtherPart:GetAttribute('damageFactor') and activeAnimationTracks[Descendant.Name] then
-- this will put the part on cooldown until its attack animation is played or looped again
activeAnimationTracks[Descendant.Name] = false
-- our damage adjusted by the victim's damage factor
onAttack(Character, OtherPart.Parent, damage * OtherPart:GetAttribute('damageFactor'))
end
end)
end
--[[ here, you can just wait for other descendants and add other things that will be necessary for further actions;
because at this point, the character can still not receive any damage ]]--
local Humanoid: Humanoid = Character:WaitForChild('Humanoid')
Character:WaitForChild('HumanoidRootPart')
Character.HumanoidRootPart:WaitForChild('RootRigAttachment')
combos[Character] = {}
Humanoid.Died:Connect(function()
local victims = combos[Character]
combos[Character] = nil
afterAttacking(Character)
afterAttacked(Character)
for Victim in victims do
-- trigger all of the victims' afterAttacked logic
afterAttacked(Victim, Character)
end
end)
--[[ this is both for tools/gear that will be equipped, and limbs or Animator that have not loaded yet
adjust if you're using a custom tool system, or make sure you parent tools to the Character ]]--
Character.DescendantAdded:Connect(processDescendant)
-- go through the already loaded stuff
for _, Descendant in Character:GetDescendants() do
processDescendant(Descendant)
end
end
local function onPlayerAdded(Player: Player)
local playerStats = { damageResistance = 10, damageMultiplier = 105 } -- get from a DataStore
Player.OnCharacterAdded:Connect(function(Character) onCharacterAdded(Character, playerStats) end)
if Player.Character then onCharacterAdded(Player.Character, playerStats) end
end
Players.PlayerAdded:Connect(onPlayerAdded)
-- this is really only necessary for Studio testing
for _, Player in Players:GetPlayers() do task.spawn(onPlayerAdded, Player) end