Flutter&Flame游戏实践#14 | 扫雷 - 逻辑实现

2,806 阅读11分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!


Flutter&Flame 游戏开发系列前言:

该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台原生级 渲染框架,兼具 全端 跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。

第一季:30 篇短文章,快速了解 Flame 基础。[已完结]
第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。

两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》【github 项目首页】 查看。


一、地图数据的生成

上一篇我们完成了基本的界面交互:本节我们将完成核心的游戏逻辑。每个单元格下方有数字或者地雷,其中数字表示该单元格四周八个单元格的地雷数量。这里单元格下面的所有内容为地图数据:

134.gif


1. 数据枚举

每个单元格下方是一个资源图片,它们个数是有限的。所以这里通过 CellType 枚举统一维护,其中包括从 0~8 九个数字和一个地雷。枚举中可以承载对应的资源图像:

enum CellType {
  value0('images/sweeper/type0.svg'),
  value1('images/sweeper/type1.svg'),
  value2('images/sweeper/type2.svg'),
  value3('images/sweeper/type3.svg'),
  value4('images/sweeper/type4.svg'),
  value5('images/sweeper/type5.svg'),
  value6('images/sweeper/type6.svg'),
  value7('images/sweeper/type7.svg'),
  value8('images/sweeper/type8.svg'),
  mine('images/sweeper/mine.svg');
  final String src;
  const CellType(this.src);

  String get key => path.basename(src);
}

2. 雷区: 地图数据映射关系

每个单元格可以通过坐标进行定位,作为唯一标识。每个单元格对应一种 CellType :

image.png

如果将坐标定义为 XY 类型,如下所示通过 typedef 定义 (int, int) 元组别名:

typedef XY = (int, int);

这样地图数据就可以看成 XYCellType 的映射关系。通过 Map<XY, CellType> 进行维护:

Map<XY, CellType> cells = {};

地图数据就是如何创建映射关系。这里通过 _createMine 方法初始化地雷的映射数据,其中有两点考量:

  • [1]. 地图数据并非一开始就生成,而是第一次点击之后生成。这是为了避免第一次点击有概率触雷。下面代码中传入第一次点击的坐标点位 pos
  • [2]. 地雷的遍历生成过程中,并非每次坐标都取随机的点位。这样随机数有概率重复而导致地雷数不足。

这里采用点位池 posPool 收集所有可能的点位,其中去除掉入参的 pos 表示不会在第一次点击出生成地雷。在遍历 mineCount 个数中,从 posPool 中随机取点作为 key,以 CellType.mine 为值,加入到 cells 映射中表示地雷数据。在改点插入地雷之后,从 posPool 中移除。以此来保证地图中地雷点位不会重复:

void _createMine(XY pos, int row, int column,int mineCount) {
  List<XY> posPool = [];
  for (int i = 0; i < row; i++) {
    for (int j = 0; j < column; j++) {
      if (pos != (j, i)) {
        posPool.add((j, i));
      }
    }
  }
  while (cells.length < mineCount) {
    int index = _random.nextInt(posPool.length);
    XY target = posPool[index];
    cells[target] = CellType.mine;
    posPool.remove(target);
  }
}

3. 数值区: 地图数据映射关系

地雷数据生成后,需要计算非雷区对应的数值。这个工作交由 _createCellValue 方法完成,其遍历行列行列数,访问每一个单元格坐标。当非地雷区域时,需要计算当前坐标的周围有多少地雷。具体计算的方式由 _calculate 方法处理,计算完后将该坐标加入到映射关系中,且对应 CellType 相关的数字:

void _createCellValue(int row, int column) {
  for (int y = 0; y < row; y++) {
    for (int x = 0; x < column; x++) {
      if (cells[(x, y)] != CellType.mine) {
        int count = _calculate(x, y);
        cells[(x, y)] = CellType.values[count];
      }
    }
  }
}

计算某点四周的有多少雷非常简单。便利 3*3 九格格子记录雷数量即可。比如计算 (1,8) 点位周围的地雷数量(图中红框中心)。便利方位: y 在 [0,2], x 在 [7~9] 的坐标格即可。代码表示如下:

image.png

int _calculate(int x, int y) {
  int count = 0;
  for (int i =  y - 1; i <= y + 1; i++) {
    for (int j =  x - 1; j <= x + 1; j++) {
      if (cells[(j, i)] == CellType.mine) count++;
    }
  }
  return count;
}

到这里,我们就完成了最核心的一步:生成地图数据。接下来的流程就是在交互过程中翻开单元格,展示其中对应的地图内容即可。


二、游戏状态逻辑: GameStateLogic

游戏在交互过程中有很多数据需要变化。比如扫雷游戏中

  • 行列格数、地雷数等配置数据;
  • 已点击打开的单元格列表、游戏地图数据、已标记的单元格列表、游戏状态等游戏过程中的数据。
  • 顶部栏中剩余地雷数和时间的 LED 屏展示数据。

这里通过 GameStateLogic 类来维护这些数据,以及它们的变化逻辑。


1. 游戏配置数据 GameMode

扫雷游戏包括四种模式,初级、中级、高级和自定义:

enum Mode{
  primary,
  middle,
  advanced,
  diy,
}

每种模式都需要有行列数 size 和地雷数量 mineCount 数据。另外对于 primarymiddleadvanced 三种模式,通过命名构造可以定死相关配置。比如初级模式是 9*9 网格,一共 10 个地雷:

class GameMode {
  final XY size;
  final int mineCount;
  final Mode mode;

  int get column => size.$1;

  int get row => size.$2;

  const GameMode(this.size, this.mineCount):mode=Mode.diy;

  const GameMode.primary()
      : size = (9, 9),
        mineCount = 10,mode=Mode.primary;

  const GameMode.middle()
      : size = (16, 16),
        mineCount = 40,mode=Mode.middle;

  const GameMode.advanced({bool portrait=false})
      : size = portrait?(16, 30):(30, 16),
        mineCount = 99,mode=Mode.advanced;
}

2. GameStateLogic 的成员

游戏在交互过程中,可以将游戏状态归为四个枚举类型,由 GameStatus 表示:

  • 游戏开始是 ready 状态,表示准备完毕,等待翻开单元格。
  • 翻开是一个单元格后,游戏进入 playing 状态,表示游戏进行中。
  • died 状态是点击地雷之后,表示游戏结束。
  • win 状态是打开所有非雷区时,表示游戏成功。
enum GameStatus {
  ready,
  died,
  playing,
  win,
}

GameStateLogic 作为一个 mixin,可以为游戏主类提供额外的能力。其中包含下面的一些游戏过程中需要依赖的数据:

image.png

---->[lib/sweeper/game/logic/game_state_logic.dart]----
mixin GameStateLogic {
  /// 游戏模式
  GameMode mode = const GameMode.middle();
  /// 游戏状态
  GameStatus _status = GameStatus.ready;

  /// 地图数据
  Map<XY, CellType> cells = {};
  /// 已打开点集
  final List<XY> _openPos = [];
  /// 已标记点集
  final List<XY> _markPos = [];

  /// 随机数
  final Random _random = Random();
}

游戏逻辑类中,提供 initMapOrNot 方法触发之前写的 _createMine_createCellValue 方法,初始化附图数据。其中只有当第一次点击前才需要触发,也就是 _openPos 打开坐标列表为空:

void initMapOrNot(XY pos) {
  if (_openPos.isEmpty) {
    status = GameStatus.playing;
    int row = mode.row;
    int column = mode.column;
    _createMine(pos, row, column,mode.mineCount);
    _createCellValue( row, column);
  }
}

3. 打开和标记点位维护

打开点位列表由 _openPos 记录,打开单元格后触发 open 方法,传入坐标加入到 _openPos 中。每次打开单元格后,通过 checkWinGame 方法校验游戏是否成功。游戏成功的校验条件是:

打开所有的非雷单元格。也就是打开点位列表长度等于单元总格数 - 地雷总数时

void open(XY pos) {
  _openPos.add(pos);
  checkWinGame();
}

bool get isWin {
  return _openPos.length == mode.row * mode.column - mode.mineCount;
}

void checkWinGame() {
  if (isWin) {
    Toast.success('恭喜胜利');
    status = GameStatus.win;
  }
}

/// 是否已经打开
bool isOpened(XY pos) => _openPos.contains(pos);

在推理过程中,当确定某一个单元格是地雷,可以通过手势交互标记旗子进行排雷。被标记的旗子对应的单元格坐标是 _markPos 列表。在 GameStateLogic 中,提供 mark 方法添加标记;unMark 方法取消标记;isMarked 方法校验是否已被标记:

image.png

void mark(XY pos) => _markPos.add(pos);

void unMark(XY pos) => _markPos.remove(pos);

bool isMarked(XY pos) => _markPos.contains(pos);

三、手势或鼠标交互事件

前面完成了游戏过程中主要数据的维护。接下来我们将基于手势交互事件,调用相关方法修改数据,来实现游戏功能。上一篇我们实现了拖拽事件,展示出单元格按压的效果。代码在 GameCellLogic 中维护,下面需要当鼠标抬起后,调用 open 方法打开单元格:

---->[lib/sweeper/game/logic/game_cell_logic.dart]----
@override
void onDragEnd(DragEndEvent event) {
  open();
  super.onDragEnd(event);
}

@override
void onTapUp(TapUpEvent event) {
  open();
  super.onTapUp(event);
}

1. 手势抬起的打开逻辑

打开单元格需要做如下几件事:

  • [1]. 当游戏胜利或失败之后, disable 为true。将禁止继续点击,打开单元格。
  • [2]. 按压过程中 _pressedCells 会记录按压的单元格。打开前先通过 _handelMark 校验是否是标记。
  • [3]. 触发 initMapOrNot 方法,在第一次打开前,初始化地图数据。
  • [4]. _handleOpenCell 方法处理具体打开单元格的逻辑。
---->[lib/sweeper/game/logic/game_cell_logic.dart]----
void open() {
  if (game.disable) return;
  if (_pressedCells.isNotEmpty) {
    Cell cell = _pressedCells.first;
    if (_handelMark(cell)) return;
    game.initMapOrNot(cell.pos);
    _handleOpenCell(cell);
    _pressedCells.clear();
  }
  unpressed();
}

标记的单元格点击时,需要取消标记。cell 的 unMark 方法会将标记取消,展示未打卡的单元格;之后调用 game 的 unMark 方法,移除对应的标记点位:

bool _handelMark(Cell cell) {
  if (game.isMarked(cell.pos)) {
    cell.unMark();
    game.unMark(cell.pos);
    return true;
  }
  return false;
}

2. 打开单元格与自动打开

通过单元格的点位坐标,在 cells 地图数据中方位其类型。如果是地雷,触发 gameOver 方法结束游戏。否则将触发 cell.open() 打开单元格。

void _handleOpenCell(Cell cell) {
  CellType? type = game.cells[cell.pos];
  if (type == CellType.mine) {
    gameOver(cell);
  } else {
    cell.open();
    handleAutoOpen(type, cell.pos);
  }
}

打开单元格,就是更换 Cell 构件坐标,对应地图数据中的数字图像。打开后,调用 GameStateLogic 的 open 方法,维护已打开的坐标:

---->[lib/sweeper/game/heroes/cell/cell.dart]----
void open() {
  CellType? type = game.cells[pos];
  if (type != null) {
    svg = game.loader.findSvg(type.key);
    game.open(pos);
  }
}

0 数字单元格以空白展示,如果单元格是 0 数字,需要自动打开周边的 0 单元格,如下所示。

image.png

这里通过 handleAutoOpen 方法处理自动打开的逻辑:校验四周的单元格,发现空格时,触发 autoOpenAt 方法,打开单元格:

void handleAutoOpen(CellType? type, XY pos) {
  if (type != CellType.value0) return;
  int x = pos.$2;
  int y = pos.$1;
  for (int i = x - 1; i <= x + 1; i++) {
    for (int j = y - 1; j <= y + 1; j++) {
        autoOpenAt((j, i));
    }
  }
}

自动打开某个坐标,先通过allowAutoOpen 校验自动打开的条件是:需要非打开的,非标记的点位。然后根据坐标查询对应激活的单元格,非地雷时,打开单元格,继续触发 handleAutoOpen 除了需要自动打开的单元格。

void autoOpenAt(XY pos) {
  if(!game.allowAutoOpen(pos)) return;
  Cell? cell = activeCell(pos);
  if (cell != null) {
    CellType? type = game.cells[pos];
    if (type != CellType.mine) {
      cell.open();
      handleAutoOpen(type, pos);
    }
  }
}

3. 游戏结束与重新开始

打开单元格时,如果是地雷则触发 gameOver 方法,结束游戏:

image.png

gameOver 中首先触发 lose 方法将游戏的当前状态置为死亡,然后需要遍历所有的雷区,打开地雷。然后将当前的地雷通过 died 设为红色的背景地雷。

image.png

void gameOver(Cell cell) {
  game.lose();
  Iterable<Cell> cells = children.whereType<Cell>();
  for (Cell cell in cells) {
    cell.openMine();
  }
  cell.died();
}

点击头部的表情后,游戏重新开始。在 SweeperGame 中提供 restart 方法,先通过 reset 重置数据;然后重新构建 SweeperWorld 即可:

---->[lib/sweeper/game/sweeper_game.dart]----
void restart() {
  reset();
  world = SweeperWorld();
}

reset 方法放在 GameStateLogic 中,游戏重置是需要更新状态,清空地图数据、打开以及标记的点位列表:

---->[lib/sweeper/game/logic/game_state_logic.dart]----
void reset() {
  status = GameStatus.ready;
  _openPos.clear();
  _markPos.clear();
  cells.clear();
}

手势交互的逻辑处理完后,扫雷游戏的整体功能就实现了。最后,我们来看一下 HUD 中的两个数字相关的处理逻辑。


四、HUD 数值变化逻辑处理

在第一次打开之后,右侧的 LED 显示屏将会展示游戏进行中的秒数;左侧的显示屏是总地雷数,减去标记数量。

image.png


1. 数字变化的通知与监听

现在面临的问题和头部栏表情的变化类似,宫格中的手势交互产生数据变化。需要通知两个显示屏更新信息,同样,我们可以基于 Stream 实现通知监听机制,将游戏主类当成一个大广播发送消息:

image.png

如下所示,定义 GameHudLogic 维护两个显示屏的数据源。其中时间的变化通过 Timer.periodic 每秒触发一次,更新秒数后发送通知。地雷数量的变化通过 changeMineCount 方法,发生通知:

---->[lib/sweeper/game/logic/game_hud_logic.dart]----
mixin GameHudLogic{
  final StreamController<int> _mineCountCtrl = StreamController.broadcast();

  final StreamController<int> _timeCountCtrl = StreamController.broadcast();

  Stream<int> get mineCountStream => _mineCountCtrl.stream;

  Stream<int> get timeCtrlStream => _timeCountCtrl.stream;

  void changeMineCount(int value) {
    _mineCountCtrl.add(value);
  }

  Timer? _timer;
  int _timeCount = 0;

  void startTimer() {
    closeTimer();
    _timer = Timer.periodic(const Duration(seconds: 1), _updateTime);
  }

  void _updateTime(Timer timer) {
    _timeCount++;
    _timeCountCtrl.add(_timeCount);
  }

  void closeTimer() {
    _timer?.cancel();
    _timeCount = 0;
    _timer = null;
  }
}

2. 标记与取消标记

标记与取消标记是在 GameStateLogic 中的逻辑。操作之后需要触发 changeMineCount 通知更新,而该方法在 GameHudLogic 中,如何在 GameStateLogic 直接调用呢? GameHudLogic 作为一个 mixin, GameStateLogic 可以通过 on 关键字依赖它,从而使用其中的方法:

image.png

---->[lib/sweeper/game/logic/game_state_logic.dart]----
void mark(XY pos) {
  _markPos.add(pos);
  changeMineCount(ledMineCount);
}

void unMark(XY pos) {
  _markPos.remove(pos);
  changeMineCount(ledMineCount);
}

int get ledMineCount => mode.mineCount - _markPos.length;

分离出 mixin 相当于对功能逻辑进行拆分,然后通过混入进行整合。这样可以保证逻辑的独立和清晰,而不是所有的逻辑全部塞在一块,影响阅读和维护。


3. 监听变化与更新

在 SweeperHud 中,onMount 装载时,监听两个数据对应的流。触发 _onMineCountChange 函数修改地雷数量;触发 _onMineCountChange 函数修改时间秒数;

---->[lib/sweeper/game/heroes/hud/sweeper_hud.dart]----
class SweeperHud extends PositionComponent with HasGameRef<SweeperGame> {
  StreamSubscription<int>? _mineSubscription;
  StreamSubscription<int>? _timerSubscription;

  @override
  void onMount() {
    super.onMount();
    _mineSubscription = game.mineCountStream.listen(_onMineCountChange);
    _timerSubscription = game.timeCtrlStream.listen(_onTimerChange);
  }
  
  void _onMineCountChange(int event) {
    leftScreen.value = event;
  }
  
  void _onTimerChange(int event) {
    rightScreen.value = event;
  }

LedScreen 通过 value 设置对应的数值,这里就不展开了。感兴趣的可以自己查看一下源码。到这里,扫雷的基本功能就完成了。下一篇我们将对当前的扫雷游戏进行功能拓展,敬请期待 ~