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

发布于:2024-05-10 ⋅ 阅读:(22) ⋅ 点赞:(0)

theme: cyanosis

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


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

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

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

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


一、地图数据的生成

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

134.gif


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 :

image.png

如果将坐标定义为 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] 的坐标格即可。代码表示如下:

image.png

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,可以为游戏主类提供额外的能力。其中包含下面的一些游戏过程中需要依赖的数据:

image.png

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

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 方法打开单元格:

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

image.png

这里通过 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 方法,结束游戏:

image.png

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

image.png

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

image.png


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

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

image.png

如下所示,定义 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 关键字依赖它,从而使用其中的方法:

image.png

``` ---->[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 设置对应的数值,这里就不展开了。感兴趣的可以自己查看一下源码。到这里,扫雷的基本功能就完成了。下一篇我们将对当前的扫雷游戏进行功能拓展,敬请期待 ~


网站公告

今日签到

点亮在社区的每一天
去签到