标签:
-----------回顾分割线-----------
系列之一讲述了游戏规则,系列之二讲述了旧版的前台效果、代码中不好的地方、以及新版的改进核心,此篇开始就是新版代码编写全过程。此系列旨在开发类似“谁是卧底+杀人游戏”的捉鬼游戏在线版,记录从分析游戏开始的开发全过程,通过此项目让自己熟悉面向对象的SOLID原则,提高对设计模式、重构的理解。
索引目录:
0. 索引(持续更新中)
2. 设计业务对象与对象职责划分(1)(图解旧版本)
3. 设计业务对象与对象职责划分(2)(旧版本代码剖析)
4. 设计业务对象与对象职责划分(3)(新版本业务对象设计)
5. 业务对象核心代码编写与单元测试(游戏开始前:玩家入座与退出)
6. 业务对象核心代码编写与单元测试(游戏开始:抽题、分角色、开启鬼讨论模式)
……(未完待续)
-----------回顾结束分割线-----------
先放上svn代码,地址:https://115.29.246.25/svn/Catghost/
账号:guest 密码:guest(支持源代码下载,已设只读权限,待我基本做出初始版本后再放到git)
-----------本篇开始分割线-----------
快乐的码字生活又开始了~ 先上之前(新版本业务对象设计)分析过的流程图:
上一篇已经做了1-4步,本篇处理的5-8都属于游戏开始的初始化——游戏玩家一达到数量立即自动开始,然后自动执行5-8步,第8之后就是等待鬼(Ghost)讨论谁开始首轮发言。
进一步说明就是,游戏自动检测玩家入座人数符合配置文件中的总人数时,自动由PlayerManager类通知Table,由Table再通知Game,让Game执行他自己的Start()方法。在Start()方法中,我们需要做上图的5-8步,即:抽题、分角色、开启鬼讨论模式(抽题与分角色两个步骤是可以互换的,但此处为遵循现实中的做法,便于客观理解而设计)。
一、抽题
(1)单例模式【此处有错,第四节更正】:题目类Subject,被设计为单例模式,因为只需在游戏开始前抽一次题就够了,而不需要像其他管理者(发言管理者SpeakManager、循环管理者LoopManager、投票官VoteManager、死神DeathManager、胜负判官WinManager)那样在后续的游戏进行过程中,会出现多次,所以一个实例就够了。此处同时提到负责分角色——角色管理者RoleManager(没有使用单例模式),因为RoleManager只是在Game中需要被调用一次,其他地方不会再调用也不允许再调用(不可能游戏进行到一半又重新分角色),但是Subject题目类却会始终伴随游戏进行,被各个玩家不断查阅自己的词(发言者要记录不能透露词中的字、胜负判官要判断是否被鬼猜中词),为了全局访问点,所以只能单例模式,全局访问点也是单例模式存在的一个重要的原因。
(2)应该从题库抽题:为了重点放在游戏流程环节,题库和抽题的做法就不强调了,此处先写死在程序里以便测试。
public void GetSubjectFromDictionary() { // Todo: get subject from system with random CivilianWord = "孙悟空"; IdiotWord = "周星驰"; GhostWord = NewGhostWord(CivilianWord, IdiotWord); }
(3)创建鬼的字条:上面的代码你也许注意到了,鬼抽到的词(也就是手中的字条)是动态生成而非写死。动态生成指的是根据平民、白痴的词的字数来显示,且同时检测平民与白痴的词字数是否相等(这个检测应该放在写入词库时检测更为准确,当然,放在这里也更为严谨,以防有人手动修改词库)。同时还新自定义了一个异常类SubjectNotSameLength(如何定义,为何定义,请看上一篇)
private string NewGhostWord(string civilianWord, string idiotWord) { if (civilianWord.Length == idiotWord.Length) { return string.Format("鬼({0}字)", civilianWord.Length); } throw new SubjectNotSameLength(); }
利用代码度量值分析(利于维护、减少耦合,也在上一篇详细提到做法和重要性了),通过重构优化代码,得到下列代码。因为写鬼的字条NewGhostWord()与获取词长GetWordLength()是两个可以分离的细节动作,而抛出词长不同的异常,更应该在获取词长时进行,而不是像优化前那样冗杂在创建鬼的字条方法当中。(需要细细品味)
/// <summary> /// 创建鬼的字条 /// </summary> /// <param name="civilianWord">平民的词</param> /// <param name="idiotWord">白痴的词</param> /// <returns>鬼的字条</returns> private string NewGhostWord(string civilianWord, string idiotWord) { return string.Format("鬼({0}字)", GetWordLength(civilianWord, idiotWord)); } /// <summary> /// 返回好人的词的程度 /// </summary> /// <param name="civilianWord">平民的词</param> /// <param name="idiotWord">白痴的词</param> /// <returns>词的长度</returns> private int GetWordLength(string civilianWord, string idiotWord) { if (civilianWord.Length == idiotWord.Length) { return civilianWord.Length; } throw new SubjectNotSameLength(); }
很简单,Subject暂时告一段落。喔对了,别忘了测试哟!先利用上一篇已经测试成功的结果,这里直接拿来写成private void SetNickNameArray(),模拟用户逐个入座后,游戏自动检测并开始。
private void SetNickNameArray() { Table table = Table.GetInstance(); PlayerManager manager = table.GetPlayerManager(); manager.ClearNameArrayAndPlayerArray(); // 全部测试时,会受Table单例模式影响,玩家列表依然存在 string[] names = new string[] { "jack", "peter", "lily", "puppy", "coco", "kimi", "angela", "cindy", "vivian" }; for (int order = 0; order < names.Length; order++) { string name = names[order]; manager.SetNickName(order, name); } // game is starting... }
[TestMethod] public void GetSubjectUnitTest() { SetNickNameArray(); Console.WriteLine(GetSubject().CivilianWord); Console.WriteLine(GetSubject().IdiotWord); Console.WriteLine(GetSubject().GhostWord); }
此时来看看Game.Start()中写了什么。是不是发现方法又一次被多个拆分了吧,没错,利于复用。
public void Start() { if (IsGameStarted()) { return; } SetGameStateToStarted(); PublishSubject(); } private void PublishSubject() { GetSubjectInstance().GetSubjectFromDictionary(); } private Subject GetSubjectInstance() { return Subject.GetInstance(); }
二、分角色
如何随机分配角色?现实中用的是抓阄方法,代码中则用的随机排序。首先我百度了第一个C#随机排序的算法:百度原文链接点这里。利用的是Array.Sort()的重载。为了适应项目,做了小小变形:
/// <summary> /// 对数组进行随机排序 /// </summary> /// <param name="array">原始数组</param> /// <returns>乱序数组</returns> private int[] RandomSort(int[] array) { int[] rnd = { -1, 0, 1 }; Array.Sort(array, (i, j) => { if (i == j) return 0; return rnd[new Random(Guid.NewGuid().GetHashCode()).Next(0, 3)]; }); return array; }
那怎么用呢?我的思路是:9位玩家的座位不动(0-9),只在内存中把座位随机乱序(如:4,2,3,5,8,0,7,1,6),然后指定前四个为平民Civilian(4,2,3,5号),接下来两个为白痴(8,0号),剩下为鬼(7,1,6号):
public void AssignRole(PlayerManager playerManager) { int[] seatArray = RandomSort(new int[] { 0, 1, 2, 3, 4, 5, 6, 7, 8 }); // Todo: use setting for (int i = 0; i < GetSetting().GetTotalCount(); i++) { if (i < GetSetting().CivilianCount) { playerManager.SetPlayer(seatArray[i], new Civilian(playerManager.GetNameArray()[seatArray[i]])); } else if (i < GetSetting().CivilianCount + GetSetting().IdiotCount) { playerManager.SetPlayer(seatArray[i], new Idiot(playerManager.GetNameArray()[seatArray[i]])); } else { playerManager.SetPlayer(seatArray[i], new Ghost(playerManager.GetNameArray()[seatArray[i]])); } } }
在Game的Start()方法中(在PublishSubject();之后)就一句话:new RoleManager().AssignRole(GetPlayerManager()); 如上述所言,RoleManager不需要单例模式,因为只有这一处允许调用,不需要全局访问点,所以普通创建类即可。写的是否正确呢?测试最有发言权
[TestMethod] public void RandomAssignRoleUnitTest() { SetNickNameArray(); ShowNameArray(); Console.WriteLine(); ShowPlayerArray(); }
可以看到两次测试的结果(其实测了超过十遍)达到了随机分配角色的效果。
算是写好了吗?代码度量值看看(为方便看图,省略不太重要的代码行数)。
可以看出维护性都非常低,耦合高、复杂度高,真实各种不给力。改吧!(RandomSort是网上的方法,怎么改,大家下载鄙人的代码就行了,不在此赘述)当前的AssignRole()其实就已经看不爽了
public void AssignRole(PlayerManager playerManager) { int[] seatArray = RandomSort(new int[] { 0, 1, 2, 3, 4, 5, 6, 7, 8 }); // Todo: use setting for (int i = 0; i < GetSetting().GetTotalCount(); i++) { if (i < GetSetting().CivilianCount) { playerManager.SetPlayer(seatArray[i], new Civilian(playerManager.GetNameArray()[seatArray[i]])); } else if (i < GetSetting().CivilianCount + GetSetting().IdiotCount) { playerManager.SetPlayer(seatArray[i], new Idiot(playerManager.GetNameArray()[seatArray[i]])); } else { playerManager.SetPlayer(seatArray[i], new Ghost(playerManager.GetNameArray()[seatArray[i]])); } } }
先改第一行,用上setting,而不是写死在程序里。
public void AssignRole(PlayerManager playerManager) { int[] seatArray = GetSeatArray(); ...... } /// <summary> /// 创建随机排序的座位号 /// </summary> /// <returns></returns> private int[] GetSeatArray() { return RandomSort(GetTempArray()); } /// <summary> /// 创建与玩家数匹配的临时数组 /// </summary> /// <returns>临时数组</returns> private int[] GetTempArray() { int[] result = new int[GetSetting().GetTotalCount()]; for (int i = 0; i < result.Length; i++) { result[i] = i; } return result; }
此时代码度量值只有一小点进步,说明主要问题还没解决——肯定是那一大坨for中的分支判断。
先从最小的块开始,我看到 new Civilian(playerManager.GetNameArray()[seatArray[i]])) 这一长串其实就是 new Civilian(name),却写的非常难理解,改为 new Civilian(playerManager.GetName(order)),对应地在PlayerManager类中增加公共方法GetName(int order)。当我再次查看代码度量值后发现——并没有什么用。不过我一点不后悔也没打算改回来,因为这一改动是绝对正确的——记得上一篇提过:计算机看得很快,代码表述直接影响最大的是开发者——所以开发者看不爽的,无论对效率是否有帮助,都应该修改完善(除非你的老板要你赶紧去做另一份工作,没错,这就是重构工作面临的效率、责任、报酬问题,再次不深究)。
目前代码张这样:
public void AssignRole(PlayerManager playerManager) { int[] seatArray = GetSeatArray(); for (int i = 0; i < GetSetting().GetTotalCount(); i++) { int order = seatArray[i]; if (i < GetSetting().CivilianCount) { playerManager.SetPlayer(order, new Civilian(playerManager.GetName(order))); } else if (i < GetSetting().CivilianCount + GetSetting().IdiotCount) { playerManager.SetPlayer(order, new Idiot(playerManager.GetName(order))); } else { playerManager.SetPlayer(order, new Ghost(playerManager.GetName(order))); } } }
回到三个分支上来,代码是想根据循环中i的值,来决定是new一个什么具体类(Player是抽象类,不能new)——对象创建问题,简直不要太像简单工厂。重构为:
public void AssignRole(PlayerManager playerManager) { int[] seatArray = GetSeatArray(); for (int i = 0; i < GetSetting().GetTotalCount(); i++) { int order = seatArray[i]; playerManager.SetPlayer(order, PlayerFactory.CreatePlayer(i, playerManager.GetName(order))); } } // 新建一个工厂类 public static class PlayerFactory { public static Player CreatePlayer(int i, string name) { if (i < GetSetting().CivilianCount) { return new Civilian(name); } else if (i < GetSetting().CivilianCount + GetSetting().IdiotCount) { return new Idiot(name); } return new Ghost(name); } private static Setting GetSetting() { return Setting.GetInstance(); } }
测试,通过。
数据好看多了(初步目标是可维护达到70分以上,耦合、复杂度都降到3以下)
最后别忘了联合查词测试——每个玩家应该能看到自己的词,即Player抽象类增加GetMyWord()抽象方法,各具体角色玩家类重写之。其中,GetSubject()来自抽象父类Player的protected修饰的方法。
public class Civilian : Player { public Civilian(string nickName) : base(nickName) { } public override string GetMyWord() { return GetSubject().CivilianWord; } }
测试结果无疑没有问题。
三、开启鬼讨论模式
先来看看在纯粹对着SpeakManager类的时候,我稍微初步写了一下各个方法的内容,很简单,看个大概即可。
public class SpeakManager { private StringBuilder _record; private Player _currentSpeaker; private bool _isGhostDiscuss = false; // public method /// <summary> /// 玩家发言 /// </summary> /// <param name="str">发言内容</param> public void PlayerSpeak(string str) { AddToRecord(FormatSpeak(this._currentSpeaker.NickName, str)); } /// <summary> /// 系统发言 /// </summary> /// <param name="tip">提示信息</param> public void SystemSpeak(string tip) { AddToRecord(tip); } /// <summary> /// 显示记录 /// </summary> /// <returns>发言记录</returns> public string ShowRecord() { return this._record.ToString(); } /// <summary> /// 清空记录 /// </summary> public void ClearRecord() { this._record.Clear(); } /// <summary> /// 设置允许发言的玩家 /// </summary> /// <param name="player">玩家</param> public void SetSpeaker(Player player) { this._currentSpeaker = player; } /// <summary> /// 开启鬼讨论模式 /// </summary> public void SetOnGhostDiscuss() { this._isGhostDiscuss = true; } /// <summary> /// 关闭鬼讨论模式 /// </summary> public void SetOffGhostDiscuss() { this._isGhostDiscuss = false; } // private method /// <summary> /// 格式化发言内容 /// </summary> /// <param name="name">姓名</param> /// <param name="str">发言内容</param> /// <returns>【姓名】发言内容</returns> private string FormatSpeak(string name, string str) { return string.Format("【{0}】{1}", name, str); } /// <summary> /// 添加到记录中 /// </summary> /// <param name="str">发言内容</param> private void AddToRecord(string str) { this._record.AppendLine(str); } }
(1)我打算把SpeakManager也发展成单例模式,因为SpeakManager中维护了比较重要的字段:发言记录、当前发言人、是否鬼讨论环节,这些都不能因出现多个实例而变化,且此类会在很多地方用到,还是全局访问点的问题,故单例模式无疑。
(2)增加鬼讨论环节的标记,并修改AddToRecord()方法、ShowRecord()方法——如果正处在贵讨论环节,那么所有发言记录、显示记录的方法都记载在鬼说话的记录板,而不是其他人看到的记录板。
private StringBuilder _ghostRecord; public string ShowRecord() { if (IsGhostDiscussing()) { return this._ghostRecord.ToString(); } return this._record.ToString(); } public void SetOnGhostDiscuss() { this._isGhostDiscuss = true; } public void SetOffGhostDiscuss() { this._isGhostDiscuss = false; } private bool IsGhostDiscussing() { return this._isGhostDiscuss; } private void AddToRecord(string str) { if (IsGhostDiscussing()) { this._ghostRecord.AppendLine(str); return; } this._record.AppendLine(str); }
public void Start() { if (IsGameStarted()) { return; } SetGameStateToStarted(); PublishSubject(); new RoleManager().AssignRole(GetPlayerManager()); GetSpeakManager().SystemSpeak("游戏开始,鬼正在讨论:由谁第一个发言。"); GetSpeakManager().SetOnGhostDiscuss(); GetSpeakManager().SystemSpeak("已开启鬼讨论模式,鬼的讨论不会被非鬼玩家看到,请统一意见后点选按钮投票,决定谁第一个发言,直到投票结果统一为止。"); }
也许聪明的你发现了一个问题:在SystemSpeak(游戏开始)时,全场所有人都能看到这句话。但在开启了鬼讨论模式后,只有鬼能看见,那如果来做到在适当的时候ShowRecord呢?为此我做了这样的设计:
在Player抽象类中增加Listen():void方法,用来获取SpeakManager.ShowRecord()的内容,但在调用ShowRecord()时,需要把自己的身份传进去,才能获取到自己应该听到的内容,这样无论在何时请求ShowRecord,都会得到本来属于自己的内容。
那么这个Listen方法该何时调用呢?如何实现这些Player的耳朵在何时主动去Listen呢——观察者模式。
在Game类中增加NotifyPlayerListen()方法
private void NotifyPlayerListen() { foreach (Player player in GetPlayerManager().GetAllPlayerArray()) { player.Listen(); } }
为便于测试,我们暂时更换Listen()方法的返回值为string,测试过程中循环打印各玩家Listen到的内容。
[TestMethod] public void SystemSpeakUnitTest() { SetNickNameArray(); foreach (Player player in GetPlayerManager().GetAllPlayerArray()) { ShowPlayer(player); Console.WriteLine(player.Listen()); } }
测试结果也是杠杠的。
相信朋友们又会有疑问了:如何做到UI显示?别忘了还有我们的eventsource(服务器发送事件),那具体如何与Controller对接,如何主动发送?
问得很好,但这不是我们这阶段要考虑的,这阶段是业务对象核心代码,把单元测试、流程走出来,怎么ui,是eventsource、还是websocket都再说,但是业务对象代码不能体现ui相关的内容,这就是分层的严格。
四、修正
上述写的有些不合理的地方,在这里修正:
Subject、SpeakManager不应该设为单例模式,因为他们只是对一个Game单例,但如果这个游戏结束了(Game.Dispose()),但Table还在。此时,ui界面上应该是所有人起立,等待谁想继续玩就继续点入座按钮,一切照旧。那么,如果Subject和SpeakManager都是单例的话,是对整个应用程序单例,则不会有新题,也还是旧的说话记录,所以修改为:
Subject、SpeakManager由Game统一维护——在同一个Game里是唯一的。
public Subject GetSubject() { if (this._subject == null) { this._subject = new Subject(); } return this._subject; }
private SpeakManager GetSpeakManager() { if (this._speakManager == null) { this._speakManager = new SpeakManager(); } return this._speakManager; }
也许你会担心多线程的问题,这个不会——Table是单例模式、全局唯一,Table里只有一个PlayerManager来决定是否开始Game,Table里只会有一个Game,一个Game里只会有一个Subject和SpeakManager。
此时,Table中加上游戏重新开始的方法(不应加在Game里,因为一局游戏的结束应该全体起立,还没坐下,所以还没有Game)
public void Restart() { this._game = null; this._playerManager = new Players.PlayerManager(this); }
消费代码如下
private void SetNickNameArray() { Table table = Table.GetInstance(); table.Restart(); // clear all except table PlayerManager manager = table.GetPlayerManager(); string[] names = new string[] { "jack", "peter", "lily", "puppy", "coco", "kimi", "angela", "cindy", "vivian" }; for (int order = 0; order < names.Length; order++) { string name = names[order]; manager.SetNickName(order, name); } // game is starting... }
五、总结
本篇主要做了游戏自动检测人数并开始后的三件事:抽题、随机分配角色、开启鬼讨论环节,第四节修正了单例模式勿用的错误。本来总结应该放在第四节的,也即是不想写这个修正,而是让大家一次性看到结果,但转念一想,自己写下这篇项目记录的初衷不就是为了记录整个项目的过程吗,无论弯路、错路,都是一路以来的过程,记下来,才更有助于自己和朋友们分享修正的成果。
编写代码的过程依旧是上一篇的:“填”代码(先写下所有可能的方法名,再一个个填写内容)、写测试、代码度量值检测。写到这里的时候,我停了两个小时,用来根据代码度量值优化代码,已提交(第6版)。
从下一章开始,每一篇都应该回顾这些类的职责有没有分配错的,每个类都要检查,包括访问修饰符。
在线捉鬼游戏开发之三 - 业务对象核心代码编写与单元测试(游戏开始:抽题、分角色、开启鬼讨论模式)
标签:
原文地址:http://www.cnblogs.com/lzhlyle/p/Catghost-Models2.html