随机地图生成算法

背景

最近基于单形噪声和柏林噪声研究设计了一个梯度循环噪声算法,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​这个算法是为了独立游戏的开发服务的。​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​噪声算法目前已经实现,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​接下来需要基于它设计实现地图生成算法,以此生成独立游戏所需的地图、植被等数据。

设计目标

地图生成算法要求生成多种不同的地形地貌、植被类型,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​并且可生成江河湖海。在实现以上目标的同时,还需要保证地图任意方向上都具备连续性。​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​也就是说,当玩家向一个方向不断前进时,地图边界与另一侧边界保持连续状态。此外,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​算法要求任意体素位置上的数据不依赖其余体素的数据,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​而且时间复杂度要尽可能得低。​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​

设计实现与Python代码

注’ 以下为设计与实现部分,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​主要阐述设计思想。

噪声算法

在进行地图生成之前,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​需要先对噪声算法进行改造。由于柏林噪声算法的时间复杂度较高,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​因此此处选择以单形噪声作为基底。通过对算法进行适当的改造,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​实现生成梯度循环连续的噪声图。​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​

配图

此图与常规噪声算法图的差异就在于其梯度循环连续,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​重复其自身置于其周围,图像依旧连续。效果如下图所示:

配图

关于梯度循环噪声算法读者可自行实现,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​或参考作者的其它文章,此处不做过多赘述。​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​

伪随机数算法

为了确保地图生成算法在任意系统平台上都能输出一致的结果,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​读者需要确保使用的随机数算法是具备跨平台确定性的。​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​也就是说,当种子和迭代步数相同的情况下,输出的随机值也是相同的。

注’ 若读者并不需要考虑跨平台的确定性,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​那随便用一个随机数生成算法便可

噪声生成

在实现地图生成之前,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​需要先获取多个噪声生成实例,后面将会基于这些实例生成的噪声数据去构成世界生成的基本信息。​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​

import math
import numpy as np
import matplotlib.pyplot as plt
# 循环流式单形噪声生成算法
import RingFlowSimplexNoise as rfsn 
# 跨平台确定性伪随机数生成算法
import RcgRandom as RCG

rdm1 = RCG(seedValue)
randomFun1 = lambda : rdm1.random()
rdm2 = RCG(seedValue + 1)
randomFun2 = lambda : rdm2.random()
rdm3 = RCG(seedValue + 2)
randomFun3 = lambda : rdm3.random()
rdm4 = RCG(seedValue + 3)
randomFun4 = lambda : rdm4.random()

# 基础噪声(海拔)
basicN = rfsn.createNoise2D(randomFun1)
# 流体噪声(流体&气温)
flowN = rfsn.createNoise2D(randomFun2)
# 优化噪声
optimizeN = rfsn.createNoise2D(randomFun3)
# 随机值random
randN = randomFun4

# 以上噪声函数和随机函数都仅生成 [0,1] 区间内的值

在上述代码中,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​生成了基本的海拔信息、流体信息、优化噪声,并且获取了一个确定性随机函数作为地图内部分数据的生成依据。​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​

构建世界生成入口函数

拥有噪声函数和随机函数后,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​需要构造一个世界生成入口函数,这是生成一整个世界的基本入口。

def WorldGeneration(
    # 生成器数组
    arrGeneration,
    # 地图尺寸
    wh = 200,
    # 地图重复次数控制
    diameter = 0.25,
    optDiameter = 0.8,
    flowDiameter = 0.5,
    # 地图细粒度控制
    # 河流宽度
    streamWidth = 0.02,
    # 海平面高度
    seaLevel = 0.1,
    # 沙滩宽度
    sandWidth = 0.05,
    # 雪域海拔
    snowLevel = 0.95,
    # 岩层宽度
    rockyAreaWidth = 0.05,
    # 植被密度
    vegetationDensity = 0.1
    ):
    #
    #  <--- 这里写具体逻辑
    #

上述代码中的生成器数组是地图元素的生成类实例数组,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​后续会将生成器实例传递进来,以此生成相关地图数据。​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​例如,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​当传入岩层生成器时,地图上就会生成岩层数据。​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​

初始化与入参规范化

WorldGeneration函数内部,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​需要对传入的参数进行规范化,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​使其贴合实际运算范围。并且在进行实际

def WorldGeneration(...):
    ### 作者这里使用图片作为数据存储和展示的示例,因此初始化图片数据
    channels = 3 # BGR
    # 创建一张空白的RGB图像(地形)
    srcImage = np.zeros((wh, wh, channels), dtype=np.uint8)
    # 创建一张空白的RGB图像(置物)
    objectImage = np.zeros((wh, wh, channels), dtype=np.uint8)
    
    ### 下面是计算数据的标准化,开发者可根据自己的需要进行定制
    whHalf = wh / 2.0
    # 基础高度图
    RECTrange = wh * diameter
    RErange = wh / RECTrange
    # 优化高度图
    optRECTrange = wh * diameter * optDiameter
    optRErange = wh / optRECTrange
    # 流体高度图
    flowRECTrange = wh * flowDiameter
    flowRErange = wh / flowRECTrange
    # 地图细粒度控制
    streamWidth = 1.0 - streamWidth
    sandWidth = (1.0 - seaLevel) * sandWidth
    # 植被密度
    vegetationDensity = 1.0 - vegetationDensity
    # 岩石层海拔
    rockyAreaLevel = snowLevel - rockyAreaWidth
    if rockyAreaLevel < 0:rockyAreaLevel = 0
    if rockyAreaLevel > 1:rockyAreaLevel = 1

    ### 下面是为了程序健壮性(鲁棒性)考虑,确保数据符合范围
    if(
        whHalf.is_integer() and
        RECTrange.is_integer() and RErange.is_integer() and 
        optRECTrange.is_integer() and optRErange.is_integer() and
        flowRECTrange.is_integer() and flowRErange.is_integer() and
        streamWidth >= 0 and streamWidth <= 1.0 and
        seaLevel >= 0 and seaLevel <= 1.0 and
        sandWidth >= 0 and sandWidth <= 1.0 and
        snowLevel >= 0 and snowLevel <= 1.0 and
        vegetationDensity >= 0 and vegetationDensity <= 1.0
        ):
        #
        #  <--- 这里是噪声数据的核心生成位置
        #

开始生成世界噪声数据

根据地图生成的宽高调用噪声数据生成函数生成相关数据,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​并根据实际需要对数据进行处理。例如:作者定义经纬度的纬度中,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​高于世界维度80%或低于20%为北极或南极,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​此时气候区温度骤降,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​进而增加生成雪地的几率。​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​

作为示例,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​作者还使用了缓动函数作为数据映射的手段,读者可根据实际情况设计更符合地形、气温等数据变化规律的映射函数。​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​

for i in range(wh):
      for j in range(wh):
        # ———— 基本噪声数据
        #  计算基础数据
        altitude = (basicN.calc(i / RECTrange,j / RECTrange,RErange) + 1) / 2
        #  计算优化数据
        opt = (optimizeN.calc(flipHorizontal(i,wh) / optRECTrange,j / optRECTrange,optRErange) + 1) / 2
        #  计算流数据
        flow = (flowN.calc(i / flowRECTrange,j / flowRECTrange,flowRErange) + 1) / 2

        #  输出混合海拔数据
        mixAltitude = altitude * 0.8 + opt * 0.2# + flow * 0.08
        mixAltitude = InOutQuad(mixAltitude,0,1,1)

        #  计算气候区气温
        climateT = TemperatureConv(i,whHalf)
        #  气温分区: 寒带 温带 热带

        # 保留原始温度数据 (复杂化波动)
        temperature = easeInBounce(TemperatureConv(flow * 0.5 + opt * 0.5,1.0),0,1,1)

        #  取高度图中心
        flow = easeMiddle(flow,0,1,1)

        # 湿度
        humidity = easeInBounce(opt * 0.8 + altitude * 0.2,0,1,1)

        #
        #  <--- 这里是场景数据生成部分
        #

将噪声数据传入生成器生成场景数据

将噪声规范化后的数据传入生成器,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​通过每个生成器内部的设计实现具体的内容生成。​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​

    # 海拔温度(同时值越接近1代表距离海、湖越近)
    if mixAltitude >= seaLevel:
        AltitudeT = 1 - (mixAltitude - seaLevel) / (1 - seaLevel)
    else:
        AltitudeT = 1
  
    # 计算与水源的距离比例,越靠近海和河流则值越接近0
    waterDist = (1 - AltitudeT) * (1 - flow)
    # 水源湿度百分比
    waterHumidity = 1 - waterDist

    # 开始遍历每个生成器
    #    这样的设计当符合一个生成器的情况后,就会退出(适合生成二维图像数据)
    #    若需要确保所有生成器都生成数据,则不能直接返回。
    for i in range(len(arrGeneration)):
        isG,map,obj = arrGeneration[i].Generation(
            x,y,
            mixAltitude,
            altitude,
            optimize,
            flow,
            streamWidth,
            seaLevel,
            sandWidth,
            snowLevel,
            climateT,
            temperature,
            humidity,
            random,
            vegetationDensity,
            AltitudeT,
            waterDist,
            waterHumidity,
            rockyAreaLevel
        )
        if isG :
            return map,obj
  return (0,0,0),(0,0,0)

生成器

所有的生成器都继承于一个基类,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​这个基类要求传入生成参数,并且输出是否符合本生成器生成规则,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​以及相关的生成数据。

# 生成器基类
class baseGeneration:
  def __init__(self,weight):
    self.weight = weight
  def getWeight(self):
    return self.weight
  def Generation(self,
    # 坐标
    x,y,
    # 混合海拔值
    mixAltitude,
    # 基础海拔值
    altitude,
    # 优化值
    optimize,
    # 流体值
    flow,
    # 河流宽度
    streamWidth,
    # 海平面高度
    seaLevel,
    # 沙滩宽度
    sandWidth,
    # 雪域海拔
    snowLevel,
    # 气候温度/湿度(寒带 < 0;热带 > 1;温带0~1)
    climateT,
    # 温度图 0~1
    temperature,
    # 湿度图 0~1
    humidity,
    # 当前定位点随机值
    random,
    # 植被密度
    vegetationDensity,
    # 海拔温度
    AltitudeT,
    # 水源距离
    waterDist,
    # 水源湿度
    waterHumidity,
    # 岩石区海拔
    rockyAreaLevel
    ):
    #
    #  <--- 这里写生成器逻辑
    #
    return False,(0,0,0),(0,0,0)

下面是一个河流生成器示例,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​代码如下:​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​

# 是否达到生成植被的条件
gObject = random > vegetationDensity and (x + y) % 2 == 1 
# 超高温
tempH = climateT * 0.4 + temperature * 0.5 + (1 - AltitudeT) * 0.4 
# 超低温
tempL = climateT * 5 + temperature * 5 - AltitudeT * 0.2 
# 潮湿
humH = climateT * 0.4 + humidity * 0.8 + waterHumidity * 0.4 > 1.0 
# 干燥
humL = climateT * 0.5 - (1 - humidity) * 0.4 + waterHumidity * 0.4 < 0.0 
# 若河流值大于阈值,则生成河流分支
if flow > streamWidth:
    if tempH > 1.0:
        # 若处于超高温领域,则沙化河流
        return True,(255,255,0),(0,0,0) # 沙化河床
    if tempL < 0.0:
        # 若处于超低温领域,则冰化河流
        return True,(225,225,225),(0,0,0) # 冰冻河水
    # 常规河流
    return True,(0,255,255),(0,0,0)
# 不生成河流
return False,(0,0,0),(0,0,0)

生成场景数据

最后,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​将定制好的场景生成器按设计顺序构成数组,并传递给WorldGeneration函数,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​就可以得到最终的生成数据。

# 生成宽高为 200px 的2D地图数据

Image,Image2 = WorldGeneration([
    # 海湖生成器
    seaGeneration(0),
    # 雪地岩层生成器
    snowGeneration(1),
    # 河流生成器
    streamGeneration(2),
    # 沙滩生成器
    sandGeneration(3),
    # 陆地生成器
    landGeneration(4)
],200)

算法效果

执行上述的代码后,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​成功生成地图如下:

地图图片

结语

完成Python的快速逻辑验证后,​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​就可以将其转换为游戏引擎的代码进行实战开发啦!​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​ 
作者这里使用的是Cocos Creator游戏引擎,所以转换为 TypeScript 语言代码。​‌‎​​‌‎​‌​‎‌‌​​‎​‌‌‌‎‌‌‌‌‌‎‌​‌‌‎‌​​‌‎​ 上述代码在游戏引擎中还需要继续细分,分帧执行可以避免导致游戏画面卡顿。