简介:二维数组是游戏开发中最基础的数据结构之一,天然适合表达网格类游戏的地图与状态。理解字符编码与状态切换,是构建逻辑正确、可扩展的推箱子核心。EasyX作为C++初学者常用的图形库,能将控制台逻辑转化为可视化交互体验。从地图数据设计、碰撞判定的算法原理,到双缓冲绘图、按键轮询与撤销管理,工程实践中的常见坑点都有对应解法。这类项目不仅能巩固C++语法,还能培养结构化编程思维,常用于课程设计或入门级游戏开发实践。本文以一个完整推箱子为例,结合EasyX图形库,梳理从编码、移动判定到窗口交互的关键实现路径,帮你避开新手最容易踩的坑。
1. 推箱子小游戏听起来简单,落地时为什么比你想的麻烦
初学 C++ 的人刚啃完循环、数组和指针,最喜欢拿小游戏当练手。推箱子往往是第一个“看着不难”的目标:一个二维数组、一个玩家、四个方向键,感觉一下午就能写出来。真动手才发现,地图编码、推箱判定、绘图顺序、按键响应、窗口闪烁、中文字符集,每一环都能让你卡到怀疑人生。用 C++ 配合 EasyX 写推箱子,本质上是在做一件事:用数组描述状态,用逻辑约束状态变换,再用绘图库把每一帧画面画出来。它适合刚学完 C++ 语法、想完整跑通一个图形小项目的人,也适合想在 EasyX 里实现复杂交互的初学者。下面的方案是我实际做这类游戏时验证过的顺序,照着走能少碰很多壁。
2. 先定地图数据结构和移动逻辑:数组是推箱子的地基
2.1 地图的标准化编码:用字符数组表达墙壁、箱子、目标点
推箱子最核心的资产是地图。地图本质是二维网格,网格每个位置要么是墙、要么是空地、要么是箱子、要么是目标点,还要标记玩家。用二维字符数组是最直观的存储方式。我用这套编码:空格代表空地,#代表墙,$代表普通箱子,.代表目标点,@代表玩家,*代表已推到目标点上的箱子,+代表站在目标点上的玩家。这套方案的关键收益在于:每个格子的“底层属性”和“上层实体”被压缩成一个字符,调试时直接读得懂。
初始化一个关卡的方式如下:
const int ROWS = 7; const int COLS = 20; // 预留足够宽,避免每行字符串末尾的 '\0' 越界 char map[ROWS][COLS] = { "#######", "#. #", "# $ #", "# @.#", "# $ #", "# #", "#######" };这里有个新手很容易踩的坑:用字符串给二维字符数组初始化时,一行"#######"实际占 8 个字节(末尾有'\0'),如果COLS只声明成 7,编译不报错,但运行时遍历或拷贝一定会越界。所以我在声明时把COLS设成 20,地图实际宽度用strlen(map[0])动态获取,后面的所有遍历都以这个宽度为准。另外每行的可见字符数必须一致,否则地图会歪掉。如果你不习惯 C 风格数组,也可以改用vector<string>,但后面做状态快照时 char 数组配合memcpy最方便,所以我保留数组写法。
2.2 玩家移动、推箱判定与“不能推”的三个条件
移动逻辑是整个游戏的地基。“能不能动”取决于三个连续判断:玩家下一步位置必须在地图内且不是墙;如果该位置有箱子,则箱子前方必须在地图内且不是墙且不是另一个箱子;通过后才真正更新地图。这个顺序不能乱,尤其是要先判断再赋值,不能边判断边改。
下面是移动函数,依赖全局变量px、py,它们在地图初始化时被赋值为@所在位置:
int mapW() { return strlen(map[0]); } bool inside(int x, int y) { return x >= 0 && x < mapW() && y >= 0 && y < ROWS; } bool tryMove(int dx, int dy) { int nx = px + dx, ny = py + dy; // 1. 玩家目标位置是否不可达 if (!inside(nx, ny) || map[ny][nx] == '#') return false; // 2. 如果目标位置是箱子,检查箱子能否被推动 if (map[ny][nx] == '$' || map[ny][nx] == '*') { int bx = nx + dx, by = ny + dy; if (!inside(bx, by) || map[by][bx] == '#' || map[by][bx] == '$' || map[by][bx] == '*') return false; // 箱子推到新位置 map[by][bx] = (map[by][bx] == '.') ? '*' : '$'; // 箱子原来的位置:如果是 '*',恢复目标点,否则恢复空格 map[ny][nx] = (map[ny][nx] == '*') ? '.' : ' '; } // 3. 玩家原来的位置恢复 map[py][px] = (map[py][px] == '+') ? '.' : ' '; // 4. 玩家移动到新位置 px = nx; py = ny; map[py][px] = (map[py][px] == '.') ? '+' : '@'; return true; }这里最关键的是状态恢复:玩家原来站在目标点时是'+',不能直接清成空格,否则目标点就永久消失了;箱子同理,'*'推走后当前格要还原成'.'。另外,玩家移动到新位置时要看那个格子是不是目标点,是则记为'+',不是则是'@'。很多版本写着写着丢目标点,就是这两处没有恢复。
2.3 用 EasyX 加载图片之前,先用控制台把逻辑跑通
图形库会遮掩很多问题。我强烈建议先写一个控制台版本,把地图用putchar打印,用getch()接收方向键,把移动和推箱逻辑验证一遍。控制台版调试的效率比图形版高得多,因为你不需要处理窗口、贴图、异步绘制这些外部因素。
int px = 4, py = 3; // 初始玩家位置,对应地图第3行第4列的 @ int main() { char cmd; while (true) { system("cls"); for (int r = 0; r < ROWS; r++) { for (int c = 0; c < mapW(); c++) putchar(map[r][c]); putchar('\n'); } cmd = getch(); if (cmd == 'w') tryMove(0, -1); else if (cmd == 's') tryMove(0, 1); else if (cmd == 'a') tryMove(-1, 0); else if (cmd == 'd') tryMove(1, 0); } }这里getch()是阻塞读,每按一下走一步,适合逐步确认逻辑。控制台版还能顺手验证越界:如果inside写错,访问到map[-1][0]不会立刻崩溃,但打印出来的地图会多出随机字符,这种“诡异现象”就是在告诉你边界判断有问题。把这里跑通之后,再接 EasyX 时你只需要关注绘图,不用再怀疑逻辑。
3. 用 EasyX 把地图画到窗口:绘图循环与贴图踩坑
3.1 createimage 与 putimage 的配合:别在每次循环里 loadimage
接 EasyX 后的第一个错误,通常是把loadimage写在主循环里,以为每次绘制都“重新加载图片”是安全的。这个习惯会拖慢帧率,甚至导致 GDI 对象泄漏。正确做法是在initgraph之后一次性加载所有素材到IMAGE对象,游戏循环里只负责贴图。
我用下面的结构初始化贴图:
IMAGE imgWall, imgBox, imgTarget, imgPlayer, imgFloor; void initImages() { loadimage(&imgWall, _T("wall.png"), 64, 64); loadimage(&imgBox, _T("box.png"), 64, 64); loadimage(&imgTarget, _T("target.png"), 64, 64); loadimage(&imgPlayer, _T("player.png"), 64, 64); loadimage(&imgFloor, _T("floor.png"), 64, 64); }loadimage的第三个参数是缩放后的宽,第四个是高。如果素材不是 64x64,缩放在这里做掉,绘制时就不用再关心尺寸。如果文件不存在,loadimage不会让程序崩溃,但绘出来是黑块,所以initImages之后建议检查IMAGE的getwidth(),为零立即提示资源缺失。这一点在 VSCode 配置 C++ 环境时尤其重要,因为 EasyX 库的链接参数配错时,编译会报 unresolved external symbol,但链接过了又跑不出图,多半就是图片路径写成了相对路径而工作目录不对。
3.2 绘制顺序:先地板再道具,避免箱子被背景覆盖
推箱子地图的一个格子可能同时是“目标点”和“箱子”,也可能同时是“目标点”和“玩家”。绘制顺序如果不分层,后画的会盖住先画的,典型事故就是箱子被地板盖没。我把绘制顺序定为:地板 → 墙 → 目标点 → 箱子 → 玩家。
void drawMap() { int w = mapW(); for (int r = 0; r < ROWS; r++) { for (int c = 0; c < w; c++) { int x = offsetX + c * TILE; int y = offsetY + r * TILE; char ch = map[r][c]; putimage(x, y, &imgFloor); if (ch == '#') putimage(x, y, &imgWall); if (ch == '.' || ch == '*' || ch == '+') putimage(x, y, &imgTarget); if (ch == '$' || ch == '*') putimage(x, y, &imgBox); if (ch == '@' || ch == '+') putimage(x, y, &imgPlayer); } } }注意这里把每个格子的地板都先铺一层,墙壁也铺,因为墙壁下面可能有透明背景的贴图,先铺地板可以避免窗口底色漏出来。目标点贴图要在箱子之前,这样箱子压着目标点,玩家压着目标点,视觉上能看出位置关系。如果你想让目标点明显,可以用一个半透明的图,但不建议直接用透明 PNG,EasyX 对透明通道的处理需要用到putimage的透明参数,新手阶段先做叠放更简单。
3.3 窗口尺寸与画面缩放:让地图居中而不是写死坐标
很多示例把窗口硬编码为 500x500,然后地图从 (0,0) 开始画。这个做法在小地图上没问题,一旦关卡变成 15 列或者缩小成 5 列,画面不是溢出就是偏在角落。更稳的方案是:根据地图实际行列数反推窗口宽度,并计算一个居中偏移量。
const int TILE = 60; int w = mapW(), h = ROWS; int winWidth = max(w * TILE + 240, 480); // 右侧留出 240 像素的提示区 int winHeight = max(h * TILE + 40, 360); initgraph(winWidth, winHeight); int offsetX = (winWidth - w * TILE) / 2; int offsetY = (winHeight - h * TILE) / 2;offsetX和offsetY是全局变量,绘制时每个格子坐标都要加上它们。winWidth里留出的 240 像素是给步数、撤销说明、胜利提示留的信息区,这样游戏画面和信息区不会重叠。如果以后想加缩放功能,把TILE提升为全局变量并勾选“允许调整窗口大小”的窗口风格即可,但要注意TILE变化后,贴图要重新loadimage一次。
4. 交互与状态:按键响应、步数统计和撤销功能
4.1 用 GetAsyncKeyState 轮询按键而不是阻塞等待
控制台版用getch()没问题,但 EasyX 窗口下调用getch()会让窗口失去响应,因为getch()在等待控制台输入,而图形窗口的事件泵没有被处理。正确做法是用GetAsyncKeyState轮询虚拟键,它只检查按键是否按下,不会阻塞游戏循环。
while (!quit) { if (GetAsyncKeyState(VK_UP) & 0x8000) tryMove(0, -1); else if (GetAsyncKeyState(VK_DOWN) & 0x8000) tryMove(0, 1); else if (GetAsyncKeyState(VK_LEFT) & 0x8000) tryMove(-1, 0); else if (GetAsyncKeyState(VK_RIGHT) & 0x8000) tryMove(1, 0); // 绘制... Sleep(100); }GetAsyncKeyState返回值的最高位表示当前是否按下,所以要和0x8000做位与。Sleep(100)是简单消抖,否则按键一直按住时角色会以“每帧多次”的速度狂跳。更精细的做法是记录每帧时间,用时间差控制移动间隔,但推箱子这种慢节奏游戏 100ms 足够了。
4.2 步数统计与过关判定:每一帧检查一次就够了
步数统计的触发点不是按键,而是tryMove成功。每次tryMove返回true时step++,顶墙不算步数。过关判定是另一件事:所有箱子都已经被推到目标点上,也就是地图里不存在'$'。注意'$'是“未归位箱子”,'*'是“已归位箱子”,所以只需要统计'$'的数量。
bool checkWin() { int w = mapW(); for (int r = 0; r < ROWS; r++) for (int c = 0; c < w; c++) if (map[r][c] == '$') return false; return true; }这个函数放在主循环绘制之前调用就够了,不需要在tryMove的每一个分支里插入胜利判断。地图再大也就几十个格子,全遍历一次的开销远小于一次绘图,性能不是问题。
4.3 撤销后悔药:用数组快照存历史状态
推错一步想反悔,是特别常见的诉求。实现撤销最简单的方案是状态快照:每次移动成功前,把map、玩家坐标、步数保存到一个vector。撤销时弹回最近一个状态。因为地图数据是紧凑的 char 数组,memcpy拷贝非常快。
struct GameState { char map[ROWS][COLS]; int px, py, steps; }; vector<GameState> history; void saveState() { GameState st; memcpy(st.map, map, sizeof(map)); st.px = px; st.py = py; st.steps = steps; history.push_back(st); } bool undo() { if (history.empty()) return false; GameState st = history.back(); history.pop_back(); memcpy(map, st.map, sizeof(map)); px = st.px; py = st.py; steps = st.steps; return true; }在tryMove成功 returntrue之前调用saveState(),撤销按键调用undo()。这里有个内存考量:GameState里 map 是 20x20,一个状态不到 400 字节,一千步也才 400KB,不用担心。不过如果想限制撤销深度,可以在push时判断history.size() >= 50则清空最早的一条,避免无限增长。
5. 新手最容易翻车的 5 个坑:从死循环到乱码
5.1 窗口一闪而过
现象:运行程序,窗口只出现一瞬就消失,看不到画面。
原因:main函数在initgraph后只画了一帧就退出,图形窗口随进程销毁。EasyX 自己不会维持窗口,必须让进程保持在消息循环里。
解决:主循环用while加退出标志,按 ESC 才退出。结构是initgraph → initImages → while(!quit){轮询按键、绘制、延时} → closegraph。千万不要在initgraph后面直接return 0,除非你只画静态图。
5.2 中文乱码
现象:outtextxy输出“步数”显示成一堆乱码。
原因:EasyX 的outtextxy默认使用宽字符(TCHAR),直接传入const char*会编码不匹配;同时项目字符集设置也会影响字符串类型。
解决:把项目字符集设为“使用 Unicode 字符集”,所有中文字符串用_T()包裹。数字也要先格式化到 TCHAR 数组:
TCHAR buf[32]; _stprintf_s(buf, _T("%d"), steps); outtextxy(offsetX, offsetY - 30, buf);_T()宏会自动适配窄宽字符,这是 EasyX 项目里处理中文的统一姿势。
5.3 方向键按下没反应
现象:鼠标操作正常,但按上下左右角色不动。
原因:GetAsyncKeyState的参数写成了字符'w'而不是VK_UP,或者返回值判断漏了& 0x8000。很多人会用 MessageBox 看返回值,发现一直是 0,就是参数不对。
解决:方向键必须用VK_UP/VK_DOWN/VK_LEFT/VK_RIGHT,数字键用'0'到'9'、字母键用'A'到'Z'。判断按下时写if (GetAsyncKeyState(VK_LEFT) & 0x8000)。如果仍没反应,用 MessageBox 输出GetAsyncKeyState的原始值排查。
5.4 箱子被推过了墙或两个箱子重叠
现象:箱子推到地图边缘后还能继续移动,甚至叠到另一个箱子上。
原因:tryMove里先移动玩家和箱子,再判断新位置是否合法,或者判断时只看了箱子当前位置,没检查箱子前方。
解决:严格按“玩家前方 → 箱子前方”两步提前判定,所有检查通过后才统一执行更新。具体代码见 2.2。记住一条硬规则:凡是涉及碰撞的逻辑,必须先算后写,不要边写边判。
5.5 画面闪烁
现象:每次刷新画面时,窗口明显闪动。
原因:直接在主窗口 DC 上逐格绘制,一帧里画了很多次,屏幕内容在中间状态暴露了。
解决:用 EasyX 的双缓冲接口,把一轮绘制全部放进内存,再一次性贴到屏幕:
BeginBatchDraw(); drawMap(); EndBatchDraw();要注意BeginBatchDraw和EndBatchDraw必须严格成对。如果你在中间用return或break跳出,缓冲区会一直挂着,后续画面不再更新。建议把绘制封装成一个函数,双缓冲只在这个函数外层调用。
6. 进阶:关卡切换、地图校验与逻辑自测技巧
当你把基础版跑通,接下来的实用价值来自三个方向:把地图从代码里抽出来、校验地图合法性、用随机场景压测逻辑。关卡文件格式用纯文本最简单,每行是一行地图,行数不能超过 ROWS。读取后要检查玩家的数量、箱子和目标点的数量。合法地图必须恰好一个玩家,箱子数等于目标点数。少了这条校验,你会遇到“所有箱子推完却不过关”的怪问题,那多半不是代码 bug 而是地图 bug。
读取关卡文件的写法可以用下面这段,每行先补满空格,再拷入文件内容,避免行长度不一致造成残留:
ifstream fin("level1.txt"); string line; int r = 0; while (getline(fin, line) && r < ROWS) { memset(map[r], ' ', COLS - 1); strncpy(map[r], line.c_str(), COLS - 1); map[r][COLS - 1] = '\0'; r++; }切换关卡时,把新地图memcpy到map,重置px、py、step和history。注意每次切关后mapW()会变化,如果窗口尺寸不变,画面偏移量offsetX、offsetY要用新宽高重新计算,否则第二关看起来偏向一边,这是我实际踩过的坑。
最后是一个我每次改动核心逻辑都会做的自测:用随机地图乱跑。生成一个 5x5 的封闭房间,随机放若干箱子和目标点,随机调用tryMove几百次,程序不崩溃说明边界处理基本可靠。当然随机地图可能没有解,所以不要拿它测通关逻辑,只用来测内存安全。养成这个习惯之后,你会发现调试 EasyX 的玄学问题少了很多,因为绝大多数你觉得是绘图库的错,其实都是逻辑状态被改坏了。希望帮到你。
本文还有配套的精品资源,点击获取