拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Java手写2048小游戏:核心算法与Swing界面实现

简介这是一份JAVA实现的2048小游戏完整源码包适合Java初学者、课程设计或对游戏开发感兴趣的学习者参考。项目基于窗体应用程序界面整洁代码注释完整、结构清晰可直接用IDE打开并运行Main类启动游戏。游戏内含三档难度从4×4到6×6格子格子越多操作空间越大、越简单通过键盘方向键控制数字方块滑动相同数字相撞相加凑出2048即获胜格子填满无路可走则失败。源码包共18个文件以Java源文件为核心辅以png图片资源、wav音效、dat数据以及readme说明等压缩包仅189KB轻量易用适合快速搭建自己的应用程序或进行二次扩展。资源包含Launcher、About、Help等模块逻辑分层清晰方便学习游戏循环、键盘事件与界面绘制。当前已有530人浏览学习可作为入门Java图形界面编程与小型游戏开发的实用参考资料。1. 为什么 2048 是 Java 程序员最该手写一遍的小游戏在 Java 小游戏里2048 的出场率远高于贪吃蛇以外的任何题材培训班课设、大一实训、java 面试题的手写环节里都是它。原因很直接数据结构、随机数、事件驱动、图形绘制全压在一个几百行的工程里难度又正好卡在一周能写完的门槛上。和 python 小游戏、c 小游戏那类面向过程的写法相比用 Java 写的价值在于棋盘天然就是二维数组移动算法逼着你思考怎么只遍历一次就完成合并这是 java 基础里数组、集合、事件三块知识最紧凑的练习场。下面给出一套能编译能运行的实现思路从棋盘模型、Swing 界面讲到 zip 交付时的目录和验证清单每一步都带代码和参数说明。2. 2048 核心算法棋盘、滑动合并与胜负判定2.1 棋盘建模为什么二维数组比集合类更合适先定数据模型。2048 的棋盘是 4×4每个格子存 0 或 2 的幂最常见的做法是int[][] grid new int[4][4]0 表示空格。选二维数组而不是 ArrayList 或 HashMap 的理由有两条一是索引即坐标渲染和判赢都不需要额外映射二是做撤销快照或 AI 搜索的结构拷贝时clone 一次就能完成性能开销可以忽略。更极端的方案是用一个 long 存全部 16 格每格 4 位用整数移位模拟滑动这在 2048 ai 的极小化博弈搜索里很常见每展开一个搜索节点就少一次 new int[4][4]。但对这个工程规模来说数组的可读性高得多也更容易写单元测试。我一般把数据、计分和随机源都收在 Game2048 类里界面类不直接碰数组public class Game2048 { public static final int SIZE 4; private final int[][] grid new int[SIZE][SIZE]; private final Random random; private int score; public Game2048(long seed) { this.random new Random(seed); // 固定种子便于测试复现 for (int i 0; i 2; i) spawnRandomTile(); } public Game2048() { this(System.nanoTime()); } }构造方法暴露 seed 是刻意的测试代码传固定值就能断言随机方块的确切落点生产环境用 nanoTime 保证每局不同。两个初始方块在构造里生成这是官方规则避免出现开局只有一个 2的尴尬。之后所有的移动、生成、判定都通过公开方法操作grid 字段保持 private防止视图层绕过规则直接改盘面。2.2 单行滑动的标准写法压缩、合并、再压缩移动的实质是对每一行或每一列做一次去零压缩、相邻相同合并、再压缩。难点在合并规则一次滑动里一个格子最多只能参与一次合并。很多初次实现的人用双重循环从头扫到尾结果[2, 2, 2, 2]一次左滑变成[8, 0, 0, 0]这是错的正确结果应该是[4, 4, 0, 0]因为四个 2 先要两两合并。我一般用一个 boolean 数组标记本行哪些位置已经合并过单次遍历完成一行private int[] slideLine(int[] line, int[] scoreHolder) { int[] out new int[line.length]; boolean[] merged new boolean[line.length]; int pos 0; for (int v : line) { if (v 0) continue; // 空格直接跳过 if (pos 0 out[pos - 1] v !merged[pos - 1]) { out[pos - 1] v 1; // 2 的幂左移一位等于乘 2 merged[pos - 1] true; // 锁定该格禁止连锁合并 if (scoreHolder ! null) scoreHolder[0] v 1; } else { out[pos] v; // 尚未配对落位 } } return out; }关键在merged[pos - 1] true这一行合并完成后立刻锁住目标格后面再遇到相同值只能落到新位置不能继续叠。v 1依赖 2048 所有格子都是 2 的幂这一事实比 v * 2 少一次乘法。scoreHolder 用长度为 1 的 int 数组绕过 Java 值传递的限制把本行合并产生的分数带回调用方界面不需要计分时传 null 即可。四个 2 左滑的完整过程是压缩后得 [2,2,2,2]第一个 2 落位第二个 2 与它合并成 4 并锁定第三个 2 落在下一个空位第四个 2 再与它合并成 4。最终 [4,4,0,0]符合官方规则。2.3 四个方向的统一处理抽行、滑动、写回单行逻辑做完四个方向的区别只是从哪抽数据、往哪写回。我准备两个方法extractLine 按方向把第 i 行或列读成一维数组writeBack 把滑动结果写回原棋盘。Direction 用枚举避免到处出现魔法数字public enum Direction { UP, DOWN, LEFT, RIGHT } public boolean move(Direction dir) { int[] scoreHolder new int[1]; boolean changed false; for (int i 0; i SIZE; i) { int[] line extractLine(dir, i); int[] after slideLine(line, scoreHolder); if (!Arrays.equals(line, after)) changed true; writeBack(dir, i, after); } if (changed) { score scoreHolder[0]; spawnRandomTile(); } return changed; }Arrays.equals负责判断本行有没有实际变化四行都没变就认为这次按键无效不生成新方块。这个细节直接决定棋盘无路可走时连按方向键会不会莫名刷出方块只有局面真的变了才允许随机生成这也是 2048 官方行为和很多劣质移植版的分水岭。extractLine 是纯粹的坐标搬运。比如 RIGHT 方向要从右往左读、从右往左写这样 slideLine 永远按靠左压缩、相邻合并的规则工作方向语义全部收敛在这段代码里private int[] extractLine(Direction dir, int i) { int[] line new int[SIZE]; for (int j 0; j SIZE; j) { switch (dir) { case LEFT: line[j] grid[i][j]; break; case RIGHT: line[j] grid[i][SIZE - 1 - j]; break; case UP: line[j] grid[j][i]; break; case DOWN: line[j] grid[SIZE - 1 - j][i]; break; } } return line; }writeBack 与此对称按同样的方向规则把结果写回 grid。这段没有做位运算优化可读性优先如果嫌 switch 在热路径上碍眼也可以写成先把棋盘旋转到统一方向滑动完再旋转回去四轮循环转四次坐标效果等价。单元测试把[2,2,4,0]之类用例在四个方向上各测一遍就能覆盖绝大多数边界靠边合并、中间合并、三连加空格、全空行。2.4 新方块生成、胜利与败北判定每次有效移动后生成一个新方块位置从所有空格里等概率抽取数值按官方参数 90% 出 2、10% 出 4private void spawnRandomTile() { Listint[] empty new ArrayList(); for (int r 0; r SIZE; r) for (int c 0; c SIZE; c) if (grid[r][c] 0) empty.add(new int[]{r, c}); if (empty.isEmpty()) return; int[] cell empty.get(random.nextInt(empty.size())); grid[cell[0]][cell[1]] (random.nextInt(10) 0) ? 4 : 2; }收集空格用 ArrayList 存坐标空格数量最多 16这个开销完全可以忽略。90/10 的比例决定游戏节奏如果改成 50/504 会刷得太多前期很快就需要大数管理如果全出 2游戏又会过于顺滑。游戏结束判定没法偷懒必须同时满足没有空格且任意相邻两格不相等否则玩家存在合并机会状态触发条件界面行为PLAYING初始状态或未触发任何终局条件继续监听按键WON任意格子值 ≥ WIN_VALUE弹提示可选继续挑战OVER无空格且无相邻相等弹提示按 R 重开胜利阈值定义成常量 WIN_VALUE 2048而不是在判赢代码里写死想改成 4096 玩地狱模式只需要动一处。到这一步Game2048 已经可以脱离界面单独跑随机方向 move 直到 OVER的模拟循环这是后面做 AI、做压力测试的基础。3. Swing 界面把核心逻辑跑成一个可操作窗口3.1 三层结构数据、绘制、输入各自为政写 Java 小游戏最容易把逻辑和绘制搅在一起paintComponent 里算移动、KeyListener 里改颜色、JFrame 里 new Random。代码一长就没人敢动。我固定用三层结构Game2048 只负责棋盘数据与规则BoardPanel 只负责把 grid 画出来输入监听独立成一个小类按键时调用 game.move再触发 panel.repaint()。这个结构带来的直接好处是核心逻辑可以在 JUnit 里测不用弹出窗口以后想换控制台版或者给 Android 复用规则层视图整个重写Game2048 一行不动。控制台调试版要避开的坑不少光标闪现、编码、退格键回显这些和算法无关的麻烦不会污染逻辑代码。小游戏代码的分层虽然听着重实际也就三个类比一个类从头写到尾好维护得多。3.2 最小 JFrame 与棋盘绘制窗口部分没有悬念JFrame 设标题、设默认关闭行为、把面板加进去。真正花功夫的是 paintComponent 里的绘制细节先把格子的几何算清楚public class BoardPanel extends JPanel { private final Game2048 game; private static final int GAP 8; private static final int BOARD 360; 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, BOARD, BOARD, 16, 16); int cell (BOARD - GAP * (Game2048.SIZE 1)) / Game2048.SIZE; for (int r 0; r Game2048.SIZE; r) for (int c 0; c Game2048.SIZE; c) drawCell(g2, GAP c * (cell GAP), GAP r * (cell GAP), cell, game.gridAt(r, c)); } }cell 尺寸由面板尺寸反推360 减去 5 个 8px 间距再除以 4得到 80px 的格子间距均匀地分布在格子两侧。改 BOARD 常量就能整体缩放窗口GAP 和圆角半径自动跟随。抗锯齿必须开否则数字和圆角都是锯齿观感直接回到 2005 年。drawCell 内部按值查颜色表0 画成空格底色非 0 画数字格子值背景色文字色0#CDC1B4不绘制2#EEE4DA#776E654#EDE0C8#776E658#F2B179#F9F6F216#F59563#F9F6F232#F67C5F#F9F6F264#F65E3B#F9F6F2128 及以上#EDC22E#F9F6F2颜色表源自 2048 官方的视觉规范从 2 的米色到 128 的金色逐级加深超过 2048 都复用金色。文字字号按位数递减一到两位用大号字体三位数缩小一档四位数再缩一档用 FontMetrics 量出字符串宽高后居中保证 131072 这种六位数不会画出格子的边界。字号与格子宽度联动cell / 2、cell / 3 这类比例写进一个 switch避免硬编码像素值。3.3 键盘输入别用 KeyListener用 Key Binding新手最常见的坑是注册了 KeyListener 却发现方向键没反应JPanel 默认不可聚焦实测必须 setFocusable(true) 再 requestFocusInWindow()窗口一旦有按钮或对话框抢焦点按键又全部失效。Swing 的 Key BindingInputMap / ActionMap能把绑定作用域设成 WHEN_IN_FOCUSED_WINDOW只要窗口处于激活状态焦点在哪都能响应InputMap im panel.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW); ActionMap am panel.getActionMap(); im.put(KeyStroke.getKeyStroke(UP), moveUp); im.put(KeyStroke.getKeyStroke(W), moveUp); im.put(KeyStroke.getKeyStroke(R), restart); am.put(moveUp, new AbstractAction() { Override public void actionPerformed(ActionEvent e) { if (game.move(Direction.UP)) panel.repaint(); } }); am.put(restart, new AbstractAction() { Override public void actionPerformed(ActionEvent e) { game.reset(); panel.repaint(); } });方向键和 WASD 绑定到同一个动作名 moveUp左右下三个方向照抄四份即可注意把方向参数换掉。move 返回 false 时不 repaint省一次绘制周期的开销也避免无效操作时画面闪烁。restart 动作里调用 game.reset()重置棋盘、分数和撤销栈这个动作可以再绑一个按钮做展示。四个方向的绑定名称各自独立方便日后在设置界面做按键自定义只需要改 InputMap 里的键值。4. 计分、动画与撤销从能玩到能看的三处打磨4.1 计分口径合并即时累加撤销要带回分数2048 官方的计分规则每次合并把合并结果累加到总分比如 22 得 4score 4。前面 slideLine 里的 scoreHolder 已经做了这件事。新手常见错误是只更新棋盘忘了累加玩到 512 分数还是 0另一种错误是把分数设计成取当前最大格子那和排行榜的最高分语义混了——游戏分应该是所有合并动作的累计值。我一般把当前局分数和历史最高分分开存score 是当前局的累计值best 只在游戏结束时比较一次。撤销时分数必须跟随棋盘一起回滚所以快照对象同时持有 grid 和 score少一个就会出现棋盘回去了分数还停在原地的错位。数据宽度上int 完全够用2048 收官局分数通常在几十万以内整个棋盘堆到理论极限也离 400 万不远不会溢出需要把多局分数累进同一个变量做排行统计时才建议用 long。4.2 滑动动画记录移动对按帧插值没有动画的 2048 像打地鼠——格子瞬间出现在目标位玩家感知不到滑动合并这个动作。常见做法是每次移动对比新旧棋盘识别出三类变化被合并的方块原地消失、移动的方块记录 from 和 to、新生成的方块从零放大。动画状态放在视图层public class AnimTile { public int fromR, fromC, toR, toC, value; public float progress; // 0.0 ~ 1.0 }move 执行前先用 deepCopy 存一份旧棋盘执行后逐格对比凡是值变化的位置就生成一条 AnimTile。绘制时按 progress 在 from 和 to 的像素坐标间线性插值progress 走完从队列移除。动画参数按下面这组值起步手感接近官方网页版参数建议值说明动画时长120ms短于 80ms 感知不到长于 200ms 挡操作刷新间隔16ms约 60 FPS即 javax.swing.Timer 的 delay进度推进1/8120ms 约 8 帧progress 每 tick 累加 0.125缓动函数ease-outprogress * (2 - progress)起速快收尾慢新方块缩放0.2 → 1.0出生带一点落子感有个新手常犯的错把动画状态塞进棋盘类。棋盘类应该和 UI 无关动画数组放 BoardPanel键用格子坐标而不是格子值——棋盘上同时存在两个 4 是常态用值做 key 会互相覆盖。另一个坑在连续快速按键上一次动画还没放完新 move 就来了旧 AnimTile 列表会和实际棋盘对不上。处理办法是每次 move 前清空动画队列直接跳到终态重算宁肯少一点过渡也不要鬼畜回弹。4.3 撤销栈与可控随机测试友好的两个小设计撤销是 2048 的标配实现上最省事的是快照栈每次有效移动前把当前 grid 深拷贝和 score 一起压栈撤销时弹栈恢复。栈容量限制在 32 步避免内存被无限快照吃光每份快照 4×4 int 加一个 int16 字节出头32 份完全可以忽略private final ArrayDequeSavedState undoStack new ArrayDeque(); private void pushSnapshot() { undoStack.push(new SavedState(deepCopy(grid), score)); if (undoStack.size() 32) undoStack.pollLast(); } public boolean undo() { if (undoStack.isEmpty()) return false; SavedState s undoStack.pop(); grid s.grid; score s.score; return true; }SavedState 用不可变对象最稳JDK 16 以上直接声明成 record旧版本就写一个 final 类字段只有 grid 和 score。注意快照只在移动成功后压栈move 返回 false 时不调用 pushSnapshot否则玩家连按三个无效方向键就白耗三步撤销额度。如果做了排行榜功能建议加一个 usedUndo 标记用了撤销就不更新最高分维持挑战性。随机数可控是容易被忽视的设计生产环境用 System.nanoTime() 做种子没问题但测试时要能复现这个 2 一定刷在右上角。构造方法暴露 long seed默认构造委托给 nanoTime测试传固定值断言才能稳定通过。90/10 的 2/4 比例也别改成 50/50刷 4 太频繁会显著改变节奏这个参数直接决定关卡手感。提示撤销键绑定建议用 CtrlZ和操作系统文本编辑习惯一致天然好记同时把 R重启放在一个不容易误触的位置误按整局重来是没有后悔药的。5. 附源码的 zip目录规范、编译验证与交付前检查附源码三个字是这类 zip 最大的卖点也是最大的雷。下载者解压后第一件事就是找 .java 文件直接 javac而编译失败的第一原因往往不是代码错而是目录结构不对源文件裸坐在 zip 根目录、没有包名、System.out 的中文编码乱掉。一个能让人顺利跑起来的 zip内部结构应该是这样2048-java/ ├── src/ru/bogdanov/g2048/ │ ├── Game2048.java │ ├── Direction.java │ ├── BoardPanel.java │ └── Main.java ├── README.md ├── build.sh ├── run.sh └── run.bat包的根目录是 src包名用反写的个人域名或机构名避免和别人的类冲突。Main 类放在包最外层README 第一屏写清三件事JDK 版本要求最低 8用了 record 就要求 16、编译命令、运行命令cd 2048-java javac -encoding UTF-8 -d out $(find src -name *.java) java -cp out ru.bogdanov.g2048.Main-encoding UTF-8必须写明否则 Windows 简体中文环境下源代码按 GBK 编译换台机器再用默认 UTF-8 解压界面汉字就会变成乱码。Windows 用户跑 run.bat批处理里用dir /s /b src\*.java生成文件列表再传给 javac 的 argfile 参数效果和 find 一致echo off dir /s /b src\*.java sources.txt javac -encoding UTF-8 -d out sources.txt java -cp out ru.bogdanov.g2048.Main交付前按下面这张清单过一遍比反复读代码更有效每一条都对应一个真实发生过的打不开案例检查项命令或操作通过标准zip 完整性unzip -t 2048-java.zip无 CRC 错误、全部文件列出编译通过javac -encoding UTF-8 -d out (文件列表)退出码 0无 error 输出运行不崩溃连续方向键操作 200 次无异常、无方块重叠四方向覆盖上下左右各按一遍移动方向与按键一致中文无乱码观察标题与对话框汉字正常显示zip 完整性这条最容易被忽视附源码压缩包经常因为传输中途损坏解压工具直接报 invalid zip archive 或解出一半就中断。解压前先unzip -t验一遍Windows 上没有 unzip 就用 JDK 自带的 jar 工具jar tf 2048-java.zip列出内容能列出就说明中心目录没坏。最后一条经验把unzip -t和javac -encoding UTF-8这两条命令写进 README 的最前面解压的人跟着三步走完五分钟内就能确认包是好是坏。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门