[Перевод] Создание игры на Lua и LÖVE — 5

image


Оглавление


  • Статья 1
    • Часть 1. Игровой цикл
    • Часть 2. Библиотеки
    • Часть 3. Комнаты и области
    • Часть 4. Упражнения
  • Статья 2
    • Часть 5. Основы игры
    • Часть 6. Основы класса Player
  • Статья 3
    • Часть 7. Параметры и атаки игрока
    • Часть 8. Враги
  • Статья 4
    • Часть 9. Режиссёр и игровой цикл
    • Часть 10. Практики написания кода
    • Часть 11. Пассивные навыки
  • Статья 5
    • Часть 12. Другие пассивные навыки


13. Skill Tree

14. Console

15. Final

Часть 12: Другие пассивные навыки


Залп


Мы начнём с реализации оставшихся атак. Первой будет атака Blast, которая выглядит так:

GIF
20d9797d2ea3214914874ef570029876.gif


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

attacks['Blast'] = {cooldown = 0.64, ammo = 6, abbreviation = 'W', color = default_color}


А вот, как выглядит процесс создания снарядов:

function Player:shoot()
    ...
  	
    elseif self.attack == 'Blast' then
        self.ammo = self.ammo - attacks[self.attack].ammo
        for i = 1, 12 do
            local random_angle = random(-math.pi/6, math.pi/6)
            self.area:addGameObject('Projectile', 
      	        self.x + 1.5*d*math.cos(self.r + random_angle), 
      	        self.y + 1.5*d*math.sin(self.r + random_angle), 
            table.merge({r = self.r + random_angle, attack = self.attack, 
          	v = random(500, 600)}, mods))
        end
        camera:shake(4, 60, 0.4)
    end

    ...
end


Здесь мы просто создаём 12 снарядов со случайным углом в интервале от -30 до +30 градусов от направления, в котором движется игрок. Также мы рандомизируем скорость в интервале от 500 и 600 (обычно её значение равно 200), то есть снаряд будет примерно в три раза быстрее обычного.

Однако это пока не даст нам желаемого поведения, потому что мы хотим, чтобы снаряды достаточно быстро исчезали. Это можно реализовать следующим образом:

function Projectile:new(...)
    ...
  	
    if self.attack == 'Blast' then
        self.damage = 75
        self.color = table.random(negative_colors)
        self.timer:tween(random(0.4, 0.6), self, {v = 0}, 'linear', function() self:die() end)
    end
  	
    ...
end


Здесь происходит три действия. Во-первых, мы задаём для урона значение меньше 100. Это значит, что для убийства обычного врага с 100 HP нам потребуется не один, а два снаряда. Это логично, потому что при этой атаке одновременно выстреливается 12 снарядов. Во-вторых, мы задаём цвет снаряда, случайным образом выбирая его из таблицы negative_colors. Именно в этом месте кода нам удобно это сделать. Наконец, мы сообщаем, что после случайного промежутка времени от 0.4 до 0.6 секунды этот снаряд должен уничтожиться, что даст нам требуемый эффект. Кроме того, мы не просто уничтожаем снаряд, а уменьшаем его скорость до 0, потому что это выглядит немного лучше.

Всё это создаёт нужное нам поведение и кажется, что мы уже закончили. Однако после добавления кучи пассивных навыков в предыдущей части статьи нам нужно быть внимательными и убедиться, что всё, добавляемое после, будет нормально сочетаться с этими пассивными навыками. Например, последним в предыдущей части мы добавили эффект снаряда-щита. Проблема с атакой Blast заключается в том, что она совершенно не сочетается с эффектом снаряда-щита, потому что снаряды Blast умирают через 0.4–0.6 секунды, что делает их очень плохими снарядами для щита.

Один из способов решения этой проблемы — отделить мешающие пассивные навыки (в нашем случае — щит) и применять к каждой ситуацию собственную логику. В ситуации, когда значение shield для снаряда равно true, то снаряд независимо от всего остального должен существовать 6 секунд. А во всех других ситуациях будет сохраняться длительность, задаваемая именно атакой. Вот, как это будет выглядеть:

function Projectile:new(...)
    ...
  	
    if self.attack == 'Blast' then
        self.damage = 75
        self.color = table.random(negative_colors)
    	if not self.shield then
            self.timer:tween(random(0.4, 0.6), self, {v = 0}, 'linear', function() 
                self:die() 
            end)
      	end
    end
  
    if self.shield then
        ...
        self.timer:after(6, function() self:die() end)
    end
  	
    ...
end


Это решение кажется хаком, и можно легко представить, что оно постепенно будет всё усложняться с добавлением новых пассивных навыков, и нам придётся добавлять всё больше и больше условий. Но исходя из моего опыта, такой способ является простейшим и менее всего подверженным ошибкам, чем все остальные. Можно попробовать решить эту проблему другим, более общим способом, и обычно это будет иметь непредусмотренные последствия. Возможно, существует и более хорошее общее решение этой проблемы, до которого лично я не додумался, но если я его не нашёл, то следующим лучшим решением будет самое простейшее, а именно множество условных конструкций, определяющих, что можно, а что нельзя делать. Как бы то ни было, теперь каждую новую добавляемую атаку, меняющую длительность жизни снаряда, мы будем предварять условием if not self.shield.

172. (КОНТЕНТ) Реализуйте пассивный навык projectile_duration_multiplier. Не забывайте использовать его для всех поведений класса Projectile, связанных с длительностью.

Вращение


Следующей реализуемой атакой будет Spin. Она выглядит следующим образом:

GIF
9624428d34edb9a94e661474c02a4dd2.gif


У этих снарядов постоянно изменяется угол на постоянную величину. Мы можем реализовать это, добавив переменную rv, которая будет обозначать скорость изменения угла, а потом прибавлять в каждом кадре это значение к r:

function Projectile:new(...)
    ...
  	
    self.rv = table.random({random(-2*math.pi, -math.pi), random(math.pi, 2*math.pi)})
end

function Projectile:update(dt)
    ...
  	
    if self.attack == 'Spin' then
    	self.r = self.r + self.rv*dt
    end
  	
    ...
end


Мы выбираем между интервалами от -2*math.pi до -math.pi ИЛИ между интервалами от math.pi до 2*math.pi потому, что не хотим, чтобы абсолютные значения были меньше math.pi или больше 2*math.pi. Низкие абсолютные значения означают, что совершаемый снарядом круг становится больше, а большие абсолютные значения означают, что круг становится меньше. Мы хотим ограничить размер круга нужными нам значениями, чтобы это выглядело правильно. Также должно быть понятно, что разница между отрицательными и положительными значениями заключается в направлении, в котором вращается круг.

Кроме того, мы можем добавить снарядам Spin длительность жизни, потому что не хотим, чтобы они существовали вечно:

function Projectile:new(...)
    ...
  	
    if self.attack == 'Spin' then
        self.timer:after(random(2.4, 3.2), function() self:die() end)
    end
end


Вот, как будет выглядеть функция shoot:

function Player:shoot()
    ...
  
    elseif self.attack == 'Spin' then
        self.ammo = self.ammo - attacks[self.attack].ammo
        self.area:addGameObject('Projectile', 
    	self.x + 1.5*d*math.cos(self.r), self.y + 1.5*d*math.sin(self.r), 
    	table.merge({r = self.r, attack = self.attack}, mods))
    end  	
end


А вот, как выглядит таблица атаки:

attacks['Spin'] = {cooldown = 0.32, ammo = 2, abbreviation = 'Sp', color = hp_color}


Таким образом мы получим нужное нам поведение. Однако нам нужно ещё одну вещь — след от снаряда. В отличие от самонаводящегося снаряда, который использует тот же след, который применялся для кораблей игрока, у этого снаряда след будет повторять форму и цвет снаряда, однако также будет медленно становиться невидимым, пока совершенно не исчезнет. Мы можем реализовать это так же, как мы делали для другого объекта следа, но приняв во внимание эти отличия:

ProjectileTrail = GameObject:extend()

function ProjectileTrail:new(area, x, y, opts)
    ProjectileTrail.super.new(self, area, x, y, opts)

    self.alpha = 128
    self.timer:tween(random(0.1, 0.3), self, {alpha = 0}, 'in-out-cubic', function() 
    	self.dead = true 
    end)
end

function ProjectileTrail:update(dt)
    ProjectileTrail.super.update(self, dt)
end

function ProjectileTrail:draw()
    pushRotate(self.x, self.y, self.r) 
    local r, g, b = unpack(self.color)
    love.graphics.setColor(r, g, b, self.alpha)
    love.graphics.setLineWidth(2)
    love.graphics.line(self.x - 2*self.s, self.y, self.x + 2*self.s, self.y)
    love.graphics.setLineWidth(1)
    love.graphics.setColor(255, 255, 255, 255)
    love.graphics.pop()
end

function ProjectileTrail:destroy()
    ProjectileTrail.super.destroy(self)
end


И это выглядит достаточно стандартно, единственный заметный аспект заключается в том, что у нас есть переменная alpha, которую мы изменяем через tween до 0, чтобы снаряд медленно исчезал через случайный промежуток времени от 0.1 до 0.3 секунды, а затем мы отрисовываем след точно так же, как отрисовываем снаряд. Важно, что мы используем переменные r, s и color родительского снаряда, то есть при создании его нам нужно их все передавать:

function Projectile:new(...)
    ...
  
    if self.attack == 'Spin' then
        self.rv = table.random({random(-2*math.pi, -math.pi), random(math.pi, 2*math.pi)})
        self.timer:after(random(2.4, 3.2), function() self:die() end)
        self.timer:every(0.05, function()
            self.area:addGameObject('ProjectileTrail', self.x, self.y, 
            {r = Vector(self.collider:getLinearVelocity()):angle(), 
            color = self.color, s = self.s})
        end)
    end

    ...
end


Таким образом мы добьёмся нужных нам результатов.

173. (КОНТЕНТ) Реализуйте атаку Flame. Вот, как должна выглядеть таблица атаки:

attacks['Flame'] = {cooldown = 0.048, ammo = 0.4, abbreviation = 'F', color = skill_point_color}


А вот, как выглядит сама атака:

GIF
99c5f1ac2e0f8524a035bc1f1769b962.gif


Снаряды должны оставаться живыми в течение случайного интервала времени от 0.6 до 1 секунды и походить на снаряды Blast, а их скорость должна в течение этого времени изменяться с помощью tween до 0. Эти снаряды тоже используют объект ProjectileTrail так, как это делают снаряды Spin. Каждый из снарядов Flame тоже наносит уменьшенный урон по 50 единиц.

Отскакивающие снаряды


Снаряды Bounce должны отскакивать от стен, а не разрушаться ими. По умолчанию снаряд Bounce может отразиться от стен 4 раза, прежде чем уничтожиться при очередном ударе об стену. Мы можем задать это с помощью таблицы opts в функции shoot:

function Player:shoot()
    ...
  	
    elseif self.attack == 'Bounce' then
        self.ammo = self.ammo - attacks[self.attack].ammo
        self.area:addGameObject('Projectile', 
    	self.x + 1.5*d*math.cos(self.r), self.y + 1.5*d*math.sin(self.r), 
    	table.merge({r = self.r, attack = self.attack, bounce = 4}, mods))
    end
end


Таким образом, переменная bounce будет содержать количество отскоков, оставшихся у снаряда. Мы можем использовать её, уменьшая на 1 при каждом ударе об стену:

function Projectile:update(dt)
    ...
  
    -- Collision
    if self.bounce and self.bounce > 0 then
        if self.x < 0 then
            self.r = math.pi - self.r
            self.bounce = self.bounce - 1
        end
        if self.y < 0 then
            self.r = 2*math.pi - self.r
            self.bounce = self.bounce - 1
        end
        if self.x > gw then
            self.r = math.pi - self.r
            self.bounce = self.bounce - 1
        end
        if self.y > gh then
            self.r = 2*math.pi - self.r
            self.bounce = self.bounce - 1
        end
    else
        if self.x < 0 then self:die() end
        if self.y < 0 then self:die() end
        if self.x > gw then self:die() end
        if self.y > gh then self:die() end
    end
  
    ...
end


Здесь кроме уменьшения количества оставшихся отскоков мы также изменяем направление снаряда с учётом стены, об которую он ударился. Возможно, существует более общий способ сделать это, но я смог придумать только такое решение, в котором учитывается столкновение с каждой стеной по отдельности, после чего выполняются необходимые вычисления для правильного отражения/отзеркаливания угла снаряда. Заметьте, что когда bounce равна 0, то первая условная конструкция пропускается и мы переходим к обычному пути, который приводит нас к уничтожению снаряда.

Важно также расположить весь этот код коллизий перед вызовом setLinearVelocity, иначе отскоки не будут работать, потому что мы будем поворачивать снаряд с задержкой в один кадр, а просто реверсирвание его угла не заставит его вернуться назад. Ради безопасности кроме поворота угла снаряда мы также можем использовать setPosition для принудительного задания его позиции, но мне не кажется это необходимым.

Цвета отскакивающего снаряда будут случайными, как и у снаряда Spread, за исключением того, что берутся из таблицы default_colors. Это значит, что нам нужно позаботиться о них в функции Projectile:draw отдельно:

function Projectile:draw()
    ...
    if self.attack == 'Bounce' then love.graphics.setColor(table.random(default_colors)) end
    ...
end


Таблица атаки выглядит следующим образом:

attacks['Bounce'] = {cooldown = 0.32, ammo = 4, abbreviation = 'Bn', color = default_color}


И всё это должно выглядеть так:

GIF
8a2fc1fd9484698f9c938080b036a333.gif


174. (КОНТЕНТ) Реализуйте атаку 2Split. Вот, как она выглядит:

GIF
bytepath123.gif


Она в точности похожа на снаряд Homing, только использует цвет ammo_color.

Когда снаряд попадает во врага, то разделяется на два (создаётся два новых снаряда) под углами ±45 градусов от направления исходного снаряда. Если снаряд ударяется об стену, то два снаряда создаются или с углом отражения от стены (то есть если снаряд ударяется о верхнюю стену, то создаются два снаряда, направленные в math.pi/4 и 3*math.pi/4) или противоположно углу отражения снаряда, можете выбирать сами. Вот, как выглядит таблица этой атаки:

attacks['2Split'] = {cooldown = 0.32, ammo = 3, abbreviation = '2S', color = ammo_color}


175. (КОНТЕНТ) Реализуйте атаку 4Split. Вот, как она выглядит:

GIF
bytepath121.gif


Она ведёт себя точно так же, как атака 2Split, только создаёт не 2, а 4 снаряда. Снаряды направляются под всеми углами в 45 градусов от центра, то есть math.pi/4, 3*math.pi/4, -math.pi/4 и -3*math.pi/4. Вот, как выглядит таблица атаки:

attacks['4Split'] = {cooldown = 0.4, ammo = 4, abbreviation = '4S', color = boost_color}


Молния


Вот, как выглядит атака Lightning:

GIF
0c39264f7450491afe2b812edc1c35e7.gif


Когда игрок достигает определённого расстояния до врага, создаётся заряд молнии, наносящий урон врагу. БОльшая часть работы здесь заключается в создании заряда молнии, поэтому мы рассмотрим в первую очередь его. Мы реализуем его, создав объект LightningLine, который будет визуальным представлением заряда молнии:

LightningLine = GameObject:extend()

function LightningLine:new(area, x, y, opts)
    LightningLine.super.new(self, area, x, y, opts)
  	
    ...
    self:generate()
end

function LightningLine:update(dt)
    LightningLine.super.update(self, dt)
end

-- Generates lines and populates the self.lines table with them
function LightningLine:generate()
    
end

function LightningLine:draw()

end

function LightningLine:destroy()
    LightningLine.super.destroy(self)
end


Я сосредоточусь на функции отрисовки и оставлю создание линий молнии для вас! В этом туториале очень подробно описывается способ генерирования, так что я не буду повторять его здесь. Будем считать, что все линии, составляющие заряд молнии, находятся в таблице self.lines, и что каждая линия — это таблица, содержащая ключи x1, y1, x2, y2. С учётом этого, мы можем простейшим образом отрисовать заряд молнии так:

function LightningLine:draw()
    for i, line in ipairs(self.lines) do 
        love.graphics.line(line.x1, line.y1, line.x2, line.y2) 
    end
end


Однако это выглядит слишком просто. Поэтому нам нужно сначала отрисовать эти линии цветом boost_color и с толщиной линии 2.5, а затем поверх них мы отрисуем те же линии снова, но с цветом default_color и толщиной линии 1.5. Это сделает заряд молнии немного толще и больше походящим на молнию.

function LightningLine:draw()
    for i, line in ipairs(self.lines) do 
        local r, g, b = unpack(boost_color)
        love.graphics.setColor(r, g, b, self.alpha)
        love.graphics.setLineWidth(2.5)
        love.graphics.line(line.x1, line.y1, line.x2, line.y2) 

        local r, g, b = unpack(default_color)
        love.graphics.setColor(r, g, b, self.alpha)
        love.graphics.setLineWidth(1.5)
        love.graphics.line(line.x1, line.y1, line.x2, line.y2) 
    end
    love.graphics.setLineWidth(1)
    love.graphics.setColor(255, 255, 255, 255)
end


Кроме того, я использую здесь атрибут alpha, который изначально равен 255 и снижается с помощью tween до 0 на протяжении срока жизни линии, то есть примерно 0,15 секунды.

Теперь перейдём к самому созданию этого объекта LightningLine. Мы хотим, чтобы эта атака работала так: когда игрок достаточно приближается к врагу в пределах его непосредственной видимости, срабатывает атака и мы наносим урон врагу. Поэтому давайте для начала получим всех врагов, находящихся близко к игроку. Мы можем делать это так же, как и в случае самонаводящегося снаряда, который подбирал цель в определённом радиусе. Однако мы хотим, чтобы радиус не был центрирован на игроке, потому что игрок не должен иметь возможности наносить урон находящимся за ним врагам, поэтому мы сместим центр этого круга немного вперёд, в направлении движения игрока, после чего будем выполнять действия.

function Player:shoot()
    ...
  
    elseif self.attack == 'Lightning' then
        local x1, y1 = self.x + d*math.cos(self.r), self.y + d*math.sin(self.r)
        local cx, cy = x1 + 24*math.cos(self.r), y1 + 24*math.sin(self.r)
        ...
end


Здесь мы определяем x1, y1, то есть позицию, из которой мы в общем случае выстреливаем снаряды (на самом носу корабля), а затем мы также определяем cx, cy, то есть центр радиуса, который мы будем использовать для поиска ближайшего врага. Мы смещаем круг на 24 единицы, что достаточно много, чтобы он не мог выбирать врагов, находящихся за игроком.

Следующее, что мы можем сделать — просто скопипастить код, который мы использовали в объекте Projectile, когда хотели, чтобы самонаводящиеся снаряды находили свои цели, но изменим его под свои нужны, заменив позицию круга на наш центр круга cx, cy:

function Player:shoot()
    ...
  	
    elseif self.attack == 'Lightning' then
        ...
  
        -- Find closest enemy
        local nearby_enemies = self.area:getAllGameObjectsThat(function(e)
            for _, enemy in ipairs(enemies) do
                if e:is(_G[enemy]) and (distance(e.x, e.y, cx, cy) < 64) then
                    return true
                end
            end
        end)
  	...
end


После этого мы получим список врагов в пределах радиуса в 64 единиц круга, расположенного на 24 единицы перед игроком. Здесь мы можем или выбирать врага случайным образом, или брать ближайшего. Мы остановимся на последнем варианте, то есть для этого нам нужно отсортировать таблицу на основании расстояния от каждого из врагов до круга:

function Player:shoot()
    ...
  	
    elseif self.attack == 'Lightning' then
        ...
  
        table.sort(nearby_enemies, function(a, b) 
      	    return distance(a.x, a.y, cx, cy) < distance(b.x, b.y, cx, cy) 
    	end)
        local closest_enemy = nearby_enemies[1]
  	...
end


Для этой цели здесь можно использовать table.sort. Затем нам достаточно взять первый элемент отсортированной таблицы и атаковать его:

function Player:shoot()
    ...
  	
    elseif self.attack == 'Lightning' then
        ...
  
        -- Attack closest enemy
        if closest_enemy then
            self.ammo = self.ammo - attacks[self.attack].ammo
            closest_enemy:hit()
            local x2, y2 = closest_enemy.x, closest_enemy.y
            self.area:addGameObject('LightningLine', 0, 0, {x1 = x1, y1 = y1, x2 = x2, y2 = y2})
            for i = 1, love.math.random(4, 8) do 
      	        self.area:addGameObject('ExplodeParticle', x1, y1, 
                {color = table.random({default_color, boost_color})}) 
    	    end
            for i = 1, love.math.random(4, 8) do 
      	        self.area:addGameObject('ExplodeParticle', x2, y2, 
                {color = table.random({default_color, boost_color})}) 
    	    end
        end
    end
end


Сначала нам нужно убедиться, что closest_enemy не равняется nil, поскольку если это так, то мы не должны ничего делать. БОльшую часть времени он будет равен nil, поскольку врагов рядом нет. Если это не так, то мы уменьшаем боезапас, как делали для всех остальных атак, а затем вызываем функцию hit для того врага, которому наносится урон. После этого мы создаём объект LightningLine с переменными x1, y1, x2, y2, представляющими собой позицию прямо перед кораблём, из которой будет выпущен заряд, а также центр врага. Наконец, мы создаём кучу частиц ExplodeParticle, чтобы сделать атаку интереснее.

Последнее, что нам нужно для работы атаки — это её таблица:

attacks['Lightning'] = {cooldown = 0.2, ammo = 8, abbreviation = 'Li', color = default_color}


И всё это должно выглядеть так:

GIF
3c274296c775533e7d8e8538621cd92a.gif


176. (КОНТЕНТ) Реализуйте атаку Explode. Вот, как она выглядит:

GIF
bytepath122.gif


Создаётся взрыв, уничтожающий всех врагов в определённом радиусе. Сам снаряд выглядит как самонаводящийся, за исключением того, что hp_color немного больше. Таблица атаки выглядит так:

attacks['Explode'] = {cooldown = 0.6, ammo = 4, abbreviation = 'E', color = hp_color}


177. (КОНТЕНТ) Реализуйте атаку Laser. Вот, как она выглядит:

GIF
c09857e2c7d1315c4365077712b3948b.gif


Создаётся огромная линия, уничтожающая всех врагов, которые её пересекают. Её можно запрограммировать или как линию, или как повёрнутый прямоугольник для распознавания коллизий. Если вы решите использовать линию, то лучше воспользоваться 3 или 5 линиями, которые немного отделены друг от друга, в противном случае игрок иногда будет промахиваться по врагам, что кажется несправедливым.

Эффект самой атаки отличается от всех остальных, но с ним не должно возникнуть проблем. Одна огромная белая линия посередине, толщина которой изменяется со временем при помощи tween, и две красные линии по бокам, которые сначала находятся близко к белым линиям, но потом расширяются и исчезают при завершении эффекта. Эффект стрельбы является увеличенной версией исходного объекта ShootEffect и тоже имеет красный цвет. Таблица атаки выглядит так:

attacks['Laser'] = {cooldown = 0.8, ammo = 6, abbreviation = 'La', color = hp_color}


178. (КОНТЕНТ) Реализуйте пассивный навык additional_lightning_bolt. Если он равен true, то игрок может атаковать двумя зарядами молний одновременно. С точки зрения программирования это означает, что вместо поиска одного ближайшего врага мы будем искать двух и атаковать обоих, если они существуют. Можно также попробовать отделить каждую атаку небольшим интервалом, например 0.5 секунды, потому что это выглядит лучше.

179. (КОНТЕНТ) Реализуйте пассивный навык increased_lightning_angle. Этот навык увеличивает угол, под которым может срабатывать атака молнией, то есть она также будет атаковать врагов по бокам, а иногда и за игроком. С точки зрения программирования это означает, что когда increased_lightning_angle равно true, то мы не выполняем смещение круга молнии на 24 единиц и используем в своих вычислениях центр игрока.

180. (КОНТЕНТ) Реализуйте пассивный навык area_multiplier. Этот навык увеличивает область всех атак и эффектов, связанных с площадями. В качестве самых последних примеров можно привести круг молнии атаки Lightning, а также область взрыва атаки Explode. Но это также будет относиться и к взрывам в целом, а также ко всему, что связано с областями (когда для получения информации или применения эффектов используется круг).

181. (КОНТЕНТ) Реализуйте пассивный навык laser_width_multiplier. Этот навык увеличивает или уменьшает толщину атаки Laser.

182. (КОНТЕНТ) Реализуйте пассивный навык additional_bounce_projectiles. Этот навык увеличивает или уменьшает количество отскоков снаряда Bounce. По умолчанию снаряды атаки Bounce могут отскакивать 4 раза. Если additional_bounce_projectiles равно 4, то снаряды атаки Bounce смогут отскакивать 8 раз.

183. (КОНТЕНТ) Реализуйте пассивный навык fixed_spin_attack_direction. Этот навык типа boolean делает так, что все снаряды атаки Spin вращаются в постоянном направлении, то есть все они будут вращаться или только влево, или только вправо.

184. (КОНТЕНТ) Реализуйте пассивный навык split_projectiles_split_chance. Это снаряд, добавляющий уже разделённым атакой 2Split или 4Split снарядам вероятность разделиться ещё раз. Например, если эта вероятность становится равной 100, то разделённые снаряды будут рекурсивно разделяться постоянно (однако в дереве навыков мы этого не допустим).

185. (КОНТЕНТ) Реализуйте пассивные навыки [attack]_spawn_chance_multiplier, где [attack] — это название каждой атаки. Эти навыки увеличивают вероятность создания определённой атаки. Сейчас, когда мы создаём ресурс Attack, атака выбирается случайным образом. Однако теперь мы хотим, чтобы они выбирались из chanceList, который изначально имеет равные вероятности для всех атак, но изменяется с помощью пассивных навыков [attack]_spawn_chance_multiplier.

186. (КОНТЕНТ) Реализуйте пассивные навыки start_with_[attack], где [attack] — название каждой атаки. Эти пассивные навыки делают так, что игрок начинает игру с соответствующей атакой. Например, если start_with_bounce равно true, то игрок будет начинать каждый раунд с атакой Bounce. Если значение true имеют несколько пассивных навыков start_with_[attack], то одна из них выбирается случайным образом.

Дополнительные самонаводящиеся снаряды


Пассивный навык additional_homing_projectiles добавляет дополнительные снаряды в пассивные навыки типа «Запуск снаряда Homing». Обычно запускаемые самонаводящиеся снаряды выглядят так:

function Player:onAmmoPickup()
    if self.chances.launch_homing_projectile_on_ammo_pickup_chance:next() then
        local d = 1.2*self.w
        self.area:addGameObject('Projectile', 
      	self.x + d*math.cos(self.r), self.y + d*math.sin(self.r), 
      	{r = self.r, attack = 'Homing'})
        self.area:addGameObject('InfoText', self.x, self.y, {text = 'Homing Projectile!'})
    end
end


additional_homing_projectiles — это число, сообщающее нам, сколько дополнительных снарядов нужно использовать. Чтобы это работало, мы можем сделать нечто подобное:

function Player:onAmmoPickup()
    if self.chances.launch_homing_projectile_on_ammo_pickup_chance:next() then
        local d = 1.2*self.w
    	for i = 1, 1+self.additional_homing_projectiles do
            self.area:addGameObject('Projectile', 
      	    self.x + d*math.cos(self.r), self.y + d*math.sin(self.r), 
      	    {r = self.r, attack = 'Homing'})
      	end
        self.area:addGameObject('InfoText', self.x, self.y, {text = 'Homing Projectile!'})
    end
end


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

187. (КОНТЕНТ) Реализуйте пассивный навык additional_barrage_projectiles.

188. (КОНТЕНТ) Реализуйте пассивный навык barrage_nova. Это переменная boolean, которая при значении делает так, что снаряды очереди выпускаются по кругу, а не в направлении движения игрока. Вот, как это выглядит:

GIF
0a40e22ae46d4253d71e12f13297c2fe.gif


Снаряд-мина


Снаряд-мина — это снаряд, остающийся на месте своего создания и со временем взрывающийся. Вот, как это выглядит:

GIF
fe42209eb626930743af8bdc7ab5070a.gif


Как вы видите, он просто вращается как снаряд атаки Spin, но гораздо быстрее. Для реализации этого мы скажем, что если атрибут mine имеет для снаряда значение true, то он будет вести себя как снаряды Spin, но с увеличенной скоростью вращения.

function Projectile:new(...)
    ...
    if self.mine then
        self.rv = table.random({random(-12*math.pi, -10*math.pi), 
        random(10*math.pi, 12*math.pi)})
        self.timer:after(random(8, 12), function()
            -- Explosion
        end)
    end
    ...
end

function Projectile:update(dt)
    ... 	
    -- Spin or Mine
    if self.attack == 'Spin' or self.mine then self.r = self.r + self.rv*dt end
    ...
end


Здесь вместо ограничения скоростей вращения в интервале абсолютных значений от math.pi до 2*math.pi, мы берём абсолютные значения от 10*math.pi до 12*math.pi. В результате снаряд вращается гораздо быстрее и покрывает меньшую область, что идеально подходит к такому типу поведения.

Кроме того, после случайного промежутка времени от 8 до 12 секунд снаряд взрывается. Этот взрыв не нужно обрабатывать так же, как взрывы, обрабатываемые для снаряда Explode. В моём случае я создал объект Explosion, но существует множество способов реализации этого действия. Я оставлю её в качестве упражнения, потому что атака Explode тоже была упражнением.

189. (КОНТЕНТ) Реализуйте пассивный навык drop_mines_chance, который добавляет игроку вероятность каждые 0,5 секунды оставить за собой снаряд-мину. С точки зрения программирования это реализуется через таймер, запускаемый через каждые 0,5 секунды. В каждый из этих запусков мы бросаем кубики функции drop_mines_chance:next().

190. (КОНТЕНТ) Реализуйте пассивный навык projectiles_explode_on_expiration, который делает так, что при уничтожении снарядов из-за завершения срока их жизни они также взрываются. Это должно относиться только к времени завершения их жизни. Если снаряд сталкивается с врагом или стеной, то он не должен взрываться, когда этот навык имеет значение true.

191. (КОНТЕНТ) Реализуйте пассивный навык self_explode_on_cycle_chance. Этот навык добавляет игроку вероятность создавать вокруг себя в каждом цикле взрывы. Это будет выглядеть так:

GIF
c3ed091462cc7238766ae22e5f0062c6.gif


Здесь используются те же самые взрывы, что и в атаке Explode. Количество, расположение и размер создаваемых взрывов можете выбрать самостоятельно.

192. (КОНТЕНТ) Реализуйте пассивный навык projectiles_explosions. Если он имеет значение true, то все взрывы, возникающие из-за снаряда, созданного игроком, будут создавать множественные снаряды, походящие на действие пассивного навыка barrage_nova. Количество создаваемых снарядов изначально равно 5 и на это количество влияет пассивный навык additional_barrage_projectiles.

Энергетический щит


Когда пассивный навык energy_shield имеет значение true, то HP игрока превращается в энергетический щит (с этого момента называемый ES). Работа ES отличается от работы HP следующим образом:

  • Игрок получает удвоенный урон
  • ES игрока перезаряжается через определённый промежуток времени, если он не получает урона
  • Время неуязвимости игрока становится в два раза меньше


Мы можем реализовать всё это в основном в функции hit:

function Player:new(...)
    ...
    -- ES
    self.energy_shield_recharge_cooldown = 2
    self.energy_shield_recharge_amount = 1

    -- Booleans
    self.energy_shield = true
    ...
end

function Player:hit(damage)
    ...
  	
    if self.energy_shield then
        damage = damage*2
        self.timer:after('es_cooldown', self.energy_shield_recharge_cooldown, function()
            self.timer:every('es_amount', 0.25, function()
                self:addHP(self.energy_shield_recharge_amount)
            end)
        end)
    end
  
    ...
end


Мы объявляем, что пауза перед началом перезарядки ES после попадания равна 2 секундам, и что скорость перезарядки составляет 4 ES в секунду (1 за 0.25 секунды в вызове every). Также мы располагаем условную конструкцию в верхней части функции hit и удваиваем переменную урона, которая будет использоваться ниже для нанесения урона игроку.

Единственное, что нам осталось здесь — уменьшение вдвое времени неуязвимости. Мы можем сделать это или в функции hit, или в функции setStats. Мы выберем второй вариант, потому что давно не занимались этой функцией.

function Player:setStats()
    ...
  
    if self.energy_shield then
        self.invulnerability_time_multiplier = self.invulnerability_time_multiplier/2
    end
end


Поскольку setStats вызывается в конце конструктора и после вызова функции treeToPlayer (то есть она вызывается после загрузки всех пассивных навыков из дерева), мы можем быть уверены, что значение energy_shield отражает все навыки, выбранные игроком в дереве. Кроме того, мы можем быть уверены, что мы уменьшаем таймер неуязвимости после применения всех увеличений/уменьшений этого множителя из дерева. Это на самом деле необязательно для этого пассивного навыка, поскольку порядок здесь не важен, но для других навыков он может быть важен и в таком случае применение изменений в setStats имеет смысл. Обычно, если вероятность параметра получается из переменной boolean и это изменение постоянно в игре, то логичнее помещать его в setStats.

193. (КОНТЕНТ) Измените UI параметра HP так, чтобы когда energy_shield имеет значение true, он выглядел следующим образом:

GIF
fae97e0f9a093e79cbe2738874d18bdd.gif


194. (КОНТЕНТ) Реализуйте пассивный навык energy_shield_recharge_amount_multiplier, увеличивающий или уменьшающий количество восстанавливаемых за секунду ES.

195. (КОНТЕНТ) Реализуйте пассивный навык energy_shield_recharge_cooldown_multiplier, который увеличивает или уменьшает время паузы после нанесения игроку урона, после которой ES начинает перезаряжаться.

Добавление вероятности всем событиям типа «при убийстве»


Например, если added_chance_to_all_on_kill_events равно 5, то все вероятность всех пассивных навыков типа «при убийстве» увеличивается на 5%.Это значит, что если изначально игрок получил навыки, в сумме увеличившие его launch_homing_projectile_on_kill_chance до 8, то конечная вероятность вместо 8% будет равна 13%. Это слишком мощный пассивный навык, но его интересно рассмотреть с точки зрения реализации.

Мы можем реализовать его, изменив способ генерирования функцией generateChances списков chanceList. Поскольку эта функция обходит все пассивные навыки, название которых заканчивается на _chance, очевидно, что мы можем также парсить все пассивные навыки, содержащие в названии _on_kill. То есть после того, как мы это сделаем, нам достаточно будет добавить added_chance_to_all_on_kill_events в соответствующее место в процессе генерации chanceList.

Для начала мы отделим обычные пассивные навыки от тех, которые имеют в названии on_kill:

function Player:generateChances()
    self.chances = {}
    for k, v in pairs(self) do
        if k:find('_chance') and type(v) == 'number' then
            if k:find('_on_kill') and v > 0 then

            else
                self.chances[k] = chanceList({true, math.ceil(v)}, {false, 100-math.ceil(v)})
            end
      	end
    end
end


Мы используем тот же способ, который применяли для поиска пассивных навыков с _chance, только заменим эту строку на _on_kill. Кроме того, нам нужно также проверять, что этот пассивный навык имеет вероятность генерирования события больше 0%. Мы не хотим, чтобы новый пассивный навык добавлял свою вероятность ко всем событиям типа «при убийстве», когда игрок не потратил на это событие очков, поэтому мы делаем это только для событий, в которые игрок уже вложил какую-то вероятность.

Теперь мы можем просто создать chanceList, но вместо использования самого v мы будем использовать v+added_chance_to_all_on_kill_events:

function Player:generateChances()
    self.chances = {}
    for k, v in pairs(self) do
        if k:find('_chance') and type(v) == 'number' then
            if k:find('_on_kill') and v > 0 then
                self.chances[k] = chanceList(
                {true, math.ceil(v+self.added_chance_to_all_on_kill_events)}, 
                {false, 100-math.ceil(v+self.added_chance_to_all_on_kill_events)})
            else
                self.chances[k] = chanceList({true, math.ceil(v)}, {false, 100-math.ceil(v)})
            end
      	end
    end
end


Увеличение ASPD за счёт добавленных боеприпасов


Этот навык является преобразованием части одного параметра в другой. В этом случае мы берём все увеличения ресурса Ammo и прибавляем их как дополнительную скорость атаки. Мы можем реализовать это с помощью такой формулы:

local ammo_increases = self.max_ammo - 100
local ammo_to_aspd = 30
aspd_multiplier:increase((ammo_to_aspd/100)*ammo_increases)


Она означает, что если, допустим, максимальный запас составляет 130 боеприпасов, а ammo_to_aspd имеет коэффициент преобразования 30%, то в результате мы увеличим скорость атак на 0.3×30 = 9%. Если максимум составляет 250 боеприпасов, то при том же проценте преобразования мы получим 1.5×30 = 45%.

Чтобы реализовать это, мы сначала определим атрибут:

function Player:new(...)
    ...
  
    -- Conversions
    self.ammo_to_aspd = 0
end


И затем мы можем применить преобразование к переменной aspd_multiplier. Поскольку эта переменная является Stat, нам нужно сделать это в функции update. Если бы эта переменная была обычной, то мы сделали бы это в функции setStats.

function Player:update(dt)
    ...
    -- Conversions
    if self.ammo_to_aspd > 0 then 
        self.aspd_multiplier:increase((self.ammo_to_aspd/100)*(self.max_ammo - 100)) 
    end

    self.aspd_multiplier:update(dt)
    ...
end


И это должно работать именно так, как мы задумали.

Последн

© Habrahabr.ru