资讯动态

C#联网象棋对战系统开发实战:从Socket通信到完整架构

发布时间:2026/10/20 21:42:47 来源:尧图企业网站定制
很多人问联机小游戏怎么做问得最多的就是“C#能不能做一个联网象棋对战系统”。这题目翻来覆去就四件事棋盘怎么建模、走子怎么判定、消息怎么传递、状态怎么同步。但真去搜代码就会发现搜索到的示例要么把绘制和通信写成一碗糊要么走棋规则只有车和炮要么一联机就各种掉线。C#联网象棋对战系统的难点从来不在某一处而在每一处怎么协调。这篇把我个人落地这个系统的完整思路写出来包括架构、协议、关键代码和联调翻车记录适合学完C#基础想拿完整联机项目练手的人也适合准备把项目放进作品集的人。1. 整体架构C#联网象棋系统怎么拆才不会越写越乱1.1 技术选型桌面端为什么是C#更顺手先回答最基础的问题为什么是C#。用C不是不行但界面部分要挂图形框架底层细节烧掉大量时间用Web技术栈又要同时维护前后端两套语言。C#在这一类局域网联机桌游上的优势很直接WinForms/WPF做界面足够快TcpClient/TcpListener把Socket封装得干净多线程和异步又是原生支持。只要把精力集中在“对局逻辑”和“通信设计”上项目推进速度会快很多。网络层选TCP还是UDP也得想明白。象棋是回合制一盘棋几十到上百手单条消息只有几十个字节对带宽和延迟完全无感。它真正需要的是“消息必须到达且顺序不能乱”这正是TCP的地盘。UDP适合实时反应类玩法用在象棋上是给自己找麻烦。我实测过在Wi-Fi热点下联机偶尔丢包重传但TCP协议栈在底层兜住了应用层完全不用考虑乱序和丢失这两回事。提示如果最终目标是发布到互联网而不是局域网可以把TcpListener替换成SignalR或gRPC让框架帮忙维护连接。但如果目标是学网络编程强烈建议保留原始Socket实现走一遍流程这对理解协议本质帮助很大。1.2 三层架构界面、逻辑、通信各管各的不要互相掺和很多项目写着写着变成一坨问题不在水平而在于没分清楚职责。我习惯拆成三层界面层、对局逻辑层、通信层。界面层只做三件事画棋盘棋子、接收鼠标点击、把对局数据渲染到画面。对局逻辑层维护棋盘数组、走棋合法性、胜负判断它完全不关心数据走的是TCP还是内存。通信层管连接、收发消息、解析封包不关心你走的是不是一步合法棋只负责把“移动请求”从一个进程搬到另一个进程。三层之间的协作我用事件和入口方法解决对局逻辑层向上抛事件界面层订阅事件刷新画面通信层收到消息后调用逻辑层的入口方法而不是直接改界面。这样分工最大的好处是定位问题很快。联机出故障时先用单机模式打一遍能走子说明界面和逻辑没问题接着本机双开对打能走通说明通信正常再上局域网测试如果这时候出问题锅基本就在防火墙或者IP配置。我就遇到过双方输入框都没反应、本机双开却一切正常的情况最后发现是服务端转发消息时写错了端口。2. 棋盘与走棋规则这一层写不好后面全是返工2.1 棋盘建模的两种方式我为什么选二维数组棋盘建模有两条常见路线用10乘9的二维数组或者为每个棋子定义类对象。很多人一上来就想面向对象给棋子建类、包含类型颜色位置听起来很符合直觉。但下棋过程中状态变化极频繁走子、吃子、悔棋都要反复增删对象反而引入很多边界条件。我最终选了最简单的int[,]数组数值的绝对值表示棋子类型正负号表示红黑。public enum PieceType { None 0, King 1, // 将/帅 Advisor 2, // 士/仕 Bishop 3, // 象/相 Knight 4, // 马 Rook 5, // 车 Cannon 6, // 炮 Pawn 7 // 兵/卒 } // 正值表示红方负值表示黑方0表示空位 private int[,] board new int[10, 9];这个方案的直接好处在于判断吃子只需检查目标格数值的正负是否相反判断走棋合法性只需查数组深拷贝棋盘做策略测试也很方便。如果用对象列表反而每一步都要重新计算棋子位置。代价是查棋子需要遍历但一盘棋几十个棋子性能完全不是问题。象棋这种项目卡顿永远不会出现在逻辑层只会出现在绘制层用了错误的重绘策略。2.2 每个棋子的走法判定我踩过的坑都在这先约定坐标行号0到9从上到下列号0到8从左到右红方在下方黑方在上方。所有走法判断的第一步都是检查目标是否越界然后检查目标格是不是自己人最后按棋种做专属限制。车最简单横竖走直线中间不能有子。炮也走直线但吃子必须恰好隔一个“炮架”不吃子时不能跳子。马的坑在于“蹩马腿”这是新手最容易写错的地方。移动偏移必定是行偏移2列偏移1或行偏移1列偏移2马腿位置是移动方向上的第一个格。正确写法是看哪个方向走了2马腿就在那个方向的中间格。private bool CanKnightMove(int fromRow, int fromCol, int toRow, int toCol) { int dr toRow - fromRow; int dc toCol - fromCol; // 马的移动必须是(2,1)或(1,2) if (!((Math.Abs(dr) 2 Math.Abs(dc) 1) || (Math.Abs(dr) 1 Math.Abs(dc) 2))) return false; // 目标格不能是自己人 int target board[toRow, toCol]; int myColor Math.Sign(board[fromRow, fromCol]); if (target ! 0 Math.Sign(target) myColor) return false; int legRow fromRow; int legCol fromCol; if (Math.Abs(dr) 2) legRow fromRow dr / 2; // 行方向走2格马腿在中间行 else legCol fromCol dc / 2; // 列方向走2格马腿在中间列 if (board[legRow, legCol] ! 0) return false; // 蹩马腿 return true; }象走田字限制是“塞象眼”和不能过河士只能在九宫内走斜线将只能在九宫内上下左右走一格兵没过河只能向前过河后可以向前或左右但永远不能后退。每条规则我都建议写成独立私有方法走棋入口统一调用。不要在一个方法里塞所有判断否则后面加悔棋、复盘、AI提示时会改到崩溃。2.3 送将与照面新手最容易漏掉的隐藏规则走棋合法性不只是“这个棋子能这么动”还有一条全局约束走完之后你的将帅不能处在被将军状态。你不能送吃掉自己的老将也不能主动走成将帅照面。很多第一次做象棋的人会漏掉这一条结果下着下着突然发现规则“失灵”其实是对方一步棋把你的将“照面”了逻辑却没拦住。我的处理办法是真正落子之前先模拟走棋再调用一次检测函数看看自己这方是否处于被将军状态。注意是模拟不是直接改数组。可以写一个临时棋盘拷贝然后做判断private bool MoveWouldLeaveSelfChecked(int fromRow, int fromCol, int toRow, int toCol, int myColor) { int[,] temp (int[,])board.Clone(); temp[toRow, toCol] temp[fromRow, fromCol]; temp[fromRow, fromCol] 0; return IsInCheck(temp, myColor); }IsInCheck 的实现思路是先找到我方将帅的位置再遍历对方所有棋子复用前面的 CanXxxMove 系列方法判断是否存在“下一步就能吃掉将帅”的局面。这里不要给炮单独写“隔子打将”这种规则碎片统一复用走法判断可以让代码更干净也为以后做AI打基础。胜负判断在此基础上扩展一方行棋后对方所有合法走法被封死就是将死没有棋可走就是困毙。对学习项目来说做出将死判断已经能覆盖绝大多数对局困毙判断可以后续再补。3. 网络协议与房间系统Socket通信前先想清楚这三件事3.1 消息格式别用文本聊天式协议宁可多花十分钟设计Socket本质是字节流没有“一条消息”和“下一条消息”的分界线。想让双方正确拆包协议得先定好。第一种是定长消息每条消息都固定N个字节不够就补零。第二种是“长度内容”先读4字节int表示正文长度再读N字节正文。我用的就是第二种更通用以后加字段不用推翻重来。消息头不只放长度还要放类型。我设计成8字节头部加可变正文前4字节是正文长度后4字节是消息类型。正文用UTF-8字符串内容按约定拼装比如“12,3,7,6”表示从12行3列移动到7行6列。这套设计的好处是扩展性。以后加聊天只需要定义新消息类型正文写成时间、发送者、文本客户端解析后单独处理完全不影响对局消息。public static byte[] PackMessage(int msgType, string payload) { byte[] body Encoding.UTF8.GetBytes(payload); byte[] packet new byte[8 body.Length]; BitConverter.GetBytes(body.Length).CopyTo(packet, 0); BitConverter.GetBytes(msgType).CopyTo(packet, 4); body.CopyTo(packet, 8); return packet; }拆包时因为TCP可能把两条消息粘在一起也可能一条消息拆成两次发送所以客户端必须有缓冲区累积。这个点放到第5章详讲这里先记住一个原则永远不要用 NetworkStream.Read 的返回值当作完整消息。3.2 消息类型与流程从进入房间到对局结束的状态机对局不是“连上就能下”中间有房间、认输、离开这些状态。消息类型起得清晰后面维护成本会低很多。我按下面这些类型组织。LOGIN客户端上报昵称服务端记录。CREATE_ROOM创建房间返回房间号和执子颜色。JOIN_ROOM加入指定房间。GAME_READY双方就位后服务端广播开始并告知红方先行。MOVE棋盘移动请求格式是四个坐标。MOVE_RESULT落子结果的通知。UNDO_REQUEST、UNDO_ACCEPT、UNDO_REJECT悔棋交互需要对方同意。SURRENDER认输。LEAVE主动离开。房间状态机不复杂就三态等待中、对局中、已结束。观战功能学习阶段就先别做那意味着要把整盘棋的同步逻辑暴露给第三方复杂度明显上升。先把双方对战做实房间能正常工作就算到位了。3.3 服务端校验还是客户端校验一个值得提前决策的问题设计网络层时有个选择绕不开走棋合法性到底由谁判断。客户端判断的话每个客户端写一套相同逻辑服务端只转发代码量小、响应快缺点是恶意客户端可以直接发非法消息服务端无从拦截。服务端判断则必须自己维护棋盘客户端只负责展示防作弊更可靠但服务端代码等于重写一套规则。我的建议是分用途。只是学网络协议客户端校验完全够用因为目标就是学会通信流程但如果要做成简历里的“完整联机系统”至少要把落子校验放到服务端架构上才能自圆其说。我当时的做法是逻辑层写成公共类库供客户端共享服务端只做房间管理和消息转发。后来被问“客户端作弊怎么办”我补了一段“把校验放服务端、客户端只发请求”的设计说明才圆过去。这个设计态度本身比代码更值钱。4. 服务端、客户端与棋盘的实操代码照着搭就能跑通4.1 服务端一个监听循环加两个连接字典够用服务端不需要复杂框架。用TcpListener监听端口每接受一个客户端就交给一个任务处理再用一个字典以连接ID为键保存所有TcpClient用来做定向转发和广播。广播时逐个发送如果有客户端断开发送会抛异常必须捕获并把它从字典移除同时通知对局中的另一方房间状态置为结束。我习惯把连接管理和业务处理分开。主循环只负责接收新连接TcpListener listener new TcpListener(IPAddress.Any, 9000); listener.Start(); while (true) { TcpClient client await listener.AcceptTcpClientAsync(); _ Task.Run(() HandleClient(client)); }HandleClient 里做一个读消息循环读完一条就分派给对应处理器。服务端不需要理解“车的走法”只需要知道房间里的两个人是谁、消息应该转给谁。这里有个关键点同一个客户端发来的两条消息有顺序问题比如先发认输又发落子服务端如果并行处理就可能先转发落子。解决办法是按客户端串行处理HandleClient 里的循环逐条读取逐条分派顺序天然保证不需要额外加锁。读消息时要特别注意 NetworkStream.Read 可能读不满缓冲区。封装一个 ReadExactly 函数非常有必要private static int ReadExactly(NetworkStream stream, byte[] buffer, int count) { int offset 0; while (offset count) { int n stream.Read(buffer, offset, count - offset); if (n 0) return -1; // 连接关闭 offset n; } return offset; }4.2 客户端网络层线程安全地收消息别在UI线程里写Socket逻辑客户端最常见的错误是在点击事件里直接new Socket然后同步读取。网络阻塞时UI线程会卡死窗口变成“未响应”。正确做法是界面事件里只把请求交给网络线程网络线程收到结果后通过 Control.Invoke 或 BeginInvoke 切回UI线程更新界面。收消息循环放在后台任务里收到完整消息后按类型分发。所有改界面控件的代码一律Invoke。这里要重点提醒循环不要写成死循环要加一个退出标志。我遇到过窗口关了进程还在后台的情况就是因为后台线程没退出。在窗口关闭事件里把标志置位并调用线程的Join等待退出才算干净收尾。4.3 棋盘UI绘制双缓冲与坐标换算绘制棋盘我直接在自定义控件的OnPaint里完成不把每个棋子创建成独立控件。五十个棋子如果各做一个控件刷新、遮罩、事件处理都是麻烦。直接在画布上绘制简单可靠性能也够。坐标换算是UI层最容易被坑的地方。逻辑坐标是行列绘制坐标是像素。我在控件里提前算好格子边长和原点然后写两个方法互换private Point LogicToPixel(int row, int col) { int x originX col * cellSize; int y originY row * cellSize; return new Point(x - pieceRadius, y - pieceRadius); }点击时反过来用鼠标坐标判断落在哪个格子。这里注意屏幕Y轴向下增长棋盘行号向下增长恰好一一对应。画完要开双缓冲构造函数里设 DoubleBuffered 为 true 或重写 OnPaintBackground 为空实现。不开双缓冲的话走棋时棋盘区域会明显闪烁看起来非常业余。4.4 单机自测到联机联调走通这四个阶段我测试分四阶段。第一阶段单机模式自己跟自己下验证走棋规则和胜负判断。第二阶段本机双开两个客户端都连127.0.0.1验证通信链路。第三阶段局域网联调验证真实网络环境和防火墙。第四阶段异常测试主动关掉一个客户端、发送非法消息、连续快速走子验证容错。这四个阶段里第二阶段和第四阶段最暴露问题。本机双开看似简单但能确定消息格式、封包拆包、服务端转发这些基础链路是否正常。异常测试则能提前发现掉线处理和UI线程安全问题。没有经过第四阶段的项目演示现场大概率翻车。5. 联调实录粘包、掉线、送将这些问题一次讲明白5.1 粘包和半包TCP字节流的经典问题第一次双开对弈连续走几步后对方就收不到消息了。排查半天不是逻辑问题是消息边界问题。TCP是流式协议多次Send可能合并成一次到达一次Send也可能拆成多次到达。如果按“读一次就当成一条完整消息”来解析必然出错。解决办法是缓冲区累积。维护一个缓存把每次收到的字节追加进去然后循环尝试从缓存里解析“长度内容”。解析出一条就消费对应的字节不够一条就留着等下一次到达。这段逻辑几乎是网络编程必修课多写几遍就有肌肉记忆了。5.2 走棋规则误判马腿、象眼、炮架三个典型的逻辑死角联调到一半有人反馈马能跳过棋子当场演示确实如此。查下来发现马的蹩马腿判断写得太简单直接写成了“目标为空即可”完全没判断中间有没有子。象眼同理。炮更隐蔽我原先只判断了“中间恰好一个子”却忘了“炮不吃子时不允许越子”。这三个问题的共性是规则本身不难难在把分支列举完整。建议把每种棋子的合法性写成专用测试方法再做一组单机用例把“马的四个方向被蹩”“炮有炮架和没炮架”“象过河”全部覆盖。自动化测试在这里真的能救命手工试几十盘不如一条用例速度快。5.3 掉线处理心跳、超时、状态重置高频问题还有一方直接关客户端另一方永远等不到下一步房间也不恢复。最简单的处理是客户端每隔几秒发心跳服务端连续N秒收不到任何消息就判定超时断开。断开后服务端把房间状态改回去通知存活方“对手已离开”再释放连接资源。资源释放最容易忽略顺序先关流再关客户端再把连接从字典移除。少一步都可能导致端口占用或线程泄漏。我后来做崩溃恢复逻辑时用这套流程把各种边界情况都跑了一遍。5.4 常见问题速查整理了一份联调问题速查表方便对着查。现象根因解决方案双方各自走棋互不影响服务端没有转发消息检查消息类型区分与转发逻辑走一两步后界面卡死在UI线程同步读取Socket网络收发全部放到后台任务关窗体后进程还在后台收消息循环未退出加退出标志并在关闭事件中触发连续快速走子偶发丢消息TCP粘包/半包缓冲区累积并完整拆包马能跳子马腿判断逻辑不完整按2格外偏移方向计算马腿格将帅能“照面”缺少送将检测模拟走棋后统一调用被将军检测局域网偶发连不上防火墙拦截或IP配置错误开放监听端口核对服务端IP悔棋后状态异常棋盘和界面状态未同步悔棋成功后通知对方统一重绘5.5 关于自动对战与压测的一个实用技巧联调阶段最耗时的是验证规则。想快速验证服务端稳定性可以写一个随机走棋的自动玩家搜索所有合法走法随机选一个延时几百毫秒后发出。两个自动玩家连上线服务端的消息吞吐、粘包处理、房间状态都能被充分考验。这个自动玩家代码不复杂但很多人没想到写它。我后续调试断线重连也靠它在自动对局中随机断开一端反复测试服务端的恢复逻辑。这个技巧强烈建议保留以后做AI、做复盘、做观战都能派上用场。个人体会放这里这个项目做完我最大的收获不是C#写得多溜而是真正理解了什么叫“系统”。画棋盘是一小块走棋规则是一小块网络通信是一小块每个小块单独拿出来都不难难的是让它们在同一时间线里协调运作。如果时间有余建议再补三个扩展点一是棋谱记录与回放这是对局完整性的重要一环二是观战模式能检验同步设计是否结实三是悔棋确认看着简单但通信协议要加好几条消息。三个功能补完这个系统的完整度会明显高出同类项目一截。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑