在线捉鬼游戏开发之三 - 业务对象核心代码编写与单元测试(游戏开始:抽题、分角色、开启鬼讨论模式)

-----------回顾分割线-----------

系列之一讲述了游戏规则系列之二讲述了旧版的前台效果代码中不好的地方以及新版的改进核心,此篇开始就是新版代码编写全过程。此系列旨在开发类似“谁是卧底+杀人游戏”的捉鬼游戏在线版,记录从分析游戏开始的开发全过程,通过此项目让自己熟悉面向对象的SOLID原则,提高对设计模式、重构的理解。

索引目录

0. 索引(持续更新中)

1. 游戏流程介绍与技术选用

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);
}
GetSubjectFromDictionary

(3)创建鬼的字条:上面的代码你也许注意到了,鬼抽到的词(也就是手中的字条)是动态生成而非写死。动态生成指的是根据平民、白痴的词的字数来显示,且同时检测平民与白痴的词字数是否相等(这个检测应该放在写入词库时检测更为准确,当然,放在这里也更为严谨,以防有人手动修改词库)。同时还新自定义了一个异常类SubjectNotSameLength(如何定义,为何定义,请看上一篇

private string NewGhostWord(string civilianWord, string idiotWord)
{
    if (civilianWord.Length == idiotWord.Length)
    {
        return string.Format("鬼({0}字)", civilianWord.Length);
    }
    throw new SubjectNotSameLength();
}
NewGhostWord

利用代码度量值分析(利于维护、减少耦合,也在上一篇详细提到做法和重要性了),通过重构优化代码,得到下列代码。因为写鬼的字条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();
        }
优化NewGhostWord

很简单,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...
        }
SetNickNameArray
[TestMethod]
public void GetSubjectUnitTest()
{
    SetNickNameArray();
    Console.WriteLine(GetSubject().CivilianWord);
    Console.WriteLine(GetSubject().IdiotWord);
    Console.WriteLine(GetSubject().GhostWord);
}
GetSubjectUnitTest

此时来看看Game.Start()中写了什么。是不是发现方法又一次被多个拆分了吧,没错,利于复用。

public void Start()
{
    if (IsGameStarted())
    {
        return;
    }

    SetGameStateToStarted();
    PublishSubject();
}

private void PublishSubject()
{
    GetSubjectInstance().GetSubjectFromDictionary();
}

private Subject GetSubjectInstance()
{
    return Subject.GetInstance();
}
Start

 

二、分角色

如何随机分配角色?现实中用的是抓阄方法,代码中则用的随机排序。首先我百度了第一个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;
}
RandomSort

那怎么用呢?我的思路是: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]]));
                }
            }
        }
AssignRole

在Game的Start()方法中(在PublishSubject();之后)就一句话:new RoleManager().AssignRole(GetPlayerManager()); 如上述所言,RoleManager不需要单例模式,因为只有这一处允许调用,不需要全局访问点,所以普通创建类即可。写的是否正确呢?测试最有发言权

[TestMethod]
public void RandomAssignRoleUnitTest()
{
    SetNickNameArray();
    ShowNameArray();
    Console.WriteLine();
    ShowPlayerArray();
}
RandomAssignRoleUnitTest

可以看到两次测试的结果(其实测了超过十遍)达到了随机分配角色的效果。

算是写好了吗?代码度量值看看(为方便看图,省略不太重要的代码行数)。

可以看出维护性都非常低,耦合高、复杂度高,真实各种不给力。改吧!(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;
}
GetSeatArray

此时代码度量值只有一小点进步,说明主要问题还没解决——肯定是那一大坨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;
    }
}
Civilian

测试结果无疑没有问题。

三、开启鬼讨论模式

先来看看在纯粹对着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);
        }
    }
SpeakManager

(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...
}
InitialStartGameUnitTest.SetNickNameArray()

五、总结

本篇主要做了游戏自动检测人数并开始后的三件事:抽题、随机分配角色、开启鬼讨论环节,第四节修正了单例模式勿用的错误。本来总结应该放在第四节的,也即是不想写这个修正,而是让大家一次性看到结果,但转念一想,自己写下这篇项目记录的初衷不就是为了记录整个项目的过程吗,无论弯路、错路,都是一路以来的过程,记下来,才更有助于自己和朋友们分享修正的成果。

编写代码的过程依旧是上一篇的:“填”代码(先写下所有可能的方法名,再一个个填写内容)、写测试、代码度量值检测。写到这里的时候,我停了两个小时,用来根据代码度量值优化代码,已提交(第6版)。

从下一章开始,每一篇都应该回顾这些类的职责有没有分配错的,每个类都要检查,包括访问修饰符。

posted @ 2015-08-15 10:50  lzhlyle  阅读(1267)  评论(0编辑  收藏  举报