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);
这样地图数据就可以看成 XY
和 CellType
的映射关系。通过 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
数据。另外对于 primary
、middle
、advanced
三种模式,通过命名构造可以定死相关配置。比如初级模式是 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 设置对应的数值,这里就不展开了。感兴趣的可以自己查看一下源码。到这里,扫雷的基本功能就完成了。下一篇我们将对当前的扫雷游戏进行功能拓展,敬请期待 ~