游戏技能系统开发探索1

最近在做一个游戏项目,开发过程中遇到需要设计技能系统的问题。个人在这方面并没有多少经验,网上搜到文章也是支离破碎,质量良莠不齐。无奈只好自己去要就一下。光靠脑子想不去记录,过后就会忘掉,所以写个文章记录一下一路以来的思路。这就是写这篇文章的动机

技能系统对于很多游戏来说都是不可或缺的一部分。但是奇怪的是如果去搜索这方面的文章会发现虽然做这方面研究的人不少,但是并没有一套成型的框架能够方便的设计实现这一个重要的系统。可能这个系统对于某些公司来说算是商业机密?总之想要彻底了解这个系统还是要自己做一些研究了。

首先根据一些网上的思路,设计一个技能时可以使用一种组合的思路,即一个大型技能实际上是有很多小技能组合而来的。例如一个技能A(随便起个名字),它的效果是在一定范围内造成伤害,并且对所有造成伤害的对象造成一个中毒的持续效果。那么这个技能设计时就可以考虑首先设计一个造成伤害的技能a,然后设计一个给敌人加毒buff的技能b,之后这个大A技能就可以考虑包含a,b两个小技能的组合技能。用代码来说明的话类似于下面这种形式

class a
{
public:
    void use(){cout<<"造成伤害"<<endl;}
}

class b
{
public:
    void use(){cout<<"加buff"<<endl;}
}

class A
{
public:
    void use(){skill1.use();skill2.use();}
private:
    a skill1;
    b skill2;
}

原理很简单,就不上运行图了。总之这样做的好处就是可以将一个大技能的实现分离成小的技能,从而实现代码复用。例如现在有一个大技能B,这个技能的效果是给敌人造成伤害同时提升自己的攻击力,那么这个技能就可以看成是一个伤害技能和一个给自己上buff的组合技能。这时B技能的代码可能长成这样

class B
{
public:
    void use(){skill1.use();skill2.use();}
private:
    a skill1;
    c skill2;
}

上面的B技能中的c就是给自己加buff的小技能,这里重用了a技能的代码,如果每一个大技能单独设计的话,就要在A,B两个技能里分别实现一遍a技能的代码。所以这样的思路我认为是一个不错的思路。

在这种思路下,技能系统就被分成了两个部分,一部分是实现基础功能的小技能类,这里我还是把他称作基础技能类,另一部分是实实在在能被使用的技能,在上面的例子中就像A,B这样的技能,我在这里把他叫做技能类。这两部分的关系如下图所示
这里写图片描述

按照这个思路,接下来到一个具体一点的环境中去。假设现在有一个主角类,一个敌人类,这两个类都继承于一个gameobject类,代码如下

class GameObject{}
class Hero:public GameObject
{
}
class Enemy:public GameObject
{
}

接下来主角要用一个技能把敌人打飞,应该怎么做呢?我继续用刚才的例子,只不过技能A我改叫毒液打击(drugattack,仍然是随便起的名字,英文不好不知道该怎么叫,汗),这个技能效果的两个基础技能名叫伤害hp技能(hurthp)和添加毒技能(adddrug),这时代码应该像这样

class HurtHP
{
public:
    void use(){cout<<"造成伤害"<<endl;}
}
class AddDrug
{
public:
    void use(){cout<<"添加毒buff"<<endl;}
}
class DrugAttack
{
public:
    void use(){skill1.use();skill2.use();}
private:
    HurtHP skill1;
    AddDrug skill2;
}

在主角类中应该怎么使用这个技能呢?首先想到的是要有一个DrugAttack的实例,所以主角类应该进行这样的修改

class Hero:public GameObject
{
public:
    void UseSkill(){skill.use();}
private:
    DrugAttack skill;
}

这样就可以使用技能了。但是这样的做法好吗?当然是不好,而且十分的不好,我能列出1w条这种方式不好的理由。限于篇幅,我只列几条重要的(实际上是列不出1w条。。。)
1、主角类必须拥有一个技能的实例,不利于代码扩展。假如主角能用100种技能,那主角类需要拥有100个技能实例,这显然是不可能的事。
2、使用这种方式主角使用代码时有很强的定向性。假如现在主角有3个可以使用的技能,像下面的代码这样又该怎么做呢?

class Hero:public GameObject
{
public:
    void UseSkill(){skill.use();}
private:
    DrugAttack skill1;
    Skill2 skill2;
    Skill3 skill3;
}

这时当然可以在useskill方法里改用switch去选择使用哪个技能之类的,但是明显是治标不治本的方法。
3、这个技能的目的是使用之后使他攻击到敌人,并对敌人造成伤害,但是这样的调用完全和敌人不沾边,又怎么能对敌人造成伤害呢?所以这样的设计一定是有问题的。
还有什么其他问题呢?我不管那么多了,就算找出来也没什么用。总之这样的设计是要改的。

首先我想要解决的问题是主角的技能包含问题。其实这里并不只是主角的问题,很多游戏中的敌人也是会使用技能的,所以解决这个问题对于敌人类来说也是很有意义的。
之前我说过,让主角去持有一个技能实例是不合理的事情,但其实这个说法并不准确。因为主角要使用技能,那么主角一定需要一定的信息去知道这个技能的存在。这样的话,可以考虑让主角持有一个技能指针,需要使用技能时把技能new出来就可以。但是这样做并没有什么意义。为什么这么说呢?看下面的代码

class Hero:public GameObject
{
public:
    void UseSkill(){skill1 = new DrugAttack;
                    skill->use();
                    delete skill1;}
private:
    DrugAttack* skill1;
    Skill2* skill2;
    Skill3* skill3;
}

这样的代码和上面直接持有实例的代码又有什么区别呢?如果主角能使用100种技能,主角就要持有100个指针,而且每次使用技能时还要调用一次多余的构造、析构函数,实际效果还不如直接持有实例。
那么应该如何修改?我的思路是既然横竖都要持有一些技能的信息,那么还是直接持有一个实例比较好。但是又不能直接在hero类中添加,用什么黑魔法才能做到这样的要求呢?
实际上换一个角度考虑,并不是不能直接在hero类中添加技能实例,而是因为直接在hero类中添加技能实例不利于代码扩展。那么也就是说如果有什么方法能够方便的在hero类中添加hero能使用的技能,那么这样的方法就是可取的。我的想法是使用stl容器统一去收集这些技能,然后hero需要做的就是持有一个容器,有一个能使用的技能,就把这个技能放进容器里,需要用到技能的时候从容器里找到这个技能use一下就好了。代码就像下面这样

class Hero:public GameObject
{
public:
    void UseSkill(){//???}
private:
    std::set<???> skills;
}

额,发现了新的问题,容器的类型是什么样的呢?因为按照之前的思路所有技能都是单独的一个类,没什么共同特点,所以用容器不知道该怎么去装这些技能。所以这里需要对之前的技能设计做一些小的修改,给这些技能加一个基类。代码如下


class SkillBase
{
public:
    virtual void Use() = 0;
}
class DrugAttack:public SkillBase
{
public:
    virtual void Use(){skill1.use();skill2.use();}
private:
    HurtHP skill1;
    AddDrug skill2;
}
class Skill2:public SkillBase
{
public:
    virtual void Use(){//一些操作}
private:
    //一些数据
}
class Skill3:public SkillBase
{
public:
    virtual void Use(){//一些操作}
private:
    //一些数据
}

这样修改技能类后,hero类中便可以这样做

class Hero:public GameObject
{
public:
    void AddSkill(SkillBase *skill)
    {
        skills.insert(skill);
    }
    void UseSkill(){//???}
private:
    std::set<SkillBase*> skills;
}

于是每次新增一个可使用技能,就调用一次hero.AddSkill方法就可以了,是不是方便了一些?
但是等一下,貌似我擅自改了一些代码,原本说好的持有一个技能实例,为什么擅自改成了技能指针?原因是在修改技能设计后,我们是通过多态来实现向容器内添加不同技能,以及从容器中获取技能并使用的,实现多态必须要用指针,所以这里只能使用指针。那么这样做就会产生之前说过的每次使用技能都多掉一次构造和析构的问题。实际上可能问题并不大,但是游戏是一个实时程序,对于时间性能要求比较高,所以我的做法还是偏向去持有技能实例的。那么问题就转换成如何用一个指针来轻松的获得一个已经构造好的实例。实际上也是很简单的,只要在什么地方构造一个实例,让指针去指向这个实例就可以了。我的做法是使用智能指针shared_ptr去管理,这里是一个参考做法,实际做法还有很多。代码如下

class Hero:public GameObject
{
public:
    void AddSkill(std::shared_ptr<SkillBase> skill)
    {
        skills.insert(skill);
    }
    void UseSkill(){//???}
private:
    std::set<std::shared_ptr<SkillBase>> skills;
}

关于智能指针,网上有很多相关文章,这里不多说。需要说明的问题在于stl容器的选取。这里的容器实际上使用vector和set我认为差别并不是很大。因为这里对于容器的操作主要是随机查找,在随机查找上set和map是所有容器中最快的,vector也不差。其次的需求比较多的是插入操作。插入操作时使用vector可以直接使用push_back,用set直接用insert就可以。这两个容器的插入效率上vector稍微快一点,因为set插入时还要做一次排序(大概需要吧,我对于具体实现不是特别了解)。我在这里使用了set容器做例子,但实际上我用的是map容器。具体原因在后面说明
现在来看一下这样的设计还有什么不合理的地方,“useskill方法里面是???”?这个先不要管,后面再解决,接下来研究一个问题,使用AddSkill方法的时机。考虑一下hero类中的skills什么时候需要里面有明确的值?答案是一个hero实例化之后就需要。为什么呢?自己去想。那么什么时候应该使用AddSkill呢?自然是在hero类中的构造函数里。在构造一个hero时就应该保证skills里有一系列明确的技能。所以代码上应该这样操作

class Hero:public GameObject
{
public:
    Hero()
    {
        AddSkill(std::make_shared<DrugAttack>());
        //省略添加其他技能,这里可能有100行代码
    }
    void AddSkill(std::shared_ptr<SkillBase> skill)
    {
        skills.insert(skill);
    }
    void UseSkill(){//???}
private:
    std::set<std::shared_ptr<SkillBase>> skills;
}

如代码中呈现,hero类的构造函数可能有100条add语句,具体数量根据主角可以使用的技能数量确定。而且这样做的问题可能还不止这一点,如果把头文件包含的语句全部包含进来的话,代码大概会是这个样子

#include "drugattack.h"
//省略一些包含头文件,可能有100个头文件

class Hero:public GameObject
{
public:
    Hero()
    {
        AddSkill(std::make_shared<DrugAttack>());
        //省略添加其他技能,这里可能有100行代码
    }
    void AddSkill(std::shared_ptr<SkillBase> skill)
    {
        skills.insert(skill);
    }
    void UseSkill(){//???}
private:
    std::set<std::shared_ptr<SkillBase>> skills;
}

再来看看最开始的我们的做法

#include "drugattack.h"
//省略一些包含头文件,可能有100个头文件

class Hero:public GameObject
{
public:
    void UseSkill(){//???}
private:
    DrugAttack skill1;
    //省略一些技能实例对象,可能有100个
}

是不是感觉相比之下并没有特别大的改善?我们可以通过将声明与实现分离,即在.cpp文件里面去实现hero的构造函数,以此缩短头文件的长度,但是hero的构造函数里还是有很长的add语句。这并不是我所希望的,因为在我的主角类中,还有很多工作要在构造函数中完成,我并不想看100行add语句,这会让我视力下降,而且更重要的是我认为这破坏了hero的独立性(或者叫内聚性,hero里干了原本不应该是hero干的工作)
于是乎,我会采取一个方案来解决这个问题,通过多加一层封装,将主角能够使用的技能都封装到另一个类中,hero类只需要持有一个另外一个类的实例,在构造hero实例时便自动构造了这个新的类。节省了好多代码。光这么说不够清晰,直接上代码

//heroskilllist.h
class HeroSkillList
{
public:
    HeroSkillList()
    {
        skillList.insert(/*技能名,技能指针的数据组*/);
        //省略,可能有100行
    }
    std::shared_ptr<SkillBase> FindSkill(std::string)
    {
        //map容器的通过键查值,查找到便将值返回
    }
    //没有add方法,因为游戏开始运行后不可能再动态添加新的技能进来,技能添加应该都在初始化阶段完成。即使真的有这样的需求,也只需要添加一个add方法便可
private:
    std::map<std::string,std::shared_ptr<SkillBase>> skillList;
}
//hero.h
#include "heroskilllist.h"

class Hero:public GameObject
{
public:
    void UseSkill()
    {
        skills.FindSkill(/*技能名*/)->Use();
    }
private:
    HeroSkillList skills;
}

这时再看hero的代码,是不是清净了很多?当然,麻烦的一大串的添加技能实际上还存在,但是在hero里完全看不出这些麻烦的代码,这些代码都被放在了HeroSkillList类中处理
当然这段代码里还有一些问题没有解释。首先是在HeroSkillList类中,我把原本的set容器换成了map容器。之前我也说过实际处理时我使用map容器来处理的技能容器。原因是在查找技能时,虽说vector和set容器的性能差不多,但是实际查找时,要么需要知道技能下标(vector),要么知道实际的技能指针(set),这种方式去查找在实际操作中是很不方便的。所以我实际采用map容器,一个键-值型容器来存放技能。这样查找时可以通过一个键值来查找实际的技能,会方便不少。这个键值用什么样的数据来做,实际上也有很多方案。我这里简单的使用技能名去做键值,在查找时直接输入技能名就可以找到技能指针,我认为还是比较方便的。
然后另一个比较重要的变化就是hero类中的use方法被具体实现了。这里就是我使用HeroSkillList的find方法,首先找到具体的技能指针,然后调用技能的use方法,这样主角就成功释放了一个技能。至于具体怎么去find,我只能说具体环境下有具体的做法。在我的项目中,会根据玩家的按键,去另一张表中查找按键对应的技能名,之后再去查找这个技能的指针(然而实际上我的项目里那张按键表里直接存着技能指针,这样又少了一步查找工作。但在这里还是不说明太多比较方便理解)。
这里在说一个题外话,如果是多人游戏,或者这个HeroSkillList变成了EnemySkillList,那么这样的设计会出现一个问题,所有的enemy实例都包含一份EnemySkillList,而每个EnemySkillList都包含一些技能实例。没错,都包含一些实例。这意味着什么?大量的空间浪费。我希望SkillList中存放实例的目的是减少构造析构的时间开销,但是副作用是会占用一些内存。实际上这种做法是以空间换时间的做法。但是如刚才所说,每一个enemy都包含一大堆的enemy技能时,这样的设计可能就显得不太合理了。所以这里我会考虑将SkillList作为一个单例类,代码

class HeroSkillList
{
public:
    //获取单例实例方法
    static HeroSkillList& Instance()
    {
        static HeroSkillList instance;
        return instance;
    }
    std::shared_ptr<SkillBase> FindSkill(std::string){}
private:
    std::map<std::string,std::shared_ptr<SkillBase>> skillList;
    //构造函数为私有,即外界无法实例化此类
    HeroSkillList()
    {
        //省略,可能有100行
    }
    //省略拷贝构造函数,重载赋值运算符等
}

这样将SkillList作为一个单例类,存在SkillList中的技能就只能存在一份,解决了空间占用的问题。即使实例化再多的enemy也只有一份技能被实例。当然这样写的话在实际游戏对象中的调用也要发生相应变化,否则编译器会罢工的。以hero类举例

#include "heroskilllist.h"

class Hero:public GameObject
{
public:
    void UseSkill()
    {
        HeroSkillList::Instance().FindSkill(/*技能名*/)->Use();
    }
private:
    //甚至不需要一个实例的SkillList对象
}

如果在.cpp文件中定义use方法,那么在hero.h中甚至可以不包含”heroskilllist.h”头文件。是不是感觉很不错?

总结一下今天的代码

//技能基类
class SkillBase
{
public:
    virtual void Use() = 0;
}
//基础技能类,举两个例子。这两个基础技能和下面一个具体技能只供参考测试用,不属于框架体系里,属于特化设计
class HurtHP
{
public:
    void use(){cout<<"造成伤害"<<endl;}
}
class AddDrug
{
public:
    void use(){cout<<"添加毒buff"<<endl;}
}
//具体技能类,举一个例子,可能一个游戏有100种技能
class DrugAttack:public SkillBase
{
public:
    virtual void Use(){skill1.use();skill2.use();}
private:
    HurtHP skill1;
    AddDrug skill2;
}
//先以主角技能表来举例,但并不是完整的SkillList设计。而且这个SkillList功能也并不完整。日后还需要进一步完善
class HeroSkillList
{
public:
    //获取单例实例方法
    static HeroSkillList& Instance()
    {
        static HeroSkillList instance;
        return instance;
    }
    std::shared_ptr<SkillBase> FindSkill(std::string)
    {
        //map容器的通过键查值,查找到便将值返回
    }
    //没有add方法,因为游戏开始运行后不可能再动态添加新的技能进来,技能添加应该都在初始化阶段完成。即使真的有这样的需求,也只需要添加一个add方法便可
private:
    std::map<std::string,std::shared_ptr<SkillBase>> skillList;
    HeroSkillList()
    {
        skillList.insert(/*技能名,技能指针的数据组*/);
        //省略,可能有100行
    }
}
//在某一个游戏对象中的处理,不局限于主角,有可能是敌人。
class Hero:public GameObject
{
public:
    void UseSkill()
    {
        HeroSkillList::Instance().FindSkill(/*技能名*/)->Use();
    }
}

今天的探索主要集中在技能组成和游戏对象对技能的管理上。我想要做的是一个具有公用型的技能系统框架。今天所做的这些功能可能在一个主角类上能发挥不错的效果,但是对其他游戏对象是否能做到相同的功效还不得而知。以后会在研究一些泛化的实现方法。下一次的博客中可能会使用泛型做一些设计,也可能不会。取决于利用泛型能否很好地实现技能系统框架设计。

猜你喜欢

转载自blog.csdn.net/fsdafsagsadgas/article/details/69756497