☰
用Java写贪吃蛇小游戏:从Swing入门到核心逻辑实战
2026/10/14 11:03:44 网站建设 项目流程

平时有人问我Java学到什么程度能动手做点东西,我一般都会说:别等,直接拿小游戏练。用Java写一个小游戏,听起来不算多高大上,但它是把语法、面向对象、事件驱动、GUI、状态管理这些知识点一次性串起来的最快路径。这篇文章我把经典到不能再经典的贪吃蛇当例子,从方案选型、整体架构、核心逻辑到常见坑位完整过一遍,代码全部基于JDK标准库,不引入任何外部依赖。你只需要准备好IDE和JDK就能跑起来。适合刚学完Java基础、正纠结下一个练手项目的人,也适合已经能写点控制台程序但完全没碰过图形界面的同学——照着这条路子走,一个下午就能玩到自己写的游戏。

1. 项目定位与方案选型

1.1 为什么用Java写小游戏当练手项目

很多人学完循环、数组、面向对象之后进入一个尴尬期:每个知识点单独看都明白,一合起来就不知道怎么用。这时候如果去啃框架,容易越学越虚。小游戏恰恰是最好的“整合练习”,它要求你同时处理状态变化、输入响应、界面刷新和逻辑判定,但又不像企业项目那样庞大的无从下手。

也有人会质疑:Java写游戏不是性能不行吗?这话得分场景。做商业游戏确实有更合适的引擎和语言,但在学习阶段,我们要的不是帧率多极致,而是理解游戏程序的核心骨架——初始化、主循环、输入、更新、绘制、退出。这套骨架用Java来表达非常清晰,加上JVM的垃圾回收,你甚至不用操心内存释放,可以把全部精力放在逻辑上。

我之前遇到一个学员,控制台版猜数字写得挺顺,但一到“做点真东西”就卡住。后来他花两天把贪吃蛇写了出来,再回头聊面向对象,整个人思路都通了。这就是小游戏的魔力:范围小、反馈快、Bug可预期,非常适合用来建立“我能独立写程序”的信心。

1.2 图形方案怎么选:Swing、JavaFX还是控制台

同样是Java写游戏,摆在面前的无非三条路:控制台、Swing/AWT、JavaFX。我做了一张对比表,方便你根据自己的情况对号入座。

方案学习成本界面效果额外依赖适合场景
控制台最低字符界面,无图形无猜数字、文本冒险,练纯逻辑
Swing/AWT中等2D绘制够用,比较朴素JDK自带第一个桌面小游戏
JavaFX中高现代化样式,特效丰富高版本JDK需额外模块已有Swing基础后尝试

我建议第一版老老实实用Swing。理由很直接:Swing是JDK内置的,不需要配置任何依赖;它的JFrame、JPanel、Timer、KeyListener这些组件刚好覆盖了小游戏需要的全部能力;资料也特别多,遇到问题随便一搜就有答案。

JavaFX虽然界面更现代,但对新手来说,模块配置本身就是一道坎。控制台则更适合纯逻辑练习,如果全程用字符画蛇,你会丢失图形编程里最关键的“事件驱动”体验,学不到键盘监听和绘制刷新这套东西。所以第一步,选Swing,别犹豫。

1.3 第一版为什么选贪吃蛇这个玩法

确定用Swing之后,下一个问题是做什么游戏。常见选项有贪吃蛇、俄罗斯方块、飞机大战,我直接说结论:新手第一版首选贪吃蛇。

俄罗斯方块看着规则简单,但方块旋转矩阵、消行判定、下一块预览这些逻辑堆在一起,新手很容易卡在数学转换上。飞机大战涉及多个敌机、子弹、碰撞批量处理,调试起来对耐心是个考验。贪吃蛇则清爽得多:一条蛇、一个食物、四个方向、撞墙或撞自己两种失败条件,状态极简。

更妙的是,贪吃蛇背后的知识点正好和Java数据结构对得上——蛇身增长天然就是队列操作,头部加一截、尾部减一截;食物生成自然用随机数;按键连按还能引出输入队列的处理。这些点单独拿出来讲都很抽象,但放在贪吃蛇里就能直观地看到它们为什么存在。先把贪吃蛇跑通,再去写俄罗斯方块和飞机大战,你会觉得路顺很多。

2. 整体架构与核心数据结构

2.1 类怎么划分:三个类就够,别过度设计

我见过不少初学者一上手就整出Model、View、Controller、Service一堆包,结果小游戏总共几百行代码,类之间互相调来调去,改一个功能要动四个文件。新手阶段最忌讳过度设计,项目小的时候,清晰直接比“架构感”重要得多。

我的建议是先用三个类把项目立起来:

类职责关键内容
GameFrame窗口容器创建JFrame、添加面板、显示窗口
GamePanel游戏面板游戏循环、绘制、键盘事件、逻辑更新
Direction方向枚举定义上下左右及坐标增量

Snake和Food这种数据,第一版可以直接用ArrayDeque<Point>和一个Point对象挂在GamePanel里跑,等跑通了再拆也行。别为了练设计模式而强行拆类,代码能跑、能改、能看懂,比什么都重要。

窗口和面板分开是底线,因为后续你很可能想加“开始菜单”“暂停界面”这样的东西。窗口负责把不同面板装进去,面板自己管自己的绘制和逻辑,两者解耦之后扩展起来几乎不用动旧代码。

2.2 蛇身为什么用ArrayDeque而不是ArrayList

蛇的移动逻辑是:头部前进一格,尾部去掉一格;吃到食物时尾部不动,长度加一。这个行为的本质就是“一头进、一头出”,标准的队列操作。

用ArrayList当然也能写,但要注意list.remove(0)在Java里是一个数组搬移操作,删除第一个元素要把后面所有元素往前挪一位,时间复杂度O(n)。用ArrayDeque的话,头部插入、头部删除、尾部插入都是O(1)。蛇最多几十上百格的时候二者性能都无感,但选对数据结构是一种习惯——等哪天你处理的数据量真的上来了,这种直觉能帮你少写很多冤枉代码。

ArrayDeque还有一个特点,它不允许放入null,迭代顺序是从队头到队尾,这正好符合蛇身从头到尾的绘制顺序。

2.3 坐标系统:运算用网格,绘制用像素

写游戏最容易翻车的地方之一就是坐标混用。我建议从一开始就定死一套规则:蛇和食物的位置全部用网格坐标表示,只有绘制时才换算成像素坐标。

具体做法是定义三个常量:

private static final int COLS = 30; // 横向格子数 private static final int ROWS = 30; // 纵向格子数 private static final int CELL = 20; // 每格像素

蛇头的坐标是一个Point(x, y),其中x范围是0到29,y范围是0到29。移动时x加一就是向右一格,y减一就是向上一格。撞墙判断就是x < 0 || x >= COLS || y < 0 || y >= ROWS,简单清晰。

绘制时才换算:画一个身体格就是fillRect(p.x * CELL, p.y * CELL, CELL, CELL)。这样做的最大好处是,碰撞检测全部是整数比较,根本不需要考虑像素级对齐问题;食物随机生成也就是random出两个0到29的整数。整套逻辑里,像素值只在窗口尺寸设置和绘制方法中出现,思路非常干净。

3. 核心功能实现与关键代码

3.1 窗口搭建与定时器驱动的游戏循环

游戏窗口用GameFrame来搭建,关键点是不要直接setSize(600, 600),因为JFrame的size包含标题栏和边框,内容区域会小于预期。正确做法是让面板决定自己的尺寸,窗口用pack()自动适配。

public class GameFrame extends JFrame { public GameFrame() { setTitle("贪吃蛇小游戏"); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setResizable(false); GamePanel panel = new GamePanel(); add(panel); pack(); setLocationRelativeTo(null); setVisible(true); } }

游戏循环这块,我最推荐的方法是用javax.swing.Timer。它本质是一个定时触发事件的组件,每隔固定毫秒执行一次actionPerformed,而且它天然运行在Swing事件线程(EDT)上,不需要处理跨线程刷新问题。

private Timer timer; public GamePanel() { setPreferredSize(new Dimension(COLS * CELL, ROWS * CELL)); setBackground(Color.BLACK); setFocusable(true); addKeyListener(this); initGame(); timer = new Timer(100, this); timer.start(); }

100毫秒表示蛇每秒钟移动10格,这个速度对贪吃蛇来说很合适,太快手指反应不过来,太慢又觉得拖沓。Timer的delay可以在游戏过程中动态调整,这个后面扩展加速功能时会用到。

为什么不推荐while(true) + Thread.sleep?因为如果你在事件线程里执行无限循环,界面会直接卡死;如果单独开线程,Swing规定组件更新必须在EDT进行,你还得用SwingUtilities.invokeLater绕一圈。与其自己踩这些坑,不如直接用现成的Timer。

3.2 方向控制:用队列处理连续按键

这是贪吃蛇里最容易写出Bug、也最能体现“输入处理”思维的地方。很多人第一版会这样写:在keyPressed里直接修改当前方向。这个写法有个致命问题——某个时间片内玩家连按了“上”和“左”,如果两条事件都被执行,蛇头还没来得及向上走一格,方向就被改成了左,结果就是蛇直接反向撞到自己身上。

正确做法是引入输入队列,每帧只消费一个方向:

private Queue<Direction> inputQueue = new LinkedList<>(); @Override public void keyPressed(KeyEvent e) { Direction next = switch (e.getKeyCode()) { case KeyEvent.VK_UP -> Direction.UP; case KeyEvent.VK_DOWN -> Direction.DOWN; case KeyEvent.VK_LEFT -> Direction.LEFT; case KeyEvent.VK_RIGHT -> Direction.RIGHT; default -> null; }; if (next != null && inputQueue.size() < 3) { inputQueue.offer(next); } if (e.getKeyCode() == KeyEvent.VK_SPACE && !running) { initGame(); } }

然后在actionPerformed里优先消费队列:

@Override public void actionPerformed(ActionEvent e) { if (!running) return; if (!inputQueue.isEmpty()) { Direction next = inputQueue.poll(); if (!isOpposite(dir, next)) { dir = next; } } move(); repaint(); }

isOpposite判断两个方向是否完全相反:

private boolean isOpposite(Direction current, Direction next) { return (current == Direction.UP && next == Direction.DOWN) || (current == Direction.DOWN && next == Direction.UP) || (current == Direction.LEFT && next == Direction.RIGHT) || (current == Direction.RIGHT && next == Direction.LEFT); }

这个设计本质上是把“事件响应”和“逻辑更新”解耦:按键只负责把意图塞进队列,真正的方向变更只发生在固定的时间步长里。很多游戏处理连续输入用的都是这套思路,在这里提前实践一遍,后面写更复杂的输入系统会轻松很多。

3.3 蛇的移动、吃食物与碰撞判定

move()是整个游戏最核心的方法,顺序很重要,我这样写:

private void move() { Point head = snake.getFirst(); Point newHead = new Point(head.x + dir.dx, head.y + dir.dy); // 1. 撞墙检测 if (newHead.x < 0 || newHead.x >= COLS || newHead.y < 0 || newHead.y >= ROWS) { running = false; return; } // 2. 撞自己检测 if (snake.contains(newHead)) { running = false; return; } // 3. 蛇头前进 snake.addFirst(newHead); // 4. 判断是否吃到食物 if (newHead.equals(food)) { score += 10; generateFood(); } else { snake.removeLast(); } }

先算新头坐标,再做碰撞判定,最后决定蛇是增长还是保持长度。注意这里不能先把尾巴去掉再加头,否则蛇在“原地掉头”这种场景下会误判撞到自己。实际调试时如果你发现蛇偶尔能穿到自己身体里,多半就是顺序写反了。

关于撞自己的判断,有一个细节:在没吃到食物的情况下,蛇尾下一秒会移走,所以如果新头正好落在旧尾巴的位置,严格来说不算撞到自己。保守的写法是像上面这样把尾巴也算障碍物,这样玩家游戏体验会稍微严苛一点,但对第一版已经足够。你可以在跑通之后自己改一版“不撞尾巴”的玩法,对比一下手感差异。

食物生成我用的是候选列表法,而不是while(true)随机撞:

private void generateFood() { List<Point> free = new ArrayList<>(); for (int x = 0; x < COLS; x++) { for (int y = 0; y < ROWS; y++) { Point p = new Point(x, y); if (!snake.contains(p)) { free.add(p); } } } if (!free.isEmpty()) { food = free.get(rand.nextInt(free.size())); } }

为什么不用“随机生成一个点,不在蛇身上就退出,否则再随机”?蛇短的时候没问题,但等蛇身长到一两百格时,随机命中空位的概率越来越低,万一运气差就一直空转。候选列表法是每次都遍历30乘30的格子,最多900次判断,复杂度完全固定,永远不会出现那种“卡半天才生成食物”的诡异情况。

3.4 游戏重置与状态控制

游戏结束之后最常见的需求是“按空格再来一局”。这个功能看起来简单,但有一个特别容易忽略的坑:输入队列里可能残留上一次游戏的按键。

private void initGame() { snake.clear(); snake.addFirst(new Point(COLS / 2, ROWS / 2)); snake.addLast(new Point(COLS / 2 - 1, ROWS / 2)); dir = Direction.RIGHT; inputQueue.clear(); score = 0; running = true; generateFood(); repaint(); }

inputQueue.clear()这行必须加。如果不加,你上一局按下的方向键可能在新一局的前几帧被消费掉,导致蛇开局就拐弯,甚至直接撞墙或撞到自己。这种“重启后莫名其妙暴毙”的Bug特别难排查,因为它不是每次必现,只在按键残留时出现。

另一个建议是给游戏状态做个基本区分,第一版用running布尔够用,但如果你后面想加暂停功能,最好是定义一个枚举,比如READY、RUNNING、PAUSED、OVER。每次方法入口都先检查当前状态,能省掉很多“游戏明明结束了还在动”的灵异现象。

4. 绘制细节与体验优化

4.1 paintComponent的正确打开方式

Swing踩坑的第一课就是覆写绘制方法。请记住:永远覆写paintComponent(Graphics g),不要覆写paint(Graphics g),也不要忘记第一行调用super.paintComponent(g)。

@Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 背景 g.setColor(Color.BLACK); g.fillRect(0, 0, getWidth(), getHeight()); // 网格线(可选,辅助观察坐标) g.setColor(new Color(40, 40, 40)); for (int i = 0; i <= COLS; i++) { g.drawLine(i * CELL, 0, i * CELL, ROWS * CELL); g.drawLine(0, i * CELL, COLS * CELL, i * CELL); } // 食物 g.setColor(Color.RED); g.fillOval(food.x * CELL + 2, food.y * CELL + 2, CELL - 4, CELL - 4); // 蛇身 for (Point p : snake) { g.setColor(Color.GREEN); g.fillRect(p.x * CELL + 1, p.y * CELL + 1, CELL - 2, CELL - 2); } }

调用super.paintComponent(g)是为了让Swing先清掉上一帧的画布,避免出现“残影”。Swing默认已经开启了双缓冲,所以初版完全不需要自己再写双缓冲逻辑。

关键原则:绘制方法里只负责读数据,不要改任何逻辑状态。比如在paintComponent里做碰撞检测、修改蛇身长度,这些都是坏味道。逻辑和绘制一旦混在一起,画面刷新和游戏速度就会互相影响,最后查起来会非常痛苦。

4.2 避免画面闪烁和CPU占用过高

Swing的Timer本身已经很稳,但还是有一些手感问题值得注意。首先是绘制开销,贪吃蛇每个格子只有20像素宽,整个画面也就900个格子,绘制性能根本不是瓶颈。真正需要注意的是不要在paintComponent里做耗时操作和创建大对象,比如读文件、加载图片、new一个巨大的数组,这些都会拖慢刷新频率。

CPU占用过高的常见原因有三个:Timer的delay被设得太小、游戏循环里做了无意义的重复计算、或者不小心用了while(true) + sleep(1)。我曾经见过有人把delay调到1毫秒,想让蛇跑快点,结果CPU直接拉满,画面反而更卡。对这类小游戏来说,10到15帧每秒已经完全够用,不要为了“流畅”而盲目追求高帧率。

另外,用Timer驱动时,actionPerformed里的代码要尽量轻。逻辑更新和绘制都放在这个回调里没问题,但不要让单帧执行时间超过delay的数值,否则定时器会堆积事件,游戏看起来就像“卡一下,然后猛跳一下”。这也是固定时间步长需要注意的基本直觉。

4.3 让第一版看起来不那么“练习作业”

画蛇的时候每格留1到2个像素的间距,比如CELL - 2,蛇身就不至于糊成一坨。蛇头用单独的黄色画,一来美观,二来方便玩家一眼定位头部朝向。食物画成圆形而不是方块,视觉感受会舒服很多。

中文字体的坑也提一句。如果你的绘制代码直接写g.drawString("得分:" + score, 10, 20),在某些平台上默认字体可能不包含中文字形,结果就是方框乱码。稳妥的做法是先设置字体:

g.setFont(new Font("宋体", Font.BOLD, 16));

然后画分数。游戏结束时的提示文字同理,字号可以放大一点,居中显示“游戏结束,按空格重新开始”。

窗口标题、面板背景色、CELL大小这类“魔法数值”,最好都提成常量放在类顶部。你可能会觉得小题大做,但等你把CELL从20改成28——想调一调游戏视觉密度——就会发现常量集中定义能省多少事。

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

5.1 键盘按了没反应,蛇纹丝不动

这个问题的出现频率极高,九成原因是焦点不在游戏面板上。JFrame创建后,焦点默认可能在JFrame的顶层组件上,而你把KeyListener挂在GamePanel上,键盘事件根本传不到面板。

解决办法:给GamePanel调用setFocusable(true),并在GameFrame显示后手动panel.requestFocus()。另外建议把KeyListener加在GamePanel而不是JFrame上,因为后者还要处理子组件抢焦点的问题。

如果用了KeyListener还是偶尔失灵,可以改用Swing的Key Bindings,也就是InputMap + ActionMap。这套机制由Swing管理焦点和按键映射,比KeyListener更可靠,不过代码量会多一点,适合有精力研究第二版再上。

5.2 蛇一瞬间反向然后自杀

出现这种Bug几乎可以肯定是没做反向检测。当前方向向右,玩家按下左键,新头坐标直接等于旧头坐标,然后蛇头撞上了紧随其后的第二节身体,游戏结束。

修复方法就是在actionPerformed里消费方向时检查isOpposite,反向输入直接丢弃。这个检查必须在“更新方向”这个环节做,而不是在按键事件里做,因为输入队列里的方向可能不是紧挨着当前方向的,需要等到真正消费时才能判断。

5.3 窗口尺寸怎么调都不对

很多人写了setSize(600, 600),结果发现游戏区域明显被压缩。原因是JFrame的setSize设置的是整个窗口的尺寸,包含标题栏和边框,实际内容区域会小于600像素。如果面板里的坐标计算按600算,绘制就可能被裁剪。

正确做法是:GamePanel里用setPreferredSize(new Dimension(600, 600))声明首选尺寸,GameFrame里用pack(),让窗口按面板的首选尺寸自动调整。这样内容区域就正好是你想要的600乘600。

5.4 中文乱码,分数显示成方块

中文乱码一般是编码问题。IDE环境下,检查项目编码是不是UTF-8;命令行运行的话,给JVM加上-Dfile.encoding=UTF-8参数。绘制中文时还要显式设置中文字体,前面已经说过用new Font("宋体", Font.BOLD, 16),这一步能规避大部分平台默认字体不支持中文的问题。

5.5 游戏重启后蛇乱动或立刻死亡

优先检查两件事:第一,initGame()里有没有清空inputQueue,残留方向键会导致开局乱拐;第二,actionPerformed方法入口有没有状态判断,如果游戏已经running = false了还继续执行move(),那蛇就会在重置后直接被上一帧的逻辑干死。

这里我强烈建议引入状态枚举而不是单纯一个布尔值。READY、RUNNING、PAUSED、OVER四种状态在方法入口做守卫,能拦截掉所有“不该执行却执行了”的情况。第一版用布尔省事可以理解,但在你决定加暂停功能的那一刻,就顺手把枚举换上去吧。

5.6 常见问题速查表

现象大概率原因解决办法
键盘没反应面板没有焦点setFocusable(true) + requestFocus()
蛇反向自杀缺少反向检测消费方向时用 isOpposite 过滤
窗口内容区变小JFrame setSize含边框用 setPreferredSize + pack()
中文乱码编码或字体问题UTF-8 + 显式设置中文字体
重启后乱动输入队列没清空initGame里彻底clear
CPU飙高Timer delay过小保持10-15帧/秒的合理节奏

6. 扩展方向与个人体会

6.1 三个能立刻上手的扩展方向

贪吃蛇跑通之后,千万别停在“能玩了”这个状态,接下来做增量迭代,收获最大。

第一个方向是难度曲线。设置一个计数器,每吃满5个食物就让Timer的delay减少10毫秒。这个修改就几行代码,可以直接体验“游戏越来越刺激”的感觉。注意delay有一个下限,比如40毫秒,否则后面快到手都跟不上。

第二个方向是玩法变体。加障碍墙、做穿墙模式(撞到一边从另一边出来)、甚至用AI控制第二条蛇来一场对战。穿墙模式只需要把边界判断改成坐标取模,障碍墙可以定义一张布尔表。每一个玩法变体背后都对应着一种逻辑变形,试着去改,比写新游戏更练人。

第三个方向是做“可保存的最高分”。用Properties文件把最高分存到本地,启动时读取并显示在界面上。这个扩展能让你第一次接触到文件IO与状态持久化,往后写任何需要存档的程序你都能从这里找到感觉。

6.2 再往下走,学什么

如果你吃这套“小游戏练手”的思路,下一步可以去了解游戏主循环里的固定时间步长、插值渲染、状态机这些概念。你会发现,Swing版贪吃蛇里的Timer就是主循环的雏形,running到PAUSED的切换就是状态机的雏形。带着这些经验去看引擎类东西,理解速度完全是另一个级别。

如果你的路线是走Java应用开发,做完这个小游戏之后建议补一下设计模式,尤其是观察者模式和状态模式,顺便把多线程和网络编程提上日程。很多人觉得设计模式抽象,那是因为没有经历过“代码被改乱”的痛。你现在有了贪吃蛇这个几百行的小项目,再去重构和套模式,会真正体会到设计模式解决的是什么问题。

6.3 最后聊几句实际感受

我做完这个项目的最大收获,不是学会了一门GUI库,而是建立了一种“程序从哪里开始”的直觉。无论我后来写服务端逻辑、写桌面工具,还是接手更复杂的系统,脑子里始终有一根主线:初始化状态、循环处理事件、更新数据、渲染反馈、退出清理。这个模式在小游戏里最直观,也最容易内化。

还想提醒一个很多人的通病:代码跑起来之后就满足了,不愿意再动。我强烈建议你跑通第一版之后,再花一个下午把它推倒重写一遍——把散落的方法收拢,把魔法数字换成常量,把输入处理抽成独立方法,把一个类里的逻辑按职责分清楚。这个过程才是真正长功力的时候。我一直觉得,写十个新项目都不如把一个项目重构一遍来得扎实。

最后的最后,如果你连一版能玩的贪吃蛇都还没有,今天就可以动手。结构就按这篇文章来,先用Timer跑通循环,再补队列输入,再写碰撞和食物,最后画界面。半天时间足够,你会看到原来Java那些枯燥的语法,组合起来就是活生生的游戏。

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

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

立即咨询