上一节我们讨论的游戏大厅的实现,这一节我们来看一下客户端游戏棋盘的处理
关于棋盘的呈现采用了GDI的DrawImage方法,先准备一张400*400的棋盘图片和两个40*40的棋子图片(分别为黑棋和白棋),我们的思路是通过和客户端服务器的数据交互得到游戏大厅某桌的棋子信息,然后客户端直观的呈现该信息。
棋盘同样可以看作一个对象,只不过这个对象我们需要从Form来继承,因为我们需要用到PictureBox控件来呈现棋盘和棋子
//此处选择从Form继承,是考虑需要在Form上利用GDI画图已达到走棋的效果 public partial class FormPlaying : Form { private int tableIndex; private int side; //这里和服务器端的GameTable一样也定义了grid(8,8),目的是在客户端保存棋子的状态 //每次通讯时只由服务器端通知更改的棋子的位置和颜色,客户端也作相应的修改 //当然这里也可以不声明grid(8,8),不过那样每次通讯都需要传递所有的棋子位置状态,效率不高 private int[,] grid = new int[8, 8]; //工具类 private Service service; //FormPlaying和FormRoom类似都需要被负责接受数据的线程操作,需要调用Object.Invoke方法,详见第二节 delegate void LabelDelegate(Label label, string str); delegate void ButtonDelegate(Button button, bool flag); LabelDelegate labelDelegate; ButtonDelegate buttonDelegate; //保存棋子位图信息 private Bitmap blackBitmap; private Bitmap whiteBitmap; //FormPlaying将在玩家落座后呈现,在FormRoom中被初始化 //TableIndex 桌数 //Side 黑方或者白方 //sw 客户端发送的数据流 public FormPlaying(int TableIndex,int Side,StreamWriter sw) { InitializeComponent(); //构造工具类,负责打印调试信息及发送数据流到服务器端 service = new Service(listBox1, sw); this.tableIndex = TableIndex; this.side = Side; blackBitmap = new Bitmap("black.gif"); whiteBitmap = new Bitmap("white.gif"); } } |
这里和服务器端的GameTable一样也定义了grid(8,8),目的是在客户端保存棋子的状态,每次通讯时只由服务器端通知更改的棋子的位置和颜色,客户端也作相应的修改。当然也可以不声明grid(8,8),不过那样每次通讯都需要传递所有的棋子位置状态,效率不高
上一节提到当玩家选中Checkbox表明坐下时,触发CheckedChange事件,这一节对该事件进行修改,初始化棋盘FormPlaying并打开
void checkBox_CheckedChanged(object sender, EventArgs e) { CheckBox checkbox = (CheckBox)sender; if (checkbox.Checked) { int i = int.Parse(checkbox.Name.Substring(5, 4)); int j = int.Parse(checkbox.Name.Substring(9, 4)); side = j; service.SendToServer(string.Format("SitDown,{0},{1}", i, j)); //初始化棋盘 formPlaying = new FormPlaying(i, j, sw); formPlaying.Show(); } } |
调用GDI画棋盘及棋子信息(初始化及任一方落子都调用该事件)
private void pictureBox1_Paint(object sender, PaintEventArgs e) { Graphics g = e.Graphics; for (int i = 0; i <= grid.GetUpperBound(0); i++) { for (int j = 0; j <= grid.GetUpperBound(1); j++) { if (grid[i, j] != DotColor.None) { if (grid[i, j] == DotColor.Black) { //i代表行j代表列 y轴应为列 x轴应为行 g.DrawImage(blackBitmap, (i + 1) * 40,(j + 1) * 40); } else { g.DrawImage(whiteBitmap, (i + 1) * 40, (j + 1) * 40); } } } } } |
当玩家落子的时候实际上是触发了PictureBox的MouseDown事件,程序判断是否可在该处下子,如果可以则调用GDI的Graphics.DrawImage将棋子“画”到棋盘上。
private void private void pictureBox1_MouseDown(object sender, MouseEventArgs e) { //i代表行j代表列 y轴应为列 x轴应为行 //棋子的图片大小为40*40,所以这里根据坐标信息得到棋子在对应的grid数组中的位置 int x = e.X / 40; int y = e.Y / 40; //黑白棋逻辑 //保证鼠标点击位置在棋盘内部 if (!(x < 1 || x > 8 || y < 1 || y > 8)) { if (grid[x - 1,y - 1] == DotColor.None) { //int color = grid[x - 1, y - 1]; //格式 SetDot,参数1,参数2,参数3,参数4 //功能:由服务器端判断此处是否允许落子, // 不允许落子的情况 // 1 在不合法的位置落子 // 2 轮到黑方落子,但白方在点击棋盘,则对白方不允许落子 //如果允许同时计算落子后双方棋子的变化情况, //参数1 桌号 //参数2 棋盘行 //参数3 棋盘列 //参数4 黑方或白方 service.SendToServer(string.Format("SetDot,{0},{1},{2},{3}", tableIndex, x - 1,y-1, side)); } } } |
此处的实现效率上有些问题,把判断能否落子的任务交给服务器端,实际上我们已经在客户端利用grid数组保存了棋子的状态,完全可以放到客户端去判断,放到服务器端会造成很多无用的通讯数据(写文章的时候代码已经完成,不打算改了)
列一下服务器端处理SetDot命令的逻辑
1 如果没有轮到该方落子,则丢弃此次命令
2 将落子的位置向八个方向进行比较(上、下、左、右、左上、左下、右上、右下)
考虑落子的同一列的上下两个方向的比较情况
(1)单列中小于目标行的情况,目标行最小为第1行(下标为0)
假设目标行为第5行(下标为4)则比较行k1依次为3,2,1,0
如果目标行为第1行(下标为0)则不需要比较小于目标行的情况
(2)单列中大于目标行的情况,目标行最大为第8行(下标为7)
假设目标行为第5行(下标为4)则比较行k2依次为5,6,7
如果目标行为第8行(下标为7)则不需要比较大于目标行的情况
3 准备八个List<string>,将八个方向需要反转的棋子分别存入代表各自方向的List<string>中,以向上方向为例
(1)从最靠近目标行的行数开始,逐个检验同列中棋子颜色,遇到相同颜色或者无子即退出,连续不同存入;如果检测位置无子,则晴空该方向List<string>,即无法改变该方向棋子颜色
(2)如果有同颜色的子,则退出,List<string>中的值即为该方向需要更改的值
(3)如果有不同颜色的子且不为第1行,则记录,否则清空列表。假设在第1列第1-4行为黑子,白方在第1列第5行打算落下白子,从向上的方向比较,当检测到第1行时,该方向列表中已有3个子,但第一行为黑子,无法反转1-4行黑子,所以清空已有列表。
4 如果八个方向List<string>均为空,则表明此处无法下子,丢弃此命令
5 通知客户端修改 落子的信息及需要反转的棋子的所有信息
此处代码较多,不列了,请看源码
本节完成了棋盘的呈现及简单的落子算法,但未考虑游戏中一方无法落子而由另一方继续落子的情况,下一节将对代码进行简单的重构以添加该功能,最终完成一个完整的网络黑白棋对弈。
上一节我们讲到了客户端发送Login命令后,服务器返回欢迎信息,完成了一个简单的数据传输。这一节我们来完成游戏大厅的基本功能,我们首先思考一下游戏大厅的基本功能:
1 提供可供对弈的游戏桌,游戏大厅可供多桌玩家同时游戏,为了考虑游戏大厅服务器的负载能力,应该设置一个人数的上限和桌数的上限。实际上前面提到的功能抽象出来就是一些数据的状态集合。
2 当玩家登入大厅,应该直观的显示当前大厅的就座情况,方便玩家选择。此处应该考虑大厅的直观显示。
3 当玩家选择某一位置就坐,游戏大厅的相应状态数据应发生更改,任何玩家都能看到大厅的就座情况的变化,方便做出选择。比如a选择坐在第一桌的黑方位置,则b应该看到该位置不可落坐,只能选择其他位置就坐。
尽量从面向对象的角度考虑,我们应当把游戏大厅,游戏桌,玩家看做对象。建议大家使用面向对象的方法去思考,个人感觉服务器客户端通信的网络程序主要涉及通信协议(就是我们前面提到的命令,参数1,参数2等等)的分析,设计不好的话到最后你会发现逻辑复杂到难以控制的程度。
下面我们分别看一下这几个对象(有删减,具体请看源代码)
//游戏大厅 public FormServer { //basilwang 2008-09-06 //new to this version myGame2 //游戏桌集合 private GameTable[] gameTable; //玩家集合 List userList = new List(); //游戏桌上限 private int maxTables; //玩家上限 private int maxUsers; //上一节列出的帮助类 private Service service; } //游戏桌 class GameTable { private const int None = -1; //无棋子 private const int Black = 0; //黑白棋子 private const int White = 1; //白色棋子 private int[,] grid = new int[15, 15]; //15*15的方格 public Player[] gamePlayer; public GameTable() { gamePlayer = new Player[2]; gamePlayer[0] = new Player(); gamePlayer[1] = new Player(); } } class Player { //是否开始 public bool started; //己方棋子个数 public int grade; //是否落座 public bool someone; public Player() { someone = false; started = false; grade = 0; } } |
当服务器程序启动的时候,需要初始化游戏大厅的数据;而当客户端登录到游戏大厅后,按照前面列出的逻辑,我们需要把游戏大厅的状态在客户端直观的显示出来。那怎么才能得到游戏大厅的状态数据呢?没错,也是利用的客户端服务器之间的通讯。
当客户端向服务器发送Login命令后,服务器处理完毕后,返回Tables命令返回状态数据
private void ReceiveData(object obj) { //省略接受客户端协议代码 //拆分接受到的协议 格式: 命令,参数1,参数2 ....... string[] splitString = receiveString.Split(','); string sendString = ""; switch (splitString[0]) { case "Login": user.userName = string.Format("[{0}--{1}]", splitString[1], client.Client.RemoteEndPoint); //向客户端发送协议 //格式 : Tables,参数1 //参数1为游戏大厅的游戏桌就座情况 sendString = "Tables," + this.GetOnlineString(); service.SendToOne(user, sendString); break; default: break; } } //返回如0100010101的字符串 奇数位表示游戏桌黑方的就座情况,偶数位相反,游戏桌按序号排列连接 private string GetOnlineString() { string str = ""; for (int i = 0; i < gameTable.Length; i++) { for (int j = 0; j < 2; j++) { str += gameTable[i].gamePlayer[j].someone == true ? "1" : "0"; } } return str; } |
客户端接受到Tables命令协议进行分析,直观显示游戏大厅的就座情况,这里为了简单,采用了动态生成若干组checkbox控件添加到Panel的方法,比较简单但能够说明问题。checkbox选中表明已有玩家就座,如果未选中表明可以在此处落座。
这部分代码就不列出来了,可以看一下原程序。
如果玩家选择在某一位置落座,将出发CheckBox的CheckedChanged事件,并向服务器发送SitDown命令
void checkBox_CheckedChanged(object sender, EventArgs e) { CheckBox checkbox = (CheckBox)sender; if (checkbox.Checked) { //动态生成的CheckBox命名规则为checkXXXXYYYY, 第5-8位为桌号,不足0补齐;第9-12位为黑方或白方,不足0补齐 int i = int.Parse(checkbox.Name.Substring(5, 4)); int j = int.Parse(checkbox.Name.Substring(9, 4)); side = j; //格式 SitDown,参数1,参数2 //参数1 桌号 //参数2 黑方或白方 service.SendToServer(string.Format("SitDown,{0},{1}", i, j)); } } |
服务器分析SitDown命令
private void ReceiveData(object obj) { //省略接受客户端协议代码 //拆分接受到的协议 格式: 命令,参数1,参数2 ....... string[] splitString = receiveString.Split(','); string sendString = ""; int tableIndex = -1; //桌号 int side = -1; //座位号 int anotherSide = -1; //对方座位号 switch (splitString[0]) { case "Login": //省略部分 break; case "SitDown": tableIndex = int.Parse(splitString[1]); side = int.Parse(splitString[2]); gameTable[tableIndex].gamePlayer[side].user = user; gameTable[tableIndex].gamePlayer[side].someone = true; service.SetListBox(string.Format( "{0}在第{1}桌第{2}座入座", user.userName, tableIndex + 1, side + 1)); //得到对家座位号 anotherSide = (side + 1) % 2; //判断对方是否有人 if (gameTable[tableIndex].gamePlayer[anotherSide].someone) { //先告诉该用户对家已经入座 //发送格式:SitDown,座位号,用户名 sendString = string.Format("SitDown,{0},{1}", anotherSide, gameTable[tableIndex].gamePlayer[anotherSide].user.userName); service.SendToOne(user, sendString); } //同时告诉两个用户该用户入座(也可能对方无人) //发送格式:SitDown,座位号,用户名 sendString = string.Format("SitDown,{0},{1}", side, user.userName); service.SendToBoth(gameTable[tableIndex], sendString); //重新将游戏室各桌情况发送给所有用户 service.SendToAll(userList, "Tables," + this.GetOnlineString()); break; default: break; } } 到这里,我们把游戏大厅的简单逻辑都处理了,下一节将介绍客户端棋盘的呈现。 <a href="http://www.basilwang.net/wp-content/uploads/othello20081011.rar">源代码下载</a> |
看过网上很多的类似系列教程(博客园包包版网络大厅的+桥牌系统),写的很深入,感觉比较复杂,初学者不宜上手。我是在学习WCF的时候,发现自己对底层的传输原理都没有搞明白,于是又回头学习网络传输的一些知识,自己写了一个简单的网络游戏黑白棋,因此也想把学习的一个过程记录下来和初学者们一块交流。我的只是小儿科,还请网友多多包涵,高手们也不要吝惜你们的砖头。
第一次写系列教程,心里没底,本来自己水平就一般,却要完成这个命题作文,难煞我了。好歹程序写的差不多了(不过还没有最终完成),这里先把完成的部分分章介绍一下,程序我在慢慢补。
本系列源代码TCP网络传输参考《C#网络应用高级编程》人民邮电出版社,马骏编,黑白棋游戏部分为本人(http://www.basilwang.net)编写。
写这个游戏只是为了我个人理解基于TCP网络游戏编程的基本思路,算法部分写的比较乱,没有优化,不过我都做了注释,方便大家阅读。
先说一下黑白棋,又叫反棋(Reversi)、奥赛罗棋(Othello),苹果棋,翻转棋。黑白棋在西方和日本很流行。但是这个游戏在中国目前还不够推广,下棋的水平还不高。黑白棋规则很简单,只要肯花点脑筋,新手也能玩得很好。因为棋盘小,下一局棋所花的时间也不多。对于黑白棋,有一种说法是:只需要几分钟学会它,却需要一生的时间去精通它(a minute to learn, a lifetime to master)。
早些年文曲星里面带的游戏就喜欢它了,我这个人脑子不开窍,很多游戏连电脑AI都打不过,好容易整了个能打败AI的游戏,还不往死里玩,嘿嘿。不过我写的这个黑白棋只是供网络对战使用,不涉及AI算法部分(我还不知道怎么做呢),斗胆发到网上,权作抛砖引玉,废话少说,进入正题。
系列的介绍打算以我学习TCP网络编程的过程为顺序,每一篇教程都能够完成功能,附上的源代码能够独立运行,我会把代码中碰到的相关知识做相应的介绍,使初学者能有直观的认识。
游戏完成的部分
1 网络大厅,可自定义桌数,人数 (完全参照《C#网络应用高级编程》,马老师应该不介意吧)
2 黑白棋游戏客户端
2.1 吃子
2.2 奇偶数统计
2.3 轮流下子
2.4 终局胜负提示
未完成部分
1 黑白棋游戏客户端
1.1 下子时间限制
1.2 甲方无子可下时,程序判定由乙方下子,甲方丢掉一次机会
1.3 判定任一方无子可下的程序(思路:需要计算盘中空子的列表,然后调用已完成的吃子程序,看能否下子,但并不真正下子)
源程序写的很简单,界面比较简陋,只是为了帮助大家更好的理解基于TCP的网络游戏传输的基本原理,还请大家多包涵。
我的机器上安装了vs2003 , vs2005 sp1 team suite , vs 2008 beta
使用NetMassDownloader下载pdb symbols,可是我的下载总是报file not on source server,后来在NetMassDownloader的主页上找到必须使用Net Framework 3.5 RTM 才能保证正确的下载,换句话说,不管你有没有安装vs 2008,只要有Net Framework 3.5 RTM而不是beta,就可以下载。
可是当我下载了源代码,却发现总是无法调试。后来偶然发现VS2005的输出窗口显示“不包含源服务器信息”,我仔细比对了网上提供的三个step,检查了VS编译器的设置,发现了问题。我的设置Debugging->General->Enable Just My Code(Managed Only)为选中,而网上的图例未选中。修改了这个设置后,发现可以正确的调试源代码了。
不过我认为应该把这个细节强调一下,毕竟不是每个人都熟悉vs05的,我工作中一直用vs03,vs03中没有这个选项。

