基于Flutter Canvas的飞机大战(二)

2,919 阅读6分钟

回顾

昨天下午笔者已经完成了背景动画的循环播放. 晚上笔者就开发中发现的问题在stackoverflow上进行提问. 问题大概内容:

如何在Canvas中, 将一个较小的图片, 拉伸平铺 问题链接

这个问题, 收到了二个有效的回答

  • Canvas.drawImageRect()
  • paintImage()

进过笔者测试

二者视觉效果相似, 可是 paintImage 的性能问题, 严重消耗了GPU资源. 查看了paintImage的源码, 发现这个函数实现的方式也是调用了 drawImageRect, 这个问题.有兴趣的同学可以深入了解一下. 共同探讨一下, 也行对于Flutter性能优化有很大的帮助.

void paintImage(
  ...
  if (centerSlice == null) {
    for (Rect tileRect in _generateImageTileRects(rect, destinationRect, repeat))
      canvas.drawImageRect(image, sourceRect, tileRect, paint);
  } else {
    for (Rect tileRect in _generateImageTileRects(rect, destinationRect, repeat))
      canvas.drawImageNine(image, centerSlice, tileRect, paint);
  }
  if (needSave)
    canvas.restore();
}

开始

本篇我们的主要任务是, 在画板上增加我们控制的飞机, 可以操作飞机移动.

绘制飞机

考虑到我们未来要绘制玩家的战机. 还要绘制敌机. 我们先抽象出一个 Plan 的类, 方便以后我们的开发.我们在 src 下, 新建一个叫 plan.dart的文件. 定义他的方法.

abstract class Plan {
  void init() {}
  void moveTo(double x, double y) {}
  void destroy() {}
  void paint(Canvas canvas, Size size) async {}
}

接下来我们就可以定义的的 MainHero我们的主角了. 我们的src下新建一个 hero.dart, 引用并继承 Plan, 并实现在上边定义的方法. 关于基本方法与属性如下:


enum PlanStatus {stay, move, die}

class MainHero extends Plan {
  // 飞机的中心坐标x
  double x = 100.0;
  // 飞机的中心坐标y
  double y = 100.0;
  // 战机宽度
  double width = 132.0;
  // 战机高度
  double height = 160.0;

  ui.Image image;

  @override
  void init() async {
    // TODO: implement init
    image = await Utils.getImage('assets/images/hero.png');
  }
  @override
  void moveTo(double x, double y) {
    // TODO: implement moveTo
  }
  @override
  void destroy() {
    // TODO: implement destroy
    super.destroy();
  }
 
 
  @override
  void paint(Canvas canvas, Size size) {
    Rect paintArea = Offset(100, 100) & Size(width, height);
    Rect planArea = Offset(0, 0) & Size(image.width, image.height)
    canvas.save();
    // 将画布向左上方偏移, 把绘图点, 迁移到飞机正中心
    canvas.translate( -width / 2, -height / 2);
    canvas.drawImageRect(image, planArea, paintArea, new Paint());
    frameIndex++;
    canvas.restore();
  }
}

在本次我们的绘图接口用的是 drawImageRect, 使用方法参考文档, 我们在游戏的 Enter入口文件中, 新建一个主角的实例, 完成初始化, 与绘图的逻辑, 具体细节与背景图类似, 我们就不细说了.

废话不多说, 直接上效果图

飞机的动效

在我们玩过的飞机类游戏里边. 我们控制的飞机通常都会有一个动态效果, 这个动态的效果会增强玩家的视觉体验, 笔者从网上找到了一份游戏飞机的动效如下:

这个飞机动效是一个 gif 类型的文件循环播放, 给人以动态的感觉. 我查阅了 flutter 貌似没有直接绘制gif的接口. 所以我们只能用绘制静态图的方式去想办法让飞机动起来, 做过h5的同学可能比较了解, 在早期html界面中的动画是由多帧拼接成一个胶片, 循环播放, 造成一种视觉停留的动画效果. 这里我们依然采用这种方式去实现本次的动态效果. 我们通过ps, 把每一帧拼接做成一个有2帧的132*80长帧图;

接下来, 我们就要盘这张图,对我们的 MainHero进行改造, 把他动态显示在我们的屏幕上. 我们给它增加二个属性和一个方法, 每一次屏幕刷新, 我们都把 frameIndex 进行加1的操作, 当达到最后一帧, 将 frameIndex重置为0, 这样我们的飞机就可以动起来了

// 总帧数
int frameNumber = 2;
// 当前帧数
int frameIndex = 0;

// 动态获取飞机的长帧图的绘制区域
Rect getPlanAreaSize(int _frameIndex) {

double perFrameWidth = image.width / frameNumber;
double offsetX = perFrameWidth * _frameIndex;
double offsetY = 0;
if (offsetX >= image.width) {
  frameIndex = 0;
  return this.getPlanAreaSize(0);
}
return Offset(offsetX, offsetY) & Size(66.0, 80.0);
}

效果图如下:

飞机的控制

关于控制飞机飞行的思路是, 我们通过监听屏幕, 手指的运动, 动态的更新飞机绘制 (x,y) 的坐标点, 从而达到我们想要的效果.

Flutter的文档中, 我们找到了 GestureDetector 接口, 在 Enter 入口中 我们用GestureDetector控件包围住我们的CustomPaint画板 控件。我们接下来的工作就是,使用 GestureDetector 控件来捕获用户的拖动事件。并更新我们 MainHero 的坐标点.

实现方式如下:

 Widget build(BuildContext context) build () {
    ...
    return GestureDetector(
      child: CustomPaint(
          painter: MainPainter(background: background, hero: hero)
      ),
      onPanStart: (DragDownDetails) {
        hero.moveTo(DragDownDetails.globalPosition.dx, DragDownDetails.globalPosition.dy);
      },
      onPanUpdate: (DragDownDetails) {
     
        hero.moveTo(DragDownDetails.globalPosition.dx, DragDownDetails.globalPosition.dy);
      }
    )
}

接下来我们来改造我们的 MainHero 类, 完善他的 moveTo 方法. 在游戏过程中, 我们手指拖动, 飞机不可能以闪现的方式进行闪动, 它需要一点点移动到我们的想要的位置. 我们在 MainHero中定义几个属性与方法

// 飞行目标点坐标
double _x;
double _y;
double speed = 20;
// 动态计算新的坐标点
void calculatePosition() {}

我们在这里用一张图, 去展示新旧坐标点之前的关系:

通过以上这张图, 我们要以明白在飞机在x与y轴上, 速度的矢量关系与运算方法, 我们完善我们的 calculatePosition

 void moveTo(double x, double y) {
    // TODO: implement moveTo
    this._x = x;
    this._y = y;
 }
void calculatePosition() {
    Point  p1 = Point(x, y);
    Point  p2 = Point(_x, _y);
    double distance = p1.distanceTo(p2);
    double flyRadian = acos(((y - _y) / distance).abs());
    // 判断位移方向
    if (_x < x) {
      x -= speed * sin(flyRadian);
    } else {
      x += speed * sin(flyRadian);
    }
    if (_y < y) {
      y -= speed * cos(flyRadian);
    } else {
      y += speed * cos(flyRadian);
    }
  }

通过以上改造, 我们进行测试发现, 在运动到终点时,飞机会在终点发生抖动, 排查问题发现, 是我们的calculatePosition方法, 在计算x值的时候, 会在最后一次计算中, 产生一个 |x - _x| > 0的结果, 所以飞机会在坐标点来回的跳动. 为了避免这种情况, 我们再次改造 calculatePosition 方法

我们为 MainHero 增加一个飞机的飞行状态, 当飞机与目标点及其接近时, 直接手动覆盖(x, y), 并将飞机的状态设为 stay.

// stay 无人控制, 自由飞行
// move 有人控制, 飞行运动状态
// die  死了
enum PlanStatus {stay, move, die}

void calculatePosition() {
    ...
    // 避免抖动, 做一个判断. 距离
    if (distance < 10) {
      x = _x;
      y = _y;
      status = PlanStatus.stay;
      return null;
    }
}
// 同时为了更好的优化我们的Pain方法函数, 我们为其增加一个逻辑的判断
void paint(Canvas canvas, Size size) {
    ...
    if (status == PlanStatus.move) {
      calculatePosition();
    }
}

通过以上改造, 我们看一下最终的效果.

总结

第二部份, 大工告成, 内容可能会有错别字, 请大家指出, 我将进行改正, 剩下的逻辑. 我会一点点补上, 如果觉得本篇内容对您有帮助, 期待您的赞~ git传送门