简介这是一份基于Android Studio开发的推箱子小游戏完整项目源码属于安卓期末大作业或课程设计类高分资源适合计算机相关专业学生在课设、期末作业或项目起步阶段参考使用。项目代码均经过本地编译并成功运行功能稳定整体难度适中结构清晰便于在此基础上二次扩展。压缩包共59个文件约10.11MB包含9个Java源文件、13个XML布局与配置、18个PNG及9个JPG图片素材另有MP4演示视频、MP3音频、PDF说明和MD文档覆盖了从界面绘制到游戏逻辑实现的主要环节。已有896人学习下载内容经过助教审定可放心使用。资源内置操作演示与项目介绍文档能帮助快速理解推箱子地图加载、角色移动和胜负判断等核心实现思路适合新手入门进阶也可作为期末答辩或项目演示的素材来源。1. 想拿高分却卡在推箱子第一关一份开箱即用的安卓项目源码安卓期末大作业选推箱子的人很多但自己从零写的时候最常翻车的不是逻辑而是状态管理——地图怎么建模、箱子推上目标点算什么状态、步数怎么统计、初始位置怎么固定这些事看着不难一动手就乱。这份基于 Android Studio 的推箱子小游戏项目源码是一个完整可运行、能直接作为课程设计提交的参考项目它把地图、绘图、交互三段拆开每段都是一个可以单独讲清楚的模块。需要交期末作业、或者想快速摸清安卓小游戏开发基本链路的人拿它当底子改一改、加个新功能比从零开始省下大半时间。2. 地图建模与状态管理为什么二维数组能同时管住玩家、箱子和目标点推箱子这类游戏地图本质是一张网格。玩家、箱子、墙、目标点、空地全部落在这张网格上。用二维数组建模是这类项目最主流的方案原因很简单数组下标天然对应行列坐标访问某个格子只需要map[row][col]判定逻辑可以直接写在数组访问上不用引入额外的空间数据结构。2.1 地图数据结构用二维数组把桌面上的元素都编上号以下是本项目里我会采用的经典建模方式public class LevelData { // 图例常量数字对应绘制和逻辑判断 public static final int EMPTY 0; // 空地 public static final int WALL 1; // 墙 public static final int BOX 2; // 箱子 public static final int TARGET 3; // 目标点 public static final int PLAYER 4; // 玩家 public static final int BOX_ON_TARGET 5; // 箱子在目标点上 public static final int PLAYER_ON_TARGET 6; // 玩家站在目标点上 private int[][] map; private int rows; private int cols; public LevelData(int[][] mapData) { this.map mapData; this.rows mapData.length; this.cols mapData[0].length; } }这套编号设计有两个关键点。一是BOX_ON_TARGET和PLAYER_ON_TARGET是额外加进去的复合状态它解决的痛点是一张图上同时出现“箱子压住目标点”和“箱子离开目标点”时的绘制与逻辑判断很多初写者只用 0~4 五个编号就会出现箱子推到目标点上时画面没有变化、或者明明箱子在目标点上却不算过关的问题。二是rows和cols被单独存下来后面做边界检查时直接用这两个值做越界保护比每次都调map.length更直观。实际项目中关卡数据可以直接硬编码成二维数组也可以从 assets 目录读取文本文件再解析。硬编码简单适合期末作业演示文件解析更接近工程实践后面第六章会展开。无论哪种方式核心都是把二维数组变成游戏世界的唯一数据源绘制和逻辑都围绕它展开。2.2 状态管理玩家位置、箱子位置、步数与状态码的同步更新有了地图数组下一步是管理“动态状态”。玩家位置是动态的箱子位置是动态的步数是动态的。这三样东西如果不集中管理代码里就会到处散落playerRow、playerCol、boxList这类变量改一个漏一个调试起来非常痛。public class GameState { private int playerRow; private int playerCol; private Listint[] boxPositions; // 每个箱子当前的 [row, col] private int stepCount; private int targetCount; // 关卡目标点总数 public GameState(int playerRow, int playerCol, Listint[] boxPositions, int targetCount) { this.playerRow playerRow; this.playerCol playerCol; this.boxPositions boxPositions; this.targetCount targetCount; this.stepCount 0; } public void recordStep() { stepCount; } public boolean isVictory() { int onTarget 0; for (int[] pos : boxPositions) { if (mapValueAt(pos[0], pos[1]) TARGET) { onTarget; } } return onTarget targetCount; } }这里的mapValueAt是为了在状态管理类里也能查原始地图数值。一个常见的错误是只维护 boxPositions 列表而不关心箱子和地图的同步——箱子从一个格移动到另一个格后地图数组本身并没有改变因为地图数组已经是“初始静态地图”动态层是 overlay 在上面的。所以判断“箱子是否在目标点上”要拿 boxPositions 里的坐标去和静态地图的 TARGET 比较而不是修改 map 数组的值。这是需要在一开始就分清的层关系。我把动态状态分成玩家坐标、箱子坐标集合、步数三个部分。玩家坐标用两个 int 存省事箱子坐标用Listint[]存因为一个关卡可能有多个箱子。如果你打算做“悔步”功能这个GameState就是快照的最小单位——把整个 state 对象存进栈里回退时恢复即可第六章会展开讲。3. Android Studio 工程落地自绘 GameView 与坐标换算的完整链路推箱子项目在 Android Studio 里有两种实现路径一是用 ImageView 动画把每个元素算好像素位置再摆放二是自定义 View在onDraw里用 Canvas 把所有元素画出来。前者布局文件会非常啰嗦后者代码集中、绘制顺序可控、性能也更好。这份源码采用的就是自绘方式工程结构围绕 Activity 自定义 View 数据类三个文件展开。3.1 工程结构拆解MainActivity、GameView、LevelData 三件套一个干净的推箱子项目Android Studio 工程里至少需要下面几个类MainActivity.java负责加载布局、创建 GameView、把关卡数据交给 ViewGameView.java继承View承载绘制和触摸事件LevelData.java关卡地图数据和图例常量GameState.java动态状态管理MainActivity里通常不需要复杂布局极端情况下甚至可以不写 XML 布局文件直接在onCreate里 new 一个 GameView 设置进去public class MainActivity extends AppCompatActivity { private GameView gameView; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); int[][] rawMap { {1, 1, 1, 1, 1}, {1, 3, 0, 0, 1}, {1, 0, 2, 0, 1}, {1, 0, 4, 0, 1}, {1, 1, 1, 1, 1} }; gameView new GameView(this, rawMap); setContentView(gameView); } }代码里直接写关卡数据省去布局查找的步骤适合期末项目。要注意setContentView(gameView)传入的是 View 对象而不是布局 ID这是和平时用R.layout.activity_main最大的区别。这个写法的好处是整个界面只有一个视图绘制完全由 GameView 自己控制代价是失去 XML 布局的灵活性但推箱子根本不需要复杂的视图层级反而更简洁。3.2 onDraw 绘制顺序先画地板、再画元素、最后画玩家绘制顺序直接决定画面是否正确。一个经典翻车现场是先画箱子再画墙结果箱子把墙盖住了或者先画目标点再画箱子目标点完全看不见。正确的绘制顺序一定是“从底层到顶层”先画所有地砖再画目标点标记再画箱子最后画玩家。Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int tileSize computeTileSize(); // 根据屏幕宽高和地图行列数计算格子像素 for (int row 0; row rows; row) { for (int col 0; col cols; col) { int x col * tileSize; int y row * tileSize; drawTile(canvas, row, col, x, y, tileSize); } } // 第二层箱子含 BoxOnTarget // 第三层玩家含 PlayerOnTarget }这是一个典型的绘制主循环。关键是分层绘制方法要拆开drawTile里画的是地砖和目标点标记箱子和玩家单独画。这样视觉上箱子永远压在地板上方玩家永远在最顶层目标点无论被箱子盖住还是被玩家踩住都先画在地板上不会出现“目标点浮在箱子上面”的违反直觉的画面。有一个细节要提醒不同 Android 屏幕的宽高比不一样不能假设格子一定是个正方形。computeTileSize()需要同时考虑屏幕宽和高取两者中的较小值再除以行列数中的较大值否则横屏地图会被拉伸变形。3.3 坐标系统逻辑坐标到像素坐标的换算逻辑坐标row, col和像素坐标x, y是两套坐标系很多新手在这里栽跟头。逻辑坐标是游戏世界的“格子编号”像素坐标是绘制和触摸检测的“屏幕位置”。中间必须经过乘法换算。private int computeTileSize() { int screenWidth getWidth(); int screenHeight getHeight(); int maxRows Math.max(rows, cols); return Math.min(screenWidth, screenHeight) / maxRows; } private float colToX(int col) { return col * tileSize; } private float rowToY(int row) { return row * tileSize; }这里tileSize是每次onDraw前根据当前 View 尺寸重新计算的。为什么不能把它定义成常量因为 View 在初始化时尺寸可能还是 0必须等布局完成后才能拿到真实宽高。如果拿不到宽高就硬编码一个值在模拟器上也许没问题换台真机就变形。我一般会在onSizeChanged回调里更新tileSize这样旋转屏幕时地图也会自动重排。触摸事件定位也是这套坐标系统的一部分。用户按下一个点要换算成逻辑坐标int targetRow event.getY() / tileSize; int targetCol event.getX() / tileSize;换算出来的targetRow和targetCol用于判断玩家该往哪个方向移动而不是直接拿像素坐标去和玩家位置比较。这一点放在第四章展开。4. 移动逻辑与交互判定从触屏滑动到“推得动”的每一步推箱子的核心玩法只有一个动作——移动但移动判定里包含三个分支玩家走向空地、玩家走向箱子需要判断箱子能不能再往前、玩家撞墙。这三个分支一定要拆清楚否则会出现“玩家穿墙”“箱子被推到天边”这类玄学问题。4.1 移动判定流程推得动、推不动、撞墙三种走向先从一个统一的入口方法开始这个方法接收目标行和列返回移动是否成功public boolean tryMove(int deltaRow, int deltaCol) { int newRow state.getPlayerRow() deltaRow; int newCol state.getPlayerCol() deltaCol; // 1. 边界检查超出地图范围直接拒绝 if (newRow 0 || newRow rows || newCol 0 || newCol cols) { return false; } // 2. 读取目标格子的原始地图值 int targetValue levelData.getMapValue(newRow, newCol); // 3. 分情况处理 if (targetValue LevelData.WALL) { return false; // 撞墙 } if (targetValue LevelData.BOX || targetValue LevelData.BOX_ON_TARGET) { // 目标格是箱子尝试把箱子再往前推一格 int boxNewRow newRow deltaRow; int boxNewCol newCol deltaCol; if (boxNewRow 0 || boxNewRow rows || boxNewCol 0 || boxNewCol cols) { return false; // 箱子到边界推不动 } int boxTargetValue levelData.getMapValue(boxNewRow, boxNewCol); if (boxTargetValue LevelData.WALL || boxTargetValue LevelData.BOX) { return false; // 箱子前面是墙或另一个箱子推不动 } // 箱子可以移动 performBoxMove(boxNewRow, boxNewCol); } // 4. 玩家移动 performPlayerMove(newRow, newCol); state.recordStep(); return true; }这段代码把移动判定压缩成三个分支墙、箱子、可走格子。箱子分支里还要再检查“箱子前方”是不是墙或者另一个箱子这是推箱子游戏最容易漏掉的逻辑——很多简化版实现只判断箱子前方是不是墙却没有判断“前方是另一个箱子”的情况结果一个箱子推着另一个箱子走游戏直接逻辑崩坏。performBoxMove和performPlayerMove是两步更新更新 GameState 里的boxPositions和玩家坐标。注意顺序一定是先移箱子再移玩家如果玩家先过去然后再处理箱子会出现箱子移动到玩家当前位置的诡异画面。4.2 触摸滑动与按键交互两条输入路径的设计触摸交互有两种风格点按方向区块和滑动方向判定。点按实现简单在 View 上划分四个区域用户点哪个区域就往哪个方向走滑动判定则是记录触摸按下点和抬起点的差值取绝对值大的方向作为移动方向。滑动判定在手感上更接近现代手游我推荐采用这种方式。private float downX; private float downY; Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: downX event.getX(); downY event.getY(); return true; case MotionEvent.ACTION_UP: float deltaX event.getX() - downX; float deltaY event.getY() - downY; if (Math.abs(deltaX) Math.abs(deltaY)) { return tryMove(deltaX 0 ? 0 : 0, deltaX 0 ? 1 : -1); } else { return tryMove(deltaY 0 ? 1 : -1, 0); } } return super.onTouchEvent(event); }注意return true的位置——ACTION_DOWN里必须返回 true 表示消费掉这次触摸否则后续的ACTION_UP根本不会触发。这是 Android 触摸机制的一个经典坑如果你在DOWN事件里返回 false系统会认为你不处理这个触摸序列后续事件都会传给父容器。这里还有一个细节滑动判定阈值。我只取了方向和差值没有设置最小滑动距离。实际使用中用户手指轻微抖动就触发移动会很烦建议加一个阈值判断比如Math.abs(deltaX) 30就忽略这次滑动。4.3 步数与过关检测每步更新、目标点比对步数统计看起来简单但有个关键决策是“每次尝试移动都加一”还是“每次成功移动才加一”。如果玩家撞了墙也算步数统计就不公平。正确做法是tryMove返回 true 时才调用recordStep()这就是我在第四章代码里把state.recordStep()放在 return true 之前的意图。过关检测有两种实现时机每次移动后全量比对或者维护一个计数器。全量比对简单可靠箱子数量少时性能差别可以忽略public boolean isVictory() { Listint[] boxes state.getBoxPositions(); for (int[] boxPos : boxes) { if (levelData.getMapValue(boxPos[0], boxPos[1]) ! LevelData.TARGET) { return false; } } return true; }这里直接拿箱子位置去查静态地图如果箱子所在格子的原始值是TARGET就说明它在目标点上。这个方案的优点是逻辑直白缺点是要遍历所有箱子。如果你的关卡箱子特别多可以改成“每次箱子移动时维护一个boxesOnTargetCount变量”箱子移上目标点就加一移开就减一等于targetCount即获胜。两种方案都可以期末项目用遍历法就够了代码可读性更重要。5. 真机适配与避坑排查Android Studio 跑不通的五个常见问题这一章写的都是实际调试中反复踩过的坑每一条都对应一个具体的崩溃或者显示异常。如果你在复现这个项目时遇到问题优先对照这一章排查。5.1 模拟器正常、真机闪退资源目录与图片解码的问题现象在 Android Studio 自带模拟器上运行一切正常装到真机上一点“开始”就闪退Logcat 里报OutOfMemoryError或者ResourceNotFound。原因模拟器默认屏幕密度较低加载图片资源时的内存压力和真机不同。如果项目里用了高分辨率 PNG 并且没有做缩放处理真机的 density 更高图片是按像素加载的内存瞬间被吃满。解决把资源图片放到res/drawable-mdpi、drawable-hdpi、drawable-xhdpi等目录中系统会根据屏幕密度自动选择合适版本。如果没有多套设计资源也可以用BitmapFactory.Options手动缩放BitmapFactory.Options options new BitmapFactory.Options(); options.inSampleSize 2; // 宽高各缩放一半 Bitmap bitmap BitmapFactory.decodeResource(getResources(), R.drawable.box, options);这个教训是自绘项目里的图片资源不要直接decodeResource就拿来画一定要考虑目标屏幕的密度差异。5.2 地图被拉伸变形不同屏幕宽高比导致的格子错位现象同一份关卡在地图行列数较大的情况下竖屏手机上格子很扁横屏设备上又很窄图片和格子对不上。原因tileSize计算时只考虑了屏幕宽度除以列数没有考虑行数。推箱子地图如果是 8 行 10 列屏幕高度除以 8 得到的格子高度比宽度除以 10 得到的格子宽度要小按其中一个算出来的tileSize去画横竖两个方向必然有一边溢出屏幕或者留白。解决computeTileSize()必须同时考虑行数和列数取两者的较小值int tileWidth screenWidth / cols; int tileHeight screenHeight / rows; int tileSize Math.min(tileWidth, tileHeight);用统一的tileSize去绘制是推箱子项目的黄金法则——格子的像素尺寸必须是一个正方形边长否则地砖和箱子图片都会被拉伸。5.3 触摸位置偏移硬编码坐标在真机上大面积失效现象在模拟器上点按钮位置没问题真机上一按就偏移尤其是有虚拟导航栏的手机点击位置明显偏下。原因模拟器的窗口尺寸和真机不同真机屏幕高度包含了系统状态栏和导航栏。如果把 View 放在setContentView里View 本身的高度已经是减去系统栏后的可用高度但很多人触摸换算用的是屏幕坐标就会偏高。还有一个常见错误在onTouchEvent里用event.getRawY()去换算格子位置getRawY返回的是屏幕绝对坐标包含了系统栏高度而绘制用的像素坐标是相对 View 的。解决统一使用event.getY()相对 View 的坐标配合tileSize换算不要在触摸事件里混用getRawY()。同时把触摸检测和绘制放在同一个坐标系内坐标系统一为 View 内部坐标。5.4 箱子推上目标点显示错乱状态码映射不完整现象箱子推到目标点后画面里目标点消失了箱子看起来和普通箱子一样箱子离开目标点后目标点又突然出现。玩家站在目标点上也有同样问题。原因绘制逻辑用map[row][col]的值直接决定画什么但 map 存的是静态数据动态状态在boxPositions里。绘制时单独查了静态地图发现格子上画着箱子就把目标点盖住了而正确的做法是分层绘制先画静态地板和目标点再画箱子最后画玩家。这样不需要额外考虑“目标点被盖住”的视觉问题。解决坚持第三章的分层绘制顺序。另外如果地图数组里确实用了 5 和 6 这种复合状态码更新时要同步处理地图数组的数值变化——移走后要把 5 改回目标点 3、把 6 改回 4。我更推荐的做法是动态层和静态层分离静态地图永远只存 0~4动态状态存于 GameState绘制时叠加这样复合状态码的更新工作就不存在了。5.5 自绘 View 在锁屏重开后画面黑屏生命周期重建与状态恢复现象游戏玩到一半锁屏再解锁后画面变黑或者回到初始状态步数清零、位置错乱。原因锁屏可能导致 Activity 被系统回收后重建重建后 GameView 里的关卡数据如果写死在成员变量里就会丢失。步数和玩家位置也存在同样的风险。解决在MainActivity里重写onSaveInstanceState把当前地图、玩家位置、箱子位置、步数保存到 Bundle 中Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putIntArray(playerPos, new int[]{state.getPlayerRow(), state.getPlayerCol()}); outState.putInt(stepCount, state.getStepCount()); }然后在onCreate里读取并恢复。这是 Android 生命周期的一个基础要求但期末项目里很少有人真正实现导致作业演示时一锁屏就露馅。如果你想让老师觉得代码质量更高这一步一定要做。6. 期末加分技巧十分钟给推箱子加上悔步与关卡外部化自绘、逻辑、状态管理都跑通之后推箱子算是一个合格的期末项目了。但如果想让分数再上一档加一个“悔步”功能往往比加一个新关卡更划算——它直接复用前面写的 GameState 快照机制代码量不大演示效果却非常直观。核心思路是维护一个状态栈每次成功移动前把当前 GameState 完整压入栈中回退时从栈里弹出一个快照并刷新画面private StackGameState undoStack new Stack(); public boolean tryMove(int deltaRow, int deltaCol) { if (!isLegalMove(deltaRow, deltaCol)) { return false; } // 移动前保存快照用于悔步 undoStack.push(state.clone()); // 执行移动逻辑... return true; } public boolean undo() { if (undoStack.isEmpty()) { return false; } state undoStack.pop(); invalidate(); // 触发重绘 return true; }注意clone()必须是深拷贝——GameState里的boxPositions是一个Listint[]直接赋值的话新旧状态会共享同一个int[]引用修改一个另一个也变。我会在GameState里提供一个copy()方法手动 new 一个新的 List 并逐个复制坐标数组。关卡外部化是另一个性价比很高的加分项。把地图数据从 Java 代码里抽出去放进assets/levels/level1.txt格式可以用最简单的文本#表示墙、空格表示空地、$表示箱子、.表示目标点、表示玩家。在LevelData里加一个解析方法public static LevelData loadFromFile(Context context, String fileName) { ListString lines new ArrayList(); try (BufferedReader reader new BufferedReader( new InputStreamReader(context.getAssets().open(fileName)))) { String line; while ((line reader.readLine()) ! null) { lines.add(line); } } catch (IOException e) { e.printStackTrace(); } // 按字符映射为二维数组 int[][] map new int[lines.size()][]; for (int i 0; i lines.size(); i) { String line lines.get(i); map[i] new int[line.length()]; for (int j 0; j line.length(); j) { char ch line.charAt(j); if (ch #) map[i][j] LevelData.WALL; else if (ch $) map[i][j] LevelData.BOX; else if (ch .) map[i][j] LevelData.TARGET; else if (ch ) map[i][j] LevelData.PLAYER; else map[i][j] LevelData.EMPTY; } } return new LevelData(map); }文本关卡的好处是任何人都能通过记事本改地图新增关卡不需要重新编译 APK。做完这两步你的期末作业可以说已经具备了“工程感”不只是把游戏跑起来而是有一个可扩展的架构。我从那以后每次提交安卓大作业都会强制走一遍“状态可恢复、关卡可配置、代码可扩展”的检查清单这三点看上去简单却实实在在地把项目从“能跑”推向了“能讲”。希望这份源码和这一路上的排查记录能帮到你。本文还有配套的精品资源点击获取