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

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


Теперь мы приступим к построению параметров. Первый параметр, который мы рассмотрим — это ускорение (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

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

Последнее, о чём мы забыли — это добавление игроку +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 атак, но сейчас мы рассмотрим только некоторые из них. Работа системы атак очень проста и основана на следующих правилах:

  1. Атаки (за исключением Neutral) при каждом выстреле расходуют боеприпасы;
  2. Когда боеприпасы снижаются до 0, то текущая атака изменяется на Neutral;
  3. Новые атаки можно получать с помощью случайно создаваемых ресурсов;
  4. При получении новой атаки текущая атака заменяется, а боеприпасы полностью восстанавливаются;
  5. Каждая атака расходует своё количество боеприпасов и обладает собственными свойствами.

Первое, что мы сделаем — определим таблицу, в которой будет содержаться информация о каждой из атак: время их «остывания», расход боеприпасов и цвет. Мы определим таблицу в 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? Если да, то как его стоит переименовать? Если нет, то почему?

Комментарии (0)