独立游戏程序化生成实战:Roguelike 关卡生成、算法与内容复用技巧
这是 「程序化生成从原理到实战的完整技术手册」。
不讲「随机就是好」的废话,只讲能落地的算法、代码和设计决策,适用于:
✔ 想做 Roguelike/Roguelite 的独立开发者
✔ 想给游戏加入随机地图但不确定从哪种算法入手
✔ 已经在用程序化生成但质量不稳定、需要优化
✔ 想了解 Dead Cells、Slay the Spire、FTL 等经典游戏的生成策略
一、程序化生成的价值与适用场景
1.1 一组有说服力的数据
根据 Steam 平台 2024-2025 年数据:
- Roguelike/Roguelite 品类 在 Steam 上的好评率中位数为 82%,高于全平台平均的 74%
- 使用程序化生成的游戏平均重玩次数为 12.7 次,手工关卡游戏为 3.2 次
- Steam 上销量前 50 的独立游戏中,38% 使用了某种形式的程序化生成
- Hades(程序化房间组合)总销量超过 100 万份,Slay the Spire 超过 350 万份
另一组来自 itch.io 的统计显示,标注了「Procedural Generation」标签的游戏,其平均游玩时长比同类未标注的游戏高出 2.8 倍。
1.2 程序化生成的四大优势
| 优势 | 说明 | 典型案例 |
|---|---|---|
| 无限内容 | 用有限的美术资产组合出近乎无限的游戏内容 | Minecraft 的世界有 2^64 种可能 |
| 高重玩价值 | 每次游戏体验不同,延长生命周期 | The Binding of Isaac 重玩价值极高 |
| 开发成本可控 | 长期来看,用算法代替大量手工关卡 | No Man’s Sky 有 18 万亿亿颗星球 |
| 社区传播 | 种子分享功能让玩家社区自发传播 | Dwarf Fortress 的传奇故事 |
1.3 适用游戏类型
| 类型 | 适配度 | 说明 |
|---|---|---|
| Roguelike/Roguelite | ⭐⭐⭐⭐⭐ | 核心卖点就是随机性 |
| 生存游戏 | ⭐⭐⭐⭐⭐ | 地图/资源/天气随机化 |
| 沙盒游戏 | ⭐⭐⭐⭐⭐ | 世界生成是核心体验 |
| 解谜游戏 | ⭐⭐⭐ | 部分适用,如 Baba Is You 式的关卡 |
| ARPG | ⭐⭐⭐⭐ | 地图和战利品随机化 |
| 卡牌游戏 | ⭐⭐⭐⭐ | 随机牌组/敌人组合 |
1.4 不适用场景
- 线性叙事游戏(如 Celeste、Undertale):叙事需要精心设计的节奏
- 精确平台跳跃(如 Super Meat Boy):关卡手感需要逐帧调优
- 解谜游戏核心关卡(如 Portal):谜题需要精确的空间设计
- 教学关卡:前 5 分钟的体验必须是手工设计的
💡 黄金法则: 程序化生成不是手工设计的替代品,而是放大器。先用手工设计验证核心玩法有趣,再用程序化生成放大内容量。
二、随机数生成基础
2.1 伪随机数 vs 真随机数
| 特性 | 伪随机数(PRNG) | 真随机数(TRNG) |
|---|---|---|
| 生成方式 | 数学算法 | 物理现象(热噪声等) |
| 可复现性 | ✅ 相同种子产生相同序列 | ❌ 不可复现 |
| 速度 | 极快(纳秒级) | 慢(依赖硬件) |
| 游戏适用性 | ✅ 游戏开发首选 | ❌ 仅加密场景需要 |
游戏中的随机数几乎都使用 PRNG,因为可复现性对于调试和种子分享至关重要。
2.2 种子(Seed)系统
为什么需要种子?
- 可调试:用固定种子复现 Bug
- 可分享:玩家分享有趣的地图种子
- 可测试:自动化测试用固定种子保证结果一致
- 每日挑战:每天一个固定种子,所有玩家面对相同地图
种子系统设计模板:
// Unity C# 示例
public class SeededRandom
{
private System.Random _rng;
public int CurrentSeed { get; private set; }
public SeededRandom(int? seed = null)
{
// 如果没提供种子,用时间戳或随机生成
CurrentSeed = seed ?? GenerateRandomSeed();
_rng = new System.Random(CurrentSeed);
}
private int GenerateRandomSeed()
{
// 用时间戳生成种子
return (int)(System.DateTime.Now.Ticks % int.MaxValue);
}
// 基础随机数方法
public float Range(float min, float max)
{
return (float)(_rng.NextDouble() * (max - min) + min);
}
public int Range(int min, int max)
{
return _rng.Next(min, max + 1);
}
// 种子字符串化(方便分享)
public static string SeedToString(int seed)
{
return seed.ToString("X8"); // 16 进制字符串
}
public static int StringToSeed(string seedStr)
{
return Convert.ToInt32(seedStr, 16);
}
// 重置到初始状态
public void Reset()
{
_rng = new System.Random(CurrentSeed);
}
}
种子分享 UI 设计建议:
┌────────────────────────────────────────┐
│ 🎲 当前地图种子: A3F8C201 │
│ │
│ [📋 复制种子] [🔄 新种子] [📝 输入] │
│ │
│ 💡 和朋友分享这个种子, │
│ 你们会玩到完全相同的地图! │
└────────────────────────────────────────┘
2.3 随机数分布类型
不同的分布适用于不同的游戏场景:
均匀分布(Uniform Distribution):
- 每个值出现概率相同
- 适用:随机选择敌人类型、随机方向
// 均匀分布:每个值概率相同
float uniform = Random.Range(0f, 1f);
正态分布(Normal/Gaussian Distribution):
- 中间值出现概率高,极端值低
- 适用:角色属性生成、伤害波动
// Box-Muller 变换生成正态分布
public float NextGaussian(float mean = 0f, float stdDev = 1f)
{
double u1 = 1.0 - _rng.NextDouble(); // 避免 log(0)
double u2 = _rng.NextDouble();
double randStdNormal = Math.Sqrt(-2.0 * Math.Log(u1)) *
Math.Sin(2.0 * Math.PI * u2);
return mean + stdDev * (float)randStdNormal;
}
泊松分布(Poisson Distribution):
- 描述固定时间/空间内事件发生的次数
- 适用:单位时间内刷怪数量、宝箱中物品数量
// 泊松分布
public int NextPoisson(double lambda)
{
double L = Math.Exp(-lambda);
double p = 1.0;
int k = 0;
do
{
k++;
p *= _rng.NextDouble();
} while (p > L);
return k - 1;
}
各分布适用场景对比:
| 分布 | 游戏场景 | 示例 |
|---|---|---|
| 均匀分布 | 等概率随机选择 | 随机选一种敌人类型 |
| 正态分布 | 围绕均值的波动 | 武器伤害 ±10% 浮动 |
| 泊松分布 | 单位时间/空间的事件数 | 每分钟遭遇战次数 |
| 指数分布 | 事件间隔时间 | 两次稀有掉落之间的尝试次数 |
| 加权分布 | 不同概率的选择 | 战利品表(常见/稀有/传说) |
三、地牢生成算法
3.1 随机游走(Random Walk / Drunkard’s Walk)
算法描述:
最简单的地牢生成算法。从地图中心开始,随机选择一个方向移动,将经过的格子标记为地板。重复直到地板数量达到目标值。
C# 实现:
public class RandomWalkGenerator
{
private int _width, _height;
private int[,] _map; // 0 = 墙, 1 = 地板
private SeededRandom _rng;
// 四个方向:上、下、左、右
private static readonly Vector2Int[] Directions = {
new Vector2Int(0, 1), // 上
new Vector2Int(0, -1), // 下
new Vector2Int(-1, 0), // 左
new Vector2Int(1, 0) // 右
};
public int[,] Generate(int width, int height, int seed,
int targetFloorCount = 500)
{
_width = width;
_height = height;
_rng = new SeededRandom(seed);
_map = new int[width, height]; // 默认全墙
// 从中心开始
Vector2Int current = new Vector2Int(width / 2, height / 2);
int floorCount = 0;
while (floorCount < targetFloorCount)
{
// 标记当前位置为地板
if (_map[current.x, current.y] == 0)
{
_map[current.x, current.y] = 1;
floorCount++;
}
// 随机选择一个方向
Vector2Int dir = Directions[_rng.Range(0, 3)];
current += dir;
// 边界检查
current.x = Mathf.Clamp(current.x, 1, width - 2);
current.y = Mathf.Clamp(current.y, 1, height - 2);
}
return _map;
}
}
GDScript 实现(Godot):
extends Node
class_name RandomWalkGenerator
var width: int
var height: int
var map: Array
var rng: RandomNumberGenerator
const DIRECTIONS = [
Vector2i(0, 1), # 上
Vector2i(0, -1), # 下
Vector2i(-1, 0), # 左
Vector2i(1, 0) # 右
]
func generate(p_width: int, p_height: int, seed_val: int,
target_floors: int = 500) -> Array:
width = p_width
height = p_height
rng = RandomNumberGenerator.new()
rng.seed = seed_val
# 初始化地图为全墙
map = []
for x in range(width):
map.append([])
for y in range(height):
map[x].append(0)
var current = Vector2i(width / 2, height / 2)
var floor_count = 0
while floor_count < target_floors:
if map[current.x][current.y] == 0:
map[current.x][current.y] = 1
floor_count += 1
var dir = DIRECTIONS[rng.randi_range(0, 3)]
current += dir
current.x = clampi(current.x, 1, width - 2)
current.y = clampi(current.y, 1, height - 2)
return map
优缺点:
- ✅ 实现极简(< 50 行代码)
- ✅ 生成的地形自然、有机
- ❌ 可能产生大量死胡同
- ❌ 走廊可能太窄,不适合战斗
- ❌ 难以控制房间数量和大小
优化技巧:
- 增加游走步数,使走廊更宽
- 对结果进行平滑处理(移除孤立墙壁)
- 多次游走取并集,增加地图多样性
3.2 BSP 树(Binary Space Partitioning)
算法描述:
将地图空间递归地一分为二,直到每个子区域足够小,然后在每个区域中放置一个房间,最后用走廊连接相邻房间。
C# 实现:
public class BSPNode
{
public RectInt Area { get; set; }
public RectInt? Room { get; set; }
public BSPNode Left { get; set; }
public BSPNode Right { get; set; }
public bool IsLeaf => Left == null && Right == null;
public BSPNode(RectInt area)
{
Area = area;
}
}
public class BSPDungeonGenerator
{
private int _minLeafSize = 8; // 叶节点最小尺寸
private int _maxLeafSize = 18; // 叶节点最大尺寸
private int _minRoomSize = 4; // 房间最小尺寸
private int _maxRoomSize = 10; // 房间最大尺寸
private SeededRandom _rng;
private int[,] _map;
public int[,] Generate(int width, int height, int seed)
{
_rng = new SeededRandom(seed);
_map = new int[width, height];
// 1. 递归分割空间
var root = new BSPNode(new RectInt(0, 0, width, height));
Split(root);
// 2. 在每个叶节点中创建房间
CreateRooms(root);
// 3. 连接房间(走廊)
ConnectRooms(root);
return _map;
}
private void Split(BSPNode node)
{
if (node.Area.width <= _maxLeafSize &&
node.Area.height <= _maxLeafSize)
return;
// 随机选择水平或垂直分割
bool splitHorizontally = _rng.Range(0, 1) == 0;
if (node.Area.width > node.Area.height * 1.25f)
splitHorizontally = false;
else if (node.Area.height > node.Area.width * 1.25f)
splitHorizontally = true;
int splitPos;
if (splitHorizontally)
{
splitPos = _rng.Range(_minLeafSize,
node.Area.height - _minLeafSize);
node.Left = new BSPNode(new RectInt(
node.Area.x, node.Area.y,
node.Area.width, splitPos));
node.Right = new BSPNode(new RectInt(
node.Area.x, node.Area.y + splitPos,
node.Area.width, node.Area.height - splitPos));
}
else
{
splitPos = _rng.Range(_minLeafSize,
node.Area.width - _minLeafSize);
node.Left = new BSPNode(new RectInt(
node.Area.x, node.Area.y,
splitPos, node.Area.height));
node.Right = new BSPNode(new RectInt(
node.Area.x + splitPos, node.Area.y,
node.Area.width - splitPos, node.Area.height));
}
Split(node.Left);
Split(node.Right);
}
private void CreateRooms(BSPNode node)
{
if (node.IsLeaf)
{
int roomW = _rng.Range(_minRoomSize,
Mathf.Min(_maxRoomSize, node.Area.width - 2));
int roomH = _rng.Range(_minRoomSize,
Mathf.Min(_maxRoomSize, node.Area.height - 2));
int roomX = node.Area.x + _rng.Range(1,
node.Area.width - roomW - 1);
int roomY = node.Area.y + _rng.Range(1,
node.Area.height - roomH - 1);
node.Room = new RectInt(roomX, roomY, roomW, roomH);
// 在地图上画房间
for (int x = roomX; x < roomX + roomW; x++)
for (int y = roomY; y < roomY + roomH; y++)
if (x >= 0 && x < _map.GetLength(0) &&
y >= 0 && y < _map.GetLength(1))
_map[x, y] = 1;
}
else
{
if (node.Left != null) CreateRooms(node.Left);
if (node.Right != null) CreateRooms(node.Right);
}
}
private void ConnectRooms(BSPNode node)
{
if (node.IsLeaf) return;
if (node.Left != null) ConnectRooms(node.Left);
if (node.Right != null) ConnectRooms(node.Right);
if (node.Left?.Room != null && node.Right?.Room != null)
{
var leftCenter = new Vector2Int(
node.Left.Value.Room.Value.center.x,
node.Left.Value.Room.Value.center.y);
var rightCenter = new Vector2Int(
node.Right.Value.Room.Value.center.x,
node.Right.Value.Room.Value.center.y);
CarveCorridor(leftCenter, rightCenter);
}
}
private void CarveCorridor(Vector2Int from, Vector2Int to)
{
// L 形走廊
if (_rng.Range(0, 1) == 0)
{
// 先水平后垂直
for (int x = Mathf.Min(from.x, to.x);
x <= Mathf.Max(from.x, to.x); x++)
_map[x, from.y] = 1;
for (int y = Mathf.Min(from.y, to.y);
y <= Mathf.Max(from.y, to.y); y++)
_map[to.x, y] = 1;
}
else
{
// 先垂直后水平
for (int y = Mathf.Min(from.y, to.y);
y <= Mathf.Max(from.y, to.y); y++)
_map[from.x, y] = 1;
for (int x = Mathf.Min(from.x, to.x);
x <= Mathf.Max(from.x, to.x); x++)
_map[x, to.y] = 1;
}
}
}
适用场景:
- 传统 Roguelike 地牢
- 需要明确的房间分隔
- 房间数量可控(5-15 个)
3.3 细胞自动机(Cellular Automata)
算法描述:
从随机噪声开始,通过多轮迭代应用简单规则,让地图自组织成洞穴状结构。
C# 实现:
public class CellularAutomataGenerator
{
private int _width, _height;
private bool[,] _map;
private SeededRandom _rng;
public bool[,] Generate(int width, int height, int seed,
float wallDensity = 0.45f,
int iterations = 5)
{
_width = width;
_height = height;
_rng = new SeededRandom(seed);
_map = new bool[width, height];
// 1. 初始化随机噪声
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
if (x == 0 || x == width - 1 ||
y == 0 || y == height - 1)
{
_map[x, y] = true; // 边界为墙
}
else
{
_map[x, y] = _rng.Range(0f, 1f) < wallDensity;
}
}
}
// 2. 迭代平滑
for (int i = 0; i < iterations; i++)
{
SmoothMap();
}
return _map;
}
private void SmoothMap()
{
bool[,] newMap = new bool[_width, _height];
for (int x = 0; x < _width; x++)
{
for (int y = 0; y < _height; y++)
{
int wallNeighbors = CountWallNeighbors(x, y);
if (wallNeighbors > 4)
newMap[x, y] = true; // 变成墙
else if (wallNeighbors < 4)
newMap[x, y] = false; // 变成地板
else
newMap[x, y] = _map[x, y]; // 保持不变
}
}
_map = newMap;
}
private int CountWallNeighbors(int x, int y)
{
int count = 0;
for (int dx = -1; dx <= 1; dx++)
{
for (int dy = -1; dy <= 1; dy++)
{
if (dx == 0 && dy == 0) continue;
int nx = x + dx, ny = y + dy;
if (nx < 0 || nx >= _width || ny < 0 || ny >= _height)
count++; // 越界算墙
else if (_map[nx, ny])
count++;
}
}
return count;
}
}
迭代规则(经典 4/5 规则):
- 如果一个格子周围(8 个邻居)有 > 4 面墙,则变成墙
- 如果一个格子周围 < 4 面墙,则变成地板
- 迭代 4-5 次 通常能得到理想的洞穴效果
3.4 算法对比表
| 算法 | 实现难度 | 生成速度 | 效果 | 适用场景 |
|---|---|---|---|---|
| 随机游走 | ⭐ | 快 | 有机的、蜿蜒的通道 | 洞穴、自然地形 |
| BSP 树 | ⭐⭐⭐ | 快 | 规整的房间 + 走廊 | 传统地牢 |
| 房间与走廊 | ⭐⭐ | 中 | 随机分布的房间 | 探索型地牢 |
| 细胞自动机 | ⭐⭐ | 中 | 洞穴状、不规则 | 洞穴、自然场景 |
| Wave Function Collapse | ⭐⭐⭐⭐⭐ | 慢 | 基于约束的高质量生成 | 高质量关卡 |
| Drunkard’s Walk (多 walker) | ⭐ | 快 | 更宽的通道 | 宽敞的地牢 |
四、程序化地形生成
4.1 Perlin Noise / Simplex Noise
Perlin Noise 由 Ken Perlin 在 1983 年发明(后来因 Tron 电影获奥斯卡技术奖),是地形生成的基石。
核心特性:
- 连续性:相邻值平滑过渡
- 可叠加:多层噪声可以创造丰富细节
- 可复现:相同输入总是产生相同输出
Unity 地形高度图生成:
public class TerrainGenerator : MonoBehaviour
{
[Header("地图参数")]
public int mapWidth = 256;
public int mapHeight = 256;
public float heightScale = 20f;
[Header("噪声参数")]
public float baseScale = 0.02f; // 基础频率
public int octaves = 6; // 叠加层数
public float persistence = 0.5f; // 振幅衰减
public float lacunarity = 2.0f; // 频率倍增
[Header("种子")]
public int seed = 42;
public float[,] GenerateHeightMap()
{
float[,] heightMap = new float[mapWidth, mapHeight];
System.Random rng = new System.Random(seed);
int[] octaveOffsets = new int[octaves];
for (int i = 0; i < octaves; i++)
octaveOffsets[i] = rng.Next(-100000, 100000);
float maxNoiseHeight = float.MinValue;
float minNoiseHeight = float.MaxValue;
for (int x = 0; x < mapWidth; x++)
{
for (int y = 0; y < mapHeight; y++)
{
float amplitude = 1f;
float frequency = 1f;
float noiseHeight = 0f;
// 多层噪声叠加(Fractal Brownian Motion)
for (int o = 0; o < octaves; o++)
{
float sampleX = x * baseScale * frequency
+ octaveOffsets[o];
float sampleY = y * baseScale * frequency
+ octaveOffsets[o];
// Unity 内置 Perlin Noise
float perlinValue = Mathf.PerlinNoise(
sampleX, sampleY);
noiseHeight += perlinValue * amplitude;
amplitude *= persistence;
frequency *= lacunarity;
}
heightMap[x, y] = noiseHeight;
if (noiseHeight > maxNoiseHeight)
maxNoiseHeight = noiseHeight;
if (noiseHeight < minNoiseHeight)
minNoiseHeight = noiseHeight;
}
}
// 归一化到 [0, 1]
for (int x = 0; x < mapWidth; x++)
for (int y = 0; y < mapHeight; y++)
heightMap[x, y] = Mathf.InverseLerp(
minNoiseHeight, maxNoiseHeight,
heightMap[x, y]);
return heightMap;
}
}
4.2 地形分层
根据高度值映射到不同地形类型:
public enum BiomeType
{
DeepWater, // 深水
ShallowWater,// 浅水
Sand, // 沙滩
Grass, // 草地
Forest, // 森林
Mountain, // 山地
Snow // 雪山
}
public BiomeType GetBiome(float height, float moisture)
{
// 基于高度和湿度的生物群落判定
if (height < 0.2f)
return BiomeType.DeepWater;
if (height < 0.3f)
return BiomeType.ShallowWater;
if (height < 0.35f)
return BiomeType.Sand;
if (height < 0.6f)
{
if (moisture > 0.6f) return BiomeType.Forest;
return BiomeType.Grass;
}
if (height < 0.8f)
return BiomeType.Mountain;
return BiomeType.Snow;
}
经典分层阈值参考:
| 高度范围 | 地形类型 | 颜色 | 游戏案例 |
|---|---|---|---|
| 0.0 - 0.2 | 深水 | 深蓝 #1a3a5c | 大多数开放世界 |
| 0.2 - 0.3 | 浅水 | 浅蓝 #3d7ab5 | - |
| 0.3 - 0.35 | 沙滩 | 黄色 #d4b96a | Minecraft 海滩 |
| 0.35 - 0.6 | 草地/森林 | 绿色 #3a8c3f | Terraria 地表 |
| 0.6 - 0.8 | 山地 | 灰色 #7a7a7a | - |
| 0.8 - 1.0 | 雪山 | 白色 #f0f0f0 | Skyrim 山顶 |
4.3 生物群落(Biome)生成
使用双重噪声系统:一个控制高度,一个控制湿度/温度。
public class BiomeGenerator
{
private float _temperatureScale = 0.01f;
private float _moistureScale = 0.015f;
public BiomeType GetBiome(float height, int x, int y)
{
float temperature = Mathf.PerlinNoise(
x * _temperatureScale + 500,
y * _temperatureScale + 500);
float moisture = Mathf.PerlinNoise(
x * _moistureScale + 1000,
y * _moistureScale + 1000);
// 温度 + 湿度组合决定生物群落
if (height < 0.3f) return BiomeType.DeepWater;
if (height < 0.35f)
{
if (temperature > 0.7f) return BiomeType.Sand; // 沙漠海滩
return BiomeType.Sand;
}
if (height < 0.6f)
{
if (temperature > 0.7f && moisture < 0.3f)
return BiomeType.Sand; // 沙漠
if (moisture > 0.6f)
return BiomeType.Forest; // 森林
return BiomeType.Grass; // 草原
}
if (height < 0.8f)
return BiomeType.Mountain;
return BiomeType.Snow;
}
}
五、程序化物品与敌人生成
5.1 物品生成系统
稀有度分层设计:
public enum Rarity { Common, Uncommon, Rare, Epic, Legendary }
[System.Serializable]
public class LootEntry
{
public string itemId;
public Rarity rarity;
public float weight; // 权重
public int minCount;
public int maxCount;
}
public class LootTable
{
public List<LootEntry> entries = new List<LootEntry>();
private System.Random _rng;
public LootTable(int seed) { _rng = new System.Random(seed); }
public LootEntry Roll()
{
float totalWeight = 0f;
foreach (var entry in entries)
totalWeight += entry.weight;
float roll = (float)(_rng.NextDouble() * totalWeight);
float cumulative = 0f;
foreach (var entry in entries)
{
cumulative += entry.weight;
if (roll <= cumulative)
return entry;
}
return entries[entries.Count - 1];
}
}
权重配置参考(典型 Roguelike):
| 稀有度 | 权重 | 出现概率 | 颜色 |
|---|---|---|---|
| Common(普通) | 60 | 60% | 白色 |
| Uncommon(优秀) | 25 | 25% | 绿色 |
| Rare(稀有) | 10 | 10% | 蓝色 |
| Epic(史诗) | 4 | 4% | 紫色 |
| Legendary(传说) | 1 | 1% | 金色 |
5.2 保底机制(Pity System)
防止玩家长时间不出稀有物品而产生挫败感:
public class PitySystem
{
private int _rollsSinceLastRare = 0;
private int _pityThreshold; // 保底阈值
private float _pityRateBonus; // 每多抽一次增加的稀有概率
private LootTable _lootTable;
public PitySystem(int threshold = 50, float rateBonus = 0.02f)
{
_pityThreshold = threshold;
_pityRateBonus = rateBonus;
}
public LootEntry RollWithPity()
{
_rollsSinceLastRare++;
// 硬保底:超过阈值必定出稀有
if (_rollsSinceLastRare >= _pityThreshold)
{
_rollsSinceLastRare = 0;
return GetGuaranteedRare();
}
// 软保底:超过阈值一半后逐步增加概率
if (_rollsSinceLastRare > _pityThreshold / 2)
{
int bonusRolls = _rollsSinceLastRare - _pityThreshold / 2;
float bonus = bonusRolls * _pityRateBonus;
// 临时提升稀有物品权重
// ...
}
var result = _lootTable.Roll();
if (result.rarity >= Rarity.Rare)
_rollsSinceLastRare = 0;
return result;
}
private LootEntry GetGuaranteedRare() { /* 返回保底稀有物品 */ return null; }
}
5.3 敌人生成系统
难度曲线控制:
public class EnemySpawner
{
[System.Serializable]
public class EnemyConfig
{
public string enemyId;
public int minFloor; // 最早出现的层数
public int maxFloor; // 最晚出现的层数
public float baseWeight; // 基础权重
public float hpScale; // 每层血量增长系数
public float dmgScale; // 每层伤害增长系数
}
public List<EnemyConfig> enemyPool;
public EnemyConfig SpawnEnemy(int currentFloor, SeededRandom rng)
{
// 筛选当前层可用的敌人
var available = enemyPool.FindAll(e =>
currentFloor >= e.minFloor && currentFloor <= e.maxFloor);
// 计算权重
float totalWeight = 0f;
foreach (var e in available) totalWeight += e.baseWeight;
// 随机选择
float roll = rng.Range(0f, totalWeight);
float cumulative = 0f;
foreach (var e in available)
{
cumulative += e.baseWeight;
if (roll <= cumulative) return e;
}
return available[available.Count - 1];
}
// 敌人属性随层数增长
public float GetScaledHP(EnemyConfig config, int floor)
{
return 100f * Mathf.Pow(1f + config.hpScale, floor);
}
}
精英怪生成规则:
public class EliteSpawner
{
public bool ShouldSpawnElite(int floor, SeededRandom rng)
{
// 精英出现概率随层数递增
float baseChance = 0.05f; // 基础 5%
float perFloorBonus = 0.02f; // 每层 +2%
float chance = baseChance + floor * perFloorBonus;
return rng.Range(0f, 1f) < Mathf.Min(chance, 0.5f); // 上限 50%
}
// 精英怪词缀系统(参考 Diablo)
public string[] RollEliteAffixes(int floor, SeededRandom rng)
{
string[] allAffixes = {
"快速", "强韧", "火焰强化", "冰冻光环",
"传送", "分裂", "吸血", "反射"
};
int affixCount = Mathf.Min(1 + floor / 5, 4); // 1-4 个词缀
string[] result = new string[affixCount];
for (int i = 0; i < affixCount; i++)
{
result[i] = allAffixes[rng.Range(0, allAffixes.Length - 1)];
}
return result;
}
}
5.4 战利品表设计
经典 Roguelike 战利品表结构:
战利品表: dungeon_chest_tier2
├── 必掉物品
│ ├── 金币: 50-150 (100%)
│ └── 药水: 1-2 (80%)
├── 主战利品 (1 件)
│ ├── 武器: 权重 30
│ ├── 护甲: 权重 25
│ ├── 饰品: 权重 15
│ └── 卷轴: 权重 30
└── 额外战利品 (0-2 件, 泊松分布 λ=0.8)
├── 宝石: 权重 20
├── 材料: 权重 50
└── 钥匙: 权重 10 (特殊用途)
六、程序化叙事与事件
6.1 事件系统设计
FTL: Faster Than Light 的事件系统是教科书级别的参考:
public class GameEvent
{
public string id;
public string title;
public string description;
public List<EventChoice> choices;
public EventCondition condition;
public float weight;
public bool isUnique; // 是否只出现一次
public string[] requiredTags; // 前置条件标签
}
public class EventChoice
{
public string text; // 选项文字
public string tooltip; // 提示信息
public EventOutcome outcome; // 结果
public bool requiresCrew; // 需要特定船员
public bool requiresItem; // 需要特定物品
}
public class EventOutcome
{
public int hullDamage; // 船体损伤
public int crewLoss; // 船员损失
public int scrapGain; // 获得废料
public int fuelChange; // 燃料变化
public string itemId; // 获得物品
public string nextEventId; // 触发后续事件
}
public class EventSystem
{
private List<GameEvent> _eventPool;
private HashSet<string> _usedEvents = new HashSet<string>();
private SeededRandom _rng;
private Dictionary<string, int> _gameState;
public GameEvent GetNextEvent(string locationTag)
{
var available = _eventPool.FindAll(e => {
if (e.isUnique && _usedEvents.Contains(e.id))
return false;
if (e.requiredTags != null &&
!e.requiredTags.Contains(locationTag))
return false;
if (e.condition != null && !CheckCondition(e.condition))
return false;
return true;
});
// 加权随机
float totalWeight = 0f;
foreach (var e in available) totalWeight += e.weight;
float roll = _rng.Range(0f, totalWeight);
float cumulative = 0f;
foreach (var e in available)
{
cumulative += e.weight;
if (roll <= cumulative)
{
if (e.isUnique) _usedEvents.Add(e.id);
return e;
}
}
return available[0];
}
private bool CheckCondition(EventCondition c) { /* 检查条件 */ return true; }
}
6.2 程序化任务生成
任务模板系统:
public class QuestTemplate
{
public string id;
public string namePattern; // "击败{enemy}首领"
public string descPattern; // "{npc}请求你前往{location}..."
public QuestType type; // Kill / Fetch / Escort / Explore
public Dictionary<string, string[]> paramOptions;
}
public class QuestGenerator
{
public Quest GenerateQuest(QuestTemplate template, SeededRandom rng)
{
var quest = new Quest();
quest.name = FillPattern(template.namePattern, template.paramOptions, rng);
quest.description = FillPattern(template.descPattern, template.paramOptions, rng);
// 生成数值参数(随玩家等级缩放)
switch (template.type)
{
case QuestType.Kill:
quest.killTarget = template.paramOptions["enemy"][
rng.Range(0, template.paramOptions["enemy"].Length - 1)];
quest.killCount = rng.Range(3, 10);
break;
case QuestType.Fetch:
quest.fetchItem = template.paramOptions["item"][
rng.Range(0, template.paramOptions["item"].Length - 1)];
quest.fetchCount = rng.Range(1, 5);
break;
}
// 奖励计算
quest.goldReward = quest.killCount * 20 + rng.Range(0, 50);
quest.xpReward = quest.killCount * 15;
return quest;
}
private string FillPattern(string pattern,
Dictionary<string, string[]> options, SeededRandom rng)
{
string result = pattern;
foreach (var kvp in options)
{
string value = kvp.Value[rng.Range(0, kvp.Value.Length - 1)];
result = result.Replace($"{{{kvp.Key}}}", value);
}
return result;
}
}
6.3 FTL 的事件系统案例分析
FTL 的事件系统有以下特点:
| 特点 | 实现方式 | 设计目的 |
|---|---|---|
| 上下文相关 | 事件根据当前星区类型筛选 | 增加代入感 |
| 选项有代价 | 每个选项都有明确的收益/损失 | 有意义的决策 |
| 隐藏信息 | 部分选项结果未知 | 增加紧张感 |
| 连锁事件 | 某些事件触发后续事件链 | 叙事深度 |
| 船员相关 | 特定种族船员解锁特殊选项 | 奖励多样化队伍 |
FTL 事件模板参考:
事件: abandoned_ship
位置: 任意星区
触发概率: 15%
描述: "你发现了一艘漂浮的废弃飞船。传感器显示内部
有微弱的生命信号,但也可能是陷阱。"
选项 A: "派人登船调查"
→ 60% 发现物资 (+30 废料, +1 武器)
→ 25% 遭遇海盗伏击 (战斗)
→ 15% 发现幸存者 (获得船员)
选项 B: "远距离扫描"
→ 100% 少量收获 (+15 废料)
选项 C: [需要人类船员] "用船员经验判断风险"
→ 80% 安全获取全部物资 (+50 废料, +1 武器)
→ 20% 发现陷阱并避开 (无损失)
选项 D: "直接飞走"
→ 无结果
七、平衡程序化与手工设计
7.1 混合方法
最成功的 Roguelike 游戏都采用了 「手工设计的组件 + 程序化的组合」 策略。
方法一:手工房间模板 + 程序化拼接
public class RoomBasedGenerator
{
[System.Serializable]
public class RoomTemplate
{
public string id;
public int width, height;
public int[,] layout; // 房间布局数据
public DoorPosition[] doors; // 可用的门位置
public RoomType type; // 普通/宝箱/Boss/商店
public int minFloor, maxFloor; // 适用层数
public float weight;
}
public int[,] GenerateDungeon(int seed, int floor,
List<RoomTemplate> templates)
{
var rng = new SeededRandom(seed);
// 1. 筛选当前层可用的房间模板
var available = templates.FindAll(t =>
floor >= t.minFloor && floor <= t.maxFloor);
// 2. 随机选择并放置房间
int roomCount = rng.Range(5, 8);
List<PlacedRoom> placedRooms = new List<PlacedRoom>();
// 第一个房间(起始房间,固定)
var startRoom = available.Find(t => t.type == RoomType.Start);
placedRooms.Add(PlaceRoom(startRoom, 0, 0));
// 3. 逐个放置后续房间
for (int i = 1; i < roomCount; i++)
{
var template = WeightedRandom(available, rng);
var lastRoom = placedRooms[placedRooms.Count - 1];
// 连接到上一个房间的某个门
var connection = TryConnect(lastRoom, template, rng);
if (connection != null)
placedRooms.Add(connection);
}
// 4. 最后一个房间放置 Boss/楼梯
var bossRoom = available.Find(t => t.type == RoomType.Boss);
// ...
return RenderToMap(placedRooms);
}
private RoomTemplate WeightedRandom(
List<RoomTemplate> list, SeededRandom rng) { return null; }
private PlacedRoom PlaceRoom(RoomTemplate t, int x, int y) { return null; }
private PlacedRoom TryConnect(PlacedRoom from, RoomTemplate to,
SeededRandom rng) { return null; }
private int[,] RenderToMap(List<PlacedRoom> rooms) { return null; }
}
7.2 Dead Cells 的混合关卡设计
Dead Cells 的关卡设计是业界标杆,核心策略:
| 策略 | 说明 |
|---|---|
| 手工房间模块 | 每个区域有 20-40 个手工设计的房间模块 |
| 程序化拼接 | 算法从模块池中随机选择并连接 |
| 关键路径保证 | 确保从入口到出口总是可达的 |
| 秘密房间 | 随机放置隐藏房间入口 |
| 主题一致性 | 每个生物群系有独立的房间模块池 |
质量控制系统:
public class DungeonValidator
{
public ValidationResult Validate(int[,] map)
{
var result = new ValidationResult();
// 1. 连通性检查:BFS 确保所有可达区域连通
result.isFullyConnected = CheckConnectivity(map);
// 2. 可达性:确保从起点到终点存在路径
result.isReachable = CheckPathExists(
map, FindStart(map), FindExit(map));
// 3. 最小面积:确保地图不会太小
result.hasEnoughSpace = CountFloorTiles(map) > 200;
// 4. 死胡同检查:确保没有过长的死胡同
result.noExcessiveDeadEnds = CheckDeadEnds(map) < 3;
// 5. 敌人放置点:确保有足够的空间放敌人
result.hasEnemySpawnPoints = CountEnemySpawns(map) > 5;
return result;
}
}
7.3 重新生成机制
当地图不满足质量要求时的处理策略:
public class SafeGenerator
{
private const int MAX_ATTEMPTS = 10;
public int[,] GenerateSafe(int width, int height, int seed)
{
var validator = new DungeonValidator();
for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++)
{
// 每次尝试用不同的种子偏移
var map = GenerateDungeon(seed + attempt, width, height);
var result = validator.Validate(map);
if (result.IsValid)
{
Debug.Log($"地图在第 {attempt + 1} 次尝试后通过验证");
return map;
}
}
// 多次失败后使用后备方案(预设计的安全地图)
Debug.LogWarning("多次生成失败,使用后备地图");
return LoadFallbackMap();
}
}
八、性能优化
8.1 生成时机选择
| 时机 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 预生成(Loading 时) | 不卡顿、可验证 | 加载时间长、内存占用大 | 地图较小的游戏 |
| 实时生成(Chunk 加载) | 无限地图、内存友好 | 可能卡顿 | Minecraft 类沙盒 |
| 异步生成(后台线程) | 不阻塞主线程 | 需要线程安全 | 大型地图 |
| 混合方案 | 兼顾速度和流畅度 | 实现复杂 | 大型 Roguelike |
8.2 异步生成示例
public class AsyncMapGenerator : MonoBehaviour
{
public async Task<int[,]> GenerateMapAsync(
int width, int height, int seed)
{
// 在后台线程生成地图
return await Task.Run(() =>
{
var generator = new BSPDungeonGenerator();
return generator.Generate(width, height, seed);
});
}
// 分块生成(Chunk-based)
public class ChunkGenerator
{
private const int CHUNK_SIZE = 32;
public Dictionary<Vector2Int, ChunkData> loadedChunks
= new Dictionary<Vector2Int, ChunkData>();
public void UpdateChunks(Vector2Int playerChunkPos,
int renderDistance)
{
// 加载周围的 Chunk
for (int dx = -renderDistance; dx <= renderDistance; dx++)
{
for (int dy = -renderDistance; dy <= renderDistance; dy++)
{
var chunkPos = new Vector2Int(
playerChunkPos.x + dx,
playerChunkPos.y + dy);
if (!loadedChunks.ContainsKey(chunkPos))
{
// 用确定性种子生成 Chunk
int chunkSeed = HashChunkPosition(chunkPos);
loadedChunks[chunkPos] =
GenerateChunk(chunkPos, chunkSeed);
}
}
}
// 卸载远处的 Chunk
var toRemove = new List<Vector2Int>();
foreach (var kvp in loadedChunks)
{
if (Vector2Int.Distance(kvp.Key, playerChunkPos)
> renderDistance + 2)
{
toRemove.Add(kvp.Key);
}
}
foreach (var pos in toRemove)
loadedChunks.Remove(pos);
}
private int HashChunkPosition(Vector2Int pos)
{
unchecked
{
int hash = 17;
hash = hash * 31 + pos.x;
hash = hash * 31 + pos.y;
return hash;
}
}
private ChunkData GenerateChunk(Vector2Int pos, int seed)
{
return null; // 实际生成逻辑
}
}
}
8.3 内存管理
// 对象池:复用生成对象,减少 GC
public class MapTilePool
{
private Stack<MapTile> _pool = new Stack<MapTile>();
public MapTile Get()
{
if (_pool.Count > 0)
return _pool.Pop();
return new MapTile();
}
public void Return(MapTile tile)
{
tile.Reset();
_pool.Push(tile);
}
public void WarmUp(int count)
{
for (int i = 0; i < count; i++)
_pool.Push(new MapTile());
}
}
九、测试与调试
9.1 种子测试
public class GenerationTester : MonoBehaviour
{
// 用固定种子复现问题
public void TestSpecificSeed(int seed)
{
var generator = new BSPDungeonGenerator();
var map = generator.Generate(100, 100, seed);
var validator = new DungeonValidator();
var result = validator.Validate(map);
if (!result.IsValid)
{
Debug.LogError($"种子 {seed} 生成失败: {result.ErrorMessage}");
RenderDebugMap(map); // 可视化问题地图
}
}
// 批量测试
public void StressTest(int seedCount = 1000)
{
int passCount = 0;
int failCount = 0;
var failures = new List<int>();
for (int i = 0; i < seedCount; i++)
{
var generator = new BSPDungeonGenerator();
var map = generator.Generate(100, 100, i);
var result = validator.Validate(map);
if (result.IsValid)
passCount++;
else
{
failCount++;
failures.Add(i);
}
}
Debug.Log($"压力测试完成: {passCount}/{seedCount} 通过 " +
$"({(float)passCount / seedCount * 100:F1}%)");
if (failures.Count > 0)
Debug.Log($"失败种子: {string.Join(", ", failures)}");
}
}
9.2 可视化调试工具
public class MapDebugVisualizer : MonoBehaviour
{
public Texture2D RenderDebugMap(int[,] map, bool showPath = false)
{
int w = map.GetLength(0);
int h = map.GetLength(1);
var tex = new Texture2D(w, h);
for (int x = 0; x < w; x++)
{
for (int y = 0; y < h; y++)
{
tex.SetPixel(x, y, map[x, y] == 1
? Color.white // 地板
: Color.black); // 墙壁
}
}
// 标记起点(绿色)和终点(红色)
var start = FindStart(map);
var end = FindEnd(map);
tex.SetPixel(start.x, start.y, Color.green);
tex.SetPixel(end.x, end.y, Color.red);
// 显示最短路径(黄色)
if (showPath)
{
var path = FindShortestPath(map, start, end);
foreach (var p in path)
tex.SetPixel(p.x, p.y, Color.yellow);
}
tex.filterMode = FilterMode.Point;
tex.Apply();
return tex;
}
private Vector2Int FindStart(int[,] map) { return Vector2Int.zero; }
private Vector2Int FindEnd(int[,] map) { return Vector2Int.zero; }
private List<Vector2Int> FindShortestPath(int[,] map,
Vector2Int start, Vector2Int end) { return null; }
private DungeonValidator validator = new DungeonValidator();
}
9.3 自动化测试 Checklist
程序化生成自动化测试清单:
基础验证(每次生成必须通过):
□ 地图尺寸正确
□ 边界全是墙壁
□ 所有地板格子连通(BFS 验证)
□ 起点和终点之间存在路径
□ 地板数量在合理范围内(min: 200, max: 1000)
质量验证(95% 的地图应通过):
□ 房间数量在合理范围内(5-15)
□ 平均房间大小在合理范围内(4x4 - 10x10)
□ 走廊宽度 ≥ 2(防止卡住)
□ 没有过长的死胡同(> 10 格)
□ 敌人放置点数量 ≥ 5
□ 宝箱/物品放置点数量 ≥ 2
性能验证:
□ 生成时间 < 50ms(60fps 下 3 帧以内)
□ 内存分配 < 10MB(单次生成)
□ 不产生 GC 尖峰
压力测试(1000 个种子):
□ 通过率 ≥ 99%
□ 平均生成时间 < 30ms
□ 没有崩溃或无限循环
□ 失败种子自动重试后通过
十、附录
附录 A:地牢生成算法代码库
| 项目 | 语言 | 算法 | 地址 |
|---|---|---|---|
| DungeonGenerator | C# | BSP + 房间 | github.com/rabbitfromhat/dungeon |
| ProceduralGeneration | C# | 多种算法 | github.com/AquariusPower/ProceduralGen |
| PCG | Python | 教学示例 | github.com/adrianstutz/pcg |
| dungeon-generation | Python | 多种 | github.com/paulstraw/dungeon-generation |
| WaveFunctionCollapse | C# | WFC | github.com/mxgmn/WaveFunctionCollapse |
| GodotDungeonGenerator | GDScript | BSP | github.com/uheartbeast/GodotDungeonGenerator |
附录 B:噪声函数库推荐
| 库 | 语言 | 特点 |
|---|---|---|
| Unity Mathf.PerlinNoise | C# | Unity 内置,够用 |
| libnoise | C++ | 最全面的噪声库 |
| FastNoiseLite | 多语言 | 高性能、跨平台、免费 |
| Noise.NET | C# | 纯 C# 实现 |
| Godot NoiseTexture | GDScript | Godot 内置 |
| OpenSimplex2 | 多语言 | Simplex Noise 改进版 |
附录 C:程序化生成游戏案例列表(20+ 款)
| 游戏 | 类型 | 生成内容 | 核心技术 |
|---|---|---|---|
| The Binding of Isaac | Roguelike | 房间组合 | 手工房间 + 随机拼接 |
| Hades | Roguelite | 房间组合 | 手工房间 + 叙事事件 |
| Dead Cells | Roguelite | 关卡地图 | 手工模块 + 程序化拼接 |
| Slay the Spire | Roguelite | 地图路径 | 分层随机路径图 |
| FTL | Roguelike | 事件序列 | 加权随机事件池 |
| Enter the Gungeon | Roguelike | 地牢房间 | 手工房间 + 程序化组合 |
| Risk of Rain 2 | Roguelite | 关卡地形 | Perlin Noise + 手工地标 |
| Noita | Roguelite | 像素地形 | 细胞自动机 + 物理模拟 |
| Spelunky 2 | Roguelike | 关卡布局 | 4x4 房间模板系统 |
| Minecraft | 沙盒 | 整个世界 | 多层 Perlin Noise |
| Terraria | 沙盒 | 世界地形 | Perlin + 洞穴生成 |
| Dwarf Fortress | 模拟 | 整个世界 | 多层噪声 + 地质模拟 |
| Rimworld | 模拟 | 地图 + 事件 | 噪声地形 + AI 故事系统 |
| Caves of Qud | Roguelike | 世界 + 历史 | 多层生成 + 文明模拟 |
| Darkest Dungeon | RPG | 地牢 | 房间 + 走廊随机化 |
| Diablo 系列 | ARPG | 地图 + 战利品 | BSP + 战利品表 |
| Path of Exile | ARPG | 地图 + 物品 | 区域模板 + 词缀系统 |
| No Man’s Sky | 探索 | 星球 + 生物 | 噪声 + L-System |
| Catan Universe | 策略 | 棋盘 | 六边形随机放置 |
| Civilization VI | 策略 | 世界地图 | 板块构造模拟 |
| Rogue Legacy 2 | Roguelite | 生物群系 | 手工房间 + 程序化 |
| Inscryption | Roguelike | 卡牌 + 地图 | 路径生成 + 事件系统 |
| Loop Hero | Roguelike | 路径 + 地块 | 环形路径 + 卡牌放置 |
| Vampire Survivors | Roguelite | 敌人波次 | 时间曲线 + 加权随机 |
总结: 程序化生成不是「让电脑随便做」,而是「用算法实现精心设计的规则」。最好的程序化生成游戏都是先用手工设计验证核心体验有趣,再用算法把这种体验放大和多样化。记住:种子系统是你的调试利器,质量验证是你的安全网,手工设计的组件是你的品质保证。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。