Lecture 4: Super Mario Bros.
Today’s Topics
- Tile Maps
- How can we take a series of numbers (e.g., tile IDs) and turn them into a game world?
- 2D Animation
- How can we “animate” a sprite and give its body the appearance of movement (e.g., walking?)
- Procedural Level Generation
- How can we generate our levels randomly so that we’re not limited to a specific number of different levels we’ve created?
- Platformer Physics
- How can we implement basic platformer physics so that we don’t have to iterate through our entire world to determine, for example, collisions?
- Basic AI
- How can we program an adversary to attack the player on its own?
- Powerups
- How can we create “powerups” that affect “Mario” and his abilities?
Downloading demo code
Tilemaps
- A tilemap can be thought of as a giant 2D array of numbers.
- It can be a little more complicated, of course, since some numbers represent tiles that are solid and others represent tiles that are not. This determines whether or not a player can collide with a certain tile, which will obviously be very relevant to mario.
- In mario, each level is comprised of many small tiles that give the appearance of some larger whole.
- As we alluded to earlier, tiles often have an ID of some kind to differentiate their appearance or behavior.
tiles0 (“Static Stiles”)
- tiles0 displays a colorful, static background on the screen with some tiles in the foreground.
Important Code
- In
love.load()
, we define atiles
table for storing each of our tiles, which will be represented as tables themselves, and we will render them on the screen using the sprite sheet intiles.png
.- It might seem like our sprite sheet contains just one sprite (the tile that is visible in
tiles.png
), but it also contains an additional sprite of the same tile, but transparent. - This will allow us to differentiate our tiles in our program with IDs (
SKY
andGROUND
), which is how we can have half the screen contain tiles, and the other half be a simple color
- It might seem like our sprite sheet contains just one sprite (the tile that is visible in
- The rest of
love.load()
populates the map (i.e., the screen) with a random-color background, filling the top half of the screen with transparent tile sprites and the lower half with opaque tile sprites:tilesheet = love.graphics.newImage('tiles.png') quads = GenerateQuads(tilesheet, TILE_SIZE, TILE_SIZE) mapWidth = 20 mapHeight = 20 backgroundR = math.random(255) / 255 / 255 backgroundG = math.random(255) / 255 / 255 backgroundB = math.random(255) / 255 / 255 for y = 1, mapHeight do table.insert(tiles, {}) for x = 1, mapWidth do table.insert(tiles[y], { id = y < 5 and SKY or GROUND }) end end
And of course, after the above block of code, it also sets up our screen dimensions, as always.
- If the loop in
love.load()
seems odd, take a look atlove.draw()
and notice how the map is actually rendered. We are looping over thetiles
table and drawing them on the screen, which is why their IDs fromlove.load()
are relevant:function love.draw() push:start() love.graphics.clear(backgroundR, backgroundG, backgroundB, 1) for y = 1, mapHeight do for x = 1, mapWidth do local tile = tiles[y][x] love.graphics.draw(tilesheet, quads[tile.id], (x - 1) * TILE_SIZE, (y - 1) * TILE_SIZE) end end push:finish() end
tiles1 (“Scrolling Tiles”)
- tiles1 looks the same as tiles0, but allows the user to shift the map from left to right using the arrow keys.
Important Functions
love.graphics.translate(x, y)
- Shifts the coordinate system by
x, y
; useful for simulating camera behavior.
- Shifts the coordinate system by
Important Code
- You’ll notice we’ve added in some logic to
love.update()
, whose purpose for now is to update acameraScroll
variable whenever the user presses the left or right arrow key:function love.update(dt) if love.keyboard.isDown('left') then cameraScroll = cameraScroll - CAMERA_SCROLL_SPEED * dt elseif love.keyboard.isDown('right') then cameraScroll = cameraScroll + CAMERA_SCROLL_SPEED * dt end end
- Now, in
love.draw()
, we have to translate the map in the opposite direction ascameraScroll
, since the map needs to move to the left when the user scrolls to the right, and vice versa:love.graphics.translate(-math.floor(cameraScroll), 0) love.graphics.clear(backgroundR, backgroundG, backgroundB, 1)
We make sure to round down our
cameraScroll
usingmath.floor()
in order to prevent any weird blurring or other such visual effects that might result from our screen trying to handle floating-point numbers in a small, virtual canvas.
character0 (“The Stationary Hero”)
- character0 behaves the same way as tiles1, but with the simple addition of introducing a stationary character sprite that stands on the tiles.
Important Code
- In
love.load()
you’ll notice that we’re accessing a new sprite sheet for our character sprite, and setting thex
andy
coordinates for our character sprite:characterSheet = love.graphics.newImage('character.png') characterQuads = GenerateQuads(characterSheet, CHARACTER_WIDTH, CHARACTER_HEIGHT) characterX = VIRTUAL_WIDTH / 2 - (CHARACTER_WIDTH / 2) characterY = ((7 - 1) * TILE_SIZE) - CHARACTER_HEIGHT
- The only other change is in
love.draw()
, where we render our character to the screen (for now, we’re only rendering the sprite in the first Quad):love.graphics.draw(characterSheet, characterQuads[1], characterX, characterY)
character1 (“The Moving Hero”)
- character1 allows our character sprite to move from left to right on the map. However, it no longer allows us to scroll the camera.
Important Code
- The main difference here is in
love.update()
. What we’ve done is instead of updatingcameraScroll
, we update our character’sx
coordinate when the user presses the left or right arrow keys:function love.update(dt) if love.keyboard.isDown('left') then characterX = characterX - CHARACTER_MOVE_SPEED * dt elseif love.keyboard.isDown('right') then characterX = characterX + CHARACTER_MOVE_SPEED * dt end end
Can you think of a better solution?
character2 (“The Tracked Hero”)
- character2 addresses the camera issue from character1.
- Now, the camera is fixed on our character sprite, allowing the user to move the sprite while scrolling the camera. However, there is still no animation for the sprite, and the user can still scroll off the edge of the map.
Important Code
- The main change here is achieved by simply updating
cameraScroll
inlove.update()
to track the character sprite’sx
coordinate, making sure to center it on the screen as well:function love.update(dt) if love.keyboard.isDown('left') then characterX = characterX - CHARACTER_MOVE_SPEED * dt elseif love.keyboard.isDown('right') then characterX = characterX + CHARACTER_MOVE_SPEED * dt end cameraScroll = characterX - (VIRTUAL_WIDTH / 2) + (CHARACTER_WIDTH / 2) end
character3 (“The Animated Hero”)
- character3 behaves the same way as character2 but with the added feature of animating our sprite’s movement.
- Animations can be achieved by simply displaying a series of frames from a sprite sheet one after the other, akin to a flip book.
Important Code
Animation.lua
will take care of handling animations for our program.Animation:init(def)
will take in an argument which will contain information about the animation in question. In particular, it will specify the number of frames in the animation as well as the interval between each frame:function Animation:init(def) self.frames = def.frames self.interval = def.interval self.timer = 0 self.currentFrame = 1 end
Animation:update(dt)
takes care of toggling among the frames at the proper intervals:function Animation:update(dt) if #self.frames > 1 then self.timer = self.timer + dt if self.timer > self.interval then self.timer = self.timer % self.interval self.currentFrame = math.max(1, (self.currentFrame + 1) % (#self.frames + 1)) end end end
Animation:getCurrentFrame()
returns the current frame for the animation.
- Back in
main.lua
,love.load()
is where we actually instantiate our animations (one for moving and the other for standing still) and set our sprite’s direction:idleAnimation = Animation { frames = {1}, interval = 1 } movingAnimation = Animation { frames = {10, 11}, interval = 0.2 } currentAnimation = idleAnimation direction = 'right'
love.update()
takes care of toggling the moving animations when the user moves the character and setting the animation back to idle when the user is not moving the character:function love.update(dt) currentAnimation:update(dt) if love.keyboard.isDown('left') then characterX = characterX - CHARACTER_MOVE_SPEED * dt currentAnimation = movingAnimation direction = 'left' elseif love.keyboard.isDown('right') then characterX = characterX + CHARACTER_MOVE_SPEED * dt currentAnimation = movingAnimation direction = 'right' else currentAnimation = idleAnimation end cameraScroll = characterX - (VIRTUAL_WIDTH / 2) + (CHARACTER_WIDTH / 2) end
love.draw()
renders everything on the screen as before.
character4 (“The Jumping Hero”)
- character4 behaves similarly to character3, but with the added feature of allowing the character to jump
Important Code
- In
love.load()
, you’ll notice we’ve added an additional animation for jumping. Another addition to the code is the introduction of acharacterDY
for jumping and applying gravity:jumpAnimation = Animation { frames = {3}, interval = 1 } characterDY = 0
As a side note, it’s important to recognize there are two separate states when jumping: jumping and falling. In mario, for example, you can destroy a block by hitting it while you jump, which you can’t do by merely falling on it. Similarly, you can defeat an enemy by falling on its head, but not by jumping towards it.
love.keypressed(key)
now monitors whether the user has hit the “space” key and responds by settingcharacterDY
to an arbitrary jump velocity and triggering the jump animation, although it enforces that the character can only jump if they are on the ground (no double jumps mid-air, etc.):function love.keypressed(key) if key == 'escape' then love.event.quit() end if key == 'space' and characterDY == 0 then characterDY = JUMP_VELOCITY currentAnimation = jumpAnimation end end
love.update(dt)
usescharacterDY
to apply velocity to the sprite’sy
coordinate, settingcharacterDY
to0
once the sprite hits the floor (since we haven’t implemented tile collision yet). This implementation is a bit hacky, but it works for now:characterDY = characterDY + GRAVITY characterY = characterY + characterDY * dt if characterY > ((7 - 1) * TILE_SIZE) - CHARACTER_HEIGHT then characterY = ((7 - 1) * TILE_SIZE) - CHARACTER_HEIGHT characterDY = 0 end
Procedural Level Generation
- Platformer levels can be procedurally generated like anything else.
- These levels can be more easily generated per column rather than per row, given things like gaps, though there are multiple ways to do it.
- Most easily, tiles can foundationally be generated and act as the condition upon which GameObjects and Entities are generated.
level0 (“Flat Levels”)
- level0 builds off from character4, but it allows the user to change the tile configuration on the map floor by pressing the
r
key on the keyboard. - Each tile configuration consists of a “topper” and a “tileset”, where the topper is a distinct color from the tileset and denotes the topmost surface of the tiles.
Important Code
- In
love.load()
you’ll notice we’re generating Quads from a sprite sheet in order to generate the tiles and toppers, selecting a random set as the starting configuration:tilesheet = love.graphics.newImage('tiles.png') quads = GenerateQuads(tilesheet, TILE_SIZE, TILE_SIZE) topperSheet = love.graphics.newImage('tile_tops.png') topperQuads = GenerateQuads(topperSheet, TILE_SIZE, TILE_SIZE) tilesets = GenerateTileSets(quads, TILE_SETS_WIDE, TILE_SETS_TALL, TILE_SET_WIDTH, TILE_SET_HEIGHT) toppersets = GenerateTileSets(topperQuads, TOPPER_SETS_WIDE, TOPPER_SETS_TALL, TILE_SET_WIDTH, TILE_SET_HEIGHT) tileset = math.random(#tilesets) topperset = math.random(#toppersets)
- In
love.keypressed(key)
we’ve added a check for ther
key, which selects another random tile and topper set to display:if key == 'r' then tileset = math.random(#tilesets) topperset = math.random(#toppersets) end
- In
love.draw()
we’re drawing the tileset and then checking thetopper
flag on each tile to determine whether or not to draw a topper on top of it. Only the tiles with ay
of 7 will need a topper:for y = 1, mapHeight do for x = 1, mapWidth do local tile = tiles[y][x] love.graphics.draw(tilesheet, tilesets[tileset][tile.id], (x - 1) * TILE_SIZE, (y - 1) * TILE_SIZE) if tile.topper then love.graphics.draw(topperSheet, toppersets[topperset][tile.id], (x - 1) * TILE_SIZE, (y - 1) * TILE_SIZE) end end end
generateLevel()
is a custom function we wrote to modularize the map-generation process:function generateLevel() local tiles = {} for y = 1, mapHeight do table.insert(tiles, {}) for x = 1, mapWidth table.insert(tiles[y], { id = y < 7 and SKY or GROUND, topper = y == 7 and true or false }) end end return tiles end
- Now, instead of having all that logic in
love.load()
, we can simply substitute it with a function call:tiles = generateLevel()
level1 (“Pillared levels”)
- level1 behaves the same way as level0 but allows for pillars to be drawn on the map.
Important Code
- We’ve modified our
generateLevel()
function to support this new functionality. - Now, we first populate the map entirely with empty tiles (i.e., tiles with
id
ofSKY
):for y = 1, mapHeight do table.insert(tiles, {}) for x = 1, mapWidth do table.insert(tiles[y], { id = SKY, topper = false }) end end
- Then, we iterate through the map column by column, generating the ground as usual:
for x = 1, mapWidth do local spawnPillar = math.random(5) == 1 if spawnPillar then for pillar = 4, 6 do tiles[pillar][x] = { id = GROUND, topper = pillar == 4 and true or false } end end ... end
- However, we now introduce the possibility of spawning a pillar (in this case it’s a 1 in 5 chance). If a pillar is to be spawned, we must modify the tiles with
y
4 to 6 to haveid
ofGROUND
(where the tile withy
of 4 needs a topper, rather than the tile withy
of 7).for x = 1, mapWidth do local spawnPillar = math.random(5) == 1 ... for ground = 7, mapHeight do tiles[ground][x] = { id = GROUND, topper = (not spawnPillar and ground == 7) and true or false } end end return tiles
level2 (Chasmed levels)
- level2 introduces chasms to the map, with everything else working the same as before.
Important Code
- Again, the changes are in
generateLevel()
. - We’ve now added a chasm spawn chance (1 in 7) similar to the pillar spawn chance.
- Since the first thing we do is populate the map with empty tiles, that means our implementation for creating chasms can be to simply skip the ground generation.
- The syntax to do this in Lua is to use the
goto
keyword to point the program to a label of your choice, in our casecontinue
, skipping all the code in between:for x = 1, mapWidth do if math.random(7) == 1 then goto continue end ... ::continue:: end
Tile Collision
- AABB can be useful for detecting entities, but we can take advantage of our static coordinate system and 2D tile array and just calculate whether the pixels in the direction we’re traveling are solid, saving us computing time.
- Check out
TileMap:pointToTile(x, y)
inmario/src/TileMap.lua
:function TileMap:pointToTile(x, y) if x < 0 or x > self.width * TILE_SIZE or y < 0 or y > self.height * TILE_SIZE then return nil end return self.tiles[math.floor(y / TILE_SIZE) + 1][math.floor(x / TILE_SIZE) + 1] end
The first part of this function merely ensures that we don’t return an error if we somehow go beyond the boundaries of the map. The second part of this function actually returns the tile in our
tiles
array that corresponds with thex
andy
coordinates in our map so that we can check if it is solid or empty. - In terms of performance, this is notably better than having to iterate over all tiles in our array to check AABB , with some inflexibilities (tiles can’t move around, for example).
- We can improve performance even further by only checking for collisions above us when in jumping state, collisions below us when in idle, walking and falling state, and collisions to the left and right when in walking, jumping, and falling state (as opposed to checking for collisions in all directions in all states)
Entities
- Entities can contain states just like the game, with their own StateMachine; states can affect input handling (for the player) or decision-making (like the Snail).
- Some engines may adopt an Entity-Component System (or ECS), where everything is an Entity and Entities are simply containers of Components, and Components ultimately drive behavior (Unity revolves around an ECS).
- Collision can just be done entity-to-entity using AABB collision detection.
- Entities in our distro represent the living things in our world (Snail and Player), but could generally represent almost anything.
Game Objects
- Game objects are separate from the tiles in our map, for things that maybe don’t align perfectly with it (maybe they have different widths/heights or their positions are offset by a different amount than
TILE_SIZE
). - They can be tested for collision by AABB, are often just containers of traits and functions, and can be represented via Entities (but aren’t in this distro).
- Powerups in mario can be represented as GameObjects:
- Effectively a GameObject that changes some
status
or trait of the player. - An invincible star may flip an
invincible
flag on the player and begin aninvincibleDuration
timer. - A mushroom to grow the player may trigger a
huge
flag on the player that alters theirx
,y
,width
, andheight
and then scales their sprite.
- Effectively a GameObject that changes some
- We allow for the generation of game objects in
mario/src/LevelMaker.lua
. Read through this file carefully in order to get a good intuition for how you could add in your own game objects.