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

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

他们之间的关系如下:

类关系图

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

主界面

点击“设置”,如下图:

模式设置界面

点击“确定”,如下图:

球队设置

点击“确定”,如下图:

操控设置界面

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

主界面

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

管理员验证界面

点击“确定”,如下图:

数据管理界面

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

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

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


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容