[Перевод] Создание игры на Lua и LÖVE — 3
Оглавление
- Статья 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 — первыми. Как будет выглядеть функция сортировки в этом случае?
Основные параметры
Теперь мы приступим к построению параметров. Первый параметр, который мы рассмотрим — это ускорение (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)
Давайте начнём с боеприпасов. Конечный результат должен стать таким:
Маленькие зелёные прямоугольники — это ресурс боеприпасов. Когда игрок касается его, ресурс уничтожается, а игрок получает 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, в которой есть очень хорошая выборка общих поведений управления. Я не буду объяснять поведение подробно, потому что, честно говоря, уже не помню, как оно работает, так что если вам интересно, то разберитесь в нём самостоятельно : D
function 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
. Всё это должно выглядеть вот так:
Последнее, о чём мы забыли — это добавление игроку +5 боеприпасов при сборе ресурса боеприпасов. Для этого мы определим функцию addAmmo
, которая просто добавляет определённое значение к переменной ammo
и проверяет, чтобы оно не превышало max_ammo
:
function Player:addAmmo(amount)
self.ammo = math.min(self.ammo + amount, self.max_ammo)
end
А затем мы просто вызываем эту функцию после object:die()
в только что добавленном коде.
Ресурс ускорения (Boost)
Теперь займёмся ускорением. Конечный результат должен выглядеть так:
Как вы видите, идея примерно такая же, как и с ресурсом боеприпасов, немного отличается только движение ресурса 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, как мы сделали это с ресурсом боеприпасов):
Теперь займёмся другой частью эффекта — безумным текстом. Этот текстовый эффект будет использоваться в игре почти повсюду, поэтому нам нужно реализовать его правильно. Ещё раз покажу, как он выглядит:
Сначала давайте разобьём эффект на несколько частей. Первое, что нужно заметить — это просто строка, изначально отрисовываемая на экране, но ближе к концу начинающая мерцать, как объект 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
, но пока не задали шрифт. Однако мы с лёгкостью можем решить эту проблему!
Для этого эффекта мы используем шрифт m5×7 Дэниела Линссена. Мы можем поместить этот шрифт в папку 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).
Если мы протестируем всё это, то получим следующее:
Как раз то, что нам нужно!
Теперь мы можем перейти к мерцанию текста перед исчезновением. В этом эффекте используется та же логика, что и в объекте 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)
Когда мы запустим код, это должно выглядеть так:
Мы можем воспользоваться той же логикой для изменения основного и фонового цветов символа. Для этого мы определим две таблицы, 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