Кодим функции: Stroke of Fate или навыки на осонове снарядов/векторов.

Илья

Друзья CG
25 Сен 2015
2,348
41
Предисловие
Спонсором руководства является пользователь VasiliiKitekat. Поэтому все благодарности к нему.
Основной вопрос, который был мне задан, звучал следующим образом: "как делать скилы с определением веторов?".
И далее для уточнения был приведен пример Stroke of Fate - умения Grimstroke.

Не являясь знатоком геометрии и линейной алгебры, я не хотел браться за основной вопрос, ибо ответ на него схож с ответом на вопрос "как выиграть в доту". К каждой ситуации нужен свой подход, да и вопрос этот не столько затрагивает процесс моддинга, сколько ту же алгебру и геометрию. Поэтому решено было попросту разобрать работу умения, что было приведено в премер: Stroke of Fate. Вернее, написать свой вариант этого умения с нуля, целиком на lua. Причем, важное уточнение: мы реализуем код без модификатора замедления, ведь нам важен не он, а реализация снаряда, что будет лететь по заданному вектору и наносить урон встречному противнику. И как бонус, включим в реализацию множитель урона за каждого встречного юнита.

Сразу оговорю: весь дальнейший разбор умения будет основываться на моем опыте. Но я далеко не мастер программирования, lua и модов. Поэтому учитывайте, что все ниже сказанное не есть "истина". Я могу ошибаться и, скорее всего, буду где-то не прав. Знатоки по собственному желанию смогут поправить и усовершенствовать изложенную мной ниже информацию, дополнив её своими комментариями. Я уверен, что такие будут, поэтому милости прошу, сразу излагать верный вариант неверного утверждения, а не переходить на личности и оскорбления умственного достоинства.


Разбор
Прежде всего производим подготовку:

1) Открываем API от Valve для моддинга: https://developer.valvesoftware.com/wiki/Dota_2_Workshop_Tools/Scripting/API.
Сразу сделаю акцент на том, что всякие вещи вроде DOTA_ABILITY_BEHAVIOR_POINT, DAMAGE_TYPE_MAGICALACT_DOTA_CAST_ABILITY_3 и так далее - не более чем глобальные переменные от Valve. Они хранят просто цифры. Сами значения можете найти по ссылке выше.

2) Подготавливаем lua файл нашей будущей абилки.
Идем в ...\scripts\vscripts\ своего мода и либо там создаем файл grimstroke_custom.lua, либо как я, сначала папочку abilities и уже в ней этот файл.

3) Определяем будущую абилку в DataDriven.
Для этого идем в ...\scripts\npc\ и в ней открываем npc_abilities_custom.txt. Либо сами его там создайте, если не найдете.
Находим в этом файле свободное место в блоке кода (то есть между фигурными скобками {}). И размещаем там следующий код:
Код:
    "grimstroke_custom"
    {
        "BaseClass"                     "ability_lua"
        "ScriptFile"                    "abilities/grimstroke_custom.lua"    
        "AbilityBehavior"               "DOTA_ABILITY_BEHAVIOR_POINT"    
        "AbilityTextureName"            "grimstroke_dark_artistry"
        "AbilityUnitDamageType"            "DAMAGE_TYPE_MAGICAL"
        "SpellImmunityType"                "SPELL_IMMUNITY_ENEMIES_NO"
        "MaxLevel"                      "4"

        // Casting
        //-------------------------------------------------------------------------------------------------------------
        "AbilityCooldown"               "11 9 7 5"
        "AbilityCastRange"                "1400"
        "AbilityManaCost"                "100 110 120 130"
        "AbilityCastPoint"                "0.5"
 
        // Special
        //--------------------------------------------------------------------------------------------------------
        "AbilitySpecial"
        {
            "01"
            {
                "var_type"                "FIELD_INTEGER"
                "cast_range"            "1400"
            }
            "02"
            {
                "var_type"                "FIELD_FLOAT"
                "damage"                "120 180 240 300"
            }
            "03"
            {
                "var_type"                "FIELD_FLOAT"
                "damage_multiply"        "16 24 32 40"
            }                                                    
        }                
    }

В абсолютно пустом файле это буедт выглядеть так:
Код:
"DOTAAbilities"
{
    "Version"        "1"

    "grimstroke_custom"
    {
        "BaseClass"                     "ability_lua"
        "ScriptFile"                    "abilities/grimstroke_custom.lua"    
        "AbilityBehavior"               "DOTA_ABILITY_BEHAVIOR_POINT"    
        "AbilityTextureName"            "grimstroke_dark_artistry"
        "AbilityUnitDamageType"            "DAMAGE_TYPE_MAGICAL"
        "SpellImmunityType"                "SPELL_IMMUNITY_ENEMIES_NO"
        "MaxLevel"                      "4"

        // Casting
        //-------------------------------------------------------------------------------------------------------------
        "AbilityCooldown"               "11 9 7 5"
        "AbilityCastRange"                "1400"
        "AbilityManaCost"                "100 110 120 130"
        "AbilityCastPoint"                "0.5"
 
        // Special
        //--------------------------------------------------------------------------------------------------------
        "AbilitySpecial"
        {
            "01"
            {
                "var_type"                "FIELD_INTEGER"
                "cast_range"            "1400"
            }
            "02"
            {
                "var_type"                "FIELD_FLOAT"
                "damage"                "120 180 240 300"
            }
            "03"
            {
                "var_type"                "FIELD_FLOAT"
                "damage_multiply"        "16 24 32 40"
            }                                                    
        }                
    }



}

Обратите внимание, какой у вас указан путь к файлу в графе ScriptFile. Так как я делал папочку abilities, у меня он выглядит вот так:
Код:
  "ScriptFile"                    "abilities/grimstroke_custom.lua"


4) Сразу добавим описание умения в локализацию.
Для этого идем в папку resource вашего мода и находим там файл addon_russian.txt и открываем. Если его нет, создаем сами.
В любом свободном месте (внутри { }) размещаем следующий код:
Код:
        "DOTA_Tooltip_Ability_grimstroke_custom"                            "Банан"
        "DOTA_Tooltip_Ability_grimstroke_custom_Description"                "Применять по назначению."
        "DOTA_Tooltip_Ability_grimstroke_custom_damage"                        "БАЗОВЫЙ УРОН:"
        "DOTA_Tooltip_Ability_grimstroke_custom_damage_multiply"            "ДОП. УРОН ЗА ВРАГА:"
        "DOTA_Tooltip_Ability_grimstroke_custom_cast_range"                    "ДАЛЬНОСТЬ:"



Пустым он бы выглядел вот так:
Код:
"lang"
{
    "Language"        "Russian"
    "Tokens"
    {
        "DOTA_Tooltip_Ability_grimstroke_custom"                            "Банан"
        "DOTA_Tooltip_Ability_grimstroke_custom_Description"                "Применять по назначению."
        "DOTA_Tooltip_Ability_grimstroke_custom_damage"                        "БАЗОВЫЙ УРОН:"
        "DOTA_Tooltip_Ability_grimstroke_custom_damage_multiply"            "ДОП. УРОН ЗА ВРАГА:"
        "DOTA_Tooltip_Ability_grimstroke_custom_cast_range"                    "ДАЛЬНОСТЬ:"
    }
}


Ну там естественно можете по своему офомрить описание. Не забудьте так же добавить подобное в addon_english.txt, только на английском. Чтобы не ловить предупреждения в консоли.

5) Подгружаем все партикли и звуки, что будем использовать.
Для этого идем в ...\scripts\vscripts\ и открываем файл addon_game_mode.lua.
Там в блоек прикеша (function Precache( context ) end) прописываем следующий код:

Код:
    PrecacheResource("particle", "particles/units/heroes/hero_grimstroke/grimstroke_loadout.vpcf", context)
    PrecacheResource("particle", "particles/units/heroes/hero_demonartist/demonartist_darkartistry_proj.vpcf", context)
    PrecacheResource("particle", "particles/units/heroes/hero_demonartist/demonartist_darkartistry_dmg_stroke_tgt.vpcf", context)
    PrecacheResource("particle", "particles/units/heroes/hero_demonartist/demonartist_darkartistry_dmg_steam.vpcf", context)
    PrecacheResource( "soundfile", "soundevents/game_sounds_heroes/game_sounds_grimstroke.vsndevts", context )

Если вы до этого ничего не подгружали, то сам блок выглядеть будет как-то так:
Код:
function Precache( context )
    PrecacheResource("particle", "particles/units/heroes/hero_grimstroke/grimstroke_loadout.vpcf", context)
    PrecacheResource("particle", "particles/units/heroes/hero_demonartist/demonartist_darkartistry_proj.vpcf", context)
    PrecacheResource("particle", "particles/units/heroes/hero_demonartist/demonartist_darkartistry_dmg_stroke_tgt.vpcf", context)
    PrecacheResource("particle", "particles/units/heroes/hero_demonartist/demonartist_darkartistry_dmg_steam.vpcf", context)
    PrecacheResource( "soundfile", "soundevents/game_sounds_heroes/game_sounds_grimstroke.vsndevts", context )
end

Ну и по собственному желанию, можете заменить все эти пять строчек на одну, подгрузку целиком героя, но увеличится её объем:
Код:
function Precache( context )
    PrecacheResource( "particle_folder", "particles/units/heroes/hero_grimstroke", context )
end


6) Откроем перед собой оригинал умения:

Stroke of Fate
Взмахом кисти герой проводит линию из чернил, нанося урон врагам на её пути и замедляя их. Урон увеличивается с каждым задетым врагом.
Анимация применения: 0,8+0,53
Дальность применения: 1400 ( 2000)
Дистанция волны: 1400 ( 2000)
Начальный радиус: 120
Конечный радиус: 160
Базовый урон: 120/180/240/300 ( 180/270/360/450)
Увеличение урона за удар: 16/24/32/40 ( 24/36/48/60)
Замедление скорости передвижения: 50%/60%/70%/80%
Длительность замедления: 1,5



Все, подготовку мы завершили, переходим непосредственно к созданию своей копии данного умения.
Возвращаемся в ...\scripts\vscripts\ и открываем grimstroke_custom.lua. Далее копируем туда следующий код с комментариями и читаем их (напоминаю, комментарии в lua располагаются в блоках --[[ ]] ) :
Lua:
--[[
Определяем саму убилку как самостоятельный класс, подонбо как опредееляем переменные.
]]
if grimstroke_custom == nil then
    grimstroke_custom = class({})
end

--[[
Определяем тип умения, непосредственно каст на точку.
]]
function grimstroke_custom:GetBehavior()
    return DOTA_ABILITY_BEHAVIOR_POINT
end


--[[
Определяем тип урона умения, у нас магический.
]]
function grimstroke_custom:GetAbilityDamageType()
    return DAMAGE_TYPE_MAGICAL
end


--[[
Этот метод, OnAbilityPhaseStart, имеет приоритет выше, чем OnSpellStart.
Соответственно, он запускается раньше.
В большинстве случаев его не используют при создании умений,
и если его не переопределять, то он самостоятельно запускает OnSpellStart.
Но наше умение будет использовать звук, который почему-то в  OnSpellStart идет с задержкой.
Поэтому пришлось переопределить метод OnAbilityPhaseStart() и перенестив  него часть логики.

Напоминаю, что "self" - это то-ли указатель, то-ли переменная, хранящая сам класс абилки.
Знатоки пояснят этот момент лучше, вам достаточно знать, что можно в классе абилки создавать
собственные переменные, дабы было удобнее хранить данные.

Логика простая:
1)сохраняем героя, кастующего спелл в сосбвтенную преемнную  self.caster.
2)Останавливаем его, если он двигался через API метод Stop().
4)Заставялем его проиграть анимацию через API метод StartGesture().
5)Проигрываем звук каста через API метод EmitSound().
6)Отрисовываем партикли - эффекты анимации через API ParticleManager.
7)Запускаем логику самого умения.

]]
function grimstroke_custom:OnAbilityPhaseStart()
    self.caster = self:GetCaster()
    self.caster:Stop()
    self.caster:StartGesture(ACT_DOTA_CAST_ABILITY_3)
    self.caster:EmitSound("Hero_Grimstroke.DarkArtistry.PreCastPoint")
    ParticleManager:CreateParticle("particles/units/heroes/hero_grimstroke/grimstroke_loadout.vpcf", PATTACH_ABSORIGIN, self.caster)    
    self:OnSpellStart()
end


--[[

Переменные, значения которых берем из DataDriven  (npc_abilities_custom.txt):
self.dmg - наша собственная переменная, в которой будем хранить урон умения.
self.dmgMultiple - наша собственная переменная, в которой будем хранить множитель урона.
self.range - наша собственная переменная, в которой будем хранить дистанцию полета снаряда.
Взглянув на DataDriven (DD) описание умения, вы можете задаться вопросом,
на кой черт я выделяю CastRange в отдельную переменную, если есть встроенная в DD
(то есть по сути у меня две величины отвечающие за одну и ту же механику).
Отвечу так: встроенная отображает радиус каста, если навести на умение на панели, но в то же время,
она не указывается в описании умения. Именно из-за этого я создаю еще и собственную величину, и пользуюсь ею.
Да, где-то здесь я чего-то недопонимаю, может кто-то пояснит за этот момент. А пока делаем, как есть.

Вспомогательные переменные:
self.targetTable - наш собственный массив, в котором будем хранить задетых снарядом юнитов.
self.vDirection - наша собственная переменная, в которую поместим вектор движения снаряда
иными словами: берем координаты точки каста умения, берем координаты точки расположения героя кастующего
находим их разницу, нормализуем API методом Normalized() и получаем    вектор движения.
Грамотеи математики меня поправят, сам я плох в ней.

Логика умения:
Далее идует объяснение некоторых механик программирования.
Знатоки забросают меня камнями, если я не предупрежу, что это ПРОИЗВОЛЬНЫЕ объяснения.
Можете в комментаах пояснить лучше, пойдет на пользу всем.

Когда мы нажимаем на кнопку каста умения, идет его обработка как на сервере, так и на клиенте.
Иными словами, мы как будто два раза кастуем одно и то же умение.
Поэтому нужно разделять код, который отработает клиент, а какой сервер. Иначе получим даблкаст.

Проверяем, вызван ли метод OnSpellStart() на сервере через оператор условия "if IsServer() then end".
Делаем это для того, чтобы умение скастовалось всего один раз, когда его будет обрабатывать именно сервер.
Почему именно на сервере реализуем дальнейшую логику? Да йух знает, там вроде обширнее база API.

После проверки запускаем таймер, который любезно на всеобщее пользование выложил BMD.
Указываем в его параметрах запуск через 8 милисекунд. А так же код нашей функции.

В функции мы создаем лоакльную переменную data, что является таблицей данных,
которые необходимы для дальнейшго создания снаряда.

Снаряд можно генерировать двух типов:
LinearProjectile - летит прост по вектору заданную дистанцию.
TrackingProjectile - летит до заданной цели (объекта).

Нам нужен LinearProjectile, пожтому после того, как задали data, создаем снаряд
с помощью API ProjectileManager:CreateLinearProjectile().

Ну и парочку звуков там еще проигрываем. через API метод EmitSound().

Подробнее про data (знатоки поправят, сам я толком не разбирался в значении каждого параметра):
EffectName - партикль снаряда
Ability - умение, которое будет отвечать за снаряд и его поведение.
Source - источник, герой.
vSpawnOrigin - точка создания снаряда.
vVelocity - вв переводе скорость, по факту одновременно и вектор направления и скорость полета.
fStartRadius - начальный радиус снаряда (ну тип в каком диапазоне касатсься других объектов, хитбоксы и все такое...)
fEndRadius  - радиус снаряда в конечной точке
fDistance - дистанция полета снаряда
iUnitTargetTeams - юнитов какой тимы "задевать" (отлавливать) при полете
iUnitTargetTypes - тип этих юнитов
iUnitTargetFlags - флаги всякие, вроде уязвимых к магии
iVisionTeamNumber - какой тиме давать обзор полета снаряда
iVisionRadius - сам радиус обзора
]]

function grimstroke_custom:OnSpellStart()

    self.dmg = self:GetSpecialValueFor("damage")
    self.dmgMultiple = self:GetSpecialValueFor("damage_multiply")
    self.range = self:GetSpecialValueFor("cast_range")

    self.targetTable = {}
    self.vDirection = self:GetCursorPosition()    - self.caster:GetAbsOrigin()
    self.vDirection = self.vDirection:Normalized()

    if IsServer() then
        Timers:CreateTimer(0.8, function()

            local data = {
                EffectName    = "particles/units/heroes/hero_demonartist/demonartist_darkartistry_proj.vpcf",
                Ability = self,
                Source = self.caster,
                vSpawnOrigin = self.caster:GetAbsOrigin(),
                vVelocity = self.vDirection * 2700 * 0.7,
                fStartRadius = 120,
                fEndRadius = 160,
                fDistance = self.range,
                iUnitTargetTeams = DOTA_UNIT_TARGET_TEAM_ENEMY,
                iUnitTargetTypes = DOTA_UNIT_TARGET_BASIC + DOTA_UNIT_TARGET_HERO,
                iUnitTargetFlags = DOTA_UNIT_TARGET_FLAG_NONE,
                iVisionTeamNumber = self.caster:GetTeamNumber(),
                iVisionRadius = 100,
            }

            self.caster:EmitSound("Hero_Grimstroke.DarkArtistry.Cast.Layer")
            self.caster:EmitSound("Hero_Grimstroke.DarkArtistry.Cast")
            self.caster:EmitSound("Hero_Grimstroke.DarkArtistry.Projectile")
            ProjectileManager:CreateLinearProjectile( data )
        end
        )
    end

end



--[[
OnProjectileThink - метод, что отрабатывает каждую координату полета снаряда.
Так как мы в data указали в качестве родителя (Ability) снаряда саму абилку, мы 
обрабатывать его полет не где-то за пределами умения, а именно здесь, прописанным ниже образом.

vLocation - переменная извне, от сервера, которая несет в себе текущую координату снаряда.

Логика:
Проверяем, существует (живой или мертвый) герой, что кастовал абилку.
Если нет, то я просто ничего не делаю (не наношу урон, и т.п.).
Если все же существует, то:

1)Даю обзор полета снаряда команде игрока. По идеи за это должен отвчать iVisionRadius из data,
но почему-то он этого не делал и я с помощью API метода AddFOWViewer решил этот вопрос.
2)Ищу существ вблизи снаряда через API метод FindUnitsInRadius.
3)Проверяю, нашлись ли такие и если да, то ппроверяю, нет ли его в targetTable.
напоминаю, что в этом массиве я храню как раз таки юнитов, которых снаряд уже имел честь
задеть один раз. Без этого массива не обойтись, ибо юниты имеют свой радиуес "жирности",
иными словами, сразу несоклько точек обхватывают на плоскости. Поэтому чтобы два раза не
считать одного юнита при подсчете конечного урона, мы и создали targetTable.

Так вот, если юнит есть в targetTable, то есть я уже обрабатывал это столккновение,
то далее прерываю код методом "return". Иными словами, выхожу из обработки полета снаряда.

Если же юнита нет в таблице, я его туда заношу через Lua функцию table.insert()
Далее увеличиваю показатель урона умения множителем урона.
Но перед этим проверяю, что self.targetTable > 1. Зачем? Чтобы первый юнит, который встретит снаряд,
получил именно базовый урон, а не увеличенный (как второй и т.д.).
Можно было бы конечно иначе обойти этот момент, но я решил так.

Далее мы непосредственно наносим урон юниту через API метод ApplyDamage().
Ну и рисуем всякие эффекты там на юните, да и звук столкновения.
]]
function  grimstroke_custom:OnProjectileThink(vLocation)

    if self:GetCaster() then

        AddFOWViewer(self.caster:GetTeamNumber(), vLocation, 10, 0.2, false)

        local units = FindUnitsInRadius( self.caster:GetTeamNumber(), vLocation, self.caster, 100,
            DOTA_UNIT_TARGET_TEAM_ENEMY, DOTA_UNIT_TARGET_BASIC + DOTA_UNIT_TARGET_HERO, DOTA_UNIT_TARGET_FLAG_NONE, 0, false )

        if units then
            for i = 1, #units do

                if units[i] then
                    for j = 0, #self.targetTable do
                        if self.targetTable[j] == units[i] then
                            return nil
                        end
                    end

                    table.insert(self.targetTable, units[i])

                    if #self.targetTable > 1 then
                        self.dmg = self.dmg + self.dmgMultiple
                    end

                    ApplyDamage({
                        victim = units[i],
                        attacker = self.caster,
                        damage = self.dmg,
                        damage_type = self:GetAbilityDamageType(),
                        ability = self
                       })

                    ParticleManager:CreateParticle("particles/units/heroes/hero_demonartist/demonartist_darkartistry_dmg_stroke_tgt.vpcf", PATTACH_ABSORIGIN, units[i])
                    ParticleManager:CreateParticle("particles/units/heroes/hero_demonartist/demonartist_darkartistry_dmg_steam.vpcf", PATTACH_ABSORIGIN, units[i])    
                    units[i]:EmitSound("Hero_Grimstroke.DarkArtistry.Damage")
                end
            end
        end

    end

end

Все, умение готово, можете прописывать его героям, видоизменять, добавлять реакцию на другие предметы и т.п. И да, уточняю, что эта реализация никоим образом не учитывает Аганим или рефрешер, поэтому при взаимодействии с ними вполне естественно могут возникнуть какие-то неполадки. Реализация взаимодействия не относится к главному вопросу, поэтому она и не учитывалась. Но естественно, вам не составит никакого труда добавит эту реализацию в этот код, благо архитектура позволяет.

Ну и напоследок гифка сравнения оригинала с поделкой. Для тех, кто не читал комментарии к коду - да, в оригинале есть некая реализация инерции от замаха, а в поделке нет этого отклонения. Не стал с этим заморачиваться , так как к основному вопросу это не относится, да и после данного руководства вы вполне сами сможете реализовать это отклонение, если понадобится (по сути прост точку сместить):




Так же прикрепляю grimstroke_custom.txt. Поменяйте разрешение ему с txt на lua и будет готовый файл (форум не разрешает грузить lua файлы).
 

Вложения

  • grimstroke_custom.txt
    13.5 KB · Просмотры: 9
Последнее редактирование:

I_GRIN_I

Друзья CG
15 Мар 2016
1,335
105
Только у гримстрока скилл вылетает из кисти, а не из героя))


Кстати особо знать геометрию, тригонометрию и тд не нужно, нужно просто знать, что будет если сделать вот-так, а что будет, если сделать вот-так, я так и пишу скрипты
 
  • Нравится
Реакции: Илья

shesmu

Продвинутый
22 Фев 2018
158
22
self:GetCaster():GetAttachmentOrigin( self:GetCaster():ScriptLookupAttachment( "attach_attack1" ) ) - для координат кисти гримстрока, ну или мб там не attach_attack1, ну вы поняли, бтв почему не использовать OnProjectileHit для нанесения урона?
 

I_GRIN_I

Друзья CG
15 Мар 2016
1,335
105
self:GetCaster():GetAttachmentOrigin( self:GetCaster():ScriptLookupAttachment( "attach_attack1" ) ) - для координат кисти гримстрока, ну или мб там не attach_attack1, ну вы поняли, бтв почему не использовать OnProjectileHit для нанесения урона?
Судя по тому, что я помню, эта функция работает кривовато, и, то-ли не возвращает в кого она воткнулась, то-ли не возвращает какую-то другую важную хурму
 

Илья

Друзья CG
25 Сен 2015
2,348
41
В руководстве нигде не говорилось, что нельзя использовать OnProjectileHit(). Если он работает, то пожалуйста (уже не помню, почему сам не использовал). Перестаньте смотреть на руководства, как на какую-то догму. Конкретно это руководство клепалось на скорую руку в соответствии с уплаченной ценой. То есть лично я вообще не планировал что-то подобное выкладывать и если вы хотите усовершенствовать логику - вперед, выкладывайте код, ибо новички вряд ли поймут что делать с вашим замечанием, а я вообще не преследую цель совершенства, лишь отработал запрос.
 
  • Нравится
Реакции: I_GRIN_I
20 Дек 2016
892
170
Этот метод, OnAbilityPhaseStart, имеет приоритет выше, чем OnSpellStart. Соответственно, он запускается раньше. В большинстве случаев его не используют при создании умений, и если его не переопределять, то он самостоятельно запускает OnSpellStart. Но наше умение будет использовать звук, который почему-то в OnSpellStart идет с задержкой. Поэтому пришлось переопределить метод OnAbilityPhaseStart() и перенестив него часть логики.
OnAbilityPhaseStart выполняется в момент начала анимации каста, когда способность еще может быть отменена, а OnSpellStart - по ее завершению, отсюда и задержка. Туда стоило вынести только создание звука и партикла (и еще желательно прописать их уничтожение в OnAbilityPhaseInterrupted), при этом не нужно было бы использовать StartGesture, и уж тем более таймер. OnSpellStart сам бы запускался, если бы не self.caster:Stop(), достаточно просто прописать return true в этом методе.
А еще из-за такого подхода способность не запускается на кулдаун и не тратит ману.
 
Последнее редактирование:
  • Нравится
Реакции: Илья
Реклама: