[Перевод] Создание игры на Lua и LÖVE — 5
Оглавление
- Статья 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, которая выглядит так:
Выстреливается несколько снарядов с разной скоростью, как из дробовика, которые потом быстро исчезают. Все цвета берутся из таблицы 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. Она выглядит следующим образом:
У этих снарядов постоянно изменяется угол на постоянную величину. Мы можем реализовать это, добавив переменную 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}
А вот, как выглядит сама атака:
Снаряды должны оставаться живыми в течение случайного интервала времени от 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}
И всё это должно выглядеть так:
174. (КОНТЕНТ) Реализуйте атаку 2Split
. Вот, как она выглядит:
Она в точности похожа на снаряд 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
. Вот, как она выглядит:
Она ведёт себя точно так же, как атака 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:
Когда игрок достигает определённого расстояния до врага, создаётся заряд молнии, наносящий урон врагу. БОльшая часть работы здесь заключается в создании заряда молнии, поэтому мы рассмотрим в первую очередь его. Мы реализуем его, создав объект 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}
И всё это должно выглядеть так:
176. (КОНТЕНТ) Реализуйте атаку Explode
. Вот, как она выглядит:
Создаётся взрыв, уничтожающий всех врагов в определённом радиусе. Сам снаряд выглядит как самонаводящийся, за исключением того, что hp_color
немного больше. Таблица атаки выглядит так:
attacks['Explode'] = {cooldown = 0.6, ammo = 4, abbreviation = 'E', color = hp_color}
177. (КОНТЕНТ) Реализуйте атаку Laser
. Вот, как она выглядит:
Создаётся огромная линия, уничтожающая всех врагов, которые её пересекают. Её можно запрограммировать или как линию, или как повёрнутый прямоугольник для распознавания коллизий. Если вы решите использовать линию, то лучше воспользоваться 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, которая при значении делает так, что снаряды очереди выпускаются по кругу, а не в направлении движения игрока. Вот, как это выглядит:
Снаряд-мина
Снаряд-мина — это снаряд, остающийся на месте своего создания и со временем взрывающийся. Вот, как это выглядит:
Как вы видите, он просто вращается как снаряд атаки 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
. Этот навык добавляет игроку вероятность создавать вокруг себя в каждом цикле взрывы. Это будет выглядеть так:
Здесь используются те же самые взрывы, что и в атаке 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, он выглядел следующим образом:
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
И это должно работать именно так, как мы задумали.