Lecture 3: Match 3
Today’s Topics
- Anonymous Functions
- We’ll see how we can define functions that operate as data types!
- Tweening
- Tweening will allow us to interpolate a value within a range over time.
- Timers
- We will learn how to make things happen within a range of time and/or after a certain amount of time has passed.
- Solving Matches
- This is the heart of Match 3; we’ll discuss how we can actually solve matches in order to progress the game.
- Procedural Grids
- We’ll dicuss how to randomly generate levels using procedural grids for our Match 3 game.
- Sprite Art and Palettes
- This is a fundamental part of 2D game development. We’ll discuss how to create sprites and color palettes for our game!
Downloading demo code
timer0 (“The Simple Way”)
- As usual, be sure to have downloaded the code for this week in order to follow along.
- timer0 renders a blank screen with the following text drawn in the center: “Timer:
x
seconds”, wherex
is an integer counting up from 0 once per second.
Important Code
- As usual, in
main.lua
we require thepush
library and set up our screen dimension variables. love.load()
initializes two variables to0
(a countercurrentSecond
and a timersecondTimer
) and sets up our screen dimensions.love.resize()
allows us to resize our screen.love.keypressed(key)
listens for keyboard input that quits the game when theescape
key is pressed.love.update()
increments our counter using our timer:function love.update(dt) secondTimer = secondTimer + dt if secondTimer > 1 then currentSecond = currentSecond + 1 secondTimer = secondTimer % 1 end end
- Finally,
love.draw()
displays our text and counter to the screen. - This implementation is not ideal.. Can you think of why?
timer1 (“Also the Ugly Way”)
- timer1 renders a screen with the text “Timer:
x
seconds (everyy
)” displayed in five rows, wherex
is an integer counting up from 0, andy
is an integer representing how many seconds have passed between each increment ofx
.
Important Code
- Take a look again at
main.lua
. This should make the problem with our implementation a little clearer. This program simulates a situation in which we might need several sprites to perform some action at different times. With the way we’ve chosen to approach this problem, you’ll notice that our code begins to get messier and messier as we introduce more and more counter and timer variables:function love.update(dt) secondTimer = secondTimer + dt if secondTimer > 1 then currentSecond = currentSecond + 1 secondTimer = secondTimer % 1 end secondTimer2 = secondTimer2 + dt if secondTimer2 > 2 then currentSecond2 = currentSecond2 + 1 secondTimer2 = secondTimer2 % 2 end secondTimer3 = secondTimer3 + dt if secondTimer3 > 4 then currentSecond3 = currentSecond3 + 1 secondTimer3 = secondTimer3 % 4 end ... end
- Surely there is a better way to do this than initializing all these variables in
love.load()
and cramming ourlove.update()
with separate logic for each of them?
timer2 (“The Clean Way”)
- timer2 behaves exactly like timer1 but is written with much better design.
Important Functions
- You’ll notice at the top of
main.lua
that we are borrowing aTimer
class from theKnife
library, which we’ve now added to our project directory:Timer = require 'knife.timer'
- This will simplify our code greatly, but before we dive into the rest of it, take a moment to familiarize yourself with the following functions:
Timer.every(interval, callback)
- Calls
callback
, which is a function, everyinterval
, whereinterval
is measured in seconds; this happens indefinitely.
- Calls
Timer.after(interval, callback)
- Calls
callback
afterinterval
, but only does this one time.
- Calls
- For more documentation, refer to the below links:
Important Code
- You’ll notice in
love.load()
that we now store all our counters and intervals using two tables, which allows us to simply loop over ourintervals
table and useTimer.every
on each interval, passing in our own custom function as thecallback
. Our anonymous function takes care of incrementing each counter per each interval:function love.load() intervals = {1, 2, 4, 3, 2, 8} counters = {0, 0, 0, 0, 0, 0} for i = 1, 6 do Timer.every(intervals[i], function() counters[i] = counters[i] + 1 end) end ... end ...
- Now, our previously cluttered
love.update()
can simply defer all timer related logic to theTimer
class.function love.update(dt) Timer.update(dt) end
- This design is incredibly scalable. Now, if we wanted to add in additional counters and timers, we need only make a handful of tweaks to our existing tables and loops, whereas previously we would’ve needed to introduce additional overhead in the form of additional variables and loops.
tween0 (“The Simple Way”)
- If unfamiliar with the term, “tweening” (short for “in-betweening”) refers to “the process of generating intermediate frames between two images to give the appearance that the first image evolves smoothly into the second image” (definition taken from webopedia.com/TERM/T/tweening.html).
- The goal, of course, is to create the illusion of motion.
- With that in mind, tween0 renders a screen in which our fifty-bird sprite moves across the screen from left to right, with a timer being displayed in the top-left corner.
Important Code
- Take special note of the global
MOVE_DURATION
constant we’ve defined atopmain.lua
. We use it inlove.update(dt)
to ensure that our sprite takes approximately that amount of time (in seconds) to move across the screen each time the program is run. love.load()
creates our bird sprite (note than we’ve added its image file to our project directory), assigns it initial coordinate values, and initializes anendX
variable to hold the value of thex
coordinate we want our sprite to have at the end of its movement.- Notice now our use of
math.min
inlove.update(dt)
:function love.update(dt) if timer < MOVE_DURATION then timer = timer + dt flappyX = math.min(endX, endX * (timer / MOVE_DURATION)) end end
- By using
math.min
here, we ensure that we don’t go past our desiredendX
. Ourtimer
variable increases bydt
each frame, sotimer / MOVE_DURATION
is the ratio by which we multiply ourendX
each turn to make it seem as if we’re moving towards the right during the desiredMOVE_DURATION
. - This program works, but again, it is not the optimal implementation. Take a moment to brainstorm for a potentially better approach…
tween1 (“A Better Way”)
- tween1 operates under the same premise as tween0, but instead of moving one sprite across the screen, it moves 1000 sprites across the screen, each at different rates, albeit with the same maximum timer.
Important Code
love.load()
now sets up the sprites on the screen using abirds
table and looping through it. Also sets up an individualrate
andy
position for each sprite:function love.load() flappySprite = love.graphics.newImage('flappy.png') birds = {} for i = 1, 100 do table.insert(birds, { x = 0, y = math.random(VIRTUAL_HEIGHT - 24), rate = math.random() + math.random(TIMER_MAX - 1) }) end ... end
- Each sprite’s
rate
is calculated randomly. Recall thatmath.random()
by itself will generate a random float between 0 and 1, so we add that tomath.random(TIMER_MAX - 1)
to get a number between 0 and 10, floating-point. love.update(dt)
loops overbirds
and moves each sprite across the screen using its individualrate
:function love.update(dt) if timer < TIMER_MAX then timer = timer + dt for k, bird in pairs(birds) do bird.x = math.min(endX, endX * (timer / bird.rate)) end end end
- Again, this is a more scalable design, which is always better
tween2 (“The Timer.tween
way”)
- tween2 behaves similarly to tween1 but with the added component of tweening the opacity of the sprites.
Important Functions
Timer.tween(duration, definition)
- Interpolates values specified in the
definition
table over some length of time (duration
), where the values indefinition
are the final values of the transformation.
- Interpolates values specified in the
Important Code
- Not much has changed in
main.lua
, but we’ve introduced anopacity
value inlove.load()
, which, along with theendX
position, gets passed intoTimer.tween
.function love.load() ... for k, bird in pairs(birds) do Timer.tween(bird.rate, { [bird] = { x = endX, opacity = 255 } }) end ... end
- This again allows us to defer our
love.update()
function to theTimer
class:function love.update(dt) Timer.update(dt) end
chain0 (“The Simple (and Hard… and Ugly) Way”)
- chain0 moves a fifty-bird sprite along the perimeter of the screen in clockwise fashion.
Important Code
- The “chain” behavior in
main.lua
comes from knowing when the sprite has finished one animation (e.g., moving from top-left to top-right) and must begin the next (e.g., moving from top-right to bottom-right) - A naive implementation of this behavior might be to keep track of our sprite’s progress using a series of
if
statements along the lines of:if sprite is at top-left then move to top-right elseif sprite is at top-right then move to bottom-right elseif sprite is at bottom-right then move to bottom-left elseif sprite is at bottom-left then move to top-left end
Although this is an intuitive and easy-to-understand way of solving the problem, you can imagine it would quickly become very messy as the path of the sprite increases in complexity.
- To avoid littering our code with
if
statements, we create adestinations
table inlove.load()
that holds the position of each corner of the screen (i.e., our sprite’s destinations):destinations = { [1] = {x = VIRTUAL_WIDTH - flappySprite:getWidth(), y = 0}, [2] = {x = VIRTUAL_WIDTH - flappySprite:getWidth(), y = VIRTUAL_HEIGHT - flappySprite:getHeight()}, [3] = {x = 0, y = VIRTUAL_HEIGHT - flappySprite:getHeight()}, [4] = {x = 0, y = 0} }
- We attribute a
reached
flag to each of the destinations that can be toggled fromfalse
totrue
as fifty-bird reaches each corner:for k, destination in pairs(destinations) do destination.reached = false end
- What this allows us to do is check in
love.update()
for each destination’sreached
flag to be toggled, and move fifty-bird accordingly, which can be done in a loop:function love.update(dt) timer = math.min(MOVEMENT_TIME, timer + dt) for i, destination in ipairs(destinations) do if not destination.reached then flappyX, flappyY = baseX + (destination.x - baseX) * timer / MOVEMENT_TIME, baseY + (destination.y - baseY) * timer / MOVEMENT_TIME if timer == MOVEMENT_TIME then destination.reached = true baseX, baseY = destination.x, destination.y timer = 0 end break end end end
- Although this solution is cleaner than the naive implementation, it still requires us to manually handle all the interpolation from one destination to the next. We can arrive at an even better solution when we make use of the
Timer
class.
chain1 (“The Better Way”)
- chain1 behaves the same way as chain0 but is written with better design.
Important Functions
Timer:finish(callback)
- A function we can call after any
Timer
function (tween
,every
,after
, etc.), which callscallback
once that function has completed. Useful for chaining any of the aforementioned function types together.
- A function we can call after any
Important Code
- By making use of the
Timer
class’s methods, we can defer ourlove.update()
function toTimer.update()
. As a result, most of the logic in the code happens inlove.load()
. - We can use
Timer.tween
to take care of the sprite’s movement from the first destination to the second, and then applyTimer.finish
to that function call. WithinTimer.finish
, we can make a subsequent call toTimer.tween
to take care of the next animation. This is analogous to telling the computer: “Move the sprite from point 1 to point 2. When you’re finished, move it from point 2 to point 3 (and so on)”.function love.load() flappySprite = love.graphics.newImage('flappy.png') flappy = {x = 0, y = 0} Timer.tween(MOVEMENT_TIME, { [flappy] = {x = VIRTUAL_WIDTH - flappySprite:getWidth(), y = 0} }) :finish(function() Timer.tween(MOVEMENT_TIME, { [flappy] = {x = VIRTUAL_WIDTH - flappySprite:getWidth(), y = VIRTUAL_HEIGHT - flappySprite:getHeight()} }) :finish(function() Timer.tween(MOVEMENT_TIME, { [flappy] = {x = 0, y = VIRTUAL_HEIGHT - flappySprite:getHeight()} }) :finish(function() Timer.tween(MOVEMENT_TIME, { [flappy] = {x = 0, y = 0} }) end) end) end) ... end
- The downside of this approach is that, similarly to the issue with our endless
if
statements example, it suffers from a certain lack of scalability, since we need to make an additional nested call each time we want to include a new chain. This is sometimes referred to in programming circles as “Callback Hell”. Thankfully, theTimer
class provides some helpful workarounds to this problem, which we may revisit later. - For now, however, merely note that it’s favorable to trade some scalability in exchange for not reinventing the wheel.
swap0 (“Just a Board”)
- swap0 creates our Match 3 board and renders it to the screen.
Important Code
- Like last week, you’ll notice we again make use of a separate file,
Util.lua
, to store our own helper functions for generating Quads. - In
love.load()
we’re generating our tile Quads (by calling our helper method,GenerateQuads()
, fromUtil.lua
) and setting up the board (by calling a helper function,generateBoard()
, from withinmain.lua
):function love.load() tileSprite = love.graphics.newImage('match3.png') tileQuads = GenerateQuads(tileSprite, 32, 32) board = generateBoard() love.graphics.setDefaultFilter('nearest', 'nearest') push:setupScreen(VIRTUAL_WIDTH, VIRTUAL_HEIGHT, WINDOW_WIDTH, WINDOW_HEIGHT, { fullscreen = false, vsync = true, resizable = true }) end
- Unlike the bricks in breakout, the tiles in Match 3 will always remain in a full grid, which we implement as an 8-by-8 2D array. Within the 2D array, we represent each tile as a table containing
x
andy
coordinates as well as a Quad:function generateBoard() local tiles = {} for y = 1, 8 do table.insert(tiles, {}) for x = 1, 8 do table.insert(tiles[y], { x = (x - 1) * 32, y = (y - 1) * 32, tile = math.random(#tileQuads) }) end end return tiles end
love.draw()
then calls another helper function,drawBoard()
, to actually render the board by looping through the 2D array and drawing each individual Quad at itsx,y
coordinates.
swap1 (“The Static Swap”)
- swap1 allows the user to swap two tiles on the board. The implementation uses keyboard behavior (move among tiles using the arrow keys, select tiles with the “enter” key) and does not enforce any rules for which tiles can be swapped.
Important Code
love.load()
looks very similar to the swap0 version, but we’ve added a few new variables to keep track of the highlighting behavior that will be useful for the user to see which tile they’re currently selecting:highlightedTile = false highlightedX, highlightedY = 1, 1 selectedTile = board[1][1]
love.keypressed(key)
contains several new additions, as might be expected. Here we’ve begun to monitor whether the user has pressed any arrow keys, and if so, we move them around our 2D tile array accordingly:local x, y = selectedTile.gridX, selectedTile.gridY if key == 'up' then if y > 1 then selectedTile = board[y - 1][x] end elseif key == 'down' then if y < 8 then selectedTile = board[y + 1][x] end elseif key == 'left' then if x > 1 then selectedTile = board[y][x - 1] end elseif key == 'right' then if x < 8 then selectedTile = board[y][x + 1] end end
Recall that the first table index in Lua is 1, not 0, and also recall that the way to index into a 2D array (i.e., a table of tables) is by specifying
table[row][column]
, sotable[1][1]
would be the top-left element in the 2D array, andtable[2][3]
would be the third element in the second row.- We also monitor whether the user has pressed
enter
(orreturn
) to select a tile. Once two tiles have been selected, we swap them by swapping their place in our 2D array as well as swapping their coordinates on the grid:if key == 'enter' or key == 'return' then if not highlightedTile then highlightedTile = true highlightedX, highlightedY = selectedTile.gridX, selectedTile.gridY else local tile1 = selectedTile local tile2 = board[highlightedY][highlightedX] local tempX, tempY = tile2.x, tile2.y local tempgridX, tempgridY = tile2.gridX, tile2.gridY local tempTile = tile1 board[tile1.gridY][tile1.gridX] = tile2 board[tile2.gridY][tile2.gridX] = tempTile tile2.x, tile2.y = tile1.x, tile1.y tile2.gridX, tile2.gridY = tile1.gridX, tile1.gridY tile1.x, tile1.y = tempX, tempY tile1.gridX, tile1.gridY = tempgridX, tempgridY highlightedTile = false selectedTile = tile2 end end
- Finally, in addition to handling the rendering of the board as before,
drawBoard()
now contains some additional logic in order to display the highlighting of selected tiles.
swap2 (“The Tween Swap”)
- swap2 behaves the same way as swap1, with the graphical difference of tweening during a swap.
Important Code
- Everything should look essentially the same except for a few lines in
love.keypressed(key)
starting from line 99, where we tween the tile coordinates instead of instantly swapping them:Timer.tween(0.2, { [tile2] = {x = tile1.x, y = tile1.y}, [tile1] = {x = tempX, y = tempY} })
Calculating Matches
- So, how exactly are we calculating matches in our final version of Match 3? If curious, you can find the implementation in
match-3/src/Board.lua
, in theBoard:calculateMatches()
method on line 50. - The algorithm itself is as follows:
- Starting from the top-left corner of the board (i.e., the first tile), set
colorToMatch
to the color of the current tile. - Then, check the adjacent tile on the right (i.e., the second tile). If the second tile is of the same color as
colorToMatch
, incrementmatchNum
(a counter). If not, setcolorToMatch
to the color of the second tile and resetmatchNum
to 1. - Repeat this process for the subsequent tiles in that row. If
matchNum
reaches 3, we add that group of tiles to amatches
table as soon as we reach an adjacent tile of a different color, or the end of the row. - After all rows have been checked for matches, this process is repeated for each column, going from top to bottom.
- Starting from the top-left corner of the board (i.e., the first tile), set
- The result of our algorithm is a table containing each group of matches, both horizontal and vertical, with each group being stored as its own table.
Removing Matches
- Now the question is how we might remove our matching tiles, once we have them all in a table. You might think we’d have to do some tricky coding with their coordinates on the grid or their position on the board, but this problem can actually be solved very straightforwardly.
- As you can see in
Board:removeMatches()
(still inmatch-3/src/Board.lua
), we can simply loop over each table within ourmatches
table and set the value of each tile tonil
, after which we can also set ourmatches
table tonil
. - Instead of breaking everything, this has the effect of erasing the tiles and the
matches
table from existence, as far as the user is concerned. The result on the screen would be the board as it was before, but with holes where previously there would’ve been matches. - The next function in the file,
Board:getFallingTiles()
, takes care of shifting down the remaining tiles in each column if needed.- As you might imagine, the algorithm consists of starting from the bottom of each column and checking each slot in the column until a
nil
slot is found. - Once a
nil
slot is found, its position is marked and the next non-nil
tile found in the column is tweened down to the marked position. - This process is iterated for each
nil
slot in each column.
- As you might imagine, the algorithm consists of starting from the bottom of each column and checking each slot in the column until a
Replacing Tiles
- After we’ve removed matching tiles and shifted down those left over, we need to figure out how to replace the remaining
nil
positions. This is also done inBoard:getFallingTiles()
. - The algorithm itself is simple:
- For each column, we count the number of
nil
spaces and generate that many new tiles. - Then, we can set each tile’s position to be that of one of the
nil
spaces, and add it to our board array. - We want the animation to look like the tiles are falling into place, however, so to produce this effect, we tween each new tile from the top of the board to its final position, letting the
Timer
class take care of the actual implementation.
- For each column, we count the number of
- One additional note is that the possibility exists that the newly-generated tiles will create additional matches when they spawn.
- As a result, we must make sure we check for matches again after generating the new tiles.
- This is done in
match-3/src/states/PlayState.lua
, in thePlayState:calculateMatches()
function, which uses the functions inBoard.lua
to calculate matches, remove matches, and get falling tiles, but then importantly recurses until it ensures that no new matches have been found.
Palettes
- A palette is essentially just a set of available colors.
- In our programs, we have been using DB32 - DawnBringer’s 32 Color Palette (V1.0).
- The idea behind using a palette is that it allows you to “dither” (i.e., interleave two colors pixel by pixel in an attempt to approximate another color) such that your programs can look like they use many colors, when in reality you only have a small set (in this case 32) available to you.
- When done properly, the difference between a dithered image and an image that actually uses hundreds of thousands of colors can be quite minimal.
Palette Swapping
- Palette swapping is the practice of using the same sprite for two different graphics, but with a different palette for each, such that the resulting sprites are noticeably distinct from one another.
- For example, a bush and a cloud could share the same sprite, but while one would be green, the other would be white.