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

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
bytepath51.gif


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

GIF
bytepath52.gif


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

GIF
bytepath53.gif


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

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

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

Размер игры


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

e4e9bf07492721727e0a2e52c7db0161.png


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

e7110caef6392b51f1ec221992a98542.png


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

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

Кроме того, в этом файле я также определю две глобальные переменные 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"                -- Версия LÖVE, для которой сделана эта игра (строка)
    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) сейчас, вот так:

e9f9e645b8dd708f341378f6345e9ea2.png


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

0c4908494ea27c684debbe3543d7e549.png


Как вы видите, круг не отмасштабировался вместе с экраном и остался просто маленьким кругом. Также он не центрирован на экране, потому что 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 из LÖVE wiki используются для предотвращения неправильного смешения (blending). Если запустить программу сейчас, то вы увидите отрисованный круг.

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

d0fd1752d6cdc160e6f9171bc8fe5a2c.png


Мы ничего не видим! Но так случилось потому, что круг, находившийся в середине холста 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, должно произойти следующее:

9f1934e471aaf5c69e22c49bb454ab3f.png


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

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

fe9fb2f1866b02caab444e7e4a308e17.png


Как раз тот ломаный и пикселизированный внешний вид, который нам нужен! Важнее всего то, что теперь мы можем использовать одно разрешение для создания всей игры. Если мы захотим создать объект в центре экрана, то мы можем сообщить, что его позиция 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 экран начнёт трястись:

482e16fc878b611a4e1dfd265ffbf074.gif


Функция тряски основана на функции, описанной в этой статье; она получает амплитуду (в пикселях), частоту и длительность. Тряска экрана будет выполняться с постепенным затуханием, начиная с амплитуды, в течение указанного количества секунд и указанной частотой. Чем выше частоты, тем активнее будет колебаться экран между двумя пределами (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, а в этой комнате камера всегда должна будет центрироваться на середине экрана и никогда не двигаться (кроме моментов тряски экрана). В результате мы получаем следующее:

9a20215e0d3cf5bdfb79461d72f1f4ab.gif


Для всей игры мы будем использовать одну глобальную камеру, потому что нет нужды создавать отдельные экземпляры камеры для каждой комнаты. В комнате 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 в LÖVE, но в целом это необязательно для нашей игры, потому что она не получит ничего полезного от использования такого полного физического движка, как box2d. Я использую его потому, что привык к нему. Но я рекомендую вам или попробовать написать свои процедуры обработки коллизий (что будет очень просто для подобной игры), или использовать библиотеку, которая займётся этим за вас.

В туториале я буду использовать созданную мной библиотеку windfield, которая делает использование box2d с LÖVE намного проще. Для LÖVE есть и другие библиотеки, тоже обрабатывающие коллизии: 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, но у нас может и не быть самих объектов. setObject (и getObject) позволяет нам задавать и извлекать объект, к которому принадлежит Collider.

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

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


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

eccb6c9650e1cb780c52bc603c2c77bf.png


Упражнения с физикой 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, то есть указывает вверх. Углы в LÖVE указываются по часовой стрелке, то есть 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
0230d52f4d695398ca75e6aa68dbf1f7.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
1330cc6bc216ddfd1b1a90e73841fbdf.gif


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

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


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

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.

8cfc5122fd1e023f6a5b1852339c518e.png


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

993ea3bc84379fda480a00951961f4ca.png


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

© Habrahabr.ru