我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

Flutter与OpenHarmony实战:俄罗斯方块核心数据结构与算法

Flutter与OpenHarmony实战:俄罗斯方块核心数据结构与算法 “Flutter、OpenHarmony、俄罗斯方块”这三个词放在一起很多人第一反应是“又要跑通一个跨端 demo”。但真正把俄罗斯方块拆开之后你会发现它比大部分业务系统都更适合拿来研究数据结构和核心算法二维棋盘是矩阵方块旋转是变换下落和固定的每一步都是碰撞检测消行是行删除与行合并7-bag 随机方块的公平性又是一套队列加洗牌算法。短短几百行逻辑几乎把计算机里常见的数据组织和算法思想串成了一条线。我这次项目定位很明确用 Flutter 跑在 OpenHarmony 上从零写一个可玩的俄罗斯方块。第一步先不碰界面把棋盘、方块、旋转、碰撞、消行这些核心逻辑做成一套纯 Dart 的独立模块。这篇文章就是系列的第一篇把数据结构和核心算法讲透顺带分享我在写的过程中踩过的坑。适合想了解跨端游戏逻辑、准备上手 OpenHarmony 适配或者只是想把俄罗斯方块写利索的开发者。1. 为什么俄罗斯方块适合用来做“Flutter OpenHarmony”实战项目1.1 俄罗斯方块是一个能玩、能测、能上台面的算法沙盒经常有人问我想练数据结构不知道该做什么项目。我的建议一直很直接别一上来就做管理系统那里面最复杂的东西不过是增删改查。俄罗斯方块这种小游戏反而更合适因为它的规则很清楚但实现方式却有讲究。随便搜索一下网上的“俄罗斯方块代码”千奇百怪有 C 语言控制台版有 HTML JavaScript 直接复制到一个 tetris.html 就能打开跑的版本也有用各种游戏引擎做出来的成品。同一个游戏不同语言、不同架构写出来核心难点都集中在几件事上。俄罗斯方块天然覆盖了数据结构课程里的好几块内容。棋盘本身是一个二维数组方块是一个小的矩阵预览区需要队列消行的时候涉及行的删除和上方整体下移甚至如果你想做 AI 辅助或者后续做“存储对局回放”还会用到状态记录和哈希。这些不是书上干巴巴的术语而是每一帧都在跑的真实逻辑。所以把它当作“算法健身房”是很划算的练一次同时把数组、矩阵变换、队列、状态机全过一遍。另外俄罗斯方块很容易形成完整闭环。它不需要联网不需要服务端逻辑量控制在几百行内做坏了重写成本也不高。对于 Flutter 和 OpenHarmony 这种偏底层适配的场景来说拿一个功能完整但逻辑体量可控的项目来验证跨端能力比拿一个庞大的 App 来试要靠谱得多。1.2 Flutter 的 OpenHarmony 适配到底走到了哪一步先说清楚一个容易混淆的点。大家常说的“鸿蒙”很多时候指的是华为的商业发行版 HarmonyOS而这里涉及的 OpenHarmony 是开放原子开源基金会孵化的开源项目。Flutter 社区很早就开始做 OpenHarmony 的适配目前比较常见的做法是使用 OpenHarmony SIG 维护的 flutter_flutter 分支配合 DevEco Studio 构建 OpenHarmony 工程把 Flutter 应用跑在 OpenHarmony 的窗口和渲染链路上。这个适配方案的核心思路是把 Flutter 引擎里的平台层替换成 OpenHarmony 的实现而上层 Dart 代码基本不需要动。也就是说你在 Android 和 iOS 上写的 Widget、写的 Dart 业务逻辑理论上可以直接跑到 OpenHarmony 设备上。实际工程里会有一些细节差异比如插件系统的 message 通道、字体渲染、系统能力调用都需要按 OpenHarmony 的方式重新适配一遍。这也正是我选择这个组合的原因。Flutter 的跨端优势在于“一套 Dart 代码到处跑”但很多人在写业务的时候把 UI、状态、平台调用全揉在一起导致换一个平台就得大改。俄罗斯方块这种项目天然适合把游戏核心逻辑做成纯 Dart 的独立模块UI 只负责画出来和接收手势。这样的结构搬到 OpenHarmony 上时需要改的地方被压缩到最小。1.3 为什么第一篇先做数据结构和核心算法很多初学者写游戏有个通病一上来就开页面、摆控件、调动画玩起来还挺像那么回事但稍微改一个需求就崩。原因很简单游戏的核心逻辑没有和数据模型分开。俄罗斯方块这个项目UI 层其实很薄无非是画一个 10 x 20 的网格把方块按坐标显示出来然后监听按键手势。真正决定游戏好不好玩的是核心逻辑层的设计。碰撞检测做得准不准旋转是否自然消行是否流畅这些跟 UI 没有关系只跟数据结构和算法有关系。如果核心逻辑是一团浆糊换什么渲染引擎都救不回来。所以我刻意把第一篇限定在数据结构和核心算法上并且全部用纯 Dart 实现不依赖 Flutter 的 Widget。这样做有一个很实际的好处可以用flutter test直接跑单元测试像验证库函数一样验证“左移是否合法”“三连消是否计分正确”不需要打开模拟器也不需要真正部署到 OpenHarmony 设备。等到核心逻辑稳定了再把它接到 UI 上这时候出问题就能很快定位到是渲染问题还是逻辑问题。2. 数据结构设计把游戏世界翻译成电脑能认的数据2.1 棋盘用二维数组还是用位运算俄罗斯方块棋盘的标准规格是 10 列 x 20 行大部分实现都会用二维数组表示。在 Dart 里我建议写成ListListint每一行是一个长度为 10 的列表整个棋盘是 20 个这样的列表。每个格子的值用 0 表示空用 1 到 7 表示不同的方块类型渲染层看到数值再映射成颜色。也有老派写法用位运算压缩棋盘每一行用 10 个 bit 表示一个 int 就能存一行整个棋盘也才 20 个 int。好处是消行判断可以用位运算一条指令算出来坏处是代码可读性差调试时看二进制能看花眼。对于 Flutter 这种设备上跑的应用来说10 x 20 的数组规模根本不存在性能压力贪图位运算的优化反而牺牲了可维护性。我的结论是第一版直接用二维数组把逻辑写得清清楚楚如果以后要做高性能的 AI 搜索再去换位掩码方案。还有一个容易被忽略的设计点棋盘坐标方向。我约定把可见区的顶部作为 y 0每一行从上往下递增x 从 0 到 9 表示从左到右。这个约定要贯穿所有代码包括碰撞检测、渲染、手势映射。很多 bug 都出在“数组坐标”和“屏幕上看到的坐标”不一致上后面踩坑部分我会详细讲。2.2 方块形状与旋转态怎么存储俄罗斯方块有 7 种标准方块I、O、T、S、Z、J、L。每种方块本质上是一个小型矩阵矩阵里有 1 的位置就是方块占用的格子。比如 T 方块的基础形态是一个 3 x 3 矩阵0 1 0 1 1 1 0 0 0而 I 方块长条最好用 4 x 4 矩阵表示0 0 0 0 1 1 1 1 0 0 0 0 0 0 0 0所有方块统一用 4 x 4 矩阵的好处是旋转中心一致代码逻辑不用为每种方块写特例。O 方块虽然只有 2 x 2 的大小但为了统一也可以放进 4 x 4 矩阵里反正它是一个正方形旋转起来视觉上没有变化。旋转操作本身是一个标准的矩阵顺时针旋转把第 i 行第 j 列的元素放到第 j 行的倒数第 i 列用代码写就是ListListint rotateMatrixCW(ListListint src) { final rows src.length; final cols src[0].length; final dst List.generate(cols, (_) Listint.filled(rows, 0)); for (var r 0; r rows; r) { for (var c 0; c cols; c) { dst[c][rows - 1 - r] src[r][c]; } } return dst; }我在工程里并没有在每次旋转时临时调用这个函数而是在定义方块时就把四个旋转状态都算好存进一个预计算列表。因为游戏运行中旋转操作非常频繁预计算可以避免大量无意义的矩阵复制也让旋转结果变得可预测方便调试时直接查表。2.3 当前方块、预览队列和 7-bag 的数据组织游戏运行时的核心状态可以拆成几个部分。第一个部分是“当前下落中的方块”它由三样东西描述方块类型决定用哪个矩阵、当前的 x 坐标、当前的 y 坐标。我用一个小类叫Piece里面持有当前旋转状态对应的矩阵以及坐标。第二个部分是“下一个方块”和“下下一个方块”这是玩家最关心的预告信息。标准俄罗斯方块并不希望完全随机地发方块如果连续给你两个 S 或两个 Z游戏体验会非常糟糕。所以现代俄罗斯方块普遍采用“7-bag”策略先把 7 种方块各放一个进一个袋子洗牌后一个个往外拿袋子拿空了再装满 7 个重新洗牌。这样每一个连续的 7 个方块周期里7 种方块各出现一次既保证随机性又保证公平性。这个袋子在代码里就是一个队列或者更简单一点一个列表加一个当前消费位置。第三个部分是棋盘本身和计分、消行数、游戏状态这些东西。棋盘我单独封装成一个Board类计分和状态则由游戏主控模块持有。2.4 游戏状态的最小集合与变化通知很多刚接触游戏开发的同事容易把游戏状态分散得到处都是比如在 Widget 里临时存一个分数在动画回调里记一个当前方块。这样写到后面一定会乱。我的做法是收敛成一个状态对象只保留最小必要数据棋盘、当前方块、下一个方块、分数、已消行数、游戏是否结束。这套状态在 Dart 里可以用一个TetrisCore类来管理内部暴露一组操作接口比如左移、右移、旋转、软降、硬降、每一步推进。UI 层看不到内部数组只能通过这些接口改变游戏状态然后监听状态变化通知去重新渲染。Flutter 里可以用内置的ChangeNotifier也可以用Stream甚至后面接flutter_riverpod之类状态管理框架都不冲突。这个设计的意义在于核心逻辑不依赖任何 Flutter 类型将来在 OpenHarmony 设备上即使渲染链路要调整核心类依然能原封不动地搬过去。3. 核心算法拆解碰撞、旋转、消行、随机3.1 碰撞检测先做边界判断再做格子判断碰撞检测是俄罗斯方块里被调用最频繁的函数。每次尝试移动、旋转、下落都要判断“如果方块放在这里会不会撞到边界或者已经固定的方块”。实现上不能直接修改棋盘而是先把方块矩阵和目标坐标算出来再逐格检查。我写的核心方法大概是这样的思路拿到当前方块的矩阵遍历每一个非 0 的格子换算成棋盘坐标。只要出现以下三种情况之一就说明碰撞。棋盘 x 坐标小于 0 或者大于等于 10说明撞了左右墙。棋盘 y 坐标大于等于 20说明撞了底部。棋盘 y 坐标在可见区域内且棋盘上对应位置已经有非 0 方块说明撞到了旧方块。对应的 Dart 代码bool collidesWith(ListListint piece, int pieceX, int pieceY) { for (var r 0; r piece.length; r) { for (var c 0; c piece[r].length; c) { if (piece[r][c] 0) continue; final bx pieceX c; final by pieceY r; if (bx 0 || bx Board.cols) return true; if (by Board.rows) return true; if (by 0 cells[by][bx] ! 0) return true; } } return false; }注意一个细节我没有对by 0的情况判碰撞。这是故意的因为方块可以在棋盘顶部上方出生刚生成时部分格子会出现在 y 为负的位置。这些格子虽然暂时不可见但允许它们悬空等方块下落进入棋盘后再参与碰撞判断。如果你把by 0也当成越界方块一出生就崩了或者永远落不下来。这个函数的效率很关键因为每次按键都要调用每下落一格也要调用。但棋盘总共只有 10 x 20方块矩阵最多也就 4 x 4遍历几十个格子对现代设备来说完全是零负担不需要额外做空间分区这类花活。真正要保证的是逻辑正确性和代码可读性。3.2 旋转与踢墙旋转后要“试着挪一挪”旋转算法本身不复杂矩阵顺时针旋转一次然后检测碰撞。难点在于方块如果贴着墙或者在方块堆旁边旋转旋转后的新位置往往会撞到东西。这时候游戏需要“踢墙”也就是在旋转后尝试做一些小幅度的平移看哪个位置能放得下。我的第一版实现是这样处理踢墙的旋转得到新矩阵后先尝试原地放置如果碰撞就往左偏移 1 格试试不行就往右偏移 1 格再不行就分别试试偏移 2 格。如果这些都失败再尝试往上提 1 格。说白了就是一个小型偏移表。更专业的行为是参考经典 SRSSuper Rotation System的踢墙表它会针对不同的旋转方向和不同的方块类型给出具体的偏移序列比如 J、L、S、T、Z 用一套表I 方块单独用一套表。SRS 能模拟出大部分玩家习惯的手感。第一版为了控制复杂度我没有上完整 SRS而是用统一偏移序列。这样做的代价是某些极限位置的旋转手感稍微差一点但核心架构没有受影响后期想优化手感只需要在旋转函数里替换成具体的踢墙表即可。旋转还有一个特殊处理O 方块不需要旋转。T、S、Z、J、L 这些方块旋转时用矩阵变换都能得到理想结果但 O 方块旋转后形状不变所以直接跳过旋转逻辑避免无意义的矩阵操作。I 方块要单独留心。它用 4 x 4 矩阵旋转状态有横、竖两种基本形态。如果只用一个 3 x 3 矩阵去旋转旋转中心位置不对长条旋转时的视觉位移会非常怪。统一用 4 x 4 矩阵后旋转中心的偏移就稳定了。3.3 消行算法从下往上、边删边补当当前方块固定到棋盘上之后就要检查有没有整行被填满。消行算法有一个容易写错的点必须从下往上扫描并且删除一行后不要急着马上跳到上一行。为什么会这样因为删除一行后上面的所有行都会往下挪一行。如果你用正序循环从第 0 行扫到第 19 行碰到满行就删那么删掉第 5 行后原来的第 6 行变成了第 5 行但循环已经检查过第 5 行了就可能漏掉这一行。所以要么倒序循环要么在删除后停在原位重新检查。我写的清理函数是这样的class Board { static const int cols 10; static const int rows 20; ListListint cells; Board() : cells List.generate(rows, (_) Listint.filled(cols, 0)); bool isFullRow(int y) { for (var x 0; x cols; x) { if (cells[y][x] 0) return false; } return true; } int clearLines() { var cleared 0; var y rows - 1; while (y 0) { if (isFullRow(y)) { cells.removeAt(y); cells.insert(0, Listint.filled(cols, 0)); cleared; // 注意这里没有 y--删除后原来的下一行已经变成当前行需要重新检查 } else { y--; } } return cleared; } }消行后的计分也有一套约定俗成的规则消 1 行 100 分消 2 行 300 分消 3 行 500 分消 4 行一次消四行也叫 Tetris800 分。这个计分表很简单但在体现“一次性消多行”的奖励时却很直观。第一版不用做得太复杂记录分数和累计消行数就够了。有一个小地方需要提醒消行时要顺便更新玩家的分数但不要在这个函数里做渲染。我在初版代码里犯过这个错误把计分和 UI 更新写在了一起导致单元测试非常难写。后来把clearLines()改成一个纯函数只返回消了多少行由上层决定加分逻辑测试就顺畅多了。3.4 7-bag 随机让方块分布更公平俄罗斯方块的方块生成我坚持用 7-bag 而不是纯随机。纯随机的意思是每来一个方块从 7 种里等概率选一个。听起来挺公平但实际玩起来会有问题连续三次都来同一个方块或者连续来一串 S/Z玩家会想砸键盘。经典街机规则里7-bag 能保证在任意连续的 7 个方块内7 种形状各出现一次这样玩家可以根据接下来的方块提前规划。7-bag 的实现核心是一组洗牌操作。先把 0 到 6 这 7 个编号放进一个列表用 Fisher-Yates 算法打乱顺序然后依次消费。列表消费完了再生成新的一组。这里我建了一个SevenBag类import dart:math; class SevenBag { final Random _random; final Listint _queue []; int _pos 0; SevenBag([Random? random]) : _random random ?? Random(); int next() { if (_pos _queue.length) { _queue.clear(); _queue.addAll(List.generate(7, (i) i)); for (var i _queue.length - 1; i 0; i--) { final j _random.nextInt(i 1); final tmp _queue[i]; _queue[i] _queue[j]; _queue[j] tmp; } _pos 0; } return _queue[_pos]; } }有一点要说一下next()返回的是方块类型的编号而不是直接返回方块矩阵。这样做的好处是上层拿到编号后可以从Tetromino类型映射到具体的旋转矩阵。这样随机生成逻辑和方块形态定义被解耦后面如果要加“强制首块为某种方块”之类的规则只需要在更上层做拦截。这个类也可以顺便用来做预览队列。渲染层需要“下一个方块”和“再下一个方块”可以调用next()缓存结果也可以维护一个预览数组。第一版我选择预取两个把第二个也缓存起来这样界面上能显示“下下个”玩家规划的空间更大。4. 实操记录用 Dart 写一版可复用的核心逻辑4.1 目录结构把游戏逻辑和渲染层分开我先把项目骨架搭出来目录大概是这样的lib/ main.dart game/ board.dart tetromino.dart piece.dart seven_bag.dart tetris_core.dart ui/ ignore_for_file... test/ game/ board_test.dart tetris_core_test.dart这里最关键的决策是让game/目录下的文件完全不依赖 Flutter SDK。也就是说这里不会出现import package:flutter/material.dart;。它们只是普通 Dart 库。这样游戏核心逻辑可以被单元测试直接调用未来就算 Flutter 引擎版本升级或者 OpenHarmony 适配分支有变化核心模块都不用改。custom_paint这类渲染相关的东西全部放到ui/目录等后续篇章再接上。4.2 方块矩阵与旋转定义我在tetromino.dart里定义方块类型和旋转状态的生成。先定义好每个方块的基础矩阵然后调用rotateMatrixCW生成四个旋转状态。T 方块和 I 方块的关键定义我之前已经展示过这是一个由基础形状自动生成旋转状态的结构class Tetromino { static ListListint baseShape(TetrominoType type) { switch (type) { case TetrominoType.t: return [ [0, 1, 0], [1, 1, 1], [0, 0, 0], ]; case TetrominoType.i: return [ [0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0], ]; // 其他方块类似 default: return [ [0, 0, 0], [0, 0, 0], [0, 0, 0], ]; } } }我特意在注释里留了一句“其他方块类似”实际工程里 J、L、S、Z、O 都是这样逐个写进去的。写完基础形状后再写一段生成四个旋转状态的逻辑预计算出一个MapTetrominoType, ListListListint。旋转的时候直接从states[type][stateIndex]取不在运行时计算。4.3 棋盘、碰撞和消行代码Board类和clearLines我在前面已经给出了核心代码。碰撞检测部分我建议把它放在TetrisCore里因为它既要访问棋盘又要访问当前方块的位置属于游戏操作层的行为而不是棋盘本身的静态行为。如果一个函数既需要棋盘数据又需要方块状态放在棋盘类里会让棋盘变成一个什么都干的“上帝类”。实际上我的项目里collidesWith就定义在TetrisCore里这样Board保持纯粹只是记载当前棋盘格子的数据容器。测试的时候可以单独构造一个Board再构造一个TetrisCore注入进去方便做各种边界场景。4.4 游戏控制器接口设计与单元测试TetrisCore对外暴露的方法我控制在这么几个moveLeft()、moveRight()、rotate()、softDrop()、hardDrop()、tick()。tick()是游戏主循环每次推进时调的它让当前方块自动下落一格如果落不下去就固定到棋盘上然后消行、发新方块。这样一个干净接口的好处是UI 层只需要在按键回调里调moveLeft()或rotate()不需要知道内部缓存了多少旋转样本也不需要知道棋盘数组怎么存的。单元测试我重点覆盖几类场景左移右移到边界时移动操作应该返回 false方块位置不变。方块落在已有方块上时碰撞检测应该返回 true。旋转后贴近墙时踢墙逻辑能否找到合法位置。构造一个满行棋盘调用clearLines()后行数减少、分数逻辑正确。SevenBag连续取出 7 个方块必须恰好包含 7 种类型各一次。这些测试跑通之后我才有信心把核心逻辑接到 UI 上。因为到时候如果界面显示错乱我可以快速判断问题出在渲染层而不是逻辑层。5. 踩坑记录写给第一次写俄罗斯方块的人5.1 数组越界不等于直接崩溃更危险的是“没崩溃但结果错”Dart 里访问数组越界会直接抛异常所以这类错误一般不会藏着。真正难查的 bug 是我之前提到的那种方块在出生区时 y 坐标是负数如果不加判断就去访问cells[by][bx]程序立刻崩。加了by 0之后逻辑反而安全了。还有一种更隐蔽的情况是视觉上出现“方块在棋盘外”。比如你允许方块向左移动时bx等于 -1但渲染层画格子时用的是方块矩阵自己的坐标没做裁剪于是方块的一部分画到屏幕左侧外面去了。检查这种方式需要格外注意渲染坐标和逻辑坐标不要混用还要在渲染时做裁剪。5.2 旋转方向数学上的顺时针和渲染时的方向我第一次实现旋转时按矩阵公式写出了旋转函数但画在屏幕上发现方块越转越不对劲旋转方向跟预期相反。原因在于屏幕坐标系的 y 轴是向下的数学上常见的坐标系 y 轴向上直接套用“顺时针旋转公式”得到的结果在屏幕上看起来是逆时针的。这个问题的解决方案说简单也简单定义清楚后统一按一个方向跑通全流程。我的做法是把“顺时针”定义为玩家按一次旋转键后的效果然后以此为准调整矩阵变换最后用一个可视化的测试页面验证 T 方块四个旋转状态是否符合直觉。这个过程用单元测试很难完全覆盖最好还是渲染出来亲眼看。5.3 消行漏判遍历方向决定 bug 去留消行是我写第一版时最容易出 bug 的地方。我尝试过正序遍历行结果消一行之后漏掉了紧挨着的另一行玩家明明消了两行只显示消了一行。后来改成倒序遍历并且删除后不立即移动指针才算稳定。这里要再多说一句cells.removeAt(y)在 Dart 里是 O(n) 的操作因为要移动后面的元素。但棋盘总共只有 20 行完全不需要担心性能。保持代码清晰比微优化重要得多。5.4 面向 OpenHarmony 适配时的工程注意核心逻辑不依赖 Flutter 之后真正要适配 OpenHarmony 的部分集中在这几个地方。第一Flutter 版本的选择要看 fork 分支支持情况OpenHarmony 的 flutter_flutter 通常比上游 Flutter 版本慢半拍不要盲目追新。第二工程构建需要通过 DevEco Studio 创建 OpenHarmony 的宿主工程Flutter 代码被打包成动态共享库接进去这个流程跟 Android 的 gradle 工程差别很大。第三如果后续要用到手柄、传感器或者系统设置需要通过 method channel 或 event channel 跟 OpenHarmony 原生侧通信这部分插件适配和 Android/iOS 上写插件不太一样我计划在后面的篇章里单独记录。还有一点OpenHarmony 设备的屏幕刷新节奏和 Android 不太一样游戏循环用 Flutter 的Timer还是Ticker会影响下落间隔的稳定性。核心逻辑里我只提供tick()把时间驱动的职责留给 UI 层这样在不同刷新率的设备上都可以自适应。最后分享一个小经验写俄罗斯方块之前我一直觉得这类小游戏是“随便写写”。真正动手才发现数据结构和算法不是用来面试的而是用来约束代码复杂度的。棋盘数组怎么设计、方块矩阵怎么存、旋转和碰撞谁先谁后、消行时遍历方向是什么这些决策一旦做错后面会连续踩坑。而把这些核心逻辑和 UI 完全隔离后项目的复杂度会下降一个量级后面接 Flutter UI、接 OpenHarmony 手势、接状态管理都会变得轻松许多。我自己在写核心模块时最大的感触是“把状态收敛成一个对象、把操作收敛成一组接口”比多写几十行代码更重要。俄罗斯方块的规则全世界都知道但不同人写出来的代码结构可以天差地别差别就在数据结构和控制流的设计上。后续我会继续更新这个系列的第二篇把 Flutter 端的渲染、手势交互和 OpenHarmony 适配细节补完。如果你想亲手复现建议也先像我一样用纯 Dart 把核心逻辑写完在跑任何 UI 之前先把单元测试跑绿。
返回列表