Pull to refresh

Разработка на LÖVE

Reading time 11 min
Views 29K
image

Цель поста — в максимально простой форме описать основные этапы разработки с помощью фреймворка 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-объекты, и которые экономят время создания/память и т.п.

Чуть ближе к делу



Для дальнейшей работы, нам потребуется выполнить следующий набор действий:
  1. Загружаем последнюю версию LÖVE с официального сайта;
  2. Настраиваем запуск текущего проекта в LÖVE, стандартный метод тестового запуска — открыть директорию с файлом main.lua в исполняемом файле love. Так же, можно паковать содержимое директории с файлом main.lua в zip-архив, и или перетаскивать на исполняемый файл, или переименовать .zip в .love и настроить ассоциации файлов. Я считаю что проще настроить шорткат для текущего редактора, у notepad++ это, например:
    <Command name=...>path/to/love.exe $(CURRENT_DIRECTORY)</Command>
    Примеры для sublime можно найти в соседней статье;
  3. Создаём пустую директорию и добавляем в неё файл с именем main.lua. Желательно чтобы в пути не было пробелов и кириллицы, а то некоторые напихают пробелов, а потом жалуются, но для обхода можно чуть изменить шорткат или метод запуска;
  4. Открываем в любимом редакторе наш чистый и незапятнанный файл 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.

image

Смотрится неплохо, но можно сделать лучше:

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.: Буду рад фидбеку и ответам на тему «будут ли иметь ценность статьи про создание маленьких игрушек и/или инструментов для данного фреймворка».
Tags:
Hubs:
+29
Comments 32
Comments Comments 32

Articles