简介基于C与EasyX图形库实现的推箱子小游戏开发资料适合C/C初学者及对2D游戏编程感兴趣的开发者用于练手与项目参考。资源包含完整的docx格式源码说明文档共1个文件压缩包大小约799KB。文档深入展示了EasyX的图形渲染流程包括背景、角色、箱子、目的地等元素的绘制方式并以二维数组定义游戏地图清晰区分墙、地板、箱子、目标点及玩家等游戏元素。源码中讲解了基于方向键控制的角色移动逻辑、箱子推动与碰撞检测处理以及音效播放、胜利判定等功能的实现思路有助于理解游戏状态更新与图形刷新机制。已有1150人学习适合用来巩固C语法、掌握图形库调用方法并积累游戏逻辑的开发与调试经验。1. 用 C 和 easyX 写推箱子一个周末能做完的事用 C 和 easyX 写一个推箱子小游戏几乎是初学者从控制台黑框走向图形界面的第一个完整项目也是很多 C 小游戏编程练习里出镜率最高的题目。它不像俄罗斯方块、贪吃蛇那样要处理连续运动核心逻辑只有「移动、推箱子、压住目标点」三件事一套二维数组加方向键就能跑通特别适合在刚配好 VSCode 的 C 环境后拿来练手。本文会把从绘图库选型、地图表示、移动判定到关卡文件、撤销功能的完整落地方案讲清楚你跟着复现一遍大约两三百行代码就能得到一个能玩的游戏中途那些易踩的坑我也会一条条指出来。2. easyX 怎么选、怎么做渲染库对比与关卡地图的数据结构2.1 为什么是 easyX而不是 raylib 或 SDL写 Windows 桌面小游戏常见的选择是 easyX、SDL 和 raylib。easyX 是封装了 Windows GDI 的绘图库initgraph 一个函数就能开窗口画矩形、画图片、输出文字都是现成 API对初学者非常友好你不需要理解消息循环、渲染管线这些东西。我一般这样安排对比如果目标是快速验证推箱子逻辑、完成课程设计或者第一次接触图形编程直接选 easyX如果以后想跨平台发布再考虑 raylib 或 SDL。raylib 和 SDL 也要自己处理窗口和事件代码量会多出一截对刚学会数组和指针的人不太舒服。easyX 的缺点是只能在 Windows 上用而且它实际是在操作 GDI 绘图性能上限比不过 OpenGL 那一套但推箱子这类静态画面游戏完全够用。安装 easyX 时需要注意它不是一个可以通过包管理器直接拉取的库需要去官网下载安装包然后选择对应的 Visual Studio 版本安装。如果你用的是 VSCode 配 MinGW 的 C 环境easyX 也能用但要确认编译器位数和库文件匹配这部分后面避坑章节细说。安装完成后代码里只要#include graphics.h和#include conio.h就能开始画图。2.2 用二维数组表示关卡从字符地图到数字地图推箱子最难的不是绘图而是想清楚地图数据怎么存。我的做法是先把关卡画成一个字符图然后再转成数字二维数组。比如一个最简单的地图长这样######## # # # . # # $ # # # ########其中#是墙.是目标点$是箱子是玩家。这个可读性好但程序里直接用字符判断也行。常见做法是定义一套数字约定0 表示空地、1 表示墙、2 表示箱子、3 表示目标点、4 表示玩家。另外还需要两个特殊状态5 表示箱子已经在目标点上6 表示玩家站在目标点上。多加这两个状态是为了在推箱子过程中能区分「这格本来是空地还是目标点」否则箱子从目标点推走之后那一格就不知道要不要还原成目标点。下面是一个具体的地图数组四周用墙围起来保证玩家不会越界#include graphics.h #include conio.h #include string.h #include stdio.h #define ROWS 8 #define COLS 12 // 0空地 1墙 2箱子 3目标 4玩家 5箱在目标 6玩家在目标 int map[ROWS][COLS] { {1,1,1,1,1,1,1,1,1,1,1,1}, {1,0,0,0,3,0,0,0,0,0,0,1}, {1,0,0,0,2,0,0,0,1,0,0,1}, {1,0,0,0,0,0,0,0,1,0,0,1}, {1,0,0,3,0,0,0,0,1,0,0,1}, {1,0,0,2,0,0,0,0,1,0,0,1}, {1,0,0,0,0,0,0,0,0,4,0,1}, {1,1,1,1,1,1,1,1,1,1,1,1} };这段代码就是游戏的地图数据源。注意第 4 行和第 6 行各放了一个箱子第 2 行和第 4 行各放了一个目标点玩家在第 7 行。选择二维数组而不是一维数组是因为在移动逻辑里通过玩家当前行列坐标加dy、dx就能算出下一格的位置比一维线性索引直观得多。数字地图虽然程序好处理但人看着费劲。我建议你写代码时把注释里的字符对应关系保持住后面做关卡文件时再用字符格式存程序读取时转换这样既保留可读性又方便别人换关卡。2.3 坐标怎么映射从数组下标到屏幕像素地图数组的下标不能直接当像素用需要换算。easyX 的坐标系原点在窗口左上角x 向右、y 向下。如果一个格子大小为 64 像素那么第 r 行第 c 列的方块它的左上角像素坐标就是(c * 64, r * 64)。这个换算要写成一个独立函数后面所有绘制都通过它便于以后把格子大小调大调小#define TILE_SIZE 64 void drawTile(int row, int col, int value) { int x col * TILE_SIZE; int y row * TILE_SIZE; // 先画底色区分空地、墙、目标点 switch (value) { case 0: // 空地 setfillcolor(RGB(240, 240, 240)); fillrectangle(x, y, x TILE_SIZE, y TILE_SIZE); break; case 1: // 墙 setfillcolor(RGB(90, 90, 90)); fillrectangle(x, y, x TILE_SIZE, y TILE_SIZE); break; case 3: // 目标点画一个红色小方块 setfillcolor(RGB(240, 240, 240)); fillrectangle(x, y, x TILE_SIZE, y TILE_SIZE); setfillcolor(RGB(220, 60, 60)); fillrectangle(x 24, y 24, x 40, y 40); break; case 2: // 箱子 case 5: // 箱子在目标点 setfillcolor(RGB(210, 150, 60)); fillrectangle(x 4, y 4, x TILE_SIZE - 4, y TILE_SIZE - 4); break; case 4: // 玩家 case 6: // 玩家在目标点 setfillcolor(RGB(60, 120, 220)); fillcircle(x 32, y 32, 22); break; } }注意 2 和 5 画同样的箱子3 和 6 的区别是玩家站着。为什么不单独画 6 的地面因为玩家圆球会把目标点盖住游戏里玩家踩到目标点时看到目标点消失是正常现象只要玩家离开后那一格还能恢复成 3就不会有 bug。这样绘制函数就有了。下一步解决两个问题怎么把整张地图画出来以及怎么响应用户按键。3. 把核心逻辑写下地初始化、按键读取、移动与碰撞检测3.1 initgraph 初始化与双缓冲绘图easyX 的初始化只有一行initgraph(COLS * TILE_SIZE, ROWS * TILE_SIZE)窗口大小等于地图的像素尺寸。之后要注意一件事如果不做双缓冲画面会在每次移动时闪烁这是很多人第一次跑起来时最容易发现的毛病。原因是填矩形、画圆这些操作是直接往屏幕缓冲区写的玩家移动一次要重画整张地图旧的还没擦掉新的就画上去了。easyX 提供了批量绘图接口把中间过程全部画到内存缓冲里画完后一次性显示int main() { initgraph(COLS * TILE_SIZE, ROWS * TILE_SIZE); BeginBatchDraw(); // 开启双缓冲 while (true) { // 每次重画前先清空否则上一帧内容会残留 cleardevice(); for (int r 0; r ROWS; r) { for (int c 0; c COLS; c) { drawTile(r, c, map[r][c]); } } FlushBatchDraw(); // 统一显示到窗口 // 按键处理在这里 Sleep(10); } EndBatchDraw(); closegraph(); return 0; }这段代码里BeginBatchDraw和FlushBatchDraw必须成对出现。Sleep(10)的作用是让出 CPU避免游戏空转占用接近 100% 的 CPU。如果你觉得 10 毫秒响应不够细腻可以降到 5但不要直接写Sleep(0)那会让循环跑满一个核心笔记本风扇马上开始转。3.2 读取方向键_getch 的两段式坑键盘读取我用的最省事的方式是_getch()它从控制台输入缓冲里读一个字符不等待回车。方向键在 Windows 的输入体系里属于扩展键_getch()第一次会返回 0xE0 或 0第二次才返回真正的按键码比如向上的按键码是 72。这个细节如果没处理现象就是按方向键没反应或者角色会往奇怪的方向跑。正确写法是连续读两次void handleInput() { int key _getch(); if (key 0xE0 || key 0) { key _getch(); } switch (key) { case 72: // 上 case w: case W: tryMovePlayer(-1, 0); break; case 80: // 下 case s: case S: tryMovePlayer(1, 0); break; case 75: // 左 case a: case A: tryMovePlayer(0, -1); break; case 77: // 右 case d: case D: tryMovePlayer(0, 1); break; case 27: // Esc 退出 running false; break; } }在main的循环里调用handleInput之前要先用kbhit()判断缓冲区里有没有按键否则_getch()会阻塞住整个游戏循环if (kbhit()) { handleInput(); }这里单独把方向键的读取拎出来成函数是为了后面加撤销、加步数统计时不用改动主循环。注意我同时支持了小写wasd因为不是每个人都喜欢用方向键尤其笔记本用户手要移到右下角很不舒服。3.3 移动与推箱子先找玩家再分类处理移动逻辑是整个游戏的核心也是容易写乱的地方。我的思路分三步先找到玩家当前位置再看玩家前面那格是什么最后分「普通移动」和「推箱子」两种情况处理。找玩家需要遍历整个地图int playerRow, playerCol; void findPlayer() { for (int r 0; r ROWS; r) { for (int c 0; c COLS; c) { if (map[r][c] 4 || map[r][c] 6) { playerRow r; playerCol c; return; } } } }注意判断条件必须同时包含 4 和 6因为玩家可能正站在目标点上。只判断 4 的话会出现玩家踏上目标点后突然从地图上消失的 bug因为那一格变成了 6。然后是移动函数。我把「修改地图上的某个格子并立刻重绘」封装成了setTile这样每次操作后画面能同步更新void setTile(int row, int col, int value) { map[row][col] value; drawTile(row, col, value); } void tryMovePlayer(int dy, int dx) { findPlayer(); int nextRow playerRow dy; int nextCol playerCol dx; // 玩家前面是墙什么都不做 if (map[nextRow][nextCol] 1) return; // 玩家前面是箱子或箱子压目标 if (map[nextRow][nextCol] 2 || map[nextRow][nextCol] 5) { int boxNextRow nextRow dy; int boxNextCol nextCol dx; // 箱子再往前是墙、另一个箱子或箱子压目标推不动 if (map[boxNextRow][boxNextCol] 1 || map[boxNextRow][boxNextCol] 2 || map[boxNextRow][boxNextCol] 5) { return; } // 箱子往前挪一格箱子前方是目标点就放 5否则放 2 if (map[boxNextRow][boxNextCol] 3) { setTile(boxNextRow, boxNextCol, 5); } else { setTile(boxNextRow, boxNextCol, 2); } // 玩家站到箱子原来的位置原来是 5箱子压目标就放 6 setTile(nextRow, nextCol, map[nextRow][nextCol] 5 ? 6 : 4); // 玩家原来位置恢复成空地或目标点 setTile(playerRow, playerCol, map[playerRow][playerCol] 6 ? 3 : 0); } else { // 普通移动目标格是目标点就放 6否则放 4 setTile(nextRow, nextCol, map[nextRow][nextCol] 3 ? 6 : 4); setTile(playerRow, playerCol, map[playerRow][playerCol] 6 ? 3 : 0); } steps; }这段代码里有个容易犯的顺序错误先移动箱子再移动玩家。如果先改玩家位置map[nextRow][nextCol]就已经不是箱子原来的值 5 了后面判断要不要放 6 就会出错。我建议你按「箱子目标格 → 箱子原格 → 玩家原格」的顺序写并且每次setTile后立即重绘那一格方便用肉眼观察每一步变化。另外tryMovePlayer函数里没有判断箱子往前推之后会不会挡住别的目标点但这不需要担心因为箱子的新位置如果恰好是目标点我们放的是 5表示箱子和目标点重合视觉上和普通箱子一样只在胜利判定时区分。3.4 胜利判定检查地图上还有没有普通箱子胜利条件很直白所有箱子都被推到目标点上。用数字表示法就是地图上不能再存在值为 2 的格子。因为箱子只要压在目标点上它的值就会变成 5所以只要遍历一次地图bool isWin() { for (int r 0; r ROWS; r) { for (int c 0; c COLS; c) { if (map[r][c] 2) return false; } } return true; }这个判定不用数目标点有几个也不用管 3 和 6 存不存在只检查值为 2 的箱子是否清零。就算关卡设计里目标点数量不等于箱子数量这个函数也能保证「所有箱子都在目标点上」这一语义。在main循环里每次移动后调用一次isWin()赢了就显示一行文字并暂停输入if (isWin()) { settextcolor(RGB(30, 120, 30)); settextstyle(32, 0, Microsoft YaHei); outtextxy(120, 240, 恭喜过关按 Esc 退出); FlushBatchDraw(); while (!GetAsyncKeyState(VK_ESCAPE)) { Sleep(50); } break; }SetTextStyle的参数里字体名必须是系统里存在的字体写中文时还要注意工程字符集这点下一章专门说。4. 避坑easyX 推箱子开发中常见的 5 个翻车现场4.1 方向键读不到明明按了没反应现象游戏窗口能开画面正常但按上下左右方向键角色一动不动按wasd却有反应。原因_getch()读方向键时返回的是两个字节第一字节是 0xE0 或者 0表示后面跟了一个扩展键码。代码里只调用一次_getch()拿到的只是 0xE0自然匹配不到任何 case。解决在读取后加判断如果第一次读到 0xE0 或 0就再调用一次_getch()取真实键码再进入 switch。这也是我前面handleInput里写的处理方式。另外要注意kbhit()只能判断有没有按键不能区分是普通键还是扩展键所以扩展键的判断必须放在_getch()之后。4.2 画面闪成幻灯片忘了双缓冲现象每按一次方向键整个窗口先变成空白然后一格格重新画出来画面肉眼可见地闪烁。原因直接调用fillrectangle画图时每个图形都会立刻显示到窗口上。重绘整张地图等于让窗口先被清成背景色再逐个画几十个方块这个过程每帧都在重复。解决用BeginBatchDraw()开启批量绘图所有绘制先进入内存缓冲画完后用FlushBatchDraw()一次性显示。注意cleardevice()也要放在BeginBatchDraw之后、绘制循环之前否则清屏动作还是会直接显示出来。4.3 中文显示乱码不是什么玄学是字符集不匹配现象outtextxy输出的「恭喜过关」变成一串乱码英文和数字正常。原因easyX 的outtextxy在默认工程配置下接受的是窄字符而源文件如果用 UTF-8 编码保存中文字符串字面量在运行时会被当成 GBK 来解析两边对不上就乱码了。新版 Visual Studio 默认字符集是 Unicode也会踩这个坑。解决最省事的方法是把工程的字符集改为「使用多字节字符集」并且确保源文件以 GBK/ANSI 编码保存。如果你是在 VSCode 里写源文件编码默认是 UTF-8可以改用settextstyle配合宽字符版本outtextxy重载或者写一个 GBK 转码的小函数。我个人的习惯是既然游戏里要显示中文就直接把项目字符集改成多字节字符串全部用窄字符省心。4.4 推到地图边缘直接崩溃数组越界现象玩家贴着最上面一排墙试图把箱子往上推时程序弹出访问冲突或者画面花掉。原因移动和推箱子的判断里只检查了「箱子前面是不是墙」没有检查这个位置是否在地图数组范围内。当玩家和箱子都在地图边界时boxNextRow或者boxNextCol可能变成 -1 或者ROWS访问map[-1][x]就崩了。解决设计地图时四周永远围一圈墙这是第一道保险同时在tryMovePlayer里所有map[boxNextRow][boxNextCol]访问前先判断行列是否在合法区间。不要觉得四周有墙就可以省掉边界判断万一以后从外部文件加载关卡文件里的地图没留墙你的程序就当场翻车。4.5 窗口关闭了程序还在跑main 循环没退出条件现象点击窗口右上角 X窗口消失了但控制台进程还挂在后台CPU 占用很高。原因easyX 窗口关闭后绘图函数不会再报错但while (true)循环仍然在跑。如果你没监听关闭消息也没有任何退出标记循环就空转到天荒地老。解决定义一个bool running true在main循环开头检查GetAsyncKeyState(VK_ESCAPE)是否按下或者检测窗口消息一旦发现关闭就置为false并跳出循环最后调用closegraph()释放资源。我用的是后者while (running) { if (GetAsyncKeyState(VK_ESCAPE) 0x8000) { running false; break; } // 原有绘制与输入逻辑 } closegraph();5. 从单关到多关关卡文件、步数统计与撤销后悔药5.1 用 xsb 文本格式存关卡程序只负责解析地图写死在二维数组里换个关卡就要重新编译很不方便。社区里推箱子关卡文件有一种通用文本格式叫 xsb字符含义是#墙、 空格、$箱子、.目标、玩家、*箱子在目标点上、玩家在目标点上。我建议你把关卡存成独立的.txt文件每一行字符串代表地图的一行这样关卡设计完全和代码解耦。程序里写一个loadLevel函数读取文件并填充地图数组bool loadLevel(const char* path, int levelMap[ROWS][COLS]) { FILE* fp fopen(path, r); if (fp NULL) return false; char line[COLS 2]; int row 0; while (row ROWS fgets(line, sizeof(line), fp) ! NULL) { int len (int)strlen(line); // 去掉行尾的换行符 if (len 0 line[len - 1] \n) line[len - 1] \0; for (int col 0; col COLS; col) { char ch line[col]; switch (ch) { case #: levelMap[row][col] 1; break; case : levelMap[row][col] 0; break; case $: levelMap[row][col] 2; break; case .: levelMap[row][col] 3; break; case : levelMap[row][col] 4; break; case *: levelMap[row][col] 5; break; case : levelMap[row][col] 6; break; default: levelMap[row][col] 0; break; } } row; } fclose(fp); return row 0; }这个函数有几个细节值得说明。第一fgets会把换行符也读进line不处理的话最后一列会多出一个字符所以要先去掉\n。第二如果文件的某一行超过COLS程序只会读前COLS个字符多余部分被忽略这保证了不会越界写数组。第三返回row 0而不是true是为了让调用者知道文件是不是真的读到了内容。调用时直接loadLevel(level1.txt, map)地图被整体覆盖。要注意关卡文件路径建议放在和 exe 同一目录别放中文路径下否则 fopen 在部分 Windows 环境下会打不开。5.2 步数统计一个全局变量加一处自增步数统计没什么技术含量但它是推箱子游戏的灵魂没有步数限制的解谜游戏就少了挑战性。我在tryMovePlayer里已经写了一句steps它只在实际发生移动或推箱时自增撞墙不会增加步数。在绘制函数里把步数画在窗口上方或者侧边char stepText[32]; sprintf(stepText, 步数: %d, steps); settextcolor(RGB(200, 80, 30)); settextstyle(24, 0, Microsoft YaHei); outtextxy(10, 10, stepText);注意sprintf在 Visual Studio 里可能提示不安全可以用sprintf_s替代但参数含义一样。每走一步重画一次步数玩家立刻能看到自己的操作代价。5.3 撤销功能给玩家一颗后悔药推箱子推错一步往往就陷入死局所以撤销是提升体验的关键功能。最简单的做法是把每一步之前的地图快照存进一个历史数组撤销时把上一份快照还原。我用栈结构来实现不用 STL 是为了避免引入太多名词#define MAX_UNDO 200 int history[MAX_UNDO][ROWS][COLS]; int historyStepCount[MAX_UNDO]; int undoCount 0; int steps 0; void pushSnapshot() { if (undoCount MAX_UNDO) { memcpy(history[undoCount], map, sizeof(map)); historyStepCount[undoCount] steps; undoCount; } } void undoMove() { if (undoCount 0) { undoCount--; memcpy(map, history[undoCount], sizeof(map)); steps historyStepCount[undoCount]; } }注意调用时机pushSnapshot必须在移动之前调用也就是tryMovePlayer函数开头第一行。不要移动完再存那存下来的就是移动之后的状态了。MAX_UNDO限制在 200 步足够用超过就丢弃最早的快照。撤销按键我绑定到Z无论大小写都触发。注意撤销后玩家位置变了下次移动前findPlayer会重新遍历地图所以不会出现玩家坐标和地图不一致的问题。这个方案的唯一缺点是内存占用200 步乘 8 行乘 12 列的 int 数组只有大约 76KB完全可以忽略。5.4 关卡切换通关后自动加载下一关有了loadLevel多关卡就顺理成章。我的一般做法是在工程目录放level1.txt到levelN.txt维护一个currentLevel变量通关后currentLevel然后重新loadLevel并清空步数和历史栈currentLevel; steps 0; undoCount 0; char fileName[32]; sprintf(fileName, level%d.txt, currentLevel); if (!loadLevel(fileName, map)) { outtextxy(120, 240, 全部通关感谢游玩); FlushBatchDraw(); GetAsyncKeyState(VK_ESCAPE); running false; }这里有个体验细节加载新关卡后要立刻重绘整张地图否则窗口里还是上一关的画面。在调用loadLevel之后直接走主循环下一次的绘制逻辑即可不需要额外刷新。6. 最后一步验证边界条件顺便给玩家一点手感游戏能跑、能通关只算完成了 80%剩下 20% 是验证边界条件和手感调整。我拿到一个关卡文件后第一件事是测试四种边界场景玩家站在目标点上往空地走、玩家站在目标点上推箱子、箱子从目标点上被推走、箱子被推到墙角推不动。这四种情况如果绘制和地图状态都正确说明 4/6 两个状态转换没有遗漏。我自己的习惯是在tryMovePlayer里临时加一行输出来验证比如把playerRow、playerCol、nextRow、nextCol打印到命令行窗口跑几十步后看看日志里有没有越界或者异常状态值。等逻辑验证完了再删掉。手感方面有几个小技巧一是Sleep时间控制在 10 到 16 毫秒太短会让角色移动变成连发太长会觉得按键迟钝二是用GetAsyncKeyState替代_getch的话可以实现按住方向键连续移动但要注意加一个 150 毫秒的初始延迟否则轻轻碰一下键角色就连走好几格操作精度全毁了。我给玩家的建议是把双向缓冲、撤销、步数统计这三样都做齐再做一层关卡文件校验比如检查箱子数量是否等于目标点数量、玩家数量是否恰好一个不合法就提示关卡文件错误而不是直接崩。这个校验函数只有十几行但能让整个项目从「能跑的作业」变成「可维护的小作品」。希望这篇实战笔记帮到你照着把地图数组跑通的那一刻你就已经摸到 C 图形编程的门道了。本文还有配套的精品资源点击获取