Flutter&Flame游戏实践#13 | 扫雷 - 界面交互

发布于:2024-04-29 ⋅ 阅读:(35) ⋅ 点赞:(0)

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


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

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

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

两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 ,系列文章列表可在 或 查看。


在之前我们实现了两个类型的小游戏:

  • Trex 跳跃碰撞类, 1~4集
  • 打砖块 射击消除类,5~12集

接下来,我们将写一下 益智类 的小游戏。扫雷 作为历史悠久的一款益智游戏。在当年游戏匮乏的时代,想必它承载着很多人童年的宝贵回忆。下面几篇 Flutter&Flame 游戏实践,将像素级复刻最经典版的扫雷游戏:

image.png


一、扫雷玩法介绍

一款益智游戏,首先要明确:

  • [1]. 游戏操作规则。
  • [2]. 游戏胜利和失败的条件。
  • [3]. 游戏交互细节。

1. 游戏操作规则
  • 闭合的单元格中隐藏着 地雷数字
  • 闭合的单元格可以通过点击打开。
  • 单元格中数字表示九个中含 地雷 的数量。

比如下面的紫框中的 1 单元格,表示它所在的九格中(红框) 存在一个地雷。而红框中只有尾翻开的单元格,那么可以推理出左上角的单元格是雷:

image.png

此时就可以通过右键将该区域标记为 地雷。这就是扫雷的核心玩法:

image.png


2.游戏的胜败条件
  • 当点到地雷时,游戏失败。并展示出地图中的所有地雷:
  • 游戏胜利的条件是排除所有的地雷,将非雷区全部翻开:
游戏失败 游戏胜利
image.png image.png

总的来看,它是一个逻辑推理的益智游戏,规则非常精简,所以很容易上手。复杂的单元格也可以提高游戏的可玩性,是一个非常优秀的游戏玩法设计。


3. 游戏交互细节

下面动态图中展示了扫雷游戏的基本交互,包括:

  • 按下及拖动过程中,对应的单元格处于按下状态。
  • 抬起时,打开单元格。
  • 右键标记、取消旗子。
  • 顶部中间的表情展示当前的游戏交互状态,点击时重新开始。
  • 左侧 LED 展示雷的数量,右侧 LED 展示使用的秒数。

130.gif


二、整体界面布局分析

本篇我们先来解决界面设计和交互的问题,在下一章再实现具体的玩法。为了让单元格的尺寸在任何大小下都不失真,这里资源图片全部采用 svg。也顺便介绍一下 svg 如何在 Flame 中使用。

image.png


1. 游戏界面布局

游戏界面在布局上非常简单,顶部展示游戏状态信息,一般称之为 HUD (Heads-Up Display);下方网格是游戏区域,将作为后期处理的重点部分;除此之外,还有两者之间的边框需要展现:

image.png

整体构件结构如下图所示,SweeperLayout 负责整体布局的展示,包括外部的边线框。其中包含 SweeperHudCellManager 两个主题内容作为孩子:

image.png


2. 尺寸设计

游戏中的构建尺寸如何规定,是一个棘手的问题。它是自适应屏幕宽高进行缩放,还是固定尺寸,不受窗口尺寸影响。扫雷游戏固定尺寸即可,如果自适应窗口缩放,会导致个数少时单元格非常大。我们希望窗口缩放不影响游戏的尺寸表现。

为了便于修改尺寸,游戏界面中所有的尺寸都基于一个标准尺寸作为单位。这里选取 单元格 尺寸 cellSize。当我们确定了网格的行列数 gridXY ,通过 SizeRes 类进行维护:

---->[lib/sweeper/game/config/size_res.dart]----
class SizeRes {
  final double cellSize;
  final (int, int) gridXY;

  SizeRes({
    this.cellSize = 18,
    this.gridXY = (16, 16),
  });

一切尺寸相关的数据够可以通过这两个数据 计算得到。比如网格区的宽高是行列数乘以单元格尺寸; Hud 尺寸高度是两个单元格大小;宽度是网格宽度。表情按钮的大小是 1.5 被的单元格大小。这里通过 get 方法提供计算逻辑:

  Vector2 get gridSize => Vector2(gridXY.$1 * cellSize, gridXY.$2 * cellSize);

  Vector2 get hudSize => Vector2(gridXY.$1 * cellSize, cellSize * 2);
  
  Vector2 get faceSize => Vector2(cellSize * 1.5, cellSize * 1.5);

同样,LED 的尺寸、间隔、矩形区域等数据可以通过 get 方法提供。这也是 SizeRes 命名的原因,将其视为尺寸资源的仓库,方便统一管理和维护:

// hud 矩形区域
Rect get hudRect => Rect.fromLTWH(gap, gap, hudSize.x, hudSize.y);

// 网格矩形区域
Rect get gridRect =>
    Rect.fromLTWH(gap, hudSize.y + gap + gap, gridSize.x, gridSize.y);

// 边框间隔
double get gap => 0.72 * cellSize;

// 布局总尺寸
Vector2 get layoutSize => Vector2(
      gridSize.x + gap * 2,
      gridSize.y + hudSize.y + gap * 3,
    );

// led 宽度
double get ledWidth => cellSize * 0.64;
    
// led 间隔
double get ledSpace => cellSize * 0.12;

3. 阴影边线框的实现

俗话说,细节决定成败。仔细观察面板可以发现,其中有很多处阴影边线。包括最外部、单元格外围、HUD 外围、LED 灯外围四个地方。如何展示这些边框呢?

image.png

首先,这种边框存在于多个场合,所以需要封装一下便于复用。边框的展现可以通过绘制 矩形 的四条边线实现。其中可以设置边线的 边线宽度四边颜色。如下所示,封装一个 BorderDecoration 类承载数据,调用 paint 方法,绘制对应矩形的四个边框:

---->[lib/sweeper/painter/decration/border_decroation.dart]----
class BorderDecoration {
  final double strokeWidth;
  final Color top;
  final Color right;
  final Color bottom;
  final Color left;

  BorderDecoration({
    required this.strokeWidth,
    required this.top,
    required this.right,
    required this.bottom,
    required this.left,
  });

  void paint(Rect rect, Canvas canvas) {
    Paint paint = Paint()..strokeWidth = strokeWidth..strokeCap = StrokeCap.round;
    canvas.drawLine(
      rect.topRight, rect.topRight + Offset(0, rect.height),
      paint..color = right,
    );
    canvas.drawLine(
      rect.bottomLeft, rect.bottomLeft + Offset(rect.width, 0),
      paint..color = bottom,
    );
    canvas.drawLine(
      rect.topLeft, rect.topLeft + Offset(rect.width, 0),
      paint..color = top,
    );
    canvas.drawLine(
      rect.topLeft, rect.topLeft + Offset(0, rect.height),
      paint..color = left,
    );
  }
}

4. 实现整体布局:SweeperLayout

游戏的外框通过 SweeperLayout 构建展示,其中复写 render 方法,操作 Canvas 进行绘制边线和背景色。一共有三个边线需要绘制,分别封装为三个方法:

image.png

class SweeperLayout extends PositionComponent with HasGameRef<SweeperGame> {
  @override
  void onGameResize(Vector2 size) {
    x = (size.x - width) / 2;
    y = (size.y - height) / 2;
    super.onGameResize(size);
  }

  @override
  FutureOr<void> onLoad() {
    size = game.sizeRes.layoutSize;
    /// TODO... 添加内容
    return super.onLoad();
  }

  @override
  void render(Canvas canvas) {
    Rect rect = Offset.zero & Size(width, height);
    canvas.drawRect(rect, Paint()..color = ColorRes.background);
    _pintHudBorder(canvas);
    _paintGridBorder(canvas);
    _paintOutBorder(canvas,rect);
    super.render(canvas);
  }

_pintHudBorder : 绘制 Hud 内部的边线,创建上面写的 BorderDecoration 对象,触发 paint 方法完成绘制边线任务。
: 其中尺寸相关的数据,封装在 game.sizeRes 中,后面会单独介绍。现在只要知道通过它可以获取尺寸数据即可:

image.png

  void _pintHudBorder(Canvas canvas) {
    double strokeWidth = game.sizeRes.gap * 0.35;
    BorderDecoration decoration = BorderDecoration(
      strokeWidth: strokeWidth,
      top: ColorRes.gray,
      left: ColorRes.gray,
      right: ColorRes.white,
      bottom: ColorRes.white,
    );
    decoration.paint(game.sizeRes.hudRect, canvas);
  }

_paintGridBorder 用于绘制网格外围的边线:

image.png

  void _paintGridBorder(Canvas canvas) {
    double strokeWidth = game.sizeRes.gap * 0.42;
    BorderDecoration decoration = BorderDecoration(
      strokeWidth: strokeWidth,
      top: ColorRes.gray,
      left: ColorRes.gray,
      right: ColorRes.white,
      bottom: ColorRes.white,
    );
    decoration.paint(game.sizeRes.gridRect, canvas);
  }

_paintGridBorder 用于绘制网格外围的边线:

image.png

  void _paintOutBorder(Canvas canvas,Rect rect) {
    double strokeWidth = game.sizeRes.gap * 0.25;
    BorderDecoration decoration = BorderDecoration(
      strokeWidth: strokeWidth,
      top: ColorRes.white,
      left: ColorRes.white,
      right: ColorRes.gray,
      bottom: ColorRes.gray,
    );
    decoration.paint(rect, canvas);
  }

三、单元格与其管理器

接下来将要完成如下的单元格布局,以及拖拽时间过程中,对应单元格呈现出按压状态。其中单元格通过 svg 图片展示,这里也正好介绍一下 Flame 对 svg 的支持情况:

131.gif


1. 单元格构件 Cell

这里称单元格为 Cell , 在 Flame 中使用 svg 构件,需要额外添加类库 。它是 Flame 官方基于 封装的构建:

flame_svg: ^1.10.1

我们知道 SpriteComponent 是基于 Sprite 渲染呈现内容; 这里 flame_svg 封装了 SvgComponent 构建,基于 Svg 渲染呈现内容。Sprite 基于资源图片得到,同理 Svg 可以通过加载 svg 文件得到。
同样将加载逻辑放在 TextureLoader 类中,通过 loadSvg 方法加载资源列表并放入 _svgMap 映射关系中。提供 findSvg 方法根据文件名,获取 Svg 对象:

---->[packages/flame_ext/lib/texture_loader.dart]----
final Map<String, Svg> _svgMap = {};

Future<void> loadSvg(List<String> images ,{
  LoadProgressCallBack? loadingCallBack,
}) async{
  int total = images.length;
  int cur = 0;
  for (int i = 0; i < images.length; i++) {
    String filename = path.basename(images[i]);
    _svgMap[filename] = await Svg.load(images[i]);
    cur++;
    loadingCallBack?.call(total, cur);
  }
}

Svg findSvg(String name) {
  if (_svgMap.containsKey(name)) {
    return _svgMap[name]!;
  }
  throw AssetsNotFindException(name);
}

Cell 单元格如下所示 ,由于一个坐标表示它的位置;默认加载 closed.svg 文件表示闭合单元格;其中目前提供两个方法 pressedreset 分别让单元格更新为按压和闭合状态。

image.png

---->[lib/sweeper/game/heros/cell/cell.dart]----
class Cell extends SvgComponent with HasGameRef<SweeperGame> {
  final (int, int) pos;

  Cell(this.pos);

  @override
  FutureOr<void> onLoad() {
    double cellSize = game.sizeRes.cellSize;
    size = Vector2(cellSize, cellSize);
    svg = game.loader.findSvg('closed.svg');
    return super.onLoad();
  }

  void pressed() {
    svg = game.loader.findSvg('pressed.svg');
  }

  void reset() {
    svg = game.loader.findSvg('closed.svg');
  }
}

2.单元格管理器

单元格的和之前打砖块中的砖块管理类似,都是遍历行列生成单体。如下所示,在 _createCells 方法中遍历行列数,创建 Cell 对象加入列表。就可以依次排列从此网格:

image.png

---->[lib/sweeper/game/heros/cell/cell_manager.dart]----
class CellManager extends PositionComponent
    with HasGameRef<SweeperGame>, DragCallbacks, GameCellLogic {
  @override
  FutureOr<void> onLoad() {
    size = game.sizeRes.gridSize;
    double gap = game.sizeRes.gap;
    double height = game.sizeRes.hudSize.y + gap * 2;
    position = Vector2(gap, height);
    addAll(_createCells());
    return super.onLoad();
  }

  List<Cell> _createCells() {
    int rowCount = game.sizeRes.gridXY.$2;
    int columnCount = game.sizeRes.gridXY.$1;
    double cellSize = game.sizeRes.cellSize;
    List<Cell> result = [];
    for (int i = 0; i < rowCount; i++) {
      for (int j = 0; j < columnCount; j++) {
        Cell cell = Cell((j, i));
        cell.x = cellSize * j;
        cell.y = cellSize * i;
        result.add(cell);
      }
    }
    return result;
  }
}

3. 交互的逻辑分离: GameCellLogic

这里做了一个有趣的尝试,将 构建构建逻辑交互时数据处理逻辑 通过 mixin 进行分离。如下所示,定义了 GameCellLogic 来处理网格整体的交互逻辑:

image.png

这样就可以让逻辑更为紧凑,之后修改交互逻辑,只需要在 GameCellLogic 中处理即可。它混入 DragCallbacks ,可以复写 onDragUpdate 方法监听拖拽事件。然后根据触点和单元格尺寸计算出落点的坐标。通过 pressed 方法进行处理,将目标点的 Cell 改为 pressed 状态。最近在看 《代码简洁之道》,对我大有裨益。方法应该短小精炼,只处理必要的事:

---->[lib/sweeper/game/logic/game_cell_logic.dart]----
mixin GameCellLogic on DragCallbacks, HasGameRef<SweeperGame> {
  /// 被按压的单元格
  final List<Cell> _pressedCells = [];

  @override
  void onDragUpdate(DragUpdateEvent event) {
     pressed(event.localStartPosition);;
    super.onDragUpdate(event);
  }
  
  void pressed(Vector2 vector2){
    game.activeFace();
    double cellSize = game.sizeRes.cellSize;
    int x = vector2.x ~/ cellSize;
    int y = vector2.y ~/ cellSize;
    pressedAt((x, y));
 }
  
  void pressedAt((int, int) pos) {
    if (!_allowPressAt(pos)) return;
    _resetPrevPressed();
    _doPressAt(pos);
  }

  bool _allowPressAt((int, int) pos) {
    return _pressedCells.where((e) => e.pos == pos).isEmpty;
  }

  void _resetPrevPressed() {
    if (_pressedCells.isNotEmpty) {
      Cell lastActive = _pressedCells.removeLast();
      lastActive.reset();
    }
  }

  void _doPressAt((int, int) pos) {
    List<Cell> cells = children.whereType<Cell>().toList();
    Iterable<Cell> targets = cells.where((e) => e.pos == pos);
    if (targets.isNotEmpty) {
      Cell cell = targets.first;
      cell.pressed();
      _pressedCells.add(cell);
    }
  }
}

到这里就完成了拖拽时按压的交互逻辑。将方法独立封装,可以带来很强的复用性,比如要增加点击的按下的事件时,额外混入 TapCallbacks,复写 onTapDown 方法调用 pressed 即可:

image.png


四、HUD 的处理

HUD 中包含三个部分,左侧是地雷个数,中间是按钮,右侧时游戏开始后的秒数。

image.png

到这里,游戏中组件的整体结构就非常明确了,如下所示:

image.png


1. Led 显示屏的封装:LedScreen

这种 Led 显示屏可能在以后的项目中也能用,可以单独封装起来便于复用。如下所示,我们要封装一个显示屏,可以指定显示屏中数字管的个数,以便更灵活使用:

image.png

显示屏封装为 LedScreen 构建,传入数量、宽度、间隔信息。通过这些信息,可以计算出显示屏幕的尺寸 screenSize。在 onLoad 方法中,遍历 count 次,加入 SvgComponent 展示数字管即可:

---->[lib/sweeper/game/heroes/hud/led_screen.dart]----
class LedScreen extends PositionComponent with HasGameRef<SweeperGame> {
  final int count;
  final double ledWidth;
  final double ledSpace;

  LedScreen({
    this.count = 3,
    required this.ledWidth,
    required this.ledSpace,
  });

  Vector2 get screenSize => Vector2(
    ledWidth * count + (count-1) * ledSpace+2*ledSpace,
    ledWidth * 2 + ledSpace,
  );

  @override
  FutureOr<void> onLoad() {
    size = screenSize;
    addAll(_createLedLamps());
    return super.onLoad();
  }

  List<Component> _createLedLamps() {
    List<Component> ledLamps = [];
    Vector2 ledSize = Vector2(ledWidth, ledWidth * 2);
    for (int i = 0; i < count; i++) {
      SvgComponent led = SvgComponent(
        svg: game.loader.findSvg('d0.svg'),
        size: ledSize,
        position: Vector2(ledSpace + (ledWidth + ledSpace) * i, ledSpace / 2),
      );
      ledLamps.add(led);
    }
    return ledLamps;
  }

这样在 SweeperHud 中通过 _addLedScreen 方法,创建 LedScreen 添加其中即可。至于显示屏的数字变化,将在下一篇结合具体的场景来完善。

--->[lib/sweeper/game/heroes/hud/sweeper_hud.dart]----
void _addLedScreen(){
 double ledWidth = game.sizeRes.ledWidth;
 double ledSpace = game.sizeRes.ledSpace;
 LedScreen left = LedScreen(ledSpace: ledSpace,ledWidth: ledWidth);
 double ledY = (height - left.screenSize.y) / 2;
 double ledX = ledWidth/2;
 left.position = Vector2(ledX, ledY);
 add(left);
 LedScreen right =  LedScreen(ledSpace: ledSpace,ledWidth: ledWidth,count: 4);
 ledX = width - ledWidth/2 - right.screenSize.x;
 right.position = Vector2(ledX, ledY);
 add(right);
}

2. 表情按钮构件:FaceButton

表情按钮看起来非常简单,就是展示一个表情 svg 图像。通过 FaceButton 来完成,其中需要处理点击时的按压效果。混入 TapCallbacks 在按下和抬起事件中切换图片资源:

image.png

---->[lib/sweeper/game/heroes/hud/face_button.dart]----
class FaceButton extends SvgComponent with HasGameRef<SweeperGame>,TapCallbacks {

 @override
 FutureOr<void> onLoad() {
   size = game.sizeRes.faceSize;
   svg = game.loader.findSvg('face.svg');
   return super.onLoad();
 }

 @override
 void onTapDown(TapDownEvent event) {
   super.onTapDown(event);
   pressed();
 }

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

 void active(){
   svg = game.loader.findSvg('face_active.svg');
 }

 void reset() {
   svg = game.loader.findSvg('face.svg');
 }
 
  void pressed() {
   svg = game.loader.findSvg('face_pressed.svg');
 }
}

3. 表情按钮构件:FaceButton

虽然表情按钮非常简单,但是其中蕴含着一个很重要的知识点:如何管理表情
如下所示,在单元格点击和拓展时,如何改变表情呢?

133.gif

常规来看,想让宫格的事件影响到表情按钮,需要通过世界来一层层找到按钮对象,然后修改其图像。这样无疑非常复杂。按钮是被动地被改变,有没有什么手段能主动让按钮主动监听需要变化的事件呢?

fbb39c4a7030a30465f4548b3b91399.png


任何构件都可以访问 Game,我们可以把它当成一个 大广播,宫格点击时发送通知。表情按钮相当于收音机,可以主动监听广播的喊话。这就是一个很标准的 监听通知机制。我们有很多种手段来完成这件事,这里先采用 Flutter 内置的 Stream 流来完成(当然你可以使用任何状态管理方式来处理)。

image.png


下面定义 GameFaceLogic 就是这个大广播,播报一个 bool 值。通过 activeFaceresetFace 可以让广播喊话,发送通知。SweeperGame 只要混入 GameFaceLogic 就具备了大广播的能力:

image.png

---->[lib/sweeper/game/logic/game_face_logic.dart]----
mixin GameFaceLogic{
 bool _isActive = false;

 final StreamController<bool> _faceCtrl = StreamController.broadcast();

 Stream<bool> get faceStream => _faceCtrl.stream;

 void activeFace(){
   if(_isActive) return;
   _faceCtrl.add(true);
   _isActive = true;
 }

 void resetFace(){
   if(!_isActive) return;
   _faceCtrl.add(false);
   _isActive = false;
 }
}
  • 单元格:广播发送消息

单元格的交互逻辑中,只需要在对应实际触发 activeFaceresetFace 方法,就可以发送通知。

---->[lib/sweeper/game/logic/game_cell_logic.dart]----
void pressed(Vector2 vector2){
  game.activeFace();
  /// 略同...
}

void unpressed(){
  game.resetFace();
}
  • 表情按钮:收音机接收消息

此时按钮构建在装载是监听广播,触发 _onFaceChange 方法,修改状态即可。注意在销毁时取消监听。

 ---->[lib/sweeper/game/heroes/hud/face_button.dart]----
StreamSubscription<bool>? _subscription;

@override
void onMount() {
  super.onMount();
  _subscription =  game.faceStream.listen(_onFaceChange);
}

@override
void onRemove() {
  _subscription?.cancel();
  super.onRemove();
}

void _onFaceChange(bool value) {
  if(value){
    active();
  }else{
    reset();
  }
}

到这里我们就完成了扫雷界面交互上的核心需求,下一篇将实现扫雷的具体功能逻辑,敬请期待。感谢大家的支持,喜欢的话,希望可以点个赞 ~

133.gif