r/roguelikedev Does The Complete Roguelike Tutorial - Weeks 1, 2
This is the first of a few posts about creating a roguelike for the 2021 “RoguelikeDev Does The Complete Roguelike Tutorial” event hosted on reddit.com/r/roguelikedev and in the RoguelikeDev Discord.
I’ll be following the tutorial in TypeScript.
Since 2020, the tutorial has been using ECS architecture. There are a number of ECS libraries available, and I messed with creating my own before deciding on @fritzy/ape-ecs. The API was welcoming and documentation is good.
rot.js is a great roguelike development toolkit for JavaScript programming, and is how I’m rendering to the screen and performing FOV calculations.
There’s quite a bit here, it should be better paced in the future. There’s a working dungeon at the bottom you can play with.
Rebuilding the Dungeon in TypeScript
About a year ago I recreated parts of the Brogue dungeon generation algorithm in JavaScript, which I’ve written about here: here. The first task was to migrate that code to TypeScript.
Types
First, a quick look at most of the types:
These are types used in the generation of the dungeon. CELL_TYPES, ROOM_TYPES, etc. are objects stored as constants.
Here are the types for performing dungeon lighting, and finally baking the final dungeon:
PerlinColorDefinition and RandomColorDefinition are the types for how color configurations are stored in constants.
ECS
ECS architectures require all logic to be split into systems, which query and operate on entities, which have specific components, which are the collections of information about the entitiy. To explain, here’s an example of a system, component, and entity:
The PlayerRender system, which operates on entities that have the components:
Character, a “tag” indicating it’s the player character
Position, a component which holds x and y coordinates
Renderable, a component which holds data for drawing any thing
The Character, Position, and Renderable components:
The player entity, which is the only entity that has all three of these Components, and therefore is the only entity this system will find when querying:
The main game logic is something like this:
Rendering happens independently of input, but player movement (and eventually NPC movement) will only happen when the user provides input, like other turn-based roguelikes.
Rendering independently of input allows for “dancing” colors, something that exists in Brogue that I really enjoy. A tile with “dancing” colors will slightly vary color even when the player is idle, which gives the dungeon a lot of life. Here’s a lake, which has dancing colors:
The dungeon generation algorithm can spit out tiles that have the following defined for their foreground (letter) or background color:
A tile with a dancing color is given the DancingColor component which is handled in the Render system like this:
This requires the Renderable component to store a baseBG and baseFG in addition to its current bg and fg. If Renderable just stores a single color, dancing colors will perform a random walk and things get psychedelic:
The following are the current components defined for the game, including their types:
Typed components don’t currently exist in ape-ecs—there’s a pattern that appears to have been first introduced by Mozilla’s ECSY where components are defined as classes with default data stored on the class’s prototype. This isn’t super type-friendly, since generics types can’t access static attributes of classes.
To fix that, I wrote my own types and a helper for ape-ecs called TypedComponent, with a signature like this:
This makes sure that instances of a subclass of TypedComponent will have fields of those types. An example:
This also lets entity creation be typesafe, using the method World.createEntityTypesafe:
TypedComponents can infer type definitions from default props:
but explicit typing allows for finer control and defaults that don’t sacrifice type safety:
Throughout the rest of this project, I’ll be using TypedComponents.
Decoupling Light
Originally, my algorithm for dungeon generation was spitting out a “baked” dungeon, where light was merged into the cells. In this code from the original pass (link), all layers of the dungeon are merged into a single layer:
Notably this didn’t allow for dynamic lighting or for dancing colors. In order to add those, I needed to separate light and color to be passed to the game engine as components.
Rendering with rot.js
Previously, I’ve been rendering using React. Every cell was a <div> with CSS describing color. Switching to rot.js made rendering much simpler, and has allowed for FOV calculation and color mixing.
To make reasoning about rendering simpler, the dungeon generation algorithm will only include colors as {r, g, b} Objects, rather than Colors (defined by the great color library by Qix). rot.js uses any valid CSS property as a color, including rgba for transparency. The generator still mixes colors as Color instances, but unpacks it into {r, g, b} before passing it to the game engine.
First, a look at the final product:
The room the player (@) is currently in is entirely visible, meaning those tiles are tagged with the Visible component. The torch (orange #) on the left wall is casting a very faint (unnoticable, really, I’ll fix that later 🙃) light. Everything not visible is “memory,” meaning those tiles have the Memory component.
Rendering is split into a few separate systems: Render, PlayerRender, MemoryRender, and Light.
Render is the first pass. The Render system queries for entities that match the following: fromAll(Renderable, Position, Visible).not(Light, Memory, Character), meaning any entity that has the Renderable, Positoin, and Visible components, but not the Light, Memory, or Character components.
These entities are drawn directly onto a rot.jsDisplay like so:
Next, MemoryRender draws any entity that matches fromAll(Renderable, Memory, Position).not(Light, Visible) using
This multiplies (darkens) the color of the cell, which is meant to evoke remembering it hazily. Also, note that the Position and DancingColor of components will not be updated when they are not Visible, which means that memory will accurately portray the state of the tile as it was last seen.
The Light system adds the value of Light components to the existing color values at its Position.
FOV
FOV calculation is done with rot.js, which was really great and simple.
A FOV is defined using the algorithm and a function for determining whether something is visible, which is a flag defined on an AnnotatedCell:
When the FOV changes (when the player position updates), I recalculate FOV and store the updated values in a map:
Then, by comparing the new visibility to previous visibility, I determine which tiles have gone from unseen to seen and need the Visible component added and Memory component removed, and which cells have gone from seen to unseen and need the opposite.
FOV in action:
You can see that tall plants block FOV.
Working Example
This dungeon doesn’t have a minimum height, so there’s a good chance for bugs I haven’t encountered in testing.
I’ve tacked the seed on at the bottom, if you run into an erroring dungeon and want to help out, feel tree to make a GitHub issue with the seed.