📜 ⬆️ ⬇️

Making Space Invaders on Love2d and Lua



Good day! Today we will make the classic game Space Invaders on the engine Love2d . For lovers of "code immediately" the final version of the game can be viewed on the githaba . Those who are interested in the development process, welcome under cat.

Here I can not describe everything that is in the final version, it is not interesting and will make the article infinite. I can say that in addition to what I’m going to figure out here, the game contains different modes (pause, loss, win), can display debug information (speed and number of objects, memory, etc.), the Player has lives and is being counted, there are different game levels (not complexity, but sequence). All this can either be viewed in the code, or develop your own versions.

So, the work plan:
')

Training


In the main.lua add calls to the main methods of love2d. Each element or function that we will do later must be directly or indirectly associated with these methods, otherwise they will go unnoticed.

function love.load() end function love.keyreleased( key ) end function love.draw() end function love.update( dt ) end 

Add a player


Add to the root of the project file 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 

Also update main.lua

 local player = require 'player' function love.draw() player.draw() end function love.update( dt ) player.update( dt ) end 

If you start the game, we will see a black screen with a white square at the bottom, which can be controlled with the left and right keys. Moreover, he cannot go beyond the screen due to limitations in the Player’s code:

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

Add enemies


Since we will fight against foreign invaders, we will call the file with them 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 

Update 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 is called at the very beginning of the application. It calls the invaders.construct_level method, which creates the invaders.current_level_invaders table and fills it in rows and columns with separate invader objects, taking into account the height and width of objects, as well as the required distance between them horizontally and vertically. It was necessary to complicate the invaders.new_row method a little to achieve the offset of even and odd rows. If you replace the current design:

 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) 

like this:

 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) 

then remove this effect and return the rectangular fill. Comparison on pictures
Current optionRectangular option

The invader object is a table with properties: position_x, position_y, width, height. All this is required to render the object, and later it will be required to check for collisions with shots.

love.draw calls invaders.draw and draws all the objects in all rows of the invaders.current_level_invaders table.

love.update , followed by invaders.update and update the current position of each invader, taking into account the current speed, which is so far only one - the original.

The invaders have already begun to move, but for now just to the right, behind the screen. We will fix that now.

Add walls and collisions


New walls.lua file

 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 

And a bit in main.lua

 ... local walls = require 'walls' function love.load() ... walls.construct_level() end function love.draw() ... -- walls.draw() end 

Similarly with the creation of invaders, the call walls.construct_level is responsible for creating the walls. We only need walls for intercepting “clashes” with invaders and shots, therefore we need to draw them unnecessarily. But this may be necessary for debugging purposes, so the Walls object has a draw method, which is called standardly from main.lua -> love.draw , but for the time being, debugging is not needed - it (the call) is commented out.

Now we’ll write a collision handler that I borrowed from here . So, collisions.lua

 local collisions = {} function collisions.check_rectangles_overlap( a, b ) local overlap = false if not( ax + a.width < bx or bx + b.width < ax or ay + a.height < by or by + b.height < ay ) 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 

Add a couple of methods and a variable in 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 

And add a check for collisions in main.lua

 local collisions = require 'collisions' function love.update( dt ) ... collisions.resolve_collisions( invaders, walls ) end 

Now the invaders stumble on the wall of collisions.invaders_walls_collision and descend a little lower, and also change the speed to the opposite.

It was necessary to introduce an additional test for equality of the type of the wall on which the invaders stumbled, and a variable in which the valid type is stored:

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

due to the fact that all the invaders simultaneously stumble on the wall from the extreme column at the same time and the collision handler manages to “for everyone” and reduce the whole team by one row before the invaders turn around and come out of contact, as a result the armada descended immediately rows. Here either to put some kind of block in the event of one collision for the next collision, or to arrange the invaders not exactly one under the other, either as done, or in some other way.

It's time for the player to learn how to shoot


New file and class 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 

Here the main method is bullets.fire . We transfer the player to it, because we want the bullet to fly out of it, which means we need to know its location. Since we don't have one patron, but a whole queue is possible, then we store it in the bullets.current_level_bullets table, we call the draw and update methods for it and for each patron. The bullets.destroy_bullet method is needed in order to remove excess cartridges from memory when in contact with the invader or the ceiling.

Add handling collisions bullet invader and bullet-ceiling.

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 

We will add a method to the invaders to destroy it, as well as to check for the presence of invaders in a specific row in the general table of invaders - if no one is left, then the row itself is deleted. And also we increase the speed of the whole armada during the murder

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 ... 

And update mail.lua : add a new class, send it to the collision handler, and hang up the shooting call on the Space key.

 ... 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 

Further work assumes modification of the existing code, therefore, what turned out at this stage is saved as version 0.5 .

NB The code in the gita is different from the one discussed here. Initially the hump library was used to work with vectors. But then it became clear that it is quite possible to do without it, and in the final version I sawed out the library. The code is equally working here and there, only, to run the code from the githab will have to initiate the submodules:

 git submodule update --init 

Hanging texture



These are three standard enemies, plus one mini-boss, whose device will not be considered here, but it is in the final version . And the player himself is a tank.

Textures for the game kindly provided annnushkkka .

All images will be in the images directory in the project root. Change Player in 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 ... 

The getImageScaleForNewDimensions function, peeking from here , fits the image to the dimensions we specified in player.width, player.height . It is used here and for enemies, later we will move it into a separate utils.lua module. The player.draw function is replaceable.

When starting a former square player now - tank!

We change enemies 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 

Add pictures of the enemies in the table and adjust the size via getImageScaleForNewDimensions. When creating a new invader, a random image from our table of images is assigned to it in the image attribute. And we change the rendering method itself.

This is what happened:



If you start the game several times, you can see that the random combination of enemies is the same every time. To avoid this, you must define math.randomseed before starting the game. It’s good to do this by passing os.time as an argument. Add this to main.lua

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

Now we have an almost full-fledged game, version 0.75 . Dismantled everything that was planned.

I will be glad to feedback, comments, tips!

Source: https://habr.com/ru/post/328264/


All Articles