Lecture 4
- Welcome!
- Tile Maps
- Camera Scrolling
- Character Sprites
- Character Movement
- Animation
- Jumping and Gravity
- Tile Sets and Toppers
- Procedural Level Generation
- Platformer Collision
- Entities and Entity States
- Game Objects
- Enemies and AI
- Summing Up
Welcome!
- Today, we’ll be looking at Super Mario Brothers, which is a very famous, possibly the most famous, franchise of games that we’ll be looking at through this term. As you can see by the slide, we won’t be necessarily using the exact Mario assets today. We’ll be looking at a really cool Creative Commons version of the same platformer tile set, but we’ll be exploring all the ideas that ultimately make up Mario.
- This is, in a lot of ways, a great transition to this idea of a virtual world in our game, as opposed to the abstract games we’ve looked at so far. We have Pong, which is these rectangles moving through space. Flappy Bird, we looked at illusions, but there is no really a world representation per se. Even last week, when we looked at Match 3 and Breakout, these are relatively abstract, not so much worlds.
- But today we’ll be actually looking at how to model something similar to Mario. We see we have enemies moving around. We have a ground point at some part. We have Mario jumping, which we have looked at through Flappy Bird. We have blocks, pipes, and we have a background. All these things make us feel like we’re in a space, which is super cool.
- Some of the things we’ll be looking at today:
- Tile maps - We looked at tile maps last week with Match 3. We’re going to be looking at the same idea this week, except instead of having a puzzle board, we can use the same idea of IDs mapping to quads that we draw onto the screen as being tiles that matter in terms of whether they’re collidable, what they look like, and so on.
- 2D animation - Mario, when he’s jumping, has a different pose than when he’s walking. When he’s walking, he’s going frame by frame from one stepping motion to another. We haven’t really had this idea of sprites that actually change their appearance frame by frame as they move.
- Procedural level generation - This week has one of the more representative procedural level generation focuses, where we actually will make all of our levels in code. We won’t actually hand-create our levels.
- Platformer physics - We’ll talk about how platformer physics differs from AABB and allows us to detect whether our Mario avatar is hitting blocks when he’s jumping or moving.
- AI - A very simple version of AI for the moving Goomba-esque creature we’ll have today, which will be a snail.
- Power-ups - Lastly, how to have things like power-ups, which will tie into the problem set.
- Play the game! You are now controlling Mario. It’ll be the arrow keys to move. You can press space bar to jump. Some of the blocks will have gems, and then some won’t. By jumping onto a snail, you will defeat it. The terrain is collidable, so you’re walking on top of the terrain.
- We have all the pieces here: Collision on the snails, the jump, frames changing as you walk. There was quite a lot of pieces there, a lot of new things, and we’ve taken a step forward in that now we are thinking in terms of existing in a world.
Tile Maps
- With Mario, we actually feel like we’re moving. We have a camera tracking Mario. We’ve got an offset into how we’re rendering things. We have this tile map, and essentially, it’s not all that different from what we looked at last week with the grid of tiles in Match 3, except last week we were using them to render tiles that we could swap and then calculate matches. This time we’ll be actually using them to draw these blocks on the screen that we can walk on, that we can walk up against and butt into.
- Just with this drawing of these little pieces of a texture and putting them in a data structure that models some level: Where maybe if you envision zero being space, one being the ground (in our example, it’s slightly different: Five for the space and three for the ground): You can essentially map that to a level. Then we just calculate, okay, at XY coordinate, there’s a tile there. We should collide with it or not, render it as a tile or not. And then we just feel like we’re in a space now, all of a sudden, magically.
-
Consider the following code from
tiles-0/main.lua:TILE_SIZE = 16 -- tile ID constants SKY = 2 GROUND = 1 function love.load() love.graphics.setDefaultFilter('nearest', 'nearest') math.randomseed(os.time()) tiles = {} -- tilesheet image and quads for it, which will map to our IDs tilesheet = love.graphics.newImage('tiles.png') quads = GenerateQuads(tilesheet, TILE_SIZE, TILE_SIZE) mapWidth = 20 mapHeight = 20 for y = 1, mapHeight do table.insert(tiles, {}) for x = 1, mapWidth do -- sky and bricks; this ID directly maps to whatever quad we want to render table.insert(tiles[y], { id = y < 5 and SKY or GROUND }) end end endNotice that the tile size is 16 pixels. We’ve got two IDs that we care about,
SKYandGROUND. We have atilesarray, which is not all that different from the tiles table that we used last time. We’re just going to splice up our tile sheet, which in this case is just a two-tile tile sheet, giving us back just two frames that we can index into and draw with our one texture. - Here we’re just populating a 2D array, a 2D table of tiles, which are just IDs: Constants mapping to sky or ground. If
yis less than 5, it’s sky; otherwise, it’s ground. -
Consider the draw function:
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() endNotice that we have a two-dimensional loop that iterates over this 2D table structure. It grabs the tile, and it draws, indexing into our quads table to get the exact offset: That rectangle from one to two. It’ll get either the one or the two. Then it draws it at the XY minus one to get coordinate space multiplied by tile size as the offset. Very simple, very similar to what we did last time with the Match 3 grid.
Camera Scrolling
- Mario is a scrolling platformer or 2D platformer. We want to be able to move around and feel like we’re in a world. To do that, we have to have some way of shifting this map around on the screen. We have a handy way that we can start to do that with a function called
love.graphics.translate. - What this will do is rather than everything being drawn just with an assumption that it’s at zero, zero,
love.graphics.translatewill take an X and a Y, and then everything that you draw thereafter will essentially be offset by that XY coordinate, such that you can essentially mimic a camera. If you have X 50 here, everything will be shifted 50 pixels forward. You can think of it as an inverse direction of the camera moving. If we were to shift everything 50 pixels to the right, we’re going to feel as if the camera has moved 50 pixels to the left and so on and so forth for every direction on every axis. - Through this method, we now have a way to envision a camera: A couple of variables, an X and a Y. Particularly we’ll focus on X, but you can have an X and a Y value that model more or less how we can think of the world shifting in the way that we would think of a camera moving on our scene. This gives us the ability to track a character and move through our 2D world.
-
Consider the following code from
tiles-1/main.lua:-- camera scroll speed CAMERA_SCROLL_SPEED = 40 -- amount by which we'll translate the scene to emulate a camera cameraScroll = 0 function love.update(dt) -- update camera scroll based on user input 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 function love.draw() push.start() -- translate scene by camera scroll amount; negative shifts have the effect of making it seem -- like we're actually moving right and vice-versa; note the use of math.floor, as rendering -- fractional camera offsets with a virtual resolution will result in weird pixelation and artifacting love.graphics.translate(-math.floor(cameraScroll), 0) 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() endNotice that we have a camera scroll speed and a
cameraScrollvariable. We manipulate this variable using delta time, just like anything else we’ve done: Some variable that’s going to change based on input. Then right before we do our actual drawing, we translate by the negative camera scroll. We’re rounding down withmath.floorjust to keep things from having fractional translations that can cause artifacting and weird lines, especially when using push and other virtual resolution systems. - We are negating the variable because we want to make sure that when we press left, we think as if we’re moving the camera left. But recall, we have to move X positive in order to actually get that effect because everything gets drawn with
love.graphics.translatewith that offset. We have to negate: If we’re thinking in terms of left and right in our input, we have to negate that in order for that to actually reflect in camera space.
Character Sprites
- Now we have a world and we can scroll it, but we should also have a character on the screen that we can start to move around. To even get started, we need to have some character on the screen to begin with.
-
Consider the following code from
character-0/main.lua:CHARACTER_WIDTH = 16 CHARACTER_HEIGHT = 20 -- texture for the character characterSheet = love.graphics.newImage('character.png') characterQuads = GenerateQuads(characterSheet, CHARACTER_WIDTH, CHARACTER_HEIGHT) -- place character in middle of the screen, above the top ground tile characterX = VIRTUAL_WIDTH / 2 - (CHARACTER_WIDTH / 2) characterY = ((7 - 1) * TILE_SIZE) - CHARACTER_HEIGHTNotice there’s a height and width of the character that we should factor in for positioning. In this particular sprite sheet, our avatar has different frames: There’s one where he’s just standing still, some tiptoeing, walking frames on the right, jumping frames, climbing frames, a ducking one, and then something that looks like he’s unhappy. We’ll use a few of these, in particular the walking one and the jumping one. For right now, we only care about this very first frame.
- It’s not unlike what we’ve looked at previously for Match 3 and for the tiles: We’re just slicing up this texture into these frames, and then we can just index into this array that we know we’ll get back through
GenerateQuads. Then if we want him to look as if he’s moving, we can just set the ID of that frame to whatever index corresponds to walking, or one will just be standing static. - We’re going to set the character X and Y, and we’re going to offset: Basically put him right above tile seven, minus the character height, so that he’s standing right above where the solid ground tiles begin in our 2D tile map.
Character Movement
- Now we want the character to be able to move, and that’s one of the important aspects of Mario. To win a level in Mario, you have to move from the left side of the screen to the right side of the screen.
-
Consider the following code from
character-1/main.lua:CHARACTER_MOVE_SPEED = 40 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 endNotice that we’re doing the same thing that we’ve always done historically: We’re just changing the X value by delta time through some constant that we’ve defined as character move speed. Pretty standard.
- But there’s a problem: If I move the character, the camera doesn’t follow. We have one piece to move the character so the character in the world is moving, but we also have to move the camera along with the character and make sure the camera is statically fastened to the character’s position.
-
Consider the following code from
character-2/main.lua: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 -- set the camera's left edge to half the screen to the left of the player's center cameraScroll = characterX - (VIRTUAL_WIDTH / 2) + (CHARACTER_WIDTH / 2) endNotice that we are doing the same thing where we move the character’s position because the character does need to move in the world. The difference is that now we need to essentially set the scroll to be equal to what’s effectively the offset of the character to its left side. We take whatever the character’s X position is, subtract half the virtual width (which will put it approximately in the center of the screen), and then add the character’s width divided by two because everything in our games is relative to its top left corner. We need to shift halfway through the character’s width to be properly centered.
- Now the camera scroll will always be whatever that half screen width is away from the character, meaning that no matter where our character moves, the camera’s position effectively is going to track the character’s position.
Animation
- Things are a little bit static right now. The fact that we’re just moving this static sprite around feels unnatural. Let’s animate the character.
- You think of an animation as a set of frames that should play over time, and typically, you’ll just loop back through the beginning of them. If you imagine the walking animation, it’s literally just two frames: IDs 10 and 11 or approximately there. If you imagine those flipping back and forth, it’s essentially 10, 11, and then looping back to 10. If your animation was five frames (11, 12, 13, 14, 15), you’d play them in order and then loop back to 11.
- An animation should have an interval, and it’s usually some very small duration in seconds or fractions of seconds. The walking animation is probably like 0.2 seconds. Then a timer to manage that, and then whatever the current frame is.
-
Consider the following code from
character-3/Animation.lua:Animation = Class{} function Animation:init(def) self.frames = def.frames self.interval = def.interval self.timer = 0 self.currentFrame = 1 end function Animation:update(dt) -- no need to update if animation is only one frame 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 function Animation:getCurrentFrame() return self.frames[self.currentFrame] endNotice that delta time is often the solution to these sorts of problems. If there is more than one frame in the animation, we update our timer. Assuming that we’ve crossed over that interval, we set the timer back to the modulo of that interval (wrapping it back to zero and starting with whatever the fractional overlap was). Then we set the current frame to be no less than one. The reason we’re adding plus one when we modulo is so that when we get to the last frame in our animation, we still want that frame to finish over that interval. If we were to just set it to
currentFrame % #frames, it would end up skipping the last frame. We’re essentially moduloing by and adding to one frame past the total number of frames such that it loops back to one at that point, and we still get that last frame rendered on its full interval. -
Consider the following code from
character-3/main.lua:-- two animations depending on whether we're moving idleAnimation = Animation { frames = {1}, interval = 1 } movingAnimation = Animation { frames = {10, 11}, interval = 0.2 } currentAnimation = idleAnimation -- direction the character is facing direction = 'right' function love.update(dt) -- update the animation so it scrolls through the right frames 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) endNotice that we can think of our idle position as an animation: Just a one-frame animation that doesn’t actually do anything. But we do have a moving animation which has 10 and 11 as the two frames into our sprite sheet. The interval is 0.2, so every 0.2 seconds, the frame will tick, and then we’ll go to the next frame and loop back around. We also want to keep track of the direction: If we’re moving left and right, we’re going to also want to flip our graphic left and right.
- When we draw the character, we need to flip it based on direction. If we scale by negative one on the X axis, that’s what flips the sprite around its origin point. But if your origin is on the top left corner of your character (which is what we do by default), the flip will have weird behavior. We want the character to actually rotate in place. To do that, we have to set the origin point of our character right in the center of the sprite instead of the top left corner.
-
Consider the draw call:
love.graphics.draw(characterSheet, characterQuads[currentAnimation:getCurrentFrame()], -- X and Y shifted by half our width/height for proper scaling math.floor(characterX) + CHARACTER_WIDTH / 2, math.floor(characterY) + CHARACTER_HEIGHT / 2, -- 0 rotation, then the X and Y scales 0, direction == 'left' and -1 or 1, 1, -- origin offsets relative to 0,0 on the sprite (set to sprite's center) CHARACTER_WIDTH / 2, CHARACTER_HEIGHT / 2)Notice that we set the origin to character width divided by 2, character height divided by 2. We’re shifting in from the top left corner to be directly in the center of the sprite such that scale operations now happen based off of that origin point and not the top left corner. We also shift the character’s X and Y by that value because now all drawing operations occur based on the center point of the sprite. We offset that by drawing inwards by that amount, and then we can flip along its X axis depending on whether we’re moving left or right.
Jumping and Gravity
- The last thing to illustrate just the basics of the character is the ability to jump, which is a very core part of Mario. Mario’s signature thing is jumping up and hitting blocks. And what’s cool is we already looked at this in Flappy Bird.
-
Consider the following code from
character-4/main.lua:JUMP_VELOCITY = -300 GRAVITY = 900 -- for jumping and applying gravity characterDY = 0 -- three animations jumpAnimation = Animation { frames = {3}, interval = 1 } function love.keypressed(key) -- if we hit space and are on the ground... if key == 'space' and characterDY == 0 then characterDY = JUMP_VELOCITY currentAnimation = jumpAnimation end end function love.update(dt) -- apply velocity to character Y characterDY = characterDY + GRAVITY * dt characterY = characterY + characterDY * dt -- if we've gone below the map limit, set DY to 0 if characterY > ((7 - 1) * TILE_SIZE) - CHARACTER_HEIGHT then characterY = ((7 - 1) * TILE_SIZE) - CHARACTER_HEIGHT characterDY = 0 end -- rest of update code... endNotice that if the key is equal to space and the
dyis zero, meaning that we aren’t already jumping, we check whether we’re already statically set on the ground. We don’t want to allow the character to just keep jumping and going up and up and up. We did that in Flappy Bird where you could just spam space bar. But in Mario, you only get to jump when you’re touching the ground. - We apply gravity, very similar to what we did with Flappy Bird. Then we have just a hard check for whether we’re at where the tiles begin on the map (about tile seven minus the character’s height) so that when its feet touch the top of that tile, it won’t go any deeper than that.
Tile Sets and Toppers
- Now we can begin to look at another key aspect of this distro, which is the level generation. We have a quite nice tile sheet that’s Creative Commons: An artist named Kenny did the original version of this, which then had a 16 by 16 version done by somebody else.
- It has a plethora of tiles. These are all tile sets. We will only use a couple of these throughout today’s lecture, in particular the empty and the full square version. But you can see there’s slopes and rounded versions and all sorts of fun things you can do with that.
- In addition to tiles, we also have toppers for them. These don’t really functionally mean anything in terms of gameplay, but they do offer a way to differentiate your levels visually. It allows us to get the sense that, okay, we’re not just in some random plane: Maybe we’re in a candy-themed place or this icy area or this foresty place. This is just drawing a texture on top of another texture, but it does offer a tremendous amount of visual variety that gives your game a lot of pop and makes it feel fun to play.
-
Consider the following code from
level-0/main.lua:-- number of tiles in each tile set TILE_SET_WIDTH = 5 TILE_SET_HEIGHT = 4 -- tile ID constants SKY = 5 GROUND = 3 -- divide quad tables into tile sets 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) -- random tile set and topper set for the level tileset = math.random(#tilesets) topperset = math.random(#toppersets)Notice that each tile set is a block of 5 by 4 tiles. Tile 3 in each set is our solid tile that we’re going to use for all generation, and tile 5 is just our empty tile: It’s transparent, no pixels being rendered. The toppers line up perfectly such that the topper would align with that tile.
-
Consider the draw function:
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) -- draw a topper on top of the tile if it contains the flag for it if tile.topper then love.graphics.draw(topperSheet, toppersets[topperset][tile.id], (x - 1) * TILE_SIZE, (y - 1) * TILE_SIZE) end end endNotice that when we get our tile, we do our usual draw: We index into the tile set at the ID. The tile stores both the ID and a boolean flag called
topper. If it’s true, then we should draw the topper; otherwise that means it’s going to be below somewhere, under the ground, under that first tile. We don’t want to draw toppers on those: It would look like a glitch. -
Consider the generate level function:
function generateLevel() local tiles = {} for y = 1, mapHeight do table.insert(tiles, {}) for x = 1, mapWidth do table.insert(tiles[y], { id = y < 7 and SKY or GROUND, topper = y == 7 and true or false }) end end return tiles endNotice that
topperis set equal to whether Y is equal to seven, meaning that’s the point at which we begin to draw solid tiles. We do this ternary expression in Lua: If Y is seven, it’ll be set to true, else it’ll be false.
Procedural Level Generation
- Now we can start to vary up the terrain. If we think about the first step being, instead of just completely flat levels, what if we had some of the terrain actually jetting up from the ground?
- If we imagine our level as this set of tiles that we place in a line going left to right, top to bottom, we can envision that maybe we want to say, “Okay, on this particular column, I decide that instead of starting the terrain at seven, we’ll start it at four.” That’s going to result in tile four getting to be solid and then all of the tiles below it being ground, and tile four there getting set to
topper = trueinstead of seven. Then that sticks up as a pillar. -
Consider the following code from
level-1/main.lua:function generateLevel() local tiles = {} -- create 2D array completely empty first for y = 1, mapHeight do table.insert(tiles, {}) for x = 1, mapWidth do table.insert(tiles[y], { id = SKY, topper = false }) end end -- iterate over X to generate the level in columns instead of rows for x = 1, mapWidth do -- random chance for a pillar 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 -- always generate ground for ground = 7, mapHeight do tiles[ground][x] = { id = GROUND, topper = (not spawnPillar and ground == 7) and true or false } end end return tiles endNotice that we create our entire level with sky tiles to start with: We completely populate the whole thing with sky. Then we’re going to start from the left, going all the way to the right of the screen. We create a one in five chance to spawn a pillar. If we do have that, then from rows 4 to 6, we create that as ground and set
topper = trueonly on row 4. We always fall back to the ground from row 7 down. - In the event that we did spawn a pillar, we don’t want to also set the topper on seven. But if we didn’t spawn a pillar, we do want to set it to true if we are at seven. The result: Pillars throughout our level space, which makes things varied.
- In Mario, one of the big perils is falling into chasms. A chasm is just essentially not drawing any tiles at all in that particular part of the level. Really, it’s as simple as just choosing not to do the whole bit of logic.
-
Consider the following code from
level-2/main.lua:for x = 1, mapWidth do -- random chance to not spawn anything on this column; a chasm if math.random(7) == 1 then goto continue end -- pillar and ground generation code... ::continue:: endNotice that we use the
goto continueway that Lua does continue. Ifmath.random(7)is equal to one, we just go to the continue label at the very bottom of the block below where we actually add tiles. If we just skip the tiles, they’ll remain pre-populated as sky. And so now we just get chasms for free by simply not generating anything. - The sky is the limit for what’s possible with procedural generation. As long as your logic is such that you never overly put the player into a position that’s unwinnable, you end up having a pretty robust system for creating infinite levels.
Platformer Collision
- We’ve been able to walk through our level and the levels generated just fine, but collision is a little bit weird. We’re not quite colliding with the level at all, except for the ground, and that’s essentially a hack: We’re hard-coding where we stop on the ground. We’re not actually performing any collision detection.
- There are benefits to being able to simplify the collision for something like this, given that there are so many tiles in the space of the game. It’s easy, actually, if we know that we’re in a tile map and all the tiles are static in their placement, we can essentially just determine at a given pixel what particular tile is there based on Mario’s position and determine, okay, that’s a ground tile, that is a collidable tile. We should shift Mario to be to the right or left of that tile versus having to do AABB for maybe every tile in the whole screen.
- There’s benefit to being able to just isolate your checks to wherever you’re moving, whether you’re walking, jumping: To just the immediately surrounded tiles based on the character’s position. We can get a lot of shortcuts and processing advantage by doing that. So we won’t be using AABB for tiles. We’ll be using a function called point to tile, which allows us to just say, okay, at this XY coordinate, divide by the map’s tile size, and then whatever tile is there at that index in our tile map, we can determine whether it’s collidable.
- When we test for collision in this new model:
- If we’re colliding with something that’s above our head (which is typically only when we’re jumping), we check whatever the tiles exist at the character’s top left coordinate and top right coordinate. We don’t want to just check the top left because if there’s a tile on the right and we’re only checking top left, we’d clip through it. We want to check both edges to ensure we don’t collide with whatever both edges are.
- Same thing for below us: If we’re falling down or walking, we want to check the bottom left and bottom right of our character.
- For moving left, we check top left and bottom left.
- For moving right, top right and bottom right.
-
Consider the point to tile function from
mario/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] endNotice that there’s a bounds check to ensure we’re within our map: We don’t want to index outside of our array of tiles. We divide Y by tile size, add one because everything is one-indexed in Lua. Same thing with our X. Then we can directly translate pixel space into tile space into our 2D array. You’re only ever checking, pretty much depending on your direction, two to four tiles at any one time, instead of maybe hundreds or thousands of tiles. It’s a very O(1) type of operation.
Entities and Entity States
- An entity is something that has an X, Y, does behavior, and interacts in your world. The benefit of having entities is that they can be generalizable in a sense that you can isolate certain types of underlying shared behavior. For example, every entity has an X and a Y. Every entity has a width and a height. Every entity has some collision detection capability with another entity, assuming that everything is axis-aligned.
- Then you can instantiate things that inherit from those behaviors and have more specific types of entities. For example, a player is an entity that we just happen to be able to steer. A snail is an entity that is going to be able to target the player with its own states.
- What’s cool is that we can also think of entities as containers for behaviors: We can visualize them the same way we visualize game states. We can envision entities as their own state machines that are engaged in a particular behavior. You can envision the diagram of Mario jumping and doing all these different things. That’s essentially what an entity can do.
-
Consider the
Entityclass frommario/src/Entity.lua:Entity = Class{} function Entity:init(def) -- position self.x = def.x self.y = def.y -- velocity self.dx = 0 self.dy = 0 -- dimensions self.width = def.width self.height = def.height self.texture = def.texture self.stateMachine = def.stateMachine self.direction = 'left' -- reference to tile map so we can check collisions self.map = def.map -- reference to level for tests against other entities + objects self.level = def.level end function Entity:changeState(state, params) self.stateMachine:change(state, params) endNotice that the entity is just a wrapper class. It takes in a definition and has a position and velocity. Most any entity we’ve ever interacted with has an X and a Y and a velocity and a width and a height. They’ll all have a texture. They’ll all have a state machine: A set of states. A direction. A reference to the map so they can see other aspects of the map and the tiles they want to interact with. Then the level itself, which contains all the game objects, entities, and the tile map.
- The same state machine class that we use for game states, we use for entity states, except that now instead of having game state classes, we implement update, render, enter, exit for every entity we want to model.
-
Consider the
PlayerIdleStatefrommario/src/states/entity/PlayerIdleState.lua:PlayerIdleState = Class{__includes = BaseState} function PlayerIdleState:init(player) self.player = player self.animation = Animation { frames = {1}, interval = 1 } self.player.currentAnimation = self.animation end function PlayerIdleState:update(dt) if love.keyboard.isDown('left') or love.keyboard.isDown('right') then self.player:changeState('walking') end if love.keyboard.wasPressed('space') then self.player:changeState('jump') end endNotice that if we press left or right, we’ll go into walking, and if we press space, we’ll jump. The idle state encapsulates all the behavior of what it means to be idle.
Game Objects
- Game objects are interactive items that you can collide with or hit and that will do something for you, but they’re not entities. They don’t have states. They don’t move around as agents.
- Game objects are perfect for things that are relatively static but should have some behavior that triggers when you interact with them or touch them. In this case, the gems are game objects, as are the blocks when you jump up and hit them, because those have behavior. That behavior being that when you hit the block, a gem has a chance to spawn up and then be claimable.
- We’ve differentiated between entities and game objects. Some engines will use game object to be literally anything, including an entity. Some will separate them. Some will use what’s called an Entity Component System where literally you just have everything as an entity, and then it contains its behavior not through inheriting from some base class but rather just having some component within it that determines its behavior.
-
Consider the
GameObjectclass frommario/src/GameObject.lua:GameObject = Class{} function GameObject:init(def) self.x = def.x self.y = def.y self.texture = def.texture self.width = def.width self.height = def.height self.frame = def.frame self.solid = def.solid self.collidable = def.collidable self.consumable = def.consumable self.onCollide = def.onCollide self.onConsume = def.onConsume self.hit = def.hit end function GameObject:collides(target) return not (target.x > self.x + self.width or self.x > target.x + target.width or target.y > self.y + self.height or self.y > target.y + target.height) endNotice that a game object expects a set of things that will trigger depending on how it’s interacted with. It has
onCollideoronConsumecallbacks that trigger when your entity that is flagged to be capable of consuming those objects indeed does consume them. -
Consider how jump blocks are created in
mario/src/LevelMaker.lua:-- jump block GameObject { texture = 'jump-blocks', x = (x - 1) * TILE_SIZE, y = (blockHeight - 1) * TILE_SIZE, width = 16, height = 16, frame = math.random(#JUMP_BLOCKS), collidable = true, hit = false, solid = true, -- collision function takes itself onCollide = function(obj) -- spawn a gem if we haven't already hit the block if not obj.hit then -- chance to spawn gem, not guaranteed if math.random(5) == 1 then local gem = GameObject { texture = 'gems', x = (x - 1) * TILE_SIZE, y = (blockHeight - 1) * TILE_SIZE - 4, width = 16, height = 16, frame = math.random(#GEMS), collidable = true, consumable = true, solid = false, -- gem has its own function to add to the player's score onConsume = function(player, object) gSounds['pickup']:play() player.score = player.score + 100 end } -- make the gem move up from the block Timer.tween(0.1, { [gem] = {y = (blockHeight - 2) * TILE_SIZE} }) gSounds['powerup-reveal']:play() table.insert(objects, gem) end obj.hit = true end gSounds['empty-block']:play() end }Notice that
collidable = truemeans it will perform collision detection. It has ahitflag that’s false: If it’s been hit already, we don’t want it to generate a gem.solid = truemeans it actually has collision detection and pushes us outside of it. You can be collidable and technically not be solid: The collision will trigger, but you won’t get pushed outside of it. - The
onCollidefunction triggers when we jump and hit the block. If the object has not been hit, we have a one in five chance to spawn a gem. The gem itself is consumable and has anonConsumefunction that plays a sound and increases the score by 100. We useTimer.tweento make the gem move up from the block with smooth interpolated Y movement.
Enemies and AI
- The snail is an entity that gets spawned at level maker creation time based on a random chance. It has its own states: Idle, moving, and chasing.
-
Consider the
SnailIdleStatefrommario/src/states/entity/snail/SnailIdleState.lua:SnailIdleState = Class{__includes = BaseState} function SnailIdleState:init(tilemap, player, snail) self.tilemap = tilemap self.player = player self.snail = snail self.waitTimer = 0 self.animation = Animation { frames = {51}, interval = 1 } self.snail.currentAnimation = self.animation end function SnailIdleState:update(dt) if self.waitTimer < self.waitPeriod then self.waitTimer = self.waitTimer + dt else self.snail:changeState('moving') end -- calculate difference between snail and player on X axis -- and only chase if <= 5 tiles local diffX = math.abs(self.player.x - self.snail.x) if diffX < 5 * TILE_SIZE then self.snail:changeState('chasing') end endNotice that in the idle state, the snail has its own animation: Just a single frame of it being in the shell. Its behavior is such that it has a wait timer or wait period where it will decide, after that wait period, to change its state to either moving or chasing. Chasing is if the difference between the player’s X and the snail’s X is five tiles or less.
-
Consider the
SnailChasingState:function SnailChasingState:update(dt) self.snail.currentAnimation:update(dt) local diffX = math.abs(self.player.x - self.snail.x) if diffX > 5 * TILE_SIZE then self.snail:changeState('moving') elseif self.player.x < self.snail.x then self.snail.direction = 'left' self.snail.x = self.snail.x - SNAIL_MOVE_SPEED * dt -- check for tiles to avoid walking off edges or into walls else self.snail.direction = 'right' self.snail.x = self.snail.x + SNAIL_MOVE_SPEED * dt end endNotice that the chasing state just always looks to see: Is the difference between our X and the player’s X five tiles? If so, keep moving in that direction. Otherwise, change to just moving: The moving state essentially just chooses a random destination.
- The states take references to the player. If you have any operation that you need to take place within your world between your entities or the player, often the most expeditious thing to do is to just pass in a reference to the player or the map. Sometimes the cleanest, easiest way to get things to talk to each other is to have them passed as references and held and shared between them.
- This is essentially how you can get some interesting fake lifelike behavior or some interesting interaction in your game without going too crazy.
Summing Up
In this lesson, you learned how to create a virtual world using tile maps and build a Mario-style platformer. Specifically, you learned…
- How to use tile maps to represent a 2D world as a grid of tile IDs.
- How to implement camera scrolling using
love.graphics.translate. - How to load and display character sprites from a sprite sheet.
- How to implement character movement with camera tracking.
- How to create an Animation class that cycles through frames over time.
- How to flip sprites by scaling with a centered origin point.
- How to implement jumping and gravity for platformer physics.
- How to use tile sets and toppers for visual variety.
- How to procedurally generate levels with pillars and chasms.
- How point-to-tile collision provides O(1) collision detection.
- How entities and entity state machines encapsulate behavior.
- How game objects use callbacks for interactive behavior.
- How to implement basic enemy AI with idle, moving, and chasing states.
See you next time!