好得很程序员自学网

<tfoot draggable='sEl'></tfoot>

我使用 React + TypeScript + Three.js 制作了一个可以用大约 200 行

はじめに

かけだしバックエンドエンジニアのhiです。
最近、JavaScriptで簡単に3D描画ができるライブラリ「Three.js」に興味を持って触っていました。どうせならなんか作ろうと思い簡単なゲームを作成してみました。よかったら見てやってください。

ゲーム↓

ソース↓

作成環境

React:18.2.0
TypeScript:4.7.4
Three.js:0.143.0

作り方

0.前提

作り方を理解するには、React、TypeScript、Three.jsがある程度わかるぐらいの知識が必要となります。
特にReact、TypeScriptの知識がないと「???」ってなるので事前に他の記事などで勉強することをおすすめします。
Three.jsについては、↓のサイトに詳しい情報を載せてくださっている神様がいらっしゃいますので恭しく(うやうやしく)確認していただければと思います。
https://ics.media/tutorial-three/quickstart/

1.React、TypeScriptを導入

create-react-appを使用してReact、TypeScriptを導入します。やり方は↓の記事を参考にしてください。(ちなみに筆者は1.プロジェクトの作成までを参考にしました。)
https://qiita.com/sanogemaru/items/05c2e9381d6ba2d9fccf

2.Three.jsをインストール

 $ yarn install --save three
 

3.ゲームの実装

3.1.前処理

/src/index.tsxを開き、root.render()内の処理を変更。

src/index.tsx

  root  .  render  ( 
   //strictモードでは、開発時のみ処理が2回実行される。今回の開発では2回実行されるとうまく動かないのでコメントアウトしておく 
   // <React.StrictMode> 
   <  App   />    //メイン処理を行うコンポーネント 
   // </React.StrictMode> 
 ); 
 

3.2.メイン処理

/src/componentsフォルダ配下にGame.tsxを作成し、使用するライブラリをimport

src/components/Game.tsx

  import   {   memo  ,   useEffect  ,   useRef  ,   useState   }   from   "  react  "  ; 
 import   *   as   THREE   from   '  three  ' 
 

 
処理で使用する型を定義します。

src/components/Game.tsx

  //ボックスの状態 
 type   EnemyBox   =   { 
     box  :   THREE  .  Mesh  ;   //ボックスのメッシュ 
     isMoveing  :   boolean  ;   //移動状態(true: 移動中, false: 停止中) 
 } 

 //マウスカーソルの座標 
 type   MousPos   =   { 
     x  :   number  ;    //X軸座標 
     y  :   number  ;    //Y軸座標 
 } 
 

 
Gameクロージャの中にゲーム動作に関わる全ての処理を書いていきます。

src/components/Game.tsx

  export   const   Game   =   memo  (()   =>   { 

   //ここにこれから説明する全ての処理を書いていきます 

 }) 
 

 
まずは、ゲームで使用する定数を設定していきます。

src/components/Game.tsx

      const   DISPLAY_WIDTH  :   number   =   960  ;   //ゲーム表示枠横幅 
     const   DISPLAY_HEIGHT  :   number   =   540  ;   //ゲーム表示枠高さ 
     const   FIELD_LIMIT  :   number   =   500  ;   //ゲームで移動できる高さ(実際は +FIELD_LIMIT ~ -FIELD_LIMIT) 
     const   BOX_HALF_SIZE  :   number   =   100  ;   //ボックスのサイズ(実際は +BOX_HALF_SIZE ~ -BOX_HALF_SIZE) 
     const   BOX_START_POSITION_Z  :   number   =   -  2000  ;   //ボックスのスタートポジション 
     const   BOX_MOVEMENT  :   number   =   10  ;   //ボックスの移動距離 
     const   BOX_APPEARANCE_RATE  :   number   =   0.006  ;   //ボックス出現率(0~1) 
     const   CAMERA_FIELD_OF_VIEW  :   number   =   90  ;   //カメラの視野(度) 
     const   CAMERA_POSITION_X  :   number   =   500  ;   //カメラのポジション(X軸) 
     const   CAMERA_POSITION_Z  :   number   =   1000  ;   //カメラのポジション(Z軸) 
     const   PLAYER_HALF_SIZE  :   number   =   35  ;   //プレイヤーの半径 
     const   PLAYER_POSITION_Z  :   number   =   500  ;   //プレイヤーのポジション(Z軸) 
     const   HIT_PLAY  :   number   =   15  ;   //当たり判定のあそび(通常の当たり判定はシビアすぎて面白くないため) 
 

 
レンダリングに関わるものはuseStateで宣言し、レンダリングに関わらないものはuseRefで宣言します。

src/components/Game.tsx

      //ゲームオーバーを保持するステート 
     const   [  gameOver  ,   setGameOver  ]   =   useState  <  boolean  >  (  false  ); 

     //canvas要素を保持 
     const   canvasRef   =   useRef  <  HTMLCanvasElement  >  (  HTMLCanvasElement  .  prototype  ); 
     //マウスカーソルの座標を保持 
     const   mousePositionYRef   =   useRef  <  number  >  (  0  ); 
     //ゲームオーバー状態を保持 
     const   gameOverRef   =   useRef  <  boolean  >  (  true  ); 
     //プレイ時間を保持 
     const   timeRef   =   useRef  <  number  >  (); 

     //スタート状態をステートからuseRefに代入(更新されたステートをタイマー処理内で使用するため) 
     gameOverRef  .  current   =   gameOver  ; 
 

 
useEffect内に以下の処理を実装していきます。
 ①マウスカーソルの座標取得イベント
 ②3Dモデルのレンダリング処理
 ③3Dモデルの移動処理 + 当たり判定

src/components/Game.tsx

      useEffect  (()   =>   { 

       //ここに処理を書いていきます 
 
     },   []) 
 

 
①マウスカーソルの座標取得イベント を実装します。
・マウスカーソルのY座標を[+500 ~ -500]で変化するよう調整します。(画面上端が+500,下端が-500)
・プレイヤーの移動とマウスカーソルの移動を調整する倍率は、プレイヤーの移動処理で使用するのであらかじめ計算しておきます。
 ※”calc()”の詳しい内容については実装箇所で解説します。

src/components/Game.tsx

          //マウスムーブイベントを定義 
         canvasRef  .  current  .  addEventListener  (  '  mousemove  '  ,   function   (  evt  )   { 
             let   mousePos  :   MousPos   =   getMousePosition  (  canvasRef  .  current  ,   evt  ); 
             //プレイヤーの中心からのY座標 
             mousePositionYRef  .  current   =   ((  DISPLAY_HEIGHT   /   2  )   -   mousePos  .  y  )   *   (  FIELD_LIMIT   *   2   /   DISPLAY_HEIGHT  ); 
         },   false  ); 

         //プレイヤーの移動とマウスカーソルの移動を調整する倍率を計算 
         const   movementMagnification  :   number   =   calc  (); 
 

 
②3Dモデルのレンダリング処理
 ここではThree.jsの基本である「レンダラーの作成」「シーンの作成」「カメラの作成」「オブジェクトの作成」「光源の作成」を行っています。

src/components/Game.tsx

          //レンダラーを作成 
         const   renderer  :   THREE  .  WebGLRenderer   =   new   THREE  .  WebGLRenderer  ({ 
             canvas  :   canvasRef  .  current  , 
         }); 
         renderer  .  setPixelRatio  (  window  .  devicePixelRatio  ); 
         renderer  .  setSize  (  DISPLAY_WIDTH  ,   DISPLAY_HEIGHT  ); 

         // シーンを作成 
         const   scene  :   THREE  .  Scene   =   new   THREE  .  Scene  (); 

         // カメラを作成 
         const   camera  :   THREE  .  PerspectiveCamera   =   new   THREE  .  PerspectiveCamera  (  CAMERA_FIELD_OF_VIEW  ,   DISPLAY_WIDTH   /   DISPLAY_HEIGHT  ,   0.1  ,   2000  ); 
         camera  .  position  .  set  (  CAMERA_POSITION_X  ,   0  ,   CAMERA_POSITION_Z  ); 

         //天井を作成(オブジェクト) 
         const   roofGeometry  :   THREE  .  BoxGeometry   =   new   THREE  .  BoxGeometry  (  400  ,   FIELD_LIMIT   *   2  ,   8000  ); 
         const   roofMaterial  :   THREE  .  MeshBasicMaterial   =   new   THREE  .  MeshBasicMaterial  ({   color  :   0xFFFFFF   }); 
         const   roof  :   THREE  .  Mesh   =   new   THREE  .  Mesh  (  roofGeometry  ,   roofMaterial  ); 
         roof  .  position  .  set  (  0  ,   FIELD_LIMIT   *   2  ,   -  3000  ) 
         scene  .  add  (  roof  ); 
         //床を作成(オブジェクト) 
         const   floorGeometry  :   THREE  .  BoxGeometry   =   new   THREE  .  BoxGeometry  (  400  ,   FIELD_LIMIT   *   2  ,   8000  ); 
         const   floorMaterial  :   THREE  .  MeshBasicMaterial   =   new   THREE  .  MeshBasicMaterial  ({   color  :   0xFFFFFF   }); 
         const   floor  :   THREE  .  Mesh   =   new   THREE  .  Mesh  (  floorGeometry  ,   floorMaterial  ); 
         floor  .  position  .  set  (  0  ,   -  FIELD_LIMIT   *   2  ,   -  3000  ) 
         scene  .  add  (  floor  ); 

         //プレイヤーを作成(オブジェクト) 
         const   playerGeometry  :   THREE  .  SphereGeometry   =   new   THREE  .  SphereGeometry  (  PLAYER_HALF_SIZE  ,   50  ,   50  ); 
         const   playerMaterial  :   THREE  .  MeshToonMaterial   =   new   THREE  .  MeshToonMaterial  ({   color  :   '  red  '   }); 
         const   player  :   THREE  .  Mesh   =   new   THREE  .  Mesh  (  playerGeometry  ,   playerMaterial  ); 
         player  .  position  .  set  (  0  ,   -  (  FIELD_LIMIT   -   PLAYER_HALF_SIZE  ),   PLAYER_POSITION_Z  ) 
         scene  .  add  (  player  ); 

         // 平行光源を作成 
         const   directionalLight  :   THREE  .  DirectionalLight   =   new   THREE  .  DirectionalLight  (  '  red  '  ); 
         directionalLight  .  position  .  set  (  1  ,   1  ,   1  ); 
         scene  .  add  (  directionalLight  ); 

         //ボックスを10個作成(オブジェクト) 
         let   boxs  :   Array  <  EnemyBox  >   =   []; 
         let   enemyBox  :   EnemyBox  ; 
         for   (  let   i   =   0  ;   i   <   10  ;   i  ++  )   { 
             const   boxGeometry  :   THREE  .  BoxGeometry   =   new   THREE  .  BoxGeometry  (  BOX_HALF_SIZE   *   2  ,   BOX_HALF_SIZE   *   2  ,   BOX_HALF_SIZE   *   2  ); 
             const   boxMaterial  :   THREE  .  MeshNormalMaterial   =   new   THREE  .  MeshNormalMaterial  (); 
             const   box  :   THREE  .  Mesh   =   new   THREE  .  Mesh  (  boxGeometry  ,   boxMaterial  ); 
             box  .  position  .  set  (  0  ,   generateRundomHeight  (),   BOX_START_POSITION_Z  ) 
             scene  .  add  (  box  ); 
             enemyBox   =   {   box  :   box  ,   isMoveing  :   false   }; 
             boxs  .  push  (  enemyBox  ) 
         } 
 

 
③3Dモデルの移動処理 + 当たり判定
 requestAnimationFrame()関数を使用し、フレームごとにボックスとプレイヤーの移動を行っています。
 ボックスの移動時にプレイヤーとの当たり判定も並行して行われています。(当たり判定がシビアすぎると面白くなくなるので少し余裕を持たせています。)
 当たり判定の方法はシンプルで、各ボックスの位置がプレイヤーの位置と被っていないかを総当たりで確認しています。

src/components/Game.tsx

         //ループイベント起動時の時間を取得 
         let   lastTime  :   number   =   performance  .  now  (); 
         //リザルト用のスタート時間を保持 
         let   startTime  :   number   =   lastTime  ; 
         //ループイベント起動 
         tick  (); 

         // 毎フレーム時に実行されるループイベント 
         function   tick  ()   { 
             //時間ごとの動作量を計算 
             let   nowTime  :   number   =   performance  .  now  () 
             let   time  :   number   =   nowTime   -   lastTime  ; 
             lastTime   =   nowTime  ; 
             const   movement  :   number   =   (  time   /   10  );  //動作量 

             //プレイヤーの移動 
             if   (  mousePositionYRef  .  current   *   movementMagnification   <   (  FIELD_LIMIT   -   PLAYER_HALF_SIZE  )   &&   mousePositionYRef  .  current   *   movementMagnification   >   -  (  FIELD_LIMIT   -   PLAYER_HALF_SIZE  ))   { 
                 player  .  position  .  y   =   mousePositionYRef  .  current   *   movementMagnification  ; 
             } 

             //ボックスの移動 
             boxs  .  map  ((  value  )   =>   { 
                 //ボックスの移動状態を判定 
                 if   (  value  .  isMoveing   ===   false  )   { 
                     //停止状態ならランダムで移動中状態にする 
                     if   (  Math  .  random  ()   <=   BOX_APPEARANCE_RATE  )   { 
                         value  .  isMoveing   =   true  ; 
                     } 
                 } 
                 else   { 
                     if   (  value  .  box  .  position  .  z   <=   CAMERA_POSITION_Z  )   { 
                         //ボックスを移動 
                         value  .  box  .  position  .  z   +=   BOX_MOVEMENT   *   movement  ; 

                         //当たり判定 
                         if   (  value  .  box  .  position  .  z   +   BOX_HALF_SIZE   -   (  BOX_MOVEMENT   *   movement  )   >=   PLAYER_POSITION_Z   -   PLAYER_HALF_SIZE   +   HIT_PLAY   &&   value  .  box  .  position  .  z   -   BOX_HALF_SIZE   -   (  BOX_MOVEMENT   *   movement  )   <=   PLAYER_POSITION_Z   +   PLAYER_HALF_SIZE   -   HIT_PLAY  )   { 
                             if   (  value  .  box  .  position  .  y   +   BOX_HALF_SIZE   >=   player  .  position  .  y   -   PLAYER_HALF_SIZE   +   HIT_PLAY   &&   value  .  box  .  position  .  y   -   BOX_HALF_SIZE   <=   player  .  position  .  y   +   PLAYER_HALF_SIZE   -   HIT_PLAY  )   { 
                                 //スコアタイムを取得 
                                 timeRef  .  current   =   Math  .  floor  ((  nowTime   -   startTime  )   /   100  )   /   10  ; 
                                 //ゲーム終了 
                                 setGameOver  (  true  ); 
                             } 
                         } 
                     }   else   { 
                         //ボックスの位置をリセット 
                         value  .  box  .  position  .  set  (  0  ,   generateRundomHeight  (),   BOX_START_POSITION_Z  ) 
                         value  .  isMoveing   =   false  ; 
                     } 
                 } 
             }) 

             // 原点方向を見つめる 
             camera  .  lookAt  (  new   THREE  .  Vector3  (  0  ,   0  ,   0  )); 
             // レンダリング 
             renderer  .  render  (  scene  ,   camera  ); 

             //ゲームオーバーならループを抜ける 
             if   (  gameOverRef  .  current  )   { 
                 return  ; 
             } 

             //ループ 
             requestAnimationFrame  (  tick  ); 
         } 
 

useEffect内に実装する処理は以上です。


次にuseEffectの外に処理で使用する関数を定義していきます。

src/components/Game.tsx

      //高さをランダムに生成 
     function   generateRundomHeight  ():   number   { 
         return   (  Math  .  random  ()   -   0.5  )   *   (  FIELD_LIMIT   *   2   -   BOX_HALF_SIZE   *   2  ); 
     } 

     //プレイヤーの位置に対する移動距離の倍率を計算 
     function   calc  ():   number   { 
         //1.角度を計算 
         let   theta  :   number   =   Math  .  atan  ((  CAMERA_POSITION_Z   -   PLAYER_POSITION_Z  )   /   CAMERA_POSITION_X  );   //ラジアン 
         //2.斜辺の長さを計算 
         let   hypotenuse   =   CAMERA_POSITION_X   /   Math  .  cos  (  theta  ); 
         //3.プレイヤー位置の視野の高さを計算 
         let   height   =   (  Math  .  tan  (  CAMERA_FIELD_OF_VIEW   /   2   *   (  Math  .  PI   /   180  ))   *   hypotenuse  ); 

         //プレイヤー位置の視野の高さ / ゲーム表示枠の高さ 
         return   height   /   FIELD_LIMIT  ; 
     } 
 

※calc()関数が何を計算しているかを以下で説明します。

簡単に説明すると、[ プレイヤー位置での視野の高さ(下図の緑線) ] / [ カーソルのY座標の範囲([+500 ~ -500]なので1000) ] を計算しています。
なぜこの計算をする必要があるかというと、カメラから距離が離れているほど視野が広くなり見えているY座標の範囲が大きくなるからです。
なのでプレイヤーがカメラから離れているほど、マウスカーソルのY座標[+500 ~ -500]をプレイヤーのY座標に直接代入するとマウスカーソルの位置とプレイヤーの位置にずれが出てしまいます。

 では、[ プレイヤー位置での視野の高さ(下図の緑線) ]はどう求めるのでしょうか?
 まずは図にして求め方を調べていきましょう。

カメラ位置と緑線の位置関係は上空から見ると下図のようになります。

今回カメラのY座標=0 なので緑線は赤線(緑線の位置からカメラ位置までの距離 )と直交していることになります。そうすると、赤線を求めることができれば緑線の長さを求めることができます。

赤線(hypotenuse)の求め方

①thetaの角度を求めます。(ソースの ”1.角度を計算” に対応)
 斜辺以外の辺の長さが分かっているので三角関数のarctanでthetaを求めることができます。
②赤線(hypotenuse)を求めます。(ソースの ”2.斜辺の長さを計算” に対応)
 底辺とthetaの角度が分かっているので三角関数のcosで赤線(hypotenuse)を求めることができます。

緑線の求め方

緑線を求めるには上空からではなく横から位置関係を見る必要があります。

 赤線(hypotenuse)の長さとカメラの視角が分かっているので三角関数のtanで緑線の半分の長さ求めることができます。
(ソースの ”3.プレイヤー位置の視野の高さを計算” に対応)


以上でcalc()関数の説明は終わりです。実装の説明に戻りたいと思います。


最後になりますがレンダリングを行うコンポーネントを書いていきます。
実装はシンプルで、
①ゲーム画面の表示を行うCanvas要素
②ゲームオーバー画面を構成する要素
で構成されています。
ゲームオーバーかどうかを保持するステートを参照して①②の切り替えを行っています。

src/components/Game.tsx

      return   ( 
         <> 
             { 
                 !  gameOver   ? 
                     <> 
                         <  canvas   ref  =  {  canvasRef  }   /  >
                      <  /  >
                      : 
                     <  div   style  =  {{   height  :   DISPLAY_HEIGHT  ,   width  :   DISPLAY_WIDTH  ,   textAlign  :   "  center  "   }}  > 
                         <  p  >  ゲームオーバー  <  /p  >
                          <  p  >  {  timeRef  .  current  }  秒  <  /p  >
                          <  button   onClick  =  {()   =>   window  .  location  .  reload  ()}  >  リトライ  <  /button  >
                      <  /div  >
              } 
         <  /  >
      ) 
 


これでゲームの説明は終わりです。

4.最後に

Three.jsはシェーダーを自分で書くこともできるので、時間があればそちらも記事を書けたらなーと思っています。

参考文献

https://ics.media/tutorial-three/quickstart/
http://www.opengl-tutorial.org/jp/beginners-tutorials/tutorial-1-opening-a-window/
https://qiita.com/sanogemaru/items/05c2e9381d6ba2d9fccf


原创声明:本文系作者授权爱码网发表,未经许可,不得转载;

原文地址:https://www.likecs.com/show-308626533.html

查看更多关于我使用 React + TypeScript + Three.js 制作了一个可以用大约 200 行的详细内容...

  阅读:55次