4 Hugues Ross - Blog: Let's Make a Roguelike - 4 - A basic map
Hugues Ross

1/25/16

Let's Make a Roguelike - 4 - A basic map

Followup

Last week's shape drawing challenges were mostly an exercise, as it's unlikely you'll need them too much going forward. With that said, I still have the solutions.

Circles

Circles and ellipses can be difficult, but you they become much simpler when you apply just a little bit of trigonometry to them. By taking the sin from 0-180 degrees, we get a smooth, semicircle curve of values. From here, we can use some basic arithmetic to get the right length and position of each horizontal line, drawing similarly to how we handled the rectangle.
   79 void draw_ellipse(WINDOW* win, int row, int col, int height, int width, char shape)
   80 {
   81     int rows, cols;
   82     getmaxyx(win, rows, cols);
   83 
   84     for(int i = clamp(row, 0, rows); i < clamp(row + height, 0, rows); ++i) {
   85         float ratio = (float)i / height;
   86         float w = sin(degtorad(ratio * 180)) * width;
   87         for(int j = clamp(col + ((width - ceil(w))), 0, cols); j < clamp(col + ceil(w), 0, cols); ++j) {
   88             mvwaddch(win, i, j, shape);
   89         }
   90     }
   91 }
I've added two new utility macros, degtorad and radtodeg. This isn't really the place to explain them, as they're exactly the same as the normal mathematical conversions.
The result is decent, although it doesn't always perfectly fit the dimensions given.

Better Rectangles

An empty rectangle is quite simple. By keeping track of the starting and ending bounds (the ones used in the nested loops),you can easily get the border positions. If either iterator is at a border, draw a normal character. If not, don't. To keep the inside empty, you can simply draw a space character. This, in particular, is a really useful trick. If you absolutely need to blank something, you can always just draw a space over it. Here's the code:
   60 void draw_rect(WINDOW* win, int row, int col, int height, int width, char shape, int filled)
   61 {
   62     int rows, cols;
   63     getmaxyx(win, rows, cols);
   64 
   65     int imin = clamp(row, 0, rows);
   66     int imax = clamp(row + height, 0, rows);
   67     int jmin = clamp(col, 0, cols);
   68     int jmax = clamp(col + width, 0, cols);
   69     for(int i = imin; i < imax; ++i) {
   70         for(int j = jmin; j < jmax; ++j) {
   71             char s = shape;
   72             if(!filled && j != jmin && i != imin && j != jmax - 1 && i != imax - 1)
   73                 s = ' ';
   74             mvwaddch(win, i, j, s);
   75         }
   76     }
   77 }
And here's my final screenshot:

A Map

Today, we're going to draw an actual map. We won't be getting to gameplay or proper generation just yet, but I thought this would be a good way to cap off these posts about drawing.

Fundamentals

Let's start with some theory.
There are a few things to keep in mind when dealing with a text-based map, and be trying to address as many of these as I can here.

Tiles and Terrain

First off, you need a way to draw your map. I highly recommend using an array of 'tiles', each with various properties like their color pair and symbol.

Note: Why not shapes?

We've spent a couple weeks playing with various ways of drawing text and shapes, so you might be wondering why we don't just use various shapes for our maps. Sadly, this method results in a couple of problems:
  1. How do you use them? To generate your map, you'll need a pretty fancy algorithm just to handle placing and arranging shapes. After that, it'll take much more tweaking to get something even remotely decent looking. The best solution is simply to arrange rectangles and the like in a grid fashion, which just results in a set of large tiles. This could be done as an aesthetic, but you'll still be using tiles anyway.
  2. How do you know what you're standing on? The player and enemies need to know where walls are, at the very least. Many roguelikes go far beyond this, with multiple types of terrain. With shapes, you'll need to use a bunch of tradition collision detection techniques, but with tiles you can just use a simple array lookup.
  3. It'll be close to impossible to easily allow for terrain modifications, at least not at the level of granularity you'll want. With tiles, changing parts of the terrain is quite simple.
Tiles can be represented as structs, which allows us to add a few extra properties. These can vary from game to game, but could easily include things like terrain cost, contact damage, height, and even handles to scripts that execute when a creature enters! The possibilities are endless, so long as you take care not to go too crazy with the amount of data you use. Remember, you'll probably be storing thousands of them in memory.

Other Objects

Of course, your game would probably be boring without any non-tile objects. Most roguelikes generally have 2 separate kinds of non-tile objects: Items and Entities.
Items are pretty straightforward. Generally, tiles are allowed to have some kind of 'inventory', which is generally just a list of items. When the player picks one up, the item is moved to the player's inventory and removed from the tile's inventory.
Entities, as I call them, are a more general group. They can be anything from static objects like traps, push-able rocks, and statues, to more complex things such as monsters (and in some cases, the player). For static entities, you may simply want to make them into special types of tiles. Nevertheless, other kinds of entities benefit from some more complicated logic.
In many roguelikes, you can generally be assured that only one entity will be on a given tile. If this is the case, you may want to make tiles hold a flag of some kind indicating that they're occupied. This flag can simply force a tile to pretend that it's solid, and any AI you write can handle the rest. However, you should probably store your entities separately from your tiles anyway. Because they usually move around and appear/disappear regularly, they need to be managed more carefully than tiles or items.

Generation

In order to actually have a map, first you need to make one. Since this a roguelike, we'll be generating our map on the fly. We won't be covering any serious procedural generation algorithms just yet, but you need to start thinking early about how you want your maps to look. Are you making dungeons with interconnected rooms? Organic-looking natural caves? Grid-like mine shafts? Continents and outdoor terrain? Modern buildings? Several of the above, or something completely different?
Keep in mind some of the underlying elements of each. When the time comes to write a map generator, you'll probably need to put multiple techniques together to form the layouts you want.

Storage

Storing map data externally is optional. After all, you don't need to implement saving if your game is short. You also might regenerate areas on the fly, and make the constant changes part of the narrative. However, many roguelikes do it., because most games benefit greatly from a basic save system.
In some cases, you can get away with nothing more than literally writing the map's data to a file directly. In others, you'll want some special changes to account for entities and items. This is also up to you, and we'll address this issue much later. For now, it's good to mention for the sake of completion.

Implementation 

As a final treat to this otherwise dry and text-heavy tutorial, let's generate and draw a super-simple map. We won't bother making it look too nice and orderly yet, and instead focus on getting a set of tiles created and drawn for the moment. Here's our generation code:
    1 #include <curses.h>
    2 #include <stdlib.h>
    3 #include "map.h"
    4 
    5 typedef struct tile
    6 {
    7     int tile_char;
    8     int tile_pair;
    9 } tile;
   10 
   11 int map_rows, map_cols;
   12 tile* map_tiles = 0;
   13 
   14 void generate_map()
   15 {
   16     // Set map dimensions to match the screen
   17     getmaxyx(stdscr, map_rows, map_cols);
   18 
   19     // Create a new map, deleting an old one if it exists already
   20     if(map_tiles)
   21         free(map_tiles);
   22     map_tiles = calloc(map_rows * map_cols, sizeof(tile));
   23     for(int i = 0; i < map_rows; ++i) {
   24         for(int j = 0; j < map_cols; ++j) {
   25             map_tiles[i * map_rows + j].tile_pair = rand() % 3;
   26             map_tiles[i * map_rows + j].tile_char = (rand() % 93) + 32;
   27         }
   28     }
   29 }
This is pretty standard with our existing code. One thing you'll find is that many of the basic techniques used earlier come back every once in a while.

To keep track of the map,we use a simple global pointer. By keeping it in our C file, we can generally prevent it from being used elsewhere later. In general, we want to keep our map and tile access to functions whenever possible, and hide the actual structure and implementations in our C file.
Our generation 'algorithm', if you can call it that, takes a random color from the ones we picked and a random visible character, then assigns them to each tile. The result, as you'll see in a moment, is some interesting gibberish.

Next, we draw the map. You'll notice that this is awfully similar to our original rectangle drawing code:
   31 void draw_map()
   32 {
   33     for(int i = 0; i < map_rows; ++i) {
   34         move(i, 0);
   35         for(int j = 0; j < map_cols; ++j) {
   36             attron(COLOR_PAIR(map_tiles[i * map_rows + j].tile_pair));
   37             addch(map_tiles[i * map_rows + j].tile_char);
   38         }
   39     }
   40 }
In time, we'll be making this a bit more complicated. In particular, you might notice that very large maps are slow to draw. This is because curses absolutely must redraw every character, since they're all new. Some curses implementations will avoid redraws when they can, but to avoid dependence, we'll eventually want to make the drawing a bit more efficient. We'll also need cropping/scrolling code at some point, but we don't need either of those for this demo either. Also, I used a new method for selecting colors. attron(COLOR_PAIR(n)) allows you to easily set the current color pair, and attron() on its own can also set other attributes that we'll be covering in the future.
Here's how mine turned out:
Even though our map is nothing more than random garbage, it still has some pleasing qualities to it. The two colors look like they make some kind of abstract pattern, and the randomized characters add some texture to break things up.
These two things are important to remember. We'll be using other kinds of random noise functions (albeit more skillfully) in the future to create interesting maps, and it's often useful to use multiple characters for natural ground to make it more interesting to look at. As a closing piece, here's two screenshots from my last 7drl entry, To the West, demonstrating this very thing:
Keep this in mind, too: I used nothing but good old rand() to make To the West's map, and it still looks decent. Even with the most basic techniques, you can make some really nice-looking areas.

Next Steps

  1. Instead of taking random color pairs and characters, try to pull tiles from a predetermined list of combinations.

Important Terms and Functions

Colors and Text Attributes

  • attron(int attrs) Turns on a particular text attibute.
  • attroff(int attrs) Turns off a particular text attibute.
  • COLOR_PAIR(n) A macro that returns the particular attribute for a given color pair.

Map Terms

  •  Tile A single portion of a map, often rectangular. These are like thew squares in a grid, and hold basic terrain and visual data.
  • Entity An object in the world, usually one that blocks movement and/or is active and can move on its own.
  • Item An object that is held and used by entities. Generally, these are stored separately to entities and cannot act on their own.

Final Code

 Full code: here

No comments: