Lecture 5
- Welcome!
- Top-Down Perspective
- Entity Definitions and Data-Driven Design
- Room Generation
- Dungeon Generation
- Hitboxes and Hurtboxes
- Enemies and Damage
- Game Objects and Triggers
- Screen Scrolling
- Stenciling
- The Dungeon Generator
- Summing Up
Welcome!
- Hello, world. This is CS50 2D Lecture 5. Today, we’re going to be looking at The Legend of Zelda, which is a favorite franchise of mine. It’s like Mario. I like Mario quite a bit as well. But this was one of the first series that I have memory playing, and also the original cartridge for the NES was actually colored gold. So was its sequel, which I thought was a cool thing.
- This is what the original Zelda looks like, and “It’s dangerous to go alone! Take this.” is a famous line that people quote online all the time. It gets referenced in various games. It’s a throwback. This is the very first thing you see when you go into the game: You start off, you see this cave, you go in, you grab a sword. It, at its time, was one of the only games that had this early idea of an open world that you could go out and explore. It accomplished this via having a bunch of discrete separate screens that you could transition in and across.
- This is a screenshot of a dungeon. You would also go and navigate these labyrinth-type structures. In today’s code base, we’ll be primarily focused on this view of what Zelda looks like, looking at a dungeon that gets generated, and then we can traverse it. We won’t have all the bells and whistles per se, but we’ll have the famous scrolling and some monsters and the ability to swing your sword and so on and so forth.
- This was the first Zelda that I played as a kid, Link to the Past for the Super Nintendo, which took the formula and expanded on it, added two different worlds and a bunch of other cool features to it. A lot of different items, which is part of the problem set, the boomerang, which is actually shown at the top of the screen, signified that you can throw this boomerang that will freeze enemies and so on.
- Some of the things that we’ll be taking a look at today:
- Top-down perspective - So far, when we’ve looked at 2D games, we’ve mostly looked at the side view. Games like Mario are called side scrolling platformers. But in a game like Zelda, you’re actually taking more of a bird’s eye view and looking from the top.
- Dungeon generation - How to actually create a dungeon in code.
- Hitboxes and hurtboxes - The difference between them: A hitbox being when you get hit by something and a hurtbox being, for example, when you swing a sword, there needs to be a way to differentiate your sword from your character’s body.
- Events - A way to broadcast and listen for game events.
- Screen scrolling - The classic 2D Zelda aesthetic where you move to the edge of the screen and then the screen pans.
- Stenciling - A way to mask pixels before they’re actually drawn to the screen to achieve certain graphical effects.
- Data-driven design - How to start representing all of the entities in our code without having to define them in code as much as have them in a separate file, something that a designer might be able to take on separately from a dedicated programmer.
- Playing the game, as you can see, we’ve got a title screen. You press Enter. Now, we’ve got a dungeon layout here. Some enemies. If you press space bar, you’ll actually have a sword you can swing to deal some damage. You can also take damage from enemies. I lost half a heart. You’ve got hearts at the top. You’ll notice there’s a set of doorways. They’re all closed currently, but if one were to take notice of the switch there on the ground, they all open up. Then we get the classic Zelda transition.
- There’s even a utility built in. If you press M, you’ll actually be able to see the map laid out there. Red being the current position, green being other rooms. And as we saw, there were a lot of pieces in there that probably seem a little bit familiar, but also a little bit different. We do have the classic screen scrolling. We haven’t quite looked at anything exactly like that. But if we think back to previous lectures, we can imagine that it does look like an interpolation; therefore, a tween might be occurring. We do have enemies that are strikeable with the hurtbox that we talked about, but they’re also moving around in a similar way to the snails in Mario. All of this can take place again through AABB collision detection.
Top-Down Perspective
- A top-down perspective often in games entails different things depending on the graphical style you’re going for. You can have isometric, and then this is more orthographic style. But as you can see, we have corner pieces and walls and things of a particular highlight color up here, for example, a different color than is down here, which simulates this idea of lighting and perspective.
- This is different from a side scrolling game in that you can actually move across the X and the Y axis. In a platformer, your Y axis is just a jump determined by gravity. In this game, you actually have full movement across the X and the Y axis. We’ve got all these different tiles across the board that are being drawn, similarly to how they were being drawn with Mario, except now they’re actually the floor upon which we’re looking from the top.
- Now, we’re going to fixate primarily on just the character for right now. As you can see, there’s a myriad of frames in this texture here that illustrate the different positions that he can walk in and be in. You can see even here some that we’re not using yet, which are like picking up an object, a heavy object, as noted by the strained expression on the character’s face. This is part of the problem set: To implement pots that the player can lift up and carry above their head and then throw as a projectile.
- We’ll be looking at the character walking: Walking to the right, walking up, and walking to the left. In theory, you could just flip these like we looked at last week with the character. But this sprite sheet actually gives it to us there in the sprite, so we’ll just use it as is. We’ll chop up the texture atlas, the sprite sheet, and just use the frames directly.
- Then you can see here a different sprite sheet where we actually have the character wielding the sword. And because the character uses a sword and it extends his sprite, the character’s sprites here are slightly larger. These are actually 32 by 32 blocks instead of what these are, 16 by 16 blocks. This involves us figuring out, okay, what should the hitbox be? What should the hurtbox be for the character? How should we offset that from the character?
-
Consider from
zelda/src/states/entity/player/PlayerIdleState.lua:PlayerIdleState = Class{__includes = EntityIdleState} function PlayerIdleState:enter(params) -- render offset for spaced character sprite (negated in render function of state) self.entity.offsetY = 5 self.entity.offsetX = 0 end function PlayerIdleState:update(dt) if love.keyboard.isDown('left') or love.keyboard.isDown('right') or love.keyboard.isDown('up') or love.keyboard.isDown('down') then self.entity:changeState('walk') end if love.keyboard.wasPressed('space') then self.entity:changeState('swing-sword') end endNotice that we now have four directions to handle. The idle state allows the player to transition to walking in any direction or to the swing sword state. This is fundamentally different from Mario where we only had left and right.
Entity Definitions and Data-Driven Design
- A concept we’re introducing in this code base is data-driven design. The idea is that we can define as much behavior, as many characteristics as possible of whatever it is that we’re trying to create, have a function that imports them, parses them, actually creates the thing, and then allow our designers and programmers to work on separate parts of the problem.
- The programmers can focus on implementing functionality. The designers can maybe focus on the sprite sheet and the animations. Maybe if we expose a high enough level API for how to define behavior within our game, we can define a DSL, a domain-specific language, for our entities or for whatever we want to model in our world. We can allow our designers to become ever more powerful without having to get into the weeds with programming.
-
Consider from
zelda/src/entity_defs.lua:ENTITY_DEFS = { ['player'] = { walkSpeed = PLAYER_WALK_SPEED, animations = { ['walk-left'] = { frames = {13, 14, 15, 16}, interval = 0.155, texture = 'character-walk' }, ['walk-right'] = { frames = {5, 6, 7, 8}, interval = 0.15, texture = 'character-walk' }, ['walk-down'] = { frames = {1, 2, 3, 4}, interval = 0.15, texture = 'character-walk' }, ['walk-up'] = { frames = {9, 10, 11, 12}, interval = 0.15, texture = 'character-walk' }, ['idle-left'] = { frames = {13}, texture = 'character-walk' }, -- additional animations... } }, ['skeleton'] = { texture = 'entities', animations = { ['walk-left'] = { frames = {22, 23, 24, 23}, interval = 0.2 }, -- additional animations... } } }Notice that we declare this as a constant global variable with this uppercase, underscore syntax, this constant notation for an object that’s global and constant. We’re not going to change it. It’s just static data that we can reference later. If we define a key
playerwhich then has all of the attributes that we care about at the time of initializing the player, you can see we have an animations key, which has walk left, walk right, walk down, walk up. They all have these frames that have been defined within there, an interval for how fast the animation should occur. We can allow people to manipulate these pieces of information without having to involve any code whatsoever. - It’s quite bulky if you were to scroll through all of this: There’s just a lot of information, a lot of sprites, a lot of different characters and creatures that we can add to our game. But it’s a relatively simple, if not mundane and tedious process that somebody could just defer to a designer to do.
-
This
createAnimationsfunction on Entity takes a definition and essentially is the key to getting all entities to be able to load texture and a set of defined animations that then define how they get drawn without us having to do it manually in the code:function Entity:createAnimations(animations) local animationsReturned = {} for k, animationDef in pairs(animations) do animationsReturned[k] = Animation { texture = animationDef.texture or 'entities', frames = animationDef.frames, interval = animationDef.interval } end return animationsReturned endNotice that we iterate through the animations definition and create Animation objects for each one. This is how we can define our animations now without having to really define it in code. We do all the heavy lifting programmatically.
Room Generation
- In this particular example today, none of these tiles actually have collision. We know for sure all of the tiles in our game are going to be in this exact orientation, similar to the original Zelda by design. Folks could definitely implement a version where the tiles did have collision. But since we know all the rooms are going to be the same size and offset the same way, we don’t have to actually worry about tile-based collision. We can check instead for boundaries. In that sense, today’s examples are going to be simpler, but there are going to be some other aspects we need to take a look at.
- The first thing we’ll take a look at is just the size of the tile set. It’s not uncommon that tile sets in games like this get as complicated as this, if not much more complicated. We have a lot that are actually functionally different. We’re only going to be using a subset of them. But as you can see, there is a general symmetry to the way that things are laid out, a general homogeny to the size of things.
- I find it really easy, in particular when we get into big tile sheets like this, to have some way to easily tell which number of the tile is that we care about on the fly. For example, I want to know that number 4 here is the top left corner of the dungeon. I want to know that 24 is the bottom right corner. But it’s a pain to go through and hand count each individual one. Recall, it just gets added in this linear way, left to right, top to bottom.
- There’s included in the distro a simple Python program that allows you to put in the name of the PNG that you care about splicing up. It uses a library called Pillow. You could also do this in code in LÖVE 2D if you wanted to just load the texture, render it, and then draw the text overlaid.
-
Consider the Room class from
zelda/src/world/Room.lua:function Room:init(player, x, y) self.width = MAP_WIDTH self.height = MAP_HEIGHT self.x = x or 1 self.y = y or 1 self.tiles = {} self:generateWallsAndFloors() self.entities = {} self:generateEntities() self.objects = {} self:generateObjects() self.player = player endNotice that the Room has a width and height, an X and Y position in the dungeon grid, and generates walls and floors, entities, and objects.
-
The
generateWallsAndFloorsfunction iterates through the entire map, Y to X, a 2D crawl, and does comparisons. It says, okay, I want to fill this tile map with the IDs that map up to the wall tiles at the top, the side wall tiles at the sides, and the bottom wall tiles at the bottom. Then we randomize: There’s three to four variants for the walls that we can choose from, and a higher number for the center:function Room:generateWallsAndFloors() for y = 1, self.height do table.insert(self.tiles, {}) for x = 1, self.width do local id = TILE_EMPTY if x == 1 and y == 1 then id = TILE_TOP_LEFT_CORNER elseif x == 1 and y == self.height then id = TILE_BOTTOM_LEFT_CORNER elseif x == self.width and y == 1 then id = TILE_TOP_RIGHT_CORNER elseif x == self.width and y == self.height then id = TILE_BOTTOM_RIGHT_CORNER elseif x == 1 then id = TILE_LEFT_WALLS[math.random(#TILE_LEFT_WALLS)] elseif x == self.width then id = TILE_RIGHT_WALLS[math.random(#TILE_RIGHT_WALLS)] elseif y == 1 then id = TILE_TOP_WALLS[math.random(#TILE_TOP_WALLS)] elseif y == self.height then id = TILE_BOTTOM_WALLS[math.random(#TILE_BOTTOM_WALLS)] else id = TILE_FLOORS[math.random(#TILE_FLOORS)] end table.insert(self.tiles[y], { id = id }) end end endNotice that randomizing this subtable of tiles you have access to (those being your walls of left and right, top and bottom) effects a better variety, better aesthetic sense for your game. If you were to just draw the same tile, it would look quite homogenous in a way that feels very unpolished.
Dungeon Generation
- One of Zelda’s more famous mechanics or themes is the idea of going through various dungeons. This is a screenshot of one of the dungeons from the original Legend of Zelda. As you can see, it’s got quite an interesting connection, it looks like an eagle or a magic lamp. You usually start at the very bottom center. Then the idea is you move up throughout the labyrinth, and it can go in a bunch of different directions.
- Our dungeon generator that we look at will look very similar to this. As you can see, there are doorways linking each individual chamber, sometimes not always. In an actual game, a lot of the time, the doors will actually be locked and you’ll have to get keys, and the keys are placed strategically such that you have to solve puzzles to find what they are. There’s hidden chambers. There’s walls that you could bomb and open up. We won’t do a lot of those more complicated things. We’ll just be looking at a simple generator which creates a simple dungeon.
- What’s cool is that this idea is even used in a lot of other games, more modern games, for example, The Binding of Isaac, which is a roguelite where it behaves very similarly to Zelda, but instead of having manually created dungeons, it actually creates dungeons randomly and then places various items throughout the dungeon.
-
The Dungeon class manages the current room, handles transitions between rooms, and keeps track of the camera position during those transitions. Consider from
zelda/src/world/Dungeon.lua:function Dungeon:init(player, rooms, startX, startY) self.player = player self.rooms = rooms or {} self.currentRoom = rooms[startY][startX] self.nextRoom = nil self.cameraX = 0 self.cameraY = 0 self.shifting = false endNotice that there’s a current room that we set, which is going to get rendered and updated, and it takes the player as a reference so that it can render the player thereby. The next room is going to be important because the current room is where the player is currently rendering and entities are doing things. But if we’re transitioning, there needs to be a next room.
Hitboxes and Hurtboxes
- So far, hitboxes have been the AABB collision detection rectangles that allow us to do the classic algorithm, the classic comparison that tests whether two things are colliding with one another. So far, it’s been somewhat of an all-or-nothing type calculation. But there’s a difference between a hitbox and a hurtbox, and folks might use different names to refer to these. They’re essentially just rectangles that have different semantic meaning.
- In the case of a hitbox, it’s essentially anything that can be struck by a hurtbox or potentially can be collided with another hitbox. All the examples we’ve used so far have used a hitbox and hurtbox as one and the same. But there are many instances, most games, where they’re actually separate.
- In Street Fighter, for example, as your character is moving and doing a kick, this part of their body should be open to attack. But if they should collide this part of their rectangle, the hurtbox, with a green rectangle (the hitbox) of their opponent, then you actually get a hit on them and you can start to do a combo.
- In Zelda, enemies are all thought of as almost having a hitbox or hurtbox that’s always active, it being just their physical body. Generally, if you touch an enemy with your hitbox, it will still cause damage to you, therefore implying that they are like one in not only a hitbox but also a hurtbox. But Zelda has the character’s ability to swing a sword and therefore have a separate hitbox from a hurtbox.
- People spend a great deal of time focused on identifying the duration of time that hitboxes and hurtboxes are open, as well as their span, their size, their frequency, their number. There’s a lot of details that go into making a game more or less balanced. If they’re out for too long, then that character is perceived as being broken and overpowered because the character has to do less to be able to inflict more damage. If it’s too small, then that character is perceived as being weak and not very fun to play.
-
Consider from
zelda/src/states/entity/player/PlayerSwingSwordState.lua:function PlayerSwingSwordState:init(player, dungeon) self.player = player self.dungeon = dungeon -- create hitbox based on where the player is and facing local direction = self.player.direction local hitboxX, hitboxY, hitboxWidth, hitboxHeight if direction == 'left' then hitboxWidth = 8 hitboxHeight = 16 hitboxX = self.player.x - hitboxWidth hitboxY = self.player.y + 2 elseif direction == 'right' then hitboxWidth = 8 hitboxHeight = 16 hitboxX = self.player.x + self.player.width hitboxY = self.player.y + 2 elseif direction == 'up' then hitboxWidth = 16 hitboxHeight = 8 hitboxX = self.player.x hitboxY = self.player.y - hitboxHeight else hitboxWidth = 16 hitboxHeight = 8 hitboxX = self.player.x hitboxY = self.player.y + self.player.height end self.swordHitbox = Hitbox(hitboxX, hitboxY, hitboxWidth, hitboxHeight) endNotice that depending on the direction, we set the width and the height differently because if we’re looking to the left or the right, it’s more of an elongated vertical rectangle. But if we’re looking top or bottom, it’s more of a horizontal rectangle. We take the player and the dungeon, the reason being that we want to be able to compare this hurtbox of the sword with all of the entities in the dungeon.
Enemies and Damage
- Now that we do have the ability to use the sword, we want to start adding the next step. The next logical step would be adding entities, things like the skeleton, spiders, and so on, to our actual rooms in the dungeon that will allow us to test that this works properly.
- The actual sprite sheet has various different creatures blocked off into these groups of three by four rows and columns. It’s got different groups of entities. To keep track of which ID maps to which (slime looking downwards, looking left, looking right, looking backwards) we probably want a way to easily tell. Similarly to the tile set, I’ve gone ahead and preset all of these using the same Python program with an ID. As you can see, we have the different blocks that they’re all grouped up in.
-
Consider from
zelda/src/world/Room.lua:function Room:generateEntities() local types = {'skeleton', 'slime', 'bat', 'ghost', 'spider'} for i = 1, 10 do local type = types[math.random(#types)] table.insert(self.entities, Entity { animations = ENTITY_DEFS[type].animations, walkSpeed = ENTITY_DEFS[type].walkSpeed or 20, x = math.random(MAP_RENDER_OFFSET_X + TILE_SIZE, VIRTUAL_WIDTH - TILE_SIZE * 2 - 16), y = math.random(MAP_RENDER_OFFSET_Y + TILE_SIZE, VIRTUAL_HEIGHT - (VIRTUAL_HEIGHT - MAP_HEIGHT * TILE_SIZE) + MAP_RENDER_OFFSET_Y - TILE_SIZE - 16), width = 16, height = 16, health = 1 }) end endNotice that we’ve got skeleton, slime, bat, ghost, and spider, all of the creatures that were in the sprite sheet. We’re going to generate 10, and then we can grab a random type, and then we can grab the animations and walk speed from a config or data file using
ENTITY_DEFS. That’s all managed with these keys. We give them a random X and Y guaranteed to be within the bounds of the room, a width and height of 16, and a health of one so that when we swing the sword, we can deal one unit of damage. -
We also need collision detection between the player and entities:
-- collision between the player and entities in the room if not entity.dead and self.player:collides(entity) and not self.player.invulnerable then gSounds['hit-player']:play() self.player:damage(1) self.player:goInvulnerable(1.5) if self.player.health == 0 then gStateMachine:change('game-over') end endNotice that if we’re not invulnerable, we take damage and then go invulnerable for 1.5 seconds. During that span of time, we essentially set a flag and the inability to be hit. If health reaches zero, we go to game over.
- The invulnerability mechanic is deliberate design such that a character can’t just be cornered by several monsters at once and then just go from three hearts to zero in the blink of an eye. We typically want to give the player some buffer to allow them the chance to escape or to defeat the monsters that are cornering them. That’s why we have this invulnerability mechanic, which is simply phasing in and out and then ensuring they can’t take hits during that span of time.
Game Objects and Triggers
- A big part of Zelda is besides just being in a room using a sword to attack enemies, it’s also getting through the dungeon, getting to the end of the dungeon. So far, we’ve been in a closed room, but now we’ll take a look at doorways in our dungeon.
- Recall in the example that we did at the start of the lecture, they all start closed, which is common in Zelda. You usually go into a room, you usually have to defeat a certain number of enemies. We’re not taking that approach, although you certainly could, you could just do a loop over all the entities in the room, if it’s equal to zero, then open the doorways. Today, we’re going to just look at using triggers, which is similar to what we did last time when we looked at game objects.
- We’re going to define some game object, a trigger, a switch, and then on collision with it, it’s going to have its own
onCollidefunction that will trigger the opening of the room’s doorways. -
Consider from
zelda/src/game_objects.lua:GAME_OBJECT_DEFS = { ['switch'] = { type = 'switch', texture = 'switches', frame = 2, width = 16, height = 16, solid = false, defaultState = 'unpressed', states = { ['unpressed'] = { frame = 2 }, ['pressed'] = { frame = 1 } } } }Notice that again, with this theme of more data-driven design, we’re going to try to keep as many of the pieces of information that tie to the switch inside this config, this definition file as much as possible. It’s got a type, a texture. You can put whatever data in here you want. As long as your code references these flags and these get preserved, then the sky’s the limit.
-
The switch is created in the room with an
onCollidecallback:function Room:generateObjects() local switch = GameObject( GAME_OBJECT_DEFS['switch'], math.random(MAP_RENDER_OFFSET_X + TILE_SIZE, VIRTUAL_WIDTH - TILE_SIZE * 2 - 16), math.random(MAP_RENDER_OFFSET_Y + TILE_SIZE, VIRTUAL_HEIGHT - (VIRTUAL_HEIGHT - MAP_HEIGHT * TILE_SIZE) + MAP_RENDER_OFFSET_Y - TILE_SIZE - 16) ) switch.onCollide = function() if switch.state == 'unpressed' then switch.state = 'pressed' -- open every door in the room if we press the switch for k, doorway in pairs(self.doorways) do doorway.open = true end gSounds['door']:play() end end table.insert(self.objects, switch) endNotice that as soon as our character collides with it, it triggers that state, it triggers the sound effect, and then all the room’s doorways which it has a reference to will get opened for us.
Screen Scrolling
- The characteristic Zelda scroll between screens is a very common mechanic that when people see it, they immediately think, “That’s a Zelda game.” When you move your character in the dungeon and you go through a doorway, the movement is automatic, similar to how the original Zelda worked. Most 2D Zeldas, when you transition screens, your character will move automatically towards the next screen and get placed at a particular destination.
- It is ultimately a little bit of illusion involved here because we have to essentially keep whatever our current room is, and then there’s usually never a next room loaded until we choose a doorway that we then move towards. Then at that point, both of them being loaded, we then perform a transition, a camera transition from one to the other. Then we instantly flip our position right back to zero.
- We have a pointer to a room that can be null, and then a pointer to the current room. We need to essentially swap pointers such that the next room points to nothing so that it can be set to point to something when we do whatever the next doorway’s transition is. We get the next room loaded, we move towards it. It’ll be off camera, but we’re going to move the camera position to it, and then we’re going to essentially set everything hard to zero.
- To accomplish this, we’re going to use events. When we touch a doorway, we’re going to want this actual event to fire, which is, “It’s time to shift down, left, right, top,” in whatever direction that is, and then have the camera essentially tween itself up to the next room.
- We’ll be using
Event.onandEvent.dispatchfrom the knife library.Event.onis the way that we say, “When we hear this string, call this callback function.” This is where we essentially set up events that are capable of being listened for.Event.dispatchis our way of broadcasting, emitting this event. Should it be being listened for, there will be some function that gets called. -
Consider from
zelda/src/world/Dungeon.lua:Event.on('shift-left', function() self:beginShifting(-VIRTUAL_WIDTH, 0) end) Event.on('shift-right', function() self:beginShifting(VIRTUAL_WIDTH, 0) end) Event.on('shift-up', function() self:beginShifting(0, -VIRTUAL_HEIGHT) end) Event.on('shift-down', function() self:beginShifting(0, VIRTUAL_HEIGHT) end)Notice that we have four event handlers using anonymous functions. We have this function, this deferred call to
self:beginShiftingat negative virtual width, zero, or virtual width, zero, or zero negative virtual height. These are full screen offsets because these model the transition between this room and then a full screen’s width or height away to a different room. -
The
beginShiftingfunction handles the actual transition:function Dungeon:beginShifting(shiftX, shiftY) self.shifting = true local nextRoomX = self.currentRoom.x + (shiftX > 0 and 1 or (shiftX < 0 and -1 or 0)) local nextRoomY = self.currentRoom.y + (shiftY > 0 and 1 or (shiftY < 0 and -1 or 0)) self.nextRoom = self.rooms[nextRoomY] and self.rooms[nextRoomY][nextRoomX] -- start all doors in next room as open until we get in for k, doorway in pairs(self.nextRoom.doorways) do doorway.open = true end -- tween the camera and player position Timer.tween(1, { [self] = {cameraX = shiftX, cameraY = shiftY}, [self.player] = {x = playerX, y = playerY} }):finish(function() self:finishShifting() end) endNotice that we’re going to tween over one second. You could make this however long you want, two seconds or faster, 0.5. That’s going to ultimately affect the speed at which the transition takes place. The player gets their X and Y set to whatever destination we calculated, and the camera X and camera Y are also going to tween towards whatever the shift X and shift Y are.
-
The
finishShiftingfunction is the illusion part:function Dungeon:finishShifting() self.cameraX = 0 self.cameraY = 0 self.shifting = false self.currentRoom = self.nextRoom self.nextRoom = nil self.currentRoom.adjacentOffsetX = 0 self.currentRoom.adjacentOffsetY = 0 endNotice that it just sets everything to zero in the current room, sets the current room to the next room, and then sets the next room to nil. We’ve tricked the player into thinking they’ve moved some distance into the dungeon when in reality, they’ve gone to that room, and then that room gets rendered and set at zero, zero.
Stenciling
- There’s a visual problem when the player walks through a doorway: The character clips through the wall, which is not ideal behavior. We want to fix that. The last phase that we can look at in our implementation of Zelda is the stencil update, which allows us to accomplish this.
- We’re effectively accomplishing this with a stencil, what’s called the stencil buffer or masking, which is where we’re essentially setting some pixels behind the scenes to be this invisible mask that prevents pixels from being drawn should we do a certain set of calculations with that stencil buffer.
- You can think of the stencil buffer as this non-transparent material through which you can look through and then draw within a particular bound: A circle, a heart, a rectangle. But if you were to try to draw on this white, assuming this is some non-permissible material, you would not actually draw pixels. That’s effectively what we’re doing, except they’re just invisible to us.
- You essentially, at the time that you’re going to try to process pixels by the pixel shader or the fragment shader (which is a stage of the GPU’s pipeline where it calculates color value) there’s a step after that before it actually renders it to the screen where this thing called the stencil buffer gets tested, and you can test it in various different ways. You can test for equality, for less than, classic boolean type operations, and you can perform draw operations onto it as well to affect those values.
-
Consider from
zelda/src/world/Room.lua(LÖVE 11 version):-- stencil out the door arches so it looks like the player is going through love.graphics.stencil(function() -- left doorway love.graphics.rectangle('fill', -TILE_SIZE - 6, MAP_RENDER_OFFSET_Y + (MAP_HEIGHT / 2) * TILE_SIZE - TILE_SIZE, TILE_SIZE * 2 + 6, TILE_SIZE * 2) -- right doorway love.graphics.rectangle('fill', MAP_RENDER_OFFSET_X + (MAP_WIDTH * TILE_SIZE), MAP_RENDER_OFFSET_Y + (MAP_HEIGHT / 2) * TILE_SIZE - TILE_SIZE, TILE_SIZE * 2 + 6, TILE_SIZE * 2) -- top doorway love.graphics.rectangle('fill', MAP_RENDER_OFFSET_X + (MAP_WIDTH / 2) * TILE_SIZE - TILE_SIZE, -TILE_SIZE - 6, TILE_SIZE * 2, TILE_SIZE * 2 + 12) -- bottom doorway love.graphics.rectangle('fill', MAP_RENDER_OFFSET_X + (MAP_WIDTH / 2) * TILE_SIZE - TILE_SIZE, VIRTUAL_HEIGHT - TILE_SIZE - 6, TILE_SIZE * 2, TILE_SIZE * 2 + 12) end, 'replace', 1) love.graphics.setStencilTest('less', 1) if self.player then self.player:render() end love.graphics.setStencilTest()Notice that LÖVE 11 takes in a function, and then
'replace'is what’s going to happen to the stencil buffer, and1is what it’s going to replace it with. These four rectangles are going to be at the doorways. Then when we set the stencil test to less than one, anything that passes less than one, when that character is being compared against the stencil buffer, will go through. Because we replaced all the rectangles with one in the stencil buffer at the doorways, it’s going to fail the test. Therefore, when it tries to draw the player and the player’s pixels are on top of those parts of the stencil buffer that are set to one, that failure means it will skip allowing those pixels through to the final result, making it look as if the player is going underneath that dark archway. -
With LÖVE 12, this is also an interesting time to talk about LÖVE 11 versus LÖVE 12. They have two different ways of doing this. LÖVE 12’s stencil changes are not backwards compatible with LÖVE 11:
-- LÖVE 12 version love.graphics.setColorMask(false, false, false, false) love.graphics.setStencilState('replace', 'always', 1) -- draw stencil rectangles... love.graphics.setColorMask(true, true, true, true) love.graphics.setStencilState('keep', 'less', 1) -- render player... love.graphics.setStencilState()Notice that in LÖVE 12, there is no longer a function that you pass. You have a
setStencilState, which is a bit more of a lower-level version. You have to usesetColorMaskbecause now when you draw things to the stencil buffer, you need to actually make sure that you’re not also drawing to the screen during that span of time. - As of the recording of this lecture, LÖVE 12 is still in development.
The Dungeon Generator
- We haven’t really looked at the dungeon generator too much, but that’s something I also wanted to spend a couple of minutes just looking at. The purpose of the dungeon demo is just to provide a visual way for us to see how the dungeon generator actually works.
- We have a 10 by 10 grid, and we’re using a queue in order to do this. There are many different ways you can generate mazes and dungeons with stacks and queues. We’re using a queue because it gives us some nice properties that align well with the aesthetic of Zelda. The head is going to be set to the index that we’re always going to be looking at. Our queue is essentially just an array of these coordinates that we’re going to look at that get randomly decided.
- If I press Enter, we’ll see the very first room is going to get placed at the bottom center, which is at 5, 10. There’s a red cursor that illustrates that that’s where we’re currently looking. There’s a visited array table at the bottom right, which is just going to be a way for us to confirm that we have already generated rooms at a particular index so that two rooms that try to spawn new rooms don’t try to both write to the same spot.
- What we’re going to do every time we iterate through each room is check all of the orthogonal neighbors (top, left, right, and bottom) unless we’re at the edge. There’s a weighted chance, a 40% chance that a direction is skipped, meaning a 60% chance that any one direction will be chosen such that there is randomness to choosing different paths for every room that gets added.
- If you were to use a stack or something similar where you’re just adding to some stack structure and taking off from the top, you’ll end up with a very snakey generator, a very linear generator. There’s many different ways that you can use different data structures to accomplish this. We’re going with a queue because it accomplishes this more organic, wider building of dungeons versus the snakey linear approach that a stack would give us.
- One important technique for debugging something like a dungeon generator (it can be hard to debug, especially if it’s a very recursive algorithm or uses complicated data structures) is to be able to visualize it. To do that, you’re thinking about, “Okay, I have this one function that does all these things, and how do I split it up so that I can take a snapshot and pause and look at every individual part of it?”
- The way that you can accomplish that is with coroutines. A coroutine is just built into a lot of languages. Lua has its own coroutine namespace with functions like
coroutine.wrap, which returns a coroutine that you can then call over and over again. A coroutine is essentially just this function that you can think of as being pausable within a context. It exists in the same process as Lua itself and is similar to a thread, but it operates within Lua’s own stack. - You can essentially just call this function, and then it’s going to have a set of pause points or yields within it, where it will then return back to you a set of values at whatever point you want while preserving its current state, its current call stack. If you were to call it again, it’s just going to resume operation wherever it left off until it’s exhausted all of its potential yields.
- In the dungeon maker, sprinkled throughout all of the implementation of the function are these
coroutine.yieldcalls that return a table of all the information that we care about: The current phase, the state of the rooms, the state of visited. You can pass them back to a calling context as a coroutine and then step through it piece by piece, such that you could use keyboard input in the case of LÖVE 2D to see step by step something that otherwise would be synchronous and really hard to visualize bugs in.
Summing Up
- We did a lot in terms of looking at a bunch of code. We looked at even some relatively sophisticated dungeon generation and coroutines there at the very end. But we’ve run the gamut largely of what’s possible with 2D in terms of side scrolling, top down. Not a whole lot of visual or aesthetic domains left per se. Most of the fundamentals, I think now, have been communicated.
- For assignment five, we’ll be extending what the player is capable of doing. Right now the player can move around, swing a sword. We illustrated how to add a state and to be able to affect behavior and add new hitboxes. Let’s extend that idea and now actually add pots: Be able to grab a pot, throw it, have that be a projectile, affect enemies, hit the wall, and so on. Also, right now the game is pretty hard and when you get hit a certain number of times, you’re out of luck because there are no ways to recover HP. But in Zelda, hearts drop from enemies or from pots that replenish health. So hearts, lift pots, pots could be throwable and damage enemies. And then lastly, treasure chests, within those treasure chests, the player will find a boomerang.
- In this lesson, you learned how to create a top-down adventure game inspired by The Legend of Zelda. Specifically, you learned…
- How top-down perspective differs from side-scrolling, requiring four-directional movement and animations.
- How data-driven design separates entity data from code, allowing designers to tweak values without touching code.
- How to generate rooms with walls, floors, and random variety for visual polish.
- How dungeon generation uses a queue-based algorithm to create connected room layouts.
- The semantic distinction between hitboxes (can be struck) and hurtboxes (deal damage).
- How to implement damage and invulnerability with visual feedback through sprite flashing.
- How game objects and triggers create interactive elements like switches that open doors.
- How to smoothly scroll between rooms using camera tweening and events.
- How stenciling masks pixels to create the illusion of walking through doorways.
- How coroutines can help debug procedural generation algorithms by allowing step-by-step visualization.
See you next time!