Всем доброго времени суток. В данной статье (если это можно назвать статьёй, в основном код), как вы уже наверно догадались, пойдёт речь о создании танка на игровом движке LÖVE.
Прежде всего определим, что должен делать наш создаваемый танк:
Для начала нам нужно создать все изображения и поместить их в какую-нибудь папку, например data/images.
Художник из меня плохой, поэтому всю графику мне предоставил пользователь форума GcUp silver52rus, за что ему ОГРОМНОЕ СПАСИБО.






Далее нам нужны вспомогательные библиотеки:
В итоге должна получиться такая структура проекта:

Для начала разберёмся с файлом main.lua:
-- Подключаем необходимые библиотеки
Class = require "classes.hump.class"
Vector = require "classes.hump.vector"
Timer = require "classes.hump.timer"
Input = require "classes.Input"
Anim = require "classes.anim8.anim8"
function love.load()
-- Здесь будет создаваться экземпляр класса "Танк"
end
function love.run()
if love.math then
love.math.setRandomSeed( os.time() )
end
if love.event then
love.event.pump()
end
if love.load then
love.load( arg )
end
if not (love.window and love.graphics and love.window.isCreated() and love.timer) then
return
end
love.timer.step()
love.graphics.setBackgroundColor( 50, 50, 50 ) -- Цвет фона
-- Главный цикл приложения
while Input.running() do
love.timer.step()
local dt = love.timer.getDelta()
Input.update()
-- Здесь будет функция обновления танка
love.graphics.clear()
love.graphics.origin()
-- Здесь будет функция отрисовки танка
love.graphics.present()
Input.reset()
love.timer.sleep( 0.001 )
end
end
В самом начале файла подключаются все необходимые для работы модули, которые будут доступны из других классов. В главном цикле происходит проверка ввода и выполняются функции обновления и отрисовки танка.
Ниже приведён код файла настроек приложения conf.lua. Здесь можно указать заголовок, ширину и высоту окна, включить или отключить какие-то модули, сделать окно на весь экран ну и прописать какие-нибудь дополнительные настройки. Более подробно о настройках проекта можно ознакомиться здесь.
function love.conf(c) c.version = "0.9.1" c.title = "Tanks" c.window.width = 1024 c.window.height = 768 c.window.resizable = false c.window.fullscreen = false c.window.fullscreentype = "normal" c.window.vsync = true c.window.fsaa = 4 c.modules.audio = true c.modules.event = true c.modules.graphics = true c.modules.image = true c.modules.joystick = false c.modules.keyboard = true c.modules.math = true c.modules.mouse = true c.modules.physics = false c.modules.sound = true c.modules.system = true c.modules.timer = true c.modules.window = true end
Наконец-то дошли до самого интересного момента - создание класса "Танк".
local Tank = Class {
-- конструктор класса
init = function( self, pos )
-- позиция танка
self.pos = pos
-- направление корпуса танка
self.body_dir = Vector( 0, 0 )
-- направление башни танка
self.top_dir = Vector( 0, -1 )
-- изображения корпуса и башни танка
self.body = love.graphics.newImage("data/images/tank_body.png")
self.top = love.graphics.newImage("data/images/tank_top.png")
-- размер одного кадра корпуса танка
self.size = Vector( 128, 128 )
-- сетка анимаций корпуса танка
-- в функцию передаётся размер одного кадра анимации и размер изображения с анимациями
local grid = Anim.newGrid( self.size.x, self.size.y, self.body:getWidth(), self.body:getHeight() )
-- анимация "покоя" танка (по сути первый кадр)
self.iddle = Anim.newAnimation( grid( 1, 1 ), 1 )
-- анимация движения танка
-- "1-9" - с 1-го по 9-й кадр
-- 1 - номер строки с кадрами анимации (в данном примере одна строка, в которой 9 кадров)
-- 0.05 - время смены кадров анимации
self.run = Anim.newAnimation( grid( "1-9", 1 ), 0.05 )
-- текущая анимация - "покой"
self.animation = self.iddle
-- изображение следа, оставляемого танком
self.trail_image = love.graphics.newImage("data/images/tank_trail.png")
-- шаг следов (чтобы следы шли не сплошной полосой, а через определённый промежуток)
self.trail_step = 0
-- массив со следами
self.tank_trails = {}
-- угол поворота корпуса танка
self.body_angle = 0
-- угол поворота башни танка
self.top_angle = 0
-- скорость перемещения танка
self.max_speed = 70
-- скорость поворота башни танка
self.max_rot_speed = 100
-- скорость полёта пуль
self.bullet_speed = 350
-- скорость полёта ракет
self.rocket_speed = 250
-- массивы с пулями и ракетами
self.bullets = {}
self.rockets = {}
-- дым от танка
self.tank_smoke = love.graphics.newImage("data/images/cloud.png")
self.ps = love.graphics.newParticleSystem( self.tank_smoke, 32 )
self.ps:setAreaSpread( "uniform", 0, 0 )
self.ps:setDirection( math.rad( self.body_angle + 90 ) )
self.ps:setEmissionRate( 10 )
self.ps:setEmitterLifetime( -1 )
self.ps:setInsertMode("bottom")
self.ps:setLinearAcceleration( 0, 1, 0, 2 )
self.ps:setParticleLifetime( 0.5, 1 )
self.ps:setRadialAcceleration( 0, 5 )
self.ps:setRotation( math.rad(0), math.rad( 360 ) )
self.ps:setSpeed( 30, 50 )
self.ps:setSpread( math.rad( 60 ) )
self.ps:setPosition( self.pos.x, self.pos.y + 60 )
self.ps:start()
end,
-- функция обновления
update = function( self, dt )
-- делаем текущую анимацию - анимация "покоя"
self.animation = self.iddle
-- плавный поворот башни танка за курсором
local mousePos = Input.mousePos()
local dir = self.pos - mousePos
local targetAngle = -math.atan2( dir.x, dir.y ) / (math.pi / 180)
local curAngle = self.top_angle
if curAngle < 0 then
curAngle = curAngle + 360
end
if targetAngle < 0 then
targetAngle = targetAngle + 360
end
local a = curAngle - targetAngle
if a > 180 then
a = a - 360
elseif a < -180 then
a = a + 360
end
local s = dt * self.max_rot_speed
if a >= -s - 0.5 and a <= s + 0.5 then
self.top_angle = targetAngle
elseif a > s + 0.5 then
self.top_angle = self.top_angle - s
elseif a < -s - 0.5 then
self.top_angle = self.top_angle + s
end
-- направление башни танка
self.top_dir.x = math.sin( math.rad( self.top_angle ) )
self.top_dir.y = -math.cos( math.rad( self.top_angle ) )
-- поворот корпуса танка по часовой стрелке
if Input.keyHeld("d") then
-- делаем текущей анимацию движения
self.animation = self.run
self.body_angle = self.body_angle + self.max_speed * dt
if self.body_angle >= 360 then
self.body_angle = 0
end
-- функция "оставления" следов
self:makeTrail()
end
-- поворот корпуса танка против часовой стрелки
if Input.keyHeld("a") then
self.animation = self.run
self.body_angle = self.body_angle - self.max_speed * dt
if self.body_angle <= 0 then
self.body_angle = 360
end
self:makeTrail()
end
-- движение вперёд
if Input.keyHeld("w") then
self.animation = self.run
self.body_dir.x = math.sin( math.rad( self.body_angle ) )
self.body_dir.y = -math.cos( math.rad( self.body_angle ) )
self.pos = self.pos + self.body_dir:normalized() * self.max_speed * dt
self:makeTrail()
end
-- движение назад
if Input.keyHeld("s") then
self.animation = self.run
self.body_dir.x = math.sin( math.rad( self.body_angle ) )
self.body_dir.y = -math.cos( math.rad( self.body_angle ) )
self.pos = self.pos - self.body_dir:normalized() * self.max_speed * dt
self:makeTrail()
end
-- стрельба пулями
if Input.mouseDown("l") then
local ind = #self.bullets + 1
self.bullets[ ind ] = {}
local x = self.pos.x - ((self.pos.y - 85) - self.pos.y) * math.sin( math.rad( self.top_angle ) )
local y = self.pos.y + ((self.pos.y - 85) - self.pos.y) * math.cos( math.rad( self.top_angle ) )
self.bullets[ ind ].pos = Vector( x, y )
self.bullets[ ind ].dir = Vector( self.top_dir.x, self.top_dir.y ):normalized()
self.bullets[ ind ].img = love.graphics.newImage("data/images/tank_bullet.png")
self.bullets[ ind ].size = Vector( self.bullets[ ind ].img:getWidth(), self.bullets[ ind ].img:getHeight() )
self.bullets[ ind ].angle = self.top_angle
end
-- стрельба ракетами
if Input.mouseDown("r") then
local ind = #self.rockets + 1
self.rockets[ ind ] = {}
local x = self.pos.x - ((self.pos.y - 85) - self.pos.y) * math.sin( math.rad( self.top_angle ) )
local y = self.pos.y + ((self.pos.y - 85) - self.pos.y) * math.cos( math.rad( self.top_angle ) )
self.rockets[ ind ].pos = Vector( x, y )
self.rockets[ ind ].dir = Vector( self.top_dir.x, self.top_dir.y ):normalized()
self.rockets[ ind ].img = love.graphics.newImage("data/images/rocket.png")
self.rockets[ ind ].size = Vector( self.rockets[ ind ].img:getWidth(), self.rockets[ ind ].img:getHeight() )
self.rockets[ ind ].angle = self.top_angle
self.rockets[ ind ].smoke = love.graphics.newImage("data/images/rocket_smoke.png")
self.rockets[ ind ].ps = love.graphics.newParticleSystem( self.rockets[ ind ].smoke, 256 )
self.rockets[ ind ].ps:setAreaSpread( "uniform", 0, 0 )
self.rockets[ ind ].ps:setDirection( math.rad( self.rockets[ ind ].angle + 90 ) )
self.rockets[ ind ].ps:setEmissionRate( 512 )
self.rockets[ ind ].ps:setEmitterLifetime( -1 )
self.rockets[ ind ].ps:setInsertMode("bottom")
self.rockets[ ind ].ps:setLinearAcceleration( 0, 10, 0, 30 )
self.rockets[ ind ].ps:setParticleLifetime( 0.1, 0.5 )
self.rockets[ ind ].ps:setRadialAcceleration( 0, 0 )
self.rockets[ ind ].ps:setSpeed( 30, 50 )
self.rockets[ ind ].ps:setSpread( math.rad( 30 ) )
self.rockets[ ind ].ps:setPosition(
self.rockets[ ind ].pos.x
- ((self.rockets[ ind ].pos.y + 16)
- self.rockets[ ind ].pos.y)
* math.sin( math.rad( self.rockets[ ind ].angle ) ),
self.rockets[ ind ].pos.y
+ ((self.rockets[ ind ].pos.y + 16)
- self.rockets[ ind ].pos.y)
* math.cos( math.rad( self.rockets[ ind ].angle ) ) )
self.rockets[ ind ].ps:start()
end
-- обновление пуль и ракет
for i = #self.bullets, 1, -1 do
self.bullets[i].pos = self.bullets[i].pos + self.bullets[i].dir * self.bullet_speed * dt
if self:checkBulletBounds( self.bullets[i] ) then
table.remove( self.bullets, i )
end
end
for i = #self.rockets, 1, -1 do
self.rockets[i].pos = self.rockets[i].pos + self.rockets[i].dir * self.rocket_speed * dt
self.rockets[i].ps:setPosition(
self.rockets[i].pos.x
- ((self.rockets[i].pos.y + 16)
- self.rockets[i].pos.y)
* math.sin( math.rad( self.rockets[i].angle ) ),
self.rockets[i].pos.y
+ ((self.rockets[i].pos.y + 16)
- self.rockets[i].pos.y)
* math.cos( math.rad( self.rockets[i].angle ) ) )
self.rockets[i].ps:update( dt )
if self:checkRocketBounds( self.rockets[i] ) then
table.remove( self.rockets, i )
end
end
-- обновление анимации
self.animation:update( dt )
-- обновление дыма от танка
self.ps:setPosition( self.pos.x - ((self.pos.y + 60) - self.pos.y) * math.sin( math.rad( self.body_angle ) ),
self.pos.y + ((self.pos.y + 60) - self.pos.y) * math.cos( math.rad( self.body_angle ) ) )
self.ps:setDirection( math.rad( self.body_angle + 90 ) )
self.ps:update( dt )
-- выход по нажатию Escape
if Input.keyDown("escape") then
Input.exit()
end
end,
-- функция отрисовки танка
draw = function( self )
-- сначала рисуем следы от танка
for i = 1, #self.tank_trails do
love.graphics.draw( self.tank_trails[i].img,
self.tank_trails[i].pos.x,
self.tank_trails[i].pos.y,
math.rad( self.tank_trails[i].angle ),
1,
1,
self.tank_trails[i].size.x / 2,
self.tank_trails[i].size.y / 2 )
end
-- отрисовка корпуса танка
self.animation:draw( self.body,
self.pos.x,
self.pos.y,
math.rad( self.body_angle ),
1,
1,
self.size.x / 2,
self.size.y / 2 )
-- отрисовка дыма от танка
love.graphics.draw( self.ps, 0, 0 )
-- отрисовка башни танка
love.graphics.draw( self.top,
self.pos.x,
self.pos.y,
math.rad( self.top_angle ),
1,
1,
self.size.x / 2,
self.size.y / 2 + 27 )
-- отрисовка пуль
for i = 1, #self.bullets do
love.graphics.draw( self.bullets[i].img,
self.bullets[i].pos.x,
self.bullets[i].pos.y,
math.rad( self.bullets[i].angle ),
1,
1,
self.bullets[i].size.x / 2,
self.bullets[i].size.y / 2 )
end
-- отрисока ракет
for i = 1, #self.rockets do
love.graphics.draw( self.rockets[i].img,
self.rockets[i].pos.x,
self.rockets[i].pos.y,
math.rad( self.rockets[i].angle ),
1,
1,
self.rockets[i].size.x / 2,
self.rockets[i].size.y / 2 )
love.graphics.draw( self.rockets[i].ps, 0, 0 )
end
end,
-- функции, которые проверяют вылет пуль и ракет за пределы экрана
checkBulletBounds = function( self, bul )
if bul.pos.x <= 0 or bul.pos.x >= love.graphics.getWidth()
or bul.pos.y <= 0 or bul.pos.y >= love.graphics.getHeight() then
return true
end
return false
end,
checkRocketBounds = function( self, r )
if r.pos.x <= 0 or r.pos.x >= love.graphics.getWidth()
or r.pos.y <= 0 or r.pos.y >= love.graphics.getHeight() then
return true
end
return false
end,
-- функция создания и удаления следов
makeTrail = function( self )
self.trail_step = self.trail_step + love.timer.getDelta()
if self.trail_step >= 0.1 then
self.trail_step = 0
local ind = #self.tank_trails + 1
self.tank_trails[ ind ] = {}
self.tank_trails[ ind ].pos = Vector( self.pos.x, self.pos.y )
self.tank_trails[ ind ].img = self.trail_image
self.tank_trails[ ind ].angle = self.body_angle
self.tank_trails[ ind ].size = Vector( self.trail_image:getWidth(), self.trail_image:getHeight() )
-- удалить через 1 секунду первый созданный след
Timer.add( 1, function() table.remove( self.tank_trails, 1 ) end )
end
end
}
return Tank
Попробую понятней объяснить, что в данном классе происходит. В основном нам интересна только функция обновления, поэтому рассмотрим её более подробней.
Первой строкой мы "сообщаем" танку, что должна проигрываться анимация, когда танк стоит.
Далее по коду идёт плавный поворот башни танка в сторону курсора. В этом коде есть небольшой баг: когда поворот идёт по часовой стрелке и дуло находится во II четверти окружности, при переходе курсора в I четверть башня танка начинает вращаться в противоположную сторону. Как работает этот код? Да очень просто.
Сначала мы получаем позицию курсора мыши и высчитываем вектор от позиции расположения танка до курсора.
local mousePos = Input.mousePos() local dir = self.pos - mousePos
Затем получаем угол, до которого нам нужно повернуть башню.
local targetAngle = -math.atan2( dir.x, dir.y ) / (math.pi / 180)
Но наша башня уже повёрнута на какой-то угол, поэтому мы берём разность текущего угла, на который повёрнута башня и угла, до которого необходимо её повернуть. В результате получаем угол, на который необходимо "довернуть" башню от текущего угла поворота. С каждым обновлением кадра этот угол будет уменьшаться.
local a = curAngle - targetAngle
Далее определяем некоторую переменную s, на которую будет изменяться текущий угол поворота башни. И уже в зависимости a от s определяется, в какую сторону будет осуществляться поворот.
local s = dt * self.max_rot_speed if a >= -s - 0.5 and a <= s + 0.5 then self.top_angle = targetAngle elseif a > s + 0.5 then self.top_angle = self.top_angle - s elseif a < -s - 0.5 then self.top_angle = self.top_angle + s end
Далее определяем вектор башни танка. Он нам понадобится, когда будем реализовывать стрельбу.
self.top_dir.x = math.sin( math.rad( self.top_angle ) ) self.top_dir.y = -math.cos( math.rad( self.top_angle ) )
С движениями вперёд и назад и поворотами думаю всё понятно.
Далее осуществляется стрельба снарядами. Рассмотрим стрельбу на примере ракет. Для обычных снарядов принцип тотже, только у ракет ещё создаётся система частиц.
-- Проверяем однократное нажатие правой кнопки мыши
if Input.mouseDown("r") then
-- Создаём новый объект, это и будет наша ракета
local ind = #self.rockets + 1
self.rockets[ ind ] = {}
-- Здесь определяется из какой точки будут вылетать ракеты
-- Делаем чтобы они вылеталь из конца дула
local x = self.pos.x - ((self.pos.y - 85) - self.pos.y) * math.sin( math.rad( self.top_angle ) )
local y = self.pos.y + ((self.pos.y - 85) - self.pos.y) * math.cos( math.rad( self.top_angle ) )
-- Сохраняем позицию ракеты
self.rockets[ ind ].pos = Vector( x, y )
-- Направление, в котором ракета будет двигаться
self.rockets[ ind ].dir = Vector( self.top_dir.x, self.top_dir.y ):normalized()
-- Спрайт ракеты
self.rockets[ ind ].img = love.graphics.newImage("data/images/rocket.png")
self.rockets[ ind ].size = Vector( self.rockets[ ind ].img:getWidth(), self.rockets[ ind ].img:getHeight() )
-- Угол поворота. Равен углу поворота башни танка
self.rockets[ ind ].angle = self.top_angle
-- Указываем спрайт для системы частиц
self.rockets[ ind ].smoke = love.graphics.newImage("data/images/rocket_smoke.png")
-- Создаём систему частиц
self.rockets[ ind ].ps = love.graphics.newParticleSystem( self.rockets[ ind ].smoke, 256 )
self.rockets[ ind ].ps:setAreaSpread( "uniform", 0, 0 )
self.rockets[ ind ].ps:setDirection( math.rad( self.rockets[ ind ].angle + 90 ) )
self.rockets[ ind ].ps:setEmissionRate( 512 )
self.rockets[ ind ].ps:setEmitterLifetime( -1 )
self.rockets[ ind ].ps:setInsertMode("bottom")
self.rockets[ ind ].ps:setLinearAcceleration( 0, 10, 0, 30 )
self.rockets[ ind ].ps:setParticleLifetime( 0.1, 0.5 )
self.rockets[ ind ].ps:setRadialAcceleration( 0, 0 )
self.rockets[ ind ].ps:setSpeed( 30, 50 )
self.rockets[ ind ].ps:setSpread( math.rad( 30 ) )
self.rockets[ ind ].ps:setPosition(
self.rockets[ ind ].pos.x
- ((self.rockets[ ind ].pos.y + 16)
- self.rockets[ ind ].pos.y)
* math.sin( math.rad( self.rockets[ ind ].angle ) ),
self.rockets[ ind ].pos.y
+ ((self.rockets[ ind ].pos.y + 16)
- self.rockets[ ind ].pos.y)
* math.cos( math.rad( self.rockets[ ind ].angle ) ) )
self.rockets[ ind ].ps:start()
end
Для того, чтобы снаряды вылетали из конца дула танка, необходимо воспользоваться формулой:
X = x0 + (x - x0) * cos(a) - (y - y0) * sin(a); Y = y0 + (y - y0) * cos(a) + (x - x0) * sin(a);
Просто подставим наши значения и получим точку (X; Y), из которой должны вылетать снаряды.

С системой частиц пока не разбирался, все параметры были подобраны эксперементальным путём. Более подробней о системе частиц можно посмотреть здесь.
Далее идёт обновление снарядов, проигрывается текущая анимация танка и обновление выхлопных газов танка.
Класс "Танк" написан, осталось добавить его в файл main.lua. Сохраните его в папку classes с названием Tank.lua и в файле main.lua после объявления всех библиотек добавьте Tank = require "classes.Tank". В функции love.load() нужно создать экземпляр класса: tank = Tank( Vector( 100, 100 ) ). И вместо комментариев "Здесь будет функция обновления танка" и "Здесь будет функция отрисовки танка" нужно прописать tank:update( dt ) и tank:draw().

Осталось добавить звуки, задержку между выстрелами, счётчик снарядов, вменяемый менеджер сцен, разных плюшек и можно делать свою игру.
Основной код был вырезан из одного старого проекта, так что возможно наличие ошибок.
P.S.: в lua новичок, так что многое в коде может показаться полным бредом, который навеяла больная фантазия автора данной статьи :)
Ну и как же обойтись без исходников: