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 =
download the code
No comments:
Post a Comment