13
\$\begingroup\$

I decided that I wanted to add some spice to my bland looking city maps, so I created a couple of algorithms to add water to the map. I also added a "marshlands" setting that would just randomly sprinkle water throughout the map.

Here is an example of the river generation:

cityRiver

Here is the world generation method in its entirety:

private Tile[][] createWorld(boolean river, boolean ocean, boolean marsh) {

    //use 6 instead of 5 to generate marshland
    int lastGroundTile = marsh ? 6 : 5; 
    Tile[][] world = new Tile[CityWorld.WORLD_WIDTH][CityWorld.WORLD_HEIGHT];
    for (int x = 0; x < CityWorld.WORLD_WIDTH; x++) {
        for (int y = 0; y < CityWorld.WORLD_HEIGHT; y++) {
            world[x][y] = new Tile(new IsoPoint(x, y), IsoTileType.getGroundType(this.game.random.nextInt(lastGroundTile)), ZoneType.NOZONE);
        }
    }

    if (river) {
        boolean randomBool = this.game.random.nextBoolean();
        int riverWidth = randomBool ? 2 : 1;

        IsoDirection sideToStart = IsoDirection.values()[this.game.random.nextInt(IsoDirection.values().length)];
        IsoDirection oppositeDirection = IsoDirection.opposite(sideToStart);
        IsoPoint startPoint = this.edgePointForDirection(sideToStart);

        for (int i = 0; i < CityWorld.WORLD_HEIGHT; i++) {
            Tile firstTile = world[startPoint.x][startPoint.y];
            firstTile.setBuildingType(IsoTileType.WATER01);
            startPoint.move(oppositeDirection, CityWorld.WORLD_WIDTH - 1);

            for (int j = 0; j < riverWidth; j++) {
                Tile secondTile = world[startPoint.x][startPoint.y];
                secondTile.setBuildingType(IsoTileType.WATER01);
                startPoint.randomMoveTowards(oppositeDirection, CityWorld.WORLD_WIDTH - 1);
            }
        }
    }

    if (ocean) {
        IsoDirection sideToStart = IsoDirection.values()[this.game.random.nextInt(IsoDirection.values().length)];
        int sideEdgeValue = this.edgeValue(sideToStart);
        switch(sideToStart) {
        case LEFT:
            for (int y = 0; y < CityWorld.WORLD_HEIGHT; y++) {
                int randomWidth = this.game.random.nextInt(4); //max 3 deep
                for (int i = randomWidth; i >= 0; i--) {
                    Tile tile = world[sideEdgeValue + i][y];
                    tile.setBuildingType(IsoTileType.WATER01);
                }
            }
            break;
        case RIGHT:
            for (int y = 0; y < CityWorld.WORLD_HEIGHT; y++) {
                int randomWidth = this.game.random.nextInt(4); //max 3 deep
                for (int i = 0; i <= randomWidth; i++) {
                    System.out.println("random # = " + String.valueOf(i));
                    Tile tile = world[sideEdgeValue - i][y];
                    tile.setBuildingType(IsoTileType.WATER01);
                }
            }
            break;
        case UP:
            for (int x = 0; x < CityWorld.WORLD_WIDTH; x++) {
                int randomWidth = this.game.random.nextInt(4); //max 3 deep
                for (int i = 0; i <= randomWidth; i++) {
                    Tile tile = world[x][sideEdgeValue - i];
                    tile.setBuildingType(IsoTileType.WATER01);
                }
            }
            break;
        case DOWN:
            for (int x = 0; x < CityWorld.WORLD_WIDTH; x++) {
                int randomWidth = this.game.random.nextInt(4); //max 3 deep
                for (int i = randomWidth; i >= 0; i--) {
                    Tile tile = world[x][sideEdgeValue + i];
                    tile.setBuildingType(IsoTileType.WATER01);
                }
            }
            break;
            default:
                break;
        }
    }
    return world;
}

Since the map is viewed from an isometric perspective, it's not as simple as just moving from 0 to max or max to 0. I needed these helper methods to find the exact positions to start the river (i.e. in the middle of the row), and the exact value of the row for each edge of the map:

IsoPoint edgePointForDirection(IsoDirection direction) {
    switch (direction) {
    case LEFT:
        return new IsoPoint(0, CityWorld.WORLD_HEIGHT/2);
    case RIGHT:
        return new IsoPoint(CityWorld.WORLD_WIDTH - 1, CityWorld.WORLD_HEIGHT/2);
    case UP:
        return new IsoPoint(CityWorld.WORLD_WIDTH/2, CityWorld.WORLD_HEIGHT - 1);
    case DOWN:
        return new IsoPoint(CityWorld.WORLD_WIDTH/2, 0);
    default:
        return new IsoPoint(0, 0);
    }
}
int edgeValue(IsoDirection direction) {
    switch (direction) {
    case LEFT:
        return 0;
    case RIGHT:
        return CityWorld.WORLD_WIDTH - 1;
    case UP:
        return CityWorld.WORLD_HEIGHT - 1;
    case DOWN:
        return 0;
    default:
        return 0;
    }
}

The basic idea of the river generation is that it starts at one edge of the map, and then moves towards the opposite edge exactly enough times to put a water tile all the way across. Each step of the way, the point is moved once or twice in a perpendicular direction with this method:

public void randomMoveTowards(IsoDirection direction, int bounds) {
    Random random = new Random();
    boolean randomBool = random.nextBoolean();
    switch (direction) {
    case LEFT:
    case RIGHT:
        if (randomBool) {
            this.move(IsoDirection.UP, bounds);
        } else {
            this.move(IsoDirection.DOWN, bounds);
        }
        break;
    case UP:
    case DOWN:
        if (randomBool) {
            this.move(IsoDirection.LEFT, bounds);
        } else {
            this.move(IsoDirection.RIGHT, bounds);
        }
        break;
    }
}

The basic idea behind the ocean generation is that it decides one of the four edges of the map, and then moves from 0 to max or max to 0 along the row of tiles at that edge of the map. Each step, it decides how many spaces to move inwards from the edge so that the coastline is not totally flat.

Here is a screenshot of a river running into the ocean:

cityOcean

You can play the game here if you want, but these features are not yet available in the HTML version of the game.

I created these algorithms myself without really studying other approaches, so I would love to hear about other ways to do this. I'm sure that the switch statement inside of the ocean generation section of the world generation method is really bad, but I can't think of a more concise way to do things.

As before, I am limited to Java 6 for this project because it is cross platform. Also, let me know if you need to see the IsoDirection or IsoPoint classes.

\$\endgroup\$
  • 10
    \$\begingroup\$ The progress of your game is impressive. Kudos! \$\endgroup\$ – janos Jul 23 '15 at 21:02
  • \$\begingroup\$ Much appreciated! \$\endgroup\$ – bazola Jul 23 '15 at 21:42
10
\$\begingroup\$

Magic values

int lastGroundTile = marsh ? 6 : 5; 
...
world[x][y] = new Tile(new IsoPoint(x, y), IsoTileType.getGroundType(this.game.random.nextInt(lastGroundTile)), ZoneType.NOZONE);

It would be clearer if you would initialize that lastGroundTile to IsoTileType.SOME_CONSTANT.getValue()

Method extractions

if (river) {

Looks like a perfect opportunity to extract a generateRiver() method.

if (ocean) {

Looks like a perfect opportunity to extract a generateOcean() method.


IsoDirection oppositeDirection = IsoDirection.opposite(sideToStart);

Instead of having that method static, you could have it non-static and use:

IsoDirection oppositeDirection = sideToStart.opposite()

Potential bug

for (int i = 0; i < CityWorld.WORLD_HEIGHT; i++) {

This looks like it will break if your WORLD_HEIGHT is not equal to your WORLD_WIDTH.

System out + Code Duplication

System.out.println("random # = " + String.valueOf(i));

As you're using LibGDX, use the Gdx.app.log method.

Also, this output statement breaks the pattern you have in your code duplication.

Now, about the code duplication... all your code there is some variation of this:

for (int y = 0; y < CityWorld.WORLD_HEIGHT; y++) { // or x and WORLD_WIDTH
    int randomValue = this.game.random.nextInt(4);

    for (int i = randomValue; i >= 0; i--) { // or loop in the other direction
        Tile tile = world[someX][someY]; // one of them is `sideEdgeValue +/- i`
        tile.setBuildingType(IsoTileType.WATER01);
    }
}

The direction you loop in doesn't seem to matter to me, you end up doing the same thing anyway.

It's important to remember what we have as input for all this: A IsoDirection.

From this direction, we could use a isHorizontal/isVertical method to determine if it is left/right or up/down.

To determine if we should increase or decrease, we can check whether or not the sideEdgeValue is more or less than half the width/height

The below code is untested, but I believe it will do the same thing:

private void createOcean(IsoDirection side) {
    int sideEdgeValue = this.edgeValue(side);
    boolean horizontal = side.isHorizontal();
    int outerLoopLimit = horizontal ? CityWorld.WORLD_HEIGHT : CityWorld.WORLD_WIDTH;
    int delta = sideEdgeValue < outerLoopLimit / 2 ? 1 : -1;

    for (int outer = 0; outer < sideEdgeValue; outer++) {
        int randomValue = this.game.random.nextInt(4); //max 3 deep
        for (int i = 0; i <= randomValue; i++) {
            int other = sideEdgeValue + i * delta;
            int someX = horizontal ? outer : other;
            int someY = horizontal ? other : outer;
            Tile tile = world[someX][someY];
            tile.setBuildingType(IsoTileType.WATER01);
        }
    }
}

Maybe it should also be a bit more documented, documentation is not my strong suit :)

randomMoveTowards

I agree with maaartinus that Random random = new Random() does not belong there. That should be a field within the class, not local variable within the method.

Personally, I'd use the ternary operator. I like it, although I know some people don't.

public void randomMoveTowards(IsoDirection direction, int bounds) {
    boolean randomBool = random.nextBoolean();
    switch (direction) {
    case LEFT:
    case RIGHT:
        this.move(randomBool ? IsoDirection.UP : IsoDirection.DOWN, bounds);
        break;
    case UP:
    case DOWN:
        this.move(randomBool ? IsoDirection.LEFT : IsoDirection.RIGHT, bounds);
        break;
    }
}

Although... there's a pattern here. You could simplify it a bit:

public void randomMoveTowards(IsoDirection direction, int bounds) {
    boolean randomBool = random.nextBoolean();
    IsoDirection moveDir = direction.rotate90degrees();
    if (randomBool) {
        moveDir = moveDir.opposite();
    }
    this.move(moveDir, bounds);
}

Assuming you extract a method that rotates a direction by 90 degrees, of course, which would be a generally useful method.

Other approaches

A very common, recommended, and overall awesome approach to generating maps (and other things!) in games is to use Perlin Noise. I discovered that LibGDX has a PerlinNoiseGenerator class among their tests. So I could easily take that code (it is Apache Licensed) and play around with it myself, which I have done in a test project I created.

Using pure Perlin Noise gives you a two-dimensional array with float values between 0 and 1. Based on those values, you can come up with a "sea level". All values below sea level is water, and all values above sea level is ground.

generated map with Perlin Noise

Imagine that all blue squares is water and all red squares is land.

Then you can also post-process this to smoothen it out a bit (removing all smaller "islands" for example).

Perlin Noise generated map after smoothing

Perlin Noise generated maps is fun stuff! You can always find an appropriate sea level by starting with 0.5, and then iteratively counting how many land vs. sea tiles there are and adjusting the sea level depending on how many land tiles you want.

Feel free to check out my GdxPlayground code and adapt it to your game!

\$\endgroup\$
10
\$\begingroup\$

createWorld

//use 6 instead of 5 to generate marshland
int lastGroundTile = marsh ? 6 : 5;

It's not lastGroundTile. It's not last and it does never get generated in

        world[x][y] = new Tile(new IsoPoint(x, y), IsoTileType.getGroundType(this.game.random.nextInt(lastGroundTile)), ZoneType.NOZONE);

I hope that IsoTileType is an enum, so you could use a corresponding constant to make it clearer (marsh ? 6 : 5 even with a comment is not very good). Maybe you should move the functionality to IsoTileType and write

        world[x][y] = new Tile(new IsoPoint(x, y), IsoTileType.getGroundType(this.game.random, marsh), ZoneType.NOZONE);

I guess, you don't need to use the this qualifier above.


    boolean randomBool = this.game.random.nextBoolean();
    int riverWidth = randomBool ? 2 : 1;

You could use

    int riverWidth = game.random.randomInt(2) + 1;

and save yourself a line and a local variable.


    IsoDirection sideToStart = IsoDirection.values()[this.game.random.nextInt(IsoDirection.values().length)];

In case you need multiple times, move it into the enum and use

    IsoDirection sideToStart = IsoDirection.get(game.random);

This method is clearly too long and can be naturally split into main, "river", and "ocean" parts.


The "ocean" part is pretty long, but I can't see how to shorten it. Your loops are descending or ascending, which is not needed, but changing it doesn't help.

            int randomWidth = this.game.random.nextInt(4); //max 3 deep

What about

            int randomWidth = this.game.random.nextInt(MAX_OCEAN_DEPTH + 1);

? Shouldn't WATER01 be called OCEAN?


                System.out.println("random # = " + String.valueOf(i));

I like printf-style debugging, but I never ever use System.out directly. Write your own trivial class and use

                Debug.print("random # = ", i);

instead. When you remove the class, you can sure that no printing is left over (logging is a different thing).

        default:
            break;

Are there more than 4 directions? If no, then drop it (and get a warning when anything changes). You may drop the "break" in any case.

edgePointForDirection

Since the map is viewed from an isometric perspective, it's not as simple as just moving from 0 to max or max to 0.

Your coordinates can be 0 to max, independently of how they world get displayed. Usually, it's a bad idea to let the view influence the model too much. However, it's your choice, just be aware of it.

    return new IsoPoint(CityWorld.WORLD_WIDTH - 1, CityWorld.WORLD_HEIGHT/2);

You're systematically using no blanks around /.

edgeValue

This could use a better name, but I have no idea.

randomMoveTowards

This is actually a "randomMovePerpendicularTo", isn't it?

Random random = new Random();

Don't do it. It's much better to use a single random instance everywhere as this allows you to seed it and get deterministic behavior which is invaluable when debugging.

I could imagine shortening the method to something like

IsoDirection[] directions = {UP, RIGHT, DOWN, LEFT};
int index = directions.indexOf(direction);
index += 1; // to get perpendicular
index += 2 * random.nextInt(2); // to conditionally get the opposite direction
return directions(index % directions.length);

or even more using IsoDirection.values() and ordinal(), but this feels hacky.

I created these algorithms myself without really studying other approaches, so I would love to hear about other ways to do this.

It took me a while to understand your river algorithm, but it's actually simple and the result is nice.

I'm sure that the switch statement inside of the ocean generation section of the world generation method is really bad, but I can't think of a more concise way to do things.

Neither can I. I could simplify it somehow, but there are too many direction-specific parts and the result could get too hard to understand. I'd probably extract the ugly switch to a separate method and forget it.

Summary

The code is good and the game looks fantastic!

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.