likes
comments
collection
share

Flutter&Flame游戏实践#07 | 打砖块 -功能菜单

作者站长头像
站长
· 阅读数 8

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

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

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


一、游戏整体优化介绍

我们前面通过两篇内容,介绍了打砖块游戏界面的核心逻辑。一个完整的游戏,光有游戏界面是不够的。在核心玩法的基础上,需要其他场景、音效、特效、道具、数据持久化等方面丰富游戏内容。下面我们将逐步优化项目,本文将完成以下功能:

  1. 游戏多个菜单界面的跳转。
  2. 游戏暂停/继续。
  3. 游戏音效/背景音乐。
  4. 构建主界面和设置界面。
  5. 初步完善游戏整体逻辑。

1. 游戏主页面与游戏界面

目前游戏主界面如下所示,目前没有界面没有太多设计,目前以实现功能为主,界面的美化可以留在后期:

  • 主页顶部展示两个游戏货币 绿水晶金币,为后续功能做准备。
  • 主页中间有四个按钮,跳转到不同的功能界面。
  • 后期游戏打算引入关卡,每关有三次机会。
  • 游戏界面顶部是当前关卡的信息,以及三个操作按钮:返回主页、暂停和设置
游戏主页游戏界面
Flutter&Flame游戏实践#07 | 打砖块 -功能菜单Flutter&Flame游戏实践#07 | 打砖块 -功能菜单

2.操作按钮

一个游戏最基本的是这三个按钮,点击时弹出对应的对话框,对话框弹出的过程中,需要暂停游戏,对话框消失后恢复游戏。

  • 系统设置: 需要支持背景音乐和点击音效开启/关闭。
  • 返回主页:顶部左侧按钮返回主页,弹出对话框进行确认操作,避免误触。
  • 暂停按钮:暂停游戏,可选择重新开始或继续游戏。
游戏设置返回主页
Flutter&Flame游戏实践#07 | 打砖块 -功能菜单Flutter&Flame游戏实践#07 | 打砖块 -功能菜单

3.游戏的结束

一个关卡中游戏结束表现为成功和失败,通过对话框展示信息,及后续交互:

  • 三次机会用完,游戏失败。
  • 击碎所有砖块,关卡通关,得到一颗绿水晶。
游戏失败游戏胜利
Flutter&Flame游戏实践#07 | 打砖块 -功能菜单Flutter&Flame游戏实践#07 | 打砖块 -功能菜单

二、主界面中浮层菜单的使用

现在相当游戏中有多个界面,我们都知道 Flutter 中的界面跳转本质上就是 Overlayer 浮层的插入和移除。Flame 中对浮层的操作进行了封装,可以自定义一个浮层界面的映射关系,通过 game 对象操作浮层的插入和移除。


1. 定义菜单浮层映射关系

GameWidget 本质上是一个 StatefulWidget,所以它可以视为一个普通的组件,放入到一个 Flutter 项目中。之前使用 GameWidget 时需要传入 FlameGame 的派生类 BricksGame 。 如果不想让组件持有游戏主类对象,可以通过 GameWidget<BricksGame>.controlled 构造,将 BricksGame 的创建交由 gameFactory 函数。这样组件类支持有一个函数,并将游戏主类的创建时机延迟到使用时。

---->[lib/bricks/04/app.dart]----
class BricksGameApp extends StatelessWidget {
  const BricksGameApp({super.key});

  @override
  Widget build(BuildContext context) {
    return GameWidget<BricksGame>.controlled(
      gameFactory: BricksGame.new,
      overlayBuilderMap: {
        'HomePage': (_, game) => HomePage(game: game),
        'Settings': (_, game) => SettingsPage(game: game),
        'ShopPage': (_, game) => ShopPage(game: game),
        'LevelPage': (_, game) => LevelPage(game: game),
        'PauseMenu': (_, game) => PauseMenu(game: game),
        'ExitMenu': (_, game) => ExitMenu(game: game),
        'GameOverMenu': (_, game) => GameOverMenu(game: game),
        'GameSuccessMenu': (_, game) => GameSuccessMenu(game: game),
      },
      initialActiveOverlays: const ['HomePage'],
    );
  }
}

GameWidget 在构造时有两个浮层相关的参数:

  • overlayBuilderMap 映射对象,将每个界面浮层与字符串 key 对应。构建界面的回调中,有游戏主类的参数。
  • initialActiveOverlays 表示初始时展示的浮层键列表。

这样在代码中就可以通过 game.overlays 根据 key 插入和移除浮层。这里开始展示 HomePage,点击开始按钮时,进入游戏界面,对于浮层来说,就是将 HomePage 对应的浮层移除:

---->[点击回调时,移除 HomePage 浮层]----
game.overlays.remove('HomePage');

2. 主界面 HomePage 的构建

前面我们知道 Flame 游戏世界会不停地渲染,但目前主页这种相对静态的界面,和游戏过程无关。我们可以使用 Flutter 的 Widget 界面进行布局,通过暂停游戏世界,来避免不必要的游戏世界更新。 Flame 也为 Flutter 提供了一些 Widget 方便展示精灵图、按钮,也是接下来需要介绍的。

对于 HomePage 组件,我们希望感知其生命周期的变化,来控制游戏世界的运行情况。比如它展示时,可以暂停游戏;它销毁时可以取消暂停。这个需求下,HomePage 可以继承自 StatefulWidget, 通过 状态类 的回调来感知生命周期的变化。在 initState 回调中,将 game.paused 设为 true,暂停游戏世界。在 dispose 回调中置为 false , 游戏世界将继续渲染。

---->[lib/bricks/04/overlays/home_page/home_page.dart]----
class HomePage extends StatefulWidget {
  final BricksGame game;
  const HomePage({super.key, required this.game});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  @override
  void initState() {
    super.initState();
    widget.game.paused = true;
  }

  @override
  void dispose() {
    widget.game.paused = false;
    super.dispose();
  }

主页视图包括两个部分,标题和按钮组,如下所示。HomePage 的布局很简单,通过 Column 将 HomeTitle 和 HomeButtons 竖向排列即可:

标题 HomeTitle按钮 HomeButtons
Flutter&Flame游戏实践#07 | 打砖块 -功能菜单Flutter&Flame游戏实践#07 | 打砖块 -功能菜单
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xff263466),
      body: Column(
        children: [
          HomeTitle(game: widget.game),
          Expanded(flex: 4, child: HomeButtons(game: widget.game)),
          const Spacer(flex: 1)
        ],
      ),
    );
  }
}

3. Flutter 布局中使用精灵图

HomeButtons 由四个按钮构成,Flame 中提供了 SpriteButton 展示精灵图按钮,如下所示,一个按钮需要准备两张精灵图,分别在按压和非按压时展示:

Flutter&Flame游戏实践#07 | 打砖块 -功能菜单

下面是 开始游戏 按钮的构建逻辑,SpriteButton 构造入参中:

  • label 展示文字内容。
  • onPressed 处理点击回调事件。
  • sprite 和 pressedSprite 是按压和非按压时的图片精灵,通过 loader 加载。
  • height 和 width 是按钮的宽高,必须传入。
---->[lib/bricks/04/overlays/home_page/home_buttons.dart]----
const TextStyle style = TextStyle(color: Color(0xFFFFFFFF), fontWeight: FontWeight.bold);
double height = 36;
double width = 36 * 241 / 55;

SpriteButton(
  onPressed: () {
    game.overlays.remove('HomePage');
  },
  label: const Text('开始游戏', style: style),
  sprite: game.loader['Btn_V15.png'],
  pressedSprite: game.loader['Btn_V16.png'],
  height: height,
  width: width,
),

这里点击事件中移除 HomePage 浮层即可。另外其他的几个按钮处理类似,就不一一介绍了。


顶部的绿水晶和金币有动画效果,对于这种序列帧,Flame 提供了 SpriteAnimationWidget 组件进行展示。使用方式如下:

Flutter&Flame游戏实践#07 | 打砖块 -功能菜单

SizedBox(
  width: 20,
  height: 20,
  child: SpriteAnimationWidget.asset(
    playing: true,
    path: 'break_bricks/MonedaD.png',
    data: SpriteAnimationData.sequenced(
      amount: 5,
      stepTime: 0.15,
      textureSize: Vector2(16, 16),
    ),
  ),
),

SpriteAnimationWidget.asset 构造方法可以加载图片资源中的序列帧; SpriteAnimationData.sequenced是动画精灵的数据,传入贴图的数量和每个贴图的尺寸大小,已经每帧间的事件间隔秒数 stepTime

Flutter&Flame游戏实践#07 | 打砖块 -功能菜单


三、菜单界面与播放声音

目前系统设置菜单只有两个设置项,主要介绍 Flame 中声音的播放。游戏中的声音包括两个方面: 背景音乐游戏音效 。背景音乐会一直持续播放;游戏音效是一些短的声音,比如小球的撞击声、按钮的点击声、获得道具时的音效等。

Flutter&Flame游戏实践#07 | 打砖块 -功能菜单


1、使用 flame_audio 播放声音

flame_audio 是 Flame 官方为游戏声音播放封装的插件,低层依赖 audioplayers 实现,基于目前已经支持全平台的音频播放。音效也可以在一些开放游戏社区找到免费的可商用资源包。

Flutter&Flame游戏实践#07 | 打砖块 -功能菜单

由于各个平台的音频格式支持程度不同,建议使用 mp3 的格式的音频文件。将其放在 assets/audio 文件夹之下,这里 break_bricks 文件夹放置打砖块的音效; ui 文件夹放置交互时的音效。

Flutter&Flame游戏实践#07 | 打砖块 -功能菜单


声音的播放非常简单,对于背景音乐来说使用 FlameAudio.bgm.play 播放指定路径的音频文件;通过 FlameAudio.play 播放短的音效。这里通过有 AudioManager 类负责维护音频播放相关的功能,并定义 enableSoundEffectenableBgMusic 来决定是否启用背景音乐:

---->[lib/bricks/04/config/audio_manager/audio_manager.dart]----
class AudioManager {
  bool enableSoundEffect = true;
  bool enableBgMusic = true;

  final String _bgMusicPath = 'break_bricks/background.mp3';

  void startBgm() async{
    FlameAudio.bgm.initialize();
    FlameAudio.bgm.play(_bgMusicPath, volume: 0.8);
  }

  void play(SoundEffect type) {
    if (!enableSoundEffect) {
      return;
    }
    FlameAudio.play(type.path);
  }

  void toggleBgMusic() {
    if (enableBgMusic) {
      FlameAudio.bgm.stop();
      enableBgMusic = false;
    } else {
      FlameAudio.bgm.play(_bgMusicPath, volume: 0.8);
      enableBgMusic = true;
    }
  }

  void toggleSoundEffect() {
    enableSoundEffect = !enableSoundEffect;
  }
}

这里将 AudioManager 作为 BricksGame 的成员,方便通过 game 对象访问:

---->[lib/bricks/04/bricks_game.dart#BricksGame]----
AudioManager am = AudioManager();

2. 短音效的维护

游戏中会有很多短音效,如何维护它们是一个问题。短音效最重要的是其路径地址,在某个版本中,短音效的个数是一定的,可枚举的。再加上 Dart 中枚举已经支持添加属性,所以这里通过枚举维护音效是比较优雅的。如下所示:

---->[lib/bricks/04/config/audio_manager/sound_effect.dart]----
enum SoundEffect {
  uiClick('ui/click.mp3'),
  uiOpen('ui/open.mp3'),
  uiClose('ui/close.mp3'),
  uiSelect('ui/select.mp3'),
  ballBrick('break_bricks/tone.mp3'),
  bitWall('break_bricks/hit2.mp3'),
  ;

  final String path;

  const SoundEffect(this.path);
}

播放短音效时,通过 game.am.play 方法,传入对应的 SoundEffect 枚举元素即可。如下所示,点击系统设置时,播放 SoundEffect.uiOpen 对应的音效:

---->[lib/bricks/04/overlays/home_page/home_page.dart]----
game.am.play(SoundEffect.uiOpen);

3. 菜单弹框与九宫缩放

一般图片在拉伸时会发生形变,将其作为面板的背景,不能适应不同的尺寸:

Flutter&Flame游戏实践#07 | 打砖块 -功能菜单

Flame 中封装了 NineTileBoxWidget 组件,支持九宫模式的缩放,可以让面板图片,可以仅缩放设定的区域.其原理是,将图片等分为九块,在渲染时仅对中间区域(下面阴影部分) 进行缩放,四角区域的内容保持不变。

Flutter&Flame游戏实践#07 | 打砖块 -功能菜单

NineTileBoxWidget 组件需要指定 tileSize 表示每块网格的大小。这种方式使用起来有一定的局限性,如果能自定义上下左右的边距来控制伸展区域的大小,会更加灵活。

SizedBox(
  width: width,
  height: height,
  child: NineTileBoxWidget.asset(
    path: 'break_bricks/panel.png',
    tileSize: 33,
    destTileSize: 100,
  ),
),

NineTileBoxWidget 组件本质上是通过 Flutter 的 Canvas#drawImageNine 方法绘制图片,所以完全可以自己写一个组件来更灵活地封装九宫模式的图片绘制。下面是我封装的 NineImageWidget 组件,可以传入 Rect 决定区域左上右下的边距 (边距值可以自己进行量取):

Flutter&Flame游戏实践#07 | 打砖块 -功能菜单

通过自定义的绘制,也可以实现更多的特性,比如可以提供透明度的参数,这样菜单面板下方就可以隐约看到下层的内容,视觉上更佳:

菜单界面的构建逻辑详见: lib/bricks/04/overlays/settings,这里就不赘述了。

不透明增加透明度
Flutter&Flame游戏实践#07 | 打砖块 -功能菜单Flutter&Flame游戏实践#07 | 打砖块 -功能菜单
---->[packages/flame_ext/lib/widget/nine_image_widget.dart]----
class NineImageWidget extends StatelessWidget {
  final Rect expandZone;
  final Widget? child;
  final ui.Image image;
  final double opacity;
  final EdgeInsetsGeometry? padding;

  const NineImageWidget({
    super.key,
    required this.expandZone,
    required this.image,
    this.child,
    this.opacity = 1,
    this.padding,
  });

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: _NightImagePainter(image, expandZone, opacity),
      child: padding == null ? child : Padding(padding: padding!, child: child),
    );
  }
}

final _emptyPaint = Paint();

class _NightImagePainter extends CustomPainter {
  final ui.Image image;
  final Rect expandZone;
  final double opacity;

  _NightImagePainter(this.image, this.expandZone, this.opacity);

  @override
  void paint(Canvas canvas, Size size) {
    Rect rect = Rect.fromLTWH(expandZone.left, expandZone.top,
        image.width - expandZone.right, image.height - expandZone.bottom);
    _emptyPaint.color = Colors.white.withOpacity(opacity);
    canvas.drawImageNine(image, rect, Offset.zero & size, _emptyPaint);
  }

  @override
  bool shouldRepaint(covariant _NightImagePainter oldDelegate) {
    return oldDelegate.expandZone != expandZone 
        || oldDelegate.image != image||oldDelegate.opacity!=opacity;
  }
}

四、游戏界面的优化

游戏界面增加了顶部栏 GameTopBar,展示游戏信息以及操作按钮。效果如下:

Flutter&Flame游戏实践#07 | 打砖块 -功能菜单


1. 游戏顶部栏 GameTopBar

GameTopBar 内部包括以下的构件,在 onLoad 时添加将他们到这些部件,并初始化它的位置信息:

  • BrickWall : 顶部的固定墙体。
  • XXXButton : 操作按钮。
  • Life: 生命信息。
  • Icon: 金币信息。
  • LevelText: 关卡信息。
---->[lib/bricks/04/heroes/game_top_bar/game_top_bar.dart]----
class GameTopBar extends PositionComponent with HasGameRef<BricksGame> {
  
  final Coin coin = Coin();
  final Life life = Life(3);
  final HomeButton home = HomeButton();
  final SettingButton setting = SettingButton();
  final PauseButton pause = PauseButton();
  final LevelText levelText = LevelText();

  void updateLifeCount(int count){
    removeWhere((component) => component is Life);
    add(Life(count));
  }

  @override
  FutureOr<void> onLoad() async {
    size = Vector2(kViewPort.width, 320);
    add(BrickWall());
    add(levelText);
    add(coin);
    add(life);
    add(home);
    add(setting);
    add(pause);
    initPosition();
    return super.onLoad();
  }

  void initPosition(){
    final double iconSize = setting.width;
    final double half = (64-iconSize)/2;
    setting..x = width - iconSize - half..y = half;
    pause..x = setting.x-64..y=setting.y;
    home..x = half..y = half;
    coin.x=64;
    levelText..x = width / 2..y = height / 2;
  }
}

2.顶部墙壁: BrickWall

碰撞的上边界将在顶部栏的底部,这里通过 BrickWall 构件,展示 64*64 的贴图,并承担碰撞检测的职能。这里贴图砖块的摆放和之前是类似的,通过行列来数遍历铺满。添加过程中也可以通过逻辑控制收集的坐标,比如这里让中间空缺:

Flutter&Flame游戏实践#07 | 打砖块 -功能菜单

---->[lib/bricks/04/heroes/game_top_bar/brick_wall.dart]----
class BrickWall extends PositionComponent with HasGameRef<BricksGame> {
  final int column;
  final int row;

  BrickWall({this.column = 9, this.row = 5});

  @override
  FutureOr<void> onLoad() {
    addAll(_createBricks());
    width = 64.0 * column;
    height = 64.0 * row;
    add(RectangleHitbox());
    return super.onLoad();
  }

  List<SpriteComponent> _createBricks() {
    Sprite sprite = game.loader['texture_metal1.png'];

    List<SpriteComponent> bricks = [];
    for (int i = 0; i < row; i++) {
      for (int j = 0; j < column; j++) {
        if ((i == 0 || i == row - 1) || (j == 0 || j == column - 1)) {
          SpriteComponent brick = SpriteComponent(sprite: sprite);
          brick.x = 64.0 * j;
          brick.y = 64.0 * i;
          bricks.add(brick);
        }
      }
    }
    return bricks;
  }
}

3. 生命组件:Life 与游戏终止

打砖块游戏中,每关卡有三条生命。小球落到底部时,生命值减 1 , 生命为 0 时游戏结束。砖块全部打完时,关卡通关。Life 组件生命值通过两个图片,根据当前生命值进行展示,小于当前生命值时填满的爱心,否则是空的爱心:

Flutter&Flame游戏实践#07 | 打砖块 -功能菜单

---->[lib/bricks/04/heroes/game_top_bar/life.dart]----
class Life extends PositionComponent with HasGameRef<BricksGame> {
  final int lifeCount;
  
  Life(this.lifeCount);

  late Sprite life = game.loader['tile_0044.png'];
  late Sprite lifeOutline = game.loader['tile_0046.png'];

  @override
  FutureOr<void> onLoad() async {
    addAll(createLife());
    position = Vector2(64, 64) + Vector2(8, 8);
    return super.onLoad();
  }

  List<SpriteComponent> createLife() {
    List<SpriteComponent> result = [];
    for (int i = 0; i < 3; i++) {
      SpriteComponent s1 = SpriteComponent(sprite: life)
        ..size = Vector2(36, 36);
      SpriteComponent s2 = SpriteComponent(sprite: lifeOutline)
        ..size = Vector2(36, 36);
      s1.x = 36.0 * i;
      s2.x = 36.0 * i;
      if (i < lifeCount) {
        result.add(s1);
      } else {
        result.add(s2);
      }
    }
    return result;
  }
}

  • 在 PlayWorld 中定义 died 方法,用于小球死亡触发时的逻辑。
  • GameTopBar 中定义 updateLifeCount 方法,移除旧的生命值,添加新的生命值。
  • 修正 Ball 碰撞到底部时的逻辑处理,触发世界的 died 方法。
---->[lib/bricks/04/bricks_game.dart#PlayWorld]----
int _life = 3;

void died() {
  _life -= 1;
  titleBar.updateLifeCount(_life);
  game.status = GameStatus.ready;
  if (_life == 0) {
    gameOver();
  }
}

---->[lib/bricks/04/heroes/game_top_bar/game_top_bar.dart]----
void updateLifeCount(int count){
  removeWhere((component) => component is Life);
  add(Life(count));
}


---->[lib/bricks/04/heroes/ball.dart]----
void _handleHitPlayground(Vector2 position, Vector2 areaSize) {
  if(position.y >= areaSize.y-height){
    game.world.died();
    v = Vector2(0, 0);
    return;
  }

4. 暂停恢复与重新开始

游戏暂停和返回主页的面板和设置面板类似,都是通过 NineImageWidget 展示局部缩放的图片面板:

游戏暂停返回主页
Flutter&Flame游戏实践#07 | 打砖块 -功能菜单Flutter&Flame游戏实践#07 | 打砖块 -功能菜单

游戏场景中,构件本身也可以混入 TapCallbacks ,通过复写 onTapDown 方法监听点击事件。下面是暂停按钮构件,继承自 SpriteComponent 展示图片资源。点击时弹出 PauseMenu并将 game.paused 置为 true ,即可暂行游戏:

class PauseButton extends SpriteComponent
    with HasGameRef<BricksGame>, TapCallbacks {
  @override
  FutureOr<void> onLoad() {
    sprite = game.loader['flatDark15.png'];
    return super.onLoad();
  }

  @override
  void onTapDown(TapDownEvent event) {
    game.paused = true;
    game.am.play(SoundEffect.uiClose);
    game.overlays.add('PauseMenu');
  }
}

游戏重新开始,可以将当前所有的已变动的构件数据重置,但这样操作起来比较麻烦。有种简单的方式,就是将游戏中的 world 重新设置,这样新的 PlayWorld 一切都会重置。

---->[lib/bricks/04/bricks_game.dart#BricksGame]----
void restart() {
  world = PlayWorld();
  status = GameStatus.ready;
}

到这里,打砖块的基本游戏流程就已经完善了,玩家可以通过操作来暂停、重新开始。游戏也有胜利和失败的结果。接下来将继续优化打砖块游戏,设计多个关卡,支持选关操作,丰富玩法。

Flutter&Flame游戏实践#07 | 打砖块 -功能菜单