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

https://github.com/SSYGEN/blog/issues/30
  • Перевод
  • Tutorial
image

Введение


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

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

GIF

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

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

GIF

Требования


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

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

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

GIF

Оглавление


  • Статья 1
    • Часть 1. Игровой цикл
    • Часть 2. Библиотеки
    • Часть 3. Комнаты и области
    • Часть 4. Упражнения
  • Статья 2
    • Часть 5. Основы игры
    • Часть 6. Основы класса Player
  • Статья 3
    • Часть 7. Параметры и атаки игрока
    • Часть 8. Враги
  • Статья 4
    • Часть 9. Режиссёр и игровой цикл
    • Часть 10. Практики написания кода
    • Часть 11. Пассивные навыки
  • Статья 5
    • Часть 12. Другие пассивные навыки

13. Skill Tree

14. Console

15. Final

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


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


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

function love.load()

end

function love.update(dt)

end

function love.draw()

end

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

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

function love.update(dt)

end

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

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

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

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

Мерцающая GIF

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

GIF

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

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

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

GIF

Эти три функции — 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. Это должно выглядеть вот так:

GIF

23. Для этого упражнения вам нужно будет создать полоску энергии. При каждом нажатии клавиши d полоска энергии должна симулировать полученный урон. Это должно выглядеть вот так:

GIF

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

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 происходит небольшой переход, выглядящий вот так:

GIF

Я не упоминал этого в примере с 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, но только для генератора случайных чисел LÖVE. Так как я использую LÖVE, то когда мне потребуется случайность, я буду применять его функции, а не функции 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.
  • +21
  • 9,9k
  • 5
Поделиться публикацией
Похожие публикации
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 5
  • +2
    Ух ты, фантастически. Сам хотел писать туториал «для не таких уж новичков», без такого обилия графики и со «своими библиотеками», т.е. значительно проще и объёмнее по тексту.
    Ещё товарищ не рассказал как именно он имплементировал Steam в Love2d (тема для отдельной статьи, я этим довольно плотно занимался как через ffi, так и динамической библиотекой).

    Но для тех кто пробовал Love2d и хочет сделать что-то законченное — самое то.
    • 0
      Для её установки достаточно просто скачать её и перетащить папку classic внутрь папки проекта. Обычно я создаю папку libraries и скидываю все библиотеки туда.

      А в Lua нет какого-нибудь менеджера пакетов, типа nuget/composer/npm/gem/cargo?

      • +1
        Что-то похожее на npm/pip/gem — luarocks, ставится отдельно, и заточен под Linux (с windows — некоторые проблемы первоначальной настройки, вроде прописывания всяких путей до компиляторов/стандартной библиотеки окружения/переменных в PATH). Лично в моём случае, есть сравнительно небольшой комплект библиотек: всякая почти стандартная шушера, типа cjson/luasocket/lanes — уже собрана под все архитектуры популярных ОС, или их можно выгрести с luapower/luaforwindows.

        Менеджер который управляет подключением библиотек в текущий проект — отсутствует, ты копируешь скрипты куда тебе нужно, или прописываешь пути внутрь Lua(package.path/package.cpath) или внутрь переменных окружения LUA_PATH/LUA_CPATH. Очевидно, при конечной сборке стоит скомпоновать все используемые библиотеки в одном проекте (папке), из которой они друг друга начинают подтягивать.

        Lua не шибко богата на всякие фичи, некоторые инструменты к которым все давно привыкли на своих ЯП — отсутствуют. Если есть желание — можно написать/дописать и выложить, но тут суть ЯП немножко другая.
    • +1
      Замечательная статья. Очень рад, что Луа начинает потихоньку популизироваться как нечто большее, чем просто скриптовый язык.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое