Pull to refresh

Делаем Space Invaders на Love2d и Lua

Reading time 12 min
Views 11K


Добрый день! Сегодня будем делать классическую игру Space Invaders на движке Love2d. Для любителей «кода сразу» окончательную версию игры можно посмотреть на гитхабе. Тем же кому интересен процесс разработки, добро пожаловать под кат.

Здесь я не смогу описать всего, что есть в окончательной версии, это и не интересно и сделает статью бесконечной. Могу сказать, что кроме того, что я разберу здесь, игра содержит разные режимы (пауза, проигрыш, выигрыш), может выводить отладочную информацию (скорость и количество объектов, память, пр.), у Игрока есть жизни и ведётся счёт, существуют разные уровни игры (не сложность, а последовательность). Всё это либо можно посмотреть в коде, либо разработать собственные варианты.

Итак, план работы:


Подготовка


В main.lua добавим вызовы основных методов love2d. Каждый элемент или функция, которые мы сделаем впоследствии должны прямо или косвенно быть связаны с этими методами, иначе пройдут незамеченными.

function love.load()
end

function love.keyreleased( key )
end

function love.draw()
end

function love.update( dt )
end

Добавляем игрока


Добавляем в корень проекта файл player.lua


local player = {}

player.position_x = 500
player.position_y = 550
player.speed_x = 300

player.width = 50
player.height = 50

function player.update( dt )
    if love.keyboard.isDown( "right" ) and 
            player.position_x < ( love.graphics.getWidth() - player.width ) then
        player.position_x = player.position_x + ( player.speed_x * dt )
    end
    if love.keyboard.isDown( "left" )  and player.position_x > 0 then
        player.position_x = player.position_x - ( player.speed_x * dt )
    end
end

function player.draw()
    love.graphics.rectangle(
                       "fill",
                       player.position_x,
                       player.position_y,
                       player.width,
                       player.height
                 )
end

return player

А также обновим main.lua

local player = require 'player'

function love.draw()
        player.draw()
end

function love.update( dt )
        player.update( dt )
end

Если запустить игру, то мы увидим чёрный экран с белым квадратом снизу, которым можно управлять клавишами «влево» и «вправо». Причём выйти за пределы экрана он не может в силу ограничений в коде Игрока:

player.position.x < ( love.graphics.getWidth() - player.width )
player.position.x > 0

Добавим врагов


Так как бороться мы будем против иноземных захватчиков, то и файлик с ними назовём invaders.lua:


local invaders = {}

invaders.rows = 5
invaders.columns = 9

invaders.top_left_position_x = 50
invaders.top_left_position_y = 50

invaders.invader_width = 40
invaders.invader_height = 40

invaders.horizontal_distance = 20
invaders.vertical_distance = 30

invaders.current_speed_x = 50

invaders.current_level_invaders = {}

local initial_speed_x = 50
local initial_direction = 'right'

function invaders.new_invader( position_x, position_y )
    return { position_x = position_x,
                 position_y = position_y,
                 width = invaders.invader_width,
                 height = invaders.invader_height }
end

function invaders.new_row( row_index )
    local row = {}
    for col_index=1, invaders.columns - (row_index % 2) do
        local new_invader_position_x = invaders.top_left_position_x + invaders.invader_width * (row_index % 2) + (col_index - 1) * (invaders.invader_width + invaders.horizontal_distance)
        local new_invader_position_y = invaders.top_left_position_y + (row_index - 1) * (invaders.invader_height + invaders.vertical_distance)
        local new_invader = invaders.new_invader( new_invader_position_x, new_invader_position_y )
        table.insert( row, new_invader )
    end
    return row
end

function invaders.construct_level()
    invaders.current_speed_x = initial_speed_x
    for row_index=1, invaders.rows do
        local invaders_row = invaders.new_row( row_index )
        table.insert( invaders.current_level_invaders, invaders_row )
    end
end

function invaders.draw_invader( single_invader )
    love.graphics.rectangle('line',
                       single_invader.position_x,
                       single_invader.position_y,
                       single_invader.width,
                       single_invader.height )
end

function invaders.draw()
    for _, invader_row in pairs( invaders.current_level_invaders ) do
        for _, invader in pairs( invader_row ) do
            invaders.draw_invader( invader, is_miniboss )
        end
    end
end

function invaders.update_invader( dt, single_invader )
    single_invader.position_x = single_invader.position_x + invaders.current_speed_x * dt
end

function invaders.update( dt )
    local invaders_rows = 0
    for _, invader_row in pairs( invaders.current_level_invaders ) do
        invaders_rows = invaders_rows + 1
    end
    if invaders_rows == 0 then
        invaders.no_more_invaders = true
    else
        for _, invader_row in pairs( invaders.current_level_invaders ) do
            for _, invader in pairs( invader_row ) do
                invaders.update_invader( dt, invader )
            end
        end
    end
end

return invaders

Обновим main.lua

...
local invaders = require 'invaders'

function love.load()
        invaders.construct_level()
end

function love.draw()
        ...
        invaders.draw()
end

function love.update( dt )
        ...
        invaders.update( dt )
end

love.load вызывается в самом начале работы приложения. Он вызывает метод invaders.construct_level, который создаёт таблицу invaders.current_level_invaders и наполняет её по строкам и столбцам отдельными объектами invader с учётом высоты и ширины объектов, а также требуемого расстояния между ними по горизонтали и вертикали. Пришлось немного усложнить метод invaders.new_row, чтобы добиться смещения чётных и нечётных рядов. Если заменить текущую конструкцию:

for col_index=1, invaders.columns - (row_index % 2) do
    local new_invader_position_x = invaders.top_left_position_x + invaders.invader_width * (row_index % 2) + (col_index - 1) * (invaders.invader_width + invaders.horizontal_distance)

вот такой:

for col_index=1, invaders.columns do
    local new_invader_position_x = invaders.top_left_position_x + (col_index - 1) * (invaders.invader_width + invaders.horizontal_distance)

то уберём этот эффект и вернём прямоугольное заполнение. Сравнение на картинках
Текущий вариант Прямоугольный вариант

Объект invader представляет собой таблицу со свойствами: position_x, position_y, width, height. Всё это требуется для отрисовки объекта, а также позднее потребуется для проверки на коллизии с выстрелами.

love.draw вызывает invaders.draw и отрисовываются все объекты во всех рядах таблицы invaders.current_level_invaders.

love.update, а следом и invaders.update обновляют текущую позицию каждого захватчика с учётом текущей скорости, которая пока только одна — изначальная.

Захватчики уже начали двигаться, но пока только вправо, за экран. Это мы сейчас поправим.

Добавим стены и коллизии


Новый файл walls.lua

local walls = {}

walls.wall_thickness = 1
walls.bottom_height_gap = 1/5 * love.graphics.getHeight()

walls.current_level_walls = {}

function walls.new_wall( position_x, position_y, width, height )
    return { position_x = position_x,
             position_y = position_y,
             width = width,
             height = height }
end

function walls.construct_level()
    local left_wall = walls.new_wall( 0,
                                      0,
                                      walls.wall_thickness,
                                      love.graphics.getHeight() - walls.bottom_height_gap
                                )
    local right_wall = walls.new_wall( love.graphics.getWidth() - walls.wall_thickness,
                                       0,
                                       walls.wall_thickness,
                                       love.graphics.getHeight() - walls.bottom_height_gap
                                )
    local top_wall = walls.new_wall( 0,
                                     0,
                                     love.graphics.getWidth(),
                                     walls.wall_thickness
                                )
    local bottom_wall = walls.new_wall( 0, 
                                love.graphics.getHeight() - walls.bottom_height_gap - walls.wall_thickness,
                                love.graphics.getWidth(),
                                walls.wall_thickness
                                )
    walls.current_level_walls["left"] = left_wall
    walls.current_level_walls["right"] = right_wall
    walls.current_level_walls["top"] = top_wall
    walls.current_level_walls["bottom"] = bottom_wall
end

function walls.draw_wall(wall)
    love.graphics.rectangle( 'line',
                             wall.position_x,
                             wall.position_y,
                             wall.width,
                             wall.height
                        )
end

function walls.draw()
    for _, wall in pairs( walls.current_level_walls ) do
        walls.draw_wall( wall )
    end
end

return walls

И немного в main.lua

...
local walls    = require 'walls'

function love.load()
    ...
    walls.construct_level()
end

function love.draw()
    ...
    -- walls.draw()
end

Аналогично с созданием захватчиков, за создание стен отвечает вызов walls.construct_level. Стены нам нужны только для перехвата «столкновений» с ними захватчиков и выстрелов, поэтому отрисовывать их нам без надобности. Но это может понадобиться для целей отладки, поэтому у объекта Walls имеется метод draw, вызов которого происходит стандартно из main.lua -> love.draw, но пока отладка не нужна — он (вызов) закомментирован.

Теперь напишем обработчик коллизий, который был мной позаимствован отсюда. Итак, collisions.lua

local collisions = {}

function collisions.check_rectangles_overlap( a, b )
    local overlap = false
    if not( a.x + a.width < b.x or b.x + b.width < a.x or
            a.y + a.height < b.y or b.y + b.height < a.y ) then
        overlap = true
    end
    return overlap
end

function collisions.invaders_walls_collision( invaders, walls )
    local overlap, wall
    if invaders.current_speed_x > 0 then
        wall, wall_type = walls.current_level_walls['right'], 'right'
    else
        wall, wall_type = walls.current_level_walls['left'], 'left'
    end
    
    local a = { x = wall.position_x,
                y = wall.position_y,
                width = wall.width,
                height = wall.height }
    for _, invader_row in pairs( invaders.current_level_invaders ) do
        for _, invader in pairs( invader_row ) do
            local b = { x = invader.position_x,
                        y = invader.position_y,
                        width = invader.width,
                        height = invader.height }
            overlap = collisions.check_rectangles_overlap( a, b )
            if overlap then
                if wall_type == invaders.allow_overlap_direction then
                    invaders.current_speed_x = -invaders.current_speed_x 
                    if invaders.allow_overlap_direction == 'right' then
                        invaders.allow_overlap_direction = 'left'
                    else
                        invaders.allow_overlap_direction = 'right'
                    end
                    invaders.descend_by_row()
                end
            end
        end
    end
end

function collisions.resolve_collisions( invaders, walls )
    collisions.invaders_walls_collision( invaders, walls )
end

return collisions

Добавим пару методов и переменную в invaders.lua

invaders.allow_overlap_direction = 'right'

function invaders.descend_by_row_invader( single_invader )
    single_invader.position_y = single_invader.position_y + invaders.vertical_distance / 2
end

function invaders.descend_by_row()
    for _, invader_row in pairs( invaders.current_level_invaders ) do
        for _, invader in pairs( invader_row ) do
            invaders.descend_by_row_invader( invader )
        end
    end
end

И добавим проверку на коллизии в main.lua

local collisions = require 'collisions'

function love.update( dt )
    ...
    collisions.resolve_collisions( invaders, walls )
end

Теперь захватчики натыкаются на стену collisions.invaders_walls_collision и спускаются немного пониже, а также меняют скорость на противоположную.

Пришлось ввести дополнительную проверку на равенство типа той стены, на которую наткнулись захватчики, и переменной, в которой хранится допустимый тип:

if overlap then
    if wall_type == invaders.allow_overlap_direction then
    ...

из-за того, что на стену натыкаются сразу все захватчики одновременно из крайнего столбца и обработчик коллизий успевает «для каждого» отработать и снизить на один ряд весь коллектив, прежде чем, захватчики развернутся и выйдут из соприкосновений, в итоге армада спускалась сразу на несколько рядов. Тут либо ставить какой-нибудь блок при возникновении одной коллизии на ближайшие коллизии, либо расставлять захватчиков не точно один под другим, либо так как сделано, либо как-то ещё.

Пора игроку научиться стрелять


Новый файлик и класс bullets.lua

local bullets = {}

bullets.current_speed_y = -200
bullets.width = 2
bullets.height = 10

bullets.current_level_bullets = {}

function bullets.destroy_bullet( bullet_i )
    bullets.current_level_bullets[bullet_i] = nil
end

function bullets.new_bullet(position_x, position_y)
    return { position_x = position_x,
             position_y = position_y,
             width = bullets.width,
             height = bullets.height }
end

function bullets.fire( player )
    local position_x = player.position_x + player.width / 2
    local position_y = player.position_y
    local new_bullet = bullets.new_bullet( position_x, position_y )
    table.insert(bullets.current_level_bullets, new_bullet)
end

function bullets.draw_bullet( bullet )
    love.graphics.rectangle( 'fill',
                             bullet.position_x,
                             bullet.position_y,
                             bullet.width,
                             bullet.height
                        )
end

function bullets.draw()
    for _, bullet in pairs(bullets.current_level_bullets) do
        bullets.draw_bullet( bullet )
    end
end

function bullets.update_bullet( dt, bullet )
    bullet.position_y = bullet.position_y + bullets.current_speed_y * dt
end

function bullets.update( dt )
    for _, bullet in pairs(bullets.current_level_bullets) do
        bullets.update_bullet( dt, bullet )
    end
end

return bullets

Здесь основной метод — bullets.fire. Мы передаём в него Игрока, т.к. хотим, чтобы пуля вылетала «из него», а значит нам надо знать его местоположение. Т.к. патрон у нас не один, а возможна целая очередь, то храним её в таблице bullets.current_level_bullets, вызываем для неё и каждого патрона методы draw и update. Метод bullets.destroy_bullet нужен, чтобы при соприкосновении с захватчиком или потолком удалять лишние патроны из памяти.

Добавим обработку коллизий пуля-захватчик и пуля-потолок.

collisions.lua
function collisions.invaders_bullets_collision( invaders, bullets )
    local overlap
    
    for b_i, bullet in pairs( bullets.current_level_bullets) do
        local a = { x = bullet.position_x,
                    y = bullet.position_y,
                    width = bullet.width,
                    height = bullet.height }
        
        for i_i, invader_row in pairs( invaders.current_level_invaders ) do
            for i_j, invader in pairs( invader_row ) do
                local b = { x = invader.position_x,
                            y = invader.position_y,
                            width = invader.width,
                            height = invader.height }
                overlap = collisions.check_rectangles_overlap( a, b )
                if overlap then
                    invaders.destroy_invader( i_i, i_j )
                    bullets.destroy_bullet( b_i )
                end
            end
        end
    end
end

function collisions.bullets_walls_collision( bullets, walls )
    local overlap
    local wall = walls.current_level_walls['top']
    
    local a = { x = wall.position_x,
                y = wall.position_y,
                width = wall.width,
                height = wall.height }
    
    for b_i, bullet in pairs( bullets.current_level_bullets) do
        local b = { x = bullet.position_x,
                    y = bullet.position_y,
                    width = bullet.width,
                    height = bullet.height }
    
        overlap = collisions.check_rectangles_overlap( a, b )
        if overlap then
            bullets.destroy_bullet( b_i )
        end
    end
end

function collisions.resolve_collisions( invaders, walls, bullets )
    ...
    collisions.invaders_bullets_collision( invaders, bullets )
    collisions.bullets_walls_collision( bullets, walls )
end

К захватчикам добавим метод для его уничтожения, а также для проверки на наличие захватчиков в конкретном ряду в общей таблице захватчиков — если никого не осталось, то и сам ряд удаляем. А также увеличиваем скорость всей армады при убийстве.

invaders.lua
...
invaders.speed_x_increase_on_destroying = 10

function invaders.destroy_invader( row, invader )
    invaders.current_level_invaders[row][invader] = nil
    local invaders_row_count = 0
    for _, invader in pairs( invaders.current_level_invaders[row] ) do
        invaders_row_count = invaders_row_count + 1
    end
    if invaders_row_count == 0 then
        invaders.current_level_invaders[row] = nil
    end
    if invaders.allow_overlap_direction == 'right' then
        invaders.current_speed_x = invaders.current_speed_x + invaders.speed_x_increase_on_destroying
    else
        invaders.current_speed_x = invaders.current_speed_x - invaders.speed_x_increase_on_destroying
    end
 end
...

И обновляем mail.lua: добавляем новый класс, отправляем его в обработчик коллизий, и вешаем вызов стрельбы на клавишу Space.

...
local bullets    = require 'bullets'

function love.keyreleased( key )
    if key == 'space' then
        bullets.fire( player )
    end
end

function love.draw()
    ...
    bullets.draw()
end

function love.update( dt )
    ...
    collisions.resolve_collisions( invaders, walls, bullets )
    bullets.update( dt )
end

Дальнейшая работа предполагает модификацию существующего кода, поэтому то, что получилось на данном этапе сохраняем как версию 0.5.

NB Код в гите отличается от разобранного здесь. Изначально использовалась библиотека hump для работы с векторами. Но потом стало ясно, что вполне можно обойтись и без неё, и в окончательной редакции выпилил библиотеку. Код одинаково рабочий и здесь и там, единственно, для запуска кода с гитхаба придётся проинициировать сабмодули:

git submodule update --init

Навешиваем текстуры



Это три стандартных врага, плюс один минибосс, устройство которого здесь рассмотрено не будет, но он есть в окончательной версии. И сам игрок-танк.

Текстуры для игры любезно предоставила annnushkkka.

Все картинки будут находиться в каталоге images в корне проекта. Меняем Игрока в player.lua

...
player.image = love.graphics.newImage('images/Hero.png')

-- from https://love2d.org/forums/viewtopic.php?t=79756
function getImageScaleForNewDimensions( image, newWidth, newHeight )
    local currentWidth, currentHeight = image:getDimensions()
    return ( newWidth / currentWidth ), ( newHeight / currentHeight )
end
local scaleX, scaleY = getImageScaleForNewDimensions( player.image, player.width, player.height )

function player.draw()  -- меняем полностью
    love.graphics.draw(player.image,
                       player.position_x,
                       player.position_y, rotation, scaleX, scaleY )
end
...

Фнкция getImageScaleForNewDimensions, подсмотренная вот отсюда, подгоняет картинку под те размеры, которые мы указали в player.width, player.height. Она используется и здесь и для врагов, впоследствии вынесем её в отдельный модуль utils.lua. Функцию player.draw заменяем.

При запуске бывший игрок-квадрат теперь — танк!

Меняем врагов invaders.lua

...
invaders.images = {love.graphics.newImage('images/bad_1.png'),
                   love.graphics.newImage('images/bad_2.png'),
                   love.graphics.newImage('images/bad_3.png')
            }

-- from https://love2d.org/forums/viewtopic.php?t=79756
function getImageScaleForNewDimensions( image, newWidth, newHeight )
    local currentWidth, currentHeight = image:getDimensions()
    return ( newWidth / currentWidth ), ( newHeight / currentHeight )
end
local scaleX, scaleY = getImageScaleForNewDimensions( invaders.images[1], invaders.invader_width,
    invaders.invader_height )

function invaders.new_invader(position_x, position_y ) -- меняем
    local invader_image_no = math.random(1, #invaders.images)
    invader_image = invaders.images[invader_image_no]
    return ({position_x = position_x,
             position_y = position_y,
             width = invaders.invader_width,
             height = invaders.invader_height,
             image = invader_image})
end

function invaders.draw_invader( single_invader ) -- меняем
    love.graphics.draw(single_invader.image,
                       single_invader.position_x,
                       single_invader.position_y, rotation, scaleX, scaleY )
end

Добавляем картинки врагов в таблице и подгоняем размеры через getImageScaleForNewDimensions. При создании нового захватчика ему в атрибут image присваивается рандомная картинка из нашей таблицы картинок. И меняем сам метод отрисовки.

Вот что вышло:



Если позапускать игру несколько раз, то можно увидеть, что рандомная комбинация врагов каждый раз одинаковая. Чтобы этого избежать надо определить math.randomseed перед началом игры. Хорошо это делать, передавая в качестве аргумента os.time. Добавим это в main.lua

function love.load()
    ...
    math.randomseed( os.time() )
    ...
end

Теперь у нас есть почти полноценная игра, версия 0.75. Разобрали всё, что планировали.

Буду рад отзывам, комментариям, подсказкам!
Tags:
Hubs:
+17
Comments 7
Comments Comments 7

Articles