背景
TDD:测试驱动设计,各种理论,各种优劣,网上有很多的文章来介绍,但是怎么做TDD,从何处开始?会遇到什么问题?怎么解决?OK,PI君也是刚接触TDD没多久,理论不多说,直接从一个小项目开始。
项目需求
① 一个足球比赛类小游戏,用户可以通过键盘操控控球球员前进/后退/左转/右转/加速/传球/射门;如果用户控制的球队没有球权,则用户可以切换控制球员进行铲球/防守;用户可以控制游戏开始,设置游戏时间,一般有两支球队进行比赛;
② 每个球队有一个教练,有十一个球员,有自己的球队队形,用户可以自己调整针对特定队形的球员站位,有自己的队服/队徽;
③ 有一个管理员账号,管理员可以管理球队相关数据,包括球员数据/教练数据/队形数据/队服数据/队徽数据。
需求分析
根据需求确定UseCase,尽可能使用代码描述UseCase。
UseCase-One:Play football game
① 一个足球比赛类小游戏,用户可以通过键盘操控控球球员前进/后退/左转/右转/加速/传球/射门;如果用户控制的球队没有球权,则用户可以切换控制球员进行铲球/防守;用户可以控制游戏开始,设置游戏时间,一般有两支球队进行比赛;
Pi君把需求①进行逐句分析如下:
Step-One:
一个足球比赛类小游戏,有两个球队进行比赛,用户可以控制比赛开始,暂停和结束,Code 描述如下:
FootballTeam teamA=newFootballTeam(); //新建一个球队A
FootballTeam teamB=newFootballTeam(); //新建一个球队B
FootballGame newGame=newFootballGame(team_A, team_B);//新建一个游戏,并用A和B球队初始化该游戏newGame.Start(); //开始游戏
newGame.Pause(); //暂停游戏
newGame.GameOver(); //结束游戏
注:文中的代码都是在新建的单元测试里进行编写,其中涉及到的FootballTeam等类型,实际上并不存在,Pi君就是通过代码把UseCase建立起来,然后确定有哪些类型需要创建,每一个类型又有哪些方法/成员等等,这些都是TDD的理论基础,不熟悉的看官直接Google吧。Pi君在此就不啰嗦了。
注:代码中有特殊标记的部分都是后续可能会引用分析,并进行修改的部分,暂时可以忽略其效果。
注:文中Pi君给出的是C#版本的代码,但是有关TDD的实践方式是相通的,如需java/python/C++版本,Pi君会根据时间安排进行转换,至于其他语言版本,很遗憾Pi暂时还不擅长。
Step-Two:
每个球队有十一个球员,比赛过程中,当球队具有球权时,则用户只能通过键盘控制控球球员进行前进/后退/左转/右转/加速/传球/射门的动作;当球队失去球权时,用户可以切换受控制的球员,在切换过程中,选取用户控制球队距离足球最近的球员。
detail step by step:
① 每个球队有十一个球员:
FootballAthlete piAthlete = new FootballAthlete(“Pi君”); //新建一个名字叫做Pi君的球员(类似新建11个球员)
team_A.AddAthlete(piAthlete); //把PI君等11个球员依次添加至球队A中
② 比赛过程中,当球队具有球权时:
//“球权”是比赛过程中的一种状态属性:teamA或teamB
newGame.BallRightTeam= teamA; //球队A具有球权
newGame.BallRightTeam= teamB; //球队A失去球权,球队B获得球权
当然,也有建议可以把“球权”作为球队的一个属性,类似teamA.HaveBall = true来描述球队A具有球权,但是这样做需要一个关键的逻辑处理,如果teamA.HaveBall = true,则teamB.HaveBall = false必须同时成立,既然如此,Pi君还是建议把“球权”作为比赛过程中的一个状态属性比较直观,也无须其他的逻辑处理。
③ 当球队具有球权时,用户只能通过键盘控制控球球员进行前进/后退/左转/右转/加速/传球/射门的动作:
“控球球员”是一个动态的概念,随着足球的运动,控制足球的球员也在随之变化,控球球员可以被操控进行各种不同的动作,所以控球球员需要一个独立的类来处理,至于为什么不把“控球”作为球员的一个属性,看官们可以反推,Pi君不赘述。
如果不考虑下一条,代码描述可以这么写:
teamA.ControlAthlete = new ControlAthlete(); //新建球队A的控制球员(球队B格式类似)
teamA.ControlAthlete.SetControlAthlete(piAthlete); //A球队的Pi君为控球球员
public class ControlAthlete //控制球员类
{
private FootballAthlete _selectAthlete; //控制球员
private string _teamType; //所属球队类型
private Key _goKey; //前进键
private Key _backKey; //后退键 ...... 类似包含左转/右转/加速/传球/射门的键
public void SetControlAthlete(FootballAthlete){......} //设置控球球员
public ControlAthlete()
{
/*注册动作键被按下时的响应事件*/
_goKey.DownEvent += goKey_DownEvent;
......
}
}
看官可能会奇怪,为什么不设置“球权”呢,毕竟事件的响应是根据“球权”状态来决定的,想想看,“球权”是比赛的一个属性,并且是一个动态的属性,取值范围固定在球队A和球队B,所以,需要获取“球权”的值,只需要让teamA.ControlAthlete知道newGame的信息就OK了,这样,每次键盘事件响应时,实时判断当前比赛的“球权”,“控球球员”即可做出正确的动作。
怎么让teamA.ControlAthlete知道newGame的信息呢?且看后续分解吧,毕竟这不是一个难点。
④ 当球队失去球权时,用户可以切换受控制的球员,在切换过程中,选取用户控制球队距离足球最近的球员:
基于第③步的分析和代码描述,这一步的需求可以这么描述:“切换受控制的球员”,即重新设置了“控球球员”(注意,这里的球员不一定真实控球的那个球员)
//比赛进行时,必然有且只有一场比赛在进行,所以比赛本身是个单例(单例模式)
//在控制球员类中添加“切换键”及其响应事件
public class ControlAthlete //控制球员类
{
private FootballAthlete _selectAthlete; //控制球员
private SwitchKey _switchKey; //切换球员按键
public ControlAthlete()
{
this._switchKey.KeyDown += switchKey_KeyDown;
}
//参数暂时不用定义
private void switchKey_KeyDown(object e, KeyArgs args)
{
//获取当前比赛对象????
FootballGame currentGame = new FootballGame();if(currentGame == null)
return;
FootballAthlete nextAthlete = currentGame.GetNeareastAthletefromBall(this._teamType); //获取指定球队举例足球最近的球员
if(nextAthlete == null)
return;
this._selectAthlete = nextAthlete;
RefreshAthleteStatus(); //刷新球员状态(绘制信息)
}
}
出现了一个问题,FootballGame类在“Step-One”中已经存在一个构造函数如下:
FootballGame newGame=newFootballGame(team_A, team_B); //新建一个游戏,并用A和B球队初始化该游戏
而刚刚,FootballGame类还存在另外一个构造函数,如上代码中黑体+斜体+中划线的部分。FootballGame本身是一个单例,也就是内存中始终只有一个该类的实例,并且单例有自己固有的实现方式,之前FootballGame类的两种构造方式显然违反了单例的实现方式,OK,Pi君先给出FootballGame类单例的实现方式:
public class FootballGame
{
private FootballGame(){} //私有化构造函数
private static FootballGame _instance; //唯一实例
public FootballTeam _teamA; //参赛球队A
public FootballTeam _teamB; //参赛球队B
public static FootballGame GetInstance() //获取球队比赛实例
{
if(_instance == null)
_instance = new FootballGame();
return _instance;
}
......
}
扩展:以上代码中加粗的“public”,可能会引起看官们的疑惑,为啥不用属性和私有变量,直接让变量公有,岂不是破坏了类的封装?有违习惯嘛~~其实,这里首先有一个问题需要研究清楚,为什么会有属性的概念,属性带来的好处有哪些?为免离题太远,Pi君只抛出问题,欢迎看官们留言讨论,说说自己的想法,也听一听别人的想法,一起学习,一起进步~
OK,对FootballGame类的实现,意味着需要对之前代码中获取或新建FootballGame对象的部分进行调整和修改。现将修改后的代码展示如下:
FootballGame newGame = FootballGame.GetInstance();//新建一个游戏
newGame._teamA = teamA; //添加球队A参加比赛
newGame._teamB = teamB; //添加球队B参加比赛
FootballGame currentGame = FootballGame.GetInstance(); //获取当前比赛对象
OK,到此,针对UseCase-One的代码描述基本清晰,但是仍然有一些细节的问题没有处理,例如,“控制球员”的每一个动作函数应该怎么编写,其实,这是深入层面需要考虑的问题,感兴趣的看官们可以思考下,Pi君也会在后续给出github上的源码链接。
UseCase-Two:FootballTeam Struct
② 每个球队有一个教练,有十一个球员,有自己的球队队形,用户可以自己调整针对特定队形的球员站位,有自己的队服/队徽;
该条需求直接给出了球队的基本数据结构,So,代码描述如下:
public class FootballTeam
{
private FootballAthlete[11] _athletes; //十一名球员
private TeamFormation _formation; //队形
private FootballTrainer _trainer; //一个足球教练
private string _TShirt; //队服
private string _teamLog; //队徽
}
针对“用户可以自己调整针对特定的球员站位”,又该怎么描述?这是一个需要深挖的需求点,请随Pi君Step by Step:
→如果“站位”只是球员开场时所处的球场位置,那么可以直接将“站位”作为球员自身的属性,这样不但可以知道球员开始的位置,随着比赛的进行,这个位置也会随之变动;
→如果“站位”除了开场时球员所处的球场位置以外,还涵盖球员的频繁跑动区域(防守责任区/进攻战术责任区等),那么“站位”的概念要丰富的多,“站位”可以理解为一种控制规则,球员跑动/传球/防守需要从“站位”中读取规则,然后做出相应的动作;
→既然“站位”的概念被丰富了,那么把“站位”作为球员的属性就变得很勉强,OK,不如把“站位”独立出来,更符合单一职责原则,二者之间的关系是“站位”---->“FootballAthlete”;
→回转查看之前FootballTeam的设计,“private FootballAthlete[11] _athletes; //十一名球员”的存在就显得的多余了,毫不犹豫,先把这一行删除,后续也许有新的需求导致该行的重新恢复,所以,暂时先注释掉该行是个不错的习惯。
OK,现在“队形”被分解为“站位”,“站位”又包括哪些行为或者属性呢?继续Step by Step:
Station oneStation = new Station(); //新建一个“站位”
oneStation.Athlete = piAthlete //把Pi君设置为该“站位”的球员
oneStation.DefendArea.Add(new Point(xxx, yyy)); //添加该“站位”的防守区域
Point startPosition = oneStation.GetStartPos(); //获取当前“站位”的起始位置
teamA._formation.AddStation(oneStation); //将当前“站位”添加至球队“队形”中
现在数据有了,怎么触发行为,行为又是怎么发生呢?继续Step by Step:
//带球跑动的球员是否会触发对方球员的防守行为?
这个问题的回答是层级性的,可以设想为游戏难度,因为需求没有涉及,理解过程中简单的假设有两种游戏难度:困难/简单,“困难”级别的游戏,这个问题的答案自然是:true,“简单”级别则是false。当然,如果把游戏难度细分为“新手级”/"普通级"/“困难级”/“专家级”/“变态级”,那这个问题就不能简单的使用bool值描述......又是一个新的逻辑处理块,但是转念思考,暂时没有这种需求,那就采取最简单的策略:“简单”级别,即答案为false,切记不可过度设计,这是TDD最给力的地方。
当然,如果是用户控制球员防守,那就另当别论了。
//球员的无球跑动?
无球跑动,理解为责任区内的晃动,及脱离“控制球员”的球员“发现”自己不在责任区内时的自动修正跑动。可以放在“RefreshAthleteStatus(); //刷新球员状态(绘制信息)”中添加处理逻辑,不赘述。
OK,到此有关UseCase-Two:FootballTeam Struct的基本结构已经清晰。
UseCase-Three:DataManager
③ 有一个管理员账号,管理员可以管理球队相关数据,包括球员数据/教练数据/队形数据/队服数据/队徽数据。
这是一个典型的数据管理员模块,这部分其实谈不上TDD,有很多现有的框架可以使用,核心是数据库的设计,Pi君不再赘述。
总结
到此,有关足球小游戏的代码逻辑基本清晰,总结来看,我们需要实现的核心类有:
public class FootballGame{......} //足球比赛,这是一个单例
public class FootballTeam{......} //球队
public class FootballAthlete{......} //球员
public class ControlAthlete{......} //控制球员
public class TeamFormation{......} //队形
public class Station{......} //站位
他们之间的关系如下:
实现后台逻辑以后,可以继续考虑UI设计,Pi君给出比较简单的UI交互图:
点击“设置”,如下图:
点击“确定”,如下图:
点击“确定”,如下图:
点击“确定”,游戏设置完毕。
点击“数据管理”,如下图:
点击“确定”,如下图:
数据管理界面也可以在“球队设置”界面中被触发。
到此,这个足球小游戏的详细设计就差不多了,感兴趣的看官们心痒不如手痒,现实不如Code,实现一下吧~任何问题欢迎留言讨论~
单元测试部分的内容正在编写中.....敬请期待吧~