☰
Java Swing手写贪吃蛇:核心逻辑、界面绘制与碰撞检测全解析
2026/10/14 6:23:05 网站建设 项目流程

想用Java写出一个能跑的贪吃蛇,很多人第一反应是找现成的源码抄一遍,但抄完往往还是一知半解,换个需求就改不动。这个项目看似简单,其实把GUI编程、事件监听、数据结构、碰撞检测这些Java入门阶段最实用的知识点全串起来了。我自己的经验是:与其漫无目的地刷语法,不如亲手拆一个完整的贪吃蛇,弄清楚每一步背后的设计理由,比你复制十份代码都管用。

这篇文章我会直接按“从零手写”的思路来拆解,不贴大段看不懂的抽象理论,而是把蛇怎么移动、食物怎么生成、界面怎么刷新这些核心问题用最直白的方式讲清楚。学完之后你不仅能复现一个可玩的贪吃蛇,更关键的是理解为什么这么写,遇到卡顿、闪烁、按键失灵这些经典问题也知道去哪里排查。

1. 项目拆解与整体设计思路

1.1 贪吃蛇游戏的核心逻辑到底是什么

贪吃蛇表面上是个小游戏,实际上是一个典型的状态机:蛇有一个方向、一长串身体坐标、一个食物坐标,游戏每过一小段时间就根据当前方向前进一格,同时判断是否吃到食物、是否撞墙、是否咬到自己。所有这些判断加在一起,就是一套完整的游戏逻辑。

拆开看,核心逻辑只有三件事:

  1. 蛇的移动:每一帧(定时器的每个周期),蛇头向前移动一个格子,蛇身跟着往前挪。如果用数据结构的眼光看,这就是“尾部删除,头部插入”,天然适合用队列或链表来表示。
  2. 食物的生成:在一个不跟蛇身重叠的随机空位生成食物。
  3. 碰撞的判断:蛇头坐标是否等于食物坐标(吃到)、是否超出边界、是否等于某一节蛇身坐标(撞到自己)。

把这个逻辑理顺了,无论你用Swing写桌面版还是用别的框架写网页版,思路都是通用的。很多人一上来就纠结界面怎么画,反而把最核心的逻辑丢在一边,这是最典型的弯路。

1.2 为什么选Java Swing而不是FX或命令行版

选择Swing并不是因为它最先进,而是因为它在教学和快速实现上足够平衡。JavaFX虽然界面更现代,但环境配置相对繁琐;纯命令行版的贪吃蛇虽然也能写,却完全无法体现图形界面的交互感,而且没法直观看到蛇移动的过程。

Swing属于Java标准库自带,不需要额外引依赖,打开IDE新建一个类就能跑。配合JPanel的paintComponent重绘机制和javax.swing.Timer定时刷新,正好能把“线程模型”“事件分派”“重绘机制”这几个核心概念串起来。对于刚接触GUI的人来说,Swing的代码量少、语法直观、出问题也好查,是理解游戏主循环的最佳入门选项。

这里顺便说一个认知误区:Swing确实有点“老”,但它背后的设计思想——事件队列、监听器模式、双缓冲绘图——在现在的桌面应用开发里依然普遍。学会Swing不是让你去写老系统,而是让你搞清楚GUI程序到底是怎么运转的。

1.3 整体架构与模块划分

一个合格的贪吃蛇项目不需要复杂分层,但至少要拆成三个职责清晰的模块:

模块职责推荐实现
数据模型保存蛇身坐标、方向、食物坐标、分数、游戏状态SnakeGame类持有核心字段
界面绘制负责把模型数据画到窗口上GamePanel继承JPanel,重写paintComponent
游戏控制监听按键、启动定时器、驱动逻辑更新控制逻辑放在SnakeGame内部,按键事件绑定到面板

这样做的好处是:绘制只负责读数据,控制只负责改数据,两边不会互相纠缠。比如我改食物生成算法,不需要动任何绘制代码;我改窗口大小,也不需要碰逻辑层。项目虽然小,但这个小分工能让你形成模块化意识,后面做复杂项目也用得上。

2. 核心细节解析:数据模型与界面绘制

2.1 蛇的表示:链表、数组还是ArrayList

蛇身体的每个部分都是一个坐标点,可以用(x, y)表示。比较自然的选择是LinkedList<Point>,因为蛇每次移动都是“头加一节、尾减一节”,这正是链表最擅长的操作。

不过实际写下来,我推荐用ArrayList<Point>,原因有两个:

  • ArrayList 在随机访问上更快,判断“蛇头是否碰到身体某段”时,for循环遍历比链表的指针跳转更高效。
  • 贪吃蛇的蛇身长度一般最多只有几百节,两者性能差异几乎无感,但ArrayList的代码写起来更顺手,调试时打印也清晰。

也有人用固定大小的二维数组来做棋盘,每个格子存0或1表示有无蛇身。这种方式适合小棋盘,但食物坐标、蛇身坐标都要额外维护,自由度反而不如坐标点集合。我的建议是:棋盘控制在20x20以内,直接用ArrayList<Point>最省事。

2.2 食物生成与碰撞检测的细节

食物生成的基础逻辑是“找一个蛇身没占用的随机位置”。听起来简单,实际操作有几个坑:

第一,随机范围必须匹配棋盘边界。如果棋盘是20列16行,每个格子边长25像素,食物坐标应该是:x = random.nextInt(COL_COUNT),后续绘制时再乘以格子边长。很多人习惯直接在像素范围里取随机数,结果食物画出来压线、超出边界,就是没搞清楚“网格坐标”和“像素坐标”的换算关系。

第二,生成食物后要检查是否和蛇身重叠。如果重叠,就重新生成。注意“重新生成”不能只是一个if判断,最好用while循环持续尝试。蛇身越长,随机位置撞到蛇身的概率越大,while循环虽然理论上可能死循环,但在可玩范围内重试次数很少,不用杞人忧天。

碰撞检测其实可以统一成一个方法:接收一个坐标点,判断它是否超出边界、是否和某段蛇身重合。蛇头撞到身体时要注意一点:不能把“蛇头自己”也算进去。如果你用List.contains(snake.get(0))来判断,那必然永远返回true,因为蛇头肯定在列表里。正确做法是从第1节开始遍历,跳过蛇头本身。

2.3 界面绘制:双缓冲与定时刷新

Swing绘制用的是paintComponent(Graphics g)方法,所有绘制代码都写在里面。游戏画面要动起来,本质就是“每隔一段时间改变数据,然后触法重绘”。JPanel有个repaint()方法,调用后系统会异步触发一次paintComponent,这时候把最新的蛇身坐标画上去,画面就更新了。

这里有一个关键经验:不要在paintComponent里直接修改游戏数据。绘制方法应该只负责“读数据、画图形”,改数据由定时器或按键事件完成。如果你把移动逻辑塞进绘制方法里,会导致键盘输入和画面刷新错乱,而且多线程环境下会引发奇怪的并发问题。

双缓冲是Swing默认开启的,所以我们不需要手动处理闪烁。但如果你是第一次写游戏,可能会在某个教程里看到手动创建缓冲图像、用Graphics.drawImage画上去的写法。在Swing里不用这么麻烦,JPanel默认就开了双缓冲。遇到画面闪烁,先别折腾缓冲代码,优先检查你是不是直接在paint方法里做了耗时操作,或者定时器间隔设得太短,导致重绘跟不上。

定时刷新用javax.swing.Timer而不是Thread.sleep,这也是一个关键点。Timer的回调事件会回到Swing的事件分发线程(EDT)上执行,线程安全;而Thread.sleep配合repaint容易造成界面卡死。用Timer的代码看起来是这样的:先创建一个Timer实例,设定延迟毫秒数(比如150毫秒),调用start()后每隔这么多时间执行一次actionPerformed。在actionPerformed中更新蛇的位置,然后repaint()。

3. 实操过程:从零写一个可玩版本

3.1 搭建基础窗口

先建一个SnakeGame类作为主窗口,继承JFrame。构造方法里设置窗口标题、默认关闭操作、大小和布局。主要内容是一个自定义的GamePanel,以后所有游戏逻辑都放这个面板里。

public class SnakeGame extends JFrame { public SnakeGame() { setTitle("贪吃蛇"); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setResizable(false); // 创建游戏面板 GamePanel panel = new GamePanel(); add(panel); pack(); // 根据面板尺寸调整窗口大小 setLocationRelativeTo(null); // 窗口居中 } public static void main(String[] args) { // 界面创建必须在事件分发线程中 java.awt.EventQueue.invokeLater(() -> { new SnakeGame().setVisible(true); }); } }

pack()是个容易忽略的方法,它的作用是让窗口大小刚好包裹住内部组件,比手动setSize更精准。面板的尺寸由你在GamePanel里覆写getPreferredSize()返回,这样改棋盘大小时不用同步改窗口尺寸,省了很多麻烦。

3.2 实现蛇的移动与控制

用一个ArrayList<Point>表示蛇身,初始给三节,分别位于棋盘中间。用一个枚举或常量表示方向:上、下、左、右。每一帧的移动逻辑可以写成独立的move()方法:

public void move() { if (!running) return; Point head = snake.get(0); Point newHead = switch (direction) { case UP -> new Point(head.x, head.y - 1); case DOWN -> new Point(head.x, head.y + 1); case LEFT -> new Point(head.x - 1, head.y); case RIGHT -> new Point(head.x + 1, head.y); }; snake.add(0, newHead); // 头部插入 if (newHead.equals(food)) { score += 10; generateFood(); } else { snake.remove(snake.size() - 1); // 尾部移除 } }

这里用到了Java 12以上的switch箭头语法,如果你的环境比较老,改成普通的switch或if/else也可以,逻辑完全一样。值得注意的是,方向值的更新要在移动前检查有效性:不能按“左”时当前方向是“右”,会导致蛇头直接折返撞到自己。所以键盘监听的逻辑里,除了设置方向,还要判断新方向是否跟当前方向相反。

控制部分通过KeyListener实现。GamePanel实现KeyListener,覆写keyPressed方法,根据按下的键码更新方向。有个常见的坑:JPanel默认没有焦点,按键盘没反应。解决办法是在构造里调用setFocusable(true),然后在合适时机调用requestFocusInWindow()。我之前遇到按键失灵,排查半天才发现是焦点跑到了别的地方。

3.3 加入食物、得分和游戏结束逻辑

食物用一个Point对象表示。生成食物时,用Random生成在网格范围内的坐标,同时判断是否和蛇身重叠。画食物时,可以画一个红色实心方块或圆形:

g.setColor(Color.RED); g.fillOval(food.x * CELL_SIZE + offset, food.y * CELL_SIZE + offset, CELL_SIZE - 2 * offset, CELL_SIZE - 2 * offset);

玩家吃一个食物分数加10,这个规则可以自己定义。分数展示最简单的方法是把比分画在窗口下方的状态栏,或者在paintComponent里直接用drawString画在面板顶部。我倾向于画在面板里,因为这样实现简单,而且不用额外加组件。

游戏结束条件有两个:撞墙和撞自己。撞墙的判断很简单,newHead.x < 0 || newHead.x >= COL_COUNT || newHead.y < 0 || newHead.y >= ROW_COUNT,一旦触发就把running设为false,游戏停止。撞自己的判断从索引1开始遍历蛇身,如果新蛇头和某一节坐标相同,游戏结束。结束之后可以打印“Game Over”,或者弹一个提示框,这里不再追求复杂。

3.4 完整的核心代码参考

下面给出GamePanel的完整参考实现。代码里我把网格常量、蛇身数据、定时器逻辑和绘制全部集中在一个类里,结构相对紧凑,也方便初学者阅读。你在实际项目中可以按之前的模块划分再拆类,但思路一致。

import javax.swing.*; import java.awt.*; import java.awt.event.*; import java.util.ArrayList; import java.util.List; import java.util.Random; public class GamePanel extends JPanel implements ActionListener, KeyListener { private static final int CELL_SIZE = 25; private static final int COLS = 20; private static final int ROWS = 16; private final List<Point> snake = new ArrayList<>(); private final Random random = new Random(); private Point food; private int direction = Direction.RIGHT; private int score = 0; private boolean running = true; private final Timer timer; private static final class Direction { static final int UP = 0; static final int DOWN = 1; static final int LEFT = 2; static final int RIGHT = 3; } public GamePanel() { setPreferredSize(new Dimension(COLS * CELL_SIZE, ROWS * CELL_SIZE)); setBackground(Color.BLACK); setFocusable(true); addKeyListener(this); initGame(); timer = new Timer(120, this); timer.start(); } private void initGame() { snake.clear(); int midX = COLS / 2, midY = ROWS / 2; snake.add(new Point(midX, midY)); snake.add(new Point(midX - 1, midY)); snake.add(new Point(midX - 2, midY)); generateFood(); direction = Direction.RIGHT; running = true; score = 0; } private void generateFood() { while (true) { int x = random.nextInt(COLS); int y = random.nextInt(ROWS); Point p = new Point(x, y); if (!snake.contains(p)) { food = p; return; } } } @Override public void actionPerformed(ActionEvent e) { if (running && !gameOver()) { move(); repaint(); } } private boolean gameOver() { Point head = snake.get(0); if (head.x < 0 || head.x >= COLS || head.y < 0 || head.y >= ROWS) { running = false; return true; } for (int i = 1; i < snake.size(); i++) { if (head.equals(snake.get(i))) { running = false; return true; } } return false; } private void move() { Point head = snake.get(0); Point newHead = null; if (direction == Direction.UP) newHead = new Point(head.x, head.y - 1); else if (direction == Direction.DOWN) newHead = new Point(head.x, head.y + 1); else if (direction == Direction.LEFT) newHead = new Point(head.x - 1, head.y); else if (direction == Direction.RIGHT) newHead = new Point(head.x + 1, head.y); snake.add(0, newHead); if (newHead.equals(food)) { score += 10; generateFood(); } else { snake.remove(snake.size() - 1); } } @Override public void paintComponent(Graphics g) { super.paintComponent(g); // 绘制食物 g.setColor(Color.RED); g.fillOval(food.x * CELL_SIZE + 2, food.y * CELL_SIZE + 2, CELL_SIZE - 4, CELL_SIZE - 4); // 绘制蛇身 for (int i = 0; i < snake.size(); i++) { Point p = snake.get(i); if (i == 0) { g.setColor(new Color(50, 200, 50)); } else { g.setColor(new Color(100, 150, 100)); } g.fillRect(p.x * CELL_SIZE + 1, p.y * CELL_SIZE + 1, CELL_SIZE - 2, CELL_SIZE - 2); } // 绘制分数 g.setColor(Color.WHITE); g.setFont(new Font("SansSerif", Font.BOLD, 16)); g.drawString("得分: " + score, 5, 18); if (!running) { g.setColor(Color.WHITE); g.setFont(new Font("SansSerif", Font.BOLD, 30)); g.drawString("游戏结束", COLS * CELL_SIZE / 2 - 60, ROWS * CELL_SIZE / 2); } } @Override public void keyPressed(KeyEvent e) { int key = e.getKeyCode(); if (key == KeyEvent.VK_UP && direction != Direction.DOWN) { direction = Direction.UP; } else if (key == KeyEvent.VK_DOWN && direction != Direction.UP) { direction = Direction.DOWN; } else if (key == KeyEvent.VK_LEFT && direction != Direction.RIGHT) { direction = Direction.LEFT; } else if (key == KeyEvent.VK_RIGHT && direction != Direction.LEFT) { direction = Direction.RIGHT; } } @Override public void keyReleased(KeyEvent e) {} @Override public void keyTyped(KeyEvent e) {} }

注意这个参考代码里,move()和gameOver()是分开的。我第一次写的时候把结束检测放在move()开头,结果蛇撞墙后还会多移动一格,画面上蛇头已经穿墙了才停止。正确的顺序应该是先检测再移动,或者移动后立即检测新头部。我用的是后者:先gameOver()判断当前状态,再调用move()。如果你的move()里已经先加了新头,那就要在move()内部检测新头是否合法。

3.5 键盘输入的细节优化

上面代码用的是KeyListener,在小项目里够用,但它有几个不太顺手的地方:焦点丢失后按键无响应、连续按键会触发按键重复。如果你有兴趣进一步优化,可以把键盘监听改成按键绑定(KeyBindings)。KeyBindings不依赖焦点,适合游戏界面。不过KeyListener实现简单,作为教学完全合格。

按键重复的问题在实际游戏中表现为:按住方向键不松,蛇会持续加速改变方向,容易造成误判。解决办法有很多,最简单的可以在keyPressed里加一个布尔标志,等keyReleased再允许下一次方向变更。但这样做会影响体验,因为玩家连续快速按两下方向键的操作也会被拦截。更好的方案是使用按键重复事件,但这种细节属于锦上添花,先跑通核心再说。

4. 常见问题与排查技巧实录

4.1 蛇身不移动或画面静止

遇到蛇完全不动,90%是定时器没有启动,或者actionPerformed里没有调用repaint()。检查顺序如下:

  1. 确认timer = new Timer(...)之后调用了timer.start()。
  2. 确认GamePanel实现了ActionListener,并且actionPerformed方法里有移动和重绘逻辑。
  3. 确认移动逻辑真的会修改蛇的坐标数据。很多时候你打印snake发现坐标没变,那就是方向枚举写错了,或者move()里每次生成的新坐标和旧坐标相同(比如坐标系理解反了)。

另一个隐藏问题是:在构造方法里初始化Timer时,ActionListener传的是this,但如果你在测试时额外创建了多个GamePanel实例,多个定时器会同时跑。每个窗口只有一个面板,一般不会碰到,但如果用IDE的预览功能,可能同时激活多个JFrame,导致逻辑混乱。

4.2 画面闪烁、卡顿和性能问题

Swing默认双缓冲,一般不至于闪烁。如果画面仍然闪,常见原因是你覆写paintComponent时调用了super.paintComponent(g),但随后又执行了耗时方法(比如random.nextInt应该很快,但如果你在里面做文件IO或大量循环,就会卡)。另一个原因是你把repaint()和修改数据放在同一个非EDT线程里,导致重绘调用过于密集。我之前见过一个例子,有人在keyPressed里调用move()再调用repaint(),同时定时器也在调用move()和repaint(),导致一帧被重复更新多次,蛇的移动速度忽快忽慢,画面看起来像跳跃。这种问题排查半天,最后发现是两个线程都改了同一个数据,解决办法是只保留一个更新源:定时器负责移动,键盘监听只改方向。

4.3 边界和自身碰撞误判的典型坑

撞墙误判通常出在“移动后未立即检测”或“检测时用旧坐标”。如果你在move()里先把新头加入列表,那么检测应该用snake.get(0)而不是移动前的head。有人图省事把head引用保存下来,但Point对象是可变引用,加入列表后对象内容变了,旧引用也会跟着变,导致判断永远不准。建议每次检测都用new Point(head.x, head.y)创建新对象,避免引用共享。我在代码里用了new Point就是基于这个原因。

自身碰撞还有一个隐蔽问题:如果蛇的尾巴正要挪走的那一格恰好是食物位置,移动时尾部还没移除,新头部撞向尾部,可能被误判为撞到自己。但实际上因为尾部这一帧会移走,新头与旧尾重合应该被允许。这个属于高级规则,普通贪吃蛇不用处理,大部分教程也没提。想追求更精准的规则,可以在判断时排除“即将移除的尾部节点”。不过新手阶段建议先保持标准规则,避免把逻辑搞复杂。

4.4 常见问题速查表

现象可能原因解决方法
按键无反应面板没有焦点调用setFocusable(true)并requestFocusInWindow()
蛇只移动一次就停定时器未启动或未repaint检查timer.start()和actionPerformed里的repaint()
食物生成在蛇身上随机坐标没有去重用while循环重新生成
蛇头能倒退方向检测缺少反向判断按上时判断当前方向不是下,以此类推
撞到边界却没结束检测时机不对新头生成后立即判断边界
画面闪烁绘制方法里有耗时操作精简paintComponent,只做绘制;更新逻辑移到定时器里
游戏速度越来越快累积了多个定时器或多个线程确保只有一个Timer实例持续运行

5. 经验心得与扩展方向

5.1 从贪吃蛇到更复杂游戏:学会抽象游戏循环

写完这个贪吃蛇之后,你会发现一个规律:几乎所有实时游戏都有一个“游戏循环”。Timer每隔固定时间更新状态,然后重绘画面;用户输入改变状态;循环检测结束条件。这个模式放在飞机大战里就是子弹坐标更新和碰撞检测,放在俄罗斯方块里就是方块下落和消除检测。一旦你从贪吃蛇里抽象出“状态更新 + 渲染”的理解,后面再写其他小游戏都能快速上手。

我写了很多次贪吃蛇,发现多数人的瓶颈根本不是语法,而是没有建立“数据驱动画面”的思维。画面只是数据的视觉表现,改数据才是游戏的核心。脑子里有了这个模型,再去看各种游戏框架的文档,理解起来会快很多。

5.2 后续可以加入的扩展功能

如果你觉得基础版太简单,可以按下面的清单逐步加难度:

  • 加速机制:每吃一定数量的食物,把定时器延迟调低,比如从150毫秒降到120毫秒。注意Timer支持setDelay动态修改。
  • 障碍物:在棋盘内固定位置生成若干障碍点,蛇头碰到障碍同样结束游戏。
  • 双玩家模式:用WASD和方向键分别控制两条蛇,实现对战。这会牵扯到同时监听两组按键,也是很好的练习。
  • 排行榜:用文件或数据库记录历史最高分。建议先练习用最简单的Properties配置文件记录,不引入重型数据库。
  • 暂停功能:空格键切换暂停状态。实现思路是加一个布尔变量,actionPerformed里判断如果暂停就直接return。

添加这些功能时,尽量沿着“模型-视图-控制”的边界去扩展,不要为了省事把所有代码堆在paintComponent里。举个反例:有人把暂停标记直接写在绘制方法里,结果每次重绘都改变游戏状态,画面上看起来像是随机暂停。这种事你踩过一次坑就明白:绘制方法永远不能修改模型。

5.3 个人建议与踩坑总结

我实际写贪吃蛇最大的教训是低估了“方向键和定时器的协作关系”。如果方向更新和移动更新同时发生,可能会出现“按左键后蛇还没动,同时定时器又用旧方向移动了一格”的错位。解决方法是在keyPressed里只把按键事件记录为一个“待生效方向”,然后在定时器的actionPerformed里统一更新方向并移动。这样做的本质是把所有的状态变更都收敛到同一个时间点,避免事件交叉。代码量只多几行,但稳定性提升明显。

还有一个小技巧:为了让蛇头更醒目,用不同颜色绘制头身之外,还可以在蛇头上画两个小圆点当眼睛,这样游戏体验会好很多。这个小细节属于锦上添花,但动手改一改绘制代码,对加深Graphics对象的理解很有帮助。

最后说一点心态上的体会:写小游戏最容易两极化,要么觉得简单不屑于动手,要么死磕某个边角问题钻牛角尖。贪吃蛇最大的意义不是做出一个完美的商业产品,而是让你在一天内体会到“从想法到可玩程序”的完整链路。这个成就感是刷一百道语法题都换不来的。跑起来玩几局,再改点参数看看效果变化,你对Java的理解绝对会上一个台阶。

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

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

立即咨询