Turmoil

A slow learner.

Dungeon 金牌收录

一、项目简介

之前很喜欢《元气骑士》这种风格的手机游戏,所以也想做一个类似的 Roguelike 游戏。刚好最近学习了一些基本的设计模式,就把这个项目当作是我的初步实践。此外,由于之前也写过一些游戏,但是每次都从零开始,对于游戏内对象的管理也比较混乱,所以这次也希望构建一个简单的通用游戏框架,使得游戏具有更强的灵活性与可扩展性。

更多内容以及可执行文件下载可以访问我的个人网站:Tony's Studio

二、运行截图

battle-1

battle-2

三、项目源代码及编译说明

项目源代码:点击下载 Dungeon 1.0.3.zip

该项目解决方案下包含三个工程:Dungeon,Dungine 和 TinyXML2。其中 TinyXML2 工程是为了把 TinyXML2 库打包成静态链接库方便使用,编译时直接编译整个解决方案即可。Release 模式下,编译成功后可执行文件将输出到 Publish\ 目录下;Debug 模式下,编译成功后可执行文件将输出到 Build\dist\Debug\ 目录下。默认采用 Release 模式编译,程序中有关调试信息的宏已关闭。

编译环境如下:

  • Windows 11 Pro
  • Visual Studio 2022 Community
  • EasyX 20220901
  • FMOD 0.2.2.7

注:EasyX 20220901 中将消息类型前缀 EM 改为了 EX,可能导致与旧版本不兼容。

四、项目实现

该项目包含游戏框架部分 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<typename T> T* GetComponent()
	{
		auto it = m_components.find(T::StaticName());
		if (it != m_components.end())
			return static_cast<T*>(it->second);
		else
			return nullptr;
	}

protected:
	Scene* m_pScene;	// 游戏对象受所在场景的管理。

	// 所有组件以及按更新顺序排列后的组件。
	std::unordered_map<const char*, AbstractComponent*> m_components;
	std::multimap<int, AbstractComponent*> 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<GameObject*> m_pool;
};

4.1.2 图像绘制与音频播放

对于图像绘制,这里把 IMAGE 类封装为了 Symbol,包含位置、图层、旋转角度、缩放比例、透明度等信息。由 Device 类管理绘制,使用画家算法,对所有 Symbol 排序后进行绘制。

对于音频播放,这里封装了 FMOD 的相关函数。声音分为两种,一种是短时间的音效,比如按钮按下的声音;一种是长时间的背景音乐。

4.1.3 资源管理

这里按我自己的想法实现了一个资源管理器,在程序开始运行时,仅从外部 XML 文件读取所有资源的索引。当需要某一资源时再通过索引加载,并在资源不被使用时自动将其释放。资源分为图像资源、音频资源、动作资源三种,其中动作资源是提供给动画使用的 Sprite Sheet。

4.1.4 UI 库

上一个版本的 UI 库参考了这篇文章:EasyUI:基于 EasyX 的 UI 界面库(by 祝融)。在这一版本中,我根据我的需求对其进行了重构,包括实现细节与各种类的组织关系,同时添加了如许多新特性。所有页面都被封装成类,并被页面管理器(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<const char*, Behavior*> 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 文件加载,因此,如果不涉及对象行为逻辑的更新,可以在不进行重新编译的情况下对游戏内容进行较大幅度的改动。比如角色、武器、子弹等属性的调整,新武器、新敌人、甚至新地图样式的添加都可以完成。

五、总结

游戏中几乎所有美术素材都是我自己绘制的,工作量比我想象的要大,因此只绘制了两种风格的地图,怪物与武器的种类也并不多,有待之后进一步丰富。此外,游戏平衡性也有待提升。

这是我第一次使用设计模式构建较大的项目,虽然达到了最初的设计目标,但很多地方实现比较笨拙,也没有进行很多优化,还希望大家多多指点。

评论 (7) -

  • 大佬,这是需要额外安装什么东西吗,我用VS2022打开后编译怎么报错了呢?
    • 应该是 EasyX 更新后把消息里的 EM 前缀改成 EX 前缀了,在 Dungine 的 Event.cpp 里改一下就好。

添加评论