[Перевод] Создание игры на Lua и LÖVE — 1
Введение
В этой серии туториалов мы рассмотрим создание завершённой игры с помощью Lua и LÖVE. Туториал предназначен для программистов, имеющих некоторый опыт, но только начинающих осваивать разработку игр, или для разработчиков игр, уже имевших опыт работы с другими языками или фреймворками, но желающими лучше узнать Lua или LÖVE.
Создаваемая нами игра будет сочетанием Bit Blaster XL и дерева пассивных навыков Path of Exile. Она достаточно проста, чтобы можно было рассмотреть её в нескольких статьях, не очень больших по объёму, но содержащих слишком большой объём знаний для новичка.
Кроме того, туториал имеет уровень сложности, не раскрываемый в большинстве туториалов по созданию игр. Большинство проблем, возникающих у новичков в разработке игр, связано с масштабом проекта. Обычно советуют начинать с малого и постепенно расширять объём. Хотя это и неплохая идея, но если вас интересуют такие проекты, которые никак нельзя сделать меньше, то в Интернете довольно мало ресурсов, способных вам помочь в решении встречаемых задач.
Что касается меня, то я всегда интересовался созданием игр со множеством предметов/пассивных возможностей/навыков, поэтому когда я приступал к работе, мне было сложно найти хороший способ структурирования кода, чтобы не запутаться в нём. Надеюсь, моя серия туториалов поможет кому-нибудь в этом.
Требования
Прежде чем приступить, я перечислю некоторые из знаний, необходимых для освоения этого туториала:
- Основы программирования: переменные, циклы, условные операторы, основные структуры данных и т.д.;
- Основы ООП, например, понимание классов, экземпляров, атрибутов и методов;
- И самые основы Lua; этого краткого туториала должно быть достаточно.
По сути, этот туториал не предназначен для людей, делающих первые шаги в программировании. Кроме того, здесь я буду давать упражнения. Если у вас когда-нибудь были ситуации, когда вы заканчивали туториал и не знали, куда двигаться дальше, то, возможно, так происходило потому, что у вас не было упражнений. Если вы не хотите, чтобы такое повторялось, то рекомендую хотя бы попробовать их сделать.
Оглавление
- Статья 1
- Часть 1. Игровой цикл
- Часть 2. Библиотеки
- Часть 3. Комнаты и области
- Часть 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
, то есть эта функция будет очень быстро случайным образом отрисовывать изображение на экране:
Заметьте, что перед каждым кадром экран очищается, в противном случае отрисовываемое изображение постепенно заполнило бы весь экран, отрисовываясь в случайных позициях. Так происходит потому, что 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. Он также должен обновляться и отрисовываться на экране. Вот, как должен выглядеть экран:
7. Создайте класс HyperCircle
, который наследует от класса Circle
. HyperCircle
похож на Circle
, только вокруг него отрисовывается внешний круг. Он должен получать в конструкторе дополнительные аргументы line_width
и outer_radius
. Экземпляр этого класса HyperCircle
нужно создать в позиции 400, 300 с радиусом 50, шириной линии 10 и внешним радиусом 120. Экран должен выглядеть вот так:
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
. (Вот полезный список всех режимов переходов) Это кажется сложным, но выглядит примерно так:
Функция 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
Это будет выглядеть вот так:
Эти три функции — 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
. Если клавиша нажимается повторно несколько раз, то модуль автоматически видит, что у события есть другие зарегистрированные таймеры и по умолчанию отменяет предыдущие таймеры, к чему мы и стремимся. Если метка не используется, то по умолч