
1. 项目概述从经典到实践为什么选择推箱子推箱子这个诞生于上世纪80年代的经典益智游戏相信是很多人的童年回忆。它规则简单——玩家控制角色推动箱子到指定位置即可过关但关卡设计却可以极其精妙考验着玩家的空间逻辑和规划能力。作为一名有多年Unity开发经验的从业者我选择“推箱子”作为2D游戏开发的入门乃至进阶案例原因有三。首先它麻雀虽小五脏俱全。一个完整的推箱子项目几乎涵盖了2D游戏开发的所有核心模块精灵Sprite的渲染与管理、瓦片地图Tilemap的构建、玩家输入与控制、碰撞检测与物理交互、游戏状态逻辑胜利判定、关卡数据管理与切换。通过实现它你能系统地串联起Unity2D的工作流而不是孤立地学习某个功能。其次它是一个绝佳的逻辑训练场。推动箱子的规则如不能拉箱子、不能同时推两个箱子、箱子卡墙即失败需要清晰严谨的代码逻辑来实现。这比做一个简单的跑酷游戏更能锻炼你的程序设计思维和状态管理能力。最后它拥有极强的可扩展性。基础版本完成后你可以轻松地为其添加动画、音效、关卡编辑器、撤销功能、AI求解器甚至联网关卡分享将其变成一个功能丰富的个人作品。这为你后续探索更复杂的游戏类型打下了坚实的基础。本文将带你从零开始一步步拆解Unity2D推箱子游戏的开发全过程并深入分析核心源码的设计思路。无论你是刚接触Unity的新手还是想巩固2D开发基础的开发者都能从中获得可直接复用的经验和代码。2. 核心设计思路与架构拆解在动手写代码之前理清整体架构至关重要。一个结构混乱的项目后期会难以维护和扩展。对于推箱子游戏我们采用基于组件Component的面向数据设计核心思路是**“数据驱动状态”**。2.1 核心数据结构定义游戏的核心是地图状态。我们用一个二维数组或者更高效的扁平化数组来存储每个格子的信息。每个格子可以包含多种元素空地、墙、目标点、箱子、玩家。为了高效处理和判断我们通常使用枚举Enum或位掩码Bit Mask来定义格子类型。// 使用枚举定义格子类型基础版 public enum GridType { Empty, // 空地 Wall, // 墙不可通行 Target, // 目标点 Box, // 箱子 Player // 玩家 } // 更高效的方案使用Flags特性的位掩码允许一个格子存在多种元素如箱子和目标点重合 [System.Flags] public enum CellContent { None 0, Wall 1 0, Target 1 1, Box 1 2, Player 1 3 } // 例如一个在目标点上的箱子可以表示为CellContent.Box | CellContent.Target使用位掩码的优势在于判断逻辑可以通过位运算快速完成例如判断一个格子是否可通行(cell CellContent.Wall) 0。同时它也完美描述了“箱子在目标点上”这种叠加状态。2.2 游戏对象与组件的职责划分在Unity中我们将游戏实体与逻辑组件分离视觉表现层GameObject负责渲染。包括地面、墙、目标点、箱子、玩家的精灵Sprite或动画。逻辑控制层MonoBehaviour组件负责规则和状态。GameManager单例模式全局管理者。负责加载关卡数据、维护当前地图状态数组、判断游戏胜负、处理关卡切换。PlayerController挂载在玩家角色对象上。监听键盘输入上下左右并根据当前地图状态计算移动是否合法若合法则更新玩家位置并触发地图状态更新。Box挂载在箱子对象上。主要是一个标识符其逻辑如被推动实际上由PlayerController和GameManager协同处理。也可以包含被推动时的简单动画触发。MapLoader/Renderer负责根据GameManager中的地图数据在场景中实例化或更新对应的视觉元素墙、目标点等。这种分离使得逻辑清晰数据地图状态是唯一的真相来源视觉对象只是数据的反映。2.3 输入与移动的决策流程玩家的每一次按键输入都需要经过一个严谨的决策链输入捕获在PlayerController的Update()中使用Input.GetKeyDown捕获方向键输入。计算目标位置根据玩家当前位置和输入方向计算出意图移动到的下一个格子坐标nextPos和下下个格子坐标nextNextPos用于判断推箱子。碰撞检测逻辑层查询GameManager中存储的地图状态数组。如果nextPos是墙(Wall)移动被阻止。如果nextPos是空地(Empty)或目标点(Target)允许移动更新玩家位置。如果nextPos是箱子(Box)则需要检查nextNextPos如果nextNextPos是空地或目标点则允许移动。同时需要将nextPos的箱子移动到nextNextPos并更新地图状态nextPos移除箱子nextNextPos添加箱子。如果nextNextPos是墙或另一个箱子则移动被阻止。状态更新与渲染逻辑移动成功后GameManager更新内部地图状态数组并通知MapRenderer更新玩家和箱子的视觉位置可以通过直接修改Transform.position或使用插值动画实现平滑移动。胜利条件检测每次移动后GameManager遍历所有目标点检查是否每个目标点上都存在一个箱子即(cell CellContent.Target) ! 0 (cell CellContent.Box) ! 0。如果所有目标点都被箱子覆盖则游戏胜利。注意这里有一个关键细节移动和推动的判断必须基于更新前的地图状态快照。如果在移动过程中直接修改全局状态数组可能会引发连锁反应导致状态错乱。稳妥的做法是PlayerController向GameManager发起一个“尝试移动”的请求并传入方向和当前状态快照由GameManager计算出一个确定的结果成功/失败及新状态再返回。3. 核心模块实现与源码深度解析接下来我们深入到代码层面看看各个核心模块如何具体实现。3.1 地图数据管理与关卡设计关卡数据需要可持久化存储和方便编辑。最简单的方式是使用文本文件如.txt或.json定义地图。我们可以用不同的字符代表不同的元素# 代表墙 (Wall) 代表玩家起始位置 (Player) $ 代表箱子 (Box) . 代表目标点 (Target) * 代表箱子在目标点上 (Box on Target) 代表玩家在目标点上 (Player on Target) (空格) 代表空地 (Empty)一个关卡文件可能看起来像这样##### # # #$ # ### $## # . $ # # . .# # #### #####GameManager在启动时读取这个文本文件将其解析为内部的CellContent二维数组。同时我们需要记录玩家的初始位置和所有目标点的位置用于重置和胜利判断。源码分析LevelManager.csusing System.Collections.Generic; using UnityEngine; public class LevelManager : MonoBehaviour { public static LevelManager Instance; // 单例 public TextAsset[] levelFiles; // 通过Inspector拖入关卡文本文件 private int currentLevelIndex 0; private char[,] gridData; // 存储字符地图 private ListVector2Int targetPositions new ListVector2Int(); private Vector2Int playerStartPos; void Awake() { if (Instance null) Instance this; else Destroy(gameObject); LoadLevel(currentLevelIndex); } public void LoadLevel(int index) { if (index 0 || index levelFiles.Length) return; currentLevelIndex index; targetPositions.Clear(); string levelText levelFiles[index].text; string[] lines levelText.Split(\n); // 计算地图最大尺寸略去代码 // 解析每一行字符 for (int y 0; y height; y) { for (int x 0; x width; x) { char c lines[y][x]; gridData[x, y] c; switch (c) { case : case : playerStartPos new Vector2Int(x, y); break; case .: case *: case : targetPositions.Add(new Vector2Int(x, y)); break; } } } // 通知GameManager和MapRenderer根据gridData初始化状态和视觉 GameManager.Instance.InitializeLevel(this); } public char GetCell(int x, int y) { /*...*/ } public Vector2Int PlayerStartPos playerStartPos; public ListVector2Int TargetPositions targetPositions; // ... 其他辅助方法 }这个管理器负责数据的“静态”部分而GameManager则负责运行时的“动态”状态。3.2 玩家控制器与移动逻辑这是游戏交互的核心。我们需要处理输入并执行前文所述的决策流程。源码分析PlayerController.cs关键部分public class PlayerController : MonoBehaviour { private Vector2Int currentGridPos; // 玩家在网格中的逻辑坐标 private bool isMoving false; // 防止动画播放时重复输入 public float moveSpeed 5f; void Update() { if (isMoving || !GameManager.Instance.IsPlayerTurn) return; Vector2Int inputDir Vector2Int.zero; if (Input.GetKeyDown(KeyCode.W)) inputDir Vector2Int.up; else if (Input.GetKeyDown(KeyCode.S)) inputDir Vector2Int.down; else if (Input.GetKeyDown(KeyCode.A)) inputDir Vector2Int.left; else if (Input.GetKeyDown(KeyCode.D)) inputDir Vector2Int.right; if (inputDir ! Vector2Int.zero) { // 向GameManager发起移动尝试 MoveResult result GameManager.Instance.TryMovePlayer(currentGridPos, inputDir); if (result.success) { // 移动成功更新逻辑位置并开始视觉移动协程 currentGridPos result.newPlayerPos; StartCoroutine(SmoothMove(result.newPlayerPos)); // 如果有箱子被推动也需要更新箱子的视觉位置 if (result.boxMoved) { // 通知对应的Box对象播放移动动画 } // 移动完成后GameManager会检查胜利条件 } else { // 移动失败可以播放一个轻微的抖动动画或音效提示 } } } IEnumerator SmoothMove(Vector2Int targetGridPos) { isMoving true; Vector3 startPos transform.position; Vector3 targetWorldPos GridToWorld(targetGridPos); // 网格坐标转世界坐标 float t 0; while (t 1) { t Time.deltaTime * moveSpeed; transform.position Vector3.Lerp(startPos, targetWorldPos, t); yield return null; } transform.position targetWorldPos; isMoving false; // 移动结束通知GameManager进行后续处理如胜利检查 GameManager.Instance.OnPlayerMoveFinished(); } }实操心得使用协程IEnumerator处理平滑移动比在Update中自己管理插值更方便代码更清晰。isMoving标志位很重要它能有效防止动画播放期间重复响应输入避免角色“鬼畜”。3.3 游戏状态管理器的中枢作用GameManager是大脑它持有当前关卡的运行时状态并协调所有其他组件。源码分析GameManager.cs核心方法public class GameManager : MonoBehaviour { public static GameManager Instance; private CellContent[,] currentLevelState; // 运行时状态使用位掩码 private Vector2Int playerPos; private LevelManager levelManager; void Awake() { Instance this; } public void InitializeLevel(LevelManager lm) { levelManager lm; // 1. 根据levelManager.gridData初始化currentLevelState // 2. 设置playerPos playerPos lm.PlayerStartPos; // 3. 通知MapRenderer生成视觉元素 MapRenderer.Instance.RenderMap(currentLevelState); } public MoveResult TryMovePlayer(Vector2Int fromPos, Vector2Int dir) { Vector2Int nextPos fromPos dir; Vector2Int nextNextPos nextPos dir; // 边界检查 if (!IsPositionInBounds(nextPos)) return MoveResult.Fail; CellContent nextCell currentLevelState[nextPos.x, nextPos.y]; CellContent nextNextCell IsPositionInBounds(nextNextPos) ? currentLevelState[nextNextPos.x, nextNextPos.y] : CellContent.Wall; // 边界外视为墙 // 撞墙 if ((nextCell CellContent.Wall) ! 0) return MoveResult.Fail; // 推箱子判断 if ((nextCell CellContent.Box) ! 0) { // 箱子前面是墙或另一个箱子 if ((nextNextCell (CellContent.Wall | CellContent.Box)) ! 0) { return MoveResult.Fail; } // 可以推动 // 更新状态nextPos移除箱子nextNextPos添加箱子 currentLevelState[nextPos.x, nextPos.y] ~CellContent.Box; // 清除箱子位 currentLevelState[nextNextPos.x, nextNextPos.y] | CellContent.Box; // 设置箱子位 // 返回成功结果并标记箱子被移动 return new MoveResult(true, nextPos, true); } // 普通移动空地或目标点 // 只需要更新玩家位置逻辑上视觉由PlayerController处理 // 注意这里不直接修改currentLevelState中的Player位因为Player是唯一的我们只记录playerPos return new MoveResult(true, nextPos, false); } public void OnPlayerMoveFinished() { // 移动完成后更新内部记录的玩家位置TryMovePlayer返回了新位置 // playerPos newPos; // 这个更新应该在TryMovePlayer调用后立即进行取决于设计 CheckWinCondition(); } private void CheckWinCondition() { foreach (var targetPos in levelManager.TargetPositions) { // 检查每个目标点是否都有箱子 if ((currentLevelState[targetPos.x, targetPos.y] CellContent.Box) 0) { return; // 有一个目标点没箱子就没赢 } } // 所有目标点都有箱子 Debug.Log(Level Complete!); // 触发胜利UI、音效等 UIManager.Instance.ShowLevelComplete(); } } // 用于传递移动结果的结构体 public struct MoveResult { public bool success; public Vector2Int newPlayerPos; public bool boxMoved; public MoveResult(bool s, Vector2Int pos, bool box) { /*...*/ } }注意事项TryMovePlayer方法是无副作用Side-effect Free设计的一个变体。它在一次调用中完成了状态检查和更新。更纯粹的无副作用设计是传入当前状态返回一个全新的状态对象。但对于推箱子这种小型状态当前设计在性能和简洁性上取得了平衡。关键在于所有状态变更都集中在此处避免了竞态条件。4. 高级功能实现与性能优化基础版本完成后我们可以考虑添加一些提升体验和代码质量的功能。4.1 实现撤销Undo功能撤销是推箱子游戏的灵魂功能。实现它的关键在于记录历史状态。我们不应该存储每一帧的完整地图状态内存消耗大而是记录每一次移动的“动作”Action。动作命令模式实现public interface ICommand { void Execute(); void Undo(); } public class MoveCommand : ICommand { private GameManager gm; private Vector2Int oldPlayerPos; private Vector2Int newPlayerPos; private bool pushedBox; private Vector2Int oldBoxPos; // 如果推了箱子记录箱子旧位置 private Vector2Int newBoxPos; // 箱子新位置 public MoveCommand(GameManager gm, Vector2Int from, Vector2Int to, bool pushed, Vector2Int boxFrom, Vector2Int boxTo) { // 初始化参数... } public void Execute() { // 通常由GameManager的TryMovePlayer执行命令只负责记录 } public void Undo() { // 1. 将玩家位置回退到oldPlayerPos gm.SetPlayerPosition(oldPlayerPos); // 2. 如果推动了箱子将箱子状态从newBoxPos回退到oldBoxPos if (pushedBox) { gm.MoveBox(newBoxPos, oldBoxPos); } // 3. 恢复游戏状态如当前可操作状态 } } // 在GameManager中维护一个命令栈 public class GameManager : MonoBehaviour { private StackICommand commandHistory new StackICommand(); public void TryMovePlayerWithRecord(...) { MoveResult result TryMovePlayer(...); if (result.success) { MoveCommand cmd new MoveCommand(this, oldPos, result.newPlayerPos, result.boxMoved, boxOldPos, boxNewPos); commandHistory.Push(cmd); } } public void Undo() { if (commandHistory.Count 0) { ICommand cmd commandHistory.Pop(); cmd.Undo(); } } }使用命令模式我们将动作封装成对象撤销操作变得清晰且易于管理。这是面向对象设计模式在游戏开发中的一个经典应用。4.2 使用ScriptableObject进行数据驱动设计对于关卡数据、角色属性等使用ScriptableObject是Unity的最佳实践之一。它可以让你在编辑器内可视化地配置数据而无需硬编码或依赖文本文件解析。创建关卡数据Asset[CreateAssetMenu(fileName New Level, menuName Sokoban/Level Data)] public class LevelData : ScriptableObject { public int levelID; public Vector2Int gridSize; public ListCellPlacement cellPlacements; // 自定义类存储坐标和类型 public Texture2D previewImage; } // 在Editor中创建一个工具可以从文本文件导入生成LevelData Asset。这样LevelManager只需引用一组LevelData的数组数据管理更加优雅也便于实现关卡选择界面。4.3 性能优化与小技巧对象池Object Pooling关卡切换时墙、目标点、箱子等元素可能需要频繁创建和销毁。使用对象池缓存这些GameObject能有效减少GC垃圾回收压力提升性能。避免在Update中做复杂查找例如不要每帧遍历所有格子检查胜利条件。只在状态改变后移动完成检查一次。使用Unity的Tilemap系统对于静态的墙和地面使用Unity内置的Tilemap和Tile Palette进行绘制和渲染效率远高于使用大量独立的Sprite GameObject。玩家和箱子则可以作为单独的游戏对象在Tilemap上方移动。输入处理优化对于移动类游戏可以考虑使用Input System包它提供更强大、可配置的输入动作映射便于后期扩展手柄支持等。5. 常见问题排查与开发心得在实际开发中你肯定会遇到一些“坑”。这里记录了几个典型问题及其解决方案。5.1 箱子推动判断的边界错误问题描述箱子被推到地图边缘时游戏可能报错数组越界或者箱子消失。排查与解决在TryMovePlayer方法中计算nextNextPos后必须首先检查其是否在地图边界内。我们的IsPositionInBounds函数至关重要。对于边界外的位置应直接视为不可移动如墙。private bool IsPositionInBounds(Vector2Int pos) { return pos.x 0 pos.x gridWidth pos.y 0 pos.y gridHeight; } // 在判断箱子前方时 CellContent nextNextCell IsPositionInBounds(nextNextPos) ? currentLevelState[nextNextPos.x, nextNextPos.y] : CellContent.Wall;5.2 视觉与逻辑位置不同步问题描述玩家或箱子在逻辑上已经移动但画面上还停留在原地或者反之。排查与解决确保坐标转换正确逻辑坐标网格坐标如(1,2)和世界坐标Unity场景中的位置之间的转换函数必须准确。通常一个格子对应一个固定的世界单位如1 Unity单位。private Vector3 GridToWorld(Vector2Int gridPos) { return new Vector3(gridPos.x * cellSize, gridPos.y * cellSize, 0); }确保状态更新顺序遵循“输入 - 逻辑计算 - 更新状态 - 通知渲染”的流程。GameManager更新内部状态数组后必须显式地调用PlayerController和Box的移动方法或发送事件而不是依赖它们自己查询状态。5.3 撤销功能导致状态混乱问题描述执行多次撤销/重做后游戏状态与显示不一致。排查与解决命令的逆操作必须完全正确Undo()方法必须将状态精确地恢复到Execute()之前。仔细检查玩家位置、箱子位置、以及格子类型箱子是否在目标点上的恢复。使用栈Stack管理历史确保Push和Pop的顺序正确。撤销是Pop然后执行Undo重做则需要另一个栈来存储被撤销的命令。深拷贝与浅拷贝如果命令中存储了引用类型如数组直接存储引用会导致问题因为原数组会被后续操作修改。对于推箱子的小地图可以在命令中存储移动的“差异”diff而不是完整状态。例如只记录“玩家从A移到B箱子从C移到D”。这样更节省内存且不易出错。5.4 关卡数据加载失败问题描述从文本文件读取关卡时解析出错地图显示乱码或报错。排查与解决检查文本编码确保文本文件使用UTF-8无BOM格式保存。Unity的TextAsset默认支持UTF-8。处理行尾符和空格不同操作系统换行符\n,\r\n可能不同。使用string.Trim()处理每行首尾的空格和不可见字符。统一地图尺寸确保文本文件的每一行长度一致。可以遍历所有行以最长的一行为宽行数为高不足的行用空格补齐。最后一点个人体会推箱子项目虽然基础但它像一面镜子能清晰地反映出你对状态管理、数据结构和对象设计的理解深度。不要满足于“它能跑”多思考“如何让它跑得更优雅、更健壮”。尝试用不同的架构比如纯粹的ECS重新实现一遍或者为它添加一个关卡编辑器你会对Unity和游戏编程有更深的认识。这个项目的源码价值不在于那几百行控制逻辑而在于你为了让它完善所做出的每一次设计决策和优化。