Game: Pumpkin Push #
This page is a work in progress.
Pumpkin Push will be a block pushing puzzle game based on the Japanese hit Sokoban. First released in 1982, the game is still available today on a variety of platforms.
In Pumpkin Push the player is a ghost trying to organize their pumpkin patch. The environment is a farm field divided into a grid, and the player is tasked with pushing all of the pumpkins into their righful places marked with vines. Pumpkins can only be pushed, not pulled, and only one pumpkin can be pushed at a time… multiple pumpkins in a row are just too heavy, can’t be done.
Create Game Files #
First we need to set up a new game directory and a few files to start with.
If you want, you can just make a copy of thehello-love2d
directory and rename it topumpkin-push
. You can start with the copies ofmain.lua
andrun.bat
that come with it.
Create a new directory for the project called pumpkin-push
inside C:\learn-lua\source\
, and create the following files:
run.bat
to easily launch the game:
@"C:\Program Files\LOVE\love.exe" --console .
… or …
@..\..\bin\love\love.exe --console .
…depending on where you installed love.exe
.
main.lua
which can start with a simple love.draw()
to clear the background colour:
function love.draw()
love.graphics.clear(156/255, 172/255, 141/255)
end
In addition to the main.lua
script, Love2D games usually have a script used to configure the game engine named conf.lua
.
conf.lua
is run before when the game is loaded, before main.lua
and before the window is created by the engine, allowing you to customize the appearance of the window, as well as a variety of other engine related settings:
function love.conf(t)
t.window.title = "Pumpkin Push"
end
Love calls the function love.conf
and passes in a table t
which contains several sub-tables, one for each of the various aspects of the engine, such as window
, mouse
, keyboard
, etc. We’ll start with just setting the window title to something more interesting than Untitled.
Running the game should display our blank – but appropriately titled – game window:
Drawing Images #
Image Assets #
First things first, if we want to draw some images on the screen, we’ll need some image files.
In your pumpkin-push
game folder (the same folder that contains main.lua
), create a directory called images
, and save the images below into that new images
folder, either right click and choose Save Image As…, or drag and drop each image file into the folder.
I created these files using the free programs Inkscape and Paint.NET. You can use these images as-is, edit them, or create your own using whatever software you want. Just make sure to save them as 64 x 64 pixel PNG files to stay consistent with the rest of the tutorial, otherwise you’ll need to update the code accordingly to match the size of the images.
Loading And Drawing #
Before we can draw an image file, we’ll need to load it and store the result in a variable. A good place to do this kind of task is in the love.load()
callback, which as you might guess, is called once at the start of your game to allow you to load the assets that you’ll need to render the game.
We’ll call love.graphics.newImage()
to load the image from the file "images/ghost.png"
(note that the path to this file is relative to the game directory, where the file main.lua
is) and store the result in a global for now called img_player
Then when love.draw()
is called to render each frame, we call love.graphics.draw()
to draw the loaded image.
function love.load()
img_player = love.graphics.newImage("images/ghost.png")
end
function love.draw()
love.graphics.clear(156/255, 172/255, 141/255)
love.graphics.draw(img_player, 0, 0)
end
When calling love.graphics.draw()
we’re passing in the coordinates 0, 0
which are the x, y
coordinates of the top left corner of the window.
Even though the image we are drawing is a rectangle, we can see the background colour around the coloured pixels of the character because the .png
we’re using has an alpha channel which marks the background pixels of the image as being transparent.
Also note that the image has been drawn with its upper left corner positioned at the coordinates (0, 0
) we specified, which will be important to take into consideration whenever we need to decide what coordinates to use in order to position images around the screen.
We can load and draw a ground tile in the same way:
function love.load()
img_player = love.graphics.newImage("images/ghost.png")
img_ground = love.graphics.newImage("images/ground.png")
end
function love.draw()
love.graphics.clear(156/255, 172/255, 141/255)
love.graphics.draw(img_ground, 0, 0)
love.graphics.draw(img_player, 0, 0)
end
The order we draw the images in matters. Since we want the player to show up on top of the ground image, we need to draw the ground image first, and then draw the player image over top of it.
One thing you never want to do is load any images or other assets inside callbacks like
love.draw()
.love.draw()
runs every time a new frame is drawn for the game, eg. 60 times per second. While it’s technically possible on modern computers to load one or two small images from disk every single frame, this is a terrible waste of resources, and will quickly become impractical as the number and size of data increases.This is what
love.load()
is for, it’s called once when the game starts giving us a chance to load all of the files we need.
Tiling Images #
We going to want more a single ground tile to give the player some room to move around and position other objects in the world.
Let’s start by drawing a row of 10 tiles. The image we’re using is 64 x 64 pixels, so to find the x coordinate for each tile, we can multiply the column number by 64. Each tile we draw will be 64 pixels farther to the right than the previous one.
function love.draw()
love.graphics.clear(156/255, 172/255, 141/255)
for column = 1, 10 do
local tile_x = column * 64
local tile_y = 0
love.graphics.draw(img_ground, tile_x, tile_y)
end
love.graphics.draw(img_player, 0, 0)
end
This isn’t quite right though… our current calculation will give us the x coordinates 64, 128, 192, 256, … but we want the first coordinate to be 0 to draw in the same position as the player.
To make the math work out, we can subtract 1 from the column number when calculating the x coordinate. That way, when we multiply by the image width, the first result will be 0, then 64, and so on.
function love.draw()
love.graphics.clear(156/255, 172/255, 141/255)
for column = 1, 10 do
local tile_x = (column - 1) * 64
local tile_y = 0
love.graphics.draw(img_ground, tile_x, tile_y)
end
love.graphics.draw(img_player, 0, 0)
end
Now that we’ve got one row drawn, we can easily expand this to the full 10 rows by nesting the current for loop inside another one.
function love.draw()
love.graphics.clear(156/255, 172/255, 141/255)
for row = 1, 10 do
for column = 1, 10 do
local tile_x = (column - 1) * 64
local tile_y = (row - 1) * 64
love.graphics.draw(img_ground, tile_x, tile_y)
end
end
love.graphics.draw(img_player, 0, 0)
end
We loop over the 10 rows top to bottom, and for each row we loop over 10 columns left to right.
In addition to calculating the x coordinate from the column
index, we are now also calculating the y coordinate from the row
index using the same formula, this time representing a distance from the top of the screen.
Changing The Window Size #
The default Love2D window size is 800 x 600 pixels and our grid of ground tiles doesn’t quite fit, it’s getting cut off at the bottom. Let’s increase the size of our window a bit by going over to conf.lua
and setting the window width and height to 1024 x 768 pixels.
function love.conf(t)
t.window.title = "Pumpkin Push"
t.window.width = 1024
t.window.height = 768
end
Now we’ve got a bit more breathing room to work with. That extra space will be useful later on to display level information, the player’s score and whatnot.
If you’re wondering where these specific numbers came from, they are based on the SVGA (800 x 600) and XGA (1024 x 768) graphics resolutions respectively. Since our game is running in a window, it doesn’t actually need to fit any particular resolution and we can pretty much just set the width and height to any number you want (within reason); however, since our tiles are 64 x 64 pixels, using a resolution like 1024 x 768, lets those tiles fit evenly into the window, 1024 / 64 is 16 tiles across, and 768 / 64 is 12 tiles high.
Player Movement #
The player character will be controlled with the keyboard. We’ll need a couple of variables to keep track of the position of the player on in the game world, and then handle the keyboard events that let us know a key has been pressed.
Screen Coordinates #
To start with, let’s declare a couple of variables to keep track of the player’s position on the grid, and start the player off in the top left corner (column 1, row 1).
local player_col = 1
local player_row = 1
But when we want to draw the player we use love.graphics.draw()
which takes screen coordinates in pixels. We can use the same formula we used for drawing all of the ground tiles.
function love.draw()
love.graphics.clear(156/255, 172/255, 141/255)
for row = 1, 10 do
for column = 1, 10 do
local tile_x = (column - 1) * 64
local tile_y = (row - 1) * 64
love.graphics.draw(img_ground, tile_x, tile_y)
end
end
local player_x = (player_col - 1) * 64
local player_y = (player_row - 1) * 64
love.graphics.draw(img_player, player_x, player_y)
end
To quickly check this is working as expected, try changing the position that the player starts in, and see where it draws on screen:
local player_col = 3
local player_row = 5
At this point, we can see that we’re repeating the same calculation several times over, when we convert from tile rows and columns to screen coordinates to draw the tiles, and to convert from the player position to screen coordinates. There will be other places we’ll need to repeat this same pattern, so let’s move this code into a helper function that we can use whenever we need it.
function tile_to_screen(column, row)
local screen_x = (column - 1) * 64
local screen_y = (row - 1) * 64
return screen_x, screen_y
end
We pass a column and row into tile_to_screen()
, and it returns both the x and y pixel coordinates where we want to draw that tile on the screen. Now we can change our drawing code in love.draw()
to use this helper function, instead of repeating the same formula over and over.
function love.draw()
love.graphics.clear(156/255, 172/255, 141/255)
for row = 1, 10 do
for column = 1, 10 do
local tile_x, tile_y = tile_to_screen(column, row)
love.graphics.draw(img_ground, tile_x, tile_y)
end
end
local player_x, player_y = tile_to_screen(player_col, player_row)
love.graphics.draw(img_player, player_x, player_y)
end
Event Handling #
Now let’s handle the key pressed event so that we can move the player character in response. To do this, we add a new event handler, a function called love.keypressed()
. As with love.draw()
, love.keypressed()
is a predefined function name that the Love2D engine knows about which the engine will call for us when the player presses a key on their keyboard.
function love.keypressed( key, scancode, isrepeat )
print(string.format("love.keypressed %s, %s, %s", key, scancode, isrepeat))
end
After defining this function, when you run the game and press some keys, you should see the messages we’re printing to the Love2D console window.
Looking at the console output we can see that each key press is represented by its lowercase letter, or a string name in the case of “left”, “right”, “space”, “escape”, etc…
love.keypressed a, a, false
love.keypressed s, s, false
love.keypressed d, d, false
love.keypressed left, left, false
love.keypressed up, up, false
love.keypressed right, right, false
love.keypressed down, down, false
love.keypressed space, space, false
love.keypressed escape, escape, false
Chances are the key
and scancode
parameters will have the same value for each key press, but if you’re using a non-English keyboard, or have a different keyboard layout configured for your OS, then key
and scancode
might not match.
We’ll let the player move the character using either the arrow keys, or the common WASD movement keys. So if the key pressed is "a"
or "left"
we’ll want to move the character one spot to the left, "d"
or "right"
will move to the right and so on.
To double check that the code is running when we expect, and doing the things we expect, we can print out messages when we try to move the player right or left, and then print out the final player position after each key press.
function love.keypressed( key, scancode, isrepeat )
print(string.format("love.keypressed %s, %s, %s", key, scancode, isrepeat))
if key == "a" or key == "left" then
print(" move left")
player_col = player_col - 1
elseif key == "d" or key == "right" then
print(" move right")
player_col = player_col + 1
end
print(string.format(" player pos %d, %d", player_col, player_row))
end
We’re able to move the character left and right, but we’re also able to keep moving past the edges of the board, or entirely off the screen. We’ll need to add some checks to make sure that the player’s position doesn’t exceed the number of tiles on the board.
Level Boundaries #
First, let’s create some variables to hold the number of columns and rows in the game:
local NUM_COLS = 10
local NUM_ROWS = 10
Then before we move the player in response to a key press event, we’ll check whether the result would stay within the bounds of the board before actually moving the player, otherwise we ignore the key press.
function love.keypressed( key, scancode, isrepeat )
print(string.format("love.keypressed %s, %s, %s", key, scancode, isrepeat))
if key == "a" or key == "left" then
if player_col > 1 then
print(" move left")
player_col = player_col - 1
end
elseif key == "d" or key == "right" then
if player_col < NUM_COLS then
print(" move right")
player_col = player_col + 1
end
end
print(string.format(" player pos %d, %d", player_col, player_row))
end
While we’re at it, now might be a good time to update the love.draw()
function to use the same NUM_COLS
and NUM_ROWS
variables, instead of hard coding the values of 10
.
function love.draw()
love.graphics.clear(156/255, 172/255, 141/255)
for row = 1, NUM_ROWS do
for column = 1, NUM_COLS do
local tile_x, tile_y = tile_to_screen(column, row)
love.graphics.draw(img_ground, tile_x, tile_y)
end
end
local player_x, player_y = tile_to_screen(player_col, player_row)
love.graphics.draw(img_player, player_x, player_y)
end
Now it’s possible to change the number of rows and columns in the game in one place, and the rest of the code will continue to work accordingly.
And let’s finish up by adding up and down movement as well…
function love.keypressed( key, scancode, isrepeat )
print(string.format("love.keypressed %s, %s, %s", key, scancode, isrepeat))
if key == "a" or key == "left" then
if player_col > 1 then
print(" move left")
player_col = player_col - 1
end
elseif key == "d" or key == "right" then
if player_col < NUM_COLS then
print(" move right")
player_col = player_col + 1
end
elseif key == "w" or key == "up" then
if player_row > 1 then
print(" move up")
player_row = player_row - 1
end
elseif key == "s" or key == "down" then
if player_row < NUM_ROWS then
print(" move down")
player_row = player_row + 1
end
end
print(string.format(" player pos %d, %d", player_col, player_row))
end
The player should now be able to move around the entire board, but not outside of it.
Code So far
conf.lua
function love.conf(t)
t.window.title = "Pumpkin Push"
t.window.width = 1024
t.window.height = 768
end
main.lua
local NUM_COLS = 10
local NUM_ROWS = 10
local player_col = 3
local player_row = 5
function love.load()
img_player = love.graphics.newImage("images/ghost.png")
img_ground = love.graphics.newImage("images/ground.png")
end
function love.draw()
love.graphics.clear(156/255, 172/255, 141/255)
for row = 1, NUM_ROWS do
for column = 1, NUM_COLS do
local tile_x, tile_y = tile_to_screen(column, row)
love.graphics.draw(img_ground, tile_x, tile_y)
end
end
local player_x, player_y = tile_to_screen(player_col, player_row)
love.graphics.draw(img_player, player_x, player_y)
end
function love.keypressed( key, scancode, isrepeat )
print(string.format("love.keypressed %s, %s, %s", key, scancode, isrepeat))
if key == "a" or key == "left" then
if player_col > 1 then
print(" move left")
player_col = player_col - 1
end
elseif key == "d" or key == "right" then
if player_col < NUM_COLS then
print(" move right")
player_col = player_col + 1
end
elseif key == "w" or key == "up" then
if player_row > 1 then
print(" move up")
player_row = player_row - 1
end
elseif key == "s" or key == "down" then
if player_row < NUM_ROWS then
print(" move down")
player_row = player_row + 1
end
end
print(string.format(" player pos %d, %d", player_col, player_row))
end
function tile_to_screen(column, row)
local screen_x = (column - 1) * 64
local screen_y = (row - 1) * 64
return screen_x, screen_y
end
2024-10-03
This page is a work in progress. The following sections are coming soon.