资讯动态

C++与EasyX实现华容道:从数据结构到游戏逻辑的完整实践

发布时间:2026/8/11 3:52:24 来源:尧图企业网站定制
1. 项目概述与核心价值最近在整理旧项目时翻出了一个用C和EasyX图形库实现的经典华容道小游戏。这个项目虽然不大但麻雀虽小五脏俱全它完整地串联了数据结构、算法逻辑、图形界面交互和游戏状态管理是一个绝佳的C练手项目。华容道本身是一个经典的滑块拼图游戏其核心是让“曹操”方块从初始布局移动到棋盘下方的出口。用代码实现它难点不在于图形绘制而在于如何优雅地建模棋盘状态、处理方块移动的逻辑碰撞、以及实现一个清晰易懂的交互循环。我选择EasyX图形库的原因很简单它足够轻量、学习曲线平缓能让我把主要精力集中在游戏逻辑本身而不是与复杂的Windows API或现代图形引擎搏斗。对于C初学者或想快速实现一个可视化小项目的开发者来说EasyX是一个能让你快速获得正反馈的工具。通过这个项目你不仅能重温回溯算法在求解中的应用更能实践面向对象的设计思想将游戏中的棋盘、方块、游戏控制器等实体抽象成类体会模块化编程的乐趣。接下来我会详细拆解整个项目的设计思路、关键代码实现并分享在开发过程中踩过的坑和总结的经验。2. 整体架构与核心类设计实现一个华容道游戏首要任务是将游戏中的实体和逻辑进行合理的抽象。一个清晰的设计是后续编码顺利进行的保障。我采用了经典的MVC模型-视图-控制器模式思想进行架构虽然对于这个小游戏来说可能有点“杀鸡用牛刀”但它能让代码结构非常清晰。2.1 核心数据模型设计游戏的核心是棋盘状态。华容道的棋盘可以看作一个网格每个格子要么为空要么被一个方块占据。方块有大小之分如2x2的曹操1x2的关羽等。因此我设计了两个核心类Block方块和Board棋盘。Block类需要记录方块的基本属性它在棋盘上的左上角坐标(row, col)、方块的宽度和高度以网格为单位、以及一个唯一的标识符如用于区分曹操、关羽等。此外为了绘制和交互还需要知道方块在屏幕上的像素坐标和颜色。class Block { public: int id; // 方块唯一ID如0代表曹操1代表关羽等 int width; // 方块占用的网格宽度 int height; // 方块占用的网格高度 int row, col; // 方块左上角在棋盘网格中的位置 COLORREF color; // 方块显示颜色 // 构造函数初始化方块 Block(int id, int w, int h, int r, int c, COLORREF clr); // 绘制方块到屏幕 void Draw(int gridSize, int offsetX, int offsetY) const; // 判断一个网格点(row, col)是否被此方块占据 bool Contains(int r, int c) const; };Board类是游戏状态的容器。它内部维护一个二维数组或向量来表示网格的占用情况以及一个Block对象的列表。最关键的方法是MoveBlock它接受一个方块ID和移动方向上、下、左、右并判断此次移动是否合法如果合法则更新方块位置和棋盘占用状态。class Board { private: int rows, cols; // 棋盘网格行列数经典华容道为5行4列 std::vectorstd::vectorint grid; // 网格存储占用方块的ID-1表示空 std::vectorBlock blocks; // 所有方块的集合 int exitRow, exitCol; // 出口位置曹操的目标位置 public: Board(); // 从预定义布局初始化棋盘和方块 void InitFromLayout(const std::vectorstd::vectorint layout); // 尝试移动指定ID的方块 bool MoveBlock(int blockId, int dir); // dir: 0上,1下,2左,3右 // 检查游戏是否胜利曹操是否到达出口 bool IsWin() const; // 绘制整个棋盘和所有方块 void Draw(int gridSize, int offsetX, int offsetY) const; // 获取棋盘当前状态的唯一哈希值用于求解器或重复状态检测 std::string GetStateHash() const; };这里有一个设计细节grid网格存储的是占用方块的ID而不是简单的布尔值。这样在MoveBlock时要判断目标位置是否可移动就需要检查目标区域的所有格子是否都为-1空或者被当前移动的方块自身占据。Contains方法在这里就派上了用场。2.2 游戏控制器与视图渲染有了模型就需要控制器来驱动游戏流程以及视图来呈现画面。我将这两部分合并在一个Game类中它负责主循环、事件处理和协调Board与图形界面。Game类的主要工作流程是初始化图形窗口-初始化棋盘-进入主循环。在主循环中依次处理用户输入鼠标或键盘、更新游戏逻辑、渲染画面。class Game { private: Board board; int selectedBlockId; // 当前鼠标选中的方块ID int gridSize; // 每个网格的像素大小 int offsetX, offsetY; // 棋盘在窗口中的绘制偏移量 public: Game(); void Run(); private: void ProcessInput(); void Update(); void Render(); // 将屏幕像素坐标转换为棋盘网格坐标 bool ScreenToGrid(int screenX, int screenY, int outGridRow, int outGridCol); };视图渲染主要依靠EasyX库的绘图函数。在Block::Draw和Board::Draw中我们会使用setfillcolor、setlinecolor、fillrectangle等函数将方块的网格位置换算为像素坐标然后绘制出带有边框的彩色矩形。为了美观还可以给方块添加圆角或简单的渐变效果。注意EasyX的坐标系统原点在窗口左上角Y轴向下为正。在计算方块绘制坐标时公式通常是pixelX offsetX col * gridSize; pixelY offsetY row * gridSize;。确保你的偏移量计算正确让棋盘在窗口中居中显示。2.3 预定义游戏布局与初始化华容道有无数种开局布局。为了简化我通常在代码中硬编码几种经典布局比如“横刀立马”。Board::InitFromLayout方法接收一个二维的int向量其中每个数字代表一个特定大小方块的ID。通过解析这个布局矩阵我们可以创建出对应的Block对象并设置好它们的初始位置。// 示例“横刀立马”布局的简化表示 // 数字含义-1:空0:曹操(2x2)1:关羽(1x2 竖)2:张飞(1x2竖)3:赵云(1x2竖)4:黄忠(1x2竖)5:马超(1x2横)6-9:小兵(1x1) std::vectorstd::vectorint classicLayout { { 0, 0, 1, -1}, { 0, 0, 1, -1}, { 5, 5, 2, 3}, { -1, 4, 2, 3}, { -1, 4, -1, -1} }; void Board::InitFromLayout(const std::vectorstd::vectorint layout) { // 清空现有数据 rows layout.size(); cols layout[0].size(); grid.assign(rows, std::vectorint(cols, -1)); blocks.clear(); // 需要先统计每个ID方块出现的次数和位置以确定其大小和初始左上角 // 这里是一个简化示例实际实现需要更复杂的解析来合并相邻相同ID的格子形成方块对象。 // 更常见的做法是直接根据已知布局手动创建Block对象。 blocks.emplace_back(0, 2, 2, 0, 0, RGB(200, 50, 50)); // 曹操红色 blocks.emplace_back(1, 1, 2, 0, 2, RGB(0, 150, 0)); // 关羽绿色 // ... 创建其他方块 // 创建完成后根据blocks更新grid for (const auto block : blocks) { for (int r 0; r block.height; r) { for (int c 0; c block.width; c) { grid[block.row r][block.col c] block.id; } } } }在实际项目中我推荐将布局数据存储在单独的配置文件如JSON中这样无需重新编译就能切换关卡扩展性更好。但对于第一个版本硬编码足以跑通核心逻辑。3. 关键算法与逻辑实现详解有了骨架接下来就是填充肌肉——实现最核心的移动逻辑、胜负判断以及一个可选的自动求解器。这部分代码的健壮性直接决定了游戏体验。3.1 方块移动的碰撞检测Board::MoveBlock是整个游戏逻辑的心脏。它的输入是一个方块ID和一个方向输出是移动是否成功。其内部逻辑可以分解为以下几步参数校验检查blockId是否有效。查找方块在blocks向量中找到对应ID的方块对象。计算目标位置根据方向计算出方块移动后其占据的网格区域。例如向右移动则新区域的列范围是[col1, colwidth]。边界检查确保目标区域没有超出棋盘边界。冲突检测遍历目标区域中的每一个网格。对于每一个网格(r, c)如果该网格当前被其他方块占据即grid[r][c] ! -1 且 grid[r][c] ! blockId则发生冲突移动非法。注意方块原来占据的区域在移动过程中被视为“空”所以需要排除自身。执行移动如果所有检查通过则将方块原占据网格在grid中的值设为-1。更新方块对象的row或col属性。将方块新占据的网格在grid中的值设为该方块的blockId。返回结果返回true表示移动成功。bool Board::MoveBlock(int blockId, int dir) { // 1. 查找方块 auto it std::find_if(blocks.begin(), blocks.end(), [blockId](const Block b) { return b.id blockId; }); if (it blocks.end()) return false; Block block *it; int newRow block.row; int newCol block.col; // 2. 计算目标区域 switch (dir) { case 0: newRow--; break; // 上 case 1: newRow; break; // 下 case 2: newCol--; break; // 左 case 3: newCol; break; // 右 default: return false; } // 3. 边界检查 if (newRow 0 || newCol 0 || newRow block.height rows || newCol block.width cols) { return false; } // 4. 冲突检测 for (int r newRow; r newRow block.height; r) { for (int c newCol; c newCol block.width; c) { int gridContent grid[r][c]; // 如果该格子不是空的也不是被自己占据则冲突 if (gridContent ! -1 gridContent ! blockId) { return false; } } } // 5. 执行移动先清空旧位置 for (int r block.row; r block.row block.height; r) { for (int c block.col; c block.col block.width; c) { grid[r][c] -1; } } // 更新方块位置 block.row newRow; block.col newCol; // 填充新位置 for (int r newRow; r newRow block.height; r) { for (int c newCol; c newCol block.width; c) { grid[r][c] blockId; } } return true; }3.2 游戏胜利条件判定胜利条件很简单曹操方块通常是ID为0的2x2方块的左上角移动到了指定的出口位置。经典布局中出口位于棋盘底部中央。因此Board::IsWin()方法只需要检查曹操方块的位置是否满足条件即可。bool Board::IsWin() const { // 假设曹操方块的ID是0出口在最后一行第1、2列0-based索引 const Block* caoCao nullptr; for (const auto block : blocks) { if (block.id 0) { caoCao block; break; } } if (!caoCao) return false; // 胜利条件曹操的底部接触到棋盘底部row height rows // 并且水平方向位于出口上方通常出口横跨两列 return (caoCao-row caoCao-height rows) (caoCao-col exitCol caoCao-col caoCao-width exitCol 2); // 假设出口宽度为2 }3.3 进阶自动求解器与BFS算法实现一个自动求解器是华容道项目一个有趣的延伸。这本质上是一个状态空间搜索问题。我们可以使用广度优先搜索BFS来寻找从初始状态到胜利状态的最短路径最少步数。核心思路状态表示将棋盘上所有方块的位置编码成一个唯一字符串或数字哈希作为BFS的节点。Board::GetStateHash()方法就是干这个的。一个简单的方法是将grid网格展平成一个字符串。状态转移对于一个给定状态即一个Board对象找出所有可能的下一步移动。即遍历所有方块对每个方块尝试四个方向的移动将移动后生成的新状态作为当前状态的邻居节点。BFS搜索队列中存储待探索的状态节点。使用一个unordered_map来记录每个状态的前驱状态和移动动作从哪个状态、移动了哪个方块、向哪个方向用于最终回溯路径。从初始状态开始将其加入队列和已访问集合。每次从队列取出一个状态检查是否胜利。如果是则通过前驱映射回溯得到移动序列。否则生成其所有合法的后续状态如果某个新状态未被访问过则将其加入队列并记录其前驱信息。路径输出BFS找到解后回溯得到的移动序列就是求解步骤。struct StateNode { std::string hash; // 从父状态到达此状态所执行的移动 int movedBlockId; int moveDirection; }; std::vectorstd::pairint, int BFSSolve(const Board initialBoard) { std::queueBoard q; std::unordered_mapstd::string, StateNode visited; // key: 状态哈希 value: 前驱节点信息 Board startBoard initialBoard; std::string startHash startBoard.GetStateHash(); q.push(startBoard); visited[startHash] {, -1, -1}; // 起始状态无前驱 while (!q.empty()) { Board current q.front(); q.pop(); std::string currentHash current.GetStateHash(); if (current.IsWin()) { // 回溯构建路径 std::vectorstd::pairint, int path; // pairblockId, direction std::string state currentHash; while (visited[state].movedBlockId ! -1) { path.emplace_back(visited[state].movedBlockId, visited[state].moveDirection); state visited[state].hash; // 前驱状态的哈希 } std::reverse(path.begin(), path.end()); return path; } // 生成所有可能的下一步状态 for (const auto block : current.GetBlocks()) { // 假设有GetBlocks方法 for (int dir 0; dir 4; dir) { Board nextBoard current; // 需要实现Board的拷贝构造函数 if (nextBoard.MoveBlock(block.id, dir)) { std::string nextHash nextBoard.GetStateHash(); if (visited.find(nextHash) visited.end()) { visited[nextHash] {currentHash, block.id, dir}; q.push(nextBoard); } } } } } return {}; // 无解 }注意华容道的状态空间可能非常大。对于复杂布局BFS可能会消耗大量内存和时间。在实际实现中需要优化状态哈希函数并考虑使用双向BFS或A*算法以当前状态到目标状态的启发式估计距离为优先级来提高搜索效率。对于教学演示使用经典布局的BFS求解通常是瞬间完成的。4. EasyX图形库的集成与交互实现游戏逻辑完成后我们需要一个友好的界面。EasyX让在Windows下用C创建图形窗口变得异常简单。4.1 环境配置与窗口初始化首先你需要从EasyX官网下载安装包。对于Visual Studio安装后直接在包含graphics.h即可。对于其他IDE如Dev-C或Code::Blocks需要按照官网教程配置库文件和链接器。项目初始化通常包含以下步骤#include graphics.h // EasyX主头文件 #include conio.h // 用于_getch等 // ... 其他标准头文件 int main() { // 1. 初始化图形窗口 initgraph(800, 600); // 窗口宽度800高度600 // 2. 设置背景色、渲染模式等可选 setbkcolor(WHITE); cleardevice(); // 用背景色清空屏幕 settextcolor(BLACK); settextstyle(20, 0, _T(宋体)); // 3. 创建游戏对象并运行 Game game; game.Run(); // 4. 关闭图形窗口 closegraph(); return 0; }在Game::Run()的主循环中通常使用peekmessage或kbhit配合_getch来处理消息实现非阻塞或半阻塞的输入检测。4.2 鼠标与键盘事件处理为了让游戏可玩我们需要处理两种输入鼠标点击拖动方块或键盘方向键移动选中的方块。鼠标交互流程在ProcessInput中使用peekmessage过滤鼠标消息。当检测到左键按下(WM_LBUTTONDOWN)时获取鼠标坐标(msg.x, msg.y)。调用ScreenToGrid函数将屏幕坐标转换为棋盘网格坐标(gridRow, gridCol)。遍历所有方块使用Block::Contains方法判断该网格点被哪个方块占据从而选中该方块记录其ID。在后续的WM_MOUSEMOVE消息中如果鼠标左键保持按下且有选中方块可以根据鼠标移动的偏移量例如移动超过半个网格来判断用户意图移动的方向然后调用Board::MoveBlock。或者更简单的做法是在鼠标左键释放(WM_LBUTTONUP)时判断鼠标释放位置相对于方块中心的方向来决定移动方向。键盘交互流程在ProcessInput中使用_kbhit()检测键盘缓冲区。使用_getch()获取键值。注意方向键是扩展键码需要连续调用两次_getch()。根据按键如WASD或上下左右箭头设置移动方向然后对当前选中的方块或通过其他方式指定的焦点方块调用Board::MoveBlock。void Game::ProcessInput() { ExMessage msg; // EasyX定义的消息结构 while (peekmessage(msg, EX_MOUSE | EX_KEY)) { switch (msg.message) { case WM_LBUTTONDOWN: { int gridRow, gridCol; if (ScreenToGrid(msg.x, msg.y, gridRow, gridCol)) { // 查找被点击的方块 for (const auto block : board.GetBlocks()) { if (block.Contains(gridRow, gridCol)) { selectedBlockId block.id; // 可以在这里高亮选中方块 break; } } } } break; case WM_LBUTTONUP: { if (selectedBlockId ! -1) { int gridRow, gridCol; if (ScreenToGrid(msg.x, msg.y, gridRow, gridCol)) { // 简化处理计算释放点与方块中心的偏移决定方向 // 实际可更精细这里仅为示例 // 移动方块... // board.MoveBlock(selectedBlockId, dir); } selectedBlockId -1; // 取消选中 } } break; case WM_KEYDOWN: { int dir -1; switch (msg.vkcode) { case VK_UP: dir 0; break; case VK_DOWN: dir 1; break; case VK_LEFT: dir 2; break; case VK_RIGHT: dir 3; break; } if (dir ! -1 selectedBlockId ! -1) { board.MoveBlock(selectedBlockId, dir); } } break; } } }4.3 画面渲染与动画效果基本的渲染就是在Render函数中调用Board::Draw。为了提升体验可以加入一些简单的动画。方块高亮被选中的方块可以用不同的边框颜色或填充色绘制。移动动画直接瞬移方块会显得生硬。可以实现一个简单的补间动画。在Game类中维护一个isAnimating标志和动画相关的数据如动画起始位置、目标位置、当前进度。当接收到移动指令时不立即更新Board中的方块位置而是启动动画。在Update函数中更新动画进度在Render函数中根据进度绘制方块的中间位置。动画结束后再正式更新Board的状态。胜利特效当IsWin()返回true时可以停止游戏循环渲染一个胜利的文本或图片并播放一个简单的效果如所有方块闪烁或曹操方块发光移动到底部出口。void Game::Render() { cleardevice(); // 清屏 // 绘制棋盘背景网格可选 // ... // 绘制所有方块 board.Draw(gridSize, offsetX, offsetY); // 如果正在播放移动动画在动画位置覆盖绘制被移动的方块 if (isAnimating) { DrawAnimatedBlock(); } // 绘制UI文字如步数 TCHAR stepStr[20]; _stprintf_s(stepStr, _T(步数: %d), stepCount); outtextxy(10, 10, stepStr); // 如果胜利绘制胜利提示 if (board.IsWin()) { settextcolor(RED); outtextxy(300, 250, _T(恭喜通关)); } }实操心得EasyX的绘图函数在双缓冲模式下会更流畅。你可以使用BeginBatchDraw()和EndBatchDraw()将一帧内的所有绘图指令批量执行减少闪烁。在游戏主循环中每次Render前BeginBatchDraw()渲染完成后FlushBatchDraw()或EndBatchDraw()并Sleep一小段时间如10毫秒来控制帧率。5. 项目构建、调试与优化心得将上述所有模块组合起来一个可玩的华容道游戏就初具雏形了。但在开发过程中还有一些工程上的细节需要注意。5.1 工程组织与编译建议将不同的类分别放在独立的.h和.cpp文件中。例如Block.h/cppBoard.h/cppGame.h/cppmain.cpp在Visual Studio中创建一个空项目将这些文件添加进去即可。确保在项目属性中正确包含了EasyX的头文件路径和库文件路径通常安装器会自动配置。如果你使用CMake需要找到EasyX的库文件通常是.lib并链接。一个常见的编译错误是“无法解析的外部符号__imp_xxx”这通常是因为没有链接正确的库。对于EasyX你需要链接EasyXa.libASCII字符集或EasyXw.libUnicode字符集具体取决于你的项目字符集设置。5.2 调试技巧与常见问题移动逻辑错误这是最容易出bug的地方。调试时可以在Board::MoveBlock函数内部关键点打印日志输出移动前后的grid状态。或者更直观的方法是在渲染时除了绘制方块还将底层的grid数组用数字绘制在每个网格上这样就能一目了然地看到逻辑状态与渲染是否一致。内存与性能在这个规模的项目中通常不是问题。但如果你实现了BFS求解器需要注意状态去重unordered_map可能会占用大量内存。如果求解时间过长可以考虑设置一个搜索深度限制。图形坐标错乱确保你的棋盘偏移量(offsetX, offsetY)、网格尺寸gridSize以及窗口客户区大小计算正确。有时候窗口边框会占用一部分空间导致计算出的中心点不准。可以使用EasyX的getwidth()和getheight()函数动态获取绘图窗口的尺寸。事件响应迟钝检查你的主循环结构。如果使用while (peekmessage(...))处理消息确保它在一个足够快的循环中被调用。如果使用_kbhit注意它可能无法同时处理鼠标事件。推荐以peekmessage为主因为它能统一处理鼠标和键盘消息。5.3 功能扩展与优化方向一个基础版本完成后你可以考虑以下方向进行扩展让项目更出彩多关卡系统将不同的棋盘布局如“横刀立马”、“层层设防”存储在数组或配置文件中允许玩家选择或依次挑战。撤销/重做功能维护一个移动历史的栈。每次成功移动后将移动前的棋盘状态或反向移动指令压入撤销栈。实现撤销时从栈顶弹出状态并恢复。步数计时与排行榜记录玩家通关所用的步数和时间并可以本地保存最佳记录。更智能的求解提示集成BFS求解器但不要一次性给出全部步骤。可以提供“下一步提示”功能只显示当前状态下的一个最优移动。音效与音乐使用mciSendString等Windows API或第三方库添加移动音效和背景音乐。更精美的UI使用EasyX加载并绘制图片作为方块皮肤替代简单的色块。设计更美观的按钮和菜单。我个人在实现时最喜欢加入的功能是“即时动画”。当玩家拖动方块时方块会跟随鼠标移动直到释放到另一个合法网格。这需要更精细的碰撞检测实时判断方块中心或边缘是否靠近某个网格线并自动吸附但交互体验提升巨大。最后将你的代码整理好写上清晰的注释就可以分享出去了。完整的源码应该包含所有头文件、源文件以及一份简单的README.md说明如何编译和运行。通过这个项目你不仅收获了一个小游戏更深入理解了状态建模、碰撞检测、搜索算法和图形界面事件循环这些都是游戏开发乃至更广泛软件开发的通用技能。

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

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

免费获取报价