一个MMO游戏服务逻辑系统(二)
对于NPC的设计,一般的考量是,NPC属性和玩家属性是差不多的。或者很多是通用的,由于在游戏设计中存在各种NPC,各种血量,各种外形和各种AI,所以需要一个虚类来实现基础的NPC属性记录。而实际的NPC属性,则可以用一个结构体来实现。#define OBJECT_NAME_MAX 50 //对象最大名称长度class IObjectAI;
class IObject
{
public:
virtual bool Update() = 0; //对象执行,根据不同的状态执行相应的代码
virtual bool SetState(int nState) = 0; //设置对象状态(需要继承者自己实现)
virtual intGetState() = 0; //获得对象状态(需要继承者自己实现)
virtual intGetNomalAttackMin() = 0; //得到普通攻击力(最小)
virtual intGetNomalAttackMax() = 0; //得到普通攻击力(最大)
virtual intSetHP(int nHP) = 0; //设置一个HP的数值,返回当前HP
virtual intGetHP() = 0; //得到当前HP的数值
virtual IObjectAI* GetObjectAI() = 0; //得到这个对象的AI引擎指针
virtual intGetRunRatio() = 0; //得到逃跑比例
virtual void SetObjectID(int nObjectID) = 0; //设置ObjectID
virtual intGetObjectID() = 0; //获得ObjectID
virtual void SetName(const char* pName) = 0; //设置对象的名称
virtual char* GetName() = 0; //得到对象的名称
virtual void SetObjectPosIndex(int nIndex) = 0; //设置当前对象由哪个刷新点刷出
virtual intGetObjectPosIndex() = 0; //获取当前对象由哪个刷新点刷出
virtual _MapPointPath* GetMapPointPath() = 0; //获取当前对象的路径参数
virtual void SetCurrPoint(_MapPoint* pCurrPoint) = 0; //设置当前位置
virtual _MapPoint GetCurrPoint() = 0; //获取当前位置
virtual void SetTimeStamp(unsigned long lNPCTimeStamp) = 0; //设置当前对象处理时间
virtual unsigned long GetTimeStamp() = 0; //获得当前对象处理时间
virtual void SetSpeed(int nSpeed) = 0; //设置对象速度
virtual intGetSpeed() = 0; //获得对象速度
virtual intGetStandTimeInterval() = 0; //得到停留的时间长度
virtual intGetRangeID() = 0; //得到区块ID
virtual void SetRangeID(int nRangID) = 0; //设置区块ID
};
这里我把NPC需要的所有属性方法,主要是set和Get封装成了一个虚类,用于NPC对象的继承。对于一个NPC而言,它应该至少由两部分组成,一个是NPC的属性部分,一个是NPC AI处理部分。
首先,让我们来看看如何管理一个NPC对象是如何构成的。
class CNPCObject : public IObject
{
public:
CNPCObject();
~CNPCObject(void);
//初始化方法
void Init(_NPCAttribute objNPCAttribute, IObjectAI* pObjectAI);
void SetMapData(CMapBaseData* pMapBaseData);
void SetRangeBaseManager(CRangeBaseManager* pRangeBaseManager);
//对应属性值的各种读取与写入
intGetState();
bool SetState(int nState);
intGetNomalAttackMin();
intGetNomalAttackMax();
intGetRunRatio();
intSetHP(int nHP);
intGetHP();
void SetObjectID(int nObjectID);
intGetObjectID();
void SetName(const char* pName);
char* GetName();
void SetObjectPosIndex(int nIndex);
intGetObjectPosIndex();
_MapPointPath* GetMapPointPath();
void SetCurrPoint(_MapPoint* pCurrPoint);
_MapPoint GetCurrPoint();
void SetTimeStamp(unsigned long lNPCTimeStamp);
unsigned long GetTimeStamp();
void SetSpeed(int nSpeed);
intGetSpeed();
intGetStandTimeInterval();
intGetRangeID();
void SetRangeID(int nRangID);
IObjectAI* GetObjectAI();
//心跳
bool Update();
private:
IObjectAI* m_pIObjectAI; //对象AI
_NPCAttribute m_NPCAttribute; //对象属性
_MapPointPath m_MapPointPath; //对象路径属性
_MapPoint m_CurrPoint; //当前NPC的位置
unsigned long m_lNPCTimeStamp; //NPC最后被服务器处理的时间戳
int m_nStateCooldown; //状态执行最少持续时间
};
这里,所有的NPC属性是由一个结构体管理的。_NPCAttribut结构体负责存储指定NPC的一些信息。
//NPC所有属性的结构体
struct _NPCAttribute
{
intm_nObjectID; //设置对象ID
char m_szNpcName; //当前NPC的名字
intm_nHP; //当前NPC的HP
intm_nAIType; //所绑定的AI策略ID
intm_nAttackMin; //最小攻击力
intm_nAttackMax; //最大攻击力
intm_nRunRatio; //逃跑比例
intm_nObjectState; //对象当前状态
intm_nNPCTypeID; //NPC的类型ID
intm_nObjectPosIndex; //刷新点Index
intm_nBaseSpeed; //对象基础移动速度
intm_nStandTimeInterval; //行走间隔停留时间
intm_nRangeID; //对象所在区块ID
intm_nScriptID; //此对象对应的脚本ID,用于NPC扩展行为
_NPCAttribute()
{
m_nObjectID = OBJECT_VAILD_ID;
m_nObjectPosIndex = OBJECT_VAILD_ID;
m_szNpcName = '\0';
m_nHP = 0;
m_nAIType = 0;
m_nAttackMin = 0;
m_nAttackMax = 0;
m_nRunRatio = 0;
m_nNPCTypeID = 0;
m_nBaseSpeed = 0;
m_nStandTimeInterval = 0;
m_nRangeID = 0;
m_nScriptID = 0;
m_nObjectState = ENUM_OBJECTSTATE::OBJECTSTATE_INIT;
};
_NPCAttribute& operator = (const _NPCAttribute& ar)
{
this->m_nObjectID = ar.m_nObjectID;
this->m_nNPCTypeID = ar.m_nNPCTypeID;
this->m_nAIType = ar.m_nAIType;
this->m_nHP = ar.m_nHP;
this->m_nAttackMin = ar.m_nAttackMin;
this->m_nAttackMax = ar.m_nAttackMax;
this->m_nRunRatio = ar.m_nRunRatio;
this->m_nObjectState = ar.m_nObjectState;
this->m_nObjectPosIndex = ar.m_nObjectPosIndex;
this->m_nBaseSpeed = ar.m_nBaseSpeed;
this->m_nRangeID = ar.m_nRangeID;
this->m_nScriptID = ar.m_nScriptID;
sprintf_safe(this->m_szNpcName, NPC_MAX_NAME, "%s", ar.m_szNpcName);
return *this;
}
};
当然,这只是一个例子,你可以自由添加你想要的元素,但是切记,需要对应的给IObject提供接口。
这里要说一个小技巧,对于NPC,一般分为两种,一种是战斗类型的NPC,一种是服务类型的NPC,其实这两种NPC并无绝对区分必要,既能在某一定条件下变为战斗NPC也可以在一定条件下变为服务性NPC是一个很灵活的属性,不是吗?其实对于主NPC而言,一般情况是由地图编辑器安插在地图点上的(也就是俗称的NPC刷新点,当某一指定的NPC不存在了,那么一定时间后会在这里复活)。那么在这种情况下,我的所有NPC ID实际不用实时生成,而是在地图编辑的时候就制定了,这样做的好处是,我在地图上生成的所有NPC,我根据ID都能做到心里有数。而且同一个ID不会出现两次。当然,你可以为了游戏观赏性,NPC召唤出小NPC辅助,这部分NPC实际也可以ID指定的。这个ID实际就是资源出生点的ID。类似,在魔兽世界中的采矿点的矿藏,你其实也大可看成一种NPC,和普通攻击的NPC没有多大区别,都是提供服务类型的NPC。在这里还可以引出一个小技巧。比如某一个法师发出一个范围大招,比如火雨,在这个区域内的所有玩家将会受到火雨的伤害,持续时间5秒。其实这个实现非常简单,就是在法师出技能的时候,创造一个时限性的NPC,这个NPC的AI就是在周围放一个火伤害的空间。当然,在游戏中你是看不见这个NPC的,但是实际上,服务器上是有这个对象存在的,5秒后自动销毁。借此,我们可以利用这个机制,实现几乎各种复杂的法术,范围技能,甚至叠加的效果。
好了,对于一个场景而言,我出现的NPC种类是有限的,那么,我怎么把NPC的刷新点,NPC类型封装在一个场景文件中呢?
看看我是怎么做的。
//创建字典文件
sprintf_s(pFileName, MAX_MAPNAME, "npc.dict");
//打开NPC字典文件
pFile = fopen(pFileName, "wb");
int nNPCCount = 3;
fwrite(&nNPCCount, 1, sizeof(int), pFile);
_NPCAttribute npca1;
sprintf_s(npca1.m_szNpcName, NPC_MAX_NAME, "自由之眼");
npca1.m_nAttackMin = 50;
npca1.m_nAttackMax = 70;
npca1.m_nHP = 230;
npca1.m_nRunRatio = 50;
npca1.m_nAIType = 0;
npca1.m_nBaseSpeed = 5;
npca1.m_nNPCTypeID = 1001;
npca1.m_nScriptID = 1001;
npca1.m_nStandTimeInterval = 1000;
fwrite(&npca1, 1, sizeof(_NPCAttribute), pFile);
sprintf_s(npca1.m_szNpcName, NPC_MAX_NAME, "蓝翼");
npca1.m_nAttackMin = 30;
npca1.m_nAttackMax = 45;
npca1.m_nHP = 100;
npca1.m_nRunRatio = 0;
npca1.m_nAIType = 0;
npca1.m_nBaseSpeed = 6;
npca1.m_nNPCTypeID = 1002;
npca1.m_nScriptID = 1002;
npca1.m_nStandTimeInterval = 1000;
fwrite(&npca1, 1, sizeof(_NPCAttribute), pFile);
sprintf_s(npca1.m_szNpcName, NPC_MAX_NAME, "天际");
npca1.m_nAttackMin = 300;
npca1.m_nAttackMax = 500;
npca1.m_nHP = 1200;
npca1.m_nRunRatio = 0;
npca1.m_nAIType = 0;
npca1.m_nBaseSpeed = 10;
npca1.m_nNPCTypeID = 1003;
npca1.m_nScriptID = 1003;
npca1.m_nStandTimeInterval = 1000;
fwrite(&npca1, 1, sizeof(_NPCAttribute), pFile);
fflush(pFile);
printf("(%s)Create NPC dict OK.\n", pFileName);
fclose(pFile);
如上,我创建一个字典文件,这个字典主要是服务于当前场景可能出现的所有NPC种类(现在我这里有三种NPC,分别为自由之眼,蓝翼和天际),这里有这些NPC对应的血量,类型等等,如果你愿意,你可以添加NPC颜色,称号。这样更具备趣味性。这个字典是为刷新点文件做对照的,对于刷新点文件,我们可以这么做。
//创建刷新点
sprintf_s(pFileName, MAX_MAPNAME, "map1.pos");
//打开NPC字典文件
pFile = fopen(pFileName, "wb");
int nObjectPosCount = 1500;
fwrite(&nObjectPosCount, 1, sizeof(int), pFile);
for(int i = 0; i < 500; i++)
{
_MapObjectPos objMapObjectPos1;
objMapObjectPos1.m_nGuid = 100001 + i;
objMapObjectPos1.m_nPosX = 5;
objMapObjectPos1.m_nPosY = 5;
objMapObjectPos1.m_nObjectType = 1001;
objMapObjectPos1.m_nTimeInterval = 500;
fwrite(&objMapObjectPos1, 1, sizeof(_MapObjectPos), pFile);
}
for(int i = 0; i < 500; i++)
{
_MapObjectPos objMapObjectPos2;
objMapObjectPos2.m_nGuid = 100501 + i;
objMapObjectPos2.m_nPosX = 10;
objMapObjectPos2.m_nPosY = 10;
objMapObjectPos2.m_nObjectType = 1002;
objMapObjectPos2.m_nTimeInterval = 500;
fwrite(&objMapObjectPos2, 1, sizeof(_MapObjectPos), pFile);
}
for(int i = 0; i < 500; i++)
{
_MapObjectPos objMapObjectPos3;
objMapObjectPos3.m_nGuid = 101001 + i;
objMapObjectPos3.m_nPosX = 15;
objMapObjectPos3.m_nPosY = 15;
objMapObjectPos3.m_nObjectType = 1003;
objMapObjectPos3.m_nTimeInterval = 500;
fwrite(&objMapObjectPos3, 1, sizeof(_MapObjectPos), pFile);
}
fflush(pFile);
printf("(%s)Create map pos OK.\n", pFileName);
fclose(pFile);
好了,比如我创造了1500个NPC,按照上面的字典一样500个,这里objMapObjectPos3.m_nGuid就是我的NPC场景中的唯一ID,m_nPosX和m_nPosY是出生点,m_nObjectType会对应NPC字典文件中的npca1.m_nNPCTypeID。这样我就知道这个刷新点上的NPC是一个什么家伙。npca1.m_nAIType指的是绑定AI的类型,我可以在服务器上提供一批AI供NPC选择,比如激进的,保守的,勇敢的,怯懦的,爱主人的。。。这样,在地图编辑器上,我就可以实现策划随意使用我创建的NPC和AI,这两个对象是独立的,可以组合的,这样就能组合成丰富多彩的NPC体系。
那么或许你会问我,为什么要将刷新点和NPC字典分开,呵呵,这是从地图编辑器上考虑,更多的时候,策划是在调整地图上的NPC对象而非创造NPC,这两个事情完全可以并行去做。分为两个文件对于游戏开发者更适合专注的去做好其一。
好了,NPC和刷新点的文件已经创造好了,让我们来看看游戏服务器是怎么读取的。
//加载NPC字典信息
if(false == m_objNPCObjectManager.Init(objSenceInfo.m_szNPCDictFileName, objSenceInfo.m_nMaxObjectCount))
{
printf_s("Read CNPCObjectManager file error.\n");
return false;
}
//加载刷新点信息
if(false == m_objMapObjectPosManager.Init(objSenceInfo.m_szObjectPosFileName))
{
printf_s("Read m_objMapObjectPosManager file fail.\n");
return false;
}
//根据刷新点,创建相应的NPC
for(int i = 0; i < m_objMapObjectPosManager.GetCount(); i++)
{
_MapObjectPos* pMapObjectPos = m_objMapObjectPosManager.GetObjectPos(i);
if(NULL != pMapObjectPos)
{
int nNPCID = m_objNPCObjectManager.CreateNPCObject(pMapObjectPos->m_nGuid, pMapObjectPos->m_nObjectType);
CNPCObject* pNPCObject = (CNPCObject* )m_objNPCObjectManager.GetNPCObject(nNPCID);
if(NULL != pNPCObject)
{
_MapPoint objCurrPoint;
objCurrPoint.m_nX = pMapObjectPos->m_nPosX;
objCurrPoint.m_nY = pMapObjectPos->m_nPosY;
pNPCObject->SetCurrPoint(&objCurrPoint);
pNPCObject->SetObjectPosIndex(i);
pNPCObject->SetState(ENUM_OBJECTSTATE::OBJECTSTATE_NOMAL);
pNPCObject->SetMapData((CMapBaseData* )&m_objMapData);
pNPCObject->SetTimeStamp(GetTimeStamp());
pNPCObject->SetRangeBaseManager((CRangeBaseManager* )&m_objRangeManager);
pNPCObject->GetObjectAI()->Init();
printf("Add NPC (%s).\n", pNPCObject->GetName());
}
}
}
我根据上述原则,先加载所有的NPC字典信息,然后加载刷新点文件,根据文件的定义,我把NPC创建出来,并还原到地图的点上,细心的你,可能会发现SetRangeBaseManager()这个东东是干什么的。呵呵,这是一个"区域"管理器,用于NPC移动广播周边信息的内容。这个东东我会在下一讲详细的说明,姑且你先当它是一个NPC位置相关的属性好了。
好了,NPC的基础数据还原了,让我们看看AI是怎么做的。
首先,我们要理解什么是AI,AI实际并不是一组数据,更确切的讲,AI是一种在一定事件发生后,你需要做怎样的反应。拿《游戏编程精粹6》里面所讲的Quick3,当NPC出生后,我先要确定周围的情况(OnLookAround),然后做出思考,我应该怎么设计我的移动路线(OnMove),当我在移动过程中,受到了攻击的时候(OnBeAttack),我应该如何处理?或者说当我看见敌人(OnSeeTargetIn),我应该区分是敌是友,决定是否靠近到攻击距离(OnApproach),然后攻击或者防御(OnAttack)。如果敌人离开我的视觉范围(OnSeeTargetLeave),我会怎么办?最后,敌人被我打死了(OnTargetDead),我是因该怎么处理?亦或者,我死了(OnDead),我因该在死的时候做些什么动作?
看看,随着想象,你已经非常清楚的知道了,一个AI应该包含多少个基础方法,至于实现,我们只需要填充这些事件产生后的处理结果即可。其实仔细想想,这些事件几乎能覆盖你所接触到的基本反应需求。
那么我来设计一个通用的AI接口吧,所有的AI处理,都可以继承于此。
class IObjectAI
{
public:
IObjectAI()
{
m_pObject = NULL;
};
virtual ~IObjectAI() {};
//设置类型本体,AI拥有者对象指针
void SetObject(IObject* pObject)
{
m_pObject = pObject;
};
//设置AI初始化信息
virtual bool Init() = 0;
//进入视野,pTarget为进入视野的对象
virtual bool OnSightInEvent(IObject* pTarget)= 0;
//离开视野,pTarget为离开视野的对象
virtual bool OnSightOutEvent(IObject* pTarget) = 0;
//被攻击,pTarget是攻击者名称,nType为攻击类型,nSkillID为技能ID
virtual bool OnBeAttackEvent(IObject* pAttack, int nType, int nSkillID) = 0;
//死亡,被那个攻击者杀死,pAttack为攻击者对象
virtual bool OnDeadEvent(IObject* pAttack) = 0;
//逃跑,打不过的时候
virtual bool OnRunEvent(IObject* pAttack) = 0;
//设置地图指针,用于AI寻路等计算
virtual void SetMapBaseData(CMapBaseData* pMapBaseData) = 0;
//设置曲别针指针,用于区域对象搜索
virtual void SetRangeBaseManager(CRangeBaseManager* pRangeBaseManager) = 0;
//平常调用,当什么事件都没有发生的时候会调用此
virtual bool Update() = 0;
public:
IObject* m_pObject;
};
这里我只是举例,实际情况中,你完全可以丰富我的基类,提供更多的基础事件。
对于AI,我并不是只需要算法就够了,我还可能需要一个列表,举例来说,如果我身边出现了多个敌人,我需要多个敌人来一个顺序,先打哪个,再打哪个,亦或,我需要一个队友列表。如此等等,根据策划的衍生来决定这个基类的构造。
那么,我来做一个真正的AI吧。
class CNPCAI : public IObjectAI
{
//AI事件相关接口
public:
CNPCAI();
~CNPCAI();
bool Init();
void SetNPCObjectManager(CObjectManager* pNPCObjectManager);
void SetMapBaseData(CMapBaseData* pMapBaseData);
void SetRangeBaseManager(CRangeBaseManager* pRangeBaseManager);
CObjectManager* GetNPCObjectManager();
bool OnSightInEvent(IObject* pTarget);
bool OnSightOutEvent(IObject* pTarget);
bool OnBeAttackEvent(IObject* pAttack, int nType, int nSkillID);
bool OnDeadEvent(IObject* pAttack);
bool OnRunEvent(IObject* pAttack);
bool Update();
private:
bool DoRandomPathFound(); //随机路径寻找
bool DoRandomMove(); //按照指定的路径移动
bool DoLookAround(); //查看周围目标对象
bool DoStandState(); //站立状态,如果是被动状态这里只等待一段时间,主动状态切换用户到OBJECTSTATE_FIND状态,开始搜索身边的对象。
bool DoAccack(); //进入进攻状态
//对象列表相关接口,AI需要根据事件存储相应的其他对象列表
public:
//添加关注的敌人
bool AddEnemy(int nObjectID);
//删除关注的敌人
bool DelEnemy(int nObjectID);
//得到当前的敌人,先进先出
IObject* GetCurrEnemy();
//清除所有敌人目标
bool ClearEnemy();
private:
CRandom m_Random; //随机数生成器
IObjectList m_ObjEnemyList; //敌人列表
CObjectManager* m_pNPCObjectManager; //NPC对象管理器
CMapBaseData* m_pMapBaseData; //地图接口类指针
CRangeBaseManager* m_pRangeBaseManager; //区域曲别针类指针
_RangeSearch* m_pRangeSearch; //周边区域块信息指针
};
在这里,我提供了对虚类定义的实现,你可以参考我的NPC类,里面创建的时候会从AI池中获取一个空闲的AI指针。在里面实现相关的动作。
所有操作都是基于NPC的状态来实现的,bool Update();AI运算的接口,和场景OnUpdate()心跳保持一致。
这样,我就可以根据策划需求,实现一整套NPC的逻辑和AI。
下一讲,我将会讲,怎么把NPC和地图,场景串联起来,成为一个有机的整体。
页:
[1]