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

image


Введение


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

Создаваемая нами игра будет сочетанием Bit Blaster XL и дерева пассивных навыков Path of Exile. Она достаточно проста, чтобы можно было рассмотреть её в нескольких статьях, не очень больших по объёму, но содержащих слишком большой объём знаний для новичка.

GIF
bytepath-shielder.gif


Кроме того, туториал имеет уровень сложности, не раскрываемый в большинстве туториалов по созданию игр. Большинство проблем, возникающих у новичков в разработке игр, связано с масштабом проекта. Обычно советуют начинать с малого и постепенно расширять объём. Хотя это и неплохая идея, но если вас интересуют такие проекты, которые никак нельзя сделать меньше, то в Интернете довольно мало ресурсов, способных вам помочь в решении встречаемых задач.

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

GIF
bytepath-skill-tree.gif


Требования


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

  • Основы программирования: переменные, циклы, условные операторы, основные структуры данных и т.д.;
  • Основы ООП, например, понимание классов, экземпляров, атрибутов и методов;
  • И самые основы Lua; этого краткого туториала должно быть достаточно.


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

GIF
18d5d1e4e4653f91943a561a620c396c.gif


Оглавление

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


Часть 1: Игровой цикл


Приступаем к работе


Для начала нам нужно установить в системе LÖVE и научиться запускать проекты LÖVE. Мы будем использовать версию LÖVE 0.10.2, которую можно скачать здесь. Если вы читаете эту статью из будущего и уже вышла новая версия LÖVE, то 0.10.2 можно скачать отсюда. Подробные инструкции описаны на этой странице. Сделав всё необходимое, создайте в своём проекте файл main.lua со следующим содержимым:

function love.load()

end

function love.update(dt)

end

function love.draw()

end


Если вы запустите проект, то увидите всплывающее окно с чёрным экраном. В представленном выше коде проект LÖVE выполняет функцию love.load один раз при запуске программы, а love.update и love.draw выполняются в каждом кадре. То есть, например, если вы хотите загрузить изображение и отрисовывать его, то напишете что-то подобное:

function love.load()
    image = love.graphics.newImage('image.png')
end

function love.update(dt)

end

function love.draw()
    love.graphics.draw(image, 0, 0)
end


love.graphics.newImage загружает текстуру-изображение в переменную image, а затем в каждом кадре она отрисовывается в позиции 0, 0. Чтобы увидеть, что love.draw на самом деле отрисовывает изображение в каждом кадре, попробуйте сделать так:

love.graphics.draw(image, love.math.random(0, 800), love.math.random(0, 600))


По умолчанию окно имеет размер 800x600, то есть эта функция будет очень быстро случайным образом отрисовывать изображение на экране:

Мерцающая GIF
5d2cbac566a004d17f6bc9bb3f37d6bd.gif


Заметьте, что перед каждым кадром экран очищается, в противном случае отрисовываемое изображение постепенно заполнило бы весь экран, отрисовываясь в случайных позициях. Так происходит потому, что LÖVE предоставляет своим проектам стандартный игровой цикл, выполняющий после каждого кадра очистку экрана. Сейчас я расскажу об игровом цикле и о там, как его можно изменять.

Игровой цикл


Стандартный игровой цикл, используемый LÖVE, находится на странице love.run. Он выглядит следующим образом:

function love.run()
    if love.math then
	love.math.setRandomSeed(os.time())
    end

    if love.load then love.load(arg) end

    -- Мы не хотим, чтобы в dt первого кадра включалось время, потраченное на love.load.
    if love.timer then love.timer.step() end

    local dt = 0

    -- Время основного цикла.
    while true do
        -- Обработка событий.
        if love.event then
	    love.event.pump()
	    for name, a,b,c,d,e,f in love.event.poll() do
	        if name == "quit" then
		    if not love.quit or not love.quit() then
		        return a
		    end
	        end
		love.handlers[name](a,b,c,d,e,f)
	    end
        end

	-- Обновление dt, потому что мы будем передавать его в update
	if love.timer then
	    love.timer.step()
	    dt = love.timer.getDelta()
	end

	-- Вызов update и draw
	if love.update then love.update(dt) end -- передаёт 0, если love.timer отключен

	if love.graphics and love.graphics.isActive() then
	    love.graphics.clear(love.graphics.getBackgroundColor())
	    love.graphics.origin()
            if love.draw then love.draw() end
	    love.graphics.present()
	end

	if love.timer then love.timer.sleep(0.001) end
    end
end


При запуске программы выполняется love.run, а затем отсюда начинает происходит всё остальное. Функция достаточно хорошо закомментирована, а назначение каждой функции можно узнать в LÖVE wiki. Но мы пройдёмся по основам:

if love.math then
    love.math.setRandomSeed(os.time())
end


В первой строке мы проверяем love.math на неравенство nil. Все значения в Lua являются true, за исключением false и nil, поэтому условие if love.math будет истинным, если love.math определёна. В случае LÖVE эти переменные задаются в файле conf.lua. Вам пока не стоит беспокоиться об этом файле, но я упомянул его, потому что именно в нём можно включать и отключать отдельные системы, такие как love.math, поэтому прежде чем работать с её функциями, в этом файле нужно убедиться, что она включена.

В общем случае, если переменная не определена в Lua и вы каким-то образом ссылаетесь на неё, то она вернёт значение nil. То есть если вы создадите условие if random_variable, то оно будет ложным, если переменная не была определена ранее, например random_variable = 1.

Как бы то ни было, если модуль love.math включен (а по умолчанию это так), то его начальное число (seed) задаётся на основании текущего времени. См. love.math.setRandomSeed и os.time. После этого вызывается функция love.load:

if love.load then love.load(arg) end


arg — это аргументы командной строки, передаваемые исполняемому файлу LÖVE, когда он выполняет проект. Как видите, love.load выполняется только один раз потому, что вызывается только один раз, а функции update и draw вызываются в цикле (и каждая итерация этого цикла соответствует кадру).

-- Мы не хотим, чтобы в dt первого кадра включалось время, потраченное на love.load.
if love.timer then love.timer.step() end

local dt = 0


После вызова love.load и выполнения функцией всей своей работы мы проверяем, что love.timer задан и вызываем love.timer.step, измеряющую время, потраченное между двумя последними кадрами. Как написано в комментарии, обработка love.load может занять длительное время (потому что в ней могут содержаться всевозможные вещи, например, изображения и звуки), а это время не должно быть первым значением, возвращаемым love.timer.getDelta в первом кадре игры.

Также здесь инициализируется dt, равное 0. Переменные в Lua по умолчанию являются глобальными, так что записью local dt мы назначаем текущему блоку только локальную область видимости, то есть ограничиваем его функцией love.run. Подробнее о блоках можно прочитать здесь.

-- Время основного цикла.
while true do
    -- Обработка событий.
    if love.event then
        love.event.pump()
        for name, a,b,c,d,e,f in love.event.poll() do
            if name == "quit" then
                if not love.quit or not love.quit() then
                    return a
                end
            end
            love.handlers[name](a,b,c,d,e,f)
        end
    end
end


Здесь начинается основной цикл. Первое, что выполняется в каждом кадре — это обработка событий. love.event.pump передаёт события в очередь событий и согласно его описанию, эти события каким-то образом генерируются пользователем. Это могут быть нажатия клавиш, щелчки мышью, изменение размеров окна, изменение фокуса окна и тому подобное. Цикл с помощью love.event.poll проходит по очереди событий и обрабатывает каждое событие. love.handlers — это таблица функций, вызывающая соответствующие механизмы обработки событий. Например, love.handlers.quit будет вызывать функцию love.quit, если она существует.

Одна из особенностей LÖVE заключается в том, что можно определять механизмы обработки событий в файле main.lua, которые будут вызываться при выполнении события. Полный список обработчиков событий доступен здесь. Больше я не буду подробно рассматривать обработчики событий, но вкратце объясню, как всё происходит. Аргументы a, b, c, d, e, f, передаваемые в love.handlers[name], являются всеми возможными аргументами, которые могут использовать соответствующие функции. Например, love.keypressed получает в качестве аргумента нажатую клавишу, её сканкод и информацию о том, повторяется ли событие нажатия клавиши. То есть в случае love.keypressed значения a, b, c будут определены, а d, e, f будут иметь значения nil.

-- Обновление dt, потому что мы будем передавать его в update
if love.timer then
    love.timer.step()
    dt = love.timer.getDelta()
end

-- Вызов update и draw
if love.update then love.update(dt) end -- передаёт 0, если love.timer отключен


love.timer.step измеряет время между двумя последними кадрами и изменяет значение, возвращаемое love.timer.getDelta. То есть в этом случае dt будет содержать время, которое потребовалось на выполнение последнего кадра. Это полезно, потому что затем это значение передаётся в функцию love.update, и с этого момента оно может использоваться игрой для обеспечения постоянных скоростей вне зависимости от изменения частоты кадров.

if love.graphics and love.graphics.isActive() then
    love.graphics.clear(love.graphics.getBackgroundColor())
    love.graphics.origin()
    if love.draw then love.draw() end
    love.graphics.present()
end


После вызова love.update вызывается love.draw. Но прежде мы убеждаемся, что модуль love.graphics существует, и проверяем с помощью love.graphics.isActive, что мы можем выполнять отрисовку на экране. Экран очищается, заливаясь заданным фоновым цветом (изначально чёрным) с помощью love.graphics.clear, с помощью love.graphics.origin сбрасываются преобразования, вызывается love.draw, а затем используется love.graphics.present для передачи всего отрисованного в love.draw на экране. И наконец:

if love.timer then love.timer.sleep(0.001) end


Я никогда не понимал, почему love.timer.sleep должен находиться здесь, в конце файла, но объяснение разработчика LÖVE кажется достаточно логичным.

И на этом функция love.run завершается. Всё, что происходит внутри цикла while true, относится к кадру, то есть love.update и love.draw вызываются один раз в кадр. Вся игра в сущности заключается в очень быстром повторении содержимого цикла (например, при 60 кадрах в секунду), так что привыкайте к этой мысли. Помню, что сначала мне потребовалось какое-то время для инстинктивного осознания того, почему всё так устроено.

Если вы хотите прочитать об этом подробнее, то на форумах LÖVE есть полезное обсуждение этой функции.

Если не хотите, то не обязательно разбираться в этом с самого начала, но это пригодится, чтобы правильным образом изменять работу игрового цикла. Есть отличная статья, в которой рассматриваются различные техники игровых циклов с качественным объяснением. Она находится здесь.

Упражнения по игровому циклу


1. Какую роль играет Vsync в игровом цикле? По умолчанию она включена и вы можете отключить её, вызвав love.window.setMode с атрибутом vsync, имеющим значение false.

2. Реализуйте цикл Fixed Delta Time из статьи Fix Your Timestep, изменив love.run.

3. Реализуйте цикл Variable Delta Time из статьи Fix Your Timestep, изменив love.run.

4. Реализуйте цикл Semi-Fixed Timestep из статьи Fix Your Timestep, изменив love.run.

5. Реализуйте цикл Free the Physics из статьи Fix Your Timestep, изменив love.run.


Часть 2: Библиотеки


Введение


В этой части мы рассмотрим некоторые из библиотек Lua/LÖVE, которые необходимы для проекта, а также изучим являющиеся уникальными для Lua принципы, которые вам нужно начать осваивать. К концу этой части мы освоим четыре библиотеки. Одна из целей этой части — привыкание к идее загрузки библиотек, собранных другими людьми, к чтению их документации, изучению их работы и возможностей использования в своём проекте. Сами по себе Lua и LÖVE не обладают широкими возможностями, поэтому загрузка и использование кода, написанного другими людьми — стандартная и необходимая практика.

Ориентация объектов


Первое, что я здесь рассмотрю — это ориентация объектов. Существует очень много способов реализации ориентации объектов в Lua, но мы просто воспользуемся библиотекой. Больше всего мне нравится ООП-библиотека rxi/classic из-за её малого объёма и эффективности. Для её установки достаточно просто скачать её и перетащить папку classic внутрь папки проекта. Обычно я создаю папку libraries и скидываю все библиотеки туда.

Закончив с этим, мы можем импортировать библиотеку в игру в верхней части файла main.lua, сделав следующее:

Object = require 'libraries/classic/classic'


Как написано на странице github, с этой библиотекой можно выполнять все обычные ООП-действия, и они должны нормально работать. При создании нового класса я обычно делаю это в отдельном файле и помещаю этот файл в папку objects. Тогда, например, создание класса Test и одного его экземпляра будет выглядеть так:

-- В файле objects/Test.lua
Test = Object:extend()

function Test:new()

end

function Test:update(dt)

end

function Test:draw()

end


-- В файле main.lua
Object = require 'libraries/classic/classic'
require 'objects/Test'

function love.load()
    test_instance = Test()
end


То есть при вызове require 'objects/Test' в main.lua выполняется всё то, что определено в файле Test.lua, а значит глобальная переменная Test теперь содержит определение класса Test. В нашей игре каждое определение класса будет выполняться таким образом, то есть названия классов должны быть уникальными, так как они привязываются к глобальной переменной. Если вы не хотите делать так, то можете внести следующие изменения:

-- В файле objects/Test.lua
local Test = Object:extend()
...
return Test


-- В файле main.lua
Test = require 'objects/Test'


Если мы сделаем переменную Test локальной в Test.lua, то она не будет привязана к глобальной переменной, то есть можно будет привязать её к любому имени, когда она потребуется в main.lua. В конце скрипта Test.lua возвращается локальная переменная, а поэтому в main.lua при объявлении Test = require 'objects/Test' определение класса Test присваивается глобальной переменной Test.

Иногда, например, при написании библиотек для других людей, так делать лучше, чтобы не загрязнять их глобальное состояние переменными своей библиотеки. Библиотека classic тоже поступает так, именно поэтому мы должны инициализировать её, присваивая переменной Object. Одно из хороших последствий этого заключается в том, что при присвоении библиотеки переменной, если мы захотим, то можем дать Object имя Class, и тогда наши определения классов будут выглядеть как Test = Class:extend().

Последнее, что я делаю — автоматизирую процесс require для всех классов. Для добавления класса в среду нужно ввести require 'objects/ClassName'. Проблема здесь в том, что может существовать множество классов и ввод этой строки для каждого класса может быть утомительным. Так что для автоматизации этого процесса можно сделать нечто подобное:

function love.load()
    local object_files = {}
    recursiveEnumerate('objects', object_files)
end

function recursiveEnumerate(folder, file_list)
    local items = love.filesystem.getDirectoryItems(folder)
    for _, item in ipairs(items) do
        local file = folder .. '/' .. item
        if love.filesystem.isFile(file) then
            table.insert(file_list, file)
        elseif love.filesystem.isDirectory(file) then
            recursiveEnumerate(file, file_list)
        end
    end
end


Давайте разберём этот код. Функция recursiveEnumerate рекурсивно перечисляет все файлы внутри заданной папки и добавляет их в таблицу как строки. Она использует модуль LÖVE filesystem, содержащий множество полезных функций для выполнения подобных операций.

Первая строка внутри цикла создаёт список всех файлов и папок в заданной папке и возвращает их с помощью love.filesystem.getDirectoryItems как таблицу строк. Далее она итеративно проходит по всем ним и получает полный путь к файлу конкатенацией (конкатенация строк в Lua выполняется с помощью ..) строки folder и строки item.

Допустим, что строка folder имеет значение 'objects', а внутри папки objects есть единственный файл с названием GameObject.lua. Тогда список items будет выглядеть как items = {'GameObject.lua'}. При итеративном проходе по списку строка local file = folder .. '/' .. item спарсится в local file = 'objects/GameObject.lua', то есть в полный путь к соответствующему файлу.

Затем этот полный путь используется для проверки с помощью функций love.filesystem.isFile и love.filesystem.isDirectory того, является ли он файлом или каталогом. Если это файл, то мы просто добавляем его в таблицу file_list, переданную вызываемой функцией, в противном случае снова вызываем recursiveEnumerate, но на этот раз используем этот путь как переменную folder. Когда этот процесс завершиться, таблица file_list будет заполнена строками, соответствующими путям ко всем файлам внутри folder. В нашем случае переменная object_files будет таблицей, заполненной строками, соответствующими всем классам в папке objects.

Остался ещё один шаг, заключающийся в добавлении всех этих путей в require:

function love.load()
    local object_files = {}
    recursiveEnumerate('objects', object_files)
    requireFiles(object_files)
end

function requireFiles(files)
    for _, file in ipairs(files) do
        local file = file:sub(1, -5)
        require(file)
    end
end


Тут всё гораздо понятнее. Код просто проходит по файлам и вызывает для них require. Единственное, что осталось — удалить .lua из конца строки, потому что функция require выдаёт ошибку, если его оставить. Это можно сделать строкой local file = file:sub(1, -5), которая использует одну из встроенных строковых функций Lua. Так что после выполнения этого будут автоматически загружаться все классы, определённые внутри папки objects. Позже также будет использована функция recursiveEnumerate для автоматической загрузки других ресурсов, таких как изображения, звуки и шейдеры.

Упражнения по ООП


6. Создайте класс Circle, получающий в своём конструкторе аргументы x, y и radius, имеющий атрибуты x, y, radius и creation_time, а также методы update и draw. Атрибуты x, y и radius должны инициализироваться со значениями, переданными из конструктора, а атрибут creation_time должен инициализироваться с относительным временем создания экземпляра (см. love.timer). Метод update должен получать аргумент dt, а функция draw должна отрисовывать закрашенный цикл с центром в x, y с радиусом radius (см. love.graphics). Экземпляр этого класса Circle должен быть создан в позиции 400, 300 с радиусом 50. Он также должен обновляться и отрисовываться на экране. Вот, как должен выглядеть экран:

52e9dc6ed41932bfff39837c23d9853c.png


7. Создайте класс HyperCircle, который наследует от класса Circle. HyperCircle похож на Circle, только вокруг него отрисовывается внешний круг. Он должен получать в конструкторе дополнительные аргументы line_width и outer_radius. Экземпляр этого класса HyperCircle нужно создать в позиции 400, 300 с радиусом 50, шириной линии 10 и внешним радиусом 120. Экран должен выглядеть вот так:

2c4dfa023a151fc7c47a6e632f092a61.png


8. Для чего в Lua служит оператор :? Чем он отличается от . и когда нужно использовать каждый из них?

9. Допустим, у нас есть следующий код:

function createCounterTable()
    return {
        value = 1,
        increment = function(self) self.value = self.value + 1 end,
    }
end

function love.load()
    counter_table = createCounterTable()
    counter_table:increment()
end


Каким будет значение counter_table.value? Почему функция increment получает аргумент с названием self? Может ли этот аргумент иметь какое-то другое название? И что это за переменная, которая в этом примере представлена self?

10. Создайте функцию, возвращающую таблицу, которая содержит атрибуты a, b, c и sum. a, b и c должны инициализироваться со значениями 1, 2 и 3, а sum должна быть функцией, складывающей a, b и c. Значение суммы должно храниться в атрибуте c таблицы (то есть после выполнения всех операций таблица должна иметь атрибут c со значением 6).

11. Если класс имеет метод с названием someMethod, может ли у него быть атрибут с тем же названием? Если нет, то почему?

12. Что такое «глобальная таблица» в Lua?

13. На основании того, как мы организовали автоматическую загрузку классов, если один класс наследует от другого, то код будет выглядеть следующим образом:

SomeClass = ParentClass:extend()


Существует ли гарантия того, что когда эта строка будет обрабатываться, переменная ParentClass уже будет определена? Или, иными словами, есть ли гарантия того, что required ParentClass будет раньше, чем SomeClass? Если да, то чем это гарантируется? Если нет, то как можно устранить эту проблему?

14. Предположим, что все файлы классов определяют класс не глобально, а локально, примерно так:

local ClassName = Object:extend()
...
return ClassName


Как нужно изменить функцию requireFiles, чтобы она всё равно могла автоматически загружать все классы?

Ввод


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

function love.load()

end

function love.update(dt)

end

function love.draw()

end

function love.keypressed(key)
    print(key)
end

function love.keyreleased(key)
    print(key)
end

function love.mousepressed(x, y, button)
    print(x, y, button)
end

function love.mousereleased(x, y, button)
    print(x, y, button)
end


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

Допустим, у нас есть объект game, внутри которого есть объект level, внутри которого есть объект player. Для того, чтобы объект player получил клавиатурный ввод, у всех этих трёх объектов должно быть определено два обработчика вызова, связанных с клавиатурой, потому что на верхнем уровне мы хотим вызывать только game:keypressed внутри love.keypressed, поскольку мы не хотим, чтобы более низкие уровни знали об уровне или игроке. Поэтому я создал библиотеку для решения этой проблемы. Можете скачать и установить её как любую другую рассмотренную нами библиотеку. Вот несколько примеров того, как она работает:

function love.load()
    input = Input()
    input:bind('mouse1', 'test')
end

function love.update(dt)
    if input:pressed('test') then print('pressed') end
    if input:released('test') then print('released') end
    if input:down('test') then print('down') end
end


Вот, что делает библиотека: вместо того, чтобы полагаться на функции обработки событий ввода, она просто запрашивает, была ли в этом кадре нажата определённая клавиша и получает ответ в виде true или false. В приведённом выше примере в кадре, где нажали кнопку mouse1, на экране будет печататься pressed, а в кадре отпускания кнопки будет печататься released. Во всех других кадрах, когда нажатие не выполняется, вызовы input:pressed и input:released будут возвращать false и всё внутри условной конструкции выполняться не будет. То же самое относится и к функции input:down, только она возвращает true в каждом кадре, когда кнопка удерживается, и false в противном случае.

Часто нам требуется поведение, повторяющееся при удерживании клавиши с определённым интервалом, а не в каждом кадре. Для этой цели можно использовать функцию down:

function love.update(dt)
    if input:down('test', 0.5) then print('test event') end
end


В этом примере, если удерживается клавиша, привязанная к действию test, то каждые 0,5 секунд в консоли будет печататься test event.

Упражнения по вводу


15. Допустим, у нас есть следующий код:

function love.load()
    input = Input()
    input:bind('mouse1', function() print(love.math.random()) end)
end


Будет ли что-то происходить при нажатии mouse1? А при отпускании? А при удерживании?

16. Привяжите клавишу алфавитно-цифрового блока + к действию add; затем при удерживании клавиши действия add увеличивайте значение переменной sum (изначально равной 0) на 1 через каждые 0,25 секунды. Выводите значение sum в консоль при каждом инкременте.

17. Можно ли к одному действию привязать несколько клавиш? Если нет, то почему? И можно ли привязать к одной клавише несколько действий? Если нет, то почему?

18. Если у вас есть контроллер, то привяжите его кнопки направлений DPAD (fup, fdown…) к действиям up, left, right и down, а затем выводите название действия в консоль при нажатии каждой из кнопок.

19. Если у вас есть контроллер, то привяжите одну из его кнопок-триггеров (l2, r2) к действию trigger. Кнопки-триггеры возвращают вместо булевого значение от 0 до 1, сообщающее о нажатии. Как вы будете получать это значение?

20. Повторите предыдущее упражнение, но для горизонтального и вертикального положения левого и правого стиков.

Таймер


Ещё одна критически важная часть кода — общие функции фиксации времени. Для них мы будем использовать hump, а более конкретно hump.timer.

Timer = require 'libraries/hump/timer'

function love.load()
    timer = Timer()
end

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


Согласно документации, его можно использовать непосредственно через переменную Timer или создать новый экземпляр. Я решил выбрать второй вариант. Я использую для глобальных таймеров глобальную переменную timer, а когда потребуются таймеры внутри объектов, например, в классе Player, то у них будут собственные экземпляры таймеров, создаваемые локально.

Самыми важными функциями отсчёта времени, используемыми на протяжении всей игры, являются after, every и tween. И хотя лично я не пользуюсь функцией script, некоторым она может оказаться полезной, так что стоит её упомянуть. Давайте разберём функции отсчёта времени:

function love.load()
    timer = Timer()
    timer:after(2, function() print(love.math.random()) end)
end


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

function love.load()
    timer = Timer()
    timer:after(2, function()
        print(love.math.random())
        timer:after(1, function()
            print(love.math.random())
            timer:after(1, function()
                print(love.math.random())
            end)
        end)
    end)
end


В этом примере через две секунды после запуска будет выведено случайное число, затем ещё одно через одну секунду (через три секунды после запуска), и, наконец, через одну секунду ещё одно (через четыре секунды после запуска). Это в чём-то похоже на работу функции script, так что вы можете выбрать наиболее удобную вам.

function love.load()
    timer = Timer()
    timer:every(1, function() print(love.math.random()) end)
end


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

function love.load()
    timer = Timer()
    timer:every(1, function() print(love.math.random()) end, 5)
end


Этот код выведет за первые пять срабатываний пять случайных чисел. Один из способов завершить срабатывание функции every без явного указания количества повторов — заставить её возвращать false. Это полезно в ситуациях, когда условие останова не фиксировано или неизвестно в момент вызова every.

Ещё один способ использования поведения функции every — применение функции after, например, так:

function love.load()
    timer = Timer()
    timer:after(1, function(f)
        print(love.math.random())
        timer:after(1, f)
    end)
end


Я никогда не изучал внутреннюю работу этой функции, но автор библиотеки решил реализовать это таким образом и задокументировал его в инструкции, поэтому я просто воспользовался им. Удобство реализации функционала every таким образом заключается в том, что мы можем менять время между срабатываниями, изменяя значение во втором вызове after внутри первого:

function love.load()
    timer = Timer()
    timer:after(1, function(f)
        print(love.math.random())
        timer:after(love.math.random(), f)
    end)
end


В этом примене время между каждым срабатыванием является переменным (от 0 до 1, так как love.math.random по умолчанию возвращает значения в этом интервале). Такого поведения по умолчанию невозможно достигнуть с помощью функции every. Срабатывания с переменными интервалами очень полезны во множестве ситуаций, поэтому стоит знать, как они реализуются. Теперь перейдём к функции tween:

function love.load()
    timer = Timer()
    circle = {radius = 24}
    timer:tween(6, circle, {radius = 96}, 'in-out-cubic')
end

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

function love.draw()
    love.graphics.circle('fill', 400, 300, circle.radius)
end


Функцию tween освоить сложнее всего, потому что она использует много аргументов: она получает число секунд, рабочую таблицу, целевую таблицу и режим перехода. Он выполняет переход в рабочей таблице к значениям в целевой таблице. В приведённом выше примере у таблицы circle есть ключ radius с начальным значением 24. В течение 6 секунд значение будет изменяться до 96 в режиме перехода in-out-cubic. (Вот полезный список всех режимов переходов) Это кажется сложным, но выглядит примерно так:

GIF
66bc1235c00da385752a590627d1f2c4.gif


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

function love.load()
    timer = Timer()
    circle = {radius = 24}
    timer:after(2, function()
        timer:tween(6, circle, {radius = 96}, 'in-out-cubic', function()
            timer:tween(6, circle, {radius = 24}, 'in-out-cubic')
        end)
    end)
end


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

GIF
0061b4ec3111676526371969c997ddf6.gif


Эти три функции — after, every и tween — остаются в группе самых полезных функций в моей кодовой базе. Они очень гибкие и с их помощью можно добиться очень многого. Так что разберитесь в них, чтобы обладать интуитивным пониманием того, что делаете!


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

function love.load()
    timer = Timer()
    local handle_1 = timer:after(2, function() print(love.math.random()) end)
    timer:cancel(handle_1)


Вот, что происходит в этом примере: сначала мы вызываем after для вывода в консоль через две секунды случайного числа и сохраняем дескриптор этого таймера в переменной handle_1. Затем мы отменяем этот вызов, вызывая cancel с аргументом handle_1. Очень важно научиться это делать, потому что часто у нас возникают ситуации, когда мы создаём вызовы по таймеру на основе определённых событий. Например, когда игрок нажимает клавишу r, мы хотим через две секунды вывести в консоль случайное число:

function love.keypressed(key)
    if key == 'r' then
        timer:after(2, function() print(love.math.random()) end)
    end
end


Если добавить этот код в файл main.lua и запустить проект, то после нажатия r на экране с задержкой должно появиться случайное число. Если нажать r несколько раз, то с задержкой появится несколько чисел, одно за другим. Но иногда нам нужно такое поведение, чтобы при повторении события несколько раз оно сбрасывало бы таймер и снова начинала отсчитывать с 0. Это значит, что при нажатии на r мы хотим, чтобы отменялись все предыдущие таймеры, созданные при выполнении этого события в прошлом. Один из способов реализации этого — каким-то образом хранить все дескрипторы, как-то привязывать их к идентификатору события и вызывать некую функцию отмены для самого идентификатора события, что будет отменять дескрипторы всех таймеров, связанных с этим событием. Вот как выглядит решение:

function love.keypressed(key)
    if key == 'r' then
        timer:after('r_key_press', 2, function() print(love.math.random()) end)
    end
end


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

© Habrahabr.ru