Разработка на LÖVE
Цель поста — в максимально простой форме описать основные этапы разработки с помощью фреймворка LÖVE, на примере классической игры atari-автоматов Asteroids.
Уголок почемучки
Что такое LÖVE и почему именно это?
LÖVE — фреймворк для двухмерных игр. Он не является движком, только прослойкой между Lua и SDL2, с дополнительными приятными фишками, вроде чистоты синтаксиса, минимумом дополнительных телодвижений чтобы заставить работать OpenGL, и набором библиотек (вроде Box2d), позволяющих сразу сделать что-то забавное, и, не сходя с места, поковырять то что получилось. Но, притом, LÖVE отличается минимумом отсебятины и низким уровнем взаимодействия с железом, что позволяет делать свой движок вокруг фреймворка (для самообучения/дальнейшего применения) или сразу хардкодить игрушку.
Простота фреймворка позволяет писать простые прототипы или даже мини-игры тем, кто не является программистом, концентрируясь на процессе программирования, а не на освоении конкретной движковой технологии. Моя практика показала, что обучаемые возраста 14–17 лет, с гораздо большим удовольствием занимаются разработкой простых игр, чем выполняют классические лабораторные работы по вычислению корней квадратных уравнений или подсчёта кредитных ставок, а некоторые ученики начинают самостоятельно углубляться в материал, после чего, порой, становятся неплохими программистами.
Почему Lua? Язык достаточно прост для освоения, проще чем JavaScript и Python, но с него достаточно просто переходить как на вышеуказанные, так и на низкоуровневые (С/С++). Так же он достаточно популярен в разработке видеоигр, как часть чего-то более крупного (cryEngine, GMod, OpenComputers в Minecraft, etc), и если в какой-то игре присутствует моддинг — с очень высокой вероятностью, он использует Lua.
Пусть не пугает бедность стандартной библиотеки, под большую часть задач существуют сторонние разработки (настолько популярные, чтобы стать практически стандартом языка), но в бедности есть и обратная сторона, такая как скорость освоения и возможность запихнуть интерпретатор языка в микроконтроллер, чем некоторые и пользуются, со всеми преимуществами и недостатками скриптов.
Плюс LÖVE по умолчанию поставляется с виртуальной машиной LuaJIT, которая многократно ускоряет исполнение (критично для игр), и позволяет использовать FFI: подключение библиотек написанных на C, инициализация и использование C-структур, которые, с метатаблицами, можно превратить в lua-объекты, и которые экономят время создания/память и т.п.
Чуть ближе к делу
Для дальнейшей работы, нам потребуется выполнить следующий набор действий:
- Загружаем последнюю версию LÖVE с официального сайта;
- Настраиваем запуск текущего проекта в LÖVE, стандартный метод тестового запуска — открыть директорию с файлом main.lua в исполняемом файле love. Так же, можно паковать содержимое директории с файлом main.lua в zip-архив, и или перетаскивать на исполняемый файл, или переименовать .zip в .love и настроить ассоциации файлов. Я считаю что проще настроить шорткат для текущего редактора, у notepad++ это, например:
Примеры для sublime можно найти в соседней статье;path/to/love.exe $(CURRENT_DIRECTORY) - Создаём пустую директорию и добавляем в неё файл с именем main.lua. Желательно чтобы в пути не было пробелов и кириллицы, а то некоторые напихают пробелов, а потом жалуются, но для обхода можно чуть изменить шорткат или метод запуска;
- Открываем в любимом редакторе наш чистый и незапятнанный файл main.lua, и LÖVE-Wiki в любимом браузере.
Ещё ближе, но не совсем
Первое что стоит узнать, это то, что фреймворк функционирует через набор колбеков, которые мы пишем в глобальную таблицу love, которая уже объявлена:
function love.load(arg)
-- Код в функции love.load будет вызван один раз,
-- как только проект будет запущен.
end
function love.update(dt)
-- Код функций update и draw будут запускаться каждый кадр,
-- чередуясь, в бесконечном цикле:
-- "посчитали->нарисовали->посчитали->нарисовали->"
-- пока не будет вызван выход из приложения.
end
function love.draw()
-- Все функции взаимодействия с модулями фреймворка -
-- аналогично прячутся внутри таблицы love.
love.graphics.print('Hello dear Love user!', 100, 100)
end
После запуска данного кода, вы должны ощутить просветление и приступить к следующему этапу: что-то, отдалённо напоминающее нечто полезное.
Уже что-то похожее на дело
У Lua, по умолчанию, отсутствует «нормальное ООП», поэтому в данном материале будет довольно сложная для начинающих конструкция отсюда, пункт 3.2, хотя если вы незнакомы с таблицами, стоит прочитать весь третий пункт.
Первым делом, так как мы делаем Asteroids, мы хотим получить кораблик, которым крайне желательно ещё и рулить.
Далее, мы хотим чем-то стрелять и цели, в которые можно попасть.
Аналогично, хотелось бы чтобы где-то вёлся подсчёт очков и манипулирование всем подряд.
Далее будет очень много кода, но надеюсь, комментарии будут достаточно содержательными.
-- Заранее инициализируем ссылки на имена классов, которые понадобятся,
-- ибо вышестоящие классы будут использовать часть нижестоящих.
local Ship, Bullet, Asteroid, Field
Ship = {}
-- У всех таблиц, метатаблицей которых является ship,
-- дополнительные методы будут искаться в таблице ship.
Ship.__index = Ship
-- Задаём общее поле для всех членов класса, для взаимодействия разных объектов
Ship.type = 'ship'
-- Двоеточие - хитрый способ передать таблицу первым скрытым аргументом 'self'.
function Ship:new(field, x, y)
-- Сюда, в качестве self, придёт таблица Ship.
-- Переопределяем self на новый объект, self как таблица Ship больше не понадобится.
self = setmetatable({}, self)
-- Мы будем передавать ссылку на игровой менеджер, чтобы командовать им.
self.field = field
-- Координаты:
self.x = x or 100 -- 100 - дефолт
self.y = y or 100
-- Текущий угол поворота:
self.angle = 0
-- И заполняем всё остальное:
-- Вектор движения:
self.vx = 0
self.vy = 0
-- Ускорение, пикс/сек:
self.acceleration = 200
-- Скорость поворота:
self.rotation = math.pi
-- Всякие таймеры стрельбы:
self.shoot_timer = 0
self.shoot_delay = 0.3
-- Радиус, для коллизии:
self.radius = 30
-- Список вершин полигона, для отрисовки нашего кораблика:
self.vertexes = {0, -30, 30, 30, 0, 20, -30, 30}
--[[
Получится что-то такое, только чуть ровнее:
/\
/ \
/_/\_\
]]
-- Возвращаем свежеиспечёный объект.
return self
end
function Ship:update(dt)
-- Декрементов нема, и инкрементов тоже, но это не очень страшно, правда?
-- dt - дельта времени, промежуток между предыдущим и текущим кадром.
self.shoot_timer = self.shoot_timer - dt
-- Управление:
-- "Если зажата кнопка и таймер истёк" - спавним новую пулю.
if love.keyboard.isDown('x') and self.shoot_timer < 0 then
self.field:spawn(Bullet:new(self.field, self.x, self.y, self.angle))
-- И сбрасываем таймер, потому что мы не хотим непрерывных струй из пуль,
-- хоть это и забавно.
self.shoot_timer = self.shoot_delay
end
if love.keyboard.isDown('left') then
-- За секунду, сумма всех dt - почти ровно 1,
-- соответственно, за секунду, кораблик повернётся на угол Pi,
-- полный оборот - две секунды, все углы в радианах.
self.angle = self.angle - self.rotation * dt
end
if love.keyboard.isDown('right') then
self.angle = self.angle + self.rotation * dt
end
if love.keyboard.isDown('up') then
-- Вычисляем вектор ускорения, который мы приобрели за текущий кадр.
local vx_dt = math.cos(self.angle) * self.acceleration * dt
local vy_dt = math.sin(self.angle) * self.acceleration * dt
-- Прибавляем к собственному вектору движения полученный.
self.vx = self.vx + vx_dt
self.vy = self.vy + vy_dt
end
-- Прибавляем к текущим координатам вектор движения за текущий кадр.
self.x = self.x + self.vx * dt
self.y = self.y + self.vy * dt
-- Пусть это и космос, но торможение в пространстве никто не отменял:
-- мы тормозим в классике, и тут должны.
-- Торможение получается прогрессивным -
-- чем быстрее двигаемся, тем быстрее тормозим.
self.vx = self.vx - self.vx * dt
self.vy = self.vy - self.vy * dt
--Тут уже проверки координат на превышение полномочий:
--как только центр кораблика вылез за пределы экрана,
--мы его тут же перебрасываем на другую сторону.
local screen_width, screen_height = love.graphics.getDimensions()
if self.x < 0 then
self.x = self.x + screen_width
end
if self.y < 0 then
self.y = self.y + screen_height
end
if self.x > screen_width then
self.x = self.x - screen_width
end
if self.y > screen_height then
self.y = self.y - screen_height
end
end
function Ship:draw()
-- Говорим графической системе,
-- что всё следующее мы будем рисовать белым цветом.
love.graphics.setColor(255,255,255)
-- Вот сейчас будет довольно сложно,
-- грубо говоря, это трансформации над графической системой.
-- Запоминаем текущее состояние графической системы.
love.graphics.push()
-- Переносим центр графической системы на координаты кораблика.
love.graphics.translate (self.x, self.y)
-- Поворачиваем графическую систему на нужный угол.
-- Прибавляем Pi/2 потому, что мы задавали вершины полигона
-- острым концом вверх а не вправо, соответственно, при отрисовке
-- нам нужно чуть довернуть угол чтобы скомпенсировать.
love.graphics.rotate (self.angle + math.pi/2)
-- Рендерим вершины полигона, line - контур, fill - заполненный полигон.
love.graphics.polygon('line', self.vertexes)
-- И, наконец, возвращаем топологию в исходное состояние
-- (перед love.graphics.push()).
love.graphics.pop()
-- Это было слегка сложно,
-- рисовать кружочки/прямоугольнички значительно проще:
-- там можно прямо указать координаты, и сразу получить результат
-- и так мы будем рисовать астероиды/пули.
-- Но на такой методике можно без проблем сделать игровую камеру.
-- За полной справкой лучше залезть в вики,
end
-- "Пушка! Они заряжают пушку! Зачем? А, они будут стрелять!"
-- Мы тоже хотим стрелять.
-- Для стрельбы, нам необходимы пули, которыми мы будем стрелять.
-- Всё почти то же самое что у кораблика:
Bullet = {}
Bullet.__index = Bullet
-- Это - общие параметры для всех членов класса,
-- пули летят с одинаковой скоростью и имеют один тип,
-- поэтому можем выделить это в класс:
Bullet.type = 'bullet'
Bullet.speed = 300
function Bullet:new(field, x, y, angle)
self = setmetatable({}, self)
-- Аналогично задаём параметры
self.field = field
self.x = x
self.y = y
self.radius = 3
-- время жизни
self.life_time = 5
-- Нам надо бы вычислить
-- вектор движения из угла поворота и скорости:
self.vx = math.cos(angle) * self.speed
self.vy = math.sin(angle) * self.speed
-- Так как у объекта self нет поля speed,
-- поиск параметра продолжится в таблице под полем
-- __index у метатаблицы
return self
end
function Bullet:update(dt)
-- Управляем временем жизни:
self.life_time = self.life_time - dt
if self.life_time < 0 then
-- У нас пока нет такого метода,
-- но это тоже неплохо.
self.field:destroy(self)
return
end
-- Те же векторы
self.x = self.x + self.vx * dt
self.y = self.y + self.vy * dt
-- Пулям тоже не стоит улетать за границы экрана
local screen_width, screen_height = love.graphics.getDimensions()
if self.x < 0 then
self.x = self.x + screen_width
end
if self.y < 0 then
self.y = self.y + screen_height
end
if self.x > screen_width then
self.x = self.x - screen_width
end
if self.y > screen_height then
self.y = self.y - screen_height
end
end
function Bullet:draw()
love.graphics.setColor(255,255,255)
-- Обещанная простая функция отрисовки.
-- Полигоны, увы, так просто вращать не получится
love.graphics.circle('fill', self.x, self.y, self.radius)
end
-- В кого стрелять? В мимопролетающие астероиды, конечно.
Asteroid = {}
Asteroid.__index = Asteroid
Asteroid.type = 'asteroid'
function Asteroid:new(field, x, y, size)
self = setmetatable({}, self)
-- Аналогично предыдущим классам.
-- Можно было было бы провернуть наследование,
-- но это может быть сложно для восприятия начинающих.
self.field = field
self.x = x
self.y = y
-- Размерность астероида будет варьироваться 1-N.
self.size = size or 3
-- Векторы движения будут - случайными и неизменными.
self.vx = math.random(-20, 20)
self.vy = math.random(-20, 20)
self.radius = size * 15 -- модификатор размера
-- Тут вводится параметр здоровья,
-- ибо астероид может принять несколько ударов
-- прежде чем сломаться. Чуть рандомизируем для интереса.
-- Чем жирнее астероид, тем потенциально жирнее он по ХП:
self.hp = size + math.random(2)
-- Пусть они будут ещё и разноцветными.
self.color = {math.random(255), math.random(255), math.random(255)}
return self
end
-- Тут сложный метод, поэтому выделяем его отдельно
function Asteroid:applyDamage(dmg)
-- если урон не указан - выставляем единицу
dmg = dmg or 1
self.hp = self.hp - 1
if self.hp < 0 then
-- Подсчёт очков - самое главное
self.field.score = self.field.score + self.size * 100
self.field:destroy(self)
if self.size > 1 then
-- Количество обломков слегка рандомизируем.
for i = 1, 1 + math.random(3) do
self.field:spawn(Asteroid:new(self.field, self.x, self.y, self.size - 1))
end
end
-- Если мы были уничтожены, вернём true, это удобно для некоторых случаев.
return true
end
end
-- Мы довольно часто будем применять эту функцию ниже
local function collide(x1, y1, r1, x2, y2, r2)
-- Измеряем расстояния между точками по Теореме Пифагора:
local distance = (x2 - x1) ^ 2 + (y2 - y1) ^ 2
-- Коль это расстояние оказалось меньше суммы радиусов - мы коснулись.
-- Возводим в квадрат чтобы сэкономить пару тактов на невычислении корней.
local rdist = (r1 + r2) ^ 2
return distance < rdist
end
function Asteroid:update(dt)
self.x = self.x + self.vx * dt
self.y = self.y + self.vy * dt
-- Астероиды у нас взаимодействуют и с пулями и с корабликом,
-- поэтому можно запихнуть обработку взаимодействия в класс астероидов:
for object in pairs(self.field:getObjects()) do
-- Вот за этим мы выставляли типы.
if object.type == 'bullet' then
if collide(self.x, self.y, self.radius, object.x, object.y, object.radius) then
self.field:destroy(object)
-- А за этим - возвращали true.
if self:applyDamage() then
-- если мы были уничтожены - прерываем дальнейшие действия
return
end
end
elseif object.type == 'ship' then
if collide(self.x, self.y, self.radius, object.x, object.y, object.radius) then
-- Показываем messagebox и завершаем работу.
-- Лучше выделить отдельно, но пока и так неплохо.
local head = 'You loose!'
local body = 'Score is: '..self.field.score..'\nRetry?'
local keys = {"Yea!", "Noo!"}
local key_pressed = love.window.showMessageBox(head, body, keys)
-- Была нажата вторая кнопка "Noo!":
if key_pressed == 2 then
love.event.quit()
end
self.field:init()
return
end
end
end
-- Границы экрана - закон, который не щадит никого!
local screen_width, screen_height = love.graphics.getDimensions()
if self.x < 0 then
self.x = self.x + screen_width
end
if self.y < 0 then
self.y = self.y + screen_height
end
if self.x > screen_width then
self.x = self.x - screen_width
end
if self.y > screen_height then
self.y = self.y - screen_height
end
end
function Asteroid:draw()
-- Указываем текущий цвет астероида:
love.graphics.setColor(self.color)
-- Полигоны, увы, так просто вращать не получится
love.graphics.circle('line', self.x, self.y, self.radius)
end
-- Наконец, пишем класс который соберёт всё воедино:
Field = {}
Field.type = 'Field'
-- Это будет синглтон, создавать много игровых менеджеров мы не собираемся,
-- поэтому тут даже __index не нужен, ибо не будет объектов,
-- которые ищут методы в этой таблице.
-- А вот инициализация/сброс параметров - очень даже пригодятся.
function Field:init()
self.score = 0
-- Таблица для всех объектов на поле
self.objects = {}
local ship = Ship:new(self, 100, 200)
print(ship)
self:spawn(ship)
end
function Field:spawn(object)
-- Это немного нестандартное применение словаря:
-- в качестве ключа и значения указывается сам объект.
self.objects[object] = object
end
function Field:destroy(object)
-- Зато просто удалять.
self.objects[object] = nil
end
function Field:getObjects()
return self.objects
end
function Field:update(dt)
-- Мы хотим создавать новые астероиды, когда все текущие сломаны.
-- Сюда можно добавлять любые игровые правила.
local asteroids_count = 0
for object in pairs(self.objects) do
-- Проверка на наличие метода
if object.update then
object:update(dt)
end
if object.type == 'asteroid' then
asteroids_count = asteroids_count + 1
end
end
if asteroids_count == 0 then
for i = 1, 3 do
-- Будем создавать новые на границах экрана
local y = math.random(love.graphics.getHeight())
self:spawn(Asteroid:new(self, 0, y, 3))
end
end
end
function Field:draw()
for object in pairs(self.objects) do
if object.draw then
object:draw()
end
end
love.graphics.print('\n Score: '..self.score)
end
-- Последние штрихи: добавляем наши классы и объекты в игровые циклы:
function love.load()
Field:init()
end
function love.update(dt)
Field:update(dt)
end
function love.draw()
Field:draw()
end
При попытке копипасты и первого запуска вышеуказанной простыни, мы можем получить что-то похожее на классический asteroids.
Смотрится неплохо, но можно сделать лучше:
1. Пространственная индексация, для ускорения обсчёта объектов;
2. Более качественная организация менеджера, с ключами-идентификаторами;
3. Всё таки, применить наследование в классах игровых объектов, наследовать их от «сферического в вакууме» (буквально) объекта, имеющего координаты и радиус, и т.п.
Реализация данных пунктов останется домашним заданием тем, кто всё таки решится раскопать простыню и чуть углубиться.
Да, данный материал написан для версии LÖVE 0.10.2.
Для людей из будущего, которые застанут версии 0.11.X и старше: в данном исходном коде, необходимо поправить таблицу цветов, изменив значения с диапазона 0–255 на соответствующие пропорции 0–1, т.е. например:
-- Цвет вроде такого:
color = {0, 127, 255}
-- Преобразовать во что-то похожее на:
color = {0, 0.5, 1}
P. S.: Буду рад фидбеку и ответам на тему «будут ли иметь ценность статьи про создание маленьких игрушек и/или инструментов для данного фреймворка».