- PVSM.RU - https://www.pvsm.ru -

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

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

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

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

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

Подготовка

В 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)

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

Текущий вариант Прямоугольный вариант
Делаем Space Invaders на Love2d и Lua - 2 Делаем Space Invaders на Love2d и Lua - 3

Объект 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, но пока отладка не нужна — он (вызов) закомментирован.

Теперь напишем обработчик коллизий, который был мной позаимствован отсюда [9]. Итак, 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 [10].

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

git submodule update --init

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

Делаем Space Invaders на Love2d и Lua - 4
Это три стандартных врага, плюс один минибосс, устройство которого здесь рассмотрено не будет, но он есть в окончательной версии [2]. И сам игрок-танк.

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

Все картинки будут находиться в каталоге 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, подсмотренная вот отсюда [13], подгоняет картинку под те размеры, которые мы указали в 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 присваивается рандомная картинка из нашей таблицы картинок. И меняем сам метод отрисовки.

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

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

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

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

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

Буду рад отзывам, комментариям, подсказкам!

Автор: vlfedotov

Источник [15]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/gamedev/254784

Ссылки в тексте:

[1] Love2d: https://love2d.org/

[2] гитхабе: https://github.com/vlfedotov/small_games/tree/games/space-invaders

[3] Подготовка: #main

[4] Добавляем игрока: #player

[5] Врагов: #enemies

[6] Стены и обработчик коллизий: #walls

[7] Учим игрока стрелять: #bullets

[8] Прикрепляем графику: #graphics

[9] отсюда: https://github.com/noooway/love2d_arkanoid_tutorial/wiki

[10] версию 0.5: https://github.com/vlfedotov/small_games/commit/21868d59adb3ea4dc358f2fecf0cc05548153410

[11] hump: https://github.com/vrld/hump

[12] annnushkkka: https://www.artstation.com/artist/annnushkkka

[13] отсюда: https://love2d.org/forums/viewtopic.php?t=79756

[14] версия 0.75: https://github.com/vlfedotov/small_games/commit/7d282a061cb77e07e05e91eb3ae8966bfa6808f8

[15] Источник: https://habrahabr.ru/post/328264/?utm_source=habrahabr&utm_medium=rss&utm_campaign=sandbox