1. 为什么2048是练手Java GUI的绝佳靶子
2048这个游戏,规则简单到一句话就能说清:同数字合并,凑出2048。但真正动手写起来,你会发现它麻雀虽小五脏俱全——有状态管理、有输入响应、有界面重绘、有动画节奏,甚至还有胜负判定。很多新手学完Java语法之后,卡在"不知道拿什么练手"的阶段,做计算器太枯燥,做管理系统又太重,2048恰好卡在一个甜点位上:逻辑复杂度适中,视觉反馈即时,成就感来得快。
我见过不少朋友一上来就想写贪吃蛇或者俄罗斯方块,结果被碰撞检测和旋转矩阵劝退。2048不一样,它的核心数据结构就是一个4x4的二维数组,所有操作都围绕这个数组展开。你不需要处理浮点物理,不需要考虑像素级碰撞,只需要把"移动"和"合并"这两件事想明白,剩下的就是画界面的事了。
这篇内容适合谁看?如果你已经学过Java基础语法,知道什么是类、什么是数组、什么是继承,但还没怎么碰过Swing或者JavaFX,那这篇就是为你准备的。我会用最直白的方式,把2048从零到跑起来的每一步都拆开讲,包括我自己在写的时候踩过的坑。代码量控制在百行级别,不是因为我刻意压缩,而是这个游戏本身就不需要那么多代码——很多教程把它写复杂了。
关键词里提到了"Java""2048""小游戏""百行代码",这几个词其实指向一个共同的需求:用最少的代码量,做出一个能跑、能玩、能拿得出手的小项目。我写这篇的目的,就是让你看完之后能直接打开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,每个数字配一个背景色,这是视觉上的惯例。我用一个Map<Integer, Color>来存,查不到的颜色用默认值。字体大小也要根据数字位数调整——两位数用大号字,四位数用小一号,否则会溢出格子。
| 数字 | 背景色(RGB) | 字体大小 |
|---|---|---|
| 0 | 205, 193, 180 | 不绘制 |
| 2 | 238, 228, 218 | 36 |
| 4 | 237, 224, 200 | 36 |
| 8 | 242, 177, 121 | 36 |
| 16 | 245, 149, 99 | 32 |
| 32 | 246, 124, 95 | 32 |
| 64 | 246, 94, 59 | 32 |
| 128 | 237, 207, 114 | 28 |
| 256 | 237, 204, 97 | 28 |
| 512 | 237, 200, 80 | 28 |
| 1024 | 237, 197, 63 | 24 |
| 2048 | 237, 194, 46 | 24 |
注意:颜色值不要照搬网上的,自己调一下对比度。我一开始用的颜色在暗色屏幕上看起来还行,换到亮色屏幕就糊成一片,后来把背景色整体调深了一档才解决。
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%概率生成2,10%概率生成4。实现方式是先收集所有空格的索引到一个列表里,然后从列表里随机取一个。不要用"随机选一个格子,如果是空的就放,不是就重试"这种写法,棋盘快满的时候会陷入长时间循环。
private void addRandom() { List<Integer> 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]向左移动的过程,把每一步的中间状态写下来。你会发现算法其实就是"挤压→合并→再挤压"这三步,想清楚之后再写代码,基本不会出错。我后来写扫雷的展开逻辑时也用了同样的方法,先手推再编码,效率高很多。