·您现在的位置: 云翼网络 >> 文章中心 >> 网站建设 >> 网站建设开发 >> ASP.NET网站开发 >> 在线捉鬼游戏开发之三-业务对象核心代码编写与单元测试(游戏开始前:玩家入座与退出)

在线捉鬼游戏开发之三-业务对象核心代码编写与单元测试(游戏开始前:玩家入座与退出)

作者:佚名      ASP.NET网站开发编辑:admin      更新时间:2022-07-23

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

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

索引目录

0. 索引(持续更新中)

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

2. 设计业务对象与对象职责划分(1)(图解旧版本)

3. 设计业务对象与对象职责划分(2)(旧版本代码剖析)

4. 设计业务对象与对象职责划分(3)(新版本业务对象设计)

5. 业务对象核心代码编写与单元测试(游戏开始前:玩家入座与退出)

……(未完待续)

-----------回顾结束分割线-----------

 

先放上svn代码,地址:https://115.29.246.25/svn/CatGhost/

账号:guest 密码:guest(支持源代码下载,已设只读权限,待我基本做出初始版本后再放到git)

 

-----------本篇开始分割线-----------

从本篇开始,后续都是代码的编写记录了,看的会有些枯燥,但如果是开发人员,应该会看的津津有味。建议看了前面四篇说明之后(个人觉得本系列亮点还是在前四篇,有助于面向对象开发的思考方式整理),再下个svn代码。我写的这些就不用再看代码了,大致感受一下流程和思路即可。(个人对于写完整个项目后直接贴代码上来毫无讲解的那种文章比较那个……反正我是看不下去的)

代码是为文中的阐述服务的,即:主要看中文,必要理解时才看代码

一、按类图搭建基础类模型

上一篇中的这张关键类图,想必大家还有印象,下面就先从这副类图入手,先把类名、属性名、方法名搭建起来,方法都是空的(即便再简单的方法都先不写,若要求返回值,也大致应付一下别出红线),访问修饰符也暂不过多考虑,如下两类:

    public abstract class Player
    {
        public string NickName { get; set; }

        public void Speak()
        {
        }

        public void Vote()
        {
        }
    }
Player
    public class SpeakManager
    {
        PRivate StringBuilder _record;

        public void PlayerSpeak()
        { }

        public void SystemSpeak()
        { }

        public string ShowRecord()
        {
            if (this._record != null)
            {
                return this._record.ToString();
            }
            return string.Empty;
        }

        public void ClearRecord()
        { }

        public void SetSpeaker()
        { }
    }
SpeakManager

 

二、从单元测试开始,从类创建、初始化类内属性、维护类属性的角度,逐步完善类

以下是Table类单例模式的测试,很简单

[TestMethod]
public void CreateTableUnitTest()
{
    Table table = Table.GetInstance();
    Assert.IsNotNull(table);
}
CreateTableUnitTest
private static Table _table = new Table();
private Table() { }
public static Table GetInstance()
{
    return _table;
}
Table Singleton

以下是Setting类初始化的同时,获取配置文件游戏人数的测试,附上目前的Setting类,也比较好理解

[TestMethod]
public void GetSettingCountUnitTest()
{
    Setting setting = Setting.GetInstance();
    int iTotalCount = setting.GetTotalCount();
    int iExpectCount = 9, iExpectCivilianCount = 4, iExpectIdiotCount = 2, iExpectGhostCount = 3;
    Assert.AreEqual(iExpectCount, iTotalCount);
    Assert.AreEqual(iExpectCivilianCount, setting.CivilianCount);
    Assert.AreEqual(iExpectIdiotCount, setting.IdiotCount);
    Assert.AreEqual(iExpectGhostCount, setting.GhostCount);
}
GetSettingCountUnitTest
[TestMethod]
public void IsFullFromSettingUnitTest()
{
    Setting setting = Setting.GetInstance();
    bool iExpectFalse = setting.IsFull(8);
    bool iExpectTrue = setting.IsFull(9);
    Assert.AreEqual(false, iExpectFalse);
    Assert.AreEqual(true, iExpectTrue);
}
IsFullFromSettingUnitTest
    public class Setting
    {
        // field
        private int _civilianCount;
        private int _idiotCount;
        private int _ghostCount;

        // property
        public int CivilianCount
        {
            get { return _civilianCount; }
        }
        public int IdiotCount
        {
            get { return _idiotCount; }
        }
        public int GhostCount
        {
            get { return _ghostCount; }
        }

        #region singleton
        private static Setting _setting = new Setting();
        private Setting()
        {
            InitialCount();
        }

        /// <summary>
        /// 初始化配置文件人数
        /// </summary>
        private void InitialCount()
        {
            string strCivilianCount = System.Configuration.ConfigurationManager.AppSettings["CivilianCount"];
            string strIdiotCount = System.Configuration.ConfigurationManager.AppSettings["IdiotCount"];
            string strGhostCount = System.Configuration.ConfigurationManager.AppSettings["GhostCount"];
            this._civilianCount = StringConvertToInt32(strCivilianCount);
            this._idiotCount = StringConvertToInt32(strIdiotCount);
            this._ghostCount = StringConvertToInt32(strGhostCount);
        }

        /// <summary>
        /// 转换字符串为数字
        /// </summary>
        /// <param name="strNumber">字符串</param>
        /// <returns>数字</returns>
        private int StringConvertToInt32(string strNumber)
        {
            int result = 0;
            int.TryParse(strNumber, out result);
            return result;
        }

        public static Setting GetInstance()
        { return _setting; }
        #endregion

        // method

        /// <summary>
        /// 返回游戏总人数
        /// </summary>
        /// <returns>总人数</returns>
        public int GetTotalCount()
        {
            return CivilianCount + IdiotCount + GhostCount;
        }

        /// <summary>
        /// 是否玩家已满
        /// </summary>
        /// <param name="iCurrentCount">当前玩家数</param>
        /// <returns>是否已满</returns>
        public bool IsFull(int iCurrentCount)
        {
            return GetTotalCount() == iCurrentCount;
        }
    }
Setting

以下是PlayerManager类增减玩家名单的测试

[TestMethod]
public void GetPlayerNameArrayUnitTest()
{
    PlayerManager manager = new PlayerManager();
    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.SetPlayer(order, name);
    }
    string[] array = manager.GetNameArray();
    Assert.AreEqual(names.Length, array.Length);

    manager.DeletePlayer(5);
    Assert.AreEqual(names.Length, array.Length);

    foreach (string name in array)
    {
        Console.WriteLine(name);
    }
}
GetPlayerNameArrayUnitTest
    public class PlayerManager
    {
        // field
        private string[] _nameArray;
        private Player[] _playerArray;

        // method
        public PlayerManager()
        {
            InitialArray();
        }

        /// <summary>
        /// 初始化数组个数
        /// </summary>
        private void InitialArray()
        {
            Setting setting = Setting.GetInstance();
            int totalCount = setting.GetTotalCount();
            this._nameArray = new string[totalCount];
            this._playerArray = new Player[totalCount];
        }

        /// <summary>
        /// 设置指定座位号的玩家名
        /// </summary>
        /// <param name="order">座位号</param>
        /// <param name="nickName">玩家名</param>
        public void SetPlayer(int order, string nickName)
        {
            this._nameArray[order] = nickName;
        }

        public void SetPlayer(Player player)
        { }

        /// <summary>
        /// 删除指定座位号的玩家名
        /// </summary>
        /// <param name="order">座位号</param>
        public void DeletePlayer(int order)
        {
            this._nameArray[order] = string.Empty;
        }

        /// <summary>
        /// 返回玩家名单数组
        /// </summary>
        /// <returns>玩家名单数组</returns>
        public string[] GetNameArray()
        {
            return this._nameArray;
        }

        /// <summary>
        /// 返回指定角色的玩家数组
        /// </summary>
        /// <param name="type">玩家角色</param>
        /// <returns>玩家数组</returns>
        public Player[] GetPlayerArray(Type type)
        {
            List<Player> returnList = new List<Player>();
            foreach (Player player in this._playerArray)
            {
                if (player.GetType().Equals(type))
                {
                    returnList.Add(player);
                }
            }
            return returnList.ToArray();
        }
    }
PlayerManager

在逐渐完善类的过程中,需要注意几点:

(1)命名问题:方法名别怕长,一定要写清楚,英文不好就找翻译,再不行就用拼音,再不行就写中文。

(2)新方法的加入:如Setting中获取游戏总人数的方法GetTotalCount(),以后在PlayerManager类需要以此为据初始化数组大小(座位数量),往后还有许多这种在设计阶段没考虑到的方法,也反衬出:设计阶段不要过于纠结如何做到最perfect的类设计之后再去写代码,而是做个大概,主要是划分职责(单一职责原则),以后再写代码的时候发现需要这么一个方法了再去添加(也要考虑这个方法/职责应该划分给谁的问题)。

(3)访问修饰符问题:多考虑迪米特法则(知道最少原则)——如果外界没必要知道,就private,然后再慢慢升级访问修饰符。好处是:在编写单元测试的时候就能够最早看出此类哪些是有必要开放的、哪些是要保护起来的,因为单元测试是最先覆盖所有类内路径的测试,比整个项目做得差不多了再考虑、或者边做边考虑,前者更完整(程序员都有代码洁癖,别跟我说你没有)。

(4)注释写得好,代码改的少

相信上述几点已经耳濡目染很久了,这里写出来也是为和我一样深有感悟的新手们做再次提醒,自己的感悟+别人的提醒=记忆更深刻。

 

三、自定义异常

我指的自定义是类似这样的:

namespace Catghost.Common.Exceptions
{
    public sealed class NickNameIsNullOrEmptyException : Exception
    {
        public NickNameIsNullOrEmptyException()
        {
            throw new Exception("昵称不能为空。");
        }
    }
}
NickNameIsNullOrEmptyException
            // 检查昵称是否为空
            if (string.IsNullOrEmpty(nickName))
            {
                throw new NickNameIsNullOrEmptyException();
            }

            // 检查昵称是否重复
            foreach (string name in this._nameArray)
            {
                // 跳过空座位的检测
                if (string.IsNullOrEmpty(name))
                {
                    continue;
                }

                if (name.Equals(nickName))
                {
                    throw new NickNameRepeatException();
                }
            }
调用自定义异常

 单元测试情况:

系统不是会自己抛异常吗,如果需要写文字,不是还有new Exception(string)重载吗,干嘛还要自定义手写一堆?理由很简单:复用。

假设有五个方法都要判断昵称是否为空,你可能会想到提取出一个private方法或者在common文件夹下新建类并将CheckNickName()公开,这是对的,复用嘛,但如果将异常也放在此类,甚至更糟糕的放在各处消费代码中时,那提示的中文语句可能是“用户名不能为空”、“您的用户名为空了”、“对不起,请保证不为空的用户名”——各种不一致,各种不复用。而且自定义异常也许在项目中后期会增加很多,甚至是你的队友们在增加,那怎么沟通呢?

团队约定这时候出现作用了:约定所有异常放在Common/Exceptions文件夹下,且命名有规则……巴拉巴拉,其实团队约定真的很重要,不然你写了半天的日期格式化,结果是团队大哥早写好放那的(以前我就遇到过这种蠢事),所以从头开始项目团队的时候,拿出一份Word,一条条写着团队约定(在哪个文件夹放什么东西、命名规则如何、注释如何写等等)。同样,进入新的团队或者有新人加入的时候,一定要自己主动去问或者告诉新人:这个项目的开发约定有哪些。

 

四、分析代码

如此高大上的章节标题,当然要依赖高级点的功能,其实我不是很能参透这一块,看了《重构》就稍微能理解多一些了:

 

右键解决方案管理器中的根目录->分析
(1)运行代码分析

意思是要加个可序列化标签(没明白深层原因,实现了ISerializble接口就要标记?回头再明白吧,目前先走完项目,写这个开发系列的好处也在这里,不明白的先记下,回头再看,以免影响主要事件),行,那就加上。果断ok。

(2)计算代码度量值

这里是本篇亮点(利用重构提高可维护性、减低耦合与复杂度

简单介绍一下,代码度量值是检测代码可维护性较好的指标。

可维护性指数(越大越好):表示指定代码容易修改、利于应对需求变换的程度。

类耦合度(越小越好):高内聚低耦合,说的就是这里,表示这里的代码与其他类之间的直接关系有多大,关系越小就越不容易一改都改——越容易维护。

圈复杂度(越小越好):表示代码中的分支情况有多少的问题,当然是越少越利于开发人员理解,越容易维护。

继承深度、代码行数(都是越小越好):简单不赘述。

来吧开始:看到图中鼠标点亮的那一行——PlayerManager类的SetPlayer()方法,此方法职责是在玩家点击“入座”按钮后,将玩家名字添加到列表中(很简单吧,这有什么难的?看下去,有戏)。可以看到可维护性非常之低(56,还没及格呢),耦合(7)、复杂度(10)看似比较低,但放眼看去其他方法(不要看成上面的其他类或者目录总和)的这些指标,都很小(3以下),说明:这个方法的代码没写好、要大改!

先来看看这个方法都写了啥

        /// <summary>
        /// 设置指定座位号的玩家名
        /// </summary>
        /// <param name="order">座位号</param>
        /// <param name="nickName">玩家名</param>
        public void SetPlayer(int order, string nickName)
        {
            // 检查座位序号正确性
            if (order < 0 || order >= this._nameArray.Length)
            {
                throw new IndexOutOfRangeException("入座位置不正确。");
            }

            // 检查昵称是否为空
            if (string.IsNullOrEmpty(nickName))
            {
                throw new NickNameIsNullOrEmptyException();
            }

            // 检查昵称是否重复
            foreach (string name in this._nameArray)
            {
                // 跳过空座位的检测
                if (string.IsNullOrEmpty(name))
                {
                    continue;
                }

                if (name.Equals(nickName))
                {
                    throw new NickNameRepeatException();
                }
            }

            this._nameArray[order] = nickName;
            if (Setting.GetInstance().IsFull(this._nameArray.Where(n => !string.IsNullOrEmpty(n)).Count()))
            {
                this._table.StartGame();
            }
        }
SetPlayer

 感觉还是比较清晰的,通俗易懂,还有注释——没错,就是注释出的问题:在《重构》中,注释就是最大的需要改的信号——你会对string myName="jack"; 添加一段这样的注释吗? // 我的名字是jack

肯定不会。为什么?你可能会回答:因为我一看就懂,不用注释,只有难懂的、太长的代码,才需要注释。

没错,那既然知道这是一段难懂、太长的代码,为何不去优化、拆分,使他变得又容易、又简短呢?下面就随笔者来做这个事儿:

(1)先测试,保证目前的代码是ok的。好了,测了,ok。

(2)把“检查座位序号正确性”的代码提出去,起个好名字,消掉注释。

 

 代码改为:

        /// <summary>
        /// 设置指定座位号的玩家名
        /// </summary>
        /// <param name="order">座位号</param>
        /// <param name="nickName">玩家名</param>
        public void SetPlayer(int order, string nickName)
        {
            CheckSeatOrder(order);

            // 检查昵称是否为空
            if (string.IsNullOrEmpty(nickName))
            {
                throw new NickNameIsNullOrEmptyException();
            }

            // 检查昵称是否重复
            foreach (string name in this._nameArray)
            {
                // 跳过空座位的检测
                if (string.IsNullOrEmpty(name))
                {
                    continue;
                }

                if (name.Equals(nickName))
                {
                    throw new NickNameRepeatException();
                }
            }

            this._nameArray[order] = nickName;
            if (Setting.GetInstance().IsFull(this._nameArray.Where(n => !string.IsNullOrEmpty(n)).Count()))
            {
                this._table.StartGame();
            }
        }

        /// <summary>
        /// 检查座位号正确性
        /// </summary>
        /// <param name="order">座位号</param>
        private void CheckSeatOrder(int order)
        {
            if (order < 0 || order >= this._nameArray.Length)
            {
                throw new IndexOutOfRangeException("座位号不正确。");
            }
        }
CheckSeatOrder

(3)回到(1)——测试。全部通过。

(4)继续(2)——找到其他注释的地方,提取方法,起个好名字,消掉注释。

(5)测试。全部通过。

直到SetPlayer()代码成为下面这样(提取后的我就不贴了,都一样的):

        public void SetPlayer(int order, string nickName)
        {
            CheckSeatOrder(order);

            CheckNickNameIsNullOrEmpty(nickName);

            CheckNickNameIsRepeat(nickName);

            this._nameArray[order] = nickName;
            if (Setting.GetInstance().IsFull(this._nameArray.Where(n => !string.IsNullOrEmpty(n)).Count()))
            {
                this._table.StartGame();
            }
        }
SetPlayer

此时我们再来测一下代码度量值:

哎哟不错喔!(80后的偶像周杰伦!)可维护性提高了9个点,耦合与复杂度都减小了一大半,嘿嘿~说明路子对了,那么咱继续优化,看能做到多好。

此时的SetPlayer方法中,最主要的就是一句 this._nameArray[order] = nickName; 这一句已经不能再简单了。除此之外,都是附加的内容(前三行的检测、之后的也算是检测——检测够不够人开始游戏)。那么要改的肯定是附加内容的。代码先贴一下:

if (Setting.GetInstance().IsFull(this._nameArray.Where(n => !string.IsNullOrEmpty(n)).Count()))
{
            this._table.StartGame();
}
检测是否够人开始游戏

怎么看有问题的地方?其实很简单,计算机的脑子转的很快(只要内存够),我们看10秒,计算机10毫秒不到,所以代码的问题,主要不是为了给计算机方便,而是给人方便,给程序员方便,作为程序员,你觉得看哪里不顺眼、不爽,那就是有问题

这段代码中我看if的判断里面就很长,不爽!

改为这个样子(也是用的提取方法):

if (GetSetting().IsFull(GetCurrentPlayerCount()))
{
            this._table.StartGame();
}
修改if判断

哈哈,爽多了,简直跟看中文一样,只不过换成英文罢了。

还有问题的地方很明显,if判断中,需要看完整个代码才明白是做什么的,此时再做一次提取方法,成为这样:

public void SetPlayer(int order, string nickName)
{
    CheckSeatOrder(order);

    CheckNickNameIsNullOrEmpty(nickName);

    CheckNickNameIsRepeat(nickName);

    this._nameArray[order] = nickName;

    CheckStartGame();
}
SetPlayer

感觉嗨了吧,现在回想一下,从一开始的一长段代码,成为这个样子,有点写纯粹写英文方法名就能完成方法一样,看起来简直不要太爽。

在最后这个if的判断这里,也许有的朋友会问,为什么不直接把整个if换掉,而要先换if判断里面的问题:

首先我想你认可的是:if判断里面肯定有问题。那么,如果直接换掉整个if,你还会记得要继续去解决if里面本来就像毒瘤一样存在的问题吗?一段代码的臭味道并没有什么,但如果养成想一步登天的坏习惯来执行重构,这是最糟糕的了。所以咱还是鼓励当傻逼,看见了也说没看见,每次只做一点点

当然,别忘了每改动一处,就测试一次

现在我们再来测一下代码度量值:(也许你会担心那一堆多出来的方法会不会使得整个类太臃肿,答案是no,还是那句话,计算机根本不怕麻烦,最怕麻烦也因为繁琐而容易出错的是人,只要那些提取出来的方法都是private,外界就不会知道,也就是程序员外界调用此类时也根本不会知道这个类里面到底有多少私有方法)

哎呀妈呀!(甜馨威武~)各指标数据也是好看的不要不要的。(还能不能更优化?此处抛砖引玉,大家可下载源码自行尝试)

 

五、总结

1. 搭建基础类模型【从无到有的质变,总好过对着一个空的class就开始无头绪的写】

2. 从单元测试开始,逐步完善类【保障正确率,而不是建了前台才测试】

3. 自定义异常【利于重用】

4. 代码分析【通过重构,优化代码,利于那一堆好处——易维护、易复用、易扩展、灵活性好——四个好处都有对应的意思和做法,详细请参照程杰版《大话设计模式》】

上述四步是我在做这个项目(其他项目,也许会有其他的步骤)到目前为止,不断在迭代(循环重复)操作的过程,使得代码不断向更正确更好的方向前进。建议大家把对自己有用的开发流程(也许我的只是小项目可用,大家可以多看大牛们的文章)当做内功一样来修为,而不是外在的尚方宝剑(拿到了才NB,没拿到就是菜B),要把思路练到不怕丢不怕忘,甚至总结自己的方法步骤。

不说了,得觅食去了,回来继续照着上面步骤改代码去,争取早点儿提交svn给大家下载最新源码(本篇的代码已提交)。