用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%出4
2048原版里,每次有效移动后会在随机空位生成一个新方块,数值是2的概率约90%,是4的概率约10%。这个比例不是随便定的,它直接影响游戏难度和节奏。如果全是2,游戏会变得过于简单,因为合并路径很顺畅;如果4的比例太高,棋盘很快被大数占满,容错率骤降。90/10这个配比是经过大量玩家验证的平衡点,自己实现的时候建议照搬。
实现上,先收集所有值为0的位置到一个列表里,然后Random.nextInt(list.size())随机选一个位置,再根据概率决定填2还是4。这里有个容易忽略的点:如果棋盘已经满了(没有空位),就不该调用生成方法。所以addRandomTile内部要先判断空位列表是否为空,为空直接返回。另外,生成新方块之后要检查是否游戏结束,这个顺序不能反——先生成再判断,因为有可能生成的那个方块恰好填满了最后一个空位,导致游戏结束。
private void addRandomTile() { List<int[]> 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 重绘时机:改完数据一定要repaint
Swing的绘图是事件驱动的,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出来。代码总量大概三四百行,但每一行背后都有值得琢磨的取舍。我自己每次重写这个项目,都会在某个细节上有新的体会,这大概就是经典练手题的价值所在——它简单到你能完整掌控,又复杂到能暴露你思维里的盲区。