尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C#推箱子小游戏开发:从二维数组建模到完整实现

C#推箱子小游戏开发:从二维数组建模到完整实现 简介C#推箱子小游戏源代码是一份面向C#初学者与WinForms游戏开发者的完整项目实例集中展示了键盘交互、游戏状态管理、撤销与重做、地图及进度文件存取等核心编程技巧。压缩包共76个文件、约135KB包含C#源码、窗体设计资源、界面图片、关卡数据、可直接运行的exe以及Visual Studio工程文件整体小巧且结构清晰。已有960人学习下载程序支持CtrlZ撤销、CtrlY重做、选关并能将最优通关步骤保存为level.way文件用于回放演示也可自行设计关卡和存盘读盘。自动寻路、鼠标操作和通关光荣榜虽未完成但由这些功能延伸出的深度优先搜索、链表栈应用与界面交互改造恰好可作为进阶练习方向。对于课程设计或自学这份代码既能直接运行体验也适合在此基础上扩展功能或重构。1. 推箱子小游戏不是练手玩具它把C#语法串成一条主线一份 C# 推箱子小游戏的源代码比十页语法笔记更能说明白 C# 到底怎么用。很多人学完 c# 入门教程类、数组、集合都认得一合上书却写不出一个能跑的程序。推箱子恰好是那个能把语法串成主线的项目它只需要控制台不需要图形库却要把二维数组、输入处理、状态判断全部用一遍。这份笔记按「地图建模 → 移动判定 → 胜利判定 → 踩坑 → 进阶」的顺序把推箱子小游戏源代码拆开讲透。适合刚学完C#基础、想拿小游戏编程练手的人也适合用来给新手布置综合练习。看完你不仅能复现还能自己往上加关卡和功能。2. 把地图变成二维数组先定数据格式再写逻辑2.1 用字符表示地图推箱子最朴素的建模方式推箱子一眼看过去是「人推箱子到目标点」落到程序里要先回答一个问题地图长什么样常见做法是把地图表达成规则的字符网格每个格子一个字符等宽排列。约定一套字符集全项目统一用字符含义运行时归谁管#墙碰撞检测只读空格空地可走.目标点targets 列表$箱子boxes 列表玩家玩家坐标*箱子已在目标点绘制用解析时当普通箱子玩家站在目标点绘制用解析时当普通玩家选用二维数组 char[,] 而不是一维数组或字符串拼接理由很直接推箱子所有逻辑都在问「某个格子是什么、某个物体在哪一行哪一列」二维数组可以用 map[row, col] 一步取到格子不用自己算偏移绘制时按行扫描输出顺序天然对齐后面要加装饰、加机关改一个字符就行逻辑层完全不用动。这里有一个值得注意的设计决策不要把「箱子在目标点上」当成箱子的一种属性存起来。存成属性意味着每走一步都要同步状态而用 targets.Contains(box) 判断既简单又不会出现状态同步遗漏。绘制时想显示 * 和 只需在 Draw 里临时判断。2.2 关卡数据用 string[] 存放可读、可改、可扩展地图建模的下一步是把关卡数据写成代码里能直接读的文本。我一般用 string[]每行是一个字符串所有行等宽string[] level1 { ########, # #, # .$$ #, # $ #, # #, ######## };这段数据表达的是 8 列 6 行的地图6 行字符串每行 8 个字符。从上到下第二行是# .$$ #注意它和第三行# $ #长度一致都是 8。所有行必须等宽这是推箱子地图数据最基础的约束理由是后面解析成 char[,] 时要知道列数。如果某行短了一格那行右边的格子会被当作越界轻则显示错位重则运行时崩溃。使用 string[] 而不是一个带 \n 的 string收益在维护性想看某一行的内容直接看数组的第几个元素要改关卡只动一行的文本即可换行符也不会混进数据里。这也是源代码组织里「关卡配置与游戏逻辑分离」的体现——关卡是数据逻辑是代码数据驱动逻辑后面加第二关就变成加一个数组元素。实际做关卡时建议每个关卡的字符串长度都保持一个字面量对齐例如统一 8 列、10 列。列数不统一会让解析代码额外做补位下面这节会处理这种边界情况。2.3 解析地图把字符变成玩家坐标与箱子列表有了 string[] 关卡数据接下来把它解析成运行时对象。解析工作放在 Game 类的构造函数里public class Game { private char[,] map; // 当前地图后续碰撞检测直接读 private int playerRow, playerCol; // 玩家当前的行、列 private List(int r, int c) boxes new(); // 箱子位置列表 private List(int r, int c) targets new(); // 目标点位置列表 public Game(string[] lines) { int rows lines.Length; int cols lines.Max(l l.Length); // 取最长行作为列数防地图缺格 map new char[rows, cols]; for (int r 0; r rows; r) { for (int c 0; c cols; c) { char ch c lines[r].Length ? lines[r][c] : ; map[r, c] ch; // 缺的格子补成空地 if (ch $) boxes.Add((r, c)); if (ch .) targets.Add((r, c)); if (ch ) { playerRow r; playerCol c; } } } } }这段解析有几个容易被忽略的细节逐个说清楚。第一col 用 lines.Max(l l.Length) 取得而不是 lines[0].Length。这样就算关卡数据里某行差了空格也不会在初始化时直接越界缺的格子会被补成 。这是一个防御性写法代价只有一个空格判断。第二$ 只进 boxes 列表. 只进 targets 列表 只记录玩家坐标。箱子、目标点、玩家被拆成三个独立数据源而不是每次从 map 里现找。原因在于移动和判胜都高频访问「箱子的位置」列表用坐标元组表示后续 FindIndex、Contains 都很方便。C# 的 (int r, int c) 是值类型存进 List 后与 map 里的字符不再关联移动时直接改列表里的坐标即可。第三 和 在解析时没有单独处理。这两个字符是给绘制用的优化标记运行时看到 应该既进 boxes 又进 targets看到 应该既进玩家坐标又进 targets。为了让这段代码保持可读我没把这两个分支写进构造函数——实际扩展时可以在循环里加一个 if 分支处理。你需要知道的是这种「一个字符同时表达两种身份」的设计是推箱子游戏的常用做法能省掉绘制时的二次判断。解析完成之后Game 对象就持有了地图、玩家位置、所有箱子位置、所有目标点位置。后面所有的移动、推动、判胜都围绕这几个状态做文章。这也解释了为什么用二维数组建模是第一步它是整个游戏的地基。3. 用方向键驱动角色TryMove 与碰撞判定3.1 移动先算目标格把「想走」和「能不能走」分开玩家按方向键程序第一件事不是立刻改坐标而是先算「如果走会走到哪个格子」。这是推箱子移动逻辑的基本套路玩家坐标 (playerRow, playerCol)加上方向增量 (dr, dc)得到目标格 (newR, newC)。根据目标格内容分三种情况目标是墙玩家不动。目标是空地玩家直接移过去。目标是箱子检查箱子的下一个格。如果箱子下个格是墙或另一个箱子玩家和箱子都不动否则玩家走一格箱子被往前推一格。把「想走」和「能不能走」分开的好处是TryMove 只接收方向增量不用关心具体是哪个方向四方向共用同一套碰撞逻辑。后面如果要做关卡回放、自动解谜或接入手柄输入只需要往 TryMove 里传增量逻辑不用重写。这也是一种源代码组织上的收益核心逻辑与输入方式解耦。方向增量本身如何定义需要提前统一。我习惯用 (行增量, 列增量)上 (-1, 0)、下 (1, 0)、左 (0, -1)、右 (0, 1)。这个约定必须在整个项目里贯彻到底否则画面上玩家上下跑数据里却往左右跑排查起来相当折腾。控制台程序里经常有人把行和列弄反后面避坑章节会专门再讲。3.2 TryMove一次处理玩家移动和箱子推动移动核心方法如下public bool TryMove(int dr, int dc) { int newR playerRow dr; int newC playerCol dc; // 1. 撞墙目标格是墙直接失败 if (IsWall(newR, newC)) return false; // 2. 目标格上是箱子尝试推动 int boxIndex boxes.FindIndex(b b.r newR b.c newC); if (boxIndex 0) { int boxNewR newR dr; int boxNewC newC dc; // 箱子前面是墙或箱子推不动 if (IsWall(boxNewR, boxNewC)) return false; if (boxes.Any(b b.r boxNewR b.c boxNewC)) return false; // 推动更新箱子坐标 boxes[boxIndex] (boxNewR, boxNewC); } // 3. 玩家移动 playerRow newR; playerCol newC; return true; } private bool IsWall(int r, int c) { if (r 0 || r map.GetLength(0) || c 0 || c map.GetLength(1)) return true; // 边界外一律当墙 return map[r, c] #; }这段逻辑有三个关键点也是新手最容易写错的地方。第一边界处理。IsWall 把数组越界当成墙处理。推箱子地图外围本来就是墙正常关卡数据不会让玩家越界但地图数据一旦有缺口防御性写法能避免运行时抛 IndexOutOfRangeException。坐标合法性的检查放在 IsWall 里集中管理TryMove 就不用每次散落一堆 if。第二箱子推动必须同时检查「箱子下一个格是墙」和「箱子下一个格是另一个箱子」。只检查墙会出现两个箱子重合玩家把箱子 A 推进箱子 B 的格子然后两个箱子叠在一起画面直接穿模。元组列表里 boxes.Any 判断够用现阶段关卡小不需要引入空间哈希。第三boxes[boxIndex] (boxNewR, boxNewC) 直接覆盖元组列表长度不变。箱子被推进目标点、或从目标点被推出来都不需要额外状态标记——目标点本身还在 targets 里判胜时拿 boxes 和 targets 比对即可。3.3 主循环与按键映射ReadKey 的正确用法控制台推箱子的主循环可以写成static void Main(string[] args) { Game game new Game(Levels[0]); while (true) { Draw(game); // 每按一次键重绘一帧 ConsoleKey key Console.ReadKey(true).Key; // 读取按键不回显 int dr 0, dc 0; switch (key) { case ConsoleKey.UpArrow: dr -1; break; case ConsoleKey.DownArrow: dr 1; break; case ConsoleKey.LeftArrow: dc -1; break; case ConsoleKey.RightArrow: dc 1; break; case ConsoleKey.R: game new Game(Levels[0]); continue; default: continue; } game.TryMove(dr, dc); } }Console.ReadKey(true) 的 true 表示不把按下的字符显示在控制台上。如果不传 true方向键会在画面里打出奇怪的字符整个界面很快被污染。另一个重点是 .Key 而不是 .KeyChar方向键属于扩展键KeyChar 是 \0如果用 ReadKey().KeyChar 去判断永远匹配不到任何方向键而 .Key 返回的是 ConsoleKey.UpArrow 这类枚举判断方向键、回车、R 键都可靠。主循环的节奏也值得说明每按一次键就重绘一帧没有 Timer、没有帧率响应完全取决于用户按键速度。对于回合制推箱子这是最合适的循环模型不需要额外引入游戏循环线程。如果你以后想给推箱子加动画、加时间限制再引入固定帧率循环但那是另一套东西了。R 键重置关卡这里直接用 new Game(Levels[0]) 重建实例最省事也最不会留脏状态。按键映射可以用一张表固定下来写成注释放在主循环上方方便后来人改键键ConsoleKey方向增量 (dr, dc)↑UpArrow(-1, 0)↓DownArrow(1, 0)←LeftArrow(0, -1)→RightArrow(0, 1)RR重置当前关有了 TryMove 和主循环推箱子已经可以跑起来地图能显示、人能走、箱子能推。下一步的问题是怎么让游戏知道玩家赢了。4. 胜利判定与关卡切换别让目标点重复计数4.1 IsWin每个箱子都在目标点上推箱子的胜利条件一句话所有箱子都停在目标点上。写成代码public bool IsWin() { foreach (var box in boxes) { if (!targets.Contains(box)) return false; } return true; }这个写法依赖元组值比较两个 (int r, int c) 只要行、列相等就相等不需要自定义比较器。箱子数量一定等于目标点数量关卡设计如此所以检查「所有箱子」等价于「所有目标点被占」。有人会把胜利条件写成统计目标点上有几个箱子、再和目标点数量比较这是一个隐患如果关卡数据里两个箱子叠在同一个目标点上前面提到过的数据错误统计会数出数量相等的假阳性直接误判胜利。用 Contains 逐个检查能避开这类问题因为两个箱子叠在一个点上意味着另一个目标点必然空着Contains 一定会发现那个箱子不在目标点上。这也是我坚持 IsWin 里一个 Contains 都不可省的原因。关卡数据里还会有 表示箱子已在目标点。解析时 同时加入 boxes 和 targetsIsWin 无需专门处理它。绘制时单独判断某箱子坐标在 targets 里就显示 *玩家坐标在 targets 里就显示 。显示层和逻辑层保持分离IsWin 永远只认 boxes 与 targets 两个列表这是整个推箱子代码架构里最值得保留的一个决定。4.2 关卡切换重新 new 一个 Game游戏不可能只有一关。把多张地图放进一个数组static string[][] Levels { new string[] { ########, # #, # .$$ #, # $ #, # #, ######## }, // 第二关、第三关往后加数组元素即可 };切换关卡时不要试图去「恢复」当前 Game 的现场直接 new 一个新实例static int currentLevel 0; static void LoadLevel(int index) { currentLevel index; game new Game(Levels[index]); }new Game 会把地图、箱子列表、目标点列表、玩家坐标全部重建等价于一次性重置所有状态不会漏掉某个字段。这个做法在推箱子这种「状态全部来自关卡数据」的游戏里尤其省心Game 的构造函数是唯一的初始化入口任何重置需求都走同一条路。主循环里判断胜利可以这样接if (game.IsWin()) { Console.WriteLine(过关按 N 进入下一关按 R 重玩本关。); ConsoleKey key Console.ReadKey(true).Key; if (key ConsoleKey.N currentLevel Levels.Length - 1) LoadLevel(currentLevel 1); else if (key ConsoleKey.R) LoadLevel(currentLevel); continue; }注意 N 键要判断 currentLevel 是否还有下一关否则按到最后一关会越界。编程里这类「数组下标访问前先检查边界」的习惯在游戏关卡切换里体现得最直接。关卡切换本身不难难的是把「切换」和「重置」的语义想清楚统一走同一个入口。4.3 重置关卡最容易留脏状态的环节重置 重新加载当前关代码只有一行LoadLevel(currentLevel)。这里真正要小心的不是代码而是设计心态。有人想省事给 Game 加一个 Reset 方法把初始坐标存下来手动恢复结果箱子列表被改过、玩家坐标被改过、撤销栈没清重置完还带着上一局的残留数据。我的习惯是凡是「回到某一关的初始状态」一律 new Game凡是「撤销一步」用栈做状态回退下一章会展开。这两种操作语义不同不要混用。重置对应的是放弃本关重新来撤销对应的是后悔一步后者保留步数、保留这局的操作历史前者全部清零。多关卡游戏还有一个数据层面的检查每个关卡的目标点数量必须等于箱子数量。写一个关卡校验函数在 LoadLevel 时断言 boxes.Count targets.Count不为 true 就抛异常这样关卡数据写错时能立刻暴露而不是让玩家在游戏里发现「这关根本过不去」。推箱子游戏的源代码里数据校验和游戏逻辑一样重要甚至更重要因为一份错误的地图数据会让前面所有逻辑白写。现在关卡能过、能切、能重置骨架已经完整。但控制台小游戏真正劝退新手的不是逻辑而是一堆细细碎碎的显示与输入问题。下一章列几个我踩过的坑。5. 踩坑记录控制台闪烁、方向键失灵与地图越界5.1 画面闪烁残影反复 Clear 是最粗暴的重绘现象每次按键后 Console.Clear() 再重画画面明显闪烁快速按键时出现残影、字符重叠。原因Console.Clear() 会清空整个缓冲区然后 Draw 从第一行开始重新输出。控制台窗口的清屏和重绘之间有可见的时间差行数越多越明显。移动过程中玩家和箱子路径上的旧字符没有被清干净也会留下「影子」。解决放弃 Clear把光标移回左上角再重绘static void Draw(Game game) { Console.SetCursorPosition(0, 0); // 回到原点覆盖上一帧 for (int r 0; r rows; r) { for (int c 0; c cols; c) { Console.Write(GetChar(game, r, c)); } Console.WriteLine(); } }只要新帧覆盖了完整区域旧内容自然被替换无需清屏。如果地图小于控制台窗口比如地图只有 8 列窗口 80 列上一帧的行尾残留字符会露出来此时需要额外用空格填充到窗口末尾或者在 Draw 末尾 Console.Write(new string( , width - cols)) 补白。处理完后闪烁基本消失这个小改动也是控制台游戏从「能跑」到「能看」的分水岭。闪烁问题看起来像玄学其实根因就是一个没有把上一帧完整盖住。5.2 方向键读不到KeyChar 是 \0现象按上、下、左、右游戏一点反应都没有按字母键却正常。原因Console.ReadKey() 返回值包含 Key 和 KeyChar 两个属性。方向键、F1-F12 这类扩展键的 KeyChar 是 \0很多新手用 Console.ReadKey().KeyChar 去 switch永远匹配不到方向键直接翻车。还有一个容易踩的版本是 ReadKey(false) 让控制台把按键回显出来方向键会打出特殊字符破坏画面。解决用 .Key 属性判断并在 ReadKey 传 trueConsoleKey key Console.ReadKey(true).Key; switch (key) { case ConsoleKey.UpArrow: // 上 case ConsoleKey.DownArrow: // 下 case ConsoleKey.LeftArrow: // 左 case ConsoleKey.RightArrow: // 右 }判断 Key 还有一个连带好处回车键、空格键、字母键都能统一用枚举判断不需要区分字符大小写。读不到方向键的问题绝大多数情况不是程序逻辑错而是用了 KeyChar。5.3 地图行长度不一致解析期不炸运行期炸现象地图某行少打一个空格或字符程序启动时报数组越界或者启动没报错但玩家走到某个位置行为异常。原因string[] 关卡数据里各行长度不同解析时如果循环按第一行的列数遍历较短的行走到最后就越界。我见过一份源代码里地图数据有五处对不齐程序在解析第七个关卡时才崩溃排错很折磨人。解决解析时取最长行做列数缺的格子补空格int cols lines.Max(l l.Length); ... char ch c lines[r].Length ? lines[r][c] : ;除此之外写一个关卡数据校验函数在 LoadLevel 统一检查行数大于 0、所有行不包含未知字符、箱子数与目标点数相等、玩家只有一个。校验在初始化阶段做比游戏运行到一半暴露问题好得多。5.4 箱子穿墙和叠箱忘记检查箱子后面的格子现象玩家推箱子时箱子穿过墙跑到墙外面或者两个箱子叠在一起。原因TryMove 里只判断了「玩家要去的格子是箱子」没有继续判断「箱子要去的格子能不能站」。箱子的下一个格是墙时箱子不可能被推过去下一个格是另一个箱子时两个箱子都该原地不动。漏了这两个判断箱子就会被玩家顶到非法位置。解决见 3.2 的 TryMove 完整实现。任何一次推动必须同时检查箱子的目标格是墙、箱子目标格上有别的箱子。检查顺序也有讲究先查玩家目标格是墙再查玩家目标格是箱子再查箱子目标格这个顺序保证「玩家→箱子→空地」的连锁推算不会中间断裂少一步画面就会穿模。5.5 关卡数据里多个 玩家后一个覆盖前一个现象某关地图有两个 玩家移动时位置跳来跳去或者开局位置不对。原因解析循环里遇到 就赋 playerRow、playerCol后面再遇到会直接覆盖前面。地图数据错得隐蔽运行期又不会报错只是行为诡异。解决解析时对 计数发现多于一个就抛异常明确指出关卡文件第几行存在重复玩家。同理目标点 . 数量和箱子 $ 数量不等时也要抛异常。这套校验让「数据错误」在开局就暴露而不是让玩家在玩到一半时觉得关卡设计有问题。控制台推箱子的排错一半时间其实花在数据合法性检查上这部分不值得省。6. 给推箱子加上步数、撤销与死锁检测从能玩到好玩6.1 步数与撤销用栈保存状态快照推箱子有了移动和判胜只算「能玩」加一个撤销功能才是玩家体验的关键。常见做法是用 Stack 保存每一步操作前的状态快照private Stack(int pr, int pc, List(int, int) boxesSnapshot) undoStack new(); public bool TryMove(int dr, int dc) { // 在移动前压栈玩家坐标 箱子列表副本 undoStack.Push((playerRow, playerCol, boxes.ToList())); // ... 原有碰撞与移动逻辑 } public void Undo() { if (undoStack.Count 0) return; var s undoStack.Pop(); playerRow s.pr; playerCol s.pc; boxes s.boxesSnapshot; steps--; }注意两个细节。第一栈里存的是 List 的副本boxes.ToList()不是引用如果直接存引用后面移动改的是同一个 List所有历史快照都跟着变撤销就失效了。第二栈元素用值类型元组包住列表引用配合 ToList 的浅拷贝每次快照的代价很小推箱子关卡几十步操作完全顶得住。按 R 重置关卡时记得清空 undoStack否则重置后还能撤销回重置前的状态语义就混乱了。6.2 死锁检测极简版墙角判定推箱子等级越高越容易推入死局箱子进了墙角、贴了墙边永远无法到达目标点。常见做法是在每次移动后做一个简化死锁检测private bool IsDeadlocked() { foreach (var (r, c) in boxes) { if (targets.Contains((r, c))) continue; // 已到位的箱子不参与 bool wallUp r 0 ? map[r - 1, c] # : false; bool wallDown r rows - 1 ? map[r 1, c] # : false; bool wallLeft c 0 ? map[r, c - 1] # : false; bool wallRight c cols - 1 ? map[r, c 1] # : false; if ((wallUp wallLeft) || (wallUp wallRight) || (wallDown wallLeft) || (wallDown wallRight)) return true; } return false; }这是个启发式检测不是完备判定有些真实死锁检测不出来有些假死锁会被误报但作为入门级学习项目够用。检测到死锁后常见的做法是提示玩家并按 R 重置。这一步加上之后游戏才从「能通关」变成「能判断玩家是否在浪费生命」玩家体验会有本质差别。我最早写推箱子源码时上下方向一直调反排查半天才发现是二维数组行列与屏幕坐标理解不一致。后来所有代码统一用 (row, col) 命名地图第一行对应 row 0、第一列对应 col 0上下左右全部按这个坐标系翻译再没出过这个问题。这个经验也分享给你写推箱子之前先把坐标系写进注释能省掉一晚上的排错。希望帮到你。本文还有配套的精品资源点击获取
返回列表