《游戏设计模式》笔记
最小化在编写代码前需要了解的信息
对可扩展性的过分强调使得无数的开发者花费多年时间制作“引擎”, 却没有搞清楚做引擎是为了什么
模板编程
灵活性的两极。 当写代码调用类中的具体方法时, 你就是在写的时候指定类——硬编码了调用的是哪个类。 当使用虚方法或接口时, 直到运行时才知道调用的类。这更加灵活但增加了运行时开销。
模板编程是在两极之间。在编译时初始化模板, 决定调用哪些类。
命令模式
情形:
一个手柄, A键调用swapWeapon(), B键调用lurch(), X键调用jump(), Y键调用fireGun()
void InputHandler::handleInput()
{
if (isPressed(BUTTON_X)) jump();
else if (isPressed(BUTTON_Y)) fireGun();
else if (isPressed(BUTTON_A)) swapWeapon();
else if (isPressed(BUTTON_B)) lurchIneffectively();
}
命令模式是一种回调的面向对象实现。
用户可以自定义按键
👇
class Command
{
public:
virtual ~Command() {}
virtual void execute(GameActor& actor) = 0;
};
class JumpCommand : public Command
{
public:
virtual void execute(GameActor& actor)
{
actor.jump();
}
};
class FireCommand : public Command
{
public:
virtual void execute(GameActor& actor)
{
actor.fireGun();
}
};
在代码的输入处理部分, 为每个按键存储一个指向命令的指针, 输入处理部分这样处理:
class InputHandler
{
public:
Command* handleInput();
// 绑定命令的方法……
private:
Command* buttonX_;
Command* buttonY_;
Command* buttonA_;
Command* buttonB_;
};
Command* InputHandler::handleInput()
{
if (isPressed(BUTTON_X)) return buttonX_;
if (isPressed(BUTTON_Y)) return buttonY_;
if (isPressed(BUTTON_A)) return buttonA_;
if (isPressed(BUTTON_B)) return buttonB_;
// 没有按下任何按键, 就什么也不做
return NULL;
}
//接受命令, 作用在玩家角色上...
Command* command = inputHandler.handleInput();
if (command)
{
command->execute(actor);
}
一个手柄, 每个按键都与一个特定的'button_'变量相关联, 这个变量再与函数关联。
如果想支持不做任何事情的按键又不想显式检测
NULL
, 我们可以定义一个命令类, 它的execute()
什么也不做。 这样, 某些按键处理器不必设为NULL
, 只需指向这个类, 即空对象。
**我们可以让玩家控制游戏中的任何角色, 只需向命令传入不同的角色。**甚至可以对不同的角色使用不同的AI, 或者为了不同的行为而混合AI。
把控制角色的命令变为第一公民对象, 去除直接方法调用中严厉的束缚。 将其视为命令队列, 或者是命令流
一些代码(输入控制器或者AI)产生一系列命令放入流中。 另一些代码(调度器或者角色自身)调用并消耗命令。 通过在中间加入队列, 我们解耦了消费者和生产者。
动作撤销
命令绑定到要移动的单位上,
Command* handleInput()
{
Unit* unit = getSelectedUnit();
if (isPressed(BUTTON_UP)) {
// 向上移动单位
int destY = unit->y() - 1;
return new MoveUnitCommand(unit, unit->x(), destY);
}
if (isPressed(BUTTON_DOWN)) {
// 向下移动单位
int destY = unit->y() + 1;
return new MoveUnitCommand(unit, unit->x(), destY);
}
// 其他的移动……
return NULL;
}
当然, 在像C++这样没有垃圾回收的语言中, 这意味着执行命令的代码也要负责释放内存。
命令的一次性为我们很快地赢得了一个优点。 为了让指令可被取消, 我们为每个类定义另一个需要实现的方法:
class Command
{
public:
virtual ~Command() {}
virtual void execute() = 0;
virtual void undo() = 0;
};
undo()
方法回滚了execute()
方法造成的游戏状态改变。 这里是添加了撤销功能后的移动命令:
class MoveUnitCommand : public Command
{
public:
MoveUnitCommand(Unit* unit, int x, int y)
: unit_(unit),
xBefore_(0),
yBefore_(0),
x_(x),
y_(y)
{}
virtual void execute()
{
// 保存移动之前的位置, 这样之后可以复原。
xBefore_ = unit_->x();
yBefore_ = unit_->y();
unit_->moveTo(x_, y_);
}
virtual void undo()
{
unit_->moveTo(xBefore_, yBefore_);
}
private:
Unit* unit_;
int xBefore_, yBefore_;
int x_, y_;
};
从旧到新排列的命令队列。 一个当前箭头指向一条命令, 一个“撤销”箭头指向之前的命令, 一个“重做”指向之后的命令
重做在游戏中并不常见, 但重放常见。 一种简单的重放实现是记录游戏每帧的状态, 这样它可以回放, 但那会消耗太多的内存。
相反, 很多游戏记录每个实体每帧运行的命令。 为了重放游戏, 引擎只需要正常运行游戏, 执行之前存储的命令。
类 or 函数(闭包)?
函数式编程风格, 闭包写法(js) :
function makeMoveUnitCommand(unit, x, y) {
var xBefore, yBefore;
return {
execute: function() {
xBefore = unit.x();
yBefore = unit.y();
unit.moveTo(x, y);
},
undo: function() {
unit.moveTo(xBefore, yBefore);
}
};
}
命令模式展现了函数式范式在很多问题上的高效性
在某种程度上说, 命令模式是为一些没有闭包的语言模拟闭包
("某种程度上"👉 即使是那些支持闭包的语言, 为命令建立真正的类或者结构也是很有用的。 如果你的命令拥有多重操作(比如可撤销的命令), 将其全部映射到同一函数中并不优雅。)
定义一个有字段的真实类能帮助读者理解命令包含了什么数据。 闭包是自动包装状态的完美解决方案, 但它们过于自动化而很难看清包装的真正状态有哪些。
tips
享元模式
将共有的数据("上下文无关")拿出来分离到另一个类中, 只需有一个对这个共享Model
的引用
比如:
我们不想为每个区块都保存一个相同的(“固有的”或者说“上下文无关的”)实例
这有点像"类型对象模式"。
相同: 两者都涉及将一个类中的状态委托给另外的类, 来达到在不同实例间分享状态的目的。
不同: 两种模式背后的意图不同
- 使用类型对象, 目标是通过将类型引入对象模型, 减少需要定义的类, 伴随而来的内容分享是额外的好处
- 享元模式则是纯粹的为了效率
对于地形来说
常规(枚举)
地形类大致如此
enum Terrain
{
TERRAIN_GRASS,
TERRAIN_HILL,
TERRAIN_RIVER
// 其他地形
};
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
。这不是巧合。 由于同一对象在多处引用, 如果你修改了它, 改变会同时在多个地方出现。这也许不是你想要的。 通过分享对象来节约内存的这种优化, 不应该影响到应用的显性行为。 因此, 享元对象几乎总是不可变的。
对象指针
我们没有必要保存多个同种地形类型, 地面上的草区块两两无异, 我们不用地形区块对象枚举构成世界网格, 而是用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();
我们回到了操作实体对象的API, 几乎没有额外开销——指针通常不比枚举大
API中的实例渲染
为了减少需要推送到GPU的数据量, 我们想把共享的数据Model
只发送一次, 然后, 我们告诉GPU, “使用同一模型渲染每个实例”
Direct3D和OpenGL都可以做>>>实例渲染
在这些API中, 你需要提供两部分数据流。
- 一块需要渲染多次的共同数据——在例子中是树的网格和纹理。
- 实例的列表以及绘制第一部分时需要使用的参数。
然后调用一次渲染, 绘制整个森林。
在实时计算机图形学中, 几何体实例化是一次在场景中渲染同一网格的多个副本的实践。此技术主要用于树木、草地或建筑物等对象, 这些对象可以表示为重复的几何体, 而不会出现过度重复, 但也可以用于角色。尽管顶点数据在所有实例化网格上都是重复的, 但每个实例可能会更改其他微分参数(例如颜色或骨骼动画姿势), 以减少重复的外观。 **API支持编辑** 从Direct3D版本9开始, Microsoft提供了对几何体实例的支持。此方法通过在单独的流中为每个副本指定区分参数, 明确允许按顺序渲染网格的多个副本, 从而提高渲染实例化几何体的潜在运行时性能。Vulkan core和OpenGL core在3.1及以上版本中都有相同的功能, 但在一些早期的实现中, 可以使用EXT\u draw\u实例扩展访问这些功能。 **在脱机渲染中** Houdini、Maya或其他3D软件包中的几何体实例化通常涉及**将静态或预设置动画的对象或几何体映射到空间中的粒子或任意点, 然后几乎可以由任何脱机渲染器进行渲染**。脱机渲染中的几何体实例化对于创建诸如昆虫群之类的对象非常有用, 在这些对象中, 每个昆虫都可以被详细描述, 但其行为仍以真实的方式进行, 不必由动画师确定。大多数包允许在每个实例的基础上更改材质或材质参数, 这有助于确保实例看起来不是彼此的精确副本。在Houdini中, 许多对象级属性(例如缩放)也可以根据每个实例进行更改。由于大多数三维软件包中的实例化几何体仅引用原始对象, 因此文件大小保持非常小, 更改原始会更改所有实例。 在许多脱机渲染器中, 例如Pixar的照片级真实感渲染器(Photorealistic Renderman), 实例化是通过使用延迟加载渲染过程来实现的, 该过程仅在包含实例的桶实际正在渲染时加载几何体。这意味着所有实例的几何体不必同时在内存中。
tips
单例模式
私有的构造器保证了它是唯一的。 公开的静态方法instance()
让任何地方的代码都能访问实例。 在首次被请求时, 它同样负责惰性实例化该单例
- 只要没有环状依赖, 一个单例在初始化它自己的时甚至可以引用另一个单例
把FileSystem
变成单例:
class FileSystem
{
public:
static FileSystem& instance();
virtual ~FileSystem() {}
virtual char* readFile(char* path) = 0;
virtual void writeFile(char* path, char* contents) = 0;
protected:
FileSystem() {}
};
灵巧之处在于如何创建实例:
FileSystem& FileSystem::instance()
{
#if PLATFORM == PLAYSTATION3
static FileSystem *instance = new PS3FileSystem();
#elif PLATFORM == WII
static FileSystem *instance = new WiiFileSystem();
#endif
return *instance;
}
通过一个简单的编译器转换, 我们把文件系统包装类绑定到合适的具体类型上。 整个代码库都可以使用FileSystem::instance()
接触到文件系统, 而无需和任何平台相关的代码耦合。耦合发生在为特定平台写的FileSystem
类实现文件中
惰性初始化弊端
在拥有虚拟内存和软性性能需求的PC里, 惰性初始化是一个小技巧。 游戏则是另一种状况。
-
初始化系统需要消耗时间:分配内存, 加载资源, 等等。 如果初始化音频系统消耗了几百个毫秒, 我们需要控制它何时发生。 如果在第一次声音播放时惰性初始化它自己, 这可能发生在游戏的高潮部分, 导致可见的掉帧和断续的游戏体验。
-
游戏通常需要严格管理在堆上分配的内存来避免碎片。 如果音频系统在初始化时分配到了堆上, 我们需要知道初始化在何时发生, 这样我们可以控制内存待在堆的哪里。
大多数游戏都不使用惰性初始化。 相反, 它们像这样实现单例模式:
class FileSystem
{
public:
static FileSystem& instance() { return instance_; }
private:
FileSystem() {}
static FileSystem instance_;
};
与创建一个单例不同, 这里实际上是一个简单的静态类。 这并非坏事, 但是如果你需要的是静态类, 为什么不完全摆脱instance()
方法, 直接使用静态函数呢?调用Foo::bar()
比Foo::instance().bar()
更简单, 也更明确地表明你在处理静态内存。
通常使用单例而不是静态类的理由是, 如果你后来决定将静态类改为非静态的, 你需要修改每一个调用点。 理论上, 用单例就不必那么做, 因为你可以将实例传来传去, 像普通的实例方法一样使用。
如何抉择?
-
看看你是不是真正地需要类
没有管理器, 也没有问题。 糟糕设计的单例通常会“帮助”另一个类增加代码。 如果可以, 把所有的行为都移到单例帮助的类中。 毕竟, OOP就是让对象管理好自己。
-
运行时检查并阻止多重实例化
class FileSystem { public: FileSystem() { assert(!instantiated_); instantiated_ = true; } ~FileSystem() { instantiated_ = false; } private: static bool instantiated_; }; bool FileSystem::instantiated_ = false;
这个类允许任何人构建它, 如果你试图构建超过一个实例, 它会断言并失败。 只要正确的代码首先创建了实例, 那么就保证了没有其他代码可以接触实例或者创建自己的实例。 这个类保证满足了它关注的单一实例, 但是它没有指定类该如何被使用。
断言 函数是一种向你的代码中添加限制的方法。 当
assert()
被调用时, 它计算传入的表达式。 如果结果为true
游戏继续。 结果为false
, 它立刻停止游戏。 在debug build时, 这通常会启动调试器, 或至少打印失败断言所在的文件和行号。assert()
表示, “我断言这个总该是真的。如果不是, 那就是漏洞, 我想立刻停止并处理它。” 这使得你可以在代码区域之间定义约束。 如果函数断言它的某个参数不能为NULL
, 那就是说, “我和调用者定下了协议:传入的参数不会NULL
。”断言帮助我们在游戏发生预期以外的事时立刻追踪漏洞, 而不是等到错误最终显现在用户可见的某些事物上。 它们是代码中的栅栏, 围住漏洞, 这样漏洞就不能从制造它的代码边逃开。
这个实现的缺点是只在运行时检查并阻止多重实例化。 单例模式正相反, 通过类的自然结构, 在编译时就能确定实例是单一的。
如何给实例提供方便的访问方法?
我们使用单例的一个主要原因是便利的访问, 而代价是——在我们不想要对象的地方, 也能轻易地使用。
原则是在能完成工作的同时, 将变量写得尽可能局部。