【游戏编程模式】享元模式

享元模式

Posted by LudoArt on July 26, 2020

【游戏编程模式】享元模式

案例一:森林

每棵树都有一系列与之相关的位:

  • 定义树干,树枝和树叶形状的多边形网格。
  • 树皮和树叶的纹理。
  • 在森林中树的位置和朝向。
  • 大小和色彩之类的调节参数,让每棵树都看起来与众不同。

如果用代码表示,那么会得到这样的东西:

class Tree
{
private:
  Mesh mesh_;
  Texture bark_;
  Texture leaves_;
  Vector position_;
  double height_;
  double thickness_;
  Color barkTint_;
  Color leafTint_;
};

森林里有千千万万的树,它们大多数长得一模一样。 它们使用了相同的网格和纹理。 这意味着这些树的实例的大部分字段是一样的

森林结构

我们可以通过显式地将对象切为两部分来更加明确地模拟。 第一,将树共有的数据拿出来分离到另一个类中:

class TreeModel
{
private:
  Mesh mesh_;
  Texture bark_;
  Texture leaves_;
};

游戏只需要一个这种类, 因为没有必要在内存中把相同的网格和纹理重复一千遍。 游戏世界中每个树的实例只需有一个对这个共享TreeModel引用。 留在Tree中的是那些实例相关的数据:

class Tree
{
private:
  TreeModel* model_;

  Vector position_;
  double height_;
  double thickness_;
  Color barkTint_;
  Color leafTint_;
};

共享TreeModel

享元模式通过将对象的数据分为两种来解决这个问题。 第一种数据没有特定指明是哪个对象的实例,因此可以在它们间分享,称之为固有状态。 在这里的例子中,是树的网格和纹理。

数据的剩余部分是变化状态,那些每个实例独一无二的东西。 在这个例子中,是每棵树的位置,拉伸和颜色。

案例二:地形

我们基于区块建立地表:世界的表面被划分为由微小区块组成的巨大网格。 每个区块都由一种地形覆盖。

每种地形类型都有一系列特性会影响游戏玩法:

  • 决定了玩家能够多快地穿过它的移动开销。
  • 表明能否用船穿过的水域标识。
  • 用来渲染它的纹理。

因为我们游戏程序员偏执于效率,我们不会在每个区块中保存这些状态。 相反,一个通用的方式是为每种地形使用一个枚举。

enum Terrain
{
  TERRAIN_GRASS,
  TERRAIN_HILL,
  TERRAIN_RIVER
  // 其他地形
};

然后,世界管理巨大的网格:

class World
{
private:
  Terrain tiles_[WIDTH][HEIGHT];
};

为了获得区块的实际有用的数据,我们做了一些这样的事情:

// 获取移动开销
int World::getMovementCost(int x, int y)
{
  switch (tiles_[x][y])
  {
    case TERRAIN_GRASS: return 1;
    case TERRAIN_HILL:  return 3;
    case TERRAIN_RIVER: return 2;
      // 其他地形……
  }
}

// 判断是否是水域
bool World::isWater(int x, int y)
{
  switch (tiles_[x][y])
  {
    case TERRAIN_GRASS: return false;
    case TERRAIN_HILL:  return false;
    case TERRAIN_RIVER: return true;
      // 其他地形……
  }
}

移动开销和水域标识是区块的数据,但在这里它们散布在代码中。 更糟的是,简单地形的数据被众多方法拆开了。 如果能够将这些包裹起来就好了。毕竟,那是我们设计对象的目的。

如果我们有实际的地形类就好了,像这样:

class Terrain
{
public:
  Terrain(int movementCost,
          bool isWater,
          Texture texture)
  : movementCost_(movementCost),
    isWater_(isWater),
    texture_(texture)
  {}

  int getMovementCost() const { return movementCost_; }
  bool isWater() const { return isWater_; }
  const Texture& getTexture() const { return texture_; }

private:
  int movementCost_;
  bool isWater_;
  Texture texture_;
};

但是我们不想为每个区块都保存一个实例。 如果你看看这个类内部,你会发现里面实际上什么也没有, 唯一特别的是区块在哪里。 用享元的术语讲,区块的所有状态都是“固有的”

鉴于此,我们没有必要保存多个同种地形类型。 我们不用地形区块对象枚举构成世界网格,而是用Terrain对象指针组成网格:

class World
{
private:
  Terrain* tiles_[WIDTH][HEIGHT];

  // 其他代码……
};

地形

由于地形实例在很多地方使用,如果你想要动态分配,它们的生命周期会有点复杂。 因此,我们直接在游戏世界中存储它们。

class World
{
public:
  World()
  : grassTerrain_(1, false, GRASS_TEXTURE),
    hillTerrain_(3, false, HILL_TEXTURE),
    riverTerrain_(2, true, RIVER_TEXTURE)
  {}

private:
  Terrain grassTerrain_;
  Terrain hillTerrain_;
  Terrain riverTerrain_;

  // 其他代码……
};

然后我们可以像这样来描绘地面:

void World::generateTerrain()
{
  // 将地面填满草皮
  for (int x = 0; x < WIDTH; x++)
  {
    for (int y = 0; y < HEIGHT; y++)
    {
      // 加入一些丘陵
      if (random(10) == 0)
      {
        tiles_[x][y] = &hillTerrain_;
      }
      else
      {
        tiles_[x][y] = &grassTerrain_;
      }
    }
  }

  // 放置河流
  int x = random(WIDTH);
  for (int y = 0; y < HEIGHT; y++) {
    tiles_[x][y] = &riverTerrain_;
  }
}

现在不需要World中的方法来接触地形属性,我们可以直接暴露出Terrain对象。

const Terrain& World::getTile(int x, int y) const
{
  return *tiles_[x][y];
}

用这种方式,World不再与各种地形的细节耦合。 如果你想要某一区块的属性,可直接从那个对象获得:

int cost = world.getTile(2, 3).getMovementCost();

参看

  • 在区块的例子中,我们只是为每种地形创建一个实例然后存储在World中。 这也许能更好找到和重用这些实例。 但是在多数情况下,你不会在一开始就创建所有享元。

    如果你不能预料哪些是实际上需要的,最好在需要时才创建。 为了保持共享的优势,当你需要一个时,首先看看是否已经创建了一个相同的实例。 如果确实如此,那么只需返回那个实例。

    这通常意味需要将构造函数封装在查询对象是否存在的接口之后。 像这样隐藏构造指令是工厂方法的一个例子。

  • 为了返回一个早先创建的享元,需要追踪那些已经实例化的对象池。 正如其名,这意味着对象池是存储它们的好地方。

  • 当使用状态模式时, 经常会出现一些没有任何特定字段的“状态对象”。 这个状态的标识和方法都很有用。 在这种情况下,你可以使用这个模式,然后在不同的状态机上使用相同的对象实例。