撰写了文章 发布于 2019-04-23 12:29:43
《真菌洞窟(Fungus Cave)》四月开发日志
一个宇宙人,一个未来人,一个超能力者。
游戏简介
《真菌洞窟(Fungus Cave)》是一款正在开发的单人、回合制 Unity Roguelike 游戏。最新版本是 0.1.1。上个月 以来主要做了四件事情:
- 将游戏数据从代码内部迁移到 XML 文件。
- 新增第二层地下城。
- 强化第一层的敌人,让游戏难度平稳上升。
- 修复自动探索。
本文将讨论三个话题:读写 XML 文件,读写二进制文件,自动探索。
图 1:演示动画,四月。
图 2:演示动画,三月。
读写 XML 文件
游戏对象需要数据,但是我们不希望把对象和数据直接绑定起来,因为数据可能来自代码内部的字典,外部的文件,或者现场生成的随机数。我们可以在游戏对象和数据源之间搭建一条管道:
- [ 数据源 ] <--> [ 数据枢纽 ] <--> [ 游戏对象 ]
数据枢纽负责两件事情:
- 从源头读取数据,或者把数据写进源头。
- 从对象那里收集数据,或者把数据发送给对象。
游戏对象调用某个方法与数据枢纽沟通,这个方法可能属于游戏对象,也可能属于数据枢纽。
我为 XML 数据和二进制数据建立了两条略有不同的管道:
- [ XML 文件 <--> SaveLoadFile ] <--> [ XData ] <--> [ 游戏对象 X ]
- [ 二进制文件 <--> SaveLoadFile ] <--> [ SaveLoadGame ] <--> [ 游戏对象 Y ]
SaveLoadFile 有四个公用方法:
图 3:公用方法。
这个对象直接读写文件,输出一个完整的数据对象。
XData 代表了一系列数据枢纽对象,它们从 LoadXML() 的返回值里抽取出一部分数据,供特定游戏对象使用。有些数据枢纽实现了 ISaveLoadXML 和/或 IGetData。
ISaveLoadXML 封装了 LoadXML(string path) 和 SaveXML(string path),这个接口提供的两个方法只能读写特定文件。IGetData 负责抽取数据。
图 4:ISaveLoadXML 和 IGetData。
我的 XML 文件通常包含两个节点(见图 2),可以使用 IGetData.GetIntData("ActorTag", "HP") 获得数据。
图 5:XML 文件结构。
ActorData 是一个 XData 对象,负责读取 Data/actorData.xml。
游戏对象 X 是 XML 数据管道的最后一站。它调用数据枢纽的方法获得数据,比方说:
图 6:游戏对象获取数据。
所以说,看到游戏对象和数据源,我们的下一个问题肯定是:数据枢纽在哪里?万事皆三,巴佬们不会懂的。
读写二进制文件
我们来看第二条数据管道。
- [ 二进制文件 <--> SaveLoadFile ] <--> [ SaveLoadGame ] <--> [ 游戏对象 Y ]
SaveLoadFile.SaveBinary() 把一个数组 IDataTemplate[] 写入二进制文件。SaveLoadGame 收集游戏对象的数据,把类型转换成 IDataTemplate,传送给 SaveLoadFile。这里有一个关键点。我们不必序列化整个 Person 对象,因为它的 MaxHP,Name 等数据保存在 XML 文件里。不妨创建一个小对象 DTPerson,单独存放这个对象的 Salary。
图 7:IDataTemplate 和 DTPerson。
SaveLoadGame 类似 XData,它有两个职责:
- 收集游戏对象的数据,然后调用 SaveLoadFile.SaveBinary()。
- 调用 SaveLoadFile.LoadBinary(),然后把数据发送给游戏对象。
那么怎样收集和发送数据呢?SaveLoadGame 包含一个私有数组 ISaveLoadBinary[] slb。游戏对象 Person 实现了接口 ISaveLoadBinary,把指向自己的引用保存在 slb 里面。接口定义见图 8。
图 8:ISaveLoadBinary。
保存游戏的时候,我们遍历 slb 的每一个元素,调用 Save(out IDataTemplate data) 收集数据。Person 的 Save() 定义如下:
图 9:Person.Save() 和 Person.Load()。
读取游戏存档的第一步是调用 SaveLoadFile.LoadBinary(),把返回值放入临时变量 IDataTemplate[] load。接下来,我们遍历 load 的每一个元素,根据 IDataTemplate.DTTag 调用不同对象的 ISaveLoadBinary.Load()。请看一个简单的例子:
图 10:读取二进制文件。
RandomNumber 实现了 ISaveLoadBinary,它的一部分数据被保存为 DTSeed 对象,详见
DataTemplate。
自动探索
自动探索的原理,Roguebasin 讲得很清楚了,但是代码写起来挺容易出错的。一个月前我发现自动探索有点问题,上周花了一个晚上重写了一遍。这个模块包含三个组件:
- AutoExplore 提供了一个(并且只有一个)公用方法,输出下一步移动的坐标。
- AutoExplore 需要来自 PCAutoExplore 和 NPCAutoExplore 的数据,这两个对象都实现了 IAutoExplore。
首先来看一下 AutoExplore 的方法 `public int[] GetDestination()`。
图 11:GetDestination()。
在 ResetBoard() 内部,我们首先生成一个和地下城一样大的二维数组;然后定义三个特殊的距离;接下来遍历二维数组的每个元素,设置初始距离,记录起始位置;最后返回这个二维数组。
图 12:ResetBoard()。
三个判断条件顺序不能弄错。起始位置未必是一方通行的。比方说,白色相簿试图接近米④达,把后者所在位置标记为起始点,但这个位置是被占据的,因此无法通过。
GetDestination() 的第二步很简单,我们直接看第三步。SetDistance(int[,] dungeon, Stack<int[]> start) 递归地标记出所有未探索(unexplored)格子的距离。
图 13:SetDistance()。
上述代码里出现了两个方法:GetNeighbor(int[] center) 和 GetDistance(int[] center)。前者返回 center 周围八个格子的坐标,后者做了三件事情:
- 调用 GetNeighbor(),获取相邻位置。
- 找出上述位置中的最小距离。
- 让最小距离增加固定值,然后返回这个数值。
GetDestination() 的最后一步是找到与当前演员相邻、并且距离最小的格子。如果有多个距离相等的格子,随机选择一个。
IAutoExplore 这个接口不是必需的,但是利用这个接口,我们可以让一套代码满足多种用途:让 PC 自动探索,让 NPC 追踪 PC。稍微改一下 SetDistance(),我们还能够让 NPC 逃离 PC,我之前说过怎样实现 逃离算法。PC 应该在发现敌人时停止自动探索;有时候 PC 会在两个格子之间来回移动,最好避免这种情况。这些功能都可以添加进 PCAutoExplore。
以上是本月总结。最后留一道思考题。请结合创作时间(1995,2006 和 2017),分析以下三幅画面的镜头语言。
图 14:是 [时间删除] 的味道!
_