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


theme: cyanosis

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


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

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

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

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


一、地图数据的生成

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


1. 数据枚举

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

```dart 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 :

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

dart 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 中移除。以此来保证地图中地雷点位不会重复:

dart 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 相关的数字:

dart 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] 的坐标格即可。代码表示如下:

dart 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

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

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

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

```dart 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 状态是打开所有非雷区时,表示游戏成功。

dart enum GameStatus { ready, died, playing, win, }

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

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

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

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

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

dart 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 方法校验游戏是否成功。游戏成功的校验条件是:

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

```dart 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 方法校验是否已被标记:

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

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

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


三、手势或鼠标交互事件

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

```dart ---->[lib/sweeper/game/logic/gamecelllogic.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 方法处理具体打开单元格的逻辑。

dart ---->[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() 打开单元格。

dart 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 方法,维护已打开的坐标:

dart ---->[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 单元格,如下所示。

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

dart 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 除了需要自动打开的单元格。

```dart 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 方法,结束游戏:

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

dart 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 显示屏将会展示游戏进行中的秒数;左侧的显示屏是总地雷数,减去标记数量。


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

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

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

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

final StreamController _timeCountCtrl = StreamController.broadcast();

Stream get mineCountStream => _mineCountCtrl.stream;

Stream 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 关键字依赖它,从而使用其中的方法:

``` ---->[lib/sweeper/game/logic/gamestatelogic.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 函数修改时间秒数;

```dart ---->[lib/sweeper/game/heroes/hud/sweeper_hud.dart]---- class SweeperHud extends PositionComponent with HasGameRef { StreamSubscription ? _mineSubscription; StreamSubscription ? _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 设置对应的数值,这里就不展开了。感兴趣的可以自己查看一下源码。到这里,扫雷的基本功能就完成了。下一篇我们将对当前的扫雷游戏进行功能拓展,敬请期待 ~

相关推荐
lin zaixi()2 小时前
Unity游戏(Assault空对地打击)开发(2) 基础场景布置
游戏
windwind20002 小时前
对游戏宣发的粗浅思考
游戏·职场和发展·创业创新·个人开发·游戏策划
TangAcrab2 小时前
vscode flutter 项目连接 mumu 浏览器
ide·vscode·flutter
Damon小智3 小时前
使用Pygame制作“Flappy Bird”游戏
python·游戏·游戏程序·pygame
小龙在山东19 小时前
Flutter常用Widget小部件
flutter
yangshuo128120 小时前
git安装flutter
git·flutter
Kevin Coding20 小时前
Flutter使用Flavor实现切换环境和多渠道打包
android·flutter·ios
字节全栈_BjO20 小时前
Flutter Raw Image Provider
flutter
lin zaixi()1 天前
Unity游戏(Assault空对地打击)开发(3) 摄像机的控制
游戏
美味小鱼2 天前
实践Rust:编写一个猜数字游戏
开发语言·游戏·rust