本文主要讨论了游戏,特别是2D SLG和AVG类游戏的场景管理设计,包括场景对象、引用计数、对象继承和场景管理的几个关键方面。通过引入COM机制的IUnknown基类来管理对象的生命周期,以及利用场景层级关系、对象容器和格子管理器来优化游戏逻辑。文章强调了灵活的设计和组件模式的重要性,以应对不断变化的游戏需求。

前言

如之前博客中所说,最近一直忙于业余游戏开发项目中。我一直是个比较懒散的家伙,可这次猫窝的管理员(我们组唯一一名策划)却不愿意让我闲着,几乎每日都会QQ联系,再三叮咛询问我项目的事情。突然有些日本某些连载漫画作家被杂志社催稿的味道了。不过,拖他的福,项目进展比我预料的迅速。虽然被我狠心砍掉了一大模块下去,仍然比我原计划快出很多,感谢他。希望项目快点出来吧。

因为我们做的是2D的SLG(战棋策略类游戏)的缘故,而我又主观的偏好一些AVG(恋爱养成游戏),所以本文重点会考虑这两类游戏的场景管理,对于3D且不基于地图格的场景管理设计恐怕您就需要进一步考虑了。

引子

场景管理以及场景对象管理,我想应当是游戏逻辑的核心模块也不为过了,实际上复杂度还是非常高的,本文仅从一些部分简单的叙述了其中一些内容。随兴而谈,仅供参考。

场景对象

熟悉Com机制的朋友会知道,Com的最基类通常都是IUnknown类,这个类通常包括下面四个函数:

/** 构造函数 */
IUnknown() : m_nRefCount(1) { }

/** 增加计数 */
void AddRef(){++m_nRefCount;}

/** 假释放 */
bool Release()
{
    assert(m_nRefCount > 0); 
    -- m_nRefCount; 
    if(m_nRefCount)
    {delete this; return true;}
    return false;
}

/** 接口查询 */
void QueryInterface(const IID&  Guid) = NULL;

首先来解释一下这个类。

1:这个类内有一个成员m_nRefCount引用计数,在我们每次New一个继承此类的类成员时,该引用计数自动加一。在Release它时,不是真正的释放掉,而是减少一个引用计数,在引用计数为0时,也就意味着的确没有类再引用此类对象时,该类对象才真正的释放。

这样有很大的好处,假设一个SLG游戏,一个关卡中有多个弓箭手敌人A,B,C,他们属性行为完全一致,那么自然都是一个类的对象。在一个敌人A死亡后,这个对象将被从场景中delete掉,但此时另一个敌人B依旧使用着这个类对象,若直接delete掉该类对象,则只要我们对其他敌人进行任何处理都被判为空指针调用,甚至连桢绘制都无法进行(我们必然会每桢对地图上可见的敌人进行绘制),我们若手动去记录New了多少个类对象将会是一个愚蠢,烦琐并且不可靠的行为。继承这个类进行自动的引用计数将会是一个非常明智的选择。

2:这个类有个接口查询函数QueryInterface()不大好理解,实际上,这种COM机制并非为游戏设计的,而是为软件库开发设计,所以我自己实现的IUnknown()函数我常常将其设计为模版类,并且这个函数会有所改动。

String GetObjectType() const;

或者你可以该成

enum eObjectType { EOJ_Saber, EOJ_Knight, … EOJ_OggSoundObj, … }

eObjectType GetObjectType() const;

这样是否就明白许多?因为这个类要被大量继承,该函数是用来强制子类继承获取子类类型以方便识别的。(请注意:该函数原形中的GUID参数是OUT类型,不是IN参)

那么,我们首先确定了一个最基类。接下来就好办多了。对象进行重重继承即可。我附带一张图以便大家了解吧。

这里值得说明的是,这个场景类继承图不是绝对的标准,我们根据游戏需求更好的去完善和修改它。

例如,SoundObject我们可以从IObject继承实现,也可以从IRenderObject去继承,这需要看你对Render的理解了。场景中是否允许出现道具?掉出的道具是静态的还是会播放动画?这都会影响该类结构图的组成,这里仅仅是说明一个思路。

(考虑到英文能力不强的朋友,这里附加一份中英文说明文档)

RenderObject: 需要“渲染”的对象

LogicObject: 逻辑对象。即不需要“渲染”的对象。

Entity: 实体。 即实际存在的东西,通常指可以看的到摸的到的物体对象。

TriggerBox: 触发块。 例如:踩到的陷阱,会触发某一个事件。包括地图切换,安全区保护这些实际上都可以使用触发块实现。本身通常可以不会渲染。

InterestedRect: 感兴趣区域。 例如:玩家视野,敌人AI反应范围,扩展来说,包括魔法攻击范围,移动范围都可算。本身通常也可以不渲染。

AnimateObj: 播放动画的对象。 例如:动态的水面,随风摇摆的树木,扩展来说,可以包括动画特效。总之,多是由多张图片切换,或需要进行动态图象处理的对象。

StaticObj: 静态对象,静态无动画对象是针对于动画对象来说的。 例如:地表,道具。通常不换图片。(特殊需求特殊对待)

MoveableObj: 能够移动的对象。应当包括能够主观被移动和被动被移动的对象。当然,你愿意把被动被移动的对象(如推箱子的那个箱子)作为静态对象也没有问题。

Npc: 非玩家控制角色。

Spc: 玩家可控制角色。实际上,通常它和Npc会互相切换状态,甚至可以统一到一个类中。例如,玩家角色中了“混乱”状态,此时就不受角色控制了,而是由计算机处理。而且来有界于两者之间的Mpc,例如,一些宠物可以接受玩家命令进行事件处理,但是在玩家移动时,它又会自动跟随,此时又属于计算机处理,或者象FF12中的队友一样,只能下宏观AI指令的对象也属于Mpc之列。(好喜欢FF啊>_<)

实际上,即使是这样的设计也有漏洞,我们常规理解是,仅有MoveableObj才有攻击能力,于是,Attack()函数常理下应当被设计到MoveableObj类中,这样没有错。但是我们的策划兄弟突然大发奇想,一棵静态的树木也有攻击能力,怎么办?

我们有以下几种处理措施:

1:将树木类移动到MoveableObj之下。 2:树木类进行多重继承,同时继承MoveableObj和StaticObj 2:将Attack()函数上移,放置到Ientity类中。

那么,结果将是:

1:第一种方法将打乱结构。树木并无法实现AnimateObj的PlayAnimate()播放动画的函数。因为它是静态的,并没有动画桢。它也无法接受Moveable的MoveRect属性,因为它是不可移动的。

2:第二种方法将更强烈的破坏继承关系,随着策划朋友的灵感,多重继承将不堪想象。(- - 想起GameRes上一位策划朋友的话了“不要挑战我们策划的想象力。”残念。Orz)

3:第三种方法随着更多的要求,我们会将更多的函数上移,结果导致几乎所有的函数都被丢在IObject中,因为这个结构中,一个类函数位置的上移将极容易带动其他相关类的上移,最终头重脚轻,使类丧失其内聚性,成为糟糕的代码设计。

那么怎么办?用组件。

(…>_<完了,写远了,这里就简单提一下好了….具体的请看设计模式或其他书籍文章…)

我们假设我们需要的是一个机器,那么我们不一定非要设计好并完整的做出来,我们只要每个零件的组配起来就可以了。例如,一个汽车,我们想让它飞起来,那么,为它配一个飞机发动机即可。又希望它能吃饭,那么给它装个嘴装个胃就可以了。组件的关系图我就不画了…没装Visio的可怜人不喜欢用触摸屏Windows附件绘图,原谅我吧。下面给个代码,希望能明白:

class CTree { private: CMoveableObj* m_pObj; CInteresteRect m_Rect; public: void Attack(IObject* obj){ m_pObj->Attack(obj); } }

这样就可以了。这个树木类不仅实现了攻击的功能,还有了自己的感兴趣区域,只有当对方进入该范围内才进行攻击。当然,这不是完全的详细的组件模式,详细的可以参考其他书籍。

这里我想说明的是,即使无法绝对完善的构造我们的类继承树,我们也可以让“零件们”继承IUnknown,之后再进行组装实现这些功能。

呃,对于场景内对象就说到这里吧。下面是场景管理。

场景管理

1:资源管理器

首先,我想先从资源管理来说。这个资源管理器应当是全局的唯一的,在使用时会非常方便,那么我们设计为Singleton单键模式是非常适合的,例如:SoundManager,ImageManager等等,它们负责对对象容器进行管理控制。例如AddObject(SoundObj* obj,const String id); DeleteObject(ImageObj* obj);进行对象的插入删除工作以及对象的查询,数据获取等管理操作。值得注意的是,每个资源对象都应该有一个唯一的ID。这里有些人喜欢用GUID(全球唯一标识符),个人不推荐,首先,麻烦- -看到一堆乱码我很头大。其次,实意不明。采用Ogre那样的自定义字符串实名的话,在效率上也没有明显差距,而且更容易理解,顶。

这里并不复杂,重点是如果能用模板设计一个优秀的ISingleton提供其他类继承就好。(要考虑多线程的话,并不太简单),另外,还有一个小麻烦,单键的析构顺序并不好掌握。要稍微花些时间。

2:对象容器

ObjectContainer很容易理解,也很容易做,STL提供了大量容器,Vertor,List都不错,因为要存储每个Obj的ID,用Map也好。

这里依旧是建议使用模板设计一个通用的对象容器类,那么我们只需在它上面加俩函数,GetObjType(), SetObjType()就好了。注意:压入的时候要进行类型检查,不要让别的类型对象进错容器哦。

3:场景层级关系

这里我认为是应该分游戏进行设计的。

我先说个人最喜欢的AVG类游戏吧。它通常是分章节管理的。根据用户选项结果的不同,不仅会影响本章节的部分对白,也会影响之后到底读取哪个章节(你也可以将章节理解成事件)。那么整个游戏的SenceManager只要保存一个每个章节SectionManager的List便好了。在每个章节内,会保存一份本章节需要的音乐列表,图片列表,脚本列表等,当然,你甚至可以设置一个LayerManager画面层管理器来进行画面的控制,这样的话,可能连横幅卷轴式的游戏也可以做了呢。那么,它的大致关系如下

SenceManager (全局的管理器,保存着事件场景管理器列表)

↑多个组成

SectionManager(事件场景管理器,管理着本事件中的音乐对象列表,层管理器列表,脚本对象列表等)

↑多个组成

LayerManager (层管理器,保存着这个画面层中的精灵对象的列表)

而SLG游戏就与这个不同的,它是由一个战场一个战场组成,每个战场中不仅有NPC对象列表,还要保存格子列表,道具列表,静物列表等。但大致设计还是没有什么区别的,它的大致关系如下:

SenceManager(全局管理器,保存着每次的战场管理器列表)

↑多个组成

SectionManager(单个战斗场景管理器,其中应当包含了本次战斗的NpcList敌人列表,SpcList自军列表,FpcList友军列表,ItemList场景道具列表(假设道具可掉落在地上),SoundList音乐列表,StaticObjList静物列表 以及一个格子管理器)

↑组成部分

GridManager(格子管理器,内部有一个GridTree,负责对场景格子进行管理)

接下来我们需要详细了解一下GridManager.

4:格子管理器

恩,因为原本从事的是3DMMORPG的开发工作,我想起之前处理的一个问题,就是怪物AI反应区。当时,设计的普通怪物主动攻击区域为怪物前方一个大约45度一定距离内的扇型区域,开始是考虑使用射线进行拣选来确定对象是否在怪物主动攻击区内,虽然经过了四叉拣选剔除了大部分地图内的对象,但效率仍然非常不理想,毕竟,一张地图多少怪物啊,只要在玩家激活区域内的怪物就要进行射线拣选,服务器依然受不了。

注:玩家激活区意思是,游戏世界中的大部分怪物是静止不存在的,只有当玩家靠近时(大约3X3屏左右),才会通知它进行自我创建和移动以及AI处理。是减轻服务器压力的一种手段。这里的3*3屏就是玩家激活区。

因为射线拣选的压力很大,于是采用了地图格的管理方式,对于SLG游戏来说,这种趋势更是十分明显。

每个格子继承于IRect,其中添加两个方法,AddObject(IObj* obj); DeleteObj(IObj* obj);当我们从一个格子移动到另一个格子时,必须在目标格子上进行注册,并在旧格子中注销。这样可以很轻松的实现很多功能,包括掉落物品的注册,感应区域的注册,事件触发器的注册等,都会方便很多。

另外,之前我有说过,格子的管理使用树型管理会是非常好的选择,当前我也见过使用Vector的,麻烦些倒也可以。在一些事件处理时使用四叉树将格子层层分割,可以有效的减少渲染压力以及逻辑处理的复杂度。若是3D带高度信息的话,八叉树则可。这些东西已经出了本文范围,不再赘述。

总结

本来只想简单写一下想法的,不知觉中写了不少….今天晚上引擎又没办法写了>_<希望这篇文章能给别人的游戏开发多少带来一点点的帮助就好了。在实际开发过程中,仍然会有很多问题需要解决,还需要更多的经验和努力。路漫漫其修远兮,吾辈当奋斗不息。