C#版网络对战五子棋以及Socket通信

前言

    这个网络版五子棋游戏是今年四月初写的。当时觉得自己应该学一些网络编程的东西。而我课程设计的题目已经定了———做一个Everything。 那就帮我斐哥做个网络版的五子棋吧。

源码:https://pan.baidu.com/s/1oLYgg-PykBkCtT0MtKI_xQ

    界面是WinForm的,使用GDI绘图来完成棋盘与棋子的绘制,落子坐标通过定义的公式来计算。我原先做过人机对战版的五子棋,因此游戏逻辑这个最重要的部分并没有花很多时间。这个程序一个多星期就搞的差不多了。
C#版网络对战五子棋以及Socket通信_第1张图片
C#版网络对战五子棋以及Socket通信_第2张图片
不过现在看来,当时的代码太青涩了,一是课程设计马上就要中期检查没太多时间,二是水平和眼界确实不高。
比如:

  • 消息对象的序列化。那时候不知道有JSON序列化,所以自己就写了个ToString()方法,对方收到之后,解析出字符串,再Split,重建实体。
  • 消息处理。在Switch里写大量的逻辑代码,来处理不同类型的消息。
  • 以及大量Bug等。

源码:https://pan.baidu.com/s/1oLYgg-PykBkCtT0MtKI_xQ

设计

    玩家对战与人机对战的区别其实就是将玩家A的操作发送给玩家B,玩家B那边的界面渲染。我将游戏里的操作指令封装为了枚举类型。

public enum MsgType
{
    LuoZi=0,//玩家落子
    Connect=1,//玩家上线
    Quit=2,//玩家退出房间
    IsWin=3,//是否胜利
    CreateRoom=4,//创建房间
    JoinRoom=5,//加入房间
    UserList=6,//请求|发送玩家列表
    RoomList,//请求|发送房间列表
    Other,//其他
    Start,//开始游戏
    Exit,//玩家连接断开
    OtherName,//忘了干嘛的了
    Restart,//重新开始游戏
    Msg//聊天
}

消息对象:

public class MessagePackage
{
    public MsgType msgType;
    public string data;
    public string senderIP = "";
    public string senderName = "";
    public string sendTime;

    public MessagePackage()
    {

    }

    public MessagePackage(string msg)
    {
        string[] msgs = msg.Split('|');
        msgType = (MsgType)int.Parse(msgs[0]);
        data = msgs[1];
        senderIP = msgs[2];
        senderName = msgs[3];
        sendTime = msgs[4];
    }

    public MessagePackage(MsgType msg, string data, string senderIP, string senderName, string sendTime)
    {
        this.msgType = msg;
        this.data = data;
        this.senderIP = senderIP;
        this.senderName = senderName;
        this.sendTime = sendTime;
    }

    public string ConvertToString()
    {
        string msg = ((int)msgType).ToString() + "|" + data + "|" + senderIP + "|" + senderName + "|" + sendTime;
        return msg;
    }


}

客户端逻辑

  • GDI绘制
  • 游戏逻辑
  • 登录建房
  • 加入开始
  • 结束重来
  • 聊天信息
  • 退出
    -C#版网络对战五子棋以及Socket通信_第3张图片
    C#版网络对战五子棋以及Socket通信_第4张图片
    C#版网络对战五子棋以及Socket通信_第5张图片

    我决定举一个最基本的栗子———游戏逻辑中的玩家落子。

落子

    进入游戏房间后,我会用GDI画出15*15的棋盘。使用过GDI的朋友都知道,它是根据像素为单位的,这样做是不简单的。

    比如你想将棋子落在棋盘上(7,7)这个点上,那就需要用GDI来画一个白色的棋子在那个位置上。GDI提供的绘圆方法是什么呢?FillEllipse,你需要指定一个长方形,包括这个长方形左上角的横纵坐标,以及它的长和宽,以及填充的颜色。这个方法才能为你画出这个长方形里最大的那个圆,或是椭圆。

    private bool GraphicsPiece(Point upleft, Color c)
    {
        Graphics g = this.panel1.CreateGraphics();
        if (upleft.X != -1 || upleft.Y != -1)
        {
            g.FillEllipse(new SolidBrush(c), upleft.X, upleft.Y, CheckerBoard.chessPiecesSize, CheckerBoard.chessPiecesSize);
            return true;
        }
        return false;
    }

    重点就是这个长方形的左上角坐标怎么得到?我们知道鼠标点击事件中,参数Args带给我们的是一个以像素为单位的,相对与绘图区的位置。而且你不能指望用户正好点在棋盘的那个点上,他可能点在(7,7)上面一点,或是下面一点。因此我们就需要对鼠标点击的坐标值就行处理,将其转化相对的表现形式(7,7)。

将像素坐标转化成相对坐标:

    public static Piece ConvertPointToCoordinates(Point p,int flag)
    {
        int x, y;
        Piece qi;
        if (p.X(lineNumber-1)*distance+leftBorder|| p.Y > (lineNumber - 1) * distance + topBorder)
        {
             qi= new Piece(-1,-1,flag);
        }
        else
        {
            float i = ((float)p.X - leftBorder) / distance;
            float j= ((float)p.Y - topBorder) / distance;
            x = Convert.ToInt32(i);
            y = Convert.ToInt32(j);
            if (GameControl.ChessPieces[x, y] != 0)
            {
                qi = new Piece(-1, -1, flag);
            }
            else
            {
                qi = new Piece(x, y,flag);                  
            }              
        }
        return qi;
    }

将相对坐标转化成像素坐标:

    public static Point ConvertCoordinatesToPoint(Piece p)
    {
        int x, y;
        x = p.X * distance + leftBorder - chessPiecesSize / 2;
        y = p.Y * distance + topBorder - chessPiecesSize / 2;
        return new Point(x, y);
    }

落子:绘制本地棋子并将相对坐标发送给服务器;如果取得胜利,则发送胜利消息给服务器,服务器根据房间信息,查找到对手玩家,发送消息给对手玩家。

    Piece p = CheckerBoard.ConvertPointToCoordinates(new Point(e.X, e.Y), 1);
            if (p.X != -1)
            {
                Point point = CheckerBoard.ConvertCoordinatesToPoint(p);
                if (Program.gc.AddPiece(p))
                {
                    GraphicsPiece(point, myColor);
                    MessageBox.Show("黑棋获胜");
                    return;
                }
                else
                {
                    GraphicsPiece(point, myColor);
                    p = Program.gc.MachineChoose();
                    point = CheckerBoard.ConvertCoordinatesToPoint(p);
                    if (Program.gc.AddPiece(p))
                    {
                        GraphicsPiece(point, otherColor);
                        turnFlag = true;
                        MessageBox.Show("白棋获胜");
                        return;
                    }
                    GraphicsPiece(point, otherColor);
                    lbmyscore.Text = (0 - Program.gc.GetScore()).ToString();
                    lbhisscore.Text = Program.gc.GetScore().ToString();
                    turnFlag = true;
                }
            }

对方收到落子消息后

    case MsgType.LuoZi:
                {
                    string[] qi = mp.data.Split(',');
                    int x = int.Parse(qi[0]);
                    int y = int.Parse(qi[1]);
                    Piece p = new Piece(x, y, 3 - flag);
                    Point point = CheckerBoard.ConvertCoordinatesToPoint(p);
                    if (Program.gc.AddPiece(p))
                    {                           
                        GraphicsPiece(point, otherColor);                            
                        start = false;
                        btnStart.Enabled = true;
                        MessageBox.Show("对方获胜");
                    }
                    else
                    {
                        GraphicsPiece(point, otherColor);
                        turnFlag = true;
                    }                        
                    break;
                }

    将相对坐标转化成本地像素坐标,绘制棋子,然后本人落子。

服务器设计

    没有考虑很多,实现“上传下达”的功能就好了。

  • 消息转发
  • 控制用户数量
  • 维护房间列表信息
  • 维护用户列表信息
  1. 比如,玩家断开连接:要及时从玩家列表清理,更新列表,并发送给在线的玩家。
  2. 比如,玩家退出房间:查找到该房间,更新房间信息,发送给在线玩家
    C#版网络对战五子棋以及Socket通信_第6张图片

举个栗子

    相对于客户端而言,服务端的代码量少很多,除了通用的代码,大概四百行左右。

某玩家退出某房间

    case MsgType.Quit:
                {
                    GameRoom r = SearchRoomBySenderName(mp.senderName);
                    GamePlayer p = SearchUserByName(mp.senderName);
                    r.QuitRoom(p);
                    if (r.PlayerNumber == 0)
                        roomList.Remove(r);
                    else
                    {
                        mp = new MessagePackage(MsgType.Quit, "", "", "", "");
                        tcpServer.Send(r.RoomMaster.Session, mp.ConvertToString());                          
                    }
                    mp = new MessagePackage(MsgType.RoomList, GetRoomList(), "", "", DateTime.Now.ToString());
                    foreach (Session session in tcpServer.SessionTable.Values)
                    {
                        tcpServer.Send(session, mp.ConvertToString());
                    }
                    break;
                }
  1. 根据玩家名称,从房间列表该找到房间。
  2. r.QuitRoom§: 判断该玩家是不是房主,是:将另一名玩家提升为房主;finally:从房间中清除该玩家。
  3. 若房间玩家全部退出,删除该房间
  4. 发送新的房间列表信息给所有玩家。

C#版网络对战五子棋以及Socket通信_第7张图片

Socket通信

    重点来了,我开头就说要学网络编程的。最后简单介绍一下C#中Socket编程。当然,C#也提供了更高级别的封装如TcpClient,TcpListener。以及更高性能的异步套接字:SocketAsyncEventArgs。

服务端Socket

    mainSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
    mainSocket.Bind(new IPEndPoint(IPAddress.Any, 4396));
    mainSocket.Listen(5);
    mainSocket.BeginAccept(new AsyncCallback(AcceptConn), mainSocket);
  1. 新建Socket实例:指定使用IPv4,流传输,TCP协议。

  2. 绑定到本机,4396端口

  3. 开始监听,连接队列最大为5

  4. 将AcceptConn函数注册为连接回调函数。回调函数必须接收一个类型为IAsyncResult的参数。

     mainSocket.BeginAccept(new AsyncCallback(AcceptConn), mainSocket);
    

BeginAccept会阻塞当前线程。当有连接进入后,将mainSocket封装为作为IAsyncResult对象,作为参数传递给AcceptConn。

连接回调函数AcceptConn的用法

    protected virtual void AcceptConn(IAsyncResult iar)
    {          
        Socket Server = (Socket)iar.AsyncState;
        Socket client = Server.EndAccept(iar);
        if (clientCount == maxClient)
        {
            ServerFull?.Invoke(this, new NetEventArgs(new Session(client)));
        }
        else
        {
            Session clientSession = new Session(client);
            sessionTable.Add(clientSession.SessionId, clientSession);
            clientCount++;
            clientSession.Socket.BeginReceive(receiveBuffer, 0, DefaultBufferSize, SocketFlags.None, new AsyncCallback(ReceiveData), clientSession.Socket);
            ClientConn?.Invoke(this, new NetEventArgs(clientSession));
            Server.BeginAccept(new AsyncCallback(AcceptConn), Server);
        }
    }
  1. 从IAsyncResult中获取到mainSocket,并结束异步操作。这是较为经典的异步编程模型写法。

  2. 服务器满,触发ServerFull事件,通知客户端无法进入。

  3. 服务器未满,将接入的socket连接进行封装,加入到玩家集合中

  4. 开始接收该Socket的消息

     clientSession.Socket.BeginReceive(receiveBuffer, 0, DefaultBufferSize, SocketFlags.None, new AsyncCallback(ReceiveData), clientSession.Socket);
    
     BeginReceive函数有多种重载形式,看看说明不难理解。
    
  5. 服务端继续监听连接

     Server.BeginAccept(new AsyncCallback(AcceptConn), Server);
    

客户端Socket

  1. 连接

     Socket newSoc = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
     IPEndPoint remoteEP = new IPEndPoint(IPAddress.Parse(ip), port);
     newSoc.BeginConnect(remoteEP, new AsyncCallback(Connected), newSoc);
    
  2. 发送

     public virtual void Send(string datagram)
     {
         if (datagram.Length == 0)
         {
             return;
         }
         if (!isConnected)
         {
             throw (new ApplicationException("没有连接服务器,不能发送数据"));
         }
         //获得报文的编码字节 
         byte[] data = coder.GetEncodingBytes(datagram);
         session.Socket.BeginSend(data, 0, data.Length, SocketFlags.None,new AsyncCallback(SendDataEnd), session.Socket);
     }
    
  3. 接收

      session.Socket.BeginReceive(receiveBuffer, 0, DefaultBufferSize, SocketFlags.None, new AsyncCallback(RecvData), socket);
    

结尾

当然实际编程的时候会遇到好多问题,比如:

  1. Socket连接正常断开和异常断开的问题。
  2. 事件驱动模型中,事件侦听程序不再直接引用,发布程序仍会有引用存在,垃圾回收器就不能对其进行回收。当多个界面都存在事件侦听操作时,会发生混乱。等。

你可能感兴趣的:(Windows,编程,C#,五子棋,网络编程,Socket)