简介:采用C#开发的推箱子小游戏完整源码,适合C#初学者、游戏编程爱好者及需要课程设计参考的读者。项目功能完整:键盘推箱操作支持撤销(Ctrl+Z)与重做(Ctrl+Y),内置选关系统,通关后自动将最佳步骤保存为level.way文件,既可回放通关过程,也便于分享过关思路;支持自行设计关卡及游戏状态存盘、读盘。压缩包约135KB,共76个文件,以10个.cs源码文件为主线,配以窗口界面资源(.resx/.resources)、关卡数据(.dat)、图片素材(.bmp)、记录文件(.way)等,目录结构直观。已有960人学习下载。读者可借此掌握WinForms界面搭建、键盘事件、游戏状态管理、文件读写与关卡解析等实用C#技能,同时项目预留自动寻路(深度优先+栈)、鼠标操控、通关光荣榜等扩展点,适合在此基础上升级二次开发。
1. 用C#写一个推箱子小游戏源代码:这个练手项目为什么值得做
推箱子(仓库番)是所有 C# 练手项目里性价比最高的一个。它的源代码体量不大,但把二维数组、枚举、碰撞判断、键盘输入、状态流转全串起来了;很多人学到数组和类就卡住,就是因为没有一条能把语法点串成完整程序的主线。这篇文章就按一套可以完整复现的推箱子小游戏源代码,把地图结构、移动规则、渲染、关卡加载和常见坑全拆开。新手照着敲能跑,熟手能从中看清状态设计和边界处理,整体投入一个周末就能做完。
2. 地图与移动:把推箱子的核心规则先写成纯逻辑
做渲染和输入之前,先把规则写清楚。推箱子游戏的所有状态,本质上只是一张二维网格和网格上的几个角色:墙、箱子、目标点、玩家。只要你把这几个实体用一种可查询、可修改的方式存下来,游戏就完成了一半。C# 里最直接的选择就是字符型二维数组char[,]。
2.1 用二维数组表示关卡:四类角色与字符约定
很多教程喜欢用string[]来存地图,每行一个字符串。但从可写性和修改成本来看,char[,]更优:字符串不可变,每次推箱子都需要临时拼接新的字符串,代码容易出错;而二维数组直接按下标读写,逻辑直观,性能开销也小。
下面是约定的字符集。这套约定是目前仓库番社区里比较通用的一套,几乎所有的开源关卡文件都按它设计:
| 字符 | 含义 | 说明 |
|---|---|---|
# | 墙 | 玩家和箱子都不能通过 |
| 空地 | 可通行 | |
. | 目标点 | 箱子需要被推到这里 |
@ | 玩家 | 玩家当前所在位置 |
$ | 箱子 | 尚未到达目标点 |
* | 箱子已在目标点 | 胜利判定时不计为未到位 |
+ | 玩家站在目标点 | 离开时恢复目标点标记 |
最后两个复合状态*和+非常重要,它们不是装饰,而是让目标点标记不被覆盖的关键。后面移动逻辑和胜负判定都依赖这两个字符。
下面是一个最小可运行的关卡,二维数组的行列结构对应屏幕上的横纵坐标:
// 5 行 8 列:最外圈全是墙,越界问题从源头消失 private static char[,] map = new char[,] { { '#', '#', '#', '#', '#', '#', '#', '#' }, { '#', ' ', ' ', ' ', ' ', ' ', ' ', '#' }, { '#', ' ', '.', '$', ' ', ' ', ' ', '#' }, { '#', ' ', ' ', ' ', '@', ' ', ' ', '#' }, { '#', '#', '#', '#', '#', '#', '#', '#' }, };这段代码里有三个细节值得注意:
[,]是 C# 的二维数组语法,第一维度是行,第二维度是列。后面取元素时用map[row, col]。- 数组最外圈全部是
#,这样玩家在移动判断时天然不会踩到数组边界,除非你自己把墙拆了。 - 玩家
@在第 4 行第 5 列,即map[3, 4]。这个坐标后面要单独维护,因为判断移动时经常要读写它。
为什么不用 List 或字典?因为地图的读取模式是高频随机访问,数组在内存里连续排列,遍历和下标访问都是最低成本。关卡即使做到 100×100,数组也只有 1 万个元素,完全不需要引入复杂结构。
2.2 玩家坐标与方向映射:枚举和坐标增量计算
玩家在地图里是一个字符,但玩家坐标必须单独维护。因为判断移动时要频繁读取map[playerY, playerX]以及它的前一格、前两格的字符。
方向建议用枚举定义,避免在代码里到处写魔法数字。下面是方向到坐标偏移的映射:
private enum Direction { Up, Down, Left, Right } // 返回 (行增量, 列增量),行索引向下增大,列索引向右增大 private static (int dy, int dx) GetOffset(Direction dir) { return dir switch { Direction.Up => (-1, 0), Direction.Down => (1, 0), Direction.Left => (0, -1), Direction.Right => (0, 1), _ => (0, 0) }; }参数和写法说明:
(int dy, int dx)是 C# 7.0 之后的值元组语法。dy是行的位移,dx是列的位移。向上移动行号减 1,所以Up返回(-1, 0);向右移动列号加 1,所以Right返回(0, 1)。switch表达式是较新 C# 版本支持的写法。如果项目还在用旧的编译器,改成传统switch语句返回即可,逻辑完全等价。- 用枚举而不是字符串的好处是,方向传错会在编译期就暴露,而不是运行到一半才发现该往左结果往右,这种方向相反的问题很容易变成玄学 bug。
枚举和增量分离之后,不管是玩家移动还是箱子移动,都用同一套位移量,杜绝了"向上其实是向左"这类低级错误。
2.3 移动与推箱判定:写成不依赖 UI 的纯逻辑
移动是整个游戏的核心动作。一次移动分四步:取目标格、判断能否进入、判断能否推箱、提交新状态。下面是完整实现:
private static int playerX = 4; private static int playerY = 3; private static bool TryMove(int dx, int dy) { int targetX = playerX + dx; int targetY = playerY + dy; char target = map[targetY, targetX]; // 前方是墙,直接不移动 if (target == '#') return false; // 前方是箱子,判断箱子后方是否有空间 if (target == '$' || target == '*') { int boxX = targetX + dx; int boxY = targetY + dy; char beyond = map[boxY, boxX]; // 箱子后面是墙、边界或另一个箱子,推不动 if (beyond == '#' || beyond == '$' || beyond == '*') return false; // 箱子新位置:如果那边是目标点就写 '*',否则写 '$' map[boxY, boxX] = (beyond == '.') ? '*' : '$'; // 玩家旧位置复位:之前若是 '+' 说明站在目标点上,恢复 '.';否则恢复空格 map[playerY, playerX] = (map[playerY, playerX] == '+') ? '.' : ' '; // 玩家走到箱子原位置 playerX = targetX; playerY = targetY; map[playerY, playerX] = (target == '*') ? '+' : '@'; } else { // 前方是空地或目标点,直接走 map[playerY, playerX] = (map[playerY, playerX] == '+') ? '.' : ' '; playerX = targetX; playerY = targetY; map[playerY, playerX] = (target == '.') ? '+' : '@'; } return true; }这段代码需要仔细读,它包含几个容易写错的点:
beyond的判断没写数组边界检查,是因为第 2.1 节的地图最外圈全是墙。这个前提一旦破坏,比如加载了没包墙的关卡文件,就得补边界判断,第 4 章会再讲。- 玩家旧位置复原用的是三元表达式,判断依据是玩家当初站在什么角色上。如果玩家站在目标点上,它的显示字符是
+,离开时就要把那一格恢复成.,否则目标点就被擦掉了。 - 箱子被推到目标点时写
*,被推到空地时写$。这样*和$的区分始终跟随着箱子,胜负判定才能简化成一个遍历。这一步遗漏的话,会出现第 5 章讲的"目标点被吃掉"。
TryMove的返回值表示本次移动是否真的发生了,这个布尔值在统计步数时有用。整个函数完全不碰控制台,不碰键盘,可以单独写单元测试,这就是"纯逻辑"的价值。测试时只需要构造地图、给坐标、调用TryMove,然后断言map的变化是否符合预期。
3. 控制台版跑起来:渲染、键盘输入与主循环怎么组织
逻辑层写得再干净,看不到画面就没人愿意玩。控制台版本是 C# 初学者最容易上手的载体,不需要引入任何第三方库,用System命名空间下的Console就能完成渲染和输入。
3.1 渲染一帧地图:把字符数组画到控制台
渲染的本质就是把char[,]按行列顺序输出。最简单、最不容易错的版本是直接输出字符本身:
private static void Render() { Console.Clear(); for (int y = 0; y < map.GetLength(0); y++) { for (int x = 0; x < map.GetLength(1); x++) { Console.Write(map[y, x]); // 直接显示 # . $ @ 等原始字符 } Console.WriteLine(); } }代码说明:
map.GetLength(0)获取二维数组第一维长度(行数),GetLength(1)获取第二维长度(列数)。不要写成map.Length,那是总元素个数。- 每个字符只写一个,天然对齐。用
Console.Write而不是WriteLine,是因为一整行写完再换行。 - 想美化显示也容易,把
Console.Write(map[y, x])改成switch映射,比如#显示成■、@显示成人,但要注意全角字符占两列,混用半角会错位。
Console.Clear()在每帧开头清屏,逻辑简单,代价是画面闪烁。这个问题留到第 5 章的避坑里给优化方案,先保证能看见画面。
3.2 单键输入与键盘映射:方向键和 WASD 一起支持
控制台读取单键输入,用的是Console.ReadKey(bool intercept)。很多人误用了ReadLine,结果是玩的时候还要按回车,手感完全不对。
private static Direction? ReadDirection() { var key = Console.ReadKey(true).Key; return key switch { ConsoleKey.UpArrow or ConsoleKey.W => Direction.Up, ConsoleKey.DownArrow or ConsoleKey.S => Direction.Down, ConsoleKey.LeftArrow or ConsoleKey.A => Direction.Left, ConsoleKey.RightArrow or ConsoleKey.D => Direction.Right, _ => null }; }几个参数要点:
Console.ReadKey(true)的第二个参数传true,表示截获本次按键且不把它回显到控制台。如果漏掉这个参数,控制台会把你按下的W、A等字符打印到屏幕上,破坏地图画面。ConsoleKey是枚举,比较时不区分大小写,所以w和W都会命中的W分支。- 返回类型是
Direction?,可空。按下无关键时返回null,调用方直接跳过。这样以后想加新功能键,比如悔棋U,只需要在ReadDirection之外单独判断。
方向键UpArrow之类是独立枚举值,不是字符,所以你用Console.ReadKey(true).Key拿到的就是它,而用Console.ReadKey(true).KeyChar拿到的可能是一个空格或特殊值,这也是常见的翻车点。
3.3 主循环:把渲染、输入、移动、判定串起来
游戏循环本质上就是一个无限循环:绘制当前状态,等待用户输入,处理输入产生的新状态,回到绘制。
private static void Main() { bool running = true; while (running) { Render(); Direction? dir = ReadDirection(); if (dir == null) continue; // 按了无关键,忽略 var (dy, dx) = GetOffset(dir.Value); bool moved = TryMove(dx, dy); if (IsWin()) { Render(); Console.WriteLine("通关!按任意键退出。"); Console.ReadKey(); running = false; } } }逻辑说明:
Render()放在输入之前,保证打开程序就有完整的一帧画面。TryMove被调用后,无论是否真的移动,都会重新进入循环,重新渲染。这样玩家按住方向键不会有残留画面。IsWin()在每次移动后调用,这里的默认行为是通关就退出。如果你想连关,可以把running = false换成"加载下一关"的逻辑。
到这里,一个能在控制台里玩的推箱子已经成型了。但还有一个问题:地图是写死在代码里的,想换关卡就得改代码重编译。下一章把它改成文件驱动。
4. 从内置关卡到文件驱动:加载与胜负判定的细节
内置地图只能演示第一关,真正的推箱子游戏必须支持自定义关卡。外置关卡文件的好处是改关不重新编译,也给玩家社区留了扩展空间。
4.1 关卡文本格式:用一行一个字符串描述一行地图
最简单、最通用的格式就是纯文本,每行对应地图的一行,字符含义和二维数组完全一致。把下面内容保存成Levels/level1.txt:
######## # # # .$ # # @ # ########这个关卡的目标点只有一个,箱子也只有一个,适合先跑通流程。文件名和路径可以自己定,约定好就行。我习惯把关卡放在项目下的Levels文件夹里,后续再放level2.txt、level3.txt方便扩展。
4.2 加载器:读文件、补齐宽度、边缘补墙
加载器的职责不只是"把文件读进数组",更重要的是处理脏数据:不规则行、空行、缺少边界墙。下面是完整实现:
private static char[,] LoadLevel(string path) { string[] lines = File.ReadAllLines(path); // 第一步:过滤空行,防止文件末尾多余空行干扰行数 lines = lines.Where(l => l.Trim().Length > 0).ToArray(); int rows = lines.Length; int cols = lines.Max(l => l.Length); // 取最长行的长度作为列数 // 第二步:初始化全空格地图,保证短行不会遗留垃圾数据 var inner = new char[rows, cols]; for (int y = 0; y < rows; y++) for (int x = 0; x < cols; x++) inner[y, x] = ' '; // 第三步:逐行拷入字符 for (int y = 0; y < rows; y++) for (int x = 0; x < lines[y].Length; x++) inner[y, x] = lines[y][x]; // 第四步:外圈补墙 map = AddWallBorder(inner); // 第五步:重新定位玩家坐标 for (int y = 0; y < map.GetLength(0); y++) for (int x = 0; x < map.GetLength(1); x++) if (map[y, x] == '@' || map[y, x] == '+') { playerY = y; playerX = x; } return map; } private static char[,] AddWallBorder(char[,] inner) { int rows = inner.GetLength(0); int cols = inner.GetLength(1); var bordered = new char[rows + 2, cols + 2]; // 四周全填墙 for (int y = 0; y < rows + 2; y++) for (int x = 0; x < cols + 2; x++) bordered[y, x] = '#'; // 内部复制原地图 for (int y = 0; y < rows; y++) for (int x = 0; x < cols; x++) bordered[y + 1, x + 1] = inner[y, x]; return bordered; }这里有几个必须理解的设计:
- 先算最长行的长度作为列数,然后让短行用空格补齐。如果不补齐,遍历时就会遇到
col超出某一行长度的情况,轻则取到脏数据,重则IndexOutOfRangeException。 AddWallBorder在索引上做了偏移:原地图的(0,0)变成新地图的(1,1),所以玩家坐标必须在补墙之后重新定位,不能在补墙前计算。- 这个加载器默认文件是 UTF-8 编码。如果你在 Windows 上遇到乱码,用
File.ReadAllLines(path, Encoding.UTF8)显式指定编码,第 5 章避坑里有详细说明。
关于坐标偏移,有个容易混淆的点:玩家字符@在 inner 里的坐标是(3,4),但补墙后整体加 1,变成(4,5)。加载器里的第五步是在补墙后的map上重新找,所以不会出错。如果你在别的地方又引用旧的playerX初始值,就会发生"玩家被卡在墙里"的怪现象,排查时先想想坐标源是否来自加载后的地图。
4.3 胜负判定:为什么箱子的两种状态必须分开
前面定义了$(未到位箱子)和*(到位箱子),这两个状态的最大价值体现在胜负判定上。判断赢没赢,只需要检查地图上还有没有$:
private static bool IsWin() { for (int y = 0; y < map.GetLength(0); y++) { for (int x = 0; x < map.GetLength(1); x++) { if (map[y, x] == '$') return false; // 还有箱子没到目标点 } } return true; // 所有箱子都已经在目标点上 }代码逻辑不复杂,但它的正确性完全依赖移动逻辑对*和$的维护:
- 箱子被推进目标点,那一格写成
*,表示"箱子在目标点上"。 - 箱子被推出目标点,那一格恢复成
.或空格,同时箱子新位置写成$。 - 玩家从目标点离开时,那一格恢复成
.,玩家新位置如果是箱子原来的位置且那里是目标点,则写成+。
这套互相咬合的状态流转,只要断一环,IsWin就会给出错误结果。比如移动逻辑漏了恢复目标点,导致某个箱子一直被认为是$,游戏永远赢不了。这种 bug 不是因为胜利判定写错了,而是上游状态没维护好。排查时要先看地图在移动前后到底发生了什么变化,而不是盯着IsWin看半天。
5. 推箱子开发避坑:五个高频翻车点与排查思路
控制台推箱子最大的好处是出错不致命,最大的坏处是出错不容易一眼看出因果。下面五个坑都是实际开发里容易遇到的,按现象、原因、解决来写,遇到问题直接对照排查。
5.1 闪屏:画面每帧都像在跳
现象:运行起来后画面不断闪烁,方向键连按时整个控制台像在震动。
原因:Render()里每次Console.Clear()然后重新输出全部字符。控制台窗口的刷新速度远低于代码执行速度,清屏产生的黑帧被肉眼捕捉到了。
解决:取消全清屏,改用Console.SetCursorPosition(0, 0)把光标移到左上角重新写。这样是覆盖旧字符,不出现黑帧:
private static void Render() { Console.SetCursorPosition(0, 0); // 不再 Console.Clear() for (int y = 0; y < map.GetLength(0); y++) { for (int x = 0; x < map.GetLength(1); x++) { Console.Write(map[y, x]); } Console.WriteLine(); } }注意,使用这种方案的前提是每次输出行数一致。如果输出行数变少,旧内容会残留在界面最下方,可以在循环结束后用空格行覆盖一遍。
5.2 目标点被吃掉:箱子离开后.不见了
现象:箱子被推走后,原来的目标点.消失了,之后无论怎么玩都通不了关。
原因:移动逻辑里只写了箱子新位置,没在箱子离开的位置恢复目标点。比如把map[boxY, boxX]改成了$,但忘了把箱子原来站的.恢复成.,于是那一格的.被player或空格覆盖了。
解决:严格按照第 2.3 节的顺序:先改箱子新位置,再恢复玩家旧位置,最后再移动玩家。恢复旧位置时用三元表达式判断原角色是+还是@,对应恢复.或空格。如果你发现目标点消失,打印移动前后整张地图对比,马上就能看出哪一格丢了。
5.3 数组越界:玩家贴着墙走直接崩
现象:玩家走到地图最边缘,再按方向键抛IndexOutOfRangeException,程序直接崩掉。
原因:关卡文件没有保证最外圈是墙,或者移动逻辑没做边界判断。TryMove里访问map[targetY, targetX]时,如果targetY或targetX超出数组维度,就会越界。
解决:两层保险任选其一。第一层在加载器里用AddWallBorder补一圈墙,物理上杜绝越界;第二层在TryMove开头手动判断坐标范围:
if (targetY < 0 || targetY >= map.GetLength(0) || targetX < 0 || targetX >= map.GetLength(1)) { return false; }我一般两层都写,因为加载器的补墙逻辑在以后换成联网关卡或内存数据时可能被绕过,移动函数自带判断更安全。
5.4 按键要按两下才有反应
现象:每次按方向键,控制台先显示一个字符,然后要再按一次才有行动,手感很差。
原因:用了Console.ReadKey()默认参数,按键内容被回显到控制台,同时读到的字符可能不是你想要的方向键值;或者干脆用了Console.ReadLine(),那就要按回车才算数。
解决:统一用Console.ReadKey(true),第二个参数传true表示截获按键不显示。方向键本身不是字符,用Console.ReadKey(true).Key拿枚举值,而不是.KeyChar。这个改动随手就能完成,但效果差异非常明显。
5.5 关卡文本显示成乱码
现象:在 Windows 上读取关卡文件后,非 ASCII 字符(比如注释里的中文标题)变成问号或乱码,严重时连路径都解析不了。
原因:文件是 UTF-8 编码,但File.ReadAllLines(path)在无 BOM 情况下按系统默认 ANSI 解码,而中文 Windows 默认是 GBK,两者对同一字节的解码结果完全不同。
解决:显式指定编码读取:
using System.Text; string[] lines = File.ReadAllLines(path, Encoding.UTF8);同时,在编辑器里创建关卡文件时把编码明确存成 UTF-8。如果已有的关卡文件是 GBK 保存的,那就统一改成Encoding.GetEncoding("GBK"),关键是读和存必须一致。
这五个坑多数时候源头不在最后爆出来的地方,而在移动逻辑的状态恢复和输入读取上。建议开发时每隔几关就做一次全图快照对比,能省很多排查时间。
6. 悔棋与步数统计:给游戏加一个能拿去演示的细节
如果要给别人演示,光能通关不够。加一个悔棋键和一个步数统计,观感立刻不一样,实现成本却很低。
最简单的可靠做法不是记录"哪一步做了什么",而是每步操作前把整个地图状态压栈。推箱子地图通常只有几十到几百个格子,快照成本很低,完全不用担心性能:
private static Stack<char[,]> history = new(); // 每次成功移动前调用 private static void PushSnapshot() { var snapshot = new char[map.GetLength(0), map.GetLength(1)]; Array.Copy(map, snapshot, map.Length); history.Push(snapshot); } // 悔棋:恢复到上一次操作之前 private static bool Undo() { if (history.Count == 0) return false; map = history.Pop(); // 悔棋后重新定位玩家坐标 for (int y = 0; y < map.GetLength(0); y++) for (int x = 0; x < map.GetLength(1); x++) if (map[y, x] == '@' || map[y, x] == '+') { playerY = y; playerX = x; } return true; }关键点在于Array.Copy做了深拷贝。二维数组是引用类型,直接把map压栈的话,后续修改地图会连带修改历史快照,悔棋就变成了一纸空文。另外,栈建议设置容量上限,比如 200 步,防止长时间挂机导致内存无限增长。
主循环里增加悔棋按键:
var key = Console.ReadKey(true).Key; if (key == ConsoleKey.U) { Undo(); continue; }步数统计同样简单,在TryMove返回true时加一即可。如果想区分移动步数和推箱次数,可以给TryMove加一个out bool pushed参数,在推箱分支里设为true。通关后输出:
Console.WriteLine($"通关!共走 {moveCount} 步,推箱 {pushCount} 次。");这一步虽然不难,但对玩家来说却非常有反馈感。我最初写推箱子时,卡在目标点被吃掉的 bug 上整整一个晚上,后来想明白是状态恢复没做好;再后来补了边缘墙和快照栈,游戏才算真正像一个能给别人演示的作品。回头看,这类小游戏难的不是语法,而是对状态的敬畏——每次移动都别忘了"原来那一格是什么"。希望帮到你。
本文还有配套的精品资源,点击获取