HAKUTAI Tech Notes

IT関連、数学のことなどを主として思いつきで色々書き溜めていきます

【Flutter】setState()のコールバック関数は空でもいいの?

StatefulWidgetを組み込んだFlutterアプリで画面の表示を動的に変更する場合、Stateが持つ変数を更新する(以降、単に変数の更新と言う)必要があり、その更新処理をsetState()メソッドのコールバック関数の中に書くというのが決まりになっています。ところが、変数の更新処理をコールバック関数の中に書くのではなくsetState()の外側に書いてみたところ、それでも問題なく画面の表示が更新できました。変数の更新処理をコールバック関数の中に書くというルールが守られていないにも関わらず動いているので、なぜそうなるのか疑問に思い色々調べていました。

カウンターアプリでの具体例

Flutterではおなじみのカウンターアプリを例に具体的な挙動を見ていきます。
画面右下の+ボタン(FloatingActionButton)のクリックイベントに_incrementCounter()関数が紐づけられていて、その内部に_counterStateの変数)を増やす処理が書かれています。 +ボタンをクリックするとカウンターが1ずつ増えていきます。

通常は変数の更新処理を書いた関数をsetState()の引数に渡します【①】
変数の更新処理を書いただけでsetState()を呼び出さなければ、当然ながらいくら変数が変更されようがそれと連動する画面の表示は何も変わりません【②】
しかし、先に変数の更新処理を書いてから空の関数を渡したsetState()を呼んだ場合も画面の表示が更新されています【③】


【①】setState()の引数に更新処理を渡す

void _incrementCounter() {
  setState(() {
    _counter++;
  });
}

【②】setState()を呼び出さない

void _incrementCounter() {
  _counter++;
}

【③】空の関数を渡したsetState()を呼び出す

void _incrementCounter() {
  _counter++;
  setState(() {
  });
}

コールバック関数内に書くのは「推奨」らしい

コールバック関数内で変数を更新する理由

変数の更新処理をsetState()のコールバック関数内に書く理由については下記のフォーラムに載っていました。

github.com

以前は画面の再構築に必要なmarkNeedsBuild()メソッドだけがあったが、それが本当に必要かどうか分からない時でもとりあえず呼び出すという使われ方が多かったようです。そこで、Stateの更新を画面に反映する方法としてsetState()を使用するように変更したところ、実装者はsetState()に変数の更新処理を渡すことだけを考えればよくなり、無駄にmarkNeedsBuild()が使われることを回避できるようになったという経緯らしいです。
変数の更新処理を絶対にsetState()のコールバック関数内に書かなければならない、そうしないと動かない、ということではないようです。

変数の更新から再構築までの流れ

setState()の定義内容を見てみると、fn()でコールバック関数を実行(実行結果をresultに代入している)してからmarkNeedsBuild()を呼び出す作りになっています。

void setState(VoidCallback fn) {
  // assert文は省略
  final Object? result = fn() as dynamic;
  _element!.markNeedsBuild();
}


次にmarkNeedsBuild()の定義内容を見てみると、再構築が必要なエレメント(Elementクラス)自身が持つ_dirtyプロパティをtrueにしています。これにより「私(エレメント)は再構築が必要ですよ!」というマークが付けられることになります。その後、自身を引数としてscheduleBuildFor()を呼んでいます。

void markNeedsBuild() {
  // assert文は省略
  if (_lifecycleState != _ElementLifecycle.active) {
    return;
  }
  if (dirty) {
    return;
  }
  _dirty = true;
  owner!.scheduleBuildFor(this);
}


この先は省略しますが、さらにいくつかのメソッドを経て_dirtyフラグがtrueになっているエレメントがあればbuild()が呼ばれ再構築されることになります。
結局、再構築を行うために最初に必要になるのはmarkNeedsBuild()の呼び出しであり、setState()は変数の更新処理と markNeedsBuild()の呼び出しをまとめてラッピングしたようなものと考えられます。

色々と余談

setState()を使わない書き方

せっかく用意されているsetState()を使わない理由は特にないですが、markNeedsBuild()を直に呼び出す書き方も可能なのでついでに載せておきます。
変数の更新処理の後で、_elementStatefulElementオブジェクト)が持つmarkNeedsBuild()を呼び出せばよいです。 _elementcontextとして参照できるが、StatefulElement型にキャストしてmarkNeedsBuild()が使えるようにしています。

void _incrementCounter() {
  _counter++;
  (context as StatefulElement).markNeedsBuild();
}

空のコールバック関数を渡している例

空のコールバック関数を渡したsetState()を呼び出すのが標準的になっている一例として、AnimationControllerTweenを使ったアニメーションの実行があります。

アニメーションの再生状態の管理などを行うAnimationControllerは、State自身を引数にして(下記のコード例ではvsync_MyHomePageState自身を指定)生成します。
Tweenはアニメーションの開始点と終了点を定義したもので、この範囲内でAnimationControllerStateに含まれるAnimationを更新します。addListener()メソッドを使ってフレーム切替えの度に実行したい処理を設定できるので、そこでコールバック関数が空のsetState()を呼んでいます。
各フレームでのAnimationの更新はcontrollerが自動的に行なっており、その都度setState()を呼び出して再構築しなければアニメーションは動きません。しかし、フレーム毎にAnimationの更新処理をsetState()のコールバック関数に収めるといったような書き方ができないため、状態の更新とsetState()の呼び出しが別々になっています。

class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
  late Animation<double> animation;
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 5),
    );
    animation = Tween<double>(begin: 0, end: 10.0)
      .animate(controller)
      ..addListener(() {
        // setStateを呼ばないとアニメーションが動かない
        setState(() {
        });
      });
    controller.repeat(reverse: false);
  }

// 省略

}

Dart学習時のメモ色々

1月なので何か新しいことでもやってみようかと思い、Flutterでのアプリ開発に足を踏み入れてみることにした。ただしその前に、Flutter開発に必要なDart言語が全くの初見だったので基礎の学習から始めた。Dartの自己学習中に引っかかった部分やDart特有の機能などについて調べた際のメモが結構貯まったので、せっかくなので項目別に整理してまとめることにした。


環境

  • Dart SDK 2.18.6

使用した参考書

Dartを始めて触るにあたり下記の書籍を参考にしながら学習を進めた。


Dart入門 - Dartの要点をつかむためのクイックツアー
各種の構文や演算子の使い方、クラス、非同期処理などDart言語の基本的な文法・仕様を一通り確認できる。Dartの公式ドキュメントを簡潔にしたような感じで、要点が分かりやすくまとめられている。最初はこの本のように必要最低限の基本事項を確認できるものに目を通して、部分的に詳細を知りたい場合は公式ドキュメントを読み込むというやり方が良さそう。


脱初心者のための問題集 Dart編
Dartの基本文法(特に制御構文やデータ型別のメソッドの使い方など)に関する練習問題が55問収録されている。上記の本で基本を確認した後で一気にこの問題集に取り組むとだいぶDartに慣れることができると思う。ただし、クラスや非同期処理に関する問題はほぼない(クラスの問題が1問あるぐらい)ので、必要に応じて別途自分でフォローする必要がある。


学習メモ

Null Safetyについて

DartはNull Safety(null安全)な言語で、nullにアクセスする可能性のある処理が含まれているとコンパイルの時点で失敗するので、プログラム実行中にnull絡みで落ちるといったことを防いでいる。Null Safetyが備わっていない言語から入ってくると、何てことない処理を書いたつもりでもNull Safetyに引っかかって「あれ?」とハマってしまうこともある。
Dartでは変数の型を宣言するとnull非許容型(non-nullable)となるのがデフォルトであり、この変数に対してnullが代入される可能性がある場合はコンパイルエラーとなる。

void main() {
  int number = 0;
  List<int> list = [12, 50, 9];

  number = null; // エラー
  list.add(null); // エラー
}

変数がnullになる可能性がありnull許容型(nullable)として宣言したい場合は型宣言に?を付ける。

void main() {
  int? number = 0;
  List<int?> list = [12, 50, 9];

  number = null; // nullを代入可
  list.add(null); // nullを追加可
  print(number); // -> null
  print(list); // -> [12, 50, 9, null]
}

null許容型の式、変数だがnullにならないことが確実に分かっている場合、nullアサーション演算子!を付けるとnull非許容型に変換できる。
例えば、下記のreturnStringAlways()は必ず「Hello」が返ってくると分かるが、null許容型なのでそのままではNull Safetyに引っかかり後続のindexOf()は使えない。この場合、!を付けてnull非許容型に変換することでindexOf()が使えるようになる。

void main() {
  print(returnStringAlways()!.indexOf('e')); // -> 1
}

// null許容型だが必ず文字列を返す関数
String? returnStringAlways() => 'Hello';

null許容型の式、変数でnullになる可能性がある場合、条件付きプロパティアクセス演算子?.を使用すれば後続に処理を繋げることができる。?.の左側がnullでなければ右側の処理が実行され、左側がnullならば右側の処理は実行されないのでそのままnullとして評価される。

void main() {
  // 現在時刻の秒が偶数なら文字列、奇数ならnullを代入する
  String? str = DateTime.now().second % 2 == 0 ? 'ABCDEFG' : null;
  print(str?.length); // -> 7 or null
  print(returnListOrNull(10)?..add(4)); // -> [1, 2, 3, 4]
  print(returnListOrNull(7)?..add(4)); // -> null
}

// 偶数が渡されたらintのリスト、奇数が渡されたらnullを返す
List<int>? returnListOrNull(int n) => n % 2 == 0 ? [1, 2, 3] : null;

null許容型の式、変数がnullになる場合に代替値を与えたい時はnull合体演算子??を使用する。??の左側がnullの場合のみ右側の式が評価される。
また、null合体代入演算子??=を使えば、null許容型変数がnullの場合にその変数自身に値を代入することができる。三項演算子を使っても同じことができるが、??=のほうが簡潔に書ける。

void main() {
  // 現在時刻の秒が偶数なら500、奇数ならnullを代入する
  int? number = DateTime.now().second % 2 == 0 ? 500 : null;
  print(number ?? 0); // -> 500 or 0
  number ??= 0;
  // number = number != null ? number : 0; 三項演算子を使った場合と同じ結果
  print(number); // -> 500 or 0
}


StringBufferでの文字列の連結

StringBufferクラスの説明には、「文字列を効率的に連結するためのクラス」とある。

api.dart.dev

何が効率的なのかと言うと、最初にバッファー(StringBufferオブジェクト)を1つ生成してそれに文字列を追加していくことで、何度も新しいインスタンスを作ることなく文字列を結合できる点である。
Stringクラスの+を使っても同じように文字列の結合はできるが、こちらは毎回新しいインスタンスが作られる。例えば、下記のようにidentical()を利用して元のstrと文字列結合後のstr += 'xxxx'を比較するとfalseが返ってくるので、両者は異なるインスタンスであることが分かる。

void main() {
  var buffer = StringBuffer();
  buffer.write('xxxx'); // 同一インスタンスとして文字列の結合が可能

  var str = '';
  print(identical(str, str += 'xxxx')); // -> false
}

StringBuffeを使う例。最初にバッファーを生成しておき、StringBuffeクラスの各種メソッドを利用してバッファーにオブジェクトを追加していく。toString()を使うとバッファーに含まれる要素が結合されて単一の文字列として出力される。

void main() {
  var buffer = StringBuffer('first');
  buffer.write('second'); // write():bufferにオブジェクトの文字列表現を追加する
  print(buffer.length);

  buffer.writeln(); // writeln():bufferに改行を追加する
  buffer.writeAll([111, 'aaa', true, '\n']); // writeAll():bufferにIterableオブジェクトの要素を順に全て書き込む
  buffer.writeAll([123, 456, 789], '-'); // 第2引数の区切り文字でIterableオブジェクトの要素を繋いだ文字列を追加する
  buffer.writeln();
  [0x52, 0x45, 0x44].forEach((code) => buffer.writeCharCode(code)); // charCode():ASCIIコード表のコードに対応する文字を書き込む
  print(buffer.toString()); // toString():bufferの中身を単一の文字列に変換する

  buffer.clear(); // clear():bufferの中身をクリアする
  print('クリア:${buffer.toString()}');
}
11
firstsecond
111aaatrue
123-456-789
RED
クリア:

同じことをStringBufferを使わずに行うと下記のようになる。

void main() {
  var str = 'first';
  str += 'second';
  print(str.length);

  str += '\n';
  str += [111, 'aaa', true, '\n'].join();
  str += [123, 456, 789].join('-');
  str += '\n';
  str += String.fromCharCodes([0x52, 0x45, 0x44]);
  print(str);

  str = '';
  print('クリア:${str}');
}


データ型の確認と比較

オブジェクトのruntimeTypeプロパティにアクセスすると、そのオブジェクトのデータ型を確認できる。javascriptで言うところのtypeofと同じようなことができる。

void main() {
  String str = 'Saitama';
  List<String> list1 = ['Tochigi', 'Tokyo', 'Kanagawa'];
  const list2 = [123, 'Gunma', true, 'Ibaraki', 456];

  print(str.runtimeType); // -> String
  print(list1.runtimeType); // -> List<String>
  print(list2.runtimeType); // -> List<Object>
}

オブジェクトが特定のデータ型かどうかを確認する時はis(否定はis!)を利用すればいい。

void main() {
  print('Chiba' is int); // -> false
  print('Chiba' is! int); // -> true
}


whereType()とwhere()

IterableクラスのwhereType()は、<>で指定した型を持つ要素のみ抽出したIterableを返す。例えば、whereType<int>()なら整数値の要素のみ抽出してくれる。 返り値はWhereTypeIterable<int>型なのでtoList()で変換したものをList<int>に格納可能。

void main() {
  const list = [12, 33, true, 5, 19, 50, 'abcd', 'efgh'];
  List<int> intList = list.whereType<int>().toList();
  print(intList); // -> [12, 33, 5, 19, 50]
  print(list.whereType<int>().runtimeType); // -> WhereTypeIterable<int>
}

where()でも同様のことは可能だが、様々な型の要素を含むList<Object>が対象の場合は結果がWhereIterable<Object>型となるので、toList()で変換してもList<int>には格納できない。なお、cast()でキャストすれば格納は可能だが、コレクションの型を変更するためにcast()を利用するのは非推奨とされているので、やはりこの場合はwhereType()を使った方がいい。

void main() {
  const list = [12, 33, true, 5, 19, 50, 'abcd', 'efgh'];
  print(list.where((e) => e is int).runtimeType); // -> WhereIterable<Object>
  List<int> intList1 = list.where((e) => e is int); // これはエラー
  List<int> intList2 = list.where((e) => e is int).toList().cast<int>(); // castすれば代入可
  print(intList2); // -> [12, 33, 5, 19, 50]
  print(intList2.runtimeType); // -> CastList<Object, int>
}


複数条件でのMapリストのソート

要素がMapオブジェクトのリストを、複数の比較条件でソートする場合の書き方。
sort()の比較関数の中で使っているcompareTo()は比較対象が同値の場合は0を返すので、1つ目の比較結果が0の時だけ2つ目の比較に移るようにif文を使って制御する。同じ要領でさらに多くの比較条件をつなげることもできる。
なお、複数条件で比較する場合は比較関数の中が2行以上になるので、アロー演算子=>で記述することはできない。

void main() {
  List<Map<String, dynamic>> teams = [
    { 'name': 'Spain', 'points': 4, 'difference': 6 },
    { 'name': 'Costarica', 'points': 3, 'difference': -8},
    { 'name': 'Germany', 'points': 4, 'difference': 1 },
    { 'name': 'Japan', 'points': 6, 'difference': 1 },
  ];

  // 各チームを1.points、2.differenceの順に比較して並び替える
  teams.sort((a, b) {
    int result = b['points'].compareTo(a['points']); // 1つ目のkey 'points' での比較(降順)
    if (result != 0) return result;
    return b['difference'].compareTo(a['difference']); // 2つ目のkey 'difference' での比較(降順)
  });

  print(teams); // -> [{name: Japan, points: 6, difference: 1}, {name: Spain, points: 4, difference: 6}, {name: Germany, points: 4, difference: 1}, {name: Costarica, points: 3, difference: -8}]
}


Mapのソート

Mapオブジェクトの要素には順番の概念がないので、keyやvalueに応じてソートしたい場合は少し工夫する必要がある。


◆ SplayTreeMap.from()を使ってソートする

SplayTreeMapクラスはkeyに順番が存在するマップである。SplayTreeMapクラスのメンバの1つであるfrom()は、比較関数に応じてソート対象のMapの中身を並び替えるものである。SplayTreeMapを使うためにはcollectionライブラリをインポートする必要がある。

from()の第2引数の比較関数には比較対象の2つのkeyが渡される。 keyをそのままcompareTo()の比較対象にすればkeyでのソートになり、keyで参照したvalueを比較対象にすればvalueでのソートになる。ただし、valueでソートする場合、値が同値になると片方の要素は消えてしまうので注意が必要。

import 'dart:collection';

void main() {
  Map<String, int> population = {
    'Gunma': 193,
    'Tochigi': 193,
    'Ibaraki': 299,
    'Saitama': 704,
    'Chiba': 603,
    'Tokyo': 1237,
    'Kanagawa': 869
  };

  // keyでソート
  SplayTreeMap<String, int> sortedByKey = SplayTreeMap.from(population, (k1, k2) => k1.compareTo(k2));
  // valueでソート
  SplayTreeMap<String, int> sortedByValue = SplayTreeMap.from(population, ((k1, k2) => population[k1]!.compareTo(population[k2]!)));
  print(sortedByKey);
  print(sortedByValue);
}
sortedByKeyの出力
{Chiba: 603, Gunma: 193, Ibaraki: 299, Kanagawa: 869, Saitama: 704, Tochigi: 193, Tokyo: 1237}

sortedByValueの出力(Tochigiが消えてしまう)
{Gunma: 193, Ibaraki: 299, Chiba: 603, Saitama: 704, Kanagawa: 869, Tokyo: 1237}

valueで比較した時に同値、つまりcompareTo()の結果が0となって要素が消えてしまう場合は下記のようにすれば良い。要はcompareTo()が0になる時は意図的に0以外の値を返すようにしてやる。 下の例では、valueでの比較結果が0になる場合、次にkeyでソートして0が返らないようにしている。

  // valueでソート(同値になる場合を考慮)
  SplayTreeMap<String, int> sortedByValue = SplayTreeMap.from(population, ((k1, k2) {
    int result = population[k1]!.compareTo(population[k2]!);
    if (result != 0) return result;
    return k1.compareTo(k2);
  }));
  print(sortedByValue);
{Gunma: 193, Tochigi: 193, Ibaraki: 299, Chiba: 603, Saitama: 704, Kanagawa: 869, Tokyo: 1237}


◆ Map.entriesとMap.fromEntries()を組み合わせてソートする

Mapentriesプロパティを参照すると、MapEntryオブジェクトとして各要素のkeyとvalueのペアを取得できる。これをtoList()MapEntryのリストに変換する。

print(population.entries.toList());
[MapEntry(Gunma: 193), MapEntry(Tochigi: 193), MapEntry(Ibaraki: 299), MapEntry(Saitama: 704), MapEntry(Chiba: 603), MapEntry(Tokyo: 1237), MapEntry(Kanagawa: 869)]

ソート処理の部分はリストに対するソートと同じで、比較対象をkeyにすればkey、valueにすればvalueでソートできる。 後は、ソートしたリストをMap.fromEntries()に渡してMapオブジェクトに変換してやれば良い。 この方法であれば、valueが同値であっても要素が消えてしまうことはない。

Map<String, int> sortedByKey = Map.fromEntries(
  population.entries.toList()..sort((a, b) => a.key.compareTo(b.key)));
Map<String, int> sortedByValue = Map.fromEntries(
  population.entries.toList()..sort((a, b) => a.value.compareTo(b.value)));


外部ライブラリの使用

Dartの標準ライブラリ(接頭辞dart:を付けてインポートするライブラリ)ではなく外部ライブラリを使用したい場合、なにもせず対象のライブラリをインポートしようとしても当然ながら「そんなものはない」と言われる。

Error: Not found: 'package:intl/intl.dart'


外部ライブラリを利用するためにはPubパッケージ・マネージャでライブラリを管理する必要があり、そのためにはpubspec.yamlを作成して必要な設定を記述する必要がある。実行対象のDartファイルとpubspec.yamlを含むディレクトリを1つのDartパッケージとして扱う。Flutterのプロジェクトとして新規作成した場合は自動でpubspec.yamlが作成されるが、今回はただDartのコードを単体で実行するだけなので自分でファイルを作成する必要がある。

pubspec.yamlに最低限記述しなければならないフィールドはnameenvironmentの2つである。
nameはパッケージの名称を指定するもので、Dartのコードを実行するだけなら適当な名称でいい。使える文字は小文字の半角英数字かアンダースコアのみで、先頭が数字のものやDartの予約語は指定不可である。
environmentはパッケージが動作するDart SDKのバージョンを指定するもので、Dart 2 以降は指定が必須である。例えば>=2.10.0 <3.0.0と指定した場合、2.10.0以降3.0.0より前のバージョンのDart SDKで動作するパッケージになる。

name: xxxx
environment:
  sdk: ">=2.10.0 <3.0.0"

pubspec.yamlを作成したら、下記のコマンドで目的のライブラリを取得する。(intlを取得する例)

$ dart pub add intl

正常に取得されればpubspec.yamldependenciesフィールドが追加され、対象のライブラリのバージョン情報が追記される。これでプログラムの内部で対象のライブラリをインポートして使うことができるようになる。

name: xxxx
environment:
  sdk: ">=2.10.0 <3.0.0"
dependencies:
  intl: ^0.18.0

macOS Venturaにアップデートした際に既存アプリが開けず苦戦した話

新年ということで心機一転、普段使っているMacBookのOSを最新のVenturaにアップデートしてみた。 ところが、元々使えていたアプリを起動しようとした時に「開発元を検証できないため開けません」という警告が出てしまい起動できるまで苦戦したので、その発生状況と解決方法について書いておく。

発生状況

問題が起きたのは「Shiftlt」というアプリを起動しようとした時だ。このアプリは簡単なキー操作だけでウインドウの上下左右寄せや最大化などを行うことができる非常に便利なもので、以前からよくお世話になっていた。 github.com

macOSをVenturaにアップデートするとShiftltの機能が無効になっていたので、「アプリケーション」ディレクトリ内のShiftltのアイコンをクリックして再度有効化しようとしたところ下記の警告がでてきた。

開発元を検証できないという警告は、App Store以外からダウンロードしたアプリを起動しようとした時に過去何度も遭遇したことがある。これ自体は見慣れているものなのでとりあえずキャンセルした。


次に、システム設定の「プライバシーとセキュリティ」を確認してみた。
元々「システム環境設定」だったものがVenturaでは「システム設定」に名称が変わっており、UIも大幅に変更されていた。
「”Shiftlt”は開発元を確認できないため、使用がブロックされました。」と表示されているので「このまま開く」をクリック。


ユーザ名とパスワードの入力を求められるので入力して「設定を変更」をクリック。


「開いてもよろしいですか?」と聞かれるが、開いてよろしいので「開く」をクリック。


「許可が必要です」というメッセージと共にポップアップが表示されるので、「再確認」や「システム環境設定を開く」をクリックしてみると…同じポップアップが表示され続けるだけで何も変わらない。
再びShiftltのアプリアイコンをクリックしてもこのポップアップが出てきてしまうので堂々巡りだ。 PCを再起動したりアプリを入れ直したりしたが結局何も変わらず。
「〜チェックマークを付ける必要があるかもしれません。」かもしれませんって何!? しかも画像に表示されているUIが以前のバージョンのものだし…。

解決方法

分かれば何のことはなかった。
システム設定の「プライバシーとセキュリティ>アクセシビリティ」を順に選択していくと、Shiftltがリストにあるので右のトグルボタン(以前はチェックボックスだった)をオンにする。これだけで、Shiftltのアプリアイコンをクリックして有効化し問題なく利用できるようになった!

確かに前述の「許可が必要です」ポップアップに書かれていた内容と同様の操作を行っただけだが…示されていた画像や説明内容と現状のUIが合っていないので気付くのに時間がかかってしまった。そもそも「このまま開く」ボタンは何だったのか。

補足 / Venturaへのアップデートについて

こちらの記事によると、Venturaへアップグレードすると「システム設定環境」アプリが「システム設定」アプリとして更新されることに伴い、一部機能で問題が発生したり不便が生じるという報告が出ているらしい。

applech2.com

Venturaに対応していないプラグインの設定パネルが開けなくなるとか、UIの大幅変更(iPhone、iPadのUIデザインにより近づけられた)で分かりにくくなったとか色々問題を抱えているようだ。

他にもこれまで問題なく使えていた機能、アプリで不具合が起きるかもしれない。
お正月から手間をかけさせられた…。

【matter.js】曲がりくねった棒を簡単に生成する

matter.jsで曲がりくねった棒状のBodyを作ろうとすると思った以上に大変である。
Bodies.fromVertices()Svg.pathToVertices()で座標点を指定して作ると、凹面が含まれる形状の場合は凸包形(凹面が埋められる)に変換されてしまうためdecomp.jsを使う必要があり、あまり複雑な形状だと思い通りに機能してくれないことが多い。さらに、これらの関数ではドーナツ形のように空洞を含む形状はサポートされていない。
そこで、もっと単純な条件の入力だけで等幅の曲がりくねった棒状のBodyを生成できる関数を作成してみた。

棒の生成の考え方

関数的には棒の骨格となる中心線の座標データと棒の幅だけを引数として渡す。
やはりBodies.fromVertices()を使うが、棒全体の座標を与えて一気に作るのではなく、棒の節点(屈折部分)と節点の間に四角形を1つずつ生成してそれらを最後にくっつけて1本の棒にするという方針になる。
中心線の各節点から腕(棒の幅と曲がりの角度に応じて長さが変わる)を左右に伸ばして、棒の1部品となる四角形の頂点の座標を決める。 なお、ここで言う棒の左側・右側とは、中心線の進行方向を見た時の左側・右側という意味で使っている。

中心線の各線分の傾きの計算

中心線のn、n+1番目の節点を$S_n(x_n, y_n)$, $S_{n+1}(x_{n+1}, y_{n+1})$とした時、線分$S_nS_{n+1}$がx軸の正の向きとなす角$\theta_n$を計算する。
線分$S_nS_{n+1}$の傾きを$m$とすると、両節点のx, y座標の値から$\theta_n$は下記のように求められる。

$$ m = \frac{y_{n+1} - y_n}{x_{n+1} - x_n} = \tan \theta_n $$

$$ \theta_n = \tan^{-1}m $$

これと同等の計算は、javascriptのMath.atan2() 関数を利用すればいい。Math.atan2() は、原点 (0, 0) から任意の1点 (x, y) までの半直線とx軸の正の向きのなす角をラジアン単位で返す。原点以外の2点間で計算したい場合は、2点のx, y座標の差分をそれぞれ引数に指定すればいい。引数はy, xの順で渡す必要がある。

なお、数学的には角の向きは反時計回りを正とするが、matter.jsではy軸が下向きなので角の向きは時計回りが正となる点に注意する必要がある。

節点から左右に伸びる腕の長さの計算

節点$S_n$, $S_{n+1}$間に四角形(図の黄色い部分)を作る時、 四角形の両端の辺(図の$P_1P_2$, $P_3P_4$)が中心線の屈折角の二等分線となるようにしてやると、棒の幅に基づいて自然な感じで屈折部を作ることができる。
$\angle S_{n-1}S_nS_{n+1}$は線分$P_1P_2$で等分されるので、まずは右側の角$\alpha_n$を求める。

$$ \alpha_n = \frac{180^{\circ} - (\theta_n + \theta_{n-1})}{2} = 90^{\circ} - \frac{\theta_n + \theta_{n-1}}{2} $$ であるが、$\theta_n$と$\theta_{n-1}$の回転方向を考慮すると図の場合は$\theta_n \gt 0$, $\theta_{n-1} \lt 0$なので、

$$ 90^{\circ} - \frac{\theta_n - \theta_{n-1}}{2} $$ になる。屈折のパターンに応じて$\theta_{n-1}$, $\theta_n$の値が変わってくるが、いずれの場合もこの式で$\alpha_n$を計算することができる。


次に、節点から左右に伸ばす腕の長さ、つまり線分$S_nP_1$または$S_nP_2$の長さ$l_n$を求める。棒の右側の$P_1$から反対側へ下ろした垂線の足を$H_n$とし、線分$P_1H_n$と$P_1P_2$のなす角を$\beta_n$とすると、図形的な関係から$\beta_n$は次のように求められる。

$$ \beta_n = 90^{\circ} - \alpha_n = \frac{\theta_n - \theta_{n-1}}{2} $$

線分$P_1H_n$の長さ、つまり棒の幅を$t$とすると、これはあらかじめ引数で与えられるもので既知であるから、$l_n$は下記のように求められる。

$$ l_n = \frac{1}{2} \left| \frac{t}{\cos \beta_n} \right| $$

四角形を構成する4頂点の座標の計算

四角形の形状と位置を決定するために、4頂点$P_1$, $P_2$, $P_3$, $P_4$の座標を求める。色々やり方はあると思うが、ここでは座標点の回転移動を利用して求めることにする。なお、四角形の両端の辺について、線分$P_1P_2$を始点側の辺、線分$P_3P_4$を終点側の辺と呼ぶことにする。

まずは始点側の$P_1$, $P_2$の座標を計算する。 回転移動の対象とする点を$Q_s(x_s, y_s)$とする。$Q_s$は線分$S_nS_{n+1}$上に点$S_n$からの距離が$l_n$となるよう取れば計算しやすい。この時$Q_s$の座標は下記の計算で求められる。

$$ \begin{cases} x_s = x_n + l_n \cos \theta_n\\ y_s = y_n + l_n \sin \theta_n \end{cases} $$

後は、節点$S_n$周りに$Q_s$を$+\alpha_n$回転して右側の$P_1$の座標が求まり、$P_1$を180°回転して$P_2$が求まる。

終点側の$P_3$,$P_4$の座標の計算も同様に計算する。 回転移動の対象とする点を$Q_e(x_e, y_e)$として、線分$S_nS_{n+1}$上に点$S_{n+1}$からの距離が$l_{n+1}$となるように取る。この時$Q_e$の座標は下記の計算で求められる。

$$ \begin{cases} x_e = x_{n+1} - l_{n+1} \cos \theta_n\\ y_e = y_{n+1} - l_{n+1} \sin \theta_n \end{cases} $$

この後はやはり節点$S_{n+1}$周りに$Q_s$を回転して$P_3$,$ P_4$を求めるのだが、四角形を構成する都合上$P_1$→$P_2$→$P_3$→$P_4$の順で決定していかなければならないので先に左側の$P_3$を計算する。 $P_3$の座標は$Q_e$を節点$S_{n+1}$周りに$Q_s$を(180° − $\alpha_{n+1}$)回転し、$P_4$は$P_3$を180°回転すれば求まる。

座標の回転移動について

ここで、行列を用いた座標の回転移動について少しまとめておく。
一般に、座標の回転移動を表す行列は下記のように表す。

$$ \begin{pmatrix} \cos \theta & -\sin \theta \\ \sin \theta & \cos \theta \\ \end{pmatrix} $$

ただし、この行列は原点周りの回転を表したものであり、原点以外の任意の点周りの回転を行う場合は平行移動も組み合わせる必要がある。
画像処理などでは、点の平行移動:Translationと線形変換(回転:Rotation、拡大縮小:Scaling、せん断:Skew)をまとめてアフィン変換と呼び、3x3の行列で表すのが一般的である。せん断については今回は省くが、平行移動量$T_x$, $T_y$、回転角度$\theta$、拡大率$S_x$, $S_y$を表す行列は下記のように表される。

$$ \begin{pmatrix} S_x\cos \theta & -\sin \theta & T_x \\ \sin \theta & S_y\cos \theta & T_y \\ 0 & 0 & 1 \end{pmatrix} $$

特に今回は平行移動と回転移動しか考慮しないので$S_x$, $S_y$は1である。
任意の点$A(x_a, y_a)$の周りに点$B(x, y)$を$\theta$回転して点$B’(x’, y’)$に移す、つまり$\overrightarrow{AB}$を$\overrightarrow{AB’}$に変換することを考えるとき、次のように3段階の変換に分けて適用する必要がある。


① $\overrightarrow{AB}$の始点$A$を原点と重なる点$A_0$まで平行移動する(点$B$の移動先を点$B_0$とする)

$$ \begin{pmatrix} 1 & 0 & -x_a \\ 0 & 1 & -y_a \\ 0 & 0 & 1 \end{pmatrix} $$

② $\overrightarrow{A_0B_0}$を$\theta$回転する(点$B_0$の移動先を点$B_0'$とする)

$$ \begin{pmatrix} \cos \theta & -\sin \theta & 0 \\ \sin \theta & \cos \theta & 0 \\ 0 & 0 & 1 \end{pmatrix} $$

③$\overrightarrow{A_0B_0’}$の始点$A_0$を元の点$A$に戻るように平行移動する

$$ \begin{pmatrix} 1 & 0 & x_a \\ 0 & 1 & y_a \\ 0 & 0 & 1 \end{pmatrix} $$

これらの変換行列を点Bに適用(変換行列はオリジナルの座標に順次左側から掛け合わせていく)して点B’に変換することを式で表すと、

$$ \begin{pmatrix} x'\\ y'\\ 1 \end{pmatrix}= \begin{pmatrix} 1 & 0 & x_a \\ 0 & 1 & y_a \\ 0 & 0 & 1 \end{pmatrix} \begin{pmatrix} \cos \theta & -\sin \theta & 0 \\ \sin \theta & \cos \theta & 0 \\ 0 & 0 & 1 \end{pmatrix} \begin{pmatrix} 1 & 0 & -x_a \\ 0 & 1 & -y_a \\ 0 & 0 & 1 \end{pmatrix} \begin{pmatrix} x\\ y \\ 1 \end{pmatrix} $$

これを展開して整理すると、

$$ \begin{cases} x' = x\cos \theta - y\sin \theta - x_a\cos \theta + y_a\sin \theta + x_a \\ y' = x\sin \theta + y\cos \theta - x_a\sin \theta - y_a\cos \theta + y_a \end{cases} $$

四角形の中心座標の計算

4頂点$P_1$, $P_2$, $P_3$, $P_4$の配列をBodies.fromVertices()に渡せば棒の部品となる四角形を生成できる。しかし、4頂点の正確な中心座標を計算してfromVertices()の引数に指定しないと、本来生成したい位置からずれた所に四角形が生成されてしまいきれいな棒状にならない場合がある。
そこで、4頂点の中心座標を求めるために、一旦4頂点の座標値を元にMatter.Verticesオブジェクトを作る。このVerticesオブジェクトをVertices.centre()の引数として渡せば一発で中心座標を返してくれる。
ただし、Vertices.centre()で中心を計算するためには$P_1$→$P_2$→$P_3$→$P_4$と辿った時にぐるっと一周する位置関係でなければならず、クロスする位置関係になっていると正しく中心を計算することができない。

このようなクロスする位置関係になってしまうのは下記の2パターンである。

$$ \begin{cases} \frac{\theta_n - \theta_{n-1}}{2} \lt -90^{\circ} \rightarrow \alpha_n \lt 0^{\circ} \cdots (1)\\ \frac{\theta_n - \theta_{n-1}}{2} \gt 90^{\circ} \rightarrow \alpha_n \gt 180^{\circ} \cdots (2) \end{cases} $$

(1)(2)になるのは、下の図のように隣り合う角$\theta_n, \theta_{n-1}$が共に鈍角かつ異符号の時である。

(1)の場合は$\alpha_n$に180°を足し、(2)の場合は$\alpha_n$から180°を引くという補正を行えばクロスすることはなくなる。

ソースコード

ここまでの考え方に示した内容をコードに書き起こした。

/**
 * 中心線に沿った棒状のBodyを生成する
 */
function createMeandering(sections, thickness, shouldClose = false, options ={}) {
  const parts = [];
  const sectionAngles = [];

  // パスを閉じる場合、先頭の節点を末尾に追加する
  if (shouldClose) {
    sections.push(sections[0]);
  }

  for (let j = 0; j < sections.length; j++) {
    if (j !== sections.length - 1) {
      // 2頂点の線分とx軸のなす角度を計算する
      sectionAngles.push(Math.atan2(sections[j + 1].y - sections[j].y, sections[j + 1].x - sections[j].x));
    }
  }

  if (shouldClose) {
    const beginningAngle = sectionAngles[0];
    const endAngle = sectionAngles[sectionAngles.length - 1];
    sectionAngles.unshift(endAngle);
    sectionAngles.push(beginningAngle);
  } else {
    sectionAngles.unshift(sectionAngles[0]);
    sectionAngles.push(sectionAngles[sectionAngles.length - 1]);
  }

  for (let j = 0; j < sections.length; j++) {
    if (j !== sections.length - 1) {
      const vertices = [];

      // 始点側の2頂点を生成する
      const armAngle1 = (sectionAngles[j + 1] - sectionAngles[j]) / 2;
      const armLength1 = Math.abs(thickness / (2 * Math.cos(armAngle1)));
      const startRadius = { x: sections[j].x + armLength1 * Math.cos(sectionAngles[j + 1]), y: sections[j].y + armLength1 * Math.sin(sectionAngles[j + 1]) };
      // 始点側の回転角度を計算する
      const rotAngle1 = calcRotAngle(-armAngle1);
      // 始点側の動径ベクトルを回転して2頂点の座標を計算する
      vertices.push(rotate(sections[j], startRadius, rotAngle1));
      vertices.push(rotate(sections[j], vertices[0], Math.PI));

      // 終点側の2頂点を生成する
      const armAngle2 = (sectionAngles[j + 2] - sectionAngles[j + 1]) / 2;
      const armLength2 = Math.abs(thickness / (2 * Math.cos(armAngle2)));
      const endRadius = { x: sections[j + 1].x - armLength2 * Math.cos(sectionAngles[j + 1]), y: sections[j + 1].y - armLength2 * Math.sin(sectionAngles[j + 1]) };
      // 終点側の回転角度を計算する
      const rotAngle2 = calcRotAngle(armAngle2);
      // 終点側の動径ベクトルを回転して2頂点の座標を計算する
      vertices.push(rotate(sections[j + 1], endRadius, rotAngle2));
      vertices.push(rotate(sections[j + 1], vertices[2], Math.PI));

      // 4頂点で作られるVerticesオブジェクトの中心座標を求める
      const tetragon = Vertices.create(vertices, Matter.Body);
      const center = Vertices.centre(tetragon);
      // 四角形を生成してpartsに追加する
      parts.push(Bodies.fromVertices(center.x, center.y, vertices, options));
    }
  }

  // 結合前のパーツの配列を返却する
  return parts;
}

/**
 * 任意の点の周りに座標点を回転する
 */
function rotate(origin, target, rotAngle) {
  // 回転移動対象の点を、原点周りに平行移動 → 原点周りの移動 → 元の任意点周りに戻す
  const x = target.x * Math.cos(rotAngle) - target.y * Math.sin(rotAngle) + origin.x - origin.x * Math.cos(rotAngle) + origin.y * Math.sin(rotAngle);
  const y = target.x * Math.sin(rotAngle) + target.y * Math.cos(rotAngle) + origin.y - origin.x * Math.sin(rotAngle) - origin.y * Math.cos(rotAngle);

  return { x, y };
}

/**
 * 回転角度を計算する
 */
function calcRotAngle(armAngle) {
  let rotAngle = Math.PI / 2 + armAngle;
  // 回転角度が0°より小、180°より大になる場合の補正
  if (armAngle > Math.PI / 2) {
    rotAngle -= Math.PI;
  } else if (armAngle < -Math.PI / 2) {
    rotAngle += Math.PI;
  }

  return rotAngle;
}
createMeandeling()
中心線の座標データの配列 [object]
棒の幅 number
パスを閉じるフラグ boolean
Matter.Bodyのオプション object

メインの処理を行う関数。
指定が必須な中心線の座標データの配列と棒の幅の他に、パスを閉じるフラグとしてtrueを指定することで棒の始点と終点を自動的に結んで閉じたような形状を作れるようにした。
なお、中心線の屈折角が0°になる区間を含む場合はうまく生成できないので注意が必要。

なお、最終的に1本の棒状とする場合はcreateMeandering()から返るBodyの配列を別途結合してやる必要がある。個々の四角形を結合してから返すようにすると、そのBodyに対してさらに別のBodyを結合した時にやはり凸包形に変換されてしまうのでこのような返却の形にした。

rotate()
回転の中心点の座標 object
回転対象の点の座標 object
回転角度(ラジアン) number

任意の点の周りに座標点を回転する関数。

calcRotateAngle()
節点から伸びる腕の角度(ラジアン) number

長方形の4頂点を計算するために中心線上の点を回転する角度を計算する関数。
回転角度が0°より小、180°より大になる場合の補正もここで行う。

使用例

新潟県

const mainland = [
  { x: 466.9, y: 59.2 }, { x: 445.5, y: 105.8 }, { x: 439.7, y: 159.7 }, { x: 397.1, y: 206.4 }, { x: 324.3, y: 245.8 },
  { x: 283.6, y: 327.7 }, { x: 225.5, y: 392 }, { x: 181.2, y: 428 }, { x: 59.7, y: 477.1 }, { x: 77.8, y: 494.1 },
  { x: 87.8, y: 534.1 }, { x: 118.8, y: 498.8 }, { x: 142.1, y: 503 }, { x: 146, y: 522.7 }, { x: 199.3, y: 513.4 },
  { x: 225.5, y: 471.9 }, { x: 257.4, y: 466.7 }, { x: 267.1, y: 497.8 }, { x: 283.6, y: 510.3 }, { x: 286.5, y: 544.5 },
  { x: 316.6, y: 536.2 }, { x: 335, y: 517.5 }, { x: 349.6, y: 481.2 }, { x: 372.8, y: 460.5 }, { x: 392.2, y: 484.3 },
  { x: 405.8, y: 483.3 }, { x: 400, y: 422.1 }, { x: 390.3, y: 408.6 }, { x: 398.4, y: 356.5 }, { x: 435.8, y: 349.6 },
  { x: 476.6, y: 335 }, { x: 468.8, y: 301.8 }, { x: 507.6, y: 253.1 }, { x: 486.2, y: 232.3 }, { x: 499.8, y: 157.7 },
  { x: 527.9, y: 147.3 }, { x: 538.6, y: 127.6 }, { x: 506.6, y: 106.9 }, { x: 501.8, y: 71.6 }
];

const sado = [
  { x: 260.3, y: 184 }, { x: 235.9, y: 236.9 }, { x: 185.9, y: 259.2 }, { x: 207.9, y: 213.3 }, { x: 198.4, y: 205.7 },
  { x: 188.3, y: 214 }, { x: 192.5, y: 185.9 }, { x: 237.7, y: 122.9 }, { x: 255, y: 119.7 }, { x: 241.9, y: 159.8 },
  { x: 234.7, y: 187.2 }
];

const options = {
  render: {
    fillStyle: '#6b8e23'
  }
}

const mainlandParts = createMeandering(mainland, 10, true, options);
const sadoParts = createMeandering(sado, 10, true, options);
const niigata = Body.create({ parts: [...mainlandParts, ...sadoParts], isStatic: false });

Composite.add(engine.world, niigata);

// 床
Composite.add(engine.world, Bodies.rectangle(400, 585, 800, 30, { isStatic: true }));


図形の入れ子

// 正多角形の節点座標を計算する関数
function polygonSections(center, sides, radius) {
  const sections = [
    { x: center.x, y: center.y - radius }
  ];
  const rotAngle = 2 * Math.PI / sides;
  for (let i = 0; i < sides - 1; i++) {
    sections.push(rotate(center, sections[i], rotAngle));
  }

  return sections;
}

// 正二十角形
const icosagonSections = polygonSections({ x: 300, y: 200 }, 20, 200); 
// 正六角形
const hexagonSections = polygonSections({ x: 300, y: 200 }, 6, 150); 
// 星形
const starSections = [
  { x: 300, y: 100 },{ x: 358.78, y: 280.9 },{ x: 204.89, y: 169.1 },{ x: 395.11, y: 169.1 },{ x: 241.22, y: 280.9 }
];
// 床
const floorSections = [
  { x: 0, y: 400 },{ x: 100, y: 440 },{ x: 200, y: 470 },{ x: 300, y: 490 },{ x: 400, y: 500 },
  { x: 500, y: 490 },{ x: 600, y: 470 },{ x: 700, y: 440 },{ x: 800, y: 400 },
];

const options = {
  inertia: 1e7,
  density: 1,
}

const icosagon = createMeandering(icosagonSections, 30, true, { ...options, render: { fillStyle: '#ffd700' }});
const hexagon = createMeandering(hexagonSections, 15, true, { ...options, render: { fillStyle: '#0000ff' } });
const star = createMeandering(starSections, 10, true, { ...options, render: { fillStyle: '#ff0000' } });
const floor = createMeandering(floorSections, 30, false, { ...options, render: { fillStyle: '#4d4d4d' } });

Composite.add(engine.world, Body.create({ parts: icosagon }));
Composite.add(engine.world, Body.create({ parts: hexagon }));
Composite.add(engine.world, Body.create({ parts: star }));
Composite.add(engine.world, Body.create({ parts: floor, isStatic: true }));

確率計算だけでカタールW杯優勝国を予想する

先日、サッカーのカタールW杯に出場する日本代表のメンバーが発表され、いよいよ開催の気運が高まってきた。ということでW杯に絡めて何かやりたいなと思い、今大会でどこが優勝しそうかを予想するシミュレーションをやってみた。予想とは言っても真面目にチーム毎の戦力分析を行うとかいったものではなく、単に各チームの勝利確率を計算してそれだけを根拠に大会のレギュレーションに沿って対戦を進めていき、どこが優勝しそうかをシミュレーションしてみるというお遊びのものです。
シミュレーションの肝となる勝利確率の計算はイロレーティング(Elo Rating)の値をベースにした。



イロレーティングから勝利確率を計算する

イロレーティングとは

対戦型の競技において相対評価によって双方の実力を表すために使われる指標(レート)である。元々はチェスの強さを評価するために考案されたものだそうだ。
ある競技において参加プレイヤーの平均のレートを主に1500と定め、その値の大小により実力を評価することができる。試合が行われる度に2プレイヤー(チーム)のレート差と結果に応じて双方のレートが更新される。
サッカー競技におけるイロレーティングは、各国の国際Aマッチの結果を用いてその試合の重み(最高値がW杯決勝の60、最低値が親善試合の20)、ホームチームのアドバンテージ、得失点差を考慮して計算されている。いわゆる強豪国であればレートは2000を超えているところが多い。
各国の最新のレートは、「World Football Elo Ratings」というサービスで確認することができる。

www.eloratings.net

なお、サッカーの世界ランキングとしてFIFAランキングがあるが、2018年に計算方法が改訂されイロレーティングをベースに計算されるようになった。これにより、南米と欧州びいきの不公平な指標でしかなかった「大陸連盟間の強さ」は考慮されなくなった。

勝利確率の計算

対戦する2チームのレート差から勝利確率を計算することができる。

チームA、Bの勝利確率を$P_A$, $P_B$、レートを$R_A$, $R_B$とすると、

$$ P_A = \frac{1}{10^{\frac{R_B - R_A}{400}}+1} $$

で計算される。
チームBの勝利確率は余事象の確率から$P_B = 1 - P_A$である。

World Football Elo Ratings - About

引き分けについて

イロレーティングの対象は引き分けのない競技であることを前提としており、サッカーなどのように引き分けがあり得る競技の場合、引き分けは0.5勝0.5敗という扱いでレートが計算されている。このため、対戦両チームのレートから引き分けの確率だけを逆算して求めるといったことができない。したがって、なんらかの方法で引き分けの確率を別に求め(後述)、あらかじめレート差から計算しておいた両チームの勝利確率に対して引き分けを考慮した補正を行う必要がある。
引き分け確率を$P_d$として、$P_d$を考慮した時のチームA、Bの勝利確率$P_A'$, $P_B'$を下記のように計算した。(下の図は$P_A$ = 65%, $P_B$ = 35%, $P_d$ = 20%とした場合の例)

$$ \begin{align} P_A'& = P_A - \frac{P_d}{2}\\ P_B'& = P_B - \frac{P_d}{2} = 1-P_A - \frac{P_d}{2} \end{align} $$

なお、「$\frac{P_d}{2}$ ≧ 弱い方の勝利確率」となってしまう場合、「$P_d$ = 弱い方の勝利確率」とした。

シミュレーションの条件

実際のW杯における対戦の組み合わせに沿って、対戦国同士のレート差から算出した勝利確率・引き分け確率を用いて抽選を行い結果を決定していく。 本物の試合であれば実力差以外にも対戦の順序や選手の士気、コンディションなどが対戦結果に影響してくると思われるが、このシミュレーションでは対戦結果を左右するものはレート差から計算される確率だけである。また、他チームの対戦結果などの外的要因の影響は一切考慮しない独立なものとする。
また、途中の対戦結果を受けて各チームのレートが変動することは考えず、シミュレーション開始時点のレートを最後まで使う。

グループリーグの考え方

  • 勝利確率に基づき勝敗を決定する
  • 引き分けを考慮する
  • 勝:3、分:1、負:0の勝ち点を計上していき各グループの成績上位2チームが決勝トーナメントへ進む


実際のレギュレーションにおいて、グループリーグ内の順位を決定する項目の優先度は下記の通りである。

1. 勝点
2. 得失点差
3. 総得点
4. 直接対決の結果
5. フェアプレーポイント・反則ポイント
6. くじ引き

しかし、このシミュレーションはレート差しか見ておらず、チームごとの得失点などは考慮できないので下記の通りにしている。

1. 勝点
2. 直接対決の結果
3. くじ引き


なお、同グループ内で勝点が並ぶパターンは以下の3通りが考えられる。

(1) 2チームが同じ勝点で並ぶ
➡︎ 直接対決の結果で判断し、引き分けだったらくじ引き
(2) 3チームが同じ勝点で並ぶ
➡︎ 残りの1チームは全勝or全分けor全敗になっており、当該3チーム同士の直接対決の結果は全分けor三すくみ状態なのでくじ引き
(3) 全チーム同じ勝点になる
➡︎ リーグ戦6試合が全て引き分けになるか全チーム1勝1分1敗であり、直接対決の結果が同じなのでくじ引き

トーナメント戦の考え方

  • 勝率確率に基づき勝敗を決定する
  • 引き分けを考慮する
  • 引き分けの場合は勝敗50%ずつの抽選(PK戦のつもり)で決着を付ける

A〜Hの各グループリーグを1位、2位通過した16チームをトーナメントに割り振る方法も実際のレギュレーションに従う。


引き分け確率の求め方

前述の通りイロレーティングでは引き分けの確率を直接算出することができないので、なんらかの方法で別途求めなければならない。
そこで、実際に過去に行われたW杯10大会分(1982年スペイン大会〜2018年ロシア大会)のデータを利用し、対戦チームのレート差と引き分けの試合数の傾向から引き分け確率を求める式を推定した。幸いWorld Football Elo Ratingsには現在のレートだけでなく、過去に世界中で行われた国際親善試合や公式戦の結果も膨大に記録されているので非常にありがたい。
まず、過去10大会分の全試合のレート差の絶対値を幅50ずつの階級に分け、各階級毎の引き分けの割合(各階級の引き分けの試合数 / 各階級の総試合数)を算出した。さらに、各階級の中央値と引き分けの割合の分布から最小二乗法により近似直線の式を求め、レート差$\Delta R$から引き分け確率$R_d$を計算するための式とした。

$$ R_d = -0.00045\left| \Delta R \right| + 0.316 $$

なお、$450 < \left| \Delta R \right| \leq 500$の階級の引き分けの割合が飛び上がってしまっているが、この階級に含まれる試合数は他と比べて少ないことを考慮して近似式を計算する際には除外した。
「レート差が小さい、つまり力が拮抗しているほど引き分けになりやすい」という感覚的にもなんとなく良さそうだ。
ちなみに、この式は単純な一次関数なのでレート差が702以上の場合は引き分け確率が0以下になってしまうが、W杯出場国同士ではまずあり得ないレート差だと思われるのでとりあえずはヨシ。

シミュレーションの実行

各グループリーグの対戦〜トーナメントの決勝戦までを1回の試行として、全部で1000回繰り返した結果を集計する。
大会のシミュレーションを行うコードはPython 3.10.5で作成・実行した。
ソースコードは下記のGithubで公開している。
world_cup_winner_simulator


シミュレーションに用いた参加32ヶ国のレートの一覧は下記になる。括弧内はイロレーティングでの順位 である。※2022年11月1日時点

A 🇶🇦カタール 1664(50位)
🇪🇨エクアドル 1840(18位)
🇸🇳セネガル 1687(43位)
🇳🇱オランダ 2040(4位)
B 🏴󠁧󠁢󠁥󠁮󠁧󠁿イングランド 1920(14位)
🇮🇷イラン 1817(21位)
🇺🇸アメリカ 1798(24位)
🏴󠁧󠁢󠁷󠁬󠁳󠁿ウェールズ 1790(26位)
C 🇦🇷アルゼンチン 2141(2位)
🇸🇦サウジアラビア 1632(56位)
🇲🇽メキシコ 1813(22位)
🇵🇱ポーランド 1809(23位)
D 🇫🇷フランス 2005(6位)
🇦🇺オーストラリア 1719(39位)
🇩🇰デンマーク 1971(9位)
🇹🇳チュニジア 1687(43位)
E 🇪🇸スペイン 2045(3位)
🇨🇷コスタリカ 1736(35位)
🇩🇪ドイツ 1960(10位)
🇯🇵日本 1798(24位)
F 🇧🇪ベルギー 2025(5位)
🇨🇦カナダ 1770(29位)
🇲🇦モロッコ 1753(32位)
🇭🇷クロアチア 1922(13位)
G 🇧🇷ブラジル 2169(1位)
🇷🇸セルビア 1892(16位)
🇨🇭スイス 1929(12位)
🇨🇲カメルーン 1613(61位)
H 🇵🇹ポルトガル 2004(7位)
🇬🇭ガーナ 1540(74位)
🇺🇾ウルグアイ 1936(11位)
🇰🇷韓国 1783(28位)

結果

ローカルPC上でカタールW杯を1000回開催するのに要した時間は13.24秒だった。
なお、今回のシミュレーションで試行1回当たりの引き分け試合数の平均は14.7試合だった。実際の過去10大会における引き分け試合数の平均が1大会当たり14.6試合であることを考えるとかなり良い線を行っているのではないだろうか。

結果の集計方法

各国毎に最終到達ランクが「グループリーグ敗退、ベスト16、ベスト8、ベスト4、準優勝、優勝」になった回数をそれぞれカウントして度数分布で表す。
さらに、「グループリーグ敗退→0ポイント、ベスト16→1ポイント、ベスト8→2ポイント、ベスト4→3ポイント、準優勝→4ポイント、優勝→5ポイント」としてポイントの期待値(ポイント* 各ランクの到達回数 / 全試行回数)を計算する。期待値が5に近いほど今大会で好成績を残す可能性が高く、(期待値の意味から言えば厳密には異なるが)優勝に近い国であるといえる。

結果発表

では期待値の昇順で32位から発表していこう。


◆ 第32位 ◆

🇬🇭 ガーナ (期待値:0.030 / レート:1540)
リーグ 敗退 972
ベスト16 26
ベスト8 2
ベスト4 0
準優勝 0
優勝 0

◆ 第31位 ◆

🇨🇲 カメルーン (期待値:0.039 / レート:1613)
リーグ 敗退 966
ベスト16 30
ベスト8 3
ベスト4 1
準優勝 0
優勝 0

◆ 第30位 ◆

🇹🇳 チュニジア (期待値:0.119 / レート:1687)
リーグ 敗退 895
ベスト16 93
ベスト8 10
ベスト4 2
準優勝 0
優勝 0

◆ 第29位 ◆

🇸🇦 サウジアラビア (期待値:0.144 / レート:1632)
リーグ 敗退 880
ベスト16 100
ベスト8 16
ベスト4 4
準優勝 0
優勝 0

◆ 第28位 ◆

🇦🇺 オーストラリア (期待値:0.163 / レート:1719)
リーグ 敗退 858
ベスト16 124
ベスト8 15
ベスト4 3
準優勝 0
優勝 0

◆ 第27位 ◆

🇨🇷 コスタリカ (期待値:0.184 / レート:1736)
リーグ 敗退 860
ベスト16 108
ベスト8 22
ベスト4 8
準優勝 2
優勝 0

◆ 第26位 ◆

🇶🇦 カタール (期待値:0.218 / レート:1664)
リーグ 敗退 834
ベスト16 123
ベスト8 34
ベスト4 9
準優勝 0
優勝 0

◆ 第25位 ◆

🇲🇦 モロッコ (期待値:0.230 / レート:1753)
リーグ 敗退 818
ベスト16 142
ベスト8 33
ベスト4 6
準優勝 1
優勝 0

◆ 第24位 ◆

🇨🇦 カナダ (期待値:0.321 / レート:1770)
リーグ 敗退 757
ベスト16 179
ベスト8 55
ベスト4 5
準優勝 3
優勝 1

◆ 第23位 ◆

🇸🇳 セネガル (期待値:0.329 / レート:1687)
リーグ 敗退 765
ベスト16 156
ベスト8 67
ベスト4 10
準優勝 1
優勝 1

◆ 第22位 ◆

🇰🇷 韓国 (期待値:0.353 / レート:1783)
リーグ 敗退 714
ベスト16 236
ベスト8 36
ベスト4 11
準優勝 3
優勝 0

◆ 第21位 ◆

🇯🇵 日本 (期待値:0.354 / レート:1798)
リーグ 敗退 731
ベスト16 197
ベスト8 59
ベスト4 13
準優勝 0
優勝 0

◆ 第20位 ◆

🇺🇸 アメリカ (期待値:0.532 / レート:1798)
リーグ 敗退 631
ベスト16 247
ベスト8 91
ベスト4 23
準優勝 6
優勝 2

◆ 第19位 ◆

🏴󠁧󠁢󠁷󠁬󠁳󠁿 ウェールズ (期待値:0.580 / レート:1790)
リーグ 敗退 591
ベスト16 277
ベスト8 98
ベスト4 29
準優勝 5
優勝 0

◆ 第18位 ◆

🇵🇱 ポーランド (期待値:0.636 / レート:1809)
リーグ 敗退 566
ベスト16 305
ベスト8 72
ベスト4 43
準優勝 12
優勝 2

◆ 第17位 ◆

🇮🇷 イラン (期待値:0.670 / レート:1817)
リーグ 敗退 539
ベスト16 300
ベスト8 121
ベスト4 33
準優勝 6
優勝 1

◆ 第16位 ◆

🇲🇽 メキシコ (期待値:0.671 / レート:1813)
リーグ 敗退 532
ベスト16 334
ベスト8 81
ベスト4 41
準優勝 8
優勝 4

◆ 第15位 ◆

🇷🇸 セルビア (期待値:0.731 / レート:1892)
リーグ 敗退 570
ベスト16 246
ベスト8 108
ベスト4 44
準優勝 23
優勝 9

◆ 第14位 ◆

🇨🇭 スイス (期待値:1.028 / レート:1929)
リーグ 敗退 420
ベスト16 315
ベスト8 151
ベスト4 61
準優勝 37
優勝 16

◆ 第13位 ◆

🇪🇨 エクアドル (期待値:1.126 / レート:1840)
リーグ 敗退 356
ベスト16 306
ベスト8 240
ベスト4 60
準優勝 30
優勝 8

◆ 第12位 ◆

🇭🇷 クロアチア (期待値:1.181 / レート:1922)
リーグ 敗退 302
ベスト16 382
ベスト8 207
ベスト4 64
準優勝 32
優勝 13

◆ 第11位 ◆

🇩🇪 ドイツ (期待値:1.244 / レート:1960)
リーグ 敗退 304
ベスト16 359
ベスト8 208
ベスト4 67
準優勝 42
優勝 20

◆ 第10位 ◆

🇺🇾 ウルグアイ (期待値:1.293 / レート:1936)
リーグ 敗退 197
ベスト16 512
ベスト8 163
ベスト4 71
準優勝 43
優勝 14

◆ 第9位 ◆

🏴󠁧󠁢󠁥󠁮󠁧󠁿 イングランド (期待値:1.435 / レート:1920)
リーグ 敗退 239
ベスト16 352
ベスト8 236
ベスト4 104
準優勝 46
優勝 23

◆ 第8位 ◆

🇩🇰 デンマーク (期待値:1.704 / レート:1971)
リーグ 敗退 146
ベスト16 411
ベスト8 182
ベスト4 150
準優勝 76
優勝 35

◆ 第7位 ◆

🇵🇹 ポルトガル (期待値:1.724 / レート:2004)
リーグ 敗退 117
ベスト16 453
ベスト8 198
ベスト4 110
準優勝 65
優勝 57

◆ 第6位 ◆

🇧🇪 ベルギー (期待値:1.946 / レート:2025)
リーグ 敗退 123
ベスト16 338
ベスト8 240
ベスト4 131
準優勝 105
優勝 63

◆ 第5位 ◆

🇫🇷 フランス (期待値:1.960 / レート:2005)
リーグ 敗退 101
ベスト16 393
ベスト8 181
ベスト4 161
準優勝 98
優勝 66

◆ 第4位 ◆

🇪🇸 スペイン (期待値:2.018 / レート:2045)
リーグ 敗退 105
ベスト16 295
ベスト8 313
ベスト4 132
準優勝 74
優勝 81

◆ 第3位 ◆

🇳🇱 オランダ (期待値:2.244 / レート:2040)
リーグ 敗退 45
ベスト16 239
ベスト8 390
ベスト4 166
準優勝 73
優勝 87

◆ 第2位 ◆

🇦🇷 アルゼンチン (期待値:2.845 / レート:2141)
リーグ 敗退 22
ベスト16 240
ベスト8 166
ベスト4 245
準優勝 97
優勝 230

◆ 第1位 ◆

🇧🇷 ブラジル (期待値:2.948 / レート:2169)
リーグ 敗退 44
ベスト16 182
ベスト8 202
ベスト4 193
準優勝 112
優勝 267


ということで、優勝に最も近い国はブラジルでした!!
🎉おめでとうございます🥳

おわりに

成績の期待値で並べてみた結果、1〜8位までの上位集団については多少の前後はあるものの概ねイロレーティング 通りの並び順になった。期待値が飛び抜けている1位ブラジル、2位アルゼンチンには是非とも20年ぶりに欧州以外の国として優勝を掴み取ってもらいたい。
ところで、我が日本代表の期待値は残念ながら0.354(グループリーグ 敗退濃厚)という結果となり、改めて厳しいグループであることを認識させられてしまった…。
しかし、これを言ったらこの記事の意味を根底から覆してしまうようなものだが、数字だけで試合結果が決まってしまうのであればスポーツ観戦なんて面白くもなんともなくなってしまう。スポーツくじだって成立しないでしょう。
なので、日本代表には是非ともレーティングの順位や前評判を払拭するような大番狂わせを期待したい。


12/20追記
開幕からあっという間に日程が進んでいき、遂にアルゼンチンが36年ぶりの優勝に輝いてW杯の幕が閉じた。シミュレーションで優勝と予想したブラジルはベスト8で終わってしまったものの、見事に20年ぶりに南米勢がW杯を手にすることができました!!!
また、大変厳しいだろうという予想に反して日本がドイツとスペインに逆転勝ちを収めグループを1位通過した快進撃を筆頭に、確率の数字だけでは到底論じることができないような大波乱や熱戦が多く、夢のような1ヶ月でした。

matter.jsの基本的な機能を使ったサンプル集

matter.jsはWEBブラウザ上で2次元の物理演算と描画を行うことができるjavascriptのライブラリである。

brm.io

公式リファレンスが整備されているので各モジュールに含まれるプロパティやメソッドなどを確認することができるが、具体的にどのように使っていけばいいのか分かりにくいものも結構ある。
そこで、いくつか簡単なサンプルを自分で作ってみながらmatter.jsの基本的な機能に触れつつ、具体的な使い方や初見で分かりにくかった点などについて調べメモ程度にまとめた。

共通設定

共通のテンプレートファイル。この後出てくる各サンプルのjsファイルを1つずつscriptタグで読み込んでいくことになる。
<div id="canvas-area"></div>の内部に物理演算用canvasが挿入される。

<html lang="ja">

<head>
  <meta charset="utf-8">
  <title>Matter.js Sample</title>
</head>

<body>
  <div id="canvas-area"></div>
  <script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
  <script src="./matter.js"></script>
  <!-- 以下、各例のjsファイルをscriptタグで読み込む -->
  <script src="./basicBodyCreation.js"></script>
</html>


次は後述の各サンプルの共通設定にあたる部分で、各jsファイルの先頭に記述しておくものである。

// 使用モジュール
const Engine = Matter.Engine,
  Render = Matter.Render,
  Runner = Matter.Runner,
    Body = Matter.Body,
  Bodies = Matter.Bodies,
  Composite = Matter.Composite,
  Composites = Matter.Composites,
    Vector = Matter.Vector,
  Constraint = Matter.Constraint,
  MouseConstraint = Matter.MouseConstraint,
  Mouse = Matter.Mouse,
  Events = Matter.Events;

// エンジンの生成
const engine = Engine.create();

// 物理演算canvasを挿入する要素
const canvas = $('#canvas-area')[0];

// レンダリングの設定
const render = Render.create({
  element: canvas,
  engine: engine,
  options: {
    width: 800,
    height: 600,
  }
});

// マウス、マウス制約を生成
const mouse = Mouse.create(canvas);
const mouseConstraint = MouseConstraint.create(engine, {
  mouse: mouse,
  constraint: {
    render: {
      visible: false
    }
  }
})

Composite.add(engine.world, mouseConstraint)
render.mouse = mouse

// レンダリングを実行
Render.run(render);

// エンジンを実行
Runner.run(engine);

/**
 * 以下、各例毎に処理を記述する
 */


共通設定で行っていることの詳細については、以前投稿した記事に書いている。 mmsrtech.com

サンプル色々

ここで掲載しているjavascriptのコードは前述の共通設定を除いた部分のみ記述している。

物体の生成と配置

まずは基本中の基本で、オブジェクト(静止、可動)を生成するだけ。


基本形(円、長方形、正多角形)の剛体オブジェクト(Bodyオブジェクト)を生成するときはBodiesモジュールのメソッドを使う。BodiesモジュールにはBodyオブジェクトを生成するための各種メソッドが含まれている。

<円の生成>

Bodies.circle x, y, 半径, オプション

<長方形の生成>

Bodies.rectangle x, y, 幅, 高さ, オプション

<正多角形の生成>

Bodies.polygon x, y, 辺の数, 半径, オプション

x, yはオブジェクトの中心の座標値を指定する。
オプション(任意の引数)は生成されるオブジェクトの各種プロパティを予め設定しておくものであり、そのオブジェクトの物理量(質量、摩擦係数、慣性モーメント、傾きなど)のようなものになると考えて良い。なお、プロパティの更新はBodyモジュールのメソッドで行うことができるので、生成時にオプションで指定しなくても後からプロパティを新規設定・更新することもできる。
設定可能なオプションはMatter.BodyモジュールのProperties / Optionsで確認できる。

// 静止オブジェクト(空中の床と地面)【①】
const floor1 = Bodies.rectangle(400, 200, 500, 30, { isStatic: true });
const floor2 = Bodies.rectangle(150, 350, 300, 30, { angle: Math.PI / 6, isStatic: true });
const floor3 = Bodies.rectangle(650, 350, 300, 30, { angle: -Math.PI / 6, isStatic: true });
const ground = Bodies.rectangle(400, 585, 800, 30, { isStatic: true });

// 可動オブジェクト(正方形と円)【②】
const square = Bodies.rectangle(floor1.bounds.min.x, floor1.bounds.max.y - 50, 50, 50, { friction: 0.001 });
const circle = Bodies.circle(floor1.bounds.max.x, floor1.bounds.max.y - 50, 25, { friction: 0.01 });

// オブジェクトの追加【③】
Composite.add(engine.world, [floor1, floor2, floor3, ground, square, circle]);


【①】静止オブジェクトの生成
オプションでisStatic: trueを指定すれば静止オブジェクト(自ら動くこともドラッグで移動させることもできない)となる。
オプションでangleに角度(ラジアン)を設定すると、その分回転した状態でオブジェクトが生成される。


【②】可動オブジェクトの生成
可動オブジェクトの生成は特別なことは何もない。isStaticはデフォルトがfalseなので、特に何もオプションを指定しなければ可動になる。


【③】オブジェクトの追加

Composite.add 投入先コンポジット, オブジェクト

オブジェクトを生成するだけではcanvasに描画されないので、Compositeモジュールのaddメソッドで追加する必要がある。
第1引数はオブジェクトを投入する先のコンポジットを指定するものであり、基本的にengine.worldを指定しておけばいい。
第2引数は投入するオブジェクトを指定するものだが、複数のオブジェクトを追加する場合は配列にして渡す必要がある。オブジェクト1個だけなら配列でなくて良い。

※コンポジットの詳細については後述

なお、旧版のmatter.jsではオブジェクト等の追加を行うモジュールとしてMatter.Worldがあったが、最新版ではMatter.Compositeに置き換えられたようである。

オブジェクトのドラッグ移動

canvasに投入したオブジェクトをドラッグ移動できるようにする。また、マウスのx, y座標とドラッグしているオブジェクトの種類(RectangleかCircleかPolygonか)をcanvas外に表示している。


共通設定のところでMouseConstraintオブジェクト(マウス制約)を生成してComposite.add()で追加した時点でオブジェクトをドラッグ移動できるようになっているのでそれについては特筆することはない。

$('body').append('<p class="coordinate"></p>');
$('body').append('<p class="target"></p>');

// 静止オブジェクト(空中の床と地面)
const floor = Bodies.rectangle(400, 200, 500, 30, { isStatic: true });
const ground = Bodies.rectangle(400, 585, 800, 30, { isStatic: true });

// 可動オブジェクト(正方形、円、三角形)
const square = Bodies.rectangle(floor.bounds.min.x + 50, floor.bounds.max.y - 50, 50, 50);
const circle = Bodies.circle(floor.position.x, floor.bounds.max.y - 50, 50);
const triangle = Bodies.polygon(floor.bounds.max.x - 50, floor.bounds.max.y - 50, 3, 50);

Composite.add(engine.world, [floor, ground, square, circle, triangle]);

// mousemoveイベントを設定してマウスの座標、ドラッグ対象を表示する【④】
Events.on(mouseConstraint, 'mousemove', e => {
  $('p.coordinate').text(`X: ${e.mouse.position.x} Y: ${e.mouse.position.y}`);
  const label = mouseConstraint.body ? mouseConstraint.body.label : '';
  $('p.target').text(`Dragging ${label}`);
});


【④】マウスイベントの設定

Events.on イベントバインディングの対象, イベント名, コールバック関数

Eventsモジュールを利用してMouseConstraintオブジェクトに各種マウスイベントを設定することができる。

mousedown マウスボタンが押された時に発生する
mousemove マウスが移動した時に発生する
mouseup マウスボタンが離された時に発生する
startdrag オブジェクトのドラッグを開始したときに発生する
enddrag オブジェクトのドラッグを終了したときに発生する

コールバック関数のイベントオブジェクトにはmouseが含まれているので、mouse.positionでマウスポインタの座標を参照することができる。
また、mouseConstraint.bodyでドラッグ中のオブジェクトを参照することができる。

オブジェクトの拘束

2つの物体同士、または物体と空間内の点を接続してそれらを拘束する。

// 静止オブジェクト(右上の長方形)
const rectangle = Bodies.rectangle(600, 70, 50, 30, { isStatic: true });

// 拘束されない正方形
const square = Bodies.rectangle(400, 0, 50, 50);

// 拘束される物体(左の正六角形、右の円2つ、中央の正方形)
const boundHex = Bodies.polygon(200, 300, 6, 50);
const boundCircle1 = Bodies.circle(700, 220, 20);
const boundCircle2 = Bodies.circle(700, 370, 20);
const boundSquare = Bodies.rectangle(400, 450, 100, 100);

Composite.add(engine.world, [rectangle, square, boundHex, boundCircle1, boundCircle2, boundSquare]);

// 矩形と2個の円の直列接続(各オブジェクトの中心の接続)【⑤】
const constraint1Upper = Constraint.create({
  bodyA: rectangle,
  bodyB: boundCircle1,
  stiffness: 1
});
const constraint1Lower = Constraint.create({
  bodyA: boundCircle1,
  bodyB: boundCircle2,
  stiffness: 1
});
Composite.add(engine.world, [constraint1Upper, constraint1Lower]);

// 正方形と2本のバネ接続(正方形の表面と空間点の接続)【⑥】
const constraint2Left = Constraint.create({
  pointA: { x: 50, y: 400 },
  bodyB: boundSquare,
  pointB: { x: -50, y: 0 },
  stiffness: 0.1
});
const constraint2Right = Constraint.create({
  pointA: { x: 750, y: 400 },
  bodyB: boundSquare,
  pointB: { x: 50, y: 0 },
  stiffness: 0.1
});
Composite.add(engine.world, [constraint2Left, constraint2Right]);

// 正六角形のピン接続【⑥】
const constraint3 = Constraint.create({
  pointA: { x: 200, y: 200 },
  bodyB: boundHex,
  length: 0,
  stiffness: 0.5,
});
Composite.add(engine.world, constraint3);


【⑤】オブジェクト同士の接続

Constraint.create オプション

create()メソッドに渡すオプションで様々な拘束条件を設定する。
接続する2つのBodyオブジェクトをbodyAbodyBに指定すると、両オブジェクトはそれぞれの中心を接続点として拘束される。接続点の位置をずらしたい場合は中心を原点(0, 0)とした相対的な座標をpointApointBに指定する。
stiffnessは拘束の硬さを表すプロパティ、lengthは「目標静止長」という拘束対象のオブジェクトの初期位置から自動計算されるプロパティであり、stiffnesslengthの値によって拘束の種類を変えることができる。

拘束の種類 条件 特徴
spring 0 < stiffness ≦ 0.1 伸び縮みのあるバネのような拘束
line 0.9 ≦ stiffness ≦ 1 伸び縮みのない棒のような拘束
pin length = 0かつ0.1 < stiffness 長さのない1点での拘束

Composite.add()で拘束対象のオブジェクトと拘束条件をそれぞれ追加すると拘束が有効になる。


【⑥】オブジェクトと空間の接続
bodyAまたはbodyBの一方だけを指定してもう一方はポイントを指定すると、そのポイントに対応した空間内の1点とオブジェクトが接続される。

複数の物体の結合

複数の長方形を結合して箱型ブランコのようなものを作る。


予め┗┛の形になるように位置を調整した長方形を3つ生成し、左右に立つ長方形を少し傾ける。

// 静止オブジェクト(地面)
const ground = Bodies.rectangle(400, 585, 800, 30, { isStatic: true });

Composite.add(engine.world, [ground]);

// 可動オブジェクト(ボール)
const ball = Bodies.circle(400, 500, 20, { restitution: 0.5 });

// 可動の複合オブジェクト
const bottom = Bodies.rectangle(400, 550, 200, 20);
const leftSide = Bodies.rectangle(310, 510, 20, 100);
const rightSide = Bodies.rectangle(490, 510, 20, 100);
// 側面の回転、位置の調整【⑦】
Body.setAngle(leftSide, -Math.PI / 6);
Body.translate(leftSide, { x: -100 / 2 * Math.tan(Math.PI / 6), y: 0 });
Body.setAngle(rightSide, Math.PI / 6);
Body.translate(rightSide, { x: 100 / 2 * Math.tan(Math.PI / 6), y: 0 });

// 部品を結合したオブジェクトの生成【⑧】
const compoundBody = Body.create({
  parts: [bottom, leftSide, rightSide],
  inertia: Infinity,
  frictionAir: 0.001
});

const constraint1 = Constraint.create({
  bodyA: compoundBody,
  pointA: { x: -100, y: 30 },
  pointB: { x: 300, y: 330 },
  stiffness: 1
});

const constraint2 = Constraint.create({
  bodyA: compoundBody,
  pointA: { x: 100, y: 30 },
  pointB: { x: 500, y: 330 },
  stiffness: 1
});

Composite.add(engine.world, [ball, compoundBody, constraint1, constraint2]);


【⑦】部品の回転、位置の調整

Body.setAngle オブジェクト, 回転角度

オブジェクトのangleを設定・更新するメソッドで、これも回転角度をラジアンで指定する。 オブジェクト生成時にangleを指定するのと結果的には同じである。


Body.translation オブジェクト, 変位

対象のオブジェクトを変位分だけ平行移動する。 変位はベクトル(現在位置のx, y座標からの相対的な移動距離)で指定する。
オブジェクトの位置を変更するメソッドとしてBody.setPosition()もあるが、移動後の位置の絶対座標を指定しないといけないので、今回のようにずらしたい距離(変位)から設定したい場合はtranslation()の方が扱いやすい。


【⑧】部品を結合したオブジェクトの生成

Body.create オプション

partsプロパティに部品となるオブジェクトの配列を指定すると、それらが結合された複合体のオブジェクトを生成することができる。個々の部品の位置や角度調整が必要になるものの、この方法であれば凹面や空洞などを含む複雑な形状も作ることができる。
Composite.add()には部品全てを指定する必要はなく、生成された複合体オブジェクト1つを指定すれば良い。
なお、create時に他にもプロパティを設定している。
inertiaは慣性モーメント(物体を回転させるために必要な力の量、つまり物体の回転しにくさ)であり、Infinity(無限大)にすればオブジェクトは回転しなくなるので常に地面に対し水平を保ったまま動かせる。
frictionAirは空気摩擦(空気抵抗)であり、値が大きいほどオブジェクトの運動速度の低下が大きくなる。

衝突フィルター

正方形、円、三角形が2つずつあり、同じ種類の図形同士でしか衝突できないようにしている。 また、これらの図形と床・地面の衝突の有無をチェックボックスで切り替えられるようにしている。


BodyオブジェクトはcollisionFilter(衝突フィルター)というプロパティを持っており、さらにcollisionFiltergroup(衝突グループ)、category(衝突カテゴリ)、mask(マスク)というプロパティを持っている。matter.jsではこれらのプロパティの値に応じて「グループ」または「カテゴリ/マスク」のいずれかのルールに基づく衝突の演算が行われる。

「グループ」に基づく衝突

2つのBodyオブジェクトについて、 両者のgroupが等しい且つgroup > 0である場合は衝突し、 両者のgroupが等しい且つgroup < 0である場合は衝突しない。 両者のgroupが異なる、または少なくとも一方がgroup = 0である場合、衝突は次の「カテゴリ/マスク」ルールに基づくことになる。
「グループ」ルールに基づく衝突はあまり細かく条件を設定することができない。

「カテゴリ/マスク」に基づく衝突

collisionFilter.groupのデフォルト値は0なので、基本的にgroupを変更していなければ「カテゴリ/マスク」ルールになる。複雑な衝突の条件を設定したい場合はこちらを利用する。
2つのオブジェクトがある時に、それぞれのmaskにもう一方のオブジェクトのcategoryを含む場合のみ2つは衝突する。
例えば下の図で、円(categoryがAでmaskにBを含む)と正方形(categoryがBでmaskにAを含む)は衝突する。一方で、円(maskにCを含まない)と三角形(categoryがCでmaskにAを含まない)は衝突しない。
長方形(categoryがD)はmaskを指定していないが、その場合はmaskに全ての衝突カテゴリ(最大32種類)が指定されているのと同じことになる。つまり、長方形は円、正方形、三角形の全てと衝突することになる。

$('body').append('<p>床との衝突あり<input type="checkbox" value="1" checked></p>');

// 衝突のカテゴリ【⑨】
const commonCategory = 0x0001, // 全オブジェクト共通のカテゴリ
  staticCategory = 0x0002, // 静止オブジェクトのカテゴリ
  squareCategory = 0x0004, // 正方形のカテゴリ
  circleCategory = 0x0008, // 円のカテゴリ
  triangleCategory = 0x0010; // 三角形のカテゴリ

// 静止オブジェクト(空中の床と地面)【⑩】
const floor = Bodies.rectangle(400, 300, 500, 30, {
  collisionFilter: {
    category: commonCategory
  },
  isStatic: true
});
const ground = Bodies.rectangle(400, 585, 800, 30, {
  isStatic: true,
  collisionFilter: {
    category: commonCategory
  },
});

// 可動オブジェクト(上下の正方形、円、三角形)【⑪】
const square1 = Bodies.rectangle(floor.bounds.min.x + 50, 100, 50, 50, {
  collisionFilter: {
    category: squareCategory,
    mask: commonCategory | squareCategory
  },
});
const circle1 = Bodies.circle(floor.position.x, 100, 50, {
  collisionFilter: {
    category: circleCategory,
    mask: commonCategory | circleCategory
  },
});
const triangle1 = Bodies.polygon(floor.bounds.max.x - 50, 100, 3, 50, {
  collisionFilter: {
    category: triangleCategory,
    mask: commonCategory | triangleCategory
  },
});
const square2 = Bodies.rectangle(floor.bounds.min.x + 50, ground.bounds.min.y - 25, 50, 50, {
  collisionFilter: {
    category: squareCategory,
    mask: commonCategory | squareCategory
  },
});
const circle2 = Bodies.circle(floor.position.x, ground.bounds.min.y - 50, 50, {
  collisionFilter: {
    category: circleCategory,
    mask: commonCategory | circleCategory
  },
});
const triangle2 = Bodies.polygon(floor.bounds.max.x - 50, ground.bounds.min.y - 25, 3, 50, {
  collisionFilter: {
    category: triangleCategory,
    mask: commonCategory | triangleCategory
  },
});

Composite.add(engine.world, [floor, ground, square1, circle1, triangle1, square2, circle2, triangle2]);

// 静止物体の衝突あり・なしを切り替える【⑫】
$('input').change(() => {
  const isChecked = $('input').prop('checked');
  floor.collisionFilter.category = isChecked ? commonCategory : staticCategory;
  ground.collisionFilter.category = isChecked ? commonCategory : staticCategory;
})


【⑨】使用する衝突カテゴリの用意
categoryの値は2の冪乗の数で指定され、最大32個(20〜231)まで使用することができる。なお、この値は20, 21, 22, 23, 24…231つまり1, 2, 4, 8, 16…2147483648をそれぞれn進数のリテラル表記にするようで、
2進数なら0b000010b000100b001000b010000b10000
16進数なら0x000010x000020x000040x000080x00010
のように設定する。(が、実は普通に10進数で指定しても目的の挙動にはなる)


【⑩】静止オブジェクトの衝突カテゴリの設定
静止オブジェクトの衝突カテゴリには、この例に含まれる全オブジェクト共通のcommonCategoryを指定する。
静止オブジェクトにはmaskを指定していないのでsquareCategorycircleCategorytriangleCategoryは全て含まれていることになる。したがって、正方形、円、三角形の各オブジェクトのmaskcommonCategoryを含めておけば(⑪で設定)静止オブジェクトと衝突させることができる。


【⑪】可動オブジェクトの衝突カテゴリの設定
オブジェクトの衝突対象となる衝突カテゴリをmaskに指定する。
ORビット演算子|を用いて衝突カテゴリを複数指定することができる。


【⑫】可動オブジェクトの衝突カテゴリの設定
チェックボックスのON/OFFを切り替える度に静止オブジェクトの衝突カテゴリがcommonCategorystaticCategoryに切り替わる。可動オブジェクトのmaskにはstaticCategoryが含まれないので、静止オブジェクの衝突カテゴリがstaticCategoryである間は可動オブジェクトが衝突せず突き抜けることになる。

オブジェクトの表示切り替え、削除、総数のカウント

canvasをクリックした位置にランダムな大きさのボールを生成し、存在しているボールの個数をcanvas外に表示する。canvasの表示領域より下に落ちたボールは随時消していく。


この例ではボール用のコンポジットを用意する。
コンポジットとは、個々のBodyオブジェクトや拘束条件などをまとめた配列のようなもので、コンポジットに別のコンポジットを追加して階層構造のようにすることもできる。
これまで色々な場所で利用してきたengine.worldもコンポジットの一つであり、全コンポジットの親に当たるものと考えられる。

$('body').append('<p class="body-counter">Number of balls : <span></span></p>');
$('body').append('<button>Clear</button>');

// ボール用Compositeを生成する【⑬】
const ballComposite = Composite.create();
Composite.add(engine.world, ballComposite);

// 静止オブジェクト(空中の床と画面外落下判定オブジェクト)【⑭】
const floor = Bodies.rectangle(400, 400, 500, 30, { isStatic: true });
const pit = Bodies.rectangle(400, 900, 50000, 30, { isStatic: true, label: 'pit' });
Composite.add(engine.world, [floor, pit]);

// クリックした位置に円を生成とballCompositeへの追加
Events.on(mouseConstraint, 'mousedown', e => {
  // ドラッグ中は生成しない
  if (mouseConstraint.body) { return }
  // 半径はランダム(10〜30)
  const min = 10;
  const max = 30;
  const radius = Math.random() * (max - min) + min;
  const ball = Bodies.circle(e.mouse.position.x, e.mouse.position.y, radius, { restitution: 0.5 });
  Composite.add(ballComposite, ball);
});

// Engineモジュールに対するイベント/衝突の発生を検知する【⑮】
Events.on(engine, 'collisionStart', e => {
  $.each(e.pairs, (i, pair) => {
    // 画面外落下判定オブジェクトに衝突したボールを削除する
    if (pair.bodyA.label === 'pit') {
      Composite.remove(ballComposite, pair.bodyB);
    }
  })
});

// Compositeへのオブジェクト追加を検知してボール総数の表示を更新する【⑯】
Events.on(ballComposite, 'afterAdd', e => {
  // Eventオブジェクトを直接参照してCompositeに含まれる全bodyを取得
  $('p.body-counter span').text(e.source.bodies.length);
});
// Compositeからのオブジェクト削除を検知してボール総数の表示を更新する【⑯】
Events.on(ballComposite, 'afterRemove', () => {
  // Composite#allBodies()を利用してCompositeに含まれる全bodyを取得
  $('p.body-counter span').text(Composite.allBodies(ballComposite).length);
});

$('button').on('click', () => {
  // ボールを一括削除する【⑰】
  Composite.clear(ballComposite);
  $('p.body-counter span').text(0);
});


【⑬】ボール用Compositeを生成する
生成したボールの個数をカウントしたり一括削除したりするのに便利なので、ボール専用のballCompositeを生成する。このコンポジットもengine.worldに追加する。


【⑭】画面外への落下を判定するオブジェクト
canvasの表示領域よりも下にボールが落下したかどうかを判定するための仕込み。
十分な横幅を持った長方形オブジェクトをcanvasの表示領域より下に設置しておき、それに衝突したボールは画面外に落下したものとみなす。衝突判定の時に必要になるので長方形オブジェクトにはlabelプロパティでpitという名前を付けている。


【⑮】衝突の発生を検知する
Engineモジュールには空間内のオブジェクトの衝突を検知するイベントが用意されている。 物理演算・描画の更新は一定時間毎(デフォルトは16.666ミリ秒)に行われており、その更新後にイベントが発生する。

collisionActive オブジェクトが衝突している間発生し続ける
collisionStart オブジェクトが衝突し始めた瞬間に発生する
collisionEnd オブジェクトが衝突し終わった瞬間に発生する

イベントオブジェクト内のpairsには衝突しているオブジェクトのペアの配列が含まれているので、衝突ペアのうちbodyAが落下判定用の長方形オブジェクト(labelpit)であればもう一方のbodyBのボールを削除する。
Composite.remove(オブジェクトを削除するコンポジット, 削除するオブジェクト)
なお、衝突している2つのオブジェクトのうち、idの小さい方がbodyAになる。この例ではボールよりも先に落下判定用の長方形オブジェクトを生成するので、常に衝突ペアのbodyAは長方形オブジェクト、bodyBはボールオブジェクトになる。


【⑯】オブジェクト追加と削除を検知する
Compositeモジュールにはオブジェクトの追加や削除を検知するイベントが用意されている。

beforeAdd オブジェクトが追加される前に発生する
beforeRemove オブジェクトが削除される前に発生する
afterAdd オブジェクトが追加された後に発生する
afterRemove オブジェクトが削除された後に発生する

コンポジット内のオブジェクトを参照する時は、e.source.bodiesでイベントオブジェクトを直接参照するかComposite.allBodies()メソッドを利用する。


【⑰】コンポジット内のオブジェクトを一括削除する

Composite.clear オブジェクトを削除するコンポジット

特定のコンポジットに含まれるオブジェクトを一括削除することができる。その他のコンポジットには影響はない。 clear()メソッドによるオブジェクトの削除はbeforeRemoveafterRemoveイベントで拾われないので注意が必要。

Compositesによるオブジェクトの拘束

Compositesモジュールを利用して鎖のように複数のオブジェクトを一列に繋いだり、縦横の格子状に繋いだりして複合体を生成する。

/**
 * 図形の列を数珠繋ぎにする
 */
// 図形の列を生成する【⑱】
const chain = Composites.stack(650, 60, 7, 1, 0, 50, (x, y) => {
  return Bodies.polygon(x, y, 8, 30);
});
// 図形の列を接続する【⑲】
Composites.chain(chain, 0.5, 0, -0.5, 0, { stiffness: 0.5, length: 1 });
const chainConstraint = Constraint.create({
  pointA: { x: 650, y: 50 },
  bodyB: chain.bodies[0],
  pointB: { x: -30, y: 0 },
  stiffness: 1
})
Composite.add(engine.world, [chain, chainConstraint]);

/**
 * 図形の行列を格子状に繋ぐ
 */
const rows = 10;
const columns = 10;
const dx = 30;
const dy = 30;

// 縦横に並んだ図形の集合を生成する【⑱】
const mesh = Composites.stack(100, 50, columns, rows, dx, dy, (x, y, column, row) => {
  // 最上行のみ固定する
  const isStatic = row === 0;
  return Bodies.circle(x, y, 10, { isStatic: isStatic});
});

// 図形の集合を接続する【⑲】
Composites.mesh(mesh, columns, rows, false, { stiffness: 0.9 });
Composite.add(engine.world, [mesh]);


【⑱】縦横に並ぶオブジェクトの集合を生成する

Composites.stack x, y, 列数, 行数, 列方向の間隔, 行方向の間隔, コールバック関数

コールバック関数内の処理で生成されるオブジェクトを、座標、列数、行数などの条件に基づき並べたコンポジットを生成する。 複数のオブジェクトを整然と並んだ状態でまとめて生成したい場合はこれを使うと便利。
xyは生成されるコンポジット内の先頭のオブジェクトの中心位置を指定するものであり、ここを基準に後続のオブジェクトが並べられる。この時点ではまだオブジェクトは結合されていないので動かせば簡単に崩れてしまう。
なお、オブジェクトは図のような順の1次元配列としてbodiesに格納されているので、1次元のインデックスを指定すれば各々のオブジェクトを参照することができる。

コールバック関数の引数はx, y, column, row, lastBody, iを渡すことができ、それぞれ各オブジェクトの「中心のx, y座標、列番号、行番号、1つ前のオブジェクト、1次元の番号」である。


【⑲】オブジェクトの集合を繋ぐ
stack()で生成したコンポジットに対してchain()mesh()メソッドを利用してオブジェクトを繋いでいく。 これらのメソッドを実行すると、オブジェクトを結合するための拘束条件が対象のコンポジットに追加される。

<オブジェクトを直列に繋ぐ>

Composites.chain コンポジット, xOffsetA, yOffsetA, xOffsetB, yOffsetB, オプション

xOffsetA, yOffsetA, xOffsetB, yOffsetBbodyAbodyB(1つ後ろのオブジェクト)を拘束する時の両者の接続点の位置を指定するものであるが、座標値ではなく中心からの変位の割合(0〜1)を指定する。 オブジェクトの表面を接続点にする場合はxOffsetを±0.5にしておけばいい。(初期が縦並びの場合はyOffsetを±0.5にする)

<オブジェクトを格子状に繋ぐ>

Composites.mesh コンポジット, 列数, 行数, crossBrace, オプション

crossBraceは行列の格子構造に斜めの梁を入れるかどうかのフラグである。斜めの梁が入ると頑丈になり、オブジェクトの格子形状が変形しにくくなる。

外力を加える

前述の箱型ブランコの例は空気抵抗により振動が減衰していくだけだったので、外部から瞬間的に力を加えてブランコを漕げるようにしてみる。

// 箱型ブランコを生成する部分は同じなので省略

$('body').append(
  '<div style="width: 800px; margin-top: 10px; display: flex; justify-content: center;">' +
  '<button id="left" style="width: 100px; height: 30px;">◀︎◀︎</button>' +
  '<button id="right" style="width: 100px; height: 30px;">▶︎▶︎</button>' +
  '</div>'
);

// 地面に対して水平右向きを正とする一定な大きさの力を生成する【⑳】
const magnitude = 0.2;
const centerX = (compoundBody.bounds.max.x + compoundBody.bounds.min.x) / 2;
const centerY = (compoundBody.bounds.max.y + compoundBody.bounds.min.y) / 2;
const workingPoint = Matter.Vector.create(centerX, centerY);
const forceVector = Matter.Vector.create(magnitude, 0);

$('#left').on('click', () => {
  // 左向きに力を加える【㉑】
  Body.applyForce(compoundBody, workingPoint, Matter.Vector.neg(forceVector));
});

$('#right').on('click', () => {
  // 右向きに力を加える【㉑】
  Body.applyForce(compoundBody, workingPoint, forceVector);
});


【⑳】力を生成する
力を作用させるためにはその力の作用点と終点の座標を指定する必要があるので、あらかじめそれらの点のVectorオブジェクトを生成する。終点は作用点を原点に取った相対的な座標値を指定する。
余談だが、Vectorオブジェクトとして生成されるものは空間内の1点を表すx, y座標のセットであり、大きさと向きの情報を持つ本来の「ベクトル」の感覚と異なり少し紛らわしい気がする。(「位置ベクトル」を意図しているのだろうか?)


【㉑】力を加える

Body.applyForce 対象のオブジェクト, 力の作用点, 力

ここで言う「力」とは、先ほど生成した終点のVectorオブジェクトのことである。 作用点と力のVectorオブジェクトを引数として渡せば対象のオブジェクトに力を作用させることができる。 Matter.Vector.neg()メソッドを利用すると力の方向を180°反転することができる。


次は空間内のオブジェクトに継続的に作用する風のような力を再現してみる。
しかし、applyForce()で加えることができる力は瞬間的なものであり、次の演算ステップになると0に戻ってしまう。そのため、継続的に作用させるためには演算ステップの度にapplyForce()を使う必要がある。

$('body').append(
  '<div style="width: 100px; margin-top: 10px;">' +
  '<select style="width: 100px; height: 30px;">' +
  '<option value="2">2</option>' +
  '<option value="1" selected>1</option>' +
  '<option value="0">0</option>' +
  '<option value="-1">-1</option>' +
  '<option value="-2">-2</option>' +
  '</select>' +
  '</div>'
);

// 静止オブジェクト(地面とハンプ)
const ground = Bodies.rectangle(400, 585, 10000, 30, { isStatic: true });
const hump = Bodies.fromVertices(400, 555, [{ x: 0, y: 0 }, { x: 150, y: 50 }, { x: -150, y: 50 }], { isStatic: true });
Composite.add(engine.world, [ground, hump]);

// 力の確認用に吹流しを生成する
const streamer = Composites.stack(400, 60, 4, 1, 0, 50, (x, y) => {
  return Bodies.rectangle(x, y, 60, 20, { isSensor: true });
});
Composites.chain(streamer, 0.5, 0, -0.5, 0, { stiffness: 1, length: 0 });
Composite.add(streamer, Constraint.create({
  pointA: { x: 400, y: 50 },
  bodyB: streamer.bodies[0],
  pointB: { x: -30, y: 0 },
  stiffness: 1
}));
Composite.add(engine.world, streamer);

// 地面に対して水平右向きを正とする一定な大きさの力を生成する【㉒】
const order = 1e-3;
Events.on(engine, 'beforeUpdate', () => {
  $.each(Composite.allBodies(engine.world), (i, body) => {
    const magnitude = Number($('option:selected').val());
    const workingPoint = Matter.Vector.create(body.position.x, body.position.y);
    const forceVector = Matter.Vector.create(magnitude * order, 0);
    Body.applyForce(body, workingPoint, forceVector);
  });
});

// クリックした位置に多角形を生成
Events.on(mouseConstraint, 'mousedown', e => {
  if (mouseConstraint.body) { return }
  const ball = Bodies.polygon(e.mouse.position.x, e.mouse.position.y, 8, 25, { restitution: 0.5 });
  Composite.add(engine.world, ball);
});


【㉒】継続的に力を作用させる
Engineオブジェクトには演算ステップの更新直前に発火するbeforeUpdateイベントが用意されている。
このイベントのコールバック関数の内部でコンポジットの全オブジェクトに対して力を加えるようにすれば、空間内で一様に継続的な力が作用しているのと近い状況を作ることができる。
プルダウンの整数値を参照しているので、力の大きさや方向(+が右向き、−が左向き)を変化させることができる。

ポケモンのアイコンで物理演算


WEBブラウザ上でできる物理演算について色々と調べていたら、絵文字で物理演算を実現している方の記事を見つけた。この記事を読んでいるうちにおぼろげながら浮かんできたんです、ポケモンのアイコンでも同じことができて面白いんじゃないかと。 この記事でのやり方を参考にさせていただき、matter.jsとOpenCVを組み合わせてポケモンのアイコン(ゲーム画面で使用されているもの)で物理演算できるようにしてみた。
完成形はこちら

qiita.com

アイコン画像の取得

まず物理演算オブジェクトの生成元となるアイコン画像を取得する。
利用するのはPokeAPIというサイトで、ゲーム内のあらゆるデータを取得できるAPIが公開されている。

pokeapi.co

このサイトではトップページのフォームでAPIのレスポンスを確認することができる。 例えば、フォームにpokemon/407と入力して「Submit」してみるとID No.407のロズレイドのデータのレスポンスが表示される。 今欲しいのはアイコン画像なので、レスポンス内を探していくとsprites/versions/の中に歴代バージョン毎のアイコン画像のURLを見つけることができる。最新バージョンのものであればgeneration-viiiだ。

ついでに対象ポケモンの日本語名も後で使うので取得しておきたい。
先ほどのフォームにpokemon-species/407と入力して取得されるレスポンスを確認すると、namesに言語毎の名前が配列で格納されているのことが分かる。この中のja-Hrktjaでもいい)表記のものだけ取って来ればいいが、必ずしも配列の0番目とは限らないので注意が必要。 なお、pokemon-speciesのURLは、先のpokemonレスポンス内のspecies/urlで取得できる。

アイコン画像のURLと日本語名の場所が分かったので、実際にAPIを実行するコードを書いて欲しいものを取得しよう。コードはPython (3.10.5)で書いた。
最新のポケモンは1000種類ほどいるらしいが流石に全部は多いので、とりあえず赤・緑〜ブラック・ホワイト(いわゆる初代〜第5世代)までの649種類に留めた。

import requests, json

base = 'https://pokeapi.co/api/v2/pokemon/'
end_number = 649
name_dict = dict(names=[])

for i in range(end_number):
  pokemon_url = base + str(i + 1)
  pokemon_res = requests.get(pokemon_url).json()

  # speciesデータから日本語名を取得する
  species_url = pokemon_res['species']['url']
  species_res = requests.get(species_url).json()
  name = list(filter(lambda v: v['language']['name'] == 'ja-Hrkt', species_res['names']))[0]['name']
  # json出力用辞書にIDと名前のセットを追加する
  name_dict['names'].append({ 'id': i + 1, 'name': name })

  # アイコン画像ファイル情報を取得する
  icon_url = pokemon_res['sprites']['versions']['generation-viii']['icons']['front_default']
  icon_res = requests.get(icon_url)
  # pngとして保存する
  path = './pic/' + str(i + 1) + '.png'
  with open(path, 'wb') as f:
    f.write(icon_res.content)

  if (i + 1) % 10 == 0:
    print('Complete to No.' + str(i + 1))

# IDと名前を一括でjson出力する
with open('name_data.json', 'w') as f:
  f.write(json.dumps(name_dict, indent=2, ensure_ascii=False))


pokemon_resspecies_resに先ほど中身を確認したレスポンスデータが入っているので、欲しい情報のところまでひたすら辿っていく。アイコン画像はそれぞれ[ID No].pngという名前で保存し、全ポケモンの日本語名は図鑑Noとセットでjsonファイルに書き出す。

{
  "names": [
    {
      "id": 1,
      "name": "フシギダネ"
    },
    {
      "id": 2,
      "name": "フシギソウ"
    },
            :
          省略
            :
  ]
}

UI画面と処理の概要

ディレクトリ構成

.
├── docs
│   ├── image
│   ├── index.html
│   └── main.js
├── node_modules
├── package-lock.json
├── package.json
├── src
│   ├── index.js
│   ├── json
│   │   └── name_data.json
│   ├── style.css
│   ├── use_matter.js
│   └── use_opencv.js
└── webpack.config.js

環境

  • jQuery 3.2.1
  • npm 6.14.4
  • webpack 5.74.0
  • css-loader 6.7.1
  • style-loader 3.3.1
  • json-loader 0.5.7
  • matter.js 0.18.0
  • opencv.js 4.6.0


先ほど保存したアイコン画像は全てdocs/imageに入れjsonファイルはsrc/jsonに入れて使う。 matter.jsの初期設定はuse_matter.js、opencv.jsでの処理はuse_opencv.jsで行い、それらをindex.jsの中でモジュールとして読み込んで使っている。use_matter.jsuse_opencv.jsの処理を別ファイルに切り出したかったのでwebpackで管理している。

大雑把に言えば、プルダウンでポケモンが選択されたタイミングで対象のアイコンの輪郭抽出とテクスチャを生成しておき、物理演算用canvasがクリックされた時にそのアイコンオブジェクトを生成・投入するという流れになる。
この記事では、主にopencv.jsでの輪郭抽出とmatter.jsでのオブジェクト生成に関する部分について述べ、その他のUIに関する詳細な処理については省略する。

opencv.jsでアイコンの輪郭を抽出

opencv.jsの導入

OpenCVは様々な画像処理を行うことができるPythonのライブラリで、それをjavascriptとして使えるようにしたものがopencv.jsである。
導入方法は、CDNで読み込む方法とビルド済みのopencv.jsをディレクトリに配置して読み込む方法がある。 今回は簡単にCDNで読み込んでいる。

<script src="https://docs.opencv.org/4.6.0/opencv.js"></script>

docs.opencv.org

輪郭抽出までの工程の概要

元のpng画像(背景は透過)からアイコンの輪郭を抽出するためには背景とアイコンの境界を明確にする必要があり、グレースケール化と二値化を行う必要がある。

グレースケール化

各画素(ピクセル)の色合いを、黒を0、白を255とした多階調の画素値に変換する。つまり、カラー画像をグレーの濃淡で表現されるように変換する。

二値化

ある閾値を設定し、その閾値以下の画素値は0、閾値より大きい画素値は255にするといったように全ての画素の色を二分する。 グレーゾーンなどなく白か黒かはっきりさせるのだ。

輪郭抽出

対象となる画像要素imgElementMatオブジェクト(画素値の行列、マトリックス)に変換し、それに対して各種の関数を適用して順次処理していく。各処理を行うための関数はcvオブジェクトによって使うことができる。
各関数の引数には処理対象のMatを指定するのはもちろんのこと、処理結果を保存する先のMatも渡す必要がある。 この辺りはopencv.js特有の方法で本家のOpenCVとは異なるらしい。
なお、生成したMatオブジェクトは自動でメモリ解放されないので、使い終わったらその都度自分でdelete関数で削除する必要がある。
これらの画像処理をまとめているgetVerticesFromImageSrc()は、抽出した輪郭の座標値verticesを最終的に返しており、後でindex.jsで読み込んで使うことになる。

const getVerticesFromImageSrc = (imgElement) => {
  // img要素の画像を読み込む
  const src = cv.imread(imgElement);
  // グレースケール化用Matの生成
  const dstGray = new cv.Mat(src.cols, src.rows, cv.CV_8UC1);
  // 二値化用Matの設定
  const dstBinary = new cv.Mat(src.cols, src.rows, cv.CV_8UC4);
  // グレースケール化
  cv.cvtColor(src, dstGray, cv.COLOR_RGBA2GRAY);
  // 二値化
  cv.threshold(dstGray, dstBinary, 1, 255, cv.THRESH_BINARY);
  src.delete();
  dstGray.delete();

  // 輪郭の抽出
  const contours = new cv.MatVector();
  const hierarchy = new cv.Mat();
  cv.findContours(dstBinary, contours, hierarchy, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE);
  dstBinary.delete();
  hierarchy.delete();

  // 輪郭の座標を頂点として格納
  const vertices = [];
  for (let i = 0; i < contours.size(); i++) {
    let d = contours.get(i).data32S;
    for (let j = 0; j < d.length; j += 2) {
      vertices.push({ x: d[j], y: d[j + 1] });
    }
  }
  contours.delete();

  return vertices;
};

export { getVerticesFromImageSrc };


各関数の役割やどのような引数を設定しているか簡単に書いていく。

画像読込み / imread
引数 img要素(or画像パス)

対象のimg要素を読み込んでMatオブジェクトに変換する。

書き出し / imshow
引数 canvas ID, 描画対象のMat

指定のcanvasにMatオブジェクトを書き出す。
輪郭抽出処理には直接必要なものではないが、確認用として頻繁に使っていたので一応記載。

画像の色空間の変換 / cvtColor
引数 入力Mat, 出力Mat, 変換処理の種類, 出力画像のチャネル数

画像にさまざまな変換処理を行う。
「変換処理の種類」によってグレースケール化以外も行えるが、今回はcv.COLOR_RGBA2GRAYを指定する。
「出力画像のチャネル数」は指定しなくてもデフォルト値0になる。

二値化 / threshold
引数 入力Mat, 出力Mat, 閾値, 最大値, 閾値処理の種類

Matオブジェクトに対して閾値処理を行う。
「最大値」には画素値が閾値より大きい場合に置き換える値を指定する。
今回の「閾値」は1としているので、黒色の背景(透過pngなので画素値0)以外は全て白(最大値255)になる。
「閾値処理の種類」はTHRESH_BINARYを指定する。

輪郭抽出 / findContours
引数 入力Mat, 輪郭, 階層, 輪郭検索モード, 輪郭近似法, オフセット

画像要素の輪郭を検索する。
出力結果1つ目の「輪郭」(MatVectorオブジェクト)に目的の輪郭の座標値がベクトルとして含まれる。
出力結果2つ目の「階層」(Matオブジェクト)に各輪郭線の親子関係などの情報が含まれる。今回は直接使用することはないが引数の指定は必要になる。
輪郭線は等高線のように多重の階層で検出されることもあるが、今回は一番外側の輪郭だけあればいいので「輪郭検索モード」をcv.RETR_EXTERNALにしている。
「輪郭近似法」は必要最小限の輪郭の点を検出するcv.CHAIN_APPROX_SIMPLEにしている。
「オフセット」は全ての輪郭線を平行移動する量を設定するものだが今回は必要ないので設定していない。(デフォルト値はx, yともに0)

座標値の取り出し

findContoursで得られた輪郭(コード中のcontours)から座標値だけを取り出してx, y座標のオブジェクトの配列にする。
contoursにはアイコン画像の領域数分だけ輪郭線の情報(Matオブジェクト)が含まれている。大抵のポケモンのアイコンは1つの領域で描画されているが、中には複数に分かれているものもある。(No.109ドガースのアイコンなどが分かりやすい)
get関数にインデックスを指定すれば各輪郭線の情報が取得できる。
それぞれの輪郭線のdata32SMatタイプCV_32S = 符号付き32ビット整数)に目的の座標値が入っているが、[x1, y1, x2, y2, …]のように縦横の座標が1次元配列で格納されているのでx, y座標のオブジェクトを作るときは注意が必要。

matter.jsで物理演算

matter.jsの導入

matter.jsはWEBブラウザ上で2次元の物理演算ができるjavascriptのライブラリである。 物理演算の世界をhtmlのcanvas上に展開し、オブジェクトを投入したりマウス操作で動かしたり衝突させたり色々できる。
matter.jsの導入はnpmでインストールする方法、ビルド済みのmatter.jsをディレクトリに配置して読み込む方法があるが、今回はnpmを使った。

npm install matter-js

brm.io

matter.jsの初期設定

matter.jsの初期設定関係の処理はbasicMatterConfig()にまとめている。basicMatterConfig()は、物理演算canvasを展開する先の要素canvasとその幅widthと高さheightを受け取るようにしている。
この関数内で物理演算の実行や描画、オブジェクトの生成に必要なモジュール群を読み込むが、その内Body,Bodies, Bounds, Constraint, CompositeEngineMouseConstraintインスタンスのenginemouseConstraintindex.jsでも使用するので返り値としている。

import Matter from 'matter-js';

const mousePointer = { x: 0, y: 0 };

const basicMatterConfig = (canvas, width, height) => {
  // 使用モジュール
  const Engine = Matter.Engine,
    Render = Matter.Render,
    Runner = Matter.Runner,
    Body = Matter.Body,
    Bodies = Matter.Bodies,
    Mouse = Matter.Mouse,
    MouseConstraint = Matter.MouseConstraint,
    Bounds = Matter.Bounds,
    Constraint = Matter.Constraint,
    Events = Matter.Events,
    Composite = Matter.Composite;

  // エンジンの生成
  const engine = Engine.create();

  // レンダリングの設定
  const render = Render.create({
    element: canvas,
    engine: engine,
    options: {
      width: width,
      height: height,
      wireframes: false,
      background: '#f0f6da'
    }
  });

  // マウス、マウス制約を生成
  const mouse = Mouse.create(canvas);
  const mouseConstraint = MouseConstraint.create(engine, {
    mouse: mouse,
    constraint: {
      angularStiffness: 0,
      render: {
        visible: false
      }
    }
  });

  // マウスポインタの座標を格納する
  Events.on(mouseConstraint, 'mousemove', e => {
    mousePointer.x = e.mouse.position.x;
    mousePointer.y = e.mouse.position.y;
  });

  Composite.add(engine.world, mouseConstraint);
  render.mouse = mouse;

  // レンダリングを実行
  Render.run(render);

  // エンジンを実行
  Runner.run(engine);

  return { Body, Bodies, Bounds, Constraint, Composite, engine, mouseConstraint };
};

export { basicMatterConfig, mousePointer };
エンジンの生成と実行

初めにEngine.create()で物理演算エンジンのインスタンス生成する。このEngineインスタンスは今後色々なところで必要になる。
他の設定が完了した上で Runner.run(engine)とすれば物理演算が可能になる。

レンダリングの設定

各種オプションを設定した上でRender.create()Renderインスタンスを生成する。 オプションのelementで指定した要素に物理演算用canvasが挿入される。width, heightを指定すればcanvasのサイズを設定できる。
wireframesがtrueの場合はワイヤーフレーム表示になり、黒い背景にオブジェクトの輪郭線だけが描画される。ワイヤーフレーム表示では塗りつぶし色やテクスチャなどが反映されないので基本的にはfalseに設定しておく。
最後にRender.run(render)でレンダリングを実行する必要がある。

マウス制約、マウスイベントの設定

物理演算オブジェクトに対するマウス入力と操作を設定する。
マウス制約MouseConstraintは主にオブジェクトをドラッグ移動させる時の挙動などを設定するモジュールである。
MouseConstraintインスタンス生成時のオプションでangularStiffnessを設定しているが、これはオブジェクトをマウスで掴んだ時の回転角度の補正速度を設定するものである。0〜1の間で設定するようで、1は回転なしで0に近いほど回転速度が大きくなる。(この辺りあまり公式ドキュメントに書かれていないので正確ではない)
mouseConstraint.bodyとすれば、ドラッグ中のオブジェクト(Bodyオブジェクト)を参照することができる。
また、Eventモジュールのon()でマウス制約を指定すればマウスイベントを設定することもできる。ここではmousemoveイベントにマウスの座標を取得する処理をバインドしている。

物理演算canvasへの投入

マウス制約やオブジェクトなどは基本的にCompositeモジュールで演算世界へ投入していく。 Composite.add(engine.world, [投入するもの])のようにする。
投入するオブジェクトが1個だけの場合はそのまま第2引数に渡せば良いが、複数の場合は配列にして渡す必要がある。

画面のテンプレートとメイン処理

まずは画面のテンプレートのほうから。
プルダウンやチェックボックスなどのUI部分を除けば、主に必要なのは<canvas id="texture-canvas" width="50" height="50"></canvas><div id="matter-canvas-area"></div>である。
canvas#texture-canvas要素は選択したアイコン画像を表示するとともに、演算用オブジェクトに貼り付けるテクスチャとしても使用する。
div#matter-canvas-area要素はmatter.js初期設定のところでRenderelementに指定した要素である。このdivの内部に物理演算用canvasが挿入される。

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <title>Poke physics calc</title>
</head>

<body>
  <div id="control-area">
    <div id="icon-control">
      <div id="icon-select">
        <select>
          <option hidden>選択してください</option>
        </select>
        <div id="texture-container">
          <!-- 初期サイズは50x50 -->
          <canvas id="texture-canvas" width="50" height="50"></canvas>
        </div>
      </div>
      <label for="is-random"><input type="checkbox" id="is-random">ランダム生成</label>
    </div>
    <div id="object-control">
      <div id="left-container">
        <label for="is-display-walls"><input type="checkbox" id="is-display-walls" checked>左右の壁</label>
        <label for="is-display-ground"><input type="checkbox" id="is-display-ground" checked>地面</label>
      </div>
      <div id="right-container">
        <span>その他のオブジェクト</span>
        <label for="none"><input type="radio" id="none" value="none" name="equipment" checked>なし</label>
        <label for="slope"><input type="radio" id="slope" value="slope" name="equipment">スロープ</label>
        <label for="cup"><input type="radio" id="cup" value="cup" name="equipment">カップ</label>
        <label for="hourglass"><input type="radio" id="hourglass" value="hourglass" name="equipment">砂時計</label>
      </div>
    </div>
  </div>
  <div id="icon-container">
    <!-- 画面表示時にiconの元画像を全て読み込んでimg要素を格納する -->
  </div>
  <div id="matter-canvas-area"></div>
  <script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
  <script src="./main.js"></script>
  <script src="https://docs.opencv.org/4.6.0/opencv.js"></script>
</body>

</html>


次はindex.jsのメイン処理(画面ロード時の処理)である。
ここでは主にアイコンオブジェクトを生成するのに必要な処理について書いている。(デモページには左右の壁や床、下端に壁や床などが存在しているがそれらの生成処理は省略)
matter.js初期設定用のbasicMatterConfig()やopencv.js関係のgetVerticesFromImageSrc()はここで読み込んでおく。
アイコン選択プルダウン生成用のname_data.jsonもここで読み込む。(webpackのjson-loaderを利用している)
アイコン選択プルダウンiconSelectが変更されたらsetIconObject()を実行し、物理演算用canvasmatterCanvasがクリックされたらaddIconObject()を実行する処理にしている。

import { basicMatterConfig, mousePointer } from './use_matter';
import { getVerticesFromImageSrc } from './use_opencv';
import './style.css';
import nameData from './json/name_data.json'

// matter.js関連のモジュール格納用
let Body, Bodies, Bounds, Constraint, Composite, engine, mouseConstraint;
// アイコンテクスチャcanvas、物理演算canvas要素
let textureCanvas, matterCanvas;
// アイコンの輪郭の座標値の配列
let vertices = [];
// 物理演算を行うcanvas領域のサイズ
const matterWidth = 900;
const matterHeight = 600;

$(document).ready(() => {
  textureCanvas = $('#texture-canvas')[0];
  matterCanvas = $('#matter-canvas-area')[0];

  const iconSelect = $('select')[0];
  const isRandom = $('#is-random')[0];

  // 名前情報オブジェクトの配列
  const names = nameData['names'];
  const iconContainer = $('#icon-container')[0];
  $.each(names, (i, data) => {
    // 名前情報からアイコン選択プルダウンのoptionを生成する
    $(iconSelect).append(`<option value="${data.id}">${String(data.id).padStart(3, 0)}: ${data.name}</option>`);
    //アイコン選択時に毎回アイコン画面を読み込んでいると時間がかかるので画面表示時に全て読み込んでimg要素を生成しておく
    $(iconContainer).append(`<img id="${i + 1}" src="./image/${i + 1}.png">`);
  });

  // matter.jsの基本設定
  ({ Body, Bodies, Bounds, Constraint, Composite, engine, mouseConstraint } = basicMatterConfig(matterCanvas, matterWidth, matterHeight));

  // 生成するアイコンの選択
  $(iconSelect).on('change', () => {
    const id = $('option:selected').val();
    const imgElement = $(`img[id='${id}']`)[0];
    setIconObject(imgElement);
  });

  $(matterCanvas).on('click', () => {
    // オブジェクトのドラッグ中は新規でオブジェクトを追加させない
    if (mouseConstraint.body) { return };

    if ($(isRandom).is(':checked')) {
      // ランダムにオブジェクトを生成する
      const id = Math.floor(Math.random() * names.length) + 1;
      $(iconSelect).prop('selectedIndex', id);
      const imgElement = $(`img[id='${id}']`)[0];
      setIconObject(imgElement);
    }
    // 頂点座標が生成されていない場合は新規でオブジェクトを追加させない
    if (vertices.length === 0) { return };
    addIconObject();
  });
});

アイコンオブジェクトの生成

アイコン画像の輪郭の座標抽出は前述のopencv.jsの処理になるので、アイコンのimg要素をgetVerticesFromImageSrc()に放り込むだけだ。
この後は輪郭の座標値verticesから、アイコンを内包する長方形の境界boundsを生成しテクスチャの切り出しに用いる。 しかし、 どうも実際のアイコンの輪郭よりも若干狭いboundsが生成されるので、テクスチャを切り出すと上下左右の端が少し切れてしまっていた。そこで、一旦生成されたborderを任意の値で少し広げてやることにした(透過背景なので切り取り領域が広い分には見た目への問題はない)。
これは推測だが、ポケモンのアイコンは全て黒い線で縁取りされており、透過背景の黒(0, 0, 0)に極めて近い色なのでグレースケールや二値化により縁取り線も背景の一部とみなされて輪郭が抽出されているのではないかと思う。つまり、実際のアイコンの輪郭よりも縁取りの幅分だけ少し狭くなるのかもしれない。

const setIconObject = (imgElement) => {
  // アイコンの輪郭の頂点座標を取得する
  vertices = getVerticesFromImageSrc(imgElement);

  // 輪郭の頂点を内包する矩形の境界線を生成
  const bounds = Bounds.create(vertices);
  // 境界を少し広げて元のアイコン画像の欠損を防ぐ
  const factor = 5;
  bounds.min.x -= factor;
  bounds.max.x += factor;
  bounds.min.y -= factor;
  bounds.max.y += factor;

  textureCanvas.width = bounds.max.x - bounds.min.x;
  textureCanvas.height = bounds.max.y - bounds.min.y;

  // 境界線で切り取ったアイコン(テクスチャ)をcanvasに描画する
  const textureContext = textureCanvas.getContext('2d');
  textureContext.drawImage(
    imgElement,
    bounds.min.x,
    bounds.min.y,
    textureCanvas.width,
    textureCanvas.height,
    0,
    0,
    textureCanvas.width,
    textureCanvas.height);
};

アイコンオブジェクトの投入

BodiesBodyとは異なる)は長方形rectangle()、円circle()、正多角形polygon()といった簡易な図形を生成する関数の他に、頂点の座標値を与えて複雑な形状の図形を生成するfromVertices()があるのでここではこれを使う。
ただし、fromVertices()で作成したオブジェクトは凹面を表現することができず自動的に埋められたような形状で生成されてしまうという欠点がある。ワイヤーフレーム表示にしてみると一目瞭然だ。


この対策としてmatter.jsの公式ドキュメントではpoly-decomp.jsというパッケージの使用が推奨されているのだが、いかんせん使いにくいし満足な結果が得られなかった。(補足に使い方を記載した。)
結局今回は凹面が埋められた形状で妥協することにした。細かく輪郭を抽出した意味が薄れるが、長方形の境界で物理演算されるよりは良いしアイコンオブジェクト自体が小さいので目をつぶった。

const addIconObject = () => {
  const pokeIcon = Bodies.fromVertices(mousePointer.x, mousePointer.y, vertices, {
    render: {
      sprite: {
        texture: textureCanvas.toDataURL(),
      }
    },
    friction: 0.01,
    restitution: 0.5,
  });

  Composite.add(engine.world, pokeIcon);
};

完成

良い感じに投入できるようになった🎉


砂時計状のオブジェクトなども入れてみた。


デモページ
ソースコード

補足〜凹面を含むオブジェクトについて〜

matter.jsのfromVertices()では凹面を表現できないという落とし穴があった。どうしても凹面まで表現したい場合はpoly-decomp.jsを導入する方法がある。

poly-decomp.js

poly-decomp.jsは、npmを利用するかdecomp.jsをダウンロードしてhtmlファイルからscriptタグで読み込めば挿入することができる。

凹面を含む図形を、凹面が含まれないように複数の図形に分割し、それらをパーツとして結合したオブジェクトにしてくれる。 複数のパーツを結合したオブジェクトであれば結果的に凹面になっても演算に考慮してくれる。
しかしさらに落とし穴があり、それで生成されたオブジェクトにテクスチャを適用すると1個1個のパーツ全てに同じテクスチャが貼られてしまう。

これを防ぐためにソースコードの修正が必要になる。(matter.jsRender.bodiesを定義しているところ)

// handle compound parts
for (k = body.parts.length > 1 ? 1 : 0; k < body.parts.length; k++) {
    part = body.parts[k];
// handle compound parts
const parts = body.render.sprite.single ? 1 : body.parts.length;
for (k = !body.render.sprite.single && body.parts.length > 1 ? 1 : 0; k < parts; k++) {
    part = body.parts[k];
さらに、index.jsaddIconObject()のオブジェクト生成時のrender.spriteオプションにsingle: trueを追加する。
      render: {
        sprite: {
          texture: originCanvas.toDataURL(),
          single: true,
        }
      },

これでOKだが、前述の通りポケモンアイコンの輪郭に適用しても良い感じにならなかった。単純な図形なら綺麗に分割できるのだが、今回のポケモンアイコンでは分割後の輪郭が元と比べて欠けていたり複数領域にちぎれたようになっていたりして上手い具合にならなかった。複雑な形状には適していないのだろうか。


参考
opencv.js関係

matter.js関係