Building a TypeScript Roguelike, Part 1: Tilemaps
I’ve been meaning to learn TypeScript for some time, so I’ve decided to build a roguelike (or at least begin to build a roguelike) in order to do some canvas programming as well.
This isn’t a guide, it’s a journal of my experiments, so follow at your own risk. I can’t guarantee I’m going to make the best design decisions right off the the bat.
I’ll be omitting most of the glue code from the articles, and I’m not planning a writeup of the build system, but you can see the full source code at the GitHub repo.
Designing the tile data
I want the game to be tile-based, so I’ll need some sort of generic tile container for doing things like rendering the map, generating levels, pathfinding, and so forth. For now, I’ll just represent the individual levels as tiles:
// src/Tile.ts
enum Tile {
Nothing = 0,
Floor = 1
}
Eventually I’m planning on making this an object or bitmask that stores additional information, but this will suffice for writing the container.
Row Major Ordering
Tilemaps should be as simple as possible; I want to make them cheap
and reusable. The simplest way to store a grid of tiles would be an
array of rowsq, where a hypothetical tileMap.at
function would look
like (x, y) => this[y][x]
. This introduces a lot of overhead because
of all the arrays. Fortunately it’s quite easy to turn this into a
single array by arranging the elements like this:
const tileMap = [ 0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 0, 0 ];
This is called a row-major order, and a tiles.at
function would look like (width, x, y) => this[(y * width) + x]
.
I’d like to avoid tying higher-level code to this specific representation, so I’ve abstracted the coordinate system in case I want to swap it out. This is the code for a coordinate system:
// coords.ts
interface CoordinateSystem {
getX(i: number): number;
getY(i: number): number;
getI(x: number, y: number): number;
}
class RowFirstCoordinates {
public static GetX(i: number, width: number): number {
return i % width;
};
public static GetY(i: number, width: number): number {
return Math.floor(i / width);
};
public static GetI(x: number, y: number, width: number): number {
return (y * width) + x;
};
// Instance methods allow you to store the width
constructor(private width: number) { }
public getX(i: number): number {
return RowFirstCoordinates.GetX(i, this.width);
}
public getY(i: number): number {
return RowFirstCoordinates.GetY(i, this.width);
}
public getI(x: number, y: number): number {
return RowFirstCoordinates.GetI(x, y, this.width);
}
}
This will allow us to convert in between coordinates and array indices. Now I have everything I need to start building the tilemaps.
Designing the TileMap API
I’d like tilemaps to have a fluent interface, but one that avoids mutating existing references. Here’s an example of the API I’m imagining:
const tiles = [ 0, 0, 0, 0, 0,
0, 0, 0, 0, 0,
0, 0, 0, 0, 0,
0, 0, 0, 0, 0,
0, 0, 0, 0, 0 ];
const mapA = new TileMap(tiles, 5);
// Create a region with x, y, width, and height
const regionB = {
x: 1, y: 1,
width: 2, height: 2
};
// Design note: Since each change creates and returns a new TileMap,
// it would be ideal to be able to batch these into some sort of
// execution-plan object that could be applied all at once, for cases
// where I don't care about the intermediate states.
const mapB = mapA
.fillRegion(regionB, 1)
.setAt(0, 3, 2)
.setAt(4, 3, 2);
mapB.tiles === [ 0, 0, 0, 2, 0,
0, 1, 1, 0, 0,
0, 1, 1, 0, 0,
0, 0, 0, 0, 2,
0, 0, 0, 0, 0 ];
For now, I’m just going to create and implement the following interface, so I can get something on the screen:
// tileMaps.ts
import * as coords from "./coords";
import Tile from "./Tile";
// IRegion is used to represent an area without having to carry around
// a tile array.
interface IRegion {
x: number;
y: number;
width: number;
height: number;
}
type TileMapProcessor = { (tileMap: ITileMap): ITileMap }
interface ITileMap {
width: number;
height: number;
tiles: Tile[];
coords: coords.CoordinateSystem;
getRegion: (region: IRegion) => ITileMap;
fillRegion: (region: Iregion, contents: Tile) => ITileMap;
applyProcessors: (processors: TileMapProcessor[]) => ITileMap;
}
You can see the beginnings of my desired interface in the signatures
of getRegion
, fillRegion
, and applyProcessors
- they return
TileMap
s so you can contine chaining operations. The methods do the
following:
getRegion
takes anIRegion
and returns anITileMap
containing the size of that region, containing the tiles in that region.fillRegion
takes anIRegion
and aTile
and returns a newITileMap
that is the result of setting all of the tiles in thisITileMap
tocontents
.applyProcessors
takes a list of functions that transform anITileMap
to anotherITileMap
. It passes this map through those functions in the order given.
The implementation for this is fairly straightforward:
// tileMaps.ts
import { assert, zeroedArray } from "./util"
class TileMap {
public static NewEmpty(width: number, height: number) {
const length = width * height;
const tiles: Tile[] = zeroedArray(length);
return new TileMap(tiles, width);
}
public readonly tiles: Tile[];
public coords: coords.RowFirstCoordinates;
public readonly height: number;
public readonly width: number;
constructor(tiles: Tile[], width: number) {
assert(tiles.length % width === 0);
this.width = width;
this.height = tiles.length / width;
this.coords = new coords.RowFirstCoordinates(width);
this.tiles = tiles;
}
public getRegion(region: IRegion): TileMap {
const { x, y, width, height } = region;
assert((x + width <= this.width) && (y + height <= this.height));
const regionTiles = this.tiles.filter((tile, i) => {
const tileX = this.coords.getX(i);
const tileY = this.coords.getY(i);
if ((tileX >= x && tileX < x + width) && (tileY >= y && tileY < y + height)) {
return true;
} else {
return false;
}
});
return new TileMap(regionTiles, width);
}
public fillRegion(region: IRegion, contents: number): TileMap {
const { x, y, width, height } = region;
assert((x + width <= this.width) && (y + height <= this.height));
const newTiles = this.tiles.map((tile, i) => {
const tileX = this.coords.getX(i);
const tileY = this.coords.getY(i);
if ((tileX >= x && tileX < x + width) && (tileY >= y && tileY < y + height)) {
return contents;
} else {
return tile;
}
});
return new TileMap(newTiles, this.width);
}
public applyProcessors(processors: TileMapProcessor[]): ITileMap {
return processors.reduce((map, processor) => processor(map), this as ITileMap);
}
}
I’ll make another type to represent renderers, and then I can move on to actually drawing something
// tileMaps.ts
type TileMapRenderer = { (tileMap: ITileMap): void };
Drawing a map
Finally, I’m ready to draw my first map. First I’ll get the canvas and create a game loop:
// main.ts
import * as tileMaps from "./tileMaps";
const canvas = <HTMLCanvasElement>document.getElementById("canvas");
const ctx = canvas.getContext("2d");
// Nothing's changing yet, so I only need to draw once
let drawn = false;
let time;
const gameLoop = (ctx: CanvasRenderingContext2D,
map: tileMaps.ITileMap,
renderer: tileMaps.TileMapRenderer) => {
const now = new Date().getTime();
const dt = now - (time || now);
time = now;
console.log(dt);
const recurse = (delta) => gameLoop(delta, ctx, map, drawer);
if (!rendered) {
renderer(map);
rendered = true;
}
requestAnimationFrame(recurse);
};
Now I just need a map and a renderer. I’ll generate a map with some simple rooms:
// main.ts
class Room extends tileMaps.TileMap {
public readonly x;
public readonly y;
constructor(public readonly parent: tileMaps.ITileMap, region: tileMaps.IRegion) {
super(parent.getRegion(region).tiles, region.width);
this.x = region.x;
this.y = region.y;
}
}
function getRandomRooms(tileMap: tileMaps.ITileMap): Room[] {
return zeroedArray(getRandomInt(3, 9)).map(() => {
const width = getRandomInt(10, 20);
const height = getRandomInt(10, 20);
const x = getRandomInt(0, tileMap.width - width);
const y = getRandomInt(0, tileMap.height - height);
return new Room(tileMap, { x, y, width, height });
});
}
function createRooms(tileMap: tileMaps.ITileMap): tileMaps.ITileMap {
const rooms = getRandomRooms(tileMap, 10);
return rooms.reduce(
(map, room) => map.fillRegion(room, Tile.Floor), tileMap);
}
const mapCreationLayers = [
createRooms,
];
You’ll notice the signature of createRooms:
(tileMap: tileMaps.ITileMap): tileMaps.ITileMap
. That’s the same
type for which I defined the alias tileMaps.TileMapProcessor
-
meaning I can create an empty map and pass it through
mapCreationLayers
as follows:
// main.ts
const mapWidth = 120;
const mapHeight = 80;
const map = tileMaps.TileMap.NewEmpty(mapWidth, mapHeight)
.applyProcessors(mapCreationLayers);
And finally, I write a function to render the tiles to the canvas:
// main.ts
const render = (tileMap: tileMaps.ITileMap) => {
tileMap.tiles.map((tile, i) => {
const x = map.coords.getX(i);
const y = map.coords.getY(i);
ctx.fillStyle = (tile === Tile.Floor) ? "#FFF" : "#000";
ctx.fillRect(x * tileWidth, y * tileHeight, 10, 10);
});
};
Eventually the tile colors will be drawn with a pixel buffer which is scaled up to the full size of the canvas, making drawing a grid of solid colors much faster. At the moment, however, I’m only rendering the map once - in the starting frame.
It’s all set - I can now kick off the game loop to start it off:
// main.ts
gameLoop(ctx, map, render);
Doesn’t look like much, but it’s a success! In the next post I’ll be adding more types of tiles and exploring ways to generate a level. You can also check out the GitHub repo, or try a running version at roguelike.benaiah.me.
If you’d like to leave a comment, please email benaiah@mischenko.com