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

image


Оглавление


  • Статья 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
26c5ce8a1a25951b7fb5e54a63e47205.gif


Основные параметры


Теперь мы приступим к построению параметров. Первый параметр, который мы рассмотрим — это ускорение (boost). Он работает следующим образом — когда игрок нажимает «вверх» или «вниз», корабль изменяет свою скорость в зависимости от нажатой клавиши. Поверх этого базового функционала также должен быть ресурс, который исчерпывается при использовании ускорения и постепенно восстанавливается, когда ускорение не используется. Я буду применять такие значения и правила:

  1. Изначально игрок будет иметь 100 единиц ускорения
  2. При использовании ускорения будет убывать по 50 единиц ускорения
  3. В секунду всегда генерируется 10 единиц ускорения
  4. Когда количество единиц ускорения достигает 0, этому свойству требуется 2 секунды «остывания», прежде чем его можно будет использовать снова
  5. Ускорение можно выполнять, только когда «остывание» отключено и ресурс единиц ускорения больше 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
29fde6a3a478693d2991f360cc0faedd.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'}})


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

  1. Projectile игнорирует Projectile
  2. Collectable игнорирует Collectable
  3. Collectable игнорирует Projectile
  4. 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. Всё это должно выглядеть вот так:

GIF
a634e04ec4d7dc090a24adbb40bf5547.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
d146c25775abc5461c5a2eda26c1f4c0.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
a54c2344171745040677c4dff60ba2aa.gif



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

GIF
d146c25775abc5461c5a2eda26c1f4c0.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, но пока не задали шрифт. Однако мы с лёгкостью можем решить эту проблему!

Для этого эффекта мы используем шрифт 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).

Если мы протестируем всё это, то получим следующее:

GIF
fa6b56998a7246ed1fb54e14be14ea13.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
19d938389d1276aa5dc4a47b9cb36134.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 
    
            

© Habrahabr.ru