ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Flutter+OpenHarmony:纯Dart内核实现俄罗斯方块核心算法

Flutter+OpenHarmony:纯Dart内核实现俄罗斯方块核心算法 做俄罗斯方块这种小游戏最容易翻车的地方其实不在界面而在数据结构和核心算法。以前我总是一上来就画棋盘、拖方块结果写到下落碰撞和消行的时候发现数据模型和渲染代码早就拧成了一团改一个方块的回退逻辑要牵连七八处地方。这次用Flutter在OpenHarmony上从零写我刻意把顺序反过来先有纯Dart的游戏内核再谈界面。这个系列的第一篇就是专门拆解棋盘怎么建模、七种方块怎么定义、旋转和碰撞检测怎么写、消行和随机生成怎么实现最后用单元测试把整个逻辑钉死。这样做的好处是OpenHarmony平台差异再大真正的游戏规则都跑在平台无关的代码里后续无论是接UI、接手势还是把逻辑搬到手机平台都不用重写一遍。1. 为什么要把“数据结构和核心算法”单独拎出来先写1.1 逻辑与渲染耦合是这类小游戏最大的返工源头很多人在实现俄罗斯方块时会把“棋盘”直接定义成一堆Widget数组把“当前方块”塞进StatefulWidget的state里渲染逻辑和游戏规则混在同一个build方法中。这种写法在刚开始时进展飞快因为不需要任何抽象但一旦玩起来就会发现所有修改都是牵一发动全身。举个最常见的例子你要实现“方块落定后再生成下一个”逻辑上这只是把当前方块的格子写进棋盘、然后切换当前方块。但如果你把“当前方块”和“棋盘格子的颜色”混在渲染层你就会发现需要处理一堆状态同步问题棋盘更新了Widget层怎么知道要重建颜色对应的方块类型从哪取游戏结束时这些状态要怎么冻结这些问题本质上都和数据建模有关跟画面一分钱关系都没有。俄罗斯方块的核心规则其实非常独立棋盘上哪些格子被占用、某个方块形状放在某个位置是否会碰撞、哪些行满了需要消除。这三条规则不依赖像素、不依赖颜色、不依赖动画。既然这样就应该把整块逻辑沉淀成一个不依赖Flutter SDK的Dart库。这是我做这个项目时最坚持的一点后面你会发现它带来的收益远超预期。1.2 OpenHarmony上的Flutter开发更需要“先逻辑后界面”Flutter for OpenHarmony的适配版本还在快速演进阶段工具链和社区模板经常更新UI层面的特性和标准Flutter也有细节差异。在这种背景下把最容易被平台差异波及的部分——渲染、输入、生命周期——和真正稳定的部分——游戏规则——彻底分开显得格外重要。我在实际搭建工程时发现OpenHarmony工程里除了常规Flutter目录还多了一堆原生侧的配置。如果游戏逻辑和界面代码混在一起排查一个随手可见的渲染问题都要同时面对Dart代码和原生工程两层结构。与其这样不如一开始就守住边界game/目录下只出现Dart标准库任何import package:flutter/...或import package:ohos/...都不允许出现。这样后续无论平台怎么变我的游戏内核都不会被牵动。1.3 这一篇结束时的产物等到这篇读完你手里应该有这样的东西一个纯Dart实现的TetrisGame类内部包含棋盘、当前方块、下一个方块、得分、游戏结束状态由TetrominoType和形状矩阵组成的方块定义旋转、碰撞、落定、消行、7-Bag随机生成等核心算法一组可以直接用flutter test跑起来的单元测试。说白了这是一个“看得见逻辑、看不见画面”的俄罗斯方块内核。UI层的东西我留到系列第二篇再讲因为先把规则钉死后面画界面其实是个体力活。2. 让Flutter跑进OpenHarmony工程搭建与目录规划2.1 准备工具链在OpenHarmony设备上跑Flutter需要的工具链比普通Flutter开发要多一层。我实际使用的组合是工具作用备注DevEco StudioOpenHarmony应用IDE负责工程构建、签名官方工具直接从官网下载即可OpenHarmony SDK提供系统API和模拟器支持版本要和工具链匹配Flutter SDKOpenHarmony适配版提供flutter命令和Dart运行时由开源社区维护的适配分支仓库里会有明确的版本要求真机或模拟器运行目标设备模拟器主要用来快速验证环境有一个原则必须记住Flutter的OpenHarmony适配版本和OpenHarmony SDK版本之间通常有对应关系。不要随手拿最新版Flutter配一个很旧的SDK也不要反过来。我见过不少“环境跑不起来”的报错最后都是版本混搭造成的。2.2 创建工程并验证环境创建工程有两条路径我建议优先用Flutter命令直接创建flutter create --platformsohos tetris_ohos cd tetris_ohos flutter doctor如果你的Flutter适配版本支持ohos平台参数那么这会生成一个包含OpenHarmony工程结构的Flutter项目。老版本的适配分支可能不支持这个参数这时候要么去社区仓库拉现成的模板要么先用DevEco Studio创建OpenHarmony空工程再把Flutter的ohos桥接代码整合进去。后一种路径比较绕对新人不友好我强烈建议直接用社区模板起步。工程创建完先别急着写游戏先跑一个Hello World验证链路通不通。运行前要在DevEco Studio里配置好签名OpenHarmony应用运行到真机需要签名文件。这一步看似繁琐但属于“一次性痛苦”配置好后整个开发周期都不用再碰。2.3 目录结构从一开始就按“纯Dart内核 UI壳”拆分整个项目跑通之后我做的第一件事不是写逻辑而是规划目录。目录规划决定了项目是越写越轻松还是越写越乱。lib/ game/ # 纯Dart游戏内核 board.dart tetromino.dart seven_bag.dart tetris_game.dart pages/ # Flutter UI层 game_page.dart widgets/ # 后续用到的画笔、手势组件 test/ game/ board_test.dart tetris_game_test.dart重点在game/目录。这个目录下的四个文件不允许依赖任何Flutter、OpenHarmony相关的包只允许使用Dart标准库。这样flutter test可以直接跑纯Dart测试不用启动模拟器不用等待UI构建几百毫秒内就能验证规则。而且将来如果要把内核复用到一个纯Dart服务端或命令行工具这四份文件可以直接拷贝过去。2.4 环境搭建阶段最容易忽略的几个配置跑通创建和签名之后还有几个细节容易踩坑。第一工程模板默认可能会带一些权限声明俄罗斯方块这种游戏用不上建议清理掉避免上架审核时产生不必要的疑问。第二真机调试时如果flutter run连接不上先确认电脑和开发板在同一个网段OpenHarmony设备的调试通道和Android不完全一样ADB思维在这里不一定好使。第三如果后续要跑模拟器记得留意模拟器镜像和SDK版本是否配套我遇到过几次镜像版本过旧导致的莫名崩溃换对应版本镜像立刻就好了。环境这块不用追求完美能跑到一个Hello Flutter就算通关。真正要花心思的是从下一章开始的数据结构设计。3. 棋盘与方块建模数据结构选型决定后续开发体验3.1 棋盘二维数组是最直接的选择标准俄罗斯方块的棋盘是10列、20行。很多变体规则会改这个尺寸但10x20是经典值作为第一版实现完全够用。我选择的表示方式是二维ListListintclass Board { static const int width 10; static const int height 20; final ListListint _cells; Board() : _cells List.generate( height, (y) Listint.filled(width, 0), ); int get(int x, int y) _cells[y][x]; void set(int x, int y, int value) _cells[y][x] value; bool isRowFull(int y) _cells[y].every((cell) cell ! 0); }这里我故意让每个格子存int而不是bool。原因很简单后续渲染时不同的方块类型要用不同的颜色区分bool只能表达“有/没有”还得另开一张表记录颜色类型非常别扭。int直接用0表示空非0表示对应方块类型的编号渲染时查一下类型即可。有人可能会问10x20的规模要不要性能优化完全不需要。哪怕你每秒渲染60帧棋盘也就200个格子遍历全棋盘的花费可以忽略不计。所以数据结构的第一原则不是“快”而是“直白”。直白的数据结构写着不累调起来不慌。3.2 方块形状矩阵定义、坐标遍历俄罗斯方块有七种标准形状I、O、T、S、Z、J、L。每种形状都可以用一个由0和1组成的矩阵来表示1代表这个位置被方块占据enum TetrominoType { i, o, t, s, z, j, l } const MapTetrominoType, ListListint tetrominoShapes { TetrominoType.i: [ [0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0], ], TetrominoType.o: [ [1, 1], [1, 1], ], TetrominoType.t: [ [0, 1, 0], [1, 1, 1], [0, 0, 0], ], TetrominoType.s: [ [0, 1, 1], [1, 1, 0], [0, 0, 0], ], TetrominoType.z: [ [1, 1, 0], [0, 1, 1], [0, 0, 0], ], TetrominoType.j: [ [1, 0, 0], [1, 1, 1], [0, 0, 0], ], TetrominoType.l: [ [0, 0, 1], [1, 1, 1], [0, 0, 0], ], };注意I方块我用了4x4矩阵O方块是2x2其他方块都是3x3。这个尺寸差异不是随意的它会影响旋转算法的行为稍后我会专门讲。在游戏运行过程中我不需要每次都用矩阵去做碰撞检测而是先把矩阵中所有1的位置转换成坐标列表再统一处理。这样旋转、碰撞、绘制都只关心“坐标对”不用反复读矩阵逻辑会清爽很多。3.3 方块状态与游戏主类设计一个正在下落中的方块不仅要记录形状还要记录它当前在棋盘上的位置。我定义了一个ActivePiece类class ActivePiece { final TetrominoType type; ListListint shape; int x; int y; ActivePiece(this.type, this.shape, {this.x 3, this.y 0}); List(int, int) get occupiedCells { final cells (int, int)[]; for (var r 0; r shape.length; r) { for (var c 0; c shape[r].length; c) { if (shape[r][c] ! 0) { cells.add((x c, y r)); } } } return cells; } }这里我的x和y表示的是方块矩阵左上角在棋盘上的坐标。occupiedCells返回的是这个方块真正占据的棋盘格子坐标碰撞检测和落定操作都基于这个方法而不是直接读shape矩阵。坐标系约定必须从一开始就统一棋盘左上角是(0,0)x向右递增y向下递增。矩阵的行索引对应y方向的偏移列索引对应x方向的偏移。后面所有算法——旋转、碰撞、消行、绘制——都死守这个约定否则代码里会到处都是一堆1、-1的魔法数字那才是灾难。TetrisGame是游戏的总入口我把所有操作收敛成几个动词class TetrisGame { final Board board Board(); final SevenBag _bag SevenBag(); ActivePiece? current; ActivePiece? next; int score 0; int lines 0; bool gameOver false; void spawnNext() { next ?? _bag.next(); current ActivePiece(next!.type, next!.shape); next _bag.next(); if (collides(current!)) { gameOver true; } } void moveLeft() _move(-1, 0); void moveRight() _move(1, 0); void softDrop() _move(0, 1); }为什么current用可空类型因为在spawnNext之前游戏还没有任何当前方块。为什么next要提前预取因为界面上要显示“下一个方块”的预览如果每次都在方块落定后才临时取预览就做不成。这种提前预取的思路在游戏开发里很常见。3.4 坐标约定背后的“为什么”我在这篇里反复强调坐标系因为它值得强调。俄罗斯方块的很多诡异bug都出在坐标方向上。比如有人会把矩阵的行索引当成x方向画图和碰撞检测时就必须翻转很快就看晕了。又比如有人会把y0定义在棋盘底部消行和下落逻辑就要反着写。坐标系统一之后旋转算法里的rows - 1 - r这种公式才能直接套否则每处逻辑都得手动调试一遍偏移量。4. 核心算法实现旋转、碰撞、落定与消行4.1 旋转算法转置加翻转以及绕不过的偏移问题方块的顺时针旋转在矩阵层面上有一套标准做法先转置再水平翻转。把两个步骤合并成一个循环就成了下面这个函数ListListint rotateClockwise(ListListint shape) { final rows shape.length; final cols shape[0].length; final rotated List.generate(cols, (_) Listint.filled(rows, 0)); for (var r 0; r rows; r) { for (var c 0; c cols; c) { rotated[c][rows - 1 - r] shape[r][c]; } } return rotated; }理解这个公式可以这样想把原矩阵第r行第c列的格子放到新矩阵的第c行倒数第r列。类比一下拿一张写满字的白纸顺时针转90度原来横着的行会变成竖着的列而且“第一行”会跑到最右边去。rows - 1 - r就是在做这个“跑到最右边”的映射。这里有个非常关键的细节I方块用4x4矩阵旋转时竖起来之后会落在矩阵的第2列而不是中间视觉上会有半格偏移。这不是代码写错了而是矩阵旋转本身的特性。正规的俄罗斯方块实现会使用SRSSuper Rotation System系统通过一张wall-kick偏移表来修正这种漂移。本篇的第一版我们可以做简化处理旋转后先尝试原位放置如果不碰撞就接受如果碰撞就尝试向左平移一格、向右平移一格、左移两格、右移两格找到第一个不碰撞的位置就落定。void rotate() { final piece current!; final rotated rotateClockwise(piece.shape); for (final dx in [0, -1, 1, -2, 2]) { if (!collides(piece, piece.x dx, piece.y)) { piece.shape rotated; piece.x dx; return; } } }这套简化版不是SRS体验已经相当接近。对小游戏来说手感平滑是第一位的等后续要支持更复杂的踢墙行为时再考虑接入完整wall-kick表。4.2 碰撞检测把边界检查写成一个统一函数碰撞检测是整个游戏最核心、最容易写错的函数。它的逻辑一句话就能说清一个方块放在某个位置如果它的任何一个格子越界了或者和棋盘上已有的格子重叠了就算碰撞。bool collides(ActivePiece piece, int px, int py) { for (final (dx, dy) in piece.occupiedCells) { final x px dx; final y py dy; if (x 0 || x Board.width || y Board.height) return true; if (y 0 board.get(x, y) ! 0) return true; } return false; }这里有一个新手最容易掉进去的坑y 0到底算不算碰撞答案是不算。理由很简单方块刚生成时顶部可能有一部分在棋盘上边界之外也就是y为负数。如果只要越界就算碰撞那新方块一出生就“撞死了”游戏直接开始即结束。所有逻辑里顶部的负坐标都应当被允许直到方块自然下落进入棋盘。这个细节是我写第一版时反复栽跟头的地方后来用单元测试才彻底把它钉死。边界检查必须放在格子重叠检查之前因为board.get(x, y)在x和y越界时会报错先检查边界能避免脏数据进入棋盘。一次遍历检查完所有格子最坏16个点对20x10的棋盘来说没有任何性能压力。4.3 落定与消行双指针写法的好处方块无法继续下落时就要把它的格子写进棋盘这一步叫落定lock。落定要注意一个细节如果方块的一部分还在棋盘上边界之上y 0这些格子不能写入棋盘否则数组下标直接越界。void lock(ActivePiece piece) { for (final (dx, dy) in piece.occupiedCells) { final x piece.x dx; final y piece.y dy; if (y 0) continue; board.set(x, y, piece.type.index 1); } }消行的逻辑是从棋盘底部往上扫描如果某一行的所有格子都不为0就消除这一行。最简单的写法是removeAt加insert(0, ...)但连续消两行以上时索引会跳位容易漏行。我推荐用双指针写法int clearLines() { int cleared 0; int writeRow Board.height - 1; for (int readRow Board.height - 1; readRow 0; readRow--) { if (board.isRowFull(readRow)) { cleared; continue; } _cells[writeRow--] _cells[readRow]; } for (; writeRow 0; writeRow--) { _cells[writeRow] Listint.filled(Board.width, 0); } return cleared; }这个写法的妙处在于writeRow就像一个“搬运工”游标只把没有被消除的行往下搬遇到满行就跳过剩下的顶部位置全部填空行。一次性完成重排没有反复插入删除的索引陷阱。计分规则我用了经典公式消1行100分2行300分3行500分4行800分。这个数值不是重点重点是留在代码里一眼能看懂。const lineScores [0, 100, 300, 500, 800]; score lineScores[cleared];4.4 7-Bag随机生成器保证游戏公平性俄罗斯方块的随机策略直接影响手感。如果每次随机都是独立均匀分布那么连续四五次出现同一种方块的可能性是存在的玩家会抓狂。绝大多数现代俄罗斯方块实现都采用7-Bag策略把7种方块放进一个容器打乱顺序后依次取出取完再重新装一袋。class SevenBag { final Random _random; final ListTetrominoType _bag []; SevenBag([Random? random]) : _random random ?? Random(); TetrominoType next() { if (_bag.isEmpty) { _bag ..addAll(TetrominoType.values) ..shuffle(_random); } return _bag.removeAt(0); } }这个实现保证每7个方块中7种形状各出现一次。玩家在连续几个方块到来前就能根据剩余形状做规划这是“运气只决定顺序、不决定种类”的公平性设计。为了测试方便构造函数允许注入Random对象固定种子就能稳定复现序列。4.5 主循环与状态转移把前面所有算法串起来的是tick方法和状态机。游戏的核心状态流转是这样新方块生成如果能放下就进入“下落”状态放不下则游戏结束每次tick尝试让当前方块下移一格下移失败说明方块到底了执行落定落定后检查满行并消除生成新方块回到第1步。tick代码逻辑void tick() { if (gameOver) return; if (_move(0, 1)) return; lock(current!); final cleared board.clearLines(); score lineScores[cleared]; lines cleared; spawnNext(); }这个状态机不需要独立线程也不需要复杂的异步回调。游戏循环每次调用tick推动状态前进一格UI层只需要按帧率调tick再刷新画面即可。后续要支持“硬降”和“暂停”都可以在这个状态机里加分支。5. 先别画界面用单元测试验证游戏内核5.1 为什么用flutter test而不是真机联调验证规则很多人习惯把游戏逻辑写完直接跑flutter run到真机上点点看。不是不行而是效率太低。规则类错误比如旋转偏移、连消计数、墙边碰撞在真机上只能靠眼睛盯出错了还要加上print反复调试特别浪费生命。用flutter test跑纯Dart测试最大的优势是快和稳定。不启动模拟器、不依赖屏幕、不发生偶发时序问题几百毫秒内把十几个用例全部跑完。界面还没开始写规则已经被验证过一遍后面接UI时的心理压力会小很多。5.2 关键测试用例设计我的测试目录和lib/game目录一一对应这样写测试时基本不用思考“这个测试该放哪”直接抄目录结构。下面是我认为最开始就应该写的几个用例。第一个用例验证方块出生时不会立刻判定游戏结束。这个用例直接检验了前面提到的“顶部负坐标不判碰撞”规则test(spawn piece with negative y does not trigger game over, () { final game TetrisGame(); game.spawnNext(); expect(game.gameOver, isFalse); });第二个用例验证I方块旋转四次后恢复原形。这个用例能抓住旋转方向或公式写反的问题test(I shape rotates back to original after four turns, () { final original tetrominoShapes[TetrominoType.i]!; var shape original; for (var i 0; i 4; i) { shape rotateClockwise(shape); } expect(shape, equals(original)); });第三个用例验证消行逻辑。我会手动把某一行填满调用clearLines然后断言棋盘高度减少了test(clearLines removes full rows, () { final board Board(); for (var x 0; x Board.width; x) { board.set(x, Board.height - 1, 1); } final cleared board.clearLines(); expect(cleared, 1); expect(board.get(0, Board.height - 1), 0); });第四个用例验证7-Bag的公平性test(seven bag contains every type exactly once, () { final bag SevenBag(Random(2024)); final firstBatch List.generate(7, (_) bag.next()); expect(firstBatch.toSet(), TetrominoType.values.toSet()); });这些用例覆盖了游戏核心规则中最容易出错的四个角落。等界面写完、你开始在真机上玩的时候会感谢当初花二十分钟写了它们。5.3 测试过程中容易翻车的地方写测试时有四个细节我必须提醒你。第一Dart判断列表相等要用equals而不是直接比较shape和original两个列表比较的是引用而不是内容。我第一版测试就是这么写失败的。第二record类型的断言要展开字段来比较直接比较整个元组在部分场景下会得到意外的结果。第三7-Bag的测试不要依赖具体的随机种子结果只断言“集合相等”就够否则换个Flutter版本随机算法一变测试就莫名红了。第四消行的测试数据一定要从底部往上构造如果顶部有洞、底下一行满着你的测试意图像是“消除一行”结果却和你预期不符问题往往出在数据构造方向而不是算法本身。我现在写这个内核用到的所有经验都是从这些坑里爬出来的。测试不是为了证明代码是对的而是为了在改坏规则时能立刻听到警报。到这里俄罗斯方块的核心已经能跑了。我个人最大的感受是先做纯逻辑内核真的省心——后面我接界面时几乎没改过game目录下的代码。下一篇我会把CustomPainter画棋盘、动画下落、手势和按钮输入接到这个内核上。如果你做到一半发现旋转或者消行行为和预期不符不要急着怀疑设备或依赖库先跑一遍这里的测试八成是坐标系或者边界条件的问题。这个小游戏看起来简单但把数据结构钉死之后后面就是水到渠成的事。
返回列表