在线捉鬼游戏开发之三 - 代码与测试(玩家发言)
-----------回顾分割线-----------
此系列旨在开发类似“谁是卧底+杀人游戏”的捉鬼游戏在线版,记录从分析游戏开始的开发全过程,通过此项目让自己熟悉面向对象的SOLID原则,提高对设计模式、重构的理解。
索引目录:
0. 索引(持续更新中)
2. 设计业务对象与对象职责划分(1)(图解旧版本)
3. 设计业务对象与对象职责划分(2)(旧版本代码剖析)
4. 设计业务对象与对象职责划分(3)(新版本业务对象设计)
5. 业务对象核心代码编写与单元测试(游戏开始前:玩家入座与退出)
6. 业务对象核心代码编写与单元测试(游戏开始:抽题、分角色、开启鬼讨论模式)
7. 代码与测试(鬼讨论、鬼投票)
8. 代码与测试(玩家发言)
-----------回顾结束分割线-----------
先放上源代码,svn地址:https://115.29.246.25/svn/Catghost/
账号:guest 密码:guest(支持源代码下载,已设只读权限,待我基本做出初始版本后再放到git)
-----------本篇开始分割线----------
依旧是按照顺序图来完成(越发感觉到类图、顺序图给代码带来的指导性意义)
1. 玩家发言
顺序图中的1-3步:CurrentSpeaker发言后,SpeakManager记录并显示出来,同时设置下一个允许发言的玩家。
在SpeakManager中其实已经写好了大部分PlayerSpeak()的内容,只需加一行SetNextSpeaker()即可。
public void PlayerSpeak(Player player, string str) { if (IsGhostDiscussing()) { CheckGhostSpeaker(player); } else { CheckCurrentSpeaker(player); } AddToRecord(FormatSpeak(player.NickName, str)); SetNextSpeaker(player); } /// <summary> /// 设置下一位发言人 /// </summary> /// <param name="currentPlayer">当前发言人</param> private void SetNextSpeaker(Player currentPlayer) { Player[] players = GetPlayerManager().GetAllPlayerArray(); Player nextPlayer = null; for (int i = 0; i < players.Length; i++) { if (players[i].Equals(currentPlayer)) { if (i == players.Count() - 1) { nextPlayer = players[0]; break; } nextPlayer = players[i + 1]; break; } } SetSpeaker(nextPlayer); }
测试代码如下:用for来测试是否满足循环发言时的SetNextSpeaker
[TestMethod] public void PlayerSpeakUnitTest() { JoinGame(); // ghost discussing... Player electPlayer = GetPlayerManager().GetAllPlayerArray()[5]; // elect player GhostVoting(electPlayer); for (int i = 0; i < 2; i++) { PlayerSpeaking(electPlayer, i); } ShowPlayerListen(); } // private method private void PlayerSpeaking(Player starter, int times) { bool canSpeak = false; if (times > 0) canSpeak = true; foreach (Player p in GetPlayerManager().GetAllPlayerArray()) { if (p.Equals(starter)) { canSpeak = true; } if (canSpeak) { p.Speak("i'm " + p.NickName); } } }
测试结果如期所至:所有玩家都能看到,且从第6个玩家(kimi)开始发言,两轮后结束。因为没加入LoopManager进行监控,所以要到vivian才结束。加入LoopManager后应该在coco发言完就结束了。
再看代码度量值,貌似还有进步的空间:判断太多导致圈复杂度上升,类耦合可适当减少。
先看当前的SetNextSpeaker()代码
/// <summary> /// 设置下一位发言人 /// </summary> /// <param name="currentPlayer">当前发言人</param> private void SetNextSpeaker(Player currentPlayer) { Player[] players = GetPlayerManager().GetAllPlayerArray(); Player nextPlayer = null; for (int i = 0; i < players.Length; i++) { if (players[i].Equals(currentPlayer)) { if (i == players.Count() - 1) { nextPlayer = players[0]; break; } nextPlayer = players[i + 1]; break; } } SetSpeaker(nextPlayer); }
感觉最内层的if(已表粗体)只是为了简单判断如果循环到数组末尾,则从第一个开始。坏味道出现了——这是SpeakManager该做的事情吗?整个SetNextSpeaker()的主要任务是设置下一个玩家允许发言,你就别给我整当前玩家是谁,你直接给我来下一个玩家是谁不就得了?所以,整个SetNextSpeaker()都跨越了自己的职责,占用了谁的职责呢?谁最清楚下一个玩家是谁呢?Player自己知道吗——不知道,只有玩家管理者PlayerManager知道,因为他就是干这个的——维护玩家列表!
题外提一句,这里很容易想到状态模式——Player说完自动换下一位Player,即CurrentSpeaker标记的状态在改变——但很遗憾,这里并不合适:首先,Player自己不应该知道自己下一位是谁,而是PlayerManager才知道,且如果Player知道自己的下一位,那么他就有权决定下一位是谁(状态模式是为了易于轻松增/改传递的下一个状态),这就与游戏规则不符了。故,还是需要一个统领全局的局外人PlayerManager来操作(有点儿建造者模式中Builder的味道)。
首先在PlayerManager中增加GetNextPlayer()方法:
/// <summary> /// 返回下一位玩家 /// </summary> /// <param name="currentPlayer">当前玩家</param> /// <returns>下一位玩家</returns> public Player GetNextPlayer(Player currentPlayer) { CheckPlayer(currentPlayer); Player result = null; for (int i = 0; i < GetAllPlayerArray().Length; i++) { if (GetAllPlayerArray()[i].Equals(currentPlayer)) { if (i == GetAllPlayerArray().Length - 1) { result = GetAllPlayerArray()[0]; }
else
{ result = GetAllPlayerArray()[i + 1];
} } } return result; }
对应SpeakManager中的SetNextSpeaker()将非常简单:
/// <summary> /// 设置下一位发言人 /// </summary> /// <param name="currentPlayer">当前发言人</param> private void SetNextSpeaker(Player currentPlayer) { Player nextPlayer = GetPlayerManager().GetNextPlayer(currentPlayer); SetSpeaker(nextPlayer); }
代码度量值方面:减少了类耦合(将不属于的职责分离出去了),但圈复杂度传给了PlayerManager。需要继续优化:
我们可以观察到寻找下一位玩家的关键就在于座位号,无论是对当前玩家的判断,还是下一位玩家的筛选,都要通过座位号,所以考虑提取出GetSeatOrder()方法:
public Player GetNextPlayer(Player currentPlayer) { Player[] players = GetAllPlayerArray(); int currentSeatOrder = GetSeatOrder(currentPlayer); return currentSeatOrder == players.Length - 1 ? players[0] : players[currentSeatOrder + 1]; } /// <summary> /// 返回玩家座位号 /// </summary> /// <param name="player">玩家</param> /// <returns>座位号</returns> private int GetSeatOrder(Player player) { CheckPlayer(player); for (int i = 0; i < GetAllPlayerArray().Length; i++) { if (GetAllPlayerArray()[i].Equals(player)) { return i; } } return -1; }
测试之,没问题。再看代码度量值:很好,算是完成了玩家发言的部分。
2. 循环管理
顺序图中的4-6步:设置下一允许发言的玩家后,LoopManager负责检查是否此循环结束(首轮发言有两个循环),若没结束,则不做操作;若已结束,则SpeakManager发出系统指令告诉大家开始投票,投票环节不允许发言,故要对CurrentSpeaker做一些处理。
首先在SpeakManager.SetNextSpeaker()的时候增加CheckIsEnd()检查:如果发言完了,则设置当前允许发言的人为空,且系统发出提示。
/// <summary> /// 设置下一位发言人 /// </summary> /// <param name="currentPlayer">当前发言人</param> private void SetNextSpeaker(Player currentPlayer) { Player nextPlayer = GetPlayerManager().GetNextPlayer(currentPlayer); SetSpeaker(nextPlayer); ChechIsLoopEnd(nextPlayer); } /// <summary> /// 检查是否循环结束 /// </summary> /// <param name="currentPlayer">当前玩家</param> private void ChechIsLoopEnd(Player currentPlayer) { if (GetLoopManager().IsLoopEnd(currentPlayer)) { SetSpeaker(null); SystemSpeak(GetSetting().GetAppSettingValue("VoteTip")); } }
接着在LoopManager中填充IsLoopEnd()方法:其注意第一轮是发言两圈。
public bool IsLoopEnd(Player currentPlayer) { if (currentPlayer.Equals(this._loopStarter)) { if (_isFirstLoop) { _isFirstLoop = false; return false; } return true; } return false; }
测试结果需要做一些小调整:把SpeakManager.CheckCurrentSpeaker()的“不许场外”的异常先禁用,否则会报错看不到输出。
可以看到,首轮每人发言了两次,且第二论(及以后)每人只能发言一次,最后框外的kimi-vivian的发言是因为异常未阻止导致的,符合预期。测试通过。代码度量值也是ok,就不贴图了。
到此,循环管理也算完成。
也许朋友们会问:那异常的处理在最后要怎么办?是返回string,还是终止程序?还是不予处理?——这些都在考虑ui的时候在考虑,此环节仅作核心Models代码编写。千万不能一时混淆太多考虑——饭要一口一口吃,代码要一处一处写。