资讯详情

C#推箱子小游戏源代码全解析:从二维数组到控制台游戏

📅 2026/10/11 6:35:54 | 华诺云谱 👁 阅读
C#推箱子小游戏源代码全解析:从二维数组到控制台游戏
简介采用C#开发的推箱子小游戏完整源码适合C#初学者、游戏编程爱好者及需要课程设计参考的读者。项目功能完整键盘推箱操作支持撤销CtrlZ与重做CtrlY内置选关系统通关后自动将最佳步骤保存为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 Stackchar[,] 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 上整整一个晚上后来想明白是状态恢复没做好再后来补了边缘墙和快照栈游戏才算真正像一个能给别人演示的作品。回头看这类小游戏难的不是语法而是对状态的敬畏——每次移动都别忘了原来那一格是什么。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑