0
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?

ReactThreeFiberで射的ゲーム

Last updated at Posted at 2024-12-19

この記事は「C3 Advent Calendar 2024」20日目の記事です。

前書き(略可)

皆様こんにちは、C3 Advent Calendar 20日担当のSemikoronだよ!
さてさて皆さん、まず最初にVRヘッドセット持っていますでしょうか?最近(執筆当時)はMeta quest 3sの登場やVRChatの流行りもありつつ少しずつVRヘッドセットが普及してきています。
そんなVRヘッドセットしっかりとブラウザ機能を備えており、WebXRというWeb上で作り出すVR/ARコンテンツなんてものも沢山あるんですねぇ~~。
という事で今回は、手元にVRヘッドセットあるけど今まで開発難しそうだから手が出せなかったという人向けに射的ゲームが作れる記事を書かせていただきました~~!

完成品(略可)

確認したい方はこちらをご覧ください。
※必ずVR上のブラウザでアクセスしてください!
https://simple-shooting-game.vercel.app/

リポジトリ
https://github.com/Semi-koron/R3FSimpleShootingGame

環境構築(プロジェクト作成まで)

~ご用意頂く環境~

  • Node.js
  • npm
  • vscode <- 本記事では一部エディタのコマンドを利用しておりますが、無視してもらって構いません
  • React.js(vite)
  • google chrome <- VRでローカルサーバーにアクセスしやすい
  • 開発者モードがオンになったVR

またコードエディタの環境構築はこちらの私の記事を参考にして下さい!
こちらの記事はNextのプロジェクトを作成しているので、
必ずviteでReactのプロジェクトを作成するようにしてください。
https://qiita.com/Semikoron/items/2f09e4fc3c2d12c14b12

viteでReactのプロジェクトを作製

以下のようなコードをターミナル上で実行します。
(npmのバージョンが7以上であれば追加で 2 つのダッシュが必要です!)

npm create vite@latest r3fxr-project -- --template react-swc-ts

実行完了したら、プロジェクトのディレクトリに移動します。

cd r3fxr-project
npm install
code .

移動のついでにパッケージのインストールのコマンドも実行されているはずなので、
新しいウィンドウのエディタからターミナルを起動し、以下のコードを実行することによって
ローカルサーバー(開発環境)が立ち上がります。
こちらのコマンドはよく使うので覚えておいてください。

npm run dev

ローカルサーバーが立ち上がったら以下のURLにアクセスしてもらうと
http://localhost:5173/

このような画面が起動すると思います!
image.png
このような画面が起動すれば、プロジェクトの準備は完了です!
ここまで終えた方は一度ターミナルで停止(ctrlキー+cキー)の操作を行い、
ローカルサーバーを閉じましょう。

要らないファイルを削除

プロジェクトを作成すると必ずデフォルトのページが追加されるので、必要のないものを消していきます!

以下のファイルにコードをコピーペーストして下さい。

src/app.tsx
import "./App.css";

function App() {
  return (
    <>
      <h1>Hello World</h1>
    </>
  );
}

export default App;
src/App.css
#root {
  margin: 0 auto;
  text-align: center;
}

R3F関連のパッケージを入れていく

以下のコマンドをターミナル上で実行します

npm install three @react-three/fiber @react-three/xr@latest @react-three/drei @react-three/rapier
npm install --save-dev @types/react

これにより、Reactで3D描画ができる環境が用意できます!

ReactThreeFiberについて(省略可)

Web上での3D描画には必須のライブラリThreejsがReactでも使えちゃうという凄いライブラリ。
本記事ではReactThreeFiberはR3Fと一部略して記述しています。

初めての3Dシーンを作ってみる

パッケージをインストールしたことによって
以下のコードを記述しているファイルにコピペすることで、
Web上で3D描画ができます!

src/App.tsx
import "./App.css";
import { Canvas } from "@react-three/fiber";
import { OrbitControls, Plane } from "@react-three/drei";
import * as THREE from "three";

function App() {
  return (
    <>
      <Canvas
        camera={{
          position: [0, 5, 8],
          fov: 50,
          near: 0.1,
          far: 2000,
        }}
        dpr={window.devicePixelRatio}
        shadows
        style={{ width: "100vw", height: "100vh" }}
      >
        {/* 背景色 */}
        <color attach="background" args={["#DDDDDD"]} />
        {/* カメラの操作 */}
        <OrbitControls />
        {/* ライトの設定 */}
        <ambientLight intensity={0.1} />
        <directionalLight
          position={[2, 6, 4]}
          intensity={1}
          shadow-mapSize-width={2048}
          shadow-mapSize-height={2048}
          castShadow
        />
        {/* オブジェクト */}
        <mesh position={[0, 1, 0]} castShadow>
          <boxGeometry args={[1, 1, 1]} />
          <meshStandardMaterial color="#f00" />
        </mesh>
        {/* 床 */}
        <Plane rotation={[-Math.PI / 2, 0, 0]} args={[10, 10]} receiveShadow>
          <meshStandardMaterial color="#fff" side={THREE.DoubleSide} />
        </Plane>
      </Canvas>
    </>
  );
}

export default App;

ローカルサーバーを起動して、
localhost:5173にアクセスすると…
R3F_3d_scene.gif

しっかりと3D描画できていますね!
React Three fiberには合わせて使えるライブラリがたくさんあり、
今回はreact-three/dreiを使うことで、簡単にマウスでカメラを動かす機能を実装しています。

物理エンジンを入れていく

さて次は物理エンジンを実装していきましょう!物理エンジン…難しそう…とお思いの方も多いと思います。
しかしですね、今回利用するrapierというライブラリで簡単に物理エンジンが実装できてしまうのです!
という事で物理エンジンを実装して、キューブに重力を与えてみましょう!

差分コード
src/App.tsx
import "./App.css";
import { Canvas } from "@react-three/fiber";
import { OrbitControls, Plane } from "@react-three/drei";
+ import { Physics, RigidBody } from "@react-three/rapier";
import * as THREE from "three";

function App() {
  return (
    <>
      <Canvas
        camera={{
          position: [0, 5, 8],
          fov: 50,
          near: 0.1,
          far: 2000,
        }}
        dpr={window.devicePixelRatio}
        shadows
        style={{ width: "100vw", height: "100vh" }}
      >
        {/* 背景色 */}
        <color attach="background" args={["#DDDDDD"]} />
        {/* カメラの操作 */}
        <OrbitControls />
        {/* ライトの設定 */}
        <ambientLight intensity={0.1} />
        <directionalLight
          position={[2, 6, 4]}
          intensity={1}
          shadow-mapSize-width={2048}
          shadow-mapSize-height={2048}
          castShadow
        />
        {/* オブジェクト */}
+        <Physics gravity={[0, -9.81, 0]}>
+          <RigidBody type="dynamic">
            <mesh position={[0, 1, 0]} castShadow>
              <boxGeometry args={[1, 1, 1]} />
              <meshStandardMaterial color="#f00" />
            </mesh>
+          </RigidBody>
+          <RigidBody type="fixed">
            {/* 床 */}
            <Plane
              rotation={[-Math.PI / 2, 0, 0]}
              args={[10, 10]}
              receiveShadow
            >
              <meshStandardMaterial color="#fff" side={THREE.DoubleSide} />
            </Plane>
+          </RigidBody>
+        </Physics>
      </Canvas>
    </>
  );
}

export default App;


コピペ用ソースコード
src/App.tsx
import "./App.css";
import { Canvas } from "@react-three/fiber";
import { OrbitControls, Plane } from "@react-three/drei";
import { Physics, RigidBody } from "@react-three/rapier";
import * as THREE from "three";

function App() {
  return (
    <>
      <Canvas
        camera={{
          position: [0, 5, 8],
          fov: 50,
          near: 0.1,
          far: 2000,
        }}
        dpr={window.devicePixelRatio}
        shadows
        style={{ width: "100vw", height: "100vh" }}
      >
        {/* 背景色 */}
        <color attach="background" args={["#DDDDDD"]} />
        {/* カメラの操作 */}
        <OrbitControls />
        {/* ライトの設定 */}
        <ambientLight intensity={0.1} />
        <directionalLight
          position={[2, 6, 4]}
          intensity={1}
          shadow-mapSize-width={2048}
          shadow-mapSize-height={2048}
          castShadow
        />
        {/* オブジェクト */}
        <Physics gravity={[0, -9.81, 0]}>
          <RigidBody type="dynamic">
            <mesh position={[0, 1, 0]} castShadow>
              <boxGeometry args={[1, 1, 1]} />
              <meshStandardMaterial color="#f00" />
            </mesh>
          </RigidBody>
          <RigidBody type="fixed">
            {/* 床 */}
            <Plane
              rotation={[-Math.PI / 2, 0, 0]}
              args={[10, 10]}
              receiveShadow
            >
              <meshStandardMaterial color="#fff" side={THREE.DoubleSide} />
            </Plane>
          </RigidBody>
        </Physics>
      </Canvas>
    </>
  );
}

export default App;

こうすることによってローカルサーバーで確認をしてみると…
R3F_3d_phsics.gif
あっという間に物理エンジンが完成しましたね!

VRでシーンに入る

VRでローカルサーバーにアクセスする

まずはVRヘッドセットとパソコンを接続しましょう。
今回私が使用しているのはmeta quest3なので、meta quest3ベースで話を進めさせていただきます。
おそらく接続した際にPCからのアクセスを許可するかどうかの通知がVRヘッドセット上で表示されると思います。
表示されたら許可を押してください。

またここではgoogle chromeを使用してもらうことになるのですが、
chrome://inspect/#devices
へアクセスしてください。

すると以下のような画面が表示されると思います。
image.png
この画面の「Port fowarding...」を押すと以下のような設定画面が出てくると思います。
image.png
こちらのPortに5173とIp address and portにlocalhost:5173を設定し、
「Done」を押します。
するとVRヘッドセット上のブラウザでローカルサーバーにアクセスできます。

VRでシーンに入れる機能を作る

VRで作成したシーンに入るにはライブラリのthree-react/xrを使用します。
たったXRのstoreの初期化を行って、Canvasタグ配下をXRタグで覆ってあげることで、
VRでシーンに入る準備はほぼ完了です。

以下のコードを記述しているファイルにコピペすることで、
VRヘッドセットからアクセスした際にVRでシーンに入ることが可能になります。

差分コード
src/App.tsx
import "./App.css";
import { Canvas } from "@react-three/fiber";
import { OrbitControls, Plane } from "@react-three/drei";
import { Physics, RigidBody } from "@react-three/rapier";
+ import { createXRStore, XR } from "@react-three/xr";
import * as THREE from "three";

+ const store = createXRStore();

function App() {
  return (
    <>
+      <button
+        onClick={() => store.enterVR()}
+        style={{
+          position: "fixed",
+          top: "10px",
+          left: "10px",
+          zIndex: 1000,
+          height: "100px",
+          width: "200px",
+        }}
+      >
+        Enter VR
+      </button>
      <Canvas
        camera={{
          position: [0, 5, 8],
          fov: 50,
          near: 0.1,
          far: 2000,
        }}
        dpr={window.devicePixelRatio}
        shadows
        style={{ width: "100vw", height: "100vh" }}
      >
+        <XR store={store}>
          {/* 背景色 */}
          <color attach="background" args={["#DDDDDD"]} />
          {/* カメラの操作 */}
          <OrbitControls />
          {/* ライトの設定 */}
          <ambientLight intensity={0.1} />
          <directionalLight
            position={[2, 6, 4]}
            intensity={1}
            shadow-mapSize-width={2048}
            shadow-mapSize-height={2048}
            castShadow
          />
          {/* オブジェクト */}
          <Physics gravity={[0, -9.81, 0]}>
            <RigidBody type="dynamic">
              <mesh position={[0, 1, 0]} castShadow>
                <boxGeometry args={[1, 1, 1]} />
                <meshStandardMaterial color="#f00" />
              </mesh>
            </RigidBody>
            <RigidBody type="fixed">
              {/* 床 */}
              <Plane
                rotation={[-Math.PI / 2, 0, 0]}
                args={[10, 10]}
                receiveShadow
              >
                <meshStandardMaterial color="#fff" side={THREE.DoubleSide} />
              </Plane>
            </RigidBody>
          </Physics>
+        </XR>
      </Canvas>
    </>
  );
}

export default App;

コピペ用ソースコード
src/App.tsx
import "./App.css";
import { Canvas } from "@react-three/fiber";
import { OrbitControls, Plane } from "@react-three/drei";
import { Physics, RigidBody } from "@react-three/rapier";
import { createXRStore, XR } from "@react-three/xr";
import * as THREE from "three";

const store = createXRStore();

function App() {
  return (
    <>
      <button
        onClick={() => store.enterVR()}
        style={{
          position: "fixed",
          top: "10px",
          left: "10px",
          zIndex: 1000,
          height: "100px",
          width: "200px",
        }}
      >
        Enter VR
      </button>
      <Canvas
        camera={{
          position: [0, 5, 8],
          fov: 50,
          near: 0.1,
          far: 2000,
        }}
        dpr={window.devicePixelRatio}
        shadows
        style={{ width: "100vw", height: "100vh" }}
      >
        <XR store={store}>
          {/* 背景色 */}
          <color attach="background" args={["#DDDDDD"]} />
          {/* カメラの操作 */}
          <OrbitControls />
          {/* ライトの設定 */}
          <ambientLight intensity={0.1} />
          <directionalLight
            position={[2, 6, 4]}
            intensity={1}
            shadow-mapSize-width={2048}
            shadow-mapSize-height={2048}
            castShadow
          />
          {/* オブジェクト */}
          <Physics gravity={[0, -9.81, 0]}>
            <RigidBody type="dynamic">
              <mesh position={[0, 1, 0]} castShadow>
                <boxGeometry args={[1, 1, 1]} />
                <meshStandardMaterial color="#f00" />
              </mesh>
            </RigidBody>
            <RigidBody type="fixed">
              {/* 床 */}
              <Plane
                rotation={[-Math.PI / 2, 0, 0]}
                args={[10, 10]}
                receiveShadow
              >
                <meshStandardMaterial color="#fff" side={THREE.DoubleSide} />
              </Plane>
            </RigidBody>
          </Physics>
        </XR>
      </Canvas>
    </>
  );
}

export default App;

実際にmeta quest 3のブラウザ上からアクセスしてみる。
R3F_first_VR.gif
「Enter VR」ボタンを押すことによって

キューブを発射する機能を作成する

さてさていい感じなってきたので次はコントローラーの操作を追加してみましょう!
コントローラーの操作を可能にするにはXRタグの配下にXROriginタグを追加し、
useXRInputSourceStateを利用することで、コントローラーの操作を読み取れます。

以下のコードをコピペすることで、
コントローラーのグリップボタンを押すことでキューブが自分の手に移動します!

差分コード
src/App.tsx
import "./App.css";
- import { Canvas } from "@react-three/fiber";
+ import { Canvas, useFrame } from "@react-three/fiber";
import { OrbitControls, Plane } from "@react-three/drei";
- import { Physics, RigidBody } from "@react-three/rapier";
+ import { Physics, RigidBody, RapierRigidBody } from "@react-three/rapier";
- import { createXRStore, XR } from "@react-three/xr";
+ import {
+  createXRStore,
+  XR,
+  XROrigin,
+ useXRInputSourceState,
+ } from "@react-three/xr";
+ import { useRef } from "react";
import * as THREE from "three";

const store = createXRStore();

function App() {
+  const ref = useRef<THREE.Group>(null);
+  const cubeRef = useRef<RapierRigidBody>(null);
+
+  const shoot = (direction: THREE.Quaternion, position: THREE.Vector3) => {
+    // キューブを速度0に
+    cubeRef.current?.setLinvel(
+      {
+        x: 0,
+        y: 0,
+        z: 0,
+      },
+      true
+    );

+    // キューブの位置を変更
+    cubeRef.current?.setTranslation(
+      {
+        x: position.x,
+        y: position.y,
+        z: position.z,
+      },
+      true
+    );
+
+    // キューブの向きを変更
+    cubeRef.current?.setRotation(
+      {
+        x: direction.x,
+        y: direction.y,
+        z: direction.z,
+        w: direction.w,
+      },
+      true
+    );
+
+    // 向きに応じて力を加える
+    const force = new THREE.Vector3(0, 0, -0.1);
+    force.applyQuaternion(direction);
+    cubeRef.current?.applyImpulse(
+      {
+        x: force.x,
+        y: force.y,
+        z: force.z,
+      },
+      true
+    );
+  };
+
+  const Locomotion = () => {
+    const leftController = useXRInputSourceState("controller", "left");
+    const rightController = useXRInputSourceState("controller", "right");
+    let isGrabbing = false;
+    useFrame(() => {
+      const leftObject = leftController?.object;
+      const rightObject = rightController?.object;
+      if (leftController && rightController) {
+        const leftSqueezeState = leftController.gamepad["xr-standard-squeeze"];
+        const rightSqueezeState =
+          rightController.gamepad["xr-standard-squeeze"];
+        if (!isGrabbing) {
+          if (leftObject && leftSqueezeState?.state == "pressed") {
+            // 左コントローラーの処理
+            isGrabbing = true;
+            const leftPosition = new THREE.Vector3();
+            leftObject.getWorldPosition(leftPosition);
+            const leftDirection = new THREE.Quaternion();
+            leftObject.getWorldQuaternion(leftDirection);
+            shoot(leftDirection, leftPosition);
+          }
+          if (rightObject && rightSqueezeState?.state == "pressed") {
+            // 右コントローラーの処理
+            isGrabbing = true;
+            const rightPosition = new THREE.Vector3();
+            rightObject.getWorldPosition(rightPosition);
+            const rightDirection = new THREE.Quaternion();
+            rightObject.getWorldQuaternion(rightDirection);
+            shoot(rightDirection, rightPosition);
+          }
+        }
+        //両手が離された時の処理
+        if (
+          rightSqueezeState?.state == "default" &&
+          leftSqueezeState?.state == "default"
+        ) {
+          isGrabbing = false;
+        }
+      }
+    });
+    return <XROrigin ref={ref} />;
+  };
  
  return (
    <>
      <button
        onClick={() => store.enterVR()}
        style={{
          position: "fixed",
          top: "10px",
          left: "10px",
          zIndex: 1000,
          height: "100px",
          width: "200px",
        }}
      >
        Enter VR
      </button>
      <Canvas
        camera={{
          position: [0, 5, 8],
          fov: 50,
          near: 0.1,
          far: 2000,
        }}
        dpr={window.devicePixelRatio}
        shadows
        style={{ width: "100vw", height: "100vh" }}
      >
        <XR store={store}>
          {/* 背景色 */}
          <color attach="background" args={["#DDDDDD"]} />
          {/* カメラの操作 */}
          <OrbitControls />
          {/* ライトの設定 */}
          <ambientLight intensity={0.1} />
          <directionalLight
            position={[2, 6, 4]}
            intensity={1}
            shadow-mapSize-width={2048}
            shadow-mapSize-height={2048}
            castShadow
          />
          {/* オブジェクト */}
          <Physics gravity={[0, -9.81, 0]}>
-            <RigidBody type="dynamic">
+            <RigidBody
+             type="dynamic"
+             ref={cubeRef}
+             position={[0, 1, 0]}
+             rotation={[0, 0, 0]}
+             >
              <mesh position={[0, 1, 0]} castShadow>
-                <boxGeometry args={[1, 1, 1]} />
+                <boxGeometry args={[0.2, 0.2, 0.2]} />
                <meshStandardMaterial color="#f00" />
              </mesh>
            </RigidBody>
            <RigidBody type="fixed">
              {/* 床 */}
              <Plane
                rotation={[-Math.PI / 2, 0, 0]}
                args={[10, 10]}
                receiveShadow
              >
                <meshStandardMaterial color="#fff" side={THREE.DoubleSide} />
              </Plane>
            </RigidBody>
          </Physics>
+         <Locomotion />
        </XR>
      </Canvas>
    </>
  );
}

export default App;

コピペ用ソースコード
src/App.tsx
import "./App.css";
import { Canvas, useFrame } from "@react-three/fiber";
import { OrbitControls, Plane } from "@react-three/drei";
import { Physics, RigidBody, RapierRigidBody } from "@react-three/rapier";
import {
  createXRStore,
  XR,
  XROrigin,
  useXRInputSourceState,
} from "@react-three/xr";
import { useRef } from "react";
import * as THREE from "three";

const store = createXRStore();

function App() {
  const ref = useRef<THREE.Group>(null);
  const cubeRef = useRef<RapierRigidBody>(null);

  const shoot = (direction: THREE.Quaternion, position: THREE.Vector3) => {
    // キューブを速度0に
    cubeRef.current?.setLinvel(
      {
        x: 0,
        y: 0,
        z: 0,
      },
      true
    );

    // キューブの位置を変更
    cubeRef.current?.setTranslation(
      {
        x: position.x,
        y: position.y,
        z: position.z,
      },
      true
    );

    // キューブの向きを変更
    cubeRef.current?.setRotation(
      {
        x: direction.x,
        y: direction.y,
        z: direction.z,
        w: direction.w,
      },
      true
    );

    // 向きに応じて力を加える
    const force = new THREE.Vector3(0, 0, -0.1);
    force.applyQuaternion(direction);
    cubeRef.current?.applyImpulse(
      {
        x: force.x,
        y: force.y,
        z: force.z,
      },
      true
    );
  };

  const Locomotion = () => {
    const leftController = useXRInputSourceState("controller", "left");
    const rightController = useXRInputSourceState("controller", "right");
    let isGrabbing = false;
    useFrame(() => {
      const leftObject = leftController?.object;
      const rightObject = rightController?.object;
      if (leftController && rightController) {
        const leftSqueezeState = leftController.gamepad["xr-standard-squeeze"];
        const rightSqueezeState =
          rightController.gamepad["xr-standard-squeeze"];
        if (!isGrabbing) {
          if (leftObject && leftSqueezeState?.state == "pressed") {
            // 左コントローラーの処理
            isGrabbing = true;
            const leftPosition = new THREE.Vector3();
            leftObject.getWorldPosition(leftPosition);
            const leftDirection = new THREE.Quaternion();
            leftObject.getWorldQuaternion(leftDirection);
            shoot(leftDirection, leftPosition);
          }
          if (rightObject && rightSqueezeState?.state == "pressed") {
            // 右コントローラーの処理
            isGrabbing = true;
            const rightPosition = new THREE.Vector3();
            rightObject.getWorldPosition(rightPosition);
            const rightDirection = new THREE.Quaternion();
            rightObject.getWorldQuaternion(rightDirection);
            shoot(rightDirection, rightPosition);
          }
        }
        //両手が離された時の処理
        if (
          rightSqueezeState?.state == "default" &&
          leftSqueezeState?.state == "default"
        ) {
          isGrabbing = false;
        }
      }
    });
    return <XROrigin ref={ref} />;
  };
  return (
    <>
      <button
        onClick={() => store.enterVR()}
        style={{
          position: "fixed",
          top: "10px",
          left: "10px",
          zIndex: 1000,
          height: "100px",
          width: "200px",
        }}
      >
        Enter VR
      </button>
      <Canvas
        camera={{
          position: [0, 5, 8],
          fov: 50,
          near: 0.1,
          far: 2000,
        }}
        dpr={window.devicePixelRatio}
        shadows
        style={{ width: "100vw", height: "100vh" }}
      >
        <XR store={store}>
          {/* 背景色 */}
          <color attach="background" args={["#DDDDDD"]} />
          {/* カメラの操作 */}
          <OrbitControls />
          {/* ライトの設定 */}
          <ambientLight intensity={0.1} />
          <directionalLight
            position={[2, 6, 4]}
            intensity={1}
            shadow-mapSize-width={2048}
            shadow-mapSize-height={2048}
            castShadow
          />
          {/* オブジェクト */}
          <Physics gravity={[0, -9.8, 0]}>
            <RigidBody
              type="dynamic"
              ref={cubeRef}
              position={[0, 1, 0]}
              rotation={[0, 0, 0]}
            >
              <mesh castShadow>
                <boxGeometry args={[0.2, 0.2, 0.2]} />
                <meshStandardMaterial color="#f00" />
              </mesh>
            </RigidBody>
            <RigidBody type="fixed">
              {/* 床 */}
              <Plane
                rotation={[-Math.PI / 2, 0, 0]}
                args={[10, 10]}
                receiveShadow
              >
                <meshStandardMaterial color="#fff" side={THREE.DoubleSide} />
              </Plane>
            </RigidBody>
          </Physics>
          <Locomotion />
        </XR>
      </Canvas>
    </>
  );
}

export default App;

コピーペーストを行ってVR上でコントローラーのグラップボタンを押すことで
キューブが発射されました!!
R3F_VR_shoot.gif

的を作成する

最後に的を作成していきましょう!
的とキューブが当たったという衝突判定を作るときもreact-three/rapierを使用します。
以下のコードのように衝突判定による的移動を作成することによって、
簡易的な射的ゲームが完成します。

差分コード
src/App.tsx
import "./App.css";
import { Canvas, useFrame } from "@react-three/fiber";
import { OrbitControls, Plane } from "@react-three/drei";
import { Physics, RigidBody, RapierRigidBody } from "@react-three/rapier";
import {
  createXRStore,
  XR,
  XROrigin,
  useXRInputSourceState,
} from "@react-three/xr";
import { useRef } from "react";
import * as THREE from "three";

const store = createXRStore();

function App() {
  const ref = useRef<THREE.Group>(null);
  const cubeRef = useRef<RapierRigidBody>(null);
+  const targetRef = useRef<RapierRigidBody>(null);

  const shoot = (direction: THREE.Quaternion, position: THREE.Vector3) => {
    // キューブを速度0に
    cubeRef.current?.setLinvel(
      {
        x: 0,
        y: 0,
        z: 0,
      },
      true
    );

    // キューブの位置を変更
    cubeRef.current?.setTranslation(
      {
        x: position.x,
        y: position.y,
        z: position.z,
      },
      true
    );

    // キューブの向きを変更
    cubeRef.current?.setRotation(
      {
        x: direction.x,
        y: direction.y,
        z: direction.z,
        w: direction.w,
      },
      true
    );

    // 向きに応じて力を加える
    const force = new THREE.Vector3(0, 0, -0.1);
    force.applyQuaternion(direction);
    cubeRef.current?.applyImpulse(
      {
        x: force.x,
        y: force.y,
        z: force.z,
      },
      true
    );
  };

  const Locomotion = () => {
    const leftController = useXRInputSourceState("controller", "left");
    const rightController = useXRInputSourceState("controller", "right");
    let isGrabbing = false;
    useFrame(() => {
      const leftObject = leftController?.object;
      const rightObject = rightController?.object;
      if (leftController && rightController) {
        const leftSqueezeState = leftController.gamepad["xr-standard-squeeze"];
        const rightSqueezeState =
          rightController.gamepad["xr-standard-squeeze"];
        if (!isGrabbing) {
          if (leftObject && leftSqueezeState?.state == "pressed") {
            // 左コントローラーの処理
            isGrabbing = true;
            const leftPosition = new THREE.Vector3();
            leftObject.getWorldPosition(leftPosition);
            const leftDirection = new THREE.Quaternion();
            leftObject.getWorldQuaternion(leftDirection);
            shoot(leftDirection, leftPosition);
          }
          if (rightObject && rightSqueezeState?.state == "pressed") {
            // 右コントローラーの処理
            isGrabbing = true;
            const rightPosition = new THREE.Vector3();
            rightObject.getWorldPosition(rightPosition);
            const rightDirection = new THREE.Quaternion();
            rightObject.getWorldQuaternion(rightDirection);
            shoot(rightDirection, rightPosition);
          }
        }
        //両手が離された時の処理
        if (
          rightSqueezeState?.state == "default" &&
          leftSqueezeState?.state == "default"
        ) {
          isGrabbing = false;
        }
      }
    });
    return <XROrigin ref={ref} />;
  };
  return (
    <>
      <button
        onClick={() => store.enterVR()}
        style={{
          position: "fixed",
          top: "10px",
          left: "10px",
          zIndex: 1000,
          height: "100px",
          width: "200px",
        }}
      >
        Enter VR
      </button>
      <Canvas
        camera={{
          position: [0, 5, 8],
          fov: 50,
          near: 0.1,
          far: 2000,
        }}
        dpr={window.devicePixelRatio}
        shadows
        style={{ width: "100vw", height: "100vh" }}
      >
        <XR store={store}>
          {/* 背景色 */}
          <color attach="background" args={["#DDDDDD"]} />
          {/* カメラの操作 */}
          <OrbitControls />
          {/* ライトの設定 */}
          <ambientLight intensity={0.1} />
          <directionalLight
            position={[2, 6, 4]}
            intensity={1}
            shadow-mapSize-width={2048}
            shadow-mapSize-height={2048}
            castShadow
          />
          {/* オブジェクト */}
          <Physics gravity={[0, -9.8, 0]}>
            <RigidBody
              type="dynamic"
+              name="bullet"
              ref={cubeRef}
              position={[0, 1, 0]}
              rotation={[0, 0, 0]}
            >
              <mesh castShadow>
                <boxGeometry args={[0.2, 0.2, 0.2]} />
                <meshStandardMaterial color="#f00" />
              </mesh>
            </RigidBody>

+            <RigidBody
+              type="dynamic"
+              ref={targetRef}
+              onCollisionEnter={(event) => {
+                if (
+                  event.colliderObject &&
+                  event.colliderObject.name === "bullet"
+                ) {
+                  targetRef.current?.setTranslation(
+                    {
+                      x: Math.random() * 5 - 2.5,
+                      y: 6,
+                      z: Math.random() * 5 - 2.5,
+                    },
+                    true
+                  );
+                }
+              }}
+              position={[0, 6, -2]}
+              rotation={[0, 0, 0]}
+            >
+              <mesh castShadow>
+                <boxGeometry args={[0.5, 0.5, 0.5]} />
+                <meshStandardMaterial color="#00f" />
+              </mesh>
+            </RigidBody>

            <RigidBody type="fixed">
              {/* 床 */}
              <Plane
                rotation={[-Math.PI / 2, 0, 0]}
                args={[10, 10]}
                receiveShadow
              >
                <meshStandardMaterial color="#fff" side={THREE.DoubleSide} />
              </Plane>
            </RigidBody>
          </Physics>
          <Locomotion />
        </XR>
      </Canvas>
    </>
  );
}

export default App;

コピペ用ソースコード
src/App.tsx
import "./App.css";
import { Canvas, useFrame } from "@react-three/fiber";
import { OrbitControls, Plane } from "@react-three/drei";
import { Physics, RigidBody, RapierRigidBody } from "@react-three/rapier";
import {
  createXRStore,
  XR,
  XROrigin,
  useXRInputSourceState,
} from "@react-three/xr";
import { useRef } from "react";
import * as THREE from "three";

const store = createXRStore();

function App() {
  const ref = useRef<THREE.Group>(null);
  const cubeRef = useRef<RapierRigidBody>(null);
  const targetRef = useRef<RapierRigidBody>(null);

  const shoot = (direction: THREE.Quaternion, position: THREE.Vector3) => {
    // キューブを速度0に
    cubeRef.current?.setLinvel(
      {
        x: 0,
        y: 0,
        z: 0,
      },
      true
    );

    // キューブの位置を変更
    cubeRef.current?.setTranslation(
      {
        x: position.x,
        y: position.y,
        z: position.z,
      },
      true
    );

    // キューブの向きを変更
    cubeRef.current?.setRotation(
      {
        x: direction.x,
        y: direction.y,
        z: direction.z,
        w: direction.w,
      },
      true
    );

    // 向きに応じて力を加える
    const force = new THREE.Vector3(0, 0, -0.1);
    force.applyQuaternion(direction);
    cubeRef.current?.applyImpulse(
      {
        x: force.x,
        y: force.y,
        z: force.z,
      },
      true
    );
  };

  const Locomotion = () => {
    const leftController = useXRInputSourceState("controller", "left");
    const rightController = useXRInputSourceState("controller", "right");
    let isGrabbing = false;
    useFrame(() => {
      const leftObject = leftController?.object;
      const rightObject = rightController?.object;
      if (leftController && rightController) {
        const leftSqueezeState = leftController.gamepad["xr-standard-squeeze"];
        const rightSqueezeState =
          rightController.gamepad["xr-standard-squeeze"];
        if (!isGrabbing) {
          if (leftObject && leftSqueezeState?.state == "pressed") {
            // 左コントローラーの処理
            isGrabbing = true;
            const leftPosition = new THREE.Vector3();
            leftObject.getWorldPosition(leftPosition);
            const leftDirection = new THREE.Quaternion();
            leftObject.getWorldQuaternion(leftDirection);
            shoot(leftDirection, leftPosition);
          }
          if (rightObject && rightSqueezeState?.state == "pressed") {
            // 右コントローラーの処理
            isGrabbing = true;
            const rightPosition = new THREE.Vector3();
            rightObject.getWorldPosition(rightPosition);
            const rightDirection = new THREE.Quaternion();
            rightObject.getWorldQuaternion(rightDirection);
            shoot(rightDirection, rightPosition);
          }
        }
        //両手が離された時の処理
        if (
          rightSqueezeState?.state == "default" &&
          leftSqueezeState?.state == "default"
        ) {
          isGrabbing = false;
        }
      }
    });
    return <XROrigin ref={ref} />;
  };
  return (
    <>
      <button
        onClick={() => store.enterVR()}
        style={{
          position: "fixed",
          top: "10px",
          left: "10px",
          zIndex: 1000,
          height: "100px",
          width: "200px",
        }}
      >
        Enter VR
      </button>
      <Canvas
        camera={{
          position: [0, 5, 8],
          fov: 50,
          near: 0.1,
          far: 2000,
        }}
        dpr={window.devicePixelRatio}
        shadows
        style={{ width: "100vw", height: "100vh" }}
      >
        <XR store={store}>
          {/* 背景色 */}
          <color attach="background" args={["#DDDDDD"]} />
          {/* カメラの操作 */}
          <OrbitControls />
          {/* ライトの設定 */}
          <ambientLight intensity={0.1} />
          <directionalLight
            position={[2, 6, 4]}
            intensity={1}
            shadow-mapSize-width={2048}
            shadow-mapSize-height={2048}
            castShadow
          />
          {/* オブジェクト */}
          <Physics gravity={[0, -9.8, 0]}>
            <RigidBody
              type="dynamic"
              name="bullet"
              ref={cubeRef}
              position={[0, 1, 0]}
              rotation={[0, 0, 0]}
            >
              <mesh castShadow>
                <boxGeometry args={[0.2, 0.2, 0.2]} />
                <meshStandardMaterial color="#f00" />
              </mesh>
            </RigidBody>

            <RigidBody
              type="dynamic"
              ref={targetRef}
              onCollisionEnter={(event) => {
                if (
                  event.colliderObject &&
                  event.colliderObject.name === "bullet"
                ) {
                  targetRef.current?.setTranslation(
                    {
                      x: Math.random() * 5 - 2.5,
                      y: 6,
                      z: Math.random() * 5 - 2.5,
                    },
                    true
                  );
                }
              }}
              position={[0, 6, -2]}
              rotation={[0, 0, 0]}
            >
              <mesh castShadow>
                <boxGeometry args={[0.5, 0.5, 0.5]} />
                <meshStandardMaterial color="#00f" />
              </mesh>
            </RigidBody>

            <RigidBody type="fixed">
              {/* 床 */}
              <Plane
                rotation={[-Math.PI / 2, 0, 0]}
                args={[10, 10]}
                receiveShadow
              >
                <meshStandardMaterial color="#fff" side={THREE.DoubleSide} />
              </Plane>
            </RigidBody>
          </Physics>
          <Locomotion />
        </XR>
      </Canvas>
    </>
  );
}

export default App;

これにて射的ゲーム完成です!!
休憩に無限に生成される青いキューブを壊してみましょう!
R3F_VR_target.gif

まとめ

A-frame、Babylon.jsなど競合のWebXRのライブラリはたくさんあるのだが、ReactThreeFiberの良さと言えば、合わせて使えるライブラリがかなーりたくさんあるため実装が凄く楽です。
若干react-three/xrの公式ドキュメントの記述内容が少ない点は少し残念ですが、僕が使ってきた中で一番といってもいい程Reactとの相性は抜群です。
最後にここまで読んでくれてありがとうございました。
この記事で少しでもVRに興味を持っていただけると幸いです。

明日は僕(@boku_me)さんの「あなたに送る難解プログラミング言語のクリスマスプレゼント」です。お楽しみに!

0
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
0
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?