Pull to refresh

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

Reading time 26 min
Views 5.2K
Original author: SSYGEN
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

Выстреливается несколько снарядов с разной скоростью, как из дробовика, которые потом быстро исчезают. Все цвета берутся из таблицы 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

У этих снарядов постоянно изменяется угол на постоянную величину. Мы можем реализовать это, добавив переменную 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

Снаряды должны оставаться живыми в течение случайного интервала времени от 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

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

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

Она ведёт себя точно так же, как атака 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

Когда игрок достигает определённого расстояния до врага, создаётся заряд молнии, наносящий урон врагу. БОльшая часть работы здесь заключается в создании заряда молнии, поэтому мы рассмотрим в первую очередь его. Мы реализуем его, создав объект 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

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

GIF

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

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

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

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

Снаряд-мина


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

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

Здесь используются те же самые взрывы, что и в атаке 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

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

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

Последние пассивные навыки


Нам осталось реализовать всего около 20 пассивных навыков. Большинство из них довольно тривиально, поэтому я оставлю их на упражнения. На самом деле они почти никак не связаны с большинством навыков, которые мы реализовали ранее, поэтому хотя они и могут быть тривиальными, можете рассматривать их как задачи, с помощью которых можно проверить, на самом ли деле вы разбираетесь в том, что и как происходит в кодовой базе.

196. (КОНТЕНТ) Реализуйте пассивный навык change_attack_periodically, меняющий атаку игрока каждые 10 секунд. Новая атака выбирается случайным образом.

197. (КОНТЕНТ) Реализуйте пассивный навык gain_sp_on_death, который даёт игроку после смерти 20 SP.

198. (КОНТЕНТ) Реализуйте пассивный навык convert_hp_to_sp_if_hp_full, который даёт игроку 3 SP каждый раз, когда он подбирает ресурс HP, а его HP уже максимально.

199. (КОНТЕНТ) Реализуйте пассивный навык mvspd_to_aspd, прибавляющий увеличение скорости движения к скорости атак. Это увеличение должно прибавляться с помощью той же формулы, которая используется для ammo_to_aspd. То есть если у игрока есть увеличение MVSPD на 30%, а mvspd_to_aspd равно 30 (то есть коэффициент преобразования составляет 30%), то его ASPD должен быть увеличен на 9%.

200. (КОНТЕНТ) Реализуйте пассивный навык mvspd_to_hp, прибавляющий уменьшение скорости движения к HP игрока. Например, если MVSPD уменьшена на 30%, а mvspd_to_hp равно 30 (то есть коэффициент преобразования равен 30%), то ему должно прибавляться 21 HP.

201. (КОНТЕНТ) Реализуйте пассивный навык mvspd_to_pspd, прибавляющий увеличение скорости движения к скорости снарядов. Он работает точно так же, как mvspd_to_aspd.

202. (КОНТЕНТ) Реализуйте пассивный навык no_boost, который отключает у игрока ускорение Boost (max_boost = 0).

203. (КОНТЕНТ) Реализуйте пассивный навык half_ammo, который вдвое уменьшает у игрока боеприпасы.

204. (КОНТЕНТ) Реализуйте пассивный навык half_hp, который вдвое уменьшает HP игрока.

205. (КОНТЕНТ) Реализуйте пассивный навык deals_damage_while_invulnerable, который позволяет игроку наносить урон врагам при контакте, когда он неуязвим (например, когда после попадания атрибут invincible принимает значение true).

206. (КОНТЕНТ) Реализуйте пассивный навык refill_ammo_if_hp_full, полностью восстанавливающий боеприпасы игрока, если он подбирает ресурс HP при полной шкале HP.

207. (КОНТЕНТ) Реализуйте пассивный навык refill_boost_if_hp_full, полностью восстанавливающий Boost игрока, когда он подбирает ресурс HP при полной шкале HP.

208. (КОНТЕНТ) Реализуйте пассивный навык only_spawn_boost, который делает так, что единственными создаваемыми ресурсами становятся Boost.

209. (КОНТЕНТ) Реализуйте пассивный навык only_spawn_attack, который делает так, что в течение заданного промежутка времени не создаётся никаких ресурсов, кроме атак. Это значит, что все атаки создаются после паузы ресурсов, а также собственной паузы атаки (то есть каждые 16 секунд, а также каждые 30 секунд).

210. (КОНТЕНТ) Реализуйте пассивный навык no_ammo_drop, при котором из врагов никогда не выпадают боеприпасы.

211. (КОНТЕНТ) Реализуйте пассивный навык infinite_ammo, при котором никакая из атак игрока не потребляет боеприпасов.

И на этом мы закончим с пассивными навыками. Всего мы рассмотрели примерно 150 разных пассивных навыков, которые в дереве навыков растянутся примерно на 900 узлов, поскольку многие их этих навыков являются просто изменениями параметров, и могут быть разбросанными по дереву, а не концентрироваться в одном месте.

Но прежде чем мы приступим к дереву (которое мы рассмотрим в следующей части статьи), мы можем немного расширить контент, и реализовать также все корабли игрока и всех врагов. Вы можете не полностью следовать моим примерам, которые я дам в упражнениях, и создавать собственные корабли/врагов.

Враги


212. (КОНТЕНТ) Реализуйте врага BigRock. Этот враг ведёт себя точно так же, как Rock, только он больше и после смерти разделяется на 4 объекта Rock. По умолчанию он имеет 300 HP.

GIF

213. (КОНТЕНТ) Реализуйте врага Waver. Этот враг ведёт себя как волновой снаряд и время от времени выстреливает снаряды спереди и сзади (как атака Back). По умолчанию у него 70 HP.

GIF

214. (КОНТЕНТ) Реализуйте врага Seeker. Этот враг ведёт себя как объект Ammo и медленно движется в направлении игрока. С постоянными интервалами времени этот враг также закладывает мины точно так же, как снаряды-мины. По умолчанию у него 200 HP.

GIF

215. (КОНТЕНТ) Реализуйте врага Orbitter. Этот враг ведёт себя как Rock или BigRock, но вокруг него есть щит из снарядов. Эти снаряды ведут себя как снаряды-щиты, которые мы реализовали в предыдущей части статьи. Если Orbitter умирает до смерти своих снарядов, то оставшиеся снаряды на короткий промежуток времени самонаводятся на игрока. По умолчанию у него 450 HP.

GIF

Корабли


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

216. (КОНТЕНТ) Реализуйте корабль Crusader:

GIF

Он имеет следующие параметры:

  • Boost = 80
  • Множитель эффективности Boost = 2
  • Множитель скорости движения = 0.6
  • Множитель скорости поворота = 0.5
  • Множитель скорости атаки = 0.66
  • Множитель скорости снаряда = 1.5
  • HP = 150
  • Множитель размера = 1.5

217. (КОНТЕНТ) Реализуйте корабль Rogue:

GIF

Он имеет следующие параметры:

  • Boost = 120
  • Множитель перезарядки Boost = 1.5
  • Множитель скорости движения = 1.3
  • Ammo = 120
  • Множитель скорости атак = 1.25
  • HP = 80
  • Множитель неуязвимости = 0.5
  • Множитель размера = 0.9

218. (КОНТЕНТ) Реализуйте корабль Bit Hunter:

GIF

Он имеет следующие параметры:

  • Множитель скорости движения = 0.9
  • Множитель скорости поворота = 0.9
  • Ammo = 80
  • Множитель скорости атак = 0.8
  • Множитель скорости снарядов = 0.9
  • Множитель неуязвимости = 1.5
  • Множитель размера = 1.1
  • Множитель удачи = 1.5
  • Множитель частоты создания ресурсов = 1.5
  • Множитель частоты создания врагов = 1.5
  • Множитель скорости цикла = 1.25

219. (КОНТЕНТ) Реализуйте корабль Sentinel:

GIF

Он имеет следующие параметры:

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

220. (КОНТЕНТ) Реализуйте корабль Striker:

GIF

Он имеет следующие параметры:

  • Ammo = 120
  • Множитель скорости атак = 2
  • Множитель скорости снарядов = 1.25
  • HP = 50
  • Дополнительные снаряды очереди = 8
  • Вероятность выстрела очередью при убийстве = 10%
  • Вероятность выстрела очередью при цикле = 10%
  • Barrage Nova = true

221. (КОНТЕНТ) Реализуйте корабль Nuclear:

GIF

Он имеет следующие параметры:

  • Boost = 80
  • Множитель скорости поворота = 0.8
  • Ammo = 80
  • Множитель скорости атак = 0.85
  • HP = 80
  • Множитель неуязвимости = 2
  • Множитель удачи = 1.5
  • Множитель частоты создания ресурсов = 1.5
  • Множитель частоты создания врагов = 1.5
  • Множитель скорости цикла = 1.5
  • Вероятность взрыва игрока при цикле = 10%

222. (КОНТЕНТ) Реализуйте корабль Cycler:

GIF

Он имеет следующие параметры:

  • Множитель скорости цикла = 2

223. (КОНТЕНТ) Реализуйте корабль Wisp:

GIF

Он имеет следующие параметры:

  • Boost = 50
  • Множитель скорости движения = 0.5
  • Множитель скорости поворота = 0.5
  • Множитель скорости атак = 0.66
  • Множитель скорости снарядов = 0.5
  • HP = 50
  • Множитель размера = 0.75
  • Множитель частоты создания ресурсов = 1.5
  • Множитель частоты создания врагов = 1.5
  • Вероятность снаряда-щита = 100%
  • Множитель длительности жизни снарядов = 1.5

КОНЕЦ


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

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



Если вам понравится эта серия туториалов, то вы можете простимулировать меня к написанию чего-то подобного в будущем:


Купив туториал на itch.io, вы получите доступ к полному исходному коду игры, к ответам на упражения из частей 1-9, к коду, разбитому по частям туториала (код будет выглядеть так, как должен выглядеть в конце каждой части) и к ключу игры в Steam.
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+19
Comments 3
Comments Comments 3

Articles