Play With Lua!

A simple puzzle game (part 3)

without comments

When we left off last week, we had part of a game. It would display a board, and we could rotate it left or right. It would even animate, which was cool, but already our code was starting to get complex, so we were probably going about things the wrong way.

The issue is that we need to start an animation, then return from the function so that Löve can actually show the animation. But, the animation isn’t the entire turn; we have more game logic that needs to run after it finishes.

Currently, the only way to find out when something finishes is by checking for it in love.update, right? So we can make this game by making something like this:

function love.update()
   if rotating then
      -- rotate the board
      if should_finish_rotating() then
         -- start falling blocks
         falling = true
      end
   end
 
   if falling then
      -- fall some blocks
      if should_finish_falling() then
         -- see if we have to fall again
      end
   end
 
   -- and so on...
end

That’s pretty ugly, for a couple of reasons: we have all those should_finish_whatever() checks, for one, but most importantly, we’re mixing two different tasks in love.update. It’s handling the graphics / animation updating, which is what it’s for, but we also have it handling the game rules. It’s operating on two different levels of abstraction. But, it has to, right? Because it has to detect, when it’s called, which long-running process it’s in.

The name of this problem is inversion of control. You see it a lot in web development when you need to send a page to the browser (like a login page), get a response (like login credentials), and then continue some function (like showing a profile page only to logged-in users). The game logic should be controlling execution and calling utility functions for animation; actually the inverse happens: animation controls execution and calls game logic utility functions. Inversion of control.

So, how to fix it? We re-invert control. In a lot of languages there’s no way to do that. We need to stop a function halfway through and pick up where we left off. Lua has a feature we’ve looked at before, though, that fits that description exactly: coroutines. We’ll make a coroutine that represents the game rules, and occasionally it will set up an animation and then call coroutine.yield. When an animation completes, love.update will call coroutine.resume to let the game rules decide what to do next.

Representing an animation

First off, we need to decide how we’ll represent animations for love.update. Let’s say that an animation is a table with three fields: a value, an update function, and a finished function. What love.update will do is run through a list of these, calling the update functions on each one, removing it if the finished function returns true. If the list is empty then we know we’re finished with all the animations, so it will then resume the game rules coroutine.

We need a function to create these animation tables. Let’s call it tween because it smoothly moves a value between two limits:

function tween(from, to, speed)
   speed = speed or 4
   if to < from and speed > 0 then speed = -speed end
 
   local tbl = {value=from}
 
   tbl.update = function(dt)
                   tbl.value = tbl.value + dt * speed
                end
 
   tbl.finished = function()
                     return speed > 0 and tbl.value >= to or
                        speed < 0 and tbl.value <= to
                  end
 
   return tbl
end

So now, to make an animation that changes from 0 to π/2 we do this: tween(0, pi/2)

Waiting on input

Annoyingly, the game rules coroutine needs to wait on things other than animations. Specifically, it needs to wait on input. We want to ignore all input that comes during an animation and we want to ignore all lack-of-animations that occur while we’re waiting on input. We’ll need a variable, let’s call it “state”, that’s just a string saying what the game rules are waiting on: “animation” or “input”. To keep that functional-programming flavor, we’ll have the new game state be what the coroutine yields when it starts waiting on something.

We now know enough about the new design to write the new love.mousepressed and love.update:

function love.mousepressed(x, y, btn)
   if state == 'input' then state = game(btn) end
end
 
function love.update(dt)
   local unfinished = {}
   local any_left = false
 
   for name, animation in pairs(animations) do
      animation.update(dt)
      if not animation.finished() then
         unfinished[name] = animation
         any_left = true
      end
   end
 
   animations = unfinished
 
   if not any_left and state == 'animation' then
      state = game()
   end
end

The game rules

We’re going to skip ahead a little bit. Let’s write the game rules and then come back and talk about a change to the game logic functions.

The game rules are a coroutine created by a function called game_state_machine. It has an infinite loop (the game actually never ends, there’s no enforced win condition yet) that receives input, adds an animation to rotate the board, actually rotates the board, handles blocks falling / crushing, and repeats. It looks like this:

function game_state_machine()
   local rules = coroutine.create(
      function()
         while true do
            local btn = coroutine.yield('input')
 
            if btn == 'l' then
               animations.angle = tween(0, -pi/2, -6)
               coroutine.yield('animation')
               board = rotate(rotate(rotate(board)))
            elseif btn == 'r' then
               animations.angle = tween(0, pi/2, 6)
               coroutine.yield('animation')
               board = rotate(board)
            end
 
            -- Falling stuff goes here, later
         end
      end)
 
   return function(input)
             -- Coroutines return the yielded value as the *second* return value,
             -- so we'll wrap this in a utility function:
             return select(2, coroutine.resume(rules, input))
          end
end

With this, we’re ready to handle falling.

Making blocks fall

The original game rules are a little inadequate in the falling department. Our old falling function, fall, just returned a board with the final position of everything that was to fall. That’s not going to work for animation because we need to know what is actually falling. So, let’s add two new functions: should_fall will return a boolean (whether anything should fall) and a board of true/false values for which blocks are currently falling, and fall_one will take a board and return one with every unsupported block shifted down one space. So, with these, our falling logic looks like this:

            local function fall_loop()
               local fall_p, falling = should_fall(board)
 
               while fall_p do
                  -- Set up an animation
                  animations.offset = tween(0, 48, 16 * 48)
                  animations.offset.falling = falling
                  coroutine.yield 'animation'
                  -- Fall one space, check if we should repeat
                  board = fall_one(board)
                  fall_p, falling = should_fall(board)
               end
            end

Calling this inside the rules will make everything fall smoothly until it all hits bottom. So, we can handle the blocks-falling-and-getting-crushed part of the turn in just a few lines:

            fall_loop()
            board = crush(board)
            fall_loop()

And done!

Drawing

Well, not quite. The update function is now properly changing variables, but we still need to make love.draw actually draw the board correctly. Luckily, this is simple.

We have these two possible animations, see, animations.angle and animations.offset. The drawing function just has to look if these exist (meaning we’re currently in the middle of an animation), and change how it draws things if they do. Rotating the board and drawing the tiles actually work the same as before (although I split them off into utility functions), so the modifications made to drawing are really easy:

function love.draw()
   if animations.angle then
      rotate_board(animations.angle.value)
   end
 
   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 -- If this is non-empty
         if animations.offset and animations.offset.falling[n] then
            -- If we're falling and this block should fall
            draw_tile(board[n], x, y, animations.offset.value)
         else
            -- Otherwise draw it at the normal location
            draw_tile(board[n], x, y)
         end
      end
   end
end

Notice that the game rules will sometimes cram handy information that love.draw needs (like which tiles will be falling) right into an animation. No harm in that.

Packaging

The game is now totally done, all that’s left is packaging it. The Löve wiki goes into more detail on this than I will, but the short version is that we’re going to make a fallgame.love (a renamed zip containing our game) and then package it up with a copy of Löve itself so there are no dependencies. This is different for each OS; here’s a packaged-up version for Mac, and a .love file (openable if you have Löve installed, which you should):

Fall for Mac
Fall for anything

And as usual, here’s the code on Github.

Written by randrews

July 2nd, 2011 at 10:24 pm

Posted in Uncategorized