
1. 项目概述从零到一用C构建一个经典的五子棋游戏最近在整理过去的项目笔记翻到了这个用C写的五子棋游戏。这算是我早期学习C时一个比较有代表性的练手项目麻雀虽小五脏俱全。它不只是一个简单的棋盘绘制和落子逻辑更是一次对C面向对象思想、数据结构、算法乃至简单AI策略的综合实践。很多朋友尤其是计算机专业的同学可能都做过或想做一个类似的小游戏但往往卡在如何组织代码、如何实现人机对战、如何让程序更“优雅”上。今天我就把当时的开发笔记整理出来结合我后来的一些思考希望能为你提供一个清晰、可复现的参考路径。无论你是刚学完C基础语法想找个项目练手还是对游戏逻辑实现感兴趣这篇文章都能带你走完从棋盘表示到实现一个基础“电脑对手”的全过程。2. 核心设计思路与架构拆解2.1 为什么选择C和命令行界面在开始敲代码之前先聊聊技术选型。用C做五子棋尤其是命令行版本听起来有点“复古”但这恰恰是它的优势所在。首先C能让你聚焦于核心逻辑。不用分心于复杂的图形界面库如Qt、SDL的API学习你可以把所有精力放在游戏最本质的部分棋盘的数据结构、胜负判定算法、人机交互逻辑上。这对于理解游戏开发的底层思维至关重要。很多现代游戏引擎如Unity、Unreal的底层逻辑模块其思想与这个命令行小游戏是相通的。其次命令行界面简化了输入输出。我们通过坐标如“H8”来落子通过清屏和重绘来刷新棋盘。这迫使你去思考如何将抽象的游戏状态一个二维数组清晰地呈现给用户如何设计友好的交互提示。这种“数据驱动视图”的思想是任何交互程序的基础。最后这是一个绝佳的面向对象设计练习。五子棋游戏天然地可以划分为几个核心类Game游戏总控、Board棋盘、Player玩家可派生出HumanPlayer和AIPlayer。通过类的封装你可以让代码结构清晰职责分明便于后续扩展比如很容易就能加入网络对战功能。2.2 整体架构设计基于以上思路我设计的项目结构大致如下。这不是唯一的方案但经过实践验证它足够清晰和灵活。五子棋项目控制台版 ├── 核心数据与逻辑层 │ ├── Board类负责棋盘的存储、落子校验、胜负判定。 │ ├── Piece枚举或结构表示棋子黑、白、空。 │ └── Position结构表示棋盘上的一个位置行列。 ├── 玩家层 │ ├── Player基类定义玩家接口如getMove(Board)。 │ ├── HumanPlayer类从控制台读取用户输入。 │ └── AIPlayer类实现简单的AI落子逻辑如随机落子、基于评分的贪心算法。 ├── 游戏控制层 │ └── Game类组合Board和Player控制游戏流程轮流落子、判断结束、显示结果。 └── 视图层控制台 └── 一系列函数负责将Board对象的状态以文本如、●、○形式打印到控制台。这个架构的核心思想是分离关注点。Board只关心棋盘状态和规则Player只关心如何产生一个落子位置Game是导演协调各方视图函数则负责展示。这样如果你想将来把命令行界面换成图形界面只需要重写视图层核心逻辑几乎不用动。注意在项目初期不必追求过于复杂的设计模式。先让程序跑起来再逐步重构优化。这个架构是一个良好的起点能有效避免代码很快变成一团乱麻。3. 核心模块实现详解3.1 棋盘Board类的设计与实现棋盘是整个游戏的状态核心。我的实现中Board类主要包含以下几个部分1. 数据成员class Board { private: static const int BOARD_SIZE 15; // 标准五子棋棋盘15x15 std::vectorstd::vectorPiece grid; // 二维向量存储棋子 // Piece 可以是枚举 enum class Piece { EMPTY, BLACK, WHITE }; public: // ... };这里使用std::vector而不是原生二维数组主要是为了安全性和便利性如方便获取大小。BOARD_SIZE定义为常量便于修改棋盘尺寸。2. 关键方法实现初始化与重置构造函数中将grid初始化为BOARD_SIZE x BOARD_SIZE的矩阵所有元素设为Piece::EMPTY。落子placePiecebool placePiece(int row, int col, Piece piece) { if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) { return false; // 位置越界 } if (grid[row][col] ! Piece::EMPTY) { return false; // 该位置已有棋子 } grid[row][col] piece; return true; }这个方法返回一个布尔值告知调用者落子是否成功。这比直接在里面打印错误信息要好因为控制错误提示的职责属于Game或视图层。胜负判定checkWin这是五子棋算法的核心。判定逻辑是在落子点(row, col)的八个方向水平、垂直、两条对角线上检查是否存在连续五个同色棋子。Piece checkWin(int lastRow, int lastCol) const { Piece currentPiece grid[lastRow][lastCol]; if (currentPiece Piece::EMPTY) return Piece::EMPTY; // 四个方向向量(行增量, 列增量) int dirs[4][2] { {1, 0}, {0, 1}, {1, 1}, {1, -1} }; for (auto dir : dirs) { int count 1; // 当前位置已经有一个棋子 // 向正方向检查 for (int step 1; step 5; step) { int r lastRow dir[0] * step; int c lastCol dir[1] * step; if (r 0 || r BOARD_SIZE || c 0 || c BOARD_SIZE || grid[r][c] ! currentPiece) break; count; } // 向反方向检查 for (int step 1; step 5; step) { int r lastRow - dir[0] * step; int c lastCol - dir[1] * step; if (r 0 || r BOARD_SIZE || c 0 || c BOARD_SIZE || grid[r][c] ! currentPiece) break; count; } if (count 5) { return currentPiece; // 返回获胜的棋子颜色 } } return Piece::EMPTY; // 无人获胜 }为什么只检查最后落子点因为五子棋的胜利条件必然包含最后落下的那颗子。全局扫描棋盘虽然直观但效率低下O(N²)。这种基于最后落子点的局部扫描其时间复杂度是常数级的O(1)是更优解。判断棋盘是否已满isFull遍历棋盘如果找不到EMPTY位置即为平局。3.2 玩家Player类的实现玩家是一个抽象概念通过基类Player定义接口。class Player { protected: Piece pieceColor; // 该玩家持有的棋子颜色 std::string name; public: Player(Piece color, const std::string n) : pieceColor(color), name(n) {} virtual ~Player() default; // 纯虚函数子类必须实现如何获取落子位置 virtual bool getMove(const Board board, int outRow, int outCol) 0; Piece getPieceColor() const { return pieceColor; } std::string getName() const { return name; } };人类玩家HumanPlayer实现getMove函数从标准输入std::cin读取用户输入的坐标。这里需要处理输入格式如“A1”“8,H”并将其转换为棋盘内部的(row, col)索引。关键点在于输入校验和错误恢复要能友好地处理用户输错的情况。AI玩家AIPlayer这是项目的趣味点和难点所在。一个最简单的AI是随机落子bool AIPlayer::getMove(const Board board, int outRow, int outCol) { // 获取所有空位 std::vectorstd::pairint, int emptyCells; for (int i 0; i Board::BOARD_SIZE; i) { for (int j 0; j Board::BOARD_SIZE; j) { if (board.getPieceAt(i, j) Piece::EMPTY) { emptyCells.emplace_back(i, j); } } } if (emptyCells.empty()) return false; // 随机选择一个空位 std::srand(static_castunsigned(std::time(nullptr))); int index std::rand() % emptyCells.size(); outRow emptyCells[index].first; outCol emptyCells[index].second; // 可以在这里加一个短暂延时模拟AI思考 std::this_thread::sleep_for(std::chrono::milliseconds(500)); return true; }当然随机AI毫无战斗力。一个显著的改进是实现一个简单的评估函数。例如扫描整个棋盘为每个空位打分分数基于如果在此落子能形成多少“活二”、“冲三”、“活四”等棋型。AI选择分数最高的位置落子。这就构成了一个贪心算法。虽然离真正的博弈AI如Minimax算法配合Alpha-Beta剪枝还有距离但已经能提供一个像样的初级对手了。实操心得在实现AI时不要一开始就追求复杂的算法。先从随机开始确保接口畅通。然后实现一个基于简单规则的评估函数比如优先堵住对方的“活三”你会发现游戏体验立刻提升一个档次。这种迭代开发的方式能持续获得正反馈。3.3 游戏主控Game类的流程编排Game类像是一个导演它持有Board对象和两个Player对象一个人类一个AI或两个人类并控制游戏的主循环。class Game { private: Board board; std::unique_ptrPlayer player1; std::unique_ptrPlayer player2; Player* currentPlayer; public: void start() { currentPlayer player1.get(); while (true) { // 1. 清屏并显示棋盘 system(cls); // Windows, Linux/Mac用 clear printBoard(board); // 2. 显示当前轮到谁 std::cout currentPlayer-getName() s turn ( pieceToChar(currentPlayer-getPieceColor()) ) std::endl; // 3. 获取当前玩家的落子 int row, col; bool validMove false; while (!validMove) { if (!currentPlayer-getMove(board, row, col)) { std::cout Failed to get a move. Game over? std::endl; return; } validMove board.placePiece(row, col, currentPlayer-getPieceColor()); if (!validMove) { // 如果是人类玩家提示位置无效AI理论上不应产生无效位置 if (dynamic_castHumanPlayer*(currentPlayer)) { std::cout Invalid position. Try again. std::endl; } } } // 4. 检查胜负 Piece winner board.checkWin(row, col); if (winner ! Piece::EMPTY) { printBoard(board); std::cout getPlayerByPiece(winner)-getName() wins! std::endl; break; } // 5. 检查平局 if (board.isFull()) { printBoard(board); std::cout The game is a draw! std::endl; break; } // 6. 切换玩家 switchPlayer(); } } void switchPlayer() { currentPlayer (currentPlayer player1.get()) ? player2.get() : player1.get(); } };主循环的逻辑非常清晰显示 - 获取落子 - 更新状态 - 判断结束 - 切换回合。这是绝大多数回合制游戏的通用框架。4. 控制台界面优化与交互细节4.1 棋盘的绘制在命令行中绘制一个清晰的棋盘是用户体验的关键。我的做法是打印列坐标A-O。对于每一行先打印行号1-15然后遍历该行的每一列根据棋子状态打印相应的字符如.表示空表示黑子O表示白子并用|或作为格子分隔线。使用Windows的system(cls)或Linux/macOS的system(clear)在每次落子后清屏实现棋盘刷新。一个改进点是使用Windows的Console API或跨平台的库如ncurses来实现更精细的光标控制避免全屏闪烁。但对于入门项目清屏重绘完全够用。4.2 输入处理与错误恢复对于人类玩家getMove函数需要稳健地处理各种输入。我推荐以下步骤提示用户输入如“请输入坐标例如H8”。使用std::getline(std::cin, input)读取一整行。解析输入字符串。可以支持多种格式“H8”, “8H”, “8,H”。核心是分离出字母部分和数字部分。将字母A-O转换为列索引0-14数字转换为行索引注意用户输入1对应内部索引0。在Board::placePiece中完成最终的边界和占用校验并将错误信息反馈给用户循环重新输入。一个常见的坑混合使用std::cin 和std::getline会导致换行符残留问题。统一使用std::getline来读取所有用户输入是更安全的选择。5. 从简单AI到进阶策略5.1 实现一个基于评分的贪心AI随机AI太弱我们可以给它一个“大脑”。思路是为每一个空位计算一个分数分数越高表示在此落子价值越大。评分规则示例非常基础可自行扩展成五直接获胜分数极高例如100000分。活四两头无阻挡的四连子次高分数例如10000分。冲四一头被堵的四连子较高分数例如5000分。活三中等分数例如1000分。活二基础分数例如100分。同时考虑进攻和防守计算如果“我方”在此落子能形成的棋型进攻分以及如果“对方”在此落子能形成的棋型防守分即阻止对方得分。总分为进攻分加上防守分的一个权重例如1.2倍。AI的getMove函数就变为遍历所有空位计算分数选择分数最高的位置落子。这个算法虽然仍是贪心只考虑一步但棋力相比随机已有质的飞跃。5.2 性能优化与预计算当棋盘为空或较满时遍历所有15x15225个空位并逐个评分是可以接受的。但随着棋局进行空位减少计算量不大。但如果评分函数变得复杂或者你想实现更深度的搜索如后面提到的Minimax性能就会成为瓶颈。优化技巧增量更新每次落子只影响落子点周围一定范围比如米字型10格以内的空位评分无需全局重算。预计算棋型表将常见的棋型如“活三”、“冲四”及其分数预先计算好并存储为模式pattern评分时进行模式匹配比实时计算快。使用位棋盘Bitboard对于棋盘类游戏使用一个unsigned long long64位的每一位来表示棋盘上一个点的状态可以利用CPU的位运算指令极大加速棋型判断和全局扫描。这是高级AI的常用技巧。6. 项目构建、调试与扩展建议6.1 开发环境与构建我使用的是Visual StudioWindows或VSCode CMake/Make跨平台。对于初学者Visual Studio的集成调试体验非常好。确保你的项目包含正确的头文件如iostream,vector,string,ctime等。项目文件结构可以很简单Gomoku/ ├── src/ │ ├── main.cpp // 程序入口创建Game并启动 │ ├── Board.cpp/h │ ├── Player.cpp/h │ ├── HumanPlayer.cpp/h │ ├── AIPlayer.cpp/h │ └── Game.cpp/h └── CMakeLists.txt 或 Makefile6.2 常见问题与调试技巧胜负判定错误最常见的原因是checkWin函数的方向检查有误或者计数逻辑count的初始化、递增有bug。调试方法在判定函数内添加详细的日志打印出每个方向检查的(r, c)坐标和棋子状态对照棋盘手动验证。可以专门写几个测试用例如水平五连、垂直五连、两种对角线的五连。内存访问越界在访问grid[row][col]时务必先检查row和col是否在[0, BOARD_SIZE)范围内。这是C程序崩溃的常见原因。使用vector的.at()方法会进行边界检查在调试阶段有助于发现问题虽然性能稍差。AI卡死或落子异常慢如果实现了评分AI检查评分函数中是否有死循环或极其低效的操作如嵌套过深的循环。调试方法在AI思考时输出其评估的空位数量和耗时。对于简单的贪心算法在15x15棋盘上思考时间不应超过1秒。输入流状态混乱如前所述统一使用std::getline并在每次读取前确保输入流是干净的。如果使用std::cin 后跟std::getline记得用std::cin.ignore()清除残留的换行符。6.3 项目扩展方向当你完成了基础版本后可以尝试以下扩展让项目更具挑战性和学习价值实现Minimax算法这是博弈AI的经典算法。给你的AI一个搜索深度例如3层让它不仅考虑当前最好的一步还考虑对手会如何应对从而做出更优的长期决策。结合Alpha-Beta剪枝可以大幅提升搜索效率。加入开局库和终局库预先存储一些经典开局走法和高胜率的终局定式可以快速提升AI在开局和残局阶段的表现。图形化界面用Qt或SFML等库重写视图层。你将学习到事件驱动编程、图形绘制、控件使用等新知识。核心的Board和Player类可以几乎无缝复用。网络对战将Player抽象为网络客户端实现一个简单的客户端-服务器架构支持两人远程对战。这会涉及到Socket编程和简单的通信协议设计。支持多种游戏模式如禁手规则、棋盘大小可选、三人对战等。这考验你对核心规则类的抽象和扩展能力。回过头看这个五子棋项目虽然代码量不大但它像是一个微型的软件工程演练场。你实践了面向对象设计、模块化开发、算法实现、用户交互处理、甚至初步的AI策略。它最大的价值不在于做出了一个多炫酷的游戏而在于你亲手将一个问题分解、设计、实现、调试、最终运行起来的完整过程。这种能力是看再多教程也无法替代的。我建议你在实现过程中多思考“为什么这样设计”并勇于尝试自己的改进。比如你觉得我的评分函数规则太简单完全可以设计一套更复杂的你觉得棋盘绘制不好看可以研究如何用字符画出更精美的效果。编程的乐趣就在于此。