🛡️ Zelda
The Legend of Zelda is a top-down dungeon crawler where the player controls a sword and shield wielding character named Link. The games in this series generally all include elements of puzzles, action, adventure, and exploration. Over the course of the game, Link will acquire various items and upgrades that he can use to defeat enemies and solve puzzles. The first game in the series was released in 1986 on Nintendo’s Famicom Disk System and was revolutionary for its time. It is widely considered to be one of the best game franchises to date.
Image from Breaking Canon
🎯 Objectives
- Top-down Perspective: With Mario, we were looking at the game world from the side. With Zelda, we’ll have a bird’s eye view of the world.
- Infinite Dungeon Generation: We’ve discussed previously the concept of games as illusions, and we’ll be seeing another example of that today in Zelda, where we’ll seemingly generate an endless dungeon.
- Hitboxes: Instead of using the entities “bounding box” (i.e. the rectangle drawn from
x, y, width, height
) to detect collisions, we’ll use the concept of a hitbox, which is just another rectangle, to detect collisions instead. The result will be a more convincing illusion of depth and perspective. - Events: Events are a way of broadcasting some message that informs us when something happens, and allows us to call a function in response.
- Screen Scrolling: We’ll take a look at how we can use tweening to give the appearance of transitioning from room to room in our dungeon.
🔨 Setup
-
Clone the repo (or download the zip) for today’s lecture, which you can find here.
-
Open the repo in Visual Studio Code.
-
Start Visual Studio Code’s “Live Server” extension. If you don’t have it installed:
-
Click on the extensions icons in the left-hand side navigation.
-
Search for “Live Server”.
-
Click install next to the extension by “Ritwick Dey”. You may have to reload the window.
-
Once it’s installed, click “Go Live” on the bottom right of the window. This should start the server and automatically open a new tab in your browser at
http://127.0.0.1:5500/
(or whatever port it says on your machine).- The files the server serves will be relative to the directory you had open in VSC when you hit “Go Live”.
-
🌅 Zelda-0 (The “Day-0” Update)
Zelda-0 generates and displays one room in the dungeon.
Sprites
- We’ll again need a sprite sheet in order to render our sprites to the screen. It’s helpful for our sprites to be laid out in tile segments of 16x16 pixels, so that we can index into the sheet evenly in order to access particular sprites. However, we will inevitably encounter sprites that do not fit into our sprite sheet in this way, and these sprites will require slightly more complicated rendering logic.
Top-Down Perspective
- A map in top-down perspective is essentially a tile map like we’ve seen before, but with the subtle difference of looking the map from up above rather than from the side.
- Some of the main visual differences might be that there’s less of a focus on screen scrolling, and more of a focus on things like shadows on the walls, corners of the screen, lighting, and camera angles (you want your world to be slightly skewed rather than completely vertical, for example).
Important Code
-
Room.js
is where everything for this update exists. The class starts with several constants that define the important measurements for the room. -
The constants prefixed with
TILE_
reference the sprites in the sheet located atassets/images/tiles.png
.Note that the tiles in the image above start at
1
whereas in the code they are indexed starting at0
. -
Sprite::generateSpritesFromSpriteSheet(spriteSheet, tileWidth, tileHeight)
is a new static method in ourSprite
library class that assumes that every individual sprite in the specifiedspriteSheet
is the exact sametileWidth
andtileHeight
. The sprites also must be laid out in a grid where the grid dimensions aretileWidth
xtileHeight
.- Obviously if the sheet provided does not meet these specifications, then more custom logic must be written on a case by case basis.
static generateSpritesFromSpriteSheet(spriteSheet, tileWidth, tileHeight) {const sprites = [];const sheetWidth = spriteSheet.width / tileWidth;const sheetHeight = spriteSheet.height / tileHeight;for (let y = 0; y < sheetHeight; y++) {for (let x = 0; x < sheetWidth; x++) {sprites.push(new Sprite(spriteSheet, x * tileWidth, y * tileHeight, tileWidth, tileHeight));}}return sprites; -
Room::generateWallsAndFloors()
uses the constants defined at the top of the class and determines which sprites to use for the walls and floor. Since there are several potential tiles to use for a piece of wall or floor, we can have a slightly different look each time we create a new room. -
Dungeon.js
right now is only a container that holds one room and renders it. -
PlayState.js
instantiates aDungeon
and renders it.
🧝 Zelda-1 (The “Hero” Update)
Zelda-1 adds a hero character that the player can control. The hero can move around the room and also swing their sword.
Sprites
- Sometimes the sprites in a sheet will not be neatly divided into even segments. For instance, our character sprite in Zelda is 20x16 pixels and also has several animations of different dimensions, such as his sword-swing animation (32x32 pixels).
- In order to render such a sprite properly to the screen, we have to associate an offset with that sprite’s coordinates, such that the sprite is shifted by that offset on the screen when rendered.
Bounding Box vs. Hitbox
In game development, a bounding box is the rectangular boundary that fully encloses a game entity or sprite, typically used for basic placement and rendering purposes. It acts as the outer container around the visual representation of the object. In this example, the green rectangle represents the player’s bounding box, which is 16x32 pixels.
On the other hand, a hitbox is a specific area defined for a sprite that designates where a collision is allowed to take place. Hitboxes are especially relevant in fighting games, where they determine the areas that can be “hit” or cause damage. When a hitbox collides with another sprite, it registers as a hit (e.g., the player deals damage to an enemy). In the blue rectangle in this example, the hitbox can be adjusted relative to the bounding box.
In top-down games like Zelda, the hitboxes are usually surround the player’s shadow. What would the hitbox offsets be to achieve this effect below?
In our Zelda game, the hero character could have two instances of hitboxes:
- A hitbox that defines the area where the character can take damage.
- Another hitbox for the sword, defining where the sword can strike enemies.
Open up the lib/Hitbox.js
file. You’ll notice that a hitbox is simply made up of two things:
- A position vector that defines its x and y coordinates.
- A dimensions vector that defines its width and height.
This setup allows you to easily define the hitbox area around any sprite. The Hitbox class can also check for collisions with other hitboxes using the didCollide()
method, which uses the AABB collision algorithm. Additionally, you can render hitboxes to the screen for debugging purposes, allowing you to visually inspect collision areas and adjust them as needed.
State Diagram
Important Code
-
src/entities/Player.js
: The hero character the player controls in the map. Has the ability to swing a sword to kill enemies and will collide into objects that are collidable.- In the constructor, we generate the sprites for both walking and sword swinging.
positionOffset
is used to render the sprite at the correct location depending on the current state.swordHitbox
andhitboxOffsets
are used to define the hitboxes that will be used to detect collisions.
- In the constructor, we generate the sprites for both walking and sword swinging.
-
src/entities/GameEntity.js
: Common properties and methods that all game entities will extend.- In
GameEntity::update()
note that we have to constantly update the hitbox such that it is always positioned relative to the entity.
- In
-
src/states/entity/player/PlayerIdlingState.js
: In this state, the player is stationary unless a directional key or the spacebar is pressed. -
src/states/entity/player/PlayerWalkingState.js
: In this state, the player can move around using the directional keys. From here, the player can go idle if no keys are being pressed. The player can also swing their sword if they press the spacebar. -
src/states/entity/player/PlayerSwordSwingingState.js
: In this state, the player swings their sword out in front of them. This creates a temporary hitbox that enemies can potentially collide into.-
Notice here that we’re setting
positionOffset
so that the sprite is rendered correctly. If we didn’t have this, then the hero would look like this when swinging the sword: -
With the offset, we get the desired result:
-
Another important feature in this state is setting the sword’s hitbox using
PlayerSwordSwingingState::setSwordHitbox()
. If we turnDEBUG
totrue
inglobals.js
, then we’ll be able to see what the hitbox area looks like:
-
😈 Zelda-2 (The “Enemies” Update)
Zelda-2 adds enemies into the room. The enemies can hurt the hero upon collision. The player can also kill the enemies by swinging their sword.
Sprites
- Luckily, the sprite sheet for the enemies (
assets/images/enemies.png
) in our game follows the ideal layout of each sprite being 16x16 pixels. This enables us to useSprite.generateSpritesFromSpriteSheet()
just like with the tilemap and player sprites.
- We can do the same for
assets/images/hearts.png
which will be used insrc/services/UserInterface.js
to display the player’s health at the top left of the screen.
The Factory Design Pattern
Image from Refactoring Guru
Recall that “design patterns” are battle-tested solutions for extremely common problems when developing software. The “problem” that we’ll run into quickly when instantiating enemies is that the more enemies our game has, the more unruly it will get to instantiate them all. To tackle this issue, we’ll put all code that instantiates new enemy classes inside of an EnemyFactory
class.
For a quick primer into the factory pattern, watch this video.
Render Order
In “top-down” perspective games, it is important to consider the order of which we render the entities and objects in our world. If we don’t consider this, then we get this undesirable effect:
To create the illusion of depth and perspective, we must render our entities and objects in a particular order such that entities and objects lower on the screen are rendered after entities and objects higher up on the screen.
To achieve this, we have to sort all the entities and objects on the screen by their bottom positions (i.e. y + height) which results in this desirable effect:
Important Code
-
src/entities/Enemy.js
: The enemy characters in the game that randomly walk around the room and can damage the player.- There are two child classes of
Enemy
, namelySlime
andSkeleton
. The difference between these two classes are their sprites/animations as well as their movement speed. As you can imagine, there are infinite possibilities for how different types of enemies could behave. - Just like
Player
,Enemy
also extendsGameEntity
.
- There are two child classes of
-
src/entities/EnemyFactory.js
: Encapsulates all definitions for instantiating new enemies. Can generate an instance of an enemy specified by the enums inEnemyType.js
. -
Room::generateEntities()
: Uses theEnemyFactory
to create all the enemies in the room. -
Room::cleanUpEntities()
: Gets rid of all dead enemies so we don’t have to keep track of them. -
Room::buildRenderQueue()
:- Sorts the entities by their bottom positions. This will put them in an order such that entities higher on the screen will appear behind entities that are lower down. Since this game is in a “top-down” perspective, we need this effect to achieve a more convincing illusion of depth.
- To do the actual sorting, we use JavaScript’s built-in
sort()
method:
buildRenderQueue() {return this.entities.sort((a, b) => {let order = 0;const bottomA = a.hitbox.position.y + a.hitbox.dimensions.y;const bottomB = b.hitbox.position.y + b.hitbox.dimensions.y;if (a.renderPriority < b.renderPriority) {order = -1;}else if (a.renderPriority > b.renderPriority) {order = 1;}else if (bottomA < bottomB) {order = -1;}else {order = 1;}return order;});}- We’ve added a
GameEntity::renderPriority
field so that we can prioritize some entities over others regardless of their bottom position.
-
Room::updateEntities()
: Loops through all the entities and updates them respectively. This involves checking if any collisions happened between entity’s hitboxes and applying the damage to the affected entities.- Note that we only detect collisions between hitboxes now instead of the entire “bounding box” of the entity.
-
Player::becomeInvulnerable()
&Player::startInvulnerabilityTimer()
:- Usually in games like these, if the player takes a hit, they become invulnerable for a short period of time. The convention to show the player that the character is invulnerable is to make the character “flash”.
startInvulnerabilityTimer() {const action = () => {this.alpha = this.alpha === 1 ? 0.5 : 1;};const interval = Player.INVULNERABLE_FLASH_INTERVAL;const duration = Player.INVULNERABLE_DURATION;const callback = () => {this.alpha = 1;this.isInvulnerable = false;};return timer.addTask(action, interval, duration, callback);}
🚪 Zelda-3 (The “Doors” Update)
Zelda-3 adds doors to the room. The doors can be opened by hitting a switch game object. In this implementation, we’ve hardcoded the doorway generation such that the doors will always be in the same position in every room, and they will always be locked until the switch is triggered. However, you can imagine that it might be nicer design to vary the doorway generation a bit, which is certainly the case in the real Zelda game.
Sprites
The door sprites for this Zelda game are bigger than 16x16 pixels, so instead of rendering a single sprite from our sheet, we must instead render 4 sprites in order to properly display each door. This will change how we monitor for collisions as well, since we’ll have to adjust each door’s hitbox to cover all 4 sprites.
Important Code
-
src/objects/Doorway.js
: One of four doors that appears on each side of a room.- These are simply instances of
GameObject
that, when collided with, the player can walk through this (open) door to go to an adjacent room. - For now, we’ll only render the door and introduce the “walking through” mechanic in the next update.
- These are simply instances of
-
src/objects/Switch.js
: Another game object that, when collided with, will open all of the doors. This is nearly identical to theBlock
game object from Mario.- We’ve set the
isSolid
property to true so that the player and enemies cannot overlap the switch. InGameObject::onCollision()
we can check if the object is solid, and if so, set the collider’s position relative to the object.
- We’ve set the
-
Room::generateObjects()
: Adds the switch and the doorways to an array that the room can reference. -
Room::updateEntities()
: For each entity, check if it collided with any of the game objects and resolve the collision based on whatever object it happens to be. For a switch, all entities will bump into it but only the player can activate it.Room.js this.objects.forEach((object) => {if (object.didCollideWithEntity(entity.hitbox)) {if (object.isCollidable) {object.onCollision(entity);}}});// Switch.jsonCollision(collider) {super.onCollision(collider);if (collider instanceof Player) {this.room.openDoors();}}
🗺️ Zelda-4 (The “Rooms” Update)
Zelda-4 enables the player to walk through the doors into (procedurally generated) adjacent rooms where they will be greeted by a new set of enemies.
Dungeon Generation
- In games such as Zelda, dungeons are generally fixed. Meaning, they are preemptively created by the developers in some predetermined layout. In our version, we will be generating dungeons/rooms procedurally instead of preemptively.
- Here’s what we want to do to simulate infinite transitioning from one room to another:
- Create a new room whenever the player collides with an open doorway.
- Render the new room off-screen with some offset depending on which direction the player is going, such that the new room is adjacent to the current room. This offset will be a negative or positive
CANVAS_WIDTH
orCANVAS_HEIGHT
depending on the doorway direction. - Tween from the coordinates of the current room to those of the new room, resetting the new room to have the coordinates of the current room
(0, 0)
once the tweening animation finishes. - If the player collides with another open door, go back to step 1.
- If you wanted to do it the real “Zelda way”, then the dungeons could be represented in a 2D array, such that some indexes are empty (“off”) and others contain rooms (“on”), with each room connected to at least one other. To transition from room to room, display the new room by adding or subtracting 1 from the x or y index of the current room.
- This allows the developer to “lock” doors and hide “keys” in certain rooms in the dungeon, such that the player has to visit each room in a particular order to beat the level.
Image from Zelda Dungeon
Events
We will be using the JavaScript Event
API to have our doors signal to the dungeon that it is time to change rooms. In a nutshell, this is how we can create our own custom events:
// Create the event.const event = new Event('EventName');
// Listen for the event.canvas.addEventListener('EventName', () => { /* The thing you want to do when the event happens... */});
// Dispatch the event.canvas.dispatchEvent(event);
Important Code
-
src/objects/Dungeon.js
now has many more properties and methods to facilitate the new feature of being able to move from room to room. There is a lot of new stuff happening in this class so please make sure to take a moment and understand everything that it is doing.currentRoom
andnextRoom
are the rooms that the player can move between. The player starts in thecurrentRoom
and after the panning animation has taken place, the player ends up innextRoom
, which then becomes the newcurrentRoom
.camera
is not like the camera we implemented in Mario. In Zelda, thecamera
will be a JS object literal containing anx
andy
value. These values will be used totranslate()
the canvas during the panning animation:
context.translate(-Math.floor(this.camera.x), -Math.floor(this.camera.y));- We set up four event listeners to listen for room shift events. If one of these events is dispatched, then we call
beginShifting()
. beginShifting()
prepares the rooms, camera, and player to all be shifted. Once all the proper coordinates are set, we tween the camera in whichever direction the new room is in, as well as the player to be at the opposite door in the next room. For example, if the player went through the top door incurrentRoom
, then the player should appear at the bottom door innextRoom
.finishShifting()
is called once the tweens have been completed. It sets all of the values back to what they were originally before the shift, positions the player in the room, and closes the doors behind them! 😈
-
Room::adjacentOffset
is passed in when rendering all the tiles, entities, and objects to make them appear like they are shifting with the room while the tween is happening.this.tiles.forEach((tileRow) => {tileRow.forEach((tile) => {tile.render(this.adjacentOffset);});});this.renderQueue.forEach((elementToRender) => {elementToRender.render(this.adjacentOffset);}); -
Doorway::update()
: To achieve the effect of the player walking underneath the arch of the door, we have to render the player before the door. Otherwise, we want the player to be above the door or else it will appear as if they are walking behind the door if they get too close.update() {this.renderPriority = this.room.isShifting ? 1 : -1;} -
Since we have a game object that has an
update()
method now, we need to make sure we call this method fromRoom
:updateObjects(dt) {this.objects.forEach((object) => {object.update(dt);});}
And with all that, we finally get this beauty:
✨ Zelda-5 (The “Polish” Update)
Zelda-5 adds a title screen state as well as fade in/out transitions between states to give the game a more dramatic feel. We’ve also added a couple of music tracks and sound effects.
Important Code
states/game/TitleScreenState.js
: Displays a title screen where the player can press enter to start a new game.states/game/TransitionState.js
: An “intermediary” state whose sole job is to play a fade out/in animation.- Now, every state changes to the
TransitionState
and passes in the state they want to transition to. - The “transition animation” is tweening the opacity value of a black rectangle. Deceptively simple! 😇
- Now, every state changes to the
And with that, we have a fully functioning game of Zelda!
🍺 NES Homebrew
One last thing if you’re interested in this sort of development…
From Wikipedia:
Homebrew, when applied to video games, refers to games produced by hobbyists for proprietary video game consoles which are not intended to be user-programmable. Official documentation is often only available to licensed developers, and these systems may use storage formats that make distribution difficult (such as ROM cartridges or encrypted CD-ROMs). Many consoles have hardware restrictions to prevent unauthorized development.
Below are some links that you may find useful if curious to learn more: