简介:面向C语言/C++初学者的推箱子小游戏完整项目资源,以经典益智小游戏为载体,帮助学习者掌握数组、循环、条件判断、碰撞检测等核心编程概念,适合课程设计、自学实践或作为程序设计课教学案例。压缩包内共11个文件,包含可执行exe程序、cpp主源文件、rar源码工程、jpg图片素材、wav背景音乐以及wmv讲解视频等,整体约725.44MB;既可直接运行体验游戏,也能对照源码逐行理解地图存储、人物移动、箱子推送到胜负判定的完整实现。已有475人浏览/学习,属于热度较高的练手项目。讲解视频会逐步演示从环境搭建、核心算法到排错思路,配合本地调试能更快掌握常见写法和程序结构;地图采用二维数组建模,人物移动与箱子推挤均围绕键盘控制展开,可在理解之后自行设计更多关卡。图片素材与背景音乐素材一应俱全,方便二次开发或打包成完整课程项目。
1. 推箱子小游戏:C/C++ 课程设计里最值得拆的一份源码
C 语言学到数组和循环之后,大部分人都会进入一个尴尬期:练习题刷了不少,但离“写一个能跑起来的东西”还差一口气。推箱子是 C/C++ 小游戏里最适合拿来破局的题材,玩法听着简单,但地图存储、角色移动、碰撞判定、胜利检测这些游戏基本功全都能在几百行代码里讲清楚。这份资源给得很实在:一个双击就能开的 exe、一份把核心逻辑都放在 main.cpp 里的完整工程、一个讲解视频,外加全套图片和音乐素材。适合要做课程设计答辩的人,也适合想从“刷题模式”切换到“做项目模式”的 C 语言初学者。我建议别急着编译,先把 exe 玩通,再顺着源码走一遍逻辑。
2. 解压之后先别急着编译:项目里每个文件是干什么的
2.1 压缩包里的文件清单:exe、贴图、音乐、视频和源码的分工
下载下来是个 zip,解压后东西不少,初看容易懵。其实文件分成了四类,各干各的活。
第一类是编译好的成品:boxman.exe,这个是直接拿来玩的。第二类是游戏素材:blank.jpg是空白地板,wall.jpg是墙,box.jpg是普通箱子,redbox.jpg是推到目的地上的红色箱子,people.jpg是玩家,end.jpg是目的地,music.wav是背景音乐。第三类是讲解视频:Video_2018-05-24_234516.wmv,wmv 格式,Windows 自带的播放器就能打开,视频里基本是把上面这些文件怎么配合、代码怎么编译讲一遍。第四类是源码:boxman项目工程.rar,真正的工程文件在里面,外面单独放的main.cpp是为了让你不找源码也能直接看到代码。
这里有个经典误区:很多人解压后只看得到 exe 和一堆图片,就开始在论坛里问“源码在哪”。源码在 rar 里,rar 解开之后是一个完整的 Visual Studio 工程,核心代码就一个main.cpp。我拿到压缩包的第一件事,就是把 rar 解出来,确认工程结构和图片素材是不是同级的。
2.2 main.cpp 的代码骨架:初始化、游戏循环、收尾三件事
推箱子这种小游戏,逻辑全在一个main.cpp里反而是优势。对于一个还没读过“百行以上工程”的人来说,一个文件比十个文件友好得多。整个程序跑起来就干三件事:初始化资源、进入游戏循环、退出时清理。
#include <graphics.h> // EasyX 图形库,负责窗口、贴图、按键 #include <conio.h> // _getch() 读取键盘输入 #include <mmsystem.h> // 播放 wav 音乐 #pragma comment(lib, "winmm.lib") // 链接多媒体库,mciSendString 靠它 int main() { // 1. 初始化:创建窗口、加载贴图、播放音乐 initgraph(640, 512); // 窗口宽 640,高 512 loadimage(&imgWall, "wall.jpg"); mciSendString("open music.wav alias bgm", NULL, 0, NULL); // 2. 游戏循环:读按键 -> 更新地图 -> 重绘画面 while (running) { int dir = getDirectionKey(); movePlayer(dir); drawMap(); } // 3. 收尾:关闭音乐、关闭窗口 mciSendString("close bgm", NULL, 0, NULL); closegraph(); return 0; }代码逻辑说明:初始化阶段只做“创建窗口、加载素材、播放音乐”三件事,游戏循环里反复执行“读输入、改数据、画画面”。这个套路几乎所有图形小游戏通用,你以后写贪吃蛇、俄罗斯方块,骨架也是长这样。
参数说明:initgraph(640, 512)里的两个数字是窗口宽和高,单位是像素。为什么是 512?因为地图是 8 行乘 8 列,每格 64 像素,8 × 64 = 512,刚好正方形。留出来的 128 像素宽度,后面可以显示步数、状态文字。mciSendString里的alias bgm是给音乐起的别名,后面play、close都靠这个名字找到它。
2.3 用 Visual Studio 打开老工程:升级提示、工作目录、Debug 和 Release
这份工程的年代明显偏早,用新版 Visual Studio 打开时,大概率会弹一个“需要升级”的提示。直接点确定就行,升级完成后工程照样能编译。这里我多说一句:别拿 VSCode 去折腾这个项目。VSCode 配置 C/C++ 环境本身不复杂,但你要手动把 EasyX 的库目录、头文件目录、链接器参数全部配一遍,对新手来说全是坑。Visual Studio 双击.sln就能开,省下的是真实时间。
工程打开后,有一个概念必须先搞清楚:工作目录。程序运行时,loadimage("wall.jpg")是相对路径,它去“当前工作目录”找文件。用 VS 调试运行时,工作目录默认是工程文件所在目录,所以素材和.vcxproj放一起就能正常显示。但如果你直接把 exe 拷走,exe 所在的目录才是工作目录,图片和音乐没跟着走,就会闪退或黑屏。这个问题后面避坑章会详细展开。
编译配置也建议看一眼:Debug 和 Release 两个配置,Debug 带着调试信息、体量大,Release 是最终交付版。课程设计答辩时交 Release 更稳,体积小,而且不依赖调试运行库。我一般会在拿到工程后先切到 Release,重新生成一次,确保从零编译也能过,而不是靠 VS 里的残留缓存跑通。
3. 推箱子的核心逻辑:一张地图就是一组数字
3.1 地图编码表:二维数组的每一格是什么
推箱子最巧妙的地方,是把一张地图变成了一串数字。C 语言数组天然适合表达这种网格结构,map[y][x]一行代码就能定位到任意一格,比用指针链表去组织地图直观得多。这份工程里地图的每个取值都对应一张贴图:
| 数字 | 含义 | 对应素材 |
|---|---|---|
| 0 | 空地 | blank.jpg |
| 1 | 墙 | wall.jpg |
| 2 | 目的地 | end.jpg |
| 3 | 普通箱子 | box.jpg |
| 4 | 玩家 | people.jpg |
| 5 | 箱子已在目的地 | redbox.jpg |
| 6 | 玩家站在目的地 | end.jpg 上叠加 people.jpg |
5和6这两个状态是整套编码的精髓。箱子推到目的地后不能只画一个箱子,那样你不知道它“算不算到位”,所以用 5 表示“红箱”;玩家站到目的地后,也不能把目的地抹掉,否则箱子再推回来就没法识别了,所以用 6 表示“人站在终点上”。我在很多课程设计里看到有人用额外一个boxOnTarget布尔数组去记这件事,思路对,但代码会变绕。直接用编码状态,一个数组全搞定。
const int ROWS = 8, COLS = 8; int map[ROWS][COLS] = { {1,1,1,1,1,1,1,1}, {1,0,0,2,0,0,0,1}, {1,0,0,0,3,0,0,1}, {1,0,0,0,0,0,0,1}, {1,0,0,0,0,0,0,1}, {1,0,0,0,0,0,0,1}, {1,0,0,0,4,0,0,1}, {1,1,1,1,1,1,1,1} };这是一张能通关的 demo 关卡:玩家在下方,箱子在第二行靠右,目的地在第一行中间。外圈全是墙,这样移动时天然不会越界。这个细节很重要——我见过有人把地图边框做成 0,结果玩家一路走出地图,程序直接访问越界数组,弹窗崩溃。单关卡写死数组没问题,但你要是自己设计地图,最外层务必保留一整圈墙。
3.2 玩家移动判定:先看空地,再看能不能推箱子,顺序不能反
移动是整个游戏最复杂的部分。玩家按方向键后,面前可能是空地、墙、箱子,或者目的地。处理顺序有讲究:先判断要不要动,再判断动了之后原来那格恢复成什么。
void movePlayer(int dx, int dy) { int px, py; findPlayer(px, py); // 遍历地图,找到数字 4 或 6 所在的位置 int nx = px + dx, ny = py + dy; // 玩家要去的位置 int bx = nx + dx, by = ny + dy; // 箱子要被推到的位置 // 情况一:前方是空地或目的地,玩家直接走 if (map[ny][nx] == 0 || map[ny][nx] == 2) { if (map[py][px] == 6) map[py][px] = 2; // 原来站在目的地,恢复成目的地 else map[py][px] = 0; // 原来是空地,恢复成空地 map[ny][nx] = (map[ny][nx] == 2) ? 6 : 4; } // 情况二:前方是箱子,检查箱子前方是否还能推 else if (map[ny][nx] == 3 || map[ny][nx] == 5) { if (map[by][bx] == 0 || map[by][bx] == 2) { map[by][bx] = (map[by][bx] == 2) ? 5 : 3; // 箱子被推走或推到目的地 map[ny][nx] = (map[ny][nx] == 5) ? 6 : 4; // 玩家站到箱子原来位置 if (map[py][px] == 6) map[py][px] = 2; else map[py][px] = 0; } } // 情况三:前方是墙或箱子推不动,什么都不做 }代码逻辑说明:三段分支对应三种情况——空地直接走,箱子能推就连人带箱一起动,墙或者箱子顶死就原地不动。第二种分支里必须“先动箱子,再动玩家”,因为玩家原来站的位置需要用到map[py][px]判断是不是 6(站在目的地上的玩家)。如果先把玩家位置清成 0,这个信息就丢了,箱子推到目的地后地板编码会错乱。
参数说明:dx、dy是方向增量,上方向是(0, -1),右方向是(1, 0),下方向是(0, 1),左方向是(-1, 0)。调用时只需要传这四个组合之一。这一段代码是所有逻辑里最容易写错的地方,我当时照着书敲的时候就在这翻过车:少判断一个map[ny][nx] == 5,结果箱子在目的地时玩家推不动,游戏直接卡死。
3.3 胜利判定:地图里还有没有“光杆箱子”
什么时候算赢?所有箱子都到了目的地。翻译成数组逻辑就是:全图找不到数字 3。因为箱子一旦到位,就会变成 5,而不是继续保留 3。
bool checkWin() { for (int i = 0; i < ROWS; i++) { for (int j = 0; j < COLS; j++) { if (map[i][j] == 3) return false; } } return true; // 没有普通箱子了,说明全部到位 }这段代码只有 10 行,但它是整个游戏的“结束信号”。checkWin()放在每次movePlayer之后调用,返回true就弹出胜利提示,然后退出循环。判断条件的妙处在于,不必关心箱子在哪个位置,也不必记录什么“箱子总数”,地图状态本身就包含了答案。这也是二维数组地图方案相对其他做法的优势:逻辑和显示用的同一份数据,不会出现“地图上明明有红箱,判定却说没赢”的别扭情况。
4. EasyX 图形与音效:素材加载和渲染的细节
4.1 loadimage 加载 jpg:格式、路径、缩放三个隐藏前提
这份工程用 EasyX 图形库。EasyX 本身不是 VS 自带的,需要单独装,这个放到避坑章讲。加载图片的代码长这样:
IMAGE imgWall, imgBox, imgEnd, imgBlank, imgPeople, imgRedBox; void loadResources() { loadimage(&imgWall, "wall.jpg", SIZE, SIZE, true); loadimage(&imgBox, "box.jpg", SIZE, SIZE, true); loadimage(&imgEnd, "end.jpg", SIZE, SIZE, true); loadimage(&imgBlank, "blank.jpg", SIZE, SIZE, true); loadimage(&imgPeople, "people.jpg", SIZE, SIZE, true); loadimage(&imgRedBox, "redbox.jpg", SIZE, SIZE, true); }参数说明:loadimage第一个参数是图片对象地址,第二个是文件名,第三第四个参数是目标宽高,第五个参数true表示平滑缩放。这里的SIZE和地图格子大小是同一个值。为什么要在加载时指定缩放?因为素材原图尺寸很可能不是 64 × 64,直接贴会出现格子间错位、露边。在加载阶段统一尺寸,比在绘制阶段每个putimage都传一次缩放参数干净得多。
路径问题这里提前提醒一句:"wall.jpg"是相对路径,程序的工作目录里没有这张图,加载就会失败。用 VS 调试时工作目录是工程目录,所以素材放在工程目录下就行;直接双击 exe 时,素材必须和 exe 在同一层目录。这就是后面要讲的“调试正常、双击闪退”经典翻车的根源。
4.2 渲染循环:先画地板和墙,再画角色,顺序反了会出现叠层穿帮
绘制函数按地图数组把每格贴图画到窗口上。逻辑不复杂,但图层顺序有讲究:地基性质的元素先画,动态角色后画。
void drawMap() { for (int i = 0; i < ROWS; i++) { for (int j = 0; j < COLS; j++) { int x = j * SIZE, y = i * SIZE; switch (map[i][j]) { case 0: putimage(x, y, &imgBlank); break; case 1: putimage(x, y, &imgWall); break; case 2: putimage(x, y, &imgEnd); break; case 3: putimage(x, y, &imgBox); break; case 4: putimage(x, y, &imgPeople); break; case 5: putimage(x, y, &imgRedBox); break; case 6: putimage(x, y, &imgEnd); // 先画目的地 putimage(x, y, &imgPeople); // 再画站在上面的人 break; } } } }代码逻辑说明:case 6要画两张图,先画end.jpg再叠people.jpg,这样能看到“人站在终点上”的效果。顺序反过来的话,目的地被盖住,视觉上就是个普通玩家站在空地上,玩家一旦走开,终点突然出现,很出戏。case 5只画红箱一张就行,因为它已经包含目的地信息了。
坐标计算这里有个常量统一性问题。代码里所有x = j * SIZE、y = i * SIZE,说明SIZE、ROWS、COLS、窗口大小四者是联动的。我习惯在工程开头就把它们写在一起:
const int SIZE = 64; // 每格边长 64 像素 const int ROWS = 8, COLS = 8; // 地图 8 行 8 列如果地图尺寸改了,只动这三个常量就行。把数字散落在代码各处,改起来就是一场灾难。
4.3 _getch 读方向键:为什么方向键按了没反应
控制台程序读键盘用_getch(),但方向键在 Windows 下返回的不是一个字节,而是两个字节:第一个是前缀0xE0,第二个才是真正的扫描码。很多照着教程手敲代码的人只读一次返回值,直接把0xE0拿去和 72、80 比较,结果永远匹配不上。
int getDirectionKey() { int ch = _getch(); if (ch == 0xE0 || ch == 0) { // 方向键或功能键的前缀 ch = _getch(); switch (ch) { case 72: return 0; // 上 case 80: return 1; // 下 case 75: return 2; // 左 case 77: return 3; // 右 default: return -1; // 其他功能键,忽略 } } if (ch == 'w' || ch == 'W') return 0; if (ch == 's' || ch == 'S') return 1; if (ch == 'a' || ch == 'A') return 2; if (ch == 'd' || ch == 'D') return 3; return -1; }代码逻辑说明:先读第一个字节,如果是0xE0或者0,说明后面还有一个扫描码,再_getch()一次。72/80/75/77分别是上下左右的扫描码。这个写法同时支持方向键和 WASD,方便不习惯方向键的人操作。0xE0和0两种前缀都要处理,因为主键盘方向键返回0xE0,小键盘方向键有时返回0。只判断一种,某些键盘上就会失灵。
参数说明:这段代码里的数字是扫描码,不是 ASCII 码。W的 ASCII 码是 87,ascll 里H是 72,但方向键“上”的扫描码也是 72,两者含义不同,别混在一起。这也是为什么有些人按W有效、按方向键没反应的玄学原因——只处理了 ASCII 分支,没处理扩展键分支。
5. 常见问题与避坑指南:从编译到运行的一条龙排错
5.1 编译报错:无法打开 easyx.h 或 graphics.h
现象:工程一编译就报fatal error C1083: 无法打开包括文件: "easyx.h",或者graphics.h同样打不开。
原因:EasyX 不是 Visual Studio 自带的库,必须单独下载安装。老教程默认你已经装好,但新手几乎都会卡在这一步。这个和源码本身没关系,纯粹是环境缺件。
解决:去 EasyX 官网下载安装包,安装时选择你正在用的 VS 版本,装完重启 VS 重新生成。另外如果双击 exe 提示缺少VCRUNTIME140.dll之类,那是缺Microsoft Visual C++ Redistributable运行库,去微软官网装最新版 x64 运行库即可,跟源码无关,属于运行环境问题。这里我再强调一次:整个项目直接在 VS 里跑,别去 VSCode 里配 C/C++ 环境,VSCode 配 EasyX 要手动设置头文件路径、库路径、链接参数,对新手来说一步错步步错。
5.2 双击 exe 闪退,或界面出来了但全是黑块白块
现象:用 VS 调试运行一切正常,双击 exe 要么一闪就退,要么贴图全是色块。
原因:闪退多半是图片加载失败,程序里没有对loadimage返回值做检查,素材缺失时直接崩溃。黑块是因为贴图资源没找到,EasyX 绘制时输出的是默认底色。根本原因是工作目录变了:调试运行时 VS 会把工作目录设成工程目录,双击 exe 时工作目录是 exe 所在目录,素材不跟着走就找不到。
解决:把整个资源包原样解压,boxman.exe、所有 jpg、music.wav保持在同一层目录里。不要单独把 exe 拷出去,也不要从几个不同压缩包凑素材。血泪经验:我当年为了“干净”,只拷 exe 出去,结果所有关卡贴图全黑,查了半天才发现是路径问题。
5.3 方向键按了没反应,但 WASD 正常
现象:游戏能跑,按字母键能走,按方向键完全没反应。
原因:_getch()读方向键返回两个字节,代码只读了一次,把前缀0xE0当成有效方向值,和扫描码比对全部失败。这类代码在旧教程里出现频率很高,很多是从 Turbo C 时代继承下来的写法,对不上 Windows 的键盘输入模型。
解决:按第四章 4.3 节的双字节读取方案改。判断0xE0和0两种前缀,再读第二字节匹配 72、80、75、77。改完之后方向键和 WASD 都能用,兼容性最好。
5.4 fopen、scanf 在 VS2019 及以后版本报 C4996
现象:编译时出现error C4996: 'fopen': This function or variable may be unsafe,或者scanf同样报错。
原因:新版 Visual Studio 默认开启 SDL 安全检查,把老的 C 运行时函数标记为不安全,提示你用fopen_s、scanf_s替换。很多网上流传的老代码都是用fopen、scanf写的,放到新编译器下就会触发。
解决:两条路。一是改代码,把fopen改成fopen_s,scanf改成scanf_s,注意这两个函数参数个数不一样,改写时容易顺手改错。二是不想动代码,就在项目属性 → C/C++ → 预处理器 → 预处理器定义里加上_CRT_SECURE_NO_WARNINGS,编译告警直接消失。课程设计我建议选第一条,答辩时老师问起来,你至少能说出“这是新版编译器更安全的检查机制”这句话。
5.5 游戏能跑但背景音乐没声音
现象:画面、操作都正常,就是从头到尾没有音乐。
原因:mciSendString打开音频文件失败。常见原因有四类:music.wav不在工作目录、wav 文件不是标准 PCM 编码、winmm.lib没链接、别名bgm被占用或之前重复 open 了。压缩包里的 wav 一般没问题,问题更多出在你自己替换音乐之后。
解决:先确认 wav 和 exe 同目录,再确认文件是标准 PCM 格式。很多从网页下载的 wav 其实是压缩编码,mciSendString播放不了,用格式转换工具转成 PCM 即可。链接问题在代码开头加#pragma comment(lib, "winmm.lib"),这份工程本身已经加了,如果你是从零新建工程抄代码,别漏掉这一行。重复 open 的问题,可以在 open 前先close bgm,忽略错误继续执行。
6. 进阶玩法:加步数统计、多关卡和一步撤回
跑通这份工程之后,可以往三个方向做小改造。第一个是步数统计:声明一个int steps = 0,在movePlayer成功移动玩家之后steps++,再在窗口右侧用outtextxy绘制出来。地图是 8 × 8 格,每格 64 像素,占 512 像素宽,窗口开 640 宽的话右边正好留了 128 像素放文字,这个布局在 initgraph 那里就预留好了。
第二个是加多关卡。把二维数组改成三维:map[MAX_LEVEL][ROWS][COLS],再定义int level = 0。切换关卡时把map[level]拷贝到当前工作数组,或者直接让所有逻辑函数都能接收关卡索引。改动量不大,但效果很唬人,答辩时从“一关”变“十关”,观感完全不同。
第三个是撤回一步。这个最实用也最考验数据结构。我常用做法是维护一个地图历史栈:数组history[100][ROWS][COLS],每次移动前memcpy(history[stepCount], map, sizeof(map))把当前状态压栈。撤回时memcpy(map, history[stepCount - 1], sizeof(map))恢复上一帧。
// 移动前保存现场 if (stepCount < 100) { memcpy(history[stepCount], map, sizeof(map)); stepCount++; } // 按 R 键撤回一步 if (key == 'r' && stepCount > 0) { stepCount--; memcpy(map, history[stepCount], sizeof(map)); }这段代码的逻辑是:历史记录只允许回退 100 步,超过就忽略新记录;撤回只在stepCount > 0时生效。memcpy整块拷贝地图,比逐格循环赋值快得多,也省得写两层 for。注意撤回之后步数也要跟着减,不然统计对不上。
我拿到这个资源时,第一件事不是读代码,而是先双击 exe 玩了两把,把“推箱子在目的地变红”这个手感记住,再回头看main.cpp。后来给学弟推荐项目,我都要求他们按同样的顺序走一遍:先跑通、再拆代码、最后改一个自己设计的关卡。从那以后每次拿到陌生工程我都强制先跑 exe 再看源码,这个习惯帮我省掉的排查时间,比任何技巧都值钱。希望帮到你。
本文还有配套的精品资源,点击获取