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

最近在做课设,题目是关于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标签的使用

运行截图

C# Winform基于socket编程的五子棋游戏(带聊天和发送文件功能)_第1张图片

五子棋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))]
    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 WPieces { get; set; }
        //黑子列表
        [XmlElement(Order = 13)]
        public List 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
    {
        /// 
        /// 反序列化
        /// 
        /// 
        /// 
        /// 
        public static T DeserializeObject(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;
        }

        /// 
        /// 序列化
        /// 
        /// 
        /// 
        /// 
        public static string XmlSerializer(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集合底层也是用数组实现的,但多一层封装会带来很大的方便。

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

        /// 
        /// 画棋子
        /// 
        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(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地址和端口号的Socket
                serverSocket = 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);
            }
        }
  • 服务端接收数据

        /// 
        /// 接收客户端发来的信息
        /// 
        /// 客户端套接字对象    
        private void Receive(object socketclientpara)
        {
            Socket socketServer = socketclientpara as Socket;
            while (true)
            {
                //创建一个内存缓冲区,其大小为1024字节  即1KB
                byte[] 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(xml);
                    OnReceiveMsg(this, mes);//事件发生
                }
                catch (Exception ex)
                {
                    //如果发生异常,说明客户端已经关闭了连接或者反序列化出错
                                        
                    //关闭之前accept出来的和客户端进行通信的套接字 
                    socketServer.Close();
                    break;
                }
            }
        }
  • 客户端建立连接

        /// 
        /// 连接服务器
        /// 
        /// 
        /// 
        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首部:

C# Winform基于socket编程的五子棋游戏(带聊天和发送文件功能)_第2张图片

TCP首部:

C# Winform基于socket编程的五子棋游戏(带聊天和发送文件功能)_第3张图片

        /// 
        /// 接收服务端发来信息的方法
        /// 
        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(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就是专门用在数据唯一、不能重复的地方上。

  • 刷新玩家

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

        /// 
        /// 判断客户端socket是否在线(处于连接状态)
        /// 
        /// 
        /// 
        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;
        }
  • 强制玩家下线

        /// 
        /// 安全关闭客户端socket
        /// 
        /// The socket.
        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", "
"); m.Receiver = yourUUID; //将对象序列化成字符串,并转换为机器可以识别的字节数组 byte[] sendMsg = Encoding.UTF8.GetBytes(XmlUtils.XmlSerializer(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(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的显示效果:

C# Winform基于socket编程的五子棋游戏(带聊天和发送文件功能)_第4张图片

 

C# Winform基于socket编程的五子棋游戏(带聊天和发送文件功能)_第5张图片C# Winform基于socket编程的五子棋游戏(带聊天和发送文件功能)_第6张图片

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

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

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

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

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

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

  • 构造完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 = @" 



  
";
            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 = @"

" + name + @"

" + msg + @"

"; } else { str = @"

" + name + @"

" + msg + @"

"; } html = html.Replace("", "") + 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 = @"

" + name + @"

" + msg + @"

"; html = html.Replace("", "") + 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本身就支持动图显示啊。

C# Winform基于socket编程的五子棋游戏(带聊天和发送文件功能)_第7张图片

不能动的原因是我把图片放入了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

你可能感兴趣的:(.Net/C#)