☰
Java GUI实战:百行代码实现2048小游戏
2026/10/12 21:17:07 网站建设 项目流程

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)字体大小
0205, 193, 180不绘制
2238, 228, 21836
4237, 224, 20036
8242, 177, 12136
16245, 149, 9932
32246, 124, 9532
64246, 94, 5932
128237, 207, 11428
256237, 204, 9728
512237, 200, 8028
1024237, 197, 6324
2048237, 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%概率生成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]向左移动的过程,把每一步的中间状态写下来。你会发现算法其实就是"挤压→合并→再挤压"这三步,想清楚之后再写代码,基本不会出错。我后来写扫雷的展开逻辑时也用了同样的方法,先手推再编码,效率高很多。

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

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

立即咨询