使用Flutter和SpriteWidget开发第一个跨平台Android/IOS手机游戏 – Beginner Tutorial

基于Flutter和Flame游戏开发引擎学习资料,Create a Mobile Game with Flutter and Flame – Beginner Tutorial,对于更加适用于复杂一点手机游戏的Flutter 开发的2D游戏引擎SpriteWidget也制作了boxGame游戏例子,希望通过起始能够对于SpriteWidget有比较好的了解和掌握,如果能够帮助到你,别忘了给我点赞哦!

如果你有什么问题,可以发邮件给我,或者在Github上留言,方便的话,我会尽力帮助你。

你在了解的过程,也可以参考下面的文章:

1. Create a Mobile Game with Flutter and Flame – Beginner Tutorial

2.飞行射击游戏spritewidget/spaceblast

3.Flutter高性能复杂游戏2D开发游戏引擎spritewidget

前提条件:

1. Android Studio - Flutter开发工具,当然你也可以使用其他的,但本例使用AS开发。

2.Flutter SDK/Framework - AS开发插件,你需要具备开发Flutter的AS开发环境,如果你还没有掌握Flutter的开发基础,请先尝试Flutter开发学习。

你可以在Github找到这个练习的完整代码。

开始撸代码吧:

Step 1: 创建一个Flutter Application(略,希望你是了解Flutter的)

Step 2: 添加spritewidget插件以及清理Application

打开./pubspec.yamland,增加下面的内容在thecupertino_icons行下面并且在derdependencies分类之下(注意缩进).

spritewidget:

然后记得执行flutter packages get,或者点击AS界面上的Packages Get来添加插件。

下一步是清理代码,Flutter Project新创建的是一个example,打开./lib/main.dart,清空代码只保留void main() {},并且确保使用material library来运行runApp() .

2.1 空程序

然后删除./test目录,不然会出现错误,这里我们用不上test方法。

Step 3:  添加主程序

打开./lib/main.dart,添加一下代码,和Flutter创建一个新的界面主程序一样:

import 'package:flutter/material.dart';

import 'package:spritewidget/spritewidget.dart';

main () async {

  runApp(MyApp());

}

class MyAppextends StatelessWidget {

  @override

  Widget build(BuildContext context) {

    return MaterialApp(

      title:"spriteWidget Game Eniger",

      theme: ThemeData(

      primarySwatch: Colors.blue,

      ),

      home: BoxGameScene(),

    );

  }

}

class BoxGameSceneextends StatefulWidget {

  @override

  State createState() => BoxGameSceneState();

}

class BoxGameSceneStateextends State {

  void initState(){

    super.initState();

  }

  @override

  Widget build(BuildContext context) {

    return null;

  }

}

这样就创建了一个Flutter的主程序。

Step 4: 添加游戏根节点RootNode:

新建一个dart文件命名为box_game.dart,创建class继承NodeWithSize,import游戏包:

import 'package:spritewidget/spritewidget.dart';

class BoxGame extends NodeWithSize {

  BoxGame() :super(new Size(320.0,320.0)){

  }

}

说明:创建一个BoxGame集成NodeWithSize作为RootNode,这样就可以实现spritewidget游戏引擎的游戏loop。一个基本的游戏loop在NodeWithSize以及继承的Node中可以通过update()和paint()方法来实现。

update()方法实现游戏node的移动或者更新(比如timer)。

paint()方法实现游戏node的绘制。

这里有一个非常重要的概念,spritewidget产生了自己的cooridinate system,使用Node自身坐标系进行对象位置的处理,因此:

BoxGame() :super(new Size(320.0,320.0)){}

就是给BoxGame这个Node初始化一个320*320尺寸的作为Node的自身坐标系,这样就可以按照这个坐标系进行新的子Node(child)的添加,每个Node,无论parent/child,都可以有自己自身的坐标系,以及起始位置相对于parent的坐标位置。

这个会在文后详细的把自己研究的坐标系相关信息进行描述。

Step 4: 添加RootNode到主程序

在main.dart中引入box_game.dart,然后在主程序app的state中初始化一个NodeWithSize,然后返回spritewidget():

import 'package:boxgamespritewidget/box_game.dart';

NodeWithSize _game;

_game =new BoxGame();

return SpriteWidget(_game);

这是main.dart就像下面的代码:

import 'package:flutter/material.dart';

import 'package:spritewidget/spritewidget.dart';

import 'package:boxgamespritewidget/box_game.dart';

main () async {

  runApp(MyApp());

}

class MyAppextends StatelessWidget {

  @override

  Widget build(BuildContext context) {

    return MaterialApp(

      title:"spriteWidget Game Eniger",

      theme: ThemeData(

      primarySwatch: Colors.blue,

      ),

      home: BoxGameScene(),

    );

  }

}

class BoxGameSceneextends StatefulWidget {

  @override

  State createState() => BoxGameSceneState();

}

class BoxGameSceneStateextends State {

  NodeWithSize _game;

  void initState(){

    super.initState();

    _game =new BoxGame();

  }

  @override

  Widget build(BuildContext context) {

    return SpriteWidget(_game);

  }

}

这时候你的app已经是个游戏app了。你可以运行一下看看。

Step 5: 绘制游戏主界面

添加游戏主节点

spritewidget添加对象非常简单,只需要在parentNode中addChild(childNode)就可以了,当然,需要你首先定义childNode。

我们在RootNode中添加一个游戏主界面_gameScreen,打开box_game.dart, 在BoxGame初始化时添加childNode。

Node_gameScreen;

BoxGame() :super(new Size(320.0,320.0)){

  _gameScreen =new Node();

  addChild(_gameScreen);

}

在rootNode中添加一个_gameScreen,是为了更好的在各Node进行交互时能够更有层次,方便不同parentNode和childNode的不同更新和绘制。

添加虚拟游戏操纵杆

spritewidget有一个炫酷的Node称之为VirtualJoystick,可以通过这个组件来控制游戏对象的输入,比如个方向在按住屏幕是的移动。

打开box_game.dart,相同于添加_gameScreen的方法,但是把_gameScreen作为父节点,将VirtualJoystick添加到_gameScreen中,

VirtualJoystick_joystick;

BoxGame() :super(new Size(320.0,320.0)){

  _gameScreen =new Node();

  addChild(_gameScreen);

  _joystick =new VirtualJoystick();

  _gameScreen.addChild(_joystick);

}

这时候你可以运行程序看看,你会发现VirtualJoystick并不存在,怎么回事?

原来,我们定义的RootNode使用了一个size(320, 320)的区域作为自己的坐标系统,那么对于不同尺寸的手机屏幕,并不是320x320的,这样就会根据rootNode的定义区域将屏幕进行适配,这时候VirtualJoystick缺省会被添加到屏幕的最下方,因此,我们需要初始化320x320

上的_gameScreen,设置它为最下端的屏幕Node,需要重新设置_gameScreen.position,

打开box_game.dart,在class BoxGame 中override方法spriteBoxPerformedLayout(),

@override

void spriteBoxPerformedLayout() {

  _gameScreen.position =new Offset(0.0,spriteBox.visibleArea.height);

}

让_gameScreen.position设置为基于ParentNode的高度方向坐标为y设置为spriteBox.visibleArea.height, 也就是320,向上平移320的高度。spriteBox.visibleArea之的是屏幕可见部分在320x320父节点中显示的部分,具体值可以参考后续说明。

这时再运行程序,一个很不错的游戏操纵杆在界面上显示了。

绘制并添加一个Box

新建一个BoxNode类集成Node,然后在绘制一个正方形,打开box_gam.dart,在BoxGame类下面创建一个BoxNode类,也可以新生成一个dart文件来创建BoxNode,然后在box_gam.dart中引用。

class BoxNode extends Node {

  BoxNode() {
    position = new Offset(0, 0);
  }

  @override
  void paint(Canvas canvas) {
       
  }
}

和BoxGame一样,BoxNode除了继承Node类以外,同样可以初始化相对于parentNode的position = new Offset(0,0); 通过paint()和update()进行绘制和更新。

说明:NodeWithSize实际上继承Node,但是增加了size和pivot点,可以通过更好的尺寸和支点来对Node进行更新和绘制。适合作为ParentNode或者RootNode,各个游戏对象可以使用Node创建。

在BoxNode中使用paint()进行绘制一个正方形,

@override
void paint(Canvas canvas) {

  boxRect = Rect.fromLTWH(
      spriteBox.visibleArea.height / 2 - 15,
      - spriteBox.visibleArea.height / 2 - 75,
      30,
      30,
  );

  Paint boxPaint = Paint();
  boxPaint.color = Color(0xff00ff00);

  canvas.drawRect(boxRect, boxPaint);
}

说明:基本上来讲,如果child的中点就是parent的size的中点减去偏移(前提是child的position初始化为new Offset(0, 0)。绘制对象使用canvas进行,对象和Paint()就可以实时绘制对象。

然后将BoxNode实例化,并添加到BoxGame的_gameScreen游戏主节点中,在BoxGame类的BoxGame() :super(new Size(320.0,320.0)){}初始化的_gameScreen下面添加,

BoxNode_box;

BoxGame() : super(new Size(320.0, 320.0)){

  _gameScreen = new Node();

  addChild(_gameScreen);

  _joystick = new VirtualJoystick();

  _gameScreen.addChild(_joystick);

  _box = new BoxNode();

  _gameScreen.addChild(_box);

}

OK,这时候运行程序,一个绿色的box以及一个虚拟游戏操纵杆就会出现在游戏主界面。

Step 6: 处理虚拟游戏操作

我们要通过虚拟游戏操纵杆来控制box的移动,需要在主界面update()中来通过VirtualJoystick来根据VirtualJoystick.value来改变box.position.

给BoxNode添加根据VirtualJoystick.value来更新自身position的方法,在class BoxNode中添加,

void applyThrust(Offset joystickValue) {

  Offset oldPos = position;

  Offset target = new Offset(joystickValue.dx * 160.0, joystickValue.dy * 220.0);

  double filterFactor = 0.2;

  position = new Offset(

      GameMath.filter(oldPos.dx, target.dx, filterFactor),

      GameMath.filter(oldPos.dy, target.dy, filterFactor));

}

说明:可以看到首先获取BoxNode的当前position作为旧的oldPos, 然后根据VirtualJoystick.value计算VirtualJoystick滑动屏幕的移动亮并根据屏幕尺寸进行放大,这里使用x放大160,y放大220,基本上是根据测试结果,会让VirtualJoystick操作box时看着比较流畅。

GameMath.filter方法可以在oldPos和target之间按照一个0-1之间的filterFactor插入多个移动位置,而不是直接将Box对象从oldPos移动到target,这样在更新的时候就会产生Box连续移动的效果,移动效果会比较流畅。

然后在BoxGame中,override主游戏RootNode的update()方法,将box的position的改变进行更新,这样就可以是的Box在屏幕上根据VirtualJoystick的操作进行移动,

void update(double dt) {

  _box.applyThrust(_joystick.value);

}

这样整个box_game.dart就像下面的代码,

import 'dart:ui';

import 'package:flutter/gestures.dart';

import 'package:spritewidget/spritewidget.dart';

class BoxGame extends NodeWithSize {

  // Game screen nodes

  Node _gameScreen;

  VirtualJoystick _joystick;

  BoxNode _box;

  double _scroll = 0.0;

  BoxGame() : super(new Size(320.0, 320.0)){

    _gameScreen = new Node();

    addChild(_gameScreen);

    _joystick = new VirtualJoystick();

    _gameScreen.addChild(_joystick);

    _box = new BoxNode();

    _gameScreen.addChild(_box);

  }

  @override

  void spriteBoxPerformedLayout() {

    _gameScreen.position = new Offset(0.0, spriteBox.visibleArea.height);

  }

  void update(double dt) {

    _box.applyThrust(_joystick.value);

  }

}

class BoxNode extends Node {

  bool hasWon = false;

  Rect boxRect;

  BoxNode() {

    position = new Offset(0, 0);

  }

  @override

  void paint(Canvas canvas) {

    boxRect = Rect.fromLTWH(

        spriteBox.visibleArea.height / 2 - 15,

        - spriteBox.visibleArea.height / 2 - 75,

        30,

        30,

    );

    Paint boxPaint = Paint();

    boxPaint.color = Color(0xff00ff00);

    canvas.drawRect(boxRect, boxPaint);

  }

  void applyThrust(Offset joystickValue) {

    Offset oldPos = position;

    Offset target = new Offset(joystickValue.dx * 160.0, joystickValue.dy * 220.0);

    double filterFactor = 0.2;

    position = new Offset(

        GameMath.filter(oldPos.dx, target.dx, filterFactor),

        GameMath.filter(oldPos.dy, target.dy, filterFactor));

  }

}

好了,运行一下程序,你可以通过VirtualJoystick来控制Box的移动了,是不是很酷?

Step 7: 添加点击事件控制box变色展示win状态

我们希望游戏对象可以被点击操作,spritewidget使用handleEvent(SpriteBoxEvent event){}来处理输入交互,SpriteBoxEvent包含多种点击屏幕处理事件,我们这里需要使用PointerDownEvent事件,当Box被点中时进行变色。

如果需要实现点击事件,需要对于点击检测Node进行设置,允许点击检测对象可以进行交互,但是不允许多点碰触,我们希望通过对BoxGame作为RootNode进行对象检测,如果发现在RootNode中点击到的区域是box的区域,则表明box被点中了,然后处理box被点击方法,首先在BoxGame初始化的时候BoxGame() :super(new Size(320.0,320.0)){}设置BoxGame定义为,

userInteractionEnabled =true;

handleMultiplePointers =false;

然后在BoxGame类中添加override方法,

@override

bool handleEvent(SpriteBoxEvent event) {

  if (event.type == PointerDownEvent) {

    Offset newPoint = convertPointToNodeSpace(event.boxPosition);

    if(newPoint.dx > _box.boxRect.left &&

        newPoint.dx < _box.boxRect.left + 30 &&

        newPoint.dy > _box.boxRect.top + spriteBox.visibleArea.height &&

        newPoint.dy < _box.boxRect.top + spriteBox.visibleArea.height + 30){

        //do box actions.

    }else{

      return false;

    }

  }

  return true;

}

说明:当点击事件发生时,判断是否为屏幕被点中,这是由于点中的位置为屏幕缺省坐标点,如果需要和RootNode的childNode的范围进行比较,需要首先使用convertPointToNodeSpace()方法将缺省坐标点转换为RootNode坐标系统,然后再和box的范围进行比较,由于_gameScreen作为主Node,初始化为new Offset(0.0, spriteBox.visibleArea.height),所以在对点击的位置判断是,需要把_box的位置也添加相同的偏移进行对比。

添加点击位置判断是box范围时,处理box的方法,在BoxNode类中定义,

bool hasWon =false;

定义一个布尔参数来分辨点击每次的状态,然后在paint()函数中,更改

boxPaint.color = Color(0xff00ff00);

布尔参数判读来使用不同颜色画笔,

if (hasWon) {

  boxPaint.color = Color(0xff00ff00);

} else {

  boxPaint.color = Color(0xffffffff);

}

如果布尔参数为true,画笔为绿色,否则为白色,然后定义一个点击调用的方法,来根据点击改变布尔参数的值,

void onTapDown() {

  hasWon = !hasWon;

}

box被点中一次布尔参数为true,再点击变为false,这样不同的点击布尔参数就会有不同的状态,这样就会在paint()方法中使用不同颜色的画笔来绘制box。

然后在BoxGame的bool handleEvent(SpriteBoxEvent event) {} 方法中判断点中box时调用onTapDown()方法,替换

//do box actions.

_box.onTapDown();

这样就完成了点击输入方式交互,整体box_game.dart的代码如下,

import 'dart:ui';

import 'package:flutter/gestures.dart';
import 'package:spritewidget/spritewidget.dart';

class BoxGame extends NodeWithSize {

  Node _gameScreen;
  VirtualJoystick _joystick;

  BoxNode _box;

  double _scroll = 0.0;

  BoxGame() : super(new Size(320.0, 320.0)){

    userInteractionEnabled = true;
    handleMultiplePointers = false;

    _gameScreen = new Node();
    addChild(_gameScreen);

    _joystick = new VirtualJoystick();
    _gameScreen.addChild(_joystick);

    _box = new BoxNode();
    _gameScreen.addChild(_box);

  }

  @override
  bool handleEvent(SpriteBoxEvent event) {

    if (event.type == PointerDownEvent) {

      Offset newPoint = convertPointToNodeSpace(event.boxPosition);

      if(newPoint.dx > _box.boxRect.left &&
          newPoint.dx < _box.boxRect.left + 30 &&
          newPoint.dy > _box.boxRect.top + spriteBox.visibleArea.height &&
          newPoint.dy < _box.boxRect.top + spriteBox.visibleArea.height + 30){
        _box.onTapDown();
      }else{
        return false;
      }
    }
    return true;
  }

  @override
  void spriteBoxPerformedLayout() {
    _gameScreen.position = new Offset(0.0, spriteBox.visibleArea.height);
  }

  void update(double dt) {

    _box.applyThrust(_joystick.value);

  }
}

class BoxNode extends Node {

  bool hasWon = false;
  Rect boxRect;

  BoxNode() {
    position = new Offset(0, 0);
  }

  @override
  void paint(Canvas canvas) {

    boxRect = Rect.fromLTWH(
        spriteBox.visibleArea.height / 2 - 15,
        - spriteBox.visibleArea.height / 2 - 75,
        30,
        30,
    );
   
    Paint boxPaint = Paint();
    boxPaint.color = Color(0xffffffff);

    if (hasWon) {
      boxPaint.color = Color(0xff00ff00);
    } else {
      boxPaint.color = Color(0xffffffff);
    }

    canvas.drawRect(boxRect, boxPaint);
  }

  void onTapDown() {
    hasWon = !hasWon;
  }

  void applyThrust(Offset joystickValue) {

    Offset oldPos = position;
    Offset target = new Offset(joystickValue.dx * 160.0, joystickValue.dy * 220.0);
    double filterFactor = 0.2;

    position = new Offset(
        GameMath.filter(oldPos.dx, target.dx, filterFactor),
        GameMath.filter(oldPos.dy, target.dy, filterFactor));
  }
}

好了你可以体验一下这个游戏了。

关于Node的坐标系统和世界坐标系统的相关联,后期添加。

你可以在Github找到这个练习的完整代码。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343