首页 / 资讯中心 / 文章详情

Java GUI实战:百行代码实现2048小游戏

Java GUI实战:百行代码实现2048小游戏 ★ FEATURED ARTICLE
1. 为什么2048是练手Java GUI的绝佳靶子2048这个游戏规则简单到一句话就能说清同数字合并凑出2048。但真正动手写起来你会发现它麻雀虽小五脏俱全——有状态管理、有输入响应、有界面重绘、有动画节奏甚至还有胜负判定。很多新手学完Java语法之后卡在不知道拿什么练手的阶段做计算器太枯燥做管理系统又太重2048恰好卡在一个甜点位上逻辑复杂度适中视觉反馈即时成就感来得快。我见过不少朋友一上来就想写贪吃蛇或者俄罗斯方块结果被碰撞检测和旋转矩阵劝退。2048不一样它的核心数据结构就是一个4x4的二维数组所有操作都围绕这个数组展开。你不需要处理浮点物理不需要考虑像素级碰撞只需要把移动和合并这两件事想明白剩下的就是画界面的事了。这篇内容适合谁看如果你已经学过Java基础语法知道什么是类、什么是数组、什么是继承但还没怎么碰过Swing或者JavaFX那这篇就是为你准备的。我会用最直白的方式把2048从零到跑起来的每一步都拆开讲包括我自己在写的时候踩过的坑。代码量控制在百行级别不是因为我刻意压缩而是这个游戏本身就不需要那么多代码——很多教程把它写复杂了。关键词里提到了Java2048小游戏百行代码这几个词其实指向一个共同的需求用最少的代码量做出一个能跑、能玩、能拿得出手的小项目。我写这篇的目的就是让你看完之后能直接打开IDE半小时内看到自己的2048跑起来而不是被各种设计模式绕晕。2. 核心数据结构与游戏状态的设计取舍2.1 为什么用一维数组而不是二维数组大多数人想到2048的棋盘第一反应是int[4][4]。我一开始也是这么写的但写到移动逻辑的时候发现四个方向的移动要写四套不同的循环代码重复度很高。后来我换了个思路用一维数组int[16]来存棋盘索引i对应行i/4、列i%4。这样做的好处是我只需要写一套向左移动的逻辑其他三个方向通过坐标变换来复用。具体怎么变换向右移动就是把每一行反转后向左移动再反转回来。向上移动是把列当行处理向下移动同理。这样四个方向的代码可以共用同一个核心方法代码量直接砍掉四分之三。这个技巧在写其他网格类游戏时同样适用比如扫雷、拼图都可以用一维数组加坐标映射来简化逻辑。// 棋盘用一维数组表示索引 i 对应 row i/4, col i%4 private int[] board new int[16]; private static final int SIZE 4;2.2 空格用0表示还是用null棋盘上的空格我用0来表示。为什么不用null因为int数组默认就是0省去了初始化赋值的步骤。而且在合并判断的时候0可以直接参与数值比较不需要额外的空值检查。如果你用Integer[]虽然能存null但每次取值都要拆箱还容易触发空指针得不偿失。这里有个细节要注意游戏结束时棋盘是满的但满的棋盘不一定死局。判断死局的条件是——没有空格且相邻格子没有相同数字。这两个条件必须同时满足才能判负。我见过有人只判断了没有空格就宣布游戏结束结果玩家明明还能合并却被迫重开体验很差。2.3 分数与最高分的存储策略分数我直接用int存每次合并时把合并后的数值累加到分数上。最高分我一开始想存文件后来觉得没必要——百行代码的项目引入IO操作会让代码量膨胀。我的做法是用static变量在内存里记着程序关了最高分就没了。如果你确实想持久化可以用Preferences类三行代码就能搞定比写文件简单得多。private int score 0; private static int bestScore 0; // 内存级最高分重启后重置提示如果你想让最高分跨会话保留用java.util.prefs.Preferences.userRoot().node(game2048)来存取不需要处理文件路径和异常适合小项目。3. 移动与合并逻辑的完整拆解3.1 单行向左移动的算法细节这是整个游戏最核心的方法。给定一行四个格子比如[2, 0, 2, 4]向左移动后应该变成[4, 4, 0, 0]同时分数加4。算法分两步第一步把所有非零元素挤到左边变成[2, 2, 4, 0]第二步从左到右扫描如果相邻两个相等就合并变成[4, 4, 0, 0]。注意合并的时候有个容易出bug的地方合并后的格子不能再参与下一次合并。比如[2, 2, 2, 2]正确结果是[4, 4, 0, 0]而不是[8, 0, 0, 0]。我的做法是用一个boolean[] merged标记哪些位置已经合并过了合并过的位置跳过。private int[] slideLeft(int[] line) { int[] result new int[4]; int pos 0; // 第一步挤压非零元素 for (int i 0; i 4; i) { if (line[i] ! 0) result[pos] line[i]; } // 第二步合并相邻相同元素 for (int i 0; i 3; i) { if (result[i] ! 0 result[i] result[i 1]) { result[i] * 2; score result[i]; result[i 1] 0; i; // 跳过下一个防止连续合并 } } // 第三步再次挤压把合并产生的空格填上 int[] finalResult new int[4]; pos 0; for (int i 0; i 4; i) { if (result[i] ! 0) finalResult[pos] result[i]; } return finalResult; }3.2 四个方向如何复用同一套逻辑有了slideLeft之后其他三个方向不需要重写。向右移动把行反转调用slideLeft再反转回来。向上移动把第col列取出来当成一行调用slideLeft再写回列。向下移动列反转后向上移动再反转回来。这里的关键是写两个辅助方法getLine(int index, int direction)和setLine(int index, int direction, int[] line)。index表示第几行或第几列direction表示方向。这样主移动方法只需要一个循环代码非常干净。private int[] getLine(int index, int dir) { int[] line new int[4]; for (int i 0; i 4; i) { switch (dir) { case 0: line[i] board[index * 4 i]; break; // 左 case 1: line[i] board[index * 4 (3 - i)]; break; // 右 case 2: line[i] board[i * 4 index]; break; // 上 case 3: line[i] board[(3 - i) * 4 index]; break; // 下 } } return line; }3.3 判断移动是否有效的必要性每次玩家按键你不能无脑执行移动然后重绘。如果棋盘没有任何变化不应该生成新数字也不应该扣分或做任何事。我的做法是在移动前把当前棋盘复制一份移动后比较是否相同。如果相同说明这个方向走不通直接返回不生成新块。这个判断还有一个作用防止玩家通过反复按同一个方向键来刷新数字。如果没有这个检查玩家按住左键不放每次都会生成新块游戏很快就满了。加上有效性判断之后只有真正发生移动时才生成新块游戏节奏才正常。private boolean move(int dir) { int[] snapshot board.clone(); for (int i 0; i 4; i) { int[] line getLine(i, dir); int[] moved slideLeft(line); setLine(i, dir, moved); } return !Arrays.equals(snapshot, board); }4. 界面绘制与键盘响应的实现路径4.1 用JPanel自定义绘制还是用JLabel网格Swing里做2048的界面有两种常见方案一种是用GridLayout放16个JLabel每个标签显示一个数字另一种是继承JPanel重写paintComponent方法自己画。我两种都试过最后选了自定义绘制。为什么因为JLabel方案在数字变化时会有闪烁而且每个格子的背景色、字体大小、圆角都要单独设置代码反而更多。自定义绘制虽然要自己算坐标但所有格子用同一个循环画出来颜色和字体统一管理改起来方便。而且绘制的时候可以用Graphics2D开启抗锯齿数字看起来更舒服。Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); for (int i 0; i 16; i) { int row i / 4, col i % 4; int x col * (CELL_SIZE GAP) GAP; int y row * (CELL_SIZE GAP) GAP; drawCell(g2, x, y, board[i]); } }4.2 数字颜色与背景色的映射表2048的数字从2到2048每个数字配一个背景色这是视觉上的惯例。我用一个MapInteger, Color来存查不到的颜色用默认值。字体大小也要根据数字位数调整——两位数用大号字四位数用小一号否则会溢出格子。数字背景色RGB字体大小0205, 193, 180不绘制2238, 228, 218364237, 224, 200368242, 177, 1213616245, 149, 993232246, 124, 953264246, 94, 5932128237, 207, 11428256237, 204, 9728512237, 200, 80281024237, 197, 63242048237, 194, 4624注意颜色值不要照搬网上的自己调一下对比度。我一开始用的颜色在暗色屏幕上看起来还行换到亮色屏幕就糊成一片后来把背景色整体调深了一档才解决。4.3 键盘监听的绑定方式键盘响应我用KeyAdapter绑定在JFrame上。方向键对应四个方向R键重开。这里有个坑JFrame默认不接收键盘事件必须把KeyListener加到有焦点的组件上或者用KeyBindings。我一开始用addKeyListener加到JFrame上按了半天没反应后来改成加到JPanel上并调用setFocusable(true)才生效。更稳妥的做法是用InputMap和ActionMap这是Swing推荐的键绑定方式不依赖焦点。但代码量会多一些百行代码的项目里我用KeyAdapter加requestFocusInWindow()就够了。panel.setFocusable(true); panel.addKeyListener(new KeyAdapter() { Override public void keyPressed(KeyEvent e) { switch (e.getKeyCode()) { case KeyEvent.VK_LEFT: if (move(0)) addRandom(); break; case KeyEvent.VK_RIGHT: if (move(1)) addRandom(); break; case KeyEvent.VK_UP: if (move(2)) addRandom(); break; case KeyEvent.VK_DOWN: if (move(3)) addRandom(); break; case KeyEvent.VK_R: restart(); break; } repaint(); } });4.4 重绘时机与动画的取舍每次移动后调用repaint()Swing会自动调度重绘。我没有加滑动动画因为动画需要定时器插值代码量至少翻倍。如果你想要动画效果可以用javax.swing.Timer每帧改变格子的偏移量但那就不是百行代码的范畴了。实测下来没有动画的2048玩起来反而更跟手按键下去立刻看到结果没有延迟感。很多手机上的2048动画太长连续快速操作时会卡顿纯静态刷新在桌面端体验很好。5. 随机数生成与游戏结束判定的边界情况5.1 新数字生成的位置选择每次有效移动后要在空格里随机选一个位置生成新数字。90%概率生成210%概率生成4。实现方式是先收集所有空格的索引到一个列表里然后从列表里随机取一个。不要用随机选一个格子如果是空的就放不是就重试这种写法棋盘快满的时候会陷入长时间循环。private void addRandom() { ListInteger empty new ArrayList(); for (int i 0; i 16; i) { if (board[i] 0) empty.add(i); } if (empty.isEmpty()) return; int pos empty.get((int) (Math.random() * empty.size())); board[pos] Math.random() 0.9 ? 2 : 4; }5.2 死局判定的完整条件前面提过死局的条件是没有空格且没有相邻相同数字。相邻包括水平和垂直两个方向。我的判定方法是先检查有没有0有0就直接返回false。然后遍历每个格子检查右边和下边的邻居是否和自己相等只要有一对相等就返回false。全部检查完还没有相等才返回true。这个判定要放在每次生成新数字之后执行。如果判定为死局弹出提示或者显示Game Over文字并停止响应方向键。private boolean isGameOver() { for (int i 0; i 16; i) { if (board[i] 0) return false; } for (int i 0; i 16; i) { int row i / 4, col i % 4; if (col 3 board[i] board[i 1]) return false; if (row 3 board[i] board[i 4]) return false; } return true; }5.3 达到2048之后继续还是停止原版2048在凑出2048之后会显示胜利但允许继续玩。我的做法是第一次出现2048时弹一个提示框告诉玩家赢了然后继续游戏。如果玩家继续玩到死局再显示游戏结束。这样既给了成就感又不强制中断。这里有个细节判断是否第一次出现2048需要一个标志位否则每次合并出2048都会弹窗。我用一个boolean won来记录初始为false出现2048后设为true后续不再弹窗。6. 百行代码背后的取舍与实测心得6.1 哪些功能被砍掉了以及为什么百行代码意味着必须做减法。我砍掉的功能包括滑动动画、撤销操作、分数持久化、音效、触摸屏支持。这些功能每一个单独拿出来都不难但加在一起代码量至少翻三倍。对于练手项目来说核心体验是能玩而不是功能齐全。先把核心跑通后续想加什么再加这才是正确的迭代顺序。另一个被砍掉的是棋盘大小可配置。原版是4x4我写死了4。如果要支持5x5所有硬编码的4都要改成变量代码会稍微复杂一点。但说实话2048的精髓就在4x4改成5x5反而不好玩。6.2 实测中遇到的三个坑第一个坑是键盘焦点。前面提过JFrame不直接接收键盘事件必须把监听器加到有焦点的面板上。我在这上面浪费了二十分钟一度以为代码写错了。第二个坑是合并顺序。[2, 2, 2, 2]向左移动我一开始写出来的结果是[8, 0, 0, 0]因为合并后的4又和下一个2合并了。修复方法是在合并后跳过下一个位置用i或者标记数组。第三个坑是重绘闪烁。用JLabel方案时每次更新文字都会触发重绘快速按键时画面会闪。换成自定义paintComponent之后问题消失。如果你坚持用JLabel可以试试双缓冲但自定义绘制本身就是双缓冲的省事。6.3 代码之外的可扩展方向跑通之后如果你想继续折腾有几个方向可以试试。一是加动画用Timer做滑动插值体验会提升一个档次。二是加AI自动玩用简单的贪心策略——优先保持棋盘有序避免大数字被小数字包围。这个策略不需要机器学习几十行代码就能实现而且效果出奇地好。三是改成手机端用JavaFX或者LibGDX但那就是另一个项目了。我个人在实际操作中的体会是2048这个项目最大的价值不在于游戏本身而在于它让你完整地走了一遍数据结构设计→核心算法→界面绘制→输入响应→边界处理的流程。这个流程和做正经软件项目的思路是一样的只是规模缩小了。把这个小项目吃透再去做更大的东西心里会有底得多。最后分享一个小技巧写移动逻辑的时候先在纸上画一个4x4的格子手动模拟一遍[2, 0, 2, 4]向左移动的过程把每一步的中间状态写下来。你会发现算法其实就是挤压→合并→再挤压这三步想清楚之后再写代码基本不会出错。我后来写扫雷的展开逻辑时也用了同样的方法先手推再编码效率高很多。
阅读完成 · 觉得有帮助?
咨询建站