独立游戏程序化生成实战:Roguelike 关卡生成、算法与内容复用技巧

从随机数种子到 BSP 地牢生成,从 Perlin Noise 地形到战利品表设计,涵盖 Roguelike/Roguelite 程序化生成的核心算法、C#/GDScript 代码实现、性能优化与质量控制系统,附 Dead Cells/FTL/Slay the Spire 案例拆解与 20+ 游戏参考清单。

独立游戏程序化生成实战: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)系统

为什么需要种子?

  1. 可调试:用固定种子复现 Bug
  2. 可分享:玩家分享有趣的地图种子
  3. 可测试:自动化测试用固定种子保证结果一致
  4. 每日挑战:每天一个固定种子,所有玩家面对相同地图

种子系统设计模板:

// 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沙滩黄色 #d4b96aMinecraft 海滩
0.35 - 0.6草地/森林绿色 #3a8c3fTerraria 地表
0.6 - 0.8山地灰色 #7a7a7a-
0.8 - 1.0雪山白色 #f0f0f0Skyrim 山顶

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(普通)6060%白色
Uncommon(优秀)2525%绿色
Rare(稀有)1010%蓝色
Epic(史诗)44%紫色
Legendary(传说)11%金色

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:地牢生成算法代码库

项目语言算法地址
DungeonGeneratorC#BSP + 房间github.com/rabbitfromhat/dungeon
ProceduralGenerationC#多种算法github.com/AquariusPower/ProceduralGen
PCGPython教学示例github.com/adrianstutz/pcg
dungeon-generationPython多种github.com/paulstraw/dungeon-generation
WaveFunctionCollapseC#WFCgithub.com/mxgmn/WaveFunctionCollapse
GodotDungeonGeneratorGDScriptBSPgithub.com/uheartbeast/GodotDungeonGenerator

附录 B:噪声函数库推荐

语言特点
Unity Mathf.PerlinNoiseC#Unity 内置,够用
libnoiseC++最全面的噪声库
FastNoiseLite多语言高性能、跨平台、免费
Noise.NETC#纯 C# 实现
Godot NoiseTextureGDScriptGodot 内置
OpenSimplex2多语言Simplex Noise 改进版

附录 C:程序化生成游戏案例列表(20+ 款)

游戏类型生成内容核心技术
The Binding of IsaacRoguelike房间组合手工房间 + 随机拼接
HadesRoguelite房间组合手工房间 + 叙事事件
Dead CellsRoguelite关卡地图手工模块 + 程序化拼接
Slay the SpireRoguelite地图路径分层随机路径图
FTLRoguelike事件序列加权随机事件池
Enter the GungeonRoguelike地牢房间手工房间 + 程序化组合
Risk of Rain 2Roguelite关卡地形Perlin Noise + 手工地标
NoitaRoguelite像素地形细胞自动机 + 物理模拟
Spelunky 2Roguelike关卡布局4x4 房间模板系统
Minecraft沙盒整个世界多层 Perlin Noise
Terraria沙盒世界地形Perlin + 洞穴生成
Dwarf Fortress模拟整个世界多层噪声 + 地质模拟
Rimworld模拟地图 + 事件噪声地形 + AI 故事系统
Caves of QudRoguelike世界 + 历史多层生成 + 文明模拟
Darkest DungeonRPG地牢房间 + 走廊随机化
Diablo 系列ARPG地图 + 战利品BSP + 战利品表
Path of ExileARPG地图 + 物品区域模板 + 词缀系统
No Man’s Sky探索星球 + 生物噪声 + L-System
Catan Universe策略棋盘六边形随机放置
Civilization VI策略世界地图板块构造模拟
Rogue Legacy 2Roguelite生物群系手工房间 + 程序化
InscryptionRoguelike卡牌 + 地图路径生成 + 事件系统
Loop HeroRoguelike路径 + 地块环形路径 + 卡牌放置
Vampire SurvivorsRoguelite敌人波次时间曲线 + 加权随机

总结: 程序化生成不是「让电脑随便做」,而是「用算法实现精心设计的规则」。最好的程序化生成游戏都是先用手工设计验证核心体验有趣,再用算法把这种体验放大和多样化。记住:种子系统是你的调试利器,质量验证是你的安全网,手工设计的组件是你的品质保证。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页