Всем доброго времени суток. В данной статье (если это можно назвать статьёй, в основном код), как вы уже наверно догадались, пойдёт речь о создании танка на игровом движке LÖVE.

Прежде всего определим, что должен делать наш создаваемый танк:

Для начала нам нужно создать все изображения и поместить их в какую-нибудь папку, например data/images.

Художник из меня плохой, поэтому всю графику мне предоставил пользователь форума GcUp silver52rus, за что ему ОГРОМНОЕ СПАСИБО.

Корпус танка с анимацией гусениц

Башня танка

След, оставляемый танком при движении

Выхлопные газы

Патрон

Ракета

Частицы для ракеты

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

Нужно их скачать и поместить в папку classes.

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

Для начала разберёмся с файлом 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 новичок, так что многое в коде может показаться полным бредом, который навеяла больная фантазия автора данной статьи :)

Ну и как же обойтись без исходников: