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^(SIZE7)左右4x4 对应 20485x5 对应 8192这个后面细说。1.3 核心状态流转一次滑动到底发生了什么理解一次滑动的完整流程是写好这个游戏的关键。玩家按下方向键后程序其实要做四件事顺序不能乱压缩把一行里所有非零数字往滑动方向靠拢去掉中间的空格。合并相邻且相等的两个数字合并成一个值翻倍。再压缩合并后可能又出现空格需要再靠拢一次。补新数在随机空位生成一个 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% 概率生成 210% 概率生成 4。我见过有人写成 50/50结果游戏难度骤降几下就凑出大数字失去了挑战性。实现上分两步先收集所有空位的坐标到一个 List 里然后随机选一个位置再随机决定数值。public void addRandomTile() { Listint[] 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) * GAP4x4 算下来是 202 4100 3*10 470 像素。这个计算我建议写成方法别硬编码数字改尺寸的时候省得满代码找。绘制时先画背景圆角矩形再遍历棋盘画每个格子。不同数值用不同背景色我整理了一个颜色映射表数值背景色十六进制文字色0#CDC1B4无2#EEE4DA#776E654#EDE0C8#776E658#F2B179#F9F6F216#F59563#F9F6F232#F67C5F#F9F6F264#F65E3B#F9F6F2128 及以上#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 Dequeint[][] 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]这两个过了合并逻辑基本就稳了。