公司动态

C++五子棋实战:从控制台到图形界面的完整项目开发指南

📅 2026/8/12 21:47:06
C++五子棋实战:从控制台到图形界面的完整项目开发指南
1. 项目概述为什么选择五子棋作为C实战项目如果你正在学习C并且已经啃完了语法书做了一些练习题但总觉得离“做出一个东西”还差一口气那么这个五子棋项目可能就是你需要的那块跳板。我当年也是从这个阶段过来的从控制台的“井字棋”开始到后来用图形库做出带界面的五子棋整个过程对理解面向对象、事件驱动、基础算法以及调试排错有巨大的帮助。五子棋规则简单但实现起来麻雀虽小五脏俱全它几乎涵盖了小型桌面应用开发的所有核心环节数据建模棋盘和棋子、游戏逻辑胜负判定、用户交互鼠标点击、以及最终的图形界面呈现。市面上很多教程只讲零散的知识点或者给一个“黑盒”源码让人看不懂。我这个项目的思路是从最核心的游戏逻辑开始完全用标准C在控制台实现一个可玩的版本。等你彻底吃透了规则和算法我们再引入图形库这里选用经典的 EasyX因为它对新手极其友好能让你快速看到成果把逻辑“套”上一个漂亮的壳子。这样做的好处是你能清晰地看到“数据”和“视图”是如何分离又协作的这是现代软件工程一个非常基础且重要的思想。整个项目做完你收获的不仅仅是一个能运行的游戏更是一套解决同类问题的思维模式和代码组织能力。2. 核心逻辑设计与数据建模在动手写任何一行代码之前我们必须先把游戏世界“数字化”。五子棋的核心就是一个二维棋盘和两种棋子。如何用C的数据结构来优雅地表示它们直接决定了后续所有逻辑的复杂度和代码的清晰度。2.1 棋盘与棋子的数据表示最直观的想法是用一个二维数组比如int board[15][15]。用0表示空位1表示黑棋2表示白棋。这个方法简单粗暴在初期完全可行。但随着功能增加比如悔棋、存盘你会发现直接操作原始数组会带来很多麻烦。我推荐的做法是封装一个Board类。这个类内部维护那个二维数组但对外提供一套清晰、安全的接口。// Board.h #pragma once #include vector enum class Piece { EMPTY, BLACK, WHITE }; class Board { public: Board(int size 15); // 构造函数默认15路棋盘 ~Board() default; // 核心接口 bool placePiece(int x, int y, Piece piece); // 在(x,y)放置棋子返回是否成功 Piece getPiece(int x, int y) const; // 获取(x,y)位置的棋子 bool isInBoard(int x, int y) const; // 判断坐标是否在棋盘内 bool isFull() const; // 棋盘是否已满平局判断 void clear(); // 清空棋盘 int getSize() const { return m_size; } // 获取棋盘大小 // 高级功能接口为后续扩展预留 std::vectorstd::pairint, int getMoveHistory() const; // 获取落子历史用于悔棋 bool undoLastMove(); // 悔棋 private: int m_size; std::vectorstd::vectorPiece m_grid; // 使用vector动态大小更安全 std::vectorstd::pairint, int m_moveHistory; // 记录落子顺序 };为什么用enum class而不用普通的enumenum class是强类型枚举能防止无意间的隐式类型转换避免把Piece::BLACK当成整数1去和别的整数比较让代码更安全。为什么用vectorvectorPiece而不是原生数组vector是STL容器自带内存管理复制、传递更安全而且配合构造函数可以方便地初始化所有元素为Piece::EMPTY。2.2 胜负判定算法的深度解析这是整个项目的算法核心。五子棋的胜负判定本质是在一个二维矩阵中检查是否存在连续五个相同的值。听起来简单但实现效率直接影响游戏体验。一个新手容易写的低效做法是每次落子后以该子为中心向四个方向横、竖、左斜、右斜分别数一遍连续的同色棋子。这个做法逻辑正确但存在大量重复计算。更高效、也是业界通用的方法是方向向量搜索法。我们定义四个方向向量水平(1, 0)垂直(0, 1)左上到右下斜线(1, 1)右上到左下斜线(1, -1)对于每一个新落下的棋子我们分别沿着这四条线的正反两个方向进行计数。只要在任何一个方向上正反连续的同类棋子数之和达到4加上新落的这颗子就是5即判定胜利。// GameLogic.h (或 Board.h 内) class GameLogic { public: static bool checkWin(const Board board, int lastX, int lastY, Piece lastPiece) { // 方向向量数组 const int dirs[4][2] { {1, 0}, {0, 1}, {1, 1}, {1, -1} }; for (const auto dir : dirs) { int dx dir[0]; int dy dir[1]; int count 1; // 从刚落下的这颗子开始计数 // 正向搜索 for (int step 1; step 5; step) { int nx lastX dx * step; int ny lastY dy * step; if (!board.isInBoard(nx, ny) || board.getPiece(nx, ny) ! lastPiece) break; count; } // 反向搜索 for (int step 1; step 5; step) { int nx lastX - dx * step; int ny lastY - dy * step; if (!board.isInBoard(nx, ny) || board.getPiece(nx, ny) ! lastPiece) break; count; } if (count 5) { return true; // 五子连珠 } } return false; } };注意这里的count 5是严谨的写法。因为理论上如果棋盘上已经存在长连超过5子正向和反向计数之和可能大于5。用可以覆盖所有获胜情况。如果你想严格限定为“等于5子获胜”可以在计数达到5时立即返回或者最后判断count 5。2.3 游戏状态机与流程控制一个完整的游戏不仅仅有棋盘和判定还需要管理游戏的状态流转。这就是“游戏状态机”的概念。通常一个五子棋游戏有以下几种状态准备中READY游戏未开始等待玩家选择先手或后手。黑方行棋BLACK_TURN轮到黑棋玩家或AI落子。白方行棋WHITE_TURN轮到白棋落子。黑方胜利BLACK_WIN游戏结束黑胜。白方胜利WHITE_WIN游戏结束白胜。平局DRAW棋盘下满无人获胜。我们可以用一个枚举类来定义这些状态并在主游戏循环中根据当前状态决定该做什么。// Game.h #pragma once #include Board.h #include GameLogic.h enum class GameState { READY, BLACK_TURN, WHITE_TURN, BLACK_WIN, WHITE_WIN, DRAW }; class Game { public: Game(); void start(); // 开始游戏 bool placePiece(int x, int y); // 处理落子参数为逻辑坐标 GameState getState() const { return m_state; } const Board getBoard() const { return m_board; } void restart(); // 重新开始 private: Board m_board; GameState m_state; Piece m_currentPlayer; // 当前行棋方 void switchPlayer(); // 切换行棋方 void updateGameState(int lastX, int lastY); // 根据最后落子更新状态 };在Game::placePiece这个核心方法里流程是这样的检查当前游戏状态是否处于BLACK_TURN或WHITE_TURN否则拒绝落子。调用Board::placePiece尝试在指定位置落子。如果该位置已有子或超出边界失败。落子成功后立即调用GameLogic::checkWin检查是否获胜。如果获胜将游戏状态设置为对应的*_WIN。如果未获胜检查棋盘是否已满 (Board::isFull)若已满则状态设为DRAW。如果既未获胜也未平局则调用switchPlayer切换行棋方BLACK_TURN-WHITE_TURN。这个清晰的流程控制将用户交互、数据修改和状态更新串联了起来是游戏能够正确运行的大脑。3. 控制台版本的实现与调试在引入复杂的图形界面之前我强烈建议你先在控制台里把游戏逻辑跑通。这能让你专注于核心算法避免被界面开发的细节干扰。控制台版本的关键在于如何将二维的棋盘数据映射到字符界面的输出。3.1 控制台界面的绘制与交互我们需要一个函数来打印棋盘。一个直观的打印方式是用表示交叉点用●或B表示黑棋用○或W表示白棋。为了清晰最好在棋盘边缘标上行号和列号字母或数字。// ConsoleUI.h #pragma once #include Game.h #include iostream class ConsoleUI { public: static void printBoard(const Board board) { int size board.getSize(); // 打印列标字母 std::cout ; for (int i 0; i size; i) { std::cout char(A i) ; } std::cout std::endl; for (int y 0; y size; y) { // 打印行号数字 std::cout (y1 10 ? : ) y1 ; for (int x 0; x size; x) { Piece p board.getPiece(x, y); switch (p) { case Piece::EMPTY: std::cout ; break; case Piece::BLACK: std::cout ● ; break; // 或 B case Piece::WHITE: std::cout ○ ; break; // 或 W } } std::cout std::endl; } } static void printGameState(GameState state) { switch (state) { case GameState::BLACK_TURN: std::cout 黑方落子 (B): ; break; case GameState::WHITE_TURN: std::cout 白方落子 (W): ; break; case GameState::BLACK_WIN: std::cout 游戏结束黑方获胜 std::endl; break; // ... 其他状态 } } };交互部分我们需要解析玩家的输入。可以约定输入格式为“列号行号”比如H8表示第8行第H列对应内部坐标(7,7)。这里涉及到简单的字符串解析和坐标转换。// 在 main 函数或某个循环中 std::string input; std::cin input; if (input.size() 2) { char colChar toupper(input[0]); // 列字母 int row std::stoi(input.substr(1)) - 1; // 行数字转成0-based索引 int col colChar - A; // 列字母转成0-based索引 if (game.placePiece(col, row)) { // 落子成功重绘棋盘 ConsoleUI::printBoard(game.getBoard()); // 检查并打印游戏状态 ConsoleUI::printGameState(game.getState()); } else { std::cout 无效落子请重新输入。 std::endl; } }3.2 逻辑验证与边界测试在控制台环境下你可以非常方便地进行单元测试和边界测试。这是保证核心逻辑健壮性的黄金时期。你需要系统性地测试以下情况正常落子与显示在空位落子棋盘显示是否正确更新。无效落子在已有棋子的位置落子应被拒绝。输入超出棋盘范围的坐标如Z20应被拒绝。输入非法格式如8H,HH程序应能优雅处理而不崩溃。胜负判定横向五连在棋盘中间、左边、右边分别测试。纵向五连在棋盘上边、中间、下边分别测试。斜向五连两个对角线方向特别注意靠近边界的五连这是容易出bug的地方。长连连续六子或以上你的判定逻辑是判胜还是无视这取决于你采用的规则标准五子棋规则中长连一般不计胜负但有些变体规则算赢。你需要明确并测试。平局判定可以创建一个小的棋盘比如3x3快速下满测试平局状态是否能正确触发。状态流转测试游戏从开始到轮流落子到结束再到重新开始整个状态循环是否正确。我个人的习惯是在Board和GameLogic类中编写一些静态的测试函数或者在main函数开头跑一遍这些测试。用assert宏或者简单的if判断输出结果确保所有基础逻辑在引入图形界面前都是牢固的。void runBasicTests() { // 测试1棋盘初始化 Board b(15); assert(b.getPiece(7, 7) Piece::EMPTY); assert(b.isInBoard(0, 0) true); assert(b.isInBoard(15, 15) false); // 测试2落子与获取 assert(b.placePiece(7, 7, Piece::BLACK) true); assert(b.getPiece(7, 7) Piece::BLACK); assert(b.placePiece(7, 7, Piece::WHITE) false); // 重复落子应失败 // 测试3胜负判定模拟一个横向胜利 Board testWinBoard(15); // 手动在 (5,10) 到 (9,10) 放置黑子 for(int x5; x9; x) testWinBoard.placePiece(x, 10, Piece::BLACK); // 检查 (7,10) 是否构成胜利 assert(GameLogic::checkWin(testWinBoard, 7, 10, Piece::BLACK) true); std::cout 所有基础测试通过 std::endl; }4. 图形界面集成从控制台到窗口当你的核心逻辑在控制台下运行得稳稳当当之后就可以开始给它“穿上衣服”了。我们将使用 EasyX 图形库。EasyX 是一个为 C/C 设计的轻量级图形库它封装了 Windows GDI 的复杂接口提供了极其简单的绘图和消息处理函数特别适合初学者快速做出有图形界面的程序。4.1 EasyX图形库的初始化与基础绘图首先你需要去 EasyX 官网下载并安装它。安装后在你的 Visual Studio 项目中只需要包含graphics.h头文件并在链接器设置中添加对应的库即可。一个最简单的 EasyX 程序框架如下#include graphics.h // 引用 EasyX 图形库 #include conio.h int main() { initgraph(640, 480); // 初始化一个 640x480 的图形窗口 // 在这里进行绘图操作 circle(100, 100, 50); // 画圆 getch(); // 按任意键继续 closegraph(); // 关闭图形窗口 return 0; }对于五子棋我们的初始化需要更精细一些。我们需要计算棋盘绘制的起始位置、格子大小并绘制出棋盘网格、坐标标记等静态元素。// GraphicsUI.h #pragma once #include “Game.h” #include graphics.h #include string class GraphicsUI { public: GraphicsUI(int boardSize 15, int gridWidth 30, int margin 50) : m_boardSize(boardSize), m_gridWidth(gridWidth), m_margin(margin) { // 计算窗口大小 int winWidth 2 * margin gridWidth * (boardSize - 1); int winHeight 2 * margin gridWidth * (boardSize - 1) 100; // 下方留出信息区 initgraph(winWidth, winHeight); setbkcolor(RGB(240, 218, 164)); // 设置背景色为木质黄色 cleardevice(); } ~GraphicsUI() { closegraph(); } void drawStaticBoard() { // 1. 绘制棋盘网格线 setlinecolor(BLACK); setlinestyle(PS_SOLID, 2); for (int i 0; i m_boardSize; i) { // 横线 int y m_margin i * m_gridWidth; line(m_margin, y, m_margin (m_boardSize-1)*m_gridWidth, y); // 竖线 int x m_margin i * m_gridWidth; line(x, m_margin, x, m_margin (m_boardSize-1)*m_gridWidth); } // 2. 绘制棋盘上的五个小黑点天元和星 solidcircle(m_margin 3 * m_gridWidth, m_margin 3 * m_gridWidth, 5); solidcircle(m_margin 11 * m_gridWidth, m_margin 3 * m_gridWidth, 5); // ... 绘制其他三个点 // 3. 绘制坐标标记A, B, C... 和 1, 2, 3... settextcolor(BLACK); settextstyle(20, 0, _T(“宋体”)); for (int i 0; i m_boardSize; i) { TCHAR colText[2] { _T(‘A’) i, _T(‘\0’) }; outtextxy(m_margin i * m_gridWidth - 8, m_margin - 30, colText); TCHAR rowText[3]; _stprintf_s(rowText, _T(“%d”), i 1); outtextxy(m_margin - 30, m_margin i * m_gridWidth - 10, rowText); } } // 将逻辑坐标 (boardX, boardY) 转换为屏幕像素坐标 std::pairint, int boardToScreen(int boardX, int boardY) const { int screenX m_margin boardX * m_gridWidth; int screenY m_margin boardY * m_gridWidth; return { screenX, screenY }; } // 将屏幕像素坐标 (screenX, screenY) 转换为最近的逻辑坐标 std::pairint, int screenToBoard(int screenX, int screenY) const { // 计算离哪个交叉点最近 int boardX (screenX - m_margin m_gridWidth / 2) / m_gridWidth; int boardY (screenY - m_margin m_gridWidth / 2) / m_gridWidth; // 确保坐标在棋盘范围内 boardX max(0, min(m_boardSize - 1, boardX)); boardY max(0, min(m_boardSize - 1, boardY)); return { boardX, boardY }; } private: int m_boardSize; int m_gridWidth; // 每个格子的像素宽度 int m_margin; // 棋盘边距 };4.2 鼠标交互与棋子绘制图形界面的核心是交互。在 EasyX 中我们通过GetMouseMsg()函数来获取鼠标消息。我们需要在游戏主循环中不断监听鼠标左键按下事件然后将点击的屏幕坐标转换为棋盘逻辑坐标再调用之前写好的Game::placePiece逻辑。void GraphicsUI::runGameLoop(Game game) { drawStaticBoard(); updateGameInfo(game.getState()); // 绘制初始游戏信息如“黑方先行” while (true) { // 获取鼠标消息 if (MouseHit()) { // 检查是否有鼠标消息 MOUSEMSG msg GetMouseMsg(); if (msg.uMsg WM_LBUTTONDOWN) { // 鼠标左键按下 // 转换坐标 auto [boardX, boardY] screenToBoard(msg.x, msg.y); // 调用游戏核心逻辑落子 if (game.placePiece(boardX, boardY)) { // 落子成功重绘整个棋盘包括所有棋子 redrawBoard(game.getBoard()); updateGameInfo(game.getState()); // 检查游戏是否结束 if (game.getState() GameState::BLACK_WIN || game.getState() GameState::WHITE_WIN || game.getState() GameState::DRAW) { // 游戏结束可以弹出提示或等待重启 MessageBox(GetHWnd(), _T(“游戏结束”), _T(“提示”), MB_OK); break; } } } } // 可以加入一个小的延时避免循环空转占用过高CPU Sleep(10); } }redrawBoard函数需要遍历整个棋盘在每一个有棋子的交叉点上绘制棋子。绘制棋子时为了美观我们通常使用渐变填充来模拟立体感。EasyX 提供了setfillcolor和solidcircle但简单的实心圆不够好看。我们可以用gradientfill函数或者更简单地画两个同心圆来模拟。void GraphicsUI::drawPiece(int boardX, int boardY, Piece piece) { auto [centerX, centerY] boardToScreen(boardX, boardY); int radius m_gridWidth / 2 - 2; // 棋子半径略小于格子一半 if (piece Piece::BLACK) { // 绘制黑棋一个深灰色实心圆外加一个更小的亮色圆模拟高光 setfillcolor(BLACK); solidcircle(centerX, centerY, radius); setfillcolor(DARKGRAY); solidcircle(centerX - radius/3, centerY - radius/3, radius/4); } else if (piece Piece::WHITE) { // 绘制白棋一个白色实心圆外加灰色边框 setfillcolor(WHITE); setlinecolor(DARKGRAY); setlinestyle(PS_SOLID, 1); fillcircle(centerX, centerY, radius); circle(centerX, centerY, radius); // 同样可以加一个更小的白色圆作为高光 setfillcolor(LIGHTGRAY); solidcircle(centerX - radius/3, centerY - radius/3, radius/5); } } void GraphicsUI::redrawBoard(const Board board) { // 先清空棋盘区域重绘静态棋盘 setfillcolor(RGB(240, 218, 164)); solidrectangle(m_margin - 1, m_margin - 1, m_margin (m_boardSize-1)*m_gridWidth 1, m_margin (m_boardSize-1)*m_gridWidth 1); drawStaticBoard(); // 重画网格和坐标 // 遍历棋盘绘制所有棋子 for (int y 0; y m_boardSize; y) { for (int x 0; x m_boardSize; x) { Piece p board.getPiece(x, y); if (p ! Piece::EMPTY) { drawPiece(x, y, p); } } } }4.3 界面美化与用户体验优化一个基本的图形界面已经完成了。但要让游戏看起来更专业、用起来更舒服还需要一些优化落子高亮/音效在玩家点击落子时除了绘制棋子可以短暂地在落子点绘制一个闪烁的光圈或者播放一个简短的音效EasyX 支持mciSendString播放音频文件给予即时反馈。游戏信息显示区在棋盘下方开辟一个区域用outtextxy或RECT结构配合drawtext函数动态显示当前行棋方、获胜信息、步数等。悔棋功能在界面上添加一个“悔棋”按钮可以用一个矩形区域模拟点击后调用Game::undoLastMove()然后重绘棋盘。这需要你的Board类已经实现了落子历史记录。重新开始与退出同样添加“重新开始”和“退出”按钮完善游戏流程。抗锯齿EasyX 默认绘图没有抗锯齿棋子边缘可能有锯齿感。可以尝试使用putimage配合提前绘制好的、带透明通道的棋子图片PNG格式来获得更平滑的效果。这需要用到IMAGE对象和LoadImage函数。自适应布局目前窗口大小是固定的。你可以监听WM_SIZE消息在 EasyX 中处理起来稍复杂或者根据棋盘大小动态计算gridWidth和margin让棋盘在不同分辨率下都能较好地显示。实操心得图形与逻辑的解耦这是本项目最重要的设计经验之一。注意看我们的GraphicsUI类只负责“显示”和“输入”它通过调用Game和Board的公共接口来获取数据和改变状态。Game类完全不知道自己是运行在控制台还是图形窗口下。这种“模型-视图-控制器”MVC模式的雏形使得代码结构清晰易于维护和扩展。比如未来你想把图形库从 EasyX 换成 Qt 或者 MFC你只需要重写GraphicsUI这个“视图”层核心的游戏逻辑Game和Board完全不需要改动。5. 项目扩展与进阶思考完成一个基础的人人对战五子棋后你的编程能力已经上了一个台阶。但学习不止于此这里有几个方向可以让你把这个项目打磨得更加出色甚至成为你简历上的一个亮点。5.1 实现一个简单的AI对手让电脑作为对手是游戏项目质的飞跃。五子棋AI可以从简到繁初级AI随机落子在所有空位中随机选择一个。这很简单但毫无挑战。中级AI基于规则的启发式搜索这是最实用也最有学习价值的。核心思想是评分函数。我们为棋盘上每个空位计算一个“分数”分数越高表示在这个位置落子潜在价值越大既能进攻成五又能防守阻止对方。如何评分可以定义一些棋型并赋予权重。例如连五极高的分数直接获胜。活四两头无阻挡的四子极高的分数。冲四一头被堵的四子高分数。活三、眠三、活二等等分别赋予递减的分数。实现步骤遍历所有空位。假设我方AI在此落子评估形成的棋型计算一个“进攻分”。假设对方在此落子评估形成的棋型计算一个“防守分”防止对方在此形成好形。将该空位的总分设为进攻分和防守分的加权和例如进攻权重0.6防守权重0.4。选择总分最高的空位落子。优化不需要全局遍历只需要在上一步棋的周围一定范围内比如3格内的空位进行评估即可这能大幅提升速度。高级AI博弈树搜索如 Minimax 算法配合 Alpha-Beta 剪枝甚至蒙特卡洛树搜索MCTS。这涉及到更深的算法知识可以作为后续的挑战目标。5.2 网络对战功能的架构设想要实现联机对战项目就从单机应用升级为了网络应用。你需要引入网络编程的知识如 Windows 的 Socket 编程或使用跨平台的库如 Boost.Asio。一个简单的客户端-服务器架构如下服务器负责管理游戏房间、匹配玩家、转发双方的落子坐标、判定胜负或由客户端判定后上报验证。客户端包含现有的所有游戏逻辑和界面增加网络通信模块。当本地玩家落子后将坐标发送给服务器同时不断从服务器接收对手的落子坐标更新本地棋盘。关键点在于状态同步和回合控制。必须确保在任何时刻两个客户端看到的棋盘状态是一致的。通常由服务器作为权威协调双方的行动顺序。5.3 代码重构与工程化建议当你把所有功能都塞进main.cpp后代码会变得难以阅读和维护。是时候进行重构了模块化将不同的功能分离到不同的头文件.h和源文件.cpp中。例如Board.h/cpp棋盘数据模型。GameLogic.h/cpp纯算法如胜负判定、AI评分。Game.h/cpp游戏流程和状态管理。ConsoleUI.h/cpp控制台界面。GraphicsUI.h/cppEasyX图形界面。Network.h/cpp如果实现网络通信模块。使用智能指针管理资源如果你的项目中有动态分配的资源考虑使用std::unique_ptr或std::shared_ptr避免内存泄漏。引入配置文件将棋盘大小、颜色、服务器地址等配置项写入一个config.ini或config.json文件让程序更灵活。使用版本控制从一开始就使用 Git 来管理你的代码。为每个大的功能点如“完成核心逻辑”、“添加图形界面”、“实现初级AI”创建单独的分支最后合并到主分支。这不仅是良好的习惯也是你项目经验的证明。从控制台的黑白字符到图形窗口的立体棋子从手动的胜负判断到简单的AI策略这个五子棋项目就像一棵技能树你可以沿着不同的分支不断深入。最重要的是你通过一个完整的项目将C的诸多知识点类、STL、算法、内存、第三方库串联了起来形成了属于自己的工程实践经验。下次当你面对一个更复杂的项目需求时你会清晰地知道该如何拆解、设计、实现和测试这才是本项目带给你的最大价值。