(游戏实现)(翻译)Dungeon Generation in Binding of Isaac 以撒的结合(原版)房间生成规则
Posted sun_dust_shadow
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了(游戏实现)(翻译)Dungeon Generation in Binding of Isaac 以撒的结合(原版)房间生成规则相关的知识,希望对你有一定的参考价值。
Dungeon Generation in Binding of Isaac
The Binding of Isaac, and its remake, Binding Of Isaac: Rebirth are one of my favourite games of all time. It’s a roguelite twin stick shooter, much like Enter the Gungeon.
The dungeons it generates are particularly iconic. I’ve seen countless tutorials online offering how to do Isaac-like generation, but I was interested in how the original did it. To my suprise, most tutorials get it wrong. In this article I go over how the generation works, including a Javascript demo.
Though I did work through a decompilation, and brush up on my rusty knowledge of Flash (I wrote my own actionscript decompiler back in the day), I was also very fortunate that Florian Himsl, the developer of Isaac, and Simon Parzer, one of the key developers of Rebirth, were both happy to answer my questions. In fact, Florian has recently made a video describing the algorithm. Check out his whole channel for development details on his latest game, Squid Invaders.
Given his write up, this article is somewhat redundant, but if you want the gory details, press on.
本文中,我将介绍生成的工作原理,包括一个Javascript演示。
以撒的作者Florian最近做了一个视频来描述这个算法。
The Core Algorithm
Isaac is heavily inspired by the 2d zelda games, and generates maps similar to their dungeons.
It’s a bunch of square rooms that connect adjacently to each other. Several rooms are special – there’s always a shop , reward room and boss on each floor, and a few other special rooms are randomly picked too. With the exception of the secret room , there are no loops in the dungeon.
The game itself is a linear progression of such levels, typically two per “chapter”. The maps get slightly larger for later levels, and the contents of rooms change, but the layout algorithm is essentially the same every time.
The first version of Isaac was developed in under 3 months, so Himsl had to be incredibly efficient with his time. The key design is elegantly simple. First, a floorplan is generated. Then some rooms are designated as special rooms. Then the interior of each room is picked from an appropriate pool.
1.房间都是以方块为单位相连的
2.有几类特殊房间是必定有的, 有几个特殊房间是随机选的
3.隐藏房间比较特殊,没有走主loop生成,是特殊处理的
4.设计上 有效、直观、简单。
5.首先生成一个布局图floorplan;然后一些房间被指定为特殊房,然后有个合适的“房间池”去选择普通战斗房。
Floorplan 布局图
Isaac is generated on a 9×8 grid. For convenience, the cells are numbered numerically, with the units digit indicating the x position and the tens digit indicating the y postion. This means you can move up, down, left, and right just by adding +10, -10, +1 and -1. The cells with an x position of 0 are unused (always empty), which means that most of the code doesn’t need worry about the boundaries of the map. So the top left cell of the map is 01 and the bottom right cell is 79.
1.9*8的格子, up:+10 down-10 左+1 右-1
2.个位数为x ,十位为y, 左上角为01 右下角为79 【这里翻译后我对细节保持怀疑。不过不是重点】
First, the number of rooms is determined by formula random(2) + 5 + level * 2.6
. I.e. levels start with 7 or 8 rooms, and increase by 2 or 3 each time.
首先,房间个数公式为: random(2) + 5 + level * 2.6 。 例如关卡开始为7或8个房间,然后每次增长2或3个房间。
The game then places the starting room, cell 35, on a queue. It then loops over the queue.
游戏将开始房间(单元35)放置在一个队列中。然后循环遍历队列。
For each cell in the queue, it loops over the 4 cardinal directions and does the following:
对于每个格子,在4个方向上进行循环以下操作:
- Determine the neighbour cell by adding +10/-10/+1/-1 to the currency cell. (上下左右找相邻cell)
- If the neighbour cell is already occupied, give up (如果相邻cell已有实体,放弃)
- If the neighbour cell itself has more than one filled neighbour, give up.(如果相邻cell的邻居有2个以及2个以上的实体,放弃)
- If we already have enough rooms, give up(如果我们房间数量够了,放弃)
- Random 50% chance, give up (50%的概率,放弃)
- Otherwise, mark the neighbour cell as having a room in it, and add it to the queue. (否则,标记这个cell为实体房,并放入队列中去)
If a cell doesn’t add a room to any of its neighbours, it’s a dead end, and it can be added to a list of end rooms for use later.
如果一个cell不能将一个房间添加给它的任何邻居(没看懂 马上再说 -> 解释:文章内一个cell就是一个room),那么它就是一个死胡同,可以将它添加到一个终端房间列表中以供以后使用。
In the case of maps needing more than 16 rooms, the starting room is reseeded into the queue periodically to encourage more growth.
如果需要超过16个房间,起始房间会周期性重新生成,用于达到多房间的目的。
As the above starts with a single room, and repeatedly expands outwards, it’s essentially a breadth first exploration. The restriction that it won’t add a room which already has 2 neighbours keeps the rooms in separate corridors that never loop around.
本质上是个广度优先搜索,不能有2个实体邻居的限制,确保不会出现回形房间。
The floorplan is then checked for consistency. It must have the right number of rooms, and the boss room cannot be adjacent to starting room. Otherwise, we retry from the start.
然后进行一致性检查:正确的房间数量,boss房不能和起始房间相邻。否则重新生成。
Special Rooms
Boss rooms are placed by reading the last item from the end rooms list. Because of the outward growth, this is always one of the rooms furthest from the start area.
Boss放从endRoom队列中选取最后一个。这是离起始房间最远的房间之一。
Then, the Secret Room is placed. These are added to the floorplan, and one one of the few exceptions to rooms not being placed adjacent to multiple existing rooms.
In fact, it prefers it. The generator randomly searches for an empty cell that is next to at least three rooms, and not next to any end rooms
If doesn’t find one after 300 attempts, it loosens the criteria a bit, and after 600 attempts it loosens it even futher. This procedure ensures that the secret room will always be placed, but generally they are wedged near intersections so they are next to a lot of rooms.
隐藏房:最大限度保证:楔在十字路口附近,靠近许多房间。
首先随机找出邻接3个实体的空cell,并且不和endRoom相邻。如果300次尝试没有找到 则放宽条件(猜测放宽条件的意思是相邻2个实体)。600次找不到则再放宽条件。
Nearly all other special rooms are placed at a random end room. Some rooms are guaranteed to spawn, while others have a small chance or criteria. For example, Sacrifice rooms appear 1 in 7 times, unless you are at full health, in which case they appear about 1 in 3 times.
其他特殊房间也是随机到endRoom内。一些房间确保生成,但是有些只有很小概率。比如献祭房只有1/7的概率创建,但是如果你HP是满的,则有1/3概率创建。(这么说有些特殊房间还是runtime决定的 ?存疑)
Normal Rooms 标准战斗房间
Adjacent rooms always have a door (or destructible wall) in the exact center and every room is designed to always be accessible from a four directions. Thus, there’s no special considerations required when choosing rooms – they will always work.
相邻房间总是联通的(有门或者可破坏的墙)。这样的话在选择NormalRoom时候不需要特殊考虑
Rooms are randomly picked from a pool. Rooms include both the layout (pits, fires, boulders etc) and the monsters. Both are subject to random variations like champion monsters, tinted rocks, and red fireplaces.
房间是从随机池拿出并创建的。房间内的怪物、障碍物等也都是随机创建的。
For normal rooms, there are 3 pools of rooms – easy, medium and hard. The first stage in a given chapter draws easy and medium rooms, and the second stage from medium and hard. The first chapter, the Basement, has 174 normals rooms in the pools. The “alternative chapters” such as the Cellar which can replace the Basement randomly, have a slightly different set of rooms.
针对标准房,有三种大类型的pool:简单中等和困难。章节刚开始的阶段是简单和中等房间,第二阶段是中等和困难房间。第一章地下室有174的房间在pool里(174个房间什么意思?一共就不超过20个类型的房间啊?存疑,大概率是同种形状的房间 enemy模版等不一样。)
Curse of the Labyrinth 迷宫的诅咒
One of the most interesting extras in the code is double sized maps. These occur randomly, and also for several of the games challenge modes. Aside from the obvious duplication of special rooms and two adjacent boss rooms, it has lots of small details too:
双倍大小地图。随机触发的,有些挑战模式也是随机触发的。除了明显的 重复特殊房和2个相邻的boss房外,还有以下细节:
- 80% more normal rooms (max 45) 多了80%的标准房间(上限45)
- Only use the 6 furthest away end rooms for special rooms 特殊房间指定为最远端的6个房间
- It pulls from easy, medium, and hard room pools. 不分阶段,直接从3种房间池中提取。
- Randomly adds extra normal rooms to the floorplan with similar placement logic to Secret rooms. 随机添加额外的普通房间,布局逻辑与秘密房间相似。
Demo
I’ve put together a simplified example of the generator in javascript for you to play with. The full code can be found here.
Rebirth 以撒 重生
Though Rebirth has a lot of fun new features, the main contribution to the level generation is adding larger, irregularly shaped rooms.
重置版内有:更大且形状不规则的房间。
This was implemented by Simon Parzer as a careful modification of Himsl’s original code.
作者重改了源码实现的
Instead of looping over the cardinal directions, it loops over all exits of a room instead. There can be up to 8, on a 2×2 room.
不再使用可选方向进行循环。使用所有可能存在的Room方向进行循环。比如2X2的大方形房间,有8种类型的可能。
When it comes to inserting a room, it randomly tries to use a big room instead. The neighbour check still only applies to the first cell, the one by the door, but it does check that there is empty space for the rest of the room.
That means that big rooms can cause some loops in the level. Commonly two large rooms are generated side by side, complete with a pair of doors leading between them.
没看懂
If there’s no space for the room, another candidate is tried. When large rooms are successfully inserted, there’s a 95% they are removed from the pool.
Even more code was needed to handle large boss rooms. Recall that boss rooms are always placed as far as possible from the starting room. If a large room is desired, the generator replaces the designated single room. As boss rooms are always dead ends, the replacement is checked that it not adjacent to any extra rooms. Sometimes the replacement is still not possible, so all the endpoints at max distance from the start room are tried before giving up entirely.
When it comes to selection rooms, the adjacent rooms on the floorplan are now considered, and some rooms are only picked if it determines that no doors are needed.
没看懂
本文详细说明了以撒原版的实现规则。不过以撒重生(多种形状的Room)的实现规则比较模糊,待我再找找。
C/C++游戏开发丨Dungeon丨游戏框架搭建丨设计模式丨可扩展性高
一、项目简介
之前很喜欢《元气骑士》这种风格的手机游戏,所以也想做一个类似的 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<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 库
所有页面都被封装成类,并被页面管理器(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 文件加载,因此,如果不涉及对象行为逻辑的更新,可以在不进行重新编译的情况下对游戏内容进行较大幅度的改动。比如角色、武器、子弹等属性的调整,新武器、新敌人、甚至新地图样式的添加都可以完成。
五、总结
游戏中几乎所有美术素材都是我自己绘制的,工作量比我想象的要大,因此只绘制了两种风格的地图,怪物与武器的种类也并不多,有待之后进一步丰富。此外,游戏平衡性也有待提升。
虽然达到了最初的设计目标,但很多地方实现比较笨拙,也没有进行很多优化,还希望大家多多指教。
【完整的项目文件可以在主页的编程粉丝俱乐部里下载】
以上是关于(游戏实现)(翻译)Dungeon Generation in Binding of Isaac 以撒的结合(原版)房间生成规则的主要内容,如果未能解决你的问题,请参考以下文章
leetcode 174. Dungeon Game 地牢游戏 --------- java
C/C++游戏开发丨Dungeon丨游戏框架搭建丨设计模式丨可扩展性高
C/C++游戏开发丨Dungeon丨游戏框架搭建丨设计模式丨可扩展性高