Flutter 动画之Spine 2D 骨骼动画食用指南

1,438 阅读4分钟

Flutter Spine 动画插件使用指南

Video_20231107_014623_444.gif

环境 sdk: '>=3.0.0 <3.1.0'

Spine版本: spine_flutter: ^4.2.12

4.0版本以下的Spine动画软件导出的动画文件只能使用4.0版本以下的Spine插件版本, 4.0以上的Spine导出动画格式跟之前的格式有一定差异,只能使用4.0版本以上的spine插件

本篇文章所涉及的Spine动画版本说明使用 都是指定 4.0版本以上,因为官方插件已经升级适配4.0版本,所以没有必要再去使用4.0之前的版本,4.0版本前的动画文件在使用4.0之后的插件的时候会出现解析异常 且加载使用方式有差异,所以如果有这个需求的尽量使用4.0以上的版本,避免以后切换Spine 软件版本之后导出的文件不兼容的问题。

spine 介绍

通过spine软件制作出来的2d骨骼动画。

spine 动画组成:

一个Spine动画(也可以称为一组Spine动画,因为动画里可以添加多个动作(action),也就是相同的spine文件可以通过控制器切换不同的动作 ) ,至少需要3个文件或3个以上资源文件才能完成播放(非json格式文件,其中包含一个.skel 、.atlas 、以及一个或多个.png文件)

比如动画名称为animate,那么至少需要以下三个文件 :animate.atlas animate.skel animate.png

如果动画较大的情况下,可能会有多张png文件

使用时同一动画文件需要放在同一层目录下,且animate.atlas 文件中关联的图片需要一致,比如上述的 animate.png

Flutter 中Spine插件使用步骤:

1、添加插件 pubspec.yaml文件中添加

spine_flutter: ^4.2.12
添加spine动画目录

在项目中新建assets文件夹,assets文件夹中新建spine文件夹,将spine动画资源导入 到spine文件夹中

flutter:
  uses-material-design: true
  assets:
    - assets/spine/

2、初始化Spine插件

void main() {
    WidgetsFlutterBinding.ensureInitialized();
    await initSpineFlutter(enableMemoryDebugging: false);
}

3、Spine动画在普通Flutter页面使用

import 'package:spine_flutter/spine_flutter.dart';

   late SpineWidgetController _controller;

   ///初始化Spine动画控制器
   initState(){
      _controller = SpineWidgetController(
          onInitialized: (controller) {
             ///可以通过controller调整缩放
             controller.skeleton.setScaleX(0.5);
             controller.skeleton.setScaleY(0.5);
             ///设置初始动作以及重复状态,动画文件中定义的动作比如:action1 ,action2
             controller.animationState.setAnimationByName(0, "action1", true);//设置action1重复
          }
      );
   }
  
  @override
  Widget build(BuildContext context) {
    reportLeaks();
    return Scaffold(
      body: SpineWidget.fromAsset(
        "assets/spine/animate.atlas",
        "assets/spine/animate.skel",
        controller,
        boundsProvider: SetupPoseBounds(),
        //boundsProvider 有三种模式,
        //1、SetupPoseBounds(),默认模式,不需要配置参数,根据动画文件配置内容进行显示
        //2、RawBounds(),我称之为偏移模式,当动画文件没有居中或更实际显示有出入时,可以设置
        // x、y、width、height 来调整
        //3、SkinAndAnimationBounds(animation: ""),样式计算模式
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _togglePlay,
        child: Icon(controller.isPlaying ? Icons.pause : Icons.play_arrow),
      ),
);

}

加载App assets资源的Spine资源动画, _controller跟上面的一样
   SpineWidget.formAsset(
        "assets/spine/animate.atlas",
        "assets/spine/animate.skel",
        _controller
   )
加载App文件夹资源的Spine资源动画 , _controller跟上面的一样
  SpineWidget.fromFile(
       "/data/user/0/com.xxx.xxx/app_flutter/animate.atlas",
       "/data/user/0/com.xxx.xxx/app_flutter/animate.skel",
       _controller,
    );

4、Spine在Flutter Flame 游戏框架中的使用

GIF-231107_150127.gif

第一步:创建 spine 需要的 drawable
Future<SkeletonDrawable> createDrawAble(){
    late final SkeletonData cachedSkeletonData;
    late final Atlas cachedAtlas;
    //本地文件
    //cachedAtlas = await Atlas.fromFile("atlasPath");
    //cachedSkeletonData = await SkeletonData.fromFile(cachedAtlas, "skelPath");
    //assets资源
    //cachedAtlas = await Atlas.fromAsset("assets/spine/animate.atlas");
    //cachedSkeletonData = await SkeletonData.fromAsset(cachedAtlas, "assets/spine/animate.skel");
    return SkeletonDrawable(cachedAtlas, cachedSkeletonData, false);
}

不想封装的也可以使用插件提供的方法:

late SkeletonDrawable _drawable;
@override
void initState() {
  reportLeaks();
  super.initState();
  SkeletonDrawable.fromAsset(
  "assets/spine/animate.atlas", 		
  "assets/spine/animate.skel"
  ).then((drawable) async {
        _drawable = drawable;
    });
}
第二步:将Spine动画封装成游戏组件
class SpineComponent extends PositionComponent with HasGameRef{
  final BoundsProvider _boundsProvider;
  final SkeletonDrawable _drawable;
  late final Bounds _bounds;
  final bool _ownsDrawable;
  late double speed;//速度
  late Vector2 pos;//位置

  SpineComponent(
    this._drawable, , this.speed , this.pos ,{
    bool ownsDrawable = true,
    BoundsProvider boundsProvider = const SetupPoseBounds(),
    super.position,
    super.scale,
    double super.angle = 0.0,
    Anchor super.anchor = Anchor.topLeft,
    super.children,
    super.priority,
  })  : _ownsDrawable = ownsDrawable,
        _boundsProvider = boundsProvider {
    _drawable.update(0);
    _bounds = _boundsProvider.computeBounds(_drawable);
    size = Vector2(_bounds.width, _bounds.height);
  }

  static Future<SpineComponent> fromAssets(
    String atlasFile,
    String skeletonFile, {
    AssetBundle? bundle,
    BoundsProvider boundsProvider = const SetupPoseBounds(),
    Vector2? position,
    Vector2? scale,
    double angle = 0.0,
    Anchor anchor = Anchor.topLeft,
    Iterable<Component>? children,
    int? priority,
  }) async {
    return SpineComponent(await SkeletonDrawable.fromAsset(atlasFile, skeletonFile, bundle: bundle),
        ownsDrawable: true,
        boundsProvider: boundsProvider,
        position: position,
        scale: scale,
        angle: angle,
        anchor: anchor,
        children: children,
        priority: priority);
  }

  void dispose() {
    if (_ownsDrawable) {
      _drawable.dispose();
    }
  }

  @override
  void update(double dt) {
    _drawable.update(dt);
  }

  @override
  void render(Canvas canvas) {
    canvas.save();
    canvas.translate(-_bounds.x, -_bounds.y);
    _drawable.renderToCanvas(canvas);
    canvas.restore();
  }

  get animationState => _drawable.animationState;

  get animationStateData => _drawable.animationStateData;

  get skeleton => _drawable.skeleton;
}
第三步:游戏场景中创建游戏对象并调用
class GameTest extends FlameGame with HasGameRef {

   late final SpineComponent spineComponent ;

   @override
   Future<void> onLoad() async {
     spineComponent = SpineComponent(await createDrawAble(),
   }
}

Flutter页面中使用Flame游戏场景

class GameViewPage extends StatefulWidget {
  CatchGameViewPage({Key? key}

  class _GameViewPageState extends State<GameViewPage> {

  late GameTest gameTest;

  @override
  void initState() {
    gameTest = GameTest();
  }


  @override
  Widget build(BuildContext context) {
  return Scaffold(
    body:Stack(
    children:[
        Positioned.fill(
           child : GameWidget(
             game:gameTest,
           )
       )
     
    ]
    ) 
  }
}

游戏没有路由,所以路由跳转都是依赖Flutter原生路由,主要体现在游戏菜单、引导等浮层的切换使用 Flame 游戏浮层使用

GameWidget 有一个overlayBuilderMap参数

overlayBuilderMap: {
    Consts.gameExplainId : (_,game) {
    return GameExplainOverlay(game: testGame);
},
Consts.gameFinishedId : (_,game){
    return GameFinishedOverlay( game: testGame );
},
Consts.gameProgressId :(_ , game){
    return GameProgressOverlay(game: testGame,);
},
Consts.gameMenuId :(_ , game){
    return GameMenuOverlay(game: testGame);
},
},

GameExplainOverlay 、GameFinishedOverlay、GameProgressOverlay、GameMenuOverlay 分别为游戏介绍、游戏结束、游戏进度、游戏菜单浮层,都是由Flutter普通页面实现

class GameExplainOverlay extends StatefulWidget {
  Game game;//传入游戏对象
  GameExplainOverlay({Key? key , required this.game }) : super(key: key);

}

class _GameExplainOverlayState extends State<GameExplainOverlay> {


   @override
   void initState() {
     super.initState();
     ///播放音频或动画结束之后,通过widget.game.overlays.remove(Consts.gameExplainId) 即可移除
     ///通过widget.game.overlays.add(Consts.gameProgressId)添加游戏进度
   }

   @override
   Widget build(BuildContext context) {
   return Scaffold(
     body:Container(
        child:Text(
          "游戏说明"
        )
     )    
    );
   }
}