Введение
В этой серии туториалов мы рассмотрим создание завершённой игры с помощью Lua и LOVE. Туториал предназначен для программистов, имеющих некоторый опыт, но только начинающих осваивать разработку игр, или для разработчиков игр, уже имевших опыт работы с другими языками или фреймворками, но желающими лучше узнать Lua или LOVE.
Создаваемая нами игра будет сочетанием Bit Blaster XL и дерева пассивных навыков Path of Exile. Она достаточно проста, чтобы можно было рассмотреть её в нескольких статьях, не очень больших по объёму, но содержащих слишком большой объём знаний для новичка.
Кроме того, туториал имеет уровень сложности, не раскрываемый в большинстве туториалов по созданию игр. Большинство проблем, возникающих у новичков в разработке игр, связано с масштабом проекта. Обычно советуют начинать с малого и постепенно расширять объём. Хотя это и неплохая идея, но если вас интересуют такие проекты, которые никак нельзя сделать меньше, то в Интернете довольно мало ресурсов, способных вам помочь в решении встречаемых задач.
Что касается меня, то я всегда интересовался созданием игр со множеством предметов/пассивных возможностей/навыков, поэтому когда я приступал к работе, мне было сложно найти хороший способ структурирования кода, чтобы не запутаться в нём. Надеюсь, моя серия туториалов поможет кому-нибудь в этом.
Требования
Прежде чем приступить, я перечислю некоторые из знаний, необходимых для освоения этого туториала:
- Основы программирования: переменные, циклы, условные операторы, основные структуры данных и т.д.;
- Основы ООП, например, понимание классов, экземпляров, атрибутов и методов;
- И самые основы Lua; этого краткого туториала должно быть достаточно.
По сути, этот туториал не предназначен для людей, делающих первые шаги в программировании. Кроме того, здесь я буду давать упражнения. Если у вас когда-нибудь были ситуации, когда вы заканчивали туториал и не знали, куда двигаться дальше, то, возможно, так происходило потому, что у вас не было упражнений. Если вы не хотите, чтобы такое повторялось, то рекомендую хотя бы попробовать их сделать.
Оглавление
- Статья 1
- Часть 1. Игровой цикл
- Часть 2. Библиотеки
- Часть 3. Комнаты и области
- Часть 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
Часть 1: Игровой цикл
Приступаем к работе
Для начала нам нужно установить в системе LOVE и научиться запускать проекты LOVE. Мы будем использовать версию LOVE 0.10.2, которую можно скачать здесь. Если вы читаете эту статью из будущего и уже вышла новая версия LOVE, то 0.10.2 можно скачать отсюда. Подробные инструкции описаны на этой странице. Сделав всё необходимое, создайте в своём проекте файл
main.lua
со следующим содержимым:function love.load()
end
function love.update(dt)
end
function love.draw()
end
Если вы запустите проект, то увидите всплывающее окно с чёрным экраном. В представленном выше коде проект LOVE выполняет функцию
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
, то есть эта функция будет очень быстро случайным образом отрисовывать изображение на экране:Заметьте, что перед каждым кадром экран очищается, в противном случае отрисовываемое изображение постепенно заполнило бы весь экран, отрисовываясь в случайных позициях. Так происходит потому, что LOVE предоставляет своим проектам стандартный игровой цикл, выполняющий после каждого кадра очистку экрана. Сейчас я расскажу об игровом цикле и о там, как его можно изменять.
Игровой цикл
Стандартный игровой цикл, используемый LOVE, находится на странице
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
, а затем отсюда начинает происходит всё остальное. Функция достаточно хорошо закомментирована, а назначение каждой функции можно узнать в LOVE wiki. Но мы пройдёмся по основам:if love.math then
love.math.setRandomSeed(os.time())
end
В первой строке мы проверяем
love.math
на неравенство nil. Все значения в Lua являются true, за исключением false и nil, поэтому условие if love.math
будет истинным, если love.math
определёна. В случае LOVE эти переменные задаются в файле 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
— это аргументы командной строки, передаваемые исполняемому файлу LOVE, когда он выполняет проект. Как видите, 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
, если она существует.Одна из особенностей LOVE заключается в том, что можно определять механизмы обработки событий в файле
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
должен находиться здесь, в конце файла, но объяснение разработчика LOVE кажется достаточно логичным.И на этом функция
love.run
завершается. Всё, что происходит внутри цикла while true
, относится к кадру, то есть love.update
и love.draw
вызываются один раз в кадр. Вся игра в сущности заключается в очень быстром повторении содержимого цикла (например, при 60 кадрах в секунду), так что привыкайте к этой мысли. Помню, что сначала мне потребовалось какое-то время для инстинктивного осознания того, почему всё так устроено.Если вы хотите прочитать об этом подробнее, то на форумах LOVE есть полезное обсуждение этой функции.
Если не хотите, то не обязательно разбираться в этом с самого начала, но это пригодится, чтобы правильным образом изменять работу игрового цикла. Есть отличная статья, в которой рассматриваются различные техники игровых циклов с качественным объяснением. Она находится здесь.
Упражнения по игровому циклу
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/LOVE, которые необходимы для проекта, а также изучим являющиеся уникальными для Lua принципы, которые вам нужно начать осваивать. К концу этой части мы освоим четыре библиотеки. Одна из целей этой части — привыкание к идее загрузки библиотек, собранных другими людьми, к чтению их документации, изучению их работы и возможностей использования в своём проекте. Сами по себе Lua и LOVE не обладают широкими возможностями, поэтому загрузка и использование кода, написанного другими людьми — стандартная и необходимая практика.
Ориентация объектов
Первое, что я здесь рассмотрю — это ориентация объектов. Существует очень много способов реализации ориентации объектов в 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
рекурсивно перечисляет все файлы внутри заданной папки и добавляет их в таблицу как строки. Она использует модуль LOVE 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
, чтобы она всё равно могла автоматически загружать все классы?Ввод
Теперь перейдём к обработке ввода. По умолчанию в LOVE для этого используется несколько обработчиков событий. Если эти функции обработки событий определены, то они могут вызываться при выполнении соответствующего события, после чего можно перехватить выполнение игры и совершить необходимые действия:
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
. Если клавиша нажимается повторно несколько раз, то модуль автоматически видит, что у события есть другие зарегистрированные таймеры и по умолчанию отменяет предыдущие таймеры, к чему мы и стремимся. Если метка не используется, то по умолчанию используется обычное поведение модуля.Расширенную версию можно скачать здесь и заменить импорт таймера в
main.lua
с libraries/hump/timer
на местонахождение файла EnhancedTimer.lua
. Лично я поместил его в libraries/enhanced_timer/EnhancedTimer
. Это также подразумевает, что библиотека hump
расположена внутри папки libraries
. Если вы назвали свои папки как-то иначе, то вам нужно изменить путь в верхней части файла EnhancedTimer
. Кроме того, можно также использовать написанную мной библиотеку, имеющую тот же функционал, что и hump.timer, плюс обрабатывающую метки событий.Упражнения с таймером
21. Пользуясь только циклом
for
и одним объявлением функции after
внутри этого цикла, напечатайте на экране 10 случайных чисел с интервалом 0,5 секунд перед каждым выводом.22. Допустим, у нас есть следующий код:
function love.load()
timer = Timer()
rect_1 = {x = 400, y = 300, w = 50, h = 200}
rect_2 = {x = 400, y = 300, w = 200, h = 50}
end
function love.update(dt)
timer:update(dt)
end
function love.draw()
love.graphics.rectangle('fill', rect_1.x - rect_1.w/2, rect_1.y - rect_1.h/2, rect_1.w, rect_1.h)
love.graphics.rectangle('fill', rect_2.x - rect_2.w/2, rect_2.y - rect_2.h/2, rect_2.w, rect_2.h)
end
Пользуясь только функцией
tween
, выполните переход атрибута w
первого прямоугольника в течение 1 секунды в режиме перехода in-out-cubic
. После этого выполните переход атрибута h
второго треугольника в течение 1 секунды в режиме перехода in-out-cubic
. После этого выполните переход обоих прямоугольников назад к их исходным атрибутам через 2 секунды в режиме перехода in-out-cubic
. Это должно выглядеть вот так:23. Для этого упражнения вам нужно будет создать полоску энергии. При каждом нажатии клавиши
d
полоска энергии должна симулировать полученный урон. Это должно выглядеть вот так:Как видите, в этой полоске энергии есть два слоя и при получении урона верхний слой движется быстрее, в то время как фоновый слой немного отстаёт от него.
24. Рассмотрим предыдущий пример с расширяющимся и сужающимся кругом: он расширяется один раз и сжимается тоже один раз. Как изменить код так, чтобы он расширялся и сжимался бесконечно?
25. Получите результаты предыдущего упражнения, пользуясь только функцией
after
.26. Привяжите клавишу
e
к расширению круга при её нажатии, а клавишу s
— к сжатию при нажатии. Каждое новое нажатие клавиши должно отменять текущее расширение/сужение.27. Допустим, у нас есть следующий код:
function love.load()
timer = Timer()
a = 10
end
function love.update(dt)
timer:update(dt)
end
Как можно, пользуясь только функцией
tween
и без размещения переменной a
внутри другой таблицы, выполнить переход её значения до 20 в течение 1 секунды в режиме перехода linear
?Табличные функции
Наконец, последняя библиотека, которую я хочу рассмотреть — это Yonaba/Moses, содержащая множество функций для более удобной обработки таблиц в Lua. Документация библиотеки находится здесь. Теперь вы уже сможете сами прочитать её и понять, как установить её и пользоваться ею самостоятельно.
Но прежде чем переходить к упражнениям, вам нужно научиться выводить таблицу в консоль для проверки её значений:
for k, v in pairs(some_table) do
print(k, v)
end
Упражнения с таблицами
Во всех упражнениях мы будем использовать следующие таблицы:
a = {1, 2, '3', 4, '5', 6, 7, true, 9, 10, 11, a = 1, b = 2, c = 3, {1, 2, 3}}
b = {1, 1, 3, 4, 5, 6, 7, false}
c = {'1', '2', '3', 4, 5, 6}
d = {1, 4, 3, 4, 5, 6}
Кроме того, в каждом упражнении можно использовать только одну функцию из библиотеки, если не сказано обратное.
28. Выведите содержимое таблицы
a
в консоль, воспользовавшись функцией each
.29. Посчитайте количество значений 1 внутри таблицы
b
.30. Прибавьте 1 ко всем значениям таблицы
d
, пользуясь функцией map
.31. Пользуясь функцией
map
, примените к таблице a
следующие преобразования: если значение является числом, то его нужно удвоить; если значение — строка, то нужно добавить к ней конкатенацией 'xD'
; если значение булево, то нужно переключить его состояние; и, наконец, если значение — таблица, его следует пропустить.32. Суммируйте все значения в списке
d
. Результат должен быть равным 26.33. Допустим, у нас имеется следующий код:
if _______ then
print('table contains the value 9')
end
Какую функцию библиотеки нужно использовать в подчёркнутом месте для проверки того, есть ли в таблице
b
значение 9?34. Найдите первый индекс, в котором находится значение 7 таблицы
c
.35. Отфильтруйте таблицу
d
так, чтобы остались только числа меньше 5.36. Отфильтруйте таблицу
c
так, чтобы остались только строки.37. Проверьте, являются ли все значения таблиц
c
и d
числами. Код должен возвращать false для первой таблицы и true для второй.38. Перемешайте случайным образом таблицу
d
.39. Сделайте так, чтобы таблица
d
шла в обратном порядке.40. Удалите все вхождения значений 1 и 4 из таблицы
d
.41. Создайте комбинацию из таблиц
b
, c
и d
, не имеющую дубликатов.42. Найдите общие значения в таблицах
b
и d
.43. Присоедините таблицу
b
к таблице d
.Часть 3: Комнаты и области
Введение
В этой части мы рассмотрим структурный код, необходимый перед переходом к самой игре. Мы изучим принцип
комнат
(Room), которые аналогичны сценам в других движках. Также мы изучим принцип областей
(Area), типа конструкции для управления объектами, который может находиться внутри комнаты. Как и в двух предыдущих частях, в этой не будет относящегося к игре кода, мы будем рассматривать только высокоуровневые архитектурные решения.Комната
Я взял идею комнат из документации GameMaker. При обдумывании подходов к решению задачи архитектуры игры мне нравится смотреть, как её решили другие, и в этом случае, даже несмотря на то, что я никогда не пользовался GameMaker, идея авторов о понятии комнаты и связанных с ней функциях дала мне хорошие подсказки.
Судя по описанию, комнаты (Rooms) — это то место, где всё происходит в игре. Это пространства, в которых создаются, обновляются и отрисовываются все игровые объекты. Возможен переход из одной комнаты в другую. Эти комнаты также являются обычными объектами, которые располагаются внутри папки
rooms
. Вот, как может выглядеть комната под названием Stage
:Stage = Object:extend()
function Stage:new()
end
function Stage:update(dt)
end
function Stage:draw()
end
Простые комнаты
В своей простейшей форме для работы этой системы достаточно всего лишь одной дополнительной переменной и одной дополнительной функции:
function love.load()
current_room = nil
end
function love.update(dt)
if current_room then current_room:update(dt) end
end
function love.draw()
if current_room then current_room:draw() end
end
function gotoRoom(room_type, ...)
current_room = _G[room_type](...)
end
Сначала в
love.load
определяется глобальная переменная current_room
. Идея заключается в том, что одновременно может быть активна только одна комната, так что в этой переменной будет храниться ссылка на текущий активный объект комнаты. Тогда при наличии текущей активной комнаты она будет отрисовываться в love.update
и love.draw
. Это значит, что у всех комнат должны быть определены функции update и draw.Для смены комнат можно использовать функцию
gotoRoom
. Она получает room_type
, которая является простой строкой с именем класса комнаты, в которую нужно перейти. Поэтому если, например, у нас есть класс Stage
, определённый как комната, то этой функции можно передавать строку 'Stage'
. Реализация этого функционала зависит от того, как была настроена автоматическая загрузка классов в предыдущей части туториала, то есть от загрузки всех классов как глобальных переменных.Глобальные переменные в Lua хранятся в таблице глобальной среды, называемой
_G
, то есть к ним можно получать доступ, как к любой другой переменной в обычной таблице. Если глобальная переменная Stage
содержит определение класса Stage, то к нему можно получить доступ, просто использовав в любом месте программы Stage
, или _G['Stage']
, или _G.Stage
. Так как мы хотим иметь возможность загружать любую произвольную комнату, то логично будет получать строку room_type
и получать доступ к определению класса через глобальную таблицу.То есть в результате, если
room_type
является строкой 'Stage'
, то строка внутри функции gotoRoom
парсит её в current_room = Stage(...)
. Это значит, что будет создан экземпляр новой комнаты Stage
. Также это значит, что при каждом переходе к новой комнате эта новая комната создаётся с нуля, а предыдущая комната удаляется. Это работает в Lua следующим образом: когда на таблицу не ссылается больше ни одна переменная, то сборщик мусора удаляет её. А когда на экземпляр предыдущей комнаты больше не ссылается переменная current_room
, то он будет удалён сборщиком мусора.В этой схеме существуют очевидные ограничения. Например, часто нам не нужно, чтобы комнаты удалялись при переходе в следующую комнату, и часто мы не хотим, чтобы новая комната создавалась с нуля при каждом переходе в неё. При такой системе избежать этого невозможно.
Однако в своей игре я использую эту схему. В игре будет всего три-четыре комнаты, и все эти комнаты не должны быть связаны друг с другом, то есть их можно без проблем создавать с нуля и удалять при переходе между ними.
Давайте рассмотрим небольшой пример того, как мы можем встроить эту систему в реально существующую игру. Для этого используем Nuclear Throne:
Посмотрите первую минуту этого видео до того момента, когда герой умирает, чтобы понять, о чём эта игра.
Игровой цикл довольно прост и для демонстрации простой схемы комнат он подходит идеально, потому что ни одна из комнат не обязана быть связана с предыдущими. (Например, вернуться к предыдущей карте невозможно.) Первый экран игры — это главное меню:
Я сделал бы его комнатой
MainMenu
и в ней создал всю логику, необходимую для работы главного меню, то есть фон, пять опций, эффект, возникающий при выборе новой опции, небольшие молнии по краям экрана и т.д. И когда игрок выбирал бы опцию, я бы вызывал gotoRoom(option_type)
, которая заменяла бы текущую комнату на создаваемую для этой опции. В нашем случае это были бы дополнительные комнаты Play
, CO-OP
, Settings
и Stats
.В качестве альтернативы можно создать одну комнату
MainMenu
, обрабатывающую все эти дополнительные опции без необходимости разделения их на разные комнаты. Часто лучше хранить всё в одной комнате и обрабатывать переходы внутри, а не во внешней системе. Выбор зависит от ситуации и в этом случае у меня недостаточно информации, чтобы сказать, что лучше.Как бы то ни было, следующее, что происходит в видео — игрок выбирает опцию Play, и это выглядит так:
Появляются новые опции и игрок может выбрать режимы normal, daily или weekly mode. Насколько я помню, они всего лишь меняют начальное число генерирования уровней, то есть в этом случае нам не нужны новые комнаты для каждой из этих опций (мы можем просто передавать разные начальные значения в качестве аргумента при вызове
gotoRoom
). Игрок выбирает опцию normal и появляется следующий экран:Я бы назвал его комнатой
CharacterSelect
. Как и остальные, она бы делала всё необходимое, что происходит на этом экране: фон, персонажи на фоне, эффекты, происходящие при смене персонажа, сам выбор персонажа и всю логику, необходимую для этого. После выбора персонажа появляется экран загрузки:Во время игры:
Когда игрок заканчивает текущий уровень, перед переходом к следующему появляется этот экран:
После того, как игрок выберет пассивный навык на предыдущем экране, снова появляется экран загрузки. Затем игра снова переходит к следующему уровню. А затем, когда игрок умирает, появляется этот экран:
Всё это разные экраны, и если бы я следовал той же логике, то я реализовал бы их как отдельные комнаты:
LoadingScreen
, Game
, MutationSelect
и DeathScreen
. Но если подумать, то некоторые из них могут оказаться излишними.Например, нет никакой причины отделять комнату
LoadingScreen
от Game
. Процесс загрузки скорее всего выполняет генерирование уровня, которое должно происходить в комнате Game
, так что нет никакого смысла отделять их друг от друга, потому что загрузка происходила бы в комнате LoadingScreen
, а не в комнате Game
, и затем данные, созданные в первой, нужно было бы переносить во вторую. По моему мнению, это ненужное переусложнение.Ещё один пример: экран смерти — это просто ещё один слой, накладываемый поверх игры (которая по-прежнему выполняется), то есть он скорее всего выполняется в той же комнате, что и игра. Думаю, что в результате единственной отдельной комнатой должен остаться экран
MutationSelect
.Это значит, что с точки зрения системы комнат игровой цикл Nuclear Throne из видео выглядел примерно так:
MainMenu
-> Play
-> CharacterSelect
-> Game
-> MutationSelect
-> Game
->… Когда случается смерть, игрок может или вернуться к MainMenu
или попробовать ещё раз и перезапустить новую Game
. Все эти переходы можно реализовать через простую функцию gotoRoom
.Сохраняющиеся комнаты
Ради полноты описания, даже несмотря на то, что в моей игре не будет использоваться эта система, я расскажу о ней, как о поддерживающей большее количество ситуаций:
function love.load()
rooms = {}
current_room = nil
end
function love.update(dt)
if current_room then current_room:update(dt) end
end
function love.draw()
if current_room then current_room:draw() end
end
function addRoom(room_type, room_name, ...)
local room = _G[room_type](room_name, ...)
rooms[room_name] = room
return room
end
function gotoRoom(room_type, room_name, ...)
if current_room and rooms[room_name] then
if current_room.deactivate then current_room:deactivate() end
current_room = rooms[room_name]
if current_room.activate then current_room:activate() end
else current_room = addRoom(room_type, room_name, ...) end
end
В этом случае кроме передачи строки
room_type
также передаётся значение room_name
. Это нужно для того, потому что в этом случае я хочу, чтобы можно было ссылаться на комнаты с помощью идентификатора, то есть каждая room_name
должна быть уникальной. Эта room_name
может быть строкой или числом — если они уникальны, то это неважно.Таким образом, в этой новой системе существует функция
addRoom
, которая просто создаёт экземпляр комнаты и хранит его внутри таблицы. Тогда функция gotoRoom
вместо создания каждый раз нового экземпляра комнаты может проверять эту таблицу, и если комната уже существует, то просто восстанавливать её, а в противном случае создавать её с нуля.Ещё одно различие здесь заключается в использовании функций
activate
и deactivate
. Если комната уже существует и вы хотите перейти в неё снова, вызвав gotoRoom
, то сначала деактивируется текущая комната, затем она изменяется на целевую комнату, а затем активируется целевая комната. Эти вызовы полезны во многих случаях, например, для сохранения данных или для загрузки данных с диска, разыменования переменных (чтобы их можно было удалить) и так далее.Эта новая система позволяет сохранять состояние комнат и оставлять их в памяти, даже когда они не активны. Так как на них всегда ссылается таблица
rooms
, то при смене current_room
на другую комнату предыдущая не будет удалена сборщиком мусора и её можно будет получить в будущем.Давайте рассмотрим пример, в котором очень пригодится эта новая система. На сей раз это будет The Binding of Isaac:
Посмотрите первую минуту этого видео. На этот раз я пропущу все меню и сосредоточусь на самом геймплее. Он состоит из перемещения из комнаты в комнату, убийства врагов и подбора предметов. Игрок может возвращаться в предыдущие комнаты и эти комнаты сохраняют состояние, в котором их оставил игрок, поэтому если он убил врагов и разрушил камни в комнате, то вернувшись в неё, он не увидит ни врагов, ни камней. Это идеально подходит для нашей системы.
Я создам следующую систему: у нас будет комната
Room
, в которой происходит весь геймплей комнаты. И будет общая комната Game
, координирующая данные на более высоком уровне. Например, в комнате Game
будет выполняться алгоритм генерирования уровней и из его результатов при помощи вызова addRoom
будут создаваться множественные экземпляры Room
. Каждый из этих экземпляров будет иметь собственный уникальный ID и при запуске игры будет использоваться gotoRoom
для активации одного из них. В процессе перемещения игрока и исследования подземелий будут выполняться дальнейшие вызовы gotoRoom
и уже созданные экземпляры Room
будут активироваться/деактивироваться при движении игрока.При перемещении из одной комнаты в другую в Isaac происходит небольшой переход, выглядящий вот так:
Я не упоминал этого в примере с Nuclear Throne, но в нём тоже есть небольшие переходы, выполняемые между комнатами. Эти переходы можно реализовать различными способами, но в случае Isaac это означает, что две комнаты должны отрисовываться одновременно, поэтому использование только одной переменной
current_room
не подойдёт. Я не буду сейчас рассматривать способы изменения кода для решения этой проблемы, но подумал, что стоит упомянуть, что представленный здесь код не будет полностью соответствовать происходящему в игре и я немного упростил всё. Когда я перейду к написанию собственной игры и реализации переходов, я расскажу об этом более подробно.Упражнения с комнатами
44. Создайте три комнаты:
CircleRoom
, которая рисует круг в центре экрана, RectangleRoom
, которая рисует прямоугольник в центре экрана и PolygonRoom
, которая рисует многоугольник в центре экрана. Привяжите клавиши F1
, F2
и F3
к переключению между комнатами.45. Что является ближайшим аналогом комнаты в следующих движках: Unity, GODOT, HaxeFlixel, Construct 2 и Phaser? Изучите их документацию и постарайтесь разобраться. Также посмотрите, какие методы имеют объекты и как можно переключаться от одной комнаты к другой.
46. Выберите две однопользовательские игры и разбейте их на части с точки зрения комнат, как я это сделал с Nuclear Throne и Isaac. Старайтесь смотреть на всё реалистично и оценивайте, должен ли каждый аспект иметь свою комнату, или нет. И попытайтесь описать то, что будет происходить при вызове
addRoom
или gotoRoom
.47. Как работает сборщик мусора Lua в общем случае? (Если вы не знаете, что такое «сборщик мусора», то почитайте об этом.) Как в Lua возникают утечки памяти? Какими способами можно избежать их возникновения или распознать их?
Области
Теперь перейдём к идее
области
(Area). Одна из операций, обычно выполняемых внутри комнаты — это управление различными объектами. Все объекты должны обновляться и отрисовываться, а также добавляться в комнату и удаляться после смерти. Иногда также требуется запрашивать объекты в определённой области (например, когда происходит взрыв, нам нужно нанести урон всем объектам вокруг него, то есть взять все объекты внутри круга и нанести им урон), а также применять к ним определённые общие действия, например, сортировку по глубине их слоя, чтобы они могли отрисовываться в определённом порядке. Все эти функции были одинаковыми в разных комнатах и разных играх, которые я создал, поэтому я собрал их в класс под названием Area
:Area = Object:extend()
function Area:new(room)
self.room = room
self.game_objects = {}
end
function Area:update(dt)
for _, game_object in ipairs(self.game_objects) do game_object:update(dt) end
end
function Area:draw()
for _, game_object in ipairs(self.game_objects) do game_object:draw() end
end
Идея в том, что этот экземпляр этого объекта будет создаваться в комнате. Сначала представленный выше код будет иметь только список потенциальных игровых объектов, и эти игровые объекты должны обновляться и отрисовываться. Все игровые объекты в игре будут наследовать от единого класса
GameObject
, имеющего несколько общих атрибутов, которые есть у всех объектов в игре. Этот класс выглядит следующим образом:GameObject = Object:extend()
function GameObject:new(area, x, y, opts)
local opts = opts or {}
if opts then for k, v in pairs(opts) do self[k] = v end end
self.area = area
self.x, self.y = x, y
self.id = UUID()
self.dead = false
self.timer = Timer()
end
function GameObject:update(dt)
if self.timer then self.timer:update(dt) end
end
function GameObject:draw()
end
Конструктор получает четыре аргумента:
area
, позицию x, y
и таблицу opts
, содержащую дополнительные необязательные аргументы. Первое, что происходит в коде — берётся эта дополнительная таблица opts
и все её атрибуты назначаются этому объекту. Например, если мы создаём GameObject
таким образом: game_object = GameObject(area, x, y, {a = 1, b = 2, c = 3})
, то строка for k, v in pairs(opts) do self[k] = v
по сути копирует объявления a = 1
, b = 2
и c = 3
в этот новый созданный экземпляр. Теперь вы уже должны понимать, что здесь происходит, но если не понимаете, то подробнее перечитайте раздел про ООП в предыдущей части, а также про работу таблиц в Lua.Далее, переданная ссылка на этот экземпляр области сохраняется в
self.area
, а позиция в self.x, self.y
. Затем этому игровому объекту назначается ID. Этот ID должен быть уникальным для каждого объекта, чтобы мы могли без конфликтов идентифицировать каждый объект. Для нашей игры вполне подойдёт простая функция генерирования UUID. Такая функция есть в библиотеке под названием lume в lume.uuid
. Мы не будем использовать эту библиотеку, нам нужна только одна эта функция, то есть логичнее будет взять только её, а не устанавливать библиотеку целиком:function UUID()
local fn = function(x)
local r = math.random(16) - 1
r = (x == "x") and (r + 1) or (r % 4) + 9
return ("0123456789abcdef"):sub(r, r)
end
return (("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"):gsub("[xy]", fn))
end
Я скопировал этот код в файл
utils.lua
. Этот файл будет содержать вспомогательные функции, которые нельзя отнести к какой-то конкретной категории. Эта функция выдаёт строку вида '123e4567-e89b-12d3-a456-426655440000'
, которая будет уникальной для любых целей.Стоит заметить, что эта функция использует функцию
math.random
. Если использовать print(UUID())
, чтобы посмотреть, что она генерирует, то мы увидим, что при выполнении проекта она будет генерировать одни и те же ID. Эта проблема возникает, потому что в ней всегда используется одинаковое начальное число (seed). Один из способов решения этой проблемы — при каждом запуске программы можно рандомизировать начальное число на основании времени, Это можно сделать с помощью math.randomseed(os.time())
.Однако я просто вместо
math.random
использовал love.math.random
. Как мы помним по первой части туториала, первая функция, вызываемая в love.run
— это love.math.randomSeed(os.time())
, которая как раз и занимается рандомизацией seed, но только для генератора случайных чисел LOVE. Так как я использую LOVE, то когда мне потребуется случайность, я буду применять его функции, а не функции Lua. Внеся такое изменение в функцию UUID
, вы увидите, что она начнёт генерировать разные ID.Вернёмся к игровому объекту, здесь определена переменная
dead
. Идея заключается в том, что когда dead
принимает значение true, игровой объект удаляется из игры. То же самое происходит и с экземпляром класса Timer
, назначенным каждому игровому объекту. Я увидел, что функции времени используются почти в каждом объекте, поэтому показалось логичным по умолчанию использовать их для всех объектов. Наконец, в функции update
обновляется таймер.С учётом всего этого, класс
Area
необходимо изменить следующим образом:Area = Object:extend()
function Area:new(room)
self.room = room
self.game_objects = {}
end
function Area:update(dt)
for i = #self.game_objects, 1, -1 do
local game_object = self.game_objects[i]
game_object:update(dt)
if game_object.dead then table.remove(self.game_objects, i) end
end
end
function Area:draw()
for _, game_object in ipairs(self.game_objects) do game_object:draw() end
end
Функция update теперь учитывает состояние переменной
dead
и действует соответственно. Сначала игровой объект обновляется обычным способом, потом выполняется проверка на dead. Если оно истинно, то объект просто удаляется из списка game_objects
. Важно здесь то, что цикл выполняется в обратном порядке, от конца списка к началу. Так происходит потому, что если мы будем удалять элементы из таблицы Lua, двигаясь в прямом порядке, то в результате пропустим некоторые элементы, как видно из этого обсуждения.Наконец, последнее, что нужно добавить — это функция
addGameObject
, которая добавляет в Area
новый игровой объект:function Area:addGameObject(game_object_type, x, y, opts)
local opts = opts or {}
local game_object = _G[game_object_type](self, x or 0, y or 0, opts)
table.insert(self.game_objects, game_object)
return game_object
end
Она будет вызываться следующим образом:
area:addGameObject('ClassName', 0, 0, {optional_argument = 1})
. Переменная game_object_type
будет работать так же, как строки в функции gotoRoom
, то есть они являются именами класса создаваемого объекта. _G[game_object_type]
в примере выше выполняет парсинг в глобальную переменную ClassName
, которая будет содержат определение класса ClassName
. Экземпляр целевого класса создаётся, добавляется в список game_objects
, а затем возвращается. Теперь этот экземпляр будет обновляться и отрисовываться в каждом кадре.Вот так будет работать этот класс. Мы будем активно изменять его в процессе разработки игры, но в целом мы описали его необходимое базовое поведение (добавление, удаление, обновление и отрисовка объектов).
Упражнения с Area
48. Создайте комнату
Stage
, в которой есть Area
. Затем создайте объект Circle
, наследующий от GameObject
и добавляйте экземпляр этого объекта в комнату Stage
в случайной позиции через каждые две секунды. Экземпляр Circle
должен уничтожать себя через случайный интервал времени от 2 до 4 секунд.49. Создайте комнату
Stage
, в которой нет Area
. Создайте объект Circle
, который не наследует от GameObject
и добавляйте экземпляр этого объекта в сцену Stage
в случайной позиции через каждые две секунды. Экземпляр Circle
должен уничтожать себя через случайный интервал времени от 2 до 4 секунд.50. В решении упражнения 1 применена функция
random
. Усовершенствуйте эту функцию таким образом, чтобы она получала только одно значение вместо двух и генерировала случайное вещественное число от 0 до значения в этом случае (когда получается только один аргумент). Также улучшите функцию так, чтобы значения min
и max
можно было обратить, то есть чтобы первое значение могло быть больше второго.51. Какова цель
local opts = opts or {}
в функции addGameObject
?Часть 4: Упражнения
В предыдущих трёх частях мы рассмотрели много кода, не относящегося непосредственно к игре. Весь этот код можно использовать независимо от создаваемой вами игры, поэтому я для себя называю его кодом
engine
, хотя и понимаю, что на самом деле это не движок. Чем дальше мы будем продвигаться в игре, тем больше и больше я буду добавлять кода, относящегося к этой категории, который можно использовать в различных играх. Если вы хотите взять самое важное из этих туториалов, то это определённо должен быть код. Он очень пригождался мне много раз.Прежде чем переходить к следующей части, где мы приступим к самой игре, нам нужно полностью освоить некоторые концепции, рассмотренные в предыдущих частях, поэтому я дам дополнительные упражнения.
52. Создайте внутри класса
Area
функцию getGameObjects
, которая будет работать следующим образом:-- Получаем все игровые объекты класса Enemy
all_enemies = area:getGameObjects(function(e)
if e:is(Enemy) then
return true
end
end)
-- Получаем все игровые объекты с энергией больше 50
healthy_objects = area:getGameObjects(function(e)
if e.hp and e.hp >= 50 then
return true
end
end)
Она получает функцию, получающую игровой объект, и выполняет её проверку. Если результат проверки равен true, то игровой объект добавляется в таблицу, возвращаемую после завершения выполнения
getGameObjects
.53. Какие значения имеют
a
, b
, c
, d
, e
, f
и g
?a = 1 and 2
b = nil and 2
c = 3 or 4
d = 4 or false
e = nil or 4
f = (4 > 3) and 1 or 2
g = (3 > 4) and 1 or 2
54. Создайте функцию
printAll
, получающую неизвестное количество аргументов и выводящую их все в консоль. printAll(1, 2, 3)
выведет в консоль 1, 2 и 3, а printAll(1, 2, 3, 4, 5, 6, 7, 8, 9)
выведет в консоль числа от 1 до 9. Количество передаваемых аргументов неизвестно и может варьироваться.55. Аналогично предыдущему упражнению, создайте функцию
printText
, получающую неизвестное количество строк, которая выполняет их конкатенацию в общую строку и выводящую эту строку в консоль.56. Как запустить цикл сбора мусора?
57. Как показать, сколько памяти занимает ваша программа на Lua?
58. Как вызвать ошибку, которая остановит выполнение программы и выдаст произвольное сообщение об ошибке?
59. Создайте класс
Rectangle
, рисующий прямоугольник с шириной и высотой в позиции создания. Создайте 10 экземпляров этого класса в случайных позициях со случайной шириной и высотой. При нажатии d
из среды должен удаляться случайный экземпляр. Когда количество экземпляров достигнет 0, в случайных позициях экрана должны создаться ещё 10 новых экземпляров со случайными шириной и высотой.60. Создайте класс
Circle
, рисующий круг с некоторым радиусом в позиции создания. Создайте 10 экземпляров этого класса в случайных позициях на экране со случайным радиусом и с интервалом 0,25 секунды между каждым экземпляром. После создания всех экземпляров (то есть через 2,5 секунды) начните удалять по одному случайному экземпляру через каждые [0,5, 1] секунд (случайное число от 0,5 до 1). После удаления всех экземпляров повторить весь процесс воссоздания 10 экземпляров и их последовательного удаления. Этот процесс должен повторяться бесконечно.61. Создайте внутри класса
Area
функцию queryCircleArea
, которая работает следующим образом:-- Получаем все объекты класса 'Enemy' и 'Projectile' в круге радиусом 50 вокруг точки 100, 100
objects = area:queryCircleArea(100, 100, 50, {'Enemy', 'Projectile'})
Она получает позицию
x
, y
, radius
и список строк, содержащий имена целевых классов. Потом она возвращает все объекты, принадлежащие к этим классам и находящиеся внутри круга радиусом radius
с центром в позиции x, y
.62. Создайте внутри класса
Area
функцию getClosestGameObject
, работающую следующим образом:-- Получаем ближайший объект класса 'Enemy' в круге радиусом 50 вокруг точки 100, 100
closest_object = area:getClosestObject(100, 100, 50, {'Enemy'})
Она получает те же аргументы, что и функция
queryCircleArea
, но возвращает только один объект (ближайший).63. Как мы можем проверить, существует ли метод в объекте, прежде чем вызвать его? И как проверить, существует ли атрибут перед тем, как использовать его значение?
64. Как можно записать содержимое одной таблицы в другую с помощью только циклом
for
?Если вам понравится эта серия туториалов, то вы можете простимулировать меня к написанию чего-то подобного в будущем:
Купив туториал на itch.io, вы получите доступ к полному исходному коду игры, к ответам на упражения из частей 1-9, к коду, разбитому по частям туториала (код будет выглядеть так, как должен выглядеть в конце каждой части) и к ключу игры в Steam.
Комментарии (5)
k12th
20.02.2018 12:08Для её установки достаточно просто скачать её и перетащить папку classic внутрь папки проекта. Обычно я создаю папку libraries и скидываю все библиотеки туда.
А в Lua нет какого-нибудь менеджера пакетов, типа nuget/composer/npm/gem/cargo?
16tomatotonns
20.02.2018 12:16+1Что-то похожее на npm/pip/gem — luarocks, ставится отдельно, и заточен под Linux (с windows — некоторые проблемы первоначальной настройки, вроде прописывания всяких путей до компиляторов/стандартной библиотеки окружения/переменных в PATH). Лично в моём случае, есть сравнительно небольшой комплект библиотек: всякая почти стандартная шушера, типа cjson/luasocket/lanes — уже собрана под все архитектуры популярных ОС, или их можно выгрести с luapower/luaforwindows.
Менеджер который управляет подключением библиотек в текущий проект — отсутствует, ты копируешь скрипты куда тебе нужно, или прописываешь пути внутрь Lua(package.path/package.cpath) или внутрь переменных окружения LUA_PATH/LUA_CPATH. Очевидно, при конечной сборке стоит скомпоновать все используемые библиотеки в одном проекте (папке), из которой они друг друга начинают подтягивать.
Lua не шибко богата на всякие фичи, некоторые инструменты к которым все давно привыкли на своих ЯП — отсутствуют. Если есть желание — можно написать/дописать и выложить, но тут суть ЯП немножко другая.
RussDragon
20.02.2018 21:50Замечательная статья. Очень рад, что Луа начинает потихоньку популизироваться как нечто большее, чем просто скриптовый язык.
16tomatotonns
Ух ты, фантастически. Сам хотел писать туториал «для не таких уж новичков», без такого обилия графики и со «своими библиотеками», т.е. значительно проще и объёмнее по тексту.
Ещё товарищ не рассказал как именно он имплементировал Steam в Love2d (тема для отдельной статьи, я этим довольно плотно занимался как через ffi, так и динамической библиотекой).
Но для тех кто пробовал Love2d и хочет сделать что-то законченное — самое то.