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

相关推荐
程序员Android29 分钟前
Android 手机耗电数据分析工具介绍
android·智能手机
moz与京2 小时前
【记】如何理解kotlin中的委托属性?
android·开发语言·kotlin
左少华2 小时前
Kotlin-inline函数特效
android·开发语言·kotlin
顾林海2 小时前
解锁Android应用进程启动:从代码到原理深度剖析
android·linux·操作系统
代码不停2 小时前
Java中的封装
android·java·开发语言
pengyu2 小时前
系统化掌握Flutter开发之路由(Route)(一):筑基之旅
android·flutter·dart
氦客2 小时前
Kotlin知识体系(一) : Kotlin的五大基础语法特性
android·开发语言·kotlin·基础语法·特性·知识体系
恋猫de小郭6 小时前
Android PC 要来了?Android 16 Beta3 出现 Enable desktop experience features 选项
android·前端·flutter