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?重叠怎么去除?想了好久也没想出来,只能去查查资料了。
我首先想到的是这个地图是一种类似迷宫的地图,于是去查了查迷宫生成算法。然后正好查到了如何生成带有房间的迷宫地图的思路。一个迷宫生成算法
- 生成随机房间
- 将除房间以外的部分用迷宫生成算法填充
- 连接房间与走廊
- 去除一些deadends,减少复杂度
观察josh的图,可以发现他采用的就是“连接房间”这种思路。而我采用的思路是一种填充类型的。其实也可以将填充的区域限定一下,不过我这里还是选择了全地图填充。
迷宫生成算法
具体的迷宫生成算法我选择的是一种叫Recursive backtracker(递归回溯)的算法,俗称不撞南墙不回头:
- 首先,初始化,做一个所有路被墙隔开的图,注意此时的路不能是floor
-
然后,生成随机一点作为起始点,并将其变为floor
-
寻找周围有没有可以连接的路,如果有,就随机取一个方向,把墙和目标点变成floor,跳到目标点
-
如果没有,则退到上一个点,重复步骤3和4。注意:没有的意思是,上下左右,距离两格的四个格子,均没有NOTHING类型的TETile。
-
至全屏再也没有可以连接的路,循环结束
由以上思路,存储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的信息。我的思路是:
- 随机在四边上各取一点
- 循环四次,每次随机生成一个0~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,加个门就完成了!过程中有一些小细节要注意:
- 由于后续操作,房间的位置和宽度高度有一定限制,必须保证房间生成在WALL上。
- 有关某一位置周围的位置的操作,我统一建了一个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真滴舒服,各种提示查错。