65.9K
CodeProject 正在变化。 阅读更多。
Home

程序化内容生成第一部分:通用资产工厂

starIconstarIconstarIconstarIconstarIcon

5.00/5 (7投票s)

2017年9月8日

MIT

10分钟阅读

viewsIcon

10419

一个可扩展的程序化生成框架

引言

程序化生成是游戏中的一种技术,通过算法和数学创建动态内容。程序化生成有许多方法,我将通过本系列文章中开发的一些技术来引导您。在第一章中,我们将创建一个通用的工厂模式,用于加载和管理游戏资产。我们将把这些资产组合成场景,然后在后续章节中研究在运行时程序化创建它们的各种方法。

背景

多年来,我一直对程序化内容生成很感兴趣。我尝试过几种不同的概念和方法,取得了不同程度的成功。我把这当作一个爱好,我想与社区分享我的一些经验。我的许多工作都基于Ken Perlin的技术。Ken Perlin在1982年为电影Tron开发了Perlin噪声算法。他后来因其工作获得了学院奖。我也喜欢Minecraft这样的游戏,并享受程序化生成的体素环境。我的目标是将不同的方法结合起来,为C#中的程序化生成创建一个灵活的工具箱。

概述

我们将从创建一个可以用于创建和存储2D资产的通用资产管理系统开始。第一步是创建一组接口核心,这将是我们通用类型系统的基础。一旦我们设置好接口,我们就可以创建一个特定的实现来生成我们选择的内容。

我将从创建一个可以动态创建地形的2D平铺系统开始。为了渲染一个像样的地图,我们可能需要加载和跟踪许多资产。这个系统将允许我们管理程序化生成资产的生命周期。我们将在后面的章节中介绍代理和IAgent接口。为了简单起见,我们暂时只将此工作渲染到控制台。将此设计扩展到GDI+、XNA、MonoGame或其他游戏平台将很容易!

场景

第一步是创建一个场景接口。场景是按2D或3D空间空间排列的游戏资产的集合。我们将从2D开始,然后探索如何在后续章节中将其扩展到3D。IScene接口将负责存储和管理游戏资产的生命周期。

// The Scene interface.
public interface IScene
{
    // Registers an Asset of type T with the Abstract Factory.
    IScene RegisterAsset<T>()
        where T : class, IAsset;
 
    // Registers Loaders of type T2 for type T1. Implements the Abstract Factory for type T1.
    IScene RegisterLoader<T1, T2>()
        where T1 : class, IAsset
        where T2 : class, ILoader;

     // Gets a list of IAssets of type T.
     List<IAsset> GetAssets<T>() where T : class, IAsset;
 
     // Queries a list of IAssets of type T within a boundary.
     List<IAsset> QueryAssets<T>(RectangleF bounds) where T : class, IAsset;

     // Loads an Asset of type T at location (X, Y)
     T LoadAsset<T>(IAsset parent, int x, int y) where T : class, IAsset;

     // Unloads an Asset and calls Dispose.
     void UnloadAsset(IAsset asset);
}

此接口创建了一个简单的通用工厂模式,用于管理基于IAsset的多种类型。它还允许您查询场景中存储的资产,并根据需要加载和卸载资产。我们将在另一节中介绍具体的实现。

资产

资产是我们游戏环境的构建块。资产基本上是用于存储数据以表示我们游戏世界不同方面的容器。这些可以是地图的图块、物品、NPC,甚至是更高级的概念,如音频区域和加载区域。我不会在本系列中涵盖所有这些想法。随着我们的进展,您将有望认识到该系统在管理多种不同类型内容方面的灵活性。我们将按如下方式实现IAsset接口

// The IAsset interface, requires implementation of IDisposable.
public interface IAsset : IDisposable
{
    // Referene to the containing Scene object.
    IScene Scene { get; }
  
    // A Referene a Parent Asset if one exists.
    IAsset Parent { get; }

    // A boolean indicator for disposed status.
    bool Disposed { get; }

    // A rectangle that represents the Assets location in the Scene.
    RectangleF Bounds { get; }

    // An Event for the QuadTree Implementation used in this example.
    event EventHandler BoundsChanged;

    // An Update function updating assets in a game loop.
    void Update(TimeSpan elapsed);
}

如您所见,这是一个相当简单的实现,它建立了资产和场景之间的关系。您还会注意到我们有一个对父对象的引用。这允许将资产更高级地组合成简单的结构。资产实现IDisposable并具有已处置状态的指示器 - 这与IDisposable接口配合使用。资产的位置通过Bounds属性检索,此QuadTree的实现使用float,但我们稍后会将其转换为整数以用于我们的平铺系统。还提供了一个更新函数,用于游戏循环中的更新。

加载器

加载器表示场景中注册的每种IAsset类型的工厂模式的实现。我们将创建一个接口,以便我们可以为每种注册类型提供自定义实现。这为程序化生成技术提供了很大的灵活性,您稍后会看到。我们将为我们的示例实现一个程序化加载器,但加载器可以很容易地实现以加载其他媒体,如文件系统或数据库。

public interface ILoader
{
    IAsset LoadAsset(IScene scene, IAsset parent, int x, int y);
}

在这种情况下,ILoader接口只有一个方法,LoadAssetLoadAsset函数接受包含场景、父资产(如果有)、2D坐标系中的X和Y位置以及一个可选参数。我们可以实现LoadAsset重载,它将接受文件名或数据库ID。

代理

代理将在本系列的后面部分讨论。我在这里简要提及它们,以便您对总体设计有更好的了解。代理将允许我们执行不一定使用Perlin噪声等体积填充算法的自定义程序化生成任务。代理更像是一个有限状态自动机,可以使用一组简单的规则修改资产。

创建场景

核心接口设置完成后,我们就可以创建一个实现。我创建了一个名为Scene的新类,它继承自IScene。我选择使用QuadTree来存储该项目的资产,但也可以使用其他数据结构,如数组、八叉树或任何适合您需求的数据结构。

public class Scene : IScene
{
    ...
}

接下来,我们需要定义接口指定的每个函数。我们将从注册资产类型开始,然后转到加载器。

注册资产

首先,创建一个List<Type>来存储场景中注册的资产类型,我们还需要一个以资产类型为键的Dictionary来存储每个注册资产类型的QuadTree。然后,为了注册资产类型,我们将创建一个泛型方法,该方法接受一个类型T,该类型必须是继承自IAsset的类。我们将使用T定义的类型在场景管理的资产类型列表中创建一个新条目。我返回this引用以创建流式接口,您将在稍后调用这些函数时看到其工作原理。

// List of types registered with Scene.
private List<Type> m_assetTypes = new List<Type>();

// List of QuadTrees to store assets of different types.
private Dictionary<Type, QuadTree<IAsset>> m_assets = new Dictionary<Type, QuadTree<IAsset>>();

...

// Register Asset.
public IScene RegisterAsset<T>() where T : class, IAsset
{
    // Convert Generic T to Type.
    Type assetType = typeof(T);

    // Throw an exception if this type is already registered.
    if (m_assetTypes.Contains(assetType))
    {
       throw new Exception(assetType.Name + " already registered.");
    }

    // Register Asset Type with the Scene.
    m_assetTypes.Add(assetType);

    // Create a new QuadTree to store loaded assets of type T.
    m_assets.Add(assetType, new QuadTree<IAsset>(new Size(100, 100), 1));

    // Return this for fluent interface.
    return this;
}

注册加载器

当我们注册加载器时,我们将实例化一个单例并将其存储在按资产类型索引的字典中。稍后我们将使用此对象创建我们的资产实例。为了创建加载器实例,我们将使用反射,我们可以调用Activator.CreateInstance<T2>来创建新实例。

...

// Dictionary of Registered Loaders.
private Dictionary<Type, ILoader> m_assetLoaders = new Dictionary<Type, ILoader>();

...

// Register ILoader of type T2 where T1 is an IAsset
public IScene RegisterLoader<T1, T2>() where T1 : class, IAsset where T2 : class, ILoader
{
    // Convert generics to types.
    Type assetType = typeof(T1);
    Type loaderType = typeof(T2);

    // Throw an Exception if T1 has not been registered.
    if (!m_assetTypes.Contains(assetType))
    {
         throw new Exception("Unable to register loader without registered asset.");
    }

    // Ensure a single instance of the Loader is created.
    if (!m_assetLoaders.ContainsKey(assetType))
    {
        // Use Reflection to create an instance of loader T2.
        m_assetLoaders.Add(assetType, Activator.CreateInstance<T2>());
    }

    // Return this for fluent interface.
    return this;
}

资产和加载器

要加载资产,我们需要创建继承自IAssetILoader的资产和加载器类。在这个例子中,我们只创建两个泛型资产,每个资产都有一个加载器,以演示抽象工厂模式的工作原理。当我们进入第二章时,当加载区块和图块时,我们将看到这种模式的一些更高级的用法。

资产类型

我将创建两种新的资产类型。Asset1Asset2,两者都将继承自IAsset。两种类型将具有相同的实现,但我还将为每种类型添加一个Name属性,在我们的演示中它将返回“Asset1”和“Asset2”。

// Asset1 concrete implementation of IAsset.

public class Asset1 : IAsset
{
    public IScene Scene { get; private set; }
    public IAsset Parent { get; private set; }
    public RectangleF Bounds { get; private set; }
    public bool Disposed { get; private set; }
    public event EventHandler BoundsChanged; 

    public string Name
    {
        get
        {
            return "Asset1";
        }
    }

    public Asset1(IScene scene, IAsset parent, int x, int y)
    {
        Scene = scene;
        Parent = parent;
        Bounds = new RectangleF(x, y, 1, 1);
    }

    public void Update(TimeSpan elapsed)
    {
        // Perform any asset specific logic.
    }

    public void Dispose() 
    {
        if(!Disposed)
        {
            // Perform any Cleanup here.

            Disposed = true;
        }
    }
}

接下来,Asset2类将有一个略微不同的实现,只需复制Asset1类并将Asset1更改为Asset2。通常,您的资产类型将具有除名称之外的不同属性,但对于此演示,我们只是保持简单。

加载器类型

创建加载器非常简单,在这个例子中,我们只是实例化我们的新Asset类型。我们需要为每种Asset类型提供一个不同的加载器。这将允许我们为每种类型使用自定义加载逻辑。这可能意味着从文件、数据库加载资产,或者像您稍后将看到的那样使用程序化方法。

// Asset1 Loader class.
public class Asset1Loader : ILoader
{
    public IAsset LoadAsset(IScene scene, IAsset parent, int x, int y)
    {
        // Create a new asset using a constructor
        Asset1 asset = new Asset1(scene, parent, x, y);

        // Perform additional loading logic here.

        // Return the loaded asset.
        return asset;
    }
}

// Asset2 Loader class.
public class Asset2Loader : ILoader 
{ 
    public IAsset LoadAsset(IScene scene, IAsset parent, int x, int y)
    {
        // Create a new asset using a constructor
        Asset2 asset = new Asset2(scene, parent, x, y);

        // Perform additional loading logic here.

        // Return the loaded asset.
        return asset;
    } 
}

在我们的示例加载器中,我们只有几行代码,这里我们只是使用构造函数实例化一个新实例。我们将在必要时在此处执行额外的加载逻辑。

注册和加载

现在我们有了一个基本的场景和一些带有加载器的示例资产,我们准备将它们组合在一起,这样我们就可以继续将资产加载到场景中。我们将创建一个控制台应用程序,用于加载和查询新的资产类型。首先创建一个简单的main函数,按如下方式注册我们的新资产。

注册

static void Main(string[] args) 
{
    // Create a new Scene instance.
    Scene scene = new Scene();
    
    // Use fluent interface to Register Asset1 and it's Loader.
    Scene.RegisterAsset<Asset1>()
         .RegisterLoader<Asset1, Asset1Loader>();

    // Use fluent interface to Register Asset2 and it's Loader.
    Scene.RegisterAsset<Asset2>()
         .RegisterLoader<Asset2, Asset2Loader();

    ...
}

在这里,我们使用场景中创建的注册函数定义的流式接口。我们将资产和加载器的类型作为泛型参数传递给我们的注册函数。

加载中

接下来,我们需要添加一个函数,它将调用我们的新加载器并将创建的资产存储在场景中。我们已经在IScene接口中定义了一个名为LoadAsset<T>()的函数。我们将在Scene类中按如下方式创建LoadAsset函数

public Scene : IScene 
{
    ...

    // Loads an Asset of type T at location (X, Y) 
    public T LoadAsset<T>(IAsset parent, int x, int y) where T : class, IAsset
    {
        IAsset asset = null;
        Type assetType = typeof(T);

        // Make sure the asset type has been registered with the scene.
        if(!m_assetTypes.Contains(assetType))
        {
            throw new Exception(assetType.Name + " has not been registered.");
        }

        // Make sure a loader has been registered for the asset type.
        if(!m_assetLoaders.ContainsKey(assetType))
        {
            throw new Exception("No loader registered for " + assetType.Name + ".");
        }

        // Call LoadAsset with registered asset loader.
        asset = m_assetLoaders[assetType].LoadAsset(this, parent, x, y);

        // Store the new asset in our scene.
        m_assets[assetType].Insert(asset);

        return asset;
    }    

    ...
}

卸载

我们还可以添加一个卸载函数,允许我们从场景中删除资产。我已在Scene类中按如下方式定义它。

public Scene : IScene
{
    ...

    // Unloads an asset and calls it's dispose method.
    public void UnloadAsset(IAsset asset)
    {
        // Check if asset is contained in the Scene.
        if(m_assets.Contains(asset))
        {
            // Remove the asset from our QuadTree.
            m_assets.Remove(asset);

            // Call the Dispose function defined for the Asset.
            asset.Dispose();
        }
    }

    ...
}

我们的场景现在可以创建泛型资产的实例并将其存储在其内部存储中,在此示例中是我们的QuadTree。使用我们的新功能非常简单。我们可以更新我们的main函数以像下面这样加载和卸载资产

static void Main(string[] args)
{   
    // Create a new Scene instance. 
    Scene scene = new Scene();

    // Use fluent interface to Register Asset1 and it's Loader.   
    scene.RegisterAsset<Asset1>()
         .RegisterLoader<Asset1, Asset1Loader>();   

    // Use fluent interface to Register Asset2 and it's Loader.   
    scene.RegisterAsset<Asset2>()
         .RegisterLoader<Asset2, Asset2Loader();   

    // Create a new asset at (0, 0) with no parent.    
​​​​​​    Asset1 asset1 = scene.LoadAsset<Asset1>(null, 0, 0);​

    // Create a new asset at (1, 1) with asset1 as a parent.
    Asset2 asset2 = scene.LoadAsset<Asset2>(asset1, 1, 1);

    // Perform additional steps with assets
    Console.Print("{0} ({1}, {2})", asset1.Name, asset1.Bounds.X, asset1.Bounds.Y);
    Console.Print("{0} ({1}, {2})", asset2.Name, asset2.Bounds.X, asset2.Bounds.Y);

    // Unload asset2.
    scene.UnloadAsset(asset2);

    ... 
}

查询资产

我们有一种存储资产的方法,但在演示中,我们只是直接使用LoadAsset函数返回的资产。这种方法对于一次管理许多资产并不是很有用。在我们的Scene实现中,我们为每个注册类型设置了一个QuadTree的字典。您还会记得我们将加载在LoadAsset中的资产存储在QuadTree字典中。

四叉树

引文:https://en.wikipedia.org/wiki/Quadtree

四叉树是一种树数据结构,其中每个内部节点恰好有四个子节点。四叉树最常用于通过递归地将其细分为四个象限或区域来划分二维空间。这些区域可以是正方形或矩形,也可以具有任意形状。这种数据结构于1974年由拉斐尔·芬克尔J.L. 宾利命名为四叉树。类似的划分也称为Q树。所有形式的四叉树都具有一些共同特征

  • 它们将空间分解为可适应的单元格
  • 每个单元格(或桶)都有最大容量。当达到最大容量时,桶会分裂
  • 树目录遵循四叉树的空间分解。

我不会深入讨论四叉树的工作原理,但正如您从上面的引文中所看到的,它们将对象存储在矩形单元格中,这使得它非常适合存储2D数据。我使用的是这里稍作修改的四叉树。可以使用其他数据结构,并且可以通过使用八叉树扩展到3D。IScene接口定义了两种用于查询资产的方法。GetAssets<T>返回场景中加载的类型为T的每个资产的列表,而不考虑位置。而QueryAssets<T>接受一个矩形参数并返回其中包含的资产列表。

查询资产

QueryAssets需要一个类型为T的泛型参数,并接受一个RectangleF边界来在我们的QuadTree中查询。QuadTree有一个QuadTree.Query方法,我们只需要验证资产类型是否已在工厂注册。QuadTree.Query返回一个包含边界内所有资产的List

public List<IAsset> QueryAssets<T>(RectangleF bounds) where T : class, IAsset
{
    List<IAsset> assets = new List<IAsset>();

    // Verify Asset type is registered.
    if (!m_assets.ContainsKey(typeof(T)))
    {
        throw new Exception("Asset Type not registered.");
    }
    
    // Query assets of type T within bounds.
    assets = m_assets[typeof(T)].Query(bounds);

    return assets;
}

获取资产

GetAssets函数接受一个类型为T的泛型参数,并返回一个资产列表。我使用的QuadTree有一种方法可以直接查询节点中的数据,而无需使用矩形。我只需遍历每个节点并将它们添加到函数返回的列表中。

public List<IAsset> GetAssets<T>() where T : class, IAsset 
{
    List<IAsset> assets = new List<IAsset>();
    
     // Loop through each QuadTree node.
    foreach(QuadTree<IAsset>.QuadNode node in m_assets[typeof(T)].GetAllNodes()) 
    {
        // Add the objects contained in the node to the list.
        assets.AddRange(node.Objects);
    }

    return assets;
}

收尾

Main函数中,我们可以根据矩形查询资产,而不是直接使用LoadAsset返回的引用。我们可以将多个资产加载到QuadTree的不同部分,然后查询其中的一部分。在这个示例中,我们只是打印返回的资产,然后卸载它们。

static void Main(string[] args) 
{   
    // Create a new Scene instance.   
    Scene scene = new Scene();   

    // Use fluent interface to Register Asset1 and it's Loader.   
    scene.RegisterAsset<Asset1>()   
         .RegisterLoader<Asset1, Asset1Loader>();   

    // Load a few assets into the scene. 
    scene.LoadAsset<Asset1>(null, 0, 0);   
    scene.LoadAsset<Asset1>(null, 5, 5);
    scene.LoadAsset<Asset1>(null, 10, 10);
    scene.LoadAsset<Asset1>(null, 15, 15);

    // Query a rectangle of 10 by 10
    var assets = scene.QueryAssets<Asset1>(new RectangleF(0, 0, 10, 10));

    // Print the assets returned and then unload them.
    foreach(Asset1 asset in assets)
    {
        Console.Print("{0} ({1}, {2})", asset.Name, asset.Bounds.X, asset.Bounds.Y); 
        
        // Unload the asset.
        scene.UnloadAsset(asset);  
    }    

    ...
}

运行程序时,我们将看到以下输出

Asset1 (0, 0)
Asset1 (50, 50)
Asset1 (100, 100)

加载和选择数据的可视化表示如下所示

就这样!在下一个教程中,我们将研究加载更多资产并将其组织成可管理单元。最终结果将是一个2D平铺系统,我们将能够使用它创建程序化生成的地图。敬请关注:《程序化地形生成 第二部分:加载区块和图块》

历史

  • 2016年8月23日 - 文章发布
© . All rights reserved.