通过网盘分享的文件:game_flutter_openharmony.zip
链接: https://pan.baidu.com/s/1ryUS1A0zcvXGrDaStu530w 提取码: tqip

前言

俄罗斯方块的核心玩法除了堆叠方块,还有消行。当一行被填满时,这一行会消除,上面的方块落下来。

消行是得分的主要方式,一次消多行分数更高。这篇来聊聊消行的实现。

消行逻辑看起来简单,但有一些细节需要注意:从下往上遍历、删除后索引变化、连续消行的处理等。
请添加图片描述

棋盘数据结构

static const int rows = 20, cols = 10;
late List<List<Color?>> board;

棋盘是20行10列的二维数组,每个格子存储颜色。

Color?表示可空,null表示空格子,有颜色表示有方块。

用颜色而不是布尔值,是因为需要保留方块的颜色信息,让游戏更好看。

初始化

board = List.generate(rows, (_) => List.filled(cols, null));

全部填null,空棋盘。

List.generate创建rows行,每行用List.filled创建cols个null。

消行逻辑

void _clearLines() {
  int cleared = 0;
  for (int y = rows - 1; y >= 0; y--) {
    if (board[y].every((c) => c != null)) {
      board.removeAt(y);
      board.insert(0, List.filled(cols, null));
      cleared++;
      y++;
    }
  }
  score += cleared * 100;
}

这是消行的核心方法,检查并消除所有填满的行。

从下往上遍历

for (int y = rows - 1; y >= 0; y--) {

从最底行(rows-1=19)开始往上检查。

为什么从下往上?因为消除一行后,上面的行会落下来,索引会变化。从下往上可以正确处理连续消行。

如果从上往下,消除一行后,原来的下一行变成了当前行,但y已经增加了,会跳过这一行。

检查是否填满

if (board[y].every((c) => c != null)) {

every方法检查列表的每个元素是否都满足条件。

如果这一行的每个格子都不是null(都有颜色),说明填满了。

every接收一个函数,对每个元素调用,全部返回true才返回true。

删除这一行

board.removeAt(y);

从列表中删除第y行。removeAt会把后面的元素往前移。

顶部插入空行

board.insert(0, List.filled(cols, null));

在顶部(索引0)插入一行空格子。

这样总行数保持不变(还是20行),效果就是上面的行"落下来"了。

重新检查当前行

y++;

删除一行后,原来y+1行变成了y行。

y++抵消循环的y--,下一次循环还是检查当前位置(现在是原来的下一行)。

这样可以处理连续多行都填满的情况。比如同时消除第18、19行,删除19行后,原来的18行变成19行,y++后下一次循环还是检查19行。

计分

cleared++;
...
score += cleared * 100;

记录消了几行,每行100分。

消行时机

void _moveDown() {
  if (gameOver) return;
  setState(() {
    if (_canMove(0, 1)) {
      pieceY++;
    } else {
      _lockPiece();
      _clearLines();
      _spawnPiece();
    }
  });
}

方块落到底部后:

  1. _lockPiece(): 把方块固定到棋盘
  2. _clearLines(): 检查并消除满行
  3. _spawnPiece(): 生成新方块

顺序很重要,先锁定再消行。如果先消行再锁定,当前方块还没写入board,消行检查会不正确。

游戏结束检查

if (gameOver) return;

游戏已经结束,不再处理下落。

能下移

if (_canMove(0, 1)) {
  pieceY++;
}

如果能下移,就下移一格。

不能下移

} else {
  _lockPiece();
  _clearLines();
  _spawnPiece();
}

不能下移说明到底了(碰到底部或已有方块),执行锁定、消行、生成新方块的流程。

连消加分

当前实现每行100分,可以改成连消加成:

void _clearLines() {
  int cleared = 0;
  // ... 消行逻辑 ...
  
  // 连消加成
  switch (cleared) {
    case 1: score += 100; break;
    case 2: score += 300; break;  // 1.5倍
    case 3: score += 500; break;  // 约1.67倍
    case 4: score += 800; break;  // 2倍,俄罗斯方块叫Tetris
  }
}

一次消4行(Tetris)是最高分,鼓励玩家堆高再消。

这是俄罗斯方块的经典计分规则,让游戏更有策略性。玩家需要权衡:是尽快消行保持安全,还是冒险堆高争取一次消4行。

视觉效果

当前实现是瞬间消除,没有动画。

如果想加动画,可以:

void _clearLines() async {
  List<int> fullRows = [];
  for (int y = 0; y < rows; y++) {
    if (board[y].every((c) => c != null)) {
      fullRows.add(y);
    }
  }
  
  if (fullRows.isEmpty) return;
  
  // 闪烁效果
  for (int i = 0; i < 3; i++) {
    setState(() {
      for (int y in fullRows) {
        board[y] = List.filled(cols, i % 2 == 0 ? Colors.white : null);
      }
    });
    await Future.delayed(Duration(milliseconds: 100));
  }
  
  // 实际消除
  for (int y in fullRows.reversed) {
    board.removeAt(y);
    board.insert(0, List.filled(cols, null));
  }
  score += fullRows.length * 100;
  setState(() {});
}

这个实现会让满行闪烁3次(白色和透明交替),然后再消除。

为什么用reversed

for (int y in fullRows.reversed) {

fullRows是从上到下的顺序(因为遍历是从0开始)。删除时要从下往上删,否则索引会乱。

reversed返回一个反向的Iterable。

但这会让代码复杂很多,当前简化处理,没有动画。

游戏结束判断

void _spawnPiece() {
  // ... 生成新方块 ...
  if (!_canMove(0, 0)) {
    gameOver = true;
    timer?.cancel();
  }
}

新方块生成后,如果初始位置就不合法(和已有方块重叠),说明堆到顶了,游戏结束。

为什么检查_canMove(0, 0)

(0, 0)表示不移动,只检查当前位置是否合法。如果新方块刚生成就和已有方块重叠,说明没有空间了。

取消定时器

timer?.cancel();

游戏结束后取消定时器,方块不再自动下落。

快速下落

void _drop() {
  while (_canMove(0, 1)) pieceY++;
  _moveDown();
}

玩家可以按下键快速落到底部。

while循环一直往下移,直到不能移为止。

然后调用_moveDown()触发锁定和消行。

为什么不直接锁定

// 不好的写法
void _drop() {
  while (_canMove(0, 1)) pieceY++;
  _lockPiece();
  _clearLines();
  _spawnPiece();
}

这样写也可以,但重复了_moveDown里的逻辑。调用_moveDown更简洁,而且如果以后_moveDown的逻辑变了,_drop也会自动更新。

定时下落

timer = Timer.periodic(const Duration(milliseconds: 500), (_) => _moveDown());

每500毫秒自动下落一格。

难度递增

可以根据分数加快速度:

int speed = 500 - (score ~/ 500) * 50;
speed = speed.clamp(100, 500);

每500分加速50毫秒,最快100毫秒。

clamp(100, 500)确保速度在100-500范围内。

要实现这个功能,需要在消行后重新创建定时器:

void _clearLines() {
  // ... 消行逻辑 ...
  if (cleared > 0) {
    timer?.cancel();
    int speed = 500 - (score ~/ 500) * 50;
    speed = speed.clamp(100, 500);
    timer = Timer.periodic(Duration(milliseconds: speed), (_) => _moveDown());
  }
}

分数显示

Padding(padding: const EdgeInsets.all(8), child: Text('分数: $score', style: const TextStyle(fontSize: 20))),

顶部显示当前分数。

控制按钮

Row(
  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
  children: [
    IconButton(icon: const Icon(Icons.arrow_back), onPressed: _moveLeft),
    IconButton(icon: const Icon(Icons.arrow_downward), onPressed: _drop),
    IconButton(icon: const Icon(Icons.rotate_right), onPressed: _rotate),
    IconButton(icon: const Icon(Icons.arrow_forward), onPressed: _moveRight),
  ],
)

四个按钮:左移、快速下落、旋转、右移。

用IconButton和Material Design图标,简洁美观。

游戏结束显示

if (gameOver) Center(
  child: Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      const Text('游戏结束', style: TextStyle(fontSize: 24, color: Colors.red)),
      ElevatedButton(onPressed: () => setState(_initGame), child: const Text('重新开始')),
    ],
  ),
)

游戏结束时显示红色文字和重新开始按钮。

小结

这篇讲了俄罗斯方块的消行效果,核心知识点:

  • every方法: 检查一行是否全部填满,简洁的函数式写法
  • removeAt: 删除指定行,List的内置方法
  • insert(0, …): 顶部插入空行,保持总行数不变
  • 从下往上遍历: 正确处理连续消行,避免索引混乱
  • y++技巧: 删除后重新检查当前位置
  • 消行时机: 锁定方块后立即检查
  • 连消加成: 一次消多行分数更高,增加策略性
  • 游戏结束: 新方块无法放置时结束

消行是俄罗斯方块的核心机制,理解了这个,游戏的主要逻辑就完整了。消行带来的满足感是俄罗斯方块吸引人的重要原因。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐