第一次玩《元气骑士》时,很多人应该都会产生同一个疑惑:为什么每次进地牢,地图布局都不一样,但每一层都一定走得到 Boss 房?这种“随机但有秩序”的效果,客户端渲染起来很自然,换到服务器开发视角就变成了另一组问题:地图能不能被复现、地图能不能整体连通、客户端要不要本地再生成一遍。
这篇开发日志要记录的是仿元气骑士玩法项目的第一个基础模块:服务器端随机地图生成。读完本文,你可以用 Java 从零实现一套房间-走廊风格的 Roguelike 地图生成器,理解种子随机、最小生成树连通、走廊生成、自动验证和服务器下发这几个关键环节。
这里先给一个判断:这类地图生成的真正难点从来不是“随机”,而是“可控的随机”。所谓可控,是指给一个种子,地图就能稳定复现;给一份生成规则,地图就不会出现玩家到不了 Boss 房的情况;给一组调参手段,地图密度、房间大小就能按策划需求调整。如果只是 random 到处撒点,那生成出来的大概率是“一堆不能玩的散装房间”。
1. 先搞清楚:服务器和客户端对“随机地图”的理解不一样
随机地图生成在很多单机 Roguelike 项目里是客户端本地完成的。但在仿元气骑士的联机项目里,地图生成放到服务器端通常更合适,原因主要有三个。
第一是权威性。服务器生成地图、记录种子、下发结果,客户端只负责渲染和交互。这样玩家不能通过修改本地数据来“刷出”自己想要的房间布局,也不能在掉线重连后和队友看到不一样的地图。
第二是一致性。一个房间里的多个玩家必须看到完全相同的关卡结构。如果每个客户端各自随机,哪怕只是随机种子初始化方式不同,也会出现“你在我左边的墙上看到了门,我看到的却是一堵墙”的严重同步问题。服务器统一生成可以避免这类分歧。
第三是可复现。服务器可以用一个长整型 seed 配合一组生成参数,完整重建地图。这样存档不需要保存几百乘几百的二维数组,只需要保存“seed + 版本号 + 参数”,掉线重连、回放录制、 bug 复现都会轻松很多。
这里要强调一个容易误会的点:服务器生成地图不等于服务器要跑特别重的地图算法。一张 100×100 的地图,几十个房间和走廊的运算成本远低于一个 Boss 的技能逻辑。随机地图生成真正的成本在验证和调参,不在生成本身。所以不要因为担心性能就把地图生成丢给客户端,性能不是主要矛盾。
那这篇日志要覆盖到什么程度?核心是把一条可运行的主链路打通:从零生成地图 → 保证连通 → 标记出生点/出口 → 序列化下发 → 自动化验证。至于房间内部摆件、怪物刷点、宝箱分布,属于地图生成之后的内容分发问题,可以作为后续日志继续展开。
2. 随机地图生成的基础概念与核心原理
在写代码之前,先把四个基础概念讲清楚。这些概念是后面所有代码的地基。
2.1 网格地图
网格地图(Grid Map)就是用二维数组表示关卡。数组里的每个格子记录一个 TileType,比如墙、地板、走廊。对于像素级流光的元气骑士风格,网格地图天然适合做碰撞、寻路和渲染裁剪。
在服务器端,网格地图就是一张二维表:tiles[y][x]。y 表示行,x 表示列。这个索引顺序在写代码时最容易搞混,建议从一开始就固定下来。
2.2 房间与走廊
在这套方案里,房间是矩形区域,内部全部铺成地板;走廊是连接不同房间的窄通道。走廊宽度可以调节,推荐最窄 1 格,玩起来更贴近元气骑士那种紧凑地牢手感。如果角色或怪物体型比较大,走廊宽度就要往上调。
2.3 随机种子
随机种子是精确复现的关键。Java 的new Random(seed)在传入相同 seed 时,会生成完全相同的随机数序列。我们后面生成房间位置、走廊方向都依赖这个特性。
需要特别注意的是:不要让地图生成的 Random 实例被其他逻辑共用。如果服务器的在线人数比较多,进入关卡的随机数被别的模块“截胡”,可能你会获得一个理论上是随机、实际却极难复现的种子。正确做法是每个地图生成器内部持有自己的 Random 实例。
2.4 图与最小生成树
房间只是地图上的“点”,走廊是连接这些点的“边”。要让所有房间连通,最简单可靠的办法是:
- 把每个房间中心看成图的节点。
- 用欧氏距离作为边的权重。
- 对所有房间两两连边,形成完全图。
- 对这个图求最小生成树(MST)。
最小生成树保证所有节点连通,同时边的数量最少。这样地图不会像蜘蛛网一样到处都是走廊,玩起来既连贯又有探索空间。
| 概念 | 解决什么问题 | 对应代码位置 |
|---|---|---|
| 网格地图 | 用数据表达关卡结构 | DungeonMap |
| 房间与走廊 | 定义可玩区域的形态 | Room、carveLShapedCorridor |
| 随机种子 | 让地图可复现、可回放 | DungeonGenerator 构造参数 |
| 最小生成树 | 保证全部房间连通 | Kruskal 算法 |
3. 环境准备与前置条件
这个项目不依赖任何地图专用库,用纯 Java 就能完成。下面是一个比较稳妥的工程环境建议:
- JDK:建议使用长期支持版本,示例代码用常见 Java 语法编写,不依赖太高版本特性,版本请以实际项目为准。
- 构建工具:Maven 或 Gradle 均可,本文不需要第三方依赖。
- 框架:核心算法不依赖 Spring。若你的项目是 Spring Boot 服务,可以直接把生成器作为 Service 使用,再加上一个 Controller 对外提供接口。
- IDE:IntelliJ IDEA 或 Eclipse 都可以,重点是能直接运行 JUnit 测试。
建议的目录结构如下:
src/main/java/com/example/dungeon/ ├── TileType.java ├── Room.java ├── DungeonMap.java └── DungeonGenerator.java src/test/java/com/example/dungeon/ └── DungeonGeneratorTest.java核心算法放在DungeonGenerator中,DungeonMap只负责数据存储和渲染输出。这样职责边界清晰,后续增加房间摆件、刷怪点时不会把生成器类撑爆。
4. 核心流程拆解
整个生成流程可以拆成五个步骤。每一步做完都要能独立验证,而不是等最后跑完才来猜问题。
4.1 生成房间
在地图范围内随机生成多个矩形房间。每次随机时给定宽度、高度、位置,然后尝试放置。放置前必须做重叠检测,否则房间会叠在一起,地图会变得非常脏。
4.2 房间去重与间隔
两个房间之间至少要留出margin格的间隔。这不是为了好看,而是为了给墙留厚度,避免走廊刚挖完,两个房间就直接贴在一起,看起来像合并成了一个房间。
4.3 用最小生成树保证连通
把房间中心作为节点,两两计算欧氏距离,构造完全图,然后运行 Kruskal 算法求出最小生成树。这一步是所有“必达”逻辑的基石。
4.4 生成走廊
对最小生成树的每条边,用 L 型走廊连接两个房间中心。先横向走到目标 x 坐标,再纵向走到目标 y 坐标,或者反过来。横竖顺序可以随机决定,这样走廊更自然。
走廊经过房间时不需要覆盖房间地板;只有经过墙的格子才变成走廊。这样可以避免走廊覆盖掉已经铺好的房间地板,也避免破坏出生点和出口标记。
4.5 标记出生点和出口
按生成顺序拿第一个房间作为出生房间,最后一个房间作为出口房间,在房间中心分别标记 BIRTH 和 EXIT。玩家从出生点出发,理论上可以走到任意房间,也一定能走到出口。
5. 完整示例与代码实现
下面进入实践环节。我会按数据结构、生成算法、服务接口、自动化验证四组代码来写,你可以直接复制到一个 Java 工程中运行。
5.1 基础数据结构
首先是 TileType,用来定义格子类型。
// 文件路径:src/main/java/com/example/dungeon/TileType.java package com.example.dungeon; public enum TileType { WALL(0), ROOM(1), CORRIDOR(2), BIRTH(3), EXIT(4); public final int code; TileType(int code) { this.code = code; } public static TileType fromCode(int code) { for (TileType type : values()) { if (type.code == code) { return type; } } return WALL; } }这里把 BIRTH 和 EXIT 也作为格子类型,方便后续客户端判断出生点和出口的位置。
然后是 Room 类和 DungeonMap 类。Room 只保存矩形信息,中心点由 x、y、w、h 推导出来。DungeonMap 负责二维数组的读写,以及可视化渲染。
// 文件路径:src/main/java/com/example/dungeon/Room.java package com.example.dungeon; public class Room { public final int x; public final int y; public final int w; public final int h; public Room(int x, int y, int w, int h) { this.x = x; this.y = y; this.w = w; this.h = h; } public int centerX() { return x + w / 2; } public int centerY() { return y + h / 2; } }// 文件路径:src/main/java/com/example/dungeon/DungeonMap.java package com.example.dungeon; import java.util.ArrayList; import java.util.List; public class DungeonMap { public final int width; public final int height; private final TileType[][] tiles; private final List<Room> rooms = new ArrayList<>(); public DungeonMap(int width, int height) { this.width = width; this.height = height; this.tiles = new TileType[height][width]; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { tiles[y][x] = TileType.WALL; } } } public void set(int x, int y, TileType type) { tiles[y][x] = type; } public TileType get(int x, int y) { return tiles[y][x]; } public void addRoom(Room room) { rooms.add(room); } public List<Room> rooms() { return rooms; } public String render() { StringBuilder sb = new StringBuilder(); for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { switch (tiles[y][x]) { case WALL: sb.append('#'); break; case ROOM: sb.append('.'); break; case CORRIDOR: sb.append('+'); break; case BIRTH: sb.append('S'); break; case EXIT: sb.append('E'); break; default: sb.append('?'); break; } } sb.append('\n'); } return sb.toString(); } }这里render()方法对排错非常有用。遇到地图异常时,直接把字符串打到服务日志里,基本一眼就能看出房间是否重叠、走廊是否断掉、出口是否可达。
5.2 生成器主逻辑
DungeonGenerator 是整个模块的核心。它包含房间生成、重叠检测、最小生成树、走廊挖掘和起点终点标记。
// 文件路径:src/main/java/com/example/dungeon/DungeonGenerator.java package com.example.dungeon; import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Random; import java.util.Set; public class DungeonGenerator { private final int width; private final int height; private final int targetRooms; private final int minRoomSize; private final int maxRoomSize; private final int margin; private final DungeonMap map; private final List<Room> rooms = new ArrayList<>(); private final Random random; public DungeonGenerator(long seed, int width, int height, int targetRooms) { this(seed, width, height, targetRooms, 5, 10, 2); } public DungeonGenerator(long seed, int width, int height, int targetRooms, int minRoomSize, int maxRoomSize, int margin) { this.random = new Random(seed); this.width = width; this.height = height; this.targetRooms = targetRooms; this.minRoomSize = minRoomSize; this.maxRoomSize = maxRoomSize; this.margin = margin; this.map = new DungeonMap(width, height); } public DungeonMap generate() { placeRooms(); connectRooms(); markStartAndExit(); return map; } private void placeRooms() { int maxAttempts = targetRooms * 10; int attempts = 0; while (rooms.size() < targetRooms && attempts < maxAttempts) { attempts++; Room room = createRoom(); if (!overlapsAny(room)) { for (int y = room.y; y < room.y + room.h; y++) { for (int x = room.x; x < room.x + room.w; x++) { map.set(x, y, TileType.ROOM); } } rooms.add(room); map.addRoom(room); } } } private Room createRoom() { int w = minRoomSize + random.nextInt(maxRoomSize - minRoomSize + 1); int h = minRoomSize + random.nextInt(maxRoomSize - minRoomSize + 1); int maxX = Math.max(1, width - w - 2); int maxY = Math.max(1, height - h - 2); int x = 1 + random.nextInt(maxX); int y = 1 + random.nextInt(maxY); return new Room(x, y, w, h); } private boolean overlapsAny(Room candidate) { for (Room room : rooms) { if (candidate.x <= room.x + room.w + margin && room.x <= candidate.x + candidate.w + margin && candidate.y <= room.y + room.h + margin && room.y <= candidate.y + candidate.h + margin) { return true; } } return false; } private void connectRooms() { if (rooms.size() <= 1) { return; } List<Edge> edges = buildAllEdges(); List<Edge> mst = kruskal(edges); for (Edge edge : mst) { Room a = rooms.get(edge.from); Room b = rooms.get(edge.to); carveLShapedCorridor(a.centerX(), a.centerY(), b.centerX(), b.centerY()); } Set<String> mstKeys = new HashSet<>(); for (Edge edge : mst) { mstKeys.add(edge.from + "-" + edge.to); } List<Edge> candidates = new ArrayList<>(); for (Edge edge : edges) { if (!mstKeys.contains(edge.from + "-" + edge.to)) { candidates.add(edge); } } if (!candidates.isEmpty()) { Edge extra = candidates.get(random.nextInt(candidates.size())); Room a = rooms.get(extra.from); Room b = rooms.get(extra.to); carveLShapedCorridor(a.centerX(), a.centerY(), b.centerX(), b.centerY()); } } private List<Edge> buildAllEdges() { List<Edge> edges = new ArrayList<>(); for (int i = 0; i < rooms.size(); i++) { for (int j = i + 1; j < rooms.size(); j++) { Room a = rooms.get(i); Room b = rooms.get(j); double weight = distance(a.centerX(), a.centerY(), b.centerX(), b.centerY()); edges.add(new Edge(i, j, weight)); } } return edges; } private double distance(int x1, int y1, int x2, int y2) { double dx = x1 - x2; double dy = y1 - y2; return Math.sqrt(dx * dx + dy * dy); } private List<Edge> kruskal(List<Edge> edges) { List<Edge> sorted = new ArrayList<>(edges); sorted.sort(Comparator.comparingDouble(e -> e.weight)); int[] parent = new int[rooms.size()]; for (int i = 0; i < parent.length; i++) { parent[i] = i; } List<Edge> mst = new ArrayList<>(); for (Edge edge : sorted) { int rootA = find(parent, edge.from); int rootB = find(parent, edge.to); if (rootA != rootB) { parent[rootA] = rootB; mst.add(edge); } } return mst; } private int find(int[] parent, int i) { while (parent[i] != i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; } private void carveLShapedCorridor(int x1, int y1, int x2, int y2) { int x = x1; int y = y1; if (random.nextBoolean()) { while (x != x2) { x += Integer.compare(x2, x); setCorridor(x, y); } while (y != y2) { y += Integer.compare(y2, y); setCorridor(x, y); } } else { while (y != y2) { y += Integer.compare(y2, y); setCorridor(x, y); } while (x != x2) { x += Integer.compare(x2, x); setCorridor(x, y); } } } private void setCorridor(int x, int y) { if (map.get(x, y) == TileType.WALL) { map.set(x, y, TileType.CORRIDOR); } } private void markStartAndExit() { if (rooms.isEmpty()) { return; } Room first = rooms.get(0); map.set(first.centerX(), first.centerY(), TileType.BIRTH); Room last = rooms.get(rooms.size() - 1); map.set(last.centerX(), last.centerY(), TileType.EXIT); } private static class Edge { int from; int to; double weight; Edge(int from, int to, double weight) { this.from = from; this.to = to; this.weight = weight; } } }这段代码里有几个容易出错的设计点,需要展开解释。
第一,placeRooms里的maxAttempts = targetRooms * 10。这是为了在房间放不下时不需要无限循环。地图越小、房间越大,失败尝试越多。如果地图空间不足,最终房间数会少于targetRooms,这属于正常现象。生产环境中可以把实际房间数返回给上层,便于日志排查。
第二,overlapsAny使用了margin边距。如果这里漏掉margin,房间之间就容易出现“贴脸”,玩起来像一个大房间被几堵细墙草率分隔。
第三,connectRooms在最小生成树之外还加了一条随机边。这样做不是画蛇添足,而是避免地图结构过于“树形”。树形地图的问题在于路径选择太少,玩家基本只能沿着一条主线走,探索感会很弱。加一条额外走廊后,地图会产生回路,玩家可以选择左绕或者右绕,更接近元气骑士的地牢体验。
第四,setCorridor只在 WALL 上覆盖 CORRIDOR,不会破坏已经存在的 ROOM、 BIRTH 或 EXIT。这一点很关键,否则走廊可能把出生点标记冲掉。
5.3 服务接口示例
在服务器开发中,地图生成之后通常要对外提供接口。这里用一个 Spring Boot Controller 做演示。如果你不用 Spring,也可以把同样的逻辑包在任意 RPC 服务里。
// 文件路径:src/main/java/com/example/dungeon/DungeonController.java package com.example.dungeon; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; import java.util.stream.Collectors; @RestController @RequestMapping("/api/dungeon") public class DungeonController { @PostMapping("/generate") public DungeonResponse generate(@RequestBody GenerateRequest request) { long seed = request.getSeed() == null ? System.currentTimeMillis() ^ System.nanoTime() : request.getSeed(); DungeonGenerator generator = new DungeonGenerator( seed, request.getWidth(), request.getHeight(), request.getTargetRooms()); DungeonMap map = generator.generate(); return DungeonResponse.from(map, seed); } }对应的请求和响应对象可以这样简化:
// 文件路径:src/main/java/com/example/dungeon/DungeonController.java(配套类) public class GenerateRequest { private Long seed; private int width = 100; private int height = 100; private int targetRooms = 12; public Long getSeed() { return seed; } public void setSeed(Long seed) { this.seed = seed; } public int getWidth() { return width; } public void setWidth(int width) { this.width = width; } public int getHeight() { return height; } public void setHeight(int height) { this.height = height; } public int getTargetRooms() { return targetRooms; } public void setTargetRooms(int targetRooms) { this.targetRooms = targetRooms; } } public class DungeonResponse { public final long seed; public final int width; public final int height; public final int roomCount; public final int[][] tiles; public final List<RoomDto> rooms; public DungeonResponse(long seed, int width, int height, int roomCount, int[][] tiles, List<RoomDto> rooms) { this.seed = seed; this.width = width; this.height = height; this.roomCount = roomCount; this.tiles = tiles; this.rooms = rooms; } public static DungeonResponse from(DungeonMap map, long seed) { int[][] tiles = new int[map.height][map.width]; for (int y = 0; y < map.height; y++) { for (int x = 0; x < map.width; x++) { tiles[y][x] = map.get(x, y).code; } } return new DungeonResponse( seed, map.width, map.height, map.rooms().size(), tiles, map.rooms().stream() .map(r -> new RoomDto(r.x, r.y, r.w, r.h)) .collect(Collectors.toList())); } public static class RoomDto { public final int x; public final int y; public final int w; public final int h; public RoomDto(int x, int y, int w, int h) { this.x = x; this.y = y; this.w = w; this.h = h; } } }实际线上项目更推荐把int[][] tiles改成协议化的紧凑格式,比如按行压缩为字符串,或直接用 Protobuf/FlatBuffers,减少消息体积。服务接口示例的重点是说明地图生成结果能够在服务器侧直接序列化下发,客户端不需要自己再跑一遍随机算法。
注意:生产环境的请求参数必须做范围校验,比如 width、height 不能超过上限,targetRooms 不能超过地图容量,否则容易被异常请求打满内存。
5.4 自动化验证
随机地图生成最怕的是“偶尔出现一个坏图”。为了让问题尽早暴露,必须写自动化验证。下面这段 JUnit 示例覆盖三个核心约束:同种子一致性、全房间连通性、出口可达。
// 文件路径:src/test/java/com/example/dungeon/DungeonGeneratorTest.java package com.example.dungeon; import org.junit.jupiter.api.Test; import java.util.ArrayDeque; import java.util.Queue; import static org.junit.jupiter.api.Assertions.*; class DungeonGeneratorTest { @Test void sameSeedProducesSameMap() { DungeonMap a = new DungeonGenerator(20250420L, 100, 100, 12).generate(); DungeonMap b = new DungeonGenerator(20250420L, 100, 100, 12).generate(); for (int y = 0; y < a.height; y++) { for (int x = 0; x < a.width; x++) { assertEquals(a.get(x, y), b.get(x, y), "相同 seed 必须生成完全一致的地图"); } } } @Test void allRoomsConnectedFromBirth() { DungeonMap map = new DungeonGenerator(42L, 100, 100, 12).generate(); boolean[][] visited = new boolean[map.height][map.width]; Queue<int[]> queue = new ArrayDeque<>(); Room first = map.rooms().get(0); queue.add(new int[]{first.centerX(), first.centerY()}); visited[first.centerY()][first.centerX()] = true; int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; while (!queue.isEmpty()) { int[] cur = queue.poll(); for (int[] dir : dirs) { int nx = cur[0] + dir[0]; int ny = cur[1] + dir[1]; if (nx < 0 || nx >= map.width || ny < 0 || ny >= map.height) { continue; } if (visited[ny][nx]) { continue; } TileType type = map.get(nx, ny); if (type == TileType.WALL) { continue; } visited[ny][nx] = true; queue.add(new int[]{nx, ny}); } } for (Room room : map.rooms()) { assertTrue(visited[room.centerY()][room.centerX()], "房间中心必须从出生点可达"); } } }这里最值得保留的是 BFS 连通性验证。只要 BFS 能够从出生房间走到所有房间中心,玩家理论上就一定能通关。
6. 运行结果与效果验证
依赖 Maven 的工程可以直接运行:
mvn test也可以只跑 DungeonGeneratorTest:
mvn -Dtest=DungeonGeneratorTest test如果全部测试通过,说明当前 seed 生成的地图同时满足“可复现”和“全局连通”两个硬性要求。
想直观看地图长什么样,可以在测试或 main 方法里打印render():
DungeonMap map = new DungeonGenerator(42L, 80, 40, 8).generate(); System.out.println(map.render());预期输出是以#、.、+、S、E组成的 ASCII 地图。其中S是出生点,E是出口,.是房间地板,+是走廊,#是墙。
判断成功的标准包括:
- 地图中房间数量接近 expected 值。
S和E都存在。- 从
S出发可以沿走廊或房间走到E。 - 使用相同 seed 重复生成,地图完全一致。
如果测试失败,第一步不要瞎猜代码逻辑,直接把render()输出贴出来看。房间重叠、走廊断裂、孤立房间在 ASCII 图里都非常明显。第二步再去看是房间生成阶段失败,还是连通阶段失败,还是标记阶段失败。
7. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 实际房间数明显少于配置 | 地图空间不足,或 maxAttempts 太小 | 打印房间坐标与地图尺寸 | 增大地图、减小房间尺寸、提高尝试次数 |
| 房间重叠在一起 | 重叠检测没有生效,或 margin 传错 | 打印所有房间坐标 | 检查 overlapsAny 边界条件,间隔至少为 margin |
| 某个房间到达不了 | MST 没有覆盖全部节点,或走廊被墙阻断 | 渲染 ASCII 地图,BFS 验证 | 确认 Kruskal 用所有房间建图,生成走廊后不要覆盖 ROOM |
| 相同 seed 地图不一致 | Random 实例被外部共用,或随机序列被其他模块消耗 | 检查是否每次都 new Random(seed) | 生成器内部持有独立 Random |
| 走廊越界 | createRoom 的 x/y 上限计算错误 | 捕获 ArrayIndexOutOfBounds 堆栈 | 限制 x + w < width,y + h < height |
| 出生点或出口丢失 | 房间列表为空,或标记被后续走廊覆盖 | 查看 rooms.size() 与渲染图 | 确认 rooms 非空后再标记, setCorridor 不覆盖 BIRTH/EXIT |
还有一个常见问题容易被忽略:房间生成本身是允许失败的。如果地图特别小,房间数会少于目标值。这时如果业务逻辑要求“这层必须至少 6 个房间”,就得在上层做补偿策略。常见做法是换一个 seed 重试,或者缩小房间尺寸。但要注意,重试不能无限制进行,否则极端情况下接口耗时会变得不可控。
8. 最佳实践与工程建议
8.1 存档只存种子,不存地图矩阵
这是这套方案最有价值的工程收益。DungeonMap 是完整二维数组,如果存档直接序列化数组,一张 100×100 的地图就要存一万个格子。但只存 seed、生成参数和生成器版本号,就能在玩家重新进入关卡时重建同一张地图。
存档结构可以设计为:
{ "dungeonVersion": 1, "seed": 20250420, "width": 100, "height": 100, "targetRooms": 12 }8.2 生成器版本号必须独立维护
地图生成算法一旦上线,最好保持稳定。因为玩家已经拿着旧地图在玩了,如果中途改了算法,存档读取时重建出的地图会跟之前不一致。这时候就需要dungeonVersion。存档里记住版本号,服务端按版本号分发对应的生成器。
这听起来很麻烦,但对联机项目非常重要。算法可以迭代,但版本号不能省。
8.3 协议与反作弊
推荐服务端下发两种信息之一:
- 下发完整地图数据,客户端只做渲染。
- 下发 seed 和生成参数,客户端内置相同算法本地重建。
第二种方式带宽成本更低,但要求客户端与服务端的生成算法完全一致,且不能有浮点环境差异。更稳妥的做法是在服务端生成完成后,把地图网格的哈希也一起下发,客户端重建后校验哈希,不一致就上报。
从反作弊角度,seed 必须由服务端生成。如果让客户端传 seed,玩家可以反复请求,直到遇到一个出生点旁边全是宝箱房、怪物极少的种子。这相当于自己选图。
8.4 参数配置与调参
房间大小、房间数量、走廊宽度、margin 这些参数应该进配置表,而不是写死在代码里。因为不同楼层可能风格不同:第一层小房间密集,Boss 层大房间空旷,隐藏层多走廊连接。
调参时建议批量生成几百张地图,跑自动化验证,统计连通率、平均房间数、走廊总长度。把统计结果输出成日志或图表。不要靠肉眼一张一张看,效率太低,也容易漏掉偶发坏图。
8.5 日志与可视化
每次生成地图,至少记录:
- seed
- 地图尺寸
- 目标房间数与实际房间数
- 生成耗时
- 连通性校验结果
如果线上出现“玩家卡在某个房间里出不去”的反馈,日志里的 seed 就是最直接的复现依据。配合render()输出,可以在几秒钟内判断是算法问题还是关卡配置问题。
8.6 关于性能
单次地图生成的运算量很小,真正的性能瓶颈往往出现在后续的寻路、批量怪物寻路、动态光照等系统。但服务器侧仍要留意一个点:如果进入关卡的玩家数量很高,且同一时刻产生大量生成请求,最好做一个小内存池或者复用对象,避免频繁 new 大数组。
更实际的做法是限制并发生成数,或者把生成任务丢到异步线程池里。地图生成本身只要几十毫秒以内,大部分情况下用同步接口完全足够。
9. 小结与后续扩展方向
这篇开发日志把“仿元气骑士随机地图生成”最基础的一层跑通了:房间生成、重叠检测、最小生成树连通、L 型走廊、出生点和出口标记,以及自动化验证。它的核心价值在于一个判断:随机地图生成不是撒点游戏,而是“种子 + 规则 + 验证”的工程问题。
如果只记一条经验,我会记这一条:随机是手段,连通是底线。地图生成得再漂亮,只要有一个房间无法从出生点到达,这层关卡就是失败的。所以无论后续怎么扩展,先把连通性验证和同种子复现测试写好,再考虑复杂玩法。
下一步值得做的方向有很多:
- 在房间内部生成摆件、宝箱、怪物刷点。
- 把房间模板化,比如书房、宝库、Boss 房,而不是一刀切的矩形。
- 生成多楼层地图,并加传送门规则。
- 用 BSP 或 Delaunay 三角化替换当前简单的完全图 + MST,让房间分布更自然。
作为服务器开发日志的第 1 篇,本文到这里先把地图地基打好。后续内容可以继续记录刷怪规则、掉落分布、存档恢复和多人同步。如果你正在做类似的地牢玩法项目,建议先把这份代码跑起来,生成几十张 ASCII 地图看一看,再开始调参数。地图这种东西,看十遍对比图,比读十篇原理文章更管用。