游戏设计模式

GoF记录的设计模式

一、命令模式

定义:
将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化对请求排队或记录请求日志,以及支持可撤销的操作。

应用一 配置输入

在每个游戏中都有一块代码读取用户的输入。这块代码会获取用户的输入,然后将其变为游戏中有意义的行为:
**游戏设计模式_第1张图片
**

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_;
};
  • 上述代码可以实现一个间接寻址,可以通过修改指针来进行输入按键的配置。
    游戏设计模式_第2张图片

应用二 角色与命令

上述代码可以实现间接寻址,但是必须关联了具体的游戏对象才能进行调用。我们可以通过函数控制将函数控制的角色对象传进去:

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。
游戏设计模式_第3张图片
一些代码(输入控制器或者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_的作用。
支持多重的撤销,就可以不记录单一的最后一条指令,而是记录命令数组。
游戏设计模式_第4张图片
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_;
};
  • 游戏世界中每个树的实例只需有一个对这个共享TreeModel的引用。 留在Tree中的是那些实例相关的数据:
class Tree
{
private:
  TreeModel* model_;

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

游戏设计模式_第5张图片

  • 把共享的数据——TreeModel——只发送一次。 然后,分别发送每个树独特的数据——位置,颜色,大小。 最后,利用GPU instance进行实例渲染。
  • 需要提供两部分数据流。 第一部分是一块需要渲染多次的共同数据——在例子中是树的网格和纹理。 第二部分是实例的列表以及绘制第一部分时需要使用的参数。 然后调用一次渲染,绘制整个森林。
    实例渲染时,每棵树通过总线送到GPU消耗的更多是时间而非内存

地形块

区块的所有状态都是“固有的“,上下文无关的。

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

  // 其他代码……
};

利用对象指针来进行管理,不需要每一块地形分配一块。
游戏设计模式_第6张图片

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);
    }
  }

  // 其他代码…………
};

游戏设计模式_第7张图片
只要一个类管理一列表指向接口实例的指针。

四、原型模式

应用一

众多类,众多引用,众多冗余,众多副本,众多重复自我……
原型模式提供了一个解决方案。 关键思路是一个对象可以产出与它自己相近的对象。 如果你有一个恶灵,你可以制造更多恶灵。 如果你有一个恶魔,你可以制造其他恶魔。 任何怪物都可以被视为原型怪物,产出其他版本的自己。
为了实现这个功能,我们给基类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_;
};

被生产者当做模板,去产生更多一样的怪物。
游戏设计模式_第8张图片
为了得到恶灵生产者,我们创建一个恶灵的原型实例,然后创建拥有这个实例的生产者:

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. 全局变量

  • ==理解代码更加困难。 为了查明发生了什么,得追踪整个代码库来看看什么修改了全局变量。==你真的不需要讨厌全局变量,直到你在凌晨三点使用grep搜索数百万行代码,搞清楚哪一个错误的调用将一个静态变量设为了错误的值。
  • 促进了耦合的发生。你我都知道这不需要将物理和音频代码耦合,但是他只想着把任务完成。 不幸的是,我们的AudioPlayer是全局可见的。 所以之后一个小小的#include,新队员就打乱了整个精心设计的架构。
  • 对并行不友好。 当我们将某些东西转为全局变量时,我们创建了一块每个线程都能看到并访问的内存, 却不知道其他线程是否正在使用那块内存。 这种方式带来了死锁,竞争状态,以及其他很难解决的线程同步问题。

单例模式不是解药,它是安慰剂。 如果浏览全局变量造成的问题列表,你会注意到单例模式解决不了其中任何一个。 因为单例确实是全局状态——它只是被封装在一个类中。

  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);

六、状态模式

1.有限状态机

给英雄每件能做的事情都画了一个盒子:站立,跳跃,俯卧,跳斩。 当角色在能响应按键的状态时,你从那个盒子画出一个箭头,标记上按键,然后连接到她变到的状态。
游戏设计模式_第9张图片

有限状态机(FSMs)的要点:

  • ==拥有状态机所有可能状态的集合。==在我们的例子中,是站立,跳跃,俯卧和速降。
  • ==状态机同时只能在一个状态。==英雄不可能同时处于跳跃和站立状态。事实上,防止这点是使用FSM的理由之一。
  • 连串的输入或事件被发送给状态机。 在我们的例子中,就是按键按下和松开。当输入进来,如果它与当前状态的某个转移相匹配,机器转换为所指的状态。

举个例子,在站立状态时,按下下方向键转换为俯卧状态。 在跳跃时按下下方向键转换为速降。 如果输入在当前状态没有定义转移,输入就被忽视。

2.枚举和分支

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;
  }
}

仍有条件分支,但简化了状态变化,将它变成了字段。 处理同一状态的所有代码都聚到了一起。 这是实现状态机最简单的方法,在某些情况下,这也不错。
假设我们想增加一个动作动作,英雄可以俯卧一段时间充能,之后释放一次特殊攻击。 当她俯卧时,我们需要追踪充能的持续时间。

  • 为Heroine添加了chargeTime_字段,记录充能的时间长度。
void Heroine::update()
{
  if (state_ == STATE_DUCKING)
  {
    chargeTime_++;
    if (chargeTime_ > MAX_CHARGE)
    {
      superBomb();
    }
  }
}
  • 她开始俯卧的时候重置计时器,所以修改handleInput():
void Heroine::handleInput(Input input)
{
  switch (state_)
  {
    case STATE_STANDING:
      if (input == PRESS_DOWN)
      {
        state_ = STATE_DUCKING;
        chargeTime_ = 0;
        setGraphics(IMAGE_DUCK);
      }
      // 处理其他输入……
      break;

      // 其他状态……
  }
}

3.状态模式

  • 定义一个状态接口
    首先,我们为状态定义接口。 状态相关的行为——之前用switch的每一处——都成为了接口中的虚方法。 在我们的例子中,那是handleInput()和update():
class HeroineState
{
public:
  virtual ~HeroineState() {}
  virtual void handleInput(Heroine& heroine, Input input) {}
  virtual void update(Heroine& heroine) {}
};
  • 为每个状态写个类
    对于每个状态,我们定义一个类实现接口。它的方法定义了英雄在状态的行为。 换言之,从之前的switch中取出每个case,将它们移动到状态类中。举个例子:
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类中。 这很好——那部分数据只在这个状态有用。

  • 状态委托
    接下来,向Heroine添加指向当前状态的指针,放弃庞大的switch,转向状态委托:
class Heroine
{
public:
  virtual void handleInput(Input input)
  {
    state_->handleInput(*this, input);
  }

  virtual void update()
  {
    state_->update(*this);
  }

  // 其他方法……
private:
  HeroineState* state_;
};

为了“改变状态”,我们只需要将state_声明指向不同的HeroineState对象。 这就是状态模式的全部了。

4.状态对象

  • 静态状态
    如果状态对象没有其他数据字段, 那么它存储的唯一数据就是指向虚方法表的指针,用来调用它的方法。 在这种情况下,没理由产生多个实例。毕竟每个实例都完全一样。
    如果你的状态没有字段,只有一个虚方法,你可以再简化这个模式。 将每个状态类替换成状态函数——只是一个普通的顶层函数。 然后,主类中的state_字段变成一个简单的函数指针。
    在那种情况下,你可以用一个静态实例。
//定义状态
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);
}
  • 实例化状态
    静态状态对俯卧状态不起作用。 它有一个chargeTime_字段,与正在俯卧的英雄特定相关。 在那种情况下,转换时需要创建状态对象。 这需要每个FSM拥有自己的状态实例。如果我们分配新状态, 那意味着我们需要释放当前的状态。 在这里要小心,由于触发变化的代码是当前状态中的方法,需要删除this,因此需要小心从事。
    相反,我们允许HeroineState中的handleInput()返回一个新状态。 如果它那么做了,Heroine会删除旧的,然后换成新的,就像这样:
//进行状态切换
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;
}

5. 入口行为和出口行为

在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();
  }

  // 其他代码……
}

它做的所有事情就是转换到站立状态,站立状态控制贴图。 现在我们的状态真正地封装了。
关于入口行为的好处就是,当你进入状态时,不必关心你是从哪个状态转换来的。
大多数真正的状态图都有转为同一状态的多个转移。 举个例子,英雄在跳跃或跳斩后进入站立状态。 这意味着我们在转换发生的最后重复相同的代码。 入口行为很好地解决了这一点。
我们能扩展并支持出口行为。 这是在我们离开现有状态,转换到新状态之前调用的方法。

6.并发状态机

我们决定赋予英雄拿枪的能力。 当她拿着枪的时候,她还是能做她之前的任何事情:跑动,跳跃,跳斩,等等。 但是她在做这些的同时也要能开火。多加几种武器,状态就会指数爆炸。 不但增加了大量的状态,也增加了大量的冗余: 持枪和不持枪的状态是完全一样的,只是多了一点负责射击的代码。

问题在于我们将两种状态绑定到了一个状态机上——她做的和她携带的。 == 为了处理所有可能的组合,我们需要为每一对组合写一个状态。修复方法很明显:使用两个单独的状态机。==

  • 两个状态引用
class Heroine
{
  // 其他代码……

private:
  HeroineState* state_;
  HeroineState* equipment_;
};

当英雄把输入委托给了状态,两个状态都需要委托:

void Heroine::handleInput(Input input)
{
  state_->handleInput(*this, input);
  equipment_->handleInput(*this, input);
}

每个状态机之后都能响应输入,发生行为,独立于其它机器改变状态。 当两个状态集合几乎没有联系的时候,它工作得不错。
在实践中,你会发现状态有时需要交互。 举个例子,也许她在跳跃时不能开火,或者她在持枪时不能跳斩攻击。 为了完成这个,你也许会在状态的代码中做一些粗糙的if测试其他状态来协同。

7.分层状态机

分层状态机的通用结构。== 状态可以有父状态(这让它变为子状态)。 当一个事件进来,如果子状态没有处理,它就会交给链上的父状态。 ==换言之,它像重载的继承方法那样运作。

  • 使用继承来实现层次。 定义一个基类作为父状态:
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);
    }
  }
};

栈顶的状态是当前状态,在他下面是它的直接父状态, 然后是那个父状态的父状态,以此类推。 当你需要状态的特定行为,你从栈的顶端开始, 然后向下寻找,直到某一个状态处理了它。(如果到底也没找到,就无视它。)

8.下推自动机

还有一种有限状态机的扩展也用了状态栈。 容易混淆的是,这里的栈表示的是完全不同的事物,被用于解决不同的问题。
要解决的问题是有限状态机没有任何历史的概念。 你记得正在什么状态中,但是不记得曾在什么状态。 没有简单的办法重回上一状态。
举个例子:早先,我们让无畏英雄武装到了牙齿。 当她开火时,我们需要新状态播放开火动画,发射子弹,产生视觉效果。 所以我们拼凑了一个FiringState,不管现在是什么状态,都能在按下开火按钮时跳转为这个状态。如果我们固执于纯粹的FSM,我们就已经忘了她之前所处的状态。 为了追踪之前的状态,我们定义了很多几乎完全一样的类——站立开火,跑步开火,跳跃开火,诸如此类—— 每个都有硬编码的转换,用来回到之前的状态。
真正想要的是,它会存储开火前所处的状态,之后能回想起来。 自动理论又一次能帮上忙了,相关的数据结构被称为下推自动机。

有限状态机有一个指向状态的指针,下推自动机有一栈指针。 在FSM中,新状态代替了之前的那个状态。 下推自动机不仅能完成那个,还能给你两个额外操作:

  • 你可以将新状态压入栈中。“当前的”状态总是在栈顶,所以你能转到新状态。 但它让之前的状态待在栈中而不是销毁它。
  • 你可以弹出最上面的状态。这个状态会被销毁,它下面的状态成为新状态。
    游戏设计模式_第10张图片
    我们创建单一的开火状态。 当开火按钮在其他状态按下时,我们压入开火状态。 当开火动画结束,我们弹出开火状态,然后下推自动机自动转回之前的状态。

有限状态机在以下情况有用:

  • 你有个实体,它的行为基于一些内在状态。
  • 状态可以被严格地分割为相对较少的不相干项目。
  • 实体响应一系列输入或事件。

序列模式

大多数游戏世界都有的特性是时间——虚构世界以其特定的节奏运行。 作为世界的架构师,我们必须发明时间,制造推动游戏时间运作的齿轮。
游戏循环是时钟的中心轴。 对象通过更新方法来聆听时钟的滴答声。 我们可以用双缓冲模式存储快照来隐藏计算机的顺序执行,这样看起来世界可以进行同步更新。

一、双缓冲模式

用序列的操作模拟瞬间或者同时发生的事情。

1. 图形学里面的双缓冲

定义缓冲类封装了缓冲:一段可改变的状态。 这个缓冲被增量地修改,但我们想要外部的代码将修改视为单一的原子操作。 为了实现这点,类保存了两个缓冲的实例:下一缓冲和当前缓冲。

当信息从缓冲区中读取,它总是读取当前的缓冲区。 当信息需要写到缓存,它总是在下一缓冲区上操作。 当改变完成后,一个交换操作会立刻将当前缓冲区和下一缓冲区交换, 这样新缓冲区就是公共可见的了。旧的缓冲区成为下一个重用的缓冲区。

2.何时使用

  • 我们需要维护一些被增量修改的状态。
  • 在修改到一半的时候,状态可能会被外部请求。
  • 我们想要防止请求状态的外部代码知道内部的工作方式。
  • 我们想要读取状态,而且不想等着修改完成。

3.不仅仅是人工智能

不是保存两大块“缓冲”,而是缓冲更小粒度的事物:每个角色的“被扇”状态。

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()函数现在更新所有的角色,然后 交换它们的状态。 最终结果是,角色在实际被扇之后的那帧才能看到巴掌。 这样一来,角色无论在舞台数组中如何排列,都会保持相同的行为。 无论外部的代码如何调用,所有的角色在一帧内同时更新。

二、游戏循环

将游戏的进行和玩家的输入解耦,和处理器速度解耦。

游戏循环是“游戏编程模式”的精髓。 几乎每个游戏都有,两两不同,而在非游戏的程序几乎没有使用。

1.事件循环

游戏循环的第一个关键部分:它处理用户输入,但是不等待它。循环总是继续旋转:

while (true)
{
  processInput();
  update();
  render();

另一个关键任务:不管潜在的硬件条件,以固定速度运行游戏。

2.模式

一个游戏循环在游玩中不断运行。 每一次循环,它无阻塞地处理玩家输入,更新游戏状态,渲染游戏。 它追踪时间的消耗并控制游戏的速度。使用错误的模式比不使用模式更糟,所以这节通常告诫你不要过于热衷设计模式。 设计模式的目标不是往代码库里尽可能的塞东西。
你可能认为在做回合制游戏时不需要它。 但是哪怕是那里,就算游戏状态到玩家回合才改变,视觉和听觉 状态仍会改变。 哪怕游戏在“等待”你进行你的回合,动画和音乐也会继续运行。
游戏循环驱动了AI,渲染和其他游戏系统,但这些不是模式的要点, 所以我们会调用虚构的方法。

while (true)
{
  processInput();
  update();
  render();

// sleep(start + MS_PER_FRAME - getCurrentTime()); 如果游戏处理太快,就使用sleep()减慢游戏运行速度
}

假设你想要你的游戏以60FPS运行。这样每帧大约16毫秒。 只要你用少于这个的时长进行游戏所有的处理和渲染,就可以以稳定的帧率运行。 你需要做的就是处理这一帧然后等待,直到处理下一帧的时候。

游戏设计模式_第11张图片

游戏中渲染通常不会被动态时间间隔影响到。 由于渲染引擎表现的是时间上的一瞬间,它不会计算上次到现在过了多久。 它只是将当前事物渲染在所在的地方。
在每帧的开始,根据过去了多少真实的时间,更新lag。 这个变量表明了游戏世界时钟比真实世界落后了多少,然后我们使用一个固定时间步长的内部循环进行追赶。 一旦我们追上真实时间,我们就渲染然后开始新一轮循环。游戏设计模式_第12张图片

在示例代码中提到的最后一个选项是最复杂的,但是也是最有适应性的。 它以固定时间步长更新,但是如果需要赶上玩家的时间,可以扔掉一些渲染帧。

能适应并调整,避免运行得太快或者太慢。 只要能实时更新,游戏状态就不会落后于真实时间。如果玩家用高端的机器,它会回以更平滑的游戏体验。

更复杂。 主要负面问题是需要在实现中写更多东西。 你需要将更新的时间步长调整得尽可能小来适应高端机,同时不至于在低端机上太慢。

unity生命周期

三、更新方法

意图:

通过每次处理一帧的行为模拟一系列独立对象。

每个游戏实体应该封装它自己的行为,这就需要抽象层,我们通过定义抽象的update()方法来完成。 游戏循环管理对象的集合,但是不知道对象的具体类型。 它只知道这些对象可以被更新。 这样,每个对象的行为与游戏循环分离,与其他对象分离。 游戏循环管理对象的集合,但是不知道对象的具体类型。 它只知道这些对象可以被更新。 这样,每个对象的行为与游戏循环分离,与其他对象分离。

模式:

游戏世界管理对象集合。 每个对象实现一个更新方法模拟对象在一帧内的行为。每一帧,游戏循环更新集合中的每一个对象。

何时使用

更新方法适应以下情况:

  • 游戏有很多对象或系统需要同时运行。

  • 每个对象的行为都与其他的大部分独立。

  • 对象需要跟着时间进行模拟。

示例代码

  • 定义Entity类
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();
    }

    // 物理和渲染……
  }
}

实体

  1. 子类化实体
    多用“对象组合”,而非“类继承”。
    解决方案是使用组件模式。 使用它,update()是实体的组件而不是在Entity中。 这让你避开了为了定义和重用行为而创建实体所需的复杂类继承层次。相反,你只需混合和组装组件。
    最少牵连其他部分的介绍方法, 就是把更新方法放在Entity中然后创建一些子类。
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。

行为模式

类型对象定义行为的类别而无需完成真正的类。 子类沙盒定义各种行为的安全原语。 最先进的是字节码,将行为从代码中分离,放入数据文件中。

一、字节码

意图

将行为编码为虚拟机器上的指令,赋予其数据的灵活性。

实用场景

-== 运行时修改==

  • 发售后修改
  • 给玩家开发MOD

让文件中的数据表示为行为

玩家电脑在运行游戏时并不会遍历一堆C++语法结构树。 我们提前将其编译成了机器码,CPU基于机器码运行。

好处:

  • 密集。 它是一块坚实连续的二进制数据块,没有一位被浪费。

  • 线性。 指令被打成包,一条接一条地执行。不会在内存里到处乱跳(除非你的控制流代码真真这么干了)。

  • 底层。 每条指令都做一件小事,有趣的行为从组合中诞生。

  • 速度快。 综合所有这些条件(当然,也包括它直接由硬件实现这一事实),机器码跑得跟风一样快。

将小模拟器称为虚拟机(或简称“VM”),它运行的二进制机器码叫做字节码。 它有数据的灵活性和易用性,但比解释器模式性能更好。

模式

指令集 定义了可执行的底层操作。 一系列的指令被编码为字节序列。 虚拟机 使用 中间值栈 依次执行这些指令。 通过组合指令,可以定义复杂的高层行为。

什么时候使用:

这个模式应当用在你有许多行为需要定义,而游戏实现语言因为如下原因不适用时:

  • 过于底层,繁琐易错。
  • 编译慢或者其他工具因素导致迭代缓慢。
  • 安全性依赖编程者。如果想保证行为不会破坏游戏,你需要将其与代码的其他部分隔开。

示例代码

  1. 法术的API
  • 定义状态
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);
  • 法术指令集
    把这种程序化的API转化为可被数据控制的东西。一个法术就只是一系列指令了。 每条指令都代表了想要呈现的操作。可以枚举如下:
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];
};

设计决策

==字节码虚拟机主要有两种:基于栈的和基于寄存器的。 ==

基于栈的虚拟机:
  • 指令短小。 由于每个指令隐式认定在栈顶寻找参数,不需要为任何数据编码。 这意味着每条指令可能会非常短,一般只需一个字节。
  • 易于生成代码。 当你需要为生成字节码编写编译器或工具时,你会发现基于栈的字节码更容易生成。 由于每个指令隐式地在栈顶工作,你只需要以正确的顺序输出指令就可以在它们之间传递参数。
  • 会生成更多的指令。 每条指令只能看到栈顶。这意味着,产生像a = b + c这样的代码, 你需要单独的指令将b和c压入栈顶,执行操作,再将结果压入a。
基于寄存器的虚拟机:

(Lua是游戏中最广泛应用的脚本语言。 它的内部被实现为一个非常紧凑的,基于寄存器的字节码虚拟机。)

  • 指令较长。 由于指令需要参数记录栈偏移量,单个指令需要更多的位。 例如,一个Lua指令占用完整的32位——它可能是最著名的基于寄存器的虚拟机了。 它采用6位做指令类型,其余的是参数。
    Lua作者没有指定Lua的字节码格式,它每个版本都会改变。现在描述的是Lua 5.1。 要深究Lua的内部构造, 读读这个。

  • 指令较少。 由于每个指令可以做更多的工作,你不需要那么多的指令。 有人说,性能会得以提升,因为不需要将值在栈中移来移去了。

二、子类沙箱

问题

创建一个Superpower基类。然后由它派生出各种超级能力的实现类。超能力种类繁多,我们可以预期有很多重叠。 很多超能力都会用相同的方式产生视觉效果并播放声音。超能力种类繁多,我们可以预期有很多重叠。 很多超能力都会用相同的方式产生视觉效果并播放声音。

解决方法

定义这些操作为Superpower基类的protected方法。 将它们放在基类给了每个子类直接便捷的途径获取方法。 让它们成为protected(很可能不是虚方法)方法暗示了它们存在就是为了被子类调用。一旦有了这些东西来使用,我们需要一个地方使用他们。 为了做到这点,我们定义沙箱方法,这是子类必须实现的抽象的protected方法。
有了这些,要实现一种新的能力,你需要:

  • 创建从Superpower继承的新类。
  • 重载沙箱方法activate()。
  • 通过调用Superpower提供的protected方法实现主体。

通过将耦合约束到一个地方解决了耦合问题。 Superpower最终与不同的系统耦合,但是继承它的几百个类不会。 相反,它们只耦合基类。 当游戏系统的某部分改变时,修改Superpower也许是必须的,但是众多的子类不需要修改。

模式定义

基类定义抽象的沙箱方法和几个提供的操作。 将操作标为protected,表明它们只为子类所使用。 每个推导出的沙箱子类用提供的操作实现了沙箱函数。

何时使用

沙箱方法在以下情况适用:

  • 你有一个能推导很多子类的基类。

  • 基类可以提供子类需要的所有操作。

  • 在子类中有行为重复,你想要更容易地在它们间分享代码。

  • 你想要最小化子类和程序的其他部分的耦合。

示例代码

  • Superpower基类
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类。
游戏设计模式_第13张图片

这就成了,就两个类。这里完全没有继承。 通过这个系统,游戏中的每个怪物都是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使用的品种决定了起始血量。 为了获得攻击字符串,怪物简单地将调用转发给它的品种。

解耦模式

更好地适应变化的工具是解耦。 当我们说两块代码“解耦”时,是指修改一块代码一般不会需要修改另一块代码。 当我们修改游戏中的特性时,需要修改的代码越少,就越容易。
组件模式将一个实体拆成多个,解耦不同的领域。 事件序列解耦了两个互相通信的事物,稳定而且及时。 服务定位器让代码使用服务而无需绑定到提供服务的代码。

一、组件模式

允许单一的实体跨越多个领域而不会导致这些领域彼此耦合。

动机

组件现在是可复用的包。考虑如果不用组件,我们将如何建立这些类的继承层次。第一遍可能是这样的:
游戏设计模式_第14张图片
有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里的EC

Unity核心架构中GameObject类完全根据这样的原则设计components。

二、事件队列

优化模式

对象池模型

  • 意图:
    放弃单独地分配和释放对象,从固定的池中重用对象,以提高性能和内存使用率
  • 技术产生背景:
    创建和销毁这些粒子在堆中的空余空间被打碎成了很多小的内存碎片,而不是大的连续内存块。
  • 原理:
    对内存管理器,需要将一大块内存分出来,保持在游戏运行时不释放它。 对于池的使用者,我们可以简单地构造析构我们想要的内容对象。
  • 模式:
    定义一个池对象,并包含一组可重用对象。 每个可重用对象都支持查询“使用中”状态,说明它是不是“正在使用”。
    当你需要新对象,向池子要一个。 它找到一个可用对象,初始化为“使用中”然后返回。 当对象不再被需要,它被设置回“不在使用中”。

你可能感兴趣的:(工作整理,游戏,设计模式,命令模式)