ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java 实现 2048 小游戏:从零设计到完整代码实战

Java 实现 2048 小游戏:从零设计到完整代码实战

2048 这个游戏,我最早是在手机上玩的,后来想着自己用 Java 撸一个桌面版出来,前后断断续续折腾了差不多一周。别看它规则简单——上下左右滑动,相同数字合并,凑出 2048 就算赢——真动手写起来,里面涉及的状态管理、合并逻辑、随机数生成、界面刷新,每一块都有不少门道。我见过不少新手朋友一上来就写界面,结果逻辑和 UI 搅在一起,改一个 bug 牵出一串问题。所以这篇内容我打算从零开始,把整个项目的设计思路、核心算法、实操步骤和踩过的坑都摊开讲一遍。

这篇适合谁看?如果你已经学过 Java 基础语法,知道类和对象是怎么回事,想找一个不大不小、能完整跑通的项目练手,那 2048 非常合适。它不像电商系统那样庞大,也不像 Hello World 那样没营养,刚好能让你把面向对象、集合操作、事件监听、Swing 绘图这些知识点串起来用一遍。全文我会围绕Java 实现 2048 小游戏这个核心,把棋盘数据结构怎么选、合并算法怎么写、界面怎么刷新、键盘事件怎么绑定这些关键环节讲透,代码可以直接抄去跑。

1. 项目整体设计与思路拆解

动手写代码之前,先把整体架构想清楚,比上来就敲键盘强十倍。2048 这个游戏看着简单,但它本质上是一个状态机:棋盘上每个格子的数字是一个状态,玩家每次滑动操作触发一次状态转移,转移的规则就是“移动 + 合并 + 补新数字”。想明白这一点,代码结构自然就出来了。

1.1 为什么选择“逻辑与界面分离”的架构

我一开始也犯过懒,想着直接在 JFrame 的 paint 方法里把合并逻辑也写了,结果就是每次改合并规则都得动绘图代码,调试的时候连个断点都不好打。后来老老实实拆成两层:游戏核心逻辑层和界面展示层。逻辑层只管数据,界面层只管画,两者通过一个棋盘对象通信。

这么拆的好处很实在。第一,逻辑层可以单独写单元测试,我不用启动窗口就能验证“向左滑动后棋盘变成什么样”,调试效率高出一大截。第二,界面想换风格——比如从 Swing 换成 JavaFX,甚至做成控制台版本——逻辑层一行都不用改。第三,代码可读性好,别人拿到你的项目,一眼能看出哪块是规则、哪块是皮肤。

具体到类设计上,我用了三个核心类:GameBoard负责棋盘数据和合并规则,GamePanel继承 JPanel 负责绘制,GameFrame继承 JFrame 负责窗口和键盘事件绑定。另外还有一个Tile类表示单个格子,虽然用二维 int 数组也能存数字,但用对象能顺便记录“这个格子是不是刚合并出来的”,方便后面做动画或者变色。

1.2 棋盘数据结构:二维数组还是对象集合

这是第一个要做的技术选型。最直接的想法是用int[][] board = new int[4][4],简单粗暴,访问快。我最初就是这么干的,但很快就遇到一个问题:我想让新合并出来的格子有个高亮效果,或者记录每个格子的“年龄”来决定颜色深浅,纯 int 数组就存不下了。

后来我改成Tile[][],每个 Tile 对象里放value(数值)和isNew(是否新生成)两个字段。这样绘制的时候可以根据isNew决定要不要加个淡入效果,也可以根据 value 大小映射不同背景色。代价是内存占用稍微大一点,但 4x4 的棋盘才 16 个对象,这点开销完全可以忽略。

提示:如果你只是想快速跑通逻辑,int 二维数组足够了;但如果想让界面有点质感,建议一开始就用对象封装,后期扩展省心。

至于棋盘尺寸,标准 2048 是 4x4,但我在代码里把它抽成了一个常量SIZE = 4,这样想改成 5x5 或者 3x3 只需要改一个数字。不过要注意,胜利条件“凑出 2048”也得跟着调整,一般规则是凑出2^(SIZE+7)左右,4x4 对应 2048,5x5 对应 8192,这个后面细说。

1.3 核心状态流转:一次滑动到底发生了什么

理解一次滑动的完整流程,是写好这个游戏的关键。玩家按下方向键后,程序其实要做四件事,顺序不能乱:

  1. 压缩:把一行里所有非零数字往滑动方向靠拢,去掉中间的空格。
  2. 合并:相邻且相等的两个数字合并成一个,值翻倍。
  3. 再压缩:合并后可能又出现空格,需要再靠拢一次。
  4. 补新数:在随机空位生成一个 2 或 4。

这四步里,第 2 步和第 3 步的顺序特别容易搞错。我见过有人先合并再压缩,结果出现[2,2,4]向左滑变成[4,4]而不是正确的[4,4](其实这里应该是[4,4]没错,但如果是[2,2,2]就会出问题)。正确的处理是:先压缩成[2,2,2],然后从左往右扫描合并,第一个 2 和第二个 2 合并成 4,第三个 2 保留,得到[4,2],再压缩(这里已经没空格了)。关键在于每个格子一次滑动只能参与一次合并,[2,2,2,2]向左应该是[4,4]而不是[8],这个规则必须严格遵守。

2. 核心算法细节与实操要点

逻辑层的核心就两个算法:单行合并和方向映射。把这两个吃透,整个游戏就成了一大半。我下面把代码和思路一起给出来,你可以直接对照着写。

2.1 单行合并算法:把一维数组玩明白

整个 2048 的合并逻辑,本质上是对“一行”做处理,四个方向只是把二维棋盘按不同方式拆成一行行而已。所以先把一维数组的合并写对,二维就是套个循环的事。

我设计的mergeLine方法接收一个 int 数组,返回合并后的数组和一个得分增量。思路是:先把非零元素挤到前面,然后从左到右扫描,遇到相邻相等的就合并。

// 将一行向左压缩并合并,返回合并后的行和得分 public static int[] mergeLine(int[] line, int[] scoreOut) { int[] temp = new int[line.length]; int idx = 0; // 第一步:压缩,去掉中间的零 for (int v : line) { if (v != 0) temp[idx++] = v; } // 第二步:合并相邻相等元素 for (int i = 0; i < temp.length - 1; i++) { if (temp[i] != 0 && temp[i] == temp[i + 1]) { temp[i] *= 2; temp[i + 1] = 0; scoreOut[0] += temp[i]; // 累加得分 i++; // 跳过下一个,保证一次滑动只合并一次 } } // 第三步:再次压缩,把合并产生的零挤掉 int[] result = new int[line.length]; idx = 0; for (int v : temp) { if (v != 0) result[idx++] = v; } return result; }

这段代码有几个细节值得说道。第一,i++那行是精髓,合并完一对之后要跳过下一个位置,否则[2,2,2,2]会被错误地合并成[8]。第二,得分累加放在合并发生时,合并出的新值就是得分,这是 2048 的标准计分规则。第三,我用了临时数组 temp 中转,虽然多一次遍历,但逻辑清晰,比原地操作不容易出错。

注意:scoreOut用数组传参是为了在 Java 里模拟“引用传递”,因为基本类型 int 传不进方法里改。你也可以把得分做成类的成员变量,看个人习惯。

2.2 四个方向的统一处理:旋转棋盘省事又不易错

有了单行向左合并的算法,四个方向怎么办?最笨的办法是写四套逻辑,分别处理上、下、左、右,代码重复不说,还容易某个方向写错。我采用的是旋转棋盘的思路:不管玩家往哪个方向滑,我都把棋盘旋转成“向左滑”的样子,处理完再转回去。

具体来说,向左滑直接处理每一行;向右滑就把每一行反转再处理;向上滑把棋盘转置(行列互换)后按行处理;向下滑转置后再反转。这样核心算法只有一份,四个方向只是预处理和后处理的差别。

// 以向上滑动为例:转置棋盘 -> 按行向左合并 -> 转置回来 public boolean moveUp() { int[][] transposed = transpose(board); boolean moved = false; for (int i = 0; i < SIZE; i++) { int[] line = transposed[i]; int[] merged = mergeLine(line, scoreOut); if (!Arrays.equals(line, merged)) moved = true; transposed[i] = merged; } board = transpose(transposed); return moved; }

moved这个返回值很重要,它告诉上层“这次滑动到底有没有改变棋盘”。如果玩家往左滑但所有数字已经靠左了,棋盘没变化,那就不应该生成新数字,否则玩家可以靠反复无效滑动刷数字。这个判断我一开始漏了,测试的时候发现一直按左键数字疯长,才意识到问题。

2.3 随机生成新数字:2 和 4 的比例有讲究

每次有效滑动后,要在随机空位生成一个新数字。标准规则是 90% 概率生成 2,10% 概率生成 4。我见过有人写成 50/50,结果游戏难度骤降,几下就凑出大数字,失去了挑战性。

实现上分两步:先收集所有空位的坐标到一个 List 里,然后随机选一个位置,再随机决定数值。

public void addRandomTile() { List<int[]> empty = new ArrayList<>(); for (int r = 0; r < SIZE; r++) for (int c = 0; c < SIZE; c++) if (board[r][c].value == 0) empty.add(new int[]{r, c}); if (empty.isEmpty()) return; int[] pos = empty.get(random.nextInt(empty.size())); int value = random.nextInt(10) < 9 ? 2 : 4; // 90% 出 2 board[pos[0]][pos[1]] = new Tile(value, true); }

这里用Random而不是Math.random(),因为前者可以传种子,方便测试时复现。比如我想验证某个 bug,固定种子后每次生成的序列一样,排查起来省事。

2.4 胜负判定:什么时候算赢,什么时候算输

胜利条件很简单,棋盘上出现 2048 就赢。但这里有个细节:很多实现是“出现 2048 立即弹窗结束”,但标准玩法允许玩家继续玩下去冲更高分。我的做法是第一次达到 2048 时弹个提示,玩家可以选择继续。

失败判定稍微绕一点:棋盘满了且四个方向都无法合并才算输。不能只看有没有空位,因为有时候棋盘满了但相邻有相等数字,还能合并。我的判断逻辑是遍历每个格子,看它右边和下边是否相等(左边和上边会被重复检查,不用管),只要有一对相等就没输。

public boolean isGameOver() { for (int r = 0; r < SIZE; r++) { for (int c = 0; c < SIZE; c++) { if (board[r][c].value == 0) return false; if (c + 1 < SIZE && board[r][c].value == board[r][c + 1].value) return false; if (r + 1 < SIZE && board[r][c].value == board[r + 1][c].value) return false; } } return true; }

这段代码短小但容易写错,我建议你写完拿几个边界情况测一下:全满但有一对相邻相等、全满且完全无解、有空位但数字都很大,这三种情况分别验证一遍。

3. 界面实现与完整实操流程

逻辑层跑通之后,界面就是给它套个壳。我用的是 Java 自带的 Swing,不用装任何第三方库,JDK 装好就能跑,对新手最友好。下面从窗口搭建到键盘绑定,一步步来。

3.1 环境准备与项目结构

先说环境。JDK 8 及以上都行,我实测 JDK 11 和 17 都没问题。IDE 用 IntelliJ IDEA 或者 Eclipse 都可以,甚至记事本加命令行也能编译。项目结构我建议这样组织:

src/ com/game2048/ GameBoard.java // 核心逻辑 Tile.java // 格子对象 GamePanel.java // 绘制面板 GameFrame.java // 主窗口 Main.java // 入口

包名随意,但建议分层清晰。编译命令就是javac加java,如果打包成 jar,用jar cvfe game2048.jar com.game2048.Main com/game2048/*.class就行。

3.2 用 Swing 绘制棋盘:坐标计算是关键

绘制这块,核心是把逻辑坐标(第几行第几列)换算成像素坐标。我定义了几个常量:格子边长TILE_SIZE = 100,格子间距GAP = 10,棋盘边距MARGIN = 20。那么第 r 行第 c 列的格子,左上角像素坐标就是:

x = MARGIN + c * (TILE_SIZE + GAP) y = MARGIN + r * (TILE_SIZE + GAP)

窗口总宽高就是MARGIN * 2 + SIZE * TILE_SIZE + (SIZE - 1) * GAP,4x4 算下来是 202 + 4100 + 3*10 = 470 像素。这个计算我建议写成方法,别硬编码数字,改尺寸的时候省得满代码找。

绘制时先画背景圆角矩形,再遍历棋盘画每个格子。不同数值用不同背景色,我整理了一个颜色映射表:

数值背景色(十六进制)文字色
0#CDC1B4无
2#EEE4DA#776E65
4#EDE0C8#776E65
8#F2B179#F9F6F2
16#F59563#F9F6F2
32#F67C5F#F9F6F2
64#F65E3B#F9F6F2
128 及以上#EDCF72 起逐级加深#F9F6F2

颜色这块纯属个人审美,你也可以换成自己顺眼的配色。关键是数值越大颜色越“热”,让玩家一眼看出局势。

@Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 = (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 画背景 g2.setColor(new Color(0xBBADA0)); g2.fillRoundRect(0, 0, getWidth(), getHeight(), 15, 15); // 画每个格子 for (int r = 0; r < SIZE; r++) { for (int c = 0; c < SIZE; c++) { drawTile(g2, r, c, board[r][c]); } } }

RenderingHints那两行是开抗锯齿,不开的话圆角矩形边缘会有锯齿,看着很糙。这个细节很多教程不提,但加上之后质感提升明显。

3.3 键盘事件绑定:让方向键真正驱动游戏

Swing 里绑定键盘有两种方式:KeyListener 和 Key Bindings。我推荐用Key Bindings,因为 KeyListener 要求组件必须获得焦点,焦点一丢按键就失效,体验很差。Key Bindings 是绑定在组件的 InputMap 上的,不受焦点影响。

private void bindKeys() { InputMap im = getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW); ActionMap am = getActionMap(); bind(im, am, "LEFT", KeyEvent.VK_LEFT, () -> doMove(Direction.LEFT)); bind(im, am, "RIGHT", KeyEvent.VK_RIGHT, () -> doMove(Direction.RIGHT)); bind(im, am, "UP", KeyEvent.VK_UP, () -> doMove(Direction.UP)); bind(im, am, "DOWN", KeyEvent.VK_DOWN, () -> doMove(Direction.DOWN)); } private void bind(InputMap im, ActionMap am, String name, int key, Runnable action) { im.put(KeyStroke.getKeyStroke(key, 0), name); am.put(name, new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { action.run(); } }); }

WHEN_IN_FOCUSED_WINDOW这个条件很关键,它让按键在整个窗口范围内生效,不用管哪个组件有焦点。doMove方法里做三件事:调逻辑层的移动方法、如果 moved 为 true 就补新数字、然后repaint()刷新界面。

3.4 完整游戏循环与重开机制

游戏主循环其实不是传统意义上的 while 循环,而是事件驱动的:玩家按键触发一次处理,处理完重绘,等待下一次按键。这种模式在 GUI 程序里很常见,不用自己写循环。

重开机制我加了个按钮或者按 R 键触发,逻辑就是把棋盘清空、得分归零、重新生成两个初始数字。这里有个小坑:重开的时候要把gameOver和won这些标志位也重置,否则会出现“重开后立刻判定游戏结束”的诡异现象。我第一次写就踩了这个坑,排查了半天才发现是标志位没清。

public void restart() { board = new Tile[SIZE][SIZE]; for (int r = 0; r < SIZE; r++) for (int c = 0; c < SIZE; c++) board[r][c] = new Tile(0, false); score = 0; gameOver = false; won = false; addRandomTile(); addRandomTile(); repaint(); }

初始生成两个数字是标准规则,别只生成一个,否则开局太单调。

4. 常见问题排查与避坑经验实录

代码写完能跑只是第一步,真正折磨人的是各种边界 bug。我把开发过程中遇到的问题整理成了一张速查表,都是实打实踩出来的。

4.1 合并逻辑的经典错误与修复

问题一:[2,2,2,2]向左滑变成了[8]。这是最经典的错误,原因就是合并时没有跳过下一个元素。修复方法就是前面代码里那个i++,合并完一对后跳过被合并掉的那个位置。

问题二:[4,4,8]向左滑,4 和 4 合并成 8 之后,又和后面的 8 合并成了 16。这也是同一类错误,本质是“新合并出来的数字在同一轮里又参与了合并”。标准规则是每个数字一次滑动只能合并一次,所以合并后要标记这个位置本轮不能再动。用i++跳过就能解决。

问题三:向右滑动时合并顺序反了。比如[2,2,4]向右滑,正确结果是[_,4,4],但如果直接反转数组处理再反转回来,合并顺序会变成从右往左,结果可能出错。我的做法是向右滑时先把整行反转,用向左的逻辑处理,再反转回来,这样合并方向自然就对了。

4.2 界面刷新与性能问题

问题:按键后界面不更新。九成是忘了调repaint()。Swing 的绘制是异步的,你改了数据不主动请求重绘,界面就还是老的。记住每次数据变动后都调一次。

问题:快速连按方向键,偶尔出现数字错乱。这是因为 Swing 的事件处理和重绘在不同线程,如果逻辑处理太慢,可能出现竞态。解决办法是把游戏逻辑放在事件分发线程(EDT)里执行,Swing 的按键回调默认就在 EDT,所以一般不会有问题。但如果你自己开了线程做动画,就要注意同步。

问题:窗口拉伸后棋盘变形。因为绘制坐标是写死的像素值,窗口一大,棋盘就缩在左上角。解决办法是要么禁止窗口拉伸(setResizable(false)),要么在 paintComponent 里根据当前窗口尺寸动态计算格子大小。我图省事用了前者,追求体验的话可以研究后者。

4.3 游戏状态判断的边界情况

问题:棋盘满了但还能合并,却判定游戏结束。这就是前面说的,失败判定不能只看空位,要检查相邻是否有相等数字。我建议单独写个canMove()方法,四个方向各模拟一次,只要有一个方向能移动就不算输。

问题:达到 2048 后弹窗,关掉弹窗游戏卡死。这是因为弹窗(JOptionPane)是模态的,会阻塞事件线程。如果弹窗里又触发了重绘,可能死锁。我的做法是弹窗只做提示,玩家点确定后设置一个标志位,主逻辑继续跑,不在弹窗回调里做复杂操作。

问题:重开后得分没清零。前面提过,重开方法里要把所有状态字段都重置,包括得分、胜负标志、棋盘数据。建议把重置逻辑集中在一个方法里,别散落在各处。

4.4 独家避坑技巧汇总

下面这几条是我折腾过程中总结的,常规教程里基本看不到:

  • 用固定随机种子调试:new Random(42),这样每次运行生成的数字序列一样,复现 bug 特别方便。上线前再换成无参构造。
  • 给 Tile 加个 id 字段:调试合并逻辑时,打印棋盘能看到每个格子的“身份”,追踪哪个格子被合并了、哪个是新生成的,比只看数字清楚得多。
  • 先写控制台版本验证逻辑:在套 Swing 之前,先写个命令行版本,用 Scanner 读方向输入,把合并逻辑跑通。这样界面出问题时你能确定是逻辑的锅还是绘制的锅。
  • 得分计算放在合并处而不是移动处:只有真正发生合并才加分,单纯移动不加分,这个规则别搞反。
  • 测试用例准备几个经典的:[2,2,2,2]、[4,4,8,8]、[2,0,2,4]、[0,0,0,2],这几个覆盖了大部分边界,写完先拿它们过一遍。
问题现象可能原因排查方向
合并出 8 而非两个 4合并后未跳过下一元素检查i++是否遗漏
按键无反应焦点丢失或未绑定改用 Key Bindings
界面不刷新忘记 repaint数据变动后调用
重开即结束标志位未重置检查 restart 方法
数字生成位置异常空位收集有误检查边界循环

5. 功能扩展与代码优化方向

基础版本跑通之后,如果你想让它更像个“作品”,还有不少可以打磨的地方。这部分不是必须,但做了之后项目完整度会高很多,拿去当简历项目也更有底气。

5.1 加入撤销功能:用栈保存历史状态

撤销是玩家呼声最高的功能。实现思路很简单:每次有效移动前,把当前棋盘状态深拷贝一份压入栈中,玩家按撤销键就弹栈恢复。关键是深拷贝,不能只存引用,否则后续修改会把历史状态也改掉。

private Deque<int[][]> history = new ArrayDeque<>(); private void saveState() { int[][] snapshot = new int[SIZE][SIZE]; for (int r = 0; r < SIZE; r++) for (int c = 0; c < SIZE; c++) snapshot[r][c] = board[r][c].value; history.push(snapshot); if (history.size() > 20) history.removeLast(); // 限制栈深度 }

限制栈深度是为了防止内存无限增长,20 步撤销对普通玩家足够了。恢复的时候把 snapshot 里的值填回 Tile 对象即可。

5.2 动画效果:让滑动不那么生硬

纯静态刷新玩起来很“干”,加个简单的滑动动画体验会好很多。Swing 里可以用javax.swing.Timer做逐帧动画:记录每个格子移动前后的坐标,在几十毫秒内插值过渡。这块稍微复杂,我建议先把静态版本做扎实,有余力再碰动画。

一个折中方案是给新生成的格子加个缩放动画,从 0.5 倍放大到 1 倍,用 Timer 控制几帧就行,代码量不大但效果明显。

5.3 最高分持久化:存到本地文件

最高分每次重开就没了很可惜。用 Java 的Properties或者简单的文本文件存一下就行,启动时读、破纪录时写。文件放在用户目录下,路径用System.getProperty("user.home")获取,跨平台没问题。

Path scoreFile = Paths.get(System.getProperty("user.home"), ".game2048_score"); // 读取 if (Files.exists(scoreFile)) { bestScore = Integer.parseInt(Files.readString(scoreFile).trim()); } // 写入 Files.writeString(scoreFile, String.valueOf(bestScore));

注意读写要包 try-catch,文件不存在或者内容损坏时给个默认值,别让程序崩了。

5.4 代码层面的优化建议

最后说几个代码质量上的点。第一,把魔法数字抽成常量,SIZE、TILE_SIZE、GAP这些别散落在代码里。第二,合并逻辑写成静态工具方法,方便单元测试。第三,方向用枚举Direction表示,比传字符串或者 int 清晰。第四,给关键方法写注释,尤其是合并算法,过一周你自己都可能忘了i++是干嘛的。

我个人在实际操作中的体会是,2048 这个项目最大的价值不在于游戏本身,而在于它逼着你去思考“数据怎么组织、逻辑怎么拆分、界面怎么解耦”。我第一版代码写了两百多行全堆在一个类里,后来重构了三遍才理清楚。你要是刚开始写,别怕写得丑,先跑通再优化,跑不通的时候拿控制台版本对照着调,比盯着窗口干瞪眼强。另外那个i++的坑,我建议你专门写个测试方法把[2,2,2,2]和[4,4,8,8]跑一遍,确认输出是[4,4]和[8,16],这两个过了,合并逻辑基本就稳了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进