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