news 2026/10/15 5:37:14

JavaFX开发俄罗斯方块:游戏循环、碰撞检测与渲染实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JavaFX开发俄罗斯方块:游戏循环、碰撞检测与渲染实战

"尝试用JavaFX写个俄罗斯方块",这个标题看起来特别像一个练手级的小项目,但真动手做完了再回头看,我会说:它一点都不简单。倒不是方块逻辑有多难,而是它把一个游戏引擎该有的东西全逼着你碰了一遍——数据模型、渲染管线、主循环、输入处理、状态管理,哪个环节偷懒,后面调试的时候就会加倍还回来。这篇复盘就把我整个过程中的设计取舍、核心代码、踩过的坑都写出来,给同样想用JavaFX做游戏或者想把经典游戏逻辑彻底吃透的朋友一个可以照着走的参考。

1. 整体设计与思路拆解

1.1 为什么选 JavaFX 而不是 Swing

如果你问一个写过桌面程序的Java开发者,大部分人第一反应是Swing。Swing胜在JDK内置、资料多,但它本质是一套AWT时代的组件架构,做游戏时有两个硬伤:一是刷新机制依赖事件驱动的组件重绘,二是它没有一个统一的高频帧循环概念。你可以强行用Timer去刷,但界面一复杂,闪烁和撕裂就来了。

JavaFX就不一样,它从设计上就是为"渲染型应用"准备的。JavaFX拥有一个完整的图形管线,Canvas节点可以直接按像素绘制,还有专门为动画和游戏设计的AnimationTimer,它绑定在JavaFX渲染线程上,每秒最多触发60次,天然解决了我最担心的"循环和UI协调"问题。再加上JavaFX的节点树、样式表、属性绑定这些机制,写俄罗斯方块这种网格类游戏,代码量可以压得很低,后期想美化界面也容易。

图形渲染这块,我见过不少教程用GridPane加上Rectangle来搭游戏区。10x20的网格,每个格子放一个Rectangle节点,静态界面没问题,一旦每个下落帧都要更新200个节点的属性和样式,场景图就开始吃力了。我的选择是直接用Canvas,每一帧手动绘制当前状态,这样渲染开销最小,后期加拖尾、高亮这些特效也好处理。

1.2 架构先想清楚再动手

很多新手写小游戏容易犯一个毛病:把游戏逻辑全部塞进UI类里。俄罗斯方块虽然小,但至少有这几块职责:游戏区域数据、方块形状、规则判定(旋转、碰撞、消行)、计分和等级、界面渲染、键盘输入。如果全堆在一起,改一个旋转逻辑要翻了几百行代码才能找到位置。

我的做法是分成三层:

  • 纯逻辑层:不依赖任何JavaFX类,管棋盘矩阵、方块形状、碰撞检测、消行。这一层可以直接单元测试。
  • 控制器层:管理主循环、游戏状态机、输入事件转发。
  • 视图层:负责Canvas绘制、分数展示、下一个方块预览。

分层的直接好处是,出Bug的时候可以先判断是规则问题还是绘制问题。比如方块卡在墙里,如果纯逻辑层测试通过,那问题一定出在渲染坐标换算上,排查范围一下就缩小了。

2. 核心数据结构与游戏规则实现

2.1 棋盘和方块怎么建模

经典俄罗斯方块的游戏区是10列20行,我的棋盘用一个二维数组表示,数组存的是格子是否有方块占据:

public class Board { public static final int COLS = 10; public static final int ROWS = 20; private int[][] grid = new int[ROWS][COLS]; // 0 表示空,非0表示该位置由某个颜色的方块占据 }

用 int[][] 而不是 boolean[][],是因为之后要按方块类型上色,存类型编号省得再维护另一张颜色表。

七种基本方块每种都用一个小矩阵来表示。这里有个常见写法,可以直接用三维数组:

private static final int[][][] SHAPES = { {{1, 1, 1, 1}}, // I {{1, 1, 1}, {0, 1, 0}}, // T {{1, 1, 0}, {0, 1, 1}}, // Z {{0, 1, 1}, {1, 1, 0}}, // S {{1, 1}, {1, 1}}, // O {{1, 0, 0}, {1, 1, 1}}, // J {{0, 0, 1}, {1, 1, 1}}, // L };

方块在棋盘上的位置用一个Point(currentX, currentY)表示,这里的坐标是"矩阵左上角在棋盘上的坐标"。注意,这个坐标不是像素坐标,而是棋盘格子坐标,绘制的时候要乘以格子边长再换算成像素。这是一个很关键的约定,我在下面碰撞检测那里会再强调。

每个活动方块我封装成一个类,包含形状矩阵、当前坐标、当前旋转状态。这样从"下一个方块"预览到"正在下落的方块",切换就只是把类实例替换一下,非常干净。

2.2 碰撞检测和旋转的细节

碰撞检测的核心思路:把当前活动方块矩阵里的每一个非零格子,映射到棋盘坐标上,然后检查这些坐标是否越界或者与已有格子重叠:

private boolean collides(int[][] shape, int x, int y) { for (int row = 0; row < shape.length; row++) { for (int col = 0; col < shape[row].length; col++) { if (shape[row][col] == 0) continue; int boardX = x + col; int boardY = y + row; if (boardX < 0 || boardX >= Board.COLS || boardY >= Board.ROWS) return true; if (boardY >= 0 && grid[boardY][boardX] != 0) return true; } } return false; }

这里有两个细节值得提。一是 boardY >= 0 才检测棋盘内容,因为方块刚生成时有一部分可能还在棋盘上方(比如I方块在顶端生成时只显示一部分),那是正常现象。二是越界判断分成左右下三个方向,但没有限制"上方越界",因为游戏结束后方块还没完全落下来就被判定为顶住了,这是游戏规则的一部分。

旋转的实现就是矩阵转置加水平翻转,这是标准做法:

private int[][] rotate(int[][] shape) { int rows = shape.length; int cols = shape[0].length; int[][] rotated = new int[cols][rows]; for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { rotated[j][rows - 1 - i] = shape[i][j]; } } return rotated; }

旋转有个著名的坑:方块贴墙的时候旋转会撞墙,但玩家直觉上会觉得"应该能转过去",否则操作手感很差。解决办法很朴素,叫墙踢。尝试旋转后如果发生碰撞,就把方块往左挪一格试,不行再往右挪一格,再不行就保持原样。我实际测试下来,这个简单版的墙踢能覆盖90%的场景,但对于I方块在左右边界旋转这类极端情况,简单版会失效。真要想做到完美,得参考现代俄罗斯方块的SRS(Super Rotation System)标准,加上踢腿表。我在这次项目里先用简单版,够玩了。

消行的逻辑看起来简单,却是我写Bug最多的地方。正确做法是从下往上扫每一行,如果整行非空,就把这一行移除,再把上面所有行下移一格。但我一开始用了for循环边遍历边删除,结果连续消多行时索引错乱。后来改成从下往上、用while循环处理:

for (int row = ROWS - 1; row >= 0; row--) { if (isFull(row)) { removeRow(row); row++; // 因为行被下移了,要重新检查当前行 } }

这条经验可能价值最高:涉及数组删除的场景,循环方向和控制变量的调整一定想清楚再写,不然消三行变消一行、甚至出现数组越界。

3. 实操过程与核心环节实现

3.1 工程初始化和界面骨架

JavaFX应用入口是继承Application类,start方法里搭舞台和场景。我的布局用BorderPane:中间是Canvas画布,右侧是一个VBox放分数、等级和下一个方块预览区。预览区我用另一个小Canvas,画起来直观省事。

初始化阶段有这么几件事:

private static final int CELL_SIZE = 30; private static final int CANVAS_WIDTH = Board.COLS * CELL_SIZE; private static final int CANVAS_HEIGHT = Board.ROWS * CELL_SIZE; Canvas canvas = new Canvas(CANVAS_WIDTH, CANVAS_HEIGHT); GraphicsContext gc = canvas.getGraphicsContext2D();

格子大小选30像素,整个游戏区正好300x600,视觉上比较紧凑,也不会太占屏幕。格子大小不要一开始定死,至少留一个常量在类顶部,方便后期接高分模式和主题皮肤调整。

方块的颜色我给每种类型配了一个固定值,绘制的时候根据grid中存的类型编号取颜色。这里可以把颜色做成数组,下标对应类型编号,绘制循环里一次查表搞定,效率比switch分支高,代码也短。

3.2 游戏主循环用AnimationTimer

主循环是整个游戏的中枢,它负责定时让方块下落一格、然后触发重绘。JavaFX提供的AnimationTimer非常契合这个用法:

AnimationTimer gameLoop = new AnimationTimer() { private long lastDropTime = 0; @Override public void handle(long now) { if (gameState != GameState.PLAYING) return; int level = score.getLevel(); long dropInterval = dropIntervals[Math.min(level, maxLevel)]; if (now - lastDropTime >= dropInterval) { stepDown(); lastDropTime = now; } render(); } };

这里的now是纳秒时间戳,dropInterval也用纳秒存,比较起来才不会有单位不一致的低级错误。我见过有人直接用两次handle之间的时间差累加来判断下落时机,写法也没问题,但要特别注意第一次调用时lastDropTime为零,需要先赋值,否则会触发一次瞬移。

速度曲线可以简单设计成每升一级缩短间隔的百分比,比如每级缩短8%,感受比较平滑。我用的公式:dropInterval = BASE_DROP_INTERVAL * Math.pow(0.92, level)。实际测下来,1秒起始间隔到后面每100多毫秒一次下落,节奏比较合理,不会一开始就像开了倍速。

渲染方法里每帧先clearRect清空画布,然后分两层绘制:先画棋盘里已经固定的格子,再画当前活动方块。固定格子的循环是ROWS乘COLS共200次判断,每帧最多200次fillRect,对Canvas来说毫无压力。

3.3 键盘交互和操作手感

操作手感是俄罗斯方块好不好玩的分水岭。我第一版把按键处理和游戏逻辑放在一个方法里,方向键一按就移动一格,结果玩起来特别"生硬",因为按住方向键没有连续移动的效果,也没下落加速。

JavaFX处理键盘输入是给scene设置onKeyPressed和onKeyReleased事件。我最终的按键映射是:左/右移动,上旋转,下软降(每按一次快速下落一格),空格硬降(直接落到底)。代码骨架:

scene.setOnKeyPressed(event -> { switch (event.getCode()) { case LEFT: moveLeft(); break; case RIGHT: moveRight(); break; case DOWN: softDrop(); break; case UP: rotate(); break; case SPACE: hardDrop(); break; case P: togglePause(); break; } event.consume(); });

按住方向上自动连续移动,就是经典的DAS(Delayed Auto Shift)机制。我在keyPressed里记录按键时间,keyReleased里清除状态,然后在游戏循环的每一帧里检查"左/右是否还在按住状态",如果按住时间超过初始延迟就每帧自动移动一格。这样手感才接近真正的街机版。软降则不需要DAS的延迟,因为玩家主要是连点触发。

硬降的实现要先计算当前方块能下落的最大距离,然后再移动+锁定+消行。这个距离计算本质上是向下碰撞检测的循环版,我封装了一个dropDistance方法,避免和stepDown里的逻辑重复。

3.4 渲染细节与下一个方块预览

固定格子的绘制我优化过一版。最初我的render方法每帧都创建JavaFX的Color对象和循环变量,玩久了偶尔能感觉到GC压力。后来把所有颜色预生成Color[]数组,绘制方法里只做查表,对象创建降到最少。

下一个方块预览,我在右侧面板上用另一个小Canvas画。逻辑上就是把一个单独的Block实例矩阵画到4x4的格子里,居中显示。注意预览方块的下落位置和实际生成位置保持一致,否则玩家会困惑"预览是这个形状怎么出来是另一个位置"。方块生成时x坐标统一放在棋盘正中间偏左半格,y坐标放在棋盘顶部之上,这样每种方块都能在可见区域露出至少一行。

4. 常见问题与排查技巧实录

4.1 按键没反应或者丢失焦点

我第一版跑起来,鼠标点了Canvas之后按键全部失灵。原因很直接:JavaFX的KeyEvent被分发给当前拥有焦点的节点,Canvas默认不接收焦点,事件就丢了。网上很多教程会告诉你给scene设onKeyPressed,但光是设了还不够,如果你的界面上有按钮、输入框这类控件,它们的焦点会抢走键盘事件。

我的解决方案是在start方法里直接调用stage.getScene().getRoot().requestFocus(),让根节点拿回焦点。更稳妥的做法是界面上别放能抢焦点的控件,必要的按钮点击后也要手动把焦点交还给游戏场景。这个坑非常隐蔽,排查的时候容易怀疑人生,我一度以为是JavaFX的bug。

4.2 旋转穿墙和贴墙旋转

旋转逻辑本身没什么难度,但应用旋转后立即做碰撞检测,如果碰了就直接撤销,就会导致玩家在墙边按旋转"没反应",体验很差。加上墙踢之后好多了,但还有穿墙的隐患:方块贴墙时旋转,如果只做"旋转后检测碰撞就拒绝",那么旋转后有一部分矩阵越界却被忽略,视觉上看起来像穿了半截墙。

这个问题的本质是碰撞检测逻辑里,边界判断和重叠判断的顺序。边界判断必须先于重叠判断,而且越界要返回碰撞,不能跳过。我在collides方法里已经处理了这一步,但一开始写成"格子越界就忽略该格子"的错误逻辑,出来的效果就是方块插进墙里。

4.3 消行时的索引混乱

前面提到的边遍历边删除问题,我单独在这里再说一次。当时我写的是:

for (int row = ROWS - 1; row >= 0; row--) { if (isFull(row)) { removeRow(row); } }

这个版本的问题是,removeRow会把上面所有格子往下移一行,而循环变量继续往下走,导致被移动下来的那一行没被检查,连续消除时总是漏掉。修正的关键就是那行row++。这也是数组遍历配合增删操作时的经典陷阱,做任何矩阵类游戏都会遇到,记住这个教训,后面写2048这类滑块游戏也用得上。

4.4 高DPI屏幕和窗口缩放

还有一个日常开发注意不到的问题:在高分屏上,Canvas画出来的格子变模糊。JavaFX默认感知屏幕缩放比例,但如果你用了物理像素尺寸来初始化Canvas,会出现渲染模糊。解决方式是让Canvas尺寸和布局按缩放值换算,或者干脆在CSS里使用font-size等逻辑单位。我的做法是把Canvas的width/height设置为逻辑尺寸,然后调用canvas.setScaleX/setScaleY设置缩放,绘制时GraphicsContext会自动适配。

4.5 调试状态机的技巧

游戏状态机我用了枚举:READY、PLAYING、PAUSED、GAME_OVER。最开始我没有把状态机分离出来,导致暂停功能做得很乱:按P暂停,再按P继续,但下落计时和键盘处理全混在一起,时不时还会出现"暂停状态还能硬降"的怪现象。

后来我把所有按键处理的开头都加上一句状态判断,非PLAYING状态就忽略,只有恢复键和菜单键例外。这样逻辑清晰很多。排查状态相关的Bug时,我习惯在每个状态转换处打一行日志,运行一遍就能看到完整的转换链,比盯着代码猜快得多。

结尾

这个项目做完,我最深的感受是:俄罗斯方块代码量不大,但它是"麻雀虽小五脏俱全"的典型代表。真正的难点从来不是7种方块和200个格子,而是怎么把游戏循环、输入响应、碰撞判定这些模块组合成一个手感自然的整体。JavaFX在这个项目里表现得比我想象的好很多,AnimationTimer和Canvas的组合足够流畅,不用碰任何多线程的东西就能跑出稳定的游戏体验。

最后再分享一个小技巧:调试下落速度和消行手感时,别用实际游戏慢慢试,直接在初始化阶段设一个调试键,按一下提升一级速度,按另一键立刻填满靠下五行。这样几秒钟就能测试高等级下的表现,比每次从头玩效率高得多。后续如果你想把它做得更完善,还可以加幽灵方块预测落点、7-bag洗牌算法保证方块分布均匀、保存最高分等功能,这些每一样都是很好的练习方向,这个项目的框架也完全容得下它们。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 5:37:08

用Python手写命令行待办应用:从需求分析到原子写入实战

上周末我又一次打开手机里的待办软件时&#xff0c;突然意识到自己已经被它绑架了&#xff1a;启动要等加载&#xff0c;添加任务要点好几个按钮&#xff0c;跨设备同步偶尔还要闹脾气。可我就想要一个三秒能记录、文件能直接备份、在命令行里随时能调的待办清单。纠结了半天&a…

作者头像 李华
网站建设 2026/10/15 5:37:07

Tkinter实战指南:Python自带GUI库打造桌面小工具

很多人听到Tkinter的第一反应是"太老了""界面丑""现在谁还用这个"。但我在实际项目里遇到过不止一次这样的场景&#xff1a;某个内部团队急需一个带界面的小工具来处理数据&#xff0c;没有前端基础&#xff0c;不能让每个人去配节点环境&#x…

作者头像 李华
网站建设 2026/10/15 5:37:07

基于ARIMA的电价预测:置信区间计算与Matlab实现详解

去年帮一个课题做短期电价预测&#xff0c;拿到数据第一眼我就觉得这玩意儿跟心电图似的&#xff0c;忽高忽低毫无规律。但真正把序列拆开看&#xff0c;你会发现每天同一时段的电价之间有很强的“记忆”&#xff0c;昨天的低谷和昨天的尖峰&#xff0c;都会以某种形态在今天重…

作者头像 李华
网站建设 2026/10/15 5:36:48

提示词库系统化整理:从随手问到工程化调用的高效实践

1. 提示词库为什么值得系统化整理1.1 从“随手问”到“工程化调用”的认知转变大多数人用对话式AI的方式还停留在“随手问”阶段&#xff1a;打开对话框&#xff0c;敲一句“帮我写个周报”&#xff0c;然后对着生成结果挑挑拣拣&#xff0c;改半天还不如自己写。问题不出在模型…

作者头像 李华
网站建设 2026/10/15 5:36:46

对称加密与非对称加密:原理、选型与工程实践

1. 项目概述与核心思路拆解1.1 加密是什么&#xff0c;我为什么会研究这个先聊聊我为什么会专门写一篇关于对称加密和非对称加密的文章。这几年做后端开发&#xff0c;接触过的系统和数据链路越来越多&#xff0c;发现一个很现实的问题&#xff1a;很多开发者对加密的理解停留在…

作者头像 李华
网站建设 2026/10/15 5:36:01

LLM request timed out排查指南:OpenClaw接入第三方API超时解决方案

LLM request timed out 这个报错&#xff0c;接第三方 API 的朋友应该不陌生。OpenClaw 本身是个开源智能体框架&#xff0c;跑起来很轻&#xff0c;但一旦把模型源切到第三方兼容接口&#xff0c;超时就像定时炸弹一样时不时爆一下。这篇文章把我自己调通的全过程写出来&#…

作者头像 李华