73
Help us understand the problem. What are the problem?

posted at

updated at

Organization

React + TypeScript + Three.js を使って「200行ぐらいで書ける」簡単な3Dゲームを作ってみた

はじめに

かけだしバックエンドエンジニアの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座標に直接代入するとマウスカーソルの位置とプレイヤーの位置にずれが出てしまいます。

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

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

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

赤線(hypotenuse)の求め方

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

緑線の求め方

緑線を求めるには上空からではなく横から位置関係を見る必要があります。
きーた用の図横.png

 赤線(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

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
73
Help us understand the problem. What are the problem?