Flutter&Flame 游戏实践#22 | 全平台游戏盒#1

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 游戏盒计划有几个好处:

    1. 可以借此将之前代码的升级到 Flame 1.26.0。
    1. 可以将之前零碎的东西整合在一起。
    1. 可以基于 Flutter 3.29.x 搭建一个全平台的应用,打通游戏和应用。
    1. 可以作为我的 fx 架构 和 tolyui 的一块试验田。

游戏盒将采用模块化的设计,每个游戏会作为独立的模块维护,每个模块可以独立运行,以便单独维护开发。新版代码将在 toly_game/game_box 分支进行开发。 Github 开源地址 (多多Star 哦~)


TolyGameBox 将先在桌面端开发,后续适配移动端。本文主要目的是:

  • 1\]. 搭建如下整体结构

  • 3\]. 展示出游戏中心数据


1. 应用启动

TolyGameBox 将使用 tolyuifx_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 仓储来处理异步的初始化任务。在整个启动流程中中,基于 onLoadedonStartSuccessonStartError 可以定制不同的业务逻辑; 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 站 。

相关推荐
AD钙奶-lalala32 分钟前
Android 11以上App主动连接WIFI的完整方案
android
大耳猫36 分钟前
Android 中的 DataBinding 详解
android·android jetpack·databinding
玲小珑42 分钟前
Auto.js 入门指南(一)什么是 Auto.js
android·前端
jianleepb2 小时前
2025Flutter(安卓)面试题详解
flutter
Fastcv2 小时前
手把手教你上传安卓库到Central Portal
android·maven·jcenter
whysqwhw2 小时前
安卓应用线程与架构问题
android
小鱼干coc2 小时前
Android 轻松实现 增强版灵活的 滑动式表格视图
android
Le_ee2 小时前
dvwa6——Insecure CAPTCHA
android·安全·网络安全·靶场·dvwa
django-尿素3 小时前
django入门-orm数据库操作
android·数据库·django
封妖九禁苍天泣3 小时前
Glide NoResultEncoderAvailableException异常解决
android