├── README.md └── tutorial ├── part0.md ├── part1.md ├── part10.md ├── part11.md ├── part2.md ├── part3.md ├── part4.md ├── part5.md ├── part6.md ├── part7.md ├── part8.md └── part9.md /README.md: -------------------------------------------------------------------------------- 1 | # Javascript rewrite of the Complete Roguelike Tutorial 2 | 3 | ![sgsO37A](https://user-images.githubusercontent.com/925980/85092017-69762e00-b1ae-11ea-8a2a-b4f0776bf728.png) 4 | 5 | - Javascript 6 | - HTML Canvas 7 | - Node.js 8 | - Webpack 9 | - Free hosting on github pages 10 | 11 | ## Tutorial 12 | 13 | - [x] [Part 0 - Setting Up](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part0.md) 14 | - [x] [Part 1 - Drawing the ‘@’ symbol and moving it around](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part1.md) 15 | - [x] [Part 2 - The generic Entity, the render functions, and the map](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part2.md) 16 | - [x] [Part 3 - Generating a dungeon](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part3.md) 17 | - [x] [part 4 - Field of view](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part4.md) 18 | - [x] [Part 5 - Placing enemies and kicking them (harmlessly)](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part5.md) 19 | - [x] [Part 6 - Doing (and taking) some damage](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part6.md) 20 | - [x] [Part 7 - Creating the Interface](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part7.md) 21 | - [x] [Part 8 - Items and Inventory](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part8.md) 22 | - [x] [Part 9 - Ranged Scrolls and Targeting](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part9.md) 23 | - [x] [Part 10 - Saving and loading](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part10.md) 24 | - [x] [Part 11 - Delving into the Dungeon](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part11.md) 25 | - [ ] Part 12 - Increasing Difficulty 26 | - [ ] Part 13 - Gearing up 27 | 28 | [Repo of the tutorial project Gobs O' Goblins is available here.](https://github.com/luetkemj/gobs-o-goblins) 29 | 30 | [The original Roguelike Tutorial - TCOD](http://rogueliketutorials.com/tutorials/tcod/) 31 | -------------------------------------------------------------------------------- /tutorial/part0.md: -------------------------------------------------------------------------------- 1 | # Part 0 - Setting Up 2 | 3 | ## Prior Knowledge 4 | 5 | This tutorial assumes some basic familiarity with programming in general and Javascript in particular. There are many free resources online about learning programming and Javascript. I would recommend you spend some time getting a handle on the basics at minimum before proceeding. In addition to javascript you should be familiar with using the command line, git, and github. 6 | 7 | That said - who am I to tell you what to do? Feel free to ignore that last paragraph and carry on! It's just typing right? 8 | 9 | ## Assumptions 10 | 11 | You will need a github account and git installed on your machine. 12 | 13 | [Install git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) 14 | 15 | If you don't already have node installed on your machine I recommend installing it with nvm. You'll thank yourself later, I promise. For this tutorial I will be using `node v12.13.1`. 16 | 17 | [Install nvm and node](https://github.com/nvm-sh/nvm#installing-and-updating) 18 | 19 | ## Editors 20 | 21 | Any text editor will work for Javacript. I personally use [Visual Studio Code](https://code.visualstudio.com/). In the past I have used Atom and Sublime among others. It really doesn't matter what editor you use so long as it helps you to be productive. Just pick one if you don't already have a daily driver. 22 | 23 | ## Making sure everything works 24 | 25 | To begin you will need to clone the basic starter project. It includes a dev-server for local development, a preprod environment for testing your build, and a deploy script for github pages so you can host your game online for free. 26 | 27 | ```bash 28 | git clone git@github.com:luetkemj/jsrlt-starter.git gobs-o-goblins 29 | cd ./gobs-o-goblins 30 | ``` 31 | 32 | ### Installation 33 | 34 | There are bunch of devDependencies we need to install before we can run the app. None of these will end up in the production build so don't worry about bloat. 35 | 36 | `npm install` 37 | 38 | ### Dev 39 | 40 | This project uses a webpack development server for local development. Run `npm start` to boot up the server. After a moment you should be able to access your project at [http://localhost:8080/](http://localhost:8080/). 41 | 42 | You should see "Hello World" and belo that "Build:" and some gibberish next to it. That gibberish is the current git hash. With that you can always pinpoint exactly what code is running. Very useful for debugging. 43 | 44 | The development server comes with hot reloading and will reload the app automatically on save. To give it a try go ahead and make the following change in index.html: 45 | 46 | ```diff 47 | -

Hello World

48 | +

Gobs O' Goblins

49 | ``` 50 | 51 | Save your changes and the browser will reload automatically. Cool! 52 | 53 | Don't forget to commit your changes to git. Stop the server with `control + c` 54 | 55 | ```bash 56 | git add . 57 | git commit -m 'my first commit' 58 | ``` 59 | 60 | Start the server back up `npm start` and the git hash should have changed. Cool! 61 | 62 | ### Preprod 63 | 64 | Sometimes there are subtle differences between the raw code and what gets compiled for production. "It works on my machine" is a common developer excuse but it doesn't do your fanbase any good. This project includes a preprod environment for testing your compiled code locally before deploying it to production. 65 | 66 | Run `npm run preprod` to create a production build and serve it locally. After the build is complete your browser should automatically open a new tab running the app. Go ahead and try it! 67 | 68 | ### Deploy 69 | 70 | Finally we will create a repo for our project on github, push everything and then deploy it to production. 71 | 72 | To start, go to github and create a new repository. Name it `gobs-o-goblins` and click `Create repository`. 73 | 74 | We can now push our local code to the new remote. Github has some code snippets for quick setup. We have an existing repository so copy the second snippet under `…or push an existing repository from the command line` it looks like this: 75 | 76 | ```bash 77 | git remote add origin git@github.com:your-github-username/gobs-o-goblins.git 78 | git push -u origin master 79 | ``` 80 | 81 | Don't forget to replace `your-github-username` with your actual github username :) 82 | 83 | We will be using github pages to host our project. The deploy script is already setup for you so all you have to do now is run `npm run deploy`. This will generate a production build and deploy it to a gh-pages branch in your github repo. 84 | 85 | If everything has worked so far you should be able to see your app running at `https://your-github-username.github.io/gobs-o-goblins/` 86 | 87 | ## About this tutorial 88 | 89 | Code snippets will be presented in a way that tries to convey exactly what you should be adding to a file at what time. When you are expected to create a file scratch and enter code into it, it will be represented with standard Javascript code highlighting, like so: 90 | 91 | ```javascript 92 | import { Component } from "geotic"; 93 | 94 | export default class Health extends Component { 95 | static properties = { current: 10 }; 96 | } 97 | ``` 98 | 99 | Most of the time, you’ll be editing a file and code that already exists. In such cases, the code will be displayed like this: 100 | 101 | ```diff 102 | import { Component } from "geotic"; 103 | 104 | export default class Health extends Component { 105 | - static properties = { current: 10 }; 106 | + static properties = { current: 10, max: 10 }; 107 | } 108 | ``` 109 | 110 | ## Ready to go? 111 | 112 | Once you’re set up and ready to go, you can proceed to [Part 1](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part1.md). 113 | -------------------------------------------------------------------------------- /tutorial/part1.md: -------------------------------------------------------------------------------- 1 | # Part 1 - Drawing the ‘@’ symbol and moving it around 2 | 3 | We're going to use html canvas to draw the `@` symbol - and the rest of our game. 4 | 5 | To start we need to add a canvas element to our html. Go ahead and do that now in `./index.html` 6 | 7 | ```diff 8 | 9 | -

Gobs O' Goblins

10 | + 11 |
Build: <%= htmlWebpackPlugin.options.version %>
12 | 13 | ``` 14 | 15 | We'll also need to add some styles so we can actually see what's going on. 16 | 17 | ```diff 18 | 19 | 20 | 21 | <%= htmlWebpackPlugin.options.title %> 22 | + 27 | 28 | ``` 29 | 30 | Alright, now that we can see our canvas - that little black rectangle in the top left - we can get to drawing our hero! 31 | 32 | First, we need to create a new folder `./src/lib` to store the code for our canvas. We will be storing most of our miscellaneous logic in here. Next create a new file `./src/lib/canvas.js` and make it look like this: 33 | 34 | ```javascript 35 | const pixelRatio = window.devicePixelRatio || 1; 36 | const canvas = document.querySelector("#canvas"); 37 | const ctx = canvas.getContext("2d"); 38 | 39 | export const grid = { 40 | width: 100, 41 | height: 34, 42 | }; 43 | 44 | const lineHeight = 1.2; 45 | 46 | let calculatedFontSize = window.innerWidth / grid.width; 47 | let cellWidth = calculatedFontSize * pixelRatio; 48 | let cellHeight = calculatedFontSize * lineHeight * pixelRatio; 49 | let fontSize = calculatedFontSize * pixelRatio; 50 | 51 | canvas.style.cssText = `width: ${calculatedFontSize * grid.width}; height: ${ 52 | calculatedFontSize * lineHeight * grid.height 53 | }`; 54 | canvas.width = cellWidth * grid.width; 55 | canvas.height = cellHeight * grid.height; 56 | 57 | ctx.font = `normal ${fontSize}px Arial`; 58 | ctx.textAlign = "center"; 59 | ctx.textBaseline = "middle"; 60 | 61 | export const drawChar = ({ char, color, position }) => { 62 | ctx.fillStyle = color; 63 | ctx.fillText( 64 | char, 65 | position.x * cellWidth + cellWidth / 2, 66 | position.y * cellHeight + cellHeight / 2 67 | ); 68 | }; 69 | ``` 70 | 71 | Ok. That's a lot. Let's a go over it real quick. 72 | 73 | ```javascript 74 | const pixelRatio = window.devicePixelRatio || 1; 75 | ``` 76 | 77 | This gives us access to the pixel ratio of the user's machine and will make our game nice and crisp no matter the resolution of our user. 78 | 79 | ```javascript 80 | const canvas = document.querySelector("#canvas"); 81 | const ctx = canvas.getContext("2d"); 82 | ``` 83 | 84 | Here we are storing a reference to the canvas element in our html and then getting the context with the canvas API. You can read more about the canvas API [here](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API). 85 | 86 | ```javascript 87 | export const grid = { 88 | width: 100, 89 | height: 34, 90 | }; 91 | ``` 92 | 93 | In this next bit we set the dimensions of our grid. It will be 100 characters wide and 34 high. 94 | 95 | After that we tweak alot of settings in our canvas so that it will always fill the browser. You won't have to touch much of this again so it's not super important that you understand every line. But again, if you want to your can go read the [docs](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API). 96 | 97 | Finally we have our function to actually draw something on the canvas! 98 | 99 | ```javascript 100 | export const drawChar = ({ char, color, position }) => { 101 | ctx.fillStyle = color; 102 | ctx.fillText( 103 | char, 104 | position.x * cellWidth + cellWidth / 2, 105 | position.y * cellHeight + cellHeight / 2 106 | ); 107 | }; 108 | ``` 109 | 110 | With it we can set the character, color, and position of any cell on our grid. Let's give it a go! 111 | 112 | To do that we need to import our script to `./src/index.js`. 113 | 114 | ```diff 115 | -console.log("Hello World!"); 116 | +import "./lib/canvas.js"; 117 | ``` 118 | 119 | If you check out the game in your browser you should see that the canvas is much larger. All we have to do now is import the drawChar function and use it! Make `./src/index.js` look like this: 120 | 121 | ```diff 122 | import "./lib/canvas.js"; 123 | +import { drawChar } from "./lib/canvas"; 124 | + 125 | +drawChar({ char: "@", color: "#FFF", position: { x: 0, y: 0 } }); 126 | ``` 127 | 128 | Our hero has arrived! 129 | 130 | At this point you should see your "@" in the top left of the canvas. You can experiement with it's position, color, and character. We're a long ways from a game but still, pretty neat! 131 | 132 | This is a good time to save your progress, push your changes to git, and even run deploy again to show the world the amazing progress you're making :) 133 | 134 | See step 0 of this tutorial if you need a reminder save your changes and deploy. 135 | 136 | --- 137 | 138 | Let's take a step back to tidy some things up before moving on to moving our hero around. 139 | 140 | First we have some styling to do. Delete this entire block in `./index.html` 141 | 142 | ```diff 143 | - 148 | ``` 149 | 150 | And replace it with this: 151 | 152 | ```html 153 | 157 | 184 | ``` 185 | 186 | We're adding some styles to position our canvas properly, get rid of any extra spacing around it, and normalize the colors. You'll also notice a link to google fonts. I'll be using a font called Fira Code, you're welcome to pick something else if you'd like but I like this one. 187 | 188 | We also need to update the font in `./src/lib/canvas`. 189 | 190 | ```diff 191 | canvas.height = cellHeight * grid.height; 192 | 193 | -ctx.font = `normal ${fontSize}px Arial`; 194 | +ctx.font = `normal ${fontSize}px 'Fira Code'`; 195 | ctx.textAlign = "center"; 196 | ``` 197 | 198 | If you pick a different font you will need to change it in the style block above and canvas.js 199 | 200 | --- 201 | 202 | Time to move! 203 | 204 | If you played around with the draw function you may have noticed that we'll just have to adjust the x and y coordinates to move our "@" around. 205 | 206 | First, let's make an object to store our player and then pass that to drawChar. 207 | 208 | ```diff 209 | -drawChar({ char: "@", color: "#FFF", position: { x: 0, y: 0 } }); 210 | +const player = { 211 | + char: "@", 212 | + color: "white", 213 | + position: { 214 | + x: 0, 215 | + y: 0, 216 | + }, 217 | +}; 218 | + 219 | +drawChar(player); 220 | ``` 221 | 222 | Nothing in the game has really changed but this refactor will help in just a minute. 223 | 224 | Now let's add an event listener so we can do stuff when a user presses a key. 225 | 226 | Add this at the bottom of the file: 227 | 228 | ```javascript 229 | document.addEventListener("keydown", (ev) => { 230 | console.log(ev.key); 231 | }); 232 | ``` 233 | 234 | Now if you open your browser's developer console and start typing on the game window again you will see each key as you type logged to the console! 235 | 236 | Let's use that to our advantage and move our hero when the arrow keys are pressed. Replace the event listener we just created with the follow: 237 | 238 | ```javascript 239 | let userInput = null; 240 | 241 | document.addEventListener("keydown", (ev) => { 242 | userInput = ev.key; 243 | processUserInput(); 244 | }); 245 | 246 | const processUserInput = () => { 247 | if (userInput === "ArrowUp") { 248 | player.position.y -= 1; 249 | } 250 | if (userInput === "ArrowRight") { 251 | player.position.x += 1; 252 | } 253 | if (userInput === "ArrowDown") { 254 | player.position.y += 1; 255 | } 256 | if (userInput === "ArrowLeft") { 257 | player.position.x -= 1; 258 | } 259 | 260 | drawChar(player); 261 | }; 262 | ``` 263 | 264 | Now, we store every keypress in `userInput` so we can process it with `processUserInput`. This function just checks what key was pressed and if it's an arrow key we update the player position and finally call drawChar again. This is where you could change the keybindings to wasd or ghjk or whatever you want, but let's test it first and see if our hero is moving! 265 | 266 | ... 267 | 268 | Hmmmmm... looks like we may have a bug - or a snake! The problem is we aren't clearing the canvas between renders. Let's add a function to our canvas lib to do just that. 269 | 270 | Add this to the bottom of `./src/lib/canvas.js` 271 | 272 | ```javascript 273 | export const clearCanvas = () => 274 | ctx.clearRect(0, 0, canvas.width, canvas.height); 275 | ``` 276 | 277 | Now we can import our new function to `./src/index.js` and call it before drawChar. 278 | 279 | ```diff 280 | -import { drawChar } from "./lib/canvas"; 281 | +import { clearCanvas, drawChar } from "./lib/canvas"; 282 | ``` 283 | 284 | ```diff 285 | } 286 | 287 | + clearCanvas() 288 | drawChar(player); 289 | }; 290 | ``` 291 | 292 | WooHoo! We did it! You should be proud of yourself, this is big step on our way towards making a real game. You have gained experience and leveled up! 293 | 294 | Go to [part 2](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part2.md) where we get to do it all again the ECS way! 295 | -------------------------------------------------------------------------------- /tutorial/part10.md: -------------------------------------------------------------------------------- 1 | # Part 10 - Saving and loading 2 | 3 | Saving and loading is an essential feature in almost every game, but if you're not careful it can easily become extremely difficult to manage. Fortunately, geotic makes saving all of our entities super easy. The only real challenge we'll face here is saving and loading our cache but even that really isn't too big of a deal. Let's get to it! 4 | 5 | ## Saving 6 | 7 | Geotic really does make saving super simple - so long as all your game state is within geotic you can do something as simple as this: 8 | 9 | ```javascript 10 | const saveGame = () => { 11 | const data = ecs.serialize(); 12 | localStorage.setItem("savegame", data); 13 | }; 14 | 15 | const loadGame = () => { 16 | const data = localStorage.getItem("savegame"); 17 | ecs.deserialize(data); 18 | }; 19 | ``` 20 | 21 | We have a few more things to keep track of so we won't be able to get away that easy. In addtion to saving our entities in geotic we need to save our cache, the message log, and the id of our player entity. Not bad really. 22 | 23 | The first thing we'll do is move our message log from `./src/state/ecs.js` to `./src/index.js`. This is just a refactor to consolidate our code a bit. It's not strictly required for saving. But it will make things a bit easier for us moving forward. 24 | 25 | In `./src/state/ecs.js` go ahead and delete the message log. 26 | 27 | ```diff 28 | -export const messageLog = ["", "Welcome to Gobs 'O Goblins!", ""]; 29 | -export const addLog = (text) => { 30 | - messageLog.unshift(text); 31 | -}; 32 | ``` 33 | 34 | And then just put that code in `./src/index.js` and remove the import at the top. 35 | 36 | ```diff 37 | import { targeting } from "./systems/targeting"; 38 | -import ecs, { addLog } from "./state/ecs"; 39 | +import ecs from "./state/ecs"; 40 | import { IsInFov, Move, Position, Ai } from "./state/components"; 41 | 42 | +export const messageLog = ["", "Welcome to Gobs 'O Goblins!", ""]; 43 | +export const addLog = (text) => { 44 | + messageLog.unshift(text); 45 | +}; 46 | ``` 47 | 48 | Now we just need to fix the imports in a few files that still expect our messageLog to be in the old location. 49 | 50 | `./src/systems/targeting.js` 51 | 52 | ```diff 53 | -import ecs, { addLog } from "../state/ecs"; 54 | +import ecs from "../state/ecs"; 55 | +import { addLog } from "../index"; 56 | import { readCacheSet } from "../state/cache"; 57 | ``` 58 | 59 | `./src/systems/render.js` 60 | 61 | ```diff 62 | -import { messageLog } from "../state/ecs"; 63 | -import { gameState, selectedInventoryIndex } from "../index"; 64 | +import { gameState, messageLog, selectedInventoryIndex } from "../index"; 65 | ``` 66 | 67 | `./src/systems/movement.js` 68 | 69 | ```diff 70 | -import ecs, { addLog } from "../state/ecs"; 71 | +import ecs from "../state/ecs"; 72 | +import { addLog } from "../index"; 73 | import { addCacheSet, deleteCacheSet, readCacheSet } from "../state/cache"; 74 | ``` 75 | 76 | With that out of the way we really only have to worry about our cache before we can save. Our cache data structure uses Set objects. This makes it super fast but unfortunately you can't just stringify it. We'll have to write a couple small functions to manually serialize and deserialize our cache. If you add more keys to your cache in the future it will be up to you handle those here as well. 77 | 78 | In `./src/state/cache.js` add the following functions: 79 | 80 | ```javascript 81 | export const serializeCache = () => { 82 | const entitiesAtLocation = Object.keys(cache.entitiesAtLocation).reduce( 83 | (acc, val) => { 84 | acc[val] = [...cache.entitiesAtLocation[val]]; 85 | return acc; 86 | }, 87 | {} 88 | ); 89 | 90 | return { 91 | entitiesAtLocation, 92 | }; 93 | }; 94 | 95 | export const deserializeCache = (data) => { 96 | cache.entitiesAtLocation = Object.keys(data.entitiesAtLocation).reduce( 97 | (acc, val) => { 98 | acc[val] = new Set(data.entitiesAtLocation[val]); 99 | return acc; 100 | }, 101 | {} 102 | ); 103 | }; 104 | 105 | export const clearCache = () => { 106 | cache.entitiesAtLocation = {}; 107 | }; 108 | ``` 109 | 110 | We can import `serializeCache` add a `saveGame` function in `./src/index.js`. 111 | 112 | ```diff 113 | -import { readCacheSet } from "./state/cache"; 114 | +import { readCacheSet, serializeCache } from "./state/cache"; 115 | ``` 116 | 117 | ```javascript 118 | const saveGame = () => { 119 | const gameSaveData = { 120 | ecs: ecs.serialize(), 121 | cache: serializeCache(), 122 | playerId: player.id, 123 | messageLog, 124 | }; 125 | localStorage.setItem("gameSaveData", JSON.stringify(gameSaveData)); 126 | addLog("Game saved"); 127 | }; 128 | ``` 129 | 130 | Finally we just need add a keybinding: 131 | 132 | ```diff 133 | const processUserInput = () => { 134 | + if (userInput === "s") { 135 | + saveGame(); 136 | + } 137 | 138 | if (gameState === "GAME") { 139 | ``` 140 | 141 | Saving your game isn't much good if you can't load it so let's handle that next. 142 | 143 | ## Loading 144 | 145 | We'll need to import our deserializeCache function this time: 146 | 147 | ```diff 148 | -import { readCacheSet, serializeCache } from "./state/cache"; 149 | +import { deserializeCache, readCacheSet, serializeCache } from "./state/cache"; 150 | ``` 151 | 152 | Our loadGame function is a little bit bigger: 153 | 154 | ```javascript 155 | const loadGame = () => { 156 | const data = JSON.parse(localStorage.getItem("gameSaveData")); 157 | if (!data) { 158 | addLog("Failed to load - no saved games found"); 159 | return; 160 | } 161 | 162 | for (let entity of ecs.entities.all) { 163 | entity.destroy(); 164 | } 165 | 166 | ecs.deserialize(data.ecs); 167 | deserializeCache(data.cache); 168 | 169 | player = ecs.getEntity(data.playerId); 170 | 171 | userInput = null; 172 | playerTurn = true; 173 | gameState = "GAME"; 174 | selectedInventoryIndex = 0; 175 | 176 | messageLog = data.messageLog; 177 | addLog("Game loaded"); 178 | }; 179 | ``` 180 | 181 | Let's go over it: 182 | 183 | We start by retrieving our save game data from localStorage and making a quick check to see if we actually go anything. 184 | 185 | ```javascript 186 | const loadGame = () => { 187 | const data = JSON.parse(localStorage.getItem("gameSaveData")); 188 | if (!data) { 189 | addLog("Failed to load - no saved games found"); 190 | return; 191 | } 192 | ``` 193 | 194 | Next we destroy all existing entities - best to start with a clean slate. 195 | 196 | ```javascript 197 | for (let entity of ecs.entities.all) { 198 | entity.destroy(); 199 | } 200 | ``` 201 | 202 | Then we deserialize our ecs entities and our cache 203 | 204 | ```javascript 205 | ecs.deserialize(data.ecs); 206 | deserializeCache(data.cache); 207 | ``` 208 | 209 | With our entities all back in place we can use our store player id to get the player entity: 210 | 211 | ```javascript 212 | player = ecs.getEntity(data.playerId); 213 | ``` 214 | 215 | And then just reset the other little bits we track to their intitial state: 216 | 217 | ```javascript 218 | userInput = null; 219 | playerTurn = true; 220 | gameState = "GAME"; 221 | selectedInventoryIndex = 0; 222 | ``` 223 | 224 | And last, we restore our message log and add a new "Game Loaded" message. 225 | 226 | ```javascript 227 | messageLog = data.messageLog; 228 | addLog("Game loaded"); 229 | }; 230 | ``` 231 | 232 | We do need to make two more adjustments - our `player` and `messageLog` variables are constants. In order to reset the values we need to initialize them with the `let` keywork instead. 233 | 234 | ```diff 235 | -export const messageLog = ["", "Welcome to Gobs 'O Goblins!", ""]; 236 | +export let messageLog = ["", "Welcome to Gobs 'O Goblins!", ""]; 237 | ``` 238 | 239 | ```diff 240 | -const player = ecs.createPrefab("Player"); 241 | +let player = ecs.createPrefab("Player"); 242 | ``` 243 | 244 | And finally we need a keybinding: 245 | 246 | ```diff 247 | + if (userInput === "l") { 248 | + loadGame(); 249 | + } 250 | 251 | if (userInput === "s") { 252 | saveGame(); 253 | } 254 | ``` 255 | 256 | With all this saving and loading, what about starting a new game? Up to this point the only way to do that was by refreshing the browser - yuck! 257 | 258 | ## Starting a new game 259 | 260 | Starting a new game is very similar to loading an existing one. When starting a new game we don't need to worry about deserializing anything. We just destroy the old entities, clear the cache, reset the bits we care about to their initial state and most importantly, rebuild the dungeon. 261 | 262 | Our newGame function looks like this: 263 | 264 | ```javascript 265 | const newGame = () => { 266 | for (let item of ecs.entities.all) { 267 | item.destroy(); 268 | } 269 | clearCache(); 270 | 271 | userInput = null; 272 | playerTurn = true; 273 | gameState = "GAME"; 274 | selectedInventoryIndex = 0; 275 | 276 | messageLog = ["", "Welcome to Gobs 'O Goblins!", ""]; 277 | 278 | initGame(); 279 | }; 280 | ``` 281 | 282 | We need to import the `clearCache` function: 283 | 284 | ```diff 285 | -import { deserializeCache, readCacheSet, serializeCache } from "./state/cache"; 286 | +import { clearCache, deserializeCache, readCacheSet, serializeCache } from "./state/cache"; 287 | ``` 288 | 289 | And we also need to create the `initGame` function. We already have all the code for initializing the game, we just need to wrap it in a function that we can call when the app starts and when a new game is created. 290 | 291 | The body of this function already exists in `./src/index.js` - start by wrapping the existing lines within a new function. 292 | 293 | ```diff 294 | +const initGame = () => { 295 | // init game map and player position 296 | const dungeon = createDungeon({ 297 | x: grid.map.x, 298 | y: grid.map.y, 299 | width: grid.map.width, 300 | height: grid.map.height, 301 | }); 302 | 303 | let player = ecs.createPrefab("Player"); 304 | player.add(Position, { 305 | x: dungeon.rooms[0].center.x, 306 | y: dungeon.rooms[0].center.y, 307 | }); 308 | 309 | const openTiles = Object.values(dungeon.tiles).filter( 310 | (x) => x.sprite === "FLOOR" 311 | ); 312 | 313 | times(5, () => { 314 | const tile = sample(openTiles); 315 | ecs.createPrefab("Goblin").add(Position, { x: tile.x, y: tile.y }); 316 | }); 317 | 318 | times(10, () => { 319 | const tile = sample(openTiles); 320 | ecs.createPrefab("HealthPotion").add(Position, { x: tile.x, y: tile.y }); 321 | }); 322 | 323 | times(10, () => { 324 | const tile = sample(openTiles); 325 | ecs.createPrefab("ScrollLightning").add(Position, { x: tile.x, y: tile.y }); 326 | }); 327 | 328 | times(10, () => { 329 | const tile = sample(openTiles); 330 | ecs.createPrefab("ScrollParalyze").add(Position, { x: tile.x, y: tile.y }); 331 | }); 332 | 333 | times(10, () => { 334 | const tile = sample(openTiles); 335 | ecs.createPrefab("ScrollFireball").add(Position, { x: tile.x, y: tile.y }); 336 | }); 337 | 338 | fov(player); 339 | render(player); 340 | +}; 341 | ``` 342 | 343 | The only modification we need to make is to initialize the `player` variable outside of the scope of the `initGame` function. 344 | 345 | Within the initGame function change this line: 346 | 347 | ```diff 348 | -let player = ecs.createPrefab("Player"); 349 | +player = ecs.createPrefab("Player"); 350 | ``` 351 | 352 | And intitialize the player variable with the others: 353 | 354 | ```diff 355 | +let player = {}; 356 | let userInput = null; 357 | let playerTurn = true; 358 | export let gameState = "GAME"; 359 | export let selectedInventoryIndex = 0; 360 | ``` 361 | 362 | Now just make sure to call `initGame` from outside the `newGame` function and add another keybinding: 363 | 364 | ```diff 365 | +initGame(); 366 | 367 | document.addEventListener("keydown", (ev) => { 368 | userInput = ev.key; 369 | }); 370 | 371 | const processUserInput = () => { 372 | if (userInput === "l") { 373 | loadGame(); 374 | } 375 | 376 | + if (userInput === "n") { 377 | + newGame(); 378 | + } 379 | 380 | if (userInput === "s") { 381 | saveGame(); 382 | } 383 | ``` 384 | 385 | ## A bug 386 | 387 | You may have noticed that after creating a new game or loading an existing one, goblins sometimes stop pathing towards the player. There is a small bug in the pathfinding lib that we need to address. 388 | 389 | In `./src/lib/pathfinding.js` make this change: 390 | 391 | ```diff 392 | - const matrix = [...baseMatrix]; 393 | + const matrix = JSON.parse(JSON.stringify(baseMatrix)); 394 | ``` 395 | 396 | Basically our matrix variable wasn't getting cleared properly and instead would combine the old map with the new one. Goblins could only path through areas that happened to be open in both the old and the new maps. Instead of using the spread operator which performs a shallow copy, we are nuking it from orbit and converting the entire thing to a string before parsing it back into an object. 397 | 398 | ## Menu 399 | 400 | Our keybindings have been steadily growing. Let's add a "menu" to clue the player into how to actually play our game. 401 | 402 | First we need to add a new menu property to our grid configuration in `./src/lib/canvas` 403 | 404 | ```diff 405 | export const grid = { 406 | + menu: { 407 | + width: 100, 408 | + height: 1, 409 | + x: 0, 410 | + y: 33, 411 | + }, 412 | ``` 413 | 414 | Then in `./src/systems/render.js` add a function called `renderMenu`. It will just render a single line with our keybindings to the bottom of the screen. No need for anything anymore complex. 415 | 416 | ```javascript 417 | const renderMenu = () => { 418 | drawText({ 419 | text: `(n)New (s)Save (l)Load | (i)Inventory (g)Pickup (arrow keys)Move/Attack (mouse)Look/Target`, 420 | background: "#000", 421 | color: "#666", 422 | x: grid.menu.x, 423 | y: grid.menu.y, 424 | }); 425 | }; 426 | ``` 427 | 428 | And call it from the `render` function: 429 | 430 | ```diff 431 | export const render = (player) => { 432 | renderMap(); 433 | renderPlayerHud(player); 434 | renderMessageLog(); 435 | + renderMenu(); 436 | 437 | if (gameState === "INVENTORY") { 438 | ``` 439 | 440 | ## Game over 441 | 442 | One final addition. You may have noticed that you can't create a new game after getting killed. This is because we don't process user input if the player is dead. Let's put the game into a new state `GAMEOVER` and processUserInput in `./src/index.js`. 443 | 444 | ```diff 445 | if (player.isDead) { 446 | + if (gameState !== "GAMEOVER") { 447 | + addLog("You are dead."); 448 | + render(player); 449 | + } 450 | + gameState = "GAMEOVER"; 451 | + processUserInput(); 452 | return; 453 | } 454 | ``` 455 | 456 | In this part we added saving, loading, and starting over to our game. We also added a small "menu" to inform the player about our growing set of keybindings. Finally we can now properly handle the game over. This game is really coming along! 457 | 458 | In [part 11](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part11.md) we delve deeper as we add more levels to the dungeon! 459 | -------------------------------------------------------------------------------- /tutorial/part11.md: -------------------------------------------------------------------------------- 1 | # Part 11 - Delving into the Dungeon 2 | 3 | Our dungeon lacks depth. There is afterall only a single floor to explore. In this part we will again refactor things - this time to support z levels, and to generate dungeon floors on the fly. We will have persistant randomly generated dungeon floors with stairs to link them. That's a lot to do so let's get started! 4 | 5 | ## Supporting z levels in the cache 6 | 7 | Our cache will need to store the current z level and some details about each floor that has been created. This is how we will be able to generate persistent levels on the way down that we can revisit on the way back up. 8 | 9 | In `./src/state/cache.js`: 10 | 11 | ```diff 12 | +import { get, set } from "lodash"; 13 | + 14 | export const cache = { 15 | entitiesAtLocation: {}, 16 | + z: -1, 17 | + floors: {}, // { z: { stairsUp: '', stairsDown: '' } } 18 | }; 19 | 20 | +export const addCache = (path, value) => { 21 | + set(cache, path, value); 22 | +}; 23 | + 24 | +export const readCache = (path) => get(cache, path); 25 | + 26 | export const addCacheSet = (name, key, value) => { 27 | if (cache[name][key]) { 28 | cache[name][key].add(value); 29 | ``` 30 | 31 | ```diff 32 | export const serializeCache = () => { 33 | const entitiesAtLocation = Object.keys(cache.entitiesAtLocation).reduce( 34 | (acc, val) => { 35 | acc[val] = [...cache.entitiesAtLocation[val]]; 36 | return acc; 37 | }, 38 | {} 39 | ); 40 | 41 | return { 42 | entitiesAtLocation, 43 | + z: cache.z, 44 | + floors: cache.floors, 45 | }; 46 | }; 47 | ``` 48 | 49 | ```diff 50 | export const deserializeCache = (data) => { 51 | cache.entitiesAtLocation = Object.keys(data.entitiesAtLocation).reduce( 52 | (acc, val) => { 53 | acc[val] = new Set(data.entitiesAtLocation[val]); 54 | return acc; 55 | }, 56 | {} 57 | ); 58 | 59 | + cache.z = data.z; 60 | + cache.floors = data.floors; 61 | }; 62 | 63 | export const clearCache = () => { 64 | cache.entitiesAtLocation = {}; 65 | + cache.z = 1; 66 | cache.floors = {}; 67 | }; 68 | ``` 69 | 70 | Before moving on the next section, test your game and make sure everything is still working exactly as it was before. 71 | 72 | ## Refactor Z 73 | 74 | Our cache now supports z levels but the rest of our game does not. That is going to require a pretty hefty refactor. While it would have been nice if we had anticipated this need from the get go - I would argue it's best to code for what you need at the time, not what you might want down the line. Refactoring is just a natural part of the gig. There are 10 files to adjust and for clarity, I have each file as a heading with the changes below it. There's no commentary for the rest of this section. Just grab a cup of coffee or tea and get on it. 75 | 76 | ### `./src/index.js` 77 | 78 | ```diff 79 | import { 80 | clearCache, 81 | deserializeCache, 82 | + readCache, 83 | readCacheSet, 84 | serializeCache, 85 | } from "./state/cache"; 86 | ``` 87 | 88 | ```diff 89 | const loadGame = () => { 90 | const data = JSON.parse(localStorage.getItem("gameSaveData")); 91 | if (!data) { 92 | addLog("Failed to load - no saved games found"); 93 | return; 94 | } 95 | 96 | for (let entity of ecs.entities.all) { 97 | entity.destroy(); 98 | } 99 | + clearCache(); 100 | ``` 101 | 102 | ```diff 103 | const dungeon = createDungeon({ 104 | x: grid.map.x, 105 | y: grid.map.y, 106 | + z: readCache('z'), 107 | width: grid.map.width, 108 | height: grid.map.height, 109 | }); 110 | ``` 111 | 112 | ```diff 113 | times(5, () => { 114 | const tile = sample(openTiles); 115 | - ecs.createPrefab("Goblin").add(Position, { x: tile.x, y: tile.y }); 116 | + ecs.createPrefab("Goblin").add(Position, tile); 117 | }); 118 | 119 | times(10, () => { 120 | const tile = sample(openTiles); 121 | - ecs.createPrefab("HealthPotion").add(Position, { x: tile.x, y: tile.y }); 122 | + ecs.createPrefab("HealthPotion").add(Position, tile); 123 | }); 124 | 125 | times(10, () => { 126 | const tile = sample(openTiles); 127 | - ecs.createPrefab("ScrollLightning").add(Position, { x: tile.x, y: tile.y }); 128 | + ecs.createPrefab("ScrollLightning").add(Position, tile); 129 | }); 130 | 131 | times(10, () => { 132 | const tile = sample(openTiles); 133 | - ecs.createPrefab("ScrollParalyze").add(Position, { x: tile.x, y: tile.y }); 134 | + ecs.createPrefab("ScrollParalyze").add(Position, tile); 135 | }); 136 | 137 | times(10, () => { 138 | const tile = sample(openTiles); 139 | - ecs.createPrefab("ScrollFireball").add(Position, { x: tile.x, y: tile.y }); 140 | + ecs.createPrefab("ScrollFireball").add(Position, tile); 141 | }); 142 | ``` 143 | 144 | ```diff 145 | if (gameState === "GAME") { 146 | if (userInput === "ArrowUp") { 147 | - player.add(Move, { x: 0, y: -1 }); 148 | + player.add(Move, { x: 0, y: -1, z: readCache("z") }); 149 | } 150 | if (userInput === "ArrowRight") { 151 | - player.add(Move, { x: 1, y: 0 }); 152 | + player.add(Move, { x: 1, y: 0, z: readCache("z") }); 153 | } 154 | if (userInput === "ArrowDown") { 155 | - player.add(Move, { x: 0, y: 1 }); 156 | + player.add(Move, { x: 0, y: 1, z: readCache("z") }); 157 | } 158 | if (userInput === "ArrowLeft") { 159 | - player.add(Move, { x: -1, y: 0 }); 160 | + player.add(Move, { x: -1, y: 0, z: readCache("z") }); 161 | } 162 | ``` 163 | 164 | ```diff 165 | canvas.onclick = (e) => { 166 | const [x, y] = pxToCell(e); 167 | - const locId = toLocId({ x, y }); 168 | + const locId = toLocId({ x, y, z: readCache("z") }); 169 | 170 | readCacheSet("entitiesAtLocation", locId).forEach((eId) => { 171 | ``` 172 | 173 | ```diff 174 | if (gameState === "TARGETING") { 175 | const entity = player.inventory.list[selectedInventoryIndex]; 176 | if (entity.requiresTarget.aoeRange) { 177 | - const targets = circle({ x, y }, entity.requiresTarget.aoeRange); 178 | + const targets = circle({ x, y }, entity.requiresTarget.aoeRange).map( 179 | + (locId) => `${locId},${readCache("z")}` 180 | + ); 181 | targets.forEach((locId) => player.add("Target", { locId })); 182 | } else { 183 | player.add("Target", { locId }); 184 | ``` 185 | 186 | ### `./src/lib/dungeon.js` 187 | 188 | ```diff 189 | import { random, times } from "lodash"; 190 | import ecs from "../state/ecs"; 191 | import { rectangle, rectsIntersect } from "./grid"; 192 | import { Position } from "../state/components"; 193 | 194 | -function digHorizontalPassage(x1, x2, y) { 195 | +function digHorizontalPassage(x1, x2, y, z) { 196 | const tiles = {}; 197 | const start = Math.min(x1, x2); 198 | const end = Math.max(x1, x2) + 1; 199 | let x = start; 200 | 201 | while (x < end) { 202 | - tiles[`${x},${y}`] = { x, y, sprite: "FLOOR" }; 203 | + tiles[`${x},${y},${z}`] = { x, y, z, sprite: "FLOOR" }; 204 | x++; 205 | } 206 | 207 | return tiles; 208 | } 209 | 210 | -function digVerticalPassage(y1, y2, x) { 211 | +function digVerticalPassage(y1, y2, x, z) { 212 | const tiles = {}; 213 | const start = Math.min(y1, y2); 214 | const end = Math.max(y1, y2) + 1; 215 | let y = start; 216 | 217 | while (y < end) { 218 | - tiles[`${x},${y}`] = { x, y, sprite: "FLOOR" }; 219 | + tiles[`${x},${y},${z}`] = { x, y, z, sprite: "FLOOR" }; 220 | y++; 221 | } 222 | 223 | return tiles; 224 | } 225 | 226 | export const createDungeon = ({ 227 | x, 228 | y, 229 | + z, 230 | width, 231 | height, 232 | minRoomSize = 6, 233 | maxRoomSize = 12, 234 | maxRoomCount = 30, 235 | }) => { 236 | // fill the entire space with walls so we can dig it out later 237 | const dungeon = rectangle( 238 | - { x, y, width, height }, 239 | + { x, y, z, width, height }, 240 | { 241 | sprite: "WALL", 242 | } 243 | ); 244 | 245 | const rooms = []; 246 | let roomTiles = {}; 247 | 248 | times(maxRoomCount, () => { 249 | let rw = random(minRoomSize, maxRoomSize); 250 | let rh = random(minRoomSize, maxRoomSize); 251 | let rx = random(x, width + x - rw - 1); 252 | let ry = random(y, height + y - rh - 1); 253 | 254 | // create a candidate room 255 | const candidate = rectangle( 256 | - { x: rx, y: ry, width: rw, height: rh, hasWalls: true }, 257 | + { x: rx, y: ry, z, width: rw, height: rh, hasWalls: true }, 258 | { sprite: "FLOOR" } 259 | ); 260 | 261 | // test if candidate is overlapping with any existing rooms 262 | if (!rooms.some((room) => rectsIntersect(room, candidate))) { 263 | rooms.push(candidate); 264 | roomTiles = { ...roomTiles, ...candidate.tiles }; 265 | } 266 | }); 267 | 268 | let prevRoom = null; 269 | let passageTiles; 270 | 271 | for (let room of rooms) { 272 | if (prevRoom) { 273 | const prev = prevRoom.center; 274 | const curr = room.center; 275 | 276 | passageTiles = { 277 | ...passageTiles, 278 | - ...digHorizontalPassage(prev.x, curr.x, curr.y), 279 | - ...digVerticalPassage(prev.y, curr.y, prev.x), 280 | + ...digHorizontalPassage(prev.x, curr.x, curr.y, z), 281 | + ...digVerticalPassage(prev.y, curr.y, prev.x, z), 282 | }; 283 | } 284 | 285 | prevRoom = room; 286 | } 287 | 288 | dungeon.rooms = rooms; 289 | dungeon.tiles = { ...dungeon.tiles, ...roomTiles, ...passageTiles }; 290 | 291 | // create tile entities 292 | Object.keys(dungeon.tiles).forEach((key) => { 293 | const tile = dungeon.tiles[key]; 294 | 295 | if (tile.sprite === "WALL") { 296 | - ecs.createPrefab("Wall").add(Position, dungeon.tiles[key]); 297 | + ecs.createPrefab("Wall").add(Position, { ...dungeon.tiles[key], z }); 298 | } 299 | 300 | if (tile.sprite === "FLOOR") { 301 | - ecs.createPrefab("Floor").add(Position, dungeon.tiles[key]); 302 | + ecs.createPrefab("Floor").add(Position, { ...dungeon.tiles[key], z }); 303 | } 304 | }); 305 | 306 | return dungeon; 307 | }; 308 | ``` 309 | 310 | ### `./src/lib/fov.js` 311 | 312 | ```diff 313 | height, 314 | originX, 315 | originY, 316 | + originZ, 317 | radius 318 | ) { 319 | const visible = new Set(); 320 | 321 | const blockingLocations = new Set(); 322 | - opaqueEntities 323 | - .get() 324 | - .forEach((x) => blockingLocations.add(`${x.position.x},${x.position.y}`)); 325 | + opaqueEntities.get().forEach((x) => { 326 | + if (x.position.z === originZ) { 327 | + blockingLocations.add(`${x.position.x},${x.position.y},${x.position.z}`); 328 | + } 329 | + }); 330 | 331 | const isOpaque = (x, y) => { 332 | - const locId = `${x},${y}`; 333 | + const locId = `${x},${y},${originZ}`; 334 | return !!blockingLocations.has(locId); 335 | }; 336 | const reveal = (x, y) => { 337 | - return visible.add(`${x},${y}`); 338 | + return visible.add(`${x},${y},${originZ}`); 339 | }; 340 | 341 | function castShadows(originX, originY, row, start, end, transform, radius) { 342 | ``` 343 | 344 | ### `./src/lib/grid.js` 345 | 346 | ```diff 347 | -export const rectangle = ({ x, y, width, height, hasWalls }, tileProps) => { 348 | +export const rectangle = ({ x, y, z, width, height, hasWalls }, tileProps) => { 349 | const tiles = {}; 350 | 351 | const x1 = x; 352 | const x2 = x + width - 1; 353 | const y1 = y; 354 | const y2 = y + height - 1; 355 | if (hasWalls) { 356 | for (let yi = y1 + 1; yi <= y2 - 1; yi++) { 357 | for (let xi = x1 + 1; xi <= x2 - 1; xi++) { 358 | - tiles[`${xi},${yi}`] = { x: xi, y: yi, ...tileProps }; 359 | + tiles[`${xi},${yi},${z}`] = { x: xi, y: yi, z, ...tileProps }; 360 | } 361 | } 362 | } else { 363 | for (let yi = y1; yi <= y2; yi++) { 364 | for (let xi = x1; xi <= x2; xi++) { 365 | - tiles[`${xi},${yi}`] = { x: xi, y: yi, ...tileProps }; 366 | + tiles[`${xi},${yi},${z}`] = { x: xi, y: yi, z, ...tileProps }; 367 | } 368 | } 369 | } 370 | 371 | const center = { 372 | x: Math.round((x1 + x2) / 2), 373 | y: Math.round((y1 + y2) / 2), 374 | + z, 375 | }; 376 | 377 | return { x1, x2, y1, y2, center, hasWalls, tiles }; 378 | }; 379 | ``` 380 | 381 | ```diff 382 | export const idToCell = (id) => { 383 | const coords = id.split(","); 384 | - return { x: parseInt(coords[0], 10), y: parseInt(coords[1], 10) }; 385 | + return { x: parseInt(coords[0], 10), y: parseInt(coords[1], 10), z: parseInt(coords[2], 10) }; 386 | }; 387 | 388 | -export const cellToId = ({ x, y }) => `${x},${y}`; 389 | +export const cellToId = ({ x, y, z }) => `${x},${y},${z}`; 390 | ``` 391 | 392 | ### `./src/lib/pathfinding.js` 393 | 394 | ```diff 395 | import PF from "pathfinding"; 396 | import { some, times } from "lodash"; 397 | import ecs from "../state/ecs"; 398 | -import cache, { readCacheSet } from "../state/cache"; 399 | +import { readCache, readCacheSet } from "../state/cache"; 400 | import { toCell } from "./grid"; 401 | import { grid } from "./canvas"; 402 | ``` 403 | 404 | ```diff 405 | export const aStar = (start, goal) => { 406 | const matrix = JSON.parse(JSON.stringify(baseMatrix)); 407 | 408 | - const locIds = Object.keys(cache.entitiesAtLocation); 409 | + const locIds = Object.keys(readCache("entitiesAtLocation")); 410 | locIds.forEach((locId) => { 411 | - if ( 412 | - some([...readCacheSet("entitiesAtLocation", locId)], (eId) => { 413 | - return ecs.getEntity(eId).isBlocking; 414 | - }) 415 | - ) { 416 | - const cell = toCell(locId); 417 | - 418 | - matrix[cell.y][cell.x] = 1; 419 | + const cell = toCell(locId); 420 | + if (cell.z === readCache("z")) { 421 | + if ( 422 | + some([...readCacheSet("entitiesAtLocation", locId)], (eId) => { 423 | + return ecs.getEntity(eId).isBlocking; 424 | + }) 425 | + ) { 426 | + matrix[cell.y][cell.x] = 1; 427 | + } 428 | } 429 | }); 430 | ``` 431 | 432 | ### `./src/state/components.js` 433 | 434 | ```diff 435 | export class Layer400 extends Component {} 436 | 437 | export class Move extends Component { 438 | - static properties = { x: 0, y: 0, relative: true }; 439 | + static properties = { x: 0, y: 0, z: 0, relative: true }; 440 | } 441 | 442 | export class Paralyzed extends Component {} 443 | 444 | export class Position extends Component { 445 | - static properties = { x: 0, y: 0 }; 446 | + static properties = { x: 0, y: 0, z: -1 }; 447 | 448 | onAttached() { 449 | - const locId = `${this.entity.position.x},${this.entity.position.y}`; 450 | + const locId = `${this.entity.position.x},${this.entity.position.y},${this.entity.position.z}`; 451 | addCacheSet("entitiesAtLocation", locId, this.entity.id); 452 | } 453 | 454 | onDetached() { 455 | - const locId = `${this.x},${this.y}`; 456 | + const locId = `${this.x},${this.y},${this.z}`; 457 | deleteCacheSet("entitiesAtLocation", locId, this.entity.id); 458 | } 459 | } 460 | ``` 461 | 462 | ### `./src/systems/ai.js` 463 | 464 | ```diff 465 | import ecs from "../state/ecs"; 466 | import { Ai, Description } from "../state/components"; 467 | import { aStar } from "../lib/pathfinding"; 468 | +import { readCache } from "../state/cache"; 469 | 470 | const aiEntities = ecs.createQuery({ 471 | all: [Ai, Description], 472 | }); 473 | 474 | const moveToTarget = (entity, target) => { 475 | const path = aStar(entity.position, target.position); 476 | if (path.length) { 477 | const newLoc = path[1]; 478 | - entity.add("Move", { x: newLoc[0], y: newLoc[1], relative: false }); 479 | + entity.add("Move", { x: newLoc[0], y: newLoc[1], z: readCache("z"), relative: false }); 480 | } 481 | }; 482 | ``` 483 | 484 | ### `./src/systems.fov.js` 485 | 486 | ```diff 487 | import { grid } from "../lib/canvas"; 488 | import createFOV from "../lib/fov"; 489 | import { IsInFov, IsOpaque, IsRevealed } from "../state/components"; 490 | +import { readCache } from "../state/cache"; 491 | ``` 492 | 493 | ```diff 494 | - const FOV = createFOV(opaqueEntities, width, height, originX, originY, 10); 495 | + const FOV = createFOV(opaqueEntities, width, height, originX, originY, readCache("z"), 10); 496 | ``` 497 | 498 | ### `./src/systems/movement.js` 499 | 500 | ```diff 501 | let mx = entity.move.x; 502 | let my = entity.move.y; 503 | +let mz = entity.move.z; 504 | 505 | if (entity.move.relative) { 506 | mx = entity.position.x + entity.move.x; 507 | ``` 508 | 509 | ```diff 510 | // check for blockers 511 | const blockers = []; 512 | // read from cache 513 | - const entitiesAtLoc = readCacheSet("entitiesAtLocation", `${mx},${my}`); 514 | + const entitiesAtLoc = readCacheSet("entitiesAtLocation", `${mx},${my},${mz}`); 515 | 516 | for (const eId of entitiesAtLoc) { 517 | ``` 518 | 519 | ```diff 520 | entity.remove("Position"); 521 | -entity.add("Position", { x: mx, y: my }); 522 | +entity.add("Position", { x: mx, y: my, z: mz }); 523 | 524 | entity.remove(Move); 525 | ``` 526 | 527 | ### `./src/systems/render.js` 528 | 529 | ```diff 530 | import { toLocId } from "../lib/grid"; 531 | -import { readCacheSet } from "../state/cache"; 532 | +import { readCache, readCacheSet } from "../state/cache"; 533 | import { gameState, messageLog, selectedInventoryIndex } from "../index"; 534 | ``` 535 | 536 | ```diff 537 | const renderInfoBar = (mPos) => { 538 | clearInfoBar(); 539 | 540 | - const { x, y } = mPos; 541 | - const locId = toLocId({ x, y }); 542 | + const { x, y, z } = mPos; 543 | + const locId = toLocId({ x, y, z }); 544 | 545 | const esAtLoc = readCacheSet("entitiesAtLocation", locId) || []; 546 | const entitiesAtLoc = [...esAtLoc]; 547 | 548 | clearInfoBar(); 549 | 550 | if (entitiesAtLoc) { 551 | if (some(entitiesAtLoc, (eId) => ecs.getEntity(eId).isRevealed)) { 552 | drawCell({ 553 | appearance: { 554 | char: "", 555 | background: "rgba(255, 255, 255, 0.5)", 556 | }, 557 | - position: { x, y }, 558 | + position: { x, y, z }, 559 | }); 560 | } 561 | ``` 562 | 563 | ```diff 564 | const renderTargeting = (mPos) => { 565 | - const { x, y } = mPos; 566 | + const { x, y, z } = mPos; 567 | - const locId = toLocId({ x, y }); 568 | + const locId = toLocId({ x, y, z }); 569 | 570 | const esAtLoc = readCacheSet("entitiesAtLocation", locId) || []; 571 | const entitiesAtLoc = [...esAtLoc]; 572 | 573 | clearInfoBar(); 574 | 575 | if (entitiesAtLoc) { 576 | if (some(entitiesAtLoc, (eId) => ecs.getEntity(eId).isRevealed)) { 577 | drawCell({ 578 | appearance: { 579 | char: "", 580 | background: "rgba(74, 232, 218, 0.5)", 581 | }, 582 | - position: { x, y }, 583 | + position: { x, y, z }, 584 | }); 585 | } 586 | } 587 | }; 588 | ``` 589 | 590 | ```diff 591 | const canvas = document.querySelector("#canvas"); 592 | canvas.onmousemove = throttle((e) => { 593 | if (gameState === "GAME") { 594 | const [x, y] = pxToCell(e); 595 | renderMap(); 596 | - renderInfoBar({ x, y }); 597 | + renderInfoBar({ x, y, z: readCache("z") }); 598 | } 599 | 600 | if (gameState === "TARGETING") { 601 | const [x, y] = pxToCell(e); 602 | renderMap(); 603 | - renderTargeting({ x, y }); 604 | + renderTargeting({ x, y, z: readCache("z") }); 605 | } 606 | }, 50); 607 | ``` 608 | 609 | Like any good refactor at the end everything should be working just like it was before! Run the game and make sure that's the case before moving on. 610 | 611 | ## Digging deeper 612 | 613 | With the big refactor out of the way let's go ahead and make our stair prefabs. We'll need to add one for each direction in `./src/state/prefabs.js` 614 | 615 | ```javascript 616 | export const StairsUp = { 617 | name: "StairsUp", 618 | inherit: ["Tile"], 619 | components: [ 620 | { 621 | type: "Appearance", 622 | properties: { char: "<", color: "#AAA" }, 623 | }, 624 | { 625 | type: "Description", 626 | properties: { name: "set of stairs leading up" }, 627 | }, 628 | ], 629 | }; 630 | 631 | export const StairsDown = { 632 | name: "StairsDown", 633 | inherit: ["Tile"], 634 | components: [ 635 | { 636 | type: "Appearance", 637 | properties: { char: ">", color: "#AAA" }, 638 | }, 639 | { 640 | type: "Description", 641 | properties: { name: "set of stairs leading down" }, 642 | }, 643 | ], 644 | }; 645 | ``` 646 | 647 | Go ahead and register them in `./src/state/ecs.js` 648 | 649 | ```diff 650 | Wall, 651 | Floor, 652 | + StairsUp, 653 | + StairsDown, 654 | } from "./prefabs"; 655 | ``` 656 | 657 | ```diff 658 | ecs.registerPrefab(ScrollParalyze); 659 | +ecs.registerPrefab(StairsUp); 660 | +ecs.registerPrefab(StairsDown); 661 | 662 | export default ecs; 663 | ``` 664 | 665 | We want to generate levels as they are needed - when a players descends to a new and deeper depth. Currently we only generate a single level in `addCache,`. We'll need to break that up into a few different functions that can be called as needed. 666 | 667 | In `./src/index.js` we need to first import a couple functions we'll use in a bit. 668 | 669 | ```diff 670 | import "./lib/canvas.js"; 671 | import { grid, pxToCell } from "./lib/canvas"; 672 | -import { toLocId, circle } from "./lib/grid"; 673 | +import { toCell, toLocId, circle } from "./lib/grid"; 674 | import { 675 | + addCache, 676 | clearCache, 677 | deserializeCache, 678 | ``` 679 | 680 | Next go ahead and delete the entire `initGame` function. 681 | 682 | ```diff 683 | -const initGame = () => { 684 | - // init game map and player position 685 | - const dungeon = createDungeon({ 686 | - x: grid.map.x, 687 | - y: grid.map.y, 688 | - z: readCache("z"), 689 | - width: grid.map.width, 690 | - height: grid.map.height, 691 | - }); 692 | - 693 | - player = ecs.createPrefab("Player"); 694 | - player.add(Position, { 695 | - x: dungeon.rooms[0].center.x, 696 | - y: dungeon.rooms[0].center.y, 697 | - }); 698 | - 699 | - const openTiles = Object.values(dungeon.tiles).filter( 700 | - (x) => x.sprite === "FLOOR" 701 | - ); 702 | - 703 | - times(5, () => { 704 | - const tile = sample(openTiles); 705 | - ecs.createPrefab("Goblin").add(Position, tile); 706 | - }); 707 | - 708 | - times(10, () => { 709 | - const tile = sample(openTiles); 710 | - ecs.createPrefab("HealthPotion").add(Position, tile); 711 | - }); 712 | - 713 | - times(10, () => { 714 | - const tile = sample(openTiles); 715 | - ecs.createPrefab("ScrollLightning").add(Position, tile); 716 | - }); 717 | - 718 | - times(10, () => { 719 | - const tile = sample(openTiles); 720 | - ecs.createPrefab("ScrollParalyze").add(Position, tile); 721 | - }); 722 | - 723 | - times(10, () => { 724 | - const tile = sample(openTiles); 725 | - ecs.createPrefab("ScrollFireball").add(Position, tile); 726 | - }); 727 | - 728 | - fov(player); 729 | - render(player); 730 | -}; 731 | ``` 732 | 733 | In it's place add a new function `createDungeonLevel`: 734 | 735 | ```javascript 736 | const createDungeonLevel = ({ 737 | createStairsUp = true, 738 | createStairsDown = true, 739 | } = {}) => { 740 | const dungeon = createDungeon({ 741 | x: grid.map.x, 742 | y: grid.map.y, 743 | z: readCache("z"), 744 | width: grid.map.width, 745 | height: grid.map.height, 746 | }); 747 | 748 | const openTiles = Object.values(dungeon.tiles).filter( 749 | (x) => x.sprite === "FLOOR" 750 | ); 751 | 752 | times(5, () => { 753 | const tile = sample(openTiles); 754 | ecs.createPrefab("Goblin").add(Position, tile); 755 | }); 756 | 757 | times(10, () => { 758 | const tile = sample(openTiles); 759 | ecs.createPrefab("HealthPotion").add(Position, tile); 760 | }); 761 | 762 | times(10, () => { 763 | const tile = sample(openTiles); 764 | ecs.createPrefab("ScrollLightning").add(Position, tile); 765 | }); 766 | 767 | times(10, () => { 768 | const tile = sample(openTiles); 769 | ecs.createPrefab("ScrollParalyze").add(Position, tile); 770 | }); 771 | 772 | times(10, () => { 773 | const tile = sample(openTiles); 774 | ecs.createPrefab("ScrollFireball").add(Position, tile); 775 | }); 776 | 777 | let stairsUp, stairsDown; 778 | 779 | if (createStairsUp) { 780 | times(1, () => { 781 | const tile = sample(openTiles); 782 | stairsUp = ecs.createPrefab("StairsUp"); 783 | stairsUp.add(Position, tile); 784 | }); 785 | } 786 | 787 | if (createStairsDown) { 788 | times(1, () => { 789 | const tile = sample(openTiles); 790 | stairsDown = ecs.createPrefab("StairsDown"); 791 | stairsDown.add(Position, tile); 792 | }); 793 | } 794 | 795 | return { dungeon, stairsUp, stairsDown }; 796 | }; 797 | ``` 798 | 799 | This function now contains all the dungeon creating logic that was in `initGame`. In addition to that it can optionally add stairs - we won't want another downstairs on the last level or an upstairs on the first. 800 | 801 | Next add another function called `goToDungeonLevel` 802 | 803 | ```javascript 804 | const goToDungeonLevel = (level) => { 805 | const goingUp = readCache("z") < level; 806 | const floor = readCache("floors")[level]; 807 | 808 | if (floor) { 809 | addCache("z", level); 810 | player.remove(Position); 811 | if (goingUp) { 812 | player.add(Position, toCell(floor.stairsDown)); 813 | } else { 814 | player.add(Position, toCell(floor.stairsUp)); 815 | } 816 | } else { 817 | addCache("z", level); 818 | const { stairsUp, stairsDown } = createDungeonLevel(); 819 | 820 | addCache(`floors.${level}`, { 821 | stairsUp: toLocId(stairsUp.position), 822 | stairsDown: toLocId(stairsDown.position), 823 | }); 824 | 825 | player.remove(Position); 826 | 827 | if (goingUp) { 828 | player.add(Position, toCell(stairsDown.position)); 829 | } else { 830 | player.add(Position, toCell(stairsUp.position)); 831 | } 832 | } 833 | 834 | fov(player); 835 | render(player); 836 | }; 837 | ``` 838 | 839 | This function is responsible for loading a level if it already exists or creating a new one if needed. At the top of the function we store two variables `goingUp` and `floor`. The variable `floor` is used to check if the floor exists or not. If it does we check `goingUp` to decide at which stairs to place the player. If `floor` does not exist we need to create one and add the stairs to cache. Finally we run the `fov` and `render` systems. 840 | 841 | Now we can add back the `initGame` function, far smaller this time. 842 | 843 | ```javascript 844 | const initGame = () => { 845 | const { stairsDown } = createDungeonLevel({ createStairsUp: false }); 846 | 847 | player = ecs.createPrefab("Player"); 848 | 849 | addCache(`floors.${-1}`, { 850 | stairsDown: toLocId(stairsDown.position), 851 | }); 852 | 853 | player.add(Position, stairsDown.position); 854 | 855 | fov(player); 856 | render(player); 857 | }; 858 | ``` 859 | 860 | The `initGame` function is now only responsible for kicking off `createDungeonLevel`, creating the player and adding some things to cache. 861 | 862 | With our code broken into reusable functions we can now add our keybindings in to call goToDungeonLevel and get our stairs working! 863 | 864 | ```diff 865 | if (gameState === "GAME") { 866 | + if (userInput === ">") { 867 | + if ( 868 | + toLocId(player.position) == 869 | + readCache(`floors.${readCache("z")}.stairsDown`) 870 | + ) { 871 | + addLog("You descend deeper into the dungeon"); 872 | + goToDungeonLevel(readCache("z") - 1); 873 | + } else { 874 | + addLog("There are no stairs to descend"); 875 | + } 876 | + } 877 | + 878 | + if (userInput === "<") { 879 | + if ( 880 | + toLocId(player.position) == readCache(`floors.${readCache("z")}.stairs`) 881 | + ) { 882 | + addLog("You climb from the depths of the dungeon"); 883 | + goToDungeonLevel(readCache("z") + 1); 884 | + } else { 885 | + addLog("There are no stairs to climb"); 886 | + } 887 | + } 888 | 889 | if (userInput === "ArrowUp") { 890 | player.add(Move, { x: 0, y: -1, z: readCache("z") }); 891 | } 892 | ``` 893 | 894 | Before you test it out - our gameloop interprets any keypress as an input. If the key is not bound to anything it acts as a skip of a turn. For this reason we need to ignore the `shift` key in order to be able to type `<` and `>` without skipping a turn and giving the baddies an extra go. 895 | 896 | ```diff 897 | document.addEventListener("keydown", (ev) => { 898 | - userInput = ev.key; 899 | + if (ev.key !== "Shift") { 900 | + userInput = ev.key; 901 | + } 902 | }); 903 | ``` 904 | 905 | That's it! Your stairs should now be working and properly link levels together! 906 | 907 | Whoops! We have a bug - the previous levels are still rendering... 908 | 909 | Fortunately this is an easy fix. In `./src/systems/render.js` we need to check the z level of entities before rendering. 910 | 911 | ```diff 912 | clearMap(); 913 | 914 | layer100Entities.get().forEach((entity) => { 915 | + if (entity.position.z !== readCache("z")) return; 916 | + 917 | if (entity.isInFov) { 918 | drawCell(entity); 919 | } else { 920 | ``` 921 | 922 | ```diff 923 | }); 924 | 925 | layer300Entities.get().forEach((entity) => { 926 | + if (entity.position.z !== readCache("z")) return; 927 | + 928 | if (entity.isInFov) { 929 | drawCell(entity); 930 | } else { 931 | ``` 932 | 933 | ```diff 934 | }); 935 | 936 | layer400Entities.get().forEach((entity) => { 937 | + if (entity.position.z !== readCache("z")) return; 938 | + 939 | if (entity.isInFov) { 940 | drawCell(entity); 941 | } else { 942 | ``` 943 | 944 | ## A bit of UI 945 | 946 | Now that everything is working properly, it would be nice to see what level we're at. We should also enhance our "menu" to show the new key commands for using stairs. 947 | 948 | In `./src/systems/render.js` call drawText again at the end of the function `renderPlayerHud` to add the current "depth". 949 | 950 | ```javascript 951 | drawText({ 952 | text: `Depth: ${Math.abs(readCache("z"))}`, 953 | background: "black", 954 | color: "#666", 955 | x: grid.playerHud.x, 956 | y: grid.playerHud.y + 2, 957 | }); 958 | ``` 959 | 960 | Next we need to add more keybindings to our "menu". Unfortunalely we're running out of space and can't fit the text required. We could solve this a number of different ways. We could make a proper menu like Inventory to give us a lot more real estate. We could remove the need for keybindings at all and have the stairs activate when a player bumps into the them. For this tutorial we're gonna take the cheap way out and just increase the height of our game by one. That will allow us to have 2 lines for our menu. 961 | 962 | ```diff 963 | const renderMenu = () => { 964 | drawText({ 965 | - text: `(n)New (s)Save (l)Load | (i)Inventory (g)Pickup (arrow keys)Move/Attack (mouse)Look/Target`, 966 | + text: `(i)Inventory (g)Pickup (arrow keys)Move/Attack (mouse)Look/Target (<)Stairs Up (>)Stairs Down`, 967 | background: "#000", 968 | color: "#666", 969 | x: grid.menu.x, 970 | y: grid.menu.y, 971 | }); 972 | + 973 | + drawText({ 974 | + text: `(n)New (s)Save (l)Load`, 975 | + background: "#000", 976 | + color: "#666", 977 | + x: grid.menu.x, 978 | + y: grid.menu.y + 1, 979 | + }); 980 | }; 981 | ``` 982 | 983 | And in `./src/lib/canvas.js` 984 | 985 | ```diff 986 | export const grid = { 987 | width: 100, 988 | - height: 34, 989 | + height: 35, 990 | ``` 991 | 992 | In this part we made it through yet another big refactor. We now have stairs in our game leading to auto generated levels that are stored in cache so we can revisit them as we ascend the dungeon. 993 | 994 | In the next part we'll start to balance things out a little bit. Enemies will be easier in the early levels and get harder and harder as you descend. 995 | -------------------------------------------------------------------------------- /tutorial/part2.md: -------------------------------------------------------------------------------- 1 | # Part 2 - The generic Entity, the render functions, and the map 2 | 3 | For this tutorial we will be using an ECS architecture. A lot of folks will probably say this is overkill for the size of our project but I have found it to be the easiest architecture to reason about once games reach anything beyond basic complexity. If you choose to expand on this project after the tutorial you'll have a decent base to do so. We won't be writing our own ECS engine, we will instead be relying on the excellent [geotic](https://github.com/ddmills/geotic) for that. But before we install anything and start refactoring our game we should probably talk a bit about what an ECS architecture is and why you would choose to use one. 4 | 5 | To state the obvious, games are complicated! My first 4 or 5 attempts experimented with all sorts of ways to manage state. Every one of those games started simple and eventually fell apart when adding new features became too complex. 6 | 7 | You shouldn't have to write complex code to do complex things. With ECS, complexity arises from simplicity. Follow a few simple rules; get complex behavior. 8 | 9 | For a detailed overview of ECS in practice Thomas Biskup (ADOM) gave a great talk at the 2018 Roguelike Celebration. [There be dragons: Entity Component Systems for Roguelikes](https://www.youtube.com/watch?v=fGLJC5UY2o4&feature=youtu.be) 10 | 11 | For a formal and rather dry definition we can turn to wikipedia: 12 | 13 | > ECS follows the composition over inheritance principle that allows greater flexibility in defining entities where every object in a game's scene is an entity (e.g. enemies, bullets, vehicles, etc.). Every entity consists of one or more components which contains data or state. Therefore, the behavior of an entity can be changed at runtime by systems that add, remove or mutate components. This eliminates the ambiguity problems of deep and wide inheritance hierarchies that are difficult to understand, maintain and extend. - [wikipedia](https://en.wikipedia.org/wiki/Entity_component_system) 14 | 15 | At it's core ECS is just a way to manage your application state. State is stored in components, entities are collections of those components, and systems run logic on those entities in order to add, remove, and mutate their components. 16 | 17 | As our game grows in scope I think you will find that these 3 simple rules will help to manage the underlying complexity of it all leaving us to follow our inspiration and just make a game! 18 | 19 | Enough talk, let's install geotic, create our first entity and learn how to do things the ECS way! 20 | 21 | --- 22 | 23 | Before we get into it, go ahead and install geotic and start the engine. 24 | 25 | ```bash 26 | npm install geotic 27 | ``` 28 | 29 | Next let's create a new folder `./src/state` and add a file called `ecs.js` at `./src/state/ecs.js`. Now we can import the Engine from geotic and start it up. 30 | 31 | ```javascript 32 | import { Engine } from "geotic"; 33 | 34 | const ecs = new Engine(); 35 | 36 | export default ecs; 37 | ``` 38 | 39 | Currently we store all of the state of our hero in the player object. To move around we directly mutate it's position values. Right now everything is very simple and easy to understand. Unfortunately this pattern won't scale very well if we were to add an NPC, a few monsters, maybe an item or two... mutating state directly like this very quickly becomes cumbersome, complicated, and prone to bugs that are hard to diagnose. Not to mention saving and loading... as our game scales up we would have to figure out how to build and rebuild state for every single piece. 40 | 41 | We're going to refactor our game to do everything it already does - draw the @ symbol and move it around - the ECS way. At first it's going to seem like a lot of code. The benefit though is that as we add NPCs, monsters, and items, the complexity of our code won't explode. 42 | 43 | To start let's look at our player object and see how we can translate it to components: 44 | 45 | ```javascript 46 | const player = { 47 | char: "@", 48 | color: "white", 49 | position: { 50 | x: 0, 51 | y: 0, 52 | }, 53 | }; 54 | ``` 55 | 56 | Components are just containers used to store bits of state. Our player object is only concerned with two things so far, appearance (char, color) and position. Let's to compoenents to track these bits of state, Appearance and Position. A generic components we will be able to use them not just for our player but also for goblins, items, walls, anything we can see and pin to a specific location! 57 | 58 | First create a file to hold our components called `components.js` at `./src/state/components.js`. In a larger application you may want to create individual files for each component but this is fine for our purposes. 59 | 60 | Make `./src/state/components.js` look like this: 61 | 62 | ```javascript 63 | import { Component } from "geotic"; 64 | 65 | export class Appearance extends Component { 66 | static properties = { 67 | color: "#ff0077", 68 | char: "?", 69 | }; 70 | } 71 | 72 | export class Position extends Component { 73 | static properties = { x: 0, y: 0 }; 74 | } 75 | ``` 76 | 77 | This is a pretty simple file so far. We just define two components that each set some default static properties. A lot of our components will be very small like these, a few might be a bit bigger but most will be even smaller. Components are _supposed_ to be simple! 78 | 79 | Before we make our first Entity we need to remember to register our new components with geotic. We will do that in `./src/state/ecs.js`. 80 | 81 | ```diff 82 | import { Engine } from "geotic"; 83 | +import { Appearance, Position } from "./components"; 84 | 85 | const ecs = new Engine(); 86 | 87 | +// all Components must be `registered` by the engine 88 | +ecs.registerComponent(Appearance); 89 | +ecs.registerComponent(Position); 90 | 91 | export default ecs; 92 | ``` 93 | 94 | We're ready to make our first Entity! 95 | 96 | Just below where we register our components in `./src/state/ecs.js` we can create an empty entity for our player and then add our components to it. 97 | 98 | ```diff 99 | ecs.registerComponent(Position); 100 | 101 | +const player = ecs.createEntity(); 102 | +player.add(Appearance, { char: "@", color: "#fff" }); 103 | +player.add(Position); 104 | 105 | export default ecs; 106 | ``` 107 | 108 | The add method takes two arguments, a component, and a properties object to override any of the static defaults. You'll notice we don't pass a second argument when we add the Position component because the default properties are just fine. 109 | 110 | We now have the (E)ntity and (C)omponent parts of ECS but what about the (S)ystem? We've seen how geotic provides classes for entities and components but it doesn't do that for systems. A system is just a function that iterates over a collection of entities. You can do whatever you want/need to in one but how you implement it is entirely up to you. 111 | 112 | Our first system will render our player entity to the screen. Let's create a new folder `./src/systems` and add a file called `render.js` at `./src/systems/render.js`. We could have our systems iterate over every single entity in the game at every tick but as you can imagine that's gonna get pretty inefficient as we add more systems. We can instead narrow our focus with geotic queries. A query is an always up to date set of entities that have a specified set of components. 113 | 114 | Make `./src/systems/render.js` look like this: 115 | 116 | ```javascript 117 | import ecs from "../state/ecs"; 118 | import { Appearance, Position } from "../state/components"; 119 | 120 | const renderableEntities = ecs.createQuery({ 121 | all: [Position, Appearance], 122 | }); 123 | ``` 124 | 125 | `renderableEntities` will keep track of all entities that contain both the Position _and_ Appearance components. Let's use this query to loop through all renderableEntities and log the result to our javascript console. Go ahead and add the actual system at the end of `./src/systems/render.js`. 126 | 127 | ```javascript 128 | export const render = () => { 129 | renderableEntities.get().forEach((entity) => { 130 | console.log(entity); 131 | }); 132 | }; 133 | ``` 134 | 135 | Next we need to actually call our render system. At the top of `./src/index.js` import the system like this: 136 | 137 | ```diff 138 | import { clearCanvas, drawChar } from "./lib/canvas"; 139 | +import { render } from "./systems/render"; 140 | 141 | const player = { 142 | ``` 143 | 144 | Then at the end of the file we can call it like this: 145 | 146 | ```diff 147 | clearCanvas(); 148 | drawChar(player); 149 | + render(); 150 | }; 151 | ``` 152 | 153 | Start the game with `npm start` if it's not already running and open up your browser's javascript console. Now every time you hit a key you should see all renderable entities logged to the console. There's only the one so far but this sort of thing is a good habit to get into just to prove that things are working as expected. 154 | 155 | Now that our ECS engine is firing on all cylinders it's time to finally make it do something useful. Let's make our render system actually render! 156 | 157 | Instead of logging each entity from the system we can use our drawChar function to draw them instead. We need to add a few things to `./src/systems/render.js` to do that. 158 | 159 | ```diff 160 | import ecs from "../state/ecs"; 161 | import { Appearance, Position } from "../state/components"; 162 | +import { clearCanvas, drawChar } from "../lib/canvas"; 163 | 164 | const renderableEntities = ecs.createQuery({ 165 | all: [Position, Appearance], 166 | }); 167 | 168 | export const render = () => { 169 | + clearCanvas() 170 | + 171 | renderableEntities.get().forEach((entity) => { 172 | - console.log(entity); 173 | + const { appearance, position } = entity; 174 | + const { char, color } = appearance; 175 | 176 | + drawChar({ char, color, position }); 177 | }); 178 | }; 179 | ``` 180 | 181 | Next we can go clean up a couple things in `./src/index.js`. We can remove the player object now that we have are storing the player as an entity. And we also don't need to call drawChar or clearCanvas anymore. 182 | 183 | ```diff 184 | import "./lib/canvas.js"; 185 | -import { clearCanvas, drawChar } from "./lib/canvas"; 186 | import { render } from "./systems/render"; 187 | 188 | -const player = { 189 | - char: "@", 190 | - color: "white", 191 | - position: { 192 | - x: 0, 193 | - y: 0, 194 | - }, 195 | -}; 196 | 197 | -drawChar(player); 198 | +render(); 199 | 200 | let userInput = null; 201 | 202 | document.addEventListener("keydown", (ev) => { 203 | userInput = ev.key; 204 | processUserInput(); 205 | }); 206 | 207 | const processUserInput = () => { 208 | if (userInput === "ArrowUp") { 209 | player.position.y -= 1; 210 | } 211 | if (userInput === "ArrowRight") { 212 | player.position.x += 1; 213 | } 214 | if (userInput === "ArrowDown") { 215 | player.position.y += 1; 216 | } 217 | if (userInput === "ArrowLeft") { 218 | player.position.x -= 1; 219 | } 220 | 221 | - clearCanvas(); 222 | - drawChar(player); 223 | render(); 224 | }; 225 | 226 | ``` 227 | 228 | Ok - if we try to run the game now we should see our @ symbol in the top left but it no longer moves. If you look in the javascriopt console, you'll see it's lit up with errors. We deleted our player object but still reference it in processUserInput. We need to think about how to process user input the ECS way. 229 | 230 | Moving an entity from one position to another is fraught with peril. What if there is a wall, or a trap, or a monster, or the entity is paralyzed, or mind controlled... What we would like to have is a generic way to let our game know where an entity intends to move, and then let the our game resolve what actually happens. To do this we will be adding an additional component and system. 231 | 232 | Lets start by adding another component to `./src/state/components`. The order here doesn't really matter, I just like to keep them in alphabetical. :P 233 | 234 | ```diff 235 | import { Component } from "geotic"; 236 | 237 | export class Appearance extends Component { 238 | static properties = { 239 | color: "#ff0077", 240 | char: "?", 241 | }; 242 | } 243 | 244 | +export class Move extends Component { 245 | + static properties = { x: 0, y: 0 } 246 | +} 247 | 248 | export class Position extends Component { 249 | static properties = { x: 0, y: 0 }; 250 | } 251 | 252 | ``` 253 | 254 | Don't forget to register it in `./src/state/ecs`! 255 | 256 | ```diff 257 | import { Engine } from "geotic"; 258 | -import { Appearance, Position } from "./components"; 259 | +import { Appearance, Move, Position } from "./components"; 260 | 261 | const ecs = new Engine(); 262 | 263 | // all Components must be `registered` by the engine 264 | ecs.registerComponent(Appearance); 265 | +ecs.registerComponent(Move); 266 | ecs.registerComponent(Position); 267 | 268 | const player = ecs.createEntity(); 269 | 270 | player.add(Appearance, { char: "@", color: "#fff" }); 271 | player.add(Position); 272 | 273 | export default ecs; 274 | ``` 275 | 276 | Alright! Now we need to add this component to the player entity in processUserInput. To do that we first need to export the player entity from './src/state/ecs` 277 | 278 | ```diff 279 | ecs.registerComponent(Position); 280 | 281 | -const player = ecs.createEntity(); 282 | +export const player = ecs.createEntity(); 283 | 284 | player.add(Appearance, { char: "@", color: "#fff" }); 285 | ``` 286 | 287 | Now instead of directly manipulating the player entity we will add a Move component to it and handle the logic of actually moving in a system. Make these changes to `./src/index.js`. 288 | 289 | ```diff 290 | import "./lib/canvas.js"; 291 | import { render } from "./systems/render"; 292 | +import { player } from "./state/ecs"; 293 | +import { Move } from "./state/components"; 294 | 295 | render(); 296 | 297 | let userInput = null; 298 | 299 | document.addEventListener("keydown", (ev) => { 300 | userInput = ev.key; 301 | processUserInput(); 302 | }); 303 | 304 | const processUserInput = () => { 305 | if (userInput === "ArrowUp") { 306 | - player.position.y -= 1; 307 | + player.add(Move, { x: 0, y: -1 }); 308 | } 309 | if (userInput === "ArrowRight") { 310 | - player.position.x += 1; 311 | + player.add(Move, { x: 1, y: 0 }); 312 | } 313 | if (userInput === "ArrowDown") { 314 | - player.position.y += 1; 315 | + player.add(Move, { x: 0, y: 1 }); 316 | } 317 | if (userInput === "ArrowLeft") { 318 | - player.position.x -= 1; 319 | + player.add(Move, { x: -1, y: 0 }); 320 | } 321 | 322 | render(); 323 | }; 324 | ``` 325 | 326 | Almost there - we just need add our system. Create a new file called `movement.js` at `./src/systems/movement.js`. It should look like this: 327 | 328 | ```javascript 329 | import ecs from "../state/ecs"; 330 | import { Move } from "../state/components"; 331 | 332 | const movableEntities = ecs.createQuery({ 333 | all: [Move], 334 | }); 335 | 336 | export const movement = () => { 337 | movableEntities.get().forEach((entity) => { 338 | const mx = entity.position.x + entity.move.x; 339 | const my = entity.position.y + entity.move.y; 340 | 341 | // this is where we will run any checks to see if entity can move to new location 342 | 343 | entity.position.x = mx; 344 | entity.position.y = my; 345 | 346 | entity.remove(Move); 347 | }); 348 | }; 349 | ``` 350 | 351 | Just like in our render system we create a query at the top so we only have to loop over entities that actually intend to move. We then calculate the actual position the entity is trying to enter and update the entity with that new position. You can see where we will eventually check for walls, traps, monsters, whatever. 352 | 353 | The last thing we have to do is import our movement system and call it in './src/index.js' 354 | 355 | ```diff 356 | import "./lib/canvas.js"; 357 | +import { movement } from "./systems/movement"; 358 | import { render } from "./systems/render"; 359 | import { player } from "./state/ecs"; 360 | import { Move } from "./state/components"; 361 | 362 | render(); 363 | 364 | let userInput = null; 365 | 366 | document.addEventListener("keydown", (ev) => { 367 | userInput = ev.key; 368 | processUserInput(); 369 | }); 370 | 371 | const processUserInput = () => { 372 | if (userInput === "ArrowUp") { 373 | player.add(Move, { x: 0, y: -1 }); 374 | } 375 | if (userInput === "ArrowRight") { 376 | player.add(Move, { x: 1, y: 0 }); 377 | } 378 | if (userInput === "ArrowDown") { 379 | player.add(Move, { x: 0, y: 1 }); 380 | } 381 | if (userInput === "ArrowLeft") { 382 | player.add(Move, { x: -1, y: 0 }); 383 | } 384 | 385 | + movement(); 386 | render(); 387 | }; 388 | ``` 389 | 390 | We're finally right back where we started! Your @ can move again! 391 | 392 | --- 393 | 394 | OK, that was a lot to get through for the same result I know, but it'll be worth in the end. We have one more thing to do before we're done with part 2. We need a map to walk on. This will be fun because we'll get to actually flex our systems a bit and use all that work we just did! 395 | 396 | To start let's create another file in `./src/lib` called `grid.js` at `./src/lib/grid.js`. It going to contain a bunch of utility functions for dealing with math on a square grid. Most of the functions here are javascript implementations based on the pseudocode from [redblobgames](https://www.redblobgames.com/). I'm not going to go over any of the logic in this file. If you're curious how these functions work I highly encourage you to read the articles on redblobgames. In fact, just bookmark it now. **It is an amazing resource**. 397 | 398 | OK, go ahead and just paste this into `./src/lib/grid.js`: 399 | 400 | ```javascript 401 | import { grid } from "../lib/canvas"; 402 | import { sample } from "lodash"; 403 | 404 | export const CARDINAL = [ 405 | { x: 0, y: -1 }, // N 406 | { x: 1, y: 0 }, // E 407 | { x: 0, y: 1 }, // S 408 | { x: -1, y: 0 }, // W 409 | ]; 410 | 411 | export const DIAGONAL = [ 412 | { x: 1, y: -1 }, // NE 413 | { x: 1, y: 1 }, // SE 414 | { x: -1, y: 1 }, // SW 415 | { x: -1, y: -1 }, // NW 416 | ]; 417 | 418 | export const ALL = [...CARDINAL, ...DIAGONAL]; 419 | 420 | export const toCell = (cellOrId) => { 421 | let cell = cellOrId; 422 | if (typeof cell === "string") cell = idToCell(cell); 423 | 424 | return cell; 425 | }; 426 | 427 | export const toLocId = (cellOrId) => { 428 | let locId = cellOrId; 429 | if (typeof locId !== "string") locId = cellToId(locId); 430 | 431 | return locId; 432 | }; 433 | 434 | const insideCircle = (center, tile, radius) => { 435 | const dx = center.x - tile.x; 436 | const dy = center.y - tile.y; 437 | const distance_squared = dx * dx + dy * dy; 438 | return distance_squared <= radius * radius; 439 | }; 440 | 441 | export const circle = (center, radius) => { 442 | const diameter = radius % 1 ? radius * 2 : radius * 2 + 1; 443 | const top = center.y - radius; 444 | const bottom = center.y + radius; 445 | const left = center.x - radius; 446 | const right = center.x + radius; 447 | 448 | const locsIds = []; 449 | 450 | for (let y = top; y <= bottom; y++) { 451 | for (let x = left; x <= right; x++) { 452 | const cx = Math.ceil(x); 453 | const cy = Math.ceil(y); 454 | if (insideCircle(center, { x: cx, y: cy }, radius)) { 455 | locsIds.push(`${cx},${cy}`); 456 | } 457 | } 458 | } 459 | 460 | return locsIds; 461 | }; 462 | 463 | export const rectangle = ({ x, y, width, height, hasWalls }, tileProps) => { 464 | const tiles = {}; 465 | 466 | const x1 = x; 467 | const x2 = x + width - 1; 468 | const y1 = y; 469 | const y2 = y + height - 1; 470 | if (hasWalls) { 471 | for (let yi = y1 + 1; yi <= y2 - 1; yi++) { 472 | for (let xi = x1 + 1; xi <= x2 - 1; xi++) { 473 | tiles[`${xi},${yi}`] = { x: xi, y: yi, ...tileProps }; 474 | } 475 | } 476 | } else { 477 | for (let yi = y1; yi <= y2; yi++) { 478 | for (let xi = x1; xi <= x2; xi++) { 479 | tiles[`${xi},${yi}`] = { x: xi, y: yi, ...tileProps }; 480 | } 481 | } 482 | } 483 | 484 | const center = { 485 | x: Math.round((x1 + x2) / 2), 486 | y: Math.round((y1 + y2) / 2), 487 | }; 488 | 489 | return { x1, x2, y1, y2, center, hasWalls, tiles }; 490 | }; 491 | 492 | export const rectsIntersect = (rect1, rect2) => { 493 | return ( 494 | rect1.x1 <= rect2.x2 && 495 | rect1.x2 >= rect2.x1 && 496 | rect1.y1 <= rect2.y2 && 497 | rect1.y2 >= rect2.y1 498 | ); 499 | }; 500 | 501 | export const distance = (cell1, cell2) => { 502 | const x = Math.pow(cell2.x - cell1.x, 2); 503 | const y = Math.pow(cell2.y - cell1.y, 2); 504 | return Math.floor(Math.sqrt(x + y)); 505 | }; 506 | 507 | export const idToCell = (id) => { 508 | const coords = id.split(","); 509 | return { x: parseInt(coords[0], 10), y: parseInt(coords[1], 10) }; 510 | }; 511 | 512 | export const cellToId = ({ x, y }) => `${x},${y}`; 513 | 514 | export const isOnMapEdge = (x, y) => { 515 | const { width, height, x: mapX, y: mapY } = grid.map; 516 | 517 | if (x === mapX) return true; // west edge 518 | if (y === mapY) return true; // north edge 519 | if (x === mapX + width - 1) return true; // east edge 520 | if (y === mapY + height - 1) return true; // south edge 521 | return false; 522 | }; 523 | 524 | export const getNeighbors = ({ x, y }, direction = CARDINAL) => { 525 | const points = []; 526 | for (let dir of direction) { 527 | let candidate = { 528 | x: x + dir.x, 529 | y: y + dir.y, 530 | }; 531 | if ( 532 | candidate.x >= 0 && 533 | candidate.x < grid.width && 534 | candidate.y >= 0 && 535 | candidate.y < grid.height 536 | ) { 537 | points.push(candidate); 538 | } 539 | } 540 | return points; 541 | }; 542 | 543 | export const getNeighborIds = (cellOrId, direction = "CARDINAL") => { 544 | let cell = toCell(cellOrId); 545 | 546 | if (direction === "CARDINAL") { 547 | return getNeighbors(cell, CARDINAL).map(cellToId); 548 | } 549 | 550 | if (direction === "DIAGONAL") { 551 | return getNeighbors(cell, DIAGONAL).map(cellToId); 552 | } 553 | 554 | if (direction === "ALL") { 555 | return [ 556 | ...getNeighbors(cell, CARDINAL).map(cellToId), 557 | ...getNeighbors(cell, DIAGONAL).map(cellToId), 558 | ]; 559 | } 560 | }; 561 | 562 | export const isNeighbor = (a, b) => { 563 | let posA = a; 564 | if (typeof posA === "string") { 565 | posA = idToCell(a); 566 | } 567 | 568 | let posB = b; 569 | if (typeof posB === "string") { 570 | posB = idToCell(b); 571 | } 572 | 573 | const { x: ax, y: ay } = posA; 574 | const { x: bx, y: by } = posB; 575 | 576 | if ( 577 | (ax - bx === 1 && ay - by === 0) || 578 | (ax - bx === 0 && ay - by === -1) || 579 | (ax - bx === -1 && ay - by === 0) || 580 | (ax - bx === 0 && ay - by === 1) 581 | ) { 582 | return true; 583 | } 584 | 585 | return false; 586 | }; 587 | 588 | export const randomNeighbor = (startX, startY) => { 589 | const direction = sample(CARDINAL); 590 | const x = startX + direction.x; 591 | const y = startY + direction.y; 592 | return { x, y }; 593 | }; 594 | 595 | export const getNeighbor = (x, y, dir) => { 596 | const dirMap = { N: 0, E: 1, S: 2, W: 3 }; 597 | const direction = CARDINAL[dirMap[dir]]; 598 | return { 599 | x: x + direction.x, 600 | y: y + direction.y, 601 | }; 602 | }; 603 | 604 | export const getDirection = (a, b) => { 605 | const cellA = toCell(a); 606 | const cellB = toCell(b); 607 | 608 | const { x: ax, y: ay } = cellA; 609 | const { x: bx, y: by } = cellB; 610 | 611 | let dir; 612 | 613 | if (ax - bx === 1 && ay - by === 0) dir = "→"; 614 | if (ax - bx === 0 && ay - by === -1) dir = "↑"; 615 | if (ax - bx === -1 && ay - by === 0) dir = "←"; 616 | if (ax - bx === 0 && ay - by === 1) dir = "↓"; 617 | 618 | return dir; 619 | }; 620 | ``` 621 | 622 | Now that that's out of the way let's make a big rectangle to walk around on. 623 | 624 | First add some dimensions for our map to the grid config in `./src/lib/canvas.js` 625 | 626 | ```diff 627 | export const grid = { 628 | width: 100, 629 | height: 34, 630 | + 631 | + map: { 632 | + width: 79, 633 | + height: 29, 634 | + x: 21, 635 | + y: 3, 636 | + }, 637 | }; 638 | ``` 639 | 640 | Now create another file called `dungeon.js` in our lib folder at `./src/lib/dungeon.js` and make it look like this: 641 | 642 | ```javascript 643 | import ecs from "../state/ecs"; 644 | import { rectangle } from "./grid"; 645 | import { grid } from "./canvas"; 646 | 647 | import { Appearance, Position } from "../state/components"; 648 | 649 | export const createDungeon = () => { 650 | const dungeon = rectangle(grid.map); 651 | Object.keys(dungeon.tiles).forEach((key) => { 652 | const tile = ecs.createEntity(); 653 | tile.add(Appearance, { char: "•", color: "#555" }); 654 | tile.add(Position, dungeon.tiles[key]); 655 | }); 656 | 657 | return dungeon; 658 | }; 659 | ``` 660 | 661 | This createDungeon function will eventually create a dungeon but for now we'll take what we can get. 662 | 663 | To start, we use the rectangle function from our grid library. 664 | 665 | ```javascript 666 | const dungeon = rectangle(grid.map); 667 | ``` 668 | 669 | It generates a bunch of different stuff to kick off the start of our dungeon but among them is an object containing all the tile locations. That object is at `dungeon.tiles` and looks something like this: 670 | 671 | ```javascript 672 | { 673 | '0,0': {x:0, y:0}, 674 | '0,1': {x:0, y:1}, 675 | '0,2': {x:0, y:2} 676 | } 677 | ``` 678 | 679 | Next we use the builtin Object.keys method to iterate over the object and create an entity with Appearance and Position components for every single tile. 680 | 681 | ```javascript 682 | Object.keys(dungeon.tiles).forEach((key) => { 683 | const tile = ecs.createEntity(); 684 | tile.add(Appearance, { char: "•", color: "#555" }); 685 | tile.add(Position, dungeon.tiles[key]); 686 | }); 687 | ``` 688 | 689 | Finally we need to call createDungeon as the game initializes. In `./src/index.js` make these changes right at the top. 690 | 691 | ```diff 692 | import "./lib/canvas.js"; 693 | +import { createDungeon } from "./lib/dungeon"; 694 | import { movement } from "./systems/movement"; 695 | import { render } from "./systems/render"; 696 | import { player } from "./state/ecs"; 697 | import { Move } from "./state/components"; 698 | 699 | +// init game map and player position 700 | +const dungeon = createDungeon(); 701 | +player.position.x = dungeon.center.x; 702 | +player.position.y = dungeon.center.y; 703 | + 704 | render(); 705 | ``` 706 | 707 | See how we create the "dungeon", then use the center location to set our player's intital starting position. 708 | 709 | If you check out the game now you should see a large grid of dots. Notice how you didn't have to do anything extra to render those dots - the render system took care of it for you because each tile has the required components in the renderableEntities query. Cool! 710 | 711 | One problem you may notice is that nothing stops the player from walking right off the edge of the map. We can handle that in our movement system. We just need to make a quick check that the goal location from an entities move component is within our map's boundaries. Go ahead and make the following changes to `./src/systems/movement` 712 | 713 | ```diff 714 | import ecs from "../state/ecs"; 715 | +import { grid } from "../lib/canvas"; 716 | import { Move } from "../state/components"; 717 | 718 | const movableEntities = ecs.createQuery({ 719 | all: [Move], 720 | }); 721 | 722 | export const movement = () => { 723 | movableEntities.get().forEach((entity) => { 724 | - const mx = entity.position.x + entity.move.x; 725 | - const my = entity.position.y + entity.move.y; 726 | + let mx = entity.position.x + entity.move.x; 727 | + let my = entity.position.y + entity.move.y; 728 | 729 | // this is where we will run any checks to see if entity can move to new location 730 | + // observe map boundaries 731 | + mx = Math.min(grid.map.width + grid.map.x - 1, Math.max(21, mx)); 732 | + my = Math.min(grid.map.height + grid.map.y - 1, Math.max(3, my)); 733 | 734 | entity.position.x = mx; 735 | entity.position.y = my; 736 | 737 | entity.remove(Move); 738 | }); 739 | }; 740 | ``` 741 | 742 | Try the game again - there is now an invisible boundary at the edge of the map! 743 | 744 | Good job and congratulations for making it this far! That was a lot to get through. 745 | 746 | In part 2 we learned what an ECS architecture is and why you might choose to use one. We created our first components, entities, and systems to render an "@" on the dungeon floor and move it around! 747 | 748 | In [part 3](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part3.md) we'll revisit createDungeon and build an actual environment to walk around in. 749 | 750 | See you there! 751 | -------------------------------------------------------------------------------- /tutorial/part3.md: -------------------------------------------------------------------------------- 1 | # Part 3 - Generating a dungeon 2 | 3 | In part 3 we get to tackle one of the most import aspects of building a roguelike: Creating a procedurally generated dungeon! 4 | 5 | We're going to be using a few more functions from the grid library and as mentioned previously we're not going to go over that logic in detail. I'd like instead to go step by step through the broad strokes of dungeon generation. Hopefully by the end of this you will have a feel for where you might be able to tweak things here or there or even add entirely new pieces to make this dungeon your own. 6 | 7 | Remember our `createDungeon` function from the last tutorial? Let's gut it so we can build it back up to actually make a dungeon! 8 | 9 | Like any good dungeon we will be digging ours out of the solid rock. Let's start by filling in the map with walls. 10 | 11 | In `./src/lib/dungeon.js` we can delete our import from `./canvas` 12 | 13 | ```diff 14 | import ecs from "../state/ecs"; 15 | import { rectangle } from "./grid"; 16 | -import { grid } from "./canvas"; 17 | ``` 18 | 19 | and replace the entire createDungeon function with this new version: 20 | 21 | ```javascript 22 | export const createDungeon = ({ x, y, width, height }) => { 23 | // fill the entire space with walls so we can dig it out later 24 | const dungeon = rectangle( 25 | { x, y, width, height }, 26 | { 27 | sprite: "WALL", 28 | } 29 | ); 30 | 31 | // create tile entities 32 | Object.keys(dungeon.tiles).forEach((key) => { 33 | const tile = dungeon.tiles[key]; 34 | 35 | if (tile.sprite === "WALL") { 36 | const entity = ecs.createEntity(); 37 | entity.add(Appearance, { char: "#", color: "#555" }); 38 | entity.add(Position, dungeon.tiles[key]); 39 | } 40 | }); 41 | 42 | return dungeon; 43 | }; 44 | ``` 45 | 46 | So we're doing a couple things here. First we're now passing an options object into createDungeon. Next we create our rectangle like before but we pass in an extra argument. The rectangle function allows you to pass an additional object that will get merged with each tile it outputs. This allows us to add additional data for use when we create our entities. 47 | 48 | Next as we iterate through our dungeoon tiles we make use of that extra data. If a tile has a sprite property that is equal to "WALL" we create an entity and add the appropriate components. 49 | 50 | Before we can test this we'll need to pass in an options object in `./src/index.js`. Go ahead and make these changes to that file: 51 | 52 | ```diff 53 | import "./lib/canvas.js"; 54 | +import { grid } from "./lib/canvas"; 55 | import { createDungeon } from "./lib/dungeon"; 56 | import { movement } from "./systems/movement"; 57 | import { render } from "./systems/render"; 58 | import { player } from "./state/ecs"; 59 | import { Move } from "./state/components"; 60 | 61 | // init game map and player position 62 | -const dungeon = createDungeon() 63 | +const dungeon = createDungeon({ 64 | + x: grid.map.x, 65 | + y: grid.map.y, 66 | + width: grid.map.width, 67 | + height: grid.map.height, 68 | +}); 69 | player.position.x = dungeon.center.x; 70 | player.position.y = dungeon.center.y; 71 | ``` 72 | 73 | Go ahead and run the game. You should see a big grid of walls. You may also notice that our @ can walk right through them like a ghost - we'll fix that later. For now, let make our first room! 74 | 75 | Back in `./src/lib/dungeon`: 76 | 77 | ```diff 78 | import ecs from "../state/ecs"; 79 | import { rectangle } from "./grid"; 80 | 81 | import { Appearance, Position } from "../state/components"; 82 | 83 | export const createDungeon = ({ x, y, width, height }) => { 84 | // fill the entire space with walls so we can dig it out later 85 | const dungeon = rectangle( 86 | { x, y, width, height }, 87 | { 88 | sprite: "WALL", 89 | } 90 | ); 91 | 92 | + const room = rectangle( 93 | + { x: 30, y: 10, width: 10, height: 10, hasWalls: true }, 94 | + { sprite: "FLOOR" } 95 | + ); 96 | + 97 | + dungeon.tiles = { ...dungeon.tiles, ...room.tiles }; 98 | 99 | // create tile entities 100 | Object.keys(dungeon.tiles).forEach((key) => { 101 | const tile = dungeon.tiles[key]; 102 | 103 | if (tile.sprite === "WALL") { 104 | const entity = ecs.createEntity(); 105 | entity.add(Appearance, { char: "#", color: "#AAA" }); 106 | entity.add(Position, dungeon.tiles[key]); 107 | } 108 | 109 | + if (tile.sprite === "FLOOR") { 110 | + const entity = ecs.createEntity(); 111 | + entity.add(Appearance, { char: "•", color: "#555" }); 112 | + entity.add(Position, dungeon.tiles[key]); 113 | + } 114 | }); 115 | 116 | return dungeon; 117 | }; 118 | ``` 119 | 120 | Let's go over these changes: 121 | 122 | We use rectangle again to create a room within our map. This time setting the sprite property on our extra data to "FLOOR". 123 | 124 | ```javascript 125 | const room = rectangle( 126 | { x: 30, y: 10, width: 10, height: 10, hasWalls: true }, 127 | { sprite: "FLOOR" } 128 | ); 129 | ``` 130 | 131 | Next we merge our dungeon tiles with our room tiles. The `...` is called the [spread operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) and allows us to clone dungeon.tiles and merge it with room.tiles in one statement. 132 | 133 | ```javascript 134 | dungeon.tiles = { ...dungeon.tiles, ...room.tiles }; 135 | ``` 136 | 137 | Finally we add another conditional statement to handle floor tiles. 138 | 139 | ```javascript 140 | if (tile.sprite === "FLOOR") { 141 | const entity = ecs.createEntity(); 142 | entity.add(Appearance, { char: "•", color: "#555" }); 143 | entity.add(Position, dungeon.tiles[key]); 144 | } 145 | ``` 146 | 147 | Your game should now have a room dug out of the rock! 148 | 149 | Now let's put the room in a random location everytime we start the game. 150 | 151 | [Lodash](https://lodash.com/) is a great utility library for javascript that end up is in most of my project at some point. It has a simple random number generator we'll use. You're welcome to use a more robust rng with support for seeds if you want but we won't be covering anything like that in this tutorial. 152 | 153 | In your terminal of choice from your projects root directory go ahead and install lodash: 154 | 155 | ```bash 156 | npm install lodash 157 | ``` 158 | 159 | Alright, lets go ahead and import the random function from lodash at the top of `./src/lib/dungeon.js`. 160 | 161 | ```diff 162 | +import { random } from "lodash"; 163 | import ecs from "../state/ecs"; 164 | import { rectangle } from "./grid"; 165 | ``` 166 | 167 | Next we need a couple more options for our dungeon. Add minRoomSize and maxRoomSize with some defaults to our options object. 168 | 169 | ```diff 170 | export const createDungeon = ({ 171 | x, 172 | y, 173 | width, 174 | height, 175 | + minRoomSize = 6, 176 | + maxRoomSize = 12, 177 | }) => { 178 | ``` 179 | 180 | Next we're going to store some variables for random width, height, x, and y. 181 | 182 | ```diff 183 | const dungeon = rectangle( 184 | { x, y, width, height }, 185 | { 186 | sprite: "WALL", 187 | } 188 | ); 189 | 190 | + let rw = random(minRoomSize, maxRoomSize); 191 | + let rh = random(minRoomSize, maxRoomSize); 192 | + let rx = random(x, width + x - rw); 193 | + let ry = random(y, height + y - rh); 194 | 195 | const room = rectangle( 196 | ``` 197 | 198 | Lodash's random function returns a number between the inclusive lower and upper bounds. Our job here is to provide reasonable boundaries so our rooms are within the map. 199 | 200 | Width (rw) and height (rh) are easy, we can just use the min and max room size options we just added. The x (rx) and y (ry) are a little bit tricker. We have to subtract our random width and height from the far right and bottom locations on our grid. 201 | 202 | Finally we just need to pass these randomized values into the rectangle function for creating rooms. 203 | 204 | ```diff 205 | const room = rectangle( 206 | - { x: 30, y: 10, width: 10, height: 10, hasWalls: true }, 207 | + { x: rx, y: ry, width: rw, height: rh, hasWalls: true }, 208 | { sprite: "FLOOR" } 209 | ); 210 | ``` 211 | 212 | Now your game should place a randomly sized room in a random location on your map. We're getting closer to a procedurally generated dungeon! 213 | 214 | Of course we're gonna need more than one room to excite our players. Let's make some changes to our algorithm to add more. 215 | 216 | We need to import a new function from lodash called `times`. Times is a great little utility for running another function multiple times. We also need to add another option for createDungeon - maxRoomCount with a default of 30. 217 | 218 | We'll go over the diff in a second but with our new code to generate multiple rooms `./src/lib/dungeon.js` should now look like this: 219 | 220 | ```javascript 221 | import { random, times } from "lodash"; 222 | import ecs from "../state/ecs"; 223 | import { rectangle, rectsIntersect } from "./grid"; 224 | import { Appearance, Position } from "../state/components"; 225 | 226 | export const createDungeon = ({ 227 | x, 228 | y, 229 | width, 230 | height, 231 | minRoomSize = 6, 232 | maxRoomSize = 12, 233 | maxRoomCount = 30, 234 | }) => { 235 | // fill the entire space with walls so we can dig it out later 236 | const dungeon = rectangle( 237 | { x, y, width, height }, 238 | { 239 | sprite: "WALL", 240 | } 241 | ); 242 | 243 | const rooms = []; 244 | let roomTiles = {}; 245 | 246 | times(maxRoomCount, () => { 247 | let rw = random(minRoomSize, maxRoomSize); 248 | let rh = random(minRoomSize, maxRoomSize); 249 | let rx = random(x, width + x - rw - 1); 250 | let ry = random(y, height + y - rh - 1); 251 | 252 | // create a candidate room 253 | const candidate = rectangle( 254 | { x: rx, y: ry, width: rw, height: rh, hasWalls: true }, 255 | { sprite: "FLOOR" } 256 | ); 257 | 258 | // test if candidate is overlapping with any existing rooms 259 | if (!rooms.some((room) => rectsIntersect(room, candidate))) { 260 | rooms.push(candidate); 261 | roomTiles = { ...roomTiles, ...candidate.tiles }; 262 | } 263 | }); 264 | 265 | dungeon.tiles = { ...dungeon.tiles, ...roomTiles }; 266 | 267 | // create tile entities 268 | Object.keys(dungeon.tiles).forEach((key) => { 269 | const tile = dungeon.tiles[key]; 270 | 271 | if (tile.sprite === "WALL") { 272 | const entity = ecs.createEntity(); 273 | entity.add(Appearance, { char: "#", color: "#AAA" }); 274 | entity.add(Position, dungeon.tiles[key]); 275 | } 276 | 277 | if (tile.sprite === "FLOOR") { 278 | const entity = ecs.createEntity(); 279 | entity.add(Appearance, { char: "•", color: "#555" }); 280 | entity.add(Position, dungeon.tiles[key]); 281 | } 282 | }); 283 | 284 | return dungeon; 285 | }; 286 | ``` 287 | 288 | The bulk of the diff is here: 289 | 290 | ```javascript 291 | const rooms = []; 292 | let roomTiles = {}; 293 | 294 | times(maxRoomCount, () => { 295 | let rw = random(minRoomSize, maxRoomSize); 296 | let rh = random(minRoomSize, maxRoomSize); 297 | let rx = random(x, width + x - rw - 1); 298 | let ry = random(y, height + y - rh - 1); 299 | 300 | // create a candidate room 301 | const candidate = rectangle( 302 | { x: rx, y: ry, width: rw, height: rh, hasWalls: true }, 303 | { sprite: "FLOOR" } 304 | ); 305 | 306 | // test if candidate is overlapping with any existing rooms 307 | if (!rooms.some((room) => rectsIntersect(room, candidate))) { 308 | rooms.push(candidate); 309 | roomTiles = { ...roomTiles, ...candidate.tiles }; 310 | } 311 | }); 312 | 313 | dungeon.tiles = { ...dungeon.tiles, ...roomTiles }; 314 | ``` 315 | 316 | We use `times` to run a function a number of times equal to the maxRoomCount. This function creates a new room with random dimensions and saves it to the variable `candidate`. We then check `candidate` against an array of accepted rooms to make sure it doesn't intersect with any of them. If everything checks out we push it onto our array of accepted rooms and add it's tiles to the roomTiles object for merging with our dungeon. 317 | 318 | Check out the game - there should now be multiple rooms of all sizes randomly sprinkled across the map! 319 | 320 | All we need yet is passages connecting our rooms. 321 | 322 | We'll add two new functions digHorizontalPassage and digVerticalPassage at the top of `./src/lib/dungeon` right after the imports. 323 | 324 | ```javascript 325 | function digHorizontalPassage(x1, x2, y) { 326 | const tiles = {}; 327 | const start = Math.min(x1, x2); 328 | const end = Math.max(x1, x2) + 1; 329 | let x = start; 330 | 331 | while (x < end) { 332 | tiles[`${x},${y}`] = { x, y, sprite: "FLOOR" }; 333 | x++; 334 | } 335 | 336 | return tiles; 337 | } 338 | 339 | function digVerticalPassage(y1, y2, x) { 340 | const tiles = {}; 341 | const start = Math.min(y1, y2); 342 | const end = Math.max(y1, y2) + 1; 343 | let y = start; 344 | 345 | while (y < end) { 346 | tiles[`${x},${y}`] = { x, y, sprite: "FLOOR" }; 347 | y++; 348 | } 349 | 350 | return tiles; 351 | } 352 | ``` 353 | 354 | These are very similar functions that just return straight passages of floor tiles. To use them we need to loop through all of our accepted rooms so that we can pair them up and draw both a vertical and horizontal lines connecting them. 355 | 356 | Just above the line where we merge all of our tiles into dungeon.tiles add: 357 | 358 | ```javascript 359 | let prevRoom = null; 360 | let passageTiles; 361 | 362 | for (let room of rooms) { 363 | if (prevRoom) { 364 | const prev = prevRoom.center; 365 | const curr = room.center; 366 | 367 | passageTiles = { 368 | ...passageTiles, 369 | ...digHorizontalPassage(prev.x, curr.x, curr.y), 370 | ...digVerticalPassage(prev.y, curr.y, prev.x), 371 | }; 372 | } 373 | 374 | prevRoom = room; 375 | } 376 | ``` 377 | 378 | And don't forget to add the passageTiles to your dungeon: 379 | 380 | ```diff 381 | -dungeon.tiles = { ...dungeon.tiles, ...roomTiles }; 382 | +dungeon.tiles = { ...dungeon.tiles, ...roomTiles, ...passageTiles }; 383 | ``` 384 | 385 | That's it! You should now have a procedurally generated dungeon! Check out your game - see if it worked. 386 | 387 | if you have a hard time visualizing everything that's going in the algorithm like I do, check out [this live version](https://luetkemj.github.io/pcgdgns/) that animates each step. You can see the code attempt to place each room and dig the passages one leg at time. Just refresh the browser to see it build another dungeon. 388 | 389 | Dungeon generation is the favorite part of a lot of developers. This is a part of the codebase where you can get a lot bang for your buck. Subtle changes can make huge differences in the landscape and really change how your game plays. In the live version above I added a drunken walk mining algorithm to generate a 'natural cave' in the middle of an artificial dungeon. What would happen if you don't test for overlapping rooms? Or allow 100 rooms, or make them all huge? Can you figure our how to tweak our code to fill a room with water? 390 | 391 | There are so many possibilities here I almost forgot that our @ can still just walk wherever the heck it pleases. We have a little more work to do to make our dungeon 'playable'. 392 | 393 | To make our game playable we need to do two things. Place our @ in the center of one of the generated rooms and make our walls blocking. 394 | 395 | Let's got for the easy win and put our player in the center of one of the rooms. 396 | 397 | In `./src/lib/dungeon.js` add our accepts rooms array to our dungeon object like this: 398 | 399 | ```diff 400 | + dungeon.rooms = rooms; 401 | dungeon.tiles = { ...dungeon.tiles, ...roomTiles, ...passageTiles }; 402 | ``` 403 | 404 | Then in `./src/index.js` set the player position to the center of the first room in that array like this: 405 | 406 | ```diff 407 | -player.position.x = dungeon.center.x; 408 | -player.position.y = dungeon.center.y; 409 | +player.position.x = dungeon.rooms[0].center.x; 410 | +player.position.y = dungeon.rooms[0].center.y; 411 | ``` 412 | 413 | Simple enough! The @ will now always start in the center of a room. 414 | 415 | Ok, now for the slightly more challenging piece. In order to prevent our "@" from walking through walls we need to add a new component on wall entities and then test that no entity with a blocking component exists in the location we want to move to from our movement system. 416 | 417 | Let's start by adding another component to `./src/state/components`. 418 | 419 | ```javascript 420 | export class IsBlocking extends Component {} 421 | ``` 422 | 423 | You will notice that this component doesn't have any properties like the others. We will only be checking if the component exists on an entity so we don't actually need any. 424 | 425 | As always, don't forget to register the component in `./src/state/ecs` 426 | 427 | ```diff 428 | import { Engine } from "geotic"; 429 | -import { Appearance, Move, Position } from "./components"; 430 | +import { Appearance, IsBlocking, Move, Position } from "./components"; 431 | 432 | const ecs = new Engine(); 433 | 434 | // all Components must be `registered` by the engine 435 | ecs.registerComponent(Appearance); 436 | +ecs.registerComponent(IsBlocking); 437 | ecs.registerComponent(Move); 438 | ecs.registerComponent(Position); 439 | 440 | export const player = ecs.createEntity(); 441 | player.add(Appearance, { char: "@", color: "#fff" }); 442 | player.add(Position); 443 | 444 | export default ecs; 445 | ``` 446 | 447 | Now head back to `./src/lib/dungeon` and where we will add the `IsBlocking` component to our wall entities. 448 | 449 | First, import the component with the others: 450 | 451 | ```diff 452 | -import { Appearance, Position } from "../state/components"; 453 | +import { Appearance, IsBlocking, Position } from "../state/components"; 454 | ``` 455 | 456 | Then add it to wall entities 457 | 458 | ```diff 459 | if (tile.sprite === "WALL") { 460 | const entity = ecs.createEntity(); 461 | entity.add(Appearance, { char: "#", color: "#AAA" }); 462 | + entity.add(IsBlocking); 463 | entity.add(Position, dungeon.tiles[key]); 464 | } 465 | ``` 466 | 467 | Finally in movement we need to check if the location we want to move to has an entity with the blocking component. Right after the check to observe map boundaries and before we actually update position of our entity we can check for any other blocking entities. 468 | 469 | ```diff 470 | // this is where we will run any checks to see if entity can move to new location 471 | // observe map boundaries 472 | mx = Math.min(grid.map.width + grid.map.x - 1, Math.max(21, mx)); 473 | my = Math.min(grid.map.height + grid.map.y - 1, Math.max(3, my)); 474 | 475 | + // check for blockers 476 | + const blockers = []; 477 | + for (const e of ecs.entities.all) { 478 | + if (e.position.x === mx && e.position.y === my && e.isBlocking) { 479 | + blockers.push(e); 480 | + } 481 | + } 482 | + if (blockers.length) { 483 | + entity.remove(Move); 484 | + return; 485 | + } 486 | 487 | entity.position.x = mx; 488 | entity.position.y = my; 489 | ``` 490 | 491 | All we're doing here is looping through all of the entities in the game testing for any that are both in the location we intend to move and contain the `IsBlocking` component. If we find any blockers they get added to the `blockers` array. Finally if any blockers were found we know we can't enter the new location and instead just remove the `Move` component and return. 492 | 493 | Typically I build an entitiesAtLocation cache that tracks entity ids at each location. It looks something like this: 494 | 495 | ```javascript 496 | { 497 | "0,0", ["entityid1"]; 498 | "0,1", ["entityid2", "entityid4"]; 499 | "0,2", ["entityid3"]; 500 | } 501 | ``` 502 | 503 | This is a lot faster as we only have to test the entities that are actually in the location we want to enter. But it's usually a mistake to over optimize early so for now, we're fine. Feel free to revisit this later if you need/want to. 504 | 505 | --- 506 | 507 | Phew! Another part complete - this was another big one, congratulations on making it this far! 508 | 509 | In part 3 we completed one of the most important and satisfying aspects of building a roguelike - procedural dungeon generation! You now have a basic algorithm ready to be expanded upon with newfound tools and knowledge. Have fun and make it your own! 510 | 511 | In [part 4](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part4.md) we'll get to tackle another typical feature in roguelikes - Field of Vision! 512 | 513 | See you there! 514 | -------------------------------------------------------------------------------- /tutorial/part4.md: -------------------------------------------------------------------------------- 1 | # Part 4 - Field of view 2 | 3 | In this part we will be implementing "Field of View". We'll need to calculate all the cells our @ can see and keep track of those that have already been discovered. Through this common mechanic we get the thrill of exploration! 4 | 5 | Before we can get to do that, we have a couple of outstanding tasks to take care of. 6 | 7 | Have you noticed how the floor renders on top of our @? That's because our game has no concept of layers and our tiles have no backgrounds. Let's take care of both of those right now. 8 | 9 | Let's add backgrounds to our tiles first. In `./src/lib/canvas.js` add two additional functions: 10 | 11 | ```javascript 12 | const drawBackground = ({ color, position }) => { 13 | if (color === "transparent") return; 14 | 15 | ctx.fillStyle = color; 16 | 17 | ctx.fillRect( 18 | position.x * cellWidth, 19 | position.y * cellHeight, 20 | cellWidth, 21 | cellHeight 22 | ); 23 | }; 24 | 25 | export const drawCell = (entity, options = {}) => { 26 | const char = options.char || entity.appearance.char; 27 | const background = options.background || entity.appearance.background; 28 | const color = options.color || entity.appearance.color; 29 | const position = entity.position; 30 | 31 | drawBackground({ color: background, position }); 32 | drawChar({ char, color, position }); 33 | }; 34 | ``` 35 | 36 | Instead of calling `drawChar` directly we can now call `drawCell` and pass it any entity with an appearance and a position component. Our new `drawCell` function calls `drawBackground` and `drawChar` for us. We can still get a transparent background if we need to by passing the color 'transparent'. We also have an options object for overrides if needed. 37 | 38 | No just replace `drawChar` in `./src/systems/render.js` with `drawCell` and pass the entity in directly. 39 | 40 | ```diff 41 | import ecs from "../state/ecs"; 42 | import { Appearance, Position } from "../state/components"; 43 | -import { clearCanvas, drawChar } from "../lib/canvas"; 44 | +import { clearCanvas, drawCell } from "../lib/canvas"; 45 | 46 | const renderableEntities = ecs.createQuery({ 47 | all: [Position, Appearance], 48 | }); 49 | 50 | export const render = () => { 51 | clearCanvas(); 52 | 53 | renderableEntities.get().forEach((entity) => { 54 | - const { appearance, position } = entity; 55 | - const { char, color } = appearance; 56 | - 57 | - drawChar({ char, color, position }); 58 | + drawCell(entity); 59 | }); 60 | }; 61 | ``` 62 | 63 | And add a default background to our Appearance component in `./src/state/components` 64 | 65 | ```diff 66 | export class Appearance extends Component { 67 | static properties = { 68 | color: "#ff0077", 69 | char: "?", 70 | + background: "#000", 71 | }; 72 | } 73 | ``` 74 | 75 | Ahhhh... I feel so much better - that's been bugging me for a while now :) 76 | 77 | Check out the game to make sure its running smooth... and our @ has gone missing. 78 | 79 | Html canvas draws pixels where you tell it, when you tell it. We just happen to be telling it to draw our @ before we tell it to the floor - so it draws our @ and then draws the floor directly on top. We could try and reorder our entities but that would be a huge pain to keep track of. Let's do it with a couple new components instead. 80 | 81 | Add these components to `./src/state/components.js` 82 | 83 | ```javascript 84 | export class Layer100 extends Component {} 85 | 86 | export class Layer300 extends Component {} 87 | 88 | export class Layer400 extends Component {} 89 | ``` 90 | 91 | And register them in `./src/state/ecs.js` 92 | 93 | ```diff 94 | import { Engine } from "geotic"; 95 | import { 96 | Appearance, 97 | IsBlocking, 98 | + Layer100, 99 | + Layer300, 100 | + Layer400, 101 | Move, 102 | Position, 103 | } from "./components"; 104 | 105 | const ecs = new Engine(); 106 | 107 | // all Components must be `registered` by the engine 108 | ecs.registerComponent(Appearance); 109 | ecs.registerComponent(IsBlocking); 110 | +ecs.registerComponent(Layer100); 111 | +ecs.registerComponent(Layer300); 112 | +ecs.registerComponent(Layer400); 113 | ecs.registerComponent(Move); 114 | ecs.registerComponent(Position); 115 | 116 | export const player = ecs.createEntity(); 117 | player.add(Appearance, { char: "@", color: "#fff" }); 118 | player.add(Position); 119 | 120 | export default ecs; 121 | ``` 122 | 123 | I like to number my layers in hundreds just in case I need to squeeze something in between. 100 is for the ground, 300 for things on the ground like items, and 400 is for the player. 124 | 125 | Next we need to add the layer components to all our entities so we now where to render what. First in `./src/lib/dungeon.js` import the `Layer100` component and add it to the floors and walls. 126 | 127 | ```diff 128 | import { 129 | Appearance, 130 | IsBlocking, 131 | + Layer100, 132 | Position, 133 | } from "../state/components"; 134 | ``` 135 | 136 | ```diff 137 | if (tile.sprite === "WALL") { 138 | const entity = ecs.createEntity(); 139 | entity.add(Appearance, { char: "#", color: "#AAA" }); 140 | entity.add(IsBlocking); 141 | + entity.add(Layer100); 142 | entity.add(Position, dungeon.tiles[key]); 143 | } 144 | 145 | if (tile.sprite === "FLOOR") { 146 | const entity = ecs.createEntity(); 147 | entity.add(Appearance, { char: "•", color: "#555" }); 148 | + entity.add(Layer100); 149 | entity.add(Position, dungeon.tiles[key]); 150 | } 151 | ``` 152 | 153 | And then in `./src/state/ecs.js` we need to add `Layer400` to our player entity. 154 | 155 | ```diff 156 | export const player = ecs.createEntity(); 157 | player.add(Appearance, { char: "@", color: "#fff" }); 158 | +player.add(Layer400); 159 | player.add(Position); 160 | 161 | export default ecs; 162 | ``` 163 | 164 | Almost there! We need to query for each layer component so we can render everything in the correct order. 165 | 166 | Make `./src/systems/render.js` look like this: 167 | 168 | ```javascript 169 | import ecs from "../state/ecs"; 170 | import { 171 | Appearance, 172 | Position, 173 | Layer100, 174 | Layer300, 175 | Layer400, 176 | } from "../state/components"; 177 | import { clearCanvas, drawCell } from "../lib/canvas"; 178 | 179 | const layer100Entities = ecs.createQuery({ 180 | all: [Position, Appearance, Layer100], 181 | }); 182 | 183 | const layer300Entities = ecs.createQuery({ 184 | all: [Position, Appearance, Layer300], 185 | }); 186 | 187 | const layer400Entities = ecs.createQuery({ 188 | all: [Position, Appearance, Layer400], 189 | }); 190 | 191 | export const render = () => { 192 | clearCanvas(); 193 | 194 | layer100Entities.get().forEach((entity) => { 195 | drawCell(entity); 196 | }); 197 | 198 | layer300Entities.get().forEach((entity) => { 199 | drawCell(entity); 200 | }); 201 | 202 | layer400Entities.get().forEach((entity) => { 203 | drawCell(entity); 204 | }); 205 | }; 206 | ``` 207 | 208 | We've removed the `renderableEntities` query in favor of queries for each layer. Then we simply render each layer in order. 209 | 210 | Our @ has returned to the dungeon and the floor properly is beneath it's feet! 211 | 212 | Nice work! We're starting to build a solid foundation for our game. We just have one more thing before we can add our "Field of View". Remember in the last tutorial where I talked about how we weren't going to add an `entitiesAtLocation` cache? I lied. We're totally going to add one. Right now. 213 | 214 | First let's add a new file to store our cache with a few helper functions for accessing it. Create a file in `./src/state` called `cache.js` at `./src/state/cache.js` and make it look like this: 215 | 216 | ```javascript 217 | export const cache = { 218 | entitiesAtLocation: {}, 219 | }; 220 | 221 | export const addCacheSet = (name, key, value) => { 222 | if (cache[name][key]) { 223 | cache[name][key].add(value); 224 | } else { 225 | cache[name][key] = new Set(); 226 | cache[name][key].add(value); 227 | } 228 | }; 229 | 230 | export const deleteCacheSet = (name, key, value) => { 231 | if (cache[name][key] && cache[name][key].has(value)) { 232 | cache[name][key].delete(value); 233 | } 234 | }; 235 | 236 | export const readCacheSet = (name, key, value) => { 237 | if (cache[name][key]) { 238 | if (value) { 239 | return cache[name][key].get(value); 240 | } 241 | 242 | return cache[name][key]; 243 | } 244 | }; 245 | 246 | export default cache; 247 | ``` 248 | 249 | We just set up an object to store our cache and create some helper functions for basic CRUD operations. Our entitiesAtLocation cache will be an object with locId keys. LocIds are just a stringified combination of the location x and y properities (e.g, '0,1'). The value at each key will be a [Set](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set) of entity ids. A Set has some advantages over an array in this case. Specifically we have a get method for simple access of values and it's super fast. 250 | 251 | The purpose of this cache is to track what entities are in each location. So to start we should add entities to the cache when they get the `Position` component. Geotic provides some lifecycle methods that can help with this. In our components file at `./src/state/components.js` we can add entities to the cache when the Position component is attached to an entity! 252 | 253 | ```diff 254 | import { Component } from "geotic"; 255 | +import { addCacheSet } from "./cache"; 256 | ``` 257 | 258 | ```diff 259 | export class Position extends Component { 260 | static properties = { x: 0, y: 0 }; 261 | 262 | + onAttached() { 263 | + const locId = `${this.entity.position.x},${this.entity.position.y}`; 264 | + addCacheSet("entitiesAtLocation", locId, this.entity.id); 265 | + } 266 | } 267 | ``` 268 | 269 | Next we need to update our cache when an entity moves. The simplest way for us to do this right now is in our movement system at `./src/systems/movement.js`. After all the checks to determine if an entity is able to move and right before we update their position, we can update the cache like this: 270 | 271 | ```diff 272 | import ecs from "../state/ecs"; 273 | +import { addCacheSet, deleteCacheSet, readCacheSet } from "../state/cache"; 274 | import { grid } from "../lib/canvas"; 275 | import { Move } from "../state/components"; 276 | ``` 277 | 278 | ```diff 279 | if (blockers.length) { 280 | entity.remove(Move); 281 | return; 282 | } 283 | 284 | +deleteCacheSet( 285 | + "entitiesAtLocation", 286 | + `${entity.position.x},${entity.position.y}`, 287 | + entity.id 288 | +); 289 | +addCacheSet("entitiesAtLocation", `${mx},${my}`, entity.id); 290 | 291 | entity.position.x = mx; 292 | entity.position.y = my; 293 | ``` 294 | 295 | We simply delete the entity id at it's previous location in cache and then add it to the new one. 296 | 297 | Ok, now that our cache is all set up, let's use it! Still in `./src/systems/movement.js` replace the current check for blockers: 298 | 299 | ```javascript 300 | // check for blockers 301 | const blockers = []; 302 | for (const e of ecs.entities.all) { 303 | if (e.position.x === mx && e.position.y === my && e.isBlocking) { 304 | blockers.push(e); 305 | } 306 | } 307 | if (blockers.length) { 308 | entity.remove(Move); 309 | return; 310 | } 311 | ``` 312 | 313 | With out new one that uses our cache: 314 | 315 | ```javascript 316 | const blockers = []; 317 | // read from cache 318 | const entitiesAtLoc = readCacheSet("entitiesAtLocation", `${mx},${my}`); 319 | 320 | for (const eId of entitiesAtLoc) { 321 | if (ecs.getEntity(eId).isBlocking) { 322 | blockers.push(eId); 323 | } 324 | } 325 | if (blockers.length) { 326 | entity.remove(Move); 327 | return; 328 | } 329 | ``` 330 | 331 | The biggest change here is that we now only check the entities at our intended location if they are blocking. Previously we checked the location of every entity in the entire game to determine if it was both blocking AND in the place we wanted to go. We're going to need this information quite a bit moving forward so it makes sense to just rip off the bandaid and implement the cache. 332 | 333 | --- 334 | 335 | We are all caught up and ready to add "Field of Vision"! We are going to be using my javascript implementation of an FOV algorithm I found online by Bob Nystrom (Munificent). He wrote the online roguelike [Hauberk](https://munificent.github.io/hauberk/) and a fantastic book called [Game Programming Patterns](http://gameprogrammingpatterns.com/). I'll be honest and say I don't remember exactly how this algorithm works. Hooking it up is the important bit for our purposes. If you want to understand it fully, there is an exhaustive [blog post about it here](http://journal.stuffwithstuff.com/2015/09/07/what-the-hero-sees/) where Bob explains far better than I could. 336 | 337 | Ok, first let's go ahead and add a new file called `fov.js` to our lib directory at `./src/lib/fov.js`. Go ahead and paste this code into it: 338 | 339 | ```javascript 340 | import { distance, idToCell } from "./grid"; 341 | 342 | const octantTransforms = [ 343 | { xx: 1, xy: 0, yx: 0, yy: 1 }, 344 | { xx: 1, xy: 0, yx: 0, yy: -1 }, 345 | { xx: -1, xy: 0, yx: 0, yy: 1 }, 346 | { xx: -1, xy: 0, yx: 0, yy: -1 }, 347 | { xx: 0, xy: 1, yx: 1, yy: 0 }, 348 | { xx: 0, xy: 1, yx: -1, yy: 0 }, 349 | { xx: 0, xy: -1, yx: 1, yy: 0 }, 350 | { xx: 0, xy: -1, yx: -1, yy: 0 }, 351 | ]; 352 | 353 | // width: width of map (or visible map?) 354 | // height: height of map (or visible map?) 355 | export default function createFOV( 356 | opaqueEntities, 357 | width, 358 | height, 359 | originX, 360 | originY, 361 | radius 362 | ) { 363 | const visible = new Set(); 364 | 365 | const blockingLocations = new Set(); 366 | opaqueEntities 367 | .get() 368 | .forEach((x) => blockingLocations.add(`${x.position.x},${x.position.y}`)); 369 | 370 | const isOpaque = (x, y) => { 371 | const locId = `${x},${y}`; 372 | return !!blockingLocations.has(locId); 373 | }; 374 | const reveal = (x, y) => { 375 | return visible.add(`${x},${y}`); 376 | }; 377 | 378 | function castShadows(originX, originY, row, start, end, transform, radius) { 379 | let newStart = 0; 380 | if (start < end) return; 381 | 382 | let blocked = false; 383 | 384 | for (let distance = row; distance < radius && !blocked; distance++) { 385 | let deltaY = -distance; 386 | for (let deltaX = -distance; deltaX <= 0; deltaX++) { 387 | let currentX = originX + deltaX * transform.xx + deltaY * transform.xy; 388 | let currentY = originY + deltaX * transform.yx + deltaY * transform.yy; 389 | 390 | let leftSlope = (deltaX - 0.5) / (deltaY + 0.5); 391 | let rightSlope = (deltaX + 0.5) / (deltaY - 0.5); 392 | 393 | if ( 394 | !( 395 | currentX >= 0 && 396 | currentY >= 0 && 397 | currentX < width && 398 | currentY < height 399 | ) || 400 | start < rightSlope 401 | ) { 402 | continue; 403 | } else if (end > leftSlope) { 404 | break; 405 | } 406 | 407 | if (Math.sqrt(deltaX * deltaX + deltaY * deltaY) <= radius) { 408 | reveal(currentX, currentY); 409 | } 410 | 411 | if (blocked) { 412 | if (isOpaque(currentX, currentY)) { 413 | newStart = rightSlope; 414 | continue; 415 | } else { 416 | blocked = false; 417 | start = newStart; 418 | } 419 | } else { 420 | if (isOpaque(currentX, currentY) && distance < radius) { 421 | blocked = true; 422 | castShadows( 423 | originX, 424 | originY, 425 | distance + 1, 426 | start, 427 | leftSlope, 428 | transform, 429 | radius 430 | ); 431 | newStart = rightSlope; 432 | } 433 | } 434 | } 435 | } 436 | } 437 | 438 | reveal(originX, originY); 439 | for (let octant of octantTransforms) { 440 | castShadows(originX, originY, 1, 1, 0, octant, radius); 441 | } 442 | 443 | return { 444 | fov: visible, 445 | distance: [...visible].reduce((acc, val) => { 446 | const cell = idToCell(val); 447 | acc[val] = distance({ x: originX, y: originY }, { x: cell.x, y: cell.y }); 448 | return acc; 449 | }, {}), 450 | }; 451 | } 452 | ``` 453 | 454 | Now that we have our Field of Vision algorithm in place we need to wire it up. We'll start with the system this time. Create a new file, again called `fov.js` but this time in the systems directory at `./src/systems/fov.js`. It should look like this: 455 | 456 | ```javascript 457 | import { readCacheSet } from "../state/cache"; 458 | import ecs, { player } from "../state/ecs"; 459 | import { grid } from "../lib/canvas"; 460 | import createFOV from "../lib/fov"; 461 | import { IsInFov, IsOpaque, IsRevealed } from "../state/components"; 462 | 463 | const inFovEntities = ecs.createQuery({ 464 | all: [IsInFov], 465 | }); 466 | 467 | const opaqueEntities = ecs.createQuery({ 468 | all: [IsOpaque], 469 | }); 470 | 471 | export const fov = () => { 472 | const { width, height } = grid; 473 | 474 | const originX = player.position.x; 475 | const originY = player.position.y; 476 | 477 | const FOV = createFOV(opaqueEntities, width, height, originX, originY, 10); 478 | 479 | // clear out stale fov 480 | inFovEntities.get().forEach((x) => x.remove(IsInFov)); 481 | 482 | FOV.fov.forEach((locId) => { 483 | const entitiesAtLoc = readCacheSet("entitiesAtLocation", locId); 484 | 485 | if (entitiesAtLoc) { 486 | entitiesAtLoc.forEach((eId) => { 487 | const entity = ecs.getEntity(eId); 488 | entity.add(IsInFov); 489 | 490 | if (!entity.has("IsRevealed")) { 491 | entity.add(IsRevealed); 492 | } 493 | }); 494 | } 495 | }); 496 | }; 497 | ``` 498 | 499 | You may have noticed some new components we imported at the top - we'll create those next. But let's go over what this system is doing first. The first thing it does is gather some data to pass into `createFOV`. The last argument passing into createFOV is the visual range of our hero and the only one you may want to manually tweak. 500 | 501 | ```javascript 502 | export const fov = () => { 503 | const { width, height } = grid; 504 | 505 | const originX = player.position.x; 506 | const originY = player.position.y; 507 | 508 | const FOV = createFOV(opaqueEntities, width, height, originX, originY, 10); 509 | ``` 510 | 511 | Next the system removes the component `IsInFov` from all entities that prevuosly had it. This clears out all the state from the last turn ensuring that we always have the latest data. 512 | 513 | The algorithm returns an array of locations within our hero's field of view. We need to find all the entities at each location and add an `IsInFov` component. If an entity has never been revealed we will add an `IsRevealed` component as well. This is why we created a cache earlier. Having to iterate through every entity in the game for every tile in FOV every turn... ugh. That would be bad. 514 | 515 | ```javascript 516 | // clear out stale fov 517 | inFovEntities.get().forEach((x) => x.remove(IsInFov)); 518 | 519 | FOV.fov.forEach((locId) => { 520 | const entitiesAtLoc = readCacheSet("entitiesAtLocation", locId); 521 | 522 | if (entitiesAtLoc) { 523 | entitiesAtLoc.forEach((eId) => { 524 | const entity = ecs.getEntity(eId); 525 | entity.add(IsInFov); 526 | 527 | if (!entity.has("IsRevealed")) { 528 | entity.add(IsRevealed); 529 | } 530 | }); 531 | } 532 | }); 533 | }; 534 | ``` 535 | 536 | We should probably create those components we're referencing. In `./src/state/components.js` add the following: 537 | 538 | ```javascript 539 | export class IsInFov extends Component {} 540 | 541 | export class IsOpaque extends Component {} 542 | 543 | export class IsRevealed extends Component {} 544 | ``` 545 | 546 | And register them in `./src/state/ecs.js` 547 | 548 | ```diff 549 | import { 550 | Appearance, 551 | IsBlocking, 552 | + IsInFov, 553 | + IsOpaque, 554 | + IsRevealed, 555 | Layer100, 556 | Layer400, 557 | Move, 558 | Position, 559 | } from "./components"; 560 | 561 | const ecs = new Engine(); 562 | 563 | // all Components must be `registered` by the engine 564 | ecs.registerComponent(Appearance); 565 | ecs.registerComponent(IsBlocking); 566 | +ecs.registerComponent(IsInFov); 567 | +ecs.registerComponent(IsOpaque); 568 | +ecs.registerComponent(IsRevealed); 569 | ecs.registerComponent(Layer100); 570 | ecs.registerComponent(Layer400); 571 | ecs.registerComponent(Move); 572 | ecs.registerComponent(Position); 573 | ``` 574 | 575 | The fov algorithm needs to know what locations on the map are see through and what aren't. The `IsOpaque` component is the flag we're using to determine that. Let's add it to walls in `./src/lib/dungeon.js` now. 576 | 577 | ```diff 578 | import { 579 | Appearance, 580 | IsBlocking, 581 | + IsOpaque, 582 | Layer100, 583 | Position, 584 | } from "../state/components"; 585 | ``` 586 | 587 | ```diff 588 | if (tile.sprite === "WALL") { 589 | const entity = ecs.createEntity(); 590 | entity.add(Appearance, { char: "#", color: "#AAA" }); 591 | entity.add(IsBlocking); 592 | + entity.add(IsOpaque); 593 | entity.add(Layer100); 594 | entity.add(Position, dungeon.tiles[key]); 595 | } 596 | ``` 597 | 598 | We're getting close - we need to call our new fov system on each turn in `./src/index.js`. While we're in this file let's do quick refactor - rather than directly mutate player position to set the starting point let's just add the `Position` component in this file. This way we are no longer breaking a core tenant of ECS - don't mutate entity props directly outside of a system - and we ensure that our player's position will be correctly cached because we are adding the Position component with the correct x and y. 599 | 600 | Go ahead and make these changes: 601 | 602 | ```diff 603 | import "./lib/canvas.js"; 604 | import { grid } from "./lib/canvas"; 605 | import { createDungeon } from "./lib/dungeon"; 606 | +import { fov } from "./systems/fov"; 607 | import { movement } from "./systems/movement"; 608 | import { render } from "./systems/render"; 609 | import { player } from "./state/ecs"; 610 | -import { Move } from "./state/components"; 611 | +import { Move, Position } from "./state/components"; 612 | 613 | // init game map and player position 614 | const dungeon = createDungeon({ 615 | x: grid.map.x, 616 | y: grid.map.y, 617 | width: grid.map.width, 618 | height: grid.map.height, 619 | }); 620 | -player.position.x = dungeon.rooms[0].center.x; 621 | -player.position.y = dungeon.rooms[0].center.y; 622 | +player.add(Position, { 623 | + x: dungeon.rooms[0].center.x, 624 | + y: dungeon.rooms[0].center.y, 625 | +}); 626 | 627 | +fov(); 628 | render(); 629 | 630 | let userInput = null; 631 | 632 | document.addEventListener("keydown", (ev) => { 633 | userInput = ev.key; 634 | processUserInput(); 635 | }); 636 | 637 | const processUserInput = () => { 638 | if (userInput === "ArrowUp") { 639 | player.add(Move, { x: 0, y: -1 }); 640 | } 641 | if (userInput === "ArrowRight") { 642 | player.add(Move, { x: 1, y: 0 }); 643 | } 644 | if (userInput === "ArrowDown") { 645 | player.add(Move, { x: 0, y: 1 }); 646 | } 647 | if (userInput === "ArrowLeft") { 648 | player.add(Move, { x: -1, y: 0 }); 649 | } 650 | 651 | movement(); 652 | + fov(); 653 | render(); 654 | }; 655 | ``` 656 | 657 | Now that we're adding the Position component in index.js we can delete the line where we were doing it in `./src/state/ecs.js`: 658 | 659 | ```diff 660 | export const player = ecs.createEntity(); 661 | player.add(Appearance, { char: "@", color: "#fff" }); 662 | -player.add(Position); 663 | player.add(Layer400); 664 | ``` 665 | 666 | We're on the home stretch! We just need a couple more edits to our render system. The queries need to know about `IsInFov` and `IsRevealed`. We will use another property in the queries, `any`. The any property will exclude any entity that does not have at least one of the specified components. So we now require that entities have all of `[Position, Appearance, Layer100]` and at least one of `[IsInFov, IsRevealed]` 667 | 668 | In `./src/systems/render.js` make the following changes: 669 | 670 | ```diff 671 | import ecs from "../state/ecs"; 672 | import { 673 | Appearance, 674 | + IsInFov, 675 | + IsRevealed, 676 | Position, 677 | Layer100, 678 | Layer300, 679 | ``` 680 | 681 | ```diff 682 | const layer100Entities = ecs.createQuery({ 683 | all: [Position, Appearance, Layer100], 684 | + any: [IsInFov, IsRevealed], 685 | }); 686 | 687 | const layer300Entities = ecs.createQuery({ 688 | all: [Position, Appearance, Layer300], 689 | + any: [IsInFov, IsRevealed], 690 | }); 691 | 692 | const layer400Entities = ecs.createQuery({ 693 | - all: [Position, Appearance, Layer400], 694 | + all: [Position, Appearance, Layer400, IsInFov], 695 | }); 696 | ``` 697 | 698 | Layer400 requires all entities have `IsInFov`. This layer will have monsters - and we only want monster locations to be revealed if they are actually in view. 699 | 700 | Go ahead and take a look at the game - cool huh? We could stop there but let's make one more improvement. Let's change the color of entities that have been revealed but are no longer FOV. We will use the options override in drawCell to do that. Go ahead and make the render function look like this: 701 | 702 | ```javascript 703 | export const render = () => { 704 | clearCanvas(); 705 | 706 | layer100Entities.get().forEach((entity) => { 707 | if (entity.isInFov) { 708 | drawCell(entity); 709 | } else { 710 | drawCell(entity, { color: "#333" }); 711 | } 712 | }); 713 | 714 | layer300Entities.get().forEach((entity) => { 715 | if (entity.isInFov) { 716 | drawCell(entity); 717 | } else { 718 | drawCell(entity, { color: "#333" }); 719 | } 720 | }); 721 | 722 | layer400Entities.get().forEach((entity) => { 723 | if (entity.isInFov) { 724 | drawCell(entity); 725 | } else { 726 | drawCell(entity, { color: "#100" }); 727 | } 728 | }); 729 | }; 730 | ``` 731 | 732 | Very nice! Now the difference between tiles in our field of vision and those that we know about but can't currently see is obvious. 733 | 734 | --- 735 | 736 | Wow, one more done! 737 | 738 | In part 4 we refactored our draw function to support backgrounds, introduced layers, and built a basic cache, all before implementing a major new feature - Field of Vision! You should be very proud of yourself for making it this far - the game is shaping up! Commit everything to github and deploy! 739 | 740 | In the [part 5](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part5.md) we get to finally add some goblins and kick 'em around! See you there :) 741 | -------------------------------------------------------------------------------- /tutorial/part5.md: -------------------------------------------------------------------------------- 1 | # Part 5 - Placing Enemies and kicking them (harmlessly) 2 | 3 | We can finally walk around the dungeon - but it appears nobody's home. This tutorial will focus on placing monsters in the dungeon! And as the title implies, we'll kick them (harmlessly). 4 | 5 | Let's start by adding some goblins to our dungeon. Our first challenge is to figure our where we can place our them. We don't want them stuck in walls or on top of the player or each other. 6 | 7 | Currently our dungeon has walls and floors. Floors are nonblocking and usually empty. Lets get an array of all of the floor locations for a basic start. 8 | 9 | In `./src/index.js` make this change: 10 | 11 | ```diff 12 | player.add(Position, { 13 | y: dungeon.rooms[0].center.y, 14 | }); 15 | 16 | +const openTiles = Object.values(dungeon.tiles).filter( 17 | + (x) => x.sprite === "FLOOR" 18 | +); 19 | 20 | fov(); 21 | ``` 22 | 23 | We're just collecting all the tiles from our dungeon algorithm with floor sprites. Good enough to start. Now that we have some locations let's just jump right in and make some goblins! 24 | 25 | ```diff 26 | const openTiles = Object.values(dungeon.tiles).filter( 27 | (x) => x.sprite === "FLOOR" 28 | ); 29 | 30 | +times(5, () => { 31 | + const tile = sample(openTiles); 32 | + 33 | + const goblin = ecs.createEntity(); 34 | + goblin.add(Appearance, { char: "g", color: "green" }); 35 | + goblin.add(Layer400); 36 | + goblin.add(Position, { x: tile.x, y: tile.y }); 37 | +}); 38 | 39 | fov(); 40 | ``` 41 | 42 | We're using a couple function from lodash again - times, we've seen. Sample is just a quick way to grab a random element from an array. 43 | 44 | We need to import a few things at the top of this file before moving on: 45 | 46 | ```diff 47 | +import { sample, times } from "lodash"; 48 | import "./lib/canvas.js"; 49 | import { grid } from "./lib/canvas"; 50 | import { createDungeon } from "./lib/dungeon"; 51 | import { fov } from "./systems/fov"; 52 | import { movement } from "./systems/movement"; 53 | import { render } from "./systems/render"; 54 | -import { player } from "./state/ecs"; 55 | +import ecs, { player } from "./state/ecs"; 56 | import { 57 | + Appearance, 58 | + Layer400, 59 | Move, 60 | Position, 61 | } from "./state/components"; 62 | ``` 63 | 64 | Try it out and see if you can find the goblins! 65 | 66 | Our dungeon now has some inhabitants! Although you may have noticed we can walk right through them as if they were ghosts and ghosts are terrible for kicking. Add the `IsBlocking` component to make them corporeal. 67 | 68 | ```diff 69 | times(5, () => { 70 | const tile = sample(openTiles); 71 | 72 | const goblin = ecs.createEntity(); 73 | goblin.add(Appearance, { char: "g", color: "green" }); 74 | + goblin.add(IsBlocking); 75 | goblin.add(Layer400); 76 | goblin.add(Position, { x: tile.x, y: tile.y }); 77 | }); 78 | ``` 79 | 80 | And import it: 81 | 82 | ```diff 83 | import { 84 | Appearance, 85 | + IsBlocking, 86 | Layer400, 87 | Move, 88 | Position, 89 | } from "./state/components"; 90 | ``` 91 | 92 | Now for the kicking part. In `./src/systems/movement.js` when blockers exist - let's log to console. 93 | 94 | ```diff 95 | if (blockers.length) { 96 | + console.log('Kick!') 97 | 98 | entity.remove(Move); 99 | return; 100 | } 101 | ``` 102 | 103 | Ok - let's improve on this a bit by adding a new component that will let us log exactly who kicked what. In `./src/state/components` add a new component: 104 | 105 | ```javascript 106 | export class Description extends Component { 107 | static properties = { name: "No Name" }; 108 | } 109 | ``` 110 | 111 | We need to register our new component in `./src/index.js` but while we're there let's add it to our player entity as well. 112 | 113 | ```diff 114 | import { Engine } from "geotic"; 115 | import { cache } from "./cache"; 116 | import { 117 | Appearance, 118 | + Description, 119 | IsBlocking, 120 | IsInFov, 121 | IsOpaque, 122 | IsRevealed, 123 | Layer100, 124 | Layer400, 125 | Move, 126 | Position, 127 | } from "./components"; 128 | 129 | const ecs = new Engine(); 130 | 131 | // all Components must be `registered` by the engine 132 | ecs.registerComponent(Appearance); 133 | +ecs.registerComponent(Description); 134 | ecs.registerComponent(IsBlocking); 135 | ecs.registerComponent(IsInFov); 136 | ecs.registerComponent(IsOpaque); 137 | ecs.registerComponent(IsRevealed); 138 | ecs.registerComponent(Layer100); 139 | ecs.registerComponent(Layer400); 140 | ecs.registerComponent(Move); 141 | ecs.registerComponent(Position); 142 | 143 | export const player = ecs.createEntity(); 144 | player.add(Appearance, { char: "@", color: "#fff" }); 145 | player.add(Layer400); 146 | +player.add(Description, { name: 'You' }) 147 | 148 | export default ecs; 149 | ``` 150 | 151 | We need to import the component add it to our goblins in `./src/index.js`: 152 | 153 | ```diff 154 | import { 155 | Appearance, 156 | + Description 157 | IsBlocking, 158 | Layer400, 159 | Move, 160 | Position, 161 | } from "./state/components"; 162 | ``` 163 | 164 | ```diff 165 | times(100, () => { 166 | const tile = sample(openTiles); 167 | 168 | const goblin = ecs.createEntity(); 169 | goblin.add(Appearance, { char: "g", color: "green" }); 170 | + goblin.add(Description, { name: "goblin" }); 171 | goblin.add(IsBlocking); 172 | goblin.add(Layer400); 173 | goblin.add(Position, { x: tile.x, y: tile.y }); 174 | }); 175 | ``` 176 | 177 | And we should also import and add it to our dungeon walls and floors in `./src/lib/dungeon.js` 178 | 179 | ```diff 180 | import { 181 | Appearance, 182 | + Description, 183 | IsBlocking, 184 | IsOpaque, 185 | Layer100, 186 | Position, 187 | } from "../state/components"; 188 | ``` 189 | 190 | ```diff 191 | if (tile.sprite === "WALL") { 192 | const entity = ecs.createEntity(); 193 | entity.add(Appearance, { char: "#", color: "#AAA" }); 194 | + entity.add(Description, { name: "wall" }); 195 | entity.add(IsBlocking); 196 | entity.add(IsOpaque); 197 | entity.add(Position, dungeon.tiles[key]); 198 | entity.add(Layer100); 199 | } 200 | 201 | if (tile.sprite === "FLOOR") { 202 | const entity = ecs.createEntity(); 203 | entity.add(Appearance, { char: "•", color: "#555" }); 204 | + entity.add(Description, { name: "floor" }); 205 | entity.add(Position, dungeon.tiles[key]); 206 | entity.add(Layer100); 207 | } 208 | ``` 209 | 210 | Oof - our components are entity definitions are really getting spread out... there's gotta be a better to add new components across entities. There is - we'll refactor that later. For now, let's push forward and modify `./src/systems/movement.js`. Replace `console.log('Kick!')` with: 211 | 212 | ```javascript 213 | blockers.forEach((eId) => { 214 | const attacker = 215 | (entity.description && entity.description.name) || "something"; 216 | const target = 217 | (ecs.getEntity(eId).description && ecs.getEntity(eId).description.name) || 218 | "something"; 219 | console.log(`${attacker} kicked a ${target}!`); 220 | }); 221 | ``` 222 | 223 | Check our your game! Kick stuff! Look in the console - it says "You kicked a goblin!" Pretty cool. 224 | 225 | But let's be honest - it's not really that fun to kick a goblin if it can't kick back right? We should probably give them a turn... 226 | 227 | You know what we don't have yet? A game loop! We don't need anything complicated but if we had a game loop we could give the goblins a turn to kick! 228 | 229 | We need a way to track whose turn it is. Add a variable `playerTurn` just below userInput. 230 | 231 | ```diff 232 | render(); 233 | 234 | let userInput = null; 235 | +let playerTurn = true; 236 | 237 | document.addEventListener("keydown", (ev) => { 238 | ``` 239 | 240 | Our gameLoop will call `processUserInput` from now on. So we can delete it from our keydown event listener. 241 | 242 | ```diff 243 | document.addEventListener("keydown", (ev) => { 244 | userInput = ev.key; 245 | - processUserInput(); 246 | }); 247 | ``` 248 | 249 | The gameloop will also handle running our systems so we can delete them from processUserInput. We also need to clear userInput after it's been processed for our loop to work correctly. We can just set it null at the end. 250 | 251 | ```diff 252 | const processUserInput = () => { 253 | if (userInput === "ArrowUp") { 254 | player.add(Move, { x: 0, y: -1 }); 255 | } 256 | if (userInput === "ArrowRight") { 257 | player.add(Move, { x: 1, y: 0 }); 258 | } 259 | if (userInput === "ArrowDown") { 260 | player.add(Move, { x: 0, y: 1 }); 261 | } 262 | if (userInput === "ArrowLeft") { 263 | player.add(Move, { x: -1, y: 0 }); 264 | } 265 | 266 | - movement(); 267 | - fov(); 268 | - render(); 269 | + userInput = null; 270 | }; 271 | ``` 272 | 273 | Our gameloop is pretty simple, looks like this, and will live in `./src/index.js` at the very bottom: 274 | 275 | ```javascript 276 | const gameLoop = () => { 277 | update(); 278 | requestAnimationFrame(gameLoop); 279 | }; 280 | 281 | requestAnimationFrame(gameLoop); 282 | ``` 283 | 284 | You can read more about [requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame) on MDN. For our purposes, it's just a nice simple way to get our gameLoop going in the browser. 285 | 286 | Inside out gameLoop we call `update()`. Let's add that and add it right above the gameLoop. 287 | 288 | ```javascript 289 | const update = () => { 290 | if (playerTurn && userInput) { 291 | processUserInput(); 292 | movement(); 293 | fov(); 294 | render(); 295 | 296 | playerTurn = false; 297 | } 298 | 299 | if (!playerTurn) { 300 | movement(); 301 | fov(); 302 | render(); 303 | 304 | playerTurn = true; 305 | } 306 | }; 307 | ``` 308 | 309 | Everytime this function runs we test if it's the player's turn AND they have pushed a key `userInput`. If both of those are true, we run our player systems and set playerTurn to false. If playerTurn is false, we run our monster systems giving them a turn. 310 | 311 | If it's the player's turn but they haven't push a key yet, the loop just goes back around without doing anything. It's a no-op. 312 | 313 | We can test this by adding an "ai" system. It should only run on the monster's turn and for now will just give them something to think about. 314 | 315 | Our ai system is super simple for now. Create a new file called `ai.js` at `./src/systems/ai.js` and make it look like this: 316 | 317 | ```javascript 318 | import ecs from "../state/ecs"; 319 | import { Ai, Description } from "../state/components"; 320 | 321 | const aiEntities = ecs.createQuery({ 322 | all: [Ai, Description], 323 | }); 324 | 325 | export const ai = () => { 326 | aiEntities.get().forEach((entity) => { 327 | console.log( 328 | `${entity.description.name} ${entity.id} ponders it's existence.` 329 | ); 330 | }); 331 | }; 332 | ``` 333 | 334 | We create a query to get our entities with `[Ai, Description]` components. Then we just iterate over them and console a simple statement so it's obvious each of the goblins got a turn. 335 | 336 | Now we need to make the Ai component and register it. 337 | 338 | In `./src/state/components.js` 339 | 340 | ```javascript 341 | export class Ai extends Component {} 342 | ``` 343 | 344 | And `./src/state/ecs` 345 | 346 | ```diff 347 | import { Engine } from "geotic"; 348 | import { cache } from "./cache"; 349 | import { 350 | + Ai, 351 | Appearance, 352 | Description, 353 | IsBlocking, 354 | ``` 355 | 356 | ```diff 357 | // all Components must be `registered` by the engine 358 | +ecs.registerComponent(Ai); 359 | ecs.registerComponent(Appearance); 360 | ``` 361 | 362 | Finally we need to add the Ai component to our goblins and run the system in `./src/index.js`. 363 | 364 | First we import the system and the component: 365 | 366 | ```diff 367 | import "./lib/canvas.js"; 368 | import { grid } from "./lib/canvas"; 369 | import { createDungeon } from "./lib/dungeon"; 370 | +import { ai } from "./systems/ai"; 371 | import { fov } from "./systems/fov"; 372 | import { movement } from "./systems/movement"; 373 | import { render } from "./systems/render"; 374 | import ecs, { player } from "./state/ecs"; 375 | import { 376 | + Ai, 377 | Appearance, 378 | Description, 379 | IsBlocking, 380 | ``` 381 | 382 | Then we add the component to our goblins: 383 | 384 | ```diff 385 | const goblin = ecs.createEntity(); 386 | + goblin.add(Ai); 387 | goblin.add(Appearance, { char: "g", color: "green" }); 388 | goblin.add(Description, { name: "goblin" }); 389 | goblin.add(IsBlocking); 390 | goblin.add(Layer400); 391 | goblin.add(Position, { x: tile.x, y: tile.y }); 392 | goblin.add(Description, { name: "goblin" }); 393 | }); 394 | ``` 395 | 396 | And finally we call the Ai system on the monsters turn - NOT on the player's turn. 397 | 398 | ```diff 399 | const update = () => { 400 | if (playerTurn && userInput) { 401 | + console.log('I am @, hear me roar.') 402 | processUserInput(); 403 | movement(); 404 | fov(); 405 | render(); 406 | 407 | playerTurn = false; 408 | } 409 | 410 | if (!playerTurn) { 411 | + ai(); 412 | movement(); 413 | fov(); 414 | render(); 415 | 416 | playerTurn = true; 417 | } 418 | }; 419 | ``` 420 | 421 | Play the game again - with the console open and check out the logs. The player gets a go - and then the goblins. Plenty of time for kicking on both sides! 422 | 423 | --- 424 | 425 | In part 5 we finally added some goblins to our game! So far all they do is stand around and think about things while we kick them (harmlessly). To give them a shot at us we also (finally) added a game loop! 426 | 427 | In [part 6](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part6.md) we look at doing (and taking) some damage when we add combat! See ya there :) 428 | -------------------------------------------------------------------------------- /tutorial/part6.md: -------------------------------------------------------------------------------- 1 | # Part 6 - Doing (and taking) some damage 2 | 3 | In the last part we setup the groundwork for combat. Now we get to actually implement it. To do that we will need to create some new components to hold data related to combat. 4 | 5 | Yet again we're about to add more components. 6 | 7 | Remember in the last section when we added the description component to all of our entities and discovered they were scattered all over the codebase? That's only gonna get worse the bigger our game gets. It's time for another refactor :) 8 | 9 | Refactoring is a natural part of the creative process - so learn to love it! 10 | 11 | Instead of creating entities on the fly we are going to use prefabs instead. Prefabs are great because you can create basic prefabs that other more complex prefabs can inherit from. We're going to create a base "Tile" prefabs that both "Wall" and "Floor" can inherit from. Then we'll do a base "Being" prefab that our Player and Goblin prefabs can inherit from. 12 | 13 | In the end prefabs are just composable blueprints for entities. In geotic they are modeled after [this talk by Thomas Biskup](https://www.youtube.com/watch?v=fGLJC5UY2o4&t=1534s). Let's go ahead and create some so you can see how they consolidate our code and make it easier to maintain in the long run. 14 | 15 | Create a new file called `prefabs.js` at `./src/state/prefab.js`. Make it look like this: 16 | 17 | ```javascript 18 | // Base 19 | export const Tile = { 20 | name: "Tile", 21 | components: [ 22 | { type: "Appearance" }, 23 | { type: "Description" }, 24 | { type: "Layer100" }, 25 | ], 26 | }; 27 | 28 | export const Being = { 29 | name: "Being", 30 | components: [ 31 | { type: "Appearance" }, 32 | { type: "Description" }, 33 | { type: "IsBlocking" }, 34 | { type: "Layer400" }, 35 | ], 36 | }; 37 | 38 | // Complex 39 | export const Wall = { 40 | name: "Wall", 41 | inherit: ["Tile"], 42 | components: [ 43 | { type: "IsBlocking" }, 44 | { type: "IsOpaque" }, 45 | { 46 | type: "Appearance", 47 | properties: { char: "#", color: "#AAA" }, 48 | }, 49 | { 50 | type: "Description", 51 | properties: { name: "wall" }, 52 | }, 53 | ], 54 | }; 55 | 56 | export const Floor = { 57 | name: "Floor", 58 | inherit: ["Tile"], 59 | components: [ 60 | { 61 | type: "Appearance", 62 | properties: { char: "•", color: "#555" }, 63 | }, 64 | { 65 | type: "Description", 66 | properties: { name: "floor" }, 67 | }, 68 | ], 69 | }; 70 | 71 | export const Player = { 72 | name: "Player", 73 | inherit: ["Being"], 74 | components: [ 75 | { 76 | type: "Appearance", 77 | properties: { char: "@", color: "#FFF" }, 78 | }, 79 | { 80 | type: "Description", 81 | properties: { name: "You" }, 82 | }, 83 | ], 84 | }; 85 | 86 | export const Goblin = { 87 | name: "Goblin", 88 | inherit: ["Being"], 89 | components: [ 90 | { type: "Ai" }, 91 | { 92 | type: "Appearance", 93 | properties: { char: "g", color: "green" }, 94 | }, 95 | { 96 | type: "Description", 97 | properties: { name: "goblin" }, 98 | }, 99 | ], 100 | }; 101 | ``` 102 | 103 | Because prefabs must be registered before they can be inherited I find it useful to keep my base prefabs and more complex ones separated. Speaking of registering prefabs - let's do that in `./src/state/ecs` 104 | 105 | First we import them: 106 | 107 | ```diff 108 | } from "./components"; 109 | 110 | +import { Being, Tile, Goblin, Player, Wall, Floor } from "./prefabs"; 111 | 112 | const ecs = new Engine(); 113 | ``` 114 | 115 | And next we register them after our components - taking care to register your base prefabs first. 116 | 117 | ```diff 118 | ecs.registerComponent(Move); 119 | ecs.registerComponent(Position); 120 | 121 | +// register "base" prefabs first! 122 | +ecs.registerPrefab(Tile); 123 | +ecs.registerPrefab(Being); 124 | + 125 | +ecs.registerPrefab(Wall); 126 | +ecs.registerPrefab(Floor); 127 | +ecs.registerPrefab(Goblin); 128 | +ecs.registerPrefab(Player); 129 | ``` 130 | 131 | Now we can refactor our game to use prefabs instead of creating entities all over the place. Beginning in `./src/lib/dungeon.js` we can get rid of a bunch of imports and simpify things quite a bit. 132 | 133 | ```diff 134 | import { rectangle, rectsIntersect } from "./grid"; 135 | 136 | import { 137 | - Appearance, 138 | - Description, 139 | - IsBlocking, 140 | - IsOpaque, 141 | - Layer100, 142 | Position, 143 | } from "../state/components"; 144 | 145 | function digHorizontalPassage(x1, x2, y) { 146 | ``` 147 | 148 | ```diff 149 | if (tile.sprite === "WALL") { 150 | - const entity = ecs.createEntity(); 151 | - entity.add(Appearance, { char: "#", color: "#AAA" }); 152 | - entity.add(IsBlocking); 153 | - entity.add(IsOpaque); 154 | - entity.add(Position, dungeon.tiles[key]); 155 | - entity.add(Layer100); 156 | - entity.add(Description, { name: "wall" }); 157 | + ecs.createPrefab("Wall").add(Position, dungeon.tiles[key]); 158 | } 159 | 160 | if (tile.sprite === "FLOOR") { 161 | - const entity = ecs.createEntity(); 162 | - entity.add(Appearance, { char: "•", color: "#555" }); 163 | - entity.add(Position, dungeon.tiles[key]); 164 | - entity.add(Layer100); 165 | - entity.add(Description, { name: "floor" }); 166 | + ecs.createPrefab("Floor").add(Position, dungeon.tiles[key]); 167 | } 168 | ``` 169 | 170 | Ahh... deleting code feels so nice - let's do more! 171 | 172 | We won't need to create our player entity in `./src/state/ecs.js` anymore so we can clean up a bit: 173 | 174 | ```diff 175 | ecs.registerPrefab(Goblin); 176 | ecs.registerPrefab(Player); 177 | 178 | -export const player = ecs.createEntity(); 179 | -player.add(Appearance, { char: "@", color: "#fff" }); 180 | -player.add(Layer400); 181 | -player.add(Description, { name: "You" }); 182 | 183 | export default ecs; 184 | ``` 185 | 186 | Next in `./src/index.js`: 187 | 188 | ```diff 189 | import { render } from "./systems/render"; 190 | -import ecs, { player } from "./state/ecs"; 191 | +import ecs from "./state/ecs"; 192 | import { 193 | - Ai, 194 | - Appearance, 195 | - Description, 196 | - IsBlocking, 197 | - Layer400, 198 | Move, 199 | Position, 200 | } from "./state/components"; 201 | 202 | // init game map and player position 203 | const dungeon = createDungeon({ 204 | x: grid.map.x, 205 | y: grid.map.y, 206 | width: grid.map.width, 207 | height: grid.map.height, 208 | }); 209 | 210 | +const player = ecs.createPrefab("Player"); 211 | player.add(Position, { 212 | x: dungeon.rooms[0].center.x, 213 | y: dungeon.rooms[0].center.y, 214 | }); 215 | ``` 216 | 217 | ```diff 218 | times(5, () => { 219 | const tile = sample(openTiles); 220 | - 221 | - const goblin = ecs.createEntity(); 222 | - goblin.add(Ai); 223 | - goblin.add(Appearance, { char: "g", color: "green" }); 224 | - goblin.add(Description, { name: "goblin" }); 225 | - goblin.add(IsBlocking); 226 | - goblin.add(Layer400); 227 | - goblin.add(Position, { x: tile.x, y: tile.y }); 228 | + ecs.createPrefab("Goblin").add(Position, { x: tile.x, y: tile.y }); 229 | }); 230 | ``` 231 | 232 | The last big of refactoring we need to do is in `./systems/fov.js`. We had been importing our player entity to use as the origin for our FOV. We're going to pass in a generic origin instead. 233 | 234 | ```diff 235 | import { readCacheSet } from "../state/cache"; 236 | -import ecs, { player } from "../state/ecs"; 237 | +import ecs from "../state/ecs"; 238 | import { grid } from "../lib/canvas"; 239 | import createFOV from "../lib/fov"; 240 | import { IsInFov, IsOpaque, IsRevealed } from "../state/components"; 241 | ``` 242 | 243 | ```diff 244 | const opaqueEntities = ecs.createQuery({ 245 | all: [IsOpaque], 246 | }); 247 | 248 | -export const fov = () => { 249 | +export const fov = (origin) => { 250 | const { width, height } = grid; 251 | 252 | - const originX = player.position.x; 253 | - const originY = player.position.y; 254 | + const originX = origin.position.x; 255 | + const originY = origin.position.y; 256 | 257 | const FOV = createFOV(opaqueEntities, width, height, originX, originY, 10); 258 | ``` 259 | 260 | And finally we need to pass in the origin everywhere we call our fov system. Back to `./src/index.js`! 261 | 262 | We call fov during initialization of the game: 263 | 264 | ```diff 265 | -fov(); 266 | +fov(player); 267 | render(); 268 | 269 | let userInput = null; 270 | ``` 271 | 272 | And in the loop: 273 | 274 | ```diff 275 | const update = () => { 276 | if (playerTurn && userInput) { 277 | console.log("I am @, hear me roar."); 278 | processUserInput(); 279 | movement(); 280 | - fov(); 281 | + fov(player); 282 | render(); 283 | 284 | playerTurn = false; 285 | } 286 | 287 | if (!playerTurn) { 288 | ai(); 289 | movement(); 290 | - fov(); 291 | + fov(player); 292 | render(); 293 | 294 | playerTurn = true; 295 | } 296 | }; 297 | ``` 298 | 299 | With that refactor out of the way - it's time to tackle combat! 300 | 301 | Our combat simulation will be pretty basic to start. We can start with health, power, and defense. Let's add those components now in `./src/state/components.js`: 302 | 303 | ```javascript 304 | export class Defense extends Component { 305 | static properties = { max: 1, current: 1 }; 306 | } 307 | 308 | export class Health extends Component { 309 | static properties = { max: 10, current: 10 }; 310 | } 311 | 312 | export class Power extends Component { 313 | static properties = { max: 5, current: 5 }; 314 | } 315 | ``` 316 | 317 | And as always, we need to register them in `./src/state/ecs.js`: 318 | 319 | ```diff 320 | import { 321 | Ai, 322 | Appearance, 323 | Description, 324 | + Defense, 325 | + Health, 326 | IsBlocking, 327 | IsInFov, 328 | IsOpaque, 329 | IsRevealed, 330 | Layer100, 331 | Layer400, 332 | Move, 333 | Position, 334 | + Power, 335 | } from "./components"; 336 | ``` 337 | 338 | ```diff 339 | // all Components must be `registered` by the engine 340 | ecs.registerComponent(Ai); 341 | ecs.registerComponent(Appearance); 342 | ecs.registerComponent(Description); 343 | +ecs.registerComponent(Defense); 344 | +ecs.registerComponent(Health); 345 | ecs.registerComponent(IsBlocking); 346 | ecs.registerComponent(IsInFov); 347 | ecs.registerComponent(IsOpaque); 348 | ecs.registerComponent(IsRevealed); 349 | ecs.registerComponent(Layer100); 350 | ecs.registerComponent(Layer400); 351 | ecs.registerComponent(Move); 352 | ecs.registerComponent(Position); 353 | +ecs.registerComponent(Power); 354 | 355 | // register "primitives" first! 356 | ``` 357 | 358 | Because of our refactor earlier we can just add these new components to our base "Being" prefab! No more having to find every instantiated entity in the code base every time we want to add new components! 359 | 360 | In `./src/state/prefabs` just make this change: 361 | 362 | ```diff 363 | export const Being = { 364 | name: "Being", 365 | components: [ 366 | { type: "Appearance" }, 367 | + { type: "Defense" }, 368 | { type: "Description" }, 369 | + { type: "Health" }, 370 | { type: "IsBlocking" }, 371 | { type: "Layer400" }, 372 | + { type: "Power" }, 373 | ], 374 | }; 375 | ``` 376 | 377 | Voilà - our player and goblins all have Defense, Health and Power components! 378 | 379 | How can we be so sure? Let's add a small bit of tooling to prove it. With it we'll be able to log any entity on the map with a click of the mouse! 380 | 381 | We first need a function to translate a mouse click to a location of our grid. In `./src/lib/canvas.js` add this function to the end of the file to do just that: 382 | 383 | ```javascript 384 | export const pxToCell = (ev) => { 385 | const bounds = canvas.getBoundingClientRect(); 386 | const relativeX = ev.clientX - bounds.left; 387 | const relativeY = ev.clientY - bounds.top; 388 | const colPos = Math.trunc((relativeX / cellWidth) * pixelRatio); 389 | const rowPos = Math.trunc((relativeY / cellHeight) * pixelRatio); 390 | 391 | return [colPos, rowPos]; 392 | }; 393 | ``` 394 | 395 | Then in `./src/index.js` we have to import a few things to use it: 396 | 397 | ```diff 398 | -import { sample, times } from "lodash"; 399 | +import { get, sample, times } from "lodash"; 400 | import "./lib/canvas.js"; 401 | -import { grid } from "./lib/canvas"; 402 | +import { grid, pxToCell } from "./lib/canvas"; 403 | +import { toLocId } from "./lib/grid"; 404 | +import { readCacheSet } from "./state/cache"; 405 | import { createDungeon } from "./lib/dungeon"; 406 | ``` 407 | 408 | Then at the bottom the file add: 409 | 410 | ```javascript 411 | // Only do this during development 412 | if (process.env.NODE_ENV === "development") { 413 | const canvas = document.querySelector("#canvas"); 414 | 415 | canvas.onclick = (e) => { 416 | const [x, y] = pxToCell(e); 417 | const locId = toLocId({ x, y }); 418 | 419 | readCacheSet("entitiesAtLocation", locId).forEach((eId) => { 420 | const entity = ecs.getEntity(eId); 421 | 422 | console.log( 423 | `${get(entity, "appearance.char", "?")} ${get( 424 | entity, 425 | "description.name", 426 | "?" 427 | )}`, 428 | entity.serialize() 429 | ); 430 | }); 431 | }; 432 | } 433 | ``` 434 | 435 | All this does is use our pxToCell function to calculate a locId from a mouse click. We can then get all the entities at that locId from our cache and log them to the console. 436 | 437 | Run the game and click on your @ with the developer console open. The player entity (and the floor it's standing on) should print to the console. You can inspect this object to prove that all the expected components are there! 438 | 439 | Finally, it's time to ATTACK! 440 | 441 | In a strict ECS architecture we would accomplish this with more components and systems. Maybe a TakeDamage component that stores the amount of damage to be reduced. Then another system that would actually remove the damage. But strict adherance to principle is it's own can of worms. Let's add another tool to our toolbelts that can be used when a system might be a bit too heavy handed. 442 | 443 | Introducing Events! 444 | 445 | All an event does is send a message to all components on an entity. [This talk by Brian Bucklew](https://www.youtube.com/watch?v=4uxN5GqXcaA) explains why this might be useful - and also happens to be what inspired the event system in geotic that we are using. I struggled for a while trying to understand when to use an event and when to use a system but finally settled on only using events for simple things like component management on their parent entity. Once you need things like queries you're better off using a system. In the end you will have to decide for yourself based on the challenges your game presents. 446 | 447 | In `./src/state/components.js` we need to add an event handler to our Health component: 448 | 449 | ```diff 450 | export class Health extends Component { 451 | static properties = { max: 10, current: 10 }; 452 | 453 | + onTakeDamage(evt) { 454 | + this.current -= evt.data.amount; 455 | + evt.handle(); 456 | + } 457 | } 458 | ``` 459 | 460 | We can fire the `take-damage` event on any entity. If that entity has a component that cares, it gets handled, else it's just ignored. 461 | 462 | Let's use our new powers in `./src/systems/movement.js`. First create a new function, `attack` that will fire the event: 463 | 464 | ```diff 465 | all: [Move], 466 | }); 467 | 468 | +const attack = (entity, target) => { 469 | + const damage = entity.power.current - target.defense.current; 470 | + target.fireEvent("take-damage", { amount: damage }); 471 | + console.log(`You kicked a ${target.description.name} for ${damage} damage!`); 472 | +}; 473 | 474 | export const movement = () => { 475 | ``` 476 | 477 | Next, instead of kicking, when encountering a blocker, we can attack instead! 478 | 479 | ```diff 480 | if (blockers.length) { 481 | - blockers.forEach((eId) => { 482 | - const attacker = 483 | - (entity.description && entity.description.name) || "something"; 484 | - const target = 485 | - (ecs.getEntity(eId).description && 486 | - ecs.getEntity(eId).description.name) || 487 | - "something"; 488 | - console.log(`${attacker} kicked a ${target}!`); 489 | - }); 490 | + blockers.forEach((eId) => { 491 | + const target = ecs.getEntity(eId); 492 | + if (target.has("Health") && target.has("Defense")) { 493 | + attack(entity, target); 494 | + } else { 495 | + console.log( 496 | + `${entity.description.name} bump into a ${target.description.name}` 497 | + ); 498 | + } 499 | + }); 500 | entity.remove(Move); 501 | return; 502 | } 503 | ``` 504 | 505 | Try it out! Run the game with the developer console open - after attacking a goblin, click on it and inspect it's health component to see if you did the damage you expect. 506 | 507 | Pretty cool! 508 | 509 | Although you may have noticed something if you kicked your goblin more than a few times - it's health goes into the negative... Time to implement death. 510 | 511 | Right after the attack function, add kill: 512 | 513 | ```javascript 514 | const kill = (entity) => { 515 | entity.appearance.char = "%"; 516 | entity.remove("Ai"); 517 | entity.remove("IsBlocking"); 518 | entity.remove("Layer400"); 519 | entity.add("Layer300"); 520 | }; 521 | ``` 522 | 523 | Nothing crazy happening here. Just changing the char to a corpse (%) and removing some components. Without "Ai" our dead goblin doesn't get a turn anymore. We also want to remove "IsBlocking" so and change the layer from the main layer our @ inhabits to the item layer. 524 | 525 | Looks like I forgot to register the Layer300 component - do that now in `./src/state/ecs` if you also forgot :P 526 | 527 | Finally, let's update our attack function to kill the target if it's health is at or below 0: 528 | 529 | ```javascript 530 | const attack = (entity, target) => { 531 | const damage = entity.power.current - target.defense.current; 532 | target.fireEvent("take-damage", { amount: damage }); 533 | 534 | if (target.health.current <= 0) { 535 | kill(target); 536 | 537 | return console.log( 538 | `You kicked a ${target.description.name} for ${damage} damage and killed it!` 539 | ); 540 | } 541 | 542 | console.log(`You kicked a ${target.description.name} for ${damage} damage!`); 543 | }; 544 | ``` 545 | 546 | Yeah! These dirty goblins don't stand a chance!!! 547 | 548 | Ok - this isn't really fair. They're completely defenseless. Let's give them a chance with some actual (albeit basic) AI! 549 | 550 | Our ai only needs to be smart enough to path towards a target. We already handle bump attacks so if we can teach our goblins to path to the player they will attack on contact. 551 | 552 | For pathing to a target we will be using an [A\*](https://en.wikipedia.org/wiki/A*_search_algorithm) implementation from [Pathfinding.js](https://www.npmjs.com/package/pathfinding). Pathfinding.js includes an array of pathing algorithms that all have their own particular uses. You can explore them on your own in their [online demo](http://qiao.github.io/PathFinding.js/visual/). 553 | 554 | To start go ahead and install the library: 555 | 556 | ```bash 557 | npm install pathfinding 558 | ``` 559 | 560 | There is a bit of setup required each time you want to calculate a path. The algorithm needs a matrix that describes all blocking and unblocking locations. Let's build a function to handle that for us. Start by creating a file called `pathfinding.js` in our lib directory at `./src/lib/pathfinding.js`. This will export a function that will require and start and an end goal and return a path that we can use elsewhere. 561 | 562 | It should look like this: 563 | 564 | ```javascript 565 | import PF from "pathfinding"; 566 | import { some, times } from "lodash"; 567 | import ecs from "../state/ecs"; 568 | import cache, { readCacheSet } from "../state/cache"; 569 | import { toCell } from "./grid"; 570 | import { grid } from "./canvas"; 571 | 572 | const baseMatrix = []; 573 | times(grid.height, () => baseMatrix.push(new Array(grid.width).fill(0))); 574 | 575 | export const aStar = (start, goal) => { 576 | const matrix = [...baseMatrix]; 577 | 578 | const locIds = Object.keys(cache.entitiesAtLocation); 579 | 580 | locIds.forEach((locId) => { 581 | if ( 582 | some([...readCacheSet("entitiesAtLocation", locId)], (eId) => { 583 | return ecs.getEntity(eId).isBlocking; 584 | }) 585 | ) { 586 | const cell = toCell(locId); 587 | 588 | matrix[cell.y][cell.x] = 1; 589 | } 590 | }); 591 | 592 | matrix[start.y][start.x] = 0; 593 | matrix[goal.y][goal.x] = 0; 594 | 595 | const grid = new PF.Grid(matrix); 596 | const finder = new PF.AStarFinder({ 597 | allowDiagonal: false, 598 | dontCrossCorners: true, 599 | }); 600 | 601 | const path = finder.findPath(start.x, start.y, goal.x, goal.y, grid); 602 | 603 | return path; 604 | }; 605 | ``` 606 | 607 | Now let's update our ai system so the goblins can actually path to the player instead of just pondering the meaning of life. In `./src/systems/ai.js` import the aStar lib that we just created. 608 | 609 | ```diff 610 | import ecs from "../state/ecs"; 611 | import { Ai, Description } from "../state/components"; 612 | +import { aStar } from "../lib/pathfinding"; 613 | 614 | const aiEntities = ecs.createQuery({ 615 | ``` 616 | 617 | Next add a moveToTarget function just after the aiEntities query. 618 | 619 | ```javascript 620 | const moveToTarget = (entity, target) => { 621 | const path = aStar(entity.position, target.position); 622 | if (path.length) { 623 | const newLoc = path[1]; 624 | entity.add("Move", { x: newLoc[0], y: newLoc[1], relative: false }); 625 | } 626 | }; 627 | ``` 628 | 629 | This function takes an entity and a target and generates a path using aStar from the entity to the target. The path that is returned is inclusive - meaning it includes the beginning position and the target position. This is why we use `path[1]` as our new location. If we used `path[0]` our goblins would never move! 630 | 631 | Now we can just replace the console.log in our existing ai with a call to our new function moveToTarget. 632 | 633 | ```diff 634 | -export const ai = () => { 635 | +export const ai = (player) => { 636 | aiEntities.get().forEach((entity) => { 637 | - console.log( 638 | - `${entity.description.name} ${entity.id} ponders it's existence.` 639 | - ); 640 | + if (entity.has("IsInFov")) { 641 | + moveToTarget(entity, player); 642 | + } 643 | }); 644 | }; 645 | ``` 646 | 647 | You may have noticed we're now passing `player` into our ai. Eventually this should probably be stored in a target component on each entity - but for now our goblins only have one concern, so we can just pass in the player from `./src/index.js` when we call our ai system like this: 648 | 649 | ```diff 650 | if (!playerTurn) { 651 | - ai(); 652 | + ai(player); 653 | movement(); 654 | fov(player); 655 | render(); 656 | ``` 657 | 658 | Go ahead and give it a shot! Not working? Yeah, there's a bug. Try and figure it out! Debugging is a big part of game development :) 659 | 660 | --- 661 | 662 | Did you figure it out? It's ok if you didn't - this was a tricky one that took me a bit to understand. It goes back to a decision we made in part 2. Our Move component expects a relative position. We take something like `{ x: 0, y: -1 }` and add it to an entities current position to calculate the new position. But our path returns absolute positions like `{ x: 43, y: 18 }` - our goblins are trying to teleport into solid rock somewhere on our map and our movement system is failing back to "goblin bump into a wall" bacause they are trying to move into a blocking location with a wall. The simplest solution here is to modify our `Move` component and `movement` system to account for both relative and absolute positions. Let's add a flag in our Move component to let the system know what sort of position it's dealing with. In `./src/state/components.js` add a property called `relative` like this: 663 | 664 | ```diff 665 | export class Move extends Component { 666 | - static properties = { x: 0, y: 0 }; 667 | + static properties = { x: 0, y: 0, relative: true }; 668 | } 669 | ``` 670 | 671 | Now in our movement system `./src/movement.js` we just need to set `mx` and `my` to either a relative or absolute position: 672 | 673 | ```diff 674 | export const movement = () => { 675 | movableEntities.get().forEach((entity) => { 676 | - let mx = entity.position.x + entity.move.x; 677 | - let my = entity.position.y + entity.move.y; 678 | + let mx = entity.move.x; 679 | + let my = entity.move.y; 680 | + 681 | + if (entity.move.relative) { 682 | + mx = entity.position.x + entity.move.x; 683 | + my = entity.position.y + entity.move.y; 684 | + } 685 | ``` 686 | 687 | Now our goblins should move straight for our @ and attack! Give it a try! 688 | 689 | Did you win? If not, you may have noticed another interesting bug. It presents in different ways - crashing completely, becoming an undead zombie, or even gaining control of a goblin skate boarding around the dungeon on the eartly remains of our @! 690 | 691 | It all boils down to the player died and our code isn't handling it well :) 692 | 693 | Rather than try and solve for what to do after the player dies in our code we can just end the game. To do that we will add an IsDead component that gets added to entities when they die. Then in our game loop we can just bail if the player is dead. 694 | 695 | In `./src/state/components.js` add an IsDead component: 696 | 697 | ```javascript 698 | export class IsDead extends Component {} 699 | ``` 700 | 701 | Don't forget to register it in `./src/state/ecs.js`! 702 | 703 | Next in `./src/systems/movement.js` we just need to add the `IsDead` component to our kill function when an entity dies. 704 | 705 | ```diff 706 | const kill = (entity) => { 707 | entity.appearance.char = "%"; 708 | entity.remove("Ai"); 709 | entity.remove("IsBlocking"); 710 | + entity.add("IsDead"); 711 | entity.remove("Layer400"); 712 | entity.add("Layer300"); 713 | }; 714 | ``` 715 | 716 | Finally, in our update function let's return early if the player is dead in `./src/index.js` like this: 717 | 718 | ```diff 719 | const update = () => { 720 | + if (player.isDead) { 721 | + return; 722 | + } 723 | 724 | if (playerTurn && userInput) { 725 | ``` 726 | 727 | That's it! We now have slightly less stupid goblins that can actually fight back! We don't have to feel bad for fighting them anymore :) 728 | 729 | In part 6 we refactored our entities with prefabs, added tooling to inspect entities with the click of a mouse, implemented combat mechanics, and created an actual Ai for our goblins. Yeesh - that's a lot! Congrats, we're almost halfway through this dungeon crawl :) 730 | 731 | In [part 7](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part7.md) we'll get our messages out of the console and start logging directly in the UI as we focus on the rest of the interface! See you there! 732 | -------------------------------------------------------------------------------- /tutorial/part7.md: -------------------------------------------------------------------------------- 1 | # Part 7 - Creating the Interface 2 | 3 | We're getting closer and closer to a playable game but before we add additional gameplay mechanics we need to take a step back and focus on the UI. We're going to add 3 elements to our game to improve the interface. A HUD with player stats, an adventure log, and an info bar. 4 | 5 | To start, open up `./src/lib/canvas.js` and add some new properties to the grid object. These settings detail the width, height and location of each of our new elements. When complete the grid object will look like this: 6 | 7 | ```javascript 8 | export const grid = { 9 | width: 100, 10 | height: 34, 11 | 12 | map: { 13 | width: 79, 14 | height: 29, 15 | x: 21, 16 | y: 3, 17 | }, 18 | 19 | messageLog: { 20 | width: 79, 21 | height: 3, 22 | x: 21, 23 | y: 0, 24 | }, 25 | 26 | playerHud: { 27 | width: 20, 28 | height: 34, 29 | x: 0, 30 | y: 0, 31 | }, 32 | 33 | infoBar: { 34 | width: 79, 35 | height: 3, 36 | x: 21, 37 | y: 32, 38 | }, 39 | }; 40 | ``` 41 | 42 | Next we'll add a new function called `drawText`. This new function will accept a template that includes a text string, color, and position options. It splits the string into characters and builds entity like objects it can pass to our existing `drawCell` function. 43 | 44 | ```javascript 45 | export const drawText = (template) => { 46 | const textToRender = template.text; 47 | 48 | textToRender.split("").forEach((char, index) => { 49 | const options = { ...template }; 50 | const character = { 51 | appearance: { 52 | char, 53 | background: options.background, 54 | color: options.color, 55 | }, 56 | position: { 57 | x: index + options.x, 58 | y: options.y, 59 | }, 60 | }; 61 | 62 | delete options.x; 63 | delete options.y; 64 | 65 | drawCell(character, options); 66 | }); 67 | }; 68 | ``` 69 | 70 | With the foundation all in place, we can add our HUD which for now will just display our player name and a health bar. 71 | 72 | In our render system `./src/systems/render.js` we'll need to import our new function and the grid settings: 73 | 74 | ```diff 75 | -import { clearCanvas, drawCell } from "../lib/canvas"; 76 | +import { clearCanvas, drawCell, drawText, grid } from "../lib/canvas"; 77 | ``` 78 | 79 | We will also need some info from the player entity itself. As with some of our other systems we can add it as an argument: 80 | 81 | ```diff 82 | -export const render = () => { 83 | +export const render = (player) => { 84 | ``` 85 | 86 | Remember to actually pass it in from `./src/index.js` wherever render is called. There should be three calls to the render function - pass in player to each like this: 87 | 88 | ```diff 89 | -render(); 90 | +render(player); 91 | ``` 92 | 93 | Back in `./src/systems/render.js` we can use the new drawText function right after our layers are rendered: 94 | 95 | ```diff 96 | layer400Entities.get().forEach((entity) => { 97 | if (entity.isInFov) { 98 | drawCell(entity); 99 | } else { 100 | drawCell(entity, { color: "#100" }); 101 | } 102 | }); 103 | 104 | + drawText({ 105 | + text: `${player.appearance.char} ${player.description.name}`, 106 | + background: `${player.appearance.background}`, 107 | + color: `${player.appearance.color}`, 108 | + x: grid.playerHud.x, 109 | + y: grid.playerHud.y, 110 | + }); 111 | }; 112 | ``` 113 | 114 | If you try our the game now you should see the player name in the top left of the screen! 115 | 116 | Let's add a health bar just below it. 117 | 118 | To do that we'll render a grayed version below a colored version that will become shorter as our health diminishes. As our @ loses health this will create the illusion that our hearts are changing from ref to gray. 119 | 120 | Render the gray version of our health bar 121 | 122 | ```javascript 123 | drawText({ 124 | text: "♥".repeat(grid.playerHud.width), 125 | background: "black", 126 | color: "#333", 127 | x: grid.playerHud.x, 128 | y: grid.playerHud.y + 1, 129 | }); 130 | ``` 131 | 132 | Calculate player health as a percentage of max and generate a string of html heart characters of the calculated length: 133 | 134 | ```javascript 135 | const hp = player.health.current / player.health.max; 136 | 137 | if (hp > 0) { 138 | drawText({ 139 | text: "♥".repeat(hp * grid.playerHud.width), 140 | background: "black", 141 | color: "red", 142 | x: grid.playerHud.x, 143 | y: grid.playerHud.y + 1, 144 | }); 145 | } 146 | ``` 147 | 148 | Give it a go! Attack some goblins and you should start to see your health bar diminish! 149 | 150 | Onwards to the adventure log! 151 | 152 | In `./src/state/ecs.js` we will add an array to store all our messages and a simple function to add new messages to it. 153 | 154 | Notice we are using the unshift method on the array instead of the more commonly used push. This is because we want to put the messages at the beginning of our array instead of the end. The reason for this will become clear later. 155 | 156 | ```diff 157 | ecs.registerPrefab(Player); 158 | 159 | +export const messageLog = ["", "Welcome to Gobs 'O Goblins!", ""]; 160 | +export const addLog = (text) => { 161 | + messageLog.unshift(text); 162 | +}; 163 | 164 | export default ecs; 165 | ``` 166 | 167 | Now in our movement system `./src/systems/movement.js` we can import our `addLog` function and change all of our console logs to addLogs. 168 | 169 | ```diff 170 | -import ecs from "../state/ecs"; 171 | +import ecs, { addLog } from "../state/ecs"; 172 | import { addCacheSet, deleteCacheSet, readCacheSet } from "../state/cache"; 173 | import { grid } from "../lib/canvas"; 174 | import { Move } from "../state/components"; 175 | ``` 176 | 177 | ```diff 178 | const attack = (entity, target) => { 179 | if (target.health.current <= 0) { 180 | kill(target); 181 | 182 | - return console.log( 183 | + return addLog( 184 | `${entity.description.name} kicked a ${target.description.name} for ${damage} damage and killed it!` 185 | ); 186 | } 187 | 188 | - console.log( 189 | + addLog( 190 | `${entity.description.name} kicked a ${target.description.name} for ${damage} damage!` 191 | ); 192 | }; 193 | ``` 194 | 195 | ```diff 196 | export const movement = () => { 197 | if (target.has("Health") && target.has("Defense")) { 198 | attack(entity, target); 199 | } else { 200 | - console.log( 201 | + addLog( 202 | `${entity.description.name} bump into a ${target.description.name}` 203 | ); 204 | } 205 | ``` 206 | 207 | Finally we need to actually render the log in our render system `./src/systems/render.js` 208 | 209 | We are going to explicity render the first three messages of our log. As new messages are added to the beginning, older ones will fall off. They will still be stored in the array - we just won't render them. 210 | 211 | First import `messageLog` to our render system: 212 | 213 | ```javascript 214 | import { messageLog } from "../state/ecs"; 215 | ``` 216 | 217 | Then, right after our health bar add the following: 218 | 219 | ```javascript 220 | drawText({ 221 | text: messageLog[2], 222 | background: "#000", 223 | color: "#666", 224 | x: grid.messageLog.x, 225 | y: grid.messageLog.y, 226 | }); 227 | 228 | drawText({ 229 | text: messageLog[1], 230 | background: "#000", 231 | color: "#aaa", 232 | x: grid.messageLog.x, 233 | y: grid.messageLog.y + 1, 234 | }); 235 | 236 | drawText({ 237 | text: messageLog[0], 238 | background: "#000", 239 | color: "#fff", 240 | x: grid.messageLog.x, 241 | y: grid.messageLog.y + 2, 242 | }); 243 | ``` 244 | 245 | You'll notice we rendered each line with a different color - this is provides a bit of a gradient to our logs giving our newest messages more emphasis. 246 | 247 | Walk around - bump into stuff - test it out! 248 | 249 | The last thing we will do is give the player the ability to mouse over the map and get some information about entities in any visible cell. 250 | 251 | We need to import a few more things to our render system at `./src/systems/render.js`: 252 | 253 | ```diff 254 | +import { throttle } from "lodash"; 255 | ``` 256 | 257 | ```diff 258 | -import { clearCanvas, drawCell, drawText, grid } from "../lib/canvas"; 259 | +import { clearCanvas, drawCell, drawText, grid, pxToCell } from "../lib/canvas"; 260 | +import { toLocId } from "../lib/grid"; 261 | +import { readCacheSet } from "../state/cache"; 262 | ``` 263 | 264 | With that out of the way, add this to the very end of `./src/systems/render.js` **outside** of the render function itself: 265 | 266 | ```javascript 267 | // info bar on mouseover 268 | const clearInfoBar = () => 269 | drawText({ 270 | text: ` `.repeat(grid.infoBar.width), 271 | x: grid.infoBar.x, 272 | y: grid.infoBar.y, 273 | background: "black", 274 | }); 275 | 276 | const canvas = document.querySelector("#canvas"); 277 | canvas.onmousemove = throttle((e) => { 278 | const [x, y] = pxToCell(e); 279 | const locId = toLocId({ x, y }); 280 | 281 | const esAtLoc = readCacheSet("entitiesAtLocation", locId) || []; 282 | const entitiesAtLoc = [...esAtLoc]; 283 | 284 | clearInfoBar(); 285 | 286 | if (entitiesAtLoc) { 287 | entitiesAtLoc 288 | .filter((eId) => { 289 | const entity = ecs.getEntity(eId); 290 | return ( 291 | layer100Entities.isMatch(entity) || 292 | layer300Entities.isMatch(entity) || 293 | layer400Entities.isMatch(entity) 294 | ); 295 | }) 296 | .forEach((eId) => { 297 | const entity = ecs.getEntity(eId); 298 | clearInfoBar(); 299 | 300 | if (entity.isInFov) { 301 | drawText({ 302 | text: `You see a ${entity.description.name}(${entity.appearance.char}) here.`, 303 | x: grid.infoBar.x, 304 | y: grid.infoBar.y, 305 | color: "white", 306 | background: "black", 307 | }); 308 | } else { 309 | drawText({ 310 | text: `You remember seeing a ${entity.description.name}(${entity.appearance.char}) here.`, 311 | x: grid.infoBar.x, 312 | y: grid.infoBar.y, 313 | color: "white", 314 | background: "black", 315 | }); 316 | } 317 | }); 318 | } 319 | }, 100); 320 | ``` 321 | 322 | There is a lot happening there - let's go through it. 323 | 324 | First we have a utility function to clear the info bar that we'll use later. Notice, it doesn't actually clear the info bar so much as it draws over it. 325 | 326 | ```javascript 327 | const clearInfoBar = () => 328 | drawText({ 329 | text: ` `.repeat(grid.infoBar.width), 330 | x: grid.infoBar.x, 331 | y: grid.infoBar.y, 332 | background: "black", 333 | }); 334 | ``` 335 | 336 | Next we just grab a reference to the canvas dom element so we can add an `onmousemove` listener to it. 337 | 338 | ```javascript 339 | const canvas = document.querySelector("#canvas"); 340 | ``` 341 | 342 | We throttle the `onmousemove` event here to prevent it from being called as fast as possible to help with performance. The code between the braces should only run once every 100ms while the mouse is moving over the canvas. 343 | 344 | ```javascript 345 | canvas.onmousemove = throttle((e) => { 346 | ... 347 | }, 100); 348 | ``` 349 | 350 | Once in the braces we use the mouse event (e) to calculate our location on the grid which is used to access any entities at the current location from cache. We also clear the info bar. 351 | 352 | ```javascript 353 | const [x, y] = pxToCell(e); 354 | const locId = toLocId({ x, y }); 355 | 356 | const esAtLoc = readCacheSet("entitiesAtLocation", locId) || []; 357 | const entitiesAtLoc = [...esAtLoc]; 358 | 359 | clearInfoBar(); 360 | ``` 361 | 362 | If there are any entities at the current location we use our layer queries to filter only those that are visible. We then write a message for each entity - overwriting the previous to make sure that int the end only the top layer shows will be seen. We also take care to write a subtly different message for entities that are in FOV and those we have been revealed but are not currently in FOV. 363 | 364 | ```javascript 365 | if (entitiesAtLoc) { 366 | entitiesAtLoc 367 | .filter((eId) => { 368 | const entity = ecs.getEntity(eId); 369 | return ( 370 | layer100Entities.isMatch(entity) || 371 | layer300Entities.isMatch(entity) || 372 | layer400Entities.isMatch(entity) 373 | ); 374 | }) 375 | .forEach((eId) => { 376 | const entity = ecs.getEntity(eId); 377 | clearInfoBar(); 378 | 379 | if (entity.isInFov) { 380 | drawText({ 381 | text: `You see a ${entity.description.name}(${entity.appearance.char}) here.`, 382 | x: grid.infoBar.x, 383 | y: grid.infoBar.y, 384 | color: "white", 385 | background: "black", 386 | }); 387 | } else { 388 | drawText({ 389 | text: `You remember seeing a ${entity.description.name}(${entity.appearance.char}) here.`, 390 | x: grid.infoBar.x, 391 | y: grid.infoBar.y, 392 | color: "white", 393 | background: "black", 394 | }); 395 | } 396 | }); 397 | } 398 | ``` 399 | 400 | Our game is looking better and should make a lot more sense to a first time user. If you want anyone else to play your game (and it's ok if you don't!) these sorts of UI enhancements are critical. 401 | 402 | In [part 8](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part8.md) we will need to expand on the UI a bit as we and items and inventory! 403 | -------------------------------------------------------------------------------- /tutorial/part8.md: -------------------------------------------------------------------------------- 1 | # Part 8 - Items and Inventory 2 | 3 | Who doesn't love finding a useful item to save you in a pinch? Our @ is in search of riches and treasure after all. We're about to add health potions and an inventory UI to manage them. I'm not gonna lie. This part is gonna be a beast. So brew a fresh cup of your stimulant of choice and buckle up. 4 | 5 | ## Adding health potions to the dungeon 6 | 7 | Let's begin by simply adding health potions to our map. They won't do anything yet - but you gotta start somewhere. 8 | 9 | First add a new component to `./src/state/components.js` called `IsPickup`. 10 | 11 | ```diff 12 | export class IsOpaque extends Component {} 13 | 14 | +export class IsPickup extends Component {} 15 | 16 | export class IsRevealed extends Component {} 17 | ``` 18 | 19 | This component will actually do some things later but for now it's just a flag to know that an entity with it can be picked up. 20 | 21 | Register it in `./src/state/ecs.js`: 22 | 23 | ```diff 24 | import { 25 | ... 26 | IsOpaque 27 | + IsPickup, 28 | IsRevealed 29 | ... 30 | } from "./components"; 31 | ``` 32 | 33 | ```diff 34 | ecs.registerComponent(IsOpaque); 35 | +ecs.registerComponent(IsPickup); 36 | ecs.registerComponent(IsRevealed); 37 | ``` 38 | 39 | Next we'll create a couple of new prefabs - the generic base `Item` and our more specific `HealthPotion` in `./src/state/prefabs.js`. 40 | 41 | ```javascript 42 | export const Item = { 43 | name: "Item", 44 | components: [ 45 | { type: "Appearance" }, 46 | { type: "Description" }, 47 | { type: "Layer300" }, 48 | { type: "IsPickup" }, 49 | ], 50 | }; 51 | 52 | export const HealthPotion = { 53 | name: "HealthPotion", 54 | inherit: ["Item"], 55 | components: [ 56 | { 57 | type: "Appearance", 58 | properties: { char: "!", color: "#DAA520" }, 59 | }, 60 | { 61 | type: "Description", 62 | properties: { name: "health potion" }, 63 | }, 64 | ], 65 | }; 66 | ``` 67 | 68 | Notice we place our Item prefab on layer300. This is our 'items' layer and will ensure they render above the floor but below players and monsters. 69 | 70 | Register these as well, taking care to register the base `Item` prefab before the more specific `HealthPotion`. Remember that prefabs must be registered before they can be inherited from. 71 | 72 | ```diff 73 | import { 74 | Being, 75 | + Item, 76 | Tile, 77 | + HealthPotion, 78 | Goblin, 79 | Player, 80 | Wall, 81 | Floor, 82 | } from "./prefabs"; 83 | ``` 84 | 85 | ```diff 86 | // register "base" prefabs first! 87 | ecs.registerPrefab(Being); 88 | +ecs.registerPrefab(Item); 89 | 90 | +ecs.registerPrefab(HealthPotion); 91 | ecs.registerPrefab(Wall); 92 | ``` 93 | 94 | And finally let's actually add them to the map in `./src/index.js`: 95 | 96 | ```diff 97 | times(5, () => { 98 | const tile = sample(openTiles); 99 | ecs.createPrefab("Goblin").add(Position, { x: tile.x, y: tile.y }); 100 | }); 101 | 102 | +times(5, () => { 103 | + const tile = sample(openTiles); 104 | + ecs.createPrefab("HealthPotion").add(Position, { x: tile.x, y: tile.y }); 105 | +}); 106 | ``` 107 | 108 | I found it useful during this part to render a ton of potions - maybe 50 - and no goblins. This makes testing a lot easier as you don't have to explore the dungeon to find a potion or worry about getting killed on the way. 109 | 110 | Run the game! You should see some potions lying on the dungeon floor (!) 111 | 112 | ## Adding an inventory 113 | 114 | Of course our @ needs a place to store the things it picks up. Time to add an inventory! 115 | 116 | Yet again, we'll start by adding a new component. In `./src/state/components.js` add an `Inventory`. It will only need a list property for storing an array of item entities. We let geotic know this by setting the list property value to the string "". 117 | 118 | ```javascript 119 | export class Inventory extends Component { 120 | static properties = { 121 | list: "", 122 | }; 123 | } 124 | ``` 125 | 126 | As always, register it in `./src/state.ecs.js`. 127 | 128 | ```diff 129 | import { 130 | ... 131 | Health, 132 | + Inventory, 133 | IsBlocking, 134 | ... 135 | } from "./components"; 136 | ``` 137 | 138 | ```diff 139 | ecs.registerComponent(Health); 140 | +ecs.registerComponent(Inventory); 141 | ecs.registerComponent(IsBlocking); 142 | ``` 143 | 144 | We also need to add it to our player prefab in `./src/state/prefabs.js`: 145 | 146 | ```diff 147 | export const Player = { 148 | name: "Player", 149 | inherit: ["Being"], 150 | components: [ 151 | { 152 | type: "Appearance", 153 | properties: { char: "@", color: "#FFF" }, 154 | }, 155 | { 156 | type: "Description", 157 | properties: { name: "You" }, 158 | }, 159 | { type: "Health", properties: { current: 20, max: 20 } }, 160 | + { type: "Inventory" }, 161 | ], 162 | }; 163 | ``` 164 | 165 | If you run the game now, you should be able to click on your @ and inspect it's components. There should be an empty inventory! 166 | 167 | ## Managing our inventory with (g)Get and (d)Drop 168 | 169 | We'll start in our components file with adding a couple events to the `Inventory` component. We will need an `onPickUp` and an `onDrop` event. These will handle adding and removing items to our inventory list as well as removing the item from the dungeon floor on pickup and putting down on drop. 170 | 171 | In `./src/state/components.js` add `onPickUp` and `onDrop` events to the Inventory components. Both of these events will expect some entity as a payload. 172 | 173 | ```diff 174 | export class Inventory extends Component { 175 | static properties = { 176 | list: [], 177 | }; 178 | 179 | + onPickUp(evt) { 180 | + this.list.push(evt.data); 181 | + 182 | + if (evt.data.position) { 183 | + evt.data.remove("Position"); 184 | + } 185 | + } 186 | + 187 | + onDrop(evt) { 188 | + remove(this.list, (x) => x.id === evt.data.id); 189 | + evt.data.add("Position", this.entity.position); 190 | + } 191 | } 192 | ``` 193 | 194 | We already take advantage of the `onAttached` lifecycle method in our `Position` component. This event fires automatically when the add method is called on an entity and we use it to populate our entitiesAtLocation cache. 195 | 196 | We can also take advantage of the `onDetached` lifecyle method to remove entities from the cache like so: 197 | 198 | ```diff 199 | export class Position extends Component { 200 | static properties = { x: 0, y: 0 }; 201 | 202 | onAttached() { 203 | const locId = `${this.entity.position.x},${this.entity.position.y}`; 204 | addCacheSet("entitiesAtLocation", locId, this.entity.id); 205 | } 206 | 207 | + onDetached() { 208 | + const locId = `${this.x},${this.y}`; 209 | + deleteCacheSet("entitiesAtLocation", locId, this.entity.id); 210 | + } 211 | } 212 | ``` 213 | 214 | Now that we have our eventing setup we just need to create keybindings to fire them with the correct payloads. 215 | 216 | We will want to add some logging when things picked up and dropped so import the addLog function at the top of `./src/index.js`. 217 | 218 | ```diff 219 | -import ecs from "./state/ecs"; 220 | +import ecs, { addLog } from "./state/ecs"; 221 | ``` 222 | 223 | In the same file add the keybindings for (g)Get and (d)Drop 224 | 225 | ```diff 226 | if (userInput === "ArrowLeft") { 227 | player.add(Move, { x: -1, y: 0 }); 228 | } 229 | + 230 | +if (userInput === "g") { 231 | + let pickupFound = false; 232 | + readCacheSet("entitiesAtLocation", toLocId(player.position)).forEach( 233 | + (eId) => { 234 | + const entity = ecs.getEntity(eId); 235 | + if (entity.isPickup) { 236 | + pickupFound = true; 237 | + player.fireEvent("pick-up", entity); 238 | + addLog(`You pickup a ${entity.description.name}`); 239 | + } 240 | + } 241 | + ); 242 | + if (!pickupFound) { 243 | + addLog("There is nothing to pick up here"); 244 | + } 245 | +} 246 | + 247 | +if (userInput === "d") { 248 | + if (player.inventory.list.length) { 249 | + player.fireEvent("drop", player.inventory.list[0]); 250 | + } 251 | +} 252 | 253 | userInput = null; 254 | ``` 255 | 256 | We're doing a few things here. 257 | 258 | First we create our keybinding for `g` and immediately create a flag to track whether or not a pickup was found. 259 | 260 | ```javascript 261 | if (userInput === "g") { 262 | let pickupFound = false; 263 | ``` 264 | 265 | After that we read from our entitiesAtLocation cache and iterate through everything we find. If it has an `isPickup` component we set our pickupFound flag to true and fire our pick-up event passing it the entity. Then we add a message to our adventure log describing what was picked up. 266 | 267 | ```javascript 268 | readCacheSet("entitiesAtLocation", toLocId(player.position)).forEach((eId) => { 269 | const entity = ecs.getEntity(eId); 270 | if (entity.isPickup) { 271 | pickupFound = true; 272 | player.fireEvent("pick-up", entity); 273 | addLog(`You pickup a ${entity.description.name}`); 274 | } 275 | }); 276 | ``` 277 | 278 | And lastly we check the flag - if after iterating through all the entities at our location we still haven't found anything to pickup - let the player know in the adventure log. 279 | 280 | ```javascript 281 | if (!pickupFound) { 282 | addLog("There is nothing to pick up here"); 283 | } 284 | ``` 285 | 286 | Next we add a keybinding for `d`. For now we just check if there is anything in the inventory and if so drop the first item. We'll add a UI next so we can actually select the item to drop but this will get us started. 287 | 288 | ```javascript 289 | if (userInput === "d") { 290 | if (player.inventory.list.length) { 291 | addLog(`You drop a ${player.inventory.list[0].description.name}`); 292 | player.fireEvent("drop", player.inventory.list[0]); 293 | } 294 | } 295 | ``` 296 | 297 | Now you can walk around the map and hit the `g` key to pickup potions and the `d` key to put them somewhere else! 298 | 299 | ## Inventory UI 300 | 301 | We really need a UI to display the inventory so we can choose what items to drop. 302 | 303 | We'll start in `./src/lib/canvas.js` with settings for where our inventory will display on our grid and another utility function for drawing rectangles. 304 | 305 | In the grid object add another setting for our inventory like so: 306 | 307 | ```javascript 308 | export const grid = { 309 | ... 310 | inventory: { 311 | width: 37, 312 | height: 28, 313 | x: 21, 314 | y: 4, 315 | }, 316 | }; 317 | ``` 318 | 319 | And then import rectangle from our grid library and use it in a new function that will draw rectangles on our grid like this: 320 | 321 | ```javascript 322 | import { rectangle } from "./grid"; 323 | ``` 324 | 325 | ```javascript 326 | export const drawRect = (x, y, width, height, color) => { 327 | const rect = rectangle({ x, y, width, height }); 328 | 329 | Object.values(rect.tiles).forEach((position) => { 330 | drawBackground({ color, position }); 331 | }); 332 | }; 333 | ``` 334 | 335 | Now in our render system we need to render the inventory itself. We don't want to render it all the time, so we'll add a new concept called gameState. We'll only render our inventory when our game is in the 'INVENTORY' gameState. 336 | 337 | In `./src/systems/render.js` import the new drawRect function as well as a couple of variables from `./src/index.js` that we haven't created yet. 338 | 339 | ```diff 340 | import { 341 | clearCanvas, 342 | drawCell, 343 | + drawRect, 344 | drawText, 345 | grid, 346 | pxToCell, 347 | } from "../lib/canvas"; 348 | import { toLocId } from "../lib/grid"; 349 | import { readCacheSet } from "../state/cache"; 350 | +import { gameState, selectedInventoryIndex } from "../index"; 351 | ``` 352 | 353 | And then at the bottom of our render function add another conditional to display our inventory as an overlay if we're in the INVENTORY gameState: 354 | 355 | ```javascript 356 | if (gameState === "INVENTORY") { 357 | // translucent to obscure the game map 358 | drawRect(0, 0, grid.width, grid.height, "rgba(0,0,0,0.65)"); 359 | 360 | drawText({ 361 | text: "INVENTORY", 362 | background: "black", 363 | color: "white", 364 | x: grid.inventory.x, 365 | y: grid.inventory.y, 366 | }); 367 | 368 | if (player.inventory.list.length) { 369 | player.inventory.list.forEach((entity, idx) => { 370 | drawText({ 371 | text: `${idx === selectedInventoryIndex ? "*" : " "}${ 372 | entity.description.name 373 | }`, 374 | background: "black", 375 | color: "white", 376 | x: grid.inventory.x, 377 | y: grid.inventory.y + 3 + idx, 378 | }); 379 | }); 380 | } else { 381 | drawText({ 382 | text: "-empty-", 383 | background: "black", 384 | color: "#666", 385 | x: grid.inventory.x, 386 | y: grid.inventory.y + 3, 387 | }); 388 | } 389 | } 390 | ``` 391 | 392 | This is pretty straight forward. We render a large rectangle grid of translucent cells to obscure the map, a heading `INVENTORY`, and then the inventory items themselves with a `*` before the current selected item. Finally we add a message if the inventory is empty. 393 | 394 | Time to deal with those undefined variables we imported from `./src/index.js`. 395 | 396 | Just above our keybinding add `gameState` and `selectInventoryIndex`: 397 | 398 | ```diff 399 | let userInput = null; 400 | let playerTurn = true; 401 | +export let gameState = "GAME"; 402 | +export let selectedInventoryIndex = 0; 403 | 404 | document.addEventListener("keydown", (ev) => { 405 | userInput = ev.key; 406 | }); 407 | ``` 408 | 409 | We need to be able to handle keybindings differently in the Inventory than we do when playing the game. This concept of a gameState allows us to do just that. 410 | 411 | Update the update function for our game loop to look like this: 412 | 413 | ```javascript 414 | const update = () => { 415 | if (player.isDead) { 416 | return; 417 | } 418 | 419 | if (playerTurn && userInput && gameState === "INVENTORY") { 420 | processUserInput(); 421 | render(player); 422 | playerTurn = true; 423 | } 424 | 425 | if (playerTurn && userInput && gameState === "GAME") { 426 | processUserInput(); 427 | movement(); 428 | fov(player); 429 | render(player); 430 | 431 | if (gameState === "GAME") { 432 | playerTurn = false; 433 | } 434 | } 435 | 436 | if (!playerTurn) { 437 | ai(player); 438 | movement(); 439 | fov(player); 440 | render(player); 441 | 442 | playerTurn = true; 443 | } 444 | }; 445 | ``` 446 | 447 | Notice how we run different systems in each game state and also that in the INVENTORY game state we always set playerTurn to true. 448 | 449 | We need to update our processUserInput function to handle different gameStates as well. It should now look like this: 450 | 451 | ```javascript 452 | const processUserInput = () => { 453 | if (gameState === "GAME") { 454 | if (userInput === "ArrowUp") { 455 | player.add(Move, { x: 0, y: -1 }); 456 | } 457 | if (userInput === "ArrowRight") { 458 | player.add(Move, { x: 1, y: 0 }); 459 | } 460 | if (userInput === "ArrowDown") { 461 | player.add(Move, { x: 0, y: 1 }); 462 | } 463 | if (userInput === "ArrowLeft") { 464 | player.add(Move, { x: -1, y: 0 }); 465 | } 466 | 467 | if (userInput === "g") { 468 | let pickupFound = false; 469 | readCacheSet("entitiesAtLocation", toLocId(player.position)).forEach( 470 | (eId) => { 471 | const entity = ecs.getEntity(eId); 472 | if (entity.isPickup) { 473 | pickupFound = true; 474 | player.fireEvent("pick-up", entity); 475 | addLog(`You pickup a ${entity.description.name}`); 476 | } 477 | } 478 | ); 479 | if (!pickupFound) { 480 | addLog("There is nothing to pick up here"); 481 | } 482 | } 483 | 484 | if (userInput === "i") { 485 | gameState = "INVENTORY"; 486 | } 487 | 488 | userInput = null; 489 | } 490 | 491 | if (gameState === "INVENTORY") { 492 | if (userInput === "i" || userInput === "Escape") { 493 | gameState = "GAME"; 494 | } 495 | 496 | if (userInput === "ArrowUp") { 497 | selectedInventoryIndex -= 1; 498 | if (selectedInventoryIndex < 0) selectedInventoryIndex = 0; 499 | } 500 | 501 | if (userInput === "ArrowDown") { 502 | selectedInventoryIndex += 1; 503 | if (selectedInventoryIndex > player.inventory.list.length - 1) 504 | selectedInventoryIndex = player.inventory.list.length - 1; 505 | } 506 | 507 | if (userInput === "d") { 508 | if (player.inventory.list.length) { 509 | addLog(`You drop a ${player.inventory.list[0].description.name}`); 510 | player.fireEvent("drop", player.inventory.list[0]); 511 | } 512 | } 513 | 514 | userInput = null; 515 | } 516 | }; 517 | ``` 518 | 519 | You'll notice we now check for the gameState before processing inputs. We also added a new keybinding (i)Inventory that sets the gameState and acts as a toggle for the menu. 520 | 521 | Give it a shot! Run the game and check your inventory to see the empty state - then pick up some things and use the arrows to select different items. 522 | 523 | ## Consuming a potion 524 | 525 | In part 6 we used a simple `take-damage` event to reduce the health of a target. This approach was quick and easy but not very flexible. We don't really have a concept for different types of damage or any means whatsoever to mix and match the various kinds of damage we might want to add. For instance, how would we create a fire and ice sword? 526 | 527 | Let's try and do something more flexible for potions. Imagine we wanted to build a crafting system where a player could combine different herbs to create a potion. We would expect each herb to have one or many effects that could combine to create a potion that delivered those effects to the player on consumption. We won't be building a crafting system but we will attempt to build something generic enough to handle one. 528 | 529 | The approach we'll take is to create an effects system. `Effect` components will store the various effects an entity has. An `ActiveEffects` component will be used by our effects system to make any necessary calculations and update relevant components. Don't worry, it's not as complicated as it sounds. 530 | 531 | Let's start as always in `./src/state/components.js`. Add the two components we just spoke about, `Effect` and `ActiveEffects` 532 | 533 | ```javascript 534 | export class ActiveEffects extends Component { 535 | static allowMultiple = true; 536 | static properties = { component: "", delta: "" }; 537 | } 538 | 539 | export class Effects extends Component { 540 | static allowMultiple = true; 541 | static properties = { component: "", delta: "" }; 542 | } 543 | ``` 544 | 545 | Up to now our entities have only supported a single component of any given type. The line `static allowMultiple = true;` tells Geotic we want to allow multiple components of this type on an entity. Each `Effect` or `ActiveEffect` contains two properties - a component name and a delta. This will allow us to easily create potions that effect different components on en entity like health and or power. We can also play with the delta (the net change in the component value) to create a health potion with a delta of 5 or a poison with a delta of -5. 546 | 547 | But before we get ahead of ourselves let's register our new components in `./src/state/ecs.js`: 548 | 549 | ```diff 550 | import { 551 | + ActiveEffects, 552 | Ai, 553 | Appearance, 554 | Description, 555 | Defense, 556 | + Effects, 557 | Health, 558 | ``` 559 | 560 | ```diff 561 | // all Components must be `registered` by the engine 562 | +ecs.registerComponent(ActiveEffects); 563 | ecs.registerComponent(Ai); 564 | ecs.registerComponent(Appearance); 565 | ecs.registerComponent(Description); 566 | ecs.registerComponent(Defense); 567 | +ecs.registerComponent(Effects); 568 | ecs.registerComponent(Health); 569 | ``` 570 | 571 | We already have a HealthPotion prefab but it doesn't have any effects yet. Let's add one now in `src/state/prefabs`: 572 | 573 | ```diff 574 | export const HealthPotion = { 575 | name: "HealthPotion", 576 | inherit: ["Item"], 577 | components: [ 578 | { 579 | type: "Appearance", 580 | properties: { char: "!", color: "#DAA520" }, 581 | }, 582 | { 583 | type: "Description", 584 | properties: { name: "health potion" }, 585 | }, 586 | + { 587 | + type: "Effects", 588 | + properties: { component: "health", delta: 5 }, 589 | + }, 590 | ], 591 | }; 592 | ``` 593 | 594 | Now for our effects system. Add a new file `effect.js` at `./src/systems/effects.js` it should look like this: 595 | 596 | ```javascript 597 | import ecs from "../state/ecs"; 598 | const { ActiveEffects } = require("../state/components"); 599 | 600 | const activeEffectsEntities = ecs.createQuery({ 601 | all: [ActiveEffects], 602 | }); 603 | 604 | export const effects = () => { 605 | activeEffectsEntities.get().forEach((entity) => { 606 | entity.activeEffects.forEach((c) => { 607 | if (entity[c.component]) { 608 | entity[c.component].current += c.delta; 609 | 610 | if (entity[c.component].current > entity[c.component].max) { 611 | entity[c.component].current = entity[c.component].max; 612 | } 613 | } 614 | 615 | c.remove(); 616 | }); 617 | }); 618 | }; 619 | ``` 620 | 621 | It's actually a pretty simple component. We have a query to find Entities with ActiveEffects and we iterate over any we find. The activeEffects components is an array (because we allow multiple) so we loop over it and for each active effect we find - check if the entity has the relevant component and if so calculate the delta and update it. 622 | 623 | Finally we remove the activeEffect. 624 | 625 | Now that we have a generic effects system in place it's time to create a means to consume a potion so it can take effect! 626 | 627 | Add another keybinding (c)Consume in `./src/index.js` right before (d)Drop like this: 628 | 629 | ```javascript 630 | if (userInput === "c") { 631 | const entity = player.inventory.list[selectedInventoryIndex]; 632 | 633 | if (entity) { 634 | if (entity.has("Effects")) { 635 | // clone all effects and add to self 636 | entity 637 | .get("Effects") 638 | .forEach((x) => player.add("ActiveEffects", { ...x.serialize() })); 639 | } 640 | 641 | addLog(`You consume a ${entity.description.name}`); 642 | entity.destroy(); 643 | 644 | if (selectedInventoryIndex > player.inventory.list.length - 1) 645 | selectedInventoryIndex = player.inventory.list.length - 1; 646 | } 647 | } 648 | ``` 649 | 650 | Consume does a few things. First it gets the currently selected item in your inventory. If it has effects components, it clones each of them and adds them to the player as ActiveEffects. A helpful message is logged to and the item is destroyed. Remember all the way at the beginning when we set the items property on the inventory component to an ""? Geotic keeps track of those entities so when we destroy one it is automatically removed from the inventory list. Pretty nice :) 651 | 652 | All that's left is to call the new effects system itself. 653 | 654 | Import the new system in `./src/index.js`: 655 | 656 | ```javascript 657 | import { effects } from "./systems/effects"; 658 | ``` 659 | 660 | And then call it in the update function like this: 661 | 662 | ```diff 663 | const update = () => { 664 | if (player.isDead) { 665 | return; 666 | } 667 | 668 | if (playerTurn && userInput && gameState === "INVENTORY") { 669 | processUserInput(); 670 | + effects(); 671 | render(player); 672 | playerTurn = true; 673 | } 674 | 675 | if (playerTurn && userInput && gameState === "GAME") { 676 | processUserInput(); 677 | + effects(); 678 | movement(); 679 | fov(player); 680 | render(player); 681 | 682 | if (gameState === "GAME") { 683 | playerTurn = false; 684 | } 685 | } 686 | 687 | if (!playerTurn) { 688 | ai(player); 689 | + effects(); 690 | movement(); 691 | fov(player); 692 | render(player); 693 | 694 | playerTurn = true; 695 | } 696 | }; 697 | ``` 698 | 699 | Add goblins back to your game if you removed them earlier and a give it a go! Find a potion, get punched by a goblin, and quaff a potion - your health bar should refill! 700 | 701 | There is but one more small addition - let's add the keybindings to our inventory screen so our player knows how to consume or drop a potion. 702 | 703 | In `./src/systems/render.js` draw another line of text just below 'INVENTORY' 704 | 705 | ```diff 706 | drawText({ 707 | text: "INVENTORY", 708 | background: "black", 709 | color: "white", 710 | x: grid.inventory.x, 711 | y: grid.inventory.y, 712 | }); 713 | + 714 | +drawText({ 715 | + text: "(c)Consume (d)Drop", 716 | + background: "black", 717 | + color: "#666", 718 | + x: grid.inventory.x, 719 | + y: grid.inventory.y + 1, 720 | +}); 721 | ``` 722 | 723 | That was a long ride! Hopefully you can see you how powerful a generic system like this can be. Can you make a poison potion or one that combines multiple effects? 724 | 725 | We'll take this concept even further in [part 9](https://github.com/luetkemj/jsrlt/blob/master/tutorial/part9.md) when we take on ranged scrolls and targeting! 726 | --------------------------------------------------------------------------------