3DCG覚書とかとか

参考書のすみっこ

~普段メモとかしても忘れちゃうのでブログにしました~

目指せUnityマスター【STG実装編①】

Q.何この記事?
A.新卒Webエンジニアが、Unityマスターになるまでの過程を描いていく記事。
自作ゲーム実装中...(-""-)。

今回の目玉は...カーソルの位置を向く視点移動、特殊なステージでの弾の射出ロジックとかかな?あと移動操作とかも。

今週やったこと

前の記事でやったところから、プレイヤー操作の改善・攻撃操作・障害物・SE・パーティクルの実装とかやってた。
▼前の記事

kuraudo.hatenablog.jp

進捗見た目

今のところこんな感じ

f:id:kuraudo0309:20200807204706g:plain
プレイヤー基本操作

目次

プレイヤー操作の改善

視点操作

前回は真ん中からのカーソルの移動量で視点移動してたけど、今度は少し改善してplayerからカーソルの位置を向くように戻した。

void FixedUpdate(){
    UpdatePlayerRotation();
}

//前回と変わらない。
//(引っかかってたのはローカルrotationの更新方法だったから、RotateじゃなくてlocalEulerAnglesを直に変えればよいような気もする。)
//前フレームの移動量を戻す -> 現フレームの移動 -> 現フレームの移動量を保存
void UpdatePlayerRotation(){
    transform.Rotate(-prevRotateY);
    float localRotationY = GetLocalRotationY();
    transform.Rotate(new Vector3(0, localRotationY, 0));
    prevRotateY = new Vector3(0, localRotationY, 0);
}

//プレイヤーからカーソルまでのベクトルを取得して、y軸回転だけを返す。
float GetLocalRotationY(){
    Quaternion lookAtCursor = Quaternion.LookRotation(GetLookAtCursor());
    float localRotationY = lookAtCursor.eulerAngles.y;
    return localRotationY;
}

//プレイヤーからカーソルまでのベクトルを返す。zx平面上のみを見るベクトル。
Vector3 GetLookAtCursor(){
    Ray cursorRay = Camera.main.ScreenPointToRay(Input.mousePosition);
    RaycastHit hitinfo;
    //Physics.Raycast(任意方向のRay, out RaycastHit 変数)
    //out は変数の初期化がいらないタイプの参照渡し。だから戻り値とかじゃなくてhitinfoが関数内で直接代入される。
    Physics.Raycast(cursorRay, out hitinfo);

    Vector3 lookDirection = hitinfo.point - transform.position;
    return new Vector3(lookDirection.x, 0, lookDirection.z).nomalized;
}

移動操作の改善

移動のもっさり感を上下移動と左右移動を分離することで改善した。
あとプレイヤー移動範囲をステージ上になるように設定した。
ただちょっと重たいメソッドになってしまったので、改善が必要かも...まぁ後回しでいいか。

▼Playerの親子関係
・PlayerBaseX
 ・PlayerBaseZ
  ・Player

    //動ける範囲を決めるための、Groundオブジェクト
    GameObject ground;
    float boundPositionX;
    float boundRotationX = 20.0f;
    Vector3 boundPosition;
    Vector3 boundRotation;

    //playerBaseXの取得とかは省いてる。
    void Start(){
        ground = GameObject.Find("Ground");
        boundPositionX = (ground.transform.localScale.x - 1.0f) / 2.0f;
        boundPosition = new Vector3(boundPositionX, 0, 0);
        boundRotation = new Vector3(boundRotationX, 0, 0);
    }

    void MoveKeyInput()
    {
        if (IsInputHorizontalMoveKey())
        {
            float horizontalInput = Input.GetAxis("Horizontal");
            Vector3 moveDirection = new Vector3(horizontalInput, 0, 0).normalized;

            if (CanHorizontalMove())
            {
                playerBaseX.transform.Translate(moveDirection * this.speed * Time.fixedDeltaTime, Space.World);
            }
            if(CanHorizontalMove() == false)
            {
                //はみ出し処理
                playerBaseX.transform.Translate(-moveDirection * this.speed * Time.fixedDeltaTime, Space.World);
            }
        }
        if (IsInputVerticalMoveKey())
        {
            float verticalInput = Input.GetAxis("Vertical");
            Vector3 rotateDirection = new Vector3(verticalInput, 0, 0).normalized;

            if (CanVerticalMove())
            {
                playerBaseX.transform.Rotate(rotateDirection * this.rotateSpeed * Time.fixedDeltaTime);
            }
            if(CanVerticalMove()==false)
            {
                //はみ出し処理
                playerBaseX.transform.Rotate(-rotateDirection * this.rotateSpeed * Time.fixedDeltaTime);
            }
        }
    }

    //水平移動可能範囲内にいる?
    bool CanHorizontalMove()
    {
        //Debug.Log("position.x: "+playerBaseX.transform.position.x);
        return Mathf.Abs(playerBaseX.transform.position.x) < boundPositionX;
    }

    //垂直移動可能範囲内にいる?
    bool CanVerticalMove()
    {
        //Debug.Log("rotation.x: "+playerBaseX.transform.localRotation.eulerAngles.x);
        float angleX = playerBaseX.transform.localRotation.eulerAngles.x;
        return !(boundRotationX < angleX && angleX < 360 - boundRotationX);
    }

基本的に特殊な階層構造になってることが前提としてわかってれば、特に躓くようなこともなかった。
ただこれも視点移動と同じように、Translateした後にその移動先が範囲外だったら、フレームが更新される前に基の位置に戻すような処理をしてるから、単純に処理が2倍になってしまう(ボトルネックその②)。

あと注意点は、CanVerticalMove()のところでよく見ると
(制限角度 < 今の角度) かつ (360 - 今の角度)
にしている。これはローカルの角度を調べたら負の値じゃなくて360°からの値になるようだったので、こんな感じの戻り値にしてる。

攻撃操作

プレイヤー側

銃を打ち出すための操作を記述した。

    [SerializeField] GameObject bulletPrefab;
    GameObject playerBaseX;

    IEnumerator shootBulletCoroutine;

    // Start is called before the first frame update
    void Start()
    {
        playerBaseX = GameObject.Find("PlayerBaseX");
    }

    // Update is called once per frame
    void Update()
    {
        ShootBullet();
    }

    void ShootBullet()
    {
        if (Input.GetKeyDown(KeyCode.Mouse0))
        {
            this.shootBulletCoroutine = InvokeInstantiateBullet();
            StartCoroutine(this.shootBulletCoroutine);
        }
        if (Input.GetKeyUp(KeyCode.Mouse0))
        {
            StopCoroutine(this.shootBulletCoroutine);
        }
    }

    void InstantiateBullet()
    {
        GameObject instance = Instantiate(bulletPrefab, GetInstantiatePosition(), GetInstatntiateRotation());
        MoveForward moveForward = instance.GetComponent<MoveForward>();
        //bullet生成時に、PlayerのlocalRotation.yを渡す
        moveForward.OnCreate(transform.localEulerAngles.y);
    }

    //弾を生成するPositionを取得(Player.x)
    Vector3 GetInstantiatePosition()
    {
        return new Vector3(transform.position.x,0,0);
    }

    //弾を生成するRotationを取得(PlayerBaseX.x)
    Quaternion GetInstatntiateRotation()
    {
        return Quaternion.Euler(new Vector3(playerBaseX.transform.rotation.eulerAngles.x, 0, 0));
    }

    IEnumerator InvokeInstantiateBullet()
    {
        while (Input.GetKey(KeyCode.Mouse0))
        {
            InstantiateBullet();
            yield return new WaitForSeconds(0.15f);
        }
    }

この時こだわったのが、「マウスを押しっぱなしにすると連続して射出されること」と「クリックした瞬間に射出されること」だった。
これはShootBullet()に書いてあることがすべてだけど、
1.コルーチンで連続射出を実現
2.毎回コルーチンを生成/破棄することでクリックした瞬間の射出を実現
っていう感じ。

そのほかは、Bulletの階層構造が
・BulletBaseX
 ・Bullet
みたいな感じにPrefab化してるので、Instantiateの座標を決めるときに少し癖のある記述になってる。

あと結構弾の速度を早めに実装してあるので、すり抜け問題をRigidbodyのパラメータにある「Collision Detection」をContinuous Dynamicにすることで改善した(障害物と弾の両方に設定する)。
だけど、おそらくこのパラメータは直線移動を想定されているのか、本ゲームの回転しながら弾が移動する場合にはうまく動作してくれない。なのでFixedUpdateは(あまりよくないが)120FPSで動作させるように変更した。

▼衝突判定の参考

qiita.com

弾側

弾はプレイヤーと同じ向きに射出されるようにしているが、これも特殊なステージのため少し工夫が必要になった。

    private static readonly float DEGREE_TO_RADIAN = (Mathf.PI/180);

    GameObject bullet;
    [SerializeField] float speed=60.0f;

    //角度を度数法で渡すと、speedに合ったrotateSpeedをsetする
    float rotateSpeed;
    float RotateSpeed
    {
        set
        {
            float rotateSpeedMagnitude = speed * Mathf.Cos(value * DEGREE_TO_RADIAN);
            //πr : 180 = rotateSpeedMagnitude : rotateSpeed
            this.rotateSpeed = (180 * rotateSpeedMagnitude) / (Mathf.PI * Ground.GetRadius());
        }
        get { return rotateSpeed; }
    }

    //角度を度数法で渡すと、speedにあったtranslateSpeedをsetする
    float translateSpeed;
    float TranslateSpeed
    {
        set
        {
            this.translateSpeed = speed * Mathf.Sin(value * DEGREE_TO_RADIAN);
        }
        get { return translateSpeed; }
    }

    //Instantiate先から、rotationYを与える。
    public void OnCreate(float rotationY)
    {
        RotateSpeed = rotationY;
        TranslateSpeed = rotationY;
        //Debug.Log("rotateSpeed: " + rotateSpeed + "\ntranslateSpeed: " + translateSpeed);
    }

    // Start is called before the first frame update
    void Start()
    {
        bullet = transform.Find("Bullet").gameObject;
    }

    private void FixedUpdate()
    {
        UpdateTransform();
    }

    void UpdateTransform()
    {
        MoveRotation();
        MovePosition();
    }

    //ステージ表面を回転する(rotation.xを更新)
    void MoveRotation()
    {
        transform.Rotate(Vector3.right, rotateSpeed * Time.fixedDeltaTime);
    }

    //ステージ表面を移動する(transform.xを更新)
    void MovePosition()
    {
        transform.Translate(Vector3.right * translateSpeed * Time.fixedDeltaTime * 0.8f/*補正値*/);
    }

これは順番に説明すると、
1.InstantiateしたオブジェクトでOnCreate(float rotationY)を記述してもらうことで、そこでのプレイヤーのy回転方向を渡す。
2.y回転方向で弾がspeedで設定した速度と等しくなるように、zx平面上のベクトルをそれぞれに分解する。
(画面奥向きがΘ=0°なので、z方向がcos、x方向がsinとなる。)
※このとき分解した移動量と回転速度を対応付けるため、πr : 180 = speed.z : rotateSpeed という感じにしている。r=円筒ステージの半径
3.それぞれを移動させる。

という感じになっている。書いてる最中は複雑だなぁとか思ってたけど、意外と順番にしてみれば単純に見えてくる。ただOnCreateで移動方向の設定がInstantiate先に依存してるのはかなりいただけないので、改善が必要(ボトルネック③)。

障害物の実装

書くことはあんまりないけど...。

    float hp = 2.0f;

    private void OnCollisionEnter(Collision collision)
    {
        if (collision.gameObject.CompareTag("Bullet"))
        {
            Destroy(collision.gameObject);

            this.hp--;
            Debug.Log(this.hp);
            if (this.hp <= 0)
            {
                Destroy(gameObject);
            }
        }
    }

こんな感じで、障害物に当たったらBulletが消えるようにしている。本来ならBulletのほうに設定したほうが良いような気もする。
でもそう思ってBulletのほうにif(collision.gameObject.CompareTag("Obstacle"))みたいな感じで記述しても反応してくれないので、おそらくこういう早い物体に対する衝突判定は障害物のほうに書くのが基本なのかなと思ってる。
そういえば「Create with Code」でもプレイヤーに衝突判定を書いてたから、それも止まっている物体に衝突判定を書いていた。

その他パーティクル/SEの実装

SEとかパーティクルとかは、別途「DestroySoundMaker」みたいなScriptを作ってそれをDestroyするスクリプトに追記するみたいな感じで書いてみてる。

この辺で書き方が全然わからなくなったので、後々オブジェクト指向デザインパターンも学習しておこうと思ってる。(多すぎてうんざりしてくるけど...。)

それとビジュアルに関してなんだけど、エフェクトはキングダムハーツ的な感じで、モデルはスーパーマリオ的な感じのイメージでやりたいと思ってる。
↓はステージ1のイメージをざっと表してみた感じ。雑だけど無いよかましだろう。

▼ゲームイメージ1

f:id:kuraudo0309:20200807221315j:plain
ゲーム画面イメージ

あと仮置きのパーティクルやSEは↓を使った。こういうフリー素材はほんとありがたい。

▼パーティクル

assetstore.unity.com

▼SE/BGM

maoudamashii.jokersounds.com

んで、ゲームエフェクトにこだわりたいと思ってたから本を買ってみた。

▼Unityパーティクル参考書

www.amazon.co.jp

まだ読めてないけど、買う前からワクワクしてたのでUnity1週間ゲームジャム終わってからはこれの学習に専念したい。

感想

今週は本当にあっという間だった...。仕事のほうでは別のことしてるからか、時間の流れが本当に早く感じてるけど、今のところ両立させられてるのでかなり充実してきた。
でも燃え尽き症候群が怖いので、まぁマイペースに気長にやっていこうとおもう。

今週もお疲れ様です( ´_ゝ`)

※来週はゲームジャムの進捗次第で更新します。