A simple puzzle game (part 2)
In part one of this post, I walked through the logic and game rules of a puzzle game, written in functional style. This time I’m going to turn that into a real game, that you can give to people to play: it will have graphics, animations, and run without installing Lua or any other dependencies.
I’m going to do this by using a game framework called Löve. I don’t want this blog to turn into library-of-the-week, but it’s impossible to do this stuff in a blog entry without using some framework, and Löve is a good one: it’s free and open-source, works on all three platforms, and its “Hello, World” is under five lines. So let’s download it and put some game rules into it!
First, some organizational issues. Löve expects its games to be in folders, and to start with a file called main.lua
; this is the file that it will run first when it loads the game. So, let’s make a new directory, fallgame
, put our original file in it (called fall.lua
, say), and put alongside it a file called main.lua
that Löve will actually run. main.lua
should do require 'fall'
on the first line, to load in all the code we wrote last time. Now, if you run Löve on that same directory, you should get a black window, because you’ve told it to load in a bunch of code but not actually do anything with it.
How to run it, by the way, is platform-dependent. I strongly recommend adding the directory containing Löve to your PATH
though, so you can just type love .
every time.
Callbacks
Löve works around the idea of callbacks. Your program doesn’t touch the flow of control; you define a handful of functions that it calls when it’s the right time: love.load()
once during startup, love.mousepressed()
when the player presses a mouse button, and so on. First thing we want to do is get rid of that ugly gray background, so let’s define a love.load
that sets a background color of a nice, tasteful blue-gray:
require 'fall' function love.load() love.graphics.setBackgroundColor(82,82,128) end
Now when you start it, it will load all the code in fall.lua
, then Löve will call love.load
, which will set the background color. We still need a board though, and love.load
is the proper place to set that up, so add this into that function:
math.randomseed(love.timer.getMicroTime()) board = populate_board(make_board(8))
The first line makes sure the board is random by seeding the random-number generator, and the second line calls our old functions from last time to make and fill a board (note we don’t say local board
anywhere; the board is stored in a global variable). Now we just need to draw it.
Loading images
Löve has a lot of support for drawing tile-based graphics. The idea is you put all your images on one big tilesheet, and then cut rectangular areas out of it to display. We can load the image with love.graphics.newImage
, and then each rectangular area we want to draw is called a quad. The image I made looks like this:
I’m definitely not an artist, but I think it has a kind of SNES-era appeal. Each tile is 48×48 (blown up 300% from a 16×16 original) and there’s no spacing between them. Don’t forget to copy this or an image like it to your game directory.
We want to have a big table of quads, where the keys are the things in our board (1-4 for the blocks and a-d for the crushers) and the values are quads that will cut sections out of this sheet. So we add this to love.load
:
tiles = {} for k,v in pairs{'a','b','c','d'} do tiles[k] = love.graphics.newQuad(48*(k-1), 0, -- x, y 48, 48, 48*4, 48*2) -- width, height, image dimensions tiles[v] = love.graphics.newQuad(48*(k-1), 48, -- x, y 48, 48, 48*4, 48*2) -- width, height, image dimensions end
For that table {'a','b','c','d'}
, the pairs are (1, ‘a’), (2, ‘b’), and so on. So we can go through the table once and get all the quads we need.
Drawing the board
Let’s draw the board in the center of the screen. We first want to determine the center of the screen, by dividing the screen size by 2 and subtracting half the board size. Since we know the board is 8×8 and it’s made of 48×48 tiles, it’s easy to figure that out:
function love.draw() local g = love.graphics local w, h = g.getWidth(), g.getHeight() local left, top = w/2 - 48*board.size/2, h/2 - 48*board.size/2 -- actually draw the board here end
Actually drawing the board is simple. It’s a loop just like the ones in fall.lua
, and we call love.graphics.drawq
to draw a quad inside it (after looking up the correct quad based on the contents of the cell). This goes in love.draw
:
for n=0, board.size * board.size - 1 do local x, y = n % board.size, math.floor(n / board.size) if board[n] ~= 0 then g.drawq(img, tiles[board[n]], left + x*48, top + y*48) end end
Taking input, updating the game state
Right now we can draw the board but that’s pretty much it. For a first step, let’s hook up rotating the board. We want to read in mouse clicks (left to rotate counter-clockwise, right for clockwise) and have the board rotate in response, after animating. So, what we’ll do is, have a global rotation_speed
variable, and a global angle
variable. When they click the mouse, we set the rotation speed (either positive or negative):
function love.mousepressed(x, y, btn) if rotation_speed ~= 0 then return elseif btn == 'l' then rotation_speed, angle = -2, 0 elseif btn == 'r' then rotation_speed, angle = 2, 0 end end
And every time love.update
is called, we’ll update angle
based on that.
This is a tricky one. When update
gets called, it’s passed the time since the last time it was called. We can use this to determine how much to change our internal model, without actually knowing or caring about our frames-per-second. It sounds tricky, but the rule of thumb is straightforward: just multiply all velocities by dt
. Here it is:
function love.update(dt) angle = angle + rotation_speed * dt if rotation_speed > 0 and angle >= math.pi/2 then board, rotation_speed, angle = rotate(board), 0, 0 elseif rotation_speed < 0 and angle <= -math.pi/2 then board, rotation_speed, angle = rotate(rotate(rotate(board))), 0, 0 end end
The first line handles the actual rotation, the rest of it is just to detect when the rotation should stop. After we hit 90 degrees (or minus 90) we want to rotate the actual board with the game rules.
One last thing and then we can play with this: we need to make the draw
function display the board rotated. The way we do this is to actually rotate the coordinate system we draw it in. Löve has two functions, translate
and rotate
, that we can use for this. We want to rotate the coordinates some around the center of the screen, so first we translate the origin to the center, then rotate by angle
radians (rotate
always rotates around the origin, which is why we translate it first), and then translate back:
-- Put this in love.draw before the drawing loop g.translate(w/2, h/2) g.rotate(angle) g.translate(-w/2,-h/2)
Now you can load it up again and watch the board move around in response to your clicks.
A problem arises
It may not look like it, but this is actually very bad code. If we tried to write the whole game like this, it would rapidly become very difficult to do. The problem lies in love.update
: it operates on two different levels of abstraction. On one level, it is responsible for keeping angle
updated in response to the game state created by love.mousepressed
, but it’s also where we put the code to update the actual game rules. This is clean enough when there are only three states the game can be in (rotating left, rotating right, or still) but even our simple game has many more states than that:
- Doing nothing; ready for input
- Rotating left
- Rotating right
- Blocks are falling after a rotation
- Blocks are falling after a crushing
- (eventually) “Game Over” screen is flashing
Each of these states can only transition to certain other states, and they each involve some change to the game board that must happen after animation is finished. Trying to write them all into one love.update
function is a recipe for disaster.
So how to fix it? Well, I’ll tell you next time, when I talk about building an actual game engine inside Löve. In the meantime, the code from this post is here and I encourage you to go play around with it!