用Java手写一个2048小游戏是我带新人练手时最常布置的题目之一。它不像贪吃蛇那样依赖实时循环和碰撞检测也不像扫雷那样需要处理复杂的递归展开但麻雀虽小五脏俱全——二维数组的状态管理、滑动合并的算法逻辑、键盘事件的响应、界面重绘的时机一个都不少。很多人第一次听到用Java写2048脑子里第一反应是是不是得搞个Swing界面其实界面只是壳真正决定这个游戏能不能跑起来、手感对不对的是那个4×4的棋盘在每次方向键按下后到底发生了什么。这篇内容我打算把整个实现过程拆开讲透从数据结构怎么选、合并算法为什么必须先压缩再合并再压缩、到Swing里键盘焦点为什么老是丢、再到怎么用颜色让数字方块看起来不那么寒酸。适合刚学完Java基础语法、想找个完整小项目练手的朋友也适合已经写过但总觉得手感别扭、想搞清楚细节的人。1. 为什么2048值得用Java从头写一遍1.1 这个题目的技术含量到底在哪很多人低估了2048。表面上看它就是个数字合并游戏规则一句话能说完相同数字撞一起就翻倍。但真动手写的时候你会发现难点根本不在合并这个动作本身而在于合并的时机、顺序和边界处理。举个最典型的例子一行是[2, 2, 2, 2]向左滑动之后应该变成[4, 4, 0, 0]而不是[8, 0, 0, 0]。也就是说每个方块在一次滑动中最多只能参与一次合并。这个约束如果不在算法层面处理好写出来的游戏就会出现四个2合成一个8的诡异现象。再比如[2, 2, 4, 0]向左滑正确结果是[4, 4, 0, 0]第一个2和第二个2合并成4原来的4不动而不是合并出来的4再跟原来的4继续合并成8。这种合并产物不参与本轮后续合并的规则是2048区别于很多消除类游戏的核心机制。用Java实现的时候最稳妥的做法是把压缩、合并、再压缩拆成三个独立步骤而不是在一个循环里边遍历边判断否则极容易踩到索引错乱的坑。从工程角度看这个项目还天然覆盖了几个Java基础里非常重要的知识点二维数组的遍历与原地修改、面向对象的状态封装、Swing的事件监听模型、以及Graphics绘图的基本用法。它足够小小到一个人两三天能写完又足够完整完整到能让你体会一个能跑的程序和一个手感对的程序之间的差距。1.2 用Swing还是控制台先想清楚你要练什么我见过不少新人一上来就纠结要不要用JavaFX觉得Swing太老。这个纠结其实跑偏了。选什么界面库取决于你这个项目想练的核心能力是什么。如果你主要想练算法和状态管理控制台版本完全够用用Scanner读方向输入用字符画打印棋盘二三十行就能跑起来注意力全在合并逻辑上。如果你想顺带把事件驱动、绘图、布局这些桌面开发的基本功过一遍那Swing是性价比最高的选择——它内置在JDK里不用配任何额外依赖JFrame加JPanel加KeyListener三件套就能搭出一个能玩的界面。我个人的建议是先写控制台版本把核心算法跑通再套Swing的壳。这个顺序很重要。因为算法没写对的时候你在Swing里调试会非常痛苦——你得靠肉眼看方块位置来判断逻辑对不对而控制台版本可以直接System.out.println把每一步的数组状态打出来哪里错了清清楚楚。等控制台版本能稳定玩到2048再迁移到图形界面你会发现界面层几乎不涉及业务逻辑只是把数组画出来而已。至于JavaFX它确实更现代、样式更好写但对于一个4×4的棋盘来说属于杀鸡用牛刀而且多引入一层依赖对新手来说多一个出错的地方。这个项目用Swing够了。1.3 整体架构一个棋盘类扛下所有状态在动手写代码之前先把类的职责划清楚能省掉后面大量的返工。我的做法是只设计一个核心类Game2048让它同时持有棋盘数据和提供操作方法界面部分单独放在一个继承JPanel的类里。棋盘数据用一个int[4][4]表示0代表空格非零代表对应的数字。为什么用int而不是Integer因为二维数组初始化后默认全是0正好对应全空棋盘省去了手动填充的步骤而且基本类型没有装箱拆箱的开销虽然在这个规模下性能差异可以忽略但语义上更干净。Game2048类需要暴露的方法其实不多一个move(Direction dir)负责处理某个方向的滑动一个isGameOver()判断是否还能继续一个hasWon()判断是否已经出现2048再加上一个addRandomTile()在每次有效移动后随机生成新方块。界面类只负责三件事监听键盘、调用move、根据返回结果重绘。这种划分的好处是业务逻辑和界面完全解耦你甚至可以在不启动界面的情况下写单元测试来验证合并算法是否正确。2. 滑动合并的算法拆解压缩、合并、再压缩2.1 为什么不能边遍历边合并这是整个项目里最容易写错的地方值得单独拎出来讲。假设你要处理向左滑动最直觉的写法是从左往右遍历每一行遇到相邻相同的就合并。但这么写会立刻撞上两个问题。第一个是前面提到的合并产物重复参与合并[2, 2, 2, 2]在朴素遍历下第一和第二个2合并成4然后这个4又跟第三个2比较不相等跳过再跟第四个2比较不相等结果变成[4, 2, 2, 0]跟预期完全不符。第二个问题是空格的处理[0, 2, 0, 2]向左滑你得先跳过前面的0才能让两个2相遇边遍历边判断会让索引逻辑变得极其混乱。正确的思路是把一行或一列的处理拆成三个独立的、职责单一的动作。第一步压缩把所有非零元素按滑动方向挤到一端中间的空格全部消掉比如[0, 2, 0, 2]压缩后变成[2, 2, 0, 0]。第二步合并从左往右扫描压缩后的数组相邻且相等的两个数合并成一个翻倍数被合并的位置置0并且跳过下一个位置保证一次滑动中每个方块只合并一次。第三步再压缩合并之后中间又会出现新的空格比如[2, 2, 4, 0]合并后是[4, 0, 4, 0]需要再压缩一次变成[4, 4, 0, 0]。这三步拆开之后每一步的逻辑都极其简单而且可以独立验证。压缩就是双指针合并就是一次线性扫描再压缩还是双指针。这种把复杂操作分解成简单操作的组合的思路在算法设计里非常通用值得养成习惯。2.2 压缩操作的双指针写法压缩的本质是把数组里的非零元素按顺序往前挪空位补0。用双指针写最清晰一个指针write指向下一个要写入的位置另一个指针read从头扫到尾遇到非零就写到write位置然后write。扫完之后从write到数组末尾全部填0。以向左压缩为例代码大概是这样private int[] compress(int[] line) { int[] result new int[4]; int write 0; for (int read 0; read 4; read) { if (line[read] ! 0) { result[write] line[read]; } } // write 之后的默认就是0不用手动填 return result; }这里有个小细节我返回的是一个新数组而不是原地修改。为什么因为原地修改需要额外记录哪些位置被写过逻辑反而更绕。新建一个长度为4的数组成本可以忽略不计代码可读性却高很多。对于向右滑动的情况压缩方向相反最简单的处理是把这一行反转过来、按向左的逻辑处理、再反转回去而不是重写一套从右往左的双指针。这种用反转复用同一套逻辑的技巧能显著减少重复代码和出错概率。2.3 合并时那个跳过下一个的细节合并步骤是三步里唯一需要小心索引的。扫描压缩后的数组如果当前位置和下一个位置的值相等且都不为0就把当前位置翻倍、下一位置0然后索引额外加1跳过被合并的那个位置。这个额外加1就是保证每个方块只合并一次的关键。还是拿[2, 2, 2, 2]举例索引0和1相等合并成[4, 0, 2, 2]索引从0直接跳到2索引2和3相等合并成[4, 0, 4, 0]索引跳到4结束。再压缩一次得到[4, 4, 0, 0]完全正确。private int[] merge(int[] line) { for (int i 0; i 3; i) { if (line[i] ! 0 line[i] line[i 1]) { line[i] * 2; line[i 1] 0; i; // 关键跳过被合并的位置 } } return line; }如果不写那个i[2, 2, 4, 0]就会先合并成[4, 0, 4, 0]然后索引1现在是0和索引2是4比较不相等没问题但如果数组是[4, 4, 8, 0]不跳过的话合并出[8, 0, 8, 0]之后索引1的0和索引2的8不相等也不会出错。真正出问题的是[2, 2, 2, 0]这种不跳过的话索引0和1合并成[4, 0, 2, 0]然后索引1是0跳过索引2是2结束结果是[4, 2, 0, 0]看起来对但如果是[2, 2, 2, 2]不跳过就会在合并出[4, 0, 2, 2]后索引1的0不影响索引2和3再合并成[4, 0, 4, 0]结果也对。等等那到底什么时候会错真正会错的情况是合并产生的值恰好和后面紧邻的值相等且中间没有0隔开。但因为我们合并后把被合并位置置0了中间必然有0隔开所以理论上不跳过也不会让合并产物再次参与合并。不过不跳过会导致索引推进变慢在某些边界情况下可能让同一个位置被重复判断而且从语义上讲一次滑动每个方块只合并一次这个规则用显式的i表达出来代码意图更清晰维护的人一眼就能看懂。我坚持写这个i更多是为了可读性和防御性而不是因为它能修复某个具体的bug。这种为了表达意图而多写一行的习惯在团队协作里价值很高。2.4 四个方向怎么复用同一套逻辑向左的逻辑写完之后向上、向右、向下怎么办最笨的办法是写四套但那样代码量翻四倍改一个bug要改四处。聪明的做法是利用矩阵的对称性做转换。向左是基准其他三个方向都可以通过转置和反转映射到向左。具体来说向上滑动等价于把矩阵转置行列互换之后向左滑动再转置回来向右滑动等价于把每一行反转之后向左滑动再反转回来向下滑动等价于转置、每行反转、向左滑动、反转、转置。这套映射关系听起来绕但实现起来就是两个工具方法transpose和reverseRows组合调用即可。好处是合并逻辑只有一份测试也只需要重点测向左其他方向靠转换的正确性来保证。public boolean move(Direction dir) { int[][] before copy(board); switch (dir) { case LEFT: slideLeft(); break; case RIGHT: reverseRows(); slideLeft(); reverseRows(); break; case UP: transpose(); slideLeft(); transpose(); break; case DOWN: transpose(); reverseRows(); slideLeft(); reverseRows(); transpose(); break; } return !Arrays.deepEquals(before, board); // 返回是否发生了有效移动 }注意最后那个返回值它判断移动前后棋盘是否发生变化。这个布尔值非常关键因为只有发生了有效移动才应该生成新方块。如果玩家按了一个方向但所有方块都顶死了没动就不该凭空冒出新数字否则游戏节奏会乱。这个细节很多新手会漏掉导致按无效方向也能刷出新方块玩起来就很怪。3. 随机生成与游戏状态判定3.1 新方块为什么是90%出2、10%出42048原版里每次有效移动后会在随机空位生成一个新方块数值是2的概率约90%是4的概率约10%。这个比例不是随便定的它直接影响游戏难度和节奏。如果全是2游戏会变得过于简单因为合并路径很顺畅如果4的比例太高棋盘很快被大数占满容错率骤降。90/10这个配比是经过大量玩家验证的平衡点自己实现的时候建议照搬。实现上先收集所有值为0的位置到一个列表里然后Random.nextInt(list.size())随机选一个位置再根据概率决定填2还是4。这里有个容易忽略的点如果棋盘已经满了没有空位就不该调用生成方法。所以addRandomTile内部要先判断空位列表是否为空为空直接返回。另外生成新方块之后要检查是否游戏结束这个顺序不能反——先生成再判断因为有可能生成的那个方块恰好填满了最后一个空位导致游戏结束。private void addRandomTile() { Listint[] empty new ArrayList(); for (int r 0; r 4; r) for (int c 0; c 4; c) if (board[r][c] 0) empty.add(new int[]{r, c}); if (empty.isEmpty()) return; int[] pos empty.get(random.nextInt(empty.size())); board[pos[0]][pos[1]] random.nextInt(10) 9 ? 2 : 4; }3.2 游戏结束的判定不能只看有没有空位判断游戏是否结束很多人第一反应是棋盘满了就结束。这个判断是错的。棋盘满了但相邻还有相同数字的时候玩家仍然可以合并游戏还能继续。正确的判定条件是没有空位且上下左右四个方向都不存在相邻相等的方块。换句话说只要还有一步能走游戏就没结束。实现上先扫一遍有没有0有就返回false没有0的话再检查每个位置和它右边、下边的邻居是否相等只需要检查右和下两个方向因为左和上会被对称地检查到只要找到一对相等就返回false全部检查完都没找到才返回true。这个判定逻辑虽然简单但漏掉相邻相等这一条是新手最常见的错误之一写完之后一定要用满盘但有一对相邻相等的棋盘手动验证一下。3.3 胜利判定与继续游戏的处理出现2048就算赢这个判定很直接遍历棋盘看有没有值等于2048的格子。但原版2048有个贴心设计——赢了之后不强制结束玩家可以选择继续玩冲击更高的分数。实现这个功能需要一个状态标记比如一个boolean won和一个boolean keepPlaying。当首次检测到2048且keepPlaying为false时弹出胜利提示玩家选择继续后把keepPlaying置true之后就不再弹胜利提示但游戏结束判定照常进行。这个细节虽然小但体现了游戏状态这个概念——一个游戏不只是棋盘数据还包括是否已胜利是否允许继续这些元状态。把这些状态和棋盘数据放在同一个类里管理比散落在界面代码里要清晰得多。我在实际项目里见过太多把状态判断写在paintComponent里的写法结果每次重绘都触发一次弹窗调试起来苦不堪言。4. Swing界面层键盘焦点和重绘的那些坑4.1 为什么你的键盘监听没反应Swing里给面板加KeyListener十有八九第一次跑起来会发现按键完全没反应。这不是代码写错了而是焦点问题。KeyListener只有在组件持有键盘焦点时才会收到事件而JPanel默认是不可聚焦的。解决办法是调用setFocusable(true)并且在窗口显示之后调用requestFocusInWindow()。但即便这样如果面板里还放了其他可聚焦的组件比如按钮焦点也可能被抢走。更稳妥的方案是改用键绑定Key Binding也就是InputMap加ActionMap那套机制。键绑定的好处是不依赖组件焦点只要组件在窗口的焦点遍历体系里就能触发而且可以把按键和动作解耦代码结构更清晰。虽然初次接触会觉得API有点绕但对于游戏这种需要稳定响应方向键的场景键绑定比KeyListener可靠得多。我现在的习惯是简单demo用KeyListener图快正经一点的项目一律上键绑定。InputMap im getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW); im.put(KeyStroke.getKeyStroke(LEFT), moveLeft); im.put(KeyStroke.getKeyStroke(RIGHT), moveRight); im.put(KeyStroke.getKeyStroke(UP), moveUp); im.put(KeyStroke.getKeyStroke(DOWN), moveDown); ActionMap am getActionMap(); am.put(moveLeft, new AbstractAction() { public void actionPerformed(ActionEvent e) { doMove(Direction.LEFT); } }); // 其余三个方向同理WHEN_IN_FOCUSED_WINDOW这个条件的意思是只要窗口处于激活状态就能响应不要求某个具体组件持有焦点正好适合游戏场景。4.2 重绘时机改完数据一定要repaintSwing的绘图是事件驱动的paintComponent什么时候被调用由系统决定你改完棋盘数据之后必须主动调用repaint()通知系统该重画了。这个调用本身是异步的它只是把重绘请求放进事件队列真正的绘制发生在之后的某个时刻。所以千万不要在repaint()之后紧接着去读界面上的像素或者做依赖绘制结果的操作那时候画面还没更新。另一个常见问题是在paintComponent里修改游戏状态。比如有人在绘制的时候顺便判断游戏是否结束并弹窗这就犯了绘制方法只做绘制的忌讳。paintComponent可能被频繁调用窗口遮挡、最小化恢复都会触发在里面做副作用操作会导致弹窗重复弹出、状态被意外修改。正确的做法是把状态判断放在move方法里绘制方法只负责根据当前状态画图。4.3 用颜色区分数字让棋盘不那么单调纯数字的棋盘看起来确实寡淡加一套颜色映射能显著提升观感。我的做法是准备一个颜色数组按数字的二进制对数索引——2对应索引1、4对应索引2、8对应索引3以此类推。这样数字越大颜色越深或越亮视觉上有层次感。具体配色可以参考常见的2048配色方案浅色背景配深色数字大数字用暖色调突出。绘制的时候每个格子先填背景色再用Graphics2D设置抗锯齿、选好字体、居中绘制数字文本。字体大小要根据数字位数动态调整两位数、三位数、四位数用不同字号否则2048这种四位数会溢出格子。居中绘制需要计算文本的宽高用FontMetrics的stringWidth和getAscent来定位这个计算稍微有点繁琐但一次写好就能复用。g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); Font font new Font(SansSerif, Font.BOLD, fontSizeFor(value)); g2.setFont(font); FontMetrics fm g2.getFontMetrics(); int x cellX (cellW - fm.stringWidth(text)) / 2; int y cellY (cellH fm.getAscent()) / 2 - 4; g2.drawString(text, x, y);那个-4是微调让文本在垂直方向看起来更居中因为getAscent只算到基线实际视觉重心会偏上一点。这种像素级的微调没有理论公式就是试出来的不同字体可能要调不同的值。5. 实测中踩过的坑和手感调优5.1 一次按键触发多次移动的诡异现象有次我写完测试发现按一下方向键棋盘会连续动两下新方块也生成了两个。排查了半天最后发现是键绑定被重复注册。原因是我在初始化方法里注册了一次键绑定又在窗口resize的回调里重新调用了初始化方法导致同一个按键对应了两个Action。Swing的InputMap在put相同按键时会覆盖而不是叠加但如果两次注册用的是不同的Action名字就会变成两个动作都触发。这个坑的教训是初始化逻辑只应该执行一次放在构造函数或者明确的init方法里不要放在会被反复触发的回调里。如果确实需要在运行时改变键绑定要先remove再put或者干脆重建InputMap。另外调试这类一次操作触发多次的问题最有效的手段是在Action里打日志看看到底触发了几次、每次的调用栈是什么比盯着代码猜快得多。5.2 动画缺失带来的生硬感原版2048的方块滑动是有动画的新方块出现也有一个缩放效果。纯Swing实现如果不做动画每次按键棋盘瞬间跳变玩起来会有种卡顿的错觉——其实不是卡是视觉上缺少过渡。做动画当然可以用javax.swing.Timer每隔十几毫秒更新一次方块位置逐步逼近目标位置到达后停止。但这会显著增加代码复杂度因为你需要为每个方块维护当前位置和目标位置两套坐标还要处理动画进行中再次按键的冲突。我的建议是第一版不做动画先把逻辑跑通。等核心玩法稳定了如果还有精力再考虑加一个简单的淡入效果或者新方块的缩放。对于练手项目来说动画属于锦上添花不是必需品。而且不做动画反而有个好处——每次按键的结果立刻呈现调试合并逻辑的时候更容易看清每一步的变化。5.3 分数统计和最高分持久化分数统计很简单每次合并时把合并产生的数值累加到总分里。注意是累加合并后的值比如两个2合并成4加4分两个4合并成8加8分。这个累加要放在合并逻辑里而不是在移动结束后重新遍历棋盘求和因为后者算出来的是棋盘上所有数字的总和跟游戏分数不是一回事。最高分持久化可以用java.util.prefs.Preferences这是JDK自带的轻量级配置存储不用引入任何第三方库几行代码就能把最高分存到用户目录下。相比自己读写文件Preferences帮你处理了路径、格式、异常这些琐事对于这种小需求非常合适。每次游戏结束时比较当前分和最高分超过就更新并写回。Preferences prefs Preferences.userNodeForPackage(Game2048.class); int best prefs.getInt(bestScore, 0); if (score best) { prefs.putInt(bestScore, score); }5.4 几个提升手感的小细节第一个是无效移动不生成新方块前面提过这里再强调一次这是手感好坏的分水岭。第二个是新方块的生成位置要真正随机不能用固定位置或者伪随机的规律否则玩家能预判游戏就没意思了。第三个是棋盘边距和格子间距要留够格子贴太紧看起来压抑间距太大又显得松散一般留格子宽度的十分之一左右比较舒服。第四个是窗口大小固定禁止用户拖拽改变尺寸因为你的绘制逻辑是基于固定格子尺寸算的窗口一变布局就乱了除非你做了自适应布局否则直接setResizable(false)省事。这些细节单看都不起眼但堆在一起就决定了这个游戏是能玩还是好玩。我始终觉得写小游戏最大的价值不在于实现功能而在于体会这种从能跑到好用的打磨过程这个过程里积累的直觉在大项目里同样适用。5.5 用单元测试锁住合并逻辑最后分享一个我觉得特别值得做的习惯给合并算法写几个单元测试。不需要引入JUnit那么重的框架写一个带main方法的测试类把各种边界情况跑一遍就行。重点测这几种[2,2,2,2]向左、[2,2,4,0]向左、[0,2,0,2]向左、[4,4,8,8]向左、全0行、已经靠左没有空位的行。每种情况打印输入和输出肉眼比对预期结果。这么做的好处是当你后面为了加动画或者改界面而重构代码时跑一遍测试就能确认核心逻辑没被改坏。我见过太多人改界面的时候顺手动了算法结果合并规则悄悄变了却浑然不觉等到玩的时候发现怎么四个2合成8了才回头查浪费大量时间。花十分钟写测试能省下后面几个小时的排查这笔账怎么算都划算。把上面这些串起来一个完整的2048其实就成型了Game2048类管棋盘和合并GamePanel管绘制和按键main方法里拼一个JFrame出来。代码总量大概三四百行但每一行背后都有值得琢磨的取舍。我自己每次重写这个项目都会在某个细节上有新的体会这大概就是经典练手题的价值所在——它简单到你能完整掌控又复杂到能暴露你思维里的盲区。