之前很喜欢《元气骑士》这种风格的手机游戏,所以也想做一个类似的 Roguelike 游戏。对于刚学习了一些基本设计模式的小伙伴,可以把这个项目当作初步实践。
这次构建一个简单的通用游戏框架,使得游戏具有更强的灵活性与可扩展性供小伙伴们学习参考。
项目源代码:点击下载 Dungeon 1.0.1.zip
该项目解决方案下包含三个工程:Dungeon,Dungine 和 TinyXML2。
其中 TinyXML2 工程是为了把 TinyXML2 库打包成静态链接库方便使用,编译时直接编译整个解决方案即可。
Release 模式下,编译成功后可执行文件将输出到 Publish\ 目录下;
Debug 模式下,编译成功后可执行文件将输出到 Build\dist\Debug\ 目录下。默认采用 Release 模式编译,程序中有关调试信息的宏已关闭。
编译环境如下:
● Windows 11 Pro
● Visual Studio 2022 Community
● EasyX 20220610
● FMOD 0.2.2.7
该项目包含游戏框架部分 Dungine (Dungeon Engine) 和游戏主体 Dungeon 两部分。除了 EasyX 外,还使用了音频库 FMOD,以及用于 XML 解析的 TinyXML2。
▍4.1 Dungine
该部分是一个较为通用的游戏框架,包括游戏中基本类型的定义,以及设备相关的封装,同时也包括一个简易的 UI 库。
▁ 4.1.1 游戏对象
框架的最核心部分之一是对游戏对象的抽象。对于游戏中需要的常见对象,比如角色、武器等,均使用了工厂模式和原型模式进行创建,并通过组件模式添加各种行为和属性。下面展示了游戏对象类和组件类的基本声明,项目中的具体实现要稍复杂一些。
GameObject 有一个重要的成员 m_isValid,因为删除对象并不是直接进行的,而是通过设置该标记,然后由场景类删除。
这里的 AbstractObject 是更一般的对象,包括对组件等的抽象,其提供了原型模式的两个 Clone 方法。
class GameObject : public AbstractObject
{
public:
GameObject(Scene* scene);
virtual ~GameObject();
// 子类调用该方法生成新的子类。
virtual GameObject* Clone() const;
// 子类复制时调用父类的该方法实现父类中成员的复制。
virtual void Clone(GameObject* clone) const;
// 游戏对象的更新和绘制。
virtual void Update(Event* evnt);
virtual void Draw();
// 组件的添加与获取。
void AddComponent(AbstractComponent* cmpt);
template T* GetComponent()
{
auto it = m_components.find(T::StaticName());
if (it != m_components.end())
return static_cast(it->second);
else
return nullptr;
}
protected:
Scene* m_pScene; // 游戏对象受所在场景的管理。
// 所有组件以及按更新顺序排列后的组件。
std::unordered_map m_components;
std::multimap m_cmptUpdateQueue;
bool m_isValid;
};
class AbstractComponent : public AbstractObject
{
public:
AbstractComponent(GameObject* gameObject, int updateOrder);
virtual ~AbstractComponent() {}
static const char* StaticName();
virtual const char* Name() { return StaticName(); }
virtual AbstractComponent* Clone() const;
virtual void Clone(AbstractComponent* clone) const;
virtual void Update(Event* evnt);
protected:
GameObject* m_pGameObject; // 组件必须获得其所属的对象以便更新。
int m_updateOrder;
};
场景类对游戏中的所有对象进行管理,主要通过对象池实现。(这里的对象池只是保存游戏场景中的所有对象,并不是提供对象复用的功能。)
所有在更新过程中遇到的对象添加、移除等动作都会在更新完成后统一处理。
class Scene
{
public:
Scene();
virtual ~Scene();
virtual void Update();
virtual void Draw();
void AddObject(GameObject* object);
void RemoveObject(GameObject* object);
protected:
void _DeleteObject(GameObject* object);
void _UpdateObjectPool();
// 当前需要更新的所有对象。
ObjectPool m_gameObjects;
// 更新中添加的对象会先保存至此。
ObjectPool m_pendingObjects;
// 更新中删除的对象会先保存至此。
ObjectPool m_dirtyObjects;
bool m_isUpdating;
};
class ObjectPool
{
public:
ObjectPool();
~ObjectPool();
void Update(Event* evnt);
void Draw();
void AddObject(GameObject* object);
bool RemoveObject(GameObject* object);
bool DeleteObject(GameObject* object);
void Clear(); // 清空对象,但不进行 delete。
void Destroy(); // 释放并清空所有对象。
private:
std::vector m_pool;
};
▁ 4.1.2 图像绘制与音频播放
对于图像绘制,这里把 IMAGE 类封装为了 Symbol,包含位置、图层、旋转角度、缩放比例、透明度等信息。由 Device 类管理绘制,使用画家算法,对所有 Symbol 排序后进行绘制。
对于音频播放,这里封装了 FMOD 的相关函数。声音分为两种,一种是短时间的音效,比如按钮按下的声音;一种是长时间的背景音乐。
▁ 4.1.3 资源管理
这里按我自己的想法实现了一个资源管理器,在程序开始运行时,仅从外部 XML 文件读取所有资源的索引。
当需要某一资源时再通过索引加载,并在资源不被使用时自动将其释放。资源分为图像资源、音频资源、动作资源三种,其中动作资源是提供给动画使用的 Sprite Sheet。
▁ 4.1.4 UI 库
所有页面都被封装成类,并被页面管理器(Application)管理,并由其启动。页面支持切换的过渡动画以及子页面(弹窗)的实现。
UI 控件均可通过外部 XML 文件加载,支持绝对坐标和相对坐标,并可根据屏幕大小自行适配。同时,还可以添加动画效果,不过该功能目前并不完善,只能支持简单的位移、缩放和透明度变化效果。
此外,键盘鼠标信息的接收也包括在 UI 库中,这里仅仅使用数组记录按键信息,不过将按键信息分为两种:InstantKey 是只要按下就是 true,松开就是 false;SluggishKey 则是只有按下的第一帧是 true,之后若不松开,也会变为 false。这里额外检测了窗口激活消息,如果窗口失去焦点,则会停止接收键盘和鼠标消息。
▁ 4.1.5 其他
除了主要功能外,该框架还提供了一些其他的功能,比如基于 TinyXML2 的 XML 解析,向量运算,四叉树等,还提供了如单例模式、原型模式、工厂模式等的模板基类。
这里在工厂模式的基础上设计了 Library 类,用于存放一类对象的所有原型。
其中的原型均通过工厂模式从 XML 文件创建,此后该类对象便可直接从 Library 中的原型直接复制得到。
▍4.2 Dungeon
该部分是游戏的具体实现,包括游戏核心流程,地图的生成,游戏对象的具体实现,以及游戏的各个页面。
▁ 4.2.1 核心流程
游戏核心流程由 Dungeon 类实现,其派生于 Scene 类。由于所有游戏对象的更新和绘制均可由对象池统一管理,因此其主要进行资源的初始化以及调用地形的生成,还有一些特殊对象,如随机宝箱(Crate)的生成等。这里使用了四叉树进行碰撞优化。
▁ 4.2.2 地图生成
地图的生成基于一个 3 * 3 的网格图,通过随机化 Prim 算法生成一个顶点数为 3 ~ 9 的树作为地图的基本形状,顶点数与当前关卡数和游戏难度成正相关。树的顶点随后生成房间(Arena),边生成连接房间的桥(Bridge)。
房间中障碍的生成有三种模式,也可能无障碍。每个房间内含 Graph 类,Graph 类将房间划分为网格,记录障碍信息,并提供 A* 寻路算法和寻找空白区域算法的接口。
▁ 4.2.3 对象行为
所有对象均通过组件赋予其属性或行为。
属性方面,比如,参与碰撞的物体都有 RigidBodyComponent 和 ColliderBoxComponent 组件,移动的对象都有 MoveComponent,需要绘制的对象都有 AnimComponent 等。
行为方面,有行为的对象均包含 BehaviorComponent,而对象的每一个行为都是一个派生自 Behavior 的类,而非通过 if - else,这样使得对象的行为有更大的灵活性,也可以方便地添加更多行为。类似的,对象的状态也是如此。
下面是行为组件的大致实现。
class BehaviorComponent : public AbstractComponent
{
public:
BehaviorComponent(int updateOrder);
virtual ~BehaviorComponent();
virtual const char* Name();
virtual BehaviorComponent* Clone() const;
virtual void Clone(BehaviorComponent* clone) const;
virtual void Update(Event* evnt);
void AddBehavior(Behavior* behavior);
void ChangeBehavior(const char* name);
private:
std::unordered_map m_behaviors;
Behavior* m_pCurBehavior;
};
class Behavior : public AbstractObject
{
public:
Behavior();
virtual ~Behavior() {}
virtual const char* Name() const;
virtual Behavior* Clone() const;
virtual void Clone(Behavior* clone) const;
virtual void Update(Event* evnt);
virtual void OnEnter();
virtual void OnExit();
protected:
BehaviorComponent* m_parent;
};
▁ 4.2.4 游戏页面
游戏包括主页面、设置页面、关于页面等,每个页面的 UI 控件样式及布局均由外部 XML 文件提供,但是事件的绑定还是在程序中进行。
游戏界面还需要管理 Dungeon 类的初始化和每帧的更新,同时游戏内的部分元素,比如玩家的状态栏、关卡数提示等,均由 UI 控件实现,因此也需要一定的交互。
▁ 4.2.5 可扩展性
游戏中绝大部分数据均从外部 XML 文件加载,因此,如果不涉及对象行为逻辑的更新,可以在不进行重新编译的情况下对游戏内容进行较大幅度的改动。比如角色、武器、子弹等属性的调整,新武器、新敌人、甚至新地图样式的添加都可以完成。
游戏中几乎所有美术素材都是我自己绘制的,工作量比我想象的要大,因此只绘制了两种风格的地图,怪物与武器的种类也并不多,有待之后进一步丰富。此外,游戏平衡性也有待提升。
虽然达到了最初的设计目标,但很多地方实现比较笨拙,也没有进行很多优化,还希望大家多多指教。
【完整的项目文件可以在主页的编程粉丝俱乐部里下载】