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 站 。

相关推荐
故事与他6454 小时前
Thinkphp(TP)框架漏洞攻略
android·服务器·网络·中间件·tomcat
每次的天空6 小时前
项目总结:GetX + Kotlin 协程实现跨端音乐播放实时同步
android·开发语言·kotlin
m0_748233178 小时前
SQL之delete、truncate和drop区别
android·数据库·sql
书弋江山8 小时前
flutter 自定义控件RenderObjectWidget使用
前端·javascript·flutter
CYRUS_STUDIO9 小时前
OLLVM 增加 C&C++ 字符串加密功能
android·c++·安全
帅次11 小时前
Flutter 输入组件 Radio 详解
android·flutter·ios·kotlin·android studio
&有梦想的咸鱼&12 小时前
Android Compose 框架的状态与 ViewModel 的协同(collectAsState)深入剖析(二十一)
android
开开心心就好12 小时前
高效PDF翻译解决方案:多引擎支持+格式零丢失
android·java·网络协议·tcp/ip·macos·智能手机·pdf
路上阡陌13 小时前
docker 安装部署 canal
android·adb·docker
&有梦想的咸鱼&14 小时前
入剖析 Android Compose 框架的关键帧动画(keyframes、Animatable)(二十三)
android