软件构造思想在Unity项目中的实践举例(2)
本文系笔者在学习软件构造课程期间所写,不保证通用性和正确性,仅供参考。
目录
一、前言
详见上一期软件构造思想在Unity项目中的实践举例(1),这是一个早早就选好题但因为懒才拖到现在的系列。我将介绍我的一个正在工作中的unity项目:一款自制音乐游戏,而本期将围绕其目前最复杂的一个过程:读取谱面文件,并据此生成关卡。在这其中,软件构造的思想和规范在很大程度上帮助了我把这个过程从想到什么写什么变得更加规范,优雅。
可以通过这份游戏内截图直观感受一下这个项目大概是什么样的:
二、Spec撰写与Test First
整个过程主要在一个类:ChartReader中完成。给定文件路径,ChartReader会读取并分析文件内容,返回一个解析好的谱面内容ChartInfo类。
在这里先提一个关于内存分配的设计:从上面的概括看出读取谱面的这个过程完全可以成为一个静态方法,然而读取过程复杂,需要划分成很多小函数,还涉及到一些本地变量,不适合把所有用到的变量都设为static。所以定义了一个public的静态方法和一个private的动态方法,静态方法实际上是声明了一个动态对象,再用该动态对象读取文件。这样的好处一方面在于内存分配在栈上,谱面读取完后就会被清理,另一方面也便于多次调用。
public static ChartInfo Read(string levelPath, int difficulty, float speed)
{
ChartReaderNew chartReader = new();
return chartReader.InClassRead(levelPath, difficulty, speed);
}
private ChartInfo InClassRead(string levelPath, int difficulty, float speed){...}
2.1 用Spec规范方法
回到这一块的主题。由于谱面文件中会涉及到对很多物体的描述,也就意味着类中会有很多不同的方法,这些方法堆在一个类里,回头再来看很容易就看晕了,这时候spec就很有用了,它描述了每个方法是干什么的,输入格式是什么,返回什么,很快就可以想起之前是怎么实现的。另外,写完文档注释后,在其他地方遇到这个方法,鼠标划上去就能看到描述,也十分方便。
举个例子,游戏中的音符有一种是UntrackNote(简称为UNote),即没有轨道的Note,虽然它在游戏中的表现是这样的,但实际上还是附着在一个透明的轨道上。在谱面文件里自然不需要体现它的轨道,所以一个UNote在谱面文件中表现为一行:
unote(timeJudge, width, pos, speed);
timeJudge:判定时间|正整数,width:Note的宽度|正浮点数,pos:Note的水平位置|任意浮点数,speed:下落速度|任意浮点数
主方法中,用正则表达式匹配到unote后,将得到的匹配作为参数传给处理函数HandleUNote(Match match)。怎么确定传进来的Match就是合法的匹配呢?这时候spec就派上用场了:
/// <summary>
/// 将一个由unote行匹配得到的Match转换为一个UNote对象。<br/>
/// unote(timeJudge, width, pos, speed);<br/>
/// timeJudge:判定时间|正整数,width:Note的宽度|正浮点数,pos:Note的水平位置|任意浮点数,speed:下落速度|任意浮点数<br/>
/// </summary>
/// <param name="match"> 该Match的Groups中的捕获组需要依次符合上述数据的规定。</param>
/// <returns> 转换好的UNote。如果Groups长度不够,会抛出异常。</returns>
private UNote HandleUNote(Match match){...}
因为是private的方法所以注释得没有非常非常规范正式了。但是这么标注一下后,调试起来就轻松多了。有一说一,C#使用XML风格的文档注释,倒是没有Java来得直观与方便。
事实上,除了在ChartReader类内的这些注释,在其他很多地方,我也在逐步完善spec,spec的实现对个人来说有效地避免了过几周如看天书的情况,以后如果与别人合作了也方便交流与规范。
2.2 制定测试用例
ChartReader天然地适合测试优先的思想:要做什么已经很明确了,而复杂的内部逻辑又很难保证不出bug。在实现之前,先把测试用例想好,对于实际编写也大有脾益。鉴于是unity,最好的测试方式当然是实际运行起来跑一遍了!
还是以UNote为例子,接下来引入谱面文件中的另一个概念:修饰行。修饰行修饰其前面的元素,但本身并不能作为一个实际的物体存在。如下例,单独的unote只会垂直下落,而pos修饰行使其拥有了水平移动的能力:
unote(1000, 1.0, -1.5, 1);
pos(0, 3.0, i, 500, 0, o);
则pos行蕴含了这么一个信息:0ms时,unote在水平3.0的位置上,通过"i"曲线移动,直到500ms移动到水平0的位置上,再通过"o"曲线移动,直到1000ms (timeJudge)移动到水平-1.5的位置上。
这时就有很多可能了:unote的速度为负会怎样?unote的时间超出了音乐长度会怎样?单独一条pos行会怎样?pos行中的时间序列非递增会怎样?pos行中的时间超出了unote本身的时间会怎样?...在制作测试用例时明确了这些问题,实现的时候思路也会清晰很多。
像这样的修饰行还有很多,这也是导致这个工作复杂的主要因素之一。
三、防止表示泄漏
对于一个unity项目来说,到最后总归是要把项目整个打包起来的,内部某个方法是否有表示泄漏,似乎并不太重要。然而,在文件读取的过程中,表示泄漏的问题也有了十分的现实意义。
这还得说到上文介绍的修饰行,例如对unote而言,还有另外一个修饰行speed,容易想见它是用来精细控制音符的下落速度的。
对应的,在UNote类中,有一个域叫speedList,存储着这个UNote在各个时间段的运动速度。
一开始,我的思路甚是简单粗暴:直接把这个域设为public,读谱的时候直接给这个域赋值就好了。学习软件构造后,我认识到这种方法的大问题所在:太表示泄漏了!虽然没有什么不怀好意的人攻击我,但是由于还有其他类型的修饰行,还需要经常对UNote进行操作,很难保证在其他方法中不会一不小心又把speedList改了,两次操作叠加,最后产生bug。于是,改进后的UNote改为用一个方法来为speedList赋值,在方法中进行了必要的转化和防御性拷贝。
反过来想,这样做不仅是防止了表示泄漏,也是对相关操作的规范化。当遇到速度相关的修饰行时,便不再是想到哪步就随意地改一改speedList,而是要等到整个步骤完成后用完整的结果进行赋值。显然,这样的规范化对代码的可维护性也大有好处。
四、重载与修饰
在一开始构思谱面文件语法时,我希望它是一个尽可能自由的语法,因此提供了一些语句的“重载”。比如上文提到,unote行的格式为
unote(timeJudge, width, pos, speed);
事实上,语法中还设计了一种省略了speed参数的unote行写法:
unote(timeJudge, width, pos);
在一开始的设计中,我要求如果采用了省略的写法,则该unote行必须有一个speed修饰行来提供速度。这看起来好像挺合理的,是吧?可是实际实现起来,就会发觉这其中遇到了许多困难,尤其重要的是不便于维护:如果还可以省略pos或者width呢?自由度变得更高了之后,对缺省的处理和对必要修饰行的检查就会变得繁琐且困难。
软件构造中讲到的重载以及decorator设计模式给了我启发,于是我更改一开始的语法规则:
现在,每个表示某个游戏物体的语句本身必须可以在没有修饰行的情况下单独表示一个行为完整的游戏物体;所有的修饰行,是在一个完整的游戏物体的基础上覆盖它原有的属性,或者为它装饰上新的属性。
这个改变令我豁然开朗:原来的修饰行既可以游离又可能不可缺失,而现在的修饰行就相当于一个个插件,插在游戏物体上,改变它的行为。这种改变,无论是对客户端(玩家、制谱师),还是对代码实现,逻辑都清晰很多。基于这个思想,还可以扩展出双行绑定的修饰行、嵌套的修饰行......开发顿时变得十分顺利。
我仍然保留原来的省略语法:unote可以没有speed参数,但是现在它可以不需要speed修饰行,而是像方法的重载一样,设置了speed的默认参数:令其为1。
五、结语
由于本文主要是介绍在项目实践中体现的软件构造思想,因此并没有详细地解释整个ChartReader的实现原理,甚至在一部分中对实际实现的叙述有一定的修改。相信对有音乐游戏游玩经验的读者来说这篇文章应该会挺好理解的;如果没有接触过音乐游戏的话,也希望我的叙述足够直观吧。