Friday, August 26, 2011

roguelike tutorial 03: scrolling through random caves

Time to work on actual gameplay. Sort of. Well ... not really. A place for gameplay to happen. A world for our heroes, foes, and treasures.

Roguelikes happen somewhere. A somewhere made of floors, walls, rivers, trees, caves, doors, or whatever you can imagine. Since this is a tutorial to show the basics, we'll start with two kinds of environment tiles: cave floors and cave walls. I've found it's often useful to have another kind that represents out of bounds. That way instead of having to always check if something is out of bounds before checking the map about a specific tile, we can just ask and the map and it can tell us it's out of bounds and we can handle that however we want. If you're familiar with the NullObject design pattern then it's very similar; I guess you could call it an OutOfBoundsObject.

Since we're talking about tiles, let's have a Tile class. Each Tile needs to be displayed so we need a glyph to display and a color to display it with. Since we only have a few different tile types, and all tiles of the same type look and behave the same, we can represent the tiles as a java enum.

package rltut;

import java.awt.Color;
import asciiPanel.AsciiPanel;

public enum Tile {
    FLOOR((char)250, AsciiPanel.yellow),
    WALL((char)177, AsciiPanel.yellow),
    BOUNDS('x', AsciiPanel.brightBlack);

    private char glyph;
    public char glyph() { return glyph; }

    private Color color;
    public Color color() { return color; }

    Tile(char glyph, Color color){
        this.glyph = glyph;
        this.color = color;
    }
}

I like using extended ascii characters since AsciiPanel supports code page 437, but if you want to use '#' and '.', or something else entirely, go ahead. This is the place to do that.

Now that we have cave walls and floors, we need a World to hold them.

package rltut;

import java.awt.Color;

public class World {
    private Tile[][] tiles;
    private int width;
    public int width() { return width; }

    private int height;
    public int height() { return height; }

    public World(Tile[][] tiles){
        this.tiles = tiles;
        this.width = tiles.length;
        this.height = tiles[0].length;
    }
}

And now that we have a world made up of tiles we can add some methods to get details about them.

public Tile tile(int x, int y){
        if (x < 0 || x >= width || y < 0 || y >= height)
            return Tile.BOUNDS;
        else
            return tiles[x][y];
    }

public char glyph(int x, int y){
        return tile(x, y).glyph();
    }

public Color color(int x, int y){
        return tile(x, y).color();
    }

By checking for bounds here we don't need to worry about out of bounds errors and check everythime we ask the world about a location.

That's perfect for getting details about our world of tiles but we don't have a way of creating the tiles a World is made of. We could add a bunch of methods to create a World, but I like having the World class only responsible for the running of a world not creating it. Creating a new world is an entirely different and complicated subject that's only relevant at the beginning of a game and should be forgotten about right after we have a world to work with. Something else needs to create, or build, a world. And if we have something else who's only responsibility is building a new world, you could use the Builder pattern and call it a WorldBuilder.



To create a WorldBuilder you need a world size. Then you can call methods, in fluent style, to build up a world. Once you've specified how to build the world you want, you call the build method and you get a new World to play with.

package rltut;

public class WorldBuilder {
    private int width;
    private int height;
    private Tile[][] tiles;

    public WorldBuilder(int width, int height) {
        this.width = width;
        this.height = height;
        this.tiles = new Tile[width][height];
    }

    public World build() {
        return new World(tiles);
    }
}

The simplest interesting (i.e. randomized) world I know of is a world of caves. I came up with a basic algorithm to build randomized caves myself, although it's just a simple form of cellular automata and I'm not the first to come up with it. The process is to fill the area with cave floors and walls at random then to smooth everything out by turning areas with mostly neighboring walls into walls and areas with mostly neighboring floors into floors. Repeat the smoothing process a couple times and you have an interesting mix of cave walls and floors.

So the builder should be able to randomize the tiles.

private WorldBuilder randomizeTiles() {
        for (int x = 0; x < width; x++) {
            for (int y = 0; y < height; y++) {
                tiles[x][y] = Math.random() < 0.5 ? Tile.FLOOR : Tile.WALL;
            }
        }
        return this;
    }

And repeatedly smooth them.

private WorldBuilder smooth(int times) {
        Tile[][] tiles2 = new Tile[width][height];
        for (int time = 0; time < times; time++) {

         for (int x = 0; x < width; x++) {
             for (int y = 0; y < height; y++) {
              int floors = 0;
              int rocks = 0;

              for (int ox = -1; ox < 2; ox++) {
                  for (int oy = -1; oy < 2; oy++) {
                   if (x + ox < 0 || x + ox >= width || y + oy < 0
                        || y + oy >= height)
                       continue;

                   if (tiles[x + ox][y + oy] == Tile.FLOOR)
                       floors++;
                   else
                       rocks++;
                  }
              }
              tiles2[x][y] = floors >= rocks ? Tile.FLOOR : Tile.WALL;
             }
         }
         tiles = tiles2;
        }
        return this;
    }

We put the new tile into tiles2 because it's usually a bad idea to update data that you're using as input to next updates. It's hard to explain but if you change the code to not use the tiles2 variable you'll see what I mean.

I don't like all those nested loops. Arrow code like this is usually a bad sign but this is simple enough and only used during world gen so I'll leave it as it is for now. This is also just part of working with multi dimentional arrays in java.

And that's how you can make some caves.

public WorldBuilder makeCaves() {
    return randomizeTiles().smooth(8);
}

So now we can create a World of Tiles to play around in. But in order to play in our new world of cave floors and cave walls, we need to display it. Scrolling is easy to implement so we'll add that now. All this talk of playing reminds me of our PlayScreen class, which makes since because it's responsible for displaying the world we're playing in and reacting to player input.

If we want the PlayScreen class to display a world then we need to make some changes to it. We need to track the world we're looking at, what part we're looking at, and how much of the screen is used for displaying the world. Here's the variables and constructor to add to the PlayScreen:

private World world;
    private int centerX;
    private int centerY;
    private int screenWidth;
    private int screenHeight;

    public PlayScreen(){
        screenWidth = 80;
        screenHeight = 21;
        createWorld();
    }

The createWorld method does exactly that, create's a world. I have a feeling this is going to expand as we make the world more interesting so putting it in a separate method will reduce how tangled it get's with other code and make changes easier later on.

private void createWorld(){
        world = new WorldBuilder(90, 31)
              .makeCaves()
              .build();
    }

We need a method to tell us how far along the X axis we should scroll. This makes sure we never try to scroll too far to the left or right.

public int getScrollX() {
    return Math.max(0, Math.min(centerX - screenWidth / 2, world.width() - screenWidth));
}

And we need a method to tell us how far along the Y axis we should scroll. This makes sure we never try to scroll too far to the top or bottom.

public int getScrollY() {
    return Math.max(0, Math.min(centerY - screenHeight / 2, world.height() - screenHeight));
}

We need a method to display some tiles. This takes a left and top to know which section of the world it should display.

private void displayTiles(AsciiPanel terminal, int left, int top) {
    for (int x = 0; x < screenWidth; x++){
        for (int y = 0; y < screenHeight; y++){
            int wx = x + left;
            int wy = y + top;

            terminal.write(world.glyph(wx, wy), x, y, world.color(wx, wy));
        }
    }
}

Now that we have a world to look at, we need to update the displayOutput method to show the section we're looking at on part of the screen - the rest of the screen is for user stats, messages, etc.

int left = getScrollX();
        int top = getScrollY();
   
        displayTiles(terminal, left, top);

Might as well show where were actually looking while we are here.

terminal.write('X', centerX - left, centerY - top);

We also need a new method to actually scroll. It should make sure we're never trying to scroll out of bounds.

private void scrollBy(int mx, int my){
        centerX = Math.max(0, Math.min(centerX + mx, world.width() - 1));
        centerY = Math.max(0, Math.min(centerY + my, world.height() - 1));
    }

Lastly, we need to add cases the the respondToUserInput code se we scroll based on user input.

case KeyEvent.VK_LEFT:
        case KeyEvent.VK_H: scrollBy(-1, 0); break;
        case KeyEvent.VK_RIGHT:
        case KeyEvent.VK_L: scrollBy( 1, 0); break;
        case KeyEvent.VK_UP:
        case KeyEvent.VK_K: scrollBy( 0,-1); break;
        case KeyEvent.VK_DOWN:
        case KeyEvent.VK_J: scrollBy( 0, 1); break;
        case KeyEvent.VK_Y: scrollBy(-1,-1); break;
        case KeyEvent.VK_U: scrollBy( 1,-1); break;
        case KeyEvent.VK_B: scrollBy(-1, 1); break;
        case KeyEvent.VK_N: scrollBy( 1, 1); break;

And now you have some random caves that you can look around in.


That seems like a fair bit of work. On the other hand, we did create a way to build thousands of worlds (90 tiles by 32 tiles with 2 tiles types = 5,760 2^2880 possible worlds) and a way to look around in them so hopefully it's worth it. Not only that, but most of this was from adding new code and not modifying old code; that's always a good sign. You should also try adding new tile types, or tweaking the cave algorithm, changing the world size, importing a hard coded level, or even implementing other world generation algorithms.

download the code

26 comments:

  1. Wow, great work! I have some questions:

    1) I like the smoothing tecnique, can you explain it better? With some images/screenshot this tutorial could be the number 1 rl tutorial!

    2) I think that need some sort of more smooth, for example set a start/end point and then make sure there is always a path between them, what do you think?

    3) there is a simple way to "inspect" tiles on screen using mouse? This could help into debug rougelike made with this tutorial, what do you think? In more general way, how to handle gui with rougelike?

    4) for now player is just a "X" on screen, without any other properties, but in next tutorial will be a separated class, right? In my attempt with rougelike, every monster/player/npc is an entity (or a Creature, as you like) with a glyph associated with. But terrain is static, right? So, how to handle moving entities? My idea: first, draw terrain, then ask to all entities glyphs to draw, for example the mighty @ for player

    ReplyDelete
  2. @Marte: thanks!
    1) The smooth method makes each tile a floor tile if it's surrounded by mostly floor tiles or a wall tile if it's surrounded by mostly wall tiles. This is repeated several times.
    2) There are a lot of different things you could do to make the map better - this is just a really simple method that fits within one blog post.
    3) Using the mouse could be another post entirely. Basically; to handle mouse clicking you need to implement the MouseListener interface and listen for the mouse events. In the mouseClicked method, take the x and y of the mouse event and divide by the width and height of a tile. This will tell you which tile was clicked on and you can determine what part of the gui or world was clicked on.
    4) A separate class for the player is coming up next.

    ReplyDelete
  3. Thanks to you!

    1) ok, now clear!
    2) I think that a nice list of links on map generation can point anyone into right direction!
    3) yes, thanks!
    4) ok!

    ReplyDelete
  4. Agreed this has the potential to be up with Richard d.c`s free basic rogue tutorial! Great job!

    ReplyDelete
  5. @Epyx, Thanks! I'm glad you like it.

    ReplyDelete
  6. There's actually way more than 5760 possible worlds. There's 2^2880 possible worlds. 2880 tiles with two possible states each.

    ReplyDelete
    Replies
    1. Fixed! How the heck did I manage to get that so wrong?

      Delete
    2. So how would I change the default size of the terminal from 40 to being as tall as the screen size. I have it to where I can make the screen that large but the map itself is still locked as 40/80 and am unable to move out of it.

      Delete
  7. I think that with an enum like your tile enum, you should use public final member fields, and skip the accessor methods. The types of the fields themselves are immutable, so the enum will still be immutable as a whole.

    ReplyDelete
    Replies
    1. Sounds like a good idea. I don't do much Java so thanks for letting me know.

      Delete
  8. Hello, i was following your tutorial and im having problems compiling this line

    private World world;

    it tells me ''cannot find symbol, class world''

    is it because im using Bluej? i had problems at the start with the ''package'' command, had to look how to do it manually

    ReplyDelete
    Replies
    1. edit from last post : i tried to look how to classpath manually but i dont understand why i have to do this since the class World is in the same package

      Delete
    2. I'm not sure how the classpath works with Bluej. It sounds like a classpath problem though. Maybe your classpath should include the root source folder? Just a guess.

      Delete
    3. I'm having the same problem. I think the problem comes from putting the Screens into a different package, rltut.screens. Do they need to be in a separate package? Or would it make sense to keep them all in the rltut package?

      I'm using Eclipse, but I really don't know anything yet about packages or the Eclipse IDE, but I'm just trying to learn as much as I can while I follow this along.

      I really appreciate this tutorial. Thanks so much for sharing it.

      Delete
    4. Same, I'm also not sure where to put the world snippet of code.

      Delete
  9. This comment has been removed by the author.

    ReplyDelete
    Replies
    1. Hi Trystan

      I had problems understanding the smooth algorith so I renamed some of the variables and gave myself some comments. Maybe it helps someone else.

      (I really don't know how to format it. Maybe just copy paste it into Eclipse and press Ctrl + Shift + F to autoformat.)

      // The process is to fill the area with cave floors and walls at random
      // then to smooth everything out by turning areas with mostly neighboring
      // walls into walls and areas with mostly neighboring floors into floors.
      // Repeat the smoothing process a couple times and you have an
      // interesting mix of cave walls and floors.
      public WorldBuilder smooth(int times){
      Tile[][] tempTiles = new Tile[width][height];

      // loop so many times
      for(int time = 0; time < times; time ++){

      // loop through all the tiles
      for(int x = 0; x < width; x++){
      for(int y = 0; y < height; y++){
      int floors = 0;
      int rocks = 0;

      // The neighbour is the tile -1 and +1
      // x and y combinated it's a 3x3 field of neighbours that is being checked
      for (int xNeighbour = -1; xNeighbour < 2; xNeighbour++){
      for (int yNeighbour = -1; yNeighbour < 2; yNeighbour++){

      // if the neighbour position is out of bound just continue
      if (x + xNeighbour < 0 || x + xNeighbour >= width || y + yNeighbour < 0 || y + yNeighbour >= height){
      continue;
      }

      // count if the neighbour tiles are floors or rocks
      if(worldTiles[x + xNeighbour][y + yNeighbour] == Tile.FLOOR)
      floors++;
      else
      rocks++;
      }
      }
      // if the neighbour tiles are mostly floors make this tile also a floor
      tempTiles[x][y] = floors >= rocks ? Tile.FLOOR : Tile.WALL;
      }
      }
      // in the end store tempTiles in the real tiles
      worldTiles = tempTiles;
      }
      return this;
      }

      Delete
  10. I might be mistaken here but I am confused as to how you initialize and index the tiles array. I was under the impression multidimensional arrays are initialized and index with the format myArray[row number][column number] but it seems here you initialize and index with the format tiles[width]height] (reading width as column and height as row). For example this.tiles = new Tile[width][height];. To me this would give me an array with width number of rows and height number of columns which seems backwards to me. Running your code proves me otherwise but I can't figure out why. If I hard code it as tiles = new Tile[90][31] I would expect to get 90 rows and 31 columns. This also makes me confused about the for loops as it seems you iterate x from 0 to width(number of columns) and y from 0 to height(number of rows) but then index the array as tiles[x][y] as in these loops:
    for (int x = 0; x < width; x++) {
    for (int y = 0; y < height; y++) {
    tiles[x][y] = Math.random() < 0.5 ? Tile.FLOOR : Tile.WALL;
    I am confused by this as it makes it seem in the for loops x is the column number and y is the row number but yet using them in tiles[x][y] would make it seem that x is the row and y is the column. I was hoping you could shed some light on this as I can't seem to wrap my head around it.
    Thank You,

    ReplyDelete
    Replies
    1. It doesn't matter what each index means - as long as you are consistent. If you create an array with "tiles = new Tile[numberOfColumns][numberOfRows]" then you would access it with "tile[column][row]". If you think of x as being the number of columns over and y being the number of rows down, then it should make sense.

      Delete
    2. I have a problem. I copied your whole project (of 3rd tutorial) and put it in my project and map cannot be seen and my hero is starting in left,top corner. I'm using java 1.6 on Eclipse and cannot say why does it happen. Also (I don't know if it's feature or bug) Ascii terminal is imprinting previous screens as faded. Only when You change cell it's updating. Any help please?

      Delete
    3. NVM. Problem solved. In LINUX wheezy-64bit ( I don't know about others ) AsciiPanel is not working properly. On windows 8 your code is working properly. That's a shame your library doesn't make it on my workstation...

      Delete
  11. Hello! Awesome tutorial so far! Sorry if I'm a bit late, but I was having trouble implementing more than two tile types in WorldBuilder(). How would you recommend adding more types of tiles in it? Like water, or trees? (If you go over this later, disregard).

    ReplyDelete
    Replies
    1. Just add them to the Tile enum like the floor and wall then add it to the WorldBuilder somewhere. Maybe call an addRivers method or addTree method after the makeCaves method.

      Delete
  12. Hi, I love your tutorial so far, I was just wondering how I could detect if a key was being held, instead of just pressed. I made it work using the keyTyped function, but I still want to know; is there some sort of variable that has the states of the keys? I've used Python with the Pygame module, and it has a variable that has the states of keys, and you update it with 'event.pump()' or something. Is there something similar in java?

    ReplyDelete
  13. The possible values and objects are bringing about more of the vital provisions of interest and ideas which are indeed considered to be important for the students to try every possible mean.

    ReplyDelete