简介:一份面向Android初学者的贪吃蛇游戏完整源码,适合想入门Android游戏开发、学习SurfaceView绘图与游戏循环机制的开发者。资源虽然精简,但覆盖了蛇的移动、食物随机生成、碰撞检测、游戏状态管理等核心逻辑,并附有可运行APK,方便边看边练。压缩包共39个文件,包括4个Java源码、13个class编译文件、5个XML布局与配置、7张PNG游戏示例图,以及APK、项目配置文件等,整体仅204KB,结构紧凑,便于快速阅读与二次开发。目前已有701人学习下载。通过分析该源码,可掌握Android游戏的基本框架、触摸/按键交互、资源加载和界面布局等实用技能。尤其适合在学习中尝试增加计分、难度等级或音效反馈,进一步理解游戏循环与碰撞检测的细节,为后续开发更复杂的游戏打下基础。
1. 从一份带gen目录的旧工程说起
拿到这份源码时,最先注意到的不是Snake.java本身,而是压缩包里保留了gen、default.properties、.settings这些在 Android Studio 时代几乎绝迹的目录。这意味着它是一份标准的 Eclipse ADT 工程,而不是后来迁移过的 Gradle 项目。对学习而言这反而是好事:gen目录下的R.java是构建期自动生成的,你能直观看到资源 ID 和 Java 代码之间的绑定关系,省去了 Gradle 按需生成的黑盒环节。核心玩法并不复杂——网格地图、方向向量、蛇身链表、碰撞判定——这几件事加起来不到三百行 Java 代码。但正因为简单,它把 SurfaceView 的线程模型、触摸事件分发、状态机切换这些 Android 游戏开发的基础设施完整暴露在了你面前。适合想补 SurfaceView 和 Canvas 绘制短板的人,也适合需要一份无依赖、可直接编译运行的代码来改造毕业设计的人。
2. 游戏循环与 SurfaceView 画面刷新
2.1 为什么是 SurfaceView 而不是 View
普通View的onDraw()由主线程的 Choreographer 驱动,你无法精确控制刷新频率,一旦在回调里做耗时计算,掉帧和 UI 卡顿会同时出现。SurfaceView不一样,它独立拥有一块Surface,可以开一条子线程自己控制绘制节奏。贪吃蛇对实时性的要求不高,但逻辑帧率必须稳定——如果蛇移动速度受系统负载影响忽快忽慢,游戏就失去节奏感了。
这套源码的SnakeView继承的就是SurfaceView并实现了SurfaceHolder.Callback:
public class SnakeView extends SurfaceView implements SurfaceHolder.Callback { private SurfaceHolder holder; private DrawThread drawThread; private boolean isRunning; public SnakeView(Context context) { super(context); holder = getHolder(); holder.addCallback(this); } @Override public void surfaceCreated(SurfaceHolder holder) { isRunning = true; drawThread = new DrawThread(); drawThread.start(); } @Override public void surfaceDestroyed(SurfaceHolder holder) { isRunning = false; try { drawThread.join(); } catch (InterruptedException e) { e.printStackTrace(); } } private class DrawThread extends Thread { @Override public void run() { while (isRunning) { long start = System.currentTimeMillis(); updateGame(); // 更新蛇位置、食物、碰撞状态 renderGame(); // 锁定 Canvas,绘制 long cost = System.currentTimeMillis() - start; long sleepTime = 100 - cost; if (sleepTime > 0) { try { Thread.sleep(sleepTime); } catch (InterruptedException e) { e.printStackTrace(); } } } } } }代码里做了核心的两层分离:updateGame()只改内存里的坐标数据,renderGame()用Canvas把数据画到屏幕上。两个方法都加上了耗时统计,用Thread.sleep()把每一帧的物理耗时顶到 100ms——这是贪吃蛇典型的帧间隔,意味着每秒走 10 格。如果你的手感偏快或偏慢,调整这个值即可,不需要动任何逻辑代码。
2.2 双缓冲与lockCanvas的坑
renderGame()里有一个所有 SurfaceView 新手都会踩的坑:忘记finally中解锁画布。
private void renderGame() { Canvas canvas = null; try { canvas = holder.lockCanvas(); if (canvas != null) { canvas.drawColor(Color.BLACK); drawSnake(canvas); drawFood(canvas); } } finally { if (canvas != null) { holder.unlockCanvasAndPost(canvas); } } }lockCanvas()拿到的画布其实就是内部缓冲区的映射,如果不在finally里unlockCanvasAndPost,Surface 的缓冲区会一直被占用,轻则画面不刷新,重则下次lockCanvas抛异常导致线程崩溃。这里还想提醒一点:不同 Android 版本的 SurfaceView 在页面切换时,surfaceDestroyed的时机并不一样。有的机器切后台时会立刻销毁,有的会延后,因此线程的while标记位要单独用 volatile 布尔值,不能依赖surfaceDestroyed参数的传入值。
3. 蛇身数据模型、移动与碰撞判定
3.1 坐标和链表的对应关系
贪吃蛇最经典的数据结构就是双向链表。蛇头是业务上的核心——所有输入都作用于蛇头,蛇身只是被拖着走。源码里用了ArrayList<Point>而不是LinkedList,这个选择值得商榷,但它实现起来更直观:
public class GameEngine { private List<Point> snake = new ArrayList<>(); private Point food; private int direction = DIRECTION_RIGHT; // 0=上, 1=右, 2=下, 3=左 private int nextDirection = DIRECTION_RIGHT; private int score = 0; public void move() { direction = nextDirection; Point head = snake.get(0); Point newHead = new Point(head.x, head.y); switch (direction) { case DIRECTION_UP: newHead.y--; break; case DIRECTION_DOWN: newHead.y++; break; case DIRECTION_LEFT: newHead.x--; break; case DIRECTION_RIGHT: newHead.x++; break; } if (newHead.equals(food)) { snake.add(0, newHead); // 吃到食物:头插入,尾不删 generateFood(); score += 10; } else { snake.add(0, newHead); // 正常移动:头插入 snake.remove(snake.size() - 1); // 尾删除 } } }这段逻辑里最重要的思想是“方向先存后取”。direction是当前帧实际使用的方向,nextDirection是这一帧内玩家按下的最新方向。如果不区分这两个变量,会出现同一帧内玩家连续按两个方向导致蛇瞬移的情况。蛇头永远通过坐标加减来产生新位置,eat的判断就是newHead.equals(food),两个Point对象只要 x 和 y 相等就判定为命中,这个优先级比“头是否撞到旧蛇身”要高。
3.2 碰撞检测的两种实现方式
源码里对“蛇头撞到蛇身”的检测是在每帧绘制前执行的。最简单的方案就是遍历链表:
| 方案 | 时间复杂度 | 特点 |
|---|---|---|
| 遍历蛇身 | O(n) | 实现简单,调试直观,适合 n 小于几百的场景 |
| HashSet 记录身体坐标 | O(1) | 需要重写 equals/hashCode,处理删除节点时同步移除 |
public boolean isHitSelf() { Point head = snake.get(0); for (int i = 1; i < snake.size(); i++) { if (snake.get(i).equals(head)) { return true; } } return false; }遍历法在贪吃蛇这个场景中性能完全够用——蛇身最长不过百来个点,循环耗时微秒级。但有一个细节容易被忽略:判断碰撞的位置必须在move()之后、绘制之前。如果在move()之前判,那么蛇头还没移动,上一帧的蛇尾和蛇头可能重叠在下一帧的路径上,会造成误判。还有一个容易漏的点:越界检测要写在move()内部,放在新蛇头生成后立刻判断。如果延迟到遍历蛇身时才统一处理,画面会出现蛇头已经消失在屏幕外一帧的情况。
3.3 食物生成的“不在蛇身上”保证
食物的随机位置生成不是简单的random.nextInt(gridX)和random.nextInt(gridY),否则有很大概率生成到蛇身上——特别是当蛇很长时。一个健壮的做法是把所有空闲格子收集起来再抽样:
public void generateFood() { List<Point> freeCells = new ArrayList<>(); for (int x = 0; x < GRID_WIDTH; x++) { for (int y = 0; y < GRID_HEIGHT; y++) { Point p = new Point(x, y); if (!snake.contains(p)) { freeCells.add(p); } } } if (freeCells.isEmpty()) { gameState = GameState.WIN; // 蛇填满全屏,玩家赢了 return; } food = freeCells.get(new Random().nextInt(freeCells.size())); }这里snake.contains(p)依赖Point的equals方法——Android的android.graphics.Point已经实现了基于 x、y 的equals,所以可以直接用。如果自己写坐标类,必须重写equals和hashCode,否则contains永远走引用比较,判断永远不成立。另外当freeCells为空时,说明蛇已经填满整个网格,此时不应当生成食物而是直接判定胜利,这是个边界状态,但不少实现会忽略,导致死循环反复生成食物。
4. 输入事件分发与游戏状态机
4.1 触摸滑动与方向键的响应优先级
源码通过两种途径接收输入:屏幕滑动和物理方向键。触摸事件的本质是onTouchEvent中记录按下点和抬起点,然后比较位移的绝对值大小来确定方向。键盘事件则通过onKeyDown直接映射。需要特别注意的是,触摸事件的判断必须带“最小滑动距离阈值”,否则手指轻微抖动会被解析成转向指令。
@Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: startX = event.getX(); startY = event.getY(); break; case MotionEvent.ACTION_UP: { float dx = event.getX() - startX; float dy = event.getY() - startY; if (Math.abs(dx) > 20 || Math.abs(dy) > 20) { if (Math.abs(dx) > Math.abs(dy)) { onDirectionChange(dx > 0 ? DIRECTION_RIGHT : DIRECTION_LEFT); } else { onDirectionChange(dy > 0 ? DIRECTION_DOWN : DIRECTION_UP); } } break; } } return true; } public void onDirectionChange(int newDirection) { // 禁止直接反向:例如当前向右时,不允许瞬间向左 if ((direction == DIRECTION_RIGHT && newDirection == DIRECTION_LEFT) || (direction == DIRECTION_LEFT && newDirection == DIRECTION_RIGHT) || (direction == DIRECTION_UP && newDirection == DIRECTION_DOWN) || (direction == DIRECTION_DOWN && newDirection == DIRECTION_UP)) { return; } nextDirection = newDirection; }滑动阈值设为 20px,低于这个值的抬手动作视为误触直接忽略。return true表示消费整个事件序列,否则事件会继续传递给父布局或别的控件。禁止直接反向的四个条件必须和当前direction比较,而不是和nextDirection比较——因为如果蛇正在向右走,玩家快速按了下、左两个方向,第一帧nextDirection变成下,第二帧左就会被判定为“右的反向”,如果不拦就会穿头自杀。
4.2 状态机的状态转移条件
游戏循环里需要有一个全局状态来控制哪些逻辑可以在当前状态下执行。源码里使用的是枚举状态机:
public enum GameState { READY, // 等待开始 RUNNING, // 游戏进行中 PAUSED, // 暂停 GAME_OVER, // 游戏结束 WIN // 通关(填满网格) }| 当前状态 | 可转移状态 | 触发方式 |
|---|---|---|
| READY | RUNNING | 点击屏幕任意位置 |
| RUNNING | PAUSED | 按暂停键或切换后台 |
| RUNNING | GAME_OVER | 撞墙或撞到自身 |
| RUNNING | WIN | 蛇身填满全部网格 |
| PAUSED | RUNNING | 点击继续按钮 |
| GAME_OVER | READY | 点击“重新开始” |
updateGame()的开头就需要做状态过滤:
public void updateGame() { if (gameState != GameState.RUNNING) { return; } move(); if (isHitSelf() || isOutOfBound()) { gameState = GameState.GAME_OVER; return; } // 触发下一帧绘制 }这段过滤逻辑如果在run()里判断,会导致问题:当游戏处于PAUSED状态时,Thread.sleep(100)仍然执行,只是不更新坐标。这没问题。但是当GAME_OVER时,updateGame()会一直空转,线程白白消耗 CPU。更合理的做法是当状态不是RUNNING时把线程挂起——用一个wait/notify或者干脆在状态切换时调用Thread.interrupt()来唤醒。源码里没做这一步也算可以理解,但自己扩展时值得补上。另外onPause()里的状态同步要执行:Activity 失焦时自动把RUNNING切到PAUSED,否则游戏在前台切后台再切回来时,蛇会自己多走好几步。
5. 网格步长、机型适配与数据持久化
5.1 绘制时动态计算单元格尺寸
贪吃蛇的经典绘制方式是在drawSnake()里用网格坐标换算像素位置。这里有一个适配问题:手机屏幕宽高比多种多样,如果硬编码单元格像素尺寸,不同分辨率的机器上地图大小就会不一致。我一般会在surfaceChanged回调中根据屏幕尺寸动态计算:
@Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { int cellSize = Math.min(width / GRID_WIDTH, height / GRID_HEIGHT); offsetX = (width - cellSize * GRID_WIDTH) / 2; offsetY = (height - cellSize * GRID_HEIGHT) / 2; this.cellSize = cellSize; }GRID_WIDTH和GRID_HEIGHT定义了逻辑坐标系的大小。比如GRID_WIDTH = 20、GRID_HEIGHT = 20,意味着游戏区域是 20x20 的网格,无论屏幕是 720p 还是 2K,逻辑层始终只关心网格坐标。这就是游戏分层的好处:GameEngine不需要知道任何像素单位,绘制层负责换算。offsetX/offsetY用于居中偏移,避免宽屏手机游戏区偏向一侧。单元格颜色可以用canvas.drawRect填充,蛇头用亮色、蛇身用深色、食物用红色,总绘制耗时不会超过 2ms。
5.2 SharedPreferences 保存历史最高分
源码包没有单独的内存数据库,这完全符合贪吃蛇这种轻量游戏的诉求。最高分只需要一个SharedPreferences文件就能搞定:
public class ScoreManager { private static final String PREF_NAME = "snake_score"; private static final String KEY_HIGH_SCORE = "high_score"; public static void saveHighScore(Context context, int score) { SharedPreferences sp = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); int old = sp.getInt(KEY_HIGH_SCORE, 0); if (score > old) { sp.edit().putInt(KEY_HIGH_SCORE, score).commit(); } } public static int getHighScore(Context context) { return context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) .getInt(KEY_HIGH_SCORE, 0); } }commit()是同步写盘,在游戏结束这种低频场景下没问题,但不要在主线程频繁调用。现在的 Android 版本推荐把commit()换成apply()做异步持久化,两者的差异在于返回值:commit()返回写入结果,apply()立即返回但保证最终写入。针对贪吃蛇场景,GameActivity的onDestroy()里调用saveHighScore即可。
5.3 提高可玩性的变速与穿墙模式
基础流程跑通后,可以从游戏性维度快速扩展。一个最常见的做法是让蛇的速度随得分递增:初始 150ms 一帧,每吃 5 个食物减少 10ms,直到下限 60ms。修改方式很简单,把第 2 章的sleepTime计算里写死的时间改成动态变量即可。如果要对这个经典案例做深度练习,可以在不改变数据结构的前提下增加“穿墙模式”:newHead.x小于 0 时改为GRID_WIDTH - 1,大于等于GRID_WIDTH时归零,y 轴同理。这完全不影响碰撞检测,因为自撞检测只关心蛇身坐标。
另一个值得打磨的细节是食物素材复用度低的问题。源码里食物是红色方块,如果你想把食物换成一张位图或者一套序列帧动画,需要修改drawFood()中canvas.drawBitmap的绘制参数——注意位图资源的density要与屏幕匹配,低密度图片直接拉伸会模糊。扩展建议是把GameEngine抽成独立类,与SnakeView完全解耦,这样无论是后面接 Google Play 游戏服务排行、还是改成多人同屏对战,都只需要改渲染层而不用动核心逻辑。
本文还有配套的精品资源,点击获取