Flutter&Flame 游戏实践#23 | 游戏盒#2 - 多页签

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

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

第一季:30 篇短文章,快速了解 Flame 基础。[已完结]

第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。

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


上一篇,我们实现了将多个游戏集成到一个 App 中的壮举,并且支持打开游戏。本篇将继续优化桌面端的交互体验,让打开的游戏支持 多页签界面保活,从而更便捷地切换已打开的游戏。


1. 使用二级导航

上一篇中为了使左侧导航栏点击时,仅在右侧面板区域变换,使用了 ShellRoute 实现局部导航。

现在希望游戏中心打开的游戏界面,可以通过顶部栏展示,并且通过 Tab 页签导航,或者关闭。这就相当于在游戏中心界面,又有一个局部导航,也就是二级导航:

首先定义一下橙色区域的路由内容,这里通过命名路由的方式,让游戏的名字作为参数,来动态控制路由对应组件的创建,如下所示:

dart 复制代码
RouteBase get gameRoute => GoRoute(
  path: 'game/:name',
  pageBuilder: (_, GoRouterState state) {
    String? gameName = state.pathParameters['name'];
    Widget child = gameWidgetMap[gameName] ?? const GameCenter();
    return NoTransitionPage(child: child);
  },
);

Map<String, Widget> get gameWidgetMap => {
      "sweeper": const SweeperPage(),
      "trex": const TrexPage(),
      "breaks": const BricksPage(),
      "snake": const SnakePage(),
      "life_game": const LifeGamePage(),
    };

在路由树中,增加以 GameCenterNavigation 为导航器的二级导航,它顶部是的 GameCenterTopBar 组件,用于展示页签列表进行导航:

dart 复制代码
class GameCenterNavigation extends StatelessWidget {
  final Widget child;

  const GameCenterNavigation({super.key, required this.child});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.transparent,
      body: Column(
        children: [
          const GameCenterTopBar(),
          const Divider(),
          Expanded(child: child)
        ],
      ),
    );
  }
}

2. 页签列表数据的维护

接下来需要维护页签数据,在状态类中增加 tabMenus 列表记录菜单信息,其中 ImageMenutolyui_meta 中定义的图片菜单数据:

dart 复制代码
class GameCenterState {
  final List<GamePo> games;
  final List<ImageMenu> tabMenus;

  const GameCenterState({
    this.games = const [],
    this.tabMenus = const [],
  });
}

GameCenterBloc 中维护数据变化的业务逻辑,如下定义 openGame 方法,传入游戏的 id ,增加页签。

这里给了一个 findGameById 方法,用于根据 id 找到 GamePo, 比如 web 中直接通过 url 访问游戏,此时游戏列表可能还未加载。该方法可以让任何游戏仅通过 id 即可打开。

根据 GamePo 生成一个 ImageMenu 页签菜单,加入到页签列表即可:

dart 复制代码
---->[lib/logic/bloc/bloc.dart]----
void openGame(String id) {
  GamePo? po = findGameById(id);
  if (po == null) return;
  List<ImageMenu> menus = state.tabMenus.toList();
  menus.removeWhere((e) => e.route == po.route);
  ImageMenu menu = ImageMenu(po.logo, label: po.title, route: po.route);
  emit(state.copyWith(tabMenus: [menu, ...menus]));
}

GamePo? findGameById(String id) {
  int index = state.games.indexWhere((e) => e.id == id);
  if (index == -1) {
    // TODO 根据 id 加载 GamePo
    return null;
  } else {
    return state.games[index];
  }
}

接下来只需要在游戏中心的点击事件在调用 openGame 方法维护页签数据,以及使用 context.go 进行路由跳转,就可以完成最基本的打开游戏页签功能:


另外,点击关闭按钮也是类似,通过 GameCenterBloc#closeGame 维护页签列表,移除对应的页签,并返回移除的页签索引:

dart 复制代码
---->[维护状态数据]----
int closeGame(ImageMenu menu) {
  List<ImageMenu> menus = state.tabMenus.toList();
  int index = menus.indexWhere((e) => e.id == menu.id);
  menus.removeAt(index);
  emit(state.copyWith(tabMenus: menus));
  return index;
}

在点击关闭按钮时触发 closeGame 方法,并且基于激活状态以及当前页签,通过 nextRoute 方法决定该进入的路由。

比如关闭当前页签时,并且还有别的页签,可以激活前一个页签,如果已经是第一个,激活下一个:

dart 复制代码
---->[维护路由跳转]----
void _closeGame(BuildContext context, ImageMenu menu, bool active) {
  GameCenterBloc bloc = context.read<GameCenterBloc>();
  int removedIndex = bloc.closeGame(menu);
  List<ImageMenu> menus = bloc.state.tabMenus;
  if (!active) return;
  String nextRoute() {
    if (menus.isEmpty) {
      return AppRoute.gameCenter.url;
    }
    if (removedIndex == 0) {
      return menus.first.route;
    }
    return menus[removedIndex - 1].route;
  }
  context.go(nextRoute());
}

3. 路由保持活性

目前虽然完成了游戏的打开和切换操作,但是切换游戏之后,游戏界面会销毁。下次进入时重新构建,这样游戏的状态就会丢失。如下所示,由扫雷切换到贪吃蛇后,在切回扫雷,状态就会重置:

那该如何保持路由界面的状态呢。有两个思路:

思路1: 让界面销毁,但让每个游戏都拥有存档的能力。

但在关闭之前,提示用户确认关闭。关闭时存档,重新加载时通过数据恢复状态。

这样优势在于:

  • 运行时只真正激活一个游戏,可以及时关闭当前未激活的游戏,释放资源。

这样劣势在于:

  • 每次切换进入游戏时,都需要重新加载资源和存档数据。
  • 存档和恢复存档需要额外的代码设计。

思路2: 保持路由栈界面活性,实现关闭页签时不销毁游戏。

go_router 中有一种嵌套导航名为 StatefulShellRoute 的路由,可以保持局部导航路由的活性。其中每一个路由成为一个分支 branche, 如下所示,gameBranches 方法通过游戏名称创建分支列表:

dart 复制代码
List<StatefulShellBranch> get gameBranches {
  List<String> pages = ["center", ...gameWidgetMap.keys];
  return pages.map((String name) {
      Widget child = gameWidgetMap[name] ?? const GameCenter();
      Page page = NoTransitionPage(child: child);
      GoRoute route = GoRoute(path: 'game/$name', pageBuilder: (_, __) => page);
      return StatefulShellBranch(routes: [route]);
    }).toList();
}

StatefulShellRoute.indexedStack(
  builder: (_, __, StatefulNavigationShell child) => GameCenterNavigation(child: child),
  branches: gameBranches,
),

这样处理之后,在切换页签时,由于界面保持了活性,使用游戏状态就不会重置了:


4. 如何关闭游戏时取消保活

在编码过程中,我发现一个非常棘手的问题:

StatefulShellRoute 只能保持活性,无法让主动销毁路由界面。

这会导致即使关闭页签,游戏组件依然存活在组件树中,这让人很难受。这一点在 go_router 的 issuse 中也有很多人提及,但目前没有支持 142258。本着精益求精的研究精神,我详细探索了一下 StatefulShellRoute 的实现原理。并且在源码中,窥见了关闭分支的可行性。于是我修改了一下 go_router 的源码,使之支持 closeBranch 操作:

javascript 复制代码
--->[StatefulNavigationShell]----
void closeBranch(String path){
  route._shellStateKey.currentState?.closeBranch(path);
}

其原理是,打开的分支会被维护在 _branchState 映射中,在组件构建时会根据其中的内容构建导航视图。但 _branchState 维护在状态类内部,外界没有任何手段能访问并修改其中内容。所以我修改了一下源码,提供了 closeBranch 方法,可以根据传入的路径,找到对应的分支,然后移除。

dart 复制代码
void closeBranch(String route) {
  RouteBase? matchRoute;
  RouteMatchList matches =  _router.configuration.findMatch(Uri.parse(route));
  for(RouteMatchBase match in matches.matches){
    if(match is ShellRouteMatch){
      RouteMatchBase shellMatch = match.matches.first;
      if(shellMatch is ShellRouteMatch){
        matchRoute = shellMatch.matches.first.route;
      }
    }
  }
  StatefulShellBranch? target;
  for (StatefulShellBranch branch in _branchState.keys) {
    if (branch.routes.contains(matchRoute)) {
      target = branch;
    }
  }
  if(target !=null){
    _branchState.remove(target);
  }
}

此时就完成了令我满意的效果:如下所示,页签在打开时可以保存游戏活性,用户主动关闭页签时,游戏也从组件树中移除。这样游戏就有了一个合理的存活时期。


尾声

不止是游戏,这种多页签打开的场景是非常广泛的,特别是在桌面端和 Web 平台,由于宽屏幕和性能很高,多页签就是司空见惯的事。比如浏览器的网页、AndroidStudio 打开的文件、桌面的文件管理器、cmd 命令行等都支持多个页签。后续还会继续优化 TolyGameBox 的功能,那本文就到这里,后续更多精彩内容,敬请期待 ~

更多文章和视频知识资讯,大家可以关注我的公众号、掘金和 B 站 。

相关推荐
大G哥24 分钟前
Kotlin Lambda语法错误修复
android·java·开发语言·kotlin
鸿蒙布道师4 小时前
鸿蒙NEXT开发动画案例2
android·ios·华为·harmonyos·鸿蒙系统·arkui·huawei
androidwork4 小时前
Kotlin Android工程Mock数据方法总结
android·开发语言·kotlin
xiangxiongfly9156 小时前
Android setContentView()源码分析
android·setcontentview
人间有清欢7 小时前
Android开发补充内容
android·okhttp·rxjava·retrofit·hilt·jetpack compose
人间有清欢8 小时前
Android开发报错解决
android
每次的天空9 小时前
Android学习总结之kotlin协程面试篇
android·学习·kotlin
每次的天空11 小时前
Android学习总结之Binder篇
android·学习·binder
峥嵘life11 小时前
Android 有线网开发调试总结
android
是店小二呀13 小时前
【算法-链表】链表操作技巧:常见算法
android·c++·算法·链表