
简介基于 Android Studio 开发的推箱子小游戏是一份可直接运行的 Java 安卓项目适合计算机相关专业学生用于课程设计、毕业设计也适合安卓入门者学习完整项目结构与界面交互。源码、界面布局与资源文件齐全包内共59个文件9个Java文件处理推箱子核心移动与胜负逻辑13个XML文件用于界面布局与配置18张PNG和9张JPG图片构成关卡与按钮素材6个MP4演示视频和2个MP3音频可辅助查看运行效果与体验声音反馈另有PDF说明文档和README帮助快速上手压缩包整体仅10.11MB目录结构简明、便于按需修改。项目代码经测试可稳定运行下载后按README即可搭建环境文档说明覆盖开发背景与运行要点适合在此基础上扩展关卡、更换素材或优化交互。目前已有174人学习下载可作为安卓游戏开发入门练手或课设毕设的实用参考。1. 先搞清楚你拿到的到底是什么推箱子Java项目在Android Studio里的生态位很多同学下载完“基于Android studio开发的推箱子Java小游戏源代码文档说明”这个压缩包之后第一件事就是解压、双击打开、Build然后眼睁睁看着Gradle转圈、报错、红一片。然后就开始怀疑自己是不是装了个假的Android Studio。先别急这个标题里的关键词其实包含了三层信息一个完整的Java实现推箱子逻辑、一套跑在Android Studio里的界面工程、以及一份课程设计或者毕业设计要用的文档说明。这三样东西混在一个包里意味着你拿到的不是“一个App”而是一个“课程设计作业的完整载体”。这篇文章要帮你干的事是把这三层拆开先看懂地图逻辑和移动判定为什么这么写再把自定义View绘图、按键监听、关卡特性和关卡切换这些Android层的东西跑通最后把你从“能运行”带到“能改、能答辩、能自己加功能”的程度。适合谁看适合刚学完Java基础、第一次接触Android课程设计的学生也适合想快速复现一个经典小游戏来练手、不想从零写UI的开发者。下面我们直接进入正题。2. 地图数据结构与移动判定推箱子游戏的地基推箱子这个游戏看着简单核心逻辑其实就两件事怎么存地图怎么判定移动。这两件事都不依赖Android纯粹是Java层面的算法问题。很多同学跑不起来或者改不动不是因为Android部分难而是因为这两块没吃透后面画界面、接按键、加功能全都会卡住。2.1 用int二维数组表示一关地图每个数字代表什么常见做法是用一个int类型的二维数组存地图行和列分别对应屏幕上的纵坐标和横坐标。每个格子用数字区分类型0表示空地、1表示墙、2表示目标点、3表示箱子、4表示玩家。这种数字映射的好处是判断逻辑简单写起来也快但可读性差。更讲究一点的做法是直接用常量名比如public static final int WALL 1这样代码里写map[r][c] WALL比map[r][c] 1清楚得多。实际项目里两种混着用很常见但作为要交的作业建议至少把常量定义出来。public class MapData { public static final int EMPTY 0; public static final int WALL 1; public static final int TARGET 2; public static final int BOX 3; public static final int PLAYER 4; // 箱子在目标点上用5表示玩家在目标点上用6表示 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 MapData(int[][] origin) { this.rows origin.length; this.cols origin[0].length; this.map new int[rows][cols]; for (int r 0; r rows; r) { System.arraycopy(origin[r], 0, map[r], 0, cols); } } }这段代码做了三件事定义格子类型的语义常量、接收外部传入的地图原始数据、做一次深拷贝防止外部修改影响内部状态。第二点需要特别留意如果你直接把外部数组引用赋值给map内部变量外面改了数组内容你关卡里已经编辑好的地图就跟着变了这在写自定义关卡编辑器时是很容易踩的坑。而System.arraycopy这种深拷贝方式虽然看起来啰嗦但保证了解耦后面无论怎么操作地图都不会污染原始数据。2.2 玩家移动与箱子推动的判定方向、空地、墙、目标点的四种情形推箱子移动判定的本质是“看新位置是谁”。玩家想往某个方向走一步系统要先计算出新格子的行列坐标然后看这个坐标在地图里对应的类型。如果新位置是空地或者目标点玩家直接走过去如果是墙什么也不发生如果是箱子就要进一步看箱子的新位置是谁——是空地或者目标点就能推是墙、是另一个箱子就推不动。这个“往外再看一格”的逻辑就是推箱子判定的核心。public boolean move(int dr, int dc) { // 找到玩家当前位置 int pr -1, pc -1; outer: for (int r 0; r rows; r) { for (int c 0; c cols; c) { if (map[r][c] PLAYER || map[r][c] PLAYER_ON_TARGET) { pr r; pc c; break outer; } } } if (pr -1) return false; // 理论上不会发生兜底防御 int nr pr dr; // 玩家下一步位置 int nc pc dc; int nnr pr 2 * dr; // 箱子再往外一格的候选位置 int nnc pc 2 * dc; // 玩家新位置越界 if (nr 0 || nr rows || nc 0 || nc cols) return false; int nextCell map[nr][nc]; // 前面是墙不动 if (nextCell WALL) return false; // 前面是空地或目标点玩家直接走过去 if (nextCell EMPTY || nextCell TARGET) { map[pr][pc] (map[pr][pc] PLAYER_ON_TARGET) ? TARGET : EMPTY; map[nr][nc] (nextCell TARGET) ? PLAYER_ON_TARGET : PLAYER; return true; } // 前面是箱子 if (nextCell BOX || nextCell BOX_ON_TARGET) { // 箱子再往前一步越界推不动 if (nnr 0 || nnr rows || nnc 0 || nnc cols) return false; int farCell map[nnr][nnc]; // 箱子再往前是墙或另一个箱子推不动 if (farCell WALL || farCell BOX || farCell BOX_ON_TARGET) return false; // 推箱子先把箱子移动到新位置 map[nnr][nnc] (farCell TARGET) ? BOX_ON_TARGET : BOX; // 箱子原来站着的位置变成玩家 map[nr][nc] PLAYER; // 玩家原来的位置要区分是不是目标点 map[pr][pc] (map[pr][pc] PLAYER_ON_TARGET) ? TARGET : EMPTY; return true; } return false; }这里有个细节需要注意如果箱子的新位置是目标点那么箱子的状态应该用BOX_ON_TARGET记录而不是用BOX。同样玩家走到目标点后状态也要变成PLAYER_ON_TARGET。这种区分是为了后面胜利判定和绘图时方便——画图时要让目标点露出来你不知道这个格子是不是目标点就只能靠状态值去判断。如果偷懒不区分这两个状态箱子和目标点叠在一起时就分不清到底有没有放对位置胜利判定也会出错。2.3 胜利判定与关卡重置目标和箱子不是一回事推箱子胜利的标准不是“所有箱子都不在地上了”而是“所有箱子都落在目标点上”。所以最稳妥的胜利判定方式是遍历地图数一下目标点的个数再遍历一遍数一下BOX_ON_TARGET的个数两者相等就说明赢了。还有一种写法是每推一次箱子就检查一次效率高一点但对课程设计这个规模来说没必要。public int getTargetCount() { int count 0; for (int r 0; r rows; r) { for (int c 0; c cols; c) { if (map[r][c] TARGET || map[r][c] BOX_ON_TARGET || map[r][c] PLAYER_ON_TARGET) { count; } } } return count; } public boolean isWin() { int targetCount 0; int boxOnTargetCount 0; for (int r 0; r rows; r) { for (int c 0; c cols; c) { if (map[r][c] TARGET || map[r][c] BOX_ON_TARGET) targetCount; if (map[r][c] BOX_ON_TARGET) boxOnTargetCount; } } return targetCount boxOnTargetCount; } public void reset() { // 需要从外部保存初始地图副本 map new int[rows][cols]; for (int r 0; r rows; r) { System.arraycopy(originalMap[r], 0, map[r], 0, cols); } }注意isWin里的坑只有BOX_ON_TARGET需要跟目标点总数对账但目标点总数要把TARGET、BOX_ON_TARGET、PLAYER_ON_TARGET三种都算进去。因为PLAYER_ON_TARGET是玩家站上去了它本质还是目标点。如果你漏算这一种胜利判定在某些边界情况下会失灵——比如玩家刚好站在最后一个目标点上这时候targetCount会比实际值少1导致永远判不了胜利。这种逻辑错误编译不报错运行也不崩但就是怎么玩都赢不了排查起来非常隐蔽。3. Android界面层自定义View绘图、方向键监听与手势滑动的完整方案逻辑层完全独立于Android之后界面层就好做多了。Android小游戏最常见的做法是用自定义View直接在画布上绘图而不是堆一堆ImageView和Button控件。原因有三个一是自定义View可以保证地图绘制逻辑和屏幕尺寸解耦适配不同分辨率手机不用改代码二是性能更好刷新整个画面只需要一次invalidate三是代码集中在一个类里答辩讲起来也清晰。当然也有偷懒的做法比如用GridLayout加Button矩阵当墙和箱子但那样关卡稍微放大就卡得不行也不方便做动画。3.1 为什么不用布局控件摆地图自定义View是更省事的方案你可能会想地图就十几乘十几用Button或者TextView摆一个网格不是更简单吗确实小关卡可以用但一旦关卡行数到15、列数到20控件数量就到了300个以上Android渲染起来会掉帧。而且还面临一个更实际的问题控件风格的统一、点击事件的绑定、箱子与墙重叠时的视觉层级这些都要单独处理。而自定义View把这些全部打包进一个onDraw里面地图数据变了调用一次invalidate重绘即可整个界面状态同步是天然完成的。在Android Studio里创建一个自定义View的步骤很简单新建一个类继承View重写onDraw方法然后在布局文件里声明这个类。布局文件最常见的写法是只放一个相对布局里面铺一个这个View。但是自定义View拿不到地图数据啊怎么办给它加一个setMapData方法外部把MapData对象传进来View内部持有引用每次重绘时读取地图状态即可。这种设计就是经典MVC里的View和Model分离后面想换数据源、加动画、做不同皮肤都方便。3.2 在onDraw里把地图画出来适配任意屏幕尺寸onDraw的核心是先算好每个格子绘制的边长再遍历地图数组画颜色块和图标。计算边长要用View的实际宽和高除以地图的列数和行数取两者中的较小值。如果不等比缩放地图就会被拉伸变形墙块变成墙条非常难看。Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (mapData null) return; int viewWidth getWidth(); int viewHeight getHeight(); int cols mapData.getCols(); int rows mapData.getRows(); int cellSize Math.min(viewWidth / cols, viewHeight / rows); // 把地图居中绘制而不是从左上角硬顶过去 int offsetX (viewWidth - cellSize * cols) / 2; int offsetY (viewHeight - cellSize * rows) / 2; for (int r 0; r rows; r) { for (int c 0; c cols; c) { int type mapData.getMap()[r][c]; int left offsetX c * cellSize; int top offsetY r * cellSize; // 根据类型绘制不同颜色或图案 switch (type) { case MapData.WALL: canvas.drawRect(left, top, left cellSize, top cellSize, wallPaint); break; case MapData.BOX: canvas.drawRect(left cellSize / 6, top cellSize / 6, left cellSize - cellSize / 6, top cellSize - cellSize / 6, boxPaint); break; case MapData.BOX_ON_TARGET: canvas.drawCircle(left cellSize / 2, top cellSize / 2, cellSize / 3, targetPaint); canvas.drawRect(left cellSize / 6, top cellSize / 6, left cellSize - cellSize / 6, top cellSize - cellSize / 6, boxPaint); break; case MapData.PLAYER: canvas.drawCircle(left cellSize / 2, top cellSize / 2, cellSize / 3, playerPaint); break; case MapData.EMPTY: default: // 空地不画内容但可以画淡色背景 break; } } } }这里有一个高频踩坑点cellSize用Math.min取小值地图会不完全填满View所以要计算offsetX和offsetY做居中。很多小白一开始直接用viewWidth除以colsviewHeight除以rows然后地图拉伸到全屏关卡两边不对称看着就别扭。另外一个细节是箱子图标最好画成圆角矩形或者加个内边距的普通矩形这样跟墙块能区分开。玩家用圆形目标点用小圆圈这样层级清晰空地无内容、目标点有圈、箱子盖住目标点、玩家独立圆形。这种视觉分层对新手判断当前局面是否获胜非常有帮助。3.3 操作方式监听物理方向键、屏幕触摸滑动、屏幕按钮三层方案推箱子游戏在Android上的操作方式有几种主流选择监听键盘方向键适合连接实体键盘或模拟器、监听屏幕触摸滑动适合手机真机、在界面上绘制方向按钮适合演示和答辩。实际课程设计最常见的是触摸滑动物理方向键都做只要能跑通演示就行。方向键监听很简单在Activity里重写onKeyDown方法处理KEYCODE_DPAD_UP、KEYCODE_DPAD_DOWN、KEYCODE_DPAD_LEFT、KEYCODE_DPAD_RIGHT四个按键即可。Override public boolean onKeyDown(int keyCode, KeyEvent event) { switch (keyCode) { case KeyEvent.KEYCODE_DPAD_UP: return handleMove(-1, 0); case KeyEvent.KEYCODE_DPAD_DOWN: return handleMove(1, 0); case KeyEvent.KEYCODE_DPAD_LEFT: return handleMove(0, -1); case KeyEvent.KEYCODE_DPAD_RIGHT: return handleMove(0, 1); default: return super.onKeyDown(keyCode, event); } } private boolean handleMove(int dr, int dc) { boolean moved mapData.move(dr, dc); if (moved) { gameView.invalidate(); if (mapData.isWin()) { Toast.makeText(this, 过关了, Toast.LENGTH_SHORT).show(); } } return moved; }触摸滑动事件要重写onTouchEvent在ACTION_DOWN时记录起点在ACTION_UP时比较水平和竖直位移的绝对值哪个大就往哪个方向移动。如果按对角线滑动理论上应该取位移更大的方向而不是同时触发两步移动。只响应一个方向可以避免误操作这个细节在答辩现场演示时特别重要——台下的人用手指滑动屏幕往往不会精准地水平或垂直滑如果你不做方向判定就会经常出现以为要往右走结果往上跳的诡异现象。触摸滑动的代码逻辑不复杂但有一个性能陷阱如果你在onTouchEvent里处理完移动后调用invalidate会触发整个View重绘。对于推箱子这种地图只有一百来个格子的小场景完全没有问题但如果关卡数据设计得异常大比如100×100重绘耗时就会明显增加。在实际项目里一般不会做这么大的关卡所以也不用特别优化知道这个边界就行。4. 关卡设计与资源组织把多关卡从硬编码里解放出来很多初学者做推箱子时会把地图数据直接写在代码里用一堆new int[][]初始化数组。第一关这样写没问题但到了第二关、第三关这种写法就是灾难。一旦需要调某个格子的数字你得在几十行数组里找到那个位置改完还要担心改错其他地方。而且代码也会越来越长一关50行8关就400行全部堆在一个类里阅读体验极差。4.1 关卡文本格式与解析一行一个地图字符即地图业界做推箱子关卡时的常见做法是把关卡存成文本文件用字符表示格子类型#表示墙、空格表示空地、$表示箱子、.表示目标点、表示玩家。这种格式也被广泛用于网上各类推箱子关卡库好处是关卡数据和代码完全分离新增加一关只需要往文件里追加几行文本不需要重新编译代码。解析的时候按行读取文本文件每行按字符转成对应的int数字填到二维数组里。public static MapData parseFromText(ListString lines) { if (lines null || lines.isEmpty()) return null; int rows lines.size(); int cols 0; for (String line : lines) { if (line.length() cols) cols line.length(); } int[][] map new int[rows][cols]; for (int r 0; r rows; r) { String line lines.get(r); for (int c 0; c cols; c) { char ch (c line.length()) ? line.charAt(c) : ; // 短行后面补空格 switch (ch) { case #: map[r][c] MapData.WALL; break; case : map[r][c] MapData.EMPTY; break; case $: map[r][c] MapData.BOX; break; case .: map[r][c] MapData.TARGET; break; case : map[r][c] MapData.PLAYER; break; default: map[r][c] MapData.EMPTY; break; } } } return new MapData(map); }解析代码里最容易出错的是短行补空格的逻辑。如果某一关某些行因为写漏了一个字符而比最长行短那么这一行末尾的格子默认是空格也就是空地。如果不补空白直接用line.charAt(c)就会抛StringIndexOutOfBoundsException游戏启动时直接崩溃。很多新手遇到这种报错第一反应是“地图代码写错了吧”实际查半天才会发现是文本里某行少了一个字符。正确做法是宽松处理缺省即空地。4.2 读取、校验与切换关卡文件读完就校验否则直接报错关卡文件放在assets目录下是比较稳妥的做法因为assets目录下的资源操作简单直接AssetManager.open即可。读取完成后要立刻做一层校验检查地图里是否只有一个玩家、至少有一个目标点、玩家周围有没有路可走。校验的目标是避免写了错关卡导致游戏启动后玩家卡在墙里或者根本没有出口看起来就是黑框一个点也动不了。public static boolean validateMap(MapData mapData) { int playerCount 0; int targetCount 0; int boxCount 0; int[][] map mapData.getMap(); for (int r 0; r map.length; r) { for (int c 0; c map[0].length; c) { int cell map[r][c]; if (cell MapData.PLAYER || cell MapData.PLAYER_ON_TARGET) playerCount; if (cell MapData.TARGET || cell MapData.BOX_ON_TARGET) targetCount; if (cell MapData.BOX || cell MapData.BOX_ON_TARGET) boxCount; } } return playerCount 1 targetCount 1 boxCount 1; }校验通过之后关卡切换的逻辑就变成维护一个当前关卡索引按索引从关卡列表里取对应的MapData然后通知游戏View刷新。切换时机要分成两种情况正常过关自动跳到下一关以及失败后手动点击“重玩本关”。过关跳转时还要顺手清理步数、播放一个简单音效或者弹Toast不然玩家不知道发生了什么。重玩本关则要把地图重置到初始状态而不是重新加载文件因为文件里存的是初始地图但当前游戏状态可能已经走了一半。4.3 关卡失败时的兜底逻辑重置当前关卡而不是回到主菜单推箱子不像动作游戏有“死亡”状态它只有“局面走死了”这一种失败形式——所有箱子都被推到角落没有可操作空间。但这种失败无法靠代码自动识别只能靠玩家自己判断。所以你要做两件事提供一个“重玩本关”的按钮以及一个“返回选关”的入口。重玩本关按钮的回调逻辑要处理玩家操作过程中的地图状态变化严格来说要做一次MapData的reset调用而不是重新读文件。public void resetLevel() { mapData.reset(); gameView.setMapData(mapData); gameView.invalidate(); Toast.makeText(this, 已重置本关重新开始, Toast.LENGTH_SHORT).show(); }这里MapData.reset的可行性取决于之前是否在构造函数里保留了原始地图的副本。如果你的MapData没有保存原始副本reset就无从谈起只能靠重新解析文本文件来恢复。虽然结果一样但重新解析文本的写法会让逻辑分散而且每次reset都多一次文件IO。所以最好的做法是在MapData构造时就把原始输入拷贝一份存起来后续reset和move互不干扰。这种“尽量在构造函数里把不变量准备好”的编程习惯放在答辩里也是个加分点。5. 从运行到交付Android Studio版本适配、真机调试与常见异常排查这个项目下载下来之后你最关心的可能是“我能不能直接跑起来”。但直接Build往往过不了原因基本集中在Gradle版本、SDK版本、compileSdk和targetSdk这几个环节。Android Studio版本更新很快几年间的项目模板差异极大老项目在新版本Studio里打开大概率会报两个常见错一个是Gradle sync失败另一个是SDK location找不到。解决思路不是去改项目代码而是把项目的Gradle配置往你本地环境上靠。5.1 打开别人的项目第一步不是Build先检查SDK版本和build文件拿到项目后不要急着点绿色三角形先看根目录下的build.gradle和app/build.gradle确认compileSdk、minSdk、targetSdk这些字段的版本号跟你本地安装的SDK匹不匹配。如果你本地的SDK Platform只有API 33项目里写的是compileSdk 34就会提示“Please install Android SDK Platform 34”。这时有两种办法一种是用SDK Manager下载对应Platform另一种是把compileSdk改成你本地已有的版本同时把targetSdk也降低。对于课程设计这种本地运行的小项目第二种办法通常更省事因为项目用不到高版本SDK的新特性改低版本对功能没有任何影响。还有一类更隐蔽的问题来自Gradle插件版本和Gradle本身版本不匹配。Android Studio的Gradle插件版本和对应的Gradle发行版版本之间有一张兼容对照表老项目用旧插件配新Gradle或者反过来都会构建失败。报错信息里如果看到Unsupported class file major version或者Failed to apply plugin基本就是这个问题。解法也很直接打开build.gradle里的classpath com.android.tools.build:gradle:x.x.x改成跟你Android Studio版本匹配的插件版本再让Gradle自动同步。改完之后最好Clean Project再Rebuild一次避免增量构建缓存干扰。5.2 模拟器跑不动的应对方案改分辨率、改地图尺寸、改用真机Android Studio自带模拟器对电脑配置要求不算低很多同学的笔记本跑起来模拟器一卡一卡的推箱子这种简单游戏也掉帧不是说代码写得烂是模拟器本身开销大。应对方案有三条第一把模拟器分辨率调低模拟器窗口右下角设置里可以改1280×720比1920×1080流畅很多第二关掉模拟器里的动画缩放开发者选项里把窗口动画缩放、过渡动画缩放都调成关闭第三直接用真机调试手机开USB调试插上数据线Studio识别后一键运行。真机调试的流畅度远高于模拟器而且触摸滑动操作更接近真实使用场景答辩展示也更自然。如果你决定用真机还有一个小坑要提前排掉Android 6.0及以上版本需要动态申请存储权限但推箱子这个游戏通常不入外部存储只需要内存读写所以只要不涉及保存游戏进度到SD卡就不用处理权限问题。但如果项目里人性化地加了“保存当前关卡进度”的功能而用的保存方式是写内部存储那就不用权限如果用了getExternalStorageDirectory就必须加Runtime Permission申请代码否则Android 6.0以上直接SecurityException崩溃。5.3 高版本Android的适配exported声明与存储权限相关说明Android 12API 31之后manifest里所有使用了intent-filter的组件必须显式声明android:exported属性。很多老项目里MainActivity的intent-filter——就是那个MAIN和LAUNCHER的过滤器——没有exported字段在Android 12以上设备安装时会直接报错提示“you must explicitly set android:exported”。解法是在对应的Activity标签里加一句android:exportedtrue。如果你的项目里还有别的Activity也需要被别人启动同样也要加具体值是true还是false取决于你是否希望别的应用唤起它。activity android:name.MainActivity android:exportedtrue intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity另外还有一类比较隐蔽的崩溃与Android 15API 35的16KB Page Size适配相关但这主要影响原生库纯Java写的推箱子完全不涉及。如果你用的是较新的真机而项目编译SDK版本偏低也可能会碰到系统提示“targetSdkVersion太低部分行为可能不兼容”的弹窗这不会导致崩溃只是系统提醒你升级targetSdk。课程设计阶段可以忽略它不影响运行。5.4 常见的五种崩溃场景与报错对应关系我见过太多同学在答辩前夜对着一个崩溃一头雾水而崩溃原因翻来覆去就那么几类。整理成表格按“现象→原因→解决”的方式说清楚你排查时可以不慌。现象原因解决启动直接崩Logcat显示ClassNotFoundException某些类没打包进APK或者混淆规则把View子类都混淆了关掉minifyEnabled或者给自定义View加keep规则走一步就崩报ArrayIndexOutOfBoundsException移动时没做边界判断走出地图范围在move方法里检测nr、nnr是否越界点击重玩按钮没反应游戏画面不变reset方法没有刷新Viewreset后调用gameView.postInvalidate()中文注释变成乱码项目编码GBKStudio默认UTF-8在build.gradle里配置compileOptions.encoding UTF-8安装后图标正常但打开白屏MainActivity的ContentView设置错误或xml布局本身出错检查setContentView的布局文件看有没有引用不存在的控件ID注意上面第四条编码问题课程设计报告里如果有中文注释导出项目给别人时建议统一转成UTF-8检查方法是在Android Studio右下角点编码标识看是UTF-8还是GBK。如果是GBK转换时选File→File Encoding→Convert to UTF-8同时把“Transparent native-to-ascii conversion”关掉不然中文会变成\uXXXX这样的转义序列刷报告的时候导入Word会非常痛苦。6. 加一个可演示的进阶功能步数统计与悔棋的实现思路如果想让这个项目在答辩里显得没那么“从网上下的”最好的办法是加一个大多数人不会加的功能步数统计和悔棋。这两个功能实现成本不高但演示效果非常好——你可以在台上故意走错一步然后点一下“悔棋”让局面回退到上一步台下的人一眼就能看出这是你真正理解了这个项目的代码而不是只会跑效果。6.1 步数统计一个计数器与一个监听时机步数统计的核心是“什么才算一步”。推箱子里推一下箱子和走一步空地都算一次操作都叫一步。所以最简单的实现是在move方法成功返回true之后把stepCount加1然后在界面显示。注意是成功才算如果玩家推墙失败那不算一步这比较符合直觉。private int stepCount 0; public int getStepCount() { return stepCount; } public boolean handleMove(int dr, int dc) { boolean moved mapData.move(dr, dc); if (moved) { stepCount; stepNumTv.setText(步数 stepCount); gameView.invalidate(); if (mapData.isWin()) { Toast.makeText(this, 恭喜过关步数 stepCount, Toast.LENGTH_SHORT).show(); } } return moved; }步数显示的刷新时机要和View刷新放在一起避免步数更新了界面没刷新看起来像“没记上”。还有一个小细节过关时把步数也带进Toast里这个体验就比干巴巴的“过关了”要专业很多。如果后续要做排行榜功能这个步数就是一个现成的排序字段。6.2 悔棋只记录最近N步的地图快照不是记录操作序列悔棋最简单的实现方式不是记录每一步的操作方向然后反向回放而是直接保存每一步的地图快照。每次成功移动后把当前地图的副本压入栈栈的大小限制为50或30超过就把最老的弹出。执行悔棋时从栈里弹一个快照把地图整体替换成快照内容步数同步回退。这种方案的内存开销对于推箱子来说完全可以接受——一个15×15的int数组快照只有约900字节50步也就45KB小到可以忽略。public static class UndoManager { private final ArrayDequeint[][] snapshotStack new ArrayDeque(); private static final int MAX_STEPS 50; public void saveSnapshot(int[][] map) { if (snapshotStack.size() MAX_STEPS) { snapshotStack.pollLast(); // 移除最旧的 } int[][] copy new int[map.length][map[0].length]; for (int r 0; r map.length; r) { System.arraycopy(map[r], 0, copy[r], 0, map[0].length); } snapshotStack.push(copy); } public int[][] popSnapshot() { return snapshotStack.pollFirst(); } public void clear() { snapshotStack.clear(); } }这里用ArrayDeque而不是Stack是刻意的ArrayDeque支持双向操作pollFirst取最新、pollLast删最旧比Stack的push和pop组合更直观。另外快照必须深拷贝如果你只存引用下一次move就会修改同一块数组悔棋时拿到的还是当前地图等于没悔。这是我反复踩过的坑提醒一次务必深拷贝否则悔棋功能做出来像个玄学。把悔棋按钮和界面接起来也很简单在Activity的按钮回调里执行undo()方法从栈里取出快照调mapData.restoreSnapshot(snapshot)恢复地图步数减1面板刷新View重绘。要注意的是如果栈为空说明没有可悔棋的步数按钮要置灰或者直接Toast提示“没有可撤销的步骤”避免空栈异常。6.3 组合起来把两个功能装进同一个UndoManager类步数和悔棋这两个功能不要在Activity里各写一套建议都收进刚才的UndoManager类里让这个类变成“游戏状态管理器”。saveSnapshot时同时保存步数popSnapshot时同时恢复步数。这样Activity里的处理逻辑会非常干净移动成功时调用undoManager.saveSnapshot(mapData.getMap(), stepCount)悔棋时调用undoManager.popSnapshot()拿到快照和当时的步数。你的Activity代码不需要关心“先恢复地图再恢复步数”还是反过来一切都是原子操作。写到这里我意识到一个从学生时代就反复出现的教训很多人下载这种项目后第一反应是“运行一下试试”但真正有价值的做法是先看懂地图数据结构和移动判定因为这两块决定你能不能在答辩时讲清楚“为什么这样写”。我当年第一次做推箱子时直接照抄了一段别人的移动判断抄完就忘导致后来想加悔棋功能时完全不知道从哪下手只能把整个逻辑推翻重写。希望你今天看完这篇文之后至少知道箭头键按下时代码里经历了“查地图→判类型→区分行走和推箱→刷新View”这条链路再遇到问题也不至于对着Logcat发呆。希望帮到你。本文还有配套的精品资源点击获取