一,前言
cocos2dx4.0 c++游戏移植--Apple的学习笔记里面我移植了一个2.2.6cocos2dx到当前的4.0引擎,因为买的cocos2dx游戏code不是什么大制作,所以只能给我提供用cocos2dx的c++ api进行小游戏开发的思路。但是无法让我学习到c++面向对象的设计模式在游戏中的应用,说明这个源码只是入门级别的,不算游戏的可复用的游戏源码。虽然我第一个目标完成了,知道了如何做游戏,但是我的第二个目标学习设计模式在游戏项目中的应用,直接看这样的小代码,是学习不到的,还好网上找了不错的书籍<游戏编程精粹>能满足我的需求。
二,游戏中设计模式小结
之前网上了解到游戏设计中的设计模式都会去看GOF,但是GOF通过我搜索到的资料来看,怎么感觉和我之前看的非游戏类的设计模式描述的是一样的,没有结合游戏中的功能去探讨,仅仅说的是面向对象的设计模式。但是看到<游戏编程精粹>后突然感觉很有代入感,能让我看进去。
2.1 命令模式
将行为请求者和行为实现者解耦,不直接打交道。示例中说明的是按4种不同的按键则执行不同的动作,使用了命令模式则添加了命令处理类,命令请求给到命令处理类,由命令处理类分配到具体的动作。特别是游戏中的undo返回上一次命令操作是很有用的。
然后网上看到关于命令模式的解耦举例,说业务员直接命令程序员很多类的事情。使用了命令模式后,就添加了项目经理类,然后业务员直接向项目经理发布任务,然后项目经理再像程序员下达任务。这样业务人员就不认识程序员,这样就达到了解构的目的。这也是典型的命令模式的应用,蛮好玩的。
2.2 享元模式
游戏中的地图场景中包括很多元素,比如草地,树,湖泊等,这些元素可能颜色和长度,位置等不同,而可以抽象出它的模型,这些模型配合上颜色和长度和位置等参数时候就可以呈现出多态。但是这里说的好像是数据结构,不是设计模式,继续看下去。里面举例在不同的位置绑定了不同的地图元素,然后world对象要获取此位置是哪类地图元素,简单的写法如下。
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;
// 其他地形……
}
}
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;
// 其他地形……
}
}
然后通过享元模式后。Terrain就是抽象出来的模型。而
grassTerrain_(1, false, GRASS_TEXTURE),``hillTerrain_(3, false, HILL_TEXTURE)
和riverTerrain_(2, true, RIVER_TEXTURE)
就是在抽象模型中传入不同参数构造出的多态。
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对象。用这种方式,World不再与各种地形的细节耦合。
const Terrain& World::getTile(int x, int y) const
{
return *tiles_[x][y];
}
如果你想要某一区块的属性,可直接从那个对象模型获得:int cost = world.getTile(2, 3).getMovementCost();
而这里的对象Terrain就是之前说的抽象出来的共同点元素类。这个抽象蛮厉害的,原来河流和山川都可以有共同点,这是因为从绘图的角度来说,它的网格和纹理可被抽象,不同的网格和纹理构造不同的河流和山川。
另外我又看了网上关于享元模式的讲解,对应相同对象要实例化很多,则浪费内存,所以对应已存在对象,则不再创建。但是这样的享元模式用法是无法区别每个相同的类别的对象的。
//该方法提供共享功能
public AbstractHero getHero(String name){
AbstractHero hero = heroMap.get(name);
if (hero == null) {
if (name.equals("恶魔巫师")) {
hero = new Lion();
}else if (name.equals("影魔")) {
hero = new SF();
}
heroMap.put(name, hero);
}
return hero;
}
2.3 观察者模式
这个我接触的最多,因为c代码中也流行这样的设计模式。cocos事件监听用的就是这个方法,我自己做的c代码工程中初始化example子系统中的每个模块,就和他类似,只是我的应用场景中没有update和notify功能,哈哈~只是初始化实现链表发方式自动注册。
这里重点说明的观察者和被观察者是解构的,我一开始看到被观察者依赖了Observer,为什么说解耦了呢!再细心看看class Achievements : public Observer
所以Achievements才是被观察者,所以文中描述的观察者和被观察者是解耦的,这句是正确的。
class Subject
{
public:
void addObserver(Observer* observer)
{
// 添加到数组中……
}
void removeObserver(Observer* observer)
{
// 从数组中移除……
}
// 其他代码……
};
2.4 原型模式
关于原型模式,现代c++都添加模板类了,所以忽略吧,他的引入是生产器,生产怪物。
class Spawner
{
public:
virtual ~Spawner() {}
virtual Monster* spawnMonster() = 0;
};
class GhostSpawner : public Spawner
{
public:
virtual Monster* spawnMonster()
{
return new Ghost();
}
};
class DemonSpawner : public Spawner
{
public:
virtual Monster* spawnMonster()
{
return new Demon();
}
};
// 你知道思路了……
根据原型模式则修改为clone原型
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);
现代c++都用模板类
class Spawner
{
public:
virtual ~Spawner() {}
virtual Monster* spawnMonster() = 0;
};
template <class T>
class SpawnerFor : public Spawner
{
public:
virtual Monster* spawnMonster() { return new T(); }
};
2.5 单例模式
单例模式我很熟悉,这本书是不推荐使用单例模式,这个理由是它有优点有缺点,看你怎么用,所以管理类可以有单例模式来创建,比如文件系统对象的管理类,log系统对象管理类。cocos2dx源码中的单例模式用的也蛮多的,难道他就不怕并行开发中的资源抢占吗?
使用方法Spawner* ghostSpawner = new SpawnerFor<Ghost>();
。
2.6 状态模式
这个状态模式很熟悉了,但是看了这章节内容,发现原来2个组合的状态动作可以用2个状态类同时调用,还有另外一种方法就是继承父状态,子状态没有动作的时候,就执行父类的动作。另外多个组合可以有状态栈的思路。
2.7 双缓冲模式
本章开始讲解的是序列模式,本节一开始就描述了渲染和显示的framebuffer要用2个,否则就会出现显示帧出错的现象,这不是底层驱动要做的吗?怎么和应用层逻辑中要关心的呢!接着向下看,原来它只是为了引出主题,就是在写的过程中取读,则会出错。后来就举例游戏中在写的过程中被中断或者多线程打断,则在写状态的或者中取读状态,则出错。
如下为针对framebuffer读写的双缓存设计,重点就在这个swap();
class Scene
{
public:
Scene()
: current_(&buffers_[0]),
next_(&buffers_[1])
{}
void draw()
{
next_->clear();
next_->draw(1, 1);
// ...
next_->draw(4, 3);
swap();
}
Framebuffer& getBuffer() { return *current_; }
private:
void swap()
{
// 只需交换指针
Framebuffer* temp = current_;
current_ = next_;
next_ = temp;
}
Framebuffer buffers_[2];
Framebuffer* current_;
Framebuffer* next_;
};
在游戏应用中,举例保存的双缓存颗粒度很小,就是一个Slapped状态。就设计了如下代码,核心还是在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_;
};
2.8 游戏循环
本节对我来说是最容易理解的,1分钟看完,因为关于总体框架是我最感兴趣的,所以早就有研究过。另外,所有GUI的开发的main函数架构基本上都是这样的。
while (true)
{
processInput();
update();
render();
}
再修改为和帧率有关
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)
{
update();
lag -= MS_PER_UPDATE;
}
render();
}
2.9 更新方法
2.8不是说了输入实际,然后更新画面,在绘制画面输出吗,那么本节就说了更新方法,有3中方法,其中继承已经淘汰,因为这个仅用于类很少的情况,另外就是组合和委托方式进行更新。
但是继承的例子他给出来了,其实我平时写c++就喜欢用这种继承的方式来实现多态,看来游戏中不适合呀~
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();//实体的具体动作
}
// 物理和渲染……
}
}
2.10 子类沙盒
这个我作为非游戏行业的人,作为汽车行业的软件工程师,是我第一次听说呢!这个其实也用的是继承的方法,不过是用protected受保护的继承,目的是减少子类代码量,归并同类项。比如所有子类都要有运动和音效等配合,则可以用到此沙盒模式的设计思路,其实这是个继承的设计思路,不是什么设计模式吧~
2.11 类型模式
本质上将部分的类型系统从硬编码的继承结构中拉出,放到可以在运行时定义的数据中去。
定义类型对象类和有类型的对象类。每个类型对象实例代表一种不同的逻辑类型。 每种有类型的对象保存对描述它类型的类型对象的引用。实例相关的数据被存储在有类型对象的实例中,被同种类分享的数据或者行为存储在类型对象中。 引用同一类型对象的对象将会像同一类型一样运作。 这让我们在一组相同的对象间分享行为和数据,就像子类让我们做的那样,但没有固定的硬编码子类集合。
这个意思就是说原来代码中的参数都是要写入code进行编译的,比如如下的230和48。而采用类型模式,则参数可以共享。
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!";
}
};
当我们建构怪物时,我们给它一个品种对象的引用。 它定义了怪物的品种,取代了之前的子类。让类型对象更像类型:构造器,Breed中的newMonster是不是就像构造器一样,子类先创建父类再返回呀!
class Breed
{
public:
Monster* newMonster() { return new Monster(*this); }
// Previous Breed code...
};
class Monster
{
friend class Breed;
public:
const char* getAttack() { return breed_.getAttack(); }
private:
Monster(Breed& breed)
: health_(breed.getHealth()),
breed_(breed)
{}
int health_; // 当前血值
Breed& breed_;
};
另外一个优点是通过继承分享数据能帮上忙的是在不同品种间分享属性的能力。我们用类型对象实现它。
class Breed
{
public:
Breed(Breed* parent, int health, const char* attack)
: parent_(parent),
health_(health),
attack_(attack)
{}
int getHealth();
const char* getAttack();
private:
Breed* parent_;
int health_; // 初始血值
const char* attack_;
};
实现方式有两种。 一种是每次属性被请求时动态处理委托,就像这样:
int Breed::getHealth()
{
// 重载
if (health_ != 0 || parent_ == NULL) return health_;
// 继承
return parent_->getHealth();
}
const char* Breed::getAttack()
{
// 重载
if (attack_ != NULL || parent_ == NULL) return attack_;
// 继承
return parent_->getAttack();
}
另外一种如下
Breed(Breed* parent, int health, const char* attack)
: health_(health),
attack_(attack)
{
// 继承没有重载的属性
if (parent != NULL)
{
if (health == 0) health_ = parent->getHealth();
if (attack == NULL) attack_ = parent->getAttack();
}
}
int getHealth() { return health_; }
const char* getAttack() { return attack_; }
假设游戏引擎从品种的JSON文件加载设置然后创建类型,我们有一段代码读取每个品种,用新数据实例化品种实例。 此模式,通过一小块代码,系统给了设计者控制权,真的不错。
2.12 组件模式
组件模式我理解和装饰器类似,也是比较简单和常见的。这个模式我喜欢用哦,哈哈~
一开始是这样的
void Bjorn::update(World& world, Graphics& graphics)
{
// 根据用户输入修改英雄的速度
switch (Controller::getJoystickDirection())
{
case DIR_LEFT:
velocity_ -= WALK_ACCELERATION;
break;
case DIR_RIGHT:
velocity_ += WALK_ACCELERATION;
break;
}
// 根据速度修改位置
x_ += velocity_;
world.resolveCollision(volume_, x_, y_, velocity_);
// 绘制合适的图形
Sprite* sprite = &spriteStand_;
if (velocity_ < 0)
{
sprite = &spriteWalkLeft_;
}
else if (velocity_ > 0)
{
sprite = &spriteWalkRight_;
}
graphics.draw(*sprite, x_, y_);
}
然后开始改造,提取了InputComponent,PhysicsComponent和GraphicsComponent类。
class Bjorn
{
public:
int velocity;
int x, y;
void update(World& world, Graphics& graphics)
{
input_.update(*this);
physics_.update(*this, world);
graphics_.update(*this, graphics);
}
private:
InputComponent input_;
PhysicsComponent physics_;
GraphicsComponent graphics_;
};
还是不够,上面Bjorn类中包含了InputComponent,PhysicsComponent和GraphicsComponent成员,所以是单向关联关系。为了模块可插拔,进行解耦,当然关系约弱越好,所以将这2类改参数引用。变成了依赖关系。
class Bjorn
{
public:
int velocity;
int x, y;
Bjorn(InputComponent* input)
: input_(input)
{}
void update(World& world, Graphics& graphics)
{
input_->update(*this);
physics_.update(*this, world);
graphics_.update(*this, graphics);
}
private:
InputComponent* input_;
PhysicsComponent physics_;
GraphicsComponent graphics_;
};
实例化类只要执行代码Bjorn* bjorn = new Bjorn(new PlayerInputComponent());
即可。
2.13 事件队列
如果你只是想解耦接收者和发送者,像观察者模式 和命令模式都可以用较小的复杂度进行处理。 在解耦某些需要及时处理的东西时使用队列。接收请求入队列,将每个独立的领域分散到一个线程——音频,渲染。在队列中处理队列中的事件请求。这个和我之前开发使用c++做应用开发的时候思路类似。
2.14 服务定位器
提供服务的全局接入点,避免使用者和实现服务的具体类耦合。就像音频。
三,总结
看完了<游戏编程精粹>,有些模式理解比较片面,也只是看过而已,不过终于能结合游戏功能应用来进行设计模式的选择了,其实每一种方法最一开始被创造出来,都是因为要解决某些问题,所以顺序应该是先有游戏应用,然后在选择或者创造对应的设计模式。将来我要是遇到问题,可能就会想到用这些方法咯~至于我为什么要复习面向对象的设计模式,原因是要为自己建立库代码做坚实的基础准备。避免经常重构。
网友评论