1. 项目概述与整体设计思路
1.1 为什么在OpenHarmony上用Flutter做游戏集合App
先说结论:Flutter跑在OpenHarmony上这件事,现在已经不是"能不能跑"的阶段,而是"怎么跑得稳、跑得好看"的阶段了。我大概在半年前开始接触这个技术方向,当时就想搞一个游戏集合App,覆盖几个经典的小游戏,俄罗斯方块排第一个,因为它的逻辑足够经典、状态管理足够清晰,做出来消行动画的视觉反馈也很有成就感。
很多人在OpenHarmony上开发,第一反应是用ArkUI直接写界面,这当然没问题。但如果你手里已经有一套Flutter代码库,或者你像我一样看好Flutter的跨平台渲染一致性,那OpenHarmony官方维护的Flutter适配分支非常值得一试。这套组合的优势在于:Flutter负责所有UI渲染和游戏逻辑,OpenHarmony只提供系统能力和生命周期容器,两边各干各的活儿,互不干扰。
从实际体验来看,Canvas渲染在OpenHarmony上的表现和我们平时在Android、iOS上跑的Flutter应用差距不大,GPU合成链路的适配已经比较成熟。对一个游戏集合App来说,这个稳定性和渲染质量是完全够用的。
1.2 游戏集合App的模块划分
游戏集合App的架构,我的思路很直接:壳工程负责承载游戏列表和公共能力,每个游戏独立成模块。俄罗斯方块模块内部再拆成三层——数据层(棋盘状态、方块状态)、控制层(游戏逻辑、输入处理)、表现层(渲染控件、动画控制器)。
壳工程用Flutter的标准导航结构,首页做一个网格入口,点击进入对应的游戏。这个壳没什么技术含量,反而是游戏模块内部的分层值得提前想清楚,不然后面加消行动画的时候,逻辑和渲染搅在一起,改起来会很痛苦。
俄罗斯方块模块的数据结构,我一开始是用二维数组存棋盘,后来发现处理消行判断和动画绑定效果时,二维数组的反查不够直观,所以换成了行位掩码方案。棋盘横坐标是8列,每一行用一个8位无符号整数表示,方块是否占用某格,就看对应bit是不是1。这个方案在后面实现消行动画的"逐行独立动画"时,帮了大忙。
我把整个消行动画的设计目标定义为三点:够炫但不抢戏、逻辑先行且可控、性能开销尽量低。这三点不是随便写的,而是结合游戏集合App的整体体验来定的。如果消行动画做得太花哨,每一行消掉都搞个全屏粒子爆炸,那用户玩十分钟眼睛就花了。反过来如果只是把整行瞬间清空,又完全没有视觉回馈。平衡点就是:行的消失要有一个短暂的"存在感",但不能拖节奏。
1.3 技术选型的几个关键决策
在技术选型上,我做了一个和传统做法不太一样的决定:游戏渲染不用第三方游戏引擎,也不引入外部物理库,全部基于Flutter自带的Widget和Animation体系实现。原因有几个:
第一,俄罗斯方块的元素是标准网格和矩形块,不需要复杂的物理碰撞模拟,自己做碰撞检测的计算量极小。第二,Flutter自带的动画控制器和一些组件足够实现消行的核心效果,引入额外库反而增加适配OpenHarmony分支的兼容风险。第三,游戏集合App后续的其他游戏,有的需要物理引擎、有的需要粒子系统,这时候按需引入库更好,而不是一开始就背一个重引擎的包袱。
实际开发下来,这个坚持是对的。消行效果用到的关键动画能力——位移动画、缩放动画、透明度动画、自定义Painter——都是Flutter直接提供的。在OpenHarmony适配分支上,这些API的兼容性也经过了大量测试,稳定性有保障。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 俄罗斯方块核心逻辑的梳理与实现
2.1 棋盘模型与方块状态管理
俄罗斯方块做得多了你会发现,代码写得好不好,一半取决于棋盘数据结构设计得好不好。我的方案是把棋盘定义为一个List<int>,总共20行,每行8位掩码:
dart复制class Board {
static const int width = 8;
static const int height = 20;
late List<int> _rows;
Board() {
_rows = List<int>.filled(height, 0);
}
bool isOccupied(int row, int col) {
if (row < 0 || row >= height || col < 0 || col >= width) return true;
return ((_rows[row] >> col) & 1) == 1;
}
void setCell(int row, int col, bool occupied) {
if (row < 0 || row >= height || col < 0 || col >= width) return;
if (occupied) {
_rows[row] |= 1 << col;
} else {
_rows[row] &= ~(1 << col);
}
}
}
位掩码方案一个很直接的好处:判断一行是否满,只需要检查_rows[row]是否等于0xFF。如果某行等于0xFF,说明这行全满,应该进入消行流程。用二维布尔数组的话,你得遍历那一行的每个格子确认,写起来繁琐,跑起来也多几行循环指令。
方块形状我用的是相对坐标列表。以七种标准俄罗斯方块为例,每个形状定义一个旋转状态列表,每次旋转时切换到下一个状态:
dart复制enum TetrominoType { I, J, L, O, S, T, Z }
class Tetromino {
final TetrominoType type;
late List<List<(int, int)>> _rotations;
int _rotationIndex;
List<(int, int)> get cells => _rotations[_rotationIndex];
}
对了,Dart要支持(int, int)这种元组写法,项目SDK版本需要到3.0以后。如果你用的是OpenHarmony应用开发环境,Dart版本跟着Flutter分支走,问题不大。
2.2 移动、旋转与碰撞检测的细节
碰撞检测的规则很简单:方块每执行一次位移或旋转,就先预测目标位置,如果目标位置的每个坐标格在棋盘上都是空的,就允许操作执行;否则取消操作或触发固定逻辑。
这里有一个新手容易踩的坑:旋转检测的偏移修正。7字形和Z字形方块旋转时可能出现"本来能转、但贴着墙转不过去"的情况,你需要在旋转失败时尝试左右平移1格,看看能不能找到落点。这是俄罗斯方块实现中公认的"踢墙"逻辑。我在代码里实现了一个简化的踢墙逻辑:
dart复制bool tryRotate(List<(int, int)> currentCells) {
List<(int, int)> rotated = rotateCells(currentCells);
for (int offset in [-1, 1, -2, 2]) {
List<(int, int)> shifted = shiftCells(rotated, offset);
if (_isValidPosition(shifted)) {
applyCells(shifted);
return true;
}
}
return false;
}
这个偏移序列不需要多复杂,能覆盖最常见的几种卡墙场景就够用了。真正的俄罗斯方块标准SRS旋转系统连坐标表和各种踢墙规则都是死的,有兴趣可以去查规范,但对一个游戏集合App来说,简化版踢墙已经能带来不错的操作手感。
2.3 消行判定的完整流程
消行判定是整个模块的"触发器"。传统做法是:先固定当前活动方块,然后从棋盘最底行开始往上扫,每一行检查是否满行,满行就记下来。这里有一个实现上的选择:是一行一行单独消,还是等所有满行一起消?
我选择了"等一次下落稳定后,一次性消除所有满行"。这样做的好处是:动画只需要触发一轮,不会出现两行动画交错播放导致的状态混乱。而且在计分规则上也符合标准俄罗斯方块的逻辑——一次消1行得100分,消2行得300分,消3行得500分,消4行得800分。
dart复制List<int> findFullRows() {
List<int> fullRows = [];
for (int row = 0; row < Board.height; row++) {
if (_board.rows[row] == 0xFF) {
fullRows.add(row);
}
}
return fullRows;
}
找到满行之后,就进入消行动画的主流程了。
3. 消行动画效果的进阶实现
3.1 动画方案选型对比
俄罗斯方块的消行动画,网上能找到的实现大概有三类:
方案一:透明渐隐。满行从完全可见到完全透明,然后删除该行数据,上方的行整体下移。这个方法实现最简单,但视觉上偏平淡,缺少"消除"的力量感。
方案二:缩放塌陷。满行在垂直方向上压缩成一条细线,然后消失。这种方式有一点立体感和重力感,比纯渐隐好不少,实现也不复杂,用Transform.scale直接缩放Y轴就行。
方案三:分割碎裂。把满行的每个格子独立拆出来,各自做不同方向的位移动画,同时配合渐隐,模拟"打碎"的效果。视觉冲击力最强,但代码复杂度也最高,需要为每个格子单独管理动画状态。
我在这三类方案里做了折中和结合:主效果是Y轴压缩 + 闪烁高亮,同时在行内部加一个从左往右的"扫过"高光效果。扫过高光其实就是一个透明的遮盖层做水平位移动画,视觉上像是行内容被"擦除"了。这样既保留了方案的视觉冲击力,又避免了碎片化的每格动画带来的性能压力。
3.2 行动画控制器的状态管理
消行动画的核心难点不是单个动画怎么写,而是多个满行的动画状态怎么协同。我设计了一个RowClearAnimation类,每一行在消行时创建一个实例,内部持有独立的AnimationController和动画状态:
dart复制class RowClearAnimation {
final int rowIndex;
late AnimationController controller;
late Animation<double> progress;
late Animation<double> highlightProgress;
RowClearAnimation(int this.rowIndex, TickerProvider vsync) {
controller = AnimationController(vsync: vsync, duration: Duration(milliseconds: 320));
progress = CurvedAnimation(parent: controller, curve: Curves.easeInOutCubic);
highlightProgress = CurvedAnimation(parent: controller, curve: Curves.linear);
}
void start() => controller.forward();
}
每个消行动画用同一个320毫秒时长, 进度从0到1。在这个进度上,我同时派出三个子效果:
- Y轴压缩效果:Progress 0到1,行高度从原始值逐渐缩到0,使用easeInOutCubic曲线控制非线性。
- 高亮闪白效果:Progress 0到0.5时颜色从正常色过渡到白色,0.5到1时再变回原色。让玩家先看到"这行被击中了"。
- 扫过擦除效果:用另一个AnimationController(或者同一Controller映射不同区间),驱动一个从左往右移动的半透明遮盖层。
动画开始之后,棋盘数据从这个时刻起就不再删除该行,直到动画播完再做真正的数据删除和行下移操作。这就保证了"视觉先表达、逻辑后处理"的时序一致性。
3.3 用CustomPainter把消行动画画出来
既然俄罗斯方块的主体是网格,直接用普通的Widget堆格子当然是可行的,但消行动画要对任意一行做压缩、移位和高亮,Widget方案的性能开销很大,因为每一格都是独立的RenderObject。更好的做法是用CustomPainter一次性把整个棋盘画出来,这样动画期间只需要触发repaint,让Painter根据动画进度计算出每一行的绘制参数。
我在项目中实现了这样一个Painter:
dart复制class GameBoardPainter extends CustomPainter {
final Board board;
final List<RowClearAnimation> rowAnimations;
@override
void paint(Canvas canvas, Size size) {
// 先绘制普通行
for (int row = 0; row < Board.height; row++) {
bool animating = _isRowAnimating(row);
if (animating) continue; // 动画行单独绘制,同时跳过基础绘制
for (int col = 0; col < Board.width; col++) {
if (board.isOccupied(row, col)) {
_drawBlock(canvas, row, col, normalColor);
}
}
}
// 再绘制动画行,叠加效果
for (RowClearAnimation anim in rowAnimations) {
_drawAnimatedRow(canvas, anim);
}
}
@override
bool shouldRepaint(covariant GameBoardPainter oldDelegate) {
return true;
}
}
绘制顺序有一个细节值得一提:动画行要最后绘制。因为动画过程中有高亮闪白和覆盖擦除效果,这些效果需要覆盖在正常格子之上才能被完整看到。如果先画动画行再画普通行,动画行上的高亮效果会被非动画行的格子遮挡一部分。
_drawAnimatedRow的内部逻辑大概是这样:
dart复制void _drawAnimatedRow(Canvas canvas, RowClearAnimation anim) {
double progress = anim.progress.value;
double remainingHeight = 1.0 - progress;
// 每个格子绘制时,先算出行当前的垂直偏移和高度
for (int col = 0; col < Board.width; col++) {
if (!board.isOccupied(anim.rowIndex, col)) continue;
Rect originalRect = _getBlockRect(anim.rowIndex, col);
Rect targetRect = Rect.fromLTWH(
originalRect.left,
originalRect.center.dy - (originalRect.height * remainingHeight / 2),
originalRect.width,
originalRect.height * remainingHeight,
);
// 先画基底色
canvas.drawRect(targetRect, normalPaint);
// 高亮闪白:进度在0.5之前,把格子涂白,透明度渐入渐出
double flashAlpha = (progress < 0.5) ? progress * 2 : (1.0 - progress) * 2;
if (flashAlpha > 0) {
canvas.drawRect(targetRect, whitePaint.withOpacity(flashAlpha));
}
}
// 扫过擦除:画一个半透明渐变矩形沿着行水平移动
double sweepX = progress * (size.width + blockSize);
Rect sweepRect = Rect.fromLTWH(sweepX - blockSize, rowTop, blockSize, rowHeight);
canvas.drawRect(sweepRect, sweepPaint); // sweepPaint是半透明白色LinearGradient
}
这个Painter在消行动画期间会每帧执行一次,绘制大约20行乘以8列的矩形,工作量完全可控。在OpenHarmony设备上实测下来,320毫秒的动画期间没有掉帧或者卡顿的问题。
3.4 动画结束后的数据下移
动画播放结束之后,真正重要的数据操作才执行。这里有一个很容易错的细节:动画行可能有多个,而且它们在数据上可能不相邻。比如第12行和第15行都满了,消掉之后,第16行往下都要下移,而且下移的距离不是固定的1行,是要考虑到"下方有多少行被消掉了"。
我的实现是自底向上扫描:
dart复制void clearRows(List<int> fullRows) {
Set<int> fullRowSet = fullRows.toSet();
int rowWriteIndex = Board.height - 1;
for (int rowReadIndex = Board.height - 1; rowReadIndex >= 0; rowReadIndex--) {
while (rowWriteIndex >= 0 && fullRowSet.contains(rowWriteIndex)) {
rowWriteIndex--;
}
if (rowReadIndex != rowWriteIndex) {
_board.rows[rowWriteIndex] = _board.rows[rowReadIndex];
}
rowWriteIndex--;
}
// 剩余顶部行补0
for (int row = 0; row < rowWriteIndex + 1; row++) {
_board.rows[row] = 0;
}
}
这个写法有点绕,但逻辑是清晰的:从底部开始遍历,跳过被标记为消掉的行,把保留数据尽量往下写。写入位置遇到消掉的行就往前跳,最终顶部空缺出来的行填0就行。
数据层的行下移我在动画结束时调用一次,不逐帧做数据更新。这样做的好处是,动画期间玩家看到的一切都是"视觉特效",真正的棋盘数据在动画结束后的同一帧内完成重排,不会出现中间状态不可控的问题。
3.5 加分表现与等级联动的实现细节
消行动画和分数弹跳之间最好是联动的。我在动画刚触发的瞬间,会让分数标签做一次短促的放大再回落的弹性动画,同时飘出一个" +100 "的文字往外浮起消散。这样玩家能感受到"这一瞬发生了得分事件"。
加分动画的实现思路:
dart复制void _triggerScorePopup(int points, Offset position) {
_scorePopups.add(ScorePopup(
points: points,
position: position,
controller: AnimationController(
vsync: this,
duration: Duration(milliseconds: 600),
)..forward(),
));
}
每个ScorePopup也监听它的Controller,在build方法里把弹跳位移和透明度实时映射到界面上。弹跳用的是Interval加Curve组合,0到0.2是放大的,0.2到0.6是回落加位移的,0.6到1是渐隐的。
等级上去了之后,不只是方块下落速度变快,消行动画的节奏也要略微加快,否则高级关卡会有"动画拖时间"的割裂感。我给动画时长的基准定了一个简单公式:
dart复制Duration _getClearAnimationDuration() {
double speedFactor = 1.0 + (level - 1) * 0.06;
return Duration(milliseconds: (320 / speedFactor).round());
}
这样到了第5级,消行动画时长缩短到260毫秒左右,不会觉得慢。
4. Flutter on OpenHarmony 环境搭建与适配要点
4.1 开发环境的准备步骤
整个Flutter for OpenHarmony的开发环境搭建,网上资料其实已经很多了,但我在实际准备过程中还是踩了一些坑,这里直接给出一份可复用的操作记录。
第一步:确保OpenHarmony SDK版本符合Flutter适配分支的要求。我用的版本对应API 10,太老或太新的API版本都可能出现适配分支内置接口不兼容的情况。SDK路径配置好之后,在环境变量里把OHOS_SDK_HOME指到你的SDK安装目录。
第二步:下载Flutter的OpenHarmony适配分支。这一步注意不要用普通的Flutter官方主线,因为官方主线对OpenHarmony的支持还不够完整。要用社区维护的OpenHarmony版本分支,具体版本号以你当时拉取的最新release为准。
bash复制git clone -b ohos-3.7 https://github.com/你的flutter_fork仓库/ohos-flutter.git
export PATH="$PWD/ohos-flutter/bin:$PATH"
第三步:创建工程后增加OpenHarmony平台。创建好普通Flutter工程后,在工程根目录执行:
bash复制flutter create --platforms=ohos .
这时候工程目录下会出现一个ohos文件夹,里面是OpenHarmony的HAP工程结构,其中entry/src/main是UIAbility入口。Flutter应用代码不需要修改,入口承载在UIAbility的生命周期里,这个和Android的Activity承载Flutter的机制是类似的。
4.2 与系统容器通信的常用姿势
在游戏集合App里,壳工程首页的网格数据可以放在Flutter里直接管理,不需要和OpenHarmony端做大量通信。但有一些场景确实需要走到系统侧,比如读取设备分辨率适配不同屏幕的棋盘缩放、获取系统时区来显示本地时间,以及退出确认弹窗的本地化。
Flutter和OpenHarmony层的通信,我用的是标准的方法通道(MethodChannel)。在OpenHarmony侧,你需要创建一个自定义的Ability并在配置里注册好通道名,然后在Flutter侧用相同通道名发起调用:
dart复制static const platform = MethodChannel('game_app/device_info');
Future<String> getDeviceName() async {
final String result = await platform.invokeMethod('getDeviceName');
return result;
}
这个调用在OpenHarmony侧解析时,就是一次普通的Ability方法分发。这块的适配难度不高,网上也有不少可以参考的示例。唯一要注意的是:通道名一定要全局唯一,多个页面或模块别撞了。
4.3 OpenHarmony适配中需要用到的特殊处理
我在开发中遇到过一个问题,在OpenHarmony真机上跑Flutter应用时,自适应屏幕对焦和触摸事件的映射偶尔会偏移。排查下来发现是屏幕安全区域和Flutter渲染区域的坐标基准不一致导致的。解决办法也很直接:在Flutter入口读取OpenHarmony侧传入的安全区偏移量,做一次全局的偏移修正。
这个属于设备适配的个性化问题,不同设备的处理方式可能不同,建议在游戏集合App的入口做一个统一的环境初始化,启动时读取设备参数,然后传给各个游戏模块的父容器。
还有一个小细节是退出App时的生命周期处理。游戏过程中如果用户按了后台切换,需要确保动画控制器暂停,而不是继续跑。我在AppLifecycleListener里做了统一的处理:
dart复制AppLifecycleListener(onStateChange: (state) {
if (state == AppLifecycleState.paused) {
_gameController?.pauseGame();
} else if (state == AppLifecycleState.resumed) {
_gameController?.resumeGame();
}
});
这个处理对游戏App来说至关重要。不然用户切后台几分钟再回来,俄罗斯方块已经自然落到了无法操作的地步,体验非常糟糕。
5. 常见问题与性能调优
5.1 真机调试中频繁出现的编译与链接问题
OpenHarmony上跑Flutter应用,编译失败的大部分原因都出在原生工程侧,而不是Dart代码上。最常见的一个报错是ohos目录下的原生工程缺少某个依赖库配置,这时候把构建日志打开,找到具体是哪个so文件或模块缺失,对应地去oh-package.json5里补依赖就行。
另外一个频率很高的问题是:直接修改Flutter侧的Dart代码后,重新构建时原生工程没有同步更新。我的处理办法是每次修改Dart代码后,在工程根目录执行一次flutter build hap --debug,确保整个HAP包重新构建。如果你用IDE自带的增量编译,有时候Flutter插件不会自动触发重新打包。
还有一次我遇到构建进程卡死的现象,最后排查发现是电脑开了多个Flutter daemon进程导致的,杀掉多余的Dart进程重启IDE就恢复了。
5.2 消行动画的性能优化实录
消行动画的性能问题主要体现在两个维度:一个是绘制帧数,一个是动画期间的内存抖动。
针对绘制帧数,我把Painter做了两处优化:第一,普通格子的绘制利用Canvas的批量矩形绘制能力,能合并的矩形尽量合并成一个drawRect调用,减少绘制指令数。第二,把不需要每帧重建的Paint对象和渐变对象缓存起来,避免动画循环创建新对象带来的垃圾回收压力。
dart复制// 缓存的Paint对象,避免每帧重建
final Paint _normalPaint = Paint()..style = PaintingStyle.fill;
final Paint _whitePaint = Paint()..style = PaintingStyle.fill;
还有一个优化点是动画监听器里做逻辑分支,避免不必要的setState。消行动画期间,棋盘的非动画区域根本不需要重建,所以我在动画回调里只标记需要重绘的区域,而不是整个控件都触发重建:
dart复制void _handleAnimationTick() {
// 只更新Painter的进度标记,不调用setState,而是直接触发repaint
_boardPainter.setAnimations(_clearAnimations);
_boardPainter.notifyListeners();
}
这里我用了ChangeNotifier接CustomPainter的repaint机制,效果比每次都setState干净很多。
5.3 状态管理中的三个容易踩坑的点
一、动画Controller的生命周期。游戏结束或者页面销毁时,正在播放的动画Controller必须安全释放,否则会出现FlutterError: A Ticker was active but never stopped的崩溃。我在游戏结束时统一调用disposeAllControllers(),把所有的行动画、分数弹跳动画、下落动画全部停掉。
二、多行消行动画的时序协调。当同时消掉4行且这4行位置分散时,所有行的动画必须在同一个vsync上启动,否则会出现各行动画不同步的视觉问题。我的做法是给所有行动画实例共享同一个TickerProviderStateMixin,确保它们对接同一个时钟。
三、消行动画与下落动画的插队问题。当消行动画还在播放阶段,游戏处于短暂的"锁定"状态,此时不能再继续下落或者旋转。我在游戏状态机里加了一个_isClearingAnimationActive标志位,在这个标志位置位期间,所有控制输入都会进入等待队列,动画结束后再统一处理缓冲输入的按键。
5.4 关于手感调试的一些心得
俄罗斯方块的游戏体验很大一部分来自操作手感。消行动画的时长、分数弹跳的位置、方块下落动画的延时,这些细节都要反复调整。
我给下落动画做了一个"重力递增"的缓动效果,而不是匀速下落。具体做法是每一行的下落步进用当前等级的间隔时间计算,但在一次软置底操作时,用一个长一点的时间参数做动画,模拟出"快速到底"和"缓慢下落"的不同手感。
在消行动画上,我也做了一件比较隐蔽的手感优化:动画的前80毫秒只是高亮闪白,行还没有开始压缩。这样做有两个好处:一是让玩家先看到"命中"反馈,再看到"消除"动作,符合视觉因果直觉;二是给玩家一个极短的时间窗口去感知自己消除的是哪几行,不至于动画太急促完全反应不过来。
启动新的消行动画后,初始行高度保持不变,高亮进度自己走,到了后半段才开始压缩,这个节奏实测下来是最舒服的。
6. 效果扩展与OpenHarmony平台发展的一些想法
消行动画做完之后,我还往上扩展了几个效果,这里分享一下思路。
一个是连消的图标反馈。连续消4行(也就是Tetris)时,在棋盘一侧飘出一个"TETRIS!"的样式化文字,用放大浮现动画强调。这个效果在动画上不复杂,但对玩家的成就感提升非常明显。我用了文字透明度+缩放的组合动画,接入消行完成后的事件回调即可。
另一个是背景颜色的动态渐变。随着消行次数增多,棋盘背景从深蓝逐渐过渡到紫红色,让玩家从余光中就能感知到游戏的节奏变化。这个效果非常轻量,只需要在CustomPainter的drawBackground里根据累计消行数插值出一个Color就行。
最后说点对整个方向的看法。Flutter在OpenHarmony上的生态还在持续发展,虽然目前覆盖的API和插件还不算全面,但基础能力已经很能打了。对中小型工具应用和游戏类应用,这个组合的性价比非常高——既能用一套代码覆盖多个桌面和移动平台,又能顺手接入OpenHarmony的分布式能力。俄罗斯方块只是个开始,后面我打算把更多经典小游戏移植到这个集合里,消行动画的这套思路,也可以直接复用到其他消除类和下落类的游戏上。
如果大家在自己的项目中实现了类似的消行动画效果,或者踩到了什么有意思的坑,欢迎到评论区一起聊聊。毕竟这种跨平台的游戏开发实战经验,多交流才能把这个生态做得更好。
