这些树所生长的地面有需要在我们的游戏中表现出来。可以有由小草、灰土、丘陵、湖泊、河流以及其他任何你可以梦想到的地形(terrain)所组成的补丁。我们将要让这地面是基于砖块的(tile-based):世界的表面是由小砖块所组成的巨大格栅(grid)。每一个砖块由一种地形所覆盖。
每一个地形类型有若干影响到游戏性(gameplay)的性质:
毕竟,我们已经从那些树木里吸取教训了。
enum Terrain
{
TERRAIN_GRASS ,
TERRAIN_HILL ,
TERRAIN_RIVER
// Other terrains...
};
然后游戏世界保存上述枚举类型的一个巨大格栅:
class World
{
private:
Terrain tiles_[WIDTH][HEIGHT];
};
在这里我使用一个嵌套的数组来存储一个2D格栅。这在C/C++中是高效的,因为它会把所有的元素打包在一起。在Java或其他的内存托管的语言中,这样做的话实际上会给你一个行数组,其中每一个元素是一个列数组的一个引用,而这可能并不像你喜欢的那样对内存友好。
不管是哪种情况,真实的代码会通过把这个实现隐藏在一个优美的2D格栅数据结构后面而更加好看(better served)。而我在这里这样做是为了保持简单。
为了实际地得到关于一个砖块的有用数据,我们做类似这样的事情:
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;
// Other terrains...
}
}
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;
// Other terrains...
}
}
你理解意思了吧。这个管用,但是我觉得它丑陋。我把移动成本和潮湿程度看成是地形的数据,但是在这里它们被嵌入到代码中了。更糟糕的是,一个地形类型的数据is smeared across一堆的方法里头了。如果能够把所有这些数据封装在一起的话,那便是极好的了。毕竟,那就是设计对象的目的。
如果我们可以有一个像这样的真正的地形类,那就很棒了:
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_;
};
你会注意到这里的所有方法都是const的。这不是巧合。因为同样的对象会用于很多个场合,如果你可以修改它的话,那么这样的改变会同时出现在多个地方的。
这很可能不是你所想要的。共享对象以节省内存的做法应该是一种不影响app的视觉行为的优化。正因为此,轻量级对象几乎总是immutable。
但是我们并不想要付出这样的代价:为世界中的每一个砖块创建这个类的一个实例。如果你看看这个类的话,你会注意到实际上没有与该砖块在哪儿有关的东西。用轻量级的术语来说,一个地形的所有状态都是“内在的”或者说是“与上下文无关的”。
说明了这一点之后,就没有理由为每个地形类型创建多于一个的实例了。地面上的每个草地砖块彼此间是等同的。并不是让世界成为由枚举类型或者Terrain对象组成的一个格栅,而是成为由指向Terrain对象的指针所组成的格栅:
class World
{
private:
Terrain* tiles_[WIDTH][HEIGHT];
// Other stuff...
};
使用同一地形的每一个砖块将会指向同一个地形实例。
由于这些地形实例会被使用多次,如果你要动态地分配它们的话,那么其生命周期管理起来有点复杂。相反,我们就直接在世界中储存它们好了:
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_;
// Other stuff...
};
然后我们就可以使用这些来绘制地面,就像这样:
void World::generateTerrain()
{
// Fill the ground with grass.
for (int x = 0; x < WIDTH; x++)
{
for (int y = 0; y < HEIGHT; y++)
{
// Sprinkle some hills.
if (random(10) == 0)
{
tiles_[x][y] = &hillTerrain_;
}
else
{
tiles_[x][y] = &grassTerrain_;
}
}
}
// Lay a river.
int x = random(WIDTH);
for (int y = 0; y < HEIGHT; y++) {
tiles_[x][y] = &riverTerrain_;
}
}
我得承认,这并不是世界上最棒的过程式地形生成算法。
现在,我们可以直接暴露Terrain对象、而不是使用World的方法来访问地形的属性了:const Terrain& World::getTile(int x, int y) const
{
return *tiles_[x][y];
}
这样的话,World不再与各种地形相关的细节耦合在一起了。如果你想要得到砖块的某个属性,你可以直接从那个对象中获得:
int cost = world.getTile(2, 3).getMovementCost();
我们回到了那个操作真实对象的令人愉悦的API了,而且我们做到这一点的时候几乎没有引起额外的开销——一个指针经常不会大于一个枚举值。
我在这里说“几乎”,因为性能统计专家(bean counters)会正当地想要知道这与使用枚举类型相比到底怎么样。通过指针来引用地形意味着一个间接的查询。为了得到某个地形的数据,比如说移动成本,你首先得跟随着格栅中的指针找到对应的地形对象,然后在那里找到移动成本。像这样追踪一个指针可能会引起一个高速缓存缺失(cache miss),而这会拖慢速度。
想知道关于指针追踪和cachemiss的更多信息,可以看看关于Data Locality这一章。
一如往常,优化的指导原则是profile first()。现代的电脑硬件太复杂了,以至于难以让性能分析成为一个纯粹逻辑推理的游戏。(Modern computer hardware is too complex for performance to be a game of pure reason anymore.)在我对本章的测试中,使用轻量级来代替枚举类型并没有penalty。实际上轻量级模式还要显著地快。但是这完全依赖于其他东西在内存中的分布。
我有信心的是,不应该不经大脑思索便摈弃使用轻量级对象(using flyweight objects shouldn't be dismissed out of hand)。它们给你了一个面向对象的优势,而不会有成吨的对象的成本。如果你发现你自己在创建一个枚举类型,并且在对它做很多的switch测试,那么考虑使用这个模式吧。如果你担心性能,在把你的代码变得具有不太容易维护的风格之前,至少profile first。