定义:
将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化; 对请求排队或记录请求日志,以及支持可撤销的操作。
在每个游戏中都有一块代码读取用户的输入。这块代码会获取用户的输入,然后将其变为游戏中有意义的行为:
**
**
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();
}
许多游戏允许玩家配置按键的功能,为了支持这点,需要将这些对jump()和fireGun()的直接调用转化为可以变换的东西。因此我们需要表示游戏行为的对象。进入:命令模式。
我们定义了一个基类代表可触发的游戏行为:
class Command
{
public:
virtual ~Command() {}
virtual void execute() = 0;
};
为不同的游戏行为定义相应的子类:
class JumpCommand : public Command
{
public:
virtual void execute() { jump(); }
};
class FireCommand : public Command
{
public:
virtual void execute() { fireGun(); }
};
// 你知道思路了吧
在代码的输入处理部分,为每个按键存储一个指向命令的指针。
class InputHandler
{
public:
void handleInput();
// 绑定命令的方法……
private:
Command* buttonX_;
Command* buttonY_;
Command* buttonA_;
Command* buttonB_;
};
上述代码可以实现间接寻址,但是必须关联了具体的游戏对象才能进行调用。我们可以通过函数控制将函数控制的角色对象传进去:
class Command
{
public:
virtual ~Command() {}
virtual void execute(GameActor& actor) = 0;
};
传入命令子类对应的对象后,自动调用方法。
class JumpCommand : public Command
{
public:
virtual void execute(GameActor& actor)
{
actor.jump();
}
};
现在,还需要在输入控制部分和对象调用命令部分进行间接调用。这里是通过 输入后返回消息,并监听这个消息来调用对应的对象方法。
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; //
}
命令的调用:延迟到具体执行时。
下面就是监听命令的调用并作用到玩家角色上。
//不同的命令都封装为一个命令对象,对象可以为 NULL
Command* command = inputHandler.handleInput();
if (command)
{
command->execute(actor);
}
将actor视为玩家角色的引用,会正确地按着玩家的输入移动,即赋予了角色和前面例子中相同的行为。
通过在命令和角色间增加了一层重定向, 获得了一个灵巧的功能:可以让玩家控制游戏中的任何角色,只需向命令传入不同的角色。
在AI和角色之间使用相同的命令模式;AI代码只需生成Command对象。对不同的角色使用不同的AI,或者为了不同的行为而混合AI。还可以为玩家角色加上AI。
一些代码(输入控制器或者AI)产生一系列命令放入流中。 另一些代码(调度器或者角色自身)调用并消耗命令。 通过在中间加入队列,我们解耦了消费者和生产者。
命令对象可以做一件事,亦可以撤销这件事。
使用了命令来抽象输入控制,所以每个玩家的举动都已经被封装其中。举个例子,移动一个单位的代码可能如下:
class MoveUnitCommand : public Command
{
public:
MoveUnitCommand(Unit* unit, int x, int y)
: unit_(unit),
x_(x),
y_(y)
{}
virtual void execute()
{
unit_->moveTo(x_, y_);
}
private:
Unit* unit_;
int x_, y_;
};
在这个例子中,我们将命令绑定到要移动的单位上。 这条命令的实例不是通用的“移动某物”命令;而是游戏回合中特殊的一次移动。指令在某些情形中是可重用的对象,代表了可执行的事件。 我们早期的输入控制器将其实现为一个命令对象,然后在按键按下时调用其execute()方法。
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;
}
class Command
{
public:
virtual ~Command() {}
virtual void execute() = 0;
virtual void undo() = 0;
};
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_;
};
为类添加了更多的状态。 当单位移动时,它忘记了它之前是什么样的。 如果我们想要撤销这个移动,我们需要记得单位之前的状态,也就是xBefore_和yBefore_的作用。
支持多重的撤销,就可以不记录单一的最后一条指令,而是记录命令数组。
ps:在撤销的时候插入新命令——清除当前指针后面的命令
什么问题?
GPU,CPU处理大批量数据时性能不行。
当屏幕上需要显示一整个森林时,图形程序员看到的是每秒需要送到GPU六十次的百万多边形。就算有足够的内存描述森林,渲染的过程中,CPU到GPU的部分也太过繁忙了。
树类可以定义如下:
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_;
};
class Tree
{
private:
TreeModel* model_;
Vector position_;
double height_;
double thickness_;
Color barkTint_;
Color leafTint_;
};
区块的所有状态都是“固有的“,上下文无关的。
class World
{
private:
Terrain* tiles_[WIDTH][HEIGHT];
// 其他代码……
};
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_;
}
}
回到了操作实体对象的API,几乎没有额外开销——指针通常不比枚举大。
让代码宣称有趣的事情发生了,而不必关心到底是谁接受了通知。
为了实现“桥上掉落”的徽章,我们可以直接把成就代码放在那里,但那就会一团糟。 相反,可以这样做:
void Physics::updateEntity(Entity& entity)
{
bool wasOnSurface = entity.isOnSurface();
entity.accelerate(GRAVITY);
entity.update();
if (wasOnSurface && !entity.isOnSurface())
{
notify(entity, EVENT_START_FALL);
}
}
成就系统注册它自己为观察者,这样无论何时物理代码发送通知,成就系统都能收到。 它可以检查掉落的物体是不是我们的失足英雄, 他之前有没有做过这种不愉快的与桥的经典力学遭遇。 如果满足条件,就伴着礼花和炫光解锁合适的成就,而这些都无需牵扯到物理代码。
事实上,我们可以改变成就的集合或者删除整个成就系统,而不必修改物理引擎。 它仍然会发送它的通知,哪怕实际没有东西接收。
class Achievements : public Observer
{
public:
virtual void onNotify(const Entity& entity, Event event)
{
switch (event)
{
case EVENT_ENTITY_FELL:
if (entity.isHero() && heroIsOnBridge_)
{
unlock(ACHIEVEMENT_FELL_OFF_BRIDGE);
}
break;
// 处理其他事件,更新heroIsOnBridge_变量……
}
}
private:
void unlock(Achievement achievement)
{
// 如果还没有解锁,那就解锁成就……
}
bool heroIsOnBridge_;
};
class Subject
{
private:
Observer* observers_[MAX_OBSERVERS];
int numObservers_;
};
被观察者暴露了公开的API来修改这个列表:
class Subject
{
public:
void addObserver(Observer* observer)
{
// 添加到数组中……
}
void removeObserver(Observer* observer)
{
// 从数组中移除……
}
// 其他代码……
};
被观察者的剩余任务就是发送通知:
class Subject
{
protected:
void notify(const Entity& entity, Event event)
{
for (int i = 0; i < numObservers_; i++)
{
observers_[i]->onNotify(entity, event);
}
}
// 其他代码…………
};
众多类,众多引用,众多冗余,众多副本,众多重复自我……
原型模式提供了一个解决方案。 关键思路是一个对象可以产出与它自己相近的对象。 如果你有一个恶灵,你可以制造更多恶灵。 如果你有一个恶魔,你可以制造其他恶魔。 任何怪物都可以被视为原型怪物,产出其他版本的自己。
为了实现这个功能,我们给基类Monster添加一个抽象方法clone():
class Monster
{
public:
virtual ~Monster() {}
virtual Monster* clone() = 0;
// 其他代码……
};
每个怪兽子类提供一个特定实现,返回与它自己的类和状态都完全一样的新对象。举个例子:
class Ghost : public Monster {
public:
Ghost(int health, int speed)
: health_(health),
speed_(speed)
{}
virtual Monster* clone()
{
return new Ghost(health_, speed_);
}
private:
int health_;
int speed_;
};
一旦我们所有的怪物都支持这个, 我们不再需要为每个怪物类创建生产者类。我们只需定义一个类:
class Spawner
{
public:
Spawner(Monster* prototype)
: prototype_(prototype)
{}
Monster* spawnMonster()
{
return prototype_->clone();
}
private:
Monster* prototype_;
};
被生产者当做模板,去产生更多一样的怪物。
为了得到恶灵生产者,我们创建一个恶灵的原型实例,然后创建拥有这个实例的生产者:
Monster* ghostPrototype = new Ghost(15, 3);
Spawner* ghostSpawner = new Spawner(ghostPrototype);
灵巧之处在于它不但拷贝原型的类,也拷贝它的状态。 这就意味着我们可以创建一个生产者,生产快速鬼魂,虚弱鬼魂,慢速鬼魂,而只需创建一个合适的原型鬼魂。
回避单例模式
避免使用某个设计模式。
定义:
保证一个类只有一个实例,并且提供了访问该实例的全局访问点。
class FileSystem
{
public:
static FileSystem& instance()
{
static FileSystem *instance = new FileSystem();
return *instance;
}
private:
FileSystem() {}
};
静态的instance_成员保存了一个类的实例, 私有的构造器保证了它是唯一的。 公开的静态方法instance()让任何地方的代码都能访问实例。 在首次被请求时,它同样负责惰性实例化该单例。
1. 全局变量
单例模式不是解药,它是安慰剂。 如果浏览全局变量造成的问题列表,你会注意到单例模式解决不了其中任何一个。 因为单例确实是全局状态——它只是被封装在一个类中。
为了保证实例是单一的,通常简单地使用静态类。 如果这无效,使用静态标识位,在运行时检测是不是只有一个实例被创建了。
class Game
{
public:
static Game& instance() { return instance_; }
// 设置log_, et. al. ……
Log& getLog() { return *log_; }
FileSystem& getFileSystem() { return *fileSystem_; }
AudioPlayer& getAudioPlayer() { return *audioPlayer_; }
private:
static Game instance_;
Log *log_;
FileSystem *fileSystem_;
AudioPlayer *audioPlayer_;
};
这样,只有Game是全局可见的。 函数可以通过它访问其他系统。
Game::instance().getAudioPlayer().play(VERY_LOUD_BANG);
给英雄每件能做的事情都画了一个盒子:站立,跳跃,俯卧,跳斩。 当角色在能响应按键的状态时,你从那个盒子画出一个箭头,标记上按键,然后连接到她变到的状态。
有限状态机(FSMs)的要点:
举个例子,在站立状态时,按下下方向键转换为俯卧状态。 在跳跃时按下下方向键转换为速降。 如果输入在当前状态没有定义转移,输入就被忽视。
Heroine类的问题在于它不合法地捆绑了一堆布尔量: isJumping_和isDucking_不会同时为真。 但有些标识同时只能有一个是true,这提示你真正需要的其实是enum(枚举)。
在这个例子中的enum就是FSM的状态的集合,所以让我们这样定义它:
enum State
{
STATE_STANDING,
STATE_JUMPING,
STATE_DUCKING,
STATE_DIVING
};
不需要一堆标识,Heroine只有一个state_状态。 这里我们同时改变了分支顺序。在前面的代码中,我们先判断输入,然后 判断状态。
不需要一堆标识,Heroine只有一个state_状态。 这里我们同时改变了分支顺序。在前面的代码中,我们先判断输入,然后 判断状态。 这让处理某个按键的代码集中到了一处,但处理某个状态的代码分散到了各处。 我们想让处理状态的代码聚在一起,所以先对状态做分支。这样的话:
void Heroine::handleInput(Input input)
{
switch (state_)
{
case STATE_STANDING:
if (input == PRESS_B)
{
state_ = STATE_JUMPING;
yVelocity_ = JUMP_VELOCITY;
setGraphics(IMAGE_JUMP);
}
else if (input == PRESS_DOWN)
{
state_ = STATE_DUCKING;
setGraphics(IMAGE_DUCK);
}
break;
case STATE_JUMPING:
if (input == PRESS_DOWN)
{
state_ = STATE_DIVING;
setGraphics(IMAGE_DIVE);
}
break;
case STATE_DUCKING:
if (input == RELEASE_DOWN)
{
state_ = STATE_STANDING;
setGraphics(IMAGE_STAND);
}
break;
}
}
仍有条件分支,但简化了状态变化,将它变成了字段。 处理同一状态的所有代码都聚到了一起。 这是实现状态机最简单的方法,在某些情况下,这也不错。
假设我们想增加一个动作动作,英雄可以俯卧一段时间充能,之后释放一次特殊攻击。 当她俯卧时,我们需要追踪充能的持续时间。
void Heroine::update()
{
if (state_ == STATE_DUCKING)
{
chargeTime_++;
if (chargeTime_ > MAX_CHARGE)
{
superBomb();
}
}
}
void Heroine::handleInput(Input input)
{
switch (state_)
{
case STATE_STANDING:
if (input == PRESS_DOWN)
{
state_ = STATE_DUCKING;
chargeTime_ = 0;
setGraphics(IMAGE_DUCK);
}
// 处理其他输入……
break;
// 其他状态……
}
}
class HeroineState
{
public:
virtual ~HeroineState() {}
virtual void handleInput(Heroine& heroine, Input input) {}
virtual void update(Heroine& heroine) {}
};
class DuckingState : public HeroineState
{
public:
DuckingState()
: chargeTime_(0)
{}
virtual void handleInput(Heroine& heroine, Input input) {
if (input == RELEASE_DOWN)
{
// 改回站立状态……
heroine.setGraphics(IMAGE_STAND);
}
}
virtual void update(Heroine& heroine) {
chargeTime_++;
if (chargeTime_ > MAX_CHARGE)
{
heroine.superBomb();
}
}
private:
int chargeTime_;
};
将chargeTime_移出了Heroine,放到了DuckingState类中。 这很好——那部分数据只在这个状态有用。
class Heroine
{
public:
virtual void handleInput(Input input)
{
state_->handleInput(*this, input);
}
virtual void update()
{
state_->update(*this);
}
// 其他方法……
private:
HeroineState* state_;
};
为了“改变状态”,我们只需要将state_声明指向不同的HeroineState对象。 这就是状态模式的全部了。
//定义状态
class HeroineState
{
public:
static StandingState standing;
static DuckingState ducking;
static JumpingState jumping;
static DivingState diving;
// 其他代码……
};
//跳转状态
if (input == PRESS_B)
{
heroine.state_ = &HeroineState::jumping;
heroine.setGraphics(IMAGE_JUMP);
}
//进行状态切换
void Heroine::handleInput(Input input)
{
HeroineState* state = state_->handleInput(*this, input);
if (state != NULL)
{
delete state_;
state_ = state;
}
}
//返回新状态
HeroineState* StandingState::handleInput(Heroine& heroine,
Input input)
{
if (input == PRESS_DOWN)
{
// 其他代码……
return new DuckingState();
}
// 保持这个状态
return NULL;
}
在Heroine中,我们将处理状态改变的代码移动到新状态上调用:
class StandingState : public HeroineState
{
public:
virtual void enter(Heroine& heroine)
{
heroine.setGraphics(IMAGE_STAND);
}
// 其他代码……
};
这让我们将俯卧代码简化为:
HeroineState* DuckingState::handleInput(Heroine& heroine,
Input input)
{
if (input == RELEASE_DOWN)
{
return new StandingState();
}
// 其他代码……
}
它做的所有事情就是转换到站立状态,站立状态控制贴图。 现在我们的状态真正地封装了。
关于入口行为的好处就是,当你进入状态时,不必关心你是从哪个状态转换来的。
大多数真正的状态图都有转为同一状态的多个转移。 举个例子,英雄在跳跃或跳斩后进入站立状态。 这意味着我们在转换发生的最后重复相同的代码。 入口行为很好地解决了这一点。
我们能扩展并支持出口行为。 这是在我们离开现有状态,转换到新状态之前调用的方法。
我们决定赋予英雄拿枪的能力。 当她拿着枪的时候,她还是能做她之前的任何事情:跑动,跳跃,跳斩,等等。 但是她在做这些的同时也要能开火。多加几种武器,状态就会指数爆炸。 不但增加了大量的状态,也增加了大量的冗余: 持枪和不持枪的状态是完全一样的,只是多了一点负责射击的代码。
问题在于我们将两种状态绑定到了一个状态机上——她做的和她携带的。 == 为了处理所有可能的组合,我们需要为每一对组合写一个状态。修复方法很明显:使用两个单独的状态机。==
class Heroine
{
// 其他代码……
private:
HeroineState* state_;
HeroineState* equipment_;
};
当英雄把输入委托给了状态,两个状态都需要委托:
void Heroine::handleInput(Input input)
{
state_->handleInput(*this, input);
equipment_->handleInput(*this, input);
}
每个状态机之后都能响应输入,发生行为,独立于其它机器改变状态。 当两个状态集合几乎没有联系的时候,它工作得不错。
在实践中,你会发现状态有时需要交互。 举个例子,也许她在跳跃时不能开火,或者她在持枪时不能跳斩攻击。 为了完成这个,你也许会在状态的代码中做一些粗糙的if测试其他状态来协同。
分层状态机的通用结构。== 状态可以有父状态(这让它变为子状态)。 当一个事件进来,如果子状态没有处理,它就会交给链上的父状态。 ==换言之,它像重载的继承方法那样运作。
class OnGroundState : public HeroineState
{
public:
virtual void handleInput(Heroine& heroine, Input input)
{
if (input == PRESS_B)
{
// 跳跃……
}
else if (input == PRESS_DOWN)
{
// 俯卧……
}
}
};
每个子状态继承它:
class DuckingState : public OnGroundState
{
public:
virtual void handleInput(Heroine& heroine, Input input)
{
if (input == RELEASE_DOWN)
{
// 站起……
}
else
{
// 没有处理输入,返回上一层方法
OnGroundState::handleInput(heroine, input);
}
}
};
栈顶的状态是当前状态,在他下面是它的直接父状态, 然后是那个父状态的父状态,以此类推。 当你需要状态的特定行为,你从栈的顶端开始, 然后向下寻找,直到某一个状态处理了它。(如果到底也没找到,就无视它。)
还有一种有限状态机的扩展也用了状态栈。 容易混淆的是,这里的栈表示的是完全不同的事物,被用于解决不同的问题。
要解决的问题是有限状态机没有任何历史的概念。 你记得正在什么状态中,但是不记得曾在什么状态。 没有简单的办法重回上一状态。
举个例子:早先,我们让无畏英雄武装到了牙齿。 当她开火时,我们需要新状态播放开火动画,发射子弹,产生视觉效果。 所以我们拼凑了一个FiringState,不管现在是什么状态,都能在按下开火按钮时跳转为这个状态。如果我们固执于纯粹的FSM,我们就已经忘了她之前所处的状态。 为了追踪之前的状态,我们定义了很多几乎完全一样的类——站立开火,跑步开火,跳跃开火,诸如此类—— 每个都有硬编码的转换,用来回到之前的状态。
真正想要的是,它会存储开火前所处的状态,之后能回想起来。 自动理论又一次能帮上忙了,相关的数据结构被称为下推自动机。
有限状态机有一个指向状态的指针,下推自动机有一栈指针。 在FSM中,新状态代替了之前的那个状态。 下推自动机不仅能完成那个,还能给你两个额外操作:
有限状态机在以下情况有用:
大多数游戏世界都有的特性是时间——虚构世界以其特定的节奏运行。 作为世界的架构师,我们必须发明时间,制造推动游戏时间运作的齿轮。
游戏循环是时钟的中心轴。 对象通过更新方法来聆听时钟的滴答声。 我们可以用双缓冲模式存储快照来隐藏计算机的顺序执行,这样看起来世界可以进行同步更新。
用序列的操作模拟瞬间或者同时发生的事情。
定义缓冲类封装了缓冲:一段可改变的状态。 这个缓冲被增量地修改,但我们想要外部的代码将修改视为单一的原子操作。 为了实现这点,类保存了两个缓冲的实例:下一缓冲和当前缓冲。
当信息从缓冲区中读取,它总是读取当前的缓冲区。 当信息需要写到缓存,它总是在下一缓冲区上操作。 当改变完成后,一个交换操作会立刻将当前缓冲区和下一缓冲区交换, 这样新缓冲区就是公共可见的了。旧的缓冲区成为下一个重用的缓冲区。
不是保存两大块“缓冲”,而是缓冲更小粒度的事物:每个角色的“被扇”状态。
class Actor
{
public:
Actor() : currentSlapped_(false) {}
virtual ~Actor() {}
virtual void update() = 0;
void swap()
{
// 交换缓冲区
currentSlapped_ = nextSlapped_;
// 清空新的“下一个”缓冲区。.
nextSlapped_ = false;
}
void slap() { nextSlapped_ = true; }
bool wasSlapped() { return currentSlapped_; }
private:
bool currentSlapped_;
bool nextSlapped_;
};
现在,就在清除交换状态前,它将下一状态拷贝到当前状态上, 使其成为新的当前状态。
void Stage::update()
{
for (int i = 0; i < NUM_ACTORS; i++)
{
actors_[i]->update();
}
for (int i = 0; i < NUM_ACTORS; i++)
{
actors_[i]->swap();
}
}
update()函数现在更新所有的角色,然后 交换它们的状态。 最终结果是,角色在实际被扇之后的那帧才能看到巴掌。 这样一来,角色无论在舞台数组中如何排列,都会保持相同的行为。 无论外部的代码如何调用,所有的角色在一帧内同时更新。
将游戏的进行和玩家的输入解耦,和处理器速度解耦。
游戏循环是“游戏编程模式”的精髓。 几乎每个游戏都有,两两不同,而在非游戏的程序几乎没有使用。
游戏循环的第一个关键部分:它处理用户输入,但是不等待它。循环总是继续旋转:
while (true)
{
processInput();
update();
render();
另一个关键任务:不管潜在的硬件条件,以固定速度运行游戏。
一个游戏循环在游玩中不断运行。 每一次循环,它无阻塞地处理玩家输入,更新游戏状态,渲染游戏。 它追踪时间的消耗并控制游戏的速度。使用错误的模式比不使用模式更糟,所以这节通常告诫你不要过于热衷设计模式。 设计模式的目标不是往代码库里尽可能的塞东西。
你可能认为在做回合制游戏时不需要它。 但是哪怕是那里,就算游戏状态到玩家回合才改变,视觉和听觉 状态仍会改变。 哪怕游戏在“等待”你进行你的回合,动画和音乐也会继续运行。
游戏循环驱动了AI,渲染和其他游戏系统,但这些不是模式的要点, 所以我们会调用虚构的方法。
while (true)
{
processInput();
update();
render();
// sleep(start + MS_PER_FRAME - getCurrentTime()); 如果游戏处理太快,就使用sleep()减慢游戏运行速度
}
假设你想要你的游戏以60FPS运行。这样每帧大约16毫秒。 只要你用少于这个的时长进行游戏所有的处理和渲染,就可以以稳定的帧率运行。 你需要做的就是处理这一帧然后等待,直到处理下一帧的时候。
游戏中渲染通常不会被动态时间间隔影响到。 由于渲染引擎表现的是时间上的一瞬间,它不会计算上次到现在过了多久。 它只是将当前事物渲染在所在的地方。
在每帧的开始,根据过去了多少真实的时间,更新lag。 这个变量表明了游戏世界时钟比真实世界落后了多少,然后我们使用一个固定时间步长的内部循环进行追赶。 一旦我们追上真实时间,我们就渲染然后开始新一轮循环。
在示例代码中提到的最后一个选项是最复杂的,但是也是最有适应性的。 它以固定时间步长更新,但是如果需要赶上玩家的时间,可以扔掉一些渲染帧。
能适应并调整,避免运行得太快或者太慢。 只要能实时更新,游戏状态就不会落后于真实时间。如果玩家用高端的机器,它会回以更平滑的游戏体验。
更复杂。 主要负面问题是需要在实现中写更多东西。 你需要将更新的时间步长调整得尽可能小来适应高端机,同时不至于在低端机上太慢。
unity生命周期
通过每次处理一帧的行为模拟一系列独立对象。
每个游戏实体应该封装它自己的行为,这就需要抽象层,我们通过定义抽象的update()方法来完成。 游戏循环管理对象的集合,但是不知道对象的具体类型。 它只知道这些对象可以被更新。 这样,每个对象的行为与游戏循环分离,与其他对象分离。 游戏循环管理对象的集合,但是不知道对象的具体类型。 它只知道这些对象可以被更新。 这样,每个对象的行为与游戏循环分离,与其他对象分离。
游戏世界管理对象集合。 每个对象实现一个更新方法模拟对象在一帧内的行为。每一帧,游戏循环更新集合中的每一个对象。
更新方法适应以下情况:
游戏有很多对象或系统需要同时运行。
每个对象的行为都与其他的大部分独立。
对象需要跟着时间进行模拟。
class Entity
{
public:
Entity()
: x_(0), y_(0)
{}
virtual ~Entity() {}
virtual void update() = 0;
double x() const { return x_; }
double y() const { return y_; }
void setX(double x) { x_ = x; }
void setY(double y) { y_ = y; }
private:
double x_;
double y_;
};
class World
{
public:
World()
: numEntities_(0)
{}
void gameLoop();
private:
Entity* entities_[MAX_ENTITIES];
int numEntities_;
};
void World::gameLoop()
{
while (true)
{
// 处理用户输入……
// 更新每个实体
for (int i = 0; i < numEntities_; i++)
{
entities_[i]->update();
}
// 物理和渲染……
}
}
class Statue : public Entity
{
public:
Statue(int delay)
: frames_(0),
delay_(delay)
{}
virtual void update()
{
if (++frames_ == delay_)
{
shootLightning();
// 重置计时器
frames_ = 0;
}
}
private:
int frames_;
int delay_;
void shootLightning()
{
// 火光效果……
}
};
大部分改动是将代码从游戏循环中移动到类中,然后重命名一些东西。但是,在这个例子中,我们真的让代码库变简单了。 先前讨厌的命令式代码中,存在存储每个雕像的帧计数器和开火的速率的分散的本地变量。
大部分改动是将代码从游戏循环中移动到类中,然后重命名一些东西。现在那些都被移动到了Statue类中,你可以想创建多少就创建多少实例了, 每个实例都有它自己的小计时器。
这个模式让我们分离了游戏世界的构建和实现。Unity框架在多个类中使用了这个模式,包括 MonoBehaviour。
类型对象定义行为的类别而无需完成真正的类。 子类沙盒定义各种行为的安全原语。 最先进的是字节码,将行为从代码中分离,放入数据文件中。
将行为编码为虚拟机器上的指令,赋予其数据的灵活性。
-== 运行时修改==
玩家电脑在运行游戏时并不会遍历一堆C++语法结构树。 我们提前将其编译成了机器码,CPU基于机器码运行。
好处:
密集。 它是一块坚实连续的二进制数据块,没有一位被浪费。
线性。 指令被打成包,一条接一条地执行。不会在内存里到处乱跳(除非你的控制流代码真真这么干了)。
底层。 每条指令都做一件小事,有趣的行为从组合中诞生。
速度快。 综合所有这些条件(当然,也包括它直接由硬件实现这一事实),机器码跑得跟风一样快。
将小模拟器称为虚拟机(或简称“VM”),它运行的二进制机器码叫做字节码。 它有数据的灵活性和易用性,但比解释器模式性能更好。
指令集 定义了可执行的底层操作。 一系列的指令被编码为字节序列。 虚拟机 使用 中间值栈 依次执行这些指令。 通过组合指令,可以定义复杂的高层行为。
什么时候使用:
这个模式应当用在你有许多行为需要定义,而游戏实现语言因为如下原因不适用时:
void setHealth(int wizard, int amount);
void setWisdom(int wizard, int amount);
void setAgility(int wizard, int amount);
void playSound(int soundId);
void spawnParticles(int particleType);
enum Instruction
{
INST_SET_HEALTH = 0x00,
INST_SET_WISDOM = 0x01,
INST_SET_AGILITY = 0x02,
INST_PLAY_SOUND = 0x03,
INST_SPAWN_PARTICLES = 0x04
};
对应的操作原语为:
switch (instruction)
{
case INST_SET_HEALTH:
setHealth(0, 100);
break;
case INST_SET_WISDOM:
setWisdom(0, 100);
break;
case INST_SET_AGILITY:
setAgility(0, 100);
break;
case INST_PLAY_SOUND:
playSound(SOUND_BANG);
break;
case INST_SPAWN_PARTICLES:
spawnParticles(PARTICLE_FLAME);
break;
}
用这种方式,解释器建立了沟通代码世界和数据世界的桥梁。可以像这样将其放进执行法术的虚拟机:
class VM
{
public:
void interpret(char bytecode[], int size)
{
for (int i = 0; i < size; i++)
{
char instruction = bytecode[i];
switch (instruction)
{
// 每条指令的跳转分支……
}
}
}
};
解释器模式将其明确地表现为嵌套对象组成的树,但我们需要指令速度达到列表的速度。我们仍然需要确保子表达式的结果正确地向外传递给包括它的表达式。
由于数据是扁平的,我们得使用指令的顺序来控制这一点。
class VM
{
public:
VM()
: stackSize_(0)
{}
// 其他代码……
private:
static const int MAX_STACK = 128;
int stackSize_;
int stack_[MAX_STACK];
};
==字节码虚拟机主要有两种:基于栈的和基于寄存器的。 ==
(Lua是游戏中最广泛应用的脚本语言。 它的内部被实现为一个非常紧凑的,基于寄存器的字节码虚拟机。)
指令较长。 由于指令需要参数记录栈偏移量,单个指令需要更多的位。 例如,一个Lua指令占用完整的32位——它可能是最著名的基于寄存器的虚拟机了。 它采用6位做指令类型,其余的是参数。
Lua作者没有指定Lua的字节码格式,它每个版本都会改变。现在描述的是Lua 5.1。 要深究Lua的内部构造, 读读这个。
指令较少。 由于每个指令可以做更多的工作,你不需要那么多的指令。 有人说,性能会得以提升,因为不需要将值在栈中移来移去了。
创建一个Superpower基类。然后由它派生出各种超级能力的实现类。超能力种类繁多,我们可以预期有很多重叠。 很多超能力都会用相同的方式产生视觉效果并播放声音。超能力种类繁多,我们可以预期有很多重叠。 很多超能力都会用相同的方式产生视觉效果并播放声音。
定义这些操作为Superpower基类的protected方法。 将它们放在基类给了每个子类直接便捷的途径获取方法。 让它们成为protected(很可能不是虚方法)方法暗示了它们存在就是为了被子类调用。一旦有了这些东西来使用,我们需要一个地方使用他们。 为了做到这点,我们定义沙箱方法,这是子类必须实现的抽象的protected方法。
有了这些,要实现一种新的能力,你需要:
通过将耦合约束到一个地方解决了耦合问题。 Superpower最终与不同的系统耦合,但是继承它的几百个类不会。 相反,它们只耦合基类。 当游戏系统的某部分改变时,修改Superpower也许是必须的,但是众多的子类不需要修改。
基类定义抽象的沙箱方法和几个提供的操作。 将操作标为protected,表明它们只为子类所使用。 每个推导出的沙箱子类用提供的操作实现了沙箱函数。
沙箱方法在以下情况适用:
你有一个能推导很多子类的基类。
基类可以提供子类需要的所有操作。
在子类中有行为重复,你想要更容易地在它们间分享代码。
你想要最小化子类和程序的其他部分的耦合。
class Superpower
{
protected:
double getHeroX()
{
// 实现代码……
}
double getHeroY()
{
// 实现代码……
}
double getHeroZ()
{
// 实现代码……
}
// 退出之类的……
};
定义实现子类
class SkyLaunch : public Superpower
{
protected:
virtual void activate()
{
if (getHeroZ() == 0)
{
// 在地面上,冲向空中
playSound(SOUND_SPROING, 1.0f);
spawnParticles(PARTICLE_DUST, 10);
move(0, 0, 20);
}
else if (getHeroZ() < 10.0f)
{
// 接近地面,再跳一次
playSound(SOUND_SWOOP, 1.0f);
move(0, 0, getHeroZ() + 20);
}
else
{
// 正在空中,跳劈攻击
playSound(SOUND_DIVE, 0.7f);
spawnParticles(PARTICLE_SPARKLES, 1);
move(0, 0, -getHeroZ());
}
}
};
传统的面向对象方案:
根据设计,龙是一种怪物,巨魔是另一种,其他品种的也一样。 用面向对象的方式思考,这引导我们创建Monster基类。
class Monster
{
public:
virtual ~Monster() {}
virtual const char* getAttack() = 0;
protected:
Monster(int startingHealth)
: health_(startingHealth)
{}
private:
int health_; // 当前血值
};
每个从Monster派生出来的类都传入起始血量,重载getAttack()返回那个品种的攻击字符串。然后,很奇怪,事情陷入了困境。 设计者最终想要几百个品种,但是我们发现所有的时间都花费在写这些只有七行长的子类和重新编译上。
class Dragon : public Monster
{
public:
Dragon() : Monster(230) {}
virtual const char* getAttack()
{
return "The dragon breathes fire!";
}
};
class Troll : public Monster
{
public:
Troll() : Monster(48) {}
virtual const char* getAttack()
{
return "The troll clubs you!";
}
};
程序员变成了填数据的猴子。 设计者也感到挫败,因为修改一个数据就要老久。 我们需要的是一种无需每次重新编译游戏就能修改品种的状态。 如果设计者创建和修改品种时无需任何程序员的介入那就更好了。
重构代码让每个怪物有品种。 不是让每个品种继承Monster,我们现在有单一的Monster类和Breed类。
这就成了,就两个类。这里完全没有继承。 通过这个系统,游戏中的每个怪物都是Monster的实例。 Breed类包含了在不同品种怪物间分享的信息:开始血量和攻击字符串。
将怪物与品种相关联,给每个Monster实例对包含品种信息的Breed对象的引用。 为了获得攻击字符串,一个怪兽可以调用它品种的方法。 Breed类本质上定义了一个怪物的类型。
模式特别有用的一点是,现在可以定义全新的类型而无需搅乱代码库。 本质上将部分的类型系统从硬编码的继承结构中拉出,放到可以在运行时定义的数据中去。可以通过用不同值实例化Monster来创建成百上千的新品种。 如果从配置文件读取不同的数据初始化品种,我们就有能力完全靠数据定义新怪物品种。
定义类型对象类和有类型的对象类。每个类型对象实例代表一种不同的逻辑类型。 每种有类型的对象保存对描述它类型的类型对象的引用。
实例相关的数据被存储在有类型对象的实例中,被同种类分享的数据或者行为存储在类型对象中。 引用同一类型对象的对象将会像同一类型一样运作。 这让我们在一组相同的对象间分享行为和数据,就像子类让我们做的那样,但没有固定的硬编码子类集合。
Breed基本上只是两个数据字段的容器:起始血量和攻击字符串。
class Breed
{
public:
Breed(int health, const char* attack)
: health_(health),
attack_(attack)
{}
int getHealth() { return health_; }
const char* getAttack() { return attack_; }
private:
int health_; // 初始血值
const char* attack_;
};
class Monster
{
public:
Monster(Breed& breed)
: health_(breed.getHealth()),
breed_(breed)
{}
const char* getAttack()
{
return breed_.getAttack();
}
private:
int health_; // 当前血值
Breed& breed_;
};
建构怪物时,我们给它一个品种对象的引用。 它定义了怪物的品种,取代了之前的子类。 在构造函数中,Monster使用的品种决定了起始血量。 为了获得攻击字符串,怪物简单地将调用转发给它的品种。
更好地适应变化的工具是解耦。 当我们说两块代码“解耦”时,是指修改一块代码一般不会需要修改另一块代码。 当我们修改游戏中的特性时,需要修改的代码越少,就越容易。
组件模式将一个实体拆成多个,解耦不同的领域。 事件序列解耦了两个互相通信的事物,稳定而且及时。 服务定位器让代码使用服务而无需绑定到提供服务的代码。
允许单一的实体跨越多个领域而不会导致这些领域彼此耦合。
组件现在是可复用的包。考虑如果不用组件,我们将如何建立这些类的继承层次。第一遍可能是这样的:
有GameObject基类,包含位置和方向之类的通用部分。 Zone继承它,增加了碰撞检测。 同样,Decoration继承GameObject,并增加了渲染。 Prop继承Zone,因此它可以重用碰撞代码。 然而,Prop不能同时继承Decoration来重用渲染, 否则就会造成致命菱形结构。
现在,让我们尝试用组件。子类将彻底消失。 取而代之的是一个GameObject类和两个组件类:PhysicsComponent和GraphicsComponent。 装饰是个简单的GameObject,包含GraphicsComponent但没有PhysicsComponent。 区域与其恰好相反,而道具包含两种组件。 没有代码重复,没有多重继承,只有三个类,而不是四个。
可以拿饭店菜单打比方。如果每个实体是一个类,那就只能订套餐。 我们需要为每种可能的组合定义各自的类。 为了满足每位用户,我们需要十几种套餐。
组件是照单点菜——每位顾客都可以选他们想要的,菜单记录可选的菜式。
对对象而言,组件是即插即用的。 将不同的可重用部件插入对象,我们就能构建复杂且具有丰富行为的实体。 就像软件中的战神金刚。
单一实体跨越了多个领域。为了保持领域之间相互分离,将每部分代码放入各自的组件类中。 实体被简化为组件的容器。
外部代码提供组件:
-== 通过它们之间相互引用:==
这里的思路是组件有要交流的组件的引用,这样它们直接交流,无需通过容器类。
假设我们想让Bjorn跳跃。图形代码想知道它需要用跳跃图像还是不用。 这可以通过询问物理引擎它当前是否在地上来确定。一种简单的方式是图形组件直接知道物理组件的存在:
class BjornGraphicsComponent
{
public:
BjornGraphicsComponent(BjornPhysicsComponent* physics)
: physics_(physics)
{}
void Update(GameObject& obj, Graphics& graphics)
{
Sprite* sprite;
if (!physics_->isOnGround())
{
sprite = &spriteJump_;
}
else
{
// 现存的图形代码……
}
graphics.draw(*sprite, obj.x, obj.y);
}
private:
BjornPhysicsComponent* physics_;
Sprite spriteStand_;
Sprite spriteWalkLeft_;
Sprite spriteWalkRight_;
Sprite spriteJump_;
};
当构建Bjorn的GraphicsComponent时,我们给它相应的PhysicsComponent引用。
简单快捷。 通信是一个对象到另一个的直接方法调用。组件可以调用任一引用对象的方法。做什么都可以。
两个组件紧绑在一起。 这是做什么都可以带来的坏处。我们向使用整块类又退回了一步。 这比只用单一类好一点,至少我们现在只是把需要通信的类绑在一起。
Unity核心架构中GameObject类完全根据这样的原则设计components。