☰
俄罗斯方块交互控制与事件处理:从按键到虚拟按钮的实战指南
2026/10/1 19:33:52 网站建设 项目流程

之前两篇文章我们把 10x20 的棋盘、七种方块、渲染和下落逻辑都跑通了,方块会自己往下掉,碰到底部或已落定的块也会停住。但玩两秒你就会发现,没有交互的俄罗斯方块只能算“演示程序”:你完全控制不了它,只能眼睁睁看着它堆到顶。这一篇要做的,就是把“演示程序”变成“能玩的游戏”,核心就两个词:交互控制、事件处理。

我会从按键、手势、虚拟按钮三个输入通道讲起,然后落到游戏引擎里的统一动作分发、碰撞预判和旋转踢墙处理。本篇不重复讲棋盘和渲染,如果你还没看过系列前两篇,建议先把前面的环境搭建和基础渲染流程跑一遍,再来看控制层会顺很多。代码我尽量给全,思路也会讲透。

1. 交互层设计:先理清输入、动作和状态的关系

1.1 俄罗斯方块到底需要哪些操作

别小看这一步,很多新手一上来就写按键监听,结果代码里全是 if,玩起来却别扭得要命。先想清楚:一个完整的俄罗斯方块控制,至少需要六个动作。

  • 左移:把当前方块往左挪一格
  • 右移:往右挪一格
  • 旋转:顺时针转 90 度
  • 软降:方块加速往下落,每落一格得 1 分
  • 硬降:方块瞬间落到能落的最低位置,立刻锁定,每落一格得 2 分
  • 暂停/继续:随时能把游戏暂停下来

这六个动作是底线。有些移植版本还会加“下一块预览”或“暂存方块”,那是进阶功能。先把底线做好,手感顺了再谈扩展。

这里的本质是:输入只是“产生意图”,游戏引擎才是“做出裁决”的地方。所以写代码时必须把这两个层面彻底分开——键盘、手势、按钮只是三个不同的“入口”,它们最后都指向同一套动作分发逻辑。否则你会有三份到处重复的 if-else,改规则的时候改到怀疑人生。

1.2 为什么 OpenHarmony 上必须做多输入通道

做过 OpenHarmony 适配的都知道,这套系统跑在手机、平板、电视盒子、开发板、带触摸屏的工控屏上都有,形态差异极大。你的应用只在模拟器上跑当然无所谓,但一旦装上真机,就会发现:

  • 带键盘的跑起来,用户会自然想按方向键
  • 纯触摸屏设备上,用户会找你屏幕上的控制按钮
  • 平板用户可能会外接键盘,也可能直接上手点

所以双输入通道不是炫技,是适配不同设备形态的基础操作。好消息是 Flutter 把底层的输入抽象统一了,键盘、触摸、鼠标在 Dart 层的 API 是一致的,不需要为 OpenHarmony 单独维护一套输入逻辑。这一点是 Flutter 做跨端时最值钱的部分之一。

1.3 统一动作入口:一个枚举就解决三路输入

我习惯的做法,是定义一个动作枚举,再让引擎对外暴露一个 dispatch 方法。所有输入通道只负责把“用户意图”翻译成 enum,具体怎么移动、怎么旋转、能不能移动,全由引擎判断。这其实就是命令模式的一个简化版。

enum GameAction { moveLeft, moveRight, rotate, softDrop, hardDrop, togglePause, }

后续键盘回调、手势回调、按钮的 onPressed,全部调同一个方法:

void onAction(GameAction action) { setState(() { engine.dispatch(action); }); }

这样做的另一个好处是调试方便。真机上手势误触、按键冲突时,你在 dispatch 入口打一行日志,就能看到玩家到底按了什么,不用在多个回调里来回猜。

2. 事件系统底层:手指和按键是怎么变成游戏指令的

2.1 指针事件的完整链路

Flutter 里触摸事件不是在 Widget 上直接“绑定”的。底层机制是:系统把原始指针事件(PointerDown、PointerMove、PointerUp)传给 Flutter 引擎,引擎先做命中测试(HitTest),找到哪些 Widget 位于手指位置,再把这些原始事件分发给对应的 Listener。

GestureDetector 是建立在 Listener 之上的高级封装。它会收集一堆原始 Pointer 事件,然后用一个叫手势竞技场(Gesture Arena)的判定机制,分辨用户到底是点了、滑了、还是长按了。这个机制你可以理解为几个“裁判”在抢一个案子:手指一碰,TapRecognizer、DoubleTapRecognizer、LongPressRecognizer、DragRecognizer 全都跳出来说“我来判”,谁符合手势特征谁就最终胜出。

这也是为什么我建议键盘和手势分开处理:手势需要竞技场判定,天然带延迟;键盘事件没有竞技场,按下就是按下,响应更快。

2.2 手势竞技场:为什么手势会有“抢”的问题

如果你把多个手势塞进同一个 GestureDetector,比如又加 onTap 又加 onDoubleTap,那么第一次点击后,系统会等一小段时间,确认没有第二次点击才把 onTap 判给“单击”。这个延迟大约 300 毫秒。玩俄罗斯方块时,如果你把“旋转”绑定在单击上,人就会觉得稍微有点钝。

解决思路有两个。第一,接受延迟,把旋转这种不追求极端手感的操作放上去。第二,别把单击和双击放一起,改用虚拟按钮或上滑手势来替代。我实际测下来,最舒服的组合是:单击旋转,双击硬降。旋转虽有延迟但可接受,硬降本身就是略微沉思一下才按的操作,等那 300 毫秒反而安全。

2.3 键盘事件的焦点机制

Flutter 里键盘事件不是谁都能收到的,只有焦点树(Focus Tree)里当前获得焦点的节点才优先处理。这跟触摸事件“点哪哪响应”完全不同。

实现键盘监听最常用的方式,是给游戏区域包一层 Focus,然后绑定 onKeyEvent:

Focus( autofocus: true, focusNode: _focusNode, onKeyEvent: _onKeyEvent, child: gameWidget, )

这里必须重视 autofocus 和 focusNode。游戏页面一加载,焦点必须立刻落在游戏区域上,否则玩家按方向键系统根本不理你。页面里如果还有其他按钮、输入框,它们会抢焦点。我用过的一个最让人崩溃的 bug 场景是:弹了个“游戏结束”对话框,玩家点掉之后焦点跑到对话框按钮上了,游戏键盘从此失灵,重启应用才恢复。

所以每次弹窗关闭、或者游戏状态切换后,要主动拉回焦点:

_focusNode.requestFocus();

这是俄罗斯方块键盘控制里最容易踩、也最隐蔽的一个坑。

3. 游戏主循环:让方块按节奏下落

3.1 用 Timer.periodic 驱动节拍

俄罗斯方块的下落节奏,本质上是一个定时器在“打节拍”。Flutter 里最直接的做法就是用 Timer.periodic。这个计时器在 UI 线程上运行,到点执行回调,回调里让当前方块下落一格。

_fallTimer?.cancel(); _fallTimer = Timer.periodic( Duration(milliseconds: currentDropIntervalMs), (_) { setState(() => engine.tick()); }, );

别用 while(true)+Future.delayed 那类写法,一来在 UI 线程里就是死循环卡死,二来也不好随时取消和改变节奏。Timer.periodic 可以随时 cancel 再重建,天然适合“暂停”和“加速”需求。

engine.tick 是引擎的“心跳”方法,专门处理自动下落这一件事:

void tick() { if (_paused || _activePiece == null) return; if (_canMove(0, 1)) { _activePiece.moveBy(0, 1); _updateShadow(); // 如果做投影地板的话 } else { _lockActivePiece(); // 锁定当前方块 } notifyListeners(); }

注意 tick 里不应该做输入控制的事,输入控制的动作都在 dispatch 里。两个入口分工明确:tick 管“自动落下”,dispatch 管“玩家主动操作”。

3.2 下落速度与动态调速

很多版本把“难度上升”做成固定的每十行加速。我用的公式比较简单直接:

int level = _completeLines ~/ 10 + 1; int interval = (800 - (level - 1) * 80).clamp(100, 800);

1 级时 800 毫秒一格,到第 8 级就只有 240 毫秒,再往上封顶 100 毫秒。这个参数不是标准,但手感接近经典版本节奏。加速时,需要把旧的 Timer 取消再新建一个新的 Timer.periodic,否则旧的还在跑,两个定时器一起 tick,方块会一次掉两格,非常鬼畜。

计时器重建的时机一定要小心,我建议封装一个方法:

void _restartFallTimer() { _fallTimer?.cancel(); _fallTimer = Timer.periodic( Duration(milliseconds: currentDropInterval), (_) => setState(() => engine.tick()), ); }

消行、暂停恢复、新方块生成这三处地方,都调用它。否则很容易出现在某个分支忘了重建计时器,游戏直接卡死不动的情况。

3.3 软降与硬降的本质区别

软降和硬降字面就差一个字,实现完全不同。

软降是“把下降间隔临时缩短”,其实我的实现更简单:直接让方块移动一格,并给 1 分。如果玩家一直按住下落键,那就是持续触发软降,靠的是按键连发机制,跟 Timer 无关。

硬降则是“计算当前方块再往下还能落多少格”,一次性移动到位并立刻锁定。这个距离怎么算?从当前 y 开始,逐行向下尝试,直到碰撞发生,记录到达的最低位置。

int hardDropDistance() { int distance = 0; while (_canMove(0, distance + 1)) { distance++; } return distance; }

硬降之后要马上锁定、消行、生成新方块,而不能等下一次 tick。也就是说 hardDrop 这个动作在 dispatch 里要打完一整个“落地-锁定-结算”流程,不能只把方块加速滑到底就了事。

4. 键盘控制实现:方向键连发是手感关键

4.1 给游戏界面挂上焦点监听

先把整个游戏区域用 Focus 包起来,并保证页面展示时焦点稳稳落在游戏区。

KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { if (event is KeyDownEvent || event is KeyRepeatEvent) { return _handleGameKey(event.logicalKey); } return KeyEventResult.ignored; } KeyEventResult _handleGameKey(LogicalKeyboardKey key) { if (key == LogicalKeyboardKey.arrowLeft) return _doGameAction(GameAction.moveLeft); if (key == LogicalKeyboardKey.arrowRight) return _doGameAction(GameAction.moveRight); if (key == LogicalKeyboardKey.arrowUp) return _doGameAction(GameAction.rotate); if (key == LogicalKeyboardKey.arrowDown) return _doGameAction(GameAction.softDrop); if (key == LogicalKeyboardKey.space) return _doGameAction(GameAction.hardDrop); if (key == LogicalKeyboardKey.keyP) return _doGameAction(GameAction.togglePause); return KeyEventResult.ignored; } KeyEventResult _doGameAction(GameAction action) { setState(() => engine.dispatch(action)); return KeyEventResult.handled; }

返回 handled 很重要。方向键、空格键在系统里一般还有默认用途,比如焦点移动、页面滚动。你处理掉之后应该告诉框架“这个按键我已经消费了,别再往上层传”。否则在电视盒子上按方向键,焦点还会在系统层跳来跳去,游戏和系统打架。

4.2 为什么 KeyDownEvent 和 KeyRepeatEvent 都要处理

物理键盘按住一个键不放,系统会持续发送“重复按键”事件。在 Flutter 里,第一次按下是 KeyDownEvent,后续重复是 KeyRepeatEvent。如果不处理 KeyRepeatEvent,玩家按住右键就只能动一格,玩起来像僵尸。

有些平台、有些版本的 Flutter 引擎对重复按键的支持并不可靠。OpenHarmony 的 Flutter 引擎经过多次迭代,重复事件的支持情况在不同版本上有差异。所以我的建议是双保险:代码里两个事件都接,同时还要自己做一套“手动连发”兜底。

4.3 手动连发兜底方案

既然系统重复事件不可靠,那就自己在按下和抬起之间维护一个连发定时器。按下左箭头时,立刻移动一格,然后启动一个 120 毫秒的周期定时器持续移动;松开按键或焦点丢失时,取消定时器。

Timer? _autoRepeatTimer; void _startAutoRepeat(GameAction action) { _autoRepeatTimer?.cancel(); _autoRepeatTimer = Timer.periodic(Duration(milliseconds: 120), (_) { setState(() => engine.dispatch(action)); }); } void _stopAutoRepeat() { _autoRepeatTimer?.cancel(); _autoRepeatTimer = null; }

按下时处理逻辑:

if (key == LogicalKeyboardKey.arrowLeft) { _doGameAction(GameAction.moveLeft); _startAutoRepeat(GameAction.moveLeft); return KeyEventResult.handled; }

在 KeyUpEvent 里调用 _stopAutoRepeat,不要等下一次按键才取消。还有焦点离开游戏区、页面失活的时候也要取消,否则可能按键都松开了,方块还在自动往一边跑,这个 bug 非常难查。

另外,连发定时器只对左移、右移、软降有意义,旋转和硬降不需要连发。旋转连发会导致方块连续转好几圈,硬降连发更是灾难——一个方块刚落地就被重复硬降指令反复触发。这两个动作按下一次处理一次即可。

5. 触摸滑动与虚拟按键:纯触摸设备的体验

5.1 滑动手势的绑定

触摸设备上没有物理键盘,最常见也最顺手的是滑动和点按组合。我给触摸设备定的一套标准方案是这样的:

  • 左右滑动:左右移动方块
  • 点击:旋转
  • 下滑:软降
  • 双击:硬降

实现起来直接用 GestureDetector,把这些回调都接上:

GestureDetector( onTap: () => onAction(GameAction.rotate), onDoubleTap: () => onAction(GameAction.hardDrop), onHorizontalDragUpdate: (details) { if (details.delta.dx > 2) { onAction(GameAction.moveRight); } else if (details.delta.dx < -2) { onAction(GameAction.moveLeft); } }, onVerticalDragEnd: (details) { if (details.primaryVelocity! > 300) { onAction(GameAction.softDrop); } }, child: boardWidget, )

细心的会注意到我用的是 onHorizontalDragUpdate 而不是 onPanUpdate。因为 Flutter 手势竞技场会把横向拖动和纵向拖动分开,onPan 是“非特定方向”,和 onVerticalDragEnd 放在一起时容易抢手势。横向拖动专管左右,纵向手势只看松手时的速度,各自职责明确,少打架。

方案里我没用上滑做旋转,是因为竖滑在手势竞技场里和横向滑动是分隔的,但上滑和“玩家想快速把方块扔下去”的下滑意图容易混。既然已经有点击旋转,上滑那个口子不如留给误触空间。

5.2 滑动阈值的手感调试

onHorizontalDragUpdate 里移动阈值的选取,直接影响游戏手感。设太小,手指稍微一动方块就飞好几格;设太大,玩家要划半条屏幕才能挪一步。

我用的是 6 像素阈值,配合 30 像素总位移之后才触发一次移动。简单实现就是记录初始触点:

Offset _dragStart = Offset.zero; onHorizontalDragStart: (d) => _dragStart = d.localPosition, onHorizontalDragUpdate: (d) { final offset = d.localPosition.dx - _dragStart.dx; if (offset.abs() > 30) { _dragStart = d.localPosition; if (offset > 0) { onAction(GameAction.moveRight); } else { onAction(GameAction.moveLeft); } } }

这种“位移累计到阈值才触发一次”的做法,比 delta.dx 直判稳定得多。手指哪怕有微小抖动,也不会一抖就挪一步。

5.3 虚拟按钮布局与防误触

光靠滑动,玩家没法很精细地操作。俄罗斯方块玩到后期,手忙脚乱时你需要一个硬朗的按钮面板。我在游戏棋盘下方或两侧放了四个按钮:左移、右移、旋转、硬降。

实现很简单,按钮的 onPressed 全走统一动作分发:

Row( children: [ _ControlButton(label: '左移', onTap: () => onAction(GameAction.moveLeft)), _ControlButton(label: '旋转', onTap: () => onAction(GameAction.rotate)), _ControlButton(label: '右移', onTap: () => onAction(GameAction.moveRight)), _ControlButton(label: '硬降', onTap: () => onAction(GameAction.hardDrop)), ], )

但按钮的命中区域和手势检测区域要分开。比如棋盘本身用 GestureDetector 响应滑动,按钮用 Material 的 InkWell 响应点击。如果按钮区域叠在棋盘上方,玩家在棋盘上滑动,手势竞技场里既有水平滑动又有按钮点击,双方会互相干扰。初期我吃过这个亏:按住右移按钮想连发,结果系统认为是按键点击,只动一格。

虚拟按钮也需要“长按连发”机制。InkWell 自带的 onTap 只有一次点击。我的做法是给按钮单独封装一个 LongPressDown 和 LongPressUp 的检测器:

GestureDetector( onTap: () => onAction(GameAction.moveLeft), onLongPressStart: (_) => _startAutoRepeat(GameAction.moveLeft), onLongPressEnd: (_) => _stopAutoRepeat(), child: buttonWidget, )

这样点击一下是单步移动,长按则是连续移动,手感接近物理键盘的连发。

6. 动作合法性判断:每次移动都要“过堂”

6.1 移动前的碰撞预判

交互控制的精髓不在“按了就动”,而在“想动但动不了的时候,游戏怎么反馈”。俄罗斯方块的标准做法是提前预判,移动前先检查目标位置是否合法,合法才动,不合法就什么都不做。

bool _canMove(int dx, int dy) { for (final block in _activePiece.blocks) { final nx = block.x + dx; final ny = block.y + dy; if (nx < 0 || nx >= boardCols) return false; if (ny >= boardRows) return false; if (board.getCell(nx, ny) != CellType.empty) return false; } return true; }

这里有个细节经常被新手忽略:方块的 y 坐标在棋盘上方还没落进来的区域,负数是合法的,所以要允许 ny < 0;但 ny 一旦等于棋盘行数就说明越界了。棋盘下边界和左右边界都要判断严,否则会出现一半方块的格子穿出棋盘、另一半还在棋盘里的诡异状态。

通过预判,玩家按左键时如果左侧是已固定的方块,就不动。这种感觉就像“撞到墙了”,玩家自然理解,不需要额外报错音效。移动成功的方块,要立即刷新界面,不能等下一个 tick 才更新,否则输入会有半拍的延迟感。

6.2 旋转与墙踢

旋转比移动复杂,因为方块旋转后占用的格子位置整体变了,周围已经落定的方块很容易挡住旋转。经典俄罗斯方块的旋转有个名字叫墙踢(Wall Kick),意思是旋转如果被挡住,就试着往左、往右、往上挪一点,只要挪完能放下就算旋转成功。

我的简单实现用预定义四方向旋转帧。每个方块有 4 套形状模板,旋转就是切换索引:

void tryRotateClockwise() { final nextSpinIndex = (_spinIndex + 1) % 4; final candidateShape = shapes[nextSpinIndex]; if (_canPlaceShape(_x, _y, candidateShape)) { _spinIndex = nextSpinIndex; return; } // 墙踢:依次尝试几个偏移位置 const kicks = [Offset(0, -1), Offset(-1, 0), Offset(1, 0), Offset(0, -2)]; for (final kick in kicks) { if (_canPlaceShape(_x + kick.dx, _y + kick.dy, candidateShape)) { _x += kick.dx.toInt(); _y += kick.dy.toInt(); _spinIndex = nextSpinIndex; return; } } // 全都放不下,就保持原样 }

墙踢偏移量我用的是轻量级方案,没按标准 SRS 那么复杂。0,-1 表示先尝试往上挪一格,-1,0 和 1,0 是左右蹭,0,-2 是最底下被顶住时跳一下。这套方案玩普通俄罗斯方块手感够用,I 形长条在底线附近也能转过来。

注意,旋转失败时要“什么都不做”并保持原状,不要试图把方块强行塞进合法位置。这个“失败但安静”的反馈很关键,玩家会理解为“这个方向转不过去”,自然而然地先移动再旋转。

6.3 硬降锁定后的连锁处理

硬降之所以叫“硬”,是因为它要把锁定、消行、生成新方块一整条流程立即走完。硬降动作执行完,紧接着要检查是否消行,如果消了行还要重新计速,并检查新方块生成位置已经被占满,那就游戏结束。

void _hardDrop() { final distance = hardDropDistance(); _score += distance * 2; _activePiece.moveBy(0, distance); _lockActivePiece(); _clearLines(); _spawnNextPiece(); }

这里顺序不能乱。先加分,再移动,再锁定,再消行,最后生成新块。一旦顺序错了,比如先生成新块再消行,可能出现新块被压在旧块下面的荒谬场景。

7. 常见问题与排查实录

7.1 键盘能按动但方向键无反应

八成是焦点不在游戏区域。先试 autofocus: true,再检查页面里有没有 TextField、Button 等抢焦点的控件。排查最直接的方式,在 onKeyEvent 入口打印一行日志,看按下方向键时回调到底有没有进。

如果日志都没打印,说明焦点不在游戏区域;如果日志打印了但游戏没动作,去看 dispatch 里的动作分发逻辑。焦点抢占问题在弹窗关闭后尤其容易复现,记得弹窗 dispose 时重新 requestFocus。

7.2 手势滑动偶尔变成“点按”

这是因为 GestureDetector 同时挂着 onTap 和 onHorizontalDragUpdate,手势竞技场里单击和水平滑动在“判定谁胜出”上存在竞争。手指滑动的偏移量超过系统阈值(一般 18 像素左右)前,事件归属不明朗,再抬起就可能被判成点击。

规避方法:把旋转从单击改成“上滑手势”或独立按钮;或者接受这个轻微误触率。想彻底消除,只能在游戏操作区取消单击旋转,旋转只用按钮。

7.3 Timer 被取消后游戏完全不动

多半是在暂停或消行分支里写了_fallTimer?.cancel(),但恢复时只调用了engine.dispatch(),忘了重建 Timer。我一直强调把重建逻辑封装到_restartFallTimer(),暂停恢复、消行、重新开始三处统一调用。一旦出现“棋盘完好但完全不下落”的情况,先去看 Timer 是否还活着,用 debugPrint 打点确认。

7.4 硬降触发了两次

这种诡异问题通常出在“同一次玩家操作被多个输入通道同时接收”。比如玩家点击硬降按钮,手在按钮上滑了一下,按钮的 onTap 和手势区的 onDoubleTap 同时触发。规避方法是两种输入通道要么选其一,要么在按钮区域设置 gesture recognizer 的回调优先,禁止手势区拦截按钮区域的事件。合理的做法是按钮放在棋盘区域之外,URL 的布局上就解决掉了。

7.5 OpenHarmony 真机按键识别不对

不同厂商的键盘硬件,尤其是遥控器和电视盒子自带的按键,上报的 key map 可能跟标准 HID 不一致。这时候别猜键名,直接打日志:

debugPrint('key: ${event.logicalKey.keyId}, ${event.logicalKey.keyLabel}');

用一个带键盘的真机把每个键都按一遍,看实际输出值,再回来调整映射表。我在测试中发现过某个牌子的盒子上,返回键被映射成了 LogicalKeyboardKey.arrowLeft,如果不做按键映射层的缓冲,玩家按返回键会直接把方块往左挪一下,同时系统还会触发返回事件,极其混乱。

7.6 软降手感太飘或太快

软降的触发性价比很高,但阀值没调好会很难受。如果玩家只是轻轻一碰屏幕就触发软降,那阀值太高了;如果滑到底部都没有反应,阀值又太大。我用 primaryVelocity > 300 这个速度阈值,配合 30 像素位移,实测下来比较接近直觉:快速下滑触发软降,慢速拖动不触发。这个参数跟屏幕密度也有关系,真机上不同分辨率表现不同,最好在真机调试后做个百宝箱式的常量配置,方便后续微调。

最后一则实操经验

这轮交互做完,我最大的感受是:输入层写完不是结束,手感调优才是无底洞。键盘连发间隔 120 毫秒、滑动位移阈值 30 像素、单击旋转的约 300 毫秒延迟,这些参数单独看都合理,组合起来要玩上几十盘才能确定顺不顺手。

我建议你自己搭一版,先按本文的默认参数跑起来,然后把所有阈值提取成一个 Constants 类,谁觉得哪难受就改哪个值试试。方向键连发太快容易过头,太慢又像癫痫;滑动阈值太小容易飘,太大又像推箱子。我自己最后定下来的是连发 100 到 130 毫秒、位移 30 像素,真机手感最接近经典版。

最后补一个小技巧:调试交互时,在引擎 dispatch 入口把动作和棋盘状态一起打印出来,看着每一帧的变化去发现“动作执行了但棋盘没变”还是“动作没执行”,能够很快区分是输入层的问题还是引擎逻辑的问题。这比反复盲调参数高效得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询