游戏设计模式

Posted by infinityyf on August 3, 2020

前言:评价架构设计的好坏就是评价它应对改动有多容易,架构的目标就是最小化在编写代码前需要了解的信息

1.命令模式 2.享元模式 3.观察者模式 4.原型模式 5.单例模式 6.状态模式 7.双缓冲模式 8.游戏循环

9.更新方法 10.字节码

11.子类沙箱 12.类型对象

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();
  //如果经常按b就会每次进行四次判断
}

但是现在有一个需求,就是支持用户改键。
定义一个命令类,记录可以触发的游戏行为:

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_;//就是按键B这里就和一个命令进行了绑定,之后还可以修改
};

之后的逻辑就变成了:

void InputHandler::handleInput()
{
  if (isPressed(BUTTON_X)) buttonX_->execute();
  else if (isPressed(BUTTON_Y)) buttonY_->execute();
  else if (isPressed(BUTTON_A)) buttonA_->execute();
  else if (isPressed(BUTTON_B)) buttonB_->execute();
}

现在还有一个问题,就是行为是去找角色进行执行的,我们希望角色自己去找命令执行,所有这里就需要把角色的引用传递给一个函数

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;
}
Command* command = inputHandler.handleInput();
if (command)
{
  command->execute(actor);
}

将命令和角色解耦,方便AI系统对其他角色进行指挥

1.1 撤销与重做

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

将命令用一个队列存储可以实现撤销

补充

持久化数据结构:发生改变时,保存之前版本的数据结构,对数据进行操作时,不会在院数据上更新,而是生成新的数据再修改。例如net中的string,一旦创建了一个String类型实例,它便不能被改变了,对于欲改变其值的任何操作都将被产生一个新的String对象,通过这样,每一个版本的String实例都将被驻留下来。
然而持久化的数据结构会带来一些开销,任何改变持久化数据结构的操作都将创建一个新的版本,这可能会涉及到大量的拷贝操作,通常我们可以通过重用旧版本对象的内部数据结构来创建一个新的对象,这种办法可以极大地降低拷贝操作所带来的消耗(公用重复的部分数据)

2.享元模式

很多对象有共有的数据,例如森林中的树,可以把mesh数据单独抽出来:(纯粹为了提高效率)

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 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_;
};
//每个区块指向对应的地形,好处在于对于重复的区块可以指向同一个地形
class World
{
private:
  Terrain* tiles_[WIDTH][HEIGHT];

  // 其他代码……
};

但是为了动态分配的方便,直接在world类中进行存储:

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

3.观察者模式

只是在某件事情发生的时候发出通知,而不用关心是谁接受到了通知。 观察者:

class Observer
{
public:
  virtual ~Observer() {}
  virtual void onNotify(const Entity& entity, Event event) = 0;
};

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

被观察者则需要记录两个信息,一个是它的观察者列表(需要一个公开的api去增减这个列表),一个是发送通知:

class Subject
{
protected:
  void notify(const Entity& entity, Event event)
  {
    for (int i = 0; i < numObservers_; i++)
    {
      observers_[i]->onNotify(entity, event);
    }
  }

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

需要注意的是,这样的机制是同步的,观察者的方法处理完之后,被观察者才能继续工作。所以最好使用事件队列进行异步的处理。

还有一个问题,就是内存的动态分配,主要是对于观察者列表。如何避免动态的内存分配: 可以使用链式观察者: 将观察者的列表分布到观察者自己种来解决动态分配。取消数组,而是记录一个观察者列表的头部指针:

class Subject
{
  Subject()
  : head_(NULL)
  {}

  // 方法……
private:
  Observer* head_;
};

//在观察者种添加一个指向下一观察者的指针
class Observer
{
  friend class Subject;//是subject的友元,这样就可以增减观察者列表

public:
  Observer()
  : next_(NULL)
  {}

  // 其他代码……
private:
  Observer* next_;
};

void Subject::addObserver(Observer* observer)
{
  observer->next_ = head_;
  head_ = observer;
}

但是这样,一个观察者只能被一个被观察者记录,可以通过链表节点池来解决这个问题。每个被观察者有一个链表的观察者,但是链表节点不是观察者本身,而是分散的小的链表节点对象,这个对象包含了指向观察者的指针和指向链表下一个节点的指针。这样每个链表就可以独立的存储观察者的地址。使得观察者可以被重复引用。

链表节点池

3.1销毁观察者或者被观察者

可能导致被观察者指向一个空地址。所以析构的时候,自动取消注册

4.原型模式

一个对象可以产出与它自己相近的对象,以monster例:

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();//通过clone方法产生实例,使用父类的指针,但是还是调用子类的方法(多态)
  }

private:
  Monster* prototype_;//充当模板
};

Monster* ghostPrototype = new Ghost(15, 3);
Spawner* ghostSpawner = new Spawner(ghostPrototype);

一种更简洁的写法:

//定义各种生产函数
Monster* spawnGhost()
{
  return new Ghost();
}

//生产类存储一个函数指针
typedef Monster* (*SpawnCallback)();

class Spawner
{
public:
  Spawner(SpawnCallback spawn)
  : spawn_(spawn)
  {}

  Monster* spawnMonster()
  {
    return spawn_();
  }

private:
  SpawnCallback spawn_;//一个函数指针,每次调用都会产生一个实例
};

//生成ghost
Spawner* ghostSpawner = new Spawner(spawnGhost);//spawnGhost是一个函数指针

5.单例模式

保证一个类只有一个实例,并且提供了访问该实例的全局访问点。这里说一个常常被忽略的优点,就是单例的可继承性。例如跨平台的文件系统,基类可以写成:

class FileSystem
{
public:
  static FileSystem& instance();//使用父类的引用,这就是多态的精髓,仔细体会

  virtual ~FileSystem() {}
  virtual char* readFile(char* path) = 0;
  virtual void  writeFile(char* path, char* contents) = 0;

protected:
  FileSystem() {}
};
//各个平台的子类
class PS3FileSystem : public FileSystem
{
public:
  virtual char* readFile(char* path)
  {
    // 使用索尼的文件读写API……
  }

  virtual void writeFile(char* path, char* contents)
  {
    // 使用索尼的文件读写API……
  }
};

class WiiFileSystem : public FileSystem
{
public:
  virtual char* readFile(char* path)
  {
    // 使用任天堂的文件读写API……
  }

  virtual void writeFile(char* path, char* contents)
  {
    // 使用任天堂的文件读写API……
  }
};

//创建实例的时候
FileSystem& FileSystem::instance()
{
  #if PLATFORM == PLAYSTATION3
    static FileSystem *instance = new PS3FileSystem();
  #elif PLATFORM == WII
    static FileSystem *instance = new WiiFileSystem();
  #endif

  return *instance;
}

但是使用单例会促进耦合的发生,就是由于单例的全局可见,别的功能可能直接一个include就用了这个单例产生了耦合,所以最好可以控制单例的访问。

防止被全局访问:

class FileSystem
{
public:
  FileSystem()
  {
    assert(!instantiated_);
    instantiated_ = true;
  }

  ~FileSystem() { instantiated_ = false; }

private:
  static bool instantiated_;
};

bool FileSystem::instantiated_ = false;
//如果试图构建超过一个实例就会断言失败

使用静态类

6.状态模式

对于角色复杂的行为变化,使用有限状态机来进行状态的转移,通过枚举记录所有的状态:

enum State
{
  STATE_STANDING,
  STATE_JUMPING,
  STATE_DUCKING,
  STATE_DIVING
};
//再根据状态和输入进行状态的转移

进一步可以使用状态类,为每个状态实现自己的接口,然后在角色中存储当前状态的指针就好了,如何修改状态: 使用静态状态:

class HeroineState
{
public:
  static StandingState standing;//每一个是一个静态类,里面定义了处在此状态下根据输入进行的行为和状态转移
  static DuckingState ducking;
  static JumpingState jumping;
  static DivingState diving;

  // 其他代码……
};

实例化状态:每个状态的切换都使用对象的释放和生成 入口行为和出口行为: 将行为写进每个状态内,而不是写在状态转移的过程中 并发状态机: 需要多种状态的叠加,例如奔跑的时候还能射击,可以实现多个状态机,让角色携带,根据输入,两个都进行更新。

下推自动机

下推自动机有一个栈指针,在新状态代替旧状态之后,还会将新状态入栈,新状态结束后自动恢复旧状态。

7.双缓冲模式

用序列操作模拟瞬间或者同时发生的事情。使用下一缓冲和当前缓冲,交换的时候必须使用原子操作。这种模式不局限在图形中,当另一个线程的代码直接访问状态就可以使用。在物理,AI等部分也有应用。

以AI系统为例:

class Actor
{
public:
  Actor() : slapped_(false) {}

  virtual ~Actor() {}
  virtual void update() = 0;

  void reset()      { slapped_ = false; }
  void slap()       { slapped_ = true; }
  bool wasSlapped() { return slapped_; }

private:
  bool slapped_;
};

每一帧都需要调用所有角色的update,而且对于玩家应该是同时进行的。但是实际上它们是依次更新的。 舞台类:

class Stage
{
public:
  void add(Actor* actor, int index)
  {
    actors_[index] = actor;
  }

  void update()
  {
    for (int i = 0; i < NUM_ACTORS; i++)
    {
      actors_[i]->update();
      actors_[i]->reset();//每个角色被slap后,马上没复原
    }
  }

private:
  static const int NUM_ACTORS = 3;

  Actor* actors_[NUM_ACTORS];
};

假设场景中有三个人,分别用两个顺序进行update,先让harry被slap。结果就会不同,因为这个事件发生的过程并不是按照我们设定的顺序进行的,而程序中就只能按照代码设定的顺序执行(问题的形状不从任何一个地方开始the shape of the problem doesn’t start anywhere)

//第一个
Stage stage;

Comedian* harry = new Comedian();
Comedian* baldy = new Comedian();
Comedian* chump = new Comedian();

harry->face(baldy);
baldy->face(chump);
chump->face(harry);

stage.add(harry, 0);
stage.add(baldy, 1);
stage.add(chump, 2);
harry->slap();

stage.update();
//harry的slap状态正确的被传递下来
//第二个
Stage stage;

Comedian* harry = new Comedian();
Comedian* baldy = new Comedian();
Comedian* chump = new Comedian();

harry->face(baldy);
baldy->face(chump);
chump->face(harry);

stage.add(harry, 2);
stage.add(baldy, 1);
stage.add(chump, 0);
harry->slap();

stage.update();
//由于更新不是从harry开始,所有状态阻塞在harry没有被传递出来

可以缓冲slap的状态当前状态用于读,下一个状态用于写,slap之后就不用reset而是swap状态

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

好好想想

8.游戏循环

将游戏的进行和玩家的输入、处理器速度解耦。 需要无阻塞的处理输入,更新游戏。

//基本
while (true)
{
  processInput();
  update();
  render();
}

如果需要游戏以60FPS的速率更新,就要强迫循环等到16ms

while (true)
{
  double start = getCurrentTime();
  processInput();
  update();
  render();

  sleep(start + MS_PER_FRAME - getCurrentTime());
}

但是在多人游戏中又会出现问题,性能好的机器迭代的帧数会超过老机器。需要计算真实世界过去多少事件,在游戏世界中尽量去追赶这个事件

double previous = getCurrentTime();
double lag = 0.0;
while (true)
{
  double current = getCurrentTime();
  double elapsed = current - previous;
  previous = current;
  lag += elapsed;

  processInput();

  while (lag >= MS_PER_UPDATE)//延迟太多就多更新几次(MS_PER_UPDATE也不能设置的太短)
  {
    update();
    lag -= MS_PER_UPDATE;
  }

  render();
}

9.更新方法

通过每次处理一帧的行为模拟一系列独立对象(双缓冲是模拟同时发生) 将不同实体的行为封装起来使用update来更新。

需要注意对象更新的顺序,是否符合自己的需求。

10.字节码

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

如果将数值写在代码中,更新一下就要修改很多东西,或者是打补丁的操作。将数据抽出来,程序读取并解析它们。通过解释器模式进行工作。更好的方式是自己定义字节码,可以直接运行在一个小型的虚拟机上。例如lua的使用。

11.子类沙箱

用一系列由基类提供的操作定义子类的行为

将函数定义为protected的含义就是它们将会被子类调用

但是不要给基类提供太多庞杂的方法。

如何让基类获取它所需要的状态

  1. 通过构造方法输入参数:但是这样每个子类都需要提供这个参数。可以使用两个阶段的初始化:初始化后再赋予状态。
  2. 让状态静态化:只要初始化一次就全部实例可见
  3. 使用服务定位器:

12.类型对象

创造一个类,来灵活的创造新类型,类的实例都代表了不同的对象类型。

传统的面向对象方法:

class Monster
{
public:
  virtual ~Monster() {}
  virtual const char* getAttack() = 0;

protected:
  Monster(int startingHealth)
  : health_(startingHealth)
  {}

private:
  int health_; // 当前血值
};

//子类
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,不断重读写以上的代码

为类型构建类: 创建一个breed类,每个monster去引用一个breed,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_;
};