Оглавление
- Статья 1
- Часть 1. Игровой цикл
- Часть 2. Библиотеки
- Часть 3. Комнаты и области
- Часть 4. Упражнения
- Статья 2
- Часть 5. Основы игры
- Часть 6. Основы класса Player
- Статья 3
- Часть 7. Параметры и атаки игрока
- Часть 8. Враги
9. Director and Gameplay Loop
10. Coding Practices
11. Passives
12. More Passives
13. Skill Tree
14. Console
15. Final
Часть 7: Параметры и атаки игрока
Введение
В этой части мы больше сосредоточимся на части геймплея, относящейся к игроку. Сначала мы добавим самые фундаментальные параметры: боеприпасы, ускорение, здоровье (HP) и очки навыков. Эти параметры будут использоваться на протяжении всей игры и они являются основными параметрами, которые будет использовать игрок для выполнения всех доступных ему действий. После этого мы перейдём к созданию объектов Resource, то есть объектов, которые может собирать игрок. В них содержатся вышеупомянутые параметры. И наконец после этого мы добавим систему атак, а также несколько разных атак игрока.
Порядок отрисовки
Прежде чем переходить к основным частям игры, нужно рассмотреть ещё один важный аспект, который я упустил: порядок отрисовки.
Порядок отрисовки определяет, какие объекты будут отрисовываться сверху, а какие снизу. Например, сейчас у нас есть несколько эффектов, отрисовываемых при выполнении определённых событий. Если эффекты отрисовываются под другими объектами, например, под Player, то их или не будет видно, или они будут выглядеть неправильно. Поэтому нам нужно сделать так, чтобы они всегда отрисовывались поверх всего остального. Для этого нам нужно задать некий порядок отрисовки объектов.
Способ решения этой задачи будет достаточно прямолинейным. В классе
GameObject
мы определим атрибут depth
, который для всех сущностей изначально равен 50. Затем в определении конструктора каждого класса мы при желании сможем задавать атрибут depth
для каждого класса объектов самостоятельно. Идея заключается в том, что объекты с большей глубиной должны отрисовываться сверху, а меньшей глубиной — внизу. То есть, например, если мы хотим, чтобы все эффекты отрисовывались поверх всего остального, то мы можем просто присвоить их атрибуту depth
, например, значение 75.function TickEffect:new(area, x, y, opts)
TickEffect.super.new(self, area, x, y, opts)
self.depth = 75
...
end
Внутри это будет работать так: в каждом кадре мы будем сортировать список
game_objects
по атрибуту depth
каждого объекта:function Area:draw()
table.sort(self.game_objects, function(a, b)
return a.depth < b.depth
end)
for _, game_object in ipairs(self.game_objects) do game_object:draw() end
end
Здесь перед отрисовкой мы просто применяем
table.sort
для сортировки сущностей по их атрибуту depth
. Сущности с меньшей глубиной переместятся в переднюю часть таблицы, то есть будут отрисовываться первыми (под всем остальным), а сущности с большей глубиной переместятся в конец таблицы и будут отрисовываться последними (поверх всего). Если вы попробуете задавать различные значения глубины для разных типов объектов, то увидите, что это работает.При таком подходе возникает одна небольшая проблема — некоторые объекты будут иметь одинаковую глубину, и когда такое происходит, то при постоянной сортировке таблицы
game_objects
может возникнуть мерцание. Мерцание возникает потому, что если объекты имеют одинаковую глубину, то в одном кадре один объект может оказаться поверх другого, но в следующем кадре спуститься под него. Вероятность этого мала, но такое может случиться и нам следует предотвратить это.Один из способов решения — определить ещё один параметр сортировки на случай, когда объекты имеют одинаковую глубину. В нашем случае я выбрал в качестве другого параметра время создания объекта:
function Area:draw()
table.sort(self.game_objects, function(a, b)
if a.depth == b.depth then return a.creation_time < b.creation_time
else return a.depth < b.depth end
end)
for _, game_object in ipairs(self.game_objects) do game_object:draw() end
end
То есть если глубины одинаков, то ранее созданный объект будет отрисовываться раньше, а позже созданный — позже. Это логичное решение, и если вы протестируете его, то увидите, что оно работает!
Упражнения с порядком отрисовки
93. Измените порядок объектов так, чтобы объекты с большей глубиной отрисовывались сзади, а с меньшей — спереди. В случае, если объекты имеют одинаковую глубину, то они должны сортироваться по времени создания. Объекты, созданные раньше, должны отрисовываться последними, а объекты, созданные позже — первыми.
94. В 2,5D-игре с видом сверху, наподобие показанной ниже, нужно сделать так, чтобы сущности отрисовывались в соответствующем порядке, то есть отсортированными по позиции
y
. Сущности с бОльшим значением y (то есть ближе к нижней части экрана) должны отрисовываться последними, а сущности с меньшим значением y — первыми. Как будет выглядеть функция сортировки в этом случае?GIF
Основные параметры
Теперь мы приступим к построению параметров. Первый параметр, который мы рассмотрим — это ускорение (boost). Он работает следующим образом — когда игрок нажимает «вверх» или «вниз», корабль изменяет свою скорость в зависимости от нажатой клавиши. Поверх этого базового функционала также должен быть ресурс, который исчерпывается при использовании ускорения и постепенно восстанавливается, когда ускорение не используется. Я буду применять такие значения и правила:
- Изначально игрок будет иметь 100 единиц ускорения
- При использовании ускорения будет убывать по 50 единиц ускорения
- В секунду всегда генерируется 10 единиц ускорения
- Когда количество единиц ускорения достигает 0, этому свойству требуется 2 секунды «остывания», прежде чем его можно будет использовать снова
- Ускорение можно выполнять, только когда «остывание» отключено и ресурс единиц ускорения больше 0
Правила кажутся немного сложными, но на самом деле всё просто. Первые три — это просто задание числовых значений, последние два нужны, чтобы предотвратить бесконечное ускорение. Когда ресурс достигает 0, он будет постоянно восстанавливаться до 1, и это может привести к такой ситуации, когда игрок будет использовать ускорение постоянно. «Остывание» нужно, чтобы предотвратить такую ситуацию.
Теперь добавим это в код:
function Player:new(...)
...
self.max_boost = 100
self.boost = self.max_boost
end
function Player:update(dt)
...
self.boost = math.min(self.boost + 10*dt, self.max_boost)
...
end
Этим мы реализуем правила 1 и 3. Изначально
boost
имеет значение max_boost
, то есть 100, а затем мы прибавляем к boost
по 10 в секунду, пока значение не превзойдёт max_boost
. Мы можем также реализовать правило 2, просто вычитая по 50 единиц в секунду, когда игрок выполняет ускорение:function Player:update(dt)
...
if input:down('up') then
self.boosting = true
self.max_v = 1.5*self.base_max_v
self.boost = self.boost - 50*dt
end
if input:down('down') then
self.boosting = true
self.max_v = 0.5*self.base_max_v
self.boost = self.boost - 50*dt
end
...
end
Часть этого кода уже здесь была, то есть единственными добавленными строками являются
self.boost -= 50*dt
. Теперь для проверки правила 4 нам нужно сделать так, чтобы когда boost
достигает 0, запускалось «остывание» на 2 секунды. Это немного сложнее, потому что здесь используется больше подвижных частей. Код выглядит так:function Player:new(...)
...
self.can_boost = true
self.boost_timer = 0
self.boost_cooldown = 2
end
Сначала мы вводим три переменные.
can_boost
будет использоваться для того, чтобы сообщать, когда можно выполнять ускорение. По умолчанию она имеет значение true, потому что игрок должен при запуске игры иметь возможность ускорения. Ей присваивается значение false, когда boost
достигает 0, а затем значение true через boost_cooldown
секунд. Переменная boost_timer
будет отслеживать, сколько прошло времени после того, как boost
достигла 0, и когда эта переменная превысит boost_cooldown
, то can_boost
будет присвоено значение true.function Player:update(dt)
...
self.boost = math.min(self.boost + 10*dt, self.max_boost)
self.boost_timer = self.boost_timer + dt
if self.boost_timer > self.boost_cooldown then self.can_boost = true end
self.max_v = self.base_max_v
self.boosting = false
if input:down('up') and self.boost > 1 and self.can_boost then
self.boosting = true
self.max_v = 1.5*self.base_max_v
self.boost = self.boost - 50*dt
if self.boost <= 1 then
self.boosting = false
self.can_boost = false
self.boost_timer = 0
end
end
if input:down('down') and self.boost > 1 and self.can_boost then
self.boosting = true
self.max_v = 0.5*self.base_max_v
self.boost = self.boost - 50*dt
if self.boost <= 1 then
self.boosting = false
self.can_boost = false
self.boost_timer = 0
end
end
self.trail_color = skill_point_color
if self.boosting then self.trail_color = boost_color end
end
Это кажется сложным, но код просто реализует то, чего мы хотели достичь. Вместо того, чтобы просто проверять, нажата ли клавиша с помощью
input:down
, мы ещё и проверяем, что boost
выше 1 (правило 5) и что can_boost
равно true (правило 5). Когда boost
достигает 0, мы присваиваем переменным boosting
и can_boost
значения false, а затем сбрасываем boost_timer
до 0. Поскольку к boost_timer
прибавляется в каждом кадре dt
, то через две секунды она присвоит can_boost
значение true и мы снова сможем ускоряться (правило 4).Представленный выше код является механизмом ускорения в его завершённом состоянии. Здесь стоит заметить, что можно назвать этот код некрасивым, неупорядоченным и сочетающим плохие решения. Но именно так выглядит большая часть кода, обрабатывающая определённые аспекты геймплея. Здесь нужно следовать нескольким правилам и следовать им одновременно. Мне кажется, что вам стоит начать привыкать к подобному коду.
Как бы то ни было, из всех основных параметров ускорение единственное имеет такую сложную логику. Существует ещё два важных параметра: боеприпасы и HP, но оба они намного проще. Боеприпасы просто расходуются при атаках игрока и восстанавливаются при собирании ресурсов в процессе игры, а HP снижается, когда игрок получает урон, и восстанавливается тоже при собирании ресурсов. Сейчас мы можем просто добавить их как основные параметры, как мы сделали с ускорением:
function Player:new(...)
...
self.max_hp = 100
self.hp = self.max_hp
self.max_ammo = 100
self.ammo = self.max_ammo
end
Ресурсы
Ресурсами я называю небольшие объекты, влияющие на один из основных параметров. В игре будет пять видов таких объектов, и они будут работать следующим образом:
- Ресурс боеприпасов восстанавливает у игрока 5 единиц боеприпасов и создаётся при смерти врага
- Ресурс ускорения восстанавливает у игрока 25 единиц ускорения и создаётся случайным образом Режиссёром
- Ресурс HP восстанавливает у игрока 25 HP и создаётся случайным образом Режиссёром
- Ресурс SkillPoint добавляет игроку 1 очко навыка и создаётся случайным образом Режиссёром
- Ресурс атаки изменяет текущую атаку игрока и создаётся случайным образом Режиссёром
Режиссёр (Director) — это участок кода, управляющий созданием врагов и ресурсов. Я назвал его так, потому что он имеет такое название в других играх (например, в L4D) и оно показалось мне подходящим. Мы пока не будем работать над этой частью кода, поэтому привяжем создание каждого ресурса к клавише, чтобы просто протестировать их работу.
Ресурс боеприпасов (Ammo Resource)
Давайте начнём с боеприпасов. Конечный результат должен стать таким:
GIF
Маленькие зелёные прямоугольники — это ресурс боеприпасов. Когда игрок касается его, ресурс уничтожается, а игрок получает 5 единиц боеприпасов. Мы можем создать новый класс
Ammo
и начать с определений:function Ammo:new(...)
...
self.w, self.h = 8, 8
self.collider = self.area.world:newRectangleCollider(self.x, self.y, self.w, self.h)
self.collider:setObject(self)
self.collider:setFixedRotation(false)
self.r = random(0, 2*math.pi)
self.v = random(10, 20)
self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r))
self.collider:applyAngularImpulse(random(-24, 24))
end
function Ammo:draw()
love.graphics.setColor(ammo_color)
pushRotate(self.x, self.y, self.collider:getAngle())
draft:rhombus(self.x, self.y, self.w, self.h, 'line')
love.graphics.pop()
love.graphics.setColor(default_color)
end
Ресурсы боеприпасов будут физическими прямоугольниками, создаваемыми со случайной небольшой скоростью и поворотом, изначально задаваемыми
setLinearVelocity
и applyAngularImpulse
. Кроме того, этот объект отрисовывается с помощью библиотеки draft
. Это небольшая библиотека, позволяющая отрисовывать всевозможные фигуры более удобно, чем вы сделали бы это самостоятельно. В нашем случае мы можем просто отрисовать ресурс как любой прямоугольник, но я решил сделать это таким образом. Я буду предполагать, что вы уже установили библиотеку самостоятельно и прочитали документацию, узнав о её возможностях. Кроме того, мы будем учитывать поворот физического объекта, используя результат getAngle
в pushRotate
.Чтобы протестировать всё это, мы можем привязать создание одного из таких объектов к клавише:
function Stage:new()
...
input:bind('p', function()
self.area:addGameObject('Ammo', random(0, gw), random(0, gh))
end)
end
Если запустить теперь игру и несколько раз нажать на P, то вы увидите, как объекты создаются и движутся/вращаются.
Следующее, что нам нужно создать — это взаимодействие коллизий между игроком и ресурсом. Это взаимодействие будет использоваться для всех ресурсов и почти всегда будет одинаковым. Первое, что мы хотим сделать — перехватывать событие столкновения физического объекта игрока с физическим объектом боеприпасов. Простейший способ реализации этого заключается в использовании
классов коллизий (collision classes)
. Для начала мы можем определить три класса коллизий для уже существующих объектов: игрока, снарядов и ресурсов.function Stage:new()
...
self.area = Area(self)
self.area:addPhysicsWorld()
self.area.world:addCollisionClass('Player')
self.area.world:addCollisionClass('Projectile')
self.area.world:addCollisionClass('Collectable')
...
end
И в каждом из этих файлов (Player, Projectile и Ammo) мы можем задать класс коллизий коллайдера с помощью
setCollisionClass
(повторите этот код в других файлах):function Player:new(...)
...
self.collider:setCollisionClass('Player')
...
end
Сам по себе он ничего не меняет, то создаёт основу, с помощью которой можно перехватывать события коллизий между физическими объектами. Например, если мы изменим класс коллизий
Collectable
так, чтобы он игнорировал Player
:self.area.world:addCollisionClass('Collectable', {ignores = {'Player'}})
то при запуске игры вы заметите, что игрок физически игнорирует объекты ресурсов боеприпасов. Мы стремимся не к этому, но это служит хорошим примером того, что можно делать с классами коллизий. Мы хотим, чтобы эти три класса коллизий следовали следующим правилам:
- Projectile игнорирует Projectile
- Collectable игнорирует Collectable
- Collectable игнорирует Projectile
- Player генерирует события коллизий с Collectable
Правила 1, 2 и 3 можно реализовать, внеся небольшие изменения в вызовы
addCollisionClass
:function Stage:new()
...
self.area.world:addCollisionClass('Player')
self.area.world:addCollisionClass('Projectile', {ignores = {'Projectile'}})
self.area.world:addCollisionClass('Collectable', {ignores = {'Collectable', 'Projectile'}})
...
end
Стоит заметить, что важен порядок объявления классов коллизий. Например, если мы поменяем местами объявления классов Projectile и Collectable, то возникнет баг, потому что класс коллизий Collectable создаёт ссылку на класс коллизий Projectile, то так как класс коллизий Projectile ещё не определён, то возникает ошибка.
Четвёртое правило можно реализовать с помощью вызова
enter
:function Player:update(dt)
...
if self.collider:enter('Collectable') then
print(1)
end
end
Если запустить код, то при каждой коллизии игрока с ресурсом боеприпасов в консоль выводится 1.
Ещё один элемент, который нужно добавить в класс
Ammo
— это медленное движение объекта к игроку. Проще всего это сделать, добавив к нему поведение Seek Behavior. Моя версия поведения поиска (seek behavior) основана на книге Programming Game AI by Example, в которой есть очень хорошая выборка общих поведений управления. Я не буду объяснять поведение подробно, потому что, честно говоря, уже не помню, как оно работает, так что если вам интересно, то разберитесь в нём самостоятельно :Dfunction Ammo:update(dt)
...
local target = current_room.player
if target then
local projectile_heading = Vector(self.collider:getLinearVelocity()):normalized()
local angle = math.atan2(target.y - self.y, target.x - self.x)
local to_target_heading = Vector(math.cos(angle), math.sin(angle)):normalized()
local final_heading = (projectile_heading + 0.1*to_target_heading):normalized()
self.collider:setLinearVelocity(self.v*final_heading.x, self.v*final_heading.y)
else self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r)) end
end
Здесь ресурс боеприпасов направляется в сторону
target
, если он существует, в противном случае ресурс будет двигаться в изначально заданном направлении. target
содержит ссылку на игрока, который задаётся в Stage
следующим образом:function Stage:new()
...
self.player = self.area:addGameObject('Player', gw/2, gh/2)
end
Единственное, что осталось — обработать действия, происходящие при собирании ресурса боеприпасов. В представленной выше gif-анимации видно, что воспроизводится небольшой эффект (похожий на эффект при «смерти» снаряда) с частицами, а затем игрок получает +5 боеприпасов.
Давайте начнём с эффекта. В этом эффекте используется та же логика, что и в объекте
ProjectileDeathEffect
: происходит небольшая белая вспышка, а затем появляется настоящий цвет эффекта. Единственная разница здесь в том, что вместо отрисовки квадрата мы будем рисовать ромб, то есть ту же фигуру, которую мы использовали для отрисовки самого ресурса боеприпасов. Я назову этот новый объект AmmoEffect
. Не будем подробно рассматривать его, потому что он аналогичен ProjectileDeathEffect
. Однако вызываем мы его следующим образом:function Ammo:die()
self.dead = true
self.area:addGameObject('AmmoEffect', self.x, self.y,
{color = ammo_color, w = self.w, h = self.h})
for i = 1, love.math.random(4, 8) do
self.area:addGameObject('ExplodeParticle', self.x, self.y, {s = 3, color = ammo_color})
end
end
Здесь мы создаём один объект
AmmoEffect
а затем от 4 до 8 объектов ExplodeParticle
, которые мы уже использовали в эффекте смерти Player. Функция die
объекта Ammo будет вызываться при его коллизии с Player:function Player:update(dt)
...
if self.collider:enter('Collectable') then
local collision_data = self.collider:getEnterCollisionData('Collectable')
local object = collision_data.collider:getObject()
if object:is(Ammo) then
object:die()
end
end
end
Здесь мы сначала используем
getEnterCollisionData
, чтобы получить данные коллизии, сгенерированные последним событием enter collision для указанной метки. Затем мы используем getObject
для получения доступа к объекту, присоединённому к участвующему в событии коллизии коллайдеру, который может быть любым объектом в классе коллизий Collectable. В нашем случае у нас есть только объект Ammo, но если бы у нас были другие, то именно здесь бы поместили код, различающий их. И именно это мы делаем — чтобы проверить является ли объект, полученный от getObject
, классом Ammo
, мы используем функцию is
из библиотеки classic. Если это на самом деле объект класса Ammo, то мы вызываем его функцию die
. Всё это должно выглядеть вот так:GIF
Последнее, о чём мы забыли — это добавление игроку +5 боеприпасов при сборе ресурса боеприпасов. Для этого мы определим функцию
addAmmo
, которая просто добавляет определённое значение к переменной ammo
и проверяет, чтобы оно не превышало max_ammo
:function Player:addAmmo(amount)
self.ammo = math.min(self.ammo + amount, self.max_ammo)
end
А затем мы просто вызываем эту функцию после
object:die()
в только что добавленном коде.Ресурс ускорения (Boost)
Теперь займёмся ускорением. Конечный результат должен выглядеть так:
GIF
Как вы видите, идея примерно такая же, как и с ресурсом боеприпасов, немного отличается только движение ресурса boost, оно выглядит немного иначе и отличается визуальный эффект, воспроизводимый при собирании ресурса.
Давайте начнём с основного. Все ресурсы, кроме боеприпасов, будут создаваться в левой или правой части экрана, а затем медленно перемещаться по прямой линии в противоположную сторону. То же самое относится и к врагам. Это даёт игроку достаточно времени для перемещения к ресурсу и его сбора, если он того захочет.
Основной начальный код класса
Boost
будет примерно таким же, как у класса Ammo
. Он выглядит вот так:function Boost:new(...)
...
local direction = table.random({-1, 1})
self.x = gw/2 + direction*(gw/2 + 48)
self.y = random(48, gh - 48)
self.w, self.h = 12, 12
self.collider = self.area.world:newRectangleCollider(self.x, self.y, self.w, self.h)
self.collider:setObject(self)
self.collider:setCollisionClass('Collectable')
self.collider:setFixedRotation(false)
self.v = -direction*random(20, 40)
self.collider:setLinearVelocity(self.v, 0)
self.collider:applyAngularImpulse(random(-24, 24))
end
function Boost:update(dt)
...
self.collider:setLinearVelocity(self.v, 0)
end
Однако есть и некоторые различия. Первые три строки в конструкторе получают начальную позицию объекта. Функция
table.random
определена в utils.lua
следующим образом:function table.random(t)
return t[love.math.random(1, #t)]
end
Как вы видите, она просто выбирает случайный элемент из таблицы. В нашем случае мы просто выбираем -1 или 1, обозначающие сторону, с которой должен быть создан объект. Если выбрано значение -1, то объект будет создаваться в левой части экрана, а если 1 — то справа. Конкретные позиции для этой выбранной позиции будут равны
-48
или gw+48
, то есть объект создаётся за пределами экрана, но достаточно близко к его краю.Далее мы определяем объект почти так же, как Ammo, за исключением некоторых отличий в скорости. Если объект был создан справа, то мы хотим, чтобы он двигался влево, а если слева — то чтобы он двигался вправо. Поэтому скорости присваивается случайное значение от 20 до 40, а затем умножается на
-direction
, ведь если объект находится справа, то direction
равно 1; мы хотим двигать его влево, поэтому скорость должна быть отрицательной (и наоборот для противоположной стороны). Компоненту скорости объекта по оси x всегда присваивается значение атрибута v
, а компоненту по y — значение 0. Мы хотим, чтобы объект двигался по горизонтальной прямой, поэтому мы задаём скорость по y равной 0.Последнее основное различие заключается в способе его отрисовки:
function Boost:draw()
love.graphics.setColor(boost_color)
pushRotate(self.x, self.y, self.collider:getAngle())
draft:rhombus(self.x, self.y, 1.5*self.w, 1.5*self.h, 'line')
draft:rhombus(self.x, self.y, 0.5*self.w, 0.5*self.h, 'fill')
love.graphics.pop()
love.graphics.setColor(default_color)
end
Вместо отрисовки одиночного ромба мы рисуем один внутренний и один внешний, который будет своего рода контуром. Разумеется, вы можете рисовать объекты, как вам угодно, но лично я выбрал такой способ.
Теперь перейдём к эффектам. Здесь используется два объекта: один схож с
AmmoEffect
(однако он немного сложнее), а второй используется для текста +BOOST
. Мы начнём с того, который похож на AmmoEffect и назовем его BoostEffect
.Этот эффект состоит из двух частей: центр с белой вспышкой и эффект мерцания после его исчезновения. Центр работает так же, как
AmmoEffect
, единственная разница заключается во времени выполнения каждой фазы: от 0,1 до 0,2 в первой фазе и от 0,15 до 0,35 во второй:function BoostEffect:new(...)
...
self.current_color = default_color
self.timer:after(0.2, function()
self.current_color = self.color
self.timer:after(0.35, function()
self.dead = true
end)
end)
end
Вторая часть эффекта — мерцание перед его смертью. Мерцания можно добиться, создав переменную
visible
, при значении true которой эффект будет отрисовываться, а при false — не будет. Меняя значение этой переменной, мы добьёмся желаемого эффекта:function BoostEffect:new(...)
...
self.visible = true
self.timer:after(0.2, function()
self.timer:every(0.05, function() self.visible = not self.visible end, 6)
self.timer:after(0.35, function() self.visible = true end)
end)
end
Здесь для переключения между видимостью/невидимостью мы шесть раз используем вызов
every
с интервалом в 0,05 секунды, а после завершения мы в конце делаем эффект видимым. Эффект «умирает» через 0,55 секунды (потому что мы присваиваем dead
значение true через 0,55 при задании текущего цвета), поэтому делать его видимым в конце не очень важно. Теперь мы можем отрисовывать его следующим образом:function BoostEffect:draw()
if not self.visible then return end
love.graphics.setColor(self.current_color)
draft:rhombus(self.x, self.y, 1.34*self.w, 1.34*self.h, 'fill')
draft:rhombus(self.x, self.y, 2*self.w, 2*self.h, 'line')
love.graphics.setColor(default_color)
end
Мы просто отрисовываем внутренний и внешний ромбы разного размера. Конкретные значения (1.34, 2) выведены в основном методом проб и ошибок.
Последнее, что нам нужно сделать для этого эффекта — увеличивать внешний контур-ромб в течение жизни объекта. Мы можем сделать это так:
function BoostEffect:new(...)
...
self.sx, self.sy = 1, 1
self.timer:tween(0.35, self, {sx = 2, sy = 2}, 'in-out-cubic')
end
А затем изменить функцию draw следующим образом:
function BoostEffect:draw()
...
draft:rhombus(self.x, self.y, self.sx*2*self.w, self.sy*2*self.h, 'line')
...
end
Благодаря этому переменные
sx
и sy
будут увеличиваться до 2 в течение 0,35 секунды, то есть контур тоже за эти 0,35 секунды увеличиться вдвое. В конце концов результат будет выглядеть так (я предполагаю, что вы уже связали функцию die
этого объекта к событию коллизии с Player, как мы сделали это с ресурсом боеприпасов):GIF
Теперь займёмся другой частью эффекта — безумным текстом. Этот текстовый эффект будет использоваться в игре почти повсюду, поэтому нам нужно реализовать его правильно. Ещё раз покажу, как он выглядит:
GIF
Сначала давайте разобьём эффект на несколько частей. Первое, что нужно заметить — это просто строка, изначально отрисовываемая на экране, но ближе к концу начинающая мерцать, как объект
BoostEffect
. Мерцание использует ту же логику, что и BoostEffect, то есть мы уже её рассмотрели.Также буквы строки начинают случайным образом изменяться на другие буквы, и фон каждого символа тоже случайно меняет цвета. Это говорит нам, что этот эффект обрабатывает отдельно каждый символ, а не оперирует всей строкой, то есть нам нужно будет хранить все символы в таблице
characters
, обрабатывать эту таблицу, а затем отрисовывать каждый символ из таблицы на экране со всеми модификациями и эффектами.Учитывая всё этом, мы можем определить основы класса
InfoText
. Мы будем вызывать его следующим образом:function Boost:die()
...
self.area:addGameObject('InfoText', self.x, self.y, {text = '+BOOST', color = boost_color})
end
То есть наша строка будет храниться в атрибуте
text
. Тогда определение основы класса будет выглядеть так:function InfoText:new(...)
...
self.depth = 80
self.characters = {}
for i = 1, #self.text do table.insert(self.characters, self.text:utf8sub(i, i)) end
end
Так мы определяем, что объект будет иметь глубину 80 (выше, чем все остальные объекты, то есть он будет отрисовываться поверх всего), а затем разделяем исходную строку на символы таблицы. Для этого мы используем библиотеку
utf8
. В целом хорошей идеей будет манипулировать строками с помощью библиотеки, поддерживающей все типы символов, и как мы скоро увидим, особенно важно это для нашего объекта.Отрисовка этих символов тоже должна выполняться индивидуально, потому что, как мы выяснили ранее, каждый символ имеет свой собственный фон, меняющийся случайным образом.
Логика отрисовки каждого символа по отдельности заключается в том, чтобы пройтись по таблице символов и отрисовывать каждый символ в позиции x, которая является суммой всех символов перед ним. То есть, например, отрисовка первой
O
в строке +BOOST
означает отрисовку в позиции initial_x_position + widthOf('+B')
. В нашем случае проблема с получением ширины +B
заключается в том, что она зависит от используемого шрифта, поскольку мы будем использовать функцию Font:getWidth
, но пока не задали шрифт. Однако мы с лёгкостью можем решить эту проблему!Для этого эффекта мы используем шрифт m5x7 Дэниела Линссена. Мы можем поместить этот шрифт в папку
resources/fonts
, а затем загрузить его. Код, необходимый для его загрузки, я оставлю в качестве упражнения для вас, потому что он в чём-то похож на код, использованный для загрузки определений классов из папки objects
(упражнение 14). К концу этого процесса загрузки у нас появится глобальная таблица fonts
, в которой будут содержаться все загруженные шрифты в формате fontname_fontsize
. В этом примере мы будем использовать m5x7_16
:function InfoText:new(...)
...
self.font = fonts.m5x7_16
...
end
И вот, как будет выглядеть код отрисовки:
function InfoText:draw()
love.graphics.setFont(self.font)
for i = 1, #self.characters do
local width = 0
if i > 1 then
for j = 1, i-1 do
width = width + self.font:getWidth(self.characters[j])
end
end
love.graphics.setColor(self.color)
love.graphics.print(self.characters[i], self.x + width, self.y,
0, 1, 1, 0, self.font:getHeight()/2)
end
love.graphics.setColor(default_color)
end
Сначала мы воспользуемся
love.graphics.setFont
для задания шрифта, который хотим использовать в следующих операциях отрисовки. Затем мы должны пройтись по каждому из символов, а затем отрисовать их. Но сначала нам нужно вычислить его позицию по x, которая является суммой ширины всех символов до него. Внутренний цикл, накапливающий переменную width
, занимается только этим. Он начинает с 1 (начало строки) до i-1 (символ перед текущим) и прибавляет ширину каждого символа к общей width
, то есть к сумме их всех. Затем мы используем love.graphics.print
для отрисовки каждого отдельного символа в соответствующей ему позиции. Также мы смещаем каждый символ на половину высоты шрифта (чтобы символы центрировались относительно заданной нами позиции y).Если мы протестируем всё это, то получим следующее:
GIF
Как раз то, что нам нужно!
Теперь мы можем перейти к мерцанию текста перед исчезновением. В этом эффекте используется та же логика, что и в объекте BoostEffect, то есть мы можем просто скопировать его:
function InfoText:new(...)
...
self.visible = true
self.timer:after(0.70, function()
self.timer:every(0.05, function() self.visible = not self.visible end, 6)
self.timer:after(0.35, function() self.visible = true end)
end)
self.timer:after(1.10, function() self.dead = true end)
end
Если мы запустим это, то увидим, что текст какое-то время остаётся обычным, потом начинает мерцать и исчезает.
А теперь самое сложное — сделаем так, чтобы каждый символ менялся случайным образом, и то же самое сделаем с основным и фоновым цветами. Эти изменения начинаются примерно тогда же когда символ начинает мерцать, поэтому мы поместим эту часть кода внутрь вызова
after
на 0,7 секунды, который мы определили выше. Мы сделаем так — каждые 0,035 секунды мы будем запускать процедуру, имеющую шанс на изменение символа на другой случайный символ. Это выглядит вот так:self.timer:after(0.70, function()
...
self.timer:every(0.035, function()
for i, character in ipairs(self.characters) do
if love.math.random(1, 20) <= 1 then
-- change character
else
-- leave character as it is
end
end
end)
end)
То есть каждые 0,035 секунды каждый символ имеет вероятность в 5% измениться на что-то другое. Мы можем завершить с этим, добавив переменную
random_characters
, являющуюся строкой, содержащей все символы, на которые может измениться символ. Когда символу нужно будет меняться, мы случайным образом выбираем символ из этой строки:self.timer:after(0.70, function()
...
self.timer:every(0.035, function()
local random_characters = '0123456789!@#$%?&*()-=+[]^~/;?><.,|abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWYXZ'
for i, character in ipairs(self.characters) do
if love.math.random(1, 20) <= 1 then
local r = love.math.random(1, #random_characters)
self.characters[i] = random_characters:utf8sub(r, r)
else
self.characters[i] = character
end
end
end)
end)
Когда мы запустим код, это должно выглядеть так:
GIF
Мы можем воспользоваться той же логикой для изменения основного и фонового цветов символа. Для этого мы определим две таблицы,
background_colors
и foreground_colors
. Каждая таблица имеет тот же размер, что и таблица characters
, и будет просто содержать фоновый и основной цвета для каждого символа. Если для какого-то символа не будет задано цветов в этой таблице, то он будет по умолчанию использовать основной цвет (boost_color
) и прозрачный фон.function InfoText:new(...)
...
self.background_colors = {}
self.foreground_colors = {}
end
function InfoText:draw()
...
for i = 1, #self.characters do
...
if self.background_colors[i] then
love.graphics.setColor(self.background_colors[i])
love.graphics.rectangle('fill', self.x + width, self.y - self.font:getHeight()/2,
self.font:getWidth(self.characters[i]), self.font:getHeight())
end
love.graphics.setColor(self.foreground_colors[i] or self.color or default_color)
love.graphics.print(self.characters[i], self.x + width, self.y,
0, 1, 1, 0, self.font:getHeight()/2)
end
end
Если определён
background_colors[i]
(фоновый цвет для текущего символа), то для фонового цвета мы просто отрисовываем прямоугольник в соответствующей позиции и размером с текущий символ. Основной цвет мы меняем, просто задавая с помощью setColor
цвет отрисовки текущего символа. Если foreground_colors[i]
не определён, то по умолчанию он равен self.color
, который для этого объекта всегда равен boost_color
, поскольку мы именно его мы передаём при вызове из объекта Boost. Но если self.color
не определён, то он по умолчанию равен белому (default_color
). Сам по себе этот фрагмент кода ничего не делает, потому что мы не определили значения внутри таблиц background_colors
и foreground_colors
.Для этого мы можем использовать ту же логику, что использовалась для случайного изменения символов:
self.timer:after(0.70, function()
...
self.timer:every(0.035, function()
for i, character in ipairs(self.characters) do
...
if love.math.random(1, 10) <= 1 then
-- change background color
else
-- set background color to transparent
end
if love.math.random(1, 10) <= 2 then
-- change foreground color
else
-- set foreground color to boost_color
end
end
end)
end)
Код, заменяющий цвета, должен выбирать из списка цветов. Мы определили глобальную группу из шести цветов, поэтому можем просто поместить все их в список и затем для случайного выбора одного из них использовать
table.random
. Кроме того, сверх этого мы определим ещё шесть цветов, которые будут негативами шести исходных. То есть если у нас есть исходный цвет 232, 48, 192
, то его негатив можно определить как 255-232, 255-48, 255-192
.function InfoText:new(...)
...
local default_colors = {default_color, hp_color, ammo_color, boost_color, skill_point_color}
local negative_colors = {
{255-default_color[1], 255-default_color[2], 255-default_color[3]},
{255-hp_color[1], 255-hp_color[2], 255-hp_color[3]},
{255-ammo_color[1], 255-ammo_color[2], 255-ammo_color[3]},
{255-boost_color[1], 255-boost_color[2], 255-boost_color[3]},
{255-skill_point_color[1], 255-skill_point_color[2], 255-skill_point_color[3]}
}
self.all_colors = fn.append(default_colors, negative_colors)
...
end
Здесь мы определяем две таблицы, содержащие соответствующие значения каждого из цветов, а затем использовать функцию
append
, чтобы объединить их. Тогда теперь мы сможем сделать что-то типа table.random(self.all_colors)
и получить один случайный цвет из десяти, определённых в этой таблице. То есть мы можем сделать следующее:self.timer:after(0.70, function()
...
self.timer:every(0.035, function()
for i, character in ipairs(self.characters) do
...
if love.math.random(1, 10) <= 1 then
self.background_colors[i] = table.random(self.all_colors)
else
self.background_colors[i] = nil
end
if love.math.random(1, 10) <= 2 then
self.foreground_colors[i] = table.random(self.all_colors)
else
self.background_colors[i] = nil
end
end
end)
end)
Если мы запустим игру, то увидим следующее:
GIF
Вот и всё. Позже мы усовершенствуем этот эффект (в том числе и в упражнениях), но пока этого достаточно. Последнее, что нам нужно сделать — при сборе ресурса ускорения прибавлять игроку +25 boost. Это работает точно так же, как и с ресурсом боеприпасов, поэтому мы пропустим код.
Упражнения с ресурсами
95. Сделайте так, чтобы класс коллизий Projectile игнорировал класс коллизий Player.
96. Измените функцию
addAmmo
так, чтобы она поддерживала прибавление отрицательных значений и не позволяла атрибуту ammo
опускаться ниже 0. Сделайте то же самое для функций addBoost
и addHP
(прибавление ресурса HP будет заданием ещё одного упражнения).97. Судя по предыдущему упражнению, лучше обрабатывать положительные и отрицательные значения в одной функции или разделить их на функции
addResource
и removeResource
?98. В объекте
InfoText
измените вероятность изменения символа на 20%, вероятность изменения основного цвета на 5%, а вероятность изменения фонового цвета — на 30%.99. Определите таблицы
default_colors
, negative_colors
и all_colors
в InfoText
не локально, а глобально.100. Рандомизируйте позицию объекта
InfoText
так, чтобы он создавался между -self.w
и self.w
по компоненту x, между -self.h
и self.h
по компоненту y. Атрибуты w
и h
относятся к объекту Boost, создающему InfoText.101. Допустим, у нас есть следующая функция:
function Area:getAllGameObjectsThat(filter)
local out = {}
for _, game_object in pairs(self.game_objects) do
if filter(game_object) then
table.insert(out, game_object)
end
end
return out
end
Она возвращает все игровые объекты внутри Area, которые передаёт функция filter. Также допустим, что она вызывается в конструкторе InfoText следующим образом:
function InfoText:new(...)
...
local all_info_texts = self.area:getAllGameObjectsThat(function(o)
if o:is(InfoText) and o.id ~= self.id then
return true
end
end)
end
Что возвращает все существующие и живые объекты InfoText, не являющиеся текущим. Сделайте так, чтобы объект InfoText визуально не накладывался ни на какой другой объект InfoText, то есть не занимал то же пространство на экране и текст не становился нечитаемым. Можете сделать это любым удобным вам способом, главное, чтобы он выполнял задачу.
102. (КОНТЕНТ) Добавьте ресурс HP со всем функционалом и визуальными эффектами. Он использует точно такую же логику, что и ресурс Boost, но прибавляет +25 HP. Ресурс и эффекты должны выглядеть так:
GIF
103. (КОНТЕНТ) Добавьте ресурс SP со всем функционалом и визуальными эффектами. Он использует ту же логику, что и ресурс, но прибавляет +1 SP. Кроме того, ресурс SP должен быть определён как глобальная переменная, а не как внутренняя переменная объекта Player. Ресурс и эффект должны выглядеть так:
GIF
Атаки
Отлично, теперь перейдём к атакам. В первую очередь мы изменим способ отрисовки снарядов. Сейчас они отрисовываются как круги, но мы хотим, чтобы они были линиями. Это можно реализовать примерно так:
function Projectile:draw()
love.graphics.setColor(default_color)
pushRotate(self.x, self.y, Vector(self.collider:getLinearVelocity()):angle())
love.graphics.setLineWidth(self.s - self.s/4)
love.graphics.line(self.x - 2*self.s, self.y, self.x, self.y)
love.graphics.line(self.x, self.y, self.x + 2*self.s, self.y)
love.graphics.setLineWidth(1)
love.graphics.pop()
end
В функции
pushRotate
мы используем скорость снаряда, поэтому можем поворачивать его в соответствии с углом, под которым он движется. Затем внутри мы используем love.graphics.setLineWidth
и задаём значение, примерно пропорциональное атрибуту s
, но немного меньшее. Это значит, что снаряды с бОльшим s
в целом будут толще. Затем мы отрисовываем снаряд с помощью love.graphics.line
. Также важно то, что мы отрисовываем одну линию от -2*self.s
до центра, а затем ещё одну от центра до 2*self.s
. Мы делаем так, потому что каждая атака будет иметь собственный цвет, и мы будем менять цвет одной из этих линий, но не второй. То есть, например, если мы сделаем так:function Projectile:draw()
love.graphics.setColor(default_color)
pushRotate(self.x, self.y, Vector(self.collider:getLinearVelocity()):angle())
love.graphics.setLineWidth(self.s - self.s/4)
love.graphics.line(self.x - 2*self.s, self.y, self.x, self.y)
love.graphics.setColor(hp_color) -- change half the projectile line to another color
love.graphics.line(self.x, self.y, self.x + 2*self.s, self.y)
love.graphics.setLineWidth(1)
love.graphics.pop()
end
То это будет выглядеть следующим образом:
GIF
Таким образом мы можем назначить каждой атаке собственный цвет, и позволит игроку лучше понимать, что происходит на экране.
В готовой игре будет 16 атак, но сейчас мы рассмотрим только некоторые из них. Работа системы атак очень проста и основана на следующих правилах:
- Атаки (за исключением Neutral) при каждом выстреле расходуют боеприпасы;
- Когда боеприпасы снижаются до 0, то текущая атака изменяется на Neutral;
- Новые атаки можно получать с помощью случайно создаваемых ресурсов;
- При получении новой атаки текущая атака заменяется, а боеприпасы полностью восстанавливаются;
- Каждая атака расходует своё количество боеприпасов и обладает собственными свойствами.
Первое, что мы сделаем — определим таблицу, в которой будет содержаться информация о каждой из атак: время их «остывания», расход боеприпасов и цвет. Мы определим таблицу в
globals.lua
и пока она будет выглядеть так:attacks = {
['Neutral'] = {cooldown = 0.24, ammo = 0, abbreviation = 'N', color = default_color},
}
Стандартная атака, которую мы уже определили, называется
Neutral
. Она будет использовать параметры атаки, которая уже есть у нас в игре. Сейчас мы можем определить функцию setAttack
, которая будет заменять одну атаку другой и использовать эту глобальную таблицу атак:function Player:setAttack(attack)
self.attack = attack
self.shoot_cooldown = attacks[attack].cooldown
self.ammo = self.max_ammo
end
Мы сможем вызывать её так:
function Player:new(...)
...
self:setAttack('Neutral')
...
end
Здесь мы просто изменяем атрибут
attack
, который будет содержать имя текущей атаки. Этот атрибут будет использоваться в функции shoot
для проверки текущей активной атаки и определения способа создания снарядов.Также мы можем менять атрибут
shoot_cooldown
. Этот атрибут мы пока не создали, но он будет похож на атрибуты boost_timer
и boost_cooldown
. Он будет использоваться для управления тем, как часто происходит какое-то действие, в нашем случае — атака. Мы удалим эту строку:function Player:new(...)
...
self.timer:every(0.24, function() self:shoot() end)
...
end
И будем задавать тайминги атак вручную:
function Player:new(...)
...
self.shoot_timer = 0
self.shoot_cooldown = 0.24
...
end
function Player:update(dt)
...
self.shoot_timer = self.shoot_timer + dt
if self.shoot_timer > self.shoot_cooldown then
self.shoot_timer = 0
self:shoot()
end
...
end
В конце функции мы также будем восстанавливать количество боеприпасов. Так мы реализуем правило 4. Следующее, что можно сделать — немного изменить функцию
shoot
, чтобы она начала учитывать существование разных атак:function Player:shoot()
local d = 1.2*self.w
self.area:addGameObject('ShootEffect',
self.x + d*math.cos(self.r), self.y + d*math.sin(self.r), {player = self, d = d})
if self.attack == 'Neutral' then
self.area:addGameObject('Projectile',
self.x + 1.5*d*math.cos(self.r), self.y + 1.5*d*math.sin(self.r), {r = self.r})
end
end
Перед запуском снаряда мы проверяем с помощью условной конструкции
if self.attack == 'Neutral'
текущую атаку. Эта функция будет постепенно разрастаться в большую цепочку условий, потому что нам придётся проверять все 16 атак.Давайте начнём с добавления одной атаки, чтобы посмотреть, как это будет выглядеть. Добавляемая нами атака будет называться
Double
. Она выглядит так:Как вы видите, она стреляет под углом двумя снарядами вместо одного. Для начала нам нужно добавить в глобальную таблицу атак описание атаки. Эта атака будет иметь время «остывания» 0,32 секунды, тратить 2 боеприпаса, а её цвет будет
ammo_color
(эти значения я получил методом проб и ошибок):attacks = {
...
['Double'] = {cooldown = 0.32, ammo = 2, abbreviation = '2', color = ammo_color},
}
Теперь мы можем добавить её и в функцию
shoot
:function Player:shoot()
...
elseif self.attack == 'Double' then
self.ammo = self.ammo - attacks[self.attack].ammo
self.area:addGameObject('Projectile',
self.x + 1.5*d*math.cos(self.r + math.pi/12),
self.y + 1.5*d*math.sin(self.r + math.pi/12),
{r = self.r + math.pi/12, attack = self.attack})
self.area:addGameObject('Projectile',
self.x + 1.5*d*math.cos(self.r - math.pi/12),
self.y + 1.5*d*math.sin(self.r - math.pi/12),
{r = self.r - math.pi/12, attack = self.attack})
end
end
Здесь мы создаём не один, а два снаряда, каждый из которых направлен под углом math.pi/12 радиан, или 15 градусов. Мы также сделали так, чтобы снаряд в качестве названия атаки получал атрибут
attack
. Мы сделаем так для каждого типа снарядов, потому что это поможет нам определять, к какому типу атаки относится снаряд. Это пригодится для задания соответствующего цвета, а также для изменения поведения при необходимости. Объект Projectile теперь выглядит так:function Projectile:new(...)
...
self.color = attacks[self.attack].color
...
end
function Projectile:draw()
pushRotate(self.x, self.y, Vector(self.collider:getLinearVelocity()):angle())
love.graphics.setLineWidth(self.s - self.s/4)
love.graphics.setColor(self.color)
love.graphics.line(self.x - 2*self.s, self.y, self.x, self.y)
love.graphics.setColor(default_color)
love.graphics.line(self.x, self.y, self.x + 2*self.s, self.y)
love.graphics.setLineWidth(1)
love.graphics.pop()
end
В конструкторе мы задаём для
color
цвет, определённый для этой атаки в глобальной таблице attacks
. А в функции отрисовки мы рисуем одну часть линии этим цветом, являющимся атрибутом color
, и другим, являющимся default_color
. Для большинства типов снарядов схема будет такой же.Последнее, о чём мы забыли — сделать так, чтобы эта атака подчинялась правилу 1, то есть мы забыли добавить код, заставляющий её тратить нужное количество боеприпасов. Это довольно просто исправить:
function Player:shoot()
...
elseif self.attack == 'Double' then
self.ammo = self.ammo - attacks[self.attack].ammo
...
end
end
Благодаря этому будет исполняться правило 1 (для атаки Double). Также мы можем добавить код, реализующий правило 2: когда
ammo
снижается до 0, мы изменяем текущую атаку на Neutral
:function Player:shoot()
...
if self.ammo <= 0 then
self:setAttack('Neutral')
self.ammo = self.max_ammo
end
end
Мы должны перейти к концу функции
shoot
, потому что не хотим, чтобы игрок мог стрелять после того, как количество боеприпасов снизится до 0.Если вы сделаете это и попробуете запустить программу, то получите следующее:
GIF
Упражнения с атаками
104. (КОНТЕНТ) Реализуйте атаку
Triple
. Её определение в таблице атак выглядит следующим образом:attacks['Triple'] = {cooldown = 0.32, ammo = 3, abbreviation = '3', color = boost_color}
А сама атака будет выглядеть так:
GIF
Углы снарядов точно те же, что и в
Double
, но тут ещё есть дополнительный снаряд, создаваемый посередине (под тем же углом, что и снаряд атаки Neutral
). Создайте эту атаку, выполнив те же действия, что и для атаки Double.105. (КОНТЕНТ) Реализуйте атаку
Rapid
. Её определение в таблице атак выглядит так:attacks['Rapid'] = {cooldown = 0.12, ammo = 1, abbreviation = 'R', color = default_color}
А сама атака выглядит так:
GIF
106. (КОНТЕНТ) Реализуйте атаку
Spread
. Её определение в таблице атак:attacks['Spread'] = {cooldown = 0.16, ammo = 1, abbreviation = 'RS', color = default_color}
А сама атака выглядит так:
GIF
Углы, используемые для выстрелов — это случайное значение от -math.pi/8 до +math.pi/8. Цвет снарядов этой атаки тоже работает немного иначе. Вместо наличия только одного цвета, в каждом кадре цвет меняется случайным образом на один из списка
all_colors
(или через кадр, в зависимости от того, как вам покажется лучше).107. (КОНТЕНТ) Реализуйте атаку
Back
. Её определение в таблице атак выглядит так:attacks['Back'] = {cooldown = 0.32, ammo = 2, abbreviation = 'Ba', color = skill_point_color}
А сама атака выглядит так:
GIF
108. (КОНТЕНТ) Реализуйте атаку
Side
. Её определение в таблице атак:attacks['Side'] = {cooldown = 0.32, ammo = 2, abbreviation = 'Si', color = boost_color}
Сама атака:
GIF
109. (КОНТЕНТ) Реализуйте ресурс
Attack
. Как и Boost
с SkillPoint
, ресурс Attack создаётся в левой или правой границе экрана, а затем очень медленно движется внутрь. Когда игрок вступает во взаимодействие с ресурсом Attack, его атака с помощью функции setAttack
изменяется на атаку, которая содержится в ресурсе.Ресурс Attack немного отличается внешне от ресурсов Boost и SkillPoint, но принцип его самого и его эффектов почти такая же. Цвета, используемые для каждого ресурса, те же, что у их снарядов, а в качестве имени-идентификатора используется то, которое мы назвали
abbreviation
в таблице attacks
. Вот, как они выглядят:GIF
Не забывайте создавать объекты InfoText при подборе новой атаки игроком!
Часть 8: Враги
Введение
В этой части мы рассмотрим создание нескольких врагов, а также класса EnemyProjectile, то есть снарядов, которыми некоторые враги смогут стрелять в игрока. Эта часть будет немного короче других, потому что теперь я не буду подробно рассказывать о создании всех врагов, а только об общем поведении, которое будет для всех них почти одинаковым.
Враги
Враги в этой игре ведут себя почти так же, как ресурсы, созданные в предыдущей части: они создаются возле левой или правой границы экрана в случайной позиции y, а затем медленно движутся внутрь. Код их поведения будет практически таким же, который мы реализовали для ресурсов.
Мы начнём с первого врага, которого назовём
Rock
. Он выглядит так:GIF
Код конструктора этого объекта будет очень похож на код
Boost
, но с небольшими отличиями:function Rock:new(area, x, y, opts)
Rock.super.new(self, area, x, y, opts)
local direction = table.random({-1, 1})
self.x = gw/2 + direction*(gw/2 + 48)
self.y = random(16, gh - 16)
self.w, self.h = 8, 8
self.collider = self.area.world:newPolygonCollider(createIrregularPolygon(8))
self.collider:setPosition(self.x, self.y)
self.collider:setObject(self)
self.collider:setCollisionClass('Enemy')
self.collider:setFixedRotation(false)
self.v = -direction*random(20, 40)
self.collider:setLinearVelocity(self.v, 0)
self.collider:applyAngularImpulse(random(-100, 100))
end
Здесь вместо RectangleCollider объект будет использовать PolygonCollider. Мы создаём вершины этого многоугольника функцией
createIrregularPolygon
, которая будет определена в utils.lua
. Эта функция должна возвращать список вершин, составляющих неправильный прямоугольник. Под неправильным прямоугольником я подразумеваю такой, который похож на круг, но каждая вершина которого может быть чуть ближе или дальше от центра, и в котором углы между каждой из вершин тоже могут быть немного случайными.Чтобы начать определение функции, мы можем сказать, что она будет получать два аргумента:
size
и point_amount
. Первый будет относиться к радиусу круга, а второй — к количеству точек, составляющих многоугольник (полигон):function createIrregularPolygon(size, point_amount)
local point_amount = point_amount or 8
end
Здесь мы также можем сказать, что если
point_amount
не определён, то по умолчанию имеет значение 8.Следующее, что мы можем сделать — определить все точки. Это можно сделать в цикле от 1 до
point_amount
, в каждой итерации которого мы будем определять следующую вершину на основе интервала углов. Например, для определения позиции второй точки мы можем сказать, что его угол будет в интервале 2*angle_interval
, где angle_interval
— это значение 2*math.pi/point_amount
. То есть в этом случае оно будет примерно равно 90 градусов. Логичнее это записать кодом, так что:function createIrregularPolygon(size, point_amount)
local point_amount = point_amount or 8
local points = {}
for i = 1, point_amount do
local angle_interval = 2*math.pi/point_amount
local distance = size + random(-size/4, size/4)
local angle = (i-1)*angle_interval + random(-angle_interval/4, angle_interval/4)
table.insert(points, distance*math.cos(angle))
table.insert(points, distance*math.sin(angle))
end
return points
end
Здесь мы определяем
angle_interval
, как объяснялось выше, но также определяем distance
как находящуюся где-то в пределах радиуса круга, но со случайным смещением от -size/4
до +size/4
. Это значит, что каждая вершина будет не точно находиться на окружности круга, а где-то рядом. Также мы немного рандомизируем интервал углов, чтобы создать тот же эффект. Наконец, мы добавим компоненты x и y в список возвращаемых точек. Заметьте, что многоугольник создаётся в локальном пространстве (предполагающем, что центр находится в 0, 0), то есть для размещения объекта в нужном месте нам придётся затем использовать setPosition
.Ещё одно отличие конструктора этого объекта в том, что он использует класс коллизий
Enemy
. Как и все другие классы коллизий, этот перед использованием тоже нужно определить:function Stage:new()
...
self.area.world:addCollisionClass('Enemy')
...
end
В общем случае, новые классы коллизий следует добавлять для типов объектов, которые будут иметь между друг другом отличающиеся поведения коллизий. Например, враги будут физически игнорировать игрока, но не снаряды. Поскольку ни один другой тип объектов не использует это поведение, то нам нужно создать для него новый класс коллизий. Если бы класс коллизий Projectile игнорировал только игрока, а не другие снаряды, то для врагов тоже можно было бы использовать класс коллизий Projectile.
Последнее, что нужно сделать с объектом Rock — выполнить его отрисовку. Так как это просто многоугольник, то мы можем просто отрисовать его точки с помощью
love.graphics.polygon
:function Rock:draw()
love.graphics.setColor(hp_color)
local points = {self.collider:getWorldPoints(self.collider.shapes.main:getPoints())}
love.graphics.polygon('line', points)
love.graphics.setColor(default_color)
end
Сначала мы получаем эти точки с помощью
PolygonShape:getPoints
. Точки возвращаются в локальных координатах, а нам нужны глобальные, поэтому придётся использовать Body:getWorldPoints
для преобразования локальных координат в глобальные. После этого мы можем отрисовать многоугольник, и он будет вести себя, как мы ожидаем. Учтите, что так как мы получаем точки непосредственно от коллайдера, а коллайдер — это поворачивающийся многоугольник, то нам не нужно применять pushRotate
для поворота объекта, как это было с объектом Boost, потому что получаемые точки уже учитывают поворот объектов.Если мы сделаем всё это, то игра будет выглядеть так:
GIF
Упражнения с врагами
110. Выполните следующие задания:
- Добавьте в класс Rock атрибут
hp
с начальным значением 100 - Добавьте в класс Rock функцию
hit
. Эта функция должна делать следующее:
- Она должна получать аргумент
damage
, а если не получает, то присваивать ему по умолчанию значение 100 damage
будет вычитаться изhp
, и еслиhp
становится 0 или меньше, то объект Rock «умирает»- Если
hp
не достигает значения 0 или ниже, то атрибутуhit_flash
присваивается true, а через 0,2 секунды — false. Когдаhit_flash
имеет значение true, то в функции отрисовки объекта цвет объекта должен принимать значениеdefault_color
, а неhp_color
.
- Она должна получать аргумент
111. Создайте новый класс
EnemyDeathEffect
. Этот эффект создаётся при смерти врага и ведёт себя точно так же, как объект ProjectileDeathEffect
, только он больше и соответствует размеру объекта Rock. Этот объект должен создаваться, когда атрибут hp
объекта Rock становится равным 0 или ниже.112. Реализуйте событие коллизии между объектом класса коллизий Projectile и объектом класса коллизий Enemy. В нашем случае нужно реализовать его в функции update класса Projectile. Когда снаряд попадает в объект класса Enemy, то он должен вызывать функцию врага
hit
с величиной урона, наносимого снарядом (по умолчанию снаряды будут иметь атрибут damage
, изначально равный 100). При попадании снаряд также должен вызывать собственную функцию die
.113. Добавьте в класс Player функцию
hit
. Эта функция должна делать следующее:- Она должна получать аргумент
damage
, а в случае, когда он не определён, по умолчанию иметь значение 10 - Эта функция не должна ничего делать, кроме как присваивать атрибуту
invincible
значение true - Должно создаваться от 4 до 8 объектов
ExplodeParticle
- Функция
addHP
(илиremoveHP
, если вы решите её добавить) должна получать атрибутdamage
и использовать её для уменьшения HP объекта Player. Внутри функцииaddHP
(илиremoveHP
) должен быть реализован способ обработки ситуации, когдаhp
становится равным или меньше 0 и игрок умирает.
Кроме того, должны быть справедливыми следующие условные операции:
- Если полученный урон равен или больше 30, атрибуту
invincible
должно присваиваться значение true, а две секунды спустя — значение false. Кроме того, в течение 0,2 секунды камера должна трястись с силой 6, экран должен мерцать три раза, а игра должна замедлиться на 0,5 секунды до скорости 0,25. Наконец, атрибутinvisible
должен попеременно принимать значения true и false каждые 0,04 секунды в течение времени, когдаinvincible
имеет значение true. Когдаinvisible
имеет значение true, функция отрисовки Player не должна ничего отрисовывать. - Если полученный урон меньше 30, то камера должна трястись 0,1 секунды с силой 6, экран должен мерцать в течение двух кадров, а игра должна на 0,25 секунды замедлиться до скорости 0,75.
Эта функция
hit
должна вызываться при коллизии Player с Enemy. При коллизии со врагом игроку должен наноситься урон 30.После завершения этих четырёх упражнений у вас должно быть всё необходимое для таких взаимодействий между Player, Projectile и врагом Rock, какими они должны быть в игре. Эти взаимодействия будут относиться и к другим врагам. Всё это должно выглядеть так:
GIF
EnemyProjectile
Теперь мы можем сосредоточиться на ещё одной части работы с врагами — создании врагов, стреляющих снарядами. Некоторым из врагов будет доступна такая возможность, поэтому нам нужно будет создать объект, похожий на Projectile, но который будут использовать враги. Для этого мы создадим объект
EnemyProjectile
.Этот объект сначала можно создать, просто скопировав и немного изменив код
Projectile
. Оба этих объекта будут иметь много общего кода. Мы можем абстрагировать в общий объект снарядов, имеющий общее поведение, но на самом деле это необязательно, потому что в игре будет всего два типа снарядов. После копирования мы должны внести такие изменения:function EnemyProjectile:new(...)
...
self.collider:setCollisionClass('EnemyProjectile')
end
Классом коллизий EnemyProjectile тоже должен быть EnemyProjectile. Мы хотим, чтобы объекты EnemyProjectile игнорировали другие EnemyProjectile, Projectile и Player. Поэтому мы добавить класс коллизий, соответствующий этой цели:
function Stage:new()
...
self.area.world:addCollisionClass('EnemyProjectile',
{ignores = {'EnemyProjectile', 'Projectile', 'Enemy'}})
end
Ещё один важный аспект, который нужно изменить — это урон. Обычный снаряд, выпускаемый игроком, наносит 100 единиц урона, а вражеский снаряд должен наносить 10 единиц урона:
function EnemyProjectile:new(...)
...
self.damage = 10
end
Также нам нужно, чтобы выстреливаемые врагами снаряды имели коллизии с Player, но не с другими врагами. Поэтому мы берём код коллизии, используемый объектом Projectile, и оборачиваем его против самого Player:
function EnemyProjectile:update(dt)
...
if self.collider:enter('Player') then
local collision_data = self.collider:getEnterCollisionData('Player')
...
end
Наконец, мы хотим, чтобы этот объект был полностью красным, а не красно-белым, чтобы игрок мог отличить свои снаряды от вражеских:
function EnemyProjectile:draw()
love.graphics.setColor(hp_color)
...
love.graphics.setColor(default_color)
end
Внеся все эти небольшие изменения, мы успешно создали объект EnemyProjectile. Теперь нам нужно создать врага, который будет его использовать!
Стреляющий враг
Вот, как выглядит враг Shooter:
GIF
Как видите, сначала воспроизводится небольшой эффект, а затем выстреливается снаряд. Снаряд выглядит так же, как у игрока, только полностью красный.
Мы можем начать создавать этого врага, скопировав код из объекта Rock. Этот враг (да и вообще все враги) будут обладать общим свойством — появляться слева или справа от экрана, а затем медленно двигаться внутрь. Так как этот код уже есть у объекта Rock, то мы можем начать с него. Скопировав код, мы должны внести небольшие изменения:
function Shooter:new(...)
...
self.w, self.h = 12, 6
self.collider = self.area.world:newPolygonCollider(
{self.w, 0, -self.w/2, self.h, -self.w, 0, -self.w/2, -self.h})
end
Ширина, высота и вершины врага Shooter отличаются по значениям от Rock. У камня мы просто создали неправильный многоугольник, но этому врагу нужно придать чётко различимую и заострённую форму, чтобы игрок мог инстинктивно понять, куда он будет двигаться. Задание вершин здесь схоже с процессом разработки дизайна кораблей, поэтому при желании можно изменить внешний вид врага и сделать его более крутым.
function Shooter:new(...)
...
self.collider:setFixedRotation(false)
self.collider:setAngle(direction == 1 and 0 or math.pi)
self.collider:setFixedRotation(true)
end
Также нам нужно изменить следующее: в отличие от камня, недостаточно просто задать скорость объекта. Мы также должны задать его угол, чтобы физический коллайдер указывал в правильном направлении. Для этого нам сначала надо отключить его постоянный поворот (в противном случае задание угла не будет работать), изменить угол, а затем снова сделать истинным постоянный поворот. Мы делаем поворот снова постоянным, потому что не хотим, чтобы коллайдер вращался, когда его что-то ударит. Нам нужно, чтобы он оставался направленным в сторону движения.
Строка
direction == 1 and math.pi or 0
— это реализация тернарного оператора в Lua. В других языках он может выглядеть как (direction == 1) ? math.pi : 0
. Думаю, упражнения в частях 2 и 4 позволили вам подробно их рассмотреть. В сущности, здесь происходит следующее: если direction
равно 1 (враг появляется справа и направлен влево), то первая условная конструкция спарсится в true, то есть мы получим true and math.pi or 0
. Из-за порядка исполнения and
и or
, первым будет true and math.pi
, то есть в результате у нас останется math.pi or 0
, что вернёт math.pi, поскольку когда оба элемента равны true, то or
возвращает первый из них. С другой стороны, если direction
равно -1, то первая условная конструкция спарсится в false и у нас получится false and math.pi or 0
, то есть false or 0
, что приводит нас к 0, так как когда первый элемент false, or
возвращает второй.С учётом всего этого мы можем начать создавать в игре объекты Shooter, и они будут выглядеть так:
GIF
Теперь нам нужно создать предваряющий атаку эффект. Обычно в большинстве игр, когда враг собирается атаковать, происходит нечто, сообщающее об этом игроку. Чаще всего это анимация, но может быть и эффектом. В нашем случае мы воспользуемся простым эффектом «зарядки», в котором множество частиц всасываются в точку, из которой вылетит снаряд.
Вот как это будет реализовано на высоком уровне:
function Player:new(...)
...
self.timer:every(random(3, 5), function()
-- spawn PreAttackEffect object with duration of 1 second
self.timer:after(1, function()
-- spawn EnemyProjectile
end)
end)
end
Это значит, что с интервалом от 3 до 5 секунд каждый враг Shooter будет выстреливать новый снаряд. Это будет происходить после выполнения эффекта
PreAttackEffect
в течение одной секунды.Подобные эффекты работают с частицами следующим образом: как и в случае со следами выхлопа, в каждом кадре или через кадр создаются частицы определённого типа, составляющие этот эффект. В нашем случае будут создаваться частицы под названием
TargetParticle
. Эти частицы будут двигаться к точке, которую мы определим как целевую, а затем умирать через какое-то время, или когда они достигнут цели.function TargetParticle:new(area, x, y, opts)
TargetParticle.super.new(self, area, x, y, opts)
self.r = opts.r or random(2, 3)
self.timer:tween(opts.d or random(0.1, 0.3), self,
{r = 0, x = self.target_x, y = self.target_y}, 'out-cubic', function() self.dead = true end)
end
function TargetParticle:draw()
love.graphics.setColor(self.color)
draft:rhombus(self.x, self.y, 2*self.r, 2*self.r, 'fill')
love.graphics.setColor(default_color)
end
Здесь для каждой частицы в течение времени
d
(или случайного значения от 0,1 до 0,3 секунд) выполняется переход функцией tween к target_x, target_y
, и когда частица достигает этой позиции, она умирает. Частица тоже отрисовывается как ромб (как в одном из эффектов, созданных ранее), но её можно отрисовывать и как круг или квадрат, потому что она довольно мала и становится со временем ещё меньше.Мы создаём эти объекты
PreAttackEffect
следующим образом:function PreAttackEffect:new(...)
...
self.timer:every(0.02, function()
self.area:addGameObject('TargetParticle',
self.x + random(-20, 20), self.y + random(-20, 20),
{target_x = self.x, target_y = self.y, color = self.color})
end)
end
Итак, здесь мы создаём одну частицу через каждые 0,02 секунды (почти в каждом кадре) в случайном месте вокруг её позиции, а затем задаём атрибутам
target_x, target_y
значения позиции самого эффекта (то есть на носу корабля).В
Shooter
мы создаём PreAttackEffect так:function Shooter:new(...)
...
self.timer:every(random(3, 5), function()
self.area:addGameObject('PreAttackEffect',
self.x + 1.4*self.w*math.cos(self.collider:getAngle()),
self.y + 1.4*self.w*math.sin(self.collider:getAngle()),
{shooter = self, color = hp_color, duration = 1})
self.timer:after(1, function()
end)
end)
end
Исходная задаваемая нами позиция должна находиться на носу объекта Shooter, поэтому мы можем использовать обычный паттерн с math.cos и math.sin, который мы уже применяли, и учитывать оба возможных угла (0 and math.pi). Также мы можем передавать атрибут
duration
, который управляет временем жизни объекта PreAttackEffect. Здесь мы можем сделать следующее:function PreAttackEffect:new(...)
...
self.timer:after(self.duration - self.duration/4, function() self.dead = true end)
end
Мы не используем сам
duration
потому, что это тот объект, который я про себя называю «объектом-контроллером». Например, у него ничего нет в функции draw, то есть мы никогда не увидим его в игре. Мы видим только объекты TargetParticle
, которым он приказывает создаваться. У этих объектов срок жизни случаен, от 0,1 до 0,3 секунды, то есть если мы захотим, чтобы последние частицы завершились сразу при выстреле снарядом, то этот объект умрёт через 0,1-0,3 секунды позже, чем его длительность в 1 секунду. Я решил сделать его равным 0,75 (duration — duration/4), но можно вместо этого использовать другое число, ближе к 0,9 секунды.Если мы запустим игру сейчас, то она будет выглядеть так:
И всё работает достаточно хорошо. Но если вы внимательно посмотрите, то заметите, что целевая позиция частиц (позиция объекта PreAttackEffect) остаётся неподвижной, а не следует за Shooter. Мы можем исправить это так же, как мы исправили объект ShootEffect для игрока. У нас уже есть атрибут
shooter
, показывающий на объект Shooter, который создал объект PreAttackEffect, поэтому мы просто можем обновлять позицию PreAttackEffect на основании позиции этого родительского объекта shooter
:function PreAttackEffect:update(dt)
...
if self.shooter and not self.shooter.dead then
self.x = self.shooter.x + 1.4*self.shooter.w*math.cos(self.shooter.collider:getAngle())
self.y = self.shooter.y + 1.4*self.shooter.w*math.sin(self.shooter.collider:getAngle())
end
end
Здесь мы каждый кадр обновляем позицию этого объекта, чтобы он находился на носу создавшего его объекта Shooter. Если запустить игру, то это будет выглядеть так:
Важный аспект кода update — это часть
not self.shooter.dead
. Может так случиться, что когда мы ссылаемся на объекты внутри друг друга подобным образом, то при смерти одного объекта другой всё ещё будет хранить на него ссылку. Например, объект PreAttackEffect живёт 0,75 секунды, но между его созданием и исчезновением создавший его объект Shooter может быть убит игроком. Если такое произойдёт, то может возникнуть проблема.В нашем случае проблема заключается в том, что у нас есть доступ к атрибуту
collider
объекта Shooter, который уничтожается при смерти объекта Shooter. И если этот объект уничтожается, мы ничего не сможем поделать с ним, потому что он больше не существует. Поэтому когда мы попробуем выполнить getAngle
, то игра вывалится. Мы можем выработать общую систему, решающую такую проблему, но на самом деле я не думаю, что это необходимо. Пока нам достаточно просто быть внимательными к тому, когда мы ссылаемся на объекты подобным образом, чтобы не пытаться получить доступ к объектам, которые могут быть уже мертвы.И, наконец, последняя часть, в которой мы создадим объект EnemyProjectile. Пока мы будем работать с ним достаточно просто, создавая его, как обычно создаём любой другой объект, но с собственными атрибутами:
function Shooter:new(...)
...
self.timer:every(random(3, 5), function()
...
self.timer:after(1, function()
self.area:addGameObject('EnemyProjectile',
self.x + 1.4*self.w*math.cos(self.collider:getAngle()),
self.y + 1.4*self.w*math.sin(self.collider:getAngle()),
{r = math.atan2(current_room.player.y - self.y, current_room.player.x - self.x),
v = random(80, 100), s = 3.5})
end)
end)
end
Здесь мы создаём снаряд в той же позиции, в которой мы создали PreAttackEffect, а затем присваиваем его скорости случайное значение от 80 и 100. Также мы слегка увеличиваем его размер относительно значения по умолчанию. Самая важная часть заключается в том, чтобы его угол (
r
attribute) указывал в направлении игрока. В общем случае, когда мы хотим получить угол от source
до target
, мы должны сделать следующее:angle = math.atan2(target.y - source.y, target.x - source.x)
Так мы и делаем. После создания объекта он будет направлен к игроку и начнёт двигаться к нему. Это должно выглядеть так:
Если вы сравните эту анимацию с анимацией из начала этой части туториала, то заметите небольшую разницу. У снарядов есть промежуток времени, когда они медленно поворачиваются к игроку, а не летят непосредственно к нему. Тут используется тот же фрагмент кода, что и в пассивном навыке самонаведения (homing), который мы со временем добавим, поэтому я оставлю это на потом.
Постепенно мы заполним объект EnemyProjectile различным функционалом, чтобы его можно было применять для множества различных врагов. Однако весь этот функционал будет сначала реализован в объекте Projectile, поскольку он будет служить пассивными навыками игрока. Например, существует пассивный навык, заставляющий снаряды кружиться вокруг игрока. После того, как мы его реализуем, мы сможем скопировать код в объект EnemyProjectile и реализовать врага, использующего функцию. Он не будет стрелять снарядами — они будут кружиться вокруг него. Таким образом мы создадим множество врагов, поэтому когда мы будем создавать пассивные навыки для игрока, эту часть я оставлю как упражнение.
Пока мы остановимся на двух врагах (Rock и Shooter), оставим EnemyProjectile таким, какой он есть, и перейдём к другим аспектам. Но позже, когда добавим больше функционала в игру, мы вернёмся, чтобы создать новых врагов.
Упражнения с EnemyProjectile/Shooter
114. Реализуйте событие коллизии между Projectile и EnemyProjectile. В классе EnemyProjectile сделайте так, чтобы когда он попадал в объект класса Projectile, вызывалась функция
die
обоих объектов и оба они уничтожались.115. Запутывает ли нас название атрибута
direction
в классе Shooter? Если да, то как его стоит переименовать? Если нет, то почему?