Flutter&Flame 游戏开发系列前言:
该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台 的 原生级 渲染框架,兼具 全端
跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。
第一季:30 篇短文章,快速了解 Flame 基础。
[已完结]
第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。
两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》 或 【github 项目首页】 查看。
0. 简介与本文目标
距离上一篇已经七个多月了,Flame 最新版本已经来到 1.26.0 ,之前用的还是 1.18.0
。以前我们已经通过Flame 搭建了不少小游戏,接下来打算做一个 全平台游戏盒 ,将之前的东西整合到一个全平台的 App 里:
TolyGameBox 游戏盒计划有几个好处:
-
- 可以借此将之前代码的升级到 Flame 1.26.0。
-
- 可以将之前零碎的东西整合在一起。
-
- 可以基于 Flutter 3.29.x 搭建一个全平台的应用,打通游戏和应用。
游戏盒将采用模块化的设计,每个游戏会作为独立的模块维护,每个模块可以独立运行,以便单独维护开发。新版代码将在 toly_game/game_box
分支进行开发。 Github 开源地址 (多多Star 哦~)
TolyGameBox 将先在桌面端开发,后续适配移动端。本文主要目的是:
-
1\]. 搭建如下整体结构
-
3\]. 展示出游戏中心数据
1. 应用启动
TolyGameBox 将使用 tolyui 和 fx_framework 进行构建。其中:
- tolyui 主要是 视图层框架,拓展了更多的 Flutter 组件,更易于快速搭建常用的视图效果;
- fx_framework 是 应用层框架 ,它脱离视图,封装 App 开发所需的常用功能。比如应用启动、路由拓展、数据库、网络请求等。
相较而言, tolyui 中的组件随意在任何 Flutter 项目中使用,是非常灵活无侵入的。而 fx_framework 则是希望制定一套 App 开发的流程,便于快速开发,但必须遵循 fx 的使用方式。fx_framework 目前处于尝试阶段 api 并不稳定,感兴趣的朋友可以体验一下:
yaml
--->[pubspec.yaml]---
dependencies:
tolyui: 0.0.4+8
fx_framework: 0.0.1
对于一个应用程序而言,如何优雅地启动是个问题。比如加载资源的时机、确定异常检测、成功时跳转等。 fx_framework 中的 fx_boot_starter 模块封装了启动流程,它规定了在哪里写初始化的逻辑、如何监听启动的状态。
如下所示,进入应用时展示 Splash 界面,资源加载成功后跳转到首页:
启动的代码如下,主要在 starter
文件夹下,其中自定义了 TolyGameBoxApp
启动器,在 main 中调用启动器的 run
方法即可工作:
dart
---->[lib/main.dart]----
void main(List<String> args) => const TolyGameBoxApp().run(args);
启动器要指定一个泛型作为初始化的结果数据;需要提供 app 组件作为应用的入口,以及 AppStartRepository 仓储来处理异步的初始化任务。在整个启动流程中中,基于 onLoaded
、onStartSuccess
、onStartError
可以定制不同的业务逻辑; onGlobalError 可以监听全局的异常。
这里在 onStartSuccess 时跳转到 gameCenter 主界面:
dart
class TolyGameBoxApp with FxStarter<AppConfig> {
const TolyGameBoxApp();
@override
Widget get app => const TolyGameBox();
@override
AppStartRepository<AppConfig> get repository => const TolyGameBoxRepo();
@override
void onGlobalError(Object error, StackTrace stack) { }
@override
void onLoaded(BuildContext context, int cost, AppConfig state) {}
@override
void onStartError(BuildContext context, Object error, StackTrace trace) {}
@override
void onStartSuccess(BuildContext context, AppConfig state) {
context.go(AppRoute.gameCenter.url);
}
}
启动器的仓储单独通过一个类来维护,是考虑到将应用初始化的逻辑强行剥离出来,引导开发者做好逻辑的隔离。这里在其中设置了桌面端的窗口尺寸。后期可以定制一些 App 的配置参数,在这里进行加载初始化:
dart
class TolyGameBoxRepo implements AppStartRepository<AppConfig>{
const TolyGameBoxRepo();
@override
Future<AppConfig> initApp() async {
WindowSizeAdapter.setSize();
// TODO 加载资源,创建 AppConfig 对象
return AppConfig();
}
}
2. 应用导航树
应用导航基于官方的 go_router, 导航 2.0 可以很方便地实现局部嵌套路由,如下所示,界面切换的过程中可以保持左侧的导航栏不懂,右侧的面板内容进行局部导航。
导航相关的代码在 navigation
文件夹下,其中:
- router 文件夹定义路树,其中包含字符串和界面的映射关系
- view 文件夹盛放导航相关的视图,在 desktop 文件夹下盛放桌面端的导航视图:
app_route 中定义路由树的根节点: 注意要在初始路由界面组件中加上 AppStartListener
来监听应用的启动事件,比如这里启动的首屏是 splash
界面:
dart
---->[lib/navigation/router/app_route.dart]----
RouteBase get appRoute {
return GoRoute(
path: AppRoute.home.path,
redirect: (_, __) => null,
routes: [
GoRoute(
path: AppRoute.splash.path,
builder: (_, __) => const AppStartListener<AppConfig>(child: Splash()),
),
if(kAppEnv.isDesktopUI)
deskHomeRoute,
],
);
}
为了更好的维护全局通知、主题,TolyUI 对 MaterialApp
进行了全量的封装,提供了 TolyUiApp
,使用方式和 MaterialApp 完全一致:
deskHomeRoute 是桌面端的嵌套路由,通过 ShellRoute
定义,在 DeskNavigation
组件的局部区域进行导航:这里通过 pageBuilder 指定 NoTransitionPage 可以让局部路由切换时立刻改变,不进行动画。当然你喜欢动画效果的话,也可以定制路由动画:
dart
RouteBase get deskHomeRoute => ShellRoute(
builder: (_, __, Widget child) => DeskNavigation(content: child),
routes: [
GoRoute(
path: AppRoute.gameCenter.path,
pageBuilder: (_, __) => const NoTransitionPage(child: GameCenterPage()),
),
GoRoute(
path: AppRoute.save.path,
pageBuilder: (_, __) => const NoTransitionPage(child: SavePage()),
),
GoRoute(
path: AppRoute.collect.path,
pageBuilder: (_, __) => const NoTransitionPage(child: CollectPage()),
),
GoRoute(
path: AppRoute.mine.path,
pageBuilder: (_, __) => const NoTransitionPage(child: MinePage()),
),
GoRoute(
path: AppRoute.settings.path,
pageBuilder: (_, __) => const NoTransitionPage(child: SettingsPage()),
),
],
);
另外,路由相关的固定字符串,通过 AppRoute
枚举统一维护:
dart
enum AppRoute {
home('/', url: '/'),
splash('splash', url: '/splash'),
startError('start_error', url: '/start_error'),
globalError('404', url: '/404'),
gameCenter('game_center', url: '/game_center'),
save('save', url: '/save'),
collect('collect', url: '/collect'),
mine('mine', url: '/mine'),
settings('settings', url: '/settings'),
;
final String path;
final String url;
const AppRoute(this.path, {required this.url});
}
3. 导航视图构建
DeskNavigation 组件负责构建桌面端整体视图,它呈左右结构,左侧是导航菜单, 右侧整体是局部导航区:
dart
class DeskNavigation extends StatelessWidget {
final Widget content;
const DeskNavigation({super.key, required this.content});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Container(
decoration: const BoxDecoration(gradient: bgGradient),
child: Row(
children: [
const DeskNavigationRail(),
const VerticalDivider(),
Expanded(child: content),
],
),
),
);
}
}
const Gradient bgGradient = LinearGradient(
colors: [Color(0xFF0A0A12), Color(0xFF1A1A2C)],
stops: [0.3, 0.8],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
左侧导航使用 tolyui 中的 TolyRailMenuBar 组件构建,首先准备侧栏菜单的数据 MenuMeta 列表:
dart
---->[lib/navigation/view/desktop/rail_navigation.dart]---
class DeskNavigationRail extends StatelessWidget {
const DeskNavigationRail({super.key});
List<MenuMeta> get navMenus => [
MenuMeta(
icon: TolyGameIcon.game_center,
label: "首页",
router: AppRoute.gameCenter.url
),
MenuMeta(
icon: TolyGameIcon.save,
label: "存档",
router: AppRoute.save.url,
),
MenuMeta(
icon: TolyGameIcon.collect,
label: "收藏",
router: AppRoute.collect.url,
),
MenuMeta(
icon: TolyGameIcon.mine,
label: "我的",
router: AppRoute.mine.url,
),
];
@override
Widget build(BuildContext context) {
// TODO 构建侧栏
}
}
视图构建逻辑如下:
- 通过
GoRouterState.of
可以通过上下文得到当前激活路由
,以此得到激活路径为 activeId 赋值,这样界面路由变化时,就可以重新构建激活对应的索引; - onSelected 回调可以感知菜单的点击事件,回调菜单的路径,传入
context.go
就可以在点击时跳转到对应菜单的路由地址; - TolyRailMenuBar 支持对菜单条目的灵活自定义,这里通过自定义
GameMenuCell
组件实现悬浮时颜色渐变的菜单项效果:
dart
@override
Widget build(BuildContext context) {
final String activePath = GoRouterState.of(context).uri.toString();
final bool isSetting = activePath == AppRoute.settings.url;
return DragToMoveWrapper(
child: TolyRailMenuBar(
width: 68,
gap: 10,
padding: const EdgeInsets.symmetric(horizontal: 6),
cellBuilder: GameMenuCell.create,
animationConfig: const AnimationConfig(type: AnimTickType.hove),
leading: (type) => const TolyGameLogo(),
menus: navMenus,
activeId: activePath,
backgroundColor: Colors.transparent,
onSelected: context.go,
tail: (_) => SettingButton(active: isSetting),
),
);
}
此时导航区就可以支持切换了,本文主要关注首页追踪游戏中心的展示,其他几个界面后面有时间再继续完善:
4. 游戏中心展示
现在希望在游戏中心展示展示之前完成的五个游戏,并且在点击时进入对应的游戏界面。这样就完成了 Flutter 应用和 Flutter 游戏的整合,我们要牢记一点,Flame 的游戏视图本质上也是一个 Widget,所以可以无缝地集成到任何的 Flutter 界面中:
游戏中心界面目前就是一个很简单的 GridView 网格列表,以下面的数据来填充界面。这里就不展开介绍了,感兴趣的可以参见源码 GameCenterPage :
json
[
{
"title": "经典扫雷",
"id":"sweeper",
"image": "assets/images/cover/sweeper.webp",
"create_at": "2024-05-07"
},
{
"title": "恐龙快跑",
"id":"trex",
"image": "assets/images/cover/trex.webp",
"create_at": "2024-03-04"
},
{
"title": "经典打砖块",
"id":"brick",
"image": "assets/images/cover/brick.webp",
"create_at": "2024-03-15"
},
{
"title": "生命游戏",
"id":"life_game",
"image": "assets/images/cover/life_game.webp",
"create_at": "2024-03-15"
},
{
"title": "贪吃蛇",
"id":"snake",
"image": "assets/images/cover/snake.webp",
"create_at": "2024-08-19"
}
]
这里重点介绍一下,一个游戏如何以独立的模块存在;一个游戏模块具有它所依赖的所有资源,外界只需要引入就可以访问该游戏界面。这里拿扫雷来说,通过如下命令可以创建一个 sweeper
模块包:
flutter create --template=package sweeper
扫雷相关的所有代码都在这里维护,包括游戏的图片资源。把之前的代码全部拷过来,就完成了 99% 的迁移工作:
对于模块包来说,最需要注意的一点是,资源使用时需要加上包名前缀:完整路径为:
packages/<模块包名称>/<资源在包内的相对路径>
之前加载资源使用的是自定义的资源加载器 TextureLoader , 现在需要对其稍加改造,让它支持指定模块加载资源。扫雷中的资源都是 svg ,从源码中可以看出,load 资源时可以传入 AssetsCache
,它可以指定资源的前缀:
现在将 TextureLoader
修改如下,构造时可以传入 package
参数,表示当前模块。构造时如果 package 非空,创建指定包名的前缀,另外 Images 对象是 flame 中加载普通图片资源的类。加载资源时使用 cache 对象即可:
dart
class TextureLoader {
final String? package;
AssetsCache? cache;
Images imageCache = Flame.images;
TextureLoader({this.package}) {
if (package != null) {
cache = AssetsCache(prefix: 'packages/$package/assets/');
imageCache = Images(prefix: 'packages/$package/assets/');
}
}
对于扫雷游戏来说, Flame 1.18.0 -> 1.26.0 间并没什么破坏性的更新。迁移后,游戏运转正常。嵌入到当前的游戏盒中也非常简单,添加一个跳转的 route 即可,点击时,推入路由:
其中 SweeperPage
使用 sweeper 模块提供的 SweeperGamePanel
视图,作为界面的一部分,你还可以自定义一些其他的信息,让游戏和应用完美融合:
dart
class SweeperPage extends StatelessWidget {
const SweeperPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.transparent,
body: Column(
children: [
CustomDeskTopBar(title: '经典扫雷',leading: BackButton(
onPressed: context.pop,
),),
const Expanded(child: SweeperGamePanel()),
],
),
);
}
}
尾声
到这里,就完成了一个最简单的游戏展示和点击打开的游戏盒。后面继续把其他几个小游戏也按照模块的方式集成进去即可。以后再写什么小游戏,就可以在这里安家了。如果有朋友写了什么好玩的游戏,也可以放一个模块进来,让 TolyGameBox 不断壮大。那本文就到这里,后续更多精彩内容,敬请期待 ~
更多文章和视频知识资讯,大家可以关注我的公众号、掘金和 B 站 。