最近在做课设,题目是关于socket编程的一对一网络小游戏。期间遇到各种问题,也从中学到了很多。在此记录下课设中遇到的问题。

题目要求:

设计4 网络版小游戏


1 设计目的

1)熟悉开发工具(Visual Studio、C/C++、Java等)的基本操作;

2)掌握windows/Linux应用程序的编写过程;

3)对于Socket编程建立初步的概念。

2 设计要求

1)熟悉Socket API主要函数的使用;

2)掌握相应开发工具对Socket API的封装;

3)设计并实现一对一网络版小游戏,如:Tic-Tac-Toe、五子棋等。(注:不同的游戏对应不同的设计题目

3 设计内容

1)服务器端设计

2)客户端设计

目前实现的功能

服务端:管理在线玩家列表、对玩家发消息、强制玩家下线、日志的记录等。

客户端:下棋、悔棋、聊天、发送语音以及图片(本质上是发送文件)等等。

后期可以加入的功能:自定义挑战对手、自定义头像、人机对战、发送视频(原理同语音消息),画框来提示上一步落子的位置

有待改进:发送大数据包导致的掉线和内存占用率高问题、刷新玩家列表导致玩家掉线的问题,接收数据时延迟较大的问题

在实现中涉及到的技术和方法:socket通信、序列化反序列化、GDI绘图、多线程、委托、CefSharp nuget包的导入和使用,还有一些html标签的使用

运行截图

五子棋demo网上有很多,也没什么好说的。这个课设主要是熟悉socket编程,我的重点就放在通信上面。

首先,大的方向是采用TCP协议。(由于TCP是面向连接的,这和无连接的广播是矛盾的,所以服务端运行图中的广播这一功能是伪广播,在实现时只是用循环代替了单个发送,并不是真正的广播)。

然后,在如何封装数据包的问题上,从网上查找资料,大概有这三种(这些方法大同小异,最终都要转成字节数组进行发送):

  1. TCP协议是面向字节流的,发送和接收的数据都是字节数组。所以我们可以用最原始的byte数组来设计传输数据的格式,例如用byte[0]=0表示落子,用byte[0]=1表示文字,byte[0]=2表示图片...这么做节省了数据量,但是给编程带来困难,可读性差。一般使用c/c++语言编程时会采用这种偏向底层的做法,但我们这是C#,是高级语言,自然要使用一些比较“高级”的方法,来简化编程。
  2. 用xml序列化和反序列化,发送时序列化成字符串,接收时反序列化为对象。这种方法可读性好一点,但是XML标签有开就要有闭,而且有些我们在某次通信时不太关心的信息也会出现在网络传输中,这样造成数据量的增加。而且序列化反序列化的过程也会带来大量的计算开销
  3. 这是第二种方法的改进版,采用键值对集合,只把需要的信息封装到集合中,接收端只取出需要的数据,不关心的数据无需进行封装。

此外还有结构体、特殊符号分隔法、json序列化、二进制序列化......就不一一列举了。本次课设我采用了第二种方法。

  • 消息类

封装一个实体类,这和Javabean的作用是类似的。其实设计的还不是很合理,有些字段可以复用以减少定义节点的数量:

    [Serializable][XmlInclude(typeof(List<Point>))]public class Message{public const int MAX_LINE_COUNT = 15;public const string ID_STATUS_PUT = "落子";public const string ID_STATUS_PP = "匹配玩家";public const string ID_STATUS_OVER = "游戏结束";public const string ID_STATUS_MSG = "聊天";public const string ID_STATUS_IMG = "图片";public const string ID_STATUS_SOUND = "声音";public const string ID_STATUS_INIT = "初始化";public const string ID_STATUS_BACK = "悔棋";public const string ID_STATUS_MSGREFUSED = "消息被拒收";public const string ID_STATUS_START = "重新开始";public const string ID_STATUS_REQUEST = "请求重新开始";public const string ID_STATUS_UPDATEBOARD = "更新棋局";public const string ID_STATUS_OFFLINE = "掉线";public const string COLOR_BLACK = "黑棋";public const string COLOR_WHITE = "白棋";public const string COLOR_NONE = "无色";//要执行的动作[XmlElement(Order = 1)]public string Action { get; set; }//接收者[XmlElement(Order = 2)]public string Receiver { get; set; }//发送者[XmlElement(Order = 3)]public string Sender { get; set; }//消息内容[XmlElement(Order = 4)]public string ExtraMsg { get; set; }//轮到谁落子[XmlElement(Order = 5)]public string WhoseTurn { get; set; }//最后一次落子的横坐标[XmlElement(Order = 6)]public int X { get; set; }//最后一次落子的纵坐标[XmlElement(Order = 7)]public int Y { get; set; }//游戏是否结束[XmlElement(Order = 8)]public bool IsGameOver { get; set; }//是否要更新棋盘[XmlElement(Order = 9)]public bool IsUpdateBoard { get; set; }//获胜者[XmlElement(Order = 10)]public string Winner { get; set; }//本方颜色[XmlElement(Order = 11)]public string Color { get; set; }//白子列表[XmlElement(Order = 12)]public List<Point> WPieces { get; set; }//黑子列表[XmlElement(Order = 13)]public List<Point> BPieces { get; set; }//发送者的昵称[XmlElement(Order = 14)]public string Name { get; set; }//是否是系统消息[XmlElement(Order = 15)]public bool IsSysMsg { get; set; }//文件名[XmlElement(Order = 16)]public string FileName { get; set; }//是否同意重新开始[XmlElement(Order = 17)]public bool IsAgree { get; set; }public Message(){}}
  • 对消息进行序列化和反序列化的工具类

序列化说白了就是把一个对象转成特定格式的字符串(二进制序列化是直接转成字节序列),这个字符串里包含了对象的属性(或者状态)信息,然后你可以拿着这个字符串进行IO操作,比如存储成一个文件,或者通过网络发给另一台计算机。

反序列化是个相反的过程,把字符串转成对象,然后你可以使用面向对象的编程方法对转换后的对象进行操作。

   class XmlUtils{/// <summary>/// 反序列化/// </summary>/// <typeparam name="T"></typeparam>/// <param name="xml"></param>/// <returns></returns>public static T DeserializeObject<T>(string xml){XmlSerializer xs = new XmlSerializer(typeof(T));StringReader sr = new StringReader(xml);T obj = (T)xs.Deserialize(sr);sr.Close();sr.Dispose();return obj;}/// <summary>/// 序列化/// </summary>/// <typeparam name="T"></typeparam>/// <param name="t"></param>/// <returns></returns>public static string XmlSerializer<T>(T t){XmlSerializerNamespaces xsn = new XmlSerializerNamespaces();xsn.Add(string.Empty, string.Empty);XmlSerializer xs = new XmlSerializer(typeof(T));StringWriter sw = new StringWriter();xs.Serialize(sw, t, xsn);string str = sw.ToString();sw.Close();sw.Dispose();return str;}}
  • 棋盘线的绘制

        private void DrawLines(PaintEventArgs e){mPanelWidth = Math.Min(Width, Height);mLineLength = mPanelWidth * 1.0f / ChessPanel.MAX_LINE_COUNT;Graphics g = e.Graphics;Pen pen = new Pen(Color.Black, 4);//画棋盘线if (Width > Height){for (int i = 0; i < ChessPanel.MAX_LINE_COUNT; i++){int startX = (int)(mLineLength / 2);int endX = (int)(mPanelWidth - mLineLength / 2);int y = (int)((0.5 + i) * mLineLength);//横线g.DrawLine(pen, startX + mOffset, y, endX + mOffset, y);//竖线g.DrawLine(pen, y + mOffset, startX, y + mOffset, endX);}}else{for (int i = 0; i < ChessPanel.MAX_LINE_COUNT; i++){int startX = (int)(mLineLength / 2);int endX = (int)(mPanelWidth - mLineLength / 2);int y = (int)((0.5 + i) * mLineLength);//横线g.DrawLine(pen, startX, y + mOffset, endX, y + mOffset);//竖线g.DrawLine(pen, y, startX + mOffset, y, endX + mOffset);}}}
  • 棋子的绘制

要支持棋子大小随棋盘控件大小的改变而改变,还要把棋子画在交叉点上,涉及到屏幕坐标和棋盘坐标的转换,这个说起来稍微复杂,画个图弄清楚大小关系就好了,具体请看网上的例子或本文后面的参考链接。

棋子坐标的存储是使用两个List集合分开存储,List集合里放的是Point点的坐标。有的人会采用二维数组代表整个棋盘,数值为奇数偶数代表白子或黑子,总之各有利弊吧。二维数组比较简单直观,但也失去了一些信息,比如不能记录各个点的落下的顺序,在悔棋时比较麻烦。在高级语言当中,我一般常用集合,较少用数组。虽然List集合底层也是用数组实现的,但多一层封装会带来很大的方便。

随着落子次数的增加,传输的数据包越来越大,因为集合中点的个数在增加。一种优化的方法是把棋盘的点的数据放在服务端保存,客户端只传送最后一次落子的坐标,服务端对集合中的点进行增加和删除。

        /// <summary>/// 画棋子/// </summary>public void DrawPieces(){//如果为null,直接返回(实际上调试时看到反序列化不会为null,但为了严谨一点还是把这句判断加上去)if (mBPieces == null || mWPieces == null){return;}Graphics g = this.CreateGraphics();//画黑棋foreach (var item in mBPieces){//转换成棋子在控件里的位置坐标int x = (int)((item.X + ratioPieceOfLineHeight / 4) * mLineLength) + mOffset;int y = (int)((item.Y + ratioPieceOfLineHeight / 4) * mLineLength);int width = (int)(mLineLength * ratioPieceOfLineHeight);Rectangle rect = new Rectangle(x, y, width, width);g.DrawImage(mBlackPiece, rect);}//画白棋foreach (var item in mWPieces){//转换成棋子在控件里的位置坐标int x = (int)((item.X + ratioPieceOfLineHeight / 4) * mLineLength) + mOffset;int y = (int)((item.Y + ratioPieceOfLineHeight / 4) * mLineLength);int width = (int)(mLineLength * ratioPieceOfLineHeight);Rectangle rect = new Rectangle(x, y, width, width);g.DrawImage(mWhitePiece, rect);}}
  • 点击棋盘绘制棋子

上面棋子的绘制是对方点击后,我收到服务器发来的消息,然后更新我自己的界面,这个过程中所有的棋子都要重新绘制。因为不知道对方是落子还是悔棋,如果是悔棋,还要进行局部擦除,比较麻烦,干脆直接把所有棋子重新画一遍。

而下面这部分代码是当自己点击自己的棋盘后,在自己的棋盘上画一枚棋子,属于局部绘制。当然如果想省事儿,自己点击后直接全局重绘也可以,就只需要判断位置合法后调用Update()之类的方法强制使棋盘重绘就行了。

        public void ChessPanel_MouseClick(object sender, MouseEventArgs e){//isGameOver=true,游戏结束,不响应鼠标点击事件,直接返回//mWhoseTurn==Message.COLOR_NONE,没有指定轮到谁,那么谁都不响应//mWPieces=null当前还没有组队//mColor.Equals(mWhoseTurn)==false没有轮到自己if (isGameOver || mWhoseTurn == Message.COLOR_NONE || !mColor.Equals(mWhoseTurn) || mWPieces == null || mBPieces == null){if (FormClient.mIsSoundOn){errorPlayer.Play();}return;}Point point = new Point();// 将点击的坐标转换成棋盘交叉点的坐标point.X = (int)((e.X-mOffset) / mLineLength);point.Y = (int)(e.Y / mLineLength);//如果点击的格子里已经有棋子了,就返回if (mWPieces.Contains(point) || mBPieces.Contains(point)){if (FormClient.mIsSoundOn){errorPlayer.Play();}return;}//判断是否点到外面去了if (point.X<0||point.Y<0||point.X>= ChessPanel.MAX_LINE_COUNT || point.Y>= ChessPanel.MAX_LINE_COUNT){if (FormClient.mIsSoundOn){errorPlayer.Play();}return;}//转换成棋子在控件里的位置坐标int x, y;if (Width>Height){x = (int)((point.X + ratioPieceOfLineHeight / 4) * mLineLength) + mOffset;y = (int)((point.Y + ratioPieceOfLineHeight / 4) * mLineLength);}else{x = (int)((point.X + ratioPieceOfLineHeight / 4) * mLineLength);y = (int)((point.Y + ratioPieceOfLineHeight / 4) * mLineLength) - mOffset;}int width = (int)(mLineLength * ratioPieceOfLineHeight);//创建控件的GDI+,准备绘制棋子Graphics g = CreateGraphics();//待绘制的棋子的位置Rectangle rect = new Rectangle(x, y, width, width);//判断本方是黑棋还是白棋if (mColor.Equals(Message.COLOR_BLACK)){g.DrawImage(mBlackPiece, rect);mBPieces.Add(point);}else if (mColor.Equals(Message.COLOR_WHITE)){g.DrawImage(mWhitePiece, rect);mWPieces.Add(point);}//播放声音if (FormClient.mIsSoundOn){downPlayer.Play();}//本方点击,向服务器发送落子消息Message m = new Message();m.Action = Message.ID_STATUS_PUT;m.WhoseTurn = mWhoseTurn;m.Receiver = FormClient.yourUUID;m.Sender = FormClient.myUUID;m.X = point.X;m.Y = point.Y;m.BPieces = mBPieces;m.WPieces = mWPieces;m.Color = mColor;try{mClientSocket.Send(Encoding.UTF8.GetBytes(XmlUtils.XmlSerializer<Message>(m)));//更新界面FormClient.delHelp("等待对方落子");FormClient.delStep(1);}catch (Exception ex){MessageBox.Show(ex.Message);}//禁止本方点击mWhoseTurn = Message.COLOR_NONE;}
  • 服务端监听

private void btnListen_Click(object sender, EventArgs e){try{//当点击开始监听的时候 在服务器端创建一个负责监听IP地址和端口号的SocketserverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);//获取ip地址IPAddress ip = IPAddress.Parse(listBox_IP.SelectedItem.ToString());//创建端口号IPEndPoint point = new IPEndPoint(ip, Convert.ToInt32(numericUpDown_Port.Value));//绑定IP地址和端口号serverSocket.Bind(point);//开始监听:设置最大可以同时连接多少个请求serverSocket.Listen(10);//禁用按钮btnListen.Enabled = false;//接收和处理棋盘事件OnReceiveMsg += new ChessEventReceiveHander(ManageChessEvent);//负责监听客户端的线程:创建一个监听线程  Thread threadwatch = new Thread(WaitConnect);//将窗体线程设置为与后台同步,随着主线程结束而结束  threadwatch.IsBackground = true;//启动线程     threadwatch.Start();}catch (Exception ex){MessageBox.Show(ex.Message);}}
  • 服务器端接受连接

        //监听客户端发来的请求  private void WaitConnect(){Socket connection = null;//持续不断监听客户端发来的连接请求     while (true){try{connection = serverSocket.Accept();connection.NoDelay = true;}catch (Exception ex){//提示套接字监听异常     Console.WriteLine(ex.Message);break;}//获取客户端的IP和端口号  IPAddress clientIP = (connection.RemoteEndPoint as IPEndPoint).Address;int clientPort = (connection.RemoteEndPoint as IPEndPoint).Port;//客户端网络结点号  string remoteEndPoint = connection.RemoteEndPoint.ToString();//创建一个通信线程      ParameterizedThreadStart pts = new ParameterizedThreadStart(Receive);Thread thread = new Thread(pts);//设置为后台线程,随着主线程退出而退出 thread.IsBackground = true;//启动线程     thread.Start(connection);}}
  • 服务端接收数据

        /// <summary>/// 接收客户端发来的信息/// </summary>/// <param name="socketclientpara">客户端套接字对象</param>    private void Receive(object socketclientpara){Socket socketServer = socketclientpara as Socket;while (true){//创建一个内存缓冲区,其大小为1024字节  即1KBbyte[] buffer = new byte[1024];    try{int len;using (MemoryStream ms = new MemoryStream()){do{//Receive方法是阻塞式接收数据//流中没有数据时会阻塞//将接收到的信息存入到内存缓冲区,并返回其字节数组的长度len = socketServer.Receive(buffer, 1024, SocketFlags.None);ms.Write(buffer, 0, len);//可以利用Available属性进行循环读取} while (socketServer.Available > 0);buffer = ms.GetBuffer();}//将套接字获取到的字符数组转换为人可以看懂的字符串  string xml = Encoding.UTF8.GetString(buffer, 0, buffer.Length);Message mes = XmlUtils.DeserializeObject<Message>(xml);OnReceiveMsg(this, mes);//事件发生}catch (Exception ex){//如果发生异常,说明客户端已经关闭了连接或者反序列化出错//关闭之前accept出来的和客户端进行通信的套接字 socketServer.Close();break;}}}
  • 客户端建立连接

        /// <summary>/// 连接服务器/// </summary>/// <param name="sender"></param>/// <param name="e"></param>private void btnConnect_Click(object sender, EventArgs e){//定义一个套接字  socketclient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);//获取文本框中的IP地址  IPAddress address = IPAddress.Parse(txtIP.Text.Trim());//将获取的IP地址和端口号绑定在网络节点上  IPEndPoint point = new IPEndPoint(address, Convert.ToInt32(numericUpDown_port.Value));try{//客户端套接字连接到网络节点上,用的是Connect  socketclient.Connect(point);socketclient.NoDelay = true;}catch (Exception ex){Console.WriteLine("连接失败");MessageBox.Show(ex.Message,"连接失败",MessageBoxButtons.OK,MessageBoxIcon.Error);return;}//进行一系列初始化和设置//this.btnConnect.Enabled = false;//......OnReceiveMsg+= new ChessEventReceiveHander(ManageChessEvent);threadclient = new Thread(Receive);threadclient.IsBackground = true;threadclient.Start();}
  • 客户端接收数据

验收前一小时,经过在两台电脑间进行发送测试,发现的比较严重的问题:

发送大数据包(图片和声音)会导致掉线,在服务端刷新后也会导致客户端掉线。前者的可能原因是:xml成功反序列化的前提条件是需要完整接收数据,当数据包还没全部到达接收端时就开始反序列化,这样肯定会出错,就会导致掉线(也可能是Server端反序列化的问题)。加入Thread.Sleep(50)只是粗略地解决了在单机上的问题,但是没能解决多机通信下的问题。而且这种粗略解决办法的缺点很明显:通信效率降低了。看来还是异步接收比较好,由于对异步编程不太了解,我在这里就直接采用同步阻塞方式接收数据了。

这里还有个注意事项,那就是tcp的粘包问题。我这里举个不准确的例子解释一下。tcp流可以类比为水流,流与流之间是没有边界的,当短时间内连续向管道中注入流的时候,发送端发送太快或者接收端没有及时取走数据,多条消息就会连在一起,在接收的时候就分不清哪个是哪个。所以要进行必要的分包处理,或者在发送时加一些标记作为边界。这里我是在接收端进行字符串的分割操作,每遇到一个xml文档声明,就视为一条消息。在发送端也要进行一些简单的设置,关闭nagle算法,防止把小的数据包合并发送。

如果采用UDP协议,它的协议数据单元是数据报,跟字节流不同,就不会存在粘包问题。这从UDP数据报的首部也可以看出来,首部中有个数据长度字段,根据这个字段,底层自动将一定长度的数据视为一个数据报。

UDP首部:

TCP首部:

        /// <summary>/// 接收服务端发来信息的方法/// </summary>private void Receive(){//持续监听服务端发来的消息 while (true){try{//定义一BUFFER_SIZE大小的内存缓冲区,用于临时性存储接收到的消息//然后循环读取,将读取到的字节数组写入内存流,最后再赋给字节数组byte[] buffer = new byte[BUFFER_SIZE];int len;using (MemoryStream ms = new MemoryStream()){do{//Receive方法是阻塞式接收数据//流中没有数据时会阻塞在这里len = socketclient.Receive(buffer, BUFFER_SIZE, SocketFlags.None);ms.Write(buffer, 0, len);//可以利用Available属性进行循环读取if (socketclient.Available <= 0){Thread.Sleep(50);//等待数据全部到达}} while (socketclient.Available > 0);buffer = ms.GetBuffer();}// 读取的字节数为0说明socket断开,字节数=0不等价于流中没有数据if (buffer.Length == 0){MessageBox.Show("您已经掉线,请重新连接!", "服务器连接失败", MessageBoxButtons.OK, MessageBoxIcon.Warning);return;}try{//将套接字获取到的字符数组转换为人可以看懂的字符串  string xml = Encoding.UTF8.GetString(buffer, 0, buffer.Length);//当数据包很小或发送间隔很短//可能得到多条消息,造成xml中有多个根节点,反序列化的时候出错//我们在这里判断,如果是多条消息合并在一起,要进行分离//......//反序列化为消息对象Message mes = XmlUtils.DeserializeObject<Message>(xml);OnReceiveMsg(this, mes);//处理消息}catch (Exception exx){MessageBox.Show(exx.Message);break;}}catch (Exception ex){//throw;Console.WriteLine("远程服务器已经中断连接" + ex.Message);break;}}}
  • 管理在线玩家

服务器接收到连接请求后,产生一个GUID作为用户识别码,并把它作为键,客户端socket对象作为值,加入到Dictionary集合中,更新到界面上。

这里有个疑问,既然Socket能够唯一标识主机上的应用进程,那为什么不直接采用(IP地址+端口号)作为识别码,而要多此一举使用GUID呢?这个问题的一种回答见https://blog.csdn.net/he_zhidan/article/details/51488945。另一个原因是出于安全考虑,如果把客户端地址直接暴露出来,可能会造成隐私泄露。对于课设来说,由于功能简单,socket标识已经足够了,但我还是习惯用GUID,毕竟GUID就是专门用在数据唯一、不能重复的地方上。

  • 刷新玩家

这个功能有缺陷,可能导致客户端掉线

        /// <summary>/// 判断客户端socket是否在线(处于连接状态)/// </summary>/// <param name="s"></param>/// <returns></returns>private bool IsAlive(Socket s){try{byte[] buf = new byte[1024];s.ReceiveTimeout = 1000;if (s.Poll(1000, SelectMode.SelectRead)){int nRead = s.Receive(buf);if (nRead == 0){return false;}}}catch (Exception){return false;}return true;}
  • 强制玩家下线

        /// <summary>/// 安全关闭客户端socket/// </summary>/// <param name="socket">The socket.</param>public void SafeClose(Socket socket){if (socket == null)return;if (!socket.Connected)return;try{socket.Shutdown(SocketShutdown.Both);}catch{}try{socket.Close();}catch{}}
  • 文字聊天

聊天就是把要发送的文本赋给对象的一个属性,把该对象序列化为xml字符串,再转为字节数组,调用客户端socket的send()方法进行发送。

                //创建一个消息对象,把要发送的内容封装到对象里Message m = new Message();m.Action = Message.ID_STATUS_MSG;m.Sender = myUUID;m.ExtraMsg = richTextMsg.Text.Replace("\n", "<br/>");m.Receiver = yourUUID;//将对象序列化成字符串,并转换为机器可以识别的字节数组 byte[] sendMsg = Encoding.UTF8.GetBytes(XmlUtils.XmlSerializer<Message>(m));//调用客户端套接字发送字节数组     socketclient.Send(sendMsg);//将发送的信息追加到聊天内容文本框中chartPanel.AppendMsg(m.ExtraMsg, txtName.Text);
  • 图片和声音消息

由于做的比较简单,没有考虑到缓冲区相关问题。在发送 几十兆的wav声音文件时,通过任务管理器看到内存占用直线上升到三四百兆,并且迟迟不降低,只能用第三方清理软件来整理内存。这里可能涉及到垃圾回收机制。改进的办法是把文件切割成若干段,每段单独封装在一个数据包中,接收端对各段进行组装重新形成文件。由于传输文件不是本次课设的重点,在此就不深入研究了。建议不要发送大数据包。

发送端:先用Convert类把图片文件转为base64字符串,然后把它当做普通文本进行下一步处理。

接收端:从消息中取出base64字符串,重新编码为图片

            using (OpenFileDialog ofd=new OpenFileDialog()){ofd.Filter = "图片|*.jpg;*.png;*.bmp;*.gif";ofd.InitialDirectory = Environment.CurrentDirectory;ofd.ShowDialog();if (ofd.FileName!=""){using (FileStream stream = new FileStream(ofd.FileName, FileMode.Open)){Message msg = new Message();msg.Action = Message.ID_STATUS_IMG;msg.Sender = myUUID;msg.Receiver = yourUUID;msg.FileName = ofd.SafeFileName;byte[] array = new byte[stream.Length];stream.Read(array, 0, array.Length);msg.ExtraMsg = Convert.ToBase64String(array);socketclient.Send(Encoding.UTF8.GetBytes(XmlUtils.XmlSerializer<Message>(msg)));}}}
  • 消息的展示

在客户端进行显示时,最开始用的是RichTextBox,因为遇到一些没解决的问题而放弃。例如插入图片时调用剪切板可能会抛异常,而用其他办法插入图片过于复杂,好像要直接去操作RTF文件,要查rtf的格式规范,并且RichTextBox显示效果也很差。后来搜到winform仿QQ聊天界面,试了一下效果还行。

这种办法是把显示的内容封装成html利用webbrowser显示,加上样式表的控制,所以功能很强大。具体做法:

文字----直接扔进p标签里

图片和声音-----把重新编码后的图片存到硬盘后,构造HTML文件,在img标签和audio标签的src属性指出文件的路径

这种办法也有缺点,不同Windows OS下显示效果可能不同,很多特性IE不支持(比如html5)。可以采用第三方浏览器(chromeBrowser、CefSharp等)解决。我用CefSharp试了一下,效果还可以,但是编译后需要一堆的动态链接库才能运行,而且项目大小将近200MB,再加上400多MB的packages目录,总共600多兆,而这只是一个简单的五子棋游戏,所以嘛,只好放弃,改用自带的webbrowser控件

下面分别是webbrowser和CefSharp的显示效果:

  • 图片和声音如何构造为html?

比如我要显示一张图片,应该用img标签,它的src属性指出图片的路径

当A向B发送图片,A先通过socket把图片传给服务端,在服务端又有一台http服务器,服务端把消息处理成图片的http地址然后发给B,如下面这种形式。只需要把图片超链接构造出来,而无需通过socket传送图片文件,但这样做会比较复杂,需要两个服务器端口。

<img src="http://www.example.com/1.jpg">

考虑在客户端进行处理。对于img标签,可以指定本地路径。我们把收到的图片存入一个目录下面,然后在src属性下用file协议指明它在硬盘上的路径,这样就免去了搭建http服务器的麻烦

<img src="file:///D:/1.jpg">

img还算简单,声音有点麻烦。audio标签在有些浏览器上不支持file协议的本地路径,我这里用embed标签

embed测试支持file协议路径,但也会有问题,那就是有些浏览器不支持关闭自动播放,造成页面一刷新就自动播放,体验总归是不好,而且当一个页面上有多个embed音频时就乱套了。

<embed  src='file:///D:/1.mp3' play='false' autostart='false' type='audio/mpeg' />
  • 构造完html文件后,web浏览器如何显示?

第一种,我们可以把HTML字符串赋给webBrowser1.DocumentText,这样就能加载出来了

第二种,把HTML字符串存到本地硬盘,保存为HTML网页类型,使用下面的办法载入本地文件(不加"file:///"也可以)

webBrowser1.Navigate("file:///" + filePath);//等效于下面的语句
webBrowser1.Url = new Uri("file:///" + filePath);

第一种办法,不需要频繁写入文件,直接在内存中加载字符串,速度快

第二种办法,消息较多时,读写文件频繁,速度慢。好处是支持src的相对路径,这是我选择第二种办法的原因。

对于支持audio标签的浏览器,最好不要用embed,改用audio,这时我们把html文件和mp3文件放在同一个目录下,就可以用相对路径设置audio 的本地文件src了,也就无需指定src的http路径

下面是聊天面板控件的部分代码,主要就是一堆css,还有要注意的是,新追加的消息通过锚点定位到末尾:

public partial class ChartPanel : UserControl{string path = string.Empty;string html = string.Empty;public static string myHead = "http://pics.sc.chinaz.com/Files/pic/icons128/7066/b5.png";public static string yourHead = "http://pics.sc.chinaz.com/Files/pic/icons128/7066/b5.png";public ChartPanel(){InitializeComponent();//自定义控件Load事件里的代码有时候不执行,故在构造函数中调用webKitBrowser_Load(null, null);}private void webKitBrowser_Load(object sender, EventArgs e){html = @"<!DOCTYPE html> <html><head>
<meta http-equiv=""Content-Type"" content=""text/html; charset=utf-8"" />
<script type=""text/javascript"">window.location.hash = ""#ok"";</script>
<style type=""text/css"">
/*滚动条宽度*/
::-webkit-scrollbar {  width: 8px;
}  /* 轨道样式 */
::-webkit-scrollbar-track {  }  /* Handle样式 */
::-webkit-scrollbar-thumb {  border-radius: 10px;  background: rgba(0,0,0,0.2);
}  /*当前窗口未激活的情况下*/
::-webkit-scrollbar-thumb:window-inactive {  background: rgba(0,0,0,0.1);
}  /*hover到滚动条上*/
::-webkit-scrollbar-thumb:vertical:hover{  background-color: rgba(0,0,0,0.3);
}
/*滚动条按下*/
::-webkit-scrollbar-thumb:vertical:active{  background-color: rgba(0,0,0,0.7);
}  textarea{width: 500px;height: 300px;border: none;padding: 5px;}  .chat_content_group.self {
text-align: right;
}.chat_content_group.sys {
text-align: center;
}
.chat_content_group {
padding: 10px;
}
.chat_content_group.self>.chat_content {
text-align: left;
}
.chat_content_group.sys>.chat_content {
box-shadow:1px 1px 5px #000;
}
.chat_content_group.self>.chat_content {
background: #7ccb6b;
color:#fff;
/*background: -webkit-gradient(linear,left top,left bottom,from(white,#e1e1e1));
background: -webkit-linear-gradient(white,#e1e1e1);
background: -moz-linear-gradient(white,#e1e1e1);
background: -ms-linear-gradient(white,#e1e1e1);
background: -o-linear-gradient(white,#e1e1e1);
background: linear-gradient(#fff,#e1e1e1);*/
}
.chat_content {
display: inline-block;
min-height: 16px;
max-width: 50%;
color:#000;
background: #f0f4f6;
/*background: -webkit-gradient(linear,left top,left bottom,from(#cf9),to(#9c3));
background: -webkit-linear-gradient(#cf9,#9c3);
background: -moz-linear-gradient(#cf9,#9c3);
background: -ms-linear-gradient(#cf9,#9c3);
background: -o-linear-gradient(#cf9,#9c3);
background: linear-gradient(#cf9,#9c3);*/
-webkit-border-radius: 5px;
-moz-border-radius: 5px;
border-radius: 5px;
padding: 10px 15px;
word-break: break-all;
/*box-shadow: 1px 1px 5px #000;*/
line-height: 1.4;
}
body{font-family:""微软雅黑""}
.chat_content_group.self>.chat_nick {
text-align: right;
}
.chat_nick {
font-size: 14px;
margin: 0 0 10px;
color:#000;
}.chat_content_group.self>.chat_content_avatar {
float: right;
margin: 0 0 0 10px;
}.chat_content_group.buddy {
text-align: left;
}
.chat_content_group {
padding: 10px;
}
.chat_content_avatar {
float: left;
width: 40px;
height: 40px;
margin-right: 10px;
}
.imgtest{margin:10px 5px;
overflow:hidden;}
.list_ul figcaption p{
font-size:12px;
color:#aaa;
}
.imgtest figure div{
display:inline-block;
margin:5px auto;
width:100px;
height:100px;
border-radius:100px;
border:2px solid #fff;
overflow:hidden;
-webkit-box-shadow:0 0 3px #ccc;
box-shadow:0 0 3px #ccc;
}
.imgtest img{width:100%;
min-height:100%; text-align:center;}
</style></head><body>
";path = Application.StartupPath + "\\MsgReceived\\" + Guid.NewGuid().ToString("N") + ".html";if (File.Exists(path) == false){using (FileStream fs = new FileStream(path, FileMode.OpenOrCreate)){byte[] buffer = Encoding.UTF8.GetBytes(html);fs.Write(buffer, 0, buffer.Length);}}webBrowser1.Url = new Uri("file://" + path);//chromeBrowser.Load("file://" + path);//CefSharp.WebBrowserExtensions.LoadString(chromeBrowser, html, "http://www.example.com/");}public void AppendMsg(string msg, string name, bool isMyWords = true){string str = string.Empty;if (isMyWords){str = @"<div class=""chat_content_group self""><img class=""chat_content_avatar"" src=" + myHead + @" width=""40px"" height=""40px""><p class=""chat_nick"">" + name + @"</p><p class=""chat_content"">" + msg + @"</p></div>
<a id='ok'></a>
";}else{str = @"<div class=""chat_content_group buddy""><img class=""chat_content_avatar"" src=" + yourHead + @" width=""40px"" height=""40px""><p class=""chat_nick"">" + name + @"</p><p class=""chat_content"">" + msg + @"</p></div>
<a id='ok'></a>
";}html = html.Replace("<a id='ok'></a>", "") + str;using (FileStream fs = new FileStream(path, FileMode.Create)){byte[] buffer = Encoding.UTF8.GetBytes(html);fs.Write(buffer, 0, buffer.Length);}webBrowser1.Navigate("file://" + path);}public void AppendSysMsg(string msg, string name){string str = @"<div class=""chat_content_group sys""><p class=""chat_nick"">" + name + @"</p><p class=""chat_content"">" + msg + @"</p></div>
<a id='ok'></a>
";html = html.Replace("<a id='ok'></a>", "") + str;using (FileStream fs = new FileStream(path, FileMode.Create)){byte[] buffer = Encoding.UTF8.GetBytes(html);fs.Write(buffer, 0, buffer.Length);}webBrowser1.Navigate("file://" + path);}public void Clear(){//File.Delete(path);webKitBrowser_Load(null, new EventArgs());}
}

最后一个小的问题,在做gif动图显示时,我还专门上网查有没有能显示动图的控件,确实有些人也在问这个问题。后来发现,pictureBox本身就支持动图显示啊。

不能动的原因是我把图片放入了imageList控件里,imageList会把原始图片转换,造成gif帧丢失,当pictureBox读取的图片来自imageList时,自然就不会动了。正确的做法是直接从文件中读取或从流中读取 。

pictureBox1.Image = Image.FromFile(path);

注:上述代码会占用图片文件,造成文件无法删除,应该在不用的时候调用下面语句释放资源

pictureBox1.Image.Dispose();

说实话,代码写得比较凌乱,主要问题就是大量使用静态全局变量,数据处理和界面的更新混杂在一起,因为没有进行过相关的编程思想的训练,还是停留在初学编程时面向过程的思想上,想到什么就写什么。

下面附上源码,需要的可以参考一下。

源代码(用VS2015打开,如果控件显示不全需要在Windows系统设置里自定义缩放为125%):https://download.csdn.net/download/qq_40582463/10722068

可执行程序:

https://pan.baidu.com/s/1ftjMI0AUXDdL1bfArP2s3A

参考:

Java swing + socket 写的一个五子棋网络对战游戏:https://blog.csdn.net/qq_20698983/article/details/80296165

安卓五子连珠(棋盘和棋子的绘制问题):https://www.imooc.com/learn/641

winform实现QQ聊天气泡200行代码(聊天消息的展示问题):https://www.cnblogs.com/tuzhiyuan/p/4518076.html

Socket Receive 避免 Blocking(socket同步方式循环接收数据问题):https://www.cnblogs.com/a_bu/p/5630158.html

C# Winform基于socket编程的五子棋游戏(带聊天和发送文件功能)相关推荐

  1. 【网络聊天室】——基于socket编程的TCP/UDP网络聊天服务器

    早期网络刚刚普及的时候,给人们印象最深的就是上网聊天,虽然作为一名上世纪的尾巴刚刚出生的我没有经历过,但仍从有所耳闻,那个时期是网络聊天室风靡的年代,全国知名聊天室大家都争破头的想要进去,基于如上和一 ...

  2. 基于socket的联机五子棋

    基于socket的联机五子棋 一.团队介绍 团队名称: 团队成员 职务 负责部分 个人链接 林仕峰 组长 网络编程和多线程 (114条消息) 五子棋个人报告_林仕峰的博客-CSDN博客 吴双 组员 五 ...

  3. socket recv 服务端阻塞 python_网络编程(基于socket编程)

    网络编程(基于socket编程) socket套接字:应用程序通常通过socket"套接字"向网络发送请求或应答网络请求,是主机间或同一计算机中的进程间相互通讯 socket是介于 ...

  4. C#_Socket网络编程实现的简单局域网内即时聊天,发送文件,抖动窗口。

    C#_Socket网络编程实现的简单局域网内即时聊天,发送文件,抖动窗口. 最近接触了C#Socket网络编程,试着做了试试(*^__^*) 实现多个客户端和服务端互相发送消息 发送文件 抖动窗口功能 ...

  5. python网络编程案例—五子棋游戏

    一.本案例基于UDP的socket编程方法来制作五子棋程序,网络五子棋采用C/S架构,分为服务器端和客户端,游戏时服务端首先启动,当客户端启动连接后,服务器端可以走棋,轮到自己棋才可以在棋盘上落子,同 ...

  6. 基于Python实现的五子棋游戏设计

    一.设计目的: 1.1 课程设计教学目的 本课程设计是本专业的一门重要实践性教学环节.在学习了专业基础课和<Python程序设计>课程的基础上,本课程设计旨在加深对Python程序设计的认 ...

  7. 基于QT的网络五子棋游戏

    系统采用当今广为流行的五子棋游戏为模版,利用C++的第三方GUI设计工具Qt为程序设计界面,并结合软件工程的思想开发一款基于网络的五子棋游戏对弈软件.本软件采用P2P的模式,利用一个服务端来辅助各个客 ...

  8. 基于安卓Android的五子棋游戏设计与实现

    下载地址 本论文主要阐述以面向对象的程序开发语言,Eclipse为开发工具, 基于智能手机Android系统之上设计的一个五子棋游戏.五子棋起源于中国古代的传统黑白棋种之一,它不仅能增强思维能力提高智 ...

  9. 基于Android Studio的五子棋游戏的简单设计

    [摘要]: 随着时代的发展,现代科技的飞跃,我们的日常娱乐生活变得丰富多彩.而手机游戏被业内人士称为继通信之后的有一座"金矿",手机休闲娱乐应用将成为PC休闲娱乐之后又一重要业务增 ...

最新文章

  1. ASP.NET遍历配置文件的连接字符串
  2. debian---nano转VIM
  3. 配置访问oracle_SpringBoot中application.properties的常用配置
  4. 微软Windows 8最新幻灯片泄露
  5. Android:安卓APP启动过程简介
  6. Mangos自己制作装备
  7. how can you understand the world
  8. 通用权限实现的核心设计思想
  9. flask高级编程 LocalStack 线程隔离
  10. 第一篇|腾讯开源项目盘点:WeUI,WePY,Tinker,Mars等
  11. Android Prefence 总结
  12. Python多线程与Socket编程综合案例:素数
  13. 测试礼让线程(Java)
  14. python多重继承_Python多重继承
  15. Codeforces - 570D 离散DFS序 特殊的子树统计 (暴力出奇迹)
  16. lg android平台驱动程序,lg g3刷KDZ教程-KDZ线刷工具及USB驱动下载
  17. 如何设置无线网络中计算机的ip,无线网络设置方法【详细步骤】
  18. render_template()
  19. 淘宝/天猫API开发流程
  20. 中建二测线上测评、笔试

热门文章

  1. 计算机科学家格言,科学家说的名人名言20句
  2. mesh组网和AC+AP组网方式哪种好?
  3. Windows系统上 如何生成 .tar.gz格式的压缩包
  4. 设计平面坐标点类,计算两点之间距离、到原点距离、关于坐标轴和原点的对称点等
  5. IE6下png背景不透明——张鑫旭博客读书笔记
  6. 用记事本实现打开页面浏览器
  7. Unity Unet(四)多人在线游戏框架
  8. JavaScript深入系列-冴羽博客读后总结
  9. 华数机器人码垛_华数工业机器人码垛路径操作教程
  10. “不敢冒险就不是硅谷的企业”