🌏

React Native で Matter.js を使って物理演算

2024/04/13に公開

React Native でも物理演算を使ってみたい

React Native でゲームを作るために?という疑問はさておき、物理演算エンジンの Matter.js を試してみたいと思います。

https://brm.io/matter-js/

React 上で動かすためにこちらの記事をコピペ参考にさせていただきました。

https://nodemand.hatenablog.com/entry/2018/12/07/000338

matter.js をインストール

npm でサクッと入れます。記事投稿時点でのバージョンは 0.19.0 です。

$ npm i matter-js

基本的な使い方

import Matter from 'matter-js';

// 物理エンジン本体
const engine = Matter.Engine.create();

// 物理エンジンに対して定期的に更新をかけてくれるモジュール
// 更新タイミングを自前で管理することもできるようですが今回は Runnner にお任せ
const runner = Matter.Runner.create();
useEffect(() => {
    // 開始
    Matter.Runner.run(runner, engine);
    // 終了
    return () => Matter.Runner.stop(runner);
}, []);

// ボディの作成
// この場合 x=0, y=0, radius=10 の球を作成
// 境界線の情報のみなので画面上への表示は別で行う必要あり(後述)
const circle = Matter.Bodies.circle(0, 0, 10, options);
// ボディの情報を変更したい場合は作成時にオプションを渡すか作成後に操作する。
// ボディ作成は Matter.Bodies を使い、各ボディに対しての操作は Matter.Body を使うイメージ。
Matter.Body.setMass(circle, 10)

// ボディを管理するコンテナ
// engine.world が全体を管理するコンテナで、
// その中に球を管理するためのコンテナを追加しているイメージ
const ballComposite = Matter.Composite.create();
Matter.Composite.add(engine.world, ballComposite);

// ボディを登録
Matter.Composite.add(ballComposite, circle);

表示する際はコンポジットで管理しているボディの一覧を取得して画面上に表示していきます。
公式のサンプルでは Matter.Render を使用していますが、React Native 上で表示するために自前で View を表示する処理を書いていきます。

画面へ表示するための準備

// 画面表示のためのボディを state で管理
const [balls, setBalls] = useState();
const callback = useCallback(() => {
    // コンポジットから全てのボディを取得
    const allBall = Matter.Composite.allBodies(ballComposite);
    // state に保存。配列を詰め直すことで強制的に更新を発生させる
    setBalls([...allBall]);
}, [setBalls]);

// コンポジットからボディを取得するタイミングは「エンジン内の状態が変更された後」にしたいため
// Events で更新が発生するたびに処理を呼んでもらいます
// 更新処理は runner が定期的に(ドキュメントでは1000/60とのこと) 呼び出してくれています
useEffect(() => {
    // engine の状態が更新されたイベントを取得開始
    Matter.Events.on(engine, 'afterUpdate', callback);
    // 終了
    return () => Matter.Events.off(engine, 'afterUpdate', callback);
}, []);

これで物理演算で計算されたボディの一覧が取得できるようになったので更新されるたびにボディ情報を React Native の View に置き換えて表示していく処理を書いていきます。

ボディの中に parts という配列があるためその情報をもとに View の位置・大きさを決めていきます。
この辺りはまだ理解しきれていませんが、おそらく複数の情報を組み合わせて1つのボディを表現する際に parts 内に複数の情報が含まれてくるのだと思います。今回は球が1つだけの簡単なボディを作成しているため parts.length は常に1になる想定です。

<>{
  // ボディ情報を View に置き換える
  balls && balls.map(body => {
      // 情報が無い場合は表示しない
      if (!body.parts) return null;
      // 今回は parts.length == 1 の想定
      const part = body.parts[0];
      return (
          <View
              key={part.id}
              style={{
                alignItems: 'center',
                justifyContent: 'center',
                position: 'absolute',
                top: part.position.y - part.circleRadius,
                left: part.position.x - part.circleRadius,
                width: part.circleRadius * 2,
                height: part.circleRadius * 2,
                borderRadius: part.circleRadius,
                backgroundColor: part.render.fillStyle, // render.fillStyle にはサンプル用のランダムなカラーが入ってくる
                transform: [
                  { rotateZ: part.angle + "rad" } // ラジアン
                ],
              }}>
              <Text style={{ fontSize: part.circleRadiu * 1.5 }}>😀</Text>
          </View>
      ));
  });
}<>

positioncircleRadius を元に View の位置・サイズを決めていきます。
回転は angle に入ってきますが、単位がラジアンのため rad を指定する必要があります。

動かしてみる

ここまでの内容を元に実際に動かしてみたいと思います。

ボタンを押すたびに画面中央の少し上の方に球を追加していきます。

App.js
import { useCallback, useEffect, useState } from 'react';
import { Button, Dimensions, StyleSheet, Text, View, } from 'react-native';
import Matter from 'matter-js';

const width = Dimensions.get('window').width;
const height = Dimensions.get('window').height;

const engine = Matter.Engine.create();
const runner = Matter.Runner.create();
const ballComposite = Matter.Composite.create();
Matter.Composite.add(engine.world, ballComposite);

export default function App() {
  const [balls, setBalls] = useState();

  const updater = useCallback(() => {
    const allBall = Matter.Composite.allBodies(ballComposite);
    setBalls([...allBall]);
  }, [setBalls]);

  const addCircle = useCallback(() => {
    const x = width / 2 + (Math.random() - 0.5);
    const y = height / 4;
    const radius = 20;
    const circle = Matter.Bodies.circle(x, y, radius);
    Matter.Composite.add(ballComposite, circle);
  }, []);

  useEffect(() => {
    Matter.Events.on(engine, 'afterUpdate', updater);
    Matter.Runner.run(runner, engine);
    return () => {
      Matter.Events.off(engine, 'afterUpdate', updater);
      Matter.Runner.stop(runner);
    };
  }, []);

  return (
    <View style={styles.container}>
      {balls && balls.map(body => {
        if (!body.parts) return null;
        const part = body.parts[0];
        return (
          <View
            key={part.id}
            style={{
              alignItems: 'center',
              justifyContent: 'center',
              position: 'absolute',
              top: part.position.y - part.circleRadius,
              left: part.position.x - part.circleRadius,
              width: part.circleRadius * 2,
              height: part.circleRadius * 2,
              borderRadius: part.circleRadius,
              backgroundColor: part.render.fillStyle,
              transform: [
                { rotateZ: part.angle + "rad" }
              ],
            }}>
            <Text style={{ fontSize: part.circleRadius * 1.5 }}>😀</Text>
          </View>
        )
      })}
      <Button onPress={addCircle} title="addCircle" />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  }
});

いい感じです👌 Expo Snack の Web プレビューでも動いてくれるのが嬉しいですね。

https://snack.expo.dev/@tadaedo/matter-js1

床の追加。画面外に出たボディの削除

今のままだと上から下に落ちていくだけであまり衝突した感じがしないので床を追加します。

また React Native 上で実行しているからというのもあるのですが、ボディを追加しすぎると View の数も増えていき全体的にもっさりしてしまうため画面外に出たボディは削除していきたいと思います。

// 床は特に表示は行わないため engine.world に直接追加してしまいます
Matter.Composite.add(engine.world, [
  // 床
  // ボディの中心になる位置をx/yで指定
  // 動かない物体なのでオプションで isStatic を付与します
  Matter.Bodies.rectangle(width / 2, height, width, 10, { isStatic: true }),
  // 削除判定用
  // 床の少し下に大きな当たり判定用のボディを追加
  // 当たり判定時に必要になるため、オプションでボディの名前を label で設定します
  Matter.Bodies.rectangle(width / 2, height + 100, width * 10, 10, { isStatic: true, label: 'outarea' }),
]);

// 当たり判定処理
const collision = useCallback((event) => {
  if (!event.pairs) {
    return
  }
  event.pairs.forEach(pair => {
    // 衝突したボディが削除判定用であれば、衝突した球をコンポジットから削除する
    if (pair.bodyA.label == 'outarea') {
      Matter.Composite.remove(ballComposite, pair.bodyB)
    }
  })
}, []);
// 当たり判定イベント取得
useEffect(() => {
  Matter.Events.on(engine, 'collisionStart', collision);
  return () => {
    Matter.Events.off(engine, 'collisionStart', collision);
  };
}, []);

先のコードに追加してて実行してみます。

物理演算してますって感じがでてますね😆

https://snack.expo.dev/@tadaedo/matter-js2

Discussion