1. 从一张方格纸到可玩的迷宫:这个项目的核心定位
很多人第一次接触Java图形界面编程,都是从"走迷宫"这个小游戏开始的。它不像贪吃蛇那样需要处理连续运动,也不像俄罗斯方块那样有复杂的碰撞旋转逻辑,迷宫游戏的核心规则简单到可以用一句话说清楚:从起点走到终点,别撞墙。但就是这么简单的规则,背后涉及的知识点却相当完整——二维数组、坐标系统、键盘事件监听、图形绘制、碰撞检测、路径搜索算法,几乎覆盖了Java SE阶段最核心的几块内容。
这个项目的目标很明确:用Java写一个能真正玩起来的小游戏,玩家用方向键控制角色在迷宫中移动,碰到墙壁走不过去,到达终点就算通关。它适合刚学完Java基础语法、想找一个完整项目练手的人,也适合想给孩子做一个简单编程启蒙demo的开发者。整个项目不需要任何第三方游戏引擎,纯Java标准库就能搞定,Swing或JavaFX都可以,我这次选择Swing,因为它的API更稳定,资料也更多,遇到问题容易查到解决方案。
为什么选迷宫而不是别的?因为迷宫天然适合用二维数组来表示。你可以把迷宫想象成一张方格纸,每个格子要么是墙,要么是路。用int[][] maze或者boolean[][]就能把整张地图存下来,0表示通路,1表示墙壁,2表示终点。这种数据结构直观到不需要任何解释,新手也能立刻理解。而且迷宫的地图可以手动设计,也可以随机生成,扩展性非常好。你做完基础版本之后,可以加计时、加步数统计、加多关卡、加自动寻路,甚至改成俯视角RPG的地图系统,成长路径非常清晰。
我在实际带人做这个项目的时候发现,大部分人卡住的地方不是"不会写代码",而是"不知道从哪里开始"。面对一个空白的IDE,脑子里有迷宫的样子,但不知道怎么把它变成代码。所以这篇文章我会按照真实的开发顺序来写:先想清楚数据怎么存,再决定界面怎么画,然后处理键盘输入,接着做碰撞检测,最后加上胜利判定和扩展功能。每一步我都会解释为什么这样做,以及我踩过哪些坑。
提示:如果你还没学过Swing,建议先花半小时了解一下JFrame、JPanel和paintComponent的基本用法,不需要深入,知道怎么在窗口里画一个矩形就够了。后面的内容都建立在这个基础上。
2. 迷宫的数据结构:二维数组怎么设计才不别扭
2.1 用int[][]还是boolean[][],这是个问题
迷宫的数据表示是整个项目的地基,地基打不好,后面写起来处处别扭。最常见的两种选择是int[][]和boolean[][]。boolean[][]看起来更省空间,true表示墙,false表示路,逻辑很干净。但实际用起来你会发现一个问题:终点怎么表示?用boolean的话,终点也是"路",你没法区分普通通路和终点。当然你可以额外用一个变量存终点坐标,但这样代码就分散了。
所以我更推荐用int[][],用不同的数字表示不同的格子类型。比如:
// 迷宫地图定义 // 0 = 通路 // 1 = 墙壁 // 2 = 终点 // 3 = 起点(可选,也可以单独用变量存) int[][] maze = { {1, 1, 1, 1, 1, 1, 1, 1, 1, 1}, {1, 0, 0, 0, 1, 0, 0, 0, 0, 1}, {1, 0, 1, 0, 1, 0, 1, 1, 0, 1}, {1, 0, 1, 0, 0, 0, 0, 1, 0, 1}, {1, 0, 1, 1, 1, 1, 0, 1, 0, 1}, {1, 0, 0, 0, 0, 0, 0, 1, 0, 1}, {1, 1, 1, 0, 1, 1, 1, 1, 0, 1}, {1, 0, 0, 0, 0, 0, 0, 0, 0, 1}, {1, 0, 1, 1, 1, 1, 1, 1, 2, 1}, {1, 1, 1, 1, 1, 1, 1, 1, 1, 1} };这样一张10x10的地图,用眼睛就能看出迷宫的形状。1围成外墙和内部墙壁,0是可以走的通道,2是终点。起点我习惯用单独的变量存,比如playerRow = 1, playerCol = 1,因为起点只有一个,没必要在地图数组里占一个特殊值。
为什么不用boolean[][]加额外变量?因为当你想扩展地图元素时,比如加一个"陷阱"格子、一个"传送门"格子,int[][]只需要加一个新的数字约定就行,而boolean[][]就得再加一堆变量,代码会越来越乱。这是我在多个小项目里反复验证过的经验:能用统一数据结构表达的状态,就不要拆成多个变量。
2.2 地图尺寸和格子大小的换算关系
确定了数据结构之后,下一个问题是:地图怎么画到屏幕上?这里涉及一个坐标换算的问题。假设每个格子画成40x40像素的正方形,那么一张10x10的地图就需要400x400像素的绘制区域。玩家角色也是一个格子大小,画在对应的位置上。
换算公式很简单:
- 格子
(row, col)的左上角像素坐标 =(col * CELL_SIZE, row * CELL_SIZE) - 格子中心像素坐标 =
(col * CELL_SIZE + CELL_SIZE / 2, row * CELL_SIZE + CELL_SIZE / 2)
这里有个新手特别容易搞混的地方:数组的索引是[row][col],但像素坐标是(x, y),x对应列,y对应行。我见过不止一个人把maze[row][col]写成maze[x][y],然后画出来的迷宫整个转置了,自己盯着看半天没发现问题。记住一个口诀:先行后列,先y后x。数组访问用[行][列],画图用(x, y),x是列方向,y是行方向。
另外,CELL_SIZE的选择也有讲究。太小了看不清,太大了窗口装不下。我一般用40像素,10x10的地图就是400x400的绘制区,加上窗口边框和可能的工具栏,整体窗口设成500x500左右比较合适。如果你要做20x20的大迷宫,CELL_SIZE可以降到25或30,保证窗口不超过屏幕高度。这个参数我建议定义成常量,方便调整:
public static final int CELL_SIZE = 40; // 每个格子的像素大小 public static final int ROWS = 10; // 迷宫行数 public static final int COLS = 10; // 迷宫列数2.3 起点和终点的位置怎么确定
起点和终点最好不要硬编码在代码里,而是从地图数组里读出来。终点的位置可以在初始化时扫描一遍数组,找到值为2的格子,把坐标存下来。起点可以固定为(1, 1),也可以在地图里用3标记,然后扫描出来。
这样做的好处是,当你换一张地图时,不需要改任何逻辑代码,只要改数组内容就行。我习惯写一个findPosition(int value)方法,返回第一个值为指定数字的格子坐标:
private int[] findPosition(int value) { for (int row = 0; row < ROWS; row++) { for (int col = 0; col < COLS; col++) { if (maze[row][col] == value) { return new int[]{row, col}; } } } return null; // 没找到 }这个方法虽然简单,但它体现了游戏开发中一个很重要的思想:数据驱动。地图数据决定游戏内容,代码只负责解释数据。你后面想加多关卡,只需要准备多个二维数组,切换数组就行,逻辑完全不用动。
3. 用Swing把迷宫画出来:从空白窗口到可视化地图
3.1 窗口和画布的基本搭建
Swing里画东西,核心是继承JPanel并重写paintComponent(Graphics g)方法。整个游戏窗口就是一个JFrame,里面放一个自定义的GamePanel。窗口的大小要根据地图尺寸算出来,不要写死。
public class MazeGame extends JFrame { public MazeGame() { setTitle("走迷宫"); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setResizable(false); GamePanel panel = new GamePanel(); add(panel); pack(); // 根据组件首选大小自动调整窗口 setLocationRelativeTo(null); // 窗口居中 setVisible(true); } public static void main(String[] args) { SwingUtilities.invokeLater(() -> new MazeGame()); } }这里有几个细节值得说。第一,setResizable(false)是必须的,因为我们的绘制逻辑是基于固定格子大小的,如果用户拉伸窗口,画面会错位。第二,pack()比setSize()更好,它会让窗口自动适应面板的首选大小,你只需要在GamePanel里重写getPreferredSize()返回正确的尺寸就行。第三,SwingUtilities.invokeLater()是Swing的标准启动方式,保证所有UI操作都在事件调度线程上执行,避免线程安全问题。虽然小游戏里不加通常也能跑,但养成好习惯没坏处。
3.2 paintComponent里的绘制顺序和技巧
paintComponent方法是整个游戏画面的绘制入口。每次窗口需要重绘时(比如移动后调用repaint()),Swing就会调用这个方法。绘制顺序很重要,后画的会盖住先画的。我的绘制顺序是:先画背景,再画墙壁和通路,然后画终点,最后画玩家。
@Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 必须调用,清除上一帧 // 1. 画迷宫格子 for (int row = 0; row < ROWS; row++) { for (int col = 0; col < COLS; col++) { int x = col * CELL_SIZE; int y = row * CELL_SIZE; if (maze[row][col] == 1) { g.setColor(new Color(60, 60, 60)); // 深灰色墙壁 g.fillRect(x, y, CELL_SIZE, CELL_SIZE); } else { g.setColor(new Color(240, 240, 240)); // 浅灰色通路 g.fillRect(x, y, CELL_SIZE, CELL_SIZE); } } } // 2. 画终点 g.setColor(new Color(50, 200, 100)); // 绿色终点 g.fillOval(endCol * CELL_SIZE + 5, endRow * CELL_SIZE + 5, CELL_SIZE - 10, CELL_SIZE - 10); // 3. 画玩家 g.setColor(new Color(220, 80, 80)); // 红色玩家 g.fillOval(playerCol * CELL_SIZE + 5, playerRow * CELL_SIZE + 5, CELL_SIZE - 10, CELL_SIZE - 10); }这里有个性能上的小细节:每次重绘都重新创建Color对象其实有点浪费,虽然在这个规模下完全感觉不到,但更好的做法是把颜色定义成常量或者成员变量,只创建一次。另外,fillOval的坐标要加一点偏移量(比如5像素),让圆形不要贴着格子边缘,看起来更舒服。
注意:
super.paintComponent(g)这一行绝对不能省。它的作用是清除之前的绘制内容,如果不调用,移动玩家时旧的位置会留下残影,整个画面会越来越花。
3.3 让画面动起来:repaint的调用时机
玩家移动之后,画面必须更新,否则你按了方向键但屏幕上什么都没变。更新的方式就是调用repaint()。这个方法会告诉Swing:"这个组件需要重画,请在合适的时候调用paintComponent。"注意它不会立即执行绘制,而是把重绘请求放进事件队列,由Swing统一调度。
所以移动逻辑和重绘逻辑是分开的:
private void movePlayer(int dRow, int dCol) { int newRow = playerRow + dRow; int newCol = playerCol + dCol; // 碰撞检测:如果新位置是墙壁,就不移动 if (maze[newRow][newCol] == 1) { return; } // 更新玩家位置 playerRow = newRow; playerCol = newCol; // 检查是否到达终点 if (maze[playerRow][playerCol] == 2) { JOptionPane.showMessageDialog(this, "恭喜你,到达终点了!"); } // 请求重绘 repaint(); }这个movePlayer方法是整个游戏的核心逻辑,它做了三件事:计算新位置、判断能不能走、更新状态并重绘。逻辑非常清晰,没有任何多余的步骤。我见过有人把碰撞检测写在paintComponent里,那是完全错误的做法——绘制方法只负责画,不负责改状态。
4. 键盘控制与碰撞检测:让角色真正"走"起来
4.1 KeyListener还是Key Bindings
Swing里处理键盘输入有两种方式:KeyListener和Key Bindings。新手教程大多教KeyListener,因为它直观——实现三个方法keyPressed、keyReleased、keyTyped,然后在keyPressed里判断按键码就行。但KeyListener有个经典的坑:它要求组件必须获得焦点才能收到键盘事件。如果你点了窗口里的其他组件,或者窗口失去焦点再回来,键盘就可能失灵。
我推荐用Key Bindings,也就是InputMap和ActionMap的组合。它不依赖焦点,而且可以把按键和动作解耦,代码更清晰:
private void setupKeyBindings() { InputMap inputMap = getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW); ActionMap actionMap = getActionMap(); // 上 inputMap.put(KeyStroke.getKeyStroke("UP"), "moveUp"); actionMap.put("moveUp", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { movePlayer(-1, 0); } }); // 下 inputMap.put(KeyStroke.getKeyStroke("DOWN"), "moveDown"); actionMap.put("moveDown", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { movePlayer(1, 0); } }); // 左 inputMap.put(KeyStroke.getKeyStroke("LEFT"), "moveLeft"); actionMap.put("moveLeft", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { movePlayer(0, -1); } }); // 右 inputMap.put(KeyStroke.getKeyStroke("RIGHT"), "moveRight"); actionMap.put("moveRight", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { movePlayer(0, 1); } }); }WHEN_IN_FOCUSED_WINDOW这个条件意味着只要窗口是激活的,不管焦点在哪个组件上,按键都能被捕获。这比KeyListener可靠得多。虽然代码量看起来多了一点,但省去了后面调试焦点问题的麻烦,非常值得。
4.2 碰撞检测的边界问题
碰撞检测看起来简单——判断目标格子是不是1就行了。但有一个边界情况必须处理:如果玩家在迷宫边缘,往外面走,数组会越界。比如玩家在第0行,按上键,newRow = -1,maze[-1][col]直接抛ArrayIndexOutOfBoundsException。
解决办法有两种。第一种是在地图外围加一圈墙,这样玩家永远走不到边界外面。我上面的地图示例就是这么做的,第0行、第0列、最后一行、最后一列全是1。这是最简单也最推荐的做法,从数据结构层面杜绝了越界问题。
第二种是在碰撞检测里加边界判断:
private boolean canMove(int row, int col) { if (row < 0 || row >= ROWS || col < 0 || col >= COLS) { return false; // 越界,不能走 } return maze[row][col] != 1; // 不是墙就能走 }两种方法可以同时用,双保险。但即使地图有外墙,我也建议保留边界判断,因为万一你后面改了地图,忘了加外墙,边界判断能兜底。这是防御性编程的习惯,在小项目里养成,到大项目里能省很多事。
4.3 连续移动和按键重复的取舍
用Key Bindings的时候,按住方向键不放,操作系统会发送重复的按键事件,角色会连续移动。这个行为在迷宫游戏里是合理的,玩家按住右键就会一直往右走,直到撞墙或者松开。但重复速率取决于操作系统的键盘设置,不同电脑可能不一样。
如果你想要更精确的控制,比如每次按键只移动一格,不管按多久,那就在actionPerformed里做一次移动就行,因为Key Bindings默认不处理按键重复——每次按键按下只触发一次动作,重复事件需要额外配置。实际上,KeyStroke.getKeyStroke("UP")默认对应的是KEY_PRESSED事件,按住不放会触发多次,所以角色会连续移动。如果你不想要这个效果,可以用KeyStroke.getKeyStroke("pressed UP")并配合setAutoRepeat(false),但这样代码会复杂一些。
我的建议是:保留连续移动。迷宫游戏里玩家经常需要长距离移动,一格一格按太累了。连续移动的体验更好,而且实现更简单。
5. 从能玩到好玩:胜利判定、计时与关卡扩展
5.1 胜利判定的时机和反馈
胜利判定很简单:玩家移动之后,检查当前格子是不是终点(值为2)。但反馈方式决定了游戏的"手感"。最简单的反馈是弹一个对话框,但对话框会阻塞游戏,玩家点了确定之后才能继续。更好的做法是在画面上显示一个胜利提示,或者让玩家角色闪烁几下。
我一般用组合反馈:先在控制台打印一条消息(开发阶段方便调试),然后在画面上绘制一个半透明的胜利遮罩,最后弹一个对话框让玩家选择"再玩一次"或"退出"。这样既有即时视觉反馈,又有明确的交互出口。
private void checkWin() { if (maze[playerRow][playerCol] == 2) { gameWon = true; repaint(); // 触发胜利画面的绘制 int choice = JOptionPane.showConfirmDialog(this, "你用了 " + stepCount + " 步,用时 " + elapsedSeconds + " 秒。\n再玩一次?", "恭喜通关", JOptionPane.YES_NO_OPTION); if (choice == JOptionPane.YES_OPTION) { resetGame(); } else { System.exit(0); } } }注意gameWon这个标志位。一旦胜利,就应该停止响应移动操作,否则玩家还能继续走,逻辑就乱了。在movePlayer开头加一个判断:if (gameWon) return;。
5.2 计时器和步数统计的实现
计时和步数统计是提升游戏可玩性的最低成本方式。步数统计最简单,每次成功移动就stepCount++。计时稍微麻烦一点,需要用javax.swing.Timer或者单独开一个线程。
用Swing Timer更安全,因为它的回调在事件调度线程上执行,不会和绘制线程冲突:
private Timer gameTimer; private int elapsedSeconds = 0; private void startTimer() { elapsedSeconds = 0; gameTimer = new Timer(1000, e -> { elapsedSeconds++; repaint(); // 每秒刷新一次,更新时间显示 }); gameTimer.start(); }然后在paintComponent里把时间和步数画到窗口顶部或者底部。记得留出足够的边距,不要把文字画到迷宫格子上。我一般会在迷宫上方留30像素的空白区域专门用来显示状态信息。
提示:
Timer的延迟参数是毫秒,1000毫秒就是1秒。如果你想要更精确的计时,可以用System.currentTimeMillis()记录开始时间,每次重绘时计算差值。但小游戏用秒级精度足够了。
5.3 多关卡设计和地图数据的组织
单张地图玩几次就腻了,加多关卡是自然的扩展方向。最直接的做法是把多个地图数组放在一个列表里:
private List<int[][]> levels = new ArrayList<>(); private int currentLevel = 0; private void loadLevel(int index) { maze = levels.get(index); // 重新扫描起点和终点 int[] start = findPosition(3); int[] end = findPosition(2); playerRow = start[0]; playerCol = start[1]; endRow = end[0]; endCol = end[1]; stepCount = 0; elapsedSeconds = 0; gameWon = false; repaint(); }通关后自动加载下一关,如果已经是最后一关,就显示"全部通关"。这样整个游戏就有了进度感,玩家有动力继续玩下去。
地图数据可以硬编码在代码里,也可以从文本文件读取。硬编码简单直接,适合关卡少的情况。如果关卡很多,建议用文本文件,每行一个字符串,#表示墙,.表示路,S表示起点,E表示终点,读取时转换成二维数组。这样设计地图更方便,用文本编辑器就能画迷宫。
6. 那些只有写过才知道的坑:实战经验与优化建议
6.1 画面闪烁和双缓冲
早期用AWT画图的时候,直接画在组件上会出现闪烁——因为Swing先清空背景再画内容,中间有一个短暂的空窗期。Swing默认开启了双缓冲,所以一般不会闪。但如果你在paintComponent里做了复杂的绘制,或者手动调用了super.paintComponent之外的重绘逻辑,可能会破坏双缓冲。
我的经验是:不要在paintComponent里做任何耗时操作。所有计算都在移动逻辑里完成,paintComponent只负责根据当前状态画图。如果你发现画面闪烁,先检查是不是在绘制方法里创建了大量对象或者做了循环计算。
6.2 数组越界的三种常见场景
数组越界是迷宫游戏里最常见的运行时异常,我总结了三类高发场景:
| 场景 | 原因 | 解决方案 |
|---|---|---|
| 玩家在边缘往外走 | 没有边界判断 | 地图加外墙 + canMove边界检查 |
| 终点扫描时数组为空 | 地图里没有标记终点 | 初始化时检查findPosition返回值 |
| 关卡切换时索引错误 | 关卡列表为空或索引越界 | 加载前检查levels.size() |
第一类最常见,第二类最隐蔽——如果地图里忘了标终点,findPosition(2)返回null,后面用endRow和endCol时直接空指针。所以初始化时一定要做防御性检查,发现地图数据有问题就打印明确的错误信息,而不是让程序崩溃。
6.3 让代码更好维护的几个小重构
项目能跑起来之后,建议做几个小重构,让代码更清晰:
第一,把地图数据、格子大小、颜色常量都提取到单独的类或者常量区,不要散落在各个方法里。第二,把移动逻辑、绘制逻辑、输入处理逻辑分到不同的方法里,每个方法只做一件事。第三,给关键方法加上注释,说明参数含义和返回值,特别是movePlayer这种核心方法。
这些重构不会改变功能,但会让代码读起来舒服很多。而且当你后面想加新功能时,清晰的代码结构能让你少改很多地方。我在实际项目中反复验证过:花十分钟重构,能省一小时调试。
6.4 从迷宫游戏延伸出去的学习路径
做完这个迷宫游戏,你已经掌握了Java图形编程的基本套路:窗口创建、画布绘制、键盘输入、状态管理、碰撞检测。这套东西可以直接迁移到很多其他小游戏上。比如贪吃蛇,就是把玩家换成蛇的身体列表,把终点换成食物;推箱子,就是加一个可以推动的箱子对象;扫雷,就是把迷宫换成网格按钮。
如果你想继续深入,下一步可以研究A*寻路算法,给迷宫加一个自动寻路的功能,让电脑帮你找到从起点到终点的最短路径。这个算法在游戏开发里非常实用,很多RPG的NPC寻路都用它。再往后可以研究地图编辑器的实现,做一个可视化工具来设计迷宫,把数据保存成文件。这些都是很自然的进阶方向。
我个人在实际操作中的体会是,小游戏项目的价值不在于游戏本身有多好玩,而在于它提供了一个完整的、可运行的、可修改的代码基座。你在这个基座上每加一个功能,都会遇到新的问题,解决这些问题的过程就是真正成长的过程。迷宫游戏的好处是它的核心足够简单,让你能把注意力放在"怎么把想法变成代码"这件事上,而不是被复杂的业务逻辑淹没。