- PVSM.RU - https://www.pvsm.ru -
Добрый день! Сегодня будем делать классическую игру 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)
то уберём этот эффект и вернём прямоугольное заполнение. Сравнение на картинках
Текущий вариант | Прямоугольный вариант |
---|---|
Объект 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
Это три стандартных врага, плюс один минибосс, устройство которого здесь рассмотрено не будет, но он есть в окончательной версии [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 присваивается рандомная картинка из нашей таблицы картинок. И меняем сам метод отрисовки.
Вот что вышло:
Если позапускать игру несколько раз, то можно увидеть, что рандомная комбинация врагов каждый раз одинаковая. Чтобы этого избежать надо определить 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
Нажмите здесь для печати.