背景
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,實現一下吧~任何問題歡迎留言討論~
單元測試部分的內容正在編寫中.....敬請期待吧~