资讯动态

C#推箱子小游戏开发实战:从地图设计到移动碰撞逻辑

发布时间:2026/10/17 2:54:04 来源:尧图企业网站定制
简介这是用C#编写的一套推箱子小游戏完整源代码工程面向刚开始接触Windows窗体编程或者对经典益智游戏实现感兴趣的学习者。整体功能较为齐全支持选关、撤销与重做操作通关时会把最优步骤记录为关卡步骤文件既可用来回放整个推箱过程也能分享给朋友查看自己的过关思路关卡内容还可以自行设计并且提供当前状态存盘与读盘能力。压缩包内含七十六个文件其中源代码文件、窗体设计文件与资源文件共同构成可直接编译运行的Visual Studio工程另有位图素材、地图配置和步骤记录等辅助数据整包大小仅一百三十五KB结构清楚、便于阅读。目前该资源已被九百六十余人浏览学习。如果希望了解推箱子中角色移动与箱子碰撞如何判定、关卡地图如何解析、撤销与重做如何用栈实现这份代码都能给出直观示例同时外置关卡文件的设计也方便大家自由扩展新地图适合课程设计或入门练手。1. C#推箱子小游戏源代码从拿到能跑到改成能玩的距离推箱子是游戏编程入门里最被低估的练手题目。规则一句话能说完但要把地图存储、移动判定、碰撞处理、胜利检测、界面刷新做成一个体验完整的C#小游戏涉及的知识点恰好是新手最该补的数组、事件、GDI绘制、状态管理全是C#基础面试常问的东西。这份源代码的价值在于它不是孤零零一段代码而是一个能跑、能改、能扩展的小项目关卡数据、绘制逻辑、游戏循环做了清晰分层。适合刚学完语法但没写过完整项目的新手也适合做课程设计想快速交差的学生改改地图文件就能变成自己的作品。2. 地图数据结构选型二维数组与关卡文件的取舍2.1 为什么用二维数组而不是字符串数组推箱子地图天然是网格棋盘结构按行、列排列。最直觉的数据结构就是int[,]二维数组第一维是行第二维是列每个格子存一个数字代表墙、空地、箱子或目标点。不少新手一开始会用string[]存地图每行字符串当一行地图取格子时先按行取字符串再按索引取字符还要把char转成int碰撞判定写起来相当绕代码片段散落在事件和各方法里。用二维数组的好处是语义直白地图数据本身是个纯数字矩阵后续渲染和逻辑都用同一份数据操作。判断某个位置能不能走直接map[row, col]取值一个if就能按数字分流到不同处理分支。数据表达和逻辑表达一致排错时看数值状态就能很快定位问题。选型上还有一个考量数组定长不能动态增删维度。推箱子关卡在游戏运行中尺寸固定不涉及动态扩容所以int[,]是恰好匹配的选择。理论上可以用ListList 但在这里没什么收益反而容易让新手在初始化内层列表时漏写new导致空引用。我一般把格子大小固定在40x40像素。这个尺寸下墙、箱子、目标点的视觉区分度最好关卡总尺寸也不会撑爆窗口。写代码时用常量CELL_SIZE定义后续想改整体缩放只动一个常量。2.2 数字约定一份0到6的地图字典地图用什么数字代表什么元素必须在一开始定死否则后面越写越乱。我常用的映射如下数字元素说明0空地可通行1墙不可通行2箱子不在目标点上3目标点玩家和箱子都能停4玩家普通状态5箱子在目标点箱子与目标点重合6玩家在目标点玩家与目标点重合5和6这两个状态非常关键它们存在的意义是当箱子或玩家离开这个格子时程序知道脚下原来是什么好把格子还原成3或0。不是所有开源代码都会保留这两个状态很多简化版本会丢失目标点被挡住后是否还存在的信息玩到第二关就出bug。初始化地图的代码长这样const int EMPTY 0, WALL 1, BOX 2, GOAL 3, PLAYER 4, BOX_ON_GOAL 5, PLAYER_ON_GOAL 6; int[,] map new int[8, 10] { { 1,1,1,1,1,1,1,1,1,1 }, { 1,0,0,0,0,0,0,0,0,1 }, { 1,3,2,0,4,0,0,0,0,1 }, { 1,0,0,0,0,0,0,3,0,1 }, { 1,0,0,2,0,0,0,0,0,1 }, { 1,0,0,0,0,0,3,2,0,1 }, { 1,0,0,0,0,0,0,0,0,1 }, { 1,1,1,1,1,1,1,1,1,1 } };逻辑说明先在一行里定义常量接着用数组初始化器声明8行10列的地图最外圈全是墙内部布置空地、目标点、箱子和玩家。这里故意没有直接使用数字5和6它们是在游戏运行中由移动逻辑动态产生的状态。参数说明map[0, 0]对应左上角格子map.GetLength(0)返回行数8map.GetLength(1)返回列数10。新手常踩的坑是把Length当行列数用Length返回的是一维扁平化后的总元素个数80不是行也不是列。2.3 绘制方案直接重写OnPaint而不是用PictureBox渲染层的常见做法是自己画而不是摆一堆控件。每个格子根据map[r,c]当下的数字决定画什么颜色或图形整体用一个Paint事件完成。protected override void OnPaint(PaintEventArgs e) { Graphics g e.Graphics; for (int r 0; r rows; r) { for (int c 0; c cols; c) { Rectangle cell new Rectangle(c * CELL_SIZE, r * CELL_SIZE, CELL_SIZE, CELL_SIZE); switch (map[r, c]) { case WALL: g.FillRectangle(Brushes.Gray, cell); break; case GOAL: g.FillEllipse(Brushes.LightGreen, cell); break; case BOX: g.FillRectangle(Brushes.Orange, cell); break; case PLAYER: g.FillRectangle(Brushes.Blue, cell); break; case BOX_ON_GOAL: g.FillEllipse(Brushes.Orange, cell); break; default: break; } } } }逻辑说明OnPaint是窗体需要重绘时自动回调的入口。每次玩家移动后调用Invalidate()系统就会安排一次OnPaint全图按最新map状态重新画一遍。这样渲染层不维护自己的状态地图数据是唯一数据源。为什么不用PictureBoxPictureBox本质是包了一层控件的绘图表面拖控件确实直观但你多了一级事件生命周期要管理。比如PictureBox背景色、焦点、尺寸和缩放都要单独处理而这些事一个OnPaint就能覆盖。做小游戏时减少中间层就是减少翻车点。2.4 关卡文件从外部加载改地图不再动代码原型阶段把地图写死在数组里没问题但想多放几个关卡代码会越堆越长。常见做法是把每个关卡放一个txt文件用固定字符映射元素。private int[,] LoadLevel(string filePath) { string[] lines File.ReadAllLines(filePath, Encoding.UTF8); int rows lines.Length; int cols lines[0].Length; int[,] mapData new int[rows, cols]; for (int r 0; r rows; r) { for (int c 0; c cols; c) { mapData[r, c] lines[r][c] switch { # WALL, $ BOX, . GOAL, PLAYER, * BOX_ON_GOAL, _ EMPTY }; } } return mapData; }逻辑说明File.ReadAllLines按行读入全部文本第一维维度就是行数第二维取首行长度。双层循环把每个字符switch成内部数字编码返回的二维数组与前面画地图用的格式完全一致。参数说明字符集约定为墙#、空格或_代表空地、$代表箱子、.代表目标点、代表玩家、*代表箱子在目标点。读文件时显式用Encoding.UTF8避免中文系统下ANSI编码导致的乱码。如果文件放在运行目录的Levels子文件夹调用时传相对路径Levels/level01.txt即可。一个必须注意的边界所有行必须等宽。某行末尾多一个空格就会让cols错位直接导致后面格子解析错乱。稳妥做法是加载时逐行检查长度不一致立刻抛出带行号的异常比游戏跑到一半画面花掉好排查得多。注意关卡文件里如果含中文注释务必保存为UTF-8编码用记事本默认的ANSI保存读取后注释会变成乱码连行列长度都受影响。3. 移动逻辑与碰撞检测从方向键到推箱判定的完整链路3.1 用KeyDown接收方向键别在KeyPress上浪费生命桌面小游戏的键盘输入第一反应是处理KeyDown而不是KeyPress。方向键在WinForms里属于功能键KeyPress事件收不到KeyCode你按键后事件根本没反应。private void MainForm_KeyDown(object sender, KeyEventArgs e) { int dr 0, dc 0; switch (e.KeyCode) { case Keys.Up: dr -1; break; case Keys.Down: dr 1; break; case Keys.Left: dc -1; break; case Keys.Right: dc 1; break; default: return; } if (TryMove(dr, dc)) { stepCount; Invalidate(); } }逻辑说明dr和dc表示行列方向偏移。向上是行号减1向下加1向左列号减1向右加1。四个方向统一换算成偏移量后交给TryMove后续碰撞逻辑只面对dr/dc不需要针对每个方向写分支。default: return是为了过滤掉回车、空格等无关按键。参数说明e.KeyCode是Keys枚举方向键直接用枚举常量比较。后续想支持WASD只要在switch里补case Keys.W: dr -1; break;等几行即可改动完全集中在输入层。顺便一提表单需要获得焦点才能收到键盘事件构造方法里一句this.KeyPreview true能解决大部分焦点问题。3.2 移动和推箱的三态判断TryMove是整份代码的核心逻辑上只有三种结果直接移动、推动箱子移动、动不了。下面这段是完整实现private bool TryMove(int dr, int dc) { int pr playerRow dr; int pc playerCol dc; if (map[pr, pc] WALL) return false; if (map[pr, pc] BOX || map[pr, pc] BOX_ON_GOAL) { int br pr dr; int bc pc dc; if (map[br, bc] WALL || map[br, bc] BOX || map[br, bc] BOX_ON_GOAL) return false; UpdateCell(br, bc, IsGoal(br, bc) ? BOX_ON_GOAL : BOX); UpdateCell(pr, pc, IsGoal(pr, pc) ? PLAYER_ON_GOAL : PLAYER); } else { UpdateCell(pr, pc, IsGoal(pr, pc) ? PLAYER_ON_GOAL : PLAYER); } UpdateCell(playerRow, playerCol, IsGoal(playerRow, playerCol) ? GOAL : EMPTY); playerRow pr; playerCol pc; return true; } private void UpdateCell(int row, int col, int value) { map[row, col] value; } private bool IsGoal(int row, int col) { return goalMap[row, col] 1; }逻辑说明先看玩家下一步格子。是墙就返回false是箱子或箱子在目标点继续看箱子后面那格箱子后头不能是墙、箱子或箱子在目标点否则推不动。移动成功后分三段更新箱子落点、玩家落点原箱子位置、玩家原位置。IsGoal配合一个bool[,]的goalMap辅助数组保存地图上哪些格子原本是目标点用于决定状态还原成GOAL还是EMPTY。这一段代码最容易漏的是最后的旧位置更新。玩家从目标点走出去之后旧格子要还原成目标点从普通空地走出去则还原成空地。漏掉这个处理目标点会被玩家踩没后续胜利判定彻底乱掉。这也是我为什么专门保留goalMap而不从map里推断目标点的原因——拿map当前值判断是3还是0只能拿到已经改完的状态拿不到原始属性。参数说明playerRow和playerCol是玩家当前坐标移动成功后统一同步。IsGoal用二维bool数组存储这个格子是不是目标点它在读关卡文件时生成之后只读不改。如果你不想多开一个数组也可以像很多实现那样在数组值里用高位标记目标属性但那样位运算对新手不友好。3.3 胜利检测只要场上存在普通箱子就继续胜利条件用一句话描述场上不存在值为BOX2的普通箱子。因为所有箱子只要在目标点上状态都会变成BOX_ON_GOAL5反过来还有值为2的箱子出现就说明有箱子没到目标点。public bool CheckWin() { for (int r 0; r rows; r) for (int c 0; c cols; c) if (map[r, c] BOX) return false; return true; }逻辑说明双重循环扫描全图遇到一个普通箱子立即返回false全部扫描完都没有再返回true。这个函数每次移动后调用O(rows*cols)的复杂度对推箱子这种每秒最多几次输入的游戏完全够用无需优化成缓存计数。如果你在意性能可以维护一个boxOnGoal计数器推箱子进目标点1推箱子离开目标点-1当计数等于总箱子数时胜利。但这是锦上添花的优化新手阶段用全图扫描足够代码也更好读。胜利后的处理一般是弹窗或画一个通关覆盖层同时把键盘事件禁用避免玩家胜利后还能继续操作造成状态混乱。禁用方式很简单把KeyDown事件里那段处理用if(gameOver) return;挡住或者移除事件订阅。4. 避坑与常见问题五个最容易翻车的C#细节4.1 事件重复绑定导致一步两动现象按一下方向键玩家移动两格打断点后发现事件处理函数被连续调用了两次。原因构造函数里写了this.KeyDown MainForm_KeyDown;同时设计器文件或Load事件里又绑了一次同一个方法被订阅两次一次按键自然触发两回。解决检查构造函数、Designer.cs、Load事件只保留一处绑定。防御式写法是先-再public MainForm() { InitializeComponent(); this.KeyDown - MainForm_KeyDown; this.KeyDown MainForm_KeyDown; }这个写法看起来多余但在代码经过多次复制粘贴后能防止重复订阅。尤其从旧项目里把Form代码拖过来改的时候新旧绑定很容易叠在一起。4.2 移动后画面疯狂闪烁现象每走一步窗体局部或整体闪一下拖动窗口时出现明显的残影和闪烁。原因WinForms默认绘制方式是先擦除背景再画内容高速重绘时表现为闪烁。用自绘OnPaint的窗体更容易触发。解决在构造函数里开启控件样式SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true);这三个flag组合是WinForms下开双缓冲的经典配置。AllPaintingInWmPaint让绘制全部在WM_PAINT消息内完成减少背景擦除UserPaint声明由代码接管绘制OptimizedDoubleBuffer启用后备缓冲区把离屏绘制结果一次性刷到屏幕。用PictureBox时也可以对PictureBox设DoubleBuffered但窗体级开启更省事。4.3 行列颠倒数组下标在这个项目里别用x/y命名现象按右方向键玩家往下走整个地图像旋转了90度箱子推动方向全乱。原因二维数组的第一维是行、第二维是列写代码时习惯性用x和y命名坐标x和横向绑定、y和纵向绑定映射到数组时就出现了行列交换。解决统一用row和col命名不要用x/y。方向映射关系固定成这样方向row变化col变化上-10下10左0-1右01把这表写在文件头部注释里。如果你用了键值对或字典来表示方向也要先经过这层换算再进数组索引不要直接用方向值加减行列。4.4 关卡文件读出来乱码或首行解析异常现象关卡文件里有中文注释时读取显示乱码第一行第一个期望是墙#解析后却多处理了一个奇怪字符。原因编码不一致。简体中文系统里记事本ANSI保存是GBK.NET的File.ReadAllLines默认按UTF-8解码中文字符就变成乱码。另一个问题是UTF-8文件带BOM某些读取方式会把BOM当字符读进来。解决文件统一保存为UTF-8代码读取时显式指定编码string[] lines File.ReadAllLines(filePath, Encoding.UTF8);自己写关卡文件时还要在解析前跳过空行和全空白行避免文件末尾的换行导致lines数组多出内容。判断条件可以写if (string.IsNullOrWhiteSpace(line)) continue;。每行长度检查同样放在验证阶段格式错误尽早报错。4.5 撤回栈的假撤回问题现象实现了Undo之后撤回两次得到的状态一模一样或者撤回一次画面和撤回前没有任何区别。原因把map二维数组直接Push进栈。二维数组是引用类型Push进去的是同一个数组对象的引用。后续移动改动原数组栈里保存的引用指向的还是同一个对象等到Pop时拿到的已经是被改动后的数据撤回自然无效。解决压栈前做深拷贝把当前地图复制一份再存private StackGameState undoStack new StackGameState(); private void PushState() { int[,] copyMap new int[rows, cols]; for (int r 0; r rows; r) for (int c 0; c cols; c) copyMap[r, c] map[r, c]; undoStack.Push(new GameState(copyMap, playerRow, playerCol)); }逻辑说明GameState是自定义类里面保存一个独立的二维数组副本和当时的玩家坐标。双层循环逐元素复制确保栈里存的是移动前的快照而不是对同一数组的引用。数组是引用类型这一点很多新手第一次写状态保存都会栽进去。参数说明撤回栈建议限制深度比如保留最近50步超出就RemoveAt(0)清掉最旧记录防止长时间游戏后内存增长明显。地图20x20时每份快照也就400个int但成百上千步累积下来同样占用可观内存。5. 进阶步数统计、关卡编辑与手动验证清单5.1 步数与计时给游戏加上反馈闭环步数统计在第3章的KeyDown里已经埋了stepCount现在要做的只是界面显示和计时器。计时用WinForms的Timer控件Interval设为1000毫秒每秒触发一次Tickprivate void Timer_Tick(object sender, EventArgs e) { if (gameOver) return; elapsedSeconds; lblTime.Text string.Format({0:00}:{1:00}, elapsedSeconds / 60, elapsedSeconds % 60); }逻辑说明gameOver在胜利时置true计时器立即停止累加。每秒更新一次Label显示mm:ss格式。重置关卡时把stepCount和elapsedSeconds归零撤回栈清空玩家坐标恢复到初始值计时重新开始。参数说明Timer不自动计算时间需要自己在Tick里面累加elapsedSeconds字段去掉了不必要的系统时间依赖。5.2 手动验证清单把翻车概率降到最低每次改完逻辑至少手动跑一遍下面这组用例箱子在地图中央时从四个方向分别推动确认都能推箱子贴墙时从贴墙方向推确认推不动两个箱子相邻时从任意方向推第一个箱子确认推不动把箱子推到目标点格子变为箱在目标点的样式把目标点上的箱子推走目标点恢复原样玩家从目标点离开目标点恢复原样连续快速按方向键确认不会产生两步操作或状态错乱胜利后按键确认游戏不响应连按撤回十次以上确认每一步都精确还原到对应状态。这份清单看着基础但每一条都是血泪经验换来的。从那以后我每次写完C#小游戏第一件事不是急着加功能而是把移动逻辑的边界用例手动跑一遍再交给别人玩。这个习惯帮我少翻了很多次车也让我后来接手其他项目时有了固定的验证套路。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑