TDD(测试驱动设计)的项目实践——需求分析

TDD(测试驱动设计)的项目实践——需求分析_第1张图片
TDD-PRACTICE

背景


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{......} //站位

他们之间的关系如下:

TDD(测试驱动设计)的项目实践——需求分析_第2张图片
类关系图

实现后台逻辑以后,可以继续考虑UI设计,Pi君给出比较简单的UI交互图:

TDD(测试驱动设计)的项目实践——需求分析_第3张图片
主界面

点击“设置”,如下图:

TDD(测试驱动设计)的项目实践——需求分析_第4张图片
模式设置界面

点击“确定”,如下图:

TDD(测试驱动设计)的项目实践——需求分析_第5张图片
球队设置

点击“确定”,如下图:

TDD(测试驱动设计)的项目实践——需求分析_第6张图片
操控设置界面

点击“确定”,游戏设置完毕。

TDD(测试驱动设计)的项目实践——需求分析_第7张图片
主界面

点击“数据管理”,如下图:

TDD(测试驱动设计)的项目实践——需求分析_第8张图片
管理员验证界面

点击“确定”,如下图:

TDD(测试驱动设计)的项目实践——需求分析_第9张图片
数据管理界面

数据管理界面也可以在“球队设置”界面中被触发。

到此,这个足球小游戏的详细设计就差不多了,感兴趣的看官们心痒不如手痒,现实不如Code,实现一下吧~任何问题欢迎留言讨论~

单元测试部分的内容正在编写中.....敬请期待吧~


你可能感兴趣的:(TDD(测试驱动设计)的项目实践——需求分析)