6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

React NativeAdvent Calendar 2021

Day 18

React NativeでSocket通信しよう

Last updated at Posted at 2021-12-17

(1)はじめに

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

20211216_143200.gif

赤色のキャラクターは自身の端末、青色のキャラクターは別の端末で操作しています。

(2)Socket通信のためのサーバー

まずはnodejsでサーバーを作成します。基本的にはアプリから受け取ったデータを配信するだけの機能です。フレームワークはexpressを利用し、端末から送られてきたデータをsocket.onで取得、socket.broadcast.emitで送信してきた端末以外に配信します。

```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>(3)使った技術</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>(4)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);

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

//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>
    </>
  );
}

(5)終わりに

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

6
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?