原创文章,转载请保留作者署名!
前面已经说了牌堆的设计,那么现在就正式进入流程,满足我们在(一)中所说的需求。
由于在(二)中已经说了要维护扩展,因此对于之前定义的Scene,则需要定义一个所选择的扩展包,代码如下:
扩展包
/// <summary>
/// 初始化新的<see cref="Scene"/>类的实例。
/// </summary>
/// <param name="packages">所要加载的包。</param>
public Scene(IEnumerable<IPackage> packages)
{
players[currentToken].HasToken = true;
selectedPackages = packages.ToArray();
}
由于玩家是轮动的,因此设定一个令牌,只有持有令牌的玩家才能行动。
那么定义一个Start方法,开始游戏循环。
首先要根据选择的扩展包生成游戏牌堆。对于游戏牌堆,由于我们之前已经定义好了牌堆基类,因此实现就比较简单了,将扩展包中的游戏牌载入并洗牌即可。
游戏牌堆
/// 表示游戏牌牌堆。
/// </summary>
public sealed class GameCardHeap : CardHeap<GameCard>
{
/// <summary>
/// 初始化新的<see cref="GameCardHeap"/>类的实例。
/// </summary>
/// <param name="packages">所要加载的扩展包。</param>
public GameCardHeap(IEnumerable<IPackage> packages)
{
foreach (var package in packages)
{
((List<GameCard>) Items).AddRange(package.GameCards);
}
Items.Shuffle();
}
}
回头看我们上面的Start方法,在牌堆创建后,就进入了流程循环,还是直接来代码吧,注释都写的蛮清楚的![]()
流程循环
/// 开始游戏。
/// </summary>
public void Start()
{
Player currentPlayer;
GameCardHeap heap = new GameCardHeap(selectedPackages); // 创建牌堆
while(true)
{
currentPlayer = players[currentToken]; // 设置当前有令牌的玩家
// 摸牌阶段
GameCard[] newCards = heap.Pop(2, true);
currentPlayer.Draw(newCards);
// 出牌阶段
while(currentPlayer.HasPlayableCard)
{
// NOTE:为了简单,先实现只杀下家,并只使用杀、闪、桃
int nextToken = currentToken == players.Length - 1 ? 0 : currentToken + 1;
currentPlayer.Play(players[nextToken], currentPlayer.FirstPlayableCard);
if (IsGameEnds())
goto label;
}
// 弃牌阶段
// NOTE:为了简单,先实现只弃从头开始的牌到当前体力值
int disCardCount = currentPlayer.HandCards.Length - currentPlayer.Hp;
if (disCardCount > 0)
{
GameCard[] removeCards = currentPlayer.HandCards.Take(disCardCount).ToArray();
currentPlayer.Discard(removeCards);
}
// 将令牌给下一个人
GiveTokenToNext();
}
label:
Console.WriteLine("游戏结束!");
}
这里使用了一个死循环,但在游戏结束的时候使用goto语句跳出循环。所涉及到的一些方法如下:
一些辅助方法
{
if (currentToken == players.Length - 1)
currentToken = 0;
else
currentToken++;
players[currentToken].HasToken = true;
}
private bool IsGameEnds()
{
return players.Any(p => p.IsDead); // 如果选择到IsDead的Player,游戏结束
}
注意,流程的设计中,由于玩家是在游戏逻辑边界内的,因此采用了主动的做法来进行设计。这样对于程序来说,我只要去考虑玩家的摸牌、出牌和弃牌所引起的各种变化即可。
原创文章,转载请保留作者署名!
前一节说到了一些基础性的定义。这一节开始将进入流程的分析。
首先,在游戏的场景建立之后,你就必须有一个牌堆。对于目前的需求来说,只要有手牌的牌堆即可;尽管后面可能还要有身份牌堆和武将牌堆,但目前只考虑手牌,即游戏牌。于是有以下定义:
/// 定义牌堆的基本类型。
/// </summary>
/// <typeparam name="T">参数类型。</typeparam>
public abstract class CardHeap<T> : Collection<T>
{
}
定义为抽象的,是我希望能提供一些通用的方法以简化其他牌堆的设计。
对于牌堆来说,其一个重要的功能就是能够压出牌以供使用,因此定义如下:
压牌
/// 从牌堆中压出指定数量的牌,这些牌将会从牌堆中移除。
/// </summary>
/// <param name="number">要压出的牌的数量。</param>
/// <returns>所压出的牌的数组。</returns>
public T[] Pop(int number)
{
if (number <= 0)
return new T[0];
if (Items.Count < number)
number = Items.Count;
T[] newT = new T[number];
for (int i = 0; i < number; i++)
{
newT[i] = Items.First();
Items.RemoveAt(0);
}
return newT;
}
牌堆定义之后就需要定义洗牌的操作。由于我定义了从Collection<T> 继承,其内部有个IList<T>类型的Items属性,因此编写一个扩展方法,对IList<T>类型的数据进行类似洗牌的操作:
洗牌扩展
/// 定义对List的扩展方法。
/// </summary>
public static class ListExtension
{
/// <summary>
/// 将IList中的元素进行洗牌操作。
/// </summary>
/// <typeparam name="T">类型参数。</typeparam>
/// <param name="list">所要洗牌的List。</param>
public static void Shuffle<T>(this IList<T> list)
{
Random random = new Random();
int count = list.Count;
for (int i = 0; i < count; i++)
{
int currentIndex = random.Next(0, count - i);
T tempCard = list[currentIndex];
list[currentIndex] = list[count - 1 - i];
list[count - 1 - i] = tempCard;
}
}
}
很明显,只要游戏没有结束,牌堆中拿出使用过的废牌总要回收进行循环利用,所以废牌要保存起来,并让牌堆支持其中的Items的重生。因此CardHeap中便多了如下定义:
牌堆重生
private void ReCreate()
{
// 将usedItems的牌还原到Items中,并进行洗牌,然后清空usedItems
((List<T>) Items).AddRange(usedItems);
usedItems.Clear();
Items.Shuffle();
}
既然多了usedItems,那么上面定义的Pop就需要有个重载,以指定牌堆是否可以进行重生,所以重构上面的Pop方法,改为重载:
重载Pop
/// 从牌堆中压出指定数量的牌,这些牌将会从牌堆中移除。如果牌堆的牌数量不够,则只返回牌堆中剩余的牌。
/// </summary>
/// <param name="number">要压出的牌的数量。</param>
/// <returns>所压出的牌的数组。</returns>
public T[] Pop(int number)
{
return Pop(number, false);
}
/// <summary>
/// 从牌堆中压出指定数量的牌,这些牌将会从牌堆中移除。
/// </summary>
/// <param name="number">要压出的牌的数量。</param>
/// <param name="recreateHeap">在压出牌数量不够的时候是否重新创建牌堆。</param>
/// <returns>所压出的牌的数组。</returns>
public T[] Pop(int number, bool recreateHeap)
{
if (number <= 0)
return new T[0];
if (Items.Count < number && !recreateHeap)
number = Items.Count;
T[] newT = new T[number];
for (int i = 0; i < number; i++)
{
if (recreateHeap && Items.Count == 0)
{
ReCreate();
}
newT[i] = Items.First();
Items.RemoveAt(0);
usedItems.Add(newT[i]);
}
return newT;
}
这样,牌堆的定义就算基本完成了。但在此基础上考虑考虑扩展包的问题。实际上扩展包主要是牌的扩展,而且在设计初期,就已经考虑将标准版的牌从这个Core中分离,即除了基本牌的杀、闪、桃之外,锦囊和装备、武将都是由扩展包来提供。因此定义了个扩展包的接口:
扩展包接口
/// 定义扩展包所必须实现的接口。
/// </summary>
public interface IPackage
{
/// <summary>
/// 扩展包中的游戏牌。
/// </summary>
GameCard[] GameCards { get; }
}
好,牌堆的设计就说到这里,后面就定义实际的基本牌,并将进入实际流程循环。
原创文章,转载请保留作者署名!
最近做翻译做的头疼,疼过之余,想想之前公司内组织的三国杀开发兴趣小组在三国杀开发问题上几乎又停滞了。于是又翻出来搞了搞,这次却搞的有点像模像样了,特地把思路和方法都共享出来,一起学习。
三国杀在上海是挺风靡的,如果你还不知道玩法和规则,请猛击这里。
今天要说的是实现的第一步。为了尽量简化需求,我们最初要实现的东西也挺简单,就是能支持两个玩家自动对战。具体的描述如下:在一个场景中设置两个玩家,游戏的牌堆中只有三种基本牌:杀、闪、桃。其中,只有杀和桃是属于主动性游戏牌,即可以在自己行动回合将其打出;闪是被动性游戏牌,只有当别人对自己出杀的时候才可以出闪,否则扣除一点体力,而使用桃则为自己增加一点体力。同样,为了简化实现,只要有能使用的牌,就必须使用;而且,如果你的手牌数量大于体力值,则需要弃牌直到手牌数与体力值相等。在这种情况下,只要有一方体力为0,游戏结束!
对需求进行整理,不难发现有以下需求待实现:
1、游戏结束的条件:只要有玩家死亡即宣告结束。
2、牌堆的实现,注意牌堆中拿出的牌需要从牌堆中移除,以及洗牌的功能。
3、杀、闪、挑的逻辑实现。
4、从手牌中计算是否还有可用的牌。
5、玩家轮询行动的实现。
在设计中,我的思路是这样的:一局游戏实际都在一个场景中进行,直到游戏结束。因此首先有场景的定义:
/// 表示游戏场景。
/// </summary>
public class Scene
{
private readonly Player[] players;
}
其中场景中定义了游戏玩家,因为场景中的游戏玩家在场景生成之后就固定了,所以使用了readonly。
同样,游戏实际是个循环,结束的条件就是有玩家死亡。因此,先定义玩家死亡的条件:
{
return players.Where(p => p.IsDead).Count() > 0; // 如果选择到IsDead的Player,游戏结束
}
这样,由于其中引用到了Player类,因此,建立玩家类,来对其按照需求进行设计。代码如下:
Player
/// 表示游戏中的玩家。
/// </summary>
public class Player
{
private readonly List<GameCard> handCards = new List<GameCard>(20);
public Player(string name, byte maxHp)
{
this.Name = name;
this.Hp = maxHp;
this.MaxHp = maxHp;
}
/// <summary>
/// 表示该玩家的名称。
/// </summary>
public string Name { get; private set; }
/// <summary>
/// 表示该玩家是否已经死亡。
/// </summary>
public bool IsDead
{
get { return Hp == 0; }
}
/// <summary>
/// 表示该玩家所选武将的当前HP.
/// </summary>
public byte Hp { get; private set; }
/// <summary>
/// 表示该玩家所选武将的最大HP.
/// </summary>
public byte MaxHp { get; private set; }
}
其中的handCards表示玩家手中可以拿的手牌数量,这里设定上限为20张。那么同样要开始定义游戏卡牌,代码如下:
GameCard
/// 表示游戏中用作游戏的卡牌。
/// </summary>
public abstract class GameCard
{
/// <summary>
/// 表示牌的花色。
/// </summary>
public CardMark Mark { get; protected set; }
/// <summary>
/// 表示牌的大小。
/// </summary>
public CardValue Value { get; protected set; }
/// <summary>
/// 表示牌的名称。
/// </summary>
public string Name { get; protected set; }
/// <summary>
/// 对目标玩家使用卡牌。
/// </summary>
/// <param name="source">使用卡牌的源对象。</param>
/// <param name="target">使用卡牌的目标对象。</param>
public abstract void Use(Player source, Player target);
}
直到现在,所需要的定义已经基本完成。这其中的代码有很多不规范的地方,之所以展现出来,也是为了表现自己的思维过程,在后面会有很多对目前代码的重构。
在这样定义完了之后,下一步将开始仔细分析流程,按照流程先写出最初能运行的版本。(未完待续...)
前篇提到过由于我们已经有了一个现成的平台,现在要对其进行单元测试的补完。而在这个过程中,就出现了HttpContext这类东西,其依附于一个host环境,对单元测试的自动化是一个很大的阻碍。
对于HttpContext,如果没有一个web托管环境,其中的Request和Response等只读属性根本就无法造出来。而如果要搭建一个web托管环境,不仅为测试带来了干扰(因为要确定是否是托管环境的问题),而且给测试的自动化带来了不方便。那么怎么去解决这个问题呢?
在MSDN中我们可以查到一个叫SimpleWorkerRequest的东西,这个东西的提供了一个简单的System.Web.HttpWorkerRequest的实现,使得我们可以在IIS之外托管ASP.NET应用程序。而当我们使用reflector来查看这个东西的源码的时候,发现其中的一些方法很有趣:
SimpleWorkerRequest
2 public class SimpleWorkerRequest : HttpWorkerRequest
3 {
4 //...
5
6 public override string GetHttpVerbName()
7 {
8 return "GET";
9 }
10
11 public override string GetHttpVersion()
12 {
13 return "HTTP/1.0";
14 }
15
16 public override string GetLocalAddress()
17 {
18 return "127.0.0.1";
19 }
20
21 public override int GetLocalPort()
22 {
23 return 80;
24 }
25
26 //...
27 }
这果然是一个简单的实现啊,把IP地址,Http版本,端口等全部硬编码了。
但对我们的需求来说,其实也非常的简单,既然他硬编码了,那我们再派生一下,把这些方法override一下不就可以了:
TestWorkerRequest
2 /// Provides a simple implementation of the System.Web.HttpWorkerRequest abstract class that can be used to host ASP.NET applications outside an Internet Information Services (IIS) application.
3 /// This class can be used for unit test which needs a web host.
4 /// </summary>
5 public class TestWorkerRequest : SimpleWorkerRequest
6 {
7 private readonly string hostName = "";
8
9 /// <summary>
10 /// Initializes a new instance of the <see cref="T:System.Web.Hosting.SimpleWorkerRequest"/> class when the target application domain has been created using the <see cref="M:System.Web.Hosting.ApplicationHost.CreateApplicationHost(System.Type,System.String,System.String)"/> method.
11 /// </summary>
12 /// <param name="page">The page to be requested (or the virtual path to the page, relative to the application directory).</param>
13 /// <param name="query">The text of the query string.</param>
14 /// <param name="output">A <see cref="T:System.IO.TextWriter"/> that captures output from the response</param>
15 /// <param name="hostName">The host name that will be requested.</param>
16 public TestWorkerRequest(string page, string query, TextWriter output, string hostName)
17 : base(page, query, output)
18 {
19 this.hostName = hostName;
20 }
21
22 /// <summary>
23 /// Initializes a new instance of the <see cref="T:System.Web.Hosting.SimpleWorkerRequest"/> class for use in an arbitrary application domain, when the user code creates an <see cref="T:System.Web.HttpContext"/> (passing the SimpleWorkerRequest as an argument to the HttpContext constructor).
24 /// </summary>
25 /// <param name="appVirtualDir">The virtual path to the application directory; for example, "/app".</param>
26 /// <param name="appPhysicalDir">The physical path to the application directory; for example, "c:\app".</param>
27 /// <param name="page">The virtual path for the request (relative to the application directory).</param>
28 /// <param name="query">The text of the query string.</param>
29 /// <param name="output">A <see cref="T:System.IO.TextWriter"/> that captures the output from the response.</param>
30 /// <param name="hostName">The host name that will be requested.</param>
31 /// <exception cref="T:System.Web.HttpException">The <paramref name="appVirtualDir"/> parameter cannot be overridden in this context.
32 /// </exception>
33 public TestWorkerRequest(string appVirtualDir, string appPhysicalDir, string page, string query, TextWriter output, string hostName)
34 : base(appVirtualDir, appPhysicalDir, page, query, output)
35 {
36 this.hostName = hostName;
37 }
38
39 /// <summary>
40 /// Returns the server IP address of the interface on which the request was received.
41 /// </summary>
42 /// <returns>
43 /// The server IP address of the interface on which the request was received.
44 /// </returns>
45 public override string GetLocalAddress()
46 {
47 return hostName;
48 }
49 }
那么使用如下代码便可以模拟出一个HttpContext了,这样依赖就不存在了。
代码
Thread.GetDomain().SetData(".appPath", @"D:\Test");
Thread.GetDomain().SetData(".appVPath", "/");
TextWriter tw = new StringWriter();
string address = "http://www.sina.com.cn/";
HttpWorkerRequest wr = new MyWorkerRequest("login.aspx", "", tw, address);
HttpContext.Current = new HttpContext(wr);
其实说这个事情,本身并不是为了这个技巧,而是想借这个例子说明怎么去考虑层的职责。比如对HttpContext这个东西,因为你知道你现在设计的是Web程序,你直接使用了这个。但如果有一天同样的业务,让你做一个WinForm呢?HttpContext该怎么办?所以,这样一分析就知道HttpContext这个东西肯定不属于逻辑层。
而如果你对逻辑层做单元测试的话,那么你必定会遇到上述问题。而一旦遇到这种问题,应该就说明了你的设计思路有问题,因为从逻辑本身来说,实现一个测试,我不应该需要借助任何的模拟。至于Mock这个东西,留给以后的篇幅吧。
前面讲到了现在被调到公司创新组做单元测试相关的工作,先交代下一些背景:
公司是一个做电子商务网站的公司,规模还可以。目前已经有了一套框架,该框架实现了ORM、同步、缓存等很多的功能;然后整个业务算是构建在这个框架之上的二次开发。我目前的任务是对这个业务代码构建一个相对完整的单元测试集。
既然提到单元测试,就不得不提自动化这一块。这里的自动化包含两层意思:首先,单元测试用例需要一个自动化执行的环境,即一个能够由外部条件触发而自动运行。其次是单元测试用例本身要能够自动化的进行,不能每次运行之前需要先配置好环境之类,也即能满足回归测试的要求。
对于第一个要求,我们使用的是CruiseControl.NET这个持续集成工具来进行自动化的配置。这个工具的具体用法这里不介绍了,具体可参见http://ccnetlive.thoughtworks.com/。注意到我们现在建立测试并不是走的测试驱动开发流程,因此可以采用一个定时的方式来发动集成请求,我们把这个时间设置在半夜,具体做的事情如下:先去SVN上取出最新的源代码和测试代码,然后编译,最后使用NUnit运行生成的测试dll。
下面是一个ccnet配置示例,有兴趣的可以拿来直接去用了
ccnet配置
2 <!-- This is your CruiseControl.NET Server Configuration file. Add your projects below! -->
3 <project name="" queuePriority="1" queue="Q1">
4 <workingDirectory>D:\ccnet</workingDirectory>
5 <artifactDirectory>D:\ccnet</artifactDirectory>
6 <category>Unit Test</category>
7
8 <sourcecontrol type="multi">
9 <sourceControls>
10 <svn>
11 <trunkUrl></trunkUrl>
12 <workingDirectory>D:\ccnet\source\</workingDirectory>
13 <executable>C:\Program Files (x86)\Subversion\bin\svn.exe</executable>
14 <username></username>
15 <password></password>
16 <cleanCopy>true</cleanCopy>
17 <timeout>60000</timeout>
18 </svn>
19
20 <svn>
21 <trunkUrl></trunkUrl>
22 <workingDirectory>D:\ccnet\source\UT</workingDirectory>
23 <executable>C:\Program Files (x86)\Subversion\bin\svn.exe</executable>
24 <username></username>
25 <password></password>
26 <cleanCopy>true</cleanCopy>
27 <timeout>60000</timeout>
28 </svn>
29 </sourceControls>
30 </sourcecontrol>
31
32 <tasks>
33 <modificationWriter>
34 <filename>changelist.xml</filename>
35 <path>D:\ccnet\changelist</path>
36 <appendTimeStamp>true</appendTimeStamp>
37 </modificationWriter>
38
39 <msbuild>
40 <executable>C:\Windows\Microsoft.NET\Framework\v3.5\MSBuild.exe</executable>
41 <workingDirectory>D:\ccnet</workingDirectory>
42 <projectFile></projectFile>
43 <timeout>600</timeout>
44 <logger>C:\Program Files (x86)\CruiseControl.NET\server\ThoughtWorks.CruiseControl.MsBuild.dll</logger>
45 </msbuild>
46
47 <nunit>
48 <path>C:\Program Files (x86)\NUnit 2.5\bin\net-2.0\nunit-console.exe</path>
49 <assemblies>
50 <assembly></assembly>
51 </assemblies>
52 </nunit>
53 </tasks>
54
55 <triggers>
56 <scheduleTrigger time="23:30" name="UnitTestSchedule" buildCondition="ForceBuild">
57 <weekDays>
58 <weekDay>Monday</weekDay>
59 <weekDay>Tuesday</weekDay>
60 <weekDay>Wednesday</weekDay>
61 <weekDay>Thursday</weekDay>
62 <weekDay>Friday</weekDay>
63 </weekDays>
64 </scheduleTrigger>
65 </triggers>
66
67 <publishers>
68 <modificationHistory onlyLogWhenChangesFound="true"/>
69 <xmllogger logDir="D:\ccnet\buildlogs"/>
70
71 <email from="" mailhost="" mailport="25" includeDetails="true" mailhostUsername="" mailhostPassword="">
72 <users>
73 <user name="Billy" group="buildmasters" address="" />
74 </users>
75 <groups>
76 <group name="buildmaster" notification="always" />
77 </groups>
78 <subjectSettings>
79 <subject buildResult="StillBroken" value="Build is still broken for ${CCNetProject}, the fix failed." />
80 <subject buildResult="Broken" value="{CCNetProject} broke at ${CCNetBuildDate} ${CCNetBuildTime } , last checkin(s) by ${CCNetFailureUsers}" />
81 <subject buildResult="Exception" value="Serious problem for ${CCNetProject}, it is now in Exception! Check status of network / sourcecontrol" />
82 </subjectSettings>
83 </email>
84 </publishers>
85 </project>
86 </cruisecontrol>
(我这里使用的是MSBuild,也可以使用VS的solution直接编译,详见ccnet帮助)
对于第二个自动化要求,这个本身对被测代码的要求比较高。比如很明显的就是现在都采用了分层架构,但这段代码到底属于哪个层,很多人却并不是很清楚。举个例子:业务逻辑层的代码中出现了HttpContext这个东西,那么对单元测试的编写就是个很大的烦恼;再比如cookie等的设置。至于原因,从第一条可以看到要保证这个测试能自动化,那么其运行环境不能依赖于IIS或者WebServer,因此这也是单元测试设计工作中最难的地方,后面将会有很大的篇幅讲述如何消除掉这些依赖或者模拟出一个环境来。
可能有人会问为什么要自动化,同样从上面的两个方面来说。第一个方面的自动化,用Email通知下相关人员可以很快知道测试中有些什么问题,需要他们的协助。第二个方面的自动化则是出于第一个方面的需求,因为如果每次测试都要人工干预的话会很麻烦,成本很高。


