React NativeでSocket通信しよう

<h1>（１）はじめに</h1>

<p>普段はソーシャルワーカーとして障がいをお持ちの方の支援をしています。Advent Calendarに合わせてバージョンアップさせているReact Nativeでの3D表示について、続きを書いていきたいと思います。前回は<a href="https://qiita.com/syeha/items/ce3f05c0fc0206c5496b">React Nativeで3Dキャラクターを動かしてみよう</a>で3Dキャラクターをバーチャルスティックで動かすコードを紹介しました。今回はチャットやマルチプレイで利用するSocket通信の機能を追加したいと思います。</p>

![20211216_143200.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635004/e772b904-0752-0638-2f38-989a6479e85e.gif)

<p>赤色のキャラクターは自身の端末、青色のキャラクターは別の端末で操作しています。</p>
<h1>（２）Socket通信のためのサーバー</h1>

<p>まずはnodejsでサーバーを作成します。基本的にはアプリから受け取ったデータを配信するだけの機能です。フレームワークはexpressを利用し、端末から送られてきたデータをsocket.onで取得、socket.broadcast.emitで送信してきた端末以外に配信します。</p>
```js
const express = require("express");
const cors = require("cors");
const http = require("http");
const socketIO = require("socket.io");
require("events").EventEmitter.defaultMaxListeners = 0;

const port = process.env.PORT || 3000;

const app = express();
const server = http.createServer(app);

const io = socketIO(server, {
  cors: true,
  origins: ["*"],
  methods: ["GET", "POST"],
  credentials: true,
});

app.use(cors());
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
app.use(express.json({ extended: true, limit: "10mb" }));

io.on("connection", (socket) => {
  socket.on("FromAPI", (data) => {
    socket.broadcast.emit("FromAPI", data);
    socket.on("disconnect", () => {
      console.log("クライアント接続が切れました");
    });
  });
});

server.listen(port, () => {
  console.log(`listening on *:${port}`);
});
```

<p>サーバーですが、herokuだとラグが気になったのでさくらvpnに立てました。KUSANAGIというOSを使うと一瞬で秘密鍵やssl設定ができます。月800円程の定額なので、テスト用にひとつ立てておくと便利です。また、"(node) warning: possible EventEmitter memory leak detected. 11 listeners added. Use emitter.setMaxListeners() to increase limit."がログ表示されるのでrequire("events").EventEmitter.defaultMaxListeners = 0;で対応しています。</p>



<h1>（３）使った技術</h1>

<p>
・react-native<br>
・expo 42.0.1<br>
・expo-gl 10.4.2<br>
・expo-three 6.0.1<br>
・gsap 3.6.0<br>
・three 0.132.0　←　今回のコードはバージョンによってはエラーになります<br> 
・base-64 1.0.0<br>
・expo-asset 8.3.3<br>
・socket.io-client 4.4.0 ←　Socket通信用に追加



<p>別端末の相手の3Dキャラクターを追加、表示させます。</p>

<p>参考にしたサイト</a><br>
<a href="https://threejs.org/">Three.js公式</a><br>
<a href="https://docs.expo.dev/versions/latest/sdk/gl-view/">Expo公式 GLView</a><br>
<a href="https://www.mixamo.com/">Mixamo公式</a><br>
<a href="https://socket.IO/">Socket.io公式</a><br><br>

React Native（Expo）のSocket通信についてはあまり情報がなく、実機でのテスト環境では問題なく動作しましたが、ストアにリリースした場合の動作は検証できていませんのでご了承ください。</p>

<h1>（４）3Dモデル表示</h1>
<p>バーチャルスティックは前回同様です。今回は相手の3Dモデルを追加でMixamoで作成し読み込ませます。設定するanimationは自身の3Dモデルと同様になります。自身のモデルをmodelsA 相手をmodelsBとしました。socketについてはuseRefを使い参照しています。このままではログ（
gl.pixelStorei() doesn't support this parameter yet! from TextureLoader）
が大量に出ますのでnode_modulesにある/three/build/three.js 16527行目からの下記3行をコメントアウトしておきます。※github expo-threeのissue#196参照
</p>

```js
// _gl.pixelStorei(_gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, texture.premultiplyAlpha);
// _gl.pixelStorei(_gl.UNPACK_ALIGNMENT, texture.unpackAlignment);
// _gl.pixelStorei(_gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, _gl.NONE);
```

できる限りそのままのコードにコメントしました。

```js
//App.js
import React, { useState, useEffect, useRef } from "react";
import { View } from "react-native";
import { GLView } from "expo-gl";
import { Renderer } from "expo-three";
import { TweenMax } from "gsap";
import {
  PointLight,
  GridHelper,
  PerspectiveCamera,
  Scene,
  AnimationMixer, // アニメーションのため追加
  Clock, // アニメーションのため追加
} from "three";
import Positon from "./Position"; // バーチャルスティックのjs
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
import { Asset } from "expo-asset"; // ファイル読み込みのため追加
import io from "socket.io-client"; // クライアント用Socket.io

export default function App() {
  const [cameras, setCameras] = useState(null);
  const [modelsA, setModelsA] = useState(null); // 自身の3Dモデルをセットする変数
  const [walkA, setWalkA] = useState(true); // 自身のアニメーションをセットする変数
  const [modelsB, setModelsB] = useState(null); // 相手の3Dモデルをセットする変数
  const [walkB, setWalkB] = useState(true); // 相手のアニメーションをセットする変数
  const [action, setAction] = useState({ z: 0, x: 0 }); // 自身のキャラクターの座標をセットする変数
  const socketRef = useRef();

  useEffect(() => {
    // サーバーのアドレス
    const socket = io("＜サーバーのアドレス＞");
    socket.on("connect", () => {
    // 接続されたときにFromAPIから座標と回転、歩いているか否かの値を受け取る
      socket.on("FromAPI", (data) => {
        // 相手のキャラクターに値をセットする
        modelsB.position.set(data.x, 0, data.z);
        modelsB.rotation.y = data.y;
        walkB.paused = data.w;
        walkB.play();
      });
    });
    socket.on("disconnect", () => {
      console.log("接続が切れました");
    });
    socket.on("connect", () => {
      console.log("接続されました");
    });
    socketRef.current = socket;
    return () => socket.disconnect();
  }, [modelsB]);

  // FromAPIと名付けて自身のキャラクターの座標と回転、歩いているか否かの値をサーバーに送る
  const send = (props) => {
    socketRef.current.emit("FromAPI", {
      x: props.x,
      y: props.y,
      z: props.z,
      w: props.w,
    });
  };

  const walk = () => {
    // 自身のキャラクターとカメラの視点を同時に座標移動させて三人称視点にする
　  // TweenMax.to([何と、何が], { z軸にaction分移動 })
    TweenMax.set([modelsA.position,cameras.position], {
      z: `+= ${action.z}`,
      x: `+= ${action.x}`,
    });
    // Math.atan2で算出したradianに1.5を加算し前後左右にいい感じで向くようにする
    modelsA.rotation.y = Math.atan2(-action.z, action.x) + 1.5;
    // サーバーに自身のキャラクターの座標と回転、歩いているか否かの値を関数sendに渡す
    send({
      x: modelsA.position.x,
      y: modelsA.rotation.y,
      z: modelsA.position.z,
      w: walkA.paused,
    });
  };

  const move = (props) => {
    walkA.paused = false; // キャラクターのポーズを解除
    walkA.play(); // // アニメーションである変数walkを再生
    setAction({ z: props.y, x: props.x }); // Position.jsから受け取った座標を変数actionにセット
    walk(); // walk関数を実行
  };
  // Position.jsから画面から指を離すことで発火する
  const end = () => {
    // アニメーションをストップ
    walkA.paused = true;
    // ストップした時の自身のキャラクターの座標と回転、歩いているか否かの値をsend関数に渡す
    send({
      x: modelsA.position.x,
      y: modelsA.rotation.y,
      z: modelsA.position.z,
      w: walkA.paused,
    });
  };

  return (
    <>
      <View style={{ flex: 1 }}>
        <GLView
          style={{ flex: 1 }}
          onContextCreate={async (gl) => {
            // 3D空間の準備
            const { drawingBufferWidth: width, drawingBufferHeight: height } =
              gl;
            const renderer = new Renderer({ gl }); // レンダラーの準備
            renderer.setSize(width, height); // 3D空間の幅と高さ
            renderer.setClearColor("white"); // 3D空間の配色
            const scene = new Scene(); // これが3D空間
            scene.add(new GridHelper(100, 100)); //グリッドを表示

            // GLTFをロードする
            const loader = new GLTFLoader();
            // 自身のキャラクターを設置
            const assetA = Asset.fromModule(require("./assets/testA.glb"));
            await assetA.downloadAsync();
            let mixerA;
            let clockA = new Clock();
            loader.load(
              assetA.uri || "",
              (gltf) => {
                const modelA = gltf.scene;
                modelA.position.set(0, 0, 0); // 配置される座標 (x,y,z)
                modelA.rotation.y = Math.PI;
                const animations = gltf.animations;
                //Animation Mixerインスタンスを生成
                mixerA = new AnimationMixer(modelA);
                // 設定した一つ目のアニメーションを設定
                let animation = animations[0];
                // アニメーションを変数walkにセット
                setWalkA(mixerA.clipAction(animation));
                // testA.glbを3D空間に追加
                scene.add(modelA);
                setModelsA(modelA);
              },
              (xhr) => {
                console.log("ロード中");
              },
              (error) => {
                console.error("読み込めませんでした");
              }
            );
            // 相手のキャラクターを設置
            let mixerB;
            let clockB = new Clock();
            const assetB = Asset.fromModule(require("./assets/testB.glb"));
            await assetB.downloadAsync();
            loader.load(
              assetB.uri || "",
              (gltf) => {
                const modelB = gltf.scene;
                modelB.position.set(0, 0, 0); // 配置される座標 (x,y,z)
                modelB.rotation.y = Math.PI;
                const animations = gltf.animations;
                //Animation Mixerインスタンスを生成
                mixerB = new AnimationMixer(modelB);
                // 設定した一つ目のアニメーションを設定
                let animation = animations[0];
                // アニメーションを変数walkにセット
                setWalkB(mixerB.clipAction(animation));
                // testB.glbを3D空間に追加;
                scene.add(modelB);
                setModelsB(modelB);
              },
              (xhr) => {
                console.log("ロード中");
              },
              (error) => {
                console.error("読み込めませんでした");
              }
            );

            // 3D空間の光！
            const pointLight = new PointLight(0xffffff, 2, 1000, 1); //一点からあらゆる方向への光源(色, 光の強さ, 距離, 光の減衰率)
            pointLight.position.set(0, 200, 200); //配置される座標 (x,y,z)
            scene.add(pointLight); //3D空間に追加
            // カメラが映し出す設定(視野角, アスペクト比, near, far)
            const camera = new PerspectiveCamera(45, width / height, 1, 1000);
            setCameras(camera);
            // カメラの初期座標
            let cameraInitialPositionX = 0;
            let cameraInitialPositionY = 2;
            let cameraInitialPositionZ = 7;
            // カメラの座標
            camera.position.set(
              cameraInitialPositionX,
              cameraInitialPositionY,
              cameraInitialPositionZ
            );

            const render = () => {
              requestAnimationFrame(render); // アニメーション　moveUd関数、moveLr関数でカメラ座標が移動
              renderer.render(scene, camera); // レンダリング
              //Animation Mixerを自身と相手ともに実行
              if (mixerA) {
                mixerA.update(clockA.getDelta());
              }
              if (mixerB) {
                mixerB.update(clockB.getDelta());
              }
              gl.endFrameEXP(); // 現在のフレームを表示する準備ができていることをコンテキストに通知するpresent (Expo公式)
            };
            render();
          }}
        />
      </View>
      <View style={{ flexDirection: "row", alignSelf: "center" }}>
        <Positon
          // Position.jsからonMoveを受け取ってmove関数を実行
          onMove={(data) => {
            move({
              x: (data.x - 60) / 1000,
              y: (data.y - 60) / 1000,
            });
          }}
          // Position.jsからonEndを受け取ってend関数を実行
          onEnd={end}
        />
      </View>
    </>
  );
}

```

<h1>（５）終わりに</h1>

<p>座標と向き、歩いているか否かをSocket通信で同期することで、React Nativeでのマルチプレイが可能になりました。VRoidで作成したキャラクターを使ったチャットやゲームも作れそうです。とはいえ、そのようなアプリはUnityで作成することが多いかと思います。Reactで作成するメリットがあれば良いな:hugging:</p>