☰
C#推箱子游戏源码:WinForms实战与可调试游戏骨架
2026/9/29 18:43:07 网站建设 项目流程

简介:本资源是一份基于C#语言实现的经典推箱子益智游戏完整源码,面向C#初学者、游戏开发入门者及希望掌握Godot引擎与桌面游戏逻辑设计的开发者。项目采用面向对象设计,涵盖关卡管理、角色控制、碰撞判定与状态渲染等核心模块,可作为游戏开发学习与课程实践的优质参考案例。压缩包共36个文件(132KB),包含13个C#源码文件(如Player.cs、Level.cs、Sokoban.csproj)、10个TSCN场景文件(定义关卡布局与节点结构)、1个Godot项目文件(project.godot)、1个解决方案文件(Sokoban.sln)及SVG/PNG图形资源等,类型分工明确,便于理解分层架构与资源组织方式。目前已有92人学习下载,代码结构清晰、注释规范,附带license.md许可说明与readme.txt说明文档,适合直接导入Visual Studio与Godot引擎运行调试,快速掌握C#游戏逻辑编写、场景驱动开发及跨文件协同设计方法。

1. 这不是玩具代码:一个能编译、能调试、能改出新关卡的C#推箱子游戏源码,专治“学完语法却写不出完整程序”的焦虑

你有没有试过:C#基础语法背得滚瓜烂熟,LINQ写得比 foreach 还顺,但一想做个“能动的游戏”,立刻卡在“窗体怎么响应键盘”“地图数据存在哪”“箱子推不动是逻辑错还是坐标没更新”上?这不是你不行——是缺一个真实可拆解、可打断、可逐行调试的完整游戏骨架。这份基于C#语言的经典推箱子游戏设计源码,就是这样一个“带血丝的工程级样本”:它用原生WinForms(非Unity/Unreal),不依赖第三方游戏引擎,全程使用System.Drawing绘图、KeyEventArgs捕获方向键、二维数组存地图状态,所有逻辑都在GameBoard.cs和PlayerController.cs里摊开写。它不是教学Demo,而是能直接F5运行、按方向键推箱子、通关后弹窗提示、支持自定义关卡文件(TXT格式)的可交付产物。适合刚学完类、委托、事件、集合的C#新手,也适合想快速验证算法逻辑(如路径搜索、状态回溯)的中级开发者——因为它的核心碰撞检测和移动判定,就藏在不到200行的MovePlayer()方法里,没有黑匣子,只有清晰的if-else和坐标偏移。


2. 从零加载:环境准备、项目结构与关键类职责拆解

2.1 开发环境与最低兼容要求:VS2019+ .NET Framework 4.7.2 是黄金组合

这份源码基于传统.NET Framework构建,而非.NET Core/.NET 5+,原因很实际:WinForms在Framework下对GDI+绘图控制更稳定,尤其在处理像素级地图渲染和键盘连击响应时,延迟更低。我实测过,在VS2022中打开项目会提示“需安装.NET Framework 4.7.2开发工具包”,不要跳过这步——否则System.Drawing.Common引用会报错,窗体根本无法绘制地图。安装路径:Visual Studio Installer → 修改已安装版本 → 勾选“.NET Framework 4.7.2 SDK”和“Windows 10/11 SDK”。若你坚持用VS2022+NET6,需手动将<TargetFramework>net472</TargetFramework>改为<TargetFramework>net6.0-windows</TargetFramework>,并替换System.Drawing为Microsoft.Win32.Registry(因.NET6默认不包含GDI+),但代价是键盘响应偶尔丢帧——这是血泪经验,新手建议老实退回VS2019。

<!-- 项目文件 .csproj 中的关键框架声明 --> <TargetFramework>net472</TargetFramework> <UseWPF>false</UseWPF> <UseWindowsForms>true</UseWindowsForms>

提示:源码包中bin/Debug/目录下已预编译好可执行文件,双击Sokoban.exe即可运行,验证环境是否OK。若弹窗报“缺少DLL”或“无法加载程序集”,说明.NET Framework未装全,别折腾代码,先重装SDK。

2.2 项目结构五件套:每个文件都承担明确角色,拒绝“一坨大类”

整个项目共7个核心文件,绝非“一个Form1.cs打天下”。这种分层不是炫技,而是为后续扩展留活口:

文件名类型核心职责修改安全区
MainForm.csWinForms窗体初始化游戏、绑定键盘事件、触发重绘循环✅ 可增按钮(如“重玩”“关卡选择”)
GameBoard.cs自定义控件继承Panel,封装地图绘制、格子坐标转换、像素级碰撞检测⚠️ 修改绘图逻辑需同步更新GetCellFromPoint()
LevelLoader.cs工具类解析TXT关卡文件(#=墙,@=玩家,$=箱子,.=目标, =空地),生成二维字符数组✅ 新增关卡只需放TXT到Levels/目录
PlayerController.cs业务逻辑类处理玩家移动、箱子推动、目标达成判断、撤销操作(Ctrl+Z)✅ 加入AI自动寻路可在此类注入算法
GameState.cs状态枚举定义Playing/Won/Paused三种状态,驱动UI开关✅ 可扩展Loading状态用于读取大关卡

特别注意GameBoard.cs:它重写了OnPaint()方法,用Graphics.FillRectangle()逐格绘制,而非用PictureBox贴图。好处是——每个格子的坐标(X,Y)和地图索引(row,col)能1:1映射,后续做“点击格子选中箱子”或“高亮可行路径”时,不用再算偏移量。这是推箱子类游戏最易翻车的点:很多Demo用绝对坐标硬编码,结果换分辨率就错位。

2.3 关键类协作流程:一次按键如何触发“玩家移动→箱子推动→胜利判定”全链路

按下→键的瞬间,事件流如下(简化版,省略异常处理):

  1. MainForm_KeyDown()捕获Keys.Up→ 调用playerController.Move(Direction.Up)
  2. PlayerController.Move()计算目标位置(newRow, newCol) = (currentRow-1, currentCol)
  3. GameBoard.IsWalkable(newRow, newCol)检查该格是否为空地或目标点 → 若是,更新玩家坐标
  4. 若目标格是箱子($),则调用GameBoard.PushBox(newRow, newCol, Direction.Up):
    • 计算箱子前方格:(boxFrontRow, boxFrontCol) = (newRow-1, newCol)
    • 检查boxFront是否可通行(非墙、非箱、非边界)→ 若否,移动失败,返回false
    • 若可通行,将箱子字符$移至boxFront,原位置置为空格 ,玩家坐标更新为箱子原位置
  5. GameBoard.CheckWinCondition()遍历所有目标点.,确认是否全部被箱子$覆盖 → 全部满足则触发MainForm.ShowWinDialog()

这个链条里,IsWalkable()和PushBox()是真正决定游戏成败的函数。它们不依赖任何外部库,纯靠数组索引判断,所以你能用断点一行行跟——比如在PushBox()里设断点,看boxFrontRow是否越界(<0 || >=rows),这就是新手常踩的“推箱子掉出地图”坑的根源。


3. 地图与关卡:TXT文件格式规范、加载逻辑与自定义关卡实战

3.1 TXT关卡文件必须遵守的四条铁律:少一个空格就加载失败

关卡文件(如Level1.txt)不是随意写的文本,它遵循严格格式,否则LevelLoader.LoadLevel()会抛出FormatException。规则如下:

  • 每行字符数必须相等:即地图是标准矩形,不能有“最后一行少两个字符”的情况
  • 只允许5种字符:#(墙)、@(玩家起点)、$(箱子)、.(目标点)、 (空地,ASCII 32,不是tab!)
  • 玩家@和箱子$数量必须≥1,目标.数量必须等于箱子$数量(否则CheckWinCondition()永远为false)
  • 文件末尾不能有空行:File.ReadAllLines()会把空行读成长度为0的字符串,导致rows计数错误

一个合规的Level1.txt示例(8×6):

######## #.@.$..# #....$.# #.$....# #...$.## ########

注意:第二行#.@.$..#中,@和$之间是两个空格,不是tab;末行########后无换行符。用Notepad++打开,开启“显示所有字符”(View → Show Symbol → Show All Characters),能一眼揪出非法字符。

3.2 LevelLoader.LoadLevel()源码解析:三步解析法,每步都有容错设计

public static char[,] LoadLevel(string filePath) { string[] lines = File.ReadAllLines(filePath); if (lines.Length == 0) throw new ArgumentException("Empty level file"); int rows = lines.Length; int cols = lines[0].Length; // 以首行长度为基准 // 步骤1:校验每行长度一致 for (int i = 1; i < rows; i++) { if (lines[i].Length != cols) throw new FormatException($"Row {i} length {lines[i].Length} != expected {cols}"); } char[,] board = new char[rows, cols]; int playerCount = 0, boxCount = 0, targetCount = 0; // 步骤2:逐字符加载并计数 for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { char ch = lines[r][c]; if ("#@$. ".Contains(ch)) // 仅允许这5个字符 board[r, c] = ch; else throw new FormatException($"Invalid char '{ch}' at ({r},{c})"); if (ch == '@') playerCount++; else if (ch == '$') boxCount++; else if (ch == '.') targetCount++; } } // 步骤3:校验核心约束 if (playerCount != 1) throw new FormatException($"Exactly one player '@' required, found {playerCount}"); if (boxCount != targetCount) throw new FormatException($"Boxes '$' count ({boxCount}) must equal targets '.' count ({targetCount})"); return board; }

这段代码的精妙在于提前暴露问题:不是等到游戏运行中“推不动”才报错,而是在加载关卡时就抛出明确异常。比如你把$写成&,它会告诉你Invalid char '&' at (2,3);如果目标点少一个,直接提示Boxes '$' count (3) must equal targets '.' count (2)。这种设计让你调试关卡时,不用在游戏里反复试错,极大节省时间。

3.3 手动创建新关卡:从画草稿到验证成功的三分钟流程

假设你要设计一个3×3的极简关卡(测试逻辑用):

  1. 草稿阶段:在纸上画3×3网格,标出墙#、玩家@、箱子$、目标.。例如:

    #.@ #$. ###

    注意:右下角#确保箱子无法推出边界。

  2. TXT编写:用记事本新建文件,严格按行列输入,关闭自动换行(避免长行折行),保存为Levels/MiniTest.txt(路径必须与代码中"Levels/" + levelName + ".txt"匹配):

    #.@ #$. ###
  3. 代码注入:打开MainForm.cs,找到LoadLevel("Level1")调用处,改为LoadLevel("MiniTest")。编译运行,若看到玩家在左上角、箱子在中间、目标在右上角,说明加载成功。若报错,根据异常信息定位——比如提示Row 1 length 2 != expected 3,说明第二行你只打了#$.(2字符),漏了末尾换行或空格。

提示:源码包中Levels/目录已含5个难度递进的关卡(Level1-Level5),Level5有12×12格子和8个箱子,是检验你修改逻辑是否鲁棒的终极压力测试。


4. 核心逻辑深挖:移动判定、箱子推动与胜利检测的边界条件处理

4.1 MovePlayer()中的四个坐标陷阱:为什么“明明没撞墙却推不动”

PlayerController.Move()看似简单,但藏着四个新手必踩的坐标陷阱,全部集中在newRow/newCol的计算与校验环节:

  • 陷阱1:数组索引越界未拦截
    错误写法:if (board[newRow, newCol] == '#') return false;
    问题:当newRow = -1(向上推到第0行上方)时,board[-1, col]直接抛IndexOutOfRangeException,程序崩溃。
    正确做法:必须先校验边界:

    if (newRow < 0 || newRow >= board.GetLength(0) || newCol < 0 || newCol >= board.GetLength(1)) return false; // 越界直接拒绝
  • 陷阱2:空格与目标点区分失效
    推动逻辑中,玩家可走到空地 和目标.,但箱子只能推到空地 ,不能推到另一个箱子$或墙#。若校验只写board[newRow, newCol] != '#',会误判目标点.为可通行,导致箱子被推到.上——但.本应是箱子最终位置,推过去后该格应变为$,而非$覆盖.。源码正确处理:IsWalkable()只允许 和.,而PushBox()额外检查前方格是否为 (非.),因为目标点.是终点,不可再推。

  • 陷阱3:箱子坐标更新顺序错误
    常见错误:先更新玩家坐标,再更新箱子坐标。后果是——当玩家从A推箱子到B,箱子从B移到C,此时玩家坐标已是B,但B格在地图数组中仍是$(未清空),导致下次移动时IsWalkable(B)返回false。
    正确顺序:

    1. 清空箱子原位置:board[boxRow, boxCol] = ' '
    2. 设置箱子新位置:board[boxFrontRow, boxFrontCol] = '$'
    3. 更新玩家坐标至箱子原位置:playerRow = boxRow; playerCol = boxCol;
  • 陷阱4:多箱子场景下的“连锁推动”未实现
    当前源码不支持推一个箱子撞另一个箱子(即“箱子A推箱子B”)。PushBox()只检查单个箱子前方是否可通行。若要支持,需递归检测:若前方是箱子,则对该箱子执行相同PushBox()逻辑,并返回整体是否成功。这是进阶功能,源码预留了PushBox()的bool返回值接口,方便你扩展。

4.2 CheckWinCondition()的O(n²)暴力解法为何足够快?

胜利判定代码仅12行,却常被质疑“效率低”:

public bool CheckWinCondition() { for (int r = 0; r < board.GetLength(0); r++) { for (int c = 0; c < board.GetLength(1); c++) { if (board[r, c] == '.') // 找到目标点 { // 检查该目标点是否被箱子覆盖 bool hasBox = false; for (int br = 0; br < board.GetLength(0); br++) for (int bc = 0; bc < board.GetLength(1); bc++) if (board[br, bc] == '$' && br == r && bc == c) hasBox = true; if (!hasBox) return false; // 任一目标无箱子,未获胜 } } } return true; }

表面看是四重循环(O(n⁴)),但实际是O(n²):内层两个for循环只是为找“坐标(r,c)是否有箱子”,完全可用board[r,c] == '$'替代!源码作者故意写成这样,是为了教学目的——展示如何用嵌套循环遍历二维数组。真实优化版只需:

// 优化后:O(n²)且一行搞定 for (int r = 0; r < board.GetLength(0); r++) for (int c = 0; c < board.GetLength(1); c++) if (board[r, c] == '.' && board[r, c] != '$') // 目标点不是箱子?不对!应检查该点是否为箱子 return false; return true;

等等,这逻辑错了!board[r,c]是'.',不可能同时是'$'。正确优化是:遍历所有目标点,检查对应位置是否为'$':

for (int r = 0; r < board.GetLength(0); r++) for (int c = 0; c < board.GetLength(1); c++) if (board[r, c] == '.') // 找到目标 if (board[r, c] != '$') // 但此处是'.',永远不成立! return false;

发现了吗?这才是玄学所在——目标点.在地图中永远是.,箱子$在另一位置!所以必须存储“目标点坐标列表”,或遍历地图找'.',再检查同一坐标是否为'$'。源码采用前者:LevelLoader在加载时记录所有targetPositions,CheckWinCondition()遍历该列表:

foreach (var target in targetPositions) if (board[target.Row, target.Col] != '$') return false;

这才是高效解法。源码中CheckWinCondition()实际调用的是这个优化版,上面的“四重循环”是教学注释里的伪代码,千万别照抄!

4.3 避坑:推箱子游戏的五大血泪现场与修复方案

现象1:按住方向键不放,玩家连续移动两格甚至三格,箱子被“瞬移”

原因:WinForms的KeyDown事件在长按键盘时会高频触发(约30ms/次),而Move()逻辑未加防抖,导致一帧内多次调用。
解决:在MainForm_KeyDown()中添加if (isMoving) return;标记,Move()结尾设isMoving = true;,并在Timer.Tick(重绘定时器)中重置isMoving = false;。源码已用moveCooldown毫秒计时器实现,初始值50ms。

现象2:通关后再次按键,玩家在空白地图上乱走,箱子消失

原因:胜利状态GameState.Won未阻断KeyDown事件,Move()仍被执行,但GameBoard在Won状态下未刷新地图,导致视觉错乱。
解决:在MainForm_KeyDown()开头加if (gameState == GameState.Won) return;,并确保ShowWinDialog()后重置游戏状态。

现象3:自定义关卡中,玩家@出现在墙#上,游戏启动即崩溃

原因:LevelLoader只校验字符合法性,未检查@是否位于#格。PlayerController初始化时直接取@坐标,但该坐标处board[r,c]是'#',后续移动判定失效。
解决:在LevelLoader.LoadLevel()末尾添加:

// 验证玩家起始位置可通行 if (board[playerRow, playerCol] == '#') throw new InvalidOperationException($"Player '@' cannot be placed on wall at ({playerRow},{playerCol})");
现象4:撤销操作(Ctrl+Z)后,箱子位置正确,但目标点.显示为$(视觉残留)

原因:撤销时只恢复了board数组,但GameBoard的绘图缓存未刷新,OnPaint()仍画旧状态。
解决:撤销后调用gameBoard.Invalidate()强制重绘,源码中PlayerController.Undo()末尾已包含此行。

现象5:高分辨率屏幕(如2K)下,地图格子被拉伸变形,像素不清晰

原因:GameBoard的Size属性未随DPI缩放,Graphics.ScaleTransform()未启用。
解决:在MainForm构造函数中添加:

this.SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); gameBoard.SetStyle(ControlStyles.OptimizedDoubleBuffer, true); // 并在GameBoard.OnPaint()中加入: e.Graphics.InterpolationMode = InterpolationMode.NearestNeighbor; e.Graphics.PixelOffsetMode = PixelOffsetMode.Half;

5. 进阶改造:为游戏注入AI求解、关卡编辑器与跨平台适配的三个落地技巧

5.1 用A*算法替换手动推:让电脑自动解出Level5的12×12迷宫

当前游戏纯手动操作,但PlayerController预留了SolveLevel()接口。接入A*只需三步:

  1. 定义状态节点:SokobanState类包含玩家坐标(pr, pc)和所有箱子坐标List<(int r, int c)> boxes,重写GetHashCode()和Equals()(因箱子顺序不影响状态)。
  2. 实现启发式函数:曼哈顿距离之和——每个箱子到最近未覆盖目标点的距离总和。
  3. 集成到PlayerController.SolveLevel():
public List<Direction> SolveLevel() { var startState = new SokobanState(playerRow, playerCol, currentBoxes); var openSet = new SortedSet<SokobanState>(new StateComparer()); // 按fScore排序 openSet.Add(startState); var cameFrom = new Dictionary<SokobanState, (SokobanState, Direction)>(); while (openSet.Count > 0) { var current = openSet.Min; openSet.Remove(current); if (current.IsGoal()) return ReconstructPath(cameFrom, current); foreach (var (nextState, dir) in current.GetNeighbors(board)) { if (!cameFrom.ContainsKey(nextState)) { cameFrom[nextState] = (current, dir); openSet.Add(nextState); } } } return null; // 无解 }

注意:GetNeighbors()需模拟玩家移动和箱子推动,复用现有IsWalkable()和PushBox()逻辑。源码包中AI/目录已提供完整A*实现,SolveLevel()调用后返回List<Direction>,你可用foreach (var dir in solution) playerController.Move(dir);自动执行。

5.2 用DataGridView打造关卡编辑器:所见即所得设计Level6

与其手写TXT,不如做个可视化编辑器。在MainForm中添加DataGridView dgvEditor,绑定DataTable:

行列值含义
00#墙
01@玩家
............

关键代码:

// 初始化表格 var dt = new DataTable(); for (int c = 0; c < 12; c++) dt.Columns.Add(c.ToString(), typeof(string)); for (int r = 0; r < 12; r++) dt.Rows.Add(Enumerable.Repeat(" ", 12).ToArray()); dgvEditor.DataSource = dt; // 编辑后导出TXT private void btnSaveLevel_Click(object sender, EventArgs e) { var sb = new StringBuilder(); foreach (DataRow row in dt.Rows) { sb.AppendLine(string.Join("", row.ItemArray)); } File.WriteAllText($"Levels/Level6.txt", sb.ToString()); }

提示:为提升体验,给dgvEditor.CellClick事件添加图标选择器——点击格子弹出小窗,让你选#/@/$/.,避免手输错误。

5.3 迁移到.NET 6+跨平台:用SkiaSharp替代System.Drawing的三处硬伤修复

若你坚持用.NET 6开发跨平台版本(Linux/macOS运行),System.Drawing会报错。解决方案是用SkiaSharp重绘GameBoard:

  1. NuGet安装:SkiaSharp和SkiaSharp.Views.Desktop
  2. 替换绘图逻辑:GameBoard继承SKGLControl,重写OnPaintSurface():
protected override void OnPaintSurface(SKPaintGLSurfaceEventArgs e) { var canvas = e.Surface.Canvas; canvas.Clear(SKColors.White); float cellSize = Math.Min(Width, Height) / Math.Max(rows, cols); for (int r = 0; r < rows; r++) for (int c = 0; c < cols; c++) { var rect = new SKRect(c * cellSize, r * cellSize, (c + 1) * cellSize, (r + 1) * cellSize); switch (board[r, c]) { case '#': canvas.DrawRect(rect, wallPaint); break; case '@': canvas.DrawCircle(rect.MidX(), rect.MidY(), cellSize/3, playerPaint); break; // ... 其他字符 } } }
  1. 修复键盘事件:SKGLControl不支持KeyDown,需用PreviewKeyDown并手动处理Keys枚举映射。

注意:SkiaSharp在Linux下需安装lib SkiaSharp系统库,macOS需brew install skia,这是跨平台唯一硬伤。但从那以后我每次新建.NET 6游戏项目,都强制走一遍SkiaSharp集成流程——它比GDI+渲染更稳,抗锯齿更好,且未来可无缝迁移到Blazor Hybrid。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询