资讯动态

Java Swing迷宫游戏开发实战:从二维数组到键盘控制与碰撞检测

发布时间:2026/10/18 4:09:47 来源:尧图企业网站定制
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 BindingsSwing里处理键盘输入有两种方式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 -1maze[-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 Listint[][] 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寻路都用它。再往后可以研究地图编辑器的实现做一个可视化工具来设计迷宫把数据保存成文件。这些都是很自然的进阶方向。我个人在实际操作中的体会是小游戏项目的价值不在于游戏本身有多好玩而在于它提供了一个完整的、可运行的、可修改的代码基座。你在这个基座上每加一个功能都会遇到新的问题解决这些问题的过程就是真正成长的过程。迷宫游戏的好处是它的核心足够简单让你能把注意力放在怎么把想法变成代码这件事上而不是被复杂的业务逻辑淹没。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑