基于C++和EasyX的推箱子小游戏开发实战:从控制台到图形化交互
简介面向初学C与EasyX图形库的开发者以经典推箱子小游戏为载体介绍二维图形游戏开发的基本思路与实践方法适合作为编程入门后的第一个练手项目。资源包为1个docx文档大小799KB已有1150人学习下载。文档内含完整游戏源码及模块分析地图用二维数组map表示通过0墙、1地板、2箱子目的地、3玩家、4箱子等数值组合出关卡布局。初始化阶段使用EasyX的graphics.h加载背景、墙壁、箱子、目标点与角色图片随后由drawMap函数依据地图当前状态将图片绘制到画布对应位置。游戏运行时walk函数接收上、下、左、右按键先计算人物下一格和下下格位置再按地板、箱子、墙等不同对象执行移动或推箱操作同时加入mciSendString调用来播放行走、推箱与成功音效使反馈更加生动。通过该项目可学习C基础语法、EasyX图形绘制流程、二维数组驱动游戏地图的机制以及碰撞检测、状态更新和模块化代码组织等核心技能是系统训练游戏逻辑设计和调试能力的实用资料。1. 基于C和EasyX的推箱子小游戏从控制台逻辑到图形化交互推箱子Sokoban是一个被写烂了的入门项目但多数教程停留在控制台黑白界面字符代替箱子方向键输入后回车才能动一步。当你准备用 EasyX 把这个游戏图形化时才会发现真正的难点不在“推箱子”本身而在于 EasyX 的消息循环怎么跟游戏逻辑解耦、角色移动动画怎么处理、关卡地图怎么从硬编码数组变成可扩展的文件格式。这篇文章直接用 C17 EasyX 2023 版本把推箱子从“能跑”做到“能玩”每一步都给出可编译代码和参数说明让你完全可以在 Visual Studio 或 VS Code MinGW 环境下复现。如果你是刚开始学 C这个项目最好的切入点在于它覆盖了二维数组状态管理、枚举状态机、键盘消息处理、贴图资源和碰撞检测这几块硬知识并且每一块都能立刻看到视觉效果。如果你已经写了几年业务代码想快速找回 C 写图形程序的体感或者想拿一个干净的案例去理解“逻辑层与渲染层分离”在小型项目里到底怎么做这篇也够用。文中所有代码遵循一个原则游戏核心逻辑不依赖 EasyXEasyX 只负责把逻辑状态画到屏幕上。这种做法能让你的核心算法拿到任何框架下都能复用也让调试时可以先用控制台输出验证逻辑再处理绘图问题。2. 架构设计与地图数据结构先搭骨架再写功能2.1 推箱子游戏的最小逻辑模型地图、坐标与状态枚举在写任何绘图代码之前先把游戏抽象成四个核心问题地图是什么、玩家在哪、箱子在哪、怎么判定胜利。推箱子的地图本质上是一个二维字符数组每个单元格有五种可能状态空地、墙体、目标点、箱子和玩家。但更严谨的做法是“图层分离”——把地图静态层墙体、空地、目标点和动态层箱子、玩家分开存储。enum class Cell : char { WALL #, FLOOR , TARGET ., BOX $, PLAYER , BOX_ON_TARGET *, PLAYER_ON_TARGET }; struct Position { int row; int col; bool operator(const Position other) const { return row other.row col other.col; } };这里把箱子在目标点上、玩家在目标点上单列枚举值是为了方便碰撞检测和胜利判断避免绘制时再做一次坐标查找。BOXA_ON_TARGET和PLAYER_ON_TARGET其实存储时就可以这样区分也可以用两个 bool 数组记录箱子是否在目标点。对于 5 到 10 个箱子的小关卡这种枚举方式更直观。地图读取时把*转换成 Box Target 的组合状态逻辑上不用区分枚举值的“物理身份”和“叠加身份”棋盘状态的处理会简单很多。2.2 关卡数据格式从硬编码数组升级到文本文件加载新手教程通常直接在代码里写死一个二维数组比如int map[8][8]这种方式改关卡需要重新编译很不专业。常见的做法是用文本文件描述关卡每个字符对应一种地形元素空行作为关卡之间的分隔符class LevelLoader { public: bool loadFromFile(const string filename, vectorstring mapData); bool parseLevel(const vectorstring mapData, GameMap map); };具体实现时你可以从map.txt逐行读入字符串每行去掉\r用string::length()作为列数。注意关卡文件编码必须是 UTF-8 无 BOM否则用ifstream读取时可能出现第一个字符异常。ifstream的默认打开模式是文本模式Windows 下\r\n会被转换成\n所以不需要手动去除\r但如果你在 Linux 上跨平台编译就要处理。2.3 地图边界与碰撞判定防止数组越界是一切的根基推箱子最容易出现的 bug 就是玩家走出地图边界导致数组越界。解决方案是在move函数内部先做目标坐标的边界检查再做墙体检查最后做箱子联动检查bool GameMap::canMoveTo(int row, int col, bool isPlayer) { if (row 0 || row height || col 0 || col width) return false; if (isWall(row, col)) return false; if (isBox(row, col) isPlayer) { int nextRow row (row - player.row); int nextCol col (col - player.col); if (nextRow 0 || nextRow height || nextCol 0 || nextCol width) return false; if (isWall(nextRow, nextCol) || isBox(nextRow, nextCol)) return false; } return true; }这个函数是推箱子游戏的核心返回值决定玩家能否移动。很多实现把边界检查散落在各个函数里导致后期增加箱子数量时漏判。建议所有移动先走canMoveTo失败直接返回避免复杂的状态回滚逻辑。注意这里player.row是当前玩家的位置判断推进箱子时用row - player.row算出移动方向比传入direction参数更简洁——前提是调用方确保row, col确实是玩家相邻的格子。3. EasyX 绘图与消息循环把逻辑状态变成屏幕画面3.1 自定义窗口初始化和贴图资源加载EasyX 的initgraph是入口参数是窗口宽高和标志位。推箱子通常需要显示步数和关卡信息所以窗口要比地图区域大一些void initGameWindow(int mapCols, int mapRows) { int tileSize 64; int infoHeight 60; int windowWidth mapCols * tileSize 2 * MARGIN; int windowHeight mapRows * tileSize infoHeight 2 * MARGIN; initgraph(windowWidth, windowHeight, EW_SHOWCONSOLE); setbkcolor(RGB(245, 245, 245)); cleardevice(); }EW_SHOWCONSOLE标志可以保留控制台窗口方便用printf输出调试信息。tileSize决定每个地图格子占多少像素64 的倍数在缩放时最清晰。窗口尺寸根据地图列数和行数动态计算这样支持不同尺寸的关卡文件。initgraph失败时会抛出异常或返回错误码实际开发中需要检查返回值——可以用GetLastError()获取原因。EasyX 加载图片有两种方式loadimage从 PNG/BMP 文件加载或者putimage把图片绘制到窗口。如果你的电脑没有美术资源可以先用solidrectangle和fillrectangle画纯色方块这样不依赖外部文件就能运行IMAGE tileSet; loadimage(tileSet, _T(tiles.png), 4 * 64, 1 * 64);注意 EasyX 的loadimage只支持 BMP、JPG、PNG 格式不支持 WebP。图片尺寸最好保证画布中只留一个格子的内容方便用putimage的srcRect参数提取。实际开发中我一般准备一张 4x1 的 PNG分别包含地板、墙体、目标点和箱子四个元素的 64x64 像素图再用下面的方式截取void drawTile(int row, int col, Cell type) { int x MARGIN col * TILE_SIZE; int y MARGIN row * TILE_SIZE; int srcX 0; switch (type) { case Cell::WALL: srcX 1; break; case Cell::TARGET: srcX 2; break; case Cell::BOX: srcX 3; break; } putimage(x, y, 64, 64, tileSet, srcX * 64, 0, SRCPAINT); }如果你不用图片资源直接画彩色矩形也可以——纯色方格在游戏开发初期反而更容易调试因为你能一眼看出哪个格子是什么类型。3.2 键盘消息处理_getch还是PeekMessageEasyX 提供了两种键盘输入方式阻塞式的_getch和消息回调式的WM_KEYDOWN。对于推箱子这种实时性要求不高的游戏_getch最简单——每帧获取一个按键然后更新地图状态并重绘。但_getch有一个潜在问题它会阻塞主线程导致窗口失去响应。更专业的做法是用消息循环while (true) { peekmessage(msg, EM_KEY); if (msg.message WM_KEYDOWN) { int key msg.vkcode; if (key VK_UP) game.movePlayer(-1, 0); else if (key VK_DOWN) game.movePlayer(1, 0); else if (key VK_LEFT) game.movePlayer(0, -1); else if (key VK_RIGHT) game.movePlayer(0, 1); else if (key R) game.resetLevel(); } }peekmessage是非阻塞的没有按键时继续循环这样游戏可以处理动画帧或者倒计时。ExMessage结构体里的vkcode是虚拟键码方向键就是VK_UP这些常量。注意 EasyX 的peekmessage需要指定过滤标志EM_KEY表示只处理键盘消息。如果你需要同时监听鼠标点击就传EM_KEY | EM_MOUSE。这里有个初学者常踩的坑直接用_getch()读取控制台输入时方向键在 Windows 终端会返回两个字节224 和具体的按键码必须做特殊处理int ch _getch(); if (ch 224) { ch _getch(); switch (ch) { case 72: movePlayer(-1, 0); break; case 80: movePlayer(1, 0); break; } }反而 EasyX 的peekmessage已经帮你处理了这个问题vkcode直接对应标准虚拟键不需要关心硬件层的转换。所以在这个项目里我推荐消息循环方案代码更清晰也方便后续加撤销功能时记录按键历史。3.3 绘制循环双缓冲避免闪烁cleardevice()在每次重绘时清空整个画布如果地图比较大会出现明显的闪烁感。EasyX 提供了双缓冲机制通过BeginBatchDraw和EndBatchDraw包裹所有绘图调用BeginBatchDraw(); for (int row 0; row map.getRows(); row) { for (int col 0; col map.getCols(); col) { drawTile(row, col, map.getCell(row, col)); } } drawPlayer(map.getPlayer()); drawInfo(stepCount, levelIndex); EndBatchDraw(); FlushBatchDraw();BeginBatchDraw让所有绘制操作先写到一个内存缓冲区FlushBatchDraw一次性把缓冲区内容拷贝到屏幕上。两个函数之间的绘图操作不会直接显示这样最终用户看到的是完整的一帧画面。这里不要漏掉FlushBatchDraw否则图形不会立即显示。如果你的电脑上EndBatchDraw已经内部调用了刷新FlushBatchDraw可以省略但显式调用更保险。执行顺序很重要先画地图单元格再画玩家否则玩家会被格子的背景覆盖。双缓冲对性能的影响在 30x30 地图以下可以忽略但如果你在调试时发现窗口闪烁先检查是否调用了BeginBatchDraw而不是怀疑显卡驱动。很多推箱子教程直接写cleardevice()看起来短小精悍但实际动起来满屏都在闪体验极差。4. 核心移动逻辑与 C 语法深入结构体、指针与状态管理4.1 移动判定三步走空地、推箱、边界推箱子的移动逻辑可以用下面这个伪代码概括实际写的时候用一个direction结构体减少参数传递struct Direction { int dRow; int dCol; }; bool GameMap::movePlayer(Direction dir) { int newRow player.row dir.dRow; int newCol player.col dir.dCol; if (!isInBounds(newRow, newCol)) { return false; } if (isWall(newRow, newCol)) { return false; } if (isBox(newRow, newCol)) { int boxRow newRow dir.dRow; int boxCol newCol dir.dCol; if (!isInBounds(boxRow, boxCol) || isWall(boxRow, boxCol) || isBox(boxRow, boxCol)) { return false; } // 箱子移动注意原位置如果是目标点要还原成目标点状态 moveBox(boxRow, boxCol, newRow, newCol); } // 玩家移动原位置如果是目标点要还原成目标点状态 movePlayerTo(newRow, newCol); stepCount; return true; }这段逻辑有几个隐藏细节。第一isInBounds和isWall是独立函数你不能只用范围检查而忽略墙体。第二箱子移动前必须检查箱子落点是否合法——落点可以不是空地但不能是墙或另一个箱子。第三移动后原来箱子所在的位置如果是目标点要显示为目标点而不是空地。很多初学者的代码在这里出错把目标点覆盖成了普通地板导致关卡永远无法完成。这里的正确做法是在moveBox和movePlayerTo里保存原来的 Cell 类型或者用一个独立的targetPositions集合来判断某个坐标是否是目标点。4.2 步数与胜利判断用std::vector管理目标点集合目标点可以保存在std::vectorPosition里胜利条件就是所有箱子都在目标点上bool GameMap::isWin() const { for (const auto pos : targetPositions) { if (!isBox(pos.row, pos.col)) return false; } return true; }这里用isBox判断箱子是否在该位置但要注意箱子在目标点上的状态是BOX_ON_TARGET所以isBox需要同时匹配Cell::BOX和Cell::BOX_ON_TARGETbool isBox(int row, int col) const { Cell cell getCell(row, col); return cell Cell::BOX || cell Cell::BOX_ON_TARGET; }这类辅助函数在多个地方会被重用绘制、移动、胜利判断。建议把地图相关的所有查询操作封装成const成员函数这样避免到处访问底层数组。std::vector在这里比 C 数组安全因为它自带边界检查用at()方法而且targetPositions的大小可以随关卡动态变化。如果你需要支持撤销功能用一个std::vectorGameState保存历史状态会更方便GameState可以是一个包含地图快照和步数的结构体每步移动前 push 一份撤销时 pop 回来。这种策略的最大优点是简单缺点是内存占用但对于推箱子这种几十步的小游戏来说完全没有压力。4.3 关卡切换与重置机制类封装接口的设计一个关卡完成后玩家需要进入下一关按R键重玩当前关。这个机制和核心移动逻辑分离放在一个Game类里管理class Game { private: GameMap currentMap; vectorGameMap levels; int currentLevelIndex; public: void loadLevels(const string directory); void nextLevel(); void resetCurrentLevel(); };resetCurrentLevel最干净的做法是从levels里重新复制一份void Game::resetCurrentLevel() { if (currentLevelIndex 0 currentLevelIndex levels.size()) { currentMap levels[currentLevelIndex]; stepCount 0; } }GameMap内部包含vectorvectorCell默认拷贝构造就是深拷贝不需要手动写析构函数——只要不持有裸指针。nextLevel类似递增currentLevelIndex后重新赋值currentMap。这里有一个值得注意的点不要把levels做成指针数组C 的 RAII 原则在这里能帮你省掉delete的内存管理。要加载多关卡可以约定关卡文件用空行分隔或者每个文件放一关用文件名序号标识level01.txt level02.txt用std::filesystem遍历目录C17 标准库提取数字排序后再加载。很多 C 入门的推箱子实现不涉及文件系统直接用数组写死三关但作为 5 年以上的工程师应该意识到这个边界资源分离是项目可维护性的分水岭。我一般会把关卡文件放在levels/子目录下启动时扫描这个目录加载失败时在控制台输出警告信息但游戏主体照常运行。5. 渲染细节与视觉优化EasyX 图形绘制的高级技巧5.1 颜色配置与视觉反馈用 RGB 常量管理主题EasyX 的颜色用RGB(r,g,b)宏生成COLORREF值。推箱子的视觉风格可以做成怀旧像素风也可以做扁平化 UI。一个实用的做法是把所有颜色抽象成主题结构体方便切换struct Theme { COLORREF floorColor; COLORREF wallColor; COLORREF targetColor; COLORREF boxColor; COLORREF playerColor; COLORREF textColor; }; const Theme CLASSIC { RGB(240, 240, 240), // floor RGB(80, 80, 90), // wall RGB(255, 200, 100), // target RGB(200, 120, 50), // box RGB(50, 120, 200), // player RGB(30, 30, 30) // text };绘制时函数签名包含一个const Theme参数不使用全局变量。这样做的好处是如果你想提供夜间模式或自定义皮肤只需新增一个Theme实例不用修改绘图代码。对于墙体可以加一点高光效果让它看起来有立体感——用rectangle绘制边框内部填充深色左上角画亮色短线右下角画暗色短线。地形图元从纯色块变成带细节的小图标视觉丰富度立刻提升代码量增加并不多。5.2 玩家动画线性插值移动而不是瞬移推箱子游戏里玩家移动是逐格瞬移的但如果想让手感更平滑可以做一个简单的插值动画。原理是记录移动开始和结束的两个坐标用getTickCount()获取时间戳按时间比例计算中间位置void PlayerAnimation::update(int startRow, int startCol, int endRow, int endCol) { DWORD startTime GetTickCount(); DWORD duration 100; // 100ms 完成移动 while (true) { DWORD elapsed GetTickCount() - startTime; if (elapsed duration) break; float progress static_castfloat(elapsed) / duration; float x startCol (endCol - startCol) * progress; float y startRow (endRow - startRow) * progress; drawPlayer(static_castint(y), static_castint(x)); // 触发重绘 } }动画的引入会让游戏出现一个新的复杂度方块碰撞的楼层管理。在动画期间用户可能快速按两次方向键你需要决定是丢弃这次输入还是排队处理。简单方案是加一个bool isAnimating标志动画播放期间忽略所有输入或者直接把动画时间设为 0 模拟瞬移。推箱子不是动作游戏我一般建议不要做动画或者只做非常短的 80ms 动画以避免输入卡顿的吐槽。合理折中是动画期间缓存最新一次按键动画结束后执行。5.3 关卡信息面板步数、关卡号、提示文本的绘制在窗口的底部区域显示当前步数、总步数和状态提示。用settextstyle设置字体大小和字体名outtextxy绘制字符串settextstyle(24, 0, _T(Microsoft YaHei)); settextcolor(theme.textColor); TCHAR buf[64]; _stprintf_s(buf, _T(步数: %d), stepCount); outtextxy(MARGIN, windowHeight - 50, buf);_stprintf_s是安全版本的sprintf在 Windows 下用宽字符编码所以格式化字符串要带_T()宏。如果你在 VS 里用窄字符char*保存字符串outtextxy有一个重载接受LPCTSTR直接传char*会被转成宽字符中文显示可能乱码。最简单的方案是全程用TCHAR和_T()宏或者直接用std::wstring配合EasyX的宽字符版 API。这里还可以加上一行提示文案比如“按 R 重开按 Q 返回选关”用settextstyle调小字号放在步数下面。这块的代码量不大但涉及中文字体渲染坑比较多设置字体时必须指定 Unicode 版本的字体名如Microsoft YaHei用默认字体可会显示方块。设置setbkmode(TRANSPARENT)避免文本绘制时覆盖背景色。步数更新逻辑位于每次成功移动之后而不是每次按键之后——按了方向键但被墙挡住不应算步数。6. 程序结束条件和细节打磨用std::function做撤销与重新开始功能6.1 实现撤销功能用栈保存历史状态撤销功能是推箱子游戏刚需也是 C 标准库实战的好题材。每一步之前把整个地图状态push进一个std::stackGameState中按U键时pop回来struct GameState { GameMap map; int stepCount; }; void Game::saveState() { history.push({currentMap, stepCount}); } void Game::undo() { if (history.empty()) return; currentMap history.top().map; stepCount history.top().stepCount; history.pop(); }GameMap内部是一个二维vector拷贝成本不高对于十个格子的小关卡可以忽略。如果想优化内存可以只保存玩家坐标和箱子坐标的历史不保存墙体——因为墙体永远不变。但代码复杂度会上升考虑到推箱子关卡一般不超过 50x50直接存整个地图的代码可读性更好。这个功能的实现要点是保存时机必须在移动之前保存而不是移动之后。移动失败时不保存严格来说应该是saveState()放在movePlayer中且只在移动到新格子时调用。如果你在movePlayer里先修改地图再 push撤销时就永远回不到初始状态。6.2 关卡编辑与调试把数组瞬间变成可玩关卡在开发过程中你可能想快速测试一个难点的关卡。常见做法是写一个小工具把二维数组直接转成文本格式或者直接在游戏内按E进入编辑模式。编辑模式实现也不复杂捕捉鼠标点击修改对应的格子状态按S保存到文件case Cell::WALL: newType Cell::FLOOR; break; case Cell::FLOOR: newType Cell::WALL; break; case Cell::TARGET: newType Cell::BOX; break; default: newType Cell::TARGET; break;编辑模式用的鼠标位置换算公式是col (mouseX - MARGIN) / TILE_SIZErow (mouseY - MARGIN) / TILE_SIZE注意判断点击是否在有效地图区域内。这个功能在标准推箱子实现中不常见但对调试意义很大——你不需要反复修改代码里的数组常量再重新编译了。如果你要加这个功能注意 EasyX 的WM_LBUTTONDOWN消息需要EM_MOUSE过滤标志配套使用MOUSEMSG类型获取坐标。6.3 游戏结束后的处理胜利弹窗与继续挑战胜利判定后弹出消息框显示步数和耗时然后询问是否进入下一关。MessageBox是 Win32 API在 EasyX 环境下可以直接调用if (map.isWin()) { TCHAR msg[128]; _stprintf_s(msg, _T(恭喜通关\n步数: %d\n\n是否进入下一关?), stepCount); int choice MessageBox(GetHWnd(), msg, _T(胜利), MB_YESNO); if (choice IDYES) { game.nextLevel(); } else { break; // 退出游戏循环 } }GetHWnd()是 EasyX 提供的函数返回当前绘图窗口的句柄。用MB_YESNO选项返回值是IDYES或IDNO。注意这一步必须放在游戏主循环里胜利检测的时机是每次玩家移动之后不能移动前检测——否则开局就触发胜利。游戏循环的退出条件除了关闭窗口外要提供明确的快捷键比如按ESC退出在WM_KEYDOWN消息里增加VK_ESCAPE分支break出循环后调用closegraph()。closegraph()用完必须调用否则下次运行initgraph可能报错“图形环境未关闭”。最后补充一个代码组织建议核心逻辑地图、移动、胜利判断放在独立的src/game.cpp文件中EasyX 渲染单独放src/render.cpp用 CMake 管理构建。这样你以后想从 EasyX 迁移到 SFML 或 Qt只需要重写渲染层逻辑完全不动。本文还有配套的精品资源点击获取