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
列表记录菜单信息,其中 ImageMenu
是 tolyui_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 站 。