LOADING...

加载过慢请开启缓存(浏览器默认开启)

loading

游戏编程模式⑥(类型对象,组件模式)

2021/8/23

本文作为《Game Programming Pattern》(游戏编程原理)的摘要总结,记录为期一个月的阅读感想与心得体会。

撰写本文时,希望可以尽量遵循star法则,此后的文章同样以这四个要点作为大纲。

situation(情境)

task(任务)

action(行为)

result(结果)

本文代码尽量以unity c#进行演示(最近看的都是cpp代码,时常有被带偏的情况,本人实在太菜菜子了)


类型对象

通过创建一个类来支持新类型的灵活创建,其每个实例都代表了一个不同的对象类型。

我们传统的编程可能是一个monster基类,然后有诸如dragon,troll,witch这样的子类去继承他们。这样会导致几乎所有的时间都在编写简单且几乎相同的子类,而且每次都需要重新编译。


situation

我们需要一个无需重新编译就能修改属性的模式,最好能在无程序员介入的情况下让设计师也能调整。

当需要定义一系列不同种类的对象,又不希望把种类硬编码到类型系统时,本模式很适用。

特别是不知道将来会有什么新的类型(诸如游戏更新,资源包下载),

或者是需要不重新编译或修改代码情况下修改或添加新的类型时,此模式特别适用。


task

我们的任务很简单,游戏中有许多不同的怪物,我们想让他们共享一些特性。当然了他最好可以不需要重新编译就能支持修改。


action

我们重构我们的代码,使得每一种怪物都“has a”种类。我们仅声明单个Monster类和单个Breed类,而不是从Monster派生出各个种类。

class Breed
{
    public:
    Breed(int health_,const char* _attack)
        :health(health_),attack(_attack){}
    
    Get(){...}
        
    private:
    int health;
    const char* attack;
}

我们创建了一个包含两个数据字段的容器,让我们看看monster如何使用它

class Monster
{
    public:
    Monster(Breed& breed_)
        :breed(breed_),health(breed_.GetHealth())
    
    const char* GetAttack()
        {
            return breed.GetAttack();
        }
    
    private:
    int health;
    Breed& breed;
   
}

我们构造一个怪物时,给他一个种族对象的印象,由此来取代之前的派生关系。在构造函数中,怪物使用种族对象来获取他的初始生命值,其他所需的也只需要调取他所属Breed的方法。这是这个模式的核心思想。

我们成功的将一部分数据从硬编码的类继承中解放了出来,成为了可在运行时定义的数据。

我们最终会有成百上千个种类,我们可以仿照多个怪物子类通过类型的基类来共享特性一样,我们直接让种族之间共享特性。我们通过派生来实现,只不过不采用语言层面上的派生,而是自己实现。

class Breed
{
    public:
    Breed(Breed* parent_,int health_,const char* _attack)
        :parent(parent_),health(health_),attack(_attack){}
    
    int GetHealth()
    {
        if(health!=0||parent=NULL)
        {
            return health;
        }
        else
        {
            return parent.GetHealth();
        }
    }
        
    private:
    Breed* parent;
    int health;
    const char* attack;
}

这样做即使在运行时修改种类,他也能正常运行。另一方面会占用更多内存,而且更慢,这方面值得好好权衡利弊。如果能保证基类不变,可以直接在构造函数里面将基类的值拷贝过来,这样会更快些。

上述过程中,我们是相当于是先分配了一段空内存,然后给他赋予了类型。我们希望能调用类自身的构造函数,由它为我们创造新的实例。

class Breed
{
    public:
    Monster* NewMonster()
    {
    return new Monster(*this);
    }
}
class Monster
{
    friend class Breed;
    
    public:
    const char* GetAttack()
        {
            return breed.GetAttack();
        }
    
    private:
    
    Monster(Breed& breed_)
        :breed(breed_),health(breed_.GetHealth())
    int health;
    Breed& breed;
   
}

我们创建怪物由new Monster(anyBreed)变成了anyBreed.newMonster()

我们将构造方法设为私有,同时将Breed设为友元类,意味着Breed仍然能访问到这个构造方法,newMonster成为了创建怪物的唯一方法。

初始化一般在内存分配后,我们提前获得了用于储存它的内存。

在Breed里定义一个构造函数,能让我们在控制权被移交到初始化函数前,从一个池或者自定义的堆里面获取内存,我们能因此自己控制对象在内存中存在的位置和时间。


result

类型模式让我们像设计自己的语言一样设计我们的系统,但时间开销是不可忽略的。

我们成功做到了不同的对象之间能共享数据,另一个角度上解决这个问题的是原型模式。

享元模式也很接近,但享元模式更倾向节约内存,而类型对象重点在于灵活性。

这个模式和状态模式相同,都把对象的部分定义工作交给了另一个代理对象来实现。但这个模式的代理对象往往是一些静态的内容,而状态模式的代理对象则是描述对象当前状态的临时数据。


组件模式

允许一个单一的实体跨越多个不同域而不会导致耦合

软件设计的趋势是尽可能多的使用组合而不是继承,为了两个类之间代码共享他们应该拥有同一个类,而不是继承同一个类。


situation

组件模式最常见于游戏中定义实体的核心类,当我们有一个设计多个领域的类(物理,渲染,声音时),我们希望这些领域能保持解耦。

又或者是希望定义很多共享不同能力的对象,但采用继承的方法很难精准无误的重用代码。

这个时候,我们可能就需要组件模式。


task

我们需要将代码解耦,将一个大类里面的各个领域划分成独立的部分,然后让类持有他。这个类本身成为这些组件的容器。


action

让我们看看原先庞大的类吧

class Bjorn
{
    public:
        Bjorn():velocity_(0),x_(0),y_(0);
    
    void update(World &world,Graphics& graphics)
    {
        //允许用户输入英雄的速度
        switch(Controller::getJoystickDirection())
        {
            case DIR_LEFT:
             velocity_-=WALK_ACCECLERATION;
                break;
            
            case DIR_RIGHT:
             velocity_+=WALK_ACCECLERATION;
                break;
        }
         //通过速度修改位置
     x_+=velocity_;
     world.resovleCollision(volume_,x_,y_,volume_);
        //绘出恰当的精灵
        Sprite* sprite=&spriteStand_;
        if(velocity_<0)sprite=&spriteWalkLeft;
        else if(velocity_>0)sprite=&priteWalkRight;
        
        graphics.draw(sprite,x_,y_);
    }
    
    private:
    static const int WALK_ACCECLERATION=1;
    
    int velocity_;
    int x_,y_;
    
    Volume volume_;
    Sprite spriteStand_;
    Sprite spriteWalkLeft;
    Sprites priteWalkRight;
}

在这个类中,我们通过操控杆的输出来判定如何对主角进行加速,通过物理引擎确定新的位置,最后将主角绘制到屏幕上。

我们慢慢的将主角分割成独立的域,先从输入开始。

输入域所做的事情是读入用户的输入并调整自身速度。

让我们将这个逻辑封装到一个独立的类:

class Input
{
    public:
    void update(Bjorn& bjorn)
    {
                switch(Controller::getJoystickDirection())
        {
            case DIR_LEFT:
            bjorn.velocity_-=WALK_ACCECLERATION;
                break;
            
            case DIR_RIGHT:
            bjorn.velocity_+=WALK_ACCECLERATION;
                break;
        }
    }
    
    private:
    static const int WALK_ACCECLERATION=1;
}

对物理部分也做出同样的工作:

class Physics
{
    public:
    void update(Bjorn& bjorn,World& world)
    {
     bjorn.x_+=velocity_;
     world.resovleCollision(volume_,x_,y_,volume_);
    }
    
    private:
    Volume volume_;
}

此时volume_由物理组件持有了。

最后是一样很重要的渲染代码:

class Graphics
{
    public:
    void update(Bjorn& bjorn,Graphcis& graphcis)
    {
        Sprite* sprite=&spriteStand_;
        if(bjorn.velocity_<0)sprite=&spriteWalkLeft;
        else if(bjorn.velocity_>0)
            sprite=&priteWalkRight;
        
        graphics.draw(sprite,x_,y_);
    }
    private:
    
    Sprite spriteStand_;
    Sprite spriteWalkLeft;
    Sprites priteWalkRight;
}

这样一来,我们几乎将所有东西都分隔开来了。只剩下没有多少代码的主角:

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:
    Input input;
    Physics physics;
    Graphics graphics;

}

result