CS61B(18Spring) - Project 2 - random world generator


昨天晚上做完了phase 1,今天整理一下思路。为了节省时间,phase 2的交互部分就不做了感觉意义并不大。其实project 2难点就是在于随机生成地图,虽然最后生成的地图也很简陋,但是就像josh说的,编生成地图的代码过程才有趣。
目前网上没搜到什么攻略,来做个头一份。估计也就我这种赋闲在家的才有时间做攻略吧,唉。

项目要求 + 成品

在这里插入图片描述
这个是josh做的例子,要求如下:

  • 2D
  • 伪随机生成。就是只要seed是确定的,世界就是确定的;seed改变,世界重新随机生成。
  • 包括rooms和hallways,也可以有外部空间(NOTHING)。
  • 房间需要是长方形的,也可以有其他形状。
  • 房间、走廊大小、长度、数量、位置随机。
  • 房间和走廊需要相连

在这里插入图片描述
上图是我做出的成品,总觉得看着没有josh的好看,有一些改进的空间。。。可以明显看出用的不是同一种思路,复杂度要高一点。不过确实满足了要求。下面简单说下思路,不会贴太多代码。如果想看代码的在这里:https://github.com/stg1205/CS61B/tree/master/proj2/byog

skeleton文档分析

拿到题目之后,我先观察了一下现有文档的结构。总体来说,是Main.java里面负责调用Game.java里面的两种方法,启动游戏

//Main.java
public class Main {
    public static void main(String[] args) {
        if (args.length > 1) {
            System.out.println("Can only have one argument - the input string");
            System.exit(0);
        } else if (args.length == 1) {
            Game game = new Game();
            TETile[][] worldState = game.playWithInputString(args[0]);
            System.out.println(TETile.toString(worldState));
        } else {
            Game game = new Game();
            game.playWithKeyboard();
        }
    }
}

args是在编译的时候输入的字符串,当只有一个字符串时,调用game.playWithInputString(String s)方法。注意这个args不等于交互,是在编译的时候默认输入的。worldState接收了生成好的TETile世界,然后下一行TETile.toString将世界的二维TETile数组转化为字符串,输出在控制台。这个方法主要用于phase 1的测试,并没有调用stdDraw库中的函数。当没有args时,进入game.playWithKeyboard()方法,包含欢迎界面,用户交互。所以在phase 1,只需要注意第一种方法。

// Game.java
public TETile[][] playWithInputString(String input) {
        // Fill out this method to run the game using the input passed in,
        // and return a 2D tile representation of the world that would have been
        // drawn if the same inputs had been given to playWithKeyboard().
    }

此方法输入为input(args),里面包含seed,返回一个生成的世界TETile[][]。

我是按照从大到小,从外往里的方式思考。现在需要一个worldGenerator方法,其输入是seed(或者是由seed生成的RANDOM),返回生成好的二维数组。另外,根据现有的代码,在Game.java里面也进行了世界的定义,设置长度宽度,并进行初始化,因此再加入一输入变量,二维数组TETile[][] world。整理好代码如下:

// Game.java
public TETile[][] playWithInputString(String input) {
        long seed = Long.parseLong(input.replaceAll("[^0-9]", ""));
        Random RANDOM = new Random(seed);
        TETile[][] world = worldInitialize();
        return WorldCreator.worldGenerator(RANDOM, world);
    }

PS. 题目要求的seed输入范围很大,必须用long类型才能通过测试。

WorldGenerator

然后,考虑worldGenerator的步骤。做proj2的时候一定要有josh说的分步思想,将一个大任务进行分解。但这种目标不好达到,因为不知道两个任务有没有重叠,是不是可以分开的。所以,开始的时候就先大胆尝试一下,之后再改。

既然项目描述就是room和hallway,那就大胆分下步,首先生成随机房间,然后将它们用走廊相连。

Room.java

一个room需要三个变量来确定,左下角坐标,宽度,高度。将它们全部设置成类的private final变量,之后有问题再说。既然涉及到坐标,那就建一个Position类方便之后的操作。

然后,需要有一个打印room的方法。四边是墙壁,中间是floor,直接暴力循环,很好写。

需要随机生成room,也就是随机生成三个变量,位置,宽度,高度,编一个private方法作为helper函数,返回生成好的Room类。

生成完并且print之后肯定会有重叠,再建一个判断是否重叠的方法和一个去除重叠的方法。

最后,综合上述方法,建roomGenerator方法,生成随机房间,并返回保存生成的roomList。

Hallway

Hallway才是真正困难的部分。如何将随机生成的Room随机连接呢?如何打印hallway?重叠怎么去除?想了好久也没想出来,只能去查查资料了。

我首先想到的是这个地图是一种类似迷宫的地图,于是去查了查迷宫生成算法。然后正好查到了如何生成带有房间的迷宫地图的思路。一个迷宫生成算法

  1. 生成随机房间
  2. 将除房间以外的部分用迷宫生成算法填充
  3. 连接房间与走廊
  4. 去除一些deadends,减少复杂度

观察josh的图,可以发现他采用的就是“连接房间”这种思路。而我采用的思路是一种填充类型的。其实也可以将填充的区域限定一下,不过我这里还是选择了全地图填充。

迷宫生成算法

具体的迷宫生成算法我选择的是一种叫Recursive backtracker(递归回溯)的算法,俗称不撞南墙不回头:

  1. 首先,初始化,做一个所有路被墙隔开的图,注意此时的路不能是floor

在这里插入图片描述

  1. 然后,生成随机一点作为起始点,并将其变为floor

  2. 寻找周围有没有可以连接的路,如果有,就随机取一个方向,把墙和目标点变成floor,跳到目标点

  3. 如果没有,则退到上一个点,重复步骤3和4。注意:没有的意思是,上下左右,距离两格的四个格子,均没有NOTHING类型的TETile。

  4. 至全屏再也没有可以连接的路,循环结束

由以上思路,存储position的数据结构,需要回退(removeLast)以及前进(addLast),也就是pop和push。所以我选择了Deque接口下的LinkedList类型作为存储position的数据结构。最终hallwayGenerator代码如下,其中包括了一些helper方法:

// Hallway.java
public static void hallwayGenerator(Random RANDOM, TETile[][] world) {
        Position start = randomStart(world); //随机取点
        world[start.x][start.y] = Tileset.FLOOR;
        positionList.addLast(start); //positionList为继承Deque接口的LinkedList类型
        while (!positionList.isEmpty()) { //当最终回退到出发点,并且发现出发点周围也没有路,则出发点也被pop,则positionList为空,循环结束
            Position curPosition = positionList.getLast();
            List<Position> availablePositions = checkPath(curPosition, world); //checkPath返回当前可能连接路的位置
            if (!availablePositions.isEmpty()) {
                connectPath(availablePositions, curPosition, RANDOM, world); //connectPath随机连接一条路
            } else {
                positionList.removeLast(); //如果没路,pop
            }
        }
    }

连接Room和迷宫

这一步正好利用了之前建立rooms保存的所有room的信息。我的思路是:

  1. 随机在四边上各取一点
  2. 循环四次,每次随机生成一个0~3的数,选择一条边,所以会有重复选一条边的情况,保证不是每个房间都开了四个口
  3. 如果开口没有到达边界并且不是死胡同,则将该点变为Floor
// Room.java
public void randomRemoveWalls(Random RANDOM, TETile[][] world) {
        Position[] randomPositions = randomRoomPositions(RANDOM);
        for (int i = 0; i < 4; i++) {
            int whichEdge = RANDOM.nextInt(4);
            if (!isOnEdge(randomPositions[whichEdge], world)
             && !isInDeadEnd(randomPositions[whichEdge], world)) {
                world[randomPositions[whichEdge].x][randomPositions[whichEdge].y] = Tileset.FLOOR;
            }
        }
    }

去除deadends

这一步比较简单,先遍历一遍world,去除所有deadends(即三边都是墙的floor),将它变成WALL。然后疯狂遍历,直到全屏没有deadends。

private static void removeDeadEnds(TETile[][] world) {
        boolean done = false;

        while (!done) {
            done = true;
            for (int i = 0; i < world[0].length; i++) {
                for (int j = 0; j < world.length; j++) {
                    if (world[j][i] != Tileset.FLOOR) {
                        continue;
                    }
                    if (!isInDeadEnd(new Position(j, i), world)) {
                        continue;
                    }
                    done = false;
                    world[j][i] = Tileset.WALL;
                }
            }
        }
    }

去掉多余的WALL

在这里插入图片描述
现在生成的图是这样的,可以注意到有许多特别厚的WALL。最后美化一下,将它们变成NOTHING,保证墙的厚度为1。思路就是去除掉所有四角周围的四个点不是FLOOR的WALL。

//RectangleHelper.java
/** 四角周围的四个点 */
public static Position[] aroundCornerPositions(Position p) {
        Position[] pArray = new Position[4];
        pArray[0] = new Position(p.x - 1, p.y - 1);
        pArray[1] = new Position(p.x + 1, p.y - 1);
        pArray[2] = new Position(p.x + 1, p.y + 1);
        pArray[3] = new Position(p.x - 1, p.y + 1);
        return pArray;
    }
private static void removeInnerWalls(TETile[][] world) {
        for (int i = 1; i < world[0].length - 1; i++) {
            for (int j = 1; j < world.length - 1; j++) {
                if (world[j][i] != Tileset.WALL) {
                    continue;
                }
                if (!isInnerWall(new Position(j, i), world)) { 
                    continue;
                }
                world[j][i] = Tileset.NOTHING;
            }
        }
    }

一些小细节

最后随便挑个FLOOR,加个门就完成了!过程中有一些小细节要注意:

  1. 由于后续操作,房间的位置和宽度高度有一定限制,必须保证房间生成在WALL上。
  2. 有关某一位置周围的位置的操作,我统一建了一个RectangleHelper类,里面包含求四角周围四个点,上下左右周围四个点以及隔一格的四个点,isOnEdge和isDeadend方法。要不然太烦了。

好像就这些。

总结

最终的worldGenerator方法如下:

public static TETile[][] worldGenerator(Random RANDOM, TETile[][] world) {
        fillWithWalls(world);
        List<Room> rooms = Room.roomGenerator(RANDOM, world);
        Hallway.hallwayGenerator(RANDOM, world);
        for (Room room : rooms) {
            room.randomRemoveWalls(RANDOM, world);
        }
        removeDeadEnds(world);
        removeInnerWalls(world);

        // Add a door at right edge.
        for (int i = world[0].length - 1; i > 0; i--) {
            if (world[world.length - 2][i].equals(Tileset.FLOOR)) {
                world[world.length - 2][i] = Tileset.LOCKED_DOOR;
                break;
            }
        }
        return world;
    }

这个项目比较难啃,因为和之前的project 0, 1以及HW相比,基本没有指导,自由度很高,开始确实有无从下手的感觉。最后搞定了还是很开心的。最大的收获就是学会了建很多helper方法,学会了将一个大目标拆成很多小目标。
刚刚入门java,肯定还有很多不足,过一段时间再看说不定会有很多新感受。最后,用IDEA写project真滴舒服,各种提示查错。

发布了20 篇原创文章 · 获赞 0 · 访问量 170

猜你喜欢

转载自blog.csdn.net/fourier_transformer/article/details/105340529
今日推荐