【Flutter】Flutterでちょっとした物理演算を使う【box2d/forge2d/spritewidget/4s4ki】

もくじ

こんにちは。

スマホアプリをメインに開発している子育てハードモードのロッキーカナイです。

Flutterでちょっと物理演算を使いたいといった状況が発生しましたので、実装した内容を記事にしてみました。

背景

物理演算って使う状況としては2Dゲームが思い当たると思うのですが、その場合はflameを使う必要があります。

flameはがっつりゲーム作れるものなのでそこまではしたくはないという状況の場合には最適かと思います。比較的簡単に作る事ができましたので紹介します。

プラグインについて

主に使うプラグインはforge2dspritewidgetです。

forge2d : 物理演算エンジンのbox2DをDartへ移植したもので、flameというゲームエンジンのチームがメンテナンスしている物理演算ライブラリです。

spritewidget : Flutterを使って複雑で高性能なアニメーションや2Dゲームを構築するためのツールキットです。

※Flutterのプラグインでbox2dが存在し、動作する事も確認しましたが(box2d0.4.0/FutterSDK2.0.3現在)、非推奨ですのでforge2dを使用します。

やること

いくつかの泡を画面中央付近まで移動させる。泡は交わらずお互い反発させたいので物理演算を使用するという感じ。

出来上がったものがこの画面頭あたりのgif画像になります。実際にはもっとスムーズに動くので、mp4も用意しました。

ちなみに、SpotifyAPIを使ってプレイリストのトラックを取ってきてアルバム画像を泡にしてます。

コード

コードは以下の通りです。

FutterSDK2.0.3

...
dependencies:
  flutter:
    sdk: flutter
  spritewidget:
  forge2d: ^0.8.1

  # SpotifyAPIから画像取得、サイズ変更等で必要なので、物理演算のみであれば不要
  flutter_shapes:
  extended_math: ^0.0.29+1
  spotify: ^0.5.1
  http: any
  image: ^3.1.0
...
// dart
import 'dart:async';
import 'dart:math';
import 'dart:ui' as ui;

// flutter
import 'package:flutter/material.dart';

// 3rd party plugin
import 'package:http/http.dart' as http;
import 'package:image/image.dart' as image;
import 'package:extended_math/extended_math.dart';
import 'package:forge2d/forge2d.dart';
import 'package:spritewidget/spritewidget.dart';
import 'package:spotify/spotify.dart';

void main() => runApp(MaterialApp(home: const _App()));

class _App extends StatelessWidget {
  const _App();

  @override
  Widget build(BuildContext context) {
    final size = MediaQuery.of(context).size;
    return Scaffold(
      body: SafeArea(child: SpriteWidget(Scene(size))),
    );
  }
}

class Scene extends NodeWithSize {
  Scene(
    Size size,
  ) : super(size) {
    /// 初期化
    _init();
  }

  /// 2Dのワールドを生成(引数は重力)
  World _world = World(Vector2.zero());

  /// 物体ノード(バブル)の配列
  List<_BubbleNode> _nodes = [];

  /// 物理演算の制御に必要なものとか
  Vector2 get _center => Vector2(size.width / 2, size.height / 2);
  final _centerAreaHeight = 50.0;
  Rect get _centerArea => Rect.fromLTWH(
        _center.x - _centerAreaHeight,
        _center.y - _centerAreaHeight,
        _centerAreaHeight,
        _centerAreaHeight,
      );
  bool _isContainsCenter = false;
  bool _isMoveActive = true;
  bool _isMount = false;

  /// 初期設定
  void _init() async {
    // SpotifyApiを使ってTOPトラック情報を取得する
    final spotify = SpotifyApi(
      SpotifyApiCredentials(
        "*********",
        "*********",
      ),
    );
    final tracks = await spotify.artists.getTopTracks(
      '5yCWuaBlu42BKsnW89brND',
      "JP",
    );

    // バブルを生成
    _createBubbles(tracks);

    // マウントフラグ(updateでの物理実行制御)
    _isMount = true;
  }

  @override
  void update(double dt) {
    super.update(dt);
    _world.stepDt(dt);

    // 物理演算制御
    if (_isMount && _isMoveActive) {
      _nodes.asMap().forEach((int i, _BubbleNode node) {
        if (!_isContainsCenter) {
          if (_centerArea
              .contains(Offset(node.body.position.x, node.body.position.y))) {
            _isContainsCenter = true;
            return;
          }
          _applyImpulse(node);
        } else {
          if (_isMoveActive &&
              node.isUpdated &&
              node.body.position
                      .distanceTo(Vector2(node.before.dx, node.before.dy)) <
                  0.1) {
            _isMoveActive = false;
            _world.clearForces();
          }
        }
      });
    }
  }

  /// 物体に衝撃を与える
  void _applyImpulse(_BubbleNode node) {
    final a = _center.x + node.body.position.x * -1.0;
    final b = _center.y + node.body.position.y * -1.0;
    node.body.applyLinearImpulse(Vector2(a, b) * node.body.mass);
  }

  /// バブルをトラック数分生成する
  void _createBubbles(Iterable<Track> tracks) async {
    tracks.forEach(
      (track) async {
        final radius = 30.0 + (20.0 * _getRandPercent());
        final image = await _loadImageURL(track.album.images.first.url,
            radius.toInt() * 2, radius.toInt() * 2);
        _nodes.add(
          _createBubble(
            Offset(size.width * _getRandPercent(), size.height + 50.0),
            radius: radius,
            image: image,
          ),
        );
      },
    );
  }

  /// バブル生成(物理関連のデータをセット)
  _BubbleNode _createBubble(
    Offset position, {
    double radius = 30.0,
    double friction = 0, // 摩擦係数
    double restitution = 0, // 反発係数
    double linearDamping = 10.0, // 移動速度の減衰率
    ui.Image image,
  }) {
    // 物体固有データ
    final FixtureDef fixtureDef = FixtureDef(CircleShape()..radius = radius);
    fixtureDef.friction = friction;
    fixtureDef.restitution = restitution;
    fixtureDef.density = 0; // 密度

    // 物体データ
    final BodyDef bodyDef = BodyDef();
    bodyDef.position = Vector2(position.dx, position.dy);
    bodyDef.type = BodyType.dynamic;
    bodyDef.linearDamping = linearDamping;

    // ワールドへ追加
    final Body body = _world.createBody(bodyDef);
    body.createFixture(fixtureDef);

    // アルバムの画像の物体を生成
    final _BubbleNode node = _BubbleNode(
      body,
      image,
      radius: radius,
    )..position = position;
    addChild(node);
    return node;
  }

  /// ネットワークURLからui.Image生成
  Future<ui.Image> _loadImageURL(String imageUrl, int height, int width) async {
    final http.Response response = await http.get(imageUrl);
    final image.Image baseSizeImage =
        image.decodeImage(response.bodyBytes.buffer.asUint8List());
    final image.Image resizeImage =
        image.copyResize(baseSizeImage, height: height, width: width);
    final ui.Codec codec =
        await ui.instantiateImageCodec(image.encodePng(resizeImage));
    final ui.FrameInfo frameInfo = await codec.getNextFrame();
    return frameInfo.image;
  }

  /// ランダム値生成
  double _getRandPercent({int max = 100}) {
    return Random().nextInt(max + 1) * 0.01;
  }
}

/// バブルノード
class _BubbleNode extends Node {
  _BubbleNode(
    this.body,
    this.image, {
    this.radius = 30,
  });
  final Body body;
  final double radius;
  final ui.Image image;
  Offset before;

  bool get isUpdated => before != null;

  @override
  void update(double dt) {
    super.update(dt);
    before = position;
    position = Offset(body.position.x, body.position.y);
  }

  @override
  void paint(Canvas canvas) {
    final Paint paintBorder = Paint()..color = Colors.white;
    canvas.drawCircle(Offset.zero, radius, paintBorder);
    final Path path = Path()
      ..addOval(Rect.fromLTWH(-radius, -radius, radius * 2, radius * 2));
    canvas.clipPath(path);
    canvas.drawImage(image, Offset(-radius, -radius), paintBorder);
  }
}

物理の動き制御の部分は結構適当なので、物理演算をFlutterで使う場合の大枠として参考にしていただければと思います。

さいごに

今回登場した私のおすすめプレイリスト

関連する記事

もっと見る