Создание игры на Lua и LOVE — 2 +12


image

Оглавление


  • Статья 1
    1. Часть 1. Игровой цикл
    2. Часть 2. Библиотеки
    3. Часть 3. Комнаты и области
    4. Часть 4. Упражнения
  • Статья 2
    • Часть 5. Основы игры
    • Часть 6. Основы класса Player

7. Player Stats and Attacks

8. Enemies

9. Director and Gameplay Loop

10. Coding Practices

11. Passives

12. More Passives

13. Skill Tree

14. Console

15. Final

Часть 5: Основы игры


Введение


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

Структура игрового процесса


Сама игра разделена всего на три отдельных комнаты: Stage, Console и SkillTree.

В комнате Stage происходит весь игровой процесс. В ней находятся такие объекты, как игрок, враги, снаряды, ресурсы, бонусы и так далее. Игровой процесс очень похож на Bit Blaster XL и на самом деле достаточно прост. Я выбрал такой простой геймплей, потому что он позволит мне сосредоточиться на другом аспекте игры (огромном дереве навыков).

GIF

В комнате Console происходит всё, относящееся к «меню»: изменение настроек звука и видео, просмотр достижений, выбор корабля, доступ к дереву навыков и так далее. Вместо создания разных меню для игры с подобным стилем логичнее придать ему «компьютерный» внешний вид (также известный как «арт ленивых программистов»), поскольку консоль эмулирует терминал и даёт понять, что вы (игрок) играете в игру просто через какой-то терминал.

GIF

В комнате SkillTree можно получить все пассивные навыки. В комнате Stage игрок может заработать SP (skill points, очки навыка), которые создаются случайно или даются при убийстве врагов. После смерти игрок может использовать эти очки навыка для покупки пассивных навыков. Я хотел реализовать нечто огромное, в стиле дерева пассивных навыков Path of Exile, и мне кажется, достаточно преуспел в этом. В созданном мной дереве навыков около 600-800 узлов. По-моему, вполне неплохо.

GIF

Я подробно рассмотрю создание каждой из этих комнат, в том числе все навыки в дереве навыков. Однако я крайне рекомендую как можно больше отклоняться от того, что делаю я. Множество решений, сделанных мной относительно геймплея — это дело вкуса, и вы можете выбрать что-нибудь другое.

Например, вместо огромного дерева навыков можете выбрать огромную систему классов, позволяющую создавать множество комбинаций наподобие реализованных в Tree of Savior. Так что вместо построения дерева пассивных навыков вы можете реализовать все пассивные навыки, а затем построить собственную систему классов, использующих эти пассивные навыки.

Это лишь одна из идей; существует множество областей, в которых вы можете выбрать собственные вариации. Я дополняю эти туториалы упражнениями в том числе и затем, чтобы стимулировать людей работать с материалом самостоятельно, а не просто копировать существующее; мне кажется, что так люди учатся лучше. Поэтому когда вы видите возможность сделать что-то по-своему, то рекомендую вам так и поступать.

Размер игры


Теперь давайте приступим к Stage. Первое, что нам нужно (и это будет справедливо для всех комнат, а не только для Stage) — это создание пикселизированного внешнего вида комнаты в низком разрешении. Например, посмотрите на этот круг:


А теперь посмотрите на этот:


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

Один из способов его реализации — определение очень маленького разрешения по умолчанию, желательно, чтобы оно хорошо масштабировалось то целевого разрешения окна игры 1920x1080. Для этой игры я выберу 480x270, потому что это 1920x1080, делённое на 4. Чтобы изменить размер игры на это значение, нам нужно использовать файл conf.lua, который, как я объяснял в предыдущей части, является файлом конфигурации, определяющим параметры проекта LOVE по умолчанию, в том числе и разрешение окна, в котором запускается игры.

Кроме того, в этом файле я также определю две глобальные переменные gw и gh, соответствующие ширине и высоте базового разрешения, и переменные sx и sy, соответствующие масштабу, применённому к этому базовому разрешению. Файл conf.lua должен находиться в той же папке, что и файл main.lua, и при этом выглядеть вот так:

gw = 480 
gh = 270 
sx = 1
sy = 1

function love.conf(t)
    t.identity = nil                   -- Имя папки сохранения (строка)
    t.version = "0.10.2"                -- Версия LOVE, для которой сделана эта игра (строка)
    t.console = false                  -- Подключение консоли (boolean, только в Windows)
 
    t.window.title = "BYTEPATH" -- Заголовок окна (строка)
    t.window.icon = nil                -- Путь к файлу изображения, используемого как значок окна (строка)
    t.window.width = gw -- Ширина окна (число)
    t.window.height = gh -- Высота окна (число)
    t.window.borderless = false        -- Удаление всего визуального оформления границ окна (boolean)
    t.window.resizable = true          -- Разрешаем пользователю изменять размер окна (boolean)
    t.window.minwidth = 1              -- Минимальная ширина окна при возможности его изменения (число)
    t.window.minheight = 1             -- Минимальная высота окна при возможности его изменения (число)
    t.window.fullscreen = false        -- Включение полноэкранного режима (boolean)
    t.window.fullscreentype = "exclusive" -- Стандартный полный экран или режим рабочего стола для полного экрана (строка)
    t.window.vsync = true              -- Включение вертикальной синхронизации (boolean)
    t.window.fsaa = 0                  -- Число сэмплов при мультисэмпловом антиалиасинге  (число)
    t.window.display = 1               -- Индекс монитора, в котором должно отображаться окно (число)
    t.window.highdpi = false           -- Включение режима высокого dpi для окна на дисплее Retina (boolean)
    t.window.srgb = false              -- Включение гамма-коррекции sRGB при отрисовке на экране (boolean)
    t.window.x = nil                   -- Координата x позиции окна на указанном дисплее (число)
    t.window.y = nil                   -- Координата y позиции окна на указанном дисплее (число)
 
    t.modules.audio = true -- Включение аудиомодуля (boolean)
    t.modules.event = true             -- Включение модуля событий (boolean)
    t.modules.graphics = true          -- Включение модуля графики (boolean)
    t.modules.image = true             -- Включение модуля изображений (boolean)
    t.modules.joystick = true -- Включение модуля джойстика (boolean)
    t.modules.keyboard = true          -- Включение модуля клавиатуры (boolean)
    t.modules.math = true              -- Включение модуля математики (boolean)
    t.modules.mouse = true             -- Включение модуля мыши (boolean)
    t.modules.physics = true -- Включение модуля физики (boolean)
    t.modules.sound = true -- Включение модуля звука (boolean)
    t.modules.system = true            -- Включение модуля системы (boolean)
    t.modules.timer = true             -- Включение модуля таймера (boolean), при его отключении 0 delta time в love.update будет иметь значение 0
    t.modules.window = true            -- Включение модуля окон (boolean)
    t.modules.thread = true            -- Включение модуля потоков (boolean)
end

Если запустить игру сейчас, то вы увидите, что окно стало меньше.

Чтобы получить пикселизированный вид, при увеличении окна нам нужно проделать дополнительную работу. Если вы отрисуете круг в центре экрана (gw/2, gh/2) сейчас, вот так:


и отмасштабируете экран напрямую, вызвав love.window.setMode, например, с шириной 3*gw и высотой 3*gh, то получите примерно следующее:


Как вы видите, круг не отмасштабировался вместе с экраном и остался просто маленьким кругом. Также он не центрирован на экране, потому что gw/2 и gh/2 больше не является центром экрана при его увеличении в три раза. Мы хотим иметь возможность отрисовать маленький круг при базовом разрешении 480x270, чтобы при увеличении экрана до размера обычного монитора круг тоже масштабировался пропорционально (и пикселизированно), а его позиция тоже пропорционально оставалась той же. Простейший способ решения этой задачи — использование Canvas, который также называется в других движках буфером кадра (framebuffer) или целевым рендером (render target). Сначала мы создадим холст (canvas) с базовым разрешением в конструкторе класса Stage:

function Stage:new()
    self.area = Area(self)
    self.main_canvas = love.graphics.newCanvas(gw, gh)
end

При этом создастся холст с размером 480x270, на котором можно выполнять отрисовку:

function Stage:draw()
    love.graphics.setCanvas(self.main_canvas)
    love.graphics.clear()
        love.graphics.circle('line', gw/2, gh/2, 50)
        self.area:draw()
    love.graphics.setCanvas()
end

Способ отрисовки холста можно посмотреть на примере со страницы Canvas. Согласно этой странице, когда я хочу отрисовать что-нибудь на холсте, я должен вызвать love.graphics.setCanvas, который перенаправит все операции отрисовки на текущий заданный холст. Затем мы вызываем love.graphics.clear, который очищает содержимое этого холста в текущем кадре, потому что оно также было отрисовано в предыдущем кадре, а в каждом кадре мы хотим отрисовывать всё с нуля. Потом, нарисовав всё, что нам нужно, мы повторно используем setCanvas, но на этот раз ничего не передавая, чтобы наш целевой холст больше не был текущим и перенаправление операций отрисовки больше не выполнялось.

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

function Stage:draw()
    love.graphics.setCanvas(self.main_canvas)
    love.graphics.clear()
        love.graphics.circle('line', gw/2, gh/2, 50)
        self.area:draw()
    love.graphics.setCanvas()

    love.graphics.setColor(255, 255, 255, 255)
    love.graphics.setBlendMode('alpha', 'premultiplied')
    love.graphics.draw(self.main_canvas, 0, 0, 0, sx, sy)
    love.graphics.setBlendMode('alpha')
end

Мы просто используем love.graphics.draw для отрисовки холста на экране, а затем также обёртываем его вызовами love.graphics.setBlendMode, которые согласно странице Canvas из LOVE wiki используются для предотвращения неправильного смешения (blending). Если запустить программу сейчас, то вы увидите отрисованный круг.

Заметьте, что мы использовали для увеличения Canvas sx и sy. Пока эти переменные имеют значение 1, но если изменить их значения, например, на 3, то произойдёт следующее:


Мы ничего не видим! Но так случилось потому, что круг, находившийся в середине холста 480x270, теперь находится посередине холста 1440x810. Так как сам экран имеет размер 480x270, мы не можем увидеть Canvas целиком, потому что он больше экрана. Чтобы исправить это, мы можем создать в main.lua функцию resize, изменяющую при своём вызове sx, sy, а также размер самого экрана:

function resize(s)
    love.window.setMode(s*gw, s*gh) 
    sx, sy = s, s
end

Поэтому когда мы вызовем resize(3) в love.load, должно произойти следующее:


Приблизительно этого мы и добивались. Однако есть ещё одна проблема: круг выглядит размытым, а не пикселизированным.

Причина этого в том, что при увеличении или уменьшении в LOVE отрисовываемых объектов они используют FilterMode, и этот режим фильтрации по умолчанию имеет значение 'linear'. Так как мы хотим, чтобы игра имела пикселизированный внешний вид, мы должны изменить значение на 'nearest'. Вызов love.graphics.setDefaultFilter с аргументом 'nearest' в начале love.load должен устранить проблему. Ещё один аспект — нам нужно присвоить LineStyle значение 'rough'. Поскольку по умолчанию оно имеет значение 'smooth', примитивы LOVE будут отрисовываться с алиасингом, а это не подходит для создания пиксельного стиля. Если сделать всё это и запустить код снова, то экран должен выглядеть вот так:


Как раз тот ломаный и пикселизированный внешний вид, который нам нужен! Важнее всего то, что теперь мы можем использовать одно разрешение для создания всей игры. Если мы захотим создать объект в центре экрана, то мы можем сообщить, что его позиция x, y должна быть равна gw/2, gh/2, и вне зависимости от конечного разрешения объект всегда будет находиться в центре экрана. Это значительно упрощает процесс: значит, нам нужно только один раз беспокоиться о том, как выглядит игра и как распределены объекты на экране.

Упражнения по размеру игры


65. Посмотрите на раздел «Primary Display Resolution» в Опросе о конфигурации компьютера Steam. Самым популярным разрешением, используемым почти половиной пользователей Steam, является 1920x1080. Базовое разрешение нашей игры отлично масштабируется до него. Но вторым по популярности разрешением является 1366x768. 480x270 не масштабируется до него. Какие вы можете предложить варианты для работы с нестандартными разрешениями при переключении игры в полноэкранный режим?

66. Выберите игру из своей коллекции, в которой используется такая же или подобная техника (увеличение малого базового разрешения). Обычно она используется в играх с пиксельной графикой. Каким является базовое разрешение игры? Как игра справляется с нестандартными разрешениями, в которые нельзя правильно вписать базовое разрешение? Несколько раз измените разрешение рабочего стола, каждый раз запуская игру с разными разрешениями, чтобы увидеть изменения и понять, как игра обрабатывает вариативность.

Камера


Во всех трёх комнатах используется камера, поэтому логично будет сейчас рассмотреть её. Во второй части туториала мы использовали для таймеров библиотеку hump. В этой библиотеке также есть полезный модуль камеры, который мы тоже используем. Однако я использую немного модифицированную версию, имеющую функцию тряски экрана. Файлы можно скачать отсюда. Поместите файл camera.lua в папку библиотеки hump (и перезапишите имеющуюся версию camera.lua), а затем добавьте require модуля камеры в main.lua. Поместите файл Shake.lua в папку objects.

(Дополнительно можно также использовать написанную мною библиотеку, в которой уже имеется весь этот функционал. Я написал эту библиотеку уже после завершения работы над туториалом, поэтому она не будет в нём использоваться. Если вы решите использовать эту библиотеку, то можете продолжить работу с туториалом, но переносить некоторые аспекты для использования функций этой библиотеки.)

После добавления камеры нам понадобится следующая функция:

function random(min, max)
    local min, max = min or 0, max or 1
    return (min > max and (love.math.random()*(min - max) + max)) or (love.math.random()*(max - min) + min)
end

Она позволит получать случайное число между любыми двумя числами. Она необходима, потому что ею пользуется файл Shake.lua. После определения этой функции в utils.lua попробуйте сделать нечто подобное:

function love.load()
    ...
    camera = Camera()
    input:bind('f3', function() camera:shake(4, 60, 1) end)
    ...
end

function love.update(dt)
    ...
    camera:update(dt)
    ...
end

А затем в классе Stage:

function love.load()
    ...
    camera = Camera()
    input:bind('f3', function() camera:shake(4, 60, 1) end)
    ...
end

function love.update(dt)
    ...
    camera:update(dt)
    ...
end

Вы увидите, что после нажатия f3 экран начнёт трястись:


Функция тряски основана на функции, описанной в этой статье; она получает амплитуду (в пикселях), частоту и длительность. Тряска экрана будет выполняться с постепенным затуханием, начиная с амплитуды, в течение указанного количества секунд и указанной частотой. Чем выше частоты, тем активнее будет колебаться экран между двумя пределами (amplitude, -amplitude); низкие частоты приводят к противоположному.

Важно также заметить, что камера пока не привязана к определённой точке, поэтому при тряске её будет бросать во всех направлениях, то есть после завершения тряски она будет центрирована на другом месте, что и видно на предыдущей gif-анимации.

Один из способов решения этой проблемы заключается в центрировании камеры, которое можно реализовать в функции camera:lockPosition. В модифицированной версии модуля камеры я изменил все функции движения камеры так, чтобы они сначала получали аргумент dt. И это будет выглядеть вот так:

function Stage:update(dt)
    camera.smoother = Camera.smooth.damped(5)
    camera:lockPosition(dt, gw/2, gh/2)

    self.area:update(dt)
end

Для сглаживания камеры установлен режим damped со значением 5. Эти параметры я вывел путём проб и ошибок, но в целом это позволяет камере фокусироваться на целевой точке плавным и приятным способом. Я поместил этот код внутрь комнаты Stage потому, что мы сейчас работаем с комнатой Stage, а в этой комнате камера всегда должна будет центрироваться на середине экрана и никогда не двигаться (кроме моментов тряски экрана). В результате мы получаем следующее:


Для всей игры мы будем использовать одну глобальную камеру, потому что нет нужды создавать отдельные экземпляры камеры для каждой комнаты. В комнате Stage камера не будет использоваться никак иначе, кроме как для тряски, поэтому на этом я пока остановлюсь. В комнатах Console и SkillTree камера будет использоваться более сложным образом, но мы дойдём до этого позже.

Физика игрока


Теперь у нас есть всё необходимое, чтобы приступить к самой игре. Мы начнём с объекта Player. Создайте в папке objects новый файл с названием Player.lua, который будет выглядеть следующим образом:

Player = GameObject:extend()

function Player:new(area, x, y, opts)
    Player.super.new(self, area, x, y, opts)
end

function Player:update(dt)
    Player.super.update(self, dt)
end

function Player:draw()

end

Таким способом по умолчанию должен создаваться новый класс игровых объектов. Все они будут наследовать от GameObject и иметь одинаковую структуру конструктора, функций update и draw. Теперь мы можем создать экземпляр этого объекта Player в комнате Stage следующим образом:

function Stage:new()
    ...
    self.area:addGameObject('Player', gw/2, gh/2)
end

Чтобы проверить, как работает создание экземпляров, и убедиться, что объект Player обновляется и отрисовывается Area, мы можем просто отрисовать в его позиции круг:

function Player:draw()
    love.graphics.circle('line', self.x, self.y, 25)
end

Это должно дать нам круг в центре экрана. Интересно заметить, что вызов addGameObject возвращает созданный объект, поэтому мы можем хранить ссылку на игрока внутри self.player Stage, и при необходимости включать событие смерти объекта Player привязанной клавишей:

function Stage:new()
    ...
    self.player = self.area:addGameObject('Player', gw/2, gh/2)
    input:bind('f3', function() self.player.dead = true end)
end

При нажатии на клавишу f3 объект Player должен умирать, то есть круг должен переставать отрисовываться. Это происходит в результате того, как мы настроили код объекта Area в предыдущей части. Также важно заметить, что если мы решим хранить ссылки, возвращаемые addGameObject таким образом, то если мы не зададим переменную, в которой хранится ссылка на nil, то этот объект никогда не будет удаляться. Кроме того, важно не забывать присваивать ссылкам значения nil (в нашем случае строкой self.player = nil), если нужно, чтобы объект на самом деле удалялся из памяти (помимо того, что его атрибуту присваивается dead значение true).



Теперь перейдём к физике. Игрок (как и враги, снаряды и ресурсы) будет физическим объектом. Для этого я использую интеграцию box2d в LOVE, но в целом это необязательно для нашей игры, потому что она не получит ничего полезного от использования такого полного физического движка, как box2d. Я использую его потому, что привык к нему. Но я рекомендую вам или попробовать написать свои процедуры обработки коллизий (что будет очень просто для подобной игры), или использовать библиотеку, которая займётся этим за вас.

В туториале я буду использовать созданную мной библиотеку windfield, которая делает использование box2d с LOVE намного проще. Для LOVE есть и другие библиотеки, тоже обрабатывающие коллизии: HardonCollider или bump.lua.

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

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

Как бы то ни было, вы можете скачать библиотеку windfield и добавить её require в файл main.lua. Согласно её документации, в ней есть две основных концепции — World и Collider. World — это физический мир, в котором происходит симуляция, а Collider — это физический объект, симулируемый внутри этого мира. То есть нашей игре будет нужно подобие физического мира, а игрок будет коллайдером внутри этого мира.

Мы создадим мир внутри класса Area, добавив вызов addPhysicsWorld:

function Area:addPhysicsWorld()
    self.world = Physics.newWorld(0, 0, true)
end

Так мы зададим атрибут .world области, содержащий физический мир. Также нам нужно обновлять этот мир (и при необходимости отрисовывать его в целях отладки), если он существует:

function Area:update(dt)
    if self.world then self.world:update(dt) end

    for i = #self.game_objects, 1, -1 do
        ...
    end
end

function Area:draw()
    if self.world then self.world:draw() end
    for _, game_object in ipairs(self.game_objects) do game_object:draw() end
end

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

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

function Stage:new()
    self.area = Area(self)
    self.area:addPhysicsWorld()
    ...
end

И теперь, когда у нас есть мир, мы можем добавить в него коллайдер Player:

function Player:new(area, x, y, opts)
    Player.super.new(self, area, x, y, opts)

    self.x, self.y = x, y
    self.w, self.h = 12, 12
    self.collider = self.area.world:newCircleCollider(self.x, self.y, self.w)
    self.collider:setObject(self)
end

Заметьте, как пригождается здесь то, что у игрока есть ссылка на Area, потому что таким образом мы можем иметь доступ к World объекта Area для добавления в него новых коллайдеров. Такой паттерн (доступа к сущностям внутри Area) часто повторяется, например, я сделал так, что все объекты GameObject имеют одинаковый конструктор, в котором они получают ссылку на объект Area, которому принадлежат.

В конструкторе Player мы с помощью атрибутов w и h определили его ширину и высоту равными 12. Далее мы добавляем новый CircleCollider с радиусом, равным ширине. Пока не очень логично создавать коллайдер в виде круга, если мы определили ширину и высоту, но это пригодится в будущем, потому что когда мы добавим разные типы кораблей, то визуально все корабли будут иметь разную ширину и высоту, но физически коллайдер всегда будет кругом, чтобы все корабли имели одинаковые шансы и обладали предсказуемым для игрока поведением.

После добавления коллайдера мы вызываем функцию setObject, которая привязывает объект Player к только что созданному Collider. Это полезно потому, что при столкновении двух коллайдеров мы можем получать информацию с точки зрения коллайдеров, а не объектов. Например, если Player сталкивается с Projectile, у нас будет два коллайдера, представляющих Player и Projectile, но у нас может и не быть самих объектов. setObjectgetObject) позволяет нам задавать и извлекать объект, к которому принадлежит Collider.

Теперь мы, наконец, можем отрисовать Player согласно его размеру:

function Player:draw()
    love.graphics.circle('line', self.x, self.y, self.w)
end

Если запустить игру сейчас, то мы увидим небольшой круг, представляющий игрока:


Упражнения с физикой Player


Если вы решили создавать коллизии самостоятельно или выбрали одну из альтернативных библиотек коллизий/физики, то вам не нужно выполнять эти упражнения.

67. Измените гравитацию оси y физического мира на 512. Что происходит с объектом Player?

68. Что делает третий аргумент вызова .newWorld и что происходит, если задать ему значение false? Есть ли преимущества задания значения true/false? Какие?

Движение игрока


Движение игрока в этой игре действует следующим образом: существует постоянная скорость, с которой движется игрок, и угол, который можно менять, удерживая «влево» или «вправо». Чтобы реализовать это, нам нужно несколько переменных:

function Player:new(area, x, y, opts)
    Player.super.new(self, area, x, y, opts)

    ...

    self.r = -math.pi/2
    self.rv = 1.66*math.pi
    self.v = 0
    self.max_v = 100
    self.a = 100
end

Здесь я определяю r как угол, под которым движется игрок. Сначала он имеет значение -math.pi/2, то есть указывает вверх. Углы в LOVE указываются по часовой стрелке, то есть math.pi/2 — это вниз, а -math.pi/2 — вверх (а 0 — это вправо). Переменная rv представляет собой скорость изменения угла при нажатии игроком «влево» или «вправо». Затем у нас есть v, обозначающая скорость игрока, и max_v, обозначающая максимальную скорость игрока. Последний атрибут — это a, представляющий собой ускорение игрока. Все значения получены методом проб и ошибок.

Для обновления позиции игрока с учётом всех этих переменных мы можем сделать нечто подобное:

function Player:update(dt)
    Player.super.update(self, dt)

    if input:down('left') then self.r = self.r - self.rv*dt end
    if input:down('right') then self.r = self.r + self.rv*dt end

    self.v = math.min(self.v + self.a*dt, self.max_v)
    self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r))
end

Первые две строки определяют то, что происходит при нажатии на клавиши «влево» и «вправо». Важно заметить, что согласно используемой нами библиотеке Input эти привязки должны быть определены заранее, и я сделал это в файле main.lua (так как мы используем для всего глобальный объект Input):

function love.load()
    ...
    input:bind('left', 'left')
    input:bind('right', 'right')
    ...
end

И когда игрок нажимает «влево» или «вправо», атрибут r, соответствующий углу игрока, изменяется на 1.66*math.pi радиан в соответствующем направлении. Ещё важно здесь заметить, что это значение умножается на dt, то есть это значение управляется на посекундной основе. То есть скорость изменения угла измеряется в 1.66*math.pi радиан в секунду. Это результат того, как работает игровой цикл, разобранный нами в первой части туториала.

После этого мы задаём атрибут v. Он немного более сложный, но если вы делали это на других языках, то он должен быть вам знаком. Исходное вычисление имеет вид self.v = self.v + self.a*dt, то есть мы просто увеличиваем скорость на величину ускорения. В этом случае мы увеличиваем её на 100 в секунду. Но мы также определили атрибут max_v, который должен ограничивать максимально допустимую скорость. Если мы не ограничим её, то self.v = self.v + self.a*dt будет увеличивать v бесконечно, и наш игрок превратится в Соника. А нам этого не нужно! Один из способов предотвратить это заключается в следующем:

function Player:update(dt)
    ...

    self.v = self.v + self.a*dt
    if self.v >= self.max_v then
        self.v = self.max_v
    end

    ...
end

При этом когда v становится больше max_v, то мы ограничиваем его этим значением, а не превышаем его. Ещё один краткий способ записи этого заключается в использовании функции math.min, которая возвращает минимальное значение среди всех переданных ей аргументов. В нашем случае мы передаём результат self.v + self.a*dt и self.max_v, то есть если результат сложения будет больше max_v, то math.min вернёт max_v, так как оно меньше суммы. Это очень распространённый и полезный паттерн в Lua (да и в других языках программирования тоже).

Наконец, мы с помощью setLinearVelocity задаём скорость Collider по x и y равной атрибуту v, умноженному на соответствующую величину в зависимости от угла объекта. В общем случае, когда мы хотим переместить что-то в каком-то направлении, и у нас есть для этого угол, то стоит использовать cos для перемещения по оси x и sin для движения по оси y. Это тоже очень распространённый паттерн в разработке 2D-игр. Я не буду объяснять этого, предположив, что вы разобрались с этим в школе (если это не так, то поищите в Google основы тригонометрии).

Последнее изменение мы можем внести в класс GameObject, и оно довольно простое. Так как мы используем физический движок, то у нас в каких-то переменных хранятся два представления, например, скорость и позиция. Мы получаем позицию и скорость игрока с помощью атрибутов x, y и v, а позицию и скорость Collider — с помощью getPosition и getLinearVelocity. Логично будет синхронизировать эти два представления, и один из способов добиться этого автоматически — изменив родительский класс всех игровых объектов:

function GameObject:update(dt)
    if self.timer then self.timer:update(dt) end
    if self.collider then self.x, self.y = self.collider:getPosition() end
end

Здесь происходит следующее: если у объекта определён атрибут collider,
то x и y будут установлены в позицию этого коллайдера. И когда позиция коллайдера изменяется, представление этой позиции в самом объекте тоже будет меняться соответствующим образом.

Если вы запустите программу сейчас, то увидите следующее:

GIF

Итак, мы видим, что объект Player обычным образом движется по экрану и меняет направление при нажатии клавиш «влево» или «вправо». Здесь также важна одна подробность: в объекте Area через вызов world:draw() отрисовывается Collider. На самом деле мы хотим отрисовывать не только коллайдеры, поэтому логично будет закомментировать эту строку и отрисовывать непосредственно объект Player:

function Player:draw()
    love.graphics.circle('line', self.x, self.y, self.w)
end

Последняя полезная вещь, которую мы можем сделать — это визуализация направления, в котором «смотрит» игрок. Это можно сделать, просто отрисовывая линию из позиции игрока в сторону, куда он направлен:

function Player:draw()
    love.graphics.circle('line', self.x, self.y, self.w)
    love.graphics.line(self.x, self.y, self.x + 2*self.w*math.cos(self.r), self.y + 2*self.w*math.sin(self.r))
end

И это будет выглядеть следующим образом:

GIF

Это тоже основы тригонометрии, здесь используется та же идея, которую мы применяли раньше. Когда мы хотим получить позицию B, находящуюся в distance единицах от позиции A, такую, что позиция B находится под определённым углом angle относительно позиции A, то паттерн будет примерно таким: bx = ax + distance*math.cos(angle) и by = ay + distance*math.sin(angle). Такое очень часто применяется при разработке 2D-игр (по крайней мере, мне так кажется) и интуитивное понимание этого паттерна будет вам полезно.

Упражнения с движением игрока


69. Преобразуйте следующие углы в градусы (мысленно) и скажите, к какому квадранту они относятся (верхнему левому, верхнему правому, нижнему левому или нижнему правому). Не забудьте, что в LOVE углы считаются по часовой стрелке, а не против, как нас учили в школе.

math.pi/2
math.pi/4
3*math.pi/4
-5*math.pi/6
0
11*math.pi/12
-math.pi/6
-math.pi/2 + math.pi/4
3*math.pi/4 + math.pi/3
math.pi

70. Обязан ли существовать атрибут ускорения a? Как будет выглядеть функция update игрока, если бы его не существовало? Есть ли вообще преимущества у его существования?

71. Получите позицию (x, y) точки B из позиции A, если используемый угол будет равен -math.pi/4, а расстояние — 100.


72. Получите позицию (x, y) точки C из позиции B, если используемый угол равен math.pi/4, а расстояние равно 50. Позиции A и B, а также расстояние и угол между ними остаются теми же, что и в предыдущем упражнении.


73. Исходя из предыдущих двух упражнений, скажите, какой общий паттерн используется, когда нужно добраться от точки A до некоторой точки C и допустимо использовать только множество промежуточных точек, которых можно достичь через углы и расстояния?

74. В синхронизации представлений атрибутов Player и атрибутов Collider упоминаются позиции и скорости, но как насчёт поворота? У коллайдера есть поворот, к которому можно получить доступ через getAngle. Почему бы не синхронизировать и его через атрибут r?

Сборка мусора


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

Сборщик мусора работает следующим образом — когда на объект не указывает ни одна ссылка, то он удаляется. То есть если у вас есть таблица, на которую ссылается только переменная a, то при выполнении присвоения a = nil сборщик мусора поймёт, что на таблицу, на которую ссылались, больше никто не ссылается, поэтому её можно удалить из памяти в будущем цикле сборки мусора. Проблема возникает, когда на один объект ссылаются несколько раз и вы забываете убрать ссылки из всех этих точек.

Например, когда мы создаём новый объект с помощью addGameObject, то объект добавляется в список .game_objects. Это считается одной ссылкой, указывающей на этот объект. Однако также в этой функции возвращается сам объект. Поэтому ранее мы делали что-то наподобие self.player = self.area:addGameObject('Player', ...), то есть кроме хранения ссылки на объект в списке внутри объекта Area мы также храним ссылку на него в переменной self.player. То есть когда мы говорим self.player.dead и объект Player удаляется из списка игровых объектов в объекте Area, он всё равно не может быть удалён из памяти, потому что на него по-прежнему указывает self.player. То есть в этом случае чтобы действительно удалить объект Player из памяти, нам нужно и присвоить dead значение true, и затем выполнить self.player = nil.

Это только один из примеров того, что может произойти, но такая проблема встречается повсеместно. Особенно внимательными нужно быть при использовании сторонних библиотек. Например, в написанной мной физической библиотеке есть функция setObject, в которую мы передаём объект, чтобы Collider хранил ссылку на него. Если объект умирает, будет ли он удалён из памяти? Нет, поскольку Collider всё ещё хранит на него ссылку. Та же проблема, только в других условиях. Один из способов решения проблемы заключается в явном удалении объектов с помощью создаваемой для них функции destroy, которая будет заниматься устранением ссылок.

То есть ко всем объектам мы можем добавить следующее:

function GameObject:destroy()
    self.timer:destroy()
    if self.collider then self.collider:destroy() end
    self.collider = nil
end

Теперь у всех объектов по умолчанию есть эта функция destroy. Эта функция вызывает функции destroy объекта EnhancedTimer, а также функцию коллайдера (Collider). Эти функции выполняют разыменование элементов, которые пользователь вероятно захочет удалить из памяти. Например, в Collider:destroy одним из действий является вызов self:setObject(nil): так как мы хотим уничтожить этот объект, то нам не нужно, чтобы Collider больше хранил на него ссылку.

Также мы можем изменить функцию обновления Area следующим образом:

function Area:update(dt)
    if self.world then self.world:update(dt) end

    for i = #self.game_objects, 1, -1 do
        local game_object = self.game_objects[i]
        game_object:update(dt)
        if game_object.dead then 
            game_object:destroy()
            table.remove(self.game_objects, i) 
        end
    end
end

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

function Area:destroy()
    for i = #self.game_objects, 1, -1 do
        local game_object = self.game_objects[i]
        game_object:destroy()
        table.remove(self.game_objects, i)
    end
    self.game_objects = {}

    if self.world then
        self.world:destroy()
        self.world = nil
    end
end

При уничтожении Area мы сначала уничтожаем все объекты в нём, а затем уничтожаем физический мир, если он существует. Теперь мы можем изменить комнату Stage, чтобы подстроиться под эти действия:

function Stage:destroy()
    self.area:destroy()
    self.area = nil
end

Также мы можем изменить функцию gotoRoom:

function gotoRoom(room_type, ...)
    if current_room and current_room.destroy then current_room:destroy() end
    current_room = _G[room_type](...)
end

Мы проверяем, является ли current_room существующей переменной, и содержит ли она атрибут destroy (по сути, мы запрашиваем, содержит ли она настоящую комнату); если это так, то мы вызываем функцию destroy. А затем мы выполняем переход к целевой комнате.

Также важно помнить, что после добавления функции destroy все объекты должны следовать такому шаблону:

NewGameObject = GameObject:extend()

function NewGameObject:new(area, x, y, opts)
    NewGameObject.super.new(self, area, x, y, opts)
end

function NewGameObject:update(dt)
    NewGameObject.super.update(self, dt)
end

function NewGameObject:draw()

end

function NewGameObject:destroy()
    NewGameObject.super.destroy(self)
end

Всё это конечно хорошо, но как нам проверить, на самом ли деле мы удаляем элементы из памяти? Я нашёл ответ в одном понравившемся мне посте, в котором также есть относительно простое решение для отслеживания утечек:

function count_all(f)
    local seen = {}
    local count_table
    count_table = function(t)
        if seen[t] then return end
            f(t)
	    seen[t] = true
	    for k,v in pairs(t) do
	        if type(v) == "table" then
		    count_table(v)
	        elseif type(v) == "userdata" then
		    f(v)
	        end
	end
    end
    count_table(_G)
end

function type_count()
    local counts = {}
    local enumerate = function (o)
        local t = type_name(o)
        counts[t] = (counts[t] or 0) + 1
    end
    count_all(enumerate)
    return counts
end

global_type_table = nil
function type_name(o)
    if global_type_table == nil then
        global_type_table = {}
            for k,v in pairs(_G) do
	        global_type_table[v] = k
	    end
	global_type_table[0] = "table"
    end
    return global_type_table[getmetatable(o) or 0] or "Unknown"
end

Я не буду разбирать этот код, потому что он объяснён в посте, но мы добавим его в main.lua, а затем добавим внутрь love.load следующее:

function love.load()
    ...
    input:bind('f1', function()
        print("Before collection: " .. collectgarbage("count")/1024)
        collectgarbage()
        print("After collection: " .. collectgarbage("count")/1024)
        print("Object count: ")
        local counts = type_count()
        for k, v in pairs(counts) do print(k, v) end
        print("-------------------------------------")
    end)
    ...
end

Что делает этот код: когда пользователь нажимает f1, он показывает количество памяти до и после цикла сборки мусора, а также выводит типы объектов, находящихся в памяти. Это полезно, потому что сейчас мы можем, например, создать новую комнату Stage с объектами внутри, удалить её, а затем убедиться, что память осталась такой же (или почти такой же, хе-хе), как и до создания Stage. Если она осталась такой же, то утечки памяти отсутствуют, а если нет, то значит, у нас есть проблемы и необходимо искать их источники.


Упражнения по сборке мусора


75. Привяжите клавишу f2 к созданию и активации новой комнаты Stage с помощью вызова gotoRoom.

76. Привяжите клавишу f3 к уничтожению текущей комнаты.

77. Проверьте несколько раз количество занятой памяти с помощью нажатия f1. После этого понажимайте несколько раз клавиши f2 и f3, чтобы создать и уничтожить новые комнаты. Теперь снова проверьте количество занятой памяти, несколько раз нажав f1. Осталось ли количество памяти таким, как было раньше, или его стало больше?

78. Задайте комнате Stage создание 100 объектов Player вместо одного, выполнив нечто подобное:

function Stage:new()
    ...
    for i = 1, 100 do 
        self.area:addGameObject('Player', gw/2 + random(-4, 4), gh/2 + random(-4, 4))
    end
end

Также измените функцию обновления Player так, чтобы объекты Player больше не двигались (закомментируйте код движения).Теперь повторите процесс из предыдущего упражнения. Изменилось ли количество занятой памяти? Изменились ли общие результаты?



Часть 6: Основы класса Player


Введение


В этом разделе мы сосредоточимся на добавлении функционала в класс Player. Сначала мы рассмотрим атаку игрока и объект Projectile. Затем мы сосредоточимся на двух основных характеристиках игрока: Boost и Cycle/Tick. И, наконец, мы начнём создавать первую часть контента, который будет добавлен в игру, а именно различные корабли игрока. Начиная с этой части мы сосредоточимся только на связанных с геймплеем моментах (предыдущие пять частей были предварительной подготовкой).

Атака игрока


Игрок в этой игре атакует следующим образом: через каждые n секунд атака срабатывает и запускается автоматически. В конце концов у нас получится 16 типов атак, но большинство из них будет связано с выстреливанием снарядов в направлении взгляда корабля игрока. Например, это атака наводящимися ракетами:

GIF

Эта атака стреляет быстрее, но под случайными углами:

GIF

Атаки и снаряды будут обладать всевозможными свойствами и на них влияют различные аспекты, но сама их основа всегда одинакова.

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

function Player:new()
    ...
    self.timer:every(0.24, function()
        self:shoot()
    end)
end

Таким образом мы будем вызывать функцию shoot каждые 0,24 секунды, а внутри этой функции мы расположим код, который будет заниматься созданием объекта снаряда.

Теперь мы можем задать то, что будет происходить внутри функции shoot. Для начала у нас будет небольшой эффект, обозначающий сделанный выстрел. На практике я выработал следующее правило: при создании или удалении из игры сущности должен появляться сопровождающий эффект, который будет скрывать то, что сущность просто появляется/исчезает с экрана в никуда. Кроме того, он должен улучшать внешний вид в целом.

Для создания этого нового эффекта нам сначала нужно создать новый игровой объект под названием ShootEffect (теперь вы уже должны знать, как это делается). Этот эффект должен быть простым квадратом, остающимся на экране краткий промежуток времени рядом с позицией, в которой был создан снаряд. Простейший способ достичь этого — использовать нечто подобное:

function Player:shoot()
    self.area:addGameObject('ShootEffect', self.x + 1.2*self.w*math.cos(self.r), 
    self.y + 1.2*self.w*math.sin(self.r))
end

function ShootEffect:new(...)
    ...
    self.w = 8
    self.timer:tween(0.1, self, {w = 0}, 'in-out-cubic', function() self.dead = true end)
end

function ShootEffect:draw()
    love.graphics.setColor(default_color)
    love.graphics.rectangle('fill', self.x - self.w/2, self.y - self.w/2, self.w, self.w)
end

И это будет выглядеть следующим образом:

GIF

Код эффекта достаточно прост. Это просто квадрат с шириной 8, живущий 0,1 секунды, ширина которого в течение этого времени переходит к 0 с помощью функции tween. Пока у нас есть одна проблема: позиция эффекта статична и не следует за игроком. Это кажется незначительной деталью, потому что длительность эффекта мала, но попробуйте изменить её до 0,5 секунд или больше, и вы увидите, что я имею в виду.

Один из способов решения проблемы заключается в передаче объекту ShootEffect как ссылки объекта Player. Таким образом объект ShootEffect сможет синхронизировать свою позицию с объектом Player:

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})
end

function ShootEffect:update(dt)
    ShootEffect.super.update(self, dt)
    if self.player then 
    	self.x = self.player.x + self.d*math.cos(self.player.r) 
    	self.y = self.player.y + self.d*math.sin(self.player.r) 
  	end
end

function ShootEffect:draw()
    pushRotate(self.x, self.y, self.player.r + math.pi/4)
    love.graphics.setColor(default_color)
    love.graphics.rectangle('fill', self.x - self.w/2, self.y - self.w/2, self.w, self.w)
    love.graphics.pop()
end

Атрибуту player объекта ShootEffect через таблицу opts в функции игрока shoot присваивается значение self. Это значит, что доступ к ссылке на объект Player можно получить через self.player в объекте ShootEffect. В общем случае мы передаём ссылки на объекты друг другу таким образом, потому что обычно объекты создаются из функции других объектов, то есть передавая self, мы получаем то, что нам нужно. Кроме того, мы назначаем атрибуту d значение расстояния, на котором эффект должен появиться от центра объекта Player. Это тоже реализуется с помощью таблицы opts.

Затем в функции обновления ShootEffect мы присваиваем его позиции значение позиции игрока. Всегда важно проверять, задана ли переменная, к которой мы будем получать доступ (if self.player then), потому что если это не так, то произойдёт ошибка. Также очень часто в процессе дальнейшего создания игры будут возникать случаи, когда на сущности будут ссылаться откуда-то ещё, и мы будем пытаться получить доступ к их значениям, но поскольку они уже умерли, эти значения уже не будут заданы, и мы получим ошибку. Важно не забывать об этом, ссылаясь на сущности внутри друг друга таким образом.

Наконец, последнее, что здесь нужно сделать — синхронизировать квадрат с углом игрока, а затем ещё и повернуть его на 45 градусов, чтобы он выглядел красивее. Для этого мы используем функцию pushRotate, которая выглядит так:

function pushRotate(x, y, r)
    love.graphics.push()
    love.graphics.translate(x, y)
    love.graphics.rotate(r or 0)
    love.graphics.translate(-x, -y)
end

Это простая функция, передающая переходы в стек переходов. В сущности, она поворачивает всё на r вокруг точки x, y, пока мы не вызовем love.graphics.pop. То есть в этом примере у нас есть квадрат и мы поворачиваем его вокруг его центра на угол игрока плюс на 45 градусов (pi/4 радиан). Ради полноты можно показать другую версию этой функции, также содержащую и масштабирование:

function pushRotateScale(x, y, r, sx, sy)
    love.graphics.push()
    love.graphics.translate(x, y)
    love.graphics.rotate(r or 0)
    love.graphics.scale(sx or 1, sy or sx or 1)
    love.graphics.translate(-x, -y)
end

Эти функции довольно полезны и мы будем использовать их на протяжении всей игры, так что поэкспериментируйте с ними и хорошо разберитесь в них!

Упражнения с атаками игрока


80. Пока мы только используем первоначальный вызов таймера в конструкторе игрока, заставляющий вызывать функцию shoot каждые 0,24 секунды. Допустим в Player существует атрибут self.attack_speed, который каждые 5 секунд изменяется на случайное значение в интервале от 1 до 2:

function Player:new(...)
    ...

    self.attack_speed = 1
    self.timer:every(5, function() self.attack_speed = random(1, 2) end)

    self.timer:every(0.24, function() self:shoot() end)

Как вы измените объект игрока так, чтобы вместо стрельбы каждые 0,24 секунды он стрелял каждые 0.24/self.attack_speed секунд? Учтите, что простое изменение в вызове every, вызывающем функцию shoot, не сработает.

81. В предыдущей части мы рассмотрели сборку мусора и рассказали о том, что забытые ссылки могут быть опасны и приводить к утечкам. В этой части на примере объектов Player и ShootEffect я объяснил, что мы можем ссылаться на объекты из друг друга. В этом случае, когда ShootEffect является недолговечным объектом, содержащим внутри себя ссылку на Player, должны ли мы беспокоиться о разыменовании ссылки на Player, чтобы этот объект можно было удалить сборщиком мусора, или это необязательно? В более общем случае, когда нам нужно заботиться о разыменовании объектов, которые ссылаются друг на друга таким образом?

82. С помощью pushRotate поверните игрока вокруг его центра на 180 градусов. Это должно выглядеть вот так:

GIF

83. С помощью pushRotate поверните на 90 градусов линию, указывающую в направлении движения игрока, вокруг её центра. Это должно выглядеть следующим образом:

GIF

84. С помощью Using pushRotate поверните на 90 градусов линию, указывающую в направлении движения игрока, вокруг центра игрока. Это должно выглядеть так:

GIF

85. С помощью pushRotate поверните объект ShootEffect вокруг центра игрока на 90 градусов (сверх того, что он уже поворачивается относительно направления игрока). Это должно выглядеть следующим образом:

GIF

Снаряд игрока


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

function Player:shoot()
    ...
    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

И здесь нет ничего неожиданного. Для задания начальной позиции Projectile мы используем ту же переменную d, которая была определена ранее, а затем передаём угол игрока как атрибут r. Заметьте, что в отличие от объекта ShootEffect, объект Projectile при создании не требует ничего, кроме угла игрока, поэтому нам не нужно передавать игрока как ссылку.

Теперь займёмся конструктором Projectile. Объект Projectile тоже будет иметь коллайдер-круг (как и Player), скорость и направлении движения:

function Projectile:new(area, x, y, opts)
    Projectile.super.new(self, area, x, y, opts)

    self.s = opts.s or 2.5
    self.v = opts.v or 200

    self.collider = self.area.world:newCircleCollider(self.x, self.y, self.s)
    self.collider:setObject(self)
    self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r))
end

Атрибут s представляет собой радиус коллайдера, он не обозначен как r, потому что эту переменную мы уже используем для угла движения. В общем случае для задания размеров объектов я буду использовать переменные w, h, r или s. Первые две — когда объект является прямоугольником, а последние две — когда он круг. В случаях, когда переменная r уже использована для направления (как в этом случае) в качестве радиуса будет использоваться s. Эти атрибуты в основном используются для визуализации, потому что чаще всего эти объекты уже имеют коллайдер, выполняющий всю работу, связанную с коллизиями.

Ещё один аспект, который мы здесь используем, это описанная ранее конструкция opts.attribute or default_value. Благодаря тому, как or работает в Lua, мы можем использовать эту конструкцию в качестве быстрого способа передачи следующего:

if opts.attribute then
    self.attribute = opts.attribute
else 
    self.attribute = default_value 
end

Мы проверяем, существует ли атрибут, а затем задаём этому атрибуту какую-нибудь переменную, а если она не существует, то мы присваиваем ему значение по умолчанию. В случае self.s ему будет присвоено значение opts.s, если оно определено, в противном случае ему присваивается значение 2.5. То же самое относится к self.v. Наконец, мы задаём скорость снаряда с помощью setLinearVelocity, указывая начальную скорость снаряда и угол, передаваемый из Player. Здесь используется тот же подход, что и при движении Player, поэтому вы должны уже в этом разбираться.

Если мы теперь будем обновлять и отрисовывать снаряд следующим образом:

function Projectile:update(dt)
    Projectile.super.update(self, dt)
    self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r))
end

function Projectile:draw()
    love.graphics.setColor(default_color)
    love.graphics.circle('line', self.x, self.y, self.s)
end

то это будет выглядеть так:

GIF

Упражнения со снарядами игрока


86. В функции игрока shoot измените размер/радиус создаваемых снарядов на 5, а их скорость на 150.

87. Измените функцию shoot так, чтобы она создавала не один, а три снаряда, причём два из них должны создаваться с углами направления игрока +-30 градусов. Это должно выглядеть так:

GIF

88. Измените функцию shoot, чтобы она создавала не один, а три снаряда, чтобы позиция каждого бокового снаряда была смещена относительно центрального на 8 пикселей. Это должно выглядеть так:

GIF

89. Измените начальную скорость снаряда на 100 и заставьте его ускоряться до 400 в течение 0,5 секунды после создания.

Смерть игрока и снаряда


Теперь, когда Player может двигаться и совершать простейшие атаки, нужно озаботиться дополнительными правилами игры. Одно из этих правил заключается в том, что если Player коснётся края игровой области, то он должен умереть. То же относится и к снарядам, потому что пока они создаются, но никогда не уничтожаются, и в какой-то момент их станет так много, что игра начнёт сильно тормозить.

Давайте начнём с объекта Projectile:

function Projectile:update(dt)
    ...

    if self.x < 0 then self:die() end
    if self.y < 0 then self:die() end
    if self.x > gw then self:die() end
    if self.y > gh then self:die() end
end

Мы знаем, что центр игровой области расположен в gw/2, gh/2, то есть верхний левый угол находится в 0, 0, а нижний правый — в gw, gh. И нам достаточно лишь добавить несколько условных конструкций в функцию update снаряда, проверяющих его позицию, и если он находится за границами, то мы должны вызвать функцию die.

Та же логика относится к объекту Player:

function Player:update(dt)
    ...

    if self.x < 0 then self:die() end
    if self.y < 0 then self:die() end
    if self.x > gw then self:die() end
    if self.y > gh then self:die() end
end

Теперь перейдём к функции die. Она очень проста и в сущности единственное, что она делает — присваивает атрибуту dead сущности значение true, а затем создаёт визуальные эффекты. Для снаряда создаваемый эффект будет называться ProjectileDeathEffect; как и в случае с ShootEffect, это будет квадрат, остающийся на экране небольшой промежуток времени, а затем исчезающий, но с некоторыми отличиями. Основное отличие в том, что ProjectileDeathEffect будет какое-то время мерцать, затем переходить к своему обычному цвету и исчезать. Это создаёт лёгкий, но интересный эффект хлопка. Так что конструктор будет выглядеть следующим образом:

function ProjectileDeathEffect:new(area, x, y, opts)
    ProjectileDeathEffect.super.new(self, area, x, y, opts)

    self.first = true
    self.timer:after(0.1, function()
        self.first = false
        self.second = true
        self.timer:after(0.15, function()
            self.second = false
            self.dead = true
        end)
    end)
end

Мы определили два атрибута — first и second, которые будут обозначать то, на каком этапе находится эффект. Если он на первом этапе, то у него будет белый цвет, а на втором он примет свой настоящий цвет. После завершения второго этапа эффект «умирает», что выполняется присвоением dead значения true. Всё это происходит в течение 0,25 секунды (0,1 + 0,15) то есть это очень недолговечный и быстрый эффект. Эффект будет отрисовываться способом, очень похожим на способ отрисовки ShootEffect:

function ProjectileDeathEffect:draw()
    if self.first then love.graphics.setColor(default_color)
    elseif self.second then love.graphics.setColor(self.color) end
    love.graphics.rectangle('fill', self.x - self.w/2, self.y - self.w/2, self.w, self.w)
end

Здесь мы просто задаём цвет согласно этапу эффекта, а затем отрисовываем прямоугольник этого цвета. Создание этого эффекта мы реализуем в функции die в объекте Projectile:

function Projectile:die()
    self.dead = true
    self.area:addGameObject('ProjectileDeathEffect', self.x, self.y, 
    {color = hp_color, w = 3*self.s})
end

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

default_color = {222, 222, 222}
background_color = {16, 16, 16}
ammo_color = {123, 200, 164}
boost_color = {76, 195, 217}
hp_color = {241, 103, 69}
skill_point_color = {255, 198, 93}

Для эффекта смерти снаряда я использую цвет hp_color (красный), чтобы показать, как выглядит эффект, но в будущем правильно будет использовать цвет объекта снаряда. Разные типы атак будут иметь разные цвета, поэтому эффект смерти тоже будет иметь разные цвета, зависящие от атаки. Сейчас эффект выглядит следующим образом:

GIF



Теперь перейдём к эффекту смерти Player. Первое, что нужно сделать — это скопировать функцию die объекта Projectile и присваивать атрибуту dead значение true, когда игрок достигает границ экрана. Сделав это, можно добавить к смерти визуальные эффекты. Основным спецэффектом при смерти игрока будет пучок частиц, называемый ExplodeParticle, немного похожий на взрыв. В общем случае частицы будут линиями, двигающимися под случайными углами из начальной позиции и медленно уменьшающимися в длине. Реализовать это можно примерно таким образом:

function ExplodeParticle:new(area, x, y, opts)
    ExplodeParticle.super.new(self, area, x, y, opts)

    self.color = opts.color or default_color
    self.r = random(0, 2*math.pi)
    self.s = opts.s or random(2, 3)
    self.v = opts.v or random(75, 150)
    self.line_width = 2
    self.timer:tween(opts.d or random(0.3, 0.5), self, {s = 0, v = 0, line_width = 0}, 
    'linear', function() self.dead = true end)
end

Здесь мы определили несколько атрибутов, большинство из них говорит само за себя. Дополнительно в интервале между 0,3 и 0,5 секунд мы с помощью tween изменяем размер, скорость и ширину линии до 0, а после завершения перехода частица «умирает». Код движения частицы схож с Projectile и Player, поэтому я пропущу его. Она просто следует в соответствии с углом со своей скоростью.

И наконец частица отрисовывается как линия:

function ExplodeParticle:draw()
    pushRotate(self.x, self.y, self.r)
    love.graphics.setLineWidth(self.line_width)
    love.graphics.setColor(self.color)
    love.graphics.line(self.x - self.s, self.y, self.x + self.s, self.y)
    love.graphics.setColor(255, 255, 255)
    love.graphics.setLineWidth(1)
    love.graphics.pop()
end

Обычно, когда нужно отрисовывать что-то поворачивающееся (в нашем случае — на угол направления частицы), то мы отрисовываем это так, как будто он находится под углом 0 (направлен вправо). То есть в нашем случае нам нужно отрисовать линию слева направо, центром которой будет позиция поворота. То есть s — это на самом деле половина размера линии, а не полный размер. Мы также используем love.graphics.setLineWidth, чтобы линия сначала была жирной и становилась тоньше с течением времени.

Частицы создаются достаточно простым способом. Просто создаём случайное их количество в функции die:

function Player:die()
    self.dead = true 
    for i = 1, love.math.random(8, 12) do 
    	self.area:addGameObject('ExplodeParticle', self.x, self.y) 
  	end
end

Последнее, что мы можем сделать — это привязать клавишу для срабатывания функции die Player, потому что на границе экрана мы не сможем увидеть эффект полностью:

function Player:new(...)
    ...

    input:bind('f4', function() self:die() end)
end

И это будет выглядеть следующим образом:

GIF

Но картина получилась не очень впечатляющей. Чтобы сделать момент драматичнее, можно немного замедлить время. Этого не заметит большинство людей, но если вы посмотрите внимательно, то многие игры немного замедляют время, когда игрок получает урон или умирает. Хорошим примером является Downwell, в этом видео показан его геймплей. Я отметил время, когда наносится урон, чтобы вы понаблюдали и заметили это самостоятельно.

Реализовать это самостоятельно довольно просто. Сначала мы можем определить глобальную переменную slow_amount в love.load и присвоить ей начальное значение 1. Эту переменную мы будем использовать для умножения дельты, передаваемой во всех функции update. Поэтому когда нам нужно будет замедлить время на 50%, мы присвоим slow_amount значение 0,5. Выполнение этого умножения выглядит так:

function love.update(dt)
    timer:update(dt*slow_amount)
    camera:update(dt*slow_amount)
    if current_room then current_room:update(dt*slow_amount) end
end

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

function slow(amount, duration)
    slow_amount = amount
    timer:tween('slow', duration, _G, {slow_amount = 1}, 'in-out-cubic')
end

То есть вызов slow(0.5, 1) будет означать, что игра сначала замедлится до 50% скорости, а затем через 1 секунду вернётся к полной скорости. Здесь важно заметить, что в функции tween используется строка 'slow'. Как рассказано в предыдущих частях, это значит, что при вызове функции slow, когда всё ещё действует tween другой функции slow, то эта предыдущая tween будет отменена, а новая tween продолжится, что предотвратит выполнение двух функций tween с одной переменной одновременно.

Если мы вызовем slow(0.15, 1) во время смерти игрока, то получим следующее:

GIF

Здесь мы также можем добавить тряску экрана. У модуля камеры уже есть функция :shake, поэтому мы можем добавить следующее:

function Player:die()
    ...
    camera:shake(6, 60, 0.4)
    ...
end

Наконец, мы можем заставить экран мерцать в течение нескольких кадров. Это ещё один эффект, используемый во многих играх, который вы можете не замечать, но он создаёт хорошее впечатление от визуального эффекта в целом. Этот эффект достаточно прост: при вызове flash(n), экран будет мерцать фоновым цветом в течение n кадров. Один из способов реализации такой возможности заключается в определении глобальной переменной flash_frames в love.load, которая изначально равна nil. Когда flash_frames равна nil, это значит, что эффект неактивен, а когда не равна nil, то он активен. Функция мерцания выглядит так:

function flash(frames)
    flash_frames = frames
end

Теперь мы можем настроить её в функции love.draw:

function love.draw()
    if current_room then current_room:draw() end

    if flash_frames then 
        flash_frames = flash_frames - 1
        if flash_frames == -1 then flash_frames = nil end
    end
    if flash_frames then
        love.graphics.setColor(background_color)
        love.graphics.rectangle('fill', 0, 0, sx*gw, sy*gh)
        love.graphics.setColor(255, 255, 255)
    end
end

Сначала мы уменьшаем в каждом кадре flash_frames на 1, и затем, когда она достигает -1 мы присваиваем ей nil, потому что эффект завершён. А когда эффект не завершён, мы просто отрисовываем большой прямоугольник с цветом background_color, закрывающий целый экран. Добавление этого в функцию die выглядит так:

function Player:die()
    self.dead = true 
    flash(4)
    camera:shake(6, 60, 0.4)
    slow(0.15, 1)

    for i = 1, love.math.random(8, 12) do 
    	self.area:addGameObject('ExplodeParticle', self.x, self.y) 
  	end
end

При этом мы получаем следующее:

GIF

Это очень слабый и малозаметный эффект, но такие мелкие детали делают всю картину более мощной и красивой.

Упражнения со смертью Player/Projectile


90. Каким другим способом можно достичь эффекта смены цветов объекта ProjectileDeathEffect без использования атрибутов first и second, и с использованием только нового атрибута current_color?

91. Измените функцию flash так, чтобы она получала длительность в секундах, а не кадрах. Какая из них лучше, или это просто дело вкуса? Может ли таймер использовать для измерения длительности кадры вместо секунд?

Такт игрока


Теперь мы перейдём к ещё одному важному аспекту Player — к его механизму цикла. Игра работает таким образом, что в дереве пассивных навыков есть навыки, имеющие вероятность срабатывания в каждом цикле. А цикл — это просто счётчик, срабатывающий каждые n секунд. Нам нужно настроить его основные задачи. И для этого мы просто сделаем так, чтобы функция tick вызывалась каждые 5 секунд:

function Player:new(...)
    ...

    self.timer:every(5, function() self:tick() end)
end

В функции tick мы для начала только добавим небольшой визуальный эффект TickEffect, срабатывающий при каждом такте. Этот эффект похож на эффект refresh в Downwell (см. видео про Downwell выше), это большой прямоугольник, ненадолго накладывающийся на игрока. Он выглядит следующим образом:

GIF

Первое, что стоит заметить — большой прямоугольник закрывает игрока и со временем становится меньше. Но кроме того, он, как ShootEffect, следует за игроком. То есть мы понимаем, что нужно будет передавать объекту TickEffect как ссылку объект Player:

function Player:tick()
    self.area:addGameObject('TickEffect', self.x, self.y, {parent = self})
end

function TickEffect:update(dt)
    ...

    if self.parent then self.x, self.y = self.parent.x, self.parent.y end
end

Также мы видим, что прямоугольник со временем становится меньше, но только по высоте. Проще всего реализовать это таким образом:

function TickEffect:new(area, x, y, opts)
    TickEffect.super.new(self, area, x, y, opts)

    self.w, self.h = 48, 32
    self.timer:tween(0.13, self, {h = 0}, 'in-out-cubic', function() self.dead = true end)
end

Но если вы попробуете это сделать, то увидите, что прямоугольник не поднимается вверх, как должен, а просто становится меньше рядом с центром игрока. Один из способов решения этой проблемы — введение увеличивающегося со временем атрибута y_offset, который вычитается из позиции y объекта TickEffect:

function TickEffect:new(...)
    ...

    self.y_offset = 0
    self.timer:tween(0.13, self, {h = 0, y_offset = 32}, 'in-out-cubic', 
    function() self.dead = true end)
end

function TickEffect:update(dt)
    ...

    if self.parent then self.x, self.y = self.parent.x, self.parent.y - self.y_offset end
end

И таким образом нам удастся получить нужный эффект. Пока это всё, что будет делать функция tick. Позже мы добавим характеристики и пассивные навыки, и в ней появится новый код.

Ускорение игрока


Ещё один важный аспект геймплея — ускорение. Когда пользователь нажимает «вверх», игрок должен начать двигаться быстрее. А когда пользователь нажимает «вниз», то игрок должен начать двигаться медленнее. Эта механика ускорения является базовой частью геймплея; как и в случае с тактами, мы сначала создадим основы, а потом будем добавлять к ним новые возможности.

Сначала нам нужно настроить управление клавишами. Игрок имеет атрибут max_v, задающий максимальную скорость, с которой может двигаться игрок. Мы хотим сделать так, чтобы при нажатии «вверх»/«вниз» это значение менялось и становилось больше/меньше. Проблема здесь в том, что после отпускания клавиши нам нужно вернуться к обычному значению. Поэтому нам нужна ещё одна переменная, хранящая базовое значение и другая, содержащая текущее значение.

Наличие в игре характеристики (например, скорость), которую нужно менять модификаторами (т.е. необходимы базовое и текущее значения), является очень распространённым паттерном. Позже мы добавим в игру новые характеристики и пассивные навыки и рассмотрим это более подробно. Но пока мы добавим атрибут base_max_v, содержащий начальное/базовое значение максимальной скорости, а обычный атрибут max_v будет содержать текущую максимальную скорость, к которой применены все возможные модификаторы (например, ускорение).

function Player:new(...)
    ...

    self.base_max_v = 100
    self.max_v = self.base_max_v
end

function Player:update(dt)
    ...

    self.max_v = self.base_max_v
    if input:down('up') then self.max_v = 1.5*self.base_max_v end
    if input:down('down') then self.max_v = 0.5*self.base_max_v end
end

Этим кодом мы в каждом кадре присваиваем max_v значение base_max_v, а затем проверяем, нажаты ли клавиши «вверх»/«вниз», и соответствующим образом меняем max_v. Важно заметить — это значит, что вызов setLinearVelocity, использующий max_v, должен происходить после этого, в противном случае всё развалится на части!

Теперь, когда у нас работает базовая возможность ускорения, мы можем добавить визуальные эффекты. Для этого мы добавим объекту игрока следы выхлопа:

GIF

Создание следов следует общему паттерну. Я буду создавать новый объект в каждом кадре или около того, а затем уменьшать размер объекта функцией tween в течение определённого времени. С течением времени мы будем создавать объект за объектом, и они будут отрисовываться рядом друг с другом. Созданные раньше будут становиться меньше, а только что созданные окажутся больше. Все они будут создаваться в нижней части игрока, и при его движении получится нужный нам эффект следа.

Для реализации этого мы можем создать новый объект TrailParticle, который будет просто кругом определённого радиуса, в течение определённого времени уменьшаемый функцией tween:

function TrailParticle:new(area, x, y, opts)
    TrailParticle.super.new(self, area, x, y, opts)

    self.r = opts.r or random(4, 6)
    self.timer:tween(opts.d or random(0.3, 0.5), self, {r = 0}, 'linear', 
    function() self.dead = true end)
end

Различные режимы переходов, например, 'in-out-cubic' вместо 'linear', будут придавать следу разную форму. Я использовал linear, потому что он мне кажется наиболее красивым, но вы можете выбрать другой. Функция draw просто отрисовывает круг соответствующего цвета и радиуса, используя атрибут r.

Со стороны объекта Player мы можем создавать новый TrailParticles так:

function Player:new(...)
    ...

    self.trail_color = skill_point_color 
    self.timer:every(0.01, function()
        self.area:addGameObject('TrailParticle', 
        self.x - self.w*math.cos(self.r), self.y - self.h*math.sin(self.r), 
        {parent = self, r = random(2, 4), d = random(0.15, 0.25), color = self.trail_color}) 
    end)

То есть каждые 0,01 секунды (то есть в каждом кадре), мы создаём за игроком новый объект TrailParticle со случайным радиусом от 2 до 4, случайной длительностью от 0,15 до 0,25 секунды и цветом skill_point_color (жёлтый).

Также при нажатии «вверх» или «вниз» мы можем изменять цвет частиц на синий. Для этого мы должны добавить логику в код ускорения, а именно нам нужно сообщить, когда происходит ускорение, и для этого нам нужен атрибут boosting. С помощью этого атрибута мы сможем узнать, когда происходит ускорение и соответствующим образом изменять цвет, на который ссылается trail_color:

function Player:update(dt)
    ...

    self.max_v = self.base_max_v
    self.boosting = false
    if input:down('up') then 
        self.boosting = true
        self.max_v = 1.5*self.base_max_v 
    end
    if input:down('down') then 
        self.boosting = true
        self.max_v = 0.5*self.base_max_v 
    end
    self.trail_color = skill_point_color 
    if self.boosting then self.trail_color = boost_color end
end

Так мы добились того, что при ускорении игрока цвет будет меняться с trail_color на boost_color (синий).

Графика корабля игрока


Последнее, что мы рассмотрим в этой части — корабли! В игре будет множество различных типов кораблей, каждый со своими характеристиками, пассивными навыками и внешним видом. Пока мы сосредоточимся только на внешнем виде и добавим один корабль, а в упражнении вам придётся создать ещё семь.

Стоит также упомянуть о контенте: когда в игру нужно будет добавлять контент — корабли, пассивные навыки, разные опции меню, визуальное построение дерева навыков, и т.д., вам придётся делать бОльшую часть работы самостоятельно. В туториале я сделаю это только на одном примере, а дальше вам надо будет вручную самостоятельно добавлять новый подобный контент самим, так что я вынесу эту работу в упражнения.

Я решил сделать так по двум причинам: во-первых, подробное объяснение всего заняло бы слишком много времени и туториал бы стал очень длинным; во-вторых, вам нужно научиться делать ручную работу по добавлению контента в игру самостоятельно. Большая часть разработки игр заключается в простом добавлении контента без создания чего-то «нового». Вам может не понравиться это, ведь придётся выполнять кучу работы, которая вам может быть неинтересна. Лучше понять это раньше, чем позже. Если вы этого не хотите, то можно, например, сосредоточиться на создании игр, не требующих большого ручного труда. Но моя игра — совсем другой случай. В дереве навыков будет примерно 800 узлов, и все их нужно задавать вручную (и вам придётся делать то же самое, если ваше дерево будет таким же большим), так что это отличная возможность понять, нравится вам такая работа, или нет.

Как бы то ни было, давайте начнём с одного корабля. Вот, как он будет выглядеть:

GIF

Как вы видите, он состоит из трёх частей: основного корпуса и двух крыльев. Мы будем отрисовывать его из набора простых многоугольников, то есть нам нужно определить три отдельных многоугольника. Мы будем определять позиции полигонов так, как будто корабль повёрнут вправо (как я объяснял выше, это угол 0). Мы получим нечто подобное:

function Player:new(...)
    ...

    self.ship = 'Fighter'
    self.polygons = {}

    if self.ship == 'Fighter' then
        self.polygons[1] = {
            ...
        }

        self.polygons[2] = {
            ...
        }

        self.polygons[3] = {
            ...
        }
    end
end

Внутри каждой таблицы многоугольников мы определяем вершины многоугольника. Для отрисовки этих многоугольников нам предстоит выполнить некоторую работу. Для начала, нам нужно повернуть многоугольники вокруг центра игрока:

function Player:draw()
    pushRotate(self.x, self.y, self.r)
    love.graphics.setColor(default_color)
    -- здесь отрисовываем многоугольники
    love.graphics.pop()
end

После этого нам нужно рассмотреть каждый из многоугольников:

function Player:draw()
    pushRotate(self.x, self.y, self.r)
    love.graphics.setColor(default_color)
    for _, polygon in ipairs(self.polygons) do
        -- здесь отрисовываем каждый многоугольник
    end
    love.graphics.pop()
end

А затем отрисовать каждый многоугольник:

function Player:draw()
    pushRotate(self.x, self.y, self.r)
    love.graphics.setColor(default_color)
    for _, polygon in ipairs(self.polygons) do
        local points = fn.map(polygon, function(k, v) 
        	if k % 2 == 1 then 
          		return self.x + v + random(-1, 1) 
        	else 
          		return self.y + v + random(-1, 1) 
        	end 
      	end)
        love.graphics.polygon('line', points)
    end
    love.graphics.pop()
end

Первое, что мы делаем — правильно упорядочиваем все точки. Каждый многоугольник должен определяться локально, то есть расстояние от его центра считается равным 0, 0. Это значит, что каждый многоугольник пока не знает, в какой позиции мира он находится.

Функция fn.map обходит каждый элемент в таблице и применяет к нему функцию. В этом случае функция проверят индекс на чётность. Если он нечётный, то обозначает компонент x, а если чётный — то компонент y. То есть в каждом из этих случаев мы просто прибавляем к вершине позицию x или y игрока, а также случайное число в интервале от -1 до 1, чтобы корабль выглядел немного более нечётким и интересным. Затем, наконец, вызывается love.graphics.polygon для отрисовки всех этих точек.

Вот, как выглядит определение каждого многоугольника:

self.polygons[1] = {
    self.w, 0, -- 1
    self.w/2, -self.w/2, -- 2
    -self.w/2, -self.w/2, -- 3
    -self.w, 0, -- 4
    -self.w/2, self.w/2, -- 5
    self.w/2, self.w/2, -- 6
}

self.polygons[2] = {
    self.w/2, -self.w/2, -- 7
    0, -self.w, -- 8
    -self.w - self.w/2, -self.w, -- 9
    -3*self.w/4, -self.w/4, -- 10
    -self.w/2, -self.w/2, -- 11
}

self.polygons[3] = {
    self.w/2, self.w/2, -- 12
    -self.w/2, self.w/2, -- 13
    -3*self.w/4, self.w/4, -- 14
    -self.w - self.w/2, self.w, -- 15
    0, self.w, -- 16
}

Первый — это основной корпус, второй — верхнее крыло, третий — нижнее крыло. Все вершины определяются в порядке против часовой стрелки, первой точкой линии всегда указывается компонент x, второй — компонент y. Вот как выглядит привязка каждой вершины к парам чисел, указанных выше:


Как вы видите, первая точка находится далеко справа и выровнена с центром, то есть имеет координаты self.w, 0. Следующая немного левее и выше первой, то есть её координаты self.w/2, -self.w/2, и так далее.

Наконец, после добавления точек мы можем сделать так, чтобы следы соответствовали кораблю. В нашем случае, как видно из приложенного выше gif, есть два следа, а не один:

function Player:new(...)
    ...

    self.timer:every(0.01, function()
        if self.ship == 'Fighter' then
            self.area:addGameObject('TrailParticle', 
            self.x - 0.9*self.w*math.cos(self.r) + 0.2*self.w*math.cos(self.r - math.pi/2), 
            self.y - 0.9*self.w*math.sin(self.r) + 0.2*self.w*math.sin(self.r - math.pi/2), 
            {parent = self, r = random(2, 4), d = random(0.15, 0.25), color = self.trail_color}) 
            self.area:addGameObject('TrailParticle', 
            self.x - 0.9*self.w*math.cos(self.r) + 0.2*self.w*math.cos(self.r + math.pi/2), 
            self.y - 0.9*self.w*math.sin(self.r) + 0.2*self.w*math.sin(self.r + math.pi/2), 
            {parent = self, r = random(2, 4), d = random(0.15, 0.25), color = self.trail_color}) 
        end
    end)
end

Здесь мы используем следующую технику: проходим от точки к точке на основании угла, необходимого, чтобы добраться до цели. Необходимые нам целевые точки находятся за игроком (сзади на 0.9*self.w), но каждая сдвинута на небольшое расстояние (0.2*self.w) вдоль оси, противоположной движению игрока.

Всё это будет выглядеть следующим образом:

GIF


Упражнения с графикой кораблей


Небольшое примечание: меткой (КОНТЕНТ) помечены упражнения, которые сами являются контентом игры. Помеченные таким образом упражнения не будут иметь ответов и вы должны будете сделать их полностью самостоятельно! С этого момента такими будут всё больше и больше упражнений, потому что мы начинаем переходить к самой игре и огромная её часть заключается в простом ручном добавлении контента.

92. (КОНТЕНТ) Добавьте ещё семь типов кораблей. Чтобы добавить новый тип корабля, нужно просто добавить ещё одну условную конструкцию elseif self.ship == 'ShipName' then и к определению многоугольников, и к определению следа. Вот как выглядят корабли, созданные мной (но вы, разумеется, можете творить сами и выдумывать собственный дизайн):

GIF




Если вам понравились эти туториалы и вы хотите простимулировать меня к написанию чего-то подобного в будущем:


Купив туториал на itch.io, вы получите доступ к полному исходному коду игры, к ответам на упражнения 1-9, к коду, разбитому по частям (код будет выглядеть так, как должен выглядеть к концу каждой части туториала), а также ключ игры в Steam.




К сожалению, не доступен сервер mySQL